mirror of
https://github.com/block/buzz.git
synced 2026-11-14 05:12:27 +00:00
### Summary Inbox rows and search results show each message as a 2-line preview. That preview was a capped `MessageContent`, so gpt_markdown parsed and laid out the whole source (syntax-highlighted code blocks and measured tables included), even though only two lines are painted. A native trace on an iPhone 12 mini attributed a 102 ms inbox-open frame mostly to this work. Previews now skip Markdown entirely. `messagePreviewSource` takes only the leading lines a preview can show, and at most the first 4,000 characters of a single long line, so link normalization, mention binding and emoji lookup never scan the rest of a long message. `messagePreviewPieces` is then a linear pass over those lines, similar to desktop's `getMarkdownPreviewText`. It drops block syntax (headings, quotes, list markers, rules, table dividers, code fences) and unwraps inline emphasis and `<u>`, honoring backslash escapes and keeping inline (including multi-backtick) and fenced code as literal text. It also replaces links with their label. The new `MessagePreview` widget renders the result as a single `Text.rich` with `maxLines` and an ellipsis. Tokens keep the look they have in the thread view: - A bound mention is the same `MessageMentionPill` chip, with the bot icon for agents. An ambiguous mention stays as text. - `#channel` tokens and `buzz://channel/...` links are the same channel token chip. - A `buzz://message`, repo, PR or issue link that shows its own URL previews with the thread view's short label, such as `general · abcdef01`. Both use the shared `buzzLinkLabel` helper. - Custom emoji render as inline images. Only shortcodes the message names are looked up in the palette. - Media links show as "Image", "Video" or "Audio". Chips in previews are not tappable, and tapping the row opens the message. Bold, italic, inline code and link styling are no longer shown in previews. The timeline and thread view are unchanged, and so is the profile status line, which still uses `MessageContent`. This replaces this PR's earlier approach of cutting the Markdown source before parsing. That approach needed delimiter-balance heuristics, and a security review found a real case (`**` inside a link label) where the heuristic misfired. ### Validation - `preview_text_test.dart` is a table test of the parser: block syntax, emphasis that must and must not unwrap (`snake_case`, a lone `*`), inline and fenced code, unpaired backticks, links, tables, the line limit and the long-line cap. It also covers mentions (bound, ambiguous, inside emphasis, link labels and code), custom emoji (including adjacent `🅰️b:`), channel tokens and links, message and repo link labels, and media. The long-line row also asserts that `messagePreviewSource` returns a bounded prefix. - `message_preview_test.dart` checks the widget: no `GptMarkdown`, one capped and ellipsized paragraph, mention chip, custom emoji image, channel chip, media label and empty content. - Existing inbox and search tests updated to find `MessagePreview`, including the agent-chip test in search. - `flutter analyze` and the full mobile test suite pass at8c721ba3a(with `--dart-define=BUZZ_PUSH_GATEWAY_URL=https://push.example`, as `just mobile-test` runs it). - Device A/B on an iPhone 12 mini, using Profile builds against the real inbox on the internal relay. Each run was a cold launch followed by 6 Inbox opens, in the order main, fix, main, fix: | | maineda26b942| this PR6661d594a| |---|---|---| | First open, worst frame (run 1 / run 2) | 40.4 / 34.9 ms | 22.4 / 23.2 ms | | First open, build total (run 1 / run 2) | 77.8 / 91.1 ms | 38.5 / 52.3 ms | | All opens, median worst frame | 27.2 ms | 23.3 ms | | All opens, median build total | 43.4 ms | 26.6 ms | First open drops about 40% in build time and about 14 ms in worst frame. On warm opens, build time also drops about 40%, but the worst frame barely moves, so what remains in those frames is not the preview parse. ## Codex review status Codex reviewed this PR over four rounds. Every finding about cost or correctness is fixed, and those threads are resolved. Later rounds turned up narrower and narrower preview edge cases, so the author decided to stop iterating on Codex findings. Any Codex thread still open, or any finding Codex posts afterf538853ed, is a deliberately accepted preview limitation and doesn't block merging. Example: a [code span that continues across a line break](https://github.com/block/buzz/pull/8675#discussion_r4415196032) can preview a mention as a chip. The full message view is unaffected. Only the one- or two-line inbox and search previews can differ from it. buzz-review-completed 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Signed-off-by: Tom Brow <tomb@block.xyz> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
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 MessagePreview(
|
|
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,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|