diff --git a/mobile/lib/features/channels/message_content/link_normalizer.dart b/mobile/lib/features/channels/message_content/link_normalizer.dart index bc8763b088..8162485af7 100644 --- a/mobile/lib/features/channels/message_content/link_normalizer.dart +++ b/mobile/lib/features/channels/message_content/link_normalizer.dart @@ -14,6 +14,7 @@ final _bareLinkPattern = RegExp( ); final _trailingPunctuationPattern = RegExp(r'[.,!?:;]+$'); final _backtickRunPattern = RegExp(r'`+'); +final _codeRunPattern = RegExp(r'`+|~{3,}'); /// Converts supported Buzz and HTTP(S) autolinks and bare links into Markdown /// links while leaving inline and fenced code untouched. Punctuation peeling @@ -22,7 +23,7 @@ String normalizeBareLinks(String content) => mapMarkdownProse(content, _normalizeLinkSegment); /// Applies [transform] to each run of [content] outside inline and fenced -/// code, leaving code untouched. +/// code, leaving code untouched. Fences may use backticks or tildes. String mapMarkdownProse(String content, String Function(String) transform) { final buffer = StringBuffer(); var offset = 0; @@ -30,27 +31,38 @@ String mapMarkdownProse(String content, String Function(String) transform) { var codeStart = 0; var inlineDelimiterLength = 0; var fenceDelimiterLength = 0; + var fenceChar = ''; while (offset < content.length) { - final run = _backtickRunPattern.matchAsPrefix(content, offset); + final run = _codeRunPattern.matchAsPrefix(content, offset); if (run == null) { offset++; continue; } final runLength = run.end - run.start; + final runChar = content[run.start]; if (fenceDelimiterLength > 0) { - if (_isClosingFence(content, run.start, run.end, fenceDelimiterLength)) { + if (runChar == fenceChar && + _isClosingFence(content, run.start, run.end, fenceDelimiterLength)) { buffer.write(content.substring(codeStart, run.end)); fenceDelimiterLength = 0; proseStart = run.end; } } else if (inlineDelimiterLength > 0) { - if (runLength == inlineDelimiterLength) { + if (runChar == '`' && runLength == inlineDelimiterLength) { buffer.write(content.substring(codeStart, run.end)); inlineDelimiterLength = 0; proseStart = run.end; } + } else if (runChar == '~') { + // Tildes only fence code; shorter runs are strikethrough. + if (_isOpeningFence(content, run.start, runLength)) { + buffer.write(transform(content.substring(proseStart, run.start))); + codeStart = run.start; + fenceDelimiterLength = runLength; + fenceChar = runChar; + } } else if (_isEscaped(content, run.start)) { // An escaped backtick is literal; the rest of its run may still open. offset = run.start + 1; @@ -65,6 +77,7 @@ String mapMarkdownProse(String content, String Function(String) transform) { buffer.write(transform(content.substring(proseStart, run.start))); codeStart = run.start; fenceDelimiterLength = runLength; + fenceChar = runChar; } offset = run.end; @@ -276,9 +289,8 @@ String _normalizeProseLinks(String segment) { // An escaped `\<` is literal, not an autolink; its URL stays a bare link. var normalized = segment.replaceAllMapped( _autolinkPattern, - (match) => _isEscaped(segment, match.start) - ? match[0]! - : '[${match[1]}](${match[1]})', + (match) => + _isEscaped(segment, match.start) ? match[0]! : _autolink(match[1]!), ); normalized = normalized.replaceAllMapped( _bareLinkPattern, @@ -287,6 +299,11 @@ String _normalizeProseLinks(String segment) { return normalized; } +// An autolink keeps its backslashes literally. Encode them so none is read +// as an escape of the synthesized `]` or `)`. +String _autolink(String url) => + '[${url.replaceAll(r'\', '\')}](${url.replaceAll(r'\', '%5C')})'; + String _normalizeBareLink(String segment, Match match) { final matched = match[0]!; var url = matched; diff --git a/mobile/test/features/channels/message_content_test.dart b/mobile/test/features/channels/message_content_test.dart index 2a18759164..5969e1ca13 100644 --- a/mobile/test/features/channels/message_content_test.dart +++ b/mobile/test/features/channels/message_content_test.dart @@ -514,6 +514,7 @@ void main() { (r'\`literal\` then `a\#b`', '`literal` then '), // Authored stand-in characters are not mistaken for escapes. ('\uFF0A \u2060\uFF0A \\#', '\uFF0A \u2060\uFF0A #'), + ('~~~\nx \\# y\n~~~', r'x \# y'), // gpt_markdown shows this unparsed; the stand-in still reads as #. ('~~a\nb \\#c~~', '\uFF03c'), // A bare link's trailing backslash stays outside the link. @@ -521,6 +522,7 @@ void main() { (r'in buzz://channel/9f18f3c6-b5e5-453d-b36d-65487913be0b.\', r'.\'), (r'*https://example.com/a\**', '*'), (r'see \', 'see <'), + (r'see ', r'https://example.com/a\'), ( '![shot \\#1](https://example.com/a.png)\n' '![shot \\#2](https://example.com/b.png)',