diff --git a/mobile/lib/features/activity/activity_page.dart b/mobile/lib/features/activity/activity_page.dart index 563d0af138..c3f46d65fb 100644 --- a/mobile/lib/features/activity/activity_page.dart +++ b/mobile/lib/features/activity/activity_page.dart @@ -59,7 +59,8 @@ class ActivityPage extends HookConsumerWidget { child: ListView.separated( padding: const EdgeInsets.symmetric(vertical: Grid.xxs), itemCount: items.length, - separatorBuilder: (_, _) => const Divider(height: 1), + separatorBuilder: (_, _) => + const SizedBox(height: Grid.half), itemBuilder: (context, index) { final item = items[index]; return _FeedItemTile( diff --git a/mobile/lib/features/channels/channel_detail_page.dart b/mobile/lib/features/channels/channel_detail_page.dart index b9db29b113..d1e729768e 100644 --- a/mobile/lib/features/channels/channel_detail_page.dart +++ b/mobile/lib/features/channels/channel_detail_page.dart @@ -12,6 +12,7 @@ import '../profile/presence_cache_provider.dart'; import '../profile/profile_provider.dart'; import '../profile/user_cache_provider.dart'; import '../profile/user_profile.dart'; +import '../forum/forum_posts_view.dart'; import 'channel.dart'; import 'channel_management_provider.dart'; import 'channel_messages_provider.dart'; @@ -145,7 +146,10 @@ class ChannelDetailPage extends HookConsumerWidget { children: [ Expanded( child: resolvedChannel.isForum - ? _ForumPlaceholder(channel: resolvedChannel) + ? ForumPostsView( + channel: resolvedChannel, + currentPubkey: currentPubkey, + ) : messagesState.when( loading: () => const Center(child: CircularProgressIndicator()), @@ -190,8 +194,7 @@ class ChannelDetailPage extends HookConsumerWidget { mentionPubkeys: mentionPubkeys, ), ) - else if (!resolvedChannel.isForum && - !resolvedChannel.isDm && + else if (!resolvedChannel.isDm && (!resolvedChannel.isMember || resolvedChannel.isArchived)) _ReadOnlyNotice(channel: resolvedChannel), ], @@ -1027,55 +1030,6 @@ IconData channelIcon(Channel channel) { return LucideIcons.hash; } -class _ForumPlaceholder extends StatelessWidget { - final Channel channel; - - const _ForumPlaceholder({required this.channel}); - - @override - Widget build(BuildContext context) { - return Center( - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: Grid.sm), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - LucideIcons.messageSquareText, - size: Grid.xl, - color: context.colors.outline, - ), - const SizedBox(height: Grid.xs), - Text( - 'Forum threads are not on mobile yet', - style: context.textTheme.titleMedium, - textAlign: TextAlign.center, - ), - const SizedBox(height: Grid.xxs), - Text( - 'You can still view channel context, canvas, and members from the actions above.', - style: context.textTheme.bodyMedium?.copyWith( - color: context.colors.onSurfaceVariant, - ), - textAlign: TextAlign.center, - ), - if (channel.description.trim().isNotEmpty) ...[ - const SizedBox(height: Grid.xs), - Text( - channel.description, - style: context.textTheme.bodySmall?.copyWith( - color: context.colors.outline, - ), - textAlign: TextAlign.center, - ), - ], - ], - ), - ), - ); - } -} - class _ReadOnlyNotice extends StatelessWidget { final Channel channel; diff --git a/mobile/lib/features/forum/forum_models.dart b/mobile/lib/features/forum/forum_models.dart new file mode 100644 index 0000000000..7fb6a0c88c --- /dev/null +++ b/mobile/lib/features/forum/forum_models.dart @@ -0,0 +1,195 @@ +import 'package:flutter/foundation.dart'; + +/// A top-level forum post with an optional thread summary. +@immutable +class ForumPost { + final String eventId; + final String pubkey; + final String content; + final int kind; + final int createdAt; + final String channelId; + final List> tags; + final ForumThreadSummary? threadSummary; + + const ForumPost({ + required this.eventId, + required this.pubkey, + required this.content, + required this.kind, + required this.createdAt, + required this.channelId, + required this.tags, + this.threadSummary, + }); + + factory ForumPost.fromJson(Map json) { + final rawSummary = json['thread_summary'] as Map?; + return ForumPost( + eventId: json['event_id'] as String, + pubkey: json['pubkey'] as String, + content: json['content'] as String, + kind: json['kind'] as int, + createdAt: json['created_at'] as int, + channelId: json['channel_id'] as String, + tags: (json['tags'] as List) + .map((t) => (t as List).map((e) => e as String).toList()) + .toList(), + threadSummary: rawSummary != null + ? ForumThreadSummary.fromJson(rawSummary) + : null, + ); + } + + /// Extract mention pubkeys from p-tags. + List get mentionPubkeys => [ + for (final tag in tags) + if (tag.length >= 2 && tag[0] == 'p') tag[1], + ]; +} + +/// Summary of replies on a forum post. +@immutable +class ForumThreadSummary { + final int replyCount; + final int descendantCount; + final int? lastReplyAt; + final List participants; + + const ForumThreadSummary({ + required this.replyCount, + required this.descendantCount, + this.lastReplyAt, + required this.participants, + }); + + factory ForumThreadSummary.fromJson(Map json) { + return ForumThreadSummary( + replyCount: json['reply_count'] as int? ?? 0, + descendantCount: json['descendant_count'] as int? ?? 0, + lastReplyAt: json['last_reply_at'] as int?, + participants: + (json['participants'] as List?) + ?.map((e) => e as String) + .toList() ?? + const [], + ); + } +} + +/// A reply within a forum thread. +@immutable +class ThreadReply { + final String eventId; + final String pubkey; + final String content; + final int kind; + final int createdAt; + final String channelId; + final List> tags; + final String? parentEventId; + final String? rootEventId; + final int depth; + + const ThreadReply({ + required this.eventId, + required this.pubkey, + required this.content, + required this.kind, + required this.createdAt, + required this.channelId, + required this.tags, + this.parentEventId, + this.rootEventId, + required this.depth, + }); + + factory ThreadReply.fromJson(Map json) { + return ThreadReply( + eventId: json['event_id'] as String, + pubkey: json['pubkey'] as String, + content: json['content'] as String, + kind: json['kind'] as int, + createdAt: json['created_at'] as int, + channelId: json['channel_id'] as String, + tags: (json['tags'] as List) + .map((t) => (t as List).map((e) => e as String).toList()) + .toList(), + parentEventId: json['parent_event_id'] as String?, + rootEventId: json['root_event_id'] as String?, + depth: json['depth'] as int? ?? 0, + ); + } + + /// Extract mention pubkeys from p-tags. + List get mentionPubkeys => [ + for (final tag in tags) + if (tag.length >= 2 && tag[0] == 'p') tag[1], + ]; +} + +/// Paginated response for forum posts. +@immutable +class ForumPostsResponse { + final List posts; + final int? nextCursor; + + const ForumPostsResponse({required this.posts, this.nextCursor}); + + factory ForumPostsResponse.fromJson(Map json) { + final messages = json['messages'] as List? ?? const []; + return ForumPostsResponse( + posts: messages + .cast>() + .map(ForumPost.fromJson) + .toList(), + nextCursor: json['next_cursor'] as int?, + ); + } +} + +/// Response for a single forum thread with replies. +@immutable +class ForumThreadResponse { + final ForumPost post; + final List replies; + final int totalReplies; + final String? nextCursor; + + const ForumThreadResponse({ + required this.post, + required this.replies, + required this.totalReplies, + this.nextCursor, + }); + + factory ForumThreadResponse.fromJson(Map json) { + final repliesJson = json['replies'] as List? ?? const []; + return ForumThreadResponse( + post: ForumPost.fromJson(json['root'] as Map), + replies: repliesJson + .cast>() + .map(ThreadReply.fromJson) + .toList(), + totalReplies: json['total_replies'] as int? ?? 0, + nextCursor: json['next_cursor'] as String?, + ); + } +} + +/// Format a unix timestamp as a relative time string (e.g. "2h ago"). +String formatRelativeTime(int timestamp) { + final now = DateTime.now().millisecondsSinceEpoch ~/ 1000; + final diff = now - timestamp; + + if (diff < 60) return 'just now'; + if (diff < 3600) return '${diff ~/ 60}m ago'; + if (diff < 86400) return '${diff ~/ 3600}h ago'; + if (diff < 604800) return '${diff ~/ 86400}d ago'; + + final dt = DateTime.fromMillisecondsSinceEpoch( + timestamp * 1000, + isUtc: true, + ).toLocal(); + return '${dt.month}/${dt.day}/${dt.year}'; +} diff --git a/mobile/lib/features/forum/forum_post_card.dart b/mobile/lib/features/forum/forum_post_card.dart new file mode 100644 index 0000000000..5597b42a26 --- /dev/null +++ b/mobile/lib/features/forum/forum_post_card.dart @@ -0,0 +1,253 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; + +import '../../shared/theme/theme.dart'; +import '../profile/user_cache_provider.dart'; +import '../profile/user_profile.dart'; +import 'forum_models.dart'; + +/// Card displaying a forum post preview in the posts list. +/// +/// Long-press opens an action sheet (copy, delete) matching the stream +/// message pattern from channel_detail_page.dart. +class ForumPostCard extends ConsumerWidget { + final ForumPost post; + final String? currentPubkey; + final VoidCallback onTap; + final void Function(String eventId)? onDelete; + + const ForumPostCard({ + super.key, + required this.post, + required this.currentPubkey, + required this.onTap, + this.onDelete, + }); + + @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 = profile?.label ?? _shortPubkey(post.pubkey); + final preview = post.content.length > 200 + ? '${post.content.substring(0, 200)}...' + : post.content; + final summary = post.threadSummary; + + return GestureDetector( + onTap: onTap, + onLongPress: () => _showActions(context), + child: Container( + width: double.infinity, + padding: const EdgeInsets.all(Grid.twelve), + decoration: BoxDecoration( + color: context.colors.surfaceContainerHighest, + borderRadius: BorderRadius.circular(Radii.lg), + border: Border.all( + color: context.colors.outlineVariant.withValues(alpha: 0.5), + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Author row + Row( + children: [ + _PostAvatar(profile: profile, pubkey: post.pubkey), + const SizedBox(width: Grid.xxs), + Expanded( + child: Text( + displayName, + style: context.textTheme.labelMedium?.copyWith( + fontWeight: FontWeight.w600, + ), + overflow: TextOverflow.ellipsis, + ), + ), + Text( + formatRelativeTime(post.createdAt), + style: context.textTheme.labelSmall?.copyWith( + color: context.colors.outline, + ), + ), + const SizedBox(width: Grid.half), + SizedBox( + width: 24, + height: 24, + child: IconButton( + onPressed: () => _showActions(context), + icon: Icon( + LucideIcons.ellipsis, + size: 16, + color: context.colors.outline, + ), + padding: EdgeInsets.zero, + visualDensity: VisualDensity.compact, + ), + ), + ], + ), + const SizedBox(height: Grid.xxs), + + // Content preview + Text( + preview, + style: context.textTheme.bodyMedium?.copyWith( + color: context.colors.onSurface, + ), + maxLines: 4, + overflow: TextOverflow.ellipsis, + ), + + // Thread summary + if (summary != null && summary.replyCount > 0) ...[ + const SizedBox(height: Grid.xxs), + Row( + children: [ + Icon( + LucideIcons.messageSquare, + size: 14, + color: context.colors.outline, + ), + const SizedBox(width: Grid.half), + Text( + '${summary.replyCount} ${summary.replyCount == 1 ? 'reply' : 'replies'}', + style: context.textTheme.labelSmall?.copyWith( + color: context.colors.outline, + ), + ), + if (summary.lastReplyAt != null) ...[ + const SizedBox(width: Grid.half), + Text( + '\u00b7', + style: context.textTheme.labelSmall?.copyWith( + color: context.colors.outline.withValues(alpha: 0.5), + ), + ), + const SizedBox(width: Grid.half), + Text( + 'last ${formatRelativeTime(summary.lastReplyAt!)}', + style: context.textTheme.labelSmall?.copyWith( + color: context.colors.outline, + ), + ), + ], + ], + ), + ], + ], + ), + ), + ); + } + + void _showActions(BuildContext context) { + final isOwn = + currentPubkey != null && + post.pubkey.toLowerCase() == currentPubkey!.toLowerCase(); + + showModalBottomSheet( + context: context, + showDragHandle: true, + builder: (sheetContext) => SafeArea( + child: Padding( + padding: const EdgeInsets.fromLTRB(Grid.xs, 0, Grid.xs, Grid.xs), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ListTile( + leading: const Icon(LucideIcons.copy), + title: const Text('Copy text'), + onTap: () { + Navigator.of(sheetContext).pop(); + Clipboard.setData(ClipboardData(text: post.content)); + }, + ), + if (isOwn && onDelete != null) + ListTile( + leading: Icon( + LucideIcons.trash2, + color: Theme.of(sheetContext).colorScheme.error, + ), + title: Text( + 'Delete post', + style: TextStyle( + color: Theme.of(sheetContext).colorScheme.error, + ), + ), + onTap: () { + Navigator.of(sheetContext).pop(); + _confirmDelete(context); + }, + ), + ], + ), + ), + ), + ); + } + + void _confirmDelete(BuildContext context) { + showDialog( + 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: () { + Navigator.of(dialogContext).pop(); + onDelete?.call(post.eventId); + }, + style: FilledButton.styleFrom( + backgroundColor: Theme.of(dialogContext).colorScheme.error, + ), + child: const Text('Delete'), + ), + ], + ), + ); + } +} + +class _PostAvatar extends StatelessWidget { + final UserProfile? profile; + final String pubkey; + + const _PostAvatar({required this.profile, required this.pubkey}); + + @override + Widget build(BuildContext context) { + final initial = + profile?.initial ?? (pubkey.isNotEmpty ? pubkey[0].toUpperCase() : '?'); + final avatarUrl = profile?.avatarUrl; + + return CircleAvatar( + radius: 14, + backgroundColor: context.colors.primaryContainer, + backgroundImage: avatarUrl != null ? NetworkImage(avatarUrl) : null, + child: avatarUrl == null + ? Text( + initial, + style: context.textTheme.labelSmall?.copyWith( + color: context.colors.onPrimaryContainer, + fontWeight: FontWeight.w600, + ), + ) + : null, + ); + } +} + +String _shortPubkey(String pubkey) { + if (pubkey.length > 12) return '${pubkey.substring(0, 8)}\u2026'; + return pubkey; +} diff --git a/mobile/lib/features/forum/forum_posts_view.dart b/mobile/lib/features/forum/forum_posts_view.dart new file mode 100644 index 0000000000..21962471ec --- /dev/null +++ b/mobile/lib/features/forum/forum_posts_view.dart @@ -0,0 +1,197 @@ +import 'dart:async'; + +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 '../../shared/theme/theme.dart'; +import '../channels/channel.dart'; +import '../channels/compose_bar.dart'; +import 'forum_models.dart'; +import 'forum_post_card.dart'; +import 'forum_provider.dart'; +import 'forum_thread_page.dart'; + +/// Main forum view — replaces the old _ForumPlaceholder. +/// +/// Shows a list of forum posts for the channel with a FAB to open the compose +/// bar, and navigates to [ForumThreadPage] when a post is tapped. +class ForumPostsView extends HookConsumerWidget { + final Channel channel; + final String? currentPubkey; + + const ForumPostsView({ + super.key, + required this.channel, + required this.currentPubkey, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final postsAsync = ref.watch(forumPostsProvider(channel.id)); + final isComposing = useState(false); + + // Periodic refresh (every 15s, matching desktop). + useEffect(() { + final timer = Stream.periodic(const Duration(seconds: 15)).listen((_) { + ref.invalidate(forumPostsProvider(channel.id)); + }); + return timer.cancel; + }, [channel.id]); + + final canPost = channel.isMember && !channel.isArchived; + + return Column( + children: [ + Expanded( + child: Scaffold( + // Transparent so the parent Scaffold's background shows through. + backgroundColor: Colors.transparent, + floatingActionButton: canPost && !isComposing.value + ? FloatingActionButton( + onPressed: () => isComposing.value = true, + tooltip: 'New post', + shape: const CircleBorder(), + child: const Icon(LucideIcons.plus), + ) + : null, + body: postsAsync.when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (e, _) => Center( + child: Text( + 'Failed to load posts', + style: context.textTheme.bodyMedium?.copyWith( + color: context.colors.error, + ), + ), + ), + data: (response) { + final posts = response.posts; + if (posts.isEmpty) { + return _EmptyState( + isMember: channel.isMember, + isArchived: channel.isArchived, + ); + } + return RefreshIndicator( + onRefresh: () async { + ref.invalidate(forumPostsProvider(channel.id)); + await ref.read(forumPostsProvider(channel.id).future); + }, + child: ListView.separated( + padding: const EdgeInsets.all(Grid.xs), + itemCount: posts.length, + separatorBuilder: (_, _) => + const SizedBox(height: Grid.xxs), + itemBuilder: (context, index) { + final post = posts[index]; + return ForumPostCard( + post: post, + currentPubkey: currentPubkey, + onTap: () => _openThread(context, post), + onDelete: (eventId) async { + await deleteForumEvent( + ref, + channelId: channel.id, + eventId: eventId, + ); + }, + ); + }, + ), + ); + }, + ), + ), + ), + if (isComposing.value) ...[ + Align( + alignment: Alignment.centerRight, + child: Padding( + padding: const EdgeInsets.only(right: Grid.xxs), + child: IconButton( + onPressed: () => isComposing.value = false, + icon: const Icon(LucideIcons.x, size: 18), + tooltip: 'Dismiss', + visualDensity: VisualDensity.compact, + ), + ), + ), + ComposeBar( + channelId: channel.id, + hintText: 'Write your post\u2026', + onSend: (content, mentionPubkeys) async { + await createForumPost( + ref, + channelId: channel.id, + content: content, + mentionPubkeys: mentionPubkeys, + ); + if (context.mounted) isComposing.value = false; + }, + ), + ], + ], + ); + } + + void _openThread(BuildContext context, ForumPost post) { + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => ForumThreadPage( + channelId: channel.id, + postEventId: post.eventId, + currentPubkey: currentPubkey, + isMember: channel.isMember, + isArchived: channel.isArchived, + ), + ), + ); + } +} + +class _EmptyState extends StatelessWidget { + final bool isMember; + final bool isArchived; + + const _EmptyState({required this.isMember, required this.isArchived}); + + @override + Widget build(BuildContext context) { + return Center( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: Grid.sm), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + LucideIcons.messageSquareText, + size: Grid.xl, + color: context.colors.outline, + ), + const SizedBox(height: Grid.xxs), + Text( + 'No posts yet', + style: context.textTheme.bodyLarge?.copyWith( + color: context.colors.onSurfaceVariant, + ), + ), + const SizedBox(height: Grid.half), + Text( + isArchived + ? 'This forum is archived.' + : isMember + ? 'Start a discussion by creating the first post.' + : 'Join this forum to create posts.', + style: context.textTheme.bodySmall?.copyWith( + color: context.colors.outline, + ), + textAlign: TextAlign.center, + ), + ], + ), + ), + ); + } +} diff --git a/mobile/lib/features/forum/forum_provider.dart b/mobile/lib/features/forum/forum_provider.dart new file mode 100644 index 0000000000..64c05cdbe0 --- /dev/null +++ b/mobile/lib/features/forum/forum_provider.dart @@ -0,0 +1,119 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +import '../../shared/relay/relay.dart'; +import '../channels/channel_management_provider.dart'; +import 'forum_models.dart'; + +/// Fetches forum posts for a channel from the REST API. +/// +/// Posts are top-level kind-45001 events with thread summaries. +/// Invalidate to refresh (e.g. after creating a new post). +final forumPostsProvider = FutureProvider.family(( + ref, + channelId, +) async { + final client = ref.watch(relayClientProvider); + final json = + await client.get( + '/api/channels/$channelId/messages', + queryParams: {'kinds': '${EventKind.forumPost}', 'limit': '50'}, + ) + as Map; + return ForumPostsResponse.fromJson(json); +}); + +/// Fetches a forum thread (root post + replies) from the REST API. +final forumThreadProvider = + FutureProvider.family< + ForumThreadResponse, + ({String channelId, String eventId}) + >((ref, args) async { + final client = ref.watch(relayClientProvider); + final json = + await client.get( + '/api/channels/${args.channelId}/threads/${args.eventId}', + queryParams: {'limit': '100'}, + ) + as Map; + return ForumThreadResponse.fromJson(json); + }); + +/// Creates a new forum post (kind 45001). +Future createForumPost( + WidgetRef ref, { + required String channelId, + required String content, + List mentionPubkeys = const [], +}) async { + final config = ref.read(relayConfigProvider); + final client = ref.read(relayClientProvider); + final relay = SignedEventRelay(client: client, nsec: config.nsec); + + final selfPubkey = relay.pubkey?.toLowerCase(); + final seen = {?selfPubkey}; + final normalizedMentions = [ + for (final pk in mentionPubkeys) + if (seen.add(pk.toLowerCase())) pk, + ]; + + await relay.submit( + kind: EventKind.forumPost, + content: content, + tags: [ + ['h', channelId], + for (final pk in normalizedMentions) ['p', pk], + ], + ); + ref.invalidate(forumPostsProvider(channelId)); +} + +/// Creates a reply to a forum post (kind 45003). +Future createForumReply( + WidgetRef ref, { + required String channelId, + required String parentEventId, + required String content, + List mentionPubkeys = const [], +}) async { + final config = ref.read(relayConfigProvider); + final client = ref.read(relayClientProvider); + final relay = SignedEventRelay(client: client, nsec: config.nsec); + + final selfPubkey = relay.pubkey?.toLowerCase(); + final seen = {?selfPubkey}; + final normalizedMentions = [ + for (final pk in mentionPubkeys) + if (seen.add(pk.toLowerCase())) pk, + ]; + + await relay.submit( + kind: EventKind.forumComment, + content: content, + tags: [ + ['h', channelId], + ['e', parentEventId, '', 'reply'], + for (final pk in normalizedMentions) ['p', pk], + ], + ); + ref.invalidate(forumPostsProvider(channelId)); + ref.invalidate( + forumThreadProvider((channelId: channelId, eventId: parentEventId)), + ); +} + +/// Deletes a forum post or reply and invalidates relevant caches. +Future deleteForumEvent( + WidgetRef ref, { + required String channelId, + required String eventId, + String? rootEventId, +}) async { + final actions = ref.read(channelActionsProvider); + await actions.deleteMessage(eventId); + ref.invalidate(forumPostsProvider(channelId)); + if (rootEventId != null) { + ref.invalidate( + forumThreadProvider((channelId: channelId, eventId: rootEventId)), + ); + } +} diff --git a/mobile/lib/features/forum/forum_thread_page.dart b/mobile/lib/features/forum/forum_thread_page.dart new file mode 100644 index 0000000000..9f8f81b4bb --- /dev/null +++ b/mobile/lib/features/forum/forum_thread_page.dart @@ -0,0 +1,550 @@ +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:lucide_icons_flutter/lucide_icons.dart'; + +import '../../shared/theme/theme.dart'; +import '../channels/compose_bar.dart'; +import '../channels/message_content.dart'; +import '../profile/user_cache_provider.dart'; +import '../profile/user_profile.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). + useEffect(() { + final timer = Stream.periodic(const Duration(seconds: 10)).listen((_) { + ref.invalidate( + forumThreadProvider((channelId: channelId, eventId: postEventId)), + ); + }); + return timer.cancel; + }, [channelId, postEventId]); + + final isOwnPost = + threadAsync + .whenData( + (t) => + currentPubkey != null && + t.post.pubkey.toLowerCase() == currentPubkey!.toLowerCase(), + ) + .value ?? + false; + + return Scaffold( + appBar: AppBar( + title: const Text('Thread'), + actions: [ + if (isOwnPost) + IconButton( + onPressed: () => + _showPostActions(context, ref, threadAsync.value!), + tooltip: 'Post actions', + icon: const Icon(LucideIcons.ellipsis), + ), + ], + ), + body: threadAsync.when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (e, _) => Center( + child: Text( + 'Failed to load thread', + style: context.textTheme.bodyMedium?.copyWith( + color: context.colors.error, + ), + ), + ), + data: (thread) => _ThreadContent( + thread: thread, + channelId: channelId, + currentPubkey: currentPubkey, + isMember: isMember, + isArchived: isArchived, + ), + ), + ); + } + + void _showPostActions( + BuildContext context, + WidgetRef ref, + ForumThreadResponse thread, + ) { + showModalBottomSheet( + context: context, + showDragHandle: true, + builder: (sheetContext) => SafeArea( + child: Padding( + padding: const EdgeInsets.fromLTRB(Grid.xs, 0, Grid.xs, Grid.xs), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ListTile( + leading: const Icon(LucideIcons.copy), + title: const Text('Copy text'), + onTap: () { + Navigator.of(sheetContext).pop(); + Clipboard.setData(ClipboardData(text: thread.post.content)); + }, + ), + ListTile( + leading: Icon( + LucideIcons.trash2, + color: Theme.of(sheetContext).colorScheme.error, + ), + title: Text( + 'Delete post', + style: TextStyle( + color: Theme.of(sheetContext).colorScheme.error, + ), + ), + onTap: () { + Navigator.of(sheetContext).pop(); + _confirmDeletePost(context, ref, thread.post.eventId); + }, + ), + ], + ), + ), + ), + ); + } + + void _confirmDeletePost(BuildContext context, WidgetRef ref, String eventId) { + showDialog( + 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: Theme.of(dialogContext).colorScheme.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) { + final post = thread.post; + final replies = thread.replies; + + // Preload profiles for all participants. + final allPubkeys = useMemoized(() { + final pks = {post.pubkey}; + for (final reply in replies) { + pks.add(reply.pubkey); + } + return pks.toList(); + }, [post, replies]); + + useEffect(() { + if (allPubkeys.isNotEmpty) { + ref.read(userCacheProvider.notifier).preload(allPubkeys); + } + return null; + }, [allPubkeys]); + + return Column( + children: [ + Expanded( + child: ListView( + padding: const EdgeInsets.only(bottom: Grid.xs), + children: [ + // Original post + _OriginalPost(post: post), + + // Replies header + Padding( + padding: const EdgeInsets.symmetric( + horizontal: Grid.xs, + vertical: Grid.xxs, + ), + child: Row( + children: [ + Icon( + LucideIcons.messageSquare, + size: 16, + color: context.colors.outline, + ), + const SizedBox(width: Grid.half), + Text( + '${replies.length} ${replies.length == 1 ? 'reply' : 'replies'}', + style: context.textTheme.labelMedium?.copyWith( + color: context.colors.outline, + 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.outline, + ), + 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) => createForumReply( + ref, + channelId: channelId, + parentEventId: post.eventId, + content: content, + mentionPubkeys: mentionPubkeys, + ), + ), + ], + ); + } +} + +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 = profile?.label ?? _shortPubkey(post.pubkey); + + final userCache = ref.watch(userCacheProvider); + final mentionNames = _buildMentionNames(post.mentionPubkeys, userCache); + + return Padding( + padding: const EdgeInsets.all(Grid.xs), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + _Avatar(profile: profile, pubkey: post.pubkey, radius: 16), + const SizedBox(width: Grid.xxs), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + displayName, + style: context.textTheme.labelMedium?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + Text( + formatRelativeTime(post.createdAt), + style: context.textTheme.labelSmall?.copyWith( + color: context.colors.outline, + ), + ), + ], + ), + ), + ], + ), + const SizedBox(height: Grid.xxs), + MessageContent(content: post.content, mentionNames: mentionNames), + ], + ), + ); + } +} + +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 = profile?.label ?? _shortPubkey(reply.pubkey); + + final userCache = ref.watch(userCacheProvider); + final mentionNames = _buildMentionNames(reply.mentionPubkeys, userCache); + + return Padding( + padding: const EdgeInsets.symmetric( + horizontal: Grid.xs, + vertical: Grid.xxs, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + _Avatar(profile: profile, pubkey: reply.pubkey, radius: 12), + const SizedBox(width: Grid.xxs), + Expanded( + child: Row( + children: [ + Text( + displayName, + style: context.textTheme.labelMedium?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(width: Grid.xxs), + Text( + formatRelativeTime(reply.createdAt), + style: context.textTheme.labelSmall?.copyWith( + color: context.colors.outline, + ), + ), + ], + ), + ), + SizedBox( + width: 28, + height: 28, + child: IconButton( + onPressed: () => _showActions(context, ref), + icon: Icon( + LucideIcons.ellipsis, + size: 16, + color: context.colors.outline, + ), + padding: EdgeInsets.zero, + visualDensity: VisualDensity.compact, + ), + ), + ], + ), + Padding( + padding: const EdgeInsets.only(left: 32, top: Grid.half), + child: MessageContent( + content: reply.content, + mentionNames: mentionNames, + ), + ), + ], + ), + ); + } + + void _showActions(BuildContext context, WidgetRef ref) { + final isOwn = + currentPubkey != null && + reply.pubkey.toLowerCase() == currentPubkey!.toLowerCase(); + + showModalBottomSheet( + context: context, + showDragHandle: true, + builder: (sheetContext) => SafeArea( + child: Padding( + padding: const EdgeInsets.fromLTRB(Grid.xs, 0, Grid.xs, Grid.xs), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ListTile( + leading: const Icon(LucideIcons.copy), + title: const Text('Copy text'), + onTap: () { + Navigator.of(sheetContext).pop(); + Clipboard.setData(ClipboardData(text: reply.content)); + }, + ), + if (isOwn) + ListTile( + leading: Icon( + LucideIcons.trash2, + color: Theme.of(sheetContext).colorScheme.error, + ), + title: Text( + 'Delete reply', + style: TextStyle( + color: Theme.of(sheetContext).colorScheme.error, + ), + ), + onTap: () { + Navigator.of(sheetContext).pop(); + _confirmDelete(context, ref); + }, + ), + ], + ), + ), + ), + ); + } + + void _confirmDelete(BuildContext context, WidgetRef ref) { + showDialog( + 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: Theme.of(dialogContext).colorScheme.error, + ), + child: const Text('Delete'), + ), + ], + ), + ); + } +} + +class _Avatar extends StatelessWidget { + final UserProfile? profile; + final String pubkey; + final double radius; + + const _Avatar({ + required this.profile, + required this.pubkey, + required this.radius, + }); + + @override + Widget build(BuildContext context) { + final initial = + profile?.initial ?? (pubkey.isNotEmpty ? pubkey[0].toUpperCase() : '?'); + final avatarUrl = profile?.avatarUrl; + + return CircleAvatar( + radius: radius, + backgroundColor: context.colors.primaryContainer, + backgroundImage: avatarUrl != null ? NetworkImage(avatarUrl) : null, + child: avatarUrl == null + ? Text( + initial, + style: TextStyle( + fontSize: radius * 0.75, + fontWeight: FontWeight.w600, + color: context.colors.onPrimaryContainer, + ), + ) + : null, + ); + } +} + +Map _buildMentionNames( + List mentionPubkeys, + Map userCache, +) { + final names = {}; + for (final pk in mentionPubkeys) { + final p = userCache[pk.toLowerCase()]; + if (p?.displayName != null) { + names[pk.toLowerCase()] = p!.displayName!; + } + } + return names; +} + +String _shortPubkey(String pubkey) { + if (pubkey.length > 12) return '${pubkey.substring(0, 8)}\u2026'; + return pubkey; +} diff --git a/mobile/test/features/channels/channel_detail_page_test.dart b/mobile/test/features/channels/channel_detail_page_test.dart index 02caac3ab3..60db8b6c55 100644 --- a/mobile/test/features/channels/channel_detail_page_test.dart +++ b/mobile/test/features/channels/channel_detail_page_test.dart @@ -178,7 +178,7 @@ Finder findRichText(String text) { void main() { group('ChannelDetailPage', () { - testWidgets('shows forum placeholder for forum channels', (tester) async { + testWidgets('shows forum posts view for forum channels', (tester) async { final forumChannel = Channel( id: _channelId, name: 'design-forum', @@ -194,10 +194,14 @@ void main() { await tester.pumpWidget( _buildTestable(messages: const [], channel: forumChannel), ); - await tester.pumpAndSettle(); + // Allow the forum posts future provider to settle. It will error + // because the stub relay has no real backend, but the ForumPostsView + // should still render (showing an error or loading state). + await tester.pump(const Duration(seconds: 1)); - expect(find.text('Forum threads are not on mobile yet'), findsOneWidget); - expect(find.text('Talk through design changes'), findsOneWidget); + // The old placeholder text should be gone. + expect(find.text('Forum threads are not on mobile yet'), findsNothing); + // The compose bar for stream messages should not appear. expect(find.text('Message…'), findsNothing); }); diff --git a/mobile/test/features/forum/forum_models_test.dart b/mobile/test/features/forum/forum_models_test.dart new file mode 100644 index 0000000000..4bd8f4937c --- /dev/null +++ b/mobile/test/features/forum/forum_models_test.dart @@ -0,0 +1,265 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:sprout_mobile/features/forum/forum_models.dart'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +Map _postJson({ + String eventId = 'evt1', + String pubkey = 'alice', + String content = 'Hello world', + int kind = 45001, + int createdAt = 1000, + String channelId = 'ch1', + List>? tags, + Map? threadSummary, +}) => { + 'event_id': eventId, + 'pubkey': pubkey, + 'content': content, + 'kind': kind, + 'created_at': createdAt, + 'channel_id': channelId, + 'tags': + tags ?? + [ + ['h', channelId], + ], + ...?threadSummary != null ? {'thread_summary': threadSummary} : null, +}; + +Map _replyJson({ + String eventId = 'reply1', + String pubkey = 'bob', + String content = 'Nice post', + int kind = 45003, + int createdAt = 2000, + String channelId = 'ch1', + String? parentEventId = 'evt1', + String? rootEventId = 'evt1', + int depth = 1, +}) => { + 'event_id': eventId, + 'pubkey': pubkey, + 'content': content, + 'kind': kind, + 'created_at': createdAt, + 'channel_id': channelId, + 'tags': [ + ['h', channelId], + ], + 'parent_event_id': parentEventId, + 'root_event_id': rootEventId, + 'depth': depth, +}; + +Map _summaryJson({ + int replyCount = 3, + int descendantCount = 5, + int? lastReplyAt = 3000, + List participants = const ['bob', 'carol'], +}) => { + 'reply_count': replyCount, + 'descendant_count': descendantCount, + 'last_reply_at': lastReplyAt, + 'participants': participants, +}; + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +void main() { + group('ForumPost.fromJson', () { + test('parses complete post with thread summary', () { + final json = _postJson( + threadSummary: _summaryJson(), + tags: [ + ['h', 'ch1'], + ['p', 'bob'], + ], + ); + final post = ForumPost.fromJson(json); + + expect(post.eventId, 'evt1'); + expect(post.pubkey, 'alice'); + expect(post.content, 'Hello world'); + expect(post.kind, 45001); + expect(post.createdAt, 1000); + expect(post.channelId, 'ch1'); + expect(post.tags, hasLength(2)); + expect(post.threadSummary, isNotNull); + expect(post.threadSummary!.replyCount, 3); + expect(post.threadSummary!.descendantCount, 5); + expect(post.threadSummary!.lastReplyAt, 3000); + expect(post.threadSummary!.participants, ['bob', 'carol']); + }); + + test('parses post without thread summary', () { + final post = ForumPost.fromJson(_postJson()); + expect(post.threadSummary, isNull); + }); + + test('extracts mention pubkeys from p-tags', () { + final post = ForumPost.fromJson( + _postJson( + tags: [ + ['h', 'ch1'], + ['p', 'bob'], + ['p', 'carol'], + ], + ), + ); + expect(post.mentionPubkeys, ['bob', 'carol']); + }); + + test('returns empty mentions when no p-tags', () { + final post = ForumPost.fromJson( + _postJson( + tags: [ + ['h', 'ch1'], + ], + ), + ); + expect(post.mentionPubkeys, isEmpty); + }); + + test('skips malformed p-tags with length < 2', () { + final post = ForumPost.fromJson( + _postJson( + tags: [ + ['h', 'ch1'], + ['p'], + ['p', 'bob'], + ], + ), + ); + expect(post.mentionPubkeys, ['bob']); + }); + }); + + group('ForumThreadSummary.fromJson', () { + test('parses all fields', () { + final summary = ForumThreadSummary.fromJson(_summaryJson()); + expect(summary.replyCount, 3); + expect(summary.descendantCount, 5); + expect(summary.lastReplyAt, 3000); + expect(summary.participants, ['bob', 'carol']); + }); + + test('defaults missing fields', () { + final summary = ForumThreadSummary.fromJson(const {}); + expect(summary.replyCount, 0); + expect(summary.descendantCount, 0); + expect(summary.lastReplyAt, isNull); + expect(summary.participants, isEmpty); + }); + }); + + group('ThreadReply.fromJson', () { + test('parses a full reply', () { + final reply = ThreadReply.fromJson(_replyJson()); + expect(reply.eventId, 'reply1'); + expect(reply.pubkey, 'bob'); + expect(reply.content, 'Nice post'); + expect(reply.kind, 45003); + expect(reply.parentEventId, 'evt1'); + expect(reply.rootEventId, 'evt1'); + expect(reply.depth, 1); + }); + + test('defaults depth to 0 when missing', () { + final json = _replyJson(); + json.remove('depth'); + final reply = ThreadReply.fromJson(json); + expect(reply.depth, 0); + }); + + test('handles null parent and root', () { + final reply = ThreadReply.fromJson( + _replyJson(parentEventId: null, rootEventId: null), + ); + expect(reply.parentEventId, isNull); + expect(reply.rootEventId, isNull); + }); + }); + + group('ForumPostsResponse.fromJson', () { + test('parses paginated response', () { + final response = ForumPostsResponse.fromJson({ + 'messages': [_postJson(), _postJson(eventId: 'evt2')], + 'next_cursor': 900, + }); + expect(response.posts, hasLength(2)); + expect(response.nextCursor, 900); + }); + + test('handles empty messages', () { + final response = ForumPostsResponse.fromJson({ + 'messages': [], + 'next_cursor': null, + }); + expect(response.posts, isEmpty); + expect(response.nextCursor, isNull); + }); + + test('handles missing messages key', () { + final response = ForumPostsResponse.fromJson(const {}); + expect(response.posts, isEmpty); + }); + }); + + group('ForumThreadResponse.fromJson', () { + test('parses thread with root and replies', () { + final response = ForumThreadResponse.fromJson({ + 'root': _postJson(), + 'replies': [_replyJson(), _replyJson(eventId: 'reply2')], + 'total_replies': 2, + 'next_cursor': 'abc', + }); + expect(response.post.eventId, 'evt1'); + expect(response.replies, hasLength(2)); + expect(response.totalReplies, 2); + expect(response.nextCursor, 'abc'); + }); + + test('handles empty replies', () { + final response = ForumThreadResponse.fromJson({ + 'root': _postJson(), + 'replies': [], + 'total_replies': 0, + }); + expect(response.replies, isEmpty); + expect(response.totalReplies, 0); + expect(response.nextCursor, isNull); + }); + }); + + group('formatRelativeTime', () { + int now() => DateTime.now().millisecondsSinceEpoch ~/ 1000; + + test('returns just now for < 60s', () { + expect(formatRelativeTime(now() - 30), 'just now'); + }); + + test('returns minutes format', () { + expect(formatRelativeTime(now() - 120), '2m ago'); + }); + + test('returns hours format', () { + expect(formatRelativeTime(now() - 7200), '2h ago'); + }); + + test('returns days format', () { + expect(formatRelativeTime(now() - 172800), '2d ago'); + }); + + test('returns date format for > 7 days', () { + final ts = now() - (8 * 86400); + final result = formatRelativeTime(ts); + // Should be M/D/YYYY format. + expect(result, matches(RegExp(r'^\d{1,2}/\d{1,2}/\d{4}$'))); + }); + }); +} diff --git a/mobile/test/features/forum/forum_widgets_test.dart b/mobile/test/features/forum/forum_widgets_test.dart new file mode 100644 index 0000000000..fc0649ab22 --- /dev/null +++ b/mobile/test/features/forum/forum_widgets_test.dart @@ -0,0 +1,538 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:sprout_mobile/features/channels/channel.dart'; +import 'package:sprout_mobile/features/forum/forum_models.dart'; +import 'package:sprout_mobile/features/forum/forum_post_card.dart'; +import 'package:sprout_mobile/features/forum/forum_posts_view.dart'; +import 'package:sprout_mobile/features/forum/forum_provider.dart'; +import 'package:sprout_mobile/features/forum/forum_thread_page.dart'; +import 'package:sprout_mobile/features/profile/profile_provider.dart'; +import 'package:sprout_mobile/features/profile/user_cache_provider.dart'; +import 'package:sprout_mobile/features/profile/user_profile.dart'; +import 'package:sprout_mobile/shared/relay/relay.dart'; +import 'package:sprout_mobile/shared/theme/theme.dart'; + +// --------------------------------------------------------------------------- +// Test fixtures +// --------------------------------------------------------------------------- + +const _channelId = 'forum-channel'; + +final _forumChannel = Channel( + id: _channelId, + name: 'design-forum', + channelType: 'forum', + visibility: 'open', + description: '', + createdBy: 'abc123', + createdAt: DateTime(2025), + memberCount: 5, + isMember: true, +); + +ForumPost _makePost({ + String eventId = 'post1', + String pubkey = 'alice', + String content = 'Hello forum', + int createdAt = 1000, + ForumThreadSummary? threadSummary, +}) => ForumPost( + eventId: eventId, + pubkey: pubkey, + content: content, + kind: 45001, + createdAt: createdAt, + channelId: _channelId, + tags: const [ + ['h', 'forum-channel'], + ], + threadSummary: threadSummary, +); + +const _aliceProfile = UserProfile(pubkey: 'alice', displayName: 'Alice'); + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +Widget _buildPostCard({ + required ForumPost post, + String? currentPubkey = 'self', + Map users = const {}, + VoidCallback? onTap, + void Function(String)? onDelete, +}) { + return ProviderScope( + overrides: [ + userCacheProvider.overrideWith(() => _FakeUserCacheNotifier(users)), + ], + child: MaterialApp( + theme: AppTheme.lightTheme, + home: Scaffold( + body: ForumPostCard( + post: post, + currentPubkey: currentPubkey, + onTap: onTap ?? () {}, + onDelete: onDelete, + ), + ), + ), + ); +} + +Widget _buildPostsView({ + required ForumPostsResponse postsResponse, + Channel? channel, + Map users = const {}, +}) { + final ch = channel ?? _forumChannel; + return ProviderScope( + overrides: [ + userCacheProvider.overrideWith(() => _FakeUserCacheNotifier(users)), + profileProvider.overrideWith(() => _FakeProfileNotifier()), + forumPostsProvider(ch.id).overrideWith((ref) async => postsResponse), + relayClientProvider.overrideWithValue( + RelayClient(baseUrl: 'http://localhost:3000'), + ), + ], + child: MaterialApp( + theme: AppTheme.lightTheme, + home: Scaffold( + body: ForumPostsView(channel: ch, currentPubkey: 'self'), + ), + ), + ); +} + +Widget _buildThreadPage({ + required ForumThreadResponse threadResponse, + String postEventId = 'post1', + String? currentPubkey = 'self', + bool isMember = true, + bool isArchived = false, + Map users = const {}, +}) { + return ProviderScope( + overrides: [ + userCacheProvider.overrideWith(() => _FakeUserCacheNotifier(users)), + profileProvider.overrideWith(() => _FakeProfileNotifier()), + forumThreadProvider(( + channelId: _channelId, + eventId: postEventId, + )).overrideWith((ref) async => threadResponse), + relayClientProvider.overrideWithValue( + RelayClient(baseUrl: 'http://localhost:3000'), + ), + ], + child: MaterialApp( + theme: AppTheme.lightTheme, + home: ForumThreadPage( + channelId: _channelId, + postEventId: postEventId, + currentPubkey: currentPubkey, + isMember: isMember, + isArchived: isArchived, + ), + ), + ); +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +void main() { + group('ForumPostCard', () { + testWidgets('renders author name and content', (tester) async { + await tester.pumpWidget( + _buildPostCard( + post: _makePost(), + users: const {'alice': _aliceProfile}, + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('Alice'), findsOneWidget); + expect(find.text('Hello forum'), findsOneWidget); + }); + + testWidgets('shows truncated pubkey when no profile', (tester) async { + await tester.pumpWidget( + _buildPostCard(post: _makePost(pubkey: 'abcdef1234567890')), + ); + await tester.pumpAndSettle(); + + expect(find.text('abcdef12\u2026'), findsOneWidget); + }); + + testWidgets('truncates long content', (tester) async { + final longContent = 'A' * 300; + await tester.pumpWidget( + _buildPostCard(post: _makePost(content: longContent)), + ); + await tester.pumpAndSettle(); + + // Should show 200 chars + "..." + expect(find.textContaining('${'A' * 200}...'), findsOneWidget); + }); + + testWidgets('shows reply count with correct pluralization', (tester) async { + await tester.pumpWidget( + _buildPostCard( + post: _makePost( + threadSummary: const ForumThreadSummary( + replyCount: 1, + descendantCount: 1, + participants: [], + ), + ), + ), + ); + await tester.pumpAndSettle(); + expect(find.text('1 reply'), findsOneWidget); + + await tester.pumpWidget( + _buildPostCard( + post: _makePost( + threadSummary: const ForumThreadSummary( + replyCount: 5, + descendantCount: 5, + participants: [], + ), + ), + ), + ); + await tester.pumpAndSettle(); + expect(find.text('5 replies'), findsOneWidget); + }); + + testWidgets('hides thread summary when reply count is 0', (tester) async { + await tester.pumpWidget( + _buildPostCard( + post: _makePost( + threadSummary: const ForumThreadSummary( + replyCount: 0, + descendantCount: 0, + participants: [], + ), + ), + ), + ); + await tester.pumpAndSettle(); + expect(find.text('0 replies'), findsNothing); + }); + + testWidgets('calls onTap when tapped', (tester) async { + var tapped = false; + await tester.pumpWidget( + _buildPostCard(post: _makePost(), onTap: () => tapped = true), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Hello forum')); + expect(tapped, isTrue); + }); + + testWidgets('long press opens action sheet with Copy text', (tester) async { + await tester.pumpWidget(_buildPostCard(post: _makePost())); + await tester.pumpAndSettle(); + + await tester.longPress(find.text('Hello forum')); + await tester.pumpAndSettle(); + + expect(find.text('Copy text'), findsOneWidget); + }); + + testWidgets('long press shows Delete only for own posts', (tester) async { + // Own post — Delete should appear. + await tester.pumpWidget( + _buildPostCard( + post: _makePost(pubkey: 'self'), + currentPubkey: 'self', + onDelete: (_) {}, + ), + ); + await tester.pumpAndSettle(); + + await tester.longPress(find.text('Hello forum')); + await tester.pumpAndSettle(); + expect(find.text('Delete post'), findsOneWidget); + + // Dismiss sheet. + await tester.tapAt(Offset.zero); + await tester.pumpAndSettle(); + + // Other's post — Delete should NOT appear. + await tester.pumpWidget( + _buildPostCard( + post: _makePost(pubkey: 'other'), + currentPubkey: 'self', + onDelete: (_) {}, + ), + ); + await tester.pumpAndSettle(); + + await tester.longPress(find.text('Hello forum')); + await tester.pumpAndSettle(); + expect(find.text('Delete post'), findsNothing); + }); + + testWidgets('delete confirmation dialog triggers onDelete', (tester) async { + String? deletedId; + await tester.pumpWidget( + _buildPostCard( + post: _makePost(pubkey: 'self', eventId: 'evt-to-delete'), + currentPubkey: 'self', + onDelete: (id) => deletedId = id, + ), + ); + await tester.pumpAndSettle(); + + // Long press → action sheet. + await tester.longPress(find.text('Hello forum')); + await tester.pumpAndSettle(); + + // Tap Delete post. + await tester.tap(find.text('Delete post')); + await tester.pumpAndSettle(); + + // Confirmation dialog appears. + expect(find.text('This cannot be undone.'), findsOneWidget); + + // Tap Delete button. + await tester.tap(find.widgetWithText(FilledButton, 'Delete')); + await tester.pumpAndSettle(); + + expect(deletedId, 'evt-to-delete'); + }); + }); + + group('ForumPostsView', () { + testWidgets('shows empty state for members', (tester) async { + await tester.pumpWidget( + _buildPostsView(postsResponse: const ForumPostsResponse(posts: [])), + ); + await tester.pumpAndSettle(); + + expect(find.text('No posts yet'), findsOneWidget); + expect( + find.text('Start a discussion by creating the first post.'), + findsOneWidget, + ); + }); + + testWidgets('shows empty state for non-members', (tester) async { + await tester.pumpWidget( + _buildPostsView( + postsResponse: const ForumPostsResponse(posts: []), + channel: Channel( + id: _channelId, + name: 'design-forum', + channelType: 'forum', + visibility: 'open', + description: '', + createdBy: 'abc123', + createdAt: DateTime(2025), + memberCount: 5, + isMember: false, + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('Join this forum to create posts.'), findsOneWidget); + }); + + testWidgets('shows FAB for members', (tester) async { + await tester.pumpWidget( + _buildPostsView(postsResponse: const ForumPostsResponse(posts: [])), + ); + await tester.pumpAndSettle(); + + expect(find.byType(FloatingActionButton), findsOneWidget); + expect(find.byTooltip('New post'), findsOneWidget); + }); + + testWidgets('hides FAB for non-members', (tester) async { + await tester.pumpWidget( + _buildPostsView( + postsResponse: const ForumPostsResponse(posts: []), + channel: Channel( + id: _channelId, + name: 'design-forum', + channelType: 'forum', + visibility: 'open', + description: '', + createdBy: 'abc123', + createdAt: DateTime(2025), + memberCount: 5, + isMember: false, + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.byType(FloatingActionButton), findsNothing); + }); + + testWidgets('renders post list', (tester) async { + await tester.pumpWidget( + _buildPostsView( + postsResponse: ForumPostsResponse( + posts: [ + _makePost(content: 'First post'), + _makePost(eventId: 'post2', content: 'Second post'), + ], + ), + users: const {'alice': _aliceProfile}, + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('First post'), findsOneWidget); + expect(find.text('Second post'), findsOneWidget); + }); + }); + + group('ForumThreadPage', () { + testWidgets('shows original post and replies header', (tester) async { + await tester.pumpWidget( + _buildThreadPage( + threadResponse: ForumThreadResponse( + post: _makePost(content: 'Thread root'), + replies: const [], + totalReplies: 0, + ), + users: const {'alice': _aliceProfile}, + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('Thread'), findsOneWidget); // App bar title + expect(find.text('0 replies'), findsOneWidget); + expect( + find.text('No replies yet. Be the first to respond.'), + findsOneWidget, + ); + }); + + testWidgets('shows reply count with replies', (tester) async { + await tester.pumpWidget( + _buildThreadPage( + threadResponse: ForumThreadResponse( + post: _makePost(), + replies: [ + const ThreadReply( + eventId: 'r1', + pubkey: 'bob', + content: 'Great post!', + kind: 45003, + createdAt: 2000, + channelId: _channelId, + tags: [ + ['h', _channelId], + ], + depth: 1, + ), + ], + totalReplies: 1, + ), + users: const { + 'alice': _aliceProfile, + 'bob': UserProfile(pubkey: 'bob', displayName: 'Bob'), + }, + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('1 reply'), findsOneWidget); + expect(find.text('Bob'), findsOneWidget); + }); + + testWidgets('shows compose bar for members', (tester) async { + await tester.pumpWidget( + _buildThreadPage( + threadResponse: ForumThreadResponse( + post: _makePost(), + replies: const [], + totalReplies: 0, + ), + isMember: true, + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('Reply to this post\u2026'), findsOneWidget); + }); + + testWidgets('hides compose bar for non-members', (tester) async { + await tester.pumpWidget( + _buildThreadPage( + threadResponse: ForumThreadResponse( + post: _makePost(), + replies: const [], + totalReplies: 0, + ), + isMember: false, + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('Reply to this post\u2026'), findsNothing); + }); + + testWidgets('shows 3-dot in app bar for own post', (tester) async { + await tester.pumpWidget( + _buildThreadPage( + threadResponse: ForumThreadResponse( + post: _makePost(pubkey: 'self'), + replies: const [], + totalReplies: 0, + ), + currentPubkey: 'self', + ), + ); + await tester.pumpAndSettle(); + + expect(find.byTooltip('Post actions'), findsOneWidget); + }); + + testWidgets('hides 3-dot in app bar for others post', (tester) async { + await tester.pumpWidget( + _buildThreadPage( + threadResponse: ForumThreadResponse( + post: _makePost(pubkey: 'alice'), + replies: const [], + totalReplies: 0, + ), + currentPubkey: 'self', + ), + ); + await tester.pumpAndSettle(); + + expect(find.byTooltip('Post actions'), findsNothing); + }); + }); +} + +// --------------------------------------------------------------------------- +// Fakes +// --------------------------------------------------------------------------- + +class _FakeUserCacheNotifier extends UserCacheNotifier { + final Map _users; + _FakeUserCacheNotifier(this._users); + + @override + Map build() => _users; + + @override + UserProfile? get(String pubkey) => _users[pubkey.toLowerCase()]; +} + +class _FakeProfileNotifier extends ProfileNotifier { + @override + Future build() async => + const UserProfile(pubkey: 'self', displayName: 'Self'); +}