From 8222e99915c7c4a3311e32c5dee989df88c49a48 Mon Sep 17 00:00:00 2001 From: Tom Brow Date: Wed, 14 Oct 2026 18:42:39 -0700 Subject: [PATCH] fix(mobile): keep thread references consistent across push and Inbox (#8332) Follow-up to [#8294](https://github.com/block/buzz/pull/8294). Malformed trailing thread tags could make a notification open the right thread while Flutter indexed its reply elsewhere; uppercase references could similarly miss lowercase event IDs. Share one validated thread-reference parser between the mobile timeline, Inbox, and feed helper. It ignores malformed markers, keeps the last valid root/reply, and normalizes lookup IDs to lowercase. Native push parsing now uses the same casing. Existing thread fixtures use valid event IDs so they continue exercising real thread relationships. Add RunnerTests coverage for the production native-to-Flutter argument conversion, including buffered cold-start navigation and omitted optional thread IDs, and run it in the native CI lane. ### Validation - New parser and Inbox regressions cover malformed markers and uppercase references. Widget regressions open and highlight the exact reply for valid, uppercase, and trailing-malformed tags. - After integrating [#8106](https://github.com/block/buzz/pull/8106), Flutter analysis and the complete mobile suite pass: 3,337 tests, four existing skips. Integration preserves per-event read predicates and migrates incoming thread fixtures to valid IDs. An unchanged keyboard-history test also failed with main's original parser; recording reads before the scroll transition fixes its timing assumption while retaining visibility assertions. - Native package tests passed (127 tests), and both Runner bridge tests passed on an iOS simulator with Xcode 26.5. Removing the production `threadRootId` assignment made the bridge regression fail; restoring it passed. Native code is unchanged by the main integration. - [Current-head CI passed](https://github.com/block/buzz/actions/runs/38659307247), including Mobile and Mobile Swift. [Codex review](https://github.com/block/buzz/pull/8332#issuecomment-6181458528) and [security review](https://github.com/block/buzz/pull/8332#issuecomment-6181449235) found no issues on the integrated commit. [Owner approval](https://github.com/block/buzz/pull/8332#pullrequestreview-5526812957) covers the exact integrated commit. - Standards and spec reviews found no blockers, including the merge resolutions and test adjustments. Validation is scoped to mobile/native per VISION_MOBILE; unrelated repository-wide checks were stopped, not counted as passing. The bridge test covers serialization and buffering, not end-to-end APNs delivery or app launch. No new physical-device acceptance is claimed; routine post-merge dogfood remains human-owned. --------- Signed-off-by: Tom Brow --- .github/workflows/_ci-clients.yml | 9 +- .../BuzzPushNavigationTarget.swift | 2 +- .../BuzzPushNavigationTargetTests.swift | 2 +- mobile/ios/Runner/AppDelegate.swift | 2 +- .../BuzzCommunicationNotificationTests.swift | 24 + mobile/lib/features/activity/feed_item.dart | 11 +- mobile/lib/features/activity/inbox_item.dart | 17 +- mobile/lib/shared/relay/nostr_models.dart | 35 +- mobile/lib/shared/relay/thread_reference.dart | 22 + .../features/activity/activity_page_test.dart | 22 +- .../features/activity/feed_item_test.dart | 23 +- .../features/activity/inbox_item_test.dart | 154 ++- .../activity/inbox_read_state_test.dart | 79 +- .../channels/channel_detail_page_test.dart | 647 +++++----- .../bubble_tests.dart | 33 +- .../loading_review_tests.dart | 24 +- .../channel_messages_provider_test.dart | 1117 ++++++++++------- .../features/channels/channels_page_test.dart | 14 +- .../channels/message_actions_test.dart | 12 +- .../thread_follows_storage_test.dart | 13 +- .../thread_replies_provider_test.dart | 15 +- .../channels/thread_reply_ownership_test.dart | 8 +- .../channels/thread_reply_refresh_cases.dart | 5 +- .../channels/timeline_message_test.dart | 411 ++++-- .../unread_badge_provider_test.dart | 20 +- .../test/features/pulse/note_card_test.dart | 4 +- mobile/test/helpers/thread_ids.dart | 93 ++ .../test/shared/relay/nostr_models_test.dart | 60 + .../relay_deadline_provider_retry_test.dart | 4 +- 29 files changed, 1807 insertions(+), 1075 deletions(-) create mode 100644 mobile/lib/shared/relay/thread_reference.dart create mode 100644 mobile/test/helpers/thread_ids.dart 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',