// SPDX-FileCopyrightText: 2021 Nheko Contributors // SPDX-FileCopyrightText: 2022 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 Item { id: chatRoot property int availableWidth: width property int padding: Nheko.paddingMedium 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) 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: 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 string 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 originalWidth required property string previousMessageDay required property bool previousMessageIsStateEvent required property string previousMessageUserId 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 === chat.model.scrollTarget && (y + height > chat.y + chat.contentY && y < chat.y + chat.height + chat.contentY) required property int status 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 anchors.horizontalCenter: parent ? parent.horizontalCenter : undefined height: section.active ? (section.item?.implicitHeight ?? 0) + timelinerow.height : timelinerow.height width: chat.delegateMaxWidth Loader { id: section property string day: wrapper.day property bool isSender: wrapper.isSender property bool isStateEvent: wrapper.isStateEvent property int parentWidth: parent.width property string 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 !== undefined && 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 isEditable: wrapper.isEditable isEdited: wrapper.isEdited isEncrypted: wrapper.isEncrypted isOnlyEmoji: wrapper.isOnlyEmoji isSender: wrapper.isSender isStateEvent: wrapper.isStateEvent originalWidth: wrapper.originalWidth proportionalHeight: wrapper.proportionalHeight reactions: wrapper.reactions relatedEventCacheBuster: wrapper.relatedEventCacheBuster replyTo: wrapper.replyTo roomName: wrapper.roomName roomTopic: wrapper.roomTopic status: wrapper.status 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 y: section.item?.implicitHeight ?? 0 background: Rectangle { id: scrollHighlight color: timelineRoot.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: chat.model.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.item?.implicitHeight ?? 0) + 2 * Nheko.paddingLarge visible: chat.model && chat.model.paginationInProgress Spinner { id: loadingSpinner anchors.centerIn: parent anchors.margins: Nheko.paddingLarge foreground: timelineRoot.palette.mid running: chat.model && chat.model.paginationInProgress z: 3 } } onCountChanged: { // Mark timeline as read if (atYEnd && room) model.currentIndex = 0; } 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: timelineRoot.palette.placeholderText border.width: 1 color: timelineRoot.palette.window radius: padding } contentItem: RowLayout { id: row property var model spacing: messageActions.padding Repeater { model: Settings.recentReactions delegate: TextButton { required property string modelData Layout.preferredHeight: fontMetrics.height font.family: Settings.emojiFont text: modelData visible: chat.model ? chat.model.permissions.canSend(MtxEvent.Reaction) : false onClicked: { room.input.reaction(row.model.eventId, modelData); TimelineManager.focusMessageInput(); } } } ImageButton { id: editButton ToolTip.delay: Nheko.tooltipDelay ToolTip.text: qsTr("Edit") ToolTip.visible: hovered buttonTextColor: timelineRoot.palette.placeholderText hoverEnabled: true image: ":/icons/icons/ui/edit.svg" visible: !!row.model && row.model.isEditable width: 16 onClicked: { if (row.model.isEditable) chat.model.editAction(row.model.eventId); } } ImageButton { id: reactButton ToolTip.delay: Nheko.tooltipDelay ToolTip.text: qsTr("React") ToolTip.visible: hovered hoverEnabled: true image: ":/icons/icons/ui/smile.svg" visible: chat.model ? chat.model.permissions.canSend(MtxEvent.Reaction) : false width: 16 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 ToolTip.delay: Nheko.tooltipDelay ToolTip.text: qsTr("Reply") ToolTip.visible: hovered hoverEnabled: true image: ":/icons/icons/ui/reply.svg" visible: chat.model ? chat.model.permissions.canSend(MtxEvent.TextMessage) : false width: 16 onClicked: chat.model.replyAction(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.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 / 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; else 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; timelineRoot.destroyOnClose(forwardMess); } } } Shortcut { sequence: "Ctrl+E" onActivated: { chat.model.edit = chat.model.reply; } } Connections { function onFocusChanged() { readTimer.running = TimelineManager.isWindowFocused; } target: TimelineManager } Timer { id: readTimer interval: 1000 // force current read index to update onTriggered: { if (chat.model) chat.model.setCurrentIndex(chat.model.currentIndex); } } Component { id: sectionHeader Column { bottomPadding: Settings.bubbles ? (isSender && previousMessageDay == day ? 0 : 2) : 3 height: ((previousMessageDay !== day) ? dateBubble.height : 0) + (isStateEvent ? 0 : userName.height + 8) 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: timelineRoot.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: timelineRoot.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 = chat.model.avatarUrl(userId).replace("mxc://", "image://MxcImage/"); } function onScrollToIndex(index) { chat.positionViewAtIndex(index, ListView.Center); } target: chat.model } AbstractButton { ToolTip.delay: Nheko.tooltipDelay ToolTip.text: userId ToolTip.visible: hovered leftInset: 0 leftPadding: 0 rightInset: 0 rightPadding: 0 contentItem: ElidedLabel { id: userName_ color: TimelineManager.userColor(userId, timelineRoot.palette.base) elideWidth: Math.min(userInfo.remainingWidth - Math.min(statusMsg.implicitWidth, userInfo.remainingWidth / 3), userName_.fullTextWidth + Nheko.paddingMedium) fullText: userName textFormat: Text.RichText } onClicked: chat.model.openUserProfile(userId) NhekoCursorShape { anchors.fill: parent cursorShape: Qt.PointingHandCursor } } Label { id: statusMsg color: timelineRoot.palette.placeholderText elide: Text.ElideRight font.italic: true text: Presence.userStatus(userId) textFormat: Text.PlainText width: userInfo.remainingWidth - userName_.width - parent.spacing Connections { function onPresenceChanged(id) { if (id == userId) statusMsg.text = 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 function show(eventId_, eventType_, isSender_, isEncrypted_, isEditable_, link_, text_, showAt_) { eventId = eventId_; 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("&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.show(null, function (emoji) { room.input.reaction(messageContextMenu.eventId, emoji); }) } Platform.MenuItem { text: qsTr("Repl&y") visible: room ? room.permissions.canSend(MtxEvent.TextMessage) : false onTriggered: room.replyAction(messageContextMenu.eventId) } Platform.MenuItem { enabled: visible text: qsTr("&Edit") visible: messageContextMenu.isEditable && (room ? room.permissions.canSend(MtxEvent.TextMessage) : false) onTriggered: room.editAction(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 receip&ts") 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: chat.model.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 ? timelineRoot.palette.highlight : timelineRoot.palette.placeholderText border.width: 1 color: toEndButton.down ? timelineRoot.palette.highlight : timelineRoot.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: chat.positionViewAtBeginning() 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 ? timelineRoot.palette.highlightedText : timelineRoot.palette.placeholderText) } } }