diff --git a/src/qml/Component/NeochatMaximizeComponent.qml b/src/qml/Component/NeochatMaximizeComponent.qml index e612c31df..1c07f88f8 100644 --- a/src/qml/Component/NeochatMaximizeComponent.qml +++ b/src/qml/Component/NeochatMaximizeComponent.qml @@ -14,16 +14,34 @@ import org.kde.neochat 1.0 Components.AlbumMaximizeComponent { id: root - property var modelData + required property string eventId + + required property var time + + required property var author + + required property int delegateType + + required property string plainText + + required property string caption + + required property var mediaInfo + + required property var progressInfo + + required property var mimeType + + required property var source property list items: [ Components.AlbumModelItem { - type: root.modelData.delegateType === MessageEventModel.Image || root.modelData.delegateType === MessageEventModel.Sticker ? Components.AlbumModelItem.Image : Components.AlbumModelItem.Video - source: root.modelData.delegateType === MessageEventModel.Video ? modelData.progressInfo.localPath : modelData.mediaInfo.source - tempSource: modelData.mediaInfo.tempInfo.source - caption: modelData.display - sourceWidth: modelData.mediaInfo.width - sourceHeight: modelData.mediaInfo.height + type: root.delegateType === MessageEventModel.Image || root.delegateType === MessageEventModel.Sticker ? Components.AlbumModelItem.Image : Components.AlbumModelItem.Video + source: root.delegateType === MessageEventModel.Video ? root.progressInfo.localPath : root.mediaInfo.source + tempSource: root.mediaInfo.tempInfo.source + caption: root.caption + sourceWidth: root.mediaInfo.width + sourceHeight: root.mediaInfo.height } ] @@ -36,22 +54,22 @@ Components.AlbumMaximizeComponent { implicitWidth: Kirigami.Units.iconSizes.medium implicitHeight: Kirigami.Units.iconSizes.medium - name: modelData.author.name ?? modelData.author.displayName - source: modelData.author.avatarSource - color: modelData.author.color + name: root.author.name ?? root.author.displayName + source: root.author.avatarSource + color: root.author.color } ColumnLayout { spacing: 0 QQC2.Label { id: userLabel - text: modelData.author.name ?? modelData.author.displayName - color: modelData.author.color + text: root.author.name ?? root.author.displayName + color: root.author.color font.weight: Font.Bold elide: Text.ElideRight } QQC2.Label { id: dateTimeLabel - text: modelData.time.toLocaleString(Qt.locale(), Locale.ShortFormat) + text: root.time.toLocaleString(Qt.locale(), Locale.ShortFormat) color: Kirigami.Theme.disabledTextColor elide: Text.ElideRight } @@ -59,14 +77,14 @@ Components.AlbumMaximizeComponent { } onItemRightClicked: { const contextMenu = fileDelegateContextMenu.createObject(parent, { - author: modelData.author, - message: modelData.plainText, - eventId: modelData.eventId, - source: modelData.source, + author: root.author, + message: root.plainText, + eventId: root.eventId, + source: root.source, file: parent, - mimeType: modelData.mimeType, - progressInfo: modelData.progressInfo, - plainMessage: modelData.plainText, + mimeType: root.mimeType, + progressInfo: root.progressInfo, + plainMessage: root.plainText, }); contextMenu.closeFullscreen.connect(root.close) contextMenu.open(); @@ -74,7 +92,7 @@ Components.AlbumMaximizeComponent { onSaveItem: { var dialog = saveAsDialog.createObject(QQC2.ApplicationWindow.overlay) dialog.open() - dialog.currentFile = dialog.folder + "/" + currentRoom.fileNameToDownload(modelData.eventId) + dialog.currentFile = dialog.folder + "/" + currentRoom.fileNameToDownload(root.eventId) } Component { @@ -88,7 +106,7 @@ Components.AlbumMaximizeComponent { if (!currentFile) { return; } - currentRoom.downloadFile(eventId, currentFile) + currentRoom.downloadFile(rooteventId, currentFile) } } } diff --git a/src/qml/Component/Timeline/AudioDelegate.qml b/src/qml/Component/Timeline/AudioDelegate.qml index 93bd68943..e12b8a35c 100644 --- a/src/qml/Component/Timeline/AudioDelegate.qml +++ b/src/qml/Component/Timeline/AudioDelegate.qml @@ -10,38 +10,58 @@ import org.kde.kirigami 2.15 as Kirigami import org.kde.neochat 1.0 +/** + * @brief A timeline delegate for an audio message. + * + * @inherit TimelineContainer + */ TimelineContainer { - id: audioDelegate + id: root - onOpenContextMenu: openFileContext(model, audioDelegate) + /** + * @brief The media info for the event. + * + * This should consist of the following: + * - source - The mxc URL for the media. + * - mimeType - The MIME type of the media (should be audio/xxx for this delegate). + * - mimeIcon - The MIME icon name (should be audio-xxx). + * - size - The file size in bytes. + * - duration - The length in seconds of the audio media. + */ + required property var mediaInfo - readonly property bool downloaded: model.progressInfo && model.progressInfo.completed + /** + * @brief Whether the media has been downloaded. + */ + readonly property bool downloaded: root.progressInfo && root.progressInfo.completed onDownloadedChanged: audio.play() + onOpenContextMenu: openFileContext(root) + innerObject: ColumnLayout { Layout.fillWidth: true - Layout.maximumWidth: audioDelegate.contentMaxWidth + Layout.maximumWidth: root.contentMaxWidth Audio { id: audio - source: model.progressInfo.localPath + source: root.progressInfo.localPath autoLoad: false } states: [ State { name: "notDownloaded" - when: !model.progressInfo.completed && !model.progressInfo.active + when: !root.progressInfo.completed && !root.progressInfo.active PropertyChanges { target: playButton icon.name: "media-playback-start" - onClicked: currentRoom.downloadFile(model.eventId) + onClicked: currentRoom.downloadFile(root.eventId) } }, State { name: "downloading" - when: model.progressInfo.active && !model.progressInfo.completed + when: root.progressInfo.active && !root.progressInfo.completed PropertyChanges { target: downloadBar visible: true @@ -50,13 +70,13 @@ TimelineContainer { target: playButton icon.name: "media-playback-stop" onClicked: { - currentRoom.cancelFileTransfer(model.eventId) + currentRoom.cancelFileTransfer(root.eventId) } } }, State { name: "paused" - when: model.progressInfo.completed && (audio.playbackState === Audio.StoppedState || audio.playbackState === Audio.PausedState) + when: root.progressInfo.completed && (audio.playbackState === Audio.StoppedState || audio.playbackState === Audio.PausedState) PropertyChanges { target: playButton icon.name: "media-playback-start" @@ -67,7 +87,7 @@ TimelineContainer { }, State { name: "playing" - when: model.progressInfo.completed && audio.playbackState === Audio.PlayingState + when: root.progressInfo.completed && audio.playbackState === Audio.PlayingState PropertyChanges { target: playButton @@ -84,7 +104,7 @@ TimelineContainer { id: playButton } QQC2.Label { - text: model.display + text: root.display wrapMode: Text.Wrap Layout.fillWidth: true } @@ -94,8 +114,8 @@ TimelineContainer { visible: false Layout.fillWidth: true from: 0 - to: model.mediaInfo.size - value: model.progressInfo.progress + to: root.mediaInfo.size + value: root.progressInfo.progress } RowLayout { visible: audio.hasAudio @@ -109,7 +129,7 @@ TimelineContainer { } QQC2.Label { - visible: audioDelegate.contentMaxWidth > Kirigami.Units.gridUnit * 12 + visible: root.contentMaxWidth > Kirigami.Units.gridUnit * 12 text: Controller.formatDuration(audio.position) + "/" + Controller.formatDuration(audio.duration) } @@ -117,7 +137,7 @@ TimelineContainer { QQC2.Label { Layout.alignment: Qt.AlignRight Layout.rightMargin: Kirigami.Units.smallSpacing - visible: audio.hasAudio && audioDelegate.contentMaxWidth < Kirigami.Units.gridUnit * 12 + visible: audio.hasAudio && root.contentMaxWidth < Kirigami.Units.gridUnit * 12 text: Controller.formatDuration(audio.position) + "/" + Controller.formatDuration(audio.duration) } diff --git a/src/qml/Component/Timeline/EncryptedDelegate.qml b/src/qml/Component/Timeline/EncryptedDelegate.qml index 70af8e00d..e64444ab0 100644 --- a/src/qml/Component/Timeline/EncryptedDelegate.qml +++ b/src/qml/Component/Timeline/EncryptedDelegate.qml @@ -8,6 +8,11 @@ import QtQuick.Layouts 1.15 import org.kde.kirigami 2.15 as Kirigami import org.kde.neochat 1.0 +/** + * @brief A timeline delegate for an encrypted message that can't be decrypted. + * + * @inherit TimelineContainer + */ TimelineContainer { id: encryptedDelegate diff --git a/src/qml/Component/Timeline/FileDelegate.qml b/src/qml/Component/Timeline/FileDelegate.qml index b36987e02..55a7f81e1 100644 --- a/src/qml/Component/Timeline/FileDelegate.qml +++ b/src/qml/Component/Timeline/FileDelegate.qml @@ -10,37 +10,60 @@ import org.kde.kirigami 2.15 as Kirigami import org.kde.neochat 1.0 +/** + * @brief A timeline delegate for an file message. + * + * @inherit TimelineContainer + */ TimelineContainer { - id: fileDelegate + id: root - onOpenContextMenu: openFileContext(model, fileDelegate) + /** + * @brief The media info for the event. + * + * This should consist of the following: + * - source - The mxc URL for the media. + * - mimeType - The MIME type of the media. + * - mimeIcon - The MIME icon name. + * - size - The file size in bytes. + */ + required property var mediaInfo - readonly property bool downloaded: progressInfo && progressInfo.completed + /** + * @brief Whether the media has been downloaded. + */ + readonly property bool downloaded: root.progressInfo && root.progressInfo.completed + + /** + * @brief Whether the file should be automatically opened when downloaded. + */ property bool autoOpenFile: false onDownloadedChanged: if (autoOpenFile) { openSavedFile(); } + onOpenContextMenu: openFileContext(root) + function saveFileAs() { const dialog = fileDialog.createObject(QQC2.ApplicationWindow.overlay) dialog.open() - dialog.currentFile = dialog.folder + "/" + currentRoom.fileNameToDownload(eventId) + dialog.currentFile = dialog.folder + "/" + currentRoom.fileNameToDownload(root.eventId) } function openSavedFile() { - UrlHelper.openUrl(progressInfo.localPath); + UrlHelper.openUrl(root.progressInfo.localPath); } innerObject: RowLayout { - Layout.maximumWidth: Math.min(fileDelegate.contentMaxWidth, implicitWidth) + Layout.maximumWidth: Math.min(root.contentMaxWidth, implicitWidth) spacing: Kirigami.Units.largeSpacing states: [ State { name: "downloadedInstant" - when: progressInfo.completed && autoOpenFile + when: root.progressInfo.completed && autoOpenFile PropertyChanges { target: openButton @@ -57,7 +80,7 @@ TimelineContainer { }, State { name: "downloaded" - when: progressInfo.completed && !autoOpenFile + when: root.progressInfo.completed && !autoOpenFile PropertyChanges { target: openButton @@ -73,7 +96,7 @@ TimelineContainer { }, State { name: "downloading" - when: progressInfo.active + when: root.progressInfo.active PropertyChanges { target: openButton @@ -82,13 +105,13 @@ TimelineContainer { PropertyChanges { target: sizeLabel - text: i18nc("file download progress", "%1 / %2", Controller.formatByteSize(progressInfo.progress), Controller.formatByteSize(progressInfo.total)) + text: i18nc("file download progress", "%1 / %2", Controller.formatByteSize(root.progressInfo.progress), Controller.formatByteSize(root.progressInfo.total)) } PropertyChanges { target: downloadButton icon.name: "media-playback-stop" QQC2.ToolTip.text: i18nc("tooltip for a button on a message; stops downloading the message's file", "Stop Download") - onClicked: currentRoom.cancelFileTransfer(eventId) + onClicked: currentRoom.cancelFileTransfer(root.eventId) } }, State { @@ -97,13 +120,13 @@ TimelineContainer { PropertyChanges { target: downloadButton - onClicked: fileDelegate.saveFileAs() + onClicked: root.saveFileAs() } } ] Kirigami.Icon { - source: model.mediaInfo.mimeIcon + source: root.mediaInfo.mimeIcon fallback: "unknown" } @@ -111,14 +134,14 @@ TimelineContainer { spacing: 0 QQC2.Label { Layout.fillWidth: true - text: model.display + text: root.display wrapMode: Text.Wrap elide: Text.ElideRight } QQC2.Label { id: sizeLabel Layout.fillWidth: true - text: Controller.formatByteSize(model.mediaInfo.size) + text: Controller.formatByteSize(root.mediaInfo.size) opacity: 0.7 elide: Text.ElideRight maximumLineCount: 1 @@ -130,7 +153,7 @@ TimelineContainer { icon.name: "document-open" onClicked: { autoOpenFile = true; - currentRoom.downloadTempFile(eventId); + currentRoom.downloadTempFile(root.eventId); } QQC2.ToolTip.text: i18nc("tooltip for a button on a message; offers ability to open its downloaded file with an appropriate application", "Open File") @@ -157,9 +180,9 @@ TimelineContainer { Config.lastSaveDirectory = folder Config.save() if (autoOpenFile) { - UrlHelper.copyTo(progressInfo.localPath, file) + UrlHelper.copyTo(root.progressInfo.localPath, file) } else { - currentRoom.download(eventId, file); + currentRoom.download(root.eventId, file); } } } diff --git a/src/qml/Component/Timeline/ImageDelegate.qml b/src/qml/Component/Timeline/ImageDelegate.qml index f768d4a9e..509105d65 100644 --- a/src/qml/Component/Timeline/ImageDelegate.qml +++ b/src/qml/Component/Timeline/ImageDelegate.qml @@ -11,37 +11,70 @@ import org.kde.kirigamiaddons.labs.components 1.0 as Components import org.kde.neochat 1.0 +/** + * @brief A timeline delegate for an image message. + * + * @inherit TimelineContainer + */ TimelineContainer { - id: imageDelegate + id: root - onOpenContextMenu: openFileContext(model, imageDelegate) + /** + * @brief The media info for the event. + * + * This should consist of the following: + * - source - The mxc URL for the media. + * - mimeType - The MIME type of the media (should be image/xxx for this delegate). + * - mimeIcon - The MIME icon name (should be image-xxx). + * - size - The file size in bytes. + * - width - The width in pixels of the audio media. + * - height - The height in pixels of the audio media. + * - tempInfo - mediaInfo (with the same properties as this except no tempInfo) for a temporary image while the file downloads. + */ + required property var mediaInfo + /** + * @brief Whether the media has been downloaded. + */ + readonly property bool downloaded: root.progressInfo && root.progressInfo.completed + + /** + * @brief Whether the image should be automatically opened when downloaded. + */ property bool openOnFinished: false - readonly property bool downloaded: progressInfo && progressInfo.completed + /** + * @brief The maximum width of the image. + */ readonly property var maxWidth: Kirigami.Units.gridUnit * 30 + + /** + * @brief The maximum height of the image. + */ readonly property var maxHeight: Kirigami.Units.gridUnit * 30 + onOpenContextMenu: openFileContext(root) + innerObject: AnimatedImage { id: img property var imageWidth: { - if (model.mediaInfo.width > 0) { - return model.mediaInfo.width; + if (root.mediaInfo.width > 0) { + return root.mediaInfo.width; } else if (sourceSize.width && sourceSize.width > 0) { return sourceSize.width; } else { - return imageDelegate.contentMaxWidth; + return root.contentMaxWidth; } } property var imageHeight: { - if (model.mediaInfo.height > 0) { - return model.mediaInfo.height; + if (root.mediaInfo.height > 0) { + return root.mediaInfo.height; } else if (sourceSize.height && sourceSize.height > 0) { return sourceSize.height; } else { // Default to a 16:9 placeholder - return imageDelegate.contentMaxWidth / 16 * 9; + return root.contentMaxWidth / 16 * 9; } } @@ -56,11 +89,11 @@ TimelineContainer { readonly property size maxSize: { if (limitWidth) { - let width = Math.min(imageDelegate.contentMaxWidth, imageDelegate.maxWidth); + let width = Math.min(root.contentMaxWidth, root.maxWidth); let height = width / aspectRatio; return Qt.size(width, height); } else { - let height = Math.min(imageDelegate.maxHeight, imageDelegate.contentMaxWidth / aspectRatio); + let height = Math.min(root.maxHeight, root.contentMaxWidth / aspectRatio); let width = height * aspectRatio; return Qt.size(width, height); } @@ -70,17 +103,17 @@ TimelineContainer { Layout.maximumHeight: maxSize.height Layout.preferredWidth: imageWidth Layout.preferredHeight: imageHeight - source: model.mediaInfo.source + source: root.mediaInfo.source Image { anchors.fill: parent - source: model.mediaInfo.tempInfo.source + source: root.mediaInfo.tempInfo.source visible: parent.status !== Image.Ready } fillMode: Image.PreserveAspectFit - QQC2.ToolTip.text: model.display + QQC2.ToolTip.text: root.display QQC2.ToolTip.visible: hoverHandler.hovered QQC2.ToolTip.delay: Kirigami.Units.toolTipDelay @@ -93,7 +126,7 @@ TimelineContainer { Rectangle { anchors.fill: parent - visible: progressInfo.active && !downloaded + visible: root.progressInfo.active && !downloaded color: "#BB000000" @@ -103,8 +136,8 @@ TimelineContainer { width: parent.width * 0.8 from: 0 - to: progressInfo.total - value: progressInfo.progress + to: root.progressInfo.total + value: root.progressInfo.progress } } @@ -113,12 +146,21 @@ TimelineContainer { onTapped: { img.QQC2.ToolTip.hide() img.paused = true - imageDelegate.ListView.view.interactive = false + root.ListView.view.interactive = false var popup = maximizeImageComponent.createObject(QQC2.ApplicationWindow.overlay, { - modelData: model, + eventId: root.eventId, + time: root.time, + author: root.author, + delegateType: root.delegateType, + plainText: root.plainText, + caption: root.display, + mediaInfo: root.mediaInfo, + progressInfo: root.progressInfo, + mimeType: root.mimeType, + source: root.source }) popup.closed.connect(() => { - imageDelegate.ListView.view.interactive = true + root.ListView.view.interactive = true img.paused = false popup.destroy() }) @@ -136,13 +178,13 @@ TimelineContainer { openSavedFile() } else { openOnFinished = true - currentRoom.downloadFile(eventId, StandardPaths.writableLocation(StandardPaths.CacheLocation) + "/" + eventId.replace(":", "_").replace("/", "_").replace("+", "_") + currentRoom.fileNameToDownload(eventId)) + currentRoom.downloadFile(root.eventId, StandardPaths.writableLocation(StandardPaths.CacheLocation) + "/" + root.eventId.replace(":", "_").replace("/", "_").replace("+", "_") + currentRoom.fileNameToDownload(root.eventId)) } } function openSavedFile() { - if (UrlHelper.openUrl(progressInfo.localPath)) return; - if (UrlHelper.openUrl(progressInfo.localDir)) return; + if (UrlHelper.openUrl(root.progressInfo.localPath)) return; + if (UrlHelper.openUrl(root.progressInfo.localDir)) return; } } } diff --git a/src/qml/Component/Timeline/LocationDelegate.qml b/src/qml/Component/Timeline/LocationDelegate.qml index 5ee6509c7..cac8479aa 100644 --- a/src/qml/Component/Timeline/LocationDelegate.qml +++ b/src/qml/Component/Timeline/LocationDelegate.qml @@ -11,18 +11,41 @@ import org.kde.kirigami 2.15 as Kirigami import org.kde.neochat 1.0 +/** + * @brief A timeline delegate for a location message. + * + * @inherit TimelineContainer + */ TimelineContainer { - id: locationDelegate + id: root + + /** + * @brief The latitude of the location marker in the message. + */ + required property real latitude + + /** + * @brief The longitude of the location marker in the message. + */ + required property real longitude + + /** + * @brief What type of marker the location message is. + * + * The main options are m.pin for a general location or m.self for a pin to show + * a user's location. + */ + required property string asset ColumnLayout { - Layout.maximumWidth: locationDelegate.contentMaxWidth - Layout.preferredWidth: locationDelegate.contentMaxWidth + Layout.maximumWidth: root.contentMaxWidth + Layout.preferredWidth: root.contentMaxWidth Map { id: map Layout.fillWidth: true - Layout.preferredHeight: locationDelegate.contentMaxWidth / 16 * 9 + Layout.preferredHeight: root.contentMaxWidth / 16 * 9 - center: QtPositioning.coordinate(model.latitude, model.longitude) + center: QtPositioning.coordinate(root.latitude, root.longitude) zoomLevel: 15 plugin: Plugin { name: "osm" @@ -44,7 +67,7 @@ TimelineContainer { anchorPoint.x: sourceItem.width / 2 anchorPoint.y: sourceItem.height - coordinate: QtPositioning.coordinate(model.latitude, model.longitude) + coordinate: QtPositioning.coordinate(rot.latitude, root.longitude) autoFadeIn: false sourceItem: Kirigami.Icon { @@ -67,23 +90,23 @@ TimelineContainer { Kirigami.Avatar { anchors.centerIn: parent anchors.verticalCenterOffset: -parent.height / 8 - visible: model.asset === "m.self" + visible: root.asset === "m.self" width: height height: parent.height / 3 + 1 - name: model.author.name ?? model.author.displayName - source: model.author.avatarSource - color: model.author.color + name: root.author.name ?? root.author.displayName + source: root.author.avatarSource + color: root.author.color } } } TapHandler { acceptedButtons: Qt.LeftButton - onLongPressed: openMessageContext(model, "", model.plainText) + onLongPressed: openMessageContext("") } TapHandler { acceptedButtons: Qt.RightButton - onTapped: openMessageContext(model, "", model.plainText) + onTapped: openMessageContext("") } } } diff --git a/src/qml/Component/Timeline/MessageDelegate.qml b/src/qml/Component/Timeline/MessageDelegate.qml index 1384d90f3..4c69f6c4f 100644 --- a/src/qml/Component/Timeline/MessageDelegate.qml +++ b/src/qml/Component/Timeline/MessageDelegate.qml @@ -10,34 +10,63 @@ import org.kde.kirigami 2.15 as Kirigami import org.kde.neochat 1.0 +/** + * @brief A timeline delegate for an text message. + * + * @inherit TimelineContainer + */ TimelineContainer { - id: messageDelegate + id: root - onOpenContextMenu: openMessageContext(model, label.selectedText, model.plainText) + /** + * @brief The link preview properties. + * + * This is a list or object containing the following: + * - url - The URL being previewed. + * - loaded - Whether the URL preview has been loaded. + * - title - the title of the URL preview. + * - description - the description of the URL preview. + * - imageSource - a source URL for the preview image. + * + * @note An empty link previewer should be passed if there are no links to + * preview. + */ + required property var linkPreview + + /** + * @brief Whether there are any links to preview. + */ + required property bool showLinkPreview + + onOpenContextMenu: openMessageContext(label.selectedText) innerObject: ColumnLayout { - Layout.maximumWidth: messageDelegate.contentMaxWidth + Layout.maximumWidth: root.contentMaxWidth RichLabel { id: label Layout.fillWidth: true - visible: currentRoom.chatBoxEditId !== model.eventId + visible: currentRoom.chatBoxEditId !== root.eventId + + isReply: root.isReply + + textMessage: root.display } Loader { Layout.fillWidth: true Layout.minimumHeight: item ? item.minimumHeight : -1 Layout.preferredWidth: item ? item.preferredWidth : -1 - visible: currentRoom.chatBoxEditId === model.eventId + visible: currentRoom.chatBoxEditId === root.eventId active: visible sourceComponent: MessageEditComponent { room: currentRoom - messageId: model.eventId + messageId: root.eventId } } LinkPreviewDelegate { Layout.fillWidth: true - active: !currentRoom.usesEncryption && currentRoom.urlPreviewEnabled && Config.showLinkPreview && model.showLinkPreview - linkPreviewer: model.linkPreview - indicatorEnabled: messageDelegate.isVisibleInTimeline() + active: !currentRoom.usesEncryption && currentRoom.urlPreviewEnabled && Config.showLinkPreview && root.showLinkPreview + linkPreviewer: root.linkPreview + indicatorEnabled: root.isVisibleInTimeline() } } } diff --git a/src/qml/Component/Timeline/PollDelegate.qml b/src/qml/Component/Timeline/PollDelegate.qml index ee6fe9b38..b5cdd7e81 100644 --- a/src/qml/Component/Timeline/PollDelegate.qml +++ b/src/qml/Component/Timeline/PollDelegate.qml @@ -10,25 +10,40 @@ import org.kde.kirigami 2.15 as Kirigami import org.kde.neochat 1.0 +/** + * @brief A timeline delegate for a poll message. + * + * @inherit TimelineContainer + */ TimelineContainer { - id: pollDelegate + id: root - readonly property var data: model - property var pollHandler: currentRoom.poll(model.eventId) + /** + * @brief The matrix message content. + */ + required property var content + + /** + * @brief The poll handler for this poll. + * + * This contains the required information like what the question, answers and + * current number of votes for each is. + */ + property var pollHandler: currentRoom.poll(root.eventId) innerObject: ColumnLayout { Label { id: questionLabel - text: pollDelegate.data.content["org.matrix.msc3381.poll.start"]["question"]["body"] + text: root.content["org.matrix.msc3381.poll.start"]["question"]["body"] } Repeater { - model: pollDelegate.data.content["org.matrix.msc3381.poll.start"]["answers"] + model: root.content["org.matrix.msc3381.poll.start"]["answers"] delegate: RowLayout { - width: pollDelegate.innerObject.width + width: root.innerObject.width CheckBox { - checked: pollDelegate.pollHandler.answers[currentRoom.localUser.id] ? pollDelegate.pollHandler.answers[currentRoom.localUser.id].includes(modelData["id"]) : false - onClicked: pollDelegate.pollHandler.sendPollAnswer(pollDelegate.data.eventId, modelData["id"]) - enabled: !pollDelegate.pollHandler.hasEnded + checked: root.pollHandler.answers[currentRoom.localUser.id] ? root.pollHandler.answers[currentRoom.localUser.id].includes(modelData["id"]) : false + onClicked: root.pollHandler.sendPollAnswer(root.eventId, modelData["id"]) + enabled: !root.pollHandler.hasEnded } Label { text: modelData["org.matrix.msc1767.text"] @@ -37,15 +52,15 @@ TimelineContainer { Layout.fillWidth: true } Label { - visible: pollDelegate.data.content["org.matrix.msc3381.poll.start"]["kind"] == "org.matrix.msc3381.poll.disclosed" || pollHandler.hasEnded + visible: root.content["org.matrix.msc3381.poll.start"]["kind"] == "org.matrix.msc3381.poll.disclosed" || pollHandler.hasEnded Layout.preferredWidth: contentWidth - text: pollDelegate.pollHandler.counts[modelData["id"]] ?? "0" + text: root.pollHandler.counts[modelData["id"]] ?? "0" } } } Label { - visible: pollDelegate.data.content["org.matrix.msc3381.poll.start"]["kind"] == "org.matrix.msc3381.poll.disclosed" || pollDelegate.pollHandler.hasEnded - text: i18np("Based on votes by %1 user", "Based on votes by %1 users", pollDelegate.pollHandler.answerCount) + (pollDelegate.pollHandler.hasEnded ? (" " + i18nc("as in 'this vote has ended'", "(Ended)")) : "") + visible: root.content["org.matrix.msc3381.poll.start"]["kind"] == "org.matrix.msc3381.poll.disclosed" || root.pollHandler.hasEnded + text: i18np("Based on votes by %1 user", "Based on votes by %1 users", root.pollHandler.answerCount) + (root.pollHandler.hasEnded ? (" " + i18nc("as in 'this vote has ended'", "(Ended)")) : "") font.pointSize: questionLabel.font.pointSize * 0.8 } } diff --git a/src/qml/Component/Timeline/ReplyComponent.qml b/src/qml/Component/Timeline/ReplyComponent.qml index 5f863ab70..4a53f1747 100644 --- a/src/qml/Component/Timeline/ReplyComponent.qml +++ b/src/qml/Component/Timeline/ReplyComponent.qml @@ -10,16 +10,67 @@ import org.kde.kirigami 2.15 as Kirigami import org.kde.neochat 1.0 +/** + * @brief A component to show a message that has been replied to. + * + * Similar to the main timeline delegate a reply delegate is chosen based on the type + * of message being replied to. The main difference is that not all messages can be + * show in their original form and are instead visualised with a MIME type delegate + * e.g. Videos. + */ Item { - id: replyComponent + id: root + /** + * @brief The reply author. + * + * This should consist of the following: + * - id - The matrix ID of the reply author. + * - isLocalUser - Whether the reply author is the local user. + * - avatarSource - The mxc URL for the reply author's avatar in the current room. + * - avatarMediaId - The media ID of the reply author's avatar. + * - avatarUrl - The mxc URL for the reply author's avatar. + * - displayName - The display name of the reply author. + * - display - The name of the reply author. + * - color - The color for the reply author. + * - object - The NeoChatUser object for the reply author. + * + * @sa NeoChatUser + */ + required property var author + + /** + * @brief The delegate type of the reply message. + */ + required property int type + + /** + * @brief The display text of the message. + */ + required property string display + + /** + * @brief The media info for the reply event. + * + * This could be an image, audio, video or file. + * + * This should consist of the following: + * - source - The mxc URL for the media. + * - mimeType - The MIME type of the media. + * - mimeIcon - The MIME icon name. + * - size - The file size in bytes. + * - duration - The length in seconds of the audio media (audio/video only). + * - width - The width in pixels of the audio media (image/video only). + * - height - The height in pixels of the audio media (image/video only). + * - tempInfo - mediaInfo (with the same properties as this except no tempInfo) for a temporary image while the file downloads (image/video only). + */ + required property var mediaInfo + + /** + * @brief The reply has been clicked. + */ signal replyClicked() - property var name - property alias avatar: replyAvatar.source - property var color - property var mediaInfo - implicitWidth: mainLayout.implicitWidth implicitHeight: mainLayout.implicitHeight @@ -40,7 +91,7 @@ Item { Layout.rowSpan: 2 implicitWidth: Kirigami.Units.smallSpacing - color: replyComponent.color + color: root.author.color } Kirigami.Avatar { id: replyAvatar @@ -48,25 +99,26 @@ Item { implicitWidth: Kirigami.Units.iconSizes.small implicitHeight: Kirigami.Units.iconSizes.small - name: replyComponent.name || "" - color: replyComponent.color + source: root.author.avatarSource + name: root.author.displayName || "" + color: root.author.color } QQC2.Label { Layout.fillWidth: true - color: replyComponent.color - text: replyComponent.name + color: root.author.color + text: root.author.displayName elide: Text.ElideRight } Loader { id: loader Layout.fillWidth: true - Layout.maximumHeight: loader.item && (model.reply.type == MessageEventModel.Image || model.reply.type == MessageEventModel.Sticker) ? loader.item.height : -1 + Layout.maximumHeight: loader.item && (root.type == MessageEventModel.Image || root.type == MessageEventModel.Sticker) ? loader.item.height : -1 Layout.columnSpan: 2 sourceComponent: { - switch (model.reply.type) { + switch (root.type) { case MessageEventModel.Image: case MessageEventModel.Sticker: return imageComponent; @@ -89,14 +141,14 @@ Item { } TapHandler { acceptedButtons: Qt.LeftButton - onTapped: replyComponent.replyClicked() + onTapped: root.replyClicked() } } Component { id: textComponent RichLabel { - textMessage: model.reply.display + textMessage: root.display textFormat: Text.RichText HoverHandler { @@ -106,7 +158,7 @@ Item { TapHandler { enabled: !hoveredLink acceptedButtons: Qt.LeftButton - onTapped: replyComponent.replyClicked() + onTapped: root.replyClicked() } } } @@ -116,15 +168,15 @@ Item { id: image property var imageWidth: { - if (replyComponent.mediaInfo.width > 0) { - return replyComponent.mediaInfo.width; + if (root.mediaInfo.width > 0) { + return root.mediaInfo.width; } else { return sourceSize.width; } } property var imageHeight: { - if (replyComponent.mediaInfo.height > 0) { - return replyComponent.mediaInfo.height; + if (root.mediaInfo.height > 0) { + return root.mediaInfo.height; } else { return sourceSize.height; } @@ -134,15 +186,15 @@ Item { height: width / aspectRatio fillMode: Image.PreserveAspectFit - source: mediaInfo.source + source: root.mediaInfo.source } } Component { id: mimeComponent MimeComponent { - mimeIconSource: replyComponent.mediaInfo.mimeIcon - label: model.reply.display - subLabel: model.reply.type === MessageEventModel.File ? Controller.formatByteSize(replyComponent.mediaInfo.size) : Controller.formatDuration(replyComponent.mediaInfo.duration) + mimeIconSource: root.mediaInfo.mimeIcon + label: root.display + subLabel: root.type === MessageEventModel.File ? Controller.formatByteSize(root.mediaInfo.size) : Controller.formatDuration(root.mediaInfo.duration) } } Component { diff --git a/src/qml/Component/Timeline/RichLabel.qml b/src/qml/Component/Timeline/RichLabel.qml index b7842b559..d41745cbc 100644 --- a/src/qml/Component/Timeline/RichLabel.qml +++ b/src/qml/Component/Timeline/RichLabel.qml @@ -8,13 +8,35 @@ import QtQuick.Layouts 1.15 import org.kde.neochat 1.0 import org.kde.kirigami 2.15 as Kirigami +/** + * @brief A component to show the rich display text of text message. + */ TextEdit { - id: contentLabel + id: root + /** + * @brief The rich text message to display. + */ + property string textMessage + + /** + * @brief Whether this message is replying to another. + */ + property bool isReply + + /** + * @brief Regex for detecting a message with a single emoji. + */ readonly property var isEmoji: /^()?(\u00a9|\u00ae|[\u20D0-\u2fff]|[\u3190-\u3300]|\ud83c[\ud000-\udfff]|\ud83d[\ud000-\udfff]|\ud83e[\ud000-\udfff])+(<\/span>)?$/ + + /** + * @brief Regex for detecting a message with a spoiler. + */ readonly property var hasSpoiler: /data-mx-spoiler/g - property string textMessage: model.display + /** + * @brief Whether a spoiler should be revealed. + */ property bool spoilerRevealed: !hasSpoiler.test(textMessage) ListView.onReused: Qt.binding(() => !hasSpoiler.test(textMessage)) @@ -23,7 +45,7 @@ TextEdit { // Work around QTBUG 93281 Component.onCompleted: if (text.includes(" @@ -63,7 +85,7 @@ a{ color: Kirigami.Theme.textColor selectedTextColor: Kirigami.Theme.highlightedTextColor selectionColor: Kirigami.Theme.highlightColor - font.pointSize: model.reply === undefined && isEmoji.test(model.display) ? Kirigami.Theme.defaultFont.pointSize * 4 : Kirigami.Theme.defaultFont.pointSize + font.pointSize: !root.isReply && isEmoji.test(textMessage) ? Kirigami.Theme.defaultFont.pointSize * 4 : Kirigami.Theme.defaultFont.pointSize selectByMouse: !Kirigami.Settings.isMobile readOnly: true wrapMode: Text.Wrap diff --git a/src/qml/Component/Timeline/TimelineContainer.qml b/src/qml/Component/Timeline/TimelineContainer.qml index bd3b435e1..80b77a0d1 100644 --- a/src/qml/Component/Timeline/TimelineContainer.qml +++ b/src/qml/Component/Timeline/TimelineContainer.qml @@ -9,34 +9,276 @@ import org.kde.kirigami 2.15 as Kirigami import org.kde.neochat 1.0 +/** + * @brief The base delegate for all messages in the timeline. + * + * This supports a message bubble plus sender avatar for each message as well as reactions + * and read markers. A date section can be show for when the message is on a different + * day to the previous one. + * + * The component is designed for all messages, positioning them in the timeline with + * variable padding depending on the window width. Local user messages are highlighted + * and can also be aligned to the right if configured. + * + * This component also supports a compact mode where the padding is adjusted, the + * background is hidden and the delegate spans the full width of the timeline. + */ ColumnLayout { id: root - property string eventId: model.eventId + /** + * @brief The index of the delegate in the model. + */ + required property var index - property var author: model.author + /** + * @brief The matrix ID of the message event. + */ + required property string eventId - property int delegateType: model.delegateType + /** + * @brief The timestamp of the message. + */ + required property var time - property bool verified: model.verified + /** + * @brief The message author. + * + * This should consist of the following: + * - id - The matrix ID of the author. + * - isLocalUser - Whether the author is the local user. + * - avatarSource - The mxc URL for the author's avatar in the current room. + * - avatarMediaId - The media ID of the author's avatar. + * - avatarUrl - The mxc URL for the author's avatar. + * - displayName - The display name of the author. + * - display - The name of the author. + * - color - The color for the author. + * - object - The NeoChatUser object for the author. + * + * @sa NeoChatUser + */ + required property var author + /** + * @brief Whether the author should be shown. + */ + required property bool showAuthor + + /** + * @brief The delegate type of the message. + */ + required property int delegateType + + /** + * @brief The display text of the message. + */ + required property string display + + /** + * @brief The display text of the message as plain text. + */ + required property string plainText + + /** + * @brief The formatted body of the message. + */ + required property string formattedBody + + /** + * @brief The date of the event as a string. + */ + required property string section + + /** + * @brief Whether the section header should be shown. + */ + required property bool showSection + + /** + * @brief A model with the reactions to the message in. + */ + required property var reaction + + /** + * @brief Whether the reaction component should be shown. + */ + required property bool showReactions + + /** + * @brief A model with the first 5 other user read markers for this message. + */ + required property var readMarkers + + /** + * @brief String with the display name and matrix ID of the other user read markers. + */ + required property string readMarkersString + + /** + * @brief The number of other users at the event after the first 5. + */ + required property var excessReadMarkers + + /** + * @brief Whether the other user read marker component should be shown. + */ + required property bool showReadMarkers + + /** + * @brief The reply author. + * + * This should consist of the following: + * - id - The matrix ID of the reply author. + * - isLocalUser - Whether the reply author is the local user. + * - avatarSource - The mxc URL for the reply author's avatar in the current room. + * - avatarMediaId - The media ID of the reply author's avatar. + * - avatarUrl - The mxc URL for the reply author's avatar. + * - displayName - The display name of the reply author. + * - display - The name of the reply author. + * - color - The color for the reply author. + * - object - The NeoChatUser object for the reply author. + * + * @sa NeoChatUser + */ + required property var replyAuthor + + /** + * @brief The reply content. + * + * This should consist of the following: + * - display - The display text of the reply. + * - type - The delegate type of the reply. + */ + required property var reply + + /** + * @brief The media info for the reply event. + * + * This could be an image, audio, video or file. + * + * This should consist of the following: + * - source - The mxc URL for the media. + * - mimeType - The MIME type of the media. + * - mimeIcon - The MIME icon name. + * - size - The file size in bytes. + * - duration - The length in seconds of the audio media (audio/video only). + * - width - The width in pixels of the audio media (image/video only). + * - height - The height in pixels of the audio media (image/video only). + * - tempInfo - mediaInfo (with the same properties as this except no tempInfo) for a temporary image while the file downloads (image/video only). + */ + required property var replyMediaInfo + + /** + * @brief Whether this message is replying to another. + */ + required property bool isReply + + /** + * @brief Whether this message has a local user mention. + */ + required property bool isHighlighted + + /** + * @brief Whether an event is waiting to be accepted by the server. + */ + required property bool isPending + + /** + * @brief Progress info when downloading files. + * + * @sa Quotient::FileTransferInfo + */ + required property var progressInfo + + /** + * @brief Whether an encrypted message is sent in a verified session. + */ + required property bool verified + + /** + * @brief The mime type of the message's file or media. + */ + required property var mimeType + + /** + * @brief The full message source JSON. + */ + required property var source + + /** + * @brief The x position of the message bubble. + * + * @note Used for positioning the hover actions. + */ readonly property real bubbleX: bubble.x + bubble.anchors.leftMargin + + /** + * @brief The y position of the message bubble. + * + * @note Used for positioning the hover actions. + */ readonly property alias bubbleY: mainContainer.y + + /** + * @brief The width of the message bubble. + * + * @note Used for sizing the hover actions. + */ readonly property alias bubbleWidth: bubble.width + + /** + * @brief Whether this message is hovered. + */ readonly property alias hovered: bubble.hovered + /** + * @brief Open the context menu for the message. + */ signal openContextMenu + + /** + * @brief Open the any message media externally. + */ signal openExternally() + + /** + * @brief The reply has been clicked. + */ signal replyClicked(string eventID) onReplyClicked: ListView.view.goToEvent(eventID) + /** + * @brief The component to display the delegate type. + * + * This is used by the inherited delegates to assign a component to visualise + * the message content for that delegate type. + */ default property alias innerObject : column.children - property Item hoverComponent: hoverActions ?? null + /** + * @brief Whether the bubble background is enabled. + */ property bool cardBackground: true - property bool showUserMessageOnRight: Config.showLocalMessagesOnRight && model.author.isLocalUser && !Config.compactLayout - property bool isHighlighted: model.isHighlighted || isTemporaryHighlighted + + /** + * @brief Whether local user messages should be aligned right. + * + * TODO: make private + */ + property bool showUserMessageOnRight: Config.showLocalMessagesOnRight && root.author.isLocalUser && !Config.compactLayout + + /** + * @brief Whether the message should be highlighted. + */ + property bool showHighlight: root.isHighlighted || isTemporaryHighlighted + + /** + * @brief Whether the message should temporarily be highlighted. + * + * Normally triggered when jumping to the event in the timeline, e.g. when a reply + * is clicked. + */ property bool isTemporaryHighlighted: false onIsTemporaryHighlightedChanged: if (isTemporaryHighlighted) temporaryHighlightTimer.start() @@ -48,6 +290,7 @@ ColumnLayout { onTriggered: isTemporaryHighlighted = false } + // TODO: make these private // The bubble and delegate widths are allowed to grow once the ListView gets beyond a certain size // extraWidth defines this as the excess after a certain ListView width, capped to a max value readonly property int extraWidth: parent ? (parent.width >= Kirigami.Units.gridUnit * 46 ? Math.min((parent.width - Kirigami.Units.gridUnit * 46), Kirigami.Units.gridUnit * 20) : 0) : 0 @@ -89,23 +332,23 @@ ColumnLayout { id: sectionDelegate Layout.fillWidth: true - visible: model.showSection - labelText: model.showSection ? section : "" + visible: root.showSection + labelText: root.section } QQC2.ItemDelegate { id: mainContainer Layout.fillWidth: true - Layout.topMargin: showAuthor ? Kirigami.Units.largeSpacing : (Config.compactLayout ? 1 : Kirigami.Units.smallSpacing) + Layout.topMargin: root.showAuthor ? Kirigami.Units.largeSpacing : (Config.compactLayout ? 1 : Kirigami.Units.smallSpacing) Layout.leftMargin: Kirigami.Units.smallSpacing Layout.rightMargin: Kirigami.Units.smallSpacing - implicitHeight: Math.max(model.showAuthor ? avatar.implicitHeight : 0, bubble.height) + implicitHeight: Math.max(root.showAuthor ? avatar.implicitHeight : 0, bubble.height) Component.onCompleted: { - if (model.isReply && model.reply === undefined) { - messageEventModel.loadReply(sortedMessageEventModel.mapToSource(collapseStateProxyModel.mapToSource(collapseStateProxyModel.index(model.index, 0)))) + if (root.isReply && root.reply === undefined) { + messageEventModel.loadReply(sortedMessageEventModel.mapToSource(collapseStateProxyModel.mapToSource(collapseStateProxyModel.index(root.index, 0)))) } } @@ -130,20 +373,20 @@ ColumnLayout { leftMargin: Kirigami.Units.smallSpacing } - visible: model.showAuthor && + visible: root.showAuthor && Config.showAvatarInTimeline && (Config.compactLayout || !showUserMessageOnRight) - name: model.author.name ?? model.author.displayName - source: model.author.avatarSource - color: model.author.color + name: root.author.name ?? root.author.displayName + source: root.author.avatarSource + color: root.author.color MouseArea { anchors.fill: parent onClicked: { userDetailDialog.createObject(QQC2.ApplicationWindow.overlay, { room: currentRoom, - user: author.object, - displayName: author.displayName + user: root.author.object, + displayName: root.author.displayName }).open(); } cursorShape: Qt.PointingHandCursor @@ -199,7 +442,7 @@ ColumnLayout { width: height Layout.preferredWidth: Kirigami.Units.iconSizes.small Layout.preferredHeight: Kirigami.Units.iconSizes.small - visible: model.isPending && Config.showLocalMessagesOnRight + visible: root.isPending && Config.showLocalMessagesOnRight } ColumnLayout { id: column @@ -208,17 +451,17 @@ ColumnLayout { id: rowLayout spacing: Kirigami.Units.smallSpacing - visible: model.showAuthor + visible: root.showAuthor QQC2.Label { id: nameLabel Layout.maximumWidth: contentMaxWidth - timeLabel.implicitWidth - rowLayout.spacing - text: visible ? author.displayName : "" + text: visible ? root.author.displayName : "" textFormat: Text.PlainText font.weight: Font.Bold - color: author.color + color: root.author.color elide: Text.ElideRight MouseArea { anchors.fill: parent @@ -226,9 +469,9 @@ ColumnLayout { onClicked: { userDetailDialog.createObject(QQC2.ApplicationWindow.overlay, { room: currentRoom, - user: author.object, - displayName: author.displayName, - avatarSource: author.avatarSource + user: root.author.object, + displayName: root.author.displayName, + avatarSource: root.author.avatarSource }).open(); } } @@ -236,10 +479,10 @@ ColumnLayout { QQC2.Label { id: timeLabel - text: visible ? model.time.toLocaleTimeString(Qt.locale(), Locale.ShortFormat) : "" + text: visible ? root.time.toLocaleTimeString(Qt.locale(), Locale.ShortFormat) : "" color: Kirigami.Theme.disabledTextColor QQC2.ToolTip.visible: hoverHandler.hovered - QQC2.ToolTip.text: model.time.toLocaleString(Qt.locale(), Locale.LongFormat) + QQC2.ToolTip.text: root.time.toLocaleString(Qt.locale(), Locale.LongFormat) QQC2.ToolTip.delay: Kirigami.Units.toolTipDelay HoverHandler { @@ -252,20 +495,20 @@ ColumnLayout { Layout.maximumWidth: contentMaxWidth - active: model.isReply + active: root.isReply visible: active sourceComponent: ReplyComponent { - name: currentRoom.htmlSafeMemberName(model.replyAuthor.id) - avatar: model.replyAuthor.avatarSource - color: model.replyAuthor.color - mediaInfo: model.replyMediaInfo + author: root.replyAuthor + type: root.reply.type + display: root.reply.display + mediaInfo: root.replyMediaInfo } Connections { target: replyLoader.item function onReplyClicked() { - replyClicked(reply.eventId) + replyClicked(root.reply.eventId) } } } @@ -275,7 +518,7 @@ ColumnLayout { width: height Layout.preferredWidth: Kirigami.Units.iconSizes.small Layout.preferredHeight: Kirigami.Units.iconSizes.small - visible: model.isPending && !Config.showLocalMessagesOnRight + visible: root.isPending && !Config.showLocalMessagesOnRight } } @@ -286,9 +529,9 @@ ColumnLayout { anchors.fill: parent Kirigami.Theme.colorSet: Kirigami.Theme.View color: { - if (model.author.isLocalUser) { + if (root.author.isLocalUser) { return Kirigami.ColorUtils.tintWithAlpha(Kirigami.Theme.backgroundColor, Kirigami.Theme.highlightColor, 0.15) - } else if (root.isHighlighted) { + } else if (root.showHighlight) { return Kirigami.Theme.positiveBackgroundColor } else { return Kirigami.Theme.backgroundColor @@ -296,7 +539,7 @@ ColumnLayout { } radius: Kirigami.Units.smallSpacing shadow.size: Kirigami.Units.smallSpacing - shadow.color: root.isHighlighted ? Qt.rgba(0.0, 0.0, 0.0, 0.10) : Qt.rgba(Kirigami.Theme.textColor.r, Kirigami.Theme.textColor.g, Kirigami.Theme.textColor.b, 0.10) + shadow.color: root.showHighlight ? Qt.rgba(0.0, 0.0, 0.0, 0.10) : Qt.rgba(Kirigami.Theme.textColor.r, Kirigami.Theme.textColor.g, Kirigami.Theme.textColor.b, 0.10) border.color: Kirigami.ColorUtils.tintWithAlpha(color, Kirigami.Theme.textColor, 0.15) border.width: 1 @@ -332,18 +575,18 @@ ColumnLayout { Layout.leftMargin: showUserMessageOnRight ? 0 : bubble.x + bubble.anchors.leftMargin Layout.rightMargin: showUserMessageOnRight ? Kirigami.Units.largeSpacing : 0 - visible: showReactions - model: reaction + visible: root.showReactions + model: root.reaction - onReactionClicked: (reaction) => currentRoom.toggleReaction(eventId, reaction) + onReactionClicked: (reaction) => currentRoom.toggleReaction(root.eventId, reaction) } AvatarFlow { Layout.alignment: Qt.AlignRight Layout.rightMargin: Kirigami.Units.largeSpacing - visible: showReadMarkers - model: readMarkers - toolTipText: readMarkersString - excessAvatars: excessReadMarkers + visible: root.showReadMarkers + model: root.readMarkers + toolTipText: root.readMarkersString + excessAvatars: root.excessReadMarkers } function isVisibleInTimeline() { @@ -352,31 +595,31 @@ ColumnLayout { } /// Open message context dialog for file and videos - function openFileContext(event, file) { + function openFileContext(file) { const contextMenu = fileDelegateContextMenu.createObject(root, { - author: event.author, - message: event.plainText, - eventId: event.eventId, - source: event.source, + author: root.author, + message: root.plainText, + eventId: root.eventId, + source: root.source, file: file, - mimeType: event.mimeType, - progressInfo: event.progressInfo, - plainMessage: event.plainText, + mimeType: root.mimeType, + progressInfo: root.progressInfo, + plainMessage: root.plainText, }); contextMenu.open(); } /// Open context menu for normal message - function openMessageContext(event, selectedText, plainMessage) { + function openMessageContext(selectedText) { const contextMenu = messageDelegateContextMenu.createObject(root, { selectedText: selectedText, - author: event.author, - message: event.plainText, - eventId: event.eventId, - formattedBody: event.formattedBody, - source: event.source, - eventType: event.eventType, - plainMessage: event.plainText, + author: root.author, + message: root.plainText, + eventId: root.eventId, + formattedBody: root.formattedBody, + source: root.source, + eventType: root.delegateType, + plainMessage: root.plainText, }); contextMenu.open(); } diff --git a/src/qml/Component/Timeline/VideoDelegate.qml b/src/qml/Component/Timeline/VideoDelegate.qml index 946f3c337..c9ff6bc50 100644 --- a/src/qml/Component/Timeline/VideoDelegate.qml +++ b/src/qml/Component/Timeline/VideoDelegate.qml @@ -12,21 +12,59 @@ import org.kde.kirigamiaddons.labs.components 1.0 as Components import org.kde.neochat 1.0 +/** + * @brief A timeline delegate for a video message. + * + * @inherit TimelineContainer + */ TimelineContainer { - id: videoDelegate + id: root + /** + * @brief The media info for the event. + * + * This should consist of the following: + * - source - The mxc URL for the media. + * - mimeType - The MIME type of the media (should be video/xxx for this delegate). + * - mimeIcon - The MIME icon name (should be video-xxx). + * - size - The file size in bytes. + * - duration - The length in seconds of the audio media. + * - width - The width in pixels of the audio media. + * - height - The height in pixels of the audio media. + * - tempInfo - mediaInfo (with the same properties as this except no tempInfo) for a temporary image while the file downloads. + */ + required property var mediaInfo + + /** + * @brief Whether the media has been downloaded. + */ + readonly property bool downloaded: root.progressInfo && root.progressInfo.completed + + /** + * @brief Whether the video should be played when downloaded. + */ property bool playOnFinished: false - readonly property bool downloaded: progressInfo && progressInfo.completed + /** + * @brief Whether the video can be streamed. + */ property bool supportStreaming: true + + /** + * @brief The maximum width of the image. + */ readonly property var maxWidth: Kirigami.Units.gridUnit * 30 + + /** + * @brief The maximum height of the image. + */ readonly property var maxHeight: Kirigami.Units.gridUnit * 30 - onOpenContextMenu: openFileContext(model, vid) + onOpenContextMenu: openFileContext(vid) onDownloadedChanged: { if (downloaded) { - vid.source = progressInfo.localPath + vid.source = root.progressInfo.localPath } if (downloaded && playOnFinished) { @@ -39,22 +77,22 @@ TimelineContainer { id: vid property var videoWidth: { - if (model.mediaInfo.width > 0) { - return model.mediaInfo.width; + if (root.mediaInfo.width > 0) { + return root.mediaInfo.width; } else if (metaData.resolution && metaData.resolution.width) { return metaData.resolution.width; } else { - return videoDelegate.contentMaxWidth; + return root.contentMaxWidth; } } property var videoHeight: { - if (model.mediaInfo.height > 0) { - return model.mediaInfo.height; + if (root.mediaInfo.height > 0) { + return root.mediaInfo.height; } else if (metaData.resolution && metaData.resolution.height) { return metaData.resolution.height; } else { // Default to a 16:9 placeholder - return videoDelegate.contentMaxWidth / 16 * 9; + return root.contentMaxWidth / 16 * 9; } } @@ -69,11 +107,11 @@ TimelineContainer { readonly property size maxSize: { if (limitWidth) { - let width = Math.min(videoDelegate.contentMaxWidth, videoDelegate.maxWidth); + let width = Math.min(root.contentMaxWidth, root.maxWidth); let height = width / aspectRatio; return Qt.size(width, height); } else { - let height = Math.min(videoDelegate.maxHeight, videoDelegate.contentMaxWidth / aspectRatio); + let height = Math.min(root.maxHeight, root.contentMaxWidth / aspectRatio); let width = height * aspectRatio; return Qt.size(width, height); } @@ -91,7 +129,7 @@ TimelineContainer { states: [ State { name: "notDownloaded" - when: !model.progressInfo.completed && !model.progressInfo.active + when: !root.progressInfo.completed && !root.progressInfo.active PropertyChanges { target: noDownloadLabel visible: true @@ -103,7 +141,7 @@ TimelineContainer { }, State { name: "downloading" - when: model.progressInfo.active && !model.progressInfo.completed + when: root.progressInfo.active && !root.progressInfo.completed PropertyChanges { target: downloadBar visible: true @@ -111,7 +149,7 @@ TimelineContainer { }, State { name: "paused" - when: model.progressInfo.completed && (vid.playbackState === MediaPlayer.StoppedState || vid.playbackState === MediaPlayer.PausedState) + when: root.progressInfo.completed && (vid.playbackState === MediaPlayer.StoppedState || vid.playbackState === MediaPlayer.PausedState) PropertyChanges { target: videoControls stateVisible: true @@ -124,7 +162,7 @@ TimelineContainer { }, State { name: "playing" - when: model.progressInfo.completed && vid.playbackState === MediaPlayer.PlayingState + when: root.progressInfo.completed && vid.playbackState === MediaPlayer.PlayingState PropertyChanges { target: videoControls stateVisible: true @@ -154,7 +192,7 @@ TimelineContainer { anchors.fill: parent visible: false - source: model.mediaInfo.tempInfo.source + source: root.mediaInfo.tempInfo.source fillMode: Image.PreserveAspectFit } @@ -190,8 +228,8 @@ TimelineContainer { width: parent.width * 0.8 from: 0 - to: progressInfo.total - value: progressInfo.progress + to: root.progressInfo.total + value: root.progressInfo.progress } } @@ -315,13 +353,22 @@ TimelineContainer { text: i18n("Maximize") icon.name: "view-fullscreen" onTriggered: { - videoDelegate.ListView.view.interactive = false + root.ListView.view.interactive = false vid.pause() var popup = maximizeVideoComponent.createObject(QQC2.ApplicationWindow.overlay, { - modelData: model, + eventId: root.eventId, + time: root.time, + author: root.author, + delegateType: root.delegateType, + plainText: root.plainText, + caption: root.display, + mediaInfo: root.mediaInfo, + progressInfo: root.progressInfo, + mimeType: root.mimeType, + source: root.source }) popup.closed.connect(() => { - videoDelegate.ListView.view.interactive = true + root.ListView.view.interactive = true popup.destroy() }) popup.open() @@ -364,14 +411,14 @@ TimelineContainer { TapHandler { acceptedButtons: Qt.LeftButton - onTapped: if (vid.supportStreaming || progressInfo.completed) { + onTapped: if (vid.supportStreaming || root.progressInfo.completed) { if (vid.playbackState == MediaPlayer.PlayingState) { vid.pause() } else { vid.play() } } else { - videoDelegate.downloadAndPlay() + root.downloadAndPlay() } } } @@ -381,7 +428,7 @@ TimelineContainer { playSavedFile() } else { playOnFinished = true - currentRoom.downloadFile(eventId, Platform.StandardPaths.writableLocation(Platform.StandardPaths.CacheLocation) + "/" + eventId.replace(":", "_").replace("/", "_").replace("+", "_") + currentRoom.fileNameToDownload(eventId)) + currentRoom.downloadFile(root.eventId, Platform.StandardPaths.writableLocation(Platform.StandardPaths.CacheLocation) + "/" + root.eventId.replace(":", "_").replace("/", "_").replace("+", "_") + currentRoom.fileNameToDownload(root.eventId)) } }