Handle authenticated media when downloading from ImageView (#28379)

* Handle authenticated media when downloading from ImageView

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Iterate

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Iterate

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Iterate

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Iterate

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

---------

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
This commit is contained in:
Michael Telatynski 2024-11-07 11:43:33 +00:00 committed by GitHub
parent 74a919cb65
commit da4672d715
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 109 additions and 17 deletions

View file

@ -8,9 +8,9 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details. Please see LICENSE files in the repository root for full details.
*/ */
import React, { createRef, CSSProperties } from "react"; import React, { createRef, CSSProperties, useRef, useState } from "react";
import FocusLock from "react-focus-lock"; import FocusLock from "react-focus-lock";
import { MatrixEvent } from "matrix-js-sdk/src/matrix"; import { MatrixEvent, parseErrorResponse } from "matrix-js-sdk/src/matrix";
import { _t } from "../../../languageHandler"; import { _t } from "../../../languageHandler";
import MemberAvatar from "../avatars/MemberAvatar"; import MemberAvatar from "../avatars/MemberAvatar";
@ -30,6 +30,9 @@ import { KeyBindingAction } from "../../../accessibility/KeyboardShortcuts";
import { getKeyBindingsManager } from "../../../KeyBindingsManager"; import { getKeyBindingsManager } from "../../../KeyBindingsManager";
import { presentableTextForFile } from "../../../utils/FileUtils"; import { presentableTextForFile } from "../../../utils/FileUtils";
import AccessibleButton from "./AccessibleButton"; import AccessibleButton from "./AccessibleButton";
import Modal from "../../../Modal";
import ErrorDialog from "../dialogs/ErrorDialog";
import { FileDownloader } from "../../../utils/FileDownloader";
// Max scale to keep gaps around the image // Max scale to keep gaps around the image
const MAX_SCALE = 0.95; const MAX_SCALE = 0.95;
@ -309,15 +312,6 @@ export default class ImageView extends React.Component<IProps, IState> {
this.setZoomAndRotation(cur + 90); this.setZoomAndRotation(cur + 90);
}; };
private onDownloadClick = (): void => {
const a = document.createElement("a");
a.href = this.props.src;
if (this.props.name) a.download = this.props.name;
a.target = "_blank";
a.rel = "noreferrer noopener";
a.click();
};
private onOpenContextMenu = (): void => { private onOpenContextMenu = (): void => {
this.setState({ this.setState({
contextMenuDisplayed: true, contextMenuDisplayed: true,
@ -555,11 +549,7 @@ export default class ImageView extends React.Component<IProps, IState> {
title={_t("lightbox|rotate_right")} title={_t("lightbox|rotate_right")}
onClick={this.onRotateClockwiseClick} onClick={this.onRotateClockwiseClick}
/> />
<AccessibleButton <DownloadButton url={this.props.src} fileName={this.props.name} />
className="mx_ImageView_button mx_ImageView_button_download"
title={_t("action|download")}
onClick={this.onDownloadClick}
/>
{contextMenuButton} {contextMenuButton}
<AccessibleButton <AccessibleButton
className="mx_ImageView_button mx_ImageView_button_close" className="mx_ImageView_button mx_ImageView_button_close"
@ -591,3 +581,61 @@ export default class ImageView extends React.Component<IProps, IState> {
); );
} }
} }
function DownloadButton({ url, fileName }: { url: string; fileName?: string }): JSX.Element {
const downloader = useRef(new FileDownloader()).current;
const [loading, setLoading] = useState(false);
const blobRef = useRef<Blob>();
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);
}
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<void> {
await downloader.download({
blob,
name: fileName ?? _t("common|image"),
});
setLoading(false);
}
return (
<AccessibleButton
className="mx_ImageView_button mx_ImageView_button_download"
title={loading ? _t("timeline|download_action_downloading") : _t("action|download")}
onClick={onDownloadClick}
disabled={loading}
/>
);
}

View file

@ -7,13 +7,57 @@
*/ */
import React from "react"; import React from "react";
import { render } from "jest-matrix-react"; import { mocked } from "jest-mock";
import { render, fireEvent, waitFor } from "jest-matrix-react";
import fetchMock from "fetch-mock-jest";
import ImageView from "../../../../../src/components/views/elements/ImageView"; import ImageView from "../../../../../src/components/views/elements/ImageView";
import { FileDownloader } from "../../../../../src/utils/FileDownloader";
import Modal from "../../../../../src/Modal";
import ErrorDialog from "../../../../../src/components/views/dialogs/ErrorDialog";
jest.mock("../../../../../src/utils/FileDownloader");
describe("<ImageView />", () => { describe("<ImageView />", () => {
beforeEach(() => {
jest.resetAllMocks();
fetchMock.reset();
});
it("renders correctly", () => { it("renders correctly", () => {
const { container } = render(<ImageView src="https://example.com/image.png" onFinished={jest.fn()} />); const { container } = render(<ImageView src="https://example.com/image.png" onFinished={jest.fn()} />);
expect(container).toMatchSnapshot(); expect(container).toMatchSnapshot();
}); });
it("should download on click", async () => {
fetchMock.get("https://example.com/image.png", "TESTFILE");
const { getByRole } = render(
<ImageView src="https://example.com/image.png" name="filename.png" onFinished={jest.fn()} />,
);
fireEvent.click(getByRole("button", { name: "Download" }));
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 });
const { getByRole } = render(
<ImageView src="https://example.com/image.png" name="filename.png" onFinished={jest.fn()} />,
);
fireEvent.click(getByRole("button", { name: "Download" }));
await waitFor(() =>
expect(modalSpy).toHaveBeenCalledWith(
ErrorDialog,
expect.objectContaining({
title: "Download failed",
}),
),
);
});
}); });