Files
buzz/mobile/lib/features/channels/message_content.dart
Wes 52aa37a929 perf(mobile): stop an open chat rebuilding on other channels' events (#8456)
## Summary

While a chat is open, a message in any other channel rebuilt every built
row of that chat. There were two causes:

- **The chat watched the whole channel list.** `channelsProvider`
republishes the list on every live event in any channel. The chat page,
its message list and the thread page watched all of it. They now watch
only their own channel's entry. The `#channel` name map comes from a new
`channelIdsByNameProvider`, which notifies only when a channel name or
id changes.
- **The root `App` rebuilt often, and each rebuild rebuilt every pushed
route.** It watched the agent observer and the user-status cache only to
keep them alive, and it watched the whole Me unread set. It now listens
to the first two and selects only whether Me has unread.

Rows still remount when a message arrives in the open channel. That
comes from `scrollable_positioned_list` keying rows by index, and it
gets its own PR.

## Before and after

Setup:
- iPhone 18 Pro simulator, debug build.
- Local relay with about 120 ms of added round-trip time.
- Seeded community: 120 channels, about 5.5k messages, a busy chat of
about 1.4k messages.
- The same scripted run on `main` at `5730c83f7` and on this branch,
counted with local probes that are not part of this PR.
- Counts are exact. Debug milliseconds are only useful for comparing the
two runs.

10 messages arrive in another channel while the busy chat is open:

| | `main` | this PR |
|---|---|---|
| Chat page rebuilds | 42 | 0 |
| Message row rebuilds | 1,218 | 0 |
| Frames with build time over 16.7 ms | 42 of 129 | 0 of 10 |
| Worst frame build time (debug) | 78 ms | 13 ms |

5 messages arrive in the open chat: 1,300 row rebuilds on `main`, 693 on
this PR. Most of the rest are the remounts from the index-keyed list.

## Test

A widget test publishes a new channel list where only another channel
changed, and fails if any `MessageContent` rebuilds. Reverting the
production change makes it fail.

## Validation

- `just mobile-check`
- Full mobile suite: 3,532 passed.
- The simulator run above.

**Human test:** Wes ran #8456, #8460 and #8462 together on the simulator
with his real account and confirmed they work.

buzz-review-completed

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Signed-off-by: Wes <wesbillman@users.noreply.github.com>
Co-authored-by: Carl <32a2e2c9d428ee08902cab75d956da2c1d235a22d4766b0dd4138bf6e2e5db1d@buzz.block.builderlab.xyz>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 16:09:52 -06:00

1042 lines
35 KiB
Dart

