mirror of
https://github.com/block/buzz.git
synced 2026-10-29 13:31:44 +00:00
🤖 ## 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>
387 lines
14 KiB
Dart
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}';
|
|
}
|