Unify and improve download interactions

With help from Palid.

This does two major things:
1. Makes the tile part of a file body clickable to trigger a download.
2. Refactors a lot of the recyclable code out of the DownloadActionButton in favour of a utility. It's not a perfect refactoring, but it sets the stage for future work in the area (if needed).

The refactoring still has a heavy reliance on being supplied an iframe, but simplifies the DownloadActionButton and a hair of the MFileBody download code. In future, we'd probably want to make the iframe completely managed by the FileDownloader rather than have it only sometimes manage a hidden iframe.
This commit is contained in:
Travis Ralston 2021-07-29 15:36:50 -06:00
parent 94af6db201
commit fb89b45c06
4 changed files with 196 additions and 67 deletions

View file

@ -1,5 +1,5 @@
/* /*
Copyright 2015, 2016, 2021 The Matrix.org Foundation C.I.C. Copyright 2015 - 2021 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.
@ -60,6 +60,8 @@ limitations under the License.
} }
.mx_MFileBody_info { .mx_MFileBody_info {
cursor: pointer;
.mx_MFileBody_info_icon { .mx_MFileBody_info_icon {
background-color: $message-body-panel-icon-bg-color; background-color: $message-body-panel-icon-bg-color;
border-radius: 20px; border-radius: 20px;

View file

@ -16,12 +16,13 @@ limitations under the License.
import { MatrixEvent } from "matrix-js-sdk/src"; import { MatrixEvent } from "matrix-js-sdk/src";
import { MediaEventHelper } from "../../../utils/MediaEventHelper"; import { MediaEventHelper } from "../../../utils/MediaEventHelper";
import React, { createRef } from "react"; import React from "react";
import { RovingAccessibleTooltipButton } from "../../../accessibility/RovingTabIndex"; import { RovingAccessibleTooltipButton } from "../../../accessibility/RovingTabIndex";
import Spinner from "../elements/Spinner"; import Spinner from "../elements/Spinner";
import classNames from "classnames"; import classNames from "classnames";
import { _t } from "../../../languageHandler"; import { _t } from "../../../languageHandler";
import { replaceableComponent } from "../../../utils/replaceableComponent"; import { replaceableComponent } from "../../../utils/replaceableComponent";
import { FileDownloader } from "../../../utils/FileDownloader";
interface IProps { interface IProps {
mxEvent: MatrixEvent; mxEvent: MatrixEvent;
@ -39,7 +40,7 @@ interface IState {
@replaceableComponent("views.messages.DownloadActionButton") @replaceableComponent("views.messages.DownloadActionButton")
export default class DownloadActionButton extends React.PureComponent<IProps, IState> { export default class DownloadActionButton extends React.PureComponent<IProps, IState> {
private iframe: React.RefObject<HTMLIFrameElement> = createRef(); private downloader = new FileDownloader();
public constructor(props: IProps) { public constructor(props: IProps) {
super(props); super(props);
@ -56,27 +57,21 @@ export default class DownloadActionButton extends React.PureComponent<IProps, IS
if (this.state.blob) { if (this.state.blob) {
// Cheat and trigger a download, again. // Cheat and trigger a download, again.
return this.onFrameLoad(); return this.doDownload();
} }
const blob = await this.props.mediaEventHelperGet().sourceBlob.value; const blob = await this.props.mediaEventHelperGet().sourceBlob.value;
this.setState({ blob }); this.setState({ blob });
await this.doDownload();
}; };
private onFrameLoad = () => { private async doDownload() {
this.setState({ loading: false }); await this.downloader.download({
// we aren't showing the iframe, so we can send over the bare minimum styles and such.
this.iframe.current.contentWindow.postMessage({
imgSrc: "", // no image
imgStyle: null,
style: "",
blob: this.state.blob, blob: this.state.blob,
download: this.props.mediaEventHelperGet().fileName, name: this.props.mediaEventHelperGet().fileName,
textContent: "", });
auto: true, // autodownload this.setState({ loading: false });
}, '*'); }
};
public render() { public render() {
let spinner: JSX.Element; let spinner: JSX.Element;
@ -97,13 +92,6 @@ export default class DownloadActionButton extends React.PureComponent<IProps, IS
disabled={!!spinner} disabled={!!spinner}
> >
{ spinner } { spinner }
{ this.state.blob && <iframe
src="usercontent/" // XXX: Like MFileBody, this should come from the skin
ref={this.iframe}
onLoad={this.onFrameLoad}
sandbox="allow-scripts allow-downloads allow-downloads-without-user-activation"
style={{ display: "none" }}
/> }
</RovingAccessibleTooltipButton>; </RovingAccessibleTooltipButton>;
} }
} }

View file

@ -26,6 +26,7 @@ import { TileShape } from "../rooms/EventTile";
import { presentableTextForFile } from "../../../utils/FileUtils"; import { presentableTextForFile } from "../../../utils/FileUtils";
import { IMediaEventContent } from "../../../customisations/models/IMediaEventContent"; import { IMediaEventContent } from "../../../customisations/models/IMediaEventContent";
import { IBodyProps } from "./IBodyProps"; import { IBodyProps } from "./IBodyProps";
import { FileDownloader } from "../../../utils/FileDownloader";
export let DOWNLOAD_ICON_URL; // cached copy of the download.svg asset for the sandboxed iframe later on export let DOWNLOAD_ICON_URL; // cached copy of the download.svg asset for the sandboxed iframe later on
@ -111,6 +112,7 @@ export default class MFileBody extends React.Component<IProps, IState> {
private iframe: React.RefObject<HTMLIFrameElement> = createRef(); private iframe: React.RefObject<HTMLIFrameElement> = createRef();
private dummyLink: React.RefObject<HTMLAnchorElement> = createRef(); private dummyLink: React.RefObject<HTMLAnchorElement> = createRef();
private userDidClick = false; private userDidClick = false;
private fileDownloader: FileDownloader = new FileDownloader(() => this.iframe.current)
public constructor(props: IProps) { public constructor(props: IProps) {
super(props); super(props);
@ -118,6 +120,32 @@ export default class MFileBody extends React.Component<IProps, IState> {
this.state = {}; this.state = {};
} }
private get content(): IMediaEventContent {
return this.props.mxEvent.getContent<IMediaEventContent>();
}
private get fileName(): string {
return this.content.body && this.content.body.length > 0 ? this.content.body : _t("Attachment");
}
private get linkText(): string {
return presentableTextForFile(this.content);
}
private downloadFile(fileName: string, text: string) {
this.fileDownloader.download({
blob: this.state.decryptedBlob,
name: fileName,
autoDownload: this.userDidClick,
opts: {
imgSrc: DOWNLOAD_ICON_URL,
imgStyle: null,
style: computedStyle(this.dummyLink.current),
textContent: _t("Download %(text)s", { text })
},
});
}
private getContentUrl(): string { private getContentUrl(): string {
const media = mediaFromContent(this.props.mxEvent.getContent()); const media = mediaFromContent(this.props.mxEvent.getContent());
return media.srcHttp; return media.srcHttp;
@ -129,24 +157,55 @@ export default class MFileBody extends React.Component<IProps, IState> {
} }
} }
private decryptFile = async (): Promise<void> => {
if (this.state.decryptedBlob) {
return;
}
try {
this.userDidClick = true;
this.setState({
decryptedBlob: await this.props.mediaEventHelper.sourceBlob.value,
});
} catch (err) {
console.warn("Unable to decrypt attachment: ", err);
Modal.createTrackedDialog('Error decrypting attachment', '', ErrorDialog, {
title: _t("Error"),
description: _t("Error decrypting attachment"),
});
}
};
private onPlaceholderClick = async () => {
const mediaHelper = this.props.mediaEventHelper;
if (mediaHelper.media.isEncrypted) {
await this.decryptFile();
this.downloadFile(this.fileName, this.linkText);
} else {
// As a button we're missing the `download` attribute for styling reasons, so
// download with the file downloader.
this.fileDownloader.download({
blob: await mediaHelper.sourceBlob.value,
name: this.fileName,
});
}
};
public render() { public render() {
const content = this.props.mxEvent.getContent<IMediaEventContent>(); const content = this.props.mxEvent.getContent<IMediaEventContent>();
const text = presentableTextForFile(content);
const isEncrypted = this.props.mediaEventHelper.media.isEncrypted; const isEncrypted = this.props.mediaEventHelper.media.isEncrypted;
const fileName = content.body && content.body.length > 0 ? content.body : _t("Attachment");
const contentUrl = this.getContentUrl(); const contentUrl = this.getContentUrl();
const fileSize = content.info ? content.info.size : null; const fileSize = content.info ? content.info.size : null;
const fileType = content.info ? content.info.mimetype : "application/octet-stream"; const fileType = content.info ? content.info.mimetype : "application/octet-stream";
let placeholder = null; let placeholder: React.ReactNode = null;
if (this.props.showGenericPlaceholder) { if (this.props.showGenericPlaceholder) {
placeholder = ( placeholder = (
<div className="mx_MediaBody mx_MFileBody_info"> <AccessibleButton className="mx_MediaBody mx_MFileBody_info" onClick={this.onPlaceholderClick}>
<span className="mx_MFileBody_info_icon" /> <span className="mx_MFileBody_info_icon" />
<span className="mx_MFileBody_info_filename"> <span className="mx_MFileBody_info_filename">
{ presentableTextForFile(content, _t("Attachment"), false) } { presentableTextForFile(content, _t("Attachment"), false) }
</span> </span>
</div> </AccessibleButton>
); );
} }
@ -157,20 +216,6 @@ export default class MFileBody extends React.Component<IProps, IState> {
// Need to decrypt the attachment // Need to decrypt the attachment
// Wait for the user to click on the link before downloading // Wait for the user to click on the link before downloading
// and decrypting the attachment. // and decrypting the attachment.
const decrypt = async () => {
try {
this.userDidClick = true;
this.setState({
decryptedBlob: await this.props.mediaEventHelper.sourceBlob.value,
});
} catch (err) {
console.warn("Unable to decrypt attachment: ", err);
Modal.createTrackedDialog('Error decrypting attachment', '', ErrorDialog, {
title: _t("Error"),
description: _t("Error decrypting attachment"),
});
}
};
// This button should actually Download because usercontent/ will try to click itself // This button should actually Download because usercontent/ will try to click itself
// but it is not guaranteed between various browsers' settings. // but it is not guaranteed between various browsers' settings.
@ -178,31 +223,14 @@ export default class MFileBody extends React.Component<IProps, IState> {
<span className="mx_MFileBody"> <span className="mx_MFileBody">
{ placeholder } { placeholder }
{ showDownloadLink && <div className="mx_MFileBody_download"> { showDownloadLink && <div className="mx_MFileBody_download">
<AccessibleButton onClick={decrypt}> <AccessibleButton onClick={this.decryptFile}>
{ _t("Decrypt %(text)s", { text: text }) } { _t("Decrypt %(text)s", { text: this.linkText }) }
</AccessibleButton> </AccessibleButton>
</div> } </div> }
</span> </span>
); );
} }
// When the iframe loads we tell it to render a download link
const onIframeLoad = (ev) => {
ev.target.contentWindow.postMessage({
imgSrc: DOWNLOAD_ICON_URL,
imgStyle: null, // it handles this internally for us. Useful if a downstream changes the icon.
style: computedStyle(this.dummyLink.current),
blob: this.state.decryptedBlob,
// Set a download attribute for encrypted files so that the file
// will have the correct name when the user tries to download it.
// We can't provide a Content-Disposition header like we would for HTTP.
download: fileName,
textContent: _t("Download %(text)s", { text: text }),
// only auto-download if a user triggered this iframe explicitly
auto: this.userDidClick,
}, "*");
};
const url = "usercontent/"; // XXX: this path should probably be passed from the skin const url = "usercontent/"; // XXX: this path should probably be passed from the skin
// If the attachment is encrypted then put the link inside an iframe. // If the attachment is encrypted then put the link inside an iframe.
@ -218,9 +246,16 @@ export default class MFileBody extends React.Component<IProps, IState> {
*/ } */ }
<a ref={this.dummyLink} /> <a ref={this.dummyLink} />
</div> </div>
{ /*
TODO: Move iframe (and dummy link) into FileDownloader.
We currently have it set up this way because of styles applied to the iframe
itself which cannot be easily handled/overridden by the FileDownloader. In
future, the download link may disappear entirely at which point it could also
be suitable to just remove this bit of code.
*/ }
<iframe <iframe
src={url} src={url}
onLoad={onIframeLoad} onLoad={() => this.downloadFile(this.fileName, this.linkText)}
ref={this.iframe} ref={this.iframe}
sandbox="allow-scripts allow-downloads allow-downloads-without-user-activation" /> sandbox="allow-scripts allow-downloads allow-downloads-without-user-activation" />
</div> } </div> }
@ -259,7 +294,7 @@ export default class MFileBody extends React.Component<IProps, IState> {
// We have to create an anchor to download the file // We have to create an anchor to download the file
const tempAnchor = document.createElement('a'); const tempAnchor = document.createElement('a');
tempAnchor.download = fileName; tempAnchor.download = this.fileName;
tempAnchor.href = blobUrl; tempAnchor.href = blobUrl;
document.body.appendChild(tempAnchor); // for firefox: https://stackoverflow.com/a/32226068 document.body.appendChild(tempAnchor); // for firefox: https://stackoverflow.com/a/32226068
tempAnchor.click(); tempAnchor.click();
@ -268,7 +303,7 @@ export default class MFileBody extends React.Component<IProps, IState> {
}; };
} else { } else {
// Else we are hoping the browser will do the right thing // Else we are hoping the browser will do the right thing
downloadProps["download"] = fileName; downloadProps["download"] = this.fileName;
} }
return ( return (
@ -277,7 +312,7 @@ export default class MFileBody extends React.Component<IProps, IState> {
{ showDownloadLink && <div className="mx_MFileBody_download"> { showDownloadLink && <div className="mx_MFileBody_download">
<a {...downloadProps}> <a {...downloadProps}>
<span className="mx_MFileBody_download_icon" /> <span className="mx_MFileBody_download_icon" />
{ _t("Download %(text)s", { text: text }) } { _t("Download %(text)s", { text: this.linkText }) }
</a> </a>
{ this.props.tileShape === TileShape.FileGrid && <div className="mx_MImageBody_size"> { this.props.tileShape === TileShape.FileGrid && <div className="mx_MImageBody_size">
{ content.info && content.info.size ? filesize(content.info.size) : "" } { content.info && content.info.size ? filesize(content.info.size) : "" }
@ -286,7 +321,7 @@ export default class MFileBody extends React.Component<IProps, IState> {
</span> </span>
); );
} else { } else {
const extra = text ? (': ' + text) : ''; const extra = this.linkText ? (': ' + this.linkText) : '';
return <span className="mx_MFileBody"> return <span className="mx_MFileBody">
{ placeholder } { placeholder }
{ _t("Invalid file%(extra)s", { extra: extra }) } { _t("Invalid file%(extra)s", { extra: extra }) }

104
src/utils/FileDownloader.ts Normal file
View file

@ -0,0 +1,104 @@
/*
Copyright 2021 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
export type getIframeFn = () => HTMLIFrameElement;
export const DEFAULT_STYLES = {
imgSrc: "",
imgStyle: null, // css props
style: "",
textContent: "",
}
type DownloadOptions = {
blob: Blob,
name: string,
autoDownload?: boolean,
opts?: typeof DEFAULT_STYLES
}
// set up the iframe as a singleton so we don't have to figure out destruction of it down the line.
let managedIframe: HTMLIFrameElement;
let onLoadPromise: Promise<void>;
function getManagedIframe(): { iframe: HTMLIFrameElement, onLoadPromise: Promise<void> } {
if (managedIframe) return { iframe: managedIframe, onLoadPromise };
managedIframe = document.createElement("iframe");
// Need to append the iframe in order for the browser to load it.
document.body.appendChild(managedIframe);
// Dev note: the reassignment warnings are entirely incorrect here.
// @ts-ignore
// noinspection JSConstantReassignment
managedIframe.style = { display: "none" };
// @ts-ignore
// noinspection JSConstantReassignment
managedIframe.sandbox = "allow-scripts allow-downloads allow-downloads-without-user-activation";
onLoadPromise = new Promise(resolve => {
managedIframe.onload = () => {
resolve();
};
managedIframe.src = "usercontent/"; // XXX: Should come from the skin
});
return { iframe: managedIframe, onLoadPromise };
}
// TODO: If we decide to keep the download link behaviour, we should bring the style management into here.
/**
* Helper to handle safe file downloads. This operates off an iframe for reasons described
* by the blob helpers. By default, this will use a hidden iframe to manage the download
* through a user content wrapper, but can be given an iframe reference if the caller needs
* additional control over the styling/position of the iframe itself.
*/
export class FileDownloader {
private onLoadPromise: Promise<void>;
/**
* Creates a new file downloader
* @param iframeFn Function to get a pre-configured iframe. Set to null to have the downloader
* use a generic, hidden, iframe.
*/
constructor(private iframeFn: getIframeFn = null) {
}
private get iframe(): HTMLIFrameElement {
const iframe = this.iframeFn?.();
if (!iframe) {
const managed = getManagedIframe();
this.onLoadPromise = managed.onLoadPromise;
return managed.iframe;
}
this.onLoadPromise = null;
return iframe;
}
public async download({blob, name, autoDownload = true, opts = DEFAULT_STYLES}: DownloadOptions) {
const iframe = this.iframe; // get the iframe first just in case we need to await onload
console.log("@@", {blob, name, autoDownload, opts, iframe, m: iframe === managedIframe, p: this.onLoadPromise});
if (this.onLoadPromise) await this.onLoadPromise;
iframe.contentWindow.postMessage({
...opts,
blob: blob,
download: name,
auto: autoDownload,
}, '*');
}
}