From 2e4aacc9e05b88f2ab6327a29e123e1b8af4e382 Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Sun, 1 Nov 2026 10:28:58 -0500 Subject: [PATCH] fix(mobile): find open channels in search and stop the stuck spinner (#8764) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Mobile search could not find an open channel you had not joined. In the Channels tab it could also show an empty list with a spinner that never stopped. This change fixes both and makes mobile rank channel results the way desktop (buzz-app) does. **Open channels you have not joined.** Mobile ranked channels from the loaded channel list, which holds open channels only after Browse channels has loaded the directory. Desktop search also looks up public channels (`usePublicChannelSearch`). Mobile now loads the open-channel directory when a search starts (`ChannelsNotifier.ensureDirectoryLoaded(maxAge:)`). It reads the directory again when it is older than 30 s, as buzz-app's `PUBLIC_CHANNEL_PAGE_TTL` does. Results are ranked again whenever the channel list or the directory status changes. Unjoined channels already render with the "Open" badge. **Directory failure.** If the directory load fails, search shows "Open channel search couldn't finish." with a Retry channels button, as buzz-app does, instead of "No results". Screen readers announce the notice (live region). The next query also retries. **Stuck spinner.** The message, people and channel lookups shared one `isLoading` flag. When no messages and no channels matched and the people lookup finished last, it set `isLoading = messages.isEmpty && channels.isEmpty`. That turned the spinner back on, and nothing turned it off again. Each relay lookup now has its own pending flag. The spinner shows while any lookup is still running, or while a directory load is running: one this search started, until its future completes, or one another caller started in this community. The shared directory status alone is not enough, because a real refresh marks it loading only after memberships arrive, and loaded before the new list is installed. Lookups are fenced by a query generation, so a late reply to an earlier query, even one with the same text, cannot settle the current search. **Displaced directory loads.** A membership refresh that starts while the directory loads retires that load. It used to mark the directory as failed, so search showed "Open channel search couldn't finish." when nothing had failed. It now marks the load idle. A refresh that starts earlier, during the membership round trips, retires the load before it is marked loading and leaves no status at all. In both cases search loads the directory again once. If that load is retired too, search offers Retry instead of looping. A real relay error still shows Retry at once and is not retried automatically. When a directory refresh starts, it clears an earlier failure for the same community to idle, so a failure left from before (for example before a reconnect) cannot be read as the new attempt's result and skip its retry. The notice follows the live directory status, so it clears when another caller loads the directory. **Directory races.** A community switch drops the cached directory, but the directory status still said the earlier community was loaded, so switching away and back within 30 s skipped the reload. `ensureDirectoryLoaded` now also requires the cached directory to come from the current community. A search during the first channel load used to start a directory refresh that retired the pending load; if that load's reply came last, its stale failure replaced the list. `retryDirectory` now waits for a pending first load, as `fetchFresh` does. Both came from the Codex security review of this branch. **Only the current community's list.** While a community switch reloads the channel list, Riverpod keeps the previous community's list as the loading value. Search now ranks only installed data (`asData`), so the old community's channels cannot match. (The signing identity derives from the community config, so the existing config watch already resets search on an identity change.) The idle marking and the `asData` read come from Wes's https://github.com/block/buzz/pull/8869, which fixed the same bug in parallel. **Same ranking as desktop.** `name_match.dart` ports buzz-app's `matchName` (`src/features/search/match.ts`). Its ranks, best first: - exact - prefix - word start - substring - word runs (`tlp` finds `team-launch-planning`; `buzz community` finds `buzz-community`) - letters in order `SearchNotifier` orders results like buzz-app's `SearchResults.tsx`: - Joined and open channels share one ranking, and joined channels win ties. Open channels with the same rank are ordered by name, then id, like buzz-app's `searchPublic`. - An archived channel ranks half a step behind a live one and stays findable only by its members. - A bare `#` lists joined channels only. Like desktop, it does not load the open-channel directory, so it shows no directory spinner and no failure notice. - The list shows the top 8. A filter tab with none of its own results now shows "No results" instead of a blank list. Plain substring matching on descriptions is removed; buzz-app does not match descriptions. **Differences from desktop:** - Desktop also orders by the viewer's past search choices. Mobile has no such history yet. - Desktop shows Solo chats and DMs in their own groups. Mobile keeps Solo channels in its Channels list. - `from:`, `in:`, `since:` and `until:` operators and desktop's people ranking are not ported. Mobile still sends the raw query to the relay. These can be a separate PR. - Lowercasing differs for a few letters: JS lowercases `İ` to `i` plus U+0307 and Dart does not, so `İstanbul` / `istanbul` ranks exact on mobile and word runs on desktop. - Desktop ranks channels at each keystroke. Mobile still waits for the 300 ms debounce, so channel rows clear while typing. - Open channels that tie in rank are ordered with Dart's case-sensitive `compareTo`; buzz-app uses `localeCompare`. Only names with capital letters can order differently. - A directory refresh runs the full channel-list reload (`retryDirectory`), not only the directory read. With the 30 s TTL this happens at most once per 30 s of searching. ### Related issue Reported in Buzz #buzz-bugs: a search for `4things` on mobile found nothing. No GitHub issue. No duplicate open PRs found. ### Testing - `search_channels_journey_test.dart` drives the real `SearchNotifier` through `SearchPage` with held relay lookups. It covers: - an unjoined open channel that appears when the directory arrives, with the "Open" badge, ranked above a joined channel with a weaker match - the spinner stopping when the people lookup finishes last - a letters-in-order query - a query with no matches that shows "No results" - a clear and retype of the same text, where late replies do not stop the spinner - the Channels tab showing "No results" when only People matched - a bare `#` whose directory finishes last, then failed directory loads that show the notice instead of "No results", then Retry channels finding the open channel - a community switch during a directory load, where the retired load does not settle the new search - A third widget test drives the real `ensureDirectoryLoaded` → `retryDirectory` path, with the membership query held at the relay boundary. The spinner stays on while that query is held. A failure shows the notice, and another caller's directory load also keeps the spinner on. - The journey fails on main's `search_provider.dart`. It also fails in each of these mutations: - the old loading rule restored - the generation fence replaced by the old query-text check - membership sorted before match rank - `name_match_test.dart` checks 20 name/query pairs. Their expected ranks come from running buzz-app's `matchRank` on the same inputs. - `search_channel_rank_test.dart` is a table test of `rankSearchChannels`: joined-wins ties, open ties by name, archived ordering and membership, private and DM filtering, `#` handling, and the 8-row cutoff. - `channels_provider_test.dart` checks that `ensureDirectoryLoaded` reads again only once the directory is older than `maxAge`. - 17 mutations of the review fixes each fail a test: the archived filter, the archived penalty, the 8-row limit, joined ties, open ties, `#` handling (×2), the failure flag, the failure flag while loading, search-owned pending, the load fence, the rebuild reset, the scoped status spinner, the search TTL, the TTL check, filter-aware "No results", and Retry. - Displaced loads (`efc17a5d4`, `22e52d687`): the journey retires a load before it is marked loading, then displaces the retry after (one retry, then Retry channels with no "No results"). It also checks that the notice clears when another caller loads the directory, and that it is not shown during the first debounce. A displaced Retry recovers. A community switch whose held list reload must match neither the old community's `general` channel nor its name on a message hit. The production-path test holds the real refresh's membership query, runs `refresh()`, and asserts the one retry. `channels_provider_test.dart` expects a displaced load to end idle. Each of 8 mutations fails a test: no retry, retry only on idle, retry on a relay error, notice only on error, no attempted flag, `.value` in ranking, `.value` in message names, and `markError` on displacement. - Review fixes (`29c74e8c5`, `fe4276538`): both displaced-load journeys now start from an earlier same-scope failure (asserted) instead of an artificial idle reset. The notice's live region is asserted. A bare `#` after a failure, and while another caller loads the directory, makes no directory request, shows no spinner and no notice. Each of 5 mutations fails a test: no error reset in `beginRefresh`, no live region, a directory load for `#`, the notice for `#`, and the spinner for `#`. - Directory races (`c73b028ac`): `channels_provider_test.dart` switches A→B→A and expects a new directory read, and starts a directory load while the first load's member metadata is held, releases it last, and expects the list with the open channel and no error. Both tests fail on `fe4276538`, and each fails again when its fix is removed. - Review hardening (`85042b1a4`): `retryDirectory` clears a same-scope error before its first await. A test reads `idle` synchronously after the call; it failed before the fix. - `just mobile-check`, `just mobile-test` and `just file-size-check` pass at `85042b1a4` on `origin/main` `b80217de3` (4052 tests, 32 skipped). - Carl's re-review of `c73b028ac` (via Wes's account): no blockers. Its optional hardening note is done in `85042b1a4`. The 8-row cap is still Wes's product call. - Wes's review (Carl) of `22e52d687`: the blocker (a stale same-scope error skipped the retry) is fixed in `29c74e8c5`. The optional `#` and live-region items are done. The 8-row cap is left for the PR author's product call. The in-app run below is from `3b5521c51`. - Agent review of `efc17a5d4`: Cerveau found no must-fix or should-fix. Auto found one should-fix (a load retired before `markLoading` got no retry and no notice), fixed in `22e52d687` with its optional notice-liveness and A→B→A items. Cerveau's optional coverage items are also done there. - Not covered by a test yet (follow-up): the empty state on the Messages and People tabs when only channels matched. - `just mobile-check` and `just mobile-test` pass at `3b5521c51` on `origin/main` `0c82435ae` (4008 tests, 28 skipped). - Agent review: Auto and Cerveau reviewed `b8a268e2c` independently. Every must-fix and should-fix is fixed in `28a0990e5`. Cerveau confirmed both of its must-fix items at that head. It then found one regression in my fix, a spinner gap during the real refresh, and that is fixed in `3b5521c51`. Cerveau re-reviewed `28a0990e5..3b5521c51`, found nothing new, and confirmed that all three of its blockers are closed. Its own full mobile suite run at `3b5521c51`: 4008 passed, 28 skipped. Auto re-checked `b8a268e2c..3b5521c51` and confirmed that all four of its should-fix items are closed and nothing new is must-fix. Auto's optional findings on `İ`, per-keystroke ranking and reload cost are listed under differences above. #### In the app (Android emulator, debug build of `3b5521c51`) No Xcode was available, so this ran on the Android emulator against a local relay. The viewer has joined `general` and `team-4things`. Another user owns the open channels `4things` and `retro2108`. https://github.com/user-attachments/assets/f54700c6-c34c-4ded-b7c7-c7e6e07aa43b 1. Channels tab, `4things`: the spinner shows while the open-channel list loads, then `4things` appears with the Open badge, above the joined `team-4things`, and the spinner stops. 2. `#`: the joined channels are listed, and the spinner stops. 3. Channels tab, `morty` (a person, no channel name): "No results for 'morty'". The All tab shows Morty under People. 4. The phone is cut off from the relay, and `retro2108` is created meanwhile. Searching for `retro2108` more than 30 s after the last load shows "Open channel search couldn't finish." with Retry channels. After the relay is reachable again, Retry channels finds `retro2108` with the Open badge. The local `ws://` relay needed two demo-only edits to the add-community checks, which are not part of this PR. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Signed-off-by: Logan Johnson Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz> Co-authored-by: Claude Opus 5.5 Co-authored-by: Wes --- .../features/channels/channel_directory.dart | 37 +- .../features/channels/channels_provider.dart | 37 +- mobile/lib/features/search/name_match.dart | 84 +++ mobile/lib/features/search/search_page.dart | 59 +- .../lib/features/search/search_provider.dart | 257 +++++-- .../features/channels/channels_page_test.dart | 2 +- .../channels/channels_provider_test.dart | 133 +++- .../test/features/search/name_match_test.dart | 34 + .../search/search_channel_rank_test.dart | 105 +++ .../search/search_channels_journey_test.dart | 628 ++++++++++++++++++ 10 files changed, 1320 insertions(+), 56 deletions(-) create mode 100644 mobile/lib/features/search/name_match.dart create mode 100644 mobile/test/features/search/name_match_test.dart create mode 100644 mobile/test/features/search/search_channel_rank_test.dart create mode 100644 mobile/test/features/search/search_channels_journey_test.dart diff --git a/mobile/lib/features/channels/channel_directory.dart b/mobile/lib/features/channels/channel_directory.dart index 8ac4720ced..beb8dc04b2 100644 --- a/mobile/lib/features/channels/channel_directory.dart +++ b/mobile/lib/features/channels/channel_directory.dart @@ -26,13 +26,21 @@ class ChannelDirectoryLoadState { /// Current loading status for [scope]. final ChannelDirectoryLoadStatus status; + /// When the directory for [scope] last finished loading. + final DateTime? loadedAt; + /// Creates directory loading state. - const ChannelDirectoryLoadState({required this.scope, required this.status}); + const ChannelDirectoryLoadState({ + required this.scope, + required this.status, + this.loadedAt, + }); /// Initial state before any directory request has started. const ChannelDirectoryLoadState.idle() : scope = null, - status = ChannelDirectoryLoadStatus.idle; + status = ChannelDirectoryLoadStatus.idle, + loadedAt = null; } /// Returns the stable directory scope for a relay and signing identity. @@ -49,6 +57,10 @@ class ChannelDirectoryLoadNotifier extends Notifier { state.scope == scope && state.status == ChannelDirectoryLoadStatus.loading; + /// Whether the last directory request for [scope] failed. + bool hasFailed(String scope) => + state.scope == scope && state.status == ChannelDirectoryLoadStatus.error; + /// Marks the directory as loading. void markLoading(String scope) => state = ChannelDirectoryLoadState( scope: scope, @@ -65,6 +77,7 @@ class ChannelDirectoryLoadNotifier extends Notifier { void markLoaded(String scope) => state = ChannelDirectoryLoadState( scope: scope, status: ChannelDirectoryLoadStatus.loaded, + loadedAt: DateTime.now(), ); /// Marks the directory request as unsuccessful. @@ -419,13 +432,19 @@ class _ChannelRefreshCoordinator { final status = loadStatus(); if (status.isLoading(scope)) { // This refresh takes ownership of the shared generation, so the - // in-flight directory response will be discarded. The mounted Browse - // sheet cannot restart an idle request on its own: it only starts a - // load when it mounts and deliberately renders idle as its initial - // spinner. Settle the displaced load to a retryable terminal state so - // the sheet never waits forever for a response that may no longer - // write results. - status.markError(scope); + // in-flight directory response will be discarded. Mark it idle, not + // error: nothing failed, so a caller may load it again, and search + // retries a displaced load once instead of reporting a failure. + status.markIdle(scope); + } + } else { + final status = loadStatus(); + if (status.hasFailed(scope)) { + // A new attempt replaces the last failure. The status turns loading + // only after the membership reads, so a refresh that retires this + // attempt earlier must not leave the old error to be read as its + // result: idle lets search retry the displaced attempt. + status.markIdle(scope); } } return fence; diff --git a/mobile/lib/features/channels/channels_provider.dart b/mobile/lib/features/channels/channels_provider.dart index c5f7babe51..ecd6521c8b 100644 --- a/mobile/lib/features/channels/channels_provider.dart +++ b/mobile/lib/features/channels/channels_provider.dart @@ -76,6 +76,9 @@ class ChannelsNotifier extends AsyncNotifier> { String? _memberSnapshotPubkey; Map> _memberSnapshotsByChannelId = const {}; List _directoryMetas = const []; + // The scope [_directoryMetas] came from. A community switch drops them, so + // the directory status alone cannot say this scope's directory is cached. + String? _directoryMetasScope; Set _hiddenDmIds = const {}; /// Fences directory responses to the relay and identity that requested them. @@ -173,6 +176,7 @@ class ChannelsNotifier extends AsyncNotifier> { _memberSnapshotPubkey = pubkey; _memberSnapshotsByChannelId = const {}; _directoryMetas = const []; + _directoryMetasScope = null; _hiddenDmIds = const {}; // Retire any in-flight directory request: its response describes the // previous relay or identity and must not reach this scope's state. @@ -340,7 +344,10 @@ class ChannelsNotifier extends AsyncNotifier> { // ever regresses. The composite cursor preserves tied-timestamp rows. if (fetchDirectory) { final metas = await _refreshCoordinator.loadDirectory(session, fence); - if (metas != null) _directoryMetas = metas; + if (metas != null) { + _directoryMetas = metas; + _directoryMetasScope = fence.scope; + } } // Merge and dedupe by `d` tag. Kind:39000 is parameterized-replaceable, @@ -1062,16 +1069,23 @@ class ChannelsNotifier extends AsyncNotifier> { return _channelFromMeta(latest, data, isMember: false); } - /// Loads the directory when Browse channels opens after startup or an error. - Future ensureDirectoryLoaded() async { + /// Loads the directory unless it is loading, or loaded within [maxAge] + /// (any age when null), for the current relay and identity. + Future ensureDirectoryLoaded({Duration? maxAge}) async { final directoryState = ref.read(channelDirectoryLoadStatusProvider); final scope = channelDirectoryScope( ref.read(relayConfigProvider).baseUrl, ref.read(myPubkeyProvider), ); + final loadedAt = directoryState.loadedAt; + final fresh = + directoryState.status == ChannelDirectoryLoadStatus.loaded && + _directoryMetasScope == scope && + (maxAge == null || + (loadedAt != null && DateTime.now().difference(loadedAt) < maxAge)); if (directoryState.scope == scope && (directoryState.status == ChannelDirectoryLoadStatus.loading || - directoryState.status == ChannelDirectoryLoadStatus.loaded)) { + fresh)) { return; } await retryDirectory(); @@ -1079,7 +1093,6 @@ class ChannelsNotifier extends AsyncNotifier> { /// Retries channel discovery while retaining the current channel list. Future retryDirectory() async { - final previousChannels = state.value; final directoryStatus = ref.read( channelDirectoryLoadStatusProvider.notifier, ); @@ -1096,6 +1109,20 @@ class ChannelsNotifier extends AsyncNotifier> { directoryStatus.markError(scope); return; } + // This attempt replaces the last failure before anything can retire it, + // so a displaced attempt never reports the old error (beginRefresh clears + // it too, for other entry points, but only after a yield). + if (directoryStatus.hasFailed(scope)) directoryStatus.markIdle(scope); + // Let a pending first load finish, as fetchFresh does. This request + // would retire it, and its late failure would replace the list. + final generation = _buildGeneration; + try { + await future; + } catch (_) { + // A failed first load must not prevent the directory request. + } + if (!ref.mounted || generation != _buildGeneration) return; + final previousChannels = state.value; try { state = AsyncData( await _fetch(subscribeLive: true, fetchDirectory: true), diff --git a/mobile/lib/features/search/name_match.dart b/mobile/lib/features/search/name_match.dart new file mode 100644 index 0000000000..735ef2f9c8 --- /dev/null +++ b/mobile/lib/features/search/name_match.dart @@ -0,0 +1,84 @@ +/// Channel name matching for search. +/// +/// Ported from buzz-app `src/features/search/match.ts` (`matchName`), so a +/// query finds the same channels, in the same order, as desktop search. +library; + +/// How well a name matches typed text, best first. Ranks are whole numbers, +/// so callers can order them and add fractional tie-breaks. +abstract final class MatchRank { + static const exact = 0; + static const prefix = 1; + + /// The text starts a later word. + static const wordStart = 2; + static const substring = 3; + + /// Each letter continues a run that began at a word start (`tlp` in + /// `team-launch-planning`). + static const wordRuns = 4; + + /// The letters appear in order. + static const inOrder = 5; +} + +final _wordChar = RegExp(r'[\p{L}\p{N}]', unicode: true); +final _spaces = RegExp(r'\s+'); + +bool _isWordChar(String? char) => char != null && _wordChar.hasMatch(char); + +List _codePoints(String value) => + value.runes.map(String.fromCharCode).toList(); + +/// Returns how well [label] matches [needle] as a [MatchRank], or null for no +/// match. [needle] must already be lowercased. +int? matchRank(String label, String needle) { + final text = label.toLowerCase(); + if (text == needle) return MatchRank.exact; + + var substring = false; + for ( + var at = text.indexOf(needle); + at >= 0; + at = text.indexOf(needle, at + 1) + ) { + if (at == 0) return MatchRank.prefix; + if (!_isWordChar(text[at - 1])) return MatchRank.wordStart; + substring = true; + } + if (substring) return MatchRank.substring; + + // Spaces in typed text only separate words; they need not match. + final letters = _codePoints(needle.replaceAll(_spaces, '')); + if (letters.isEmpty) return null; + final chars = _codePoints(text); + + // Letters in order, the loosest rank. Without them nothing below matches, + // so a name that misses skips the word-run search. + var found = 0; + for (final char in chars) { + if (found < letters.length && char == letters[found]) found++; + } + if (found < letters.length) return null; + + // Can letters[i..] match chars[j..] with every run starting at a word start? + // `inRun` means the previous letter matched chars[j - 1]. + final memo = {}; + late final bool Function(int, int, bool) wordRuns; + bool takes(int i, int j, bool inRun) => + chars[j] == letters[i] && + (inRun || !_isWordChar(j > 0 ? chars[j - 1] : null)) && + wordRuns(i + 1, j + 1, true); + wordRuns = (i, j, inRun) { + if (i == letters.length) return true; + if (j == chars.length) return false; + final key = (i * (chars.length + 1) + j) * 2 + (inRun ? 1 : 0); + final known = memo[key]; + if (known != null) return known; + final result = takes(i, j, inRun) || wordRuns(i, j + 1, false); + memo[key] = result; + return result; + }; + if (wordRuns(0, 0, false)) return MatchRank.wordRuns; + return MatchRank.inOrder; +} diff --git a/mobile/lib/features/search/search_page.dart b/mobile/lib/features/search/search_page.dart index 4e4418b0ca..65e5308b23 100644 --- a/mobile/lib/features/search/search_page.dart +++ b/mobile/lib/features/search/search_page.dart @@ -312,12 +312,27 @@ class _SearchBody extends ConsumerWidget { final showMessages = filter == _SearchFilter.all || filter == _SearchFilter.messages; + // Only the active tab's results count, so a tab with none of its own + // says so rather than showing a blank list. final hasAnyResults = - state.channelResults.isNotEmpty || - state.userResults.isNotEmpty || - state.messageResults.isNotEmpty; + (showChannels && state.channelResults.isNotEmpty) || + (showPeople && state.userResults.isNotEmpty) || + (showMessages && state.messageResults.isNotEmpty); void recordResultSelection() => ref.read(recentSearchesProvider.notifier).record(state.query); + // A failed directory means unjoined open channels may be missing, so the + // page must not claim there are no results. + final channelsFailed = showChannels && state.channelSearchFailed; + void retryChannels() => ref.read(searchProvider.notifier).retryChannels(); + + if (!state.isLoading && !hasAnyResults && channelsFailed) { + return Padding( + padding: EdgeInsets.only( + bottom: MediaQuery.viewInsetsOf(context).bottom, + ), + child: Center(child: _ChannelSearchFailed(onRetry: retryChannels)), + ); + } if (!state.isLoading && !hasAnyResults) { return Padding( @@ -353,6 +368,10 @@ class _SearchBody extends ConsumerWidget { channels: state.channelResults, onResultSelected: recordResultSelection, ), + if (channelsFailed) + SliverToBoxAdapter( + child: _ChannelSearchFailed(onRetry: retryChannels), + ), if (showPeople && state.userResults.isNotEmpty) _PeopleSection( users: state.userResults, @@ -384,6 +403,40 @@ class _SearchBody extends ConsumerWidget { } } +/// Tells the user open-channel search failed, with desktop's Retry channels. +class _ChannelSearchFailed extends StatelessWidget { + final VoidCallback onRetry; + + const _ChannelSearchFailed({required this.onRetry}); + + @override + Widget build(BuildContext context) { + return Padding( + key: const Key('search-channels-failed'), + padding: const EdgeInsets.symmetric( + horizontal: Grid.gutter, + vertical: Grid.xs, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Semantics( + liveRegion: true, + child: Text( + "Open channel search couldn't finish.", + textAlign: TextAlign.center, + style: context.textTheme.bodyMedium?.copyWith( + color: context.colors.onSurfaceVariant, + ), + ), + ), + TextButton(onPressed: onRetry, child: const Text('Retry channels')), + ], + ), + ); + } +} + class _RecentSearches extends StatelessWidget { final List searches; final ValueChanged onSelected; diff --git a/mobile/lib/features/search/search_provider.dart b/mobile/lib/features/search/search_provider.dart index bc9bfddc33..c04122514e 100644 --- a/mobile/lib/features/search/search_provider.dart +++ b/mobile/lib/features/search/search_provider.dart @@ -7,6 +7,7 @@ import '../../shared/relay/relay.dart'; import '../channels/channel.dart'; import '../channels/channel_management_provider.dart'; import '../channels/channels_provider.dart'; +import 'name_match.dart'; @immutable class SearchHit { @@ -58,6 +59,10 @@ class SearchState { final bool isLoading; final String? error; + /// The open-channel directory failed to load, so channel results may be + /// missing open channels the user has not joined. + final bool channelSearchFailed; + const SearchState({ this.query = '', this.messageResults = const [], @@ -65,6 +70,7 @@ class SearchState { this.channelResults = const [], this.isLoading = false, this.error, + this.channelSearchFailed = false, }); const SearchState.initial() @@ -73,7 +79,8 @@ class SearchState { userResults = const [], channelResults = const [], isLoading = false, - error = null; + error = null, + channelSearchFailed = false; SearchState copyWith({ String? query, @@ -82,6 +89,7 @@ class SearchState { List? channelResults, bool? isLoading, String? error, + bool? channelSearchFailed, }) => SearchState( query: query ?? this.query, messageResults: messageResults ?? this.messageResults, @@ -89,16 +97,107 @@ class SearchState { channelResults: channelResults ?? this.channelResults, isLoading: isLoading ?? this.isLoading, error: error ?? this.error, + channelSearchFailed: channelSearchFailed ?? this.channelSearchFailed, ); } +/// The channel name text a search [query] matches: lowercased, without a +/// leading "#". +String _channelNeedle(String query) => + query.trim().toLowerCase().replaceFirst(RegExp(r'^#'), ''); + +/// Whether [query] searches open channels. A bare "#" does not, as on desktop. +bool _searchesOpenChannels(String query) => _channelNeedle(query).isNotEmpty; + +/// Most channel rows one query shows, ranked best first. +const searchChannelResultLimit = 8; + +/// How long a loaded open-channel directory serves searches before the next +/// search reads it again, as desktop's `PUBLIC_CHANNEL_PAGE_TTL` does. +const searchDirectoryMaxAge = Duration(seconds: 30); + +/// Ranks [channels] for a search [query] with the same rules as desktop +/// (buzz-app `SearchResults.tsx` and `store.ts` `searchPublic`): name match +/// rank first, an archived channel half a step behind a live one, then joined +/// channels in list order before open ones by name. A bare "#" lists joined +/// channels only, since desktop does not search open channels for it. +List rankSearchChannels(Iterable channels, String query) { + final needle = _channelNeedle(query); + final ranked = <({Channel channel, double rank, int order})>[]; + for (final channel in channels) { + if (channel.isDm) continue; + // An archived channel stays findable only by its members. + final visible = channel.isArchived || needle.isEmpty + ? channel.isMember + : channel.visibility == 'open' || channel.isMember; + if (!visible) continue; + final rank = matchRank(channel.name, needle); + if (rank == null) continue; + ranked.add(( + channel: channel, + rank: rank + (channel.isArchived ? 0.5 : 0), + order: ranked.length, + )); + } + ranked.sort((a, b) { + final byRank = a.rank.compareTo(b.rank); + if (byRank != 0) return byRank; + // Joined channels win ties and keep the list's order. + if (a.channel.isMember != b.channel.isMember) { + return a.channel.isMember ? -1 : 1; + } + if (a.channel.isMember) return a.order.compareTo(b.order); + final byName = a.channel.name.compareTo(b.channel.name); + return byName != 0 ? byName : a.channel.id.compareTo(b.channel.id); + }); + return [ + for (final entry in ranked.take(searchChannelResultLimit)) entry.channel, + ]; +} + class SearchNotifier extends Notifier { Timer? _debounce; + // The relay lookups still running for the current query. The spinner shows + // only while one of them, or a directory load, is pending, so the last + // lookup to finish settles it regardless of which results came back empty. + bool _messagesPending = false; + bool _usersPending = false; + + // The directory load this search started, until its future completes. The + // shared directory status is not enough: production marks it loading only + // after memberships arrive, and loaded before the new list is installed. + // A rebuild drops it, so a load from an earlier community cannot settle a + // newer one. + Object? _directoryLoad; + + // This search has loaded, or tried to load, the directory. Until then an + // unloaded directory is not a failure to report. + bool _directoryAttempted = false; + + // Bumped by every new query, clear and rebuild. A lookup writes its result + // only while its generation is current, so a late reply to an earlier query, + // even one with the same text, cannot settle the current spinner. + int _generation = 0; + @override SearchState build() { - // Watch relayConfig so search state resets on community switch. + // Watch relayConfig so search state resets on a community or identity + // switch: myPubkeyProvider derives from its nsec. ref.watch(relayConfigProvider); + _generation++; + _messagesPending = false; + _usersPending = false; + _directoryLoad = null; + _directoryAttempted = false; + // Channel rows come from the loaded channel list, which gains open + // channels when the directory arrives. Re-rank whenever either changes; + // the directory status for this community also drives the spinner. + ref.listen(channelsProvider, (_, _) => _refreshChannels()); + ref.listen( + channelDirectoryLoadStatusProvider, + (_, _) => _refreshChannels(), + ); ref.onDispose(() { _debounce?.cancel(); }); @@ -110,28 +209,103 @@ class SearchNotifier extends Notifier { final trimmed = query.trim(); if (trimmed.isEmpty) { - state = const SearchState.initial(); + clear(); return; } + final generation = ++_generation; + // Pending from the keystroke, so a channel-list change during the + // debounce cannot show "No results" before the lookups start. + _messagesPending = true; + _usersPending = true; state = SearchState(query: trimmed, isLoading: true); _debounce = Timer(const Duration(milliseconds: 300), () { - _executeSearch(trimmed); + _executeSearch(trimmed, generation); }); } - Future _executeSearch(String query) async { - // If the query changed while debouncing, bail out. - if (state.query != query) return; + void _executeSearch(String query, int generation) { + if (generation != _generation) return; - // Fire all three lookups in parallel. - _searchMessages(query); - _searchUsers(query); - _searchChannels(query); + // Like desktop, a bare "#" lists joined channels without the directory. + if (_searchesOpenChannels(query)) _loadDirectory(); + _refreshChannels(); + _searchMessages(query, generation); + _searchUsers(query, generation); } - Future _searchMessages(String query) async { + bool get _isLoading => + _messagesPending || + _usersPending || + (_searchesOpenChannels(state.query) && + (_directoryLoad != null || + _directoryStatus == ChannelDirectoryLoadStatus.loading)); + + /// This community's directory status, or null when the status describes + /// another community or no load has started. + ChannelDirectoryLoadStatus? get _directoryStatus { + final directory = ref.read(channelDirectoryLoadStatusProvider); + final scope = channelDirectoryScope( + ref.read(relayConfigProvider).baseUrl, + ref.read(myPubkeyProvider), + ); + return directory.scope == scope ? directory.status : null; + } + + /// Whether this community's directory is not loaded and no load for it is + /// running: it failed, or a newer channel refresh retired the load. + bool get _directoryMissing => switch (_directoryStatus) { + ChannelDirectoryLoadStatus.loaded || + ChannelDirectoryLoadStatus.loading => false, + _ => true, + }; + + /// Loads the directory again after it failed, like desktop's Retry channels. + void retryChannels() { + _loadDirectory(); + _refreshChannels(); + } + + /// Loads the open-channel directory so search also finds open channels the + /// user has not joined, as desktop search does. + void _loadDirectory() { + if (_directoryLoad != null) return; + final load = _directoryLoad = Object(); + _directoryAttempted = true; + final channels = ref.read(channelsProvider.notifier); + Future ensure() => + channels.ensureDirectoryLoaded(maxAge: searchDirectoryMaxAge); + unawaited( + ensure() + .then((_) { + // A membership refresh that starts during the load retires it. + // Nothing failed, so the load ends with no status for this + // community, or idle. Load it again once; if that is retired + // too, search offers Retry instead of looping. A relay error + // ends in error and is not retried automatically. + if (!ref.mounted || + _directoryLoad != load || + !_directoryMissing || + _directoryStatus == ChannelDirectoryLoadStatus.error) { + return null; + } + return ensure(); + }) + .catchError((Object error) { + debugPrint( + '[SearchNotifier] channel directory load failed: $error', + ); + }) + .whenComplete(() { + if (!ref.mounted || _directoryLoad != load) return; + _directoryLoad = null; + _refreshChannels(); + }), + ); + } + + Future _searchMessages(String query, int generation) async { try { final session = ref.read(relaySessionProvider.notifier); final events = await session.fetchHistory( @@ -145,7 +319,8 @@ class SearchNotifier extends Notifier { // Channel-name lookup from the cached channels list — not all hits // will have a known channel (e.g. ones we're not a member of). final channelsById = { - for (final c in ref.read(channelsProvider).value ?? const []) + for (final c + in ref.read(channelsProvider).asData?.value ?? const []) c.id: c.name, }; @@ -167,48 +342,56 @@ class SearchNotifier extends Notifier { ) .toList(); - if (state.query != query) return; - state = state.copyWith(messageResults: hits, isLoading: false); + if (!ref.mounted || generation != _generation) return; + _messagesPending = false; + state = state.copyWith(messageResults: hits, isLoading: _isLoading); } catch (e) { - if (state.query != query) return; - state = state.copyWith(isLoading: false, error: e.toString()); + if (!ref.mounted || generation != _generation) return; + _messagesPending = false; + state = state.copyWith(isLoading: _isLoading, error: e.toString()); } } - Future _searchUsers(String query) async { + Future _searchUsers(String query, int generation) async { + List? users; try { - final users = await ref + users = await ref .read(channelActionsProvider) .searchUsers(query, limit: 8); - - if (state.query != query) return; - state = state.copyWith( - userResults: users, - isLoading: state.messageResults.isEmpty && state.channelResults.isEmpty, - ); } catch (_) { // User search failure is non-critical — keep existing results. } + if (!ref.mounted || generation != _generation) return; + _usersPending = false; + state = state.copyWith(userResults: users, isLoading: _isLoading); } - void _searchChannels(String query) { - final channels = ref.read(channelsProvider).value ?? []; - final lowerQuery = query.toLowerCase(); - final matches = channels.where((c) { - if (c.isDm) return false; - return c.name.toLowerCase().contains(lowerQuery) || - c.description.toLowerCase().contains(lowerQuery); - }).toList(); - - if (state.query != query) return; + /// Publishes the channel rows and spinner for the current query. + void _refreshChannels() { + if (state.query.isEmpty) return; state = state.copyWith( - channelResults: matches, - isLoading: state.messageResults.isEmpty && state.userResults.isEmpty, + // A reload keeps the previous community's list in AsyncLoading; only + // installed data belongs to this search's community. + channelResults: rankSearchChannels( + 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, + isLoading: _isLoading, ); } void clear() { _debounce?.cancel(); + _generation++; + _messagesPending = false; + _usersPending = false; state = const SearchState.initial(); } } diff --git a/mobile/test/features/channels/channels_page_test.dart b/mobile/test/features/channels/channels_page_test.dart index c19ea530b9..542207bfc4 100644 --- a/mobile/test/features/channels/channels_page_test.dart +++ b/mobile/test/features/channels/channels_page_test.dart @@ -3687,7 +3687,7 @@ class _FakeNotifier extends ChannelsNotifier { } @override - Future ensureDirectoryLoaded() async { + Future ensureDirectoryLoaded({Duration? maxAge}) async { ref .read(channelDirectoryLoadStatusProvider.notifier) .markLoaded(_activeDirectoryScope(ref)); diff --git a/mobile/test/features/channels/channels_provider_test.dart b/mobile/test/features/channels/channels_provider_test.dart index 77afe3fea1..719e100dfa 100644 --- a/mobile/test/features/channels/channels_provider_test.dart +++ b/mobile/test/features/channels/channels_provider_test.dart @@ -62,6 +62,105 @@ void main() { }, ); + test('ensureDirectoryLoaded reads again once older than maxAge', () async { + final session = _FakeRelaySession( + memberships: const [], + metadata: [_meta(id: _channelA, name: 'general')], + ); + final container = _buildContainer(session: session); + addTearDown(container.dispose); + await container.read(channelsProvider.future); + final channels = container.read(channelsProvider.notifier); + + await channels.ensureDirectoryLoaded(); + final reads = session.directoryQueryFilters.length; + expect(reads, isPositive); + // A loaded directory serves later calls until it is older than maxAge. + await channels.ensureDirectoryLoaded(); + await channels.ensureDirectoryLoaded(maxAge: const Duration(hours: 1)); + expect(session.directoryQueryFilters, hasLength(reads)); + await channels.ensureDirectoryLoaded(maxAge: Duration.zero); + expect(session.directoryQueryFilters, hasLength(reads * 2)); + }); + + test( + 'ensureDirectoryLoaded reads again after the community switches away and back', + () async { + final session = _FakeRelaySession( + memberships: const [], + metadata: [_meta(id: _channelA, name: 'general')], + ); + final container = _buildContainer(session: session); + addTearDown(container.dispose); + await container.read(channelsProvider.future); + final config = container.read(relayConfigProvider.notifier); + final communityA = container.read(relayConfigProvider).baseUrl; + const maxAge = Duration(hours: 1); + + await container + .read(channelsProvider.notifier) + .ensureDirectoryLoaded(maxAge: maxAge); + expect(container.read(channelsProvider).requireValue.map((c) => c.id), [ + _channelA, + ]); + + // Switch to B without loading its directory, then back to A. The switch + // drops A's cached directory, so A's earlier load no longer serves it. + config.update(baseUrl: 'https://community-b.example'); + await container.read(channelsProvider.future); + config.update(baseUrl: communityA); + await container.read(channelsProvider.future); + final reads = session.directoryQueryFilters.length; + + await container + .read(channelsProvider.notifier) + .ensureDirectoryLoaded(maxAge: maxAge); + expect(session.directoryQueryFilters.length, greaterThan(reads)); + expect(container.read(channelsProvider).requireValue.map((c) => c.id), [ + _channelA, + ]); + }, + ); + + test('a directory load during the first channel load waits for it', () async { + final session = _FakeRelaySession( + memberships: [_membership(_channelA, myPk)], + metadata: [ + _meta(id: _channelA, name: 'general'), + _meta(id: _channelB, name: 'discoverable'), + ], + ); + final container = _buildContainer(session: session); + addTearDown(container.dispose); + final sub = container.listen(channelsProvider, (_, _) {}); + addTearDown(sub.close); + + // Search starts while the first load's member metadata is held. + session.pauseNextMemberMetadataQuery(); + container.read(channelsProvider); + await session.nextMemberMetadataQueryStarted; + final directory = container + .read(channelsProvider.notifier) + .ensureDirectoryLoaded(maxAge: const Duration(seconds: 30)); + await _settle(); + + // The first load's reply arrives last. It must not replace the list. + session.resumePausedMemberMetadataQuery(); + await directory; + await _settle(); + + final state = container.read(channelsProvider); + expect(state.hasError, isFalse); + expect( + state.requireValue.map((c) => c.id), + unorderedEquals([_channelA, _channelB]), + ); + expect( + container.read(channelDirectoryLoadStatusProvider).status, + ChannelDirectoryLoadStatus.loaded, + ); + }); + test('paginates channel discovery with a composite cursor', () async { final firstPage = List.generate( 500, @@ -301,6 +400,38 @@ void main() { ); }); + test( + 'a directory retry clears an earlier failure before it yields', + () async { + final session = _FakeRelaySession( + memberships: [_membership(_channelA, myPk)], + metadata: [_meta(id: _channelA, name: 'general')], + ); + final container = _buildContainer(session: session); + addTearDown(container.dispose); + await container.read(channelsProvider.future); + session.membershipFailures = 1; + await container.read(channelsProvider.notifier).retryDirectory(); + expect( + container.read(channelDirectoryLoadStatusProvider).status, + ChannelDirectoryLoadStatus.error, + ); + + // A refresh that retires this attempt in the same microtask drain must + // not leave the old error to be read as its result. + final retry = container.read(channelsProvider.notifier).retryDirectory(); + expect( + container.read(channelDirectoryLoadStatusProvider).status, + ChannelDirectoryLoadStatus.idle, + ); + await retry; + expect( + container.read(channelDirectoryLoadStatusProvider).status, + ChannelDirectoryLoadStatus.loaded, + ); + }, + ); + for (final rebuild in [false, true]) { test( '${rebuild ? 'Same-scope rebuild' : 'Ordinary refresh'} settles a superseded directory load for retry', @@ -346,7 +477,7 @@ void main() { expect( container.read(channelDirectoryLoadStatusProvider).status, - ChannelDirectoryLoadStatus.error, + ChannelDirectoryLoadStatus.idle, ); expect( container diff --git a/mobile/test/features/search/name_match_test.dart b/mobile/test/features/search/name_match_test.dart new file mode 100644 index 0000000000..666f34c145 --- /dev/null +++ b/mobile/test/features/search/name_match_test.dart @@ -0,0 +1,34 @@ +import 'package:buzz/features/search/name_match.dart'; +import 'package:flutter_test/flutter_test.dart'; + +// Expected ranks come from buzz-app `src/features/search/match.ts` +// (`matchRank`) run on the same inputs, so both clients rank a query alike. +void main() { + test('channel names rank like desktop', () { + const cases = [ + ('general', 'general', MatchRank.exact), + ('Design', 'design', MatchRank.exact), + ('4things', '4things', MatchRank.exact), + ('general-chat', 'general', MatchRank.prefix), + ('team-general', 'general', MatchRank.wordStart), + ('team-4things', '4things', MatchRank.wordStart), + ('release-notes', 'notes', MatchRank.wordStart), + ('Ünïcode-ok', 'ok', MatchRank.wordStart), + ('the4things', '4things', MatchRank.substring), + ('release-notes', 'ease', MatchRank.substring), + ('release-notes', 'releasenotes', MatchRank.wordRuns), + ('release-notes', 'release notes', MatchRank.wordRuns), + ('buzz-community', 'buzz community', MatchRank.wordRuns), + ('team-launch-planning', 'tlp', MatchRank.wordRuns), + ('release-notes', 'reln', MatchRank.wordRuns), + ('build_and_deploy', 'bd', MatchRank.wordRuns), + ('dev', 'd e v', MatchRank.wordRuns), + ('general', 'genral', MatchRank.inOrder), + ('release-notes', 'budget', null), + ('abc', 'ca', null), + ]; + for (final (label, needle, rank) in cases) { + expect(matchRank(label, needle), rank, reason: '$label / $needle'); + } + }); +} diff --git a/mobile/test/features/search/search_channel_rank_test.dart b/mobile/test/features/search/search_channel_rank_test.dart new file mode 100644 index 0000000000..ef230d5892 --- /dev/null +++ b/mobile/test/features/search/search_channel_rank_test.dart @@ -0,0 +1,105 @@ +import 'package:buzz/features/channels/channel.dart'; +import 'package:buzz/features/search/search_provider.dart'; +import 'package:flutter_test/flutter_test.dart'; + +// Channel rows rank like buzz-app search: match rank, archived half a step +// behind, joined channels first on a tie, open channels by name, top eight. +void main() { + test('search ranks channels like desktop', () { + final cases = <(String, List, String, List)>[ + ( + 'joined wins a tie with an open channel listed and named first', + [_channel('ops-a', isMember: false), _channel('ops-b', isMember: true)], + 'ops', + ['ops-b', 'ops-a'], + ), + ( + 'open channels tie by name, joined ones keep list order', + [ + _channel('ops-z', isMember: true), + _channel('ops-y', isMember: false), + _channel('ops-a', isMember: true), + _channel('ops-b', isMember: false), + ], + 'ops', + ['ops-z', 'ops-a', 'ops-b', 'ops-y'], + ), + ( + 'archived ranks half a step behind and only for members', + [ + _channel('ops-old', isMember: true, archived: true), + _channel('ops-live', isMember: true), + _channel('ops-gone', isMember: false, archived: true), + ], + 'ops', + ['ops-live', 'ops-old'], + ), + ( + 'archived exact still leads a worse live match', + [ + _channel('team-ops', isMember: true), + _channel('ops', isMember: true, archived: true), + ], + 'ops', + ['ops', 'team-ops'], + ), + ( + 'private channels need membership, DMs never show', + [ + _channel('ops-private', isMember: false, visibility: 'private'), + _channel('ops-mine', isMember: true, visibility: 'private'), + _channel('ops-dm', isMember: true, channelType: 'dm'), + ], + 'ops', + ['ops-mine'], + ), + ( + 'a leading # is ignored', + [_channel('general', isMember: true)], + '#gen', + ['general'], + ), + ( + 'a bare # lists joined channels only', + [_channel('a', isMember: false), _channel('b', isMember: true)], + '#', + ['b'], + ), + ( + 'the exact name leads past eight weaker matches, cut to eight', + [ + for (var i = 1; i <= 9; i++) _channel('team-ops-$i', isMember: true), + _channel('ops', isMember: false), + ], + 'ops', + ['ops', for (var i = 1; i <= 7; i++) 'team-ops-$i'], + ), + ]; + for (final (reason, channels, query, names) in cases) { + expect( + rankSearchChannels(channels, query).map((channel) => channel.name), + names, + reason: reason, + ); + } + }); +} + +Channel _channel( + String name, { + required bool isMember, + bool archived = false, + String visibility = 'open', + String channelType = 'stream', +}) => Channel( + id: '$name-id', + name: name, + channelType: channelType, + visibility: visibility, + description: '', + memberCount: 3, + createdBy: 'test', + createdAt: DateTime(2025), + isMember: isMember, + archivedAt: archived ? DateTime(2025) : null, +); diff --git a/mobile/test/features/search/search_channels_journey_test.dart b/mobile/test/features/search/search_channels_journey_test.dart new file mode 100644 index 0000000000..ad44f21a71 --- /dev/null +++ b/mobile/test/features/search/search_channels_journey_test.dart @@ -0,0 +1,628 @@ +import 'dart:async'; + +import 'package:buzz/features/channels/channel.dart'; +import 'package:buzz/features/channels/channel_management_provider.dart'; +import 'package:buzz/features/channels/channels_provider.dart'; +import 'package:buzz/features/profile/profile_provider.dart'; +import 'package:buzz/features/search/recent_searches_provider.dart'; +import 'package:buzz/features/search/search_page.dart'; +import 'package:buzz/features/search/search_provider.dart'; +import 'package:buzz/shared/profile/user_profile.dart'; +import 'package:buzz/shared/relay/relay.dart'; +import 'package:buzz/shared/widgets/buzz_loading_indicator.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +import '../../helpers/widget_helpers.dart'; + +// Channel search must find open channels the user has not joined, rank names +// like desktop, and stop its spinner once every lookup is done, whichever +// lookup finishes last. +void main() { + testWidgets('search finds unjoined open channels and settles its spinner', ( + tester, + ) async { + final session = _HeldSession(); + final actions = _HeldUserSearch(); + final channels = _DirectoryChannels(); + + await tester.pumpWidget( + WidgetHelpers.testable( + overrides: [ + relayConfigProvider.overrideWith(_Community.new), + relaySessionProvider.overrideWith(() => session), + channelActionsProvider.overrideWith((ref) => actions), + channelsProvider.overrideWith(() => channels), + recentSearchesProvider.overrideWith(_NoRecentSearches.new), + profileProvider.overrideWith(_Profile.new), + ], + child: const SearchPage(), + ), + ); + await tester.pumpAndSettle(); + + final spinner = find.byType(BuzzLoadingIndicator); + final unjoined = find.byKey( + const ValueKey('search-channel-row-4things-id'), + ); + + // The open channel is not in the member list, so it needs the directory. + await tester.enterText(find.byType(TextField), '4things'); + await tester.pump(const Duration(milliseconds: 300)); + expect(channels.directoryRequests, 1); + // Later searches read the directory again once it is this old. + expect(channels.maxAge, searchDirectoryMaxAge); + expect(spinner, findsOneWidget); + expect(unjoined, findsNothing); + + final joined = find.byKey( + const ValueKey('search-channel-row-team-4things-id'), + ); + expect(joined, findsOneWidget); + + channels.finishDirectory(); + await tester.pump(); + expect(unjoined, findsOneWidget); + // Match rank comes before membership: the exact name leads. + expect( + tester.getTopLeft(unjoined).dy, + lessThan(tester.getTopLeft(joined).dy), + ); + expect( + find.descendant(of: unjoined, matching: find.text('Open')), + findsOneWidget, + ); + // Messages and people are still searching. + expect(spinner, findsOneWidget); + + session.searches.removeAt(0).complete(const []); + await tester.pump(); + expect(spinner, findsOneWidget); + // People finish last with nothing. The spinner must still stop. + actions.searches.removeAt(0).complete(const []); + await tester.pump(); + expect(spinner, findsNothing); + expect(unjoined, findsOneWidget); + + // Letters in order still find the member channel, like desktop. + await tester.enterText(find.byType(TextField), 'genral'); + await tester.pump(const Duration(milliseconds: 300)); + // The directory is already loaded for this community. + expect(channels.directoryRequests, 1); + expect( + find.byKey(const ValueKey('search-channel-row-general-id')), + findsOneWidget, + ); + session.searches.removeAt(0).complete(const []); + actions.searches.removeAt(0).complete(const []); + await tester.pump(); + expect(spinner, findsNothing); + + // Nothing matches anywhere and people finish last: no endless spinner. + await tester.enterText(find.byType(TextField), 'zzzz'); + await tester.pump(const Duration(milliseconds: 300)); + session.searches.removeAt(0).complete(const []); + await tester.pump(); + actions.searches.removeAt(0).complete(const []); + await tester.pump(); + expect(spinner, findsNothing); + expect(find.text("No results for 'zzzz'"), findsOneWidget); + + // Clearing and retyping the same text starts new lookups. Late replies to + // the first ones must not settle the second search's spinner. + await tester.enterText(find.byType(TextField), 'again'); + await tester.pump(const Duration(milliseconds: 300)); + await tester.enterText(find.byType(TextField), ''); + await tester.pump(); + await tester.enterText(find.byType(TextField), 'again'); + await tester.pump(const Duration(milliseconds: 300)); + expect(session.searches, hasLength(2)); + expect(actions.searches, hasLength(2)); + session.searches.removeAt(0).complete(const []); + actions.searches.removeAt(0).complete(const []); + await tester.pump(); + expect(spinner, findsOneWidget); + session.searches.removeAt(0).complete(const []); + actions.searches.removeAt(0).complete(const []); + await tester.pump(); + expect(spinner, findsNothing); + + // The Channels tab says it has no results even when People has some. + await tester.enterText(find.byType(TextField), 'ada'); + await tester.pump(const Duration(milliseconds: 300)); + session.searches.removeAt(0).complete(const []); + actions.searches.removeAt(0).complete(const [ + DirectoryUser(pubkey: 'ada', displayName: 'Ada'), + ]); + await tester.pump(); + expect(find.text("No results for 'ada'"), findsNothing); + await tester.tap(find.text('Channels')); + await tester.pump(); + expect(find.text("No results for 'ada'"), findsOneWidget); + }); + + testWidgets('a failed directory offers a retry instead of no results', ( + tester, + ) async { + final session = _HeldSession(); + final actions = _HeldUserSearch(); + final channels = _DirectoryChannels(); + + await tester.pumpWidget( + WidgetHelpers.testable( + overrides: [ + relayConfigProvider.overrideWith(_Community.new), + relaySessionProvider.overrideWith(() => session), + channelActionsProvider.overrideWith((ref) => actions), + channelsProvider.overrideWith(() => channels), + recentSearchesProvider.overrideWith(_NoRecentSearches.new), + profileProvider.overrideWith(_Profile.new), + ], + child: const SearchPage(), + ), + ); + await tester.pumpAndSettle(); + + final spinner = find.byType(BuzzLoadingIndicator); + final failed = find.byKey(const Key('search-channels-failed')); + final retry = find.widgetWithText(TextButton, 'Retry channels'); + final unjoined = find.byKey( + const ValueKey('search-channel-row-4things-id'), + ); + + final container = ProviderScope.containerOf( + tester.element(find.byType(SearchPage)), + ); + final directoryStatus = container.read( + channelDirectoryLoadStatusProvider.notifier, + ); + + // The directory finishing last must still stop the spinner. + await tester.enterText(find.byType(TextField), 'gen'); + // Before search first loads the directory, an unloaded directory is not + // a failure, even when its status changes during the debounce. + directoryStatus.markIdle('http://elsewhere.test:'); + await tester.pump(const Duration(milliseconds: 100)); + expect(failed, findsNothing); + await tester.pump(const Duration(milliseconds: 200)); + session.searches.removeAt(0).complete(const []); + actions.searches.removeAt(0).complete(const []); + await tester.pump(); + expect(spinner, findsOneWidget); + channels.failDirectory(); + await tester.pump(); + expect(spinner, findsNothing); + expect( + find.byKey(const ValueKey('search-channel-row-general-id')), + findsOneWidget, + ); + expect(failed, findsOneWidget); + // Screen readers announce the notice when it appears. + expect( + find.descendant( + of: failed, + matching: find.byWidgetPredicate( + (widget) => + widget is Semantics && widget.properties.liveRegion == true, + ), + ), + findsOneWidget, + ); + + // A bare "#" lists joined channels without the directory, like desktop: + // no load, no spinner for it, and no notice, even after a failure or + // while another caller loads the directory. + final communityScope = channelDirectoryScope( + 'http://community.test', + container.read(myPubkeyProvider), + ); + directoryStatus.markLoading(communityScope); + await tester.enterText(find.byType(TextField), '#'); + await tester.pump(const Duration(milliseconds: 300)); + expect(channels.directoryRequests, 1); + session.searches.removeAt(0).complete(const []); + actions.searches.removeAt(0).complete(const []); + await tester.pump(); + expect(spinner, findsNothing); + directoryStatus.markError(communityScope); + await tester.pump(); + expect(failed, findsNothing); + expect( + find.byKey(const ValueKey('search-channel-row-general-id')), + findsOneWidget, + ); + expect(unjoined, findsNothing); + + // Each query tries the directory again. A failure must not read as + // "No results". + await tester.enterText(find.byType(TextField), 'zzzz'); + await tester.pump(const Duration(milliseconds: 300)); + expect(channels.directoryRequests, 2); + session.searches.removeAt(0).complete(const []); + actions.searches.removeAt(0).complete(const []); + channels.failDirectory(); + await tester.pump(); + expect(spinner, findsNothing); + expect(failed, findsOneWidget); + expect(find.text("No results for 'zzzz'"), findsNothing); + + // A membership refresh that retires the load is not a failure: search + // loads it again once, whether the load was retired before it started + // loading or after. A second displacement offers Retry, not a loop. + await tester.enterText(find.byType(TextField), 'zzz'); + await tester.pump(const Duration(milliseconds: 300)); + expect(channels.directoryRequests, 3); + session.searches.removeAt(0).complete(const []); + actions.searches.removeAt(0).complete(const []); + final scope = channelDirectoryScope( + 'http://community.test', + container.read(myPubkeyProvider), + ); + // The search starts from the earlier same-scope failure. + expect( + container.read(channelDirectoryLoadStatusProvider).status, + ChannelDirectoryLoadStatus.idle, + ); + channels.retireDirectory(); + await tester.pump(); + expect(channels.directoryRequests, 4); + expect(spinner, findsOneWidget); + expect(failed, findsNothing); + channels.displaceDirectory(); + await tester.pump(); + expect(channels.directoryRequests, 4); + expect(spinner, findsNothing); + expect(failed, findsOneWidget); + expect(find.text("No results for 'zzz'"), findsNothing); + // Another caller loading the directory clears the notice. + directoryStatus.markLoaded(scope); + await tester.pump(); + expect(failed, findsNothing); + directoryStatus.markError(scope); + await tester.pump(); + expect(failed, findsOneWidget); + + // When it fails again, the member match stays beside the notice. + await tester.enterText(find.byType(TextField), '4things'); + await tester.pump(const Duration(milliseconds: 300)); + expect(channels.directoryRequests, 5); + session.searches.removeAt(0).complete(const []); + actions.searches.removeAt(0).complete(const []); + channels.failDirectory(); + await tester.pump(); + expect(spinner, findsNothing); + expect( + find.byKey(const ValueKey('search-channel-row-team-4things-id')), + findsOneWidget, + ); + expect(unjoined, findsNothing); + expect(failed, findsOneWidget); + + // Retry loads the directory and finds the open channel, even when a + // membership refresh displaces the first load. + await tester.tap(retry); + await tester.pump(); + expect(channels.directoryRequests, 6); + expect(spinner, findsOneWidget); + expect(failed, findsNothing); + channels.displaceDirectory(); + await tester.pump(); + expect(channels.directoryRequests, 7); + expect(spinner, findsOneWidget); + channels.finishDirectory(); + await tester.pump(); + expect(spinner, findsNothing); + expect(failed, findsNothing); + expect(unjoined, findsOneWidget); + + // A community switch retires a directory load still in flight. When it + // finishes, it must not settle the new community's search. + final community = + ProviderScope.containerOf( + tester.element(find.byType(SearchPage)), + ).read(relayConfigProvider.notifier) + as _Community; + // While the switch reloads the channel list, the old community's + // channels must not match. + final reload = channels.holdBuild = Completer(); + final general = find.byKey(const ValueKey('search-channel-row-general-id')); + community.switchTo('http://other.test'); + await tester.pump(); + await tester.enterText(find.byType(TextField), 'general'); + await tester.pump(const Duration(milliseconds: 300)); + expect(channels.directoryRequests, 8); + expect(general, findsNothing); + // A message hit must not take its channel name from that old list. + session.searches.removeLast().complete([ + NostrEvent( + id: 'a' * 64, + pubkey: 'b' * 64, + createdAt: 1, + kind: 9, + tags: const [ + ['h', 'general-id'], + ], + content: 'general notes', + sig: '', + ), + ]); + actions.searches.removeLast().complete(const []); + await tester.pump(); + expect( + find.byKey(ValueKey('search-message-row-${'a' * 64}')), + findsOneWidget, + ); + expect( + find.byKey(ValueKey('search-message-channel-${'a' * 64}')), + findsNothing, + ); + // The row's channel role lookup arms a relay subscription timeout. + await tester.pump(const Duration(milliseconds: 500)); + reload.complete(); + await tester.pump(); + expect(general, findsOneWidget); + community.switchTo('http://third.test'); + await tester.pump(); + await tester.enterText(find.byType(TextField), 'dev'); + await tester.pump(const Duration(milliseconds: 300)); + expect(channels.directoryRequests, 9); + session.searches.removeLast().complete(const []); + actions.searches.removeLast().complete(const []); + channels.finishDirectory(); + await tester.pump(); + expect(spinner, findsOneWidget); + channels.finishDirectory(); + await tester.pump(); + expect(spinner, findsNothing); + }); + + testWidgets('the production directory refresh holds the spinner', ( + tester, + ) async { + final session = _MembershipHeldSession(); + 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(); + + final spinner = find.byType(BuzzLoadingIndicator); + + // The real refresh reads memberships before it marks the directory + // loading. Messages and people finishing first must not stop the spinner. + await tester.enterText(find.byType(TextField), '4things'); + await tester.pump(const Duration(milliseconds: 300)); + session.searches.removeAt(0).complete(const []); + actions.searches.removeAt(0).complete(const []); + await tester.pump(); + expect(session.memberships, hasLength(1)); + expect(spinner, findsOneWidget); + expect(find.text("No results for '4things'"), findsNothing); + + // The refresh fails; search says so and offers a retry. + session.memberships.single.completeError(StateError('relay down')); + await tester.pump(); + expect(spinner, findsNothing); + expect(find.byKey(const Key('search-channels-failed')), findsOneWidget); + + // Another caller's directory load keeps the spinner on until it ends, + // though this search's own request returns at once. + final container = ProviderScope.containerOf( + tester.element(find.byType(SearchPage)), + ); + final status = container.read(channelDirectoryLoadStatusProvider.notifier); + final scope = channelDirectoryScope('http://community.test', 'test'); + status.markLoading(scope); + 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(session.memberships, hasLength(1)); + expect(spinner, findsOneWidget); + status.markError(scope); + await tester.pump(); + expect(spinner, findsNothing); + + // A membership refresh that starts before the load marks the directory + // loading retires the load and writes no status. Search loads it again + // once, through the real refresh. + // It starts from the earlier same-scope failure, as after a reconnect. + expect( + container.read(channelDirectoryLoadStatusProvider).status, + ChannelDirectoryLoadStatus.error, + ); + await tester.enterText(find.byType(TextField), 'dev'); + await tester.pump(const Duration(milliseconds: 300)); + session.searches.removeAt(0).complete(const []); + actions.searches.removeAt(0).complete(const []); + await tester.pump(); + expect(session.memberships, hasLength(2)); + unawaited(container.read(channelsProvider.notifier).refresh()); + await tester.pump(); + expect(session.memberships, hasLength(3)); + session.memberships[1].complete(const []); + await tester.pump(); + expect(session.memberships, hasLength(4)); + expect(spinner, findsOneWidget); + expect(find.byKey(const Key('search-channels-failed')), findsNothing); + }); +} + +Channel _channel(String name, {required bool isMember}) => Channel( + id: '$name-id', + name: name, + channelType: 'stream', + visibility: 'open', + description: '', + memberCount: 3, + createdBy: 'test', + createdAt: DateTime(2025), + isMember: isMember, +); + +class _Community extends RelayConfigNotifier { + @override + RelayConfig build() => const RelayConfig(baseUrl: 'http://community.test'); + + void switchTo(String baseUrl) => state = RelayConfig(baseUrl: baseUrl); +} + +/// Holds every message search open until the test answers it. +class _HeldSession extends RelaySessionNotifier { + final searches = >>[]; + + @override + SessionState build() => const SessionState(status: SessionStatus.connected); + + @override + Future> fetchHistory( + NostrFilter filter, { + Duration timeout = const Duration(seconds: 8), + }) { + final search = Completer>(); + searches.add(search); + return search.future; + } +} + +/// Holds every people search open until the test answers it. +class _HeldUserSearch implements ChannelActions { + final searches = >>[]; + + @override + Future> searchUsers(String query, {int limit = 8}) { + final search = Completer>(); + searches.add(search); + return search.future; + } + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +/// Lists member channels, and adds the open directory when it is loaded. +class _DirectoryChannels extends ChannelsNotifier { + final _directories = >[]; + var directoryRequests = 0; + Duration? maxAge; + + void finishDirectory() => + _directories.removeAt(0).complete(_DirectoryOutcome.loaded); + + void failDirectory() => + _directories.removeAt(0).complete(_DirectoryOutcome.failed); + + /// A membership refresh retires the load after it started loading, so + /// production marks it idle. + void displaceDirectory() => + _directories.removeAt(0).complete(_DirectoryOutcome.displaced); + + /// A membership refresh retires the load before it started loading, so + /// production writes no status at all. + void retireDirectory() => + _directories.removeAt(0).complete(_DirectoryOutcome.retired); + + /// Holds the next list load, like a community switch waiting on the relay. + Completer? holdBuild; + + @override + Future> build() async { + // Like production, a community switch reloads the list. + ref.watch(relayConfigProvider); + final hold = holdBuild; + holdBuild = null; + if (hold != null) await hold.future; + return [ + _channel('general', isMember: true), + _channel('team-4things', isMember: true), + ]; + } + + @override + Future ensureDirectoryLoaded({Duration? maxAge}) async { + this.maxAge = maxAge; + final status = ref.read(channelDirectoryLoadStatusProvider.notifier); + final scope = channelDirectoryScope( + ref.read(relayConfigProvider).baseUrl, + ref.read(myPubkeyProvider), + ); + final directory = ref.read(channelDirectoryLoadStatusProvider); + if (directory.scope == scope && + directory.status == ChannelDirectoryLoadStatus.loaded) { + return; + } + directoryRequests++; + // Like production, a new attempt replaces this scope's last failure, and + // the status turns loading only after memberships arrive. + if (directory.scope == scope && + directory.status == ChannelDirectoryLoadStatus.error) { + status.markIdle(scope); + } + final load = Completer<_DirectoryOutcome>(); + _directories.add(load); + switch (await load.future) { + case _DirectoryOutcome.failed: + // Like production retryDirectory: record the error, do not throw. + status.markError(scope); + return; + case _DirectoryOutcome.displaced: + status.markIdle(scope); + return; + case _DirectoryOutcome.retired: + return; + case _DirectoryOutcome.loaded: + break; + } + // Production marks the directory loaded before it installs the list. + status.markLoaded(scope); + state = AsyncData([...?state.value, _channel('4things', isMember: false)]); + } +} + +enum _DirectoryOutcome { loaded, failed, displaced, retired } + +class _NoRecentSearches extends RecentSearchesNotifier { + @override + List build() => const []; +} + +class _Profile extends ProfileNotifier { + @override + Future build() async => + const UserProfile(pubkey: 'test', displayName: 'Test'); +} + +/// Real channel loading, starting with no memberships. +class _NoMemberChannels extends ChannelsNotifier { + @override + Future> build() async => []; +} + +/// Holds the membership query that starts a real directory refresh. +class _MembershipHeldSession extends _HeldSession { + final memberships = >>[]; + + @override + Future> queryRelay( + List filters, { + Duration timeout = const Duration(seconds: 8), + }) { + final membership = Completer>(); + memberships.add(membership); + return membership.future; + } +}