import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:gap/gap.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:island/database/message.dart'; import 'package:island/models/chat.dart'; import 'package:island/screens/chat/room.dart'; import 'package:island/widgets/app_scaffold.dart'; import 'package:island/widgets/content/cloud_file_collection.dart'; import 'package:island/widgets/content/cloud_files.dart'; import 'package:island/widgets/content/markdown.dart'; import 'package:material_symbols_icons/material_symbols_icons.dart'; import 'package:styled_widget/styled_widget.dart'; import 'package:super_context_menu/super_context_menu.dart'; class MessageItemAction { static const String edit = "edit"; static const String delete = "delete"; static const String reply = "reply"; static const String forward = "forward"; } class MessageItem extends HookConsumerWidget { final LocalChatMessage message; final bool isCurrentUser; final Function(String action)? onAction; final Map? progress; final bool showAvatar; const MessageItem({ super.key, required this.message, required this.isCurrentUser, required this.onAction, required this.progress, required this.showAvatar, }); @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 linkColor = Color.alphaBlend( Theme.of(context).colorScheme.primary, containerColor, ); final hasBackground = ref.watch(backgroundImageFileProvider).valueOrNull != null; final remoteMessage = message.toRemoteMessage(); final sender = remoteMessage.sender; return ContextMenuWidget( menuProvider: (_) { if (onAction == null) return Menu(children: []); return Menu( children: [ if (isCurrentUser) MenuAction( title: 'edit'.tr(), image: MenuImage.icon(Symbols.edit), callback: () { onAction!.call(MessageItemAction.edit); }, ), if (isCurrentUser) MenuAction( title: 'delete'.tr(), image: MenuImage.icon(Symbols.delete), callback: () { onAction!.call(MessageItemAction.delete); }, ), if (isCurrentUser) MenuSeparator(), MenuAction( title: 'reply'.tr(), image: MenuImage.icon(Symbols.reply), callback: () { onAction!.call(MessageItemAction.reply); }, ), MenuAction( title: 'forward'.tr(), image: MenuImage.icon(Symbols.forward), callback: () { onAction!.call(MessageItemAction.forward); }, ), ], ); }, child: Material( color: hasBackground ? Colors.transparent : Theme.of(context).colorScheme.surface, child: Padding( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ if (showAvatar) ...[ const Gap(8), Row( spacing: 8, mainAxisSize: MainAxisSize.min, children: [ ProfilePictureWidget( fileId: sender.account.profile.pictureId, radius: 16, ), Column( crossAxisAlignment: CrossAxisAlignment.start, spacing: 2, children: [ Text( DateTime.now().difference(message.createdAt).inDays > 365 ? DateFormat( 'yyyy/MM/dd HH:mm', ).format(message.createdAt.toLocal()) : DateTime.now() .difference(message.createdAt) .inDays > 0 ? DateFormat( 'MM/dd HH:mm', ).format(message.createdAt.toLocal()) : DateFormat( 'HH:mm', ).format(message.createdAt.toLocal()), style: TextStyle(fontSize: 10, color: textColor), ), Row( mainAxisSize: MainAxisSize.min, spacing: 5, children: [ Text( sender.account.nick, style: Theme.of(context).textTheme.bodySmall, ), Badge( label: Text( sender.role >= 100 ? 'permissionOwner' : sender.role >= 50 ? 'permissionModerator' : 'permissionMember', ).tr(), ), ], ), ], ), ], ), 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 (remoteMessage.content?.isNotEmpty ?? false) MarkdownTextContent( content: remoteMessage.content!, isSelectable: true, linkStyle: TextStyle(color: linkColor), textStyle: TextStyle( color: textColor, fontSize: 14, ), ), if (remoteMessage.attachments.isNotEmpty) LayoutBuilder( builder: (context, constraints) { return CloudFileList( files: remoteMessage.attachments, maxWidth: constraints.maxWidth, ).padding(vertical: 4); }, ), if (progress != null && progress!.isNotEmpty) Column( crossAxisAlignment: CrossAxisAlignment.stretch, spacing: 8, children: [ if ((remoteMessage.content?.isNotEmpty ?? false)) const Gap(0), for (var entry in progress!.entries) Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( 'fileUploadingProgress'.tr( args: [ (entry.key + 1).toString(), entry.value.toStringAsFixed(1), ], ), style: TextStyle( fontSize: 12, color: textColor.withOpacity(0.8), ), ), const Gap(4), LinearProgressIndicator( value: entry.value / 100, backgroundColor: Theme.of( context, ).colorScheme.surfaceVariant, valueColor: AlwaysStoppedAnimation( Theme.of( context, ).colorScheme.primary, ), ), ], ), const Gap(0), ], ), ], ), ), ), _buildMessageIndicators( context, textColor, remoteMessage, message, isCurrentUser, ), ], ), ], ), ), ), ); } Widget _buildMessageIndicators( BuildContext context, Color textColor, SnChatMessage remoteMessage, LocalChatMessage message, bool isCurrentUser, ) { return Row( spacing: 4, mainAxisSize: MainAxisSize.min, children: [ if (remoteMessage.editedAt != null) Text( 'edited'.tr().toLowerCase(), style: TextStyle(fontSize: 11, color: textColor.withOpacity(0.7)), ), if (isCurrentUser) _buildStatusIcon( context, message.status, textColor.withOpacity(0.7), ).padding(bottom: 3), ], ); } Widget _buildStatusIcon( BuildContext context, MessageStatus status, Color textColor, ) { switch (status) { case MessageStatus.pending: return Icon(Icons.access_time, size: 12, color: textColor); case MessageStatus.sent: return Icon(Icons.check, size: 12, color: textColor); case MessageStatus.failed: return Consumer( builder: (context, ref, _) => GestureDetector( onTap: () { ref .read(messagesNotifierProvider(message.roomId).notifier) .retryMessage(message.id); }, child: const Icon( Icons.error_outline, size: 12, color: Colors.red, ), ), ); } } } 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( future: messagesNotifier.fetchMessageById( isReply ? message.toRemoteMessage().repliedMessageId! : message.toRemoteMessage().forwardedMessageId!, ), builder: (context, snapshot) { if (snapshot.hasData) { return ClipRRect( borderRadius: BorderRadius.all(Radius.circular(8)), 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( 'Replying to ${snapshot.data!.toRemoteMessage().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 from ${snapshot.data!.toRemoteMessage().sender.account.nick}', ).textColor(textColor).bold(), ], ).padding(right: 8), if (snapshot.data!.toRemoteMessage().content?.isNotEmpty ?? false) Text( snapshot.data!.toRemoteMessage().content!, style: TextStyle(color: textColor), ), ], ), ), ).padding(bottom: 4); } else { return SizedBox.shrink(); } }, ); } }