Files
buzz/mobile/lib/features/pulse/note_card.dart
Logan Johnson 7a1910e700 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>
2026-09-30 10:46:14 -04:00

387 lines
14 KiB
Dart

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 '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 '../profile/user_profile_sheet.dart';
import 'compose_note_page.dart';
import 'pulse_actions.dart';
import 'pulse_models.dart';
class NoteCard extends HookConsumerWidget {
final UserNote note;
final PulseReactionState reaction;
final bool isAgent;
final bool isFollowing;
final bool canFollow;
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,
required this.reaction,
this.isAgent = false,
this.isFollowing = false,
this.canFollow = false,
this.onReactionChanged,
this.onFollowChanged,
this.names,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final pendingUpvote = useState<bool?>(null);
final pubkey = note.pubkey.toLowerCase();
final profile =
ref.watch(userCacheProvider.select((cache) => cache[pubkey])) ??
ref.read(userCacheProvider.notifier).get(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);
// Clear the optimistic flag only once the refetched server state matches
// it, so the count never flickers back through the stale value mid-refetch.
useEffect(() {
if (pendingUpvote.value != null &&
reaction.reactedByCurrentUser == pendingUpvote.value) {
pendingUpvote.value = null;
}
return null;
}, [reaction.reactedByCurrentUser]);
return Padding(
padding: const EdgeInsets.symmetric(vertical: Grid.twelve),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
GestureDetector(
onTap: () => showUserProfileSheet(
context,
note.pubkey,
names: liveIdentityNamesProvider(labels),
),
child: AvatarImage(
imageUrl: profile?.avatarUrl,
radius: 18,
backgroundColor: context.colors.primaryContainer,
fallback: Text(
// 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,
),
),
isAgent: profile?.ownerPubkey != null,
),
),
const SizedBox(width: Grid.xs),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: GestureDetector(
onTap: () => showUserProfileSheet(
context,
note.pubkey,
names: liveIdentityNamesProvider(labels),
),
child: Row(
children: [
Expanded(
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Flexible(
child: Text(
displayName,
maxLines: 1,
style: messageUsernameTextStyle,
overflow: TextOverflow.ellipsis,
),
),
if (isAgent) ...[
const SizedBox(width: Grid.half),
Icon(
LucideIcons.bot,
size: 13,
color: context.colors.primary,
),
],
],
),
),
const SizedBox(width: Grid.xxs),
ConstrainedBox(
constraints: const BoxConstraints(
maxWidth: Grid.xl,
),
child: Text(
formatPulseRelativeTime(note.createdAt),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: messageTimestampTextStyle.copyWith(
color: context.colors.onSurfaceVariant,
),
),
),
],
),
),
),
if (canFollow) ...[
const SizedBox(width: Grid.half),
_FollowButton(
isFollowing: isFollowing,
onPressed: () async {
if (isFollowing) {
await unfollowUser(ref, note.pubkey);
} else {
await followUser(ref, note.pubkey);
}
onFollowChanged?.call(note.pubkey);
},
),
],
],
),
if (note.replyParentId != null) ...[
const SizedBox(height: 2),
Text(
'Replying to ${_replyTargetLabel(note, labels)}',
style: context.textTheme.labelSmall?.copyWith(
color: context.colors.onSurfaceVariant,
),
),
],
const SizedBox(height: Grid.half),
MessageContent(
content: note.content,
mentionLabels: mentionLabels,
tags: note.tags,
baseStyle: messageBodyTextStyle.copyWith(
color: context.colors.onSurface,
),
),
const SizedBox(height: Grid.xxs),
Row(
children: [
_ActionButton(
icon: effectiveUpvoted
? Icons.favorite
: LucideIcons.heart,
label: effectiveCount > 0 ? '$effectiveCount' : null,
color: effectiveUpvoted ? Colors.redAccent : null,
onTap: () async {
final next = !effectiveUpvoted;
pendingUpvote.value = next;
try {
await toggleNoteUpvote(
ref,
noteId: note.id,
isUpvoted: reaction.reactedByCurrentUser,
reactionEventId: reaction.currentUserReactionId,
);
onReactionChanged?.call();
} catch (_) {
// Revert the optimistic state on failure.
pendingUpvote.value = null;
}
},
),
_ActionButton(
icon: LucideIcons.messageCircle,
onTap: () => Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) =>
ComposeNotePage(replyTo: note, names: labels),
),
),
),
_ActionButton(
icon: LucideIcons.share,
onTap: () async {
await copyToClipboard(
context,
_shareUri(note),
message: 'Copied note URI',
);
},
),
_ActionButton(
icon: LucideIcons.mail,
onTap: () async {
final channel = await ref
.read(channelActionsProvider)
.openDm(pubkeys: [note.pubkey]);
if (!context.mounted) return;
Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => ChannelDetailPage(channel: channel),
),
);
},
),
],
),
],
),
),
],
),
);
}
int _effectiveCount(PulseReactionState reaction, bool? pending) {
if (pending == null || pending == reaction.reactedByCurrentUser) {
return reaction.count;
}
return (reaction.count + (pending ? 1 : -1)).clamp(0, 1 << 31);
}
}
class _FollowButton extends StatelessWidget {
final bool isFollowing;
final VoidCallback onPressed;
const _FollowButton({required this.isFollowing, required this.onPressed});
@override
Widget build(BuildContext context) {
return InkWell(
onTap: onPressed,
borderRadius: BorderRadius.circular(Radii.md),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: Grid.half, vertical: 2),
child: Text(
isFollowing ? 'Following' : 'Follow',
style: context.textTheme.labelSmall?.copyWith(
color: context.colors.primary,
fontWeight: FontWeight.w700,
),
),
),
);
}
}
class _ActionButton extends StatelessWidget {
final IconData icon;
final String? label;
final VoidCallback onTap;
final Color? color;
const _ActionButton({
required this.icon,
required this.onTap,
this.label,
this.color,
});
@override
Widget build(BuildContext context) {
final effectiveColor = color ?? context.colors.onSurfaceVariant;
return InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(Radii.md),
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: Grid.gutter,
vertical: Grid.half,
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 16, color: effectiveColor),
if (label != null) ...[
const SizedBox(width: 3),
Text(
label!,
style: context.textTheme.labelSmall?.copyWith(
color: effectiveColor,
fontWeight: FontWeight.w600,
),
),
],
],
),
),
);
}
}
/// 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 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 names.labelFor(author);
final eventId = note.replyParentId!;
return eventId.length <= 8 ? eventId : '${eventId.substring(0, 8)}…';
}
String formatPulseRelativeTime(int createdAt) {
final date = DateTime.fromMillisecondsSinceEpoch(createdAt * 1000);
final diff = DateTime.now().difference(date);
if (diff.inMinutes < 1) return 'just now';
if (diff.inHours < 1) return '${diff.inMinutes}m';
if (diff.inDays < 1) return '${diff.inHours}h';
if (diff.inDays < 7) return '${diff.inDays}d';
const months = [
'Jan',
'Feb',
'Mar',
'Apr',
'May',
'Jun',
'Jul',
'Aug',
'Sep',
'Oct',
'Nov',
'Dec',
];
return '${months[date.month - 1]} ${date.day}';
}