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
blurhashimageprovider.cpp
models/collapsestateproxymodel.cpp
models/mediamessagefiltermodel.cpp
urlhelper.cpp
windowcontroller.cpp
linkpreviewer.cpp

View File

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

View File

@@ -27,6 +27,7 @@ public:
StateEventsRole, /**< List of state events in the aggregated state. */
AuthorListRole, /**< List of the first 5 unique authors of the aggregated state event. */
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[ReactionRole] = "reaction";
roles[ShowReactionsRole] = "showReactions";
roles[SourceRole] = "source";
roles[SourceRole] = "jsonSource";
roles[MimeTypeRole] = "mimeType";
roles[AuthorIdRole] = "authorId";
roles[VerifiedRole] = "verified";

View File

@@ -4,7 +4,7 @@
import QtQuick 2.15
import QtQuick.Controls 2.15 as QQC2
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.kirigamiaddons.labs.components 1.0 as Components
@@ -14,39 +14,46 @@ import org.kde.neochat 1.0
Components.AlbumMaximizeComponent {
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
required property var source
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
downloadAction: Components.DownloadAction {
id: downloadAction
onTriggered: {
currentRoom.downloadFile(root.currentEventId, Platform.StandardPaths.writableLocation(Platform.StandardPaths.CacheLocation) + "/" + root.currentEventId.replace(":", "_").replace("/", "_").replace("+", "_") + currentRoom.fileNameToDownload(root.currentEventId))
}
]
}
model: items
initialIndex: 0
Connections {
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 {
Kirigami.Avatar {
@@ -54,22 +61,23 @@ Components.AlbumMaximizeComponent {
implicitWidth: Kirigami.Units.iconSizes.medium
implicitHeight: Kirigami.Units.iconSizes.medium
name: root.author.displayName
source: root.author.avatarSource
color: root.author.color
name: root.currentAuthor.name ?? root.currentAuthor.displayName
source: root.currentAuthor.avatarSource
color: root.currentAuthor.color
}
ColumnLayout {
spacing: 0
QQC2.Label {
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
elide: Text.ElideRight
}
QQC2.Label {
id: dateTimeLabel
text: root.time.toLocaleString(Qt.locale(), Locale.ShortFormat)
text: root.currentTime.toLocaleString(Qt.locale(), Locale.ShortFormat)
color: Kirigami.Theme.disabledTextColor
elide: Text.ElideRight
}
@@ -77,13 +85,13 @@ Components.AlbumMaximizeComponent {
}
onItemRightClicked: {
const contextMenu = fileDelegateContextMenu.createObject(parent, {
author: root.author,
eventId: root.eventId,
source: root.source,
author: root.currentAuthor,
eventId: root.currentEventId,
source: root.currentJsonSource,
file: parent,
mimeType: root.mimeType,
progressInfo: root.progressInfo,
plainText: root.plainText,
mimeType: root.currentMimeType,
progressInfo: root.currentProgressInfo,
plainMessage: root.currentPlainText
});
contextMenu.closeFullscreen.connect(root.close)
contextMenu.open();
@@ -91,12 +99,12 @@ Components.AlbumMaximizeComponent {
onSaveItem: {
var dialog = saveAsDialog.createObject(QQC2.ApplicationWindow.overlay)
dialog.open()
dialog.currentFile = dialog.folder + "/" + currentRoom.fileNameToDownload(root.eventId)
dialog.currentFile = dialog.folder + "/" + currentRoom.fileNameToDownload(root.currentEventId)
}
Component {
id: saveAsDialog
FileDialog {
Platform.FileDialog {
fileMode: FileDialog.SaveFile
folder: root.saveFolder
onAccepted: {

View File

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

View File

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

View File

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

View File

@@ -40,14 +40,16 @@ QQC2.ScrollView {
model: !isLoaded ? undefined : collapseStateProxyModel
MessageEventModel {
id: messageEventModel
room: root.currentRoom
}
CollapseStateProxyModel {
id: collapseStateProxyModel
sourceModel: MessageFilterModel {
id: sortedMessageEventModel
sourceModel: MessageEventModel {
id: messageEventModel
room: root.currentRoom
}
sourceModel: messageEventModel
}
}
@@ -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) {
userDetailDialog.createObject(QQC2.ApplicationWindow.overlay, {