diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 000d51062..9a36e3fe5 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -36,6 +36,7 @@ add_library(neochat STATIC blurhash.cpp blurhashimageprovider.cpp models/collapsestateproxymodel.cpp + models/mediamessagefiltermodel.cpp urlhelper.cpp windowcontroller.cpp linkpreviewer.cpp diff --git a/src/main.cpp b/src/main.cpp index a1b9edc73..9d8d97695 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -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("org.kde.neochat", 1, 0, "MessageEventModel"); qmlRegisterType("org.kde.neochat", 1, 0, "ReactionModel"); qmlRegisterType("org.kde.neochat", 1, 0, "CollapseStateProxyModel"); + qmlRegisterType("org.kde.neochat", 1, 0, "MediaMessageFilterModel"); qmlRegisterType("org.kde.neochat", 1, 0, "MessageFilterModel"); qmlRegisterType("org.kde.neochat", 1, 0, "UserFilterModel"); qmlRegisterType("org.kde.neochat", 1, 0, "PublicRoomListModel"); diff --git a/src/models/collapsestateproxymodel.h b/src/models/collapsestateproxymodel.h index 75d5fe870..82db9886e 100644 --- a/src/models/collapsestateproxymodel.h +++ b/src/models/collapsestateproxymodel.h @@ -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 }; /** diff --git a/src/models/mediamessagefiltermodel.cpp b/src/models/mediamessagefiltermodel.cpp new file mode 100644 index 000000000..7a66041e0 --- /dev/null +++ b/src/models/mediamessagefiltermodel.cpp @@ -0,0 +1,79 @@ +// SPDX-FileCopyrightText: 2023 James Graham +// 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 + +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(); + + if (progressInfo.completed()) { + return mapToSource(index).data(MessageEventModel::ProgressInfoRole).value().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 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(); +} diff --git a/src/models/mediamessagefiltermodel.h b/src/models/mediamessagefiltermodel.h new file mode 100644 index 000000000..44938996f --- /dev/null +++ b/src/models/mediamessagefiltermodel.h @@ -0,0 +1,56 @@ +// SPDX-FileCopyrightText: 2023 James Graham +// SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-LGPL + +#pragma once + +#include + +#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 roleNames() const override; + + Q_INVOKABLE int getRowForSourceItem(int sourceRow) const; +}; diff --git a/src/models/messageeventmodel.cpp b/src/models/messageeventmodel.cpp index d9ddbb01f..a626b53c3 100644 --- a/src/models/messageeventmodel.cpp +++ b/src/models/messageeventmodel.cpp @@ -61,7 +61,7 @@ QHash 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"; diff --git a/src/qml/Component/NeochatMaximizeComponent.qml b/src/qml/Component/NeochatMaximizeComponent.qml index 91fedb775..7a3cbd56d 100644 --- a/src/qml/Component/NeochatMaximizeComponent.qml +++ b/src/qml/Component/NeochatMaximizeComponent.qml @@ -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 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: { diff --git a/src/qml/Component/Timeline/ImageDelegate.qml b/src/qml/Component/Timeline/ImageDelegate.qml index 509105d65..6e88fb5f8 100644 --- a/src/qml/Component/Timeline/ImageDelegate.qml +++ b/src/qml/Component/Timeline/ImageDelegate.qml @@ -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() diff --git a/src/qml/Component/Timeline/TimelineContainer.qml b/src/qml/Component/Timeline/TimelineContainer.qml index 969c78587..142ee83bc 100644 --- a/src/qml/Component/Timeline/TimelineContainer.qml +++ b/src/qml/Component/Timeline/TimelineContainer.qml @@ -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, }); diff --git a/src/qml/Component/Timeline/VideoDelegate.qml b/src/qml/Component/Timeline/VideoDelegate.qml index c9ff6bc50..8e30fbcf9 100644 --- a/src/qml/Component/Timeline/VideoDelegate.qml +++ b/src/qml/Component/Timeline/VideoDelegate.qml @@ -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 diff --git a/src/qml/Component/TimelineView.qml b/src/qml/Component/TimelineView.qml index 5b1b665ff..fc14982ac 100644 --- a/src/qml/Component/TimelineView.qml +++ b/src/qml/Component/TimelineView.qml @@ -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, {