1293 lines
		
	
	
		
			46 KiB
		
	
	
	
		
			Dart
		
	
	
	
	
	
			
		
		
	
	
			1293 lines
		
	
	
		
			46 KiB
		
	
	
	
		
			Dart
		
	
	
	
	
	
| import 'dart:async';
 | |
| import 'dart:io';
 | |
| 
 | |
| import 'package:easy_localization/easy_localization.dart';
 | |
| import 'package:flutter/foundation.dart';
 | |
| import 'package:flutter/material.dart';
 | |
| import 'package:flutter/services.dart';
 | |
| import 'package:flutter_hooks/flutter_hooks.dart';
 | |
| import 'package:gap/gap.dart';
 | |
| import 'package:hooks_riverpod/hooks_riverpod.dart';
 | |
| import 'package:island/database/message.dart';
 | |
| import 'package:island/pods/chat/chat_rooms.dart';
 | |
| import 'package:island/pods/chat/messages_notifier.dart';
 | |
| import 'package:island/pods/translate.dart';
 | |
| import 'package:island/pods/config.dart';
 | |
| import 'package:island/services/time.dart';
 | |
| import 'package:island/widgets/account/account_pfc.dart';
 | |
| import 'package:island/widgets/chat/message_content.dart';
 | |
| import 'package:island/widgets/chat/message_indicators.dart';
 | |
| import 'package:island/widgets/chat/message_sender_info.dart';
 | |
| import 'package:island/widgets/alert.dart';
 | |
| import 'package:island/widgets/content/cloud_file_collection.dart';
 | |
| import 'package:island/widgets/content/cloud_files.dart';
 | |
| import 'package:island/widgets/content/embed/embed_list.dart';
 | |
| import 'package:island/widgets/post/post_shared.dart';
 | |
| import 'package:material_symbols_icons/material_symbols_icons.dart';
 | |
| import 'package:styled_widget/styled_widget.dart';
 | |
| import 'package:swipe_to/swipe_to.dart';
 | |
| import 'package:island/widgets/content/sheet.dart';
 | |
| 
 | |
| class MessageItemAction {
 | |
|   static const String edit = "edit";
 | |
|   static const String delete = "delete";
 | |
|   static const String reply = "reply";
 | |
|   static const String forward = "forward";
 | |
|   static const String resend = "resend";
 | |
| }
 | |
| 
 | |
