From 6f18afbaacf0716d74a8d0129acb4d26a3f90776 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?S=C3=A9bastien=20Blin?=
 <sebastien.blin@savoirfairelinux.com>
Date: Tue, 19 Jul 2022 15:57:44 -0400
Subject: [PATCH] chatview: add ability to reply to a specific message

https://git.jami.net/savoirfairelinux/jami-daemon/-/issues/318
Change-Id: I79916422b90c6eb12252c96bb12e209188a81f94
---
 qml.qrc                                       |   2 +
 src/app/commoncomponents/ReplyToRow.qml       | 104 ++++++++++++++++
 src/app/commoncomponents/SBSContextMenu.qml   |  11 ++
 src/app/commoncomponents/SBSMessageBase.qml   |  71 +++++++----
 .../commoncomponents/TextMessageDelegate.qml  |   3 +-
 src/app/constant/JamiStrings.qml              |   3 +
 src/app/constant/JamiTheme.qml                |   1 -
 src/app/currentconversation.cpp               |  12 +-
 src/app/currentconversation.h                 |   3 +-
 .../mainview/components/ChatViewFooter.qml    |  10 ++
 .../mainview/components/MessageListView.qml   |  13 ++
 .../mainview/components/OngoingCallPage.qml   |   2 +-
 .../mainview/components/ReplyingContainer.qml | 117 ++++++++++++++++++
 src/app/messagesadapter.cpp                   |  49 +++++++-
 src/app/messagesadapter.h                     |   7 +-
 src/libclient/api/conversationmodel.h         |   1 +
 src/libclient/conversationmodel.cpp           |  19 +++
 src/libclient/messagelistmodel.cpp            |  31 +++++
 src/libclient/messagelistmodel.h              |   9 +-
 .../qtwrapper/configurationmanager_wrap.h     |  10 ++
 20 files changed, 441 insertions(+), 37 deletions(-)
 create mode 100644 src/app/commoncomponents/ReplyToRow.qml
 create mode 100644 src/app/mainview/components/ReplyingContainer.qml

diff --git a/qml.qrc b/qml.qrc
index 36590f64e..1d6af2c7d 100644
--- a/qml.qrc
+++ b/qml.qrc
@@ -163,6 +163,7 @@
         <file>src/app/mainview/components/FilesToSendDelegate.qml</file>
         <file>src/app/mainview/components/MessageBar.qml</file>
         <file>src/app/mainview/components/FilesToSendContainer.qml</file>
+        <file>src/app/mainview/components/ReplyingContainer.qml</file>
         <file>src/app/commoncomponents/Avatar.qml</file>
         <file>src/app/mainview/components/ConversationAvatar.qml</file>
         <file>src/app/mainview/components/InvitationView.qml</file>
@@ -178,6 +179,7 @@
         <file>src/app/constant/MsgSeq.qml</file>
         <file>src/app/commoncomponents/SBSContextMenu.qml</file>
         <file>src/app/commoncomponents/SBSMessageBase.qml</file>
+        <file>src/app/commoncomponents/ReplyToRow.qml</file>
         <file>src/app/commoncomponents/ReadStatus.qml</file>
         <file>src/app/commoncomponents/GeneratedMessageDelegate.qml</file>
         <file>src/app/commoncomponents/DataTransferMessageDelegate.qml</file>
