diff --git a/mobile/lib/features/channels/message_content/markdown_escapes.dart b/mobile/lib/features/channels/message_content/markdown_escapes.dart index 40275f76fd..9a4e4bf3ce 100644 --- a/mobile/lib/features/channels/message_content/markdown_escapes.dart +++ b/mobile/lib/features/channels/message_content/markdown_escapes.dart @@ -1,18 +1,19 @@ import 'link_normalizer.dart'; -/// Holds each backslash-escaped character as a private-use stand-in, so no -/// link, emphasis, mention, emoji, channel or math syntax matches it: `\*x\*` -/// stays `*x*` and `\@alice` stays text. +/// Holds each backslash-escaped character as a stand-in, so no link, +/// emphasis, mention, emoji, channel or math syntax matches it: `\*x\*` stays +/// `*x*` and `\@alice` stays text. /// -/// A stand-in is [_marker] followed by the character shifted into the -/// private-use area. An authored [_marker] becomes two markers, so authored -/// private-use characters come back from [unshieldMarkdownEscapes] unchanged. +/// A stand-in is an invisible [_marker] followed by the character's fullwidth +/// form, so text the renderer shows without restoring it still reads right. +/// An authored [_marker] becomes two markers, so authored text comes back from +/// [unshieldMarkdownEscapes] unchanged. String shieldMarkdownEscapes(String text) => _needsShield(text) ? text.replaceAllMapped( _escape, (m) => m[1] == null ? '$_marker$_marker' - : '$_marker${String.fromCharCode(_escapeBase + m[1]!.codeUnitAt(0))}', + : '$_marker${String.fromCharCode(_fullwidthOffset + m[1]!.codeUnitAt(0))}', ) : text; @@ -34,18 +35,20 @@ String reescapeMarkdownEscapes(String text) => text.replaceAllMapped( ); /// Matches one stand-in created by [shieldMarkdownEscapes]. -final shieldedMarkdownEscape = RegExp('\uE000[\uE000\uE021-\uE07E]'); +final shieldedMarkdownEscape = RegExp('\u2060[\u2060\uFF01-\uFF5E]'); bool _needsShield(String text) => text.contains(r'\') || text.contains(_marker); String _held(String standIn) { - final unit = standIn.codeUnitAt(1); - return unit == _escapeBase + final held = standIn.substring(1); + return held == _marker ? _marker - : String.fromCharCode(unit - _escapeBase); + : String.fromCharCode(held.codeUnitAt(0) - _fullwidthOffset); } -const _marker = '\uE000'; -const _escapeBase = 0xE000; +// WORD JOINER: zero-width, not whitespace, and not Markdown syntax. +const _marker = '\u2060'; +// ASCII 0x21-0x7E to FULLWIDTH 0xFF01-0xFF5E. +const _fullwidthOffset = 0xFEE0; // CommonMark: a backslash escapes any ASCII punctuation character. -final _escape = RegExp(r'\\([!-/:-@\[-`{-~])|\uE000'); +final _escape = RegExp(r'\\([!-/:-@\[-`{-~])|\u2060'); diff --git a/mobile/test/features/channels/message_content_test.dart b/mobile/test/features/channels/message_content_test.dart index 274102f41f..2a18759164 100644 --- a/mobile/test/features/channels/message_content_test.dart +++ b/mobile/test/features/channels/message_content_test.dart @@ -512,8 +512,10 @@ void main() { ('```\nx \\# y\n```', r'x \# y'), (r'code `a\#b`', r'a\#b'), (r'\`literal\` then `a\#b`', '`literal` then '), - // Authored private-use characters are not mistaken for escapes. - ('\uE02A \uE000\uE02A \\#', '\uE02A \uE000\uE02A #'), + // Authored stand-in characters are not mistaken for escapes. + ('\uFF0A \u2060\uFF0A \\#', '\uFF0A \u2060\uFF0A #'), + // 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. (r'see https://example.com/a\', 'see '), (r'in buzz://channel/9f18f3c6-b5e5-453d-b36d-65487913be0b.\', r'.\'), @@ -538,17 +540,17 @@ void main() { ); } // No stand-in or leaked link syntax reaches text or semantics. - if (content.contains('\uE000')) continue; + if (content.contains('\u2060') || shown == '\uFF03c') continue; expect( find.textContaining( - RegExp('\uE000|\\]\\(http'), + RegExp('\u2060|\\]\\(http'), findRichText: true, ), findsNothing, reason: reason, ); expect( - find.bySemanticsLabel(RegExp('\uE000')), + find.bySemanticsLabel(RegExp('\u2060')), findsNothing, reason: reason, );