import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:flutter_context_menu/flutter_context_menu.dart'; import 'package:gap/gap.dart'; import 'package:material_symbols_icons/symbols.dart'; import 'package:provider/provider.dart'; import 'package:styled_widget/styled_widget.dart'; import 'package:surface/providers/user_directory.dart'; import 'package:surface/providers/userinfo.dart'; import 'package:surface/types/chat.dart'; import 'package:surface/widgets/account/account_image.dart'; import 'package:surface/widgets/attachment/attachment_list.dart'; import 'package:surface/widgets/link_preview.dart'; import 'package:surface/widgets/markdown_content.dart'; import 'package:swipe_to/swipe_to.dart'; class ChatMessage extends StatelessWidget { final SnChatMessage data; final bool isCompact; final bool isMerged; final bool hasMerged; final bool isPending; final Function(SnChatMessage)? onReply; final Function(SnChatMessage)? onEdit; final Function(SnChatMessage)? onDelete; const ChatMessage({ super.key, required this.data, this.isCompact = false, this.isMerged = false, this.hasMerged = false, this.isPending = false, this.onReply, this.onEdit, this.onDelete, }); @override Widget build(BuildContext context) { final ua = context.read(); final ud = context.read(); final user = ud.getAccountFromCache(data.sender.accountId); final isOwner = ua.isAuthorized && data.sender.accountId == ua.user?.id; final dateFormatter = DateFormat('MM/dd HH:mm'); return SwipeTo( key: Key('chat-message-${data.id}'), iconOnLeftSwipe: Symbols.reply, iconOnRightSwipe: Symbols.edit, swipeSensitivity: 20, onLeftSwipe: onReply != null ? (_) => onReply!(data) : null, onRightSwipe: onEdit != null ? (_) => onEdit!(data) : null, child: ContextMenuRegion( contextMenu: ContextMenu( entries: [ MenuHeader(text: "eventResourceTag".tr(args: ['#${data.id}'])), if (onReply != null) MenuItem( label: 'reply'.tr(), icon: Symbols.reply, onSelected: () { onReply!(data); }, ), if (isOwner && data.type == 'messages.new' && onEdit != null) MenuItem( label: 'edit'.tr(), icon: Symbols.edit, onSelected: () { onEdit!(data); }, ), if (isOwner && data.type == 'messages.new' && onDelete != null) MenuItem( label: 'delete'.tr(), icon: Symbols.delete, onSelected: () { onDelete!(data); }, ), ], ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ if (!isMerged && !isCompact) AccountImage( content: user?.avatar, ) else if (isMerged) const Gap(40), const Gap(8), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ if (!isMerged) Row( crossAxisAlignment: CrossAxisAlignment.baseline, textBaseline: TextBaseline.alphabetic, children: [ if (isCompact) AccountImage( content: user?.avatar, radius: 12, ).padding(right: 6), Text( (data.sender.nick?.isNotEmpty ?? false) ? data.sender.nick! : user?.nick ?? 'unknown', ).bold(), const Gap(6), Text( dateFormatter.format(data.createdAt.toLocal()), ).fontSize(13), ], ), if (isCompact) const Gap(4), if (data.preload?.quoteEvent != null) StyledWidget(Container( decoration: BoxDecoration( borderRadius: const BorderRadius.all(Radius.circular(8)), border: Border.all( color: Theme.of(context).dividerColor, width: 1, ), ), padding: const EdgeInsets.only( left: 4, right: 4, top: 8, bottom: 6, ), child: ChatMessage( data: data.preload!.quoteEvent!, isCompact: true, onReply: onReply, onEdit: onEdit, onDelete: onDelete, ), )).padding(bottom: 4, top: isMerged ? 4 : 2), switch (data.type) { 'messages.new' => _ChatMessageText(data: data), _ => _ChatMessageSystemNotify(data: data), }, ], ), ) ], ).opacity(isPending ? 0.5 : 1), if (data.body['text'] != null && (data.body['text']?.isNotEmpty ?? false)) LinkPreviewWidget(text: data.body['text']!), if (data.preload?.attachments?.isNotEmpty ?? false) AttachmentList( data: data.preload!.attachments!, bordered: true, noGrow: true, maxHeight: 520, listPadding: const EdgeInsets.only(top: 8), ), if (!hasMerged && !isCompact) const Gap(12) else if (!isCompact) const Gap(6), ], ), ), ); } } class _ChatMessageText extends StatelessWidget { final SnChatMessage data; const _ChatMessageText({super.key, required this.data}); @override Widget build(BuildContext context) { if (data.body['text'] != null && data.body['text'].isNotEmpty) { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ MarkdownTextContent( content: data.body['text'], isSelectable: true, isAutoWarp: true, ), if (data.updatedAt != data.createdAt) Text( 'messageEditedHint'.tr(), ).fontSize(13).opacity(0.75), ], ); } else if (data.body['attachments']?.isNotEmpty) { return Row( children: [ const Icon(Symbols.file_present, size: 20), const Gap(4), Text( 'messageFileHint'.plural( data.body['attachments']!.length, ), ), ], ).opacity(0.75); } return const SizedBox.shrink(); } } class _ChatMessageSystemNotify extends StatelessWidget { final SnChatMessage data; const _ChatMessageSystemNotify({super.key, required this.data}); String _formatDuration(Duration duration) { String negativeSign = duration.isNegative ? '-' : ''; String twoDigits(int n) => n.toString().padLeft(2, '0'); String twoDigitMinutes = twoDigits(duration.inMinutes.remainder(60).abs()); String twoDigitSeconds = twoDigits(duration.inSeconds.remainder(60).abs()); return '$negativeSign${twoDigits(duration.inHours)}:$twoDigitMinutes:$twoDigitSeconds'; } @override Widget build(BuildContext context) { switch (data.type) { case 'messages.edit': return Row( children: [ const Icon(Symbols.edit, size: 20), const Gap(4), Text( 'messageEdited'.tr(args: ['#${data.relatedEventId}']), ), ], ).opacity(0.75); case 'messages.delete': return Row( children: [ const Icon(Symbols.delete, size: 20), const Gap(4), Text( 'messageDeleted'.tr(args: ['#${data.relatedEventId}']), ), ], ).opacity(0.75); case 'calls.start': return Row( children: [ const Icon(Symbols.call, size: 20), const Gap(4), Text( 'callMessageStarted'.tr(), ), ], ).opacity(0.75); case 'calls.end': return Row( children: [ const Icon(Symbols.call_end, size: 20), const Gap(4), Text( 'callMessageEnded'.tr(args: [ _formatDuration(Duration(seconds: data.body['last'])), ]), ), ], ).opacity(0.75); default: return Row( children: [ const Icon(Symbols.info, size: 20), const Gap(4), Text('messageUnsupported'.tr(args: [data.type])), ], ).opacity(0.75); } } }