diff --git a/mobile/lib/features/channels/channel_detail_page.dart b/mobile/lib/features/channels/channel_detail_page.dart index e5eed6365..ab3c70d53 100644 --- a/mobile/lib/features/channels/channel_detail_page.dart +++ b/mobile/lib/features/channels/channel_detail_page.dart @@ -18,6 +18,7 @@ import 'channel_typing_provider.dart'; import 'channels_provider.dart'; import 'message_content.dart'; import 'send_message_provider.dart'; +import 'thread_detail_page.dart'; import 'timeline_message.dart'; /// Fetch channel members and preload their profiles into the user cache. @@ -51,7 +52,11 @@ class ChannelDetailPage extends HookConsumerWidget { final detailsAsync = ref.watch(channelDetailsProvider(channel.id)); final channelsAsync = ref.watch(channelsProvider); final messagesState = ref.watch(channelMessagesProvider(channel.id)); - final typingEntries = ref.watch(channelTypingProvider(channel.id)); + // Only show channel-level typing (exclude thread-scoped entries). + final typingEntries = ref + .watch(channelTypingProvider(channel.id)) + .where((e) => e.threadHeadId == null) + .toList(); final currentPubkey = ref .watch(profileProvider) .whenData((value) => value?.pubkey) @@ -148,10 +153,14 @@ class ChannelDetailPage extends HookConsumerWidget { events, currentPubkey: currentPubkey, ); + final entries = buildMainTimelineEntries(messages); return _MessageList( - messages: messages, + entries: entries, + allMessages: messages, channelId: channel.id, currentPubkey: currentPubkey, + isMember: resolvedChannel.isMember, + isArchived: resolvedChannel.isArchived, ); }, ), @@ -172,20 +181,49 @@ class ChannelDetailPage extends HookConsumerWidget { } } -class _MessageList extends ConsumerWidget { - final List messages; +class _MessageList extends HookConsumerWidget { + final List entries; + final List allMessages; final String channelId; final String? currentPubkey; + final bool isMember; + final bool isArchived; const _MessageList({ - required this.messages, + required this.entries, + required this.allMessages, required this.channelId, required this.currentPubkey, + required this.isMember, + required this.isArchived, }); @override Widget build(BuildContext context, WidgetRef ref) { - if (messages.isEmpty) { + // Pagination: fetch older messages when scrolling near the top. + final scrollController = useScrollController(); + final isLoadingOlder = useState(false); + + useEffect(() { + void onScroll() { + if (isLoadingOlder.value) return; + final notifier = ref.read(channelMessagesProvider(channelId).notifier); + if (notifier.reachedOldest) return; + // In a reversed ListView, maxScrollExtent is the oldest messages. + final pos = scrollController.position; + if (pos.pixels >= pos.maxScrollExtent - 200) { + isLoadingOlder.value = true; + notifier.fetchOlder().whenComplete( + () => isLoadingOlder.value = false, + ); + } + } + + scrollController.addListener(onScroll); + return () => scrollController.removeListener(onScroll); + }, [scrollController]); + + if (entries.isEmpty) { return Center( child: Column( mainAxisSize: MainAxisSize.min, @@ -224,35 +262,70 @@ class _MessageList extends ConsumerWidget { }); return ListView.builder( + controller: scrollController, reverse: true, padding: const EdgeInsets.symmetric( horizontal: Grid.xs, vertical: Grid.xxs, ), - itemCount: messages.length, + itemCount: entries.length + (isLoadingOlder.value ? 1 : 0), itemBuilder: (context, index) { + // Loading indicator at the top (last index in reversed list). + if (index >= entries.length) { + return const Padding( + padding: EdgeInsets.symmetric(vertical: Grid.xs), + child: Center( + child: SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ), + ), + ); + } + // Reversed list: index 0 = newest (bottom of screen). - final chronIdx = messages.length - 1 - index; - final message = messages[chronIdx]; + final chronIdx = entries.length - 1 - index; + final entry = entries[chronIdx]; + final message = entry.message; if (message.isSystem) { return _SystemMessageRow(message: message); } // The message visually above is the one earlier in time. - final prevMessage = chronIdx > 0 ? messages[chronIdx - 1] : null; + final prevEntry = chronIdx > 0 ? entries[chronIdx - 1] : null; + final prevMessage = prevEntry?.message; final showAuthor = prevMessage == null || prevMessage.isSystem || prevMessage.pubkey.toLowerCase() != message.pubkey.toLowerCase() || (message.createdAt - prevMessage.createdAt) > 300; - return _MessageBubble( - message: message, - showAuthor: showAuthor, - channelNames: channelNamesMap, - currentChannelId: channelId, - currentPubkey: currentPubkey, + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _MessageBubble( + message: message, + showAuthor: showAuthor, + channelNames: channelNamesMap, + currentChannelId: channelId, + currentPubkey: currentPubkey, + allMessages: allMessages, + isMember: isMember, + isArchived: isArchived, + ), + if (entry.summary != null) + _ThreadSummaryRow( + summary: entry.summary!, + message: message, + allMessages: allMessages, + channelId: channelId, + currentPubkey: currentPubkey, + isMember: isMember, + isArchived: isArchived, + ), + ], ); }, ); @@ -323,6 +396,134 @@ class _SystemMessageRow extends ConsumerWidget { } } +// --------------------------------------------------------------------------- +// Thread summary row (shown below messages that have replies) +// --------------------------------------------------------------------------- + +class _ThreadSummaryRow extends ConsumerWidget { + final ThreadSummary summary; + final TimelineMessage message; + final List allMessages; + final String channelId; + final String? currentPubkey; + final bool isMember; + final bool isArchived; + + const _ThreadSummaryRow({ + required this.summary, + required this.message, + required this.allMessages, + required this.channelId, + required this.currentPubkey, + required this.isMember, + required this.isArchived, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final userCache = ref.watch(userCacheProvider); + + return GestureDetector( + onTap: () { + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => ThreadDetailPage( + threadHead: message, + allMessages: allMessages, + channelId: channelId, + currentPubkey: currentPubkey, + isMember: isMember, + isArchived: isArchived, + ), + ), + ); + }, + child: Padding( + padding: const EdgeInsets.only( + left: 36, + top: Grid.half, + bottom: Grid.half, + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + // Stacked participant avatars. + SizedBox( + width: 20.0 + (summary.participantPubkeys.length - 1) * 12.0, + height: 20, + child: Stack( + children: [ + for (var i = 0; i < summary.participantPubkeys.length; i++) + Positioned( + left: i * 12.0, + child: _SmallAvatar( + pubkey: summary.participantPubkeys[i], + userCache: userCache, + ), + ), + ], + ), + ), + const SizedBox(width: Grid.xxs), + Text( + '${summary.replyCount} ${summary.replyCount == 1 ? 'reply' : 'replies'}', + style: context.textTheme.labelMedium?.copyWith( + color: context.colors.primary, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(width: Grid.half), + Icon( + LucideIcons.chevronRight, + size: 14, + color: context.colors.primary, + ), + ], + ), + ), + ); + } +} + +class _SmallAvatar extends StatelessWidget { + final String pubkey; + final Map userCache; + + const _SmallAvatar({required this.pubkey, required this.userCache}); + + @override + Widget build(BuildContext context) { + final profile = userCache[pubkey.toLowerCase()]; + final avatarUrl = profile?.avatarUrl; + final initial = + profile?.initial ?? (pubkey.isNotEmpty ? pubkey[0].toUpperCase() : '?'); + + return Container( + width: 20, + height: 20, + decoration: BoxDecoration( + shape: BoxShape.circle, + border: Border.all(color: context.colors.surface, width: 1.5), + ), + child: CircleAvatar( + radius: 9, + backgroundColor: context.colors.primaryContainer, + backgroundImage: avatarUrl != null ? NetworkImage(avatarUrl) : null, + child: avatarUrl == null + ? Text( + initial, + style: TextStyle( + fontSize: 8, + fontWeight: FontWeight.w600, + color: context.colors.onPrimaryContainer, + ), + ) + : null, + ), + ); + } +} + // --------------------------------------------------------------------------- // User message bubble // --------------------------------------------------------------------------- @@ -333,6 +534,9 @@ class _MessageBubble extends ConsumerWidget { final Map channelNames; final String currentChannelId; final String? currentPubkey; + final List? allMessages; + final bool isMember; + final bool isArchived; const _MessageBubble({ required this.message, @@ -340,6 +544,9 @@ class _MessageBubble extends ConsumerWidget { required this.channelNames, required this.currentChannelId, required this.currentPubkey, + this.allMessages, + this.isMember = false, + this.isArchived = false, }); @override @@ -370,6 +577,10 @@ class _MessageBubble extends ConsumerWidget { channelId: currentChannelId, isOwnMessage: currentPubkey?.toLowerCase() == message.pubkey.toLowerCase(), + allMessages: allMessages, + currentPubkey: currentPubkey, + isMember: isMember, + isArchived: isArchived, ), child: Padding( padding: EdgeInsets.only(top: showAuthor ? Grid.xs : Grid.quarter), @@ -576,6 +787,10 @@ void _showMessageActions({ required TimelineMessage message, required String channelId, required bool isOwnMessage, + List? allMessages, + String? currentPubkey, + bool isMember = false, + bool isArchived = false, }) { showModalBottomSheet( context: context, @@ -614,6 +829,26 @@ void _showMessageActions({ ], ), const SizedBox(height: Grid.xs), + if (allMessages != null) + ListTile( + leading: const Icon(LucideIcons.messageSquareReply), + title: const Text('Reply in thread'), + onTap: () { + Navigator.of(sheetContext).pop(); + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => ThreadDetailPage( + threadHead: message, + allMessages: allMessages, + channelId: channelId, + currentPubkey: currentPubkey, + isMember: isMember, + isArchived: isArchived, + ), + ), + ); + }, + ), ListTile( leading: const Icon(LucideIcons.copy), title: const Text('Copy text'), diff --git a/mobile/lib/features/channels/channel_messages_provider.dart b/mobile/lib/features/channels/channel_messages_provider.dart index ac9e1da4a..b2665c921 100644 --- a/mobile/lib/features/channels/channel_messages_provider.dart +++ b/mobile/lib/features/channels/channel_messages_provider.dart @@ -7,6 +7,7 @@ import '../../shared/relay/relay.dart'; class ChannelMessagesNotifier extends Notifier>> { final String channelId; void Function()? _unsubscribe; + bool _reachedOldest = false; ChannelMessagesNotifier(this.channelId); @@ -22,6 +23,8 @@ class ChannelMessagesNotifier extends Notifier>> { return const AsyncData([]); } + // Reset pagination state on rebuild (e.g. after reconnect). + _reachedOldest = false; _init(); return const AsyncLoading(); } @@ -75,10 +78,16 @@ class ChannelMessagesNotifier extends Notifier>> { return updated; } + /// Whether all history has been loaded (no more older messages). + bool get reachedOldest => _reachedOldest; + /// Fetch older messages (pagination). Call this when the user scrolls up. - Future fetchOlder() async { + /// Returns `true` if new messages were loaded. + Future fetchOlder() async { + if (_reachedOldest) return false; + final currentEvents = state.value; - if (currentEvents == null || currentEvents.isEmpty) return; + if (currentEvents == null || currentEvents.isEmpty) return false; final oldest = currentEvents.first.createdAt; final session = ref.read(relaySessionProvider.notifier); @@ -94,15 +103,28 @@ class ChannelMessagesNotifier extends Notifier>> { ), ); - if (older.isEmpty) return; + if (older.isEmpty) { + _reachedOldest = true; + return false; + } + + // Dedup against existing events. If nothing new remains after dedup + // (e.g. all returned events share the boundary timestamp), mark as + // exhausted to avoid an infinite fetch loop. + final currentIds = state.value?.map((e) => e.id).toSet() ?? {}; + final deduped = older.where((e) => !currentIds.contains(e.id)).toList(); + + if (deduped.isEmpty) { + _reachedOldest = true; + return false; + } state = state.whenData((events) { - final ids = events.map((e) => e.id).toSet(); - final deduped = older.where((e) => !ids.contains(e.id)).toList(); final merged = [...deduped, ...events]; merged.sort((a, b) => a.createdAt.compareTo(b.createdAt)); return merged; }); + return true; } } diff --git a/mobile/lib/features/channels/send_message_provider.dart b/mobile/lib/features/channels/send_message_provider.dart index 17b72e161..dc08be1a7 100644 --- a/mobile/lib/features/channels/send_message_provider.dart +++ b/mobile/lib/features/channels/send_message_provider.dart @@ -18,19 +18,36 @@ class SendMessage { _readUserCache = readUserCache; /// Send a text message to a channel. + /// + /// For thread replies, pass [parentEventId] and optionally [rootEventId]. + /// If [rootEventId] is null it defaults to [parentEventId] (direct reply to + /// thread head). Tags are built to match the desktop's `buildReplyTags` + /// convention with `root` / `reply` markers. Future call({ required String channelId, required String content, String? parentEventId, + String? rootEventId, List? mentionPubkeys, }) async { // Resolve @mentions in the message content to pubkeys. final resolvedMentions = mentionPubkeys ?? _resolveMentions(content); + final authorPubkey = _signedEventRelay.pubkey; + + // Normalize mentions: lowercase, deduplicate, exclude self (matching + // the desktop's normalizeMentionPubkeys). + final selfLower = authorPubkey?.toLowerCase(); + final seenMentions = {?selfLower}; + final normalizedMentions = [ + for (final pk in resolvedMentions) + if (seenMentions.add(pk.toLowerCase())) pk, + ]; final tags = >[ + if (authorPubkey != null) ['p', authorPubkey], ['h', channelId], - if (parentEventId != null) ['e', parentEventId], - for (final pk in resolvedMentions) ['p', pk], + if (parentEventId != null) ..._buildReplyTags(parentEventId, rootEventId), + for (final pk in normalizedMentions) ['p', pk], ]; await _signedEventRelay.submit( @@ -70,6 +87,25 @@ class SendMessage { return pubkeys.toList(); } + + /// Build `e`-tags for a thread reply, matching the desktop convention: + /// - Direct reply to thread head: `["e", id, "", "reply"]` + /// - Nested reply: `["e", rootId, "", "root"]` + `["e", parentId, "", "reply"]` + static List> _buildReplyTags( + String parentEventId, + String? rootEventId, + ) { + final root = rootEventId ?? parentEventId; + if (parentEventId == root) { + return [ + ['e', root, '', 'reply'], + ]; + } + return [ + ['e', root, '', 'root'], + ['e', parentEventId, '', 'reply'], + ]; + } } final sendMessageProvider = Provider((ref) { diff --git a/mobile/lib/features/channels/thread_detail_page.dart b/mobile/lib/features/channels/thread_detail_page.dart new file mode 100644 index 000000000..68ad96397 --- /dev/null +++ b/mobile/lib/features/channels/thread_detail_page.dart @@ -0,0 +1,877 @@ +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 '../profile/user_cache_provider.dart'; +import '../profile/user_profile.dart'; +import 'channel_management_provider.dart'; +import 'channel_messages_provider.dart'; +import 'channel_typing_provider.dart'; +import 'channels_provider.dart'; +import 'message_content.dart'; +import 'send_message_provider.dart'; +import 'timeline_message.dart'; + +/// Full-screen thread detail page. +/// +/// Shows the thread head message, direct replies, typing indicators scoped to +/// the thread, and a compose bar for replying. +class ThreadDetailPage extends HookConsumerWidget { + final TimelineMessage threadHead; + final List allMessages; + final String channelId; + final String? currentPubkey; + final bool isMember; + final bool isArchived; + + const ThreadDetailPage({ + super.key, + required this.threadHead, + required this.allMessages, + required this.channelId, + required this.currentPubkey, + required this.isMember, + required this.isArchived, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + // Re-derive replies from live message state so new replies appear. + final messagesState = ref.watch(channelMessagesProvider(channelId)); + final liveMessages = messagesState.whenData((events) { + return formatTimeline(events, currentPubkey: currentPubkey); + }); + + final allMsgs = liveMessages.value ?? allMessages; + + // Index all messages by parentId so we can find direct children of any + // message and compute thread summaries for nested threads. + final childrenByParent = >{}; + for (final msg in allMsgs) { + final pid = msg.parentId; + if (pid == null) continue; + childrenByParent.putIfAbsent(pid, () => []).add(msg); + } + + final replies = childrenByParent[threadHead.id] ?? const []; + + // Thread-scoped typing indicators. + final allTyping = ref.watch(channelTypingProvider(channelId)); + final threadTyping = allTyping + .where((e) => e.threadHeadId == threadHead.id) + .toList(); + + // Resolve thread head from live data (reactions/edits may have changed). + final liveHead = + allMsgs.where((m) => m.id == threadHead.id).firstOrNull ?? threadHead; + + // The root of the entire thread chain. If the current thread head is + // itself a root message its rootId is null, so fall back to its own id. + final effectiveRootId = threadHead.rootId ?? threadHead.id; + + // Channel names for message content rendering. + final channelsAsync = ref.watch(channelsProvider); + final channelNamesMap = {}; + channelsAsync.whenData((channels) { + for (final ch in channels) { + channelNamesMap[ch.name.toLowerCase()] = ch.id; + } + }); + + return Scaffold( + appBar: AppBar(title: const Text('Thread')), + body: Column( + children: [ + Expanded( + child: ListView.builder( + padding: const EdgeInsets.symmetric( + horizontal: Grid.xs, + vertical: Grid.xxs, + ), + itemCount: replies.length + 1, // +1 for thread head + itemBuilder: (context, index) { + if (index == 0) { + // Thread head. + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _ThreadMessage( + message: liveHead, + channelNames: channelNamesMap, + channelId: channelId, + currentPubkey: currentPubkey, + showAuthor: true, + allMessages: allMsgs, + isMember: isMember, + isArchived: isArchived, + ), + Padding( + padding: const EdgeInsets.symmetric(vertical: Grid.xxs), + child: Row( + children: [ + Text( + '${replies.length} ${replies.length == 1 ? 'reply' : 'replies'}', + style: context.textTheme.labelMedium?.copyWith( + color: context.colors.onSurfaceVariant, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(width: Grid.xxs), + Expanded( + child: Divider( + color: context.colors.outlineVariant, + ), + ), + ], + ), + ), + ], + ); + } + + final reply = replies[index - 1]; + final prevReply = index > 1 ? replies[index - 2] : null; + final showAuthor = + prevReply == null || + prevReply.pubkey.toLowerCase() != + reply.pubkey.toLowerCase() || + (reply.createdAt - prevReply.createdAt) > 300; + + // Check if this reply itself has children (nested thread). + final nestedChildren = childrenByParent[reply.id]; + final nestedSummary = + nestedChildren != null && nestedChildren.isNotEmpty + ? _buildNestedSummary(reply.id, nestedChildren) + : null; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _ThreadMessage( + message: reply, + channelNames: channelNamesMap, + channelId: channelId, + currentPubkey: currentPubkey, + showAuthor: showAuthor, + allMessages: allMsgs, + isMember: isMember, + isArchived: isArchived, + ), + if (nestedSummary != null) + _NestedThreadSummaryRow( + summary: nestedSummary, + replyMessage: reply, + allMessages: allMsgs, + channelId: channelId, + currentPubkey: currentPubkey, + isMember: isMember, + isArchived: isArchived, + ), + ], + ); + }, + ), + ), + if (threadTyping.isNotEmpty) + _ThreadTypingIndicator(entries: threadTyping), + if (isMember && !isArchived) + _ThreadComposeBar( + channelId: channelId, + threadHeadId: threadHead.id, + rootId: effectiveRootId, + ), + ], + ), + ); + } +} + +// --------------------------------------------------------------------------- +// Nested thread summary helpers +// --------------------------------------------------------------------------- + +/// Build a lightweight summary for a nested thread (reply that has its own +/// replies). Same logic as the top-level [ThreadSummary] but kept local to +/// avoid coupling. +ThreadSummary _buildNestedSummary( + String messageId, + List children, +) { + final seen = {}; + final participants = []; + for (var i = children.length - 1; i >= 0 && participants.length < 3; i--) { + final pk = children[i].pubkey.toLowerCase(); + if (seen.add(pk)) participants.add(pk); + } + return ThreadSummary( + threadHeadId: messageId, + replyCount: children.length, + participantPubkeys: participants.reversed.toList(), + ); +} + +/// Tappable summary row shown below a reply that itself has replies. +/// Pushes a new [ThreadDetailPage] for the nested thread. +class _NestedThreadSummaryRow extends ConsumerWidget { + final ThreadSummary summary; + final TimelineMessage replyMessage; + final List allMessages; + final String channelId; + final String? currentPubkey; + final bool isMember; + final bool isArchived; + + const _NestedThreadSummaryRow({ + required this.summary, + required this.replyMessage, + required this.allMessages, + required this.channelId, + required this.currentPubkey, + required this.isMember, + required this.isArchived, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final userCache = ref.watch(userCacheProvider); + + return GestureDetector( + onTap: () { + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => ThreadDetailPage( + threadHead: replyMessage, + allMessages: allMessages, + channelId: channelId, + currentPubkey: currentPubkey, + isMember: isMember, + isArchived: isArchived, + ), + ), + ); + }, + child: Padding( + padding: const EdgeInsets.only( + left: 36, + top: Grid.half, + bottom: Grid.half, + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + // Stacked participant avatars. + SizedBox( + width: + 20.0 + + (summary.participantPubkeys.length - 1).clamp(0, 2) * 12.0, + height: 20, + child: Stack( + children: [ + for (var i = 0; i < summary.participantPubkeys.length; i++) + Positioned( + left: i * 12.0, + child: _SmallAvatar( + pubkey: summary.participantPubkeys[i], + userCache: userCache, + ), + ), + ], + ), + ), + const SizedBox(width: Grid.xxs), + Text( + '${summary.replyCount} ${summary.replyCount == 1 ? 'reply' : 'replies'}', + style: context.textTheme.labelMedium?.copyWith( + color: context.colors.primary, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(width: Grid.half), + Icon( + LucideIcons.chevronRight, + size: 14, + color: context.colors.primary, + ), + ], + ), + ), + ); + } +} + +class _SmallAvatar extends StatelessWidget { + final String pubkey; + final Map userCache; + + const _SmallAvatar({required this.pubkey, required this.userCache}); + + @override + Widget build(BuildContext context) { + final profile = userCache[pubkey.toLowerCase()]; + final avatarUrl = profile?.avatarUrl; + final initial = + profile?.initial ?? (pubkey.isNotEmpty ? pubkey[0].toUpperCase() : '?'); + + return Container( + width: 20, + height: 20, + decoration: BoxDecoration( + shape: BoxShape.circle, + border: Border.all(color: context.colors.surface, width: 1.5), + ), + child: CircleAvatar( + radius: 9, + backgroundColor: context.colors.primaryContainer, + backgroundImage: avatarUrl != null ? NetworkImage(avatarUrl) : null, + child: avatarUrl == null + ? Text( + initial, + style: TextStyle( + fontSize: 8, + fontWeight: FontWeight.w600, + color: context.colors.onPrimaryContainer, + ), + ) + : null, + ), + ); + } +} + +// --------------------------------------------------------------------------- +// Thread message row (reusable for head and replies) +// --------------------------------------------------------------------------- + +class _ThreadMessage extends ConsumerWidget { + final TimelineMessage message; + final Map channelNames; + final String channelId; + final String? currentPubkey; + final bool showAuthor; + final List? allMessages; + final bool isMember; + final bool isArchived; + + const _ThreadMessage({ + required this.message, + required this.channelNames, + required this.channelId, + required this.currentPubkey, + required this.showAuthor, + this.allMessages, + this.isMember = false, + this.isArchived = false, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final pk = message.pubkey.toLowerCase(); + final profile = + ref.watch(userCacheProvider.select((cache) => cache[pk])) ?? + ref.read(userCacheProvider.notifier).get(pk); + final displayName = profile?.label ?? _shortPubkey(message.pubkey); + + final userCache = ref.watch(userCacheProvider); + final mentionNames = {}; + for (final mpk in message.mentionPubkeys) { + final p = userCache[mpk.toLowerCase()]; + if (p?.displayName != null) { + mentionNames[mpk.toLowerCase()] = p!.displayName!; + } + } + + return GestureDetector( + behavior: HitTestBehavior.opaque, + onLongPress: () => _showThreadMessageActions( + context: context, + ref: ref, + message: message, + channelId: channelId, + isOwnMessage: + currentPubkey?.toLowerCase() == message.pubkey.toLowerCase(), + allMessages: allMessages, + currentPubkey: currentPubkey, + isMember: isMember, + isArchived: isArchived, + ), + child: Padding( + padding: EdgeInsets.only(top: showAuthor ? Grid.xs : Grid.quarter), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (showAuthor) + _Avatar(profile: profile, pubkey: message.pubkey) + else + const SizedBox(width: 28), + const SizedBox(width: Grid.xxs), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (showAuthor) + Padding( + padding: const EdgeInsets.only(bottom: Grid.quarter), + child: Row( + children: [ + Text( + displayName, + style: context.textTheme.labelMedium?.copyWith( + fontWeight: FontWeight.w600, + color: context.colors.onSurface, + ), + ), + const SizedBox(width: Grid.xxs), + Text( + _formatTime(message.createdAt), + style: context.textTheme.labelSmall?.copyWith( + color: context.colors.outline, + ), + ), + if (message.edited) ...[ + const SizedBox(width: Grid.half), + Text( + '(edited)', + style: context.textTheme.labelSmall?.copyWith( + color: context.colors.outline, + fontStyle: FontStyle.italic, + ), + ), + ], + ], + ), + ), + MessageContent( + content: message.content, + mentionNames: mentionNames, + channelNames: channelNames, + onChannelTap: (_) {}, + ), + if (message.reactions.isNotEmpty) + _ReactionRow( + reactions: message.reactions, + onToggle: (emoji) { + final actions = ref.read(channelActionsProvider); + final reaction = message.reactions.firstWhere( + (r) => r.emoji == emoji, + ); + if (reaction.reactedByCurrentUser && + reaction.currentUserReactionId != null) { + actions.removeReaction( + reaction.currentUserReactionId!, + emoji, + ); + } else { + actions.addReaction(message.id, emoji); + } + }, + ), + ], + ), + ), + ], + ), + ), + ); + } +} + +// --------------------------------------------------------------------------- +// Thread compose bar +// --------------------------------------------------------------------------- + +class _ThreadComposeBar extends HookConsumerWidget { + final String channelId; + final String threadHeadId; + final String? rootId; + + const _ThreadComposeBar({ + required this.channelId, + required this.threadHeadId, + required this.rootId, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final controller = useTextEditingController(); + final isSending = useState(false); + + Future send() async { + final text = controller.text.trim(); + if (text.isEmpty || isSending.value) return; + + isSending.value = true; + try { + await ref + .read(sendMessageProvider) + .call( + channelId: channelId, + content: text, + parentEventId: threadHeadId, + rootEventId: rootId ?? threadHeadId, + ); + if (context.mounted) controller.clear(); + } finally { + if (context.mounted) isSending.value = false; + } + } + + return Container( + decoration: BoxDecoration( + border: Border(top: BorderSide(color: context.colors.outlineVariant)), + color: context.colors.surface, + ), + padding: EdgeInsets.only( + left: Grid.xs, + right: Grid.xxs, + top: Grid.xxs, + bottom: MediaQuery.viewPaddingOf(context).bottom + Grid.xxs, + ), + child: Row( + children: [ + Expanded( + child: TextField( + controller: controller, + textInputAction: TextInputAction.send, + onSubmitted: (_) => send(), + minLines: 1, + maxLines: 5, + decoration: InputDecoration( + hintText: 'Reply...', + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(Radii.lg), + borderSide: BorderSide(color: context.colors.outlineVariant), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(Radii.lg), + borderSide: BorderSide(color: context.colors.outlineVariant), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(Radii.lg), + borderSide: BorderSide(color: context.colors.primary), + ), + contentPadding: const EdgeInsets.symmetric( + horizontal: Grid.twelve, + vertical: Grid.xxs, + ), + isDense: true, + ), + ), + ), + const SizedBox(width: Grid.half), + IconButton( + onPressed: isSending.value ? null : send, + icon: isSending.value + ? SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + color: context.colors.primary, + ), + ) + : Icon( + LucideIcons.sendHorizontal, + color: context.colors.primary, + ), + ), + ], + ), + ); + } +} + +// --------------------------------------------------------------------------- +// Thread-scoped typing indicator +// --------------------------------------------------------------------------- + +class _ThreadTypingIndicator extends ConsumerWidget { + final List entries; + + const _ThreadTypingIndicator({required this.entries}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final userCache = ref.watch(userCacheProvider); + final names = entries.map((e) { + final profile = + userCache[e.pubkey.toLowerCase()] ?? + ref.read(userCacheProvider.notifier).get(e.pubkey.toLowerCase()); + return profile?.label ?? _shortPubkey(e.pubkey); + }).toList(); + final text = switch (names.length) { + 1 => '${names[0]} is typing...', + 2 => '${names[0]} and ${names[1]} are typing...', + _ => '${names[0]} and ${names.length - 1} others are typing...', + }; + + return Container( + width: double.infinity, + padding: const EdgeInsets.symmetric( + horizontal: Grid.xs, + vertical: Grid.quarter + 2, + ), + child: Text( + text, + style: context.textTheme.labelSmall?.copyWith( + color: context.colors.outline, + fontStyle: FontStyle.italic, + ), + ), + ); + } +} + +// --------------------------------------------------------------------------- +// Thread message actions (long-press sheet — reactions, copy, edit, delete) +// --------------------------------------------------------------------------- + +const _quickEmojis = [ + '\u{1F44D}', + '\u{2764}\u{FE0F}', + '\u{1F602}', + '\u{1F389}', + '\u{1F440}', + '\u{1F64F}', +]; + +void _showThreadMessageActions({ + required BuildContext context, + required WidgetRef ref, + required TimelineMessage message, + required String channelId, + required bool isOwnMessage, + List? allMessages, + String? currentPubkey, + bool isMember = false, + bool isArchived = false, +}) { + showModalBottomSheet( + context: context, + showDragHandle: true, + builder: (sheetContext) => SafeArea( + child: Padding( + padding: const EdgeInsets.fromLTRB(Grid.xs, 0, Grid.xs, Grid.xs), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + for (final emoji in _quickEmojis) + GestureDetector( + onTap: () { + Navigator.of(sheetContext).pop(); + ref + .read(channelActionsProvider) + .addReaction(message.id, emoji); + }, + child: Container( + width: 44, + height: 44, + alignment: Alignment.center, + decoration: BoxDecoration( + color: Theme.of( + sheetContext, + ).colorScheme.surfaceContainerHighest, + shape: BoxShape.circle, + ), + child: Text(emoji, style: const TextStyle(fontSize: 20)), + ), + ), + ], + ), + const SizedBox(height: Grid.xs), + if (allMessages != null) + ListTile( + leading: const Icon(LucideIcons.messageSquareReply), + title: const Text('Reply in thread'), + onTap: () { + Navigator.of(sheetContext).pop(); + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => ThreadDetailPage( + threadHead: message, + allMessages: allMessages, + channelId: channelId, + currentPubkey: currentPubkey, + isMember: isMember, + isArchived: isArchived, + ), + ), + ); + }, + ), + ListTile( + leading: const Icon(LucideIcons.copy), + title: const Text('Copy text'), + onTap: () { + Navigator.of(sheetContext).pop(); + Clipboard.setData(ClipboardData(text: message.content)); + }, + ), + if (isOwnMessage) ...[ + ListTile( + leading: Icon( + LucideIcons.trash2, + color: Theme.of(sheetContext).colorScheme.error, + ), + title: Text( + 'Delete message', + style: TextStyle( + color: Theme.of(sheetContext).colorScheme.error, + ), + ), + onTap: () { + Navigator.of(sheetContext).pop(); + showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + title: const Text('Delete message'), + content: const Text('This cannot be undone.'), + actions: [ + TextButton( + onPressed: () => Navigator.of(dialogContext).pop(), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () { + Navigator.of(dialogContext).pop(); + ref + .read(channelActionsProvider) + .deleteMessage(message.id); + }, + style: FilledButton.styleFrom( + backgroundColor: Theme.of( + dialogContext, + ).colorScheme.error, + ), + child: const Text('Delete'), + ), + ], + ), + ); + }, + ), + ], + ], + ), + ), + ), + ); +} + +// --------------------------------------------------------------------------- +// Shared helpers (duplicated here to keep the file self-contained) +// --------------------------------------------------------------------------- + +class _Avatar extends StatelessWidget { + final UserProfile? profile; + final String pubkey; + + const _Avatar({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, + ); + } +} + +class _ReactionRow extends StatelessWidget { + final List reactions; + final void Function(String emoji) onToggle; + + const _ReactionRow({required this.reactions, required this.onToggle}); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(top: Grid.half), + child: Wrap( + spacing: Grid.half, + runSpacing: Grid.half, + children: [ + for (final reaction in reactions) + GestureDetector( + onTap: () => onToggle(reaction.emoji), + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: Grid.xxs, + vertical: Grid.quarter, + ), + decoration: BoxDecoration( + color: reaction.reactedByCurrentUser + ? context.colors.primary.withValues(alpha: 0.12) + : context.colors.surfaceContainerHighest, + borderRadius: BorderRadius.circular(Radii.lg), + border: Border.all( + color: reaction.reactedByCurrentUser + ? context.colors.primary.withValues(alpha: 0.4) + : context.colors.outlineVariant, + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text(reaction.emoji, style: const TextStyle(fontSize: 14)), + if (reaction.count > 1) ...[ + const SizedBox(width: Grid.quarter), + Text( + '${reaction.count}', + style: context.textTheme.labelSmall?.copyWith( + color: reaction.reactedByCurrentUser + ? context.colors.primary + : context.colors.onSurfaceVariant, + fontWeight: FontWeight.w600, + ), + ), + ], + ], + ), + ), + ), + ], + ), + ); + } +} + +String _shortPubkey(String pubkey) { + if (pubkey.length > 12) return '${pubkey.substring(0, 8)}...'; + return pubkey; +} + +String _formatTime(int createdAt) { + final dt = DateTime.fromMillisecondsSinceEpoch( + createdAt * 1000, + isUtc: true, + ).toLocal(); + final now = DateTime.now(); + final diff = now.difference(dt); + + if (diff.inDays > 0) { + return '${dt.month}/${dt.day} ${_pad(dt.hour)}:${_pad(dt.minute)}'; + } + return '${_pad(dt.hour)}:${_pad(dt.minute)}'; +} + +String _pad(int n) => n.toString().padLeft(2, '0'); diff --git a/mobile/lib/features/channels/timeline_message.dart b/mobile/lib/features/channels/timeline_message.dart index 6b027961e..f931f0f03 100644 --- a/mobile/lib/features/channels/timeline_message.dart +++ b/mobile/lib/features/channels/timeline_message.dart @@ -143,6 +143,12 @@ class TimelineMessage { /// Aggregated reactions on this message. final List reactions; + /// Direct parent event ID (null for top-level messages). + final String? parentId; + + /// Root event ID of the thread (null for top-level messages). + final String? rootId; + const TimelineMessage({ required this.id, required this.pubkey, @@ -153,9 +159,39 @@ class TimelineMessage { this.systemEvent, this.mentionPubkeys = const [], this.reactions = const [], + this.parentId, + this.rootId, }); } +// --------------------------------------------------------------------------- +// ThreadSummary — inline thread indicator on the main timeline +// --------------------------------------------------------------------------- + +@immutable +class ThreadSummary { + final String threadHeadId; + final int replyCount; + + /// Up to 3 most recent unique participant pubkeys. + final List participantPubkeys; + + const ThreadSummary({ + required this.threadHeadId, + required this.replyCount, + required this.participantPubkeys, + }); +} + +/// A main-timeline entry: a root message with an optional thread summary. +@immutable +class MainTimelineEntry { + final TimelineMessage message; + final ThreadSummary? summary; + + const MainTimelineEntry({required this.message, this.summary}); +} + // --------------------------------------------------------------------------- // formatTimeline — converts raw NostrEvents into display-ready messages // --------------------------------------------------------------------------- @@ -267,6 +303,8 @@ List formatTimeline( ), ]; + final threadRef = event.threadReference; + result.add( TimelineMessage( id: event.id, @@ -276,6 +314,8 @@ List formatTimeline( edited: edit != null, mentionPubkeys: mentions, reactions: reactions, + parentId: threadRef.parentId, + rootId: threadRef.rootId, ), ); } @@ -284,6 +324,53 @@ List formatTimeline( return result; } +/// Build main-timeline entries: only root messages (parentId == null), +/// each with an optional [ThreadSummary] when replies exist. +/// +/// Mirrors the desktop's `buildMainTimelineEntries`. +List buildMainTimelineEntries( + List messages, +) { + // Index direct children by parentId. + final childrenByParent = >{}; + for (final msg in messages) { + final pid = msg.parentId; + if (pid == null) continue; + childrenByParent.putIfAbsent(pid, () => []).add(msg); + } + + return [ + for (final msg in messages) + if (msg.parentId == null) + MainTimelineEntry( + message: msg, + summary: _buildSummary(msg.id, childrenByParent), + ), + ]; +} + +ThreadSummary? _buildSummary( + String messageId, + Map> childrenByParent, +) { + final replies = childrenByParent[messageId]; + if (replies == null || replies.isEmpty) return null; + + // Up to 3 most recent unique participants (walk backwards). + final seen = {}; + final participants = []; + for (var i = replies.length - 1; i >= 0 && participants.length < 3; i--) { + final pk = replies[i].pubkey.toLowerCase(); + if (seen.add(pk)) participants.add(pk); + } + + return ThreadSummary( + threadHeadId: messageId, + replyCount: replies.length, + participantPubkeys: participants.reversed.toList(), + ); +} + // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- diff --git a/mobile/lib/shared/relay/nostr_models.dart b/mobile/lib/shared/relay/nostr_models.dart index ab501a6eb..d0f35f756 100644 --- a/mobile/lib/shared/relay/nostr_models.dart +++ b/mobile/lib/shared/relay/nostr_models.dart @@ -87,8 +87,39 @@ class NostrEvent { /// The channel/group ID from the `h` tag (NIP-29). String? get channelId => getTagValue('h'); + /// Extract thread parent and root IDs from `e` tags. + /// + /// Matches the desktop's `getThreadReference` logic: + /// - Tags with marker `"reply"` identify the direct parent. + /// - Tags with marker `"root"` identify the thread root. + /// - If no markers are present, falls back to null (top-level message). + ({String? parentId, String? rootId}) get threadReference { + final eTags = [ + for (final tag in tags) + if (tag.length >= 2 && tag[0] == 'e') tag, + ]; + + if (eTags.isEmpty) return (parentId: null, rootId: null); + + // Find tagged root and reply markers (desktop convention). + List? rootTag; + List? replyTag; + for (final tag in eTags) { + if (tag.length >= 4) { + if (tag[3] == 'root') rootTag = tag; + if (tag[3] == 'reply') replyTag = tag; + } + } + + if (replyTag == null) return (parentId: null, rootId: null); + + final parentId = replyTag[1]; + final rootId = rootTag?[1] ?? parentId; + return (parentId: parentId, rootId: rootId); + } + /// The parent event ID from the `e` tag. - String? get parentEventId => getTagValue('e'); + String? get parentEventId => threadReference.parentId; @override bool operator ==(Object other) => diff --git a/mobile/lib/shared/relay/signed_event_relay.dart b/mobile/lib/shared/relay/signed_event_relay.dart index 6ab5db445..075c759ac 100644 --- a/mobile/lib/shared/relay/signed_event_relay.dart +++ b/mobile/lib/shared/relay/signed_event_relay.dart @@ -13,6 +13,15 @@ class SignedEventRelay { : _client = client, _nsec = nsec; + /// The hex pubkey derived from the signing key, or null if no key. + String? get pubkey { + final nsec = _nsec; + if (nsec == null || nsec.isEmpty) return null; + final privkeyHex = nostr.Nip19.decodePrivkey(nsec); + if (privkeyHex.isEmpty) return null; + return nostr.Keychain(privkeyHex).public; + } + Future submit({ required int kind, required String content, diff --git a/mobile/test/features/channels/timeline_message_test.dart b/mobile/test/features/channels/timeline_message_test.dart index 473c99b50..bbdf22d7d 100644 --- a/mobile/test/features/channels/timeline_message_test.dart +++ b/mobile/test/features/channels/timeline_message_test.dart @@ -13,6 +13,7 @@ NostrEvent _textMsg({ String pubkey = 'alice', String content = 'hello', int createdAt = 1000, + List>? extraTags, }) => NostrEvent( id: id, pubkey: pubkey, @@ -20,11 +21,39 @@ NostrEvent _textMsg({ kind: EventKind.streamMessage, tags: [ ['h', 'ch1'], + ...?extraTags, ], content: content, sig: '', ); +/// A reply message with proper root/reply e-tag markers. +NostrEvent _replyMsg({ + required String id, + required String parentId, + String? rootId, + String pubkey = 'alice', + String content = 'reply', + int createdAt = 2000, +}) { + final root = rootId ?? parentId; + final eTags = parentId == root + ? [ + ['e', root, '', 'reply'], + ] + : [ + ['e', root, '', 'root'], + ['e', parentId, '', 'reply'], + ]; + return _textMsg( + id: id, + pubkey: pubkey, + content: content, + createdAt: createdAt, + extraTags: eTags, + ); +} + NostrEvent _systemMsg({ required String id, required Map payload, @@ -443,4 +472,193 @@ void main() { expect(result[0].content, contains('diff')); }); }); + + // ------------------------------------------------------------------------- + // NostrEvent.threadReference + // ------------------------------------------------------------------------- + + group('NostrEvent.threadReference', () { + test('returns nulls for top-level message (no e-tags)', () { + final event = _textMsg(id: 'a'); + final ref = event.threadReference; + expect(ref.parentId, isNull); + expect(ref.rootId, isNull); + }); + + test('returns nulls for e-tags without root/reply markers', () { + final event = _textMsg( + id: 'a', + extraTags: [ + ['e', 'target'], + ], + ); + final ref = event.threadReference; + expect(ref.parentId, isNull); + expect(ref.rootId, isNull); + }); + + test('parses direct reply to root (single reply marker)', () { + final event = _textMsg( + id: 'reply1', + extraTags: [ + ['e', 'root1', '', 'reply'], + ], + ); + final ref = event.threadReference; + expect(ref.parentId, 'root1'); + expect(ref.rootId, 'root1'); + }); + + test('parses nested reply (root + reply markers)', () { + final event = _textMsg( + id: 'reply2', + extraTags: [ + ['e', 'root1', '', 'root'], + ['e', 'parent1', '', 'reply'], + ], + ); + final ref = event.threadReference; + expect(ref.parentId, 'parent1'); + expect(ref.rootId, 'root1'); + }); + + test('last reply tag wins when multiple exist', () { + final event = _textMsg( + id: 'reply3', + extraTags: [ + ['e', 'root1', '', 'root'], + ['e', 'old_parent', '', 'reply'], + ['e', 'new_parent', '', 'reply'], + ], + ); + final ref = event.threadReference; + expect(ref.parentId, 'new_parent'); + expect(ref.rootId, 'root1'); + }); + }); + + // ------------------------------------------------------------------------- + // formatTimeline — threading fields + // ------------------------------------------------------------------------- + + group('formatTimeline threading', () { + test('root messages have null parentId and rootId', () { + final events = [_textMsg(id: 'a')]; + final result = formatTimeline(events); + expect(result[0].parentId, isNull); + expect(result[0].rootId, isNull); + }); + + test('reply messages carry parentId and rootId', () { + final events = [ + _textMsg(id: 'root1'), + _replyMsg(id: 'r1', parentId: 'root1', createdAt: 2000), + ]; + final result = formatTimeline(events); + final reply = result.firstWhere((m) => m.id == 'r1'); + expect(reply.parentId, 'root1'); + expect(reply.rootId, 'root1'); + }); + + test('nested reply has distinct parentId and rootId', () { + final events = [ + _textMsg(id: 'root1'), + _replyMsg(id: 'r1', parentId: 'root1', createdAt: 2000), + _replyMsg(id: 'r2', parentId: 'r1', rootId: 'root1', createdAt: 3000), + ]; + final result = formatTimeline(events); + final nested = result.firstWhere((m) => m.id == 'r2'); + expect(nested.parentId, 'r1'); + expect(nested.rootId, 'root1'); + }); + }); + + // ------------------------------------------------------------------------- + // buildMainTimelineEntries + // ------------------------------------------------------------------------- + + group('buildMainTimelineEntries', () { + test('returns only root messages', () { + final messages = formatTimeline([ + _textMsg(id: 'a', createdAt: 1000), + _replyMsg(id: 'r1', parentId: 'a', createdAt: 2000), + _textMsg(id: 'b', createdAt: 3000), + ]); + + final entries = buildMainTimelineEntries(messages); + expect(entries, hasLength(2)); + expect(entries[0].message.id, 'a'); + expect(entries[1].message.id, 'b'); + }); + + test('root without replies has null summary', () { + final messages = formatTimeline([_textMsg(id: 'a')]); + final entries = buildMainTimelineEntries(messages); + expect(entries[0].summary, isNull); + }); + + test('root with replies has summary with correct count', () { + final messages = formatTimeline([ + _textMsg(id: 'a', createdAt: 1000), + _replyMsg(id: 'r1', parentId: 'a', pubkey: 'bob', createdAt: 2000), + _replyMsg(id: 'r2', parentId: 'a', pubkey: 'carol', createdAt: 3000), + ]); + + final entries = buildMainTimelineEntries(messages); + expect(entries, hasLength(1)); + expect(entries[0].summary, isNotNull); + expect(entries[0].summary!.replyCount, 2); + expect(entries[0].summary!.threadHeadId, 'a'); + }); + + test('summary counts only direct children, not nested replies', () { + final messages = formatTimeline([ + _textMsg(id: 'a', createdAt: 1000), + _replyMsg(id: 'r1', parentId: 'a', createdAt: 2000), + _replyMsg(id: 'r2', parentId: 'r1', rootId: 'a', createdAt: 3000), + ]); + + final entries = buildMainTimelineEntries(messages); + // Only r1 is a direct child of a; r2 is a child of r1. + expect(entries[0].summary!.replyCount, 1); + }); + + test('summary has up to 3 unique participant pubkeys', () { + final messages = formatTimeline([ + _textMsg(id: 'a', createdAt: 1000), + _replyMsg(id: 'r1', parentId: 'a', pubkey: 'bob', createdAt: 2000), + _replyMsg(id: 'r2', parentId: 'a', pubkey: 'carol', createdAt: 3000), + _replyMsg(id: 'r3', parentId: 'a', pubkey: 'bob', createdAt: 4000), + _replyMsg(id: 'r4', parentId: 'a', pubkey: 'dave', createdAt: 5000), + _replyMsg(id: 'r5', parentId: 'a', pubkey: 'eve', createdAt: 6000), + ]); + + final entries = buildMainTimelineEntries(messages); + final participants = entries[0].summary!.participantPubkeys; + expect(participants, hasLength(3)); + // Most recent unique: eve, dave, bob (r5, r4, r3 — carol skipped + // because bob at r3 comes before carol at r2 when walking backwards). + // Reversed to chronological order. + expect(participants, ['bob', 'dave', 'eve']); + }); + + test('system messages remain in entries (parentId is null)', () { + final events = [ + _systemMsg( + id: 's1', + payload: {'type': 'channel_created', 'actor': 'pk1'}, + createdAt: 500, + ), + _textMsg(id: 'a', createdAt: 1000), + ]; + final messages = formatTimeline(events); + final entries = buildMainTimelineEntries(messages); + expect(entries, hasLength(2)); + expect(entries[0].message.isSystem, true); + }); + + test('empty input returns empty', () { + expect(buildMainTimelineEntries([]), isEmpty); + }); + }); }