import 'dart:async';
import '../../shared/widgets/ios_media_carousel.dart';
import '../../shared/widgets/message_attachment_corners.dart';
import 'dart:io';
import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:gpt_markdown/gpt_markdown.dart';
import 'package:buzz/shared/theme/buzz_icons.dart';
import 'package:open_filex/open_filex.dart';
import 'package:path_provider/path_provider.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:video_player/video_player.dart';
import '../../shared/clipboard_utils.dart';
import '../../shared/widgets/media_loading_placeholder.dart';
import '../../shared/mentions/mention_bindings.dart';
import '../../shared/mentions/mention_tags.dart';
import '../../shared/deeplink/deep_link.dart';
import '../../shared/deeplink/pending_deep_link_provider.dart';
import '../../shared/relay/relay.dart';
import '../../shared/syntax_highlight.dart';
import '../../shared/theme/theme.dart';
import '../../shared/custom_emoji/custom_emoji.dart';
import '../../shared/custom_emoji/custom_emoji_provider.dart';
import '../../shared/custom_emoji/custom_emoji_render.dart';
import '../../shared/emoji/emoji_data_provider.dart';
import '../../shared/emoji/emoji_only.dart';
import 'channel_ids_by_name_provider.dart';
import 'local_send_bubble_surface.dart';
import 'media_viewer_page.dart';
import 'message_content/link_normalizer.dart';
import 'message_media.dart';
import 'message_content/bubble_segments.dart';
import 'message_mention_pill.dart';
import 'message_gallery.dart';
import 'message_gallery_frame.dart';
import 'message_media_avatar.dart';
import 'message_media_geometry.dart';
import 'voice_note_attachment.dart';
part 'message_content/media_carousel.dart';
part 'message_content/bubble_shape.dart';
part 'message_content/inline_components.dart';
part 'message_content/token_pill.dart';
part 'message_content/video_preview.dart';
typedef OpenDownloadedFile =
Future<void> Function(
String url,
Map<String, String> headers,
String filename,
);
final openDownloadedFileProvider = Provider<OpenDownloadedFile>((ref) {
final client = ref.watch(mediaHttpClientProvider);
return (url, headers, filename) async {
final response = await client.get(Uri.parse(url), headers: headers);
if (response.statusCode < 200 || response.statusCode >= 300) {
throw HttpException(
'Attachment download failed (${response.statusCode})',
uri: Uri.parse(url),
);
}
final directory = await getTemporaryDirectory();
final safeName = _safeDownloadedFilename(filename);
final file = File(
'${directory.path}${Platform.pathSeparator}'
'${DateTime.now().microsecondsSinceEpoch}-$safeName',
);
await file.writeAsBytes(response.bodyBytes, flush: true);
final result = await OpenFilex.open(file.path);
if (result.type != ResultType.done) {
throw FileSystemException(result.message, file.path);
}
};
});
String _safeDownloadedFilename(String filename) {
final safe = filename
.split(RegExp(r'[/\\]'))
.last
.replaceAll(RegExp(r'[\x00-\x1F\x7F]'), '')
.trim();
return safe.isEmpty ? 'attachment' : safe;
}
/// Renders message content with markdown formatting, @mentions, #channel links,
/// and media-aware markdown images/videos.
class MessageContent extends HookConsumerWidget {
final String content;
/// Paint conversation text bubbles while keeping attachments on the page.
final bool bubble;
/// Distinguishes messages authored by the signed-in identity.
final bool ownMessage;
/// Lets an adjacent day divider own spacing after the final text segment.
final bool trimTrailingSpacing;
/// Marks the final message in a sender run for rounded corners and dots.
final bool bubbleEnd;
/// Marks the first message in a sender run for rounded top corners.
final bool bubbleStart;
/// Reserves room above overlapping reactions.
final bool hasReactions;
/// Display names for mentioned pubkeys, extracted from event p-tags.
/// Keys are lowercase pubkeys, values are display names.
final Map<String, String> mentionNames;
/// Contextual display labels for mentioned pubkeys (lowercase keys).
///
/// Presentation only: text still binds through [mentionNames], so a
/// disambiguated label never changes which signed text names whom.
final Map<String, String> mentionLabels;
/// Mentioned pubkeys that resolve to agents. Agent chips use the desktop
/// robot treatment instead of an `@` prefix.
final Set<String> agentMentionPubkeys;
/// Known channel names for #channel links. Keys are lowercase channel
/// names, values are channel IDs.
final Map<String, String> channelNames;
/// Raw event tags, used for `imeta` media metadata lookups.
final List<List<String>> tags;
/// Called when a #channel link is tapped.
final void Function(String channelId)? onChannelTap;
/// Called when an @mention of a known user is tapped, with the
/// mentioned user's pubkey.
final void Function(String pubkey)? onMentionTap;
/// Opens the message's thread from the full-screen image viewer.
final VoidCallback? onMediaReply;
/// Opens message-specific actions for an image in the full-screen viewer.
final MediaViewerMoreAction? onMediaMore;
final TextStyle? baseStyle;
/// Alignment applied to rendered markdown text.
final TextAlign? textAlign;
final int? maxLines;
/// Render a body that is nothing but emoji at [kEmojiOnlyFontSize], the way
/// desktop's `MessageRow` does. Off by default: previews, search hits, and
/// notification rows want a message to occupy its usual line height whatever
/// it contains.
final bool scaleEmojiOnly;
/// Allows a multi-image carousel to reclaim leading space reserved by the
/// surrounding message layout, while keeping its image count aligned with
/// the message body.
final double mediaCarouselLeadingOverflow;
/// Allows a multi-image carousel to continue through the trailing page
/// gutter while keeping its first image and count aligned with the body.
final double mediaCarouselTrailingOverflow;
const MessageContent({
super.key,
required this.content,
this.bubble = false,
this.ownMessage = false,
this.trimTrailingSpacing = false,
this.bubbleEnd = true,
this.bubbleStart = true,
this.hasReactions = false,
this.mentionNames = const {},
this.mentionLabels = const {},
this.agentMentionPubkeys = const {},
this.channelNames = const {},
this.tags = const [],
this.onChannelTap,
this.onMentionTap,
this.onMediaReply,
this.onMediaMore,
this.baseStyle,
this.textAlign,
this.maxLines,
this.scaleEmojiOnly = false,
this.mediaCarouselLeadingOverflow = 0,
this.mediaCarouselTrailingOverflow = 0,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final markdownTheme = GptMarkdownTheme.of(context);
final chatColors = ChatColors.of(context);
final ownTextColor = chatColors.foreground;
// Desktop uses blue links on neutral bubbles and the bubble ink on colors.
final linkColor = ownMessage && ownTextColor == Colors.white
? ownTextColor
: context.colors.brightness == Brightness.dark
? const Color(0xFF83C4FF)
: const Color(0xFF0B5FA8);
final baseTextStyle =
(baseStyle ??
context.textTheme.bodyMedium?.copyWith(
color: context.colors.onSurface,
))
?.copyWith(color: bubble && ownMessage ? ownTextColor : null);
final resolvedMentionNames = mentionNames;
final signedMentionPubkeys = mentionedPubkeysFromTags(tags);
final mentionBindings = renderedMentionBindings(
content,
mentionNames,
signedMentionPubkeys,
);
final resolvedAgentMentionPubkeys = {
...agentMentionPubkeys.map((pubkey) => pubkey.toLowerCase()),
};
final resolvedChannelNames = channelNames.isNotEmpty
? channelNames
: ref.watch(channelIdsByNameProvider);
final channelHandler = useRef(onChannelTap)..value = onChannelTap;
final resolvedChannelTap = useMemoized(
() => (String channelId) {
final handler = channelHandler.value;
if (handler != null) {
handler(channelId);
} else {
ref
.read(pendingDeepLinkProvider.notifier)
.open(Uri(scheme: 'buzz', host: 'channel', path: channelId));
}
},
const [],
);
final replyHandler = useRef(onMediaReply)..value = onMediaReply;
final moreHandler = useRef(onMediaMore)..value = onMediaMore;
final mediaReply = useMemoized(
() =>
() => replyHandler.value?.call(),
const [],
);
final mediaMore = useMemoized(
() =>
(BuildContext context, String url) =>
moreHandler.value?.call(context, url),
const [],
);
final channelPresentationKey = [
for (final entry
in (resolvedChannelNames.entries.toList()
..sort((a, b) => a.key.compareTo(b.key))))
'${entry.key}\u0000${entry.value}',
].join('\u0001');
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(normalizedContent, imetaByUrl)
: null;
final markdownContent = trailingGallery?.content ?? normalizedContent;
final customEmoji = _mergeCustomEmoji(
customEmojiFromTags(tags),
ref.watch(customEmojiListProvider),
);
final mentionPresentationKey = [
for (final entry
in (resolvedMentionNames.entries.toList()
..sort((a, b) => a.key.compareTo(b.key))))
'${entry.key}\u0000${entry.value}',
...(resolvedAgentMentionPubkeys.toList()..sort()),
...(signedMentionPubkeys.toList()..sort()),
].join('\u0001');
// Decided here rather than by the caller: this is where the event's own
// emoji tags and the community palette have already been merged, and a
// `:shortcode:` only counts as emoji if it resolves against that palette.
final emojiOnly =
scaleEmojiOnly &&
isEmojiOnlyMessage(
markdownContent,
nativeEmoji: ref.watch(nativeEmojiGlyphsProvider),
customEmoji: customEmoji,
);
final style = emojiOnly
? baseTextStyle?.copyWith(
fontSize: kEmojiOnlyFontSize,
height: kEmojiOnlyHeight,
)
: baseTextStyle;
final inlineCustomEmojiSize = emojiOnly
? kEmojiOnlyCustomEmojiSize
: kCustomEmojiInlineSize;
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 = markdownContent.split('`');
final mentionBuf = StringBuffer();
for (var i = 0; i < mentionParts.length; i++) {
if (i.isOdd) {
mentionBuf.write('`${mentionParts[i]}`');
} else {
var segment = mentionParts[i];
for (final range in mentionOccurrences(
segment,
mentionBindings.keys,
).reversed) {
segment = segment.replaceRange(
range.start,
range.end,
'@${_markdownMentionName(range.label)}',
);
}
mentionBuf.write(segment);
}
}
var result = mentionBuf.toString();
// Ensure channel links at the very start of content don't get
// swallowed by markdown processing.
if (RegExp(r'^#[A-Za-z0-9_]').hasMatch(result)) {
result = '\u200B$result';
}
return result;
}, [markdownContent, mentionPresentationKey]);
final inlineComponents = _useMessageInlineComponents(
content: content,
finalContent: finalContent,
mentionNames: resolvedMentionNames,
mentionLabels: mentionLabels,
bindings: mentionBindings,
agentPubkeys: resolvedAgentMentionPubkeys,
channelNames: resolvedChannelNames,
customEmoji: customEmoji,
emojiSize: inlineCustomEmojiSize,
tags: tags,
hasMediaReply: onMediaReply != null,
hasMediaMore: onMediaMore != null,
onMentionTap: onMentionTap,
onChannelTap: resolvedChannelTap,
);
Widget renderMarkdown(String text) => KeyedSubtree(
key: ValueKey(
'$text\u0000$mentionPresentationKey\u0000$channelPresentationKey',
),
child: GptMarkdown(
text,
style: style,
followLinkColor: false,
// normalizeBareLinks() already turns bare URLs into Markdown links;
// gpt_markdown 1.2.0 autolinks by default, so both would run.
autolink: false,
codeBuilder: (context, name, code, closed) =>
_MessageCodeBlock(name: name, code: code),
linkBuilder: (context, linkText, url, linkStyle) => _buildLink(
context,
ref,
linkText,
url,
imetaByUrl[url],
linkStyle,
style,
resolvedChannelTap,
resolvedChannelNames,
),
imageBuilder: (context, imageUrl, _, _) => _buildMedia(
context,
imageUrl,
imetaByUrl[imageUrl],
onReply: onMediaReply == null ? null : mediaReply,
onMore: onMediaMore == null ? null : mediaMore,
),
textAlign: textAlign,
maxLines: maxLines,
inlineComponents: inlineComponents,
),
);
final segments = bubble
? messageBubbleSegments(finalContent, imetaByUrl)
: const <({String text, bool attachment})>[];
final markdown = bubble
? Column(
crossAxisAlignment: ownMessage
? CrossAxisAlignment.end
: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
for (final (index, segment) in segments.indexed)
if (segment.attachment)
MessageAttachmentCorners(
borderRadius: _attachmentCorners(
start: bubbleStart && index == 0,
end:
bubbleEnd &&
trailingGallery == null &&
index == segments.length - 1,
),
child: renderMarkdown(segment.text),
)
else
Padding(
key: ValueKey('message-text-bubble-$index'),
padding: EdgeInsets.only(
bottom:
trimTrailingSpacing &&
trailingGallery == null &&
index == segments.length - 1
? 0
: Grid.quarter,
),
child: LocalSendBubbleSurface(
color: ownMessage
? chatColors.outgoing
: context.colors.secondaryContainer,
shape: const _MessageBubbleShape(),
child: Padding(
padding: EdgeInsets.fromLTRB(
Grid.xs,
Grid.xxs,
Grid.xs,
hasReactions && index == segments.length - 1
? Grid.xs
: Grid.xxs,
),
child: Theme(
data: Theme.of(context).copyWith(
colorScheme: context.colors.copyWith(
primary: linkColor,
),
),
child: GptMarkdownTheme(
gptThemeData: markdownTheme.copyWith(
h1: markdownTheme.h1?.copyWith(
color: ownMessage
? ownTextColor
: context.colors.onSurface,
),
h2: markdownTheme.h2?.copyWith(
color: ownMessage
? ownTextColor
: context.colors.onSurface,
),
h3: markdownTheme.h3?.copyWith(
color: ownMessage
? ownTextColor
: context.colors.onSurface,
),
h4: markdownTheme.h4?.copyWith(
color: ownMessage
? ownTextColor
: context.colors.onSurface,
),
h5: markdownTheme.h5?.copyWith(
color: ownMessage
? ownTextColor
: context.colors.onSurface,
),
h6: markdownTheme.h6?.copyWith(
color: ownMessage
? ownTextColor
: context.colors.onSurface,
),
),
child: renderMarkdown(segment.text),
),
),
),
),
),
],
)
: renderMarkdown(finalContent);
if (trailingGallery == null) return markdown;
return Column(
crossAxisAlignment: ownMessage
? CrossAxisAlignment.end
: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
if (trailingGallery.content.trim().isNotEmpty) markdown,
MessageAttachmentCorners(
borderRadius: _attachmentCorners(
start: bubbleStart && trailingGallery.content.trim().isEmpty,
end: bubbleEnd,
),
child: _MessageImageCarousel(
key: ValueKey(
trailingGallery.items.map((item) => item.url).join('\u0000'),
),
items: trailingGallery.items,
mirrored: ownMessage,
leadingOverflow: mediaCarouselLeadingOverflow,
trailingOverflow: mediaCarouselTrailingOverflow,
onReply: onMediaReply,
onMore: onMediaMore,
),
),
],
);
}
Widget _buildMedia(
BuildContext context,
String imageUrl,
ImetaEntry? imeta, {
VoidCallback? onReply,
MediaViewerMoreAction? onMore,
}) {
final mediaKind = classifyMediaUrl(imageUrl, imeta: imeta);
if (mediaKind == MessageMediaKind.audio) {
return Padding(
padding: const EdgeInsets.only(top: Grid.half),
child: VoiceNoteAttachment.remote(
url: imageUrl,
duration: Duration(
milliseconds: ((imeta?.duration ?? 0) * 1000).round(),
),
),
);
}
if (mediaKind == MessageMediaKind.video) {
return _MessageVideoPreview(
url: imageUrl,
imeta: imeta,
onReply: onReply,
);
}
return _MessageImagePreview(
url: imageUrl,
imeta: imeta,
semanticLabel: imeta?.alt ?? 'Message image',
onReply: onReply,
onMore: onMore,
);
}
Widget _buildLink(
BuildContext context,
WidgetRef ref,
InlineSpan linkText,
String url,
ImetaEntry? imeta,
TextStyle linkStyle,
TextStyle? fallbackStyle,
void Function(String channelId) resolvedChannelTap,
Map<String, String> resolvedChannelNames,
) {
String text = '';
linkText.visitChildren((span) {
if (span is TextSpan && span.text != null) {
text += span.text!;
}
return true;
});
final baseStyle = fallbackStyle ?? linkStyle;
if (imeta != null &&
classifyMediaUrl(url, imeta: imeta) == MessageMediaKind.audio) {
return _buildMedia(context, url, imeta);
}
final uri = Uri.tryParse(url);
final buzzLink = uri?.scheme == 'buzz'
? parseBuzzDeepLink(uri!) ?? parseEntityDeepLink(uri)
: null;
final isBuzzLink =
buzzLink is ChannelDeepLink ||
buzzLink is MessageDeepLink ||
buzzLink is EntityDeepLink;
final isCanonicalBuzzLabel = isBuzzLink && text == url;
final buzzPresentation = switch (buzzLink) {
ChannelDeepLink(:final channelId) => (
icon: BuzzIcons.hash,
label:
_channelNameForId(resolvedChannelNames, channelId) ??
channelId.substring(0, math.min(8, channelId.length)),
semanticLabel:
'Open channel ${_channelNameForId(resolvedChannelNames, channelId) ?? channelId.substring(0, math.min(8, channelId.length))}',
interactive: true,
),
MessageDeepLink(:final channelId, :final messageId) => (
icon: BuzzIcons.messageSquare,
label:
'${_channelNameForId(resolvedChannelNames, channelId) ?? channelId.substring(0, math.min(8, channelId.length))} · ${messageId.substring(0, math.min(8, messageId.length))}',
semanticLabel:
'Open message ${messageId.substring(0, math.min(8, messageId.length))} in channel ${_channelNameForId(resolvedChannelNames, channelId) ?? channelId.substring(0, math.min(8, channelId.length))}',
interactive: true,
),
EntityDeepLink(:final type, :final repository, :final eventId) => (
icon: switch (type) {
'repo' => BuzzIcons.folderGit2,
'pr' => BuzzIcons.gitPullRequest,
_ => BuzzIcons.circleDot,
},
label: type == 'repo'
? repository
: '$repository · ${eventId!.substring(0, 8)}',
semanticLabel: switch (type) {
'repo' => 'Repository $repository',
'pr' =>
'Pull request ${eventId!.substring(0, 8)} in repository $repository',
_ => 'Issue ${eventId!.substring(0, 8)} in repository $repository',
},
interactive: false,
),
_ => null,
};
final authoredLinkStyle = baseStyle.copyWith(
color: context.colors.primary,
decoration: TextDecoration.underline,
decorationColor: context.colors.primary,
);
final linkTextWidget = isCanonicalBuzzLabel
? Text(
text,
style: baseStyle.copyWith(
color: context.colors.primary,
fontWeight: FontWeight.w600,
),
)
: Text.rich(TextSpan(style: authoredLinkStyle, children: [linkText]));
final renderedLink = isCanonicalBuzzLabel && buzzPresentation != null
? _TokenPill(
key: ValueKey('buzz-link-chip:$url'),
icon: buzzPresentation.icon,
interactive: buzzPresentation.interactive,
semanticLabel: buzzPresentation.semanticLabel,
text: buzzPresentation.label,
textStyle: baseStyle.copyWith(fontWeight: FontWeight.w600),
)
: linkTextWidget;
// Mobile has no repo/PR/issue destination yet. Keep these presentation-only
// instead of exposing a control whose tap cannot do anything.
if (buzzLink is EntityDeepLink) {
return IgnorePointer(child: renderedLink);
}
return GestureDetector(
onTap: () async {
final uri = Uri.tryParse(url);
if (uri == null) return;
// Rendered channel URLs must use the same callback as `#channel`
// references so detail-page callers can suppress self-navigation.
// Message and join links still need the top-level authenticated
// dispatcher.
if (uri.scheme == 'buzz') {
final deepLink = parseBuzzDeepLink(uri);
if (deepLink case ChannelDeepLink(:final channelId)) {
resolvedChannelTap(channelId);
} else if (deepLink is MessageDeepLink ||
deepLink is InviteDeepLink) {
ref.read(pendingDeepLinkProvider.notifier).open(uri);
}
return;
}
if (uri.scheme != 'http' && uri.scheme != 'https') return;
final auth = ref.read(mediaGetAuthServiceProvider);
if (!auth.isRelayMediaUrl(url)) {
await launchUrl(uri, mode: LaunchMode.externalApplication);
return;
}
try {
await ref.read(openDownloadedFileProvider)(
url,
auth.headersFor(url),
text,
);
} catch (_) {
if (!context.mounted) return;
ScaffoldMessenger.maybeOf(context)?.showSnackBar(
const SnackBar(content: Text('Could not open attachment')),
);
}
},
child: renderedLink,
);
}
}
List<CustomEmoji> _mergeCustomEmoji(
List<CustomEmoji> eventEmoji,
List<CustomEmoji> paletteEmoji,
) {
if (eventEmoji.isEmpty) return paletteEmoji;
if (paletteEmoji.isEmpty) return eventEmoji;
final seen = <String>{};
final merged = <CustomEmoji>[];
for (final emoji in [...eventEmoji, ...paletteEmoji]) {
if (seen.add(emoji.shortcode)) merged.add(emoji);
}
return merged;
}
class _MessageImagePreview extends HookConsumerWidget {
final String url;
final ImetaEntry? imeta;
final String semanticLabel;
final VoidCallback? onReply;
final MediaViewerMoreAction? onMore;
const _MessageImagePreview({
required this.url,
required this.imeta,
required this.semanticLabel,
required this.onReply,
required this.onMore,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final heroTag = useMemoized(() => Object());
final layout = _resolveImagePreviewLayout(context, imeta?.aspectRatio);
final previewDecodeWidth = layout.width ?? messageMediaMaxWidth(context);
return Padding(
padding: const EdgeInsets.only(top: Grid.half),
child: GestureDetector(
onTap: () => openImageViewer(
context,
imageUrl: url,
heroTag: heroTag,
semanticLabel: semanticLabel,
previewDecodeWidth: previewDecodeWidth,
aspectRatio: imeta?.aspectRatio,
onReply: onReply,
onMore: onMore,
),
child: _MessageMediaPreviewFrame(
previewKey: ValueKey('message-media-image-preview:$url'),
backgroundColor: context.colors.surfaceContainerHighest,
width: layout.width,
height: layout.height,
child: MediaViewerHero(
tag: heroTag,
child: ClipRRect(
borderRadius:
MessageAttachmentCorners.of(context) ??
BorderRadius.circular(Radii.md),
child: MediaImage(
url: url,
decodeWidth: previewDecodeWidth,
fit: layout.fit,
semanticLabel: semanticLabel,
frameBuilder: (context, child, frame, synchronous) =>
frame != null || synchronous
? child
: const MediaLoadingPlaceholder(label: 'Loading image'),
errorBuilder: (_, _, _) => _MediaPreviewFallback(
icon: BuzzIcons.imageOff,
label: 'Image unavailable',
),
),
),
),
),
),
);
}
}
class _MessageMediaPreviewFrame extends StatelessWidget {
final Key previewKey;
final Color backgroundColor;
final double? width;
final double? height;
final Widget child;
const _MessageMediaPreviewFrame({
required this.previewKey,
required this.backgroundColor,
this.width,
this.height,
required this.child,
});
@override
Widget build(BuildContext context) {
final resolvedWidth = width ?? messageMediaMaxWidth(context);
return Container(
key: previewKey,
width: resolvedWidth,
height: height,
clipBehavior: Clip.antiAlias,
decoration: BoxDecoration(
color: backgroundColor,
borderRadius:
MessageAttachmentCorners.of(context) ??
BorderRadius.circular(Radii.md),
border: Border.all(color: context.colors.outlineVariant),
),
child: child,
);
}
}
_ImagePreviewLayout _resolveImagePreviewLayout(
BuildContext context,
double? aspectRatio,
) {
final previewSize = messageImagePreviewSize(context, aspectRatio);
return _ImagePreviewLayout(
width: previewSize.width,
height: previewSize.height,
fit: aspectRatio == null ? BoxFit.contain : BoxFit.cover,
);
}
class _ImagePreviewLayout {
final double? width;
final double? height;
final BoxFit fit;
const _ImagePreviewLayout({this.width, this.height, required this.fit});
}
class _MediaPreviewFallback extends StatelessWidget {
final IconData icon;
final String label;
const _MediaPreviewFallback({required this.icon, required this.label});
@override
Widget build(BuildContext context) {
return ColoredBox(
color: context.colors.surfaceContainerHighest,
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, color: context.colors.onSurfaceVariant),
const SizedBox(height: Grid.quarter),
Text(
label,
style: context.textTheme.labelSmall?.copyWith(
color: context.colors.onSurfaceVariant,
),
textAlign: TextAlign.center,
),
],
),
),
);
}
}
class _MessageCodeBlock extends HookWidget {
final String name;
final String code;
const _MessageCodeBlock({required this.name, required this.code});
@override
Widget build(BuildContext context) {
final isCopied = useState(false);
Future<void> handleCopy() async {
await copyToClipboard(context, code, message: 'Copied code to clipboard');
if (!context.mounted) return;
isCopied.value = true;
Future.delayed(const Duration(seconds: 2), () {
if (context.mounted) isCopied.value = false;
});
}
final codeBaseStyle = TextStyle(
fontFamily: CodeStyle.fontFamily,
fontSize: CodeStyle.fontSize,
height: CodeStyle.lineHeight,
color: context.colors.onSurface,
);
final isDark = context.theme.brightness == Brightness.dark;
final codeTheme = isDark ? highlightDarkTheme : highlightLightTheme;
final codeSpans = useMemoized(
() => highlightCode(code, name, codeTheme, codeBaseStyle),
[code, name, isDark],
);
return Container(
margin: const EdgeInsets.only(top: Grid.half),
decoration: BoxDecoration(
color: CodeStyle.background(context.colors),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: CodeStyle.border(context.colors)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
if (name.isNotEmpty)
Padding(
padding: const EdgeInsets.only(
left: Grid.twelve,
top: Grid.half + Grid.quarter,
),
child: Text(
name,
style: context.textTheme.labelSmall?.copyWith(
color: context.colors.onSurfaceVariant,
),
),
),
Stack(
children: [
Padding(
padding: EdgeInsets.fromLTRB(
Grid.twelve,
name.isEmpty ? Grid.half + Grid.quarter : Grid.quarter,
44,
Grid.half + Grid.quarter,
),
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: RichText(
softWrap: false,
text: TextSpan(style: codeBaseStyle, children: codeSpans),
),
),
),
Positioned(
top: 0,
right: Grid.quarter,
child: SizedBox(
width: 28,
height: 28,
child: IconButton(
onPressed: handleCopy,
padding: EdgeInsets.zero,
visualDensity: VisualDensity.compact,
style: IconButton.styleFrom(
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
icon: Icon(
isCopied.value ? BuzzIcons.check : BuzzIcons.copy,
size: 14,
color: isCopied.value
? context.colors.primary
: context.colors.onSurfaceVariant,
),
),
),
),
],
),
],
),
);
}
}
class _MentionMd extends InlineMd {
final Map<String, Set<String>> bindings;
final Map<String, String> displayLabels;
final Map<String, String> mentionNames;
final Map<String, String> mentionLabels;
final Set<String> agentMentionPubkeys;
final void Function(String pubkey)? onMentionTap;
late final RegExp _exp = _buildPrefixPattern(
prefix: '@',
knownNames: bindings.keys.map(_markdownMentionName),
genericTokenPattern: r'[A-Za-z0-9_][A-Za-z0-9_\u00A0-]*',
);
_MentionMd({
required this.bindings,
required this.displayLabels,
required this.mentionNames,
required this.mentionLabels,
required this.agentMentionPubkeys,
this.onMentionTap,
});
@override
RegExp get exp => _exp;
@override
InlineSpan span(
BuildContext context,
String text,
final GptMarkdownConfig config,
) {
final raw = exp.firstMatch(text.trim())?.group(0);
if (raw == null) {
return TextSpan(text: text, style: config.style);
}
final name = raw.substring(1).replaceAll('\u00A0', ' ').toLowerCase();
final matches = bindings[name] ?? const <String>{};
final pubkey = matches.length == 1 ? matches.single : null;
if (bindings.containsKey(name) && matches.length != 1) {
return TextSpan(text: text, style: config.style);
}
final displayName = name.contains(RegExp(r'\([0-9a-f]{64}\)'))
? displayLabels[name]
: mentionLabels[pubkey] ?? mentionNames[pubkey];
final isAgent =
pubkey != null && agentMentionPubkeys.contains(pubkey.toLowerCase());
final fullLabel = displayName ?? raw.substring(1);
final visibleLabel = fullLabel.replaceAllMapped(
RegExp(r'\(([0-9a-f]{64})\)'),
(m) => '(${m[1]!.substring(0, 8)}…${m[1]!.substring(60)})',
);
final pill = MessageMentionPill(
label: visibleLabel,
semanticsLabel: fullLabel,
isAgent: isAgent,
textStyle: config.style,
);
return WidgetSpan(
alignment: PlaceholderAlignment.baseline,
baseline: TextBaseline.alphabetic,
child: pubkey != null && onMentionTap != null
? GestureDetector(onTap: () => onMentionTap!(pubkey), child: pill)
: pill,
);
}
}
String _markdownMentionName(String name) => name.replaceAll(' ', '\u00A0');