| class MessageItem extends HookConsumerWidget {
 | |
|   final LocalChatMessage message;
 | |
|   final bool isCurrentUser;
 | |
|   final Function(String action)? onAction;
 | |
|   final Map<int, double>? progress;
 | |
|   final bool showAvatar;
 | |
|   final Function(String messageId) onJump;
 | |
|   final bool isSelectionMode;
 | |
|   final bool isSelected;
 | |
|   final Function(String messageId)? onToggleSelection;
 | |
|   final Function()? onEnterSelectionMode;
 | |
| 
 | |
|   const MessageItem({
 | |
|     super.key,
 | |
|     required this.message,
 | |
|     required this.isCurrentUser,
 | |
|     required this.onAction,
 | |
|     required this.progress,
 | |
|     required this.showAvatar,
 | |
|     required this.onJump,
 | |
|     this.isSelectionMode = false,
 | |
|     this.isSelected = false,
 | |
|     this.onToggleSelection,
 | |
|     this.onEnterSelectionMode,
 | |
|   });
 | |
| 
 | |
|   static const kFlashDuration = 300;
 | |
| 
 | |
|   @override
 | |
|   Widget build(BuildContext context, WidgetRef ref) {
 | |
|     final remoteMessage = message.toRemoteMessage();
 | |
|     final settings = ref.watch(appSettingsNotifierProvider);
 | |
| 
 | |
|     final isMobile = !kIsWeb && (Platform.isAndroid || Platform.isIOS);
 | |
| 
 | |
|     final currentLanguage = context.locale.toString();
 | |
|     final translatableLanguage = remoteMessage.content?.isNotEmpty ?? false;
 | |
| 
 | |
|     final translating = useState(false);
 | |
|     final translatedText = useState<String?>(null);
 | |
| 
 | |
|     Future<void> translate() async {
 | |
|       if (translatedText.value != null) {
 | |
|         translatedText.value = null;
 | |
|         return;
 | |
|       }
 | |
| 
 | |
|       if (translating.value) return;
 | |
|       if (remoteMessage.content == null) return;
 | |
|       translating.value = true;
 | |
|       try {
 | |
|         final text = await ref.watch(
 | |
|           translateStringProvider(
 | |
|             TranslateQuery(
 | |
|               text: remoteMessage.content!,
 | |
|               lang: currentLanguage.substring(0, 2),
 | |
|             ),
 | |
|           ).future,
 | |
|         );
 | |
|         translatedText.value = text;
 | |
|       } catch (err) {
 | |
|         showErrorAlert(err);
 | |
|       } finally {
 | |
|         translating.value = false;
 | |
|       }
 | |
|     }
 | |
| 
 | |
|     void showActionMenu() {
 | |
|       if (onAction == null) return;
 | |
|       showModalBottomSheet(
 | |
|         context: context,
 | |
|         builder:
 | |
|             (context) => MessageActionSheet(
 | |
|               isCurrentUser: isCurrentUser,
 | |
|               onAction: onAction,
 | |
|               translatableLanguage: translatableLanguage,
 | |
|               translating: translating.value,
 | |
|               translatedText: translatedText.value,
 | |
|               translate: translate,
 | |
|               isMobile: isMobile,
 | |
|               remoteMessage: remoteMessage,
 | |
|               message: message,
 | |
|               onToggleSelection: onToggleSelection,
 | |
|               onEnterSelectionMode: onEnterSelectionMode,
 | |
|             ),
 | |
|       );
 | |
|     }
 | |
| 
 | |
|     final flashing = ref.watch(
 | |
|       flashingMessagesProvider.select((set) => set.contains(message.id)),
 | |
|     );
 | |
| 
 | |
|     final isFlashing = useState(false);
 | |
|     final flashTimer = useState<Timer?>(null);
 | |
| 
 | |
|     useEffect(() {
 | |
|       if (flashing) {
 | |
|         flashTimer.value?.cancel();
 | |
|         isFlashing.value = true;
 | |
|         flashTimer.value = Timer.periodic(
 | |
|           const Duration(milliseconds: kFlashDuration),
 | |
|           (timer) {
 | |
|             isFlashing.value = !isFlashing.value;
 | |
|             if (timer.tick >= 6) {
 | |
|               // 6 ticks: 1, 0, 1, 0, 1, 0
 | |
|               timer.cancel();
 | |
|               flashTimer.value = null;
 | |
|               isFlashing.value = false;
 | |
|               ref
 | |
|                   .read(flashingMessagesProvider.notifier)
 | |
|                   .update((set) => set.difference({message.id}));
 | |
|             }
 | |
|           },
 | |
|         );
 | |
|       } else {
 | |
|         flashTimer.value?.cancel();
 | |
|         flashTimer.value = null;
 | |
|         isFlashing.value = false;
 | |
|       }
 | |
|       return () {
 | |
|         flashTimer.value?.cancel();
 | |
|       };
 | |
|     }, [flashing]);
 | |
| 
 | |
|     final flashColor =
 | |
|         isFlashing.value
 | |
|             ? Theme.of(context).colorScheme.primaryContainer.withOpacity(0.8)
 | |
|             : Colors.transparent;
 | |
| 
 | |
|     final isHovered = useState(false);
 | |
| 
 | |
|     return Stack(
 | |
|       clipBehavior: Clip.none,
 | |
|       children: [
 | |
|         SwipeTo(
 | |
|           swipeSensitivity: 15,
 | |
|           rightSwipeWidget: Transform.flip(
 | |
|             flipX: true,
 | |
|             child: Icon(Symbols.menu_open),
 | |
|           ).padding(left: 16),
 | |
|           leftSwipeWidget: Icon(
 | |
|             isCurrentUser ? Symbols.forward : Symbols.reply,
 | |
|           ).padding(right: 16),
 | |
|           onLeftSwipe: (details) {
 | |
|             if (onAction != null) {
 | |
|               if (isCurrentUser) {
 | |
|                 onAction!(MessageItemAction.forward);
 | |
|               } else {
 | |
|                 onAction!(MessageItemAction.reply);
 | |
|               }
 | |
|             }
 | |
|           },
 | |
|           onRightSwipe: (details) => showActionMenu(),
 | |
|           child: InkWell(
 | |
|             mouseCursor: MouseCursor.defer,
 | |
|             focusColor: Colors.transparent,
 | |
|             onLongPress: () {
 | |
|               if (isSelectionMode && onToggleSelection != null) {
 | |
|                 onToggleSelection!(message.id);
 | |
|               } else {
 | |
|                 showActionMenu();
 | |
|               }
 | |
|             },
 | |
|             onSecondaryTap: showActionMenu,
 | |
|             onTap: () {
 | |
|               if (isSelectionMode && onToggleSelection != null) {
 | |
|                 onToggleSelection!(message.id);
 | |
|               } else {
 | |
|                 // Jump to related message
 | |
|                 if ([
 | |
|                       'messages.update',
 | |
|                       'messages.delete',
 | |
|                     ].contains(message.type) &&
 | |
|                     message.meta['message_id'] is String &&
 | |
|                     message.meta['message_id'] != null) {
 | |
|                   onJump(message.meta['message_id']);
 | |
|                 }
 | |
|               }
 | |
|             },
 | |
|             child: SizedBox(
 | |
|               width: double.infinity,
 | |
|               child: MouseRegion(
 | |
|                 onEnter: (_) => isHovered.value = true,
 | |
|                 onExit: (_) => isHovered.value = false,
 | |
|                 child: AnimatedContainer(
 | |
|                   curve: Curves.easeInOut,
 | |
|                   duration: const Duration(milliseconds: kFlashDuration),
 | |
|                   decoration: BoxDecoration(color: flashColor),
 | |
|                   child: switch (settings.messageDisplayStyle) {
 | |
|                     'compact' => MessageItemDisplayIRC(
 | |
|                       message: message,
 | |
|                       isCurrentUser: isCurrentUser,
 | |
|                       progress: progress,
 | |
|                       showAvatar: showAvatar,
 | |
|                       onJump: onJump,
 | |
|                       translatedText: translatedText.value,
 | |
|                       translating: translating.value,
 | |
|                     ),
 | |
|                     'column' => MessageItemDisplayDiscord(
 | |
|                       message: message,
 | |
|                       isCurrentUser: isCurrentUser,
 | |
|                       progress: progress,
 | |
|                       showAvatar: showAvatar,
 | |
|                       onJump: onJump,
 | |
|                       translatedText: translatedText.value,
 | |
|                       translating: translating.value,
 | |
|                     ),
 | |
|                     _ => MessageItemDisplayBubble(
 | |
|                       message: message,
 | |
|                       isCurrentUser: isCurrentUser,
 | |
|                       progress: progress,
 | |
|                       showAvatar: showAvatar,
 | |
|                       onJump: onJump,
 | |
|                       translatedText: translatedText.value,
 | |
|                       translating: translating.value,
 | |
|                     ),
 | |
|                   },
 | |
|                 ),
 | |
|               ),
 | |
|             ),
 | |
|           ),
 | |
|         ),
 | |
|         if (isHovered.value && !isMobile)
 | |
|           Positioned(
 | |
|             top: -15,
 | |
|             right: 15,
 | |
|             child: MouseRegion(
 | |
|               onEnter: (_) => isHovered.value = true,
 | |
|               onExit: (_) => isHovered.value = false,
 | |
|               child: MessageHoverActionMenu(
 | |
|                 isCurrentUser: isCurrentUser,
 | |
|                 onAction: onAction,
 | |
|                 translatableLanguage: translatableLanguage,
 | |
|                 translating: translating.value,
 | |
|                 translatedText: translatedText.value,
 | |
|                 translate: translate,
 | |
|                 remoteMessage: remoteMessage,
 | |
|               ),
 | |
|             ),
 | |
|           ),
 | |
|       ],
 | |
|     );
 | |
|   }
 | |
| }
 | |
| 
 | |
| class MessageActionSheet extends StatefulWidget {
 | |
|   final bool isCurrentUser;
 | |
|   final Function(String action)? onAction;
 | |
|   final bool translatableLanguage;
 | |
|   final bool translating;
 | |
|   final String? translatedText;
 | |
|   final VoidCallback translate;
 | |
|   final bool isMobile;
 | |
|   final dynamic remoteMessage;
 | |
|   final LocalChatMessage message;
 | |
|   final Function(String messageId)? onToggleSelection;
 | |
|   final Function()? onEnterSelectionMode;
 | |
| 
 | |
|   const MessageActionSheet({
 | |
|     super.key,
 | |
|     required this.isCurrentUser,
 | |
|     required this.onAction,
 | |
|     required this.translatableLanguage,
 | |
|     required this.translating,
 | |
|     required this.translatedText,
 | |
|     required this.translate,
 | |
|     required this.isMobile,
 | |
|     required this.remoteMessage,
 | |
|     required this.message,
 | |
|     this.onToggleSelection,
 | |
|     this.onEnterSelectionMode,
 | |
|   });
 | |
| 
 | |
|   @override
 | |
|   State<MessageActionSheet> createState() => _MessageActionSheetState();
 | |
| }
 | |
| 
 | |
