From 46d599acf0aceae9838ad85d480c2d23c9086b4b Mon Sep 17 00:00:00 2001 From: henri2h Date: Sun, 9 Feb 2025 13:58:47 +0100 Subject: [PATCH] feat: add edit possibility and fix timer draft discardal --- .../advanced_message_composer.dart | 49 +++++++++-- .../message_composer/message_composer.dart | 24 +++-- .../chat/widgets/room/room_event_item.dart | 87 +++++++++++-------- .../chat/widgets/room/room_timeline.dart | 26 +++++- lib/partials/emoji/emoji_picker.dart | 54 ++++++++---- 5 files changed, 169 insertions(+), 71 deletions(-) diff --git a/lib/features/chat/widgets/message_composer/advanced_message_composer.dart b/lib/features/chat/widgets/message_composer/advanced_message_composer.dart index 932a5404..dc293664 100644 --- a/lib/features/chat/widgets/message_composer/advanced_message_composer.dart +++ b/lib/features/chat/widgets/message_composer/advanced_message_composer.dart @@ -18,16 +18,23 @@ import '../../../../utils/matrix_widget.dart'; class AdvancedMessageComposer extends StatefulWidget { final Room? room; final String? userId; - final Event? reply; - final VoidCallback removeReply; + final Event? replyEvent; + // The original event that we want to edit + final Event? editEvent; + // The last modification of the event which we want to edit. This is used to get the latest edit text + final Event? editDisplayEvent; + + final VoidCallback cancelEditAndReply; final void Function(Room)? onRoomCreated; final bool isMobile; const AdvancedMessageComposer({ super.key, required this.room, - required this.reply, - required this.removeReply, + required this.replyEvent, + required this.editEvent, + required this.editDisplayEvent, + required this.cancelEditAndReply, required this.isMobile, this.userId, required this.onRoomCreated, @@ -123,10 +130,20 @@ class AdvancedMessageComposerState extends State { bool get writingUsername => controller.text.contains("@"); + Event? _cachedEditEvent; @override Widget build(BuildContext context) { + // See if a new edit event has been set. If yes, then use it to populate the messeage composer field + if (_cachedEditEvent != widget.editEvent) { + _cachedEditEvent = widget.editEvent; + if (widget.editEvent != null) { + controller.text = (widget.editDisplayEvent ?? widget.editEvent)! + .calcUnlocalizedBody(hideEdit: true, hideReply: true); + } + } + final room = widget.room; - Event? reply = widget.reply; + Event? reply = widget.replyEvent; final client = Matrix.of(context).client; final matrixComposerWidget = MessageComposer( @@ -134,10 +151,11 @@ class AdvancedMessageComposerState extends State { userId: widget.userId, onRoomCreated: widget.onRoomCreated, inReplyTo: reply, + editEvent: widget.editEvent, inputStream: inputStream.stream, controller: controller, onSend: () { - widget.removeReply(); + widget.cancelEditAndReply(); setState(() {}); }); @@ -204,6 +222,23 @@ class AdvancedMessageComposerState extends State { }); } }), + if (_cachedEditEvent != null) + Card( + child: Row( + children: [ + Expanded( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 4.0), + child: Text("Edit message"), + )), + IconButton( + onPressed: () { + widget.cancelEditAndReply(); + }, + icon: Icon(Icons.close)) + ], + ), + ), if (reply != null) Padding( padding: const EdgeInsets.all(4.0), @@ -246,7 +281,7 @@ class AdvancedMessageComposerState extends State { IconButton( icon: const Icon(Icons.cancel), onPressed: () { - widget.removeReply(); + widget.cancelEditAndReply(); setState(() {}); }) ], diff --git a/lib/features/chat/widgets/message_composer/message_composer.dart b/lib/features/chat/widgets/message_composer/message_composer.dart index bf91c67d..0522bef5 100644 --- a/lib/features/chat/widgets/message_composer/message_composer.dart +++ b/lib/features/chat/widgets/message_composer/message_composer.dart @@ -21,6 +21,7 @@ class MessageComposer extends StatefulWidget { final Room? room; final String? userId; final Event? inReplyTo; + final Event? editEvent; final VoidCallback? onSend; final String hintText; // Allows showing a progress indicator when sending a message. This will prevent sending other messages when the previous message hasn't been sent. @@ -52,6 +53,7 @@ class MessageComposer extends StatefulWidget { this.overrideSending, this.onRoomCreated, this.onEdit, + this.editEvent, this.loadSavedText = true, this.inputStream, this.controller, @@ -219,13 +221,15 @@ class MessageComposerState extends State { _isTyping = false; }); + await setMessageDraftImmediate(""); // Test if the input is supposed to be a command if (!text.replaceAll(" ", "").startsWith("/")) { // Don't send if it's a command if (text != "") { widget.onSend?.call(); if (widget.overrideSending == null) { - await room?.sendTextEvent(text, inReplyTo: onReplyTo); + await room?.sendTextEvent(text, + inReplyTo: onReplyTo, editEventId: widget.editEvent?.eventId); } else { await widget.overrideSending!(text); } @@ -244,8 +248,6 @@ class MessageComposerState extends State { setState(() { _isSending = false; }); - - setMessageDraft(""); } Future _sendMessageOrCreate() async { @@ -274,18 +276,26 @@ class MessageComposerState extends State { Timer? editTimer; - void setMessageDraft(String text) { + Future _setMessageDraft(String text) async { final client = Matrix.of(context).client; + await client.setDraft( + message: text, roomId: room?.id ?? widget.userId ?? ''); + } + + Future setMessageDraftImmediate(String text) async { + editTimer?.cancel(); + await _setMessageDraft(text); + } + void setMessageDraftTimer(String text) { editTimer?.cancel(); editTimer = Timer(const Duration(milliseconds: 600), () async { - await client.setDraft( - message: text, roomId: room?.id ?? widget.userId ?? ''); + await _setMessageDraft(text); }); } void onEdit(String text) { - setMessageDraft(text); + setMessageDraftTimer(text); widget.onEdit?.call(text); } diff --git a/lib/features/chat/widgets/room/room_event_item.dart b/lib/features/chat/widgets/room/room_event_item.dart index a7bbe106..b37feafe 100644 --- a/lib/features/chat/widgets/room/room_event_item.dart +++ b/lib/features/chat/widgets/room/room_event_item.dart @@ -4,6 +4,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; import 'package:flutter/services.dart'; import 'package:matrix/matrix.dart'; +import 'package:pasteboard/pasteboard.dart'; import 'package:piaf/utils/date_time_extension.dart'; import '../../../../partials/popup_route_wrapper.dart'; @@ -41,8 +42,9 @@ class RoomEventItem extends StatelessWidget { required this.t, required this.filteredEvents, required this.i, - required this.onReplyEventPressed, + required this.onJumpToReplyCallback, required this.onReply, + this.onEdit, this.eventsToAnimateStream, this.fullyReadEventId, required this.isDirectChat}); @@ -55,8 +57,10 @@ class RoomEventItem extends StatelessWidget { final bool displayTime; final bool displayPadding; final int i; - final void Function(Event) onReplyEventPressed; + final void Function(Event) onJumpToReplyCallback; final void Function(Event) onReply; + final void Function(Event)? onEdit; + // To display an annimation when this event is selected final Stream? eventsToAnimateStream; final String? fullyReadEventId; @@ -168,12 +172,17 @@ class RoomEventItem extends StatelessWidget { anchorKeyContext: context, useAnimation: false, offset: offset, - maxHeight: 150, + maxHeight: 350, builder: (rect) { - return ReactionBox(rect, event: event); + return ReactionBox( + rect, + event: event, + onEdit: onEdit != null ? () => onEdit!(event) : null, + onReply: () => onReply(oldEvent), + ); })); }, - onReplyEventPressed: onReplyEventPressed, + onReplyEventPressed: onJumpToReplyCallback, onReply: (_) => onReply(oldEvent)), // Disable read receipts in large group as it's quit costly @@ -219,9 +228,12 @@ class RoomEventItem extends StatelessWidget { } class ReactionBox extends StatefulWidget { - const ReactionBox(this.rect, {super.key, required this.event}); + const ReactionBox(this.rect, + {super.key, required this.event, this.onEdit, this.onReply}); final Rect rect; final Event event; + final void Function()? onEdit; + final void Function()? onReply; @override State createState() => _ReactionBoxState(); @@ -252,8 +264,37 @@ class _ReactionBoxState extends State { height: 100, selectedEmoji: _selectedEmoji, selectedEdge: _emojiPickerEdge, - enableDelete: - widget.event.canRedact && !widget.event.redacted, + onReply: widget.onReply, + onCopy: () { + Pasteboard.writeText(widget.event.body); + }, + onEdit: widget.event.senderId == + widget.event.room.client.userID + ? widget.onEdit + : null, + onDelete: widget.event.canRedact && !widget.event.redacted + ? () async { + if (mounted) { + final result = await showTextInputDialog( + useRootNavigator: false, + context: context, + title: "Confirm removal", + message: + "Are you sure you wish to remove this event? This cannot be undone.", + okLabel: "Remove", + textFields: [ + const DialogTextField( + hintText: "Reason (optional)", + initialText: "") + ], + ); + if (result?.isNotEmpty ?? false) { + await widget.event + .redactEvent(reason: result?.first); + } + } + } + : null, //enableEdit: _selectedEvent?.canRedact ?? false, //enableReply: true, ))))); @@ -335,34 +376,8 @@ class _ReactionBoxState extends State { } if (_selectedEmoji != null) { - switch (_selectedEmoji) { - case "reply": - case "edit": - break; - case "delete": - if (mounted) { - final result = await showTextInputDialog( - useRootNavigator: false, - context: context, - title: "Confirm removal", - message: - "Are you sure you wish to remove this event? This cannot be undone.", - okLabel: "Remove", - textFields: [ - const DialogTextField( - hintText: "Reason (optional)", initialText: "") - ], - ); - if (result?.isNotEmpty ?? false) { - await widget.event.redactEvent(reason: result?.first); - } - } - - break; - default: - await widget.event.room - .sendReaction(widget.event.eventId, _selectedEmoji!); - } + await widget.event.room + .sendReaction(widget.event.eventId, _selectedEmoji!); } } } diff --git a/lib/features/chat/widgets/room/room_timeline.dart b/lib/features/chat/widgets/room/room_timeline.dart index 103435c8..e5e65794 100644 --- a/lib/features/chat/widgets/room/room_timeline.dart +++ b/lib/features/chat/widgets/room/room_timeline.dart @@ -4,6 +4,7 @@ import 'package:flutter/material.dart'; import 'package:matrix/matrix.dart'; import 'package:piaf/features/chat/widgets/message_composer/advanced_message_composer.dart'; import 'package:piaf/partials/matrix/matrix_image_avatar.dart'; +import 'package:piaf/partials/minestrix_chat.dart'; import 'package:scrollview_observer/scrollview_observer.dart'; import '../../../../utils/matrix_widget.dart'; @@ -49,6 +50,8 @@ class RoomTimelineState extends State { StreamController eventsToAnimate = StreamController.broadcast(); Event? composerReplyToEvent; + Event? composerEditEvent; + Event? composerEditDisplayEvent; Room? room; List filteredEvents = []; @@ -217,7 +220,7 @@ class RoomTimelineState extends State { filteredEvents: filteredEvents, t: widget.timeline, i: index, - onReplyEventPressed: (event) async { + onJumpToReplyCallback: (event) async { // Jump to index final index = filteredEvents.indexOf( event.getDisplayEvent(widget.timeline!)); @@ -239,8 +242,21 @@ class RoomTimelineState extends State { } }, eventsToAnimateStream: eventsToAnimate.stream, + onEdit: (Event event) { + setState(() { + // Update the edit event and remove the reply reference if existing + composerReplyToEvent = null; + composerEditEvent = filteredEvents[ + index]; // Get the original event + composerEditDisplayEvent = + event; // The last edit version of the event + }); + }, onReply: (Event event) => setState(() { + // update the reply event reference and remove the edit event if existing composerReplyToEvent = event; + composerEditDisplayEvent = null; + composerEditEvent = null; }), fullyReadEventId: initialFullyReadEventId, ); @@ -299,9 +315,13 @@ class RoomTimelineState extends State { isMobile: widget.isMobile, userId: widget.userId, onRoomCreated: widget.onRoomCreated, - reply: composerReplyToEvent, - removeReply: () { + replyEvent: composerReplyToEvent, + editEvent: composerEditEvent, + editDisplayEvent: composerEditDisplayEvent, + cancelEditAndReply: () { setState(() { + composerEditEvent = null; + composerEditDisplayEvent = null; composerReplyToEvent = null; }); }), diff --git a/lib/partials/emoji/emoji_picker.dart b/lib/partials/emoji/emoji_picker.dart index a622135f..59997641 100644 --- a/lib/partials/emoji/emoji_picker.dart +++ b/lib/partials/emoji/emoji_picker.dart @@ -1,3 +1,4 @@ +import 'package:auto_route/auto_route.dart'; import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; @@ -10,9 +11,10 @@ class MinestrixEmojiPicker extends StatefulWidget { final String? selectedEmoji; final EdgeInsets? selectedEdge; - final bool enableReply; - final bool enableEdit; - final bool enableDelete; + final void Function()? onReply; + final void Function()? onEdit; + final void Function()? onCopy; + final void Function()? onDelete; const MinestrixEmojiPicker( {super.key, @@ -20,9 +22,10 @@ class MinestrixEmojiPicker extends StatefulWidget { required this.width, required this.selectedEmoji, required this.selectedEdge, - this.enableReply = false, - this.enableEdit = false, - this.enableDelete = false}); + this.onReply, + this.onEdit, + this.onCopy, + this.onDelete}); @override MinestrixEmojiPickerState createState() => MinestrixEmojiPickerState(); @@ -58,7 +61,7 @@ class MinestrixEmojiPickerState extends State { ], ), ), - if (!_open && widget.enableReply) + if (!_open) Card( child: Padding( padding: const EdgeInsets.all(8.0), @@ -66,30 +69,45 @@ class MinestrixEmojiPickerState extends State { constraints: const BoxConstraints(maxWidth: 160), child: Column( children: [ - if (widget.enableReply) - const ListTile( + if (widget.onReply != null) + ListTile( leading: Icon(Icons.reply), title: Text("Reply"), + onTap: () { + Navigator.of(context).pop(); + widget.onReply?.call(); + }, ), - if (widget.enableEdit) + if (widget.onEdit != null) ListTile( leading: const Icon(Icons.edit), title: const Text("Edit"), - onTap: () {}, + onTap: () { + Navigator.of(context).pop(); + widget.onEdit?.call(); + }, ), - ListTile( - leading: const Icon(Icons.copy), - title: const Text("Copy"), - onTap: () {}, - ), - if (widget.enableDelete) + if (widget.onCopy != null) + ListTile( + leading: const Icon(Icons.copy), + title: const Text("Copy"), + onTap: () { + Navigator.of(context).pop(); + widget.onCopy?.call(); + }, + ), + if (widget.onDelete != null) ListTile( leading: const Icon(Icons.delete, color: Colors.red), title: const Text( "Delete", style: TextStyle(color: Colors.red), ), - onTap: () {}, + onTap: () { + Navigator.of(context).pop(); + + widget.onDelete?.call(); + }, ), ], ),