Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 4 additions & 5 deletions mobile/lib/features/channels/thread_detail_page.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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 = <String, String>{};
Expand Down
98 changes: 98 additions & 0 deletions mobile/test/features/channels/channel_detail_page_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,7 @@ Widget _buildTestable({
ChannelActions Function(Ref ref)? createChannelActions,
ReadStateNotifier? readStateNotifier,
_FakeMessagesNotifier? messagesNotifier,
_NestedThreadRelaySession? relaySession,
String? canvasContent,
List<NostrEvent>? threadReplies,
}) {
Expand Down Expand Up @@ -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'),
Expand Down Expand Up @@ -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<void>(
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);
},
);
});
}

Expand Down Expand Up @@ -1633,6 +1699,38 @@ class _ErrorMessagesNotifier extends ChannelMessagesNotifier {
AsyncError('Connection failed', StackTrace.current);
}

class _NestedThreadRelaySession extends RelaySessionNotifier {
final String expectedRootId;
final List<NostrEvent> replies;
final List<NostrFilter> queryFilters = [];

_NestedThreadRelaySession({
required this.expectedRootId,
required this.replies,
});

@override
SessionState build() => const SessionState(status: SessionStatus.connected);

@override
Future<List<NostrEvent>> queryRelay(
List<NostrFilter> filters, {
Duration timeout = const Duration(seconds: 8),
}) async {
queryFilters.addAll(filters);
if (filters.single.tags['#e']?.single == expectedRootId) {
return replies;
}
return const [];
}

@override
Future<List<NostrEvent>> fetchHistory(
NostrFilter filter, {
Duration timeout = const Duration(seconds: 8),
}) async => const [];
}

class _FakeTypingNotifier extends ChannelTypingNotifier {
final List<TypingEntry> _entries;
_FakeTypingNotifier(this._entries) : super(_channelId);
Expand Down