Make media openable with the openLinks keybind

This involved a refactoring to move all the media handling functions
(downloading, opening externally, etc) out of the Event delegates and
into the EventList, which manage keybinds instead.

This should also be better for performance since all these functions are
no longer duplicated for every Event in view.

Other user-noticable change: clicking on non-image media will
always download and open them no matter if the room is encrypted or not,
instead of opening non-encrypted files in browser like before. It will
be possible to still do that with an "open externally" command later.
This commit is contained in:
miruka 2020-07-19 20:10:31 -04:00
parent fe08014697
commit de6d8fa59d
9 changed files with 207 additions and 169 deletions

View File

@ -1,10 +1,15 @@
# TODO
- Image viewer:
- fix gifs
- double click on img to open fullscreen
- open externally in context menu in timeline thumbnail
- hflickable support kinetic scrolling disabler and speed/decel settings
- buttons
- keyboard controls
- prevent drag-scrolling timeline when image opened
- eventfile middle click
- right click in dark space of image viewer
- clipboard preview doesn't update when copied image changes until second time
- Avatar tooltip can get displayed in front of presence menu

View File

@ -3,6 +3,7 @@
import QtQuick 2.12
import QtQuick.Layouts 1.12
import QtAV 1.7
import "../../.."
import "../../../Base"
import "../../../Base/MediaPlayer"
@ -12,6 +13,6 @@ AudioPlayer {
HoverHandler {
onHoveredChanged:
eventDelegate.hoveredMediaTypeUrl =
hovered ? [EventDelegate.Media.Audio, audio.source] : []
hovered ? [Utils.Media.Audio, audio.source] : []
}
}

View File

