From 205ff47744bd7a2dc542346b900a06363fb57f67 Mon Sep 17 00:00:00 2001 From: John Matthew Tennant Date: Mon, 2 Nov 2026 20:29:13 -0500 Subject: [PATCH] fix(mobile): retry failed channel searches after reconnecting (#8968) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Searching while disconnected leaves “Open channel search couldn't finish” visible after the connection returns. Users must change the query or tap Retry channels to recover. This fix automatically retries the open-channel directory search when the relay reconnects, using the current query. It does not rerun message search or explain the separate onboarding setup failure. ### Related issue None found. ### Testing The widget regression searches while disconnected, reconnects without typing, and checks that the error clears and an unjoined open channel appears. It fails on main and passes with this fix. On the iOS simulator at this PR head, restarting the local relay cleared the error within about five seconds without changing the query; the unjoined channel remained listed. ### Screenshots Relay down: the error shows under the cached channel. ![Relay down: open channel search error](https://github.com/user-attachments/assets/9014735e-2743-444a-aa77-7d9d2126f8b3) Relay back, same query, no typing: the error is gone and the unjoined channel stays listed. ![After reconnect: error cleared](https://github.com/user-attachments/assets/beed543f-a6a4-4e88-aae1-3c061bb016b2) --------- Signed-off-by: John Matthew Tennant Co-authored-by: Simone <0828be588d5c9c4d092c5b1cf01761d5aef63d6b104807fd8e8284d517dd1a99@buzz.block.builderlab.xyz> Co-authored-by: Claude Opus 5.5 --- .../lib/features/search/search_provider.dart | 26 ++++-- .../search/search_channels_journey_test.dart | 89 +++++++++++++++++++ 2 files changed, 108 insertions(+), 7 deletions(-) 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', + ), + ]; +}