mirror of
https://github.com/block/buzz.git
synced 2026-11-14 13:21:02 +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 channelNames = ref.watch(channelIdsByNameProvider);
|
||||||
final palette = ref.watch(customEmojiListProvider);
|
final palette = ref.watch(customEmojiListProvider);
|
||||||
final pieces = useMemoized(
|
final pieces = useMemoized(
|
||||||
() => messagePreviewPieces(
|
() {
|
||||||
normalizeBareLinks(content),
|
final source = messagePreviewSource(content, maxLines: maxLines);
|
||||||
maxLines: maxLines,
|
return messagePreviewPieces(
|
||||||
mentionBindings: renderedMentionBindings(
|
normalizeBareLinks(source),
|
||||||
content,
|
maxLines: maxLines,
|
||||||
mentionNames,
|
mentionBindings: renderedMentionBindings(
|
||||||
mentionedPubkeysFromTags(tags),
|
source,
|
||||||
),
|
mentionNames,
|
||||||
mentionNames: mentionNames,
|
mentionedPubkeysFromTags(tags),
|
||||||
mentionLabels: mentionLabels,
|
),
|
||||||
customEmoji: _referencedCustomEmoji(
|
mentionNames: mentionNames,
|
||||||
content,
|
mentionLabels: mentionLabels,
|
||||||
_mergeCustomEmoji(customEmojiFromTags(tags), palette),
|
customEmoji: _referencedCustomEmoji(
|
||||||
),
|
source,
|
||||||
channelNames: channelNames,
|
_mergeCustomEmoji(customEmojiFromTags(tags), palette),
|
||||||
imetaByUrl: {
|
),
|
||||||
for (final entry in parseImetaTags(tags).entries)
|
channelNames: channelNames,
|
||||||
normalizeMarkdownDestination(entry.key): entry.value,
|
imetaByUrl: {
|
||||||
},
|
for (final entry in parseImetaTags(tags).entries)
|
||||||
),
|
normalizeMarkdownDestination(entry.key): entry.value,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
[
|
[
|
||||||
content,
|
content,
|
||||||
tags,
|
tags,
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import 'dart:math' as math;
|
||||||
|
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
|
|
||||||
import '../../../shared/deeplink/deep_link.dart';
|
import '../../../shared/deeplink/deep_link.dart';
|
||||||
@@ -118,11 +120,48 @@ List<PreviewPiece> messagePreviewPieces(
|
|||||||
channelNames: channelNames,
|
channelNames: channelNames,
|
||||||
imetaByUrl: imetaByUrl,
|
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 kept = 0;
|
||||||
var fenceOpen = false;
|
var fenceOpen = false;
|
||||||
for (final source in content.split('\n')) {
|
var start = 0;
|
||||||
if (kept > maxLines) break;
|
while (start <= content.length && kept <= maxLines) {
|
||||||
var line = source.trim();
|
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
|
// A fence opens at any line starting with backticks and closes only at a
|
||||||
// line holding nothing else, as in gpt_markdown.
|
// line holding nothing else, as in gpt_markdown.
|
||||||
if (!fenceOpen && line.startsWith('```') || fenceOpen && line == '```') {
|
if (!fenceOpen && line.startsWith('```') || fenceOpen && line == '```') {
|
||||||
@@ -135,19 +174,12 @@ List<PreviewPiece> messagePreviewPieces(
|
|||||||
line = _blockPrefix(line);
|
line = _blockPrefix(line);
|
||||||
if (line.isEmpty) continue;
|
if (line.isEmpty) continue;
|
||||||
}
|
}
|
||||||
// A long line wraps past the visible lines well before this length.
|
|
||||||
if (line.length > _maxLineLength) {
|
if (line.length > _maxLineLength) {
|
||||||
line = line.substring(0, _maxLineLength);
|
line = line.substring(0, _maxLineLength);
|
||||||
}
|
}
|
||||||
if (kept > 0) builder.text('\n');
|
|
||||||
kept++;
|
kept++;
|
||||||
if (fenceOpen) {
|
yield (text: line, code: fenceOpen, end: end);
|
||||||
builder.text(line);
|
|
||||||
} else {
|
|
||||||
builder.line(line);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return builder.pieces;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const _maxLineLength = 2000;
|
const _maxLineLength = 2000;
|
||||||
@@ -190,16 +222,35 @@ final _emphasis = [
|
|||||||
RegExp(r'\*(?=\S)(.+?)(?<=\S)\*'),
|
RegExp(r'\*(?=\S)(.+?)(?<=\S)\*'),
|
||||||
RegExp(r'(?<![A-Za-z0-9_])_(?=\S)(.+?)(?<=\S)_(?![A-Za-z0-9_])'),
|
RegExp(r'(?<![A-Za-z0-9_])_(?=\S)(.+?)(?<=\S)_(?![A-Za-z0-9_])'),
|
||||||
RegExp(r'~~(?=\S)(.+?)(?<=\S)~~'),
|
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) {
|
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) {
|
for (final pattern in _emphasis) {
|
||||||
result = result.replaceAllMapped(pattern, (m) => m[1]!);
|
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.
|
// Look ahead so adjacent tokens sharing a colon are both considered.
|
||||||
final _shortcode = RegExp(r'(?=:([a-z0-9_-]+):)', caseSensitive: false);
|
final _shortcode = RegExp(r'(?=:([a-z0-9_-]+):)', caseSensitive: false);
|
||||||
final _qualifier = RegExp(r'\(([0-9a-f]{64})\)');
|
final _qualifier = RegExp(r'\(([0-9a-f]{64})\)');
|
||||||
|
|||||||
@@ -58,6 +58,9 @@ void main() {
|
|||||||
('rule', 'above\n---\nbelow', 'above\nbelow'),
|
('rule', 'above\n---\nbelow', 'above\nbelow'),
|
||||||
('table', '| a | b |\n|---|:-:|\n| 1 | 2 |', 'a b\n1 2'),
|
('table', '| a | b |\n|---|:-:|\n| 1 | 2 |', 'a b\n1 2'),
|
||||||
('blank lines', 'one\n\n\ntwo', 'one\ntwo'),
|
('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));
|
test(name, () => expect(_text(source), expected));
|
||||||
}
|
}
|
||||||
@@ -72,6 +75,20 @@ void main() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
group('line limit', () {
|
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', () {
|
test('reads only maxLines + 1 kept lines', () {
|
||||||
final content = List.generate(1000, (i) => 'line $i').join('\n');
|
final content = List.generate(1000, (i) => 'line $i').join('\n');
|
||||||
expect(_text(content), 'line 0\nline 1\nline 2');
|
expect(_text(content), 'line 0\nline 1\nline 2');
|
||||||
|
|||||||
Reference in New Issue
Block a user