From 6661d594ae4f719f11012de0eabfcb5954fd13f6 Mon Sep 17 00:00:00 2001 From: Tom Brow Date: Thu, 29 Oct 2026 07:53:19 -0700 Subject: [PATCH] mobile: bound preview preprocessing to visible lines; handle underline and escapes Co-Authored-By: Claude Opus 5.5 --- .../message_content/message_preview.dart | 43 ++++++----- .../message_content/preview_text.dart | 77 +++++++++++++++---- .../message_content/preview_text_test.dart | 17 ++++ 3 files changed, 104 insertions(+), 33 deletions(-) diff --git a/mobile/lib/features/channels/message_content/message_preview.dart b/mobile/lib/features/channels/message_content/message_preview.dart index 1a5fc26897..746d6f8b7d 100644 --- a/mobile/lib/features/channels/message_content/message_preview.dart +++ b/mobile/lib/features/channels/message_content/message_preview.dart @@ -37,26 +37,29 @@ class MessagePreview extends HookConsumerWidget { final channelNames = ref.watch(channelIdsByNameProvider); final palette = ref.watch(customEmojiListProvider); final pieces = useMemoized( - () => messagePreviewPieces( - normalizeBareLinks(content), - maxLines: maxLines, - mentionBindings: renderedMentionBindings( - content, - mentionNames, - mentionedPubkeysFromTags(tags), - ), - mentionNames: mentionNames, - mentionLabels: mentionLabels, - customEmoji: _referencedCustomEmoji( - content, - _mergeCustomEmoji(customEmojiFromTags(tags), palette), - ), - channelNames: channelNames, - imetaByUrl: { - for (final entry in parseImetaTags(tags).entries) - normalizeMarkdownDestination(entry.key): entry.value, - }, - ), + () { + final source = messagePreviewSource(content, maxLines: maxLines); + return messagePreviewPieces( + normalizeBareLinks(source), + maxLines: maxLines, + mentionBindings: renderedMentionBindings( + source, + mentionNames, + mentionedPubkeysFromTags(tags), + ), + mentionNames: mentionNames, + mentionLabels: mentionLabels, + customEmoji: _referencedCustomEmoji( + source, + _mergeCustomEmoji(customEmojiFromTags(tags), palette), + ), + channelNames: channelNames, + imetaByUrl: { + for (final entry in parseImetaTags(tags).entries) + normalizeMarkdownDestination(entry.key): entry.value, + }, + ); + }, [ content, tags, diff --git a/mobile/lib/features/channels/message_content/preview_text.dart b/mobile/lib/features/channels/message_content/preview_text.dart index ffd9a36192..79e95d6072 100644 --- a/mobile/lib/features/channels/message_content/preview_text.dart +++ b/mobile/lib/features/channels/message_content/preview_text.dart @@ -1,3 +1,5 @@ +import 'dart:math' as math; + import 'package:flutter/foundation.dart'; import '../../../shared/deeplink/deep_link.dart'; @@ -118,11 +120,48 @@ List messagePreviewPieces( channelNames: channelNames, imetaByUrl: imetaByUrl, ); + var first = true; + for (final line in _keptLines(content, maxLines)) { + if (!first) builder.text('\n'); + first = false; + if (line.code) { + builder.text(line.text); + } else { + builder.line(line.text); + } + } + return builder.pieces; +} + +/// The leading part of [content] that [messagePreviewPieces] reads for a +/// [maxLines] preview, so whole-message preprocessing such as link +/// normalization and mention binding can run on just that part. +String messagePreviewSource(String content, {required int maxLines}) { + var end = 0; + for (final line in _keptLines(content, maxLines)) { + end = line.end; + } + return content.substring(0, end); +} + +/// The first `maxLines + 1` lines that render, trimmed and without block +/// syntax, read lazily so the rest of [content] is never scanned. [code] marks +/// a line inside a fenced block, and [end] is its end offset in [content]. +Iterable<({String text, bool code, int end})> _keptLines( + String content, + int maxLines, +) sync* { var kept = 0; var fenceOpen = false; - for (final source in content.split('\n')) { - if (kept > maxLines) break; - var line = source.trim(); + var start = 0; + while (start <= content.length && kept <= maxLines) { + var end = content.indexOf('\n', start); + if (end < 0) end = content.length; + // A long line wraps past the visible lines well before this length. + var line = content + .substring(start, math.min(end, start + 2 * _maxLineLength)) + .trim(); + start = end + 1; // A fence opens at any line starting with backticks and closes only at a // line holding nothing else, as in gpt_markdown. if (!fenceOpen && line.startsWith('```') || fenceOpen && line == '```') { @@ -135,19 +174,12 @@ List messagePreviewPieces( line = _blockPrefix(line); if (line.isEmpty) continue; } - // A long line wraps past the visible lines well before this length. if (line.length > _maxLineLength) { line = line.substring(0, _maxLineLength); } - if (kept > 0) builder.text('\n'); kept++; - if (fenceOpen) { - builder.text(line); - } else { - builder.line(line); - } + yield (text: line, code: fenceOpen, end: end); } - return builder.pieces; } const _maxLineLength = 2000; @@ -190,16 +222,35 @@ final _emphasis = [ RegExp(r'\*(?=\S)(.+?)(?<=\S)\*'), RegExp(r'(?(.+?)', caseSensitive: false), ]; +/// Unwraps emphasis and resolves backslash escapes. An escaped character is +/// held as a private-use stand-in while delimiters are matched, so `\*x\*` +/// stays `*x*`. String _unwrapEmphasis(String text) { - var result = text; + if (!text.contains(_escapePrefix) && !_hasDelimiter.hasMatch(text)) { + return text; + } + var result = text.replaceAllMapped( + _escape, + (m) => String.fromCharCode(_escapeBase + m[1]!.codeUnitAt(0)), + ); for (final pattern in _emphasis) { result = result.replaceAllMapped(pattern, (m) => m[1]!); } - return result; + return result.replaceAllMapped( + _escaped, + (m) => String.fromCharCode(m[0]!.codeUnitAt(0) - _escapeBase), + ); } +const _escapePrefix = r'\'; +const _escapeBase = 0xE000; +final _hasDelimiter = RegExp(r'[*_~<]'); +final _escape = RegExp(r'\\([!-/:-@\[-`{-~])'); +final _escaped = RegExp('[\uE021-\uE07E]'); + // Look ahead so adjacent tokens sharing a colon are both considered. final _shortcode = RegExp(r'(?=:([a-z0-9_-]+):)', caseSensitive: false); final _qualifier = RegExp(r'\(([0-9a-f]{64})\)'); diff --git a/mobile/test/features/channels/message_content/preview_text_test.dart b/mobile/test/features/channels/message_content/preview_text_test.dart index b55b96b860..2dfbe63b6a 100644 --- a/mobile/test/features/channels/message_content/preview_text_test.dart +++ b/mobile/test/features/channels/message_content/preview_text_test.dart @@ -58,6 +58,9 @@ void main() { ('rule', 'above\n---\nbelow', 'above\nbelow'), ('table', '| a | b |\n|---|:-:|\n| 1 | 2 |', 'a b\n1 2'), ('blank lines', 'one\n\n\ntwo', 'one\ntwo'), + ('underline', 'an important note', 'an important note'), + ('escaped emphasis', r'\*literal\* and \_x\_', '*literal* and _x_'), + ('escaped backslash', r'a \\ b', r'a \ b'), ]) { test(name, () => expect(_text(source), expected)); } @@ -72,6 +75,20 @@ void main() { }); group('line limit', () { + test('the source prefix ends with the last line the preview reads', () { + const content = '# a\n\n```\ncode\n```\nb\nc\nd\ne'; + expect( + messagePreviewSource(content, maxLines: 2), + '# a\n\n```\ncode\n```\nb', + ); + expect(_text(messagePreviewSource(content, maxLines: 2)), 'a\ncode\nb'); + }); + + test('the source prefix of a short message is the message', () { + expect(messagePreviewSource('one\ntwo', maxLines: 2), 'one\ntwo'); + expect(messagePreviewSource('', maxLines: 2), ''); + }); + test('reads only maxLines + 1 kept lines', () { final content = List.generate(1000, (i) => 'line $i').join('\n'); expect(_text(content), 'line 0\nline 1\nline 2');