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.
This commit is contained in:
miruka 2020-07-20 22:58:02 -04:00
parent 89e6931d9d
commit 55e22ea948
8 changed files with 96 additions and 79 deletions

View File

@ -18,7 +18,7 @@ from .matrix_client import MatrixClient
from .media_cache import MediaCache from .media_cache import MediaCache
from .models import SyncId from .models import SyncId
from .models.filters import FieldSubstringFilter from .models.filters import FieldSubstringFilter
from .models.items import Account from .models.items import Account, Event
from .models.model import Model from .models.model import Model
from .models.model_store import ModelStore from .models.model_store import ModelStore
from .presence import Presence from .presence import Presence
@ -86,6 +86,9 @@ class Backend:
presences: A `{user_id: Presence}` dict for storing presence info about presences: A `{user_id: Presence}` dict for storing presence info about
matrix users registered on Mirage. 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: def __init__(self) -> None:
@ -117,6 +120,8 @@ class Backend:
self.concurrent_get_presence_limit = asyncio.BoundedSemaphore(8) self.concurrent_get_presence_limit = asyncio.BoundedSemaphore(8)
self.mxc_events: DefaultDict[str, List[Event]] = DefaultDict(list)
def __repr__(self) -> str: def __repr__(self) -> str:
return f"{type(self).__name__}(clients={self.clients!r})" return f"{type(self).__name__}(clients={self.clients!r})"

View File

@ -693,7 +693,9 @@ class MatrixClient(nio.AsyncClient):
await asyncio.sleep(0.1) await asyncio.sleep(0.1)
upload_item.status = UploadStatus.Caching 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] kind = (mime or "").split("/")[0]
@ -843,6 +845,7 @@ class MatrixClient(nio.AsyncClient):
media_size = content["info"]["size"], media_size = content["info"]["size"],
media_mime = content["info"]["mimetype"], media_mime = content["info"]["mimetype"],
media_crypt_dict = crypt_dict, media_crypt_dict = crypt_dict,
media_local_path = await local_media.get_local(),
thumbnail_url = thumb_url, thumbnail_url = thumb_url,
thumbnail_crypt_dict = thumb_crypt_dict, thumbnail_crypt_dict = thumb_crypt_dict,
@ -1926,7 +1929,7 @@ class MatrixClient(nio.AsyncClient):
event_id: str = "", event_id: str = "",
override_fetch_profile: Optional[bool] = None, override_fetch_profile: Optional[bool] = None,
**fields, **fields,
) -> None: ) -> Event:
"""Register/update a `nio.Event` as a `models.items.Event` object.""" """Register/update a `nio.Event` as a `models.items.Event` object."""
await self.register_nio_room(room) await self.register_nio_room(room)
@ -1988,7 +1991,7 @@ class MatrixClient(nio.AsyncClient):
# Alerts # Alerts
if from_us or await self.event_is_past(ev): 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) mentions_us = HTML.user_id_link_in_html(item.content, self.user_id)
AlertRequested(high_importance=mentions_us) AlertRequested(high_importance=mentions_us)
@ -2000,3 +2003,4 @@ class MatrixClient(nio.AsyncClient):
room_item.local_highlights = True room_item.local_highlights = True
await self.update_account_unread_counts() await self.update_account_unread_counts()
return item

View File

