mirror of
https://github.com/block/buzz.git
synced 2026-11-05 17:01:35 +00:00
Follow-up to [#8925](https://github.com/block/buzz/pull/8925), from a [Codex finding](https://github.com/block/buzz/pull/8925#discussion_r4446048706) there. After #8925, message Markdown arrives with backslash escapes shielded, but imeta is keyed by plain URLs. Two lookups used the raw, still-escaped destination: - **Bubble segmentation** (`bubble_segments.dart`): an attachment link such as `[voice.mp4](https://…/voice\_note.mp4)` missed its imeta entry and stayed inside the text bubble, even though the link builder rendered it as an attachment. The fix unshields the URL before the lookup. - **Trailing image gallery** (`extractTrailingImageGallery` in `message_gallery.dart`): with two or more trailing images, an escaped destination kept its backslash, so the carousel loaded the wrong URL and lost the imeta alt text. The fix unescapes the destination the same way as the label beside it. Its callers in `message_media_avatar.dart` and `message_skeleton_body.dart` get the fix too. This gap was already on main. ### Validation - The existing `attachments stay outside bubbles and text order is preserved` journey now uses an escaped destination with a plain imeta URL. The existing carousel journey now has one escaped image destination and taps it by its plain URL. Each fails with its fix reverted. - `flutter analyze` is clean and the full mobile suite passes (4151 tests). - Limitation: no simulator run. Both changes are key lookups, and the widget journeys exercise the rendered layout. The full-app run for the surrounding escape handling is recorded in #8925. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Signed-off-by: Tom Brow <tomb@squareup.com> Co-authored-by: Tom Brow <tomb@squareup.com> Co-authored-by: studio <dc2fc481343672df31a689840c09299972e704dac592e0da6f18486646a7ee3e@buzz.block.builderlab.xyz> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
74 lines
2.1 KiB
Dart
74 lines
2.1 KiB
Dart
import 'package:flutter/foundation.dart';
|
|
|
|
import 'message_media.dart';
|
|
import 'message_content/link_normalizer.dart';
|
|
import 'message_content/markdown_escapes.dart';
|
|
|
|
/// Image metadata shared by carousel rendering and loading projection.
|
|
@immutable
|
|
class MessageGalleryItem {
|
|
final String url;
|
|
final String semanticLabel;
|
|
final double? aspectRatio;
|
|
|
|
const MessageGalleryItem({
|
|
required this.url,
|
|
required this.semanticLabel,
|
|
required this.aspectRatio,
|
|
});
|
|
}
|
|
|
|
/// A trailing run of image embeds and its preceding message text.
|
|
@immutable
|
|
class TrailingImageGallery {
|
|
final String content;
|
|
final List<MessageGalleryItem> items;
|
|
|
|
const TrailingImageGallery({required this.content, required this.items});
|
|
}
|
|
|
|
/// Groups trailing image lines using the production carousel policy.
|
|
TrailingImageGallery? extractTrailingImageGallery(
|
|
String content,
|
|
Map<String, ImetaEntry> imetaByUrl,
|
|
) {
|
|
final lines = content.split('\n');
|
|
var cursor = lines.length - 1;
|
|
while (cursor >= 0 && lines[cursor].trim().isEmpty) {
|
|
cursor -= 1;
|
|
}
|
|
|
|
final items = <MessageGalleryItem>[];
|
|
final imagePattern = RegExp(r'^!\[([^\]]*)\]\((https?://[^)\s]+)\)$');
|
|
while (cursor >= 0) {
|
|
final match = imagePattern.firstMatch(lines[cursor].trim());
|
|
if (match == null) break;
|
|
// CommonMark unescapes link destinations, as it does labels below.
|
|
final url = unshieldMarkdownEscapes(shieldMarkdownEscapes(match.group(2)!));
|
|
final imeta = imetaByUrl[url];
|
|
final mediaKind = classifyMediaUrl(url, imeta: imeta);
|
|
if (mediaKind != MessageMediaKind.image) {
|
|
break;
|
|
}
|
|
final markdownLabel = decodeMarkdownLabelSyntax(
|
|
unshieldMarkdownEscapes(shieldMarkdownEscapes(match.group(1)!)),
|
|
).trim();
|
|
items.add(
|
|
MessageGalleryItem(
|
|
url: url,
|
|
semanticLabel:
|
|
imeta?.alt ??
|
|
(markdownLabel.isNotEmpty ? markdownLabel : 'Message image'),
|
|
aspectRatio: imeta?.aspectRatio,
|
|
),
|
|
);
|
|
cursor -= 1;
|
|
}
|
|
|
|
if (items.length < 2) return null;
|
|
return TrailingImageGallery(
|
|
content: lines.take(cursor + 1).join('\n').trimRight(),
|
|
items: items.reversed.toList(),
|
|
);
|
|
}
|