Skip to content

Commit da4672d

Browse files
authored
Handle authenticated media when downloading from ImageView (#28379)
* Handle authenticated media when downloading from ImageView Signed-off-by: Michael Telatynski <[email protected]> * Iterate Signed-off-by: Michael Telatynski <[email protected]> * Iterate Signed-off-by: Michael Telatynski <[email protected]> * Iterate Signed-off-by: Michael Telatynski <[email protected]> * Iterate Signed-off-by: Michael Telatynski <[email protected]> --------- Signed-off-by: Michael Telatynski <[email protected]>
1 parent 74a919c commit da4672d

File tree

2 files changed

+109
-17
lines changed

2 files changed

+109
-17
lines changed

src/components/views/elements/ImageView.tsx

Lines changed: 64 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,9 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
88
Please see LICENSE files in the repository root for full details.
99
*/
1010

11-
import React, { createRef, CSSProperties } from "react";
11+
import React, { createRef, CSSProperties, useRef, useState } from "react";
1212
import FocusLock from "react-focus-lock";
13-
import { MatrixEvent } from "matrix-js-sdk/src/matrix";
13+
import { MatrixEvent, parseErrorResponse } from "matrix-js-sdk/src/matrix";
1414

1515
import { _t } from "../../../languageHandler";
1616
import MemberAvatar from "../avatars/MemberAvatar";
@@ -30,6 +30,9 @@ import { KeyBindingAction } from "../../../accessibility/KeyboardShortcuts";
3030
import { getKeyBindingsManager } from "../../../KeyBindingsManager";
3131
import { presentableTextForFile } from "../../../utils/FileUtils";
3232
import AccessibleButton from "./AccessibleButton";
33+
import Modal from "../../../Modal";
34+
import ErrorDialog from "../dialogs/ErrorDialog";
35+
import { FileDownloader } from "../../../utils/FileDownloader";
3336

3437
// Max scale to keep gaps around the image
3538
const MAX_SCALE = 0.95;
@@ -309,15 +312,6 @@ export default class ImageView extends React.Component<IProps, IState> {
309312
this.setZoomAndRotation(cur + 90);
310313
};
311314

312-
private onDownloadClick = (): void => {
313-
const a = document.createElement("a");
314-
a.href = this.props.src;
315-
if (this.props.name) a.download = this.props.name;
316-
a.target = "_blank";
317-
a.rel = "noreferrer noopener";
318-
a.click();
319-
};
320-
321315
private onOpenContextMenu = (): void => {
322316
this.setState({
323317
contextMenuDisplayed: true,
@@ -555,11 +549,7 @@ export default class ImageView extends React.Component<IProps, IState> {
555549
title={_t("lightbox|rotate_right")}
556550
onClick={this.onRotateClockwiseClick}
557551
/>
558-
<AccessibleButton
559-
className="mx_ImageView_button mx_ImageView_button_download"
560-
title={_t("action|download")}
561-
onClick={this.onDownloadClick}
562-
/>
552+
<DownloadButton url={this.props.src} fileName={this.props.name} />
563553
{contextMenuButton}
564554
<AccessibleButton
565555
className="mx_ImageView_button mx_ImageView_button_close"
@@ -591,3 +581,61 @@ export default class ImageView extends React.Component<IProps, IState> {
591581
);
592582
}
593583
}
584+
585+
function DownloadButton({ url, fileName }: { url: string; fileName?: string }): JSX.Element {
586+
const downloader = useRef(new FileDownloader()).current;
587+
const [loading, setLoading] = useState(false);
588+
const blobRef = useRef<Blob>();
589+
590+
function showError(e: unknown): void {
591+
Modal.createDialog(ErrorDialog, {
592+
title: _t("timeline|download_failed"),
593+
description: (
594+
<>
595+
<div>{_t("timeline|download_failed_description")}</div>
596+
<div>{e instanceof Error ? e.toString() : ""}</div>
597+
</>
598+
),
599+
});
600+
setLoading(false);
601+
}
602+
603+
const onDownloadClick = async (): Promise<void> => {
604+
try {
605+
if (loading) return;
606+
setLoading(true);
607+
608+
if (blobRef.current) {
609+
// Cheat and trigger a download, again.
610+
return downloadBlob(blobRef.current);
611+
}
612+
613+
const res = await fetch(url);
614+
if (!res.ok) {
615+
throw parseErrorResponse(res, await res.text());
616+
}
617+
const blob = await res.blob();
618+
blobRef.current = blob;
619+
await downloadBlob(blob);
620+
} catch (e) {
621+
showError(e);
622+
}
623+
};
624+
625+
async function downloadBlob(blob: Blob): Promise<void> {
626+
await downloader.download({
627+
blob,
628+
name: fileName ?? _t("common|image"),
629+
});
630+
setLoading(false);
631+
}
632+
633+
return (
634+
<AccessibleButton
635+
className="mx_ImageView_button mx_ImageView_button_download"
636+
title={loading ? _t("timeline|download_action_downloading") : _t("action|download")}
637+
onClick={onDownloadClick}
638+
disabled={loading}
639+
/>
640+
);
641+
}

test/unit-tests/components/views/elements/ImageView-test.tsx

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,57 @@
77
*/
88

99
import React from "react";
10-
import { render } from "jest-matrix-react";
10+
import { mocked } from "jest-mock";
11+
import { render, fireEvent, waitFor } from "jest-matrix-react";
12+
import fetchMock from "fetch-mock-jest";
1113

1214
import ImageView from "../../../../../src/components/views/elements/ImageView";
15+
import { FileDownloader } from "../../../../../src/utils/FileDownloader";
16+
import Modal from "../../../../../src/Modal";
17+
import ErrorDialog from "../../../../../src/components/views/dialogs/ErrorDialog";
18+
19+
jest.mock("../../../../../src/utils/FileDownloader");
1320

1421
describe("<ImageView />", () => {
22+
beforeEach(() => {
23+
jest.resetAllMocks();
24+
fetchMock.reset();
25+
});
26+
1527
it("renders correctly", () => {
1628
const { container } = render(<ImageView src="https://example.com/image.png" onFinished={jest.fn()} />);
1729
expect(container).toMatchSnapshot();
1830
});
31+
32+
it("should download on click", async () => {
33+
fetchMock.get("https://example.com/image.png", "TESTFILE");
34+
const { getByRole } = render(
35+
<ImageView src="https://example.com/image.png" name="filename.png" onFinished={jest.fn()} />,
36+
);
37+
fireEvent.click(getByRole("button", { name: "Download" }));
38+
await waitFor(() =>
39+
expect(mocked(FileDownloader).mock.instances[0].download).toHaveBeenCalledWith({
40+
blob: expect.anything(),
41+
name: "filename.png",
42+
}),
43+
);
44+
expect(fetchMock).toHaveFetched("https://example.com/image.png");
45+
});
46+
47+
it("should handle download errors", async () => {
48+
const modalSpy = jest.spyOn(Modal, "createDialog");
49+
fetchMock.get("https://example.com/image.png", { status: 500 });
50+
const { getByRole } = render(
51+
<ImageView src="https://example.com/image.png" name="filename.png" onFinished={jest.fn()} />,
52+
);
53+
fireEvent.click(getByRole("button", { name: "Download" }));
54+
await waitFor(() =>
55+
expect(modalSpy).toHaveBeenCalledWith(
56+
ErrorDialog,
57+
expect.objectContaining({
58+
title: "Download failed",
59+
}),
60+
),
61+
);
62+
});
1963
});

0 commit comments

Comments
 (0)