fix(mobile): retry failed channel searches after reconnecting (#8968)

## 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 <jtennant@squareup.com>
Co-authored-by: Simone <0828be588d5c9c4d092c5b1cf01761d5aef63d6b104807fd8e8284d517dd1a99@buzz.block.builderlab.xyz>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
John Matthew Tennant
2026-11-02 20:29:13 -05:00
committed by GitHub
parent cdd2af1a35
commit 205ff47744
2 changed files with 108 additions and 7 deletions

View File

@@ -198,6 +198,15 @@ class SearchNotifier extends Notifier<SearchState> {
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<SearchState> {
_ => 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<SearchState> {
ref.read(channelsProvider).asData?.value ?? const <Channel>[],
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,
);
}

View File

@@ -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<void Function()> 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<List<NostrEvent>> fetchHistory(
NostrFilter filter, {
Duration timeout = const Duration(seconds: 8),
}) => filter.search == null
? Future.value(const [])
: super.fetchHistory(filter, timeout: timeout);
@override
Future<List<NostrEvent>> queryRelay(
List<NostrFilter> 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',
),
];
}