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; };