feat: import Chinese-localized Buzz source snapshot
Docker image / Build (linux/amd64) (push) Has been cancelled
Docker image / Build (linux/arm64) (push) Has been cancelled
Docker image / Merge release multi-arch manifest (push) Has been cancelled
Docker image / Merge debug multi-arch manifest (push) Has been cancelled
Docker image / Build public push gateway (linux/amd64) (push) Has been cancelled
Docker image / Build public push gateway (linux/arm64) (push) Has been cancelled
Docker image / Publish public push gateway image (push) Has been cancelled
Sprig image / Build (linux/amd64) (push) Has been cancelled
Sprig image / Build (linux/arm64) (push) Has been cancelled
Sprig image / Merge multi-arch manifest (push) Has been cancelled
Harbor Buzz Orchestra / Python tests and lint (push) Has been cancelled
CI / Detect Changed Paths (push) Has been cancelled
CI / Rust Lint (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Desktop Core (push) Has been cancelled
CI / Desktop Smoke E2E (1) (push) Has been cancelled
CI / Desktop Smoke E2E (2) (push) Has been cancelled
CI / Desktop Smoke E2E (3) (push) Has been cancelled
CI / Desktop Smoke E2E (4) (push) Has been cancelled
CI / Desktop (push) Has been cancelled
CI / Desktop E2E Relay (push) Has been cancelled
CI / Desktop E2E Integration (1/2) (push) Has been cancelled
CI / Desktop E2E Integration (2/2) (push) Has been cancelled
CI / Desktop E2E Integration (push) Has been cancelled
CI / Backend Integration (relay e2e) (push) Has been cancelled
CI / Relay E2E (push) Has been cancelled
CI / Web (push) Has been cancelled
CI / Mobile (push) Has been cancelled
CI / Security (push) Has been cancelled
CI / Dead Token Reference Guard (push) Has been cancelled
CI / Server Cross-Compile (aarch64-unknown-linux-musl) (push) Has been cancelled
CI / Server Cross-Compile (x86_64-unknown-linux-musl) (push) Has been cancelled
CI / Windows Rust (x86_64-pc-windows-msvc) (push) Has been cancelled
CI / Desktop Build (macOS) (push) Has been cancelled
helm chart / lint + unittest + render matrix (push) Has been cancelled
helm chart / install on kind (gated) (push) Has been cancelled
helm chart / publish chart to GHCR (push) Has been cancelled
Mesh Lifecycle / Relay-Driven Mesh Lifecycle Smoke (push) Has been cancelled
Sprig / Build (aarch64-unknown-linux-musl) (push) Has been cancelled
Sprig / Build (x86_64-unknown-linux-musl) (push) Has been cancelled
Sprig / Publish rolling release (push) Has been cancelled
Sprig / Publish tagged release (push) Has been cancelled
Docker image / Build (linux/amd64) (push) Has been cancelled
Docker image / Build (linux/arm64) (push) Has been cancelled
Docker image / Merge release multi-arch manifest (push) Has been cancelled
Docker image / Merge debug multi-arch manifest (push) Has been cancelled
Docker image / Build public push gateway (linux/amd64) (push) Has been cancelled
Docker image / Build public push gateway (linux/arm64) (push) Has been cancelled
Docker image / Publish public push gateway image (push) Has been cancelled
Sprig image / Build (linux/amd64) (push) Has been cancelled
Sprig image / Build (linux/arm64) (push) Has been cancelled
Sprig image / Merge multi-arch manifest (push) Has been cancelled
Harbor Buzz Orchestra / Python tests and lint (push) Has been cancelled
CI / Detect Changed Paths (push) Has been cancelled
CI / Rust Lint (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Desktop Core (push) Has been cancelled
CI / Desktop Smoke E2E (1) (push) Has been cancelled
CI / Desktop Smoke E2E (2) (push) Has been cancelled
CI / Desktop Smoke E2E (3) (push) Has been cancelled
CI / Desktop Smoke E2E (4) (push) Has been cancelled
CI / Desktop (push) Has been cancelled
CI / Desktop E2E Relay (push) Has been cancelled
CI / Desktop E2E Integration (1/2) (push) Has been cancelled
CI / Desktop E2E Integration (2/2) (push) Has been cancelled
CI / Desktop E2E Integration (push) Has been cancelled
CI / Backend Integration (relay e2e) (push) Has been cancelled
CI / Relay E2E (push) Has been cancelled
CI / Web (push) Has been cancelled
CI / Mobile (push) Has been cancelled
CI / Security (push) Has been cancelled
CI / Dead Token Reference Guard (push) Has been cancelled
CI / Server Cross-Compile (aarch64-unknown-linux-musl) (push) Has been cancelled
CI / Server Cross-Compile (x86_64-unknown-linux-musl) (push) Has been cancelled
CI / Windows Rust (x86_64-pc-windows-msvc) (push) Has been cancelled
CI / Desktop Build (macOS) (push) Has been cancelled
helm chart / lint + unittest + render matrix (push) Has been cancelled
helm chart / install on kind (gated) (push) Has been cancelled
helm chart / publish chart to GHCR (push) Has been cancelled
Mesh Lifecycle / Relay-Driven Mesh Lifecycle Smoke (push) Has been cancelled
Sprig / Build (aarch64-unknown-linux-musl) (push) Has been cancelled
Sprig / Build (x86_64-unknown-linux-musl) (push) Has been cancelled
Sprig / Publish rolling release (push) Has been cancelled
Sprig / Publish tagged release (push) Has been cancelled
Signed-off-by: cls_宁波本机 <908705107@qq.com>
This commit is contained in:
@@ -0,0 +1,936 @@
|
||||
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/l10n/l10n.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 'media_viewer_page.dart';
|
||||
import 'message_media.dart';
|
||||
|
||||
part 'message_content/media_carousel.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 imetaByUrl = parseImetaTags(tags);
|
||||
final trailingGallery = maxLines == null
|
||||
? _extractTrailingImageGallery(
|
||||
content,
|
||||
imetaByUrl,
|
||||
context.l10n.messageImage,
|
||||
)
|
||||
: 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 finalContent = useMemoized(() {
|
||||
// Convert autolinks and bare URLs to standard markdown links,
|
||||
// but skip content inside backticks (inline code / fenced blocks).
|
||||
final buffer = StringBuffer();
|
||||
final parts = markdownContent.split('`');
|
||||
for (var i = 0; i < parts.length; i++) {
|
||||
if (i.isOdd) {
|
||||
// Inside backticks — preserve as-is.
|
||||
buffer.write('`${parts[i]}`');
|
||||
} else {
|
||||
// 1. Angle-bracket autolinks: <https://...>
|
||||
var segment = parts[i].replaceAllMapped(
|
||||
RegExp(r'<(https?://[^>]+)>'),
|
||||
(m) => '[${m[1]}](${m[1]})',
|
||||
);
|
||||
// 2. Bare URLs not already inside markdown link/image syntax.
|
||||
// Negative lookbehind avoids matching URLs preceded by ]( or =
|
||||
// which are already part of markdown links or imeta tags.
|
||||
segment = segment.replaceAllMapped(
|
||||
RegExp(r'(?<![(\]=])https?://[^\s)>\]]+'),
|
||||
(m) {
|
||||
final url = m[0]!;
|
||||
// Skip if this URL is already a markdown link label that equals
|
||||
// the URL (produced by step 1 or authored as [url](url)).
|
||||
final start = m.start;
|
||||
if (start >= 1 && segment[start - 1] == '[') return url;
|
||||
return '[$url]($url)';
|
||||
},
|
||||
);
|
||||
buffer.write(segment);
|
||||
}
|
||||
}
|
||||
final processed = buffer.toString();
|
||||
|
||||
// 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 = processed.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);
|
||||
}
|
||||
}
|
||||
final mentionProcessed = mentionBuf.toString();
|
||||
|
||||
// Ensure channel links at the very start of content don't get
|
||||
// swallowed by markdown processing.
|
||||
var result = mentionProcessed;
|
||||
if (RegExp(r'^#[A-Za-z0-9_]').hasMatch(result)) {
|
||||
result = '\u200B$result';
|
||||
}
|
||||
return result;
|
||||
}, [markdownContent, resolvedMentionNames]);
|
||||
|
||||
final markdown = KeyedSubtree(
|
||||
key: ValueKey('$finalContent\u0000$mentionPresentationKey'),
|
||||
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),
|
||||
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: channelNames,
|
||||
onChannelTap: onChannelTap,
|
||||
),
|
||||
...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 ?? context.l10n.messageImage,
|
||||
onReply: onMediaReply,
|
||||
onMore: onMediaMore,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildLink(
|
||||
BuildContext context,
|
||||
WidgetRef ref,
|
||||
InlineSpan linkText,
|
||||
String url,
|
||||
TextStyle linkStyle,
|
||||
TextStyle? fallbackStyle,
|
||||
) {
|
||||
String text = '';
|
||||
linkText.visitChildren((span) {
|
||||
if (span is TextSpan && span.text != null) {
|
||||
text += span.text!;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
final baseStyle = fallbackStyle ?? linkStyle;
|
||||
|
||||
return GestureDetector(
|
||||
onTap: () async {
|
||||
final uri = Uri.tryParse(url);
|
||||
if (uri == null || (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(
|
||||
SnackBar(content: Text(context.l10n.openAttachmentFailed)),
|
||||
);
|
||||
}
|
||||
},
|
||||
child: Text(
|
||||
text,
|
||||
style: baseStyle.copyWith(
|
||||
color: context.colors.primary,
|
||||
decoration: TextDecoration.underline,
|
||||
decorationColor: context.colors.primary,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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: context.l10n.imageUnavailable,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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: context.l10n.copiedCodeToClipboard,
|
||||
);
|
||||
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),
|
||||
] else
|
||||
Transform.translate(
|
||||
offset: const Offset(0, -Grid.quarter),
|
||||
child: Text('@', style: style),
|
||||
),
|
||||
Text(label, style: style),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ChannelLinkMd extends InlineMd {
|
||||
final Map<String, String> channelNames;
|
||||
final void Function(String channelId)? onChannelTap;
|
||||
late final RegExp _exp = _buildPrefixPattern(
|
||||
prefix: '#',
|
||||
knownNames: channelNames.keys,
|
||||
genericTokenPattern: r'[A-Za-z0-9_][A-Za-z0-9_-]*',
|
||||
);
|
||||
|
||||
_ChannelLinkMd({required this.channelNames, this.onChannelTap});
|
||||
|
||||
@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 channelId = channelNames[raw.substring(1).toLowerCase()];
|
||||
final child = _TokenPill(
|
||||
text: raw,
|
||||
textStyle: config.style?.copyWith(fontWeight: FontWeight.w500),
|
||||
);
|
||||
|
||||
return WidgetSpan(
|
||||
alignment: PlaceholderAlignment.baseline,
|
||||
baseline: TextBaseline.alphabetic,
|
||||
child: channelId != null && onChannelTap != null
|
||||
? GestureDetector(onTap: () => onChannelTap!(channelId), child: child)
|
||||
: child,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _TokenPill extends StatelessWidget {
|
||||
final String text;
|
||||
final TextStyle? textStyle;
|
||||
|
||||
const _TokenPill({required this.text, this.textStyle});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1),
|
||||
decoration: BoxDecoration(
|
||||
color: context.colors.primary.withValues(alpha: 0.15),
|
||||
borderRadius: BorderRadius.circular(Radii.sm),
|
||||
),
|
||||
child: Text(
|
||||
text,
|
||||
style:
|
||||
textStyle?.copyWith(color: context.colors.primary) ??
|
||||
context.textTheme.bodyMedium?.copyWith(
|
||||
color: context.colors.primary,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
RegExp _buildPrefixPattern({
|
||||
required String prefix,
|
||||
required Iterable<String> knownNames,
|
||||
required String genericTokenPattern,
|
||||
}) {
|
||||
final names =
|
||||
knownNames
|
||||
.map((name) => name.trim())
|
||||
.where((name) => name.isNotEmpty)
|
||||
.toSet()
|
||||
.toList()
|
||||
..sort((a, b) => b.length.compareTo(a.length));
|
||||
|
||||
final escapedPrefix = RegExp.escape(prefix);
|
||||
const leadingBoundary = r'(?<![\w./:-])';
|
||||
const trailingBoundary = r'(?=$|[\s,;.!?:)\]}])';
|
||||
|
||||
if (names.isEmpty) {
|
||||
return RegExp(
|
||||
'$leadingBoundary$escapedPrefix(?:$genericTokenPattern)$trailingBoundary',
|
||||
caseSensitive: false,
|
||||
multiLine: true,
|
||||
);
|
||||
}
|
||||
|
||||
final knownAlternatives = names.map(RegExp.escape).join('|');
|
||||
return RegExp(
|
||||
'$leadingBoundary$escapedPrefix(?:(?:$knownAlternatives)$trailingBoundary|(?:$genericTokenPattern)$trailingBoundary)',
|
||||
caseSensitive: false,
|
||||
multiLine: true,
|
||||
);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user