| class _MessageActionSheetState extends State<MessageActionSheet> {
 | |
|   bool _isExpanded = false;
 | |
|   static const int _maxPreviewLines = 3;
 | |
| 
 | |
|   String get _displayContent {
 | |
|     return widget.translatedText ?? widget.remoteMessage.content ?? '';
 | |
|   }
 | |
| 
 | |
|   bool get _shouldShowExpandButton {
 | |
|     // Simple check: show expand button if content is not empty
 | |
|     // The actual line limiting is handled by maxLines in SelectableText
 | |
|     return (widget.translatedText ?? widget.remoteMessage.content ?? '')
 | |
|         .isNotEmpty;
 | |
|   }
 | |
| 
 | |
|   @override
 | |
|   Widget build(BuildContext context) {
 | |
|     return SheetScaffold(
 | |
|       titleText: 'messageActions'.tr(),
 | |
|       child: SingleChildScrollView(
 | |
|         child: Column(
 | |
|           crossAxisAlignment: CrossAxisAlignment.start,
 | |
|           mainAxisSize: MainAxisSize.min,
 | |
|           children: [
 | |
|             // Message content preview section
 | |
|             if (widget.remoteMessage.content?.isNotEmpty ?? false) ...[
 | |
|               Container(
 | |
|                 margin: const EdgeInsets.fromLTRB(16, 16, 16, 0),
 | |
|                 padding: const EdgeInsets.symmetric(
 | |
|                   horizontal: 16,
 | |
|                   vertical: 12,
 | |
|                 ),
 | |
|                 decoration: BoxDecoration(
 | |
|                   color: Theme.of(
 | |
|                     context,
 | |
|                   ).colorScheme.surfaceContainerHighest.withOpacity(0.3),
 | |
|                   borderRadius: BorderRadius.circular(12),
 | |
|                   border: Border.all(
 | |
|                     color: Theme.of(
 | |
|                       context,
 | |
|                     ).colorScheme.outlineVariant.withOpacity(0.5),
 | |
|                     width: 1,
 | |
|                   ),
 | |
|                 ),
 | |
|                 child: Column(
 | |
|                   crossAxisAlignment: CrossAxisAlignment.start,
 | |
|                   mainAxisSize: MainAxisSize.min,
 | |
|                   children: [
 | |
|                     // Header
 | |
|                     SizedBox(
 | |
|                       height: 24,
 | |
|                       child: Row(
 | |
|                         children: [
 | |
|                           Icon(
 | |
|                             Symbols.article,
 | |
|                             size: 16,
 | |
|                             color: Theme.of(context).colorScheme.primary,
 | |
|                           ),
 | |
|                           const Gap(6),
 | |
|                           Text(
 | |
|                             'messageContent'.tr(),
 | |
|                             style: TextStyle(
 | |
|                               fontSize: 12,
 | |
|                               fontWeight: FontWeight.w500,
 | |
|                               color: Theme.of(context).colorScheme.primary,
 | |
|                             ),
 | |
|                           ),
 | |
|                           const Spacer(),
 | |
|                           if (_shouldShowExpandButton)
 | |
|                             IconButton(
 | |
|                               visualDensity: VisualDensity.compact,
 | |
|                               icon: Icon(
 | |
|                                 _isExpanded
 | |
|                                     ? Symbols.expand_less
 | |
|                                     : Symbols.expand_more,
 | |
|                                 size: 16,
 | |
|                               ),
 | |
|                               onPressed: () {
 | |
|                                 setState(() {
 | |
|                                   _isExpanded = !_isExpanded;
 | |
|                                 });
 | |
|                               },
 | |
|                               padding: EdgeInsets.zero,
 | |
|                               constraints: const BoxConstraints(
 | |
|                                 minWidth: 32,
 | |
|                                 minHeight: 24,
 | |
|                               ),
 | |
|                             ),
 | |
|                         ],
 | |
|                       ),
 | |
|                     ),
 | |
|                     const Gap(8),
 | |
|                     // Selectable content
 | |
|                     SelectableText(
 | |
|                       _displayContent,
 | |
|                       style: TextStyle(
 | |
|                         fontSize: 14,
 | |
|                         height: 1.4,
 | |
|                         color: Theme.of(context).colorScheme.onSurfaceVariant,
 | |
|                       ),
 | |
|                       minLines: 1,
 | |
|                       maxLines: _isExpanded ? null : _maxPreviewLines,
 | |
|                       textAlign: TextAlign.start,
 | |
|                     ),
 | |
|                   ],
 | |
|                 ),
 | |
|               ),
 | |
|               const Gap(4),
 | |
|             ],
 | |
| 
 | |
|             Row(
 | |
|               spacing: 6,
 | |
|               children: [
 | |
|                 Icon(Symbols.send, size: 16),
 | |
|                 Text(
 | |
|                   'messageSentAt'.tr(
 | |
|                     args: [widget.message.createdAt.formatSystem()],
 | |
|                   ),
 | |
|                 ).fontSize(13),
 | |
|               ],
 | |
|             ).opacity(0.75).padding(horizontal: 20, top: 8, bottom: 6),
 | |
| 
 | |
|             const Divider(),
 | |
| 
 | |
|             // Action buttons
 | |
|             if (widget.isCurrentUser)
 | |
|               _ActionListTile(
 | |
|                 leading: Icon(Symbols.edit),
 | |
|                 title: Text('edit'.tr()),
 | |
|                 onTap: () {
 | |
|                   widget.onAction!.call(MessageItemAction.edit);
 | |
|                   Navigator.pop(context);
 | |
|                 },
 | |
|               ),
 | |
|             if (widget.isCurrentUser &&
 | |
|                 widget.message.status == MessageStatus.failed)
 | |
|               _ActionListTile(
 | |
|                 leading: Icon(Symbols.refresh),
 | |
|                 title: Text('resend'.tr()),
 | |
|                 onTap: () {
 | |
|                   widget.onAction!.call(MessageItemAction.resend);
 | |
|                   Navigator.pop(context);
 | |
|                 },
 | |
|               ),
 | |
|             if (widget.isCurrentUser)
 | |
|               _ActionListTile(
 | |
|                 leading: Icon(Symbols.delete),
 | |
|                 title: Text('delete'.tr()),
 | |
|                 onTap: () {
 | |
|                   widget.onAction!.call(MessageItemAction.delete);
 | |
|                   Navigator.pop(context);
 | |
|                 },
 | |
|               ),
 | |
|             if (widget.isCurrentUser) const Divider(),
 | |
| 
 | |
|             _ActionListTile(
 | |
|               leading: Icon(Symbols.reply),
 | |
|               title: Text('reply'.tr()),
 | |
|               onTap: () {
 | |
|                 widget.onAction!.call(MessageItemAction.reply);
 | |
|                 Navigator.pop(context);
 | |
|               },
 | |
|             ),
 | |
|             _ActionListTile(
 | |
|               leading: Icon(Symbols.forward),
 | |
|               title: Text('forward'.tr()),
 | |
|               onTap: () {
 | |
|                 widget.onAction!.call(MessageItemAction.forward);
 | |
|                 Navigator.pop(context);
 | |
|               },
 | |
|             ),
 | |
| 
 | |
|             // AI Selection action
 | |
|             _ActionListTile(
 | |
|               leading: Icon(Symbols.smart_toy),
 | |
|               title: Text('Select for AI'),
 | |
|               onTap: () {
 | |
|                 if (widget.onEnterSelectionMode != null) {
 | |
|                   widget.onEnterSelectionMode!();
 | |
|                   if (widget.onToggleSelection != null) {
 | |
|                     widget.onToggleSelection!(widget.message.id);
 | |
|                   }
 | |
|                 }
 | |
|                 Navigator.pop(context);
 | |
|               },
 | |
|             ),
 | |
| 
 | |
|             if (widget.translatableLanguage) const Divider(),
 | |
|             if (widget.translatableLanguage)
 | |
|               _ActionListTile(
 | |
|                 leading: Icon(Symbols.translate),
 | |
|                 title: Text(
 | |
|                   widget.translatedText == null
 | |
|                       ? 'translate'.tr()
 | |
|                       : widget.translating
 | |
|                       ? 'translating'.tr()
 | |
|                       : 'translated'.tr(),
 | |
|                 ),
 | |
|                 onTap: () {
 | |
|                   widget.translate();
 | |
|                   Navigator.pop(context);
 | |
|                 },
 | |
|               ),
 | |
| 
 | |
|             if (widget.isMobile) const Divider(),
 | |
|             if (widget.isMobile)
 | |
|               _ActionListTile(
 | |
|                 leading: Icon(Symbols.copy_all),
 | |
|                 title: Text('copyMessage'.tr()),
 | |
|                 onTap: () {
 | |
|                   Clipboard.setData(
 | |
|                     ClipboardData(text: widget.remoteMessage.content ?? ''),
 | |
|                   );
 | |
|                   Navigator.pop(context);
 | |
|                 },
 | |
|               ),
 | |
| 
 | |
|             Gap(MediaQuery.of(context).padding.bottom + 32),
 | |
|           ],
 | |
|         ),
 | |
|       ),
 | |
|     );
 | |
|   }
 | |
| }
 | |