diff --git a/src/app/commoncomponents/ReplyToRow.qml b/src/app/commoncomponents/ReplyToRow.qml
new file mode 100644
index 000000000..08c6c6b72
--- /dev/null
+++ b/src/app/commoncomponents/ReplyToRow.qml
@@ -0,0 +1,104 @@
+/*
+ * 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 net.jami.Adapters 1.1
+import net.jami.Constants 1.1
+
+Item {
+    id: root
+    anchors.right: isOutgoing ? parent.right : undefined
+
+    visible: ReplyTo !== ""
+    width: visible ? replyToRow.width : 0
+    height: replyToRow.height + replyToRow.anchors.topMargin 
+
+    MouseArea {
+
+        z: 2
+        anchors.fill: parent
+        RowLayout {
+            id: replyToRow
+            anchors.top: parent.top
+            anchors.topMargin: JamiTheme.preferredMarginSize / 2
+
+            property bool isSelf: ReplyToAuthor === CurrentAccount.uri || ReplyToAuthor === ""
+
+            onVisibleChanged: {
+                if (visible) {
+                    // Make sure we show the original post
+                    // In the future, we may just want to load the previous interaction of the thread
+                    // and not show it, but for now we can simplify.
+                    MessagesAdapter.loadConversationUntil(ReplyTo)
+                }
+            }
+
+            Label {
+                id: replyTo
+
+                text: JamiStrings.inReplyTo
+
+                color: UtilsAdapter.luma(bubble.color) ?
+                    JamiTheme.chatviewTextColorLight :
+                    JamiTheme.chatviewTextColorDark
+                font.pointSize: JamiTheme.textFontSize
+                font.kerning: true
+                font.bold: true
+                Layout.leftMargin: JamiTheme.preferredMarginSize
+            }
+
+            Avatar {
+                id: avatarReply
+
+                Layout.preferredWidth: JamiTheme.avatarReadReceiptSize
+                Layout.preferredHeight: JamiTheme.avatarReadReceiptSize
+
+                showPresenceIndicator: false
+
+                imageId: {
+                    if (replyToRow.isSelf)
+                        return CurrentAccount.id
+                    return ReplyToAuthor
+                }
+                mode: replyToRow.isSelf ? Avatar.Mode.Account : Avatar.Mode.Contact
+            }
+
+            Text {
+                id: body
+                Layout.maximumWidth: JamiTheme.preferredFieldWidth - JamiTheme.preferredMarginSize
+                Layout.rightMargin: JamiTheme.preferredMarginSize
+
+                text: ReplyToBody
+                elide: Text.ElideRight
+
+                color:  UtilsAdapter.luma(bubble.color) ?
+                    JamiTheme.chatviewTextColorLight :
+                    JamiTheme.chatviewTextColorDark
+                font.pointSize: JamiTheme.textFontSize
+                font.kerning: true
+                font.bold: true
+            }
+        }
+
+        onClicked: function(mouse) {
+            CurrentConversation.scrollToMsg(ReplyTo)
+        }
+    }
+}
diff --git a/src/app/commoncomponents/SBSContextMenu.qml b/src/app/commoncomponents/SBSContextMenu.qml
index 4af744cd4..8c6f0f094 100644
--- a/src/app/commoncomponents/SBSContextMenu.qml
+++ b/src/app/commoncomponents/SBSContextMenu.qml
@@ -29,6 +29,7 @@ ContextMenuAutoLoader {
     id: root
 
     property string location
+    property string msgId
     property string transferName
     property string transferId
 
@@ -36,6 +37,7 @@ ContextMenuAutoLoader {
         GeneralMenuItem {
             id: saveFile
 
+            canTrigger: root.transferId !== ""
             itemName: JamiStrings.saveFile
             onClicked: {
                 MessagesAdapter.copyToDownloads(root.transferId, root.transferName)
@@ -44,10 +46,19 @@ ContextMenuAutoLoader {
         GeneralMenuItem {
             id: openLocation
 
+            canTrigger: root.transferId !== ""
             itemName: JamiStrings.openLocation
             onClicked: {
                 MessagesAdapter.openDirectory(root.location)
             }
+        },
+        GeneralMenuItem {
+            id: reply
+
+            itemName: JamiStrings.reply
+            onClicked: {
+                MessagesAdapter.replyToId = root.msgId
+            }
         }
     ]
 
diff --git a/src/app/commoncomponents/SBSMessageBase.qml b/src/app/commoncomponents/SBSMessageBase.qml
index 825c663f6..b4b075435 100644
--- a/src/app/commoncomponents/SBSMessageBase.qml
+++ b/src/app/commoncomponents/SBSMessageBase.qml
@@ -32,6 +32,7 @@ Control {
     property alias avatarBlockWidth: avatarBlock.width
     property alias innerContent: innerContent
     property alias bubble: bubble
+    property alias selectAnimation: selectAnimation
     property real extraHeight: 0
 
     // these MUST be set but we won't use the 'required' keyword yet
@@ -44,6 +45,7 @@ Control {
     property string transferName
     property string formattedTime
     property string location
+    property string id: Id
     property string hoveredLink
     property var readers: []
 
@@ -89,7 +91,6 @@ Control {
             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
@@ -100,34 +101,74 @@ Control {
                     anchors.bottom: parent.bottom
                     width: avatarSize
                     height: avatarSize
-                    imageId: author
+                    imageId: root.author
                     showPresenceIndicator: false
                     mode: Avatar.Mode.Contact
                 }
             }
-            Item {
+
+
+            MouseArea {
+                id: itemMouseArea
+            
                 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)
+                    }
+                }
+
                 Column {
                     id: innerContent
                     width: parent.width
 
                     // place actual content here
+                    ReplyToRow {}
                 }
+
                 MessageBubble {
                     id: bubble
                     z:-1
                     out: isOutgoing
                     type: seq
-                    color: isOutgoing ?
-                               JamiTheme.messageOutBgColor :
-                               CurrentConversation.isCoreDialog ? JamiTheme.messageInBgColor : Qt.lighter(CurrentConversation.color, 1.5)
+                    function getBaseColor() {
+                        var baseColor = isOutgoing ? JamiTheme.messageOutBgColor
+                                            : CurrentConversation.isCoreDialog ?
+                                                JamiTheme.messageInBgColor : Qt.lighter(CurrentConversation.color, 1.5)
+                        if (Id === MessagesAdapter.replyToId) {
+                            // If we are replying to
+                            return Qt.darker(baseColor, 1.5)
+                        }
+                        return baseColor
+                    }
+                    color: getBaseColor()
                     radius: msgRadius
                     anchors.right: isOutgoing ? parent.right : undefined
                     anchors.top: parent.top
                     width: innerContent.childrenRect.width
                     height: innerContent.childrenRect.height + (visible ? root.extraHeight : 0)
                 }
+
+                SequentialAnimation {
+                    id: selectAnimation
+                    ColorAnimation {
+                        target: bubble; property: "color"
+                        to: Qt.darker(bubble.getBaseColor(), 1.5); duration: 240
+                    }
+                    ColorAnimation {
+                        target: bubble; property: "color"
+                        to: bubble.getBaseColor(); duration: 240
+                    }
+                }
             }
 
             Item {
@@ -220,25 +261,9 @@ Control {
     SBSContextMenu {
         id: ctxMenu
 
+        msgId: Id
         location: root.location
         transferId: root.transferId
         transferName: root.transferName
     }
-
-    MouseArea {
-        id: itemMouseArea
-        anchors.fill: parent
-        z: -1
-        acceptedButtons: Qt.LeftButton | Qt.RightButton
-        onClicked: function (mouse) {
-
-            if (mouse.button === Qt.RightButton && transferId !== "") {
-                // Context Menu for Transfers
-                ctxMenu.x = mouse.x
-                ctxMenu.y = mouse.y
-                ctxMenu.openMenu()
-            } else if (root.hoveredLink)
-                MessagesAdapter.openUrl(root.hoveredLink)
-        }
-    }
 }
diff --git a/src/app/commoncomponents/TextMessageDelegate.qml b/src/app/commoncomponents/TextMessageDelegate.qml
index ab98565d4..35b95d2fb 100644
--- a/src/app/commoncomponents/TextMessageDelegate.qml
+++ b/src/app/commoncomponents/TextMessageDelegate.qml
@@ -43,7 +43,7 @@ SBSMessageBase {
     innerContent.children: [
         TextEdit {
 
-            padding: JamiTheme.chatviewPadding
+            padding: JamiTheme.preferredMarginSize
             anchors.right: isOutgoing ? parent.right : undefined
 
             text: Body
@@ -76,6 +76,7 @@ SBSMessageBase {
                        JamiTheme.chatviewTextColorDark
 
             TapHandler {
+                enabled: parent.selectedText.length > 0
                 acceptedButtons: Qt.RightButton
                 onTapped: function onTapped(eventPoint) {
                     ctxMenu.openMenuAt(eventPoint.position)
diff --git a/src/app/constant/JamiStrings.qml b/src/app/constant/JamiStrings.qml
index 8690a1e2f..32af891a0 100644
--- a/src/app/constant/JamiStrings.qml
+++ b/src/app/constant/JamiStrings.qml
@@ -719,6 +719,9 @@ Item {
     property string leaveVideoMessage: qsTr("Leave video message")
     property string send: qsTr("Send")
     property string remove: qsTr("Remove")
+    property string replyTo: qsTr("Reply to")
+    property string inReplyTo: qsTr("In reply to")
+    property string reply: qsTr("Reply")
     property string writeTo: qsTr("Write to %1")
 
     // Invitation View
diff --git a/src/app/constant/JamiTheme.qml b/src/app/constant/JamiTheme.qml
index 3b745e022..5a1cb4a73 100644
--- a/src/app/constant/JamiTheme.qml
+++ b/src/app/constant/JamiTheme.qml
@@ -334,7 +334,6 @@ Item {
 
     // MessageWebView
     property real chatViewHairLineSize: 1
-    property real chatviewPadding : 16
     property real chatViewMaximumWidth: 900
     property real chatViewHeaderPreferredHeight: 64
     property real chatViewHeaderMinimumWidth: 200
diff --git a/src/app/currentconversation.cpp b/src/app/currentconversation.cpp
index 0038bc9cf..42c9a9fcc 100644
--- a/src/app/currentconversation.cpp
+++ b/src/app/currentconversation.cpp
@@ -167,7 +167,7 @@ CurrentConversation::updateErrors(const QString& convId)
             auto& convInfo = optConv->get();
             QStringList newErrors;
             QStringList newBackendErr;
-            for (const auto& [code, error]: convInfo.errors) {
+            for (const auto& [code, error] : convInfo.errors) {
                 if (code == 1) {
                     newErrors.append(tr("An error occurred while fetching this repository"));
                 } else if (code == 2) {
@@ -175,7 +175,8 @@ CurrentConversation::updateErrors(const QString& convId)
                 } else if (code == 3) {
                     newErrors.append(tr("An invalid message was detected"));
                 } else if (code == 4) {
-                    newErrors.append(tr("Not enough authorization for updating conversation's infos"));
+                    newErrors.append(
+                        tr("Not enough authorization for updating conversation's infos"));
                 } else {
                     continue;
                 }
@@ -185,6 +186,11 @@ CurrentConversation::updateErrors(const QString& convId)
             set_errors(newErrors);
         }
     } catch (...) {
-
     }
 }
+
+void
+CurrentConversation::scrollToMsg(const QString& msg)
+{
+    Q_EMIT scrollTo(msg);
+}
diff --git a/src/app/currentconversation.h b/src/app/currentconversation.h
index 399f19dd4..7ccbf511e 100644
--- a/src/app/currentconversation.h
+++ b/src/app/currentconversation.h
@@ -54,10 +54,11 @@ class CurrentConversation final : public QObject
 public:
     explicit CurrentConversation(LRCInstance* lrcInstance, QObject* parent = nullptr);
     ~CurrentConversation() = default;
-
+    Q_INVOKABLE void scrollToMsg(const QString& msgId);
     Q_INVOKABLE void showSwarmDetails() const;
 
 Q_SIGNALS:
+    void scrollTo(const QString& msgId);
     void showDetails() const;
 
 private Q_SLOTS:
diff --git a/src/app/mainview/components/ChatViewFooter.qml b/src/app/mainview/components/ChatViewFooter.qml
index ae97243d5..25d7a71cf 100644
--- a/src/app/mainview/components/ChatViewFooter.qml
+++ b/src/app/mainview/components/ChatViewFooter.qml
@@ -121,6 +121,16 @@ Rectangle {
 
         spacing: 0
 
+        ReplyingContainer {
+            id: replyingContainer
+
+            Layout.alignment: Qt.AlignHCenter
+            Layout.preferredWidth: footerColumnLayout.width
+            Layout.maximumWidth: JamiTheme.chatViewMaximumWidth
+            Layout.preferredHeight: 36
+            visible: MessagesAdapter.replyToId !== ""
+        }
+
         MessageBar {
             id: messageBar
 
diff --git a/src/app/mainview/components/MessageListView.qml b/src/app/mainview/components/MessageListView.qml
index eba5bfd5c..656b9a95d 100644
--- a/src/app/mainview/components/MessageListView.qml
+++ b/src/app/mainview/components/MessageListView.qml
@@ -165,6 +165,19 @@ JamiListView {
     Connections {
         target: CurrentConversation
         function onIdChanged() { fadeAnimation.start() }
+        function onScrollTo(id) {
+            var idx = -1
+            for (var i = 1; i < root.count; i++) {
+                var delegate = root.itemAtIndex(i)
+                if (delegate && delegate.id === id) {
+                    idx = i
+                }
+            }
+            positionViewAtIndex(idx, ListView.Center)
+            var delegate = root.itemAtIndex(idx)
+            if (delegate.selectAnimation)
+                delegate.selectAnimation.start()
+        }
     }
 
     topMargin: 12
diff --git a/src/app/mainview/components/OngoingCallPage.qml b/src/app/mainview/components/OngoingCallPage.qml
index 72cc19c04..affad4e69 100644
--- a/src/app/mainview/components/OngoingCallPage.qml
+++ b/src/app/mainview/components/OngoingCallPage.qml
@@ -333,7 +333,7 @@ Rectangle {
                     target: MessagesAdapter
                     enabled: root.visible
 
-                    function onNewInteraction(interactionType) {
+                    function onNewInteraction(id, interactionType) {
                         // Ignore call notifications, as we are in the call.
                         if (interactionType !== Interaction.Type.CALL && !inCallMessageWebViewStack.visible)
                             openInCallConversation()
diff --git a/src/app/mainview/components/ReplyingContainer.qml b/src/app/mainview/components/ReplyingContainer.qml
new file mode 100644
index 000000000..0489888b5
--- /dev/null
+++ b/src/app/mainview/components/ReplyingContainer.qml
@@ -0,0 +1,117 @@
+/*
+ * 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 isSelf: false
+    property var author: {
+        if (MessagesAdapter.replyToId === "")
+            return ""
+
+        var author = MessagesAdapter.dataForInteraction(MessagesAdapter.replyToId, MessageList.Author)
+        isSelf = author === "" || author === undefined
+        if (isSelf) {
+            avatar.mode = Avatar.Mode.Account
+            avatar.imageId = CurrentAccount.id
+        } else {
+            avatar.mode = Avatar.Mode.Contact
+            avatar.imageId = author
+        }
+        return isSelf ? CurrentAccount.uri : author
+    }
+
+    RowLayout {
+        anchors.fill: parent
+        spacing: 12
+
+        RowLayout {
+            Layout.alignment: Qt.AlignVCenter | Qt.AlignLeft
+
+            Label {
+                id: replyTo
+
+                text: JamiStrings.replyTo
+
+                color:  UtilsAdapter.luma(root.color) ?
+                            JamiTheme.chatviewTextColorLight :
+                            JamiTheme.chatviewTextColorDark
+                font.pointSize: JamiTheme.textFontSize
+                font.kerning: true
+                font.bold: true
+                Layout.leftMargin: JamiTheme.preferredMarginSize
+            }
+
+            Avatar {
+                id: avatar
+
+                Layout.preferredWidth: JamiTheme.avatarReadReceiptSize
+                Layout.preferredHeight: JamiTheme.avatarReadReceiptSize
+
+                showPresenceIndicator: false
+
+                imageId: ""
+                mode: Avatar.Mode.Account
+            }
+
+            Label {
+                id: username
+
+                text: author === CurrentAccount.uri ?
+                            CurrentAccount.bestName
+                            : UtilsAdapter.getBestNameForUri(CurrentAccount.id, author)
+
+                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.replyToId = ""
+        }
+    }
+}
diff --git a/src/app/messagesadapter.cpp b/src/app/messagesadapter.cpp
index 5ea91cdda..c715b91c7 100644
--- a/src/app/messagesadapter.cpp
+++ b/src/app/messagesadapter.cpp
@@ -52,6 +52,7 @@ MessagesAdapter::MessagesAdapter(AppSettingsManager* settingsManager,
     , filteredMsgListModel_(new FilteredMsgListModel(this))
 {
     connect(lrcInstance_, &LRCInstance::selectedConvUidChanged, [this]() {
+        set_replyToId("");
         const QString& convId = lrcInstance_->get_selectedConvUid();
         const auto& conversation = lrcInstance_->getConversationFromConvUid(convId);
         filteredMsgListModel_->setSourceModel(conversation.interactions.get());
@@ -100,6 +101,23 @@ MessagesAdapter::loadMoreMessages()
     }
 }
 
+void
+MessagesAdapter::loadConversationUntil(const QString& to)
+{
+    if (auto* model = static_cast<MessageListModel*>(filteredMsgListModel_->sourceModel())) {
+        auto idx = model->indexOfMessage(to);
+        if (idx == -1) {
+            auto accountId = lrcInstance_->get_currentAccountId();
+            auto convId = lrcInstance_->get_selectedConvUid();
+            const auto& convInfo = lrcInstance_->getConversationFromConvUid(convId, accountId);
+            if (convInfo.isSwarm()) {
+                auto* convModel = lrcInstance_->getCurrentConversationModel();
+                convModel->loadConversationUntil(convId, to);
+            }
+        }
+    }
+}
+
 void
 MessagesAdapter::connectConversationModel()
 {
@@ -135,7 +153,8 @@ MessagesAdapter::sendMessage(const QString& message)
 {
     try {
         const auto convUid = lrcInstance_->get_selectedConvUid();
-        lrcInstance_->getCurrentConversationModel()->sendMessage(convUid, message);
+        lrcInstance_->getCurrentConversationModel()->sendMessage(convUid, message, replyToId_);
+        set_replyToId("");
     } catch (...) {
         qDebug() << "Exception during sendMessage:" << message;
     }
@@ -302,6 +321,26 @@ MessagesAdapter::getTransferStats(const QString& msgId, int status)
     return {{"totalSize", qint64(info.totalSize)}, {"progress", qint64(info.progress)}};
 }
 
+QVariant
+MessagesAdapter::dataForInteraction(const QString& interactionId, int role) const
+{
+    if (auto* model = static_cast<MessageListModel*>(filteredMsgListModel_->sourceModel())) {
+        auto idx = model->indexOfMessage(interactionId);
+        if (idx != -1)
+            return model->data(idx, role);
+    }
+    return {};
+}
+
+int
+MessagesAdapter::getIndexOfMessage(const QString& interactionId) const
+{
+    if (auto* model = static_cast<MessageListModel*>(filteredMsgListModel_->sourceModel())) {
+        return model->indexOfMessage(interactionId);
+    }
+    return {};
+}
+
 void
 MessagesAdapter::userIsComposing(bool isComposing)
 {
@@ -327,7 +366,7 @@ MessagesAdapter::onNewInteraction(const QString& convUid,
         auto& accountInfo = lrcInstance_->getAccountInfo(accountId);
         auto& convModel = accountInfo.conversationModel;
         convModel->clearUnreadInteractions(convUid);
-        Q_EMIT newInteraction(static_cast<int>(interaction.type));
+        Q_EMIT newInteraction(interactionId, static_cast<int>(interaction.type));
     } catch (...) {
     }
 }
@@ -486,8 +525,10 @@ MessagesAdapter::isLocalImage(const QString& mimename)
         QImageReader reader;
         QList<QByteArray> supportedFormats = reader.supportedImageFormats();
         auto iterator = std::find_if(supportedFormats.begin(),
-                                    supportedFormats.end(),
-                                    [fileFormat](QByteArray format) { return format == fileFormat; });
+                                     supportedFormats.end(),
+                                     [fileFormat](QByteArray format) {
+                                         return format == fileFormat;
+                                     });
         return {{"isImage", iterator != supportedFormats.end()}};
     }
     return {{"isImage", false}};
diff --git a/src/app/messagesadapter.h b/src/app/messagesadapter.h
index 2473fcbb2..26afb48a9 100644
--- a/src/app/messagesadapter.h
+++ b/src/app/messagesadapter.h
@@ -58,6 +58,7 @@ class MessagesAdapter final : public QmlAdapterBase
 {
     Q_OBJECT
     QML_RO_PROPERTY(QVariant, messageListModel)
+    QML_PROPERTY(QString, replyToId)
     QML_RO_PROPERTY(QList<QString>, currentConvComposingList)
 
 public:
@@ -68,7 +69,7 @@ public:
     ~MessagesAdapter() = default;
 
 Q_SIGNALS:
-    void newInteraction(int type);
+    void newInteraction(const QString& id, int type);
     void newMessageBarPlaceholderText(QString placeholderText);
     void newFilePasted(QString filePath);
     void newTextPasted();
@@ -80,6 +81,7 @@ protected:
 
     Q_INVOKABLE void setupChatView(const QVariantMap& convInfo);
     Q_INVOKABLE void loadMoreMessages();
+    Q_INVOKABLE void loadConversationUntil(const QString& to);
     Q_INVOKABLE void connectConversationModel();
     Q_INVOKABLE void sendConversationRequest();
     Q_INVOKABLE void removeConversation(const QString& convUid);
@@ -109,8 +111,11 @@ protected:
                                       const QString& msg,
                                       bool showPreview);
     Q_INVOKABLE void onPaste();
+    Q_INVOKABLE int getIndexOfMessage(const QString& messageId) const;
     Q_INVOKABLE QString getStatusString(int status);
     Q_INVOKABLE QVariantMap getTransferStats(const QString& messageId, int);
+    Q_INVOKABLE QVariant dataForInteraction(const QString& interactionId,
+                                            int role = Qt::DisplayRole) const;
 
     // Run corrsponding js functions, c++ to qml.
     void setMessagesImageContent(const QString& path, bool isBased64 = false);
diff --git a/src/libclient/api/conversationmodel.h b/src/libclient/api/conversationmodel.h
index 7da8d5aa0..f0307d969 100644
--- a/src/libclient/api/conversationmodel.h
+++ b/src/libclient/api/conversationmodel.h
@@ -313,6 +313,7 @@ public:
      * @return id for loading request. -1 if not loaded
      */
     int loadConversationMessages(const QString& conversationId, const int size = 1);
