mirror of
https://github.com/block/buzz.git
synced 2026-11-02 23:41:08 +00:00
fix(mobile): keep mention rows still and cache directory searches (#8875)
Typing `@s` in a stream showed the matching channel members, then about half a second later added people from outside the channel. The chooser sits on the composer and grows upward, so each late row pushed every shown row up and a tap could land on the wrong person. It happened every time, even for a query typed moments earlier, because each new `@` started its directory search from nothing. ### Changes - **Search cache.** Finished directory searches are kept per community and signer, in a small LRU keyed by query (`MentionSearchCache`). A query searched before shows its people at once while a fresh search runs in the background. A query not searched yet starts from the longest searched query it extends (`@si` starts from `@s`), since a name-prefix search for a longer query finds no one the shorter one missed (up to the search's result limit). This replaces the per-opening "last search" that reset on every new `@`. - **Rows hold still.** While a query's directory search can still add people or a retry row (`mentionSearchPendingProvider`), the chooser reserves its full height with rows at the top, so late rows fill empty space instead of moving shown rows. This includes the background refresh of a cached query. The reservation lasts until the query or the opening changes, so the panel never shrinks under the finger either. - **Three rows, with progress.** The chooser has a fixed three-row height (fewer rows at larger text sizes) before it scrolls, and never taller than the space above the composer (so a short view, such as landscape with the keyboard up, keeps its rows on screen). While the search runs, a thin bar overlays its top edge without changing its height. The trade-off: while a query's search runs, the panel is full height even when it ends with one or two rows. ### Demo iPhone 18 Pro simulator, local relay. Sam and Sasha are channel members; Saba, Sierra and Skyler come from the directory search. https://github.com/user-attachments/assets/efae33ce-ee65-447b-9402-b0741c8dc2da ### Before merge - [x] Kenny Lopez reviews the visible changes ([approved in Buzz #buzz-mobile](buzz://message?channel=9f18f3c6-b5e5-453d-b36d-65487913be0b&id=c8b01eaa3760e44c3b6583f2e02bec576d78679adfb364e5fe718cc0926bdc2d)). ### Validation - New tests: a cached query reopens with its people at once, stays pending while it searches again, and clears when that search finishes; a longer query starts from its prefix's people; an uncached query is pending until its search finishes; and a compose bar journey step checks that a late directory person leaves the shown row's position and the panel height unchanged, both on a new query and on a cached query's refresh. Other new tests cover the three-row cap, the progress bar, and an 844×390 landscape view with the keyboard up, where the reserved chooser previously pushed the only row above the screen. Disabling the cache lookup fails the two cache tests, and disabling the reservation fails the layout tests (the cached-refresh case moved a row 59pt). - `just mobile-check` and `just mobile-test` pass. - Exercised on the simulator against a local relay as shown above. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Signed-off-by: Tom Brow <tomb@squareup.com> Signed-off-by: Tom Brow <tomb@block.xyz> Co-authored-by: Tom Brow <tomb@squareup.com> Co-authored-by: studio <dc2fc481343672df31a689840c09299972e704dac592e0da6f18486646a7ee3e@buzz.block.builderlab.xyz> Co-authored-by: mbp <973be878edd6d4f79692a3827f20a6f7fe0ed0362f269ff6b6ba8867033d7d8e@buzz.block.builderlab.xyz> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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));
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -160,6 +160,8 @@ Widget _composerSuggestionPanel({
|
||||
required List<MentionCandidate> mentionSuggestions,
|
||||
required Set<String> unavailableMentions,
|
||||
required bool mentionSearchFailed,
|
||||
required bool reserveMentionHeight,
|
||||
required bool mentionSearching,
|
||||
required VoidCallback onMentionSearchRetry,
|
||||
required Map<String, UserProfile> 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,
|
||||
|
||||
@@ -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<MentionCandidate> rows,
|
||||
Set<String> 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<MentionCandidate>)?>(null);
|
||||
final shown = useRef<(String, int, List<MentionCandidate>, 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,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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<MentionCandidate> 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<String, UserProfile> 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,
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<List<UserProfile>, 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<UserProfile>>{};
|
||||
/// 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 = <String, List<UserProfile>>{};
|
||||
|
||||
List<UserProfile>? 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<UserProfile> people) {
|
||||
if (people.isEmpty) return;
|
||||
final key = (channelId, opening);
|
||||
/// The finished search for exactly [query], or null.
|
||||
List<UserProfile>? 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<UserProfile>? 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<UserProfile> 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<MentionSearchCache>((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 <UserProfile>[]
|
||||
? searches.seed(args.query) ?? const <UserProfile>[]
|
||||
: const <UserProfile>[],
|
||||
);
|
||||
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<bool, MentionChooserArgs>((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;
|
||||
});
|
||||
|
||||
@@ -227,7 +227,10 @@ Widget _buildComposeBar({
|
||||
if (searchPeople != null)
|
||||
mentionUserSearchProvider.overrideWith((ref, query) async {
|
||||
await Future<void>.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 {
|
||||
|
||||
@@ -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 = <Completer<List<NostrEvent>>>[];
|
||||
final queries = <String>[];
|
||||
|
||||
/// The open search for [query].
|
||||
Completer<List<NostrEvent>> 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<List<NostrEvent>>();
|
||||
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<String> 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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user