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: [