diff --git a/mobile/lib/features/activity/activity_page/inbox_row.dart b/mobile/lib/features/activity/activity_page/inbox_row.dart index 1252968baf..cb19e65b07 100644 --- a/mobile/lib/features/activity/activity_page/inbox_row.dart +++ b/mobile/lib/features/activity/activity_page/inbox_row.dart @@ -80,7 +80,9 @@ class _InboxRow extends HookConsumerWidget { pubkey: ref.watch(userCacheProvider.select((cache) => cache[pubkey])), }; final profile = profiles[senderPubkey]; - final senderLabel = profile?.displayName ?? shortPubkey(item.item.pubkey); + // 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); final profileMentionNames = { for (final pubkey in mentionPubkeys) if (profiles[pubkey]?.displayName?.trim().isNotEmpty == true) 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 5ea4c041d7..2914184729 100644 --- a/mobile/lib/features/channels/channel_detail_page/app_bar.dart +++ b/mobile/lib/features/channels/channel_detail_page/app_bar.dart @@ -238,11 +238,14 @@ class _DmAppBarTitle extends ConsumerWidget { .contains(otherPubkey)) || profile?.ownerPubkey != null; final animatedAvatar = parseAnimatedAvatarUrl(avatarUrl); + // Keyed to the hex public key when the participant is unnamed and the + // profile isn't cached — the compact-npub participant label would + // otherwise render `N` for every unnamed DM counterpart. Selection skips + // the current user like the header label does, so the initial always + // identifies the same counterpart the label names. final initial = profile?.initial ?? - (channel.participants.isNotEmpty - ? channel.participants.first[0].toUpperCase() - : '?'); + dmAvatarInitial(channel, currentPubkey: currentPubkey); final presenceLabel = switch (presence) { 'online' => 'Online', 'away' => 'Away', diff --git a/mobile/lib/features/channels/channel_details_page.dart b/mobile/lib/features/channels/channel_details_page.dart index d86c01de7a..a626f0dc4f 100644 --- a/mobile/lib/features/channels/channel_details_page.dart +++ b/mobile/lib/features/channels/channel_details_page.dart @@ -655,11 +655,18 @@ class _ChannelMemberPreviewRow extends StatelessWidget { @override Widget build(BuildContext context) { final isSelf = member.pubkey.toLowerCase() == currentPubkey?.toLowerCase(); + final hasName = displayName?.trim().isNotEmpty == true; final label = isSelf ? 'You' - : displayName?.trim().isNotEmpty == true + : hasName ? displayName!.trim() : member.labelFor(currentPubkey); + // 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() + : (member.pubkey.isNotEmpty ? member.pubkey[0].toUpperCase() : '?'); final roleLabel = _channelMemberRoleLabel(member.role); final titleStyle = context.textTheme.bodyLarge; final roleStyle = context.textTheme.bodySmall?.copyWith( @@ -671,7 +678,7 @@ class _ChannelMemberPreviewRow extends StatelessWidget { imageUrl: avatarUrl, radius: 20, backgroundColor: context.colors.primaryContainer, - fallback: Text(label.isEmpty ? '?' : label[0].toUpperCase()), + fallback: Text(initial), isAgent: member.isBot, ), title: Text.rich( diff --git a/mobile/lib/features/channels/channel_management_provider.dart b/mobile/lib/features/channels/channel_management_provider.dart index 6fad4ecc4a..36e640b411 100644 --- a/mobile/lib/features/channels/channel_management_provider.dart +++ b/mobile/lib/features/channels/channel_management_provider.dart @@ -10,6 +10,7 @@ import '../../shared/custom_emoji/custom_emoji_provider.dart'; import '../../shared/crypto/nip_oa.dart'; import '../../shared/mentions/agent_identity_provider.dart'; import '../../shared/relay/relay.dart'; +import '../../shared/utils/string_utils.dart'; import '../profile/profile_provider.dart'; import 'channel.dart'; import 'channel_metadata_updates.dart'; @@ -35,10 +36,7 @@ class AddMembersException implements Exception { const AddMembersException(this.failures); String get message => failures.entries - .map( - (entry) => - '${entry.key.length > 8 ? '${entry.key.substring(0, 8)}…' : entry.key}: ${entry.value}', - ) + .map((entry) => '${shortPubkey(entry.key)}: ${entry.value}') .join('; '); @override @@ -71,7 +69,7 @@ class ChannelMember { if (displayName case final name? when name.trim().isNotEmpty) { return name.trim(); } - return pubkey.length > 8 ? '${pubkey.substring(0, 8)}…' : pubkey; + return shortPubkey(pubkey); } } @@ -175,7 +173,7 @@ class DirectoryUser { if (nip05 != null && nip05.isNotEmpty) { return nip05; } - return pubkey.length > 8 ? '${pubkey.substring(0, 8)}…' : pubkey; + return shortPubkey(pubkey); } String get secondaryLabel { @@ -183,11 +181,22 @@ class DirectoryUser { if (nip05 != null && nip05.isNotEmpty && nip05 != label) { return nip05; } - return pubkey.length > 16 ? '${pubkey.substring(0, 16)}…' : pubkey; + // The primary label is already the compact key when no name or NIP-05 + // exists; a second key-shaped line would only duplicate it. + final display = displayName?.trim(); + return display != null && display.isNotEmpty ? shortPubkey(pubkey) : ''; } - /// First visible character used when no avatar image is available. - String get initial => label.isNotEmpty ? label[0].toUpperCase() : '?'; + /// Avatar initial — name-derived when available, otherwise keyed to the + /// hex public key so unnamed identities keep distinct initials (a compact + /// npub would render `N` for everyone). + String get initial { + final display = displayName?.trim(); + if (display != null && display.isNotEmpty) return display[0].toUpperCase(); + final nip05 = nip05Handle?.trim(); + if (nip05 != null && nip05.isNotEmpty) return nip05[0].toUpperCase(); + return pubkey.isNotEmpty ? pubkey[0].toUpperCase() : '?'; + } } /// Whether the mobile DM directory should show local preview identities. diff --git a/mobile/lib/features/channels/channels_page/channel_tile.dart b/mobile/lib/features/channels/channels_page/channel_tile.dart index b94be1951f..e060e9e9ac 100644 --- a/mobile/lib/features/channels/channels_page/channel_tile.dart +++ b/mobile/lib/features/channels/channels_page/channel_tile.dart @@ -191,11 +191,14 @@ class _DmAvatar extends ConsumerWidget { } final avatarUrl = profile?.avatarUrl; + // Keyed to the hex public key when the counterpart is unnamed and the + // profile isn't cached — the compact-npub participant label would + // otherwise render `N` for every unnamed DM counterpart. Selection skips + // the current user like the row label does, so the initial always + // identifies the same counterpart the label names. final initial = profile?.initial ?? - (channel.participants.isNotEmpty - ? channel.participants.first[0].toUpperCase() - : '?'); + dmAvatarInitial(channel, currentPubkey: currentPubkey); return SizedBox( width: _kDmAvatarSize, height: _kDmAvatarSize, diff --git a/mobile/lib/features/channels/compose_bar/suggestions.dart b/mobile/lib/features/channels/compose_bar/suggestions.dart index e6bee4515b..31e57c85f0 100644 --- a/mobile/lib/features/channels/compose_bar/suggestions.dart +++ b/mobile/lib/features/channels/compose_bar/suggestions.dart @@ -47,7 +47,10 @@ class _MentionSuggestions extends StatelessWidget { radius: 18, backgroundColor: context.colors.primaryContainer, fallback: Text( - name[0].toUpperCase(), + // Name-derived for named candidates; keyed to the hex + // public key for unnamed ones so the compact-npub label + // doesn't render `N` for everyone. + candidate.initial, style: context.textTheme.labelMedium?.copyWith( color: context.colors.onPrimaryContainer, fontWeight: FontWeight.w600, diff --git a/mobile/lib/features/channels/dm_channel_labels.dart b/mobile/lib/features/channels/dm_channel_labels.dart index 809b00876f..51e7573058 100644 --- a/mobile/lib/features/channels/dm_channel_labels.dart +++ b/mobile/lib/features/channels/dm_channel_labels.dart @@ -54,6 +54,56 @@ String resolveDmChannelDisplayLabel(Channel channel, {String? currentPubkey}) { : channel.name; } +/// Avatar initial for the DM's visible counterpart. +/// +/// Mirrors [resolveDmChannelDisplayLabel]'s participant selection: the +/// first participant that is not [currentPubkey], since member order does +/// not guarantee the counterpart is listed first — otherwise the avatar +/// could identify the current user while the label beside it identifies +/// the counterpart. When every participant is the current user (a +/// self-DM), the label names the current user too, so the avatar keys off +/// that same first participant instead of its label's first character. +/// +/// A resolved display name keeps its name-derived initial. A label that +/// fell back to the compact npub form of the participant's key is keyed to +/// the hex public key instead — the npub form starts with `npub1`, so every +/// unnamed participant would otherwise render `N`. +String dmAvatarInitial(Channel channel, {String? currentPubkey}) { + final normalizedCurrent = currentPubkey?.toLowerCase(); + var index = 0; + while (normalizedCurrent != null && + index < channel.participantPubkeys.length && + channel.participantPubkeys[index].toLowerCase() == normalizedCurrent) { + index++; + } + + if (index >= channel.participantPubkeys.length) { + // No participant pubkeys (labels only): fall back to the first + // participant label, like the channel label does when the non-self + // list is empty. + if (channel.participantPubkeys.isEmpty) { + final label = channel.participants.isNotEmpty + ? channel.participants.first + : ''; + return label.isNotEmpty ? label[0].toUpperCase() : '?'; + } + // Keys exist but every participant is the current user (a self-DM): + // the label names the current user, so key the avatar to that same + // first participant with the shared provenance rule below — the hex + // key for a compact npub label, the authored name otherwise. + index = 0; + } + + final pubkey = channel.participantPubkeys[index]; + final label = index < channel.participants.length + ? channel.participants[index] + : shortPubkey(pubkey); + if (pubkey.isNotEmpty && label == shortPubkey(pubkey)) { + return pubkey[0].toUpperCase(); + } + return label.isNotEmpty ? label[0].toUpperCase() : '?'; +} + List sortDmChannelsByDisplayLabel( Iterable channels, { String? currentPubkey, diff --git a/mobile/lib/features/channels/members_sheet.dart b/mobile/lib/features/channels/members_sheet.dart index 568737362f..ba720a408e 100644 --- a/mobile/lib/features/channels/members_sheet.dart +++ b/mobile/lib/features/channels/members_sheet.dart @@ -221,7 +221,12 @@ class _MemberTile extends ConsumerWidget { : (profile?.displayName?.trim().isNotEmpty == true ? profile!.displayName!.trim() : member.labelFor(currentPubkey)); - final initial = label.substring(0, 1).toUpperCase(); + // 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() + : (member.pubkey.isNotEmpty ? member.pubkey[0].toUpperCase() : '?'); final showManagementActions = canManage && !isSelf && !member.isOwner; final showMenu = showManagementActions || onViewActivity != null; diff --git a/mobile/lib/features/channels/mentions/mention_candidates.dart b/mobile/lib/features/channels/mentions/mention_candidates.dart index 6f3bae57a2..2187fedd73 100644 --- a/mobile/lib/features/channels/mentions/mention_candidates.dart +++ b/mobile/lib/features/channels/mentions/mention_candidates.dart @@ -1,5 +1,6 @@ import '../../../shared/mentions/agent_identity_provider.dart'; import '../../../shared/profile/user_profile.dart'; +import '../../../shared/utils/string_utils.dart'; import '../channel_management_provider.dart'; import 'mention_ranking.dart'; @@ -35,7 +36,7 @@ String? formatOwnerLabel( if (name != null && name.isNotEmpty) return name; final handle = profile?.nip05Handle?.trim(); if (handle != null && handle.isNotEmpty) return handle; - return '${ownerPubkey.substring(0, 8)}…'; + return shortPubkey(ownerPubkey); } /// Assemble the full mention candidate list: channel members first-class, diff --git a/mobile/lib/features/channels/mentions/mention_ranking.dart b/mobile/lib/features/channels/mentions/mention_ranking.dart index 0d0875f0ee..7706df4bdf 100644 --- a/mobile/lib/features/channels/mentions/mention_ranking.dart +++ b/mobile/lib/features/channels/mentions/mention_ranking.dart @@ -1,5 +1,7 @@ import 'package:flutter/foundation.dart'; +import '../../../shared/utils/string_utils.dart'; + /// A mention autocomplete candidate. Mirrors the desktop's /// `MentionCandidateForRanking` (desktop/src/features/messages/lib/mentionRanking.ts). @immutable @@ -27,7 +29,16 @@ class MentionCandidate { String get label { final name = displayName?.trim(); if (name != null && name.isNotEmpty) return name; - return pubkey.length >= 8 ? pubkey.substring(0, 8) : pubkey; + return shortPubkey(pubkey); + } + + /// Avatar initial: display-name-derived, or keyed to the hex public key + /// so unnamed identities keep distinct initials (a compact npub would + /// render `N` for everyone). + String get initial { + final name = displayName?.trim(); + if (name != null && name.isNotEmpty) return name[0].toUpperCase(); + return pubkey.isNotEmpty ? pubkey[0].toUpperCase() : '?'; } } diff --git a/mobile/lib/features/channels/reaction_row.dart b/mobile/lib/features/channels/reaction_row.dart index f5d0066bad..1b30fea3a2 100644 --- a/mobile/lib/features/channels/reaction_row.dart +++ b/mobile/lib/features/channels/reaction_row.dart @@ -13,6 +13,7 @@ import '../../shared/emoji/native_emoji_glyph.dart'; import '../../shared/emoji/positive_emoji.dart'; import '../../shared/profile/user_cache_provider.dart'; import '../../shared/profile/user_profile.dart'; +import '../../shared/utils/string_utils.dart'; import 'channel_management_provider.dart'; import 'emoji_picker.dart'; import 'recent_emoji_provider.dart'; @@ -451,9 +452,7 @@ class _ReactorTile extends StatelessWidget { @override Widget build(BuildContext context) { - final displayName = - profile?.label ?? - (pubkey.length >= 8 ? '${pubkey.substring(0, 8)}...' : pubkey); + final displayName = profile?.label ?? shortPubkey(pubkey); final about = profile?.about; return ListTile( diff --git a/mobile/lib/features/forum/forum_post_card.dart b/mobile/lib/features/forum/forum_post_card.dart index 0a2d2de3e8..9ec9a3afb1 100644 --- a/mobile/lib/features/forum/forum_post_card.dart +++ b/mobile/lib/features/forum/forum_post_card.dart @@ -10,6 +10,7 @@ import '../../shared/widgets/avatar_image.dart'; import '../../shared/widgets/modal_presentation.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 '../../shared/profile/user_profile.dart'; import 'forum_models.dart'; @@ -53,7 +54,7 @@ class ForumPostCard extends HookConsumerWidget { final profile = ref.watch(userCacheProvider.select((cache) => cache[pk])) ?? ref.read(userCacheProvider.notifier).get(pk); - final displayName = profile?.label ?? _shortPubkey(post.pubkey); + final displayName = profile?.label ?? shortPubkey(post.pubkey); final isAgent = ref.watch(agentMentionPubkeysProvider(post.channelId)).contains(pk) || profile?.ownerPubkey != null; @@ -345,11 +346,6 @@ class _PostAvatar extends StatelessWidget { } } -String _shortPubkey(String pubkey) { - if (pubkey.length > 12) return '${pubkey.substring(0, 8)}\u2026'; - return pubkey; -} - Map _buildMentionNames( List mentionPubkeys, Map userCache, diff --git a/mobile/lib/features/forum/forum_thread_page.dart b/mobile/lib/features/forum/forum_thread_page.dart index f8c1d904bd..298630b42c 100644 --- a/mobile/lib/features/forum/forum_thread_page.dart +++ b/mobile/lib/features/forum/forum_thread_page.dart @@ -16,6 +16,7 @@ import '../../shared/widgets/modal_presentation.dart'; import '../channels/compose_bar.dart'; import '../channels/message_content.dart'; import '../../shared/profile/user_cache_provider.dart'; +import '../../shared/utils/string_utils.dart'; import '../../shared/profile/user_profile.dart'; import '../profile/user_profile_sheet.dart'; import 'forum_models.dart'; @@ -332,7 +333,7 @@ class _OriginalPost extends ConsumerWidget { final profile = ref.watch(userCacheProvider.select((cache) => cache[pk])) ?? ref.read(userCacheProvider.notifier).get(pk); - final displayName = profile?.label ?? _shortPubkey(post.pubkey); + final displayName = profile?.label ?? shortPubkey(post.pubkey); final userCache = ref.watch(userCacheProvider); final agentMentionPubkeys = agentPubkeysWithProfileOwners( @@ -434,7 +435,7 @@ class _ReplyRow extends ConsumerWidget { final profile = ref.watch(userCacheProvider.select((cache) => cache[pk])) ?? ref.read(userCacheProvider.notifier).get(pk); - final displayName = profile?.label ?? _shortPubkey(reply.pubkey); + final displayName = profile?.label ?? shortPubkey(reply.pubkey); final userCache = ref.watch(userCacheProvider); final agentMentionPubkeys = agentPubkeysWithProfileOwners( @@ -670,8 +671,3 @@ Map _buildMentionNames( } return names; } - -String _shortPubkey(String pubkey) { - if (pubkey.length > 12) return '${pubkey.substring(0, 8)}\u2026'; - return pubkey; -} diff --git a/mobile/lib/features/invites/invite_create_page.dart b/mobile/lib/features/invites/invite_create_page.dart index 7e6b63e20f..5f19a99c10 100644 --- a/mobile/lib/features/invites/invite_create_page.dart +++ b/mobile/lib/features/invites/invite_create_page.dart @@ -9,6 +9,7 @@ import '../../shared/clipboard_utils.dart'; import '../../shared/community/community_membership_provider.dart'; import '../../shared/relay/relay.dart'; import '../../shared/theme/theme.dart'; +import '../../shared/utils/string_utils.dart'; import '../../shared/widgets/app_list.dart'; import '../../shared/widgets/app_list_card.dart'; import '../../shared/widgets/buzz_action_tile.dart'; diff --git a/mobile/lib/features/invites/invite_create_page/person_invite_section.dart b/mobile/lib/features/invites/invite_create_page/person_invite_section.dart index ea118e44e0..42482c002d 100644 --- a/mobile/lib/features/invites/invite_create_page/person_invite_section.dart +++ b/mobile/lib/features/invites/invite_create_page/person_invite_section.dart @@ -199,7 +199,7 @@ class _InviteeResolutionCard extends StatelessWidget { ), ), title: const Text('Resolving profile'), - subtitle: Text(shortCommunityInviteNpub(pubkey)), + subtitle: Text(shortPubkey(pubkey)), ), ], ), @@ -208,7 +208,7 @@ class _InviteeResolutionCard extends StatelessWidget { AppListRow( key: Key('community-invite-resolution-error-$pubkey'), title: 'Could not resolve profile', - subtitle: shortCommunityInviteNpub(pubkey), + subtitle: shortPubkey(pubkey), trailing: TextButton( onPressed: onRetry, child: const Text('Retry'), @@ -223,7 +223,7 @@ class _InviteeResolutionCard extends StatelessWidget { AppListRow( key: Key('community-invite-unresolved-$pubkey'), title: 'Invalid npub', - subtitle: shortCommunityInviteNpub(pubkey), + subtitle: shortPubkey(pubkey), ), ], ); @@ -234,7 +234,7 @@ class _InviteeResolutionCard extends StatelessWidget { children: [ AppListRow( key: Key('community-invite-resolved-$pubkey'), - title: shortCommunityInviteNpub(invitee.pubkey), + title: shortPubkey(invitee.pubkey), trailing: FilledButton( key: const Key('community-invite-submit'), onPressed: isSubmitting ? null : () => onInvite(invitee), diff --git a/mobile/lib/features/invites/invite_create_provider.dart b/mobile/lib/features/invites/invite_create_provider.dart index 5a6eaf2b74..88c06f847b 100644 --- a/mobile/lib/features/invites/invite_create_provider.dart +++ b/mobile/lib/features/invites/invite_create_provider.dart @@ -8,6 +8,7 @@ import 'package:nostr/nostr.dart' as nostr; import 'package:share_plus/share_plus.dart'; import '../../shared/community/community_membership_provider.dart'; +import '../../shared/utils/string_utils.dart'; import '../../shared/relay/relay.dart'; /// The default lifetime of a newly minted community invite link. @@ -112,36 +113,28 @@ class CommunityInviteDirectoryUser { if (display != null && display.isNotEmpty) return display; final nip05 = nip05Handle?.trim(); if (nip05 != null && nip05.isNotEmpty) return nip05; - return shortCommunityInvitePubkey(pubkey); + return shortPubkey(pubkey); } /// A supporting identity label distinct from [label]. String get secondaryLabel { final nip05 = nip05Handle?.trim(); if (nip05 != null && nip05.isNotEmpty && nip05 != label) return nip05; - return pubkey.length > 16 ? '${pubkey.substring(0, 16)}…' : pubkey; + // The primary label is already the compact key when no name or NIP-05 + // exists; a second key-shaped line would only duplicate it. + final display = displayName?.trim(); + return display != null && display.isNotEmpty ? shortPubkey(pubkey) : ''; } - /// The uppercase first character of [label], or `?` when unavailable. - String get initial => label.isEmpty ? '?' : label[0].toUpperCase(); -} - -/// Abbreviates a hexadecimal public key for compact display. -String shortCommunityInvitePubkey(String pubkey) => - pubkey.length > 8 ? '${pubkey.substring(0, 8)}…' : pubkey; - -/// Encodes and abbreviates a hexadecimal public key as an npub. -String shortCommunityInviteNpub(String pubkey) { - try { - final npub = nostr.Nip19.encode( - prefix: nostr.Nip19Prefix.npub, - data: pubkey, - ); - return npub.length > 20 - ? '${npub.substring(0, 12)}…${npub.substring(npub.length - 6)}' - : npub; - } catch (_) { - return shortCommunityInvitePubkey(pubkey); + /// Avatar initial — name-derived when available, otherwise keyed to the + /// hex public key so unnamed identities keep distinct initials (a compact + /// npub would render `N` for everyone). + String get initial { + final display = displayName?.trim(); + if (display != null && display.isNotEmpty) return display[0].toUpperCase(); + final nip05 = nip05Handle?.trim(); + if (nip05 != null && nip05.isNotEmpty) return nip05[0].toUpperCase(); + return pubkey.isNotEmpty ? pubkey[0].toUpperCase() : '?'; } } diff --git a/mobile/lib/features/profile/user_profile_sheet.dart b/mobile/lib/features/profile/user_profile_sheet.dart index 63b0eddeef..97af99fa00 100644 --- a/mobile/lib/features/profile/user_profile_sheet.dart +++ b/mobile/lib/features/profile/user_profile_sheet.dart @@ -83,14 +83,23 @@ class UserProfileSheet extends HookConsumerWidget { final copied = useState(false); final isOpeningDirectMessage = useState(false); - final displayName = profile?.displayName; + // Canonical npub for the copy action; null when [pubkey] is not a valid + // identity, in which case the copy tile is disabled — an invalid key is + // never placed on the clipboard. + final npub = fullNpub(pubkey); + + // Routed through the shared label so a blank cached name (empty or + // whitespace-only, relay-valid) falls back to the compact npub instead + // of an empty heading. + final displayName = profile?.label; final avatarUrl = profile?.avatarUrl; final nip05 = profile?.nip05Handle; final initial = profile?.initial ?? (pubkey.isNotEmpty ? pubkey[0].toUpperCase() : '?'); Future copyPublicKey() async { - await Clipboard.setData(ClipboardData(text: pubkey)); + if (npub == null) return; + await Clipboard.setData(ClipboardData(text: npub)); if (!context.mounted) return; copied.value = true; _showProfileCopyToast(context); @@ -239,6 +248,7 @@ class UserProfileSheet extends HookConsumerWidget { ? LucideIcons.check : LucideIcons.key, label: copied.value ? 'Copied' : 'Copy public key', + isEnabled: npub != null, onTap: copyPublicKey, ), ), diff --git a/mobile/lib/features/pulse/agent_activity_card.dart b/mobile/lib/features/pulse/agent_activity_card.dart index f844244cc9..d5af2b3046 100644 --- a/mobile/lib/features/pulse/agent_activity_card.dart +++ b/mobile/lib/features/pulse/agent_activity_card.dart @@ -6,6 +6,7 @@ import 'package:lucide_icons_flutter/lucide_icons.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'; @@ -27,7 +28,7 @@ class AgentActivityCard extends HookConsumerWidget { 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 = profile?.label ?? shortPubkey(group.pubkey); return Column( children: [ @@ -173,6 +174,3 @@ class AgentActivityCard extends HookConsumerWidget { ); } } - -String _shortPubkey(String pubkey) => - pubkey.length <= 8 ? pubkey : '${pubkey.substring(0, 8)}…'; diff --git a/mobile/lib/features/pulse/compose_note_page.dart b/mobile/lib/features/pulse/compose_note_page.dart index d4654c2fc7..9564b10cfa 100644 --- a/mobile/lib/features/pulse/compose_note_page.dart +++ b/mobile/lib/features/pulse/compose_note_page.dart @@ -10,6 +10,7 @@ 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'; @@ -166,7 +167,7 @@ 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 displayName = profile?.label ?? shortPubkey(pubkey); return Padding( padding: const EdgeInsets.fromLTRB(Grid.gutter, Grid.xs, Grid.gutter, 0), @@ -191,7 +192,12 @@ class _ReplyContext extends ConsumerWidget { radius: 18, backgroundColor: context.colors.primaryContainer, fallback: Text( - (profile?.initial ?? displayName[0]).toUpperCase(), + // Name-derived when the profile is cached; keyed to the + // hex public key when it isn't, so the compact-npub + // fallback label doesn't render `N` for every unnamed + // author. + profile?.initial ?? + (pubkey.isNotEmpty ? pubkey[0].toUpperCase() : '?'), style: context.textTheme.labelMedium?.copyWith( color: context.colors.onPrimaryContainer, ), @@ -260,7 +266,4 @@ class _ReplyContext extends ConsumerWidget { ), ); } - - String _shortPubkey(String pubkey) => - pubkey.length >= 8 ? '${pubkey.substring(0, 8)}...' : pubkey; } diff --git a/mobile/lib/features/pulse/note_card.dart b/mobile/lib/features/pulse/note_card.dart index 30294a988f..769240d3da 100644 --- a/mobile/lib/features/pulse/note_card.dart +++ b/mobile/lib/features/pulse/note_card.dart @@ -11,6 +11,7 @@ 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'; @@ -43,7 +44,7 @@ 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 displayName = profile?.label ?? shortPubkey(pubkey); final effectiveUpvoted = pendingUpvote.value ?? reaction.reactedByCurrentUser; final effectiveCount = _effectiveCount(reaction, pendingUpvote.value); @@ -70,7 +71,11 @@ class NoteCard extends HookConsumerWidget { radius: 18, backgroundColor: context.colors.primaryContainer, fallback: Text( - (profile?.initial ?? displayName[0]).toUpperCase(), + // Name-derived when the profile is cached; keyed to the hex + // public key when it isn't, so the compact-npub fallback + // label doesn't render `N` for every unnamed author. + profile?.initial ?? + (pubkey.isNotEmpty ? pubkey[0].toUpperCase() : '?'), style: context.textTheme.labelMedium?.copyWith( color: context.colors.onPrimaryContainer, ), @@ -150,7 +155,7 @@ class NoteCard extends HookConsumerWidget { if (note.replyParentId != null) ...[ const SizedBox(height: 2), Text( - 'Replying to ${_shortPubkey(note.replyParentAuthor ?? note.replyParentId!)}', + 'Replying to ${_replyTargetLabel(note)}', style: context.textTheme.labelSmall?.copyWith( color: context.colors.onSurfaceVariant, ), @@ -313,8 +318,15 @@ class _ActionButton extends StatelessWidget { String _shareUri(UserNote note) => 'nostr:${nostr.Nip19.encodeShareableIdentifiers(prefix: nostr.Nip19Prefix.nevent, data: note.id, author: note.pubkey, kind: 1)}'; -String _shortPubkey(String pubkey) => - pubkey.length <= 8 ? pubkey : '${pubkey.substring(0, 8)}…'; +/// 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) { + final author = note.replyParentAuthor; + if (author != null) return shortPubkey(author); + final eventId = note.replyParentId!; + return eventId.length <= 8 ? eventId : '${eventId.substring(0, 8)}…'; +} String formatPulseRelativeTime(int createdAt) { final date = DateTime.fromMillisecondsSinceEpoch(createdAt * 1000); diff --git a/mobile/lib/features/search/search_page.dart b/mobile/lib/features/search/search_page.dart index 507e05fb2c..b66a72a709 100644 --- a/mobile/lib/features/search/search_page.dart +++ b/mobile/lib/features/search/search_page.dart @@ -728,7 +728,7 @@ class _PeopleSection extends ConsumerWidget { key: ValueKey('search-person-leading-${user.pubkey}'), imageUrl: user.avatarUrl, radius: 20, - fallback: Text(user.label.substring(0, 1).toUpperCase()), + fallback: Text(user.initial), isAgent: user.isAgent, ), title: Text( diff --git a/mobile/lib/features/settings/settings_page.dart b/mobile/lib/features/settings/settings_page.dart index b5faa970a1..3253e2212e 100644 --- a/mobile/lib/features/settings/settings_page.dart +++ b/mobile/lib/features/settings/settings_page.dart @@ -14,6 +14,7 @@ import '../../shared/clipboard_utils.dart'; import '../../shared/community/community_membership_provider.dart'; import '../../shared/push/push_bridge.dart'; import '../../shared/relay/relay.dart'; +import '../../shared/utils/string_utils.dart'; import '../pairing/pairing_provider.dart'; import '../../shared/theme/theme.dart'; import '../../shared/widgets/app_list.dart'; diff --git a/mobile/lib/features/settings/settings_page/connection_section.dart b/mobile/lib/features/settings/settings_page/connection_section.dart index 6e7592440a..7e14fb559c 100644 --- a/mobile/lib/features/settings/settings_page/connection_section.dart +++ b/mobile/lib/features/settings/settings_page/connection_section.dart @@ -121,12 +121,16 @@ class _IdentityRow extends StatelessWidget { @override Widget build(BuildContext context) { final privHex = nostr.Nip19.decode(payload: nsec).data; - final pubkey = privHex.isNotEmpty ? nostr.Keys(privHex).public : 'unknown'; + final npub = privHex.isNotEmpty + ? fullNpub(nostr.Keys(privHex).public) + : null; + // The full npub is the canonical copy/share form (never raw hex); an + // invalid identity is surfaced as unavailable and never copied. return Semantics( button: true, label: 'Copy identity public key', - value: pubkey, + value: npub ?? 'Identity unavailable', child: AppListRow( icon: LucideIcons.key, title: 'Identity (pubkey)', @@ -135,9 +139,11 @@ class _IdentityRow extends StatelessWidget { size: 18, color: context.colors.onSurfaceVariant, ), - onTap: () async { - await copyToClipboard(context, pubkey, message: 'Pubkey copied'); - }, + onTap: npub == null + ? null + : () async { + await copyToClipboard(context, npub, message: 'Pubkey copied'); + }, ), ); } diff --git a/mobile/lib/shared/mentions/agent_identity_provider.dart b/mobile/lib/shared/mentions/agent_identity_provider.dart index 35c063d13d..d4d5b237ae 100644 --- a/mobile/lib/shared/mentions/agent_identity_provider.dart +++ b/mobile/lib/shared/mentions/agent_identity_provider.dart @@ -6,6 +6,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import '../../shared/crypto/nip_oa.dart'; import '../../shared/relay/relay.dart'; +import '../../shared/utils/string_utils.dart'; /// A relay agent parsed from its kind:10100 agent-profile event. /// @@ -158,8 +159,7 @@ Map mentionNamesWithDirectoryLabels({ return names; } -String _agentFallbackLabel(String pubkey) => - pubkey.length >= 8 ? pubkey.substring(0, 8) : pubkey; +String _agentFallbackLabel(String pubkey) => shortPubkey(pubkey); /// Readiness and refresh state for a channel's live membership subscription. class ChannelMembershipUpdateState { diff --git a/mobile/lib/shared/profile/user_profile.dart b/mobile/lib/shared/profile/user_profile.dart index abc915437b..8e16b06dfd 100644 --- a/mobile/lib/shared/profile/user_profile.dart +++ b/mobile/lib/shared/profile/user_profile.dart @@ -1,5 +1,7 @@ import 'package:flutter/foundation.dart'; +import '../utils/string_utils.dart'; + @immutable class UserProfile { final String pubkey; @@ -31,15 +33,26 @@ class UserProfile { nip05Handle: json['nip05_handle'] as String?, ); - /// Short label: display name, or first 8 chars of pubkey. - String get label => - displayName ?? - '${pubkey.length >= 8 ? pubkey.substring(0, 8) : pubkey}...'; + /// Short label: display name, or the compact npub form of the public key. + /// + /// Blank display names (empty or whitespace-only) fall back to the compact + /// npub too — relay profiles can carry them — so a valid identity never + /// renders an empty label, like [initial]. Nonblank names render as + /// authored: trim only tests blankness, so authored padding survives + /// (unlike [initial], which reads the trimmed padding). + String get label { + final name = displayName; + return name != null && name.trim().isNotEmpty ? name : shortPubkey(pubkey); + } /// First letter for fallback avatar. - String get initial => - (displayName?.isNotEmpty == true ? displayName! : pubkey)[0] - .toUpperCase(); + String get initial { + final name = displayName?.trim(); + if (name != null && name.isNotEmpty) return name[0].toUpperCase(); + // Hex-derived (not npub-derived) so unnamed identities keep distinct + // initials instead of every npub rendering `N`. + return pubkey.isNotEmpty ? pubkey[0].toUpperCase() : '?'; + } } /// Optional profile handle shown beside a message author's display name. diff --git a/mobile/lib/shared/utils/string_utils.dart b/mobile/lib/shared/utils/string_utils.dart index e821b30d52..b9fb720b51 100644 --- a/mobile/lib/shared/utils/string_utils.dart +++ b/mobile/lib/shared/utils/string_utils.dart @@ -1,5 +1,52 @@ -/// Truncates a hex pubkey to the first 8 characters with an ellipsis. -String shortPubkey(String pubkey) { - if (pubkey.length > 12) return '${pubkey.substring(0, 8)}\u2026'; - return pubkey; +import 'package:nostr/nostr.dart' as nostr; + +/// Neutral label rendered when an identity string cannot be read as a Nostr +/// public key — malformed identities never leak truncated raw hex into the UI. +const String unknownIdentityLabel = 'Unknown identity'; + +final RegExp _hexPubkeyPattern = RegExp(r'^[0-9a-fA-F]{64}$'); + +/// The canonical full NIP-19 npub for [identity], or null when [identity] is +/// not a valid public key. +/// +/// Accepts both canonical public-key forms — a 64-character hex key (any +/// case) or an `npub1…` string — and returns the full canonical npub. +/// Already-npub input round-trips through the codec so the result is always +/// the canonical lowercase encoding. Internal storage stays hex; the npub is +/// the user-facing display/copy form. Invalid input returns null (never a +/// passthrough of the raw string) so callers can suppress copy actions. +String? fullNpub(String identity) { + final trimmed = identity.trim(); + try { + if (_hexPubkeyPattern.hasMatch(trimmed)) { + return _encodeNpub(trimmed.toLowerCase()); + } + final decoded = nostr.Nip19.decode(payload: trimmed); + if (decoded.prefix != nostr.Nip19Prefix.npub || + !_hexPubkeyPattern.hasMatch(decoded.data)) { + return null; + } + return _encodeNpub(decoded.data.toLowerCase()); + } catch (_) { + return null; + } +} + +String _encodeNpub(String hexPubkey) => + nostr.Nip19.encode(prefix: nostr.Nip19Prefix.npub, data: hexPubkey); + +/// The canonical compact public-key label: the first 8 and last 4 characters +/// of the full npub joined by an ellipsis — the same truncation desktop uses +/// for identity surfaces. +/// +/// A compact label is a recognition aid, never a verification path; copy +/// actions expose the full npub via [fullNpub]. Accepts hex or npub input +/// (already-npub input yields the same label as its underlying key). +/// Returns [unknownIdentityLabel] when [identity] is not a valid public key. +String shortPubkey(String identity) { + final npub = fullNpub(identity); + if (npub == null) return unknownIdentityLabel; + return npub.length <= 12 + ? npub + : '${npub.substring(0, 8)}…${npub.substring(npub.length - 4)}'; } diff --git a/mobile/test/features/activity/activity_page_test.dart b/mobile/test/features/activity/activity_page_test.dart index 767e15278f..9fe4a1e2a9 100644 --- a/mobile/test/features/activity/activity_page_test.dart +++ b/mobile/test/features/activity/activity_page_test.dart @@ -32,7 +32,7 @@ void main() { final testMention = FeedItem( id: 'm1', kind: 9, - pubkey: 'alice_pk', + pubkey: 'a11ce00000000000000000000000000000000000000000000000000000000000', content: 'Hey check this out', createdAt: now - 120, channelId: 'ch1', @@ -101,11 +101,13 @@ void main() { ]; final testUsers = { - 'alice_pk': const UserProfile( - pubkey: 'alice_pk', - displayName: 'Alice', - nip05Handle: 'alice@example.com', - ), + 'a11ce00000000000000000000000000000000000000000000000000000000000': + const UserProfile( + pubkey: + 'a11ce00000000000000000000000000000000000000000000000000000000000', + displayName: 'Alice', + nip05Handle: 'alice@example.com', + ), 'bob_pk': const UserProfile(pubkey: 'bob_pk', displayName: 'Bob'), 'agent_pk': const UserProfile(pubkey: 'agent_pk', displayName: 'Scout'), }; @@ -407,7 +409,8 @@ void main() { eventId: 'm1', channelId: 'ch1', preview: 'Follow up', - authorPubkey: 'alice_pk', + authorPubkey: + 'a11ce00000000000000000000000000000000000000000000000000000000000', ), note: null, createdAt: now - 60, @@ -536,6 +539,44 @@ void main() { ); }); + testWidgets('blank cached sender names fall back to the compact npub', ( + tester, + ) async { + // Relay profiles can cache blank display names (empty and + // whitespace-only) unchanged, so the sender must resolve through the + // shared nonblank-name label contract: the row shows the compact npub + // of the a11ce key instead of a blank author label. Binds the production + // seam — the sender resolves through the user cache exactly as the live + // page does. Keyed remounts keep each ProviderScope (and its user-cache + // override) fresh between scenarios, so each iteration actually + // consumes its own blank-name fixture. + const sender = + 'a11ce00000000000000000000000000000000000000000000000000000000000'; + for (final blankName in const ['', ' ']) { + await tester.pumpWidget( + KeyedSubtree( + key: ValueKey('blank-sender-${blankName.length}'), + child: await buildTestable( + users: { + ...testUsers, + sender: UserProfile(pubkey: sender, displayName: blankName), + }, + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.text(blankName), findsNothing); + expect( + find.descendant( + of: find.byKey(const ValueKey('inbox-row-m1')), + matching: find.text('npub15yw…ccpw'), + ), + findsOneWidget, + ); + } + }); + testWidgets('directory-known Activity authors use agent avatars', ( tester, ) async { @@ -574,7 +615,8 @@ void main() { FeedItem dmMessage(String id, int age) => FeedItem( id: id, kind: 9, - pubkey: 'alice_pk', + pubkey: + 'a11ce00000000000000000000000000000000000000000000000000000000000', content: 'dm body $id', createdAt: now - age, channelId: 'dm1', @@ -678,7 +720,8 @@ void main() { final threadMention = FeedItem( id: 'reply-event', kind: 9, - pubkey: 'alice_pk', + pubkey: + 'a11ce00000000000000000000000000000000000000000000000000000000000', content: 'Reply in a thread', createdAt: DateTime.now().millisecondsSinceEpoch ~/ 1000, channelId: 'ch1', @@ -931,14 +974,20 @@ void main() { ); }); - testWidgets('falls back to short pubkey when user not cached', ( + testWidgets('falls back to a compact npub when user is not cached', ( tester, ) async { await tester.pumpWidget(await buildTestable(users: const {})); await tester.pumpAndSettle(); - // Sender label falls back to the (short) pubkey. - expect(find.text('alice_pk'), findsOneWidget); + // Sender label falls back to the compact npub of the author's key. + expect( + find.text( + 'a11ce00000000000000000000000000000000000000000000000000000000000', + ), + findsNothing, + ); + expect(find.text('npub15yw\u2026ccpw'), findsOneWidget); expect(find.text('Alice'), findsNothing); }); } diff --git a/mobile/test/features/channels/channel_detail_page_test.dart b/mobile/test/features/channels/channel_detail_page_test.dart index 2bd6face24..23f547dbad 100644 --- a/mobile/test/features/channels/channel_detail_page_test.dart +++ b/mobile/test/features/channels/channel_detail_page_test.dart @@ -576,10 +576,87 @@ void main() { expect(name.style?.fontWeight, FontWeight.w500); expect(presence.style?.fontSize, 14); expect(presence.style?.fontWeight, FontWeight.w400); + // Named counterpart: the avatar initial comes from the authored name. + expect(_dmHeaderAvatarInitial(tester), 'A'); expect(find.byTooltip('View members'), findsNothing); expect(find.byTooltip('Start Huddle'), findsOneWidget); }); + testWidgets('keys unnamed DM header avatars to the hex participant key', ( + tester, + ) async { + // A valid unnamed counterpart: the compact-npub label would render `N` + // for every unnamed DM, so the header avatar stays keyed to the hex + // public key instead. + const a11ce = + 'a11ce00000000000000000000000000000000000000000000000000000000000'; + final dmChannel = Channel( + id: _channelId, + name: 'DM', + channelType: 'dm', + visibility: 'private', + description: 'Direct message', + createdBy: 'self', + createdAt: DateTime(2025), + memberCount: 2, + participants: [shortPubkey(a11ce), 'Self'], + participantPubkeys: const [a11ce, 'self'], + isMember: true, + ); + + await tester.pumpWidget( + _buildTestable(messages: const [], channel: dmChannel), + ); + await tester.pumpAndSettle(); + + expect( + tester.widget(find.byKey(const ValueKey('dm-header-name'))).data, + shortPubkey(a11ce), + ); + // The named counterpart in the test above keeps its authored initial + // ('A' from 'Alice'); this unnamed one gets the hex-key-derived 'A', + // not the `N` its npub label starts with. + expect(_dmHeaderAvatarInitial(tester), 'A'); + }); + + testWidgets('keys DM header fallback avatars to the non-self counterpart', ( + tester, + ) async { + // Member order does not guarantee the counterpart is listed first: + // the current user (self, from the fake profile) comes FIRST, so an + // avatar keyed to the first participant would render the current + // user's initial while the header label names the counterpart. + const b0b = + 'b0b0000000000000000000000000000000000000000000000000000000000000'; + final dmChannel = Channel( + id: _channelId, + name: 'DM', + channelType: 'dm', + visibility: 'private', + description: 'Direct message', + createdBy: 'self', + createdAt: DateTime(2025), + memberCount: 2, + participants: ['Self', shortPubkey(b0b)], + participantPubkeys: const ['self', b0b], + isMember: true, + ); + + await tester.pumpWidget( + _buildTestable(messages: const [], channel: dmChannel), + ); + await tester.pumpAndSettle(); + + // Label and avatar agree on the counterpart's key: the compact npub + // names the unnamed counterpart, and the avatar initial is keyed to + // that same hex key — never the current user's `S`. + expect( + tester.widget(find.byKey(const ValueKey('dm-header-name'))).data, + shortPubkey(b0b), + ); + expect(_dmHeaderAvatarInitial(tester), 'B'); + }); + testWidgets('uses a fallback squircle for bot-role DM participants', ( tester, ) async { @@ -2291,16 +2368,36 @@ void main() { testWidgets('previews five members before an icon-free See all row', ( tester, ) async { + // Valid fixture keys whose npub encodings were verified against the + // NIP-19 codec independently of the code under test. + const a11ce = + 'a11ce00000000000000000000000000000000000000000000000000000000000'; + const carol = + 'c010100000000000000000000000000000000000000000000000000000000000'; await tester.pumpWidget( _buildTestable( messages: const [], + users: { + carol: const UserProfile(pubkey: carol, displayName: 'Carol'), + }, members: [ ChannelMember( pubkey: 'self', role: 'owner', joinedAt: DateTime(2025), ), - for (var index = 0; index < 5; index++) + ChannelMember( + pubkey: a11ce, + role: 'member', + joinedAt: DateTime(2025), + ), + ChannelMember( + pubkey: carol, + role: 'member', + joinedAt: DateTime(2025), + displayName: 'Carol', + ), + for (var index = 0; index < 3; index++) ChannelMember( pubkey: 'member-$index', role: 'member', @@ -2360,6 +2457,31 @@ void main() { ); expect(tester.getSize(seeAllRow).height, 40 + (Grid.xxs * 2)); + // Identity display in the preview rows: unnamed members keep distinct + // hex-keyed avatar initials (the compact-npub label would render `N` + // for everyone), while self and named rows keep their label initials. + final unnamedRow = find.byKey(ValueKey('channel-details-member-$a11ce')); + expect( + find.descendant( + of: unnamedRow, + matching: find.textContaining(shortPubkey(a11ce)), + ), + findsOneWidget, + ); + expect(_previewRowAvatarInitial(tester, a11ce), 'A'); + final namedRow = find.byKey(ValueKey('channel-details-member-$carol')); + expect( + find.descendant(of: namedRow, matching: find.textContaining('Carol')), + findsOneWidget, + ); + expect(_previewRowAvatarInitial(tester, carol), 'C'); + final selfRow = find.byKey(const ValueKey('channel-details-member-self')); + expect( + find.descendant(of: selfRow, matching: find.textContaining('You')), + findsOneWidget, + ); + expect(_previewRowAvatarInitial(tester, 'self'), 'Y'); + await tester.ensureVisible(seeAllRow); await tester.pumpAndSettle(); await tester.tap(seeAllRow); @@ -5287,11 +5409,12 @@ void main() { expect(find.text('Alice'), findsNWidgets(2)); }); - testWidgets('shows pubkey fallback when no profile', (tester) async { + testWidgets('shows compact npub fallback when no profile', (tester) async { final messages = [ _textMsg( id: 'msg1', - pubkey: 'abcdef1234567890', + pubkey: + 'abcdef0000000000000000000000000000000000000000000000000000000000', content: 'Hi', createdAt: 1000, ), @@ -5301,8 +5424,8 @@ void main() { await tester.pumpAndSettle(); expect(findRichText('Hi'), findsOneWidget); - // Should show first 8 chars of pubkey + ellipsis - expect(find.text('abcdef12…'), findsOneWidget); + // Should show the compact npub form of the author's public key + expect(find.text('npub140x…etzk'), findsOneWidget); }); }); @@ -8363,21 +8486,60 @@ void main() { testWidgets('opens a profile sheet from a membership system avatar', ( tester, ) async { - await tester.pumpWidget( - _buildTestable( + const alicePubkey = + 'a11ce00000000000000000000000000000000000000000000000000000000000'; + const bobPubkey = + 'b0b0000000000000000000000000000000000000000000000000000000000000'; + // Not a valid hex public key — the sheet must surface a neutral label + // and refuse to copy it rather than leaking the raw string. + const invalidPubkey = 'bob-not-a-real-pubkey'; + final clipboardTexts = []; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(SystemChannels.platform, (call) async { + if (call.method == 'Clipboard.setData') { + clipboardTexts.add((call.arguments as Map)['text'] as String); + } + return null; + }); + addTearDown( + () => TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(SystemChannels.platform, null), + ); + + // Both scenarios share this sheet workflow — a keyed remount so the + // second [ProviderScope] (and its user-cache override) is fresh. + Widget sheetHost( + String scenario, + String target, { + Map users = const {}, + }) => KeyedSubtree( + key: ValueKey('sheet-$scenario'), + child: _buildTestable( messages: [ _systemMsg( - id: 'sys-membership-avatar', + id: 'sys-membership-avatar-$scenario', payload: { 'type': 'member_joined', - 'actor': 'alice', - 'target': 'bob', + 'actor': alicePubkey, + 'target': target, }, ), ], + users: users, + ), + ); + + // A valid identity: the sheet copies the full canonical npub. + await tester.pumpWidget( + sheetHost( + 'valid', + bobPubkey, users: { - 'alice': const UserProfile(pubkey: 'alice', displayName: 'Alice'), - 'bob': const UserProfile(pubkey: 'bob', displayName: 'Bob'), + alicePubkey: const UserProfile( + pubkey: alicePubkey, + displayName: 'Alice', + ), + bobPubkey: const UserProfile(pubkey: bobPubkey, displayName: 'Bob'), }, ), ); @@ -8387,15 +8549,11 @@ void main() { await tester.pumpAndSettle(); expect(find.text('Copy public key'), findsOneWidget); - expect(find.text('alice'), findsNothing); + // The full hex key is never rendered in the sheet. + expect(find.text(alicePubkey), findsNothing); + expect(find.text(bobPubkey), findsNothing); expect(find.byType(UserProfileSheet), findsOneWidget); - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(SystemChannels.platform, (_) async => null); - addTearDown( - () => TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(SystemChannels.platform, null), - ); await tester.ensureVisible(find.text('Copy public key')); await tester.pumpAndSettle(); final copyAction = find @@ -8408,6 +8566,68 @@ void main() { await tester.pump(); await tester.pump(); expect(find.text('Public key copied'), findsOneWidget); + // The clipboard receives the full canonical npub — never the raw hex. + expect(clipboardTexts, [ + 'npub1kzcqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq0euyv8', + ]); + + await tester.tap(find.byTooltip('Close sheet')); + await tester.pumpAndSettle(); + + // An invalid identity through the same workflow: neutral label, + // disabled copy, and no second clipboard write. + await tester.pumpWidget( + sheetHost( + 'invalid', + invalidPubkey, + users: { + alicePubkey: const UserProfile( + pubkey: alicePubkey, + displayName: 'Alice', + ), + }, + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.byType(CircleAvatar)); + await tester.pumpAndSettle(); + + expect(find.text('Copy public key'), findsOneWidget); + // Malformed identity → neutral label, never truncated raw input. + expect( + find.descendant( + of: find.byType(UserProfileSheet), + matching: find.text('Unknown identity'), + ), + findsOneWidget, + ); + expect(find.text(invalidPubkey), findsNothing); + // The copy tile is disabled and exposes no tap handler. + final disabledCopyAction = find + .ancestor( + of: find.text('Copy public key'), + matching: find.byType(GestureDetector), + ) + .last; + expect(tester.widget(disabledCopyAction).onTap, isNull); + final copySemantics = find + .ancestor( + of: find.text('Copy public key'), + matching: find.byType(Semantics), + ) + .first; + expect( + tester.widget(copySemantics).properties.enabled, + isFalse, + ); + + await tester.tap(disabledCopyAction, warnIfMissed: false); + await tester.pump(); + await tester.pump(); + expect(find.text('Public key copied'), findsNothing); + // The valid scenario's npub is still the only clipboard write. + expect(clipboardTexts, hasLength(1)); await tester.tap(find.byTooltip('Close sheet')); await tester.pumpAndSettle(); @@ -8416,6 +8636,79 @@ void main() { expect(tester.takeException(), isNull); }); + testWidgets( + 'profile sheet heading falls back to the compact npub for blank cached names', + (tester) async { + const alicePubkey = + 'a11ce00000000000000000000000000000000000000000000000000000000000'; + const bobPubkey = + 'b0b0000000000000000000000000000000000000000000000000000000000000'; + + // The membership row opens the sheet for the joined member (bob). + // His cached display name is relay-valid but blank (empty and + // whitespace-only), so the heading must resolve through the shared + // nonblank-name label contract — the compact npub of the b0b key, + // never a blank heading. Keyed remounts keep each ProviderScope + // (and its user-cache override) fresh between scenarios. + for (final blankName in const ['', ' ']) { + await tester.pumpWidget( + KeyedSubtree( + key: ValueKey('blank-name-sheet-${blankName.length}'), + child: _buildTestable( + messages: [ + _systemMsg( + id: 'sys-membership-blank-${blankName.length}', + payload: { + 'type': 'member_joined', + 'actor': alicePubkey, + 'target': bobPubkey, + }, + ), + ], + users: { + alicePubkey: const UserProfile( + pubkey: alicePubkey, + displayName: 'Alice', + ), + bobPubkey: UserProfile( + pubkey: bobPubkey, + displayName: blankName, + ), + }, + ), + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.byType(CircleAvatar)); + await tester.pumpAndSettle(); + + expect(find.byType(UserProfileSheet), findsOneWidget); + expect( + find.descendant( + of: find.byType(UserProfileSheet), + matching: find.text(blankName), + ), + findsNothing, + ); + expect( + find.descendant( + of: find.byType(UserProfileSheet), + matching: find.text('npub1kzc…uyv8'), + ), + findsOneWidget, + ); + // The full hex key is never rendered either. + expect(find.text(bobPubkey), findsNothing); + + await tester.tap(find.byTooltip('Close sheet')); + await tester.pumpAndSettle(); + } + + expect(tester.takeException(), isNull); + }, + ); + testWidgets('opens a profile sheet from a huddle system avatar', ( tester, ) async { @@ -14895,3 +15188,25 @@ class _TestNavigatorObserver extends NavigatorObserver { super.didPush(route, previousRoute); } } + +/// Avatar fallback initial in the DM header — asserts at the production +/// seam (the masked `dm-header-avatar` badge), not the label helper. +String _dmHeaderAvatarInitial(WidgetTester tester) { + final avatar = find.byKey(const ValueKey('dm-header-avatar')); + final initial = tester.widget( + find.descendant(of: avatar, matching: find.byType(Text)), + ); + return initial.data!; +} + +/// Avatar fallback initial in the channel-details member preview row keyed +/// to [pubkey] — asserts at the production seam (the rendered +/// `_ChannelMemberPreviewRow`), not the label helper. +String _previewRowAvatarInitial(WidgetTester tester, String pubkey) { + final row = find.byKey(ValueKey('channel-details-member-$pubkey')); + final avatar = find.descendant(of: row, matching: find.byType(AvatarImage)); + final initial = tester.widget( + find.descendant(of: avatar, matching: find.byType(Text)), + ); + return initial.data!; +} diff --git a/mobile/test/features/channels/channel_management_provider_test.dart b/mobile/test/features/channels/channel_management_provider_test.dart index 04b8c16671..69f588622b 100644 --- a/mobile/test/features/channels/channel_management_provider_test.dart +++ b/mobile/test/features/channels/channel_management_provider_test.dart @@ -81,6 +81,69 @@ void main() { expect(users.first.avatarUrl, 'https://example.com/alice.png'); }); + group('identity labels', () { + // Valid fixture keys whose npub encodings were verified against the + // NIP-19 codec independently of the code under test. + const alicePubkey = + 'a11ce00000000000000000000000000000000000000000000000000000000000'; + const bobPubkey = + 'b0b0000000000000000000000000000000000000000000000000000000000000'; + + test('member labels honor "You", authored names, and npub fallback', () { + final unnamed = ChannelMember( + pubkey: alicePubkey, + role: 'member', + joinedAt: DateTime.fromMillisecondsSinceEpoch(0), + ); + final named = ChannelMember( + pubkey: alicePubkey, + role: 'member', + joinedAt: DateTime.fromMillisecondsSinceEpoch(0), + displayName: ' Alice ', + ); + + // Unnamed members fall back to the compact npub … + expect(unnamed.labelFor(null), 'npub15yw…ccpw'); + // … but only the member's own identity is "You" — never the caller's key. + expect(unnamed.labelFor(alicePubkey), 'You'); + expect(unnamed.labelFor(alicePubkey.toUpperCase()), 'You'); + expect(unnamed.labelFor(bobPubkey), 'npub15yw…ccpw'); + // An authored display name wins over every fallback. + expect(named.labelFor(bobPubkey), 'Alice'); + }); + + test( + 'directory labels keep names, one-line npub fallbacks, and initials', + () { + final unnamed = DirectoryUser(pubkey: alicePubkey); + final alsoUnnamed = DirectoryUser(pubkey: bobPubkey); + final named = DirectoryUser( + pubkey: alicePubkey, + displayName: 'Alice', + nip05Handle: 'alice@example.com', + ); + + // Unnamed: the primary label is already the compact key, so a + // second key-shaped line would only duplicate it. + expect(unnamed.label, 'npub15yw…ccpw'); + expect(unnamed.secondaryLabel, ''); + // Initials stay hex-derived — a compact npub would render `N` for all. + expect(unnamed.initial, 'A'); + expect(alsoUnnamed.initial, 'B'); + // Named: the authored name leads, the handle keys the second line. + expect(named.label, 'Alice'); + expect(named.secondaryLabel, 'alice@example.com'); + expect(named.initial, 'A'); + }, + ); + + test('add-members failures identify the member by compact npub', () { + const failure = AddMembersException({alicePubkey: 'not a member'}); + + expect(failure.message, 'npub15yw…ccpw: not a member'); + }); + }); + test('propagates archived state from kind:39000 archived tag', () { // Regression: previously this mapping ignored the `archived` tag, so // `Channel.mergeDetails` would clear the archived flag the list provider diff --git a/mobile/test/features/channels/channels_page_test.dart b/mobile/test/features/channels/channels_page_test.dart index 44f8714c64..6293ae373e 100644 --- a/mobile/test/features/channels/channels_page_test.dart +++ b/mobile/test/features/channels/channels_page_test.dart @@ -19,6 +19,7 @@ import 'package:buzz/features/channels/unread_badge/observed_unread_event.dart'; import 'package:buzz/features/profile/profile_avatar.dart'; import 'package:buzz/features/profile/profile_provider.dart'; import 'package:buzz/shared/profile/user_profile.dart'; +import 'package:buzz/shared/utils/string_utils.dart'; import 'package:buzz/shared/auth/auth.dart'; import 'package:buzz/shared/community/community_icon_provider.dart'; import 'package:buzz/shared/relay/relay.dart'; @@ -42,11 +43,14 @@ void main() { Gradient? topSectionGradient, ValueChanged? onSettingsTransitionProgress, ValueListenable? tabReselection, + _FakeProfileNotifier? profile, }) { return ProviderScope( overrides: [ - // Provide a fake profile and presence so the avatar doesn't hit the network. - profileProvider.overrideWith(() => _FakeProfileNotifier()), + // Provide a fake profile and presence so the avatar doesn't hit the + // network. [profile] swaps the current user for tests keyed to a + // different identity (the page reads its pubkey from profileProvider). + profileProvider.overrideWith(() => profile ?? _FakeProfileNotifier()), presenceProvider.overrideWith(() => _FakePresenceNotifier()), communityIconProvider.overrideWith((ref, relayUrl) async { onCommunityIconLoad?.call(relayUrl); @@ -124,17 +128,51 @@ void main() { createdBy: 'abc', createdAt: DateTime(2025), memberCount: 2, - participants: const ['Test', 'Alice'], - participantPubkeys: const ['aabb', 'alice'], + participants: const ['Alice', 'Test'], + participantPubkeys: const ['alice', 'aabb'], isMember: true, ), ]; testWidgets('shows grouped channel list when data loads', (tester) async { + // Valid fixture keys whose npub encodings were verified against the + // NIP-19 codec independently of the code under test. + const a11ce = + 'a11ce00000000000000000000000000000000000000000000000000000000000'; + const b0b = + 'b0b0000000000000000000000000000000000000000000000000000000000000'; + final unnamedDm = Channel( + id: 'dm-unnamed', + name: 'DM', + channelType: 'dm', + visibility: 'open', + description: 'Direct message', + createdBy: 'aabb', + createdAt: DateTime(2025), + memberCount: 2, + participants: [shortPubkey(a11ce), 'Test'], + participantPubkeys: const [a11ce, 'aabb'], + isMember: true, + ); + final groupDm = Channel( + id: 'dm-group', + name: 'Group DM', + channelType: 'dm', + visibility: 'open', + description: 'Direct message', + createdBy: 'aabb', + createdAt: DateTime(2025), + memberCount: 3, + participants: [shortPubkey(a11ce), shortPubkey(b0b), 'Test'], + participantPubkeys: const [a11ce, b0b, 'aabb'], + isMember: true, + ); await tester.pumpWidget( buildTestable( overrides: [ - channelsProvider.overrideWith(() => _FakeNotifier(testChannels)), + channelsProvider.overrideWith( + () => _FakeNotifier([...testChannels, unnamedDm, groupDm]), + ), ], ), ); @@ -153,6 +191,26 @@ void main() { expect(find.byIcon(LucideIcons.arrowUpDown), findsNothing); expect(find.byTooltip('DMs options'), findsOneWidget); + // DM identity display: the unnamed counterpart tile renders its + // compact npub label, but its avatar initial stays keyed to the hex + // public key — never the `N` the npub label starts with. The named + // tile keeps its authored initial from the positional participant + // label even without a cached profile. + expect(find.text(shortPubkey(a11ce)), findsOneWidget); + expect(_dmTileAvatarInitial(tester, shortPubkey(a11ce)), 'A'); + expect(_dmTileAvatarInitial(tester, 'Alice'), 'A'); + // A multi-counterpart DM still takes the group count badge — the + // single-counterpart avatar above is not shared with it. + final groupTile = _dmTileFor('${shortPubkey(a11ce)}, ${shortPubkey(b0b)}'); + expect( + find.descendant(of: groupTile, matching: find.byType(AvatarImage)), + findsNothing, + ); + expect( + find.descendant(of: groupTile, matching: find.text('2')), + findsOneWidget, + ); + await tester.tap(find.byTooltip('Channels options')); await tester.pumpAndSettle(); expect(find.text('Sort: Recent'), findsOneWidget); @@ -195,6 +253,103 @@ void main() { expect(sectionTitle.style?.fontWeight, FontWeight.w600); }); + testWidgets('keys DM tile fallback avatars to the non-self counterpart', ( + tester, + ) async { + // Valid fixture keys whose npub encodings were verified against the + // NIP-19 codec independently of the code under test. The current user + // (aabb, from the fake profile) is listed FIRST — member order does + // not guarantee the counterpart is first — so an avatar that keys off + // the first participant would identify the current user while the + // label beside it identifies the counterpart. + const b0b = + 'b0b0000000000000000000000000000000000000000000000000000000000000'; + final selfFirstUnnamedDm = Channel( + id: 'dm-self-first-unnamed', + name: 'DM', + channelType: 'dm', + visibility: 'open', + description: 'Direct message', + createdBy: 'aabb', + createdAt: DateTime(2025), + memberCount: 2, + participants: ['Test', shortPubkey(b0b)], + participantPubkeys: const ['aabb', b0b], + isMember: true, + ); + final selfFirstNamedDm = Channel( + id: 'dm-self-first-named', + name: 'DM', + channelType: 'dm', + visibility: 'open', + description: 'Direct message', + createdBy: 'aabb', + createdAt: DateTime(2025), + memberCount: 2, + participants: const ['Test', 'Dana'], + participantPubkeys: const ['aabb', b0b], + isMember: true, + ); + await tester.pumpWidget( + buildTestable( + overrides: [ + channelsProvider.overrideWith( + () => _FakeNotifier([selfFirstUnnamedDm, selfFirstNamedDm]), + ), + ], + ), + ); + await tester.pumpAndSettle(); + + // Label and avatar agree on the counterpart's key: the compact npub + // names the unnamed counterpart, and the avatar initial is keyed to + // that same hex key — never the current user's `A`. + expect(find.text(shortPubkey(b0b)), findsOneWidget); + expect(_dmTileAvatarInitial(tester, shortPubkey(b0b)), 'B'); + // A named counterpart listed second keeps its authored initial too — + // `D`, not its key's `B`, proves the name is the initial's source. + expect(_dmTileAvatarInitial(tester, 'Dana'), 'D'); + }); + + testWidgets('keys the self-DM tile avatar to the current user key', ( + tester, + ) async { + // A self-DM lists only the current user, so the tile label falls back + // to the sole participant — the compact npub of the current user's + // own key. + const b0b = + 'b0b0000000000000000000000000000000000000000000000000000000000000'; + final selfDm = Channel( + id: 'dm-self', + name: 'DM', + channelType: 'dm', + visibility: 'open', + description: 'Direct message', + createdBy: b0b, + createdAt: DateTime(2025), + memberCount: 1, + participants: [shortPubkey(b0b)], + participantPubkeys: const [b0b], + isMember: true, + ); + await tester.pumpWidget( + buildTestable( + // The tile reads the current user's pubkey from profileProvider, so + // the self-DM needs the fake profile keyed to the same participant. + profile: _FakeProfileNotifier(pubkey: b0b), + overrides: [ + channelsProvider.overrideWith(() => _FakeNotifier([selfDm])), + ], + ), + ); + await tester.pumpAndSettle(); + + // The compact-npub label and its avatar identify the same key: the + // hex-key initial `B`, never the `N` the npub label starts with. + expect(find.text(shortPubkey(b0b)), findsOneWidget); + expect(_dmTileAvatarInitial(tester, shortPubkey(b0b)), 'B'); + }); + testWidgets('sizes the community header for accessible text', (tester) async { await tester.pumpWidget( buildTestable( @@ -2517,9 +2672,15 @@ class _ReconnectingRelaySession extends RelaySessionNotifier { } class _FakeProfileNotifier extends ProfileNotifier { + _FakeProfileNotifier({this.pubkey = 'aabb'}); + + /// Current-user pubkey; the default keeps the historical 'aabb' fake used + /// by the other tile tests. The display name stays 'Test'. + final String pubkey; + @override Future build() async => - const UserProfile(pubkey: 'aabb', displayName: 'Test'); + UserProfile(pubkey: pubkey, displayName: 'Test'); } class _FakePresenceNotifier extends PresenceNotifier { @@ -2578,3 +2739,20 @@ ObservedUnreadEvent _observed({ channelType: 'stream', isThreadedReply: isThreadedReply, ); + +/// The channel tile (its `InkWell`) that renders [labelText] as its row +/// label. +Finder _dmTileFor(String labelText) => find + .ancestor(of: find.text(labelText), matching: find.byType(InkWell)) + .first; + +/// Avatar fallback initial for the DM tile rendering [labelText] — asserts at +/// the production seam (the tile's `_DmAvatar`), not the label helper. +String _dmTileAvatarInitial(WidgetTester tester, String labelText) { + final tile = _dmTileFor(labelText); + final avatar = find.descendant(of: tile, matching: find.byType(AvatarImage)); + final initial = tester.widget( + find.descendant(of: avatar, matching: find.byType(Text)), + ); + return initial.data!; +} diff --git a/mobile/test/features/channels/compose_bar_test.dart b/mobile/test/features/channels/compose_bar_test.dart index 23b19d9557..b8025112a2 100644 --- a/mobile/test/features/channels/compose_bar_test.dart +++ b/mobile/test/features/channels/compose_bar_test.dart @@ -26,7 +26,9 @@ import 'package:buzz/shared/custom_emoji/custom_emoji_provider.dart'; import 'package:buzz/shared/mentions/agent_identity_provider.dart'; import 'package:buzz/shared/relay/relay.dart'; import 'package:buzz/shared/theme/theme.dart'; +import 'package:buzz/shared/utils/string_utils.dart'; import 'package:buzz/shared/widgets/anchored_popover_menu.dart'; +import 'package:buzz/shared/widgets/avatar_image.dart'; import 'package:buzz/shared/widgets/mobile_tab_footer_backdrop.dart'; import 'package:shared_preferences/shared_preferences.dart'; @@ -1643,6 +1645,12 @@ void main() { addTearDown(() { if (!pendingMembers.isCompleted) pendingMembers.complete(const []); }); + // Valid fixture keys whose npub encodings were verified against the + // NIP-19 codec independently of the code under test. + const a11ce = + 'a11ce00000000000000000000000000000000000000000000000000000000000'; + const b0b = + 'b0b0000000000000000000000000000000000000000000000000000000000000'; await tester.pumpWidget( _buildComposeBar( uploadService: _testUploadService(nostr.Keys.generate().nsec), @@ -1654,6 +1662,17 @@ void main() { joinedAt: DateTime.fromMillisecondsSinceEpoch(1000), displayName: 'Alice', ), + ChannelMember( + pubkey: a11ce, + role: 'member', + joinedAt: DateTime.fromMillisecondsSinceEpoch(2000), + ), + ChannelMember( + pubkey: b0b, + role: 'member', + joinedAt: DateTime.fromMillisecondsSinceEpoch(3000), + displayName: 'Carol', + ), ], channels: [_makeCurrentChannel()], onSend: @@ -1675,6 +1694,13 @@ void main() { findsOneWidget, ); expect(find.text('Alice'), findsOneWidget); + // The unnamed member renders its compact npub label, but its avatar + // initial stays keyed to the hex public key — never the `N` the npub + // label starts with. A named member keeps its authored initial even + // though its key would render `B`. + expect(find.text(shortPubkey(a11ce)), findsOneWidget); + expect(_suggestionAvatarInitial(tester, shortPubkey(a11ce)), 'A'); + expect(_suggestionAvatarInitial(tester, 'Carol'), 'C'); }); testWidgets('dismisses mention suggestions in the selection frame', ( @@ -5555,6 +5581,20 @@ List<({String text, TextStyle style})> _flattenStyledTextSpans( return result; } +/// Avatar fallback initial rendered for the mention suggestion row titled +/// [labelText] — asserts at the production seam, not the model getter. +String _suggestionAvatarInitial(WidgetTester tester, String labelText) { + final row = find.ancestor( + of: find.text(labelText), + matching: find.byType(ListTile), + ); + final avatar = find.descendant(of: row, matching: find.byType(AvatarImage)); + final initial = tester.widget( + find.descendant(of: avatar, matching: find.byType(Text)), + ); + return initial.data!; +} + Channel _makeCurrentChannel({ String channelType = 'stream', String visibility = 'open', diff --git a/mobile/test/features/channels/mentions/mention_candidates_test.dart b/mobile/test/features/channels/mentions/mention_candidates_test.dart index 9694d68535..9a0b655f4b 100644 --- a/mobile/test/features/channels/mentions/mention_candidates_test.dart +++ b/mobile/test/features/channels/mentions/mention_candidates_test.dart @@ -19,8 +19,9 @@ ChannelMember member(String pubkey, {String role = 'member'}) { } void main() { - test('role-only agent mentions fall back to a pubkey prefix label', () { - const pubkey = 'deadbeef0123456789'; + test('role-only agent mentions fall back to a compact npub label', () { + const pubkey = + 'deadbeef00000000000000000000000000000000000000000000000000000000'; expect( mentionNamesWithDirectoryLabels( @@ -29,7 +30,7 @@ void main() { directoryDisplayNames: const {}, agentMentionPubkeys: const {pubkey}, ), - const {pubkey: 'deadbeef'}, + const {pubkey: 'npub1m6k\u20263kf3'}, ); }); @@ -61,14 +62,14 @@ void main() { expect(formatOwnerLabel(userPubkey, userPubkey, const {}), 'you'); }); - test('prefers display name, then handle, then pubkey prefix', () { + test('prefers display name, then handle, then compact npub', () { final profiles = { ownerPubkey: UserProfile(pubkey: ownerPubkey, displayName: 'Wes'), }; expect(formatOwnerLabel(ownerPubkey, userPubkey, profiles), 'Wes'); expect( formatOwnerLabel(ownerPubkey, userPubkey, const {}), - '${'d' * 8}\u2026', + 'npub1mhw\u2026dmpv', ); }); diff --git a/mobile/test/features/forum/forum_widgets_test.dart b/mobile/test/features/forum/forum_widgets_test.dart index 338dbf36ac..2d22026530 100644 --- a/mobile/test/features/forum/forum_widgets_test.dart +++ b/mobile/test/features/forum/forum_widgets_test.dart @@ -208,13 +208,18 @@ void main() { expect(find.text('Hello forum'), findsOneWidget); }); - testWidgets('shows truncated pubkey when no profile', (tester) async { + testWidgets('shows compact npub when no profile', (tester) async { await tester.pumpWidget( - _buildPostCard(post: _makePost(pubkey: 'abcdef1234567890')), + _buildPostCard( + post: _makePost( + pubkey: + 'abcdef0000000000000000000000000000000000000000000000000000000000', + ), + ), ); await tester.pumpAndSettle(); - expect(find.text('abcdef12\u2026'), findsOneWidget); + expect(find.text('npub140x\u2026etzk'), findsOneWidget); }); testWidgets('uses directory classification for uncached author avatar', ( diff --git a/mobile/test/features/invites/invite_create_page_test.dart b/mobile/test/features/invites/invite_create_page_test.dart index 9aecfd10e5..5e6970151c 100644 --- a/mobile/test/features/invites/invite_create_page_test.dart +++ b/mobile/test/features/invites/invite_create_page_test.dart @@ -5,6 +5,7 @@ import 'package:buzz/features/invites/invite_create_provider.dart'; import 'package:buzz/shared/community/community_membership_provider.dart'; import 'package:buzz/shared/relay/relay.dart'; import 'package:buzz/shared/theme/theme.dart'; +import 'package:buzz/shared/utils/string_utils.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -162,7 +163,7 @@ void main() { find.byKey(const Key('community-invite-resolved-$pastedPubkey')), findsOneWidget, ); - expect(find.text(shortCommunityInviteNpub(pastedPubkey)), findsOneWidget); + expect(find.text(shortPubkey(pastedPubkey)), findsOneWidget); expect(find.text('Resolved person'), findsNothing); expect(find.text('person@example.com'), findsNothing); expect( @@ -270,7 +271,7 @@ void main() { findsOneWidget, ); expect(find.text('Profile not found'), findsNothing); - expect(find.text(shortCommunityInviteNpub(pastedPubkey)), findsOneWidget); + expect(find.text(shortPubkey(pastedPubkey)), findsOneWidget); expect(find.byKey(const Key('community-invite-submit')), findsOneWidget); await tester.tap(find.byKey(const Key('community-invite-submit'))); diff --git a/mobile/test/features/profile/profile_provider_test.dart b/mobile/test/features/profile/profile_provider_test.dart index 5638656b71..93c68cfa20 100644 --- a/mobile/test/features/profile/profile_provider_test.dart +++ b/mobile/test/features/profile/profile_provider_test.dart @@ -7,6 +7,7 @@ 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:buzz/shared/utils/string_utils.dart'; import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; @@ -106,7 +107,9 @@ void main() { expect(content, {'about': 'Building Buzz'}); final profile = container.read(profileProvider).requireValue!; expect(profile.displayName, isNull); - expect(profile.label, '${keys.public.substring(0, 8)}...'); + // The exact compact label is pinned; its npub shape is covered by the + // shared string_utils vectors. + expect(profile.label, shortPubkey(keys.public)); }); test('malformed profile metadata can be repaired by an edit', () async { diff --git a/mobile/test/features/pulse/compose_note_page_test.dart b/mobile/test/features/pulse/compose_note_page_test.dart index 7b2e7480ac..d0650a5bc5 100644 --- a/mobile/test/features/pulse/compose_note_page_test.dart +++ b/mobile/test/features/pulse/compose_note_page_test.dart @@ -6,6 +6,7 @@ import 'package:buzz/shared/profile/user_profile.dart'; import 'package:buzz/features/pulse/compose_note_page.dart'; import 'package:buzz/features/pulse/pulse_models.dart'; import 'package:buzz/shared/theme/theme.dart'; +import 'package:buzz/shared/widgets/avatar_image.dart'; class _FakeUserCacheNotifier extends UserCacheNotifier { final Map _users; @@ -13,6 +14,9 @@ class _FakeUserCacheNotifier extends UserCacheNotifier { @override Map build() => _users; + + @override + UserProfile? get(String pubkey) => _users[pubkey.toLowerCase()]; } void main() { @@ -28,16 +32,20 @@ void main() { Widget home, { TextScaler textScaler = TextScaler.noScaling, String displayName = 'Alice', + Map? users, }) { return ProviderScope( overrides: [ userCacheProvider.overrideWith( - () => _FakeUserCacheNotifier({ - 'alice_pk': UserProfile( - pubkey: 'alice_pk', - displayName: displayName, - ), - }), + () => _FakeUserCacheNotifier( + users ?? + { + 'alice_pk': UserProfile( + pubkey: 'alice_pk', + displayName: displayName, + ), + }, + ), ), ], child: MaterialApp( @@ -62,6 +70,8 @@ void main() { expect(find.text('Alice'), findsOneWidget); // author name in the row expect(find.textContaining('original note being replied to'), findsWidgets); expect(find.text('Reply'), findsOneWidget); // action button label + // Named parent: the preview avatar initial comes from the authored name. + expect(_replyPreviewAvatarInitial(tester, 'Replying to Alice'), 'A'); }); testWidgets('reply preview constrains its timestamp at large text sizes', ( @@ -167,4 +177,50 @@ void main() { final dividerTop = tester.getRect(find.byType(Divider)).top; expect(dividerTop - labelTop, lessThan(220)); }); + + testWidgets('reply preview keys unnamed parent authors to their hex key', ( + tester, + ) async { + const a11ce = + 'a11ce00000000000000000000000000000000000000000000000000000000000'; + + final unnamedParentNote = UserNote( + id: 'note-unnamed-parent', + pubkey: a11ce, + createdAt: DateTime.now().millisecondsSinceEpoch ~/ 1000 - 120, + content: 'The original note being replied to', + tags: const [], + ); + + await tester.pumpWidget( + buildTestable( + ComposeNotePage(replyTo: unnamedParentNote), + users: const {}, + ), + ); + await tester.pump(); + + expect(find.text('Replying to npub15yw\u2026ccpw'), findsOneWidget); + expect( + _replyPreviewAvatarInitial(tester, 'Replying to npub15yw\u2026ccpw'), + 'A', + ); + }); +} + +/// Avatar fallback initial in the reply-context preview, located by the +/// `Replying to` label it renders beside — the parent author's avatar, not +/// the composer's. +String _replyPreviewAvatarInitial(WidgetTester tester, String replyingLabel) { + final preview = find + .ancestor(of: find.text(replyingLabel), matching: find.byType(Column)) + .first; + final avatar = find.descendant( + of: preview, + matching: find.byType(AvatarImage), + ); + final initial = tester.widget( + find.descendant(of: avatar, matching: find.byType(Text)), + ); + return initial.data!; } diff --git a/mobile/test/features/pulse/note_card_test.dart b/mobile/test/features/pulse/note_card_test.dart index 2b59d1838d..baa4fbe2dd 100644 --- a/mobile/test/features/pulse/note_card_test.dart +++ b/mobile/test/features/pulse/note_card_test.dart @@ -3,6 +3,7 @@ import 'package:buzz/shared/profile/user_profile.dart'; import 'package:buzz/features/pulse/note_card.dart'; import 'package:buzz/features/pulse/pulse_models.dart'; import 'package:buzz/shared/theme/theme.dart'; +import 'package:buzz/shared/widgets/avatar_image.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; @@ -127,4 +128,170 @@ void main() { expect(find.text('2m'), findsOneWidget); expect(tester.takeException(), isNull); }); + + testWidgets( + 'reply targets and author identities render npub keys, hex event ids, ' + 'and keyed avatar initials', + (tester) async { + const b0b = + 'b0b0000000000000000000000000000000000000000000000000000000000000'; + const a11ce = + 'a11ce00000000000000000000000000000000000000000000000000000000000'; + const parentEventId = + 'feedbeef00000000000000000000000000000000000000000000000000000000'; + + UserNote noteOf(String id, List> tags) => UserNote( + id: id, + pubkey: b0b, + createdAt: DateTime.utc(2025, 9, 30, 12).millisecondsSinceEpoch ~/ 1000, + content: 'A reply', + tags: tags, + ); + + final scenarios = + < + ({ + String id, + List> tags, + Map users, + String authorLabel, + String? replyLabel, + int npubLabelCount, + String avatarInitial, + }) + >[ + // `p`-tagged parent: the reply target is a public key, so it renders + // the compact npub — a second npub beside the unnamed author's own. + ( + id: 'reply-parent-author', + tags: [ + ['e', parentEventId, '', 'reply'], + ['p', a11ce], + ], + users: const {}, + authorLabel: 'npub1kzc\u2026uyv8', + replyLabel: 'Replying to npub15yw\u2026ccpw', + npubLabelCount: 2, + avatarInitial: 'B', + ), + // No `p` tag — the parent author is unknown, so the reply target + // falls back to the parent event id, which is not a public key: it + // keeps its hex truncation and stays out of the npub contract. + ( + id: 'reply-parent-event', + tags: [ + ['e', parentEventId, '', 'reply'], + ], + users: const {}, + authorLabel: 'npub1kzc\u2026uyv8', + replyLabel: 'Replying to feedbeef\u2026', + npubLabelCount: 1, + avatarInitial: 'B', + ), + // Cached author: the authored name wins for both the label and the + // avatar initial. + ( + id: 'cached-named-author', + tags: const [], + users: { + b0b: const UserProfile(pubkey: b0b, displayName: 'Carol'), + }, + authorLabel: 'Carol', + replyLabel: null, + npubLabelCount: 0, + avatarInitial: 'C', + ), + // Cached blank display names (empty or whitespace-only — relay + // profiles can carry both) must fall back to the compact npub + // for the label instead of rendering an empty author row. + ( + id: 'cached-blank-name-author', + tags: const [], + users: {b0b: const UserProfile(pubkey: b0b, displayName: '')}, + authorLabel: 'npub1kzc\u2026uyv8', + replyLabel: null, + npubLabelCount: 1, + avatarInitial: 'B', + ), + ( + id: 'cached-whitespace-name-author', + tags: const [], + users: {b0b: const UserProfile(pubkey: b0b, displayName: ' ')}, + authorLabel: 'npub1kzc\u2026uyv8', + replyLabel: null, + npubLabelCount: 1, + avatarInitial: 'B', + ), + ]; + + for (final scenario in scenarios) { + // Each scenario remounts its own ProviderScope: Riverpod keeps an + // updated scope's container, so an already-initialized user cache + // would otherwise serve the first scenario's overrides. + await tester.pumpWidget( + KeyedSubtree( + key: ValueKey(scenario.id), + child: ProviderScope( + overrides: [ + userCacheProvider.overrideWith( + () => _FakeUserCacheNotifier(scenario.users), + ), + ], + child: MaterialApp( + theme: AppTheme.light(), + home: Scaffold( + body: NoteCard( + note: noteOf(scenario.id, scenario.tags), + reaction: const PulseReactionState( + count: 0, + reactedByCurrentUser: false, + ), + ), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + expect( + find.text(scenario.authorLabel), + findsOneWidget, + reason: scenario.id, + ); + expect( + find.textContaining('npub'), + findsNWidgets(scenario.npubLabelCount), + reason: scenario.id, + ); + if (scenario.replyLabel != null) { + expect( + find.text(scenario.replyLabel!), + findsOneWidget, + reason: scenario.id, + ); + } + // Unnamed authors key the avatar initial to the hex public key + // (never the `N` every npub label starts with); a cached profile + // switches it to the authored-name initial. + expect( + _noteCardAvatarInitial(tester), + scenario.avatarInitial, + reason: scenario.id, + ); + expect(tester.takeException(), isNull, reason: scenario.id); + } + }, + ); +} + +/// Avatar fallback initial of the single rendered [NoteCard] — asserts at +/// the production seam (the rendered card), not the model getter. +String _noteCardAvatarInitial(WidgetTester tester) { + final card = find.byType(NoteCard); + final avatar = find.descendant(of: card, matching: find.byType(AvatarImage)); + final initial = tester.widget( + find.descendant(of: avatar, matching: find.byType(Text)), + ); + return initial.data!; } diff --git a/mobile/test/features/settings/connection_section_test.dart b/mobile/test/features/settings/connection_section_test.dart index b8e3464c2e..2eb8304fe5 100644 --- a/mobile/test/features/settings/connection_section_test.dart +++ b/mobile/test/features/settings/connection_section_test.dart @@ -53,9 +53,27 @@ void main() { final expectedPubkey = nostr.Keys( '1111111111111111111111111111111111111111111111111111111111111111', ).public; + // Keys('1'×64).public ↔ this npub — the NIP-19 canonical vector for the + // identity row, hardcoded so the codec itself stays under test. + const expectedNpub = + 'npub1fu64hh9hes90w2808n8tjc2ajp5yhddjef0ctx4s7zmsgp6cwx4qgy4eg9'; expect(find.text('Connected to'), findsNothing); expect(find.text('https://relay.test'), findsNothing); + // Neither the raw hex key nor the full npub is rendered visually — the + // full npub is exposed through a11y and the clipboard only. expect(find.text(expectedPubkey), findsNothing); + expect(find.text(expectedNpub), findsNothing); + // The identity row's a11y value carries the full npub (not raw hex). + expect( + find.ancestor( + of: find.text('Identity (pubkey)'), + matching: find.byWidgetPredicate( + (widget) => + widget is Semantics && widget.properties.value == expectedNpub, + ), + ), + findsOneWidget, + ); final copy = tester.getRect(find.byIcon(LucideIcons.copy)); final chevron = tester.getRect(find.byIcon(LucideIcons.chevronRight).first); expect(copy.center.dx, closeTo(chevron.center.dx, 0.5)); @@ -63,7 +81,7 @@ void main() { await tester.tap(find.text('Identity (pubkey)')); await tester.pump(); expect(clipboardCall?.method, 'Clipboard.setData'); - expect(clipboardCall?.arguments, {'text': expectedPubkey}); + expect(clipboardCall?.arguments, {'text': expectedNpub}); expect(find.text('Pubkey copied'), findsOneWidget); }); diff --git a/mobile/test/shared/profile/user_profile_test.dart b/mobile/test/shared/profile/user_profile_test.dart new file mode 100644 index 0000000000..a2e9117936 --- /dev/null +++ b/mobile/test/shared/profile/user_profile_test.dart @@ -0,0 +1,36 @@ +import 'package:buzz/shared/profile/user_profile.dart'; +import 'package:buzz/shared/utils/string_utils.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('UserProfile label and initial', () { + // Valid fixture key whose npub encoding was verified against the NIP-19 + // codec independently of the code under test. + const b0b = + 'b0b0000000000000000000000000000000000000000000000000000000000000'; + + // Relay profiles can carry a blank `display_name` (empty or + // whitespace-only) and reach the cache unchanged, so both accessors must + // reject blank names and fall back to the key instead of rendering an + // empty label. Nonblank names render as authored: `label` only tests + // blankness with trim, while `initial` reads the trimmed padding. + final cases = <({String? displayName, String label, String initial})>[ + (displayName: null, label: shortPubkey(b0b), initial: 'B'), + (displayName: '', label: shortPubkey(b0b), initial: 'B'), + (displayName: ' ', label: shortPubkey(b0b), initial: 'B'), + (displayName: 'Carol', label: 'Carol', initial: 'C'), + (displayName: ' Carol ', label: ' Carol ', initial: 'C'), + ]; + + for (final testCase in cases) { + test('displayName ${testCase.displayName ?? '(null)'}', () { + final profile = UserProfile( + pubkey: b0b, + displayName: testCase.displayName, + ); + expect(profile.label, testCase.label); + expect(profile.initial, testCase.initial); + }); + } + }); +} diff --git a/mobile/test/shared/utils/string_utils_test.dart b/mobile/test/shared/utils/string_utils_test.dart new file mode 100644 index 0000000000..f5188c2a68 --- /dev/null +++ b/mobile/test/shared/utils/string_utils_test.dart @@ -0,0 +1,90 @@ +import 'package:buzz/features/invites/invite_create_provider.dart'; +import 'package:buzz/shared/utils/string_utils.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:nostr/nostr.dart' as nostr; + +/// Tests for the npub identity helpers shared by every mobile surface. +/// +/// The canonical vector comes from the NIP-19 specification itself, so these +/// tests fail if the encoding (or the truncation policy) drifts — they never +/// re-derive expectations from the codec under test. +void main() { + const canonicalHex = + '3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d'; + const canonicalNpub = + 'npub180cvv07tjdrrgpa0j7j7tmnyl2yr6yr7l8j4s3evf6u64th6gkwsyjh6w6'; + const canonicalCompact = 'npub180c\u2026h6w6'; + + group('fullNpub', () { + test('canonicalizes every accepted input wrapper', () { + // Every accepted wrapper — lowercase hex, uppercase hex, whitespace- + // padded, and an already-npub key — converges on the canonical npub. + final inputs = [ + canonicalHex, + canonicalHex.toUpperCase(), + ' $canonicalHex \n', + canonicalNpub, + ]; + for (final input in inputs) { + expect(fullNpub(input), canonicalNpub, reason: input); + } + }); + + test('rejects malformed identities without echoing them back', () { + expect(fullNpub(''), isNull); + expect(fullNpub('unknown'), isNull); + // Wrong-length hex. + expect(fullNpub('a' * 63), isNull); + expect(fullNpub('a' * 65), isNull); + // Hex-length but not hex. + expect(fullNpub('z' * 64), isNull); + // Not decodable bech32. + expect(fullNpub('npub1garbage'), isNull); + // Mixed-case bech32 is invalid by checksum. + final mixedCase = + '${canonicalNpub.substring(0, 20)}' + '${canonicalNpub.substring(20).toUpperCase()}'; + expect(fullNpub(mixedCase), isNull); + // Valid NIP-19 forms that are not public keys stay out of the npub + // contract instead of leaking their payload. + final nsec = nostr.Nip19.encode( + prefix: nostr.Nip19Prefix.nsec, + data: canonicalHex, + ); + expect(fullNpub(nsec), isNull); + final note = nostr.Nip19.encode( + prefix: nostr.Nip19Prefix.note, + data: canonicalHex, + ); + expect(fullNpub(note), isNull); + }); + }); + + group('shortPubkey', () { + test('renders the compact npub label — first 8 … last 4', () { + expect(shortPubkey(canonicalHex), canonicalCompact); + }); + + test('is idempotent for already-npub input', () { + expect(shortPubkey(canonicalNpub), canonicalCompact); + }); + + test('returns the neutral label for invalid identities', () { + expect(shortPubkey(''), unknownIdentityLabel); + expect(shortPubkey('not-a-key'), unknownIdentityLabel); + expect(shortPubkey('npub1garbage'), unknownIdentityLabel); + expect(shortPubkey('n' * 64), unknownIdentityLabel); + }); + }); + + group('copy to paste roundtrip', () { + test('a copied npub parses back to the original hex key', () { + final copied = fullNpub(canonicalHex); + // The invite input accepts what the profile/settings copy actions put + // on the clipboard — the mobile copy/paste contract. + expect(parseCommunityInvitePubkey(copied!), canonicalHex); + // Hex input keeps working unchanged. + expect(parseCommunityInvitePubkey(canonicalHex), canonicalHex); + }); + }); +}