From 7bb5ad0ee0bcd29d3ce8492353eb5ed897b8c6b2 Mon Sep 17 00:00:00 2001
From: Nicolas Vengeon <nicolas.vengeon@savoirfairelinux.com>
Date: Wed, 23 Nov 2022 09:50:53 -0500
Subject: [PATCH] Emoji: Implement emoji-reactions

Change-Id: I83f27e86a6a7ee2140dc3028a4e183dcb8de8a27
GitLab: #875
---
 resources/icons/add_reaction.svg              |   1 +
 resources/icons/copy.svg                      |   1 +
 resources/icons/delete.svg                    |   1 +
 resources/icons/edit.svg                      |   1 +
 resources/icons/reply.svg                     |   1 +
 resources/icons/save_file.svg                 |   1 +
 .../ChatviewMessageOptions.qml                | 205 ++++++++++++++++++
 .../commoncomponents/EmojiReactionPopup.qml   | 174 +++++++++++++++
 src/app/commoncomponents/EmojiReactions.qml   | 111 ++++++++++
 .../commoncomponents/MessageOptionButton.qml  |  63 ++++++
 src/app/commoncomponents/SBSMessageBase.qml   | 160 ++++++++++++--
 .../commoncomponents/TextMessageDelegate.qml  |  13 +-
 src/app/constant/JamiStrings.qml              |   5 +
 src/app/constant/JamiTheme.qml                |  14 +-
 src/app/mainview/components/ChatView.qml      |   8 +-
 .../mainview/components/MessageListView.qml   |   6 +-
 src/app/messagesadapter.cpp                   |  31 ++-
 src/app/messagesadapter.h                     |   6 +
 src/libclient/api/conversationmodel.h         |   7 +
 src/libclient/api/interaction.h               |  30 ++-
 src/libclient/conversationmodel.cpp           |  43 +++-
 src/libclient/messagelistmodel.cpp            |  92 +++++++-
 src/libclient/messagelistmodel.h              |  11 +
 src/libclient/qtwrapper/conversions_wrap.hpp  |  10 +
 src/libclient/typedefs.h                      |   6 +
 25 files changed, 962 insertions(+), 39 deletions(-)
 create mode 100644 resources/icons/add_reaction.svg
 create mode 100644 resources/icons/copy.svg
 create mode 100644 resources/icons/delete.svg
 create mode 100644 resources/icons/edit.svg
 create mode 100644 resources/icons/reply.svg
 create mode 100644 resources/icons/save_file.svg
 create mode 100644 src/app/commoncomponents/ChatviewMessageOptions.qml
 create mode 100644 src/app/commoncomponents/EmojiReactionPopup.qml
 create mode 100644 src/app/commoncomponents/EmojiReactions.qml
 create mode 100644 src/app/commoncomponents/MessageOptionButton.qml

