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

704 lines
26 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
2 years 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();
}
2 years ago
target: MainWindow
}
Connections {
function onScrollToIndex(index) {
chat.positionViewAtIndex(index, ListView.Center);
chat.updateLastScroll();
}
target: room
}
ScrollBar {
id: scrollbar
2 years ago
anchors.bottom: parent.bottom
2 years ago
anchors.right: parent.right
anchors.top: parent.top
parent: chat.parent
}
ListView {
id: chat
2 years 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
2 years 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
2 years ago
model: (filteredTimeline.filterByThread || filteredTimeline.filterByContent) ? filteredTimeline : room
//pixelAligned: true
spacing: 2
verticalLayoutDirection: ListView.BottomToTop
2 years 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.
function updateLastScroll() {
lastScrollPos = (contentY+height);
}
onMovementEnded: updateLastScroll()
onModelChanged: updateLastScroll()
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)
data: [
Connections {
function onMovementEnded() {
if (y + height + 2 * chat.spacing > chat.contentY + chat.height && y < chat.contentY + chat.height) {
room.currentIndex = index;
}
}
target: chat
}
]
}
2 years 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)
data: [
Connections {
function onMovementEnded() {
if (y + height + 2 * chat.spacing > chat.contentY + chat.height && y < chat.contentY + chat.height) {
room.currentIndex = index;
}
}
target: chat
}
]
}
}
delegate: Settings.bubbles ? bubbleMessageStyle : defaultMessageStyle
2 years ago
footer: Item {
width: chat.delegateMaxWidth
2 years 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
2 years ago
if (atYEnd && room)
model.currentIndex = 0;
}
2 years ago
TimelineFilter {
id: filteredTimeline
2 years ago
filterByContent: chatRoot.searchString
filterByThread: room ? room.thread : ""
source: room
}
Control {
id: messageActionsC
property Item attached: null
// use comma to update on scroll
2 years ago
property alias model: row.model
hoverEnabled: true
2 years 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
2 years 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
2 years 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
2 years 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
2 years 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
2 years 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 {
2 years ago
ToolTip.delay: Nheko.tooltipDelay
ToolTip.text: qsTr("Edit")
ToolTip.visible: hovered
buttonTextColor: palette.buttonText
hoverEnabled: true
image: ":/icons/icons/ui/edit.svg"
2 years ago
visible: !!row.model && row.model.isEditable
Layout.preferredWidth: 16
2 years ago
onClicked: {
2 years ago
if (row.model.isEditable)
room.edit = row.model.eventId;
}
}
ImageButton {
id: reactButton
ToolTip.delay: Nheko.tooltipDelay
ToolTip.text: qsTr("React")
2 years ago
ToolTip.visible: hovered
hoverEnabled: true
image: ":/icons/icons/ui/smile-add.svg"
visible: room ? room.permissions.canSend(MtxEvent.Reaction) : false
Layout.preferredWidth: 16
2 years 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")
2 years 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
2 years ago
onClicked: room.thread = (row.model.threadId || row.model.eventId)
}
ImageButton {
ToolTip.delay: Nheko.tooltipDelay
ToolTip.text: qsTr("Reply")
2 years ago
ToolTip.visible: hovered
hoverEnabled: true
image: ":/icons/icons/ui/reply.svg"
visible: room ? room.permissions.canSend(MtxEvent.TextMessage) : false
Layout.preferredWidth: 16
2 years ago
onClicked: room.reply = row.model.eventId
}
ImageButton {
2 years 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"
2 years ago
visible: !!row.model && filteredTimeline.filterByContent
Layout.preferredWidth: 16
2 years ago
onClicked: {
topBar.searchString = "";
room.showEvent(row.model.eventId);
}
}
ImageButton {
id: optionsButton
ToolTip.delay: Nheko.tooltipDelay
ToolTip.text: qsTr("Options")
2 years ago
ToolTip.visible: hovered
hoverEnabled: true
image: ":/icons/icons/ui/options.svg"
Layout.preferredWidth: 16
2 years 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]
2 years ago
onActivated: {
chat.contentY = chat.contentY - chat.height * 0.9;
chat.returnToBounds();
}
}
Shortcut {
sequences: [StandardKey.MoveToNextPage]
2 years ago
onActivated: {
chat.contentY = chat.contentY + chat.height * 0.9;
chat.returnToBounds();
}
}
Shortcut {
sequences: [StandardKey.Cancel]
2 years ago
onActivated: {
2 years ago
if (room.input.uploads.length > 0)
room.input.declineUploads();
2 years ago
else if (room.reply)
room.reply = undefined;
else if (room.edit)
room.edit = undefined;
else
2 years 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"
2 years ago
onActivated: room.reply = room.indexToId(room.reply ? room.idToIndex(room.reply) + 1 : 0)
}
Shortcut {
sequence: "Alt+Down"
2 years ago
onActivated: {
var idx = room.reply ? room.idToIndex(room.reply) - 1 : -1;
room.reply = idx >= 0 ? room.indexToId(idx) : null;
}
}
Shortcut {
sequence: "Alt+F"
2 years 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"
2 years ago
onActivated: {
room.edit = room.reply;
}
}
Timer {
id: readTimer
2 years ago
interval: 1000
// force current read index to update
onTriggered: {
if (room)
2 years ago
room.setCurrentIndex(room.currentIndex);
}
}
}
Platform.Menu {
id: messageContextMenuC
property string eventId
property int eventType
property bool isEditable
2 years ago
property bool isEncrypted
property bool isSender
2 years 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_)
2 years ago
text = text_;
else
2 years ago
text = "";
if (link_)
2 years ago
link = link_;
else
2 years ago
link = "";
if (showAt_)
2 years ago
open(showAt_);
else
2 years ago
open();
}
Component {
id: removeReason
2 years ago
InputDialog {
id: removeReasonDialog
property string eventId
prompt: qsTr("Enter reason for removal or hit enter for no reason:")
2 years ago
title: qsTr("Reason for removal")
onAccepted: function (text) {
room.redactEvent(eventId, text);
}
}
}
Component {
id: reportDialog
ReportMessage {}
}
Platform.MenuItem {
2 years ago
enabled: visible
text: qsTr("Go to &message")
visible: filteredTimeline.filterByContent
onTriggered: function () {
topBar.searchString = "";
room.showEvent(messageContextMenuC.eventId);
}
2 years ago
}
Platform.MenuItem {
enabled: visible
text: qsTr("&Copy")
visible: messageContextMenuC.text
2 years ago
onTriggered: Clipboard.text = messageContextMenuC.text
}
Platform.MenuItem {
enabled: visible
text: qsTr("Copy &link location")
visible: messageContextMenuC.link
2 years ago
onTriggered: Clipboard.text = messageContextMenuC.link
}
Platform.MenuItem {
id: reactionOption
text: qsTr("Re&act")
2 years ago
visible: room ? room.permissions.canSend(MtxEvent.Reaction) : false
2 years ago
onTriggered: emojiPopup.visible ? emojiPopup.close() : emojiPopup.show(null, room.roomId, function (plaintext, markdown) {
room.input.reaction(messageContextMenuC.eventId, plaintext);
2 years ago
TimelineManager.focusMessageInput();
})
}
Platform.MenuItem {
text: qsTr("Repl&y")
2 years 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)
2 years ago
onTriggered: room.edit = (messageContextMenuC.eventId)
}
Platform.MenuItem {
enabled: visible
text: qsTr("&Thread")
2 years 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")
2 years 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")
2 years 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
2 years 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")
2 years ago
onTriggered: room.viewRawMessage(messageContextMenuC.eventId)
}
Platform.MenuItem {
enabled: visible
text: qsTr("View decrypted raw message")
2 years ago
// TODO(Nico): Fix this still being iterated over, when using keyboard to select options
visible: messageContextMenuC.isEncrypted
2 years ago
onTriggered: room.viewDecryptedRawMessage(messageContextMenuC.eventId)
}
Platform.MenuItem {
text: qsTr("Remo&ve message")
visible: (room ? room.permissions.canRedact() : false) || messageContextMenuC.isSender
2 years 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
2 years 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
2 years ago
onTriggered: room.openMedia(messageContextMenuC.eventId)
}
Platform.MenuItem {
enabled: visible
text: qsTr("Copy link to eve&nt")
visible: messageContextMenuC.eventId
2 years ago
onTriggered: room.copyLinkToEvent(messageContextMenuC.eventId)
}
}
Component {
id: forwardCompleterComponent
ForwardCompleter {
}
}
Platform.Menu {
id: replyContextMenuC
property string eventId
2 years 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
2 years ago
onTriggered: Clipboard.text = replyContextMenuC.text
}
Platform.MenuItem {
enabled: visible
text: qsTr("Copy &link location")
visible: replyContextMenuC.link
2 years ago
onTriggered: Clipboard.text = replyContextMenuC.link
}
Platform.MenuItem {
enabled: visible
text: qsTr("&Go to quoted message")
2 years ago
visible: true
onTriggered: room.showEvent(replyContextMenuC.eventId)
}
}
RoundButton {
id: toEndButton
2 years ago
property int fullWidth: 40
2 years ago
flat: true
2 years ago
height: width
hoverEnabled: true
2 years ago
radius: width / 2
width: 0
background: Rectangle {
border.color: toEndButton.hovered ? palette.highlight : palette.buttonText
border.width: 1
2 years ago
color: toEndButton.down ? palette.highlight : palette.button
opacity: enabled ? 1 : 0.3
radius: toEndButton.radius
}
states: [
State {
name: ""
2 years ago
PropertyChanges {
toEndButton.width: 0
2 years ago
}
},
State {
name: "shown"
when: !chat.atYEnd
2 years ago
PropertyChanges {
toEndButton.width: toEndButton.fullWidth
2 years ago
}
}
]
transitions: Transition {
from: ""
reversible: true
2 years ago
to: "shown"
SequentialAnimation {
2 years ago
PauseAnimation {
duration: 500
}
PropertyAnimation {
duration: 200
2 years ago
easing.type: Easing.InOutQuad
properties: "width"
target: toEndButton
}
}
}
2 years ago
onClicked: function () {
chat.positionViewAtBeginning();
TimelineManager.focusMessageInput();
chat.updateLastScroll();
2 years ago
}
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)
}
}
}