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 {