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/config.dart'; import 'package:surface/providers/keypair.dart'; import 'package:surface/providers/translation.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/account/account_popover.dart'; import 'package:surface/widgets/account/badge.dart'; import 'package:surface/widgets/attachment/attachment_list.dart'; import 'package:surface/widgets/context_menu.dart'; import 'package:surface/widgets/dialog.dart'; import 'package:surface/widgets/link_preview.dart'; import 'package:surface/widgets/markdown_content.dart'; import 'package:flutter_animate/flutter_animate.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; final EdgeInsets padding; 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, this.padding = const EdgeInsets.only(left: 12, right: 12), }); @override Widget build(BuildContext context) { final ua = context.read(); final ud = context.read(); final user = ud.getFromCache(data.sender.accountId); final isOwner = ua.isAuthorized && data.sender.accountId == ua.user?.id; final dateFormatter = DateFormat('MM/dd HH:mm'); final cfg = context.read(); return SwipeTo( key: Key('chat-message-${data.id}'), iconOnLeftSwipe: Symbols.reply, iconOnRightSwipe: Symbols.edit, swipeSensitivity: 10, onLeftSwipe: onReply != null ? (_) => onReply!(data) : null, onRightSwipe: (onEdit != null && isOwner) ? (_) => onEdit!(data) : null, child: ContextMenuArea( 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: [ Padding( padding: isCompact ? EdgeInsets.zero : padding, child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ if (!isMerged && !isCompact) GestureDetector( child: AccountImage( content: user?.avatar, badge: (user?.badges.isNotEmpty ?? false) ? AccountBadge( badge: user!.badges.first, radius: 16, padding: EdgeInsets.all(2), ) : null, ), onTap: () { if (user == null) return; showModalBottomSheet( context: context, builder: (context) => AccountPopoverCard(data: user), ); }, ) else if (isMerged) const Gap(40), const Gap(8), Expanded( child: Container( constraints: BoxConstraints(maxWidth: 480), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ if (!isMerged) Row( crossAxisAlignment: CrossAxisAlignment.center, children: [ if (isCompact) AccountImage( content: user?.avatar, radius: 12) .padding(right: 8), Text( (data.sender.nick?.isNotEmpty ?? false) ? data.sender.nick! : user?.nick ?? 'unknown', ).bold(), const Gap(8), Text(dateFormatter .format(data.createdAt.toLocal())) .fontSize(13), ], ).height(21), if (isCompact) const Gap(8), if (data.preload?.quoteEvent != null) StyledWidget( Container( constraints: BoxConstraints(maxWidth: 360), 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: 4), switch (data.type) { 'messages.new' => _ChatMessageText( data: data, onReply: onReply, onEdit: onEdit, onDelete: onDelete, ), _ => _ChatMessageSystemNotify(data: data), }, ], ), ), ), ], ).opacity(isPending ? 0.5 : 1), ), if (data.body['text'] != null && data.type == 'messages.new' && (data.body['text']?.isNotEmpty ?? false) && (cfg.prefs.getBool(kAppExpandChatLink) ?? true)) LinkPreviewWidget(text: data.body['text']!) .padding(left: isCompact ? 0 : 48), if (data.preload?.attachments?.isNotEmpty ?? false) AttachmentList( data: data.preload!.attachments!, bordered: true, maxHeight: 360, maxWidth: 480 - 48 - padding.left, padding: padding.copyWith( top: 8, left: isCompact ? padding.left : 48 + padding.left), ), if (!hasMerged && !isCompact) const Gap(12) else if (!isCompact) const Gap(8), ], ), ), ); } } class _ChatMessageText extends StatefulWidget { final SnChatMessage data; final Function(SnChatMessage)? onReply; final Function(SnChatMessage)? onEdit; final Function(SnChatMessage)? onDelete; const _ChatMessageText( {required this.data, this.onReply, this.onEdit, this.onDelete}); @override State<_ChatMessageText> createState() => _ChatMessageTextState(); } class _ChatMessageTextState extends State<_ChatMessageText> { late String _displayText = widget.data.body['text'] ?? ''; bool _isTranslated = false; bool _isTranslating = false; Future _translateText() async { if (widget.data.body['text'] == null || widget.data.body['text']!.isEmpty) { return; } final ta = context.read(); setState(() => _isTranslating = true); try { final to = EasyLocalization.of(context)!.locale.languageCode; _displayText = await ta.translate( widget.data.body['text'], to: to, ); _isTranslated = true; if (mounted) setState(() {}); } catch (err) { if (mounted) context.showErrorDialog(err); } finally { setState(() => _isTranslating = false); } } @override void initState() { super.initState(); final cfg = context.read(); if (cfg.autoTranslate) { Future.delayed(const Duration(milliseconds: 100), () { _translateText(); }); } } @override Widget build(BuildContext context) { final ua = context.read(); final isOwner = ua.isAuthorized && widget.data.sender.accountId == ua.user?.id; if (widget.data.body['text'] != null && widget.data.body['text'].isNotEmpty) { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ SelectionArea( contextMenuBuilder: (context, editableTextState) { final List items = editableTextState.contextMenuButtonItems; if (widget.onReply != null) { items.insert( 0, ContextMenuButtonItem( label: 'reply'.tr(), onPressed: () { ContextMenuController.removeAny(); widget.onReply?.call(widget.data); }, ), ); } if (isOwner && widget.onEdit != null) { items.insert( 1, ContextMenuButtonItem( label: 'edit'.tr(), onPressed: () { ContextMenuController.removeAny(); widget.onEdit?.call(widget.data); }, ), ); } if (isOwner && widget.onDelete != null) { items.insert( 2, ContextMenuButtonItem( label: 'delete'.tr(), onPressed: () { ContextMenuController.removeAny(); widget.onDelete?.call(widget.data); }, ), ); } if (widget.data.body['algorithm'] == 'plain') { items.insert( 3, ContextMenuButtonItem( label: 'translate'.tr(), onPressed: () { ContextMenuController.removeAny(); _translateText(); }, ), ); } return AdaptiveTextSelectionToolbar.buttonItems( anchors: editableTextState.contextMenuAnchors, buttonItems: items, ); }, child: switch (widget.data.body['algorithm']) { 'rsa' => _ChatDecryptMessage(message: widget.data), _ => MarkdownTextContent( content: _displayText, isAutoWarp: true, isEnlargeSticker: RegExp(r"^:([-\w]+):$") .hasMatch(widget.data.body['text'] ?? ''), ), }, ), if (widget.data.updatedAt != widget.data.createdAt) Text('messageEditedHint'.tr()).fontSize(13).opacity(0.75), if (_isTranslating) AnimateWidgetExtensions(Text('translating').tr()) .animate(onPlay: (e) => e.repeat()) .fadeIn(duration: 500.ms, curve: Curves.easeOut) .then() .fadeOut( duration: 500.ms, delay: 1000.ms, curve: Curves.easeIn, ), if (_isTranslated) InkWell( child: Text('translated').tr().opacity(0.75), onTap: () { setState(() { _displayText = widget.data.body['text'] ?? ''; _isTranslated = false; }); }, ), ], ); } else if (widget.data.body['attachments']?.isNotEmpty) { return Row( children: [ const Icon(Symbols.file_present, size: 20), const Gap(4), Text('messageFileHint' .plural(widget.data.body['attachments']!.length)), ], ).opacity(0.8); } return const SizedBox.shrink(); } } class _ChatMessageSystemNotify extends StatelessWidget { final SnChatMessage data; const _ChatMessageSystemNotify({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: 16), const Gap(8), Text('messageEdited'.tr(args: ['#${data.relatedEventId}'])), ], ).opacity(0.8); case 'messages.delete': return Row( children: [ const Icon(Symbols.delete, size: 16), const Gap(8), Text('messageDeleted'.tr(args: ['#${data.relatedEventId}'])), ], ).opacity(0.8); case 'calls.start': return Row( children: [ const Icon(Symbols.call, size: 16), const Gap(8), Text('callMessageStarted'.tr()) ], ).opacity(0.8); case 'calls.end': return Row( children: [ const Icon(Symbols.call_end, size: 16), const Gap(8), Text('callMessageEnded'.tr( args: [_formatDuration(Duration(seconds: data.body['last']))])), ], ).opacity(0.8); default: return Row( children: [ const Icon(Symbols.info, size: 16), const Gap(8), Text('messageUnsupported'.tr(args: [data.type])), ], ).opacity(0.8); } } } class _ChatDecryptMessage extends StatelessWidget { final SnChatMessage message; const _ChatDecryptMessage({required this.message}); @override Widget build(BuildContext context) { final kp = context.read(); return FutureBuilder( key: Key('chat-message-encrypted-${message.id}'), future: kp.decryptText( message.body['text'], message.body['keypair_id'], kpOwner: message.sender.accountId, ), builder: (context, snapshot) { if (snapshot.hasError) { return Row( children: [ Icon(Symbols.key_off, size: 16), const Gap(8), Expanded( child: Text('decryptingKeyNotFound'.tr()), ), ], ).opacity(0.8); } if (!snapshot.hasData) { return Row( children: [ Animate( autoPlay: true, onPlay: (e) => e.repeat(), effects: [ RotateEffect( duration: const Duration(seconds: 3), begin: 0, end: -1, ) ], child: Icon(Symbols.sync, size: 16), ), const Gap(8), Expanded( child: Text('decrypting'.tr()), ), ], ); } return MarkdownTextContent( content: snapshot.data!, isAutoWarp: true, isEnlargeSticker: RegExp(r"^:([-\w]+):$").hasMatch(snapshot.data!), ); }, ); } }