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; + } +}