diff --git a/.github/workflows/_ci-clients.yml b/.github/workflows/_ci-clients.yml index 310ab676b7..7b8969c336 100644 --- a/.github/workflows/_ci-clients.yml +++ b/.github/workflows/_ci-clients.yml @@ -122,7 +122,7 @@ jobs: mobile-swift: name: Mobile Swift runs-on: macos-latest - timeout-minutes: 30 + timeout-minutes: 45 if: inputs.lane == 'mobile-swift' && inputs.mobile steps: - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 @@ -139,7 +139,7 @@ jobs: run: /bin/bash scripts/test-mobile-pagination.sh - name: Build complete iOS simulator app run: cd mobile && flutter build ios --simulator --debug --no-pub - - name: Test native pagination accessibility + - name: Test native controls and push navigation bridge run: | device=$(xcrun simctl create "Pagination accessibility" "iPhone 16") trap 'xcrun simctl shutdown "$device"; xcrun simctl delete "$device"' EXIT @@ -147,6 +147,11 @@ jobs: xcrun simctl bootstatus "$device" -b /bin/bash scripts/test-mobile-pagination-accessibility.sh "$device" /bin/bash scripts/test-mobile-member-picker.sh "$device" + xcodebuild -workspace mobile/ios/Runner.xcworkspace -scheme Runner \ + -configuration Debug -destination "platform=iOS Simulator,id=$device" \ + -parallel-testing-enabled NO \ + -only-testing:RunnerTests/BuzzPushNavigationBridgeTests \ + CODE_SIGNING_ALLOWED=NO test - name: Build complete unsigned iOS release run: | /bin/bash scripts/test-mobile-gateway-recipes.sh diff --git a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushNavigationTarget.swift b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushNavigationTarget.swift index c6cb813648..4a4df7b102 100644 --- a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushNavigationTarget.swift +++ b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushNavigationTarget.swift @@ -45,7 +45,7 @@ public struct BuzzPushNavigationTarget: Codable, Equatable, Sendable { return bytes.count == 64 && bytes.allSatisfy { (48...57).contains($0) || (65...70).contains($0) || (97...102).contains($0) } - })?[1] + })?[1].lowercased() } /// Decodes a target without trusting other fields from the APNs payload. diff --git a/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzPushNavigationTargetTests.swift b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzPushNavigationTargetTests.swift index c2bba98b7d..db65c9997c 100644 --- a/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzPushNavigationTargetTests.swift +++ b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzPushNavigationTargetTests.swift @@ -134,6 +134,6 @@ import Testing #expect(BuzzPushNavigationTarget.threadRootID(in: [invalidTag]) == nil) #expect(BuzzPushNavigationTarget.threadRootID(in: [ ["e", first, "", "reply"], ["e", last, "", "reply"], invalidTag, - ]) == last) + ]) == last.lowercased()) } } diff --git a/mobile/ios/Runner/AppDelegate.swift b/mobile/ios/Runner/AppDelegate.swift index ce7dc6e543..36405a405e 100644 --- a/mobile/ios/Runner/AppDelegate.swift +++ b/mobile/ios/Runner/AppDelegate.swift @@ -1073,7 +1073,7 @@ import os.log } extension BuzzPushNavigationTarget { - fileprivate var flutterArguments: [String: String] { + var flutterArguments: [String: String] { var arguments = [ "eventId": eventID, "communityId": communityID, diff --git a/mobile/ios/RunnerTests/BuzzCommunicationNotificationTests.swift b/mobile/ios/RunnerTests/BuzzCommunicationNotificationTests.swift index 940088933c..28e5a34bbe 100644 --- a/mobile/ios/RunnerTests/BuzzCommunicationNotificationTests.swift +++ b/mobile/ios/RunnerTests/BuzzCommunicationNotificationTests.swift @@ -524,3 +524,27 @@ final class BuzzPushNotificationResponseTests: XCTestCase { private final class MissingAppGroupDelegate: AppDelegate { override var appGroupIdentifier: String? { nil } } + +final class BuzzPushNavigationBridgeTests: XCTestCase { + func testWarmAndColdNavigationCarryTheSameThreadArguments() throws { + let target = BuzzPushNavigationTarget( + eventID: String(repeating: "a", count: 64), communityID: "community", + channelID: "channel", threadRootID: String(repeating: "b", count: 64) + ) + let expected = ["eventId": target.eventID, "communityId": "community", + "channelId": "channel", "threadRootId": String(repeating: "b", count: 64)] + XCTAssertEqual(target.flutterArguments, expected) + let buffer = BuzzPushNavigationBuffer() + buffer.record(target) + XCTAssertEqual(try XCTUnwrap(buffer.take()).flutterArguments, expected) + XCTAssertNil(buffer.take()) + } + + func testChannelNavigationOmitsOptionalThreadArgument() { + let target = BuzzPushNavigationTarget( + eventID: "event", communityID: "community", channelID: "channel" + ) + XCTAssertEqual(target.flutterArguments, + ["eventId": "event", "communityId": "community", "channelId": "channel"]) + } +} diff --git a/mobile/lib/features/activity/feed_item.dart b/mobile/lib/features/activity/feed_item.dart index 84aa1eba49..a3c6e10758 100644 --- a/mobile/lib/features/activity/feed_item.dart +++ b/mobile/lib/features/activity/feed_item.dart @@ -1,5 +1,7 @@ import 'package:flutter/foundation.dart'; +import '../../shared/relay/thread_reference.dart'; + @immutable class FeedItem { final String id; @@ -43,14 +45,7 @@ class FeedItem { /// The message whose thread directly contains this item, when this event is /// a reply. For nested replies this is the direct parent, not the outer root. - String? get threadHeadId { - for (final tag in tags) { - if (tag.length >= 4 && tag[0] == 'e' && tag[3] == 'reply') { - return tag[1]; - } - } - return null; - } + String? get threadHeadId => parseThreadReference(tags).parentId; /// Human-readable headline based on event kind and category. String get headline { diff --git a/mobile/lib/features/activity/inbox_item.dart b/mobile/lib/features/activity/inbox_item.dart index 4910c73d08..e118958f0a 100644 --- a/mobile/lib/features/activity/inbox_item.dart +++ b/mobile/lib/features/activity/inbox_item.dart @@ -1,5 +1,6 @@ import 'package:flutter/foundation.dart'; +import '../../shared/relay/thread_reference.dart'; import 'feed_item.dart'; /// Inbox filters, mirroring desktop's `InboxFilter` in @@ -26,22 +27,10 @@ int categoryPriority(String category) { }; } -/// Thread reference from NIP-10 tags — desktop's `getThreadReference`. +/// Validated, lowercase thread IDs shared with timeline navigation. ({String? parentId, String? rootId}) threadReferenceOf( List> tags, -) { - List? rootTag; - List? replyTag; - for (final tag in tags) { - if (tag.length >= 4 && tag[0] == 'e') { - if (tag[3] == 'root') rootTag = tag; - if (tag[3] == 'reply') replyTag = tag; - } - } - if (replyTag == null) return (parentId: null, rootId: null); - final parentId = replyTag[1]; - return (parentId: parentId, rootId: rootTag?[1] ?? parentId); -} +) => parseThreadReference(tags); /// Broadcast replies surface at the channel top level — desktop's /// `isBroadcastReply`. diff --git a/mobile/lib/shared/relay/nostr_models.dart b/mobile/lib/shared/relay/nostr_models.dart index c041e4399f..6f8922f15b 100644 --- a/mobile/lib/shared/relay/nostr_models.dart +++ b/mobile/lib/shared/relay/nostr_models.dart @@ -2,6 +2,8 @@ import 'dart:convert'; import 'package:flutter/foundation.dart'; +import 'thread_reference.dart'; + /// Nostr event kind constants. /// /// Keep in sync with `desktop/src/shared/constants/kinds.ts`. @@ -156,36 +158,9 @@ 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); - } + /// Validated, lowercase thread IDs shared by timeline and Inbox navigation. + ({String? parentId, String? rootId}) get threadReference => + parseThreadReference(tags); /// The parent event ID from the `e` tag. String? get parentEventId => threadReference.parentId; diff --git a/mobile/lib/shared/relay/thread_reference.dart b/mobile/lib/shared/relay/thread_reference.dart new file mode 100644 index 0000000000..b3ef371dce --- /dev/null +++ b/mobile/lib/shared/relay/thread_reference.dart @@ -0,0 +1,22 @@ +/// Resolve NIP-10 markers using relay ingest validity and ordering rules. +/// +/// The last valid 64-character ASCII-hex marker wins. IDs are lowercased for +/// client lookup. Root-only events remain top-level; a reply without a valid +/// root uses its parent as root. +({String? parentId, String? rootId}) parseThreadReference( + List> tags, +) { + String? parentId; + String? rootId; + for (final tag in tags) { + if (tag.length < 4 || tag[0] != 'e') continue; + final id = tag[1]; + if (id.length != 64 || !_threadEventId.hasMatch(id)) continue; + if (tag[3] == 'reply') parentId = id.toLowerCase(); + if (tag[3] == 'root') rootId = id.toLowerCase(); + } + if (parentId == null) return (parentId: null, rootId: null); + return (parentId: parentId, rootId: rootId ?? parentId); +} + +final _threadEventId = RegExp(r'^[0-9a-fA-F]{64}$'); diff --git a/mobile/test/features/activity/activity_page_test.dart b/mobile/test/features/activity/activity_page_test.dart index 7995192167..30eacbddce 100644 --- a/mobile/test/features/activity/activity_page_test.dart +++ b/mobile/test/features/activity/activity_page_test.dart @@ -29,6 +29,8 @@ import 'package:hooks_riverpod/misc.dart'; import 'package:buzz/shared/theme/buzz_icons.dart'; import 'package:shared_preferences/shared_preferences.dart'; +import '../../helpers/thread_ids.dart'; + void main() { final now = DateTime.now().millisecondsSinceEpoch ~/ 1000; @@ -53,8 +55,8 @@ void main() { channelId: 'ch2', channelName: 'engineering', tags: const [ - ['e', 'root1', '', 'root'], - ['e', 'root1', '', 'reply'], + ['e', threadIdRoot1, '', 'root'], + ['e', threadIdRoot1, '', 'reply'], ], category: 'activity', ); @@ -711,7 +713,7 @@ void main() { await tester.pumpWidget( await buildTestable( // Mention (2m ago) and agent (1m ago) unread; thread (1h ago) read. - readContexts: {'thread:root1': now, 'ch2': now}, + readContexts: {'thread:$threadIdRoot1': now, 'ch2': now}, ), ); await tester.pumpAndSettle(); @@ -773,8 +775,8 @@ void main() { channelId: 'ch1', channelName: 'general', tags: const [ - ['e', 'thread-root', '', 'root'], - ['e', 'parent-reply', '', 'reply'], + ['e', threadIdThreadDashRoot, '', 'root'], + ['e', threadIdParentDashReply, '', 'reply'], ], category: 'mention', ); @@ -795,7 +797,7 @@ void main() { find.byType(ChannelDetailPage), ); expect(page.channel.id, 'ch1'); - expect(page.initialThreadRootId, 'parent-reply'); + expect(page.initialThreadRootId, threadIdParentDashReply); expect(page.initialMessageId, 'reply-event'); expect( page.initialThreadRouteBehavior, @@ -860,7 +862,7 @@ void main() { testWidgets('long-press mark unread reopens a read row', (tester) async { await tester.pumpWidget( await buildTestable( - readContexts: {'ch1': now, 'ch2': now, 'thread:root1': now}, + readContexts: {'ch1': now, 'ch2': now, 'thread:$threadIdRoot1': now}, ), ); await tester.pumpAndSettle(); @@ -885,7 +887,7 @@ void main() { ) async { await tester.pumpWidget( await buildTestable( - readContexts: {'ch1': now, 'ch2': now, 'thread:root1': now}, + readContexts: {'ch1': now, 'ch2': now, 'thread:$threadIdRoot1': now}, ), ); await tester.pumpAndSettle(); @@ -948,7 +950,7 @@ void main() { await tester.pumpWidget( await buildTestable( - readContexts: {'ch1': now, 'ch2': now, 'thread:root1': now}, + readContexts: {'ch1': now, 'ch2': now, 'thread:$threadIdRoot1': now}, ), ); await tester.pumpAndSettle(); @@ -1004,7 +1006,7 @@ void main() { ) async { await tester.pumpWidget( await buildTestable( - readContexts: {'ch1': now, 'ch2': now, 'thread:root1': now}, + readContexts: {'ch1': now, 'ch2': now, 'thread:$threadIdRoot1': now}, ), ); await tester.pumpAndSettle(); diff --git a/mobile/test/features/activity/feed_item_test.dart b/mobile/test/features/activity/feed_item_test.dart index f655bf316a..e9fbd63d7d 100644 --- a/mobile/test/features/activity/feed_item_test.dart +++ b/mobile/test/features/activity/feed_item_test.dart @@ -1,6 +1,8 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:buzz/features/activity/feed_item.dart'; +import '../../helpers/thread_ids.dart'; + void main() { group('FeedItem.fromJson', () { test('parses all fields', () { @@ -66,19 +68,30 @@ void main() { test('uses the reply marker for a direct thread reply', () { expect( makeItem(const [ - ['e', 'root', '', 'reply'], + ['e', threadIdRoot, '', 'reply'], ]).threadHeadId, - 'root', + threadIdRoot, ); }); test('uses the direct parent marker for a nested thread reply', () { expect( makeItem(const [ - ['e', 'root', '', 'root'], - ['e', 'parent', '', 'reply'], + ['e', threadIdRoot, '', 'root'], + ['e', threadIdParent, '', 'reply'], ]).threadHeadId, - 'parent', + threadIdParent, + ); + }); + + test('uses the last valid parent and normalizes its case', () { + expect( + makeItem([ + ['e', threadIdRoot, '', 'reply'], + ['e', threadIdParent.toUpperCase(), '', 'reply'], + ['e', 'bad', '', 'reply'], + ]).threadHeadId, + threadIdParent, ); }); diff --git a/mobile/test/features/activity/inbox_item_test.dart b/mobile/test/features/activity/inbox_item_test.dart index ad0f75d170..5c2341a2a8 100644 --- a/mobile/test/features/activity/inbox_item_test.dart +++ b/mobile/test/features/activity/inbox_item_test.dart @@ -2,6 +2,8 @@ import 'package:buzz/features/activity/feed_item.dart'; import 'package:buzz/features/activity/inbox_item.dart'; import 'package:flutter_test/flutter_test.dart'; +import '../../helpers/thread_ids.dart'; + FeedItem item({ required String id, int createdAt = 100, @@ -29,20 +31,55 @@ List> replyTags(String rootId, String parentId) => [ ]; void main() { + test( + 'Inbox groups mixed-case replies despite trailing malformed markers', + () { + final root = 'a' * 64; + final parent = 'b' * 64; + final tags = [ + ['e', root.toUpperCase(), '', 'root'], + ['e', parent.toUpperCase(), '', 'reply'], + ['e', 'bad', '', 'reply'], + ['e', '', '', 'root'], + ]; + final target = item(id: 'target', createdAt: 20, tags: tags); + final rows = buildInboxItems([ + item(id: 'earlier', createdAt: 10, tags: replyTags(root, parent)), + target, + ]); + expect(rows, hasLength(1)); + expect(rows.single.conversationId, root); + expect(rows.single.threadRootId, root); + expect(rows.single.deepLinkTarget((e) => e.createdAt <= 10).id, 'target'); + expect(threadReferenceOf(target.tags).parentId, parent); + expect(isThreadReply(tags), isTrue); + expect( + isThreadReply([ + ...tags, + ['broadcast', '1'], + ]), + isFalse, + ); + }, + ); + group('inboxConversationId', () { test('uses root id when present', () { expect( - inboxConversationId(replyTags('root1', 'parent1'), 'ev1'), - 'root1', + inboxConversationId( + replyTags(threadIdInboxRoot1, threadIdParent1), + 'ev1', + ), + threadIdInboxRoot1, ); }); test('falls back to parent id without a root tag', () { expect( inboxConversationId([ - ['e', 'parent1', '', 'reply'], + ['e', threadIdParent1, '', 'reply'], ], 'ev1'), - 'parent1', + threadIdParent1, ); }); @@ -53,13 +90,13 @@ void main() { group('isThreadReply', () { test('true for reply-tagged events', () { - expect(isThreadReply(replyTags('r', 'p')), isTrue); + expect(isThreadReply(replyTags(threadIdInboxR, threadIdInboxP)), isTrue); }); test('false for broadcast replies', () { expect( isThreadReply([ - ...replyTags('r', 'p'), + ...replyTags(threadIdInboxR, threadIdInboxP), ['broadcast', '1'], ]), isFalse, @@ -74,17 +111,27 @@ void main() { group('buildInboxItems', () { test('groups thread events into one conversation row', () { final rows = buildInboxItems([ - item(id: 'a', createdAt: 10, tags: replyTags('root1', 'root1')), - item(id: 'b', createdAt: 20, tags: replyTags('root1', 'a')), + item( + id: threadIdInboxA, + createdAt: 10, + tags: replyTags(threadIdInboxRoot1, threadIdInboxRoot1), + ), + item( + id: threadIdInboxB, + createdAt: 20, + tags: replyTags(threadIdInboxRoot1, threadIdInboxA), + ), item(id: 'c', createdAt: 15), ]); expect(rows, hasLength(2)); - final threadRow = rows.firstWhere((r) => r.conversationId == 'root1'); - expect(threadRow.id, 'b'); // latest event represents the row + final threadRow = rows.firstWhere( + (r) => r.conversationId == threadIdInboxRoot1, + ); + expect(threadRow.id, threadIdInboxB); // latest event represents the row expect(threadRow.groupItems, hasLength(2)); expect(threadRow.latestActivityAt, 20); - expect(threadRow.threadRootId, 'root1'); + expect(threadRow.threadRootId, threadIdInboxRoot1); }); test('sorts rows by latest activity, newest first', () { @@ -117,32 +164,35 @@ void main() { test('thread replies inside a DM still group by thread root', () { bool isDm(String channelId) => channelId == 'dm1'; final rows = buildInboxItems([ - item(id: 'top', createdAt: 10, channelId: 'dm1'), + item(id: threadIdInboxTop, createdAt: 10, channelId: 'dm1'), item( id: 'reply', createdAt: 20, channelId: 'dm1', - tags: replyTags('top', 'top'), + tags: replyTags(threadIdInboxTop, threadIdInboxTop), ), ], isDmChannel: isDm); expect(rows, hasLength(2)); - expect(rows.map((r) => r.conversationId).toSet(), {'dm:dm1', 'top'}); + expect(rows.map((r) => r.conversationId).toSet(), { + 'dm:dm1', + threadIdInboxTop, + }); }); test('categories are priority sorted and set isActionRequired', () { final rows = buildInboxItems([ item( - id: 'a', + id: threadIdInboxA, createdAt: 10, category: 'activity', - tags: replyTags('root1', 'root1'), + tags: replyTags(threadIdInboxRoot1, threadIdInboxRoot1), ), item( - id: 'b', + id: threadIdInboxB, createdAt: 20, category: 'needs_action', - tags: replyTags('root1', 'a'), + tags: replyTags(threadIdInboxRoot1, threadIdInboxA), ), ]); expect(rows.single.categories.first, 'needs_action'); @@ -152,19 +202,34 @@ void main() { group('InboxItem.deepLinkTarget', () { final rows = buildInboxItems([ - item(id: 'a', createdAt: 10, tags: replyTags('root1', 'root1')), - item(id: 'b', createdAt: 20, tags: replyTags('root1', 'a')), - item(id: 'c', createdAt: 30, tags: replyTags('root1', 'b')), + item( + id: threadIdInboxA, + createdAt: 10, + tags: replyTags(threadIdInboxRoot1, threadIdInboxRoot1), + ), + item( + id: threadIdInboxB, + createdAt: 20, + tags: replyTags(threadIdInboxRoot1, threadIdInboxA), + ), + item( + id: 'c', + createdAt: 30, + tags: replyTags(threadIdInboxRoot1, threadIdInboxB), + ), ]); bool readUpTo(int time, FeedItem event) => event.createdAt <= time; test('targets the oldest unread event', () { - expect(rows.single.deepLinkTarget((e) => readUpTo(15, e)).id, 'b'); + expect( + rows.single.deepLinkTarget((e) => readUpTo(15, e)).id, + threadIdInboxB, + ); }); test('targets the oldest event when nothing is read', () { - expect(rows.single.deepLinkTarget((_) => false).id, 'a'); + expect(rows.single.deepLinkTarget((_) => false).id, threadIdInboxA); }); test('falls back to the latest event when all are read', () { @@ -173,7 +238,10 @@ void main() { test('checks each event, not a single time', () { // Only the middle reply is unread, though the newest is read. - expect(rows.single.deepLinkTarget((e) => e.id != 'b').id, 'b'); + expect( + rows.single.deepLinkTarget((e) => e.id != threadIdInboxB).id, + threadIdInboxB, + ); }); }); @@ -181,17 +249,25 @@ void main() { InboxItem rowOf(FeedItem feedItem) => buildInboxItems([feedItem]).single; test('all matches everything', () { - expect(matchesInboxFilter(rowOf(item(id: 'a')), InboxFilter.all), isTrue); + expect( + matchesInboxFilter(rowOf(item(id: threadIdInboxA)), InboxFilter.all), + isTrue, + ); }); test('thread filter needs a thread reply in the group', () { expect( - matchesInboxFilter(rowOf(item(id: 'a')), InboxFilter.thread), + matchesInboxFilter(rowOf(item(id: threadIdInboxA)), InboxFilter.thread), isFalse, ); expect( matchesInboxFilter( - rowOf(item(id: 'a', tags: replyTags('r', 'p'))), + rowOf( + item( + id: threadIdInboxA, + tags: replyTags(threadIdInboxR, threadIdInboxP), + ), + ), InboxFilter.thread, ), isTrue, @@ -199,13 +275,13 @@ void main() { }); test('category filters match their category', () { - final mention = rowOf(item(id: 'a', category: 'mention')); + final mention = rowOf(item(id: threadIdInboxA, category: 'mention')); expect(matchesInboxFilter(mention, InboxFilter.mention), isTrue); expect(matchesInboxFilter(mention, InboxFilter.needsAction), isFalse); expect(matchesInboxFilter(mention, InboxFilter.activity), isFalse); expect( matchesInboxFilter( - rowOf(item(id: 'b', category: 'agent_activity')), + rowOf(item(id: threadIdInboxB, category: 'agent_activity')), InboxFilter.agentActivity, ), isTrue, @@ -213,7 +289,7 @@ void main() { }); test('reminders and drafts are separate surfaces', () { - final row = rowOf(item(id: 'a')); + final row = rowOf(item(id: threadIdInboxA)); expect(matchesInboxFilter(row, InboxFilter.reminders), isFalse); expect(matchesInboxFilter(row, InboxFilter.drafts), isFalse); }); @@ -224,7 +300,7 @@ void main() { test('DM rows lead with the sender', () { final label = inboxTypeLabel( - rowOf(item(id: 'a', category: 'activity')), + rowOf(item(id: threadIdInboxA, category: 'activity')), channelName: null, isDm: true, senderLabel: 'Alice', @@ -235,7 +311,7 @@ void main() { test('mention rows say Mentioned in #channel', () { final label = inboxTypeLabel( - rowOf(item(id: 'a', category: 'mention')), + rowOf(item(id: threadIdInboxA, category: 'mention')), channelName: 'general', isDm: false, senderLabel: 'Alice', @@ -247,7 +323,11 @@ void main() { test('needs action wins over thread context', () { final label = inboxTypeLabel( rowOf( - item(id: 'a', category: 'needs_action', tags: replyTags('r', 'p')), + item( + id: threadIdInboxA, + category: 'needs_action', + tags: replyTags(threadIdInboxR, threadIdInboxP), + ), ), channelName: 'general', isDm: false, @@ -258,7 +338,13 @@ void main() { test('plain thread replies say Thread in', () { final label = inboxTypeLabel( - rowOf(item(id: 'a', category: 'activity', tags: replyTags('r', 'p'))), + rowOf( + item( + id: threadIdInboxA, + category: 'activity', + tags: replyTags(threadIdInboxR, threadIdInboxP), + ), + ), channelName: 'general', isDm: false, senderLabel: 'Alice', diff --git a/mobile/test/features/activity/inbox_read_state_test.dart b/mobile/test/features/activity/inbox_read_state_test.dart index 9885dd992e..431928ee2e 100644 --- a/mobile/test/features/activity/inbox_read_state_test.dart +++ b/mobile/test/features/activity/inbox_read_state_test.dart @@ -3,6 +3,8 @@ import 'package:buzz/features/activity/inbox_item.dart'; import 'package:buzz/features/activity/inbox_read_state.dart'; import 'package:flutter_test/flutter_test.dart'; +import '../../helpers/thread_ids.dart'; + FeedItem item({ required String id, int createdAt = 100, @@ -32,27 +34,40 @@ int? Function(String) markers(Map map) => void main() { group('inboxEventReadAt', () { test('a top-level event uses the channel and its own mark', () { - final event = item(id: 'a', createdAt: 60); + final event = item(id: threadIdInboxA, createdAt: 60); expect(inboxEventReadAt(event, markerOf: markers({'ch1': 42})), 42); expect( - inboxEventReadAt(event, markerOf: markers({'ch1': 42, 'msg:a': 60})), + inboxEventReadAt( + event, + markerOf: markers({'ch1': 42, 'msg:$threadIdInboxA': 60}), + ), 60, ); }); test('a reply also uses its thread marks', () { - final event = item(id: 'a', tags: replyTags('root1', 'root1')); + final event = item( + id: threadIdInboxA, + tags: replyTags(threadIdInboxRoot1, threadIdInboxRoot1), + ); expect( inboxEventReadAt( event, - markerOf: markers({'thread:root1': 10, 'thread-activity:root1': 30}), + markerOf: markers({ + 'thread:$threadIdInboxRoot1': 10, + 'thread-activity:$threadIdInboxRoot1': 30, + }), ), 30, ); }); test('channel catch-up never reads an Activity event', () { - final event = item(id: 'a', createdAt: 60, category: 'activity'); + final event = item( + id: threadIdInboxA, + createdAt: 60, + category: 'activity', + ); expect( inboxEventReadAt(event, markerOf: markers({'activity:ch1': 99})), isNull, @@ -60,14 +75,16 @@ void main() { }); test('an event without a channel has no marker', () { - final event = item(id: 'a', channelId: null); + final event = item(id: threadIdInboxA, channelId: null); expect(inboxEventReadAt(event, markerOf: markers({})), isNull); }); }); group('isInboxItemDone', () { test('done when no grouped activity is newer than the marker', () { - final row = buildInboxItems([item(id: 'a', createdAt: 50)]).single; + final row = buildInboxItems([ + item(id: threadIdInboxA, createdAt: 50), + ]).single; expect( isInboxItemDone( row, @@ -104,8 +121,16 @@ void main() { test('a thread mention needs every grouped reply read', () { final row = buildInboxItems([ - item(id: 'r1', createdAt: 40, tags: replyTags('root1', 'root1')), - item(id: 'r2', createdAt: 50, tags: replyTags('root1', 'r1')), + item( + id: threadIdR1, + createdAt: 40, + tags: replyTags(threadIdInboxRoot1, threadIdInboxRoot1), + ), + item( + id: 'r2', + createdAt: 50, + tags: replyTags(threadIdInboxRoot1, threadIdR1), + ), ]).single; bool done(Map marks) => isInboxItemDone( row, @@ -115,17 +140,19 @@ void main() { ); // Seeing only the newest reply leaves the older mention unread. expect(done({'msg:r2': 50}), isFalse); - expect(done({'msg:r1': 40, 'msg:r2': 50}), isTrue); - expect(done({'thread-activity:root1': 50}), isTrue); + expect(done({'msg:$threadIdR1': 40, 'msg:r2': 50}), isTrue); + expect(done({'thread-activity:$threadIdInboxRoot1': 50}), isTrue); }); test('a local unread override always wins', () { - final row = buildInboxItems([item(id: 'a', createdAt: 50)]).single; + final row = buildInboxItems([ + item(id: threadIdInboxA, createdAt: 50), + ]).single; expect( isInboxItemDone( row, markerOf: markers({'ch1': 99}), - localUnreadOverrides: const {'a'}, + localUnreadOverrides: const {threadIdInboxA}, localDoneSet: const {}, ), isFalse, @@ -133,7 +160,9 @@ void main() { }); test('channel-less rows fall back to the local done set', () { - final row = buildInboxItems([item(id: 'a', channelId: null)]).single; + final row = buildInboxItems([ + item(id: threadIdInboxA, channelId: null), + ]).single; expect( isInboxItemDone( row, @@ -148,7 +177,7 @@ void main() { row, markerOf: markers({}), localUnreadOverrides: const {}, - localDoneSet: const {'a'}, + localDoneSet: const {threadIdInboxA}, ), isTrue, ); @@ -158,8 +187,16 @@ void main() { group('groupedChannelReadTimestamp', () { test('only counts top-level events in the channel', () { final row = buildInboxItems([ - item(id: 'a', createdAt: 10, tags: replyTags('root1', 'root1')), - item(id: 'b', createdAt: 30, tags: replyTags('root1', 'a')), + item( + id: threadIdInboxA, + createdAt: 10, + tags: replyTags(threadIdInboxRoot1, threadIdInboxRoot1), + ), + item( + id: threadIdInboxB, + createdAt: 30, + tags: replyTags(threadIdInboxRoot1, threadIdInboxA), + ), ]).single; // Every grouped event is a thread reply, so marking the thread read // must not advance the channel marker. @@ -168,8 +205,12 @@ void main() { test('returns the newest top-level timestamp', () { final row = buildInboxItems([ - item(id: 'root1', createdAt: 10), - item(id: 'b', createdAt: 30, tags: replyTags('root1', 'root1')), + item(id: threadIdInboxRoot1, createdAt: 10), + item( + id: threadIdInboxB, + createdAt: 30, + tags: replyTags(threadIdInboxRoot1, threadIdInboxRoot1), + ), ]).single; final result = groupedChannelReadTimestamp(row); expect(result?.channelId, 'ch1'); diff --git a/mobile/test/features/channels/channel_detail_page_test.dart b/mobile/test/features/channels/channel_detail_page_test.dart index 66f1506f9e..da0a715ff8 100644 --- a/mobile/test/features/channels/channel_detail_page_test.dart +++ b/mobile/test/features/channels/channel_detail_page_test.dart @@ -80,6 +80,8 @@ import 'package:buzz/shared/widgets/tabler_star_icon.dart'; import 'package:buzz/shared/widgets/skeleton.dart'; import 'package:shared_preferences/shared_preferences.dart'; +import '../../helpers/thread_ids.dart'; + part 'thread_reply_refresh_cases.dart'; part 'thread_title_capsule_cases.dart'; part 'channel_detail_page_test/loading_review_tests.dart'; @@ -635,7 +637,7 @@ void main() { 'bot-role author avatars stay squircles in channel and thread', (tester) async { final message = _textMsg( - id: 'bot-message', + id: threadIdBotDashMessage, pubkey: 'bot', content: 'Bot message', ); @@ -645,7 +647,7 @@ void main() { content: 'Reply to bot', createdAt: 1100, extraTags: const [ - ['e', 'bot-message', '', 'reply'], + ['e', threadIdBotDashMessage, '', 'reply'], ], ); await tester.pumpWidget( @@ -656,7 +658,7 @@ void main() { }, loadChannelBotPubkeys: () async => const {'bot'}, threadReplies: { - 'bot-message': [reply], + threadIdBotDashMessage: [reply], }, ), ); @@ -674,16 +676,20 @@ void main() { ); expect( avatarIn( - find.byKey(const ValueKey('message-row-bot-message')), + find.byKey(const ValueKey('message-row-$threadIdBotDashMessage')), ).isAgent, isTrue, ); - await tester.tap(find.byKey(const ValueKey('message-row-bot-message'))); + await tester.tap( + find.byKey(const ValueKey('message-row-$threadIdBotDashMessage')), + ); await tester.pumpAndSettle(); expect( avatarIn( - find.byKey(const ValueKey('thread-message-row-bot-message')), + find.byKey( + const ValueKey('thread-message-row-$threadIdBotDashMessage'), + ), ).isAgent, isTrue, ); @@ -2401,7 +2407,7 @@ void main() { ), ); final root = _textMsg( - id: 'root', + id: threadIdRoot, pubkey: 'alice', content: 'Thread root', createdAt: 1000, @@ -2414,7 +2420,7 @@ void main() { content: 'Reply $i', createdAt: 1100 + i, extraTags: const [ - ['e', 'root', '', 'reply'], + ['e', threadIdRoot, '', 'reply'], ], ), ]; @@ -2422,7 +2428,7 @@ void main() { await tester.pumpWidget( _buildTestable( messages: [root], - threadReplies: {'root': replies}, + threadReplies: {threadIdRoot: replies}, readStateNotifier: readState, ), ); @@ -2450,7 +2456,7 @@ void main() { // mark too, as in `buzz-app`: the thread mark reads a reply only while // its root is loaded. The covered channel page does not read meanwhile. expect(readState.markedContexts, { - 'thread-activity:root': 1102, + 'thread-activity:$threadIdRoot': 1102, 'msg:reply0': 1100, 'msg:reply1': 1101, 'msg:reply2': 1102, @@ -2471,7 +2477,7 @@ void main() { ); // A mention: no catch-up mark reads it, so only its own mark does. final root = _textMsg( - id: 'root', + id: threadIdRoot, pubkey: 'alice', content: 'Thread root for @self', createdAt: 1000, @@ -2485,7 +2491,7 @@ void main() { content: 'Reply 0', createdAt: 1100, extraTags: const [ - ['e', 'root', '', 'reply'], + ['e', threadIdRoot, '', 'reply'], ], ); @@ -2493,9 +2499,9 @@ void main() { _buildTestable( messages: [root, reply], threadReplies: { - 'root': [reply], + threadIdRoot: [reply], }, - initialThreadRootId: 'root', + initialThreadRootId: threadIdRoot, readStateNotifier: readState, ), ); @@ -2503,8 +2509,8 @@ void main() { await tester.pump(const Duration(milliseconds: 300)); expect(find.byType(ThreadDetailPage), findsOneWidget); - expect(readState.markedContexts['msg:root'], 1000); - expect(readState.markedContexts['thread-activity:root'], 1100); + expect(readState.markedContexts['msg:$threadIdRoot'], 1000); + expect(readState.markedContexts['thread-activity:$threadIdRoot'], 1100); }); testWidgets('a thread with no replies reads its head', (tester) async { @@ -2520,7 +2526,7 @@ void main() { ), ); final root = _textMsg( - id: 'root', + id: threadIdRoot, pubkey: 'alice', content: 'Thread root for @self', createdAt: 1000, @@ -2532,7 +2538,7 @@ void main() { await tester.pumpWidget( _buildTestable( messages: [root], - threadReplies: const {'root': []}, + threadReplies: const {threadIdRoot: []}, readStateNotifier: readState, home: ThreadDetailPage( threadHead: formatTimeline([root]).single, @@ -2547,7 +2553,7 @@ void main() { await tester.pumpAndSettle(); await tester.pump(const Duration(milliseconds: 300)); - expect(readState.markedContexts, {'msg:root': 1000}); + expect(readState.markedContexts, {'msg:$threadIdRoot': 1000}); }); testWidgets('rows under the Android keyboard are not read in history', ( @@ -2615,12 +2621,13 @@ void main() { context: messageListElement, direction: ScrollDirection.reverse, ).dispatch(messageListElement); + // Settling can already complete the read dwell; record the whole jump. + readState.markedContexts.clear(); tester .widget(messageList) .itemScrollController! .jumpTo(index: 20); await tester.pumpAndSettle(); - readState.markedContexts.clear(); await tester.pump(const Duration(milliseconds: 300)); final textField = tester.widget(find.byType(TextField)); @@ -2674,7 +2681,7 @@ void main() { ), ); final root = _textMsg( - id: 'root', + id: threadIdRoot, pubkey: 'alice', content: 'Thread root', createdAt: 1000, @@ -2687,7 +2694,7 @@ void main() { content: 'Reply $i', createdAt: 1100 + i, extraTags: const [ - ['e', 'root', '', 'reply'], + ['e', threadIdRoot, '', 'reply'], ], ), ]; @@ -2695,7 +2702,7 @@ void main() { await tester.pumpWidget( _buildTestable( messages: [root], - threadReplies: {'root': replies}, + threadReplies: {threadIdRoot: replies}, readStateNotifier: readState, home: ThreadDetailPage( threadHead: formatTimeline([root]).single, @@ -2732,7 +2739,7 @@ void main() { .getTopLeft(find.byKey(const ValueKey('thread-composer-dock'))) .dy; final marked = readState.markedContexts.keys.toSet(); - expect(marked, isNot(contains('thread-activity:root'))); + expect(marked, isNot(contains('thread-activity:$threadIdRoot'))); var hiddenRows = 0; for (var i = 0; i < 40; i++) { final row = find.byKey(ValueKey('thread-message-group-reply$i')); @@ -4146,9 +4153,9 @@ void main() { await tester.pumpWidget( _buildTestable( messages: [ - _reaction(id: 'reaction-root', targetId: 'root'), + _reaction(id: 'reaction-root', targetId: threadIdRoot), _textMsg( - id: 'root', + id: threadIdRoot, pubkey: 'alice', content: 'Thread head', createdAt: 1000, @@ -4159,7 +4166,7 @@ void main() { content: 'First reply', createdAt: 1100, extraTags: const [ - ['e', 'root', '', 'reply'], + ['e', threadIdRoot, '', 'reply'], ], ), _textMsg( @@ -4168,7 +4175,7 @@ void main() { content: 'Second reply', createdAt: 1200, extraTags: const [ - ['e', 'root', '', 'reply'], + ['e', threadIdRoot, '', 'reply'], ], ), ], @@ -4185,7 +4192,7 @@ void main() { const Size.square(20), ); } - final pill = find.byKey(const ValueKey('thread-summary-root')); + final pill = find.byKey(const ValueKey('thread-summary-$threadIdRoot')); final tray = find.byKey(const ValueKey('combined-reaction-tray')); expect(tester.getTopLeft(pill).dx - tester.getTopRight(tray).dx, 8); expect( @@ -4216,7 +4223,7 @@ void main() { _buildTestable( messages: [ _textMsg( - id: 'root', + id: threadIdRoot, pubkey: 'alice', content: 'Thread head', createdAt: lastReplyAt - 300, @@ -4228,7 +4235,7 @@ void main() { content: 'Reply $i', createdAt: lastReplyAt - 2 + i, extraTags: const [ - ['e', 'root', '', 'reply'], + ['e', threadIdRoot, '', 'reply'], ], ), ], @@ -4493,7 +4500,7 @@ void main() { makeObservedUnreadEvent( id: 'thread-reply', createdAt: 1021, - rootId: 'thread-root', + rootId: threadIdThreadDashRoot, highPriority: true, channelType: 'stream', isThreadedReply: true, @@ -11344,7 +11351,7 @@ void main() { tester, ) async { final root = _textMsg( - id: 'local-root', + id: threadIdLocalDashRoot, pubkey: 'alice', content: 'Start a local thread', ); @@ -11354,7 +11361,7 @@ void main() { content: 'Waiting for relay echo', createdAt: 1100, extraTags: const [ - ['e', 'local-root', '', 'reply'], + ['e', threadIdLocalDashRoot, '', 'reply'], ], ); final query = Completer>(); @@ -11363,7 +11370,7 @@ void main() { _buildTestable( messages: [root], messagesNotifier: notifier, - pendingThreadReplies: {'local-root': query.future}, + pendingThreadReplies: {threadIdLocalDashRoot: query.future}, ), ); await tester.pumpAndSettle(); @@ -11493,7 +11500,7 @@ void main() { 'cold broadcast row with $directReplies relay-only direct replies', (tester) async { final root = _textMsg( - id: 'outer-root', + id: threadIdOuterDashRoot, pubkey: 'bob', content: 'Outer root', ); @@ -11502,8 +11509,8 @@ void main() { pubkey: 'alice', content: 'Broadcast reply with unloaded children', extraTags: const [ - ['e', 'outer-root', '', 'root'], - ['e', 'outer-root', '', 'reply'], + ['e', threadIdOuterDashRoot, '', 'root'], + ['e', threadIdOuterDashRoot, '', 'reply'], ['broadcast', '1'], ], ); @@ -11544,7 +11551,7 @@ void main() { : 'tapping a message without replies stays in the channel', (tester) async { final root = _textMsg( - id: 'tap-root', + id: threadIdTapDashRoot, pubkey: 'alice', content: 'Tap this message', ); @@ -11556,14 +11563,14 @@ void main() { content: 'A reply', createdAt: 1100, extraTags: const [ - ['e', 'tap-root', '', 'reply'], + ['e', threadIdTapDashRoot, '', 'reply'], ], ), ]; await tester.pumpWidget( _buildTestable( messages: [root, ...replies], - threadReplies: {'tap-root': replies}, + threadReplies: {threadIdTapDashRoot: replies}, ), ); await tester.pumpAndSettle(); @@ -11592,7 +11599,7 @@ void main() { tester, ) async { final root = _textMsg( - id: 'root', + id: threadIdRoot, pubkey: 'alice', content: 'Thread root', createdAt: 1000, @@ -11603,7 +11610,7 @@ void main() { content: 'Target reply', createdAt: 1100, extraTags: const [ - ['e', 'root', '', 'reply'], + ['e', threadIdRoot, '', 'reply'], ], ); final timelineMessages = formatTimeline([root, target]); @@ -11615,7 +11622,7 @@ void main() { _buildTestable( messages: [root, target], threadReplies: { - 'root': [target], + threadIdRoot: [target], }, users: const { 'alice': UserProfile(pubkey: 'alice', displayName: 'Alice'), @@ -11704,7 +11711,7 @@ void main() { tester, ) async { final root = _textMsg( - id: 'root', + id: threadIdRoot, pubkey: 'alice', content: 'Thread root', createdAt: 1000, @@ -11717,7 +11724,7 @@ void main() { content: 'Reply $i', createdAt: 1100 + i, extraTags: const [ - ['e', 'root', '', 'reply'], + ['e', threadIdRoot, '', 'reply'], ], ), ]; @@ -11728,7 +11735,7 @@ void main() { await tester.pumpWidget( _buildTestable( messages: [root], - pendingThreadReplies: {'root': replyCompleter.future}, + pendingThreadReplies: {threadIdRoot: replyCompleter.future}, users: const { 'alice': UserProfile(pubkey: 'alice', displayName: 'Alice'), 'bob': UserProfile(pubkey: 'bob', displayName: 'Bob'), @@ -11794,7 +11801,7 @@ void main() { tester, ) async { final root = _textMsg( - id: 'root', + id: threadIdRoot, pubkey: 'alice', content: 'Thread root', createdAt: 1000, @@ -11805,7 +11812,7 @@ void main() { content: 'Hydrated target', createdAt: 1400, extraTags: const [ - ['e', 'root', '', 'reply'], + ['e', threadIdRoot, '', 'reply'], ], ); final earlierReplies = [ @@ -11816,7 +11823,7 @@ void main() { content: 'Reply $i', createdAt: 1100 + i, extraTags: const [ - ['e', 'root', '', 'reply'], + ['e', threadIdRoot, '', 'reply'], ], ), ]; @@ -11830,10 +11837,10 @@ void main() { providerRetry: (retryCount, _) => retryCount == 0 ? const Duration(seconds: 30) : null, localThreadReplies: { - 'root': [target], + threadIdRoot: [target], }, threadReplyLoaders: { - 'root': () { + threadIdRoot: () { attempts++; if (attempts == 1) return firstAttempt.future; return Future.value([...earlierReplies, target]); @@ -11905,7 +11912,7 @@ void main() { tester, ) async { final root = _textMsg( - id: 'root', + id: threadIdRoot, pubkey: 'alice', content: 'Thread root', createdAt: 1000, @@ -11916,7 +11923,7 @@ void main() { content: 'Hydrated target', createdAt: 1100, extraTags: const [ - ['e', 'root', '', 'reply'], + ['e', threadIdRoot, '', 'reply'], ], ); final timelineMessages = formatTimeline([root, target]); @@ -11925,7 +11932,7 @@ void main() { await tester.pumpWidget( _buildTestable( messages: [root, target], - pendingThreadReplies: {'root': replyCompleter.future}, + pendingThreadReplies: {threadIdRoot: replyCompleter.future}, disableRetries: true, users: const { 'alice': UserProfile(pubkey: 'alice', displayName: 'Alice'), @@ -11965,18 +11972,18 @@ void main() { group('after a first-load deadline', () { final root = _textMsg( - id: 'root', + id: threadIdRoot, pubkey: 'alice', content: 'Thread root', createdAt: 1000, ); final mid = _textMsg( - id: 'mid', + id: threadIdMid, pubkey: 'bob', content: 'Nested parent', createdAt: 1100, extraTags: const [ - ['e', 'root', '', 'reply'], + ['e', threadIdRoot, '', 'reply'], ], ); @@ -11993,7 +12000,7 @@ void main() { messagesNotifier: messages, disableRetries: true, threadReplyLoaders: { - 'root': () { + threadIdRoot: () { attempts++; return Future.error( RelayException(503, '{"error":"query timed out"}'), @@ -12029,7 +12036,7 @@ void main() { root, mid, liveReply('direct', const [ - ['e', 'root', '', 'reply'], + ['e', threadIdRoot, '', 'reply'], ]), ]); await tester.pumpAndSettle(); @@ -12055,7 +12062,7 @@ void main() { messagesNotifier: messages, disableRetries: true, threadReplyLoaders: { - 'root': () { + threadIdRoot: () { loads++; if (!fail) return Future.value(const []); return Future.error( @@ -12064,7 +12071,7 @@ void main() { }, }, home: ThreadDetailPage( - threadHead: timeline.firstWhere((m) => m.id == 'root'), + threadHead: timeline.firstWhere((m) => m.id == threadIdRoot), allMessages: timeline, channelId: _testChannel.id, currentPubkey: 'me', @@ -12080,7 +12087,7 @@ void main() { root, mid, liveReply('direct', const [ - ['e', 'root', '', 'reply'], + ['e', threadIdRoot, '', 'reply'], ]), ]); await tester.pumpAndSettle(); @@ -12114,10 +12121,10 @@ void main() { _buildTestable( messages: [root, mid], threadReplyLoaders: { - 'root': () => Completer>().future, + threadIdRoot: () => Completer>().future, }, home: ThreadDetailPage( - threadHead: timeline.firstWhere((m) => m.id == 'root'), + threadHead: timeline.firstWhere((m) => m.id == threadIdRoot), allMessages: timeline, channelId: _testChannel.id, currentPubkey: 'me', @@ -12141,8 +12148,8 @@ void main() { root, mid, liveReply('nested', const [ - ['e', 'root', '', 'root'], - ['e', 'mid', '', 'reply'], + ['e', threadIdRoot, '', 'root'], + ['e', threadIdMid, '', 'reply'], ]), ]); await tester.pumpAndSettle(); @@ -12160,13 +12167,16 @@ void main() { content: 'My reply', createdAt: 1300, extraTags: const [ - ['e', 'root', '', 'reply'], + ['e', threadIdRoot, '', 'reply'], ], ); final container = ProviderScope.containerOf( tester.element(find.byType(ThreadDetailPage)), ); - const args = ThreadRepliesArgs(channelId: _channelId, rootId: 'root'); + const args = ThreadRepliesArgs( + channelId: _channelId, + rootId: threadIdRoot, + ); container.read(threadLocalRepliesProvider(args).notifier).add(sent); await tester.pumpAndSettle(); expect(find.text('My reply'), findsOneWidget); @@ -12182,107 +12192,116 @@ void main() { }); }); - testWidgets('push tap highlights its exact reply after newer arrivals', ( - tester, - ) async { - pendingPushNotificationLink.value = null; - installBuzzPushMethodHandler(); - addTearDown(() { - pendingPushNotificationLink.value = null; - }); - const channel = MethodChannel('buzz/push'); - await TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .handlePlatformMessage( - channel.name, - channel.codec.encodeMethodCall( - const MethodCall('notificationOpened', { - 'eventId': 'target', - 'communityId': 'community', - 'channelId': _channelId, - 'threadRootId': 'parent', - }), - ), - (_) {}, + for (final variant in ['valid', 'uppercase', 'trailing malformed']) { + String marker(String id) => + variant == 'uppercase' ? id.toUpperCase() : id; + testWidgets( + 'push tap highlights its exact reply after newer arrivals: $variant', + (tester) async { + pendingPushNotificationLink.value = null; + installBuzzPushMethodHandler(); + addTearDown(() { + pendingPushNotificationLink.value = null; + }); + const channel = MethodChannel('buzz/push'); + await TestDefaultBinaryMessengerBinding + .instance + .defaultBinaryMessenger + .handlePlatformMessage( + channel.name, + channel.codec.encodeMethodCall( + const MethodCall('notificationOpened', { + 'eventId': 'target', + 'communityId': 'community', + 'channelId': _channelId, + 'threadRootId': threadIdParent, + }), + ), + (_) {}, + ); + final link = pendingPushNotificationLink.value!; + final root = _textMsg( + id: threadIdRoot, + pubkey: 'alice', + content: 'Outer root', + createdAt: 1000, ); - final link = pendingPushNotificationLink.value!; - final root = _textMsg( - id: 'root', - pubkey: 'alice', - content: 'Outer root', - createdAt: 1000, + final parent = _textMsg( + id: threadIdParent, + pubkey: 'alice', + content: 'Containing thread', + createdAt: 1100, + extraTags: [ + ['e', marker(threadIdRoot), '', 'reply'], + ], + ); + final target = _textMsg( + id: 'target', + pubkey: 'alice', + content: 'Notification reply', + createdAt: 1200, + extraTags: [ + ['e', marker(threadIdRoot), '', 'root'], + ['e', marker(threadIdParent), '', 'reply'], + if (variant == 'trailing malformed') ['e', 'bad', '', 'reply'], + ], + ); + final newer = _textMsg( + id: 'newer', + pubkey: 'alice', + content: 'Newer reply', + createdAt: 1300, + extraTags: [ + ['e', marker(threadIdRoot), '', 'root'], + ['e', marker(threadIdParent), '', 'reply'], + if (variant == 'trailing malformed') ['e', 'bad', '', 'reply'], + ], + ); + await tester.pumpWidget( + _buildTestable( + messages: [root, parent, target, newer], + initialMessageId: link.messageId, + initialThreadRootId: link.threadRootId, + threadReplies: { + threadIdRoot: [parent, target, newer], + }, + ), + ); + await tester.pumpAndSettle(); + final page = tester.widget( + find.byType(ThreadDetailPage), + ); + expect(page.threadHead.id, threadIdParent); + expect(page.initialMessageId, 'target'); + final decoration = + tester + .widget( + find.byKey(const ValueKey('thread-message-target')), + ) + .decoration + as BoxDecoration; + expect(decoration.color!.a, closeTo(0.12, 0.001)); + expect(find.text('Newer reply'), findsOneWidget); + }, ); - final parent = _textMsg( - id: 'parent', - pubkey: 'alice', - content: 'Containing thread', - createdAt: 1100, - extraTags: const [ - ['e', 'root', '', 'reply'], - ], - ); - final target = _textMsg( - id: 'target', - pubkey: 'alice', - content: 'Notification reply', - createdAt: 1200, - extraTags: const [ - ['e', 'root', '', 'root'], - ['e', 'parent', '', 'reply'], - ], - ); - final newer = _textMsg( - id: 'newer', - pubkey: 'alice', - content: 'Newer reply', - createdAt: 1300, - extraTags: const [ - ['e', 'root', '', 'root'], - ['e', 'parent', '', 'reply'], - ], - ); - await tester.pumpWidget( - _buildTestable( - messages: [root, parent, target, newer], - initialMessageId: link.messageId, - initialThreadRootId: link.threadRootId, - threadReplies: { - 'root': [parent, target, newer], - }, - ), - ); - await tester.pumpAndSettle(); - final page = tester.widget( - find.byType(ThreadDetailPage), - ); - expect(page.threadHead.id, 'parent'); - expect(page.initialMessageId, 'target'); - final decoration = - tester - .widget( - find.byKey(const ValueKey('thread-message-target')), - ) - .decoration - as BoxDecoration; - expect(decoration.color!.a, closeTo(0.12, 0.001)); - expect(find.text('Newer reply'), findsOneWidget); - }); + } testWidgets('opens a nested reply in its direct-parent thread', ( tester, ) async { final root = _textMsg( - id: 'root', + id: threadIdRoot, pubkey: 'alice', content: 'Outer root', createdAt: 1000, ); final parent = _textMsg( - id: 'parent', + id: threadIdParent, pubkey: 'bob', content: 'Nested thread head', createdAt: 1100, extraTags: const [ - ['e', 'root', '', 'reply'], + ['e', threadIdRoot, '', 'reply'], ], ); final target = _textMsg( @@ -12291,8 +12310,8 @@ void main() { content: 'Deeply nested target', createdAt: 1200, extraTags: const [ - ['e', 'root', '', 'root'], - ['e', 'parent', '', 'reply'], + ['e', threadIdRoot, '', 'root'], + ['e', threadIdParent, '', 'reply'], ], ); @@ -12300,11 +12319,11 @@ void main() { _buildTestable( messages: [root, parent, target], initialMessageId: 'target', - initialThreadRootId: 'parent', + initialThreadRootId: threadIdParent, threadReplies: { // Relay subtree filtering is keyed by thread_metadata.root_event_id, // so nested replies are returned by the outer-root query. - 'root': [parent, target], + threadIdRoot: [parent, target], }, users: const { 'alice': UserProfile(pubkey: 'alice', displayName: 'Alice'), @@ -12318,7 +12337,7 @@ void main() { final threadPage = tester.widget( find.byType(ThreadDetailPage), ); - expect(threadPage.threadHead.id, 'parent'); + expect(threadPage.threadHead.id, threadIdParent); expect(threadPage.initialMessageId, 'target'); final highlighted = tester.widget( @@ -12367,7 +12386,7 @@ void main() { tester, ) async { final root = _textMsg( - id: 'root', + id: threadIdRoot, pubkey: 'alice', content: 'Thread root', ); @@ -12377,7 +12396,7 @@ void main() { _buildTestable( messages: const [], messagesNotifier: messagesNotifier, - initialThreadRootId: 'root', + initialThreadRootId: threadIdRoot, initialThreadRouteBehavior: InitialThreadRouteBehavior.replaceCurrentRoute, ), @@ -12403,7 +12422,7 @@ void main() { tester, ) async { final root = _textMsg( - id: 'root', + id: threadIdRoot, pubkey: 'alice', content: 'Thread root', ); @@ -12413,7 +12432,7 @@ void main() { content: 'Target reply', createdAt: 1100, extraTags: const [ - ['e', 'root', '', 'reply'], + ['e', threadIdRoot, '', 'reply'], ], ); final relaySession = _TrackingRelaySession(); @@ -12423,7 +12442,7 @@ void main() { messages: [root, target], relaySessionNotifier: relaySession, threadReplies: { - 'root': [target], + threadIdRoot: [target], }, users: const { 'alice': UserProfile(pubkey: 'alice', displayName: 'Alice'), @@ -12438,7 +12457,7 @@ void main() { builder: (_) => ChannelDetailPage( channel: _testChannel, initialMessageId: 'target', - initialThreadRootId: 'root', + initialThreadRootId: threadIdRoot, initialThreadRouteBehavior: InitialThreadRouteBehavior.replaceCurrentRoute, ), @@ -12594,7 +12613,7 @@ void main() { final nextDayCreatedAt = DateTime(2025, 1, 2, 12).toUtc().millisecondsSinceEpoch ~/ 1000; final rootEvent = _textMsg( - id: 'thread-root', + id: threadIdThreadDashRoot, pubkey: 'alice', content: 'Thread root', createdAt: rootCreatedAt, @@ -12606,7 +12625,7 @@ void main() { content: 'Same day', createdAt: rootCreatedAt + 60, extraTags: const [ - ['e', 'thread-root', '', 'reply'], + ['e', threadIdThreadDashRoot, '', 'reply'], ], ), _textMsg( @@ -12615,7 +12634,7 @@ void main() { content: 'Next day', createdAt: nextDayCreatedAt, extraTags: const [ - ['e', 'thread-root', '', 'reply'], + ['e', threadIdThreadDashRoot, '', 'reply'], ], ), ]; @@ -12623,7 +12642,7 @@ void main() { await tester.pumpWidget( _buildTestable( messages: [rootEvent], - threadReplies: {'thread-root': replies}, + threadReplies: {threadIdThreadDashRoot: replies}, users: { 'alice': const UserProfile(pubkey: 'alice', displayName: 'Alice'), 'bob': const UserProfile(pubkey: 'bob', displayName: 'Bob'), @@ -12681,7 +12700,9 @@ void main() { // composer, matching desktop's thread panel. final headY = tester .getTopLeft( - find.byKey(const ValueKey('thread-message-group-thread-root')), + find.byKey( + const ValueKey('thread-message-group-$threadIdThreadDashRoot'), + ), ) .dy; final oldestReplyY = tester @@ -12697,12 +12718,16 @@ void main() { expect(headY, lessThan(oldestReplyY)); expect(oldestReplyY, lessThan(newestReplyY)); final threadTimestamp = tester.widget( - find.byKey(const ValueKey('thread-message-timestamp-thread-root')), + find.byKey( + const ValueKey('thread-message-timestamp-$threadIdThreadDashRoot'), + ), ); expect(threadTimestamp.style?.fontSize, 11); expect( find.descendant( - of: find.byKey(const ValueKey('thread-message-row-thread-root')), + of: find.byKey( + const ValueKey('thread-message-row-$threadIdThreadDashRoot'), + ), matching: find.text('·'), ), findsNothing, @@ -12721,7 +12746,7 @@ void main() { DateTime(2025, 1, day, 12, minute).toUtc().millisecondsSinceEpoch ~/ 1000; final rootEvent = _textMsg( - id: 'sticky-thread-root', + id: threadIdStickyDashThreadDashRoot, pubkey: 'alice', content: 'Thread root', createdAt: timestampForDay(1, 0), @@ -12734,7 +12759,7 @@ void main() { content: 'Reply $i', createdAt: timestampForDay(1 + (i ~/ 30), i % 30), extraTags: const [ - ['e', 'sticky-thread-root', '', 'reply'], + ['e', threadIdStickyDashThreadDashRoot, '', 'reply'], ], ), ]; @@ -12742,7 +12767,7 @@ void main() { await tester.pumpWidget( _buildTestable( messages: [rootEvent], - threadReplies: {'sticky-thread-root': replies}, + threadReplies: {threadIdStickyDashThreadDashRoot: replies}, users: const { 'alice': UserProfile(pubkey: 'alice', displayName: 'Alice'), 'bob': UserProfile(pubkey: 'bob', displayName: 'Bob'), @@ -12830,7 +12855,7 @@ void main() { addTearDown(tester.view.resetDevicePixelRatio); final rootEvent = _textMsg( - id: 'thread-root', + id: threadIdThreadDashRoot, pubkey: 'alice', content: 'Thread root', createdAt: 1000, @@ -12843,7 +12868,7 @@ void main() { content: 'Reply $i', createdAt: 1100 + i, extraTags: const [ - ['e', 'thread-root', '', 'reply'], + ['e', threadIdThreadDashRoot, '', 'reply'], ], ), ]; @@ -12851,7 +12876,7 @@ void main() { await tester.pumpWidget( _buildTestable( messages: [rootEvent], - threadReplies: {'thread-root': replies}, + threadReplies: {threadIdThreadDashRoot: replies}, users: const { 'alice': UserProfile(pubkey: 'alice', displayName: 'Alice'), 'bob': UserProfile(pubkey: 'bob', displayName: 'Bob'), @@ -12937,7 +12962,7 @@ void main() { addTearDown(tester.view.reset); final rootEvent = _textMsg( - id: 'thread-root', + id: threadIdThreadDashRoot, pubkey: 'alice', content: 'A short thread', createdAt: 1000, @@ -12946,7 +12971,7 @@ void main() { await tester.pumpWidget( _buildTestable( messages: [rootEvent], - threadReplies: const {'thread-root': []}, + threadReplies: const {threadIdThreadDashRoot: []}, users: const { 'alice': UserProfile(pubkey: 'alice', displayName: 'Alice'), }, @@ -12970,7 +12995,7 @@ void main() { await tester.pumpAndSettle(); final head = find.byKey( - const ValueKey('thread-message-group-thread-root'), + const ValueKey('thread-message-group-$threadIdThreadDashRoot'), ); final initialHeadY = tester.getTopLeft(head).dy; expect(initialHeadY, lessThan(300)); @@ -12999,7 +13024,7 @@ void main() { addTearDown(tester.view.reset); try { final rootEvent = _textMsg( - id: 'thread-root', + id: threadIdThreadDashRoot, pubkey: 'alice', content: 'A short thread', createdAt: 1000, @@ -13012,7 +13037,7 @@ void main() { content: i.isEven ? 'hello' : 'testing', createdAt: 1100 + i, extraTags: const [ - ['e', 'thread-root', '', 'reply'], + ['e', threadIdThreadDashRoot, '', 'reply'], ], ), ]; @@ -13020,7 +13045,7 @@ void main() { await tester.pumpWidget( _buildTestable( messages: [rootEvent], - threadReplies: {'thread-root': replies}, + threadReplies: {threadIdThreadDashRoot: replies}, users: const { 'alice': UserProfile(pubkey: 'alice', displayName: 'Alice'), 'bob': UserProfile(pubkey: 'bob', displayName: 'Bob'), @@ -13087,7 +13112,7 @@ void main() { tester.view.devicePixelRatio = 1; addTearDown(tester.view.reset); final rootEvent = _textMsg( - id: 'thread-root', + id: threadIdThreadDashRoot, pubkey: 'alice', content: 'Short root', createdAt: 1000, @@ -13100,14 +13125,14 @@ void main() { content: 'Short reply', createdAt: 1100, extraTags: const [ - ['e', 'thread-root', '', 'reply'], + ['e', threadIdThreadDashRoot, '', 'reply'], ], ), ]; await tester.pumpWidget( _buildTestable( messages: [rootEvent], - threadReplies: {'thread-root': replies}, + threadReplies: {threadIdThreadDashRoot: replies}, users: const { 'alice': UserProfile(pubkey: 'alice', displayName: 'Alice'), 'bob': UserProfile(pubkey: 'bob', displayName: 'Bob'), @@ -13146,7 +13171,7 @@ void main() { final tail = find.byKey( ValueKey( replyCount == 0 - ? 'thread-message-group-thread-root' + ? 'thread-message-group-$threadIdThreadDashRoot' : 'thread-message-group-reply-0', ), ); @@ -13171,7 +13196,7 @@ void main() { addTearDown(tester.view.reset); final rootEvent = _textMsg( - id: 'thread-root', + id: threadIdThreadDashRoot, pubkey: 'alice', content: 'Thread root', createdAt: 1000, @@ -13186,7 +13211,7 @@ void main() { : 'Reply $i', createdAt: 1100 + i, extraTags: const [ - ['e', 'thread-root', '', 'reply'], + ['e', threadIdThreadDashRoot, '', 'reply'], ], ), ]; @@ -13194,7 +13219,7 @@ void main() { await tester.pumpWidget( _buildTestable( messages: [rootEvent], - threadReplies: {'thread-root': replies}, + threadReplies: {threadIdThreadDashRoot: replies}, users: const { 'alice': UserProfile(pubkey: 'alice', displayName: 'Alice'), 'bob': UserProfile(pubkey: 'bob', displayName: 'Bob'), @@ -13238,7 +13263,7 @@ void main() { tester, ) async { final rootEvent = _textMsg( - id: 'thread-root', + id: threadIdThreadDashRoot, pubkey: 'alice', content: 'Thread root', createdAt: 1000, @@ -13251,7 +13276,7 @@ void main() { content: 'Reply $i', createdAt: 1100 + i, extraTags: const [ - ['e', 'thread-root', '', 'reply'], + ['e', threadIdThreadDashRoot, '', 'reply'], ], ), ]; @@ -13259,7 +13284,7 @@ void main() { await tester.pumpWidget( _buildTestable( messages: [rootEvent], - threadReplies: {'thread-root': replies}, + threadReplies: {threadIdThreadDashRoot: replies}, users: const { 'alice': UserProfile(pubkey: 'alice', displayName: 'Alice'), 'bob': UserProfile(pubkey: 'bob', displayName: 'Bob'), @@ -13301,7 +13326,7 @@ void main() { addTearDown(tester.view.reset); final rootEvent = _textMsg( - id: 'thread-root', + id: threadIdThreadDashRoot, pubkey: 'alice', content: 'Thread root', createdAt: 1000, @@ -13314,7 +13339,7 @@ void main() { content: 'Reply $i', createdAt: 1100 + i, extraTags: const [ - ['e', 'thread-root', '', 'reply'], + ['e', threadIdThreadDashRoot, '', 'reply'], ], ), ]; @@ -13323,7 +13348,7 @@ void main() { await tester.pumpWidget( _buildTestable( messages: [rootEvent], - pendingThreadReplies: {'thread-root': completer.future}, + pendingThreadReplies: {threadIdThreadDashRoot: completer.future}, users: const { 'alice': UserProfile(pubkey: 'alice', displayName: 'Alice'), 'bob': UserProfile(pubkey: 'bob', displayName: 'Bob'), @@ -13392,7 +13417,7 @@ void main() { tester, ) async { final rootEvent = _textMsg( - id: 'thread-root', + id: threadIdThreadDashRoot, pubkey: 'alice', content: 'Thread root', createdAt: 1000, @@ -13405,7 +13430,7 @@ void main() { content: 'Reply $i', createdAt: 1100 + i, extraTags: const [ - ['e', 'thread-root', '', 'reply'], + ['e', threadIdThreadDashRoot, '', 'reply'], ], ), ]; @@ -13414,7 +13439,7 @@ void main() { await tester.pumpWidget( _buildTestable( messages: [rootEvent], - pendingThreadReplies: {'thread-root': completer.future}, + pendingThreadReplies: {threadIdThreadDashRoot: completer.future}, users: const { 'alice': UserProfile(pubkey: 'alice', displayName: 'Alice'), 'bob': UserProfile(pubkey: 'bob', displayName: 'Bob'), @@ -13440,7 +13465,7 @@ void main() { await tester.pumpAndSettle(); final anchor = find.byKey( - const ValueKey('thread-message-group-thread-root'), + const ValueKey('thread-message-group-$threadIdThreadDashRoot'), ); final anchorTop = tester.getTopLeft(anchor).dy; completer.complete(replies); @@ -13461,7 +13486,7 @@ void main() { tester, ) async { final rootEvent = _textMsg( - id: 'thread-root', + id: threadIdThreadDashRoot, pubkey: 'alice', content: 'Thread root', createdAt: 1000, @@ -13474,7 +13499,7 @@ void main() { content: 'Reply $i', createdAt: 1100 + i, extraTags: const [ - ['e', 'thread-root', '', 'reply'], + ['e', threadIdThreadDashRoot, '', 'reply'], ], ), ]; @@ -13483,7 +13508,7 @@ void main() { await tester.pumpWidget( _buildTestable( messages: [rootEvent], - pendingThreadReplies: {'thread-root': completer.future}, + pendingThreadReplies: {threadIdThreadDashRoot: completer.future}, users: const { 'alice': UserProfile(pubkey: 'alice', displayName: 'Alice'), 'bob': UserProfile(pubkey: 'bob', displayName: 'Bob'), @@ -13508,7 +13533,7 @@ void main() { await tester.pumpAndSettle(); final anchor = find.byKey( - const ValueKey('thread-message-group-thread-root'), + const ValueKey('thread-message-group-$threadIdThreadDashRoot'), ); final anchorTop = tester.getTopLeft(anchor).dy; completer.complete(replies); @@ -13537,7 +13562,7 @@ void main() { addTearDown(tester.view.reset); final rootEvent = _textMsg( - id: 'thread-root', + id: threadIdThreadDashRoot, pubkey: 'alice', content: 'Thread root', createdAt: 1000, @@ -13552,7 +13577,7 @@ void main() { : 'Reply $i', createdAt: 1100 + i, extraTags: const [ - ['e', 'thread-root', '', 'reply'], + ['e', threadIdThreadDashRoot, '', 'reply'], ], ), ]; @@ -13561,7 +13586,7 @@ void main() { await tester.pumpWidget( _buildTestable( messages: [rootEvent], - pendingThreadReplies: {'thread-root': completer.future}, + pendingThreadReplies: {threadIdThreadDashRoot: completer.future}, users: const { 'alice': UserProfile(pubkey: 'alice', displayName: 'Alice'), 'bob': UserProfile(pubkey: 'bob', displayName: 'Bob'), @@ -13625,7 +13650,7 @@ void main() { debugDefaultTargetPlatformOverride = TargetPlatform.iOS; try { final rootEvent = _textMsg( - id: 'thread-root', + id: threadIdThreadDashRoot, pubkey: 'alice', content: 'Thread root', createdAt: 1000, @@ -13637,7 +13662,7 @@ void main() { content: 'First reply', createdAt: 1100, extraTags: const [ - ['e', 'thread-root', '', 'reply'], + ['e', threadIdThreadDashRoot, '', 'reply'], ], ), _textMsg( @@ -13646,7 +13671,7 @@ void main() { content: 'Second reply', createdAt: 1101, extraTags: const [ - ['e', 'thread-root', '', 'reply'], + ['e', threadIdThreadDashRoot, '', 'reply'], ], ), ]; @@ -13655,7 +13680,7 @@ void main() { await tester.pumpWidget( _buildTestable( messages: [rootEvent], - pendingThreadReplies: {'thread-root': completer.future}, + pendingThreadReplies: {threadIdThreadDashRoot: completer.future}, users: const { 'alice': UserProfile(pubkey: 'alice', displayName: 'Alice'), 'bob': UserProfile(pubkey: 'bob', displayName: 'Bob'), @@ -13680,7 +13705,7 @@ void main() { await tester.pumpAndSettle(); final headFinder = find.byKey( - const ValueKey('thread-message-group-thread-root'), + const ValueKey('thread-message-group-$threadIdThreadDashRoot'), ); final initialHeadY = tester.getTopLeft(headFinder).dy; const latestButton = ValueKey('thread-jump-to-latest'); @@ -13715,7 +13740,7 @@ void main() { 'slow initial hydration requests the frame that settles the latest reply', (tester) async { final rootEvent = _textMsg( - id: 'thread-root', + id: threadIdThreadDashRoot, pubkey: 'alice', content: 'Thread root', createdAt: 1000, @@ -13728,7 +13753,7 @@ void main() { content: 'Reply $i', createdAt: 1100 + i, extraTags: const [ - ['e', 'thread-root', '', 'reply'], + ['e', threadIdThreadDashRoot, '', 'reply'], ], ), ]; @@ -13737,7 +13762,7 @@ void main() { await tester.pumpWidget( _buildTestable( messages: [rootEvent], - pendingThreadReplies: {'thread-root': completer.future}, + pendingThreadReplies: {threadIdThreadDashRoot: completer.future}, users: const { 'alice': UserProfile(pubkey: 'alice', displayName: 'Alice'), 'bob': UserProfile(pubkey: 'bob', displayName: 'Bob'), @@ -13768,7 +13793,9 @@ void main() { expect( find - .byKey(const ValueKey('thread-message-group-thread-root')) + .byKey( + const ValueKey('thread-message-group-$threadIdThreadDashRoot'), + ) .hitTestable(), findsNothing, ); @@ -13783,7 +13810,7 @@ void main() { 'initial thread hydration settles on the latest reply after pagination', (tester) async { final rootEvent = _textMsg( - id: 'thread-root', + id: threadIdThreadDashRoot, pubkey: 'alice', content: 'Thread root', createdAt: 1000, @@ -13796,7 +13823,7 @@ void main() { content: 'Reply $i', createdAt: 1100 + i, extraTags: const [ - ['e', 'thread-root', '', 'reply'], + ['e', threadIdThreadDashRoot, '', 'reply'], ], ), ]; @@ -13807,7 +13834,7 @@ void main() { _buildTestable( messages: [rootEvent], messagesNotifier: messagesNotifier, - pendingThreadReplies: {'thread-root': completer.future}, + pendingThreadReplies: {threadIdThreadDashRoot: completer.future}, users: const { 'alice': UserProfile(pubkey: 'alice', displayName: 'Alice'), 'bob': UserProfile(pubkey: 'bob', displayName: 'Bob'), @@ -13831,7 +13858,9 @@ void main() { ); await tester.pumpAndSettle(); expect( - find.byKey(const ValueKey('thread-message-group-thread-root')), + find.byKey( + const ValueKey('thread-message-group-$threadIdThreadDashRoot'), + ), findsOneWidget, ); expect( @@ -13847,7 +13876,7 @@ void main() { content: List.filled(10, 'Tall live reply').join('\n'), createdAt: 1200, extraTags: const [ - ['e', 'thread-root', '', 'reply'], + ['e', threadIdThreadDashRoot, '', 'reply'], ], ); messagesNotifier.setMessages([rootEvent, latestLiveReply]); @@ -13855,7 +13884,9 @@ void main() { expect( find - .byKey(const ValueKey('thread-message-group-thread-root')) + .byKey( + const ValueKey('thread-message-group-$threadIdThreadDashRoot'), + ) .hitTestable(), findsNothing, ); @@ -13882,7 +13913,7 @@ void main() { 'active primary drag rejects queued remote and local reply tail work', (tester) async { final rootEvent = _textMsg( - id: 'thread-root', + id: threadIdThreadDashRoot, pubkey: 'alice', content: 'Root', createdAt: 1000, @@ -13895,7 +13926,7 @@ void main() { content: 'Reply $i', createdAt: 1100 + i, extraTags: const [ - ['e', 'thread-root', '', 'reply'], + ['e', threadIdThreadDashRoot, '', 'reply'], ], ), ]; @@ -13904,7 +13935,7 @@ void main() { _buildTestable( messages: [rootEvent], messagesNotifier: messagesNotifier, - threadReplies: {'thread-root': replies}, + threadReplies: {threadIdThreadDashRoot: replies}, ), ); await tester.pumpAndSettle(); @@ -13936,7 +13967,7 @@ void main() { content: 'Remote', createdAt: 1200, extraTags: const [ - ['e', 'thread-root', '', 'reply'], + ['e', threadIdThreadDashRoot, '', 'reply'], ], ); messagesNotifier.setMessages([rootEvent, remoteReply]); @@ -13948,7 +13979,7 @@ void main() { content: 'Local', createdAt: 1201, extraTags: const [ - ['e', 'thread-root', '', 'reply'], + ['e', threadIdThreadDashRoot, '', 'reply'], ], ); messagesNotifier.setMessages([rootEvent, remoteReply, localReply]); @@ -13962,7 +13993,7 @@ void main() { tester, ) async { final rootEvent = _textMsg( - id: 'thread-root', + id: threadIdThreadDashRoot, pubkey: 'alice', content: 'Root', createdAt: 1000, @@ -13975,7 +14006,7 @@ void main() { content: 'Reply $i', createdAt: 1100 + i, extraTags: const [ - ['e', 'thread-root', '', 'reply'], + ['e', threadIdThreadDashRoot, '', 'reply'], ], ), ]; @@ -13984,7 +14015,7 @@ void main() { _buildTestable( messages: [rootEvent], messagesNotifier: messagesNotifier, - threadReplies: {'thread-root': replies}, + threadReplies: {threadIdThreadDashRoot: replies}, ), ); await tester.pumpAndSettle(); @@ -14013,7 +14044,7 @@ void main() { content: 'Local', createdAt: 1200, extraTags: const [ - ['e', 'thread-root', '', 'reply'], + ['e', threadIdThreadDashRoot, '', 'reply'], ], ); messagesNotifier.setMessages([rootEvent, localReply]); @@ -14031,7 +14062,7 @@ void main() { content: List.filled(8, 'Tall remote reply $i').join('\n'), createdAt: 1300 + i, extraTags: const [ - ['e', 'thread-root', '', 'reply'], + ['e', threadIdThreadDashRoot, '', 'reply'], ], ), ]; @@ -14054,7 +14085,7 @@ void main() { tester.view.devicePixelRatio = 1; addTearDown(tester.view.reset); final rootEvent = _textMsg( - id: 'thread-root', + id: threadIdThreadDashRoot, pubkey: 'alice', content: 'Root', createdAt: 1000, @@ -14067,7 +14098,7 @@ void main() { content: 'Reply $i', createdAt: 1100 + i, extraTags: const [ - ['e', 'thread-root', '', 'reply'], + ['e', threadIdThreadDashRoot, '', 'reply'], ], ), ]; @@ -14075,7 +14106,7 @@ void main() { await tester.pumpWidget( _buildTestable( messages: [rootEvent], - threadReplies: {'thread-root': replies}, + threadReplies: {threadIdThreadDashRoot: replies}, ), ); await tester.pumpAndSettle(); @@ -14095,7 +14126,7 @@ void main() { await tester.pumpAndSettle(); final head = find.byKey( - const ValueKey('thread-message-group-thread-root'), + const ValueKey('thread-message-group-$threadIdThreadDashRoot'), ); final latest = find.byKey( const ValueKey('thread-message-group-reply-2'), @@ -14126,7 +14157,7 @@ void main() { tester.view.devicePixelRatio = 1; addTearDown(tester.view.reset); final rootEvent = _textMsg( - id: 'thread-root', + id: threadIdThreadDashRoot, pubkey: 'alice', content: 'Root', createdAt: 1000, @@ -14142,7 +14173,7 @@ void main() { : 'Reply $i', createdAt: 1100 + i, extraTags: const [ - ['e', 'thread-root', '', 'reply'], + ['e', threadIdThreadDashRoot, '', 'reply'], ], ), ]; @@ -14151,7 +14182,7 @@ void main() { _buildTestable( messages: [rootEvent], typingNotifier: typingNotifier, - threadReplies: {'thread-root': replies}, + threadReplies: {threadIdThreadDashRoot: replies}, disableAnimations: true, ), ); @@ -14174,7 +14205,7 @@ void main() { ValueKey('thread-message-group-reply-${replyCount - 1}'), ); final head = find.byKey( - const ValueKey('thread-message-group-thread-root'), + const ValueKey('thread-message-group-$threadIdThreadDashRoot'), ); final initialHeadTop = tail.isLong ? null @@ -14199,7 +14230,7 @@ void main() { typingNotifier.setEntries(const [ TypingEntry( pubkey: 'bob', - threadHeadId: 'thread-root', + threadHeadId: threadIdThreadDashRoot, expiresAtMs: 9999999999999, ), ]); @@ -14229,7 +14260,7 @@ void main() { tester.view.devicePixelRatio = 1; addTearDown(tester.view.reset); final rootEvent = _textMsg( - id: 'thread-root', + id: threadIdThreadDashRoot, pubkey: 'alice', content: 'Root', createdAt: 1000, @@ -14242,7 +14273,7 @@ void main() { content: 'Reply $i', createdAt: 1100 + i, extraTags: const [ - ['e', 'thread-root', '', 'reply'], + ['e', threadIdThreadDashRoot, '', 'reply'], ], ), ]; @@ -14251,7 +14282,7 @@ void main() { _buildTestable( messages: [rootEvent], typingNotifier: typingNotifier, - threadReplies: {'thread-root': replies}, + threadReplies: {threadIdThreadDashRoot: replies}, disableAnimations: true, ), ); @@ -14289,7 +14320,7 @@ void main() { typingNotifier.setEntries(const [ TypingEntry( pubkey: 'bob', - threadHeadId: 'thread-root', + threadHeadId: threadIdThreadDashRoot, expiresAtMs: 9999999999999, ), ]); @@ -14310,7 +14341,7 @@ void main() { tester, ) async { final rootEvent = _textMsg( - id: 'thread-root', + id: threadIdThreadDashRoot, pubkey: 'alice', content: 'Thread root', createdAt: 1000, @@ -14323,7 +14354,7 @@ void main() { content: 'Reply $i', createdAt: 1100 + i, extraTags: const [ - ['e', 'thread-root', '', 'reply'], + ['e', threadIdThreadDashRoot, '', 'reply'], ], ), ]; @@ -14332,7 +14363,7 @@ void main() { _buildTestable( messages: [rootEvent], messagesNotifier: messagesNotifier, - threadReplies: {'thread-root': replies}, + threadReplies: {threadIdThreadDashRoot: replies}, ), ); await tester.pumpAndSettle(); @@ -14365,7 +14396,7 @@ void main() { content: 'Remote tail', createdAt: 9999, extraTags: const [ - ['e', 'thread-root', '', 'reply'], + ['e', threadIdThreadDashRoot, '', 'reply'], ], ), ]); @@ -14386,7 +14417,7 @@ void main() { addTearDown(tester.view.reset); final rootEvent = _textMsg( - id: 'thread-root', + id: threadIdThreadDashRoot, pubkey: 'alice', content: 'Thread root', createdAt: 1000, @@ -14399,7 +14430,7 @@ void main() { content: 'Reply $i', createdAt: 1100 + i, extraTags: const [ - ['e', 'thread-root', '', 'reply'], + ['e', threadIdThreadDashRoot, '', 'reply'], ], ), ]; @@ -14408,7 +14439,7 @@ void main() { _buildTestable( messages: [rootEvent], messagesNotifier: messagesNotifier, - threadReplies: {'thread-root': replies}, + threadReplies: {threadIdThreadDashRoot: replies}, ), ); await tester.pumpAndSettle(); @@ -14472,7 +14503,7 @@ void main() { content: 'Remote tail', createdAt: 9999, extraTags: const [ - ['e', 'thread-root', '', 'reply'], + ['e', threadIdThreadDashRoot, '', 'reply'], ], ), ]); @@ -14503,7 +14534,7 @@ void main() { tester, ) async { final rootEvent = _textMsg( - id: 'thread-root', + id: threadIdThreadDashRoot, pubkey: 'alice', content: 'Thread root', createdAt: 1000, @@ -14516,7 +14547,7 @@ void main() { content: 'Reply $i', createdAt: 1100 + i, extraTags: const [ - ['e', 'thread-root', '', 'reply'], + ['e', threadIdThreadDashRoot, '', 'reply'], ], ), ]; @@ -14525,7 +14556,7 @@ void main() { _buildTestable( messages: [rootEvent], messagesNotifier: messagesNotifier, - threadReplies: {'thread-root': replies}, + threadReplies: {threadIdThreadDashRoot: replies}, ), ); await tester.pumpAndSettle(); @@ -14569,7 +14600,7 @@ void main() { content: 'Remote tail', createdAt: 9999, extraTags: const [ - ['e', 'thread-root', '', 'reply'], + ['e', threadIdThreadDashRoot, '', 'reply'], ], ), ]); @@ -14582,7 +14613,7 @@ void main() { tester, ) async { final rootEvent = _textMsg( - id: 'thread-root', + id: threadIdThreadDashRoot, pubkey: 'alice', content: 'Thread root', createdAt: 1000, @@ -14595,7 +14626,7 @@ void main() { content: 'Reply $i', createdAt: 1100 + i, extraTags: const [ - ['e', 'thread-root', '', 'reply'], + ['e', threadIdThreadDashRoot, '', 'reply'], ], ), ]; @@ -14604,7 +14635,7 @@ void main() { _buildTestable( messages: [rootEvent], messagesNotifier: messagesNotifier, - threadReplies: {'thread-root': replies}, + threadReplies: {threadIdThreadDashRoot: replies}, ), ); await tester.pumpAndSettle(); @@ -14647,7 +14678,7 @@ void main() { content: 'Remote tail', createdAt: 9999, extraTags: const [ - ['e', 'thread-root', '', 'reply'], + ['e', threadIdThreadDashRoot, '', 'reply'], ], ), ]); @@ -14667,7 +14698,7 @@ void main() { addTearDown(tester.view.reset); final rootEvent = _textMsg( - id: 'thread-root', + id: threadIdThreadDashRoot, pubkey: 'alice', content: 'Thread root', createdAt: 1000, @@ -14680,7 +14711,7 @@ void main() { content: 'Reply $i', createdAt: 1100 + i, extraTags: const [ - ['e', 'thread-root', '', 'reply'], + ['e', threadIdThreadDashRoot, '', 'reply'], ], ), ]; @@ -14688,7 +14719,7 @@ void main() { await tester.pumpWidget( _buildTestable( messages: [rootEvent], - threadReplies: {'thread-root': replies}, + threadReplies: {threadIdThreadDashRoot: replies}, users: const { 'alice': UserProfile(pubkey: 'alice', displayName: 'Alice'), 'bob': UserProfile(pubkey: 'bob', displayName: 'Bob'), @@ -14791,7 +14822,7 @@ void main() { addTearDown(tester.view.reset); final rootEvent = _textMsg( - id: 'thread-root', + id: threadIdThreadDashRoot, pubkey: 'alice', content: 'Thread root', createdAt: 1000, @@ -14804,7 +14835,7 @@ void main() { content: 'Reply $i', createdAt: 1100 + i, extraTags: const [ - ['e', 'thread-root', '', 'reply'], + ['e', threadIdThreadDashRoot, '', 'reply'], ], ), ]; @@ -14813,7 +14844,7 @@ void main() { await tester.pumpWidget( _buildTestable( messages: [rootEvent], - pendingThreadReplies: {'thread-root': completer.future}, + pendingThreadReplies: {threadIdThreadDashRoot: completer.future}, users: const { 'alice': UserProfile(pubkey: 'alice', displayName: 'Alice'), 'bob': UserProfile(pubkey: 'bob', displayName: 'Bob'), @@ -14882,7 +14913,7 @@ void main() { addTearDown(tester.view.reset); final rootEvent = _textMsg( - id: 'thread-root', + id: threadIdThreadDashRoot, pubkey: 'alice', content: 'Thread root', createdAt: 1000, @@ -14895,7 +14926,7 @@ void main() { content: 'Reply $i', createdAt: 1100 + i, extraTags: const [ - ['e', 'thread-root', '', 'reply'], + ['e', threadIdThreadDashRoot, '', 'reply'], ], ), ]; @@ -14903,7 +14934,7 @@ void main() { await tester.pumpWidget( _buildTestable( messages: [rootEvent], - threadReplies: {'thread-root': replies}, + threadReplies: {threadIdThreadDashRoot: replies}, users: const { 'alice': UserProfile(pubkey: 'alice', displayName: 'Alice'), 'bob': UserProfile(pubkey: 'bob', displayName: 'Bob'), @@ -15005,7 +15036,7 @@ void main() { addTearDown(tester.view.reset); final rootEvent = _textMsg( - id: 'thread-root', + id: threadIdThreadDashRoot, pubkey: 'alice', content: 'Thread root', createdAt: 1000, @@ -15018,7 +15049,7 @@ void main() { content: 'Reply $i', createdAt: 1100 + i, extraTags: const [ - ['e', 'thread-root', '', 'reply'], + ['e', threadIdThreadDashRoot, '', 'reply'], ], ), ]; @@ -15026,7 +15057,7 @@ void main() { await tester.pumpWidget( _buildTestable( messages: [rootEvent], - threadReplies: {'thread-root': replies}, + threadReplies: {threadIdThreadDashRoot: replies}, users: const { 'alice': UserProfile(pubkey: 'alice', displayName: 'Alice'), 'bob': UserProfile(pubkey: 'bob', displayName: 'Bob'), @@ -15096,7 +15127,7 @@ void main() { addTearDown(tester.view.reset); final rootEvent = _textMsg( - id: 'thread-root', + id: threadIdThreadDashRoot, pubkey: 'alice', content: 'Thread root', createdAt: 1000, @@ -15109,7 +15140,7 @@ void main() { content: 'Reply $i', createdAt: 1100 + i, extraTags: const [ - ['e', 'thread-root', '', 'reply'], + ['e', threadIdThreadDashRoot, '', 'reply'], ], ), ]; @@ -15117,7 +15148,7 @@ void main() { await tester.pumpWidget( _buildTestable( messages: [rootEvent], - threadReplies: {'thread-root': replies}, + threadReplies: {threadIdThreadDashRoot: replies}, users: const { 'alice': UserProfile(pubkey: 'alice', displayName: 'Alice'), 'bob': UserProfile(pubkey: 'bob', displayName: 'Bob'), @@ -15172,7 +15203,7 @@ void main() { addTearDown(tester.view.resetDevicePixelRatio); final rootEvent = _textMsg( - id: 'thread-root', + id: threadIdThreadDashRoot, pubkey: 'alice', content: 'Thread root', createdAt: 1000, @@ -15185,7 +15216,7 @@ void main() { content: 'Reply $i', createdAt: 1100 + i, extraTags: const [ - ['e', 'thread-root', '', 'reply'], + ['e', threadIdThreadDashRoot, '', 'reply'], ], ), ]; @@ -15193,7 +15224,7 @@ void main() { await tester.pumpWidget( _buildTestable( messages: [rootEvent], - threadReplies: {'thread-root': replies}, + threadReplies: {threadIdThreadDashRoot: replies}, users: const { 'alice': UserProfile(pubkey: 'alice', displayName: 'Alice'), 'bob': UserProfile(pubkey: 'bob', displayName: 'Bob'), @@ -15253,7 +15284,7 @@ void main() { addTearDown(tester.view.resetDevicePixelRatio); final rootEvent = _textMsg( - id: 'thread-root', + id: threadIdThreadDashRoot, pubkey: 'alice', content: 'Thread root', createdAt: 1000, @@ -15266,7 +15297,7 @@ void main() { content: 'Reply $i', createdAt: 1100 + i, extraTags: const [ - ['e', 'thread-root', '', 'reply'], + ['e', threadIdThreadDashRoot, '', 'reply'], ], ), ]; @@ -15275,8 +15306,10 @@ void main() { await tester.pumpWidget( _buildTestable( messages: [rootEvent], - pendingThreadReplies: {'thread-root': authoritativeReplies.future}, - localThreadReplies: {'thread-root': replies}, + pendingThreadReplies: { + threadIdThreadDashRoot: authoritativeReplies.future, + }, + localThreadReplies: {threadIdThreadDashRoot: replies}, users: const { 'alice': UserProfile(pubkey: 'alice', displayName: 'Alice'), 'bob': UserProfile(pubkey: 'bob', displayName: 'Bob'), @@ -15378,7 +15411,7 @@ void main() { addTearDown(tester.view.resetDevicePixelRatio); final rootEvent = _textMsg( - id: 'thread-root', + id: threadIdThreadDashRoot, pubkey: 'alice', content: 'Thread root', createdAt: 1000, @@ -15397,7 +15430,7 @@ void main() { ].join('\n'), createdAt: 1100 + i, extraTags: const [ - ['e', 'thread-root', '', 'reply'], + ['e', threadIdThreadDashRoot, '', 'reply'], ], ), ]; @@ -15405,7 +15438,7 @@ void main() { await tester.pumpWidget( _buildTestable( messages: [rootEvent], - threadReplies: {'thread-root': replies}, + threadReplies: {threadIdThreadDashRoot: replies}, users: const { 'alice': UserProfile(pubkey: 'alice', displayName: 'Alice'), 'bob': UserProfile(pubkey: 'bob', displayName: 'Bob'), @@ -15471,7 +15504,7 @@ void main() { addTearDown(tester.view.resetDevicePixelRatio); final rootEvent = _textMsg( - id: 'thread-root', + id: threadIdThreadDashRoot, pubkey: 'alice', content: 'Thread root', createdAt: 1000, @@ -15484,7 +15517,7 @@ void main() { content: 'Reply $i', createdAt: 1100 + i, extraTags: const [ - ['e', 'thread-root', '', 'reply'], + ['e', threadIdThreadDashRoot, '', 'reply'], ], ), ]; @@ -15492,7 +15525,7 @@ void main() { await tester.pumpWidget( _buildTestable( messages: [rootEvent], - threadReplies: {'thread-root': replies}, + threadReplies: {threadIdThreadDashRoot: replies}, users: const { 'alice': UserProfile(pubkey: 'alice', displayName: 'Alice'), 'bob': UserProfile(pubkey: 'bob', displayName: 'Bob'), @@ -15644,7 +15677,7 @@ void main() { addTearDown(tester.view.resetDevicePixelRatio); final rootEvent = _textMsg( - id: 'thread-root', + id: threadIdThreadDashRoot, pubkey: 'alice', content: 'Thread root', createdAt: 1000, @@ -15663,7 +15696,7 @@ void main() { ].join('\n'), createdAt: 1100 + i, extraTags: const [ - ['e', 'thread-root', '', 'reply'], + ['e', threadIdThreadDashRoot, '', 'reply'], ], ), ]; @@ -15673,7 +15706,7 @@ void main() { _buildTestable( messages: [rootEvent], messagesNotifier: messagesNotifier, - threadReplies: {'thread-root': replies}, + threadReplies: {threadIdThreadDashRoot: replies}, users: const { 'alice': UserProfile(pubkey: 'alice', displayName: 'Alice'), 'bob': UserProfile(pubkey: 'bob', displayName: 'Bob'), @@ -15719,7 +15752,7 @@ void main() { content: 'My new reply\n${List.filled(30, 'Final line').join('\n')}', createdAt: 2000, extraTags: const [ - ['e', 'thread-root', '', 'reply'], + ['e', threadIdThreadDashRoot, '', 'reply'], ], ); @@ -15768,7 +15801,7 @@ void main() { // the live channel events were folded in, the pill (and its burst) only // appeared after leaving the thread and coming back, which refetched. final rootEvent = _textMsg( - id: 'thread-root', + id: threadIdThreadDashRoot, pubkey: 'alice', content: 'Thread root', createdAt: 1000, @@ -15779,7 +15812,7 @@ void main() { content: 'A reply', createdAt: 1100, extraTags: const [ - ['e', 'thread-root', '', 'reply'], + ['e', threadIdThreadDashRoot, '', 'reply'], ], ); final messagesNotifier = _FakeMessagesNotifier([rootEvent]); @@ -15789,7 +15822,7 @@ void main() { messages: [rootEvent], messagesNotifier: messagesNotifier, threadReplies: { - 'thread-root': [reply], + threadIdThreadDashRoot: [reply], }, users: { 'alice': const UserProfile(pubkey: 'alice', displayName: 'Alice'), @@ -15837,7 +15870,7 @@ void main() { tester, ) async { final rootEvent = _textMsg( - id: 'thread-root', + id: threadIdThreadDashRoot, pubkey: 'alice', content: 'Thread root', createdAt: 1000, @@ -15848,7 +15881,7 @@ void main() { content: 'A reply', createdAt: 1100, extraTags: const [ - ['e', 'thread-root', '', 'reply'], + ['e', threadIdThreadDashRoot, '', 'reply'], ], ); final messagesNotifier = _FakeMessagesNotifier([rootEvent]); @@ -15858,7 +15891,7 @@ void main() { messages: [rootEvent], messagesNotifier: messagesNotifier, threadReplies: { - 'thread-root': [reply], + threadIdThreadDashRoot: [reply], }, users: const { 'alice': UserProfile(pubkey: 'alice', displayName: 'Alice'), @@ -15886,7 +15919,7 @@ void main() { messagesNotifier.setMessages([ rootEvent, - _deletion(id: 'delete-root', targetIds: ['thread-root']), + _deletion(id: 'delete-root', targetIds: [threadIdThreadDashRoot]), ]); await tester.pumpAndSettle(); @@ -15902,7 +15935,7 @@ void main() { tester, ) async { final rootEvent = _textMsg( - id: 'thread-root', + id: threadIdThreadDashRoot, pubkey: 'alice', content: 'Thread root', createdAt: 1000, @@ -15913,7 +15946,7 @@ void main() { content: 'No reactions here', createdAt: 1100, extraTags: const [ - ['e', 'thread-root', '', 'reply'], + ['e', threadIdThreadDashRoot, '', 'reply'], ], ); final reactedReply = _textMsg( @@ -15922,7 +15955,7 @@ void main() { content: 'This one has a reaction', createdAt: 1200, extraTags: const [ - ['e', 'thread-root', '', 'reply'], + ['e', threadIdThreadDashRoot, '', 'reply'], ], ); @@ -15933,7 +15966,7 @@ void main() { _reaction(id: 'reaction-1', targetId: 'reply-reacted'), ], threadReplies: { - 'thread-root': [bareReply, reactedReply], + threadIdThreadDashRoot: [bareReply, reactedReply], }, users: { 'alice': const UserProfile(pubkey: 'alice', displayName: 'Alice'), @@ -15962,7 +15995,9 @@ void main() { // reads quietly. Reacted replies open details, whose first control adds emoji. expect(find.byKey(const ValueKey('add-reaction-pill')), findsOneWidget); final headRow = find.descendant( - of: find.byKey(const ValueKey('thread-message-group-thread-root')), + of: find.byKey( + const ValueKey('thread-message-group-$threadIdThreadDashRoot'), + ), matching: find.byKey(const ValueKey('add-reaction-pill')), ); expect(headRow, findsOneWidget); diff --git a/mobile/test/features/channels/channel_detail_page_test/bubble_tests.dart b/mobile/test/features/channels/channel_detail_page_test/bubble_tests.dart index 627ebe9839..454cb733f5 100644 --- a/mobile/test/features/channels/channel_detail_page_test/bubble_tests.dart +++ b/mobile/test/features/channels/channel_detail_page_test/bubble_tests.dart @@ -231,7 +231,7 @@ void bubbleLayoutTests() { final events = [ if (thread) _textMsg( - id: 'root', + id: threadIdRoot, pubkey: 'bob', content: 'Root', createdAt: 900, @@ -245,7 +245,7 @@ void bubbleLayoutTests() { ? '![photo](https://example.com/photo.png)' : 'Text $index', extraTags: [ - if (thread) ['e', 'root', '', 'reply'], + if (thread) ['e', threadIdRoot, '', 'reply'], if (tagged && index == 1) [ 'imeta', @@ -262,7 +262,7 @@ void bubbleLayoutTests() { home: thread ? ThreadDetailPage( threadHead: messages.firstWhere( - (message) => message.id == 'root', + (message) => message.id == threadIdRoot, ), allMessages: messages, channelId: _channelId, @@ -431,7 +431,7 @@ void bubbleLayoutTests() { (tester) async { final events = [ _textMsg( - id: 'top-root', + id: threadIdTopDashRoot, pubkey: 'bob', content: 'Oldest content', createdAt: 900, @@ -444,7 +444,7 @@ void bubbleLayoutTests() { createdAt: 1000 + index, extraTags: thread ? const [ - ['e', 'top-root', '', 'reply'], + ['e', threadIdTopDashRoot, '', 'reply'], ] : const [], ), @@ -455,7 +455,9 @@ void bubbleLayoutTests() { messages: events, home: thread ? ThreadDetailPage( - threadHead: messages.firstWhere((m) => m.id == 'top-root'), + threadHead: messages.firstWhere( + (m) => m.id == threadIdTopDashRoot, + ), allMessages: messages, channelId: _channelId, currentPubkey: 'me', @@ -518,7 +520,12 @@ void bubbleLayoutTests() { tester, ) async { final events = [ - _textMsg(id: 'root', pubkey: 'bob', content: 'Root', createdAt: 900), + _textMsg( + id: threadIdRoot, + pubkey: 'bob', + content: 'Root', + createdAt: 900, + ), for (var index = 0; index < 3; index++) _textMsg( id: 'gap-$index', @@ -526,7 +533,7 @@ void bubbleLayoutTests() { content: 'Short', createdAt: 1000 + index, extraTags: const [ - ['e', 'root', '', 'reply'], + ['e', threadIdRoot, '', 'reply'], ], ), ]; @@ -535,7 +542,7 @@ void bubbleLayoutTests() { _buildTestable( messages: events, home: ThreadDetailPage( - threadHead: messages.firstWhere((m) => m.id == 'root'), + threadHead: messages.firstWhere((m) => m.id == threadIdRoot), allMessages: messages, channelId: _channelId, currentPubkey: 'me', @@ -662,14 +669,14 @@ void bubbleLayoutTests() { 'thread replies use the same bubble and group-end avatar layout', (tester) async { final events = [ - _textMsg(id: 'root', pubkey: 'bob', content: 'Root'), + _textMsg(id: threadIdRoot, pubkey: 'bob', content: 'Root'), _textMsg( id: 'first', pubkey: 'alice', content: 'First reply', createdAt: 1010, extraTags: const [ - ['e', 'root', '', 'reply'], + ['e', threadIdRoot, '', 'reply'], ], ), _textMsg( @@ -678,7 +685,7 @@ void bubbleLayoutTests() { content: 'Last reply', createdAt: 1020, extraTags: const [ - ['e', 'root', '', 'reply'], + ['e', threadIdRoot, '', 'reply'], ], ), ]; @@ -687,7 +694,7 @@ void bubbleLayoutTests() { _buildTestable( messages: events, home: ThreadDetailPage( - threadHead: messages.firstWhere((m) => m.id == 'root'), + threadHead: messages.firstWhere((m) => m.id == threadIdRoot), allMessages: messages, channelId: _channelId, currentPubkey: 'me', diff --git a/mobile/test/features/channels/channel_detail_page_test/loading_review_tests.dart b/mobile/test/features/channels/channel_detail_page_test/loading_review_tests.dart index c4f78c44f0..3b054824b8 100644 --- a/mobile/test/features/channels/channel_detail_page_test/loading_review_tests.dart +++ b/mobile/test/features/channels/channel_detail_page_test/loading_review_tests.dart @@ -9,7 +9,7 @@ void _loadingReviewTests() { tester.view.devicePixelRatio = 1; addTearDown(tester.view.reset); final root = _textMsg( - id: 'thread-root', + id: threadIdThreadDashRoot, pubkey: 'alice', content: 'Original message', createdAt: 1000, @@ -22,7 +22,7 @@ void _loadingReviewTests() { content: 'Reply $i', createdAt: 1100 + i, extraTags: const [ - ['e', 'thread-root', '', 'reply'], + ['e', threadIdThreadDashRoot, '', 'reply'], ], ), ]; @@ -30,7 +30,7 @@ void _loadingReviewTests() { await tester.pumpWidget( _buildTestable( messages: [root], - pendingThreadReplies: {'thread-root': query.future}, + pendingThreadReplies: {threadIdThreadDashRoot: query.future}, home: target == null ? null : ThreadDetailPage( @@ -181,7 +181,7 @@ void _loadingReviewTests() { tester.view.devicePixelRatio = 1; addTearDown(tester.view.reset); final root = _textMsg( - id: 'thread-root', + id: threadIdThreadDashRoot, pubkey: 'alice', content: 'Original message', createdAt: 1000, @@ -194,7 +194,7 @@ void _loadingReviewTests() { content: 'Reply $i', createdAt: 1100 + i, extraTags: const [ - ['e', 'thread-root', '', 'reply'], + ['e', threadIdThreadDashRoot, '', 'reply'], ], ), ]; @@ -203,7 +203,7 @@ void _loadingReviewTests() { await tester.pumpWidget( _buildTestable( messages: [root], - pendingThreadReplies: {'thread-root': query.future}, + pendingThreadReplies: {threadIdThreadDashRoot: query.future}, home: ThreadDetailPage( threadHead: provisional.first, allMessages: provisional, @@ -267,7 +267,7 @@ void _loadingReviewTests() { tester.view.devicePixelRatio = 1; addTearDown(tester.view.reset); final root = _textMsg( - id: 'tall-root', + id: threadIdTallDashRoot, pubkey: 'alice', content: '${List.generate(70, (i) => 'Head line $i').join('\n')}\nEnd of original message', @@ -276,7 +276,7 @@ void _loadingReviewTests() { await tester.pumpWidget( _buildTestable( messages: [root], - pendingThreadReplies: {'tall-root': replies.future}, + pendingThreadReplies: {threadIdTallDashRoot: replies.future}, home: ThreadDetailPage( threadHead: formatTimeline([root]).single, allMessages: formatTimeline([root]), @@ -294,7 +294,7 @@ void _loadingReviewTests() { pubkey: 'bob', content: 'Reply after tall head', extraTags: const [ - ['e', 'tall-root', '', 'reply'], + ['e', threadIdTallDashRoot, '', 'reply'], ], ), ]); @@ -336,7 +336,7 @@ void _loadingReviewTests() { tester.view.devicePixelRatio = 1; addTearDown(tester.view.reset); final root = _textMsg( - id: 'video-root', + id: threadIdVideoDashRoot, pubkey: 'alice', content: '![video](https://example.com/head.mp4)', extraTags: const [ @@ -355,7 +355,7 @@ void _loadingReviewTests() { await tester.pumpWidget( _buildTestable( messages: [root], - pendingThreadReplies: {'video-root': replies.future}, + pendingThreadReplies: {threadIdVideoDashRoot: replies.future}, videoPreviewLoader: (_) { loads++; return preview.future; @@ -391,7 +391,7 @@ void _loadingReviewTests() { pubkey: 'bob', content: 'Reply to video', extraTags: const [ - ['e', 'video-root', '', 'reply'], + ['e', threadIdVideoDashRoot, '', 'reply'], ], ), ]); diff --git a/mobile/test/features/channels/channel_messages_provider_test.dart b/mobile/test/features/channels/channel_messages_provider_test.dart index bc6f1dab1d..4d6ef0f362 100644 --- a/mobile/test/features/channels/channel_messages_provider_test.dart +++ b/mobile/test/features/channels/channel_messages_provider_test.dart @@ -10,6 +10,8 @@ import 'package:buzz/features/channels/thread_replies_provider.dart'; import 'package:buzz/features/channels/timeline_message.dart'; import 'package:buzz/shared/relay/relay.dart'; +import '../../helpers/thread_ids.dart'; + void main() { test('live window without deep links does not rescan flattened ids', () async { var historyIdReads = 0; @@ -236,7 +238,7 @@ void main() { channelMessagesProvider(_channelId).notifier, ); - relaySession.emit(_summary(rootId: 'root', replyCount: 2)); + relaySession.emit(_summary(rootId: threadIdRoot, replyCount: 2)); await _pumpEventQueue(); expect( container.read(channelMessagesProvider(_channelId)).isLoading, @@ -244,19 +246,19 @@ void main() { ); window.complete([ - _event(id: 'root', createdAt: 10), - _summary(rootId: 'root', replyCount: 1, createdAt: 10), + _event(id: threadIdRoot, createdAt: 10), + _summary(rootId: threadIdRoot, replyCount: 1, createdAt: 10), _bounds(), ]); await _pumpEventQueue(); - expect(notifier.threadSummaries['root']?.replyCount, 2); + expect(notifier.threadSummaries[threadIdRoot]?.replyCount, 2); expect( container .read(channelMessagesProvider(_channelId)) .value ?.map((event) => event.id), - ['root'], + [threadIdRoot], ); }, ); @@ -320,10 +322,10 @@ void main() { .where((filter) => filter.extensions.containsKey('depth_limit')) .length; NostrEvent reply(int i) => _event( - id: 'reply-$i', + id: indexedThreadId('reply-$i'), createdAt: 20 + i, extraTags: const [ - ['e', 'root', '', 'reply'], + ['e', threadIdRoot, '', 'reply'], ], ); @@ -346,7 +348,7 @@ void main() { test('a recount deadline ends its retry backoff', () async { final session = _RecordingRelaySessionNotifier( queryResults: [ - [_event(id: 'root', createdAt: 10), _bounds()], + [_event(id: threadIdRoot, createdAt: 10), _bounds()], deadline(), deadline(), ], @@ -558,8 +560,8 @@ void main() { final relaySession = _RecordingRelaySessionNotifier( queryResults: [ [ - _event(id: 'root', createdAt: 10), - _summary(rootId: 'root', replyCount: 1), + _event(id: threadIdRoot, createdAt: 10), + _summary(rootId: threadIdRoot, replyCount: 1), _bounds(), ], ], @@ -575,19 +577,19 @@ void main() { ); notifier.addLocalMessage(_event(id: 'local', createdAt: 20)); - relaySession.emit(_summary(rootId: 'root', replyCount: 2)); + relaySession.emit(_summary(rootId: threadIdRoot, replyCount: 2)); await _pumpEventQueue(); - expect(notifier.threadSummaries['root']?.replyCount, 2); + expect(notifier.threadSummaries[threadIdRoot]?.replyCount, 2); notifier.removeLocalMessage('local'); - expect(notifier.threadSummaries['root']?.replyCount, 2); + expect(notifier.threadSummaries[threadIdRoot]?.replyCount, 2); expect( container .read(channelMessagesProvider(_channelId)) .value ?.map((event) => event.id), - ['root'], + [threadIdRoot], ); }, ); @@ -641,13 +643,13 @@ void main() { [ if (nested) _event( - id: 'parent', + id: threadIdParent, createdAt: 15, extraTags: const [ - ['e', 'root', '', 'reply'], + ['e', threadIdRoot, '', 'reply'], ], ), - _event(id: 'root', createdAt: 10), + _event(id: threadIdRoot, createdAt: 10), _bounds(), ], ], @@ -660,13 +662,16 @@ void main() { final notifier = container.read( channelMessagesProvider(_channelId).notifier, ); - const args = ThreadRepliesArgs(channelId: _channelId, rootId: 'root'); + const args = ThreadRepliesArgs( + channelId: _channelId, + rootId: threadIdRoot, + ); final reply = _event( id: 'reply', createdAt: 20, extraTags: [ - if (nested) ['e', 'root', '', 'root'], - ['e', nested ? 'parent' : 'root', '', 'reply'], + if (nested) ['e', threadIdRoot, '', 'root'], + ['e', nested ? threadIdParent : threadIdRoot, '', 'reply'], ], ); notifier.addLocalMessage(reply); @@ -700,7 +705,7 @@ void main() { id: 'reply', createdAt: 20, extraTags: const [ - ['e', 'root', '', 'reply'], + ['e', threadIdRoot, '', 'reply'], ], ), ], @@ -712,7 +717,10 @@ void main() { container.read(channelMessagesProvider(_channelId)); await relaySession.subscribed; await _pumpEventQueue(); - const args = ThreadRepliesArgs(channelId: _channelId, rootId: 'root'); + const args = ThreadRepliesArgs( + channelId: _channelId, + rootId: threadIdRoot, + ); final threadSubscription = container.listen( threadRepliesWithLocalProvider(args), (_, _) {}, @@ -726,7 +734,7 @@ void main() { id: 'reply', createdAt: 20, extraTags: const [ - ['e', 'root', '', 'reply'], + ['e', threadIdRoot, '', 'reply'], ], ); @@ -763,7 +771,7 @@ void main() { id: 'rejected', createdAt: 21, extraTags: const [ - ['e', 'root', '', 'reply'], + ['e', threadIdRoot, '', 'reply'], ], ); notifier.addLocalMessage(rejected); @@ -794,7 +802,10 @@ void main() { container.read(channelMessagesProvider(_channelId)); await relaySession.subscribed; await _pumpEventQueue(); - const args = ThreadRepliesArgs(channelId: _channelId, rootId: 'root'); + const args = ThreadRepliesArgs( + channelId: _channelId, + rootId: threadIdRoot, + ); final threadSubscription = container.listen( threadRepliesWithLocalProvider(args), (_, _) {}, @@ -808,7 +819,7 @@ void main() { id: 'reply', createdAt: 20, extraTags: const [ - ['e', 'root', '', 'reply'], + ['e', threadIdRoot, '', 'reply'], ], ); notifier.addLocalMessage(reply); @@ -840,7 +851,7 @@ void main() { id: 'reply', createdAt: 20, extraTags: const [ - ['e', 'root', '', 'reply'], + ['e', threadIdRoot, '', 'reply'], ], ), ], @@ -855,7 +866,10 @@ void main() { await _pumpEventQueue(); expect(relaySession.operations, ['subscribe', 'query', 'fetch']); - const args = ThreadRepliesArgs(channelId: _channelId, rootId: 'root'); + const args = ThreadRepliesArgs( + channelId: _channelId, + rootId: threadIdRoot, + ); final subscription = container.listen( threadRepliesProvider(args), (_, _) {}, @@ -868,7 +882,7 @@ void main() { id: 'reply', createdAt: 20, extraTags: const [ - ['e', 'root', '', 'reply'], + ['e', threadIdRoot, '', 'reply'], ], ), ); @@ -961,7 +975,7 @@ void main() { () async { final relaySession = _RecordingRelaySessionNotifier( queryResults: [ - [_event(id: 'root', createdAt: 10), _bounds()], + [_event(id: threadIdRoot, createdAt: 10), _bounds()], ], ); final container = _buildContainer(relaySession); @@ -976,7 +990,7 @@ void main() { id: 'reply', createdAt: 20, extraTags: const [ - ['e', 'root', '', 'reply'], + ['e', threadIdRoot, '', 'reply'], ], ), ); @@ -990,7 +1004,7 @@ void main() { .read(channelMessagesProvider(_channelId)) .value ?.map((event) => event.id), - ['root', 'reply'], + [threadIdRoot, 'reply'], ); expect( buildMainTimelineEntries( @@ -1001,7 +1015,7 @@ void main() { .read(channelMessagesProvider(_channelId).notifier) .threadSummaries, ).map((entry) => entry.message.id), - ['root'], + [threadIdRoot], ); }, ); @@ -1010,12 +1024,12 @@ void main() { test( 'confirmed reply evidence survives stale reconnect (deleted: $deleted)', () async { - final root = _event(id: 'root', createdAt: 10); + final root = _event(id: threadIdRoot, createdAt: 10); final reply = _event( id: 'reply', createdAt: 20, extraTags: const [ - ['e', 'root', '', 'reply'], + ['e', threadIdRoot, '', 'reply'], ], ); final relaySession = _RecordingRelaySessionNotifier( @@ -1062,7 +1076,7 @@ void main() { relaySession.setConnected(true); await _pumpEventQueue(); // A subsequent live event rebuilds state from the window store. - relaySession.emit(_event(id: 'unrelated', createdAt: 40)); + relaySession.emit(_event(id: threadIdUnrelated, createdAt: 40)); await _pumpEventQueue(); final entries = buildMainTimelineEntries( formatTimeline( @@ -1071,7 +1085,7 @@ void main() { relaySummaries: notifier.threadSummaries, ); final rootEntry = entries.singleWhere( - (entry) => entry.message.id == 'root', + (entry) => entry.message.id == threadIdRoot, ); if (deleted) { expect(rootEntry.summary, isNull); @@ -1088,7 +1102,7 @@ void main() { final scan = Completer>(); final session = _RecordingRelaySessionNotifier( queryResults: [ - [_event(id: 'root', createdAt: 10), _bounds()], + [_event(id: threadIdRoot, createdAt: 10), _bounds()], scan.future, [], ], @@ -1105,18 +1119,24 @@ void main() { id: 'accepted-late', createdAt: 20, extraTags: const [ - ['e', 'root', '', 'reply'], + ['e', threadIdRoot, '', 'reply'], ], ), ); - const args = ThreadRepliesArgs(channelId: _channelId, rootId: 'root'); + const args = ThreadRepliesArgs( + channelId: _channelId, + rootId: threadIdRoot, + ); container.listen(threadRepliesProvider(args), (_, _) {}); final result = container.read(threadRepliesProvider(args).future); await _pumpEventQueue(); notifier.completeLocalMessage('accepted-late'); scan.complete([]); await result; - expect(notifier.cachedThreadReplyIds('root'), contains('accepted-late')); + expect( + notifier.cachedThreadReplyIds(threadIdRoot), + contains('accepted-late'), + ); final entries = buildMainTimelineEntries( formatTimeline( container.read(channelMessagesProvider(_channelId)).value!, @@ -1144,7 +1164,7 @@ void main() { ); final session = _RecordingRelaySessionNotifier( queryResults: [ - [_event(id: 'root', createdAt: 10), _bounds()], + [_event(id: threadIdRoot, createdAt: 10), _bounds()], [], [for (var i = 0; i < 20; i++) marker(i)], [], @@ -1164,26 +1184,29 @@ void main() { id: 'pending-$i', createdAt: 20 + i, extraTags: const [ - ['e', 'root', '', 'reply'], + ['e', threadIdRoot, '', 'reply'], ], ), ); } - const args = ThreadRepliesArgs(channelId: _channelId, rootId: 'root'); + const args = ThreadRepliesArgs( + channelId: _channelId, + rootId: threadIdRoot, + ); container.listen(threadRepliesProvider(args), (_, _) {}); await container.read(threadRepliesProvider(args).future); int proofs() => session.queryFilters .where((f) => f.kinds.contains(EventKind.deletion)) .length; expect(proofs(), 20); - expect(notifier.unconfirmedThreadReplyIds('root'), { + expect(notifier.unconfirmedThreadReplyIds(threadIdRoot), { for (var i = 20; i < 25; i++) 'pending-$i', }); expect(container.read(threadLocalRepliesProvider(args)), hasLength(5)); container.invalidate(threadRepliesProvider(args)); await container.read(threadRepliesProvider(args).future); expect(proofs(), 25); - expect(notifier.unconfirmedThreadReplyIds('root'), isEmpty); + expect(notifier.unconfirmedThreadReplyIds(threadIdRoot), isEmpty); expect(container.read(threadLocalRepliesProvider(args)), isEmpty); }, ); @@ -1204,7 +1227,7 @@ void main() { ); final session = _RecordingRelaySessionNotifier( queryResults: [ - [_event(id: 'root', createdAt: 10), _bounds()], + [_event(id: threadIdRoot, createdAt: 10), _bounds()], [], [], [], @@ -1226,18 +1249,21 @@ void main() { id: 'pending-$i', createdAt: 20 + i, extraTags: const [ - ['e', 'root', '', 'reply'], + ['e', threadIdRoot, '', 'reply'], ], ), ); } - const args = ThreadRepliesArgs(channelId: _channelId, rootId: 'root'); + const args = ThreadRepliesArgs( + channelId: _channelId, + rootId: threadIdRoot, + ); final subscription = container.listen( threadRepliesProvider(args), (_, _) {}, ); await container.read(threadRepliesProvider(args).future); - expect(notifier.unconfirmedThreadReplyIds('root'), hasLength(25)); + expect(notifier.unconfirmedThreadReplyIds(threadIdRoot), hasLength(25)); subscription.close(); await _pumpEventQueue(); container.listen(threadRepliesProvider(args), (_, _) {}); @@ -1249,9 +1275,9 @@ void main() { expect(targets, hasLength(40)); expect(targets.take(20), isNot(contains('pending-24'))); expect(targets.skip(20), contains('pending-24')); - expect(notifier.unconfirmedThreadReplyIds('root'), hasLength(24)); + expect(notifier.unconfirmedThreadReplyIds(threadIdRoot), hasLength(24)); expect( - notifier.cachedThreadReplyIds('root'), + notifier.cachedThreadReplyIds(threadIdRoot), isNot(contains('pending-24')), ); container.invalidate(threadRepliesProvider(args)); @@ -1279,13 +1305,13 @@ void main() { id: 'survivor-$i', createdAt: 30 + i, extraTags: const [ - ['e', 'root', '', 'reply'], + ['e', threadIdRoot, '', 'reply'], ], ), ]; final session = _RecordingRelaySessionNotifier( queryResults: [ - [_event(id: 'root', createdAt: 10), _bounds()], + [_event(id: threadIdRoot, createdAt: 10), _bounds()], scan.future, if (count >= 200) survivors.skip(200).toList(), [ @@ -1316,11 +1342,14 @@ void main() { id: 'deleted-local', createdAt: 20, extraTags: const [ - ['e', 'root', '', 'reply'], + ['e', threadIdRoot, '', 'reply'], ], ), ); - const args = ThreadRepliesArgs(channelId: _channelId, rootId: 'root'); + const args = ThreadRepliesArgs( + channelId: _channelId, + rootId: threadIdRoot, + ); container.listen(threadRepliesProvider(args), (_, _) {}); final result = container.read(threadRepliesProvider(args).future); await _pumpEventQueue(); @@ -1329,8 +1358,11 @@ void main() { } scan.complete(survivors.take(200).toList()); expect(await result, survivors); - expect(notifier.threadSummaries['root']!.descendantCount, count); - expect(notifier.unconfirmedThreadReplyIds('root'), isEmpty); + expect( + notifier.threadSummaries[threadIdRoot]!.descendantCount, + count, + ); + expect(notifier.unconfirmedThreadReplyIds(threadIdRoot), isEmpty); final entries = buildMainTimelineEntries( formatTimeline( container.read(channelMessagesProvider(_channelId)).value!, @@ -1349,7 +1381,7 @@ void main() { () async { final session = _RecordingRelaySessionNotifier( queryResults: [ - [_event(id: 'root', createdAt: 10), _bounds()], + [_event(id: threadIdRoot, createdAt: 10), _bounds()], Exception('recount unavailable'), Exception('recount unavailable'), Exception('recount unavailable'), @@ -1367,23 +1399,23 @@ void main() { id: 'deleted', createdAt: 20, extraTags: const [ - ['e', 'root', '', 'reply'], + ['e', threadIdRoot, '', 'reply'], ], ), ]); - final snapshot = notifier.cachedThreadReplyIds('root'); + final snapshot = notifier.cachedThreadReplyIds(threadIdRoot); expect(snapshot, contains('deleted')); - notifier.cacheCompleteThreadQuery('root', snapshot, [ + notifier.cacheCompleteThreadQuery(threadIdRoot, snapshot, [ for (var i = 0; i < 300; i++) _event( id: 'survivor-$i', createdAt: 30 + i, extraTags: const [ - ['e', 'root', '', 'reply'], + ['e', threadIdRoot, '', 'reply'], ], ), ]); - expect(notifier.threadSummaries['root']!.descendantCount, 300); + expect(notifier.threadSummaries[threadIdRoot]!.descendantCount, 300); session.emit( NostrEvent( id: 'late-deletion', @@ -1398,8 +1430,8 @@ void main() { sig: '', ), ); - expect(notifier.threadSummaries['root']!.descendantCount, 300); - expect(notifier.threadSummaries['root']!.isLowerBound, isFalse); + expect(notifier.threadSummaries[threadIdRoot]!.descendantCount, 300); + expect(notifier.threadSummaries[threadIdRoot]!.isLowerBound, isFalse); await Future.delayed(const Duration(milliseconds: 300)); expect( session.queryFilters.where( @@ -1424,7 +1456,7 @@ void main() { 'complete thread scan removes absent replies (marker: $deletionMarkerAvailable, late arrival: $lateArrival)', () async { final query = Completer>(); - final root = _event(id: 'root', createdAt: 10); + final root = _event(id: threadIdRoot, createdAt: 10); final relaySession = _RecordingRelaySessionNotifier( queryResults: [ [root, _bounds()], @@ -1464,7 +1496,7 @@ void main() { id: 'deleted-offline', createdAt: 20, extraTags: const [ - ['e', 'root', '', 'reply'], + ['e', threadIdRoot, '', 'reply'], ], ), ]); @@ -1472,7 +1504,10 @@ void main() { await _pumpEventQueue(); relaySession.setConnected(true); await _pumpEventQueue(); - const args = ThreadRepliesArgs(channelId: _channelId, rootId: 'root'); + const args = ThreadRepliesArgs( + channelId: _channelId, + rootId: threadIdRoot, + ); final threadSubscription = container.listen( threadRepliesProvider(args), (_, _) {}, @@ -1487,7 +1522,7 @@ void main() { id: 'new-arrival', createdAt: 30, extraTags: const [ - ['e', 'root', '', 'reply'], + ['e', threadIdRoot, '', 'reply'], ], ), ]); @@ -1495,15 +1530,15 @@ void main() { query.complete([]); await container.read(threadRepliesProvider(args).future); await _pumpEventQueue(); - relaySession.emit(_event(id: 'unrelated', createdAt: 40)); + relaySession.emit(_event(id: threadIdUnrelated, createdAt: 40)); await _pumpEventQueue(); final events = container .read(channelMessagesProvider(_channelId)) .value!; expect(formatTimeline(events).map((event) => event.id), [ - 'root', + threadIdRoot, if (lateArrival) 'new-arrival', - 'unrelated', + threadIdUnrelated, ]); final merged = mergeThreadEvents( container.read(threadRepliesProvider(args)).value!, @@ -1518,7 +1553,7 @@ void main() { final rootEntry = buildMainTimelineEntries( formatTimeline(events), relaySummaries: notifier.threadSummaries, - ).singleWhere((entry) => entry.message.id == 'root'); + ).singleWhere((entry) => entry.message.id == threadIdRoot); final expectedCount = lateArrival ? 1 : 0; expect( rootEntry.summary?.replyCount, @@ -1535,12 +1570,12 @@ void main() { id: 'discovered', createdAt: 20, extraTags: const [ - ['e', 'root', '', 'reply'], + ['e', threadIdRoot, '', 'reply'], ], ); final session = _RecordingRelaySessionNotifier( queryResults: [ - [_event(id: 'root', createdAt: 10), _bounds()], + [_event(id: threadIdRoot, createdAt: 10), _bounds()], [reply], ], ); @@ -1548,7 +1583,10 @@ void main() { addTearDown(container.dispose); container.listen(channelMessagesProvider(_channelId), (_, _) {}); await _pumpEventQueue(); - const args = ThreadRepliesArgs(channelId: _channelId, rootId: 'root'); + const args = ThreadRepliesArgs( + channelId: _channelId, + rootId: threadIdRoot, + ); final thread = container.listen(threadRepliesProvider(args), (_, _) {}); await container.read(threadRepliesProvider(args).future); thread.close(); @@ -1570,9 +1608,9 @@ void main() { test('reconnect evicts reply evidence outside the newest window', () async { final session = _RecordingRelaySessionNotifier( queryResults: [ - [_event(id: 'old-root', createdAt: 10), _bounds()], - [_event(id: 'new-root', createdAt: 30), _bounds()], - [_event(id: 'new-root', createdAt: 30), _bounds()], + [_event(id: threadIdOldDashRoot, createdAt: 10), _bounds()], + [_event(id: threadIdNewDashRoot, createdAt: 30), _bounds()], + [_event(id: threadIdNewDashRoot, createdAt: 30), _bounds()], ], ); final container = _buildContainer(session); @@ -1587,7 +1625,7 @@ void main() { id: 'old-reply', createdAt: 20, extraTags: const [ - ['e', 'old-root', '', 'reply'], + ['e', threadIdOldDashRoot, '', 'reply'], ], ), ]); @@ -1596,7 +1634,7 @@ void main() { await _pumpEventQueue(); session.setConnected(true); await _pumpEventQueue(); - expect(notifier.cachedThreadReplyIds('old-root'), isEmpty); + expect(notifier.cachedThreadReplyIds(threadIdOldDashRoot), isEmpty); session.emit( _event(id: 'window-publication-$cycle', createdAt: 50 + cycle), ); @@ -1613,12 +1651,12 @@ void main() { id: 'new-reply', createdAt: 40, extraTags: const [ - ['e', 'new-root', '', 'reply'], + ['e', threadIdNewDashRoot, '', 'reply'], ], ), ]); } - expect(notifier.cachedThreadReplyIds('new-root'), {'new-reply'}); + expect(notifier.cachedThreadReplyIds(threadIdNewDashRoot), {'new-reply'}); }); for (final nested in [false, true]) { @@ -1640,7 +1678,7 @@ void main() { ); final session = _RecordingRelaySessionNotifier( queryResults: [ - [_event(id: 'root', createdAt: 10), _bounds()], + [_event(id: threadIdRoot, createdAt: 10), _bounds()], [], [deletion], ], @@ -1657,13 +1695,16 @@ void main() { id: 'local', createdAt: 20, extraTags: [ - if (nested) ['e', 'root', '', 'root'], - ['e', nested ? 'parent' : 'root', '', 'reply'], + if (nested) ['e', threadIdRoot, '', 'root'], + ['e', nested ? threadIdParent : threadIdRoot, '', 'reply'], ], ), ); notifier.completeLocalMessage('local'); - const args = ThreadRepliesArgs(channelId: _channelId, rootId: 'root'); + const args = ThreadRepliesArgs( + channelId: _channelId, + rootId: threadIdRoot, + ); if (fetched) { container.listen(threadRepliesProvider(args), (_, _) {}); await container.read(threadRepliesProvider(args).future); @@ -1672,7 +1713,7 @@ void main() { } await _pumpEventQueue(); expect(container.exists(threadLocalRepliesProvider(args)), isFalse); - expect(notifier.cachedThreadReplyIds('root'), isEmpty); + expect(notifier.cachedThreadReplyIds(threadIdRoot), isEmpty); final entries = buildMainTimelineEntries( formatTimeline( container.read(channelMessagesProvider(_channelId)).value!, @@ -1691,16 +1732,16 @@ void main() { final replies = [ for (var i = 0; i < 600; i++) _event( - id: 'reply-$i', + id: indexedThreadId('reply-$i'), createdAt: 20 + i, extraTags: const [ - ['e', 'root', '', 'reply'], + ['e', threadIdRoot, '', 'reply'], ], ), ]; final session = _RecordingRelaySessionNotifier( queryResults: [ - [_event(id: 'root', createdAt: 10), _bounds()], + [_event(id: threadIdRoot, createdAt: 10), _bounds()], replies.take(200).toList(), replies.skip(200).take(200).toList(), replies.skip(400).toList(), @@ -1712,7 +1753,10 @@ void main() { addTearDown(container.dispose); container.listen(channelMessagesProvider(_channelId), (_, _) {}); await _pumpEventQueue(); - const args = ThreadRepliesArgs(channelId: _channelId, rootId: 'root'); + const args = ThreadRepliesArgs( + channelId: _channelId, + rootId: threadIdRoot, + ); container.listen(threadRepliesProvider(args), (_, _) {}); expect( await container.read(threadRepliesProvider(args).future), @@ -1721,7 +1765,7 @@ void main() { final notifier = container.read( channelMessagesProvider(_channelId).notifier, ); - expect(notifier.cachedThreadReplyIds('root'), hasLength(256)); + expect(notifier.cachedThreadReplyIds(threadIdRoot), hasLength(256)); expect( container.read(channelMessagesProvider(_channelId)).value, hasLength(257), @@ -1732,12 +1776,12 @@ void main() { .where((filter) => filter.kinds.contains(EventKind.deletion)) .toList(); expect(deletionFilters, isEmpty); - expect(notifier.threadSummaries['root']?.descendantCount, 0); + expect(notifier.threadSummaries[threadIdRoot]?.descendantCount, 0); expect( formatTimeline( container.read(channelMessagesProvider(_channelId)).value!, ).map((event) => event.id), - ['root'], + [threadIdRoot], ); }, ); @@ -1747,7 +1791,7 @@ void main() { () async { final session = _RecordingRelaySessionNotifier( queryResults: [ - [_event(id: 'root', createdAt: 10), _bounds()], + [_event(id: threadIdRoot, createdAt: 10), _bounds()], ], ); final container = _buildContainer(session); @@ -1764,7 +1808,7 @@ void main() { id: '$root-$i', createdAt: 20 + root * 300 + i, extraTags: [ - ['e', 'root-$root', '', 'reply'], + ['e', indexedThreadId('root-$root'), '', 'reply'], ], ), ]); @@ -1775,7 +1819,7 @@ void main() { ); for (var root = 0; root < 12; root++) { expect( - notifier.cachedThreadReplyIds('root-$root').length, + notifier.cachedThreadReplyIds(indexedThreadId('root-$root')).length, lessThanOrEqualTo(256), ); } @@ -1793,7 +1837,7 @@ void main() { id: 'old-reply', createdAt: 20, extraTags: const [ - ['e', 'old-root', '', 'reply'], + ['e', threadIdOldDashRoot, '', 'reply'], ], ), ], @@ -1806,10 +1850,15 @@ void main() { final notifier = container.read( channelMessagesProvider(_channelId).notifier, ); - final load = notifier.loadEventsById(['old-root']); - session.completeTargetHistory([_event(id: 'old-root', createdAt: 10)]); + final load = notifier.loadEventsById([threadIdOldDashRoot]); + session.completeTargetHistory([ + _event(id: threadIdOldDashRoot, createdAt: 10), + ]); await load; - const args = ThreadRepliesArgs(channelId: _channelId, rootId: 'old-root'); + const args = ThreadRepliesArgs( + channelId: _channelId, + rootId: threadIdOldDashRoot, + ); final thread = container.listen(threadRepliesProvider(args), (_, _) {}); await container.read(threadRepliesProvider(args).future); thread.close(); @@ -1823,7 +1872,7 @@ void main() { ); expect( entries - .singleWhere((entry) => entry.message.id == 'old-root') + .singleWhere((entry) => entry.message.id == threadIdOldDashRoot) .summary ?.replyCount, 1, @@ -1837,7 +1886,7 @@ void main() { () async { final session = _RecordingRelaySessionNotifier( queryResults: [ - [_event(id: 'root', createdAt: 10), _bounds()], + [_event(id: threadIdRoot, createdAt: 10), _bounds()], ], ); final container = _buildContainer(session); @@ -1853,7 +1902,7 @@ void main() { id: '$root-$i', createdAt: 20 + root * 300 + i, extraTags: [ - ['e', 'root-$root', '', 'reply'], + ['e', indexedThreadId('root-$root'), '', 'reply'], ], ); if (source == 'ack') { @@ -1864,7 +1913,7 @@ void main() { } } expect( - notifier.cachedThreadReplyIds('root-$root').length, + notifier.cachedThreadReplyIds(indexedThreadId('root-$root')).length, lessThanOrEqualTo(256), ); } @@ -1884,7 +1933,10 @@ void main() { expect( container.exists( threadLocalRepliesProvider( - ThreadRepliesArgs(channelId: _channelId, rootId: 'root-$root'), + ThreadRepliesArgs( + channelId: _channelId, + rootId: indexedThreadId('root-$root'), + ), ), ), isFalse, @@ -1901,11 +1953,16 @@ void main() { final replies = [ for (var i = 0; i < count; i++) _event( - id: 'reply-$i', + id: indexedThreadId('reply-$i'), createdAt: 20 + i, extraTags: [ - if (count == 600 && i > 0) ['e', 'root', '', 'root'], - ['e', count == 600 && i > 0 ? 'reply-0' : 'root', '', 'reply'], + if (count == 600 && i > 0) ['e', threadIdRoot, '', 'root'], + [ + 'e', + count == 600 && i > 0 ? threadIdReplyZero : threadIdRoot, + '', + 'reply', + ], ], ), ]; @@ -1914,7 +1971,7 @@ void main() { pages.add(replies.skip(start).take(200).toList()); } if (count % 200 == 0) pages.add([]); - final window = [_event(id: 'root', createdAt: 10), _bounds()]; + final window = [_event(id: threadIdRoot, createdAt: 10), _bounds()]; final session = _RecordingRelaySessionNotifier( queryResults: [window, ...pages, window, ...pages, ...pages], ); @@ -1922,7 +1979,10 @@ void main() { addTearDown(container.dispose); container.listen(channelMessagesProvider(_channelId), (_, _) {}); await _pumpEventQueue(); - const args = ThreadRepliesArgs(channelId: _channelId, rootId: 'root'); + const args = ThreadRepliesArgs( + channelId: _channelId, + rootId: threadIdRoot, + ); final thread = container.listen(threadRepliesProvider(args), (_, _) {}); expect( await container.read(threadRepliesProvider(args).future), @@ -1942,14 +2002,17 @@ void main() { ); expect( entries - .singleWhere((entry) => entry.message.id == 'root') + .singleWhere((entry) => entry.message.id == threadIdRoot) .summary ?.replyCount, count, ); - expect(notifier.cachedThreadReplyIds('root'), hasLength(256)); - expect(notifier.threadSummaries['root']?.lastReplyAt, 19 + count); - expect(notifier.threadSummaries['root']?.participantPubkeys, [ + expect(notifier.cachedThreadReplyIds(threadIdRoot), hasLength(256)); + expect( + notifier.threadSummaries[threadIdRoot]?.lastReplyAt, + 19 + count, + ); + expect(notifier.threadSummaries[threadIdRoot]?.participantPubkeys, [ 'alice', ]); } @@ -1957,7 +2020,9 @@ void main() { checkSummary(); if (count == 600) { expect( - notifier.cachedThreadReplyIds('root').contains('reply-0'), + notifier + .cachedThreadReplyIds(threadIdRoot) + .contains(threadIdReplyZero), isFalse, ); } @@ -1966,7 +2031,7 @@ void main() { session.setConnected(true); await _pumpEventQueue(); await Future.delayed(const Duration(milliseconds: 300)); - session.emit(_event(id: 'unrelated', createdAt: 1000)); + session.emit(_event(id: threadIdUnrelated, createdAt: 1000)); checkSummary(); final reopened = container.listen( threadRepliesProvider(args), @@ -1991,10 +2056,10 @@ void main() { final session = _RecordingRelaySessionNotifier( queryResults: [ [ - _event(id: 'root', createdAt: 10), - _event(id: 'other-root', createdAt: 9), - _event(id: 'unrelated', createdAt: 8), - _summary(rootId: 'unrelated', replyCount: 2), + _event(id: threadIdRoot, createdAt: 10), + _event(id: threadIdOtherRoot, createdAt: 9), + _event(id: threadIdUnrelated, createdAt: 8), + _summary(rootId: threadIdUnrelated, replyCount: 2), _bounds(), ], [], @@ -2008,7 +2073,7 @@ void main() { final notifier = container.read( channelMessagesProvider(_channelId).notifier, ); - for (final root in ['root', 'other-root']) { + for (final root in [threadIdRoot, threadIdOtherRoot]) { notifier.cacheCompleteThreadQuery(root, {}, [ _event( id: '$root-reply', @@ -2025,20 +2090,26 @@ void main() { createdAt: 30, kind: EventKind.deletion, tags: const [ - ['e', 'root-reply'], - ['e', 'other-root-reply'], + ['e', '$threadIdRoot-reply'], + ['e', '$threadIdOtherRoot-reply'], ['e', 'outside-channel'], ], content: '', sig: 'original-signature', ); - notifier.cacheThreadDeletions([deletion], scopedTargetIds: {'root-reply'}); - expect(notifier.cachedThreadReplyIds('other-root'), isEmpty); - expect(notifier.threadSummaries['other-root']?.descendantCount, 0); - expect(notifier.threadSummaries['unrelated']!.isCountPending, isFalse); + notifier.cacheThreadDeletions( + [deletion], + scopedTargetIds: {'$threadIdRoot-reply'}, + ); + expect(notifier.cachedThreadReplyIds(threadIdOtherRoot), isEmpty); + expect(notifier.threadSummaries[threadIdOtherRoot]?.descendantCount, 0); + expect( + notifier.threadSummaries[threadIdUnrelated]!.isCountPending, + isFalse, + ); await Future.delayed(const Duration(milliseconds: 350)); - expect(notifier.threadSummaries['other-root']?.descendantCount, 0); - expect(notifier.threadSummaries['other-root']?.isLowerBound, isFalse); + expect(notifier.threadSummaries[threadIdOtherRoot]?.descendantCount, 0); + expect(notifier.threadSummaries[threadIdOtherRoot]?.isLowerBound, isFalse); expect( session.queryFilters.where( (filter) => filter.extensions['resolve_thread_roots'] == true, @@ -2071,9 +2142,9 @@ void main() { final session = _RecordingRelaySessionNotifier( queryResults: [ [ - _event(id: 'root', createdAt: 10), - _event(id: 'unrelated', createdAt: 9), - _summary(rootId: 'unrelated', replyCount: 2), + _event(id: threadIdRoot, createdAt: 10), + _event(id: threadIdUnrelated, createdAt: 9), + _summary(rootId: threadIdUnrelated, replyCount: 2), _bounds(), ], [], @@ -2094,11 +2165,14 @@ void main() { id: 'pending', createdAt: 20, extraTags: const [ - ['e', 'root', '', 'reply'], + ['e', threadIdRoot, '', 'reply'], ], ), ); - const args = ThreadRepliesArgs(channelId: _channelId, rootId: 'root'); + const args = ThreadRepliesArgs( + channelId: _channelId, + rootId: threadIdRoot, + ); container.listen(threadRepliesProvider(args), (_, _) {}); await container.read(threadRepliesProvider(args).future); expect( @@ -2113,7 +2187,10 @@ void main() { container.read(pendingLocalMessagesProvider(_channelId)), isEmpty, ); - expect(notifier.threadSummaries['unrelated']!.isCountPending, isFalse); + expect( + notifier.threadSummaries[threadIdUnrelated]!.isCountPending, + isFalse, + ); expect( session.queryFilters.where( (filter) => filter.extensions['resolve_thread_roots'] == true, @@ -2144,7 +2221,7 @@ void main() { id: 'live-$i', createdAt: 20 + i, extraTags: const [ - ['e', 'root', '', 'reply'], + ['e', threadIdRoot, '', 'reply'], ], ), ]; @@ -2154,7 +2231,7 @@ void main() { replies.skip(400).toList(), [], ]; - final window = [_event(id: 'root', createdAt: 10), _bounds()]; + final window = [_event(id: threadIdRoot, createdAt: 10), _bounds()]; final session = _RecordingRelaySessionNotifier( queryResults: [window, ...pages, window, ...pages, ...pages], ); @@ -2168,7 +2245,7 @@ void main() { for (final reply in replies) { session.emit(reply); } - expect(notifier.threadSummaries['root']?.isLowerBound, isTrue); + expect(notifier.threadSummaries[threadIdRoot]?.isLowerBound, isTrue); expect( session.queryFilters.where( (filter) => filter.extensions.containsKey('depth_limit'), @@ -2176,23 +2253,26 @@ void main() { isEmpty, ); await Future.delayed(const Duration(milliseconds: 300)); - expect(notifier.threadSummaries['root']?.descendantCount, 600); - expect(notifier.threadSummaries['root']?.isLowerBound, isFalse); - expect(notifier.cachedThreadReplyIds('root'), hasLength(256)); + expect(notifier.threadSummaries[threadIdRoot]?.descendantCount, 600); + expect(notifier.threadSummaries[threadIdRoot]?.isLowerBound, isFalse); + expect(notifier.cachedThreadReplyIds(threadIdRoot), hasLength(256)); expect( session.queryFilters.where( (filter) => filter.extensions.containsKey('depth_limit'), ), hasLength(4), ); - const args = ThreadRepliesArgs(channelId: _channelId, rootId: 'root'); + const args = ThreadRepliesArgs( + channelId: _channelId, + rootId: threadIdRoot, + ); expect(container.exists(threadRepliesProvider(args)), isFalse); session.setConnected(false); await _pumpEventQueue(); session.setConnected(true); await _pumpEventQueue(); await Future.delayed(const Duration(milliseconds: 300)); - session.emit(_event(id: 'unrelated', createdAt: 1000)); + session.emit(_event(id: threadIdUnrelated, createdAt: 1000)); final entries = buildMainTimelineEntries( formatTimeline( container.read(channelMessagesProvider(_channelId)).value!, @@ -2201,7 +2281,7 @@ void main() { ); expect( entries - .singleWhere((entry) => entry.message.id == 'root') + .singleWhere((entry) => entry.message.id == threadIdRoot) .summary ?.replyCount, 600, @@ -2211,7 +2291,7 @@ void main() { await container.read(threadRepliesProvider(args).future), hasLength(600), ); - expect(notifier.threadSummaries['root']?.isLowerBound, isFalse); + expect(notifier.threadSummaries[threadIdRoot]?.isLowerBound, isFalse); reopened.close(); }, ); @@ -2224,7 +2304,7 @@ void main() { queryResults: [ [ for (var root = 2; root >= 0; root--) - _event(id: 'root-$root', createdAt: 10 + root), + _event(id: indexedThreadId('root-$root'), createdAt: 10 + root), _bounds(), ], ...gates.map((gate) => gate.future), @@ -2240,7 +2320,7 @@ void main() { id: '$root-$i', createdAt: 20 + i, extraTags: [ - ['e', 'root-$root', '', 'reply'], + ['e', indexedThreadId('root-$root'), '', 'reply'], ], ), ); @@ -2281,14 +2361,14 @@ void main() { final replies = [ for (var i = 0; i < 300; i++) _event( - id: 'reply-$i', + id: indexedThreadId('reply-$i'), createdAt: 20 + i, extraTags: const [ - ['e', 'root', '', 'reply'], + ['e', threadIdRoot, '', 'reply'], ], ), ]; - final window = [_event(id: 'root', createdAt: 10), _bounds()]; + final window = [_event(id: threadIdRoot, createdAt: 10), _bounds()]; final session = _RecordingRelaySessionNotifier( queryResults: [ window, @@ -2315,8 +2395,8 @@ void main() { await _pumpEventQueue(); oldScan.complete(replies.take(200).toList()); await Future.delayed(const Duration(milliseconds: 300)); - expect(notifier.threadSummaries['root']?.descendantCount, 300); - expect(notifier.threadSummaries['root']?.isLowerBound, isFalse); + expect(notifier.threadSummaries[threadIdRoot]?.descendantCount, 300); + expect(notifier.threadSummaries[threadIdRoot]?.isLowerBound, isFalse); expect( session.queryFilters.where( (filter) => filter.extensions.containsKey('depth_limit'), @@ -2330,16 +2410,16 @@ void main() { final replies = [ for (var i = 0; i < 301; i++) _event( - id: 'reply-$i', + id: indexedThreadId('reply-$i'), createdAt: 20 + i, extraTags: const [ - ['e', 'root', '', 'reply'], + ['e', threadIdRoot, '', 'reply'], ], ), ]; final session = _RecordingRelaySessionNotifier( queryResults: [ - [_event(id: 'root', createdAt: 10), _bounds()], + [_event(id: threadIdRoot, createdAt: 10), _bounds()], for (var i = 0; i < 3; i++) Exception('recount unavailable'), replies.take(200).toList(), replies.skip(200).toList(), @@ -2362,17 +2442,17 @@ void main() { ), hasLength(3), ); - expect(notifier.threadSummaries['root']?.isLowerBound, isTrue); + expect(notifier.threadSummaries[threadIdRoot]?.isLowerBound, isTrue); session.emit(replies.last); await Future.delayed(const Duration(milliseconds: 300)); - expect(notifier.threadSummaries['root']?.descendantCount, 301); - expect(notifier.threadSummaries['root']?.isLowerBound, isFalse); + expect(notifier.threadSummaries[threadIdRoot]?.descendantCount, 301); + expect(notifier.threadSummaries[threadIdRoot]?.isLowerBound, isFalse); }); test('a fresh relay summary cancels the queued overflow scan', () async { final session = _RecordingRelaySessionNotifier( queryResults: [ - [_event(id: 'root', createdAt: 10), _bounds()], + [_event(id: threadIdRoot, createdAt: 10), _bounds()], ], ); final container = _buildContainer(session); @@ -2382,19 +2462,21 @@ void main() { for (var i = 0; i < 300; i++) { session.emit( _event( - id: 'reply-$i', + id: indexedThreadId('reply-$i'), createdAt: 20 + i, extraTags: const [ - ['e', 'root', '', 'reply'], + ['e', threadIdRoot, '', 'reply'], ], ), ); } - session.emit(_summary(rootId: 'root', replyCount: 300, createdAt: 400)); + session.emit( + _summary(rootId: threadIdRoot, replyCount: 300, createdAt: 400), + ); await Future.delayed(const Duration(milliseconds: 300)); final summary = container .read(channelMessagesProvider(_channelId).notifier) - .threadSummaries['root']; + .threadSummaries[threadIdRoot]; expect(summary?.descendantCount, 300); expect(summary?.isLowerBound, isFalse); expect( @@ -2409,16 +2491,16 @@ void main() { final replies = [ for (var i = 0; i < 300; i++) _event( - id: 'reply-$i', + id: indexedThreadId('reply-$i'), createdAt: 20 + i, extraTags: const [ - ['e', 'root', '', 'reply'], + ['e', threadIdRoot, '', 'reply'], ], ), ]; final session = _RecordingRelaySessionNotifier( queryResults: [ - [_event(id: 'root', createdAt: 10), _bounds()], + [_event(id: threadIdRoot, createdAt: 10), _bounds()], replies.take(200).toList(), replies.skip(200).toList(), ], @@ -2448,7 +2530,7 @@ void main() { await Future.delayed(const Duration(milliseconds: 300)); final summary = container .read(channelMessagesProvider(_channelId).notifier) - .threadSummaries['root']; + .threadSummaries[threadIdRoot]; expect(summary?.descendantCount, 300); expect(summary?.isLowerBound, isFalse); resumed.close(); @@ -2459,16 +2541,16 @@ void main() { final replies = [ for (var i = 0; i < 400; i++) _event( - id: 'reply-$i', + id: indexedThreadId('reply-$i'), createdAt: 20 + i, extraTags: const [ - ['e', 'root', '', 'reply'], + ['e', threadIdRoot, '', 'reply'], ], ), ]; final session = _RecordingRelaySessionNotifier( queryResults: [ - [_event(id: 'root', createdAt: 10), _bounds()], + [_event(id: threadIdRoot, createdAt: 10), _bounds()], oldScan.future, replies.take(200).toList(), replies.skip(200).toList(), @@ -2483,7 +2565,7 @@ void main() { session.emit(reply); } await Future.delayed(const Duration(milliseconds: 300)); - const args = ThreadRepliesArgs(channelId: _channelId, rootId: 'root'); + const args = ThreadRepliesArgs(channelId: _channelId, rootId: threadIdRoot); container.listen(threadRepliesProvider(args), (_, _) {}); expect( await container.read(threadRepliesProvider(args).future), @@ -2493,7 +2575,7 @@ void main() { await _pumpEventQueue(); final summary = container .read(channelMessagesProvider(_channelId).notifier) - .threadSummaries['root']; + .threadSummaries[threadIdRoot]; expect(summary?.descendantCount, 400); expect(summary?.isLowerBound, isFalse); }); @@ -2506,17 +2588,17 @@ void main() { final replies = [ for (var i = 0; i < count; i++) _event( - id: 'reply-$i', + id: indexedThreadId('reply-$i'), createdAt: 20 + i, extraTags: const [ - ['e', 'root', '', 'reply'], + ['e', threadIdRoot, '', 'reply'], ], ), ]; final remaining = replies.skip(1).toList(); final session = _RecordingRelaySessionNotifier( queryResults: [ - [_event(id: 'root', createdAt: 10), _bounds()], + [_event(id: threadIdRoot, createdAt: 10), _bounds()], for (var i = 0; i < remaining.length; i += 200) remaining.skip(i).take(200).toList(), if (remaining.length % 200 == 0) [], @@ -2529,12 +2611,15 @@ void main() { final notifier = container.read( channelMessagesProvider(_channelId).notifier, ); - notifier.cacheCompleteThreadQuery('root', {}, replies); - expect(notifier.threadSummaries['root']?.descendantCount, count); + notifier.cacheCompleteThreadQuery(threadIdRoot, {}, replies); + expect( + notifier.threadSummaries[threadIdRoot]?.descendantCount, + count, + ); if (count == 600) { expect( - notifier.cachedThreadReplyIds('root'), - isNot(contains('reply-0')), + notifier.cachedThreadReplyIds(threadIdRoot), + isNot(contains(threadIdReplyZero)), ); } final deletion = NostrEvent( @@ -2544,7 +2629,7 @@ void main() { kind: EventKind.deletion, tags: const [ ['h', _channelId], - ['e', 'reply-0'], + ['e', threadIdReplyZero], ], content: '', sig: '', @@ -2554,12 +2639,21 @@ void main() { } else { session.emit(deletion); } - expect(notifier.threadSummaries['root']?.descendantCount, count - 1); - expect(notifier.threadSummaries['root']?.isCountPending, isFalse); - expect(notifier.threadSummaries['root']?.isLowerBound, isTrue); + expect( + notifier.threadSummaries[threadIdRoot]?.descendantCount, + count - 1, + ); + expect( + notifier.threadSummaries[threadIdRoot]?.isCountPending, + isFalse, + ); + expect(notifier.threadSummaries[threadIdRoot]?.isLowerBound, isTrue); await Future.delayed(const Duration(milliseconds: 300)); - expect(notifier.threadSummaries['root']?.descendantCount, count - 1); - expect(notifier.threadSummaries['root']?.isLowerBound, isFalse); + expect( + notifier.threadSummaries[threadIdRoot]?.descendantCount, + count - 1, + ); + expect(notifier.threadSummaries[threadIdRoot]?.isLowerBound, isFalse); final entries = buildMainTimelineEntries( formatTimeline( container.read(channelMessagesProvider(_channelId)).value!, @@ -2568,7 +2662,7 @@ void main() { ); expect( entries - .singleWhere((entry) => entry.message.id == 'root') + .singleWhere((entry) => entry.message.id == threadIdRoot) .summary ?.replyCount, count == 1 ? null : count - 1, @@ -2582,7 +2676,7 @@ void main() { test( 'reconnect summary replaces queried count with $refreshedCount', () async { - final root = _event(id: 'root', createdAt: 10); + final root = _event(id: threadIdRoot, createdAt: 10); final session = _RecordingRelaySessionNotifier( queryResults: [ [root, _bounds()], @@ -2590,7 +2684,7 @@ void main() { root, if (refreshedCount >= 0) _summary( - rootId: 'root', + rootId: threadIdRoot, replyCount: refreshedCount, createdAt: 1000, ), @@ -2606,13 +2700,13 @@ void main() { final notifier = container.read( channelMessagesProvider(_channelId).notifier, ); - notifier.cacheCompleteThreadQuery('root', {}, [ + notifier.cacheCompleteThreadQuery(threadIdRoot, {}, [ for (var i = 0; i < 600; i++) _event( - id: 'reply-$i', + id: indexedThreadId('reply-$i'), createdAt: 20 + i, extraTags: const [ - ['e', 'root', '', 'reply'], + ['e', threadIdRoot, '', 'reply'], ], ), ]); @@ -2622,10 +2716,10 @@ void main() { await _pumpEventQueue(); await Future.delayed(const Duration(milliseconds: 300)); expect( - notifier.threadSummaries['root']?.descendantCount, + notifier.threadSummaries[threadIdRoot]?.descendantCount, refreshedCount < 0 ? 0 : refreshedCount, ); - expect(notifier.threadSummaries['root']?.isLowerBound, isFalse); + expect(notifier.threadSummaries[threadIdRoot]?.isLowerBound, isFalse); final entries = buildMainTimelineEntries( formatTimeline( container.read(channelMessagesProvider(_channelId)).value!, @@ -2634,7 +2728,7 @@ void main() { ); expect( entries - .singleWhere((entry) => entry.message.id == 'root') + .singleWhere((entry) => entry.message.id == threadIdRoot) .summary ?.replyCount, refreshedCount <= 0 ? null : refreshedCount, @@ -2649,8 +2743,8 @@ void main() { final session = _RecordingRelaySessionNotifier( queryResults: [ [ - _event(id: 'unrelated', createdAt: 10), - _summary(rootId: 'unrelated', replyCount: 1), + _event(id: threadIdUnrelated, createdAt: 10), + _summary(rootId: threadIdUnrelated, replyCount: 1), _bounds(), ], Exception('recount unavailable'), @@ -2678,7 +2772,7 @@ void main() { final notifier = container.read( channelMessagesProvider(_channelId).notifier, ); - final summary = notifier.threadSummaries['unrelated']; + final summary = notifier.threadSummaries[threadIdUnrelated]; expect(summary?.descendantCount, 1); expect(summary?.isCountPending, isTrue); final entries = buildMainTimelineEntries( @@ -2703,9 +2797,9 @@ void main() { _bounds(hasMore: true, cursorCreatedAt: 100, cursorId: 'newest'), ], [ - _event(id: 'old-root', createdAt: 10), + _event(id: threadIdOldDashRoot, createdAt: 10), if (olderCount > 0) - _summary(rootId: 'old-root', replyCount: olderCount), + _summary(rootId: threadIdOldDashRoot, replyCount: olderCount), _bounds(dTag: '${_channelId.toLowerCase()}:100:newest'), ], if (olderCount == 0) [], @@ -2718,23 +2812,28 @@ void main() { final notifier = container.read( channelMessagesProvider(_channelId).notifier, ); - final load = notifier.loadEventsById(['old-root']); - session.completeTargetHistory([_event(id: 'old-root', createdAt: 10)]); + final load = notifier.loadEventsById([threadIdOldDashRoot]); + session.completeTargetHistory([ + _event(id: threadIdOldDashRoot, createdAt: 10), + ]); await load; - notifier.cacheCompleteThreadQuery('old-root', {}, [ + notifier.cacheCompleteThreadQuery(threadIdOldDashRoot, {}, [ _event( id: 'old-reply', createdAt: 20, extraTags: const [ - ['e', 'old-root', '', 'reply'], + ['e', threadIdOldDashRoot, '', 'reply'], ], ), ]); - expect(notifier.threadSummaries['old-root']?.descendantCount, 1); + expect( + notifier.threadSummaries[threadIdOldDashRoot]?.descendantCount, + 1, + ); expect(await notifier.fetchOlder(), isTrue); await Future.delayed(const Duration(milliseconds: 300)); expect( - notifier.threadSummaries['old-root']?.descendantCount, + notifier.threadSummaries[threadIdOldDashRoot]?.descendantCount, olderCount, ); final entries = buildMainTimelineEntries( @@ -2745,7 +2844,7 @@ void main() { ); expect( entries - .singleWhere((entry) => entry.message.id == 'old-root') + .singleWhere((entry) => entry.message.id == threadIdOldDashRoot) .summary ?.replyCount, olderCount == 0 ? null : olderCount, @@ -2760,17 +2859,17 @@ void main() { final replies = [ for (var i = 0; i < 603; i++) _event( - id: 'reply-$i', + id: indexedThreadId('reply-$i'), createdAt: 20 + i, extraTags: const [ - ['e', 'root', '', 'reply'], + ['e', threadIdRoot, '', 'reply'], ], ), ]; final gates = [for (var i = 0; i < 4; i++) Completer>()]; final session = _RecordingRelaySessionNotifier( queryResults: [ - [_event(id: 'root', createdAt: 10), _bounds()], + [_event(id: threadIdRoot, createdAt: 10), _bounds()], for (final gate in gates) gate.future, for (var i = 0; i < 603; i += 200) replies.skip(i).take(200).toList(), ], @@ -2801,11 +2900,11 @@ void main() { final notifier = container.read( channelMessagesProvider(_channelId).notifier, ); - expect(notifier.threadSummaries['root']?.descendantCount, 603); - expect(notifier.threadSummaries['root']?.isLowerBound, isTrue); + expect(notifier.threadSummaries[threadIdRoot]?.descendantCount, 603); + expect(notifier.threadSummaries[threadIdRoot]?.isLowerBound, isTrue); await Future.delayed(const Duration(milliseconds: 300)); - expect(notifier.threadSummaries['root']?.descendantCount, 603); - expect(notifier.threadSummaries['root']?.isLowerBound, isFalse); + expect(notifier.threadSummaries[threadIdRoot]?.descendantCount, 603); + expect(notifier.threadSummaries[threadIdRoot]?.isLowerBound, isFalse); expect( session.queryFilters.where( (filter) => filter.extensions.containsKey('depth_limit'), @@ -2838,8 +2937,10 @@ void main() { final notifier = container.read( channelMessagesProvider(_channelId).notifier, ); - final load = notifier.loadEventsById(['old-root']); - session.completeTargetHistory([_event(id: 'old-root', createdAt: 10)]); + final load = notifier.loadEventsById([threadIdOldDashRoot]); + session.completeTargetHistory([ + _event(id: threadIdOldDashRoot, createdAt: 10), + ]); await load; Future? older; if (newest) { @@ -2852,13 +2953,13 @@ void main() { } const args = ThreadRepliesArgs( channelId: _channelId, - rootId: 'old-root', + rootId: threadIdOldDashRoot, ); container.listen(threadRepliesProvider(args), (_, _) {}); final thread = container.read(threadRepliesProvider(args).future); await _pumpEventQueue(); page.complete([ - _event(id: 'old-root', createdAt: 10), + _event(id: threadIdOldDashRoot, createdAt: 10), _bounds( dTag: newest ? '${_channelId.toLowerCase()}:head' @@ -2872,7 +2973,7 @@ void main() { id: 'reply', createdAt: 20, extraTags: const [ - ['e', 'old-root', '', 'reply'], + ['e', threadIdOldDashRoot, '', 'reply'], ], ), ]); @@ -2881,7 +2982,10 @@ void main() { session.historyFilters.where((filter) => filter.ids == null), isEmpty, ); - expect(notifier.threadSummaries['old-root']?.descendantCount, 1); + expect( + notifier.threadSummaries[threadIdOldDashRoot]?.descendantCount, + 1, + ); }, ); } @@ -2893,8 +2997,8 @@ void main() { queryResults: [ [ for (var i = 39; i >= 0; i--) ...[ - _event(id: 'root-$i', createdAt: 10 + i), - _summary(rootId: 'root-$i', replyCount: 1), + _event(id: indexedThreadId('root-$i'), createdAt: 10 + i), + _summary(rootId: indexedThreadId('root-$i'), replyCount: 1), ], _bounds(), ], @@ -2953,7 +3057,7 @@ void main() { final ownership = Completer>(); final session = _RecordingRelaySessionNotifier( queryResults: [ - [_event(id: 'root', createdAt: 10), _bounds()], + [_event(id: threadIdRoot, createdAt: 10), _bounds()], ownership.future, [], ], @@ -2965,12 +3069,12 @@ void main() { final notifier = container.read( channelMessagesProvider(_channelId).notifier, ); - notifier.cacheCompleteThreadQuery('root', {}, [ + notifier.cacheCompleteThreadQuery(threadIdRoot, {}, [ _event( id: 'known', createdAt: 20, extraTags: const [ - ['e', 'root', '', 'reply'], + ['e', threadIdRoot, '', 'reply'], ], ), ]); @@ -2990,9 +3094,9 @@ void main() { ), ); } - ownership.complete([_summary(rootId: 'root', replyCount: 1)]); + ownership.complete([_summary(rootId: threadIdRoot, replyCount: 1)]); await _pumpEventQueue(); - expect(notifier.threadSummaries['root']!.descendantCount, 0); + expect(notifier.threadSummaries[threadIdRoot]!.descendantCount, 0); await Future.delayed(const Duration(milliseconds: 300)); expect( session.queryFilters.where( @@ -3007,7 +3111,7 @@ void main() { relaySummaries: notifier.threadSummaries, ); expect( - entries.singleWhere((e) => e.message.id == 'root').summary, + entries.singleWhere((e) => e.message.id == threadIdRoot).summary, isNull, ); }, @@ -3019,16 +3123,16 @@ void main() { final replies = [ for (var i = 0; i < (source == 'overflow' ? 257 : 1); i++) _event( - id: 'reply-$i', + id: indexedThreadId('reply-$i'), createdAt: 20 + i, extraTags: const [ - ['e', 'root', '', 'reply'], + ['e', threadIdRoot, '', 'reply'], ], ), ]; final session = _RecordingRelaySessionNotifier( queryResults: [ - [_event(id: 'root', createdAt: 10), _bounds()], + [_event(id: threadIdRoot, createdAt: 10), _bounds()], if (source == 'fallback') Exception('NIP-CW unavailable'), Exception('temporary recount outage'), if (outcome == 'recovered') ...[ @@ -3043,7 +3147,7 @@ void main() { ], ], historyResults: [ - [_event(id: 'root', createdAt: 10)], + [_event(id: threadIdRoot, createdAt: 10)], ], ); final container = _buildContainer(session); @@ -3079,7 +3183,7 @@ void main() { : 2, ); if (outcome == 'disposed') return; - final summary = notifier.threadSummaries['root']!; + final summary = notifier.threadSummaries[threadIdRoot]!; if (outcome == 'exhausted') { expect(summary.isLowerBound, isTrue); } else { @@ -3108,7 +3212,7 @@ void main() { final route = Completer>(); final session = _RecordingRelaySessionNotifier( queryResults: [ - [_event(id: 'root', createdAt: 10), _bounds()], + [_event(id: threadIdRoot, createdAt: 10), _bounds()], page.future, route.future, [], @@ -3124,12 +3228,12 @@ void main() { final notifier = container.read( channelMessagesProvider(_channelId).notifier, ); - notifier.cacheCompleteThreadQuery('root', {}, [ + notifier.cacheCompleteThreadQuery(threadIdRoot, {}, [ _event( id: 'deleted-offline', createdAt: 20, extraTags: const [ - ['e', 'root', '', 'reply'], + ['e', threadIdRoot, '', 'reply'], ], ), ]); @@ -3137,7 +3241,10 @@ void main() { await _pumpEventQueue(); session.setConnected(true); await _pumpEventQueue(); - const args = ThreadRepliesArgs(channelId: _channelId, rootId: 'root'); + const args = ThreadRepliesArgs( + channelId: _channelId, + rootId: threadIdRoot, + ); container.listen(threadRepliesProvider(args), (_, _) {}); final failure = expectLater( container.read(threadRepliesProvider(args).future), @@ -3148,7 +3255,7 @@ void main() { route.completeError(Exception('route query failed')); await failure; } - page.complete([_event(id: 'root', createdAt: 10), _bounds()]); + page.complete([_event(id: threadIdRoot, createdAt: 10), _bounds()]); await _pumpEventQueue(); if (!failureFirst) { route.completeError(Exception('route query failed')); @@ -3162,7 +3269,7 @@ void main() { relaySummaries: notifier.threadSummaries, ); expect(entries.single.summary, isNull); - expect(notifier.cachedThreadReplyIds('root'), isEmpty); + expect(notifier.cachedThreadReplyIds(threadIdRoot), isEmpty); }, ); } @@ -3175,12 +3282,12 @@ void main() { id: 'old-reply', createdAt: 20, extraTags: const [ - ['e', 'retained', '', 'reply'], + ['e', threadIdRetained, '', 'reply'], ], ); final session = _RecordingRelaySessionNotifier( queryResults: [ - [_event(id: 'retained', createdAt: 10), _bounds()], + [_event(id: threadIdRetained, createdAt: 10), _bounds()], [_event(id: 'newest', createdAt: 200), _bounds()], [if (survives) reply], ], @@ -3192,8 +3299,8 @@ void main() { final notifier = container.read( channelMessagesProvider(_channelId).notifier, ); - await notifier.loadEventsById(['retained']); - notifier.cacheCompleteThreadQuery('retained', {}, [reply]); + await notifier.loadEventsById([threadIdRetained]); + notifier.cacheCompleteThreadQuery(threadIdRetained, {}, [reply]); session.setConnected(false); await _pumpEventQueue(); session.setConnected(true); @@ -3205,12 +3312,15 @@ void main() { hasLength(1), ); expect( - notifier.threadSummaries['retained']?.descendantCount, + notifier.threadSummaries[threadIdRetained]?.descendantCount, survives ? 1 : 0, ); - expect(notifier.threadSummaries['retained']?.isCountPending, isFalse); expect( - notifier.cachedThreadReplyIds('retained'), + notifier.threadSummaries[threadIdRetained]?.isCountPending, + isFalse, + ); + expect( + notifier.cachedThreadReplyIds(threadIdRetained), survives ? {'old-reply'} : isEmpty, ); }, @@ -3224,8 +3334,8 @@ void main() { final session = _RecordingRelaySessionNotifier( queryResults: [ [ - _event(id: 'retained', createdAt: 10), - _summary(rootId: 'retained', replyCount: 1), + _event(id: threadIdRetained, createdAt: 10), + _summary(rootId: threadIdRetained, replyCount: 1), _bounds(), ], if (phase == 'backoff') @@ -3240,7 +3350,7 @@ void main() { Exception('unavailable'), Exception('unavailable'), ] else - [_summary(rootId: 'retained', replyCount: 0)], + [_summary(rootId: threadIdRetained, replyCount: 0)], if (phase != 'exhausted') [], ], ); @@ -3251,14 +3361,14 @@ void main() { final notifier = container.read( channelMessagesProvider(_channelId).notifier, ); - await notifier.loadEventsById(['retained']); - notifier.cacheCompleteThreadQuery('retained', {}, [ + await notifier.loadEventsById([threadIdRetained]); + notifier.cacheCompleteThreadQuery(threadIdRetained, {}, [ for (var i = 0; i < 257; i++) _event( id: 'old-reply-$i', createdAt: 20 + i, extraTags: const [ - ['e', 'retained', '', 'reply'], + ['e', threadIdRetained, '', 'reply'], ], ), ]); @@ -3313,7 +3423,9 @@ void main() { ), relaySummaries: notifier.threadSummaries, ); - final retained = entries.singleWhere((e) => e.message.id == 'retained'); + final retained = entries.singleWhere( + (e) => e.message.id == threadIdRetained, + ); if (phase == 'exhausted') { expect(retained.summary!.isCountPending, isTrue); } else { @@ -3335,8 +3447,8 @@ void main() { final session = _RecordingRelaySessionNotifier( queryResults: [ [ - _event(id: 'root', createdAt: 10), - _summary(rootId: 'root', replyCount: 1), + _event(id: threadIdRoot, createdAt: 10), + _summary(rootId: threadIdRoot, replyCount: 1), _bounds(), ], ownership.future, @@ -3367,13 +3479,16 @@ void main() { sig: '', ), ); - const args = ThreadRepliesArgs(channelId: _channelId, rootId: 'root'); + const args = ThreadRepliesArgs( + channelId: _channelId, + rootId: threadIdRoot, + ); container.listen(threadRepliesProvider(args), (_, _) {}); final result = container.read(threadRepliesProvider(args).future); await _pumpEventQueue(); - ownership.complete([_summary(rootId: 'root', replyCount: 0)]); + ownership.complete([_summary(rootId: threadIdRoot, replyCount: 0)]); await _pumpEventQueue(); - expect(notifier.threadSummaries['root']!.isCountPending, isTrue); + expect(notifier.threadSummaries[threadIdRoot]!.isCountPending, isTrue); if (querySucceeds) { threadQuery.complete([]); await result; @@ -3384,7 +3499,7 @@ void main() { } await _pumpEventQueue(); expect( - notifier.threadSummaries['root']!.isCountPending, + notifier.threadSummaries[threadIdRoot]!.isCountPending, !querySucceeds, ); final entries = buildMainTimelineEntries( @@ -3407,13 +3522,13 @@ void main() { final session = _RecordingRelaySessionNotifier( queryResults: [ [ - _event(id: 'root', createdAt: 10), - _summary(rootId: 'root', replyCount: 1), + _event(id: threadIdRoot, createdAt: 10), + _summary(rootId: threadIdRoot, replyCount: 1), _bounds(), ], Exception('temporary outage'), if (outcome == 'recovered') - [_summary(rootId: 'root', replyCount: 0)] + [_summary(rootId: threadIdRoot, replyCount: 0)] else ...[ Exception('still unavailable'), Exception('still unavailable'), @@ -3445,7 +3560,7 @@ void main() { .where((f) => f.extensions['resolve_thread_roots'] == true) .length; expect(requests(), 1); - expect(notifier.threadSummaries['root']!.isCountPending, isTrue); + expect(notifier.threadSummaries[threadIdRoot]!.isCountPending, isTrue); // A replay is deduplicated, but must not prevent the scheduled recovery. session.emit(deletion); if (outcome == 'disposed') container.dispose(); @@ -3465,9 +3580,9 @@ void main() { relaySummaries: notifier.threadSummaries, ); expect(entries.single.summary, isNull); - expect(notifier.threadSummaries['root']!.isCountPending, isFalse); + expect(notifier.threadSummaries[threadIdRoot]!.isCountPending, isFalse); } else if (outcome == 'exhausted') { - expect(notifier.threadSummaries['root']!.isCountPending, isTrue); + expect(notifier.threadSummaries[threadIdRoot]!.isCountPending, isTrue); } }); } @@ -3481,15 +3596,15 @@ void main() { final session = _RecordingRelaySessionNotifier( queryResults: [ [ - _event(id: 'root', createdAt: 10), - _summary(rootId: 'root', replyCount: 1), + _event(id: threadIdRoot, createdAt: 10), + _summary(rootId: threadIdRoot, replyCount: 1), _bounds(), ], first.future, second.future, for (var i = 2; i < count; i++) i == count - 1 - ? [_summary(rootId: 'root', replyCount: 0)] + ? [_summary(rootId: threadIdRoot, replyCount: 0)] : [], ], ); @@ -3523,7 +3638,7 @@ void main() { await _pumpEventQueue(); expect(requests(), count); if (count == 150) { - expect(notifier.threadSummaries['root']!.descendantCount, 0); + expect(notifier.threadSummaries[threadIdRoot]!.descendantCount, 0); final entries = buildMainTimelineEntries( formatTimeline( container.read(channelMessagesProvider(_channelId)).value!, @@ -3532,8 +3647,11 @@ void main() { ); expect(entries.single.summary, isNull); } else { - expect(notifier.threadSummaries['root']!.descendantCount, 0); - expect(notifier.threadSummaries['root']!.isCountPending, isFalse); + expect(notifier.threadSummaries[threadIdRoot]!.descendantCount, 0); + expect( + notifier.threadSummaries[threadIdRoot]!.isCountPending, + isFalse, + ); session.emit(deletion); await _pumpEventQueue(); expect( @@ -3552,14 +3670,14 @@ void main() { final session = _RecordingRelaySessionNotifier( queryResults: [ [ - _event(id: 'root', createdAt: 10), - _summary(rootId: 'root', replyCount: 1), + _event(id: threadIdRoot, createdAt: 10), + _summary(rootId: threadIdRoot, replyCount: 1), _bounds(), ], first.future, second.future, for (var i = 2; i < 258; i++) [], - [_summary(rootId: 'root', replyCount: 0)], + [_summary(rootId: threadIdRoot, replyCount: 0)], ], ); final container = _buildContainer(session); @@ -3580,7 +3698,7 @@ void main() { sig: '', ), ); - session.emit(_summary(rootId: 'root', replyCount: 1)); + session.emit(_summary(rootId: threadIdRoot, replyCount: 1)); first.complete([]); second.complete([]); await _pumpEventQueue(); @@ -3592,7 +3710,7 @@ void main() { ); final summary = container .read(channelMessagesProvider(_channelId).notifier) - .threadSummaries['root']!; + .threadSummaries[threadIdRoot]!; expect(summary.descendantCount, 1); expect(summary.isCountPending, isFalse); }); @@ -3606,8 +3724,8 @@ void main() { final session = _RecordingRelaySessionNotifier( queryResults: [ [ - _event(id: 'root', createdAt: 10), - _summary(rootId: 'root', replyCount: 1), + _event(id: threadIdRoot, createdAt: 10), + _summary(rootId: threadIdRoot, replyCount: 1), _bounds(), ], first.future, @@ -3657,9 +3775,15 @@ void main() { reason: 'deferred targets drain only while mounted', ); if (!disposeEarly) { - expect(notifier.threadSummaries['root']!.isCountPending, isTrue); - session.emit(_summary(rootId: 'root', replyCount: 1)); - expect(notifier.threadSummaries['root']!.isCountPending, isFalse); + expect( + notifier.threadSummaries[threadIdRoot]!.isCountPending, + isTrue, + ); + session.emit(_summary(rootId: threadIdRoot, replyCount: 1)); + expect( + notifier.threadSummaries[threadIdRoot]!.isCountPending, + isFalse, + ); } }, ); @@ -3674,7 +3798,7 @@ void main() { final session = _RecordingRelaySessionNotifier( queryResults: [ [ - for (final id in ['owner', 'unrelated']) ...[ + for (final id in [threadIdOwner, threadIdUnrelated]) ...[ _event(id: id, createdAt: 10), _summary(rootId: id, replyCount: 2), ], @@ -3708,25 +3832,40 @@ void main() { ); } await _pumpEventQueue(); - expect(notifier.threadSummaries['unrelated']!.isCountPending, isTrue); + expect( + notifier.threadSummaries[threadIdUnrelated]!.isCountPending, + isTrue, + ); if (newestFirst) { - second.complete([_summary(rootId: 'owner', replyCount: 0)]); + second.complete([_summary(rootId: threadIdOwner, replyCount: 0)]); } else { - first.complete([_summary(rootId: 'owner', replyCount: 1)]); + first.complete([_summary(rootId: threadIdOwner, replyCount: 1)]); } await _pumpEventQueue(); - expect(notifier.threadSummaries['unrelated']!.isCountPending, isTrue); + expect( + notifier.threadSummaries[threadIdUnrelated]!.isCountPending, + isTrue, + ); if (newestFirst) { - first.complete([_summary(rootId: 'owner', replyCount: 1)]); + first.complete([_summary(rootId: threadIdOwner, replyCount: 1)]); } else { - second.complete([_summary(rootId: 'owner', replyCount: 0)]); + second.complete([_summary(rootId: threadIdOwner, replyCount: 0)]); } await _pumpEventQueue(); - expect(notifier.threadSummaries['owner']!.descendantCount, 0); - expect(notifier.threadSummaries['unrelated']!.descendantCount, 2); - expect(notifier.threadSummaries['unrelated']!.isCountPending, isFalse); - expect(notifier.threadSummaries['unrelated']!.isLowerBound, isFalse); - expect(notifier.threadSummaries['owner']!.isCountPending, isFalse); + expect(notifier.threadSummaries[threadIdOwner]!.descendantCount, 0); + expect(notifier.threadSummaries[threadIdUnrelated]!.descendantCount, 2); + expect( + notifier.threadSummaries[threadIdUnrelated]!.isCountPending, + isFalse, + ); + expect( + notifier.threadSummaries[threadIdUnrelated]!.isLowerBound, + isFalse, + ); + expect( + notifier.threadSummaries[threadIdOwner]!.isCountPending, + isFalse, + ); }, ); } @@ -3740,7 +3879,7 @@ void main() { final session = _RecordingRelaySessionNotifier( queryResults: [ [ - for (final id in ['owner', 'unrelated']) ...[ + for (final id in [threadIdOwner, threadIdUnrelated]) ...[ _event(id: id, createdAt: 10), _summary(rootId: id, replyCount: 2), ], @@ -3773,25 +3912,27 @@ void main() { ), ); await _pumpEventQueue(); - first.complete([_summary(rootId: 'owner', replyCount: 1)]); + first.complete([_summary(rootId: threadIdOwner, replyCount: 1)]); await _pumpEventQueue(); expect( - notifier.threadSummaries['unrelated']!.isCountPending, + notifier.threadSummaries[threadIdUnrelated]!.isCountPending, isTrue, reason: 'the queued sibling still has unresolved ownership', ); - expect(notifier.threadSummaries['owner']!.isCountPending, isTrue); + expect(notifier.threadSummaries[threadIdOwner]!.isCountPending, isTrue); second.complete( - secondResolves ? [_summary(rootId: 'owner', replyCount: 0)] : [], + secondResolves + ? [_summary(rootId: threadIdOwner, replyCount: 0)] + : [], ); await _pumpEventQueue(); - expect(notifier.threadSummaries['unrelated']!.descendantCount, 2); + expect(notifier.threadSummaries[threadIdUnrelated]!.descendantCount, 2); expect( - notifier.threadSummaries['unrelated']!.isCountPending, + notifier.threadSummaries[threadIdUnrelated]!.isCountPending, !secondResolves, ); expect( - notifier.threadSummaries['owner']!.descendantCount, + notifier.threadSummaries[threadIdOwner]!.descendantCount, secondResolves ? 0 : 1, ); final requests = session.queryFilters.where( @@ -3808,7 +3949,8 @@ void main() { relaySummaries: notifier.threadSummaries, ); expect( - entries.singleWhere((e) => e.message.id == 'owner').summary == null, + entries.singleWhere((e) => e.message.id == threadIdOwner).summary == + null, secondResolves, ); }, @@ -3823,7 +3965,7 @@ void main() { final session = _RecordingRelaySessionNotifier( queryResults: [ [ - for (final id in ['owner', 'unrelated']) ...[ + for (final id in [threadIdOwner, threadIdUnrelated]) ...[ _event(id: id, createdAt: 10), _summary(rootId: id, replyCount: 2), ], @@ -3858,13 +4000,22 @@ void main() { } first.completeError(Exception('ownership unavailable')); await _pumpEventQueue(); - second.complete([_summary(rootId: 'owner', replyCount: 0)]); + second.complete([_summary(rootId: threadIdOwner, replyCount: 0)]); await _pumpEventQueue(); - expect(notifier.threadSummaries['owner']!.descendantCount, 0); - expect(notifier.threadSummaries['unrelated']!.isCountPending, isTrue); - session.emit(_summary(rootId: 'unrelated', replyCount: 2)); - expect(notifier.threadSummaries['unrelated']!.isCountPending, isFalse); - expect(notifier.threadSummaries['unrelated']!.isLowerBound, isFalse); + expect(notifier.threadSummaries[threadIdOwner]!.descendantCount, 0); + expect( + notifier.threadSummaries[threadIdUnrelated]!.isCountPending, + isTrue, + ); + session.emit(_summary(rootId: threadIdUnrelated, replyCount: 2)); + expect( + notifier.threadSummaries[threadIdUnrelated]!.isCountPending, + isFalse, + ); + expect( + notifier.threadSummaries[threadIdUnrelated]!.isLowerBound, + isFalse, + ); }, ); @@ -3873,7 +4024,7 @@ void main() { () async { final session = _RecordingRelaySessionNotifier( queryResults: [ - [_event(id: 'root', createdAt: 10), _bounds()], + [_event(id: threadIdRoot, createdAt: 10), _bounds()], ], ); final container = _buildContainer(session); @@ -3912,16 +4063,16 @@ void main() { () async { final page = Completer>(); final broadcast = _event( - id: 'broadcast', + id: threadIdBroadcast, createdAt: 20, extraTags: const [ - ['e', 'root', '', 'root'], - ['e', 'root', '', 'reply'], + ['e', threadIdRoot, '', 'root'], + ['e', threadIdRoot, '', 'reply'], ['broadcast', '1'], ], ); final outer = _summary( - rootId: 'root', + rootId: threadIdRoot, replyCount: 1, descendantCount: survives ? 2 : 1, ); @@ -3936,8 +4087,8 @@ void main() { id: 'survivor', createdAt: 30, extraTags: const [ - ['e', 'root', '', 'root'], - ['e', 'broadcast', '', 'reply'], + ['e', threadIdRoot, '', 'root'], + ['e', threadIdBroadcast, '', 'reply'], ], ), ], @@ -3978,7 +4129,7 @@ void main() { page.complete([ broadcast, _summary( - rootId: 'broadcast', + rootId: threadIdBroadcast, replyCount: survives ? 2 : 1, descendantCount: 0, ), @@ -3991,14 +4142,14 @@ void main() { final events = container .read(channelMessagesProvider(_channelId)) .value!; - expect(events.any((e) => e.id == 'root'), isFalse); + expect(events.any((e) => e.id == threadIdRoot), isFalse); final entries = buildMainTimelineEntries( formatTimeline(events), relaySummaries: notifier.threadSummaries, ); expect( entries - .singleWhere((e) => e.message.id == 'broadcast') + .singleWhere((e) => e.message.id == threadIdBroadcast) .summary ?.replyCount, survives ? 1 : null, @@ -4017,11 +4168,11 @@ void main() { 'unchanged outer evidence does not repeat an exact nested recount', () async { final broadcast = _event( - id: 'broadcast', + id: threadIdBroadcast, createdAt: 20, extraTags: const [ - ['e', 'root', '', 'root'], - ['e', 'root', '', 'reply'], + ['e', threadIdRoot, '', 'root'], + ['e', threadIdRoot, '', 'reply'], ['broadcast', '1'], ], ); @@ -4029,16 +4180,20 @@ void main() { id: 'child', createdAt: 30, extraTags: const [ - ['e', 'root', '', 'root'], - ['e', 'broadcast', '', 'reply'], + ['e', threadIdRoot, '', 'root'], + ['e', threadIdBroadcast, '', 'reply'], ], ); final session = _RecordingRelaySessionNotifier( queryResults: [ [ broadcast, - _event(id: 'root', createdAt: 10), - _summary(rootId: 'broadcast', replyCount: 1, descendantCount: 0), + _event(id: threadIdRoot, createdAt: 10), + _summary( + rootId: threadIdBroadcast, + replyCount: 1, + descendantCount: 0, + ), _bounds(), ], [broadcast, child], @@ -4051,13 +4206,15 @@ void main() { final notifier = container.read( channelMessagesProvider(_channelId).notifier, ); - session.emit(_summary(rootId: 'root', replyCount: 1, descendantCount: 2)); + session.emit( + _summary(rootId: threadIdRoot, replyCount: 1, descendantCount: 2), + ); await Future.delayed(const Duration(milliseconds: 350)); - expect(notifier.threadSummaries['broadcast']?.replyCount, 1); + expect(notifier.threadSummaries[threadIdBroadcast]?.replyCount, 1); for (var i = 0; i < 3; i++) { session.emit( _summary( - rootId: 'root', + rootId: threadIdRoot, replyCount: 1, descendantCount: 2, createdAt: 40 + i, @@ -4071,7 +4228,10 @@ void main() { ), hasLength(1), ); - expect(notifier.threadSummaries['broadcast']?.isCountPending, isFalse); + expect( + notifier.threadSummaries[threadIdBroadcast]?.isCountPending, + isFalse, + ); }, ); @@ -4083,15 +4243,19 @@ void main() { queryResults: [ [ _event( - id: 'broadcast', + id: threadIdBroadcast, createdAt: 20, extraTags: const [ - ['e', 'root', '', 'root'], - ['e', 'root', '', 'reply'], + ['e', threadIdRoot, '', 'root'], + ['e', threadIdRoot, '', 'reply'], ['broadcast', '1'], ], ), - _summary(rootId: 'broadcast', replyCount: 1, descendantCount: 0), + _summary( + rootId: threadIdBroadcast, + replyCount: 1, + descendantCount: 0, + ), _bounds(), ], if (lookupFails) @@ -4121,7 +4285,10 @@ void main() { sig: 'sig', ), ); - expect(notifier.threadSummaries['broadcast']?.isCountPending, isTrue); + expect( + notifier.threadSummaries[threadIdBroadcast]?.isCountPending, + isTrue, + ); await Future.delayed( Duration(milliseconds: lookupFails ? 1800 : 50), ); @@ -4156,11 +4323,11 @@ void main() { 'nested deletion reconciles broadcast summary (root visible: $rootVisible, source: $source, survives: $survives)', () async { final broadcast = _event( - id: 'broadcast', + id: threadIdBroadcast, createdAt: 20, extraTags: const [ - ['e', 'root', '', 'root'], - ['e', 'root', '', 'reply'], + ['e', threadIdRoot, '', 'root'], + ['e', threadIdRoot, '', 'reply'], ['broadcast', '1'], ], ); @@ -4168,30 +4335,35 @@ void main() { id: id, createdAt: 30, extraTags: const [ - ['e', 'root', '', 'root'], - ['e', 'broadcast', '', 'reply'], + ['e', threadIdRoot, '', 'root'], + ['e', threadIdBroadcast, '', 'reply'], ], ); final session = _RecordingRelaySessionNotifier( queryResults: [ [ broadcast, - if (rootVisible) _event(id: 'root', createdAt: 10), + if (rootVisible) _event(id: threadIdRoot, createdAt: 10), if (rootVisible) _summary( - rootId: 'root', + rootId: threadIdRoot, replyCount: 1, descendantCount: survives ? 3 : 2, ), _summary( - rootId: 'broadcast', + rootId: threadIdBroadcast, replyCount: survives ? 2 : 1, descendantCount: 0, ), _bounds(), ], if (source == 'owner') - [_summary(rootId: 'root', replyCount: survives ? 2 : 1)], + [ + _summary( + rootId: threadIdRoot, + replyCount: survives ? 2 : 1, + ), + ], [broadcast, if (survives) child('survivor')], ], ); @@ -4209,13 +4381,13 @@ void main() { container .read(channelMessagesProvider(_channelId)) .value! - .any((e) => e.id == 'root'), + .any((e) => e.id == threadIdRoot), rootVisible, ); session.emit( source == 'live' ? _summary( - rootId: 'root', + rootId: threadIdRoot, replyCount: 1, descendantCount: survives ? 2 : 1, ) @@ -4247,7 +4419,7 @@ void main() { ); expect( entries - .singleWhere((e) => e.message.id == 'broadcast') + .singleWhere((e) => e.message.id == threadIdBroadcast) .summary ?.replyCount, survives ? 1 : null, @@ -4262,11 +4434,11 @@ void main() { test('nested broadcast recount remains honest (fails: $fails)', () async { final recount = Completer>(); final broadcast = _event( - id: 'broadcast', + id: threadIdBroadcast, createdAt: 20, extraTags: const [ - ['e', 'root', '', 'root'], - ['e', 'root', '', 'reply'], + ['e', threadIdRoot, '', 'root'], + ['e', threadIdRoot, '', 'reply'], ['broadcast', '1'], ], ); @@ -4274,9 +4446,13 @@ void main() { queryResults: [ [ broadcast, - _event(id: 'root', createdAt: 10), - _summary(rootId: 'root', replyCount: 1, descendantCount: 2), - _summary(rootId: 'broadcast', replyCount: 1, descendantCount: 0), + _event(id: threadIdRoot, createdAt: 10), + _summary(rootId: threadIdRoot, replyCount: 1, descendantCount: 2), + _summary( + rootId: threadIdBroadcast, + replyCount: 1, + descendantCount: 0, + ), _bounds(), ], recount.future, @@ -4291,9 +4467,14 @@ void main() { channelMessagesProvider(_channelId).notifier, ); // The deleted child was evicted. Only the outer-root update is delivered. - session.emit(_summary(rootId: 'root', replyCount: 1, descendantCount: 1)); - expect(notifier.threadSummaries['broadcast']!.isCountPending, isTrue); - expect(notifier.threadSummaries['broadcast']!.isLowerBound, isTrue); + session.emit( + _summary(rootId: threadIdRoot, replyCount: 1, descendantCount: 1), + ); + expect( + notifier.threadSummaries[threadIdBroadcast]!.isCountPending, + isTrue, + ); + expect(notifier.threadSummaries[threadIdBroadcast]!.isLowerBound, isTrue); await Future.delayed(const Duration(milliseconds: 300)); if (fails) { recount.completeError(Exception('offline')); @@ -4308,14 +4489,17 @@ void main() { relaySummaries: notifier.threadSummaries, ); final summary = entries - .singleWhere((e) => e.message.id == 'broadcast') + .singleWhere((e) => e.message.id == threadIdBroadcast) .summary; if (fails) { expect(summary!.isCountPending, isTrue); expect(summary.isLowerBound, isTrue); } else { expect(summary, isNull); - expect(notifier.threadSummaries['broadcast']!.isCountPending, isFalse); + expect( + notifier.threadSummaries[threadIdBroadcast]!.isCountPending, + isFalse, + ); } expect( session.queryFilters.where( @@ -4330,13 +4514,15 @@ void main() { final session = _RecordingRelaySessionNotifier( queryResults: [ [ - for (final id in ['owner', 'unrelated']) ...[ + for (final id in [threadIdOwner, threadIdUnrelated]) ...[ _event(id: id, createdAt: 10), _summary(rootId: id, replyCount: 1), ], _bounds(), ], - _DeletedTargetSummaryResponse(_summary(rootId: 'owner', replyCount: 0)), + _DeletedTargetSummaryResponse( + _summary(rootId: threadIdOwner, replyCount: 0), + ), ], ); final container = _buildContainer(session); @@ -4365,10 +4551,10 @@ void main() { final summaries = container .read(channelMessagesProvider(_channelId).notifier) .threadSummaries; - expect(summaries['owner']?.descendantCount, 0); - expect(summaries['unrelated']?.descendantCount, 1); - expect(summaries['unrelated']?.isCountPending, isFalse); - expect(summaries['unrelated']?.isLowerBound, isFalse); + expect(summaries[threadIdOwner]?.descendantCount, 0); + expect(summaries[threadIdUnrelated]?.descendantCount, 1); + expect(summaries[threadIdUnrelated]?.isCountPending, isFalse); + expect(summaries[threadIdUnrelated]?.isLowerBound, isFalse); final entries = buildMainTimelineEntries( formatTimeline( container.read(channelMessagesProvider(_channelId)).value!, @@ -4376,12 +4562,12 @@ void main() { relaySummaries: summaries, ); expect( - entries.singleWhere((entry) => entry.message.id == 'owner').summary, + entries.singleWhere((entry) => entry.message.id == threadIdOwner).summary, isNull, ); expect( entries - .singleWhere((entry) => entry.message.id == 'unrelated') + .singleWhere((entry) => entry.message.id == threadIdUnrelated) .summary ?.replyCount, 1, @@ -4400,7 +4586,7 @@ void main() { ], if (recountFirst) [], [ - _event(id: 'old-root', createdAt: 10), + _event(id: threadIdOldDashRoot, createdAt: 10), _bounds(dTag: '${_channelId.toLowerCase()}:100:newest'), ], ], @@ -4412,12 +4598,17 @@ void main() { final notifier = container.read( channelMessagesProvider(_channelId).notifier, ); - final load = notifier.loadEventsById(['old-root']); - session.completeTargetHistory([_event(id: 'old-root', createdAt: 10)]); + final load = notifier.loadEventsById([threadIdOldDashRoot]); + session.completeTargetHistory([ + _event(id: threadIdOldDashRoot, createdAt: 10), + ]); await load; - notifier.cacheCompleteThreadQuery('old-root', {}, []); - session.emit(_summary(rootId: 'old-root', replyCount: 1)); - expect(notifier.threadSummaries['old-root']!.descendantCount, 0); + notifier.cacheCompleteThreadQuery(threadIdOldDashRoot, {}, []); + session.emit(_summary(rootId: threadIdOldDashRoot, replyCount: 1)); + expect( + notifier.threadSummaries[threadIdOldDashRoot]!.descendantCount, + 0, + ); if (recountFirst) { await Future.delayed(const Duration(milliseconds: 300)); expect( @@ -4426,7 +4617,10 @@ void main() { ), hasLength(1), ); - expect(notifier.threadSummaries['old-root']!.descendantCount, 0); + expect( + notifier.threadSummaries[threadIdOldDashRoot]!.descendantCount, + 0, + ); } expect(await notifier.fetchOlder(), isTrue); final entries = buildMainTimelineEntries( @@ -4436,7 +4630,9 @@ void main() { relaySummaries: notifier.threadSummaries, ); expect( - entries.singleWhere((e) => e.message.id == 'old-root').summary, + entries + .singleWhere((e) => e.message.id == threadIdOldDashRoot) + .summary, isNull, ); }, @@ -4455,7 +4651,7 @@ void main() { _bounds(hasMore: true, cursorCreatedAt: 100, cursorId: 'newest'), ], [ - _event(id: 'root', createdAt: 10), + _event(id: threadIdRoot, createdAt: 10), _bounds(dTag: '${_channelId.toLowerCase()}:100:newest'), ], recount.future, @@ -4469,8 +4665,8 @@ void main() { channelMessagesProvider(_channelId).notifier, ); expect(await notifier.fetchOlder(), isTrue); - session.emit(_summary(rootId: 'root', replyCount: 1)); - expect(notifier.threadSummaries['root']!.descendantCount, 0); + session.emit(_summary(rootId: threadIdRoot, replyCount: 1)); + expect(notifier.threadSummaries[threadIdRoot]!.descendantCount, 0); await Future.delayed(const Duration(milliseconds: 300)); expect( session.queryFilters.where( @@ -4484,7 +4680,7 @@ void main() { id: 'new-reply', createdAt: 30, extraTags: const [ - ['e', 'root', '', 'reply'], + ['e', threadIdRoot, '', 'reply'], ], ), ]); @@ -4497,7 +4693,7 @@ void main() { ); expect( entries - .singleWhere((e) => e.message.id == 'root') + .singleWhere((e) => e.message.id == threadIdRoot) .summary ?.replyCount, newReplyExists ? 1 : null, @@ -4513,7 +4709,7 @@ void main() { final recount = Completer>(); final session = _RecordingRelaySessionNotifier( queryResults: [ - [_event(id: 'root', createdAt: 10), _bounds()], + [_event(id: threadIdRoot, createdAt: 10), _bounds()], recount.future, ], ); @@ -4524,9 +4720,9 @@ void main() { final notifier = container.read( channelMessagesProvider(_channelId).notifier, ); - notifier.cacheCompleteThreadQuery('root', {}, []); - session.emit(_summary(rootId: 'root', replyCount: 1)); - expect(notifier.threadSummaries['root']!.descendantCount, 0); + notifier.cacheCompleteThreadQuery(threadIdRoot, {}, []); + session.emit(_summary(rootId: threadIdRoot, replyCount: 1)); + expect(notifier.threadSummaries[threadIdRoot]!.descendantCount, 0); await Future.delayed(const Duration(milliseconds: 300)); expect( session.queryFilters.where( @@ -4540,7 +4736,7 @@ void main() { id: 'new-reply', createdAt: 30, extraTags: const [ - ['e', 'root', '', 'reply'], + ['e', threadIdRoot, '', 'reply'], ], ), ]); @@ -4564,12 +4760,12 @@ void main() { id: 'cached-reply', createdAt: 20, extraTags: const [ - ['e', 'root', '', 'reply'], + ['e', threadIdRoot, '', 'reply'], ], ); final session = _RecordingRelaySessionNotifier( queryResults: [ - [_event(id: 'root', createdAt: 10), _bounds()], + [_event(id: threadIdRoot, createdAt: 10), _bounds()], stillExists ? [reply] : [], ], ); @@ -4580,8 +4776,8 @@ void main() { final notifier = container.read( channelMessagesProvider(_channelId).notifier, ); - notifier.cacheCompleteThreadQuery('root', {}, [reply]); - session.emit(_summary(rootId: 'root', replyCount: 0)); + notifier.cacheCompleteThreadQuery(threadIdRoot, {}, [reply]); + session.emit(_summary(rootId: threadIdRoot, replyCount: 0)); await Future.delayed(const Duration(milliseconds: 300)); final entries = buildMainTimelineEntries( formatTimeline( @@ -4590,14 +4786,15 @@ void main() { relaySummaries: notifier.threadSummaries, ); expect( - entries.singleWhere((e) => e.message.id == 'root').summary == null, + entries.singleWhere((e) => e.message.id == threadIdRoot).summary == + null, !stillExists, ); expect( - notifier.cachedThreadReplyIds('root').contains('cached-reply'), + notifier.cachedThreadReplyIds(threadIdRoot).contains('cached-reply'), stillExists, ); - expect(notifier.threadSummaries['root']!.isCountPending, isFalse); + expect(notifier.threadSummaries[threadIdRoot]!.isCountPending, isFalse); expect( session.queryFilters.where( (f) => f.extensions.containsKey('depth_limit'), @@ -4616,17 +4813,17 @@ void main() { id: 'live-reply', createdAt: 20, extraTags: const [ - ['e', 'root', '', 'reply'], + ['e', threadIdRoot, '', 'reply'], ], ); final session = _RecordingRelaySessionNotifier( queryResults: [ - [_event(id: 'root', createdAt: 10), _bounds()], + [_event(id: threadIdRoot, createdAt: 10), _bounds()], Exception('NIP-CW unavailable'), stillExists ? [reply] : [], ], historyResults: [ - [_event(id: 'root', createdAt: 10)], + [_event(id: threadIdRoot, createdAt: 10)], ], ); final container = _buildContainer(session); @@ -4653,11 +4850,12 @@ void main() { relaySummaries: notifier.threadSummaries, ); expect( - entries.singleWhere((e) => e.message.id == 'root').summary == null, + entries.singleWhere((e) => e.message.id == threadIdRoot).summary == + null, !stillExists, ); expect( - notifier.cachedThreadReplyIds('root').contains('live-reply'), + notifier.cachedThreadReplyIds(threadIdRoot).contains('live-reply'), stillExists, ); expect( @@ -4675,7 +4873,7 @@ void main() { id: 'old-reply', createdAt: 20, extraTags: const [ - ['e', 'old-root', '', 'reply'], + ['e', threadIdOldDashRoot, '', 'reply'], ], ); final recount = Completer>(); @@ -4683,9 +4881,10 @@ void main() { queryResults: [Exception('NIP-CW unavailable'), recount.future], historyResults: [ [ - _event(id: 'old-root', createdAt: 10), + _event(id: threadIdOldDashRoot, createdAt: 10), reply, - for (var i = 0; i < 16; i++) _event(id: 'other-$i', createdAt: 10), + for (var i = 0; i < 16; i++) + _event(id: indexedThreadId('other-$i'), createdAt: 10), ], ], ); @@ -4702,12 +4901,12 @@ void main() { id: 'new-$i', createdAt: 100 + i, extraTags: [ - ['e', 'other-${i ~/ 128}', '', 'reply'], + ['e', indexedThreadId('other-${i ~/ 128}'), '', 'reply'], ], ), ); } - expect(notifier.cachedThreadReplyIds('old-root'), isEmpty); + expect(notifier.cachedThreadReplyIds(threadIdOldDashRoot), isEmpty); final entries = buildMainTimelineEntries( formatTimeline( container.read(channelMessagesProvider(_channelId)).value!, @@ -4716,7 +4915,7 @@ void main() { ); expect( entries - .singleWhere((e) => e.message.id == 'old-root') + .singleWhere((e) => e.message.id == threadIdOldDashRoot) .summary ?.replyCount, 1, @@ -4726,11 +4925,14 @@ void main() { (f) => f.extensions.containsKey('depth_limit'), ); expect(scans, hasLength(1)); - expect(scans.single.tags['#e'], ['old-root']); + expect(scans.single.tags['#e'], [threadIdOldDashRoot]); recount.complete([reply]); await _pumpEventQueue(); - expect(notifier.threadSummaries['old-root']?.descendantCount, 1); - expect(notifier.threadSummaries['old-root']?.isLowerBound, isFalse); + expect(notifier.threadSummaries[threadIdOldDashRoot]?.descendantCount, 1); + expect( + notifier.threadSummaries[threadIdOldDashRoot]?.isLowerBound, + isFalse, + ); }); for (final fromHistory in [false, true]) { @@ -4742,14 +4944,14 @@ void main() { id: 'fallback-reply', createdAt: 20, extraTags: const [ - ['e', 'root', '', 'reply'], + ['e', threadIdRoot, '', 'reply'], ], ); final scan = Completer>(); final session = _RecordingRelaySessionNotifier( queryResults: [Exception('NIP-CW unavailable'), scan.future], historyResults: [ - [_event(id: 'root', createdAt: 10), if (fromHistory) reply], + [_event(id: threadIdRoot, createdAt: 10), if (fromHistory) reply], ], ); final container = _buildContainer(session); @@ -4760,7 +4962,10 @@ void main() { final notifier = container.read( channelMessagesProvider(_channelId).notifier, ); - const args = ThreadRepliesArgs(channelId: _channelId, rootId: 'root'); + const args = ThreadRepliesArgs( + channelId: _channelId, + rootId: threadIdRoot, + ); container.listen(threadRepliesProvider(args), (_, _) {}); final result = container.read(threadRepliesProvider(args).future); scan.complete([if (survives) reply]); @@ -4775,13 +4980,13 @@ void main() { ); expect( entries - .singleWhere((e) => e.message.id == 'root') + .singleWhere((e) => e.message.id == threadIdRoot) .summary ?.replyCount, survives ? 1 : null, ); expect( - notifier.cachedThreadReplyIds('root').contains(reply.id), + notifier.cachedThreadReplyIds(threadIdRoot).contains(reply.id), survives, ); }, @@ -4797,18 +5002,18 @@ void main() { id: 'cached-reply', createdAt: 20, extraTags: const [ - ['e', 'root', '', 'reply'], + ['e', threadIdRoot, '', 'reply'], ], ); final recount = Completer>(); final session = _RecordingRelaySessionNotifier( queryResults: [ - [_event(id: 'root', createdAt: 10), _bounds()], + [_event(id: threadIdRoot, createdAt: 10), _bounds()], Exception('NIP-CW unavailable'), recount.future, ], historyResults: [ - [_event(id: 'root', createdAt: 10)], + [_event(id: threadIdRoot, createdAt: 10)], ], ); final container = _buildContainer(session); @@ -4818,13 +5023,16 @@ void main() { final notifier = container.read( channelMessagesProvider(_channelId).notifier, ); - notifier.cacheCompleteThreadQuery('root', {}, [reply]); + notifier.cacheCompleteThreadQuery(threadIdRoot, {}, [reply]); session.setConnected(false); await _pumpEventQueue(); session.setConnected(true); await _pumpEventQueue(); if (outcome != 'deleted') { - expect(notifier.threadSummaries['root']!.isCountPending, isTrue); + expect( + notifier.threadSummaries[threadIdRoot]!.isCountPending, + isTrue, + ); } await Future.delayed(const Duration(milliseconds: 250)); if (outcome == 'unavailable') { @@ -4840,16 +5048,16 @@ void main() { relaySummaries: notifier.threadSummaries, ); final summary = entries - .singleWhere((entry) => entry.message.id == 'root') + .singleWhere((entry) => entry.message.id == threadIdRoot) .summary; if (outcome == 'deleted') { expect(summary, isNull); - expect(notifier.cachedThreadReplyIds('root'), isEmpty); + expect(notifier.cachedThreadReplyIds(threadIdRoot), isEmpty); } else { expect(summary, isNotNull); expect(summary!.replyCount, 1); expect( - notifier.threadSummaries['root']!.isCountPending, + notifier.threadSummaries[threadIdRoot]!.isCountPending, outcome == 'unavailable', ); } @@ -4871,12 +5079,12 @@ void main() { id: 'cached-reply', createdAt: 20, extraTags: const [ - ['e', 'root', '', 'reply'], + ['e', threadIdRoot, '', 'reply'], ], ); final session = _RecordingRelaySessionNotifier( queryResults: [ - [_event(id: 'root', createdAt: 10), _bounds()], + [_event(id: threadIdRoot, createdAt: 10), _bounds()], Exception('NIP-CW unavailable'), ], ); @@ -4887,23 +5095,23 @@ void main() { final notifier = container.read( channelMessagesProvider(_channelId).notifier, ); - notifier.cacheCompleteThreadQuery('root', {}, [reply]); + notifier.cacheCompleteThreadQuery(threadIdRoot, {}, [reply]); session.setConnected(false); await _pumpEventQueue(); session.setConnected(true); await _pumpEventQueue(); - final snapshot = notifier.cachedThreadReplyIds('root'); - final version = notifier.beginThreadQuery('root'); + final snapshot = notifier.cachedThreadReplyIds(threadIdRoot); + final version = notifier.beginThreadQuery(threadIdRoot); notifier.cacheCompleteThreadQuery( - 'root', + threadIdRoot, snapshot, [], queryVersion: version, ); - session.completeHistory([_event(id: 'root', createdAt: 10)]); + session.completeHistory([_event(id: threadIdRoot, createdAt: 10)]); await Future.delayed(const Duration(milliseconds: 300)); - expect(notifier.threadSummaries['root']!.descendantCount, 0); - expect(notifier.threadSummaries['root']!.isCountPending, isFalse); + expect(notifier.threadSummaries[threadIdRoot]!.descendantCount, 0); + expect(notifier.threadSummaries[threadIdRoot]!.isCountPending, isFalse); expect( session.queryFilters.where( (f) => f.extensions.containsKey('depth_limit'), @@ -4918,21 +5126,21 @@ void main() { id: 'survivor', createdAt: 21, extraTags: const [ - ['e', 'root', '', 'reply'], + ['e', threadIdRoot, '', 'reply'], ], ); final session = _RecordingRelaySessionNotifier( queryResults: [ [ - _event(id: 'root', createdAt: 10), - _summary(rootId: 'root', replyCount: 2), + _event(id: threadIdRoot, createdAt: 10), + _summary(rootId: threadIdRoot, replyCount: 2), _bounds(), ], [survivor], [ - _event(id: 'root', createdAt: 10), + _event(id: threadIdRoot, createdAt: 10), // A new reply arrived while disconnected; its payload is not cached. - _summary(rootId: 'root', replyCount: 2), + _summary(rootId: threadIdRoot, replyCount: 2), _bounds(), ], Exception('recount unavailable'), @@ -4950,7 +5158,7 @@ void main() { id: 'target', createdAt: 20, extraTags: const [ - ['e', 'root', '', 'reply'], + ['e', threadIdRoot, '', 'reply'], ], ), ]); @@ -4966,14 +5174,17 @@ void main() { content: '', sig: '', ); - final snapshot = notifier.cachedThreadReplyIds('root'); + final snapshot = notifier.cachedThreadReplyIds(threadIdRoot); session.emit(deletion(EventKind.deletion)); - final queryVersion = notifier.beginThreadQuery('root'); - notifier.cacheCompleteThreadQuery('root', snapshot, [ + final queryVersion = notifier.beginThreadQuery(threadIdRoot); + notifier.cacheCompleteThreadQuery(threadIdRoot, snapshot, [ survivor, ], queryVersion: queryVersion); await Future.delayed(const Duration(milliseconds: 300)); - expect(notifier.cachedThreadReplyIds('root'), isNot(contains('target'))); + expect( + notifier.cachedThreadReplyIds(threadIdRoot), + isNot(contains('target')), + ); session.setConnected(false); await _pumpEventQueue(); session.setConnected(true); @@ -4987,11 +5198,11 @@ void main() { isFalse, reason: 'the marker payload was pruned on reconnect', ); - expect(notifier.threadSummaries['root']!.descendantCount, 2); + expect(notifier.threadSummaries[threadIdRoot]!.descendantCount, 2); session.emit(deletion(EventKind.nip29DeleteEvent)); await Future.delayed(const Duration(milliseconds: 300)); - expect(notifier.threadSummaries['root']!.descendantCount, 2); - expect(notifier.threadSummaries['root']!.isLowerBound, isFalse); + expect(notifier.threadSummaries[threadIdRoot]!.descendantCount, 2); + expect(notifier.threadSummaries[threadIdRoot]!.isLowerBound, isFalse); expect( session.queryFilters.where( (filter) => filter.extensions.containsKey('depth_limit'), @@ -5007,8 +5218,8 @@ void main() { final session = _RecordingRelaySessionNotifier( queryResults: [ [ - _event(id: 'root', createdAt: 10), - _summary(rootId: 'root', replyCount: 2), + _event(id: threadIdRoot, createdAt: 10), + _summary(rootId: threadIdRoot, replyCount: 2), _bounds(), ], Exception('recount unavailable'), @@ -5026,7 +5237,7 @@ void main() { id: 'target', createdAt: 20, extraTags: const [ - ['e', 'root', '', 'reply'], + ['e', threadIdRoot, '', 'reply'], ], ), ]); @@ -5054,7 +5265,7 @@ void main() { } } await Future.delayed(const Duration(milliseconds: 300)); - expect(notifier.threadSummaries['root']?.descendantCount, 1); + expect(notifier.threadSummaries[threadIdRoot]?.descendantCount, 1); expect( session.queryFilters.where( (filter) => filter.extensions.containsKey('depth_limit'), @@ -5068,7 +5279,7 @@ void main() { test('a reply newer than the relay recount raises the badge', () async { final relaySession = _RecordingRelaySessionNotifier( queryResults: [ - [_event(id: 'root', createdAt: 10), _bounds()], + [_event(id: threadIdRoot, createdAt: 10), _bounds()], ], ); final container = _buildContainer(relaySession); @@ -5083,18 +5294,20 @@ void main() { id: 'reply-1', createdAt: 20, extraTags: const [ - ['e', 'root', '', 'reply'], + ['e', threadIdRoot, '', 'reply'], ], ), ); - relaySession.emit(_summary(rootId: 'root', replyCount: 1, createdAt: 20)); + relaySession.emit( + _summary(rootId: threadIdRoot, replyCount: 1, createdAt: 20), + ); // A second reply lands, and its recount is lost or still in flight. relaySession.emit( _event( id: 'reply-2', createdAt: 21, extraTags: const [ - ['e', 'root', '', 'reply'], + ['e', threadIdRoot, '', 'reply'], ], ), ); @@ -5103,14 +5316,14 @@ void main() { final notifier = container.read( channelMessagesProvider(_channelId).notifier, ); - expect(notifier.threadSummaries['root']?.replyCount, 1); + expect(notifier.threadSummaries[threadIdRoot]?.replyCount, 1); final entries = buildMainTimelineEntries( formatTimeline( container.read(channelMessagesProvider(_channelId)).value!, ), relaySummaries: notifier.threadSummaries, ); - expect(entries.single.message.id, 'root'); + expect(entries.single.message.id, threadIdRoot); expect(entries.single.summary!.replyCount, 2); expect(entries.single.summary!.lastReplyAt, 21); }); diff --git a/mobile/test/features/channels/channels_page_test.dart b/mobile/test/features/channels/channels_page_test.dart index 0a34bf2957..5d6047e9b8 100644 --- a/mobile/test/features/channels/channels_page_test.dart +++ b/mobile/test/features/channels/channels_page_test.dart @@ -51,6 +51,8 @@ import 'package:buzz/shared/widgets/ios_navigation_bar.dart'; import 'package:buzz/shared/widgets/masked_avatar_badge.dart'; import 'package:buzz/shared/widgets/skeleton.dart'; +import '../../helpers/thread_ids.dart'; + part 'channels_page_test/presence_tests.dart'; part 'channels_page_test/unread_readiness_tests.dart'; @@ -3600,13 +3602,13 @@ void main() { _observed( id: 'reply-1', createdAt: 20, - rootId: 'root', + rootId: threadIdRoot, isThreadedReply: true, ), _observed( id: 'reply-2', createdAt: 30, - rootId: 'root', + rootId: threadIdRoot, isThreadedReply: true, ), ], @@ -3633,7 +3635,7 @@ void main() { _observed( id: 'reply-1', createdAt: 30, - rootId: 'root', + rootId: threadIdRoot, isThreadedReply: true, ), ], @@ -3667,7 +3669,11 @@ void main() { const ReadStateState( isReady: true, pubkey: 'pk', - contexts: {'1': 10, 'activity:1': 25, 'thread-activity:root': 30}, + contexts: { + '1': 10, + 'activity:1': 25, + 'thread-activity:$threadIdRoot': 30, + }, version: 0, ), ); diff --git a/mobile/test/features/channels/message_actions_test.dart b/mobile/test/features/channels/message_actions_test.dart index ef9a9a01e9..410d4cc843 100644 --- a/mobile/test/features/channels/message_actions_test.dart +++ b/mobile/test/features/channels/message_actions_test.dart @@ -21,6 +21,8 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:nostr/nostr.dart' as nostr; import 'package:shared_preferences/shared_preferences.dart'; +import '../../helpers/thread_ids.dart'; + const _channelId = 'chan-1'; TimelineMessage _message({ @@ -1142,7 +1144,7 @@ void main() { final prefs = await _mockPrefs(); await _pumpSheet( tester, - message: _message(rootId: 'root-9'), + message: _message(rootId: threadIdRootDash9), prefs: prefs, ); @@ -1152,7 +1154,9 @@ void main() { final container = ProviderScope.containerOf( tester.element(find.text('open')), ); - expect(container.read(threadFollowsProvider).followedRootIds, {'root-9'}); + expect(container.read(threadFollowsProvider).followedRootIds, { + threadIdRootDash9, + }); // Re-open: the row now offers Unfollow. await tester.tap(find.text('open')); @@ -1226,10 +1230,10 @@ void main() { test('builds a canonical link with thread context', () { expect( messageLinkFor( - message: _message(rootId: 'root-1'), + message: _message(rootId: threadIdRootDash1), channelId: _channelId, ), - 'buzz://message?channel=chan-1&id=msg-1&thread=root-1', + 'buzz://message?channel=chan-1&id=msg-1&thread=$threadIdRootDash1', ); }); diff --git a/mobile/test/features/channels/thread_follows/thread_follows_storage_test.dart b/mobile/test/features/channels/thread_follows/thread_follows_storage_test.dart index 58d04c5de9..e74357ca1d 100644 --- a/mobile/test/features/channels/thread_follows/thread_follows_storage_test.dart +++ b/mobile/test/features/channels/thread_follows/thread_follows_storage_test.dart @@ -2,6 +2,8 @@ import 'package:buzz/features/channels/thread_follows/thread_follows_storage.dar import 'package:flutter_test/flutter_test.dart'; import 'package:shared_preferences/shared_preferences.dart'; +import '../../../helpers/thread_ids.dart'; + Future _prefs() async { SharedPreferences.setMockInitialValues({}); return SharedPreferences.getInstance(); @@ -13,12 +15,15 @@ void main() { test('round-trips entries per pubkey', () async { final storage = ThreadFollowsStorage(await _prefs()); storage.write(pubkey, const [ - ThreadFollowEntry(rootId: 'root-1', followedAt: 100), - ThreadFollowEntry(rootId: 'root-2', followedAt: 200), + ThreadFollowEntry(rootId: threadIdRootDash1, followedAt: 100), + ThreadFollowEntry(rootId: threadIdRootDash2, followedAt: 200), ]); final entries = storage.read(pubkey); - expect(entries.map((e) => e.rootId), ['root-1', 'root-2']); + expect(entries.map((e) => e.rootId), [ + threadIdRootDash1, + threadIdRootDash2, + ]); expect(entries.map((e) => e.followedAt), [100, 200]); expect(storage.read('other-pubkey'), isEmpty); }); @@ -54,7 +59,7 @@ void main() { }); test('capThreadFollowEntries keeps small lists untouched', () { - const entries = [ThreadFollowEntry(rootId: 'a', followedAt: 1)]; + const entries = [ThreadFollowEntry(rootId: threadIdA, followedAt: 1)]; expect(capThreadFollowEntries(entries), same(entries)); }); } diff --git a/mobile/test/features/channels/thread_replies_provider_test.dart b/mobile/test/features/channels/thread_replies_provider_test.dart index b23f475c28..c0334cb4e0 100644 --- a/mobile/test/features/channels/thread_replies_provider_test.dart +++ b/mobile/test/features/channels/thread_replies_provider_test.dart @@ -6,6 +6,8 @@ import 'package:buzz/shared/relay/relay.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import '../../helpers/thread_ids.dart'; + class _FakeRelaySession extends RelaySessionNotifier { int queryCount = 0; bool honorDepthLimit = false; @@ -47,14 +49,14 @@ NostrEvent _reply(String id, int createdAt) => NostrEvent( kind: EventKind.streamMessage, tags: const [ ['h', 'chan'], - ['e', 'root', '', 'reply'], + ['e', threadIdRoot, '', 'reply'], ], content: 'reply $id', sig: '', ); void main() { - const args = ThreadRepliesArgs(channelId: 'chan', rootId: 'root'); + const args = ThreadRepliesArgs(channelId: 'chan', rootId: threadIdRoot); test('complete scan includes a legal 80-deep reply chain', () async { final session = _FakeRelaySession() @@ -68,8 +70,13 @@ void main() { kind: EventKind.streamMessage, tags: [ ['h', 'chan'], - ['e', 'root', '', 'root'], - ['e', depth == 1 ? 'root' : 'reply-${depth - 1}', '', 'reply'], + ['e', threadIdRoot, '', 'root'], + [ + 'e', + depth == 1 ? threadIdRoot : 'reply-${depth - 1}', + '', + 'reply', + ], ], content: '', sig: '', diff --git a/mobile/test/features/channels/thread_reply_ownership_test.dart b/mobile/test/features/channels/thread_reply_ownership_test.dart index 1088bc75c4..6720c2a42c 100644 --- a/mobile/test/features/channels/thread_reply_ownership_test.dart +++ b/mobile/test/features/channels/thread_reply_ownership_test.dart @@ -2,6 +2,8 @@ import 'package:buzz/features/channels/thread_reply_ownership.dart'; import 'package:buzz/shared/relay/relay.dart'; import 'package:flutter_test/flutter_test.dart'; +import '../../helpers/thread_ids.dart'; + void main() { test( 'identity evidence is bounded while keeping recent evicted-payload owners', @@ -16,15 +18,15 @@ void main() { kind: EventKind.streamMessage, tags: const [ ['h', 'channel'], - ['e', 'root', '', 'reply'], + ['e', threadIdRoot, '', 'reply'], ], content: '', sig: '', ), ]); expect(owners.rootFor('reply-0'), isNull); - expect(owners.rootFor('reply-1'), 'root'); - expect(owners.rootFor('reply-8192'), 'root'); + expect(owners.rootFor('reply-1'), threadIdRoot); + expect(owners.rootFor('reply-8192'), threadIdRoot); }, ); } diff --git a/mobile/test/features/channels/thread_reply_refresh_cases.dart b/mobile/test/features/channels/thread_reply_refresh_cases.dart index c788731fc5..40d5bed066 100644 --- a/mobile/test/features/channels/thread_reply_refresh_cases.dart +++ b/mobile/test/features/channels/thread_reply_refresh_cases.dart @@ -1,7 +1,10 @@ part of 'channel_detail_page_test.dart'; void threadReplyRefreshTests() { - const args = ThreadRepliesArgs(channelId: _channelId, rootId: 'refresh-root'); + const args = ThreadRepliesArgs( + channelId: _channelId, + rootId: threadIdRefreshDashRoot, + ); final root = _textMsg( id: args.rootId, pubkey: 'alice', diff --git a/mobile/test/features/channels/timeline_message_test.dart b/mobile/test/features/channels/timeline_message_test.dart index 8f0798a8ce..c2e6d3f3e5 100644 --- a/mobile/test/features/channels/timeline_message_test.dart +++ b/mobile/test/features/channels/timeline_message_test.dart @@ -5,6 +5,8 @@ import 'package:buzz/features/channels/channel_window.dart'; import 'package:buzz/features/channels/timeline_message.dart'; import 'package:buzz/shared/relay/relay.dart'; +import '../../helpers/thread_ids.dart'; + NostrEvent _textMsg({ required String id, String pubkey = 'alice', @@ -395,7 +397,7 @@ void main() { test('passes through text messages', () { final events = [ - _textMsg(id: 'a', content: 'hello'), + _textMsg(id: threadIdA, content: 'hello'), _textMsg(id: 'b', content: 'world', createdAt: 1100), ]; @@ -409,7 +411,7 @@ void main() { test('filters deleted messages', () { final events = [ - _textMsg(id: 'a', content: 'keep'), + _textMsg(id: threadIdA, content: 'keep'), _textMsg(id: 'b', content: 'delete', createdAt: 1100), _deletion(id: 'd1', targets: ['b']), ]; @@ -424,7 +426,7 @@ void main() { // and treat 9005 as a deletion marker, otherwise agent-deleted messages // stay rendered until manual refresh. final events = [ - _textMsg(id: 'a', content: 'keep'), + _textMsg(id: threadIdA, content: 'keep'), _textMsg(id: 'b', content: 'delete', createdAt: 1100), _nip29Deletion(id: 'd1', targets: ['b']), ]; @@ -436,8 +438,8 @@ void main() { test('applies edits', () { final events = [ - _textMsg(id: 'a', content: 'original'), - _edit(id: 'e1', targetId: 'a', content: 'edited'), + _textMsg(id: threadIdA, content: 'original'), + _edit(id: 'e1', targetId: threadIdA, content: 'edited'), ]; final result = formatTimeline(events); @@ -449,7 +451,7 @@ void main() { test('applies edit tags for custom emoji rendering', () { final events = [ _textMsg( - id: 'a', + id: threadIdA, content: 'original :old:', extraTags: [ ['emoji', 'old', 'https://relay.example/old.png'], @@ -457,7 +459,7 @@ void main() { ), _edit( id: 'e1', - targetId: 'a', + targetId: threadIdA, content: 'edited :new:', extraTags: [ ['emoji', 'new', 'https://relay.example/new.png'], @@ -493,7 +495,7 @@ void main() { final messages = formatTimeline([ _textMsg(id: 'm1'), _reaction( - id: 'r1', + id: threadIdR1, targetId: 'm1', content: ':shipit:', extraTags: [ @@ -510,9 +512,9 @@ void main() { test('latest edit wins', () { final events = [ - _textMsg(id: 'a', content: 'v1'), - _edit(id: 'e1', targetId: 'a', content: 'v2', createdAt: 2000), - _edit(id: 'e2', targetId: 'a', content: 'v3', createdAt: 3000), + _textMsg(id: threadIdA, content: 'v1'), + _edit(id: 'e1', targetId: threadIdA, content: 'v2', createdAt: 2000), + _edit(id: 'e2', targetId: threadIdA, content: 'v3', createdAt: 3000), ]; final result = formatTimeline(events); @@ -521,8 +523,8 @@ void main() { test('deleted edit is ignored', () { final events = [ - _textMsg(id: 'a', content: 'original'), - _edit(id: 'e1', targetId: 'a', content: 'edited'), + _textMsg(id: threadIdA, content: 'original'), + _edit(id: 'e1', targetId: threadIdA, content: 'edited'), _deletion(id: 'd1', targets: ['e1']), ]; @@ -533,9 +535,9 @@ void main() { test('edit of deleted message is ignored', () { final events = [ - _textMsg(id: 'a', content: 'original'), - _edit(id: 'e1', targetId: 'a', content: 'edited'), - _deletion(id: 'd1', targets: ['a']), + _textMsg(id: threadIdA, content: 'original'), + _edit(id: 'e1', targetId: threadIdA, content: 'edited'), + _deletion(id: 'd1', targets: [threadIdA]), ]; final result = formatTimeline(events); @@ -605,7 +607,7 @@ void main() { 'actor': 123, }, ), - _textMsg(id: 'a', content: 'hello', createdAt: 1100), + _textMsg(id: threadIdA, content: 'hello', createdAt: 1100), ]; final result = formatTimeline(events); @@ -615,8 +617,8 @@ void main() { test('reactions and typing indicators are filtered out', () { final events = [ - _textMsg(id: 'a', content: 'hello'), - _reaction(id: 'r1', targetId: 'a'), + _textMsg(id: threadIdA, content: 'hello'), + _reaction(id: threadIdR1, targetId: threadIdA), NostrEvent( id: 'typing1', pubkey: 'bob', @@ -637,7 +639,7 @@ void main() { test('preserves chronological order', () { final events = [ - _textMsg(id: 'a', content: 'first', createdAt: 1000), + _textMsg(id: threadIdA, content: 'first', createdAt: 1000), _systemMsg( id: 's1', payload: {'type': 'member_joined', 'actor': 'pk1', 'target': 'pk1'}, @@ -696,7 +698,7 @@ void main() { group('NostrEvent.threadReference', () { test('returns nulls for top-level message (no e-tags)', () { - final event = _textMsg(id: 'a'); + final event = _textMsg(id: threadIdA); final ref = event.threadReference; expect(ref.parentId, isNull); expect(ref.rootId, isNull); @@ -704,7 +706,7 @@ void main() { test('returns nulls for e-tags without root/reply markers', () { final event = _textMsg( - id: 'a', + id: threadIdA, extraTags: [ ['e', 'target'], ], @@ -718,45 +720,45 @@ void main() { final event = _textMsg( id: 'reply1', extraTags: [ - ['e', 'root1', '', 'reply'], + ['e', threadIdRoot1, '', 'reply'], ], ); final ref = event.threadReference; - expect(ref.parentId, 'root1'); - expect(ref.rootId, 'root1'); + expect(ref.parentId, threadIdRoot1); + expect(ref.rootId, threadIdRoot1); }); test('parses nested reply (root + reply markers)', () { final event = _textMsg( id: 'reply2', extraTags: [ - ['e', 'root1', '', 'root'], - ['e', 'parent1', '', 'reply'], + ['e', threadIdRoot1, '', 'root'], + ['e', threadIdParent1, '', 'reply'], ], ); final ref = event.threadReference; - expect(ref.parentId, 'parent1'); - expect(ref.rootId, 'root1'); + expect(ref.parentId, threadIdParent1); + expect(ref.rootId, threadIdRoot1); }); 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'], + ['e', threadIdRoot1, '', 'root'], + ['e', threadIdOldUnderscoreParent, '', 'reply'], + ['e', threadIdNewUnderscoreParent, '', 'reply'], ], ); final ref = event.threadReference; - expect(ref.parentId, 'new_parent'); - expect(ref.rootId, 'root1'); + expect(ref.parentId, threadIdNewUnderscoreParent); + expect(ref.rootId, threadIdRoot1); }); }); group('formatTimeline threading', () { test('root messages have null parentId and rootId', () { - final events = [_textMsg(id: 'a')]; + final events = [_textMsg(id: threadIdA)]; final result = formatTimeline(events); expect(result[0].parentId, isNull); expect(result[0].rootId, isNull); @@ -764,49 +766,54 @@ void main() { test('reply messages carry parentId and rootId', () { final events = [ - _textMsg(id: 'root1'), - _replyMsg(id: 'r1', parentId: 'root1', createdAt: 2000), + _textMsg(id: threadIdRoot1), + _replyMsg(id: threadIdR1, parentId: threadIdRoot1, createdAt: 2000), ]; final result = formatTimeline(events); - final reply = result.firstWhere((m) => m.id == 'r1'); - expect(reply.parentId, 'root1'); - expect(reply.rootId, 'root1'); + final reply = result.firstWhere((m) => m.id == threadIdR1); + expect(reply.parentId, threadIdRoot1); + expect(reply.rootId, threadIdRoot1); }); 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), + _textMsg(id: threadIdRoot1), + _replyMsg(id: threadIdR1, parentId: threadIdRoot1, createdAt: 2000), + _replyMsg( + id: 'r2', + parentId: threadIdR1, + rootId: threadIdRoot1, + createdAt: 3000, + ), ]; final result = formatTimeline(events); final nested = result.firstWhere((m) => m.id == 'r2'); - expect(nested.parentId, 'r1'); - expect(nested.rootId, 'root1'); + expect(nested.parentId, threadIdR1); + expect(nested.rootId, threadIdRoot1); }); }); group('buildMainTimelineEntries', () { test('returns only root messages', () { final messages = formatTimeline([ - _textMsg(id: 'a', createdAt: 1000), - _replyMsg(id: 'r1', parentId: 'a', createdAt: 2000), + _textMsg(id: threadIdA, createdAt: 1000), + _replyMsg(id: threadIdR1, parentId: threadIdA, createdAt: 2000), _textMsg(id: 'b', createdAt: 3000), ]); final entries = buildMainTimelineEntries(messages); expect(entries, hasLength(2)); - expect(entries[0].message.id, 'a'); + expect(entries[0].message.id, threadIdA); expect(entries[1].message.id, 'b'); }); test('includes broadcast replies in the main timeline', () { final messages = formatTimeline([ - _textMsg(id: 'a', createdAt: 1000), - _replyMsg(id: 'hidden', parentId: 'a', createdAt: 2000), + _textMsg(id: threadIdA, createdAt: 1000), + _replyMsg(id: 'hidden', parentId: threadIdA, createdAt: 2000), _replyMsg( id: 'broadcast', - parentId: 'a', + parentId: threadIdA, createdAt: 3000, extraTags: const [ ['broadcast', '1'], @@ -815,56 +822,96 @@ void main() { ]); final entries = buildMainTimelineEntries(messages); - expect(entries.map((entry) => entry.message.id), ['a', 'broadcast']); + expect(entries.map((entry) => entry.message.id), [ + threadIdA, + 'broadcast', + ]); }); test('root without replies has null summary', () { - final messages = formatTimeline([_textMsg(id: 'a')]); + final messages = formatTimeline([_textMsg(id: threadIdA)]); 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), + _textMsg(id: threadIdA, createdAt: 1000), + _replyMsg( + id: threadIdR1, + parentId: threadIdA, + pubkey: 'bob', + createdAt: 2000, + ), + _replyMsg( + id: 'r2', + parentId: threadIdA, + 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'); + expect(entries[0].summary!.threadHeadId, threadIdA); expect(entries[0].summary!.lastReplyAt, 3000); }); - test( - 'summary counts every loaded descendant, not only direct children', - () { - final messages = formatTimeline([ - _textMsg(id: 'a', createdAt: 1000), - _replyMsg(id: 'r1', parentId: 'a', createdAt: 2000), - _replyMsg(id: 'r2', parentId: 'r1', rootId: 'a', createdAt: 3000), - ]); + test('summary counts every loaded descendant, not only direct children', () { + final messages = formatTimeline([ + _textMsg(id: threadIdA, createdAt: 1000), + _replyMsg(id: threadIdR1, parentId: threadIdA, createdAt: 2000), + _replyMsg( + id: 'r2', + parentId: threadIdR1, + rootId: threadIdA, + createdAt: 3000, + ), + ]); - final entries = buildMainTimelineEntries(messages); - // The root badge stands for the whole thread, so the reply to r1 counts - // towards 'a' as well. This matches the relay's `descendant_count` and - // the desktop's `buildDescendantStatsByMessageId`. - expect(entries[0].summary!.replyCount, 2); - expect(entries[0].summary!.lastReplyAt, 3000); - }, - ); + final entries = buildMainTimelineEntries(messages); + // The root badge stands for the whole thread, so the reply to r1 counts + // towards threadIdA as well. This matches the relay's `descendant_count` and + // the desktop's `buildDescendantStatsByMessageId`. + expect(entries[0].summary!.replyCount, 2); + expect(entries[0].summary!.lastReplyAt, 3000); + }); 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), + _textMsg(id: threadIdA, createdAt: 1000), + _replyMsg( + id: threadIdR1, + parentId: threadIdA, + pubkey: 'bob', + createdAt: 2000, + ), + _replyMsg( + id: 'r2', + parentId: threadIdA, + pubkey: 'carol', + createdAt: 3000, + ), + _replyMsg( + id: 'r3', + parentId: threadIdA, + pubkey: 'bob', + createdAt: 4000, + ), + _replyMsg( + id: 'r4', + parentId: threadIdA, + pubkey: 'dave', + createdAt: 5000, + ), + _replyMsg( + id: 'r5', + parentId: threadIdA, + pubkey: 'eve', + createdAt: 6000, + ), ]); final entries = buildMainTimelineEntries(messages); @@ -883,7 +930,7 @@ void main() { payload: {'type': 'channel_created', 'actor': 'pk1'}, createdAt: 500, ), - _textMsg(id: 'a', createdAt: 1000), + _textMsg(id: threadIdA, createdAt: 1000), ]; final messages = formatTimeline(events); final entries = buildMainTimelineEntries(messages); @@ -910,11 +957,15 @@ void main() { ); test('relay recount covers replies this client never loaded', () { - final messages = formatTimeline([_textMsg(id: 'a', createdAt: 1000)]); + final messages = formatTimeline([ + _textMsg(id: threadIdA, createdAt: 1000), + ]); final entries = buildMainTimelineEntries( messages, - relaySummaries: {'a': relaySummary(replyCount: 4, lastReplyAt: 9000)}, + relaySummaries: { + threadIdA: relaySummary(replyCount: 4, lastReplyAt: 9000), + }, ); expect(entries.single.summary!.replyCount, 4); @@ -924,15 +975,27 @@ void main() { test('a reply newer than the recount is added to the badge', () { 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), + _textMsg(id: threadIdA, createdAt: 1000), + _replyMsg( + id: threadIdR1, + parentId: threadIdA, + pubkey: 'bob', + createdAt: 2000, + ), + _replyMsg( + id: 'r2', + parentId: threadIdA, + pubkey: 'carol', + createdAt: 3000, + ), ]); // The relay counted only r1 before r2 landed. final entries = buildMainTimelineEntries( messages, - relaySummaries: {'a': relaySummary(replyCount: 1, lastReplyAt: 2000)}, + relaySummaries: { + threadIdA: relaySummary(replyCount: 1, lastReplyAt: 2000), + }, ); expect(entries.single.summary!.replyCount, 2); @@ -941,16 +1004,28 @@ void main() { test('a reply in the same second as the recount still counts', () { 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: 2000), + _textMsg(id: threadIdA, createdAt: 1000), + _replyMsg( + id: threadIdR1, + parentId: threadIdA, + pubkey: 'bob', + createdAt: 2000, + ), + _replyMsg( + id: 'r2', + parentId: threadIdA, + pubkey: 'carol', + createdAt: 2000, + ), ]); // Relay timestamps have second precision, so an equal timestamp is no // proof the recount already included the second reply. final entries = buildMainTimelineEntries( messages, - relaySummaries: {'a': relaySummary(replyCount: 1, lastReplyAt: 2000)}, + relaySummaries: { + threadIdA: relaySummary(replyCount: 1, lastReplyAt: 2000), + }, ); expect(entries.single.summary!.replyCount, 2); @@ -958,8 +1033,13 @@ void main() { test('a lost recount still leaves a badge from the local reply', () { final messages = formatTimeline([ - _textMsg(id: 'a', createdAt: 1000), - _replyMsg(id: 'r1', parentId: 'a', pubkey: 'bob', createdAt: 2000), + _textMsg(id: threadIdA, createdAt: 1000), + _replyMsg( + id: threadIdR1, + parentId: threadIdA, + pubkey: 'bob', + createdAt: 2000, + ), ]); final entries = buildMainTimelineEntries(messages); @@ -970,13 +1050,20 @@ void main() { test('the relay recount wins when it is ahead of the loaded replies', () { final messages = formatTimeline([ - _textMsg(id: 'a', createdAt: 1000), - _replyMsg(id: 'r1', parentId: 'a', pubkey: 'bob', createdAt: 2000), + _textMsg(id: threadIdA, createdAt: 1000), + _replyMsg( + id: threadIdR1, + parentId: threadIdA, + pubkey: 'bob', + createdAt: 2000, + ), ]); final entries = buildMainTimelineEntries( messages, - relaySummaries: {'a': relaySummary(replyCount: 7, lastReplyAt: 8000)}, + relaySummaries: { + threadIdA: relaySummary(replyCount: 7, lastReplyAt: 8000), + }, ); expect(entries.single.summary!.replyCount, 7); @@ -985,13 +1072,20 @@ void main() { test('a zero recount does not erase a locally seen reply', () { final messages = formatTimeline([ - _textMsg(id: 'a', createdAt: 1000), - _replyMsg(id: 'r1', parentId: 'a', pubkey: 'bob', createdAt: 2000), + _textMsg(id: threadIdA, createdAt: 1000), + _replyMsg( + id: threadIdR1, + parentId: threadIdA, + pubkey: 'bob', + createdAt: 2000, + ), ]); final entries = buildMainTimelineEntries( messages, - relaySummaries: {'a': relaySummary(replyCount: 0, lastReplyAt: null)}, + relaySummaries: { + threadIdA: relaySummary(replyCount: 0, lastReplyAt: null), + }, ); expect(entries.single.summary!.replyCount, 1); @@ -1000,23 +1094,28 @@ void main() { test('a locally seen nested reply raises its root badge', () { final messages = formatTimeline([ - _textMsg(id: 'a', createdAt: 1000), - _replyMsg(id: 'r1', parentId: 'a', pubkey: 'bob', createdAt: 2000), + _textMsg(id: threadIdA, createdAt: 1000), + _replyMsg( + id: threadIdR1, + parentId: threadIdA, + pubkey: 'bob', + createdAt: 2000, + ), _replyMsg( id: 'r2', - parentId: 'r1', - rootId: 'a', + parentId: threadIdR1, + rootId: threadIdA, pubkey: 'carol', createdAt: 3000, ), ]); - // The recount for 'a' predates r2, so only the locally seen nested reply + // The recount for threadIdA predates r2, so only the locally seen nested reply // can bring the root badge, time, and facepile up to date. final entries = buildMainTimelineEntries( messages, relaySummaries: { - 'a': relaySummary( + threadIdA: relaySummary( replyCount: 1, lastReplyAt: 2000, participantPubkeys: const ['bob'], @@ -1024,14 +1123,16 @@ void main() { }, ); - final root = entries.firstWhere((entry) => entry.message.id == 'a'); + final root = entries.firstWhere((entry) => entry.message.id == threadIdA); expect(root.summary!.replyCount, 2); expect(root.summary!.lastReplyAt, 3000); expect(root.summary!.participantPubkeys, ['bob', 'carol']); }); test('the relay half counts descendants, not direct replies', () { - final messages = formatTimeline([_textMsg(id: 'a', createdAt: 1000)]); + final messages = formatTimeline([ + _textMsg(id: threadIdA, createdAt: 1000), + ]); // The relay reports both numbers. A thread of nested replies has a // `reply_count` far below its `descendant_count`, and the badge stands @@ -1039,7 +1140,7 @@ void main() { final entries = buildMainTimelineEntries( messages, relaySummaries: { - 'a': relaySummary( + threadIdA: relaySummary( replyCount: 1, descendantCount: 5, lastReplyAt: 9000, @@ -1052,10 +1153,10 @@ void main() { test('relay direct replies are a lower bound for a broadcast row', () { final messages = formatTimeline([ - _textMsg(id: 'a'), + _textMsg(id: threadIdA), _replyMsg( id: 'broadcast', - parentId: 'a', + parentId: threadIdA, extraTags: const [ ['broadcast', '1'], ], @@ -1074,10 +1175,10 @@ void main() { test('a nested reply badges the reply it answers', () { final messages = formatTimeline([ - _textMsg(id: 'a', createdAt: 1000), + _textMsg(id: threadIdA, createdAt: 1000), _replyMsg( - id: 'r1', - parentId: 'a', + id: threadIdR1, + parentId: threadIdA, pubkey: 'bob', createdAt: 2000, extraTags: const [ @@ -1086,8 +1187,8 @@ void main() { ), _replyMsg( id: 'r2', - parentId: 'r1', - rootId: 'a', + parentId: threadIdR1, + rootId: threadIdA, pubkey: 'carol', createdAt: 3000, ), @@ -1097,27 +1198,39 @@ void main() { // its badge has to come from the locally seen nested reply. final entries = buildMainTimelineEntries( messages, - relaySummaries: {'a': relaySummary(replyCount: 1, lastReplyAt: 2000)}, + relaySummaries: { + threadIdA: relaySummary(replyCount: 1, lastReplyAt: 2000), + }, ); final byId = {for (final entry in entries) entry.message.id: entry}; - expect(byId['a']!.summary!.replyCount, 2); - expect(byId['r1']!.summary!.replyCount, 1); - expect(byId['r1']!.summary!.participantPubkeys, ['carol']); - expect(byId['r1']!.summary!.lastReplyAt, 3000); + expect(byId[threadIdA]!.summary!.replyCount, 2); + expect(byId[threadIdR1]!.summary!.replyCount, 1); + expect(byId[threadIdR1]!.summary!.participantPubkeys, ['carol']); + expect(byId[threadIdR1]!.summary!.lastReplyAt, 3000); }); test('merged participants keep relay identities first, capped at 3', () { 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), + _textMsg(id: threadIdA, createdAt: 1000), + _replyMsg( + id: threadIdR1, + parentId: threadIdA, + pubkey: 'bob', + createdAt: 2000, + ), + _replyMsg( + id: 'r2', + parentId: threadIdA, + pubkey: 'carol', + createdAt: 3000, + ), ]); final entries = buildMainTimelineEntries( messages, relaySummaries: { - 'a': relaySummary( + threadIdA: relaySummary( replyCount: 1, lastReplyAt: 2000, participantPubkeys: const ['zoe', 'yara'], @@ -1137,15 +1250,27 @@ void main() { // larger count must not resurrect a deleted reply. `formatTimeline` drops // it from the local half first, which is what keeps `max` honest here. 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), + _textMsg(id: threadIdA, createdAt: 1000), + _replyMsg( + id: threadIdR1, + parentId: threadIdA, + pubkey: 'bob', + createdAt: 2000, + ), + _replyMsg( + id: 'r2', + parentId: threadIdA, + pubkey: 'carol', + createdAt: 3000, + ), _deletion(id: 'd1', targets: ['r2']), ]); final entries = buildMainTimelineEntries( messages, - relaySummaries: {'a': relaySummary(replyCount: 1, lastReplyAt: 2000)}, + relaySummaries: { + threadIdA: relaySummary(replyCount: 1, lastReplyAt: 2000), + }, ); expect(entries.single.summary!.replyCount, 1); @@ -1154,15 +1279,25 @@ void main() { test('a participant in both halves is not listed twice', () { 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), + _textMsg(id: threadIdA, createdAt: 1000), + _replyMsg( + id: threadIdR1, + parentId: threadIdA, + pubkey: 'BOB', + createdAt: 2000, + ), + _replyMsg( + id: 'r2', + parentId: threadIdA, + pubkey: 'carol', + createdAt: 3000, + ), ]); final entries = buildMainTimelineEntries( messages, relaySummaries: { - 'a': relaySummary( + threadIdA: relaySummary( replyCount: 2, lastReplyAt: 3000, participantPubkeys: const ['carol', 'bob'], @@ -1182,7 +1317,7 @@ void main() { final grouped = groupMembershipTimelineEntries( entries([ _systemMsg( - id: 'a', + id: threadIdA, payload: { 'type': 'member_joined', 'actor': 'alice', @@ -1212,14 +1347,18 @@ void main() { ); expect(grouped, hasLength(1)); - expect(grouped.single.map((entry) => entry.message.id), ['a', 'b', 'c']); + expect(grouped.single.map((entry) => entry.message.id), [ + threadIdA, + 'b', + 'c', + ]); }); test('groups self-joins from different people', () { final grouped = groupMembershipTimelineEntries( entries([ _systemMsg( - id: 'a', + id: threadIdA, payload: { 'type': 'member_joined', 'actor': 'alice', @@ -1242,7 +1381,7 @@ void main() { final grouped = groupMembershipTimelineEntries( entries([ _systemMsg( - id: 'a', + id: threadIdA, payload: { 'type': 'member_joined', 'actor': 'alice', @@ -1281,7 +1420,7 @@ void main() { final grouped = groupMembershipTimelineEntries( entries([ _systemMsg( - id: 'a', + id: threadIdA, payload: { 'type': 'member_joined', 'actor': 'alice', diff --git a/mobile/test/features/channels/unread_badge/unread_badge_provider_test.dart b/mobile/test/features/channels/unread_badge/unread_badge_provider_test.dart index a86a566e81..bffc9e97e1 100644 --- a/mobile/test/features/channels/unread_badge/unread_badge_provider_test.dart +++ b/mobile/test/features/channels/unread_badge/unread_badge_provider_test.dart @@ -8,6 +8,8 @@ import 'package:buzz/shared/read_state/read_state_provider.dart'; import 'package:buzz/features/channels/unread_badge/observed_unread_event.dart'; import 'package:buzz/features/channels/unread_badge/unread_badge_provider.dart'; +import '../../../helpers/thread_ids.dart'; + /// Unit tests for [unreadBadgeProvider]. /// /// Strategy: override [channelsProvider] with a [_StubbedChannelsNotifier] that @@ -167,7 +169,7 @@ void main() { }); test('activity: does not read a thread reply', () async { - final result = await badge(stream, event('m', rootId: 'root'), { + final result = await badge(stream, event('m', rootId: threadIdRoot), { 'activity:ch-a': t30, }); expect(result.generalUnreadCount, 1); @@ -188,11 +190,11 @@ void main() { }); test('thread-activity: reads replies in its thread only', () async { - final read = await badge(stream, event('m', rootId: 'root'), { - 'thread-activity:root': t20, + final read = await badge(stream, event('m', rootId: threadIdRoot), { + 'thread-activity:$threadIdRoot': t20, }); expect(read.generalUnreadCount, 0); - final other = await badge(stream, event('m', rootId: 'root'), { + final other = await badge(stream, event('m', rootId: threadIdRoot), { 'thread-activity:other': t30, }); expect(other.generalUnreadCount, 1); @@ -354,19 +356,19 @@ void main() { const channelId = 'ch-a'; final container = buildContainer( channels: [makeChannel(id: channelId, lastMessageAtSeconds: t30)], - readContexts: {'thread:root-1': t30}, + readContexts: {'thread:$threadIdRootDash1': t30}, observedEventsByChannel: { channelId: [ _observed( id: 'reply-1', createdAt: t20, - rootId: 'root-1', + rootId: threadIdRootDash1, isThreadedReply: true, ), _observed( id: 'reply-2', createdAt: t20, - rootId: 'root-2', + rootId: threadIdRootDash2, isThreadedReply: true, ), ], @@ -390,13 +392,13 @@ void main() { _observed( id: 'reply-1', createdAt: t20, - rootId: 'root-1', + rootId: threadIdRootDash1, isThreadedReply: true, ), _observed( id: 'reply-2', createdAt: t20, - rootId: 'root-1', + rootId: threadIdRootDash1, isThreadedReply: true, ), ], diff --git a/mobile/test/features/pulse/note_card_test.dart b/mobile/test/features/pulse/note_card_test.dart index 7a9492d4e5..0f660ea851 100644 --- a/mobile/test/features/pulse/note_card_test.dart +++ b/mobile/test/features/pulse/note_card_test.dart @@ -9,6 +9,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import '../../helpers/thread_ids.dart'; + class _FakeUserCacheNotifier extends UserCacheNotifier { final Map _users; @@ -35,7 +37,7 @@ void main() { createdAt: DateTime.utc(2025, 9, 30, 12).millisecondsSinceEpoch ~/ 1000, content: 'A reply', tags: [ - ['e', 'parent-note', '', 'reply'], + ['e', threadIdParentDashNote, '', 'reply'], ['p', second], ], ); diff --git a/mobile/test/helpers/thread_ids.dart b/mobile/test/helpers/thread_ids.dart new file mode 100644 index 0000000000..d9b08185b4 --- /dev/null +++ b/mobile/test/helpers/thread_ids.dart @@ -0,0 +1,93 @@ +// Valid event IDs for thread fixtures. Names retain the scenario vocabulary. +const threadIdA = + 'cda64cca437003cfa45e048e657ea16d84c976258dbf2bcc9b8671da7ed780fc'; +const threadIdBotDashMessage = + 'c8e8258450906baf898a88c29afec447315d586e1693f05dfe0a71eaafd7c2e1'; +const threadIdBroadcast = + '6bc5ac314996ff5d95c1a60078115d395ecb501cf6d715a9e91cb9187f9606dd'; +const threadIdLocalDashRoot = + 'd80a1acb7655fc14d04097c8f8de30ac8b0735101d3d6f861c4a0c70033803e4'; +const threadIdMid = + '231d7ca8ae13bac3ebd696c5c716423c07ab85d9d49ca8d4865ec7a8f7a35235'; +const threadIdNewDashRoot = + 'e6d295e744f0050d61680f203b637c76f8b3c2b631b78ad4ed13e6df81847e42'; +const threadIdNewUnderscoreParent = + '7717c240593ebc319e5c47af9dc26137b3d9183fbe259b7dc70076ecd3d3dc4a'; +const threadIdOldDashRoot = + '13508ffa8f8c267ef347c06ba6c523d29bac5febba8d9c86101c3e7473cc903e'; +const threadIdOldUnderscoreParent = + '870f30951b8a58571f95c17b866b91b4cfd8df9c3f9800f2162842c234710a3a'; +const threadIdOuterDashRoot = + '5f2441c40dfa4031b611a769d5480af8190b36afe934dbbfed78c0e06f4f7567'; +const threadIdOwner = + '07d3c362dbb52cf541a0c6fa0f66c194d6a3708038439cc35d41be42cda683ab'; +const threadIdParent = + '35e2659925fc3ed5d1433f37772bb6d3ff5062aa557bb2a3c4aa8206cceb3e59'; +const threadIdParent1 = + '052ae53ff85a8c9b02a6699b0792e2d88fea6773e71260e4c30d58b4db3a558d'; +const threadIdParentDashNote = + '6b512f2db72703829a14f28d84f313dc836f9ebb028102476f7628c6cbfd99b0'; +const threadIdParentDashReply = + '9fd28ae5a15a58b792157e5ef689c4bc47e3c0fbb943976ca6066d8d60a6443f'; +const threadIdR1 = + 'ebf874a4b0b1b2eae28b3eac554f895bdd0a5821d5d546c4ce8869075287c2c7'; +const threadIdRefreshDashRoot = + 'ee0e6ebc1b13e2751752060106c2d1c99e52dcd5feb7a917de0788f4a831c8b6'; +const threadIdRetained = + '89de9f0810a1ee27ca48249b067a2ad1aeea433a04ff8fcd9ce87027c606726f'; +const threadIdRoot = + '70fcdcb3018461973b5ab30b8485650f4b5a5a917d14ba8471de62f4e312b32d'; +const threadIdRoot1 = + '5feade3971cffc7089962f5386f38ebce3e214a3fc11e7d5e03e76ebb3fb3b76'; +const threadIdRootDash1 = + '722ee7881f4a2e36157247f7b555c7fd48154928613d628d3141ecb2da175eb9'; +const threadIdRootDash2 = + '51196df818f9c90509ae9aadb4e1d67a8a34bc12cdec683afa7526cfaa6f2d3d'; +const threadIdRootDash9 = + 'decac4ac88ce84fff8e6cb46def78cbb2da80a49ce38a2cb2c9e1a334b4b059f'; +const threadIdStickyDashThreadDashRoot = + '535e416282f60dfd00cb81d34effdd6902f128c8c75c8186777e4fe11349b39b'; +const threadIdTallDashRoot = + 'e42e600a65b80dcb5e4d6a444bfb5359266d88979a628bb6b0d76ceaaa256b0b'; +const threadIdTapDashRoot = + 'f29babc532b7cceaabacb531fa5b61715c13933e6a511d29b897dde23af7ccc9'; +const threadIdThreadDashRoot = + '6a431d1af075d290deddf39e5018c90250f7301c5ce9abaed28ed4ec95271080'; +const threadIdTopDashRoot = + '0c44b7e3be2808282a9f11ec9a144aff460cab80c3b14fb99867c834411d6fa3'; +const threadIdUnrelated = + 'd0ce3e35b5a76a22e9c81dbd60843672d36906033406a3640279f97c16fa361a'; +const threadIdVideoDashRoot = + 'eca034e64102b0ef0a4c5202afbef5570e4229ae28b342fc9383107a6e173f07'; + +const threadIdReplyZero = + '7265706c792d3000000000000000000000000000000000000000000000000000'; + +String indexedThreadId(String label) { + assert(label.length <= 32); + return label.codeUnits + .map((c) => c.toRadixString(16).padLeft(2, '0')) + .join() + .padRight(64, '0'); +} + +const threadIdOtherRoot = + 'e54aad59fd94333b15eb79e7fd803fb12d0572fd15eda9c14c2d53240cb77ea6'; + +const threadIdInboxRoot1 = + '5feade3971cffc7089962f5386f38ebce3e214a3fc11e7d5e03e76ebb3fb3b76'; + +const threadIdInboxTop = + '84c18f776617e2acec24db8ad4b4f76271bfcec8777cc608b3594ddaf1e319f5'; + +const threadIdInboxA = + 'cda64cca437003cfa45e048e657ea16d84c976258dbf2bcc9b8671da7ed780fc'; + +const threadIdInboxP = + 'be916ad31aba19f29c5d2746a1e5fae3baab44ca81b9c5ce795e43f71e45d490'; + +const threadIdInboxB = + '255f9f032274c31cf0d52b3005e8f29b42d89d5bb4ffa30645b9415e4d584490'; + +const threadIdInboxR = + '30961e01b3b182917abeac529df1ad972f51f4b344fa4f918a280696c807155b'; diff --git a/mobile/test/shared/relay/nostr_models_test.dart b/mobile/test/shared/relay/nostr_models_test.dart index 3fdd745c32..dccc25ea43 100644 --- a/mobile/test/shared/relay/nostr_models_test.dart +++ b/mobile/test/shared/relay/nostr_models_test.dart @@ -2,6 +2,66 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:buzz/shared/relay/nostr_models.dart'; void main() { + group('thread marker validation and normalization', () { + final parent = 'a' * 64; + final root = 'b' * 64; + NostrEvent event(List> tags) => NostrEvent( + id: 'c' * 64, + pubkey: 'd' * 64, + createdAt: 1, + kind: EventKind.streamMessage, + tags: tags, + content: 'reply', + sig: '', + ); + test('last valid markers win and normalize before thread indexing', () { + final result = event([ + ['e', 'c' * 64, '', 'reply'], + ['e', parent.toUpperCase(), '', 'reply'], + ['e', root.toUpperCase(), '', 'root'], + ['e', 'bad', '', 'reply'], + ['e', '', '', 'root'], + ]).threadReference; + expect(result, (parentId: parent, rootId: root)); + }); + test('malformed markers do not create thread links or hide valid ones', () { + for (final invalid in [ + '', + 'bad', + 'a' * 63, + 'a' * 65, + 'g' * 64, + 'é' * 64, + '${'a' * 63}\n', + ]) { + expect( + event([ + ['e', invalid, '', 'reply'], + ]).threadReference, + (parentId: null, rootId: null), + ); + expect( + event([ + ['e', parent, '', 'reply'], + ['e', invalid, '', 'reply'], + ['e', invalid, '', 'root'], + ]).threadReference, + (parentId: parent, rootId: parent), + ); + } + }); + test('root-only, unmarked, and non-e tags remain top-level', () { + expect( + event([ + ['e', root, '', 'root'], + ['e', parent], + ['p', parent, '', 'reply'], + ]).threadReference, + (parentId: null, rootId: null), + ); + }); + }); + test('NostrFilter serializes and preserves authors', () { const filter = NostrFilter( kinds: [EventKind.readState], diff --git a/mobile/test/shared/relay/relay_deadline_provider_retry_test.dart b/mobile/test/shared/relay/relay_deadline_provider_retry_test.dart index c2b53103c0..95729bf0a7 100644 --- a/mobile/test/shared/relay/relay_deadline_provider_retry_test.dart +++ b/mobile/test/shared/relay/relay_deadline_provider_retry_test.dart @@ -9,6 +9,8 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:shared_preferences/shared_preferences.dart'; +import '../../helpers/thread_ids.dart'; + // --------------------------------------------------------------------------- // Fake sessions // --------------------------------------------------------------------------- @@ -152,7 +154,7 @@ void main() { // ------------------------------------------------------------------------- group('threadRepliesProvider deadline retry suppression', () { - const args = ThreadRepliesArgs(channelId: 'chan', rootId: 'root'); + const args = ThreadRepliesArgs(channelId: 'chan', rootId: threadIdRoot); test( 'deadline: settles error in exactly one call and does not rebuild',