diff --git a/resources/icons/add_reaction.svg b/resources/icons/add_reaction.svg
new file mode 100644
index 000000000..10778a520
--- /dev/null
+++ b/resources/icons/add_reaction.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" height="48" width="48"><path d="M24 24Zm0 20q-4.1 0-7.75-1.575-3.65-1.575-6.375-4.3-2.725-2.725-4.3-6.375Q4 28.1 4 24q0-4.15 1.575-7.8 1.575-3.65 4.3-6.35 2.725-2.7 6.375-4.275Q19.9 4 24 4q2.3 0 4.425.5T32.5 5.9v3.35q-1.9-1.1-4.025-1.675Q26.35 7 24 7q-7.05 0-12.025 4.95Q7 16.9 7 24q0 7.05 4.975 12.025Q16.95 41 24 41q7.1 0 12.05-4.975Q41 31.05 41 24q0-1.75-.325-3.375T39.7 17.5h3.2q.55 1.55.825 3.15Q44 22.25 44 24q0 4.1-1.575 7.75-1.575 3.65-4.275 6.375t-6.35 4.3Q28.15 44 24 44Zm16.5-30V9.5H36v-3h4.5V2h3v4.5H48v3h-4.5V14Zm-9.2 7.35q1.15 0 1.925-.8.775-.8.775-1.9 0-1.15-.775-1.925-.775-.775-1.925-.775-1.1 0-1.9.775-.8.775-.8 1.925 0 1.1.8 1.9.8.8 1.9.8Zm-14.6 0q1.15 0 1.925-.8.775-.8.775-1.9 0-1.15-.775-1.925-.775-.775-1.925-.775-1.1 0-1.9.775-.8.775-.8 1.925 0 1.1.8 1.9.8.8 1.9.8Zm7.3 13.6q3.3 0 6.075-1.775Q32.85 31.4 34.1 28.35H13.9q1.3 3.05 4.05 4.825Q20.7 34.95 24 34.95Z"/></svg>
\ No newline at end of file
diff --git a/resources/icons/copy.svg b/resources/icons/copy.svg
new file mode 100644
index 000000000..c5f4a8d92
--- /dev/null
+++ b/resources/icons/copy.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" height="48" width="48"><path d="M9 43.95q-1.2 0-2.1-.9-.9-.9-.9-2.1V10.8h3v30.15h23.7v3Zm6-6q-1.2 0-2.1-.9-.9-.9-.9-2.1v-28q0-1.2.9-2.1.9-.9 2.1-.9h22q1.2 0 2.1.9.9.9.9 2.1v28q0 1.2-.9 2.1-.9.9-2.1.9Zm0-3h22v-28H15v28Zm0 0v-28 28Z"/></svg>
\ No newline at end of file
diff --git a/resources/icons/delete.svg b/resources/icons/delete.svg
new file mode 100644
index 000000000..a8565b38b
--- /dev/null
+++ b/resources/icons/delete.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" height="48" width="48"><path d="M13.05 42q-1.25 0-2.125-.875T10.05 39V10.5H8v-3h9.4V6h13.2v1.5H40v3h-2.05V39q0 1.2-.9 2.1-.9.9-2.1.9Zm21.9-31.5h-21.9V39h21.9Zm-16.6 24.2h3V14.75h-3Zm8.3 0h3V14.75h-3Zm-13.6-24.2V39Z"/></svg>
\ No newline at end of file
diff --git a/resources/icons/edit.svg b/resources/icons/edit.svg
new file mode 100644
index 000000000..69222a042
--- /dev/null
+++ b/resources/icons/edit.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" height="48" width="48"><path d="M24 42v-3.55l10.8-10.8 3.55 3.55L27.55 42ZM6 31.5v-3h15v3Zm34.5-2.45-3.55-3.55 1.45-1.45q.4-.4 1.05-.4t1.05.4l1.45 1.45q.4.4.4 1.05t-.4 1.05ZM6 23.25v-3h23.5v3ZM6 15v-3h23.5v3Z"/></svg>
\ No newline at end of file
diff --git a/resources/icons/reply.svg b/resources/icons/reply.svg
new file mode 100644
index 000000000..59f3c8927
--- /dev/null
+++ b/resources/icons/reply.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" height="48" width="48"><path d="M39 38v-8.7q0-2.7-1.9-4.6-1.9-1.9-4.6-1.9H11.7l7.7 7.7-2.1 2.1L6 21.3 17.3 10l2.1 2.1-7.7 7.7h20.8q3.9 0 6.7 2.775Q42 25.35 42 29.3V38Z"/></svg>
\ No newline at end of file
diff --git a/resources/icons/save_file.svg b/resources/icons/save_file.svg
new file mode 100644
index 000000000..9c84d19ae
--- /dev/null
+++ b/resources/icons/save_file.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" height="48" width="48"><path d="M42 13.85V39q0 1.2-.9 2.1-.9.9-2.1.9H9q-1.2 0-2.1-.9Q6 40.2 6 39V9q0-1.2.9-2.1Q7.8 6 9 6h25.15Zm-3 1.35L32.8 9H9v30h30ZM24 35.75q2.15 0 3.675-1.525T29.2 30.55q0-2.15-1.525-3.675T24 25.35q-2.15 0-3.675 1.525T18.8 30.55q0 2.15 1.525 3.675T24 35.75ZM11.65 18.8h17.9v-7.15h-17.9ZM9 15.2V39 9Z"/></svg>
\ No newline at end of file
diff --git a/src/app/commoncomponents/ChatviewMessageOptions.qml b/src/app/commoncomponents/ChatviewMessageOptions.qml
new file mode 100644
index 000000000..d32e3af2b
--- /dev/null
+++ b/src/app/commoncomponents/ChatviewMessageOptions.qml
@@ -0,0 +1,205 @@
+/*
+ * Copyright (C) 2022 Savoir-faire Linux Inc.
+ * Author: Nicolas Vengeon <nicolas.vengeon@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 Qt5Compat.GraphicalEffects
+import QtQuick.Layouts
+
+import net.jami.Models 1.1
+import net.jami.Adapters 1.1
+import net.jami.Constants 1.1
+
+Popup {
+    id: root
+
+    width: emojiColumn.width + JamiTheme.emojiMargins
+    height: emojiColumn.height + JamiTheme.emojiMargins
+    padding: 0
+    background.visible: false
+
+    property string msgId
+    property string msg
+    property string emojiReplied
+    property bool out
+    property int type
+    property string transferId: msgId
+    property string location: Body
+    property string transferName
+
+    Rectangle {
+        id: bubble
+
+        color: JamiTheme.chatviewBgColor
+        anchors.fill: parent
+        radius: JamiTheme.modalPopupRadius
+
+        ColumnLayout {
+            id: emojiColumn
+
+            anchors.centerIn: parent
+
+            RowLayout {
+                id: emojiRow
+                Layout.alignment: Qt.AlignCenter
+
+                Repeater {
+                    model: ["👍", "😂", "😠" ]
+
+                    delegate: Button {
+                        id: emojiButton
+
+                        height: 50
+                        width: 50
+                        text: modelData
+                        font.pointSize: JamiTheme.emojiBubbleSize
+
+                        Text {
+                            visible: emojiButton.hovered
+                            anchors.centerIn: parent
+                            text: modelData
+                            font.pointSize: JamiTheme.emojiBubbleSizeBig
+                            z: 1
+                        }
+
+                        background: Rectangle {
+                            anchors.fill: parent
+                            opacity: emojiReplied.includes(modelData) ? 1 : 0
+                            color: JamiTheme.emojiReactPushButtonColor
+                            radius: 10
+                        }
+
+                        onClicked: {
+                            if (emojiReplied.includes(modelData))
+                                MessagesAdapter.removeEmojiReaction(CurrentConversation.id,text,msgId)
+                            else
+                                MessagesAdapter.addEmojiReaction(CurrentConversation.id,text,msgId)
+                            close()
+                        }
+                    }
+                }
+            }
+
+            Rectangle {
+                Layout.margins: 5
+                color: JamiTheme.timestampColor
+                Layout.fillWidth: true
+                Layout.preferredHeight: 1
+                radius: width * 0.5
+                opacity: 0.6
+            }
+
+            MessageOptionButton {
+                textButton: JamiStrings.copy
+                iconSource: JamiResources.copy_svg
+                Layout.fillWidth: true
+                Layout.margins: 5
+                onClicked: {
+                    UtilsAdapter.setClipboardText(msg)
+                    close()
+                }
+            }
+
+            MessageOptionButton {
+                visible: type === Interaction.Type.DATA_TRANSFER
+                textButton: JamiStrings.saveFile
+                iconSource: JamiResources.save_file_svg
+                Layout.fillWidth: true
+                Layout.margins: 5
+                onClicked: {
+                    MessagesAdapter.copyToDownloads(root.transferId, root.transferName)
+                    close()
+                }
+            }
+
+            MessageOptionButton {
+                visible: type === Interaction.Type.DATA_TRANSFER
+                textButton: JamiStrings.openLocation
+                iconSource: JamiResources.round_folder_24dp_svg
+                Layout.fillWidth: true
+                Layout.margins: 5
+                onClicked: {
+                    MessagesAdapter.openDirectory(root.location)
+                    close()
+                }
+            }
+
+            MessageOptionButton {
+                id: buttonEdit
+
+                visible: root.out && type === Interaction.Type.TEXT
+                textButton: JamiStrings.editMessage
+                iconSource: JamiResources.edit_svg
+                Layout.fillWidth: true
+                Layout.margins: 5
+                onClicked: {
+                    MessagesAdapter.replyToId = ""
+                    MessagesAdapter.editId = root.msgId
+                    close()
+                }
+            }
+
+            MessageOptionButton {
+                visible: root.out && type === Interaction.Type.TEXT
+                textButton: JamiStrings.deleteMessage
+                iconSource: JamiResources.delete_svg
+                Layout.fillWidth: true
+                Layout.margins: 5
+                onClicked: {
+                    MessagesAdapter.editMessage(CurrentConversation.id, "", root.msgId)
+                    close()
+                }
+            }
+        }
+    }
+
+    Overlay.modal: Rectangle {
+        color: JamiTheme.transparentColor
+        // Color animation for overlay when pop up is shown.
+        ColorAnimation on color {
+            to: JamiTheme.popupOverlayColor
+            duration: 500
+        }
+    }
+
+    DropShadow {
+        z: -1
+
+        width: bubble.width
+        height: bubble.height
+        horizontalOffset: 3.0
+        verticalOffset: 3.0
+        radius: bubble.radius * 4
+        color: JamiTheme.shadowColor
+        source: bubble
+        transparentBorder: true
+    }
+
+    enter: Transition {
+        NumberAnimation {
+            properties: "opacity"; from: 0.0; to: 1.0
+            duration: JamiTheme.shortFadeDuration
+        }
+    }
+
+    exit: Transition {
+        NumberAnimation {
+            properties: "opacity"; from: 1.0; to: 0.0
+            duration: JamiTheme.shortFadeDuration
+        }
+    }
+}
diff --git a/src/app/commoncomponents/EmojiReactionPopup.qml b/src/app/commoncomponents/EmojiReactionPopup.qml
new file mode 100644
index 000000000..78cded7d9
--- /dev/null
+++ b/src/app/commoncomponents/EmojiReactionPopup.qml
@@ -0,0 +1,174 @@
+/*
+ * Copyright (C) 2022 Savoir-faire Linux Inc.
+ * Author: Nicolas Vengeon <nicolas.vengeon@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.Layouts
+import QtQuick.Controls
+
+import net.jami.Models 1.1
+import net.jami.Adapters 1.1
+import net.jami.Constants 1.1
+import Qt5Compat.GraphicalEffects
+
+
+Popup {
+    id: root
+
+    width: popupContent.width
+    height: popupContent.height
+    background.visible: false
+    parent: Overlay.overlay
+
+    property var emojiReaction
+
+    // center in parent
+    x: Math.round((parent.width - width) / 2)
+    y: Math.round((parent.height - height) / 2)
+
+    modal: true
+    padding: 0
+
+    visible: false
+    closePolicy:  Popup.CloseOnEscape | Popup.CloseOnPressOutside
+
+    Rectangle {
+        id: container
+
+        anchors.fill: parent
+        radius: JamiTheme.modalPopupRadius
+        color: JamiTheme.secondaryBackgroundColor
+
+        ColumnLayout {
+            id:  popupContent
+
+            Layout.alignment: Qt.AlignCenter
+
+            PushButton {
+                id: btnClose
+
+                Layout.alignment: Qt.AlignRight
+                width: 30
+                height: 30
+                imageContainerWidth: 30
+                imageContainerHeight : 30
+                Layout.margins: 8
+                radius : 5
+                imageColor: "grey"
+                normalColor: JamiTheme.transparentColor
+                source: JamiResources.round_close_24dp_svg
+                onClicked: { root.close() }
+            }
+
+            RowLayout {
+                Layout.leftMargin: JamiTheme.popupButtonsMargin
+                Layout.rightMargin: JamiTheme.popupButtonsMargin
+                Layout.alignment: Qt.AlignCenter
+
+                ListView {
+                    id: listViewReaction
+
+                    Layout.preferredWidth: 400
+                    Layout.preferredHeight: modelCount < 5
+                                            ? 50 + (JamiTheme.avatarSize * modelCount)
+                                            : 300
+                    model: Object.entries(emojiReaction)
+                    clip: true
+                    property int modelCount: Object.entries(emojiReaction).length
+                    delegate: RowLayout {
+
+                        width: parent.width
+                        property string authorUri: modelData[0]
+                        property var emojiArray: modelData[1]
+                        property bool isMe: authorUri === CurrentAccount.uri
+
+                        Avatar {
+                            imageId: isMe ? CurrentAccount.id : authorUri
+                            showPresenceIndicator: false
+                            mode: isMe ? Avatar.Mode.Account : Avatar.Mode.Contact
+                            width: JamiTheme.avatarSize
+                            height: JamiTheme.avatarSize
+                        }
+
+                        Text {
+                            id: authorName
+
+                            Layout.maximumWidth: 180
+                            elide: Text.ElideRight
+                            font.pointSize: JamiTheme.namePopupFontsize
+                            color: JamiTheme.chatviewTextColor
+                            text: isMe
+                                  ? " " + CurrentAccount.bestName
+                                        + "   "
+                                  : " " + UtilsAdapter.getBestNameForUri(CurrentAccount.id, authorUri)
+                                        + "   "
+                        }
+
+                        Text {
+                            Layout.fillWidth: true
+                            text: {
+                                var cur = "";
+                                for (const emojiIndex in emojiArray) {
+                                    cur = cur + emojiArray[emojiIndex]
+                                }
+                                return cur
+                            }
+                            horizontalAlignment: Text.AlignRight
+                            font.pointSize: JamiTheme.emojiPopupFontsize
+                            elide: Text.ElideRight
+                        }
+                    }
+                }
+            }
+        }
+    }
+
+    Overlay.modal: Rectangle {
+        color: JamiTheme.transparentColor
+        // Color animation for overlay when pop up is shown.
+        ColorAnimation on color {
+            to: JamiTheme.popupOverlayColor
+            duration: 500
+        }
+    }
+
+    DropShadow {
+        z: -1
+        width: root.width
+        height: root.height
+        horizontalOffset: 3.0
+        verticalOffset: 3.0
+        radius: container.radius * 4
+        color: JamiTheme.shadowColor
+        source: container
+        transparentBorder: true
+    }
+
+    enter: Transition {
+        NumberAnimation {
+            properties: "opacity"; from: 0.0; to: 1.0
+            duration: JamiTheme.shortFadeDuration
+        }
+    }
+
+    exit: Transition {
+        NumberAnimation {
+            properties: "opacity"; from: 1.0; to: 0.0
+            duration: JamiTheme.shortFadeDuration
+        }
+    }
+}
diff --git a/src/app/commoncomponents/EmojiReactions.qml b/src/app/commoncomponents/EmojiReactions.qml
new file mode 100644
index 000000000..db93e912e
--- /dev/null
+++ b/src/app/commoncomponents/EmojiReactions.qml
@@ -0,0 +1,111 @@
+/*
+ * Copyright (C) 2022 Savoir-faire Linux Inc.
+ * Author: Nicolas Vengeon <nicolas.vengeon@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 Qt5Compat.GraphicalEffects
+
+import net.jami.Models 1.1
+import net.jami.Adapters 1.1
+import net.jami.Constants 1.1
+
+Item {
+    id: root
+
+    property var emojiReaction
+    property real contentHeight: bubble.height
+    property real contentWidth: bubble.width
+    property string emojiTexts: ownEmojiList
+
+    visible: emojis ? emojis.length : false
+
+    property string emojis: {
+        var space = ""
+        var emojiList = []
+        var emojiNumberList = []
+        for (const reactions of Object.entries(emojiReaction)) {
+            var authorEmojiList = reactions[1]
+            for (var emojiIndex in authorEmojiList) {
+                var emoji = authorEmojiList[emojiIndex]
+                if (emojiList.includes(emoji)) {
+                    var findIndex = emojiList.indexOf(emoji)
+                    if (findIndex != -1)
+                        emojiNumberList[findIndex] += 1
+                } else {
+                    emojiList.push(emoji)
+                    emojiNumberList.push(1)
+                }
+            }
+        }
+        var cur = ""
+        for (var i in emojiList) {
+            if (emojiNumberList[i] !== 1)
+                cur = cur + space + emojiList[i] + emojiNumberList[i] + ""
+            else
+                cur = cur + space + emojiList[i] + ""
+            space = "  "
+        }
+        return cur
+    }
+
+    property string ownEmojiList: {
+        var list = ""
+        for (const reactions of Object.entries(emojiReaction)) {
+            var authorUri = reactions[0]
+            var authorEmojiList = reactions[1]
+            if (CurrentAccount.uri === authorUri) {
+                for (var emojiIndex in authorEmojiList) {
+                    list = list + authorEmojiList[emojiIndex]
+                }
+                return list
+            }
+        }
+        return ""
+    }
+
+    Rectangle {
+        id: bubble
+
+        color: JamiTheme.emojiReactBubbleBgColor
+        width: textEmojis.width + 6
+        height: textEmojis.height + 6
+        radius: 10
+
+        Text {
+            id: textEmojis
+
+            anchors.margins: 10
+            anchors.centerIn: bubble
+            font.pointSize: JamiTheme.emojiReactSize
+            color: JamiTheme.chatviewTextColor
+            text: root.emojis
+        }
+    }
+
+    DropShadow {
+        z: -1
+
+        width: bubble.width
+        height: bubble.height
+        horizontalOffset: 3.0
+        verticalOffset: 3.0
+        radius: bubble.radius * 4
+        color: JamiTheme.shadowColor
+        source: bubble
+        transparentBorder: true
+    }
+}
diff --git a/src/app/commoncomponents/MessageOptionButton.qml b/src/app/commoncomponents/MessageOptionButton.qml
new file mode 100644
index 000000000..c0176804e
--- /dev/null
+++ b/src/app/commoncomponents/MessageOptionButton.qml
@@ -0,0 +1,63 @@
+/*
+ * Copyright (C) 2022 Savoir-faire Linux Inc.
+ * Author: Nicolas Vengeon <nicolas.vengeon@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 Qt5Compat.GraphicalEffects
+
+import net.jami.Models 1.1
+import net.jami.Adapters 1.1
+import net.jami.Constants 1.1
+
+Button {
+    id: buttonA
+
+    icon.color: JamiTheme.emojiReactPushButtonColor
+    font.pixelSize:JamiTheme.messageOptionTextFontSize
+    height: 20
+
+    property string textButton
+    property string iconSource
+
+    contentItem: RowLayout {
+        ResponsiveImage {
+            id: icon
+
+            source: iconSource
+            width: 25
+            height: 25
+            color: JamiTheme.emojiReactPushButtonColor
+            Layout.rightMargin: 10
+        }
+
+        Text {
+            text: textButton
+            Layout.fillWidth: true
+            horizontalAlignment: Text.AlignLeft
+            font.pixelSize: JamiTheme.messageOptionTextFontSize
+            color: JamiTheme.chatviewTextColor
+        }
+    }
+
+    background: Rectangle {
+        visible: parent.hovered
+        radius: 10
+        color: parent.down ? JamiTheme.pressedButtonColor : JamiTheme.hoveredButtonColor
+    }
+}
diff --git a/src/app/commoncomponents/SBSMessageBase.qml b/src/app/commoncomponents/SBSMessageBase.qml
index 50847d957..459988f47 100644
--- a/src/app/commoncomponents/SBSMessageBase.qml
+++ b/src/app/commoncomponents/SBSMessageBase.qml
@@ -54,6 +54,7 @@ Control {
     readonly property real avatarSize: 20
     readonly property real msgRadius: 20
     readonly property real hPadding: JamiTheme.sbsMessageBasePreferredPadding
+    property bool textHovered: false
     width: ListView.view ? ListView.view.width : 0
     height: mainColumnLayout.implicitHeight
 
@@ -80,7 +81,6 @@ Control {
         }
 
         Item {
-
             id: usernameblock
             Layout.preferredHeight: (seq === MsgSeq.first || seq === MsgSeq.single) ? 10 : 0
 
@@ -98,11 +98,14 @@ Control {
 
 
         RowLayout {
+            id: msgRowlayout
+
             Layout.preferredHeight: innerContent.height + root.extraHeight
             Layout.topMargin: (seq === MsgSeq.first || seq === MsgSeq.single) ? 6 : 0
 
             Item {
                 id: avatarBlock
+
                 Layout.preferredWidth: isOutgoing ? 0 : avatar.width + hPadding/3
                 Layout.preferredHeight: isOutgoing ? 0 : bubble.height
                 Avatar {
@@ -117,32 +120,132 @@ Control {
                 }
             }
 
-            MouseArea {
-                id: itemMouseArea
+            Item {
+                id: itemRowMessage
 
-                Layout.fillWidth: true
                 Layout.fillHeight: true
-                acceptedButtons: Qt.LeftButton | Qt.RightButton
-                onClicked: function (mouse) {
-                    if (mouse.button === Qt.RightButton
-                            && (transferId !== "" || Type === Interaction.Type.TEXT)) {
-                        // Context Menu for Transfers
-                        ctxMenu.x = mouse.x
-                        ctxMenu.y = mouse.y
-                        ctxMenu.openMenu()
-                    } else if (root.hoveredLink) {
-                        MessagesAdapter.openUrl(root.hoveredLink)
+                Layout.fillWidth: true
+
+                MouseArea {
+                    id: bubbleArea
+
+                    anchors.fill: bubble
+                    hoverEnabled: true
+                    onClicked: function (mouse) {
+                        if (root.hoveredLink) {
+                            MessagesAdapter.openUrl(root.hoveredLink)
+                        }
                     }
+                    property bool bubbleHovered: containsMouse || textHovered
                 }
 
                 Column {
                     id: innerContent
+
                     width: parent.width
+                    visible: true
 
                     // place actual content here
                     ReplyToRow {}
                 }
 
+                Item {
+                    id: optionButtonItem
+
+                    anchors.right: isOutgoing ? bubble.left : undefined
+                    anchors.left: !isOutgoing ? bubble.right : undefined
+                    width: JamiTheme.emojiPushButtonSize * 2
+                    height: JamiTheme.emojiPushButtonSize
+                    anchors.verticalCenter: bubble.verticalCenter
+
+                    HoverHandler {
+                        id: bgHandler
+                    }
+
+                    PushButton {
+                        id: more
+
+                        anchors.rightMargin: isOutgoing ? 10 : 0
+                        anchors.leftMargin: !isOutgoing ? 10 : 0
+
+                        imageColor: JamiTheme.emojiReactPushButtonColor
+                        normalColor: JamiTheme.transparentColor
+                        toolTipText: JamiStrings.moreOptions
+                        anchors.verticalCenter: parent.verticalCenter
+                        anchors.right: isOutgoing ? optionButtonItem.right : undefined
+                        anchors.left: !isOutgoing ? optionButtonItem.left : undefined
+                        visible: bubbleArea.bubbleHovered
+                                 || hovered
+                                 || reply.hovered
+                                 || bgHandler.hovered
+                        source: JamiResources.more_vert_24dp_svg
+                        width: optionButtonItem.width / 2
+                        height: optionButtonItem.height
+
+                        onClicked: {
+                            messageOptionPopup.setPosition()
+                            messageOptionPopup.open()
+                        }
+                    }
+
+                    PushButton {
+                        id: reply
+
+                        imageColor: JamiTheme.emojiReactPushButtonColor
+                        normalColor: JamiTheme.transparentColor
+                        toolTipText: JamiStrings.reply
+                        source: JamiResources.reply_svg
+                        width: optionButtonItem.width / 2
+                        height: optionButtonItem.height
+                        anchors.verticalCenter: parent.verticalCenter
+                        anchors.right: isOutgoing ? more.left : undefined
+                        anchors.left: !isOutgoing ? more.right : undefined
+                        visible: bubbleArea.bubbleHovered
+                                 || hovered
+                                 || more.hovered
+                                 || bgHandler.hovered
+
+                        onClicked: {
+                            MessagesAdapter.editId = ""
+                            MessagesAdapter.replyToId = Id
+                        }
+                    }
+                }
+
+
+                ChatviewMessageOptions {
+                    id: messageOptionPopup
+
+                    emojiReplied: emojiReaction.emojiTexts
+                    out: isOutgoing
+                    msgId: Id
+                    msg: Body
+                    type: Type
+                    transferName: TransferName
+
+                    function setPosition() {
+                        var mappedCoord = bubble.mapToItem(appWindow.contentItem,0, 0)
+                        var distBottomScreen = appWindow.height - mappedCoord.y - height
+                        if (distBottomScreen < 0) {
+                            y = distBottomScreen
+                        } else {
+                            y = 0
+                        }
+                        var distBorders = root.width - bubble.width - width
+                        if (isOutgoing) {
+                            if (distBorders > 0)
+                                x = bubble.x - width
+                            else
+                                x = bubble.x
+                        } else {
+                            if (distBorders > 0)
+                                x = bubble.x + bubble.width
+                            else
+                                x = bubble.x + bubble.width - width
+                        }
+                    }
+                }
+
                 MessageBubble {
                     id: bubble
 
@@ -253,6 +356,25 @@ Control {
             }
         }
 
+        EmojiReactions {
+            id: emojiReaction
+
+            property bool isOutgoing: Author === ""
+            Layout.alignment: isOutgoing ? Qt.AlignRight : Qt.AlignLeft
+            Layout.rightMargin: isOutgoing ? status.width : undefined
+            Layout.leftMargin: !isOutgoing ? avatarBlock.width : undefined
+            Layout.topMargin: - contentHeight/4
+            Layout.preferredHeight: contentHeight + 5
+            Layout.preferredWidth: contentWidth
+            emojiReaction: Reactions
+
+            TapHandler {
+                onTapped: {
+                    reactionPopup.open()
+                }
+            }
+        }
+
         ListView {
             id: infoCell
 
@@ -285,13 +407,9 @@ Control {
         }
     }
 
-    SBSContextMenu {
-        id: ctxMenu
+    EmojiReactionPopup {
+        id: reactionPopup
 
-        msgId: Id
-        location: root.location
-        isOutgoing: root.isOutgoing
-        transferId: root.transferId
-        transferName: root.transferName
+        emojiReaction: Reactions
     }
 }
diff --git a/src/app/commoncomponents/TextMessageDelegate.qml b/src/app/commoncomponents/TextMessageDelegate.qml
index 943f08455..7016ccf9e 100644
--- a/src/app/commoncomponents/TextMessageDelegate.qml
+++ b/src/app/commoncomponents/TextMessageDelegate.qml
@@ -44,7 +44,7 @@ SBSMessageBase {
     formattedTime: MessagesAdapter.getFormattedTime(Timestamp)
     formattedDay: MessagesAdapter.getFormattedDay(Timestamp)
     extraHeight: extraContent.active && !isRemoteImage ? msgRadius : -isRemoteImage
-
+    textHovered: textHoverhandler.hovered
 
     EditedPopup {
         id: editedPopup
@@ -58,11 +58,13 @@ SBSMessageBase {
 
             padding: isEmojiOnly ? 0 : JamiTheme.preferredMarginSize
             anchors.right: isOutgoing ? parent.right : undefined
-
             text: Body
-
             horizontalAlignment: Text.AlignLeft
 
+            HoverHandler {
+                id: textHoverhandler
+            }
+
             width: {
                 if (extraContent.active)
                     Math.max(extraContent.width,
@@ -77,7 +79,6 @@ SBSMessageBase {
             wrapMode: Label.WrapAtWordBoundaryOrAnywhere
             selectByMouse: true
             font.pixelSize: isEmojiOnly? JamiTheme.chatviewEmojiSize : JamiTheme.chatviewFontSize
-
             font.hintingPreference: Font.PreferNoHinting
             renderType: Text.NativeRendering
             textFormat: Text.MarkdownText
@@ -119,6 +120,7 @@ SBSMessageBase {
         },
         RowLayout {
             id: editedRow
+
             anchors.right: isOutgoing ? parent.right : undefined
             visible: PreviousBodies.length !== 0
 
@@ -127,7 +129,6 @@ SBSMessageBase {
 
                 Layout.leftMargin: JamiTheme.preferredMarginSize
                 Layout.bottomMargin: JamiTheme.preferredMarginSize
-
                 source: JamiResources.round_edit_24dp_svg
                 width: JamiTheme.editedFontSize
                 height: JamiTheme.editedFontSize
@@ -161,12 +162,14 @@ SBSMessageBase {
         },
         Loader {
             id: extraContent
+
             anchors.right: isOutgoing ? parent.right : undefined
             property real minSize: 192
             property real maxSize: 320
             active: LinkPreviewInfo.url !== undefined
             sourceComponent: ColumnLayout {
                 id: previewContent
+
                 spacing: 12
                 Component.onCompleted: {
                     isRemoteImage = MessagesAdapter.isRemoteImage(LinkPreviewInfo.url)
diff --git a/src/app/constant/JamiStrings.qml b/src/app/constant/JamiStrings.qml
index 39091b93b..9426b79d5 100644
--- a/src/app/constant/JamiStrings.qml
+++ b/src/app/constant/JamiStrings.qml
@@ -806,4 +806,9 @@ Item {
     property string customizationDescription: qsTr("This profile is only shared with this account's contacts")
     property string customizationDescription2: qsTr("Your profile is only shared with your contacts")
     property string whySaveAccount: qsTr("Why should I save my account?")
+
+    //message options
+    property string deleteMessage: qsTr("Delete message")
+    property string editMessage: qsTr("Edit message")
+
 }
diff --git a/src/app/constant/JamiTheme.qml b/src/app/constant/JamiTheme.qml
index 34686dc94..afea00299 100644
--- a/src/app/constant/JamiTheme.qml
+++ b/src/app/constant/JamiTheme.qml
@@ -222,6 +222,19 @@ Item {
     property color sharePositionIndicatorColor: red_
     property color sharedPositionIndicatorColor: urgentOrange_
 
+    //EmojiReact
+    property real emojiBubbleSize: calcSize(17)
+    property real emojiBubbleSizeBig: calcSize(21)
+    property real emojiReactSize: calcSize(12)
+    property real emojiPopupFontsize: calcSize(25)
+    property real namePopupFontsize: calcSize(15)
+    property real avatarSize: 30
+    property int emojiPushButtonSize: 30
+    property int emojiMargins: 20
+    property color emojiReactBubbleBgColor: darkTheme ? darkGreyColor : whiteColor
+    property color emojiReactPushButtonColor: darkTheme ? "#bbb" : "#003b4e"
+    property real messageOptionTextFontSize: calcSize(15)
+
     // Files To Send Container
     property color removeFileButtonColor: Qt.rgba(96, 95, 97, 0.5)
 
@@ -233,7 +246,6 @@ Item {
     property color typingDotsEnlargeColor: darkTheme ? "white" : Qt.darker("lightgrey", 3.0)
 
     // Font.
-
     property color faddedFontColor: darkTheme? "#c0c0c0" : "#a0a0a0"
     property color faddedLastInteractionFontColor: darkTheme ? "#c0c0c0" : "#505050"
 
diff --git a/src/app/mainview/components/ChatView.qml b/src/app/mainview/components/ChatView.qml
index 5b40d8712..6bbe66745 100644
--- a/src/app/mainview/components/ChatView.qml
+++ b/src/app/mainview/components/ChatView.qml
@@ -39,6 +39,12 @@ Rectangle {
     signal messagesCleared
     signal messagesLoaded
 
+     onVisibleChanged: {
+        if (visible)
+            return
+        UtilsAdapter.clearInteractionsCache(CurrentAccount.id, CurrentConversation.id)
+    }
+
     function focusChatView() {
         chatViewFooter.textInput.forceActiveFocus()
         swarmDetailsPanel.visible = false
@@ -213,7 +219,7 @@ Rectangle {
                     Layout.rightMargin: JamiTheme.chatviewMargin
 
                     currentIndex: CurrentConversation.isRequest ||
-                                CurrentConversation.needsSyncing
+                                  CurrentConversation.needsSyncing
 
                     Loader {
                         active: CurrentConversation.id !== ""
diff --git a/src/app/mainview/components/MessageListView.qml b/src/app/mainview/components/MessageListView.qml
index 2ccc8098b..ef1a559c5 100644
--- a/src/app/mainview/components/MessageListView.qml
+++ b/src/app/mainview/components/MessageListView.qml
@@ -214,7 +214,11 @@ JamiListView {
         filters: ExpressionFilter {
             readonly property int mergeType: Interaction.Type.MERGE
             readonly property int editedType: Interaction.Type.EDITED
-            expression: Body !== "" && Type !== mergeType && Type !== editedType
+            readonly property int reactionType: Interaction.Type.REACTION
+            expression: Body !== ""
+                        && Type !== mergeType
+                        && Type !== editedType
+                        && Type !== reactionType
         }
         sorters: ExpressionSorter {
             expression: modelLeft.index > modelRight.index
diff --git a/src/app/messagesadapter.cpp b/src/app/messagesadapter.cpp
index f09348cc0..837abb947 100644
--- a/src/app/messagesadapter.cpp
+++ b/src/app/messagesadapter.cpp
@@ -170,16 +170,43 @@ MessagesAdapter::editMessage(const QString& convId, const QString& newBody, cons
         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::removeEmojiReaction(const QString& convId,
+                                     const QString& emoji,
+                                     const QString& messageId)
+{
+    try {
+        const auto convUid = lrcInstance_->get_selectedConvUid();
+        const auto authorUri = lrcInstance_->getCurrentAccountInfo().profileInfo.uri;
+        // check if this emoji has already been added by this author
+        auto emojiId = lrcInstance_->getConversationFromConvUid(convId)
+                           .interactions->findEmojiReaction(emoji, authorUri, messageId);
+        editMessage(convId, "", emojiId);
+    } catch (...) {
+        qDebug() << "Exception during removeEmojiReaction():" << messageId;
+    }
+}
+
+void
+MessagesAdapter::addEmojiReaction(const QString& convId,
+                                  const QString& emoji,
+                                  const QString& messageId)
+{
+    try {
+        lrcInstance_->getCurrentConversationModel()->reactMessage(convId, emoji, messageId);
+    } catch (...) {
+        qDebug() << "Exception during addEmojiReaction():" << messageId;
+    }
+}
+
 void
 MessagesAdapter::sendFile(const QString& message)
 {
diff --git a/src/app/messagesadapter.h b/src/app/messagesadapter.h
index f3362cf3a..b19193e50 100644
--- a/src/app/messagesadapter.h
+++ b/src/app/messagesadapter.h
@@ -72,6 +72,12 @@ protected:
     Q_INVOKABLE void editMessage(const QString& convId,
                                  const QString& newBody,
                                  const QString& messageId = "");
+    Q_INVOKABLE void addEmojiReaction(const QString& convId,
+                                      const QString& emoji,
+                                      const QString& messageId = "");
+    Q_INVOKABLE void removeEmojiReaction(const QString& convId,
+                                         const QString& emoji,
+                                         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/libclient/api/conversationmodel.h b/src/libclient/api/conversationmodel.h
index cd5849d18..18caa2d6b 100644
--- a/src/libclient/api/conversationmodel.h
+++ b/src/libclient/api/conversationmodel.h
@@ -229,6 +229,13 @@ public:
      * @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);
+    /**
+     * React to a message with an emoji
+     * @param convId        The conversation id
+     * @param emoji         The emoji
+     * @param messageId     The id of the message
+     */
+    void reactMessage(const QString& convId, const QString& emoji, 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 4cebc79ff..84e58df56 100644
--- a/src/libclient/api/interaction.h
+++ b/src/libclient/api/interaction.h
@@ -32,7 +32,18 @@ namespace interaction {
 Q_NAMESPACE
 Q_CLASSINFO("RegisterEnumClassesUnscoped", "false")
 
-enum class Type { INVALID, INITIAL, TEXT, CALL, CONTACT, DATA_TRANSFER, MERGE, EDITED, COUNT__ };
+enum class Type {
+    INVALID,
+    INITIAL,
+    TEXT,
+    CALL,
+    CONTACT,
+    DATA_TRANSFER,
+    MERGE,
+    EDITED,
+    REACTION,
+    COUNT__
+};
 Q_ENUM_NS(Type)
 
 static inline const QString
@@ -53,6 +64,8 @@ to_string(const Type& type)
         return "MERGE";
     case Type::EDITED:
         return "EDITED";
+    case Type::REACTION:
+        return "REACTION";
     case Type::INVALID:
     case Type::COUNT__:
     default:
@@ -67,6 +80,8 @@ to_type(const QString& type)
         return interaction::Type::INITIAL;
     else if (type == "TEXT" || type == TEXT_PLAIN)
         return interaction::Type::TEXT;
+    else if (type == "REACTION")
+        return interaction::Type::REACTION;
     else if (type == "CALL" || type == "application/call-history+json")
         return interaction::Type::CALL;
     else if (type == "CONTACT" || type == "member")
@@ -281,6 +296,8 @@ struct Info
     MapStringString commit;
     QVariantMap linkPreviewInfo = {};
     bool linkified = false;
+    QVariantMap reactions;
+    QString react_to;
     QVector<Body> previousBodies;
 
     Info() {}
@@ -305,10 +322,17 @@ struct Info
     Info(const MapStringString& message, const QString& accountURI)
     {
         type = to_type(message["type"]);
-        if (type == Type::TEXT || type == Type::EDITED) {
+        if (message.contains("react-to") && type == Type::TEXT) {
+            type = to_type("REACTION");
+            react_to = message["react-to"];
+            authorUri = message["author"];
+        }
+
+        if (type == Type::TEXT || type == Type::EDITED || type == Type::REACTION) {
             body = message["body"];
         }
-        authorUri = accountURI == message["author"] ? "" : message["author"];
+        if (type != Type::REACTION)
+            authorUri = accountURI == message["author"] ? "" : message["author"];
         timestamp = message["timestamp"].toInt();
         status = Status::SUCCESS;
         parentId = message["linearizedParent"];
diff --git a/src/libclient/conversationmodel.cpp b/src/libclient/conversationmodel.cpp
index 45d777fa9..b4c295e45 100644
--- a/src/libclient/conversationmodel.cpp
+++ b/src/libclient/conversationmodel.cpp
@@ -1223,7 +1223,11 @@ 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, 0);
+            ConfigurationManager::instance().sendMessage(owner.id,
+                                                         uid,
+                                                         body,
+                                                         parentId,
+                                                         static_cast<int>(MessageFlag::Text));
             return;
         }
 
@@ -1251,7 +1255,7 @@ ConversationModel::sendMessage(const QString& uid, const QString& body, const QS
                                                              conversationId,
                                                              body,
                                                              parentId,
-                                                             0);
+                                                             static_cast<int>(MessageFlag::Text));
                 return;
             }
             auto& peers = pimpl_->peersForConversation(newConv);
