Skip to content
Snippets Groups Projects
MessageView.qml 26.3 KiB
Newer Older
Nicolas Werner's avatar
Nicolas Werner committed
// SPDX-FileCopyrightText: 2021 Nheko Contributors
// SPDX-FileCopyrightText: 2022 Nheko Contributors
Nicolas Werner's avatar
Nicolas Werner committed
// SPDX-License-Identifier: GPL-3.0-or-later

import "./components"
import "./delegates"
import "./emoji"
import Qt.labs.platform 1.1 as Platform
import QtQuick 2.15
import QtQuick.Controls 2.15
import QtQuick.Layouts 1.2
import QtQuick.Window 2.13
Item {
    id: chatRoot
    property int padding: Nheko.paddingMedium

    property int availableWidth: width
    ScrollBar {
        id: scrollbar
        parent: chat.parent
        anchors.top: parent.top
        anchors.right: parent.right
        anchors.bottom: parent.bottom
    }
Malte E's avatar
Malte E committed
    ListView {
        id: chat
        anchors.fill: parent
Malte E's avatar
Malte E committed
        property int delegateMaxWidth: ((Settings.timelineMaxWidth > 100 && Settings.timelineMaxWidth < chatRoot.availableWidth) ? Settings.timelineMaxWidth : chatRoot.availableWidth) - chatRoot.padding * 2 - (scrollbar.interactive? scrollbar.width : 0)
Malte E's avatar
Malte E committed

        displayMarginBeginning: height / 2
        displayMarginEnd: height / 2
        model: room
        // reuseItems still has a few bugs, see https://bugreports.qt.io/browse/QTBUG-95105 https://bugreports.qt.io/browse/QTBUG-95107
        //onModelChanged: if (room) room.sendReset()
        //reuseItems: true
        boundsBehavior: Flickable.StopAtBounds
        //pixelAligned: true
        spacing: 2
        verticalLayoutDirection: ListView.BottomToTop
        onCountChanged: {
            // Mark timeline as read
            if (atYEnd && room) model.currentIndex = 0;
Malte E's avatar
Malte E committed
        }
Malte E's avatar
Malte E committed
        ScrollBar.vertical: scrollbar
Malte E's avatar
Malte E committed
        anchors.rightMargin: scrollbar.interactive? scrollbar.width : 0
Malte E's avatar
Malte E committed
        Rectangle {
            //closePolicy: Popup.NoAutoClose
Malte E's avatar
Malte E committed
            id: messageActions
Malte E's avatar
Malte E committed
            property Item attached: null
            property alias model: row.model
            // use comma to update on scroll
            property var attachedPos: chat.contentY, chat.count, attached ? chat.mapFromItem(attached, attached ? attached.width - width : 0, -height) : null
Malte E's avatar
Malte E committed
            readonly property int padding: Nheko.paddingSmall
Malte E's avatar
Malte E committed
            visible: Settings.buttonsInTimeline && !!attached && (attached.hovered || messageActionHover.hovered)
            x: attached ? attachedPos.x : 0
            y: attached ? attachedPos.y : 0
            z: 10
            height: row.implicitHeight + padding * 2
            width: row.implicitWidth + padding * 2
            color: Nheko.colors.window
            border.color: Nheko.colors.buttonText
            border.width: 1
            radius: padding
Malte E's avatar
Malte E committed
            HoverHandler {
                id: messageActionHover
Malte E's avatar
Malte E committed
                grabPermissions: PointerHandler.CanTakeOverFromAnything
            }
Malte E's avatar
Malte E committed
            Row {
                id: row
Malte E's avatar
Malte E committed
                property var model
Malte E's avatar
Malte E committed
                anchors.centerIn: parent
                spacing: messageActions.padding
Malte E's avatar
Malte E committed
                Repeater {
                    model: Settings.recentReactions
Malte E's avatar
Malte E committed
                    delegate: TextButton {
                        required property string modelData
Malte E's avatar
Malte E committed
                        visible: chat.model ? chat.model.permissions.canSend(MtxEvent.Reaction) : false
Malte E's avatar
Malte E committed
                        height: fontMetrics.height
                        font.family: Settings.emojiFont
Malte E's avatar
Malte E committed
                        text: modelData
Malte E's avatar
Malte E committed
                            room.input.reaction(row.model.eventId, modelData);
                            TimelineManager.focusMessageInput();
Malte E's avatar
Malte E committed
                }
Malte E's avatar
Malte E committed
                ImageButton {
                    id: editButton

                    visible: !!row.model && row.model.isEditable
                    buttonTextColor: Nheko.colors.buttonText
                    width: 16
                    hoverEnabled: true
                    image: ":/icons/icons/ui/edit.svg"
                    ToolTip.visible: hovered
                    ToolTip.delay: Nheko.tooltipDelay
                    ToolTip.text: qsTr("Edit")
                    onClicked: {
                        if (row.model.isEditable)
                            chat.model.editAction(row.model.eventId);
Malte E's avatar
Malte E committed
                }
Malte E's avatar
Malte E committed
                ImageButton {
                    id: reactButton

                    visible: chat.model ? chat.model.permissions.canSend(MtxEvent.Reaction) : false
                    width: 16
                    hoverEnabled: true
                    image: ":/icons/icons/ui/smile.svg"
                    ToolTip.visible: hovered
                    ToolTip.delay: Nheko.tooltipDelay
                    ToolTip.text: qsTr("React")
                    onClicked: emojiPopup.visible ? emojiPopup.close() : emojiPopup.show(reactButton, function(emoji) {
                        var event_id = row.model ? row.model.eventId : "";
                        room.input.reaction(event_id, emoji);
                        TimelineManager.focusMessageInput();
                    })
                }
Malte E's avatar
Malte E committed
                ImageButton {
                    id: replyButton

                    visible: chat.model ? chat.model.permissions.canSend(MtxEvent.TextMessage) : false
                    width: 16
                    hoverEnabled: true
                    image: ":/icons/icons/ui/reply.svg"
                    ToolTip.visible: hovered
                    ToolTip.delay: Nheko.tooltipDelay
                    ToolTip.text: qsTr("Reply")
                    onClicked: chat.model.replyAction(row.model.eventId)
                }
Malte E's avatar
Malte E committed
                ImageButton {
                    id: optionsButton
Malte E's avatar
Malte E committed
                    width: 16
                    hoverEnabled: true
                    image: ":/icons/icons/ui/options.svg"
                    ToolTip.visible: hovered
                    ToolTip.delay: Nheko.tooltipDelay
                    ToolTip.text: qsTr("Options")
                    onClicked: messageContextMenu.show(row.model.eventId, row.model.type, row.model.isSender, row.model.isEncrypted, row.model.isEditable, "", row.model.body, optionsButton)
Malte E's avatar
Malte E committed
        }
Malte E's avatar
Malte E committed
        ScrollHelper {
            flickable: parent
            anchors.fill: parent
        }

        Shortcut {
            sequence: StandardKey.MoveToPreviousPage
            onActivated: {
                chat.contentY = chat.contentY - chat.height / 2;
                chat.returnToBounds();
Malte E's avatar
Malte E committed
        }
Malte E's avatar
Malte E committed
        Shortcut {
            sequence: StandardKey.MoveToNextPage
            onActivated: {
                chat.contentY = chat.contentY + chat.height / 2;
                chat.returnToBounds();
Malte E's avatar
Malte E committed
        }
Malte E's avatar
Malte E committed
        Shortcut {
            sequence: StandardKey.Cancel
            onActivated: {
                if (chat.model.reply)
                    chat.model.reply = undefined;
Malte E's avatar
Malte E committed
                else
                    chat.model.edit = undefined;
            }
Malte E's avatar
Malte E committed
        }
Malte E's avatar
Malte E committed
        Shortcut {
            sequence: "Alt+Up"
            onActivated: chat.model.reply = chat.model.indexToId(chat.model.reply ? chat.model.idToIndex(chat.model.reply) + 1 : 0)
        }
Malte E's avatar
Malte E committed
        Shortcut {
            sequence: "Alt+Down"
            onActivated: {
                var idx = chat.model.reply ? chat.model.idToIndex(chat.model.reply) - 1 : -1;
                chat.model.reply = idx >= 0 ? chat.model.indexToId(idx) : null;
Malte E's avatar
Malte E committed
        }
Malte E's avatar
Malte E committed
        Shortcut {
            sequence: "Alt+F"
            onActivated: {
                if (chat.model.reply) {
                    var forwardMess = forwardCompleterComponent.createObject(timelineRoot);
                    forwardMess.setMessageEventId(chat.model.reply);
                    forwardMess.open();
                    chat.model.reply = null;
Nicolas Werner's avatar
Nicolas Werner committed
                    timelineRoot.destroyOnClose(forwardMess);
Malte E's avatar
Malte E committed
        }
Malte E's avatar
Malte E committed
        Shortcut {
            sequence: "Ctrl+E"
            onActivated: {
                chat.model.edit = chat.model.reply;
Malte E's avatar
Malte E committed
        }
Malte E's avatar
Malte E committed
        Connections {
            function onFocusChanged() {
                readTimer.running = TimelineManager.isWindowFocused;
Malte E's avatar
Malte E committed
            target: TimelineManager
        }

        Timer {
            id: readTimer
Malte E's avatar
Malte E committed
            // force current read index to update
            onTriggered: {
                if (chat.model)
                    chat.model.setCurrentIndex(chat.model.currentIndex);
Malte E's avatar
Malte E committed
            interval: 1000
        }
Malte E's avatar
Malte E committed
        Component {
            id: sectionHeader

            Column {
                topPadding: userName_.visible? 4: 0
                bottomPadding: Settings.bubbles? (isSender? 0 : 2) : 3
                spacing: 8
                visible: (previousMessageUserId !== userId || previousMessageDay !== day || isStateEvent !== previousMessageIsStateEvent)
                width: parentWidth
                height: ((previousMessageDay !== day) ? dateBubble.height : 0) + (isStateEvent? 0 : userName.height +8 )

                Label {
                    id: dateBubble

                    anchors.horizontalCenter: parent ? parent.horizontalCenter : undefined
                    visible: room && previousMessageDay !== day
                    text: room ? room.formatDateSeparator(timestamp) : ""
                    color: Nheko.colors.text
                    height: Math.round(fontMetrics.height * 1.4)
                    width: contentWidth * 1.2
                    horizontalAlignment: Text.AlignHCenter
                    verticalAlignment: Text.AlignVCenter

                    background: Rectangle {
                        radius: parent.height / 2
                        color: Nheko.colors.window
                    }
Malte E's avatar
Malte E committed
                }

                Row {
                    height: userName_.height
Malte E's avatar
Malte E committed
                    visible: !isStateEvent && (!isSender || !Settings.bubbles)

                    Avatar {
                        id: messageUserAvatar

                        width: Nheko.avatarSize * (Settings.smallAvatars? 0.5 : 1)
                        height: Nheko.avatarSize * (Settings.smallAvatars? 0.5 : 1)
                        url: !room ? "" : room.avatarUrl(userId).replace("mxc://", "image://MxcImage/")
                        displayName: userName
                        userid: userId
                        onClicked: room.openUserProfile(userId)
                        ToolTip.visible: messageUserAvatar.hovered
Malte E's avatar
Malte E committed
                        ToolTip.delay: Nheko.tooltipDelay
                        ToolTip.text: userid
Malte E's avatar
Malte E committed
                    Connections {
                        function onRoomAvatarUrlChanged() {
                            messageUserAvatar.url = chat.model.avatarUrl(userId).replace("mxc://", "image://MxcImage/");
                        }
Malte E's avatar
Malte E committed
                        function onScrollToIndex(index) {
                            chat.positionViewAtIndex(index, ListView.Center);
Malte E's avatar
Malte E committed
                        target: chat.model
                    }
                    AbstractButton {
                        contentItem: Label {
                            id: userName_
                            text: TimelineManager.escapeEmoji(userName)
                            color: TimelineManager.userColor(userId, Nheko.colors.base)
                            textFormat: Text.RichText
                        }
                        ToolTip.visible: hovered
Malte E's avatar
Malte E committed
                        ToolTip.delay: Nheko.tooltipDelay
                        ToolTip.text: userId
                        onClicked: chat.model.openUserProfile(userId)
Malte E's avatar
Malte E committed
                        CursorShape {
                            anchors.fill: parent
                            cursorShape: Qt.PointingHandCursor
                        }
Malte E's avatar
Malte E committed
                    }
Malte E's avatar
Malte E committed
                    Label {
                        id: statusMsg
                        color: Nheko.colors.buttonText
                        text: Presence.userStatus(userId)
                        textFormat: Text.PlainText
                        elide: Text.ElideRight
                        width: chat.delegateMaxWidth - parent.spacing * 2 - userName.implicitWidth - Nheko.avatarSize
                        font.italic: true
Malte E's avatar
Malte E committed
                        Connections {
                            target: Presence
Malte E's avatar
Malte E committed
                            function onPresenceChanged(id) {
                                if (id == userId) statusMsg.text = Presence.userStatus(userId);
Malte E's avatar
Malte E committed
        }
Malte E's avatar
Malte E committed
        delegate: ItemDelegate {
            id: wrapper

            required property double proportionalHeight
            required property int type
            required property string typeString
            required property int originalWidth
            required property string blurhash
            required property string body
            required property string formattedBody
            required property string eventId
            required property string filename
            required property string filesize
            required property string url
            required property string thumbnailUrl
            required property bool isOnlyEmoji
            required property bool isSender
            required property bool isEncrypted
            required property bool isEditable
            required property bool isEdited
            required property bool isStateEvent
            required property bool previousMessageIsStateEvent
            required property string replyTo
            required property string userId
            required property string roomTopic
            required property string roomName
            required property string callType
            required property var reactions
            required property int trustlevel
            required property int encryptionError
            required property var timestamp
            required property int status
            required property int index
            required property int relatedEventCacheBuster
            required property string previousMessageUserId
            required property string day
            required property string previousMessageDay
            required property string userName
            property bool scrolledToThis: eventId === chat.model.scrollTarget && (y + height > chat.y + chat.contentY && y < chat.y + chat.height + chat.contentY)

            anchors.horizontalCenter: parent ? parent.horizontalCenter : undefined
            width: chat.delegateMaxWidth
            height: section.active ? section.height + timelinerow.height : timelinerow.height

            hoverEnabled: true

            background: Rectangle {
                id: scrollHighlight

                opacity: 0
                visible: true
                z: 1
                enabled: false
                color: Nheko.colors.highlight

                states: State {
                    name: "revealed"
                    when: wrapper.scrolledToThis
                }
Malte E's avatar
Malte E committed
                transitions: Transition {
                    from: ""
                    to: "revealed"

                    SequentialAnimation {
                        PropertyAnimation {
                            target: scrollHighlight
                            properties: "opacity"
                            easing.type: Easing.InOutQuad
                            from: 0
                            to: 1
                            duration: 500
                        }
Malte E's avatar
Malte E committed
                        PropertyAnimation {
                            target: scrollHighlight
                            properties: "opacity"
                            easing.type: Easing.InOutQuad
                            from: 1
                            to: 0
                            duration: 500
                        }
Malte E's avatar
Malte E committed
                        ScriptAction {
                            script: chat.model.eventShown()
Malte E's avatar
Malte E committed
            }
Malte E's avatar
Malte E committed
            Loader {
                id: section

                property int parentWidth: parent.width
                property string userId: wrapper.userId
                property string previousMessageUserId: wrapper.previousMessageUserId
                property string day: wrapper.day
                property string previousMessageDay: wrapper.previousMessageDay
                property bool previousMessageIsStateEvent: wrapper.previousMessageIsStateEvent
                property bool isStateEvent: wrapper.isStateEvent
                property bool isSender: wrapper.isSender
                property string userName: wrapper.userName
                property date timestamp: wrapper.timestamp

                z: 4
                active: previousMessageUserId !== undefined && previousMessageUserId !== userId || previousMessageDay !== day || previousMessageIsStateEvent !== isStateEvent
                //asynchronous: true
                sourceComponent: sectionHeader
                visible: status == Loader.Ready
            }
Malte E's avatar
Malte E committed
            TimelineRow {
                id: timelinerow

                hovered: messageActionHover.hovered ? (messageActions.model != undefined && messageActions.model.eventId == timelinerow.eventId) : wrapper.hovered
Malte E's avatar
Malte E committed

                proportionalHeight: wrapper.proportionalHeight
                type: chat.model, wrapper.type
                typeString: wrapper.typeString
                originalWidth: wrapper.originalWidth
                blurhash: wrapper.blurhash
                body: wrapper.body
                formattedBody: wrapper.formattedBody
                eventId: chat.model, wrapper.eventId
                filename: wrapper.filename
                filesize: wrapper.filesize
                url: wrapper.url
                thumbnailUrl: wrapper.thumbnailUrl
                isOnlyEmoji: wrapper.isOnlyEmoji
                isSender: wrapper.isSender
                isEncrypted: wrapper.isEncrypted
                isEditable: wrapper.isEditable
                isEdited: wrapper.isEdited
                isStateEvent: wrapper.isStateEvent
                replyTo: wrapper.replyTo
                userId: wrapper.userId
                userName: wrapper.userName
                roomTopic: wrapper.roomTopic
                roomName: wrapper.roomName
                callType: wrapper.callType
                reactions: wrapper.reactions
                trustlevel: wrapper.trustlevel
                encryptionError: wrapper.encryptionError
                timestamp: wrapper.timestamp
                status: wrapper.status
                relatedEventCacheBuster: wrapper.relatedEventCacheBuster
                y: section.visible && section.active ? section.y + section.height : 0
            }

            onHoveredChanged: {
                if (!Settings.mobileMode && hovered) {
                    if (!messageActionHover.hovered) {
                        messageActions.attached = timelinerow;
                        messageActions.model = timelinerow;
Malte E's avatar
Malte E committed
            }
Malte E's avatar
Malte E committed
            Connections {
                function onMovementEnded() {
                    if (y + height + 2 * chat.spacing > chat.contentY + chat.height && y < chat.contentY + chat.height)
                        chat.model.currentIndex = index;
Malte E's avatar
Malte E committed
                target: chat
Malte E's avatar
Malte E committed
        }
Malte E's avatar
Malte E committed
        footer: Item {
            anchors.horizontalCenter: parent.horizontalCenter
            anchors.margins: Nheko.paddingLarge
            visible: chat.model && chat.model.paginationInProgress
            // hacky, but works
            height: loadingSpinner.height + 2 * Nheko.paddingLarge

            Spinner {
                id: loadingSpinner

                anchors.centerIn: parent
                anchors.margins: Nheko.paddingLarge
                running: chat.model && chat.model.paginationInProgress
                foreground: Nheko.colors.mid
                z: 3
Loren Burkholder's avatar
Loren Burkholder committed

    Platform.Menu {
        id: messageContextMenu

        property string eventId
        property string link
        property string text
        property int eventType
        property bool isEncrypted
        property bool isEditable
        property bool isSender

        function show(eventId_, eventType_, isSender_, isEncrypted_, isEditable_, link_, text_, showAt_) {
            eventId = eventId_;
            eventType = eventType_;
            isEncrypted = isEncrypted_;
            isEditable = isEditable_;
            isSender = isSender_;
            if (text_)
        }

        Platform.MenuItem {
            visible: messageContextMenu.text
            enabled: visible
            text: qsTr("&Copy")
            onTriggered: Clipboard.text = messageContextMenu.text
        }

        Platform.MenuItem {
            visible: messageContextMenu.link
            enabled: visible
            text: qsTr("Copy &link location")
            onTriggered: Clipboard.text = messageContextMenu.link
        }

        Platform.MenuItem {
            id: reactionOption

            visible: room ? room.permissions.canSend(MtxEvent.Reaction) : false
            text: qsTr("Re&act")
            onTriggered: emojiPopup.show(null, function(emoji) {
                room.input.reaction(messageContextMenu.eventId, emoji);
            })
        }

        Platform.MenuItem {
            visible: room ? room.permissions.canSend(MtxEvent.TextMessage) : false
            text: qsTr("Repl&y")
            onTriggered: room.replyAction(messageContextMenu.eventId)
        }

        Platform.MenuItem {
            visible: messageContextMenu.isEditable && (room ? room.permissions.canSend(MtxEvent.TextMessage) : false)
            enabled: visible
            text: qsTr("&Edit")
            onTriggered: room.editAction(messageContextMenu.eventId)
        }

Nicolas Werner's avatar
Nicolas Werner committed
        Platform.MenuItem {
            visible: (room ? room.permissions.canChange(MtxEvent.PinnedEvents) : false)
            enabled: visible
            text: visible && room.pinnedMessages.includes(messageContextMenu.eventId) ? qsTr("Un&pin") : qsTr("&Pin")
            onTriggered: visible && room.pinnedMessages.includes(messageContextMenu.eventId) ? room.unpin(messageContextMenu.eventId) : room.pin(messageContextMenu.eventId)
        }

        Platform.MenuItem {
            text: qsTr("Read receip&ts")
            onTriggered: room.showReadReceipts(messageContextMenu.eventId)
        }

        Platform.MenuItem {
            visible: messageContextMenu.eventType == MtxEvent.ImageMessage || messageContextMenu.eventType == MtxEvent.VideoMessage || messageContextMenu.eventType == MtxEvent.AudioMessage || messageContextMenu.eventType == MtxEvent.FileMessage || messageContextMenu.eventType == MtxEvent.Sticker || messageContextMenu.eventType == MtxEvent.TextMessage || messageContextMenu.eventType == MtxEvent.LocationMessage || messageContextMenu.eventType == MtxEvent.EmoteMessage || messageContextMenu.eventType == MtxEvent.NoticeMessage
            text: qsTr("&Forward")
            onTriggered: {
                var forwardMess = forwardCompleterComponent.createObject(timelineRoot);
                forwardMess.setMessageEventId(messageContextMenu.eventId);
                forwardMess.open();
Nicolas Werner's avatar
Nicolas Werner committed
                timelineRoot.destroyOnClose(forwardMess);
            text: qsTr("&Mark as read")
        }

        Platform.MenuItem {
            text: qsTr("View raw message")
            onTriggered: room.viewRawMessage(messageContextMenu.eventId)
        }

        Platform.MenuItem {
            // TODO(Nico): Fix this still being iterated over, when using keyboard to select options
            visible: messageContextMenu.isEncrypted
            enabled: visible
            text: qsTr("View decrypted raw message")
            onTriggered: room.viewDecryptedRawMessage(messageContextMenu.eventId)
        }

        Platform.MenuItem {
            visible: (room ? room.permissions.canRedact() : false) || messageContextMenu.isSender
            text: qsTr("Remo&ve message")
            onTriggered: room.redactEvent(messageContextMenu.eventId)
        }

        Platform.MenuItem {
            visible: messageContextMenu.eventType == MtxEvent.ImageMessage || messageContextMenu.eventType == MtxEvent.VideoMessage || messageContextMenu.eventType == MtxEvent.AudioMessage || messageContextMenu.eventType == MtxEvent.FileMessage || messageContextMenu.eventType == MtxEvent.Sticker
            enabled: visible
            text: qsTr("&Save as")
            onTriggered: room.saveMedia(messageContextMenu.eventId)
        }

        Platform.MenuItem {
            visible: messageContextMenu.eventType == MtxEvent.ImageMessage || messageContextMenu.eventType == MtxEvent.VideoMessage || messageContextMenu.eventType == MtxEvent.AudioMessage || messageContextMenu.eventType == MtxEvent.FileMessage || messageContextMenu.eventType == MtxEvent.Sticker
            enabled: visible
            text: qsTr("&Open in external program")
            onTriggered: room.openMedia(messageContextMenu.eventId)
        }

        Platform.MenuItem {
            visible: messageContextMenu.eventId
            enabled: visible
            text: qsTr("Copy link to eve&nt")
            onTriggered: room.copyLinkToEvent(messageContextMenu.eventId)
        }

    }

    Component {
        id: forwardCompleterComponent

        ForwardCompleter {
        }

    }

    Platform.Menu {
        id: replyContextMenu

        property string text
        property string link

        function show(text_, link_) {
            text = text_;
            link = link_;
            open();
        }

        Platform.MenuItem {
            visible: replyContextMenu.text
            enabled: visible
            text: qsTr("&Copy")
            onTriggered: Clipboard.text = replyContextMenu.text
        }

        Platform.MenuItem {
            visible: replyContextMenu.link
            enabled: visible
            text: qsTr("Copy &link location")
            onTriggered: Clipboard.text = replyContextMenu.link
        }

        Platform.MenuItem {
            visible: true
            enabled: visible
Nicolas Werner's avatar
Nicolas Werner committed
            text: qsTr("&Go to quoted message")
            onTriggered: chat.model.showEvent(eventId)
        }