Files
buzz/mobile/lib/features/channels/message_content/inline_components.dart
klopez4212 1ac0a1271b 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>
2026-10-08 14:05:46 -04:00

170 lines
5.9 KiB
Dart

part of '../message_content.dart';
List<MarkdownComponent> _useMessageInlineComponents({
required String content,
required String finalContent,
required Map<String, String> mentionNames,
required Map<String, String> mentionLabels,
required Map<String, Set<String>> bindings,
required Set<String> agentPubkeys,
required Map<String, String> channelNames,
required List<CustomEmoji> customEmoji,
required double emojiSize,
required List<List<String>> tags,
required bool hasMediaReply,
required bool hasMediaMore,
required void Function(String)? onMentionTap,
required void Function(String) onChannelTap,
}) {
// Parsed spans retain these callbacks. Forward to the current handlers without
// invalidating the parser for a new closure from an otherwise unchanged row.
final mentionHandler = useRef(onMentionTap)..value = onMentionTap;
final channelHandler = useRef(onChannelTap)..value = onChannelTap;
final mentionTap = useMemoized(
() =>
(String pubkey) => mentionHandler.value?.call(pubkey),
const [],
);
final channelTap = useMemoized(
() =>
(String channelId) => channelHandler.value(channelId),
const [],
);
final inputs = _InlineComponentInputs(
content: content,
finalContent: finalContent,
mentionNames: mentionNames,
mentionLabels: mentionLabels,
bindings: bindings,
agentPubkeys: agentPubkeys,
channelNames: channelNames,
customEmoji: customEmoji,
emojiSize: emojiSize,
tags: tags,
hasMediaReply: hasMediaReply,
hasMediaMore: hasMediaMore,
hasMentionHandler: onMentionTap != null,
);
// gpt_markdown compares components by identity. Recreating them on every
// parent rebuild reparses unchanged message bodies on the UI isolate.
return useMemoized(
() => [
_MentionMd(
mentionNames: inputs.mentionNames,
mentionLabels: inputs.mentionLabels,
bindings: inputs.bindings,
displayLabels: {
for (final range in mentionOccurrences(content, bindings.keys))
range.label: content.substring(range.start + 1, range.end),
},
agentMentionPubkeys: inputs.agentPubkeys,
onMentionTap: inputs.hasMentionHandler ? mentionTap : null,
),
CustomEmojiMd(inputs.customEmoji, content: finalContent, size: emojiSize),
_ChannelLinkMd(
channelNames: inputs.channelNames,
onChannelTap: channelTap,
),
_MarkdownLabelEntityMd(),
for (final component in MarkdownComponent.inlineComponents)
component is ATagMd ? _MessageLinkMd() : component,
],
[inputs],
);
}
class _InlineComponentInputs {
final String content;
final String finalContent;
final Map<String, String> mentionNames;
final Map<String, String> mentionLabels;
final Map<String, Set<String>> bindings;
final Set<String> agentPubkeys;
final Map<String, String> channelNames;
final List<CustomEmoji> customEmoji;
final double emojiSize;
final bool hasMentionHandler;
final List<List<String>> tags;
final bool hasMediaReply;
final bool hasMediaMore;
_InlineComponentInputs({
required this.content,
required this.finalContent,
required Map<String, String> mentionNames,
required Map<String, String> mentionLabels,
required Map<String, Set<String>> bindings,
required Set<String> agentPubkeys,
required Map<String, String> channelNames,
required List<CustomEmoji> customEmoji,
required this.emojiSize,
required this.hasMentionHandler,
required List<List<String>> tags,
required this.hasMediaReply,
required this.hasMediaMore,
}) : tags = List.unmodifiable(
tags.map((tag) => List<String>.unmodifiable(tag)),
),
mentionNames = Map.unmodifiable(mentionNames),
mentionLabels = Map.unmodifiable(mentionLabels),
bindings = Map.unmodifiable({
for (final entry in bindings.entries)
entry.key: Set<String>.unmodifiable(entry.value),
}),
agentPubkeys = Set.unmodifiable(agentPubkeys),
channelNames = Map.unmodifiable(channelNames),
customEmoji = List.unmodifiable(customEmoji);
@override
bool operator ==(Object other) =>
other is _InlineComponentInputs &&
content == other.content &&
finalContent == other.finalContent &&
emojiSize == other.emojiSize &&
hasMentionHandler == other.hasMentionHandler &&
hasMediaReply == other.hasMediaReply &&
hasMediaMore == other.hasMediaMore &&
tags.length == other.tags.length &&
tags.indexed.every(
(entry) => listEquals(entry.$2, other.tags[entry.$1]),
) &&
mapEquals(mentionNames, other.mentionNames) &&
mapEquals(mentionLabels, other.mentionLabels) &&
mapEquals(channelNames, other.channelNames) &&
setEquals(agentPubkeys, other.agentPubkeys) &&
listEquals(customEmoji, other.customEmoji) &&
bindings.length == other.bindings.length &&
bindings.entries.every(
(entry) => setEquals(entry.value, other.bindings[entry.key]),
);
@override
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]*\)');
}