diff --git a/AUTHORS.rst b/AUTHORS.rst index d027b59c99..a3f3be6586 100644 --- a/AUTHORS.rst +++ b/AUTHORS.rst @@ -19,3 +19,6 @@ include: * Thom Cleary (https://github.com/thomcatdotrocks) Small update for tarball deployment + +* Alexander (https://github.com/ioalexander) + Save image on CTRL + S shortcut diff --git a/src/accessibility/KeyboardShortcuts.ts b/src/accessibility/KeyboardShortcuts.ts index e3cc42a775..a1b18a106c 100644 --- a/src/accessibility/KeyboardShortcuts.ts +++ b/src/accessibility/KeyboardShortcuts.ts @@ -146,6 +146,7 @@ export enum KeyBindingAction { ArrowDown = "KeyBinding.arrowDown", Tab = "KeyBinding.tab", Comma = "KeyBinding.comma", + Save = "KeyBinding.save", /** Toggle visibility of hidden events */ ToggleHiddenEventVisibility = "KeyBinding.toggleHiddenEventVisibility", @@ -269,6 +270,7 @@ export const CATEGORIES: Record = { KeyBindingAction.ArrowRight, KeyBindingAction.ArrowDown, KeyBindingAction.Comma, + KeyBindingAction.Save, ], }, [CategoryName.NAVIGATION]: { @@ -621,6 +623,13 @@ export const KEYBOARD_SHORTCUTS: IKeyboardShortcuts = { }, displayName: _td("keyboard|composer_redo"), }, + [KeyBindingAction.Save]: { + default: { + key: Key.S, + ctrlOrCmdKey: true, + }, + displayName: _td("keyboard|save"), + }, [KeyBindingAction.PreviousVisitedRoomOrSpace]: { default: { metaKey: IS_MAC, diff --git a/src/components/views/elements/ImageView.tsx b/src/components/views/elements/ImageView.tsx index 9b1384ba1a..0ee15efa20 100644 --- a/src/components/views/elements/ImageView.tsx +++ b/src/components/views/elements/ImageView.tsx @@ -8,10 +8,9 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com Please see LICENSE files in the repository root for full details. */ -import React, { type JSX, createRef, type CSSProperties, useRef, useState, useMemo, useEffect } from "react"; +import React, { type JSX, createRef, type CSSProperties, useEffect } from "react"; import FocusLock from "react-focus-lock"; -import { type MatrixEvent, parseErrorResponse } from "matrix-js-sdk/src/matrix"; -import { logger } from "matrix-js-sdk/src/logger"; +import { type MatrixEvent } from "matrix-js-sdk/src/matrix"; import { _t } from "../../../languageHandler"; import MemberAvatar from "../avatars/MemberAvatar"; @@ -31,11 +30,7 @@ import { KeyBindingAction } from "../../../accessibility/KeyboardShortcuts"; import { getKeyBindingsManager } from "../../../KeyBindingsManager"; import { presentableTextForFile } from "../../../utils/FileUtils"; import AccessibleButton from "./AccessibleButton"; -import Modal from "../../../Modal"; -import ErrorDialog from "../dialogs/ErrorDialog"; -import { FileDownloader } from "../../../utils/FileDownloader"; -import { MediaEventHelper } from "../../../utils/MediaEventHelper.ts"; -import ModuleApi from "../../../modules/Api"; +import { useDownloadMedia } from "../../../hooks/useDownloadMedia.ts"; // Max scale to keep gaps around the image const MAX_SCALE = 0.95; @@ -123,6 +118,8 @@ export default class ImageView extends React.Component { private imageWrapper = createRef(); private image = createRef(); + private downloadFunction?: () => Promise; + private initX = 0; private initY = 0; private previousX = 0; @@ -302,6 +299,13 @@ export default class ImageView extends React.Component { ev.preventDefault(); this.props.onFinished(); break; + case KeyBindingAction.Save: + ev.preventDefault(); + ev.stopPropagation(); + if (this.downloadFunction) { + this.downloadFunction(); + } + break; } }; @@ -327,6 +331,10 @@ export default class ImageView extends React.Component { }); }; + private onDownloadFunctionReady = (download: () => Promise): void => { + this.downloadFunction = download; + }; + private onPermalinkClicked = (ev: React.MouseEvent): void => { // This allows the permalink to be opened in a new tab/window or copied as // matrix.to, but also for it to enable routing within Element when clicked. @@ -552,7 +560,12 @@ export default class ImageView extends React.Component { title={_t("lightbox|rotate_right")} onClick={this.onRotateClockwiseClick} /> - + {contextMenuButton} { } } -function DownloadButton({ - url, - fileName, - mxEvent, -}: { +interface DownloadButtonProps { url: string; fileName?: string; mxEvent?: MatrixEvent; -}): JSX.Element | null { - const downloader = useRef(new FileDownloader()).current; - const [loading, setLoading] = useState(false); - const [canDownload, setCanDownload] = useState(false); - const blobRef = useRef(undefined); - const mediaEventHelper = useMemo(() => (mxEvent ? new MediaEventHelper(mxEvent) : undefined), [mxEvent]); + onDownloadReady?: (download: () => Promise) => void; +} + +export const DownloadButton: React.FC = ({ url, fileName, mxEvent, onDownloadReady }) => { + const { download, loading, canDownload } = useDownloadMedia(url, fileName, mxEvent); useEffect(() => { - if (!mxEvent) { - // If we have no event, we assume this is safe to download. - setCanDownload(true); - return; - } - const hints = ModuleApi.customComponents.getHintsForMessage(mxEvent); - if (hints?.allowDownloadingMedia) { - // Disable downloading as soon as we know there is a hint. - setCanDownload(false); - hints - .allowDownloadingMedia() - .then((downloadable) => { - setCanDownload(downloadable); - }) - .catch((ex) => { - logger.error(`Failed to check if media from ${mxEvent.getId()} could be downloaded`, ex); - // Err on the side of safety. - setCanDownload(false); - }); - } else { - setCanDownload(true); - } - }, [mxEvent]); + if (onDownloadReady) onDownloadReady(download); + }, [download, onDownloadReady]); - function showError(e: unknown): void { - Modal.createDialog(ErrorDialog, { - title: _t("timeline|download_failed"), - description: ( - <> -
{_t("timeline|download_failed_description")}
-
{e instanceof Error ? e.toString() : ""}
- - ), - }); - setLoading(false); - } - - const onDownloadClick = async (): Promise => { - try { - if (loading) return; - setLoading(true); - - if (blobRef.current) { - // Cheat and trigger a download, again. - return downloadBlob(blobRef.current); - } - - const res = await fetch(url); - if (!res.ok) { - throw parseErrorResponse(res, await res.text()); - } - const blob = await res.blob(); - blobRef.current = blob; - await downloadBlob(blob); - } catch (e) { - showError(e); - } - }; - - async function downloadBlob(blob: Blob): Promise { - await downloader.download({ - blob, - name: mediaEventHelper?.fileName ?? fileName ?? _t("common|image"), - }); - setLoading(false); - } - - if (!canDownload) { - return null; - } + if (!canDownload) return null; return ( ); -} +}; diff --git a/src/components/views/messages/DownloadActionButton.tsx b/src/components/views/messages/DownloadActionButton.tsx index b1d3407185..479e792fca 100644 --- a/src/components/views/messages/DownloadActionButton.tsx +++ b/src/components/views/messages/DownloadActionButton.tsx @@ -7,19 +7,15 @@ Please see LICENSE files in the repository root for full details. */ import { type MatrixEvent } from "matrix-js-sdk/src/matrix"; -import React, { type JSX } from "react"; +import React, { type ReactElement, useMemo } from "react"; import classNames from "classnames"; import { DownloadIcon } from "@vector-im/compound-design-tokens/assets/web/icons"; -import { logger } from "matrix-js-sdk/src/logger"; import { type MediaEventHelper } from "../../../utils/MediaEventHelper"; import { RovingAccessibleButton } from "../../../accessibility/RovingTabIndex"; import Spinner from "../elements/Spinner"; -import { _t, _td, type TranslationKey } from "../../../languageHandler"; -import { FileDownloader } from "../../../utils/FileDownloader"; -import Modal from "../../../Modal"; -import ErrorDialog from "../dialogs/ErrorDialog"; -import ModuleApi from "../../../modules/Api"; +import { _t } from "../../../languageHandler"; +import { useDownloadMedia } from "../../../hooks/useDownloadMedia"; interface IProps { mxEvent: MatrixEvent; @@ -30,121 +26,32 @@ interface IProps { mediaEventHelperGet: () => MediaEventHelper | undefined; } -interface IState { - canDownload: null | boolean; - loading: boolean; - blob?: Blob; - tooltip: TranslationKey; -} - -export default class DownloadActionButton extends React.PureComponent { - private downloader = new FileDownloader(); - - public constructor(props: IProps) { - super(props); - - const moduleHints = ModuleApi.customComponents.getHintsForMessage(props.mxEvent); - const downloadState: Pick = { canDownload: true }; - if (moduleHints?.allowDownloadingMedia) { - downloadState.canDownload = null; - moduleHints - .allowDownloadingMedia() - .then((canDownload) => { - this.setState({ - canDownload: canDownload, - }); - }) - .catch((ex) => { - logger.error(`Failed to check if media from ${props.mxEvent.getId()} could be downloaded`, ex); - this.setState({ - canDownload: false, - }); - }); - } - - this.state = { - loading: false, - tooltip: _td("timeline|download_action_downloading"), - ...downloadState, - }; - } - - private onDownloadClick = async (): Promise => { - try { - await this.doDownload(); - } catch (e) { - Modal.createDialog(ErrorDialog, { - title: _t("timeline|download_failed"), - description: ( - <> -
{_t("timeline|download_failed_description")}
-
{e instanceof Error ? e.toString() : ""}
- - ), - }); - this.setState({ loading: false }); - } - }; - - private async doDownload(): Promise { - const mediaEventHelper = this.props.mediaEventHelperGet(); - if (this.state.loading || !mediaEventHelper) return; - - if (mediaEventHelper.media.isEncrypted) { - this.setState({ tooltip: _td("timeline|download_action_decrypting") }); - } - - this.setState({ loading: true }); - - if (this.state.blob) { - // Cheat and trigger a download, again. - return this.downloadBlob(this.state.blob); - } - - const blob = await mediaEventHelper.sourceBlob.value; - this.setState({ blob }); - await this.downloadBlob(blob); - } - - private async downloadBlob(blob: Blob): Promise { - await this.downloader.download({ - blob, - name: this.props.mediaEventHelperGet()!.fileName, - }); - this.setState({ loading: false }); - } - - public render(): React.ReactNode { - let spinner: JSX.Element | undefined; - if (this.state.loading) { - spinner = ; - } - - if (this.state.canDownload === null) { - spinner = ; - } - - if (this.state.canDownload === false) { - return null; - } - - const classes = classNames({ - mx_MessageActionBar_iconButton: true, - mx_MessageActionBar_downloadButton: true, - mx_MessageActionBar_downloadSpinnerButton: !!spinner, - }); - - return ( - - - {spinner} - - ); - } +export default function DownloadActionButton({ mxEvent, mediaEventHelperGet }: IProps): ReactElement | null { + const mediaEventHelper = useMemo(() => mediaEventHelperGet(), [mediaEventHelperGet]); + const downloadUrl = mediaEventHelper?.media.srcHttp ?? ""; + const fileName = mediaEventHelper?.fileName; + + const { download, loading, canDownload } = useDownloadMedia(downloadUrl, fileName, mxEvent); + + if (!canDownload) return null; + + const spinner = loading ? : undefined; + const classes = classNames({ + mx_MessageActionBar_iconButton: true, + mx_MessageActionBar_downloadButton: true, + mx_MessageActionBar_downloadSpinnerButton: !!spinner, + }); + + return ( + + + {spinner} + + ); } diff --git a/src/hooks/useDownloadMedia.ts b/src/hooks/useDownloadMedia.ts new file mode 100644 index 0000000000..74328ac7ca --- /dev/null +++ b/src/hooks/useDownloadMedia.ts @@ -0,0 +1,93 @@ +/* +Copyright 2024 New Vector Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE files in the repository root for full details. +*/ + +import { parseErrorResponse } from "matrix-js-sdk/src/matrix"; +import { useRef, useState, useMemo, useEffect } from "react"; +import { type MatrixEvent } from "matrix-js-sdk/src/matrix"; +import { logger } from "matrix-js-sdk/src/logger"; + +import ErrorDialog from "../components/views/dialogs/ErrorDialog"; +import { _t } from "../languageHandler"; +import Modal from "../Modal"; +import { FileDownloader } from "../utils/FileDownloader"; +import { MediaEventHelper } from "../utils/MediaEventHelper"; +import ModuleApi from "../modules/Api"; + +export interface UseDownloadMediaReturn { + download: () => Promise; + loading: boolean; + canDownload: boolean; +} + +export function useDownloadMedia(url: string, fileName?: string, mxEvent?: MatrixEvent): UseDownloadMediaReturn { + const downloader = useRef(new FileDownloader()).current; + const blobRef = useRef(null); + const [loading, setLoading] = useState(false); + const [canDownload, setCanDownload] = useState(true); + + const mediaEventHelper = useMemo(() => (mxEvent ? new MediaEventHelper(mxEvent) : undefined), [mxEvent]); + + useEffect(() => { + if (!mxEvent) return; + + const hints = ModuleApi.customComponents.getHintsForMessage(mxEvent); + if (hints?.allowDownloadingMedia) { + setCanDownload(false); + hints + .allowDownloadingMedia() + .then(setCanDownload) + .catch((err: any) => { + logger.error(`Failed to check media download permission for ${mxEvent.event.event_id}`, err); + + setCanDownload(false); + }); + } else { + setCanDownload(true); + } + }, [mxEvent]); + + const download = async (): Promise => { + if (loading) return; + try { + setLoading(true); + + if (blobRef.current) { + return downloadBlob(blobRef.current); + } + + const res = await fetch(url); + if (!res.ok) { + throw parseErrorResponse(res, await res.text()); + } + + const blob = await res.blob(); + blobRef.current = blob; + + await downloadBlob(blob); + } catch (e) { + showError(e); + } + }; + + const downloadBlob = async (blob: Blob): Promise => { + await downloader.download({ + blob, + name: mediaEventHelper?.fileName ?? fileName ?? _t("common|image"), + }); + setLoading(false); + }; + + const showError = (e: unknown): void => { + Modal.createDialog(ErrorDialog, { + title: _t("timeline|download_failed"), + description: `${_t("timeline|download_failed_description")}\n\n${String(e)}`, + }); + setLoading(false); + }; + + return { download, loading, canDownload }; +} diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index b4baa65901..3be18c544f 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -1450,6 +1450,7 @@ "room_list_navigate_down": "Navigate down in the room list", "room_list_navigate_up": "Navigate up in the room list", "room_list_select_room": "Select room from the room list", + "save": "Save", "scroll_down_timeline": "Scroll down in the timeline", "scroll_up_timeline": "Scroll up in the timeline", "search": "Search (must be enabled)", @@ -3370,7 +3371,6 @@ "unable_to_decrypt": "Unable to decrypt message" }, "disambiguated_profile": "%(displayName)s (%(matrixId)s)", - "download_action_decrypting": "Decrypting", "download_action_downloading": "Downloading", "download_failed": "Download failed", "download_failed_description": "An error occurred while downloading this file", diff --git a/test/unit-tests/components/views/elements/ImageView-test.tsx b/test/unit-tests/components/views/elements/ImageView-test.tsx index e9ba7cdd69..b0e9338f69 100644 --- a/test/unit-tests/components/views/elements/ImageView-test.tsx +++ b/test/unit-tests/components/views/elements/ImageView-test.tsx @@ -44,6 +44,28 @@ describe("", () => { expect(fetchMock).toHaveFetched("https://example.com/image.png"); }); + it("should start download on Ctrl+S", async () => { + fetchMock.get("https://example.com/image.png", "TESTFILE"); + + const { container } = render( + , + ); + + const dialog = container.querySelector('[role="dialog"]') as HTMLElement; + dialog?.focus(); + + fireEvent.keyDown(dialog!, { key: "s", code: "KeyS", ctrlKey: true }); + + await waitFor(() => { + expect(mocked(FileDownloader).mock.instances[0].download).toHaveBeenCalledWith({ + blob: expect.anything(), + name: "filename.png", + }); + }); + + expect(fetchMock).toHaveFetched("https://example.com/image.png"); + }); + it("should handle download errors", async () => { const modalSpy = jest.spyOn(Modal, "createDialog"); fetchMock.get("https://example.com/image.png", { status: 500 }); diff --git a/test/unit-tests/components/views/settings/tabs/user/__snapshots__/KeyboardUserSettingsTab-test.tsx.snap b/test/unit-tests/components/views/settings/tabs/user/__snapshots__/KeyboardUserSettingsTab-test.tsx.snap index fb733bdbeb..89bbcd2ee4 100644 --- a/test/unit-tests/components/views/settings/tabs/user/__snapshots__/KeyboardUserSettingsTab-test.tsx.snap +++ b/test/unit-tests/components/views/settings/tabs/user/__snapshots__/KeyboardUserSettingsTab-test.tsx.snap @@ -742,6 +742,26 @@ exports[`KeyboardUserSettingsTab renders list of keyboard shortcuts 1`] = ` +
  • + Save +
    + + + Ctrl + + + + + + + s + + +
    +