@ -9,8 +9,6 @@ import "../../../Base"
HColumnLayout {
id: eventDelegate
enum Media { Page, File, Image, Video, Audio }
property var hoveredMediaTypeUrl: []
property var fetchProfilesFuture: null
@ -171,19 +169,19 @@ HColumnLayout {
text:
contextMenu.media.length < 1 ? "" :
contextMenu.media[0] === EventDelegate.Media.Page ?
contextMenu.media[0] === Utils.Media.Page ?
qsTr("Copy page address") :
contextMenu.media[0] === EventDelegate.Media.File ?
contextMenu.media[0] === Utils.Media.File ?
qsTr("Copy file address") :
contextMenu.media[0] === EventDelegate.Media.Image ?
contextMenu.media[0] === Utils.Media.Image ?
qsTr("Copy image address") :
contextMenu.media[0] === EventDelegate.Media.Video ?
contextMenu.media[0] === Utils.Media.Video ?
qsTr("Copy video address") :
contextMenu.media[0] === EventDelegate.Media.Audio ?
contextMenu.media[0] === Utils.Media.Audio ?
qsTr("Copy audio address") :
qsTr("Copy media address")

View File

@ -3,6 +3,7 @@
import QtQuick 2.12
import QtQuick.Layouts 1.12
import CppUtils 0.1
import "../../.."
import "../../../Base"
import "../../../Base/HTile"
@ -11,11 +12,6 @@ HTile {
property EventMediaLoader loader
readonly property bool cryptDict:
JSON.parse(loader.singleMediaInfo.media_crypt_dict)
readonly property bool isEncrypted: ! utils.isEmptyObject(cryptDict)
width: Math.min(
eventDelegate.width,
@ -50,7 +46,12 @@ HTile {
onRightClicked: eventDelegate.openContextMenu()
onLeftClicked:
eventList.selectedCount ?
eventDelegate.toggleChecked() : download(Qt.openUrlExternally)
eventDelegate.toggleChecked() :
loader.isMedia ?
eventList.openMediaExternally(singleMediaInfo) :
Qt.openUrlExternally(loader.mediaUrl)
onHoveredChanged: {
if (! hovered) {
@ -59,8 +60,10 @@ HTile {
}
eventDelegate.hoveredMediaTypeUrl = [
EventDelegate.Media.File,
loader.downloadedPath.replace(/^file:\/\//, "") || loader.mediaUrl
Utils.Media.File,
// XXX
// loader.downloadedPath.replace(/^file:\/\//, "") ||
loader.mediaUrl
]
}

View File

@ -1,6 +1,7 @@
// SPDX-License-Identifier: LGPL-3.0-or-later
import QtQuick 2.12
import "../../.."
import "../../../Base"
HMxcImage {
@ -8,8 +9,6 @@ HMxcImage {
property EventMediaLoader loader
readonly property bool isEncrypted: ! utils.isEmptyObject(cryptDict)
readonly property real maxHeight:
eventList.height * theme.chat.message.thumbnailMaxHeightRatio
@ -43,69 +42,13 @@ HMxcImage {
Math.max(maxHeight, theme.chat.message.thumbnailMinSize.height),
)
function getOpenUrl(callback) {
if (image.isEncrypted && loader.mediaUrl) {
loader.download(callback)
return
}
if (image.isEncrypted) {
callback(image.cachedPath)
return
}
const toOpen = loader.mediaUrl || loader.thumbnailMxc
const isMxc = toOpen.startsWith("mxc://")
isMxc ?
py.callClientCoro(chat.userId, "mxc_to_http", [toOpen], callback) :
callback(toOpen)
}
function openUrlExternally() {
getOpenUrl(Qt.openUrlExternally)
}
function openImageViewer() {
utils.makePopup(
"Popups/ImageViewerPopup.qml",
{
thumbnailTitle: loader.thumbnailTitle,
thumbnailMxc: loader.thumbnailMxc,
thumbnailPath: image.cachedPath,
thumbnailCryptDict:
JSON.parse(loader.singleMediaInfo.thumbnail_crypt_dict),
fullTitle: loader.title,
// The thumbnail/cached path will be the full GIF
fullMxc: animated ? "" : loader.mediaUrl,
fullCryptDict:
JSON.parse(loader.singleMediaInfo.media_crypt_dict),
overallSize: Qt.size(
loader.singleMediaInfo.media_width ||
loader.singleMediaInfo.thumbnail_width ||
implicitWidth ||
800,
loader.singleMediaInfo.media_height ||
loader.singleMediaInfo.thumbnail_height ||
implicitHeight ||
600,
)
},
obj => { obj.openExternallyRequested.connect(openUrlExternally) },
)
}
width: fitSize.width
height: fitSize.height
horizontalAlignment: Image.AlignLeft
title: thumbnail ? loader.thumbnailTitle : loader.title
animated: loader.singleMediaInfo.media_mime === "image/gif" ||
utils.urlExtension(loader.mediaUrl).toLowerCase() === "gif"
animated: eventList.isAnimated(loader.singleMediaInfo)
thumbnail: ! animated && loader.thumbnailMxc
mxc: thumbnail ?
(loader.thumbnailMxc || loader.mediaUrl) :
@ -116,30 +59,36 @@ HMxcImage {
loader.singleMediaInfo.media_crypt_dict
)
onCachedPathChanged:
eventList.thumbnailCachedPaths[loader.singleMediaInfo.id] = cachedPath
TapHandler {
acceptedButtons: Qt.LeftButton
acceptedModifiers: Qt.NoModifier
gesturePolicy: TapHandler.ReleaseWithinBounds
onTapped:
eventList.selectedCount ?
eventDelegate.toggleChecked() :
openImageViewer()
eventList.openImageViewer(singleMediaInfo)
gesturePolicy: TapHandler.ReleaseWithinBounds
}
TapHandler {
acceptedButtons: Qt.MiddleButton
acceptedModifiers: Qt.NoModifier
onTapped: getOpenUrl(Qt.openUrlExternally)
gesturePolicy: TapHandler.ReleaseWithinBounds
onTapped: {
loader.isMedia ?
eventList.openMediaExternally(singleMediaInfo) :
Qt.openUrlExternally(loader.mediaUrl)
}
}
TapHandler {
acceptedModifiers: Qt.ShiftModifier
gesturePolicy: TapHandler.ReleaseWithinBounds
onTapped:
eventList.checkFromLastToHere(singleMediaInfo.index)
gesturePolicy: TapHandler.ReleaseWithinBounds
}
HoverHandler {
@ -151,8 +100,9 @@ HMxcImage {
}
eventDelegate.hoveredMediaTypeUrl = [
EventDelegate.Media.Image,
loader.downloadedPath.replace(/^file:\/\//, "") ||
Utils.Media.Image,
// XXX
// loader.downloadedPath.replace(/^file:\/\//, "") ||
loader.mediaUrl
]
}

View File

@ -125,29 +125,43 @@ Rectangle {
}
HShortcut {
sequences: window.settings.keys.openMessagesLinks
sequences: window.settings.keys.openMessagesLinks // XXX: rename
onActivated: {
let events = []
let indice = []
if (eventList.selectedCount) {
events = eventList.getSortedChecked()
indice = eventList.checkedIndice
} else if (eventList.currentIndex !== -1) {
events = [eventList.model.get(eventList.currentIndex)]
indice = [eventList.currentIndex]
} else {
// Find most recent event containing links
// 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 ev = eventList.model.get(i)
const links = JSON.parse(ev.links)
if (JSON.parse(ev.links).length) {
events = [ev]
if (ev.media_url || ev.thumbnail_url || links.length) {
indice = [i]
break
}
}
}
for (const event of events) {
for (const link of JSON.parse(event.links))
Qt.openUrlExternally(link)
for (const i of indice.sort().reverse()) {
const event = eventList.model.get(i)
if (event.media_url || event.thumbnail_url) {
eventList.getMediaType(event) === Utils.Media.Image ?
eventList.openImageViewer(event) :
eventList.openMediaExternally(event)
continue
}
for (const url of JSON.parse(event.links)) {
utils.getLinkType(url) === Utils.Media.Image ?
eventList.openImageViewer(event, url) :
Qt.openUrlExternally(url)
}
}
}
}
@ -198,6 +212,8 @@ Rectangle {
property alias cursorShape: cursorShapeArea.cursorShape
readonly property var thumbnailCachedPaths: ({}) // {event.id: path}
readonly property var redactableCheckedEvents:
getSortedChecked().filter(ev => eventList.canRedact(ev))
@ -302,6 +318,106 @@ Rectangle {
}
}
function getMediaType(event) {
if (event.event_type === "RoomAvatarEvent")
return Utils.Media.Image
const mainType = event.media_mime.split("/")[0].toLowerCase()
const fileEvents = ["RoomMessageFile", "RoomEncryptedFile"]
return (
mainType === "image" ? Utils.Media.Image :
mainType === "video" ? Utils.Media.Video :
mainType === "audio" ? Utils.Media.Audio :
fileEvents.includes(event.event_type) ? Utils.Media.File :
null
)
}
function isAnimated(event) {
return (
event.media_mime === "image/gif" ||
utils.urlExtension(event.media_url).toLowerCase() === "gif"
)
}
function getThumbnailTitle(event) {
return event.media_title.replace(
/\.[^\.]+$/,
event.thumbnail_mime === "image/jpeg" ? ".jpg" :
event.thumbnail_mime === "image/png" ? ".png" :
event.thumbnail_mime === "image/gif" ? ".gif" :
event.thumbnail_mime === "image/tiff" ? ".tiff" :
event.thumbnail_mime === "image/svg+xml" ? ".svg" :
event.thumbnail_mime === "image/webp" ? ".webp" :
event.thumbnail_mime === "image/bmp" ? ".bmp" :
".thumbnail"
) || utils.urlFileName(event.media_url)
}
function openImageViewer(event, forLink="") {
// if forLink is empty, this must be a media event
const title =
event.media_title || utils.urlFileName(event.media_url)
// The thumbnail/cached path will be the full GIF
const fullMxc =
forLink || (isAnimated(event) ? "" : event.media_url)
utils.makePopup(
"Popups/ImageViewerPopup.qml",
{
thumbnailTitle: getThumbnailTitle(event),
thumbnailMxc: event.thumbnail_url,
thumbnailPath: eventList.thumbnailCachedPaths[event.id],
thumbnailCryptDict: JSON.parse(event.thumbnail_crypt_dict),
fullTitle: title,
fullMxc: fullMxc,
fullCryptDict: JSON.parse(event.media_crypt_dict),
overallSize: Qt.size(
event.media_width ||
event.thumbnail_width ||
implicitWidth || // XXX
800,
event.media_height ||
event.thumbnail_height ||
implicitHeight || // XXX
600,
)
},
obj => {
obj.openExternallyRequested.connect(() =>
forLink ?
Qt.openUrlExternally(forLink) :
eventList.openMediaExternally(event)
)
},
)
}
function getLocalOrDownloadMedia(event, callback) {
print("Downloading " + event.media_url + " ...")
const args = [
event.media_url,
event.media_title,
JSON.parse(event.media_crypt_dict),
]
py.callCoro("media_cache.get_media", args, path => {
print("Done: " + path)
callback(path)
})
}
function openMediaExternally(event) {
eventList.getLocalOrDownloadMedia(event, Qt.openUrlExternally)
}
anchors.fill: parent
clip: true
keyNavigationWraps: false

View File

@ -1,6 +1,7 @@
// SPDX-License-Identifier: LGPL-3.0-or-later
import QtQuick 2.12
import "../../.."
import "../../../Base"
HLoader {
@ -12,97 +13,31 @@ HLoader {
property string showDate: ""
property string showLocalEcho: ""
property string downloadedPath: ""
readonly property string title:
singleMediaInfo.media_title || utils.urlFileName(mediaUrl)
readonly property string thumbnailTitle:
singleMediaInfo.media_title.replace(
/\.[^\.]+$/,
singleMediaInfo.thumbnail_mime === "image/jpeg" ? ".jpg" :
singleMediaInfo.thumbnail_mime === "image/png" ? ".png" :
singleMediaInfo.thumbnail_mime === "image/gif" ? ".gif" :
singleMediaInfo.thumbnail_mime === "image/tiff" ? ".tiff" :
singleMediaInfo.thumbnail_mime === "image/svg+xml" ? ".svg" :
singleMediaInfo.thumbnail_mime === "image/webp" ? ".webp" :
singleMediaInfo.thumbnail_mime === "image/bmp" ? ".bmp" :
".thumbnail"
) || utils.urlFileName(mediaUrl)
eventList.getThumbnailTitle(singleMediaInfo)
readonly property var imageExtensions: [
"bmp", "gif", "jpg", "jpeg", "png", "pbm", "pgm", "ppm", "xbm", "xpm",
"tiff", "webp", "svg",
]
readonly property bool isMedia:
eventList.getMediaType(singleMediaInfo) !== null
readonly property var videoExtensions: [
"3gp", "avi", "flv", "m4p", "m4v", "mkv", "mov", "mp4",
"mpeg", "mpg", "ogv", "qt", "vob", "webm", "wmv", "yuv",
]
readonly property var audioExtensions: [
"pcm", "wav", "raw", "aiff", "flac", "m4a", "tta", "aac", "mp3",
"ogg", "oga", "opus",
]
readonly property int type: {
if (singleMediaInfo.event_type === "RoomAvatarEvent")
return EventDelegate.Media.Image
const mainType = singleMediaInfo.media_mime.split("/")[0].toLowerCase()
if (mainType === "image") return EventDelegate.Media.Image
if (mainType === "video") return EventDelegate.Media.Video
if (mainType === "audio") return EventDelegate.Media.Audio
const fileEvents = ["RoomMessageFile", "RoomEncryptedFile"]
if (fileEvents.includes(singleMediaInfo.event_type))
return EventDelegate.Media.File
// If this is a preview for a link in a normal message
const ext = utils.urlExtension(mediaUrl).toLowerCase()
if (imageExtensions.includes(ext)) return EventDelegate.Media.Image
if (videoExtensions.includes(ext)) return EventDelegate.Media.Video
if (audioExtensions.includes(ext)) return EventDelegate.Media.Audio
return EventDelegate.Media.Page
}
readonly property int type:
isMedia ?
eventList.getMediaType(singleMediaInfo) :
utils.getLinkType(mediaUrl)
readonly property string thumbnailMxc: singleMediaInfo.thumbnail_url
function download(callback) {
if (! loader.mediaUrl.startsWith("mxc://")) {
downloadedPath = loader.mediaUrl
callback(loader.mediaUrl)
return
}
if (! downloadedPath) print("Downloading " + loader.mediaUrl + " ...")
const args = [
loader.mediaUrl,
loader.title,
JSON.parse(loader.singleMediaInfo.media_crypt_dict)
]
py.callCoro("media_cache.get_media", args, path => {
if (! downloadedPath) print("Done: " + path)
downloadedPath = path
callback(path)
})
}
visible: Boolean(item)
x: eventContent.spacing
onTypeChanged: {
if (type === EventDelegate.Media.Image) {
if (type === Utils.Media.Image) {
var file = "EventImage.qml"
} else if (type !== EventDelegate.Media.Page) {
} else if (type !== Utils.Media.Page) {
var file = "EventFile.qml"
} else { return }

View File

@ -3,6 +3,7 @@
import QtQuick 2.12
import QtQuick.Layouts 1.12
import QtAV 1.7
import "../../.."
import "../../../Base"
import "../../../Base/MediaPlayer"
@ -11,5 +12,5 @@ VideoPlayer {
onHoveredChanged:
eventDelegate.hoveredMediaTypeUrl =
hovered ? [EventDelegate.Media.Video, video.source] : []
hovered ? [Utils.Media.Video, video.source] : []
}

View File

@ -4,6 +4,23 @@ import QtQuick 2.12
import CppUtils 0.1
QtObject {
enum Media { Page, File, Image, Video, Audio }
readonly property var imageExtensions: [
"bmp", "gif", "jpg", "jpeg", "png", "pbm", "pgm", "ppm", "xbm", "xpm",
"tiff", "webp", "svg",
]
readonly property var videoExtensions: [
"3gp", "avi", "flv", "m4p", "m4v", "mkv", "mov", "mp4",
"mpeg", "mpg", "ogv", "qt", "vob", "webm", "wmv", "yuv",
]
readonly property var audioExtensions: [
"pcm", "wav", "raw", "aiff", "flac", "m4a", "tta", "aac", "mp3",
"ogg", "oga", "opus",
]
function makeObject(urlComponent, parent=null, properties={},
callback=null) {
let comp = urlComponent
@ -456,6 +473,18 @@ QtObject {
}
function getLinkType(url) {
const ext = urlExtension(url).toLowerCase()
return (
imageExtensions.includes(ext) ? Utils.Media.Image :
videoExtensions.includes(ext) ? Utils.Media.Video :
audioExtensions.includes(ext) ? Utils.Media.Audio :
Utils.Media.Page
)
}
function sendFile(userId, roomId, path, onSuccess, onError) {
py.callClientCoro(
userId, "send_file", [roomId, path], onSuccess, onError,