Files
cls 9dfa06ffee
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
feat: import Chinese-localized Buzz source snapshot
Signed-off-by: cls_宁波本机 <908705107@qq.com>
2026-08-13 18:34:25 +08:00

979 lines
31 KiB
Dart

import 'dart:async';
import 'dart:io';
import 'dart:math' as math;
import 'dart:ui';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/physics.dart';
import 'package:flutter/services.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:path_provider/path_provider.dart';
import 'package:photo_manager/photo_manager.dart';
import 'package:share_plus/share_plus.dart';
import '../../shared/clipboard_utils.dart';
import '../../shared/l10n/l10n.dart';
import '../../shared/deeplink/deep_link.dart';
import '../../shared/relay/relay.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/native_emoji_glyph.dart';
import '../../shared/widgets/sheet_divider.dart';
import '../../shared/widgets/modal_presentation.dart';
import '../../shared/reminders/remind_me_later_sheet.dart';
import '../../shared/reminders/reminder_service.dart';
import 'channel_management_provider.dart';
import 'emoji_picker.dart';
import 'reaction_row.dart';
import 'recent_emoji_provider.dart';
import '../../shared/read_state/message_read_state.dart';
import '../../shared/read_state/read_state_format.dart';
import '../../shared/read_state/read_state_provider.dart';
import 'thread_detail_page.dart';
import 'thread_follows/thread_follows_provider.dart';
import 'timeline_message.dart';
part 'message_actions/reaction_popover.dart';
/// Preview length for reminder targets — matches desktop's
/// `msg.body.slice(0, 100)`.
const _reminderPreviewLength = 100;
void showMessageActions({
required BuildContext context,
required WidgetRef ref,
required TimelineMessage message,
required String channelId,
required bool canManageMessage,
List<TimelineMessage>? allMessages,
String? currentPubkey,
bool isMember = false,
bool isArchived = false,
Rect? anchorRect,
EdgeInsets popoverSpotlightPadding = const EdgeInsets.all(Grid.xxs),
}) {
final hasReactionOnlyActions = message.isSystem && !canManageMessage;
if (anchorRect != null && hasReactionOnlyActions) {
_showMessageReactionPopover(
context: context,
ref: ref,
message: message,
anchorRect: anchorRect,
spotlightPadding: popoverSpotlightPadding,
);
return;
}
showBuzzModalBottomSheet<void>(
context: context,
isScrollControlled: true,
showDragHandle: true,
showCloseButton: false,
builder: (sheetContext) => SafeArea(
child: IconTheme.merge(
data: const IconThemeData(size: 22),
child: ConstrainedBox(
constraints: BoxConstraints(
maxHeight: MediaQuery.sizeOf(sheetContext).height * 0.7,
),
child: SingleChildScrollView(
child: Padding(
padding: const EdgeInsets.fromLTRB(
Grid.gutter,
0,
Grid.gutter,
Grid.xs,
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
_QuickReactionRow(
message: message,
sheetContext: sheetContext,
pageContext: context,
pageRef: ref,
),
const SizedBox(height: Grid.xs),
if (!message.isSystem) ...[
// Fast actions: respond now, hand off context, defer.
_FastActionsRow(
message: message,
channelId: channelId,
allMessages: allMessages,
currentPubkey: currentPubkey,
isMember: isMember,
isArchived: isArchived,
pageContext: context,
),
const SizedBox(height: Grid.xs),
// Triage: come back to this message later.
_MarkReadUnreadTile(message: message, channelId: channelId),
_FollowThreadTile(message: message),
const SheetDivider(),
// Export: take the content out of the conversation.
ListTile(
contentPadding: EdgeInsets.zero,
leading: const Icon(LucideIcons.copy),
title: Text(context.l10n.copyText),
onTap: () {
Navigator.of(sheetContext).pop();
// Copy to clipboard
final data = ClipboardData(text: message.content);
Clipboard.setData(data);
},
),
],
if (canManageMessage) ...[
if (!message.isSystem) const SheetDivider(),
ListTile(
contentPadding: EdgeInsets.zero,
leading: const Icon(LucideIcons.pencil),
title: Text(context.l10n.editMessage),
onTap: () {
Navigator.of(sheetContext).pop();
_showEditSheet(
context: context,
ref: ref,
message: message,
channelId: channelId,
);
},
),
ListTile(
contentPadding: EdgeInsets.zero,
leading: Icon(
LucideIcons.trash2,
color: sheetContext.colors.error,
),
title: Text(
context.l10n.deleteMessage,
style: TextStyle(color: sheetContext.colors.error),
),
onTap: () {
Navigator.of(sheetContext).pop();
_confirmDelete(
context: context,
ref: ref,
channelId: channelId,
messageId: message.id,
);
},
),
],
],
),
),
),
),
),
),
);
}
/// Image-focused actions shown from the full-screen viewer.
void showImageActions({
required BuildContext context,
required WidgetRef ref,
required TimelineMessage message,
required String channelId,
required String imageUrl,
required bool canManageMessage,
VoidCallback? onDeleted,
}) {
showBuzzModalBottomSheet<void>(
context: context,
isScrollControlled: true,
showDragHandle: true,
builder: (sheetContext) => SafeArea(
child: IconTheme.merge(
data: const IconThemeData(size: 22),
child: Padding(
padding: const EdgeInsets.fromLTRB(
Grid.gutter,
0,
Grid.gutter,
Grid.xs,
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
contentPadding: EdgeInsets.zero,
leading: const Icon(LucideIcons.download),
title: Text(context.l10n.saveImage),
onTap: () {
Navigator.of(sheetContext).pop();
unawaited(_saveImage(context, ref, imageUrl));
},
),
ListTile(
contentPadding: EdgeInsets.zero,
leading: const Icon(LucideIcons.share2),
title: Text(context.l10n.shareImage),
onTap: () {
final renderBox = context.findRenderObject() as RenderBox?;
final shareOrigin = renderBox == null
? null
: renderBox.localToGlobal(Offset.zero) & renderBox.size;
Navigator.of(sheetContext).pop();
unawaited(
_shareImage(
context,
ref,
imageUrl,
shareOrigin: shareOrigin,
),
);
},
),
ListTile(
contentPadding: EdgeInsets.zero,
leading: const Icon(LucideIcons.link2),
title: Text(context.l10n.copyImageLink),
onTap: () {
Navigator.of(sheetContext).pop();
copyToClipboard(
context,
imageUrl,
message: context.l10n.imageLinkCopied,
);
},
),
if (canManageMessage) ...[
const SheetDivider(),
ListTile(
contentPadding: EdgeInsets.zero,
leading: Icon(
LucideIcons.trash2,
color: sheetContext.colors.error,
),
title: Text(
context.l10n.deleteMessage,
style: TextStyle(color: sheetContext.colors.error),
),
onTap: () {
Navigator.of(sheetContext).pop();
_confirmDelete(
context: context,
ref: ref,
channelId: channelId,
messageId: message.id,
onDeleted: onDeleted,
);
},
),
],
],
),
),
),
),
);
}
@immutable
class _DownloadedImage {
final Uint8List bytes;
final String filename;
const _DownloadedImage({required this.bytes, required this.filename});
}
Future<_DownloadedImage> _downloadImage(WidgetRef ref, String imageUrl) async {
final response = await ref
.read(mediaHttpClientProvider)
.get(
Uri.parse(imageUrl),
headers: ref.read(mediaGetAuthServiceProvider).headersFor(imageUrl),
);
if (response.statusCode < 200 || response.statusCode >= 300) {
throw HttpException(
'Image download failed (${response.statusCode})',
uri: Uri.parse(imageUrl),
);
}
return _DownloadedImage(
bytes: response.bodyBytes,
filename: downloadedImageFilename(
imageUrl,
response.headers['content-type'],
),
);
}
/// Returns a safe image filename while preserving supported image formats.
@visibleForTesting
String downloadedImageFilename(String imageUrl, String? contentType) {
final pathSegments = Uri.tryParse(imageUrl)?.pathSegments;
final rawName = pathSegments == null || pathSegments.isEmpty
? ''
: pathSegments.last;
final safeName = rawName
.replaceAll(RegExp(r'[^A-Za-z0-9._-]'), '-')
.replaceAll(RegExp(r'-+'), '-');
if (RegExp(
r'\.(avif|bmp|gif|heic|heif|jpe?g|png|webp)$',
caseSensitive: false,
).hasMatch(safeName)) {
return safeName;
}
final extension = switch (contentType?.split(';').first.trim()) {
'image/avif' => '.avif',
'image/gif' => '.gif',
'image/heic' => '.heic',
'image/heif' => '.heif',
'image/png' => '.png',
'image/webp' => '.webp',
_ => '.jpg',
};
return 'buzz-${DateTime.now().millisecondsSinceEpoch}$extension';
}
Future<void> _saveImage(
BuildContext context,
WidgetRef ref,
String imageUrl,
) async {
final messenger = ScaffoldMessenger.maybeOf(context);
try {
final needsPhotoLibraryPermission =
defaultTargetPlatform == TargetPlatform.iOS ||
await requiresLegacyMediaStoragePermission();
if (needsPhotoLibraryPermission) {
final permission = await PhotoManager.requestPermissionExtend(
requestOption: const PermissionRequestOption(
iosAccessLevel: IosAccessLevel.addOnly,
androidPermission: AndroidPermission(
type: RequestType.image,
mediaLocation: false,
),
),
);
if (!permission.isAuth) {
throw const FileSystemException(
'Photo library permission was not granted.',
);
}
}
final image = await _downloadImage(ref, imageUrl);
await PhotoManager.editor.saveImage(image.bytes, filename: image.filename);
messenger?.showSnackBar(
SnackBar(content: Text(context.l10n.imageSaved)),
);
} catch (_) {
messenger?.showSnackBar(
SnackBar(content: Text(context.l10n.imageSaveFailed)),
);
}
}
Future<void> _shareImage(
BuildContext context,
WidgetRef ref,
String imageUrl, {
Rect? shareOrigin,
}) async {
final messenger = ScaffoldMessenger.maybeOf(context);
try {
final image = await _downloadImage(ref, imageUrl);
final directory = await getTemporaryDirectory();
final file = File(
'${directory.path}${Platform.pathSeparator}${image.filename}',
);
await file.writeAsBytes(image.bytes, flush: true);
await SharePlus.instance.share(
ShareParams(files: [XFile(file.path)], sharePositionOrigin: shareOrigin),
);
} catch (_) {
messenger?.showSnackBar(
SnackBar(content: Text(context.l10n.imageShareFailed)),
);
}
}
/// Canonical `buzz://message` link for a timeline message, including thread
/// context when the message is a reply.
String messageLinkFor({
required TimelineMessage message,
required String channelId,
}) {
return buildMessageLink(
channelId: channelId,
messageId: message.id,
threadRootId: message.rootId,
);
}
class _MarkReadUnreadTile extends ConsumerWidget {
final TimelineMessage message;
final String channelId;
const _MarkReadUnreadTile({required this.message, required this.channelId});
@override
Widget build(BuildContext context, WidgetRef ref) {
final readState = ref.watch(readStateProvider);
if (!readState.isReady) return const SizedBox.shrink();
final unread = isMessageUnread(
readState,
channelId: channelId,
messageId: message.id,
createdAt: message.createdAt,
threadRootId: message.rootId,
);
return ListTile(
contentPadding: EdgeInsets.zero,
leading: Icon(unread ? LucideIcons.mailCheck : LucideIcons.mailOpen),
title: Text(unread ? context.l10n.markRead : context.l10n.markUnread),
onTap: () {
Navigator.of(context).pop();
final notifier = ref.read(readStateProvider.notifier);
if (unread) {
// Advances the `msg:` marker and clears this message's own forced
// flag — a channel-level forced unread (channel tile) is a separate
// choice and stays untouched.
notifier.markContextRead(
msgContextKey(message.id),
message.createdAt,
);
} else {
// Read markers are monotonic and cannot move backward, so mark
// unread forces this message unread locally (session-local, does
// not survive relaunch). The channel surfaces it as unread too.
notifier.markContextUnread(
msgContextKey(message.id),
channelId: channelId,
);
}
},
);
}
}
class _FollowThreadTile extends ConsumerWidget {
final TimelineMessage message;
const _FollowThreadTile({required this.message});
@override
Widget build(BuildContext context, WidgetRef ref) {
final follows = ref.watch(threadFollowsProvider);
// Follow acts on the effective thread root: the root for replies, the
// message itself for potential thread heads (matches desktop).
final rootId = message.rootId ?? message.id;
final following = follows.isFollowing(rootId);
return ListTile(
contentPadding: EdgeInsets.zero,
leading: Icon(following ? LucideIcons.bellOff : LucideIcons.bellRing),
title: Text(
following ? context.l10n.unfollowThread : context.l10n.followThread,
),
onTap: () {
Navigator.of(context).pop();
final notifier = ref.read(threadFollowsProvider.notifier);
if (following) {
notifier.unfollowThread(rootId);
} else {
notifier.followThread(rootId);
}
},
);
}
}
/// Promoted actions for the three dominant mobile jobs: respond now (Reply),
/// hand off context (Copy link — the `buzz://message` link is the workspace's
/// context-transfer primitive), and defer (Remind me).
class _FastActionsRow extends ConsumerWidget {
final TimelineMessage message;
final String channelId;
final List<TimelineMessage>? allMessages;
final String? currentPubkey;
final bool isMember;
final bool isArchived;
/// The long-pressed message's page context — survives the sheet pop, used
/// for the thread push and the copy-link snackbar.
final BuildContext pageContext;
const _FastActionsRow({
required this.message,
required this.channelId,
required this.allMessages,
required this.currentPubkey,
required this.isMember,
required this.isArchived,
required this.pageContext,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final messages = allMessages;
final canRemind = ref.watch(reminderServiceProvider) != null;
final tiles = <Widget>[
if (messages != null)
_FastActionTile(
icon: LucideIcons.messageSquareReply,
label: context.l10n.reply,
onTap: () {
Navigator.of(context).pop();
Navigator.of(pageContext).push(
MaterialPageRoute<void>(
builder: (_) => ThreadDetailPage(
threadHead: message,
allMessages: messages,
channelId: channelId,
currentPubkey: currentPubkey,
isMember: isMember,
isArchived: isArchived,
),
),
);
},
),
_FastActionTile(
icon: LucideIcons.link2,
label: context.l10n.copyLink,
onTap: () {
Navigator.of(context).pop();
copyToClipboard(
pageContext,
messageLinkFor(message: message, channelId: channelId),
message: pageContext.l10n.messageLinkCopied,
);
},
),
if (canRemind)
_FastActionTile(
icon: LucideIcons.clock,
label: context.l10n.remindMe,
onTap: () {
final rootContext = Navigator.of(
context,
rootNavigator: true,
).context;
Navigator.of(context).pop();
showRemindMeLaterSheet(
context: rootContext,
ref: ref,
target: ReminderTarget(
eventId: message.id,
channelId: channelId,
preview: message.content.characters
.take(_reminderPreviewLength)
.toString(),
authorPubkey: message.pubkey,
),
);
},
),
];
return Row(
children: [
for (var index = 0; index < tiles.length; index++) ...[
Expanded(child: tiles[index]),
if (index < tiles.length - 1) const SizedBox(width: Grid.twelve),
],
],
);
}
}
class _FastActionTile extends StatelessWidget {
final IconData icon;
final String label;
final VoidCallback onTap;
const _FastActionTile({
required this.icon,
required this.label,
required this.onTap,
});
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: () {
unawaited(HapticFeedback.lightImpact());
onTap();
},
behavior: HitTestBehavior.opaque,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// The full-width tiles deliberately contrast with the compact emoji
// reactions immediately above them.
Container(
height: 68 + (Grid.xxs * 2),
decoration: BoxDecoration(
color: context.colors.surfaceContainerHighest,
borderRadius: BorderRadius.circular(Radii.dialog),
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(icon, size: 22, color: context.colors.onSurface),
const SizedBox(height: Grid.xxs),
Text(
label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: context.textTheme.labelMedium?.copyWith(
color: context.colors.onSurface,
),
),
],
),
),
],
),
);
}
}
/// The row of one-tap reactions at the top of the action sheet, plus the "+"
/// tile that opens the full picker.
///
/// The emoji shown are the user's own frequently-used set (desktop's
/// `useQuickReactionEmojis` behaviour), topped up with [defaultQuickEmojis] so
/// the row is full on a fresh install.
class _QuickReactionRow extends ConsumerWidget {
final TimelineMessage message;
/// The sheet's context, popped before the reaction fires.
final BuildContext sheetContext;
/// The long-pressed message's page context — survives the sheet pop, so the
/// picker opened from "+" isn't torn down with the sheet.
final BuildContext pageContext;
/// The long-pressed message's page ref. The picker callback outlives this
/// bottom sheet, so it must not read through the sheet's disposed ref.
final WidgetRef pageRef;
/// Drives the staged glyph reveal when this row is shown in the popover.
/// The bottom sheet leaves this null and retains its existing static row.
final Animation<double>? presentationAnimation;
const _QuickReactionRow({
required this.message,
required this.sheetContext,
required this.pageContext,
required this.pageRef,
this.presentationAnimation,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final customEmoji = ref.watch(customEmojiListProvider);
final emoji = quickReactionEmoji(
ref.watch(recentEmojiProvider),
customShortcodes: {
for (final entry in customEmoji) entry.shortcode.toLowerCase(),
},
);
final customByShortcode = {
for (final entry in customEmoji) entry.shortcode.toLowerCase(): entry,
};
void react(String value) {
// The generic picker is also used for composing and statuses. Record
// recency here, at the reaction call site, so only reactions drive the
// quick-reaction row.
pageRef.read(recentEmojiProvider.notifier).record(value);
// The sheet is on its way out, so the burst can't come from this tile —
// hand it to the pill that's about to appear in the timeline.
armReactionBurst(pageRef, message, value);
pageRef.read(channelActionsProvider).addReaction(message.id, value);
}
return LayoutBuilder(
builder: (context, constraints) {
const desiredCircleSize = 52.0;
const minimumCircleSize = 44.0;
final itemCount = emoji.length + 1;
final gapCount = itemCount - 1;
final circleSize =
((constraints.maxWidth - (Grid.twelve * gapCount)) / itemCount)
.clamp(minimumCircleSize, desiredCircleSize)
.toDouble();
final gap =
((constraints.maxWidth - (circleSize * itemCount)) / gapCount)
.clamp(0.0, Grid.twelve)
.toDouble();
final circles = <Widget>[
for (var index = 0; index < emoji.length; index++)
_ReactionItemReveal(
key: ValueKey('quick-reaction-${emoji[index]}'),
animation: presentationAnimation,
index: index,
child: _QuickReactionCircle(
size: circleSize,
onTap: () {
Navigator.of(sheetContext).pop();
react(emoji[index]);
},
child: _QuickReactionGlyph(
value: emoji[index],
customByShortcode: customByShortcode,
),
),
),
_ReactionItemReveal(
key: const ValueKey('quick-reaction-more'),
animation: presentationAnimation,
index: emoji.length,
child: _QuickReactionCircle(
size: circleSize,
onTap: () {
Navigator.of(sheetContext).pop();
showEmojiPicker(context: pageContext, onSelect: react);
},
child: Icon(
LucideIcons.plus,
size: 24,
color: context.colors.onSurfaceVariant,
),
),
),
];
return Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
for (var index = 0; index < circles.length; index++) ...[
circles[index],
if (index < circles.length - 1) SizedBox(width: gap),
],
],
);
},
);
}
}
class _ReactionItemReveal extends StatelessWidget {
final Animation<double>? animation;
final int index;
final Widget child;
const _ReactionItemReveal({
super.key,
required this.animation,
required this.index,
required this.child,
});
@override
Widget build(BuildContext context) {
final parent = animation;
if (parent == null) return child;
final start = 0.06 + (index * 0.11);
final opacityEnd = math.min(1.0, start + 0.20);
final scaleEnd = math.min(1.0, start + 0.30);
final opacity = CurvedAnimation(
parent: parent,
curve: Interval(start, opacityEnd, curve: Curves.easeOutCubic),
);
final scale = Tween<double>(begin: 0.86, end: 1).animate(
CurvedAnimation(
parent: parent,
curve: Interval(start, scaleEnd, curve: _reactionSpringCurve),
),
);
return FadeTransition(
opacity: opacity,
child: ScaleTransition(scale: scale, child: child),
);
}
}
/// Renders a quick-reaction value: a Unicode glyph as text, a `:shortcode:` as
/// its custom-emoji image.
class _QuickReactionGlyph extends StatelessWidget {
final String value;
final Map<String, CustomEmoji> customByShortcode;
const _QuickReactionGlyph({
required this.value,
required this.customByShortcode,
});
@override
Widget build(BuildContext context) {
if (value.startsWith(':') && value.endsWith(':')) {
final shortcode = value.substring(1, value.length - 1).toLowerCase();
final custom = customByShortcode[shortcode];
if (custom != null) {
return CustomEmojiImage(
shortcode: custom.shortcode,
url: custom.url,
size: 24,
);
}
}
return NativeEmojiGlyph(emoji: value, size: 24);
}
}
/// Circular filled tap target shared by the quick-reaction emojis and the
/// "+" emoji-picker tile — one treatment, one place to change it.
class _QuickReactionCircle extends StatelessWidget {
final VoidCallback onTap;
final Widget child;
final double size;
const _QuickReactionCircle({
required this.onTap,
required this.child,
required this.size,
});
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: () {
unawaited(HapticFeedback.lightImpact());
onTap();
},
child: Container(
width: size,
height: size,
alignment: Alignment.center,
decoration: BoxDecoration(
color: Color.lerp(
context.colors.surface,
context.colors.primaryContainer,
0.78,
),
shape: BoxShape.circle,
),
child: child,
),
);
}
}
void _showEditSheet({
required BuildContext context,
required WidgetRef ref,
required TimelineMessage message,
required String channelId,
}) {
final controller = TextEditingController(text: message.content);
showBuzzModalBottomSheet<void>(
context: context,
isScrollControlled: true,
showDragHandle: true,
builder: (sheetContext) => Padding(
padding: EdgeInsets.fromLTRB(
Grid.gutter,
0,
Grid.gutter,
MediaQuery.viewInsetsOf(sheetContext).bottom,
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextField(
controller: controller,
autofocus: true,
minLines: 1,
maxLines: 5,
decoration: InputDecoration(hintText: context.l10n.editMessage),
),
const SizedBox(height: Grid.xxs),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
onPressed: () => Navigator.of(sheetContext).pop(),
child: Text(context.l10n.cancel),
),
const SizedBox(width: Grid.half),
FilledButton(
onPressed: () {
final text = controller.text.trim();
if (text.isEmpty || text == message.content) {
Navigator.of(sheetContext).pop();
return;
}
ref
.read(channelActionsProvider)
.editMessage(
channelId: channelId,
eventId: message.id,
content: text,
mediaTags: buildCustomEmojiTags(
text,
ref.read(customEmojiListProvider),
),
);
Navigator.of(sheetContext).pop();
},
child: Text(context.l10n.save),
),
],
),
],
),
),
);
}
void _confirmDelete({
required BuildContext context,
required WidgetRef ref,
required String channelId,
required String messageId,
VoidCallback? onDeleted,
}) {
showBuzzDialog<void>(
context: context,
builder: (dialogContext) => AlertDialog(
title: Text(context.l10n.deleteMessage),
content: Text(context.l10n.cannotUndo),
actions: [
TextButton(
onPressed: () => Navigator.of(dialogContext).pop(),
child: Text(context.l10n.cancel),
),
FilledButton(
onPressed: () async {
Navigator.of(dialogContext).pop();
final messenger = ScaffoldMessenger.of(context);
try {
await ref
.read(channelActionsProvider)
.deleteMessage(channelId: channelId, eventId: messageId);
onDeleted?.call();
} catch (error) {
messenger.showSnackBar(
SnackBar(
content: Text(context.l10n.deleteFailed(error.toString())),
),
);
}
},
style: FilledButton.styleFrom(
backgroundColor: dialogContext.colors.error,
),
child: Text(context.l10n.delete),
),
],
),
);
}