diff --git a/src/neochatroom.cpp b/src/neochatroom.cpp index 8077f677d..ae785f92b 100644 --- a/src/neochatroom.cpp +++ b/src/neochatroom.cpp @@ -39,6 +39,7 @@ #include #include +#include "clipboard.h" #include "controller.h" #include "eventhandler.h" #include "events/joinrulesevent.h" @@ -47,6 +48,7 @@ #include "neochatconfig.h" #include "notificationsmanager.h" #include "texthandler.h" +#include "urlhelper.h" #include "utils.h" #include @@ -1344,6 +1346,62 @@ QByteArray NeoChatRoom::getEventJsonSource(const QString &eventId) return {}; } +void NeoChatRoom::openEventMediaExternally(const QString &eventId) +{ + const auto evtIt = findInTimeline(eventId); + if (evtIt != messageEvents().rend() && is(**evtIt)) { + const auto event = evtIt->viewAs(); + if (event->hasFileContent()) { + const auto transferInfo = fileTransferInfo(eventId); + if (transferInfo.completed()) { + UrlHelper helper; + helper.openUrl(transferInfo.localPath); + } else { + downloadFile(eventId, + QUrl(QStandardPaths::writableLocation(QStandardPaths::CacheLocation) + u'/' + + event->id().replace(u':', u'_').replace(u'/', u'_').replace(u'+', u'_') + fileNameToDownload(eventId))); + connect(this, &Room::fileTransferCompleted, this, [this, eventId](QString id, QUrl localFile, FileSourceInfo fileMetadata) { + Q_UNUSED(localFile); + Q_UNUSED(fileMetadata); + if (id == eventId) { + auto transferInfo = fileTransferInfo(eventId); + UrlHelper helper; + helper.openUrl(transferInfo.localPath); + } + }); + } + } + } +} + +void NeoChatRoom::copyEventMedia(const QString &eventId) +{ + const auto evtIt = findInTimeline(eventId); + if (evtIt != messageEvents().rend() && is(**evtIt)) { + const auto event = evtIt->viewAs(); + if (event->hasFileContent()) { + const auto transferInfo = fileTransferInfo(eventId); + if (transferInfo.completed()) { + Clipboard clipboard; + clipboard.setImage(transferInfo.localPath); + } else { + downloadFile(eventId, + QUrl(QStandardPaths::writableLocation(QStandardPaths::CacheLocation) + u'/' + + event->id().replace(u':', u'_').replace(u'/', u'_').replace(u'+', u'_') + fileNameToDownload(eventId))); + connect(this, &Room::fileTransferCompleted, this, [this, eventId](QString id, QUrl localFile, FileSourceInfo fileMetadata) { + Q_UNUSED(localFile); + Q_UNUSED(fileMetadata); + if (id == eventId) { + auto transferInfo = fileTransferInfo(eventId); + Clipboard clipboard; + clipboard.setImage(transferInfo.localPath); + } + }); + } + } + } +} + QString NeoChatRoom::chatBoxText() const { return m_chatBoxText; diff --git a/src/neochatroom.h b/src/neochatroom.h index ca36f9d8b..39a3e838d 100644 --- a/src/neochatroom.h +++ b/src/neochatroom.h @@ -553,6 +553,20 @@ public: Q_INVOKABLE QByteArray getEventJsonSource(const QString &eventId); + /** + * @brief Open the media for the given event in an appropriate external app. + * + * Will do nothing if the event has no media. + */ + Q_INVOKABLE void openEventMediaExternally(const QString &eventId); + + /** + * @brief Copy the media for the given event to the clipboard. + * + * Will do nothing if the event has no media. + */ + Q_INVOKABLE void copyEventMedia(const QString &eventId); + [[nodiscard]] bool readMarkerLoaded() const; /** diff --git a/src/qml/Component/NeochatMaximizeComponent.qml b/src/qml/Component/NeochatMaximizeComponent.qml index fcec2c2b7..c923bed69 100644 --- a/src/qml/Component/NeochatMaximizeComponent.qml +++ b/src/qml/Component/NeochatMaximizeComponent.qml @@ -25,6 +25,8 @@ Components.AlbumMaximizeComponent { readonly property var currentTime: model.data(model.index(content.currentIndex, 0), MessageEventModel.TimeRole) + readonly property var currentDelegateType: model.data(model.index(content.currentIndex, 0), MessageEventModel.DelegateTypeRole) + readonly property string currentPlainText: model.data(model.index(content.currentIndex, 0), MessageEventModel.PlainText) readonly property var currentMimeType: model.data(model.index(content.currentIndex, 0), MessageEventModel.MimeTypeRole) @@ -84,28 +86,26 @@ Components.AlbumMaximizeComponent { } } } - onItemRightClicked: { - const contextMenu = fileDelegateContextMenu.createObject(parent, { - author: root.currentAuthor, - eventId: root.currentEventId, - file: parent, - mimeType: root.currentMimeType, - progressInfo: root.currentProgressInfo, - plainText: root.currentPlainText, - connection: root.currentRoom.connection - }); - contextMenu.closeFullscreen.connect(root.close) - contextMenu.open(); - } + onItemRightClicked: RoomManager.viewEventMenu(root.currentEventId, + root.currentAuthor, + root.currentDelegateType, + root.currentPlainText, + "", + "", + root.currentMimeType, + root.currentProgressInfo) + onSaveItem: { var dialog = saveAsDialog.createObject(QQC2.ApplicationWindow.overlay) dialog.open() dialog.currentFile = dialog.folder + "/" + currentRoom.fileNameToDownload(root.currentEventId) } - Component { - id: fileDelegateContextMenu - FileDelegateContextMenu {} + Connections { + target: RoomManager + function onCloseFullScreen() { + root.close() + } } Component { diff --git a/src/qml/Component/Timeline/AudioDelegate.qml b/src/qml/Component/Timeline/AudioDelegate.qml index c0549e3f0..44120bbe5 100644 --- a/src/qml/Component/Timeline/AudioDelegate.qml +++ b/src/qml/Component/Timeline/AudioDelegate.qml @@ -36,7 +36,7 @@ TimelineContainer { readonly property bool downloaded: root.progressInfo && root.progressInfo.completed onDownloadedChanged: audio.play() - onOpenContextMenu: openFileContext(root) + onOpenContextMenu: RoomManager.viewEventMenu(eventId, author, delegateType, plainText, "", "", mediaInfo.mimeType, progressInfo) innerObject: ColumnLayout { Layout.fillWidth: true diff --git a/src/qml/Component/Timeline/FileDelegate.qml b/src/qml/Component/Timeline/FileDelegate.qml index 55a7f81e1..73a24cebe 100644 --- a/src/qml/Component/Timeline/FileDelegate.qml +++ b/src/qml/Component/Timeline/FileDelegate.qml @@ -43,7 +43,7 @@ TimelineContainer { openSavedFile(); } - onOpenContextMenu: openFileContext(root) + onOpenContextMenu: RoomManager.viewEventMenu(eventId, author, delegateType, plainText, "", "", mediaInfo.mimeType, progressInfo) function saveFileAs() { const dialog = fileDialog.createObject(QQC2.ApplicationWindow.overlay) diff --git a/src/qml/Component/Timeline/ImageDelegate.qml b/src/qml/Component/Timeline/ImageDelegate.qml index d2f4ec187..80caf7f4e 100644 --- a/src/qml/Component/Timeline/ImageDelegate.qml +++ b/src/qml/Component/Timeline/ImageDelegate.qml @@ -53,7 +53,7 @@ TimelineContainer { */ readonly property var maxHeight: Kirigami.Units.gridUnit * 30 - onOpenContextMenu: openFileContext(root) + onOpenContextMenu: RoomManager.viewEventMenu(eventId, author, delegateType, plainText, "", "", mediaInfo.mimeType, progressInfo) innerObject: Item { id: imageContainer diff --git a/src/qml/Component/Timeline/MessageDelegate.qml b/src/qml/Component/Timeline/MessageDelegate.qml index 79c1d2b27..9191396d7 100644 --- a/src/qml/Component/Timeline/MessageDelegate.qml +++ b/src/qml/Component/Timeline/MessageDelegate.qml @@ -36,7 +36,7 @@ TimelineContainer { */ required property bool showLinkPreview - onOpenContextMenu: openMessageContext(label.selectedText) + onOpenContextMenu: RoomManager.viewEventMenu(eventId, author, delegateType, plainText, display, label.selectedText) innerObject: ColumnLayout { Layout.maximumWidth: root.contentMaxWidth diff --git a/src/qml/Component/Timeline/TimelineContainer.qml b/src/qml/Component/Timeline/TimelineContainer.qml index f01081240..faf37b460 100644 --- a/src/qml/Component/Timeline/TimelineContainer.qml +++ b/src/qml/Component/Timeline/TimelineContainer.qml @@ -589,44 +589,6 @@ 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, { - author: root.author, - eventId: root.eventId, - file: file, - progressInfo: root.progressInfo, - plainText: root.plainText, - htmlText: root.display, - connection: root.connection, - }); - contextMenu.open(); - } - - /// Open context menu for normal message - function openMessageContext(selectedText) { - const contextMenu = messageDelegateContextMenu.createObject(root, { - selectedText: selectedText, - author: root.author, - eventId: root.eventId, - eventType: root.delegateType, - plainText: root.plainText, - htmlText: root.display, - connection: root.connection, - }); - contextMenu.open(); - } - function setHoverActionsToDelegate() { if (ListView.view.setHoverActionsToDelegate) { ListView.view.setHoverActionsToDelegate(root) diff --git a/src/qml/Component/Timeline/VideoDelegate.qml b/src/qml/Component/Timeline/VideoDelegate.qml index 9ee8dd20b..758db5d3d 100644 --- a/src/qml/Component/Timeline/VideoDelegate.qml +++ b/src/qml/Component/Timeline/VideoDelegate.qml @@ -54,7 +54,7 @@ TimelineContainer { */ readonly property var maxHeight: Kirigami.Units.gridUnit * 30 - onOpenContextMenu: openFileContext(vid) + onOpenContextMenu: RoomManager.viewEventMenu(eventId, author, delegateType, plainText, "", "", mediaInfo.mimeType, progressInfo) onDownloadedChanged: { if (downloaded) { diff --git a/src/qml/Menu/Timeline/FileDelegateContextMenu.qml b/src/qml/Menu/Timeline/FileDelegateContextMenu.qml index caa0331e8..8235f04ab 100644 --- a/src/qml/Menu/Timeline/FileDelegateContextMenu.qml +++ b/src/qml/Menu/Timeline/FileDelegateContextMenu.qml @@ -9,36 +9,40 @@ import org.kde.kirigami 2.15 as Kirigami import org.kde.neochat 1.0 +/** + * @brief The menu for media messages. + * + * This component just overloads the actions and nested actions of the base menu + * to what is required for a media item. + * + * @sa MessageDelegateContextMenu + */ MessageDelegateContextMenu { id: root - signal closeFullscreen - /** * @brief The MIME type of the media. */ property string mimeType - required property var file + /** + * @brief Progress info when downloading files. + * + * @sa Quotient::FileTransferInfo + */ required property var progressInfo + /** + * @brief The main list of menu item actions. + * + * Each action will be instantiated as a single line in the menu. + */ property list actions: [ Kirigami.Action { text: i18n("Open Externally") icon.name: "document-open" onTriggered: { - if (file.downloaded) { - if (!UrlHelper.openUrl(progressInfo.localPath)) { - UrlHelper.openUrl(progressInfo.localDir); - } - } else { - file.onDownloadedChanged.connect(function() { - if (!UrlHelper.openUrl(progressInfo.localPath)) { - UrlHelper.openUrl(progressInfo.localDir); - } - }); - currentRoom.downloadFile(eventId, StandardPaths.writableLocation(StandardPaths.CacheLocation) + "/" + eventId.replace(":", "_").replace("/", "_").replace("+", "_") + currentRoom.fileNameToDownload(eventId)) - } + currentRoom.openEventMediaExternally(root.eventId) } }, Kirigami.Action { @@ -56,21 +60,14 @@ MessageDelegateContextMenu { onTriggered: { currentRoom.chatBoxReplyId = eventId currentRoom.chatBoxEditId = "" - root.closeFullscreen() + RoomManager.requestFullScreenClose() } }, Kirigami.Action { text: i18n("Copy") icon.name: "edit-copy" onTriggered: { - if(file.downloaded) { - Clipboard.setImage(progressInfo.localPath) - } else { - file.onDownloadedChanged.connect(function() { - Clipboard.setImage(progressInfo.localPath) - }); - currentRoom.downloadFile(eventId, StandardPaths.writableLocation(StandardPaths.CacheLocation) + "/" + eventId.replace(":", "_").replace("/", "_").replace("+", "_") + currentRoom.fileNameToDownload(eventId)) - } + currentRoom.copyEventMedia(root.eventId) } }, Kirigami.Action { @@ -99,12 +96,17 @@ MessageDelegateContextMenu { } ] + /** + * @brief The list of menu item actions that have sub-actions. + * + * Each action will be instantiated as a single line that opens a sub menu. + */ property list nestedActions: [ ShareAction { id: shareAction inputData: { 'urls': [], - 'mimeType': [root.mimeType ? root.mimeType : root.file.mediaINfo.mimeType] + 'mimeType': [root.mimeType] } property string filename: StandardPaths.writableLocation(StandardPaths.CacheLocation) + "/" + eventId.replace(":", "_").replace("/", "_").replace("+", "_") + currentRoom.fileNameToDownload(eventId); @@ -114,11 +116,12 @@ MessageDelegateContextMenu { Component.onCompleted: { shareAction.inputData = { urls: [filename], - mimeType: [root.mimeType ? root.mimeType : root.file.mediaINfo.mimeType] + mimeType: [root.mimeType] }; } } ] + Component { id: saveAsDialog FileDialog { diff --git a/src/qml/Menu/Timeline/MessageDelegateContextMenu.qml b/src/qml/Menu/Timeline/MessageDelegateContextMenu.qml index fa11fb141..0a1be1d93 100644 --- a/src/qml/Menu/Timeline/MessageDelegateContextMenu.qml +++ b/src/qml/Menu/Timeline/MessageDelegateContextMenu.qml @@ -10,20 +10,82 @@ import org.kde.kirigamiaddons.labs.components 1.0 as KirigamiComponents import org.kde.neochat 1.0 +/** + * @brief The base menu for most message types. + * + * This menu supports showing a list of actions to be shown for a particular event + * delegate in a message timeline. The menu supports both desktop and mobile menus + * with different visuals appropriate to the platform. + * + * The menu supports both a list of main actions and the ability to define sub menus + * using the nested action parameter. + * + * For event types that need alternate actions this class can be used as a base and + * the actions and nested actions can be overwritten to show the alternate items. + */ Loader { id: root - required property var author - required property string eventId - property var eventType - property string selectedText: "" - required property string plainText - property string htmlText: undefined - + /** + * @brief The curent connection for the account accessing the event. + */ required property NeoChatConnection connection + /** + * @brief The matrix ID of the message event. + */ + required property string eventId + + /** + * @brief The message author. + * + * This should consist of the following: + * - id - The matrix ID of the author. + * - isLocalUser - Whether the author is the local user. + * - avatarSource - The mxc URL for the author's avatar in the current room. + * - avatarMediaId - The media ID of the author's avatar. + * - avatarUrl - The mxc URL for the author's avatar. + * - displayName - The display name of the author. + * - display - The name of the author. + * - color - The color for the author. + * - object - The Quotient::User object for the author. + * + * @sa Quotient::User + */ + required property var author + + /** + * @brief The delegate type of the message. + */ + required property int delegateType + + /** + * @brief The display text of the message as plain text. + */ + required property string plainText + + /** + * @brief The display text of the message as rich text. + */ + property string htmlText: "" + + /** + * @brief The text the user currently has selected. + */ + property string selectedText: "" + + /** + * @brief The list of menu item actions that have sub-actions. + * + * Each action will be instantiated as a single line that open a sub menu. + */ property list nestedActions + /** + * @brief The main list of menu item actions. + * + * Each action will be instantiated as a single line in the menu. + */ property list actions: [ Kirigami.Action { text: i18n("Edit") @@ -32,7 +94,7 @@ Loader { currentRoom.chatBoxEditId = eventId; currentRoom.chatBoxReplyId = ""; } - visible: author.id === root.connection.localUserId && (root.eventType === DelegateType.Emote || root.eventType === DelegateType.Message) + visible: author.isLocalUser && (root.delegateType === DelegateType.Emote || root.delegateType === DelegateType.Message) }, Kirigami.Action { text: i18n("Reply") @@ -53,13 +115,13 @@ Loader { width: Kirigami.Units.gridUnit * 25 }) page.chosen.connect(function(targetRoomId) { - root.connection.room(targetRoomId).postHtmlMessage(root.plainText, root.htmlText ? root.htmlText : root.plainText) + root.connection.room(targetRoomId).postHtmlMessage(root.plainText, root.htmlText.length > 0 ? root.htmlText : root.plainText) page.closeDialog() }) } }, Kirigami.Action { - visible: author.id === currentRoom.localUser.id || currentRoom.canSendState("redact") + visible: author.isLocalUser || currentRoom.canSendState("redact") text: i18n("Remove") icon.name: "edit-delete-remove" icon.color: "red" @@ -71,12 +133,12 @@ Loader { Kirigami.Action { text: i18n("Copy") icon.name: "edit-copy" - onTriggered: Clipboard.saveText(root.selectedText === "" ? root.plainText : root.selectedText) + onTriggered: Clipboard.saveText(root.selectedText.length > 0 ? root.plainText : root.selectedText) }, Kirigami.Action { text: i18nc("@action:button 'Report' as in 'Report this event to the administrators'", "Report") icon.name: "dialog-warning-symbolic" - visible: author.id !== currentRoom.localUser.id + visible: author.isLocalUser onTriggered: applicationWindow().pageStack.pushDialogLayer("qrc:/ReportSheet.qml", {room: currentRoom, eventId: eventId}, { title: i18nc("@title", "Report Message"), width: Kirigami.Units.gridUnit * 25 @@ -116,12 +178,10 @@ Loader { icon.name: modelData.icon.name onTriggered: modelData.trigger() } - onObjectAdded: { - menuItem.insertItem(0, object) - } + onObjectAdded: (index, object) => {menuItem.insertItem(0, object)} } } - onObjectAdded: { + onObjectAdded: (index, object) => { object.visible = false; menu.addMenu(object) } @@ -130,7 +190,6 @@ Loader { Repeater { model: root.actions QQC2.MenuItem { - id: menuItem visible: modelData.visible action: modelData onClicked: root.item.close(); @@ -147,7 +206,7 @@ Loader { Instantiator { model: WebShortcutModel { id: webshortcutmodel - selectedText: root.selectedText ? root.selectedText : root.plainText + selectedText: root.selectedText.length > 0 ? root.selectedText : root.plainText onOpenUrl: RoomManager.visitNonMatrix(url) } delegate: QQC2.MenuItem { diff --git a/src/qml/Page/RoomPage.qml b/src/qml/Page/RoomPage.qml index b7aad0107..7ca3997e8 100644 --- a/src/qml/Page/RoomPage.qml +++ b/src/qml/Page/RoomPage.qml @@ -208,6 +208,30 @@ Kirigami.Page { width: Kirigami.Units.gridUnit * 25 }); } + + function onShowMessageMenu(eventId, author, delegateType, plainText, htmlText, selectedText) { + const contextMenu = messageDelegateContextMenu.createObject(root, { + selectedText: selectedText, + author: author, + eventId: eventId, + delegateType: delegateType, + plainText: plainText, + htmlText: htmlText + }); + contextMenu.open(); + } + + function onShowFileMenu(eventId, author, delegateType, plainText, mimeType, progressInfo) { + const contextMenu = fileDelegateContextMenu.createObject(root, { + author: author, + eventId: eventId, + delegateType: delegateType, + plainText: plainText, + mimeType: mimeType, + progressInfo: progressInfo + }); + contextMenu.open(); + } } function showUserDetail(user) { @@ -221,4 +245,18 @@ Kirigami.Page { id: userDetailDialog UserDetailDialog {} } + + Component { + id: messageDelegateContextMenu + MessageDelegateContextMenu { + connection: root.connection + } + } + + Component { + id: fileDelegateContextMenu + FileDelegateContextMenu { + connection: root.connection + } + } } diff --git a/src/roommanager.cpp b/src/roommanager.cpp index cc6f05317..c07d876b3 100644 --- a/src/roommanager.cpp +++ b/src/roommanager.cpp @@ -5,6 +5,7 @@ #include "roommanager.h" #include "controller.h" +#include "enums/delegatetype.h" #include "models/messageeventmodel.h" #include "neochatconfig.h" #include "neochatroom.h" @@ -103,11 +104,34 @@ void RoomManager::maximizeMedia(int index) Q_EMIT showMaximizedMedia(index); } +void RoomManager::requestFullScreenClose() +{ + Q_EMIT closeFullScreen(); +} + void RoomManager::viewEventSource(const QString &eventId) { Q_EMIT showEventSource(eventId); } +void RoomManager::viewEventMenu(const QString &eventId, + const QVariantMap &author, + DelegateType::Type delegateType, + const QString &plainText, + const QString &htmlText, + const QString &selectedText, + const QString &mimeType, + const FileTransferInfo &progressInfo) +{ + if (delegateType == DelegateType::Image || delegateType == DelegateType::Video || delegateType == DelegateType::Audio + || delegateType == DelegateType::File) { + Q_EMIT showFileMenu(eventId, author, delegateType, plainText, mimeType, progressInfo); + return; + } + + Q_EMIT showMessageMenu(eventId, author, delegateType, plainText, htmlText, selectedText); +} + bool RoomManager::hasOpenRoom() const { return m_currentRoom != nullptr; diff --git a/src/roommanager.h b/src/roommanager.h index d13716546..44169fd22 100644 --- a/src/roommanager.h +++ b/src/roommanager.h @@ -6,9 +6,11 @@ #include #include #include +#include #include #include "chatdocumenthandler.h" +#include "enums/delegatetype.h" #include "models/mediamessagefiltermodel.h" #include "models/messageeventmodel.h" #include "models/messagefiltermodel.h" @@ -195,11 +197,28 @@ public: */ Q_INVOKABLE void maximizeMedia(int index); + /** + * @brief Request that any full screen overlay currently open closes. + */ + Q_INVOKABLE void requestFullScreenClose(); + /** * @brief Show the JSON source for the given event Matrix ID */ Q_INVOKABLE void viewEventSource(const QString &eventId); + /** + * @brief Show a conterxt menu for the given event. + */ + Q_INVOKABLE void viewEventMenu(const QString &eventId, + const QVariantMap &author, + DelegateType::Type delegateType, + const QString &plainText, + const QString &htmlText = {}, + const QString &selectedText = {}, + const QString &mimeType = {}, + const FileTransferInfo &progressInfo = {}); + /** * @brief Call this when the current used connection is dropped. */ @@ -266,11 +285,36 @@ Q_SIGNALS: */ void showMaximizedMedia(int index); + /** + * @brief Request that any full screen overlay closes. + */ + void closeFullScreen(); + /** * @brief Request the JSON source for the given event ID is shown. */ void showEventSource(const QString &eventId); + /** + * @brief Request to show a menu for the given event. + */ + void showMessageMenu(const QString &eventId, + const QVariantMap &author, + DelegateType::Type delegateType, + const QString &plainText, + const QString &htmlText, + const QString &selectedText); + + /** + * @brief Request to show a menu for the given media event. + */ + void showFileMenu(const QString &eventId, + const QVariantMap &author, + DelegateType::Type delegateType, + const QString &plainText, + const QString &mimeType, + const FileTransferInfo &progressInfo); + /** * @brief Show the direct chat confirmation dialog. *