| 
 | |
| class _ActionListTile extends StatelessWidget {
 | |
|   final Widget leading;
 | |
|   final Widget title;
 | |
|   final VoidCallback onTap;
 | |
| 
 | |
|   const _ActionListTile({
 | |
|     required this.leading,
 | |
|     required this.title,
 | |
|     required this.onTap,
 | |
|   });
 | |
| 
 | |
|   @override
 | |
|   Widget build(BuildContext context) {
 | |
|     return InkWell(
 | |
|       onTap: onTap,
 | |
|       child: Padding(
 | |
|         padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
 | |
|         child: Row(
 | |
|           children: [
 | |
|             SizedBox(width: 24, height: 24, child: leading),
 | |
|             const Gap(12),
 | |
|             Expanded(child: title),
 | |
|             Icon(
 | |
|               Symbols.chevron_right,
 | |
|               size: 16,
 | |
|               color: Theme.of(context).colorScheme.outline,
 | |
|             ),
 | |
|           ],
 | |
|         ),
 | |
|       ),
 | |
|     );
 | |
|   }
 | |
| }
 | |
| 
 | |
| class MessageHoverActionMenu extends StatelessWidget {
 | |
|   final bool isCurrentUser;
 | |
|   final Function(String action)? onAction;
 | |
|   final bool translatableLanguage;
 | |
|   final bool translating;
 | |
|   final String? translatedText;
 | |
|   final VoidCallback translate;
 | |
|   final dynamic remoteMessage;
 | |
| 
 | |
|   const MessageHoverActionMenu({
 | |
|     super.key,
 | |
|     required this.isCurrentUser,
 | |
|     required this.onAction,
 | |
|     required this.translatableLanguage,
 | |
|     required this.translating,
 | |
|     required this.translatedText,
 | |
|     required this.translate,
 | |
|     required this.remoteMessage,
 | |
|   });
 | |
| 
 | |
|   Future<void> _handleDelete(BuildContext context) async {
 | |
|     final confirmed = await showConfirmAlert(
 | |
|       'deleteMessageConfirmation'.tr(),
 | |
|       'deleteMessage'.tr(),
 | |
|     );
 | |
| 
 | |
|     if (confirmed) {
 | |
|       onAction?.call(MessageItemAction.delete);
 | |
|     }
 | |
|   }
 | |
| 
 | |
|   @override
 | |
|   Widget build(BuildContext context) {
 | |
|     // General actions (available for all users)
 | |
|     final generalActions = [
 | |
|       if (!isCurrentUser) // Hide reply for message author
 | |
|         IconButton(
 | |
|           icon: Icon(Symbols.reply, size: 16),
 | |
|           onPressed: () => onAction?.call(MessageItemAction.reply),
 | |
|           tooltip: 'reply'.tr(),
 | |
|           padding: const EdgeInsets.all(8),
 | |
|           constraints: const BoxConstraints(minWidth: 32, minHeight: 32),
 | |
|         ),
 | |
|       IconButton(
 | |
|         icon: Icon(Symbols.forward, size: 16),
 | |
|         onPressed: () => onAction?.call(MessageItemAction.forward),
 | |
|         tooltip: 'forward'.tr(),
 | |
|         padding: const EdgeInsets.all(8),
 | |
|         constraints: const BoxConstraints(minWidth: 32, minHeight: 32),
 | |
|       ),
 | |
|       if (translatableLanguage)
 | |
|         IconButton(
 | |
|           icon: Icon(Symbols.translate, size: 16),
 | |
|           onPressed: translate,
 | |
|           tooltip:
 | |
|               translatedText == null ? 'translate'.tr() : 'translated'.tr(),
 | |
|           padding: const EdgeInsets.all(8),
 | |
|           constraints: const BoxConstraints(minWidth: 32, minHeight: 32),
 | |
|         ),
 | |
|     ];
 | |
| 
 | |
|     // Author-only actions (edit/delete)
 | |
|     final authorActions = [
 | |
|       if (isCurrentUser)
 | |
|         IconButton(
 | |
|           icon: Icon(Symbols.edit, size: 16),
 | |
|           onPressed: () => onAction?.call(MessageItemAction.edit),
 | |
|           tooltip: 'edit'.tr(),
 | |
|           padding: const EdgeInsets.all(8),
 | |
|           constraints: const BoxConstraints(minWidth: 32, minHeight: 32),
 | |
|         ),
 | |
|       if (isCurrentUser)
 | |
|         IconButton(
 | |
|           icon: Icon(Symbols.delete, size: 16, color: Colors.red),
 | |
|           onPressed: () => _handleDelete(context),
 | |
|           tooltip: 'delete'.tr(),
 | |
|           padding: const EdgeInsets.all(8),
 | |
|           constraints: const BoxConstraints(minWidth: 32, minHeight: 32),
 | |
|         ),
 | |
|     ];
 | |
| 
 | |
|     return Container(
 | |
|       decoration: BoxDecoration(
 | |
|         color: Theme.of(context).colorScheme.surfaceContainerHighest,
 | |
|         borderRadius: BorderRadius.circular(20),
 | |
|         boxShadow: [
 | |
|           BoxShadow(
 | |
|             color: Colors.black.withOpacity(0.1),
 | |
|             blurRadius: 4,
 | |
|             offset: const Offset(0, 2),
 | |
|           ),
 | |
|         ],
 | |
|       ),
 | |
|       child: Row(
 | |
|         mainAxisSize: MainAxisSize.min,
 | |
|         children: [
 | |
|           // General actions (left side)
 | |
|           ...generalActions,
 | |
|           // Separator (only if both general and author actions exist)
 | |
|           if (generalActions.isNotEmpty && authorActions.isNotEmpty)
 | |
|             Container(
 | |
|               width: 1,
 | |
|               height: 24,
 | |
|               color: Theme.of(context).colorScheme.outlineVariant,
 | |
|               margin: const EdgeInsets.symmetric(horizontal: 4),
 | |
|             ),
 | |
|           // Author actions (right side)
 | |
|           ...authorActions,
 | |
|         ],
 | |
|       ),
 | |
|     );
 | |
|   }
 | |
| }
 | |