@@ -1351,7 +1355,27 @@ ConversationModel::editMessage(const QString& convId,
     if (!conversationOpt.has_value()) {
         return;
     }
-    ConfigurationManager::instance().sendMessage(owner.id, convId, newBody, messageId, 1);
+    ConfigurationManager::instance().sendMessage(owner.id,
+                                                 convId,
+                                                 newBody,
+                                                 messageId,
+                                                 static_cast<int>(MessageFlag::Reply));
+}
+
+void
+ConversationModel::reactMessage(const QString& convId,
+                                const QString& emoji,
+                                const QString& messageId)
+{
+    auto conversationOpt = getConversationForUid(convId);
+    if (!conversationOpt.has_value()) {
+        return;
+    }
+    ConfigurationManager::instance().sendMessage(owner.id,
+                                                 convId,
+                                                 emoji,
+                                                 messageId,
+                                                 static_cast<int>(MessageFlag::Reaction));
 }
 
 void
@@ -2430,6 +2454,7 @@ ConversationModelPimpl::slotConversationLoaded(uint32_t requestId,
             auto msgId = message["id"];
             auto msg = interaction::Info(message, linked.owner.profileInfo.uri);
             conversation.interactions->editMessage(msgId, msg);
+            conversation.interactions->reactToMessage(msgId, msg);
             auto downloadFile = false;
             if (msg.type == interaction::Type::INITIAL) {
                 allLoaded = true;
@@ -2465,6 +2490,8 @@ ConversationModelPimpl::slotConversationLoaded(uint32_t requestId,
                                                                         message["action"]));
             } else if (msg.type == interaction::Type::EDITED) {
                 conversation.interactions->addEdition(msgId, msg, false);
+            } else if (msg.type == interaction::Type::REACTION) {
+                conversation.interactions->addReaction(msg.react_to, msgId);
             }
             insertSwarmInteraction(msgId, msg, conversation, true);
             if (downloadFile) {
@@ -2617,6 +2644,8 @@ ConversationModelPimpl::slotMessageReceived(const QString& accountId,
             if (msg.authorUri != linked.owner.profileInfo.uri) {
                 updateUnread = true;
             }
+        } else if (msg.type == interaction::Type::REACTION) {
+            conversation.interactions->addReaction(msg.react_to, msgId);
         } else if (msg.type == interaction::Type::EDITED) {
             conversation.interactions->addEdition(msgId, msg, true);
         }
@@ -2625,6 +2654,14 @@ ConversationModelPimpl::slotMessageReceived(const QString& accountId,
             // message already exists
             return;
         }
+        // once the reaction is added to interactions, we can update the reacted
+        // message
+        if (msg.type == interaction::Type::REACTION) {
+            auto reactInteraction = conversation.interactions->find(msg.react_to);
+            if (reactInteraction != conversation.interactions->end()) {
+                conversation.interactions->reactToMessage(msg.react_to, reactInteraction->second);
+            }
+        }
         if (updateUnread) {
             conversation.unreadMessages++;
         }
diff --git a/src/libclient/messagelistmodel.cpp b/src/libclient/messagelistmodel.cpp
index e6913a508..b657ceaa4 100644
--- a/src/libclient/messagelistmodel.cpp
+++ b/src/libclient/messagelistmodel.cpp
@@ -23,6 +23,7 @@
 
 #include "api/conversationmodel.h"
 #include "api/interaction.h"
+#include "qtwrapper/conversions_wrap.hpp"
 
 #include <QAbstractListModel>
 
@@ -191,6 +192,7 @@ MessageListModel::clear()
     interactions_.clear();
     replyTo_.clear();
     editedBodies_.clear();
+    reactedMessages_.clear();
     Q_EMIT endResetModel();
 }
 
