// SPDX-FileCopyrightText: Nheko Contributors // // SPDX-License-Identifier: GPL-3.0-or-later import "./components" import "./delegates" import "./emoji" import "./ui" import "./dialogs" 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 import im.nheko 1.0 Item { id: chatRoot property int availableWidth: width property int padding: Nheko.paddingMedium property string searchString: "" property Room roommodel: room // HACK: https://bugreports.qt.io/browse/QTBUG-83972, qtwayland cannot auto hide menu Connections { function onHideMenu() { messageContextMenuC.close(); replyContextMenu.close(); } target: MainWindow } ScrollBar { id: scrollbar anchors.bottom: parent.bottom anchors.right: parent.right anchors.top: parent.top parent: chat.parent } ListView { id: chat property int delegateMaxWidth: ((Settings.timelineMaxWidth > 100 && Settings.timelineMaxWidth < chatRoot.availableWidth) ? Settings.timelineMaxWidth : chatRoot.availableWidth) - chatRoot.padding * 2 - (scrollbar.interactive ? scrollbar.width : 0) readonly property alias filteringInProgress: filteredTimeline.filteringInProgress ScrollBar.vertical: scrollbar anchors.fill: parent anchors.rightMargin: scrollbar.interactive ? scrollbar.width : 0 // 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 displayMarginBeginning: height / 4 displayMarginEnd: height / 4 model: (filteredTimeline.filterByThread || filteredTimeline.filterByContent) ? filteredTimeline : room //pixelAligned: true spacing: 2 verticalLayoutDirection: ListView.BottomToTop Component { id: defaultMessageStyle TimelineDefaultMessageStyle { messageActions: messageActionsC messageContextMenu: messageContextMenuC scrolledToThis: eventId === room.scrollTarget && (y + height > chat.y + chat.contentY && y < chat.y + chat.height + chat.contentY) } } delegate: defaultMessageStyle footer: Item { anchors.horizontalCenter: parent.horizontalCenter anchors.margins: Nheko.paddingLarge // hacky, but works height: loadingSpinner.height + 2 * Nheko.paddingLarge visible: (room && room.paginationInProgress) || chat.filteringInProgress Spinner { id: loadingSpinner anchors.centerIn: parent anchors.margins: Nheko.paddingLarge foreground: palette.mid running: (room && room.paginationInProgress) || chat.filteringInProgress z: 3 } } Window.onActiveChanged: readTimer.running = Window.active onCountChanged: { // Mark timeline as read if (atYEnd && room) model.currentIndex = 0; } TimelineFilter { id: filteredTimeline filterByContent: chatRoot.searchString filterByThread: room ? room.thread : "" source: room } Control { id: messageActionsC property Item attached: null // use comma to update on scroll property alias model: row.model hoverEnabled: true padding: Nheko.paddingSmall visible: Settings.buttonsInTimeline && !!attached && (attached.hovered || hovered) z: 10 parent: chat.contentItem anchors.bottom: attached?.top anchors.right: attached?.right background: Rectangle { border.color: palette.buttonText border.width: 1 color: palette.window radius: padding } contentItem: RowLayout { id: row property var model spacing: messageActionsC.padding Repeater { model: Settings.recentReactions visible: room ? room.permissions.canSend(MtxEvent.Reaction) : false delegate: AbstractButton { id: button property color buttonTextColor: palette.buttonText property color highlightColor: palette.highlight required property string modelData property bool showImage: modelData.startsWith("mxc://") //Layout.preferredHeight: fontMetrics.height Layout.alignment: Qt.AlignBottom focusPolicy: Qt.NoFocus height: showImage ? 16 : buttonText.implicitHeight implicitHeight: showImage ? 16 : buttonText.implicitHeight implicitWidth: showImage ? 16 : buttonText.implicitWidth width: showImage ? 16 : buttonText.implicitWidth onClicked: { room.input.reaction(row.model.eventId, modelData); TimelineManager.focusMessageInput(); } Label { id: buttonText anchors.centerIn: parent color: button.hovered ? button.highlightColor : button.buttonTextColor font.family: Settings.emojiFont horizontalAlignment: Text.AlignHCenter padding: 0 text: button.modelData verticalAlignment: Text.AlignVCenter visible: !button.showImage } Image { id: buttonImg // Workaround, can't get icon.source working for now... anchors.fill: parent fillMode: Image.PreserveAspectFit source: button.showImage ? (button.modelData.replace("mxc://", "image://MxcImage/") + "?scale") : "" sourceSize.height: button.height sourceSize.width: button.width } NhekoCursorShape { anchors.fill: parent cursorShape: Qt.PointingHandCursor } Ripple { color: Qt.rgba(buttonTextColor.r, buttonTextColor.g, buttonTextColor.b, 0.5) } } } ImageButton { ToolTip.delay: Nheko.tooltipDelay ToolTip.text: qsTr("Edit") ToolTip.visible: hovered buttonTextColor: palette.buttonText hoverEnabled: true image: ":/icons/icons/ui/edit.svg" visible: !!row.model && row.model.isEditable width: 16 onClicked: { if (row.model.isEditable) room.edit = row.model.eventId; } } ImageButton { id: reactButton ToolTip.delay: Nheko.tooltipDelay ToolTip.text: qsTr("React") ToolTip.visible: hovered hoverEnabled: true image: ":/icons/icons/ui/smile-add.svg" visible: room ? room.permissions.canSend(MtxEvent.Reaction) : false width: 16 onClicked: emojiPopup.visible ? emojiPopup.close() : emojiPopup.show(reactButton, room.roomId, function (plaintext, markdown) { var event_id = row.model ? row.model.eventId : ""; room.input.reaction(event_id, plaintext); TimelineManager.focusMessageInput(); }) } ImageButton { ToolTip.delay: Nheko.tooltipDelay ToolTip.text: (row.model && row.model.threadId) ? qsTr("Reply in thread") : qsTr("New thread") ToolTip.visible: hovered hoverEnabled: true image: (row.model && row.model.threadId) ? ":/icons/icons/ui/thread.svg" : ":/icons/icons/ui/new-thread.svg" visible: room ? room.permissions.canSend(MtxEvent.TextMessage) : false width: 16 onClicked: room.thread = (row.model.threadId || row.model.eventId) } ImageButton { ToolTip.delay: Nheko.tooltipDelay ToolTip.text: qsTr("Reply") ToolTip.visible: hovered hoverEnabled: true image: ":/icons/icons/ui/reply.svg" visible: room ? room.permissions.canSend(MtxEvent.TextMessage) : false width: 16 onClicked: room.reply = row.model.eventId } ImageButton { ToolTip.delay: Nheko.tooltipDelay ToolTip.text: qsTr("Go to message") ToolTip.visible: hovered buttonTextColor: palette.buttonText hoverEnabled: true image: ":/icons/icons/ui/go-to.svg" visible: !!row.model && filteredTimeline.filterByContent width: 16 onClicked: { topBar.searchString = ""; room.showEvent(row.model.eventId); } } ImageButton { id: optionsButton ToolTip.delay: Nheko.tooltipDelay ToolTip.text: qsTr("Options") ToolTip.visible: hovered hoverEnabled: true image: ":/icons/icons/ui/options.svg" width: 16 onClicked: messageContextMenuC.show(row.model.eventId, row.model.threadId, row.model.type, row.model.isSender, row.model.isEncrypted, row.model.isEditable, "", row.model.body, optionsButton) } } } Shortcut { sequence: StandardKey.MoveToPreviousPage onActivated: { chat.contentY = chat.contentY - chat.height * 0.9; chat.returnToBounds(); } } Shortcut { sequence: StandardKey.MoveToNextPage onActivated: { chat.contentY = chat.contentY + chat.height * 0.9; chat.returnToBounds(); } } Shortcut { sequence: StandardKey.Cancel onActivated: { if (room.input.uploads.length > 0) room.input.declineUploads(); else if (room.reply) room.reply = undefined; else if (room.edit) room.edit = undefined; else room.thread = undefined; TimelineManager.focusMessageInput(); } } // These shortcuts use the room timeline because switching to threads and out is annoying otherwise. // Better solution welcome. Shortcut { sequence: "Alt+Up" onActivated: room.reply = room.indexToId(room.reply ? room.idToIndex(room.reply) + 1 : 0) } Shortcut { sequence: "Alt+Down" onActivated: { var idx = room.reply ? room.idToIndex(room.reply) - 1 : -1; room.reply = idx >= 0 ? room.indexToId(idx) : null; } } Shortcut { sequence: "Alt+F" onActivated: { if (room.reply) { var forwardMess = forwardCompleterComponent.createObject(timelineRoot); forwardMess.setMessageEventId(room.reply); forwardMess.open(); room.reply = null; timelineRoot.destroyOnClose(forwardMess); } } } Shortcut { sequence: "Ctrl+E" onActivated: { room.edit = room.reply; } } Timer { id: readTimer interval: 1000 // force current read index to update onTriggered: { if (room) room.setCurrentIndex(room.currentIndex); } } } Platform.Menu { id: messageContextMenuC property string eventId property int eventType property bool isEditable property bool isEncrypted property bool isSender property string link property string text property string threadId function show(eventId_, threadId_, eventType_, isSender_, isEncrypted_, isEditable_, link_, text_, showAt_) { eventId = eventId_; threadId = threadId_; eventType = eventType_; isEncrypted = isEncrypted_; isEditable = isEditable_; isSender = isSender_; if (text_) text = text_; else text = ""; if (link_) link = link_; else link = ""; if (showAt_) open(showAt_); else open(); } Component { id: removeReason InputDialog { id: removeReasonDialog property string eventId prompt: qsTr("Enter reason for removal or hit enter for no reason:") title: qsTr("Reason for removal") onAccepted: function (text) { room.redactEvent(eventId, text); } } } Platform.MenuItem { enabled: visible text: qsTr("Go to &message") visible: filteredTimeline.filterByContent onTriggered: function () { topBar.searchString = ""; room.showEvent(messageContextMenuC.eventId); } } Platform.MenuItem { enabled: visible text: qsTr("&Copy") visible: messageContextMenuC.text onTriggered: Clipboard.text = messageContextMenuC.text } Platform.MenuItem { enabled: visible text: qsTr("Copy &link location") visible: messageContextMenuC.link onTriggered: Clipboard.text = messageContextMenuC.link } Platform.MenuItem { id: reactionOption text: qsTr("Re&act") visible: room ? room.permissions.canSend(MtxEvent.Reaction) : false onTriggered: emojiPopup.visible ? emojiPopup.close() : emojiPopup.show(null, room.roomId, function (plaintext, markdown) { room.input.reaction(messageContextMenuC.eventId, plaintext); TimelineManager.focusMessageInput(); }) } Platform.MenuItem { text: qsTr("Repl&y") visible: room ? room.permissions.canSend(MtxEvent.TextMessage) : false onTriggered: room.reply = (messageContextMenuC.eventId) } Platform.MenuItem { enabled: visible text: qsTr("&Edit") visible: messageContextMenuC.isEditable && (room ? room.permissions.canSend(MtxEvent.TextMessage) : false) onTriggered: room.edit = (messageContextMenuC.eventId) } Platform.MenuItem { enabled: visible text: qsTr("&Thread") visible: (room ? room.permissions.canSend(MtxEvent.TextMessage) : false) onTriggered: room.thread = (messageContextMenuC.threadId || messageContextMenuC.eventId) } Platform.MenuItem { enabled: visible text: visible && room.pinnedMessages.includes(messageContextMenuC.eventId) ? qsTr("Un&pin") : qsTr("&Pin") visible: (room ? room.permissions.canChange(MtxEvent.PinnedEvents) : false) onTriggered: visible && room.pinnedMessages.includes(messageContextMenuC.eventId) ? room.unpin(messageContextMenuC.eventId) : room.pin(messageContextMenuC.eventId) } Platform.MenuItem { text: qsTr("&Read receipts") onTriggered: room.showReadReceipts(messageContextMenuC.eventId) } Platform.MenuItem { text: qsTr("&Forward") visible: messageContextMenuC.eventType == MtxEvent.ImageMessage || messageContextMenuC.eventType == MtxEvent.VideoMessage || messageContextMenuC.eventType == MtxEvent.AudioMessage || messageContextMenuC.eventType == MtxEvent.FileMessage || messageContextMenuC.eventType == MtxEvent.Sticker || messageContextMenuC.eventType == MtxEvent.TextMessage || messageContextMenuC.eventType == MtxEvent.LocationMessage || messageContextMenuC.eventType == MtxEvent.EmoteMessage || messageContextMenuC.eventType == MtxEvent.NoticeMessage onTriggered: { var forwardMess = forwardCompleterComponent.createObject(timelineRoot); forwardMess.setMessageEventId(messageContextMenuC.eventId); forwardMess.open(); timelineRoot.destroyOnClose(forwardMess); } } Platform.MenuItem { text: qsTr("&Mark as read") } Platform.MenuItem { text: qsTr("View raw message") onTriggered: room.viewRawMessage(messageContextMenuC.eventId) } Platform.MenuItem { enabled: visible text: qsTr("View decrypted raw message") // TODO(Nico): Fix this still being iterated over, when using keyboard to select options visible: messageContextMenuC.isEncrypted onTriggered: room.viewDecryptedRawMessage(messageContextMenuC.eventId) } Platform.MenuItem { text: qsTr("Remo&ve message") visible: (room ? room.permissions.canRedact() : false) || messageContextMenuC.isSender onTriggered: function () { var dialog = removeReason.createObject(timelineRoot); dialog.eventId = messageContextMenuC.eventId; dialog.show(); dialog.forceActiveFocus(); timelineRoot.destroyOnClose(dialog); } } Platform.MenuItem { enabled: visible text: qsTr("&Save as") visible: messageContextMenuC.eventType == MtxEvent.ImageMessage || messageContextMenuC.eventType == MtxEvent.VideoMessage || messageContextMenuC.eventType == MtxEvent.AudioMessage || messageContextMenuC.eventType == MtxEvent.FileMessage || messageContextMenuC.eventType == MtxEvent.Sticker onTriggered: room.saveMedia(messageContextMenuC.eventId) } Platform.MenuItem { enabled: visible text: qsTr("&Open in external program") visible: messageContextMenuC.eventType == MtxEvent.ImageMessage || messageContextMenuC.eventType == MtxEvent.VideoMessage || messageContextMenuC.eventType == MtxEvent.AudioMessage || messageContextMenuC.eventType == MtxEvent.FileMessage || messageContextMenuC.eventType == MtxEvent.Sticker onTriggered: room.openMedia(messageContextMenuC.eventId) } Platform.MenuItem { enabled: visible text: qsTr("Copy link to eve&nt") visible: messageContextMenuC.eventId onTriggered: room.copyLinkToEvent(messageContextMenuC.eventId) } } Component { id: forwardCompleterComponent ForwardCompleter { } } Platform.Menu { id: replyContextMenu property string eventId property string link property string text function show(text_, link_, eventId_) { text = text_; link = link_; eventId = eventId_; open(); } Platform.MenuItem { enabled: visible text: qsTr("&Copy") visible: replyContextMenu.text onTriggered: Clipboard.text = replyContextMenu.text } Platform.MenuItem { enabled: visible text: qsTr("Copy &link location") visible: replyContextMenu.link onTriggered: Clipboard.text = replyContextMenu.link } Platform.MenuItem { enabled: visible text: qsTr("&Go to quoted message") visible: true onTriggered: room.showEvent(replyContextMenu.eventId) } } RoundButton { id: toEndButton property int fullWidth: 40 flat: true height: width hoverEnabled: true radius: width / 2 width: 0 background: Rectangle { border.color: toEndButton.hovered ? palette.highlight : palette.buttonText border.width: 1 color: toEndButton.down ? palette.highlight : palette.button opacity: enabled ? 1 : 0.3 radius: toEndButton.radius } states: [ State { name: "" PropertyChanges { target: toEndButton width: 0 } }, State { name: "shown" when: !chat.atYEnd PropertyChanges { target: toEndButton width: toEndButton.fullWidth } } ] transitions: Transition { from: "" reversible: true to: "shown" SequentialAnimation { PauseAnimation { duration: 500 } PropertyAnimation { duration: 200 easing.type: Easing.InOutQuad properties: "width" target: toEndButton } } } onClicked: function () { chat.positionViewAtBeginning(); TimelineManager.focusMessageInput(); } anchors { bottom: parent.bottom bottomMargin: Nheko.paddingMedium + (fullWidth - width) / 2 right: scrollbar.left rightMargin: Nheko.paddingMedium + (fullWidth - width) / 2 } Image { id: buttonImg anchors.fill: parent anchors.margins: Nheko.paddingMedium fillMode: Image.PreserveAspectFit source: "image://colorimage/:/icons/icons/ui/download.svg?" + (toEndButton.down ? palette.highlightedText : palette.buttonText) } } }