mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
538 lines
15 KiB
Dart
538 lines
15 KiB
Dart
import 'dart:math' as math;
|
|
|
|
import 'package:flutter/material.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:url_launcher/url_launcher.dart';
|
|
|
|
import '../../shared/theme/theme.dart';
|
|
import 'media_viewer_page.dart';
|
|
import 'message_media.dart';
|
|
|
|
const _messageMediaMaxInlineWidth = 320.0;
|
|
const _messageMediaMaxImageHeight = 240.0;
|
|
|
|
/// Renders message content with markdown formatting, @mentions, #channel links,
|
|
/// and media-aware markdown images/videos.
|
|
class MessageContent extends StatelessWidget {
|
|
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;
|
|
|
|
/// 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;
|
|
|
|
final TextStyle? baseStyle;
|
|
|
|
const MessageContent({
|
|
super.key,
|
|
required this.content,
|
|
this.mentionNames = const {},
|
|
this.channelNames = const {},
|
|
this.tags = const [],
|
|
this.onChannelTap,
|
|
this.baseStyle,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final style =
|
|
baseStyle ??
|
|
context.textTheme.bodyMedium?.copyWith(color: context.colors.onSurface);
|
|
final imetaByUrl = parseImetaTags(tags);
|
|
|
|
return GptMarkdown(
|
|
content,
|
|
style: style,
|
|
followLinkColor: false,
|
|
linkBuilder: (context, linkText, url, linkStyle) =>
|
|
_buildLink(context, linkText, url, linkStyle, style),
|
|
imageBuilder: (context, imageUrl) =>
|
|
_buildMedia(context, imageUrl, imetaByUrl[imageUrl]),
|
|
inlineComponents: [
|
|
_MentionMd(mentionNames: mentionNames),
|
|
_ChannelLinkMd(channelNames: channelNames, onChannelTap: onChannelTap),
|
|
...MarkdownComponent.inlineComponents,
|
|
],
|
|
);
|
|
}
|
|
|
|
Widget _buildMedia(BuildContext context, String imageUrl, ImetaEntry? imeta) {
|
|
final mediaKind = classifyMediaUrl(imageUrl, imeta: imeta);
|
|
if (mediaKind == MessageMediaKind.video) {
|
|
return _MessageVideoPreview(url: imageUrl, imeta: imeta);
|
|
}
|
|
return _MessageImagePreview(
|
|
url: imageUrl,
|
|
imeta: imeta,
|
|
semanticLabel: imeta?.alt ?? 'Message image',
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Link builder (URL scheme validation)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
Widget _buildLink(
|
|
BuildContext context,
|
|
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: () {
|
|
final uri = Uri.tryParse(url);
|
|
if (uri != null && (uri.scheme == 'http' || uri.scheme == 'https')) {
|
|
launchUrl(uri, mode: LaunchMode.externalApplication);
|
|
}
|
|
},
|
|
child: Text(
|
|
text,
|
|
style: baseStyle.copyWith(
|
|
color: context.colors.primary,
|
|
decoration: TextDecoration.underline,
|
|
decorationColor: context.colors.primary,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _MessageImagePreview extends StatefulWidget {
|
|
final String url;
|
|
final ImetaEntry? imeta;
|
|
final String semanticLabel;
|
|
|
|
const _MessageImagePreview({
|
|
required this.url,
|
|
required this.imeta,
|
|
required this.semanticLabel,
|
|
});
|
|
|
|
@override
|
|
State<_MessageImagePreview> createState() => _MessageImagePreviewState();
|
|
}
|
|
|
|
class _MessageImagePreviewState extends State<_MessageImagePreview> {
|
|
late final Object _heroTag = Object();
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final layout = _resolveImagePreviewLayout(
|
|
context,
|
|
widget.imeta?.aspectRatio,
|
|
);
|
|
|
|
return Padding(
|
|
padding: const EdgeInsets.only(top: Grid.half),
|
|
child: GestureDetector(
|
|
onTap: () => openImageViewer(
|
|
context,
|
|
imageUrl: widget.url,
|
|
heroTag: _heroTag,
|
|
semanticLabel: widget.semanticLabel,
|
|
),
|
|
child: _MessageMediaPreviewFrame(
|
|
previewKey: ValueKey('message-media-image-preview:${widget.url}'),
|
|
backgroundColor: context.colors.surfaceContainerHighest,
|
|
width: layout.width,
|
|
height: layout.height,
|
|
constraints: layout.constraints,
|
|
child: Hero(
|
|
tag: _heroTag,
|
|
child: Image.network(
|
|
widget.url,
|
|
fit: layout.fit,
|
|
semanticLabel: widget.semanticLabel,
|
|
errorBuilder: (_, _, _) => _MediaPreviewFallback(
|
|
icon: LucideIcons.imageOff,
|
|
label: 'Image unavailable',
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _MessageVideoPreview extends StatelessWidget {
|
|
final String url;
|
|
final ImetaEntry? imeta;
|
|
|
|
const _MessageVideoPreview({required this.url, required this.imeta});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final rawAspectRatio = imeta?.aspectRatio ?? (16 / 9);
|
|
final aspectRatio = rawAspectRatio.clamp(0.75, 1.91);
|
|
final posterUrl = imeta?.posterUrl;
|
|
|
|
return Padding(
|
|
padding: const EdgeInsets.only(top: Grid.half),
|
|
child: GestureDetector(
|
|
onTap: () =>
|
|
openVideoViewer(context, videoUrl: url, posterUrl: posterUrl),
|
|
child: _MessageMediaPreviewFrame(
|
|
previewKey: ValueKey('message-media-video-preview:$url'),
|
|
backgroundColor: Colors.black,
|
|
child: AspectRatio(
|
|
aspectRatio: aspectRatio.toDouble(),
|
|
child: Stack(
|
|
fit: StackFit.expand,
|
|
children: [
|
|
if (posterUrl != null)
|
|
Image.network(
|
|
posterUrl,
|
|
fit: BoxFit.cover,
|
|
errorBuilder: (_, _, _) => const _MediaPreviewFallback(
|
|
icon: LucideIcons.video,
|
|
label: 'Video preview unavailable',
|
|
),
|
|
)
|
|
else
|
|
const _MediaPreviewFallback(
|
|
icon: LucideIcons.video,
|
|
label: 'Video attachment',
|
|
),
|
|
const ColoredBox(color: Color.fromRGBO(0, 0, 0, 0.28)),
|
|
Center(
|
|
child: Container(
|
|
width: 52,
|
|
height: 52,
|
|
decoration: const BoxDecoration(
|
|
color: Color.fromRGBO(0, 0, 0, 0.6),
|
|
shape: BoxShape.circle,
|
|
),
|
|
child: const Icon(
|
|
LucideIcons.play,
|
|
color: Colors.white,
|
|
size: 24,
|
|
),
|
|
),
|
|
),
|
|
Positioned(
|
|
left: Grid.xxs,
|
|
right: Grid.xxs,
|
|
bottom: Grid.xxs,
|
|
child: Text(
|
|
'Video',
|
|
style: context.textTheme.labelSmall?.copyWith(
|
|
color: Colors.white,
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
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 _MentionMd extends InlineMd {
|
|
final Map<String, String> mentionNames;
|
|
late final RegExp _exp = _buildPrefixPattern(
|
|
prefix: '@',
|
|
knownNames: _mentionAliases(mentionNames.values),
|
|
genericTokenPattern: r'[A-Za-z0-9_][A-Za-z0-9_-]*',
|
|
);
|
|
|
|
_MentionMd({required this.mentionNames});
|
|
|
|
@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).toLowerCase();
|
|
String? displayName;
|
|
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;
|
|
break;
|
|
}
|
|
}
|
|
|
|
return WidgetSpan(
|
|
alignment: PlaceholderAlignment.baseline,
|
|
baseline: TextBaseline.alphabetic,
|
|
child: _TokenPill(
|
|
text: '@${displayName ?? raw.substring(1)}',
|
|
textStyle: config.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,
|
|
);
|
|
}
|
|
|
|
Iterable<String> _mentionAliases(Iterable<String> mentionNames) sync* {
|
|
for (final name in mentionNames) {
|
|
final trimmed = name.trim();
|
|
if (trimmed.isEmpty) continue;
|
|
yield trimmed;
|
|
final firstName = trimmed.split(RegExp(r'\s+')).first;
|
|
if (firstName.isNotEmpty) {
|
|
yield firstName;
|
|
}
|
|
}
|
|
}
|