Files
buzz/mobile/lib/features/forum/forum_thread_page.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

723 lines
23 KiB
Dart

import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:buzz/shared/theme/buzz_icons.dart';
import '../../shared/mentions/agent_identity_provider.dart';
import '../../shared/relay/relay_closed_policy.dart';
import '../../shared/theme/theme.dart';
import '../../shared/widgets/sheet_action_section.dart';
import '../../shared/widgets/avatar_image.dart';
import '../../shared/widgets/buzz_loading_indicator.dart';
import '../../shared/widgets/frosted_app_bar.dart';
import '../../shared/widgets/frosted_scaffold.dart';
import '../../shared/widgets/load_error_view.dart';
import '../../shared/widgets/modal_presentation.dart';
import '../channels/compose_bar.dart';
import '../channels/channel_identity_names_provider.dart';
import '../channels/message_content.dart';
import '../../shared/profile/user_cache_provider.dart';
import '../../shared/profile/user_profile.dart';
import '../profile/user_profile_sheet.dart';
import 'forum_models.dart';
import 'forum_provider.dart';
/// Full-screen page showing a forum post and its replies.
class ForumThreadPage extends HookConsumerWidget {
final String channelId;
final String postEventId;
final String? currentPubkey;
final bool isMember;
final bool isArchived;
const ForumThreadPage({
super.key,
required this.channelId,
required this.postEventId,
required this.currentPubkey,
required this.isMember,
required this.isArchived,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final threadAsync = ref.watch(
forumThreadProvider((channelId: channelId, eventId: postEventId)),
);
// Periodic refresh (every 10s, matching desktop). A settled deadline
// pauses polling; reopening the thread is the explicit retry.
useEffect(() {
final provider = forumThreadProvider((
channelId: channelId,
eventId: postEventId,
));
// Only a deadline already settled when this surface mounts is a
// reopen; a first load that fails after mounting is not retried.
// Hooks run effects during build, so defer the invalidation past it.
if (isSettledRelayDeadline(ref.read(provider))) {
Future.microtask(() {
if (context.mounted) ref.invalidate(provider);
});
}
final timer = Stream.periodic(const Duration(seconds: 10)).listen((_) {
if (!isSettledRelayDeadline(ref.read(provider))) {
ref.invalidate(provider);
}
});
return timer.cancel;
}, [channelId, postEventId]);
final isOwnPost =
threadAsync
.whenData(
(t) =>
currentPubkey != null &&
t.post.pubkey.toLowerCase() == currentPubkey!.toLowerCase(),
)
.value ??
false;
return FrostedScaffold(
appBar: FrostedAppBar(
title: const Text('Thread'),
actions: [
if (isOwnPost)
IconButton(
onPressed: () =>
_showPostActions(context, ref, threadAsync.value!),
tooltip: 'Post actions',
icon: const Icon(BuzzIcons.ellipsis),
),
],
),
body: threadAsync.when(
// A retry from an error shows loading, not the stale error and its Retry.
skipLoadingOnRefresh: !threadAsync.hasError,
loading: () => Padding(
padding: EdgeInsets.only(top: frostedAppBarHeight(context)),
child: const Center(
child: BuzzLoadingIndicator(
size: 44,
semanticLabel: 'Loading thread',
),
),
),
error: (e, _) => Padding(
padding: EdgeInsets.only(top: frostedAppBarHeight(context)),
child: LoadErrorView(
message: 'Failed to load thread',
onRetry: () => ref.invalidate(
forumThreadProvider((channelId: channelId, eventId: postEventId)),
),
),
),
data: (thread) => _ThreadContent(
thread: thread,
channelId: channelId,
currentPubkey: currentPubkey,
isMember: isMember,
isArchived: isArchived,
),
),
);
}
void _showPostActions(
BuildContext context,
WidgetRef ref,
ForumThreadResponse thread,
) {
showBuzzModalBottomSheet<void>(
context: context,
showDragHandle: true,
builder: (sheetContext) => SafeArea(
child: IconTheme.merge(
data: const IconThemeData(size: 22),
child: Padding(
padding: const EdgeInsets.fromLTRB(
Grid.gutter,
0,
Grid.gutter,
Grid.xs,
),
child: SheetActionSection(
children: [
ListTile(
leading: const Icon(BuzzIcons.copy),
title: const Text('Copy text'),
onTap: () {
Navigator.of(sheetContext).pop();
Clipboard.setData(ClipboardData(text: thread.post.content));
},
),
ListTile(
leading: Icon(
BuzzIcons.trash2,
color: sheetContext.colors.error,
),
title: Text(
'Delete post',
style: TextStyle(color: sheetContext.colors.error),
),
onTap: () {
Navigator.of(sheetContext).pop();
_confirmDeletePost(context, ref, thread.post.eventId);
},
),
],
),
),
),
),
);
}
void _confirmDeletePost(BuildContext context, WidgetRef ref, String eventId) {
showBuzzDialog<void>(
context: context,
builder: (dialogContext) => AlertDialog(
title: const Text('Delete post'),
content: const Text('This cannot be undone.'),
actions: [
TextButton(
onPressed: () => Navigator.of(dialogContext).pop(),
child: const Text('Cancel'),
),
FilledButton(
onPressed: () async {
Navigator.of(dialogContext).pop();
await deleteForumEvent(
ref,
channelId: channelId,
eventId: eventId,
);
if (context.mounted) {
Navigator.of(context).pop();
}
},
style: FilledButton.styleFrom(
backgroundColor: dialogContext.colors.error,
),
child: const Text('Delete'),
),
],
),
);
}
}
class _ThreadContent extends HookConsumerWidget {
final ForumThreadResponse thread;
final String channelId;
final String? currentPubkey;
final bool isMember;
final bool isArchived;
const _ThreadContent({
required this.thread,
required this.channelId,
required this.currentPubkey,
required this.isMember,
required this.isArchived,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
// Background media delivery may outlive this route's WidgetRef.
final providerContainer = ProviderScope.containerOf(context, listen: false);
final forumDelivery = ForumEventDelivery.capture(providerContainer);
final post = thread.post;
final replies = thread.replies;
// Preload profiles for all participants and tagged mentions.
final allPubkeys = useMemoized(() {
final pks = <String>{
post.pubkey.toLowerCase(),
...post.mentionPubkeys.map((pubkey) => pubkey.toLowerCase()),
};
for (final reply in replies) {
pks
..add(reply.pubkey.toLowerCase())
..addAll(reply.mentionPubkeys.map((pubkey) => pubkey.toLowerCase()));
}
return pks.toList()..sort();
}, [post, replies]);
final allPubkeysKey = allPubkeys.join('\u0000');
useEffect(() {
if (allPubkeys.isNotEmpty) {
ref.read(userCacheProvider.notifier).preload(allPubkeys);
}
return null;
}, [allPubkeysKey]);
return Column(
children: [
Expanded(
child: ListView(
padding: EdgeInsets.only(
top: frostedAppBarHeight(context),
bottom: Grid.xs,
),
children: [
_OriginalPost(post: post),
Padding(
padding: const EdgeInsets.symmetric(
horizontal: Grid.gutter,
vertical: Grid.xxs,
),
child: Row(
children: [
Icon(
BuzzIcons.messageSquare,
size: 16,
color: context.colors.onSurfaceVariant,
),
const SizedBox(width: Grid.half),
Text(
'${replies.length} ${replies.length == 1 ? 'reply' : 'replies'}',
style: context.textTheme.labelMedium?.copyWith(
color: context.colors.onSurfaceVariant,
fontWeight: FontWeight.w600,
),
),
],
),
),
// Reply list
if (replies.isEmpty)
Padding(
padding: const EdgeInsets.all(Grid.sm),
child: Text(
'No replies yet. Be the first to respond.',
style: context.textTheme.bodyMedium?.copyWith(
color: context.colors.onSurfaceVariant,
),
textAlign: TextAlign.center,
),
)
else
for (final reply in replies)
_ReplyRow(
reply: reply,
currentPubkey: currentPubkey,
channelId: channelId,
rootEventId: post.eventId,
),
],
),
),
// Reply composer
if (isMember && !isArchived)
ComposeBar(
channelId: channelId,
hintText: 'Reply to this post\u2026',
onSend:
(
content,
mentionPubkeys, {
mediaTags = const <List<String>>[],
}) => forumDelivery.createReply(
channelId: channelId,
parentEventId: post.eventId,
content: content,
mentionPubkeys: mentionPubkeys,
mediaTags: mediaTags,
),
),
],
);
}
}
class _OriginalPost extends ConsumerWidget {
final ForumPost post;
const _OriginalPost({required this.post});
@override
Widget build(BuildContext context, WidgetRef ref) {
final pk = post.pubkey.toLowerCase();
final profile =
ref.watch(userCacheProvider.select((cache) => cache[pk])) ??
ref.read(userCacheProvider.notifier).get(pk);
final displayName = watchChannelIdentityLabel(ref, post.channelId, pk);
final userCache = ref.watch(userCacheProvider);
final agentMentionPubkeys = agentPubkeysWithProfileOwners(
knownAgentPubkeys: ref.watch(agentMentionPubkeysProvider(post.channelId)),
profileOwnedAgentPubkeys: [
for (final profile in userCache.values)
if (profile.ownerPubkey != null) profile.pubkey,
],
);
final mentionNames = mentionNamesWithDirectoryLabels(
mentionPubkeys: post.mentionPubkeys,
profileMentionNames: _buildMentionNames(post.mentionPubkeys, userCache),
directoryDisplayNames: ref.watch(agentDirectoryDisplayNamesProvider),
agentMentionPubkeys: agentMentionPubkeys,
);
final mentionLabels = watchChannelIdentityLabels(
ref,
post.channelId,
post.mentionPubkeys,
);
return Padding(
padding: const EdgeInsets.all(Grid.xs),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
GestureDetector(
onTap: () => showUserProfileSheet(
context,
post.pubkey,
names: channelIdentityNamesProvider(post.channelId),
),
child: _Avatar(
key: ValueKey('forum-original-avatar-${post.eventId}'),
profile: profile,
pubkey: post.pubkey,
radius: 16,
isAgent: agentMentionPubkeys.contains(pk),
),
),
const SizedBox(width: Grid.xxs),
Expanded(
child: Row(
children: [
Expanded(
child: GestureDetector(
onTap: () => showUserProfileSheet(
context,
post.pubkey,
names: channelIdentityNamesProvider(post.channelId),
),
child: Text(
displayName,
maxLines: 1,
style: messageUsernameTextStyle,
overflow: TextOverflow.ellipsis,
),
),
),
const SizedBox(width: Grid.xxs),
ConstrainedBox(
constraints: const BoxConstraints(maxWidth: Grid.xxl),
child: Text(
formatRelativeTime(post.createdAt),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: messageTimestampTextStyle.copyWith(
color: context.colors.onSurfaceVariant,
),
),
),
],
),
),
],
),
const SizedBox(height: Grid.xxs),
MessageContent(
content: post.content,
mentionNames: mentionNames,
mentionLabels: mentionLabels,
agentMentionPubkeys: agentMentionPubkeys,
tags: post.tags,
baseStyle: messageBodyTextStyle.copyWith(
color: context.colors.onSurface,
),
onMentionTap: (pubkey) => showUserProfileSheet(
context,
pubkey,
names: channelIdentityNamesProvider(post.channelId),
),
),
],
),
);
}
}
class _ReplyRow extends ConsumerWidget {
final ThreadReply reply;
final String? currentPubkey;
final String channelId;
final String rootEventId;
const _ReplyRow({
required this.reply,
required this.currentPubkey,
required this.channelId,
required this.rootEventId,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final pk = reply.pubkey.toLowerCase();
final profile =
ref.watch(userCacheProvider.select((cache) => cache[pk])) ??
ref.read(userCacheProvider.notifier).get(pk);
final displayName = watchChannelIdentityLabel(ref, channelId, pk);
final userCache = ref.watch(userCacheProvider);
final agentMentionPubkeys = agentPubkeysWithProfileOwners(
knownAgentPubkeys: ref.watch(agentMentionPubkeysProvider(channelId)),
profileOwnedAgentPubkeys: [
for (final profile in userCache.values)
if (profile.ownerPubkey != null) profile.pubkey,
],
);
final mentionNames = mentionNamesWithDirectoryLabels(
mentionPubkeys: reply.mentionPubkeys,
profileMentionNames: _buildMentionNames(reply.mentionPubkeys, userCache),
directoryDisplayNames: ref.watch(agentDirectoryDisplayNamesProvider),
agentMentionPubkeys: agentMentionPubkeys,
);
final mentionLabels = watchChannelIdentityLabels(
ref,
channelId,
reply.mentionPubkeys,
);
return Padding(
padding: const EdgeInsets.symmetric(
horizontal: Grid.gutter,
vertical: Grid.xxs,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
GestureDetector(
onTap: () => showUserProfileSheet(
context,
reply.pubkey,
names: channelIdentityNamesProvider(channelId),
),
child: _Avatar(
key: ValueKey('forum-reply-avatar-${reply.eventId}'),
profile: profile,
pubkey: reply.pubkey,
radius: 12,
isAgent: agentMentionPubkeys.contains(pk),
),
),
const SizedBox(width: Grid.xxs),
Expanded(
child: Row(
children: [
Expanded(
child: GestureDetector(
onTap: () => showUserProfileSheet(
context,
reply.pubkey,
names: channelIdentityNamesProvider(channelId),
),
child: Text(
displayName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: messageUsernameTextStyle,
),
),
),
const SizedBox(width: Grid.xxs),
ConstrainedBox(
constraints: const BoxConstraints(maxWidth: Grid.xxl),
child: Text(
formatRelativeTime(reply.createdAt),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: messageTimestampTextStyle.copyWith(
color: context.colors.onSurfaceVariant,
),
),
),
],
),
),
SizedBox(
width: 28,
height: 28,
child: IconButton(
onPressed: () => _showActions(context, ref),
icon: Icon(
BuzzIcons.ellipsis,
size: 16,
color: context.colors.onSurfaceVariant,
),
padding: EdgeInsets.zero,
visualDensity: VisualDensity.compact,
),
),
],
),
Padding(
padding: const EdgeInsets.only(left: 32, top: Grid.half),
child: MessageContent(
content: reply.content,
mentionNames: mentionNames,
mentionLabels: mentionLabels,
agentMentionPubkeys: agentMentionPubkeys,
tags: reply.tags,
baseStyle: messageBodyTextStyle.copyWith(
color: context.colors.onSurface,
),
onMentionTap: (pubkey) => showUserProfileSheet(
context,
pubkey,
names: channelIdentityNamesProvider(channelId),
),
),
),
],
),
);
}
void _showActions(BuildContext context, WidgetRef ref) {
final isOwn =
currentPubkey != null &&
reply.pubkey.toLowerCase() == currentPubkey!.toLowerCase();
showBuzzModalBottomSheet<void>(
context: context,
showDragHandle: true,
builder: (sheetContext) => SafeArea(
child: IconTheme.merge(
data: const IconThemeData(size: 22),
child: Padding(
padding: const EdgeInsets.fromLTRB(
Grid.gutter,
0,
Grid.gutter,
Grid.xs,
),
child: SheetActionSection(
children: [
ListTile(
leading: const Icon(BuzzIcons.copy),
title: const Text('Copy text'),
onTap: () {
Navigator.of(sheetContext).pop();
Clipboard.setData(ClipboardData(text: reply.content));
},
),
if (isOwn)
ListTile(
leading: Icon(
BuzzIcons.trash2,
color: sheetContext.colors.error,
),
title: Text(
'Delete reply',
style: TextStyle(color: sheetContext.colors.error),
),
onTap: () {
Navigator.of(sheetContext).pop();
_confirmDelete(context, ref);
},
),
],
),
),
),
),
);
}
void _confirmDelete(BuildContext context, WidgetRef ref) {
showBuzzDialog<void>(
context: context,
builder: (dialogContext) => AlertDialog(
title: const Text('Delete reply'),
content: const Text('This cannot be undone.'),
actions: [
TextButton(
onPressed: () => Navigator.of(dialogContext).pop(),
child: const Text('Cancel'),
),
FilledButton(
onPressed: () async {
Navigator.of(dialogContext).pop();
await deleteForumEvent(
ref,
channelId: channelId,
eventId: reply.eventId,
rootEventId: rootEventId,
);
},
style: FilledButton.styleFrom(
backgroundColor: dialogContext.colors.error,
),
child: const Text('Delete'),
),
],
),
);
}
}
class _Avatar extends StatelessWidget {
final UserProfile? profile;
final String pubkey;
final double radius;
final bool isAgent;
const _Avatar({
super.key,
required this.profile,
required this.pubkey,
required this.radius,
required this.isAgent,
});
@override
Widget build(BuildContext context) {
final initial =
profile?.initial ?? (pubkey.isNotEmpty ? pubkey[0].toUpperCase() : '?');
final avatarUrl = profile?.avatarUrl;
return AvatarImage(
imageUrl: avatarUrl,
radius: radius,
backgroundColor: context.controlSurface,
fallback: Text(
initial,
style: TextStyle(
fontSize: radius * 0.75,
fontWeight: FontWeight.w600,
color: context.colors.onPrimaryContainer,
),
),
isAgent: isAgent,
);
}
}
Map<String, String> _buildMentionNames(
List<String> mentionPubkeys,
Map<String, UserProfile> userCache,
) {
final names = <String, String>{};
for (final pk in mentionPubkeys) {
final p = userCache[pk.toLowerCase()];
if (p?.displayName != null) {
names[pk.toLowerCase()] = p!.displayName!;
}
}
return names;
}