From 47cd60fbe406e155bef3b6cb5c3abf42709ca0cd Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?S=C3=A9bastien=20Blin?=
 <sebastien.blin@savoirfairelinux.com>
Date: Tue, 11 Oct 2022 16:24:04 -0400
Subject: [PATCH] messagelistmodel: support message edition

Handle application/edited-message type to support message edition.
Previous bodies are saved in the interaction to be able to get the
original post to avoid unwanted editions.

While loading a conversation, we store the editions in a temporary
map that we link once the edited message is detected. This work
because we can't edit a message before this message exists.
PreviousBodies in interaction.h contains every previous body detected
and the client can show previous version of the message in a popup.

Deleting a message works the same way, just that any message
with an empty body is not shown.

https://git.jami.net/savoirfairelinux/jami-daemon/-/issues/316

Change-Id: Ib158abd16ad4b629532de11694e88d86a12d72a8
---
 src/app/commoncomponents/EditedPopup.qml      | 97 ++++++++++++++++++
 src/app/commoncomponents/SBSContextMenu.qml   | 22 +++++
 src/app/commoncomponents/SBSMessageBase.qml   |  5 +-
 .../commoncomponents/TextMessageDelegate.qml  | 48 +++++++++
 .../contextmenu/GeneralMenuItem.qml           |  3 +-
 src/app/constant/JamiStrings.qml              |  2 +
 src/app/constant/JamiTheme.qml                |  1 +
 .../mainview/components/ChatViewFooter.qml    | 29 +++++-
 src/app/mainview/components/EditContainer.qml | 99 +++++++++++++++++++
 .../mainview/components/MessageListView.qml   |  3 +-
 .../components/ParticipantOverlay.qml         |  2 +-
 src/app/messagesadapter.cpp                   | 18 ++++
 src/app/messagesadapter.h                     |  4 +
 src/app/qml.qrc                               |  2 +
 src/libclient/api/conversation.h              |  2 +-
 src/libclient/api/conversationmodel.h         |  7 ++
 src/libclient/api/interaction.h               | 22 ++++-
 src/libclient/conversationmodel.cpp           | 33 ++++++-
 src/libclient/messagelistmodel.cpp            | 56 ++++++++++-
 src/libclient/messagelistmodel.h              |  4 +
 .../qtwrapper/configurationmanager_wrap.h     |  7 +-
 21 files changed, 448 insertions(+), 18 deletions(-)
 create mode 100644 src/app/commoncomponents/EditedPopup.qml
 create mode 100644 src/app/mainview/components/EditContainer.qml

diff --git a/src/app/commoncomponents/EditedPopup.qml b/src/app/commoncomponents/EditedPopup.qml
new file mode 100644
index 000000000..2df12aef0
--- /dev/null
+++ b/src/app/commoncomponents/EditedPopup.qml
@@ -0,0 +1,97 @@
+/*
+ * Copyright (C) 2022 Savoir-faire Linux Inc.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <https://www.gnu.org/licenses/>.
+ */
+
+import QtQuick
+import QtQuick.Controls
+import QtQuick.Layouts
+import Qt5Compat.GraphicalEffects
+
+import net.jami.Models 1.1
+import net.jami.Adapters 1.1
+import net.jami.Constants 1.1
+
+BaseModalDialog {
+    id: root
+
+    width: 488
+    height: 256
+
+    property var previousBodies: undefined
+
+    popupContent: Item {
+        id: rect
+
+        width: root.width
+
+        JamiListView {
+            anchors.fill: parent
+            anchors.margins: JamiTheme.preferredMarginSize
+
+            model: root.previousBodies
+
+            delegate: Rectangle {
+                width: root.width - 2 * JamiTheme.preferredMarginSize
+                height: Math.max(JamiTheme.menuItemsPreferredHeight, rowBody.implicitHeight)
+                color: index % 2 === 0 ? JamiTheme.backgroundColor : JamiTheme.secondaryBackgroundColor
+
+                RowLayout {
+                    id: rowBody
+                    spacing: JamiTheme.preferredMarginSize
+                    width: parent.width
+                    anchors.centerIn: parent
+
+                    Text {
+                        Layout.preferredWidth: root.width / 4
+                        Layout.leftMargin: JamiTheme.settingsMarginSize
+
+                        text: MessagesAdapter.getFormattedDay(modelData.timestamp.toString())
+                              + " - " + MessagesAdapter.getFormattedTime(modelData.timestamp.toString())
+                        color: JamiTheme.textColor
+                        opacity: 0.5
+                    }
+
+                    Text {
+                        Layout.alignment: Qt.AlignLeft
+                        Layout.fillWidth: true
+
+                        TextMetrics {
+                            id: metrics
+                            elide: Text.ElideRight
+                            elideWidth: 3 * rowBody.width / 4 - 2 * JamiTheme.preferredMarginSize
+                            text: modelData.body
+                        }
+
+                        text: metrics.elidedText
+                        color: JamiTheme.textColor
+                    }
+                }
+            }
+        }
+
+        PushButton {
+            id: btnCancel
+            imageColor: "grey"
+            normalColor: "transparent"
+            anchors.right: parent.right
+            anchors.top: parent.top
+            anchors.topMargin: 10
+            anchors.rightMargin: 10
+            source: JamiResources.round_close_24dp_svg
+            onClicked: close()
+        }
+    }
+}
\ No newline at end of file
diff --git a/src/app/commoncomponents/SBSContextMenu.qml b/src/app/commoncomponents/SBSContextMenu.qml
index 8c6f0f094..261b0048b 100644
--- a/src/app/commoncomponents/SBSContextMenu.qml
+++ b/src/app/commoncomponents/SBSContextMenu.qml
@@ -29,6 +29,7 @@ ContextMenuAutoLoader {
     id: root
 
     property string location
+    property bool isOutgoing
     property string msgId
     property string transferName
     property string transferId
@@ -57,8 +58,29 @@ ContextMenuAutoLoader {
 
             itemName: JamiStrings.reply
             onClicked: {
+                MessagesAdapter.editId = ""
                 MessagesAdapter.replyToId = root.msgId
             }
+        },
+        GeneralMenuItem {
+            id: edit
+
+            canTrigger: transferId === "" && isOutgoing
+            itemName: JamiStrings.edit
+            onClicked: {
+                MessagesAdapter.replyToId = ""
+                MessagesAdapter.editId = root.msgId
+            }
+        },
+        GeneralMenuItem {
+            id: deleteMsg
+            dangerous: true
+
+            canTrigger: transferId === "" && isOutgoing
+            itemName: JamiStrings.optionDelete
+            onClicked: {
+                MessagesAdapter.editMessage(CurrentConversation.id, "", root.msgId)
+            }
         }
     ]
 
