From 35df866b334c905559e10caf0ea2ea56ee1886ee Mon Sep 17 00:00:00 2001 From: Tom Brow Date: Mon, 2 Nov 2026 11:18:10 -0800 Subject: [PATCH] fix(mobile): split escaped voice-note links into their own segment (#8940) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Co-authored-by: Tom Brow Co-authored-by: studio Co-authored-by: Claude Opus 5.5 --- .../features/channels/message_content/bubble_segments.dart | 5 ++++- mobile/lib/features/channels/message_gallery.dart | 3 ++- .../test/features/channels/message_content_bubble_cases.dart | 5 +++-- mobile/test/features/channels/message_content_test.dart | 5 +++-- 4 files changed, 12 insertions(+), 6 deletions(-) diff --git a/mobile/lib/features/channels/message_content/bubble_segments.dart b/mobile/lib/features/channels/message_content/bubble_segments.dart index 6ad52cdb8b..787b464afd 100644 --- a/mobile/lib/features/channels/message_content/bubble_segments.dart +++ b/mobile/lib/features/channels/message_content/bubble_segments.dart @@ -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) { diff --git a/mobile/lib/features/channels/message_gallery.dart b/mobile/lib/features/channels/message_gallery.dart index 3186b4432d..d65e2f1e18 100644 --- a/mobile/lib/features/channels/message_gallery.dart +++ b/mobile/lib/features/channels/message_gallery.dart @@ -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) { diff --git a/mobile/test/features/channels/message_content_bubble_cases.dart b/mobile/test/features/channels/message_content_bubble_cases.dart index 6179c51ac6..c3175adc1b 100644 --- a/mobile/test/features/channels/message_content_bubble_cases.dart +++ b/mobile/test/features/channels/message_content_bubble_cases.dart @@ -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, ), diff --git a/mobile/test/features/channels/message_content_test.dart b/mobile/test/features/channels/message_content_test.dart index 71eb0682bf..1d7dc8bb52 100644 --- a/mobile/test/features/channels/message_content_test.dart +++ b/mobile/test/features/channels/message_content_test.dart @@ -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: [