Merge pull request #195 from vector-im/bwindels/file-downloads

File downloads
This commit is contained in:
Bruno Windels 2020-11-10 18:14:49 +00:00 committed by GitHub
commit d88c3f6dea
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 233 additions and 4 deletions

View File

@ -0,0 +1,24 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
</head>
<body>
<a id="link" href="#">Download!</a>
<script type="text/javascript">
var link = document.getElementById("link");
function download(blob, filename) {
var url = URL.createObjectURL(blob);
link.href = url;
link.download = filename;
link.click();
URL.revokeObjectURL(url);
}
window.addEventListener("message", function(event) {
if (event.data.type === "download") {
download(event.data.blob, event.data.filename);
}
});
</script>
</body>
</html>

View File

@ -23,6 +23,7 @@
import {Platform} from "./src/platform/web/Platform.js";
main(new Platform(document.body, {
worker: "src/worker.js",
downloadSandbox: "assets/download-sandbox.html",
olm: {
wasm: "lib/olm/olm.wasm",
legacyBundle: "lib/olm/olm_legacy.js",

View File

@ -78,6 +78,10 @@ async function build({modernOnly}) {
]));
await assets.write(`worker.js`, await buildJsLegacy("src/platform/web/worker/main.js", ['src/platform/web/worker/polyfill.js']));
}
// copy over non-theme assets
const downloadSandbox = "download-sandbox.html";
let downloadSandboxHtml = await fs.readFile(path.join(projectDir, `assets/${downloadSandbox}`));
await assets.write(downloadSandbox, downloadSandboxHtml);
// creates the directories where the theme css bundles are placed in,
// and writes to assets, so the build bundles can translate them, so do it first
await copyThemeAssets(themes, assets);
@ -143,6 +147,7 @@ async function buildHtml(doc, version, globalHash, modernOnly, assets) {
});
const pathsJSON = JSON.stringify({
worker: assets.has("worker.js") ? assets.resolve(`worker.js`) : null,
downloadSandbox: assets.resolve("download-sandbox.html"),
serviceWorker: "sw.js",
olm: {
wasm: assets.resolve("olm.wasm"),
@ -234,6 +239,7 @@ function isPreCached(asset) {
asset.endsWith(".png") ||
asset.endsWith(".css") ||
asset.endsWith(".wasm") ||
asset.endsWith(".html") ||
// most environments don't need the worker
asset.endsWith(".js") && !NON_PRECACHED_JS.includes(asset);
}

View File

@ -0,0 +1,70 @@
/*
Copyright 2020 Bruno Windels <bruno@windels.cloud>
Copyright 2020 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.
*/
import {MessageTile} from "./MessageTile.js";
import {formatSize} from "../../../../../utils/formatSize.js";
export class FileTile extends MessageTile {
constructor(options) {
super(options);
this._error = null;
this._downloading = false;
}
async download() {
if (this._downloading) {
return;
}
const content = this._getContent();
const filename = content.body;
this._downloading = true;
this.emitChange("label");
let bufferHandle;
try {
bufferHandle = await this._mediaRepository.downloadAttachment(content);
this.platform.offerSaveBufferHandle(bufferHandle, filename);
} catch (err) {
this._error = err;
} finally {
bufferHandle?.dispose();
this._downloading = false;
}
this.emitChange("label");
}
get label() {
if (this._error) {
return `Could not decrypt file: ${this._error.message}`;
}
const content = this._getContent();
const filename = content.body;
const size = formatSize(content.info?.size);
if (this._downloading) {
return this.i18n`Downloading ${filename} (${size})…`;
} else {
return this.i18n`Download ${filename} (${size})`;
}
}
get error() {
return null;
}
get shape() {
return "file";
}
}

View File

@ -35,7 +35,7 @@ export class ImageTile extends MessageTile {
}
async _loadEncryptedFile(file) {
const bufferHandle = await this._mediaRepository.downloadEncryptedFile(file);
const bufferHandle = await this._mediaRepository.downloadEncryptedFile(file, true);
if (this.isDisposed) {
bufferHandle.dispose();
return;

View File

@ -17,6 +17,7 @@ limitations under the License.
import {GapTile} from "./tiles/GapTile.js";
import {TextTile} from "./tiles/TextTile.js";
import {ImageTile} from "./tiles/ImageTile.js";
import {FileTile} from "./tiles/FileTile.js";
import {LocationTile} from "./tiles/LocationTile.js";
import {RoomNameTile} from "./tiles/RoomNameTile.js";
import {RoomMemberTile} from "./tiles/RoomMemberTile.js";
@ -40,6 +41,8 @@ export function tilesCreator(baseOptions) {
return new TextTile(options);
case "m.image":
return new ImageTile(options);
case "m.file":
return new FileTile(options);
case "m.location":
return new LocationTile(options);
default:

View File

@ -52,10 +52,25 @@ export class MediaRepository {
}
}
async downloadEncryptedFile(fileEntry) {
async downloadEncryptedFile(fileEntry, cache = false) {
const url = this.mxcUrl(fileEntry.url);
const {body: encryptedBuffer} = await this._platform.request(url, {method: "GET", format: "buffer", cache: true}).response();
const {body: encryptedBuffer} = await this._platform.request(url, {method: "GET", format: "buffer", cache}).response();
const decryptedBuffer = await decryptAttachment(this._platform.crypto, encryptedBuffer, fileEntry);
return this._platform.createBufferHandle(decryptedBuffer, fileEntry.mimetype);
}
async downloadPlaintextFile(mxcUrl, mimetype, cache = false) {
const url = this.mxcUrl(mxcUrl);
const {body: buffer} = await this._platform.request(url, {method: "GET", format: "buffer", cache}).response();
return this._platform.createBufferHandle(buffer, mimetype);
}
async downloadAttachment(content, cache = false) {
if (content.file) {
return this.downloadEncryptedFile(content.file, cache);
} else {
return this.downloadPlaintextFile(content.url, content.info?.mimetype, cache);
}
}
}

View File

@ -28,6 +28,7 @@ import {Crypto} from "./dom/Crypto.js";
import {estimateStorageUsage} from "./dom/StorageEstimate.js";
import {WorkerPool} from "./dom/WorkerPool.js";
import {BufferHandle} from "./dom/BufferHandle.js";
import {downloadInIframe} from "./dom/download.js";
function addScript(src) {
return new Promise(function (resolve, reject) {
@ -133,4 +134,12 @@ export class Platform {
createBufferHandle(buffer, mimetype) {
return new BufferHandle(buffer, mimetype);
}
offerSaveBufferHandle(bufferHandle, filename) {
if (navigator.msSaveBlob) {
navigator.msSaveBlob(bufferHandle.blob, filename);
} else {
downloadInIframe(this._container, this._paths.downloadSandbox, bufferHandle.blob, filename);
}
}
}

View File

@ -0,0 +1,37 @@
/*
Copyright 2020 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 async function downloadInIframe(container, iframeSrc, blob, filename) {
let iframe = container.querySelector("iframe.downloadSandbox");
if (!iframe) {
iframe = document.createElement("iframe");
iframe.setAttribute("sandbox", "allow-scripts allow-downloads allow-downloads-without-user-activation");
iframe.setAttribute("src", iframeSrc);
iframe.className = "downloadSandbox";
container.appendChild(iframe);
let detach;
await new Promise((resolve, reject) => {
detach = () => {
iframe.removeEventListener("load", resolve);
iframe.removeEventListener("error", reject);
}
iframe.addEventListener("load", resolve);
iframe.addEventListener("error", reject);
});
detach();
}
iframe.contentWindow.postMessage({type: "download", blob: blob, filename: filename}, "*");
}

View File

@ -49,3 +49,7 @@ body.hydrogen {
input::-ms-clear {
display: none;
}
.hydrogen > iframe.downloadSandbox {
display: none;
}

View File

@ -18,6 +18,7 @@ import {ListView} from "../../general/ListView.js";
import {GapView} from "./timeline/GapView.js";
import {TextMessageView} from "./timeline/TextMessageView.js";
import {ImageView} from "./timeline/ImageView.js";
import {FileView} from "./timeline/FileView.js";
import {AnnouncementView} from "./timeline/AnnouncementView.js";
function viewClassForEntry(entry) {
@ -28,6 +29,7 @@ function viewClassForEntry(entry) {
case "message-status":
return TextMessageView;
case "image": return ImageView;
case "file": return FileView;
}
}

View File

@ -0,0 +1,29 @@
/*
Copyright 2020 Bruno Windels <bruno@windels.cloud>
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.
*/
import {TemplateView} from "../../../general/TemplateView.js";
import {renderMessage} from "./common.js";
export class FileView extends TemplateView {
render(t, vm) {
return renderMessage(t, vm, [
t.p([
t.button({className: "link", onClick: () => vm.download()}, vm => vm.label),
t.time({className: {hidden: !vm.date}}, vm.date + " " + vm.time)
])
]);
}
}

29
src/utils/formatSize.js Normal file
View File

@ -0,0 +1,29 @@
/*
Copyright 2020 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 function formatSize(size, decimals = 2) {
if (Number.isSafeInteger(size)) {
const base = Math.min(3, Math.floor(Math.log(size) / Math.log(1024)));
const decimalFactor = Math.pow(10, decimals);
const formattedSize = Math.round((size / Math.pow(1024, base)) * decimalFactor) / decimalFactor;
switch (base) {
case 0: return `${formattedSize} bytes`;
case 1: return `${formattedSize} KB`;
case 2: return `${formattedSize} MB`;
case 3: return `${formattedSize} GB`;
}
}
}