+    int loadConversationUntil(const QString& conversationId, const QString& to);
     /**
      * accept request for conversation
      * @param conversationId conversation's id
diff --git a/src/libclient/conversationmodel.cpp b/src/libclient/conversationmodel.cpp
index 278ff26e1..9c7430ef5 100644
--- a/src/libclient/conversationmodel.cpp
+++ b/src/libclient/conversationmodel.cpp
@@ -1630,6 +1630,25 @@ ConversationModel::loadConversationMessages(const QString& conversationId, const
                                                                      size);
 }
 
+int
+ConversationModel::loadConversationUntil(const QString& conversationId, const QString& to)
+{
+    auto conversationOpt = getConversationForUid(conversationId);
+    if (!conversationOpt.has_value()) {
+        return -1;
+    }
+    auto& conversation = conversationOpt->get();
+    if (conversation.allMessagesLoaded) {
+        return -1;
+    }
+    auto lastMsgId = conversation.interactions->empty() ? ""
+                                                        : conversation.interactions->front().first;
+    return ConfigurationManager::instance().loadConversationUntil(owner.id,
+                                                                  conversationId,
+                                                                  lastMsgId,
+                                                                  to);
+}
+
 void
 ConversationModel::acceptConversationRequest(const QString& conversationId)
 {
diff --git a/src/libclient/messagelistmodel.cpp b/src/libclient/messagelistmodel.cpp
index ac79134b5..b81c3e617 100644
--- a/src/libclient/messagelistmodel.cpp
+++ b/src/libclient/messagelistmodel.cpp
@@ -171,6 +171,7 @@ MessageListModel::clear()
 {
     Q_EMIT beginResetModel();
     interactions_.clear();
+    replyTo_.clear();
     Q_EMIT endResetModel();
 }
 
@@ -289,6 +290,18 @@ MessageListModel::insertMessage(int index, item_t& message)
     Q_EMIT beginInsertRows(QModelIndex(), index, index);
     interactions_.insert(index, message);
     Q_EMIT endInsertRows();
+    auto replyId = message.second.commit["reply-to"];
+    auto commitId = message.second.commit["id"];
+    if (!replyId.isEmpty())
+        replyTo_[replyId].append(commitId);
+    for (const auto& msgId : replyTo_[commitId]) {
+        int index = getIndexOfMessage(msgId);
+        if (index == -1)
+            continue;
+        QModelIndex modelIndex = QAbstractListModel::index(index, 0);
+        Q_EMIT dataChanged(modelIndex, modelIndex, {Role::ReplyToAuthor});
+        Q_EMIT dataChanged(modelIndex, modelIndex, {Role::ReplyToBody});
+    }
 }
 
 iterator
@@ -343,6 +356,8 @@ MessageListModel::roleNames() const
 QVariant
 MessageListModel::dataForItem(item_t item, int, int role) const
 {
+    auto replyId = item.second.commit["reply-to"];
+    auto repliedMsg = getIndexOfMessage(replyId);
     switch (role) {
     case Role::Id:
         return QVariant(item.first);
@@ -368,6 +383,12 @@ 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::ReplyTo:
+        return QVariant(replyId);
+    case Role::ReplyToAuthor:
+        return repliedMsg == -1 ? QVariant("") : QVariant(data(repliedMsg, Role::Author));
+    case Role::ReplyToBody:
+        return repliedMsg == -1 ? QVariant("") : QVariant(data(repliedMsg, Role::Body));
     case Role::TransferName:
         return QVariant(item.second.commit["displayName"]);
     case Role::Readers:
@@ -377,6 +398,16 @@ MessageListModel::dataForItem(item_t item, int, int role) const
     }
 }
 
+QVariant
+MessageListModel::data(int idx, int role) const
+{
+    QModelIndex index = QAbstractListModel::index(idx, 0);
+    if (!index.isValid() || index.row() < 0 || index.row() >= rowCount()) {
+        return {};
+    }
+    return dataForItem(interactions_.at(index.row()), index.row(), role);
+}
+
 QVariant
 MessageListModel::data(const QModelIndex& index, int role) const
 {
diff --git a/src/libclient/messagelistmodel.h b/src/libclient/messagelistmodel.h
index 3d36e577c..d4137beec 100644
--- a/src/libclient/messagelistmodel.h
+++ b/src/libclient/messagelistmodel.h
@@ -44,6 +44,9 @@ struct Info;
     X(ActionUri) \
     X(LinkPreviewInfo) \
     X(Linkified) \
+    X(ReplyTo) \
+    X(ReplyToBody) \
+    X(ReplyToAuthor) \
     X(TransferName) \
     X(Readers)
 
@@ -89,7 +92,7 @@ public:
     iterator begin();
     constIterator begin() const;
     reverseIterator rbegin();
-    int size() const;
+    Q_INVOKABLE int size() const;
     void clear();
     bool empty() const;
     interaction::Info at(const QString& intId) const;
@@ -102,7 +105,8 @@ public:
     void moveMessages(QList<QString> msgIds, const QString& parentId);
 
     int rowCount(const QModelIndex& parent = QModelIndex()) const override;
-    virtual QVariant data(const QModelIndex& index, int role = Qt::DisplayRole) const;
+    Q_INVOKABLE virtual QVariant data(const QModelIndex& index, int role = Qt::DisplayRole) const;
+    Q_INVOKABLE virtual QVariant data(int idx, int role = Qt::DisplayRole) const;
     QHash<int, QByteArray> roleNames() const override;
     QVariant dataForItem(item_t item, int indexRow, int role = Qt::DisplayRole) const;
     bool contains(const QString& msgId);
@@ -132,6 +136,7 @@ private:
     // to allow quick access.
     QMap<QString, QString> lastDisplayedMessageUid_;
     QMap<QString, QStringList> messageToReaders_;
+    QMap<QString, QStringList> replyTo_;
 
     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 ad287fc94..ae1093b29 100644
--- a/src/libclient/qtwrapper/configurationmanager_wrap.h
+++ b/src/libclient/qtwrapper/configurationmanager_wrap.h
@@ -1056,6 +1056,16 @@ public Q_SLOTS: // METHODS
                                                fromId.toStdString(),
                                                size);
     }
+    uint32_t loadConversationUntil(const QString& accountId,
+                                   const QString& conversationId,
+                                   const QString& fromId,
+                                   const QString& toId)
+    {
+        return DRing::loadConversationUntil(accountId.toStdString(),
+                                            conversationId.toStdString(),
+                                            fromId.toStdString(),
+                                            toId.toStdString());
+    }
 
     void setDefaultModerator(const QString& accountID, const QString& peerURI, const bool& state)
     {
-- 
GitLab