From 0845701e1600fabafaaff0fa02ae0978f42595ae Mon Sep 17 00:00:00 2001 From: otsmr Date: Fri, 18 Jul 2025 01:28:56 +0200 Subject: [PATCH] fix #236 #235 --- lib/src/database/daos/messages_dao.dart | 2 +- lib/src/model/memory_item.model.dart | 2 +- lib/src/views/chats/chat_messages.view.dart | 254 ++++++++---------- .../chat_date_chip.dart | 35 +++ .../chat_list_entry.dart | 112 ++++++++ .../chat_media_entry.dart | 93 +++---- .../chat_message_entry.dart | 109 -------- .../chat_reaction_row.dart | 11 +- .../chat_text_entry.dart | 15 +- .../chat_text_response_columns.dart | 80 ------ .../in_chat_media_viewer.dart | 12 +- .../message_actions.dart | 114 +------- .../message_context_menu.dart | 114 ++++++++ .../response_container.dart | 227 ++++++++++++++++ .../views/components/user_context_menu.dart | 4 +- 15 files changed, 679 insertions(+), 505 deletions(-) create mode 100644 lib/src/views/chats/chat_messages_components/chat_date_chip.dart create mode 100644 lib/src/views/chats/chat_messages_components/chat_list_entry.dart delete mode 100644 lib/src/views/chats/chat_messages_components/chat_message_entry.dart delete mode 100644 lib/src/views/chats/chat_messages_components/chat_text_response_columns.dart create mode 100644 lib/src/views/chats/chat_messages_components/message_context_menu.dart create mode 100644 lib/src/views/chats/chat_messages_components/response_container.dart diff --git a/lib/src/database/daos/messages_dao.dart b/lib/src/database/daos/messages_dao.dart index 51e2a82..1c9e333 100644 --- a/lib/src/database/daos/messages_dao.dart +++ b/lib/src/database/daos/messages_dao.dart @@ -52,7 +52,7 @@ class MessagesDao extends DatabaseAccessor t.mediaStored.equals(true) | t.openedAt.isBiggerThanValue( DateTime.now().subtract(const Duration(days: 1))))) - ..orderBy([(t) => OrderingTerm.desc(t.sendAt)])) + ..orderBy([(t) => OrderingTerm.asc(t.sendAt)])) .watch(); } diff --git a/lib/src/model/memory_item.model.dart b/lib/src/model/memory_item.model.dart index 1dffd06..b79487e 100644 --- a/lib/src/model/memory_item.model.dart +++ b/lib/src/model/memory_item.model.dart @@ -34,7 +34,7 @@ class MemoryItem { final isSend = message.messageOtherId == null; final id = message.mediaUploadId ?? message.messageId; final basePath = await send.getMediaFilePath( - isSend ? message.mediaUploadId! : message.messageId, + id, isSend ? 'send' : 'received', ); File? imagePath; diff --git a/lib/src/views/chats/chat_messages.view.dart b/lib/src/views/chats/chat_messages.view.dart index 4b414cc..5330bbc 100644 --- a/lib/src/views/chats/chat_messages.view.dart +++ b/lib/src/views/chats/chat_messages.view.dart @@ -17,7 +17,9 @@ import 'package:twonly/src/services/api/messages.dart'; import 'package:twonly/src/services/notifications/background.notifications.dart'; import 'package:twonly/src/utils/misc.dart'; import 'package:twonly/src/views/camera/camera_send_to_view.dart'; -import 'package:twonly/src/views/chats/chat_messages_components/chat_message_entry.dart'; +import 'package:twonly/src/views/chats/chat_messages_components/chat_date_chip.dart'; +import 'package:twonly/src/views/chats/chat_messages_components/chat_list_entry.dart'; +import 'package:twonly/src/views/chats/chat_messages_components/response_container.dart'; import 'package:twonly/src/views/components/animate_icon.dart'; import 'package:twonly/src/views/components/initialsavatar.dart'; import 'package:twonly/src/views/components/user_context_menu.dart'; @@ -31,6 +33,31 @@ Color getMessageColor(Message message) { : const Color.fromARGB(233, 68, 137, 255); } +class ChatMessage { + ChatMessage({required this.message, required this.responseTo}); + final Message message; + final Message? responseTo; +} + +class ChatItem { + const ChatItem._({this.message, this.date, this.time}); + factory ChatItem.date(DateTime date) { + return ChatItem._(date: date); + } + factory ChatItem.time(DateTime time) { + return ChatItem._(time: time); + } + factory ChatItem.message(ChatMessage message) { + return ChatItem._(message: message); + } + final ChatMessage? message; + final DateTime? date; + final DateTime? time; + bool get isMessage => message != null; + bool get isDate => date != null; + bool get isTime => time != null; +} + /// Displays detailed information about a SampleItem. class ChatMessagesView extends StatefulWidget { const ChatMessagesView(this.contact, {super.key}); @@ -48,9 +75,8 @@ class _ChatMessagesViewState extends State { String currentInputText = ''; late StreamSubscription userSub; late StreamSubscription> messageSub; - List messages = []; + List messages = []; List galleryItems = []; - Map> textReactionsToMessageId = {}; Map> emojiReactionsToMessageId = {}; Message? responseToMessage; GlobalKey verifyShieldKey = GlobalKey(); @@ -92,61 +118,85 @@ class _ChatMessagesViewState extends State { final msgStream = twonlyDB.messagesDao.watchAllMessagesFrom(widget.contact.userId); - messageSub = msgStream.listen((msgs) async { + messageSub = msgStream.listen((newMessages) async { // if (!context.mounted) return; if (Platform.isAndroid) { await flutterLocalNotificationsPlugin.cancel(widget.contact.userId); } else { await flutterLocalNotificationsPlugin.cancelAll(); } - final displayedMessages = []; - // should be cleared - final tmpTextReactionsToMessageId = >{}; + final chatItems = []; + final storedMediaFiles = []; + DateTime? lastDate; final tmpEmojiReactionsToMessageId = >{}; final openedMessageOtherIds = []; final messageOtherMessageIdToMyMessageId = {}; + final messageIdToMessage = {}; /// there is probably a better way... - for (final msg in msgs) { + for (final msg in newMessages) { if (msg.messageOtherId != null) { messageOtherMessageIdToMyMessageId[msg.messageOtherId!] = msg.messageId; } + messageIdToMessage[msg.messageId] = msg; } - for (final msg in msgs) { + for (final msg in newMessages) { if (msg.kind == MessageKind.textMessage && msg.messageOtherId != null && msg.openedAt == null) { openedMessageOtherIds.add(msg.messageOtherId!); } + Message? responseTo; + + if (msg.kind == MessageKind.media && msg.mediaStored) { + storedMediaFiles.add(msg); + } + final responseId = msg.responseToMessageId ?? messageOtherMessageIdToMyMessageId[msg.responseToOtherMessageId]; + var isReaction = false; if (responseId != null) { - var added = false; + responseTo = messageIdToMessage[responseId]; final content = MessageContent.fromJson( msg.kind, jsonDecode(msg.contentJson!) as Map, ); if (content is TextMessageContent) { - if (content.text.isNotEmpty && !isEmoji(content.text)) { - added = true; - tmpTextReactionsToMessageId + if (isEmoji(content.text)) { + isReaction = true; + tmpEmojiReactionsToMessageId .putIfAbsent(responseId, () => []) .add(msg); } } - if (!added) { + if (msg.kind == MessageKind.reopenedMedia) { + isReaction = true; tmpEmojiReactionsToMessageId .putIfAbsent(responseId, () => []) .add(msg); } - } else { - displayedMessages.add(msg); + } + if (!isReaction) { + if (lastDate == null || + msg.sendAt.day != lastDate.day || + msg.sendAt.month != lastDate.month || + msg.sendAt.year != lastDate.year) { + chatItems.add(ChatItem.date(msg.sendAt)); + lastDate = msg.sendAt; + } else if (msg.sendAt.difference(lastDate).inMinutes >= 20) { + chatItems.add(ChatItem.time(msg.sendAt)); + lastDate = msg.sendAt; + } + chatItems.add(ChatItem.message(ChatMessage( + message: msg, + responseTo: responseTo, + ))); } } @@ -161,17 +211,11 @@ class _ChatMessagesViewState extends State { .openedAllNonMediaMessages(widget.contact.userId); setState(() { - textReactionsToMessageId = tmpTextReactionsToMessageId; emojiReactionsToMessageId = tmpEmojiReactionsToMessageId; - messages = displayedMessages; + messages = chatItems.reversed.toList(); }); - final filteredMediaFiles = displayedMessages - .where((x) => x.kind == MessageKind.media && x.mediaStored) - .toList() - .reversed - .toList(); - final items = await MemoryItem.convertFromMessages(filteredMediaFiles); + final items = await MemoryItem.convertFromMessages(storedMediaFiles); galleryItems = items.values.toList(); setState(() {}); }); @@ -204,56 +248,6 @@ class _ChatMessagesViewState extends State { setState(() {}); } - Widget getResponsePreview(Message message) { - String? subtitle; - - if (message.kind == MessageKind.textMessage) { - if (message.contentJson != null) { - final content = MessageContent.fromJson( - MessageKind.textMessage, jsonDecode(message.contentJson!) as Map); - if (content is TextMessageContent) { - subtitle = truncateString(content.text); - } - } - } - if (message.kind == MessageKind.media) { - final content = MessageContent.fromJson( - MessageKind.media, jsonDecode(message.contentJson!) as Map); - if (content is MediaMessageContent) { - subtitle = content.isVideo ? 'Video' : 'Image'; - } - } - - var username = 'You'; - if (message.messageOtherId != null) { - username = getContactDisplayName(widget.contact); - } - - final color = getMessageColor(message); - - return Container( - padding: const EdgeInsets.only(left: 10), - decoration: BoxDecoration( - border: Border( - left: BorderSide( - color: color, - width: 2, - ), - ), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - username, - style: const TextStyle(fontWeight: FontWeight.bold), - ), - if (subtitle != null) Text(subtitle) - ], - ), - ); - } - @override Widget build(BuildContext context) { return GestureDetector( @@ -296,70 +290,37 @@ class _ChatMessagesViewState extends State { children: [ Expanded( child: ListView.builder( - itemCount: messages.length + 1, reverse: true, - itemExtentBuilder: (index, dimensions) { - if (index == 0) return 10; // empty padding - index -= 1; - double size = 44; - if (messages[index].kind == MessageKind.textMessage) { - final content = TextMessageContent.fromJson( - jsonDecode(messages[index].contentJson!) as Map); - if (EmojiAnimation.supported(content.text)) { - size = 99; - } else { - size = 11 + - calculateNumberOfLines( - content.text, - MediaQuery.of(context).size.width * 0.8, - 17) * - 27; - } - } - if (messages[index].mediaStored) { - size = 271; - } - final reactions = - textReactionsToMessageId[messages[index].messageId]; - if (reactions != null && reactions.isNotEmpty) { - for (final reaction in reactions) { - if (reaction.kind == MessageKind.textMessage) { - final content = TextMessageContent.fromJson( - jsonDecode(reaction.contentJson!) as Map); - size += calculateNumberOfLines( - content.text, - MediaQuery.of(context).size.width * 0.5, - 14) * - 27; - } - } - } - - if (!isLastMessageFromSameUser(messages, index)) { - size += 20; - } - return size; - }, + itemCount: messages.length + 1, itemBuilder: (context, i) { - if (i == 0) { - return Container(); // just a padding + if (i == messages.length) { + return const Padding( + padding: EdgeInsetsGeometry.only(top: 10), + ); + } + if (messages[i].isDate || messages[i].isTime) { + return ChatDateChip( + item: messages[i], + ); + } else { + final chatMessage = messages[i].message!; + return ChatListEntry( + key: Key(chatMessage.message.messageId.toString()), + chatMessage, + user, + galleryItems, + isLastMessageFromSameUser(messages, i), + emojiReactionsToMessageId[ + chatMessage.message.messageId] ?? + [], + onResponseTriggered: () { + setState(() { + responseToMessage = chatMessage.message; + }); + textFieldFocus.requestFocus(); + }, + ); } - i -= 1; - return ChatListEntry( - key: Key(messages[i].messageId.toString()), - messages[i], - user, - galleryItems, - isLastMessageFromSameUser(messages, i), - textReactionsToMessageId[messages[i].messageId] ?? [], - emojiReactionsToMessageId[messages[i].messageId] ?? [], - onResponseTriggered: (message) { - setState(() { - responseToMessage = message; - }); - textFieldFocus.requestFocus(); - }, - ); }, ), ), @@ -372,7 +333,13 @@ class _ChatMessagesViewState extends State { ), child: Row( children: [ - Expanded(child: getResponsePreview(responseToMessage!)), + Expanded( + child: ResponsePreview( + message: responseToMessage!, + showBorder: true, + contact: user, + ), + ), IconButton( onPressed: () { setState(() { @@ -449,7 +416,7 @@ class _ChatMessagesViewState extends State { } } -bool isLastMessageFromSameUser(List messages, int index) { +bool isLastMessageFromSameUser(List messages, int index) { if (index <= 0) { return true; // If there is no previous message, return true } @@ -457,11 +424,14 @@ bool isLastMessageFromSameUser(List messages, int index) { final lastMessage = messages[index - 1]; final currentMessage = messages[index]; - // Check if both messages have the same messageOtherId (or both are null) - return (lastMessage.messageOtherId == null && - currentMessage.messageOtherId == null) || - (lastMessage.messageOtherId != null && - currentMessage.messageOtherId != null); + if (lastMessage.isMessage && currentMessage.isMessage) { + // Check if both messages have the same messageOtherId (or both are null) + return (lastMessage.message!.message.messageOtherId == null && + currentMessage.message!.message.messageOtherId == null) || + (lastMessage.message!.message.messageOtherId != null && + currentMessage.message!.message.messageOtherId != null); + } + return false; } double calculateNumberOfLines(String text, double width, double fontSize) { diff --git a/lib/src/views/chats/chat_messages_components/chat_date_chip.dart b/lib/src/views/chats/chat_messages_components/chat_date_chip.dart new file mode 100644 index 0000000..24b085e --- /dev/null +++ b/lib/src/views/chats/chat_messages_components/chat_date_chip.dart @@ -0,0 +1,35 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/widgets.dart'; +import 'package:intl/intl.dart'; +import 'package:twonly/src/utils/misc.dart'; +import 'package:twonly/src/views/chats/chat_messages.view.dart'; + +class ChatDateChip extends StatelessWidget { + const ChatDateChip({required this.item, super.key}); + final ChatItem item; + + @override + Widget build(BuildContext context) { + var formattedDate = item.isTime + ? DateFormat.Hm(Localizations.localeOf(context).toLanguageTag()) + .format(item.time!) + : '${DateFormat.Hm(Localizations.localeOf(context).toLanguageTag()).format(item.date!)} ${DateFormat.yMd(Localizations.localeOf(context).toLanguageTag()).format(item.date!)}'; + + return Center( + child: Container( + decoration: BoxDecoration( + color: Colors.black.withAlpha(40), + borderRadius: BorderRadius.circular(8), + ), + padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 8), + child: Text( + formattedDate, + style: TextStyle( + fontSize: 10, + color: isDarkMode(context) ? Colors.white : Colors.black, + ), + ), + ), + ); + } +} diff --git a/lib/src/views/chats/chat_messages_components/chat_list_entry.dart b/lib/src/views/chats/chat_messages_components/chat_list_entry.dart new file mode 100644 index 0000000..bb55a6a --- /dev/null +++ b/lib/src/views/chats/chat_messages_components/chat_list_entry.dart @@ -0,0 +1,112 @@ +import 'dart:convert'; + +import 'package:flutter/material.dart'; +import 'package:twonly/src/database/twonly_database.dart'; +import 'package:twonly/src/model/json/message.dart'; +import 'package:twonly/src/model/memory_item.model.dart'; +import 'package:twonly/src/views/chats/chat_messages.view.dart'; +import 'package:twonly/src/views/chats/chat_messages_components/chat_media_entry.dart'; +import 'package:twonly/src/views/chats/chat_messages_components/chat_reaction_row.dart'; +import 'package:twonly/src/views/chats/chat_messages_components/chat_text_entry.dart'; +import 'package:twonly/src/views/chats/chat_messages_components/message_actions.dart'; +import 'package:twonly/src/views/chats/chat_messages_components/message_context_menu.dart'; +import 'package:twonly/src/views/chats/chat_messages_components/response_container.dart'; + +class ChatListEntry extends StatefulWidget { + const ChatListEntry( + this.msg, + this.contact, + this.galleryItems, + this.lastMessageFromSameUser, + this.otherReactions, { + required this.onResponseTriggered, + super.key, + }); + final ChatMessage msg; + final Contact contact; + final bool lastMessageFromSameUser; + final List otherReactions; + final List galleryItems; + final void Function() onResponseTriggered; + + @override + State createState() => _ChatListEntryState(); +} + +class _ChatListEntryState extends State { + MessageContent? content; + String? textMessage; + + @override + void initState() { + super.initState(); + final msgContent = MessageContent.fromJson(widget.msg.message.kind, + jsonDecode(widget.msg.message.contentJson!) as Map); + if (msgContent is TextMessageContent) { + textMessage = msgContent.text; + } + content = msgContent; + } + + @override + Widget build(BuildContext context) { + if (content == null) return Container(); + final right = widget.msg.message.messageOtherId == null; + + return Align( + alignment: right ? Alignment.centerRight : Alignment.centerLeft, + child: Padding( + padding: widget.lastMessageFromSameUser + ? const EdgeInsets.only(top: 5, right: 10, left: 10) + : const EdgeInsets.only(top: 5, bottom: 20, right: 10, left: 10), + child: MessageContextMenu( + message: widget.msg.message, + onResponseTriggered: widget.onResponseTriggered, + child: Column( + mainAxisAlignment: + right ? MainAxisAlignment.end : MainAxisAlignment.start, + crossAxisAlignment: + right ? CrossAxisAlignment.end : CrossAxisAlignment.start, + children: [ + MessageActions( + message: widget.msg.message, + onResponseTriggered: widget.onResponseTriggered, + child: Stack( + alignment: + right ? Alignment.centerRight : Alignment.centerLeft, + children: [ + ResponseContainer( + msg: widget.msg, + contact: widget.contact, + child: (textMessage != null) + ? ChatTextEntry( + message: widget.msg.message, + text: textMessage!, + hasReaction: widget.otherReactions.isNotEmpty, + ) + : ChatMediaEntry( + message: widget.msg.message, + contact: widget.contact, + galleryItems: widget.galleryItems, + content: content!, + ), + ), + Positioned( + bottom: 5, + left: 5, + right: 5, + child: ReactionRow( + otherReactions: widget.otherReactions, + message: widget.msg.message, + ), + ), + ], + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/src/views/chats/chat_messages_components/chat_media_entry.dart b/lib/src/views/chats/chat_messages_components/chat_media_entry.dart index 3d86dc6..514291d 100644 --- a/lib/src/views/chats/chat_messages_components/chat_media_entry.dart +++ b/lib/src/views/chats/chat_messages_components/chat_media_entry.dart @@ -59,6 +59,51 @@ class _ChatMediaEntryState extends State { } } + Future onDoubleTap() async { + if (widget.message.openedAt == null && + widget.message.messageOtherId != null || + widget.message.mediaStored) { + return; + } + if (await received.existsMediaFile(widget.message.messageId, 'png')) { + await encryptAndSendMessageAsync( + null, + widget.contact.userId, + MessageJson( + kind: MessageKind.reopenedMedia, + messageSenderId: widget.message.messageId, + content: ReopenedMediaFileContent( + messageId: widget.message.messageOtherId!, + ), + timestamp: DateTime.now(), + ), + pushNotification: PushNotification( + kind: PushKind.reopenedMedia, + ), + ); + await twonlyDB.messagesDao.updateMessageByMessageId( + widget.message.messageId, + const MessagesCompanion(openedAt: Value(null)), + ); + } + } + + Future onTap() async { + if (widget.message.downloadState == DownloadState.downloaded && + widget.message.openedAt == null) { + await Navigator.push( + context, + MaterialPageRoute(builder: (context) { + return MediaViewerView(widget.contact, + initialMessage: widget.message); + }), + ); + await checkIfTutorialCanBeShown(); + } else if (widget.message.downloadState == DownloadState.pending) { + await received.startDownloadMedia(widget.message, true); + } + } + @override Widget build(BuildContext context) { final color = getMessageColorFromType( @@ -68,53 +113,11 @@ class _ChatMediaEntryState extends State { return GestureDetector( key: reopenMediaFile, - onDoubleTap: () async { - if (widget.message.openedAt == null && - widget.message.messageOtherId != null || - widget.message.mediaStored) { - return; - } - if (await received.existsMediaFile(widget.message.messageId, 'png')) { - await encryptAndSendMessageAsync( - null, - widget.contact.userId, - MessageJson( - kind: MessageKind.reopenedMedia, - messageSenderId: widget.message.messageId, - content: ReopenedMediaFileContent( - messageId: widget.message.messageOtherId!, - ), - timestamp: DateTime.now(), - ), - pushNotification: PushNotification( - kind: PushKind.reopenedMedia, - ), - ); - await twonlyDB.messagesDao.updateMessageByMessageId( - widget.message.messageId, - const MessagesCompanion(openedAt: Value(null)), - ); - } - }, - onTap: () async { - if (widget.message.kind == MessageKind.media) { - if (widget.message.downloadState == DownloadState.downloaded && - widget.message.openedAt == null) { - await Navigator.push( - context, - MaterialPageRoute(builder: (context) { - return MediaViewerView(widget.contact, - initialMessage: widget.message); - }), - ); - await checkIfTutorialCanBeShown(); - } else if (widget.message.downloadState == DownloadState.pending) { - await received.startDownloadMedia(widget.message, true); - } - } - }, + onDoubleTap: onDoubleTap, + onTap: widget.message.kind == MessageKind.media ? onTap : null, child: SizedBox( width: 150, + height: widget.message.mediaStored ? 271 : null, child: Align( alignment: Alignment.centerRight, child: ClipRRect( diff --git a/lib/src/views/chats/chat_messages_components/chat_message_entry.dart b/lib/src/views/chats/chat_messages_components/chat_message_entry.dart deleted file mode 100644 index c801335..0000000 --- a/lib/src/views/chats/chat_messages_components/chat_message_entry.dart +++ /dev/null @@ -1,109 +0,0 @@ -import 'dart:convert'; - -import 'package:flutter/material.dart'; -import 'package:twonly/src/database/twonly_database.dart'; -import 'package:twonly/src/model/json/message.dart'; -import 'package:twonly/src/model/memory_item.model.dart'; -import 'package:twonly/src/views/chats/chat_messages_components/chat_media_entry.dart'; -import 'package:twonly/src/views/chats/chat_messages_components/chat_reaction_row.dart'; -import 'package:twonly/src/views/chats/chat_messages_components/chat_text_entry.dart'; -import 'package:twonly/src/views/chats/chat_messages_components/chat_text_response_columns.dart'; -import 'package:twonly/src/views/chats/chat_messages_components/message_actions.dart'; - -class ChatListEntry extends StatefulWidget { - const ChatListEntry( - this.message, - this.contact, - this.galleryItems, - this.lastMessageFromSameUser, - this.textReactions, - this.otherReactions, { - required this.onResponseTriggered, - super.key, - }); - final Message message; - final Contact contact; - final bool lastMessageFromSameUser; - final List textReactions; - final List otherReactions; - final List galleryItems; - final void Function(Message) onResponseTriggered; - - @override - State createState() => _ChatListEntryState(); -} - -class _ChatListEntryState extends State { - MessageContent? content; - String? textMessage; - - @override - void initState() { - super.initState(); - final msgContent = MessageContent.fromJson( - widget.message.kind, jsonDecode(widget.message.contentJson!) as Map); - if (msgContent is TextMessageContent) { - textMessage = msgContent.text; - } - content = msgContent; - } - - @override - Widget build(BuildContext context) { - if (content == null) return Container(); - final right = widget.message.messageOtherId == null; - - return Align( - alignment: right ? Alignment.centerRight : Alignment.centerLeft, - child: Padding( - padding: widget.lastMessageFromSameUser - ? const EdgeInsets.only(top: 5, right: 10, left: 10) - : const EdgeInsets.only(top: 5, bottom: 20, right: 10, left: 10), - child: Column( - mainAxisAlignment: - right ? MainAxisAlignment.end : MainAxisAlignment.start, - crossAxisAlignment: - right ? CrossAxisAlignment.end : CrossAxisAlignment.start, - children: [ - MessageActions( - message: widget.message, - child: Stack( - alignment: right ? Alignment.centerRight : Alignment.centerLeft, - children: [ - if (textMessage != null) - ChatTextEntry( - message: widget.message, - text: textMessage!, - ) - else - ChatMediaEntry( - message: widget.message, - contact: widget.contact, - galleryItems: widget.galleryItems, - content: content!, - ), - Positioned( - bottom: 5, - left: 5, - right: 5, - child: ReactionRow( - otherReactions: widget.otherReactions, - message: widget.message, - ), - ), - ], - ), - onResponseTriggered: () { - widget.onResponseTriggered(widget.message); - }, - ), - ChatTextResponseColumns( - textReactions: widget.textReactions, - right: right, - ) - ], - ), - ), - ); - } -} diff --git a/lib/src/views/chats/chat_messages_components/chat_reaction_row.dart b/lib/src/views/chats/chat_messages_components/chat_reaction_row.dart index 7a3aed2..8f57e45 100644 --- a/lib/src/views/chats/chat_messages_components/chat_reaction_row.dart +++ b/lib/src/views/chats/chat_messages_components/chat_reaction_row.dart @@ -4,6 +4,7 @@ import 'package:flutter/material.dart'; import 'package:font_awesome_flutter/font_awesome_flutter.dart'; import 'package:twonly/src/database/twonly_database.dart'; import 'package:twonly/src/model/json/message.dart'; +import 'package:twonly/src/utils/misc.dart'; import 'package:twonly/src/views/components/animate_icon.dart'; class ReactionRow extends StatefulWidget { @@ -26,7 +27,7 @@ class _ReactionRowState extends State { final children = []; var hasOneTextReaction = false; var hasOneReopened = false; - for (final reaction in widget.otherReactions) { + for (final reaction in widget.otherReactions.reversed) { final content = MessageContent.fromJson( reaction.kind, jsonDecode(reaction.contentJson!) as Map); @@ -34,15 +35,15 @@ class _ReactionRowState extends State { if (hasOneReopened) continue; hasOneReopened = true; children.add( - const Expanded( + Expanded( child: Align( alignment: Alignment.bottomRight, child: Padding( - padding: EdgeInsets.only(right: 3), + padding: const EdgeInsets.only(right: 3), child: FaIcon( FontAwesomeIcons.repeat, size: 12, - color: Colors.white, + color: isDarkMode(context) ? Colors.white : Colors.black, ), ), ), @@ -78,7 +79,7 @@ class _ReactionRowState extends State { return Row( mainAxisAlignment: widget.message.messageOtherId == null - ? MainAxisAlignment.start + ? MainAxisAlignment.end : MainAxisAlignment.end, children: children, ); diff --git a/lib/src/views/chats/chat_messages_components/chat_text_entry.dart b/lib/src/views/chats/chat_messages_components/chat_text_entry.dart index fdb6901..2cbe220 100644 --- a/lib/src/views/chats/chat_messages_components/chat_text_entry.dart +++ b/lib/src/views/chats/chat_messages_components/chat_text_entry.dart @@ -5,10 +5,16 @@ import 'package:twonly/src/views/components/animate_icon.dart'; import 'package:twonly/src/views/components/better_text.dart'; class ChatTextEntry extends StatelessWidget { - const ChatTextEntry({required this.message, required this.text, super.key}); + const ChatTextEntry({ + required this.message, + required this.text, + required this.hasReaction, + super.key, + }); final String text; final Message message; + final bool hasReaction; @override Widget build(BuildContext context) { @@ -28,9 +34,12 @@ class ChatTextEntry extends StatelessWidget { constraints: BoxConstraints( maxWidth: MediaQuery.of(context).size.width * 0.8, ), - padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 10), + padding: EdgeInsets.only( + left: 10, top: 4, bottom: 4, right: hasReaction ? 30 : 10), decoration: BoxDecoration( - color: getMessageColor(message), + color: message.responseToMessageId == null + ? getMessageColor(message) + : null, borderRadius: BorderRadius.circular(12), ), child: BetterText(text: text), diff --git a/lib/src/views/chats/chat_messages_components/chat_text_response_columns.dart b/lib/src/views/chats/chat_messages_components/chat_text_response_columns.dart deleted file mode 100644 index 3b9f702..0000000 --- a/lib/src/views/chats/chat_messages_components/chat_text_response_columns.dart +++ /dev/null @@ -1,80 +0,0 @@ -import 'dart:convert'; - -import 'package:flutter/material.dart'; -import 'package:font_awesome_flutter/font_awesome_flutter.dart'; -import 'package:twonly/src/database/twonly_database.dart'; -import 'package:twonly/src/model/json/message.dart'; -import 'package:twonly/src/views/chats/chat_messages.view.dart'; - -class ChatTextResponseColumns extends StatelessWidget { - const ChatTextResponseColumns({ - required this.textReactions, - required this.right, - super.key, - }); - - final List textReactions; - final bool right; - - @override - Widget build(BuildContext context) { - final children = []; - for (final reaction in textReactions) { - final content = MessageContent.fromJson( - reaction.kind, jsonDecode(reaction.contentJson!) as Map); - - if (content is TextMessageContent) { - var entries = [ - const FaIcon( - FontAwesomeIcons.reply, - size: 10, - ), - const SizedBox(width: 5), - Container( - constraints: BoxConstraints( - maxWidth: MediaQuery.of(context).size.width * 0.5, - ), - child: Text( - content.text, - style: const TextStyle(fontSize: 14), - textAlign: right ? TextAlign.right : TextAlign.left, - )), - ]; - if (right) { - entries = entries.reversed.toList(); - } - - final color = getMessageColor(reaction); - - children.insert( - 0, - Container( - padding: const EdgeInsets.only(top: 5, right: 10, left: 10), - child: Container( - constraints: BoxConstraints( - maxWidth: MediaQuery.of(context).size.width * 0.8, - ), - padding: const EdgeInsets.symmetric(vertical: 1, horizontal: 10), - decoration: BoxDecoration( - color: color, - borderRadius: BorderRadius.circular(12), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: entries, - ), - ), - ), - ); - } - } - - if (children.isEmpty) return Container(); - - return Column( - crossAxisAlignment: - right ? CrossAxisAlignment.end : CrossAxisAlignment.start, - children: children, - ); - } -} diff --git a/lib/src/views/chats/chat_messages_components/in_chat_media_viewer.dart b/lib/src/views/chats/chat_messages_components/in_chat_media_viewer.dart index cf09e0e..206c518 100644 --- a/lib/src/views/chats/chat_messages_components/in_chat_media_viewer.dart +++ b/lib/src/views/chats/chat_messages_components/in_chat_media_viewer.dart @@ -108,7 +108,7 @@ class _InChatMediaViewerState extends State { @override Widget build(BuildContext context) { - if (galleryItemIndex == null) { + if (!widget.message.mediaStored) { return Container( constraints: const BoxConstraints( minHeight: 39, @@ -138,10 +138,12 @@ class _InChatMediaViewerState extends State { color: Colors.transparent, borderRadius: BorderRadius.circular(12), ), - child: MemoriesItemThumbnail( - galleryItem: widget.galleryItems[galleryItemIndex!], - onTap: onTap, - ), + child: galleryItemIndex != null + ? MemoriesItemThumbnail( + galleryItem: widget.galleryItems[galleryItemIndex!], + onTap: onTap, + ) + : null, ); } } diff --git a/lib/src/views/chats/chat_messages_components/message_actions.dart b/lib/src/views/chats/chat_messages_components/message_actions.dart index aa320f8..2e289e4 100644 --- a/lib/src/views/chats/chat_messages_components/message_actions.dart +++ b/lib/src/views/chats/chat_messages_components/message_actions.dart @@ -3,18 +3,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:font_awesome_flutter/font_awesome_flutter.dart'; -import 'package:pie_menu/pie_menu.dart'; -import 'package:twonly/globals.dart'; -import 'package:twonly/src/database/tables/messages_table.dart'; import 'package:twonly/src/database/twonly_database.dart'; -import 'package:twonly/src/model/json/message.dart'; -import 'package:twonly/src/model/protobuf/push_notification/push_notification.pbserver.dart'; -import 'package:twonly/src/services/api/messages.dart'; -import 'package:twonly/src/utils/log.dart'; -import 'package:twonly/src/utils/misc.dart'; -import 'package:twonly/src/views/camera/image_editor/data/layer.dart'; -import 'package:twonly/src/views/camera/image_editor/modules/all_emojis.dart'; -import 'package:twonly/src/views/components/alert_dialog.dart'; class MessageActions extends StatefulWidget { const MessageActions({ @@ -70,11 +59,7 @@ class _SlidingResponseWidgetState extends State { child: GestureDetector( onHorizontalDragUpdate: _onHorizontalDragUpdate, onHorizontalDragEnd: _onHorizontalDragEnd, - child: MessageContextMenu( - message: widget.message, - onResponseTriggered: widget.onResponseTriggered, - child: widget.child, - ), + child: widget.child, ), ), if (_offsetX >= 40) @@ -97,100 +82,3 @@ class _SlidingResponseWidgetState extends State { ); } } - -class MessageContextMenu extends StatelessWidget { - const MessageContextMenu({ - required this.message, - required this.child, - required this.onResponseTriggered, - super.key, - }); - final Widget child; - final Message message; - final VoidCallback onResponseTriggered; - - @override - Widget build(BuildContext context) { - return PieMenu( - onPressed: () => (), - onToggle: (menuOpen) { - if (menuOpen) { - HapticFeedback.heavyImpact(); - } - }, - actions: [ - PieAction( - tooltip: Text(context.lang.react), - onSelect: () async { - final layer = await showModalBottomSheet( - context: context, - backgroundColor: Colors.black, - builder: (BuildContext context) { - return const Emojis(); - }, - ) as TextLayerData?; - if (layer == null) return; - Log.info(layer.text); - - await sendTextMessage( - message.contactId, - TextMessageContent( - text: layer.text, - responseToMessageId: message.messageOtherId, - responseToOtherMessageId: (message.messageOtherId == null) - ? message.messageId - : null), - (message.messageOtherId != null) - ? PushNotification( - kind: (message.kind == MessageKind.textMessage) - ? PushKind.reactionToText - : (getMediaContent(message)!.isVideo) - ? PushKind.reactionToVideo - : PushKind.reactionToImage, - reactionContent: layer.text, - ) - : null, - ); - }, - child: const FaIcon(FontAwesomeIcons.faceLaugh), - ), - PieAction( - tooltip: Text(context.lang.reply), - onSelect: onResponseTriggered, - child: const FaIcon(FontAwesomeIcons.reply), - ), - PieAction( - tooltip: Text(context.lang.copy), - onSelect: () { - final text = getMessageText(message); - Clipboard.setData(ClipboardData(text: text)); - HapticFeedback.heavyImpact(); - }, - child: const FaIcon(FontAwesomeIcons.solidCopy), - ), - PieAction( - tooltip: Text(context.lang.delete), - onSelect: () async { - final delete = await showAlertDialog( - context, - context.lang.deleteTitle, - null, - customOk: context.lang.deleteOkBtn, - ); - if (delete) { - await twonlyDB.messagesDao - .deleteMessagesByMessageId(message.messageId); - } - }, - child: const FaIcon(FontAwesomeIcons.trash), - ), - // PieAction( - // tooltip: Text(context.lang.info), - // onSelect: () {}, - // child: const FaIcon(FontAwesomeIcons.circleInfo), - // ), - ], - child: child, - ); - } -} diff --git a/lib/src/views/chats/chat_messages_components/message_context_menu.dart b/lib/src/views/chats/chat_messages_components/message_context_menu.dart new file mode 100644 index 0000000..8783068 --- /dev/null +++ b/lib/src/views/chats/chat_messages_components/message_context_menu.dart @@ -0,0 +1,114 @@ +// ignore_for_file: avoid_dynamic_calls, inference_failure_on_function_invocation + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:font_awesome_flutter/font_awesome_flutter.dart'; +import 'package:pie_menu/pie_menu.dart'; +import 'package:twonly/globals.dart'; +import 'package:twonly/src/database/tables/messages_table.dart'; +import 'package:twonly/src/database/twonly_database.dart'; +import 'package:twonly/src/model/json/message.dart'; +import 'package:twonly/src/model/protobuf/push_notification/push_notification.pbserver.dart'; +import 'package:twonly/src/services/api/messages.dart'; +import 'package:twonly/src/utils/log.dart'; +import 'package:twonly/src/utils/misc.dart'; +import 'package:twonly/src/views/camera/image_editor/data/layer.dart'; +import 'package:twonly/src/views/camera/image_editor/modules/all_emojis.dart'; +import 'package:twonly/src/views/components/alert_dialog.dart'; + +class MessageContextMenu extends StatelessWidget { + const MessageContextMenu({ + required this.message, + required this.child, + required this.onResponseTriggered, + super.key, + }); + final Widget child; + final Message message; + final VoidCallback onResponseTriggered; + + @override + Widget build(BuildContext context) { + return PieMenu( + onPressed: () => (), + onToggle: (menuOpen) { + if (menuOpen) { + HapticFeedback.heavyImpact(); + } + }, + actions: [ + PieAction( + tooltip: Text(context.lang.react), + onSelect: () async { + final layer = await showModalBottomSheet( + context: context, + backgroundColor: Colors.black, + builder: (BuildContext context) { + return const Emojis(); + }, + ) as EmojiLayerData?; + if (layer == null) return; + Log.info(layer.text); + + await sendTextMessage( + message.contactId, + TextMessageContent( + text: layer.text, + responseToMessageId: message.messageOtherId, + responseToOtherMessageId: (message.messageOtherId == null) + ? message.messageId + : null), + (message.messageOtherId != null) + ? PushNotification( + kind: (message.kind == MessageKind.textMessage) + ? PushKind.reactionToText + : (getMediaContent(message)!.isVideo) + ? PushKind.reactionToVideo + : PushKind.reactionToImage, + reactionContent: layer.text, + ) + : null, + ); + }, + child: const FaIcon(FontAwesomeIcons.faceLaugh), + ), + PieAction( + tooltip: Text(context.lang.reply), + onSelect: onResponseTriggered, + child: const FaIcon(FontAwesomeIcons.reply), + ), + PieAction( + tooltip: Text(context.lang.copy), + onSelect: () { + final text = getMessageText(message); + Clipboard.setData(ClipboardData(text: text)); + HapticFeedback.heavyImpact(); + }, + child: const FaIcon(FontAwesomeIcons.solidCopy), + ), + PieAction( + tooltip: Text(context.lang.delete), + onSelect: () async { + final delete = await showAlertDialog( + context, + context.lang.deleteTitle, + null, + customOk: context.lang.deleteOkBtn, + ); + if (delete) { + await twonlyDB.messagesDao + .deleteMessagesByMessageId(message.messageId); + } + }, + child: const FaIcon(FontAwesomeIcons.trash), + ), + // PieAction( + // tooltip: Text(context.lang.info), + // onSelect: () {}, + // child: const FaIcon(FontAwesomeIcons.circleInfo), + // ), + ], + child: child, + ); + } +} diff --git a/lib/src/views/chats/chat_messages_components/response_container.dart b/lib/src/views/chats/chat_messages_components/response_container.dart new file mode 100644 index 0000000..3c5612c --- /dev/null +++ b/lib/src/views/chats/chat_messages_components/response_container.dart @@ -0,0 +1,227 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:twonly/src/database/daos/contacts_dao.dart'; +import 'package:twonly/src/database/tables/messages_table.dart'; +import 'package:twonly/src/database/twonly_database.dart'; +import 'package:twonly/src/model/json/message.dart'; +import 'package:twonly/src/model/memory_item.model.dart'; +import 'package:twonly/src/utils/misc.dart'; +import 'package:twonly/src/views/chats/chat_messages.view.dart'; + +class ResponseContainer extends StatefulWidget { + const ResponseContainer({ + required this.msg, + required this.contact, + required this.child, + super.key, + }); + + final ChatMessage msg; + final Widget child; + final Contact contact; + + @override + State createState() => _ResponseContainerState(); +} + +class _ResponseContainerState extends State { + double? minWidth; + final GlobalKey _message = GlobalKey(); + final GlobalKey _preview = GlobalKey(); + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + WidgetsBinding.instance.addPostFrameCallback((_) { + final messageBox = + _message.currentContext?.findRenderObject() as RenderBox?; + final previewBox = + _preview.currentContext?.findRenderObject() as RenderBox?; + if (messageBox == null || previewBox == null) { + return; + } + setState(() { + if (messageBox.size.width > previewBox.size.width) { + minWidth = messageBox.size.width; + } else { + minWidth = previewBox.size.width; + } + }); + }); + } + + @override + Widget build(BuildContext context) { + if (widget.msg.responseTo == null) { + return widget.child; + } + return Container( + constraints: BoxConstraints( + maxWidth: MediaQuery.of(context).size.width * 0.8, + ), + decoration: BoxDecoration( + color: getMessageColor(widget.msg.message), + borderRadius: BorderRadius.circular(12), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.only(top: 4, right: 4, left: 4), + child: Container( + key: _preview, + width: minWidth, + decoration: BoxDecoration( + color: context.color.surface.withAlpha(150), + borderRadius: const BorderRadius.only( + topRight: Radius.circular(8), + topLeft: Radius.circular(8), + bottomLeft: Radius.circular(4), + bottomRight: Radius.circular(4), + ), + ), + child: ResponsePreview( + contact: widget.contact, + message: widget.msg.responseTo!, + showBorder: false, + ), + ), + ), + SizedBox( + key: _message, + width: minWidth, + child: widget.child, + ), + ], + ), + ); + } +} + +class ResponsePreview extends StatefulWidget { + const ResponsePreview({ + required this.message, + required this.contact, + required this.showBorder, + super.key, + }); + + final Message message; + final Contact contact; + final bool showBorder; + + @override + State createState() => _ResponsePreviewState(); +} + +class _ResponsePreviewState extends State { + File? thumbnailPath; + + @override + void initState() { + initAsync(); + super.initState(); + } + + Future initAsync() async { + final items = await MemoryItem.convertFromMessages([widget.message]); + if (items.length == 1) { + setState(() { + thumbnailPath = items.values.first.thumbnailPath; + }); + } + } + + @override + Widget build(BuildContext context) { + String? subtitle; + + if (widget.message.kind == MessageKind.textMessage) { + if (widget.message.contentJson != null) { + final content = MessageContent.fromJson(MessageKind.textMessage, + jsonDecode(widget.message.contentJson!) as Map); + if (content is TextMessageContent) { + subtitle = truncateString(content.text); + } + } + } + if (widget.message.kind == MessageKind.media) { + final content = MessageContent.fromJson( + MessageKind.media, jsonDecode(widget.message.contentJson!) as Map); + if (content is MediaMessageContent) { + subtitle = content.isVideo ? 'Video' : 'Image'; + } + } + + var username = 'You'; + if (widget.message.messageOtherId != null) { + username = getContactDisplayName(widget.contact); + } + + final color = getMessageColor(widget.message); + + if (!widget.message.mediaStored) { + return Container( + padding: widget.showBorder + ? const EdgeInsets.only(left: 10, right: 10) + : const EdgeInsets.symmetric(horizontal: 5), + decoration: (widget.showBorder) + ? BoxDecoration( + border: Border( + left: BorderSide( + color: color, + width: 2, + ), + ), + ) + : null, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + username, + style: const TextStyle(fontWeight: FontWeight.bold), + ), + if (subtitle != null) Text(subtitle) + ], + ), + ); + } + + return Container( + padding: const EdgeInsets.only(left: 10), + width: 200, + decoration: BoxDecoration( + border: Border( + left: BorderSide( + color: color, + width: 2, + ), + ), + ), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + username, + style: const TextStyle(fontWeight: FontWeight.bold), + ), + if (subtitle != null) Text(subtitle) + ], + ), + ), + if (thumbnailPath != null) + SizedBox( + height: widget.showBorder ? 100 : 210, + child: Image.file(thumbnailPath!), + ) + ], + ), + ); + } +} diff --git a/lib/src/views/components/user_context_menu.dart b/lib/src/views/components/user_context_menu.dart index ac2b0a1..2f0c8ad 100644 --- a/lib/src/views/components/user_context_menu.dart +++ b/lib/src/views/components/user_context_menu.dart @@ -179,7 +179,9 @@ PieTheme getPieCanvasTheme(BuildContext context) { iconColor: Theme.of(context).colorScheme.surfaceBright, ), tooltipPadding: const EdgeInsets.all(20), - overlayColor: const Color.fromARGB(69, 0, 0, 0), + overlayColor: isDarkMode(context) + ? const Color.fromARGB(69, 0, 0, 0) + : const Color.fromARGB(40, 0, 0, 0), // spacing: 0, tooltipTextStyle: const TextStyle( fontSize: 32,