@@ -466,6 +468,8 @@ MessageListModel::dataForItem(item_t item, int, int role) const
         return QVariant(messageToReaders_[item.first]);
     case Role::IsEmojiOnly:
         return QVariant(isOnlyEmoji(item.second.body));
+    case Role::Reactions:
+        return QVariant(item.second.reactions);
     default:
         return {};
     }
@@ -588,6 +592,11 @@ MessageListModel::addEdition(const QString& msgId, const interaction::Info& info
     if (editedId.isEmpty())
         return;
     auto& edited = editedBodies_[editedId];
+    auto editedMsgIt = std::find_if(edited.begin(), edited.end(), [&](const auto& v) {
+        return msgId == v.commitId;
+    });
+    if (editedMsgIt != edited.end())
+        return; // Already added
     auto value = interaction::Body {msgId, info.body, info.timestamp};
     if (end)
         edited.push_back(value);
@@ -597,7 +606,58 @@ MessageListModel::addEdition(const QString& msgId, const interaction::Info& info
     if (editedIt != interactions_.end()) {
         // If already there, we can update the content
         editMessage(editedId, editedIt->second);
+        if (!editedIt->second.react_to.isEmpty()) {
+            auto reactToIt = find(editedIt->second.react_to);
+            if (reactToIt != interactions_.end())
+                reactToMessage(editedIt->second.react_to, reactToIt->second);
+        }
+    }
+}
+
+void
+MessageListModel::addReaction(const QString& messageId, const QString& reactionId)
+{
+    auto itReacted = reactedMessages_.find(messageId);
+    if (itReacted != reactedMessages_.end()) {
+        itReacted->insert(reactionId);
+    } else {
+        QSet<QString> emojiList;
+        emojiList.insert(reactionId);
+        reactedMessages_.insert(messageId, emojiList);
+    }
+    auto interaction = find(reactionId);
+    if (interaction != interactions_.end()) {
+        // Edit reaction if needed
+        editMessage(reactionId, interaction->second);
+    }
+}
+
+QVariantMap
+MessageListModel::convertReactMessagetoQVariant(const QSet<QString>& emojiIdList)
+{
+    QVariantMap convertedMap;
+    QMap<QString, QStringList> mapStringEmoji;
+    for (auto emojiId = emojiIdList.begin(); emojiId != emojiIdList.end(); emojiId++) {
+        auto interaction = find(*emojiId);
+        if (interaction != interactions_.end()) {
+            auto author = interaction->second.authorUri;
+            auto body = interaction->second.body;
+            if (!body.isEmpty()) {
+                auto itAuthor = mapStringEmoji.find(author);
+                if (itAuthor != mapStringEmoji.end()) {
+                    mapStringEmoji[author].append(body);
+                } else {
+                    QStringList emojiList;
+                    emojiList.append(body);
+                    mapStringEmoji.insert(author, emojiList);
+                }
+            }
+        }
+    }
+    for (auto i = mapStringEmoji.begin(); i != mapStringEmoji.end(); i++) {
+        convertedMap.insert(i.key(), i.value());
     }
+    return convertedMap;
 }
 
 void
@@ -635,6 +695,19 @@ MessageListModel::editMessage(const QString& msgId, interaction::Info& info)
     }
 }
 
