Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions AUTHORS.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
9 changes: 9 additions & 0 deletions src/accessibility/KeyboardShortcuts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,7 @@ export enum KeyBindingAction {
ArrowDown = "KeyBinding.arrowDown",
Tab = "KeyBinding.tab",
Comma = "KeyBinding.comma",
Save = "KeyBinding.save",

/** Toggle visibility of hidden events */
ToggleHiddenEventVisibility = "KeyBinding.toggleHiddenEventVisibility",
Expand Down Expand Up @@ -268,6 +269,7 @@ export const CATEGORIES: Record<CategoryName, ICategory> = {
KeyBindingAction.ArrowRight,
KeyBindingAction.ArrowDown,
KeyBindingAction.Comma,
KeyBindingAction.Save,
],
},
[CategoryName.NAVIGATION]: {
Expand Down Expand Up @@ -620,6 +622,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,
Expand Down
124 changes: 33 additions & 91 deletions src/components/views/elements/ImageView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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;
Expand Down Expand Up @@ -123,6 +118,8 @@ export default class ImageView extends React.Component<IProps, IState> {
private imageWrapper = createRef<HTMLDivElement>();
private image = createRef<HTMLImageElement>();

private downloadFunction?: () => Promise<void>;

private initX = 0;
private initY = 0;
private previousX = 0;
Expand Down Expand Up @@ -302,6 +299,13 @@ export default class ImageView extends React.Component<IProps, IState> {
ev.preventDefault();
this.props.onFinished();
break;
case KeyBindingAction.Save:
ev.preventDefault();
ev.stopPropagation();
if (this.downloadFunction) {
this.downloadFunction();
}
break;
}
};

Expand All @@ -327,6 +331,10 @@ export default class ImageView extends React.Component<IProps, IState> {
});
};

private onDownloadFunctionReady = (download: () => Promise<void>): 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.
Expand Down Expand Up @@ -552,7 +560,12 @@ export default class ImageView extends React.Component<IProps, IState> {
title={_t("lightbox|rotate_right")}
onClick={this.onRotateClockwiseClick}
/>
<DownloadButton url={this.props.src} fileName={this.props.name} mxEvent={this.props.mxEvent} />
<DownloadButton
url={this.props.src}
fileName={this.props.name}
mxEvent={this.props.mxEvent}
onDownloadReady={this.onDownloadFunctionReady}
/>
{contextMenuButton}
<AccessibleButton
className="mx_ImageView_button mx_ImageView_button_close"
Expand Down Expand Up @@ -585,99 +598,28 @@ export default class ImageView extends React.Component<IProps, IState> {
}
}

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<boolean>(false);
const blobRef = useRef<Blob>(undefined);
const mediaEventHelper = useMemo(() => (mxEvent ? new MediaEventHelper(mxEvent) : undefined), [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]);

function showError(e: unknown): void {
Modal.createDialog(ErrorDialog, {
title: _t("timeline|download_failed"),
description: (
<>
<div>{_t("timeline|download_failed_description")}</div>
<div>{e instanceof Error ? e.toString() : ""}</div>
</>
),
});
setLoading(false);
}

const onDownloadClick = async (): Promise<void> => {
try {
if (loading) return;
setLoading(true);

if (blobRef.current) {
// Cheat and trigger a download, again.
return downloadBlob(blobRef.current);
}
onDownloadReady?: (download: () => Promise<void>) => void;
}

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);
}
};
export const DownloadButton: React.FC<DownloadButtonProps> = ({ url, fileName, mxEvent, onDownloadReady }) => {
const { download, loading, canDownload } = useDownloadMedia(url, fileName, mxEvent);

async function downloadBlob(blob: Blob): Promise<void> {
await downloader.download({
blob,
name: mediaEventHelper?.fileName ?? fileName ?? _t("common|image"),
});
setLoading(false);
}
useEffect(() => {
if (onDownloadReady) onDownloadReady(download);
}, [download, onDownloadReady]);

if (!canDownload) {
return null;
}
if (!canDownload) return null;

return (
<AccessibleButton
className="mx_ImageView_button mx_ImageView_button_download"
title={loading ? _t("timeline|download_action_downloading") : _t("action|download")}
onClick={onDownloadClick}
onClick={download}
disabled={loading}
/>
);
}
};
155 changes: 31 additions & 124 deletions src/components/views/messages/DownloadActionButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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<IProps, IState> {
private downloader = new FileDownloader();

public constructor(props: IProps) {
super(props);

const moduleHints = ModuleApi.customComponents.getHintsForMessage(props.mxEvent);
const downloadState: Pick<IState, "canDownload"> = { 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<void> => {
try {
await this.doDownload();
} catch (e) {
Modal.createDialog(ErrorDialog, {
title: _t("timeline|download_failed"),
description: (
<>
<div>{_t("timeline|download_failed_description")}</div>
<div>{e instanceof Error ? e.toString() : ""}</div>
</>
),
});
this.setState({ loading: false });
}
};

private async doDownload(): Promise<void> {
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<void> {
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 = <Spinner w={18} h={18} />;
}

if (this.state.canDownload === null) {
spinner = <Spinner w={18} h={18} />;
}

if (this.state.canDownload === false) {
return null;
}

const classes = classNames({
mx_MessageActionBar_iconButton: true,
mx_MessageActionBar_downloadButton: true,
mx_MessageActionBar_downloadSpinnerButton: !!spinner,
});

return (
<RovingAccessibleButton
className={classes}
title={spinner ? _t(this.state.tooltip) : _t("action|download")}
onClick={this.onDownloadClick}
disabled={!!spinner}
placement="left"
>
<DownloadIcon />
{spinner}
</RovingAccessibleButton>
);
}
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 ? <Spinner w={18} h={18} /> : undefined;
const classes = classNames({
mx_MessageActionBar_iconButton: true,
mx_MessageActionBar_downloadButton: true,
mx_MessageActionBar_downloadSpinnerButton: !!spinner,
});

return (
<RovingAccessibleButton
className={classes}
title={loading ? _t("timeline|download_action_downloading") : _t("action|download")}
onClick={download}
disabled={loading}
placement="left"
>
<DownloadIcon />
{spinner}
</RovingAccessibleButton>
);
}
Loading
Loading