From e85d4506dec0f82af8b9a2966ff528cae4881690 Mon Sep 17 00:00:00 2001
From: Andreas Traczyk <andreas.traczyk@savoirfairelinux.com>
Date: Mon, 27 Sep 2021 10:29:07 -0400
Subject: [PATCH] chatview: implement side-by-side styling

+ fix multiline richtext
+ add SBS msg bubbles
+ limit timestamp visibility
+ render link previews
+ render data transfer interactions

Gitlab: #467
Change-Id: I80b6db33d786180d479730213855b9816bea4793
---
 qml.qrc                                       |   3 +
 .../icons/baseline_error_outline_24dp.svg     |   1 -
 resources/icons/close_black_24dp.svg          |   1 +
 resources/icons/download_black_24dp.svg       |   1 +
 resources/icons/error_outline_black_24dp.svg  |   1 +
 .../AccountMigrationDialog.qml                |   2 +-
 src/commoncomponents/MessageBubble.qml        |  46 ++
 src/commoncomponents/MessageDelegate.qml      | 617 ++++++++++++++----
 src/commoncomponents/SBSMessageBase.qml       | 115 ++++
 src/constant/JamiTheme.qml                    |   2 +-
 src/constant/MsgSeq.qml                       |   9 +
 src/mainview/components/MessageListView.qml   | 122 +++-
 src/messagesadapter.cpp                       |  95 ++-
 src/messagesadapter.h                         |  12 +-
 src/qmlregister.cpp                           |   1 +
 15 files changed, 888 insertions(+), 140 deletions(-)
 delete mode 100644 resources/icons/baseline_error_outline_24dp.svg
 create mode 100644 resources/icons/close_black_24dp.svg
 create mode 100644 resources/icons/download_black_24dp.svg
 create mode 100644 resources/icons/error_outline_black_24dp.svg
 create mode 100644 src/commoncomponents/MessageBubble.qml
 create mode 100644 src/commoncomponents/SBSMessageBase.qml
 create mode 100644 src/constant/MsgSeq.qml

diff --git a/qml.qrc b/qml.qrc
index 8597566ea..67fb8da2c 100644
--- a/qml.qrc
+++ b/qml.qrc
@@ -163,5 +163,8 @@
         <file>src/mainview/components/ReadOnlyFooter.qml</file>
         <file>src/commoncomponents/MessageDelegate.qml</file>
         <file>src/mainview/components/MessageListView.qml</file>
+        <file>src/commoncomponents/MessageBubble.qml</file>
+        <file>src/constant/MsgSeq.qml</file>
+        <file>src/commoncomponents/SBSMessageBase.qml</file>
     </qresource>
 </RCC>
diff --git a/resources/icons/baseline_error_outline_24dp.svg b/resources/icons/baseline_error_outline_24dp.svg
deleted file mode 100644
index 39062fa75..000000000
--- a/resources/icons/baseline_error_outline_24dp.svg
+++ /dev/null
@@ -1 +0,0 @@
-<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path fill="none" d="M0 0h24v24H0V0z"/><path d="M11 15h2v2h-2zm0-8h2v6h-2zm.99-5C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zM12 20c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8z"/></svg>
\ No newline at end of file
diff --git a/resources/icons/close_black_24dp.svg b/resources/icons/close_black_24dp.svg
new file mode 100644
index 000000000..5f1267d71
--- /dev/null
+++ b/resources/icons/close_black_24dp.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 0 24 24" width="24px" fill="#000000"><path d="M0 0h24v24H0V0z" fill="none"/><path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12 19 6.41z"/></svg>
\ No newline at end of file
diff --git a/resources/icons/download_black_24dp.svg b/resources/icons/download_black_24dp.svg
new file mode 100644
index 000000000..aef604333
--- /dev/null
+++ b/resources/icons/download_black_24dp.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 0 24 24" width="24px" fill="#000000"><path d="M0 0h24v24H0V0z" fill="none"/><path d="M19 9h-4V3H9v6H5l7 7 7-7zm-8 2V5h2v6h1.17L12 13.17 9.83 11H11zm-6 7h14v2H5z"/></svg>
\ No newline at end of file
diff --git a/resources/icons/error_outline_black_24dp.svg b/resources/icons/error_outline_black_24dp.svg
new file mode 100644
index 000000000..a0481c0d6
--- /dev/null
+++ b/resources/icons/error_outline_black_24dp.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 0 24 24" width="24px" fill="#000000"><path d="M11 15h2v2h-2v-2zm0-8h2v6h-2V7zm.99-5C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zM12 20c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8z"/></svg>
\ No newline at end of file
diff --git a/src/commoncomponents/AccountMigrationDialog.qml b/src/commoncomponents/AccountMigrationDialog.qml
index 04db7cb1b..04ddf73b8 100644
--- a/src/commoncomponents/AccountMigrationDialog.qml
+++ b/src/commoncomponents/AccountMigrationDialog.qml
@@ -518,7 +518,7 @@ Window {
                                                                         import \"qrc:/src/constant/\";
                                                                         Image {
                                                                         anchors.fill: parent;
-                                                                        source: JamiResources.baseline_error_outline_24dp_svg;
+                                                                        source: JamiResources.error_outline_black_24dp_svg;
                                                                         mipmap: true;}", spinnerLabel)
                                     break
                                 }
