Media Model

Create a media model for all the media message in the timeline and then setup `NeoChatMaximizeComponent` so that it can use the media model to scroll through all loaded images and video in the current room.

Depends upon libraries/kirigami-addons!105

FEATURE: 467411
This commit is contained in:
James Graham
2023-07-08 11:07:04 +00:00
committed by Tobias Fella
parent 81928d8b93
commit c55b40c9c6
11 changed files with 219 additions and 90 deletions

View File

@@ -36,6 +36,7 @@ add_library(neochat STATIC
blurhash.cpp blurhash.cpp
blurhashimageprovider.cpp blurhashimageprovider.cpp
models/collapsestateproxymodel.cpp models/collapsestateproxymodel.cpp
models/mediamessagefiltermodel.cpp
urlhelper.cpp urlhelper.cpp
windowcontroller.cpp windowcontroller.cpp
linkpreviewer.cpp linkpreviewer.cpp

View File

@@ -60,6 +60,7 @@
#include "models/keywordnotificationrulemodel.h" #include "models/keywordnotificationrulemodel.h"
#include "models/livelocationsmodel.h" #include "models/livelocationsmodel.h"
#include "models/locationsmodel.h" #include "models/locationsmodel.h"
#include "models/mediamessagefiltermodel.h"
#include "models/messageeventmodel.h" #include "models/messageeventmodel.h"
#include "models/messagefiltermodel.h" #include "models/messagefiltermodel.h"
#include "models/publicroomlistmodel.h" #include "models/publicroomlistmodel.h"
@@ -237,6 +238,7 @@ int main(int argc, char *argv[])
qmlRegisterType<MessageEventModel>("org.kde.neochat", 1, 0, "MessageEventModel"); qmlRegisterType<MessageEventModel>("org.kde.neochat", 1, 0, "MessageEventModel");
qmlRegisterType<ReactionModel>("org.kde.neochat", 1, 0, "ReactionModel"); qmlRegisterType<ReactionModel>("org.kde.neochat", 1, 0, "ReactionModel");
qmlRegisterType<CollapseStateProxyModel>("org.kde.neochat", 1, 0, "CollapseStateProxyModel"); qmlRegisterType<CollapseStateProxyModel>("org.kde.neochat", 1, 0, "CollapseStateProxyModel");
qmlRegisterType<MediaMessageFilterModel>("org.kde.neochat", 1, 0, "MediaMessageFilterModel");
qmlRegisterType<MessageFilterModel>("org.kde.neochat", 1, 0, "MessageFilterModel"); qmlRegisterType<MessageFilterModel>("org.kde.neochat", 1, 0, "MessageFilterModel");
qmlRegisterType<UserFilterModel>("org.kde.neochat", 1, 0, "UserFilterModel"); qmlRegisterType<UserFilterModel>("org.kde.neochat", 1, 0, "UserFilterModel");
qmlRegisterType<PublicRoomListModel>("org.kde.neochat", 1, 0, "PublicRoomListModel"); qmlRegisterType<PublicRoomListModel>("org.kde.neochat", 1, 0, "PublicRoomListModel");

View File

@@ -27,6 +27,7 @@ public:
StateEventsRole, /**< List of state events in the aggregated state. */ StateEventsRole, /**< List of state events in the aggregated state. */
AuthorListRole, /**< List of the first 5 unique authors of the aggregated state event. */ AuthorListRole, /**< List of the first 5 unique authors of the aggregated state event. */
ExcessAuthorsRole, /**< The number of unique authors beyond the first 5. */ ExcessAuthorsRole, /**< The number of unique authors beyond the first 5. */
LastRole, // Keep this last
}; };
/** /**

View File

@@ -0,0 +1,79 @@
// SPDX-FileCopyrightText: 2023 James Graham <james.h.graham@protonmail.com>
// SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-LGPL
#include "mediamessagefiltermodel.h"
#include "models/messageeventmodel.h"
#include <room.h>
MediaMessageFilterModel::MediaMessageFilterModel(QObject *parent)
: QSortFilterProxyModel(parent)
{
}
bool MediaMessageFilterModel::filterAcceptsRow(int sourceRow, const QModelIndex &sourceParent) const
{
const QModelIndex index = sourceModel()->index(sourceRow, 0, sourceParent);
if (index.data(MessageEventModel::DelegateTypeRole).toInt() == MessageEventModel::DelegateType::Image
|| index.data(MessageEventModel::DelegateTypeRole).toInt() == MessageEventModel::DelegateType::Video) {
return true;
}
return false;
}
QVariant MediaMessageFilterModel::data(const QModelIndex &index, int role) const
{
if (role == SourceRole) {
if (mapToSource(index).data(MessageEventModel::DelegateTypeRole).toInt() == MessageEventModel::DelegateType::Image) {
return mapToSource(index).data(MessageEventModel::MediaInfoRole).toMap()["source"].toUrl();
} else if (mapToSource(index).data(MessageEventModel::DelegateTypeRole).toInt() == MessageEventModel::DelegateType::Video) {
auto progressInfo = mapToSource(index).data(MessageEventModel::ProgressInfoRole).value<Quotient::FileTransferInfo>();
if (progressInfo.completed()) {
return mapToSource(index).data(MessageEventModel::ProgressInfoRole).value<Quotient::FileTransferInfo>().localPath;
} else {
return QUrl();
}
} else {
return QUrl();
}
}
if (role == TempSourceRole) {
return mapToSource(index).data(MessageEventModel::MediaInfoRole).toMap()["tempInfo"].toMap()["source"].toUrl();
}
if (role == TypeRole) {
if (mapToSource(index).data(MessageEventModel::DelegateTypeRole).toInt() == MessageEventModel::DelegateType::Image) {
return 0;
} else {
return 1;
}
}
if (role == CaptionRole) {
return mapToSource(index).data(Qt::DisplayRole);
}
if (role == SourceWidthRole) {
return mapToSource(index).data(MessageEventModel::MediaInfoRole).toMap()["width"].toFloat();
}
if (role == SourceHeightRole) {
return mapToSource(index).data(MessageEventModel::MediaInfoRole).toMap()["height"].toFloat();
}
return sourceModel()->data(mapToSource(index), role);
}
QHash<int, QByteArray> MediaMessageFilterModel::roleNames() const
{
auto roles = sourceModel()->roleNames();
roles[SourceRole] = "source";
roles[TempSourceRole] = "tempSource";
roles[TypeRole] = "type";
roles[CaptionRole] = "caption";
roles[SourceWidthRole] = "sourceWidth";
roles[SourceHeightRole] = "sourceHeight";
return roles;
}
int MediaMessageFilterModel::getRowForSourceItem(int sourceRow) const
{
return mapFromSource(sourceModel()->index(sourceRow, 0)).row();
}

View File

@@ -0,0 +1,56 @@
// SPDX-FileCopyrightText: 2023 James Graham <james.h.graham@protonmail.com>
// SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-LGPL
#pragma once
#include <QSortFilterProxyModel>
#include "models/collapsestateproxymodel.h"
/**
* @class MediaMessageFilterModel
*
* This model filters a MessageEventModel for image and video messages.
*
* @sa MessageEventModel
*/
class MediaMessageFilterModel : public QSortFilterProxyModel
{
Q_OBJECT
public:
/**
* @brief Defines the model roles.
*/
enum Roles {
SourceRole = CollapseStateProxyModel::LastRole + 1, /**< The mxc source URL for the item. */
TempSourceRole, /**< Source for the temporary content (either blurhash or mxc URL). */
TypeRole, /**< The type of the media (image or video). */
CaptionRole, /**< The caption for the item. */
SourceWidthRole, /**< The width of the source item. */
SourceHeightRole, /**< The height of the source item. */
};
Q_ENUM(Roles)
MediaMessageFilterModel(QObject *parent = nullptr);
/**
* @brief Custom filter to show only image and video messages.
*/
bool filterAcceptsRow(int sourceRow, const QModelIndex &sourceParent) const override;
/**
* @brief Get the given role value at the given index.
*
* @sa QSortFilterProxyModel::data
*/
[[nodiscard]] QVariant data(const QModelIndex &idx, int role = Qt::DisplayRole) const override;
/**
* @brief Returns a mapping from Role enum values to role names.
*
* @sa Roles, QAbstractProxyModel::roleNames()
*/
[[nodiscard]] QHash<int, QByteArray> roleNames() const override;
Q_INVOKABLE int getRowForSourceItem(int sourceRow) const;
};

