Message Content Rework

For now everything should look identical. However this moves to using a model for the content of the message and is intended to lay the foundation for improved message content representation, e.g. splitting up a text message in multiple sections and using different delegates for things like code and quotes.
This commit is contained in:
James Graham
2024-02-18 09:53:08 +00:00
parent 0ebcacce69
commit b598584aea
52 changed files with 2515 additions and 2186 deletions

168
src/qml/AudioComponent.qml Normal file
View File

@@ -0,0 +1,168 @@
// SPDX-FileCopyrightText: 2022 Tobias Fella <tobias.fella@kde.org>
// SPDX-FileCopyrightText: 2024 James Graham <james.h.graham@protonmail.com>
// SPDX-License-Identifier: GPL-2.0-or-later
import QtQuick
import QtQuick.Controls as QQC2
import QtQuick.Layouts
import QtMultimedia
import org.kde.coreaddons
import org.kde.kirigami as Kirigami
import org.kde.neochat
/**
* @brief A component to show audio from a message.
*/
ColumnLayout {
id: root
/**
* @brief The NeoChatRoom the delegate is being displayed in.
*/
required property NeoChatRoom room
/**
* @brief The matrix ID of the message event.
*/
required property string eventId
/**
* @brief The display text of the message.
*/
required property string display
/**
* @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 FileTransferInfo for any downloading files.
*/
required property var fileTransferInfo
/**
* @brief Whether the media has been downloaded.
*/
readonly property bool downloaded: root.fileTransferInfo && root.fileTransferInfo.completed
onDownloadedChanged: if (downloaded) {
audio.play()
}
/**
* @brief The maximum width that the bubble's content can be.
*/
property real maxContentWidth: -1
MediaPlayer {
id: audio
onErrorOccurred: (error, errorString) => console.warn("Audio playback error:" + error + errorString)
audioOutput: AudioOutput {}
}
states: [
State {
name: "notDownloaded"
when: !root.fileTransferInfo.completed && !root.fileTransferInfo.active
PropertyChanges {
target: playButton
icon.name: "media-playback-start"
onClicked: root.room.downloadFile(root.eventId)
}
},
State {
name: "downloading"
when: root.fileTransferInfo.active && !root.fileTransferInfo.completed
PropertyChanges {
target: downloadBar
visible: true
}
PropertyChanges {
target: playButton
icon.name: "media-playback-stop"
onClicked: {
root.room.cancelFileTransfer(root.eventId)
}
}
},
State {
name: "paused"
when: root.fileTransferInfo.completed && (audio.playbackState === MediaPlayer.StoppedState || audio.playbackState === MediaPlayer.PausedState)
PropertyChanges {
target: playButton
icon.name: "media-playback-start"
onClicked: {
audio.source = root.progressInfo.localPath;
audio.play()
}
}
},
State {
name: "playing"
when: root.fileTransferInfo.completed && audio.playbackState === MediaPlayer.PlayingState
PropertyChanges {
target: playButton
icon.name: "media-playback-pause"
onClicked: audio.pause()
}
}
]
RowLayout {
QQC2.ToolButton {
id: playButton
}
QQC2.Label {
text: root.display
wrapMode: Text.Wrap
Layout.fillWidth: true
}
}
QQC2.ProgressBar {
id: downloadBar
visible: false
Layout.fillWidth: true
from: 0
to: root.mediaInfo.size
value: root.fileTransferInfo.progress
}
RowLayout {
visible: audio.hasAudio
QQC2.Slider {
Layout.fillWidth: true
from: 0
to: audio.duration
value: audio.position
onMoved: audio.seek(value)
}
QQC2.Label {
visible: root.maxContentWidth > Kirigami.Units.gridUnit * 12
text: Format.formatDuration(audio.position) + "/" + Format.formatDuration(audio.duration)
}
}
QQC2.Label {
Layout.alignment: Qt.AlignRight
Layout.rightMargin: Kirigami.Units.smallSpacing
visible: audio.hasAudio && root.maxContentWidth < Kirigami.Units.gridUnit * 12
text: Format.formatDuration(audio.position) + "/" + Format.formatDuration(audio.duration)
}
}

View File

@@ -1,144 +0,0 @@
// SPDX-FileCopyrightText: 2022 Tobias Fella <tobias.fella@kde.org>
// SPDX-License-Identifier: GPL-2.0-or-later
import QtQuick
import QtQuick.Controls as QQC2
import QtQuick.Layouts
import QtMultimedia
import org.kde.coreaddons
import org.kde.kirigami as Kirigami
import org.kde.neochat
/**
* @brief A timeline delegate for an audio message.
*
* @inherit MessageDelegate
*/
MessageDelegate {
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 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
/**
* @brief Whether the media has been downloaded.
*/
readonly property bool downloaded: root.progressInfo && root.progressInfo.completed
onDownloadedChanged: audio.play()
onOpenContextMenu: RoomManager.viewEventMenu(eventId, author, delegateType, plainText, "", "", mediaInfo.mimeType, progressInfo)
bubbleContent: ColumnLayout {
MediaPlayer {
id: audio
onErrorOccurred: (error, errorString) => console.warn("Audio playback error:" + error + errorString)
audioOutput: AudioOutput {}
}
states: [
State {
name: "notDownloaded"
when: !root.progressInfo.completed && !root.progressInfo.active
PropertyChanges {
target: playButton
icon.name: "media-playback-start"
onClicked: root.room.downloadFile(root.eventId)
}
},
State {
name: "downloading"
when: root.progressInfo.active && !root.progressInfo.completed
PropertyChanges {
target: downloadBar
visible: true
}
PropertyChanges {
target: playButton
icon.name: "media-playback-stop"
onClicked: {
root.room.cancelFileTransfer(root.eventId);
}
}
},
State {
name: "paused"
when: root.progressInfo.completed && (audio.playbackState === MediaPlayer.StoppedState || audio.playbackState === MediaPlayer.PausedState)
PropertyChanges {
target: playButton
icon.name: "media-playback-start"
onClicked: {
audio.source = root.progressInfo.localPath;
audio.play();
}
}
},
State {
name: "playing"
when: root.progressInfo.completed && audio.playbackState === MediaPlayer.PlayingState
PropertyChanges {
target: playButton
icon.name: "media-playback-pause"
onClicked: audio.pause()
}
}
]
RowLayout {
QQC2.ToolButton {
id: playButton
}
QQC2.Label {
text: root.display
wrapMode: Text.Wrap
Layout.fillWidth: true
}
}
QQC2.ProgressBar {
id: downloadBar
visible: false
Layout.fillWidth: true
from: 0
to: root.mediaInfo.size
value: root.progressInfo.progress
}
RowLayout {
visible: audio.hasAudio
QQC2.Slider {
Layout.fillWidth: true
from: 0
to: audio.duration
value: audio.position
onMoved: audio.seek(value)
}
QQC2.Label {
visible: root.contentMaxWidth > Kirigami.Units.gridUnit * 12
text: Format.formatDuration(audio.position) + "/" + Format.formatDuration(audio.duration)
}
}
QQC2.Label {
Layout.alignment: Qt.AlignRight
Layout.rightMargin: Kirigami.Units.smallSpacing
visible: audio.hasAudio && root.contentMaxWidth < Kirigami.Units.gridUnit * 12
text: Format.formatDuration(audio.position) + "/" + Format.formatDuration(audio.duration)
}
}
}

View File

