From 199772a013161f66d0b20311e3e5eaa0ae483a76 Mon Sep 17 00:00:00 2001 From: James Graham Date: Sun, 3 Sep 2023 10:25:04 +0000 Subject: [PATCH] Room Drawer Media Tab Add a tab bar to the room drawer which includes a new media tab in addition to the room information tab. This mr completes the architecture for adding others easily later e.g. message highlights or threads. To put this together I had to make sure things like the menus and the maximize delegate were available to both the room drawer and page so there is some rework there to put it all together. Wide\ ![image](/uploads/b7d3a3ee00016f9ede5cf6fb93e7b40c/image.png) Mobile\ ![image](/uploads/aa02e23f79b37f6cad903d3f356e0ef4/image.png) --- src/models/mediamessagefiltermodel.cpp | 20 +- src/models/mediamessagefiltermodel.h | 13 +- src/models/messagefiltermodel.cpp | 5 +- src/models/messagefiltermodel.h | 2 +- .../Component/NeochatMaximizeComponent.qml | 5 + src/qml/Component/Timeline/ImageDelegate.qml | 10 +- .../Component/Timeline/SectionDelegate.qml | 4 +- .../Component/Timeline/TimelineContainer.qml | 51 ++- src/qml/Component/Timeline/VideoDelegate.qml | 10 +- src/qml/Component/TimelineView.qml | 59 +-- src/qml/RoomDrawer/RoomDrawer.qml | 46 ++- src/qml/RoomDrawer/RoomDrawerPage.qml | 56 ++- src/qml/RoomDrawer/RoomInformation.qml | 384 +++++++++--------- src/qml/RoomDrawer/RoomMedia.qml | 71 ++++ src/res.qrc | 1 + src/roommanager.cpp | 32 +- src/roommanager.h | 57 +++ 17 files changed, 562 insertions(+), 264 deletions(-) create mode 100644 src/qml/RoomDrawer/RoomMedia.qml diff --git a/src/models/mediamessagefiltermodel.cpp b/src/models/mediamessagefiltermodel.cpp index 5ed9701eb..3b7e28d3f 100644 --- a/src/models/mediamessagefiltermodel.cpp +++ b/src/models/mediamessagefiltermodel.cpp @@ -2,12 +2,17 @@ // 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) +#include "messageeventmodel.h" +#include "messagefiltermodel.h" + +MediaMessageFilterModel::MediaMessageFilterModel(QObject *parent, MessageFilterModel *sourceMediaModel) : QSortFilterProxyModel(parent) { + Q_ASSERT(sourceMediaModel); + setSourceModel(sourceMediaModel); } bool MediaMessageFilterModel::filterAcceptsRow(int sourceRow, const QModelIndex &sourceParent) const @@ -43,9 +48,9 @@ QVariant MediaMessageFilterModel::data(const QModelIndex &index, int role) const } if (role == TypeRole) { if (mapToSource(index).data(MessageEventModel::DelegateTypeRole).toInt() == MessageEventModel::DelegateType::Image) { - return 0; + return MediaType::Image; } else { - return 1; + return MediaType::Video; } } if (role == CaptionRole) { @@ -57,6 +62,13 @@ QVariant MediaMessageFilterModel::data(const QModelIndex &index, int role) const if (role == SourceHeightRole) { return mapToSource(index).data(MessageEventModel::MediaInfoRole).toMap()[QStringLiteral("height")].toFloat(); } + // We need to catch this one and return true if the next media object was + // on a different day. + if (role == MessageEventModel::ShowSectionRole) { + const auto day = mapToSource(index).data(MessageEventModel::TimeRole).toDateTime().toLocalTime().date(); + const auto previousEventDay = mapToSource(this->index(index.row() + 1, 0)).data(MessageEventModel::TimeRole).toDateTime().toLocalTime().date(); + return day != previousEventDay; + } return sourceModel()->data(mapToSource(index), role); } diff --git a/src/models/mediamessagefiltermodel.h b/src/models/mediamessagefiltermodel.h index 865534c18..ffecac500 100644 --- a/src/models/mediamessagefiltermodel.h +++ b/src/models/mediamessagefiltermodel.h @@ -4,9 +4,12 @@ #pragma once #include +#include #include "models/messagefiltermodel.h" +class MessageFilterModel; + /** * @class MediaMessageFilterModel * @@ -18,6 +21,12 @@ class MediaMessageFilterModel : public QSortFilterProxyModel { Q_OBJECT public: + enum MediaType { + Image = 0, + Video, + }; + Q_ENUM(MediaType) + /** * @brief Defines the model roles. */ @@ -31,7 +40,7 @@ public: }; Q_ENUM(Roles) - explicit MediaMessageFilterModel(QObject *parent = nullptr); + explicit MediaMessageFilterModel(QObject *parent = nullptr, MessageFilterModel *sourceMediaModel = nullptr); /** * @brief Custom filter to show only image and video messages. @@ -43,7 +52,7 @@ public: * * @sa QSortFilterProxyModel::data */ - [[nodiscard]] QVariant data(const QModelIndex &idx, int role = Qt::DisplayRole) const override; + [[nodiscard]] QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override; /** * @brief Returns a mapping from Role enum values to role names. diff --git a/src/models/messagefiltermodel.cpp b/src/models/messagefiltermodel.cpp index 10dba604d..12e7feb21 100644 --- a/src/models/messagefiltermodel.cpp +++ b/src/models/messagefiltermodel.cpp @@ -10,9 +10,12 @@ using namespace Quotient; -MessageFilterModel::MessageFilterModel(QObject *parent) +MessageFilterModel::MessageFilterModel(QObject *parent, MessageEventModel *sourceMessageModel) : QSortFilterProxyModel(parent) { + Q_ASSERT(sourceMessageModel); + setSourceModel(sourceMessageModel); + connect(NeoChatConfig::self(), &NeoChatConfig::ShowStateEventChanged, this, [this] { invalidateFilter(); }); diff --git a/src/models/messagefiltermodel.h b/src/models/messagefiltermodel.h index 0649e75cb..2f5b2666b 100644 --- a/src/models/messagefiltermodel.h +++ b/src/models/messagefiltermodel.h @@ -33,7 +33,7 @@ public: LastRole, // Keep this last }; - explicit MessageFilterModel(QObject *parent = nullptr); + explicit MessageFilterModel(QObject *parent = nullptr, MessageEventModel *sourceMessageModel = nullptr); /** * @brief Custom filter function to remove hidden messages. diff --git a/src/qml/Component/NeochatMaximizeComponent.qml b/src/qml/Component/NeochatMaximizeComponent.qml index 5410a64d1..e85ac3c4d 100644 --- a/src/qml/Component/NeochatMaximizeComponent.qml +++ b/src/qml/Component/NeochatMaximizeComponent.qml @@ -100,6 +100,11 @@ Components.AlbumMaximizeComponent { dialog.currentFile = dialog.folder + "/" + currentRoom.fileNameToDownload(root.currentEventId) } + Component { + id: fileDelegateContextMenu + FileDelegateContextMenu {} + } + Component { id: saveAsDialog Platform.FileDialog { diff --git a/src/qml/Component/Timeline/ImageDelegate.qml b/src/qml/Component/Timeline/ImageDelegate.qml index 310e5aad2..2a051ca18 100644 --- a/src/qml/Component/Timeline/ImageDelegate.qml +++ b/src/qml/Component/Timeline/ImageDelegate.qml @@ -129,13 +129,19 @@ TimelineContainer { TapHandler { acceptedButtons: Qt.LeftButton + gesturePolicy: TapHandler.ReleaseWithinBounds | TapHandler.WithinBounds onTapped: { imageContainer.QQC2.ToolTip.hide() if (root.mediaInfo.animated) { imageContainer.imageItem.paused = true } root.ListView.view.interactive = false - root.ListView.view.showMaximizedMedia(root.index) + // We need to make sure the index is that of the MediaMessageFilterModel. + if (root.ListView.view.model instanceof MessageFilterModel) { + RoomManager.maximizeMedia(RoomManager.mediaMessageFilterModel.getRowForSourceItem(root.index)) + } else { + RoomManager.maximizeMedia(root.index) + } } } @@ -144,7 +150,7 @@ TimelineContainer { openSavedFile() } else { openOnFinished = true - currentRoom.downloadFile(root.eventId, StandardPaths.writableLocation(StandardPaths.CacheLocation) + "/" + root.eventId.replace(":", "_").replace("/", "_").replace("+", "_") + currentRoom.fileNameToDownload(root.eventId)) + ListView.view.currentRoom.downloadFile(root.eventId, StandardPaths.writableLocation(StandardPaths.CacheLocation) + "/" + root.eventId.replace(":", "_").replace("/", "_").replace("+", "_") + ListView.view.currentRoom.fileNameToDownload(root.eventId)) } } diff --git a/src/qml/Component/Timeline/SectionDelegate.qml b/src/qml/Component/Timeline/SectionDelegate.qml index 127fd0328..5d3c46a59 100644 --- a/src/qml/Component/Timeline/SectionDelegate.qml +++ b/src/qml/Component/Timeline/SectionDelegate.qml @@ -16,6 +16,8 @@ QQC2.ItemDelegate { property alias labelText: sectionLabel.text property var maxWidth: Number.POSITIVE_INFINITY + property int colorSet: Kirigami.Theme.Window + topPadding: Kirigami.Units.largeSpacing bottomPadding: 0 // Note not 0 by default @@ -42,6 +44,6 @@ QQC2.ItemDelegate { background: Rectangle { color: Config.blur ? "transparent" : Kirigami.Theme.backgroundColor Kirigami.Theme.inherit: false - Kirigami.Theme.colorSet: Config.compactLayout ? Kirigami.Theme.View : Kirigami.Theme.Window + Kirigami.Theme.colorSet: sectionDelegate.colorSet } } diff --git a/src/qml/Component/Timeline/TimelineContainer.qml b/src/qml/Component/Timeline/TimelineContainer.qml index 9c6855d79..3abed8784 100644 --- a/src/qml/Component/Timeline/TimelineContainer.qml +++ b/src/qml/Component/Timeline/TimelineContainer.qml @@ -65,6 +65,16 @@ ColumnLayout { */ required property bool showAuthor + /** + * @brief Whether the author should always be shown. + * + * This is primarily used when these delegates are used in a filtered list of + * events rather than a sequential timeline, e.g. the media model view. + * + * @note This setting still respects the avatar configuration settings. + */ + property bool alwaysShowAuthor: false + /** * @brief The delegate type of the message. */ @@ -262,12 +272,17 @@ ColumnLayout { */ property bool cardBackground: true + /** + * @brief Whether the delegate should always stretch to the maximum availabel width. + */ + property bool alwaysMaxWidth: false + /** * @brief Whether local user messages should be aligned right. * * TODO: make private */ - property bool showUserMessageOnRight: Config.showLocalMessagesOnRight && root.author.isLocalUser && !Config.compactLayout + property bool showUserMessageOnRight: Config.showLocalMessagesOnRight && root.author.isLocalUser && !Config.compactLayout && !alwaysMaxWidth /** * @brief Whether the message should be highlighted. @@ -296,7 +311,7 @@ ColumnLayout { width: parent ? timelineDelegateSizeHelper.currentWidth : 0 spacing: Kirigami.Units.smallSpacing - state: Config.compactLayout ? "alignLeft" : "alignCenter" + state: Config.compactLayout || root.alwaysMaxWidth ? "alignLeft" : "alignCenter" // Align left when in compact mode and center when using bubbles states: [ State { @@ -325,21 +340,21 @@ ColumnLayout { SectionDelegate { id: sectionDelegate - Layout.fillWidth: true visible: root.showSection labelText: root.section + colorSet: Config.compactLayout || root.alwaysMaxWidth ? Kirigami.Theme.View : Kirigami.Theme.Window } QQC2.ItemDelegate { id: mainContainer Layout.fillWidth: true - Layout.topMargin: root.showAuthor ? Kirigami.Units.largeSpacing : (Config.compactLayout ? 1 : Kirigami.Units.smallSpacing) + Layout.topMargin: root.showAuthor || root.alwaysShowAuthor ? Kirigami.Units.largeSpacing : (Config.compactLayout ? 1 : Kirigami.Units.smallSpacing) Layout.leftMargin: Kirigami.Units.smallSpacing Layout.rightMargin: Kirigami.Units.smallSpacing - implicitHeight: Math.max(root.showAuthor ? avatar.implicitHeight : 0, bubble.height) + implicitHeight: Math.max(root.showAuthor || root.alwaysShowAuthor ? avatar.implicitHeight : 0, bubble.height) Component.onCompleted: { if (root.isReply && root.reply === undefined) { @@ -365,7 +380,7 @@ ColumnLayout { topMargin: Kirigami.Units.smallSpacing } - visible: root.showAuthor && + visible: (root.showAuthor || root.alwaysShowAuthor) && Config.showAvatarInTimeline && (Config.compactLayout || !showUserMessageOnRight) name: root.author.displayName @@ -395,7 +410,7 @@ ColumnLayout { rightMargin: Kirigami.Units.largeSpacing } // HACK: anchoring didn't reset anchors.right when switching from parent.right to undefined reliably - width: Config.compactLayout ? mainContainer.width - (Config.showAvatarInTimeline ? Kirigami.Units.gridUnit * 2 : 0) + Kirigami.Units.largeSpacing * 2 : implicitWidth + width: Config.compactLayout || root.alwaysMaxWidth ? mainContainer.width - (Config.showAvatarInTimeline ? Kirigami.Units.gridUnit * 2 : 0) + Kirigami.Units.largeSpacing * 2 : implicitWidth state: showUserMessageOnRight ? "userMessageOnRight" : "userMessageOnLeft" // states for anchor animations on window resize @@ -440,7 +455,7 @@ ColumnLayout { id: rowLayout spacing: Kirigami.Units.smallSpacing - visible: root.showAuthor + visible: root.showAuthor || root.alwaysShowAuthor QQC2.Label { id: nameLabel @@ -535,7 +550,7 @@ ColumnLayout { } background: Rectangle { - visible: mainContainer.hovered && Config.compactLayout + visible: mainContainer.hovered && (Config.compactLayout || root.alwaysMaxWidth) color: Kirigami.ColorUtils.tintWithAlpha(Kirigami.Theme.backgroundColor, Kirigami.Theme.highlightColor, 0.15) radius: Kirigami.Units.smallSpacing } @@ -577,6 +592,16 @@ ColumnLayout { return (yoff + height > 0 && yoff < ListView.view.height) } + Component { + id: messageDelegateContextMenu + MessageDelegateContextMenu {} + } + + Component { + id: fileDelegateContextMenu + FileDelegateContextMenu {} + } + /// Open message context dialog for file and videos function openFileContext(file) { const contextMenu = fileDelegateContextMenu.createObject(root, { @@ -616,8 +641,8 @@ ColumnLayout { startBreakpoint: Kirigami.Units.gridUnit * 46 endBreakpoint: Kirigami.Units.gridUnit * 66 startPercentWidth: 100 - endPercentWidth: Config.compactLayout ? 100 : 85 - maxWidth: Config.compactLayout ? -1 : Kirigami.Units.gridUnit * 60 + endPercentWidth: Config.compactLayout || root.alwaysMaxWidth ? 100 : 85 + maxWidth: Config.compactLayout || root.alwaysMaxWidth ? -1 : Kirigami.Units.gridUnit * 60 parentWidth: root.parent ? root.parent.width - (Config.compactLayout && root.ListView.view.width >= Kirigami.Units.gridUnit * 20 ? Kirigami.Units.gridUnit * 2 + Kirigami.Units.largeSpacing : 0) : 0 } @@ -625,8 +650,8 @@ ColumnLayout { id: bubbleSizeHelper startBreakpoint: Kirigami.Units.gridUnit * 25 endBreakpoint: Kirigami.Units.gridUnit * 40 - startPercentWidth: Config.compactLayout ? 100 : 90 - endPercentWidth: Config.compactLayout ? 100 : 60 + startPercentWidth: Config.compactLayout || root.alwaysMaxWidth ? 100 : 90 + endPercentWidth: Config.compactLayout || root.alwaysMaxWidth ? 100 : 60 parentWidth: mainContainer.availableWidth - (Config.showAvatarInTimeline ? avatar.width + bubble.anchors.leftMargin : 0) } diff --git a/src/qml/Component/Timeline/VideoDelegate.qml b/src/qml/Component/Timeline/VideoDelegate.qml index fe071f7d1..47c8e1180 100644 --- a/src/qml/Component/Timeline/VideoDelegate.qml +++ b/src/qml/Component/Timeline/VideoDelegate.qml @@ -292,7 +292,12 @@ TimelineContainer { onTriggered: { root.ListView.view.interactive = false vid.pause() - root.ListView.view.showMaximizedMedia(root.index) + // We need to make sure the index is that of the MediaMessageFilterModel. + if (root.ListView.view.model instanceof MessageFilterModel) { + RoomManager.maximizeMedia(RoomManager.mediaMessageFilterModel.getRowForSourceItem(root.index)) + } else { + RoomManager.maximizeMedia(root.index) + } } } } @@ -328,6 +333,7 @@ TimelineContainer { TapHandler { acceptedButtons: Qt.LeftButton + gesturePolicy: TapHandler.ReleaseWithinBounds | TapHandler.WithinBounds onTapped: if (root.progressInfo.completed) { if (vid.playbackState == MediaPlayer.PlayingState) { vid.pause() @@ -352,7 +358,7 @@ TimelineContainer { playSavedFile() } else { playOnFinished = true - currentRoom.downloadFile(root.eventId, Platform.StandardPaths.writableLocation(Platform.StandardPaths.CacheLocation) + "/" + root.eventId.replace(":", "_").replace("/", "_").replace("+", "_") + currentRoom.fileNameToDownload(root.eventId)) + ListView.view.currentRoom.downloadFile(root.eventId, Platform.StandardPaths.writableLocation(Platform.StandardPaths.CacheLocation) + "/" + root.eventId.replace(":", "_").replace("/", "_").replace("+", "_") + ListView.view.currentRoom.fileNameToDownload(root.eventId)) } } diff --git a/src/qml/Component/TimelineView.qml b/src/qml/Component/TimelineView.qml index 4d89d7a51..7f031dc60 100644 --- a/src/qml/Component/TimelineView.qml +++ b/src/qml/Component/TimelineView.qml @@ -32,6 +32,9 @@ QQC2.ScrollView { ListView { id: messageListView + // So that delegates can access the current room properly. + readonly property NeoChatRoom currentRoom: root.currentRoom + readonly property int largestVisibleIndex: count > 0 ? indexAt(contentX + (width / 2), contentY + height - 1) : -1 readonly property var sectionBannerItem: contentHeight >= height ? itemAtIndex(sectionBannerIndex()) : undefined @@ -47,33 +50,23 @@ QQC2.ScrollView { interactive: Kirigami.Settings.isMobile bottomMargin: Kirigami.Units.largeSpacing + Math.round(Kirigami.Theme.defaultFont.pointSize * 2) - model: sortedMessageEventModel - - MessageEventModel { - id: messageEventModel - room: root.currentRoom - } - - MessageFilterModel { - id: sortedMessageEventModel - sourceModel: messageEventModel - } + model: RoomManager.messageFilterModel Timer { interval: 1000 running: messageListView.atYBeginning triggeredOnStart: true onTriggered: { - if (messageListView.atYBeginning && messageEventModel.canFetchMore(messageEventModel.index(0, 0))) { - messageEventModel.fetchMore(messageEventModel.index(0, 0)); + if (messageListView.atYBeginning && RoomManager.messageEventModel.canFetchMore(RoomManager.messageEventModel.index(0, 0))) { + RoomManager.messageEventModel.fetchMore(RoomManager.messageEventModel.index(0, 0)); } } repeat: true } // HACK: The view should do this automatically but doesn't. - onAtYBeginningChanged: if (atYBeginning && messageEventModel.canFetchMore(messageEventModel.index(0, 0))) { - messageEventModel.fetchMore(messageEventModel.index(0, 0)); + onAtYBeginningChanged: if (atYBeginning && RoomManager.messageEventModel.canFetchMore(RoomManager.messageEventModel.index(0, 0))) { + RoomManager.messageEventModel.fetchMore(RoomManager.messageEventModel.index(0, 0)); } Timer { @@ -207,18 +200,6 @@ QQC2.ScrollView { } } - Component { - id: messageDelegateContextMenu - - MessageDelegateContextMenu {} - } - - Component { - id: fileDelegateContextMenu - - FileDelegateContextMenu {} - } - TypingPane { id: typingPane visible: root.currentRoom && root.currentRoom.usersTyping.length > 0 @@ -285,7 +266,7 @@ QQC2.ScrollView { } Connections { - target: messageEventModel + target: RoomManager.messageEventModel function onRowsInserted() { markReadIfVisibleTimer.restart() @@ -326,7 +307,7 @@ QQC2.ScrollView { Connections { //enabled: Config.showFancyEffects - target: messageEventModel + target: RoomManager.messageEventModel function onFancyEffectsReasonFound(fancyEffect) { fancyEffectsContainer.processFancyEffectsReason(fancyEffect) @@ -344,21 +325,23 @@ QQC2.ScrollView { } } - MediaMessageFilterModel { - id: mediaMessageFilterModel - sourceModel: sortedMessageEventModel - } - Component { id: maximizeComponent NeochatMaximizeComponent { - model: mediaMessageFilterModel + model: RoomManager.mediaMessageFilterModel + } + } + + Connections { + target: RoomManager + function onShowMaximizedMedia(index) { + messageListView.showMaximizedMedia(index) } } function showMaximizedMedia(index) { var popup = maximizeComponent.createObject(QQC2.ApplicationWindow.overlay, { - initialIndex: index === -1 ? -1 : mediaMessageFilterModel.getRowForSourceItem(index) + initialIndex: index }) popup.closed.connect(() => { messageListView.interactive = true @@ -374,10 +357,10 @@ QQC2.ScrollView { } function eventToIndex(eventID) { - const index = messageEventModel.eventIdToRow(eventID) + const index = RoomManager.messageEventModel.eventIdToRow(eventID) if (index === -1) return -1 - return sortedMessageEventModel.mapFromSource(messageEventModel.index(index, 0)).row + return RoomManager.messageFilterModel.mapFromSource(RoomManager.messageEventModel.index(index, 0)).row } function firstVisibleIndex() { diff --git a/src/qml/RoomDrawer/RoomDrawer.qml b/src/qml/RoomDrawer/RoomDrawer.qml index ee60b3749..36d573116 100644 --- a/src/qml/RoomDrawer/RoomDrawer.qml +++ b/src/qml/RoomDrawer/RoomDrawer.qml @@ -65,9 +65,12 @@ Kirigami.OverlayDrawer { onAnimatingChanged: if (dim === false) dim = undefined topPadding: 0 + bottomPadding: 0 leftPadding: 0 rightPadding: 0 + Kirigami.Theme.colorSet: Kirigami.Theme.View + contentItem: Loader { id: loader active: root.drawerOpen @@ -75,6 +78,8 @@ Kirigami.OverlayDrawer { sourceComponent: ColumnLayout { spacing: 0 + Component.onCompleted: infoAction.toggle() + QQC2.ToolBar { Layout.fillWidth: true @@ -83,7 +88,7 @@ Kirigami.OverlayDrawer { contentItem: RowLayout { Kirigami.Heading { Layout.fillWidth: true - text: i18n("Room information") + text: drawerItemLoader.item ? drawerItemLoader.item.title : "" } QQC2.ToolButton { @@ -102,18 +107,47 @@ Kirigami.OverlayDrawer { } } - QQC2.ScrollView { + Loader { + id: drawerItemLoader Layout.fillWidth: true Layout.fillHeight: true + sourceComponent: roomInformation + } - // HACK: Hide unnecessary horizontal scrollbar (https://bugreports.qt.io/browse/QTBUG-83890) - QQC2.ScrollBar.horizontal.policy: QQC2.ScrollBar.AlwaysOff - + Component { + id: roomInformation RoomInformation { - id: roomInformation room: root.room } } + + Component { + id: roomMedia + RoomMedia { + currentRoom: root.room + } + } + + Kirigami.NavigationTabBar { + id: navigationBar + Layout.fillWidth: true + Kirigami.Theme.colorSet: Kirigami.Theme.Window + Kirigami.Theme.inherit: false + + actions: [ + Kirigami.Action { + id: infoAction + text: i18n("Information") + icon.name: "documentinfo" + onTriggered: drawerItemLoader.sourceComponent = roomInformation + }, + Kirigami.Action { + text: i18n("Media") + icon.name: "mail-attachment-symbollic" + onTriggered: drawerItemLoader.sourceComponent = roomMedia + } + ] + } } } } diff --git a/src/qml/RoomDrawer/RoomDrawerPage.qml b/src/qml/RoomDrawer/RoomDrawerPage.qml index 9a3c0758c..c880dc918 100644 --- a/src/qml/RoomDrawer/RoomDrawerPage.qml +++ b/src/qml/RoomDrawer/RoomDrawerPage.qml @@ -18,7 +18,7 @@ import org.kde.neochat 1.0 * * @sa RoomDrawer */ -Kirigami.ScrollablePage { +Kirigami.Page { id: root /** @@ -26,18 +26,66 @@ Kirigami.ScrollablePage { */ readonly property NeoChatRoom room: RoomManager.currentRoom - title: roomInformation.title + title: drawerItemLoader.item ? drawerItemLoader.item.title : "" + + topPadding: 0 + bottomPadding: 0 + leftPadding: 0 + rightPadding: 0 + + Kirigami.Theme.colorSet: Kirigami.Theme.View + Kirigami.Theme.inherit: false + + Component.onCompleted: infoAction.toggle() actions { main: Kirigami.Action { + displayHint: Kirigami.DisplayHint.IconOnly + text: i18n("Settings") icon.name: "settings-configure" onTriggered: applicationWindow().pageStack.pushDialogLayer('qrc:/Categories.qml', {room: root.room}, { title: i18n("Room Settings") }) } } - RoomInformation { + Loader { + id: drawerItemLoader + width: parent.width + height: parent.height + sourceComponent: roomInformation + } + + Component { id: roomInformation - room: root.room + RoomInformation { + room: root.room + } + } + + Component { + id: roomMedia + RoomMedia { + currentRoom: root.room + } + } + + footer: Kirigami.NavigationTabBar { + id: navigationBar + Kirigami.Theme.colorSet: Kirigami.Theme.Window + Kirigami.Theme.inherit: false + + actions: [ + Kirigami.Action { + id: infoAction + text: i18n("Information") + icon.name: "documentinfo" + onTriggered: drawerItemLoader.sourceComponent = roomInformation + }, + Kirigami.Action { + text: i18n("Media") + icon.name: "mail-attachment-symbollic" + onTriggered: drawerItemLoader.sourceComponent = roomMedia + } + ] } Connections { diff --git a/src/qml/RoomDrawer/RoomInformation.qml b/src/qml/RoomDrawer/RoomInformation.qml index b04f4119f..d1e3b2ad8 100644 --- a/src/qml/RoomDrawer/RoomInformation.qml +++ b/src/qml/RoomDrawer/RoomInformation.qml @@ -24,7 +24,7 @@ import org.kde.neochat 1.0 * * @sa RoomDrawer, RoomDrawerPage */ -ListView { +QQC2.ScrollView { id: root /** @@ -37,206 +37,212 @@ ListView { */ readonly property string title: i18nc("@action:title", "Room information") - header: ColumnLayout { - id: columnLayout + // HACK: Hide unnecessary horizontal scrollbar (https://bugreports.qt.io/browse/QTBUG-83890) + QQC2.ScrollBar.horizontal.policy: QQC2.ScrollBar.AlwaysOff - property alias userListSearchField: userListSearchField + ListView { + id: userList + header: ColumnLayout { + id: columnLayout - spacing: 0 - width: root.width + property alias userListSearchField: userListSearchField - Loader { - active: true - Layout.fillWidth: true - Layout.topMargin: Kirigami.Units.smallSpacing - sourceComponent: root.room.isDirectChat() ? directChatDrawerHeader : groupChatDrawerHeader - onItemChanged: if (item) { - root.positionViewAtBeginning(); - } - } - - Kirigami.ListSectionHeader { - label: i18n("Options") - activeFocusOnTab: false - - Layout.fillWidth: true - } - - Delegates.RoundedItemDelegate { - id: devtoolsButton - - icon.name: "tools" - text: i18n("Open developer tools") - visible: Config.developerTools - - Layout.fillWidth: true - - onClicked: { - applicationWindow().pageStack.pushDialogLayer("qrc:/DevtoolsPage.qml", {room: root.room}, {title: i18n("Developer Tools")}) - } - } - - Delegates.RoundedItemDelegate { - id: searchButton - - icon.name: "search" - text: i18n("Search in this room") - - Layout.fillWidth: true - - onClicked: { - pageStack.pushDialogLayer("qrc:/SearchPage.qml", { - currentRoom: root.room - }, { - title: i18nc("@action:title", "Search") - }) - } - } - - Delegates.RoundedItemDelegate { - id: favouriteButton - - icon.name: root.room && root.room.isFavourite ? "rating" : "rating-unrated" - text: root.room && root.room.isFavourite ? i18n("Remove room from favorites") : i18n("Make room favorite") - - onClicked: root.room.isFavourite ? root.room.removeTag("m.favourite") : root.room.addTag("m.favourite", 1.0) - - Layout.fillWidth: true - } - - Delegates.RoundedItemDelegate { - id: locationsButton - - icon.name: "map-flat" - text: i18n("Show locations for this room") - - onClicked: pageStack.pushDialogLayer("qrc:/LocationsPage.qml", { - room: root.room - }, { - title: i18nc("Locations on a map", "Locations") - }) - - Layout.fillWidth: true - } - - Kirigami.ListSectionHeader { - label: i18n("Members") - activeFocusOnTab: false spacing: 0 - visible: !root.room.isDirectChat() + width: root.width - Layout.fillWidth: true - - QQC2.ToolButton { - id: memberSearchToggle - checkable: true - icon.name: "search" - QQC2.ToolTip.text: i18n("Search user in room") - QQC2.ToolTip.visible: hovered - QQC2.ToolTip.delay: Kirigami.Units.toolTipDelay - onToggled: { - userListSearchField.text = ""; + Loader { + active: true + Layout.fillWidth: true + Layout.topMargin: Kirigami.Units.smallSpacing + sourceComponent: root.room.isDirectChat() ? directChatDrawerHeader : groupChatDrawerHeader + onItemChanged: if (item) { + userList.positionViewAtBeginning(); } } - QQC2.ToolButton { - visible: root.room.canSendState("invite") - icon.name: "list-add-user" - - onClicked: { - applicationWindow().pageStack.pushDialogLayer("qrc:/InviteUserPage.qml", {room: root.room}, {title: i18nc("@title", "Invite a User")}) - } - - QQC2.ToolTip.text: i18n("Invite user to room") - QQC2.ToolTip.visible: hovered - QQC2.ToolTip.delay: Kirigami.Units.toolTipDelay - } - - QQC2.Label { - Layout.alignment: Qt.AlignRight - text: root.room ? i18np("%1 member", "%1 members", root.room.joinedCount) : i18n("No member count") - } - } - - Kirigami.SearchField { - id: userListSearchField - visible: memberSearchToggle.checked - - onVisibleChanged: if (visible) forceActiveFocus() - Layout.fillWidth: true - Layout.leftMargin: Kirigami.Units.largeSpacing - 1 - Layout.rightMargin: Kirigami.Units.largeSpacing - 1 - Layout.bottomMargin: Kirigami.Units.smallSpacing - - focusSequence: "Ctrl+Shift+F" - - onAccepted: sortedMessageEventModel.filterString = text; - } - } - - KSortFilterProxyModel { - id: sortedMessageEventModel - - sourceModel: UserListModel { - room: root.room - } - - sortRole: "powerLevel" - sortOrder: Qt.DescendingOrder - filterRole: "name" - filterCaseSensitivity: Qt.CaseInsensitive - } - - model: root.room.isDirectChat() ? 0 : sortedMessageEventModel - - clip: true - activeFocusOnTab: true - - delegate: Delegates.RoundedItemDelegate { - id: userDelegate - - required property string name - required property string userId - required property string avatar - required property int powerLevel - required property string powerLevelString - - implicitHeight: Kirigami.Units.gridUnit * 2 - - text: name - - onClicked: { - userDelegate.highlighted = true; - RoomManager.visitUser(room.getUser(userDelegate.userId).object, "mention") - } - - contentItem: RowLayout { - KirigamiComponents.Avatar { - implicitWidth: height - sourceSize { - height: Kirigami.Units.gridUnit + Kirigami.Units.smallSpacing * 2.5 - width: Kirigami.Units.gridUnit + Kirigami.Units.smallSpacing * 2.5 - } - source: userDelegate.avatar - name: userDelegate.userId - - Layout.fillHeight: true - } - - QQC2.Label { - text: userDelegate.name - textFormat: Text.PlainText - elide: Text.ElideRight + Kirigami.ListSectionHeader { + label: i18n("Options") + activeFocusOnTab: false Layout.fillWidth: true } - QQC2.Label { - visible: userDelegate.powerLevel > 0 + Delegates.RoundedItemDelegate { + id: devtoolsButton - text: userDelegate.powerLevelString - color: Kirigami.Theme.disabledTextColor - textFormat: Text.PlainText + icon.name: "tools" + text: i18n("Open developer tools") + visible: Config.developerTools + + Layout.fillWidth: true + + onClicked: { + applicationWindow().pageStack.pushDialogLayer("qrc:/DevtoolsPage.qml", {room: root.room}, {title: i18n("Developer Tools")}) + } + } + + Delegates.RoundedItemDelegate { + id: searchButton + + icon.name: "search" + text: i18n("Search in this room") + + Layout.fillWidth: true + + onClicked: { + pageStack.pushDialogLayer("qrc:/SearchPage.qml", { + currentRoom: root.room + }, { + title: i18nc("@action:title", "Search") + }) + } + } + + Delegates.RoundedItemDelegate { + id: favouriteButton + + icon.name: root.room && root.room.isFavourite ? "rating" : "rating-unrated" + text: root.room && root.room.isFavourite ? i18n("Remove room from favorites") : i18n("Make room favorite") + + onClicked: root.room.isFavourite ? root.room.removeTag("m.favourite") : root.room.addTag("m.favourite", 1.0) + + Layout.fillWidth: true + } + + Delegates.RoundedItemDelegate { + id: locationsButton + + icon.name: "map-flat" + text: i18n("Show locations for this room") + + onClicked: pageStack.pushDialogLayer("qrc:/LocationsPage.qml", { + room: root.room + }, { + title: i18nc("Locations on a map", "Locations") + }) + + Layout.fillWidth: true + } + + Kirigami.ListSectionHeader { + label: i18n("Members") + activeFocusOnTab: false + spacing: 0 + visible: !root.room.isDirectChat() + + Layout.fillWidth: true + + QQC2.ToolButton { + id: memberSearchToggle + checkable: true + icon.name: "search" + QQC2.ToolTip.text: i18n("Search user in room") + QQC2.ToolTip.visible: hovered + QQC2.ToolTip.delay: Kirigami.Units.toolTipDelay + onToggled: { + userListSearchField.text = ""; + } + } + + QQC2.ToolButton { + visible: root.room.canSendState("invite") + icon.name: "list-add-user" + + onClicked: { + applicationWindow().pageStack.pushDialogLayer("qrc:/InviteUserPage.qml", {room: root.room}, {title: i18nc("@title", "Invite a User")}) + } + + QQC2.ToolTip.text: i18n("Invite user to room") + QQC2.ToolTip.visible: hovered + QQC2.ToolTip.delay: Kirigami.Units.toolTipDelay + } + + QQC2.Label { + Layout.alignment: Qt.AlignRight + text: root.room ? i18np("%1 member", "%1 members", root.room.joinedCount) : i18n("No member count") + } + } + + Kirigami.SearchField { + id: userListSearchField + visible: memberSearchToggle.checked + + onVisibleChanged: if (visible) forceActiveFocus() + Layout.fillWidth: true + Layout.leftMargin: Kirigami.Units.largeSpacing - 1 + Layout.rightMargin: Kirigami.Units.largeSpacing - 1 + Layout.bottomMargin: Kirigami.Units.smallSpacing + + focusSequence: "Ctrl+Shift+F" + + onAccepted: sortedMessageEventModel.filterString = text; + } + } + + KSortFilterProxyModel { + id: sortedMessageEventModel + + sourceModel: UserListModel { + room: root.room + } + + sortRole: "powerLevel" + sortOrder: Qt.DescendingOrder + filterRole: "name" + filterCaseSensitivity: Qt.CaseInsensitive + } + + model: root.room.isDirectChat() ? 0 : sortedMessageEventModel + + clip: true + activeFocusOnTab: true + + delegate: Delegates.RoundedItemDelegate { + id: userDelegate + + required property string name + required property string userId + required property string avatar + required property int powerLevel + required property string powerLevelString + + implicitHeight: Kirigami.Units.gridUnit * 2 + + text: name + + onClicked: { + userDelegate.highlighted = true; + RoomManager.visitUser(room.getUser(userDelegate.userId).object, "mention") + } + + contentItem: RowLayout { + KirigamiComponents.Avatar { + implicitWidth: height + sourceSize { + height: Kirigami.Units.gridUnit + Kirigami.Units.smallSpacing * 2.5 + width: Kirigami.Units.gridUnit + Kirigami.Units.smallSpacing * 2.5 + } + source: userDelegate.avatar + name: userDelegate.userId + + Layout.fillHeight: true + } + + QQC2.Label { + text: userDelegate.name + textFormat: Text.PlainText + elide: Text.ElideRight + + Layout.fillWidth: true + } + + QQC2.Label { + visible: userDelegate.powerLevel > 0 + + text: userDelegate.powerLevelString + color: Kirigami.Theme.disabledTextColor + textFormat: Text.PlainText + } } } } @@ -255,6 +261,6 @@ ListView { if (root.headerItem) { root.headerItem.userListSearchField.text = ""; } - root.currentIndex = -1 + userList.currentIndex = -1 } } diff --git a/src/qml/RoomDrawer/RoomMedia.qml b/src/qml/RoomDrawer/RoomMedia.qml new file mode 100644 index 000000000..799350a59 --- /dev/null +++ b/src/qml/RoomDrawer/RoomMedia.qml @@ -0,0 +1,71 @@ +// SPDX-FileCopyrightText: 2023 James Graham +// SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL + +import QtQuick 2.15 +import QtQuick.Controls 2.15 as QQC2 +import QtQuick.Layouts 1.15 +import Qt.labs.qmlmodels 1.0 + +import org.kde.kirigami 2.20 as Kirigami + +import org.kde.neochat 1.0 + +/** + * @brief Component for visualising the loaded media items in the room. + * + * The component is a simple list of media delegates (videos or images) with the + * ability to open them in the mamimize component. + * + * @note This component is only the contents, it will need to be placed in either + * a drawer (desktop) or page (mobile) to be used. + * + * @sa RoomDrawer, RoomDrawerPage + */ +QQC2.ScrollView { + id: root + + /** + * @brief The title that should be displayed for this component if available. + */ + readonly property string title: i18nc("@action:title", "Room Media") + + /** + * @brief The current room that user is viewing. + */ + required property NeoChatRoom currentRoom + + // HACK: Hide unnecessary horizontal scrollbar (https://bugreports.qt.io/browse/QTBUG-83890) + QQC2.ScrollBar.horizontal.policy: QQC2.ScrollBar.AlwaysOff + + ListView { + // So that delegates can access current room properly. + readonly property NeoChatRoom currentRoom: root.currentRoom + + clip: true + verticalLayoutDirection: ListView.BottomToTop + + model: RoomManager.mediaMessageFilterModel + + delegate: DelegateChooser { + role: "type" + + DelegateChoice { + roleValue: 0//MediaMessageFilterModel.Image + delegate: ImageDelegate { + alwaysShowAuthor: true + alwaysMaxWidth: true + cardBackground: false + } + } + + DelegateChoice { + roleValue: 1//MediaMessageFilterModel.Video + delegate: VideoDelegate { + alwaysShowAuthor: true + alwaysMaxWidth: true + cardBackground: false + } + } + } + } +} diff --git a/src/res.qrc b/src/res.qrc index 3cce484f5..60937d6f4 100644 --- a/src/res.qrc +++ b/src/res.qrc @@ -141,6 +141,7 @@ qml/RoomDrawer/DirectChatDrawerHeader.qml qml/RoomDrawer/GroupChatDrawerHeader.qml qml/RoomDrawer/RoomInformation.qml + qml/RoomDrawer/RoomMedia.qml qml/Page/ChooseRoomDialog.qml diff --git a/src/roommanager.cpp b/src/roommanager.cpp index 2334acc85..df5ce8d55 100644 --- a/src/roommanager.cpp +++ b/src/roommanager.cpp @@ -5,6 +5,7 @@ #include "roommanager.h" #include "controller.h" +#include "models/messageeventmodel.h" #include "neochatconfig.h" #include "neochatroom.h" #include @@ -26,8 +27,15 @@ RoomManager::RoomManager(QObject *parent) , m_currentRoom(nullptr) , m_lastCurrentRoom(nullptr) , m_config(KConfig(QStringLiteral("data"), KConfig::SimpleConfig, QStandardPaths::AppDataLocation)) + , m_messageEventModel(new MessageEventModel(this)) + , m_messageFilterModel(new MessageFilterModel(this, m_messageEventModel)) + , m_mediaMessageFilterModel(new MediaMessageFilterModel(this, m_messageFilterModel)) { m_lastRoomConfig = m_config.group(QStringLiteral("LastOpenRoom")); + + connect(this, &RoomManager::currentRoomChanged, this, [this]() { + m_messageEventModel->setRoom(m_currentRoom); + }); } RoomManager::~RoomManager() @@ -45,6 +53,21 @@ NeoChatRoom *RoomManager::currentRoom() const return m_currentRoom; } +MessageEventModel *RoomManager::messageEventModel() const +{ + return m_messageEventModel; +} + +MessageFilterModel *RoomManager::messageFilterModel() const +{ + return m_messageFilterModel; +} + +MediaMessageFilterModel *RoomManager::mediaMessageFilterModel() const +{ + return m_mediaMessageFilterModel; +} + void RoomManager::openResource(const QString &idOrUri, const QString &action) { Uri uri{idOrUri}; @@ -72,6 +95,14 @@ void RoomManager::openResource(const QString &idOrUri, const QString &action) } } +void RoomManager::maximizeMedia(int index) +{ + if (index < -1 || index > m_mediaMessageFilterModel->rowCount()) { + return; + } + Q_EMIT showMaximizedMedia(index); +} + bool RoomManager::hasOpenRoom() const { return m_currentRoom != nullptr; @@ -266,7 +297,6 @@ void RoomManager::leaveRoom(NeoChatRoom *room) if (m_currentRoom && m_currentRoom->id() == room->id()) { m_currentRoom = m_lastCurrentRoom; m_lastCurrentRoom = nullptr; - Q_EMIT currentRoomChanged(); } diff --git a/src/roommanager.h b/src/roommanager.h index 938a5ee4d..2371427cc 100644 --- a/src/roommanager.h +++ b/src/roommanager.h @@ -9,6 +9,9 @@ #include #include "chatdocumenthandler.h" +#include "models/mediamessagefiltermodel.h" +#include "models/messageeventmodel.h" +#include "models/messagefiltermodel.h" class NeoChatRoom; @@ -36,6 +39,34 @@ class RoomManager : public QObject, public UriResolverBase */ Q_PROPERTY(NeoChatRoom *currentRoom READ currentRoom NOTIFY currentRoomChanged) + /** + * @brief The MessageEventModel that should be used for room message visualisation. + * + * The room object the model uses to get the data will be updated by this class + * so there is no need to do this manually or replace the model when a room + * changes. + * + * @note Available here so that the room page and drawer both have access to the + * same model. + */ + Q_PROPERTY(MessageEventModel *messageEventModel READ messageEventModel CONSTANT) + + /** + * @brief The MessageFilterModel that should be used for room message visualisation. + * + * @note Available here so that the room page and drawer both have access to the + * same model. + */ + Q_PROPERTY(MessageFilterModel *messageFilterModel READ messageFilterModel CONSTANT) + + /** + * @brief The MediaMessageFilterModel that should be used for room media message visualisation. + * + * @note Available here so that the room page and drawer both have access to the + * same model. + */ + Q_PROPERTY(MediaMessageFilterModel *mediaMessageFilterModel READ mediaMessageFilterModel CONSTANT) + /** * @brief Whether a room is currently open in NeoChat. * @@ -57,6 +88,10 @@ public: NeoChatRoom *currentRoom() const; + MessageEventModel *messageEventModel() const; + MessageFilterModel *messageFilterModel() const; + MediaMessageFilterModel *mediaMessageFilterModel() const; + bool hasOpenRoom() const; /** @@ -151,6 +186,15 @@ public: */ Q_INVOKABLE void openResource(const QString &idOrUri, const QString &action = {}); + /** + * @brief Show a media item maximized. + * + * @param index the index to open the maximize delegate model at. This is the + * index in the MediaMessageFilterModel owned by this RoomManager. A value + * of -1 opens a the default item. + */ + Q_INVOKABLE void maximizeMedia(int index); + /** * @brief Call this when the current used connection is dropped. */ @@ -208,6 +252,15 @@ Q_SIGNALS: */ void showUserDetail(const Quotient::User *user); + /** + * @brief Request a media item is shown maximized. + * + * @param index the index to open the maximize delegate model at. This is the + * index in the MediaMessageFilterModel owned by this RoomManager. A value + * of -1 opens a the default item. + */ + void showMaximizedMedia(int index); + /** * @brief Show the direct chat confirmation dialog. * @@ -232,4 +285,8 @@ private: KConfig m_config; KConfigGroup m_lastRoomConfig; QPointer m_chatDocumentHandler; + + MessageEventModel *m_messageEventModel; + MessageFilterModel *m_messageFilterModel; + MediaMessageFilterModel *m_mediaMessageFilterModel; };