mirror of
https://github.com/block/buzz.git
synced 2026-10-27 04:30:52 +00:00
fix(mobile): standardize public-key identity display on npub (#7493)
🤖 ## Summary In the mobile app, anyone who hasn't set a display name shows up as a raw 64-character hex key (e.g. `3a5d4f9c…`) — unreadable, and unrecognizable as the same identity across screens. Profile and Settings also let you copy that raw hex. Nostr public keys have a standard readable form — `npub1…`, the same encoding other Nostr apps and our desktop app already display. This PR makes every mobile identity surface render npub instead: - **Unnamed people everywhere** — message and thread authors, reactions, typing indicators, member lists, channel details, DM headers and tiles, inbox, search, forum cards, Pulse notes and reply context, mention suggestions, and invite rows — now show a compact npub label: first 8 + last 4 characters of the full npub joined by an ellipsis (`npub1abcd…wxyz`), the same truncation desktop uses. Previously these showed truncated raw hex. - **DM fallback avatars and blank names** — 1:1 DM tiles and headers key their fallback avatar to the same non-self counterpart the label names, including self-first participant order; a self-DM keeps its hex-key-derived initial. Blank or whitespace-only display names fall back to the compact npub instead of rendering empty, while nonblank authored names render verbatim (padding included). - **Profile sheet → "Copy public key"** now copies the full canonical npub — never raw hex. When the identity string isn't a valid public key, the copy tile is disabled, so a malformed key never reaches the clipboard. - **Settings → Identity (pubkey)** displays and copies the full npub; an invalid identity reads "Identity unavailable" with copy disabled. - **Invalid identities never leak truncated raw hex** into the UI anywhere — they render a neutral "Unknown identity" label. - **Unchanged on purpose:** display names and verified handles (NIP-05 — the `name@domain` badge) still render as before. Unnamed avatars keep distinct per-key initials, derived from the underlying hex key rather than the npub — otherwise every unnamed key would render the same "N" initial. Event IDs are not public keys, so they keep their hex truncation (in Pulse's "Replying to", the parent author shows npub while an event-id fallback still shows hex). The nevent share link, private keys, and internal hex storage are untouched. Inputs that accept a key (invite/member entry) accept both hex and npub and keep working in hex internally. ### Related issue N/A. Searched open issues/PRs for npub identity display on mobile — closest related: none found. Desktop's parallel npub standardization lives in the stacked desktop PRs (#7488 foundation, #7489 controls, #7495 display surfaces); this is the independent mobile slice (based directly on `main`, not on those branches). ### Testing At exact head `5a620e420a1fd57d9d8011ac26434eed32fcf765` (base: `main` `44316ff72`; 40 files, +1,345/−154): - Full mobile suite: 2,098 tests passing (`cd mobile && flutter test`); `flutter analyze` clean; `dart format --set-exit-if-changed .` clean — the same checks CI runs. - Widget/unit coverage at production seams: compact labels and hex-keyed avatar initials for DM headers/tiles, member rows, mention suggestions, and Pulse reply context; DM fallback avatars keyed to the labeled counterpart (self-first order and self-DMs); blank/whitespace display-name npub fallback with nonblank authored labels verbatim, including the Activity inbox sender and profile-sheet heading (each with its own empty/whitespace production-seam regression); full-npub copy and disabled-copy semantics in profile and settings; invalid-key suppression; and hex↔npub input round-trips. Verified via unit and widget tests — no device/simulator validation is claimed. ### Task provenance Buzz channel: `1f0e4a3d-7e01-4efe-bb16-843b357f85c9` Task: buzz://message?channel=1f0e4a3d-7e01-4efe-bb16-843b357f85c9&id=86b34eb4bd84a1472419e9af22636c011c0fe273e3c196f967d7a36996e149b6 --------- Signed-off-by: Logan Johnson <loganj@squareup.com> Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz>
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<Channel> sortDmChannelsByDisplayLabel(
|
||||
Iterable<Channel> channels, {
|
||||
String? currentPubkey,
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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() : '?';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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<String, String> _buildMentionNames(
|
||||
List<String> mentionPubkeys,
|
||||
Map<String, UserProfile> userCache,
|
||||
|
||||
@@ -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<String, String> _buildMentionNames(
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
String _shortPubkey(String pubkey) {
|
||||
if (pubkey.length > 12) return '${pubkey.substring(0, 8)}\u2026';
|
||||
return pubkey;
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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() : '?';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<void> 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,
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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)}…';
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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');
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<String, String> 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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)}';
|
||||
}
|
||||
|
||||
@@ -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 = <String, UserProfile>{
|
||||
'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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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<Text>(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<Text>(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 = <String>[];
|
||||
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<String, UserProfile> 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<GestureDetector>(disabledCopyAction).onTap, isNull);
|
||||
final copySemantics = find
|
||||
.ancestor(
|
||||
of: find.text('Copy public key'),
|
||||
matching: find.byType(Semantics),
|
||||
)
|
||||
.first;
|
||||
expect(
|
||||
tester.widget<Semantics>(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<Text>(
|
||||
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<Text>(
|
||||
find.descendant(of: avatar, matching: find.byType(Text)),
|
||||
);
|
||||
return initial.data!;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<double>? onSettingsTransitionProgress,
|
||||
ValueListenable<int>? 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<UserProfile?> 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<Text>(
|
||||
find.descendant(of: avatar, matching: find.byType(Text)),
|
||||
);
|
||||
return initial.data!;
|
||||
}
|
||||
|
||||
@@ -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<Text>(
|
||||
find.descendant(of: avatar, matching: find.byType(Text)),
|
||||
);
|
||||
return initial.data!;
|
||||
}
|
||||
|
||||
Channel _makeCurrentChannel({
|
||||
String channelType = 'stream',
|
||||
String visibility = 'open',
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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', (
|
||||
|
||||
@@ -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')));
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<String, UserProfile> _users;
|
||||
@@ -13,6 +14,9 @@ class _FakeUserCacheNotifier extends UserCacheNotifier {
|
||||
|
||||
@override
|
||||
Map<String, UserProfile> 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<String, UserProfile>? 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<Text>(
|
||||
find.descendant(of: avatar, matching: find.byType(Text)),
|
||||
);
|
||||
return initial.data!;
|
||||
}
|
||||
|
||||
@@ -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<List<String>> 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<List<String>> tags,
|
||||
Map<String, UserProfile> 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<Text>(
|
||||
find.descendant(of: avatar, matching: find.byType(Text)),
|
||||
);
|
||||
return initial.data!;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
36
mobile/test/shared/profile/user_profile_test.dart
Normal file
36
mobile/test/shared/profile/user_profile_test.dart
Normal file
@@ -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);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
90
mobile/test/shared/utils/string_utils_test.dart
Normal file
90
mobile/test/shared/utils/string_utils_test.dart
Normal file
@@ -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 = <String>[
|
||||
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);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user