diff --git a/mobile/lib/features/channels/channels_page/body.dart b/mobile/lib/features/channels/channels_page/body.dart index ce2d3fb8d6..026ee822bb 100644 --- a/mobile/lib/features/channels/channels_page/body.dart +++ b/mobile/lib/features/channels/channels_page/body.dart @@ -168,7 +168,7 @@ class _SliverChannelsList extends HookConsumerWidget { return; } - final notifier = ref.read(readStateProvider.notifier); + final seeds = {}; for (final channel in visibleChannels) { if (readState.effectiveTimestamp(channel.id) != null) { continue; @@ -176,9 +176,10 @@ class _SliverChannelsList extends HookConsumerWidget { final lastMessageAt = dateTimeToUnixSeconds(channel.lastMessageAt); if (lastMessageAt != null) { - notifier.seedContextRead(channel.id, lastMessageAt); + seeds[channel.id] = lastMessageAt; } } + ref.read(readStateProvider.notifier).seedContextsRead(seeds); initialSeedComplete.value = true; }); }, [readState.isReady, readState.pubkey, visibleChannels]); diff --git a/mobile/lib/shared/read_state/read_state_manager.dart b/mobile/lib/shared/read_state/read_state_manager.dart index 53cf19e136..4c80c2f697 100644 --- a/mobile/lib/shared/read_state/read_state_manager.dart +++ b/mobile/lib/shared/read_state/read_state_manager.dart @@ -131,11 +131,28 @@ class ReadStateManager { currentUnixSeconds(), _maxFetchedCreatedAt + 1, ); - _advanceContext(contextId, unixTimestamp, publishable: true); + if (_advanceContext(contextId, unixTimestamp, publishable: true)) { + _persistLocalState(); + _onChanged(); + _schedulePublish(); + } } void seedContextRead(String contextId, int unixTimestamp) { - _advanceContext(contextId, unixTimestamp, publishable: false); + seedContextsRead({contextId: unixTimestamp}); + } + + /// Advance local-only seeds together, saving and notifying once per batch. + void seedContextsRead(Map contexts) { + var changed = false; + for (final entry in contexts.entries) { + if (_advanceContext(entry.key, entry.value, publishable: false)) { + changed = true; + } + } + if (!changed) return; + _persistLocalState(); + _onChanged(); } Future flush() async { @@ -180,35 +197,28 @@ class ReadStateManager { _unsubscribeLive = null; } - void _advanceContext( + bool _advanceContext( String contextId, int unixTimestamp, { required bool publishable, }) { - if (_disposed || unixTimestamp < 0) return; + if (_disposed || unixTimestamp < 0) return false; final current = _effectiveState[contextId] ?? 0; if (unixTimestamp <= current) { if (!publishable || _publishableContextIds.contains(contextId)) { - return; + return false; } _publishableContextIds.add(contextId); - _persistLocalState(); - _onChanged(); - _schedulePublish(); - return; + return true; } _effectiveState[contextId] = unixTimestamp; if (publishable) { _publishableContextIds.add(contextId); } - _persistLocalState(); - _onChanged(); - if (publishable) { - _schedulePublish(); - } + return true; } Future _fetchAndMerge() async { diff --git a/mobile/lib/shared/read_state/read_state_provider.dart b/mobile/lib/shared/read_state/read_state_provider.dart index 1059525b1a..f47ea707ee 100644 --- a/mobile/lib/shared/read_state/read_state_provider.dart +++ b/mobile/lib/shared/read_state/read_state_provider.dart @@ -200,6 +200,11 @@ class ReadStateNotifier extends Notifier { _manager?.seedContextRead(contextId, unixTimestamp); } + /// Seed a channel-list snapshot with one local save and state emission. + void seedContextsRead(Map contexts) { + _manager?.seedContextsRead(contexts); + } + void _emitManagerState(ReadStateManager manager) { if (_manager != manager) return; final advances = manager.drainSyncedAdvances(); diff --git a/mobile/test/features/channels/channels_page_test.dart b/mobile/test/features/channels/channels_page_test.dart index 10acee918d..804a92e0eb 100644 --- a/mobile/test/features/channels/channels_page_test.dart +++ b/mobile/test/features/channels/channels_page_test.dart @@ -1,3 +1,7 @@ +import 'dart:convert'; +import 'package:nostr/nostr.dart' as nostr; +import 'package:shared_preferences_platform_interface/shared_preferences_platform_interface.dart'; +import 'package:buzz/shared/read_state/read_state_storage.dart'; import 'package:buzz/features/home/home_page.dart'; import 'package:buzz/shared/widgets/app_route_observer.dart'; import 'package:buzz/features/pairing/pairing_provider.dart'; @@ -62,6 +66,7 @@ import '../../helpers/thread_ids.dart'; part 'channels_page_test/presence_tests.dart'; part 'channels_page_test/dm_recipient_layout_tests.dart'; part 'channels_page_test/unread_readiness_tests.dart'; +part 'channels_page_test/read_seed_journey.dart'; void main() { setUpAll(() async { @@ -159,6 +164,7 @@ void main() { } _communityUnreadLandingTests(buildTestable); + _readSeedJourney(buildTestable); _communityCachedLandingTests(buildTestable); final testChannels = [ @@ -3717,51 +3723,6 @@ void main() { }); } - testWidgets('seeds first loaded channels as read', (tester) async { - final channels = [ - Channel( - id: '1', - name: 'general', - channelType: 'stream', - visibility: 'open', - description: 'General discussion', - createdBy: 'abc', - createdAt: DateTime(2025), - memberCount: 10, - lastMessageAt: DateTime.fromMillisecondsSinceEpoch( - 20 * 1000, - isUtc: true, - ), - isMember: true, - ), - ]; - final readState = _FakeReadStateNotifier( - const ReadStateState( - isReady: true, - pubkey: 'pk', - contexts: {}, - version: 0, - ), - ); - - await tester.pumpWidget( - buildTestable( - overrides: [ - channelsProvider.overrideWith(() => _FakeNotifier(channels)), - readStateProvider.overrideWith(() => readState), - ], - ), - ); - await tester.pumpAndSettle(); - - expect(readState.seededContexts, {'1': 20}); - expect(readState.markedContexts, isEmpty); - expect( - tester.widget(find.text('general')).style?.fontWeight, - FontWeight.w400, - ); - }); - testWidgets('waits for read-state readiness before initial seeding', ( tester, ) async { @@ -3996,6 +3957,13 @@ class _FakeReadStateNotifier extends ReadStateNotifier { ); } + @override + void seedContextsRead(Map contexts) { + for (final entry in contexts.entries) { + seedContextRead(entry.key, entry.value); + } + } + @override void seedContextRead(String contextId, int unixTimestamp) { seededContexts[contextId] = unixTimestamp; diff --git a/mobile/test/features/channels/channels_page_test/read_seed_journey.dart b/mobile/test/features/channels/channels_page_test/read_seed_journey.dart new file mode 100644 index 0000000000..9119e10339 --- /dev/null +++ b/mobile/test/features/channels/channels_page_test/read_seed_journey.dart @@ -0,0 +1,169 @@ +part of '../channels_page_test.dart'; + +void _readSeedJourney( + Widget Function({required List overrides}) buildTestable, +) { + testWidgets('cold community seeds read state with one saved snapshot', ( + tester, + ) async { + SharedPreferences.setMockInitialValues({}); + final store = _ReadSeedStore(); + SharedPreferencesStorePlatform.instance = store; + final prefs = await SharedPreferences.getInstance(); + final keys = nostr.Keys.generate(); + final session = _ReadSeedSession(keys.public); + final readKey = localReadStateKey(keys.public, 'https://seed.example'); + // Preserve a real pre-existing unread frontier while seeding new channels. + await prefs.setString( + readKey, + jsonEncode({ + 'channel-0': DateTime.fromMillisecondsSinceEpoch( + 10000, + isUtc: true, + ).toIso8601String(), + }), + ); + store.nonemptySnapshots.clear(); + await tester.pumpWidget( + buildTestable( + overrides: [ + savedPrefsProvider.overrideWithValue(prefs), + relayConfigProvider.overrideWith(() => _ReadSeedConfig(keys.nsec)), + relaySessionProvider.overrideWith(() => session), + activeCommunityProvider.overrideWith((ref) async => null), + ], + ), + ); + await tester.pumpAndSettle(); + final container = ProviderScope.containerOf( + tester.element(find.byType(ChannelsPage)), + ); + final reads = container.read(readStateProvider); + expect(reads.contexts, { + 'channel-0': 10, + for (var i = 1; i < 400; i++) 'channel-$i': 20, + }); + // Ignore hydration saves of the existing mark; landing saves all seeds together. + expect(store.nonemptySnapshots.where((count) => count > 1), [400]); + expect( + tester.widget(find.text('channel-0')).style?.fontWeight, + FontWeight.w700, + ); + expect( + tester.widget(find.text('channel-1')).style?.fontWeight, + FontWeight.w400, + ); + final saved = ReadStateStorage( + prefs, + ).read(keys.public, 'https://seed.example'); + expect(saved.contexts, reads.contexts); + expect(saved.publishableContextIds, isEmpty); + await tester.pump(const Duration(seconds: 6)); + expect(session.readStatePublishes, 0); + // A refresh/rebuild does not re-seed or save unchanged marks. + await container.read(channelsProvider.notifier).refresh(); + await tester.pumpAndSettle(); + expect(store.nonemptySnapshots.where((count) => count > 1), [400]); + expect(tester.takeException(), isNull); + await tester.pumpWidget(const SizedBox()); + }); +} + +class _ReadSeedStore extends InMemorySharedPreferencesStore { + _ReadSeedStore() : super.empty(); + final nonemptySnapshots = []; + + @override + Future setValue(String valueType, String key, Object value) async { + if (key.contains('buzz.channel-read-state.v2:')) { + final contexts = jsonDecode(value as String) as Map; + if (contexts.isNotEmpty) nonemptySnapshots.add(contexts.length); + } + return super.setValue(valueType, key, value); + } +} + +class _ReadSeedConfig extends RelayConfigNotifier { + _ReadSeedConfig(this.nsec); + final String nsec; + @override + RelayConfig build() => + RelayConfig(baseUrl: 'https://seed.example', nsec: nsec); +} + +class _ReadSeedSession extends RelaySessionNotifier { + _ReadSeedSession(this.pubkey); + final String pubkey; + int readStatePublishes = 0; + @override + SessionState build() => const SessionState(status: SessionStatus.connected); + + NostrEvent event(int kind, String channel, List> tags) => + NostrEvent( + id: '$kind-$channel', + pubkey: kind == 9 ? 'other-member' : pubkey, + createdAt: 20, + kind: kind, + tags: tags, + content: '', + sig: '', + ); + + @override + Future> queryRelay( + List filters, { + Duration timeout = const Duration(seconds: 8), + }) async { + final filter = filters.first; + if (filter.kinds.contains(39002)) { + if (filter.until != null) return []; + return [ + for (var i = 0; i < 400; i++) + event(39002, 'channel-$i', [ + ['d', 'channel-$i'], + ['p', pubkey], + ]), + ]; + } + return [ + for (final f in filters) + if (f.tags['#h'] != null) + for (final id in f.tags['#h']!) + event(9, id, [ + ['h', id], + ]), + ]; + } + + @override + Future> fetchHistory( + NostrFilter filter, { + Duration timeout = const Duration(seconds: 8), + }) async => filter.kinds.contains(39000) + ? [ + for (var i = 0; i < 400; i++) + event(39000, 'channel-$i', [ + ['d', 'channel-$i'], + ['name', 'channel-$i'], + ['t', 'stream'], + ['public'], + ]), + ] + : []; + + @override + Future subscribe( + NostrFilter filter, + void Function(NostrEvent) onEvent, { + void Function(String)? onClosed, + }) async => () {}; + + @override + Future publish( + NostrEvent event, { + Duration timeout = const Duration(seconds: 8), + }) async { + if (event.kind == EventKind.readState) readStatePublishes++; + return event; + } +} diff --git a/mobile/test/features/channels/me/me_unread_test.dart b/mobile/test/features/channels/me/me_unread_test.dart index 86488699e5..bcd585b3bf 100644 --- a/mobile/test/features/channels/me/me_unread_test.dart +++ b/mobile/test/features/channels/me/me_unread_test.dart @@ -73,6 +73,13 @@ class _Reads extends ReadStateNotifier { final bool empty; final bool ready; _Reads({this.empty = false, this.ready = true}); + @override + void seedContextsRead(Map contexts) { + for (final entry in contexts.entries) { + seedContextRead(entry.key, entry.value); + } + } + @override void seedContextRead(String key, int timestamp) => read(key, timestamp); @override