fix(mobile): find open channels in search and stop the stuck spinner (#8764)

## 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>
This commit is contained in:
Logan Johnson
2026-11-01 10:28:58 -05:00
committed by GitHub
parent db4478e8d2
commit 2e4aacc9e0
10 changed files with 1320 additions and 56 deletions

View File

@@ -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<ChannelDirectoryLoadState> {
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<ChannelDirectoryLoadState> {
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;

View File

@@ -76,6 +76,9 @@ class ChannelsNotifier extends AsyncNotifier<List<Channel>> {
String? _memberSnapshotPubkey;
Map<String, List<ChannelMember>> _memberSnapshotsByChannelId = const {};
List<NostrEvent> _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<String> _hiddenDmIds = const {};
/// Fences directory responses to the relay and identity that requested them.
@@ -173,6 +176,7 @@ class ChannelsNotifier extends AsyncNotifier<List<Channel>> {
_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<List<Channel>> {
// 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<List<Channel>> {
return _channelFromMeta(latest, data, isMember: false);
}
/// Loads the directory when Browse channels opens after startup or an error.
Future<void> ensureDirectoryLoaded() async {
/// Loads the directory unless it is loading, or loaded within [maxAge]
/// (any age when null), for the current relay and identity.
Future<void> 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<List<Channel>> {
/// Retries channel discovery while retaining the current channel list.
Future<void> retryDirectory() async {
final previousChannels = state.value;
final directoryStatus = ref.read(
channelDirectoryLoadStatusProvider.notifier,
);
@@ -1096,6 +1109,20 @@ class ChannelsNotifier extends AsyncNotifier<List<Channel>> {
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),

View File

@@ -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<String> _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 = <int, bool>{};
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;
}

View File

@@ -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<String> searches;
final ValueChanged<String> onSelected;

View File

@@ -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<Channel>? 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<Channel> rankSearchChannels(Iterable<Channel> 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<SearchState> {
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<SearchState> {
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<void> _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<void> _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<void> 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<void> _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<SearchState> {
// 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 <Channel>[])
for (final c
in ref.read(channelsProvider).asData?.value ?? const <Channel>[])
c.id: c.name,
};
@@ -167,48 +342,56 @@ class SearchNotifier extends Notifier<SearchState> {
)
.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<void> _searchUsers(String query) async {
Future<void> _searchUsers(String query, int generation) async {
List<DirectoryUser>? 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 <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,
isLoading: _isLoading,
);
}
void clear() {
_debounce?.cancel();
_generation++;
_messagesPending = false;
_usersPending = false;
state = const SearchState.initial();
}
}

View File

@@ -3687,7 +3687,7 @@ class _FakeNotifier extends ChannelsNotifier {
}
@override
Future<void> ensureDirectoryLoaded() async {
Future<void> ensureDirectoryLoaded({Duration? maxAge}) async {
ref
.read(channelDirectoryLoadStatusProvider.notifier)
.markLoaded(_activeDirectoryScope(ref));

View File

@@ -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

View File

@@ -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');
}
});
}

View File

@@ -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<Channel>, String, List<String>)>[
(
'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,
);

View File

@@ -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<void>();
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 = <Completer<List<NostrEvent>>>[];
@override
SessionState build() => const SessionState(status: SessionStatus.connected);
@override
Future<List<NostrEvent>> fetchHistory(
NostrFilter filter, {
Duration timeout = const Duration(seconds: 8),
}) {
final search = Completer<List<NostrEvent>>();
searches.add(search);
return search.future;
}
}
/// Holds every people search open until the test answers it.
class _HeldUserSearch implements ChannelActions {
final searches = <Completer<List<DirectoryUser>>>[];
@override
Future<List<DirectoryUser>> searchUsers(String query, {int limit = 8}) {
final search = Completer<List<DirectoryUser>>();
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 = <Completer<_DirectoryOutcome>>[];
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<void>? holdBuild;
@override
Future<List<Channel>> 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<void> 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<String> build() => const [];
}
class _Profile extends ProfileNotifier {
@override
Future<UserProfile?> build() async =>
const UserProfile(pubkey: 'test', displayName: 'Test');
}
/// Real channel loading, starting with no memberships.
class _NoMemberChannels extends ChannelsNotifier {
@override
Future<List<Channel>> build() async => [];
}
/// Holds the membership query that starts a real directory refresh.
class _MembershipHeldSession extends _HeldSession {
final memberships = <Completer<List<NostrEvent>>>[];
@override
Future<List<NostrEvent>> queryRelay(
List<NostrFilter> filters, {
Duration timeout = const Duration(seconds: 8),
}) {
final membership = Completer<List<NostrEvent>>();
memberships.add(membership);
return membership.future;
}
}