diff --git a/mobile/lib/features/channels/compose_bar/compose_bar_widget.dart b/mobile/lib/features/channels/compose_bar/compose_bar_widget.dart index 8db1b577a6..aa56b7d910 100644 --- a/mobile/lib/features/channels/compose_bar/compose_bar_widget.dart +++ b/mobile/lib/features/channels/compose_bar/compose_bar_widget.dart @@ -257,6 +257,8 @@ class ComposeBar extends HookConsumerWidget { rows: const [], unavailable: const {}, searchFailed: false, + reserveHeight: false, + searching: false, )); // Map of displayName → selected mention candidate built as the user selects // mentions. Used to pass resolved pubkeys directly to onSend and to attach @@ -1045,6 +1047,8 @@ class ComposeBar extends HookConsumerWidget { mentionSuggestions: suggestions, unavailableMentions: mentionRows.unavailable, mentionSearchFailed: mentionRows.searchFailed, + reserveMentionHeight: mentionRows.reserveHeight, + mentionSearching: mentionRows.searching, onMentionSearchRetry: () { final query = mentionQuery.value; if (query != null) ref.invalidate(mentionUserSearchProvider(query)); diff --git a/mobile/lib/features/channels/compose_bar/dock.dart b/mobile/lib/features/channels/compose_bar/dock.dart index 382a2b10b8..3b0cbb71e6 100644 --- a/mobile/lib/features/channels/compose_bar/dock.dart +++ b/mobile/lib/features/channels/compose_bar/dock.dart @@ -188,10 +188,25 @@ class _ComposerOverlayPortal extends StatelessWidget { left: surfaceLeft, bottom: layoutInfo.overlaySize.height - overlayAnchorY, width: surfaceWidth, - child: ClipRect( - child: Padding( - padding: const EdgeInsets.only(bottom: Grid.xxs), - child: buildOverlayPanel(surface), + child: ConstrainedBox( + // The suggestions grow upward from the composer, so cap + // them at the space above it. A short view, such as + // landscape with the keyboard up, then keeps the top row + // on screen instead of honoring a reserved height. + constraints: BoxConstraints( + maxHeight: surface == _AttachmentSurface.closed + ? math.max( + 0, + overlayAnchorY - + MediaQuery.paddingOf(context).top, + ) + : double.infinity, + ), + child: ClipRect( + child: Padding( + padding: const EdgeInsets.only(bottom: Grid.xxs), + child: buildOverlayPanel(surface), + ), ), ), ), diff --git a/mobile/lib/features/channels/compose_bar/helpers.dart b/mobile/lib/features/channels/compose_bar/helpers.dart index 50889c059a..6754f77531 100644 --- a/mobile/lib/features/channels/compose_bar/helpers.dart +++ b/mobile/lib/features/channels/compose_bar/helpers.dart @@ -160,6 +160,8 @@ Widget _composerSuggestionPanel({ required List mentionSuggestions, required Set unavailableMentions, required bool mentionSearchFailed, + required bool reserveMentionHeight, + required bool mentionSearching, required VoidCallback onMentionSearchRetry, required Map userCache, required String? currentPubkey, @@ -181,6 +183,8 @@ Widget _composerSuggestionPanel({ suggestions: mentionSuggestions, unavailable: unavailableMentions, searchFailed: mentionSearchFailed, + reserveHeight: reserveMentionHeight, + searching: mentionSearching, onRetry: onMentionSearchRetry, userCache: userCache, currentPubkey: currentPubkey, diff --git a/mobile/lib/features/channels/compose_bar/mention_chooser.dart b/mobile/lib/features/channels/compose_bar/mention_chooser.dart index bd63b5d83f..61871aa6c5 100644 --- a/mobile/lib/features/channels/compose_bar/mention_chooser.dart +++ b/mobile/lib/features/channels/compose_bar/mention_chooser.dart @@ -3,10 +3,19 @@ part of '../compose_bar.dart'; /// The rows the open mention chooser shows, and the shown rows that can no /// longer be chosen. A row keeps its place while the query and the opening /// stay the same (portable mention rules, section 6). +/// +/// The chooser sits on the composer and grows upward, so a taller chooser +/// moves every shown row. While the directory search can still add people +/// for this query, `reserveHeight` holds the chooser at full height so a +/// late row fills empty space instead of moving the row under a finger. +/// The reservation lasts until the query or the opening changes. +/// `searching` is true only while that search is still running. typedef _MentionRows = ({ List rows, Set unavailable, bool searchFailed, + bool reserveHeight, + bool searching, }); _MentionRows _useMentionRows( @@ -15,22 +24,32 @@ _MentionRows _useMentionRows( required String? query, required int opening, }) { - final shown = useRef<(String, int, List)?>(null); + final shown = useRef<(String, int, List, bool)?>(null); if (query == null) { shown.value = null; - return (rows: const [], unavailable: const {}, searchFailed: false); + return ( + rows: const [], + unavailable: const {}, + searchFailed: false, + reserveHeight: false, + searching: false, + ); } final args = (channelId: channelId, query: query, opening: opening); final ranked = ref.watch(mentionCandidatesProvider(args)); final searchFailed = ref.watch(mentionSearchFailedProvider(args)); + final pending = ref.watch(mentionSearchPendingProvider(args)); final last = shown.value; final same = last != null && last.$1 == query && last.$2 == opening; final result = stableMentionRows(same ? last.$3 : const [], ranked); - shown.value = (query, opening, result.rows); + final reserveHeight = pending || (same && last.$4); + shown.value = (query, opening, result.rows, reserveHeight); return ( rows: result.rows, unavailable: result.unavailable, searchFailed: searchFailed, + reserveHeight: reserveHeight, + searching: pending, ); } diff --git a/mobile/lib/features/channels/compose_bar/suggestions.dart b/mobile/lib/features/channels/compose_bar/suggestions.dart index f743c144c2..9481804a99 100644 --- a/mobile/lib/features/channels/compose_bar/suggestions.dart +++ b/mobile/lib/features/channels/compose_bar/suggestions.dart @@ -1,5 +1,11 @@ part of '../compose_bar.dart'; +/// A mention row with its info line: a dense, compact two-line `ListTile`. +const _mentionRowHeight = 59.0; + +/// The chooser shows at most three rows, then scrolls. +const _mentionChooserHeight = _mentionRowHeight * 3 + Grid.xxs * 2; + class _MentionSuggestions extends StatelessWidget { final List suggestions; @@ -8,6 +14,14 @@ class _MentionSuggestions extends StatelessWidget { /// The directory search failed: show its error and a retry below the rows. final bool searchFailed; + + /// Hold the full height, rows at the top, so rows that join late fill + /// empty space instead of pushing the shown rows up. + final bool reserveHeight; + + /// The directory search is still running: show a thin progress bar on the + /// top edge. It overlays the rows, so it never changes the height. + final bool searching; final VoidCallback? onRetry; final Map userCache; final String? currentPubkey; @@ -18,6 +32,8 @@ class _MentionSuggestions extends StatelessWidget { required this.suggestions, this.unavailable = const {}, this.searchFailed = false, + this.reserveHeight = false, + this.searching = false, this.onRetry, required this.userCache, required this.currentPubkey, @@ -36,73 +52,96 @@ class _MentionSuggestions extends StatelessWidget { shadowColor: appPopoverShadowColor(context), shape: appPopoverShape(context), clipBehavior: Clip.hardEdge, - child: ConstrainedBox( - constraints: const BoxConstraints(maxHeight: 240), - child: ListView.separated( - shrinkWrap: true, - padding: const EdgeInsets.symmetric(vertical: Grid.xxs), - itemCount: suggestions.length + (searchFailed ? 1 : 0), - separatorBuilder: (_, _) => const SizedBox.shrink(), - itemBuilder: (context, index) { - if (index == suggestions.length) { - return ListTile( - key: const ValueKey('mention-search-error'), - dense: true, - visualDensity: VisualDensity.compact, - title: Text( - 'Could not search community people.', - style: context.textTheme.bodySmall?.copyWith( - color: context.colors.error, - ), - ), - trailing: TextButton( - onPressed: onRetry == null - ? null - : () => _runComposerAction(onRetry!), - child: const Text('Retry'), - ), - ); - } - final candidate = suggestions[index]; - final name = candidate.pickerLabel; - final avatarUrl = - candidate.avatarUrl ?? userCache[candidate.pubkey]?.avatarUrl; + child: Stack( + children: [ + _rows(context), + if (searching) + const Positioned( + top: 0, + left: 0, + right: 0, + child: LinearProgressIndicator( + key: ValueKey('mention-search-progress'), + minHeight: 2, + backgroundColor: Colors.transparent, + semanticsLabel: 'Searching people', + ), + ), + ], + ), + ); + } - final available = !unavailable.contains(candidate.pubkey); + Widget _rows(BuildContext context) { + return ConstrainedBox( + constraints: BoxConstraints( + minHeight: reserveHeight ? _mentionChooserHeight : 0, + maxHeight: _mentionChooserHeight, + ), + child: ListView.separated( + shrinkWrap: true, + padding: const EdgeInsets.symmetric(vertical: Grid.xxs), + itemCount: suggestions.length + (searchFailed ? 1 : 0), + separatorBuilder: (_, _) => const SizedBox.shrink(), + itemBuilder: (context, index) { + if (index == suggestions.length) { return ListTile( - enabled: available, + key: const ValueKey('mention-search-error'), dense: true, visualDensity: VisualDensity.compact, - leading: AvatarImage( - imageUrl: avatarUrl, - radius: 18, - backgroundColor: context.controlSurface, - fallback: Text( - // Name-derived for named candidates; keyed to the hex - // public key for unnamed ones so the compact-npub label - // doesn't render `N` for everyone. - candidate.initial, - style: context.textTheme.labelMedium?.copyWith( - color: context.colors.onPrimaryContainer, - fontWeight: FontWeight.w600, - ), + title: Text( + 'Could not search community people.', + style: context.textTheme.bodySmall?.copyWith( + color: context.colors.error, ), - isAgent: candidate.isAgent, ), - title: Text(name, style: context.textTheme.titleSmall), - subtitle: _MentionSuggestionInfo.build( - context, - candidate: candidate, - currentPubkey: currentPubkey, - isDmChannel: isDmChannel, - userCache: userCache, + trailing: TextButton( + onPressed: onRetry == null + ? null + : () => _runComposerAction(onRetry!), + child: const Text('Retry'), ), - onTap: available - ? () => _runComposerAction(() => onSelect(candidate)) - : null, ); - }, - ), + } + final candidate = suggestions[index]; + final name = candidate.pickerLabel; + final avatarUrl = + candidate.avatarUrl ?? userCache[candidate.pubkey]?.avatarUrl; + + final available = !unavailable.contains(candidate.pubkey); + return ListTile( + enabled: available, + dense: true, + visualDensity: VisualDensity.compact, + leading: AvatarImage( + imageUrl: avatarUrl, + radius: 18, + backgroundColor: context.controlSurface, + fallback: Text( + // Name-derived for named candidates; keyed to the hex + // public key for unnamed ones so the compact-npub label + // doesn't render `N` for everyone. + candidate.initial, + style: context.textTheme.labelMedium?.copyWith( + color: context.colors.onPrimaryContainer, + fontWeight: FontWeight.w600, + ), + ), + isAgent: candidate.isAgent, + ), + title: Text(name, style: context.textTheme.titleSmall), + subtitle: _MentionSuggestionInfo.build( + context, + candidate: candidate, + currentPubkey: currentPubkey, + isDmChannel: isDmChannel, + userCache: userCache, + ), + onTap: available + ? () => _runComposerAction(() => onSelect(candidate)) + : null, + ); + }, ), ); } diff --git a/mobile/lib/features/channels/mentions/mention_candidates_provider.dart b/mobile/lib/features/channels/mentions/mention_candidates_provider.dart index 09dd14cdc4..88f73b0db4 100644 --- a/mobile/lib/features/channels/mentions/mention_candidates_provider.dart +++ b/mobile/lib/features/channels/mentions/mention_candidates_provider.dart @@ -26,6 +26,7 @@ const mentionSearchDebounce = Duration(milliseconds: 200); /// /// Debounced: the provider waits [mentionSearchDebounce] before querying; /// keystrokes dispose the stale family member so its request never fires. +/// Each finished search is kept in [mentionSearchCacheProvider]. final mentionUserSearchProvider = FutureProvider.autoDispose .family, String>((ref, query) async { // The session notifier is stable across communities, and two @@ -57,9 +58,12 @@ final mentionUserSearchProvider = FutureProvider.autoDispose } } - return [ + final people = [ for (final event in latestByPubkey.values) _profileFromEvent(event), ]; + // A search that outlived its query or community is not kept. + if (!disposed) ref.read(mentionSearchCacheProvider).put(trimmed, people); + return people; }); UserProfile _profileFromEvent(NostrEvent event) { @@ -77,31 +81,49 @@ UserProfile _profileFromEvent(NostrEvent event) { /// One chooser opening: an inline `@` token or one open picker. typedef MentionChooserArgs = ({String channelId, String query, int opening}); -/// The last finished directory search for each chooser opening. A new query -/// keeps showing its still-matching people while its own search runs. -/// Nothing here outlives the relay session or the community, and a new -/// opening starts empty. -class _SettledSearches { - final _pages = <(String, int), List>{}; +/// Finished directory searches by query, for the current community and +/// signer. A query searched before shows its people at once while a fresh +/// search runs, so typing the same query twice shows the same rows. A +/// query not searched yet starts from the longest searched query it +/// continues: a name search for `sa` finds no one that `s` did not. +class MentionSearchCache { + static const limit = 64; + final _pages = >{}; - List? last(String channelId, int opening) => - _pages[(channelId, opening)]; + static String _key(String query) => query.trim().toLowerCase(); - /// Keeps [people] as this opening's last search. A search that found no - /// one is not kept, so the next opening searches again. - void settle(String channelId, int opening, List people) { - if (people.isEmpty) return; - final key = (channelId, opening); + /// The finished search for exactly [query], or null. + List? exact(String query) => _pages[_key(query)]; + + /// The finished search for [query], else the one for the longest + /// searched query that [query] continues, else null. + List? seed(String query) { + final key = _key(query); + if (key.isEmpty) return null; + for (var end = key.length; end > 0; end--) { + final page = _pages[key.substring(0, end)]; + if (page != null) return page; + } + return null; + } + + /// Keeps [people] as the latest search for [query], newest last. + void put(String query, List people) { + final key = _key(query); + if (key.isEmpty) return; _pages.remove(key); _pages[key] = people; - if (_pages.length > 20) _pages.remove(_pages.keys.first); + while (_pages.length > limit) { + _pages.remove(_pages.keys.first); + } } } -final _settledSearchesProvider = Provider<_SettledSearches>((ref) { - ref.watch(relaySessionProvider.select((s) => s.status)); +/// Nothing in the search cache outlives the community or the signer. +final mentionSearchCacheProvider = Provider((ref) { ref.watch(relayConfigProvider); - return _SettledSearches(); + ref.watch(currentPubkeyProvider); + return MentionSearchCache(); }); /// Explicit mention choices per channel, newest highest. Memory only, @@ -169,7 +191,7 @@ final mentionCandidatesProvider = Provider.family // Archived channels take no mentions. if (channel?.isArchived == true) return const []; final directory = channel?.mentionsOutsidePeople ?? false; - final settled = ref.watch(_settledSearchesProvider); + final searches = ref.watch(mentionSearchCacheProvider); final sharedChannelIds = { for (final channel in channels) @@ -198,17 +220,13 @@ final mentionCandidatesProvider = Provider.family // again with the people it finds (portable mention rules, section 2). var candidates = build( directory - ? settled.last(args.channelId, args.opening) ?? - const [] + ? searches.seed(args.query) ?? const [] : const [], ); if (directory) { final search = ref.watch(mentionUserSearchProvider(args.query)); final page = search.asData?.value; - if (page != null) { - settled.settle(args.channelId, args.opening, page); - candidates = build(page); - } + if (page != null) candidates = build(page); } if (!matchesMentionQuery(args.query, [ for (final candidate in candidates) @@ -274,3 +292,18 @@ final mentionSearchFailedProvider = Provider.family final search = ref.watch(mentionUserSearchProvider(args.query)); return search.hasError && !search.isLoading; }); + +/// Whether this chooser's directory search is still running, so people may +/// still join its rows. A cached query counts too: its refresh can add people +/// or the retry row below the cached page. +final mentionSearchPendingProvider = Provider.family + .autoDispose((ref, args) { + final channel = ref.watch(channelByIdProvider(args.channelId)); + if (channel == null || + channel.isArchived || + !channel.mentionsOutsidePeople || + args.query.trim().isEmpty) { + return false; + } + return ref.watch(mentionUserSearchProvider(args.query)).isLoading; + }); diff --git a/mobile/test/features/channels/compose_bar_test.dart b/mobile/test/features/channels/compose_bar_test.dart index 46ebf72e1f..9afad8a85c 100644 --- a/mobile/test/features/channels/compose_bar_test.dart +++ b/mobile/test/features/channels/compose_bar_test.dart @@ -227,7 +227,10 @@ Widget _buildComposeBar({ if (searchPeople != null) mentionUserSearchProvider.overrideWith((ref, query) async { await Future.delayed(mentionSearchDebounce); - return searchPeople(query.trim()); + final people = await searchPeople(query.trim()); + // Like the real search, a finished page is cached by query. + ref.read(mentionSearchCacheProvider).put(query.trim(), people); + return people; }), customEmojiListProvider.overrideWithValue(customEmoji), mediaUploadServiceProvider.overrideWithValue(uploadService), @@ -1897,6 +1900,124 @@ void main() { expect(find.text('Mary Jane'), findsOneWidget); }); + testWidgets('a late directory person does not move the shown rows', ( + tester, + ) async { + const saba = UserProfile( + pubkey: + 'eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee', + displayName: 'Saba Saba', + ); + await pumpMembers(tester, [ + 'Sam', + ], searchPeople: (query) async => query == 'S' ? [saba] : const []); + await tester.enterText(find.byType(TextField), '@S'); + await tester.pump(); + expect(find.text('Saba Saba'), findsNothing); + final samTop = tester.getTopLeft(find.text('Sam')).dy; + final height = tester.getSize(popover()).height; + + await settleSearch(tester); + expect(find.text('Saba Saba'), findsOneWidget); + // The late row filled reserved space: nothing shown moved. + expect(tester.getTopLeft(find.text('Sam')).dy, samTop); + expect(tester.getSize(popover()).height, height); + }); + + testWidgets('a cached query\'s refresh does not move the shown rows', ( + tester, + ) async { + const saba = UserProfile( + pubkey: + 'eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee', + displayName: 'Saba Saba', + ); + var searches = 0; + await pumpMembers( + tester, + ['Sam'], + searchPeople: (query) async => + query == 'S' && searches++ > 0 ? [saba] : const [], + ); + await tester.enterText(find.byType(TextField), '@S'); + await tester.pump(); + await settleSearch(tester); + expect(find.text('Saba Saba'), findsNothing); + await tester.enterText(find.byType(TextField), ''); + await tester.pump(); + + // A new opening shows the cached page at once, then refreshes. + await tester.enterText(find.byType(TextField), '@S'); + await tester.pump(); + expect(find.text('Saba Saba'), findsNothing); + final samTop = tester.getTopLeft(find.text('Sam')).dy; + final height = tester.getSize(popover()).height; + + await settleSearch(tester); + expect(find.text('Saba Saba'), findsOneWidget); + expect(tester.getTopLeft(find.text('Sam')).dy, samTop); + expect(tester.getSize(popover()).height, height); + }); + + testWidgets( + 'a short landscape view keeps the reserved chooser on screen', + (tester) async { + tester.view.devicePixelRatio = 1; + tester.view.physicalSize = const Size(844, 390); + tester.view.viewInsets = const FakeViewPadding(bottom: 170); + addTearDown(tester.view.reset); + await pumpMembers(tester, ['Sam']); + await tester.enterText(find.byType(TextField), '@S'); + await tester.pump(); + + // The search is still running, so the chooser reserves its height, + // but no more than fits above the composer. + expect( + find.byKey(const ValueKey('mention-search-progress')), + findsOneWidget, + ); + final sam = tester.getRect(find.widgetWithText(ListTile, 'Sam')); + expect(sam.top, greaterThanOrEqualTo(0)); + expect(tester.getRect(popover()).top, greaterThanOrEqualTo(0)); + await tester.tap(find.text('Sam')); + await tester.pump(); + expect(find.textContaining('@Sam'), findsWidgets); + await settleSearch(tester); + }, + ); + + testWidgets('the chooser shows at most three rows, then scrolls', ( + tester, + ) async { + final people = [ + for (final (i, name) in ['Saba', 'Sage', 'Sasha', 'Selma'].indexed) + UserProfile(pubkey: '${'a' * 63}$i', displayName: '$name Person'), + ]; + await pumpMembers(tester, [ + 'Sam', + ], searchPeople: (query) async => query == 'S' ? people : const []); + await tester.enterText(find.byType(TextField), '@S'); + await tester.pump(); + await settleSearch(tester); + final row = tester.getSize( + find.widgetWithText(ListTile, 'Saba Person'), + ); + expect(tester.getSize(popover()).height, row.height * 3 + Grid.xxs * 2); + }); + + testWidgets('a running search shows a progress bar on the chooser', ( + tester, + ) async { + final progress = find.byKey(const ValueKey('mention-search-progress')); + await pumpMembers(tester, ['Sam']); + await tester.enterText(find.byType(TextField), '@S'); + await tester.pump(); + expect(progress, findsOneWidget); + expect(tester.getTopLeft(progress).dy, tester.getTopLeft(popover()).dy); + await settleSearch(tester); + expect(progress, findsNothing); + }); + testWidgets('a DM finds directory people too, marked not in DM', ( tester, ) async { diff --git a/mobile/test/features/channels/mentions/mention_search_community_test.dart b/mobile/test/features/channels/mentions/mention_search_community_test.dart index 19f7e974be..9dcd2d0f1b 100644 --- a/mobile/test/features/channels/mentions/mention_search_community_test.dart +++ b/mobile/test/features/channels/mentions/mention_search_community_test.dart @@ -50,6 +50,11 @@ NostrEvent _profile(String pubkey, String name) => NostrEvent( /// Holds every search request open until the test answers it. class _HeldSearchSession extends RelaySessionNotifier { final searches = >>[]; + final queries = []; + + /// The open search for [query]. + Completer> searchFor(String query) => + searches[queries.lastIndexOf(query)]; @override SessionState build() => const SessionState(status: SessionStatus.connected); @@ -68,6 +73,7 @@ class _HeldSearchSession extends RelaySessionNotifier { }) { final search = Completer>(); searches.add(search); + queries.add(query); return search.future; } @@ -214,4 +220,83 @@ void main() { expect(session.searches, hasLength(2)); expect(shownPubkeys(), isNot(contains(_alice))); }); + + group('search cache', () { + List shownFor(MentionChooserArgs args) => [ + for (final candidate in container.read(mentionCandidatesProvider(args))) + candidate.pubkey, + ]; + + /// Opens a chooser for [args]; the returned function closes it. + void Function() open(MentionChooserArgs args) { + final candidates = container.listen( + mentionCandidatesProvider(args), + (_, _) {}, + ); + final pending = container.listen( + mentionSearchPendingProvider(args), + (_, _) {}, + ); + void close() { + candidates.close(); + pending.close(); + } + + addTearDown(close); + return close; + } + + test('an unsearched query is pending until its search finishes', () async { + expect(container.read(mentionSearchPendingProvider(_args)), isTrue); + await passTypingPause(); + expect(container.read(mentionSearchPendingProvider(_args)), isTrue); + + session.searches.first.complete([_profile(_alice, 'Alice')]); + await container.pump(); + expect(container.read(mentionSearchPendingProvider(_args)), isFalse); + expect(shownPubkeys(), [_alice]); + }); + + test( + 'a new opening of a searched query shows its people at once', + () async { + const first = (channelId: _channelId, query: 'bo', opening: 2); + final close = open(first); + await passTypingPause(); + session.searchFor('bo').complete([_profile(_bob, 'Bob')]); + await container.pump(); + expect(shownFor(first), [_bob]); + close(); + await container.pump(); + + const again = (channelId: _channelId, query: 'bo', opening: 3); + open(again); + expect(shownFor(again), [_bob]); + // It still searches again in the background, which can add people, + // so it is pending until that search finishes. + expect(container.read(mentionSearchPendingProvider(again)), isTrue); + final searched = session.searches.length; + await passTypingPause(); + expect(session.searches, hasLength(searched + 1)); + session.searches.last.complete([_profile(_bob, 'Bob')]); + await container.pump(); + expect(container.read(mentionSearchPendingProvider(again)), isFalse); + }, + ); + + test('a longer query starts from the people its prefix found', () async { + await passTypingPause(); + session.searches.first.complete([ + _profile(_alice, 'Alice'), + _profile(_bob, 'Alba'), + ]); + await container.pump(); + + const longer = (channelId: _channelId, query: 'ali', opening: 1); + open(longer); + expect(shownFor(longer), [_alice]); + // Its own search can still add people, so it is pending. + expect(container.read(mentionSearchPendingProvider(longer)), isTrue); + }); + }); }