| 
 | |
| class MessageItemDisplayBubble extends HookConsumerWidget {
 | |
|   final LocalChatMessage message;
 | |
|   final bool isCurrentUser;
 | |
|   final Map<int, double>? progress;
 | |
|   final bool showAvatar;
 | |
|   final Function(String messageId) onJump;
 | |
|   final String? translatedText;
 | |
|   final bool translating;
 | |
| 
 | |
|   const MessageItemDisplayBubble({
 | |
|     super.key,
 | |
|     required this.message,
 | |
|     required this.isCurrentUser,
 | |
|     required this.progress,
 | |
|     required this.showAvatar,
 | |
|     required this.onJump,
 | |
|     required this.translatedText,
 | |
|     required this.translating,
 | |
|   });
 | |
| 
 | |
|   @override
 | |
|   Widget build(BuildContext context, WidgetRef ref) {
 | |
|     final textColor =
 | |
|         isCurrentUser
 | |
|             ? Theme.of(context).colorScheme.onPrimaryContainer
 | |
|             : Theme.of(context).colorScheme.onSurfaceVariant;
 | |
|     final containerColor =
 | |
|         isCurrentUser
 | |
|             ? Theme.of(context).colorScheme.primaryContainer.withOpacity(0.5)
 | |
|             : Theme.of(context).colorScheme.surfaceContainer;
 | |
| 
 | |
|     final remoteMessage = message.toRemoteMessage();
 | |
|     final sender = remoteMessage.sender;
 | |
| 
 | |
|     return Material(
 | |
|       color: Colors.transparent,
 | |
|       child: Padding(
 | |
|         padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
 | |
|         child: Column(
 | |
|           crossAxisAlignment: CrossAxisAlignment.start,
 | |
|           mainAxisSize: MainAxisSize.min,
 | |
|           children: [
 | |
|             if (showAvatar) ...[
 | |
|               const Gap(8),
 | |
|               MessageSenderInfo(
 | |
|                 sender: sender,
 | |
|                 createdAt: message.createdAt,
 | |
|                 textColor: textColor,
 | |
|               ),
 | |
|               const Gap(4),
 | |
|             ],
 | |
|             const Gap(2),
 | |
|             Row(
 | |
|               spacing: 4,
 | |
|               mainAxisSize: MainAxisSize.min,
 | |
|               crossAxisAlignment: CrossAxisAlignment.end,
 | |
|               children: [
 | |
|                 Flexible(
 | |
|                   child: Container(
 | |
|                     decoration: BoxDecoration(
 | |
|                       color: containerColor,
 | |
|                       borderRadius: BorderRadius.circular(16),
 | |
|                     ),
 | |
|                     padding: const EdgeInsets.symmetric(
 | |
|                       horizontal: 12,
 | |
|                       vertical: 6,
 | |
|                     ),
 | |
|                     child: Column(
 | |
|                       crossAxisAlignment: CrossAxisAlignment.start,
 | |
|                       children: [
 | |
|                         if (remoteMessage.repliedMessageId != null)
 | |
|                           MessageQuoteWidget(
 | |
|                             message: message,
 | |
|                             textColor: textColor,
 | |
|                             isReply: true,
 | |
|                           ).padding(vertical: 4),
 | |
|                         if (remoteMessage.forwardedMessageId != null)
 | |
|                           MessageQuoteWidget(
 | |
|                             message: message,
 | |
|                             textColor: textColor,
 | |
|                             isReply: false,
 | |
|                           ).padding(vertical: 4),
 | |
|                         if (MessageContent.hasContent(remoteMessage))
 | |
|                           MessageContent(
 | |
|                             item: remoteMessage,
 | |
|                             translatedText: translatedText,
 | |
|                           ),
 | |
|                         if (remoteMessage.attachments.isNotEmpty)
 | |
|                           LayoutBuilder(
 | |
|                             builder: (context, constraints) {
 | |
|                               return CloudFileList(
 | |
|                                 files: remoteMessage.attachments,
 | |
|                                 maxWidth: constraints.maxWidth,
 | |
|                                 padding: EdgeInsets.symmetric(vertical: 4),
 | |
|                               );
 | |
|                             },
 | |
|                           ),
 | |
|                         if (remoteMessage.meta['embeds'] != null &&
 | |
|                             kMessageEnableEmbedTypes.contains(message.type))
 | |
|                           EmbedListWidget(
 | |
|                             embeds:
 | |
|                                 remoteMessage.meta['embeds'] as List<dynamic>,
 | |
|                             isInteractive: true,
 | |
|                             isFullPost: false,
 | |
|                             renderingPadding: EdgeInsets.zero,
 | |
|                             maxWidth: 480,
 | |
|                           ),
 | |
|                         FileUploadProgressWidget(
 | |
|                           progress: progress,
 | |
|                           textColor: textColor,
 | |
|                           hasContent:
 | |
|                               remoteMessage.content?.isNotEmpty ?? false,
 | |
|                         ),
 | |
|                       ],
 | |
|                     ),
 | |
|                   ),
 | |
|                 ),
 | |
|                 MessageIndicators(
 | |
|                   editedAt: remoteMessage.editedAt,
 | |
|                   status: message.status,
 | |
|                   isCurrentUser: isCurrentUser,
 | |
|                   textColor: textColor,
 | |
|                 ),
 | |
|               ],
 | |
|             ),
 | |
|           ],
 | |
|         ),
 | |
|       ),
 | |
|     );
 | |
|   }
 | |
| }
 | |
