mirror of
https://github.com/block/buzz.git
synced 2026-11-12 20:31:12 +00:00
fix(mobile): preserve Markdown attachment destinations (#8168)
## Summary Fix mobile “Image unavailable” errors caused by angle-bracketed Markdown URLs. Preserve attachment destinations across images, galleries, videos, and file links, including spaces, parentheses, and optional titles. ### Related issue Related: #8086 (attachment filenames). ### Testing - `just mobile-install mobile-check mobile-test` - Regression coverage decodes PNG/JPEG/GIF/WebP/BMP and verifies gallery viewers, video routing, and authenticated file links. - Native device playback not tested. --------- Signed-off-by: kenny lopez <klopez4212@gmail.com>
This commit is contained in:
@@ -219,11 +219,17 @@ class MessageContent extends HookConsumerWidget {
|
||||
..sort((a, b) => a.key.compareTo(b.key))))
|
||||
'${entry.key}\u0000${entry.value}',
|
||||
].join('\u0001');
|
||||
final imetaByUrl = parseImetaTags(tags);
|
||||
final imetaByUrl = {
|
||||
for (final entry in parseImetaTags(tags).entries)
|
||||
normalizeMarkdownDestination(entry.key): entry.value,
|
||||
};
|
||||
final normalizedContent = useMemoized(() => normalizeBareLinks(content), [
|
||||
content,
|
||||
]);
|
||||
final trailingGallery = maxLines == null
|
||||
? extractTrailingImageGallery(content, imetaByUrl)
|
||||
? extractTrailingImageGallery(normalizedContent, imetaByUrl)
|
||||
: null;
|
||||
final markdownContent = trailingGallery?.content ?? content;
|
||||
final markdownContent = trailingGallery?.content ?? normalizedContent;
|
||||
final customEmoji = _mergeCustomEmoji(
|
||||
customEmojiFromTags(tags),
|
||||
ref.watch(customEmojiListProvider),
|
||||
@@ -257,17 +263,12 @@ class MessageContent extends HookConsumerWidget {
|
||||
? kEmojiOnlyCustomEmojiSize
|
||||
: kCustomEmojiInlineSize;
|
||||
|
||||
final linkNormalizedContent = useMemoized(
|
||||
() => normalizeBareLinks(markdownContent),
|
||||
[markdownContent],
|
||||
);
|
||||
|
||||
final finalContent = useMemoized(() {
|
||||
// Replace spaces with non-breaking spaces inside known mention names
|
||||
// so the gpt_markdown combined regex can match multi-word names
|
||||
// even when caseSensitive is not preserved.
|
||||
// Skip content inside backticks to avoid altering inline code.
|
||||
final mentionParts = linkNormalizedContent.split('`');
|
||||
final mentionParts = markdownContent.split('`');
|
||||
final mentionBuf = StringBuffer();
|
||||
for (var i = 0; i < mentionParts.length; i++) {
|
||||
if (i.isOdd) {
|
||||
@@ -295,7 +296,7 @@ class MessageContent extends HookConsumerWidget {
|
||||
result = '\u200B$result';
|
||||
}
|
||||
return result;
|
||||
}, [linkNormalizedContent, mentionPresentationKey]);
|
||||
}, [markdownContent, mentionPresentationKey]);
|
||||
|
||||
final inlineComponents = _useMessageInlineComponents(
|
||||
content: content,
|
||||
|
||||
@@ -66,7 +66,9 @@ List<MarkdownComponent> _useMessageInlineComponents({
|
||||
channelNames: inputs.channelNames,
|
||||
onChannelTap: channelTap,
|
||||
),
|
||||
...MarkdownComponent.inlineComponents,
|
||||
_MarkdownLabelEntityMd(),
|
||||
for (final component in MarkdownComponent.inlineComponents)
|
||||
component is ATagMd ? _MessageLinkMd() : component,
|
||||
],
|
||||
[inputs],
|
||||
);
|
||||
@@ -141,3 +143,27 @@ class _InlineComponentInputs {
|
||||
int get hashCode =>
|
||||
Object.hash(content, finalContent, emojiSize, hasMentionHandler);
|
||||
}
|
||||
|
||||
// Encoded label punctuation must remain literal even inside nested formatting.
|
||||
class _MarkdownLabelEntityMd extends InlineMd {
|
||||
@override
|
||||
RegExp get exp => RegExp(r'&#(?:91|92|93);');
|
||||
|
||||
@override
|
||||
Set<MarkdownScope> get scopes => const {MarkdownScope.linkLabel};
|
||||
|
||||
@override
|
||||
InlineSpan span(
|
||||
BuildContext context,
|
||||
String text,
|
||||
GptMarkdownConfig config,
|
||||
) => TextSpan(text: decodeMarkdownLabelSyntax(text), style: config.style);
|
||||
}
|
||||
|
||||
// Labels are already normalized, including literal/nested brackets. The
|
||||
// package's permissive .*? label matcher can otherwise start at a prose '['
|
||||
// and swallow a later image or link before its own component gets to render.
|
||||
class _MessageLinkMd extends ATagMd {
|
||||
@override
|
||||
RegExp get exp => RegExp(r'(?<!!)\[[^\[\]\n]*\]\([^\s]*\)');
|
||||
}
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
/// Escapes destination characters that the mobile Markdown renderer treats as
|
||||
/// syntax. Apply the same conversion to metadata keys to preserve media types.
|
||||
String normalizeMarkdownDestination(String url) =>
|
||||
url.replaceAll(' ', '%20').replaceAll('(', '%28').replaceAll(')', '%29');
|
||||
|
||||
const _markdownDelimiters = ['***', '___', '**', '__', '~~', '*', '_'];
|
||||
|
||||
final _autolinkPattern = RegExp(
|
||||
@@ -110,7 +115,149 @@ bool _hasInlineCloser(String content, int start, int delimiterLength) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Numeric entities shield literal label punctuation from gpt_markdown's
|
||||
// bracket-blind image/link matchers and its earlier \[...\] math pass.
|
||||
String _normalizeMarkdownLabel(String label) => label.replaceAllMapped(
|
||||
RegExp(r'\\([\\\[\]])|[\[\]]'),
|
||||
(match) => '&#${(match[1] ?? match[0]!).codeUnitAt(0)};',
|
||||
);
|
||||
|
||||
/// Restores the literal punctuation encoded for Markdown attachment labels.
|
||||
String decodeMarkdownLabelSyntax(String label) => label.replaceAllMapped(
|
||||
RegExp(r'&#(91|92|93);'),
|
||||
(match) => String.fromCharCode(int.parse(match[1]!)),
|
||||
);
|
||||
|
||||
// Scan each label character once, treating escaped brackets as label text.
|
||||
// Nested brackets are balanced without restarting at every opening bracket.
|
||||
({int start, int end})? _findMarkdownLinkStart(String segment, int offset) {
|
||||
final openers = <int>[];
|
||||
for (var cursor = offset; cursor < segment.length; cursor++) {
|
||||
final char = segment[cursor];
|
||||
if (char == r'\' &&
|
||||
cursor + 1 < segment.length &&
|
||||
segment[cursor + 1] != '\n') {
|
||||
cursor++;
|
||||
continue;
|
||||
}
|
||||
if (char == '\n') {
|
||||
openers.clear();
|
||||
} else if (char == '[') {
|
||||
openers.add(cursor);
|
||||
} else if (char == ']' && openers.isNotEmpty) {
|
||||
final opener = openers.removeLast();
|
||||
// A destination belongs to this matching opener even if an earlier
|
||||
// prose bracket never closed. Inner label brackets without a destination
|
||||
// still balance normally, preserving true nested labels.
|
||||
if (cursor + 1 < segment.length && segment[cursor + 1] == '(') {
|
||||
final start = opener > 0 && segment[opener - 1] == '!'
|
||||
? opener - 1
|
||||
: opener;
|
||||
return (start: start, end: cursor + 2);
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
String _normalizeLinkSegment(String segment) {
|
||||
final result = StringBuffer();
|
||||
var offset = 0;
|
||||
while (offset < segment.length) {
|
||||
final match = _findMarkdownLinkStart(segment, offset);
|
||||
if (match == null) break;
|
||||
var cursor = match.end;
|
||||
if (cursor >= segment.length) break;
|
||||
final angled = segment[cursor] == '<';
|
||||
final start = angled ? ++cursor : cursor;
|
||||
var depth = 0;
|
||||
while (cursor < segment.length) {
|
||||
final char = segment[cursor];
|
||||
if (angled) {
|
||||
if (char == '>' || char == '\n' || char == '<') break;
|
||||
} else {
|
||||
if (char.trim().isEmpty) break;
|
||||
if (char == '(') depth++;
|
||||
if (char == ')') {
|
||||
if (depth == 0) break;
|
||||
depth--;
|
||||
}
|
||||
}
|
||||
cursor++;
|
||||
}
|
||||
final destinationEnd = cursor;
|
||||
var valid = cursor > start && cursor < segment.length;
|
||||
if (angled && valid) {
|
||||
valid = segment[cursor] == '>';
|
||||
if (valid) cursor++;
|
||||
}
|
||||
if (valid) {
|
||||
// gpt_markdown has no title parameter. Consume all Markdown title
|
||||
// delimiters and escaped delimiters before removing the title.
|
||||
final suffix = _scanLinkSuffix(segment, cursor);
|
||||
cursor = suffix.end;
|
||||
valid = suffix.valid;
|
||||
}
|
||||
result.write(_normalizeProseLinks(segment.substring(offset, match.start)));
|
||||
if (valid) {
|
||||
final destination = segment.substring(start, destinationEnd);
|
||||
final image = segment[match.start] == '!';
|
||||
final labelStart = match.start + (image ? 2 : 1);
|
||||
final label = _normalizeMarkdownLabel(
|
||||
segment.substring(labelStart, match.end - 2),
|
||||
);
|
||||
result.write(
|
||||
'${image ? '!' : ''}[$label](${normalizeMarkdownDestination(destination)})',
|
||||
);
|
||||
} else {
|
||||
// Preserve malformed source, and never scan its inspected suffix again.
|
||||
// This cursor advances on failure as well as success, bounding work even
|
||||
// for a relay-sized message made entirely of unterminated link openers.
|
||||
result.write(segment.substring(match.start, cursor));
|
||||
}
|
||||
offset = cursor;
|
||||
}
|
||||
result.write(_normalizeProseLinks(segment.substring(offset)));
|
||||
return result.toString();
|
||||
}
|
||||
|
||||
({int end, bool valid}) _scanLinkSuffix(String segment, int cursor) {
|
||||
final start = cursor;
|
||||
while (cursor < segment.length && segment[cursor].trim().isEmpty) {
|
||||
cursor++;
|
||||
}
|
||||
if (cursor < segment.length && segment[cursor] == ')') {
|
||||
return (end: cursor + 1, valid: true);
|
||||
}
|
||||
if (cursor == start || cursor == segment.length) {
|
||||
return (end: cursor, valid: false);
|
||||
}
|
||||
final opener = segment[cursor];
|
||||
if (opener != '"' && opener != "'" && opener != '(') {
|
||||
return (end: cursor, valid: false);
|
||||
}
|
||||
final closer = opener == '(' ? ')' : opener;
|
||||
cursor++;
|
||||
while (cursor < segment.length) {
|
||||
final char = segment[cursor++];
|
||||
if (char == r'\' && cursor < segment.length) {
|
||||
cursor++;
|
||||
} else if (char == closer) {
|
||||
while (cursor < segment.length && segment[cursor].trim().isEmpty) {
|
||||
cursor++;
|
||||
}
|
||||
if (cursor < segment.length && segment[cursor] == ')') {
|
||||
return (end: cursor + 1, valid: true);
|
||||
}
|
||||
return (end: cursor, valid: false);
|
||||
} else if (opener == '(' && char == '(') {
|
||||
return (end: cursor, valid: false);
|
||||
}
|
||||
}
|
||||
return (end: cursor, valid: false);
|
||||
}
|
||||
|
||||
String _normalizeProseLinks(String segment) {
|
||||
var normalized = segment.replaceAllMapped(
|
||||
_autolinkPattern,
|
||||
(match) => '[${match[1]}](${match[1]})',
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import 'message_media.dart';
|
||||
import 'message_content/link_normalizer.dart';
|
||||
|
||||
/// Image metadata shared by carousel rendering and loading projection.
|
||||
@immutable
|
||||
@@ -47,15 +48,13 @@ TrailingImageGallery? extractTrailingImageGallery(
|
||||
if (mediaKind != MessageMediaKind.image) {
|
||||
break;
|
||||
}
|
||||
final markdownLabel = match.group(1)?.trim();
|
||||
final markdownLabel = decodeMarkdownLabelSyntax(match.group(1)!).trim();
|
||||
items.add(
|
||||
MessageGalleryItem(
|
||||
url: url,
|
||||
semanticLabel:
|
||||
imeta?.alt ??
|
||||
(markdownLabel?.isNotEmpty == true
|
||||
? markdownLabel!
|
||||
: 'Message image'),
|
||||
(markdownLabel.isNotEmpty ? markdownLabel : 'Message image'),
|
||||
aspectRatio: imeta?.aspectRatio,
|
||||
),
|
||||
);
|
||||
|
||||
@@ -0,0 +1,273 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:buzz/features/channels/message_content.dart';
|
||||
import 'package:buzz/features/channels/media_viewer_page.dart';
|
||||
import 'package:buzz/shared/relay/relay.dart';
|
||||
import 'package:buzz/shared/theme/theme.dart';
|
||||
import 'package:buzz/shared/widgets/media_loading_placeholder.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:hooks_riverpod/misc.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:http/testing.dart';
|
||||
import 'package:nostr/nostr.dart' as nostr;
|
||||
|
||||
import '../../helpers/media_codec_fixtures.dart';
|
||||
|
||||
const _base = 'https://relay.example';
|
||||
|
||||
Widget _app(
|
||||
String content, {
|
||||
List<List<String>> tags = const [],
|
||||
List<Override> overrides = const [],
|
||||
}) => ProviderScope(
|
||||
overrides: overrides,
|
||||
child: MaterialApp(
|
||||
theme: AppTheme.light(),
|
||||
home: Scaffold(
|
||||
body: MessageContent(
|
||||
content: content,
|
||||
tags: tags,
|
||||
channelNames: const {'general': 'channel-id'},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
// Image codecs complete outside the fake test clock. Wait for actual pixels,
|
||||
// rather than waiting for an indefinitely animated loading shimmer to settle.
|
||||
Future<void> _waitForImages(WidgetTester tester) async {
|
||||
for (var attempt = 0; attempt < 200; attempt++) {
|
||||
await tester.runAsync(
|
||||
() => Future<void>.delayed(const Duration(milliseconds: 10)),
|
||||
);
|
||||
await tester.pump(const Duration(milliseconds: 16));
|
||||
final images = tester.widgetList<RawImage>(find.byType(RawImage));
|
||||
if (images.any((image) => image.image != null) &&
|
||||
find.byType(MediaLoadingPlaceholder).evaluate().isEmpty) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
fail('Media did not produce a decoded image frame');
|
||||
}
|
||||
|
||||
void main() {
|
||||
setUp(() {
|
||||
MediaImageProvider.debugResetCooldowns();
|
||||
PaintingBinding.instance.imageCache.clear();
|
||||
PaintingBinding.instance.imageCache.clearLiveImages();
|
||||
});
|
||||
|
||||
for (final fixture in mediaCodecFixtures.entries) {
|
||||
for (final angled in [false, true]) {
|
||||
testWidgets(
|
||||
'${fixture.key} ${angled ? 'angle' : 'plain'} image downloads and opens',
|
||||
(tester) async {
|
||||
final url = '$_base/media/photo.${fixture.key}?download=1&v=2';
|
||||
final requests = <http.Request>[];
|
||||
final client = MockClient((request) async {
|
||||
requests.add(request);
|
||||
return http.Response.bytes(base64Decode(fixture.value), 200);
|
||||
});
|
||||
final auth = MediaGetAuthService(
|
||||
baseUrl: _base,
|
||||
nsec: nostr.Keys.generate().nsec,
|
||||
);
|
||||
await tester.pumpWidget(
|
||||
_app(
|
||||
'${angled ? 'oops [ text ' : 'Look\n'}'
|
||||
'![photo \\[Q4\\]](${angled ? '<$url>' : url})',
|
||||
tags: [
|
||||
[
|
||||
'imeta',
|
||||
'url $url',
|
||||
'm image/${fixture.key}',
|
||||
'dim 2x2',
|
||||
'alt Photo',
|
||||
],
|
||||
],
|
||||
overrides: [
|
||||
mediaHttpClientProvider.overrideWithValue(client),
|
||||
mediaGetAuthServiceProvider.overrideWithValue(auth),
|
||||
],
|
||||
),
|
||||
);
|
||||
await _waitForImages(tester);
|
||||
await tester.pumpAndSettle();
|
||||
expect(requests, hasLength(1));
|
||||
expect(requests.single.url.toString(), url);
|
||||
expect(
|
||||
requests.single.headers['Authorization'],
|
||||
startsWith('Nostr '),
|
||||
);
|
||||
expect(find.text('Image unavailable'), findsNothing);
|
||||
expect(tester.takeException(), isNull);
|
||||
await tester.tap(
|
||||
find.byKey(ValueKey('message-media-image-preview:$url')),
|
||||
);
|
||||
await _waitForImages(tester);
|
||||
await tester.pumpAndSettle();
|
||||
expect(
|
||||
tester
|
||||
.widget<MediaImageViewerPage>(find.byType(MediaImageViewerPage))
|
||||
.imageUrl,
|
||||
url,
|
||||
);
|
||||
expect(find.text('Failed to load image'), findsNothing);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
for (final title in ['(Video title)', r'"A \"preview\""']) {
|
||||
for (final item in [
|
||||
('mp4', 'video/mp4'),
|
||||
('mov', 'video/quicktime'),
|
||||
('webm', 'video/webm'),
|
||||
('bin', 'video/mp4'),
|
||||
]) {
|
||||
testWidgets('${item.$1} uses video metadata with title $title', (
|
||||
tester,
|
||||
) async {
|
||||
final url = '$_base/media/clip (1).${item.$1}';
|
||||
final expected = '$_base/media/clip%20%281%29.${item.$1}';
|
||||
String? requested;
|
||||
await tester.pumpWidget(
|
||||
_app(
|
||||
'oops [ text ![clip \\[Q4\\]](<$url> $title)',
|
||||
tags: [
|
||||
['imeta', 'url $url', 'm ${item.$2}'],
|
||||
],
|
||||
overrides: [
|
||||
videoPreviewFrameLoaderProvider.overrideWithValue((url) async {
|
||||
requested = url;
|
||||
return null;
|
||||
}),
|
||||
],
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
expect(requested, expected);
|
||||
expect(
|
||||
find.byKey(ValueKey('message-media-video-preview:$expected')),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(find.text('Image unavailable'), findsNothing);
|
||||
});
|
||||
}
|
||||
|
||||
for (final (stem, angled) in [
|
||||
('report [Q4]', false),
|
||||
('report [Q4]', true),
|
||||
('report [Q4', true),
|
||||
('report Q4]', true),
|
||||
(r'report \ [Q4]', true),
|
||||
]) {
|
||||
for (final extension in ['pdf', 'txt', 'zip', 'mp3', 'svg']) {
|
||||
testWidgets(
|
||||
'$stem.$extension angled=$angled title $title opens with auth and filename',
|
||||
(tester) async {
|
||||
final url = '$_base/media/report.$extension';
|
||||
final blob = BlobDescriptor(
|
||||
url: url,
|
||||
sha256: 'a' * 64,
|
||||
size: 1,
|
||||
type: 'application/octet-stream',
|
||||
uploaded: 0,
|
||||
filename: '$stem.$extension',
|
||||
);
|
||||
final markdown = blob.toMarkdownImage().replaceFirst(
|
||||
'($url)',
|
||||
angled ? '(<$url> $title)' : '($url)',
|
||||
);
|
||||
String? opened;
|
||||
String? filename;
|
||||
Map<String, String>? headers;
|
||||
await tester.pumpWidget(
|
||||
_app(
|
||||
'${angled ? 'oops [ then ' : ''}$markdown',
|
||||
overrides: [
|
||||
mediaGetAuthServiceProvider.overrideWithValue(
|
||||
MediaGetAuthService(
|
||||
baseUrl: _base,
|
||||
nsec: nostr.Keys.generate().nsec,
|
||||
),
|
||||
),
|
||||
openDownloadedFileProvider.overrideWithValue((
|
||||
url,
|
||||
auth,
|
||||
name,
|
||||
) async {
|
||||
opened = url;
|
||||
headers = auth;
|
||||
filename = name;
|
||||
}),
|
||||
],
|
||||
),
|
||||
);
|
||||
await tester.tap(find.text('$stem.$extension'));
|
||||
await tester.pump();
|
||||
expect(opened, url);
|
||||
expect(filename, '$stem.$extension');
|
||||
expect(headers?['Authorization'], startsWith('Nostr '));
|
||||
expect(find.byType(MediaImage), findsNothing);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
testWidgets(
|
||||
'mixed angle/plain gallery retains URLs, metadata and viewer items',
|
||||
(tester) async {
|
||||
const first = '$_base/media/photo(1).png';
|
||||
const second = '$_base/media/photo2.png';
|
||||
const normalizedFirst = '$_base/media/photo%281%29.png';
|
||||
final client = MockClient(
|
||||
(_) async =>
|
||||
http.Response.bytes(base64Decode(mediaCodecFixtures['png']!), 200),
|
||||
);
|
||||
await tester.pumpWidget(
|
||||
_app(
|
||||
'oops [ text \n\n![second \\[Q4\\]]($second)',
|
||||
tags: [
|
||||
[
|
||||
'imeta',
|
||||
'url $first',
|
||||
'm image/png',
|
||||
'dim 2x2',
|
||||
'alt First photo',
|
||||
],
|
||||
['imeta', 'url $second', 'm image/png', 'dim 2x2'],
|
||||
],
|
||||
overrides: [mediaHttpClientProvider.overrideWithValue(client)],
|
||||
),
|
||||
);
|
||||
await _waitForImages(tester);
|
||||
await tester.pumpAndSettle();
|
||||
expect(
|
||||
find.byKey(const ValueKey('message-media-image-preview:$second')),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(find.text('2 images'), findsOneWidget);
|
||||
expect(find.bySemanticsLabel('Open second [Q4]'), findsOneWidget);
|
||||
expect(find.bySemanticsLabel('Open First photo'), findsOneWidget);
|
||||
expect(find.text('Image unavailable'), findsNothing);
|
||||
await tester.tap(
|
||||
find.byKey(
|
||||
const ValueKey('message-media-carousel-item:$normalizedFirst'),
|
||||
),
|
||||
);
|
||||
await _waitForImages(tester);
|
||||
await tester.pumpAndSettle();
|
||||
final viewer = tester.widget<MediaImageViewerPage>(
|
||||
find.byType(MediaImageViewerPage),
|
||||
);
|
||||
expect(viewer.galleryItems!.map((item) => item.url), [
|
||||
normalizedFirst,
|
||||
second,
|
||||
]);
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -30,6 +30,125 @@ void main() {
|
||||
);
|
||||
});
|
||||
|
||||
group('attachment destinations', () {
|
||||
const media = 'https://relay.example/media/image.png';
|
||||
for (final prefix in ['!', '']) {
|
||||
test('$prefix preserves angle destinations and optional titles', () {
|
||||
for (final destination in ['<$media>', media, '<$media> "Title"']) {
|
||||
expect(
|
||||
normalizeBareLinks('$prefix[label]($destination)'),
|
||||
'$prefix[label]($media)',
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
for (final title in [
|
||||
'(Video title)',
|
||||
r'"A \"preview\""',
|
||||
r"'A \'preview\''",
|
||||
r'(A \(preview\))',
|
||||
]) {
|
||||
for (final prefix in ['!', '']) {
|
||||
test('$prefix supports title $title', () {
|
||||
expect(
|
||||
normalizeBareLinks('$prefix[label](<$media> $title)'),
|
||||
'$prefix[label]($media)',
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
test('near-limit malformed openers complete without suffix rescans', () {
|
||||
// A generous wall-clock ceiling catches the original ~20 second freeze
|
||||
// while allowing slow CI hosts ample headroom for the linear scanner.
|
||||
for (final unit in ['[x](', '[', r'[x\[', r'[x\]']) {
|
||||
final input = unit * (256 * 1024 ~/ unit.length);
|
||||
final watch = Stopwatch()..start();
|
||||
final result = normalizeBareLinks(input);
|
||||
watch.stop();
|
||||
expect(result == input, isTrue);
|
||||
expect(watch.elapsed, lessThan(const Duration(seconds: 2)));
|
||||
}
|
||||
});
|
||||
for (final (label, rendered) in [
|
||||
(r'report \[Q4\].pdf', 'report [Q4].pdf'),
|
||||
(r'only \[ opening', 'only [ opening'),
|
||||
(r'only \] closing', 'only ] closing'),
|
||||
(
|
||||
r'backslash \\ and \[brackets\]',
|
||||
'backslash \ and [brackets]',
|
||||
),
|
||||
('nested [label]', 'nested [label]'),
|
||||
]) {
|
||||
for (final prefix in ['!', '']) {
|
||||
test('$prefix preserves escaped label $label', () {
|
||||
expect(
|
||||
normalizeBareLinks('$prefix[$label](<$media>)'),
|
||||
'$prefix[$rendered]($media)',
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
test('URL and title brackets do not capture a later attachment label', () {
|
||||
expect(
|
||||
normalizeBareLinks(
|
||||
'[first](<https://relay.example/file?q=[> "Title [") '
|
||||
r'[second \[Q4\]](<https://relay.example/file.pdf>)',
|
||||
),
|
||||
'[first](https://relay.example/file?q=[) '
|
||||
'[second [Q4]](https://relay.example/file.pdf)',
|
||||
);
|
||||
});
|
||||
for (final prose in ['oops [ then ', 'oops [[ text ', 'oops [x] [ ']) {
|
||||
for (final prefix in ['!', '']) {
|
||||
test('$prefix recovers after unmatched prose $prose', () {
|
||||
expect(
|
||||
normalizeBareLinks(
|
||||
'$prose$prefix[outer [inner] label](<$media>) '
|
||||
r'[report \[Q4\]](<https://relay.example/report.pdf>)',
|
||||
),
|
||||
'$prose$prefix[outer [inner] label]($media) '
|
||||
'[report [Q4]](https://relay.example/report.pdf)',
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
test('near-limit unmatched prose preserves every later attachment', () {
|
||||
const source = '[file](<https://relay.example/file.pdf>) ';
|
||||
const expected = '[file](https://relay.example/file.pdf) ';
|
||||
final count = (256 * 1024 - 2) ~/ source.length;
|
||||
final watch = Stopwatch()..start();
|
||||
final output = normalizeBareLinks('[ ${source * count}');
|
||||
watch.stop();
|
||||
expect(output == '[ ${expected * count}', isTrue);
|
||||
expect(watch.elapsed, lessThan(const Duration(seconds: 2)));
|
||||
});
|
||||
test('escapes spaces and parentheses without double-encoding URLs', () {
|
||||
const raw = 'https://relay.example/media/photo (1).png?q=a%20b&v=2';
|
||||
const encoded =
|
||||
'https://relay.example/media/photo%20%281%29.png?q=a%20b&v=2';
|
||||
expect(normalizeBareLinks(''), '');
|
||||
expect(normalizeBareLinks(''), '');
|
||||
expect(
|
||||
normalizeBareLinks('.png)'),
|
||||
'',
|
||||
);
|
||||
});
|
||||
test('normalizes prose surrounding multiple authored destinations', () {
|
||||
expect(
|
||||
normalizeBareLinks('See <$media>  [two](<$media>)'),
|
||||
'See [$media]($media)  [two]($media)',
|
||||
);
|
||||
});
|
||||
test('preserves attachment examples inside inline and fenced code', () {
|
||||
const example = '';
|
||||
expect(normalizeBareLinks('`$example`'), '`$example`');
|
||||
expect(
|
||||
normalizeBareLinks('```md\n$example\n```'),
|
||||
'```md\n$example\n```',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('code boundaries', () {
|
||||
final cases = <({String name, String input, String expected})>[
|
||||
(
|
||||
|
||||
26
mobile/test/helpers/media_codec_fixtures.dart
Normal file
26
mobile/test/helpers/media_codec_fixtures.dart
Normal file
@@ -0,0 +1,26 @@
|
||||
// Tiny locally generated images exercise real Flutter decoders.
|
||||
const mediaCodecFixtures = <String, String>{
|
||||
'png':
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAIAAAACCAIAAAD91JpzAAAAEklEQVR4nGOUm+bNwMDAxAAGAAvx'
|
||||
'AQN7uo8CAAAAAElFTkSuQmCC',
|
||||
'jpg':
|
||||
'/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAgGBgcGBQgHBwcJCQgKDBQNDAsLDBkSEw8UHRofHh0a'
|
||||
'HBwgJC4nICIsIxwcKDcpLDAxNDQ0Hyc5PTgyPC4zNDL/2wBDAQkJCQwLDBgNDRgyIRwhMjIyMjIy'
|
||||
'MjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjL/wAARCAACAAIDASIA'
|
||||
'AhEBAxEB/8QAHwAAAQUBAQEBAQEAAAAAAAAAAAECAwQFBgcICQoL/8QAtRAAAgEDAwIEAwUFBAQA'
|
||||
'AAF9AQIDAAQRBRIhMUEGE1FhByJxFDKBkaEII0KxwRVS0fAkM2JyggkKFhcYGRolJicoKSo0NTY3'
|
||||
'ODk6Q0RFRkdISUpTVFVWV1hZWmNkZWZnaGlqc3R1dnd4eXqDhIWGh4iJipKTlJWWl5iZmqKjpKWm'
|
||||
'p6ipqrKztLW2t7i5usLDxMXGx8jJytLT1NXW19jZ2uHi4+Tl5ufo6erx8vP09fb3+Pn6/8QAHwEA'
|
||||
'AwEBAQEBAQEBAQAAAAAAAAECAwQFBgcICQoL/8QAtREAAgECBAQDBAcFBAQAAQJ3AAECAxEEBSEx'
|
||||
'BhJBUQdhcRMiMoEIFEKRobHBCSMzUvAVYnLRChYkNOEl8RcYGRomJygpKjU2Nzg5OkNERUZHSElK'
|
||||
'U1RVVldYWVpjZGVmZ2hpanN0dXZ3eHl6goOEhYaHiImKkpOUlZaXmJmaoqOkpaanqKmqsrO0tba3'
|
||||
'uLm6wsPExcbHyMnK0tPU1dbX2Nna4uPk5ebn6Onq8vP09fb3+Pn6/9oADAMBAAIRAxEAPwDJooor'
|
||||
'gPjz/9k=',
|
||||
'gif': 'R0lGODdhAgACAIEAAB6WSwAAAAAAAAAAACwAAAAAAgACAAAIBgABCAQQEAA7',
|
||||
'webp':
|
||||
'UklGRjgAAABXRUJQVlA4ICwAAACwAQCdASoCAAIAAUAmJaACdLoABDAAAP7r3i9fAFPVH/00j/jS'
|
||||
'P+NI+U0AAA==',
|
||||
'bmp':
|
||||
'Qk1GAAAAAAAAADYAAAAoAAAAAgAAAAIAAAABABgAAAAAABAAAADEDgAAxA4AAAAAAAAAAAAAS5Ye'
|
||||
'S5YeAABLlh5Llh4AAA==',
|
||||
};
|
||||
Reference in New Issue
Block a user