Files
buzz/mobile/lib/features/pulse/note_card.dart
klopez4212 bbcbd7ce13 Preview Tabler icons in the mobile app (#8109)
## Summary

Preview buzz-app's Tabler icon style in the mobile app, replacing
app-drawn Lucide glyphs across channels, messages, activity, search, and
settings. A shared `BuzzIcons` mapping uses the bundled Tabler 3.46.0
outline font, matching buzz-app's version, with its MIT license and
source documented.

Native UIKit controls retain SF Symbols. The former 300/500 navigation
glyphs use Tabler's standard outline; existing selection colors and
labels remain.

### Related issue

No matching Tabler mobile migration issue or PR found. Related native
iOS controls: #8055.

### Testing

- Pre-push mobile analysis, formatting, file-size checks, and the full
mobile test suite passed.
- 194 focused widget tests passed, including channels, home, search,
settings, and message content.
- Light and dark community-settings renders inspected.
- Signed iPhone Release build succeeded; installed and launched as Buzz
Tabler on a physical iPhone.
- Full `just ci` attempted; stopped during Rust clippy compilation
because the Mac ran low on disk space. Full repository validation is not
claimed.

Signed-off-by: kenny lopez <klopez4212@gmail.com>
2026-10-08 10:40:12 -04:00

385 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:buzz/shared/theme/buzz_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(
BuzzIcons.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 : BuzzIcons.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: BuzzIcons.messageCircle,
onTap: () => Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) =>
ComposeNotePage(replyTo: note, names: labels),
),
),
),
_ActionButton(
icon: BuzzIcons.share,
onTap: () async {
await copyToClipboard(
context,
_shareUri(note),
message: 'Copied note URI',
);
},
),
_ActionButton(
icon: BuzzIcons.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}';
}