diff --git a/mobile/lib/features/search/search_provider.dart b/mobile/lib/features/search/search_provider.dart index c04122514e..b490218176 100644 --- a/mobile/lib/features/search/search_provider.dart +++ b/mobile/lib/features/search/search_provider.dart @@ -198,6 +198,15 @@ class SearchNotifier extends Notifier { channelDirectoryLoadStatusProvider, (_, _) => _refreshChannels(), ); + // Offline, a directory load fails at once. Load it again on reconnect, so + // the failure clears without another query. + ref.listen(relaySessionProvider, (previous, next) { + if (next.status == SessionStatus.connected && + previous?.status != SessionStatus.connected && + _directoryFailed) { + retryChannels(); + } + }); ref.onDispose(() { _debounce?.cancel(); }); @@ -261,6 +270,14 @@ class SearchNotifier extends Notifier { _ => true, }; + /// Whether this search loaded the directory for the current query and it + /// did not load. An earlier failure stays hidden while the next load runs. + bool get _directoryFailed => + _searchesOpenChannels(state.query) && + _directoryAttempted && + _directoryLoad == null && + _directoryMissing; + /// Loads the directory again after it failed, like desktop's Retry channels. void retryChannels() { _loadDirectory(); @@ -376,13 +393,8 @@ class SearchNotifier extends Notifier { ref.read(channelsProvider).asData?.value ?? const [], state.query, ), - // An earlier failure stays hidden while our next load runs, and the - // notice clears when another caller loads the directory. - channelSearchFailed: - _searchesOpenChannels(state.query) && - _directoryAttempted && - _directoryLoad == null && - _directoryMissing, + // The notice clears when another caller loads the directory. + channelSearchFailed: _directoryFailed, isLoading: _isLoading, ); } diff --git a/mobile/test/features/search/search_channels_journey_test.dart b/mobile/test/features/search/search_channels_journey_test.dart index ad44f21a71..48411592c8 100644 --- a/mobile/test/features/search/search_channels_journey_test.dart +++ b/mobile/test/features/search/search_channels_journey_test.dart @@ -460,6 +460,44 @@ void main() { expect(spinner, findsOneWidget); expect(find.byKey(const Key('search-channels-failed')), findsNothing); }); + + testWidgets( + 'a reconnect reloads the directory a disconnected search missed', + (tester) async { + final session = _ReconnectingSession(); + final actions = _HeldUserSearch(); + + await tester.pumpWidget( + WidgetHelpers.testable( + overrides: [ + relayConfigProvider.overrideWith(_Community.new), + myPubkeyProvider.overrideWith((ref) => 'test'), + relaySessionProvider.overrideWith(() => session), + channelActionsProvider.overrideWith((ref) => actions), + channelsProvider.overrideWith(_NoMemberChannels.new), + recentSearchesProvider.overrideWith(_NoRecentSearches.new), + profileProvider.overrideWith(_Profile.new), + ], + child: const SearchPage(), + ), + ); + await tester.pumpAndSettle(); + + // Offline, the directory cannot load, and search says so. + await tester.enterText(find.byType(TextField), 'ops'); + await tester.pump(const Duration(milliseconds: 300)); + session.searches.removeAt(0).complete(const []); + actions.searches.removeAt(0).complete(const []); + await tester.pump(); + expect(find.byKey(const Key('search-channels-failed')), findsOneWidget); + + // Reconnecting loads it for the same query, with no typing. + session.connect(); + await tester.pumpAndSettle(); + expect(find.byKey(const Key('search-channels-failed')), findsNothing); + expect(find.text('ops-room'), findsOneWidget); + }, + ); } Channel _channel(String name, {required bool isMember}) => Channel( @@ -626,3 +664,54 @@ class _MembershipHeldSession extends _HeldSession { return membership.future; } } + +/// Starts offline and answers channel-list reads once [connect]ed, listing +/// one open channel, "ops-room". +class _ReconnectingSession extends _HeldSession { + @override + SessionState build() => + const SessionState(status: SessionStatus.disconnected); + + @override + Future subscribeWithStatus( + NostrFilter filter, + void Function(NostrEvent) onEvent, { + void Function(String message)? onClosed, + required void Function(RelaySubscriptionStatus status) onStatusChanged, + }) async { + onStatusChanged(RelaySubscriptionStatus.ready); + return () {}; + } + + void connect() => state = const SessionState(status: SessionStatus.connected); + + @override + Future> fetchHistory( + NostrFilter filter, { + Duration timeout = const Duration(seconds: 8), + }) => filter.search == null + ? Future.value(const []) + : super.fetchHistory(filter, timeout: timeout); + + @override + Future> queryRelay( + List filters, { + Duration timeout = const Duration(seconds: 8), + }) async => [ + if (filters.single.kinds.contains(39000) && filters.single.until == null) + const NostrEvent( + id: 'meta-ops', + pubkey: 'relay', + createdAt: 2, + kind: 39000, + tags: [ + ['d', 'ops-id'], + ['name', 'ops-room'], + ['t', 'stream'], + ['public'], + ], + content: '', + sig: 'sig', + ), + ]; +}