feat(mobile): show contextual names in lists, Search and Pulse (#7896)

🤖
## Summary

This PR finishes the change on the remaining mobile screens. After it,
every place that names a person or agent uses a contextual name:

- The channel list, DM labels, and the new-DM picker
- The channel header and details, and the members and add-members sheets
- Huddle avatars, spotlight, and overlay
- The Activity inbox
- Search results for people and messages
- Pulse notes, agent cards, and reply previews

Screens that belong to a channel compare names against its members.
Search, Pulse, and the new-DM picker have no channel, so they compare
the identities shown together. In the example below, Search showed four
results all named "murderbot". Now the human keeps the plain name, and
baxen's three agents are labeled by owner and key suffix.

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

### Related issue

Related to #2910. The desktop search change in #6483 addresses the same
problem in a different app.

### Testing

- Widget tests cover the Activity inbox, Search, and Pulse notes with
colliding names.
- `flutter analyze` is clean, and the full `flutter test` suite passes.
- Checked on an Android emulator against the live relay by searching for
"murderbot". The after image also shows "classy-murderbot", a new
account that was created between the two captures.

| Before | After |
| --- | --- |
| <img width="300" alt="Before: Search shows four people all named
murderbot"
src="https://github.com/user-attachments/assets/21bbcc52-fea8-4b31-ab8c-ff3ec9b32a39"
/> | <img width="300" alt="After: three results show as baxen's
murderbot with a key suffix, and the human shows as murderbot"
src="https://github.com/user-attachments/assets/d59584d9-4a35-46da-b39a-83addfad3cd2"
/> |

---------

Signed-off-by: Logan Johnson <loganj@squareup.com>
Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz>
This commit is contained in:
Logan Johnson
2026-09-30 10:46:14 -04:00
committed by GitHub
parent 2664d14316
commit 7a1910e700
32 changed files with 715 additions and 60 deletions

View File

@@ -7,6 +7,7 @@ import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../../shared/identity_names/identity_names_provider.dart';
import '../../shared/mentions/agent_identity_provider.dart';
import '../../shared/mentions/mention_tags.dart';
import '../../shared/relay/relay.dart';
@@ -22,6 +23,7 @@ import '../../shared/widgets/message_author_meta.dart';
import '../../shared/widgets/modal_presentation.dart';
import '../channels/channel.dart';
import '../channels/channel_detail_page.dart';
import '../channels/channel_identity_names_provider.dart';
import '../channels/channel_management_provider.dart';
import '../channels/channels_provider.dart';
import '../channels/dm_channel_labels.dart';

View File

@@ -82,7 +82,26 @@ class _InboxRow extends HookConsumerWidget {
final profile = profiles[senderPubkey];
// The shared label contract: blank cached names (empty or whitespace-only
// are relay-valid) fall back to the compact npub, never a blank sender.
final senderLabel = profile?.label ?? shortPubkey(item.item.pubkey);
// Rows compare names within their own channel, like the channel itself.
final channelId = channel?.id ?? item.item.channelId;
final Map<String, String> contextualLabels;
if (channelId == null) {
// No channel: the row's own identities are the comparison context.
final names = watchIdentityNames(ref, relevantPubkeys);
contextualLabels = {
for (final key in names.candidates) key: names.labelFor(key),
};
} else {
contextualLabels = watchChannelIdentityLabels(
ref,
channelId,
relevantPubkeys,
);
}
final senderLabel =
contextualLabels[senderPubkey] ??
profile?.label ??
shortPubkey(item.item.pubkey);
final profileMentionNames = {
for (final pubkey in mentionPubkeys)
if (profiles[pubkey]?.displayName?.trim().isNotEmpty == true)
@@ -325,6 +344,7 @@ class _InboxRow extends HookConsumerWidget {
MessageContent(
content: item.item.displayContent,
mentionNames: mentionNames,
mentionLabels: contextualLabels,
agentMentionPubkeys: agentMentionPubkeys,
tags: item.item.tags,
maxLines: 2,

View File

@@ -77,7 +77,7 @@ class _RemindersList extends ConsumerWidget {
/// Drafts surface for the Drafts filter — locally saved unsent composer
/// text that reopens the target composer.
class _DraftsList extends StatelessWidget {
class _DraftsList extends ConsumerWidget {
final List<ComposeDraft> drafts;
final ScrollController scrollController;
final Map<String, Channel> channelById;
@@ -95,7 +95,8 @@ class _DraftsList extends StatelessWidget {
});
@override
Widget build(BuildContext context) {
Widget build(BuildContext context, WidgetRef ref) {
final names = ref.watch(identityNameSourcesProvider);
if (drafts.isEmpty) {
return const _EmptySurface(
icon: LucideIcons.filePen,
@@ -114,7 +115,11 @@ class _DraftsList extends StatelessWidget {
final destination = channel == null
? 'Unavailable channel'
: channel.isDm
? resolveDmChannelDisplayLabel(channel, currentPubkey: myPubkey)
? resolveDmChannelDisplayLabel(
channel,
currentPubkey: myPubkey,
names: names,
)
: '#${channel.name}';
return ListTile(
key: ValueKey('draft-row-${draft.key}'),

View File

@@ -8,6 +8,7 @@ import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../../shared/theme/theme.dart';
import '../../shared/widgets/avatar_image.dart';
import '../../shared/widgets/buzz_loading_indicator.dart';
import '../../shared/identity_names/identity_names_provider.dart';
import 'channel_management_provider.dart';
/// Searchable multi-select used to add people or agents to a channel.
@@ -56,6 +57,30 @@ class AddChannelMembersSheet extends HookConsumerWidget {
)
.toList() ??
const <DirectoryUser>[];
// Compare every shown choice with the channel's members, so a candidate
// who shares a member's name is told apart before being added. Members
// are only compared: their names come from the roster the details page
// already loaded, so only the shown choices' profiles are fetched.
final choices = [...availableUsers, ...selectedUsers.value];
final roster =
ref.watch(channelMembersProvider(channelId)).asData?.value ??
const <ChannelMember>[];
final names = watchIdentityNames(
ref,
[...normalizedExisting, for (final user in choices) user.pubkey],
agentPubkeys: {
for (final member in roster)
if (member.isBot) member.pubkey,
for (final user in choices)
if (user.isAgent) user.pubkey,
},
fallbackNames: {
for (final member in roster) member.pubkey: ?member.displayName,
for (final user in choices) user.pubkey: user.label,
},
shown: [for (final user in choices) user.pubkey],
);
String labelFor(DirectoryUser user) => names.labelFor(user.pubkey);
void toggleUser(DirectoryUser user) {
if (isSubmitting.value) return;
@@ -145,7 +170,7 @@ class AddChannelMembersSheet extends HookConsumerWidget {
key: ValueKey(
'add-channel-member-selected-${user.pubkey}',
),
label: Text(user.label),
label: Text(labelFor(user)),
onDeleted: isSubmitting.value
? null
: () => toggleUser(user),
@@ -181,8 +206,8 @@ class AddChannelMembersSheet extends HookConsumerWidget {
button: true,
selected: selected,
label: selected
? '${user.label}, selected'
: user.label,
? '${labelFor(user)}, selected'
: labelFor(user),
child: ListTile(
contentPadding: EdgeInsets.zero,
leading: AvatarImage(
@@ -194,7 +219,7 @@ class AddChannelMembersSheet extends HookConsumerWidget {
isAgent: user.isAgent,
),
title: Text(
user.label,
labelFor(user),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),

View File

@@ -22,6 +22,7 @@ import '../../shared/widgets/modal_presentation.dart';
import '../../shared/widgets/sheet_divider.dart';
import 'channel.dart';
import 'add_members_sheet.dart';
import 'channel_identity_names_provider.dart';
import 'channel_management_provider.dart';
import 'channel_mutes/channel_mutes_provider.dart';
import 'channel_sections/channel_sections_provider.dart';

View File

@@ -14,6 +14,8 @@ import 'package:scrollable_positioned_list/scrollable_positioned_list.dart';
import '../../shared/animated_avatar.dart';
import '../../shared/emoji/emoji_burst.dart';
import '../../shared/huddle/huddle.dart';
import '../../shared/identity_names/identity_names.dart';
import '../../shared/identity_names/identity_names_provider.dart';
import '../../shared/mentions/agent_identity_provider.dart';
import '../../shared/relay/relay.dart';
import '../../shared/theme/theme.dart';

View File

@@ -306,9 +306,14 @@ class _DmAppBarTitle extends ConsumerWidget {
children: [
Flexible(
child: Text(
resolveDmChannelDisplayLabel(
channel,
currentPubkey: currentPubkey,
ref.watch(
identityNameSourcesProvider.select(
(names) => resolveDmChannelDisplayLabel(
channel,
currentPubkey: currentPubkey,
names: names,
),
),
),
maxLines: 1,
overflow: TextOverflow.ellipsis,

View File

@@ -11,6 +11,7 @@ class _HuddleCallAvatar extends HookConsumerWidget {
required this.speakerLevel,
required this.preparingResponse,
required this.onTap,
this.contextualLabel,
this.isSelf = false,
this.frameSize = _huddleAvatarFrameSize,
});
@@ -18,6 +19,9 @@ class _HuddleCallAvatar extends HookConsumerWidget {
final String pubkey;
final UserProfile? profile;
final String? fallbackLabel;
/// The huddle-scoped identity label, when resolved.
final String? contextualLabel;
final bool active;
final double speakerLevel;
final bool preparingResponse;
@@ -122,6 +126,7 @@ class _HuddleCallAvatar extends HookConsumerWidget {
pubkey: pubkey,
profile: profile,
fallbackLabel: fallbackLabel,
contextualLabel: contextualLabel,
isSelf: isSelf,
);
final isAgent = profile?.isAgent == true || fallbackLabel != null;
@@ -265,8 +270,10 @@ String _huddleParticipantLabel({
required UserProfile? profile,
required String? fallbackLabel,
required bool isSelf,
String? contextualLabel,
}) {
if (isSelf) return 'You';
if (contextualLabel != null) return contextualLabel;
final profileName = profile?.displayName?.trim();
final directoryName = fallbackLabel?.trim();
return (profileName?.isNotEmpty == true ? profileName : null) ??

View File

@@ -6,6 +6,7 @@ class _HuddleCallParticipants extends StatelessWidget {
required this.error,
required this.profiles,
required this.fallbackLabels,
required this.contextualLabels,
required this.remotePubkeys,
required this.localPubkey,
required this.activeSpeakerPubkeys,
@@ -22,6 +23,9 @@ class _HuddleCallParticipants extends StatelessWidget {
final String? error;
final Map<String, UserProfile> profiles;
final Map<String, String> fallbackLabels;
/// Huddle-scoped identity labels, keyed by lowercase pubkey.
final Map<String, String> contextualLabels;
final List<String> remotePubkeys;
final String? localPubkey;
final Set<String> activeSpeakerPubkeys;
@@ -146,6 +150,7 @@ class _HuddleCallParticipants extends StatelessWidget {
pubkeys: remotePubkeys,
profiles: profiles,
fallbackLabels: fallbackLabels,
contextualLabels: contextualLabels,
activeSpeakerPubkeys: activeSpeakerPubkeys,
speakerLevels: speakerLevels,
workingAgentPubkeys: workingAgentPubkeys,

View File

@@ -38,6 +38,7 @@ class _HuddleParticipantCluster extends HookWidget {
required this.pubkeys,
required this.profiles,
required this.fallbackLabels,
required this.contextualLabels,
required this.activeSpeakerPubkeys,
required this.speakerLevels,
required this.workingAgentPubkeys,
@@ -51,6 +52,9 @@ class _HuddleParticipantCluster extends HookWidget {
final List<String> pubkeys;
final Map<String, UserProfile> profiles;
final Map<String, String> fallbackLabels;
/// Huddle-scoped identity labels, keyed by lowercase pubkey.
final Map<String, String> contextualLabels;
final Set<String> activeSpeakerPubkeys;
final Map<String, double> speakerLevels;
final Set<String> workingAgentPubkeys;
@@ -139,6 +143,7 @@ class _HuddleParticipantCluster extends HookWidget {
pubkey: pubkey,
profile: profiles[pubkey],
fallbackLabel: fallbackLabels[pubkey],
contextualLabel: contextualLabels[pubkey],
active: activeSpeakerPubkeys.contains(pubkey),
speakerLevel: speakerLevels[pubkey] ?? 0,
preparingResponse: workingAgentPubkeys.contains(pubkey),
@@ -174,6 +179,7 @@ class _HuddleAnimatedParticipant extends StatelessWidget {
required this.pubkey,
required this.profile,
required this.fallbackLabel,
required this.contextualLabel,
required this.active,
required this.speakerLevel,
required this.preparingResponse,
@@ -188,6 +194,7 @@ class _HuddleAnimatedParticipant extends StatelessWidget {
final String pubkey;
final UserProfile? profile;
final String? fallbackLabel;
final String? contextualLabel;
final bool active;
final double speakerLevel;
final bool preparingResponse;
@@ -240,6 +247,7 @@ class _HuddleAnimatedParticipant extends StatelessWidget {
pubkey: pubkey,
profile: profile,
fallbackLabel: fallbackLabel,
contextualLabel: contextualLabel,
active: active,
speakerLevel: speakerLevel,
preparingResponse: preparingResponse,

View File

@@ -168,6 +168,11 @@ class _HuddleParticipantSpotlight extends ConsumerWidget {
pubkey: pubkey,
profile: profile,
fallbackLabel: fallbackLabel,
contextualLabel: ref.watch(
_huddleIdentityNamesProvider(
ephemeralChannelId,
).select((names) => names.labelFor(pubkey)),
),
isSelf: isSelf,
);
final isAgent = profile?.isAgent == true || fallbackLabel != null;
@@ -269,6 +274,7 @@ class _HuddleParticipantRoster extends ConsumerWidget {
}
final profiles = ref.watch(userCacheProvider);
final fallbackLabels = ref.watch(agentDirectoryDisplayNamesProvider);
final names = ref.watch(_huddleIdentityNamesProvider(ephemeralChannelId));
final activeSpeakerPubkeys = ref.watch(
huddleSessionProvider.select((session) => session.activeSpeakerPubkeys),
);
@@ -333,6 +339,7 @@ class _HuddleParticipantRoster extends ConsumerWidget {
pubkey: pubkey,
profile: profile,
fallbackLabel: fallbackLabels[pubkey],
contextualLabel: names.labelFor(pubkey),
isSelf: false,
);
return Semantics(

View File

@@ -41,6 +41,28 @@ final _huddleLogicalParticipantPubkeysProvider = Provider.autoDispose
);
});
/// Contextual identity labels for one Huddle: its logical participants are
/// the comparison context, and its bot members are agents.
final _huddleIdentityNamesProvider = Provider.autoDispose
.family<IdentityNames, String>((ref, channelId) {
final sources = ref.watch(identityNameSourcesProvider);
final members =
ref.watch(channelMembersProvider(channelId)).value ??
const <ChannelMember>[];
final names = sources.scope(
ref.watch(_huddleLogicalParticipantPubkeysProvider(channelId)),
agentPubkeys: {
for (final member in members)
if (member.isBot) member.pubkey,
},
fallbackNames: {
for (final member in members) member.pubkey: ?member.displayName,
},
);
loadIdentityNameOwners(ref, names);
return names;
});
class _HuddleParticipantProfileUpdates extends Notifier<int> {
_HuddleParticipantProfileUpdates(this.channelId);
@@ -573,6 +595,12 @@ class _MobileHuddleCallPage extends ConsumerWidget {
);
final profiles = ref.watch(userCacheProvider);
final directoryDisplayNames = ref.watch(agentDirectoryDisplayNamesProvider);
final huddleNames = ref.watch(
_huddleIdentityNamesProvider(invite.ephemeralChannelId),
);
final huddleLabels = {
for (final pubkey in remotePubkeys) pubkey: huddleNames.labelFor(pubkey),
};
final huddleTypingEntries = ref.watch(
channelTypingProvider(invite.ephemeralChannelId),
);
@@ -679,6 +707,7 @@ class _MobileHuddleCallPage extends ConsumerWidget {
: null,
profiles: profiles,
fallbackLabels: directoryDisplayNames,
contextualLabels: huddleLabels,
remotePubkeys: remotePubkeys,
localPubkey: localPubkey,
activeSpeakerPubkeys: session.activeSpeakerPubkeys,

View File

@@ -110,6 +110,11 @@ class ChannelDetailsPage extends HookConsumerWidget {
'$memberCount ${memberCount == 1 ? 'member' : 'members'}';
final previewMembers = members.take(_channelMemberPreviewLimit).toList();
final userCache = ref.watch(userCacheProvider);
final memberLabels = watchChannelIdentityLabels(
ref,
resolvedChannel.id,
previewMembers.map((member) => member.pubkey),
);
final currentSectionId = sectionState.isReady
? sectionState.store.assignments[resolvedChannel.id]
: sectionId;
@@ -410,6 +415,7 @@ class ChannelDetailsPage extends HookConsumerWidget {
onMemberTap: onMemberTap,
displayName:
userCache[member.pubkey.toLowerCase()]?.displayName,
contextualLabel: memberLabels[member.pubkey.toLowerCase()],
avatarUrl:
userCache[member.pubkey.toLowerCase()]?.avatarUrl,
),
@@ -643,6 +649,7 @@ class _ChannelMemberPreviewRow extends StatelessWidget {
required this.currentPubkey,
required this.onMemberTap,
required this.displayName,
required this.contextualLabel,
required this.avatarUrl,
});
@@ -650,22 +657,26 @@ class _ChannelMemberPreviewRow extends StatelessWidget {
final String? currentPubkey;
final void Function(BuildContext context, String pubkey) onMemberTap;
final String? displayName;
/// The channel's contextual label for this member, when resolved.
final String? contextualLabel;
final String? avatarUrl;
@override
Widget build(BuildContext context) {
final isSelf = member.pubkey.toLowerCase() == currentPubkey?.toLowerCase();
final hasName = displayName?.trim().isNotEmpty == true;
final label = isSelf
final name = isSelf
? 'You'
: hasName
? displayName!.trim()
: member.labelFor(currentPubkey);
final label = isSelf ? name : contextualLabel ?? name;
// Self/named initials come from the visible label; unnamed members stay
// keyed to the hex public key so the compact-npub label doesn't render
// `N` for everyone.
final initial = isSelf || hasName
? label[0].toUpperCase()
? name[0].toUpperCase()
: (member.pubkey.isNotEmpty ? member.pubkey[0].toUpperCase() : '?');
final roleLabel = _channelMemberRoleLabel(member.role);
final titleStyle = context.textTheme.bodyLarge;

View File

@@ -30,6 +30,7 @@ import '../../shared/custom_emoji/custom_emoji_render.dart';
import '../profile/profile_avatar.dart';
import '../profile/profile_provider.dart';
import '../profile/presence_cache_provider.dart';
import '../../shared/identity_names/identity_names_provider.dart';
import '../../shared/profile/user_cache_provider.dart';
import '../pairing/pairing_page.dart';
import '../pairing/pairing_provider.dart';

View File

@@ -119,10 +119,25 @@ class _SliverChannelsList extends HookConsumerWidget {
final streamChannels = visibleChannels
.where((channel) => channel.isStream)
.toList();
final dmChannels = sortDmChannelsByDisplayLabel(
visibleChannels.where((channel) => channel.isDm),
currentPubkey: currentPubkey,
final unsortedDms = visibleChannels.where((channel) => channel.isDm);
// Rebuild only when the DM order changes, not for every profile fetch.
final dmOrder = ref.watch(
identityNameSourcesProvider.select(
(names) => [
for (final channel in sortDmChannelsByDisplayLabel(
unsortedDms,
currentPubkey: currentPubkey,
names: names,
))
channel.id,
].join('\u0000'),
),
);
final dmRank = {
for (final (index, id) in dmOrder.split('\u0000').indexed) id: index,
};
final dmChannels = unsortedDms.toList()
..sort((a, b) => dmRank[a.id]!.compareTo(dmRank[b.id]!));
final starredExpanded = useState(true);
final channelsExpanded = useState(true);

View File

@@ -65,9 +65,14 @@ class _ChannelTile extends ConsumerWidget {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
resolveDmChannelDisplayLabel(
channel,
currentPubkey: currentPubkey,
ref.watch(
identityNameSourcesProvider.select(
(names) => resolveDmChannelDisplayLabel(
channel,
currentPubkey: currentPubkey,
names: names,
),
),
),
maxLines: 1,
overflow: TextOverflow.ellipsis,

View File

@@ -486,6 +486,17 @@ class _NewDirectMessageSheet extends HookConsumerWidget {
normalizedPubkey.contains(normalizedQuery);
}).toList() ??
const <DirectoryUser>[];
// Eligible recipients shown or chosen here are the comparison context.
final recipients = [...availableResults, ...selectedUsers.value];
final names = watchIdentityNames(
ref,
[for (final user in recipients) user.pubkey],
agentPubkeys: {
for (final user in recipients)
if (user.isAgent) user.pubkey,
},
fallbackNames: {for (final user in recipients) user.pubkey: user.label},
);
final canSubmit = !isSubmitting.value && selectedUsers.value.isNotEmpty;
Future<void> submit() async {
@@ -580,6 +591,7 @@ class _NewDirectMessageSheet extends HookConsumerWidget {
for (final user in selectedUsers.value)
_SelectedDmRecipientChip(
user: user,
label: names.labelFor(user.pubkey),
enabled: !isSubmitting.value,
onDeleted: () {
selectedUsers.value = [
@@ -748,7 +760,7 @@ class _NewDirectMessageSheet extends HookConsumerWidget {
isAgent: user.isAgent,
),
title: Text(
user.label,
names.labelFor(user.pubkey),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
@@ -799,11 +811,13 @@ class _NewDirectMessageSheet extends HookConsumerWidget {
class _SelectedDmRecipientChip extends StatelessWidget {
final DirectoryUser user;
final String label;
final bool enabled;
final VoidCallback onDeleted;
const _SelectedDmRecipientChip({
required this.user,
required this.label,
required this.enabled,
required this.onDeleted,
});
@@ -825,7 +839,7 @@ class _SelectedDmRecipientChip extends StatelessWidget {
button: true,
enabled: enabled,
excludeSemantics: true,
label: 'Remove ${user.label}',
label: 'Remove $label',
child: InkWell(
customBorder: const StadiumBorder(),
onTap: enabled ? onDeleted : null,
@@ -853,7 +867,7 @@ class _SelectedDmRecipientChip extends StatelessWidget {
const SizedBox(width: Grid.xxs),
Flexible(
child: Text(
user.label,
label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: context.textTheme.bodyLarge?.copyWith(

View File

@@ -1,3 +1,4 @@
import '../../shared/identity_names/identity_names.dart';
import '../../shared/utils/string_utils.dart';
import 'channel.dart';
@@ -22,18 +23,41 @@ String formatDmParticipantDisplayName(List<String> displayNames) {
: visible.join(', ');
}
String resolveDmChannelDisplayLabel(Channel channel, {String? currentPubkey}) {
/// Contextual participant labels for a DM: its participants are the whole
/// comparison context, and relay participant names are fallbacks.
IdentityNames dmParticipantNames(Channel channel, IdentityNameSources names) =>
names.scope(
channel.participantPubkeys,
fallbackNames: {
for (var index = 0; index < channel.participantPubkeys.length; index++)
if (index < channel.participants.length)
channel.participantPubkeys[index]: channel.participants[index],
},
);
/// The DM's display label. With [names], participant labels follow the
/// contextual identity-name policy, so namesakes stay distinguishable.
String resolveDmChannelDisplayLabel(
Channel channel, {
String? currentPubkey,
IdentityNameSources? names,
}) {
if (!channel.isDm || !isGenericDmChannelName(channel.name)) {
return channel.name;
}
final normalizedCurrent = currentPubkey?.toLowerCase();
final participantNames = names == null
? null
: dmParticipantNames(channel, names);
final participants = <({String label, String? pubkey})>[
for (var index = 0; index < channel.participantPubkeys.length; index++)
(
label: index < channel.participants.length
? channel.participants[index]
: shortPubkey(channel.participantPubkeys[index]),
label:
participantNames?.labelFor(channel.participantPubkeys[index]) ??
(index < channel.participants.length
? channel.participants[index]
: shortPubkey(channel.participantPubkeys[index])),
pubkey: channel.participantPubkeys[index].toLowerCase(),
),
];
@@ -107,16 +131,19 @@ String dmAvatarInitial(Channel channel, {String? currentPubkey}) {
List<Channel> sortDmChannelsByDisplayLabel(
Iterable<Channel> channels, {
String? currentPubkey,
IdentityNameSources? names,
}) {
final sorted = channels.toList();
sorted.sort((left, right) {
final leftLabel = resolveDmChannelDisplayLabel(
left,
currentPubkey: currentPubkey,
names: names,
);
final rightLabel = resolveDmChannelDisplayLabel(
right,
currentPubkey: currentPubkey,
names: names,
);
final labelCompare = leftLabel.toLowerCase().compareTo(
rightLabel.toLowerCase(),

View File

@@ -14,6 +14,7 @@ import '../profile/user_status_cache_provider.dart';
import 'agent_activity/agent_activity_sheet.dart';
import 'agent_activity/working_bots_provider.dart';
import 'channel.dart';
import 'channel_identity_names_provider.dart';
import 'channel_management_provider.dart';
class MembersSheet extends HookConsumerWidget {
@@ -216,16 +217,17 @@ class _MemberTile extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final label = isSelf
final name = isSelf
? 'You'
: (profile?.displayName?.trim().isNotEmpty == true
? profile!.displayName!.trim()
: member.labelFor(currentPubkey));
final label = _watchContextualLabel(ref);
// Named members initial from their name; unnamed ones stay keyed to the
// hex public key so the compact-npub label doesn't render `N` for all.
final hasName = profile?.displayName?.trim().isNotEmpty == true;
final initial = isSelf || hasName
? label[0].toUpperCase()
? name[0].toUpperCase()
: (member.pubkey.isNotEmpty ? member.pubkey[0].toUpperCase() : '?');
final showManagementActions = canManage && !isSelf && !member.isOwner;
final showMenu = showManagementActions || onViewActivity != null;
@@ -287,6 +289,10 @@ class _MemberTile extends ConsumerWidget {
);
}
/// 'You' for the viewer; otherwise the channel's contextual label.
String _watchContextualLabel(WidgetRef ref) =>
isSelf ? 'You' : watchChannelIdentityLabel(ref, channelId, member.pubkey);
void _showMemberActions(
BuildContext context,
WidgetRef ref, {
@@ -294,9 +300,9 @@ class _MemberTile extends ConsumerWidget {
}) {
final label = isSelf
? 'You'
: (profile?.displayName?.trim().isNotEmpty == true
? profile!.displayName!.trim()
: member.labelFor(currentPubkey));
: ref
.read(channelIdentityNamesProvider(channelId))
.labelFor(member.pubkey);
final canChangeRole = showManagementActions && !member.isBot;
showBuzzModalBottomSheet<void>(
context: context,

View File

@@ -3,10 +3,10 @@ import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../../shared/identity_names/identity_names.dart';
import '../../shared/theme/theme.dart';
import '../../shared/widgets/avatar_image.dart';
import '../../shared/profile/user_cache_provider.dart';
import '../../shared/utils/string_utils.dart';
import 'note_card.dart';
import 'pulse_models.dart';
@@ -19,16 +19,20 @@ class AgentActivityCard extends HookConsumerWidget {
super.key,
required this.group,
required this.reactions,
required this.names,
this.onReactionChanged,
});
/// The Pulse timeline's identity labels.
final IdentityNames names;
@override
Widget build(BuildContext context, WidgetRef ref) {
final expanded = useState(group.notes.length == 1);
final profile =
ref.watch(userCacheProvider.select((cache) => cache[group.pubkey])) ??
ref.read(userCacheProvider.notifier).get(group.pubkey);
final name = profile?.label ?? shortPubkey(group.pubkey);
final name = names.labelFor(group.pubkey);
return Column(
children: [
@@ -143,6 +147,7 @@ class AgentActivityCard extends HookConsumerWidget {
reactedByCurrentUser: false,
),
isAgent: true,
names: names,
onReactionChanged: onReactionChanged,
),
if (note != group.notes.last)

View File

@@ -2,6 +2,9 @@ import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import '../../shared/identity_names/identity_names.dart';
import '../../shared/identity_names/identity_names_provider.dart';
import '../../shared/mentions/mention_tags.dart';
import '../../shared/theme/theme.dart';
import '../../shared/widgets/avatar_image.dart';
import '../../shared/widgets/buzz_loading_indicator.dart';
@@ -10,7 +13,6 @@ import '../../shared/widgets/frosted_scaffold.dart';
import '../channels/message_content.dart';
import '../profile/profile_provider.dart';
import '../../shared/profile/user_cache_provider.dart';
import '../../shared/utils/string_utils.dart';
import 'note_card.dart';
import 'pulse_actions.dart';
import 'pulse_models.dart';
@@ -24,7 +26,12 @@ import 'pulse_models.dart';
class ComposeNotePage extends HookConsumerWidget {
final UserNote? replyTo;
const ComposeNotePage({super.key, this.replyTo});
/// The comparison context of the surface that opened the reply, so the
/// reply context names people as they were shown there. Its labels are
/// resolved against live naming facts while this page is open.
final IdentityNames? names;
const ComposeNotePage({super.key, this.replyTo, this.names});
bool get _isReply => replyTo != null;
@@ -99,7 +106,7 @@ class ComposeNotePage extends HookConsumerWidget {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(height: frostedAppBarHeight(context)),
if (_isReply) _ReplyContext(note: replyTo!),
if (_isReply) _ReplyContext(note: replyTo!, names: names),
Expanded(
child: SingleChildScrollView(
padding: const EdgeInsets.all(Grid.xs),
@@ -158,8 +165,9 @@ class ComposeNotePage extends HookConsumerWidget {
/// height so a long note doesn't push the editor off-screen.
class _ReplyContext extends ConsumerWidget {
final UserNote note;
final IdentityNames? names;
const _ReplyContext({required this.note});
const _ReplyContext({required this.note, this.names});
@override
Widget build(BuildContext context, WidgetRef ref) {
@@ -167,7 +175,11 @@ class _ReplyContext extends ConsumerWidget {
final profile =
ref.watch(userCacheProvider.select((cache) => cache[pubkey])) ??
ref.read(userCacheProvider.notifier).get(pubkey);
final displayName = profile?.label ?? shortPubkey(pubkey);
final mentionPubkeys = mentionedPubkeysFromTags(note.tags);
final labels =
names?.withSources(ref.watch(identityNameSourcesProvider)) ??
watchIdentityNames(ref, {pubkey, ...mentionPubkeys});
final displayName = labels.labelFor(pubkey);
return Padding(
padding: const EdgeInsets.fromLTRB(Grid.gutter, Grid.xs, Grid.gutter, 0),
@@ -244,6 +256,10 @@ class _ReplyContext extends ConsumerWidget {
heightFactor: 1,
child: MessageContent(
content: note.content,
mentionLabels: {
for (final key in mentionPubkeys)
key: labels.labelFor(key),
},
tags: note.tags,
baseStyle: messageBodyTextStyle.copyWith(
color: context.colors.onSurface,

View File

@@ -5,13 +5,15 @@ import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:nostr/nostr.dart' as nostr;
import '../../shared/clipboard_utils.dart';
import '../../shared/identity_names/identity_names.dart';
import '../../shared/identity_names/identity_names_provider.dart';
import '../../shared/mentions/mention_tags.dart';
import '../../shared/theme/theme.dart';
import '../../shared/widgets/avatar_image.dart';
import '../channels/channel_detail_page.dart';
import '../channels/channel_management_provider.dart';
import '../channels/message_content.dart';
import '../../shared/profile/user_cache_provider.dart';
import '../../shared/utils/string_utils.dart';
import '../profile/user_profile_sheet.dart';
import 'compose_note_page.dart';
import 'pulse_actions.dart';
@@ -26,6 +28,10 @@ class NoteCard extends HookConsumerWidget {
final VoidCallback? onReactionChanged;
final ValueChanged<String>? onFollowChanged;
/// The surrounding collection's identity labels (for example the Pulse
/// timeline's authors). When absent, the note alone is the context.
final IdentityNames? names;
const NoteCard({
super.key,
required this.note,
@@ -35,6 +41,7 @@ class NoteCard extends HookConsumerWidget {
this.canFollow = false,
this.onReactionChanged,
this.onFollowChanged,
this.names,
});
@override
@@ -44,7 +51,13 @@ class NoteCard extends HookConsumerWidget {
final profile =
ref.watch(userCacheProvider.select((cache) => cache[pubkey])) ??
ref.read(userCacheProvider.notifier).get(pubkey);
final displayName = profile?.label ?? shortPubkey(pubkey);
final mentionPubkeys = mentionedPubkeysFromTags(note.tags);
final labels =
names ?? watchIdentityNames(ref, pulseNamedIdentities([note]));
final displayName = labels.labelFor(pubkey);
final mentionLabels = {
for (final key in mentionPubkeys) key: labels.labelFor(key),
};
final effectiveUpvoted =
pendingUpvote.value ?? reaction.reactedByCurrentUser;
final effectiveCount = _effectiveCount(reaction, pendingUpvote.value);
@@ -65,7 +78,11 @@ class NoteCard extends HookConsumerWidget {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
GestureDetector(
onTap: () => showUserProfileSheet(context, note.pubkey),
onTap: () => showUserProfileSheet(
context,
note.pubkey,
names: liveIdentityNamesProvider(labels),
),
child: AvatarImage(
imageUrl: profile?.avatarUrl,
radius: 18,
@@ -92,7 +109,11 @@ class NoteCard extends HookConsumerWidget {
children: [
Expanded(
child: GestureDetector(
onTap: () => showUserProfileSheet(context, note.pubkey),
onTap: () => showUserProfileSheet(
context,
note.pubkey,
names: liveIdentityNamesProvider(labels),
),
child: Row(
children: [
Expanded(
@@ -155,7 +176,7 @@ class NoteCard extends HookConsumerWidget {
if (note.replyParentId != null) ...[
const SizedBox(height: 2),
Text(
'Replying to ${_replyTargetLabel(note)}',
'Replying to ${_replyTargetLabel(note, labels)}',
style: context.textTheme.labelSmall?.copyWith(
color: context.colors.onSurfaceVariant,
),
@@ -164,6 +185,7 @@ class NoteCard extends HookConsumerWidget {
const SizedBox(height: Grid.half),
MessageContent(
content: note.content,
mentionLabels: mentionLabels,
tags: note.tags,
baseStyle: messageBodyTextStyle.copyWith(
color: context.colors.onSurface,
@@ -199,7 +221,8 @@ class NoteCard extends HookConsumerWidget {
icon: LucideIcons.messageCircle,
onTap: () => Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => ComposeNotePage(replyTo: note),
builder: (_) =>
ComposeNotePage(replyTo: note, names: labels),
),
),
),
@@ -315,15 +338,25 @@ class _ActionButton extends StatelessWidget {
}
}
/// Every identity [notes] name on screen: authors, reply-parent authors and
/// mentions. A Pulse collection compares all of them together.
Set<String> pulseNamedIdentities(Iterable<UserNote> notes) => {
for (final note in notes) ...{
note.pubkey.toLowerCase(),
?note.replyParentAuthor?.toLowerCase(),
...mentionedPubkeysFromTags(note.tags),
},
};
String _shareUri(UserNote note) =>
'nostr:${nostr.Nip19.encodeShareableIdentifiers(prefix: nostr.Nip19Prefix.nevent, data: note.id, author: note.pubkey, kind: 1)}';
/// Compact label for a note's reply target: the parent author's public key
/// (npub form) when known, otherwise the parent event id. Event ids are not
/// public keys — they stay hex-truncated and out of the npub contract.
String _replyTargetLabel(UserNote note) {
/// Compact label for a note's reply target: the parent author's contextual
/// label when known, otherwise the parent event id. Event ids are not public
/// keys — they stay hex-truncated and out of the npub contract.
String _replyTargetLabel(UserNote note, IdentityNames names) {
final author = note.replyParentAuthor;
if (author != null) return shortPubkey(author);
if (author != null) return names.labelFor(author);
final eventId = note.replyParentId!;
return eventId.length <= 8 ? eventId : '${eventId.substring(0, 8)}…';
}

View File

@@ -3,6 +3,7 @@ import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../../shared/identity_names/identity_names_provider.dart';
import '../../shared/relay/relay.dart';
import '../../shared/theme/theme.dart';
import '../../shared/widgets/filter_chip_bar.dart';
@@ -169,6 +170,16 @@ class _PulseBody extends ConsumerWidget {
child: _EmptyState(message: _emptyMessage(tab)),
);
}
// Everyone the timeline names — authors, reply targets and mentions
// — is one comparison context, so two same-name targets on
// different notes are still told apart.
final names = watchIdentityNames(
ref,
pulseNamedIdentities(notes),
agentPubkeys: tab == PulseTab.agents
? {for (final note in notes) note.pubkey.toLowerCase()}
: agentPubkeys,
);
if (tab == PulseTab.agents) {
final groups = groupAgentNotes(notes);
return ListView.separated(
@@ -183,6 +194,7 @@ class _PulseBody extends ConsumerWidget {
itemBuilder: (context, index) => AgentActivityCard(
group: groups[index],
reactions: reactions,
names: names,
onReactionChanged: onReactionChanged,
),
);
@@ -207,6 +219,7 @@ class _PulseBody extends ConsumerWidget {
reactedByCurrentUser: false,
),
isAgent: agentPubkeys.contains(note.pubkey),
names: names,
isFollowing: contactPubkeys.contains(note.pubkey),
canFollow:
currentPubkey != null &&

View File

@@ -3,6 +3,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../../shared/identity_names/identity_names_provider.dart';
import '../../shared/mentions/agent_identity_provider.dart';
import '../../shared/mentions/mention_tags.dart';
import '../../shared/theme/theme.dart';
@@ -15,6 +16,7 @@ import '../../shared/widgets/frosted_scaffold.dart';
import '../../shared/widgets/message_author_meta.dart';
import '../channels/channel.dart';
import '../channels/channel_detail_page.dart';
import '../channels/channel_identity_names_provider.dart';
import '../channels/channel_management_provider.dart';
import '../channels/channels_provider.dart';
import '../channels/small_avatar.dart';
@@ -716,6 +718,16 @@ class _PeopleSection extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
// The shown results are the comparison context.
final names = watchIdentityNames(
ref,
[for (final user in users) user.pubkey],
agentPubkeys: {
for (final user in users)
if (user.isAgent) user.pubkey,
},
fallbackNames: {for (final user in users) user.pubkey: user.label},
);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
@@ -732,7 +744,7 @@ class _PeopleSection extends ConsumerWidget {
isAgent: user.isAgent,
),
title: Text(
user.label,
names.labelFor(user.pubkey),
key: ValueKey('search-person-title-${user.pubkey}'),
style: contentListTitleTextStyle,
),
@@ -824,7 +836,23 @@ class _MessageTile extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final authorName = authorProfile?.label ?? shortPubkey(hit.pubkey);
final mentionedPubkeys = mentionedPubkeysFromTags(hit.tags);
// A hit is labelled within its channel, as it would be there.
final hitChannelId = channel?.id ?? hit.channelId;
final labelPubkeys = {hit.pubkey.toLowerCase(), ...mentionedPubkeys};
final Map<String, String> labels;
if (hitChannelId == null) {
final names = watchIdentityNames(ref, labelPubkeys);
labels = {
for (final pubkey in labelPubkeys) pubkey: names.labelFor(pubkey),
};
} else {
labels = watchChannelIdentityLabels(ref, hitChannelId, labelPubkeys);
}
final authorName =
labels[hit.pubkey.toLowerCase()] ??
authorProfile?.label ??
shortPubkey(hit.pubkey);
final timeAgo = relativeTime(hit.createdAt);
final channelName = hit.channelName?.trim().replaceFirst(RegExp(r'^#'), '');
final hasChannelName = channelName != null && channelName.isNotEmpty;
@@ -923,6 +951,7 @@ class _MessageTile extends ConsumerWidget {
key: ValueKey('search-message-body-${hit.eventId}'),
content: hit.content,
mentionNames: mentionNames,
mentionLabels: labels,
agentMentionPubkeys: agentMentionPubkeys,
tags: hit.tags,
maxLines: 2,

View File

@@ -133,13 +133,16 @@ class IdentityNames {
_ownerPubkeys,
);
/// Owner keys of the context whose profile is not cached yet. Callers load
/// them so readable owner prefixes can appear; none are invented meanwhile.
Set<String> missingOwnerProfiles() => _candidates
.map((key) => _factFor(key)?.ownerPubkey)
.nonNulls
.where((owner) => !_sources.profiles.containsKey(owner))
.toSet();
/// Owner keys of the context (or of [keys] only) whose profile is not
/// cached yet. Callers load them so readable owner prefixes can appear;
/// none are invented meanwhile.
Set<String> missingOwnerProfiles([Iterable<String>? keys]) =>
(keys?.map((key) => key.toLowerCase()) ?? _candidates)
.map(_factFor)
.map((fact) => fact?.ownerPubkey)
.nonNulls
.where((owner) => !_sources.profiles.containsKey(owner))
.toSet();
NamingIdentity? _factFor(String key) => _sources.factFor(
key,

View File

@@ -39,11 +39,17 @@ void loadIdentityNameOwners(Ref ref, IdentityNames names) {
/// Labels for a displayed collection with no channel context, such as search
/// results, a Pulse timeline, or a picker's choices. [candidates] is that
/// collection; keys outside it resolve against it plus themselves.
///
/// Uncached profiles are loaded for the identities in [shown] (default: all
/// [candidates]). Pass [shown] when the context also holds identities that are
/// only compared, such as a channel roster whose names another owner loads,
/// so opening the view does not queue a profile read for every one of them.
IdentityNames watchIdentityNames(
WidgetRef ref,
Iterable<String> candidates, {
Set<String> agentPubkeys = const {},
Map<String, String> fallbackNames = const {},
Iterable<String>? shown,
}) {
final sources = ref.watch(identityNameSourcesProvider);
final names = sources.scope(
@@ -54,10 +60,16 @@ IdentityNames watchIdentityNames(
// A displayed identity's profile carries its owner hint, so load uncached
// candidates as well as their owners; channels load member profiles
// elsewhere, but a displayed collection has no other loader.
final loadFor = shown == null
? names.candidates
: {
for (final key in shown)
if (names.candidates.contains(key.toLowerCase())) key.toLowerCase(),
};
final missing = {
for (final key in names.candidates)
for (final key in loadFor)
if (!sources.profiles.containsKey(key)) key,
...names.missingOwnerProfiles(),
...names.missingOwnerProfiles(loadFor),
};
if (missing.isNotEmpty) {
Future.microtask(() {

View File

@@ -9,8 +9,10 @@ import 'package:buzz/features/activity/inbox_item.dart';
import 'package:buzz/features/activity/reminders_provider.dart';
import 'package:buzz/features/channels/channel.dart';
import 'package:buzz/features/channels/channel_detail_page.dart';
import 'package:buzz/features/channels/channel_management_provider.dart';
import 'package:buzz/features/channels/message_content.dart';
import 'package:buzz/features/channels/channels_provider.dart';
import 'package:buzz/shared/identity_names/identity_names.dart';
import 'package:buzz/shared/mentions/agent_identity_provider.dart';
import 'package:buzz/shared/read_state/read_state_provider.dart';
import 'package:buzz/shared/profile/user_cache_provider.dart';
@@ -23,6 +25,7 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:hooks_riverpod/misc.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:shared_preferences/shared_preferences.dart';
@@ -124,6 +127,7 @@ void main() {
List<ComposeDraft> drafts = const [],
List<Reminder> reminders = const [],
Set<String> knownAgentPubkeys = const {},
List<Override> overrides = const [],
}) async {
SharedPreferences.setMockInitialValues({});
final prefs = await SharedPreferences.getInstance();
@@ -147,6 +151,7 @@ void main() {
() => _FakeComposeDraftsNotifier(drafts),
),
remindersProvider.overrideWith(() => _FakeRemindersNotifier(reminders)),
...overrides,
],
child: MaterialApp(
theme: AppTheme.light(),
@@ -577,6 +582,47 @@ void main() {
}
});
testWidgets('same-name senders get their channel\'s contextual label', (
tester,
) async {
// Another ch1 member shares the sender's name, so the row must use the
// channel's disambiguated label rather than the bare profile name.
const sender =
'a11ce00000000000000000000000000000000000000000000000000000000000';
const twin =
'b0b0000000000000000000000000000000000000000000000000000000000000';
final members = [
for (final pubkey in const [sender, twin])
ChannelMember(pubkey: pubkey, role: 'member', joinedAt: DateTime(2025)),
];
final users = {
...testUsers,
sender: const UserProfile(pubkey: sender, displayName: 'Scout'),
twin: const UserProfile(pubkey: twin, displayName: 'Scout'),
};
await tester.pumpWidget(
await buildTestable(
users: users,
overrides: [
channelMembersProvider('ch1').overrideWith((ref) async => members),
],
),
);
await tester.pumpAndSettle();
final expected = IdentityNameSources(
profiles: users,
).scope(const [sender, twin]).labelFor(sender);
expect(expected, isNot('Scout'));
expect(
find.descendant(
of: find.byKey(const ValueKey('inbox-row-m1')),
matching: find.textContaining(expected),
),
findsWidgets,
);
});
testWidgets('directory-known Activity authors use agent avatars', (
tester,
) async {

View File

@@ -0,0 +1,73 @@
import 'package:buzz/features/channels/add_members_sheet.dart';
import 'package:buzz/features/channels/channel_management_provider.dart';
import 'package:buzz/shared/profile/user_cache_provider.dart';
import 'package:buzz/shared/profile/user_profile.dart';
import 'package:buzz/shared/theme/theme.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
class _RecordingUserCache extends UserCacheNotifier {
final preloaded = <String>{};
@override
Map<String, UserProfile> build() => const {};
@override
UserProfile? get(String pubkey) => null;
@override
Future<bool> preload(List<String> pubkeys) async {
preloaded.addAll(pubkeys);
return true;
}
}
void main() {
testWidgets('compares with every member but loads only shown choices', (
tester,
) async {
const channelId = 'channel-1';
// A large roster with no cached profiles; one human member is Scout.
final roster = [
for (var i = 0; i < 200; i++)
ChannelMember(
pubkey: i.toRadixString(16).padLeft(64, '0'),
role: 'member',
joinedAt: DateTime(2026),
displayName: i == 0 ? 'Scout' : 'Member $i',
),
];
final agent = 'f' * 64;
final cache = _RecordingUserCache();
await tester.pumpWidget(
ProviderScope(
overrides: [
userCacheProvider.overrideWith(() => cache),
channelMembersProvider(channelId).overrideWith((ref) async => roster),
relayDirectoryUsersProvider.overrideWith(
(ref) async => [
DirectoryUser(pubkey: agent, displayName: 'Scout', isAgent: true),
],
),
],
child: MaterialApp(
theme: AppTheme.light(),
home: Scaffold(
body: AddChannelMembersSheet(
channelId: channelId,
existingPubkeys: {for (final member in roster) member.pubkey},
),
),
),
),
);
await tester.pumpAndSettle();
// The shown agent is told apart from the human member named Scout.
expect(find.text('Scout (agent)'), findsOneWidget);
// Only the shown choice's profile is requested, not the whole roster.
expect(cache.preloaded, {agent});
});
}

View File

@@ -1,6 +1,8 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:buzz/shared/identity_names/identity_names.dart';
import 'package:buzz/shared/identity_names/identity_names_provider.dart';
import 'package:buzz/shared/profile/user_cache_provider.dart';
import 'package:buzz/shared/profile/user_profile.dart';
import 'package:buzz/features/pulse/compose_note_page.dart';
@@ -17,6 +19,27 @@ class _FakeUserCacheNotifier extends UserCacheNotifier {
@override
UserProfile? get(String pubkey) => _users[pubkey.toLowerCase()];
@override
Future<bool> preload(List<String> pubkeys) async => true;
}
class _MutableUserCache extends UserCacheNotifier {
_MutableUserCache(this._initial);
final Map<String, UserProfile> _initial;
@override
Map<String, UserProfile> build() => _initial;
@override
UserProfile? get(String pubkey) => state[pubkey.toLowerCase()];
@override
Future<bool> preload(List<String> pubkeys) async => true;
void replace(UserProfile profile) =>
state = {...state, profile.pubkey: profile};
}
void main() {
@@ -74,6 +97,45 @@ void main() {
expect(_replyPreviewAvatarInitial(tester, 'Replying to Alice'), 'A');
});
testWidgets('a supplied context follows profile changes while open', (
tester,
) async {
final author = 'a' * 64;
final cache = _MutableUserCache({
author: UserProfile(pubkey: author, displayName: 'Scout'),
});
final note = UserNote(
id: 'note-live',
pubkey: author,
createdAt: DateTime.now().millisecondsSinceEpoch ~/ 1000 - 120,
content: 'Original',
tags: const [],
);
await tester.pumpWidget(
ProviderScope(
overrides: [userCacheProvider.overrideWith(() => cache)],
child: MaterialApp(
theme: AppTheme.light(),
home: Consumer(
builder: (context, ref, _) {
// The opener's context, as NoteCard passes it to the route.
final IdentityNames opened = ref
.read(identityNameSourcesProvider)
.scope([author]);
return ComposeNotePage(replyTo: note, names: opened);
},
),
),
),
);
await tester.pump();
expect(find.text('Replying to Scout'), findsOneWidget);
cache.replace(UserProfile(pubkey: author, displayName: 'Renamed'));
await tester.pump();
expect(find.text('Replying to Renamed'), findsOneWidget);
});
testWidgets('reply preview constrains its timestamp at large text sizes', (
tester,
) async {

View File

@@ -1,3 +1,4 @@
import 'package:buzz/shared/identity_names/identity_names.dart';
import 'package:buzz/shared/profile/user_cache_provider.dart';
import 'package:buzz/shared/profile/user_profile.dart';
import 'package:buzz/features/pulse/note_card.dart';
@@ -18,6 +19,54 @@ class _FakeUserCacheNotifier extends UserCacheNotifier {
}
void main() {
testWidgets('names the author and reply target within the timeline', (
tester,
) async {
final first = 'a' * 64, second = 'b' * 64;
final users = {
first: UserProfile(pubkey: first, displayName: 'Scout'),
second: UserProfile(pubkey: second, displayName: 'Scout'),
};
final names = IdentityNameSources(profiles: users).scope([first, second]);
expect(names.labelFor(first), isNot('Scout'));
final note = UserNote(
id: 'note-1',
pubkey: first,
createdAt: DateTime.utc(2025, 9, 30, 12).millisecondsSinceEpoch ~/ 1000,
content: 'A reply',
tags: [
['e', 'parent-note', '', 'reply'],
['p', second],
],
);
expect(note.replyParentAuthor, second);
await tester.pumpWidget(
ProviderScope(
overrides: [
userCacheProvider.overrideWith(() => _FakeUserCacheNotifier(users)),
],
child: MaterialApp(
theme: AppTheme.light(),
home: Scaffold(
body: NoteCard(
note: note,
reaction: const PulseReactionState(
count: 0,
reactedByCurrentUser: false,
),
names: names,
),
),
),
),
);
await tester.pumpAndSettle();
expect(find.text(names.labelFor(first)), findsOneWidget);
expect(find.text('Replying to ${names.labelFor(second)}'), findsOneWidget);
});
testWidgets('constrains timestamp with agent and follow metadata', (
tester,
) async {

View File

@@ -0,0 +1,81 @@
import 'package:buzz/features/pulse/pulse_models.dart';
import 'package:buzz/features/pulse/pulse_page.dart';
import 'package:buzz/features/pulse/pulse_provider.dart';
import 'package:buzz/shared/profile/user_cache_provider.dart';
import 'package:buzz/shared/profile/user_profile.dart';
import 'package:buzz/shared/relay/relay.dart';
import 'package:buzz/shared/theme/theme.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
class _UserCache extends UserCacheNotifier {
_UserCache(this._users);
final Map<String, UserProfile> _users;
@override
Map<String, UserProfile> build() => _users;
@override
UserProfile? get(String pubkey) => _users[pubkey.toLowerCase()];
@override
Future<bool> preload(List<String> pubkeys) async => true;
}
void main() {
testWidgets('same-name reply targets on different notes are told apart', (
tester,
) async {
// Two authors reply to two different people who are not authors on the
// timeline but share the name Scout.
final alice = '1' * 64, bob = '2' * 64;
final scoutA = 'a' * 64, scoutB = 'b' * 64;
final users = {
alice: UserProfile(pubkey: alice, displayName: 'Alice'),
bob: UserProfile(pubkey: bob, displayName: 'Bob'),
scoutA: UserProfile(pubkey: scoutA, displayName: 'Scout'),
scoutB: UserProfile(pubkey: scoutB, displayName: 'Scout'),
};
final createdAt =
DateTime.utc(2025, 9, 30, 12).millisecondsSinceEpoch ~/ 1000;
UserNote reply(String id, String author, String target) => UserNote(
id: id,
pubkey: author,
createdAt: createdAt,
content: 'A reply',
tags: [
['e', 'parent-$id', '', 'reply'],
['p', target],
],
);
await tester.pumpWidget(
ProviderScope(
overrides: [
userCacheProvider.overrideWith(() => _UserCache(users)),
myPubkeyProvider.overrideWithValue(null),
agentPubkeysProvider.overrideWith((ref) async => const []),
globalNotesProvider.overrideWith(
(ref) async => [
reply('note-1', alice, scoutA),
reply('note-2', bob, scoutB),
],
),
noteReactionsProvider.overrideWith((ref, key) async => const {}),
],
child: MaterialApp(theme: AppTheme.light(), home: const PulsePage()),
),
);
await tester.pumpAndSettle();
final replyLabels = tester
.widgetList<Text>(find.textContaining('Replying to '))
.map((text) => text.data)
.toList();
expect(replyLabels, hasLength(2));
expect(replyLabels.toSet(), hasLength(2));
expect(replyLabels, isNot(contains('Replying to Scout')));
});
}

View File

@@ -653,6 +653,44 @@ void main() {
expect(tester.takeException(), isNull);
});
testWidgets('same-name people results get distinct labels', (tester) async {
final first = 'a' * 64, second = 'b' * 64;
final state = SearchState(
query: 'scout',
channelResults: const [],
userResults: [
DirectoryUser(pubkey: first, displayName: 'Scout'),
DirectoryUser(pubkey: second, displayName: 'Scout'),
],
messageResults: const [],
);
await tester.pumpWidget(
WidgetHelpers.testable(
overrides: [
searchProvider.overrideWith(() => _FakeSearchNotifier(state)),
recentSearchesProvider.overrideWith(
() => _FakeRecentSearchesNotifier(const []),
),
profileProvider.overrideWith(() => _FakeProfileNotifier()),
channelsProvider.overrideWith(() => _FakeChannelsNotifier()),
userCacheProvider.overrideWith(
() => _FakeUserCacheNotifier(
UserProfile(pubkey: first, displayName: 'Scout'),
),
),
],
child: const SearchPage(),
),
);
await tester.pumpAndSettle();
String title(String pubkey) => tester
.widget<Text>(find.byKey(ValueKey('search-person-title-$pubkey')))
.data!;
expect(title(first), isNot(title(second)));
expect(title(first), isNot('Scout'));
});
testWidgets('uses compact content styles and keeps message time by author', (
tester,
) async {