// 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() { 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: TimelineEvent { id: wrapper ListView.delayRemove: true width: chat.delegateMaxWidth height: Math.max((section.item?.height ?? 0) + gridContainer.implicitHeight + reactionRow.implicitHeight + unreadRow.height, 10) anchors.horizontalCenter: ListView.view.contentItem.horizontalCenter //room: chatRoot.roommodel required property var day required property bool isSender required property int index 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 date timestamp required property string userId required property string userName required property string threadId required property int userPowerlevel required property bool isEdited required property bool isEncrypted required property var reactions required property int status required property int trustlevel required property int type required property bool isEditable property int avatarMargin: (wrapper.isStateEvent || Settings.smallAvatars ? 0 : (Nheko.avatarSize + 8)) // align bubble with section header property alias hovered: messageHover.hovered data: [ Loader { id: section property var day: wrapper.day property bool isSender: wrapper.isSender property bool isStateEvent: wrapper.isStateEvent property int parentWidth: wrapper.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 property string userPowerlevel: wrapper.userPowerlevel active: previousMessageUserId !== userId || previousMessageDay !== day || previousMessageIsStateEvent !== isStateEvent //asynchronous: true sourceComponent: sectionHeader visible: status == Loader.Ready z: 4 }, Rectangle { anchors.fill: gridContainer color: (Settings.messageHoverHighlight && messageHover.hovered) ? palette.alternateBase : "transparent" // this looks better without margins TapHandler { acceptedButtons: Qt.RightButton acceptedDevices: PointerDevice.Mouse | PointerDevice.Stylus | PointerDevice.TouchPad gesturePolicy: TapHandler.ReleaseWithinBounds onSingleTapped: messageContextMenu.show(wrapper.eventId, wrapper.threadId, wrapper.type, wrapper.isSender, wrapper.isEncrypted, wrapper.isEditable, wrapper.main.hoveredLink, wrapper.main.copyText) } }, RowLayout { id: gridContainer width: wrapper.width y: section.visible && section.active ? section.y + section.height : 0 Item { Layout.preferredWidth: wrapper.avatarMargin } AbstractButton { ToolTip.delay: Nheko.tooltipDelay ToolTip.text: qsTr("Part of a thread") ToolTip.visible: hovered Layout.fillHeight: true visible: wrapper.threadId Layout.preferredWidth: 4 onClicked: room.thread = wrapper.threadId Rectangle { id: threadLine anchors.fill: parent color: TimelineManager.userColor(wrapper.threadId, palette.base) } } ColumnLayout { id: contentColumn Layout.fillWidth: true AbstractButton { id: replyRow visible: wrapper.reply Layout.fillWidth: true Layout.maximumHeight: timelineView.height / 8 Layout.preferredWidth: replyRowLay.implicitWidth Layout.preferredHeight: replyRowLay.implicitHeight property color userColor: TimelineManager.userColor(wrapper.reply?.userId ?? '', palette.base) clip: true contentItem: RowLayout { id: replyRowLay anchors.fill: parent Rectangle { id: replyLine Layout.fillHeight: true color: replyRow.userColor Layout.preferredWidth: 4 } ColumnLayout { spacing: 0 AbstractButton { id: replyUserButton Layout.fillWidth: true contentItem: ElidedLabel { id: userName_ fullText: wrapper.reply?.userName ?? '' color: replyRow.userColor textFormat: Text.RichText width: parent.width elideWidth: width } onClicked: room.openUserProfile(wrapper.reply?.userId) } data: [ replyUserButton, wrapper.reply, ] } } background: Rectangle { width: replyRow.implicitContentWidth color: Qt.tint(palette.base, Qt.hsla(replyRow.userColor.hslHue, 0.5, replyRow.userColor.hslLightness, 0.1)) } } data: [ replyRow, wrapper.main, ] } Item { // spacer to fill width if needed Layout.fillWidth: true } RowLayout { id: metadata property int iconSize: Math.floor(fontMetrics.ascent * scaling) property double scaling: Settings.bubbles ? 0.75 : 1 Layout.alignment: Qt.AlignTop | Qt.AlignRight Layout.preferredWidth: implicitWidth spacing: 2 visible: !isStateEvent StatusIndicator { Layout.alignment: Qt.AlignRight | Qt.AlignVCenter eventId: wrapper.eventId height: parent.iconSize status: wrapper.status width: parent.iconSize } Image { Layout.alignment: Qt.AlignRight | Qt.AlignVCenter ToolTip.delay: Nheko.tooltipDelay ToolTip.text: qsTr("Edited") ToolTip.visible: editHovered.hovered height: parent.iconSize source: "image://colorimage/:/icons/icons/ui/edit.svg?" + ((wrapper.eventId == room.edit) ? palette.highlight : palette.buttonText) sourceSize.height: parent.iconSize * Screen.devicePixelRatio sourceSize.width: parent.iconSize * Screen.devicePixelRatio visible: wrapper.isEdited || wrapper.eventId == room.edit width: parent.iconSize HoverHandler { id: editHovered } } ImageButton { Layout.alignment: Qt.AlignRight | Qt.AlignVCenter ToolTip.delay: Nheko.tooltipDelay ToolTip.text: qsTr("Part of a thread") ToolTip.visible: hovered buttonTextColor: TimelineManager.userColor(wrapper.threadId, palette.base) height: parent.iconSize image: ":/icons/icons/ui/thread.svg" visible: wrapper.threadId width: parent.iconSize onClicked: room.thread = threadId } EncryptionIndicator { Layout.alignment: Qt.AlignRight | Qt.AlignVCenter encrypted: wrapper.isEncrypted height: parent.iconSize sourceSize.height: parent.iconSize * Screen.devicePixelRatio sourceSize.width: parent.iconSize * Screen.devicePixelRatio trust: wrapper.trustlevel visible: room.isEncrypted width: parent.iconSize } Label { id: ts Layout.alignment: Qt.AlignRight | Qt.AlignTop Layout.preferredWidth: implicitWidth ToolTip.delay: Nheko.tooltipDelay ToolTip.text: Qt.formatDateTime(wrapper.timestamp, Qt.DefaultLocaleLongDate) ToolTip.visible: ma.hovered color: palette.inactive.text font.pointSize: fontMetrics.font.pointSize * parent.scaling text: wrapper.timestamp.toLocaleTimeString(Locale.ShortFormat) HoverHandler { id: ma } } } }, Item { id: messageActionsAnchor anchors.fill: gridContainer property alias hovered: messageHover.hovered HoverHandler { id: messageHover onHoveredChanged: () => { if (!Settings.mobileMode && hovered) { if (!messageActions.hovered) { messageActions.model = wrapper; messageActions.attached = wrapper; messageActions.anchors.bottomMargin = -gridContainer.y } } } } }, Reactions { id: reactionRow eventId: wrapper.eventId layoutDirection: row.bubbleOnRight ? Qt.RightToLeft : Qt.LeftToRight reactions: wrapper.reactions width: wrapper.width - wrapper.avatarMargin x: wrapper.avatarMargin anchors { //left: row.bubbleOnRight ? undefined : row.left //right: row.bubbleOnRight ? row.right : undefined top: gridContainer.bottom topMargin: -4 } }, Rectangle { id: unreadRow color: palette.highlight height: visible ? 3 : 0 visible: (wrapper.index > 0 && (room.fullyReadEventId == wrapper.eventId)) anchors { left: parent.left right: parent.right top: reactionRow.bottom topMargin: 5 } } ] } 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 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: 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 } 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: 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 PowerlevelIndicator { id: powerlevelIndicator anchors.left: parent.left //anchors.horizontalCenter: parent.horizontalCenter powerlevel: userPowerlevel height: fontMetrics.lineSpacing width: fontMetrics.lineSpacing sourceSize.width: fontMetrics.lineSpacing sourceSize.height: fontMetrics.lineSpacing permissions: room ? room.permissions : null visible: isAdmin || isModerator } ToolTip.delay: Nheko.tooltipDelay ToolTip.text: userId ToolTip.visible: hovered leftPadding: powerlevelIndicator.visible ? 16 : 0 leftInset: 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 } NhekoCursorShape { 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) } } }