mirror of
https://github.com/block/buzz.git
synced 2026-11-04 16:31:38 +00:00
Mobile showed Markdown backslash escapes literally. A message linking a channel as `[\#buzz-mobile](buzz://channel/…)` rendered as `\#buzz-mobile`, and `\[text\]` rendered as LaTeX math. gpt_markdown has no backslash escapes. Outside code, each escaped ASCII punctuation character is now held as a stand-in (an invisible word joiner plus the character's fullwidth form) before parsing, so no other syntax (links, emphasis, mentions, channel tokens, math) matches it. A new inline component and the link and image builders restore the original character. The inbox/search preview builder already handled escapes this way; its helpers moved to a shared `markdown_escapes.dart`. Two edge cases found in review are covered: shielding runs after trailing-gallery extraction so carousel URLs and labels stay as authored, and a bare link no longer takes a trailing backslash into its destination. Codex review added: escaped backticks stay literal instead of opening code, authored word joiners are not mistaken for stand-ins, Buzz URL punctuation peeling keeps a trailing backslash, gallery semantic labels drop escapes, the trailing-backslash peel is linear and reruns after delimiter stripping, an escaped `\<url>` is not treated as an autolink, `~~~` fences are skipped like backtick fences, an autolink containing `\` keeps it in both label and destination, and the stand-ins use fullwidth characters so the one gpt_markdown fallback that shows raw text (multi-line strikethrough) still reads right. Later review: fence detection looks back only over blanks and `>` markers, so it stays linear on long lines (security review) and treats an unclosed fence inside a quote or list as code, like the renderer (Carl). Behavior change: `\(x\)` and `\[x\]` now show as `(x)` and `[x]` instead of math, per CommonMark. Desktop (buzz-app) doesn't render math either. `$…$` math is unchanged. ### Validation - Added tests: a `MessageContent` table covering escapes in prose, bold, link labels (HTTP and `buzz://channel`), fenced and inline code (left literal), a bare link ending in `\`, and escaped backticks, authored word joiners and fullwidth characters, a `~~~` fence, a multi-line strikethrough, `<https://example.com/a\>`, a Buzz URL ending in `.\`, an emphasized bare URL ending in `\*`, an escaped `\<url>`, and a trailing image gallery with escaped labels (including its semantics label), in plain and bubble layouts. Each row also asserts no stand-in reaches visible text or semantics. Reverting any one of these fixes fails it. - `flutter analyze` is clean and the full mobile suite passes, except for 11 `notification_toggle_test.dart` tests. Those fail locally on the Studio at the CI-greena3964abas well, so they are environmental and unrelated to this change. A 256 KiB regression test covers the linear fence scan, and it fails on the earlier code. - iPhone 17 Pro simulator (iOS 27) signed in to the internal community: loganj's original message now shows `#buzz-github-prs` and `#buzz-mobile`. - Full-app run on [a3964ab](a3964ab08c): debug build on a fresh iPhone 17 Pro simulator (iOS 27), signed in to the internal community with live data. Covered the #buzz-github-prs timeline (long linked lists, bare URLs, mentions, reactions, thread summaries), a thread view, Inbox previews (the shared preview helpers), a DM with a bare link and a channel link, and search results. Everything rendered as before apart from the fixed escapes, with no stray stand-in characters. Limitations: debug build, iOS only, no gallery or code-block messages were in the visited history (the widget tests cover those), and a 165-second recording was taken but GitHub can't take it through our upload script. Two quirks seen are pre-existing and unchanged here: a bare link absorbs a trailing comma, and search shows raw Markdown for messages over 512 characters. <details><summary>Full-app screenshots</summary> | Channel | Channel (scrolled) | Thread | | --- | --- | --- | |  |  |  | | **Inbox** | **DM** | **Search** | |  |  |  | </details> ### Before / after Before (production build, reported):  After (this branch, same message):  🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Signed-off-by: Tom Brow <tomb@squareup.com> Co-authored-by: Tom Brow <tomb@squareup.com> Co-authored-by: studio <dc2fc481343672df31a689840c09299972e704dac592e0da6f18486646a7ee3e@buzz.block.builderlab.xyz> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
185 lines
6.3 KiB
Dart
185 lines
6.3 KiB
Dart
part of '../message_content.dart';
|
|
|
|
List<MarkdownComponent> _useMessageInlineComponents({
|
|
required String content,
|
|
required String finalContent,
|
|
required Map<String, String> mentionNames,
|
|
required Map<String, String> mentionLabels,
|
|
required Map<String, Set<String>> bindings,
|
|
required Set<String> agentPubkeys,
|
|
required Map<String, String> channelNames,
|
|
required List<CustomEmoji> customEmoji,
|
|
required double emojiSize,
|
|
required List<List<String>> tags,
|
|
required bool hasMediaReply,
|
|
required bool hasMediaMore,
|
|
required void Function(String)? onMentionTap,
|
|
required void Function(String) onChannelTap,
|
|
}) {
|
|
// Parsed spans retain these callbacks. Forward to the current handlers without
|
|
// invalidating the parser for a new closure from an otherwise unchanged row.
|
|
final mentionHandler = useRef(onMentionTap)..value = onMentionTap;
|
|
final channelHandler = useRef(onChannelTap)..value = onChannelTap;
|
|
final mentionTap = useMemoized(
|
|
() =>
|
|
(String pubkey) => mentionHandler.value?.call(pubkey),
|
|
const [],
|
|
);
|
|
final channelTap = useMemoized(
|
|
() =>
|
|
(String channelId) => channelHandler.value(channelId),
|
|
const [],
|
|
);
|
|
final inputs = _InlineComponentInputs(
|
|
content: content,
|
|
finalContent: finalContent,
|
|
mentionNames: mentionNames,
|
|
mentionLabels: mentionLabels,
|
|
bindings: bindings,
|
|
agentPubkeys: agentPubkeys,
|
|
channelNames: channelNames,
|
|
customEmoji: customEmoji,
|
|
emojiSize: emojiSize,
|
|
tags: tags,
|
|
hasMediaReply: hasMediaReply,
|
|
hasMediaMore: hasMediaMore,
|
|
hasMentionHandler: onMentionTap != null,
|
|
);
|
|
|
|
// gpt_markdown compares components by identity. Recreating them on every
|
|
// parent rebuild reparses unchanged message bodies on the UI isolate.
|
|
return useMemoized(
|
|
() => [
|
|
_MentionMd(
|
|
mentionNames: inputs.mentionNames,
|
|
mentionLabels: inputs.mentionLabels,
|
|
bindings: inputs.bindings,
|
|
displayLabels: {
|
|
for (final range in mentionOccurrences(content, bindings.keys))
|
|
range.label: content.substring(range.start + 1, range.end),
|
|
},
|
|
agentMentionPubkeys: inputs.agentPubkeys,
|
|
onMentionTap: inputs.hasMentionHandler ? mentionTap : null,
|
|
),
|
|
CustomEmojiMd(inputs.customEmoji, content: finalContent, size: emojiSize),
|
|
_ChannelLinkMd(
|
|
channelNames: inputs.channelNames,
|
|
onChannelTap: channelTap,
|
|
),
|
|
_MarkdownLabelEntityMd(),
|
|
_MarkdownEscapeMd(),
|
|
for (final component in MarkdownComponent.inlineComponents)
|
|
component is ATagMd ? _MessageLinkMd() : component,
|
|
],
|
|
[inputs],
|
|
);
|
|
}
|
|
|
|
class _InlineComponentInputs {
|
|
final String content;
|
|
final String finalContent;
|
|
final Map<String, String> mentionNames;
|
|
final Map<String, String> mentionLabels;
|
|
final Map<String, Set<String>> bindings;
|
|
final Set<String> agentPubkeys;
|
|
final Map<String, String> channelNames;
|
|
final List<CustomEmoji> customEmoji;
|
|
final double emojiSize;
|
|
final bool hasMentionHandler;
|
|
final List<List<String>> tags;
|
|
final bool hasMediaReply;
|
|
final bool hasMediaMore;
|
|
|
|
_InlineComponentInputs({
|
|
required this.content,
|
|
required this.finalContent,
|
|
required Map<String, String> mentionNames,
|
|
required Map<String, String> mentionLabels,
|
|
required Map<String, Set<String>> bindings,
|
|
required Set<String> agentPubkeys,
|
|
required Map<String, String> channelNames,
|
|
required List<CustomEmoji> customEmoji,
|
|
required this.emojiSize,
|
|
required this.hasMentionHandler,
|
|
required List<List<String>> tags,
|
|
required this.hasMediaReply,
|
|
required this.hasMediaMore,
|
|
}) : tags = List.unmodifiable(
|
|
tags.map((tag) => List<String>.unmodifiable(tag)),
|
|
),
|
|
mentionNames = Map.unmodifiable(mentionNames),
|
|
mentionLabels = Map.unmodifiable(mentionLabels),
|
|
bindings = Map.unmodifiable({
|
|
for (final entry in bindings.entries)
|
|
entry.key: Set<String>.unmodifiable(entry.value),
|
|
}),
|
|
agentPubkeys = Set.unmodifiable(agentPubkeys),
|
|
channelNames = Map.unmodifiable(channelNames),
|
|
customEmoji = List.unmodifiable(customEmoji);
|
|
|
|
@override
|
|
bool operator ==(Object other) =>
|
|
other is _InlineComponentInputs &&
|
|
content == other.content &&
|
|
finalContent == other.finalContent &&
|
|
emojiSize == other.emojiSize &&
|
|
hasMentionHandler == other.hasMentionHandler &&
|
|
hasMediaReply == other.hasMediaReply &&
|
|
hasMediaMore == other.hasMediaMore &&
|
|
tags.length == other.tags.length &&
|
|
tags.indexed.every(
|
|
(entry) => listEquals(entry.$2, other.tags[entry.$1]),
|
|
) &&
|
|
mapEquals(mentionNames, other.mentionNames) &&
|
|
mapEquals(mentionLabels, other.mentionLabels) &&
|
|
mapEquals(channelNames, other.channelNames) &&
|
|
setEquals(agentPubkeys, other.agentPubkeys) &&
|
|
listEquals(customEmoji, other.customEmoji) &&
|
|
bindings.length == other.bindings.length &&
|
|
bindings.entries.every(
|
|
(entry) => setEquals(entry.value, other.bindings[entry.key]),
|
|
);
|
|
|
|
@override
|
|
int get hashCode =>
|
|
Object.hash(content, finalContent, emojiSize, hasMentionHandler);
|
|
}
|
|
|
|
// Encoded label punctuation must remain literal even inside nested formatting.
|
|
class _MarkdownLabelEntityMd extends InlineMd {
|
|
@override
|
|
RegExp get exp => RegExp(r'&#(?:91|92|93);');
|
|
|
|
@override
|
|
Set<MarkdownScope> get scopes => const {MarkdownScope.linkLabel};
|
|
|
|
@override
|
|
InlineSpan span(
|
|
BuildContext context,
|
|
String text,
|
|
GptMarkdownConfig config,
|
|
) => TextSpan(text: decodeMarkdownLabelSyntax(text), style: config.style);
|
|
}
|
|
|
|
// Backslash-escaped characters, held by shieldMarkdownEscapes so no other
|
|
// syntax matches them, shown as themselves.
|
|
class _MarkdownEscapeMd extends InlineMd {
|
|
@override
|
|
RegExp get exp => RegExp('(?:${shieldedMarkdownEscape.pattern})+');
|
|
|
|
@override
|
|
InlineSpan span(
|
|
BuildContext context,
|
|
String text,
|
|
GptMarkdownConfig config,
|
|
) => TextSpan(text: unshieldMarkdownEscapes(text), style: config.style);
|
|
}
|
|
|
|
// Labels are already normalized, including literal/nested brackets. The
|
|
// package's permissive .*? label matcher can otherwise start at a prose '['
|
|
// and swallow a later image or link before its own component gets to render.
|
|
class _MessageLinkMd extends ATagMd {
|
|
@override
|
|
RegExp get exp => RegExp(r'(?<!!)\[[^\[\]\n]*\]\([^\s]*\)');
|
|
}
|