mirror of
https://github.com/block/buzz.git
synced 2026-10-30 22:11:36 +00:00
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:
@@ -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,
|
||||
|
||||
@@ -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})\)');
|
||||
|
||||
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user