mirror of
https://github.com/block/buzz.git
synced 2026-11-03 16:01:35 +00:00
## 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 <loganj@squareup.com> Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: Wes <wesbillman@users.noreply.github.com>