mirror of
https://github.com/block/buzz.git
synced 2026-11-07 18:01:06 +00:00
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>
237 lines
7.7 KiB
Dart
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,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|