| 
 | |
| class MessageItemDisplayIRC extends HookConsumerWidget {
 | |
|   final LocalChatMessage message;
 | |
|   final bool isCurrentUser;
 | |
|   final Map<int, double>? progress;
 | |
|   final bool showAvatar;
 | |
|   final Function(String messageId) onJump;
 | |
|   final String? translatedText;
 | |
|   final bool translating;
 | |
| 
 | |
|   const MessageItemDisplayIRC({
 | |
|     super.key,
 | |
|     required this.message,
 | |
|     required this.isCurrentUser,
 | |
|     required this.progress,
 | |
|     required this.showAvatar,
 | |
|     required this.onJump,
 | |
|     required this.translatedText,
 | |
|     required this.translating,
 | |
|   });
 | |
| 
 | |
|   @override
 | |
|   Widget build(BuildContext context, WidgetRef ref) {
 | |
|     final remoteMessage = message.toRemoteMessage();
 | |
|     final sender = remoteMessage.sender;
 | |
|     final textColor = Theme.of(context).colorScheme.onSurfaceVariant;
 | |
| 
 | |
|     final isMultiline =
 | |
|         message.type == 'text' ||
 | |
|         message.repliedMessageId != null ||
 | |
|         message.forwardedMessageId != null;
 | |
| 
 | |
|     return Padding(
 | |
|       padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 2),
 | |
|       child: Row(
 | |
|         crossAxisAlignment:
 | |
|             isMultiline ? CrossAxisAlignment.start : CrossAxisAlignment.center,
 | |
|         children: [
 | |
|           Text(
 | |
|             DateFormat('HH:mm').format(message.createdAt.toLocal()),
 | |
|             style: TextStyle(color: textColor.withOpacity(0.7), fontSize: 12),
 | |
|           ).padding(top: isMultiline ? 2 : 0),
 | |
|           AccountPfcGestureDetector(
 | |
|             uname: sender.account.name,
 | |
|             child: Row(
 | |
|               crossAxisAlignment: CrossAxisAlignment.center,
 | |
|               children: [
 | |
|                 ProfilePictureWidget(
 | |
|                   file: sender.account.profile.picture,
 | |
|                   radius: 8,
 | |
|                 ).padding(horizontal: 6, top: isMultiline ? 2 : 0),
 | |
|                 Text(
 | |
|                   sender.account.nick,
 | |
|                   style: TextStyle(
 | |
|                     color: Theme.of(context).colorScheme.primary,
 | |
|                   ),
 | |
|                 ),
 | |
|               ],
 | |
|             ),
 | |
|           ),
 | |
|           const Gap(8),
 | |
|           Expanded(
 | |
|             child: Row(
 | |
|               mainAxisSize: MainAxisSize.min,
 | |
|               crossAxisAlignment: CrossAxisAlignment.end,
 | |
|               children: [
 | |
|                 Flexible(
 | |
|                   child: Column(
 | |
|                     crossAxisAlignment: CrossAxisAlignment.start,
 | |
|                     children: [
 | |
|                       if (remoteMessage.repliedMessageId != null)
 | |
|                         MessageQuoteWidget(
 | |
|                           message: message,
 | |
|                           textColor: textColor,
 | |
|                           isReply: true,
 | |
|                         ).padding(vertical: 4),
 | |
|                       if (remoteMessage.forwardedMessageId != null)
 | |
|                         MessageQuoteWidget(
 | |
|                           message: message,
 | |
|                           textColor: textColor,
 | |
|                           isReply: false,
 | |
|                         ).padding(vertical: 4),
 | |
|                       if (MessageContent.hasContent(remoteMessage))
 | |
|                         MessageContent(
 | |
|                           item: remoteMessage,
 | |
|                           translatedText: translatedText,
 | |
|                         ),
 | |
|                       if (remoteMessage.attachments.isNotEmpty)
 | |
|                         LayoutBuilder(
 | |
|                           builder: (context, constraints) {
 | |
|                             return CloudFileList(
 | |
|                               files: remoteMessage.attachments,
 | |
|                               maxWidth: constraints.maxWidth,
 | |
|                               padding: EdgeInsets.symmetric(vertical: 4),
 | |
|                             );
 | |
|                           },
 | |
|                         ),
 | |
|                       if (remoteMessage.meta['embeds'] != null &&
 | |
|                           kMessageEnableEmbedTypes.contains(message.type))
 | |
|                         EmbedListWidget(
 | |
|                           embeds: remoteMessage.meta['embeds'] as List<dynamic>,
 | |
|                           isInteractive: true,
 | |
|                           isFullPost: false,
 | |
|                           renderingPadding: EdgeInsets.zero,
 | |
|                           maxWidth: 480,
 | |
|                         ),
 | |
|                       FileUploadProgressWidget(
 | |
|                         progress: progress,
 | |
|                         textColor: textColor,
 | |
|                         hasContent: remoteMessage.content?.isNotEmpty ?? false,
 | |
|                       ),
 | |
|                     ],
 | |
|                   ),
 | |
|                 ),
 | |
|                 MessageIndicators(
 | |
|                   editedAt: remoteMessage.editedAt,
 | |
|                   status: message.status,
 | |
|                   isCurrentUser: isCurrentUser,
 | |
|                   textColor: textColor,
 | |
|                 ),
 | |
|               ],
 | |
|             ),
 | |
|           ),
 | |
|         ],
 | |
|       ),
 | |
|     );
 | |
|   }
 | |
| }
 | |
| 
 | |