@@ -22,6 +22,11 @@ import org.kde.neochat
QQC2.Control {
id: root
/**
* @brief The NeoChatRoom the delegate is being displayed in.
*/
required property NeoChatRoom room
/**
* @brief The message author.
*
@@ -61,70 +66,28 @@ QQC2.Control {
property bool showHighlight: false
/**
* @brief The main delegate content item to show in the bubble.
* @brief The model to visualise the content of the message.
*/
property Item content
required property MessageContentModel contentModel
/**
* @brief Whether this message is replying to another.
*/
property bool isReply: false
/**
* @brief The matrix ID of the reply event.
*/
required property var replyId
/**
* @brief The reply author.
* @brief The ActionsHandler object to use.
*
* 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 Quotient::User object for the reply author.
*
* @sa Quotient::User
* This is expected to have the correct room set otherwise messages will be sent
* to the wrong room.
*/
required property var replyAuthor
/**
* @brief The delegate type of the message replied to.
*/
required property int replyDelegateType
/**
* @brief The display text of the message replied to.
*/
required property string replyDisplay
/**
* @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
property ActionsHandler actionsHandler
/**
* @brief Whether the bubble background should be shown.
*/
property alias showBackground: bubbleBackground.visible
/**
* @brief The timeline ListView this component is being used in.
*/
required property ListView timeline
/**
* @brief The maximum width that the bubble's content can be.
*/
@@ -135,11 +98,26 @@ QQC2.Control {
*/
signal replyClicked(string eventID)
/**
* @brief The user selected text has changed.
*/
signal selectedTextChanged(string selectedText)
/**
* @brief Request a context menu be show for the message.
*/
signal showMessageMenu()
contentItem: ColumnLayout {
id: contentColumn
spacing: Kirigami.Units.smallSpacing
RowLayout {
id: headerRow
Layout.maximumWidth: root.maxContentWidth
implicitHeight: Math.max(nameButton.implicitHeight, timeLabel.implicitHeight)
visible: root.showAuthor
QQC2.AbstractButton {
id: nameButton
Layout.fillWidth: true
contentItem: QQC2.Label {
text: root.author.displayName
@@ -152,6 +130,7 @@ QQC2.Control {
onClicked: RoomManager.resolveResource(root.author.id, "mention")
}
QQC2.Label {
id: timeLabel
text: root.timeString
horizontalAlignment: Text.AlignRight
color: Kirigami.Theme.disabledTextColor
@@ -164,35 +143,19 @@ QQC2.Control {
}
}
}
Loader {
id: replyLoader
Layout.fillWidth: true
Layout.maximumWidth: root.maxContentWidth
Repeater {
id: contentRepeater
model: root.contentModel
delegate: MessageComponentChooser {
room: root.room
actionsHandler: root.actionsHandler
timeline: root.timeline
maxContentWidth: root.maxContentWidth
active: root.isReply && root.replyDelegateType !== DelegateType.Other
visible: active
sourceComponent: ReplyComponent {
author: root.replyAuthor
type: root.replyDelegateType
display: root.replyDisplay
mediaInfo: root.replyMediaInfo
contentMaxWidth: root.maxContentWidth
onReplyClicked: (eventId) => {root.replyClicked(eventId)}
onSelectedTextChanged: (selectedText) => {root.selectedTextChanged(selectedText);}
onShowMessageMenu: root.showMessageMenu()
}
Connections {
target: replyLoader.item
function onReplyClicked() {
replyClicked(root.replyId);
}
}
}
Item {
id: contentParent
Layout.fillWidth: true
Layout.maximumWidth: root.maxContentWidth
implicitWidth: root.content ? root.content.implicitWidth : 0
implicitHeight: root.content ? root.content.implicitHeight : 0
}
}
@@ -220,12 +183,4 @@ QQC2.Control {
}
}
}
onContentChanged: {
if (!root.content) {
return;
}
root.content.parent = contentParent;
root.content.anchors.fill = contentParent;
}
}

View File

@@ -0,0 +1,30 @@
// SPDX-FileCopyrightText: 2021 Tobias Fella <tobias.fella@kde.org>
// SPDX-License-Identifier: GPL-2.0-or-later
import QtQuick
import QtQuick.Layouts
import org.kde.kirigami as Kirigami
/**
* @brief A component for an encrypted message that can't be decrypted.
*/
TextEdit {
/**
* @brief The maximum width that the bubble's content can be.
*/
property real maxContentWidth: -1
Layout.fillWidth: true
Layout.maximumWidth: root.maxContentWidth
text: i18n("This message is encrypted and the sender has not shared the key with this device.")
color: Kirigami.Theme.disabledTextColor
selectedTextColor: Kirigami.Theme.highlightedTextColor
selectionColor: Kirigami.Theme.highlightColor
font.pointSize: Kirigami.Theme.defaultFont.pointSize
selectByMouse: !Kirigami.Settings.isMobile
readOnly: true
wrapMode: Text.WordWrap
textFormat: Text.RichText
}

View File

@@ -1,31 +0,0 @@
// SPDX-FileCopyrightText: 2021 Tobias Fella <tobias.fella@kde.org>
// SPDX-License-Identifier: GPL-2.0-or-later
import QtQuick
import QtQuick.Layouts
import org.kde.kirigami as Kirigami
import org.kde.neochat
import org.kde.neochat.config
/**
* @brief A timeline delegate for an encrypted message that can't be decrypted.
*
* @inherit MessageDelegate
*/
MessageDelegate {
id: encryptedDelegate
bubbleContent: TextEdit {
text: i18n("This message is encrypted and the sender has not shared the key with this device.")
color: Kirigami.Theme.disabledTextColor
selectedTextColor: Kirigami.Theme.highlightedTextColor
selectionColor: Kirigami.Theme.highlightColor
font.pointSize: Kirigami.Theme.defaultFont.pointSize
selectByMouse: !Kirigami.Settings.isMobile
readOnly: true
wrapMode: Text.WordWrap
textFormat: Text.RichText
Layout.leftMargin: Config.showAvatarInTimeline ? Kirigami.Units.largeSpacing : 0
}
}

View File

@@ -1,4 +1,5 @@
// SPDX-FileCopyrightText: 2021 Tobias Fella <tobias.fella@kde.org>
// SPDX-FileCopyrightText: 2024 James Graham <james.h.graham@protonmail.com>
// SPDX-License-Identifier: GPL-3.0-only
import QtQuick
@@ -23,65 +24,9 @@ DelegateChooser {
delegate: StateDelegate {}
}
DelegateChoice {
roleValue: DelegateType.Emote
delegate: TextDelegate {
room: root.room
}
}
DelegateChoice {
roleValue: DelegateType.Message
delegate: TextDelegate {
room: root.room
}
}
DelegateChoice {
roleValue: DelegateType.Notice
delegate: TextDelegate {
room: root.room
}
}
DelegateChoice {
roleValue: DelegateType.Image
delegate: ImageDelegate {
room: root.room
}
}
DelegateChoice {
roleValue: DelegateType.Sticker
delegate: ImageDelegate {
room: root.room
}
}
DelegateChoice {
roleValue: DelegateType.Audio
delegate: AudioDelegate {
room: root.room
}
}
DelegateChoice {
roleValue: DelegateType.Video
delegate: VideoDelegate {
room: root.room
}
}
DelegateChoice {
roleValue: DelegateType.File
delegate: FileDelegate {
room: root.room
}
}
DelegateChoice {
roleValue: DelegateType.Encrypted
delegate: EncryptedDelegate {
delegate: MessageDelegate {
room: root.room
}
}
@@ -91,27 +36,6 @@ DelegateChooser {
delegate: ReadMarkerDelegate {}
}
DelegateChoice {
roleValue: DelegateType.Poll
delegate: PollDelegate {
room: root.room
}
}
DelegateChoice {
roleValue: DelegateType.Location
delegate: LocationDelegate {
room: root.room
}
}
DelegateChoice {
roleValue: DelegateType.LiveLocation
delegate: LiveLocationDelegate {
room: root.room
}
}
DelegateChoice {
roleValue: DelegateType.Loading
delegate: LoadingDelegate {}

302
src/qml/FileComponent.qml Normal file
View File

@@ -0,0 +1,302 @@
// SPDX-FileCopyrightText: 2018-2019 Black Hat <bhat@encom.eu.org>
// SPDX-FileCopyrightText: 2024 James Graham <james.h.graham@protonmail.com>
// SPDX-License-Identifier: GPL-3.0-only
import QtQuick
import QtQuick.Controls as QQC2
import QtQuick.Layouts
import Qt.labs.platform
import Qt.labs.qmlmodels
import org.kde.coreaddons
import org.kde.kirigami as Kirigami
import org.kde.neochat
import org.kde.neochat.config
/**
* @brief A component to show a file from a message.
*/
ColumnLayout {
id: root
/**
* @brief The NeoChatRoom the delegate is being displayed in.
*/
required property NeoChatRoom room
/**
* @brief The matrix ID of the message event.
*/
required property string eventId
/**
* @brief The display text of the message.
*/
required property string display
/**
* @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 FileTransferInfo for any downloading files.
*/
required property var fileTransferInfo
/**
* @brief Whether the media has been downloaded.
*/
readonly property bool downloaded: root.fileTransferInfo && root.fileTransferInfo.completed
onDownloadedChanged: {
itineraryModel.path = root.fileTransferInfo.localPath
if (autoOpenFile) {
openSavedFile();
}
}
/**
* @brief Whether the file should be automatically opened when downloaded.
*/
property bool autoOpenFile: false
/**
* @brief The maximum width that the bubble's content can be.
*/
property real maxContentWidth: -1
function saveFileAs() {
const dialog = fileDialog.createObject(QQC2.ApplicationWindow.overlay)
dialog.open()
dialog.currentFile = dialog.folder + "/" + root.room.fileNameToDownload(root.eventId)
}
function openSavedFile() {
UrlHelper.openUrl(root.fileTransferInfo.localPath);
}
Layout.fillWidth: true
Layout.maximumWidth: root.maxContentWidth
spacing: Kirigami.Units.largeSpacing
RowLayout {
spacing: Kirigami.Units.largeSpacing
states: [
State {
name: "downloadedInstant"
when: root.fileTransferInfo.completed && autoOpenFile
PropertyChanges {
target: openButton
icon.name: "document-open"
onClicked: openSavedFile()
}
PropertyChanges {
target: downloadButton
icon.name: "download"
QQC2.ToolTip.text: i18nc("tooltip for a button on a message; offers ability to download its file", "Download")
onClicked: saveFileAs()
}
},
State {
name: "downloaded"
when: root.fileTransferInfo.completed && !autoOpenFile
PropertyChanges {
target: openButton
visible: false
}
PropertyChanges {
target: downloadButton
icon.name: "document-open"
QQC2.ToolTip.text: i18nc("tooltip for a button on a message; offers ability to open its downloaded file with an appropriate application", "Open File")
onClicked: openSavedFile()
}
},
State {
name: "downloading"
when: root.fileTransferInfo.active
PropertyChanges {
target: openButton
visible: false
}
PropertyChanges {
target: sizeLabel
text: i18nc("file download progress", "%1 / %2", Format.formatByteSize(root.fileTransferInfo.progress), Format.formatByteSize(root.fileTransferInfo.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: root.room.cancelFileTransfer(root.eventId)
}
},
State {
name: "raw"
when: true
PropertyChanges {
target: downloadButton
onClicked: root.saveFileAs()
}
}
]
Kirigami.Icon {
source: root.mediaInfo.mimeIcon
fallback: "unknown"
}
ColumnLayout {
spacing: 0
QQC2.Label {
Layout.fillWidth: true
text: root.display
wrapMode: Text.Wrap
elide: Text.ElideRight
}
QQC2.Label {
id: sizeLabel
Layout.fillWidth: true
text: Format.formatByteSize(root.mediaInfo.size)
opacity: 0.7
elide: Text.ElideRight
maximumLineCount: 1
}
}
QQC2.Button {
id: openButton
icon.name: "document-open"
onClicked: {
autoOpenFile = true;
root.room.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")
QQC2.ToolTip.visible: hovered
QQC2.ToolTip.delay: Kirigami.Units.toolTipDelay
}
QQC2.Button {
id: downloadButton
icon.name: "download"
QQC2.ToolTip.text: i18nc("tooltip for a button on a message; offers ability to download its file", "Download")
QQC2.ToolTip.visible: hovered
QQC2.ToolTip.delay: Kirigami.Units.toolTipDelay
}
Component {
id: fileDialog
FileDialog {
fileMode: FileDialog.SaveFile
folder: Config.lastSaveDirectory.length > 0 ? Config.lastSaveDirectory : StandardPaths.writableLocation(StandardPaths.DownloadLocation)
onAccepted: {
Config.lastSaveDirectory = folder
Config.save()
if (autoOpenFile) {
UrlHelper.copyTo(root.fileTransferInfo.localPath, file)
} else {
root.room.download(root.eventId, file);
}
}
}
}
}
Repeater {
id: itinerary
model: ItineraryModel {
id: itineraryModel
connection: root.room.connection
}
delegate: DelegateChooser {
role: "type"
DelegateChoice {
roleValue: "TrainReservation"
delegate: ColumnLayout {
Kirigami.Separator {
Layout.fillWidth: true
}
RowLayout {
QQC2.Label {
text: model.name
}
QQC2.Label {
text: model.coach ? i18n("Coach: %1, Seat: %2", model.coach, model.seat) : ""
visible: model.coach
opacity: 0.7
}
}
RowLayout {
Layout.fillWidth: true
ColumnLayout {
QQC2.Label {
text: model.departureStation + (model.departurePlatform ? (" [" + model.departurePlatform + "]") : "")
}
QQC2.Label {
text: model.departureTime
opacity: 0.7
}
}
Item {
Layout.fillWidth: true
}
ColumnLayout {
QQC2.Label {
text: model.arrivalStation + (model.arrivalPlatform ? (" [" + model.arrivalPlatform + "]") : "")
}
QQC2.Label {
text: model.arrivalTime
opacity: 0.7
Layout.alignment: Qt.AlignRight
}
}
}
}
}
DelegateChoice {
roleValue: "LodgingReservation"
delegate: ColumnLayout {
Kirigami.Separator {
Layout.fillWidth: true
}
QQC2.Label {
text: model.name
}
QQC2.Label {
text: i18nc("<start time> - <end time>", "%1 - %2", model.startTime, model.endTime)
}
QQC2.Label {
text: model.address
}
}
}
}
}
QQC2.Button {
icon.name: "map-globe"
text: i18nc("@action", "Send to KDE Itinerary")
QQC2.ToolTip.visible: hovered
QQC2.ToolTip.text: text
QQC2.ToolTip.delay: Kirigami.Units.toolTipDelay
onClicked: itineraryModel.sendToItinerary()
visible: itinerary.count > 0
}
}

View File

@@ -1,277 +0,0 @@
// SPDX-FileCopyrightText: 2018-2019 Black Hat <bhat@encom.eu.org>
// SPDX-License-Identifier: GPL-3.0-only
import QtQuick
import QtQuick.Controls as QQC2
import QtQuick.Layouts
import Qt.labs.platform
import Qt.labs.qmlmodels
import org.kde.coreaddons
import org.kde.kirigami as Kirigami
import org.kde.neochat
import org.kde.neochat.config
/**
* @brief A timeline delegate for an file message.
*
* @inherit MessageDelegate
*/
MessageDelegate {
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.
* - mimeIcon - The MIME icon name.
* - size - The file size in bytes.
*/
required property var mediaInfo
/**
* @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: {
itineraryModel.path = root.progressInfo.localPath;
if (autoOpenFile) {
openSavedFile();
}
}
onOpenContextMenu: RoomManager.viewEventMenu(eventId, author, delegateType, plainText, "", "", mediaInfo.mimeType, progressInfo)
function saveFileAs() {
const dialog = fileDialog.createObject(QQC2.ApplicationWindow.overlay);
dialog.open();
dialog.currentFile = dialog.folder + "/" + root.room.fileNameToDownload(root.eventId);
}
function openSavedFile() {
UrlHelper.openUrl(root.progressInfo.localPath);
}
bubbleContent: ColumnLayout {
spacing: Kirigami.Units.largeSpacing
RowLayout {
spacing: Kirigami.Units.largeSpacing
states: [
State {
name: "downloadedInstant"
when: root.progressInfo.completed && autoOpenFile
PropertyChanges {
target: openButton
icon.name: "document-open"
onClicked: openSavedFile()
}
PropertyChanges {
target: downloadButton
icon.name: "download"
QQC2.ToolTip.text: i18nc("tooltip for a button on a message; offers ability to download its file", "Download")
onClicked: saveFileAs()
}
},
State {
name: "downloaded"
when: root.progressInfo.completed && !autoOpenFile
PropertyChanges {
target: openButton
visible: false
}
PropertyChanges {
target: downloadButton
icon.name: "document-open"
QQC2.ToolTip.text: i18nc("tooltip for a button on a message; offers ability to open its downloaded file with an appropriate application", "Open File")
onClicked: openSavedFile()
}
},
State {
name: "downloading"
when: root.progressInfo.active
PropertyChanges {
target: openButton
visible: false
}
PropertyChanges {
target: sizeLabel
text: i18nc("file download progress", "%1 / %2", Format.formatByteSize(root.progressInfo.progress), Format.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: root.room.cancelFileTransfer(root.eventId)
}
},
State {
name: "raw"
when: true
PropertyChanges {
target: downloadButton
onClicked: root.saveFileAs()
}
}
]
Kirigami.Icon {
source: root.mediaInfo.mimeIcon
fallback: "unknown"
}
ColumnLayout {
spacing: 0
QQC2.Label {
Layout.fillWidth: true
text: root.display
wrapMode: Text.Wrap
elide: Text.ElideRight
}
QQC2.Label {
id: sizeLabel
Layout.fillWidth: true
text: Format.formatByteSize(root.mediaInfo.size)
opacity: 0.7
elide: Text.ElideRight
maximumLineCount: 1
}
}
QQC2.Button {
id: openButton
icon.name: "document-open"
onClicked: {
autoOpenFile = true;
root.room.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")
QQC2.ToolTip.visible: hovered
QQC2.ToolTip.delay: Kirigami.Units.toolTipDelay
}
QQC2.Button {
id: downloadButton
icon.name: "download"
QQC2.ToolTip.text: i18nc("tooltip for a button on a message; offers ability to download its file", "Download")
QQC2.ToolTip.visible: hovered
QQC2.ToolTip.delay: Kirigami.Units.toolTipDelay
}
Component {
id: fileDialog
FileDialog {
fileMode: FileDialog.SaveFile
folder: Config.lastSaveDirectory.length > 0 ? Config.lastSaveDirectory : StandardPaths.writableLocation(StandardPaths.DownloadLocation)
onAccepted: {
Config.lastSaveDirectory = folder;
Config.save();
if (autoOpenFile) {
UrlHelper.copyTo(root.progressInfo.localPath, file);
} else {
root.room.download(root.eventId, file);
}
}
}
}
}
Repeater {
id: itinerary
model: ItineraryModel {
id: itineraryModel
connection: root.room.connection
}
delegate: DelegateChooser {
role: "type"
DelegateChoice {
roleValue: "TrainReservation"
delegate: ColumnLayout {
Kirigami.Separator {
Layout.fillWidth: true
}
RowLayout {
QQC2.Label {
text: model.name
}
QQC2.Label {
text: model.coach ? i18n("Coach: %1, Seat: %2", model.coach, model.seat) : ""
visible: model.coach
opacity: 0.7
}
}
RowLayout {
Layout.fillWidth: true
ColumnLayout {
QQC2.Label {
text: model.departureStation + (model.departurePlatform ? (" [" + model.departurePlatform + "]") : "")
}
QQC2.Label {
text: model.departureTime
opacity: 0.7
}
}
Item {
Layout.fillWidth: true
}
ColumnLayout {
QQC2.Label {
text: model.arrivalStation + (model.arrivalPlatform ? (" [" + model.arrivalPlatform + "]") : "")
}
QQC2.Label {
text: model.arrivalTime
opacity: 0.7
Layout.alignment: Qt.AlignRight
}
}
}
}
}
DelegateChoice {
roleValue: "LodgingReservation"
delegate: ColumnLayout {
Kirigami.Separator {
Layout.fillWidth: true
}
QQC2.Label {
text: model.name
}
QQC2.Label {
text: i18nc("<start time> - <end time>", "%1 - %2", model.startTime, model.endTime)
}
QQC2.Label {
text: model.address
}
}
}
}
}
QQC2.Button {
icon.name: "map-globe"
text: i18nc("@action", "Send to KDE Itinerary")
QQC2.ToolTip.visible: hovered
QQC2.ToolTip.text: text
QQC2.ToolTip.delay: Kirigami.Units.toolTipDelay
onClicked: itineraryModel.sendToItinerary()
visible: itinerary.count > 0
}
}
}

174
src/qml/ImageComponent.qml Normal file
View File

@@ -0,0 +1,174 @@
// SPDX-FileCopyrightText: 2018-2020 Black Hat <bhat@encom.eu.org>
// SPDX-FileCopyrightText: 2024 James Graham <james.h.graham@protonmail.com>
// SPDX-License-Identifier: GPL-3.0-only
import QtQuick
import QtQuick.Controls as QQC2
import QtQuick.Layouts
import org.kde.kirigami as Kirigami
import org.kde.neochat
/**
* @brief A component to show the image from a message.
*/
Item {
id: root
/**
* @brief The NeoChatRoom the delegate is being displayed in.
*/
required property NeoChatRoom room
/**
* @brief The matrix ID of the message event.
*/
required property string eventId
/**
* @brief The display text of the message.
*/
required property string display
/**
* @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 FileTransferInfo for any downloading files.
*/
required property var fileTransferInfo
/**
* @brief The timeline ListView this component is being used in.
*/
required property ListView timeline
/**
* @brief The maximum width that the bubble's content can be.
*/
property real maxContentWidth: -1
implicitWidth: mediaSizeHelper.currentSize.width
implicitHeight: mediaSizeHelper.currentSize.height
Loader {
id: imageLoader
anchors.fill: parent
active: !root.mediaInfo.animated
sourceComponent: Image {
source: root.mediaInfo.source
sourceSize.width: mediaSizeHelper.currentSize.width * Screen.devicePixelRatio
sourceSize.height: mediaSizeHelper.currentSize.height * Screen.devicePixelRatio
fillMode: Image.PreserveAspectFit
}
}
Loader {
id: animatedImageLoader
anchors.fill: parent
active: root?.mediaInfo.animated ?? false
sourceComponent: AnimatedImage {
source: root.mediaInfo.source
fillMode: Image.PreserveAspectFit
paused: !applicationWindow().active
}
}
Image {
anchors.fill: parent
source: root?.mediaInfo.tempInfo.source ?? ""
visible: _private.imageItem.status !== Image.Ready
}
QQC2.ToolTip.text: root.display
QQC2.ToolTip.visible: hoverHandler.hovered
QQC2.ToolTip.delay: Kirigami.Units.toolTipDelay
HoverHandler {
id: hoverHandler
}
Rectangle {
anchors.fill: parent
visible: _private.imageItem.status !== Image.Ready
color: "#BB000000"
QQC2.ProgressBar {
anchors.centerIn: parent
width: parent.width * 0.8
from: 0
to: 1.0
value: _private.imageItem.progress
}
}
TapHandler {
acceptedButtons: Qt.LeftButton
gesturePolicy: TapHandler.ReleaseWithinBounds | TapHandler.WithinBounds
onTapped: {
root.QQC2.ToolTip.hide()
if (root.mediaInfo.animated) {
_private.imageItem.paused = true
}
root.timeline.interactive = false
// We need to make sure the index is that of the MediaMessageFilterModel.
if (root.timeline.model instanceof MessageFilterModel) {
RoomManager.maximizeMedia(RoomManager.mediaMessageFilterModel.getRowForSourceItem(root.index))
} else {
RoomManager.maximizeMedia(root.index)
}
}
}
function downloadAndOpen() {
if (_private.downloaded) {
openSavedFile()
} else {
openOnFinished = true
root.room.downloadFile(root.eventId, StandardPaths.writableLocation(StandardPaths.CacheLocation) + "/" + root.eventId.replace(":", "_").replace("/", "_").replace("+", "_") + root.room.fileNameToDownload(root.eventId))
}
}
function openSavedFile() {
if (UrlHelper.openUrl(root.fileTransferInfo.localPath)) return;
if (UrlHelper.openUrl(root.fileTransferInfo.localDir)) return;
}
MediaSizeHelper {
id: mediaSizeHelper
contentMaxWidth: root.maxContentWidth
mediaWidth: root?.mediaInfo.width ?? 0
mediaHeight: root?.mediaInfo.height ?? 0
}
QtObject {
id: _private
readonly property var imageItem: root.mediaInfo.animated ? animatedImageLoader.item : imageLoader.item
// The space available for the component after taking away the border
readonly property real downloaded: root.fileTransferInfo && root.fileTransferInfo.completed
}
}

View File

@@ -1,169 +0,0 @@
// SPDX-FileCopyrightText: 2018-2020 Black Hat <bhat@encom.eu.org>
// SPDX-License-Identifier: GPL-3.0-only
import QtQuick
import QtQuick.Window
import QtQuick.Controls as QQC2
import QtQuick.Layouts
import QtQml.Models
import org.kde.kirigami as Kirigami
import org.kde.neochat
/**
* @brief A timeline delegate for an image message.
*
* @inherit MessageDelegate
*/
MessageDelegate {
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 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
/**
* @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: RoomManager.viewEventMenu(eventId, author, delegateType, plainText, "", "", mediaInfo.mimeType, progressInfo)
bubbleContent: Item {
id: imageContainer
property var imageItem: root.mediaInfo.animated ? animatedImageLoader.item : imageLoader.item
implicitWidth: mediaSizeHelper.currentSize.width
implicitHeight: mediaSizeHelper.currentSize.height
Loader {
id: imageLoader
anchors.fill: parent
active: !root.mediaInfo.animated
sourceComponent: Image {
source: root.mediaInfo.source
sourceSize.width: mediaSizeHelper.currentSize.width * Screen.devicePixelRatio
sourceSize.height: mediaSizeHelper.currentSize.height * Screen.devicePixelRatio
fillMode: Image.PreserveAspectFit
}
}
Loader {
id: animatedImageLoader
anchors.fill: parent
active: root.mediaInfo.animated
sourceComponent: AnimatedImage {
source: root.mediaInfo.source
fillMode: Image.PreserveAspectFit
paused: !applicationWindow().active
}
}
Image {
anchors.fill: parent
source: root.mediaInfo.tempInfo.source
visible: imageContainer.imageItem.status !== Image.Ready
}
QQC2.ToolTip.text: root.display
QQC2.ToolTip.visible: hoverHandler.hovered
QQC2.ToolTip.delay: Kirigami.Units.toolTipDelay
HoverHandler {
id: hoverHandler
}
Rectangle {
anchors.fill: parent
visible: (root.progressInfo.active && !downloaded) || imageContainer.imageItem.status !== Image.Ready
color: "#BB000000"
QQC2.ProgressBar {
anchors.centerIn: parent
width: parent.width * 0.8
from: 0
to: root.progressInfo.total
value: root.progressInfo.progress
}
}
TapHandler {
acceptedButtons: Qt.LeftButton
gesturePolicy: TapHandler.ReleaseWithinBounds | TapHandler.WithinBounds
onTapped: {
imageContainer.QQC2.ToolTip.hide();
if (root.mediaInfo.animated) {
imageContainer.imageItem.paused = true;
}
root.ListView.view.interactive = false;
// We need to make sure the index is that of the MediaMessageFilterModel.
if (root.ListView.view.model instanceof MessageFilterModel) {
RoomManager.maximizeMedia(RoomManager.mediaMessageFilterModel.getRowForSourceItem(root.index));
} else {
RoomManager.maximizeMedia(root.index);
}
}
}
function downloadAndOpen() {
if (downloaded) {
openSavedFile();
} else {
openOnFinished = true;
root.room.downloadFile(root.eventId, StandardPaths.writableLocation(StandardPaths.CacheLocation) + "/" + root.eventId.replace(":", "_").replace("/", "_").replace("+", "_") + root.room.fileNameToDownload(root.eventId));
}
}
function openSavedFile() {
if (UrlHelper.openUrl(root.progressInfo.localPath))
return;
if (UrlHelper.openUrl(root.progressInfo.localDir))
return;
}
MediaSizeHelper {
id: mediaSizeHelper
contentMaxWidth: root.contentMaxWidth
mediaWidth: root.mediaInfo.width
mediaHeight: root.mediaInfo.height
}
}
}

View File

@@ -0,0 +1,131 @@
// SPDX-FileCopyrightText: 2022 Bharadwaj Raju <bharadwaj.raju777@protonmail.com>
// SPDX-FileCopyrightText: 2023-2024 James Graham <james.h.graham@protonmail.com>
// SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-or-later OR LicenseRef-KDE-Accepted-GPL
import QtQuick
import QtQuick.Controls as QQC2
import QtQuick.Layouts
import org.kde.kirigami as Kirigami
import org.kde.neochat
/**
* @brief A component to show a link preview from a message.
*/
QQC2.Control {
id: root
/**
* @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.
*/
required property var linkPreviewer
/**
* @brief Standard height for the link preview.
*
* When the content of the link preview is larger than this it will be
* elided/hidden until maximized.
*/
property var defaultHeight: Kirigami.Units.gridUnit * 3 + Kirigami.Units.smallSpacing * 2
property bool truncated: linkPreviewDescription.truncated || !linkPreviewDescription.visible
/**
* @brief The maximum width that the bubble's content can be.
*/
property real maxContentWidth: -1
Layout.fillWidth: true
Layout.maximumWidth: root.maxContentWidth
leftPadding: 0
rightPadding: 0
topPadding: 0
bottomPadding: 0
contentItem: RowLayout {
id: contentRow
spacing: Kirigami.Units.smallSpacing
Rectangle {
id: separator
Layout.fillHeight: true
width: Kirigami.Units.smallSpacing
color: Kirigami.Theme.highlightColor
}
Image {
id: previewImage
Layout.preferredWidth: root.defaultHeight
Layout.preferredHeight: root.defaultHeight
visible: root.linkPreviewer.imageSource.length > 0
source: root.linkPreviewer.imageSource
fillMode: Image.PreserveAspectFit
}
ColumnLayout {
id: column
implicitWidth: Math.max(linkPreviewTitle.implicitWidth, linkPreviewDescription.implicitWidth)
spacing: Kirigami.Units.smallSpacing
Kirigami.Heading {
id: linkPreviewTitle
Layout.fillWidth: true
level: 3
wrapMode: Text.Wrap
textFormat: Text.RichText
text: "<style>
a {
text-decoration: none;
}
</style>
<a href=\"" + root.linkPreviewer.url + "\">" + (maximizeButton.checked ? root.linkPreviewer.title : titleTextMetrics.elidedText).replace("&ndash;", "—") + "</a>"
onLinkActivated: RoomManager.resolveResource(link, "join")
TextMetrics {
id: titleTextMetrics
text: root.linkPreviewer.title
font: linkPreviewTitle.font
elide: Text.ElideRight
elideWidth: (linkPreviewTitle.availableWidth()) * 3
}
function availableWidth() {
let previewImageWidth = (previewImage.visible ? previewImage.width + contentRow.spacing : 0);
return root.maxContentWidth - contentRow.spacing - separator.width - previewImageWidth;
}
}
QQC2.Label {
id: linkPreviewDescription
Layout.fillWidth: true
Layout.maximumHeight: maximizeButton.checked ? -1 : root.defaultHeight - linkPreviewTitle.height - column.spacing
visible: linkPreviewTitle.height + column.spacing <= root.defaultHeight || maximizeButton.checked
text: linkPreviewer.description
wrapMode: Text.Wrap
elide: Text.ElideRight
}
}
}
QQC2.Button {
id: maximizeButton
anchors.right: parent.right
anchors.bottom: parent.bottom
visible: root.hovered && (root.truncated || checked)
checkable: true
text: checked ? i18n("Shrink preview") : i18n("Expand preview")
icon.name: checked ? "go-up" : "go-down"
display: QQC2.AbstractButton.IconOnly
QQC2.ToolTip {
text: maximizeButton.text
visible: hovered
delay: Kirigami.Units.toolTipDelay
}
}
}

View File

@@ -1,148 +0,0 @@
// SPDX-FileCopyrightText: 2022 Bharadwaj Raju <bharadwaj.raju777@protonmail.com>
// SPDX-FileCopyrightText: 2023 James Graham <james.h.graham@protonmail.com>
// SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-or-later OR LicenseRef-KDE-Accepted-GPL
import QtQuick
import QtQuick.Controls as QQC2
import QtQuick.Layouts
import org.kde.kirigami as Kirigami
import org.kde.neochat
Loader {
id: root
/**
* @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.
*/
required property var linkPreviewer
/**
* @brief Standard height for the link preview.
*
* When the content of the link preview is larger than this it will be
* elided/hidden until maximized.
*/
property var defaultHeight: Kirigami.Units.gridUnit * 3 + Kirigami.Units.smallSpacing * 2
/**
* @brief Whether the loading indicator should animate if visible.
*/
property bool indicatorEnabled: false
visible: active
sourceComponent: linkPreviewer && linkPreviewer.loaded ? linkPreviewComponent : loadingComponent
Component {
id: linkPreviewComponent
QQC2.Control {
id: componentRoot
property bool truncated: linkPreviewDescription.truncated || !linkPreviewDescription.visible
leftPadding: 0
rightPadding: 0
topPadding: 0
bottomPadding: 0
contentItem: RowLayout {
spacing: Kirigami.Units.smallSpacing
Rectangle {
Layout.fillHeight: true
width: Kirigami.Units.smallSpacing
color: Kirigami.Theme.highlightColor
}
Image {
visible: root.linkPreviewer.imageSource
Layout.maximumHeight: root.defaultHeight
Layout.maximumWidth: root.defaultHeight
source: root.linkPreviewer.imageSource
fillMode: Image.PreserveAspectFit
}
ColumnLayout {
id: column
spacing: Kirigami.Units.smallSpacing
Kirigami.Heading {
id: linkPreviewTitle
Layout.fillWidth: true
level: 3
wrapMode: Text.Wrap
textFormat: Text.RichText
text: "<style>
a {
text-decoration: none;
}
</style>
<a href=\"" + root.linkPreviewer.url + "\">" + (maximizeButton.checked ? root.linkPreviewer.title : titleTextMetrics.elidedText).replace("&ndash;", "—") + "</a>"
onLinkActivated: RoomManager.resolveResource(link, "join")
TextMetrics {
id: titleTextMetrics
text: root.linkPreviewer.title
font: linkPreviewTitle.font
elide: Text.ElideRight
elideWidth: (linkPreviewTitle.width - Kirigami.Units.largeSpacing * 2.5) * 3
}
}
QQC2.Label {
id: linkPreviewDescription
Layout.fillWidth: true
Layout.maximumHeight: maximizeButton.checked ? -1 : root.defaultHeight - linkPreviewTitle.height - column.spacing
visible: linkPreviewTitle.height + column.spacing <= root.defaultHeight || maximizeButton.checked
text: linkPreviewer.description
wrapMode: Text.Wrap
elide: Text.ElideRight
}
}
}
QQC2.Button {
id: maximizeButton
anchors.right: parent.right
anchors.bottom: parent.bottom
visible: componentRoot.hovered && (componentRoot.truncated || checked)
checkable: true
text: checked ? i18n("Shrink preview") : i18n("Expand preview")
icon.name: checked ? "go-up" : "go-down"
display: QQC2.AbstractButton.IconOnly
QQC2.ToolTip {
text: maximizeButton.text
visible: hovered
delay: Kirigami.Units.toolTipDelay
}
}
}
}
Component {
id: loadingComponent
RowLayout {
id: componentRoot
property bool truncated: false
Rectangle {
Layout.fillHeight: true
width: Kirigami.Units.smallSpacing
color: Kirigami.Theme.highlightColor
}
QQC2.BusyIndicator {
running: root.indicatorEnabled
}
Kirigami.Heading {
Layout.fillWidth: true
Layout.minimumHeight: root.defaultHeight
level: 2
text: i18n("Loading URL preview")
}
}
}
}

View File

@@ -0,0 +1,92 @@
// SPDX-FileCopyrightText: 2021 Tobias Fella <fella@posteo.de>
// SPDX-FileCopyrightText: 2023 Volker Krause <vkrause@kde.org>
// SPDX-FileCopyrightText: 2024 James Graham <james.h.graham@protonmail.com>
// SPDX-License-Identifier: GPL-2.0-or-later
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import QtLocation
import QtPositioning
import org.kde.neochat
/**
* @brief A component to show a live location from a message.
*/
ColumnLayout {
id: root
/**
* @brief The NeoChatRoom the delegate is being displayed in.
*/
required property NeoChatRoom room
/**
* @brief The matrix ID of the message event.
*/
required property string eventId
/**
* @brief The display text of the message.
*/
required property string display
/**
* @brief The maximum width that the bubble's content can be.
*/
property real maxContentWidth: -1
Layout.fillWidth: true
Layout.maximumWidth: root.maxContentWidth
LiveLocationsModel {
id: liveLocationModel
eventId: root.eventId
room: root.room
}
MapView {
id: mapView
Layout.fillWidth: true
Layout.preferredWidth: root.maxContentWidth
Layout.preferredHeight: root.maxContentWidth / 16 * 9
map.center: QtPositioning.coordinate(liveLocationModel.boundingBox.y, liveLocationModel.boundingBox.x)
map.zoomLevel: 15
map.plugin: OsmLocationPlugin.plugin
MapItemView {
model: liveLocationModel
delegate: LocationMapItem {}
}
TapHandler {
acceptedButtons: Qt.LeftButton
onTapped: {
let map = fullScreenMap.createObject(parent, {liveLocationModel: liveLocationModel});
map.open()
}
onLongPressed: openMessageContext("")
}
TapHandler {
acceptedButtons: Qt.RightButton
onTapped: openMessageContext("")
}
Connections {
target: mapView.map
function onCopyrightLinkActivated() {
Qt.openUrlExternally(link)
}
}
}
Component {
id: fullScreenMap
FullScreenMap {}
}
TextComponent {
display: root.display
visible: root.display !== ""
}
}

View File

@@ -1,73 +0,0 @@
// SPDX-FileCopyrightText: 2021 Tobias Fella <fella@posteo.de>
// SPDX-FileCopyrightText: 2023 Volker Krause <vkrause@kde.org>
// SPDX-License-Identifier: GPL-2.0-or-later
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import QtLocation
import QtPositioning
import org.kde.neochat
/**
* @brief A timeline delegate for a location message.
*
* @inherit MessageDelegate
*/
MessageDelegate {
id: root
bubbleContent: ColumnLayout {
LiveLocationsModel {
id: liveLocationModel
eventId: root.eventId
room: root.room
}
MapView {
id: mapView
Layout.fillWidth: true
Layout.preferredHeight: root.contentMaxWidth / 16 * 9
map.center: QtPositioning.coordinate(liveLocationModel.boundingBox.y, liveLocationModel.boundingBox.x)
map.zoomLevel: 15
map.plugin: OsmLocationPlugin.plugin
MapItemView {
model: liveLocationModel
delegate: LocationMapItem {}
}
TapHandler {
acceptedButtons: Qt.LeftButton
onTapped: {
let map = fullScreenMap.createObject(parent, {
liveLocationModel: liveLocationModel
});
map.open();
}
onLongPressed: openMessageContext("")
}
TapHandler {
acceptedButtons: Qt.RightButton
onTapped: openMessageContext("")
}
Connections {
target: mapView.map
function onCopyrightLinkActivated() {
Qt.openUrlExternally(link);
}
}
}
Component {
id: fullScreenMap
FullScreenMap {}
}
RichLabel {
textMessage: root.display
visible: root.display !== ""
}
}
}

60
src/qml/LoadComponent.qml Normal file
View File

@@ -0,0 +1,60 @@
// SPDX-FileCopyrightText: 2024 James Graham <james.h.graham@protonmail.com>
// SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-or-later OR LicenseRef-KDE-Accepted-GPL
import QtQuick
import QtQuick.Controls as QQC2
import QtQuick.Layouts
import org.kde.kirigami as Kirigami
/**
* @brief A component to show a link preview loading from a message.
*/
RowLayout {
id: root
required property int type
/**
* @brief Standard height for the link preview.
*
* When the content of the link preview is larger than this it will be
* elided/hidden until maximized.
*/
property var defaultHeight: Kirigami.Units.gridUnit * 3 + Kirigami.Units.smallSpacing * 2
/**
* @brief The maximum width that the bubble's content can be.
*/
property real maxContentWidth: -1
enum Type {
Reply,
LinkPreview
}
Layout.fillWidth: true
Layout.maximumWidth: root.maxContentWidth
Rectangle {
Layout.fillHeight: true
width: Kirigami.Units.smallSpacing
color: Kirigami.Theme.highlightColor
}
QQC2.BusyIndicator {}
Kirigami.Heading {
Layout.fillWidth: true
Layout.minimumHeight: root.defaultHeight
verticalAlignment: Text.AlignVCenter
level: 2
text: {
switch (root.type) {
case LoadComponent.Reply:
return i18n("Loading reply");
case LoadComponent.LinkPreview:
return i18n("Loading URL preview");
}
}
}
}

View File

@@ -0,0 +1,116 @@
// SPDX-FileCopyrightText: 2021 Tobias Fella <fella@posteo.de>
// SPDX-FileCopyrightText: 2024 James Graham <james.h.graham@protonmail.com>
// SPDX-License-Identifier: GPL-2.0-or-later
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import QtLocation
import QtPositioning
import org.kde.neochat
/**
* @brief A component to show a location from a message.
*/
ColumnLayout {
id: root
/**
* @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 Quotient::User object for the author.
*
* @sa Quotient::User
*/
required property var author
/**
* @brief The display text of the message.
*/
required property string display
/**
* @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
/**
* @brief The maximum width that the bubble's content can be.
*/
property real maxContentWidth: -1
Layout.fillWidth: true
Layout.maximumWidth: root.maxContentWidth
MapView {
id: mapView
Layout.fillWidth: true
Layout.preferredWidth: root.maxContentWidth
Layout.preferredHeight: root.maxContentWidth / 16 * 9
map.center: QtPositioning.coordinate(root.latitude, root.longitude)
map.zoomLevel: 15
map.plugin: OsmLocationPlugin.plugin
LocationMapItem {
latitude: root.latitude
longitude: root.longitude
asset: root.asset
author: root.author
isLive: true
heading: NaN
}
TapHandler {
acceptedButtons: Qt.LeftButton
onTapped: {
let map = fullScreenMap.createObject(parent, {latitude: root.latitude, longitude: root.longitude, asset: root.asset, author: root.author});
map.open()
}
onLongPressed: openMessageContext("")
}
TapHandler {
acceptedButtons: Qt.RightButton
onTapped: openMessageContext("")
}
Connections {
target: mapView.map
function onCopyrightLinkActivated() {
Qt.openUrlExternally(link)
}
}
}
Component {
id: fullScreenMap
FullScreenMap { }
}
TextComponent {
display: root.display
visible: root.display !== ""
}
}

View File

@@ -1,92 +0,0 @@
// SPDX-FileCopyrightText: 2021 Tobias Fella <fella@posteo.de>
// SPDX-License-Identifier: GPL-2.0-or-later
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import QtLocation
import QtPositioning
import org.kde.neochat
/**
* @brief A timeline delegate for a location message.
*
* @inherit MessageDelegate
*/
MessageDelegate {
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
bubbleContent: ColumnLayout {
MapView {
id: mapView
Layout.fillWidth: true
Layout.preferredHeight: root.contentMaxWidth / 16 * 9
map.center: QtPositioning.coordinate(root.latitude, root.longitude)
map.zoomLevel: 15
map.plugin: OsmLocationPlugin.plugin
LocationMapItem {
latitude: root.latitude
longitude: root.longitude
asset: root.asset
author: root.author
isLive: true
heading: NaN
}
TapHandler {
acceptedButtons: Qt.LeftButton
onTapped: {
let map = fullScreenMap.createObject(parent, {
latitude: root.latitude,
longitude: root.longitude,
asset: root.asset,
author: root.author
});
map.open();
}
onLongPressed: openMessageContext("")
}
TapHandler {
acceptedButtons: Qt.RightButton
onTapped: openMessageContext("")
}
Connections {
target: mapView.map
function onCopyrightLinkActivated() {
Qt.openUrlExternally(link);
}
}
}
Component {
id: fullScreenMap
FullScreenMap {}
}
RichLabel {
textMessage: root.display
visible: root.display !== ""
}
}
}

View File

@@ -0,0 +1,172 @@
// SPDX-FileCopyrightText: 2024 James Graham <james.h.graham@protonmail.com>
// SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
import QtQuick
import Qt.labs.qmlmodels
import org.kde.neochat
/**
* @brief Select a message component based on a MessageComponentType.
*/
DelegateChooser {
id: root
/**
* @brief The NeoChatRoom the delegate is being displayed in.
*/
required property NeoChatRoom room
/**
* @brief The ActionsHandler object to use.
*
* This is expected to have the correct room set otherwise messages will be sent
* to the wrong room.
*/
required property ActionsHandler actionsHandler
/**
* @brief The timeline ListView this component is being used in.
*/
required property ListView timeline
/**
* @brief The maximum width that the bubble's content can be.
*/
property real maxContentWidth: -1
/**
* @brief The reply has been clicked.
*/
signal replyClicked(string eventID)
/**
* @brief The user selected text has changed.
*/
signal selectedTextChanged(string selectedText)
/**
* @brief Request a context menu be show for the message.
*/
signal showMessageMenu()
role: "componentType"
DelegateChoice {
roleValue: MessageComponentType.Text
delegate: TextComponent {
maxContentWidth: root.maxContentWidth
onSelectedTextChanged: root.selectedTextChanged(selectedText);
onShowMessageMenu: root.showMessageMenu()
}
}
DelegateChoice {
roleValue: MessageComponentType.Image
delegate: ImageComponent {
room: root.room
timeline: root.timeline
maxContentWidth: root.maxContentWidth
}
}
DelegateChoice {
roleValue: MessageComponentType.Video
delegate: VideoComponent {
room: root.room
timeline: root.timeline
maxContentWidth: root.maxContentWidth
}
}
DelegateChoice {
roleValue: MessageComponentType.Audio
delegate: AudioComponent {
room: root.room
maxContentWidth: root.maxContentWidth
}
}
DelegateChoice {
roleValue: MessageComponentType.File
delegate: FileComponent {
room: root.room
maxContentWidth: root.maxContentWidth
}
}
DelegateChoice {
roleValue: MessageComponentType.Poll
delegate: PollComponent {
room: root.room
maxContentWidth: root.maxContentWidth
}
}
DelegateChoice {
roleValue: MessageComponentType.Location
delegate: LocationComponent {
maxContentWidth: root.maxContentWidth
}
}
DelegateChoice {
roleValue: MessageComponentType.LiveLocation
delegate: LiveLocationComponent {
room: root.room
maxContentWidth: root.maxContentWidth
}
}
DelegateChoice {
roleValue: MessageComponentType.Encrypted
delegate: EncryptedComponent {
maxContentWidth: root.maxContentWidth
}
}
DelegateChoice {
roleValue: MessageComponentType.Reply
delegate: ReplyComponent {
maxContentWidth: root.maxContentWidth
onReplyClicked: (eventId) => {root.replyClicked(eventId)}
}
}
DelegateChoice {
roleValue: MessageComponentType.ReplyLoad
delegate: LoadComponent {
type: LoadComponent.Reply
maxContentWidth: root.maxContentWidth
}
}
DelegateChoice {
roleValue: MessageComponentType.LinkPreview
delegate: LinkPreviewComponent {
maxContentWidth: root.maxContentWidth
}
}
DelegateChoice {
roleValue: MessageComponentType.LinkPreviewLoad
delegate: LoadComponent {
type: LoadComponent.LinkPreview
maxContentWidth: root.maxContentWidth
}
}
DelegateChoice {
roleValue: MessageComponentType.Edit
delegate: MessageEditComponent {
room: root.room
actionsHandler: root.actionsHandler
maxContentWidth: root.maxContentWidth
}
}
DelegateChoice {
roleValue: MessageComponentType.Other
delegate: Item {}
}
}

View File

@@ -88,19 +88,9 @@ TimelineDelegate {
property bool alwaysShowAuthor: false
/**
* @brief The delegate type of the message.
* @brief The model to visualise the content 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
required property MessageContentModel contentModel
/**
* @brief The date of the event as a string.
@@ -142,65 +132,10 @@ TimelineDelegate {
*/
required property bool showReadMarkers
/**
* @brief The matrix ID of the reply event.
*/
required property var replyId
/**
* @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 Quotient::User object for the reply author.
*
* @sa Quotient::User
*/
required property var replyAuthor
/**
* @brief The delegate type of the message replied to.
*/
required property int replyDelegateType
/**
* @brief The display text of the message replied to.
*/
required property string replyDisplay
/**
* @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
required property bool isThreaded
required property string threadRoot
/**
* @brief Whether this message is replying to another.
*/
required property bool isReply
/**
* @brief Whether this message has a local user mention.
*/
@@ -211,13 +146,6 @@ TimelineDelegate {
*/
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.
*/
@@ -249,11 +177,6 @@ TimelineDelegate {
*/
readonly property alias hovered: bubble.hovered
/**
* @brief Open the context menu for the message.
*/
signal openContextMenu
/**
* @brief Open the any message media externally.
*/
@@ -268,7 +191,7 @@ TimelineDelegate {
/**
* @brief The main delegate content item to show in the bubble.
*/
property alias bubbleContent: bubble.content
property var bubbleContent
/**
* @brief Whether the bubble background is enabled.
@@ -293,6 +216,11 @@ TimelineDelegate {
*/
property bool isTemporaryHighlighted: false
/**
* @brief The user selected text.
*/
property string selectedText: ""
onIsTemporaryHighlightedChanged: if (isTemporaryHighlighted) {
temporaryHighlightTimer.start();
}
@@ -329,12 +257,6 @@ TimelineDelegate {
implicitHeight: Math.max(root.showAuthor || root.alwaysShowAuthor ? avatar.implicitHeight : 0, bubble.height)
Component.onCompleted: {
if (root.isReply && root.replyDelegateType === DelegateType.Other) {
root.room.loadReply(root.eventId, root.replyId);
}
}
// show hover actions
onHoveredChanged: {
if (hovered && !Kirigami.Settings.isMobile) {
@@ -395,23 +317,24 @@ TimelineDelegate {
}
]
room: root.room
author: root.author
showAuthor: root.showAuthor || root.alwaysShowAuthor
time: root.time
timeString: root.timeString
showHighlight: root.showHighlight
contentModel: root.contentModel
actionsHandler: root.ListView.view?.actionsHandler ?? null
timeline: root.ListView.view
isReply: root.isReply
replyId: root.replyId
replyAuthor: root.replyAuthor
replyDelegateType: root.replyDelegateType
replyDisplay: root.replyDisplay
replyMediaInfo: root.replyMediaInfo
showHighlight: root.showHighlight
onReplyClicked: eventId => {
root.replyClicked(eventId);
}
onSelectedTextChanged: (selectedText) => {root.selectedText = selectedText;}
onShowMessageMenu: _private.showMessageMenu()
showBackground: root.cardBackground && !Config.compactLayout
}
@@ -424,12 +347,12 @@ TimelineDelegate {
TapHandler {
acceptedButtons: Qt.RightButton
onTapped: root.openContextMenu()
onTapped: _private.showMessageMenu()
}
TapHandler {
acceptedButtons: Qt.LeftButton
onLongPressed: root.openContextMenu()
onLongPressed: _private.showMessageMenu()
}
}
@@ -482,5 +405,9 @@ TimelineDelegate {
* @brief Whether local user messages should be aligned right.
*/
property bool showUserMessageOnRight: Config.showLocalMessagesOnRight && root.author.isLocalUser && !Config.compactLayout && !root.alwaysMaxWidth
function showMessageMenu() {
RoomManager.viewEventMenu(root.eventId, root.room, root.selectedText)
}
}
}

View File

@@ -59,7 +59,7 @@ Loader {
/**
* @brief The delegate type of the message.
*/
required property int delegateType
required property int messageComponentType
/**
* @brief The display text of the message as plain text.
@@ -96,7 +96,7 @@ Loader {
currentRoom.editCache.editId = eventId;
currentRoom.mainCache.replyId = "";
}
visible: author.isLocalUser && (root.delegateType === DelegateType.Emote || root.delegateType === DelegateType.Message)
visible: author.isLocalUser && (root.messageComponentType === MessageComponentType.Emote || root.messageComponentType === MessageComponentType.Message)
},
Kirigami.Action {
text: i18n("Reply")

View File

@@ -9,13 +9,19 @@ import org.kde.kirigami as Kirigami
import org.kde.neochat
/**
* @brief A component to show an edit text field for a text message being edited.
*/
QQC2.TextArea {
id: root
/**
* @brief The NeoChatRoom the delegate is being displayed in.
*/
required property NeoChatRoom room
onRoomChanged: {
_private.chatBarCache = room.editCache;
_private.chatBarCache.relationIdChanged.connect(_private.updateEditText);
_private.chatBarCache.relationIdChanged.connect(_private.updateEditText());
}
/**
@@ -26,10 +32,19 @@ QQC2.TextArea {
*/
required property ActionsHandler actionsHandler
property string messageId
property var minimumHeight: editButtons.height + topPadding + bottomPadding
property var preferredWidth: editTextMetrics.advanceWidth + rightPadding + Kirigami.Units.smallSpacing + Kirigami.Units.gridUnit
/**
* @brief The maximum width that the bubble's content can be.
*/
property real maxContentWidth: -1
Layout.fillWidth: true
Layout.maximumWidth: root.maxContentWidth
Component.onCompleted: _private.updateEditText()
rightPadding: editButtons.width + editButtons.anchors.rightMargin * 2
color: Kirigami.Theme.textColor

View File

@@ -7,6 +7,9 @@ import QtQuick.Layouts
import org.kde.kirigami as Kirigami
/**
* @brief A component to show media based upon its mime type.
*/
RowLayout {
property alias mimeIconSource: icon.source
property alias label: nameLabel.text

View File

@@ -26,12 +26,6 @@ Components.AlbumMaximizeComponent {
readonly property var currentTime: model.data(model.index(content.currentIndex, 0), MessageEventModel.TimeRole)
readonly property var currentDelegateType: model.data(model.index(content.currentIndex, 0), MessageEventModel.DelegateTypeRole)
readonly property string currentPlainText: model.data(model.index(content.currentIndex, 0), MessageEventModel.PlainText)
readonly property var currentMimeType: model.data(model.index(content.currentIndex, 0), MessageEventModel.MimeTypeRole)
readonly property var currentProgressInfo: model.data(model.index(content.currentIndex, 0), MessageEventModel.ProgressInfoRole)
downloadAction: Components.DownloadAction {
@@ -87,7 +81,8 @@ Components.AlbumMaximizeComponent {
}
}
}
onItemRightClicked: RoomManager.viewEventMenu(root.currentEventId, root.currentAuthor, root.currentDelegateType, root.currentPlainText, "", "", root.currentMimeType, root.currentProgressInfo)
onItemRightClicked: RoomManager.viewEventMenu(root.currentEventId, root.currentRoom)
onSaveItem: {
var dialog = saveAsDialog.createObject(QQC2.ApplicationWindow.overlay);

76
src/qml/PollComponent.qml Normal file
View File

@@ -0,0 +1,76 @@
// SPDX-FileCopyrightText: 2022 Tobias Fella <tobias.fella@kde.org>
// SPDX-FileCopyrightText: 2024 James Graham <james.h.graham@protonmail.com>
// SPDX-License-Identifier: GPL-2.0-or-later
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import Qt.labs.platform
import org.kde.neochat
/**
* @brief A component to show a poll from a message.
*/
ColumnLayout {
id: root
/**
* @brief The NeoChatRoom the delegate is being displayed in.
*/
required property NeoChatRoom room
/**
* @brief The matrix ID of the message event.
*/
required property string eventId
/**
* @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.
*/
required property var pollHandler
/**
* @brief The maximum width that the bubble's content can be.
*/
property real maxContentWidth: -1
Layout.fillWidth: true
Layout.maximumWidth: root.maxContentWidth
Label {
id: questionLabel
text: root.pollHandler.question
wrapMode: Text.Wrap
Layout.fillWidth: true
}
Repeater {
model: root.pollHandler.options
delegate: RowLayout {
Layout.fillWidth: true
CheckBox {
checked: root.pollHandler.answers[root.room.localUser.id] ? root.pollHandler.answers[root.room.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"]
Layout.fillWidth: true
wrapMode: Text.Wrap
}
Label {
visible: root.pollHandler.kind == "org.matrix.msc3381.poll.disclosed" || pollHandler.hasEnded
Layout.preferredWidth: contentWidth
text: root.pollHandler.counts[modelData["id"]] ?? "0"
}
}
}
Label {
visible: root.pollHandler.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
}
}

View File

@@ -1,61 +0,0 @@
// SPDX-FileCopyrightText: 2022 Tobias Fella <tobias.fella@kde.org>
// SPDX-License-Identifier: GPL-2.0-or-later
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import Qt.labs.platform
import org.kde.neochat
/**
* @brief A timeline delegate for a poll message.
*
* @inherit MessageDelegate
*/
MessageDelegate {
id: root
/**
* @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.
*/
required property var pollHandler
bubbleContent: ColumnLayout {
Label {
id: questionLabel
text: root.pollHandler.question
wrapMode: Text.Wrap
Layout.fillWidth: true
}
Repeater {
model: root.pollHandler.options
delegate: RowLayout {
Layout.fillWidth: true
CheckBox {
checked: root.pollHandler.answers[root.room.localUser.id] ? root.pollHandler.answers[root.room.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"]
Layout.fillWidth: true
wrapMode: Text.Wrap
}
Label {
visible: root.pollHandler.kind == "org.matrix.msc3381.poll.disclosed" || pollHandler.hasEnded
Layout.preferredWidth: contentWidth
text: root.pollHandler.counts[modelData["id"]] ?? "0"
}
}
}
Label {
visible: root.pollHandler.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
}
}
}

View File

@@ -5,6 +5,7 @@
import QtQuick
import QtQuick.Controls as QQC2
import QtQuick.Layouts
import Qt.labs.qmlmodels
import org.kde.coreaddons
import org.kde.kirigami as Kirigami
@@ -23,6 +24,16 @@ import org.kde.neochat
RowLayout {
id: root
/**
* @brief The matrix ID of the reply event.
*/
required property var replyComponentType
/**
* @brief The matrix ID of the reply event.
*/
required property var replyEventId
/**
* @brief The reply author.
*
@@ -39,17 +50,12 @@ RowLayout {
*
* @sa Quotient::User
*/
required property var author
required property var replyAuthor
/**
* @brief The delegate type of the reply message.
* @brief The display text of the message replied to.
*/
required property int type
/**
* @brief The display text of the message.
*/
required property string display
required property string replyDisplay
/**
* @brief The media info for the reply event.
@@ -66,15 +72,19 @@ RowLayout {
* - 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
required property var replyMediaInfo
property real contentMaxWidth
/**
* @brief The maximum width that the bubble's content can be.
*/
property real maxContentWidth: -1
/**
* @brief The reply has been clicked.
*/
signal replyClicked
signal replyClicked(string eventID)
implicitHeight: contentColumn.implicitHeight
spacing: Kirigami.Units.largeSpacing
Rectangle {
@@ -82,12 +92,17 @@ RowLayout {
Layout.fillHeight: true
implicitWidth: Kirigami.Units.smallSpacing
color: root.author.color
color: root.replyAuthor.color
}
ColumnLayout {
id: contentColumn
implicitHeight: headerRow.implicitHeight + (root.replyComponentType != MessageComponentType.Other ? contentRepeater.itemAt(0).implicitHeight + spacing : 0)
spacing: Kirigami.Units.smallSpacing
RowLayout {
id: headerRow
implicitHeight: Math.max(replyAvatar.implicitHeight, replyName.implicitHeight)
Layout.maximumWidth: root.maxContentWidth
spacing: Kirigami.Units.largeSpacing
KirigamiComponents.Avatar {
@@ -96,42 +111,87 @@ RowLayout {
implicitWidth: Kirigami.Units.iconSizes.small
implicitHeight: Kirigami.Units.iconSizes.small
source: root.author.avatarSource
name: root.author.displayName
color: root.author.color
source: root.replyAuthor.avatarSource
name: root.replyAuthor.displayName
color: root.replyAuthor.color
}
QQC2.Label {
id: replyName
Layout.fillWidth: true
color: root.author.color
text: root.author.displayName
color: root.replyAuthor.color
text: root.replyAuthor.displayName
elide: Text.ElideRight
}
}
Loader {
id: loader
Repeater {
id: contentRepeater
model: [root.replyComponentType]
delegate: DelegateChooser {
role: "modelData"
Layout.fillWidth: true
Layout.maximumHeight: loader.item && (root.type == DelegateType.Image || root.type == DelegateType.Sticker) ? loader.item.height : loader.item.implicitHeight
Layout.columnSpan: 2
DelegateChoice {
roleValue: MessageComponentType.Text
delegate: TextComponent {
display: root.replyDisplay
maxContentWidth: _private.availableContentWidth
sourceComponent: {
switch (root.type) {
case DelegateType.Image:
case DelegateType.Sticker:
return imageComponent;
case DelegateType.Message:
case DelegateType.Notice:
return textComponent;
case DelegateType.File:
case DelegateType.Video:
case DelegateType.Audio:
return mimeComponent;
case DelegateType.Encrypted:
return encryptedComponent;
default:
return textComponent;
HoverHandler {
enabled: !hoveredLink
cursorShape: Qt.PointingHandCursor
}
TapHandler {
enabled: !hoveredLink
acceptedButtons: Qt.LeftButton
onTapped: root.replyClicked(root.replyEventId)
}
}
}
DelegateChoice {
roleValue: MessageComponentType.Image
delegate: Image {
id: image
Layout.maximumWidth: mediaSizeHelper.currentSize.width
Layout.maximumHeight: mediaSizeHelper.currentSize.height
source: root?.replyMediaInfo.source ?? ""
MediaSizeHelper {
id: mediaSizeHelper
contentMaxWidth: _private.availableContentWidth
mediaWidth: root?.replyMediaInfo.width ?? -1
mediaHeight: root?.replyMediaInfo.height ?? -1
}
}
}
DelegateChoice {
roleValue: MessageComponentType.File
delegate: MimeComponent {
mimeIconSource: root.replyMediaInfo.mimeIcon
label: root.replyDisplay
subLabel: root.replyComponentType === DelegateType.File ? Format.formatByteSize(root.replyMediaInfo.size) : Format.formatDuration(root.replyMediaInfo.duration)
}
}
DelegateChoice {
roleValue: MessageComponentType.Video
delegate: MimeComponent {
mimeIconSource: root.replyMediaInfo.mimeIcon
label: root.replyDisplay
subLabel: root.replyComponentType === DelegateType.File ? Format.formatByteSize(root.replyMediaInfo.size) : Format.formatDuration(root.replyMediaInfo.duration)
}
}
DelegateChoice {
roleValue: MessageComponentType.Audio
delegate: MimeComponent {
mimeIconSource: root.replyMediaInfo.mimeIcon
label: root.replyDisplay
subLabel: root.replyComponentType === DelegateType.File ? Format.formatByteSize(root.replyMediaInfo.size) : Format.formatDuration(root.replyMediaInfo.duration)
}
}
DelegateChoice {
roleValue: MessageComponentType.Encrypted
delegate: TextComponent {
display: i18n("This message is encrypted and the sender has not shared the key with this device.")
}
}
}
}
@@ -141,55 +201,11 @@ RowLayout {
}
TapHandler {
acceptedButtons: Qt.LeftButton
onTapped: root.replyClicked()
onTapped: root.replyClicked(root.replyEventId)
}
Component {
id: textComponent
RichLabel {
textMessage: root.display
HoverHandler {
enabled: !hoveredLink
cursorShape: Qt.PointingHandCursor
}
TapHandler {
enabled: !hoveredLink
acceptedButtons: Qt.LeftButton
onTapped: root.replyClicked()
}
}
}
Component {
id: imageComponent
Image {
id: image
width: mediaSizeHelper.currentSize.width
height: mediaSizeHelper.currentSize.height
fillMode: Image.PreserveAspectFit
source: root?.mediaInfo.source ?? ""
MediaSizeHelper {
id: mediaSizeHelper
contentMaxWidth: root.contentMaxWidth - verticalBorder.width - root.spacing
mediaWidth: root?.mediaInfo.width ?? -1
mediaHeight: root?.mediaInfo.height ?? -1
}
}
}
Component {
id: mimeComponent
MimeComponent {
mimeIconSource: root.mediaInfo.mimeIcon
label: root.display
subLabel: root.type === DelegateType.File ? Format.formatByteSize(root.mediaInfo.size) : Format.formatDuration(root.mediaInfo.duration)
}
}
Component {
id: encryptedComponent
RichLabel {
textMessage: i18n("This message is encrypted and the sender has not shared the key with this device.")
textFormat: Text.RichText
}
QtObject {
id: _private
// The space available for the component after taking away the border
readonly property real availableContentWidth: root.maxContentWidth - verticalBorder.implicitWidth - root.spacing
}
}

View File

@@ -47,8 +47,8 @@ QQC2.ScrollView {
role: "type"
DelegateChoice {
roleValue: 0//MediaMessageFilterModel.Image
delegate: ImageDelegate {
roleValue: MediaMessageFilterModel.Image
delegate: MessageDelegate {
alwaysShowAuthor: true
alwaysMaxWidth: true
cardBackground: false
@@ -57,8 +57,8 @@ QQC2.ScrollView {
}
DelegateChoice {
roleValue: 1//MediaMessageFilterModel.Video
delegate: VideoDelegate {
roleValue: MediaMessageFilterModel.Video
delegate: MessageDelegate {
alwaysShowAuthor: true
alwaysMaxWidth: true
cardBackground: false

View File

@@ -256,23 +256,23 @@ Kirigami.Page {
});
}
function onShowMessageMenu(eventId, author, delegateType, plainText, htmlText, selectedText) {
function onShowMessageMenu(eventId, author, messageComponentType, plainText, htmlText, selectedText) {
const contextMenu = messageDelegateContextMenu.createObject(root, {
selectedText: selectedText,
author: author,
eventId: eventId,
delegateType: delegateType,
messageComponentType: messageComponentType,
plainText: plainText,
htmlText: htmlText
});
contextMenu.open();
}
function onShowFileMenu(eventId, author, delegateType, plainText, mimeType, progressInfo) {
function onShowFileMenu(eventId, author, messageComponentType, plainText, mimeType, progressInfo) {
const contextMenu = fileDelegateContextMenu.createObject(root, {
author: author,
eventId: eventId,
delegateType: delegateType,
messageComponentType: messageComponentType,
plainText: plainText,
mimeType: mimeType,
progressInfo: progressInfo

View File

@@ -1,27 +1,29 @@
// SPDX-FileCopyrightText: 2020 Black Hat <bhat@encom.eu.org>
// SPDX-FileCopyrightText: 2024 James Graham <james.h.graham@protonmail.com>
// SPDX-License-Identifier: GPL-3.0-only
import QtQuick
import QtQuick.Layouts
import org.kde.neochat
import org.kde.kirigami as Kirigami
import org.kde.neochat
/**
* @brief A component to show the rich display text of text message.
* @brief A component to show rich text from a message.
*/
TextEdit {
id: root
/**
* @brief The rich text message to display.
* @brief The display text of the message.
*/
property string textMessage
required property string display
/**
* @brief Whether this message is replying to another.
*/
property bool isReply
property bool isReply: false
/**
* @brief Regex for detecting a message with a single emoji.
@@ -31,7 +33,7 @@ TextEdit {
/**
* @brief Whether the message is an emoji
*/
readonly property var isEmoji: isEmojiRegex.test(textMessage)
readonly property var isEmoji: isEmojiRegex.test(display)
/**
* @brief Regex for detecting a message with a spoiler.
@@ -41,9 +43,23 @@ TextEdit {
/**
* @brief Whether a spoiler should be revealed.
*/
property bool spoilerRevealed: !hasSpoiler.test(textMessage)
property bool spoilerRevealed: !hasSpoiler.test(display)
ListView.onReused: Qt.binding(() => !hasSpoiler.test(textMessage))
/**
* @brief The maximum width that the bubble's content can be.
*/
property real maxContentWidth: -1
/**
* @brief Request a context menu be show for the message.
*/
signal showMessageMenu()
Layout.fillWidth: true
Layout.fillHeight: true
Layout.maximumWidth: root.maxContentWidth
ListView.onReused: Qt.binding(() => !hasSpoiler.test(display))
persistentSelection: true
@@ -91,7 +107,7 @@ a{
background: " + Kirigami.Theme.textColor + ";
}
" : "") + "
</style>" + textMessage
</style>" + display
color: Kirigami.Theme.textColor
selectedTextColor: Kirigami.Theme.highlightedTextColor
@@ -106,8 +122,8 @@ a{
textFormat: Text.RichText
onLinkActivated: link => {
spoilerRevealed = true;
RoomManager.resolveResource(link, "join");
spoilerRevealed = true
RoomManager.resolveResource(link, "join")
}
onHoveredLinkChanged: if (hoveredLink.length > 0 && hoveredLink !== "1") {
applicationWindow().hoverLinkIndicator.text = hoveredLink;
@@ -116,11 +132,16 @@ a{
}
HoverHandler {
cursorShape: (parent.hoveredLink || !spoilerRevealed) ? Qt.PointingHandCursor : Qt.IBeamCursor
cursorShape: (root.hoveredLink || !spoilerRevealed) ? Qt.PointingHandCursor : Qt.IBeamCursor
}
TapHandler {
enabled: !parent.hoveredLink && !spoilerRevealed
enabled: !root.hoveredLink && !spoilerRevealed
onTapped: spoilerRevealed = true
}
TapHandler {
enabled: !root.hoveredLink
acceptedButtons: Qt.LeftButton
onLongPressed: root.showMessageMenu()
}
}

View File

@@ -1,77 +0,0 @@
// SPDX-FileCopyrightText: 2021 Tobias Fella <tobias.fella@kde.org>
// SPDX-License-Identifier: GPL-3.0-only
import QtQuick
import QtQuick.Layouts
import Qt.labs.qmlmodels
import org.kde.neochat
import org.kde.neochat.config
/**
* @brief A timeline delegate for a text message.
*
* @inherit MessageDelegate
*/
MessageDelegate {
id: root
/**
* @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: RoomManager.viewEventMenu(eventId, author, delegateType, plainText, display, label.selectedText)
bubbleContent: ColumnLayout {
RichLabel {
id: label
Layout.fillWidth: true
visible: root.room.editCache.editId !== root.eventId
isReply: root.isReply
textMessage: root.display
TapHandler {
enabled: !label.hoveredLink
acceptedButtons: Qt.LeftButton
onLongPressed: root.openContextMenu()
}
}
Loader {
Layout.fillWidth: true
Layout.minimumHeight: item ? item.minimumHeight : -1
Layout.preferredWidth: item ? item.preferredWidth : -1
visible: root.room.editCache.editId === root.eventId
active: visible
sourceComponent: MessageEditComponent {
room: root.room
actionsHandler: root.ListView.view.actionsHandler
messageId: root.eventId
}
}
LinkPreviewDelegate {
Layout.fillWidth: true
active: !root.room.usesEncryption && root.room.urlPreviewEnabled && Config.showLinkPreview && root.showLinkPreview && !root.linkPreview.empty
linkPreviewer: root.linkPreview
indicatorEnabled: root.isVisibleInTimeline()
}
}
}

381
src/qml/VideoComponent.qml Normal file
View File

@@ -0,0 +1,381 @@
// SPDX-FileCopyrightText: 2019 Black Hat <bhat@encom.eu.org>
// SPDX-FileCopyrightText: 2024 James Graham <james.h.graham@protonmail.com>
// SPDX-License-Identifier: GPL-3.0-only
import QtQuick
import QtQuick.Controls as QQC2
import QtQuick.Layouts
import QtMultimedia
import Qt.labs.platform as Platform
import org.kde.coreaddons
import org.kde.kirigami as Kirigami
import org.kde.neochat
/**
* @brief A component to show a video from a message.
*/
Video {
id: root
/**
* @brief The NeoChatRoom the delegate is being displayed in.
*/
required property NeoChatRoom room
/**
* @brief The matrix ID of the message event.
*/
required property string eventId
/**
* @brief The display text of the message.
*/
required property string display
/**
* @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 FileTransferInfo for any downloading files.
*/
required property var fileTransferInfo
/**
* @brief Whether the media has been downloaded.
*/
readonly property bool downloaded: root.fileTransferInfo && root.fileTransferInfo.completed
onDownloadedChanged: {
if (downloaded) {
root.source = root.fileTransferInfo.localPath
}
if (downloaded && playOnFinished) {
playSavedFile()
playOnFinished = false
}
}
/**
* @brief Whether the video should be played when downloaded.
*/
property bool playOnFinished: false
/**
* @brief The timeline ListView this component is being used in.
*/
required property ListView timeline
/**
* @brief The maximum width that the bubble's content can be.
*/
property real maxContentWidth: -1
Layout.preferredWidth: mediaSizeHelper.currentSize.width
Layout.preferredHeight: mediaSizeHelper.currentSize.height
fillMode: VideoOutput.PreserveAspectFit
states: [
State {
name: "notDownloaded"
when: !root.fileTransferInfo.completed && !root.fileTransferInfo.active
PropertyChanges {
target: noDownloadLabel
visible: true
}
PropertyChanges {
target: mediaThumbnail
visible: true
}
},
State {
name: "downloading"
when: root.fileTransferInfo.active && !root.fileTransferInfo.completed
PropertyChanges {
target: downloadBar
visible: true
}
},
State {
name: "paused"
when: root.fileTransferInfo.completed && (root.playbackState === MediaPlayer.StoppedState || root.playbackState === MediaPlayer.PausedState)
PropertyChanges {
target: videoControls
stateVisible: true
}
PropertyChanges {
target: playButton
icon.name: "media-playback-start"
onClicked: root.play()
}
},
State {
name: "playing"
when: root.fileTransferInfo.completed && root.playbackState === MediaPlayer.PlayingState
PropertyChanges {
target: videoControls
stateVisible: true
}
PropertyChanges {
target: playButton
icon.name: "media-playback-pause"
onClicked: root.pause()
}
}
]
Image {
id: mediaThumbnail
anchors.fill: parent
visible: false
source: root.mediaInfo.tempInfo.source
fillMode: Image.PreserveAspectFit
}
QQC2.Label {
id: noDownloadLabel
anchors.centerIn: parent
visible: false
color: "white"
text: i18n("Video")
font.pixelSize: 16
padding: 8
background: Rectangle {
radius: Kirigami.Units.smallSpacing
color: "black"
opacity: 0.3
}
}
Rectangle {
id: downloadBar
anchors.fill: parent
visible: false
color: Kirigami.Theme.backgroundColor
radius: Kirigami.Units.smallSpacing
QQC2.ProgressBar {
anchors.centerIn: parent
width: parent.width * 0.8
from: 0
to: root.fileTransferInfo.total
value: root.fileTransferInfo.progress
}
}
QQC2.Control {
id: videoControls
property bool stateVisible: false
anchors.bottom: root.bottom
anchors.left: root.left
anchors.right: root.right
visible: stateVisible && (videoHoverHandler.hovered || volumePopupHoverHandler.hovered || volumeSlider.hovered || videoControlTimer.running)
contentItem: RowLayout {
id: controlRow
QQC2.ToolButton {
id: playButton
}
QQC2.Slider {
Layout.fillWidth: true
from: 0
to: root.duration
value: root.position
onMoved: root.seek(value)
}
QQC2.Label {
text: Format.formatDuration(root.position) + "/" + Format.formatDuration(root.duration)
}
QQC2.ToolButton {
id: volumeButton
property var unmuteVolume: root.volume
icon.name: root.volume <= 0 ? "player-volume-muted" : "player-volume"
QQC2.ToolTip.visible: hovered
QQC2.ToolTip.delay: Kirigami.Units.toolTipDelay
QQC2.ToolTip.timeout: Kirigami.Units.toolTipDelay
QQC2.ToolTip.text: i18nc("@action:button", "Volume")
onClicked: {
if (root.volume > 0) {
root.volume = 0
} else {
if (unmuteVolume === 0) {
root.volume = 1
} else {
root.volume = unmuteVolume
}
}
}
onHoveredChanged: {
if (!hovered && (root.state === "paused" || root.state === "playing")) {
videoControlTimer.restart()
volumePopupTimer.restart()
}
}
QQC2.Popup {
id: volumePopup
y: -height
width: volumeButton.width
visible: videoControls.stateVisible && (volumeButton.hovered || volumePopupHoverHandler.hovered || volumeSlider.hovered || volumePopupTimer.running)
focus: true
padding: Kirigami.Units.smallSpacing
closePolicy: QQC2.Popup.NoAutoClose
QQC2.Slider {
id: volumeSlider
anchors.centerIn: parent
implicitHeight: Kirigami.Units.gridUnit * 7
orientation: Qt.Vertical
padding: 0
from: 0
to: 1
value: root.volume
onMoved: {
root.volume = value
volumeButton.unmuteVolume = value
}
onHoveredChanged: {
if (!hovered && (root.state === "paused" || root.state === "playing")) {
rooteoControlTimer.restart()
volumePopupTimer.restart()
}
}
}
Timer {
id: volumePopupTimer
interval: 500
}
HoverHandler {
id: volumePopupHoverHandler
onHoveredChanged: {
if (!hovered && (root.state === "paused" || root.state === "playing")) {
videoControlTimer.restart()
volumePopupTimer.restart()
}
}
}
background: Kirigami.ShadowedRectangle {
radius: 4
color: Kirigami.Theme.backgroundColor
opacity: 0.8
property color borderColor: Kirigami.Theme.textColor
border.color: Qt.rgba(borderColor.r, borderColor.g, borderColor.b, 0.3)
border.width: 1
shadow.xOffset: 0
shadow.yOffset: 4
shadow.color: Qt.rgba(0, 0, 0, 0.3)
shadow.size: 8
}
}
}
QQC2.ToolButton {
id: maximizeButton
display: QQC2.AbstractButton.IconOnly
action: Kirigami.Action {
text: i18n("Maximize")
icon.name: "view-fullscreen"
onTriggered: {
root.timeline.interactive = false
root.pause()
// We need to make sure the index is that of the MediaMessageFilterModel.
if (root.timeline.model instanceof MessageFilterModel) {
RoomManager.maximizeMedia(RoomManager.mediaMessageFilterModel.getRowForSourceItem(root.index))
} else {
RoomManager.maximizeMedia(root.index)
}
}
}
}
}
background: Kirigami.ShadowedRectangle {
radius: 4
color: Kirigami.Theme.backgroundColor
opacity: 0.8
property color borderColor: Kirigami.Theme.textColor
border.color: Qt.rgba(borderColor.r, borderColor.g, borderColor.b, 0.3)
border.width: 1
shadow.xOffset: 0
shadow.yOffset: 4
shadow.color: Qt.rgba(0, 0, 0, 0.3)
shadow.size: 8
}
}
Timer {
id: videoControlTimer
interval: 1000
}
HoverHandler {
id: videoHoverHandler
onHoveredChanged: {
if (!hovered && (root.state === "paused" || root.state === "playing")) {
videoControlTimer.restart()
}
}
}
TapHandler {
acceptedButtons: Qt.LeftButton
gesturePolicy: TapHandler.ReleaseWithinBounds | TapHandler.WithinBounds
onTapped: if (root.fileTransferInfo.completed) {
if (root.playbackState == MediaPlayer.PlayingState) {
root.pause()
} else {
root.play()
}
} else {
root.downloadAndPlay()
}
}
MediaSizeHelper {
id: mediaSizeHelper
contentMaxWidth: root.maxContentWidth
mediaWidth: root.mediaInfo.width
mediaHeight: root.mediaInfo.height
}
function downloadAndPlay() {
if (root.downloaded) {
playSavedFile()
} else {
playOnFinished = true
root.room.downloadFile(root.eventId, Platform.StandardPaths.writableLocation(Platform.StandardPaths.CacheLocation) + "/" + root.eventId.replace(":", "_").replace("/", "_").replace("+", "_") + root.room.fileNameToDownload(root.eventId))
}
}
function playSavedFile() {
root.stop()
root.play()
}
}

View File

@@ -1,368 +0,0 @@
// SPDX-FileCopyrightText: 2019 Black Hat <bhat@encom.eu.org>
// SPDX-License-Identifier: GPL-3.0-only
import QtQuick
import QtQuick.Controls as QQC2
import QtQuick.Layouts
import QtMultimedia
import Qt.labs.platform as Platform
import org.kde.coreaddons
import org.kde.kirigami as Kirigami
import org.kde.neochat
/**
* @brief A timeline delegate for a video message.
*
* @inherit MessageDelegate
*/
MessageDelegate {
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
/**
* @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: RoomManager.viewEventMenu(eventId, author, delegateType, plainText, "", "", mediaInfo.mimeType, progressInfo)
onDownloadedChanged: {
if (downloaded) {
vid.source = root.progressInfo.localPath;
}
if (downloaded && playOnFinished) {
playSavedFile();
playOnFinished = false;
}
}
bubbleContent: Video {
id: vid
implicitWidth: mediaSizeHelper.currentSize.width
implicitHeight: mediaSizeHelper.currentSize.height
fillMode: VideoOutput.PreserveAspectFit
states: [
State {
name: "notDownloaded"
when: !root.progressInfo.completed && !root.progressInfo.active
PropertyChanges {
target: noDownloadLabel
visible: true
}
PropertyChanges {
target: mediaThumbnail
visible: true
}
},
State {
name: "downloading"
when: root.progressInfo.active && !root.progressInfo.completed
PropertyChanges {
target: downloadBar
visible: true
}
},
State {
name: "paused"
when: root.progressInfo.completed && (vid.playbackState === MediaPlayer.StoppedState || vid.playbackState === MediaPlayer.PausedState)
PropertyChanges {
target: videoControls
stateVisible: true
}
PropertyChanges {
target: playButton
icon.name: "media-playback-start"
onClicked: vid.play()
}
},
State {
name: "playing"
when: root.progressInfo.completed && vid.playbackState === MediaPlayer.PlayingState
PropertyChanges {
target: videoControls
stateVisible: true
}
PropertyChanges {
target: playButton
icon.name: "media-playback-pause"
onClicked: vid.pause()
}
}
]
Image {
id: mediaThumbnail
anchors.fill: parent
visible: false
source: root.mediaInfo.tempInfo.source
fillMode: Image.PreserveAspectFit
}
QQC2.Label {
id: noDownloadLabel
anchors.centerIn: parent
visible: false
color: "white"
text: i18n("Video")
font.pixelSize: 16
padding: 8
background: Rectangle {
radius: Kirigami.Units.smallSpacing
color: "black"
opacity: 0.3
}
}
Rectangle {
id: downloadBar
anchors.fill: parent
visible: false
color: Kirigami.Theme.backgroundColor
radius: Kirigami.Units.smallSpacing
QQC2.ProgressBar {
anchors.centerIn: parent
width: parent.width * 0.8
from: 0
to: root.progressInfo.total
value: root.progressInfo.progress
}
}
QQC2.Control {
id: videoControls
property bool stateVisible: false
anchors.bottom: vid.bottom
anchors.left: vid.left
anchors.right: vid.right
visible: stateVisible && (videoHoverHandler.hovered || volumePopupHoverHandler.hovered || volumeSlider.hovered || videoControlTimer.running)
contentItem: RowLayout {
id: controlRow
QQC2.ToolButton {
id: playButton
}
QQC2.Slider {
Layout.fillWidth: true
from: 0
to: vid.duration
value: vid.position
onMoved: vid.seek(value)
}
QQC2.Label {
text: Format.formatDuration(vid.position) + "/" + Format.formatDuration(vid.duration)
}
QQC2.ToolButton {
id: volumeButton
property var unmuteVolume: vid.volume
icon.name: vid.volume <= 0 ? "player-volume-muted" : "player-volume"
QQC2.ToolTip.visible: hovered
QQC2.ToolTip.delay: Kirigami.Units.toolTipDelay
QQC2.ToolTip.timeout: Kirigami.Units.toolTipDelay
QQC2.ToolTip.text: i18nc("@action:button", "Volume")
onClicked: {
if (vid.volume > 0) {
vid.volume = 0;
} else {
if (unmuteVolume === 0) {
vid.volume = 1;
} else {
vid.volume = unmuteVolume;
}
}
}
onHoveredChanged: {
if (!hovered && (vid.state === "paused" || vid.state === "playing")) {
videoControlTimer.restart();
volumePopupTimer.restart();
}
}
QQC2.Popup {
id: volumePopup
y: -height
width: volumeButton.width
visible: videoControls.stateVisible && (volumeButton.hovered || volumePopupHoverHandler.hovered || volumeSlider.hovered || volumePopupTimer.running)
focus: true
padding: Kirigami.Units.smallSpacing
closePolicy: QQC2.Popup.NoAutoClose
QQC2.Slider {
id: volumeSlider
anchors.centerIn: parent
implicitHeight: Kirigami.Units.gridUnit * 7
orientation: Qt.Vertical
padding: 0
from: 0
to: 1
value: vid.volume
onMoved: {
vid.volume = value;
volumeButton.unmuteVolume = value;
}
onHoveredChanged: {
if (!hovered && (vid.state === "paused" || vid.state === "playing")) {
videoControlTimer.restart();
volumePopupTimer.restart();
}
}
}
Timer {
id: volumePopupTimer
interval: 500
}
HoverHandler {
id: volumePopupHoverHandler
onHoveredChanged: {
if (!hovered && (vid.state === "paused" || vid.state === "playing")) {
videoControlTimer.restart();
volumePopupTimer.restart();
}
}
}
background: Kirigami.ShadowedRectangle {
radius: 4
color: Kirigami.Theme.backgroundColor
opacity: 0.8
property color borderColor: Kirigami.Theme.textColor
border.color: Qt.rgba(borderColor.r, borderColor.g, borderColor.b, 0.3)
border.width: 1
shadow.xOffset: 0
shadow.yOffset: 4
shadow.color: Qt.rgba(0, 0, 0, 0.3)
shadow.size: 8
}
}
}
QQC2.ToolButton {
id: maximizeButton
display: QQC2.AbstractButton.IconOnly
action: Kirigami.Action {
text: i18n("Maximize")
icon.name: "view-fullscreen"
onTriggered: {
root.ListView.view.interactive = false;
vid.pause();
// We need to make sure the index is that of the MediaMessageFilterModel.
if (root.ListView.view.model instanceof MessageFilterModel) {
RoomManager.maximizeMedia(RoomManager.mediaMessageFilterModel.getRowForSourceItem(root.index));
} else {
RoomManager.maximizeMedia(root.index);
}
}
}
}
}
background: Kirigami.ShadowedRectangle {
radius: 4
color: Kirigami.Theme.backgroundColor
opacity: 0.8
property color borderColor: Kirigami.Theme.textColor
border.color: Qt.rgba(borderColor.r, borderColor.g, borderColor.b, 0.3)
border.width: 1
shadow.xOffset: 0
shadow.yOffset: 4
shadow.color: Qt.rgba(0, 0, 0, 0.3)
shadow.size: 8
}
}
Timer {
id: videoControlTimer
interval: 1000
}
HoverHandler {
id: videoHoverHandler
onHoveredChanged: {
if (!hovered && (vid.state === "paused" || vid.state === "playing")) {
videoControlTimer.restart();
}
}
}
TapHandler {
acceptedButtons: Qt.LeftButton
gesturePolicy: TapHandler.ReleaseWithinBounds | TapHandler.WithinBounds
onTapped: if (root.progressInfo.completed) {
if (vid.playbackState == MediaPlayer.PlayingState) {
vid.pause();
} else {
vid.play();
}
} else {
root.downloadAndPlay();
}
}
MediaSizeHelper {
id: mediaSizeHelper
contentMaxWidth: root.contentMaxWidth
mediaWidth: root.mediaInfo.width
mediaHeight: root.mediaInfo.height
}
}
function downloadAndPlay() {
if (vid.downloaded) {
playSavedFile();
} else {
playOnFinished = true;
root.room.downloadFile(root.eventId, Platform.StandardPaths.writableLocation(Platform.StandardPaths.CacheLocation) + "/" + root.eventId.replace(":", "_").replace("/", "_").replace("+", "_") + root.room.fileNameToDownload(root.eventId));
}
}
function playSavedFile() {
vid.stop();
vid.play();
}
}