Skip to content
This repository was archived by the owner on Sep 11, 2024. It is now read-only.

Commit 1efd226

Browse files
authored
Merge pull request #6510 from matrix-org/travis/relfix/vm/dl-1
[Release] Improve download interactions
2 parents fe2993e + c5c58a5 commit 1efd226

File tree

6 files changed

+224
-77
lines changed

6 files changed

+224
-77
lines changed

res/css/views/messages/_MFileBody.scss

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
Copyright 2015, 2016, 2021 The Matrix.org Foundation C.I.C.
2+
Copyright 2015 - 2021 The Matrix.org Foundation C.I.C.
33
44
Licensed under the Apache License, Version 2.0 (the "License");
55
you may not use this file except in compliance with the License.
@@ -60,6 +60,8 @@ limitations under the License.
6060
}
6161

6262
.mx_MFileBody_info {
63+
cursor: pointer;
64+
6365
.mx_MFileBody_info_icon {
6466
background-color: $message-body-panel-icon-bg-color;
6567
border-radius: 20px;

src/components/views/messages/DownloadActionButton.tsx

Lines changed: 12 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -16,12 +16,13 @@ limitations under the License.
1616

1717
import { MatrixEvent } from "matrix-js-sdk/src";
1818
import { MediaEventHelper } from "../../../utils/MediaEventHelper";
19-
import React, { createRef } from "react";
19+
import React from "react";
2020
import { RovingAccessibleTooltipButton } from "../../../accessibility/RovingTabIndex";
2121
import Spinner from "../elements/Spinner";
2222
import classNames from "classnames";
2323
import { _t } from "../../../languageHandler";
2424
import { replaceableComponent } from "../../../utils/replaceableComponent";
25+
import { FileDownloader } from "../../../utils/FileDownloader";
2526

2627
interface IProps {
2728
mxEvent: MatrixEvent;
@@ -39,7 +40,7 @@ interface IState {
3940

4041
@replaceableComponent("views.messages.DownloadActionButton")
4142
export default class DownloadActionButton extends React.PureComponent<IProps, IState> {
42-
private iframe: React.RefObject<HTMLIFrameElement> = createRef();
43+
private downloader = new FileDownloader();
4344

4445
public constructor(props: IProps) {
4546
super(props);
@@ -56,27 +57,21 @@ export default class DownloadActionButton extends React.PureComponent<IProps, IS
5657

5758
if (this.state.blob) {
5859
// Cheat and trigger a download, again.
59-
return this.onFrameLoad();
60+
return this.doDownload();
6061
}
6162

6263
const blob = await this.props.mediaEventHelperGet().sourceBlob.value;
6364
this.setState({ blob });
65+
await this.doDownload();
6466
};
6567

66-
private onFrameLoad = () => {
67-
this.setState({ loading: false });
68-
69-
// we aren't showing the iframe, so we can send over the bare minimum styles and such.
70-
this.iframe.current.contentWindow.postMessage({
71-
imgSrc: "", // no image
72-
imgStyle: null,
73-
style: "",
68+
private async doDownload() {
69+
await this.downloader.download({
7470
blob: this.state.blob,
75-
download: this.props.mediaEventHelperGet().fileName,
76-
textContent: "",
77-
auto: true, // autodownload
78-
}, '*');
79-
};
71+
name: this.props.mediaEventHelperGet().fileName,
72+
});
73+
this.setState({ loading: false });
74+
}
8075

8176
public render() {
8277
let spinner: JSX.Element;
@@ -92,18 +87,11 @@ export default class DownloadActionButton extends React.PureComponent<IProps, IS
9287

9388
return <RovingAccessibleTooltipButton
9489
className={classes}
95-
title={spinner ? _t("Downloading") : _t("Download")}
90+
title={spinner ? _t("Decrypting") : _t("Download")}
9691
onClick={this.onDownloadClick}
9792
disabled={!!spinner}
9893
>
9994
{ spinner }
100-
{ this.state.blob && <iframe
101-
src="usercontent/" // XXX: Like MFileBody, this should come from the skin
102-
ref={this.iframe}
103-
onLoad={this.onFrameLoad}
104-
sandbox="allow-scripts allow-downloads allow-downloads-without-user-activation"
105-
style={{ display: "none" }}
106-
/> }
10795
</RovingAccessibleTooltipButton>;
10896
}
10997
}

src/components/views/messages/MFileBody.tsx

Lines changed: 87 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@ import { TileShape } from "../rooms/EventTile";
2626
import { presentableTextForFile } from "../../../utils/FileUtils";
2727
import { IMediaEventContent } from "../../../customisations/models/IMediaEventContent";
2828
import { IBodyProps } from "./IBodyProps";
29+
import { FileDownloader } from "../../../utils/FileDownloader";
30+
import TextWithTooltip from "../elements/TextWithTooltip";
2931

3032
export let DOWNLOAD_ICON_URL; // cached copy of the download.svg asset for the sandboxed iframe later on
3133

@@ -111,13 +113,40 @@ export default class MFileBody extends React.Component<IProps, IState> {
111113
private iframe: React.RefObject<HTMLIFrameElement> = createRef();
112114
private dummyLink: React.RefObject<HTMLAnchorElement> = createRef();
113115
private userDidClick = false;
116+
private fileDownloader: FileDownloader = new FileDownloader(() => this.iframe.current);
114117

115118
public constructor(props: IProps) {
116119
super(props);
117120

118121
this.state = {};
119122
}
120123

124+
private get content(): IMediaEventContent {
125+
return this.props.mxEvent.getContent<IMediaEventContent>();
126+
}
127+
128+
private get fileName(): string {
129+
return this.content.body && this.content.body.length > 0 ? this.content.body : _t("Attachment");
130+
}
131+
132+
private get linkText(): string {
133+
return presentableTextForFile(this.content);
134+
}
135+
136+
private downloadFile(fileName: string, text: string) {
137+
this.fileDownloader.download({
138+
blob: this.state.decryptedBlob,
139+
name: fileName,
140+
autoDownload: this.userDidClick,
141+
opts: {
142+
imgSrc: DOWNLOAD_ICON_URL,
143+
imgStyle: null,
144+
style: computedStyle(this.dummyLink.current),
145+
textContent: _t("Download %(text)s", { text }),
146+
},
147+
});
148+
}
149+
121150
private getContentUrl(): string {
122151
const media = mediaFromContent(this.props.mxEvent.getContent());
123152
return media.srcHttp;
@@ -129,24 +158,56 @@ export default class MFileBody extends React.Component<IProps, IState> {
129158
}
130159
}
131160

161+
private decryptFile = async (): Promise<void> => {
162+
if (this.state.decryptedBlob) {
163+
return;
164+
}
165+
try {
166+
this.userDidClick = true;
167+
this.setState({
168+
decryptedBlob: await this.props.mediaEventHelper.sourceBlob.value,
169+
});
170+
} catch (err) {
171+
console.warn("Unable to decrypt attachment: ", err);
172+
Modal.createTrackedDialog('Error decrypting attachment', '', ErrorDialog, {
173+
title: _t("Error"),
174+
description: _t("Error decrypting attachment"),
175+
});
176+
}
177+
};
178+
179+
private onPlaceholderClick = async () => {
180+
const mediaHelper = this.props.mediaEventHelper;
181+
if (mediaHelper.media.isEncrypted) {
182+
await this.decryptFile();
183+
this.downloadFile(this.fileName, this.linkText);
184+
} else {
185+
// As a button we're missing the `download` attribute for styling reasons, so
186+
// download with the file downloader.
187+
this.fileDownloader.download({
188+
blob: await mediaHelper.sourceBlob.value,
189+
name: this.fileName,
190+
});
191+
}
192+
};
193+
132194
public render() {
133-
const content = this.props.mxEvent.getContent<IMediaEventContent>();
134-
const text = presentableTextForFile(content);
135195
const isEncrypted = this.props.mediaEventHelper.media.isEncrypted;
136-
const fileName = content.body && content.body.length > 0 ? content.body : _t("Attachment");
137196
const contentUrl = this.getContentUrl();
138-
const fileSize = content.info ? content.info.size : null;
139-
const fileType = content.info ? content.info.mimetype : "application/octet-stream";
197+
const fileSize = this.content.info ? this.content.info.size : null;
198+
const fileType = this.content.info ? this.content.info.mimetype : "application/octet-stream";
140199

141-
let placeholder = null;
200+
let placeholder: React.ReactNode = null;
142201
if (this.props.showGenericPlaceholder) {
143202
placeholder = (
144-
<div className="mx_MediaBody mx_MFileBody_info">
203+
<AccessibleButton className="mx_MediaBody mx_MFileBody_info" onClick={this.onPlaceholderClick}>
145204
<span className="mx_MFileBody_info_icon" />
146-
<span className="mx_MFileBody_info_filename">
147-
{ presentableTextForFile(content, _t("Attachment"), false) }
148-
</span>
149-
</div>
205+
<TextWithTooltip tooltip={presentableTextForFile(this.content, _t("Attachment"), true)}>
206+
<span className="mx_MFileBody_info_filename">
207+
{ presentableTextForFile(this.content, _t("Attachment"), true, true) }
208+
</span>
209+
</TextWithTooltip>
210+
</AccessibleButton>
150211
);
151212
}
152213

@@ -157,52 +218,21 @@ export default class MFileBody extends React.Component<IProps, IState> {
157218
// Need to decrypt the attachment
158219
// Wait for the user to click on the link before downloading
159220
// and decrypting the attachment.
160-
const decrypt = async () => {
161-
try {
162-
this.userDidClick = true;
163-
this.setState({
164-
decryptedBlob: await this.props.mediaEventHelper.sourceBlob.value,
165-
});
166-
} catch (err) {
167-
console.warn("Unable to decrypt attachment: ", err);
168-
Modal.createTrackedDialog('Error decrypting attachment', '', ErrorDialog, {
169-
title: _t("Error"),
170-
description: _t("Error decrypting attachment"),
171-
});
172-
}
173-
};
174221

175222
// This button should actually Download because usercontent/ will try to click itself
176223
// but it is not guaranteed between various browsers' settings.
177224
return (
178225
<span className="mx_MFileBody">
179226
{ placeholder }
180227
{ showDownloadLink && <div className="mx_MFileBody_download">
181-
<AccessibleButton onClick={decrypt}>
182-
{ _t("Decrypt %(text)s", { text: text }) }
228+
<AccessibleButton onClick={this.decryptFile}>
229+
{ _t("Decrypt %(text)s", { text: this.linkText }) }
183230
</AccessibleButton>
184231
</div> }
185232
</span>
186233
);
187234
}
188235

189-
// When the iframe loads we tell it to render a download link
190-
const onIframeLoad = (ev) => {
191-
ev.target.contentWindow.postMessage({
192-
imgSrc: DOWNLOAD_ICON_URL,
193-
imgStyle: null, // it handles this internally for us. Useful if a downstream changes the icon.
194-
style: computedStyle(this.dummyLink.current),
195-
blob: this.state.decryptedBlob,
196-
// Set a download attribute for encrypted files so that the file
197-
// will have the correct name when the user tries to download it.
198-
// We can't provide a Content-Disposition header like we would for HTTP.
199-
download: fileName,
200-
textContent: _t("Download %(text)s", { text: text }),
201-
// only auto-download if a user triggered this iframe explicitly
202-
auto: this.userDidClick,
203-
}, "*");
204-
};
205-
206236
const url = "usercontent/"; // XXX: this path should probably be passed from the skin
207237

208238
// If the attachment is encrypted then put the link inside an iframe.
@@ -218,9 +248,16 @@ export default class MFileBody extends React.Component<IProps, IState> {
218248
*/ }
219249
<a ref={this.dummyLink} />
220250
</div>
251+
{ /*
252+
TODO: Move iframe (and dummy link) into FileDownloader.
253+
We currently have it set up this way because of styles applied to the iframe
254+
itself which cannot be easily handled/overridden by the FileDownloader. In
255+
future, the download link may disappear entirely at which point it could also
256+
be suitable to just remove this bit of code.
257+
*/ }
221258
<iframe
222259
src={url}
223-
onLoad={onIframeLoad}
260+
onLoad={() => this.downloadFile(this.fileName, this.linkText)}
224261
ref={this.iframe}
225262
sandbox="allow-scripts allow-downloads allow-downloads-without-user-activation" />
226263
</div> }
@@ -259,7 +296,7 @@ export default class MFileBody extends React.Component<IProps, IState> {
259296

260297
// We have to create an anchor to download the file
261298
const tempAnchor = document.createElement('a');
262-
tempAnchor.download = fileName;
299+
tempAnchor.download = this.fileName;
263300
tempAnchor.href = blobUrl;
264301
document.body.appendChild(tempAnchor); // for firefox: https://stackoverflow.com/a/32226068
265302
tempAnchor.click();
@@ -268,7 +305,7 @@ export default class MFileBody extends React.Component<IProps, IState> {
268305
};
269306
} else {
270307
// Else we are hoping the browser will do the right thing
271-
downloadProps["download"] = fileName;
308+
downloadProps["download"] = this.fileName;
272309
}
273310

274311
return (
@@ -277,16 +314,16 @@ export default class MFileBody extends React.Component<IProps, IState> {
277314
{ showDownloadLink && <div className="mx_MFileBody_download">
278315
<a {...downloadProps}>
279316
<span className="mx_MFileBody_download_icon" />
280-
{ _t("Download %(text)s", { text: text }) }
317+
{ _t("Download %(text)s", { text: this.linkText }) }
281318
</a>
282319
{ this.props.tileShape === TileShape.FileGrid && <div className="mx_MImageBody_size">
283-
{ content.info && content.info.size ? filesize(content.info.size) : "" }
320+
{ this.content.info && this.content.info.size ? filesize(this.content.info.size) : "" }
284321
</div> }
285322
</div> }
286323
</span>
287324
);
288325
} else {
289-
const extra = text ? (': ' + text) : '';
326+
const extra = this.linkText ? (': ' + this.linkText) : '';
290327
return <span className="mx_MFileBody">
291328
{ placeholder }
292329
{ _t("Invalid file%(extra)s", { extra: extra }) }

src/i18n/strings/en_EN.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1882,7 +1882,7 @@
18821882
"Saturday": "Saturday",
18831883
"Today": "Today",
18841884
"Yesterday": "Yesterday",
1885-
"Downloading": "Downloading",
1885+
"Decrypting": "Decrypting",
18861886
"Download": "Download",
18871887
"View Source": "View Source",
18881888
"Messages here are end-to-end encrypted. Verify %(displayName)s in their profile - tap on their avatar.": "Messages here are end-to-end encrypted. Verify %(displayName)s in their profile - tap on their avatar.",
@@ -1897,9 +1897,9 @@
18971897
"Retry": "Retry",
18981898
"Reply": "Reply",
18991899
"Message Actions": "Message Actions",
1900+
"Download %(text)s": "Download %(text)s",
19001901
"Error decrypting attachment": "Error decrypting attachment",
19011902
"Decrypt %(text)s": "Decrypt %(text)s",
1902-
"Download %(text)s": "Download %(text)s",
19031903
"Invalid file%(extra)s": "Invalid file%(extra)s",
19041904
"Error decrypting image": "Error decrypting image",
19051905
"Show image": "Show image",

0 commit comments

Comments
 (0)