| class MessageItemDisplayDiscord extends HookConsumerWidget {
 | |
|   final LocalChatMessage message;
 | |
|   final bool isCurrentUser;
 | |
|   final Map<int, double>? progress;
 | |
|   final bool showAvatar;
 | |
|   final Function(String messageId) onJump;
 | |
|   final String? translatedText;
 | |
|   final bool translating;
 | |
| 
 | |
|   const MessageItemDisplayDiscord({
 | |
|     super.key,
 | |
|     required this.message,
 | |
|     required this.isCurrentUser,
 | |
|     required this.progress,
 | |
|     required this.showAvatar,
 | |
|     required this.onJump,
 | |
|     required this.translatedText,
 | |
|     required this.translating,
 | |
|   });
 | |
| 
 | |
|   @override
 | |
|   Widget build(BuildContext context, WidgetRef ref) {
 | |
|     final textColor = Theme.of(context).colorScheme.onSurfaceVariant;
 | |
|     final remoteMessage = message.toRemoteMessage();
 | |
|     final sender = remoteMessage.sender;
 | |
| 
 | |
|     const kAvatarRadius = 12.0;
 | |
| 
 | |
|     return Padding(
 | |
|       padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
 | |
|       child:
 | |
|           showAvatar
 | |
|               ? Column(
 | |
|                 crossAxisAlignment: CrossAxisAlignment.start,
 | |
|                 children: [
 | |
|                   Row(
 | |
|                     spacing: 8,
 | |
|                     children: [
 | |
|                       AccountPfcGestureDetector(
 | |
|                         uname: sender.account.name,
 | |
|                         child: ProfilePictureWidget(
 | |
|                           file: sender.account.profile.picture,
 | |
|                           radius: kAvatarRadius,
 | |
|                         ),
 | |
|                       ),
 | |
|                       MessageSenderInfo(
 | |
|                         sender: sender,
 | |
|                         createdAt: message.createdAt,
 | |
|                         textColor: textColor,
 | |
|                         showAvatar: false,
 | |
|                         isCompact: true,
 | |
|                       ),
 | |
|                     ],
 | |
|                   ),
 | |
|                   Row(
 | |
|                     mainAxisSize: MainAxisSize.min,
 | |
|                     crossAxisAlignment: CrossAxisAlignment.end,
 | |
|                     children: [
 | |
|                       Flexible(
 | |
|                         child: Column(
 | |
|                           crossAxisAlignment: CrossAxisAlignment.start,
 | |
|                           children: [
 | |
|                             if (remoteMessage.repliedMessageId != null)
 | |
|                               MessageQuoteWidget(
 | |
|                                 message: message,
 | |
|                                 textColor: textColor,
 | |
|                                 isReply: true,
 | |
|                               ).padding(vertical: 4),
 | |
|                             if (remoteMessage.forwardedMessageId != null)
 | |
|                               MessageQuoteWidget(
 | |
|                                 message: message,
 | |
|                                 textColor: textColor,
 | |
|                                 isReply: false,
 | |
|                               ).padding(vertical: 4),
 | |
|                             if (MessageContent.hasContent(remoteMessage))
 | |
|                               MessageContent(
 | |
|                                 item: remoteMessage,
 | |
|                                 translatedText: translatedText,
 | |
|                               ),
 | |
|                             if (remoteMessage.attachments.isNotEmpty)
 | |
|                               LayoutBuilder(
 | |
|                                 builder: (context, constraints) {
 | |
|                                   return CloudFileList(
 | |
|                                     files: remoteMessage.attachments,
 | |
|                                     maxWidth: constraints.maxWidth,
 | |
|                                     padding: EdgeInsets.symmetric(vertical: 4),
 | |
|                                   );
 | |
|                                 },
 | |
|                               ),
 | |
|                             if (remoteMessage.meta['embeds'] != null &&
 | |
|                                 kMessageEnableEmbedTypes.contains(message.type))
 | |
|                               EmbedListWidget(
 | |
|                                 embeds:
 | |
|                                     remoteMessage.meta['embeds']
 | |
|                                         as List<dynamic>,
 | |
|                                 isInteractive: true,
 | |
|                                 isFullPost: false,
 | |
|                                 renderingPadding: EdgeInsets.zero,
 | |
|                                 maxWidth: 480,
 | |
|                               ),
 | |
|                             FileUploadProgressWidget(
 | |
|                               progress: progress,
 | |
|                               textColor: textColor,
 | |
|                               hasContent:
 | |
|                                   remoteMessage.content?.isNotEmpty ?? false,
 | |
|                             ),
 | |
|                           ],
 | |
|                         ),
 | |
|                       ),
 | |
|                       MessageIndicators(
 | |
|                         editedAt: remoteMessage.editedAt,
 | |
|                         status: message.status,
 | |
|                         isCurrentUser: isCurrentUser,
 | |
|                         textColor: textColor,
 | |
|                       ),
 | |
|                     ],
 | |
|                   ).padding(left: kAvatarRadius * 2 + 8),
 | |
|                 ],
 | |
|               )
 | |
|               : Padding(
 | |
|                 padding: EdgeInsets.only(left: kAvatarRadius * 2 + 8),
 | |
|                 child: Row(
 | |
|                   mainAxisSize: MainAxisSize.min,
 | |
|                   crossAxisAlignment: CrossAxisAlignment.end,
 | |
|                   children: [
 | |
|                     Flexible(
 | |
|                       child: Column(
 | |
|                         crossAxisAlignment: CrossAxisAlignment.start,
 | |
|                         children: [
 | |
|                           if (remoteMessage.repliedMessageId != null)
 | |
|                             MessageQuoteWidget(
 | |
|                               message: message,
 | |
|                               textColor: textColor,
 | |
|                               isReply: true,
 | |
|                             ).padding(vertical: 4),
 | |
|                           if (remoteMessage.forwardedMessageId != null)
 | |
|                             MessageQuoteWidget(
 | |
|                               message: message,
 | |
|                               textColor: textColor,
 | |
|                               isReply: false,
 | |
|                             ).padding(vertical: 4),
 | |
|                           if (MessageContent.hasContent(remoteMessage))
 | |
|                             MessageContent(
 | |
|                               item: remoteMessage,
 | |
|                               translatedText: translatedText,
 | |
|                             ),
 | |
|                           if (remoteMessage.attachments.isNotEmpty)
 | |
|                             LayoutBuilder(
 | |
|                               builder: (context, constraints) {
 | |
|                                 return CloudFileList(
 | |
|                                   files: remoteMessage.attachments,
 | |
|                                   maxWidth: constraints.maxWidth,
 | |
|                                   padding: EdgeInsets.symmetric(vertical: 4),
 | |
|                                 );
 | |
|                               },
 | |
|                             ),
 | |
|                           if (remoteMessage.meta['embeds'] != null &&
 | |
|                               kMessageEnableEmbedTypes.contains(message.type))
 | |
|                             EmbedListWidget(
 | |
|                               embeds:
 | |
|                                   remoteMessage.meta['embeds'] as List<dynamic>,
 | |
|                               isInteractive: true,
 | |
|                               isFullPost: false,
 | |
|                               renderingPadding: EdgeInsets.zero,
 | |
|                               maxWidth: 480,
 | |
|                             ),
 | |
|                           FileUploadProgressWidget(
 | |
|                             progress: progress,
 | |
|                             textColor: textColor,
 | |
|                             hasContent:
 | |
|                                 remoteMessage.content?.isNotEmpty ?? false,
 | |
|                           ),
 | |
|                         ],
 | |
|                       ),
 | |
|                     ),
 | |
|                     MessageIndicators(
 | |
|                       editedAt: remoteMessage.editedAt,
 | |
|                       status: message.status,
 | |
|                       isCurrentUser: isCurrentUser,
 | |
|                       textColor: textColor,
 | |
|                     ),
 | |
|                   ],
 | |
|                 ),
 | |
|               ),
 | |
|     );
 | |
|   }
 | |
| }
 | |
