diff --git a/mobile/lib/features/activity/activity_provider.dart b/mobile/lib/features/activity/activity_provider.dart index 60df2efdfc..26a5882a55 100644 --- a/mobile/lib/features/activity/activity_provider.dart +++ b/mobile/lib/features/activity/activity_provider.dart @@ -71,7 +71,18 @@ class ActivityNotifier extends AsyncNotifier { @override Future build() async { ref.watch(relayConfigProvider); - final sessionState = ref.watch(relaySessionProvider); + // Read again once per new connection, for what it missed and for live + // subscriptions. The socket states on the way there (disconnected, + // reconnecting) would each download the whole feed again. + ref.listen( + relaySessionProvider.select( + (session) => session.status == SessionStatus.connected, + ), + (_, connected) { + if (connected) ref.invalidateSelf(); + }, + ); + final sessionState = ref.read(relaySessionProvider); // React to the DM channel set (loading → data, membership changes) so a // cold start where channels resolve after the first fetch still surfaces // DMs without a manual refresh. diff --git a/mobile/test/features/activity/activity_provider_test.dart b/mobile/test/features/activity/activity_provider_test.dart index 61280645bd..449325bb8f 100644 --- a/mobile/test/features/activity/activity_provider_test.dart +++ b/mobile/test/features/activity/activity_provider_test.dart @@ -40,6 +40,8 @@ class _RecordingSessionNotifier extends RelaySessionNotifier { @override SessionState build() => const SessionState(status: SessionStatus.connected); + void setStatus(SessionState next) => state = next; + @override Future> fetchHistory( NostrFilter filter, { @@ -305,6 +307,48 @@ void main() { expect(session.dmQueries, isEmpty); }); + test( + 'reads the feed once per reconnect, not once per socket state', + () async { + final session = _RecordingSessionNotifier() + ..seed(_mentionEvent('mention', 1_700_000_001)); + final container = ProviderContainer( + overrides: [ + relayConfigProvider.overrideWith(_FixedRelayConfigNotifier.new), + myPubkeyProvider.overrideWithValue('me_pk'), + relaySessionProvider.overrideWith(() => session), + channelsProvider.overrideWith( + () => _FixedChannelsNotifier(const []), + ), + ], + ); + addTearDown(container.dispose); + final sub = container.listen(activityProvider, (_, _) {}); + addTearDown(sub.close); + + await container.read(channelsProvider.future); + await container.read(activityProvider.future); + await Future.delayed(Duration.zero); + await container.read(activityProvider.future); + final settled = session.mentionFetchCount; + + // Like a resume after the background grace period. + for (final next in const [ + SessionState(status: SessionStatus.disconnected), + SessionState(status: SessionStatus.reconnecting), + SessionState(status: SessionStatus.reconnecting, reconnectAttempt: 1), + SessionState(status: SessionStatus.connected), + ]) { + session.setStatus(next); + await Future.delayed(Duration.zero); + } + final feed = await container.read(activityProvider.future); + + expect(session.mentionFetchCount, settled + 1); + expect(feed.mentions.map((item) => item.id), ['mention']); + }, + ); + test('falls back to websocket history when the HTTP batch fails', () async { final session = _RecordingSessionNotifier() ..seed(_mentionEvent('fallback-mention', 1_700_000_001))