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 |
| --- | --- |
|
![Before](https://raw.githubusercontent.com/block/buzz/5840caf6cc3e35ce0ecdba68cfbcb10506f3eb23/pr-8418--search-long-before.png)
|
![After](https://raw.githubusercontent.com/block/buzz/5840caf6cc3e35ce0ecdba68cfbcb10506f3eb23/pr-8418--search-long-preview.png)
|

---------

Signed-off-by: Tom Brow <tomb@block.xyz>
This commit is contained in:
Tom Brow
2026-10-18 12:41:06 -07:00
committed by GitHub
parent 27b9c827fc
commit e048f5ac78
4 changed files with 474 additions and 173 deletions

View File

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

View File

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

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

View File

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