mirror of
https://github.com/block/buzz.git
synced 2026-10-27 04:30:52 +00:00
feat(mobile): implement forum posts and threads (#356)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
195
mobile/lib/features/forum/forum_models.dart
Normal file
195
mobile/lib/features/forum/forum_models.dart
Normal file
@@ -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<List<String>> 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<String, dynamic> json) {
|
||||
final rawSummary = json['thread_summary'] as Map<String, dynamic>?;
|
||||
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<dynamic>)
|
||||
.map((t) => (t as List<dynamic>).map((e) => e as String).toList())
|
||||
.toList(),
|
||||
threadSummary: rawSummary != null
|
||||
? ForumThreadSummary.fromJson(rawSummary)
|
||||
: null,
|
||||
);
|
||||
}
|
||||
|
||||
/// Extract mention pubkeys from p-tags.
|
||||
List<String> 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<String> participants;
|
||||
|
||||
const ForumThreadSummary({
|
||||
required this.replyCount,
|
||||
required this.descendantCount,
|
||||
this.lastReplyAt,
|
||||
required this.participants,
|
||||
});
|
||||
|
||||
factory ForumThreadSummary.fromJson(Map<String, dynamic> 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<dynamic>?)
|
||||
?.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<List<String>> 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<String, dynamic> 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<dynamic>)
|
||||
.map((t) => (t as List<dynamic>).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<String> 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<ForumPost> posts;
|
||||
final int? nextCursor;
|
||||
|
||||
const ForumPostsResponse({required this.posts, this.nextCursor});
|
||||
|
||||
factory ForumPostsResponse.fromJson(Map<String, dynamic> json) {
|
||||
final messages = json['messages'] as List<dynamic>? ?? const [];
|
||||
return ForumPostsResponse(
|
||||
posts: messages
|
||||
.cast<Map<String, dynamic>>()
|
||||
.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<ThreadReply> replies;
|
||||
final int totalReplies;
|
||||
final String? nextCursor;
|
||||
|
||||
const ForumThreadResponse({
|
||||
required this.post,
|
||||
required this.replies,
|
||||
required this.totalReplies,
|
||||
this.nextCursor,
|
||||
});
|
||||
|
||||
factory ForumThreadResponse.fromJson(Map<String, dynamic> json) {
|
||||
final repliesJson = json['replies'] as List<dynamic>? ?? const [];
|
||||
return ForumThreadResponse(
|
||||
post: ForumPost.fromJson(json['root'] as Map<String, dynamic>),
|
||||
replies: repliesJson
|
||||
.cast<Map<String, dynamic>>()
|
||||
.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}';
|
||||
}
|
||||
253
mobile/lib/features/forum/forum_post_card.dart
Normal file
253
mobile/lib/features/forum/forum_post_card.dart
Normal file
@@ -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<void>(
|
||||
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<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: () {
|
||||
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;
|
||||
}
|
||||
197
mobile/lib/features/forum/forum_posts_view.dart
Normal file
197
mobile/lib/features/forum/forum_posts_view.dart
Normal file
@@ -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<void>(
|
||||
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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
119
mobile/lib/features/forum/forum_provider.dart
Normal file
119
mobile/lib/features/forum/forum_provider.dart
Normal file
@@ -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<ForumPostsResponse, String>((
|
||||
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<String, dynamic>;
|
||||
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<String, dynamic>;
|
||||
return ForumThreadResponse.fromJson(json);
|
||||
});
|
||||
|
||||
/// Creates a new forum post (kind 45001).
|
||||
Future<void> createForumPost(
|
||||
WidgetRef ref, {
|
||||
required String channelId,
|
||||
required String content,
|
||||
List<String> 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 = <String>{?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<void> createForumReply(
|
||||
WidgetRef ref, {
|
||||
required String channelId,
|
||||
required String parentEventId,
|
||||
required String content,
|
||||
List<String> 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 = <String>{?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<void> 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)),
|
||||
);
|
||||
}
|
||||
}
|
||||
550
mobile/lib/features/forum/forum_thread_page.dart
Normal file
550
mobile/lib/features/forum/forum_thread_page.dart
Normal file
@@ -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<void>(
|
||||
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<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: 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 = <String>{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<void>(
|
||||
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<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: 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<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;
|
||||
}
|
||||
|
||||
String _shortPubkey(String pubkey) {
|
||||
if (pubkey.length > 12) return '${pubkey.substring(0, 8)}\u2026';
|
||||
return pubkey;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
265
mobile/test/features/forum/forum_models_test.dart
Normal file
265
mobile/test/features/forum/forum_models_test.dart
Normal file
@@ -0,0 +1,265 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:sprout_mobile/features/forum/forum_models.dart';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Map<String, dynamic> _postJson({
|
||||
String eventId = 'evt1',
|
||||
String pubkey = 'alice',
|
||||
String content = 'Hello world',
|
||||
int kind = 45001,
|
||||
int createdAt = 1000,
|
||||
String channelId = 'ch1',
|
||||
List<List<String>>? tags,
|
||||
Map<String, dynamic>? 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<String, dynamic> _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<String, dynamic> _summaryJson({
|
||||
int replyCount = 3,
|
||||
int descendantCount = 5,
|
||||
int? lastReplyAt = 3000,
|
||||
List<String> 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 <String, dynamic>{});
|
||||
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': <dynamic>[],
|
||||
'next_cursor': null,
|
||||
});
|
||||
expect(response.posts, isEmpty);
|
||||
expect(response.nextCursor, isNull);
|
||||
});
|
||||
|
||||
test('handles missing messages key', () {
|
||||
final response = ForumPostsResponse.fromJson(const <String, dynamic>{});
|
||||
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': <dynamic>[],
|
||||
'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}$')));
|
||||
});
|
||||
});
|
||||
}
|
||||
538
mobile/test/features/forum/forum_widgets_test.dart
Normal file
538
mobile/test/features/forum/forum_widgets_test.dart
Normal file
@@ -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<String, UserProfile> 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<String, UserProfile> 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<String, UserProfile> 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<String, UserProfile> _users;
|
||||
_FakeUserCacheNotifier(this._users);
|
||||
|
||||
@override
|
||||
Map<String, UserProfile> build() => _users;
|
||||
|
||||
@override
|
||||
UserProfile? get(String pubkey) => _users[pubkey.toLowerCase()];
|
||||
}
|
||||
|
||||
class _FakeProfileNotifier extends ProfileNotifier {
|
||||
@override
|
||||
Future<UserProfile?> build() async =>
|
||||
const UserProfile(pubkey: 'self', displayName: 'Self');
|
||||
}
|
||||
Reference in New Issue
Block a user