mirror of
https://github.com/block/buzz.git
synced 2026-10-27 12:40:30 +00:00
fix(mobile): bound search result rendering work (#8418)
Global search eagerly mounted every result and parsed entire messages even for two-line previews. Large markdown hits could block the UI while changing categories, updating results, or reopening search. Build result rows lazily and bound long message previews to a literal prefix of at most 512 UTF-16 units without splitting surrogate pairs. Short messages retain rich formatting and mentions. Long snippets show markdown punctuation literally; opening a result still uses the original message. Truncated markdown is never passed to the rich renderer. ### Validation - Added regressions for lazy message/channel/people rows, scrolling to the final result, and a >100 KB markdown hit with a surrogate pair at the preview boundary. All 23 focused search tests pass; full mobile analysis is clean. The original large-hit test consumed a CPU core for over 30 seconds before being terminated. - Native iOS simulator build passed. Diagnostic Debug/JIT captures attributed a 1,033 ms search frame and a separate 12,972 ms result-update callback to widget/markdown work. A verified populated-results run of the final candidate completed category switching, scrolling, and closing, with a longest UI callback of 70 ms. Separate instrumented Debug/JIT runs have uncontrolled host load, warm-up, and live data; they are not release-performance benchmarks. - Full local gate passed through desktop builds/checks, then failed two unchanged native subprocess PID/timeout tests; both passed in isolation. The remaining web build and mobile suite passed. - [Codex review](https://github.com/block/buzz/pull/8418#issuecomment-6243734066) completed on the current head with no findings. [CI](https://github.com/block/buzz/actions/runs/39082758977) passed, including mobile checks, all three test shards, Android, the iOS release build, and native simulator build/tests. ### Preview examples Synthetic widget captures with app fonts, without real community data or native iOS glass controls. | Long message before | Bounded literal preview after | | --- | --- | |  |  | --------- Signed-off-by: Tom Brow <tomb@block.xyz>
This commit is contained in:
@@ -328,41 +328,49 @@ class _SearchBody extends ConsumerWidget {
|
||||
);
|
||||
}
|
||||
|
||||
return ListView(
|
||||
return CustomScrollView(
|
||||
key: const Key('search-results-list'),
|
||||
padding: EdgeInsets.only(
|
||||
bottom:
|
||||
Grid.xl +
|
||||
MediaQuery.paddingOf(context).bottom +
|
||||
MediaQuery.viewInsetsOf(context).bottom,
|
||||
),
|
||||
children: [
|
||||
if (showChannels && state.channelResults.isNotEmpty)
|
||||
_ChannelsSection(
|
||||
channels: state.channelResults,
|
||||
onResultSelected: recordResultSelection,
|
||||
slivers: [
|
||||
SliverPadding(
|
||||
padding: EdgeInsets.only(
|
||||
bottom:
|
||||
Grid.xl +
|
||||
MediaQuery.paddingOf(context).bottom +
|
||||
MediaQuery.viewInsetsOf(context).bottom,
|
||||
),
|
||||
if (showPeople && state.userResults.isNotEmpty)
|
||||
_PeopleSection(
|
||||
users: state.userResults,
|
||||
onResultSelected: recordResultSelection,
|
||||
),
|
||||
if (showMessages && state.messageResults.isNotEmpty)
|
||||
_MessagesSection(
|
||||
hits: state.messageResults,
|
||||
currentPubkey: currentPubkey,
|
||||
onResultSelected: recordResultSelection,
|
||||
),
|
||||
if (state.isLoading)
|
||||
const Padding(
|
||||
padding: EdgeInsets.all(Grid.sm),
|
||||
child: Center(
|
||||
child: BuzzLoadingIndicator(
|
||||
size: 36,
|
||||
semanticLabel: 'Loading more search results',
|
||||
),
|
||||
),
|
||||
sliver: SliverMainAxisGroup(
|
||||
slivers: [
|
||||
if (showChannels && state.channelResults.isNotEmpty)
|
||||
_ChannelsSection(
|
||||
channels: state.channelResults,
|
||||
onResultSelected: recordResultSelection,
|
||||
),
|
||||
if (showPeople && state.userResults.isNotEmpty)
|
||||
_PeopleSection(
|
||||
users: state.userResults,
|
||||
onResultSelected: recordResultSelection,
|
||||
),
|
||||
if (showMessages && state.messageResults.isNotEmpty)
|
||||
_MessagesSection(
|
||||
hits: state.messageResults,
|
||||
currentPubkey: currentPubkey,
|
||||
onResultSelected: recordResultSelection,
|
||||
),
|
||||
if (state.isLoading)
|
||||
const SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(Grid.sm),
|
||||
child: Center(
|
||||
child: BuzzLoadingIndicator(
|
||||
size: 36,
|
||||
semanticLabel: 'Loading more search results',
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
@@ -472,57 +480,57 @@ class _ChannelsSection extends ConsumerWidget {
|
||||
});
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_SectionLabel(label: 'Channels'),
|
||||
for (final channel in channels)
|
||||
ListTile(
|
||||
key: ValueKey('search-channel-row-${channel.id}'),
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: Grid.gutter),
|
||||
leading: Icon(
|
||||
channelIcon(channel),
|
||||
key: ValueKey('search-channel-leading-${channel.id}'),
|
||||
size: 20,
|
||||
),
|
||||
title: Text(
|
||||
channel.name,
|
||||
key: ValueKey('search-channel-title-${channel.id}'),
|
||||
style: contentListTitleTextStyle,
|
||||
),
|
||||
subtitle: channel.isMember
|
||||
? Text(
|
||||
'${channel.memberCount} member${channel.memberCount == 1 ? '' : 's'}',
|
||||
style: contentListBodyTextStyle.copyWith(
|
||||
color: context.colors.onSurfaceVariant,
|
||||
),
|
||||
)
|
||||
: null,
|
||||
trailing: !channel.isMember && !channel.isDm
|
||||
? Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: Grid.half + 2,
|
||||
vertical: 3,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: context.colors.primary.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(Radii.sm),
|
||||
),
|
||||
child: Text(
|
||||
'Open',
|
||||
style: context.textTheme.labelSmall?.copyWith(
|
||||
color: context.colors.primary,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
)
|
||||
: null,
|
||||
onTap: () {
|
||||
onResultSelected();
|
||||
_openSearchChannel(context, channel);
|
||||
},
|
||||
return SliverList.builder(
|
||||
itemCount: channels.length + 1,
|
||||
itemBuilder: (context, index) {
|
||||
if (index == 0) return const _SectionLabel(label: 'Channels');
|
||||
final channel = channels[index - 1];
|
||||
return ListTile(
|
||||
key: ValueKey('search-channel-row-${channel.id}'),
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: Grid.gutter),
|
||||
leading: Icon(
|
||||
channelIcon(channel),
|
||||
key: ValueKey('search-channel-leading-${channel.id}'),
|
||||
size: 20,
|
||||
),
|
||||
],
|
||||
title: Text(
|
||||
channel.name,
|
||||
key: ValueKey('search-channel-title-${channel.id}'),
|
||||
style: contentListTitleTextStyle,
|
||||
),
|
||||
subtitle: channel.isMember
|
||||
? Text(
|
||||
'${channel.memberCount} member${channel.memberCount == 1 ? '' : 's'}',
|
||||
style: contentListBodyTextStyle.copyWith(
|
||||
color: context.colors.onSurfaceVariant,
|
||||
),
|
||||
)
|
||||
: null,
|
||||
trailing: !channel.isMember && !channel.isDm
|
||||
? Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: Grid.half + 2,
|
||||
vertical: 3,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: context.colors.primary.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(Radii.sm),
|
||||
),
|
||||
child: Text(
|
||||
'Open',
|
||||
style: context.textTheme.labelSmall?.copyWith(
|
||||
color: context.colors.primary,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
)
|
||||
: null,
|
||||
onTap: () {
|
||||
onResultSelected();
|
||||
_openSearchChannel(context, channel);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -545,46 +553,46 @@ class _PeopleSection extends ConsumerWidget {
|
||||
},
|
||||
fallbackNames: {for (final user in users) user.pubkey: user.label},
|
||||
);
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_SectionLabel(label: 'People'),
|
||||
for (final user in users)
|
||||
ListTile(
|
||||
key: ValueKey('search-person-row-${user.pubkey}'),
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: Grid.gutter),
|
||||
leading: AvatarImage(
|
||||
key: ValueKey('search-person-leading-${user.pubkey}'),
|
||||
imageUrl: user.avatarUrl,
|
||||
radius: 20,
|
||||
fallback: Text(user.initial),
|
||||
isAgent: user.isAgent,
|
||||
),
|
||||
title: Text(
|
||||
names.labelFor(user.pubkey),
|
||||
key: ValueKey('search-person-title-${user.pubkey}'),
|
||||
style: contentListTitleTextStyle,
|
||||
),
|
||||
subtitle: Text(
|
||||
user.secondaryLabel,
|
||||
style: contentListBodyTextStyle.copyWith(
|
||||
color: context.colors.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
onTap: () async {
|
||||
onResultSelected();
|
||||
final channel = await ref
|
||||
.read(channelActionsProvider)
|
||||
.openDm(pubkeys: [user.pubkey]);
|
||||
if (!context.mounted) return;
|
||||
await Navigator.of(context).push(
|
||||
MaterialPageRoute<void>(
|
||||
builder: (_) => ChannelDetailPage(channel: channel),
|
||||
),
|
||||
);
|
||||
},
|
||||
return SliverList.builder(
|
||||
itemCount: users.length + 1,
|
||||
itemBuilder: (context, index) {
|
||||
if (index == 0) return const _SectionLabel(label: 'People');
|
||||
final user = users[index - 1];
|
||||
return ListTile(
|
||||
key: ValueKey('search-person-row-${user.pubkey}'),
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: Grid.gutter),
|
||||
leading: AvatarImage(
|
||||
key: ValueKey('search-person-leading-${user.pubkey}'),
|
||||
imageUrl: user.avatarUrl,
|
||||
radius: 20,
|
||||
fallback: Text(user.initial),
|
||||
isAgent: user.isAgent,
|
||||
),
|
||||
],
|
||||
title: Text(
|
||||
names.labelFor(user.pubkey),
|
||||
key: ValueKey('search-person-title-${user.pubkey}'),
|
||||
style: contentListTitleTextStyle,
|
||||
),
|
||||
subtitle: Text(
|
||||
user.secondaryLabel,
|
||||
style: contentListBodyTextStyle.copyWith(
|
||||
color: context.colors.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
onTap: () async {
|
||||
onResultSelected();
|
||||
final channel = await ref
|
||||
.read(channelActionsProvider)
|
||||
.openDm(pubkeys: [user.pubkey]);
|
||||
if (!context.mounted) return;
|
||||
await Navigator.of(context).push(
|
||||
MaterialPageRoute<void>(
|
||||
builder: (_) => ChannelDetailPage(channel: channel),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -616,20 +624,20 @@ class _MessagesSection extends HookConsumerWidget {
|
||||
return null;
|
||||
}, [preloadPubkeysKey]);
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_SectionLabel(label: 'Messages'),
|
||||
for (final hit in hits)
|
||||
_MessageTile(
|
||||
hit: hit,
|
||||
authorProfile: profiles[hit.pubkey.toLowerCase()],
|
||||
userCache: profiles,
|
||||
channel: channels.where((c) => c.id == hit.channelId).firstOrNull,
|
||||
currentPubkey: currentPubkey,
|
||||
onResultSelected: onResultSelected,
|
||||
),
|
||||
],
|
||||
return SliverList.builder(
|
||||
itemCount: hits.length + 1,
|
||||
itemBuilder: (context, index) {
|
||||
if (index == 0) return const _SectionLabel(label: 'Messages');
|
||||
final hit = hits[index - 1];
|
||||
return _MessageTile(
|
||||
hit: hit,
|
||||
authorProfile: profiles[hit.pubkey.toLowerCase()],
|
||||
userCache: profiles,
|
||||
channel: channels.where((c) => c.id == hit.channelId).firstOrNull,
|
||||
currentPubkey: currentPubkey,
|
||||
onResultSelected: onResultSelected,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,16 @@
|
||||
part of '../search_page.dart';
|
||||
|
||||
// Two-line search snippets must not parse an entire document. Long messages
|
||||
// use literal text so cutting a code fence or link cannot create a new embed.
|
||||
const _richSearchPreviewLimit = 512;
|
||||
|
||||
String _literalSearchPreview(String content) {
|
||||
var end = _richSearchPreviewLimit;
|
||||
final last = content.codeUnitAt(end - 1);
|
||||
if (last >= 0xD800 && last <= 0xDBFF) end--;
|
||||
return '${content.substring(0, end)}…';
|
||||
}
|
||||
|
||||
class _MessageTile extends ConsumerWidget {
|
||||
final SearchHit hit;
|
||||
final UserProfile? authorProfile;
|
||||
@@ -19,7 +30,9 @@ class _MessageTile extends ConsumerWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final mentionedPubkeys = mentionedPubkeysFromTags(hit.tags);
|
||||
final mentionedPubkeys = hit.content.length > _richSearchPreviewLimit
|
||||
? <String>{}
|
||||
: mentionedPubkeysFromTags(hit.tags);
|
||||
// A hit is labelled within its channel, as it would be there.
|
||||
final hitChannelId = channel?.id ?? hit.channelId;
|
||||
final labelPubkeys = {hit.pubkey.toLowerCase(), ...mentionedPubkeys};
|
||||
@@ -40,28 +53,6 @@ class _MessageTile extends ConsumerWidget {
|
||||
final channelName = hit.channelName?.trim().replaceFirst(RegExp(r'^#'), '');
|
||||
final hasChannelName = channelName != null && channelName.isNotEmpty;
|
||||
final isDm = channel?.isDm ?? false;
|
||||
final profileMentionNames = {
|
||||
for (final pubkey in mentionedPubkeysFromTags(hit.tags))
|
||||
if (userCache[pubkey]?.displayName?.trim().isNotEmpty == true)
|
||||
pubkey: userCache[pubkey]!.displayName!.trim(),
|
||||
};
|
||||
final mentionPubkeys = mentionedPubkeysFromTags(hit.tags);
|
||||
final knownAgentPubkeys = channel == null
|
||||
? ref.watch(knownAgentPubkeysProvider)
|
||||
: ref.watch(agentMentionPubkeysProvider(channel!.id));
|
||||
final agentMentionPubkeys = agentPubkeysWithProfileOwners(
|
||||
knownAgentPubkeys: knownAgentPubkeys,
|
||||
profileOwnedAgentPubkeys: [
|
||||
for (final profile in userCache.values)
|
||||
if (profile.ownerPubkey != null) profile.pubkey,
|
||||
],
|
||||
);
|
||||
final mentionNames = mentionNamesWithDirectoryLabels(
|
||||
mentionPubkeys: mentionPubkeys,
|
||||
profileMentionNames: profileMentionNames,
|
||||
directoryDisplayNames: ref.watch(agentDirectoryDisplayNamesProvider),
|
||||
agentMentionPubkeys: agentMentionPubkeys,
|
||||
);
|
||||
|
||||
return ListTile(
|
||||
key: ValueKey('search-message-row-${hit.eventId}'),
|
||||
@@ -130,18 +121,7 @@ class _MessageTile extends ConsumerWidget {
|
||||
],
|
||||
),
|
||||
const SizedBox(height: Grid.half),
|
||||
MessageContent(
|
||||
key: ValueKey('search-message-body-${hit.eventId}'),
|
||||
content: hit.content,
|
||||
mentionNames: mentionNames,
|
||||
mentionLabels: labels,
|
||||
agentMentionPubkeys: agentMentionPubkeys,
|
||||
tags: hit.tags,
|
||||
maxLines: 2,
|
||||
baseStyle: activityPreviewTextStyle.copyWith(
|
||||
color: context.colors.onSurface,
|
||||
),
|
||||
),
|
||||
_buildPreview(context, ref, labels),
|
||||
],
|
||||
),
|
||||
onTap: () {
|
||||
@@ -151,6 +131,59 @@ class _MessageTile extends ConsumerWidget {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPreview(
|
||||
BuildContext context,
|
||||
WidgetRef ref,
|
||||
Map<String, String> labels,
|
||||
) {
|
||||
if (hit.content.length > _richSearchPreviewLimit) {
|
||||
return Text(
|
||||
_literalSearchPreview(hit.content),
|
||||
key: ValueKey('search-message-body-${hit.eventId}'),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: activityPreviewTextStyle.copyWith(
|
||||
color: context.colors.onSurface,
|
||||
),
|
||||
);
|
||||
}
|
||||
final profileMentionNames = {
|
||||
for (final pubkey in mentionedPubkeysFromTags(hit.tags))
|
||||
if (userCache[pubkey]?.displayName?.trim().isNotEmpty == true)
|
||||
pubkey: userCache[pubkey]!.displayName!.trim(),
|
||||
};
|
||||
final mentionPubkeys = mentionedPubkeysFromTags(hit.tags);
|
||||
final knownAgentPubkeys = channel == null
|
||||
? ref.watch(knownAgentPubkeysProvider)
|
||||
: ref.watch(agentMentionPubkeysProvider(channel!.id));
|
||||
final agentMentionPubkeys = agentPubkeysWithProfileOwners(
|
||||
knownAgentPubkeys: knownAgentPubkeys,
|
||||
profileOwnedAgentPubkeys: [
|
||||
for (final profile in userCache.values)
|
||||
if (profile.ownerPubkey != null) profile.pubkey,
|
||||
],
|
||||
);
|
||||
final mentionNames = mentionNamesWithDirectoryLabels(
|
||||
mentionPubkeys: mentionPubkeys,
|
||||
profileMentionNames: profileMentionNames,
|
||||
directoryDisplayNames: ref.watch(agentDirectoryDisplayNamesProvider),
|
||||
agentMentionPubkeys: agentMentionPubkeys,
|
||||
);
|
||||
|
||||
return MessageContent(
|
||||
key: ValueKey('search-message-body-${hit.eventId}'),
|
||||
content: hit.content,
|
||||
mentionNames: mentionNames,
|
||||
mentionLabels: labels,
|
||||
agentMentionPubkeys: agentMentionPubkeys,
|
||||
tags: hit.tags,
|
||||
maxLines: 2,
|
||||
baseStyle: activityPreviewTextStyle.copyWith(
|
||||
color: context.colors.onSurface,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _navigateToHit(
|
||||
BuildContext context,
|
||||
WidgetRef ref,
|
||||
|
||||
257
mobile/test/features/search/search_page_lazy_test.dart
Normal file
257
mobile/test/features/search/search_page_lazy_test.dart
Normal file
@@ -0,0 +1,257 @@
|
||||
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/channels/message_content.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/mentions/agent_identity_provider.dart';
|
||||
import 'package:buzz/shared/profile/user_cache_provider.dart';
|
||||
import 'package:buzz/shared/profile/user_profile.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import '../../helpers/widget_helpers.dart';
|
||||
|
||||
void main() {
|
||||
testWidgets(
|
||||
'message results build only near the viewport and remain scrollable',
|
||||
(tester) async {
|
||||
tester.view.physicalSize = const Size(390, 600);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.reset);
|
||||
final state = SearchState(
|
||||
query: 'result',
|
||||
messageResults: [
|
||||
for (var index = 0; index < 20; index++)
|
||||
SearchHit(
|
||||
eventId: 'message-$index',
|
||||
content: 'Result **$index** with a preview',
|
||||
kind: 9,
|
||||
pubkey: 'author-pubkey',
|
||||
createdAt: 1,
|
||||
score: 1,
|
||||
),
|
||||
],
|
||||
);
|
||||
await tester.pumpWidget(
|
||||
WidgetHelpers.testable(
|
||||
overrides: [
|
||||
searchProvider.overrideWith(() => _FakeSearchNotifier(state)),
|
||||
recentSearchesProvider.overrideWith(
|
||||
() => _FakeRecentSearchesNotifier(const []),
|
||||
),
|
||||
profileProvider.overrideWith(() => _FakeProfileNotifier()),
|
||||
channelsProvider.overrideWith(() => _FakeChannelsNotifier()),
|
||||
userCacheProvider.overrideWith(
|
||||
() => _FakeUserCacheNotifier(
|
||||
const UserProfile(pubkey: 'author-pubkey'),
|
||||
),
|
||||
),
|
||||
knownAgentPubkeysProvider.overrideWith((ref) => const {}),
|
||||
agentDirectoryDisplayNamesProvider.overrideWith((ref) => const {}),
|
||||
],
|
||||
child: const SearchPage(),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byType(MessageContent).evaluate().length, lessThan(10));
|
||||
final last = find.byKey(const ValueKey('search-message-row-message-19'));
|
||||
expect(last, findsNothing);
|
||||
await tester.scrollUntilVisible(
|
||||
last,
|
||||
400,
|
||||
scrollable: find.descendant(
|
||||
of: find.byKey(const Key('search-results-list')),
|
||||
matching: find.byType(Scrollable),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
expect(last.hitTestable(), findsOneWidget);
|
||||
expect(find.byType(MessageContent).evaluate().length, lessThan(10));
|
||||
expect(tester.takeException(), isNull);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets(
|
||||
'long message previews bound text without parsing partial markdown',
|
||||
(tester) async {
|
||||
tester.view.physicalSize = const Size(390, 600);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.reset);
|
||||
final state = SearchState(
|
||||
query: 'result',
|
||||
messageResults: [
|
||||
SearchHit(
|
||||
eventId: 'long',
|
||||
content:
|
||||
'${'x' * 511}😀${'**[long](https://example.com)**' * 4000}',
|
||||
kind: 9,
|
||||
pubkey: 'author-pubkey',
|
||||
createdAt: 1,
|
||||
score: 1,
|
||||
),
|
||||
],
|
||||
);
|
||||
await tester.pumpWidget(
|
||||
WidgetHelpers.testable(
|
||||
overrides: [
|
||||
searchProvider.overrideWith(() => _FakeSearchNotifier(state)),
|
||||
recentSearchesProvider.overrideWith(
|
||||
() => _FakeRecentSearchesNotifier(const []),
|
||||
),
|
||||
profileProvider.overrideWith(() => _FakeProfileNotifier()),
|
||||
channelsProvider.overrideWith(() => _FakeChannelsNotifier()),
|
||||
userCacheProvider.overrideWith(
|
||||
() => _FakeUserCacheNotifier(
|
||||
const UserProfile(pubkey: 'author-pubkey'),
|
||||
),
|
||||
),
|
||||
knownAgentPubkeysProvider.overrideWith((ref) => const {}),
|
||||
agentDirectoryDisplayNamesProvider.overrideWith((ref) => const {}),
|
||||
],
|
||||
child: const SearchPage(),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byType(MessageContent), findsNothing);
|
||||
final preview = tester.widget<Text>(
|
||||
find.byKey(const ValueKey('search-message-body-long')),
|
||||
);
|
||||
expect(preview.data, '${'x' * 511}…');
|
||||
expect(preview.maxLines, 2);
|
||||
expect(preview.overflow, TextOverflow.ellipsis);
|
||||
expect(tester.takeException(), isNull);
|
||||
},
|
||||
);
|
||||
|
||||
for (final category in ['Channels', 'People']) {
|
||||
testWidgets('$category results are lazy and reachable after filtering', (
|
||||
tester,
|
||||
) async {
|
||||
tester.view.physicalSize = const Size(390, 600);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.reset);
|
||||
final state = SearchState(
|
||||
query: 'result',
|
||||
channelResults: [
|
||||
for (var index = 0; index < 30; index++)
|
||||
Channel(
|
||||
id: 'channel-$index',
|
||||
name: 'Result $index',
|
||||
channelType: 'stream',
|
||||
visibility: 'open',
|
||||
description: '',
|
||||
createdBy: 'test',
|
||||
createdAt: DateTime(2025),
|
||||
memberCount: 1,
|
||||
),
|
||||
],
|
||||
userResults: [
|
||||
for (var index = 0; index < 30; index++)
|
||||
DirectoryUser(
|
||||
pubkey: 'person-$index',
|
||||
displayName: 'Person $index',
|
||||
),
|
||||
],
|
||||
);
|
||||
await tester.pumpWidget(
|
||||
WidgetHelpers.testable(
|
||||
overrides: [
|
||||
searchProvider.overrideWith(() => _FakeSearchNotifier(state)),
|
||||
recentSearchesProvider.overrideWith(
|
||||
() => _FakeRecentSearchesNotifier(const []),
|
||||
),
|
||||
profileProvider.overrideWith(() => _FakeProfileNotifier()),
|
||||
channelsProvider.overrideWith(() => _FakeChannelsNotifier()),
|
||||
],
|
||||
child: const SearchPage(),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text(category).first);
|
||||
await tester.pumpAndSettle();
|
||||
final key = category == 'Channels'
|
||||
? 'search-channel-row-channel-29'
|
||||
: 'search-person-row-person-29';
|
||||
final last = find.byKey(ValueKey(key));
|
||||
expect(last, findsNothing);
|
||||
await tester.scrollUntilVisible(
|
||||
last,
|
||||
400,
|
||||
scrollable: find.descendant(
|
||||
of: find.byKey(const Key('search-results-list')),
|
||||
matching: find.byType(Scrollable),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
expect(last.hitTestable(), findsOneWidget);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
class _FakeSearchNotifier extends SearchNotifier {
|
||||
_FakeSearchNotifier(this.initialState);
|
||||
|
||||
final SearchState initialState;
|
||||
|
||||
@override
|
||||
SearchState build() => initialState;
|
||||
|
||||
@override
|
||||
void search(String query) {
|
||||
state = SearchState(query: query.trim());
|
||||
}
|
||||
|
||||
@override
|
||||
void clear() {
|
||||
state = const SearchState.initial();
|
||||
}
|
||||
}
|
||||
|
||||
class _FakeRecentSearchesNotifier extends RecentSearchesNotifier {
|
||||
_FakeRecentSearchesNotifier(this.initialSearches);
|
||||
|
||||
final List<String> initialSearches;
|
||||
List<String> get searches => state;
|
||||
|
||||
@override
|
||||
List<String> build() => initialSearches;
|
||||
|
||||
@override
|
||||
void record(String query) {
|
||||
final trimmed = query.trim();
|
||||
if (trimmed.isEmpty) return;
|
||||
state = [
|
||||
trimmed,
|
||||
...state.where((item) => item.toLowerCase() != trimmed.toLowerCase()),
|
||||
];
|
||||
}
|
||||
|
||||
@override
|
||||
void clear() {
|
||||
state = const [];
|
||||
}
|
||||
}
|
||||
|
||||
class _FakeProfileNotifier extends ProfileNotifier {
|
||||
@override
|
||||
Future<UserProfile?> build() async =>
|
||||
const UserProfile(pubkey: 'test', displayName: 'Test');
|
||||
}
|
||||
|
||||
class _FakeChannelsNotifier extends ChannelsNotifier {
|
||||
@override
|
||||
Future<List<Channel>> build() async => const [];
|
||||
}
|
||||
|
||||
class _FakeUserCacheNotifier extends UserCacheNotifier {
|
||||
_FakeUserCacheNotifier(this.profile);
|
||||
|
||||
final UserProfile profile;
|
||||
|
||||
@override
|
||||
Map<String, UserProfile> build() => {profile.pubkey: profile};
|
||||
}
|
||||
@@ -444,10 +444,13 @@ void main() {
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final results = tester.widget<ListView>(
|
||||
find.byKey(const Key('search-results-list')),
|
||||
final resultsPadding = tester.widget<SliverPadding>(
|
||||
find.descendant(
|
||||
of: find.byKey(const Key('search-results-list')),
|
||||
matching: find.byType(SliverPadding),
|
||||
),
|
||||
);
|
||||
final padding = results.padding! as EdgeInsets;
|
||||
final padding = resultsPadding.padding as EdgeInsets;
|
||||
|
||||
expect(padding.bottom, Grid.xl + footerClearance + keyboardInset);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user