From 7a1910e700c08358292ec82fdc93464be8f00274 Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Wed, 30 Sep 2026 10:46:14 -0400 Subject: [PATCH] feat(mobile): show contextual names in lists, Search and Pulse (#7896) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 ## Summary This PR finishes the change on the remaining mobile screens. After it, every place that names a person or agent uses a contextual name: - The channel list, DM labels, and the new-DM picker - The channel header and details, and the members and add-members sheets - Huddle avatars, spotlight, and overlay - The Activity inbox - Search results for people and messages - Pulse notes, agent cards, and reply previews Screens that belong to a channel compare names against its members. Search, Pulse, and the new-DM picker have no channel, so they compare the identities shown together. In the example below, Search showed four results all named "murderbot". Now the human keeps the plain name, and baxen's three agents are labeled by owner and key suffix. Part 3 of 3: #7894 (resolver) → #7895 (conversations) → #7896 (lists, Search, Pulse). ### Related issue Related to #2910. The desktop search change in #6483 addresses the same problem in a different app. ### Testing - Widget tests cover the Activity inbox, Search, and Pulse notes with colliding names. - `flutter analyze` is clean, and the full `flutter test` suite passes. - Checked on an Android emulator against the live relay by searching for "murderbot". The after image also shows "classy-murderbot", a new account that was created between the two captures. | Before | After | | --- | --- | | Before: Search shows four people all named
murderbot | After: three results show as baxen's
murderbot with a key suffix, and the human shows as murderbot | --------- Signed-off-by: Logan Johnson Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz> --- .../lib/features/activity/activity_page.dart | 2 + .../activity/activity_page/inbox_row.dart | 22 ++++- .../activity/activity_page/lists.dart | 11 ++- .../features/channels/add_members_sheet.dart | 33 +++++++- .../channels/channel_actions_sheet.dart | 1 + .../channels/channel_detail_page.dart | 2 + .../channels/channel_detail_page/app_bar.dart | 11 ++- .../huddle_call_avatar.dart | 7 ++ .../huddle_call_participants.dart | 5 ++ .../huddle_participant_cluster.dart | 8 ++ .../huddle_participant_overlay.dart | 7 ++ .../channel_detail_page/huddle_sheet.dart | 29 +++++++ .../channels/channel_details_page.dart | 15 +++- .../lib/features/channels/channels_page.dart | 1 + .../features/channels/channels_page/body.dart | 21 ++++- .../channels/channels_page/channel_tile.dart | 11 ++- .../channels/channels_page/sheets.dart | 20 ++++- .../features/channels/dm_channel_labels.dart | 35 +++++++- .../lib/features/channels/members_sheet.dart | 16 ++-- .../features/pulse/agent_activity_card.dart | 9 ++- .../lib/features/pulse/compose_note_page.dart | 26 ++++-- mobile/lib/features/pulse/note_card.dart | 55 ++++++++++--- mobile/lib/features/pulse/pulse_page.dart | 13 +++ mobile/lib/features/search/search_page.dart | 33 +++++++- .../shared/identity_names/identity_names.dart | 17 ++-- .../identity_names_provider.dart | 16 +++- .../features/activity/activity_page_test.dart | 46 +++++++++++ .../add_members_sheet_names_test.dart | 73 +++++++++++++++++ .../pulse/compose_note_page_test.dart | 62 ++++++++++++++ .../test/features/pulse/note_card_test.dart | 49 +++++++++++ .../test/features/pulse/pulse_page_test.dart | 81 +++++++++++++++++++ .../features/search/search_page_test.dart | 38 +++++++++ 32 files changed, 715 insertions(+), 60 deletions(-) create mode 100644 mobile/test/features/channels/add_members_sheet_names_test.dart create mode 100644 mobile/test/features/pulse/pulse_page_test.dart diff --git a/mobile/lib/features/activity/activity_page.dart b/mobile/lib/features/activity/activity_page.dart index b4dc54fd1e..8185fd70a9 100644 --- a/mobile/lib/features/activity/activity_page.dart +++ b/mobile/lib/features/activity/activity_page.dart @@ -7,6 +7,7 @@ import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; +import '../../shared/identity_names/identity_names_provider.dart'; import '../../shared/mentions/agent_identity_provider.dart'; import '../../shared/mentions/mention_tags.dart'; import '../../shared/relay/relay.dart'; @@ -22,6 +23,7 @@ import '../../shared/widgets/message_author_meta.dart'; import '../../shared/widgets/modal_presentation.dart'; import '../channels/channel.dart'; import '../channels/channel_detail_page.dart'; +import '../channels/channel_identity_names_provider.dart'; import '../channels/channel_management_provider.dart'; import '../channels/channels_provider.dart'; import '../channels/dm_channel_labels.dart'; diff --git a/mobile/lib/features/activity/activity_page/inbox_row.dart b/mobile/lib/features/activity/activity_page/inbox_row.dart index cb19e65b07..147d143c4c 100644 --- a/mobile/lib/features/activity/activity_page/inbox_row.dart +++ b/mobile/lib/features/activity/activity_page/inbox_row.dart @@ -82,7 +82,26 @@ class _InboxRow extends HookConsumerWidget { final profile = profiles[senderPubkey]; // The shared label contract: blank cached names (empty or whitespace-only // are relay-valid) fall back to the compact npub, never a blank sender. - final senderLabel = profile?.label ?? shortPubkey(item.item.pubkey); + // Rows compare names within their own channel, like the channel itself. + final channelId = channel?.id ?? item.item.channelId; + final Map contextualLabels; + if (channelId == null) { + // No channel: the row's own identities are the comparison context. + final names = watchIdentityNames(ref, relevantPubkeys); + contextualLabels = { + for (final key in names.candidates) key: names.labelFor(key), + }; + } else { + contextualLabels = watchChannelIdentityLabels( + ref, + channelId, + relevantPubkeys, + ); + } + final senderLabel = + contextualLabels[senderPubkey] ?? + profile?.label ?? + shortPubkey(item.item.pubkey); final profileMentionNames = { for (final pubkey in mentionPubkeys) if (profiles[pubkey]?.displayName?.trim().isNotEmpty == true) @@ -325,6 +344,7 @@ class _InboxRow extends HookConsumerWidget { MessageContent( content: item.item.displayContent, mentionNames: mentionNames, + mentionLabels: contextualLabels, agentMentionPubkeys: agentMentionPubkeys, tags: item.item.tags, maxLines: 2, diff --git a/mobile/lib/features/activity/activity_page/lists.dart b/mobile/lib/features/activity/activity_page/lists.dart index ad97049272..f867cc64ab 100644 --- a/mobile/lib/features/activity/activity_page/lists.dart +++ b/mobile/lib/features/activity/activity_page/lists.dart @@ -77,7 +77,7 @@ class _RemindersList extends ConsumerWidget { /// Drafts surface for the Drafts filter — locally saved unsent composer /// text that reopens the target composer. -class _DraftsList extends StatelessWidget { +class _DraftsList extends ConsumerWidget { final List drafts; final ScrollController scrollController; final Map channelById; @@ -95,7 +95,8 @@ class _DraftsList extends StatelessWidget { }); @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { + final names = ref.watch(identityNameSourcesProvider); if (drafts.isEmpty) { return const _EmptySurface( icon: LucideIcons.filePen, @@ -114,7 +115,11 @@ class _DraftsList extends StatelessWidget { final destination = channel == null ? 'Unavailable channel' : channel.isDm - ? resolveDmChannelDisplayLabel(channel, currentPubkey: myPubkey) + ? resolveDmChannelDisplayLabel( + channel, + currentPubkey: myPubkey, + names: names, + ) : '#${channel.name}'; return ListTile( key: ValueKey('draft-row-${draft.key}'), diff --git a/mobile/lib/features/channels/add_members_sheet.dart b/mobile/lib/features/channels/add_members_sheet.dart index 4041f1efbb..996a1a34d7 100644 --- a/mobile/lib/features/channels/add_members_sheet.dart +++ b/mobile/lib/features/channels/add_members_sheet.dart @@ -8,6 +8,7 @@ import 'package:lucide_icons_flutter/lucide_icons.dart'; import '../../shared/theme/theme.dart'; import '../../shared/widgets/avatar_image.dart'; import '../../shared/widgets/buzz_loading_indicator.dart'; +import '../../shared/identity_names/identity_names_provider.dart'; import 'channel_management_provider.dart'; /// Searchable multi-select used to add people or agents to a channel. @@ -56,6 +57,30 @@ class AddChannelMembersSheet extends HookConsumerWidget { ) .toList() ?? const []; + // Compare every shown choice with the channel's members, so a candidate + // who shares a member's name is told apart before being added. Members + // are only compared: their names come from the roster the details page + // already loaded, so only the shown choices' profiles are fetched. + final choices = [...availableUsers, ...selectedUsers.value]; + final roster = + ref.watch(channelMembersProvider(channelId)).asData?.value ?? + const []; + final names = watchIdentityNames( + ref, + [...normalizedExisting, for (final user in choices) user.pubkey], + agentPubkeys: { + for (final member in roster) + if (member.isBot) member.pubkey, + for (final user in choices) + if (user.isAgent) user.pubkey, + }, + fallbackNames: { + for (final member in roster) member.pubkey: ?member.displayName, + for (final user in choices) user.pubkey: user.label, + }, + shown: [for (final user in choices) user.pubkey], + ); + String labelFor(DirectoryUser user) => names.labelFor(user.pubkey); void toggleUser(DirectoryUser user) { if (isSubmitting.value) return; @@ -145,7 +170,7 @@ class AddChannelMembersSheet extends HookConsumerWidget { key: ValueKey( 'add-channel-member-selected-${user.pubkey}', ), - label: Text(user.label), + label: Text(labelFor(user)), onDeleted: isSubmitting.value ? null : () => toggleUser(user), @@ -181,8 +206,8 @@ class AddChannelMembersSheet extends HookConsumerWidget { button: true, selected: selected, label: selected - ? '${user.label}, selected' - : user.label, + ? '${labelFor(user)}, selected' + : labelFor(user), child: ListTile( contentPadding: EdgeInsets.zero, leading: AvatarImage( @@ -194,7 +219,7 @@ class AddChannelMembersSheet extends HookConsumerWidget { isAgent: user.isAgent, ), title: Text( - user.label, + labelFor(user), maxLines: 1, overflow: TextOverflow.ellipsis, ), diff --git a/mobile/lib/features/channels/channel_actions_sheet.dart b/mobile/lib/features/channels/channel_actions_sheet.dart index 0df6a03863..1c9a3f0926 100644 --- a/mobile/lib/features/channels/channel_actions_sheet.dart +++ b/mobile/lib/features/channels/channel_actions_sheet.dart @@ -22,6 +22,7 @@ import '../../shared/widgets/modal_presentation.dart'; import '../../shared/widgets/sheet_divider.dart'; import 'channel.dart'; import 'add_members_sheet.dart'; +import 'channel_identity_names_provider.dart'; import 'channel_management_provider.dart'; import 'channel_mutes/channel_mutes_provider.dart'; import 'channel_sections/channel_sections_provider.dart'; diff --git a/mobile/lib/features/channels/channel_detail_page.dart b/mobile/lib/features/channels/channel_detail_page.dart index 92b2ce578b..f535d3ed2b 100644 --- a/mobile/lib/features/channels/channel_detail_page.dart +++ b/mobile/lib/features/channels/channel_detail_page.dart @@ -14,6 +14,8 @@ import 'package:scrollable_positioned_list/scrollable_positioned_list.dart'; import '../../shared/animated_avatar.dart'; import '../../shared/emoji/emoji_burst.dart'; import '../../shared/huddle/huddle.dart'; +import '../../shared/identity_names/identity_names.dart'; +import '../../shared/identity_names/identity_names_provider.dart'; import '../../shared/mentions/agent_identity_provider.dart'; import '../../shared/relay/relay.dart'; import '../../shared/theme/theme.dart'; diff --git a/mobile/lib/features/channels/channel_detail_page/app_bar.dart b/mobile/lib/features/channels/channel_detail_page/app_bar.dart index 2914184729..6168d89c09 100644 --- a/mobile/lib/features/channels/channel_detail_page/app_bar.dart +++ b/mobile/lib/features/channels/channel_detail_page/app_bar.dart @@ -306,9 +306,14 @@ class _DmAppBarTitle extends ConsumerWidget { children: [ Flexible( child: Text( - resolveDmChannelDisplayLabel( - channel, - currentPubkey: currentPubkey, + ref.watch( + identityNameSourcesProvider.select( + (names) => resolveDmChannelDisplayLabel( + channel, + currentPubkey: currentPubkey, + names: names, + ), + ), ), maxLines: 1, overflow: TextOverflow.ellipsis, diff --git a/mobile/lib/features/channels/channel_detail_page/huddle_call_avatar.dart b/mobile/lib/features/channels/channel_detail_page/huddle_call_avatar.dart index 58127f9011..eca5f97865 100644 --- a/mobile/lib/features/channels/channel_detail_page/huddle_call_avatar.dart +++ b/mobile/lib/features/channels/channel_detail_page/huddle_call_avatar.dart @@ -11,6 +11,7 @@ class _HuddleCallAvatar extends HookConsumerWidget { required this.speakerLevel, required this.preparingResponse, required this.onTap, + this.contextualLabel, this.isSelf = false, this.frameSize = _huddleAvatarFrameSize, }); @@ -18,6 +19,9 @@ class _HuddleCallAvatar extends HookConsumerWidget { final String pubkey; final UserProfile? profile; final String? fallbackLabel; + + /// The huddle-scoped identity label, when resolved. + final String? contextualLabel; final bool active; final double speakerLevel; final bool preparingResponse; @@ -122,6 +126,7 @@ class _HuddleCallAvatar extends HookConsumerWidget { pubkey: pubkey, profile: profile, fallbackLabel: fallbackLabel, + contextualLabel: contextualLabel, isSelf: isSelf, ); final isAgent = profile?.isAgent == true || fallbackLabel != null; @@ -265,8 +270,10 @@ String _huddleParticipantLabel({ required UserProfile? profile, required String? fallbackLabel, required bool isSelf, + String? contextualLabel, }) { if (isSelf) return 'You'; + if (contextualLabel != null) return contextualLabel; final profileName = profile?.displayName?.trim(); final directoryName = fallbackLabel?.trim(); return (profileName?.isNotEmpty == true ? profileName : null) ?? diff --git a/mobile/lib/features/channels/channel_detail_page/huddle_call_participants.dart b/mobile/lib/features/channels/channel_detail_page/huddle_call_participants.dart index 5e0d7922d0..a4d2ac02f4 100644 --- a/mobile/lib/features/channels/channel_detail_page/huddle_call_participants.dart +++ b/mobile/lib/features/channels/channel_detail_page/huddle_call_participants.dart @@ -6,6 +6,7 @@ class _HuddleCallParticipants extends StatelessWidget { required this.error, required this.profiles, required this.fallbackLabels, + required this.contextualLabels, required this.remotePubkeys, required this.localPubkey, required this.activeSpeakerPubkeys, @@ -22,6 +23,9 @@ class _HuddleCallParticipants extends StatelessWidget { final String? error; final Map profiles; final Map fallbackLabels; + + /// Huddle-scoped identity labels, keyed by lowercase pubkey. + final Map contextualLabels; final List remotePubkeys; final String? localPubkey; final Set activeSpeakerPubkeys; @@ -146,6 +150,7 @@ class _HuddleCallParticipants extends StatelessWidget { pubkeys: remotePubkeys, profiles: profiles, fallbackLabels: fallbackLabels, + contextualLabels: contextualLabels, activeSpeakerPubkeys: activeSpeakerPubkeys, speakerLevels: speakerLevels, workingAgentPubkeys: workingAgentPubkeys, diff --git a/mobile/lib/features/channels/channel_detail_page/huddle_participant_cluster.dart b/mobile/lib/features/channels/channel_detail_page/huddle_participant_cluster.dart index d966d26058..fbbe42cb38 100644 --- a/mobile/lib/features/channels/channel_detail_page/huddle_participant_cluster.dart +++ b/mobile/lib/features/channels/channel_detail_page/huddle_participant_cluster.dart @@ -38,6 +38,7 @@ class _HuddleParticipantCluster extends HookWidget { required this.pubkeys, required this.profiles, required this.fallbackLabels, + required this.contextualLabels, required this.activeSpeakerPubkeys, required this.speakerLevels, required this.workingAgentPubkeys, @@ -51,6 +52,9 @@ class _HuddleParticipantCluster extends HookWidget { final List pubkeys; final Map profiles; final Map fallbackLabels; + + /// Huddle-scoped identity labels, keyed by lowercase pubkey. + final Map contextualLabels; final Set activeSpeakerPubkeys; final Map speakerLevels; final Set workingAgentPubkeys; @@ -139,6 +143,7 @@ class _HuddleParticipantCluster extends HookWidget { pubkey: pubkey, profile: profiles[pubkey], fallbackLabel: fallbackLabels[pubkey], + contextualLabel: contextualLabels[pubkey], active: activeSpeakerPubkeys.contains(pubkey), speakerLevel: speakerLevels[pubkey] ?? 0, preparingResponse: workingAgentPubkeys.contains(pubkey), @@ -174,6 +179,7 @@ class _HuddleAnimatedParticipant extends StatelessWidget { required this.pubkey, required this.profile, required this.fallbackLabel, + required this.contextualLabel, required this.active, required this.speakerLevel, required this.preparingResponse, @@ -188,6 +194,7 @@ class _HuddleAnimatedParticipant extends StatelessWidget { final String pubkey; final UserProfile? profile; final String? fallbackLabel; + final String? contextualLabel; final bool active; final double speakerLevel; final bool preparingResponse; @@ -240,6 +247,7 @@ class _HuddleAnimatedParticipant extends StatelessWidget { pubkey: pubkey, profile: profile, fallbackLabel: fallbackLabel, + contextualLabel: contextualLabel, active: active, speakerLevel: speakerLevel, preparingResponse: preparingResponse, diff --git a/mobile/lib/features/channels/channel_detail_page/huddle_participant_overlay.dart b/mobile/lib/features/channels/channel_detail_page/huddle_participant_overlay.dart index 48f55bfd87..c9f13b158e 100644 --- a/mobile/lib/features/channels/channel_detail_page/huddle_participant_overlay.dart +++ b/mobile/lib/features/channels/channel_detail_page/huddle_participant_overlay.dart @@ -168,6 +168,11 @@ class _HuddleParticipantSpotlight extends ConsumerWidget { pubkey: pubkey, profile: profile, fallbackLabel: fallbackLabel, + contextualLabel: ref.watch( + _huddleIdentityNamesProvider( + ephemeralChannelId, + ).select((names) => names.labelFor(pubkey)), + ), isSelf: isSelf, ); final isAgent = profile?.isAgent == true || fallbackLabel != null; @@ -269,6 +274,7 @@ class _HuddleParticipantRoster extends ConsumerWidget { } final profiles = ref.watch(userCacheProvider); final fallbackLabels = ref.watch(agentDirectoryDisplayNamesProvider); + final names = ref.watch(_huddleIdentityNamesProvider(ephemeralChannelId)); final activeSpeakerPubkeys = ref.watch( huddleSessionProvider.select((session) => session.activeSpeakerPubkeys), ); @@ -333,6 +339,7 @@ class _HuddleParticipantRoster extends ConsumerWidget { pubkey: pubkey, profile: profile, fallbackLabel: fallbackLabels[pubkey], + contextualLabel: names.labelFor(pubkey), isSelf: false, ); return Semantics( diff --git a/mobile/lib/features/channels/channel_detail_page/huddle_sheet.dart b/mobile/lib/features/channels/channel_detail_page/huddle_sheet.dart index 5b0abb5766..06312929d2 100644 --- a/mobile/lib/features/channels/channel_detail_page/huddle_sheet.dart +++ b/mobile/lib/features/channels/channel_detail_page/huddle_sheet.dart @@ -41,6 +41,28 @@ final _huddleLogicalParticipantPubkeysProvider = Provider.autoDispose ); }); +/// Contextual identity labels for one Huddle: its logical participants are +/// the comparison context, and its bot members are agents. +final _huddleIdentityNamesProvider = Provider.autoDispose + .family((ref, channelId) { + final sources = ref.watch(identityNameSourcesProvider); + final members = + ref.watch(channelMembersProvider(channelId)).value ?? + const []; + final names = sources.scope( + ref.watch(_huddleLogicalParticipantPubkeysProvider(channelId)), + agentPubkeys: { + for (final member in members) + if (member.isBot) member.pubkey, + }, + fallbackNames: { + for (final member in members) member.pubkey: ?member.displayName, + }, + ); + loadIdentityNameOwners(ref, names); + return names; + }); + class _HuddleParticipantProfileUpdates extends Notifier { _HuddleParticipantProfileUpdates(this.channelId); @@ -573,6 +595,12 @@ class _MobileHuddleCallPage extends ConsumerWidget { ); final profiles = ref.watch(userCacheProvider); final directoryDisplayNames = ref.watch(agentDirectoryDisplayNamesProvider); + final huddleNames = ref.watch( + _huddleIdentityNamesProvider(invite.ephemeralChannelId), + ); + final huddleLabels = { + for (final pubkey in remotePubkeys) pubkey: huddleNames.labelFor(pubkey), + }; final huddleTypingEntries = ref.watch( channelTypingProvider(invite.ephemeralChannelId), ); @@ -679,6 +707,7 @@ class _MobileHuddleCallPage extends ConsumerWidget { : null, profiles: profiles, fallbackLabels: directoryDisplayNames, + contextualLabels: huddleLabels, remotePubkeys: remotePubkeys, localPubkey: localPubkey, activeSpeakerPubkeys: session.activeSpeakerPubkeys, diff --git a/mobile/lib/features/channels/channel_details_page.dart b/mobile/lib/features/channels/channel_details_page.dart index a626f0dc4f..bd84d2d8d6 100644 --- a/mobile/lib/features/channels/channel_details_page.dart +++ b/mobile/lib/features/channels/channel_details_page.dart @@ -110,6 +110,11 @@ class ChannelDetailsPage extends HookConsumerWidget { '$memberCount ${memberCount == 1 ? 'member' : 'members'}'; final previewMembers = members.take(_channelMemberPreviewLimit).toList(); final userCache = ref.watch(userCacheProvider); + final memberLabels = watchChannelIdentityLabels( + ref, + resolvedChannel.id, + previewMembers.map((member) => member.pubkey), + ); final currentSectionId = sectionState.isReady ? sectionState.store.assignments[resolvedChannel.id] : sectionId; @@ -410,6 +415,7 @@ class ChannelDetailsPage extends HookConsumerWidget { onMemberTap: onMemberTap, displayName: userCache[member.pubkey.toLowerCase()]?.displayName, + contextualLabel: memberLabels[member.pubkey.toLowerCase()], avatarUrl: userCache[member.pubkey.toLowerCase()]?.avatarUrl, ), @@ -643,6 +649,7 @@ class _ChannelMemberPreviewRow extends StatelessWidget { required this.currentPubkey, required this.onMemberTap, required this.displayName, + required this.contextualLabel, required this.avatarUrl, }); @@ -650,22 +657,26 @@ class _ChannelMemberPreviewRow extends StatelessWidget { final String? currentPubkey; final void Function(BuildContext context, String pubkey) onMemberTap; final String? displayName; + + /// The channel's contextual label for this member, when resolved. + final String? contextualLabel; final String? avatarUrl; @override Widget build(BuildContext context) { final isSelf = member.pubkey.toLowerCase() == currentPubkey?.toLowerCase(); final hasName = displayName?.trim().isNotEmpty == true; - final label = isSelf + final name = isSelf ? 'You' : hasName ? displayName!.trim() : member.labelFor(currentPubkey); + final label = isSelf ? name : contextualLabel ?? name; // Self/named initials come from the visible label; unnamed members stay // keyed to the hex public key so the compact-npub label doesn't render // `N` for everyone. final initial = isSelf || hasName - ? label[0].toUpperCase() + ? name[0].toUpperCase() : (member.pubkey.isNotEmpty ? member.pubkey[0].toUpperCase() : '?'); final roleLabel = _channelMemberRoleLabel(member.role); final titleStyle = context.textTheme.bodyLarge; diff --git a/mobile/lib/features/channels/channels_page.dart b/mobile/lib/features/channels/channels_page.dart index 55fec3f08e..5bee5e5bdc 100644 --- a/mobile/lib/features/channels/channels_page.dart +++ b/mobile/lib/features/channels/channels_page.dart @@ -30,6 +30,7 @@ import '../../shared/custom_emoji/custom_emoji_render.dart'; import '../profile/profile_avatar.dart'; import '../profile/profile_provider.dart'; import '../profile/presence_cache_provider.dart'; +import '../../shared/identity_names/identity_names_provider.dart'; import '../../shared/profile/user_cache_provider.dart'; import '../pairing/pairing_page.dart'; import '../pairing/pairing_provider.dart'; diff --git a/mobile/lib/features/channels/channels_page/body.dart b/mobile/lib/features/channels/channels_page/body.dart index 34faa068eb..33364e2b21 100644 --- a/mobile/lib/features/channels/channels_page/body.dart +++ b/mobile/lib/features/channels/channels_page/body.dart @@ -119,10 +119,25 @@ class _SliverChannelsList extends HookConsumerWidget { final streamChannels = visibleChannels .where((channel) => channel.isStream) .toList(); - final dmChannels = sortDmChannelsByDisplayLabel( - visibleChannels.where((channel) => channel.isDm), - currentPubkey: currentPubkey, + final unsortedDms = visibleChannels.where((channel) => channel.isDm); + // Rebuild only when the DM order changes, not for every profile fetch. + final dmOrder = ref.watch( + identityNameSourcesProvider.select( + (names) => [ + for (final channel in sortDmChannelsByDisplayLabel( + unsortedDms, + currentPubkey: currentPubkey, + names: names, + )) + channel.id, + ].join('\u0000'), + ), ); + final dmRank = { + for (final (index, id) in dmOrder.split('\u0000').indexed) id: index, + }; + final dmChannels = unsortedDms.toList() + ..sort((a, b) => dmRank[a.id]!.compareTo(dmRank[b.id]!)); final starredExpanded = useState(true); final channelsExpanded = useState(true); diff --git a/mobile/lib/features/channels/channels_page/channel_tile.dart b/mobile/lib/features/channels/channels_page/channel_tile.dart index e060e9e9ac..cbafb0db0e 100644 --- a/mobile/lib/features/channels/channels_page/channel_tile.dart +++ b/mobile/lib/features/channels/channels_page/channel_tile.dart @@ -65,9 +65,14 @@ class _ChannelTile extends ConsumerWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - resolveDmChannelDisplayLabel( - channel, - currentPubkey: currentPubkey, + ref.watch( + identityNameSourcesProvider.select( + (names) => resolveDmChannelDisplayLabel( + channel, + currentPubkey: currentPubkey, + names: names, + ), + ), ), maxLines: 1, overflow: TextOverflow.ellipsis, diff --git a/mobile/lib/features/channels/channels_page/sheets.dart b/mobile/lib/features/channels/channels_page/sheets.dart index 14dab6497f..e15805d171 100644 --- a/mobile/lib/features/channels/channels_page/sheets.dart +++ b/mobile/lib/features/channels/channels_page/sheets.dart @@ -486,6 +486,17 @@ class _NewDirectMessageSheet extends HookConsumerWidget { normalizedPubkey.contains(normalizedQuery); }).toList() ?? const []; + // Eligible recipients shown or chosen here are the comparison context. + final recipients = [...availableResults, ...selectedUsers.value]; + final names = watchIdentityNames( + ref, + [for (final user in recipients) user.pubkey], + agentPubkeys: { + for (final user in recipients) + if (user.isAgent) user.pubkey, + }, + fallbackNames: {for (final user in recipients) user.pubkey: user.label}, + ); final canSubmit = !isSubmitting.value && selectedUsers.value.isNotEmpty; Future submit() async { @@ -580,6 +591,7 @@ class _NewDirectMessageSheet extends HookConsumerWidget { for (final user in selectedUsers.value) _SelectedDmRecipientChip( user: user, + label: names.labelFor(user.pubkey), enabled: !isSubmitting.value, onDeleted: () { selectedUsers.value = [ @@ -748,7 +760,7 @@ class _NewDirectMessageSheet extends HookConsumerWidget { isAgent: user.isAgent, ), title: Text( - user.label, + names.labelFor(user.pubkey), maxLines: 1, overflow: TextOverflow.ellipsis, ), @@ -799,11 +811,13 @@ class _NewDirectMessageSheet extends HookConsumerWidget { class _SelectedDmRecipientChip extends StatelessWidget { final DirectoryUser user; + final String label; final bool enabled; final VoidCallback onDeleted; const _SelectedDmRecipientChip({ required this.user, + required this.label, required this.enabled, required this.onDeleted, }); @@ -825,7 +839,7 @@ class _SelectedDmRecipientChip extends StatelessWidget { button: true, enabled: enabled, excludeSemantics: true, - label: 'Remove ${user.label}', + label: 'Remove $label', child: InkWell( customBorder: const StadiumBorder(), onTap: enabled ? onDeleted : null, @@ -853,7 +867,7 @@ class _SelectedDmRecipientChip extends StatelessWidget { const SizedBox(width: Grid.xxs), Flexible( child: Text( - user.label, + label, maxLines: 1, overflow: TextOverflow.ellipsis, style: context.textTheme.bodyLarge?.copyWith( diff --git a/mobile/lib/features/channels/dm_channel_labels.dart b/mobile/lib/features/channels/dm_channel_labels.dart index 51e7573058..5e1a35e8ed 100644 --- a/mobile/lib/features/channels/dm_channel_labels.dart +++ b/mobile/lib/features/channels/dm_channel_labels.dart @@ -1,3 +1,4 @@ +import '../../shared/identity_names/identity_names.dart'; import '../../shared/utils/string_utils.dart'; import 'channel.dart'; @@ -22,18 +23,41 @@ String formatDmParticipantDisplayName(List displayNames) { : visible.join(', '); } -String resolveDmChannelDisplayLabel(Channel channel, {String? currentPubkey}) { +/// Contextual participant labels for a DM: its participants are the whole +/// comparison context, and relay participant names are fallbacks. +IdentityNames dmParticipantNames(Channel channel, IdentityNameSources names) => + names.scope( + channel.participantPubkeys, + fallbackNames: { + for (var index = 0; index < channel.participantPubkeys.length; index++) + if (index < channel.participants.length) + channel.participantPubkeys[index]: channel.participants[index], + }, + ); + +/// The DM's display label. With [names], participant labels follow the +/// contextual identity-name policy, so namesakes stay distinguishable. +String resolveDmChannelDisplayLabel( + Channel channel, { + String? currentPubkey, + IdentityNameSources? names, +}) { if (!channel.isDm || !isGenericDmChannelName(channel.name)) { return channel.name; } final normalizedCurrent = currentPubkey?.toLowerCase(); + final participantNames = names == null + ? null + : dmParticipantNames(channel, names); final participants = <({String label, String? pubkey})>[ for (var index = 0; index < channel.participantPubkeys.length; index++) ( - label: index < channel.participants.length - ? channel.participants[index] - : shortPubkey(channel.participantPubkeys[index]), + label: + participantNames?.labelFor(channel.participantPubkeys[index]) ?? + (index < channel.participants.length + ? channel.participants[index] + : shortPubkey(channel.participantPubkeys[index])), pubkey: channel.participantPubkeys[index].toLowerCase(), ), ]; @@ -107,16 +131,19 @@ String dmAvatarInitial(Channel channel, {String? currentPubkey}) { List sortDmChannelsByDisplayLabel( Iterable channels, { String? currentPubkey, + IdentityNameSources? names, }) { final sorted = channels.toList(); sorted.sort((left, right) { final leftLabel = resolveDmChannelDisplayLabel( left, currentPubkey: currentPubkey, + names: names, ); final rightLabel = resolveDmChannelDisplayLabel( right, currentPubkey: currentPubkey, + names: names, ); final labelCompare = leftLabel.toLowerCase().compareTo( rightLabel.toLowerCase(), diff --git a/mobile/lib/features/channels/members_sheet.dart b/mobile/lib/features/channels/members_sheet.dart index ba720a408e..8b14f411b4 100644 --- a/mobile/lib/features/channels/members_sheet.dart +++ b/mobile/lib/features/channels/members_sheet.dart @@ -14,6 +14,7 @@ import '../profile/user_status_cache_provider.dart'; import 'agent_activity/agent_activity_sheet.dart'; import 'agent_activity/working_bots_provider.dart'; import 'channel.dart'; +import 'channel_identity_names_provider.dart'; import 'channel_management_provider.dart'; class MembersSheet extends HookConsumerWidget { @@ -216,16 +217,17 @@ class _MemberTile extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final label = isSelf + final name = isSelf ? 'You' : (profile?.displayName?.trim().isNotEmpty == true ? profile!.displayName!.trim() : member.labelFor(currentPubkey)); + final label = _watchContextualLabel(ref); // Named members initial from their name; unnamed ones stay keyed to the // hex public key so the compact-npub label doesn't render `N` for all. final hasName = profile?.displayName?.trim().isNotEmpty == true; final initial = isSelf || hasName - ? label[0].toUpperCase() + ? name[0].toUpperCase() : (member.pubkey.isNotEmpty ? member.pubkey[0].toUpperCase() : '?'); final showManagementActions = canManage && !isSelf && !member.isOwner; final showMenu = showManagementActions || onViewActivity != null; @@ -287,6 +289,10 @@ class _MemberTile extends ConsumerWidget { ); } + /// 'You' for the viewer; otherwise the channel's contextual label. + String _watchContextualLabel(WidgetRef ref) => + isSelf ? 'You' : watchChannelIdentityLabel(ref, channelId, member.pubkey); + void _showMemberActions( BuildContext context, WidgetRef ref, { @@ -294,9 +300,9 @@ class _MemberTile extends ConsumerWidget { }) { final label = isSelf ? 'You' - : (profile?.displayName?.trim().isNotEmpty == true - ? profile!.displayName!.trim() - : member.labelFor(currentPubkey)); + : ref + .read(channelIdentityNamesProvider(channelId)) + .labelFor(member.pubkey); final canChangeRole = showManagementActions && !member.isBot; showBuzzModalBottomSheet( context: context, diff --git a/mobile/lib/features/pulse/agent_activity_card.dart b/mobile/lib/features/pulse/agent_activity_card.dart index d5af2b3046..6c8c4c5f30 100644 --- a/mobile/lib/features/pulse/agent_activity_card.dart +++ b/mobile/lib/features/pulse/agent_activity_card.dart @@ -3,10 +3,10 @@ import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; +import '../../shared/identity_names/identity_names.dart'; import '../../shared/theme/theme.dart'; import '../../shared/widgets/avatar_image.dart'; import '../../shared/profile/user_cache_provider.dart'; -import '../../shared/utils/string_utils.dart'; import 'note_card.dart'; import 'pulse_models.dart'; @@ -19,16 +19,20 @@ class AgentActivityCard extends HookConsumerWidget { super.key, required this.group, required this.reactions, + required this.names, this.onReactionChanged, }); + /// The Pulse timeline's identity labels. + final IdentityNames names; + @override Widget build(BuildContext context, WidgetRef ref) { final expanded = useState(group.notes.length == 1); final profile = ref.watch(userCacheProvider.select((cache) => cache[group.pubkey])) ?? ref.read(userCacheProvider.notifier).get(group.pubkey); - final name = profile?.label ?? shortPubkey(group.pubkey); + final name = names.labelFor(group.pubkey); return Column( children: [ @@ -143,6 +147,7 @@ class AgentActivityCard extends HookConsumerWidget { reactedByCurrentUser: false, ), isAgent: true, + names: names, onReactionChanged: onReactionChanged, ), if (note != group.notes.last) diff --git a/mobile/lib/features/pulse/compose_note_page.dart b/mobile/lib/features/pulse/compose_note_page.dart index 9564b10cfa..e456a5de3a 100644 --- a/mobile/lib/features/pulse/compose_note_page.dart +++ b/mobile/lib/features/pulse/compose_note_page.dart @@ -2,6 +2,9 @@ import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import '../../shared/identity_names/identity_names.dart'; +import '../../shared/identity_names/identity_names_provider.dart'; +import '../../shared/mentions/mention_tags.dart'; import '../../shared/theme/theme.dart'; import '../../shared/widgets/avatar_image.dart'; import '../../shared/widgets/buzz_loading_indicator.dart'; @@ -10,7 +13,6 @@ import '../../shared/widgets/frosted_scaffold.dart'; import '../channels/message_content.dart'; import '../profile/profile_provider.dart'; import '../../shared/profile/user_cache_provider.dart'; -import '../../shared/utils/string_utils.dart'; import 'note_card.dart'; import 'pulse_actions.dart'; import 'pulse_models.dart'; @@ -24,7 +26,12 @@ import 'pulse_models.dart'; class ComposeNotePage extends HookConsumerWidget { final UserNote? replyTo; - const ComposeNotePage({super.key, this.replyTo}); + /// The comparison context of the surface that opened the reply, so the + /// reply context names people as they were shown there. Its labels are + /// resolved against live naming facts while this page is open. + final IdentityNames? names; + + const ComposeNotePage({super.key, this.replyTo, this.names}); bool get _isReply => replyTo != null; @@ -99,7 +106,7 @@ class ComposeNotePage extends HookConsumerWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ SizedBox(height: frostedAppBarHeight(context)), - if (_isReply) _ReplyContext(note: replyTo!), + if (_isReply) _ReplyContext(note: replyTo!, names: names), Expanded( child: SingleChildScrollView( padding: const EdgeInsets.all(Grid.xs), @@ -158,8 +165,9 @@ class ComposeNotePage extends HookConsumerWidget { /// height so a long note doesn't push the editor off-screen. class _ReplyContext extends ConsumerWidget { final UserNote note; + final IdentityNames? names; - const _ReplyContext({required this.note}); + const _ReplyContext({required this.note, this.names}); @override Widget build(BuildContext context, WidgetRef ref) { @@ -167,7 +175,11 @@ class _ReplyContext extends ConsumerWidget { final profile = ref.watch(userCacheProvider.select((cache) => cache[pubkey])) ?? ref.read(userCacheProvider.notifier).get(pubkey); - final displayName = profile?.label ?? shortPubkey(pubkey); + final mentionPubkeys = mentionedPubkeysFromTags(note.tags); + final labels = + names?.withSources(ref.watch(identityNameSourcesProvider)) ?? + watchIdentityNames(ref, {pubkey, ...mentionPubkeys}); + final displayName = labels.labelFor(pubkey); return Padding( padding: const EdgeInsets.fromLTRB(Grid.gutter, Grid.xs, Grid.gutter, 0), @@ -244,6 +256,10 @@ class _ReplyContext extends ConsumerWidget { heightFactor: 1, child: MessageContent( content: note.content, + mentionLabels: { + for (final key in mentionPubkeys) + key: labels.labelFor(key), + }, tags: note.tags, baseStyle: messageBodyTextStyle.copyWith( color: context.colors.onSurface, diff --git a/mobile/lib/features/pulse/note_card.dart b/mobile/lib/features/pulse/note_card.dart index 769240d3da..90d2b35fdd 100644 --- a/mobile/lib/features/pulse/note_card.dart +++ b/mobile/lib/features/pulse/note_card.dart @@ -5,13 +5,15 @@ import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:nostr/nostr.dart' as nostr; import '../../shared/clipboard_utils.dart'; +import '../../shared/identity_names/identity_names.dart'; +import '../../shared/identity_names/identity_names_provider.dart'; +import '../../shared/mentions/mention_tags.dart'; import '../../shared/theme/theme.dart'; import '../../shared/widgets/avatar_image.dart'; import '../channels/channel_detail_page.dart'; import '../channels/channel_management_provider.dart'; import '../channels/message_content.dart'; import '../../shared/profile/user_cache_provider.dart'; -import '../../shared/utils/string_utils.dart'; import '../profile/user_profile_sheet.dart'; import 'compose_note_page.dart'; import 'pulse_actions.dart'; @@ -26,6 +28,10 @@ class NoteCard extends HookConsumerWidget { final VoidCallback? onReactionChanged; final ValueChanged? onFollowChanged; + /// The surrounding collection's identity labels (for example the Pulse + /// timeline's authors). When absent, the note alone is the context. + final IdentityNames? names; + const NoteCard({ super.key, required this.note, @@ -35,6 +41,7 @@ class NoteCard extends HookConsumerWidget { this.canFollow = false, this.onReactionChanged, this.onFollowChanged, + this.names, }); @override @@ -44,7 +51,13 @@ class NoteCard extends HookConsumerWidget { final profile = ref.watch(userCacheProvider.select((cache) => cache[pubkey])) ?? ref.read(userCacheProvider.notifier).get(pubkey); - final displayName = profile?.label ?? shortPubkey(pubkey); + final mentionPubkeys = mentionedPubkeysFromTags(note.tags); + final labels = + names ?? watchIdentityNames(ref, pulseNamedIdentities([note])); + final displayName = labels.labelFor(pubkey); + final mentionLabels = { + for (final key in mentionPubkeys) key: labels.labelFor(key), + }; final effectiveUpvoted = pendingUpvote.value ?? reaction.reactedByCurrentUser; final effectiveCount = _effectiveCount(reaction, pendingUpvote.value); @@ -65,7 +78,11 @@ class NoteCard extends HookConsumerWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ GestureDetector( - onTap: () => showUserProfileSheet(context, note.pubkey), + onTap: () => showUserProfileSheet( + context, + note.pubkey, + names: liveIdentityNamesProvider(labels), + ), child: AvatarImage( imageUrl: profile?.avatarUrl, radius: 18, @@ -92,7 +109,11 @@ class NoteCard extends HookConsumerWidget { children: [ Expanded( child: GestureDetector( - onTap: () => showUserProfileSheet(context, note.pubkey), + onTap: () => showUserProfileSheet( + context, + note.pubkey, + names: liveIdentityNamesProvider(labels), + ), child: Row( children: [ Expanded( @@ -155,7 +176,7 @@ class NoteCard extends HookConsumerWidget { if (note.replyParentId != null) ...[ const SizedBox(height: 2), Text( - 'Replying to ${_replyTargetLabel(note)}', + 'Replying to ${_replyTargetLabel(note, labels)}', style: context.textTheme.labelSmall?.copyWith( color: context.colors.onSurfaceVariant, ), @@ -164,6 +185,7 @@ class NoteCard extends HookConsumerWidget { const SizedBox(height: Grid.half), MessageContent( content: note.content, + mentionLabels: mentionLabels, tags: note.tags, baseStyle: messageBodyTextStyle.copyWith( color: context.colors.onSurface, @@ -199,7 +221,8 @@ class NoteCard extends HookConsumerWidget { icon: LucideIcons.messageCircle, onTap: () => Navigator.of(context).push( MaterialPageRoute( - builder: (_) => ComposeNotePage(replyTo: note), + builder: (_) => + ComposeNotePage(replyTo: note, names: labels), ), ), ), @@ -315,15 +338,25 @@ class _ActionButton extends StatelessWidget { } } +/// Every identity [notes] name on screen: authors, reply-parent authors and +/// mentions. A Pulse collection compares all of them together. +Set pulseNamedIdentities(Iterable notes) => { + for (final note in notes) ...{ + note.pubkey.toLowerCase(), + ?note.replyParentAuthor?.toLowerCase(), + ...mentionedPubkeysFromTags(note.tags), + }, +}; + String _shareUri(UserNote note) => 'nostr:${nostr.Nip19.encodeShareableIdentifiers(prefix: nostr.Nip19Prefix.nevent, data: note.id, author: note.pubkey, kind: 1)}'; -/// Compact label for a note's reply target: the parent author's public key -/// (npub form) when known, otherwise the parent event id. Event ids are not -/// public keys — they stay hex-truncated and out of the npub contract. -String _replyTargetLabel(UserNote note) { +/// Compact label for a note's reply target: the parent author's contextual +/// label when known, otherwise the parent event id. Event ids are not public +/// keys — they stay hex-truncated and out of the npub contract. +String _replyTargetLabel(UserNote note, IdentityNames names) { final author = note.replyParentAuthor; - if (author != null) return shortPubkey(author); + if (author != null) return names.labelFor(author); final eventId = note.replyParentId!; return eventId.length <= 8 ? eventId : '${eventId.substring(0, 8)}…'; } diff --git a/mobile/lib/features/pulse/pulse_page.dart b/mobile/lib/features/pulse/pulse_page.dart index 08568fc8dc..522212aedd 100644 --- a/mobile/lib/features/pulse/pulse_page.dart +++ b/mobile/lib/features/pulse/pulse_page.dart @@ -3,6 +3,7 @@ import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; +import '../../shared/identity_names/identity_names_provider.dart'; import '../../shared/relay/relay.dart'; import '../../shared/theme/theme.dart'; import '../../shared/widgets/filter_chip_bar.dart'; @@ -169,6 +170,16 @@ class _PulseBody extends ConsumerWidget { child: _EmptyState(message: _emptyMessage(tab)), ); } + // Everyone the timeline names — authors, reply targets and mentions + // — is one comparison context, so two same-name targets on + // different notes are still told apart. + final names = watchIdentityNames( + ref, + pulseNamedIdentities(notes), + agentPubkeys: tab == PulseTab.agents + ? {for (final note in notes) note.pubkey.toLowerCase()} + : agentPubkeys, + ); if (tab == PulseTab.agents) { final groups = groupAgentNotes(notes); return ListView.separated( @@ -183,6 +194,7 @@ class _PulseBody extends ConsumerWidget { itemBuilder: (context, index) => AgentActivityCard( group: groups[index], reactions: reactions, + names: names, onReactionChanged: onReactionChanged, ), ); @@ -207,6 +219,7 @@ class _PulseBody extends ConsumerWidget { reactedByCurrentUser: false, ), isAgent: agentPubkeys.contains(note.pubkey), + names: names, isFollowing: contactPubkeys.contains(note.pubkey), canFollow: currentPubkey != null && diff --git a/mobile/lib/features/search/search_page.dart b/mobile/lib/features/search/search_page.dart index b66a72a709..9acc7e117c 100644 --- a/mobile/lib/features/search/search_page.dart +++ b/mobile/lib/features/search/search_page.dart @@ -3,6 +3,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; +import '../../shared/identity_names/identity_names_provider.dart'; import '../../shared/mentions/agent_identity_provider.dart'; import '../../shared/mentions/mention_tags.dart'; import '../../shared/theme/theme.dart'; @@ -15,6 +16,7 @@ import '../../shared/widgets/frosted_scaffold.dart'; import '../../shared/widgets/message_author_meta.dart'; import '../channels/channel.dart'; import '../channels/channel_detail_page.dart'; +import '../channels/channel_identity_names_provider.dart'; import '../channels/channel_management_provider.dart'; import '../channels/channels_provider.dart'; import '../channels/small_avatar.dart'; @@ -716,6 +718,16 @@ class _PeopleSection extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { + // The shown results are the comparison context. + final names = watchIdentityNames( + ref, + [for (final user in users) user.pubkey], + agentPubkeys: { + for (final user in users) + if (user.isAgent) user.pubkey, + }, + fallbackNames: {for (final user in users) user.pubkey: user.label}, + ); return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -732,7 +744,7 @@ class _PeopleSection extends ConsumerWidget { isAgent: user.isAgent, ), title: Text( - user.label, + names.labelFor(user.pubkey), key: ValueKey('search-person-title-${user.pubkey}'), style: contentListTitleTextStyle, ), @@ -824,7 +836,23 @@ class _MessageTile extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final authorName = authorProfile?.label ?? shortPubkey(hit.pubkey); + final mentionedPubkeys = mentionedPubkeysFromTags(hit.tags); + // A hit is labelled within its channel, as it would be there. + final hitChannelId = channel?.id ?? hit.channelId; + final labelPubkeys = {hit.pubkey.toLowerCase(), ...mentionedPubkeys}; + final Map labels; + if (hitChannelId == null) { + final names = watchIdentityNames(ref, labelPubkeys); + labels = { + for (final pubkey in labelPubkeys) pubkey: names.labelFor(pubkey), + }; + } else { + labels = watchChannelIdentityLabels(ref, hitChannelId, labelPubkeys); + } + final authorName = + labels[hit.pubkey.toLowerCase()] ?? + authorProfile?.label ?? + shortPubkey(hit.pubkey); final timeAgo = relativeTime(hit.createdAt); final channelName = hit.channelName?.trim().replaceFirst(RegExp(r'^#'), ''); final hasChannelName = channelName != null && channelName.isNotEmpty; @@ -923,6 +951,7 @@ class _MessageTile extends ConsumerWidget { key: ValueKey('search-message-body-${hit.eventId}'), content: hit.content, mentionNames: mentionNames, + mentionLabels: labels, agentMentionPubkeys: agentMentionPubkeys, tags: hit.tags, maxLines: 2, diff --git a/mobile/lib/shared/identity_names/identity_names.dart b/mobile/lib/shared/identity_names/identity_names.dart index daf0f65a25..ba45a2b434 100644 --- a/mobile/lib/shared/identity_names/identity_names.dart +++ b/mobile/lib/shared/identity_names/identity_names.dart @@ -133,13 +133,16 @@ class IdentityNames { _ownerPubkeys, ); - /// Owner keys of the context whose profile is not cached yet. Callers load - /// them so readable owner prefixes can appear; none are invented meanwhile. - Set missingOwnerProfiles() => _candidates - .map((key) => _factFor(key)?.ownerPubkey) - .nonNulls - .where((owner) => !_sources.profiles.containsKey(owner)) - .toSet(); + /// Owner keys of the context (or of [keys] only) whose profile is not + /// cached yet. Callers load them so readable owner prefixes can appear; + /// none are invented meanwhile. + Set missingOwnerProfiles([Iterable? keys]) => + (keys?.map((key) => key.toLowerCase()) ?? _candidates) + .map(_factFor) + .map((fact) => fact?.ownerPubkey) + .nonNulls + .where((owner) => !_sources.profiles.containsKey(owner)) + .toSet(); NamingIdentity? _factFor(String key) => _sources.factFor( key, diff --git a/mobile/lib/shared/identity_names/identity_names_provider.dart b/mobile/lib/shared/identity_names/identity_names_provider.dart index df7192279b..a43b32e645 100644 --- a/mobile/lib/shared/identity_names/identity_names_provider.dart +++ b/mobile/lib/shared/identity_names/identity_names_provider.dart @@ -39,11 +39,17 @@ void loadIdentityNameOwners(Ref ref, IdentityNames names) { /// Labels for a displayed collection with no channel context, such as search /// results, a Pulse timeline, or a picker's choices. [candidates] is that /// collection; keys outside it resolve against it plus themselves. +/// +/// Uncached profiles are loaded for the identities in [shown] (default: all +/// [candidates]). Pass [shown] when the context also holds identities that are +/// only compared, such as a channel roster whose names another owner loads, +/// so opening the view does not queue a profile read for every one of them. IdentityNames watchIdentityNames( WidgetRef ref, Iterable candidates, { Set agentPubkeys = const {}, Map fallbackNames = const {}, + Iterable? shown, }) { final sources = ref.watch(identityNameSourcesProvider); final names = sources.scope( @@ -54,10 +60,16 @@ IdentityNames watchIdentityNames( // A displayed identity's profile carries its owner hint, so load uncached // candidates as well as their owners; channels load member profiles // elsewhere, but a displayed collection has no other loader. + final loadFor = shown == null + ? names.candidates + : { + for (final key in shown) + if (names.candidates.contains(key.toLowerCase())) key.toLowerCase(), + }; final missing = { - for (final key in names.candidates) + for (final key in loadFor) if (!sources.profiles.containsKey(key)) key, - ...names.missingOwnerProfiles(), + ...names.missingOwnerProfiles(loadFor), }; if (missing.isNotEmpty) { Future.microtask(() { diff --git a/mobile/test/features/activity/activity_page_test.dart b/mobile/test/features/activity/activity_page_test.dart index 9fe4a1e2a9..910300b741 100644 --- a/mobile/test/features/activity/activity_page_test.dart +++ b/mobile/test/features/activity/activity_page_test.dart @@ -9,8 +9,10 @@ import 'package:buzz/features/activity/inbox_item.dart'; import 'package:buzz/features/activity/reminders_provider.dart'; import 'package:buzz/features/channels/channel.dart'; import 'package:buzz/features/channels/channel_detail_page.dart'; +import 'package:buzz/features/channels/channel_management_provider.dart'; import 'package:buzz/features/channels/message_content.dart'; import 'package:buzz/features/channels/channels_provider.dart'; +import 'package:buzz/shared/identity_names/identity_names.dart'; import 'package:buzz/shared/mentions/agent_identity_provider.dart'; import 'package:buzz/shared/read_state/read_state_provider.dart'; import 'package:buzz/shared/profile/user_cache_provider.dart'; @@ -23,6 +25,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:hooks_riverpod/misc.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:shared_preferences/shared_preferences.dart'; @@ -124,6 +127,7 @@ void main() { List drafts = const [], List reminders = const [], Set knownAgentPubkeys = const {}, + List overrides = const [], }) async { SharedPreferences.setMockInitialValues({}); final prefs = await SharedPreferences.getInstance(); @@ -147,6 +151,7 @@ void main() { () => _FakeComposeDraftsNotifier(drafts), ), remindersProvider.overrideWith(() => _FakeRemindersNotifier(reminders)), + ...overrides, ], child: MaterialApp( theme: AppTheme.light(), @@ -577,6 +582,47 @@ void main() { } }); + testWidgets('same-name senders get their channel\'s contextual label', ( + tester, + ) async { + // Another ch1 member shares the sender's name, so the row must use the + // channel's disambiguated label rather than the bare profile name. + const sender = + 'a11ce00000000000000000000000000000000000000000000000000000000000'; + const twin = + 'b0b0000000000000000000000000000000000000000000000000000000000000'; + final members = [ + for (final pubkey in const [sender, twin]) + ChannelMember(pubkey: pubkey, role: 'member', joinedAt: DateTime(2025)), + ]; + final users = { + ...testUsers, + sender: const UserProfile(pubkey: sender, displayName: 'Scout'), + twin: const UserProfile(pubkey: twin, displayName: 'Scout'), + }; + await tester.pumpWidget( + await buildTestable( + users: users, + overrides: [ + channelMembersProvider('ch1').overrideWith((ref) async => members), + ], + ), + ); + await tester.pumpAndSettle(); + + final expected = IdentityNameSources( + profiles: users, + ).scope(const [sender, twin]).labelFor(sender); + expect(expected, isNot('Scout')); + expect( + find.descendant( + of: find.byKey(const ValueKey('inbox-row-m1')), + matching: find.textContaining(expected), + ), + findsWidgets, + ); + }); + testWidgets('directory-known Activity authors use agent avatars', ( tester, ) async { diff --git a/mobile/test/features/channels/add_members_sheet_names_test.dart b/mobile/test/features/channels/add_members_sheet_names_test.dart new file mode 100644 index 0000000000..2b60fee72d --- /dev/null +++ b/mobile/test/features/channels/add_members_sheet_names_test.dart @@ -0,0 +1,73 @@ +import 'package:buzz/features/channels/add_members_sheet.dart'; +import 'package:buzz/features/channels/channel_management_provider.dart'; +import 'package:buzz/shared/profile/user_cache_provider.dart'; +import 'package:buzz/shared/profile/user_profile.dart'; +import 'package:buzz/shared/theme/theme.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +class _RecordingUserCache extends UserCacheNotifier { + final preloaded = {}; + + @override + Map build() => const {}; + + @override + UserProfile? get(String pubkey) => null; + + @override + Future preload(List pubkeys) async { + preloaded.addAll(pubkeys); + return true; + } +} + +void main() { + testWidgets('compares with every member but loads only shown choices', ( + tester, + ) async { + const channelId = 'channel-1'; + // A large roster with no cached profiles; one human member is Scout. + final roster = [ + for (var i = 0; i < 200; i++) + ChannelMember( + pubkey: i.toRadixString(16).padLeft(64, '0'), + role: 'member', + joinedAt: DateTime(2026), + displayName: i == 0 ? 'Scout' : 'Member $i', + ), + ]; + final agent = 'f' * 64; + final cache = _RecordingUserCache(); + + await tester.pumpWidget( + ProviderScope( + overrides: [ + userCacheProvider.overrideWith(() => cache), + channelMembersProvider(channelId).overrideWith((ref) async => roster), + relayDirectoryUsersProvider.overrideWith( + (ref) async => [ + DirectoryUser(pubkey: agent, displayName: 'Scout', isAgent: true), + ], + ), + ], + child: MaterialApp( + theme: AppTheme.light(), + home: Scaffold( + body: AddChannelMembersSheet( + channelId: channelId, + existingPubkeys: {for (final member in roster) member.pubkey}, + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + // The shown agent is told apart from the human member named Scout. + expect(find.text('Scout (agent)'), findsOneWidget); + // Only the shown choice's profile is requested, not the whole roster. + expect(cache.preloaded, {agent}); + }); +} diff --git a/mobile/test/features/pulse/compose_note_page_test.dart b/mobile/test/features/pulse/compose_note_page_test.dart index d0650a5bc5..3370dd5457 100644 --- a/mobile/test/features/pulse/compose_note_page_test.dart +++ b/mobile/test/features/pulse/compose_note_page_test.dart @@ -1,6 +1,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:buzz/shared/identity_names/identity_names.dart'; +import 'package:buzz/shared/identity_names/identity_names_provider.dart'; import 'package:buzz/shared/profile/user_cache_provider.dart'; import 'package:buzz/shared/profile/user_profile.dart'; import 'package:buzz/features/pulse/compose_note_page.dart'; @@ -17,6 +19,27 @@ class _FakeUserCacheNotifier extends UserCacheNotifier { @override UserProfile? get(String pubkey) => _users[pubkey.toLowerCase()]; + + @override + Future preload(List pubkeys) async => true; +} + +class _MutableUserCache extends UserCacheNotifier { + _MutableUserCache(this._initial); + + final Map _initial; + + @override + Map build() => _initial; + + @override + UserProfile? get(String pubkey) => state[pubkey.toLowerCase()]; + + @override + Future preload(List pubkeys) async => true; + + void replace(UserProfile profile) => + state = {...state, profile.pubkey: profile}; } void main() { @@ -74,6 +97,45 @@ void main() { expect(_replyPreviewAvatarInitial(tester, 'Replying to Alice'), 'A'); }); + testWidgets('a supplied context follows profile changes while open', ( + tester, + ) async { + final author = 'a' * 64; + final cache = _MutableUserCache({ + author: UserProfile(pubkey: author, displayName: 'Scout'), + }); + final note = UserNote( + id: 'note-live', + pubkey: author, + createdAt: DateTime.now().millisecondsSinceEpoch ~/ 1000 - 120, + content: 'Original', + tags: const [], + ); + await tester.pumpWidget( + ProviderScope( + overrides: [userCacheProvider.overrideWith(() => cache)], + child: MaterialApp( + theme: AppTheme.light(), + home: Consumer( + builder: (context, ref, _) { + // The opener's context, as NoteCard passes it to the route. + final IdentityNames opened = ref + .read(identityNameSourcesProvider) + .scope([author]); + return ComposeNotePage(replyTo: note, names: opened); + }, + ), + ), + ), + ); + await tester.pump(); + expect(find.text('Replying to Scout'), findsOneWidget); + + cache.replace(UserProfile(pubkey: author, displayName: 'Renamed')); + await tester.pump(); + expect(find.text('Replying to Renamed'), findsOneWidget); + }); + testWidgets('reply preview constrains its timestamp at large text sizes', ( tester, ) async { diff --git a/mobile/test/features/pulse/note_card_test.dart b/mobile/test/features/pulse/note_card_test.dart index baa4fbe2dd..7a9492d4e5 100644 --- a/mobile/test/features/pulse/note_card_test.dart +++ b/mobile/test/features/pulse/note_card_test.dart @@ -1,3 +1,4 @@ +import 'package:buzz/shared/identity_names/identity_names.dart'; import 'package:buzz/shared/profile/user_cache_provider.dart'; import 'package:buzz/shared/profile/user_profile.dart'; import 'package:buzz/features/pulse/note_card.dart'; @@ -18,6 +19,54 @@ class _FakeUserCacheNotifier extends UserCacheNotifier { } void main() { + testWidgets('names the author and reply target within the timeline', ( + tester, + ) async { + final first = 'a' * 64, second = 'b' * 64; + final users = { + first: UserProfile(pubkey: first, displayName: 'Scout'), + second: UserProfile(pubkey: second, displayName: 'Scout'), + }; + final names = IdentityNameSources(profiles: users).scope([first, second]); + expect(names.labelFor(first), isNot('Scout')); + final note = UserNote( + id: 'note-1', + pubkey: first, + createdAt: DateTime.utc(2025, 9, 30, 12).millisecondsSinceEpoch ~/ 1000, + content: 'A reply', + tags: [ + ['e', 'parent-note', '', 'reply'], + ['p', second], + ], + ); + expect(note.replyParentAuthor, second); + + await tester.pumpWidget( + ProviderScope( + overrides: [ + userCacheProvider.overrideWith(() => _FakeUserCacheNotifier(users)), + ], + child: MaterialApp( + theme: AppTheme.light(), + home: Scaffold( + body: NoteCard( + note: note, + reaction: const PulseReactionState( + count: 0, + reactedByCurrentUser: false, + ), + names: names, + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.text(names.labelFor(first)), findsOneWidget); + expect(find.text('Replying to ${names.labelFor(second)}'), findsOneWidget); + }); + testWidgets('constrains timestamp with agent and follow metadata', ( tester, ) async { diff --git a/mobile/test/features/pulse/pulse_page_test.dart b/mobile/test/features/pulse/pulse_page_test.dart new file mode 100644 index 0000000000..00b48d1dc1 --- /dev/null +++ b/mobile/test/features/pulse/pulse_page_test.dart @@ -0,0 +1,81 @@ +import 'package:buzz/features/pulse/pulse_models.dart'; +import 'package:buzz/features/pulse/pulse_page.dart'; +import 'package:buzz/features/pulse/pulse_provider.dart'; +import 'package:buzz/shared/profile/user_cache_provider.dart'; +import 'package:buzz/shared/profile/user_profile.dart'; +import 'package:buzz/shared/relay/relay.dart'; +import 'package:buzz/shared/theme/theme.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +class _UserCache extends UserCacheNotifier { + _UserCache(this._users); + + final Map _users; + + @override + Map build() => _users; + + @override + UserProfile? get(String pubkey) => _users[pubkey.toLowerCase()]; + + @override + Future preload(List pubkeys) async => true; +} + +void main() { + testWidgets('same-name reply targets on different notes are told apart', ( + tester, + ) async { + // Two authors reply to two different people who are not authors on the + // timeline but share the name Scout. + final alice = '1' * 64, bob = '2' * 64; + final scoutA = 'a' * 64, scoutB = 'b' * 64; + final users = { + alice: UserProfile(pubkey: alice, displayName: 'Alice'), + bob: UserProfile(pubkey: bob, displayName: 'Bob'), + scoutA: UserProfile(pubkey: scoutA, displayName: 'Scout'), + scoutB: UserProfile(pubkey: scoutB, displayName: 'Scout'), + }; + final createdAt = + DateTime.utc(2025, 9, 30, 12).millisecondsSinceEpoch ~/ 1000; + UserNote reply(String id, String author, String target) => UserNote( + id: id, + pubkey: author, + createdAt: createdAt, + content: 'A reply', + tags: [ + ['e', 'parent-$id', '', 'reply'], + ['p', target], + ], + ); + + await tester.pumpWidget( + ProviderScope( + overrides: [ + userCacheProvider.overrideWith(() => _UserCache(users)), + myPubkeyProvider.overrideWithValue(null), + agentPubkeysProvider.overrideWith((ref) async => const []), + globalNotesProvider.overrideWith( + (ref) async => [ + reply('note-1', alice, scoutA), + reply('note-2', bob, scoutB), + ], + ), + noteReactionsProvider.overrideWith((ref, key) async => const {}), + ], + child: MaterialApp(theme: AppTheme.light(), home: const PulsePage()), + ), + ); + await tester.pumpAndSettle(); + + final replyLabels = tester + .widgetList(find.textContaining('Replying to ')) + .map((text) => text.data) + .toList(); + expect(replyLabels, hasLength(2)); + expect(replyLabels.toSet(), hasLength(2)); + expect(replyLabels, isNot(contains('Replying to Scout'))); + }); +} diff --git a/mobile/test/features/search/search_page_test.dart b/mobile/test/features/search/search_page_test.dart index 8e8c4f81ec..047f68f666 100644 --- a/mobile/test/features/search/search_page_test.dart +++ b/mobile/test/features/search/search_page_test.dart @@ -653,6 +653,44 @@ void main() { expect(tester.takeException(), isNull); }); + testWidgets('same-name people results get distinct labels', (tester) async { + final first = 'a' * 64, second = 'b' * 64; + final state = SearchState( + query: 'scout', + channelResults: const [], + userResults: [ + DirectoryUser(pubkey: first, displayName: 'Scout'), + DirectoryUser(pubkey: second, displayName: 'Scout'), + ], + messageResults: const [], + ); + await tester.pumpWidget( + WidgetHelpers.testable( + overrides: [ + searchProvider.overrideWith(() => _FakeSearchNotifier(state)), + recentSearchesProvider.overrideWith( + () => _FakeRecentSearchesNotifier(const []), + ), + profileProvider.overrideWith(() => _FakeProfileNotifier()), + channelsProvider.overrideWith(() => _FakeChannelsNotifier()), + userCacheProvider.overrideWith( + () => _FakeUserCacheNotifier( + UserProfile(pubkey: first, displayName: 'Scout'), + ), + ), + ], + child: const SearchPage(), + ), + ); + await tester.pumpAndSettle(); + + String title(String pubkey) => tester + .widget(find.byKey(ValueKey('search-person-title-$pubkey'))) + .data!; + expect(title(first), isNot(title(second))); + expect(title(first), isNot('Scout')); + }); + testWidgets('uses compact content styles and keeps message time by author', ( tester, ) async {