From 55e22ea94861e297a882b29cc2dc5e8be0912b55 Mon Sep 17 00:00:00 2001 From: miruka Date: Mon, 20 Jul 2020 22:58:02 -0400 Subject: [PATCH] Add openMessagesLinksOrFilesExternally keybind This required us to set the media downloaded local path on events entirely from python instead of simply lazy-fetching them when needed from QML, due to pyotherside's async nature and files that must be open in a certain order. --- src/backend/backend.py | 7 ++- src/backend/matrix_client.py | 10 +++- src/backend/media_cache.py | 28 ++------- src/backend/models/items.py | 17 +++--- src/backend/nio_callbacks.py | 17 +++++- src/backend/user_files.py | 21 +++---- src/gui/Pages/Chat/Timeline/EventDelegate.qml | 15 +---- src/gui/Pages/Chat/Timeline/EventList.qml | 60 +++++++++++++------ 8 files changed, 96 insertions(+), 79 deletions(-) diff --git a/src/backend/backend.py b/src/backend/backend.py index 715b026b..c5a6e0b7 100644 --- a/src/backend/backend.py +++ b/src/backend/backend.py @@ -18,7 +18,7 @@ from .matrix_client import MatrixClient from .media_cache import MediaCache from .models import SyncId from .models.filters import FieldSubstringFilter -from .models.items import Account +from .models.items import Account, Event from .models.model import Model from .models.model_store import ModelStore from .presence import Presence @@ -86,6 +86,9 @@ class Backend: presences: A `{user_id: Presence}` dict for storing presence info about matrix users registered on Mirage. + + mxc_events: A dict storing media `Event` model items for any account + that have the same mxc URI """ def __init__(self) -> None: @@ -117,6 +120,8 @@ class Backend: self.concurrent_get_presence_limit = asyncio.BoundedSemaphore(8) + self.mxc_events: DefaultDict[str, List[Event]] = DefaultDict(list) + def __repr__(self) -> str: return f"{type(self).__name__}(clients={self.clients!r})" diff --git a/src/backend/matrix_client.py b/src/backend/matrix_client.py index 9fda547b..978599eb 100644 --- a/src/backend/matrix_client.py +++ b/src/backend/matrix_client.py @@ -693,7 +693,9 @@ class MatrixClient(nio.AsyncClient): await asyncio.sleep(0.1) upload_item.status = UploadStatus.Caching - await Media.from_existing_file(self.backend.media_cache, url, path) + local_media = await Media.from_existing_file( + self.backend.media_cache, url, path, + ) kind = (mime or "").split("/")[0] @@ -843,6 +845,7 @@ class MatrixClient(nio.AsyncClient): media_size = content["info"]["size"], media_mime = content["info"]["mimetype"], media_crypt_dict = crypt_dict, + media_local_path = await local_media.get_local(), thumbnail_url = thumb_url, thumbnail_crypt_dict = thumb_crypt_dict, @@ -1926,7 +1929,7 @@ class MatrixClient(nio.AsyncClient): event_id: str = "", override_fetch_profile: Optional[bool] = None, **fields, - ) -> None: + ) -> Event: """Register/update a `nio.Event` as a `models.items.Event` object.""" await self.register_nio_room(room) @@ -1988,7 +1991,7 @@ class MatrixClient(nio.AsyncClient): # Alerts if from_us or await self.event_is_past(ev): - return + return item mentions_us = HTML.user_id_link_in_html(item.content, self.user_id) AlertRequested(high_importance=mentions_us) @@ -2000,3 +2003,4 @@ class MatrixClient(nio.AsyncClient): room_item.local_highlights = True await self.update_account_unread_counts() + return item diff --git a/src/backend/media_cache.py b/src/backend/media_cache.py index 25274cf0..afbfaa1e 100644 --- a/src/backend/media_cache.py +++ b/src/backend/media_cache.py @@ -47,30 +47,6 @@ class MediaCache: self.downloads_dir.mkdir(parents=True, exist_ok=True) - async def get_local_media(self, mxc: str, title: str) -> Optional[Path]: - """Return `Media.get_local()`'s result for QML.""" - - media = Media(self, mxc, title, None) - - try: - return await media.get_local() - except FileNotFoundError: - return None - - - async def get_local_thumbnail( - self, mxc: str, title: str, width: int, height: int, - ) -> Optional[Path]: - """Return `Thumbnail.get_local()`'s result for QML.""" - - th = Thumbnail(self, mxc, title, None, (round(width), round(height))) - - try: - return await th.get_local() - except FileNotFoundError: - return None - - async def get_media( self, mxc: str, @@ -166,6 +142,10 @@ class Media: await file.write(data) done() + if type(self) is Media: + for event in self.cache.backend.mxc_events[self.mxc]: + event.media_local_path = self.local_path + return self.local_path diff --git a/src/backend/models/items.py b/src/backend/models/items.py index b8027d78..5f0793cd 100644 --- a/src/backend/models/items.py +++ b/src/backend/models/items.py @@ -279,14 +279,15 @@ class Event(ModelItem): is_local_echo: bool = False source: Optional[nio.Event] = None - media_url: str = "" - media_title: str = "" - media_width: int = 0 - media_height: int = 0 - media_duration: int = 0 - media_size: int = 0 - media_mime: str = "" - media_crypt_dict: Dict[str, Any] = field(default_factory=dict) + media_url: str = "" + media_title: str = "" + media_width: int = 0 + media_height: int = 0 + media_duration: int = 0 + media_size: int = 0 + media_mime: str = "" + media_crypt_dict: Dict[str, Any] = field(default_factory=dict) + media_local_path: Union[str, Path] = "" thumbnail_url: str = "" thumbnail_mime: str = "" diff --git a/src/backend/nio_callbacks.py b/src/backend/nio_callbacks.py index 4b389f40..190d8503 100644 --- a/src/backend/nio_callbacks.py +++ b/src/backend/nio_callbacks.py @@ -6,12 +6,14 @@ import logging as log from dataclasses import dataclass, field from datetime import datetime, timedelta from html import escape +from pathlib import Path from typing import TYPE_CHECKING, Dict, List, Optional, Tuple, Union from urllib.parse import quote import nio from .html_markdown import HTML_PROCESSOR +from .media_cache import Media from .models.items import TypeSpecifier from .presence import Presence from .pyotherside_events import DevicesUpdated @@ -190,7 +192,17 @@ class NioCallbacks: thumb_info = info.get("thumbnail_info", {}) thumb_crypt_dict = info.get("thumbnail_file", {}) - await self.client.register_nio_event( + try: + media_local_path: Union[Path, str] = await Media( + cache = self.client.backend.media_cache, + mxc = ev.url, + title = ev.body, + crypt_dict = media_crypt_dict, + ).get_local() + except FileNotFoundError: + media_local_path = "" + + item = await self.client.register_nio_event( room, ev, content = "", @@ -204,6 +216,7 @@ class NioCallbacks: media_size = info.get("size") or 0, media_mime = info.get("mimetype") or "", media_crypt_dict = media_crypt_dict, + media_local_path = media_local_path, thumbnail_url = info.get("thumbnail_url") or thumb_crypt_dict.get("url") or "", @@ -214,6 +227,8 @@ class NioCallbacks: thumbnail_crypt_dict = thumb_crypt_dict, ) + self.client.backend.mxc_events[ev.url].append(item) + async def onRoomEncryptedMedia( self, room: nio.MatrixRoom, ev: nio.RoomEncryptedMedia, diff --git a/src/backend/user_files.py b/src/backend/user_files.py index e89a68f3..a8483b14 100644 --- a/src/backend/user_files.py +++ b/src/backend/user_files.py @@ -357,16 +357,17 @@ class UISettings(JSONDataFile): "10": f"{alt_or_cmd()}+0", }, - "unfocusOrDeselectAllMessages": ["Ctrl+D"], - "focusPreviousMessage": ["Ctrl+Up", "Ctrl+K"], - "focusNextMessage": ["Ctrl+Down", "Ctrl+J"], - "toggleSelectMessage": ["Ctrl+Space"], - "selectMessagesUntilHere": ["Ctrl+Shift+Space"], - "removeFocusedOrSelectedMessages": ["Ctrl+R", "Alt+Del"], - "replyToFocusedOrLastMessage": ["Ctrl+Q"], # Q for Quote - "debugFocusedMessage": ["Ctrl+Shift+D"], - "openMessagesLinksOrFiles": ["Ctrl+O"], - "clearRoomMessages": ["Ctrl+L"], + "unfocusOrDeselectAllMessages": ["Ctrl+D"], + "focusPreviousMessage": ["Ctrl+Up", "Ctrl+K"], + "focusNextMessage": ["Ctrl+Down", "Ctrl+J"], + "toggleSelectMessage": ["Ctrl+Space"], + "selectMessagesUntilHere": ["Ctrl+Shift+Space"], + "removeFocusedOrSelectedMessages": ["Ctrl+R", "Alt+Del"], + "replyToFocusedOrLastMessage": ["Ctrl+Q"], # Q → Quote + "debugFocusedMessage": ["Ctrl+Shift+D"], + "openMessagesLinksOrFiles": ["Ctrl+O"], + "openMessagesLinksOrFilesExternally": ["Ctrl+Shift+O"], + "clearRoomMessages": ["Ctrl+L"], "sendFile": ["Alt+S"], "sendFileFromPathInClipboard": ["Alt+Shift+S"], diff --git a/src/gui/Pages/Chat/Timeline/EventDelegate.qml b/src/gui/Pages/Chat/Timeline/EventDelegate.qml index c9a53342..82aee287 100644 --- a/src/gui/Pages/Chat/Timeline/EventDelegate.qml +++ b/src/gui/Pages/Chat/Timeline/EventDelegate.qml @@ -141,26 +141,15 @@ HColumnLayout { property var media: [] property string link: "" - property var localPath: null - property Future getLocalFuture: null readonly property bool isEncryptedMedia: Object.keys(JSON.parse(model.media_crypt_dict)).length > 0 onClosed: { - if (getLocalFuture) getLocalFuture.cancel() media = [] link = "" } - onOpened: if (media.length === 3 && media[1].startsWith("mxc://")) { - getLocalFuture = py.callCoro( - "media_cache.get_local_media", - [media[1], media[2]], - path => { localPath = path; getLocalFuture = null }, - ) - } - HMenuItem { icon.name: "toggle-select-message" text: eventDelegate.checked ? qsTr("Deselect") : qsTr("Select") @@ -184,10 +173,10 @@ HColumnLayout { HMenuItem { icon.name: "copy-local-path" text: qsTr("Copy local path") - visible: Boolean(contextMenu.localPath) + visible: Boolean(model.media_local_path) onTriggered: Clipboard.text = - contextMenu.localPath.replace(/^file:\/\//, "") + model.media_local_path.replace(/^file:\/\//, "") } HMenuItem { diff --git a/src/gui/Pages/Chat/Timeline/EventList.qml b/src/gui/Pages/Chat/Timeline/EventList.qml index e84302fb..3d7a861c 100644 --- a/src/gui/Pages/Chat/Timeline/EventList.qml +++ b/src/gui/Pages/Chat/Timeline/EventList.qml @@ -127,26 +127,10 @@ Rectangle { HShortcut { sequences: window.settings.keys.openMessagesLinksOrFiles onActivated: { - let indice = [] + const indice = + eventList.getFocusedOrSelectedOrLastMediaEvents(true) - if (eventList.selectedCount) { - indice = eventList.checkedIndice - } else if (eventList.currentIndex !== -1) { - indice = [eventList.currentIndex] - } else { - // Find most recent event that's a media or contains links - for (let i = 0; i < eventList.model.count && i <= 1000; i++) { - const ev = eventList.model.get(i) - const links = JSON.parse(ev.links) - - if (ev.media_url || ev.thumbnail_url || links.length) { - indice = [i] - break - } - } - } - - for (const i of indice.sort().reverse()) { + for (const i of Array.from(indice).sort().reverse()) { const event = eventList.model.get(i) if (event.media_url || event.thumbnail_url) { @@ -166,6 +150,26 @@ Rectangle { } } + HShortcut { + sequences: window.settings.keys.openMessagesLinksOrFilesExternally + onActivated: { + const indice = + eventList.getFocusedOrSelectedOrLastMediaEvents(true) + + for (const i of Array.from(indice).sort().reverse()) { + const event = eventList.model.get(i) + + if (event.media_url) { + eventList.openMediaExternally(event) + continue + } + + for (const url of JSON.parse(event.links)) + Qt.openUrlExternally(url) + } + } + } + HShortcut { active: eventList.currentItem sequences: window.settings.keys.debugFocusedMessage @@ -318,6 +322,19 @@ Rectangle { } } + function getFocusedOrSelectedOrLastMediaEvents(acceptLinks=false) { + if (eventList.selectedCount) return eventList.checkedIndice + if (eventList.currentIndex !== -1) return [eventList.currentIndex] + + // Find most recent event that's a media or contains links + for (let i = 0; i < eventList.model.count && i <= 1000; i++) { + const ev = eventList.model.get(i) + const links = JSON.parse(ev.links) + + if (ev.media_url || (acceptLinks && links.length)) return [i] + } + } + function getMediaType(event) { if (event.event_type === "RoomAvatarEvent") return Utils.Media.Image @@ -402,6 +419,11 @@ Rectangle { } function getLocalOrDownloadMedia(event, callback) { + if (event.media_local_path) { + callback(event.media_local_path) + return + } + print("Downloading " + event.media_url + " ...") const args = [