Timeline required properties

Move to using required properties for timeline delegates.
This commit is contained in:
James Graham
2023-05-27 14:57:34 +00:00
committed by Tobias Fella
parent a94f46f904
commit 8ad23e7a40
12 changed files with 766 additions and 227 deletions

View File

@@ -14,16 +14,34 @@ import org.kde.neochat 1.0
Components.AlbumMaximizeComponent { Components.AlbumMaximizeComponent {
id: root id: root
property var modelData required property string eventId
required property var time
required property var author
required property int delegateType
required property string plainText
required property string caption
required property var mediaInfo
required property var progressInfo
required property var mimeType
required property var source
property list<Components.AlbumModelItem> items: [ property list<Components.AlbumModelItem> items: [
Components.AlbumModelItem { Components.AlbumModelItem {
type: root.modelData.delegateType === MessageEventModel.Image || root.modelData.delegateType === MessageEventModel.Sticker ? Components.AlbumModelItem.Image : Components.AlbumModelItem.Video type: root.delegateType === MessageEventModel.Image || root.delegateType === MessageEventModel.Sticker ? Components.AlbumModelItem.Image : Components.AlbumModelItem.Video
source: root.modelData.delegateType === MessageEventModel.Video ? modelData.progressInfo.localPath : modelData.mediaInfo.source source: root.delegateType === MessageEventModel.Video ? root.progressInfo.localPath : root.mediaInfo.source
tempSource: modelData.mediaInfo.tempInfo.source tempSource: root.mediaInfo.tempInfo.source
caption: modelData.display caption: root.caption
sourceWidth: modelData.mediaInfo.width sourceWidth: root.mediaInfo.width
sourceHeight: modelData.mediaInfo.height sourceHeight: root.mediaInfo.height
} }
] ]
@@ -36,22 +54,22 @@ Components.AlbumMaximizeComponent {
implicitWidth: Kirigami.Units.iconSizes.medium implicitWidth: Kirigami.Units.iconSizes.medium
implicitHeight: Kirigami.Units.iconSizes.medium implicitHeight: Kirigami.Units.iconSizes.medium
name: modelData.author.name ?? modelData.author.displayName name: root.author.name ?? root.author.displayName
source: modelData.author.avatarSource source: root.author.avatarSource
color: modelData.author.color color: root.author.color
} }
ColumnLayout { ColumnLayout {
spacing: 0 spacing: 0
QQC2.Label { QQC2.Label {
id: userLabel id: userLabel
text: modelData.author.name ?? modelData.author.displayName text: root.author.name ?? root.author.displayName
color: modelData.author.color color: root.author.color
font.weight: Font.Bold font.weight: Font.Bold
elide: Text.ElideRight elide: Text.ElideRight
} }
QQC2.Label { QQC2.Label {
id: dateTimeLabel id: dateTimeLabel
text: modelData.time.toLocaleString(Qt.locale(), Locale.ShortFormat) text: root.time.toLocaleString(Qt.locale(), Locale.ShortFormat)
color: Kirigami.Theme.disabledTextColor color: Kirigami.Theme.disabledTextColor
elide: Text.ElideRight elide: Text.ElideRight
} }
@@ -59,14 +77,14 @@ Components.AlbumMaximizeComponent {
} }
onItemRightClicked: { onItemRightClicked: {
const contextMenu = fileDelegateContextMenu.createObject(parent, { const contextMenu = fileDelegateContextMenu.createObject(parent, {
author: modelData.author, author: root.author,
message: modelData.plainText, message: root.plainText,
eventId: modelData.eventId, eventId: root.eventId,
source: modelData.source, source: root.source,
file: parent, file: parent,
mimeType: modelData.mimeType, mimeType: root.mimeType,
progressInfo: modelData.progressInfo, progressInfo: root.progressInfo,
plainMessage: modelData.plainText, plainMessage: root.plainText,
}); });
contextMenu.closeFullscreen.connect(root.close) contextMenu.closeFullscreen.connect(root.close)
contextMenu.open(); contextMenu.open();
@@ -74,7 +92,7 @@ Components.AlbumMaximizeComponent {
onSaveItem: { onSaveItem: {
var dialog = saveAsDialog.createObject(QQC2.ApplicationWindow.overlay) var dialog = saveAsDialog.createObject(QQC2.ApplicationWindow.overlay)
dialog.open() dialog.open()
dialog.currentFile = dialog.folder + "/" + currentRoom.fileNameToDownload(modelData.eventId) dialog.currentFile = dialog.folder + "/" + currentRoom.fileNameToDownload(root.eventId)
} }
Component { Component {
@@ -88,7 +106,7 @@ Components.AlbumMaximizeComponent {
if (!currentFile) { if (!currentFile) {
return; return;
} }
currentRoom.downloadFile(eventId, currentFile) currentRoom.downloadFile(rooteventId, currentFile)
} }
} }
} }

View File

@@ -10,38 +10,58 @@ import org.kde.kirigami 2.15 as Kirigami
import org.kde.neochat 1.0 import org.kde.neochat 1.0
/**
* @brief A timeline delegate for an audio message.
*
* @inherit TimelineContainer
*/
TimelineContainer { TimelineContainer {
id: audioDelegate id: root
onOpenContextMenu: openFileContext(model, audioDelegate) /**
* @brief The media info for the event.
*
* This should consist of the following:
* - source - The mxc URL for the media.
* - mimeType - The MIME type of the media (should be audio/xxx for this delegate).
* - mimeIcon - The MIME icon name (should be audio-xxx).
* - size - The file size in bytes.
* - duration - The length in seconds of the audio media.
*/
required property var mediaInfo
readonly property bool downloaded: model.progressInfo && model.progressInfo.completed /**
* @brief Whether the media has been downloaded.
*/
readonly property bool downloaded: root.progressInfo && root.progressInfo.completed
onDownloadedChanged: audio.play() onDownloadedChanged: audio.play()
onOpenContextMenu: openFileContext(root)
innerObject: ColumnLayout { innerObject: ColumnLayout {
Layout.fillWidth: true Layout.fillWidth: true
Layout.maximumWidth: audioDelegate.contentMaxWidth Layout.maximumWidth: root.contentMaxWidth
Audio { Audio {
id: audio id: audio
source: model.progressInfo.localPath source: root.progressInfo.localPath
autoLoad: false autoLoad: false
} }
states: [ states: [
State { State {
name: "notDownloaded" name: "notDownloaded"
when: !model.progressInfo.completed && !model.progressInfo.active when: !root.progressInfo.completed && !root.progressInfo.active
PropertyChanges { PropertyChanges {
target: playButton target: playButton
icon.name: "media-playback-start" icon.name: "media-playback-start"
onClicked: currentRoom.downloadFile(model.eventId) onClicked: currentRoom.downloadFile(root.eventId)
} }
}, },
State { State {
name: "downloading" name: "downloading"
when: model.progressInfo.active && !model.progressInfo.completed when: root.progressInfo.active && !root.progressInfo.completed
PropertyChanges { PropertyChanges {
target: downloadBar target: downloadBar
visible: true visible: true
@@ -50,13 +70,13 @@ TimelineContainer {
target: playButton target: playButton
icon.name: "media-playback-stop" icon.name: "media-playback-stop"
onClicked: { onClicked: {
currentRoom.cancelFileTransfer(model.eventId) currentRoom.cancelFileTransfer(root.eventId)
} }
} }
}, },
State { State {
name: "paused" name: "paused"
when: model.progressInfo.completed && (audio.playbackState === Audio.StoppedState || audio.playbackState === Audio.PausedState) when: root.progressInfo.completed && (audio.playbackState === Audio.StoppedState || audio.playbackState === Audio.PausedState)
PropertyChanges { PropertyChanges {
target: playButton target: playButton
icon.name: "media-playback-start" icon.name: "media-playback-start"
@@ -67,7 +87,7 @@ TimelineContainer {
}, },
State { State {
name: "playing" name: "playing"
when: model.progressInfo.completed && audio.playbackState === Audio.PlayingState when: root.progressInfo.completed && audio.playbackState === Audio.PlayingState
PropertyChanges { PropertyChanges {
target: playButton target: playButton
@@ -84,7 +104,7 @@ TimelineContainer {
id: playButton id: playButton
} }
QQC2.Label { QQC2.Label {
text: model.display text: root.display
wrapMode: Text.Wrap wrapMode: Text.Wrap
Layout.fillWidth: true Layout.fillWidth: true
} }
@@ -94,8 +114,8 @@ TimelineContainer {
visible: false visible: false
Layout.fillWidth: true Layout.fillWidth: true
from: 0 from: 0
to: model.mediaInfo.size to: root.mediaInfo.size
value: model.progressInfo.progress value: root.progressInfo.progress
} }
RowLayout { RowLayout {
visible: audio.hasAudio visible: audio.hasAudio
@@ -109,7 +129,7 @@ TimelineContainer {
} }
QQC2.Label { QQC2.Label {
visible: audioDelegate.contentMaxWidth > Kirigami.Units.gridUnit * 12 visible: root.contentMaxWidth > Kirigami.Units.gridUnit * 12
text: Controller.formatDuration(audio.position) + "/" + Controller.formatDuration(audio.duration) text: Controller.formatDuration(audio.position) + "/" + Controller.formatDuration(audio.duration)
} }
@@ -117,7 +137,7 @@ TimelineContainer {
QQC2.Label { QQC2.Label {
Layout.alignment: Qt.AlignRight Layout.alignment: Qt.AlignRight
Layout.rightMargin: Kirigami.Units.smallSpacing Layout.rightMargin: Kirigami.Units.smallSpacing
visible: audio.hasAudio && audioDelegate.contentMaxWidth < Kirigami.Units.gridUnit * 12 visible: audio.hasAudio && root.contentMaxWidth < Kirigami.Units.gridUnit * 12
text: Controller.formatDuration(audio.position) + "/" + Controller.formatDuration(audio.duration) text: Controller.formatDuration(audio.position) + "/" + Controller.formatDuration(audio.duration)
} }

View File

@@ -8,6 +8,11 @@ import QtQuick.Layouts 1.15
import org.kde.kirigami 2.15 as Kirigami import org.kde.kirigami 2.15 as Kirigami
import org.kde.neochat 1.0 import org.kde.neochat 1.0
/**
* @brief A timeline delegate for an encrypted message that can't be decrypted.
*
* @inherit TimelineContainer
*/
TimelineContainer { TimelineContainer {
id: encryptedDelegate id: encryptedDelegate

View File

@@ -10,37 +10,60 @@ import org.kde.kirigami 2.15 as Kirigami
import org.kde.neochat 1.0 import org.kde.neochat 1.0
/**
* @brief A timeline delegate for an file message.
*
* @inherit TimelineContainer
*/
TimelineContainer { TimelineContainer {
id: fileDelegate id: root
onOpenContextMenu: openFileContext(model, fileDelegate) /**
* @brief The media info for the event.
*
* This should consist of the following:
* - source - The mxc URL for the media.
* - mimeType - The MIME type of the media.
* - mimeIcon - The MIME icon name.
* - size - The file size in bytes.
*/
required property var mediaInfo
readonly property bool downloaded: progressInfo && progressInfo.completed /**
* @brief Whether the media has been downloaded.
*/
readonly property bool downloaded: root.progressInfo && root.progressInfo.completed
/**
* @brief Whether the file should be automatically opened when downloaded.
*/
property bool autoOpenFile: false property bool autoOpenFile: false
onDownloadedChanged: if (autoOpenFile) { onDownloadedChanged: if (autoOpenFile) {
openSavedFile(); openSavedFile();
} }
onOpenContextMenu: openFileContext(root)
function saveFileAs() { function saveFileAs() {
const dialog = fileDialog.createObject(QQC2.ApplicationWindow.overlay) const dialog = fileDialog.createObject(QQC2.ApplicationWindow.overlay)
dialog.open() dialog.open()
dialog.currentFile = dialog.folder + "/" + currentRoom.fileNameToDownload(eventId) dialog.currentFile = dialog.folder + "/" + currentRoom.fileNameToDownload(root.eventId)
} }
function openSavedFile() { function openSavedFile() {
UrlHelper.openUrl(progressInfo.localPath); UrlHelper.openUrl(root.progressInfo.localPath);
} }
innerObject: RowLayout { innerObject: RowLayout {
Layout.maximumWidth: Math.min(fileDelegate.contentMaxWidth, implicitWidth) Layout.maximumWidth: Math.min(root.contentMaxWidth, implicitWidth)
spacing: Kirigami.Units.largeSpacing spacing: Kirigami.Units.largeSpacing
states: [ states: [
State { State {
name: "downloadedInstant" name: "downloadedInstant"
when: progressInfo.completed && autoOpenFile when: root.progressInfo.completed && autoOpenFile
PropertyChanges { PropertyChanges {
target: openButton target: openButton
@@ -57,7 +80,7 @@ TimelineContainer {
}, },
State { State {
name: "downloaded" name: "downloaded"
when: progressInfo.completed && !autoOpenFile when: root.progressInfo.completed && !autoOpenFile
PropertyChanges { PropertyChanges {
target: openButton target: openButton
@@ -73,7 +96,7 @@ TimelineContainer {
}, },
State { State {
name: "downloading" name: "downloading"
when: progressInfo.active when: root.progressInfo.active
PropertyChanges { PropertyChanges {
target: openButton target: openButton
@@ -82,13 +105,13 @@ TimelineContainer {
PropertyChanges { PropertyChanges {
target: sizeLabel target: sizeLabel
text: i18nc("file download progress", "%1 / %2", Controller.formatByteSize(progressInfo.progress), Controller.formatByteSize(progressInfo.total)) text: i18nc("file download progress", "%1 / %2", Controller.formatByteSize(root.progressInfo.progress), Controller.formatByteSize(root.progressInfo.total))
} }
PropertyChanges { PropertyChanges {
target: downloadButton target: downloadButton
icon.name: "media-playback-stop" icon.name: "media-playback-stop"
QQC2.ToolTip.text: i18nc("tooltip for a button on a message; stops downloading the message's file", "Stop Download") QQC2.ToolTip.text: i18nc("tooltip for a button on a message; stops downloading the message's file", "Stop Download")
onClicked: currentRoom.cancelFileTransfer(eventId) onClicked: currentRoom.cancelFileTransfer(root.eventId)
} }
}, },
State { State {
@@ -97,13 +120,13 @@ TimelineContainer {
PropertyChanges { PropertyChanges {
target: downloadButton target: downloadButton
onClicked: fileDelegate.saveFileAs() onClicked: root.saveFileAs()
} }
} }
] ]
Kirigami.Icon { Kirigami.Icon {
source: model.mediaInfo.mimeIcon source: root.mediaInfo.mimeIcon
fallback: "unknown" fallback: "unknown"
} }
@@ -111,14 +134,14 @@ TimelineContainer {
spacing: 0 spacing: 0
QQC2.Label { QQC2.Label {
Layout.fillWidth: true Layout.fillWidth: true
text: model.display text: root.display
wrapMode: Text.Wrap wrapMode: Text.Wrap
elide: Text.ElideRight elide: Text.ElideRight
} }
QQC2.Label { QQC2.Label {
id: sizeLabel id: sizeLabel
Layout.fillWidth: true Layout.fillWidth: true
text: Controller.formatByteSize(model.mediaInfo.size) text: Controller.formatByteSize(root.mediaInfo.size)
opacity: 0.7 opacity: 0.7
elide: Text.ElideRight elide: Text.ElideRight
maximumLineCount: 1 maximumLineCount: 1
@@ -130,7 +153,7 @@ TimelineContainer {
icon.name: "document-open" icon.name: "document-open"
onClicked: { onClicked: {
autoOpenFile = true; autoOpenFile = true;
currentRoom.downloadTempFile(eventId); currentRoom.downloadTempFile(root.eventId);
} }
QQC2.ToolTip.text: i18nc("tooltip for a button on a message; offers ability to open its downloaded file with an appropriate application", "Open File") QQC2.ToolTip.text: i18nc("tooltip for a button on a message; offers ability to open its downloaded file with an appropriate application", "Open File")
@@ -157,9 +180,9 @@ TimelineContainer {
Config.lastSaveDirectory = folder Config.lastSaveDirectory = folder
Config.save() Config.save()
if (autoOpenFile) { if (autoOpenFile) {
UrlHelper.copyTo(progressInfo.localPath, file) UrlHelper.copyTo(root.progressInfo.localPath, file)
} else { } else {
currentRoom.download(eventId, file); currentRoom.download(root.eventId, file);
} }
} }
} }

View File

@@ -11,37 +11,70 @@ import org.kde.kirigamiaddons.labs.components 1.0 as Components
import org.kde.neochat 1.0 import org.kde.neochat 1.0
/**
* @brief A timeline delegate for an image message.
*
* @inherit TimelineContainer
*/
TimelineContainer { TimelineContainer {
id: imageDelegate id: root
onOpenContextMenu: openFileContext(model, imageDelegate) /**
* @brief The media info for the event.
*
* This should consist of the following:
* - source - The mxc URL for the media.
* - mimeType - The MIME type of the media (should be image/xxx for this delegate).
* - mimeIcon - The MIME icon name (should be image-xxx).
* - size - The file size in bytes.
* - width - The width in pixels of the audio media.
* - height - The height in pixels of the audio media.
* - tempInfo - mediaInfo (with the same properties as this except no tempInfo) for a temporary image while the file downloads.
*/
required property var mediaInfo
/**
* @brief Whether the media has been downloaded.
*/
readonly property bool downloaded: root.progressInfo && root.progressInfo.completed
/**
* @brief Whether the image should be automatically opened when downloaded.
*/
property bool openOnFinished: false property bool openOnFinished: false
readonly property bool downloaded: progressInfo && progressInfo.completed
/**
* @brief The maximum width of the image.
*/
readonly property var maxWidth: Kirigami.Units.gridUnit * 30 readonly property var maxWidth: Kirigami.Units.gridUnit * 30
/**
* @brief The maximum height of the image.
*/
readonly property var maxHeight: Kirigami.Units.gridUnit * 30 readonly property var maxHeight: Kirigami.Units.gridUnit * 30
onOpenContextMenu: openFileContext(root)
innerObject: AnimatedImage { innerObject: AnimatedImage {
id: img id: img
property var imageWidth: { property var imageWidth: {
if (model.mediaInfo.width > 0) { if (root.mediaInfo.width > 0) {
return model.mediaInfo.width; return root.mediaInfo.width;
} else if (sourceSize.width && sourceSize.width > 0) { } else if (sourceSize.width && sourceSize.width > 0) {
return sourceSize.width; return sourceSize.width;
} else { } else {
return imageDelegate.contentMaxWidth; return root.contentMaxWidth;
} }
} }
property var imageHeight: { property var imageHeight: {
if (model.mediaInfo.height > 0) { if (root.mediaInfo.height > 0) {
return model.mediaInfo.height; return root.mediaInfo.height;
} else if (sourceSize.height && sourceSize.height > 0) { } else if (sourceSize.height && sourceSize.height > 0) {
return sourceSize.height; return sourceSize.height;
} else { } else {
// Default to a 16:9 placeholder // Default to a 16:9 placeholder
return imageDelegate.contentMaxWidth / 16 * 9; return root.contentMaxWidth / 16 * 9;
} }
} }
@@ -56,11 +89,11 @@ TimelineContainer {
readonly property size maxSize: { readonly property size maxSize: {
if (limitWidth) { if (limitWidth) {
let width = Math.min(imageDelegate.contentMaxWidth, imageDelegate.maxWidth); let width = Math.min(root.contentMaxWidth, root.maxWidth);
let height = width / aspectRatio; let height = width / aspectRatio;
return Qt.size(width, height); return Qt.size(width, height);
} else { } else {
let height = Math.min(imageDelegate.maxHeight, imageDelegate.contentMaxWidth / aspectRatio); let height = Math.min(root.maxHeight, root.contentMaxWidth / aspectRatio);
let width = height * aspectRatio; let width = height * aspectRatio;
return Qt.size(width, height); return Qt.size(width, height);
} }
@@ -70,17 +103,17 @@ TimelineContainer {
Layout.maximumHeight: maxSize.height Layout.maximumHeight: maxSize.height
Layout.preferredWidth: imageWidth Layout.preferredWidth: imageWidth
Layout.preferredHeight: imageHeight Layout.preferredHeight: imageHeight
source: model.mediaInfo.source source: root.mediaInfo.source
Image { Image {
anchors.fill: parent anchors.fill: parent
source: model.mediaInfo.tempInfo.source source: root.mediaInfo.tempInfo.source
visible: parent.status !== Image.Ready visible: parent.status !== Image.Ready
} }
fillMode: Image.PreserveAspectFit fillMode: Image.PreserveAspectFit
QQC2.ToolTip.text: model.display QQC2.ToolTip.text: root.display
QQC2.ToolTip.visible: hoverHandler.hovered QQC2.ToolTip.visible: hoverHandler.hovered
QQC2.ToolTip.delay: Kirigami.Units.toolTipDelay QQC2.ToolTip.delay: Kirigami.Units.toolTipDelay
@@ -93,7 +126,7 @@ TimelineContainer {
Rectangle { Rectangle {
anchors.fill: parent anchors.fill: parent
visible: progressInfo.active && !downloaded visible: root.progressInfo.active && !downloaded
color: "#BB000000" color: "#BB000000"
@@ -103,8 +136,8 @@ TimelineContainer {
width: parent.width * 0.8 width: parent.width * 0.8
from: 0 from: 0
to: progressInfo.total to: root.progressInfo.total
value: progressInfo.progress value: root.progressInfo.progress
} }
} }
@@ -113,12 +146,21 @@ TimelineContainer {
onTapped: { onTapped: {
img.QQC2.ToolTip.hide() img.QQC2.ToolTip.hide()
img.paused = true img.paused = true
imageDelegate.ListView.view.interactive = false root.ListView.view.interactive = false
var popup = maximizeImageComponent.createObject(QQC2.ApplicationWindow.overlay, { var popup = maximizeImageComponent.createObject(QQC2.ApplicationWindow.overlay, {
modelData: model, eventId: root.eventId,
time: root.time,
author: root.author,
delegateType: root.delegateType,
plainText: root.plainText,
caption: root.display,
mediaInfo: root.mediaInfo,
progressInfo: root.progressInfo,
mimeType: root.mimeType,
source: root.source
}) })
popup.closed.connect(() => { popup.closed.connect(() => {
imageDelegate.ListView.view.interactive = true root.ListView.view.interactive = true
img.paused = false img.paused = false
popup.destroy() popup.destroy()
}) })
@@ -136,13 +178,13 @@ TimelineContainer {
openSavedFile() openSavedFile()
} else { } else {
openOnFinished = true openOnFinished = true
currentRoom.downloadFile(eventId, StandardPaths.writableLocation(StandardPaths.CacheLocation) + "/" + eventId.replace(":", "_").replace("/", "_").replace("+", "_") + currentRoom.fileNameToDownload(eventId)) currentRoom.downloadFile(root.eventId, StandardPaths.writableLocation(StandardPaths.CacheLocation) + "/" + root.eventId.replace(":", "_").replace("/", "_").replace("+", "_") + currentRoom.fileNameToDownload(root.eventId))
} }
} }
function openSavedFile() { function openSavedFile() {
if (UrlHelper.openUrl(progressInfo.localPath)) return; if (UrlHelper.openUrl(root.progressInfo.localPath)) return;
if (UrlHelper.openUrl(progressInfo.localDir)) return; if (UrlHelper.openUrl(root.progressInfo.localDir)) return;
} }
} }
} }

View File

@@ -11,18 +11,41 @@ import org.kde.kirigami 2.15 as Kirigami
import org.kde.neochat 1.0 import org.kde.neochat 1.0
/**
* @brief A timeline delegate for a location message.
*
* @inherit TimelineContainer
*/
TimelineContainer { TimelineContainer {
id: locationDelegate id: root
/**
* @brief The latitude of the location marker in the message.
*/
required property real latitude
/**
* @brief The longitude of the location marker in the message.
*/
required property real longitude
/**
* @brief What type of marker the location message is.
*
* The main options are m.pin for a general location or m.self for a pin to show
* a user's location.
*/
required property string asset
ColumnLayout { ColumnLayout {
Layout.maximumWidth: locationDelegate.contentMaxWidth Layout.maximumWidth: root.contentMaxWidth
Layout.preferredWidth: locationDelegate.contentMaxWidth Layout.preferredWidth: root.contentMaxWidth
Map { Map {
id: map id: map
Layout.fillWidth: true Layout.fillWidth: true
Layout.preferredHeight: locationDelegate.contentMaxWidth / 16 * 9 Layout.preferredHeight: root.contentMaxWidth / 16 * 9
center: QtPositioning.coordinate(model.latitude, model.longitude) center: QtPositioning.coordinate(root.latitude, root.longitude)
zoomLevel: 15 zoomLevel: 15
plugin: Plugin { plugin: Plugin {
name: "osm" name: "osm"
@@ -44,7 +67,7 @@ TimelineContainer {
anchorPoint.x: sourceItem.width / 2 anchorPoint.x: sourceItem.width / 2
anchorPoint.y: sourceItem.height anchorPoint.y: sourceItem.height
coordinate: QtPositioning.coordinate(model.latitude, model.longitude) coordinate: QtPositioning.coordinate(rot.latitude, root.longitude)
autoFadeIn: false autoFadeIn: false
sourceItem: Kirigami.Icon { sourceItem: Kirigami.Icon {
@@ -67,23 +90,23 @@ TimelineContainer {
Kirigami.Avatar { Kirigami.Avatar {
anchors.centerIn: parent anchors.centerIn: parent
anchors.verticalCenterOffset: -parent.height / 8 anchors.verticalCenterOffset: -parent.height / 8
visible: model.asset === "m.self" visible: root.asset === "m.self"
width: height width: height
height: parent.height / 3 + 1 height: parent.height / 3 + 1
name: model.author.name ?? model.author.displayName name: root.author.name ?? root.author.displayName
source: model.author.avatarSource source: root.author.avatarSource
color: model.author.color color: root.author.color
} }
} }
} }
TapHandler { TapHandler {
acceptedButtons: Qt.LeftButton acceptedButtons: Qt.LeftButton
onLongPressed: openMessageContext(model, "", model.plainText) onLongPressed: openMessageContext("")
} }
TapHandler { TapHandler {
acceptedButtons: Qt.RightButton acceptedButtons: Qt.RightButton
onTapped: openMessageContext(model, "", model.plainText) onTapped: openMessageContext("")
} }
} }
} }

View File

@@ -10,34 +10,63 @@ import org.kde.kirigami 2.15 as Kirigami
import org.kde.neochat 1.0 import org.kde.neochat 1.0
/**
* @brief A timeline delegate for an text message.
*
* @inherit TimelineContainer
*/
TimelineContainer { TimelineContainer {
id: messageDelegate id: root
onOpenContextMenu: openMessageContext(model, label.selectedText, model.plainText) /**
* @brief The link preview properties.
*
* This is a list or object containing the following:
* - url - The URL being previewed.
* - loaded - Whether the URL preview has been loaded.
* - title - the title of the URL preview.
* - description - the description of the URL preview.
* - imageSource - a source URL for the preview image.
*
* @note An empty link previewer should be passed if there are no links to
* preview.
*/
required property var linkPreview
/**
* @brief Whether there are any links to preview.
*/
required property bool showLinkPreview
onOpenContextMenu: openMessageContext(label.selectedText)
innerObject: ColumnLayout { innerObject: ColumnLayout {
Layout.maximumWidth: messageDelegate.contentMaxWidth Layout.maximumWidth: root.contentMaxWidth
RichLabel { RichLabel {
id: label id: label
Layout.fillWidth: true Layout.fillWidth: true
visible: currentRoom.chatBoxEditId !== model.eventId visible: currentRoom.chatBoxEditId !== root.eventId
isReply: root.isReply
textMessage: root.display
} }
Loader { Loader {
Layout.fillWidth: true Layout.fillWidth: true
Layout.minimumHeight: item ? item.minimumHeight : -1 Layout.minimumHeight: item ? item.minimumHeight : -1
Layout.preferredWidth: item ? item.preferredWidth : -1 Layout.preferredWidth: item ? item.preferredWidth : -1
visible: currentRoom.chatBoxEditId === model.eventId visible: currentRoom.chatBoxEditId === root.eventId
active: visible active: visible
sourceComponent: MessageEditComponent { sourceComponent: MessageEditComponent {
room: currentRoom room: currentRoom
messageId: model.eventId messageId: root.eventId
} }
} }
LinkPreviewDelegate { LinkPreviewDelegate {
Layout.fillWidth: true Layout.fillWidth: true
active: !currentRoom.usesEncryption && currentRoom.urlPreviewEnabled && Config.showLinkPreview && model.showLinkPreview active: !currentRoom.usesEncryption && currentRoom.urlPreviewEnabled && Config.showLinkPreview && root.showLinkPreview
linkPreviewer: model.linkPreview linkPreviewer: root.linkPreview
indicatorEnabled: messageDelegate.isVisibleInTimeline() indicatorEnabled: root.isVisibleInTimeline()
} }
} }
} }

View File

@@ -10,25 +10,40 @@ import org.kde.kirigami 2.15 as Kirigami
import org.kde.neochat 1.0 import org.kde.neochat 1.0
/**
* @brief A timeline delegate for a poll message.
*
* @inherit TimelineContainer
*/
TimelineContainer { TimelineContainer {
id: pollDelegate id: root
readonly property var data: model /**
property var pollHandler: currentRoom.poll(model.eventId) * @brief The matrix message content.
*/
required property var content
/**
* @brief The poll handler for this poll.
*
* This contains the required information like what the question, answers and
* current number of votes for each is.
*/
property var pollHandler: currentRoom.poll(root.eventId)
innerObject: ColumnLayout { innerObject: ColumnLayout {
Label { Label {
id: questionLabel id: questionLabel
text: pollDelegate.data.content["org.matrix.msc3381.poll.start"]["question"]["body"] text: root.content["org.matrix.msc3381.poll.start"]["question"]["body"]
} }
Repeater { Repeater {
model: pollDelegate.data.content["org.matrix.msc3381.poll.start"]["answers"] model: root.content["org.matrix.msc3381.poll.start"]["answers"]
delegate: RowLayout { delegate: RowLayout {
width: pollDelegate.innerObject.width width: root.innerObject.width
CheckBox { CheckBox {
checked: pollDelegate.pollHandler.answers[currentRoom.localUser.id] ? pollDelegate.pollHandler.answers[currentRoom.localUser.id].includes(modelData["id"]) : false checked: root.pollHandler.answers[currentRoom.localUser.id] ? root.pollHandler.answers[currentRoom.localUser.id].includes(modelData["id"]) : false
onClicked: pollDelegate.pollHandler.sendPollAnswer(pollDelegate.data.eventId, modelData["id"]) onClicked: root.pollHandler.sendPollAnswer(root.eventId, modelData["id"])
enabled: !pollDelegate.pollHandler.hasEnded enabled: !root.pollHandler.hasEnded
} }
Label { Label {
text: modelData["org.matrix.msc1767.text"] text: modelData["org.matrix.msc1767.text"]
@@ -37,15 +52,15 @@ TimelineContainer {
Layout.fillWidth: true Layout.fillWidth: true
} }
Label { Label {
visible: pollDelegate.data.content["org.matrix.msc3381.poll.start"]["kind"] == "org.matrix.msc3381.poll.disclosed" || pollHandler.hasEnded visible: root.content["org.matrix.msc3381.poll.start"]["kind"] == "org.matrix.msc3381.poll.disclosed" || pollHandler.hasEnded
Layout.preferredWidth: contentWidth Layout.preferredWidth: contentWidth
text: pollDelegate.pollHandler.counts[modelData["id"]] ?? "0" text: root.pollHandler.counts[modelData["id"]] ?? "0"
} }
} }
} }
Label { Label {
visible: pollDelegate.data.content["org.matrix.msc3381.poll.start"]["kind"] == "org.matrix.msc3381.poll.disclosed" || pollDelegate.pollHandler.hasEnded visible: root.content["org.matrix.msc3381.poll.start"]["kind"] == "org.matrix.msc3381.poll.disclosed" || root.pollHandler.hasEnded
text: i18np("Based on votes by %1 user", "Based on votes by %1 users", pollDelegate.pollHandler.answerCount) + (pollDelegate.pollHandler.hasEnded ? (" " + i18nc("as in 'this vote has ended'", "(Ended)")) : "") 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 font.pointSize: questionLabel.font.pointSize * 0.8
} }
} }

View File

@@ -10,16 +10,67 @@ import org.kde.kirigami 2.15 as Kirigami
import org.kde.neochat 1.0 import org.kde.neochat 1.0
/**
* @brief A component to show a message that has been replied to.
*
* Similar to the main timeline delegate a reply delegate is chosen based on the type
* of message being replied to. The main difference is that not all messages can be
* show in their original form and are instead visualised with a MIME type delegate
* e.g. Videos.
*/
Item { Item {
id: replyComponent id: root
/**
* @brief The reply author.
*
* This should consist of the following:
* - id - The matrix ID of the reply author.
* - isLocalUser - Whether the reply author is the local user.
* - avatarSource - The mxc URL for the reply author's avatar in the current room.
* - avatarMediaId - The media ID of the reply author's avatar.
* - avatarUrl - The mxc URL for the reply author's avatar.
* - displayName - The display name of the reply author.
* - display - The name of the reply author.
* - color - The color for the reply author.
* - object - The NeoChatUser object for the reply author.
*
* @sa NeoChatUser
*/
required property var author
/**
* @brief The delegate type of the reply message.
*/
required property int type
/**
* @brief The display text of the message.
*/
required property string display
/**
* @brief The media info for the reply event.
*
* This could be an image, audio, video or file.
*
* This should consist of the following:
* - source - The mxc URL for the media.
* - mimeType - The MIME type of the media.
* - mimeIcon - The MIME icon name.
* - size - The file size in bytes.
* - duration - The length in seconds of the audio media (audio/video only).
* - width - The width in pixels of the audio media (image/video only).
* - height - The height in pixels of the audio media (image/video only).
* - tempInfo - mediaInfo (with the same properties as this except no tempInfo) for a temporary image while the file downloads (image/video only).
*/
required property var mediaInfo
/**
* @brief The reply has been clicked.
*/
signal replyClicked() signal replyClicked()
property var name
property alias avatar: replyAvatar.source
property var color
property var mediaInfo
implicitWidth: mainLayout.implicitWidth implicitWidth: mainLayout.implicitWidth
implicitHeight: mainLayout.implicitHeight implicitHeight: mainLayout.implicitHeight
@@ -40,7 +91,7 @@ Item {
Layout.rowSpan: 2 Layout.rowSpan: 2
implicitWidth: Kirigami.Units.smallSpacing implicitWidth: Kirigami.Units.smallSpacing
color: replyComponent.color color: root.author.color
} }
Kirigami.Avatar { Kirigami.Avatar {
id: replyAvatar id: replyAvatar
@@ -48,25 +99,26 @@ Item {
implicitWidth: Kirigami.Units.iconSizes.small implicitWidth: Kirigami.Units.iconSizes.small
implicitHeight: Kirigami.Units.iconSizes.small implicitHeight: Kirigami.Units.iconSizes.small
name: replyComponent.name || "" source: root.author.avatarSource
color: replyComponent.color name: root.author.displayName || ""
color: root.author.color
} }
QQC2.Label { QQC2.Label {
Layout.fillWidth: true Layout.fillWidth: true
color: replyComponent.color color: root.author.color
text: replyComponent.name text: root.author.displayName
elide: Text.ElideRight elide: Text.ElideRight
} }
Loader { Loader {
id: loader id: loader
Layout.fillWidth: true Layout.fillWidth: true
Layout.maximumHeight: loader.item && (model.reply.type == MessageEventModel.Image || model.reply.type == MessageEventModel.Sticker) ? loader.item.height : -1 Layout.maximumHeight: loader.item && (root.type == MessageEventModel.Image || root.type == MessageEventModel.Sticker) ? loader.item.height : -1
Layout.columnSpan: 2 Layout.columnSpan: 2
sourceComponent: { sourceComponent: {
switch (model.reply.type) { switch (root.type) {
case MessageEventModel.Image: case MessageEventModel.Image:
case MessageEventModel.Sticker: case MessageEventModel.Sticker:
return imageComponent; return imageComponent;
@@ -89,14 +141,14 @@ Item {
} }
TapHandler { TapHandler {
acceptedButtons: Qt.LeftButton acceptedButtons: Qt.LeftButton
onTapped: replyComponent.replyClicked() onTapped: root.replyClicked()
} }
} }
Component { Component {
id: textComponent id: textComponent
RichLabel { RichLabel {
textMessage: model.reply.display textMessage: root.display
textFormat: Text.RichText textFormat: Text.RichText
HoverHandler { HoverHandler {
@@ -106,7 +158,7 @@ Item {
TapHandler { TapHandler {
enabled: !hoveredLink enabled: !hoveredLink
acceptedButtons: Qt.LeftButton acceptedButtons: Qt.LeftButton
onTapped: replyComponent.replyClicked() onTapped: root.replyClicked()
} }
} }
} }
@@ -116,15 +168,15 @@ Item {
id: image id: image
property var imageWidth: { property var imageWidth: {
if (replyComponent.mediaInfo.width > 0) { if (root.mediaInfo.width > 0) {
return replyComponent.mediaInfo.width; return root.mediaInfo.width;
} else { } else {
return sourceSize.width; return sourceSize.width;
} }
} }
property var imageHeight: { property var imageHeight: {
if (replyComponent.mediaInfo.height > 0) { if (root.mediaInfo.height > 0) {
return replyComponent.mediaInfo.height; return root.mediaInfo.height;
} else { } else {
return sourceSize.height; return sourceSize.height;
} }
@@ -134,15 +186,15 @@ Item {
height: width / aspectRatio height: width / aspectRatio
fillMode: Image.PreserveAspectFit fillMode: Image.PreserveAspectFit
source: mediaInfo.source source: root.mediaInfo.source
} }
} }
Component { Component {
id: mimeComponent id: mimeComponent
MimeComponent { MimeComponent {
mimeIconSource: replyComponent.mediaInfo.mimeIcon mimeIconSource: root.mediaInfo.mimeIcon
label: model.reply.display label: root.display
subLabel: model.reply.type === MessageEventModel.File ? Controller.formatByteSize(replyComponent.mediaInfo.size) : Controller.formatDuration(replyComponent.mediaInfo.duration) subLabel: root.type === MessageEventModel.File ? Controller.formatByteSize(root.mediaInfo.size) : Controller.formatDuration(root.mediaInfo.duration)
} }
} }
Component { Component {

View File

@@ -8,13 +8,35 @@ import QtQuick.Layouts 1.15
import org.kde.neochat 1.0 import org.kde.neochat 1.0
import org.kde.kirigami 2.15 as Kirigami import org.kde.kirigami 2.15 as Kirigami
/**
* @brief A component to show the rich display text of text message.
*/
TextEdit { TextEdit {
id: contentLabel id: root
/**
* @brief The rich text message to display.
*/
property string textMessage
/**
* @brief Whether this message is replying to another.
*/
property bool isReply
/**
* @brief Regex for detecting a message with a single emoji.
*/
readonly property var isEmoji: /^(<span style='.*'>)?(\u00a9|\u00ae|[\u20D0-\u2fff]|[\u3190-\u3300]|\ud83c[\ud000-\udfff]|\ud83d[\ud000-\udfff]|\ud83e[\ud000-\udfff])+(<\/span>)?$/ readonly property var isEmoji: /^(<span style='.*'>)?(\u00a9|\u00ae|[\u20D0-\u2fff]|[\u3190-\u3300]|\ud83c[\ud000-\udfff]|\ud83d[\ud000-\udfff]|\ud83e[\ud000-\udfff])+(<\/span>)?$/
/**
* @brief Regex for detecting a message with a spoiler.
*/
readonly property var hasSpoiler: /data-mx-spoiler/g readonly property var hasSpoiler: /data-mx-spoiler/g
property string textMessage: model.display /**
* @brief Whether a spoiler should be revealed.
*/
property bool spoilerRevealed: !hasSpoiler.test(textMessage) property bool spoilerRevealed: !hasSpoiler.test(textMessage)
ListView.onReused: Qt.binding(() => !hasSpoiler.test(textMessage)) ListView.onReused: Qt.binding(() => !hasSpoiler.test(textMessage))
@@ -23,7 +45,7 @@ TextEdit {
// Work around QTBUG 93281 // Work around QTBUG 93281
Component.onCompleted: if (text.includes("<img")) { Component.onCompleted: if (text.includes("<img")) {
Controller.forceRefreshTextDocument(contentLabel.textDocument, contentLabel) Controller.forceRefreshTextDocument(root.textDocument, root)
} }
text: "<style> text: "<style>
@@ -63,7 +85,7 @@ a{
color: Kirigami.Theme.textColor color: Kirigami.Theme.textColor
selectedTextColor: Kirigami.Theme.highlightedTextColor selectedTextColor: Kirigami.Theme.highlightedTextColor
selectionColor: Kirigami.Theme.highlightColor selectionColor: Kirigami.Theme.highlightColor
font.pointSize: model.reply === undefined && isEmoji.test(model.display) ? Kirigami.Theme.defaultFont.pointSize * 4 : Kirigami.Theme.defaultFont.pointSize font.pointSize: !root.isReply && isEmoji.test(textMessage) ? Kirigami.Theme.defaultFont.pointSize * 4 : Kirigami.Theme.defaultFont.pointSize
selectByMouse: !Kirigami.Settings.isMobile selectByMouse: !Kirigami.Settings.isMobile
readOnly: true readOnly: true
wrapMode: Text.Wrap wrapMode: Text.Wrap

View File

@@ -9,34 +9,276 @@ import org.kde.kirigami 2.15 as Kirigami
import org.kde.neochat 1.0 import org.kde.neochat 1.0
/**
* @brief The base delegate for all messages in the timeline.
*
* This supports a message bubble plus sender avatar for each message as well as reactions
* and read markers. A date section can be show for when the message is on a different
* day to the previous one.
*
* The component is designed for all messages, positioning them in the timeline with
* variable padding depending on the window width. Local user messages are highlighted
* and can also be aligned to the right if configured.
*
* This component also supports a compact mode where the padding is adjusted, the
* background is hidden and the delegate spans the full width of the timeline.
*/
ColumnLayout { ColumnLayout {
id: root id: root
property string eventId: model.eventId /**
* @brief The index of the delegate in the model.
*/
required property var index
property var author: model.author /**
* @brief The matrix ID of the message event.
*/
required property string eventId
property int delegateType: model.delegateType /**
* @brief The timestamp of the message.
*/
required property var time
property bool verified: model.verified /**
* @brief The message author.
*
* This should consist of the following:
* - id - The matrix ID of the author.
* - isLocalUser - Whether the author is the local user.
* - avatarSource - The mxc URL for the author's avatar in the current room.
* - avatarMediaId - The media ID of the author's avatar.
* - avatarUrl - The mxc URL for the author's avatar.
* - displayName - The display name of the author.
* - display - The name of the author.
* - color - The color for the author.
* - object - The NeoChatUser object for the author.
*
* @sa NeoChatUser
*/
required property var author
/**
* @brief Whether the author should be shown.
*/
required property bool showAuthor
/**
* @brief The delegate type of the message.
*/
required property int delegateType
/**
* @brief The display text of the message.
*/
required property string display
/**
* @brief The display text of the message as plain text.
*/
required property string plainText
/**
* @brief The formatted body of the message.
*/
required property string formattedBody
/**
* @brief The date of the event as a string.
*/
required property string section
/**
* @brief Whether the section header should be shown.
*/
required property bool showSection
/**
* @brief A model with the reactions to the message in.
*/
required property var reaction
/**
* @brief Whether the reaction component should be shown.
*/
required property bool showReactions
/**
* @brief A model with the first 5 other user read markers for this message.
*/
required property var readMarkers
/**
* @brief String with the display name and matrix ID of the other user read markers.
*/
required property string readMarkersString
/**
* @brief The number of other users at the event after the first 5.
*/
required property var excessReadMarkers
/**
* @brief Whether the other user read marker component should be shown.
*/
required property bool showReadMarkers
/**
* @brief The reply author.
*
* This should consist of the following:
* - id - The matrix ID of the reply author.
* - isLocalUser - Whether the reply author is the local user.
* - avatarSource - The mxc URL for the reply author's avatar in the current room.
* - avatarMediaId - The media ID of the reply author's avatar.
* - avatarUrl - The mxc URL for the reply author's avatar.
* - displayName - The display name of the reply author.
* - display - The name of the reply author.
* - color - The color for the reply author.
* - object - The NeoChatUser object for the reply author.
*
* @sa NeoChatUser
*/
required property var replyAuthor
/**
* @brief The reply content.
*
* This should consist of the following:
* - display - The display text of the reply.
* - type - The delegate type of the reply.
*/
required property var reply
/**
* @brief The media info for the reply event.
*
* This could be an image, audio, video or file.
*
* This should consist of the following:
* - source - The mxc URL for the media.
* - mimeType - The MIME type of the media.
* - mimeIcon - The MIME icon name.
* - size - The file size in bytes.
* - duration - The length in seconds of the audio media (audio/video only).
* - width - The width in pixels of the audio media (image/video only).
* - height - The height in pixels of the audio media (image/video only).
* - tempInfo - mediaInfo (with the same properties as this except no tempInfo) for a temporary image while the file downloads (image/video only).
*/
required property var replyMediaInfo
/**
* @brief Whether this message is replying to another.
*/
required property bool isReply
/**
* @brief Whether this message has a local user mention.
*/
required property bool isHighlighted
/**
* @brief Whether an event is waiting to be accepted by the server.
*/
required property bool isPending
/**
* @brief Progress info when downloading files.
*
* @sa Quotient::FileTransferInfo
*/
required property var progressInfo
/**
* @brief Whether an encrypted message is sent in a verified session.
*/
required property bool verified
/**
* @brief The mime type of the message's file or media.
*/
required property var mimeType
/**
* @brief The full message source JSON.
*/
required property var source
/**
* @brief The x position of the message bubble.
*
* @note Used for positioning the hover actions.
*/
readonly property real bubbleX: bubble.x + bubble.anchors.leftMargin readonly property real bubbleX: bubble.x + bubble.anchors.leftMargin
/**
* @brief The y position of the message bubble.
*
* @note Used for positioning the hover actions.
*/
readonly property alias bubbleY: mainContainer.y readonly property alias bubbleY: mainContainer.y
/**
* @brief The width of the message bubble.
*
* @note Used for sizing the hover actions.
*/
readonly property alias bubbleWidth: bubble.width readonly property alias bubbleWidth: bubble.width
/**
* @brief Whether this message is hovered.
*/
readonly property alias hovered: bubble.hovered readonly property alias hovered: bubble.hovered
/**
* @brief Open the context menu for the message.
*/
signal openContextMenu signal openContextMenu
/**
* @brief Open the any message media externally.
*/
signal openExternally() signal openExternally()
/**
* @brief The reply has been clicked.
*/
signal replyClicked(string eventID) signal replyClicked(string eventID)
onReplyClicked: ListView.view.goToEvent(eventID) onReplyClicked: ListView.view.goToEvent(eventID)
/**
* @brief The component to display the delegate type.
*
* This is used by the inherited delegates to assign a component to visualise
* the message content for that delegate type.
*/
default property alias innerObject : column.children default property alias innerObject : column.children
property Item hoverComponent: hoverActions ?? null /**
* @brief Whether the bubble background is enabled.
*/
property bool cardBackground: true property bool cardBackground: true
property bool showUserMessageOnRight: Config.showLocalMessagesOnRight && model.author.isLocalUser && !Config.compactLayout
property bool isHighlighted: model.isHighlighted || isTemporaryHighlighted /**
* @brief Whether local user messages should be aligned right.
*
* TODO: make private
*/
property bool showUserMessageOnRight: Config.showLocalMessagesOnRight && root.author.isLocalUser && !Config.compactLayout
/**
* @brief Whether the message should be highlighted.
*/
property bool showHighlight: root.isHighlighted || isTemporaryHighlighted
/**
* @brief Whether the message should temporarily be highlighted.
*
* Normally triggered when jumping to the event in the timeline, e.g. when a reply
* is clicked.
*/
property bool isTemporaryHighlighted: false property bool isTemporaryHighlighted: false
onIsTemporaryHighlightedChanged: if (isTemporaryHighlighted) temporaryHighlightTimer.start() onIsTemporaryHighlightedChanged: if (isTemporaryHighlighted) temporaryHighlightTimer.start()
@@ -48,6 +290,7 @@ ColumnLayout {
onTriggered: isTemporaryHighlighted = false onTriggered: isTemporaryHighlighted = false
} }
// TODO: make these private
// The bubble and delegate widths are allowed to grow once the ListView gets beyond a certain size // The bubble and delegate widths are allowed to grow once the ListView gets beyond a certain size
// extraWidth defines this as the excess after a certain ListView width, capped to a max value // extraWidth defines this as the excess after a certain ListView width, capped to a max value
readonly property int extraWidth: parent ? (parent.width >= Kirigami.Units.gridUnit * 46 ? Math.min((parent.width - Kirigami.Units.gridUnit * 46), Kirigami.Units.gridUnit * 20) : 0) : 0 readonly property int extraWidth: parent ? (parent.width >= Kirigami.Units.gridUnit * 46 ? Math.min((parent.width - Kirigami.Units.gridUnit * 46), Kirigami.Units.gridUnit * 20) : 0) : 0
@@ -89,23 +332,23 @@ ColumnLayout {
id: sectionDelegate id: sectionDelegate
Layout.fillWidth: true Layout.fillWidth: true
visible: model.showSection visible: root.showSection
labelText: model.showSection ? section : "" labelText: root.section
} }
QQC2.ItemDelegate { QQC2.ItemDelegate {
id: mainContainer id: mainContainer
Layout.fillWidth: true Layout.fillWidth: true
Layout.topMargin: showAuthor ? Kirigami.Units.largeSpacing : (Config.compactLayout ? 1 : Kirigami.Units.smallSpacing) Layout.topMargin: root.showAuthor ? Kirigami.Units.largeSpacing : (Config.compactLayout ? 1 : Kirigami.Units.smallSpacing)
Layout.leftMargin: Kirigami.Units.smallSpacing Layout.leftMargin: Kirigami.Units.smallSpacing
Layout.rightMargin: Kirigami.Units.smallSpacing Layout.rightMargin: Kirigami.Units.smallSpacing
implicitHeight: Math.max(model.showAuthor ? avatar.implicitHeight : 0, bubble.height) implicitHeight: Math.max(root.showAuthor ? avatar.implicitHeight : 0, bubble.height)
Component.onCompleted: { Component.onCompleted: {
if (model.isReply && model.reply === undefined) { if (root.isReply && root.reply === undefined) {
messageEventModel.loadReply(sortedMessageEventModel.mapToSource(collapseStateProxyModel.mapToSource(collapseStateProxyModel.index(model.index, 0)))) messageEventModel.loadReply(sortedMessageEventModel.mapToSource(collapseStateProxyModel.mapToSource(collapseStateProxyModel.index(root.index, 0))))
} }
} }
@@ -130,20 +373,20 @@ ColumnLayout {
leftMargin: Kirigami.Units.smallSpacing leftMargin: Kirigami.Units.smallSpacing
} }
visible: model.showAuthor && visible: root.showAuthor &&
Config.showAvatarInTimeline && Config.showAvatarInTimeline &&
(Config.compactLayout || !showUserMessageOnRight) (Config.compactLayout || !showUserMessageOnRight)
name: model.author.name ?? model.author.displayName name: root.author.name ?? root.author.displayName
source: model.author.avatarSource source: root.author.avatarSource
color: model.author.color color: root.author.color
MouseArea { MouseArea {
anchors.fill: parent anchors.fill: parent
onClicked: { onClicked: {
userDetailDialog.createObject(QQC2.ApplicationWindow.overlay, { userDetailDialog.createObject(QQC2.ApplicationWindow.overlay, {
room: currentRoom, room: currentRoom,
user: author.object, user: root.author.object,
displayName: author.displayName displayName: root.author.displayName
}).open(); }).open();
} }
cursorShape: Qt.PointingHandCursor cursorShape: Qt.PointingHandCursor
@@ -199,7 +442,7 @@ ColumnLayout {
width: height width: height
Layout.preferredWidth: Kirigami.Units.iconSizes.small Layout.preferredWidth: Kirigami.Units.iconSizes.small
Layout.preferredHeight: Kirigami.Units.iconSizes.small Layout.preferredHeight: Kirigami.Units.iconSizes.small
visible: model.isPending && Config.showLocalMessagesOnRight visible: root.isPending && Config.showLocalMessagesOnRight
} }
ColumnLayout { ColumnLayout {
id: column id: column
@@ -208,17 +451,17 @@ ColumnLayout {
id: rowLayout id: rowLayout
spacing: Kirigami.Units.smallSpacing spacing: Kirigami.Units.smallSpacing
visible: model.showAuthor visible: root.showAuthor
QQC2.Label { QQC2.Label {
id: nameLabel id: nameLabel
Layout.maximumWidth: contentMaxWidth - timeLabel.implicitWidth - rowLayout.spacing Layout.maximumWidth: contentMaxWidth - timeLabel.implicitWidth - rowLayout.spacing
text: visible ? author.displayName : "" text: visible ? root.author.displayName : ""
textFormat: Text.PlainText textFormat: Text.PlainText
font.weight: Font.Bold font.weight: Font.Bold
color: author.color color: root.author.color
elide: Text.ElideRight elide: Text.ElideRight
MouseArea { MouseArea {
anchors.fill: parent anchors.fill: parent
@@ -226,9 +469,9 @@ ColumnLayout {
onClicked: { onClicked: {
userDetailDialog.createObject(QQC2.ApplicationWindow.overlay, { userDetailDialog.createObject(QQC2.ApplicationWindow.overlay, {
room: currentRoom, room: currentRoom,
user: author.object, user: root.author.object,
displayName: author.displayName, displayName: root.author.displayName,
avatarSource: author.avatarSource avatarSource: root.author.avatarSource
}).open(); }).open();
} }
} }
@@ -236,10 +479,10 @@ ColumnLayout {
QQC2.Label { QQC2.Label {
id: timeLabel id: timeLabel
text: visible ? model.time.toLocaleTimeString(Qt.locale(), Locale.ShortFormat) : "" text: visible ? root.time.toLocaleTimeString(Qt.locale(), Locale.ShortFormat) : ""
color: Kirigami.Theme.disabledTextColor color: Kirigami.Theme.disabledTextColor
QQC2.ToolTip.visible: hoverHandler.hovered QQC2.ToolTip.visible: hoverHandler.hovered
QQC2.ToolTip.text: model.time.toLocaleString(Qt.locale(), Locale.LongFormat) QQC2.ToolTip.text: root.time.toLocaleString(Qt.locale(), Locale.LongFormat)
QQC2.ToolTip.delay: Kirigami.Units.toolTipDelay QQC2.ToolTip.delay: Kirigami.Units.toolTipDelay
HoverHandler { HoverHandler {
@@ -252,20 +495,20 @@ ColumnLayout {
Layout.maximumWidth: contentMaxWidth Layout.maximumWidth: contentMaxWidth
active: model.isReply active: root.isReply
visible: active visible: active
sourceComponent: ReplyComponent { sourceComponent: ReplyComponent {
name: currentRoom.htmlSafeMemberName(model.replyAuthor.id) author: root.replyAuthor
avatar: model.replyAuthor.avatarSource type: root.reply.type
color: model.replyAuthor.color display: root.reply.display
mediaInfo: model.replyMediaInfo mediaInfo: root.replyMediaInfo
} }
Connections { Connections {
target: replyLoader.item target: replyLoader.item
function onReplyClicked() { function onReplyClicked() {
replyClicked(reply.eventId) replyClicked(root.reply.eventId)
} }
} }
} }
@@ -275,7 +518,7 @@ ColumnLayout {
width: height width: height
Layout.preferredWidth: Kirigami.Units.iconSizes.small Layout.preferredWidth: Kirigami.Units.iconSizes.small
Layout.preferredHeight: Kirigami.Units.iconSizes.small Layout.preferredHeight: Kirigami.Units.iconSizes.small
visible: model.isPending && !Config.showLocalMessagesOnRight visible: root.isPending && !Config.showLocalMessagesOnRight
} }
} }
@@ -286,9 +529,9 @@ ColumnLayout {
anchors.fill: parent anchors.fill: parent
Kirigami.Theme.colorSet: Kirigami.Theme.View Kirigami.Theme.colorSet: Kirigami.Theme.View
color: { color: {
if (model.author.isLocalUser) { if (root.author.isLocalUser) {
return Kirigami.ColorUtils.tintWithAlpha(Kirigami.Theme.backgroundColor, Kirigami.Theme.highlightColor, 0.15) return Kirigami.ColorUtils.tintWithAlpha(Kirigami.Theme.backgroundColor, Kirigami.Theme.highlightColor, 0.15)
} else if (root.isHighlighted) { } else if (root.showHighlight) {
return Kirigami.Theme.positiveBackgroundColor return Kirigami.Theme.positiveBackgroundColor
} else { } else {
return Kirigami.Theme.backgroundColor return Kirigami.Theme.backgroundColor
@@ -296,7 +539,7 @@ ColumnLayout {
} }
radius: Kirigami.Units.smallSpacing radius: Kirigami.Units.smallSpacing
shadow.size: Kirigami.Units.smallSpacing shadow.size: Kirigami.Units.smallSpacing
shadow.color: root.isHighlighted ? Qt.rgba(0.0, 0.0, 0.0, 0.10) : Qt.rgba(Kirigami.Theme.textColor.r, Kirigami.Theme.textColor.g, Kirigami.Theme.textColor.b, 0.10) shadow.color: root.showHighlight ? Qt.rgba(0.0, 0.0, 0.0, 0.10) : Qt.rgba(Kirigami.Theme.textColor.r, Kirigami.Theme.textColor.g, Kirigami.Theme.textColor.b, 0.10)
border.color: Kirigami.ColorUtils.tintWithAlpha(color, Kirigami.Theme.textColor, 0.15) border.color: Kirigami.ColorUtils.tintWithAlpha(color, Kirigami.Theme.textColor, 0.15)
border.width: 1 border.width: 1
@@ -332,18 +575,18 @@ ColumnLayout {
Layout.leftMargin: showUserMessageOnRight ? 0 : bubble.x + bubble.anchors.leftMargin Layout.leftMargin: showUserMessageOnRight ? 0 : bubble.x + bubble.anchors.leftMargin
Layout.rightMargin: showUserMessageOnRight ? Kirigami.Units.largeSpacing : 0 Layout.rightMargin: showUserMessageOnRight ? Kirigami.Units.largeSpacing : 0
visible: showReactions visible: root.showReactions
model: reaction model: root.reaction
onReactionClicked: (reaction) => currentRoom.toggleReaction(eventId, reaction) onReactionClicked: (reaction) => currentRoom.toggleReaction(root.eventId, reaction)
} }
AvatarFlow { AvatarFlow {
Layout.alignment: Qt.AlignRight Layout.alignment: Qt.AlignRight
Layout.rightMargin: Kirigami.Units.largeSpacing Layout.rightMargin: Kirigami.Units.largeSpacing
visible: showReadMarkers visible: root.showReadMarkers
model: readMarkers model: root.readMarkers
toolTipText: readMarkersString toolTipText: root.readMarkersString
excessAvatars: excessReadMarkers excessAvatars: root.excessReadMarkers
} }
function isVisibleInTimeline() { function isVisibleInTimeline() {
@@ -352,31 +595,31 @@ ColumnLayout {
} }
/// Open message context dialog for file and videos /// Open message context dialog for file and videos
function openFileContext(event, file) { function openFileContext(file) {
const contextMenu = fileDelegateContextMenu.createObject(root, { const contextMenu = fileDelegateContextMenu.createObject(root, {
author: event.author, author: root.author,
message: event.plainText, message: root.plainText,
eventId: event.eventId, eventId: root.eventId,
source: event.source, source: root.source,
file: file, file: file,
mimeType: event.mimeType, mimeType: root.mimeType,
progressInfo: event.progressInfo, progressInfo: root.progressInfo,
plainMessage: event.plainText, plainMessage: root.plainText,
}); });
contextMenu.open(); contextMenu.open();
} }
/// Open context menu for normal message /// Open context menu for normal message
function openMessageContext(event, selectedText, plainMessage) { function openMessageContext(selectedText) {
const contextMenu = messageDelegateContextMenu.createObject(root, { const contextMenu = messageDelegateContextMenu.createObject(root, {
selectedText: selectedText, selectedText: selectedText,
author: event.author, author: root.author,
message: event.plainText, message: root.plainText,
eventId: event.eventId, eventId: root.eventId,
formattedBody: event.formattedBody, formattedBody: root.formattedBody,
source: event.source, source: root.source,
eventType: event.eventType, eventType: root.delegateType,
plainMessage: event.plainText, plainMessage: root.plainText,
}); });
contextMenu.open(); contextMenu.open();
} }

View File

@@ -12,21 +12,59 @@ import org.kde.kirigamiaddons.labs.components 1.0 as Components
import org.kde.neochat 1.0 import org.kde.neochat 1.0
/**
* @brief A timeline delegate for a video message.
*
* @inherit TimelineContainer
*/
TimelineContainer { TimelineContainer {
id: videoDelegate id: root
/**
* @brief The media info for the event.
*
* This should consist of the following:
* - source - The mxc URL for the media.
* - mimeType - The MIME type of the media (should be video/xxx for this delegate).
* - mimeIcon - The MIME icon name (should be video-xxx).
* - size - The file size in bytes.
* - duration - The length in seconds of the audio media.
* - width - The width in pixels of the audio media.
* - height - The height in pixels of the audio media.
* - tempInfo - mediaInfo (with the same properties as this except no tempInfo) for a temporary image while the file downloads.
*/
required property var mediaInfo
/**
* @brief Whether the media has been downloaded.
*/
readonly property bool downloaded: root.progressInfo && root.progressInfo.completed
/**
* @brief Whether the video should be played when downloaded.
*/
property bool playOnFinished: false property bool playOnFinished: false
readonly property bool downloaded: progressInfo && progressInfo.completed
/**
* @brief Whether the video can be streamed.
*/
property bool supportStreaming: true property bool supportStreaming: true
/**
* @brief The maximum width of the image.
*/
readonly property var maxWidth: Kirigami.Units.gridUnit * 30 readonly property var maxWidth: Kirigami.Units.gridUnit * 30
/**
* @brief The maximum height of the image.
*/
readonly property var maxHeight: Kirigami.Units.gridUnit * 30 readonly property var maxHeight: Kirigami.Units.gridUnit * 30
onOpenContextMenu: openFileContext(model, vid) onOpenContextMenu: openFileContext(vid)
onDownloadedChanged: { onDownloadedChanged: {
if (downloaded) { if (downloaded) {
vid.source = progressInfo.localPath vid.source = root.progressInfo.localPath
} }
if (downloaded && playOnFinished) { if (downloaded && playOnFinished) {
@@ -39,22 +77,22 @@ TimelineContainer {
id: vid id: vid
property var videoWidth: { property var videoWidth: {
if (model.mediaInfo.width > 0) { if (root.mediaInfo.width > 0) {
return model.mediaInfo.width; return root.mediaInfo.width;
} else if (metaData.resolution && metaData.resolution.width) { } else if (metaData.resolution && metaData.resolution.width) {
return metaData.resolution.width; return metaData.resolution.width;
} else { } else {
return videoDelegate.contentMaxWidth; return root.contentMaxWidth;
} }
} }
property var videoHeight: { property var videoHeight: {
if (model.mediaInfo.height > 0) { if (root.mediaInfo.height > 0) {
return model.mediaInfo.height; return root.mediaInfo.height;
} else if (metaData.resolution && metaData.resolution.height) { } else if (metaData.resolution && metaData.resolution.height) {
return metaData.resolution.height; return metaData.resolution.height;
} else { } else {
// Default to a 16:9 placeholder // Default to a 16:9 placeholder
return videoDelegate.contentMaxWidth / 16 * 9; return root.contentMaxWidth / 16 * 9;
} }
} }
@@ -69,11 +107,11 @@ TimelineContainer {
readonly property size maxSize: { readonly property size maxSize: {
if (limitWidth) { if (limitWidth) {
let width = Math.min(videoDelegate.contentMaxWidth, videoDelegate.maxWidth); let width = Math.min(root.contentMaxWidth, root.maxWidth);
let height = width / aspectRatio; let height = width / aspectRatio;
return Qt.size(width, height); return Qt.size(width, height);
} else { } else {
let height = Math.min(videoDelegate.maxHeight, videoDelegate.contentMaxWidth / aspectRatio); let height = Math.min(root.maxHeight, root.contentMaxWidth / aspectRatio);
let width = height * aspectRatio; let width = height * aspectRatio;
return Qt.size(width, height); return Qt.size(width, height);
} }
@@ -91,7 +129,7 @@ TimelineContainer {
states: [ states: [
State { State {
name: "notDownloaded" name: "notDownloaded"
when: !model.progressInfo.completed && !model.progressInfo.active when: !root.progressInfo.completed && !root.progressInfo.active
PropertyChanges { PropertyChanges {
target: noDownloadLabel target: noDownloadLabel
visible: true visible: true
@@ -103,7 +141,7 @@ TimelineContainer {
}, },
State { State {
name: "downloading" name: "downloading"
when: model.progressInfo.active && !model.progressInfo.completed when: root.progressInfo.active && !root.progressInfo.completed
PropertyChanges { PropertyChanges {
target: downloadBar target: downloadBar
visible: true visible: true
@@ -111,7 +149,7 @@ TimelineContainer {
}, },
State { State {
name: "paused" name: "paused"
when: model.progressInfo.completed && (vid.playbackState === MediaPlayer.StoppedState || vid.playbackState === MediaPlayer.PausedState) when: root.progressInfo.completed && (vid.playbackState === MediaPlayer.StoppedState || vid.playbackState === MediaPlayer.PausedState)
PropertyChanges { PropertyChanges {
target: videoControls target: videoControls
stateVisible: true stateVisible: true
@@ -124,7 +162,7 @@ TimelineContainer {
}, },
State { State {
name: "playing" name: "playing"
when: model.progressInfo.completed && vid.playbackState === MediaPlayer.PlayingState when: root.progressInfo.completed && vid.playbackState === MediaPlayer.PlayingState
PropertyChanges { PropertyChanges {
target: videoControls target: videoControls
stateVisible: true stateVisible: true
@@ -154,7 +192,7 @@ TimelineContainer {
anchors.fill: parent anchors.fill: parent
visible: false visible: false
source: model.mediaInfo.tempInfo.source source: root.mediaInfo.tempInfo.source
fillMode: Image.PreserveAspectFit fillMode: Image.PreserveAspectFit
} }
@@ -190,8 +228,8 @@ TimelineContainer {
width: parent.width * 0.8 width: parent.width * 0.8
from: 0 from: 0
to: progressInfo.total to: root.progressInfo.total
value: progressInfo.progress value: root.progressInfo.progress
} }
} }
@@ -315,13 +353,22 @@ TimelineContainer {
text: i18n("Maximize") text: i18n("Maximize")
icon.name: "view-fullscreen" icon.name: "view-fullscreen"
onTriggered: { onTriggered: {
videoDelegate.ListView.view.interactive = false root.ListView.view.interactive = false
vid.pause() vid.pause()
var popup = maximizeVideoComponent.createObject(QQC2.ApplicationWindow.overlay, { var popup = maximizeVideoComponent.createObject(QQC2.ApplicationWindow.overlay, {
modelData: model, eventId: root.eventId,
time: root.time,
author: root.author,
delegateType: root.delegateType,
plainText: root.plainText,
caption: root.display,
mediaInfo: root.mediaInfo,
progressInfo: root.progressInfo,
mimeType: root.mimeType,
source: root.source
}) })
popup.closed.connect(() => { popup.closed.connect(() => {
videoDelegate.ListView.view.interactive = true root.ListView.view.interactive = true
popup.destroy() popup.destroy()
}) })
popup.open() popup.open()
@@ -364,14 +411,14 @@ TimelineContainer {
TapHandler { TapHandler {
acceptedButtons: Qt.LeftButton acceptedButtons: Qt.LeftButton
onTapped: if (vid.supportStreaming || progressInfo.completed) { onTapped: if (vid.supportStreaming || root.progressInfo.completed) {
if (vid.playbackState == MediaPlayer.PlayingState) { if (vid.playbackState == MediaPlayer.PlayingState) {
vid.pause() vid.pause()
} else { } else {
vid.play() vid.play()
} }
} else { } else {
videoDelegate.downloadAndPlay() root.downloadAndPlay()
} }
} }
} }
@@ -381,7 +428,7 @@ TimelineContainer {
playSavedFile() playSavedFile()
} else { } else {
playOnFinished = true playOnFinished = true
currentRoom.downloadFile(eventId, Platform.StandardPaths.writableLocation(Platform.StandardPaths.CacheLocation) + "/" + eventId.replace(":", "_").replace("/", "_").replace("+", "_") + currentRoom.fileNameToDownload(eventId)) currentRoom.downloadFile(root.eventId, Platform.StandardPaths.writableLocation(Platform.StandardPaths.CacheLocation) + "/" + root.eventId.replace(":", "_").replace("/", "_").replace("+", "_") + currentRoom.fileNameToDownload(root.eventId))
} }
} }