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:
parent
74a919cb65
commit
da4672d715
2 changed files with 109 additions and 17 deletions
|
@ -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}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
|
@ -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",
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
Loading…
Reference in a new issue