Files
5ddf23d700 feat(mobile-messages): render compact Buzz permalink chips (#5639)
**Category:** improvement
**User Impact:** Buzz channel, message, repository, pull request, and
issue links now display recognizable context and navigate reliably in
the mobile app.
**Problem:** Bare Buzz permalinks appeared as raw or ambiguous URLs on
mobile, while channel and message links were not handled consistently
across Markdown forms and startup states.
**Solution:** Normalize eligible bare Buzz URLs without consuming
Markdown syntax, render them as semantic icon-prefixed chips, and route
channel/message targets through the mobile deep-link dispatcher while
preserving authored Markdown labels as ordinary links.

<details>
<summary>File changes</summary>

**mobile/lib/features/channels/deep_link_dispatcher.dart**
Routes parsed channel and message links through the appropriate in-app
navigation callbacks.

**mobile/lib/features/channels/message_content.dart**
Presents all bare Buzz permalinks as semantic icon chips and keeps
authored labels as ordinary links.

**mobile/lib/features/channels/message_content/link_normalizer.dart**
Normalizes bare and autolinked Buzz URLs without consuming Markdown
delimiters, code, or punctuation.

**mobile/lib/shared/deeplink/deep_link.dart**
Adds strict channel and project-entity parsing alongside message deep
links.

**mobile/lib/shared/deeplink/pending_deep_link_provider.dart**
Preserves pending navigation until the mobile routing surface is ready.

**mobile/test/features/channels/channel_detail_page_test.dart**
Updates navigation integration coverage for icon-prefixed channel chips.

**mobile/test/features/channels/deep_link_dispatcher_test.dart**
Covers channel/message dispatch and missing-target behavior.


**mobile/test/features/channels/message_content/link_normalizer_test.dart**
Exercises Markdown-safe normalization across the full Buzz link suite.

**mobile/test/features/channels/message_content_test.dart**
Verifies chip labels, icons, semantics, authored-label opt-out, and
navigation callbacks.

**mobile/test/shared/deeplink/deep_link_test.dart**
Covers strict parsing for channel, message, repository, pull-request,
and issue links.

</details>

## Reproduction steps
1. Run the mobile app and open a channel containing bare
`buzz://channel`, `buzz://message`, `buzz://repo`, `buzz://pr`, and
`buzz://issue` URLs.
2. Confirm each bare URL renders as one cohesive chip with a type icon,
a useful name or shortened identifier, and no duplicated channel `#`
character.
3. Add an authored Markdown link such as `[design
discussion](buzz://issue?...)` and confirm the supplied label remains an
ordinary link rather than becoming a chip.
4. Select channel and message links and confirm they navigate correctly
from inline and autolinked forms.

## Screenshots / demos
**iOS Simulator — channel, message, repository, pull request, and issue
permalink chips**

Real app build (`37b2cb5eb`) running on an iPhone 17 Pro simulator.

![Mobile permalink chips on iOS
Simulator](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/5639/mobile-permalink-chips-simulator.png)

---------

Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
Signed-off-by: Wes <wesbillman@users.noreply.github.com>
Co-authored-by: Carl <acda9e433d19dcd0e6b6840f7f4b98f3a56f1fab98049d444c087019e6d36560@buzz.block.builderlab.xyz>
Co-authored-by: Wes <wesbillman@users.noreply.github.com>
Co-authored-by: Carl <c7ebe626f000404285d3686e1dc74cc07cc60a9754a150041ba132e14bd3e2ec@buzz.block.builderlab.xyz>
2026-08-14 10:44:58 -07:00

921 lines
29 KiB
Dart

import 'dart:async';
import 'dart:io';
import 'dart:math' as math;
import 'package:flutter/material.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:gpt_markdown/custom_widgets/markdown_config.dart';
import 'package:lucide_icons_flutter/lucide_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/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 'channels_provider.dart';
import 'media_viewer_page.dart';
import 'message_content/link_normalizer.dart';
import 'message_media.dart';
part 'message_content/media_carousel.dart';
part 'message_content/token_pill.dart';
part 'message_content/video_preview.dart';
const _messageMediaMaxInlineWidth = 320.0;
const _messageMediaMaxImageHeight = 240.0;
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;
/// Display names for mentioned pubkeys, extracted from event p-tags.
/// Keys are lowercase pubkeys, values are display names.
final Map<String, String> mentionNames;
/// 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.mentionNames = 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 baseTextStyle =
baseStyle ??
context.textTheme.bodyMedium?.copyWith(color: context.colors.onSurface);
final resolvedMentionNames = mentionNames;
final resolvedAgentMentionPubkeys = {
...agentMentionPubkeys.map((pubkey) => pubkey.toLowerCase()),
};
final resolvedChannelNames = channelNames.isNotEmpty
? channelNames
: <String, String>{
for (final channel
in ref.watch(channelsProvider).asData?.value ?? const [])
channel.name.toLowerCase(): channel.id,
};
final resolvedChannelTap =
onChannelTap ??
(String channelId) {
ref
.read(pendingDeepLinkProvider.notifier)
.open(Uri(scheme: 'buzz', host: 'channel', path: channelId));
};
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 = parseImetaTags(tags);
final trailingGallery = maxLines == null
? _extractTrailingImageGallery(content, imetaByUrl)
: null;
final markdownContent = trailingGallery?.content ?? content;
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()),
].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 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 mentionBuf = StringBuffer();
for (var i = 0; i < mentionParts.length; i++) {
if (i.isOdd) {
mentionBuf.write('`${mentionParts[i]}`');
} else {
var segment = mentionParts[i];
for (final name in resolvedMentionNames.values) {
if (name.contains(' ')) {
final normalizedName = _markdownMentionName(name);
segment = segment.replaceAllMapped(
RegExp('@${RegExp.escape(name)}', caseSensitive: false),
(m) => '@$normalizedName',
);
}
}
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;
}, [linkNormalizedContent, resolvedMentionNames]);
final markdown = KeyedSubtree(
key: ValueKey(
'$finalContent\u0000$mentionPresentationKey\u0000$channelPresentationKey',
),
child: GptMarkdown(
finalContent,
style: style,
followLinkColor: false,
codeBuilder: (context, name, code, closed) =>
_MessageCodeBlock(name: name, code: code),
linkBuilder: (context, linkText, url, linkStyle) => _buildLink(
context,
ref,
linkText,
url,
linkStyle,
style,
resolvedChannelTap,
resolvedChannelNames,
),
imageBuilder: (context, imageUrl) =>
_buildMedia(context, imageUrl, imetaByUrl[imageUrl]),
textAlign: textAlign,
maxLines: maxLines,
inlineComponents: [
_MentionMd(
mentionNames: resolvedMentionNames,
agentMentionPubkeys: resolvedAgentMentionPubkeys,
onMentionTap: onMentionTap,
),
CustomEmojiMd(customEmoji, size: inlineCustomEmojiSize),
_ChannelLinkMd(
channelNames: resolvedChannelNames,
onChannelTap: resolvedChannelTap,
),
...MarkdownComponent.inlineComponents,
],
),
);
if (trailingGallery == null) return markdown;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
if (trailingGallery.content.trim().isNotEmpty) markdown,
_MessageImageCarousel(
key: ValueKey(
trailingGallery.items.map((item) => item.url).join('\u0000'),
),
items: trailingGallery.items,
leadingOverflow: mediaCarouselLeadingOverflow,
trailingOverflow: mediaCarouselTrailingOverflow,
onReply: onMediaReply,
onMore: onMediaMore,
),
],
);
}
Widget _buildMedia(BuildContext context, String imageUrl, ImetaEntry? imeta) {
final mediaKind = classifyMediaUrl(imageUrl, imeta: imeta);
if (mediaKind == MessageMediaKind.video) {
return _MessageVideoPreview(
url: imageUrl,
imeta: imeta,
onReply: onMediaReply,
);
}
return _MessageImagePreview(
url: imageUrl,
imeta: imeta,
semanticLabel: imeta?.alt ?? 'Message image',
onReply: onMediaReply,
onMore: onMediaMore,
);
}
Widget _buildLink(
BuildContext context,
WidgetRef ref,
InlineSpan linkText,
String url,
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;
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: LucideIcons.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: LucideIcons.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' => LucideIcons.folderGit2,
'pr' => LucideIcons.gitPullRequest,
_ => LucideIcons.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,
constraints: layout.constraints,
child: MediaViewerHero(
tag: heroTag,
child: ClipRRect(
borderRadius: BorderRadius.circular(Radii.md),
child: MediaImage(
url: url,
decodeWidth: previewDecodeWidth,
fit: layout.fit,
semanticLabel: semanticLabel,
errorBuilder: (_, _, _) => _MediaPreviewFallback(
icon: LucideIcons.imageOff,
label: 'Image unavailable',
),
),
),
),
),
),
);
}
}
class _MessageMediaPreviewFrame extends StatelessWidget {
final Key previewKey;
final Color backgroundColor;
final double? width;
final double? height;
final BoxConstraints? constraints;
final Widget child;
const _MessageMediaPreviewFrame({
required this.previewKey,
required this.backgroundColor,
this.width,
this.height,
this.constraints,
required this.child,
});
@override
Widget build(BuildContext context) {
final resolvedWidth = constraints == null
? (width ?? _messageMediaMaxWidth(context))
: width;
return Container(
key: previewKey,
width: resolvedWidth,
height: height,
constraints: constraints,
clipBehavior: Clip.antiAlias,
decoration: BoxDecoration(
color: backgroundColor,
borderRadius: BorderRadius.circular(Radii.md),
border: Border.all(color: context.colors.outlineVariant),
),
child: child,
);
}
}
double _messageMediaMaxWidth(BuildContext context) {
return math
.min(MediaQuery.sizeOf(context).width * 0.72, _messageMediaMaxInlineWidth)
.toDouble();
}
_ImagePreviewLayout _resolveImagePreviewLayout(
BuildContext context,
double? aspectRatio,
) {
if (aspectRatio == null) {
return _ImagePreviewLayout(
constraints: BoxConstraints(
maxWidth: _messageMediaMaxWidth(context),
maxHeight: _messageMediaMaxImageHeight,
),
fit: BoxFit.contain,
);
}
final previewSize = _imagePreviewSize(context, aspectRatio);
return _ImagePreviewLayout(
width: previewSize.width,
height: previewSize.height,
fit: BoxFit.cover,
);
}
Size _imagePreviewSize(BuildContext context, double? aspectRatio) {
final maxWidth = _messageMediaMaxWidth(context);
final safeAspectRatio = (aspectRatio ?? 1.0).clamp(0.2, 4.0).toDouble();
var width = maxWidth;
var height = width / safeAspectRatio;
if (height > _messageMediaMaxImageHeight) {
height = _messageMediaMaxImageHeight;
width = height * safeAspectRatio;
}
return Size(width, height);
}
class _ImagePreviewLayout {
final double? width;
final double? height;
final BoxConstraints? constraints;
final BoxFit fit;
const _ImagePreviewLayout({
this.width,
this.height,
this.constraints,
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: 'GeistMono',
fontSize: 13,
height: 1.5,
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: context.colors.surfaceContainerHighest.withValues(alpha: 0.6),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: context.colors.outline.withValues(alpha: 0.7),
),
),
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 ? LucideIcons.check : LucideIcons.copy,
size: 14,
color: isCopied.value
? context.colors.primary
: context.colors.onSurfaceVariant,
),
),
),
),
],
),
],
),
);
}
}
class _MentionMd extends InlineMd {
final Map<String, String> mentionNames;
final Set<String> agentMentionPubkeys;
final void Function(String pubkey)? onMentionTap;
late final RegExp _exp = _buildPrefixPattern(
prefix: '@',
knownNames: _mentionAliases(mentionNames.values),
genericTokenPattern: r'[A-Za-z0-9_][A-Za-z0-9_\u00A0-]*',
);
_MentionMd({
required this.mentionNames,
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();
String? displayName;
String? pubkey;
for (final entry in mentionNames.entries) {
final entryName = entry.value.toLowerCase();
final firstName = entryName.split(RegExp(r'\s+')).first;
if (entryName == name || firstName == name) {
displayName = entry.value;
pubkey = entry.key;
break;
}
}
final isAgent =
pubkey != null && agentMentionPubkeys.contains(pubkey.toLowerCase());
final pill = _MentionPill(
label: displayName ?? raw.substring(1),
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,
);
}
}
class _MentionPill extends StatelessWidget {
final String label;
final bool isAgent;
final TextStyle? textStyle;
const _MentionPill({
required this.label,
required this.isAgent,
this.textStyle,
});
@override
Widget build(BuildContext context) {
final style =
(textStyle ?? context.textTheme.bodyMedium)?.copyWith(
color: context.colors.primary,
fontWeight: FontWeight.w500,
height: 1,
) ??
TextStyle(
color: context.colors.primary,
fontWeight: FontWeight.w500,
height: 1,
);
final fontSize = style.fontSize ?? 16;
return Container(
padding: const EdgeInsets.fromLTRB(
Grid.half,
Grid.quarter + 1,
Grid.half,
Grid.quarter,
),
decoration: BoxDecoration(
color: context.colors.primary.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(Radii.sm),
),
child: Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
if (isAgent) ...[
Icon(
LucideIcons.bot,
size: fontSize * 0.95,
color: context.colors.primary,
),
const SizedBox(width: Grid.quarter + 1),
] else
Transform.translate(
offset: const Offset(0, -Grid.quarter),
child: Text('@', style: style),
),
Text(label, style: style),
],
),
);
}
}
String _markdownMentionName(String name) => name.replaceAll(' ', '\u00A0');
Iterable<String> _mentionAliases(Iterable<String> mentionNames) sync* {
for (final name in mentionNames) {
final trimmed = name.trim();
if (trimmed.isEmpty) continue;
yield _markdownMentionName(trimmed);
final firstName = trimmed.split(RegExp(r'\s+')).first;
if (firstName.isNotEmpty) {
yield firstName;
}
}
}