diff --git a/src/commoncomponents/MessageBubble.qml b/src/commoncomponents/MessageBubble.qml
new file mode 100644
index 000000000..621769160
--- /dev/null
+++ b/src/commoncomponents/MessageBubble.qml
@@ -0,0 +1,46 @@
+/*
+ * Copyright (C) 2021 by Savoir-faire Linux
+ * Author: Andreas Traczyk <andreas.traczyk@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 2.15
+import QtGraphicalEffects 1.0
+
+import net.jami.Constants 1.1
+
+Rectangle {
+    id: root
+
+    property bool out: true
+    property int type: MsgSeq.single
+
+    Rectangle {
+        id: mask
+
+        visible: type !== MsgSeq.single
+        z: -1
+        radius: 2
+        color: root.color
+
+        anchors {
+            fill: parent
+            leftMargin: out ? root.width - root.radius : 0
+            rightMargin: out ? 0 : root.width - root.radius
+            topMargin: type === MsgSeq.first ? root.height - root.radius : 0
+            bottomMargin: type === MsgSeq.last ? root.height - root.radius : 0
+        }
+    }
+}
diff --git a/src/commoncomponents/MessageDelegate.qml b/src/commoncomponents/MessageDelegate.qml
index fca345efa..210530e95 100644
--- a/src/commoncomponents/MessageDelegate.qml
+++ b/src/commoncomponents/MessageDelegate.qml
@@ -1,6 +1,26 @@
-import QtQuick 2.15
+/*
+ * Copyright (C) 2021 by Savoir-faire Linux
+ * Author: Trevor Tabah <trevor.tabah@savoirfairelinux.com>
+ * Author: Andreas Traczyk <andreas.traczyk@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 2.15
 import QtQuick.Controls 2.15
 import QtQuick.Layouts 1.15
+import QtGraphicalEffects 1.15
 import QtWebEngine 1.10
 
 import net.jami.Models 1.1
@@ -10,33 +30,213 @@ import net.jami.Constants 1.1
 Control {
     id: root
 
+    readonly property ListView listView: ListView.view
+
     readonly property bool isGenerated: Type === Interaction.Type.CALL ||
                                         Type === Interaction.Type.CONTACT
     readonly property string author: Author
+    readonly property var body: Body
     readonly property var timestamp: Timestamp
     readonly property bool isOutgoing: model.Author === ""
     readonly property var formattedTime: MessagesAdapter.getFormattedTime(Timestamp)
-    readonly property bool isImage: MessagesAdapter.isImage(Body)
-    readonly property bool isAnimatedImage: MessagesAdapter.isAnimatedImage(Body)
-    readonly property var linkPreviewInfo: LinkPreviewInfo
+    readonly property var linkInfo: LinkPreviewInfo
+    property var mediaInfo
 
-    readonly property var body: Body
-    readonly property real msgMargin: 64
+    readonly property real senderMargin: 64
+    readonly property real avatarSize: 32
+    readonly property real msgRadius: 18
+    readonly property real hMargin: 12
+
+    property bool showTime: false
+    property int seq: MsgSeq.single
 
     width: parent ? parent.width : 0
     height: loader.height
 
+    // message interaction
+    property string hoveredLink
+    MouseArea {
+        id: itemMouseArea
+        anchors.fill: parent
+        acceptedButtons: Qt.LeftButton
+        onClicked: {
+            if (root.hoveredLink)
+                Qt.openUrlExternally(root.hoveredLink)
+        }
+    }
+
     Loader {
         id: loader
 
-        property alias isOutgoing: root.isOutgoing
-        property alias isGenerated: root.isGenerated
-        readonly property var author: Author
-        readonly property var body: Body
+        width: root.width
+        height: sourceComponent.height
+
+        sourceComponent: {
+            switch (Type) {
+            case Interaction.Type.TEXT: return textMsgComp
+            case Interaction.Type.CALL:
+            case Interaction.Type.CONTACT: return generatedMsgComp
+            case Interaction.Type.DATA_TRANSFER:
+                if (Status === Interaction.Status.TRANSFER_FINISHED) {
+                    mediaInfo = MessagesAdapter.getMediaInfo(Body)
+                    if (Object.keys(mediaInfo).length !== 0)
+                        return localMediaMsgComp
+                }
+                return dataTransferMsgComp
+            default:
+                // if this happens, adjust FilteredMsgListModel
+                console.warn("Invalid message type has not been filtered.")
+                return null
+            }
+        }
+    }
 
-        sourceComponent: isGenerated ?
-                             generatedMsgComp :
-                             userMsgComp
+    Component {
+        id: textMsgComp
+
+        SBSMessageBase {
+            property real maxMsgWidth: root.width - senderMargin - 2 * hMargin - avatarBlockWidth
+            property bool isRemoteImage
+            isOutgoing: root.isOutgoing
+            showTime: root.showTime
+            seq: root.seq
+            author: root.author
+            formattedTime: root.formattedTime
+            extraHeight: extraContent.active && !isRemoteImage ? msgRadius : -isRemoteImage
+            innerContent.children: [
+                TextEdit {
+                    padding: 10
+                    anchors.right: isOutgoing ? parent.right : undefined
+                    text: '<span style="white-space: pre-wrap">' + body + '</span>'
+                    width: {
+                        if (extraContent.active)
+                            Math.max(extraContent.width,
+                                     Math.min(implicitWidth - avatarBlockWidth,
+                                              extraContent.minSize) - senderMargin)
+                        else
+                            Math.min(implicitWidth, innerContent.width - senderMargin)
+                    }
+                    height: implicitHeight
+                    wrapMode: Label.WrapAtWordBoundaryOrAnywhere
+                    selectByMouse: true
+                    font.pointSize: 11
+                    font.hintingPreference: Font.PreferNoHinting
+                    renderType: Text.NativeRendering
+                    textFormat: TextEdit.RichText
+                    onLinkHovered: root.hoveredLink = hoveredLink
+                    onLinkActivated: Qt.openUrlExternally(hoveredLink)
+                    readOnly: true
+                    color: isOutgoing ?
+                               JamiTheme.messageOutTxtColor :
+                               JamiTheme.messageInTxtColor
+                },
+                Loader {
+                    id: extraContent
+                    width: sourceComponent.width
+                    height: sourceComponent.height
+                    anchors.right: isOutgoing ? parent.right : undefined
+                    property real minSize: 192
+                    property real maxSize: 320
+                    active: linkInfo.url !== undefined
+                    sourceComponent: ColumnLayout {
+                        id: previewContent
+                        spacing: 12
+                        Component.onCompleted: {
+                            isRemoteImage = MessagesAdapter.isRemoteImage(linkInfo.url)
+                        }
+                        HoverHandler {
+                            target: previewContent
+                            onHoveredChanged: {
+                                root.hoveredLink = hovered ? linkInfo.url : ""
+                            }
+                            cursorShape: Qt.PointingHandCursor
+                        }
+                        AnimatedImage {
+                            id: img
+                            cache: true
+                            source: isRemoteImage ?
+                                        linkInfo.url :
+                                        (hasImage ? linkInfo.image : "")
+                            fillMode: Image.PreserveAspectCrop
+                            mipmap: true
+                            antialiasing: true
+                            autoTransform: true
+                            asynchronous: true
+                            readonly property bool hasImage: linkInfo.image !== null
+                            property real aspectRatio: implicitWidth / implicitHeight
+                            property real adjustedWidth: Math.min(extraContent.maxSize,
+                                                                  Math.max(extraContent.minSize,
+                                                                           maxMsgWidth))
+                            Layout.preferredWidth: adjustedWidth
+                            Layout.preferredHeight: Math.ceil(adjustedWidth / aspectRatio)
+                            Rectangle {
+                                color: JamiTheme.previewImageBackgroundColor
+                                z: -1
+                                anchors.fill: parent
+                            }
+                            layer.enabled: isRemoteImage
+                            layer.effect: OpacityMask {
+                                maskSource: MessageBubble {
+                                    Rectangle { height: msgRadius; width: parent.width }
+                                    out: isOutgoing
+                                    type: seq
+                                    width: img.width
+                                    height: img.height
+                                    radius: msgRadius
+                                }
+                            }
+                        }
+                        Column {
+                            opacity: img.status !== Image.Loading
+                            visible: !isRemoteImage
+                            Layout.preferredWidth: img.width - 2 * hMargin
+                            Layout.leftMargin: hMargin
+                            Layout.rightMargin: hMargin
+                            spacing: 6
+                            Label {
+                                width: parent.width
+                                font.pointSize: 10
+                                font.hintingPreference: Font.PreferNoHinting
+                                wrapMode: Label.WrapAtWordBoundaryOrAnywhere
+                                renderType: Text.NativeRendering
+                                textFormat: TextEdit.RichText
+                                color: JamiTheme.previewTitleColor
+                                visible: linkInfo.title !== null
+                                text: linkInfo.title
+                            }
+                            Label {
+                                width: parent.width
+                                font.pointSize: 11
+                                font.hintingPreference: Font.PreferNoHinting
+                                wrapMode: Label.WrapAtWordBoundaryOrAnywhere
+                                renderType: Text.NativeRendering
+                                textFormat: TextEdit.RichText
+                                color: JamiTheme.previewSubtitleColor
+                                visible: linkInfo.description !== null
+                                text: '<a href=" " style="text-decoration: ' +
+                                      ( hoveredLink ? 'underline' : 'none') + ';"' +
+                                      '>' + linkInfo.description + '</a>'
+                            }
+                            Label {
+                                width: parent.width
+                                font.pointSize: 10
+                                font.hintingPreference: Font.PreferNoHinting
+                                wrapMode: Label.WrapAtWordBoundaryOrAnywhere
+                                renderType: Text.NativeRendering
+                                textFormat: TextEdit.RichText
+                                color: JamiTheme.previewSubtitleColor
+                                text: linkInfo.domain
+                            }
+                        }
+                    }
+                }
+            ]
+            Component.onCompleted: {
+                if (!Linkified) {
+                    MessagesAdapter.parseMessageUrls(Id, Body)
+                }
+            }
+        }
     }
 
     Component {
@@ -45,13 +245,14 @@ Control {
         Column {
             width: root.width
             spacing: 2
+            topPadding: 12
+            bottomPadding: 12
 
-            TextArea {
+            Label {
                 width: parent.width
                 text: body
                 horizontalAlignment: Qt.AlignHCenter
-                readOnly: true
-                font.pointSize: 11
+                font.pointSize: 12
                 color: JamiTheme.chatviewTextColor
             }
 
@@ -61,131 +262,303 @@ Control {
                 width: parent.width
                 height: childrenRect.height
 
-                Component.onCompleted: children = timestampLabel
-            }
+                Label {
+                    text: formattedTime
+                    color: JamiTheme.timestampColor
+                    visible: showTime || seq === MsgSeq.last
+                    height: visible * implicitHeight
+                    font.pointSize: 9
 
-            bottomPadding: 12
+                    anchors.horizontalCenter: parent.horizontalCenter
+                }
+            }
         }
     }
 
     Component {
-        id: userMsgComp
-
-        GridLayout {
-            id: gridLayout
-
-            width: root.width
-
-            columns: 2
-            rows: 2
-
-            columnSpacing: 2
-            rowSpacing: 2
-
-            Column {
-                id: msgCell
-
-                Layout.column: isOutgoing ? 0 : 1
-                Layout.row: 0
-                Layout.fillWidth: true
-                Layout.maximumWidth: 640
-                Layout.preferredHeight: childrenRect.height
-                Layout.alignment: isOutgoing ? Qt.AlignRight : Qt.AlignLeft
-                Layout.leftMargin: isOutgoing ? msgMargin : 0
-                Layout.rightMargin: isOutgoing ? 0 : msgMargin
-
-                Control {
-                    id: msgBlock
-
-                    width: parent.width
-
-                    contentItem: Column {
-                        id: msgContent
-
-                        property real txtWidth: ta.contentWidth + 3 * ta.padding
-
-                        TextArea {
-                            id: ta
-                            width: parent.width
-                            text: body
-                            padding: 10
+        id: dataTransferMsgComp
+
+        SBSMessageBase {
+            id: dataTransferItem
+            property var transferStats: MessagesAdapter.getTransferStats(Id, Status)
+            property bool canOpen: Status === Interaction.Status.TRANSFER_FINISHED || isOutgoing
+            property real maxMsgWidth: root.width - senderMargin -
+                                       2 * hMargin - avatarBlockWidth
+                                       - buttonsLoader.width - 24 - 6 - 24
+            isOutgoing: root.isOutgoing
+            showTime: root.showTime
+            seq: root.seq
+            author: root.author
+            formattedTime: root.formattedTime
+            extraHeight: progressBar.visible ? 18 : 0
+            innerContent.children: [
+                RowLayout {
+                    id: transferItem
+                    spacing: 6
+                    anchors.right: isOutgoing ? parent.right : undefined
+                    HoverHandler {
+                        target: parent
+                        enabled: canOpen
+                        onHoveredChanged: {
+                            root.hoveredLink = enabled && hovered ?
+                                        ("file:///" + body) :
+                                        ""
+                        }
+                        cursorShape: enabled ?
+                                         Qt.PointingHandCursor :
+                                         Qt.ArrowCursor
+                    }
+                    Loader {
+                        id: buttonsLoader
+
+                        property string iconSourceA
+                        property string iconSourceB
+
+                        Layout.margins: 12
+
+                        sourceComponent: {
+                            switch (Status) {
+                            case Interaction.Status.TRANSFER_CANCELED:
+                            case Interaction.Status.TRANSFER_ERROR:
+                            case Interaction.Status.TRANSFER_UNJOINABLE_PEER:
+                            case Interaction.Status.TRANSFER_TIMEOUT_EXPIRED:
+                                iconSourceA = JamiResources.error_outline_black_24dp_svg
+                                return terminatedComp
+                            case Interaction.Status.TRANSFER_CREATED:
+                            case Interaction.Status.TRANSFER_FINISHED:
+                                iconSourceA = JamiResources.link_black_24dp_svg
+                                return terminatedComp
+                            case Interaction.Status.TRANSFER_AWAITING_HOST:
+                                iconSourceA = JamiResources.download_black_24dp_svg
+                                iconSourceB = JamiResources.close_black_24dp_svg
+                                return optionsComp
+                            case Interaction.Status.TRANSFER_ONGOING:
+                                iconSourceA = JamiResources.close_black_24dp_svg
+                                return optionsComp
+                            default:
+                                iconSourceA = JamiResources.error_outline_black_24dp_svg
+                                return terminatedComp
+                            }
+                        }
+                        Component {
+                            id: terminatedComp
+                            ResponsiveImage {
+                                source: buttonsLoader.iconSourceA
+                                Layout.leftMargin: 12
+                                Layout.preferredWidth: 24
+                                Layout.preferredHeight: 24
+                            }
+                        }
+                        Component {
+                            id: optionsComp
+                            ColumnLayout {
+                                Layout.leftMargin: 12
+                                PushButton {
+                                    source: buttonsLoader.iconSourceA
+                                    normalColor: JamiTheme.chatviewBgColor
+                                    imageColor: JamiTheme.chatviewButtonColor
+                                    onClicked: {
+                                        switch (Status) {
+                                        case Interaction.Status.TRANSFER_ONGOING:
+                                            return MessagesAdapter.cancelFile(Id)
+                                        case Interaction.Status.TRANSFER_AWAITING_HOST:
+                                            return MessagesAdapter.acceptFile(Id)
+                                        default: break
+                                        }
+                                    }
+                                }
+                                PushButton {
+                                    visible: !CurrentConversation.isSwarm
+                                    height: visible * implicitHeight
+                                    source: buttonsLoader.iconSourceB
+                                    normalColor: JamiTheme.chatviewBgColor
+                                    imageColor: JamiTheme.chatviewButtonColor
+                                    onClicked: {
+                                        switch (Status) {
+                                        case Interaction.Status.TRANSFER_AWAITING_HOST:
+                                            return MessagesAdapter.cancelFile(Id)
+                                        default: break
+                                        }
+                                    }
+                                }
+                            }
+                        }
+                    }
+                    Column {
+                        Layout.rightMargin: 24
+                        spacing: 6
+                        TextEdit {
+                            id: transferName
+                            width: Math.min(implicitWidth, maxMsgWidth)
+                            topPadding: 10
+                            text: CurrentConversation.isSwarm ?
+                                      TransferName :
+                                      body
+                            wrapMode: Label.WrapAtWordBoundaryOrAnywhere
+                            font.weight: Font.DemiBold
                             font.pointSize: 11
-                            font.hintingPreference: Font.PreferNoHinting
                             renderType: Text.NativeRendering
-                            textFormat: TextEdit.RichText
-                            wrapMode: Text.WrapAtWordBoundaryOrAnywhere
-                            transform: Translate { x: bg.x }
-                            rightPadding: isOutgoing ? padding * 1.5 : 0
+                            readOnly: true
                             color: isOutgoing ?
                                        JamiTheme.messageOutTxtColor :
                                        JamiTheme.messageInTxtColor
+                            MouseArea {
+                                anchors.fill: parent
+                                cursorShape: canOpen ?
+                                                 Qt.PointingHandCursor :
+                                                 Qt.ArrowCursor
+                                onClicked: if(canOpen) itemMouseArea.clicked(mouse)
+                            }
+                        }
+                        Label {
+                            id: transferInfo
+                            width: Math.min(implicitWidth, maxMsgWidth)
+                            bottomPadding: 10
+                            text: {
+                                var res = formattedTime + " - "
+                                if (transferStats.totalSize !== undefined) {
+                                    if (transferStats.progress !== 0 &&
+                                            transferStats.progress !== transferStats.totalSize) {
+                                        res += UtilsAdapter.humanFileSize(transferStats.progress) + " / "
+                                    }
+                                    res += UtilsAdapter.humanFileSize(transferStats.totalSize)
+                                }
+                                return res + " - " + MessagesAdapter.getStatusString(Status)
+                            }
+                            wrapMode: Label.WrapAtWordBoundaryOrAnywhere
+                            font.pointSize: 10
+                            renderType: Text.NativeRendering
+                            color: Qt.lighter((isOutgoing ?
+                                       JamiTheme.messageOutTxtColor :
+                                       JamiTheme.messageInTxtColor), 1.5)
                         }
-                    }
-                    background: Rectangle {
-                        id: bg
-
-                        anchors.right: isOutgoing ? msgContent.right : undefined
-                        width: msgContent.txtWidth
-                        radius: 18
-                        color: isOutgoing ?
-                                   JamiTheme.messageOutBgColor :
-                                   JamiTheme.messageInBgColor
                     }
                 }
-            }
-            Item {
-                id: infoCell
-
-                Layout.column: isOutgoing ? 0 : 1
-                Layout.row: 1
-                Layout.fillWidth: true
-                Layout.preferredHeight: childrenRect.height
-
-                Component.onCompleted: children = timestampLabel
-            }
-            Item {
-                id: avatarCell
-
-                Layout.column: isOutgoing ? 1 : 0
-                Layout.row: 0
-                Layout.preferredWidth: isOutgoing ? 16 : avatar.width
-                Layout.preferredHeight: msgCell.height
-                Layout.leftMargin: isOutgoing ? 0 : 6
-                Layout.rightMargin: Layout.leftMargin
-                Avatar {
-                    id: avatar
-                    visible: !isOutgoing
-                    anchors.bottom: parent.bottom
-                    width: 32
-                    height: 32
-                    imageId: author
-                    showPresenceIndicator: false
-                    mode: Avatar.Mode.Contact
+                ,ProgressBar {
+                    id: progressBar
+                    visible: Status === Interaction.Status.TRANSFER_ONGOING
+                    height: visible * implicitHeight
+                    value: transferStats.progress / transferStats.totalSize
+                    width: transferItem.width
+                    anchors.right: isOutgoing ? parent.right : undefined
                 }
-            }
+            ]
         }
     }
 
-    Label {
-        id: timestampLabel
-
-        text: formattedTime
-        color: JamiTheme.timestampColor
-
-        anchors.right: isGenerated || !isOutgoing ? undefined : parent.right
-        anchors.rightMargin: 6
-        anchors.left: isGenerated || isOutgoing ? undefined : parent.left
-        anchors.leftMargin: 6
-        anchors.horizontalCenter: isGenerated ? parent.horizontalCenter : undefined
+    Component {
+        id: localMediaMsgComp
+
+        SBSMessageBase {
+            isOutgoing: root.isOutgoing
+            showTime: root.showTime
+            seq: root.seq
+            author: root.author
+            formattedTime: root.formattedTime
+            bubble.visible: false
+            innerContent.children: [
+                Loader {
+                    id: localMediaCompLoader
+                    anchors.right: isOutgoing ? parent.right : undefined
+                    width: sourceComponent.width
+                    height: sourceComponent.height
+                    sourceComponent: mediaInfo.isImage !== undefined ?
+                                         imageComp :
+                                         avComp
+                    Component {
+                        id: avComp
+                        WebEngineView {
+                            id: wev
+                            anchors.right: isOutgoing ? parent.right : undefined
+                            readonly property real minSize: 192
+                            readonly property real maxSize: 256
+                            readonly property real aspectRatio: 1 / .75
+                            readonly property real adjustedWidth: Math.min(maxSize,
+                                                                  Math.max(minSize,
+                                                                           innerContent.width - senderMargin))
+                            width: isFullScreen ? parent.width : adjustedWidth
+                            height: mediaInfo.isVideo ?
+                                        isFullScreen ?
+                                            parent.height :
+                                            Math.ceil(adjustedWidth / aspectRatio) :
+                                        54
+                            settings.fullScreenSupportEnabled: mediaInfo.isVideo
+                            settings.javascriptCanOpenWindows: false
+                            Component.onCompleted: loadHtml(mediaInfo.html, 'file://')
+                            layer.enabled: parent !== appContainer && !appWindow.isFullScreen
+                            layer.effect: OpacityMask {
+                                maskSource: MessageBubble {
+                                    out: isOutgoing
+                                    type: seq
+                                    width: wev.width
+                                    height: wev.height
+                                    radius: msgRadius
+                                }
+                            }
+                            onFullScreenRequested: function(request) {
+                                if (JamiQmlUtils.callIsFullscreen)
+                                    return
+                                if (request.toggleOn && !appWindow.isFullScreen) {
+                                    parent = appContainer
+                                    appWindow.toggleFullScreen()
+                                } else if (!request.toggleOn && appWindow.isFullScreen) {
+                                    parent = localMediaCompLoader
+                                    appWindow.toggleFullScreen()
+                                }
+                                request.accept()
+                            }
+                        }
+                    }
+                    Component {
+                        id: imageComp
+                        AnimatedImage {
+                            id: img
+                            anchors.right: isOutgoing ? parent.right : undefined
+                            property real minSize: 192
+                            property real maxSize: 256
+                            cache: true
+                            fillMode: Image.PreserveAspectCrop
+                            mipmap: true
+                            antialiasing: true
+                            autoTransform: false
+                            asynchronous: true
+                            source: "file:///" + body
+                            property real aspectRatio: implicitWidth / implicitHeight
+                            property real adjustedWidth: Math.min(maxSize,
+                                                                  Math.max(minSize,
+                                                                           innerContent.width - senderMargin))
+                            width: adjustedWidth
+                            height: Math.ceil(adjustedWidth / aspectRatio)
+                            Rectangle {
+                                color: JamiTheme.previewImageBackgroundColor
+                                z: -1
+                                anchors.fill: parent
+                            }
+                            layer.enabled: true
+                            layer.effect: OpacityMask {
+                                maskSource: MessageBubble {
+                                    out: isOutgoing
+                                    type: seq
+                                    width: img.width
+                                    height: img.height
+                                    radius: msgRadius
+                                }
+                            }
+                            HoverHandler {
+                                target : parent
+                                onHoveredChanged: {
+                                    root.hoveredLink = hovered ? img.source : ""
+                                }
+                                cursorShape: Qt.PointingHandCursor
+                            }
+                        }
+                    }
+                }
+            ]
+        }
     }
 
     opacity: 0
     Behavior on opacity { NumberAnimation { duration: 40 } }
-
-    Component.onCompleted: {
-        opacity = 1
-        if (!Linkified && !isImage && !isAnimatedImage) {
-            MessagesAdapter.parseMessageUrls(Id, Body)
-        }
-    }
+    Component.onCompleted: opacity = 1
 }
diff --git a/src/commoncomponents/SBSMessageBase.qml b/src/commoncomponents/SBSMessageBase.qml
new file mode 100644
index 000000000..0c9cafa72
--- /dev/null
+++ b/src/commoncomponents/SBSMessageBase.qml
@@ -0,0 +1,115 @@
+/*
+ * Copyright (C) 2021 by Savoir-faire Linux
+ * Author: Andreas Traczyk <andreas.traczyk@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 2.15
+import QtQuick.Controls 2.15
+import QtQuick.Layouts 1.15
+import QtGraphicalEffects 1.15
+
+import net.jami.Models 1.1
+import net.jami.Adapters 1.1
+import net.jami.Constants 1.1
+
+ColumnLayout {
+    id: root
+
+    property alias avatarBlockWidth: avatarBlock.width
+    property alias innerContent: innerContent
+    property alias bubble: bubble
+    property real extraHeight: 0
+
+    // these MUST be set but we won't use the 'required' keyword yet
+    property bool isOutgoing
+    property bool showTime
+    property int seq
+    property string author
+    property string formattedTime
+
+    readonly property real senderMargin: 64
+    readonly property real avatarSize: 32
+    readonly property real msgRadius: 18
+    readonly property real hMargin: 12
+
+    anchors.left: parent.left
+    anchors.right: parent.right
+    anchors.leftMargin: hMargin
+    anchors.rightMargin: hMargin
+    spacing: 2
+
+    RowLayout {
+        Layout.preferredHeight: innerContent.height + root.extraHeight
+        Layout.topMargin: (seq === MsgSeq.first || seq === MsgSeq.single) ? 6 : 0
+        spacing: 0
+        Item {
+            id: avatarBlock
+            Layout.preferredWidth: isOutgoing ? 0 : avatar.width + hMargin
+            Layout.preferredHeight: isOutgoing ? 0 : bubble.height
+            Avatar {
+                id: avatar
+                visible: !isOutgoing && (seq === MsgSeq.last || seq === MsgSeq.single)
+                anchors.bottom: parent.bottom
+                width: avatarSize
+                height: avatarSize
+                imageId: author
+                showPresenceIndicator: false
+                mode: Avatar.Mode.Contact
+            }
+        }
+        Item {
+            Layout.fillWidth: true
+            Layout.fillHeight: true
+            Column {
+                id: innerContent
+                width: parent.width
+                // place actual content here
+            }
+            MessageBubble {
+                id: bubble
+                z:-1
+                out: isOutgoing
+                type: seq
+                color: isOutgoing ?
+                           JamiTheme.messageOutBgColor :
+                           JamiTheme.messageInBgColor
+                radius: msgRadius
+                anchors.right: isOutgoing ? parent.right : undefined
+                width: innerContent.childrenRect.width
+                height: innerContent.childrenRect.height + (visible ? root.extraHeight : 0)
+            }
+        }
+    }
+    Item {
+        id: infoCell
+
+        Layout.preferredWidth: parent.width
+        Layout.preferredHeight: childrenRect.height
+
+        Label {
+            text: formattedTime
+            color: JamiTheme.timestampColor
+            visible: showTime || seq === MsgSeq.last
+            height: visible * implicitHeight
+            font.pointSize: 9
+
+            anchors.right: !isOutgoing ? undefined : parent.right
+            anchors.rightMargin: 8
+            anchors.left: isOutgoing ? undefined : parent.left
+            anchors.leftMargin: avatarBlockWidth + 6
+        }
+    }
+}
diff --git a/src/constant/JamiTheme.qml b/src/constant/JamiTheme.qml
index a16114497..6048a129c 100644
--- a/src/constant/JamiTheme.qml
+++ b/src/constant/JamiTheme.qml
@@ -148,7 +148,7 @@ Item {
     property color jamiLightBlue: darkTheme ? "#003b4e" : Qt.rgba(59, 193, 211, 0.3)
     property color jamiDarkBlue: darkTheme ? "#28b1ed" : "#003b4e"
     property color chatviewTextColor: darkTheme ? "#f0f0f0" : "#353637"
-    property color timestampColor: darkTheme ? "#bbb" : "#333"
+    property color timestampColor: darkTheme ? "#bbb" : "#777"
     property color messageOutBgColor: darkTheme ? "#28b1ed" : "#cfd8dc"
     property color messageOutTxtColor: chatviewTextColor
     property color messageInBgColor: darkTheme? "#616161" : "#cfebf5"
diff --git a/src/constant/MsgSeq.qml b/src/constant/MsgSeq.qml
new file mode 100644
index 000000000..de25c238d
--- /dev/null
+++ b/src/constant/MsgSeq.qml
@@ -0,0 +1,9 @@
+pragma Singleton
+import QtQml 2.15
+QtObject {
+    //readonly property int unknown: -1
+    readonly property int single: 0
+    readonly property int first: 1
+    readonly property int middle: 2
+    readonly property int last: 3
+}
diff --git a/src/mainview/components/MessageListView.qml b/src/mainview/components/MessageListView.qml
index 2bc9a68fb..2224edc14 100644
--- a/src/mainview/components/MessageListView.qml
+++ b/src/mainview/components/MessageListView.qml
@@ -60,8 +60,10 @@ ListView {
     anchors.centerIn: parent
     height: parent.height
     width: parent.width
-    displayMarginBeginning: 2048
-    displayMarginEnd: 2048
+    // this offscreen caching is pretty huge
+    // displayMarginEnd may be removed
+    displayMarginBeginning: 4096
+    displayMarginEnd: 4096
     maximumFlickVelocity: 2048
     verticalLayoutDirection: ListView.BottomToTop
     clip: true
@@ -72,7 +74,121 @@ ListView {
 
     model: MessagesAdapter.messageListModel
 
-    delegate: MessageDelegate {}
+    delegate: MessageDelegate {
+        // sequencing/timestamps (2-sided style)
+        function computeTimestampVisibility() {
+            if (listView === undefined)
+                return
+            var nItem = listView.itemAtIndex(index - 1)
+            if (nItem && index !== listView.count - 1) {
+                showTime = (nItem.timestamp - timestamp) > 60 &&
+                        nItem.formattedTime !== formattedTime
+            } else {
+                showTime = true
+                var pItem = listView.itemAtIndex(index + 1)
+                if (pItem) {
+                    pItem.showTime = (timestamp - pItem.timestamp) > 60 &&
+                            pItem.formattedTime !== formattedTime
+                }
+            }
+        }
+
+        function computeSequencing() {
+            if (listView === undefined)
+                return
+            var cItem = {
+                'author': author,
+                'isGenerated': isGenerated,
+                'showTime': showTime
+            }
+            var pItem = listView.itemAtIndex(index + 1)
+            var nItem = listView.itemAtIndex(index - 1)
+
+            let isSeq = (item0, item1) =>
+                item0.author === item1.author &&
+                !(item0.isGenerated || item1.isGenerated) &&
+                !item0.showTime
+
+            let setSeq = function (newSeq, item) {
+                if (item === undefined)
+                    seq = isGenerated ? MsgSeq.single : newSeq
+                else
+                    item.seq = item.isGenerated ? MsgSeq.single : newSeq
+            }
+
+            let rAdjustSeq = function (item) {
+                if (item.seq === MsgSeq.last)
+                    item.seq = MsgSeq.middle
+                else if (item.seq === MsgSeq.single)
+                    setSeq(MsgSeq.first, item)
+            }
+
+            let adjustSeq = function (item) {
+                if (item.seq === MsgSeq.first)
+                    item.seq = MsgSeq.middle
+                else if (item.seq === MsgSeq.single)
+                    setSeq(MsgSeq.last, item)
+            }
+
+            if (pItem && !nItem) {
+                if (!isSeq(pItem, cItem)) {
+                    seq = MsgSeq.single
+                } else {
+                    seq = MsgSeq.last
+                    rAdjustSeq(pItem)
+                }
+            } else if (nItem && !pItem) {
+                if (!isSeq(cItem, nItem)) {
+                    seq = MsgSeq.single
+                } else {
+                    setSeq(MsgSeq.first)
+                    adjustSeq(nItem)
+                }
+            } else if (!nItem && !pItem) {
+                seq = MsgSeq.single
+            } else {
+                if (isSeq(pItem, nItem)) {
+                    if (isSeq(pItem, cItem)) {
+                        seq = MsgSeq.middle
+                    } else {
+                        seq = MsgSeq.single
+
+                        if (pItem.seq === MsgSeq.first)
+                            pItem.seq = MsgSeq.single
+                        else if (item.seq === MsgSeq.middle)
+                            pItem.seq = MsgSeq.last
+
+                        if (nItem.seq === MsgSeq.last)
+                            nItem.seq = MsgSeq.single
+                        else if (nItem.seq === MsgSeq.middle)
+                            nItem.seq = MsgSeq.first
+                    }
+                } else {
+                    if (!isSeq(pItem, cItem)) {
+                        seq = MsgSeq.first
+                        adjustSeq(pItem)
+                    } else {
+                        seq = MsgSeq.last
+                        rAdjustSeq(nItem)
+                    }
+                }
+            }
+
+            if (seq === MsgSeq.last) {
+                showTime = true
+            }
+        }
+
+        Component.onCompleted: {
+            if (index) {
+                computeTimestampVisibility()
+                computeSequencing()
+            } else {
+                Qt.callLater(computeTimestampVisibility)
+                Qt.callLater(computeSequencing)
+            }
+        }
+    }
 
     function getDistanceToBottom() {
         const scrollDiff = ScrollBar.vertical.position -
diff --git a/src/messagesadapter.cpp b/src/messagesadapter.cpp
index fb6d7ef2b..15e33daa7 100644
--- a/src/messagesadapter.cpp
+++ b/src/messagesadapter.cpp
@@ -196,7 +196,7 @@ MessagesAdapter::acceptFile(const QString& interactionId)
 }
 
 void
-MessagesAdapter::refuseFile(const QString& interactionId)
+MessagesAdapter::cancelFile(const QString& interactionId)
 {
     const auto convUid = lrcInstance_->get_selectedConvUid();
     lrcInstance_->getCurrentConversationModel()->cancelTransfer(convUid, interactionId);
@@ -240,6 +240,50 @@ MessagesAdapter::onPaste()
     }
 }
 
+QString
+MessagesAdapter::getStatusString(int status)
+{
+    switch (static_cast<interaction::Status>(status)) {
+    case interaction::Status::SENDING:
+        return QObject::tr("Sending");
+    case interaction::Status::FAILURE:
+        return QObject::tr("Failure");
+    case interaction::Status::SUCCESS:
+        return QObject::tr("Sent");
+    case interaction::Status::TRANSFER_CREATED:
+        return QObject::tr("Connecting");
+    case interaction::Status::TRANSFER_ACCEPTED:
+        return QObject::tr("Accept");
+    case interaction::Status::TRANSFER_CANCELED:
+        return QObject::tr("Canceled");
+    case interaction::Status::TRANSFER_ERROR:
+    case interaction::Status::TRANSFER_UNJOINABLE_PEER:
+        return QObject::tr("Unable to make contact");
+    case interaction::Status::TRANSFER_ONGOING:
+        return QObject::tr("Ongoing");
+    case interaction::Status::TRANSFER_AWAITING_PEER:
+        return QObject::tr("Waiting for contact");
+    case interaction::Status::TRANSFER_AWAITING_HOST:
+        return QObject::tr("Incoming transfer");
+    case interaction::Status::TRANSFER_TIMEOUT_EXPIRED:
+        return QObject::tr("Timed out waiting for contact");
+    case interaction::Status::TRANSFER_FINISHED:
+        return QObject::tr("Finished");
+    default:
+        return {};
+    }
+}
+
+QVariantMap
+MessagesAdapter::getTransferStats(const QString& msgId, int status)
+{
+    Q_UNUSED(status)
+    auto convModel = lrcInstance_->getCurrentConversationModel();
+    lrc::api::datatransfer::Info info = {};
+    convModel->getTransferInfo(lrcInstance_->get_selectedConvUid(), msgId, info);
+    return {{"totalSize", qint64(info.totalSize)}, {"progress", qint64(info.progress)}};
+}
+
 void
 MessagesAdapter::userIsComposing(bool isComposing)
 {
@@ -371,18 +415,53 @@ MessagesAdapter::onMessageLinkified(const QString& messageId, const QString& lin
 }
 
 bool
-MessagesAdapter::isImage(const QString& message)
+MessagesAdapter::isLocalImage(const QString& msg)
 {
-    QRegularExpression pattern("[^\\s]+(.*?)\\.(jpg|jpeg|png)$",
-                               QRegularExpression::CaseInsensitiveOption);
-    QRegularExpressionMatch match = pattern.match(message);
-    return match.hasMatch();
+    QImageReader reader;
+    reader.setDecideFormatFromContent(true);
+    reader.setFileName(msg);
+    return !reader.read().isNull();
+}
+
+QVariantMap
+MessagesAdapter::getMediaInfo(const QString& msg)
+{
+    auto filePath = QFileInfo(msg).absoluteFilePath();
+    static const QString html
+        = "<body style='margin:0;padding:0;'>"
+          "<%1 style='width:100%;height:%2;outline:none;background-color:#f1f3f4;"
+          "object-fit:cover;' "
+          "controls controlsList='nodownload' src='file://%3' type='%4'/></body>";
+    if (isLocalImage(msg)) {
+        return {{"isImage", true}};
+    }
+    QRegularExpression vPattern("[^\\s]+(.*?)\\.(avi|mov|webm|webp|rmvb)$",
+                                QRegularExpression::CaseInsensitiveOption);
+    QString type = vPattern.match(filePath).captured(2);
+    if (!type.isEmpty()) {
+        return {
+            {"isVideo", true},
+            {"html", html.arg("video", "100%", filePath, "video/" + type)},
+        };
+    } else {
+        QRegularExpression aPattern("[^\\s]+(.*?)\\.(ogg|flac|wav|mpeg|mp3)$",
+                                    QRegularExpression::CaseInsensitiveOption);
+        type = aPattern.match(filePath).captured(2);
+        if (!type.isEmpty()) {
+            return {
+                {"isVideo", false},
+                {"html", html.arg("audio", "54px", filePath, "audio/" + type)},
+            };
+        }
+    }
+    return {};
 }
 
 bool
-MessagesAdapter::isAnimatedImage(const QString& msg)
+MessagesAdapter::isRemoteImage(const QString& msg)
 {
-    QRegularExpression pattern("[^\\s]+(.*?)\\.(gif|apng|webp|avif|flif)$",
+    // TODO: test if all these open in the AnimatedImage component
+    QRegularExpression pattern("[^\\s]+(.*?)\\.(jpg|jpeg|png|gif|apng|webp|avif|flif)$",
                                QRegularExpression::CaseInsensitiveOption);
     QRegularExpressionMatch match = pattern.match(msg);
     return match.hasMatch();
diff --git a/src/messagesadapter.h b/src/messagesadapter.h
index fd27ac9b1..062743332 100644
--- a/src/messagesadapter.h
+++ b/src/messagesadapter.h
@@ -44,7 +44,8 @@ public:
     {
         auto index = sourceModel()->index(sourceRow, 0, sourceParent);
         auto type = sourceModel()->data(index, MessageList::Role::Type).toInt();
-        return static_cast<interaction::Type>(type) != interaction::Type::MERGE;
+        auto hasBody = !sourceModel()->data(index, MessageList::Role::Body).toString().isEmpty();
+        return static_cast<interaction::Type>(type) != interaction::Type::MERGE && hasBody;
     };
 
     bool lessThan(const QModelIndex& left, const QModelIndex& right) const override
@@ -92,18 +93,21 @@ protected:
     Q_INVOKABLE void sendMessage(const QString& message);
     Q_INVOKABLE void sendFile(const QString& message);
     Q_INVOKABLE void acceptFile(const QString& arg);
-    Q_INVOKABLE void refuseFile(const QString& arg);
+    Q_INVOKABLE void cancelFile(const QString& arg);
     Q_INVOKABLE void openUrl(const QString& url);
     Q_INVOKABLE void openFile(const QString& arg);
     Q_INVOKABLE void retryInteraction(const QString& interactionId);
     Q_INVOKABLE void deleteInteraction(const QString& interactionId);
     Q_INVOKABLE void copyToDownloads(const QString& interactionId, const QString& displayName);
     Q_INVOKABLE void userIsComposing(bool isComposing);
-    Q_INVOKABLE bool isImage(const QString& msg);
-    Q_INVOKABLE bool isAnimatedImage(const QString& msg);
+    Q_INVOKABLE bool isLocalImage(const QString& msg);
+    Q_INVOKABLE QVariantMap getMediaInfo(const QString& msg);
+    Q_INVOKABLE bool isRemoteImage(const QString& msg);
     Q_INVOKABLE QString getFormattedTime(const quint64 timestamp);
     Q_INVOKABLE void parseMessageUrls(const QString& messageId, const QString& msg);
     Q_INVOKABLE void onPaste();
+    Q_INVOKABLE QString getStatusString(int status);
+    Q_INVOKABLE QVariantMap getTransferStats(const QString& messageId, int);
 
     // Run corrsponding js functions, c++ to qml.
     void setMessagesImageContent(const QString& path, bool isBased64 = false);
diff --git a/src/qmlregister.cpp b/src/qmlregister.cpp
index 31cdc5271..6954d3988 100644
--- a/src/qmlregister.cpp
+++ b/src/qmlregister.cpp
@@ -177,6 +177,7 @@ registerTypes(QQmlEngine* engine,
     QML_REGISTERSINGLETONTYPE_URL(NS_MODELS, "qrc:/src/constant/JamiQmlUtils.qml", JamiQmlUtils);
     QML_REGISTERSINGLETONTYPE_URL(NS_CONSTANTS, "qrc:/src/constant/JamiStrings.qml", JamiStrings);
     QML_REGISTERSINGLETONTYPE_URL(NS_CONSTANTS, "qrc:/src/constant/JamiResources.qml", JamiResources);
+    QML_REGISTERSINGLETONTYPE_URL(NS_CONSTANTS, "qrc:/src/constant/MsgSeq.qml", MsgSeq);
 
     QML_REGISTERSINGLETONTYPE_POBJECT(NS_CONSTANTS, screenInfo, "ScreenInfo")
     QML_REGISTERSINGLETONTYPE_POBJECT(NS_CONSTANTS, lrcInstance, "LRCInstance")
-- 
GitLab