+void
+MessageListModel::reactToMessage(const QString& msgId, interaction::Info& info)
+{
+    // If already there, we can update the content
+    auto itReact = reactedMessages_.find(msgId);
+
+    if (itReact != reactedMessages_.end()) {
+        auto convertedMap = convertReactMessagetoQVariant(reactedMessages_[msgId]);
+        info.reactions = convertedMap;
+        emitDataChanged(find(msgId), {Role::Reactions});
+    }
+}
+
 QString
 MessageListModel::lastMessageUid() const
 {
@@ -653,12 +726,27 @@ MessageListModel::lastSelfMessageId() const
 {
     for (auto it = interactions_.rbegin(); it != interactions_.rend(); ++it) {
         auto lastType = it->second.type;
-        if (lastType == interaction::Type::TEXT
-            and !it->second.body.isEmpty() and it->second.authorUri.isEmpty()) {
+        if (lastType == interaction::Type::TEXT and !it->second.body.isEmpty()
+            and it->second.authorUri.isEmpty()) {
             return it->first;
         }
     }
     return {};
 }
 
+QString
+MessageListModel::findEmojiReaction(const QString& emoji,
+                                    const QString& authorURI,
+                                    const QString& messageId)
+{
+    auto& messageReactions = reactedMessages_[messageId];
+    for (auto it = messageReactions.begin(); it != messageReactions.end(); it++) {
+        auto interaction = find(*it);
+        if (interaction != interactions_.end() && interaction->second.body == emoji
+            && interaction->second.authorUri == authorURI) {
+            return *it;
+        }
+    }
+    return {};
+}
 } // namespace lrc
diff --git a/src/libclient/messagelistmodel.h b/src/libclient/messagelistmodel.h
index 6c367bf77..2a86affde 100644
--- a/src/libclient/messagelistmodel.h
+++ b/src/libclient/messagelistmodel.h
@@ -48,6 +48,7 @@ struct Info;
     X(LinkPreviewInfo) \
     X(Linkified) \
     X(PreviousBodies) \
+    X(Reactions) \
     X(ReplyTo) \
     X(ReplyToBody) \
     X(ReplyToAuthor) \
@@ -136,10 +137,17 @@ public:
     Q_SIGNAL void timestampUpdate();
 
     void addEdition(const QString& msgId, const interaction::Info& info, bool end);
+    void addReaction(const QString& messageId, const QString& reactionId);
     void editMessage(const QString& msgId, interaction::Info& info);
+    void reactToMessage(const QString& msgId, interaction::Info& info);
+    QVariantMap convertReactMessagetoQVariant(const QSet<QString>&);
     QString lastMessageUid() const;
     QString lastSelfMessageId() const;
 
+    QString findEmojiReaction(const QString& emoji,
+                              const QString& authorURI,
+                              const QString& messageId);
+
 protected:
     using Role = MessageList::Role;
 
@@ -156,6 +164,9 @@ private:
     void updateReplies(item_t& message);
     QMap<QString, QVector<interaction::Body>> editedBodies_;
 
+    // key = messageId and values = QSet of reactionIds
+    QMap<QString, QSet<QString>> reactedMessages_;
+
     void moveMessage(const QString& msgId, const QString& parentId);
     void insertMessage(int index, item_t& message);
     iterator insertMessage(iterator it, item_t& message);
diff --git a/src/libclient/qtwrapper/conversions_wrap.hpp b/src/libclient/qtwrapper/conversions_wrap.hpp
index 7389dc596..9e9dd1fb3 100644
--- a/src/libclient/qtwrapper/conversions_wrap.hpp
+++ b/src/libclient/qtwrapper/conversions_wrap.hpp
@@ -53,6 +53,16 @@ mapStringStringToQVariantMap(const MapStringString& map)
     return convertedMap;
 }
 
+inline QVariantMap
+mapStringIntToQVariantMap(const MapStringInt& map)
+{
+    QVariantMap convertedMap;
+    for (auto i = map.begin(); i != map.end(); i++) {
+        convertedMap.insert(i.key(), i.value());
+    }
+    return convertedMap;
+}
+
 inline MapStringString
 convertMap(const std::map<std::string, std::string>& m)
 {
diff --git a/src/libclient/typedefs.h b/src/libclient/typedefs.h
index 45fd32c84..b84c47eda 100644
--- a/src/libclient/typedefs.h
+++ b/src/libclient/typedefs.h
@@ -47,6 +47,12 @@ constexpr static const char* TEXT_PLAIN = "text/plain";
 constexpr static const char* APPLICATION_GEO = "application/geo";
 constexpr static const char* FALSE_STR = "false";
 
+enum class MessageFlag : int {
+    Text = 0,
+    Reply = 1,
+    Reaction = 2,
+};
+
 // Adapted from libring libjami::DataTransferInfo
 struct DataTransferInfo
 {
-- 
GitLab