Files
buzz/mobile/lib/features/channels/message_content/inline_components.dart
Logan Johnson 4ef23609b7 feat(mobile): show contextual names in channel conversations (#7895)
🤖
## Summary

In a channel conversation, two identities with the same display name
looked identical. In the example below, the channel shows "Bad Janet"
twice, for two different identities. A reader cannot tell which one was
added and then removed.

This PR uses the contextual names from the previous PR everywhere a
conversation names someone:

- Message authors, threads, forum posts and replies
- Reactions, the typing indicator, and system rows (joined, added,
removed)
- Inline mentions and the agent activity sheet
- The profile sheet title, which now matches the row that opened it

Names are compared against the channel's members, so a label only gets
longer when a real collision exists in that channel.

The mention picker labels each choice against every candidate, so two
"Honey" choices look different. When you pick one, it still inserts the
identity's own name ("@Honey"), so the message text does not change.

Part 2 of 3: #7894 (resolver) → #7895 (conversations) → #7896 (lists,
Search, Pulse).

### Related issue

Related to #2910.

### Testing

- A widget test checks that the profile sheet names an author the same
way the channel does.
- A unit test checks that two same-name mention choices get different
labels but insert the same wire name.
- `flutter analyze` is clean, and the full `flutter test` suite passes.
- Checked on an Android emulator against the live relay, in an archived
channel with two identities named "Bad Janet".

| Before | After |
| --- | --- |
| <img width="300" alt="Before: the channel shows two different
identities both as Bad Janet"
src="https://github.com/user-attachments/assets/c7f6c345-12d8-4e11-9434-bbd2c63722db"
/> | <img width="300" alt="After: the second identity shows as Bad Janet
· sqxl"
src="https://github.com/user-attachments/assets/51c6b9fc-8900-4200-8ce0-ad1887f41dba"
/> |

---------

Signed-off-by: Logan Johnson <loganj@squareup.com>
Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz>
2026-09-29 15:47:43 -04:00

144 lines
5.0 KiB
Dart

part of '../message_content.dart';
List<MarkdownComponent> _useMessageInlineComponents({
required String content,
required String finalContent,
required Map<String, String> mentionNames,
required Map<String, String> mentionLabels,
required Map<String, Set<String>> bindings,
required Set<String> agentPubkeys,
required Map<String, String> channelNames,
required List<CustomEmoji> customEmoji,
required double emojiSize,
required List<List<String>> tags,
required bool hasMediaReply,
required bool hasMediaMore,
required void Function(String)? onMentionTap,
required void Function(String) onChannelTap,
}) {
// Parsed spans retain these callbacks. Forward to the current handlers without
// invalidating the parser for a new closure from an otherwise unchanged row.
final mentionHandler = useRef(onMentionTap)..value = onMentionTap;
final channelHandler = useRef(onChannelTap)..value = onChannelTap;
final mentionTap = useMemoized(
() =>
(String pubkey) => mentionHandler.value?.call(pubkey),
const [],
);
final channelTap = useMemoized(
() =>
(String channelId) => channelHandler.value(channelId),
const [],
);
final inputs = _InlineComponentInputs(
content: content,
finalContent: finalContent,
mentionNames: mentionNames,
mentionLabels: mentionLabels,
bindings: bindings,
agentPubkeys: agentPubkeys,
channelNames: channelNames,
customEmoji: customEmoji,
emojiSize: emojiSize,
tags: tags,
hasMediaReply: hasMediaReply,
hasMediaMore: hasMediaMore,
hasMentionHandler: onMentionTap != null,
);
// gpt_markdown compares components by identity. Recreating them on every
// parent rebuild reparses unchanged message bodies on the UI isolate.
return useMemoized(
() => [
_MentionMd(
mentionNames: inputs.mentionNames,
mentionLabels: inputs.mentionLabels,
bindings: inputs.bindings,
displayLabels: {
for (final range in mentionOccurrences(content, bindings.keys))
range.label: content.substring(range.start + 1, range.end),
},
agentMentionPubkeys: inputs.agentPubkeys,
onMentionTap: inputs.hasMentionHandler ? mentionTap : null,
),
CustomEmojiMd(inputs.customEmoji, content: finalContent, size: emojiSize),
_ChannelLinkMd(
channelNames: inputs.channelNames,
onChannelTap: channelTap,
),
...MarkdownComponent.inlineComponents,
],
[inputs],
);
}
class _InlineComponentInputs {
final String content;
final String finalContent;
final Map<String, String> mentionNames;
final Map<String, String> mentionLabels;
final Map<String, Set<String>> bindings;
final Set<String> agentPubkeys;
final Map<String, String> channelNames;
final List<CustomEmoji> customEmoji;
final double emojiSize;
final bool hasMentionHandler;
final List<List<String>> tags;
final bool hasMediaReply;
final bool hasMediaMore;
_InlineComponentInputs({
required this.content,
required this.finalContent,
required Map<String, String> mentionNames,
required Map<String, String> mentionLabels,
required Map<String, Set<String>> bindings,
required Set<String> agentPubkeys,
required Map<String, String> channelNames,
required List<CustomEmoji> customEmoji,
required this.emojiSize,
required this.hasMentionHandler,
required List<List<String>> tags,
required this.hasMediaReply,
required this.hasMediaMore,
}) : tags = List.unmodifiable(
tags.map((tag) => List<String>.unmodifiable(tag)),
),
mentionNames = Map.unmodifiable(mentionNames),
mentionLabels = Map.unmodifiable(mentionLabels),
bindings = Map.unmodifiable({
for (final entry in bindings.entries)
entry.key: Set<String>.unmodifiable(entry.value),
}),
agentPubkeys = Set.unmodifiable(agentPubkeys),
channelNames = Map.unmodifiable(channelNames),
customEmoji = List.unmodifiable(customEmoji);
@override
bool operator ==(Object other) =>
other is _InlineComponentInputs &&
content == other.content &&
finalContent == other.finalContent &&
emojiSize == other.emojiSize &&
hasMentionHandler == other.hasMentionHandler &&
hasMediaReply == other.hasMediaReply &&
hasMediaMore == other.hasMediaMore &&
tags.length == other.tags.length &&
tags.indexed.every(
(entry) => listEquals(entry.$2, other.tags[entry.$1]),
) &&
mapEquals(mentionNames, other.mentionNames) &&
mapEquals(mentionLabels, other.mentionLabels) &&
mapEquals(channelNames, other.channelNames) &&
setEquals(agentPubkeys, other.agentPubkeys) &&
listEquals(customEmoji, other.customEmoji) &&
bindings.length == other.bindings.length &&
bindings.entries.every(
(entry) => setEquals(entry.value, other.bindings[entry.key]),
);
@override
int get hashCode =>
Object.hash(content, finalContent, emojiSize, hasMentionHandler);
}