mirror of
https://github.com/block/buzz.git
synced 2026-11-13 21:01:53 +00:00
Message Markdown on mobile moves from gpt_markdown 1.2.1's legacy regex
pipeline to the CommonMark parser that 1.3 added ("plusparse"). Before
this, passing `inlineComponents` (now deprecated, removed in 2.0.0) kept
every message on the legacy pipeline.
### What changes for users
Rendering moves closer to desktop (react-markdown + remark-gfm):
- `_italic_` and `__bold__` work, backslash escapes and HTML entities
decode, and `[a [b] c](url)` labels are correct.
- Inline code follows CommonMark pairing: `"```npm install```"` in a
sentence is inline code, and four-backtick fences work.
- Setext headings, reference links, footnotes, `~~~` fences, `1)` lists
and CommonMark list/quote continuation render.
- HTML comments are hidden, as on desktop.
- `\[`, `\]`, `\(` and `\)` stay literal, as on desktop. Plusparse
hard-codes them as LaTeX delimiters, so they're written as entities
before rendering.
- Side effect: an unfenced `__init__` now renders as bold "init", and a
line over `---` becomes a heading, same as desktop.
Device benchmark (iPhone SE, profile build, 600 synthetic messages, 3
launches per build): scrolling spends 9% less time building, and frames
with more than 8 ms of build drop from 63 to 22. Jumping onto unbuilt
messages is 11% cheaper, and raster time is unchanged.
App size: the release app grows about 4.6 MB uncompressed (80.6 to 85.2
MB), mostly Dart code from the new math and highlighting packages.
### Before and after
Synthetic messages that reproduce differences found in real channel
history, rendered through `MessageContent` on main and on this branch:








