Newer
Older
// SPDX-FileCopyrightText: 2021 Nheko Contributors
// SPDX-FileCopyrightText: 2022 Nheko Contributors
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
Item {
id: chatRoot
property int padding: Nheko.paddingMedium
property int availableWidth: width
ScrollBar {
id: scrollbar
parent: chat.parent
anchors.top: parent.top
anchors.right: parent.right
anchors.bottom: parent.bottom
}
property int delegateMaxWidth: ((Settings.timelineMaxWidth > 100 && Settings.timelineMaxWidth < chatRoot.availableWidth) ? Settings.timelineMaxWidth : chatRoot.availableWidth) - chatRoot.padding * 2 - (scrollbar.interactive? scrollbar.width : 0)
displayMarginBeginning: height / 2
displayMarginEnd: height / 2
model: room
// 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
//pixelAligned: true
spacing: 2
verticalLayoutDirection: ListView.BottomToTop
onCountChanged: {
// Mark timeline as read
if (atYEnd && room) model.currentIndex = 0;
anchors.rightMargin: scrollbar.interactive? scrollbar.width : 0
property Item attached: null
property alias model: row.model
// use comma to update on scroll
property var attachedPos: chat.contentY, attached ? chat.mapFromItem(attached, attached ? attached.width - width : 0, -height) : null
padding: Nheko.paddingSmall
visible: Settings.buttonsInTimeline && !!attached && (attached.hovered || hovered)
y: attached ? attachedPos.y + Nheko.paddingSmall : 0
color: Nheko.colors.window
border.color: Nheko.colors.buttonText
border.width: 1
radius: padding
delegate: TextButton {
required property string modelData
visible: chat.model ? chat.model.permissions.canSend(MtxEvent.Reaction) : false
Layout.preferredHeight: fontMetrics.height
room.input.reaction(row.model.eventId, modelData);
TimelineManager.focusMessageInput();
ImageButton {
id: editButton
visible: !!row.model && row.model.isEditable
buttonTextColor: Nheko.colors.buttonText
width: 16
hoverEnabled: true
image: ":/icons/icons/ui/edit.svg"
ToolTip.visible: hovered
ToolTip.delay: Nheko.tooltipDelay
ToolTip.text: qsTr("Edit")
onClicked: {
if (row.model.isEditable)
ImageButton {
id: reactButton
visible: chat.model ? chat.model.permissions.canSend(MtxEvent.Reaction) : false
width: 16
hoverEnabled: true
image: ":/icons/icons/ui/smile.svg"
ToolTip.visible: hovered
ToolTip.delay: Nheko.tooltipDelay
ToolTip.text: qsTr("React")
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
visible: chat.model ? chat.model.permissions.canSend(MtxEvent.TextMessage) : false
width: 16
hoverEnabled: true
image: ":/icons/icons/ui/reply.svg"
ToolTip.visible: hovered
ToolTip.delay: Nheko.tooltipDelay
ToolTip.text: qsTr("Reply")
onClicked: chat.model.replyAction(row.model.eventId)
}
width: 16
hoverEnabled: true
image: ":/icons/icons/ui/options.svg"
ToolTip.visible: hovered
ToolTip.delay: Nheko.tooltipDelay
ToolTip.text: qsTr("Options")
onClicked: messageContextMenu.show(row.model.eventId, row.model.type, row.model.isSender, row.model.isEncrypted, row.model.isEditable, "", row.model.body, optionsButton)
ScrollHelper {
flickable: parent
anchors.fill: parent
}
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)
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;
Shortcut {
sequence: "Ctrl+E"
onActivated: {
chat.model.edit = chat.model.reply;
Connections {
function onFocusChanged() {
readTimer.running = TimelineManager.isWindowFocused;
target: TimelineManager
}
Timer {
id: readTimer
// force current read index to update
onTriggered: {
if (chat.model)
chat.model.setCurrentIndex(chat.model.currentIndex);
Component {
id: sectionHeader
Column {
topPadding: userName_.visible? 4: 0
bottomPadding: Settings.bubbles? (isSender && previousMessageDay == day? 0 : 2) : 3
spacing: 8
visible: (previousMessageUserId !== userId || previousMessageDay !== day || isStateEvent !== previousMessageIsStateEvent)
width: parentWidth
height: ((previousMessageDay !== day) ? dateBubble.height : 0) + (isStateEvent? 0 : userName.height +8 )
Label {
id: dateBubble
anchors.horizontalCenter: parent ? parent.horizontalCenter : undefined
visible: room && previousMessageDay !== day
text: room ? room.formatDateSeparator(timestamp) : ""
color: Nheko.colors.text
height: Math.round(fontMetrics.height * 1.4)
width: contentWidth * 1.2
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
background: Rectangle {
radius: parent.height / 2
color: Nheko.colors.window
}
visible: !isStateEvent && (!isSender || !Settings.bubbles)
Avatar {
id: messageUserAvatar
width: Nheko.avatarSize * (Settings.smallAvatars? 0.5 : 1)
height: Nheko.avatarSize * (Settings.smallAvatars? 0.5 : 1)
url: !room ? "" : room.avatarUrl(userId).replace("mxc://", "image://MxcImage/")
displayName: userName
userid: userId
onClicked: room.openUserProfile(userId)
ToolTip.visible: messageUserAvatar.hovered
ToolTip.delay: Nheko.tooltipDelay
ToolTip.text: userid
Jedi18
committed
}
Connections {
function onRoomAvatarUrlChanged() {
messageUserAvatar.url = chat.model.avatarUrl(userId).replace("mxc://", "image://MxcImage/");
}
function onScrollToIndex(index) {
chat.positionViewAtIndex(index, ListView.Center);
property int remainingWidth: chat.delegateMaxWidth - spacing - messageUserAvatar.width
AbstractButton {
id: userName_
color: TimelineManager.userColor(userId, Nheko.colors.base)
textFormat: Text.RichText
elideWidth: Math.min(userInfo.remainingWidth-Math.min(statusMsg.implicitWidth,userInfo.remainingWidth/3), userName_.fullTextWidth)
}
ToolTip.visible: hovered
ToolTip.delay: Nheko.tooltipDelay
ToolTip.text: userId
onClicked: chat.model.openUserProfile(userId)
leftInset: 0
rightInset: 0
leftPadding: 0
rightPadding: 0
CursorShape {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
}
Label {
id: statusMsg
color: Nheko.colors.buttonText
text: Presence.userStatus(userId)
textFormat: Text.PlainText
elide: Text.ElideRight
width: userInfo.remainingWidth - userName_.width - parent.spacing
function onPresenceChanged(id) {
if (id == userId) statusMsg.text = Presence.userStatus(userId);
id: wrapper
required property double proportionalHeight
required property int type
required property string typeString
required property int originalWidth
required property string blurhash
required property string body
required property string formattedBody
required property string eventId
required property string filename
required property string filesize
required property string url
required property string thumbnailUrl
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
required property bool isOnlyEmoji
required property bool isSender
required property bool isEncrypted
required property bool isEditable
required property bool isEdited
required property bool isStateEvent
required property bool previousMessageIsStateEvent
required property string replyTo
required property string userId
required property string roomTopic
required property string roomName
required property string callType
required property var reactions
required property int trustlevel
required property int encryptionError
required property var timestamp
required property int status
required property int index
required property int relatedEventCacheBuster
required property string previousMessageUserId
required property string day
required property string previousMessageDay
required property string userName
property bool scrolledToThis: eventId === chat.model.scrollTarget && (y + height > chat.y + chat.contentY && y < chat.y + chat.height + chat.contentY)
anchors.horizontalCenter: parent ? parent.horizontalCenter : undefined
width: chat.delegateMaxWidth
height: section.active ? section.height + timelinerow.height : timelinerow.height
Loader {
id: section
property int parentWidth: parent.width
property string userId: wrapper.userId
property string previousMessageUserId: wrapper.previousMessageUserId
property string day: wrapper.day
property string previousMessageDay: wrapper.previousMessageDay
property bool previousMessageIsStateEvent: wrapper.previousMessageIsStateEvent
property bool isStateEvent: wrapper.isStateEvent
property bool isSender: wrapper.isSender
property string userName: wrapper.userName
property date timestamp: wrapper.timestamp
z: 4
active: previousMessageUserId !== undefined && previousMessageUserId !== userId || previousMessageDay !== day || previousMessageIsStateEvent !== isStateEvent
//asynchronous: true
sourceComponent: sectionHeader
visible: status == Loader.Ready
}
TimelineRow {
id: timelinerow
proportionalHeight: wrapper.proportionalHeight
type: chat.model, wrapper.type
typeString: wrapper.typeString
originalWidth: wrapper.originalWidth
blurhash: wrapper.blurhash
body: wrapper.body
formattedBody: wrapper.formattedBody
eventId: chat.model, wrapper.eventId
filename: wrapper.filename
filesize: wrapper.filesize
url: wrapper.url
thumbnailUrl: wrapper.thumbnailUrl
isOnlyEmoji: wrapper.isOnlyEmoji
isSender: wrapper.isSender
isEncrypted: wrapper.isEncrypted
isEditable: wrapper.isEditable
isEdited: wrapper.isEdited
isStateEvent: wrapper.isStateEvent
replyTo: wrapper.replyTo
userId: wrapper.userId
userName: wrapper.userName
roomTopic: wrapper.roomTopic
roomName: wrapper.roomName
callType: wrapper.callType
reactions: wrapper.reactions
trustlevel: wrapper.trustlevel
encryptionError: wrapper.encryptionError
timestamp: wrapper.timestamp
status: wrapper.status
relatedEventCacheBuster: wrapper.relatedEventCacheBuster
y: section.visible && section.active ? section.y + section.height : 0
if (!Settings.mobileMode && hovered) {
if (!messageActions.hovered) {
messageActions.attached = timelinerow;
messageActions.model = timelinerow;
}
background: Rectangle {
id: scrollHighlight
opacity: 0
visible: true
z: 1
enabled: false
color: Nheko.colors.highlight
states: State {
name: "revealed"
when: wrapper.scrolledToThis
}
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
transitions: Transition {
from: ""
to: "revealed"
SequentialAnimation {
PropertyAnimation {
target: scrollHighlight
properties: "opacity"
easing.type: Easing.InOutQuad
from: 0
to: 1
duration: 500
}
PropertyAnimation {
target: scrollHighlight
properties: "opacity"
easing.type: Easing.InOutQuad
from: 1
to: 0
duration: 500
}
ScriptAction {
script: chat.model.eventShown()
}
}
}
}
Connections {
function onMovementEnded() {
if (y + height + 2 * chat.spacing > chat.contentY + chat.height && y < chat.contentY + chat.height)
footer: Item {
anchors.horizontalCenter: parent.horizontalCenter
anchors.margins: Nheko.paddingLarge
visible: chat.model && chat.model.paginationInProgress
// hacky, but works
height: loadingSpinner.height + 2 * Nheko.paddingLarge
Spinner {
id: loadingSpinner
anchors.centerIn: parent
anchors.margins: Nheko.paddingLarge
running: chat.model && chat.model.paginationInProgress
foreground: Nheko.colors.mid
z: 3
Platform.Menu {
id: messageContextMenu
property string eventId
property string link
property string text
property int eventType
property bool isEncrypted
property bool isEditable
property bool isSender
function show(eventId_, eventType_, isSender_, isEncrypted_, isEditable_, link_, text_, showAt_) {
eventId = eventId_;
eventType = eventType_;
isEncrypted = isEncrypted_;
isEditable = isEditable_;
isSender = isSender_;
if (text_)
Component {
id: removeReason
InputDialog {
id: removeReasonDialog
property string eventId
title: qsTr("Reason for removal")
prompt: qsTr("Enter reason for removal or hit enter for no reason:")
onAccepted: function(text) {
room.redactEvent(eventId, text);
}
}
}
Platform.MenuItem {
visible: messageContextMenu.text
enabled: visible
onTriggered: Clipboard.text = messageContextMenu.text
}
Platform.MenuItem {
visible: messageContextMenu.link
enabled: visible
text: qsTr("Copy &link location")
onTriggered: Clipboard.text = messageContextMenu.link
}
Platform.MenuItem {
id: reactionOption
visible: room ? room.permissions.canSend(MtxEvent.Reaction) : false
onTriggered: emojiPopup.show(null, function(emoji) {
room.input.reaction(messageContextMenu.eventId, emoji);
})
}
Platform.MenuItem {
visible: room ? room.permissions.canSend(MtxEvent.TextMessage) : false
onTriggered: room.replyAction(messageContextMenu.eventId)
}
Platform.MenuItem {
visible: messageContextMenu.isEditable && (room ? room.permissions.canSend(MtxEvent.TextMessage) : false)
enabled: visible
onTriggered: room.editAction(messageContextMenu.eventId)
}
Platform.MenuItem {
visible: (room ? room.permissions.canChange(MtxEvent.PinnedEvents) : false)
enabled: visible
text: visible && room.pinnedMessages.includes(messageContextMenu.eventId) ? qsTr("Un&pin") : qsTr("&Pin")
onTriggered: visible && room.pinnedMessages.includes(messageContextMenu.eventId) ? room.unpin(messageContextMenu.eventId) : room.pin(messageContextMenu.eventId)
}
onTriggered: room.showReadReceipts(messageContextMenu.eventId)
}
Platform.MenuItem {
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();
}
}
Platform.MenuItem {
}
Platform.MenuItem {
text: qsTr("View raw message")
onTriggered: room.viewRawMessage(messageContextMenu.eventId)
}
Platform.MenuItem {
// TODO(Nico): Fix this still being iterated over, when using keyboard to select options
visible: messageContextMenu.isEncrypted
enabled: visible
text: qsTr("View decrypted raw message")
onTriggered: room.viewDecryptedRawMessage(messageContextMenu.eventId)
}
Platform.MenuItem {
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 {
visible: messageContextMenu.eventType == MtxEvent.ImageMessage || messageContextMenu.eventType == MtxEvent.VideoMessage || messageContextMenu.eventType == MtxEvent.AudioMessage || messageContextMenu.eventType == MtxEvent.FileMessage || messageContextMenu.eventType == MtxEvent.Sticker
enabled: visible
onTriggered: room.saveMedia(messageContextMenu.eventId)
}
Platform.MenuItem {
visible: messageContextMenu.eventType == MtxEvent.ImageMessage || messageContextMenu.eventType == MtxEvent.VideoMessage || messageContextMenu.eventType == MtxEvent.AudioMessage || messageContextMenu.eventType == MtxEvent.FileMessage || messageContextMenu.eventType == MtxEvent.Sticker
enabled: visible
text: qsTr("&Open in external program")
onTriggered: room.openMedia(messageContextMenu.eventId)
}
Platform.MenuItem {
visible: messageContextMenu.eventId
enabled: visible
text: qsTr("Copy link to eve&nt")
onTriggered: room.copyLinkToEvent(messageContextMenu.eventId)
}
}
Component {
id: forwardCompleterComponent
ForwardCompleter {
}
}
Platform.Menu {
id: replyContextMenu
property string text
property string link
open();
}
Platform.MenuItem {
visible: replyContextMenu.text
enabled: visible
text: qsTr("&Copy")
onTriggered: Clipboard.text = replyContextMenu.text
}
Platform.MenuItem {
visible: replyContextMenu.link
enabled: visible
text: qsTr("Copy &link location")
onTriggered: Clipboard.text = replyContextMenu.link
}
Platform.MenuItem {
visible: true
enabled: visible
onTriggered: chat.model.showEvent(replyContextMenu.eventId)
RoundButton {
id: toEndButton
anchors {
bottom: parent.bottom
right: scrollbar.left
bottomMargin: Nheko.paddingMedium+(fullWidth-width)/2
rightMargin: Nheko.paddingMedium+(fullWidth-width)/2
}
property int fullWidth: 40
width: fullWidth
height: width
radius: width/2
onClicked: chat.positionViewAtBeginning();
flat: true
hoverEnabled: true
background: Rectangle {
color: toEndButton.down ? Nheko.colors.highlight : Nheko.colors.button
opacity: enabled ? 1 : 0.3
border.color: toEndButton.hovered ? Nheko.colors.highlight : Nheko.colors.buttonText
border.width: 1
radius: toEndButton.radius
}
states: State {
name: "hidden"
when: chat.atYEnd
}
Image {
id: buttonImg
anchors.fill: parent
anchors.margins: Nheko.paddingMedium
source: "image://colorimage/:/icons/icons/ui/download.svg?" + (toEndButton.down ? Nheko.colors.highlightedText : Nheko.colors.buttonText)
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
fillMode: Image.PreserveAspectFit
}
transitions: [
Transition {
from: ""
to: "hidden"
PropertyAnimation {
target: toEndButton
properties: "width"
easing.type: Easing.InOutQuad
from: 40
to: 0
duration: 200
}
},
Transition {
from: "hidden"
to: ""
PropertyAnimation {
target: toEndButton
properties: "width"
easing.type: Easing.InOutQuad
from: 0
to: 40
duration: 200
}
}
]
}