Files
buzz/mobile/lib/features/search/search_page/message_tile.dart
Tom Brow e048f5ac78 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>
2026-10-18 12:41:06 -07:00

237 lines
7.7 KiB
Dart

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;
final Map<String, UserProfile> userCache;
final Channel? channel;
final String? currentPubkey;
final VoidCallback onResultSelected;
const _MessageTile({
required this.hit,
required this.authorProfile,
required this.userCache,
required this.channel,
required this.currentPubkey,
required this.onResultSelected,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
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};
final Map<String, String> labels;
if (hitChannelId == null) {
final names = watchIdentityNames(ref, labelPubkeys);
labels = {
for (final pubkey in labelPubkeys) pubkey: names.labelFor(pubkey),
};
} else {
labels = watchChannelIdentityLabels(ref, hitChannelId, labelPubkeys);
}
final authorName =
labels[hit.pubkey.toLowerCase()] ??
authorProfile?.label ??
shortPubkey(hit.pubkey);
final timeAgo = relativeTime(hit.createdAt);
final channelName = hit.channelName?.trim().replaceFirst(RegExp(r'^#'), '');
final hasChannelName = channelName != null && channelName.isNotEmpty;
final isDm = channel?.isDm ?? false;
return ListTile(
key: ValueKey('search-message-row-${hit.eventId}'),
contentPadding: const EdgeInsets.symmetric(horizontal: Grid.gutter),
titleAlignment: ListTileTitleAlignment.top,
horizontalTitleGap: messageAvatarContentGap,
leading: SmallAvatar(
key: ValueKey('search-message-avatar-${hit.eventId}'),
pubkey: hit.pubkey,
userCache: userCache,
size: compactMessageAvatarSize,
),
title: MessageAuthorMeta(
displayName: authorName,
username: messageUsernameLabel(authorProfile),
timestamp: timeAgo,
nameColor: context.colors.onSurface,
metadataColor: context.colors.onSurfaceVariant,
displayNameKey: ValueKey('search-message-author-${hit.eventId}'),
usernameKey: ValueKey('search-message-username-${hit.eventId}'),
timestampKey: ValueKey('search-message-timestamp-${hit.eventId}'),
),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 2),
Row(
key: ValueKey('search-message-context-${hit.eventId}'),
children: [
Flexible(
child: Text(
isDm
? 'Direct message'
: hasChannelName
? 'Message in'
: 'Message',
style: activityContextTextStyle.copyWith(
color: context.colors.onSurfaceVariant,
),
overflow: TextOverflow.ellipsis,
),
),
if (!isDm && hasChannelName) ...[
const SizedBox(width: Grid.half),
Flexible(
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: Grid.half + Grid.quarter,
vertical: Grid.quarter / 2,
),
decoration: BoxDecoration(
color: context.colors.surfaceContainerHighest,
borderRadius: BorderRadius.circular(Radii.xs),
),
child: Text(
'#$channelName',
key: ValueKey('search-message-channel-${hit.eventId}'),
style: activityContextTextStyle.copyWith(
color: context.colors.onSurfaceVariant,
),
overflow: TextOverflow.ellipsis,
),
),
),
],
],
),
const SizedBox(height: Grid.half),
_buildPreview(context, ref, labels),
],
),
onTap: () {
onResultSelected();
_navigateToHit(context, ref, hit, channel);
},
);
}
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,
SearchHit hit,
Channel? channel,
) {
if (channel == null) return;
if (hit.kind == 45001) {
Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => ForumThreadPage(
channelId: channel.id,
postEventId: hit.eventId,
currentPubkey: currentPubkey,
isMember: channel.isMember,
isArchived: channel.isArchived,
),
),
);
} else {
_openSearchChannel(context, channel);
}
}
}
class _SectionLabel extends StatelessWidget {
final String label;
const _SectionLabel({required this.label});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.fromLTRB(
Grid.gutter,
Grid.xs,
Grid.gutter,
Grid.half,
),
child: Text(
label,
key: ValueKey('search-section-${label.toLowerCase()}'),
style: activityContextTextStyle.copyWith(
color: context.colors.onSurfaceVariant,
),
),
);
}
}