diff --git a/mobile/lib/features/channels/thread_detail_page.dart b/mobile/lib/features/channels/thread_detail_page.dart index 94e95b8fe5..a07a795bd8 100644 --- a/mobile/lib/features/channels/thread_detail_page.dart +++ b/mobile/lib/features/channels/thread_detail_page.dart @@ -53,9 +53,12 @@ class ThreadDetailPage extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { + // The relay's thread query is root-scoped. Nested thread heads retain the + // original root ID, while top-level thread heads use their own ID. + final effectiveRootId = threadHead.rootId ?? threadHead.id; final repliesState = ref.watch( threadRepliesProvider( - ThreadRepliesArgs(channelId: channelId, rootId: threadHead.id), + ThreadRepliesArgs(channelId: channelId, rootId: effectiveRootId), ), ); final replyMessages = repliesState.whenData((events) { @@ -132,10 +135,6 @@ class ThreadDetailPage extends HookConsumerWidget { 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 = {}; diff --git a/mobile/test/features/channels/channel_detail_page_test.dart b/mobile/test/features/channels/channel_detail_page_test.dart index 01fae2b799..af991b0c15 100644 --- a/mobile/test/features/channels/channel_detail_page_test.dart +++ b/mobile/test/features/channels/channel_detail_page_test.dart @@ -142,6 +142,7 @@ Widget _buildTestable({ ChannelActions Function(Ref ref)? createChannelActions, ReadStateNotifier? readStateNotifier, _FakeMessagesNotifier? messagesNotifier, + _NestedThreadRelaySession? relaySession, String? canvasContent, List? threadReplies, }) { @@ -182,6 +183,8 @@ Widget _buildTestable({ threadRepliesProvider( const ThreadRepliesArgs(channelId: _channelId, rootId: 'thread-root'), ).overrideWith((ref) async => threadReplies), + if (relaySession != null) + relaySessionProvider.overrideWith(() => relaySession), // Stub the relay client provider so preloadMembers doesn't crash. relayClientProvider.overrideWithValue( RelayClient(baseUrl: 'http://localhost:3000'), @@ -1591,6 +1594,69 @@ void main() { expect(find.text(formatDayHeading(rootCreatedAt)), findsOneWidget); expect(find.text(formatDayHeading(nextDayCreatedAt)), findsOneWidget); }); + + testWidgets( + 'nested thread queries the true root and renders its direct reply', + (tester) async { + final root = _textMsg( + id: 'root', + pubkey: 'alice', + content: 'Root message', + createdAt: 1000, + ); + final nestedHead = _textMsg( + id: 'nested-head', + pubkey: 'bob', + content: 'Nested head', + createdAt: 1100, + extraTags: const [ + ['e', 'root', '', 'root'], + ['e', 'root', '', 'reply'], + ], + ); + final nestedReply = _textMsg( + id: 'nested-reply', + pubkey: 'carol', + content: 'Nested child', + createdAt: 1200, + extraTags: const [ + ['e', 'root', '', 'root'], + ['e', 'nested-head', '', 'reply'], + ], + ); + final relaySession = _NestedThreadRelaySession( + expectedRootId: root.id, + replies: [nestedHead, nestedReply], + ); + + await tester.pumpWidget( + _buildTestable( + messages: [root, nestedHead], + relaySession: relaySession, + ), + ); + await tester.pumpAndSettle(); + + final threadMessages = formatTimeline([root, nestedHead]); + Navigator.of(tester.element(find.byType(ChannelDetailPage))).push( + MaterialPageRoute( + builder: (_) => ThreadDetailPage( + threadHead: threadMessages.last, + allMessages: threadMessages, + channelId: _channelId, + currentPubkey: 'self', + isMember: true, + isArchived: false, + ), + ), + ); + await tester.pumpAndSettle(); + + expect(relaySession.queryFilters, hasLength(1)); + expect(relaySession.queryFilters.single.tags['#e'], [root.id]); + expect(findRichText('Nested child'), findsOneWidget); + }, + ); }); } @@ -1633,6 +1699,38 @@ class _ErrorMessagesNotifier extends ChannelMessagesNotifier { AsyncError('Connection failed', StackTrace.current); } +class _NestedThreadRelaySession extends RelaySessionNotifier { + final String expectedRootId; + final List replies; + final List queryFilters = []; + + _NestedThreadRelaySession({ + required this.expectedRootId, + required this.replies, + }); + + @override + SessionState build() => const SessionState(status: SessionStatus.connected); + + @override + Future> queryRelay( + List filters, { + Duration timeout = const Duration(seconds: 8), + }) async { + queryFilters.addAll(filters); + if (filters.single.tags['#e']?.single == expectedRootId) { + return replies; + } + return const []; + } + + @override + Future> fetchHistory( + NostrFilter filter, { + Duration timeout = const Duration(seconds: 8), + }) async => const []; +} + class _FakeTypingNotifier extends ChannelTypingNotifier { final List _entries; _FakeTypingNotifier(this._entries) : super(_channelId);