fix(mobile): split escaped voice-note links into their own segment (#8940)

Follow-up to [#8925](https://github.com/block/buzz/pull/8925), from a
[Codex
finding](https://github.com/block/buzz/pull/8925#discussion_r4446048706)
there.

After #8925, message Markdown arrives with backslash escapes shielded,
but imeta is keyed by plain URLs. Two lookups used the raw,
still-escaped destination:

- **Bubble segmentation** (`bubble_segments.dart`): an attachment link
such as `[voice.mp4](https://…/voice\_note.mp4)` missed its imeta entry
and stayed inside the text bubble, even though the link builder rendered
it as an attachment. The fix unshields the URL before the lookup.
- **Trailing image gallery** (`extractTrailingImageGallery` in
`message_gallery.dart`): with two or more trailing images, an escaped
destination kept its backslash, so the carousel loaded the wrong URL and
lost the imeta alt text. The fix unescapes the destination the same way
as the label beside it. Its callers in `message_media_avatar.dart` and
`message_skeleton_body.dart` get the fix too. This gap was already on
main.

### Validation

- The existing `attachments stay outside bubbles and text order is
preserved` journey now uses an escaped destination with a plain imeta
URL. The existing carousel journey now has one escaped image destination
and taps it by its plain URL. Each fails with its fix reverted.
- `flutter analyze` is clean and the full mobile suite passes (4151
tests).
- Limitation: no simulator run. Both changes are key lookups, and the
widget journeys exercise the rendered layout. The full-app run for the
surrounding escape handling is recorded in #8925.

🤖 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>
This commit is contained in:
Tom Brow
2026-11-02 11:18:10 -08:00
committed by GitHub
parent b5b69d3de8
commit 35df866b33
4 changed files with 12 additions and 6 deletions

View File

@@ -1,4 +1,5 @@
import '../message_media.dart';
import 'markdown_escapes.dart';
/// Splits top-level media from text without breaking Markdown blocks.
/// Fenced code, lists and quotes retain their nested content.
@@ -44,7 +45,9 @@ List<({String text, bool attachment})> messageBubbleSegments(
: embed.firstMatch(line.trimRight());
final isAttachment =
match != null &&
(line.trim().startsWith('!') || imeta.containsKey(match.group(1)));
(line.trim().startsWith('!') ||
// Content arrives with escapes shielded; imeta keys are plain.
imeta.containsKey(unshieldMarkdownEscapes(match.group(1)!)));
if (attachment != null &&
isAttachment != attachment &&
line.trim().isNotEmpty) {

View File

@@ -43,7 +43,8 @@ TrailingImageGallery? extractTrailingImageGallery(
while (cursor >= 0) {
final match = imagePattern.firstMatch(lines[cursor].trim());
if (match == null) break;
final url = match.group(2)!;
// CommonMark unescapes link destinations, as it does labels below.
final url = unshieldMarkdownEscapes(shieldMarkdownEscapes(match.group(2)!));
final imeta = imetaByUrl[url];
final mediaKind = classifyMediaUrl(url, imeta: imeta);
if (mediaKind != MessageMediaKind.image) {

View File

@@ -240,9 +240,10 @@ void bubbleContentTests() {
_testable(
const MessageContent(
content:
'Before\n\n[file.txt](https://example.com/file.txt)\n\nAfter',
// The escaped destination must still match its plain imeta URL.
'Before\n\n[file.txt](https://example.com/file\\_a.txt)\n\nAfter',
tags: [
['imeta', 'url https://example.com/file.txt', 'm text/plain'],
['imeta', 'url https://example.com/file_a.txt', 'm text/plain'],
],
bubble: true,
),

View File

@@ -1924,7 +1924,8 @@ void main() {
'groups uploaded photos into a carousel and opens the full gallery',
(tester) async {
const first = 'https://example.com/media/one.png';
const second = 'https://example.com/media/two.png';
// The second destination is escaped; the carousel must unescape it.
const second = 'https://example.com/media/two_2.png';
const third = 'https://example.com/media/three.png';
await tester.pumpWidget(
_testable(
@@ -1933,7 +1934,7 @@ void main() {
'''
Photos
![image]($first)
![image]($second)
![image](https://example.com/media/two\\_2.png)
![image]($third)
''',
tags: [