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? 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( 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( 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 _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 _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? 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 = [ if (messages != null) _FastActionTile( icon: LucideIcons.messageSquareReply, label: context.l10n.reply, onTap: () { Navigator.of(context).pop(); Navigator.of(pageContext).push( MaterialPageRoute( 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? 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 = [ 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? 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(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 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( 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( 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), ), ], ), ); }