Skip to content
Snippets Groups Projects
MessageView.qml 28.2 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
        interactive: !touchObserver.wasTouched
        parent: chat.parent
        anchors.top: parent.top
        anchors.right: parent.right
        anchors.bottom: parent.bottom
    }
    EventObserver {
        id: touchObserver
        anchors.fill: parent
            anchors.fill: parent
            property int delegateMaxWidth: ((Settings.timelineMaxWidth > 100 && Settings.timelineMaxWidth < chatRoot.availableWidth) ? Settings.timelineMaxWidth : chatRoot.availableWidth) - chatRoot.padding * 2 - scrollbar.width

            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;
            ScrollBar.vertical: scrollbar
            anchors.rightMargin: scrollbar.interactive ? scrollbar.width : 0
            Rectangle {
                //closePolicy: Popup.NoAutoClose
                id: messageActions
                property Item attached: null
                property alias model: row.model
                // use comma to update on scroll
                property var attachedPos: chat.contentY, attached ? chat.mapFromItem(attached, attached ? attached.width - width : 0, -height) : null
                readonly property int padding: Nheko.paddingSmall
                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
                HoverHandler {
                    id: messageActionHover
                    grabPermissions: PointerHandler.CanTakeOverFromAnything
                }

                Row {
                    id: row

                    property var model

                    anchors.centerIn: parent
                    spacing: messageActions.padding

                    Repeater {
                        model: Settings.recentReactions

                        delegate: TextButton {
                            required property string modelData

                            visible: chat.model ? chat.model.permissions.canSend(MtxEvent.Reaction) : false

                            height: fontMetrics.height
                            font.family: Settings.emojiFont

                            text: modelData
                            onClicked: {
                                room.input.reaction(row.model.eventId, modelData);
                                TimelineManager.focusMessageInput();
                            }
                    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);
                    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();
                        })
                    }
                    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)
                    }

                    ImageButton {
                        id: optionsButton

                        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)
                    }
            ScrollHelper {
                flickable: parent
                anchors.fill: parent
            }
            Shortcut {
                sequence: StandardKey.MoveToPreviousPage
                onActivated: {
                    chat.contentY = chat.contentY - chat.height / 2;
                    chat.returnToBounds();
                }
            Shortcut {
                sequence: StandardKey.MoveToNextPage
                onActivated: {
                    chat.contentY = chat.contentY + chat.height / 2;
                    chat.returnToBounds();
                }
            Shortcut {
                sequence: StandardKey.Cancel
                onActivated: {
                    if (chat.model.reply)
                    chat.model.reply = undefined;
                    chat.model.edit = undefined;
            Shortcut {
                sequence: "Alt+Up"
                onActivated: chat.model.reply = chat.model.indexToId(chat.model.reply ? chat.model.idToIndex(chat.model.reply) + 1 : 0)
            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;
            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;
                    }
                }
            Shortcut {
                sequence: "Ctrl+E"
                onActivated: {
                    chat.model.edit = chat.model.reply;
                }
            Connections {
                function onFocusChanged() {
                    readTimer.running = TimelineManager.isWindowFocused;
                }

                target: TimelineManager
            }
                // force current read index to update
                onTriggered: {
                    if (chat.model)
                    chat.model.setCurrentIndex(chat.model.currentIndex);
            Component {
                id: sectionHeader
                Column {
                    topPadding: userName_.visible? 4: 0
                    bottomPadding: Settings.bubbles? (isSender? 0 : 2) : 3
                    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
                    Row {
                        height: userName_.height
                        spacing: 8
                        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: avatarHover.hovered
                            ToolTip.delay: Nheko.tooltipDelay
                            ToolTip.text: userid

                            HoverHandler {
                                id: avatarHover
                            }
                        Connections {
                            function onRoomAvatarUrlChanged() {
                                messageUserAvatar.url = chat.model.avatarUrl(userId).replace("mxc://", "image://MxcImage/");
                            }
                            function onScrollToIndex(index) {
                                chat.positionViewAtIndex(index, ListView.Center);
                            }
                            target: chat.model
                            text: TimelineManager.escapeEmoji(userName)
                            color: TimelineManager.userColor(userId, Nheko.colors.base)
                            textFormat: Text.RichText
                            ToolTip.visible: displayNameHover.hovered
                            ToolTip.delay: Nheko.tooltipDelay
                            ToolTip.text: userId
                            TapHandler {
                                onSingleTapped: chat.model.openUserProfile(userId)
                                dragThreshold: 0
                            }
                            CursorShape {
                                anchors.fill: parent
                                cursorShape: Qt.PointingHandCursor
                            }
                            HoverHandler {
                                id: displayNameHover
                            }
                        }

                        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

                            Connections {
                                target: Presence

                                function onPresenceChanged(id) {
                                    if (id == userId) statusMsg.text = Presence.userStatus(userId);
                                }
            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
                    }
                    transitions: Transition {
                        from: ""
                        to: "revealed"

                        SequentialAnimation {
                            PropertyAnimation {
                                target: scrollHighlight
                                properties: "opacity"
                                easing.type: Easing.InOutQuad
                                from: 0
                                to: 1
                                duration: 500
                            }
                            PropertyAnimation {
                                target: scrollHighlight
                                properties: "opacity"
                                easing.type: Easing.InOutQuad
                                from: 1
                                to: 0
                                duration: 500
                            }
                            ScriptAction {
                                script: chat.model.eventShown()
                            }
                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
                }
                TimelineRow {
                    id: timelinerow

                    hovered: (wrapper.hovered && !messageActionHover.hovered) || (messageActions.model != undefined && messageActions.model.eventId == timelinerow.eventId)

                    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;
                        }
                Connections {
                    function onMovementEnded() {
                        if (y + height + 2 * chat.spacing > chat.contentY + chat.height && y < chat.contentY + chat.height)
                        chat.model.currentIndex = index;
            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();
            }
        }

        Platform.MenuItem {
            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)
        }