diff --git a/assets/download-sandbox.html b/assets/download-sandbox.html
new file mode 100644
index 00000000..d8b26fad
--- /dev/null
+++ b/assets/download-sandbox.html
@@ -0,0 +1,24 @@
+
+
+
+
+
+
+ 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/domain/session/room/timeline/tiles/FileTile.js b/src/domain/session/room/timeline/tiles/FileTile.js
new file mode 100644
index 00000000..b2afd728
--- /dev/null
+++ b/src/domain/session/room/timeline/tiles/FileTile.js
@@ -0,0 +1,70 @@
+/*
+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");
+ 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";
+ }
+}
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/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/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);
+ }
+ }
+
}
diff --git a/src/platform/web/Platform.js b/src/platform/web/Platform.js
index 87b055cd..745122d9 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) {
@@ -100,7 +101,7 @@ export class Platform {
this.request = xhrRequest;
}
const isIE11 = !!window.MSInputMethodContext && !!document.documentMode;
- this.isIE11 = isIE11;
+ this.isIE11 = isIE11;
}
get updateService() {
@@ -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;
+}
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)
+ ])
+ ]);
+ }
+}
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`;
+ }
+ }
+}