### Demo
A scroll through synthetic messages covering common formatting
(headings, lists, quotes, links, highlighted code, a table, a task list,
escapes), recorded on an iPhone 17 Pro simulator running this branch:
https://github.com/user-attachments/assets/bdc9680c-d20b-41ec-8e1a-a5b51c602f9d
### How
- Mentions, custom emoji and `#channel` tokens are `InlinePattern`s.
Links go through `inlineLinkBuilder` into the existing `_buildLink`, so
chips, media links and taps are unchanged.
- Removed workarounds the parser makes unnecessary:
- the render-path escape shielding from #8925
- the `_MarkdownEscapeMd`, `_MarkdownLabelEntityMd` and `_MessageLinkMd`
components
- the zero-width prefix before a leading `#channel`
Plain-text previews and gallery helpers keep their own escape handling.
`normalizeBareLinks` stays because attachment and gallery detection
depend on its output. Bubble segmentation unescapes a link destination
itself before its imeta lookup, since content no longer arrives
shielded.
- Kept desktop parity where plusparse differs from GFM:
- a table typed directly under a caption line gets a blank line inserted
before it, since plusparse can't start a table mid-paragraph
- `> [!NOTE]` quotes render as quotes (`alertBuilder` returns
`asBlockQuote()`), since desktop has no alerts
- `mapMarkdownProse` now ends an unclosed backtick run at its paragraph.
Before, it could pair with a run in a later block and flip code and
prose for the rest of the message.
- Synthesized autolink labels escape backslashes instead of using
`\`, which the parser leaves undecoded in a URL-shaped label.
- Repo/PR/issue chips keep their own semantics node.
- Forum card previews clip at their 120 px cap instead of overflowing:
blocks now lay out as a column.
- Pinned to exactly 1.3.4. 1.4.0 renders HTML by default and themes
through the separate `material_ui` package, which would need a theme
bridge.
### Validation
- Full mobile suite passes and analyze is clean at 78b9c1a6d0.
- All of #8925's and #8940's backslash-escape cases pass on the new
parser, including the strike-across-lines case that used to be a known
limitation.
- Real-message diff: 2,259 recent messages from 13 busy channels,
rendered through `MessageContent` on main and on this branch. 29
differed in text or styling. On inline and fenced code, the branch
matched desktop's parser (remark-gfm) in 23 of them and main in 7. The
review found the three parity gaps above, now fixed. The rest are
CommonMark behavior that desktop shares.
- Added tests, each confirmed to fail without its fix:
- a leading `#channel` renders as a link, not a heading
- a table under its caption renders as a table
- an alert-marker quote stays a quote
- an unclosed backtick run doesn't flip later code
- the transcript tests detect a rendered link by its style; the old
`LinkButton` check passed even with autolink on
- Automated simulator run (iPhone 17 Pro, iOS 27, debug build, signed in
to a Builderlab community): channel list, two channels with history
scrolled back several pages (mentions, link previews, thread summaries,
images), an agent Solo chat with long agent messages, and the inbox. No
rendering errors or layout breaks; the Solo chat screen also matched
main's build side by side.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Signed-off-by: Tom Brow <tomb@squareup.com>
Co-authored-by: Tom Brow <tomb@squareup.com>
Co-authored-by: studio <dc2fc481343672df31a689840c09299972e704dac592e0da6f18486646a7ee3e@buzz.block.builderlab.xyz>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1132 lines
39 KiB
Dart
1132 lines
39 KiB
Dart
import 'message_reply_drag_target.dart';
|
|
import 'package:buzz/shared/widgets/feedback_snackbar.dart';
|
|
|
|
import 'dart:async';
|
|
import 'dart:convert';
|
|
import '../../shared/widgets/ios_media_carousel.dart';
|
|
import '../../shared/widgets/message_attachment_corners.dart';
|
|
|
|
import 'dart:io';
|
|
import 'dart:math' as math;
|
|
|
|
import 'package:flutter/gestures.dart';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter/foundation.dart';
|
|
import 'package:flutter/services.dart';
|
|
import 'package:flutter/semantics.dart';
|
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|
import 'package:gpt_markdown/gpt_markdown.dart';
|
|
import 'package:buzz/shared/theme/buzz_icons.dart';
|
|
import 'package:open_filex/open_filex.dart';
|
|
import 'package:path_provider/path_provider.dart';
|
|
import 'package:share_plus/share_plus.dart';
|
|
import 'package:url_launcher/url_launcher.dart';
|
|
|
|
import '../../shared/clipboard_utils.dart';
|
|
import '../../shared/widgets/media_loading_placeholder.dart';
|
|
import '../../shared/widgets/modal_presentation.dart';
|
|
import '../../shared/widgets/sheet_action_section.dart';
|
|
import '../../shared/mentions/mention_bindings.dart';
|
|
import '../../shared/mentions/mention_tags.dart';
|
|
import '../../shared/deeplink/deep_link.dart';
|
|
import '../../shared/deeplink/pending_deep_link_provider.dart';
|
|
import '../../shared/relay/relay.dart';
|
|
import '../../shared/syntax_highlight.dart';
|
|
import '../../shared/theme/theme.dart';
|
|
import '../../shared/custom_emoji/custom_emoji.dart';
|
|
import '../../shared/custom_emoji/custom_emoji_provider.dart';
|
|
import '../../shared/custom_emoji/custom_emoji_render.dart';
|
|
import '../../shared/emoji/emoji_data_provider.dart';
|
|
import '../../shared/emoji/emoji_only.dart';
|
|
import 'channel_ids_by_name_provider.dart';
|
|
import 'local_send_bubble_surface.dart';
|
|
import 'media_viewer_page.dart';
|
|
import 'native_photo_zoom_preview.dart';
|
|
import 'native_video_files.dart';
|
|
import 'message_content/link_normalizer.dart';
|
|
import 'message_content/markdown_escapes.dart';
|
|
import 'message_content/markdown_tables.dart';
|
|
import 'message_content/preview_text.dart';
|
|
import 'message_media.dart';
|
|
import 'message_content/bubble_segments.dart';
|
|
import 'message_content/edited_marker.dart';
|
|
import 'message_content/buzz_link_label.dart';
|
|
import 'message_content/channel_token_pattern.dart';
|
|
import 'message_mention_pill.dart';
|
|
import 'message_gallery.dart';
|
|
import 'message_gallery_frame.dart';
|
|
import 'message_long_press_region.dart';
|
|
import 'message_media_avatar.dart';
|
|
import 'message_media_geometry.dart';
|
|
import 'voice_note_attachment.dart';
|
|
|
|
part 'message_content/media_carousel.dart';
|
|
part 'message_content/mention_markdown.dart';
|
|
part 'message_content/media_stack.dart';
|
|
part 'message_content/media_stack_tuning.dart';
|
|
part 'message_content/message_preview.dart';
|
|
part 'message_content/bubble_shape.dart';
|
|
part 'message_content/inline_components.dart';
|
|
part 'message_content/token_pill.dart';
|
|
part 'message_content/video_preview.dart';
|
|
part 'message_content/link_actions.dart';
|
|
|
|
typedef OpenDownloadedFile =
|
|
Future<void> Function(
|
|
String url,
|
|
Map<String, String> headers,
|
|
String filename,
|
|
);
|
|
|
|
final openDownloadedFileProvider = Provider<OpenDownloadedFile>((ref) {
|
|
final client = ref.watch(mediaHttpClientProvider);
|
|
return (url, headers, filename) async {
|
|
final response = await client.get(Uri.parse(url), headers: headers);
|
|
if (response.statusCode < 200 || response.statusCode >= 300) {
|
|
throw HttpException(
|
|
'Attachment download failed (${response.statusCode})',
|
|
uri: Uri.parse(url),
|
|
);
|
|
}
|
|
|
|
final directory = await getTemporaryDirectory();
|
|
final safeName = _safeDownloadedFilename(filename);
|
|
final file = File(
|
|
'${directory.path}${Platform.pathSeparator}'
|
|
'${DateTime.now().microsecondsSinceEpoch}-$safeName',
|
|
);
|
|
await file.writeAsBytes(response.bodyBytes, flush: true);
|
|
final result = await OpenFilex.open(file.path);
|
|
if (result.type != ResultType.done) {
|
|
throw FileSystemException(result.message, file.path);
|
|
}
|
|
};
|
|
});
|
|
|
|
String _safeDownloadedFilename(String filename) {
|
|
final safe = filename
|
|
.split(RegExp(r'[/\\]'))
|
|
.last
|
|
.replaceAll(RegExp(r'[\x00-\x1F\x7F]'), '')
|
|
.trim();
|
|
return safe.isEmpty ? 'attachment' : safe;
|
|
}
|
|
|
|
/// Renders message content with markdown formatting, @mentions, #channel links,
|
|
/// and media-aware markdown images/videos.
|
|
class MessageContent extends HookConsumerWidget {
|
|
final String content;
|
|
|
|
/// Paint conversation text bubbles while keeping attachments on the page.
|
|
final bool bubble;
|
|
|
|
/// Space reclaimed from an omitted sender avatar and its gutter.
|
|
final double additionalTextWidth;
|
|
|
|
/// Distinguishes messages authored by the signed-in identity.
|
|
final bool ownMessage;
|
|
|
|
/// Lets an adjacent day divider own spacing after the final text segment.
|
|
final bool trimTrailingSpacing;
|
|
|
|
/// Marks the final message in a sender run for rounded corners and dots.
|
|
final bool bubbleEnd;
|
|
|
|
/// Marks the first message in a sender run for rounded top corners.
|
|
final bool bubbleStart;
|
|
|
|
/// Reserves room above overlapping reactions.
|
|
final bool hasReactions;
|
|
|
|
/// Display names for mentioned pubkeys, extracted from event p-tags.
|
|
/// Keys are lowercase pubkeys, values are display names.
|
|
final Map<String, String> mentionNames;
|
|
|
|
/// Contextual display labels for mentioned pubkeys (lowercase keys).
|
|
///
|
|
/// Presentation only: text still binds through [mentionNames], so a
|
|
/// disambiguated label never changes which signed text names whom.
|
|
final Map<String, String> mentionLabels;
|
|
|
|
/// Mentioned pubkeys that resolve to agents. Agent chips use the desktop
|
|
/// robot treatment instead of an `@` prefix.
|
|
final Set<String> agentMentionPubkeys;
|
|
|
|
/// Known channel names for #channel links. Keys are lowercase channel
|
|
/// names, values are channel IDs.
|
|
final Map<String, String> channelNames;
|
|
|
|
/// Raw event tags, used for `imeta` media metadata lookups.
|
|
final List<List<String>> tags;
|
|
|
|
/// Called when a #channel link is tapped.
|
|
final void Function(String channelId)? onChannelTap;
|
|
|
|
/// Called when an @mention of a known user is tapped, with the
|
|
/// mentioned user's pubkey.
|
|
final void Function(String pubkey)? onMentionTap;
|
|
|
|
/// Opens the message's thread from the full-screen image viewer.
|
|
final VoidCallback? onMediaReply;
|
|
|
|
/// Opens message-specific actions for an image in the full-screen viewer.
|
|
final MediaViewerMoreAction? onMediaMore;
|
|
final NativePhotoActions? nativePhotoActions;
|
|
|
|
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;
|
|
|
|
/// Marks the body as replaced by an edit, after its last line: inline after
|
|
/// prose, else on its own line after a closing block or trailing media.
|
|
final bool edited;
|
|
|
|
/// Allows a multi-image carousel to reclaim leading space reserved by the
|
|
/// surrounding message layout, while keeping its image count aligned with
|
|
/// the message body.
|
|
final double mediaCarouselLeadingOverflow;
|
|
|
|
/// Allows a multi-image carousel to continue through the trailing page
|
|
/// gutter while keeping its first image and count aligned with the body.
|
|
final double mediaCarouselTrailingOverflow;
|
|
|
|
const MessageContent({
|
|
super.key,
|
|
required this.content,
|
|
this.bubble = false,
|
|
this.additionalTextWidth = 0,
|
|
this.ownMessage = false,
|
|
this.trimTrailingSpacing = false,
|
|
this.bubbleEnd = true,
|
|
this.bubbleStart = true,
|
|
this.hasReactions = false,
|
|
this.mentionNames = const {},
|
|
this.mentionLabels = const {},
|
|
this.agentMentionPubkeys = const {},
|
|
this.channelNames = const {},
|
|
this.tags = const [],
|
|
this.onChannelTap,
|
|
this.onMentionTap,
|
|
this.onMediaReply,
|
|
this.onMediaMore,
|
|
this.nativePhotoActions,
|
|
this.baseStyle,
|
|
this.textAlign,
|
|
this.maxLines,
|
|
this.scaleEmojiOnly = false,
|
|
this.edited = false,
|
|
this.mediaCarouselLeadingOverflow = 0,
|
|
this.mediaCarouselTrailingOverflow = 0,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context, WidgetRef ref) {
|
|
final markdownTheme = GptMarkdownTheme.of(context);
|
|
final chatColors = ChatColors.of(context);
|
|
final ownTextColor = chatColors.foreground;
|
|
// Own-bubble links share the bubble ink; markdown marks them by underline.
|
|
final linkColor = ownMessage ? ownTextColor : context.colors.tertiary;
|
|
final bubbleInk = ownMessage ? ownTextColor : context.colors.onSurface;
|
|
final onSurface = context.colors.onSurface;
|
|
// GptMarkdownTheme notifies its dependents whenever the data object
|
|
// changes (the data has no value equality), and each notification makes
|
|
// every GptMarkdown below re-parse its text. Reuse one bubble theme while
|
|
// its inputs are unchanged so an ordinary rebuild does not re-parse.
|
|
final bubbleMarkdownTheme = useMemoized(
|
|
() => markdownTheme.copyWith(
|
|
linkColor: linkColor,
|
|
// A neutral chip would put white ink on a light fill; tint the accent
|
|
// instead.
|
|
inlineCode: ownMessage
|
|
? markdownTheme.inlineCode.copyWith(
|
|
color: ownTextColor,
|
|
backgroundColor: chatCodeTint(ownTextColor, 0.18),
|
|
borderColor: ownTextColor.withValues(alpha: 0.3),
|
|
)
|
|
: null,
|
|
h1: markdownTheme.h1?.copyWith(color: bubbleInk),
|
|
h2: markdownTheme.h2?.copyWith(color: bubbleInk),
|
|
h3: markdownTheme.h3?.copyWith(color: bubbleInk),
|
|
h4: markdownTheme.h4?.copyWith(color: bubbleInk),
|
|
h5: markdownTheme.h5?.copyWith(color: bubbleInk),
|
|
h6: markdownTheme.h6?.copyWith(color: bubbleInk),
|
|
),
|
|
[markdownTheme, linkColor, ownMessage, ownTextColor, onSurface],
|
|
);
|
|
final baseTextStyle =
|
|
(baseStyle ??
|
|
context.textTheme.bodyMedium?.copyWith(
|
|
color: context.colors.onSurface,
|
|
))
|
|
?.copyWith(color: bubble && ownMessage ? ownTextColor : null);
|
|
final resolvedMentionNames = mentionNames;
|
|
final signedMentionPubkeys = mentionedPubkeysFromTags(tags);
|
|
final mentionBindings = renderedMentionBindings(
|
|
content,
|
|
mentionNames,
|
|
signedMentionPubkeys,
|
|
);
|
|
final resolvedAgentMentionPubkeys = {
|
|
...agentMentionPubkeys.map((pubkey) => pubkey.toLowerCase()),
|
|
};
|
|
final resolvedChannelNames = channelNames.isNotEmpty
|
|
? channelNames
|
|
: ref.watch(channelIdsByNameProvider);
|
|
final channelHandler = useRef(onChannelTap)..value = onChannelTap;
|
|
final resolvedChannelTap = useMemoized(
|
|
() => (String channelId) {
|
|
final handler = channelHandler.value;
|
|
if (handler != null) {
|
|
handler(channelId);
|
|
} else {
|
|
ref
|
|
.read(pendingDeepLinkProvider.notifier)
|
|
.open(Uri(scheme: 'buzz', host: 'channel', path: channelId));
|
|
}
|
|
},
|
|
const [],
|
|
);
|
|
final replyHandler = useRef(onMediaReply)..value = onMediaReply;
|
|
final moreHandler = useRef(onMediaMore)..value = onMediaMore;
|
|
final mediaReply = useMemoized(
|
|
() =>
|
|
() => replyHandler.value?.call(),
|
|
const [],
|
|
);
|
|
final mediaMore = useMemoized(
|
|
() =>
|
|
(BuildContext context, String url) =>
|
|
moreHandler.value?.call(context, url),
|
|
const [],
|
|
);
|
|
final channelPresentationKey = [
|
|
for (final entry
|
|
in (resolvedChannelNames.entries.toList()
|
|
..sort((a, b) => a.key.compareTo(b.key))))
|
|
'${entry.key}\u0000${entry.value}',
|
|
].join('\u0001');
|
|
final imetaByUrl = {
|
|
for (final entry in parseImetaTags(tags).entries)
|
|
normalizeMarkdownDestination(entry.key): entry.value,
|
|
};
|
|
final normalizedContent = useMemoized(() => normalizeBareLinks(content), [
|
|
content,
|
|
]);
|
|
final trailingGallery = maxLines == null
|
|
? extractTrailingImageGallery(
|
|
normalizedContent,
|
|
imetaByUrl,
|
|
includeVideos: bubble,
|
|
)
|
|
: null;
|
|
final galleryText = trailingGallery?.content ?? normalizedContent;
|
|
final markdownContent = useMemoized(
|
|
() => separateMarkdownTables(escapeMarkdownLiterals(galleryText)),
|
|
[galleryText],
|
|
);
|
|
final customEmoji = _mergeCustomEmoji(
|
|
customEmojiFromTags(tags),
|
|
ref.watch(customEmojiListProvider),
|
|
);
|
|
final mentionPresentationKey = [
|
|
for (final entry
|
|
in (resolvedMentionNames.entries.toList()
|
|
..sort((a, b) => a.key.compareTo(b.key))))
|
|
'${entry.key}\u0000${entry.value}',
|
|
...(resolvedAgentMentionPubkeys.toList()..sort()),
|
|
...(signedMentionPubkeys.toList()..sort()),
|
|
].join('\u0001');
|
|
|
|
// Decided here rather than by the caller: this is where the event's own
|
|
// emoji tags and the community palette have already been merged, and a
|
|
// `:shortcode:` only counts as emoji if it resolves against that palette.
|
|
final emojiOnly =
|
|
scaleEmojiOnly &&
|
|
isEmojiOnlyMessage(
|
|
markdownContent,
|
|
nativeEmoji: ref.watch(nativeEmojiGlyphsProvider),
|
|
customEmoji: customEmoji,
|
|
);
|
|
final style = emojiOnly
|
|
? baseTextStyle?.copyWith(
|
|
fontSize: kEmojiOnlyFontSize,
|
|
height: kEmojiOnlyHeight,
|
|
)
|
|
: baseTextStyle;
|
|
final inlineCustomEmojiSize = emojiOnly
|
|
? kEmojiOnlyCustomEmojiSize
|
|
: kCustomEmojiInlineSize;
|
|
|
|
final finalContent = useMemoized(() {
|
|
// Replace spaces with non-breaking spaces inside known mention names
|
|
// so _MentionMd's pattern matches multi-word names as one token, in
|
|
// the authored case. Code, inline or fenced, is left as written.
|
|
return mapMarkdownProse(markdownContent, (segment) {
|
|
for (final range in mentionOccurrences(
|
|
segment,
|
|
mentionBindings.keys,
|
|
).reversed) {
|
|
segment = segment.replaceRange(
|
|
range.start,
|
|
range.end,
|
|
'@${_markdownMentionName(segment.substring(range.start + 1, range.end))}',
|
|
);
|
|
}
|
|
return segment;
|
|
});
|
|
}, [markdownContent, mentionPresentationKey]);
|
|
|
|
final inlinePatterns = _useMessageInlinePatterns(
|
|
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 editedStyle = editedMarkerStyle(context, own: bubble && ownMessage);
|
|
// An authored slot is drawn as written; the marker takes its own line.
|
|
final markerSlot = edited && !finalContent.contains(editedSlot);
|
|
final components = useMemoized(
|
|
() => [
|
|
if (markerSlot) editedMarkerPattern(editedStyle),
|
|
...inlinePatterns,
|
|
escapedSigilPattern,
|
|
],
|
|
[inlinePatterns, editedStyle, markerSlot],
|
|
);
|
|
|
|
Widget renderMarkdown(String text) => KeyedSubtree(
|
|
key: ValueKey(
|
|
'$text\u0000$mentionPresentationKey\u0000$channelPresentationKey',
|
|
),
|
|
child: GptMarkdown(
|
|
text,
|
|
style: style,
|
|
styleSheet: bubble
|
|
? GptMarkdownStyleSheet(
|
|
blockQuote: BlockQuoteStyle(barColor: bubbleInk),
|
|
hr: HrStyle(color: bubbleInk.withValues(alpha: 0.3)),
|
|
table: TableStyle(
|
|
borderColor: bubbleInk.withValues(alpha: 0.3),
|
|
headerBackground: Colors.transparent,
|
|
headerTextStyle: TextStyle(color: bubbleInk),
|
|
),
|
|
)
|
|
: null,
|
|
followLinkColor: false,
|
|
// normalizeBareLinks() already turns bare URLs into Markdown links;
|
|
// gpt_markdown autolinks by default, so both would run.
|
|
autolink: false,
|
|
// Desktop has no GitHub alerts: a `> [!NOTE]` quote stays a quote.
|
|
alertBuilder: (_, alert) => alert.asBlockQuote(),
|
|
codeBuilder: (context, name, code, closed) => _MessageCodeBlock(
|
|
name: name,
|
|
code: code,
|
|
bubble: bubble,
|
|
ink: bubble && ownMessage ? ownTextColor : null,
|
|
),
|
|
// Same widget-shaped link as before: chips, media links and taps
|
|
// are handled in _buildLink, so the package's own tap is not used.
|
|
inlineLinkBuilder: (link) {
|
|
final url = unmaskInlinePatterns(link.url);
|
|
return link.asWidgetSpan(
|
|
_buildLink(
|
|
link.context,
|
|
ref,
|
|
TextSpan(children: link.labelSpans),
|
|
url,
|
|
imetaByUrl[url],
|
|
link.style,
|
|
style,
|
|
resolvedChannelTap,
|
|
resolvedChannelNames,
|
|
),
|
|
);
|
|
},
|
|
imageBuilder: (context, maskedUrl, _, _) {
|
|
final imageUrl = unmaskInlinePatterns(maskedUrl);
|
|
return _buildMedia(
|
|
context,
|
|
imageUrl,
|
|
imetaByUrl[imageUrl],
|
|
onReply: onMediaReply == null ? null : mediaReply,
|
|
onMore: onMediaMore == null ? null : mediaMore,
|
|
);
|
|
},
|
|
textAlign: textAlign ?? (bubble ? TextAlign.start : null),
|
|
maxLines: maxLines,
|
|
inlinePatterns: components,
|
|
),
|
|
);
|
|
final segments = bubble
|
|
? messageBubbleSegments(finalContent, imetaByUrl)
|
|
: const <({String text, bool attachment})>[];
|
|
// Media that closes the body takes the marker after it instead.
|
|
final editedInBody =
|
|
edited &&
|
|
trailingGallery == null &&
|
|
(bubble
|
|
? segments.isNotEmpty && !segments.last.attachment
|
|
: finalContent.trim().isNotEmpty);
|
|
Widget renderBody(String text, {required bool last}) => editedInBody && last
|
|
? markedEditedBody(text, renderMarkdown, editedStyle, slot: markerSlot)
|
|
: renderMarkdown(text);
|
|
final markdown = bubble
|
|
? Column(
|
|
crossAxisAlignment: ownMessage
|
|
? CrossAxisAlignment.end
|
|
: CrossAxisAlignment.start,
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
for (final (index, segment) in segments.indexed)
|
|
if (segment.attachment)
|
|
MessageReplyDragTarget(
|
|
child: MessageAttachmentCorners(
|
|
borderRadius: _attachmentCorners(
|
|
start: bubbleStart && index == 0,
|
|
end:
|
|
bubbleEnd &&
|
|
trailingGallery == null &&
|
|
index == segments.length - 1,
|
|
),
|
|
child: renderMarkdown(segment.text),
|
|
),
|
|
)
|
|
else
|
|
Padding(
|
|
key: ValueKey('message-text-bubble-$index'),
|
|
padding: EdgeInsets.only(
|
|
bottom:
|
|
trimTrailingSpacing &&
|
|
trailingGallery == null &&
|
|
index == segments.length - 1
|
|
? 0
|
|
: Grid.quarter,
|
|
),
|
|
child: ConstrainedBox(
|
|
constraints: BoxConstraints(
|
|
maxWidth:
|
|
MediaQuery.sizeOf(context).width *
|
|
messageTextMaxWidthFraction +
|
|
additionalTextWidth,
|
|
),
|
|
child: MessageReplyDragTarget(
|
|
child: LocalSendBubbleSurface(
|
|
color: ownMessage
|
|
? chatColors.outgoing
|
|
: receivedMessageSurface(
|
|
context.colors.brightness,
|
|
),
|
|
shape: const _MessageBubbleShape(),
|
|
child: Padding(
|
|
padding: EdgeInsets.fromLTRB(
|
|
Grid.xs,
|
|
Grid.xxs,
|
|
Grid.xs,
|
|
hasReactions && index == segments.length - 1
|
|
? Grid.xs
|
|
: Grid.xxs,
|
|
),
|
|
child: Theme(
|
|
data: Theme.of(context).copyWith(
|
|
colorScheme: context.colors.copyWith(
|
|
primary: linkColor,
|
|
),
|
|
),
|
|
child: GptMarkdownTheme(
|
|
gptThemeData: bubbleMarkdownTheme,
|
|
child: renderBody(
|
|
segment.text,
|
|
last: index == segments.length - 1,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
)
|
|
: renderBody(finalContent, last: true);
|
|
if (trailingGallery == null) {
|
|
return edited && !editedInBody
|
|
? withEditedMarkerBelow(markdown, end: ownMessage)
|
|
: markdown;
|
|
}
|
|
|
|
return Column(
|
|
crossAxisAlignment: ownMessage
|
|
? CrossAxisAlignment.end
|
|
: CrossAxisAlignment.start,
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
if (bubble)
|
|
Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: Grid.xs),
|
|
child: Text(
|
|
'${trailingGallery.items.length} ${trailingGallery.items.any((item) => item.isVideo) ? 'attachments' : 'photos'}',
|
|
key: const ValueKey('message-media-stack-count'),
|
|
style: context.textTheme.labelSmall?.copyWith(
|
|
color: context.colors.onSurfaceVariant,
|
|
),
|
|
),
|
|
),
|
|
if (trailingGallery.content.trim().isNotEmpty) markdown,
|
|
MessageReplyDragTarget(
|
|
child: MessageAttachmentCorners(
|
|
borderRadius: _attachmentCorners(
|
|
start: bubbleStart && trailingGallery.content.trim().isEmpty,
|
|
end: bubbleEnd,
|
|
),
|
|
child: bubble
|
|
? _MessageImageStack(
|
|
key: ValueKey(
|
|
trailingGallery.items
|
|
.map((item) => item.url)
|
|
.join('\u0000'),
|
|
),
|
|
items: trailingGallery.items,
|
|
mirrored: ownMessage,
|
|
nativeActions: nativePhotoActions,
|
|
onReply: onMediaReply,
|
|
onMore: onMediaMore,
|
|
)
|
|
: _MessageImageCarousel(
|
|
key: ValueKey(
|
|
trailingGallery.items
|
|
.map((item) => item.url)
|
|
.join('\u0000'),
|
|
),
|
|
items: trailingGallery.items,
|
|
mirrored: ownMessage,
|
|
leadingOverflow: mediaCarouselLeadingOverflow,
|
|
trailingOverflow: mediaCarouselTrailingOverflow,
|
|
onReply: onMediaReply,
|
|
onMore: onMediaMore,
|
|
),
|
|
),
|
|
),
|
|
if (edited) const EditedMarkerBelow(),
|
|
],
|
|
);
|
|
}
|
|
|
|
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;
|
|
String? channelName(String id) =>
|
|
_channelNameForId(resolvedChannelNames, id);
|
|
final buzzLabel = buzzLinkLabel(buzzLink, channelName);
|
|
final buzzPresentation = switch (buzzLink) {
|
|
ChannelDeepLink() => (
|
|
icon: BuzzIcons.hash,
|
|
label: buzzLabel!,
|
|
semanticLabel: 'Open channel $buzzLabel',
|
|
interactive: true,
|
|
),
|
|
MessageDeepLink(:final channelId, :final messageId) => (
|
|
icon: BuzzIcons.messageSquare,
|
|
label: buzzLabel!,
|
|
semanticLabel:
|
|
'Open message ${shortBuzzId(messageId)} in channel ${buzzChannelLabel(channelId, channelName)}',
|
|
interactive: true,
|
|
),
|
|
EntityDeepLink(:final type, :final repository, :final eventId) => (
|
|
icon: switch (type) {
|
|
'repo' => BuzzIcons.folderGit2,
|
|
'pr' => BuzzIcons.gitPullRequest,
|
|
_ => BuzzIcons.circleDot,
|
|
},
|
|
label: buzzLabel!,
|
|
semanticLabel: switch (type) {
|
|
'repo' => 'Repository $repository',
|
|
'pr' =>
|
|
'Pull request ${shortBuzzId(eventId ?? '')} in repository $repository',
|
|
_ => 'Issue ${shortBuzzId(eventId ?? '')} 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;
|
|
|
|
final highlightColor = (baseStyle.color ?? context.colors.primary)
|
|
.withValues(alpha: 0.2);
|
|
|
|
// Mobile has no repo/PR/issue destination yet, so these chips have no tap
|
|
// and no Open action, but a hold still copies or shares the link.
|
|
// The container keeps the chip's label its own node; without it, the
|
|
// non-interactive chip merges into the surrounding paragraph's label.
|
|
if (buzzLink is EntityDeepLink) {
|
|
return Semantics(
|
|
container: true,
|
|
child: _LinkLongPress(
|
|
url: url,
|
|
onOpen: null,
|
|
highlightColor: highlightColor,
|
|
child: renderedLink,
|
|
),
|
|
);
|
|
}
|
|
|
|
Future<void> openLink() 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)?.showFeedbackSnackBar(
|
|
const SnackBar(content: Text('Could not open attachment')),
|
|
kind: ToastKind.error,
|
|
);
|
|
}
|
|
}
|
|
|
|
// openLink handles only these schemes; the sheet hides Open for others.
|
|
final scheme = Uri.tryParse(url)?.scheme;
|
|
final canOpen = scheme == 'buzz' || scheme == 'http' || scheme == 'https';
|
|
return _LinkLongPress(
|
|
url: url,
|
|
onOpen: canOpen ? () => unawaited(openLink()) : null,
|
|
highlightColor: highlightColor,
|
|
child: GestureDetector(onTap: openLink, child: renderedLink),
|
|
);
|
|
}
|
|
}
|
|
|
|
List<CustomEmoji> _mergeCustomEmoji(
|
|
List<CustomEmoji> eventEmoji,
|
|
List<CustomEmoji> paletteEmoji,
|
|
) {
|
|
if (eventEmoji.isEmpty) return paletteEmoji;
|
|
if (paletteEmoji.isEmpty) return eventEmoji;
|
|
final seen = <String>{};
|
|
final merged = <CustomEmoji>[];
|
|
for (final emoji in [...eventEmoji, ...paletteEmoji]) {
|
|
if (seen.add(emoji.shortcode)) merged.add(emoji);
|
|
}
|
|
return merged;
|
|
}
|
|
|
|
class _MessageImagePreview extends HookConsumerWidget {
|
|
final String url;
|
|
final ImetaEntry? imeta;
|
|
final String semanticLabel;
|
|
final VoidCallback? onReply;
|
|
final MediaViewerMoreAction? onMore;
|
|
|
|
const _MessageImagePreview({
|
|
required this.url,
|
|
required this.imeta,
|
|
required this.semanticLabel,
|
|
required this.onReply,
|
|
required this.onMore,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context, WidgetRef ref) {
|
|
final heroTag = useMemoized(() => Object());
|
|
final layout = _resolveImagePreviewLayout(context, imeta?.aspectRatio);
|
|
final previewDecodeWidth = layout.width ?? messageMediaMaxWidth(context);
|
|
|
|
return Padding(
|
|
padding: const EdgeInsets.only(top: Grid.half),
|
|
child: GestureDetector(
|
|
onTap: () => openImageViewer(
|
|
context,
|
|
imageUrl: url,
|
|
heroTag: heroTag,
|
|
semanticLabel: semanticLabel,
|
|
previewDecodeWidth: previewDecodeWidth,
|
|
aspectRatio: imeta?.aspectRatio,
|
|
onReply: onReply,
|
|
onMore: onMore,
|
|
),
|
|
child: _MessageMediaPreviewFrame(
|
|
previewKey: ValueKey('message-media-image-preview:$url'),
|
|
backgroundColor: context.colors.surfaceContainerHighest,
|
|
width: layout.width,
|
|
height: layout.height,
|
|
child: MediaViewerHero(
|
|
tag: heroTag,
|
|
child: ClipRRect(
|
|
borderRadius:
|
|
MessageAttachmentCorners.of(context) ??
|
|
BorderRadius.circular(Radii.md),
|
|
child: MediaImage(
|
|
url: url,
|
|
decodeWidth: previewDecodeWidth,
|
|
fit: layout.fit,
|
|
semanticLabel: semanticLabel,
|
|
frameBuilder: (context, child, frame, synchronous) =>
|
|
frame != null || synchronous
|
|
? child
|
|
: const MediaLoadingPlaceholder(label: 'Loading image'),
|
|
errorBuilder: (_, _, _) => _MediaPreviewFallback(
|
|
icon: BuzzIcons.imageOff,
|
|
label: 'Image unavailable',
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _MessageMediaPreviewFrame extends StatelessWidget {
|
|
final Key previewKey;
|
|
final Color backgroundColor;
|
|
final double? width;
|
|
final double? height;
|
|
final Widget child;
|
|
|
|
const _MessageMediaPreviewFrame({
|
|
required this.previewKey,
|
|
required this.backgroundColor,
|
|
this.width,
|
|
this.height,
|
|
required this.child,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final resolvedWidth = width ?? messageMediaMaxWidth(context);
|
|
|
|
return Container(
|
|
key: previewKey,
|
|
width: resolvedWidth,
|
|
height: height,
|
|
clipBehavior: Clip.antiAlias,
|
|
decoration: BoxDecoration(
|
|
color: backgroundColor,
|
|
borderRadius:
|
|
MessageAttachmentCorners.of(context) ??
|
|
BorderRadius.circular(Radii.md),
|
|
border: Border.all(color: context.colors.outlineVariant),
|
|
),
|
|
child: child,
|
|
);
|
|
}
|
|
}
|
|
|
|
_ImagePreviewLayout _resolveImagePreviewLayout(
|
|
BuildContext context,
|
|
double? aspectRatio,
|
|
) {
|
|
final previewSize = messageImagePreviewSize(context, aspectRatio);
|
|
return _ImagePreviewLayout(
|
|
width: previewSize.width,
|
|
height: previewSize.height,
|
|
fit: aspectRatio == null ? BoxFit.contain : BoxFit.cover,
|
|
);
|
|
}
|
|
|
|
class _ImagePreviewLayout {
|
|
final double? width;
|
|
final double? height;
|
|
final BoxFit fit;
|
|
|
|
const _ImagePreviewLayout({this.width, this.height, required this.fit});
|
|
}
|
|
|
|
class _MediaPreviewFallback extends StatelessWidget {
|
|
final IconData icon;
|
|
final String label;
|
|
|
|
const _MediaPreviewFallback({required this.icon, required this.label});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return ColoredBox(
|
|
color: context.colors.surfaceContainerHighest,
|
|
child: Center(
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Icon(icon, color: context.colors.onSurfaceVariant),
|
|
const SizedBox(height: Grid.quarter),
|
|
Text(
|
|
label,
|
|
style: context.textTheme.labelSmall?.copyWith(
|
|
color: context.colors.onSurfaceVariant,
|
|
),
|
|
textAlign: TextAlign.center,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _MessageCodeBlock extends HookWidget {
|
|
final String name;
|
|
final String code;
|
|
|
|
/// Whether the block sits inside a message bubble.
|
|
final bool bubble;
|
|
|
|
/// Text color on an accent bubble; null keeps the neutral highlighted card.
|
|
final Color? ink;
|
|
|
|
const _MessageCodeBlock({
|
|
required this.name,
|
|
required this.code,
|
|
this.bubble = false,
|
|
this.ink,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final isCopied = useState(false);
|
|
|
|
Future<void> handleCopy() async {
|
|
await copyToClipboard(context, code, message: 'Code copied');
|
|
if (!context.mounted) return;
|
|
isCopied.value = true;
|
|
Future.delayed(const Duration(seconds: 2), () {
|
|
if (context.mounted) isCopied.value = false;
|
|
});
|
|
}
|
|
|
|
final ink = this.ink;
|
|
// A fence's closing newline would otherwise render as a blank last line.
|
|
final shownCode = code.trimRight();
|
|
final codeBaseStyle = TextStyle(
|
|
fontFamily: CodeStyle.fontFamily,
|
|
fontSize: CodeStyle.fontSize,
|
|
height: CodeStyle.lineHeight,
|
|
color: ink ?? context.colors.onSurface,
|
|
);
|
|
final isDark = context.theme.brightness == Brightness.dark;
|
|
final codeTheme = isDark ? highlightDarkTheme : highlightLightTheme;
|
|
// Syntax colors are tuned for neutral cards and lose contrast on accents.
|
|
final codeSpans = useMemoized(
|
|
() => ink == null
|
|
? highlightCode(shownCode, name, codeTheme, codeBaseStyle)
|
|
: [TextSpan(text: shownCode)],
|
|
[code, name, isDark, ink],
|
|
);
|
|
return Container(
|
|
// In a bubble, the vertical margin brings the bubble's 8px vertical
|
|
// padding up to its 16px side padding, so the block is inset evenly and
|
|
// its corners stay concentric with the bubble's.
|
|
margin: bubble
|
|
? const EdgeInsets.symmetric(vertical: Grid.xs - Grid.xxs)
|
|
: const EdgeInsets.only(top: Grid.half),
|
|
decoration: BoxDecoration(
|
|
color: ink == null
|
|
? CodeStyle.background(context.colors)
|
|
: chatCodeTint(ink, 0.14),
|
|
borderRadius: BorderRadius.circular(
|
|
bubble ? Radii.container - Grid.xs : 12,
|
|
),
|
|
border: Border.all(
|
|
color:
|
|
ink?.withValues(alpha: 0.24) ?? CodeStyle.border(context.colors),
|
|
),
|
|
),
|
|
child: Stack(
|
|
children: [
|
|
// Even 12px insets; the right side also reserves the copy button.
|
|
Padding(
|
|
padding: const EdgeInsets.fromLTRB(
|
|
Grid.twelve,
|
|
Grid.twelve,
|
|
Grid.twelve + 28,
|
|
Grid.twelve,
|
|
),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
if (name.isNotEmpty)
|
|
Padding(
|
|
padding: const EdgeInsets.only(bottom: Grid.half),
|
|
child: Text(
|
|
name,
|
|
style: context.textTheme.labelSmall?.copyWith(
|
|
color: ink ?? context.colors.onSurfaceVariant,
|
|
),
|
|
),
|
|
),
|
|
SingleChildScrollView(
|
|
scrollDirection: Axis.horizontal,
|
|
child: RichText(
|
|
softWrap: false,
|
|
text: TextSpan(style: codeBaseStyle, children: codeSpans),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
Positioned(
|
|
top: Grid.half + Grid.quarter,
|
|
right: Grid.half + Grid.quarter,
|
|
child: SizedBox(
|
|
width: 28,
|
|
height: 28,
|
|
child: IconButton(
|
|
onPressed: handleCopy,
|
|
padding: EdgeInsets.zero,
|
|
visualDensity: VisualDensity.compact,
|
|
style: IconButton.styleFrom(
|
|
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
|
),
|
|
icon: Icon(
|
|
isCopied.value ? BuzzIcons.check : BuzzIcons.copy,
|
|
size: 14,
|
|
color:
|
|
ink ??
|
|
(isCopied.value
|
|
? context.colors.primary
|
|
: context.colors.onSurfaceVariant),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|