You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
nheko/resources/qml/MessageView.qml

670 lines
24 KiB

// SPDX-FileCopyrightText: Nheko Contributors
//
// SPDX-License-Identifier: GPL-3.0-or-later
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
1 year ago
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();
replyContextMenuC.close();
}
1 year ago
target: MainWindow
}
ScrollBar {
id: scrollbar
1 year ago
anchors.bottom: parent.bottom
1 year ago
anchors.right: parent.right
anchors.top: parent.top
parent: chat.parent
}
ListView {
id: chat
1 year ago
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
1 year ago
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
1 year ago
model: (filteredTimeline.filterByThread || filteredTimeline.filterByContent) ? filteredTimeline : room
//pixelAligned: true
spacing: 2
verticalLayoutDirection: ListView.BottomToTop
1 year ago
property int lastScrollPos: 0
// Fixup the scroll position when the height changes. Without this, the view is kept around the center of the currently visible content, while we usually want to stick to the bottom.
onMovementEnded: lastScrollPos = (contentY+height)
onModelChanged: lastScrollPos = (contentY+height)
onHeightChanged: contentY = (lastScrollPos-height)
Component {
id: defaultMessageStyle
TimelineDefaultMessageStyle {
messageActions: messageActionsC
messageContextMenu: messageContextMenuC
replyContextMenu: replyContextMenuC
scrolledToThis: eventId === room.scrollTarget && (y + height > chat.y + chat.contentY && y < chat.y + chat.height + chat.contentY)
}
1 year ago
}
Component {
id: bubbleMessageStyle
TimelineBubbleMessageStyle {
messageActions: messageActionsC
messageContextMenu: messageContextMenuC
replyContextMenu: replyContextMenuC
scrolledToThis: eventId === room.scrollTarget && (y + height > chat.y + chat.contentY && y < chat.y + chat.height + chat.contentY)
}
}
delegate: Settings.bubbles ? bubbleMessageStyle : defaultMessageStyle
1 year ago
footer: Item {
width: chat.delegateMaxWidth
1 year ago
// 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
1 year ago
if (atYEnd && room)
model.currentIndex = 0;
}
1 year ago
TimelineFilter {
id: filteredTimeline
1 year ago
filterByContent: chatRoot.searchString
filterByThread: room ? room.thread : ""
source: room
}
Control {
id: messageActionsC
property Item attached: null
// use comma to update on scroll
1 year ago
property alias model: row.model
hoverEnabled: true
1 year ago
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
1 year ago
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
1 year ago
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
1 year ago
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
1 year ago
padding: 0
text: button.modelData
verticalAlignment: Text.AlignVCenter
visible: !button.showImage
}
Image {
// Workaround, can't get icon.source working for now...
anchors.fill: parent
1 year ago
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 {
1 year ago
ToolTip.delay: Nheko.tooltipDelay
ToolTip.text: qsTr("Edit")
ToolTip.visible: hovered
buttonTextColor: palette.buttonText
hoverEnabled: true
image: ":/icons/icons/ui/edit.svg"
1 year ago
visible: !!row.model && row.model.isEditable
Layout.preferredWidth: 16
1 year ago
onClicked: {
1 year ago
if (row.model.isEditable)
room.edit = row.model.eventId;
}
}
ImageButton {
id: reactButton
ToolTip.delay: Nheko.tooltipDelay
ToolTip.text: qsTr("React")
1 year ago
ToolTip.visible: hovered
hoverEnabled: true
image: ":/icons/icons/ui/smile-add.svg"
visible: room ? room.permissions.canSend(MtxEvent.Reaction) : false
Layout.preferredWidth: 16
1 year ago
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")
1 year ago
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
Layout.preferredWidth: 16
1 year ago
onClicked: room.thread = (row.model.threadId || row.model.eventId)
}
ImageButton {
ToolTip.delay: Nheko.tooltipDelay
ToolTip.text: qsTr("Reply")
1 year ago
ToolTip.visible: hovered
hoverEnabled: true
image: ":/icons/icons/ui/reply.svg"
visible: room ? room.permissions.canSend(MtxEvent.TextMessage) : false
Layout.preferredWidth: 16
1 year ago
onClicked: room.reply = row.model.eventId
}
ImageButton {
1 year ago
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"
1 year ago
visible: !!row.model && filteredTimeline.filterByContent
Layout.preferredWidth: 16
1 year ago
onClicked: {
topBar.searchString = "";
room.showEvent(row.model.eventId);
}
}
ImageButton {
id: optionsButton
ToolTip.delay: Nheko.tooltipDelay
ToolTip.text: qsTr("Options")
1 year ago
ToolTip.visible: hovered
hoverEnabled: true
image: ":/icons/icons/ui/options.svg"
Layout.preferredWidth: 16
1 year ago
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 {
sequences: [StandardKey.MoveToPreviousPage]
1 year ago
onActivated: {
chat.contentY = chat.contentY - chat.height * 0.9;
chat.returnToBounds();
}
}
Shortcut {
sequences: [StandardKey.MoveToNextPage]
1 year ago
onActivated: {
chat.contentY = chat.contentY + chat.height * 0.9;
chat.returnToBounds();
}
}
Shortcut {
sequences: [StandardKey.Cancel]
1 year ago
onActivated: {
1 year ago
if (room.input.uploads.length > 0)
room.input.declineUploads();
1 year ago
else if (room.reply)
room.reply = undefined;
else if (room.edit)
room.edit = undefined;
else
1 year ago
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"
1 year ago
onActivated: room.reply = room.indexToId(room.reply ? room.idToIndex(room.reply) + 1 : 0)
}
Shortcut {
sequence: "Alt+Down"
1 year ago
onActivated: {
var idx = room.reply ? room.idToIndex(room.reply) - 1 : -1;
room.reply = idx >= 0 ? room.indexToId(idx) : null;
}
}
Shortcut {
sequence: "Alt+F"
1 year ago
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"
1 year ago
onActivated: {
room.edit = room.reply;
}
}
Timer {
id: readTimer
1 year ago
interval: 1000
// force current read index to update
onTriggered: {
if (room)
1 year ago
room.setCurrentIndex(room.currentIndex);
}
}
}
Platform.Menu {
id: messageContextMenuC
property string eventId
property int eventType
property bool isEditable
1 year ago
property bool isEncrypted
property bool isSender
1 year ago
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_)
1 year ago
text = text_;
else
1 year ago
text = "";
if (link_)
1 year ago
link = link_;
else
1 year ago
link = "";
if (showAt_)
1 year ago
open(showAt_);
else
1 year ago
open();
}
Component {
id: removeReason
1 year ago
InputDialog {
id: removeReasonDialog
property string eventId
prompt: qsTr("Enter reason for removal or hit enter for no reason:")
1 year ago
title: qsTr("Reason for removal")
onAccepted: function (text) {
room.redactEvent(eventId, text);
}
}
}
Component {
id: reportDialog
ReportMessage {}
}
Platform.MenuItem {
1 year ago
enabled: visible
text: qsTr("Go to &message")
visible: filteredTimeline.filterByContent
onTriggered: function () {
topBar.searchString = "";
room.showEvent(messageContextMenuC.eventId);
}
1 year ago
}
Platform.MenuItem {
enabled: visible
text: qsTr("&Copy")
visible: messageContextMenuC.text
1 year ago
onTriggered: Clipboard.text = messageContextMenuC.text
}
Platform.MenuItem {
enabled: visible
text: qsTr("Copy &link location")
visible: messageContextMenuC.link
1 year ago
onTriggered: Clipboard.text = messageContextMenuC.link
}
Platform.MenuItem {
id: reactionOption
text: qsTr("Re&act")
1 year ago
visible: room ? room.permissions.canSend(MtxEvent.Reaction) : false
1 year ago
onTriggered: emojiPopup.visible ? emojiPopup.close() : emojiPopup.show(null, room.roomId, function (plaintext, markdown) {
room.input.reaction(messageContextMenuC.eventId, plaintext);
1 year ago
TimelineManager.focusMessageInput();
})
}
Platform.MenuItem {
text: qsTr("Repl&y")
1 year ago
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)
1 year ago
onTriggered: room.edit = (messageContextMenuC.eventId)
}
Platform.MenuItem {
enabled: visible
text: qsTr("&Thread")
1 year ago
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")
1 year ago
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")
1 year ago
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
1 year ago
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")
1 year ago
onTriggered: room.viewRawMessage(messageContextMenuC.eventId)
}
Platform.MenuItem {
enabled: visible
text: qsTr("View decrypted raw message")
1 year ago
// TODO(Nico): Fix this still being iterated over, when using keyboard to select options
visible: messageContextMenuC.isEncrypted
1 year ago
onTriggered: room.viewDecryptedRawMessage(messageContextMenuC.eventId)
}
Platform.MenuItem {
text: qsTr("Remo&ve message")
visible: (room ? room.permissions.canRedact() : false) || messageContextMenuC.isSender
1 year ago
onTriggered: function () {
var dialog = removeReason.createObject(timelineRoot);
dialog.eventId = messageContextMenuC.eventId;
dialog.show();
dialog.forceActiveFocus();
timelineRoot.destroyOnClose(dialog);
}
}
Platform.MenuItem {
text: qsTr("Report message")
enabled: visible
onTriggered: function () {
var dialog = reportDialog.createObject(timelineRoot, {"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
1 year ago
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
1 year ago
onTriggered: room.openMedia(messageContextMenuC.eventId)
}
Platform.MenuItem {
enabled: visible
text: qsTr("Copy link to eve&nt")
visible: messageContextMenuC.eventId
1 year ago
onTriggered: room.copyLinkToEvent(messageContextMenuC.eventId)
}
}
Component {
id: forwardCompleterComponent
ForwardCompleter {
}
}
Platform.Menu {
id: replyContextMenuC
property string eventId
1 year ago
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: replyContextMenuC.text
1 year ago
onTriggered: Clipboard.text = replyContextMenuC.text
}
Platform.MenuItem {
enabled: visible
text: qsTr("Copy &link location")
visible: replyContextMenuC.link
1 year ago
onTriggered: Clipboard.text = replyContextMenuC.link
}
Platform.MenuItem {
enabled: visible
text: qsTr("&Go to quoted message")
1 year ago
visible: true
onTriggered: room.showEvent(replyContextMenuC.eventId)
}
}
RoundButton {
id: toEndButton
1 year ago
property int fullWidth: 40
1 year ago
flat: true
1 year ago
height: width
hoverEnabled: true
1 year ago
radius: width / 2
width: 0
background: Rectangle {
border.color: toEndButton.hovered ? palette.highlight : palette.buttonText
border.width: 1
1 year ago
color: toEndButton.down ? palette.highlight : palette.button
opacity: enabled ? 1 : 0.3
radius: toEndButton.radius
}
states: [
State {
name: ""
1 year ago
PropertyChanges {
toEndButton.width: 0
1 year ago
}
},
State {
name: "shown"
when: !chat.atYEnd
1 year ago
PropertyChanges {
toEndButton.width: toEndButton.fullWidth
1 year ago
}
}
]
transitions: Transition {
from: ""
reversible: true
1 year ago
to: "shown"
SequentialAnimation {
1 year ago
PauseAnimation {
duration: 500
}
PropertyAnimation {
duration: 200
1 year ago
easing.type: Easing.InOutQuad
properties: "width"
target: toEndButton
}
}
}
1 year ago
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 {
anchors.fill: parent
anchors.margins: Nheko.paddingMedium
fillMode: Image.PreserveAspectFit
source: "image://colorimage/:/icons/icons/ui/download.svg?" + (toEndButton.down ? palette.highlightedText : palette.buttonText)
}
}
}