| 
 | |
| class MessageQuoteWidget extends HookConsumerWidget {
 | |
|   final LocalChatMessage message;
 | |
|   final Color textColor;
 | |
|   final bool isReply;
 | |
| 
 | |
|   const MessageQuoteWidget({
 | |
|     super.key,
 | |
|     required this.message,
 | |
|     required this.textColor,
 | |
|     required this.isReply,
 | |
|   });
 | |
| 
 | |
|   @override
 | |
|   Widget build(BuildContext context, WidgetRef ref) {
 | |
|     final messagesNotifier = ref.watch(
 | |
|       messagesNotifierProvider(message.roomId).notifier,
 | |
|     );
 | |
| 
 | |
|     return FutureBuilder<LocalChatMessage?>(
 | |
|       future: messagesNotifier.fetchMessageById(
 | |
|         isReply
 | |
|             ? message.toRemoteMessage().repliedMessageId!
 | |
|             : message.toRemoteMessage().forwardedMessageId!,
 | |
|       ),
 | |
|       builder: (context, snapshot) {
 | |
|         final remoteMessage =
 | |
|             snapshot.hasData ? snapshot.data!.toRemoteMessage() : null;
 | |
| 
 | |
|         if (remoteMessage != null) {
 | |
|           return ClipRRect(
 | |
|             borderRadius: BorderRadius.all(Radius.circular(8)),
 | |
|             child: GestureDetector(
 | |
|               onTap: () {
 | |
|                 final messageId =
 | |
|                     isReply
 | |
|                         ? message.toRemoteMessage().repliedMessageId!
 | |
|                         : message.toRemoteMessage().forwardedMessageId!;
 | |
|                 // Find the nearest MessageItem ancestor and call its onJump method
 | |
|                 final MessageItem? ancestor =
 | |
|                     context.findAncestorWidgetOfExactType<MessageItem>();
 | |
|                 if (ancestor != null) {
 | |
|                   ancestor.onJump(messageId);
 | |
|                 }
 | |
|               },
 | |
|               child: Container(
 | |
|                 padding: EdgeInsets.symmetric(vertical: 4, horizontal: 6),
 | |
|                 color: Theme.of(
 | |
|                   context,
 | |
|                 ).colorScheme.primaryFixedDim.withOpacity(0.4),
 | |
|                 child: Column(
 | |
|                   crossAxisAlignment: CrossAxisAlignment.start,
 | |
|                   children: [
 | |
|                     if (isReply)
 | |
|                       Row(
 | |
|                         mainAxisSize: MainAxisSize.min,
 | |
|                         spacing: 4,
 | |
|                         children: [
 | |
|                           Icon(Symbols.reply, size: 16, color: textColor),
 | |
|                           Text(
 | |
|                             '${'repliedTo'.tr()} ${remoteMessage.sender.account.nick}',
 | |
|                           ).textColor(textColor).bold(),
 | |
|                         ],
 | |
|                       ).padding(right: 8)
 | |
|                     else
 | |
|                       Row(
 | |
|                         mainAxisSize: MainAxisSize.min,
 | |
|                         spacing: 4,
 | |
|                         children: [
 | |
|                           Icon(Symbols.forward, size: 16, color: textColor),
 | |
|                           Text(
 | |
|                             '${'forwarded'.tr()} ${remoteMessage.sender.account.nick}',
 | |
|                           ).textColor(textColor).bold(),
 | |
|                         ],
 | |
|                       ).padding(right: 8),
 | |
|                     if (MessageContent.hasContent(remoteMessage))
 | |
|                       MessageContent(item: remoteMessage),
 | |
|                     if (remoteMessage.attachments.isNotEmpty)
 | |
|                       Row(
 | |
|                         mainAxisSize: MainAxisSize.min,
 | |
|                         children: [
 | |
|                           Icon(Symbols.attach_file, size: 12, color: textColor),
 | |
|                           const SizedBox(width: 4),
 | |
|                           Text(
 | |
|                             'hasAttachments'.plural(
 | |
|                               remoteMessage.attachments.length,
 | |
|                             ),
 | |
|                             style: TextStyle(color: textColor, fontSize: 12),
 | |
|                           ),
 | |
|                         ],
 | |
|                       ).padding(vertical: 2),
 | |
|                   ],
 | |
|                 ),
 | |
|               ),
 | |
|             ),
 | |
|           ).padding(bottom: 4);
 | |
|         } else {
 | |
|           return SizedBox.shrink();
 | |
|         }
 | |
|       },
 | |
|     );
 | |
|   }
 | |
| }
 | |
| 
 | |
| class FileUploadProgressWidget extends StatelessWidget {
 | |
|   final Map<int, double>? progress;
 | |
|   final Color textColor;
 | |
|   final bool hasContent;
 | |
| 
 | |
|   const FileUploadProgressWidget({
 | |
|     super.key,
 | |
|     required this.progress,
 | |
|     required this.textColor,
 | |
|     required this.hasContent,
 | |
|   });
 | |
| 
 | |
|   @override
 | |
|   Widget build(BuildContext context) {
 | |
|     if (progress == null || progress!.isEmpty) return const SizedBox.shrink();
 | |
| 
 | |
|     return Column(
 | |
|       crossAxisAlignment: CrossAxisAlignment.stretch,
 | |
|       spacing: 8,
 | |
|       children: [
 | |
|         if (hasContent) const Gap(0),
 | |
|         for (var entry in progress!.entries)
 | |
|           Column(
 | |
|             crossAxisAlignment: CrossAxisAlignment.start,
 | |
|             children: [
 | |
|               Text(
 | |
|                 'fileUploadingProgress'.tr(
 | |
|                   args: [
 | |
|                     (entry.key + 1).toString(),
 | |
|                     (entry.value * 100).toStringAsFixed(1),
 | |
|                   ],
 | |
|                 ),
 | |
|                 style: TextStyle(
 | |
|                   fontSize: 12,
 | |
|                   color: textColor.withOpacity(0.8),
 | |
|                 ),
 | |
|               ),
 | |
|               const Gap(4),
 | |
|               LinearProgressIndicator(
 | |
|                 value: entry.value,
 | |
|                 backgroundColor: Theme.of(context).colorScheme.surfaceVariant,
 | |
|                 valueColor: AlwaysStoppedAnimation<Color>(
 | |
|                   Theme.of(context).colorScheme.primary,
 | |
|                 ),
 | |
|                 trackGap: 0,
 | |
|               ),
 | |
|             ],
 | |
|           ),
 | |
|         const Gap(0),
 | |
|       ],
 | |
|     );
 | |
|   }
 | |
| }
 |