From 4477073d6d47e0e00fc02bcd5df6bb1af64778c6 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Tue, 10 Nov 2020 17:23:23 +0100 Subject: [PATCH 1/7] add platform method to offer saving a buffer handle --- assets/download-sandbox.html | 23 ++++++++++++++++++++ index.html | 1 + scripts/build.mjs | 6 ++++++ src/platform/web/Platform.js | 9 ++++++++ src/platform/web/dom/download.js | 37 ++++++++++++++++++++++++++++++++ src/platform/web/ui/css/main.css | 4 ++++ 6 files changed, 80 insertions(+) create mode 100644 assets/download-sandbox.html create mode 100644 src/platform/web/dom/download.js diff --git a/assets/download-sandbox.html b/assets/download-sandbox.html new file mode 100644 index 00000000..ecb4886e --- /dev/null +++ b/assets/download-sandbox.html @@ -0,0 +1,23 @@ + + + + + + + Download! + + + diff --git a/index.html b/index.html index af5f513b..3f077e04 100644 --- a/index.html +++ b/index.html @@ -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", diff --git a/scripts/build.mjs b/scripts/build.mjs index 718802d1..dbbeabcd 100644 --- a/scripts/build.mjs +++ b/scripts/build.mjs @@ -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); } diff --git a/src/platform/web/Platform.js b/src/platform/web/Platform.js index 87b055cd..0f5222bd 100644 --- a/src/platform/web/Platform.js +++ b/src/platform/web/Platform.js @@ -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); + } + } } diff --git a/src/platform/web/dom/download.js b/src/platform/web/dom/download.js new file mode 100644 index 00000000..4e8aaece --- /dev/null +++ b/src/platform/web/dom/download.js @@ -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}, "*"); +} diff --git a/src/platform/web/ui/css/main.css b/src/platform/web/ui/css/main.css index aa22839e..913141b6 100644 --- a/src/platform/web/ui/css/main.css +++ b/src/platform/web/ui/css/main.css @@ -49,3 +49,7 @@ body.hydrogen { input::-ms-clear { display: none; } + +.hydrogen > iframe.downloadSandbox { + display: none; +} From 373a42c7a86f32c4d02e79d4df80382b387abf4d Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Tue, 10 Nov 2020 17:49:48 +0100 Subject: [PATCH 2/7] allow downloading plaintext attachments also, with or without cache --- .../session/room/timeline/tiles/ImageTile.js | 2 +- src/matrix/net/MediaRepository.js | 19 +++++++++++++++++-- 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/src/domain/session/room/timeline/tiles/ImageTile.js b/src/domain/session/room/timeline/tiles/ImageTile.js index 1e31e414..71b5b9d6 100644 --- a/src/domain/session/room/timeline/tiles/ImageTile.js +++ b/src/domain/session/room/timeline/tiles/ImageTile.js @@ -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; diff --git a/src/matrix/net/MediaRepository.js b/src/matrix/net/MediaRepository.js index a20b6d1c..f04c387f 100644 --- a/src/matrix/net/MediaRepository.js +++ b/src/matrix/net/MediaRepository.js @@ -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); + } + } + } From 21a7ec0dff5b25b92fe820657083f7b2d4038785 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Tue, 10 Nov 2020 17:50:20 +0100 Subject: [PATCH 3/7] byte size formatting --- src/utils/formatSize.js | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 src/utils/formatSize.js diff --git a/src/utils/formatSize.js b/src/utils/formatSize.js new file mode 100644 index 00000000..c8a6ea1e --- /dev/null +++ b/src/utils/formatSize.js @@ -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`; + } + } +} From a3ca0feda976ef43c0adff7bf7234d38aef2afb0 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Tue, 10 Nov 2020 17:50:38 +0100 Subject: [PATCH 4/7] file tile view model --- .../session/room/timeline/tiles/FileTile.js | 68 +++++++++++++++++++ 1 file changed, 68 insertions(+) create mode 100644 src/domain/session/room/timeline/tiles/FileTile.js diff --git a/src/domain/session/room/timeline/tiles/FileTile.js b/src/domain/session/room/timeline/tiles/FileTile.js new file mode 100644 index 00000000..2891e8be --- /dev/null +++ b/src/domain/session/room/timeline/tiles/FileTile.js @@ -0,0 +1,68 @@ +/* +Copyright 2020 Bruno Windels +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"); + try { + const bufferHandle = await this._mediaRepository.downloadAttachment(content); + this.platform.offerSaveBufferHandle(bufferHandle, filename); + } catch (err) { + this._error = err; + } finally { + 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"; + } +} From 2d8000d11dad598cf9268e5087e29b576c88e4cf Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Tue, 10 Nov 2020 17:50:53 +0100 Subject: [PATCH 5/7] file tile view --- .../session/room/timeline/tilesCreator.js | 3 ++ .../web/ui/session/room/TimelineList.js | 2 ++ .../web/ui/session/room/timeline/FileView.js | 29 +++++++++++++++++++ 3 files changed, 34 insertions(+) create mode 100644 src/platform/web/ui/session/room/timeline/FileView.js diff --git a/src/domain/session/room/timeline/tilesCreator.js b/src/domain/session/room/timeline/tilesCreator.js index 549ad65d..9ac27a54 100644 --- a/src/domain/session/room/timeline/tilesCreator.js +++ b/src/domain/session/room/timeline/tilesCreator.js @@ -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: diff --git a/src/platform/web/ui/session/room/TimelineList.js b/src/platform/web/ui/session/room/TimelineList.js index 0cadbb71..5d0f3fbe 100644 --- a/src/platform/web/ui/session/room/TimelineList.js +++ b/src/platform/web/ui/session/room/TimelineList.js @@ -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; } } diff --git a/src/platform/web/ui/session/room/timeline/FileView.js b/src/platform/web/ui/session/room/timeline/FileView.js new file mode 100644 index 00000000..cb99dd0a --- /dev/null +++ b/src/platform/web/ui/session/room/timeline/FileView.js @@ -0,0 +1,29 @@ +/* +Copyright 2020 Bruno Windels + +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) + ]) + ]); + } +} From 6fa94712920186929f0fae020176aaf7e398cf7a Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Tue, 10 Nov 2020 17:51:39 +0100 Subject: [PATCH 6/7] remove trailing whitespace --- src/platform/web/Platform.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/platform/web/Platform.js b/src/platform/web/Platform.js index 0f5222bd..745122d9 100644 --- a/src/platform/web/Platform.js +++ b/src/platform/web/Platform.js @@ -101,7 +101,7 @@ export class Platform { this.request = xhrRequest; } const isIE11 = !!window.MSInputMethodContext && !!document.documentMode; - this.isIE11 = isIE11; + this.isIE11 = isIE11; } get updateService() { From c65e8bea1113a588ec807d776770cb4b9a58335f Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Tue, 10 Nov 2020 19:05:50 +0100 Subject: [PATCH 7/7] clean up properly --- assets/download-sandbox.html | 1 + src/domain/session/room/timeline/tiles/FileTile.js | 4 +++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/assets/download-sandbox.html b/assets/download-sandbox.html index ecb4886e..d8b26fad 100644 --- a/assets/download-sandbox.html +++ b/assets/download-sandbox.html @@ -12,6 +12,7 @@ link.href = url; link.download = filename; link.click(); + URL.revokeObjectURL(url); } window.addEventListener("message", function(event) { if (event.data.type === "download") { diff --git a/src/domain/session/room/timeline/tiles/FileTile.js b/src/domain/session/room/timeline/tiles/FileTile.js index 2891e8be..b2afd728 100644 --- a/src/domain/session/room/timeline/tiles/FileTile.js +++ b/src/domain/session/room/timeline/tiles/FileTile.js @@ -33,12 +33,14 @@ export class FileTile extends MessageTile { const filename = content.body; this._downloading = true; this.emitChange("label"); + let bufferHandle; try { - const bufferHandle = await this._mediaRepository.downloadAttachment(content); + 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");