Files
buzz/mobile/lib/features/pulse/note_card.dart
klopez4212 c8ea4fb4eb feat(mobile): polish Home navigation and Solo workspace (#8393)
## Summary
Polish mobile Home and Solo: group Search/Inbox beside a separate
profile avatar, move Inbox and Settings to bottom-up pages with Close
controls, and place tabs opposite the plus/compose action. Morph plus to
compose clockwise when entering Solo and reverse counterclockwise when
returning Home, with a directional quarter-turn of the icon inside a
level button and a subtle scale pulse, respecting reduced motion. Align
Search’s Close control with Inbox, simplify its placeholder, and
initialize selected filter contrast correctly. Collapse the plus menu
when leaving Home. Center Solo’s native-glass New chat empty state and
keep it stationary behind the keyboard.

Standardize Tabler icons and neutral backgrounds, retain SF Back/Close
on iOS, use native Liquid Glass headers, tab container, and actions
(solid grey selected tab, slightly darker on iOS) on iOS and black
actions on Android, and smooth the expanded menu corners. Repair native
header safe-area/return layout and compose movement after cancellation.

### Related issue
None found for this UI refinement; follows #8337.

### Testing
- Formatting, analysis, full mobile suite: 3,487 passed, 4 skipped; 3
push-configuration tests passed. File-size checks passed.
- Base UI built and installed on Pixel and iPhone in place. Pixel
navigation/New chat cancellation checked. Subsequent glass and rotation
refinements are previewed on iPhone; motion feel awaits human
confirmation.
- Draft pending human confirmation of the final Settings entrance/return
behavior on iPhone.

### Pixel snapshots
| Solo | Expanded actions |
| --- | --- |
| <img
src="https://raw.githubusercontent.com/block/buzz/16dc0f8af971e63d788fee20974997fab925dec9/docs/screenshots/mobile-home-solo/pixel-solo.png"
width="280" alt="Solo empty state with New chat" /> | <img
src="https://raw.githubusercontent.com/block/buzz/16dc0f8af971e63d788fee20974997fab925dec9/docs/screenshots/mobile-home-solo/pixel-actions.png"
width="280" alt="Expanded channel actions with smooth corners" /> |

---------

Signed-off-by: kenny lopez <klopez4212@gmail.com>
Co-authored-by: Terp <5856f6900e8e0a4e0ea1f4f8d2ee33e4753d2119bc122f94ecafc660b46c0511@buzz.block.builderlab.xyz>
2026-10-16 12:28:02 -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.controlSurface,
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: 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}';
}