diff --git a/src/app/commoncomponents/SBSMessageBase.qml b/src/app/commoncomponents/SBSMessageBase.qml
index 343b4da43..13ccfd497 100644
--- a/src/app/commoncomponents/SBSMessageBase.qml
+++ b/src/app/commoncomponents/SBSMessageBase.qml
@@ -153,8 +153,8 @@ Control {
                         var baseColor = isOutgoing ? JamiTheme.messageOutBgColor
                                                    : CurrentConversation.isCoreDialog ?
                                                          JamiTheme.messageInBgColor : Qt.lighter(CurrentConversation.color, 1.5)
-                        if (Id === MessagesAdapter.replyToId) {
-                            // If we are replying to
+                        if (Id === MessagesAdapter.replyToId || Id === MessagesAdapter.editId) {
+                            // If we are replying to or editing the message
                             return Qt.darker(baseColor, 1.5)
                         }
                         return baseColor
@@ -289,6 +289,7 @@ Control {
 
         msgId: Id
         location: root.location
+        isOutgoing: root.isOutgoing
         transferId: root.transferId
         transferName: root.transferName
     }
diff --git a/src/app/commoncomponents/TextMessageDelegate.qml b/src/app/commoncomponents/TextMessageDelegate.qml
index fdd352f19..6fb61fd46 100644
--- a/src/app/commoncomponents/TextMessageDelegate.qml
+++ b/src/app/commoncomponents/TextMessageDelegate.qml
@@ -42,6 +42,12 @@ SBSMessageBase {
     formattedDay: MessagesAdapter.getFormattedDay(Timestamp)
     extraHeight: extraContent.active && !isRemoteImage ? msgRadius : -isRemoteImage
 
+    EditedPopup {
+        id: editedPopup
+
+        previousBodies: PreviousBodies
+    }
+
     innerContent.children: [
         TextEdit {
             id: textEditId
@@ -106,6 +112,48 @@ SBSMessageBase {
                 selectOnly: parent.readOnly
             }
         },
+        RowLayout {
+            id: editedRow
+            anchors.right: isOutgoing ? parent.right : undefined
+            visible: PreviousBodies.length !== 0
+
+            ResponsiveImage  {
+                id: editedImage
+
+                Layout.leftMargin: JamiTheme.preferredMarginSize
+                Layout.bottomMargin: JamiTheme.preferredMarginSize
+
+                source: JamiResources.round_edit_24dp_svg
+                width: JamiTheme.editedFontSize
+                height: JamiTheme.editedFontSize
+                layer {
+                    enabled: true
+                    effect: ColorOverlay {
+                        color: editedLabel.color
+                    }
+                }
+            }
+
+            Text {
+                id: editedLabel
+
+                Layout.rightMargin: JamiTheme.preferredMarginSize
+                Layout.bottomMargin: JamiTheme.preferredMarginSize
+
+                text: JamiStrings.edited
+                color: UtilsAdapter.luma(bubble.color) ?
+                        JamiTheme.chatviewTextColorLight :
+                        JamiTheme.chatviewTextColorDark
+                font.pointSize: JamiTheme.editedFontSize
+
+                TapHandler {
+                    acceptedButtons: Qt.LeftButton
+                    onTapped: {
+                        editedPopup.open()
+                    }
+                }
+            }
+        },
         Loader {
             id: extraContent
             anchors.right: isOutgoing ? parent.right : undefined
diff --git a/src/app/commoncomponents/contextmenu/GeneralMenuItem.qml b/src/app/commoncomponents/contextmenu/GeneralMenuItem.qml
index 82a0b9fb1..d42b5cd5e 100644
--- a/src/app/commoncomponents/contextmenu/GeneralMenuItem.qml
+++ b/src/app/commoncomponents/contextmenu/GeneralMenuItem.qml
@@ -37,6 +37,7 @@ MenuItem {
     property bool canTrigger: true
     property bool addMenuSeparatorAfter: false
     property bool autoTextSizeAdjustment: true
+    property bool dangerous: false
     property BaseContextMenu parentMenu
 
     property int itemPreferredWidth: JamiTheme.menuItemsPreferredWidth
@@ -94,7 +95,7 @@ MenuItem {
                 Layout.fillWidth: true
 
                 text: itemName
-                color: JamiTheme.textColor
+                color: dangerous ? JamiTheme.redColor : JamiTheme.textColor
                 font.pointSize: JamiTheme.textFontSize
                 horizontalAlignment: Text.AlignLeft
                 verticalAlignment: Text.AlignVCenter
diff --git a/src/app/constant/JamiStrings.qml b/src/app/constant/JamiStrings.qml
index 3e83a6c47..9624c0df4 100644
--- a/src/app/constant/JamiStrings.qml
+++ b/src/app/constant/JamiStrings.qml
@@ -726,6 +726,8 @@ Item {
     property string inReplyTo: qsTr("In reply to")
     property string reply: qsTr("Reply")
     property string writeTo: qsTr("Write to %1")
+    property string edit: qsTr("Edit")
+    property string edited: qsTr("Edited")
 
     // Invitation View
     property string invitationViewSentRequest: qsTr("%1 has sent you a request for a conversation.")
diff --git a/src/app/constant/JamiTheme.qml b/src/app/constant/JamiTheme.qml
index 67447c788..7712b000f 100644
--- a/src/app/constant/JamiTheme.qml
+++ b/src/app/constant/JamiTheme.qml
@@ -267,6 +267,7 @@ Item {
     property real smartlistItemInfoFontSize: calcSize(9 + fontSizeOffsetSmall)
     property real filterItemFontSize: calcSize(smartlistItemFontSize)
     property real filterBadgeFontSize: calcSize(8.25)
+    property real editedFontSize: calcSize(8)
     property real accountListItemHeight: 64
     property real accountListAvatarSize: 40
     property real smartListItemHeight: 64
diff --git a/src/app/mainview/components/ChatViewFooter.qml b/src/app/mainview/components/ChatViewFooter.qml
index c51070764..9ab6cd287 100644
--- a/src/app/mainview/components/ChatViewFooter.qml
+++ b/src/app/mainview/components/ChatViewFooter.qml
@@ -81,6 +81,16 @@ Rectangle {
         function onNewTextPasted() {
             messageBar.textAreaObj.pasteText()
         }
+
+        function onEditIdChanged() {
+            if (MessagesAdapter.editId.length > 0)
+                messageBar.textAreaObj.forceActiveFocus()
+        }
+
+        function onReplyToIdChanged() {
+            if (MessagesAdapter.replyToId.length > 0)
+                messageBar.forceActiveFocus()
+        }
     }
 
     RecordBox {
@@ -131,6 +141,16 @@ Rectangle {
             visible: MessagesAdapter.replyToId !== ""
         }
 
+        EditContainer {
+            id: editContainer
+
+            Layout.alignment: Qt.AlignHCenter
+            Layout.preferredWidth: footerColumnLayout.width
+            Layout.maximumWidth: JamiTheme.chatViewMaximumWidth
+            Layout.preferredHeight: 36
+            visible: MessagesAdapter.editId !== ""
+        }
+
         MessageBar {
             id: messageBar
 
@@ -162,8 +182,13 @@ Rectangle {
             onSendFileButtonClicked: jamiFileDialog.open()
             onSendMessageButtonClicked: {
                 // Send text message
-                if (messageBar.text)
-                    MessagesAdapter.sendMessage(messageBar.text)
+                if (messageBar.text) {
+                    if (MessagesAdapter.editId !== "") {
+                        MessagesAdapter.editMessage(CurrentConversation.id, messageBar.text)
+                    } else {
+                        MessagesAdapter.sendMessage(messageBar.text)
+                    }
+                }
                 messageBar.textAreaObj.clearText()
 
                 // Send file messages
diff --git a/src/app/mainview/components/EditContainer.qml b/src/app/mainview/components/EditContainer.qml
new file mode 100644
index 000000000..5f1360987
--- /dev/null
+++ b/src/app/mainview/components/EditContainer.qml
@@ -0,0 +1,99 @@
+/*
+ * Copyright (C) 2022 Savoir-faire Linux Inc.
+ * Author: Sébastien Blin <sebastien.blin@savoirfairelinux.com>
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <https://www.gnu.org/licenses/>.
+ */
+
+import QtQuick
+import QtQuick.Controls
+import QtQuick.Layouts
+
+import net.jami.Adapters 1.1
+import net.jami.Constants 1.1
+import net.jami.Models 1.1
+
+import "../../commoncomponents"
+
+Rectangle {
+    id: root
+
+    color: JamiTheme.messageOutBgColor
+
+    property var body: {
+        if (MessagesAdapter.editId === "")
+            return ""
+
+        return MessagesAdapter.dataForInteraction(MessagesAdapter.editId, MessageList.Body)
+    }
+
+    RowLayout {
+        anchors.fill: parent
+        spacing: 12
+
+        RowLayout {
+            Layout.alignment: Qt.AlignVCenter | Qt.AlignLeft
+
+            Label {
+                id: editLbl
+
+                text: JamiStrings.edit
+
+                color:  UtilsAdapter.luma(root.color) ?
+                            JamiTheme.chatviewTextColorLight :
+                            JamiTheme.chatviewTextColorDark
+                font.pointSize: JamiTheme.textFontSize
+                font.kerning: true
+                font.bold: true
+                Layout.leftMargin: JamiTheme.preferredMarginSize
+            }
+
+            Label {
+                id: bodyLbl
+
+                TextMetrics {
+                    id: metrics
+                    elide: Text.ElideRight
+                    elideWidth: root.width - 100
+                    text: root.body
+                }
+
+                text: metrics.elidedText
+                color:  UtilsAdapter.luma(root.color) ?
+                            JamiTheme.chatviewTextColorLight :
+                            JamiTheme.chatviewTextColorDark
+                font.pointSize: JamiTheme.textFontSize
+                font.kerning: true
+                font.bold: true
+            }
+        }
+
+
+        PushButton {
+            id: closeReply
+
+            Layout.alignment: Qt.AlignVCenter | Qt.AlignRight
+            Layout.rightMargin: JamiTheme.preferredMarginSize
+
+            preferredSize: 24
+
+            source: JamiResources.round_close_24dp_svg
+
+            normalColor: JamiTheme.chatviewBgColor
+            imageColor: JamiTheme.chatviewButtonColor
+
+            onClicked: MessagesAdapter.editId = ""
+        }
+    }
+}
diff --git a/src/app/mainview/components/MessageListView.qml b/src/app/mainview/components/MessageListView.qml
index ffc2c0125..01cff87db 100644
--- a/src/app/mainview/components/MessageListView.qml
+++ b/src/app/mainview/components/MessageListView.qml
@@ -193,7 +193,8 @@ JamiListView {
         onMessageListModelChanged: sourceModel = messageListModel
         filters: ExpressionFilter {
             readonly property int mergeType: Interaction.Type.MERGE
-            expression: Body !== "" && Type !== mergeType
+            readonly property int editedType: Interaction.Type.EDITED
+            expression: Body !== "" && Type !== mergeType && Type !== editedType
         }
         sorters: ExpressionSorter {
             expression: modelLeft.index > modelRight.index
diff --git a/src/app/mainview/components/ParticipantOverlay.qml b/src/app/mainview/components/ParticipantOverlay.qml
index e66bdb54b..3de0ad19a 100644
--- a/src/app/mainview/components/ParticipantOverlay.qml
+++ b/src/app/mainview/components/ParticipantOverlay.qml
@@ -291,7 +291,7 @@ Item {
                                 visible: (!root.isMe && !root.meModerator) ? root.participantIsMuted : root.isLocalMuted
 
                                 source: JamiResources.micro_off_black_24dp_svg
-                                color: "red"
+                                color: JamiTheme.redColor
 
                                 HoverHandler { id: hoverMicrophone }
                                 MaterialToolTip {
diff --git a/src/app/messagesadapter.cpp b/src/app/messagesadapter.cpp
index b3ae25b31..3d9f70ba6 100644
--- a/src/app/messagesadapter.cpp
+++ b/src/app/messagesadapter.cpp
@@ -52,6 +52,7 @@ MessagesAdapter::MessagesAdapter(AppSettingsManager* settingsManager,
 {
     connect(lrcInstance_, &LRCInstance::selectedConvUidChanged, [this]() {
         set_replyToId("");
+        set_editId("");
         const QString& convId = lrcInstance_->get_selectedConvUid();
         const auto& conversation = lrcInstance_->getConversationFromConvUid(convId);
         set_messageListModel(QVariant::fromValue(conversation.interactions.get()));
@@ -155,6 +156,23 @@ MessagesAdapter::sendMessage(const QString& message)
     }
 }
 
+void
+MessagesAdapter::editMessage(const QString& convId, const QString& newBody, const QString& messageId)
+{
+    try {
+        const auto convUid = lrcInstance_->get_selectedConvUid();
+        auto editId = !messageId.isEmpty() ? messageId : editId_;
+        if (editId.isEmpty()) {
+            qWarning("No message to edit");
+            return;
+        }
+        lrcInstance_->getCurrentConversationModel()->editMessage(convId, newBody, editId);
+        set_editId("");
+    } catch (...) {
+        qDebug() << "Exception during message edition:" << messageId;
+    }
+}
+
 void
 MessagesAdapter::sendFile(const QString& message)
 {
diff --git a/src/app/messagesadapter.h b/src/app/messagesadapter.h
index 210979347..b059fc8e6 100644
--- a/src/app/messagesadapter.h
+++ b/src/app/messagesadapter.h
@@ -32,6 +32,7 @@ class MessagesAdapter final : public QmlAdapterBase
     Q_OBJECT
     QML_RO_PROPERTY(QVariant, messageListModel)
     QML_PROPERTY(QString, replyToId)
+    QML_PROPERTY(QString, editId)
     QML_RO_PROPERTY(QList<QString>, currentConvComposingList)
 
 public:
@@ -67,6 +68,9 @@ protected:
     Q_INVOKABLE void unbanContact(int index);
     Q_INVOKABLE void unbanConversation(const QString& convUid);
     Q_INVOKABLE void sendMessage(const QString& message);
+    Q_INVOKABLE void editMessage(const QString& convId,
+                                 const QString& newBody,
+                                 const QString& messageId = "");
     Q_INVOKABLE void sendFile(const QString& message);
     Q_INVOKABLE void acceptFile(const QString& arg);
     Q_INVOKABLE void cancelFile(const QString& arg);
diff --git a/src/app/qml.qrc b/src/app/qml.qrc
index 854594f81..3560ee0bb 100644
--- a/src/app/qml.qrc
+++ b/src/app/qml.qrc
@@ -142,6 +142,7 @@
         <file>mainview/components/CallButtonDelegate.qml</file>
         <file>mainview/components/CallActionBar.qml</file>
         <file>commoncomponents/HalfPill.qml</file>
+        <file>commoncomponents/EditedPopup.qml</file>
         <file>commoncomponents/MaterialToolTip.qml</file>
         <file>mainview/components/ParticipantCallInStatusDelegate.qml</file>
         <file>mainview/components/ParticipantCallInStatusView.qml</file>
@@ -163,6 +164,7 @@
         <file>mainview/components/MessageBar.qml</file>
         <file>mainview/components/FilesToSendContainer.qml</file>
         <file>mainview/components/ReplyingContainer.qml</file>
+        <file>mainview/components/EditContainer.qml</file>
         <file>commoncomponents/Avatar.qml</file>
         <file>mainview/components/ConversationAvatar.qml</file>
         <file>mainview/components/InvitationView.qml</file>
diff --git a/src/libclient/api/conversation.h b/src/libclient/api/conversation.h
index 51911788e..5cb87ecc0 100644
--- a/src/libclient/api/conversation.h
+++ b/src/libclient/api/conversation.h
@@ -74,7 +74,7 @@ struct Info
     QString callId;
     QString confId;
     std::unique_ptr<MessageListModel> interactions;
-    QString lastMessageUid = 0;
+    QString lastMessageUid;
     QHash<QString, QString> parentsId; // pair messageid/parentid for messages without parent loaded
     unsigned int unreadMessages = 0;
     QVector<QPair<int, QString>> errors;
diff --git a/src/libclient/api/conversationmodel.h b/src/libclient/api/conversationmodel.h
index 29907bb8d..d0871caac 100644
--- a/src/libclient/api/conversationmodel.h
+++ b/src/libclient/api/conversationmodel.h
@@ -217,6 +217,13 @@ public:
      * @param parentId id of parent message. Default is "" - last message in conversation.
      */
     void sendMessage(const QString& uid, const QString& body, const QString& parentId = "");
+    /**
+     * Edit a message (empty body = delete message)
+     * @param convId        The conversation with the message to edit
+     * @param newBody       The new body
+     * @param messageId     The id of the message (MUST be by the same author & plain/text)
+     */
+    void editMessage(const QString& convId, const QString& newBody, const QString& messageId);
     /**
      * Modify the current filter (will change the result of getFilteredConversations)
      * @param filter the new filter
diff --git a/src/libclient/api/interaction.h b/src/libclient/api/interaction.h
index 3b32df017..5993fc79e 100644
--- a/src/libclient/api/interaction.h
+++ b/src/libclient/api/interaction.h
@@ -32,7 +32,7 @@ namespace interaction {
 Q_NAMESPACE
 Q_CLASSINFO("RegisterEnumClassesUnscoped", "false")
 
-enum class Type { INVALID, INITIAL, TEXT, CALL, CONTACT, DATA_TRANSFER, MERGE, COUNT__ };
+enum class Type { INVALID, INITIAL, TEXT, CALL, CONTACT, DATA_TRANSFER, MERGE, EDITED, COUNT__ };
 Q_ENUM_NS(Type)
 
 static inline const QString
@@ -51,6 +51,8 @@ to_string(const Type& type)
         return "DATA_TRANSFER";
     case Type::MERGE:
         return "MERGE";
+    case Type::EDITED:
+        return "EDITED";
     case Type::INVALID:
     case Type::COUNT__:
     default:
@@ -73,6 +75,8 @@ to_type(const QString& type)
         return interaction::Type::DATA_TRANSFER;
     else if (type == "merge")
         return interaction::Type::MERGE;
+    else if (type == "application/edited-message")
+        return interaction::Type::EDITED;
     else
         return interaction::Type::INVALID;
 }
@@ -238,6 +242,19 @@ getContactInteractionString(const QString& authorUri, const ContactAction& actio
     return {};
 }
 
+struct Body
+{
+    Q_GADGET
+
+    Q_PROPERTY(QString commitId MEMBER commitId)
+    Q_PROPERTY(QString body MEMBER body)
+    Q_PROPERTY(int timestamp MEMBER timestamp)
+public:
+    QString commitId;
+    QString body;
+    std::time_t timestamp;
+};
+
 /**
  * @var authorUri
  * @var body
@@ -263,6 +280,7 @@ struct Info
     MapStringString commit;
     QVariantMap linkPreviewInfo = {};
     bool linkified = false;
+    QVector<Body> previousBodies;
 
     Info() {}
 
@@ -286,7 +304,7 @@ struct Info
     Info(const MapStringString& message, const QString& accountURI)
     {
         type = to_type(message["type"]);
-        if (type == Type::TEXT) {
+        if (type == Type::TEXT || type == Type::EDITED) {
             body = message["body"];
         }
         authorUri = accountURI == message["author"] ? "" : message["author"];
diff --git a/src/libclient/conversationmodel.cpp b/src/libclient/conversationmodel.cpp
index 0a4184ffb..c904ad1df 100644
--- a/src/libclient/conversationmodel.cpp
+++ b/src/libclient/conversationmodel.cpp
@@ -220,7 +220,7 @@ public:
     const VectorString peersForConversation(const conversation::Info& conversation) const;
     // insert swarm interactions. Return false if interaction already exists.
     bool insertSwarmInteraction(const QString& interactionId,
-                                const interaction::Info& interaction,
+                                interaction::Info& interaction,
                                 conversation::Info& conversation,
                                 bool insertAtBegin);
     void invalidateModel();
@@ -1154,7 +1154,7 @@ ConversationModel::sendMessage(const QString& uid, const QString& body, const QS
     try {
         auto& conversation = pimpl_->getConversationForUid(uid, true).get();
         if (!conversation.isLegacy()) {
-            ConfigurationManager::instance().sendMessage(owner.id, uid, body, parentId);
+            ConfigurationManager::instance().sendMessage(owner.id, uid, body, parentId, 0);
             return;
         }
 
@@ -1181,7 +1181,8 @@ ConversationModel::sendMessage(const QString& uid, const QString& body, const QS
                 ConfigurationManager::instance().sendMessage(owner.id,
                                                              conversationId,
                                                              body,
-                                                             parentId);
+                                                             parentId,
+                                                             0);
                 return;
             }
             auto& peers = pimpl_->peersForConversation(newConv);
@@ -1271,6 +1272,18 @@ ConversationModel::sendMessage(const QString& uid, const QString& body, const QS
     }
 }
 
+void
+ConversationModel::editMessage(const QString& convId,
+                               const QString& newBody,
+                               const QString& messageId)
+{
+    auto conversationOpt = getConversationForUid(convId);
+    if (!conversationOpt.has_value()) {
+        return;
+    }
+    ConfigurationManager::instance().sendMessage(owner.id, convId, newBody, messageId, 1);
+}
+
 void
 ConversationModel::refreshFilter()
 {
@@ -2323,6 +2336,7 @@ ConversationModelPimpl::slotConversationLoaded(uint32_t requestId,
             }
             auto msgId = message["id"];
             auto msg = interaction::Info(message, linked.owner.profileInfo.uri);
+            conversation.interactions->editMessage(msgId, msg);
             auto downloadFile = false;
             if (msg.type == interaction::Type::INITIAL) {
                 allLoaded = true;
@@ -2356,6 +2370,8 @@ ConversationModelPimpl::slotConversationLoaded(uint32_t requestId,
                 msg.body = interaction::getContactInteractionString(bestName,
                                                                     interaction::to_action(
                                                                         message["action"]));
+            } else if (msg.type == interaction::Type::EDITED) {
+                conversation.interactions->addEdition(msgId, msg, false);
             }
             insertSwarmInteraction(msgId, msg, conversation, true);
             if (downloadFile) {
@@ -2380,7 +2396,6 @@ ConversationModelPimpl::slotConversationLoaded(uint32_t requestId,
                     return;
                 }
             }
-
             // In this case, we only have loaded merge commits. Load more messages
             ConfigurationManager::instance().loadConversationMessages(linked.owner.id,
                                                                       conversationId,
@@ -2427,6 +2442,7 @@ ConversationModelPimpl::slotMessageReceived(const QString& accountId,
         }
         auto msgId = message["id"];
         auto msg = interaction::Info(message, linked.owner.profileInfo.uri);
+        conversation.interactions->editMessage(msgId, msg);
         api::datatransfer::Info info;
         QString fileId;
 
@@ -2464,6 +2480,8 @@ ConversationModelPimpl::slotMessageReceived(const QString& accountId,
         } else if (msg.type == interaction::Type::TEXT
                    && msg.authorUri != linked.owner.profileInfo.uri) {
             conversation.unreadMessages++;
+        } else if (msg.type == interaction::Type::EDITED) {
+            conversation.interactions->addEdition(msgId, msg, true);
         }
         if (!insertSwarmInteraction(msgId, msg, conversation, false)) {
             // message already exists
@@ -2510,7 +2528,7 @@ ConversationModelPimpl::slotConversationProfileUpdated(const QString& accountId,
 
 bool
 ConversationModelPimpl::insertSwarmInteraction(const QString& interactionId,
-                                               const interaction::Info& interaction,
+                                               interaction::Info& interaction,
                                                conversation::Info& conversation,
                                                bool insertAtBegin)
 {
@@ -2518,6 +2536,11 @@ ConversationModelPimpl::insertSwarmInteraction(const QString& interactionId,
     auto itExists = conversation.interactions->find(interactionId);
     if (itExists != conversation.interactions->end()) {
         // Erase interaction if exists, as it will be updated via a re-insertion
+        if (itExists->second.previousBodies.size() != 0) {
+            // If the message was edited, we should keep this state
+            interaction.body = itExists->second.body;
+            interaction.previousBodies = itExists->second.previousBodies;
+        }
         itExists = conversation.interactions->erase(itExists);
         if (itExists != conversation.interactions->end()) {
             // next interaction doesn't have parent anymore.
diff --git a/src/libclient/messagelistmodel.cpp b/src/libclient/messagelistmodel.cpp
index 339ec2895..a2c5cb43e 100644
--- a/src/libclient/messagelistmodel.cpp
+++ b/src/libclient/messagelistmodel.cpp
@@ -176,6 +176,7 @@ MessageListModel::clear()
     Q_EMIT beginResetModel();
     interactions_.clear();
     replyTo_.clear();
+    editedBodies_.clear();
     Q_EMIT endResetModel();
 }
 
@@ -410,6 +411,13 @@ MessageListModel::dataForItem(item_t item, int, int role) const
         return QVariant(item.second.commit["uri"]);
     case Role::ContactAction:
         return QVariant(item.second.commit["action"]);
+    case Role::PreviousBodies: {
+        QVariantList variantList;
+        for (int i = 0; i < item.second.previousBodies.size(); i++) {
+            variantList.append(QVariant::fromValue(item.second.previousBodies[i]));
+        }
+        return variantList;
+    }
     case Role::ReplyTo:
         return QVariant(replyId);
     case Role::ReplyToAuthor:
@@ -541,12 +549,58 @@ MessageListModel::emitDataChanged(const QString& msgId, VectorInt roles)
     Q_EMIT dataChanged(modelIndex, modelIndex, roles);
 }
 
+void
+MessageListModel::addEdition(const QString& msgId, const interaction::Info& info, bool end)
+{
+    auto editedId = info.commit["edit"];
+    if (editedId.isEmpty())
+        return;
+    auto& edited = editedBodies_[editedId];
+    auto value = interaction::Body {msgId, info.body, info.timestamp};
+    if (end)
+        edited.push_back(value);
+    else
+        edited.push_front(value);
+    auto editedIt = find(editedId);
+    if (editedIt != interactions_.end()) {
+        // If already there, we can update the content
+        editMessage(editedId, editedIt->second);
+    }
+}
+
+void
+MessageListModel::editMessage(const QString& msgId, interaction::Info& info)
+{
+    auto it = editedBodies_.find(msgId);
+    if (it != editedBodies_.end()) {
+        if (info.previousBodies.isEmpty()) {
+            info.previousBodies.push_back(interaction::Body {msgId, info.body, info.timestamp});
+        }
+        // Find if already added (because MessageReceived can be triggered
+        // multiple times for same message)
+        for (const auto& editedBody : *it) {
+            auto itCommit = std::find_if(info.previousBodies.begin(),
+                                         info.previousBodies.end(),
+                                         [&](const auto& element) {
+                                             return element.commitId == editedBody.commitId;
+                                         });
+            if (itCommit == info.previousBodies.end()) {
+                info.previousBodies.push_back(editedBody);
+            }
+        }
+        info.body = it->rbegin()->body;
+        editedBodies_.erase(it);
+        emitDataChanged(msgId, {MessageList::Role::Body, MessageList::Role::PreviousBodies});
+    }
+}
+
 QString
 MessageListModel::lastMessageUid() const
 {
     for (auto it = interactions_.rbegin(); it != interactions_.rend(); ++it) {
         auto lastType = it->second.type;
-        if (lastType != interaction::Type::MERGE and !it->second.body.isEmpty()) {
+        if (lastType != interaction::Type::MERGE and lastType != interaction::Type::EDITED
+            and !it->second.body.isEmpty()) {
             return it->first;
         }
     }
diff --git a/src/libclient/messagelistmodel.h b/src/libclient/messagelistmodel.h
index b3d494ac7..241e59bd7 100644
--- a/src/libclient/messagelistmodel.h
+++ b/src/libclient/messagelistmodel.h
@@ -45,6 +45,7 @@ struct Info;
     X(ActionUri) \
     X(LinkPreviewInfo) \
     X(Linkified) \
+    X(PreviousBodies) \
     X(ReplyTo) \
     X(ReplyToBody) \
     X(ReplyToAuthor) \
@@ -130,6 +131,8 @@ public:
 
     Q_SIGNAL void timestampUpdate();
 
+    void addEdition(const QString& msgId, const interaction::Info& info, bool end);
+    void editMessage(const QString& msgId, interaction::Info& info);
     QString lastMessageUid() const;
 
 protected:
@@ -145,6 +148,7 @@ private:
     QMap<QString, QString> lastDisplayedMessageUid_;
     QMap<QString, QStringList> messageToReaders_;
     QMap<QString, QStringList> replyTo_;
+    QMap<QString, QVector<interaction::Body>> editedBodies_;
 
     void moveMessage(const QString& msgId, const QString& parentId);
     void insertMessage(int index, item_t& message);
diff --git a/src/libclient/qtwrapper/configurationmanager_wrap.h b/src/libclient/qtwrapper/configurationmanager_wrap.h
index 4946e86a5..ad7328bf0 100644
--- a/src/libclient/qtwrapper/configurationmanager_wrap.h
+++ b/src/libclient/qtwrapper/configurationmanager_wrap.h
@@ -1047,13 +1047,16 @@ public Q_SLOTS: // METHODS
     void sendMessage(const QString& accountId,
                      const QString& conversationId,
                      const QString& message,
-                     const QString& parent)
+                     const QString& parent,
+                     int flags = 0)
     {
         DRing::sendMessage(accountId.toStdString(),
                            conversationId.toStdString(),
                            message.toStdString(),
-                           parent.toStdString());
+                           parent.toStdString(),
+                           flags);
     }
+
     uint32_t loadConversationMessages(const QString& accountId,
                                       const QString& conversationId,
                                       const QString& fromId,
-- 
GitLab