From 181bf49df171928d7132fc91f82fd88b8c24edeb Mon Sep 17 00:00:00 2001 From: Talha Date: Tue, 28 Jan 2025 19:25:54 +0500 Subject: [PATCH 01/14] clean up reaction actions UI and transform into message actions implementation --- .../message_actions.dart} | 71 +++++++----- .../chat_ng/widgets/events/chat_event.dart | 4 + .../widgets/events/chat_event_item.dart | 3 + .../widgets/events/message_event_item.dart | 7 +- .../widgets/message_actions_widget.dart | 106 ++++++++++++++++++ 5 files changed, 158 insertions(+), 33 deletions(-) rename app/lib/features/chat_ng/{actions/reaction_selection_action.dart => dialogs/message_actions.dart} (59%) create mode 100644 app/lib/features/chat_ng/widgets/message_actions_widget.dart diff --git a/app/lib/features/chat_ng/actions/reaction_selection_action.dart b/app/lib/features/chat_ng/dialogs/message_actions.dart similarity index 59% rename from app/lib/features/chat_ng/actions/reaction_selection_action.dart rename to app/lib/features/chat_ng/dialogs/message_actions.dart index 1670e52d75a0..f6dc60758cb4 100644 --- a/app/lib/features/chat_ng/actions/reaction_selection_action.dart +++ b/app/lib/features/chat_ng/dialogs/message_actions.dart @@ -1,19 +1,22 @@ import 'dart:ui'; +import 'package:acter/features/chat_ng/widgets/message_actions_widget.dart'; import 'package:acter/features/chat_ng/widgets/reactions/reaction_selector.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; -// reaction selector action on chat message -void reactionSelectionAction({ +// message actions on chat message +void messageActions({ required BuildContext context, required Widget messageWidget, required bool isMe, + required bool canRedact, required String roomId, required String messageId, -}) { - final RenderBox box = context.findRenderObject() as RenderBox; - final Offset position = box.localToGlobal(Offset.zero); - final messageSize = box.size; +}) async { + // trigger vibration haptic + await HapticFeedback.heavyImpact(); + if (!context.mounted) return; showGeneralDialog( context: context, @@ -22,29 +25,35 @@ void reactionSelectionAction({ barrierColor: Colors.transparent, transitionDuration: const Duration(milliseconds: 200), pageBuilder: (context, animation, secondaryAnimation) { - return Stack( + return Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, children: [ - _ReactionOverlay( + _BlurOverlay( animation: animation, - child: const SizedBox.expand(), + child: const SizedBox.shrink(), ), - Positioned( - left: position.dx, - top: position.dy, - width: messageSize.width, - child: messageWidget, + // Reaction Row + _AnimatedActionsContainer( + animation: animation, + tagId: messageId, + child: ReactionSelector( + isMe: isMe, + messageId: '$messageId-reactions', + roomId: roomId, + ), ), - Positioned( - left: position.dx, - top: position.dy - 60, - child: _AnimatedReactionSelector( - animation: animation, + // Message + messageWidget, + // Message actions + _AnimatedActionsContainer( + animation: animation, + tagId: '$messageId-actions', + child: MessageActionsWidget( + isMe: isMe, + canRedact: canRedact, messageId: messageId, - child: ReactionSelector( - isMe: isMe, - messageId: messageId, - roomId: roomId, - ), + roomId: roomId, ), ), ], @@ -53,11 +62,11 @@ void reactionSelectionAction({ ); } -class _ReactionOverlay extends StatelessWidget { +class _BlurOverlay extends StatelessWidget { final Animation animation; final Widget child; - const _ReactionOverlay({ + const _BlurOverlay({ required this.animation, required this.child, }); @@ -86,21 +95,21 @@ class _ReactionOverlay extends StatelessWidget { } } -class _AnimatedReactionSelector extends StatelessWidget { +class _AnimatedActionsContainer extends StatelessWidget { final Animation animation; final Widget child; - final String messageId; + final String tagId; - const _AnimatedReactionSelector({ + const _AnimatedActionsContainer({ required this.animation, required this.child, - required this.messageId, + required this.tagId, }); @override Widget build(BuildContext context) { return Hero( - tag: messageId, + tag: tagId, child: Material( color: Colors.transparent, child: AnimatedBuilder( diff --git a/app/lib/features/chat_ng/widgets/events/chat_event.dart b/app/lib/features/chat_ng/widgets/events/chat_event.dart index 91d3cb0406e3..14fddc5e6de3 100644 --- a/app/lib/features/chat_ng/widgets/events/chat_event.dart +++ b/app/lib/features/chat_ng/widgets/events/chat_event.dart @@ -70,6 +70,9 @@ class ChatEvent extends ConsumerWidget { final options = AvatarOptions.DM(avatarInfo, size: 14); final myId = ref.watch(myUserIdStrProvider); final messageId = msg.uniqueId(); + // FIXME: should check canRedact permission from the room + final canRedact = item.sender() == myId; + final isMe = myId == item.sender(); // TODO: render a regular timeline event return Row( @@ -89,6 +92,7 @@ class ChatEvent extends ConsumerWidget { messageId: messageId, item: item, isMe: isMe, + canRedact: canRedact, isNextMessageInGroup: isNextMessageInGroup, ), ), diff --git a/app/lib/features/chat_ng/widgets/events/chat_event_item.dart b/app/lib/features/chat_ng/widgets/events/chat_event_item.dart index 33e44d27ec58..f3b64482b635 100644 --- a/app/lib/features/chat_ng/widgets/events/chat_event_item.dart +++ b/app/lib/features/chat_ng/widgets/events/chat_event_item.dart @@ -14,6 +14,7 @@ class ChatEventItem extends StatelessWidget { final String messageId; final RoomEventItem item; final bool isMe; + final bool canRedact; final bool isNextMessageInGroup; const ChatEventItem({ super.key, @@ -21,6 +22,7 @@ class ChatEventItem extends StatelessWidget { required this.messageId, required this.item, required this.isMe, + required this.canRedact, required this.isNextMessageInGroup, }); @@ -35,6 +37,7 @@ class ChatEventItem extends StatelessWidget { messageId: messageId, item: item, isMe: isMe, + canRedact: canRedact, isNextMessageInGroup: isNextMessageInGroup, ), 'm.room.redaction' => isMe diff --git a/app/lib/features/chat_ng/widgets/events/message_event_item.dart b/app/lib/features/chat_ng/widgets/events/message_event_item.dart index 0dd65a99244d..a4380bbbb3c3 100644 --- a/app/lib/features/chat_ng/widgets/events/message_event_item.dart +++ b/app/lib/features/chat_ng/widgets/events/message_event_item.dart @@ -1,5 +1,5 @@ import 'package:acter/features/chat/utils.dart'; -import 'package:acter/features/chat_ng/actions/reaction_selection_action.dart'; +import 'package:acter/features/chat_ng/dialogs/message_actions.dart'; import 'package:acter/features/chat_ng/widgets/chat_bubble.dart'; import 'package:acter/features/chat_ng/widgets/events/file_message_event.dart'; import 'package:acter/features/chat_ng/widgets/events/image_message_event.dart'; @@ -17,6 +17,7 @@ class MessageEventItem extends StatelessWidget { final String messageId; final RoomEventItem item; final bool isMe; + final bool canRedact; final bool isNextMessageInGroup; const MessageEventItem({ @@ -25,6 +26,7 @@ class MessageEventItem extends StatelessWidget { required this.messageId, required this.item, required this.isMe, + required this.canRedact, required this.isNextMessageInGroup, }); @@ -56,10 +58,11 @@ class MessageEventItem extends StatelessWidget { ); return GestureDetector( - onLongPressStart: (_) => reactionSelectionAction( + onLongPressStart: (_) => messageActions( context: context, messageWidget: messageWidget, isMe: isMe, + canRedact: canRedact, roomId: roomId, messageId: messageId, ), diff --git a/app/lib/features/chat_ng/widgets/message_actions_widget.dart b/app/lib/features/chat_ng/widgets/message_actions_widget.dart new file mode 100644 index 000000000000..e52f4539b929 --- /dev/null +++ b/app/lib/features/chat_ng/widgets/message_actions_widget.dart @@ -0,0 +1,106 @@ +import 'package:atlas_icons/atlas_icons.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/l10n.dart'; + +class MessageActionsWidget extends StatelessWidget { + final bool isMe; + final bool canRedact; + final String messageId; + final String roomId; + const MessageActionsWidget({ + super.key, + required this.isMe, + required this.canRedact, + required this.messageId, + required this.roomId, + }); + + @override + Widget build(BuildContext context) { + final lang = L10n.of(context); + return Container( + constraints: const BoxConstraints(maxWidth: 200), + padding: const EdgeInsets.all(8.0), + margin: const EdgeInsets.only(top: 4), + decoration: BoxDecoration( + borderRadius: const BorderRadius.all(Radius.circular(5)), + color: Theme.of(context).colorScheme.surface.withOpacity(0.8), + ), + child: Column( + children: menuItems(context, lang).map((e) => e).toList(), + ), + ); + } + + List menuItems(BuildContext context, L10n lang) => [ + makeMenuItem( + pressed: () {}, + text: Text(lang.reply), + icon: const Icon(Icons.reply_rounded, size: 18), + ), + // if (isTextMessage) + // makeMenuItem( + // pressed: () => onCopyMessage(context, ref, message), + // text: Text(lang.copyMessage), + // icon: const Icon( + // Icons.copy_all_outlined, + // size: 14, + // ), + // ), + if (isMe) + makeMenuItem( + pressed: () {}, + text: Text(lang.edit), + icon: const Icon(Atlas.pencil_box_bold, size: 14), + ), + if (!isMe) + makeMenuItem( + pressed: () {}, + text: Text( + lang.report, + style: TextStyle(color: Theme.of(context).colorScheme.error), + ), + icon: Icon( + Icons.flag_outlined, + size: 14, + color: Theme.of(context).colorScheme.error, + ), + ), + if (canRedact) + makeMenuItem( + pressed: () {}, + text: Text( + lang.delete, + style: TextStyle(color: Theme.of(context).colorScheme.error), + ), + icon: Icon( + Atlas.trash_can_thin, + size: 14, + color: Theme.of(context).colorScheme.error, + ), + ), + ]; + + Widget makeMenuItem({ + required Widget text, + Icon? icon, + required void Function() pressed, + }) { + return InkWell( + onTap: pressed, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 3, + vertical: 5, + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + text, + if (icon != null) icon, + ], + ), + ), + ); + } +} From 7725ba0e21e0a3c3ec9af3842e0362c2f8c84955 Mon Sep 17 00:00:00 2001 From: Talha Date: Wed, 29 Jan 2025 20:10:30 +0500 Subject: [PATCH 02/14] implement reply/edit preview UI --- .../actions/redact_message_action.dart | 70 ++++++++ .../actions/report_message_action.dart | 23 +++ .../chat_ng/dialogs/message_actions.dart | 7 +- app/lib/features/chat_ng/pages/chat_room.dart | 6 +- .../chat_room_messages_provider.dart | 6 + .../notifiers/chat_editor_notifier.dart | 72 ++++++++ .../chat_editor.dart | 31 +++- .../chat_editor_actions_preview.dart | 157 ++++++++++++++++++ .../chat_editor_loading.dart | 0 .../chat_editor_no_access.dart | 4 +- .../chat_editor_view.dart} | 10 +- .../chat_emoji_picker.dart | 0 .../widgets/events/message_event_item.dart | 3 +- .../widgets/message_actions_widget.dart | 38 ++++- 14 files changed, 403 insertions(+), 24 deletions(-) create mode 100644 app/lib/features/chat_ng/actions/redact_message_action.dart create mode 100644 app/lib/features/chat_ng/actions/report_message_action.dart create mode 100644 app/lib/features/chat_ng/providers/notifiers/chat_editor_notifier.dart rename app/lib/features/chat_ng/widgets/{chat_input => chat_editor}/chat_editor.dart (90%) create mode 100644 app/lib/features/chat_ng/widgets/chat_editor/chat_editor_actions_preview.dart rename app/lib/features/chat_ng/widgets/{chat_input => chat_editor}/chat_editor_loading.dart (100%) rename app/lib/features/chat_ng/widgets/{chat_input => chat_editor}/chat_editor_no_access.dart (88%) rename app/lib/features/chat_ng/widgets/{chat_input/chat_input.dart => chat_editor/chat_editor_view.dart} (68%) rename app/lib/features/chat_ng/widgets/{chat_input => chat_editor}/chat_emoji_picker.dart (100%) diff --git a/app/lib/features/chat_ng/actions/redact_message_action.dart b/app/lib/features/chat_ng/actions/redact_message_action.dart new file mode 100644 index 000000000000..2ee1731d14b9 --- /dev/null +++ b/app/lib/features/chat_ng/actions/redact_message_action.dart @@ -0,0 +1,70 @@ +import 'package:acter/common/providers/chat_providers.dart'; +import 'package:acter/common/providers/room_providers.dart'; +import 'package:acter/common/toolkit/buttons/primary_action_button.dart'; +import 'package:acter/common/widgets/default_dialog.dart'; +import 'package:acter/features/chat_ng/providers/chat_room_messages_provider.dart'; +import 'package:acter_flutter_sdk/acter_flutter_sdk_ffi.dart' + show RoomEventItem; +import 'package:flutter/material.dart'; +import 'package:flutter_easyloading/flutter_easyloading.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_gen/gen_l10n/l10n.dart'; +import 'package:logging/logging.dart'; + +final _log = Logger('a3::chat::message_actions_redact'); + +Future redactMessageAction( + BuildContext context, + WidgetRef ref, + RoomEventItem item, + String messageId, + String roomId, +) async { + final chatEditorNotifier = ref.watch(chatEditorStateProvider.notifier); + final lang = L10n.of(context); + final senderId = item.sender(); + + await showAdaptiveDialog( + context: context, + builder: (context) => DefaultDialog( + title: Text(lang.areYouSureYouWantToDeleteThisMessage), + actions: [ + OutlinedButton( + onPressed: () => Navigator.pop(context), + child: Text(lang.no), + ), + ActerPrimaryActionButton( + onPressed: () async { + try { + final convo = await ref.read(chatProvider(roomId).future); + if (convo == null) throw RoomNotFound(); + await convo.redactMessage( + messageId, + senderId, + null, + null, + ); + chatEditorNotifier.unsetActions(); + if (context.mounted) { + Navigator.pop(context); + // dismiss actions overlay also + Navigator.pop(context); + } + } catch (e, s) { + _log.severe('Redacting message failed', e, s); + if (!context.mounted) return; + EasyLoading.showError( + lang.redactionFailed(e), + duration: const Duration(seconds: 3), + ); + Navigator.pop(context); + // dismiss actions overlay also + Navigator.pop(context); + } + }, + child: Text(lang.yes), + ), + ], + ), + ); +} diff --git a/app/lib/features/chat_ng/actions/report_message_action.dart b/app/lib/features/chat_ng/actions/report_message_action.dart new file mode 100644 index 000000000000..bdab5a7bf46a --- /dev/null +++ b/app/lib/features/chat_ng/actions/report_message_action.dart @@ -0,0 +1,23 @@ +import 'package:acter/common/actions/report_content.dart'; +import 'package:acter_flutter_sdk/acter_flutter_sdk_ffi.dart' + show RoomEventItem; +import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/l10n.dart'; + +Future reportMessageAction( + BuildContext context, + RoomEventItem item, + String messageId, + String roomId, +) async { + final lang = L10n.of(context); + final senderId = item.sender(); + await openReportContentDialog( + context, + title: lang.reportThisMessage, + description: lang.reportMessageContent, + senderId: senderId, + roomId: roomId, + eventId: messageId, + ); +} diff --git a/app/lib/features/chat_ng/dialogs/message_actions.dart b/app/lib/features/chat_ng/dialogs/message_actions.dart index f6dc60758cb4..053d361effec 100644 --- a/app/lib/features/chat_ng/dialogs/message_actions.dart +++ b/app/lib/features/chat_ng/dialogs/message_actions.dart @@ -2,6 +2,8 @@ import 'dart:ui'; import 'package:acter/features/chat_ng/widgets/message_actions_widget.dart'; import 'package:acter/features/chat_ng/widgets/reactions/reaction_selector.dart'; +import 'package:acter_flutter_sdk/acter_flutter_sdk_ffi.dart' + show RoomEventItem; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; @@ -11,8 +13,9 @@ void messageActions({ required Widget messageWidget, required bool isMe, required bool canRedact, - required String roomId, + required RoomEventItem item, required String messageId, + required String roomId, }) async { // trigger vibration haptic await HapticFeedback.heavyImpact(); @@ -52,6 +55,8 @@ void messageActions({ child: MessageActionsWidget( isMe: isMe, canRedact: canRedact, + messageWidget: messageWidget, + item: item, messageId: messageId, roomId: roomId, ), diff --git a/app/lib/features/chat_ng/pages/chat_room.dart b/app/lib/features/chat_ng/pages/chat_room.dart index e57b735a97c0..64c918d2e0d9 100644 --- a/app/lib/features/chat_ng/pages/chat_room.dart +++ b/app/lib/features/chat_ng/pages/chat_room.dart @@ -5,7 +5,7 @@ import 'package:acter/common/utils/routes.dart'; import 'package:acter/common/widgets/frost_effect.dart'; import 'package:acter/features/chat/providers/chat_providers.dart'; import 'package:acter/features/chat/widgets/room_avatar.dart'; -import 'package:acter/features/chat_ng/widgets/chat_input/chat_input.dart'; +import 'package:acter/features/chat_ng/widgets/chat_editor/chat_editor_view.dart'; import 'package:acter/features/chat_ng/widgets/chat_messages.dart'; import 'package:acter/features/settings/providers/app_settings_provider.dart'; import 'package:flutter/material.dart'; @@ -123,8 +123,8 @@ class ChatRoomNgPage extends ConsumerWidget { (settings) => settings.valueOrNull?.typingNotice() ?? false, ), ); - return ChatInput( - key: Key('chat-input-$roomId'), + return ChatEditorView( + key: Key('chat-editor-$roomId'), roomId: roomId, onTyping: (typing) async { if (sendTypingNotice) { diff --git a/app/lib/features/chat_ng/providers/chat_room_messages_provider.dart b/app/lib/features/chat_ng/providers/chat_room_messages_provider.dart index 1ba2b37396dd..c35e00547cd6 100644 --- a/app/lib/features/chat_ng/providers/chat_room_messages_provider.dart +++ b/app/lib/features/chat_ng/providers/chat_room_messages_provider.dart @@ -5,6 +5,7 @@ import 'package:acter/common/utils/utils.dart'; import 'package:acter/common/widgets/html_editor/models/mention_type.dart'; import 'package:acter/features/chat_ng/models/chat_room_state/chat_room_state.dart'; import 'package:acter/features/chat_ng/models/replied_to_msg_state.dart'; +import 'package:acter/features/chat_ng/providers/notifiers/chat_editor_notifier.dart'; import 'package:acter/features/chat_ng/providers/notifiers/chat_room_messages_notifier.dart'; import 'package:acter/features/chat_ng/providers/notifiers/reply_messages_notifier.dart'; import 'package:acter_flutter_sdk/acter_flutter_sdk_ffi.dart'; @@ -156,3 +157,8 @@ final messageReactionsProvider = StateProvider.autoDispose return reactions; }); + +final chatEditorStateProvider = + NotifierProvider.autoDispose( + () => ChatEditorNotifier(), +); diff --git a/app/lib/features/chat_ng/providers/notifiers/chat_editor_notifier.dart b/app/lib/features/chat_ng/providers/notifiers/chat_editor_notifier.dart new file mode 100644 index 000000000000..e089cee8e711 --- /dev/null +++ b/app/lib/features/chat_ng/providers/notifiers/chat_editor_notifier.dart @@ -0,0 +1,72 @@ +import 'package:acter_flutter_sdk/acter_flutter_sdk_ffi.dart' + show RoomEventItem; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +enum MessageAction { none, reply, edit } + +class ChatEditorState { + final Widget? selectedMessage; + final RoomEventItem? selectedMsgItem; + final MessageAction actionType; + + const ChatEditorState({ + this.selectedMessage, + this.selectedMsgItem, + this.actionType = MessageAction.none, + }); + + bool get isReplying => actionType == MessageAction.reply; + bool get isEditing => actionType == MessageAction.edit; + + ChatEditorState copyWith({ + Widget? selectedMessage, + RoomEventItem? selectedMsgItem, + MessageAction? actionType, + }) { + return ChatEditorState( + selectedMessage: selectedMessage ?? this.selectedMessage, + selectedMsgItem: selectedMsgItem ?? this.selectedMsgItem, + actionType: actionType ?? this.actionType, + ); + } +} + +class ChatEditorNotifier extends AutoDisposeNotifier { + ChatEditorNotifier() : super(); + + @override + ChatEditorState build() => state = ChatEditorState( + selectedMessage: null, + selectedMsgItem: null, + actionType: MessageAction.none, + ); + void setReplyToMessage(Widget message, RoomEventItem msgItem) { + state = state.copyWith( + selectedMessage: message, + selectedMsgItem: msgItem, + actionType: MessageAction.reply, + ); + } + + void setEditMessage( + Widget message, + RoomEventItem msgItem, + ) { + state = state.copyWith( + selectedMessage: message, + selectedMsgItem: msgItem, + actionType: MessageAction.edit, + ); + } + + void unsetActions() { + if (state.actionType != MessageAction.none) { + state = state.copyWith( + selectedMessage: null, + selectedMsgItem: null, + actionType: MessageAction.none, + ); + } + } +} diff --git a/app/lib/features/chat_ng/widgets/chat_input/chat_editor.dart b/app/lib/features/chat_ng/widgets/chat_editor/chat_editor.dart similarity index 90% rename from app/lib/features/chat_ng/widgets/chat_input/chat_editor.dart rename to app/lib/features/chat_ng/widgets/chat_editor/chat_editor.dart index a00593f2462c..8a209da85e7f 100644 --- a/app/lib/features/chat_ng/widgets/chat_input/chat_editor.dart +++ b/app/lib/features/chat_ng/widgets/chat_editor/chat_editor.dart @@ -8,7 +8,9 @@ import 'package:acter/features/chat/providers/chat_providers.dart'; import 'package:acter/features/chat/utils.dart'; import 'package:acter/features/chat_ng/actions/attachment_upload_action.dart'; import 'package:acter/features/chat_ng/actions/send_message_action.dart'; -import 'package:acter/features/chat_ng/widgets/chat_input/chat_emoji_picker.dart'; +import 'package:acter/features/chat_ng/providers/chat_room_messages_provider.dart'; +import 'package:acter/features/chat_ng/widgets/chat_editor/chat_editor_actions_preview.dart'; +import 'package:acter/features/chat_ng/widgets/chat_editor/chat_emoji_picker.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:atlas_icons/atlas_icons.dart'; import 'package:flutter/material.dart'; @@ -137,15 +139,31 @@ class _ChatEditorState extends ConsumerState { @override Widget build(BuildContext context) { + final viewInsets = MediaQuery.viewInsetsOf(context).bottom; final isKeyboardVisible = ref.watch(keyboardVisibleProvider).valueOrNull; final emojiPickerVisible = ref .watch(chatInputProvider.select((value) => value.emojiPickerVisible)); final isEncrypted = ref.watch(isRoomEncryptedProvider(widget.roomId)).valueOrNull == true; + final chatEditorState = ref.watch(chatEditorStateProvider); + + Widget? previewWidget; + + if (chatEditorState.isReplying || chatEditorState.isEditing) { + final msgItem = chatEditorState.selectedMsgItem; + previewWidget = msgItem.map( + (item) => ChatEditorActionsPreview( + textEditorState: textEditorState, + msgItem: item, + roomId: widget.roomId, + ), + orElse: () => const SizedBox.shrink(), + ); + } - final viewInsets = MediaQuery.viewInsetsOf(context).bottom; return Column( children: [ + if (previewWidget != null) previewWidget, renderEditorUI(emojiPickerVisible, isEncrypted), // Emoji Picker UI if (emojiPickerVisible) ChatEmojiPicker(editorState: textEditorState), @@ -158,12 +176,17 @@ class _ChatEditorState extends ConsumerState { // chat editor UI Widget renderEditorUI(bool emojiPickerVisible, bool isEncrypted) { + final chatEditorState = ref.watch(chatEditorStateProvider); + final isPreviewOpen = + chatEditorState.isReplying || chatEditorState.isEditing; + final radiusVal = isPreviewOpen ? 2.0 : 15.0; + return DecoratedBox( decoration: BoxDecoration( color: Theme.of(context).colorScheme.primaryContainer, borderRadius: BorderRadius.only( - topLeft: Radius.circular(15.0), - topRight: Radius.circular(15.0), + topLeft: Radius.circular(radiusVal), + topRight: Radius.circular(radiusVal), ), border: BorderDirectional( top: BorderSide(color: greyColor), diff --git a/app/lib/features/chat_ng/widgets/chat_editor/chat_editor_actions_preview.dart b/app/lib/features/chat_ng/widgets/chat_editor/chat_editor_actions_preview.dart new file mode 100644 index 000000000000..a8ac91ec918f --- /dev/null +++ b/app/lib/features/chat_ng/widgets/chat_editor/chat_editor_actions_preview.dart @@ -0,0 +1,157 @@ +import 'package:acter/common/providers/room_providers.dart'; +import 'package:acter/common/widgets/html_editor/html_editor.dart'; +import 'package:acter/features/chat_ng/providers/chat_room_messages_provider.dart'; +import 'package:acter_avatar/acter_avatar.dart'; +import 'package:acter_flutter_sdk/acter_flutter_sdk_ffi.dart' + show RoomEventItem; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:atlas_icons/atlas_icons.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_gen/gen_l10n/l10n.dart'; +import 'package:intl/intl.dart'; + +class ChatEditorActionsPreview extends ConsumerStatefulWidget { + final EditorState textEditorState; + final RoomEventItem msgItem; + final String roomId; + const ChatEditorActionsPreview({ + super.key, + required this.textEditorState, + required this.msgItem, + required this.roomId, + }); + + @override + ConsumerState createState() => + _ChatEditorActionsPreviewConsumerState(); +} + +class _ChatEditorActionsPreviewConsumerState + extends ConsumerState { + @override + void didChangeDependencies() { + super.didChangeDependencies(); + final chatEditorState = ref.read(chatEditorStateProvider); + final textEditorState = widget.textEditorState; + final msgItem = widget.msgItem; + + if (chatEditorState.isEditing) { + final transaction = textEditorState.transaction; + final msgContent = msgItem.msgContent(); + if (msgContent == null) return; + final doc = ActerDocumentHelpers.fromMsgContent(msgContent); + Node rootNode = doc.root; + transaction.document.insert([0], rootNode.children); + transaction.afterSelection = + Selection.single(path: rootNode.path, startOffset: 0); + textEditorState.apply(transaction); + } + } + + @override + Widget build(BuildContext context) { + final chatEditorState = ref.watch(chatEditorStateProvider); + final children = []; + if (chatEditorState.isReplying) { + children.add(_buildRepliedToMsgView()); + if (chatEditorState.selectedMessage != null) { + children.add(chatEditorState.selectedMessage!); + } + } else if (chatEditorState.isEditing) { + children.add(_buildEditView()); + // add a bit space for clean UI + children.add(const SizedBox(height: 12)); + } + return _buildPreviewContainer(children); + } + + Widget _buildPreviewContainer(List children) { + return Container( + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.primaryContainer, + borderRadius: const BorderRadius.only( + topLeft: Radius.circular(6.0), + topRight: Radius.circular(6.0), + ), + ), + child: Padding( + padding: const EdgeInsets.only( + top: 12.0, + left: 16.0, + right: 16.0, + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.start, + children: children, + ), + ), + ); + } + + Widget _buildRepliedToMsgView() { + final authorId = widget.msgItem.sender(); + final memberAvatar = ref.watch( + memberAvatarInfoProvider((userId: authorId, roomId: widget.roomId)), + ); + return Row( + children: [ + const SizedBox(width: 1), + const Icon( + Icons.reply_rounded, + size: 12, + color: Colors.grey, + ), + const SizedBox(width: 4), + ActerAvatar( + options: AvatarOptions.DM( + memberAvatar, + size: 12, + ), + ), + const SizedBox(width: 5), + Text( + L10n.of(context).replyTo(toBeginningOfSentenceCase(authorId)), + style: const TextStyle( + color: Colors.grey, + fontSize: 12, + ), + ), + const Spacer(), + GestureDetector( + onTap: () => + ref.read(chatEditorStateProvider.notifier).unsetActions(), + child: const Icon(Atlas.xmark_circle), + ), + ], + ); + } + + Widget _buildEditView() { + return Row( + children: [ + const SizedBox(width: 1), + const Icon( + Atlas.pencil_edit_thin, + size: 12, + color: Colors.grey, + ), + const SizedBox(width: 4), + Text( + L10n.of(context).editMessage, + style: const TextStyle( + color: Colors.grey, + fontSize: 12, + ), + ), + const Spacer(), + GestureDetector( + onTap: () => + ref.read(chatEditorStateProvider.notifier).unsetActions(), + child: const Icon(Atlas.xmark_circle), + ), + ], + ); + } +} diff --git a/app/lib/features/chat_ng/widgets/chat_input/chat_editor_loading.dart b/app/lib/features/chat_ng/widgets/chat_editor/chat_editor_loading.dart similarity index 100% rename from app/lib/features/chat_ng/widgets/chat_input/chat_editor_loading.dart rename to app/lib/features/chat_ng/widgets/chat_editor/chat_editor_loading.dart diff --git a/app/lib/features/chat_ng/widgets/chat_input/chat_editor_no_access.dart b/app/lib/features/chat_ng/widgets/chat_editor/chat_editor_no_access.dart similarity index 88% rename from app/lib/features/chat_ng/widgets/chat_input/chat_editor_no_access.dart rename to app/lib/features/chat_ng/widgets/chat_editor/chat_editor_no_access.dart index da9c17e019d9..e7fef4999bb0 100644 --- a/app/lib/features/chat_ng/widgets/chat_input/chat_editor_no_access.dart +++ b/app/lib/features/chat_ng/widgets/chat_editor/chat_editor_no_access.dart @@ -1,4 +1,4 @@ -import 'package:acter/features/chat_ng/widgets/chat_input/chat_input.dart'; +import 'package:acter/features/chat_ng/widgets/chat_editor/chat_editor_view.dart'; import 'package:atlas_icons/atlas_icons.dart'; import 'package:flutter/material.dart'; import 'package:flutter_gen/gen_l10n/l10n.dart'; @@ -21,7 +21,7 @@ class ChatEditorNoAccess extends StatelessWidget { ), const SizedBox(width: 4), Text( - key: ChatInput.noAccessKey, + key: ChatEditorView.noAccessKey, L10n.of(context).chatMissingPermissionsToSend, style: TextStyle(color: Theme.of(context).unselectedWidgetColor), ), diff --git a/app/lib/features/chat_ng/widgets/chat_input/chat_input.dart b/app/lib/features/chat_ng/widgets/chat_editor/chat_editor_view.dart similarity index 68% rename from app/lib/features/chat_ng/widgets/chat_input/chat_input.dart rename to app/lib/features/chat_ng/widgets/chat_editor/chat_editor_view.dart index 0fe2c303e40c..59b54e4a8c75 100644 --- a/app/lib/features/chat_ng/widgets/chat_input/chat_input.dart +++ b/app/lib/features/chat_ng/widgets/chat_editor/chat_editor_view.dart @@ -1,18 +1,18 @@ import 'package:acter/features/chat/providers/chat_providers.dart'; -import 'package:acter/features/chat_ng/widgets/chat_input/chat_editor.dart'; -import 'package:acter/features/chat_ng/widgets/chat_input/chat_editor_loading.dart'; -import 'package:acter/features/chat_ng/widgets/chat_input/chat_editor_no_access.dart'; +import 'package:acter/features/chat_ng/widgets/chat_editor/chat_editor.dart'; +import 'package:acter/features/chat_ng/widgets/chat_editor/chat_editor_loading.dart'; +import 'package:acter/features/chat_ng/widgets/chat_editor/chat_editor_no_access.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -class ChatInput extends ConsumerWidget { +class ChatEditorView extends ConsumerWidget { static const loadingKey = Key('chat-ng-loading'); static const noAccessKey = Key('chat-ng-no-access'); final String roomId; final void Function(bool)? onTyping; - const ChatInput({super.key, required this.roomId, this.onTyping}); + const ChatEditorView({super.key, required this.roomId, this.onTyping}); @override Widget build(BuildContext context, WidgetRef ref) { diff --git a/app/lib/features/chat_ng/widgets/chat_input/chat_emoji_picker.dart b/app/lib/features/chat_ng/widgets/chat_editor/chat_emoji_picker.dart similarity index 100% rename from app/lib/features/chat_ng/widgets/chat_input/chat_emoji_picker.dart rename to app/lib/features/chat_ng/widgets/chat_editor/chat_emoji_picker.dart diff --git a/app/lib/features/chat_ng/widgets/events/message_event_item.dart b/app/lib/features/chat_ng/widgets/events/message_event_item.dart index a4380bbbb3c3..8e1b4155f876 100644 --- a/app/lib/features/chat_ng/widgets/events/message_event_item.dart +++ b/app/lib/features/chat_ng/widgets/events/message_event_item.dart @@ -63,8 +63,9 @@ class MessageEventItem extends StatelessWidget { messageWidget: messageWidget, isMe: isMe, canRedact: canRedact, - roomId: roomId, + item: item, messageId: messageId, + roomId: roomId, ), child: Hero( tag: messageId, diff --git a/app/lib/features/chat_ng/widgets/message_actions_widget.dart b/app/lib/features/chat_ng/widgets/message_actions_widget.dart index e52f4539b929..9ccd082d3c0c 100644 --- a/app/lib/features/chat_ng/widgets/message_actions_widget.dart +++ b/app/lib/features/chat_ng/widgets/message_actions_widget.dart @@ -1,22 +1,32 @@ +import 'package:acter/features/chat_ng/actions/redact_message_action.dart'; +import 'package:acter/features/chat_ng/actions/report_message_action.dart'; +import 'package:acter/features/chat_ng/providers/chat_room_messages_provider.dart'; +import 'package:acter_flutter_sdk/acter_flutter_sdk_ffi.dart' + show RoomEventItem; import 'package:atlas_icons/atlas_icons.dart'; import 'package:flutter/material.dart'; import 'package:flutter_gen/gen_l10n/l10n.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; -class MessageActionsWidget extends StatelessWidget { +class MessageActionsWidget extends ConsumerWidget { final bool isMe; final bool canRedact; + final Widget messageWidget; + final RoomEventItem item; final String messageId; final String roomId; const MessageActionsWidget({ super.key, required this.isMe, required this.canRedact, + required this.messageWidget, + required this.item, required this.messageId, required this.roomId, }); @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { final lang = L10n.of(context); return Container( constraints: const BoxConstraints(maxWidth: 200), @@ -27,14 +37,19 @@ class MessageActionsWidget extends StatelessWidget { color: Theme.of(context).colorScheme.surface.withOpacity(0.8), ), child: Column( - children: menuItems(context, lang).map((e) => e).toList(), + children: menuItems(context, ref, lang).map((e) => e).toList(), ), ); } - List menuItems(BuildContext context, L10n lang) => [ + List menuItems(BuildContext context, WidgetRef ref, L10n lang) => [ makeMenuItem( - pressed: () {}, + pressed: () { + ref + .read(chatEditorStateProvider.notifier) + .setReplyToMessage(messageWidget, item); + Navigator.pop(context); + }, text: Text(lang.reply), icon: const Icon(Icons.reply_rounded, size: 18), ), @@ -49,13 +64,19 @@ class MessageActionsWidget extends StatelessWidget { // ), if (isMe) makeMenuItem( - pressed: () {}, + pressed: () { + ref + .read(chatEditorStateProvider.notifier) + .setEditMessage(messageWidget, item); + Navigator.pop(context); + }, text: Text(lang.edit), icon: const Icon(Atlas.pencil_box_bold, size: 14), ), if (!isMe) makeMenuItem( - pressed: () {}, + pressed: () => + reportMessageAction(context, item, messageId, roomId), text: Text( lang.report, style: TextStyle(color: Theme.of(context).colorScheme.error), @@ -68,7 +89,8 @@ class MessageActionsWidget extends StatelessWidget { ), if (canRedact) makeMenuItem( - pressed: () {}, + pressed: () => + redactMessageAction(context, ref, item, messageId, roomId), text: Text( lang.delete, style: TextStyle(color: Theme.of(context).colorScheme.error), From cad88fb46960493be0adf8e0bca56a297bc5b9e2 Mon Sep 17 00:00:00 2001 From: Talha Date: Wed, 29 Jan 2025 20:49:53 +0500 Subject: [PATCH 03/14] more better logic for previewing replied to items and general cleanup --- .../chat_ng/models/chat_editor_state.dart | 27 +++++++++ .../chat_room_messages_provider.dart | 1 + .../notifiers/chat_editor_notifier.dart | 36 +----------- .../chat_editor_actions_preview.dart | 55 +++++++++++++++++-- .../widgets/message_actions_widget.dart | 4 +- 5 files changed, 81 insertions(+), 42 deletions(-) create mode 100644 app/lib/features/chat_ng/models/chat_editor_state.dart diff --git a/app/lib/features/chat_ng/models/chat_editor_state.dart b/app/lib/features/chat_ng/models/chat_editor_state.dart new file mode 100644 index 000000000000..85d9d874b735 --- /dev/null +++ b/app/lib/features/chat_ng/models/chat_editor_state.dart @@ -0,0 +1,27 @@ +import 'package:acter_flutter_sdk/acter_flutter_sdk_ffi.dart' + show RoomEventItem; + +enum MessageAction { none, reply, edit } + +class ChatEditorState { + final RoomEventItem? selectedMsgItem; + final MessageAction actionType; + + const ChatEditorState({ + this.selectedMsgItem, + this.actionType = MessageAction.none, + }); + + bool get isReplying => actionType == MessageAction.reply; + bool get isEditing => actionType == MessageAction.edit; + + ChatEditorState copyWith({ + RoomEventItem? selectedMsgItem, + MessageAction? actionType, + }) { + return ChatEditorState( + selectedMsgItem: selectedMsgItem ?? this.selectedMsgItem, + actionType: actionType ?? this.actionType, + ); + } +} diff --git a/app/lib/features/chat_ng/providers/chat_room_messages_provider.dart b/app/lib/features/chat_ng/providers/chat_room_messages_provider.dart index c35e00547cd6..52a8c4f53c41 100644 --- a/app/lib/features/chat_ng/providers/chat_room_messages_provider.dart +++ b/app/lib/features/chat_ng/providers/chat_room_messages_provider.dart @@ -3,6 +3,7 @@ import 'package:acter/common/providers/common_providers.dart'; import 'package:acter/common/providers/room_providers.dart'; import 'package:acter/common/utils/utils.dart'; import 'package:acter/common/widgets/html_editor/models/mention_type.dart'; +import 'package:acter/features/chat_ng/models/chat_editor_state.dart'; import 'package:acter/features/chat_ng/models/chat_room_state/chat_room_state.dart'; import 'package:acter/features/chat_ng/models/replied_to_msg_state.dart'; import 'package:acter/features/chat_ng/providers/notifiers/chat_editor_notifier.dart'; diff --git a/app/lib/features/chat_ng/providers/notifiers/chat_editor_notifier.dart b/app/lib/features/chat_ng/providers/notifiers/chat_editor_notifier.dart index e089cee8e711..7a0257129f4d 100644 --- a/app/lib/features/chat_ng/providers/notifiers/chat_editor_notifier.dart +++ b/app/lib/features/chat_ng/providers/notifiers/chat_editor_notifier.dart @@ -1,49 +1,19 @@ +import 'package:acter/features/chat_ng/models/chat_editor_state.dart'; import 'package:acter_flutter_sdk/acter_flutter_sdk_ffi.dart' show RoomEventItem; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -enum MessageAction { none, reply, edit } - -class ChatEditorState { - final Widget? selectedMessage; - final RoomEventItem? selectedMsgItem; - final MessageAction actionType; - - const ChatEditorState({ - this.selectedMessage, - this.selectedMsgItem, - this.actionType = MessageAction.none, - }); - - bool get isReplying => actionType == MessageAction.reply; - bool get isEditing => actionType == MessageAction.edit; - - ChatEditorState copyWith({ - Widget? selectedMessage, - RoomEventItem? selectedMsgItem, - MessageAction? actionType, - }) { - return ChatEditorState( - selectedMessage: selectedMessage ?? this.selectedMessage, - selectedMsgItem: selectedMsgItem ?? this.selectedMsgItem, - actionType: actionType ?? this.actionType, - ); - } -} - class ChatEditorNotifier extends AutoDisposeNotifier { ChatEditorNotifier() : super(); @override ChatEditorState build() => state = ChatEditorState( - selectedMessage: null, selectedMsgItem: null, actionType: MessageAction.none, ); - void setReplyToMessage(Widget message, RoomEventItem msgItem) { + void setReplyToMessage(RoomEventItem msgItem) { state = state.copyWith( - selectedMessage: message, selectedMsgItem: msgItem, actionType: MessageAction.reply, ); @@ -54,7 +24,6 @@ class ChatEditorNotifier extends AutoDisposeNotifier { RoomEventItem msgItem, ) { state = state.copyWith( - selectedMessage: message, selectedMsgItem: msgItem, actionType: MessageAction.edit, ); @@ -63,7 +32,6 @@ class ChatEditorNotifier extends AutoDisposeNotifier { void unsetActions() { if (state.actionType != MessageAction.none) { state = state.copyWith( - selectedMessage: null, selectedMsgItem: null, actionType: MessageAction.none, ); diff --git a/app/lib/features/chat_ng/widgets/chat_editor/chat_editor_actions_preview.dart b/app/lib/features/chat_ng/widgets/chat_editor/chat_editor_actions_preview.dart index a8ac91ec918f..17fd6dc33008 100644 --- a/app/lib/features/chat_ng/widgets/chat_editor/chat_editor_actions_preview.dart +++ b/app/lib/features/chat_ng/widgets/chat_editor/chat_editor_actions_preview.dart @@ -1,6 +1,10 @@ import 'package:acter/common/providers/room_providers.dart'; import 'package:acter/common/widgets/html_editor/html_editor.dart'; import 'package:acter/features/chat_ng/providers/chat_room_messages_provider.dart'; +import 'package:acter/features/chat_ng/widgets/events/file_message_event.dart'; +import 'package:acter/features/chat_ng/widgets/events/image_message_event.dart'; +import 'package:acter/features/chat_ng/widgets/events/text_message_event.dart'; +import 'package:acter/features/chat_ng/widgets/events/video_message_event.dart'; import 'package:acter_avatar/acter_avatar.dart'; import 'package:acter_flutter_sdk/acter_flutter_sdk_ffi.dart' show RoomEventItem; @@ -15,6 +19,7 @@ class ChatEditorActionsPreview extends ConsumerStatefulWidget { final EditorState textEditorState; final RoomEventItem msgItem; final String roomId; + const ChatEditorActionsPreview({ super.key, required this.textEditorState, @@ -43,8 +48,7 @@ class _ChatEditorActionsPreviewConsumerState final doc = ActerDocumentHelpers.fromMsgContent(msgContent); Node rootNode = doc.root; transaction.document.insert([0], rootNode.children); - transaction.afterSelection = - Selection.single(path: rootNode.path, startOffset: 0); + transaction.afterSelection = textEditorState.selection; textEditorState.apply(transaction); } } @@ -55,9 +59,7 @@ class _ChatEditorActionsPreviewConsumerState final children = []; if (chatEditorState.isReplying) { children.add(_buildRepliedToMsgView()); - if (chatEditorState.selectedMessage != null) { - children.add(chatEditorState.selectedMessage!); - } + children.add(_buildRepliedToItem(context, widget.msgItem)); } else if (chatEditorState.isEditing) { children.add(_buildEditView()); // add a bit space for clean UI @@ -154,4 +156,47 @@ class _ChatEditorActionsPreviewConsumerState ], ); } + + Widget _buildRepliedToItem(BuildContext context, RoomEventItem item) { + final roomId = widget.roomId; + final messageId = item.eventId(); + final msgType = item.msgType(); + final content = item.msgContent(); + if (msgType == null || content == null || messageId == null) { + return const SizedBox.shrink(); + } + + Widget child = switch (msgType) { + 'm.emote' || + 'm.notice' || + 'm.server_notice' || + 'm.text' => + TextMessageEvent( + content: content, + roomId: roomId, + ), + 'm.image' => ImageMessageEvent( + messageId: messageId, + roomId: roomId, + content: content, + ), + 'm.video' => VideoMessageEvent( + roomId: roomId, + messageId: messageId, + content: content, + ), + 'm.file' => FileMessageEvent( + roomId: roomId, + messageId: messageId, + content: content, + ), + _ => const SizedBox.shrink(), + }; + + return Container( + constraints: BoxConstraints(maxHeight: 100), + padding: const EdgeInsets.all(12.0), + child: child, + ); + } } diff --git a/app/lib/features/chat_ng/widgets/message_actions_widget.dart b/app/lib/features/chat_ng/widgets/message_actions_widget.dart index 9ccd082d3c0c..4ce30f4527ef 100644 --- a/app/lib/features/chat_ng/widgets/message_actions_widget.dart +++ b/app/lib/features/chat_ng/widgets/message_actions_widget.dart @@ -45,9 +45,7 @@ class MessageActionsWidget extends ConsumerWidget { List menuItems(BuildContext context, WidgetRef ref, L10n lang) => [ makeMenuItem( pressed: () { - ref - .read(chatEditorStateProvider.notifier) - .setReplyToMessage(messageWidget, item); + ref.read(chatEditorStateProvider.notifier).setReplyToMessage(item); Navigator.pop(context); }, text: Text(lang.reply), From acb5b501560a7a10c4778f74c3993508f8199bb6 Mon Sep 17 00:00:00 2001 From: Talha Date: Wed, 29 Jan 2025 20:55:49 +0500 Subject: [PATCH 04/14] update send message action with new editor state provider --- .../chat_ng/actions/send_message_action.dart | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/app/lib/features/chat_ng/actions/send_message_action.dart b/app/lib/features/chat_ng/actions/send_message_action.dart index 0f16b130e082..3db237c35a55 100644 --- a/app/lib/features/chat_ng/actions/send_message_action.dart +++ b/app/lib/features/chat_ng/actions/send_message_action.dart @@ -1,7 +1,7 @@ import 'package:acter/common/providers/chat_providers.dart'; import 'package:acter/common/widgets/html_editor/html_editor.dart'; -import 'package:acter/features/chat/models/chat_input_state/chat_input_state.dart'; import 'package:acter/features/chat/providers/chat_providers.dart'; +import 'package:acter/features/chat_ng/providers/chat_room_messages_provider.dart'; import 'package:acter/features/home/providers/client_providers.dart'; import 'package:acter_flutter_sdk/acter_flutter_sdk_ffi.dart' show MsgDraft; import 'package:appflowy_editor/appflowy_editor.dart'; @@ -40,15 +40,15 @@ Future sendMessageAction({ } // actually send it out - final inputState = ref.read(chatInputProvider); + final chatEditorState = ref.read(chatEditorStateProvider); final stream = await ref.read(timelineStreamProvider(roomId).future); - if (inputState.selectedMessageState == SelectedMessageState.replyTo) { - final remoteId = inputState.selectedMessage?.remoteId; + if (chatEditorState.isReplying) { + final remoteId = chatEditorState.selectedMsgItem?.eventId(); if (remoteId == null) throw 'remote id of sel msg not available'; await stream.replyMessage(remoteId, draft); - } else if (inputState.selectedMessageState == SelectedMessageState.edit) { - final remoteId = inputState.selectedMessage?.remoteId; + } else if (chatEditorState.isEditing) { + final remoteId = chatEditorState.selectedMsgItem?.eventId(); if (remoteId == null) throw 'remote id of sel msg not available'; await stream.editMessage(remoteId, draft); } else { From d4d3c49c937d42bda6c45ddbc74d373d4f16cf0a Mon Sep 17 00:00:00 2001 From: Talha Date: Thu, 30 Jan 2025 22:01:30 +0500 Subject: [PATCH 05/14] fix editing/reply view logic and add editor utility fn --- .../widgets/html_editor/html_editor.dart | 18 +++- .../chat_ng/dialogs/message_actions.dart | 1 - .../chat_room_messages_provider.dart | 13 +-- .../notifiers/chat_editor_notifier.dart | 6 +- .../widgets/chat_editor/chat_editor.dart | 83 +++++++++++++++---- .../chat_editor_actions_preview.dart | 74 +++++++---------- .../chat_ng/widgets/chat_messages.dart | 11 ++- .../widgets/message_actions_widget.dart | 6 +- 8 files changed, 129 insertions(+), 83 deletions(-) diff --git a/app/lib/common/widgets/html_editor/html_editor.dart b/app/lib/common/widgets/html_editor/html_editor.dart index 3543e4c744d3..1243870553b5 100644 --- a/app/lib/common/widgets/html_editor/html_editor.dart +++ b/app/lib/common/widgets/html_editor/html_editor.dart @@ -48,6 +48,22 @@ extension ActerEditorStateHelpers on EditorState { String intoMarkdown({AppFlowyEditorMarkdownCodec? codec}) { return (codec ?? defaultMarkdownCodec).encode(document); } + + /// clear the editor text with selection + void clear() async { + if (!document.isEmpty) { + final node = getNodeAtPath([0]); + final transaction = this.transaction; + final selection = this.selection; + if (node == null) return; + transaction.deleteText(node, 0, node.delta?.length ?? 0); + await updateSelectionWithReason( + selection, + reason: SelectionUpdateReason.transaction, + ); + apply(transaction); + } + } } extension ActerDocumentHelpers on Document { @@ -80,7 +96,7 @@ extension ActerDocumentHelpers on Document { }) { if (htmlContent != null) { final document = ActerDocumentHelpers._fromHtml(htmlContent); - if (document != null) { + if (document != null && !document.isEmpty) { return document; } } diff --git a/app/lib/features/chat_ng/dialogs/message_actions.dart b/app/lib/features/chat_ng/dialogs/message_actions.dart index 053d361effec..b0d114c63495 100644 --- a/app/lib/features/chat_ng/dialogs/message_actions.dart +++ b/app/lib/features/chat_ng/dialogs/message_actions.dart @@ -55,7 +55,6 @@ void messageActions({ child: MessageActionsWidget( isMe: isMe, canRedact: canRedact, - messageWidget: messageWidget, item: item, messageId: messageId, roomId: roomId, diff --git a/app/lib/features/chat_ng/providers/chat_room_messages_provider.dart b/app/lib/features/chat_ng/providers/chat_room_messages_provider.dart index 52a8c4f53c41..c93a4bb740c3 100644 --- a/app/lib/features/chat_ng/providers/chat_room_messages_provider.dart +++ b/app/lib/features/chat_ng/providers/chat_room_messages_provider.dart @@ -26,14 +26,14 @@ typedef RoomMsgId = ({String roomId, String uniqueId}); typedef MentionQuery = (String, MentionType); typedef ReactionItem = (String, List); -final chatStateProvider = StateNotifierProvider.family( +final chatMessagesStateProvider = StateNotifierProvider.family< + ChatRoomMessagesNotifier, ChatRoomState, String>( (ref, roomId) => ChatRoomMessagesNotifier(ref: ref, roomId: roomId), ); final chatRoomMessageProvider = StateProvider.family((ref, roomMsgId) { - final chatRoomState = ref.watch(chatStateProvider(roomMsgId.roomId)); + final chatRoomState = ref.watch(chatMessagesStateProvider(roomMsgId.roomId)); return chatRoomState.message(roomMsgId.uniqueId); }); @@ -41,13 +41,14 @@ final showHiddenMessages = StateProvider((ref) => false); final animatedListChatMessagesProvider = StateProvider.family, String>( - (ref, roomId) => ref.watch(chatStateProvider(roomId).notifier).animatedList, + (ref, roomId) => + ref.watch(chatMessagesStateProvider(roomId).notifier).animatedList, ); final renderableChatMessagesProvider = StateProvider.autoDispose.family, String>((ref, roomId) { - final msgList = - ref.watch(chatStateProvider(roomId).select((value) => value.messageList)); + final msgList = ref.watch( + chatMessagesStateProvider(roomId).select((value) => value.messageList)); if (ref.watch(showHiddenMessages)) { // do not apply filters return msgList; diff --git a/app/lib/features/chat_ng/providers/notifiers/chat_editor_notifier.dart b/app/lib/features/chat_ng/providers/notifiers/chat_editor_notifier.dart index 7a0257129f4d..b737d2761c42 100644 --- a/app/lib/features/chat_ng/providers/notifiers/chat_editor_notifier.dart +++ b/app/lib/features/chat_ng/providers/notifiers/chat_editor_notifier.dart @@ -1,7 +1,6 @@ import 'package:acter/features/chat_ng/models/chat_editor_state.dart'; import 'package:acter_flutter_sdk/acter_flutter_sdk_ffi.dart' show RoomEventItem; -import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; class ChatEditorNotifier extends AutoDisposeNotifier { @@ -19,10 +18,7 @@ class ChatEditorNotifier extends AutoDisposeNotifier { ); } - void setEditMessage( - Widget message, - RoomEventItem msgItem, - ) { + void setEditMessage(RoomEventItem msgItem) { state = state.copyWith( selectedMsgItem: msgItem, actionType: MessageAction.edit, diff --git a/app/lib/features/chat_ng/widgets/chat_editor/chat_editor.dart b/app/lib/features/chat_ng/widgets/chat_editor/chat_editor.dart index 8a209da85e7f..4c967e588001 100644 --- a/app/lib/features/chat_ng/widgets/chat_editor/chat_editor.dart +++ b/app/lib/features/chat_ng/widgets/chat_editor/chat_editor.dart @@ -11,6 +11,8 @@ import 'package:acter/features/chat_ng/actions/send_message_action.dart'; import 'package:acter/features/chat_ng/providers/chat_room_messages_provider.dart'; import 'package:acter/features/chat_ng/widgets/chat_editor/chat_editor_actions_preview.dart'; import 'package:acter/features/chat_ng/widgets/chat_editor/chat_emoji_picker.dart'; +import 'package:acter_flutter_sdk/acter_flutter_sdk_ffi.dart' + show RoomEventItem; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:atlas_icons/atlas_icons.dart'; import 'package:flutter/material.dart'; @@ -57,6 +59,22 @@ class _ChatEditorState extends ConsumerState { // have it call the first time to adjust height _updateHeight(); WidgetsBinding.instance.addPostFrameCallback((_) => _loadDraft()); + + ref.listenManual(chatEditorStateProvider, (prev, next) async { + if (next.isEditing && + (next.actionType != prev?.actionType || + next.selectedMsgItem != prev?.selectedMsgItem)) { + _handleEditing(next.selectedMsgItem); + } else if (next.isReplying && + (next.actionType != prev?.actionType || + next.selectedMsgItem != prev?.selectedMsgItem)) { + final transaction = textEditorState.transaction; + transaction.afterSelection = Selection.collapsed( + Position(path: [0], offset: 0), + ); + await textEditorState.apply(transaction); + } + }); } @override @@ -74,6 +92,29 @@ class _ChatEditorState extends ConsumerState { } } + void _handleEditing(RoomEventItem? item) { + try { + if (item == null) return; + // clear existing content + textEditorState.clear(); + final msgContent = item.msgContent(); + if (msgContent == null) return; + final body = msgContent.body(); + // insert editing text + final transaction = textEditorState.transaction; + final docNode = transaction.document.root; + + transaction.insertText(docNode.children.last, 0, body); + textEditorState.updateSelectionWithReason( + textEditorState.selection, + reason: SelectionUpdateReason.transaction, + ); + textEditorState.apply(transaction); + } catch (e) { + _log.severe('Error handling edit state change: $e'); + } + } + void _editorUpdate(Transaction data) { // check if actual document content is empty final state = data.document.root.children @@ -108,31 +149,37 @@ class _ChatEditorState extends ConsumerState { await ref.read(chatComposerDraftProvider(widget.roomId).future); if (draft != null) { - final inputNotifier = ref.read(chatInputProvider.notifier); - inputNotifier.unsetSelectedMessage(); + final chatEditorState = ref.read(chatEditorStateProvider.notifier); + chatEditorState.unsetActions(); draft.eventId().map((eventId) { final draftType = draft.draftType(); - final m = ref - .read(chatMessagesProvider(widget.roomId)) - .firstWhere((x) => x.id == eventId); - if (draftType == 'edit') { - inputNotifier.setEditMessage(m); - } else if (draftType == 'reply') { - inputNotifier.setReplyToMessage(m); + final msgsList = + ref.read(chatMessagesStateProvider(widget.roomId)).messages; + try { + final roomMsg = msgsList[eventId]; + final item = roomMsg?.eventItem(); + + if (item == null) return; + + if (draftType == 'edit') { + chatEditorState.setEditMessage(item); + } else if (draftType == 'reply') { + chatEditorState.setReplyToMessage(item); + } + } catch (e) { + _log.severe('Message with $eventId not found'); + return; } }); - final transaction = textEditorState.transaction; - final doc = ActerDocumentHelpers.parse( - draft.plainText(), - htmlContent: draft.htmlText(), + final docNode = transaction.document.root; + + transaction.insertText(docNode.children.last, 0, draft.plainText()); + textEditorState.updateSelectionWithReason( + textEditorState.selection, + reason: SelectionUpdateReason.transaction, ); - Node rootNode = doc.root; - transaction.document.insert([0], rootNode.children); - transaction.afterSelection = - Selection.single(path: rootNode.path, startOffset: 0); textEditorState.apply(transaction); - _log.info('compose draft loaded for room: ${widget.roomId}'); } } diff --git a/app/lib/features/chat_ng/widgets/chat_editor/chat_editor_actions_preview.dart b/app/lib/features/chat_ng/widgets/chat_editor/chat_editor_actions_preview.dart index 17fd6dc33008..5ae947669081 100644 --- a/app/lib/features/chat_ng/widgets/chat_editor/chat_editor_actions_preview.dart +++ b/app/lib/features/chat_ng/widgets/chat_editor/chat_editor_actions_preview.dart @@ -11,11 +11,12 @@ import 'package:acter_flutter_sdk/acter_flutter_sdk_ffi.dart' import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:atlas_icons/atlas_icons.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_gen/gen_l10n/l10n.dart'; import 'package:intl/intl.dart'; -class ChatEditorActionsPreview extends ConsumerStatefulWidget { +class ChatEditorActionsPreview extends ConsumerWidget { final EditorState textEditorState; final RoomEventItem msgItem; final String roomId; @@ -28,47 +29,21 @@ class ChatEditorActionsPreview extends ConsumerStatefulWidget { }); @override - ConsumerState createState() => - _ChatEditorActionsPreviewConsumerState(); -} - -class _ChatEditorActionsPreviewConsumerState - extends ConsumerState { - @override - void didChangeDependencies() { - super.didChangeDependencies(); - final chatEditorState = ref.read(chatEditorStateProvider); - final textEditorState = widget.textEditorState; - final msgItem = widget.msgItem; - - if (chatEditorState.isEditing) { - final transaction = textEditorState.transaction; - final msgContent = msgItem.msgContent(); - if (msgContent == null) return; - final doc = ActerDocumentHelpers.fromMsgContent(msgContent); - Node rootNode = doc.root; - transaction.document.insert([0], rootNode.children); - transaction.afterSelection = textEditorState.selection; - textEditorState.apply(transaction); - } - } - - @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { final chatEditorState = ref.watch(chatEditorStateProvider); final children = []; if (chatEditorState.isReplying) { - children.add(_buildRepliedToMsgView()); - children.add(_buildRepliedToItem(context, widget.msgItem)); + children.add(_buildRepliedToMsgView(context, ref)); + children.add(_buildRepliedToItem(context, msgItem)); } else if (chatEditorState.isEditing) { - children.add(_buildEditView()); + children.add(_buildEditView(context, ref)); // add a bit space for clean UI children.add(const SizedBox(height: 12)); } - return _buildPreviewContainer(children); + return _buildPreviewContainer(context, children); } - Widget _buildPreviewContainer(List children) { + Widget _buildPreviewContainer(BuildContext context, List children) { return Container( decoration: BoxDecoration( color: Theme.of(context).colorScheme.primaryContainer, @@ -92,10 +67,10 @@ class _ChatEditorActionsPreviewConsumerState ); } - Widget _buildRepliedToMsgView() { - final authorId = widget.msgItem.sender(); + Widget _buildRepliedToMsgView(BuildContext context, WidgetRef ref) { + final authorId = msgItem.sender(); final memberAvatar = ref.watch( - memberAvatarInfoProvider((userId: authorId, roomId: widget.roomId)), + memberAvatarInfoProvider((userId: authorId, roomId: roomId)), ); return Row( children: [ @@ -130,7 +105,7 @@ class _ChatEditorActionsPreviewConsumerState ); } - Widget _buildEditView() { + Widget _buildEditView(BuildContext context, WidgetRef ref) { return Row( children: [ const SizedBox(width: 1), @@ -149,8 +124,11 @@ class _ChatEditorActionsPreviewConsumerState ), const Spacer(), GestureDetector( - onTap: () => - ref.read(chatEditorStateProvider.notifier).unsetActions(), + onTap: () { + ref.read(chatEditorStateProvider.notifier).unsetActions(); + // closing editing action, also clear the editor + textEditorState.clear(); + }, child: const Icon(Atlas.xmark_circle), ), ], @@ -158,7 +136,6 @@ class _ChatEditorActionsPreviewConsumerState } Widget _buildRepliedToItem(BuildContext context, RoomEventItem item) { - final roomId = widget.roomId; final messageId = item.eventId(); final msgType = item.msgType(); final content = item.msgContent(); @@ -193,10 +170,21 @@ class _ChatEditorActionsPreviewConsumerState _ => const SizedBox.shrink(), }; - return Container( + // keep this UI logic for clipping reply content + return ConstrainedBox( constraints: BoxConstraints(maxHeight: 100), - padding: const EdgeInsets.all(12.0), - child: child, + child: ClipRect( + child: Padding( + padding: const EdgeInsets.all(12.0), + child: OverflowBox( + fit: OverflowBoxFit.deferToChild, + alignment: Alignment.topLeft, + maxHeight: double.infinity, + minHeight: 0, + child: child, + ), + ), + ), ); } } diff --git a/app/lib/features/chat_ng/widgets/chat_messages.dart b/app/lib/features/chat_ng/widgets/chat_messages.dart index 2e5cfbe1fd14..722aac4706a4 100644 --- a/app/lib/features/chat_ng/widgets/chat_messages.dart +++ b/app/lib/features/chat_ng/widgets/chat_messages.dart @@ -18,7 +18,8 @@ class _ChatMessagesConsumerState extends ConsumerState { ScrollController(keepScrollOffset: true); bool get isLoading => ref.watch( - chatStateProvider(widget.roomId).select((v) => v.loading.isLoading), + chatMessagesStateProvider(widget.roomId) + .select((v) => v.loading.isLoading), ); @override @@ -26,7 +27,7 @@ class _ChatMessagesConsumerState extends ConsumerState { super.initState(); // for first time messages load, should scroll at the latest (bottom) ref.listenManual( - chatStateProvider(widget.roomId) + chatMessagesStateProvider(widget.roomId) .select((value) => value.messageList.length), (_, __) { if (_scrollController.hasClients && _scrollController.position.pixels > @@ -52,7 +53,8 @@ class _ChatMessagesConsumerState extends ConsumerState { if (isLoading) return; // Get the notifier to load more messages - final notifier = ref.read(chatStateProvider(widget.roomId).notifier); + final notifier = + ref.read(chatMessagesStateProvider(widget.roomId).notifier); await notifier.loadMore(); } } @@ -70,7 +72,8 @@ class _ChatMessagesConsumerState extends ConsumerState { @override Widget build(BuildContext context) { final messages = ref.watch( - chatStateProvider(widget.roomId).select((value) => value.messageList), + chatMessagesStateProvider(widget.roomId) + .select((value) => value.messageList), ); return PageStorage( diff --git a/app/lib/features/chat_ng/widgets/message_actions_widget.dart b/app/lib/features/chat_ng/widgets/message_actions_widget.dart index 4ce30f4527ef..5d2f3ab90dc0 100644 --- a/app/lib/features/chat_ng/widgets/message_actions_widget.dart +++ b/app/lib/features/chat_ng/widgets/message_actions_widget.dart @@ -11,7 +11,6 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; class MessageActionsWidget extends ConsumerWidget { final bool isMe; final bool canRedact; - final Widget messageWidget; final RoomEventItem item; final String messageId; final String roomId; @@ -19,7 +18,6 @@ class MessageActionsWidget extends ConsumerWidget { super.key, required this.isMe, required this.canRedact, - required this.messageWidget, required this.item, required this.messageId, required this.roomId, @@ -63,9 +61,7 @@ class MessageActionsWidget extends ConsumerWidget { if (isMe) makeMenuItem( pressed: () { - ref - .read(chatEditorStateProvider.notifier) - .setEditMessage(messageWidget, item); + ref.read(chatEditorStateProvider.notifier).setEditMessage(item); Navigator.pop(context); }, text: Text(lang.edit), From b826309f52f192cad7c6c9b62727ff85810ca0e3 Mon Sep 17 00:00:00 2001 From: Talha Date: Tue, 4 Feb 2025 15:24:27 +0500 Subject: [PATCH 06/14] fix editor state on switching reply/edit view --- .../widgets/html_editor/html_editor.dart | 10 ++-- app/lib/features/chat_ng/utils.dart | 27 +++++++++++ .../widgets/chat_editor/chat_editor.dart | 47 +++++++++---------- .../chat_editor_actions_preview.dart | 2 +- 4 files changed, 56 insertions(+), 30 deletions(-) create mode 100644 app/lib/features/chat_ng/utils.dart diff --git a/app/lib/common/widgets/html_editor/html_editor.dart b/app/lib/common/widgets/html_editor/html_editor.dart index 1243870553b5..6b4c77b14580 100644 --- a/app/lib/common/widgets/html_editor/html_editor.dart +++ b/app/lib/common/widgets/html_editor/html_editor.dart @@ -52,12 +52,13 @@ extension ActerEditorStateHelpers on EditorState { /// clear the editor text with selection void clear() async { if (!document.isEmpty) { - final node = getNodeAtPath([0]); final transaction = this.transaction; final selection = this.selection; - if (node == null) return; - transaction.deleteText(node, 0, node.delta?.length ?? 0); - await updateSelectionWithReason( + final node = transaction.document.root.children.last; + transaction.deleteNode(node); + transaction.insertNode([0], paragraphNode(text: '')); + + updateSelectionWithReason( selection, reason: SelectionUpdateReason.transaction, ); @@ -222,6 +223,7 @@ class HtmlEditorState extends State { void _triggerExport(ExportCallback exportFn) { final plain = editorState.intoMarkdown(); final htmlBody = editorState.intoHtml(); + exportFn(plain, htmlBody != plain ? htmlBody : null); } diff --git a/app/lib/features/chat_ng/utils.dart b/app/lib/features/chat_ng/utils.dart new file mode 100644 index 000000000000..9fc528bf2e58 --- /dev/null +++ b/app/lib/features/chat_ng/utils.dart @@ -0,0 +1,27 @@ +import 'package:acter/common/providers/chat_providers.dart'; +import 'package:acter/features/chat_ng/providers/chat_room_messages_provider.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +Future saveMsgDraft( + String text, + String? htmlText, + String roomId, + WidgetRef ref, +) async { + // get the convo object to initiate draft + final chat = await ref.read(chatProvider(roomId).future); + final chatEditorState = ref.read(chatEditorStateProvider); + final messageId = chatEditorState.selectedMsgItem?.eventId(); + + if (chat != null) { + if (messageId != null) { + if (chatEditorState.isEditing) { + await chat.saveMsgDraft(text, htmlText, 'edit', messageId); + } else if (chatEditorState.isReplying) { + await chat.saveMsgDraft(text, htmlText, 'reply', messageId); + } + } else { + await chat.saveMsgDraft(text, htmlText, 'new', null); + } + } +} diff --git a/app/lib/features/chat_ng/widgets/chat_editor/chat_editor.dart b/app/lib/features/chat_ng/widgets/chat_editor/chat_editor.dart index 4c967e588001..729d22e1f6c3 100644 --- a/app/lib/features/chat_ng/widgets/chat_editor/chat_editor.dart +++ b/app/lib/features/chat_ng/widgets/chat_editor/chat_editor.dart @@ -5,10 +5,10 @@ import 'package:acter/common/themes/colors/color_scheme.dart'; import 'package:acter/common/widgets/html_editor/html_editor.dart'; import 'package:acter/features/attachments/actions/select_attachment.dart'; import 'package:acter/features/chat/providers/chat_providers.dart'; -import 'package:acter/features/chat/utils.dart'; import 'package:acter/features/chat_ng/actions/attachment_upload_action.dart'; import 'package:acter/features/chat_ng/actions/send_message_action.dart'; import 'package:acter/features/chat_ng/providers/chat_room_messages_provider.dart'; +import 'package:acter/features/chat_ng/utils.dart'; import 'package:acter/features/chat_ng/widgets/chat_editor/chat_editor_actions_preview.dart'; import 'package:acter/features/chat_ng/widgets/chat_editor/chat_emoji_picker.dart'; import 'package:acter_flutter_sdk/acter_flutter_sdk_ffi.dart' @@ -65,14 +65,6 @@ class _ChatEditorState extends ConsumerState { (next.actionType != prev?.actionType || next.selectedMsgItem != prev?.selectedMsgItem)) { _handleEditing(next.selectedMsgItem); - } else if (next.isReplying && - (next.actionType != prev?.actionType || - next.selectedMsgItem != prev?.selectedMsgItem)) { - final transaction = textEditorState.transaction; - transaction.afterSelection = Selection.collapsed( - Position(path: [0], offset: 0), - ); - await textEditorState.apply(transaction); } }); } @@ -95,20 +87,17 @@ class _ChatEditorState extends ConsumerState { void _handleEditing(RoomEventItem? item) { try { if (item == null) return; - // clear existing content - textEditorState.clear(); final msgContent = item.msgContent(); if (msgContent == null) return; final body = msgContent.body(); // insert editing text final transaction = textEditorState.transaction; - final docNode = transaction.document.root; + final docNode = textEditorState.getNodeAtPath([0]); + if (docNode == null) return; - transaction.insertText(docNode.children.last, 0, body); - textEditorState.updateSelectionWithReason( - textEditorState.selection, - reason: SelectionUpdateReason.transaction, - ); + transaction.replaceText(docNode, 0, docNode.delta?.length ?? 0, body); + final pos = Position(path: [0], offset: body.length); + transaction.afterSelection = Selection.collapsed(pos); textEditorState.apply(transaction); } catch (e) { _log.severe('Error handling edit state change: $e'); @@ -126,7 +115,8 @@ class _ChatEditorState extends ConsumerState { // save composing draft final text = textEditorState.intoMarkdown(); final htmlText = textEditorState.intoHtml(); - await saveDraft(text, htmlText, widget.roomId, ref); + + await saveMsgDraft(text, htmlText, widget.roomId, ref); _log.info('compose draft saved for room: ${widget.roomId}'); }); } @@ -147,10 +137,13 @@ class _ChatEditorState extends ConsumerState { Future _loadDraft() async { final draft = await ref.read(chatComposerDraftProvider(widget.roomId).future); - if (draft != null) { + final body = draft.plainText(); + if (body.trim().isEmpty) return; + final chatEditorState = ref.read(chatEditorStateProvider.notifier); chatEditorState.unsetActions(); + textEditorState.clear(); draft.eventId().map((eventId) { final draftType = draft.draftType(); final msgsList = @@ -171,14 +164,18 @@ class _ChatEditorState extends ConsumerState { return; } }); - final transaction = textEditorState.transaction; - final docNode = transaction.document.root; - transaction.insertText(docNode.children.last, 0, draft.plainText()); - textEditorState.updateSelectionWithReason( - textEditorState.selection, - reason: SelectionUpdateReason.transaction, + final transaction = textEditorState.transaction; + final docNode = textEditorState.getNodeAtPath([0]); + if (docNode == null) return; + transaction.replaceText( + docNode, + 0, + docNode.delta?.length ?? 0, + body, ); + final pos = Position(path: [0], offset: body.length); + transaction.afterSelection = Selection.collapsed(pos); textEditorState.apply(transaction); _log.info('compose draft loaded for room: ${widget.roomId}'); } diff --git a/app/lib/features/chat_ng/widgets/chat_editor/chat_editor_actions_preview.dart b/app/lib/features/chat_ng/widgets/chat_editor/chat_editor_actions_preview.dart index 5ae947669081..7ed57aa4b3a9 100644 --- a/app/lib/features/chat_ng/widgets/chat_editor/chat_editor_actions_preview.dart +++ b/app/lib/features/chat_ng/widgets/chat_editor/chat_editor_actions_preview.dart @@ -124,7 +124,7 @@ class ChatEditorActionsPreview extends ConsumerWidget { ), const Spacer(), GestureDetector( - onTap: () { + onTap: () async { ref.read(chatEditorStateProvider.notifier).unsetActions(); // closing editing action, also clear the editor textEditorState.clear(); From 131932ee27f9c03708ec279ec73b3a5fc97f627c Mon Sep 17 00:00:00 2001 From: Talha Date: Tue, 4 Feb 2025 16:17:35 +0500 Subject: [PATCH 07/14] implement copy message action --- .../chat_ng/actions/copy_message_action.dart | 18 +++++++++++ .../widgets/message_actions_widget.dart | 30 ++++++++++++------- 2 files changed, 38 insertions(+), 10 deletions(-) create mode 100644 app/lib/features/chat_ng/actions/copy_message_action.dart diff --git a/app/lib/features/chat_ng/actions/copy_message_action.dart b/app/lib/features/chat_ng/actions/copy_message_action.dart new file mode 100644 index 000000000000..0e643f01c283 --- /dev/null +++ b/app/lib/features/chat_ng/actions/copy_message_action.dart @@ -0,0 +1,18 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_easyloading/flutter_easyloading.dart'; +import 'package:flutter_gen/gen_l10n/l10n.dart'; + +Future copyMessageAction( + BuildContext context, + String body, +) async { + String msg = body.trim(); + await Clipboard.setData( + ClipboardData(text: msg), + ); + if (context.mounted) { + EasyLoading.showToast(L10n.of(context).messageCopiedToClipboard); + Navigator.pop(context); + } +} diff --git a/app/lib/features/chat_ng/widgets/message_actions_widget.dart b/app/lib/features/chat_ng/widgets/message_actions_widget.dart index 5d2f3ab90dc0..7b38e2fdcf63 100644 --- a/app/lib/features/chat_ng/widgets/message_actions_widget.dart +++ b/app/lib/features/chat_ng/widgets/message_actions_widget.dart @@ -1,3 +1,4 @@ +import 'package:acter/features/chat_ng/actions/copy_message_action.dart'; import 'package:acter/features/chat_ng/actions/redact_message_action.dart'; import 'package:acter/features/chat_ng/actions/report_message_action.dart'; import 'package:acter/features/chat_ng/providers/chat_room_messages_provider.dart'; @@ -40,7 +41,12 @@ class MessageActionsWidget extends ConsumerWidget { ); } - List menuItems(BuildContext context, WidgetRef ref, L10n lang) => [ + List menuItems( + BuildContext context, + WidgetRef ref, + L10n lang, + ) => + [ makeMenuItem( pressed: () { ref.read(chatEditorStateProvider.notifier).setReplyToMessage(item); @@ -49,15 +55,19 @@ class MessageActionsWidget extends ConsumerWidget { text: Text(lang.reply), icon: const Icon(Icons.reply_rounded, size: 18), ), - // if (isTextMessage) - // makeMenuItem( - // pressed: () => onCopyMessage(context, ref, message), - // text: Text(lang.copyMessage), - // icon: const Icon( - // Icons.copy_all_outlined, - // size: 14, - // ), - // ), + if (item.msgType() == 'm.text') + makeMenuItem( + pressed: () { + final messageBody = item.msgContent()?.body(); + if (messageBody == null) return; + copyMessageAction(context, messageBody); + }, + text: Text(lang.copyMessage), + icon: const Icon( + Icons.copy_all_outlined, + size: 14, + ), + ), if (isMe) makeMenuItem( pressed: () { From 34bf5894c8b459c5295187499f681c16b0fe0ff1 Mon Sep 17 00:00:00 2001 From: Talha Date: Tue, 4 Feb 2025 20:30:15 +0500 Subject: [PATCH 08/14] add widget tests --- app/lib/common/providers/chat_providers.dart | 9 + .../widgets/html_editor/html_editor.dart | 1 + .../chat/providers/chat_providers.dart | 9 - .../widgets/chat_editor/chat_editor.dart | 1 + .../chat_editor_actions_preview.dart | 76 +++-- .../features/chat_ng/chat_editor_test.dart | 313 ++++++++++++++++++ .../chat_ng/messages/chat_message_test.dart | 7 +- 7 files changed, 375 insertions(+), 41 deletions(-) create mode 100644 app/test/features/chat_ng/chat_editor_test.dart diff --git a/app/lib/common/providers/chat_providers.dart b/app/lib/common/providers/chat_providers.dart index 1a924572fd26..de3524bebe4c 100644 --- a/app/lib/common/providers/chat_providers.dart +++ b/app/lib/common/providers/chat_providers.dart @@ -34,3 +34,12 @@ final selectedChatIdProvider = NotifierProvider( () => SelectedChatIdNotifier(), ); + +final chatComposerDraftProvider = FutureProvider.autoDispose + .family((ref, roomId) async { + final chat = await ref.watch(chatProvider(roomId).future); + if (chat == null) { + return null; + } + return (await chat.msgDraft().then((val) => val.draft())); +}); diff --git a/app/lib/common/widgets/html_editor/html_editor.dart b/app/lib/common/widgets/html_editor/html_editor.dart index 6b4c77b14580..33fe84bb5064 100644 --- a/app/lib/common/widgets/html_editor/html_editor.dart +++ b/app/lib/common/widgets/html_editor/html_editor.dart @@ -396,6 +396,7 @@ class HtmlEditorState extends State { ...standardCharacterShortcutEvents, if (roomId != null) ...mentionShortcuts(context, roomId), ], + disableAutoScroll: true, ), ), ), diff --git a/app/lib/features/chat/providers/chat_providers.dart b/app/lib/features/chat/providers/chat_providers.dart index abb20f8f0ab0..3088eaf38ada 100644 --- a/app/lib/features/chat/providers/chat_providers.dart +++ b/app/lib/features/chat/providers/chat_providers.dart @@ -45,15 +45,6 @@ final chatStateProvider = (ref, roomId) => ChatRoomNotifier(ref: ref, roomId: roomId), ); -final chatComposerDraftProvider = FutureProvider.autoDispose - .family((ref, roomId) async { - final chat = await ref.watch(chatProvider(roomId).future); - if (chat == null) { - return null; - } - return (await chat.msgDraft().then((val) => val.draft())); -}); - final chatTopic = FutureProvider.autoDispose.family((ref, roomId) async { final c = await ref.watch(chatProvider(roomId).future); diff --git a/app/lib/features/chat_ng/widgets/chat_editor/chat_editor.dart b/app/lib/features/chat_ng/widgets/chat_editor/chat_editor.dart index 729d22e1f6c3..5f116cdb9210 100644 --- a/app/lib/features/chat_ng/widgets/chat_editor/chat_editor.dart +++ b/app/lib/features/chat_ng/widgets/chat_editor/chat_editor.dart @@ -1,5 +1,6 @@ import 'dart:async'; +import 'package:acter/common/providers/chat_providers.dart'; import 'package:acter/common/providers/keyboard_visbility_provider.dart'; import 'package:acter/common/themes/colors/color_scheme.dart'; import 'package:acter/common/widgets/html_editor/html_editor.dart'; diff --git a/app/lib/features/chat_ng/widgets/chat_editor/chat_editor_actions_preview.dart b/app/lib/features/chat_ng/widgets/chat_editor/chat_editor_actions_preview.dart index 7ed57aa4b3a9..3cbd744f8105 100644 --- a/app/lib/features/chat_ng/widgets/chat_editor/chat_editor_actions_preview.dart +++ b/app/lib/features/chat_ng/widgets/chat_editor/chat_editor_actions_preview.dart @@ -17,6 +17,8 @@ import 'package:flutter_gen/gen_l10n/l10n.dart'; import 'package:intl/intl.dart'; class ChatEditorActionsPreview extends ConsumerWidget { + static const closePreviewKey = Key('chat-editor-actions-close'); + final EditorState textEditorState; final RoomEventItem msgItem; final String roomId; @@ -31,12 +33,22 @@ class ChatEditorActionsPreview extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final chatEditorState = ref.watch(chatEditorStateProvider); + final memberAvatar = ref.watch( + memberAvatarInfoProvider((userId: msgItem.sender(), roomId: roomId)), + ); final children = []; if (chatEditorState.isReplying) { - children.add(_buildRepliedToMsgView(context, ref)); + children.add( + _buildPreviewView( + context, + ref, + replyPreviewItems(context, memberAvatar), + ), + ); children.add(_buildRepliedToItem(context, msgItem)); } else if (chatEditorState.isEditing) { - children.add(_buildEditView(context, ref)); + children + .add(_buildPreviewView(context, ref, editPreviewItems(context, ref))); // add a bit space for clean UI children.add(const SizedBox(height: 12)); } @@ -67,13 +79,36 @@ class ChatEditorActionsPreview extends ConsumerWidget { ); } - Widget _buildRepliedToMsgView(BuildContext context, WidgetRef ref) { - final authorId = msgItem.sender(); - final memberAvatar = ref.watch( - memberAvatarInfoProvider((userId: authorId, roomId: roomId)), - ); + Widget _buildPreviewView( + BuildContext context, + WidgetRef ref, + List previewItems, + ) { return Row( children: [ + const SizedBox(width: 1), + ...previewItems, + GestureDetector( + key: closePreviewKey, + onTap: () { + final isEdit = ref.read(chatEditorStateProvider).isEditing; + final notifier = ref.read(chatEditorStateProvider.notifier); + notifier.unsetActions(); + if (isEdit) textEditorState.clear(); + }, + child: const Icon( + Atlas.xmark_circle, + ), + ), + ], + ); + } + + List replyPreviewItems( + BuildContext context, + AvatarInfo memberAvatar, + ) => + [ const SizedBox(width: 1), const Icon( Icons.reply_rounded, @@ -89,25 +124,16 @@ class ChatEditorActionsPreview extends ConsumerWidget { ), const SizedBox(width: 5), Text( - L10n.of(context).replyTo(toBeginningOfSentenceCase(authorId)), + L10n.of(context).replyTo(toBeginningOfSentenceCase(msgItem.sender())), style: const TextStyle( color: Colors.grey, fontSize: 12, ), ), const Spacer(), - GestureDetector( - onTap: () => - ref.read(chatEditorStateProvider.notifier).unsetActions(), - child: const Icon(Atlas.xmark_circle), - ), - ], - ); - } + ]; - Widget _buildEditView(BuildContext context, WidgetRef ref) { - return Row( - children: [ + List editPreviewItems(BuildContext context, WidgetRef ref) => [ const SizedBox(width: 1), const Icon( Atlas.pencil_edit_thin, @@ -123,17 +149,7 @@ class ChatEditorActionsPreview extends ConsumerWidget { ), ), const Spacer(), - GestureDetector( - onTap: () async { - ref.read(chatEditorStateProvider.notifier).unsetActions(); - // closing editing action, also clear the editor - textEditorState.clear(); - }, - child: const Icon(Atlas.xmark_circle), - ), - ], - ); - } + ]; Widget _buildRepliedToItem(BuildContext context, RoomEventItem item) { final messageId = item.eventId(); diff --git a/app/test/features/chat_ng/chat_editor_test.dart b/app/test/features/chat_ng/chat_editor_test.dart new file mode 100644 index 000000000000..9f4883e8791a --- /dev/null +++ b/app/test/features/chat_ng/chat_editor_test.dart @@ -0,0 +1,313 @@ +import 'package:acter/common/providers/chat_providers.dart'; +import 'package:acter/common/providers/sdk_provider.dart'; +import 'package:acter/features/chat_ng/models/chat_editor_state.dart'; +import 'package:acter/features/chat_ng/providers/chat_room_messages_provider.dart'; +import 'package:acter/features/chat_ng/providers/notifiers/chat_editor_notifier.dart'; +import 'package:acter/features/chat_ng/widgets/chat_editor/chat_editor.dart'; +import 'package:acter/features/chat_ng/widgets/chat_editor/chat_editor_actions_preview.dart'; +import 'package:acter/features/chat_ng/widgets/message_actions_widget.dart'; +import 'package:acter/features/home/providers/client_providers.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; + +import '../../helpers/mock_a3sdk.dart'; +import '../../helpers/test_wrapper_widget.dart'; +import '../comments/mock_data/mock_message_content.dart'; +import 'diff_applier_test.dart'; +import 'messages/chat_message_test.dart'; + +class MockChatEditorNotifier extends AutoDisposeNotifier + with Mock + implements ChatEditorNotifier { + @override + ChatEditorState build() => ChatEditorState( + selectedMsgItem: null, + actionType: MessageAction.none, + ); +} + +void main() { + group('Chat editor reply/edit preview tests', () { + final mockMsgContent = MockMsgContent(bodyText: 'Test Content Message'); + final mockEventItem = + MockRoomEventItem(mockSender: 'user-1', mockMsgContent: mockMsgContent); + final roomMsg1 = + MockRoomMessage(id: 'test-messageId-1', mockEventItem: mockEventItem); + + final overrides = [ + sdkProvider.overrideWith((ref) => MockActerSdk()), + alwaysClientProvider.overrideWith((ref) => MockClient()), + chatProvider.overrideWith(() => MockAsyncConvoNotifier()), + chatComposerDraftProvider + .overrideWith((ref, roomId) => MockComposeDraft()), + renderableChatMessagesProvider + .overrideWith((ref, roomId) => ['test-messageId-1']), + chatRoomMessageProvider.overrideWith((ref, roomMsgId) { + final uniqueId = roomMsgId.uniqueId; + return switch (uniqueId) { + 'test-messageId-1' => roomMsg1, + _ => null, + }; + }), + ]; + testWidgets('verify chat editor correctly sets reply preview', + (tester) async { + await tester.pumpWidget( + ProviderScope( + overrides: overrides, + child: InActerContextTestWrapper( + child: Column( + children: [ + MessageActionsWidget( + isMe: false, + canRedact: false, + item: mockEventItem, + messageId: 'test-messageId-1', + roomId: 'test-roomId-1', + ), + ChatEditor(roomId: 'test-roomId-1'), + ], + ), + ), + ), + ); + + // initial state + final element = tester.element(find.byType(ChatEditor)); + final container = ProviderScope.containerOf(element); + final initialState = container.read(chatEditorStateProvider); + expect(initialState.actionType, equals(MessageAction.none)); + expect(initialState.selectedMsgItem, isNull); + expect(find.byType(ChatEditorActionsPreview), findsNothing); + + // Tap reply + await tester.tap(find.text('Reply')); + await tester.pump(); + + expect(find.text('Reply'), findsOneWidget); + + // Verify state after reply action + final updatedState = container.read(chatEditorStateProvider); + expect(updatedState.actionType, equals(MessageAction.reply)); + expect(updatedState.selectedMsgItem, equals(mockEventItem)); + + await tester.pump(); + + // Verify preview appears with correct item + expect(find.byType(ChatEditorActionsPreview), findsOneWidget); + final previewWidget = tester.widget( + find.byType(ChatEditorActionsPreview), + ); + expect(previewWidget.msgItem, equals(mockEventItem)); + }); + + testWidgets( + 'verify chat editor correctly sets edit preview', + (tester) async { + await tester.pumpWidget( + ProviderScope( + overrides: overrides, + child: InActerContextTestWrapper( + child: Column( + children: [ + MessageActionsWidget( + isMe: true, + canRedact: false, + item: mockEventItem, + messageId: 'test-messageId-1', + roomId: 'test-roomId-1', + ), + ChatEditor(roomId: 'test-roomId-1'), + ], + ), + ), + ), + ); + + // initial state + final element = tester.element(find.byType(ChatEditor)); + + final container = ProviderScope.containerOf(element); + + final initialState = container.read(chatEditorStateProvider); + expect(initialState.actionType, equals(MessageAction.none)); + expect(initialState.selectedMsgItem, isNull); + expect(find.byType(ChatEditorActionsPreview), findsNothing); + + // Tap edit + await tester.tap(find.text('Edit')); + await tester.pump(); + + expect(find.text('Edit'), findsOneWidget); + + // verify edit preview + final updatedState = container.read(chatEditorStateProvider); + expect(updatedState.actionType, equals(MessageAction.edit)); + expect(updatedState.selectedMsgItem, equals(mockEventItem)); + + expect(find.byType(ChatEditorActionsPreview), findsOneWidget); + + final previewWidget = tester.widget( + find.byType(ChatEditorActionsPreview), + ); + final textEditorState = previewWidget.textEditorState; + + final messageContent = + updatedState.selectedMsgItem?.msgContent()?.body(); + final editorText = + textEditorState.getNodeAtPath([0])?.delta?.toPlainText(); + + // This test is timing out due to a pending timer (compose draft). + // put 300ms delay as (debounceTimerDuration) + await tester.pumpAndSettle(Durations.medium2); + + expect(previewWidget.msgItem, equals(mockEventItem)); + expect(messageContent, editorText); + }, + ); + + // testWidgets('closing edit preview resets chat editor state', + // (tester) async { + // await tester.pumpWidget( + // ProviderScope( + // overrides: overrides, + // child: InActerContextTestWrapper( + // child: Column( + // children: [ + // MessageActionsWidget( + // isMe: true, + // canRedact: false, + // item: mockEventItem, + // messageId: 'test-messageId-1', + // roomId: 'test-roomId-1', + // ), + // ChatEditor(roomId: 'test-roomId-1'), + // ], + // ), + // ), + // ), + // ); + + // final element = tester.element(find.byType(ChatEditor)); + // final container = ProviderScope.containerOf(element); + + // // initial state + // final initialState = container.read(chatEditorStateProvider); + // expect(initialState.actionType, equals(MessageAction.none)); + // expect(initialState.selectedMsgItem, isNull); + // expect(find.byType(ChatEditorActionsPreview), findsNothing); + + // await tester.tap(find.text('Edit')); + // await tester.pump(); + + // // verify edit preview with updated state + // final updatedState = container.read(chatEditorStateProvider); + // expect(updatedState.actionType, equals(MessageAction.edit)); + // expect(updatedState.selectedMsgItem, equals(mockEventItem)); + + // await tester.pump(); + + // expect(find.byType(ChatEditorActionsPreview), findsOneWidget); + + // final finder = find.descendant( + // of: find.byKey(ChatEditorActionsPreview.closePreviewKey), + // matching: find.byIcon(Atlas.xmark_circle), + // ); + // final previewWidget = tester.widget( + // find.byType(ChatEditorActionsPreview), + // ); + + // final textEditorState = previewWidget.textEditorState; + // final messageContent = updatedState.selectedMsgItem?.msgContent()?.body(); + // final editorText = + // textEditorState.getNodeAtPath([0])?.delta?.toPlainText(); + + // // This test is timing out due to a pending timer (compose draft). + // // put 300ms delay as (debounceTimerDuration) + // await tester.pumpAndSettle(Durations.medium2); + // expect(previewWidget.msgItem, equals(mockEventItem)); + // // verify editor field has edit preview content + // expect(messageContent, editorText); + + // // now close edit preview + // // FIXME: apparently tester cannot find icon for some reason. + + // expect(finder, findsOneWidget); + // await tester.tap(finder); + // // This test is timing out due to a pending timer (compose draft). + // // put 300ms delay as (debounceTimerDuration) + // await tester.pumpAndSettle(Durations.medium2); + // // verify actions set to none + // final finalState = container.read(chatEditorStateProvider); + // expect(finalState.actionType, equals(MessageAction.none)); + // expect(finalState.selectedMsgItem, isNull); + // expect(find.byType(ChatEditorActionsPreview), findsNothing); + + // // editor state resets + // expect(editorText, isEmpty); + // }); + + testWidgets( + 'switching between reply and edit states correctly sets editor state', + (tester) async { + await tester.pumpWidget( + ProviderScope( + overrides: overrides, + child: InActerContextTestWrapper( + child: Column( + children: [ + MessageActionsWidget( + isMe: true, + canRedact: false, + item: mockEventItem, + messageId: 'test-messageId-1', + roomId: 'test-roomId-1', + ), + ChatEditor(roomId: 'test-roomId-1'), + ], + ), + ), + ), + ); + + final element = tester.element(find.byType(ChatEditor)); + final container = ProviderScope.containerOf(element); + final notifier = container.read(chatEditorStateProvider.notifier); + + // set reply preview + notifier.setReplyToMessage(mockEventItem); + await tester.pump(); + + // verify reply preview + var state = container.read(chatEditorStateProvider); + expect(state.actionType, equals(MessageAction.reply)); + expect(state.selectedMsgItem, equals(mockEventItem)); + expect(find.byType(ChatEditorActionsPreview), findsOneWidget); + + // set edit preview + notifier.setEditMessage(mockEventItem); + await tester.pump(); + + // verify edit preview + var updatedState = container.read(chatEditorStateProvider); + expect(updatedState.actionType, equals(MessageAction.edit)); + expect(updatedState.selectedMsgItem, equals(mockEventItem)); + expect(find.byType(ChatEditorActionsPreview), findsOneWidget); + + // verify editor field has edit preview content + final messageContent = updatedState.selectedMsgItem?.msgContent()?.body(); + final previewWidget = tester.widget( + find.byType(ChatEditorActionsPreview), + ); + final textEditorState = previewWidget.textEditorState; + final editorText = + textEditorState.getNodeAtPath([0])?.delta?.toPlainText(); + // This test is timing out due to a pending timer (compose draft). + // put 300ms delay as (debounceTimerDuration) + await tester.pumpAndSettle(Durations.medium2); + expect(messageContent, editorText); + }); + }); +} diff --git a/app/test/features/chat_ng/messages/chat_message_test.dart b/app/test/features/chat_ng/messages/chat_message_test.dart index 522b71abfc34..0ea05f77fef4 100644 --- a/app/test/features/chat_ng/messages/chat_message_test.dart +++ b/app/test/features/chat_ng/messages/chat_message_test.dart @@ -20,10 +20,13 @@ import '../diff_applier_test.dart'; class MockRoomEventItem extends Mock implements RoomEventItem { final String mockSender; - - MockRoomEventItem({required this.mockSender}); + final MockMsgContent? mockMsgContent; + MockRoomEventItem({required this.mockSender, this.mockMsgContent}); @override String sender() => mockSender; + + @override + MockMsgContent? msgContent() => mockMsgContent; } void main() { From 941e997f733f5f347a78bb0732186dbcb951e500 Mon Sep 17 00:00:00 2001 From: Talha Date: Tue, 4 Feb 2025 22:56:29 +0500 Subject: [PATCH 09/14] fix editor focus when switching message actions and composer draft state --- app/lib/features/chat_ng/utils.dart | 12 ++-- .../widgets/chat_editor/chat_editor.dart | 65 +++++++++++-------- .../chat_editor_actions_preview.dart | 18 ++++- .../widgets/message_actions_widget.dart | 4 +- 4 files changed, 61 insertions(+), 38 deletions(-) diff --git a/app/lib/features/chat_ng/utils.dart b/app/lib/features/chat_ng/utils.dart index 9fc528bf2e58..92d5ae8534d9 100644 --- a/app/lib/features/chat_ng/utils.dart +++ b/app/lib/features/chat_ng/utils.dart @@ -13,13 +13,11 @@ Future saveMsgDraft( final chatEditorState = ref.read(chatEditorStateProvider); final messageId = chatEditorState.selectedMsgItem?.eventId(); - if (chat != null) { - if (messageId != null) { - if (chatEditorState.isEditing) { - await chat.saveMsgDraft(text, htmlText, 'edit', messageId); - } else if (chatEditorState.isReplying) { - await chat.saveMsgDraft(text, htmlText, 'reply', messageId); - } + if (chat != null && messageId != null) { + if (chatEditorState.isEditing) { + await chat.saveMsgDraft(text, htmlText, 'edit', messageId); + } else if (chatEditorState.isReplying) { + await chat.saveMsgDraft(text, htmlText, 'reply', messageId); } else { await chat.saveMsgDraft(text, htmlText, 'new', null); } diff --git a/app/lib/features/chat_ng/widgets/chat_editor/chat_editor.dart b/app/lib/features/chat_ng/widgets/chat_editor/chat_editor.dart index 5f116cdb9210..49ebcb474278 100644 --- a/app/lib/features/chat_ng/widgets/chat_editor/chat_editor.dart +++ b/app/lib/features/chat_ng/widgets/chat_editor/chat_editor.dart @@ -39,7 +39,6 @@ class ChatEditor extends ConsumerStatefulWidget { class _ChatEditorState extends ConsumerState { EditorState textEditorState = EditorState.blank(); late EditorScrollController scrollController; - FocusNode chatFocus = FocusNode(); StreamSubscription<(TransactionTime, Transaction)>? _updateListener; final ValueNotifier _isInputEmptyNotifier = ValueNotifier(true); double _cHeight = 0.10; @@ -62,11 +61,25 @@ class _ChatEditorState extends ConsumerState { WidgetsBinding.instance.addPostFrameCallback((_) => _loadDraft()); ref.listenManual(chatEditorStateProvider, (prev, next) async { + final body = textEditorState.intoMarkdown(); + final bodyHtml = textEditorState.intoHtml(); if (next.isEditing && (next.actionType != prev?.actionType || next.selectedMsgItem != prev?.selectedMsgItem)) { _handleEditing(next.selectedMsgItem); } + if (next.isReplying && + (next.actionType != prev?.actionType || + next.selectedMsgItem != prev?.selectedMsgItem)) { + textEditorState.updateSelectionWithReason( + Selection.single( + path: [0], + startOffset: textEditorState.intoMarkdown().length - 1, + ), + reason: SelectionUpdateReason.uiEvent, + ); + saveMsgDraft(body, bodyHtml, widget.roomId, ref); + } }); } @@ -116,7 +129,6 @@ class _ChatEditorState extends ConsumerState { // save composing draft final text = textEditorState.intoMarkdown(); final htmlText = textEditorState.intoHtml(); - await saveMsgDraft(text, htmlText, widget.roomId, ref); _log.info('compose draft saved for room: ${widget.roomId}'); }); @@ -138,13 +150,13 @@ class _ChatEditorState extends ConsumerState { Future _loadDraft() async { final draft = await ref.read(chatComposerDraftProvider(widget.roomId).future); - if (draft != null) { - final body = draft.plainText(); - if (body.trim().isEmpty) return; + if (draft != null) { final chatEditorState = ref.read(chatEditorStateProvider.notifier); chatEditorState.unsetActions(); textEditorState.clear(); + + final body = draft.plainText(); draft.eventId().map((eventId) { final draftType = draft.draftType(); final msgsList = @@ -152,9 +164,7 @@ class _ChatEditorState extends ConsumerState { try { final roomMsg = msgsList[eventId]; final item = roomMsg?.eventItem(); - if (item == null) return; - if (draftType == 'edit') { chatEditorState.setEditMessage(item); } else if (draftType == 'reply') { @@ -166,6 +176,8 @@ class _ChatEditorState extends ConsumerState { } }); + if (body.trim().isEmpty) return; + final transaction = textEditorState.transaction; final docNode = textEditorState.getNodeAtPath([0]); if (docNode == null) return; @@ -301,27 +313,24 @@ class _ChatEditorState extends ConsumerState { ); } - Widget _renderEditor(String? hintText) => Focus( - focusNode: chatFocus, - child: HtmlEditor( - footer: null, - // if provided, will activate mentions - roomId: widget.roomId, - hintText: hintText, - autoFocus: false, - editable: true, - shrinkWrap: true, - editorState: textEditorState, - scrollController: scrollController, - editorPadding: const EdgeInsets.symmetric(horizontal: 10), - onChanged: (body, html) { - if (html != null) { - widget.onTyping?.map((cb) => cb(html.isNotEmpty)); - } else { - widget.onTyping?.map((cb) => cb(body.isNotEmpty)); - } - }, - ), + Widget _renderEditor(String? hintText) => HtmlEditor( + footer: null, + // if provided, will activate mentions + roomId: widget.roomId, + hintText: hintText, + autoFocus: false, + editable: true, + shrinkWrap: true, + editorState: textEditorState, + scrollController: scrollController, + editorPadding: const EdgeInsets.symmetric(horizontal: 10), + onChanged: (body, html) { + if (html != null) { + widget.onTyping?.map((cb) => cb(html.isNotEmpty)); + } else { + widget.onTyping?.map((cb) => cb(body.isNotEmpty)); + } + }, ); // attachment/send button diff --git a/app/lib/features/chat_ng/widgets/chat_editor/chat_editor_actions_preview.dart b/app/lib/features/chat_ng/widgets/chat_editor/chat_editor_actions_preview.dart index 3cbd744f8105..a19f1d075f7f 100644 --- a/app/lib/features/chat_ng/widgets/chat_editor/chat_editor_actions_preview.dart +++ b/app/lib/features/chat_ng/widgets/chat_editor/chat_editor_actions_preview.dart @@ -1,6 +1,7 @@ import 'package:acter/common/providers/room_providers.dart'; import 'package:acter/common/widgets/html_editor/html_editor.dart'; import 'package:acter/features/chat_ng/providers/chat_room_messages_provider.dart'; +import 'package:acter/features/chat_ng/utils.dart'; import 'package:acter/features/chat_ng/widgets/events/file_message_event.dart'; import 'package:acter/features/chat_ng/widgets/events/image_message_event.dart'; import 'package:acter/features/chat_ng/widgets/events/text_message_event.dart'; @@ -90,10 +91,25 @@ class ChatEditorActionsPreview extends ConsumerWidget { ...previewItems, GestureDetector( key: closePreviewKey, - onTap: () { + onTap: () async { final isEdit = ref.read(chatEditorStateProvider).isEditing; final notifier = ref.read(chatEditorStateProvider.notifier); + notifier.unsetActions(); + Future.delayed((Duration.zero), () => {}); + if (!isEdit) { + final body = textEditorState.intoMarkdown(); + final bodyHtml = textEditorState.intoHtml(); + + await saveMsgDraft(body, bodyHtml, roomId, ref); + textEditorState.updateSelectionWithReason( + Selection.single( + path: [0], + startOffset: body.length - 1, + ), + reason: SelectionUpdateReason.uiEvent, + ); + } if (isEdit) textEditorState.clear(); }, child: const Icon( diff --git a/app/lib/features/chat_ng/widgets/message_actions_widget.dart b/app/lib/features/chat_ng/widgets/message_actions_widget.dart index 7b38e2fdcf63..8ea2a9a53386 100644 --- a/app/lib/features/chat_ng/widgets/message_actions_widget.dart +++ b/app/lib/features/chat_ng/widgets/message_actions_widget.dart @@ -48,7 +48,7 @@ class MessageActionsWidget extends ConsumerWidget { ) => [ makeMenuItem( - pressed: () { + pressed: () async { ref.read(chatEditorStateProvider.notifier).setReplyToMessage(item); Navigator.pop(context); }, @@ -68,7 +68,7 @@ class MessageActionsWidget extends ConsumerWidget { size: 14, ), ), - if (isMe) + if (isMe && item.msgType() == 'm.text') makeMenuItem( pressed: () { ref.read(chatEditorStateProvider.notifier).setEditMessage(item); From 4ef159f8845ed8901eb64d69cb47244c052452d8 Mon Sep 17 00:00:00 2001 From: Talha Date: Tue, 4 Feb 2025 23:18:08 +0500 Subject: [PATCH 10/14] lint and minor code clean up --- .../chat_ng/actions/redact_message_action.dart | 7 ++----- .../chat_ng/actions/report_message_action.dart | 1 + .../chat_ng/actions/send_message_action.dart | 13 +++---------- .../providers/chat_room_messages_provider.dart | 3 ++- .../chat_ng/widgets/chat_editor/chat_editor.dart | 1 - 5 files changed, 8 insertions(+), 17 deletions(-) diff --git a/app/lib/features/chat_ng/actions/redact_message_action.dart b/app/lib/features/chat_ng/actions/redact_message_action.dart index 2ee1731d14b9..3f2562becde6 100644 --- a/app/lib/features/chat_ng/actions/redact_message_action.dart +++ b/app/lib/features/chat_ng/actions/redact_message_action.dart @@ -23,7 +23,8 @@ Future redactMessageAction( final chatEditorNotifier = ref.watch(chatEditorStateProvider.notifier); final lang = L10n.of(context); final senderId = item.sender(); - + // pop message action options + Navigator.pop(context); await showAdaptiveDialog( context: context, builder: (context) => DefaultDialog( @@ -47,8 +48,6 @@ Future redactMessageAction( chatEditorNotifier.unsetActions(); if (context.mounted) { Navigator.pop(context); - // dismiss actions overlay also - Navigator.pop(context); } } catch (e, s) { _log.severe('Redacting message failed', e, s); @@ -58,8 +57,6 @@ Future redactMessageAction( duration: const Duration(seconds: 3), ); Navigator.pop(context); - // dismiss actions overlay also - Navigator.pop(context); } }, child: Text(lang.yes), diff --git a/app/lib/features/chat_ng/actions/report_message_action.dart b/app/lib/features/chat_ng/actions/report_message_action.dart index bdab5a7bf46a..e88cbc65177f 100644 --- a/app/lib/features/chat_ng/actions/report_message_action.dart +++ b/app/lib/features/chat_ng/actions/report_message_action.dart @@ -12,6 +12,7 @@ Future reportMessageAction( ) async { final lang = L10n.of(context); final senderId = item.sender(); + Navigator.pop(context); await openReportContentDialog( context, title: lang.reportThisMessage, diff --git a/app/lib/features/chat_ng/actions/send_message_action.dart b/app/lib/features/chat_ng/actions/send_message_action.dart index 3db237c35a55..a8678eb01231 100644 --- a/app/lib/features/chat_ng/actions/send_message_action.dart +++ b/app/lib/features/chat_ng/actions/send_message_action.dart @@ -56,19 +56,12 @@ Future sendMessageAction({ } ref.read(chatInputProvider.notifier).messageSent(); - final transaction = textEditorState.transaction; - final nodes = transaction.document.root.children; - // delete all nodes of document (reset) - transaction.document.delete([0], nodes.length); - final delta = Delta()..insert(''); - // insert empty text node - transaction.document.insert([0], [paragraphNode(delta: delta)]); - await textEditorState.apply(transaction, withUpdateSelection: false); - // FIXME: works for single line text, but doesn't get focus on multi-line (iOS) - textEditorState.moveCursorForward(SelectionMoveRange.line); + textEditorState.clear(); // also clear composed state final convo = await ref.read(chatProvider(roomId).future); + final notifier = ref.read(chatEditorStateProvider.notifier); + notifier.unsetActions(); if (convo != null) { await convo.saveMsgDraft( textEditorState.intoMarkdown(), diff --git a/app/lib/features/chat_ng/providers/chat_room_messages_provider.dart b/app/lib/features/chat_ng/providers/chat_room_messages_provider.dart index c93a4bb740c3..b7f0503a20cc 100644 --- a/app/lib/features/chat_ng/providers/chat_room_messages_provider.dart +++ b/app/lib/features/chat_ng/providers/chat_room_messages_provider.dart @@ -48,7 +48,8 @@ final animatedListChatMessagesProvider = final renderableChatMessagesProvider = StateProvider.autoDispose.family, String>((ref, roomId) { final msgList = ref.watch( - chatMessagesStateProvider(roomId).select((value) => value.messageList)); + chatMessagesStateProvider(roomId).select((value) => value.messageList), + ); if (ref.watch(showHiddenMessages)) { // do not apply filters return msgList; diff --git a/app/lib/features/chat_ng/widgets/chat_editor/chat_editor.dart b/app/lib/features/chat_ng/widgets/chat_editor/chat_editor.dart index 49ebcb474278..065d45704b2b 100644 --- a/app/lib/features/chat_ng/widgets/chat_editor/chat_editor.dart +++ b/app/lib/features/chat_ng/widgets/chat_editor/chat_editor.dart @@ -155,7 +155,6 @@ class _ChatEditorState extends ConsumerState { final chatEditorState = ref.read(chatEditorStateProvider.notifier); chatEditorState.unsetActions(); textEditorState.clear(); - final body = draft.plainText(); draft.eventId().map((eventId) { final draftType = draft.draftType(); From a6a55c75d5ad0c6c8e50b6a6d78d62344920a99c Mon Sep 17 00:00:00 2001 From: Talha Date: Tue, 4 Feb 2025 23:27:31 +0500 Subject: [PATCH 11/14] fix tests --- .../chat_ng/actions/report_message_action.dart | 1 + app/test/features/chat_ng/chat_editor_test.dart | 9 +++++---- .../features/chat_ng/messages/chat_message_test.dart | 10 +++++++++- 3 files changed, 15 insertions(+), 5 deletions(-) diff --git a/app/lib/features/chat_ng/actions/report_message_action.dart b/app/lib/features/chat_ng/actions/report_message_action.dart index e88cbc65177f..d0ce3be7368e 100644 --- a/app/lib/features/chat_ng/actions/report_message_action.dart +++ b/app/lib/features/chat_ng/actions/report_message_action.dart @@ -12,6 +12,7 @@ Future reportMessageAction( ) async { final lang = L10n.of(context); final senderId = item.sender(); + // pop message action options Navigator.pop(context); await openReportContentDialog( context, diff --git a/app/test/features/chat_ng/chat_editor_test.dart b/app/test/features/chat_ng/chat_editor_test.dart index 9f4883e8791a..221872d6d11c 100644 --- a/app/test/features/chat_ng/chat_editor_test.dart +++ b/app/test/features/chat_ng/chat_editor_test.dart @@ -31,8 +31,11 @@ class MockChatEditorNotifier extends AutoDisposeNotifier void main() { group('Chat editor reply/edit preview tests', () { final mockMsgContent = MockMsgContent(bodyText: 'Test Content Message'); - final mockEventItem = - MockRoomEventItem(mockSender: 'user-1', mockMsgContent: mockMsgContent); + final mockEventItem = MockRoomEventItem( + mockSender: 'user-1', + mockMsgContent: mockMsgContent, + mockMsgType: 'm.text', + ); final roomMsg1 = MockRoomMessage(id: 'test-messageId-1', mockEventItem: mockEventItem); @@ -128,9 +131,7 @@ void main() { // initial state final element = tester.element(find.byType(ChatEditor)); - final container = ProviderScope.containerOf(element); - final initialState = container.read(chatEditorStateProvider); expect(initialState.actionType, equals(MessageAction.none)); expect(initialState.selectedMsgItem, isNull); diff --git a/app/test/features/chat_ng/messages/chat_message_test.dart b/app/test/features/chat_ng/messages/chat_message_test.dart index 0ea05f77fef4..0edd87e76fc5 100644 --- a/app/test/features/chat_ng/messages/chat_message_test.dart +++ b/app/test/features/chat_ng/messages/chat_message_test.dart @@ -21,12 +21,20 @@ import '../diff_applier_test.dart'; class MockRoomEventItem extends Mock implements RoomEventItem { final String mockSender; final MockMsgContent? mockMsgContent; - MockRoomEventItem({required this.mockSender, this.mockMsgContent}); + final String? mockMsgType; + MockRoomEventItem({ + required this.mockSender, + this.mockMsgContent, + this.mockMsgType, + }); @override String sender() => mockSender; @override MockMsgContent? msgContent() => mockMsgContent; + + @override + String? msgType() => mockMsgType; } void main() { From 0295470bc4e9763ba77a99969268aca1a61bb851 Mon Sep 17 00:00:00 2001 From: Talha Date: Tue, 4 Feb 2025 23:31:20 +0500 Subject: [PATCH 12/14] add changelogs --- .changes/2529-chat_ng-message-actions.md | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 .changes/2529-chat_ng-message-actions.md diff --git a/.changes/2529-chat_ng-message-actions.md b/.changes/2529-chat_ng-message-actions.md new file mode 100644 index 000000000000..13c88148ff7f --- /dev/null +++ b/.changes/2529-chat_ng-message-actions.md @@ -0,0 +1,3 @@ +[Labs] Chat-NG: + +- [Feature] Now you can do message actions on message .i.e. edit/reply/copy/delete/report. From a6bd120626f213271053560cef286305277e21f9 Mon Sep 17 00:00:00 2001 From: Talha Date: Wed, 5 Feb 2025 18:18:22 +0500 Subject: [PATCH 13/14] Feedback Review (Kumar): fix message actions alignment and remove throw error line --- .../actions/redact_message_action.dart | 4 +- .../chat_ng/dialogs/message_actions.dart | 41 +++++++++++-------- .../widgets/message_actions_widget.dart | 6 ++- 3 files changed, 29 insertions(+), 22 deletions(-) diff --git a/app/lib/features/chat_ng/actions/redact_message_action.dart b/app/lib/features/chat_ng/actions/redact_message_action.dart index 3f2562becde6..68ad2e070587 100644 --- a/app/lib/features/chat_ng/actions/redact_message_action.dart +++ b/app/lib/features/chat_ng/actions/redact_message_action.dart @@ -1,5 +1,4 @@ import 'package:acter/common/providers/chat_providers.dart'; -import 'package:acter/common/providers/room_providers.dart'; import 'package:acter/common/toolkit/buttons/primary_action_button.dart'; import 'package:acter/common/widgets/default_dialog.dart'; import 'package:acter/features/chat_ng/providers/chat_room_messages_provider.dart'; @@ -38,8 +37,7 @@ Future redactMessageAction( onPressed: () async { try { final convo = await ref.read(chatProvider(roomId).future); - if (convo == null) throw RoomNotFound(); - await convo.redactMessage( + await convo?.redactMessage( messageId, senderId, null, diff --git a/app/lib/features/chat_ng/dialogs/message_actions.dart b/app/lib/features/chat_ng/dialogs/message_actions.dart index b0d114c63495..4e540ba5dc04 100644 --- a/app/lib/features/chat_ng/dialogs/message_actions.dart +++ b/app/lib/features/chat_ng/dialogs/message_actions.dart @@ -30,34 +30,39 @@ void messageActions({ pageBuilder: (context, animation, secondaryAnimation) { return Column( mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.center, children: [ _BlurOverlay( animation: animation, child: const SizedBox.shrink(), ), // Reaction Row - _AnimatedActionsContainer( - animation: animation, - tagId: messageId, - child: ReactionSelector( - isMe: isMe, - messageId: '$messageId-reactions', - roomId: roomId, + Align( + alignment: isMe ? Alignment.centerRight : Alignment.centerLeft, + child: _AnimatedActionsContainer( + animation: animation, + tagId: messageId, + child: ReactionSelector( + isMe: isMe, + messageId: '$messageId-reactions', + roomId: roomId, + ), ), ), // Message - messageWidget, + Center(child: messageWidget), // Message actions - _AnimatedActionsContainer( - animation: animation, - tagId: '$messageId-actions', - child: MessageActionsWidget( - isMe: isMe, - canRedact: canRedact, - item: item, - messageId: messageId, - roomId: roomId, + Align( + alignment: isMe ? Alignment.centerRight : Alignment.centerLeft, + child: _AnimatedActionsContainer( + animation: animation, + tagId: '$messageId-actions', + child: MessageActionsWidget( + isMe: isMe, + canRedact: canRedact, + item: item, + messageId: messageId, + roomId: roomId, + ), ), ), ], diff --git a/app/lib/features/chat_ng/widgets/message_actions_widget.dart b/app/lib/features/chat_ng/widgets/message_actions_widget.dart index 8ea2a9a53386..7e1d7e059110 100644 --- a/app/lib/features/chat_ng/widgets/message_actions_widget.dart +++ b/app/lib/features/chat_ng/widgets/message_actions_widget.dart @@ -30,7 +30,11 @@ class MessageActionsWidget extends ConsumerWidget { return Container( constraints: const BoxConstraints(maxWidth: 200), padding: const EdgeInsets.all(8.0), - margin: const EdgeInsets.only(top: 4), + margin: EdgeInsets.only( + top: 4, + left: isMe ? 0 : 12, + right: isMe ? 12 : 0, + ), decoration: BoxDecoration( borderRadius: const BorderRadius.all(Radius.circular(5)), color: Theme.of(context).colorScheme.surface.withOpacity(0.8), From 804afd5d1046ef970e9d71441bd0af5314bd3dff Mon Sep 17 00:00:00 2001 From: Talha Date: Wed, 5 Feb 2025 18:35:32 +0500 Subject: [PATCH 14/14] fix tests --- app/pubspec.lock | 8 ++++++++ app/test/features/chat_ng/chat_editor_test.dart | 4 +++- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/app/pubspec.lock b/app/pubspec.lock index 88e3a343d6fd..8f6a12a45075 100644 --- a/app/pubspec.lock +++ b/app/pubspec.lock @@ -1970,6 +1970,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.5.1" + preload_page_view: + dependency: "direct main" + description: + name: preload_page_view + sha256: "488a10c158c5c2e9ba9d77e5dbc09b1e49e37a20df2301e5ba02992eac802b7a" + url: "https://pub.dev" + source: hosted + version: "0.2.0" pretty_qr_code: dependency: "direct main" description: diff --git a/app/test/features/chat_ng/chat_editor_test.dart b/app/test/features/chat_ng/chat_editor_test.dart index 221872d6d11c..251a5b9aad59 100644 --- a/app/test/features/chat_ng/chat_editor_test.dart +++ b/app/test/features/chat_ng/chat_editor_test.dart @@ -13,6 +13,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:mocktail/mocktail.dart'; import '../../helpers/mock_a3sdk.dart'; +import '../../helpers/mock_client_provider.dart'; import '../../helpers/test_wrapper_widget.dart'; import '../comments/mock_data/mock_message_content.dart'; import 'diff_applier_test.dart'; @@ -41,7 +42,8 @@ void main() { final overrides = [ sdkProvider.overrideWith((ref) => MockActerSdk()), - alwaysClientProvider.overrideWith((ref) => MockClient()), + clientProvider + .overrideWith(() => MockClientNotifier(client: MockClient())), chatProvider.overrideWith(() => MockAsyncConvoNotifier()), chatComposerDraftProvider .overrideWith((ref, roomId) => MockComposeDraft()),