mobile: bound preview preprocessing to visible lines; handle underline and escapes

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Tom Brow
2026-10-29 07:53:19 -07:00
parent c77326a918
commit 6661d594ae
3 changed files with 104 additions and 33 deletions

View File

@@ -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,

View File

@@ -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<PreviewPiece> 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<PreviewPiece> 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'(?<![A-Za-z0-9_])_(?=\S)(.+?)(?<=\S)_(?![A-Za-z0-9_])'),
RegExp(r'~~(?=\S)(.+?)(?<=\S)~~'),
RegExp(r'<u>(.+?)</u>', 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})\)');

View File

@@ -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 <u>important</u> 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');