@ -47,30 +47,6 @@ class MediaCache:
self.downloads_dir.mkdir(parents=True, exist_ok=True) 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( async def get_media(
self, self,
mxc: str, mxc: str,
@ -166,6 +142,10 @@ class Media:
await file.write(data) await file.write(data)
done() 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 return self.local_path

View File

@ -279,14 +279,15 @@ class Event(ModelItem):
is_local_echo: bool = False is_local_echo: bool = False
source: Optional[nio.Event] = None source: Optional[nio.Event] = None
media_url: str = "" media_url: str = ""
media_title: str = "" media_title: str = ""
media_width: int = 0 media_width: int = 0
media_height: int = 0 media_height: int = 0
media_duration: int = 0 media_duration: int = 0
media_size: int = 0 media_size: int = 0
media_mime: str = "" media_mime: str = ""
media_crypt_dict: Dict[str, Any] = field(default_factory=dict) media_crypt_dict: Dict[str, Any] = field(default_factory=dict)
media_local_path: Union[str, Path] = ""
thumbnail_url: str = "" thumbnail_url: str = ""
thumbnail_mime: str = "" thumbnail_mime: str = ""

View File

@ -6,12 +6,14 @@ import logging as log
from dataclasses import dataclass, field from dataclasses import dataclass, field
from datetime import datetime, timedelta from datetime import datetime, timedelta
from html import escape from html import escape
from pathlib import Path
from typing import TYPE_CHECKING, Dict, List, Optional, Tuple, Union from typing import TYPE_CHECKING, Dict, List, Optional, Tuple, Union
from urllib.parse import quote from urllib.parse import quote
import nio import nio
from .html_markdown import HTML_PROCESSOR from .html_markdown import HTML_PROCESSOR
from .media_cache import Media
from .models.items import TypeSpecifier from .models.items import TypeSpecifier
from .presence import Presence from .presence import Presence
from .pyotherside_events import DevicesUpdated from .pyotherside_events import DevicesUpdated
@ -190,7 +192,17 @@ class NioCallbacks:
thumb_info = info.get("thumbnail_info", {}) thumb_info = info.get("thumbnail_info", {})
thumb_crypt_dict = info.get("thumbnail_file", {}) 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, room,
ev, ev,
content = "", content = "",
@ -204,6 +216,7 @@ class NioCallbacks:
media_size = info.get("size") or 0, media_size = info.get("size") or 0,
media_mime = info.get("mimetype") or "", media_mime = info.get("mimetype") or "",
media_crypt_dict = media_crypt_dict, media_crypt_dict = media_crypt_dict,
media_local_path = media_local_path,
thumbnail_url = thumbnail_url =
info.get("thumbnail_url") or thumb_crypt_dict.get("url") or "", info.get("thumbnail_url") or thumb_crypt_dict.get("url") or "",
@ -214,6 +227,8 @@ class NioCallbacks:
thumbnail_crypt_dict = thumb_crypt_dict, thumbnail_crypt_dict = thumb_crypt_dict,
) )
self.client.backend.mxc_events[ev.url].append(item)
async def onRoomEncryptedMedia( async def onRoomEncryptedMedia(
self, room: nio.MatrixRoom, ev: nio.RoomEncryptedMedia, self, room: nio.MatrixRoom, ev: nio.RoomEncryptedMedia,

View File

@ -357,16 +357,17 @@ class UISettings(JSONDataFile):
"10": f"{alt_or_cmd()}+0", "10": f"{alt_or_cmd()}+0",
}, },
"unfocusOrDeselectAllMessages": ["Ctrl+D"], "unfocusOrDeselectAllMessages": ["Ctrl+D"],
"focusPreviousMessage": ["Ctrl+Up", "Ctrl+K"], "focusPreviousMessage": ["Ctrl+Up", "Ctrl+K"],
"focusNextMessage": ["Ctrl+Down", "Ctrl+J"], "focusNextMessage": ["Ctrl+Down", "Ctrl+J"],
"toggleSelectMessage": ["Ctrl+Space"], "toggleSelectMessage": ["Ctrl+Space"],
"selectMessagesUntilHere": ["Ctrl+Shift+Space"], "selectMessagesUntilHere": ["Ctrl+Shift+Space"],
"removeFocusedOrSelectedMessages": ["Ctrl+R", "Alt+Del"], "removeFocusedOrSelectedMessages": ["Ctrl+R", "Alt+Del"],
"replyToFocusedOrLastMessage": ["Ctrl+Q"], # Q for Quote "replyToFocusedOrLastMessage": ["Ctrl+Q"], # Q → Quote
"debugFocusedMessage": ["Ctrl+Shift+D"], "debugFocusedMessage": ["Ctrl+Shift+D"],
"openMessagesLinksOrFiles": ["Ctrl+O"], "openMessagesLinksOrFiles": ["Ctrl+O"],
"clearRoomMessages": ["Ctrl+L"], "openMessagesLinksOrFilesExternally": ["Ctrl+Shift+O"],
"clearRoomMessages": ["Ctrl+L"],
"sendFile": ["Alt+S"], "sendFile": ["Alt+S"],
"sendFileFromPathInClipboard": ["Alt+Shift+S"], "sendFileFromPathInClipboard": ["Alt+Shift+S"],

View File

@ -141,26 +141,15 @@ HColumnLayout {
property var media: [] property var media: []
property string link: "" property string link: ""
property var localPath: null
property Future getLocalFuture: null
readonly property bool isEncryptedMedia: readonly property bool isEncryptedMedia:
Object.keys(JSON.parse(model.media_crypt_dict)).length > 0 Object.keys(JSON.parse(model.media_crypt_dict)).length > 0
onClosed: { onClosed: {
if (getLocalFuture) getLocalFuture.cancel()
media = [] media = []
link = "" 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 { HMenuItem {
icon.name: "toggle-select-message" icon.name: "toggle-select-message"
text: eventDelegate.checked ? qsTr("Deselect") : qsTr("Select") text: eventDelegate.checked ? qsTr("Deselect") : qsTr("Select")
@ -184,10 +173,10 @@ HColumnLayout {
HMenuItem { HMenuItem {
icon.name: "copy-local-path" icon.name: "copy-local-path"
text: qsTr("Copy local path") text: qsTr("Copy local path")
visible: Boolean(contextMenu.localPath) visible: Boolean(model.media_local_path)
onTriggered: onTriggered:
Clipboard.text = Clipboard.text =
contextMenu.localPath.replace(/^file:\/\//, "") model.media_local_path.replace(/^file:\/\//, "")
} }
HMenuItem { HMenuItem {

View File

@ -127,26 +127,10 @@ Rectangle {
HShortcut { HShortcut {
sequences: window.settings.keys.openMessagesLinksOrFiles sequences: window.settings.keys.openMessagesLinksOrFiles
onActivated: { onActivated: {
let indice = [] const indice =
eventList.getFocusedOrSelectedOrLastMediaEvents(true)
if (eventList.selectedCount) { for (const i of Array.from(indice).sort().reverse()) {
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()) {
const event = eventList.model.get(i) const event = eventList.model.get(i)
if (event.media_url || event.thumbnail_url) { 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 { HShortcut {
active: eventList.currentItem active: eventList.currentItem
sequences: window.settings.keys.debugFocusedMessage 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) { function getMediaType(event) {
if (event.event_type === "RoomAvatarEvent") if (event.event_type === "RoomAvatarEvent")
return Utils.Media.Image return Utils.Media.Image
@ -402,6 +419,11 @@ Rectangle {
} }
function getLocalOrDownloadMedia(event, callback) { function getLocalOrDownloadMedia(event, callback) {
if (event.media_local_path) {
callback(event.media_local_path)
return
}
print("Downloading " + event.media_url + " ...") print("Downloading " + event.media_url + " ...")
const args = [ const args = [