mirror of
https://github.com/block/buzz.git
synced 2026-10-27 12:40:30 +00:00
🤖 ## Summary In a channel conversation, two identities with the same display name looked identical. In the example below, the channel shows "Bad Janet" twice, for two different identities. A reader cannot tell which one was added and then removed. This PR uses the contextual names from the previous PR everywhere a conversation names someone: - Message authors, threads, forum posts and replies - Reactions, the typing indicator, and system rows (joined, added, removed) - Inline mentions and the agent activity sheet - The profile sheet title, which now matches the row that opened it Names are compared against the channel's members, so a label only gets longer when a real collision exists in that channel. The mention picker labels each choice against every candidate, so two "Honey" choices look different. When you pick one, it still inserts the identity's own name ("@Honey"), so the message text does not change. Part 2 of 3: #7894 (resolver) → #7895 (conversations) → #7896 (lists, Search, Pulse). ### Related issue Related to #2910. ### Testing - A widget test checks that the profile sheet names an author the same way the channel does. - A unit test checks that two same-name mention choices get different labels but insert the same wire name. - `flutter analyze` is clean, and the full `flutter test` suite passes. - Checked on an Android emulator against the live relay, in an archived channel with two identities named "Bad Janet". | Before | After | | --- | --- | | <img width="300" alt="Before: the channel shows two different identities both as Bad Janet" src="https://github.com/user-attachments/assets/c7f6c345-12d8-4e11-9434-bbd2c63722db" /> | <img width="300" alt="After: the second identity shows as Bad Janet · sqxl" src="https://github.com/user-attachments/assets/51c6b9fc-8900-4200-8ce0-ad1887f41dba" /> | --------- Signed-off-by: Logan Johnson <loganj@squareup.com> Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz>
995 lines
32 KiB
Dart
995 lines
32 KiB
Dart
import 'dart:async';
|
|
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: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/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 'channels_provider.dart';
|
|
import 'media_viewer_page.dart';
|
|
import 'message_content/link_normalizer.dart';
|
|
import 'message_media.dart';
|
|
import 'voice_note_attachment.dart';
|
|
|
|
part 'message_content/media_carousel.dart';
|
|
part 'message_content/inline_components.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;
|
|
|
|
/// 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.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 baseTextStyle =
|
|
baseStyle ??
|
|
context.textTheme.bodyMedium?.copyWith(color: context.colors.onSurface);
|
|
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
|
|
: <String, String>{
|
|
for (final channel
|
|
in ref.watch(channelsProvider).asData?.value ?? const [])
|
|
channel.name.toLowerCase(): channel.id,
|
|
};
|
|
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 = 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()),
|
|
...(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 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 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;
|
|
}, [linkNormalizedContent, 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,
|
|
);
|
|
|
|
final markdown = KeyedSubtree(
|
|
key: ValueKey(
|
|
'$finalContent\u0000$mentionPresentationKey\u0000$channelPresentationKey',
|
|
),
|
|
child: GptMarkdown(
|
|
finalContent,
|
|
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,
|
|
),
|
|
);
|
|
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, {
|
|
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: 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: 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 ? LucideIcons.check : LucideIcons.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 = _MentionPill(
|
|
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,
|
|
);
|
|
}
|
|
}
|
|
|
|
class _MentionPill extends StatelessWidget {
|
|
final String label;
|
|
final String? semanticsLabel;
|
|
final bool isAgent;
|
|
final TextStyle? textStyle;
|
|
|
|
const _MentionPill({
|
|
required this.label,
|
|
this.semanticsLabel,
|
|
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),
|
|
),
|
|
Flexible(
|
|
child: Text(label, style: style, semanticsLabel: semanticsLabel),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
String _markdownMentionName(String name) => name.replaceAll(' ', '\u00A0');
|