feat: import Chinese-localized Buzz source snapshot
Docker image / Build (linux/amd64) (push) Has been cancelled
Docker image / Build (linux/arm64) (push) Has been cancelled
Docker image / Merge release multi-arch manifest (push) Has been cancelled
Docker image / Merge debug multi-arch manifest (push) Has been cancelled
Docker image / Build public push gateway (linux/amd64) (push) Has been cancelled
Docker image / Build public push gateway (linux/arm64) (push) Has been cancelled
Docker image / Publish public push gateway image (push) Has been cancelled
Sprig image / Build (linux/amd64) (push) Has been cancelled
Sprig image / Build (linux/arm64) (push) Has been cancelled
Sprig image / Merge multi-arch manifest (push) Has been cancelled
Harbor Buzz Orchestra / Python tests and lint (push) Has been cancelled
CI / Detect Changed Paths (push) Has been cancelled
CI / Rust Lint (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Desktop Core (push) Has been cancelled
CI / Desktop Smoke E2E (1) (push) Has been cancelled
CI / Desktop Smoke E2E (2) (push) Has been cancelled
CI / Desktop Smoke E2E (3) (push) Has been cancelled
CI / Desktop Smoke E2E (4) (push) Has been cancelled
CI / Desktop (push) Has been cancelled
CI / Desktop E2E Relay (push) Has been cancelled
CI / Desktop E2E Integration (1/2) (push) Has been cancelled
CI / Desktop E2E Integration (2/2) (push) Has been cancelled
CI / Desktop E2E Integration (push) Has been cancelled
CI / Backend Integration (relay e2e) (push) Has been cancelled
CI / Relay E2E (push) Has been cancelled
CI / Web (push) Has been cancelled
CI / Mobile (push) Has been cancelled
CI / Security (push) Has been cancelled
CI / Dead Token Reference Guard (push) Has been cancelled
CI / Server Cross-Compile (aarch64-unknown-linux-musl) (push) Has been cancelled
CI / Server Cross-Compile (x86_64-unknown-linux-musl) (push) Has been cancelled
CI / Windows Rust (x86_64-pc-windows-msvc) (push) Has been cancelled
CI / Desktop Build (macOS) (push) Has been cancelled
helm chart / lint + unittest + render matrix (push) Has been cancelled
helm chart / install on kind (gated) (push) Has been cancelled
helm chart / publish chart to GHCR (push) Has been cancelled
Mesh Lifecycle / Relay-Driven Mesh Lifecycle Smoke (push) Has been cancelled
Sprig / Build (aarch64-unknown-linux-musl) (push) Has been cancelled
Sprig / Build (x86_64-unknown-linux-musl) (push) Has been cancelled
Sprig / Publish rolling release (push) Has been cancelled
Sprig / Publish tagged release (push) Has been cancelled

Signed-off-by: cls_宁波本机 <908705107@qq.com>
This commit is contained in:
2026-08-13 18:34:25 +08:00
parent 61c3fa1df9
commit 9dfa06ffee
3785 changed files with 1085458 additions and 2 deletions
+269
View File
@@ -0,0 +1,269 @@
import 'package:flutter/foundation.dart';
import '../../l10n/app_localizations.dart';
import '../../shared/relay/relay.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,
);
}
/// Build a [ForumPost] from a raw Nostr event (kind:45001).
factory ForumPost.fromEvent(NostrEvent event) {
return ForumPost(
eventId: event.id,
pubkey: event.pubkey,
content: event.content,
kind: event.kind,
createdAt: event.createdAt,
channelId: event.channelId ?? '',
tags: event.tags,
);
}
/// 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,
);
}
/// Build a [ThreadReply] from a raw Nostr event.
factory ThreadReply.fromEvent(NostrEvent event) {
final ref = event.threadReference;
return ThreadReply(
eventId: event.id,
pubkey: event.pubkey,
content: event.content,
kind: event.kind,
createdAt: event.createdAt,
channelId: event.channelId ?? '',
tags: event.tags,
parentEventId: ref.parentId,
rootEventId: ref.rootId,
depth: 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?,
);
}
/// Build from a list of kind:45001 events. Posts are sorted newest-first.
factory ForumPostsResponse.fromEvents(List<NostrEvent> events) {
final posts = events.map(ForumPost.fromEvent).toList()
..sort((a, b) => b.createdAt.compareTo(a.createdAt));
return ForumPostsResponse(posts: posts, nextCursor: null);
}
}
/// 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?,
);
}
/// Build from a root event and a list of reply events. Replies are sorted
/// oldest-first so the UI can render them top-down.
factory ForumThreadResponse.fromEvents({
required NostrEvent root,
required List<NostrEvent> replies,
}) {
final sortedReplies = replies.map(ThreadReply.fromEvent).toList()
..sort((a, b) => a.createdAt.compareTo(b.createdAt));
return ForumThreadResponse(
post: ForumPost.fromEvent(root),
replies: sortedReplies,
totalReplies: sortedReplies.length,
nextCursor: null,
);
}
}
/// 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}';
}
/// Localized variant used by forum UI; the legacy formatter remains for
/// model-only callers and stable tests.
String formatRelativeTimeLocalized(AppLocalizations l10n, int timestamp) {
final now = DateTime.now().millisecondsSinceEpoch ~/ 1000;
final diff = now - timestamp;
if (diff < 60) return l10n.forumTimeJustNow;
if (diff < 3600) return l10n.forumTimeMinutesAgo(diff ~/ 60);
if (diff < 86400) return l10n.forumTimeHoursAgo(diff ~/ 3600);
if (diff < 604800) return l10n.forumTimeDaysAgo(diff ~/ 86400);
final dt = DateTime.fromMillisecondsSinceEpoch(
timestamp * 1000,
isUtc: true,
).toLocal();
return l10n.forumFullDate(dt.month, dt.day, dt.year);
}
@@ -0,0 +1,361 @@
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/mentions/agent_identity_provider.dart';
import '../../shared/l10n/l10n.dart';
import '../../shared/theme/theme.dart';
import '../../shared/widgets/avatar_image.dart';
import '../../shared/widgets/modal_presentation.dart';
import '../channels/message_content.dart';
import '../profile/user_cache_provider.dart';
import '../profile/user_profile_sheet.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 HookConsumerWidget {
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 mentionPubkeys = useMemoized(
() =>
post.mentionPubkeys.map((pubkey) => pubkey.toLowerCase()).toSet()
..remove(post.pubkey.toLowerCase()),
[post],
);
final mentionPubkeysKey = (mentionPubkeys.toList()..sort()).join('\u0000');
useEffect(() {
if (mentionPubkeys.isNotEmpty) {
ref.read(userCacheProvider.notifier).preload(mentionPubkeys.toList());
}
return null;
}, [mentionPubkeysKey]);
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 profileMentionNames = ref.watch(
userCacheProvider.select(
(cache) => _buildMentionNames(post.mentionPubkeys, cache),
),
);
final profileOwnedMentionPubkeys = ref.watch(
userCacheProvider.select(
(cache) =>
(post.mentionPubkeys
.where(
(pubkey) =>
cache[pubkey.toLowerCase()]?.ownerPubkey != null,
)
.map((pubkey) => pubkey.toLowerCase())
.toList()
..sort())
.join('\u0000'),
),
);
final agentMentionPubkeys = agentPubkeysWithProfileOwners(
knownAgentPubkeys: ref.watch(agentMentionPubkeysProvider(post.channelId)),
profileOwnedAgentPubkeys: profileOwnedMentionPubkeys.isEmpty
? const <String>[]
: profileOwnedMentionPubkeys.split('\u0000'),
);
final mentionNames = mentionNamesWithDirectoryLabels(
mentionPubkeys: post.mentionPubkeys,
profileMentionNames: profileMentionNames,
directoryDisplayNames: ref.watch(agentDirectoryDisplayNamesProvider),
agentMentionPubkeys: agentMentionPubkeys,
);
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: [
GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () => showUserProfileSheet(context, post.pubkey),
child: _PostAvatar(profile: profile, pubkey: post.pubkey),
),
const SizedBox(width: Grid.xxs),
Expanded(
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () => showUserProfileSheet(context, post.pubkey),
child: Text(
displayName,
maxLines: 1,
style: messageUsernameTextStyle,
overflow: TextOverflow.ellipsis,
),
),
),
const SizedBox(width: Grid.xxs),
ConstrainedBox(
constraints: const BoxConstraints(maxWidth: Grid.xxl),
child: Text(
formatRelativeTimeLocalized(
context.l10n,
post.createdAt,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: messageTimestampTextStyle.copyWith(
color: context.colors.onSurfaceVariant,
),
),
),
const SizedBox(width: Grid.half),
SizedBox(
width: 24,
height: 24,
child: IconButton(
onPressed: () => _showActions(context),
icon: Icon(
LucideIcons.ellipsis,
size: 16,
color: context.colors.onSurfaceVariant,
),
padding: EdgeInsets.zero,
visualDensity: VisualDensity.compact,
),
),
],
),
const SizedBox(height: Grid.xxs),
ShaderMask(
shaderCallback: (bounds) => const LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Colors.white, Colors.white, Colors.transparent],
stops: [0.0, 0.75, 1.0],
).createShader(bounds),
blendMode: BlendMode.dstIn,
child: ConstrainedBox(
constraints: const BoxConstraints(maxHeight: 120),
child: IgnorePointer(
child: MessageContent(
content: preview,
mentionNames: mentionNames,
agentMentionPubkeys: agentMentionPubkeys,
tags: post.tags,
baseStyle: messageBodyTextStyle.copyWith(
color: context.colors.onSurface,
),
),
),
),
),
// Thread summary
if (summary != null && summary.replyCount > 0) ...[
const SizedBox(height: Grid.xxs),
Row(
children: [
Icon(
LucideIcons.messageSquare,
size: 14,
color: context.colors.onSurfaceVariant,
),
const SizedBox(width: Grid.half),
Text(
context.l10n.replyCount(summary.replyCount),
style: context.textTheme.labelSmall?.copyWith(
color: context.colors.onSurfaceVariant,
),
),
if (summary.lastReplyAt != null) ...[
const SizedBox(width: Grid.half),
Text(
'\u00b7',
style: context.textTheme.labelSmall?.copyWith(
color: context.colors.onSurfaceVariant.withValues(
alpha: 0.5,
),
),
),
const SizedBox(width: Grid.half),
Text(
context.l10n.lastReply(
formatRelativeTimeLocalized(
context.l10n,
summary.lastReplyAt!,
),
),
style: context.textTheme.labelSmall?.copyWith(
color: context.colors.onSurfaceVariant,
),
),
],
],
),
],
],
),
),
);
}
void _showActions(BuildContext context) {
final isOwn =
currentPubkey != null &&
post.pubkey.toLowerCase() == currentPubkey!.toLowerCase();
showBuzzModalBottomSheet<void>(
context: context,
showDragHandle: true,
builder: (sheetContext) => SafeArea(
child: IconTheme.merge(
data: const IconThemeData(size: 22),
child: Padding(
padding: const EdgeInsets.fromLTRB(
Grid.gutter,
0,
Grid.gutter,
Grid.xs,
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
leading: const Icon(LucideIcons.copy),
title: Text(context.l10n.copyText),
onTap: () {
Navigator.of(sheetContext).pop();
Clipboard.setData(ClipboardData(text: post.content));
},
),
if (isOwn && onDelete != null)
ListTile(
leading: Icon(
LucideIcons.trash2,
color: sheetContext.colors.error,
),
title: Text(
context.l10n.deletePost,
style: TextStyle(color: sheetContext.colors.error),
),
onTap: () {
Navigator.of(sheetContext).pop();
_confirmDelete(context);
},
),
],
),
),
),
),
);
}
void _confirmDelete(BuildContext context) {
showBuzzDialog<void>(
context: context,
builder: (dialogContext) => AlertDialog(
title: Text(context.l10n.deletePost),
content: Text(context.l10n.cannotUndo),
actions: [
TextButton(
onPressed: () => Navigator.of(dialogContext).pop(),
child: Text(context.l10n.cancel),
),
FilledButton(
onPressed: () {
Navigator.of(dialogContext).pop();
onDelete?.call(post.eventId);
},
style: FilledButton.styleFrom(
backgroundColor: dialogContext.colors.error,
),
child: Text(context.l10n.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 AvatarImage(
imageUrl: avatarUrl,
radius: 14,
backgroundColor: context.colors.primaryContainer,
fallback: Text(
initial,
style: context.textTheme.labelSmall?.copyWith(
color: context.colors.onPrimaryContainer,
fontWeight: FontWeight.w600,
),
),
);
}
}
String _shortPubkey(String pubkey) {
if (pubkey.length > 12) return '${pubkey.substring(0, 8)}\u2026';
return pubkey;
}
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;
}
@@ -0,0 +1,227 @@
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/l10n/l10n.dart';
import '../../shared/theme/theme.dart';
import '../../shared/widgets/buzz_loading_indicator.dart';
import '../../shared/widgets/frosted_app_bar.dart';
import '../../shared/widgets/bee_refresh_indicator.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);
// A queued attachment can finish after this view is popped. Capture the
// app-level provider container instead of retaining the route's WidgetRef.
final providerContainer = ProviderScope.containerOf(context, listen: false);
final forumDelivery = ForumEventDelivery.capture(providerContainer);
// 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(
heroTag: 'forum-fab',
onPressed: () => isComposing.value = true,
tooltip: context.l10n.newPost,
shape: const CircleBorder(),
child: const Icon(LucideIcons.plus),
)
: null,
body: postsAsync.when(
loading: () => Padding(
padding: EdgeInsets.only(top: frostedAppBarHeight(context)),
child: const Center(
child: BuzzLoadingIndicator(
size: 44,
semanticLabel: context.l10n.loadingPosts,
),
),
),
error: (e, _) => Padding(
padding: EdgeInsets.only(top: frostedAppBarHeight(context)),
child: Center(
child: Text(
context.l10n.failedLoadPosts,
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 BeeRefreshIndicator(
onRefresh: () async {
ref.invalidate(forumPostsProvider(channel.id));
await ref.read(forumPostsProvider(channel.id).future);
},
child: ListView.separated(
padding: EdgeInsets.only(
top: frostedAppBarHeight(context),
left: Grid.gutter,
right: Grid.gutter,
bottom: 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: context.l10n.dismiss,
visualDensity: VisualDensity.compact,
),
),
),
ComposeBar(
channelId: channel.id,
hintText: context.l10n.writePostHint,
onSend:
(
content,
mentionPubkeys, {
mediaTags = const <List<String>>[],
}) async {
await forumDelivery.createPost(
channelId: channel.id,
content: content,
mentionPubkeys: mentionPubkeys,
mediaTags: mediaTags,
);
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.onSurfaceVariant,
),
const SizedBox(height: Grid.xxs),
Text(
context.l10n.noPostsYet,
style: context.textTheme.bodyLarge?.copyWith(
color: context.colors.onSurfaceVariant,
),
),
const SizedBox(height: Grid.half),
Text(
isArchived
? context.l10n.forumArchived
: isMember
? context.l10n.startForumDiscussion
: context.l10n.joinForumToPost,
style: context.textTheme.bodySmall?.copyWith(
color: context.colors.onSurfaceVariant,
),
textAlign: TextAlign.center,
),
],
),
),
);
}
}
@@ -0,0 +1,189 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import '../../shared/relay/relay.dart';
import '../channels/channel_management_provider.dart';
import '../../shared/custom_emoji/custom_emoji.dart';
import '../../shared/custom_emoji/custom_emoji_provider.dart';
import 'forum_models.dart';
/// Fetches forum posts (kind:45001) for a channel from the relay.
///
/// Posts are top-level events tagged `#h:<channelId>`. Invalidate to refresh
/// (e.g. after creating a new post).
final forumPostsProvider = FutureProvider.family<ForumPostsResponse, String>((
ref,
channelId,
) async {
final session = ref.watch(relaySessionProvider.notifier);
final events = await session.fetchHistory(
NostrFilters.forumPosts(channelId, limit: 50),
);
return ForumPostsResponse.fromEvents(events);
});
/// Fetches a forum thread (root post + replies) from the relay.
final forumThreadProvider =
FutureProvider.family<
ForumThreadResponse,
({String channelId, String eventId})
>((ref, args) async {
final session = ref.watch(relaySessionProvider.notifier);
final results = await Future.wait([
// Root event lookup by id.
session.fetchHistory(
NostrFilter(
kinds: const [9, 40002, 45001, 45003],
ids: [args.eventId],
limit: 1,
),
),
// Replies pointing at this root.
session.fetchHistory(
NostrFilters.forumThread(args.eventId, args.channelId),
),
]);
final rootEvents = results[0];
final replyEvents = results[1];
if (rootEvents.isEmpty) {
throw Exception('Forum thread not found: ${args.eventId}');
}
return ForumThreadResponse.fromEvents(
root: rootEvents.first,
replies: replyEvents,
);
});
/// A forum event delivery bound to the community where composition began.
///
/// Attachment uploads can outlive their route. Capturing the relay identity,
/// signing key, and emoji palette prevents a queued draft from being delivered
/// to a different community after the user switches relays.
class ForumEventDelivery {
final ProviderContainer _container;
final String _relayUrl;
final String? _nsec;
final SignedEventRelay _relay;
final List<CustomEmoji> _customEmoji;
ForumEventDelivery._({
required ProviderContainer container,
required String relayUrl,
required String? nsec,
required SignedEventRelay relay,
required List<CustomEmoji> customEmoji,
}) : _container = container,
_relayUrl = relayUrl,
_nsec = nsec,
_relay = relay,
_customEmoji = customEmoji;
/// Captures the active community dependencies for a future delivery.
factory ForumEventDelivery.capture(ProviderContainer container) {
final config = container.read(relayConfigProvider);
return ForumEventDelivery._(
container: container,
relayUrl: config.baseUrl,
nsec: config.nsec,
relay: SignedEventRelay(
session: container.read(relaySessionProvider.notifier),
nsec: config.nsec,
),
customEmoji: List<CustomEmoji>.unmodifiable(
container.read(customEmojiListProvider),
),
);
}
/// Creates a new forum post (kind:45001).
Future<void> createPost({
required String channelId,
required String content,
List<String> mentionPubkeys = const [],
List<List<String>> mediaTags = const [],
}) async {
await _submit(
kind: EventKind.forumPost,
channelId: channelId,
content: content,
mentionPubkeys: mentionPubkeys,
mediaTags: mediaTags,
);
_container.invalidate(forumPostsProvider(channelId));
}
/// Creates a reply to a forum post (kind:45003).
Future<void> createReply({
required String channelId,
required String parentEventId,
required String content,
List<String> mentionPubkeys = const [],
List<List<String>> mediaTags = const [],
}) async {
await _submit(
kind: EventKind.forumComment,
channelId: channelId,
parentEventId: parentEventId,
content: content,
mentionPubkeys: mentionPubkeys,
mediaTags: mediaTags,
);
_container.invalidate(forumPostsProvider(channelId));
_container.invalidate(
forumThreadProvider((channelId: channelId, eventId: parentEventId)),
);
}
Future<void> _submit({
required int kind,
required String channelId,
required String content,
String? parentEventId,
required List<String> mentionPubkeys,
required List<List<String>> mediaTags,
}) async {
final currentConfig = _container.read(relayConfigProvider);
if (currentConfig.baseUrl != _relayUrl || currentConfig.nsec != _nsec) {
throw StateError(
'Forum delivery cancelled because the active community changed',
);
}
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: kind,
content: content,
tags: [
['h', channelId],
if (parentEventId != null) ['e', parentEventId, '', 'reply'],
for (final pk in normalizedMentions) ['p', pk],
...mediaTags,
...buildCustomEmojiTags(content, _customEmoji),
],
);
}
}
/// 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(channelId: channelId, eventId: eventId);
ref.invalidate(forumPostsProvider(channelId));
if (rootEventId != null) {
ref.invalidate(
forumThreadProvider((channelId: channelId, eventId: rootEventId)),
);
}
}
@@ -0,0 +1,676 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../../shared/mentions/agent_identity_provider.dart';
import '../../shared/l10n/l10n.dart';
import '../../shared/theme/theme.dart';
import '../../shared/widgets/avatar_image.dart';
import '../../shared/widgets/buzz_loading_indicator.dart';
import '../../shared/widgets/frosted_app_bar.dart';
import '../../shared/widgets/frosted_scaffold.dart';
import '../../shared/widgets/modal_presentation.dart';
import '../channels/compose_bar.dart';
import '../channels/message_content.dart';
import '../profile/user_cache_provider.dart';
import '../profile/user_profile.dart';
import '../profile/user_profile_sheet.dart';
import 'forum_models.dart';
import 'forum_provider.dart';
/// Full-screen page showing a forum post and its replies.
class ForumThreadPage extends HookConsumerWidget {
final String channelId;
final String postEventId;
final String? currentPubkey;
final bool isMember;
final bool isArchived;
const ForumThreadPage({
super.key,
required this.channelId,
required this.postEventId,
required this.currentPubkey,
required this.isMember,
required this.isArchived,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final threadAsync = ref.watch(
forumThreadProvider((channelId: channelId, eventId: postEventId)),
);
// Periodic refresh (every 10s, matching desktop).
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 FrostedScaffold(
appBar: FrostedAppBar(
title: Text(context.l10n.threadTitle),
actions: [
if (isOwnPost)
IconButton(
onPressed: () =>
_showPostActions(context, ref, threadAsync.value!),
tooltip: context.l10n.postActions,
icon: const Icon(LucideIcons.ellipsis),
),
],
),
body: threadAsync.when(
loading: () => Padding(
padding: EdgeInsets.only(top: frostedAppBarHeight(context)),
child: Center(
child: BuzzLoadingIndicator(
size: 44,
semanticLabel: context.l10n.loadingThread,
),
),
),
error: (e, _) => Padding(
padding: EdgeInsets.only(top: frostedAppBarHeight(context)),
child: Center(
child: Text(
context.l10n.failedLoadThread,
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,
) {
showBuzzModalBottomSheet<void>(
context: context,
showDragHandle: true,
builder: (sheetContext) => SafeArea(
child: IconTheme.merge(
data: const IconThemeData(size: 22),
child: Padding(
padding: const EdgeInsets.fromLTRB(
Grid.gutter,
0,
Grid.gutter,
Grid.xs,
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
leading: const Icon(LucideIcons.copy),
title: Text(context.l10n.copyText),
onTap: () {
Navigator.of(sheetContext).pop();
Clipboard.setData(ClipboardData(text: thread.post.content));
},
),
ListTile(
leading: Icon(
LucideIcons.trash2,
color: sheetContext.colors.error,
),
title: Text(
context.l10n.deletePost,
style: TextStyle(color: sheetContext.colors.error),
),
onTap: () {
Navigator.of(sheetContext).pop();
_confirmDeletePost(context, ref, thread.post.eventId);
},
),
],
),
),
),
),
);
}
void _confirmDeletePost(BuildContext context, WidgetRef ref, String eventId) {
showBuzzDialog<void>(
context: context,
builder: (dialogContext) => AlertDialog(
title: Text(context.l10n.deletePost),
content: Text(context.l10n.cannotUndo),
actions: [
TextButton(
onPressed: () => Navigator.of(dialogContext).pop(),
child: Text(context.l10n.cancel),
),
FilledButton(
onPressed: () async {
Navigator.of(dialogContext).pop();
await deleteForumEvent(
ref,
channelId: channelId,
eventId: eventId,
);
if (context.mounted) {
Navigator.of(context).pop();
}
},
style: FilledButton.styleFrom(
backgroundColor: dialogContext.colors.error,
),
child: Text(context.l10n.delete),
),
],
),
);
}
}
class _ThreadContent extends HookConsumerWidget {
final ForumThreadResponse thread;
final String channelId;
final String? currentPubkey;
final bool isMember;
final bool isArchived;
const _ThreadContent({
required this.thread,
required this.channelId,
required this.currentPubkey,
required this.isMember,
required this.isArchived,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
// Background media delivery may outlive this route's WidgetRef.
final providerContainer = ProviderScope.containerOf(context, listen: false);
final forumDelivery = ForumEventDelivery.capture(providerContainer);
final post = thread.post;
final replies = thread.replies;
// Preload profiles for all participants and tagged mentions.
final allPubkeys = useMemoized(() {
final pks = <String>{
post.pubkey.toLowerCase(),
...post.mentionPubkeys.map((pubkey) => pubkey.toLowerCase()),
};
for (final reply in replies) {
pks
..add(reply.pubkey.toLowerCase())
..addAll(reply.mentionPubkeys.map((pubkey) => pubkey.toLowerCase()));
}
return pks.toList()..sort();
}, [post, replies]);
final allPubkeysKey = allPubkeys.join('\u0000');
useEffect(() {
if (allPubkeys.isNotEmpty) {
ref.read(userCacheProvider.notifier).preload(allPubkeys);
}
return null;
}, [allPubkeysKey]);
return Column(
children: [
Expanded(
child: ListView(
padding: EdgeInsets.only(
top: frostedAppBarHeight(context),
bottom: Grid.xs,
),
children: [
_OriginalPost(post: post),
Padding(
padding: const EdgeInsets.symmetric(
horizontal: Grid.gutter,
vertical: Grid.xxs,
),
child: Row(
children: [
Icon(
LucideIcons.messageSquare,
size: 16,
color: context.colors.onSurfaceVariant,
),
const SizedBox(width: Grid.half),
Text(
context.l10n.replyCount(replies.length),
style: context.textTheme.labelMedium?.copyWith(
color: context.colors.onSurfaceVariant,
fontWeight: FontWeight.w600,
),
),
],
),
),
// Reply list
if (replies.isEmpty)
Padding(
padding: const EdgeInsets.all(Grid.sm),
child: Text(
context.l10n.noRepliesYet,
style: context.textTheme.bodyMedium?.copyWith(
color: context.colors.onSurfaceVariant,
),
textAlign: TextAlign.center,
),
)
else
for (final reply in replies)
_ReplyRow(
reply: reply,
currentPubkey: currentPubkey,
channelId: channelId,
rootEventId: post.eventId,
),
],
),
),
// Reply composer
if (isMember && !isArchived)
ComposeBar(
channelId: channelId,
hintText: context.l10n.replyToPost,
onSend:
(
content,
mentionPubkeys, {
mediaTags = const <List<String>>[],
}) => forumDelivery.createReply(
channelId: channelId,
parentEventId: post.eventId,
content: content,
mentionPubkeys: mentionPubkeys,
mediaTags: mediaTags,
),
),
],
);
}
}
class _OriginalPost extends ConsumerWidget {
final ForumPost post;
const _OriginalPost({required this.post});
@override
Widget build(BuildContext context, WidgetRef ref) {
final pk = post.pubkey.toLowerCase();
final profile =
ref.watch(userCacheProvider.select((cache) => cache[pk])) ??
ref.read(userCacheProvider.notifier).get(pk);
final displayName = profile?.label ?? _shortPubkey(post.pubkey);
final userCache = ref.watch(userCacheProvider);
final agentMentionPubkeys = agentPubkeysWithProfileOwners(
knownAgentPubkeys: ref.watch(agentMentionPubkeysProvider(post.channelId)),
profileOwnedAgentPubkeys: [
for (final profile in userCache.values)
if (profile.ownerPubkey != null) profile.pubkey,
],
);
final mentionNames = mentionNamesWithDirectoryLabels(
mentionPubkeys: post.mentionPubkeys,
profileMentionNames: _buildMentionNames(post.mentionPubkeys, userCache),
directoryDisplayNames: ref.watch(agentDirectoryDisplayNamesProvider),
agentMentionPubkeys: agentMentionPubkeys,
);
return Padding(
padding: const EdgeInsets.all(Grid.xs),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
GestureDetector(
onTap: () => showUserProfileSheet(context, post.pubkey),
child: _Avatar(
profile: profile,
pubkey: post.pubkey,
radius: 16,
),
),
const SizedBox(width: Grid.xxs),
Expanded(
child: Row(
children: [
Expanded(
child: GestureDetector(
onTap: () => showUserProfileSheet(context, post.pubkey),
child: Text(
displayName,
maxLines: 1,
style: messageUsernameTextStyle,
overflow: TextOverflow.ellipsis,
),
),
),
const SizedBox(width: Grid.xxs),
ConstrainedBox(
constraints: const BoxConstraints(maxWidth: Grid.xxl),
child: Text(
formatRelativeTimeLocalized(
context.l10n,
post.createdAt,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: messageTimestampTextStyle.copyWith(
color: context.colors.onSurfaceVariant,
),
),
),
],
),
),
],
),
const SizedBox(height: Grid.xxs),
MessageContent(
content: post.content,
mentionNames: mentionNames,
agentMentionPubkeys: agentMentionPubkeys,
tags: post.tags,
baseStyle: messageBodyTextStyle.copyWith(
color: context.colors.onSurface,
),
onMentionTap: (pubkey) => showUserProfileSheet(context, pubkey),
),
],
),
);
}
}
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 agentMentionPubkeys = agentPubkeysWithProfileOwners(
knownAgentPubkeys: ref.watch(agentMentionPubkeysProvider(channelId)),
profileOwnedAgentPubkeys: [
for (final profile in userCache.values)
if (profile.ownerPubkey != null) profile.pubkey,
],
);
final mentionNames = mentionNamesWithDirectoryLabels(
mentionPubkeys: reply.mentionPubkeys,
profileMentionNames: _buildMentionNames(reply.mentionPubkeys, userCache),
directoryDisplayNames: ref.watch(agentDirectoryDisplayNamesProvider),
agentMentionPubkeys: agentMentionPubkeys,
);
return Padding(
padding: const EdgeInsets.symmetric(
horizontal: Grid.gutter,
vertical: Grid.xxs,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
GestureDetector(
onTap: () => showUserProfileSheet(context, reply.pubkey),
child: _Avatar(
profile: profile,
pubkey: reply.pubkey,
radius: 12,
),
),
const SizedBox(width: Grid.xxs),
Expanded(
child: Row(
children: [
Expanded(
child: GestureDetector(
onTap: () =>
showUserProfileSheet(context, reply.pubkey),
child: Text(
displayName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: messageUsernameTextStyle,
),
),
),
const SizedBox(width: Grid.xxs),
ConstrainedBox(
constraints: const BoxConstraints(maxWidth: Grid.xxl),
child: Text(
formatRelativeTimeLocalized(
context.l10n,
reply.createdAt,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: messageTimestampTextStyle.copyWith(
color: context.colors.onSurfaceVariant,
),
),
),
],
),
),
SizedBox(
width: 28,
height: 28,
child: IconButton(
onPressed: () => _showActions(context, ref),
icon: Icon(
LucideIcons.ellipsis,
size: 16,
color: context.colors.onSurfaceVariant,
),
padding: EdgeInsets.zero,
visualDensity: VisualDensity.compact,
),
),
],
),
Padding(
padding: const EdgeInsets.only(left: 32, top: Grid.half),
child: MessageContent(
content: reply.content,
mentionNames: mentionNames,
agentMentionPubkeys: agentMentionPubkeys,
tags: reply.tags,
baseStyle: messageBodyTextStyle.copyWith(
color: context.colors.onSurface,
),
onMentionTap: (pubkey) => showUserProfileSheet(context, pubkey),
),
),
],
),
);
}
void _showActions(BuildContext context, WidgetRef ref) {
final isOwn =
currentPubkey != null &&
reply.pubkey.toLowerCase() == currentPubkey!.toLowerCase();
showBuzzModalBottomSheet<void>(
context: context,
showDragHandle: true,
builder: (sheetContext) => SafeArea(
child: IconTheme.merge(
data: const IconThemeData(size: 22),
child: Padding(
padding: const EdgeInsets.fromLTRB(
Grid.gutter,
0,
Grid.gutter,
Grid.xs,
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
leading: const Icon(LucideIcons.copy),
title: Text(context.l10n.copyText),
onTap: () {
Navigator.of(sheetContext).pop();
Clipboard.setData(ClipboardData(text: reply.content));
},
),
if (isOwn)
ListTile(
leading: Icon(
LucideIcons.trash2,
color: sheetContext.colors.error,
),
title: Text(
context.l10n.deleteReply,
style: TextStyle(color: sheetContext.colors.error),
),
onTap: () {
Navigator.of(sheetContext).pop();
_confirmDelete(context, ref);
},
),
],
),
),
),
),
);
}
void _confirmDelete(BuildContext context, WidgetRef ref) {
showBuzzDialog<void>(
context: context,
builder: (dialogContext) => AlertDialog(
title: Text(context.l10n.deleteReply),
content: Text(context.l10n.cannotUndo),
actions: [
TextButton(
onPressed: () => Navigator.of(dialogContext).pop(),
child: Text(context.l10n.cancel),
),
FilledButton(
onPressed: () async {
Navigator.of(dialogContext).pop();
await deleteForumEvent(
ref,
channelId: channelId,
eventId: reply.eventId,
rootEventId: rootEventId,
);
},
style: FilledButton.styleFrom(
backgroundColor: dialogContext.colors.error,
),
child: Text(context.l10n.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 AvatarImage(
imageUrl: avatarUrl,
radius: radius,
backgroundColor: context.colors.primaryContainer,
fallback: Text(
initial,
style: TextStyle(
fontSize: radius * 0.75,
fontWeight: FontWeight.w600,
color: context.colors.onPrimaryContainer,
),
),
);
}
}
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;
}