// 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: "" // HACK: https://bugreports.qt.io/browse/QTBUG-83972, qtwayland cannot auto hide menu Connections { function onHideMenu() { messageContextMenu.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 / 2 displayMarginEnd: height / 2 model: (filteredTimeline.filterByThread || filteredTimeline.filterByContent) ? filteredTimeline : room //pixelAligned: true spacing: 2 verticalLayoutDirection: ListView.BottomToTop delegate: Item { id: wrapper required property string blurhash required property string body required property string callType required property var day required property string duration required property int encryptionError required property string eventId required property string filename required property string filesize required property string formattedBody required property int index required property bool isEditable required property bool isEdited required property bool isEncrypted required property bool isOnlyEmoji required property bool isSender required property bool isStateEvent required property int notificationlevel required property int originalWidth property var previousMessageDay: (index + 1) >= chat.count ? 0 : chat.model.dataByIndex(index + 1, Room.Day) property bool previousMessageIsStateEvent: (index + 1) >= chat.count ? true : chat.model.dataByIndex(index + 1, Room.IsStateEvent) property string previousMessageUserId: (index + 1) >= chat.count ? "" : chat.model.dataByIndex(index + 1, Room.UserId) required property double proportionalHeight required property var reactions required property int relatedEventCacheBuster required property string replyTo required property string roomName required property string roomTopic property bool scrolledToThis: eventId === room.scrollTarget && (y + height > chat.y + chat.contentY && y < chat.y + chat.height + chat.contentY) required property int status required property string threadId required property string thumbnailUrl required property var timestamp required property int trustlevel required property int type required property string typeString required property string url required property string userId required property string userName ListView.delayRemove: true anchors.horizontalCenter: parent ? parent.horizontalCenter : undefined height: (section.item?.height ?? 0) + timelinerow.height width: chat.delegateMaxWidth Loader { id: section property var day: wrapper.day property bool isSender: wrapper.isSender property bool isStateEvent: wrapper.isStateEvent property int parentWidth: parent.width property var previousMessageDay: wrapper.previousMessageDay property bool previousMessageIsStateEvent: wrapper.previousMessageIsStateEvent property string previousMessageUserId: wrapper.previousMessageUserId property date timestamp: wrapper.timestamp property string userId: wrapper.userId property string userName: wrapper.userName active: previousMessageUserId !== userId || previousMessageDay !== day || previousMessageIsStateEvent !== isStateEvent //asynchronous: true sourceComponent: sectionHeader visible: status == Loader.Ready z: 4 } TimelineRow { id: timelinerow blurhash: wrapper.blurhash body: wrapper.body callType: wrapper.callType duration: wrapper.duration encryptionError: wrapper.encryptionError eventId: chat.model, wrapper.eventId filename: wrapper.filename filesize: wrapper.filesize formattedBody: wrapper.formattedBody index: wrapper.index isEditable: wrapper.isEditable isEdited: wrapper.isEdited isEncrypted: wrapper.isEncrypted isOnlyEmoji: wrapper.isOnlyEmoji isSender: wrapper.isSender isStateEvent: wrapper.isStateEvent notificationlevel: wrapper.notificationlevel originalWidth: wrapper.originalWidth proportionalHeight: wrapper.proportionalHeight reactions: wrapper.reactions relatedEventCacheBuster: wrapper.relatedEventCacheBuster replyTo: wrapper.replyTo roomName: wrapper.roomName roomTopic: wrapper.roomTopic status: wrapper.status threadId: wrapper.threadId thumbnailUrl: wrapper.thumbnailUrl timestamp: wrapper.timestamp trustlevel: wrapper.trustlevel type: chat.model, wrapper.type typeString: wrapper.typeString url: wrapper.url userId: wrapper.userId userName: wrapper.userName width: wrapper.width y: section.visible && section.active ? section.y + section.height : 0 background: Rectangle { id: scrollHighlight color: palette.highlight enabled: false opacity: 0 visible: true z: 1 states: State { name: "revealed" when: wrapper.scrolledToThis } transitions: Transition { from: "" to: "revealed" SequentialAnimation { PropertyAnimation { duration: 500 easing.type: Easing.InOutQuad from: 0 properties: "opacity" target: scrollHighlight to: 1 } PropertyAnimation { duration: 500 easing.type: Easing.InOutQuad from: 1 properties: "opacity" target: scrollHighlight to: 0 } ScriptAction { script: room.eventShown() } } } } onHoveredChanged: { if (!Settings.mobileMode && hovered) { if (!messageActions.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; } target: chat } } 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: messageActions property Item attached: null // use comma to update on scroll property var attachedPos: chat.contentY, attached ? chat.mapFromItem(attached, attached ? attached.width - width : 0, -height) : null property alias model: row.model hoverEnabled: true padding: Nheko.paddingSmall visible: Settings.buttonsInTimeline && !!attached && (attached.hovered || hovered) x: attached ? attachedPos.x : 0 y: attached ? attachedPos.y + Nheko.paddingSmall : 0 z: 10 background: Rectangle { border.color: palette.buttonText border.width: 1 color: palette.window radius: padding } contentItem: RowLayout { id: row property var model spacing: messageActions.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 } CursorShape { 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: messageContextMenu.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); } } Component { id: sectionHeader Column { bottomPadding: Settings.bubbles ? (isSender && previousMessageDay == day ? 0 : 2) : 3 spacing: 8 topPadding: userName_.visible ? 4 : 0 visible: (previousMessageUserId !== userId || previousMessageDay !== day || isStateEvent !== previousMessageIsStateEvent) width: parentWidth Label { id: dateBubble anchors.horizontalCenter: parent ? parent.horizontalCenter : undefined color: palette.text height: Math.round(fontMetrics.height * 1.4) horizontalAlignment: Text.AlignHCenter text: room ? room.formatDateSeparator(timestamp) : "" verticalAlignment: Text.AlignVCenter visible: room && previousMessageDay !== day width: contentWidth * 1.2 background: Rectangle { color: palette.window radius: parent.height / 2 } } Row { id: userInfo property int remainingWidth: chat.delegateMaxWidth - spacing - messageUserAvatar.width height: userName_.height spacing: 8 visible: !isStateEvent && (!isSender || !Settings.bubbles) Avatar { id: messageUserAvatar ToolTip.delay: Nheko.tooltipDelay ToolTip.text: userid ToolTip.visible: messageUserAvatar.hovered displayName: userName height: Nheko.avatarSize * (Settings.smallAvatars ? 0.5 : 1) url: !room ? "" : room.avatarUrl(userId).replace("mxc://", "image://MxcImage/") userid: userId width: Nheko.avatarSize * (Settings.smallAvatars ? 0.5 : 1) onClicked: room.openUserProfile(userId) } Connections { function onRoomAvatarUrlChanged() { messageUserAvatar.url = room.avatarUrl(userId).replace("mxc://", "image://MxcImage/"); } function onScrollToIndex(index) { chat.positionViewAtIndex(index, ListView.Center); } target: room } AbstractButton { id: userNameButton ToolTip.delay: Nheko.tooltipDelay ToolTip.text: userId ToolTip.visible: hovered leftInset: 0 leftPadding: 0 rightInset: 0 rightPadding: 0 contentItem: Label { id: userName_ color: TimelineManager.userColor(userId, palette.base) text: TimelineManager.escapeEmoji(userNameTextMetrics.elidedText) textFormat: Text.RichText } onClicked: room.openUserProfile(userId) TextMetrics { id: userNameTextMetrics elide: Text.ElideRight elideWidth: userInfo.remainingWidth - Math.min(statusMsg.implicitWidth, userInfo.remainingWidth / 3) text: userName } CursorShape { anchors.fill: parent cursorShape: Qt.PointingHandCursor } } Label { id: statusMsg property string userStatus: Presence.userStatus(userId) ToolTip.delay: Nheko.tooltipDelay ToolTip.text: qsTr("%1's status message").arg(userName) ToolTip.visible: statusMsgHoverHandler.hovered anchors.baseline: userNameButton.baseline color: palette.buttonText elide: Text.ElideRight font.italic: true font.pointSize: Math.floor(fontMetrics.font.pointSize * 0.8) text: userStatus.replace(/\n/g, " ") textFormat: Text.PlainText width: Math.min(implicitWidth, userInfo.remainingWidth - userName_.width - parent.spacing) HoverHandler { id: statusMsgHoverHandler } Connections { function onPresenceChanged(id) { if (id == userId) statusMsg.userStatus = Presence.userStatus(userId); } target: Presence } } } } } } Platform.Menu { id: messageContextMenu 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(messageContextMenu.eventId); } } Platform.MenuItem { enabled: visible text: qsTr("&Copy") visible: messageContextMenu.text onTriggered: Clipboard.text = messageContextMenu.text } Platform.MenuItem { enabled: visible text: qsTr("Copy &link location") visible: messageContextMenu.link onTriggered: Clipboard.text = messageContextMenu.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(messageContextMenu.eventId, plaintext); TimelineManager.focusMessageInput(); }) } Platform.MenuItem { text: qsTr("Repl&y") visible: room ? room.permissions.canSend(MtxEvent.TextMessage) : false onTriggered: room.reply = (messageContextMenu.eventId) } Platform.MenuItem { enabled: visible text: qsTr("&Edit") visible: messageContextMenu.isEditable && (room ? room.permissions.canSend(MtxEvent.TextMessage) : false) onTriggered: room.edit = (messageContextMenu.eventId) } Platform.MenuItem { enabled: visible text: qsTr("&Thread") visible: (room ? room.permissions.canSend(MtxEvent.TextMessage) : false) onTriggered: room.thread = (messageContextMenu.threadId || messageContextMenu.eventId) } Platform.MenuItem { enabled: visible text: visible && room.pinnedMessages.includes(messageContextMenu.eventId) ? qsTr("Un&pin") : qsTr("&Pin") visible: (room ? room.permissions.canChange(MtxEvent.PinnedEvents) : false) onTriggered: visible && room.pinnedMessages.includes(messageContextMenu.eventId) ? room.unpin(messageContextMenu.eventId) : room.pin(messageContextMenu.eventId) } Platform.MenuItem { text: qsTr("&Read receipts") onTriggered: room.showReadReceipts(messageContextMenu.eventId) } Platform.MenuItem { text: qsTr("&Forward") 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 onTriggered: { var forwardMess = forwardCompleterComponent.createObject(timelineRoot); forwardMess.setMessageEventId(messageContextMenu.eventId); forwardMess.open(); timelineRoot.destroyOnClose(forwardMess); } } Platform.MenuItem { text: qsTr("&Mark as read") } Platform.MenuItem { text: qsTr("View raw message") onTriggered: room.viewRawMessage(messageContextMenu.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: messageContextMenu.isEncrypted onTriggered: room.viewDecryptedRawMessage(messageContextMenu.eventId) } Platform.MenuItem { text: qsTr("Remo&ve message") visible: (room ? room.permissions.canRedact() : false) || messageContextMenu.isSender onTriggered: function () { var dialog = removeReason.createObject(timelineRoot); dialog.eventId = messageContextMenu.eventId; dialog.show(); dialog.forceActiveFocus(); timelineRoot.destroyOnClose(dialog); } } Platform.MenuItem { enabled: visible text: qsTr("&Save as") visible: messageContextMenu.eventType == MtxEvent.ImageMessage || messageContextMenu.eventType == MtxEvent.VideoMessage || messageContextMenu.eventType == MtxEvent.AudioMessage || messageContextMenu.eventType == MtxEvent.FileMessage || messageContextMenu.eventType == MtxEvent.Sticker onTriggered: room.saveMedia(messageContextMenu.eventId) } Platform.MenuItem { enabled: visible text: qsTr("&Open in external program") visible: messageContextMenu.eventType == MtxEvent.ImageMessage || messageContextMenu.eventType == MtxEvent.VideoMessage || messageContextMenu.eventType == MtxEvent.AudioMessage || messageContextMenu.eventType == MtxEvent.FileMessage || messageContextMenu.eventType == MtxEvent.Sticker onTriggered: room.openMedia(messageContextMenu.eventId) } Platform.MenuItem { enabled: visible text: qsTr("Copy link to eve&nt") visible: messageContextMenu.eventId onTriggered: room.copyLinkToEvent(messageContextMenu.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) } } }