View File

@@ -61,7 +61,7 @@ QHash<int, QByteArray> MessageEventModel::roleNames() const
roles[ShowReadMarkersRole] = "showReadMarkers"; roles[ShowReadMarkersRole] = "showReadMarkers";
roles[ReactionRole] = "reaction"; roles[ReactionRole] = "reaction";
roles[ShowReactionsRole] = "showReactions"; roles[ShowReactionsRole] = "showReactions";
roles[SourceRole] = "source"; roles[SourceRole] = "jsonSource";
roles[MimeTypeRole] = "mimeType"; roles[MimeTypeRole] = "mimeType";
roles[AuthorIdRole] = "authorId"; roles[AuthorIdRole] = "authorId";
roles[VerifiedRole] = "verified"; roles[VerifiedRole] = "verified";

View File

@@ -4,7 +4,7 @@
import QtQuick 2.15 import QtQuick 2.15
import QtQuick.Controls 2.15 as QQC2 import QtQuick.Controls 2.15 as QQC2
import QtQuick.Layouts 1.15 import QtQuick.Layouts 1.15
import Qt.labs.platform 1.1 import Qt.labs.platform 1.1 as Platform
import org.kde.kirigami 2.13 as Kirigami import org.kde.kirigami 2.13 as Kirigami
import org.kde.kirigamiaddons.labs.components 1.0 as Components import org.kde.kirigamiaddons.labs.components 1.0 as Components
@@ -14,39 +14,46 @@ import org.kde.neochat 1.0
Components.AlbumMaximizeComponent { Components.AlbumMaximizeComponent {
id: root id: root
required property string eventId readonly property string currentEventId: model.data(model.index(content.currentIndex, 0), MessageEventModel.EventIdRole)
required property var time readonly property var currentAuthor: model.data(model.index(content.currentIndex, 0), MessageEventModel.AuthorRole)
required property var author readonly property var currentTime: model.data(model.index(content.currentIndex, 0), MessageEventModel.TimeRole)
required property int delegateType readonly property string currentPlainText: model.data(model.index(content.currentIndex, 0), MessageEventModel.PlainText)
required property string plainText readonly property var currentMimeType: model.data(model.index(content.currentIndex, 0), MessageEventModel.MimeTypeRole)
required property string caption readonly property var currentProgressInfo: model.data(model.index(content.currentIndex, 0), MessageEventModel.ProgressInfoRole)
required property var mediaInfo readonly property var currentJsonSource: model.data(model.index(content.currentIndex, 0), MessageEventModel.SourceRole)
required property var progressInfo autoLoad: false
required property var mimeType downloadAction: Components.DownloadAction {
id: downloadAction
required property var source onTriggered: {
currentRoom.downloadFile(root.currentEventId, Platform.StandardPaths.writableLocation(Platform.StandardPaths.CacheLocation) + "/" + root.currentEventId.replace(":", "_").replace("/", "_").replace("+", "_") + currentRoom.fileNameToDownload(root.currentEventId))
property list<Components.AlbumModelItem> items: [
Components.AlbumModelItem {
type: root.delegateType === MessageEventModel.Image || root.delegateType === MessageEventModel.Sticker ? Components.AlbumModelItem.Image : Components.AlbumModelItem.Video
source: root.delegateType === MessageEventModel.Video ? root.progressInfo.localPath : root.mediaInfo.source
tempSource: root.mediaInfo.tempInfo.source
caption: root.caption
sourceWidth: root.mediaInfo.width
sourceHeight: root.mediaInfo.height
} }
] }
model: items Connections {
initialIndex: 0 target: currentRoom
function onFileTransferProgress(id, progress, total) {
if (id == root.currentEventId) {
downloadAction.progress = progress / total * 100.0
}
}
}
Connections {
target: content
function onCurrentIndexChanged() {
downloadAction.progress = currentProgressInfo.progress / currentProgressInfo.total * 100.0
}
}
leading: RowLayout { leading: RowLayout {
Kirigami.Avatar { Kirigami.Avatar {
@@ -54,22 +61,23 @@ Components.AlbumMaximizeComponent {
implicitWidth: Kirigami.Units.iconSizes.medium implicitWidth: Kirigami.Units.iconSizes.medium
implicitHeight: Kirigami.Units.iconSizes.medium implicitHeight: Kirigami.Units.iconSizes.medium
name: root.author.displayName name: root.currentAuthor.name ?? root.currentAuthor.displayName
source: root.author.avatarSource source: root.currentAuthor.avatarSource
color: root.author.color color: root.currentAuthor.color
} }
ColumnLayout { ColumnLayout {
spacing: 0 spacing: 0
QQC2.Label { QQC2.Label {
id: userLabel id: userLabel
text: root.author.displayName
color: root.author.color text: root.currentAuthor.name ?? root.currentAuthor.displayName
color: root.currentAuthor.color
font.weight: Font.Bold font.weight: Font.Bold
elide: Text.ElideRight elide: Text.ElideRight
} }
QQC2.Label { QQC2.Label {
id: dateTimeLabel id: dateTimeLabel
text: root.time.toLocaleString(Qt.locale(), Locale.ShortFormat) text: root.currentTime.toLocaleString(Qt.locale(), Locale.ShortFormat)
color: Kirigami.Theme.disabledTextColor color: Kirigami.Theme.disabledTextColor
elide: Text.ElideRight elide: Text.ElideRight
} }
@@ -77,13 +85,13 @@ Components.AlbumMaximizeComponent {
} }
onItemRightClicked: { onItemRightClicked: {
const contextMenu = fileDelegateContextMenu.createObject(parent, { const contextMenu = fileDelegateContextMenu.createObject(parent, {
author: root.author, author: root.currentAuthor,
eventId: root.eventId, eventId: root.currentEventId,
source: root.source, source: root.currentJsonSource,
file: parent, file: parent,
mimeType: root.mimeType, mimeType: root.currentMimeType,
progressInfo: root.progressInfo, progressInfo: root.currentProgressInfo,
plainText: root.plainText, plainMessage: root.currentPlainText
}); });
contextMenu.closeFullscreen.connect(root.close) contextMenu.closeFullscreen.connect(root.close)
contextMenu.open(); contextMenu.open();
@@ -91,12 +99,12 @@ 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(root.eventId) dialog.currentFile = dialog.folder + "/" + currentRoom.fileNameToDownload(root.currentEventId)
} }
Component { Component {
id: saveAsDialog id: saveAsDialog
FileDialog { Platform.FileDialog {
fileMode: FileDialog.SaveFile fileMode: FileDialog.SaveFile
folder: root.saveFolder folder: root.saveFolder
onAccepted: { onAccepted: {

View File

@@ -147,32 +147,10 @@ TimelineContainer {
img.QQC2.ToolTip.hide() img.QQC2.ToolTip.hide()
img.paused = true img.paused = true
root.ListView.view.interactive = false root.ListView.view.interactive = false
var popup = maximizeImageComponent.createObject(QQC2.ApplicationWindow.overlay, { root.ListView.view.showMaximizedMedia(root.index)
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(() => {
root.ListView.view.interactive = true
img.paused = false
popup.destroy()
})
popup.open()
} }
} }
Component {
id: maximizeImageComponent
NeochatMaximizeComponent {}
}
function downloadAndOpen() { function downloadAndOpen() {
if (downloaded) { if (downloaded) {
openSavedFile() openSavedFile()

View File

@@ -203,7 +203,7 @@ ColumnLayout {
/** /**
* @brief The full message source JSON. * @brief The full message source JSON.
*/ */
required property var source required property var jsonSource
/** /**
* @brief The x position of the message bubble. * @brief The x position of the message bubble.
@@ -590,7 +590,7 @@ ColumnLayout {
const contextMenu = fileDelegateContextMenu.createObject(root, { const contextMenu = fileDelegateContextMenu.createObject(root, {
author: root.author, author: root.author,
eventId: root.eventId, eventId: root.eventId,
source: root.source, source: root.jsonSource,
file: file, file: file,
mimeType: root.mimeType, mimeType: root.mimeType,
progressInfo: root.progressInfo, progressInfo: root.progressInfo,
@@ -605,7 +605,7 @@ ColumnLayout {
selectedText: selectedText, selectedText: selectedText,
author: root.author, author: root.author,
eventId: root.eventId, eventId: root.eventId,
source: root.source, source: root.jsonSource,
eventType: root.delegateType, eventType: root.delegateType,
plainText: root.plainText, plainText: root.plainText,
}); });

View File

@@ -355,30 +355,10 @@ TimelineContainer {
onTriggered: { onTriggered: {
root.ListView.view.interactive = false root.ListView.view.interactive = false
vid.pause() vid.pause()
var popup = maximizeVideoComponent.createObject(QQC2.ApplicationWindow.overlay, { root.ListView.view.showMaximizedMedia(root.index)
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(() => {
root.ListView.view.interactive = true
popup.destroy()
})
popup.open()
} }
} }
} }
Component {
id: maximizeVideoComponent
NeochatMaximizeComponent {}
}
} }
background: Kirigami.ShadowedRectangle { background: Kirigami.ShadowedRectangle {
radius: 4 radius: 4

View File

@@ -40,14 +40,16 @@ QQC2.ScrollView {
model: !isLoaded ? undefined : collapseStateProxyModel model: !isLoaded ? undefined : collapseStateProxyModel
MessageEventModel {
id: messageEventModel
room: root.currentRoom
}
CollapseStateProxyModel { CollapseStateProxyModel {
id: collapseStateProxyModel id: collapseStateProxyModel
sourceModel: MessageFilterModel { sourceModel: MessageFilterModel {
id: sortedMessageEventModel id: sortedMessageEventModel
sourceModel: MessageEventModel { sourceModel: messageEventModel
id: messageEventModel
room: root.currentRoom
}
} }
} }
@@ -395,6 +397,28 @@ QQC2.ScrollView {
} }
} }
MediaMessageFilterModel {
id: mediaMessageFilterModel
sourceModel: collapseStateProxyModel
}
Component {
id: maximizeComponent
NeochatMaximizeComponent {
model: mediaMessageFilterModel
}
}
function showMaximizedMedia(index) {
var popup = maximizeComponent.createObject(QQC2.ApplicationWindow.overlay, {
initialIndex: index === -1 ? -1 : mediaMessageFilterModel.getRowForSourceItem(index)
})
popup.closed.connect(() => {
messageListView.interactive = true
popup.destroy()
})
popup.open()
}
function showUserDetail(user) { function showUserDetail(user) {
userDetailDialog.createObject(QQC2.ApplicationWindow.overlay, { userDetailDialog.createObject(QQC2.ApplicationWindow.overlay, {