From 1ad8af34d193096e6fdf5df59b4ddff798e5f165 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 12 Nov 2020 13:50:06 +0100 Subject: [PATCH 01/36] add thumbnailing code --- src/platform/web/Platform.js | 11 +++- src/platform/web/dom/BlobHandle.js | 4 +- src/platform/web/dom/ImageHandle.js | 96 +++++++++++++++++++++++++++++ 3 files changed, 108 insertions(+), 3 deletions(-) create mode 100644 src/platform/web/dom/ImageHandle.js diff --git a/src/platform/web/Platform.js b/src/platform/web/Platform.js index 6a981c64..f99fd13c 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 {BlobHandle} from "./dom/BlobHandle.js"; +import {hasReadPixelPermission, ImageHandle} from "./dom/ImageHandle.js"; import {downloadInIframe} from "./dom/download.js"; function addScript(src) { @@ -156,7 +157,7 @@ export class Platform { const file = input.files[0]; this._container.removeChild(input); if (file) { - resolve({name: file.name, blob: BlobHandle.fromFile(file)}); + resolve({name: file.name, blob: BlobHandle.fromBlob(file)}); } else { reject(new Error("No file selected")); } @@ -168,4 +169,12 @@ export class Platform { input.click(); return promise; } + + async loadImage(blob) { + return ImageHandle.fromBlob(blob); + } + + hasReadPixelPermission() { + return hasReadPixelPermission(); + } } diff --git a/src/platform/web/dom/BlobHandle.js b/src/platform/web/dom/BlobHandle.js index 00098de1..fb1b150a 100644 --- a/src/platform/web/dom/BlobHandle.js +++ b/src/platform/web/dom/BlobHandle.js @@ -84,9 +84,9 @@ export class BlobHandle { return new BlobHandle(new Blob([buffer], {type: mimetype}), buffer); } - static fromFile(file) { + static fromBlob(blob) { // ok to not filter mimetypes as these are local files - return new BlobHandle(file); + return new BlobHandle(blob); } get nativeBlob() { diff --git a/src/platform/web/dom/ImageHandle.js b/src/platform/web/dom/ImageHandle.js new file mode 100644 index 00000000..691ead22 --- /dev/null +++ b/src/platform/web/dom/ImageHandle.js @@ -0,0 +1,96 @@ +/* +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 {BlobHandle} from "./BlobHandle.js"; + +export class ImageHandle { + static async fromBlob(blob) { + const img = await loadImgFromBlob(blob); + const {width, height} = img; + return new ImageHandle(blob, width, height, img); + } + + constructor(blob, width, height, imgElement) { + this.blob = blob; + this.width = width; + this.height = height; + this._imgElement = imgElement; + } + + async _getImgElement() { + if (!this._imgElement) { + this._imgElement = await loadImgFromBlob(this.blob); + } + return this._imgElement; + } + + async scale(maxDimension) { + const aspectRatio = this.width / this.height; + const scaleFactor = Math.min(1, maxDimension / (aspectRatio >= 1 ? this.width : this.height)); + const scaledWidth = this.width * scaleFactor; + const scaledHeight = this.height * scaleFactor; + + const canvas = document.createElement("canvas"); + canvas.width = scaledWidth; + canvas.height = scaledHeight; + const ctx = canvas.getContext("2d"); + const img = await this._getImgElement(); + ctx.drawImage(img, 0, 0, scaledWidth, scaledHeight); + const mimeType = this.blob.mimeType === "image/jpeg" ? "image/jpeg" : "image/png"; + const nativeBlob = await new Promise(resolve => { + canvas.toBlob(resolve, mimeType); + }); + const blob = BlobHandle.fromBlob(nativeBlob); + return new ImageHandle(blob, scaledWidth, scaledHeight, null); + } + + dispose() { + this.blob.dispose(); + } +} + +export function hasReadPixelPermission() { + const canvas = document.createElement("canvas"); + canvas.width = 1; + canvas.height = 1; + const ctx = canvas.getContext("2d"); + const rgb = [ + Math.round(Math.random() * 255), + Math.round(Math.random() * 255), + Math.round(Math.random() * 255), + ] + ctx.fillStyle = `rgb(${rgb[0]}, ${rgb[1]}, ${rgb[2]})`; + ctx.fillRect(0, 0, 1, 1); + const data = ctx.getImageData(0, 0, 1, 1).data; + return data[0] === rgb[0] && data[1] === rgb[1] && data[2] === rgb[2]; +} + +async function loadImgFromBlob(blob) { + const img = document.createElement("img"); + let detach; + const loadPromise = new Promise((resolve, reject) => { + detach = () => { + img.removeEventListener("load", resolve); + img.removeEventListener("error", reject); + }; + img.addEventListener("load", resolve); + img.addEventListener("error", reject); + }); + img.src = blob.url; + await loadPromise; + detach(); + return img; +} From 6fd10b63e5d3d3c4338cf43ecd7774df5f848363 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 12 Nov 2020 13:50:32 +0100 Subject: [PATCH 02/36] add device pixel ratio scaling while at it --- src/domain/session/leftpanel/RoomTileViewModel.js | 3 ++- src/domain/session/room/RoomViewModel.js | 3 ++- src/platform/web/Platform.js | 4 ++++ 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/src/domain/session/leftpanel/RoomTileViewModel.js b/src/domain/session/leftpanel/RoomTileViewModel.js index 6cfea617..1858fce7 100644 --- a/src/domain/session/leftpanel/RoomTileViewModel.js +++ b/src/domain/session/leftpanel/RoomTileViewModel.js @@ -133,7 +133,8 @@ export class RoomTileViewModel extends ViewModel { get avatarUrl() { if (this._room.avatarUrl) { - return this._room.mediaRepository.mxcUrlThumbnail(this._room.avatarUrl, 32, 32, "crop"); + const size = 32 * this.platform.devicePixelRatio; + return this._room.mediaRepository.mxcUrlThumbnail(this._room.avatarUrl, size, size, "crop"); } return null; } diff --git a/src/domain/session/room/RoomViewModel.js b/src/domain/session/room/RoomViewModel.js index c13dd1a3..83fcca3a 100644 --- a/src/domain/session/room/RoomViewModel.js +++ b/src/domain/session/room/RoomViewModel.js @@ -134,7 +134,8 @@ export class RoomViewModel extends ViewModel { get avatarUrl() { if (this._room.avatarUrl) { - return this._room.mediaRepository.mxcUrlThumbnail(this._room.avatarUrl, 32, 32, "crop"); + const size = 32 * this.platform.devicePixelRatio; + return this._room.mediaRepository.mxcUrlThumbnail(this._room.avatarUrl, size, size, "crop"); } return null; } diff --git a/src/platform/web/Platform.js b/src/platform/web/Platform.js index f99fd13c..a49073c9 100644 --- a/src/platform/web/Platform.js +++ b/src/platform/web/Platform.js @@ -177,4 +177,8 @@ export class Platform { hasReadPixelPermission() { return hasReadPixelPermission(); } + + get devicePixelRatio() { + return window.devicePixelRatio || 1; + } } From 9bb521986b5441474ee1344f70a872816c3d33b5 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Fri, 13 Nov 2020 15:57:14 +0100 Subject: [PATCH 03/36] first draft of generic popup and menu views --- prototypes/menu-relative.html | 378 ++++++++++++++++++ src/platform/web/ui/css/layout.css | 7 + .../web/ui/css/themes/element/theme.css | 27 ++ src/platform/web/ui/general/Menu.js | 49 +++ src/platform/web/ui/general/Popup.js | 174 ++++++++ src/platform/web/ui/general/TemplateView.js | 16 +- 6 files changed, 645 insertions(+), 6 deletions(-) create mode 100644 prototypes/menu-relative.html create mode 100644 src/platform/web/ui/general/Menu.js create mode 100644 src/platform/web/ui/general/Popup.js diff --git a/prototypes/menu-relative.html b/prototypes/menu-relative.html new file mode 100644 index 00000000..8b1c79b0 --- /dev/null +++ b/prototypes/menu-relative.html @@ -0,0 +1,378 @@ + + + + + + + +
+
+

Welcome!

+
    +
  • Room xyz
  • +
  • Room xyz
  • +
  • Room xyz
  • +
  • Room xyz
  • +
  • Room xyz
  • +
  • Room xyz
  • +
  • Room xyz
  • +
  • Room xyz
  • +
  • Room xyz
  • +
  • Room xyz
  • +
  • Room xyz
  • +
  • Room xyz
  • +
  • Room xyz
  • +
  • Room xyz
  • +
  • Room xyz
  • +
  • Room xyz
  • +
  • Room xyz
  • +
  • Room xyz
  • +
  • Room xyz
  • +
  • Room xyz
  • +
  • Room xyz
  • +
  • Room xyz
  • +
  • Room xyz
  • +
  • Room xyz
  • +
  • Room xyz
  • +
  • Room xyz
  • +
  • Room xyz
  • +
  • Room xyz
  • +
+
+
+
+

Room xyz

+ +
+
    +
  • Message abc
  • +
  • Message abc
  • +
  • Message abc
  • +
  • Message abc
  • +
  • Message abc
  • +
  • Message abc
  • +
  • Message abc
  • +
  • Message abc
  • +
  • Message abc
  • +
  • Message abc
  • +
  • Message abc
  • +
  • Message abc
  • +
  • Message abc
  • +
  • Message abc
  • +
  • Message abc
  • +
  • Message abc
  • +
  • Message abc
  • +
  • Message abc
  • +
  • Message abc
  • +
  • Message abc
  • +
  • Message abc
  • +
  • Message abc
  • +
  • Message abc
  • +
  • Message abc
  • +
  • Message abc
  • +
  • Message abc
  • +
  • Message abc
  • +
  • Message abc
  • +
  • Message abc
  • +
  • Message abc
  • +
  • Message abc
  • +
  • Message abc
  • +
  • Message abc
  • +
  • Message abc
  • +
  • Message abc
  • +
  • Message abc
  • +
  • Message abc
  • +
  • Message abc
  • +
  • Message abc
  • +
  • Message abc
  • +
  • Message abc
  • +
  • Message abc
  • +
  • Message abc
  • +
  • Message abc
  • +
+
+ + +
+
+
+ + + diff --git a/src/platform/web/ui/css/layout.css b/src/platform/web/ui/css/layout.css index c4a48425..841ab3ef 100644 --- a/src/platform/web/ui/css/layout.css +++ b/src/platform/web/ui/css/layout.css @@ -96,6 +96,8 @@ main { width: 100%; /* otherwise we don't get scrollbars and the content grows as large as it can */ min-height: 0; + /* make popups relative to this element so changing the left panel width doesn't affect their position */ + position: relative; } .RoomView { @@ -163,3 +165,8 @@ main { z-index: 1; pointer-events: none; } + +.menu { + position: absolute; + z-index: 2; +} diff --git a/src/platform/web/ui/css/themes/element/theme.css b/src/platform/web/ui/css/themes/element/theme.css index 57bfc03b..99af88d0 100644 --- a/src/platform/web/ui/css/themes/element/theme.css +++ b/src/platform/web/ui/css/themes/element/theme.css @@ -762,4 +762,31 @@ button.link { width: 200px; } +.menu { + border-radius: 8px; + box-shadow: 2px 2px 10px rgba(0,0,0,0.5); + padding: 4px; + background-color: white; + list-style: none; + margin: 0; +} +.menu button { + border-radius: 4px; + display: block; + border: none; + width: 100%; + background-color: transparent; + text-align: left; + padding: 8px 32px 8px 8px; +} + +.menu button:focus { + background-color: #03B381; + color: white; +} + +.menu button:hover { + background-color: #03B381; + color: white; +} diff --git a/src/platform/web/ui/general/Menu.js b/src/platform/web/ui/general/Menu.js new file mode 100644 index 00000000..10c5f07e --- /dev/null +++ b/src/platform/web/ui/general/Menu.js @@ -0,0 +1,49 @@ +/* +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 {TemplateView} from "./TemplateView.js"; + +export class Menu extends TemplateView { + static option(label, callback) { + return new MenuOption(label, callback); + } + + constructor(options) { + super(); + this._options = options; + } + + render(t) { + return t.ul({className: "menu", role: "menu"}, this._options.map(o => { + return t.li({ + className: o.icon ? `icon ${o.icon}` : "", + }, t.button({onClick: o.callback}, o.label)); + })); + } +} + +class MenuOption { + constructor(label, callback) { + this.label = label; + this.callback = callback; + this.icon = null; + } + + setIcon(className) { + this.icon = className; + return this; + } +} diff --git a/src/platform/web/ui/general/Popup.js b/src/platform/web/ui/general/Popup.js new file mode 100644 index 00000000..e6d18033 --- /dev/null +++ b/src/platform/web/ui/general/Popup.js @@ -0,0 +1,174 @@ +/* +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. +*/ + +const HorizontalAxis = { + scrollOffset(el) {return el.scrollLeft;}, + size(el) {return el.offsetWidth;}, + offsetStart(el) {return el.offsetLeft;}, + setStart(el, value) {el.style.left = `${value}px`;}, + setEnd(el, value) {el.style.right = `${value}px`;}, +}; +const VerticalAxis = { + scrollOffset(el) {return el.scrollTop;}, + size(el) {return el.offsetHeight;}, + offsetStart(el) {return el.offsetTop;}, + setStart(el, value) {el.style.top = `${value}px`;}, + setEnd(el, value) {el.style.bottom = `${value}px`;}, +}; + +export class Popup { + constructor(view) { + this._view = view; + this._target = null; + this._arrangement = null; + this._scroller = null; + this._fakeRoot = null; + this._trackingTemplateView = null; + } + + trackInTemplateView(templateView) { + this._trackingTemplateView = templateView; + this._trackingTemplateView.addSubView(this); + } + + showRelativeTo(target, arrangement) { + this._target = target; + this._arrangement = arrangement; + this._scroller = findScrollParent(this._target); + this._view.mount(); + this._target.offsetParent.appendChild(this._popup); + this._applyArrangementAxis(HorizontalAxis, this._arrangement.horizontal); + this._applyArrangementAxis(VerticalAxis, this._arrangement.vertical); + if (this._scroller) { + document.body.addEventListener("scroll", this, true); + } + setTimeout(() => { + document.body.addEventListener("click", this, false); + }, 10); + } + + close() { + this._view.unmount(); + this._trackingTemplateView.removeSubView(this); + if (this._scroller) { + document.body.removeEventListener("scroll", this, true); + } + document.body.removeEventListener("click", this, false); + this._popup.remove(); + } + + get _popup() { + return this._view.root(); + } + + handleEvent(evt) { + if (evt.type === "scroll") { + this._onScroll(); + } else if (evt.type === "click") { + this._onClick(evt); + } + } + + _onScroll() { + if (this._scroller && !this._isVisibleInScrollParent(VerticalAxis)) { + this.close(); + } + this._applyArrangementAxis(HorizontalAxis, this._arrangement.horizontal); + this._applyArrangementAxis(VerticalAxis, this._arrangement.vertical); + } + + _onClick() { + this.close(); + } + + _applyArrangementAxis(axis, {relativeTo, align, before, after}) { + if (relativeTo === "end") { + let end = axis.size(this._target.offsetParent) - axis.offsetStart(this._target); + if (align === "end") { + end -= axis.size(this._popup); + } else if (align === "center") { + end -= ((axis.size(this._popup) / 2) - (axis.size(this._target) / 2)); + } + if (typeof before === "number") { + end += before; + } else if (typeof after === "number") { + end -= (axis.size(this._target) + after); + } + axis.setEnd(this._popup, end); + } else if (relativeTo === "start") { + let scrollOffset = this._scroller ? axis.scrollOffset(this._scroller) : 0; + let start = axis.offsetStart(this._target) - scrollOffset; + if (align === "start") { + start -= axis.size(this._popup); + } else if (align === "center") { + start -= ((axis.size(this._popup) / 2) - (axis.size(this._target) / 2)); + } + if (typeof before === "number") { + start -= before; + } else if (typeof after === "number") { + start += (axis.size(this._target) + after); + } + axis.setStart(this._popup, start); + } else { + throw new Error("unknown relativeTo: " + relativeTo); + } + } + + _isVisibleInScrollParent(axis) { + // clipped at start? + if ((axis.offsetStart(this._target) + axis.size(this._target)) < ( + axis.offsetStart(this._scroller) + + axis.scrollOffset(this._scroller) + )) { + return false; + } + // clipped at end? + if (axis.offsetStart(this._target) > ( + axis.offsetStart(this._scroller) + + axis.size(this._scroller) + + axis.scrollOffset(this._scroller) + )) { + return false; + } + return true; + } + + /* fake UIView api, so it can be tracked by a template view as a subview */ + root() { + return this._fakeRoot; + } + + mount() { + this._fakeRoot = document.createComment("popup"); + return this._fakeRoot; + } + + unmount() { + this.close(); + } + + update() {} +} + +function findScrollParent(el) { + let parent = el; + do { + parent = parent.parentElement; + if (parent.scrollHeight > parent.clientHeight) { + return parent; + } + } while (parent !== el.offsetParent); +} diff --git a/src/platform/web/ui/general/TemplateView.js b/src/platform/web/ui/general/TemplateView.js index 8158fcb3..14cb53ac 100644 --- a/src/platform/web/ui/general/TemplateView.js +++ b/src/platform/web/ui/general/TemplateView.js @@ -44,9 +44,6 @@ export class TemplateView { this._render = render; this._eventListeners = null; this._bindings = null; - // this should become _subViews and also include templates. - // How do we know which ones we should update though? - // Wrapper class? this._subViews = null; this._root = null; this._boundUpdateFromValue = null; @@ -57,7 +54,7 @@ export class TemplateView { } _subscribe() { - if (typeof this._value.on === "function") { + if (typeof this._value?.on === "function") { this._boundUpdateFromValue = this._updateFromValue.bind(this); this._value.on("change", this._boundUpdateFromValue); } @@ -146,12 +143,19 @@ export class TemplateView { this._bindings.push(bindingFn); } - _addSubView(view) { + addSubView(view) { if (!this._subViews) { this._subViews = []; } this._subViews.push(view); } + + removeSubView(view) { + const idx = this._subViews.indexOf(view); + if (idx !== -1) { + this._subViews.splice(idx, 1); + } + } } // what is passed to render @@ -288,7 +292,7 @@ class TemplateBuilder { } catch (err) { return errorToDOM(err); } - this._templateView._addSubView(view); + this._templateView.addSubView(view); return root; } From 14b3c4b701952a8c1835493508aaa52f021b3313 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Fri, 13 Nov 2020 16:00:32 +0100 Subject: [PATCH 04/36] no need for a dedicated class just to hide it --- src/platform/web/dom/download.js | 2 +- src/platform/web/ui/css/main.css | 4 ---- 2 files changed, 1 insertion(+), 5 deletions(-) diff --git a/src/platform/web/dom/download.js b/src/platform/web/dom/download.js index 4e8aaece..ec934a65 100644 --- a/src/platform/web/dom/download.js +++ b/src/platform/web/dom/download.js @@ -20,7 +20,7 @@ export async function downloadInIframe(container, iframeSrc, blob, filename) { iframe = document.createElement("iframe"); iframe.setAttribute("sandbox", "allow-scripts allow-downloads allow-downloads-without-user-activation"); iframe.setAttribute("src", iframeSrc); - iframe.className = "downloadSandbox"; + iframe.className = "hidden"; container.appendChild(iframe); let detach; await new Promise((resolve, reject) => { diff --git a/src/platform/web/ui/css/main.css b/src/platform/web/ui/css/main.css index 913141b6..aa22839e 100644 --- a/src/platform/web/ui/css/main.css +++ b/src/platform/web/ui/css/main.css @@ -49,7 +49,3 @@ body.hydrogen { input::-ms-clear { display: none; } - -.hydrogen > iframe.downloadSandbox { - display: none; -} From d2a4242e5b0447e5f62d420e5e81c18c8cf693cf Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Fri, 13 Nov 2020 17:19:19 +0100 Subject: [PATCH 05/36] support multiple attachments per event --- .../session/room/timeline/tiles/FileTile.js | 19 +++-- src/matrix/room/AttachmentUpload.js | 71 ++++++++++++------- src/matrix/room/Room.js | 7 +- src/matrix/room/sending/PendingEvent.js | 4 +- src/matrix/room/sending/SendQueue.js | 25 ++++--- .../timeline/entries/PendingEventEntry.js | 4 +- 6 files changed, 75 insertions(+), 55 deletions(-) diff --git a/src/domain/session/room/timeline/tiles/FileTile.js b/src/domain/session/room/timeline/tiles/FileTile.js index eab55136..c87b090f 100644 --- a/src/domain/session/room/timeline/tiles/FileTile.js +++ b/src/domain/session/room/timeline/tiles/FileTile.js @@ -24,12 +24,9 @@ export class FileTile extends MessageTile { this._error = null; this._downloading = false; if (this._isUploading) { - // should really do this with an ObservableValue and waitFor to prevent leaks when the promise never resolves - this._entry.attachment.uploaded().finally(() => { - if (!this.isDisposed) { - this.emitChange("label"); - } - }); + this.track(this._entry.attachments.url.status.subscribe(() => { + this.emitChange("label"); + })); } } @@ -56,28 +53,28 @@ export class FileTile extends MessageTile { get size() { if (this._isUploading) { - return this._entry.attachment.localPreview.size; + return this._entry.attachments.url.localPreview.size; } else { return this._getContent().info?.size; } } get _isUploading() { - return this._entry.attachment && !this._entry.attachment.isUploaded; + return this._entry.attachments?.url && !this._entry.attachments.url.isUploaded; } get label() { if (this._error) { return `Could not decrypt file: ${this._error.message}`; } - if (this._entry.attachment?.error) { - return `Failed to upload: ${this._entry.attachment.error.message}`; + if (this._entry.attachments?.url?.error) { + return `Failed to upload: ${this._entry.attachments.url.error.message}`; } const content = this._getContent(); const filename = content.body; const size = formatSize(this.size); if (this._isUploading) { - return this.i18n`Uploading ${filename} (${size})…`; + return this.i18n`Uploading (${this._entry.attachments.url.status.get()}) ${filename} (${size})…`; } else if (this._downloading) { return this.i18n`Downloading ${filename} (${size})…`; } else { diff --git a/src/matrix/room/AttachmentUpload.js b/src/matrix/room/AttachmentUpload.js index 98d81894..5dd957a0 100644 --- a/src/matrix/room/AttachmentUpload.js +++ b/src/matrix/room/AttachmentUpload.js @@ -15,6 +15,11 @@ limitations under the License. */ import {encryptAttachment} from "../e2ee/attachment.js"; +import {createEnum} from "../../utils/enum.js"; +import {ObservableValue} from "../../observable/ObservableValue.js"; +import {AbortError} from "../../utils/error.js"; + +export const UploadStatus = createEnum("Waiting", "Encrypting", "Uploading", "Uploaded", "Error"); export class AttachmentUpload { constructor({filename, blob, hsApi, platform, isEncrypted}) { @@ -26,44 +31,51 @@ export class AttachmentUpload { this._mxcUrl = null; this._transferredBlob = null; this._encryptionInfo = null; - this._uploadPromise = null; this._uploadRequest = null; this._aborted = false; this._error = null; + this._status = new ObservableValue(UploadStatus.Waiting); } - upload() { - if (!this._uploadPromise) { - this._uploadPromise = this._upload(); + get status() { + return this._status; + } + + async upload() { + if (this._status.get() === UploadStatus.Waiting) { + this._upload(); + } + await this._status.waitFor(s => s === UploadStatus.Error || s === UploadStatus.Uploaded).promise; + if (this._status.get() === UploadStatus.Error) { + throw this._error; } - return this._uploadPromise; } + /** @package */ async _upload() { try { let transferredBlob = this._unencryptedBlob; if (this._isEncrypted) { + this._status.set(UploadStatus.Encrypting); const {info, blob} = await encryptAttachment(this._platform, this._unencryptedBlob); transferredBlob = blob; this._encryptionInfo = info; } if (this._aborted) { - return; + throw new AbortError("upload aborted during encryption"); } + this._status.set(UploadStatus.Uploading); this._uploadRequest = this._hsApi.uploadAttachment(transferredBlob, this._filename); const {content_uri} = await this._uploadRequest.response(); this._mxcUrl = content_uri; this._transferredBlob = transferredBlob; + this._status.set(UploadStatus.Uploaded); } catch (err) { this._error = err; - throw err; + this._status.set(UploadStatus.Error); } } - get isUploaded() { - return !!this._transferredBlob; - } - /** @public */ abort() { this._aborted = true; @@ -80,29 +92,34 @@ export class AttachmentUpload { } /** @package */ - uploaded() { - if (!this._uploadPromise) { - throw new Error("upload has not started yet"); - } - return this._uploadPromise; - } - - /** @package */ - applyToContent(content) { + applyToContent(urlPath, content) { if (!this._mxcUrl) { throw new Error("upload has not finished"); } - content.info = { - size: this._transferredBlob.size, - mimetype: this._unencryptedBlob.mimeType, - }; + let prefix = urlPath.substr(0, urlPath.lastIndexOf("url")); + setPath(`${prefix}info.size`, content, this._transferredBlob.size); + setPath(`${prefix}info.mimetype`, content, this._unencryptedBlob.mimeType); if (this._isEncrypted) { - content.file = Object.assign(this._encryptionInfo, { + setPath(`${prefix}file`, content, Object.assign(this._encryptionInfo, { mimetype: this._unencryptedBlob.mimeType, url: this._mxcUrl - }); + })); } else { - content.url = this._mxcUrl; + setPath(`${prefix}url`, content, this._mxcUrl); } } } + +function setPath(path, content, value) { + const parts = path.split("."); + let obj = content; + for (let i = 0; i < (parts.length - 1); i += 1) { + const key = parts[i]; + if (!obj[key]) { + obj[key] = {}; + } + obj = obj[key]; + } + const propKey = parts[parts.length - 1]; + obj[propKey] = value; +} diff --git a/src/matrix/room/Room.js b/src/matrix/room/Room.js index 109c096e..69829e9c 100644 --- a/src/matrix/room/Room.js +++ b/src/matrix/room/Room.js @@ -352,8 +352,8 @@ export class Room extends EventEmitter { } /** @public */ - sendEvent(eventType, content, attachment) { - return this._sendQueue.enqueueEvent(eventType, content, attachment); + sendEvent(eventType, content, attachments) { + return this._sendQueue.enqueueEvent(eventType, content, attachments); } /** @public */ @@ -633,10 +633,9 @@ export class Room extends EventEmitter { } } - uploadAttachment(blob, filename) { + createAttachment(blob, filename) { const attachment = new AttachmentUpload({blob, filename, hsApi: this._hsApi, platform: this._platform, isEncrypted: this.isEncrypted}); - attachment.upload(); return attachment; } diff --git a/src/matrix/room/sending/PendingEvent.js b/src/matrix/room/sending/PendingEvent.js index 030e57b6..b1e7f5a2 100644 --- a/src/matrix/room/sending/PendingEvent.js +++ b/src/matrix/room/sending/PendingEvent.js @@ -15,9 +15,9 @@ limitations under the License. */ export class PendingEvent { - constructor(data, attachment) { + constructor(data, attachments) { this._data = data; - this.attachment = attachment; + this.attachments = attachments; } get roomId() { return this._data.roomId; } diff --git a/src/matrix/room/sending/SendQueue.js b/src/matrix/room/sending/SendQueue.js index 76ff2c66..23019358 100644 --- a/src/matrix/room/sending/SendQueue.js +++ b/src/matrix/room/sending/SendQueue.js @@ -51,16 +51,15 @@ export class SendQueue { this._amountSent += 1; continue; } - if (pendingEvent.attachment) { - const {attachment} = pendingEvent; + if (pendingEvent.attachments) { try { - await attachment.uploaded(); + await this._uploadAttachments(pendingEvent); } catch (err) { - console.log("upload failed, skip sending message", pendingEvent); + console.log("upload failed, skip sending message", err, pendingEvent); this._amountSent += 1; continue; } - attachment.applyToContent(pendingEvent.content); + console.log("attachments upload, content is now", pendingEvent.content); } if (pendingEvent.needsEncryption) { const {type, content} = await this._roomEncryption.encrypt( @@ -127,8 +126,8 @@ export class SendQueue { } } - async enqueueEvent(eventType, content, attachment) { - const pendingEvent = await this._createAndStoreEvent(eventType, content, attachment); + async enqueueEvent(eventType, content, attachments) { + const pendingEvent = await this._createAndStoreEvent(eventType, content, attachments); this._pendingEvents.set(pendingEvent); console.log("added to _pendingEvents set", this._pendingEvents.length); if (!this._isSending && !this._offline) { @@ -161,7 +160,7 @@ export class SendQueue { await txn.complete(); } - async _createAndStoreEvent(eventType, content, attachment) { + async _createAndStoreEvent(eventType, content, attachments) { console.log("_createAndStoreEvent"); const txn = this._storage.readWriteTxn([this._storage.storeNames.pendingEvents]); let pendingEvent; @@ -178,7 +177,7 @@ export class SendQueue { content, txnId: makeTxnId(), needsEncryption: !!this._roomEncryption - }, attachment); + }, attachments); console.log("_createAndStoreEvent: adding to pendingEventsStore"); pendingEventsStore.add(pendingEvent.data); } catch (err) { @@ -188,4 +187,12 @@ export class SendQueue { await txn.complete(); return pendingEvent; } + + async _uploadAttachments(pendingEvent) { + const {attachments} = pendingEvent; + for (const [urlPath, attachment] of Object.entries(attachments)) { + await attachment.upload(); + attachment.applyToContent(urlPath, pendingEvent.content); + } + } } diff --git a/src/matrix/room/timeline/entries/PendingEventEntry.js b/src/matrix/room/timeline/entries/PendingEventEntry.js index 9f91c80f..e31e56d5 100644 --- a/src/matrix/room/timeline/entries/PendingEventEntry.js +++ b/src/matrix/room/timeline/entries/PendingEventEntry.js @@ -64,8 +64,8 @@ export class PendingEventEntry extends BaseEntry { return this._pendingEvent.txnId; } - get attachment() { - return this._pendingEvent.attachment; + get attachments() { + return this._pendingEvent.attachments; } notifyUpdate() { From 791b3ae04f551b44b8bc449bd49882bf15f3eb0e Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Fri, 13 Nov 2020 19:04:46 +0100 Subject: [PATCH 06/36] don't center align link buttons (while at it) --- src/platform/web/ui/css/themes/element/theme.css | 1 + 1 file changed, 1 insertion(+) diff --git a/src/platform/web/ui/css/themes/element/theme.css b/src/platform/web/ui/css/themes/element/theme.css index 99af88d0..9961dedd 100644 --- a/src/platform/web/ui/css/themes/element/theme.css +++ b/src/platform/web/ui/css/themes/element/theme.css @@ -316,6 +316,7 @@ a { .SessionStatusView button.link { color: currentcolor; + text-align: left; } .SessionStatusView > .end { From 96e46d154c51907b1dc25b12b2c0f7ee54fcb728 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Fri, 13 Nov 2020 19:05:09 +0100 Subject: [PATCH 07/36] copyright header for legacy platform --- src/platform/web/LegacyPlatform.js | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/src/platform/web/LegacyPlatform.js b/src/platform/web/LegacyPlatform.js index 4ecea1d3..e6bf7774 100644 --- a/src/platform/web/LegacyPlatform.js +++ b/src/platform/web/LegacyPlatform.js @@ -1,3 +1,19 @@ +/* +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 aesjs from "../../../lib/aes-js/index.js"; import {hkdf} from "../../utils/crypto/hkdf.js"; import {Platform as ModernPlatform} from "./Platform.js"; From 262cc8936e977ad563fb32b1db19703b924a3ff2 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Fri, 13 Nov 2020 19:10:04 +0100 Subject: [PATCH 08/36] don't leak mimetype for encrypted attachments --- src/matrix/e2ee/attachment.js | 2 +- src/matrix/room/AttachmentUpload.js | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/matrix/e2ee/attachment.js b/src/matrix/e2ee/attachment.js index 105c16c5..3ead83eb 100644 --- a/src/matrix/e2ee/attachment.js +++ b/src/matrix/e2ee/attachment.js @@ -65,7 +65,7 @@ export async function encryptAttachment(platform, blob) { const ciphertext = await crypto.aes.encryptCTR({jwkKey: key, iv, data: buffer}); const digest = await crypto.digest("SHA-256", ciphertext); return { - blob: platform.createBlob(ciphertext, blob.mimeType), + blob: platform.createBlob(ciphertext, 'application/octet-stream'), info: { v: "v2", key, diff --git a/src/matrix/room/AttachmentUpload.js b/src/matrix/room/AttachmentUpload.js index 5dd957a0..356bb5a7 100644 --- a/src/matrix/room/AttachmentUpload.js +++ b/src/matrix/room/AttachmentUpload.js @@ -98,10 +98,10 @@ export class AttachmentUpload { } let prefix = urlPath.substr(0, urlPath.lastIndexOf("url")); setPath(`${prefix}info.size`, content, this._transferredBlob.size); - setPath(`${prefix}info.mimetype`, content, this._unencryptedBlob.mimeType); + setPath(`${prefix}info.mimetype`, content, this._transferredBlob.mimeType); if (this._isEncrypted) { setPath(`${prefix}file`, content, Object.assign(this._encryptionInfo, { - mimetype: this._unencryptedBlob.mimeType, + mimetype: this._transferredBlob.mimeType, url: this._mxcUrl })); } else { From 02927250765698bcdfd6ec5baace830564d9ae9a Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Fri, 13 Nov 2020 19:10:18 +0100 Subject: [PATCH 09/36] better formatting --- src/matrix/room/AttachmentUpload.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/matrix/room/AttachmentUpload.js b/src/matrix/room/AttachmentUpload.js index 356bb5a7..013bf150 100644 --- a/src/matrix/room/AttachmentUpload.js +++ b/src/matrix/room/AttachmentUpload.js @@ -45,7 +45,9 @@ export class AttachmentUpload { if (this._status.get() === UploadStatus.Waiting) { this._upload(); } - await this._status.waitFor(s => s === UploadStatus.Error || s === UploadStatus.Uploaded).promise; + await this._status.waitFor(s => { + return s === UploadStatus.Error || s === UploadStatus.Uploaded; + }).promise; if (this._status.get() === UploadStatus.Error) { throw this._error; } From 41fb3e742e006615a4e91d0e0c4c3b4e51dc38b5 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Fri, 13 Nov 2020 19:13:35 +0100 Subject: [PATCH 10/36] fix file upload attachment api changes --- src/domain/session/room/RoomViewModel.js | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/domain/session/room/RoomViewModel.js b/src/domain/session/room/RoomViewModel.js index 83fcca3a..a979be09 100644 --- a/src/domain/session/room/RoomViewModel.js +++ b/src/domain/session/room/RoomViewModel.js @@ -172,12 +172,14 @@ export class RoomViewModel extends ViewModel { } catch (err) { return; } - const attachment = this._room.uploadAttachment(file.blob, file.name); const content = { body: file.name, - msgtype: "m.file", + msgtype: "m.file" }; - await this._room.sendEvent("m.room.message", content, attachment); + await this._room.sendEvent("m.room.message", content, { + "url": this._room.createAttachment(file.blob, file.name) + }); + // TODO: dispose file.blob (in the attachment, after upload) } get composerViewModel() { From 8b82c991e7f03de99e9a42029c10044485042b36 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Fri, 13 Nov 2020 19:15:03 +0100 Subject: [PATCH 11/36] wire up image uploads --- src/domain/session/room/RoomViewModel.js | 45 ++++++++++++++++++- src/platform/web/dom/ImageHandle.js | 4 ++ .../web/ui/session/room/MessageComposer.js | 27 ++++++++++- 3 files changed, 73 insertions(+), 3 deletions(-) diff --git a/src/domain/session/room/RoomViewModel.js b/src/domain/session/room/RoomViewModel.js index a979be09..2586f2b2 100644 --- a/src/domain/session/room/RoomViewModel.js +++ b/src/domain/session/room/RoomViewModel.js @@ -182,6 +182,36 @@ export class RoomViewModel extends ViewModel { // TODO: dispose file.blob (in the attachment, after upload) } + async _sendPicture() { + if (!this.platform.hasReadPixelPermission()) { + alert("Please allow canvas image data access, so we can scale your images down."); + return; + } + let file; + try { + file = await this.platform.openFile("image/*"); + } catch (err) { + return; + } + const image = await this.platform.loadImage(file.blob); + const content = { + body: file.name, + msgtype: "m.image", + info: imageToInfo(image) + }; + const attachments = { + "url": this._room.createAttachment(file.blob, file.name), + }; + if (image.maxDimension > 600) { + const thumbnail = await image.scale(400); + content.info.thumbnail_info = imageToInfo(thumbnail); + attachments["info.thumbnail_url"] = + this._room.createAttachment(thumbnail.blob, file.name); + } + await this._room.sendEvent("m.room.message", content, attachments); + } + + get composerViewModel() { return this._composerVM; } @@ -207,7 +237,11 @@ class ComposerViewModel extends ViewModel { return success; } - sendAttachment() { + sendPicture() { + this._roomVM._sendPicture(); + } + + sendFile() { this._roomVM._sendFile(); } @@ -226,3 +260,12 @@ class ComposerViewModel extends ViewModel { } } } + +function imageToInfo(image) { + return { + w: image.width, + h: image.height, + mimetype: image.blob.mimeType, + size: image.blob.size + }; +} diff --git a/src/platform/web/dom/ImageHandle.js b/src/platform/web/dom/ImageHandle.js index 691ead22..dcead27c 100644 --- a/src/platform/web/dom/ImageHandle.js +++ b/src/platform/web/dom/ImageHandle.js @@ -30,6 +30,10 @@ export class ImageHandle { this._imgElement = imgElement; } + get maxDimension() { + return Math.max(this.width, this.height); + } + async _getImgElement() { if (!this._imgElement) { this._imgElement = await loadImgFromBlob(this.blob); diff --git a/src/platform/web/ui/session/room/MessageComposer.js b/src/platform/web/ui/session/room/MessageComposer.js index 62b55b15..0609285b 100644 --- a/src/platform/web/ui/session/room/MessageComposer.js +++ b/src/platform/web/ui/session/room/MessageComposer.js @@ -15,6 +15,8 @@ limitations under the License. */ import {TemplateView} from "../../general/TemplateView.js"; +import {Popup} from "../../general/Popup.js"; +import {Menu} from "../../general/Menu.js"; export class MessageComposer extends TemplateView { constructor(viewModel) { @@ -32,8 +34,8 @@ export class MessageComposer extends TemplateView { this._input, t.button({ className: "sendFile", - title: vm.i18n`Send file`, - onClick: () => vm.sendAttachment(), + title: vm.i18n`Pick attachment`, + onClick: evt => this._showAttachmentMenu(evt), }, vm.i18n`Send file`), t.button({ className: "send", @@ -56,4 +58,25 @@ export class MessageComposer extends TemplateView { this._trySend(); } } + + _showAttachmentMenu(evt) { + const vm = this.value; + const popup = new Popup(new Menu([ + Menu.option(vm.i18n`Send picture`, () => vm.sendPicture()).setIcon("picture"), + Menu.option(vm.i18n`Send file`, () => vm.sendFile()).setIcon("file"), + ])); + popup.trackInTemplateView(this); + popup.showRelativeTo(evt.target, { + horizontal: { + relativeTo: "end", + align: "start", + after: 0 + }, + vertical: { + relativeTo: "end", + align: "start", + before: 8, + } + }); + } } From 41738ad6602ebf8819f94b4eefc18be08104fe32 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Fri, 13 Nov 2020 19:15:21 +0100 Subject: [PATCH 12/36] local echo for image tiles --- .../session/room/timeline/tiles/ImageTile.js | 19 ++++++++++++ .../web/ui/session/room/timeline/ImageView.js | 31 ++++++++++++------- 2 files changed, 39 insertions(+), 11 deletions(-) diff --git a/src/domain/session/room/timeline/tiles/ImageTile.js b/src/domain/session/room/timeline/tiles/ImageTile.js index 6d518b11..87c16205 100644 --- a/src/domain/session/room/timeline/tiles/ImageTile.js +++ b/src/domain/session/room/timeline/tiles/ImageTile.js @@ -32,6 +32,11 @@ export class ImageTile extends MessageTile { this.navigation.segment("room", this._room.id), this.navigation.segment("lightbox", this._entry.id) ]); + if (this._entry.attachments) { + this.track(this._entry.attachments.url.status.subscribe(() => { + this.emitChange("uploadStatus"); + })); + } } async _loadEncryptedFile(file) { @@ -64,12 +69,26 @@ export class ImageTile extends MessageTile { return this._lightboxUrl; } + get isUploading() { + return !!this._entry.attachments; + } + + get uploadStatus() { + if (this._entry.attachments) { + return this._entry.attachments.url.status.get(); + } + return ""; + } + get thumbnailUrl() { if (this._decryptedThumbail) { return this._decryptedThumbail.url; } else if (this._decryptedImage) { return this._decryptedImage.url; } + if (this._entry.attachments) { + return this._entry.attachments.url.localPreview.url; + } const mxcUrl = this._getContent()?.url; if (typeof mxcUrl === "string") { return this._mediaRepository.mxcUrlThumbnail(mxcUrl, this.thumbnailWidth, this.thumbnailHeight, "scale"); diff --git a/src/platform/web/ui/session/room/timeline/ImageView.js b/src/platform/web/ui/session/room/timeline/ImageView.js index eb060e34..058cda45 100644 --- a/src/platform/web/ui/session/room/timeline/ImageView.js +++ b/src/platform/web/ui/session/room/timeline/ImageView.js @@ -16,6 +16,7 @@ limitations under the License. import {TemplateView} from "../../../general/TemplateView.js"; import {renderMessage} from "./common.js"; +import {spinner} from "../../../common.js"; export class ImageView extends TemplateView { render(t, vm) { @@ -31,18 +32,26 @@ export class ImageView extends TemplateView { // can slow down rendering, and was bleeding through the lightbox. spacerStyle = `height: ${vm.thumbnailHeight}px`; } + const children = [ + t.div({className: "spacer", style: spacerStyle}), + t.img({ + loading: "lazy", + src: vm => vm.thumbnailUrl, + alt: vm => vm.label, + title: vm => vm.label, + style: `max-width: ${vm.thumbnailWidth}px; max-height: ${vm.thumbnailHeight}px;` + }), + t.time(vm.date + " " + vm.time), + ]; + if (vm.isUploading) { + const uploadStatus = t.div({className: "uploadStatus"}, [ + spinner(t), + vm => vm.uploadStatus + ]); + children.push(uploadStatus); + } return renderMessage(t, vm, [ - t.a({href: vm.lightboxUrl, className: "picture", style: `max-width: ${vm.thumbnailWidth}px`}, [ - t.div({className: "spacer", style: spacerStyle}), - t.img({ - loading: "lazy", - src: vm => vm.thumbnailUrl, - alt: vm => vm.label, - title: vm => vm.label, - style: `max-width: ${vm.thumbnailWidth}px; max-height: ${vm.thumbnailHeight}px;` - }), - t.time(vm.date + " " + vm.time), - ]), + t.a({href: vm.lightboxUrl, className: "picture", style: `max-width: ${vm.thumbnailWidth}px`}, children), t.if(vm => vm.error, t.createTemplate((t, vm) => t.p({className: "error"}, vm.error))) ]); } From 1dd46b875b77e031748b10a9ad08d8c597907a35 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Fri, 13 Nov 2020 19:22:06 +0100 Subject: [PATCH 13/36] this is fine actually, as it will get encrypted. just don't pass it to the upload --- src/matrix/room/AttachmentUpload.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/matrix/room/AttachmentUpload.js b/src/matrix/room/AttachmentUpload.js index 013bf150..75a5bfb1 100644 --- a/src/matrix/room/AttachmentUpload.js +++ b/src/matrix/room/AttachmentUpload.js @@ -100,10 +100,10 @@ export class AttachmentUpload { } let prefix = urlPath.substr(0, urlPath.lastIndexOf("url")); setPath(`${prefix}info.size`, content, this._transferredBlob.size); - setPath(`${prefix}info.mimetype`, content, this._transferredBlob.mimeType); + setPath(`${prefix}info.mimetype`, content, this._unencryptedBlob.mimeType); if (this._isEncrypted) { setPath(`${prefix}file`, content, Object.assign(this._encryptionInfo, { - mimetype: this._transferredBlob.mimeType, + mimetype: this._unencryptedBlob.mimeType, url: this._mxcUrl })); } else { From 83cbe78cd65b0128b5b7c105a196e2012db2f27a Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Mon, 16 Nov 2020 10:45:46 +0100 Subject: [PATCH 14/36] report attachment upload progress --- src/matrix/net/HomeServerApi.js | 1 + src/matrix/room/AttachmentUpload.js | 11 ++++++++++- src/platform/web/dom/request/fetch.js | 8 +++++++- src/platform/web/dom/request/xhr.js | 21 +++++++++++++-------- 4 files changed, 31 insertions(+), 10 deletions(-) diff --git a/src/matrix/net/HomeServerApi.js b/src/matrix/net/HomeServerApi.js index bdf35363..935c6889 100644 --- a/src/matrix/net/HomeServerApi.js +++ b/src/matrix/net/HomeServerApi.js @@ -110,6 +110,7 @@ export class HomeServerApi { headers, body: encodedBody, timeout: options?.timeout, + uploadProgress: options?.uploadProgress, format: "json" // response format }); diff --git a/src/matrix/room/AttachmentUpload.js b/src/matrix/room/AttachmentUpload.js index 75a5bfb1..bccbbb67 100644 --- a/src/matrix/room/AttachmentUpload.js +++ b/src/matrix/room/AttachmentUpload.js @@ -35,12 +35,17 @@ export class AttachmentUpload { this._aborted = false; this._error = null; this._status = new ObservableValue(UploadStatus.Waiting); + this._progress = new ObservableValue(0); } get status() { return this._status; } + get uploadProgress() { + return this._progress; + } + async upload() { if (this._status.get() === UploadStatus.Waiting) { this._upload(); @@ -66,9 +71,13 @@ export class AttachmentUpload { if (this._aborted) { throw new AbortError("upload aborted during encryption"); } + this._progress.set(0); this._status.set(UploadStatus.Uploading); - this._uploadRequest = this._hsApi.uploadAttachment(transferredBlob, this._filename); + this._uploadRequest = this._hsApi.uploadAttachment(transferredBlob, this._filename, { + uploadProgress: sentBytes => this._progress.set(sentBytes / transferredBlob.size) + }); const {content_uri} = await this._uploadRequest.response(); + this._progress.set(1); this._mxcUrl = content_uri; this._transferredBlob = transferredBlob; this._status.set(UploadStatus.Uploaded); diff --git a/src/platform/web/dom/request/fetch.js b/src/platform/web/dom/request/fetch.js index 96c9ff9c..dd3b7949 100644 --- a/src/platform/web/dom/request/fetch.js +++ b/src/platform/web/dom/request/fetch.js @@ -21,6 +21,7 @@ import { } from "../../../../matrix/error.js"; import {abortOnTimeout} from "./timeout.js"; import {addCacheBuster} from "./common.js"; +import {xhrRequest} from "./xhr.js"; class RequestResult { constructor(promise, controller) { @@ -51,7 +52,12 @@ class RequestResult { } export function createFetchRequest(createTimeout) { - return function fetchRequest(url, {method, headers, body, timeout, format, cache = false}) { + return function fetchRequest(url, requestOptions) { + // fetch doesn't do upload progress yet, delegate to xhr + if (requestOptions?.uploadProgress) { + return xhrRequest(url, requestOptions); + } + let {method, headers, body, timeout, format, cache = false} = requestOptions; const controller = typeof AbortController === "function" ? new AbortController() : null; // if a BlobHandle, take native blob if (body?.nativeBlob) { diff --git a/src/platform/web/dom/request/xhr.js b/src/platform/web/dom/request/xhr.js index 5ca2d460..e2b9c15b 100644 --- a/src/platform/web/dom/request/xhr.js +++ b/src/platform/web/dom/request/xhr.js @@ -35,7 +35,7 @@ class RequestResult { } } -function send(url, {method, headers, timeout, body, format}) { +function createXhr(url, {method, headers, timeout, format, uploadProgress}) { const xhr = new XMLHttpRequest(); xhr.open(method, url); @@ -52,11 +52,9 @@ function send(url, {method, headers, timeout, body, format}) { xhr.timeout = timeout; } - // if a BlobHandle, take native blob - if (body?.nativeBlob) { - body = body.nativeBlob; + if (uploadProgress) { + xhr.upload.addEventListener("progress", evt => uploadProgress(evt.loaded)); } - xhr.send(body || null); return xhr; } @@ -71,12 +69,12 @@ function xhrAsPromise(xhr, method, url) { } export function xhrRequest(url, options) { - const {cache, format} = options; + let {cache, format, body, method} = options; if (!cache) { url = addCacheBuster(url); } - const xhr = send(url, options); - const promise = xhrAsPromise(xhr, options.method, url).then(xhr => { + const xhr = createXhr(url, options); + const promise = xhrAsPromise(xhr, method, url).then(xhr => { const {status} = xhr; let body = null; if (format === "buffer") { @@ -86,5 +84,12 @@ export function xhrRequest(url, options) { } return {status, body}; }); + + // if a BlobHandle, take native blob + if (body?.nativeBlob) { + body = body.nativeBlob; + } + xhr.send(body || null); + return new RequestResult(promise, xhr); } From 6bd777e7be07a570164bd58d428e900e9cbfcc61 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Mon, 16 Nov 2020 15:10:25 +0100 Subject: [PATCH 15/36] make sure cors errors on setting headers don't fail sending the request --- src/platform/web/dom/request/xhr.js | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/platform/web/dom/request/xhr.js b/src/platform/web/dom/request/xhr.js index e2b9c15b..98893387 100644 --- a/src/platform/web/dom/request/xhr.js +++ b/src/platform/web/dom/request/xhr.js @@ -45,7 +45,11 @@ function createXhr(url, {method, headers, timeout, format, uploadProgress}) { } if (headers) { for(const [name, value] of headers.entries()) { - xhr.setRequestHeader(name, value); + try { + xhr.setRequestHeader(name, value); + } catch (err) { + console.info(`Could not set ${name} header: ${err.message}`); + } } } if (timeout) { From 671e23a123c294019529af4ff9f52a558de77637 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Mon, 16 Nov 2020 15:10:44 +0100 Subject: [PATCH 16/36] basic reporting of image upload progress in % --- src/domain/session/room/timeline/tiles/ImageTile.js | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/domain/session/room/timeline/tiles/ImageTile.js b/src/domain/session/room/timeline/tiles/ImageTile.js index 87c16205..3e671f09 100644 --- a/src/domain/session/room/timeline/tiles/ImageTile.js +++ b/src/domain/session/room/timeline/tiles/ImageTile.js @@ -36,6 +36,9 @@ export class ImageTile extends MessageTile { this.track(this._entry.attachments.url.status.subscribe(() => { this.emitChange("uploadStatus"); })); + this.track(this._entry.attachments.url.uploadProgress.subscribe(() => { + this.emitChange("uploadStatus"); + })); } } @@ -75,7 +78,8 @@ export class ImageTile extends MessageTile { get uploadStatus() { if (this._entry.attachments) { - return this._entry.attachments.url.status.get(); + const attachment = this._entry.attachments.url; + return `${attachment.status.get()} (${Math.round(attachment.uploadProgress.get() * 100)}%)`; } return ""; } From fd81111bfbf75c84d6dc3107c8ee43849ead90ca Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Wed, 18 Nov 2020 13:02:38 +0100 Subject: [PATCH 17/36] merge state machine from AttachmentUpload into PendingEvent to have less state machines, and we are mostly interested in the aggregate status of all attachments of an event this will also drive updates through the pending events collection that already exists rather than an extra observablevalue, so less housekeeping to update the UI. --- src/matrix/room/AttachmentUpload.js | 90 ++++-------- src/matrix/room/Room.js | 4 +- src/matrix/room/sending/PendingEvent.js | 129 +++++++++++++++++- src/matrix/room/sending/SendQueue.js | 112 ++++++++------- .../timeline/entries/PendingEventEntry.js | 4 +- 5 files changed, 215 insertions(+), 124 deletions(-) diff --git a/src/matrix/room/AttachmentUpload.js b/src/matrix/room/AttachmentUpload.js index bccbbb67..dc25ce16 100644 --- a/src/matrix/room/AttachmentUpload.js +++ b/src/matrix/room/AttachmentUpload.js @@ -15,81 +15,33 @@ limitations under the License. */ import {encryptAttachment} from "../e2ee/attachment.js"; -import {createEnum} from "../../utils/enum.js"; -import {ObservableValue} from "../../observable/ObservableValue.js"; -import {AbortError} from "../../utils/error.js"; - -export const UploadStatus = createEnum("Waiting", "Encrypting", "Uploading", "Uploaded", "Error"); export class AttachmentUpload { - constructor({filename, blob, hsApi, platform, isEncrypted}) { + constructor({filename, blob, platform}) { this._filename = filename; + // need to keep around for local preview while uploading this._unencryptedBlob = blob; - this._isEncrypted = isEncrypted; + this._transferredBlob = this._unencryptedBlob; this._platform = platform; - this._hsApi = hsApi; this._mxcUrl = null; - this._transferredBlob = null; this._encryptionInfo = null; this._uploadRequest = null; this._aborted = false; this._error = null; - this._status = new ObservableValue(UploadStatus.Waiting); - this._progress = new ObservableValue(0); + this._sentBytes = 0; } - get status() { - return this._status; + /** important to call after encrypt() if encryption is needed */ + get size() { + return this._transferredBlob.size; } - get uploadProgress() { - return this._progress; - } - - async upload() { - if (this._status.get() === UploadStatus.Waiting) { - this._upload(); - } - await this._status.waitFor(s => { - return s === UploadStatus.Error || s === UploadStatus.Uploaded; - }).promise; - if (this._status.get() === UploadStatus.Error) { - throw this._error; - } - } - - /** @package */ - async _upload() { - try { - let transferredBlob = this._unencryptedBlob; - if (this._isEncrypted) { - this._status.set(UploadStatus.Encrypting); - const {info, blob} = await encryptAttachment(this._platform, this._unencryptedBlob); - transferredBlob = blob; - this._encryptionInfo = info; - } - if (this._aborted) { - throw new AbortError("upload aborted during encryption"); - } - this._progress.set(0); - this._status.set(UploadStatus.Uploading); - this._uploadRequest = this._hsApi.uploadAttachment(transferredBlob, this._filename, { - uploadProgress: sentBytes => this._progress.set(sentBytes / transferredBlob.size) - }); - const {content_uri} = await this._uploadRequest.response(); - this._progress.set(1); - this._mxcUrl = content_uri; - this._transferredBlob = transferredBlob; - this._status.set(UploadStatus.Uploaded); - } catch (err) { - this._error = err; - this._status.set(UploadStatus.Error); - } + get sentBytes() { + return this._sentBytes; } /** @public */ abort() { - this._aborted = true; this._uploadRequest?.abort(); } @@ -98,8 +50,26 @@ export class AttachmentUpload { return this._unencryptedBlob; } - get error() { - return this._error; + /** @package */ + async encrypt() { + if (this._encryptionInfo) { + throw new Error("already encrypted"); + } + const {info, blob} = await encryptAttachment(this._platform, this._transferredBlob); + this._transferredBlob = blob; + this._encryptionInfo = info; + } + + /** @package */ + async upload(hsApi, progressCallback) { + this._uploadRequest = hsApi.uploadAttachment(this._transferredBlob, this._filename, { + uploadProgress: sentBytes => { + this._sentBytes = sentBytes; + progressCallback(); + } + }); + const {content_uri} = await this._uploadRequest.response(); + this._mxcUrl = content_uri; } /** @package */ @@ -110,7 +80,7 @@ export class AttachmentUpload { let prefix = urlPath.substr(0, urlPath.lastIndexOf("url")); setPath(`${prefix}info.size`, content, this._transferredBlob.size); setPath(`${prefix}info.mimetype`, content, this._unencryptedBlob.mimeType); - if (this._isEncrypted) { + if (this._encryptionInfo) { setPath(`${prefix}file`, content, Object.assign(this._encryptionInfo, { mimetype: this._unencryptedBlob.mimeType, url: this._mxcUrl diff --git a/src/matrix/room/Room.js b/src/matrix/room/Room.js index 69829e9c..f2635492 100644 --- a/src/matrix/room/Room.js +++ b/src/matrix/room/Room.js @@ -634,9 +634,7 @@ export class Room extends EventEmitter { } createAttachment(blob, filename) { - const attachment = new AttachmentUpload({blob, filename, - hsApi: this._hsApi, platform: this._platform, isEncrypted: this.isEncrypted}); - return attachment; + return new AttachmentUpload({blob, filename, platform: this._platform}); } dispose() { diff --git a/src/matrix/room/sending/PendingEvent.js b/src/matrix/room/sending/PendingEvent.js index b1e7f5a2..a1e906ae 100644 --- a/src/matrix/room/sending/PendingEvent.js +++ b/src/matrix/room/sending/PendingEvent.js @@ -13,11 +13,31 @@ 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 {createEnum} from "../../../utils/enum.js"; +import {AbortError} from "../../../utils/error.js"; + +export const SendStatus = createEnum( + "Waiting", + "EncryptingAttachments", + "UploadingAttachments", + "Encrypting", + "Sending", + "Sent", + "Error", +); export class PendingEvent { - constructor(data, attachments) { + constructor({data, remove, emitUpdate, attachments}) { this._data = data; - this.attachments = attachments; + this._attachments = attachments; + this._emitUpdate = () => { + console.log("PendingEvent status", this.status, this._attachments && Object.entries(this._attachments).map(([key, a]) => `${key}: ${a.sentBytes}/${a.size}`)); + emitUpdate(); + }; + this._removeFromQueueCallback = remove; + this._aborted = false; + this._status = SendStatus.Waiting; + this._sendRequest = null; } get roomId() { return this._data.roomId; } @@ -25,14 +45,111 @@ export class PendingEvent { get eventType() { return this._data.eventType; } get txnId() { return this._data.txnId; } get remoteId() { return this._data.remoteId; } - set remoteId(value) { this._data.remoteId = value; } get content() { return this._data.content; } - get needsEncryption() { return this._data.needsEncryption; } get data() { return this._data; } + getAttachment(key) { + return this._attachments && this._attachments[key]; + } + + get needsSending() { + return !this.remoteId && !this.aborted; + } + + get needsEncryption() { + return this._data.needsEncryption && !this.aborted; + } + + get needsUpload() { + return this._data.needsUpload && !this.aborted; + } + + setEncrypting() { + this._status = SendStatus.Encrypting; + this._emitUpdate("status"); + } + setEncrypted(type, content) { - this._data.eventType = type; - this._data.content = content; + this._data.encryptedEventType = type; + this._data.encryptedContent = content; this._data.needsEncryption = false; } + + setError(error) { + this._status = SendStatus.Error; + this._error = error; + this._emitUpdate("status"); + } + + get status() { return this._status; } + get error() { return this._error; } + + get attachmentsTotalBytes() { + return Object.values(this._attachments).reduce((t, a) => t + a.size, 0); + } + + get attachmentsSentBytes() { + return Object.values(this._attachments).reduce((t, a) => t + a.sentBytes, 0); + } + + async uploadAttachments(hsApi) { + if (!this.needsUpload) { + return; + } + if (this.needsEncryption) { + this._status = SendStatus.EncryptingAttachments; + this._emitUpdate("status"); + for (const attachment of Object.values(this._attachments)) { + await attachment.encrypt(); + if (this.aborted) { + throw new AbortError(); + } + } + } + this._status = SendStatus.UploadingAttachments; + this._emitUpdate("status"); + for (const [urlPath, attachment] of Object.entries(this._attachments)) { + await attachment.upload(hsApi, () => { + this._emitUpdate("attachmentsSentBytes"); + }); + attachment.applyToContent(urlPath, this.content); + } + this._data.needsUpload = false; + } + + abort() { + if (!this._aborted) { + this._aborted = true; + if (this._attachments) { + for (const attachment of Object.values(this._attachments)) { + attachment.abort(); + } + } + this._sendRequest?.abort(); + this._removeFromQueueCallback(); + } + } + + get aborted() { + return this._aborted; + } + + async send(hsApi) { + console.log(`sending event ${this.eventType} in ${this.roomId}`); + this._status = SendStatus.Sending; + this._emitUpdate("status"); + const eventType = this._data.encryptedEventType || this._data.eventType; + const content = this._data.encryptedContent || this._data.content; + this._sendRequest = hsApi.send( + this.roomId, + eventType, + this.txnId, + content + ); + const response = await this._sendRequest.response(); + this._sendRequest = null; + this._data.remoteId = response.event_id; + this._status = SendStatus.Sent; + this._emitUpdate("status"); + } } diff --git a/src/matrix/room/sending/SendQueue.js b/src/matrix/room/sending/SendQueue.js index 23019358..aa3a29b9 100644 --- a/src/matrix/room/sending/SendQueue.js +++ b/src/matrix/room/sending/SendQueue.js @@ -29,13 +29,22 @@ export class SendQueue { if (pendingEvents.length) { console.info(`SendQueue for room ${roomId} has ${pendingEvents.length} pending events`, pendingEvents); } - this._pendingEvents.setManyUnsorted(pendingEvents.map(data => new PendingEvent(data))); + this._pendingEvents.setManyUnsorted(pendingEvents.map(data => this._createPendingEvent(data))); this._isSending = false; this._offline = false; - this._amountSent = 0; this._roomEncryption = null; } + _createPendingEvent(data, attachments = null) { + const pendingEvent = new PendingEvent({ + data, + remove: () => this._removeEvent(pendingEvent), + emitUpdate: () => this._pendingEvents.set(pendingEvent), + attachments + }); + return pendingEvent; + } + enableEncryption(roomEncryption) { this._roomEncryption = roomEncryption; } @@ -43,53 +52,44 @@ export class SendQueue { async _sendLoop() { this._isSending = true; try { - console.log("start sending", this._amountSent, "<", this._pendingEvents.length); - while (this._amountSent < this._pendingEvents.length) { - const pendingEvent = this._pendingEvents.get(this._amountSent); - console.log("trying to send", pendingEvent.content.body); - if (pendingEvent.remoteId) { - this._amountSent += 1; - continue; - } - if (pendingEvent.attachments) { - try { - await this._uploadAttachments(pendingEvent); - } catch (err) { - console.log("upload failed, skip sending message", err, pendingEvent); - this._amountSent += 1; - continue; + for (let i = 0; i < this._pendingEvents.length; i += 1) { + const pendingEvent = this._pendingEvents.get(i); + try { + await this._sendEvent(pendingEvent); + } catch(err) { + if (err instanceof ConnectionError) { + this._offline = true; + break; + } else { + pendingEvent.setError(err); } - console.log("attachments upload, content is now", pendingEvent.content); - } - if (pendingEvent.needsEncryption) { - const {type, content} = await this._roomEncryption.encrypt( - pendingEvent.eventType, pendingEvent.content, this._hsApi); - pendingEvent.setEncrypted(type, content); - await this._tryUpdateEvent(pendingEvent); - } - console.log("really sending now"); - const response = await this._hsApi.send( - pendingEvent.roomId, - pendingEvent.eventType, - pendingEvent.txnId, - pendingEvent.content - ).response(); - pendingEvent.remoteId = response.event_id; - // - console.log("writing remoteId now"); - await this._tryUpdateEvent(pendingEvent); - console.log("keep sending?", this._amountSent, "<", this._pendingEvents.length); - this._amountSent += 1; - } - } catch(err) { - if (err instanceof ConnectionError) { - this._offline = true; + } } } finally { this._isSending = false; } } + async _sendEvent(pendingEvent) { + if (pendingEvent.needsUpload) { + await pendingEvent.uploadAttachments(this._hsApi); + console.log("attachments upload, content is now", pendingEvent.content); + await this._tryUpdateEvent(pendingEvent); + } + if (pendingEvent.needsEncryption) { + pendingEvent.setEncrypting(); + const {type, content} = await this._roomEncryption.encrypt( + pendingEvent.eventType, pendingEvent.content, this._hsApi); + pendingEvent.setEncrypted(type, content); + await this._tryUpdateEvent(pendingEvent); + } + if (pendingEvent.needsSending) { + await pendingEvent.send(this._hsApi); + console.log("writing remoteId"); + await this._tryUpdateEvent(pendingEvent); + } + } + removeRemoteEchos(events, txn) { const removed = []; for (const event of events) { @@ -109,11 +109,24 @@ export class SendQueue { return removed; } + async _removeEvent(pendingEvent) { + const idx = this._pendingEvents.array.indexOf(pendingEvent); + if (idx !== -1) { + const txn = this._storage.readWriteTxn([this._storage.storeNames.pendingEvents]); + try { + txn.pendingEvents.remove(pendingEvent.roomId, pendingEvent.queueIndex); + } catch (err) { + txn.abort(); + } + await txn.complete(); + this._pendingEvents.remove(idx); + } + } + emitRemovals(pendingEvents) { for (const pendingEvent of pendingEvents) { const idx = this._pendingEvents.array.indexOf(pendingEvent); if (idx !== -1) { - this._amountSent -= 1; this._pendingEvents.remove(idx); } } @@ -170,13 +183,14 @@ export class SendQueue { const maxQueueIndex = await pendingEventsStore.getMaxQueueIndex(this._roomId) || 0; console.log("_createAndStoreEvent got maxQueueIndex", maxQueueIndex); const queueIndex = maxQueueIndex + 1; - pendingEvent = new PendingEvent({ + pendingEvent = this._createPendingEvent({ roomId: this._roomId, queueIndex, eventType, content, txnId: makeTxnId(), - needsEncryption: !!this._roomEncryption + needsEncryption: !!this._roomEncryption, + needsUpload: !!attachments }, attachments); console.log("_createAndStoreEvent: adding to pendingEventsStore"); pendingEventsStore.add(pendingEvent.data); @@ -187,12 +201,4 @@ export class SendQueue { await txn.complete(); return pendingEvent; } - - async _uploadAttachments(pendingEvent) { - const {attachments} = pendingEvent; - for (const [urlPath, attachment] of Object.entries(attachments)) { - await attachment.upload(); - attachment.applyToContent(urlPath, pendingEvent.content); - } - } } diff --git a/src/matrix/room/timeline/entries/PendingEventEntry.js b/src/matrix/room/timeline/entries/PendingEventEntry.js index e31e56d5..eff14cb5 100644 --- a/src/matrix/room/timeline/entries/PendingEventEntry.js +++ b/src/matrix/room/timeline/entries/PendingEventEntry.js @@ -64,8 +64,8 @@ export class PendingEventEntry extends BaseEntry { return this._pendingEvent.txnId; } - get attachments() { - return this._pendingEvent.attachments; + get pendingEvent() { + return this._pendingEvent; } notifyUpdate() { From a930dec8db64716b73b8231e92ab32af9cedb134 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Wed, 18 Nov 2020 13:05:10 +0100 Subject: [PATCH 18/36] adjust ImageTile to state machine changes --- .../session/room/timeline/tiles/ImageTile.js | 46 ++++++++++++------- 1 file changed, 29 insertions(+), 17 deletions(-) diff --git a/src/domain/session/room/timeline/tiles/ImageTile.js b/src/domain/session/room/timeline/tiles/ImageTile.js index 3e671f09..c2704d06 100644 --- a/src/domain/session/room/timeline/tiles/ImageTile.js +++ b/src/domain/session/room/timeline/tiles/ImageTile.js @@ -16,7 +16,7 @@ limitations under the License. */ import {MessageTile} from "./MessageTile.js"; - +import {SendStatus} from "../../../../../matrix/room/sending/PendingEvent.js"; const MAX_HEIGHT = 300; const MAX_WIDTH = 400; @@ -32,14 +32,6 @@ export class ImageTile extends MessageTile { this.navigation.segment("room", this._room.id), this.navigation.segment("lightbox", this._entry.id) ]); - if (this._entry.attachments) { - this.track(this._entry.attachments.url.status.subscribe(() => { - this.emitChange("uploadStatus"); - })); - this.track(this._entry.attachments.url.uploadProgress.subscribe(() => { - this.emitChange("uploadStatus"); - })); - } } async _loadEncryptedFile(file) { @@ -69,19 +61,38 @@ export class ImageTile extends MessageTile { } get lightboxUrl() { - return this._lightboxUrl; + if (!this.isUploading) { + return this._lightboxUrl; + } + return ""; } get isUploading() { - return !!this._entry.attachments; + return this._entry.isPending; } get uploadStatus() { - if (this._entry.attachments) { - const attachment = this._entry.attachments.url; - return `${attachment.status.get()} (${Math.round(attachment.uploadProgress.get() * 100)}%)`; + const {pendingEvent} = this._entry; + switch (pendingEvent?.status) { + case SendStatus.Waiting: + return this.i18n`Waiting`; + case SendStatus.EncryptingAttachments: + return this.i18n`Encrypting image`; + case SendStatus.UploadingAttachments: { + const percent = Math.round((pendingEvent.attachmentsSentBytes / pendingEvent.attachmentsTotalBytes) * 100); + return this.i18n`Uploading image (${percent}%)`; + } + case SendStatus.Encrypting: + return this.i18n`Encrypting message`; + case SendStatus.Sending: + return this.i18n`Sending message`; + case SendStatus.Sent: + return this.i18n`Message sent`; + case SendStatus.Error: + return this.i18n`Error: ${pendingEvent.error.message}`; + default: + return ""; } - return ""; } get thumbnailUrl() { @@ -90,8 +101,9 @@ export class ImageTile extends MessageTile { } else if (this._decryptedImage) { return this._decryptedImage.url; } - if (this._entry.attachments) { - return this._entry.attachments.url.localPreview.url; + if (this._entry.isPending) { + const attachment = this._entry.pendingEvent.getAttachment("url"); + return attachment && attachment.localPreview.url; } const mxcUrl = this._getContent()?.url; if (typeof mxcUrl === "string") { From 6f94ca1a4afe960c460dc06e728d395f94ea91b1 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Wed, 18 Nov 2020 16:28:48 +0100 Subject: [PATCH 19/36] make toBlob work on IE11 --- src/platform/web/dom/ImageHandle.js | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/src/platform/web/dom/ImageHandle.js b/src/platform/web/dom/ImageHandle.js index dcead27c..c8b08e04 100644 --- a/src/platform/web/dom/ImageHandle.js +++ b/src/platform/web/dom/ImageHandle.js @@ -53,10 +53,16 @@ export class ImageHandle { const ctx = canvas.getContext("2d"); const img = await this._getImgElement(); ctx.drawImage(img, 0, 0, scaledWidth, scaledHeight); - const mimeType = this.blob.mimeType === "image/jpeg" ? "image/jpeg" : "image/png"; - const nativeBlob = await new Promise(resolve => { - canvas.toBlob(resolve, mimeType); - }); + let mimeType = this.blob.mimeType === "image/jpeg" ? "image/jpeg" : "image/png"; + let nativeBlob; + if (canvas.toBlob) { + nativeBlob = await new Promise(resolve => canvas.toBlob(resolve, mimeType)); + } else if (canvas.msToBlob) { + mimeType = "image/png"; + nativeBlob = canvas.msToBlob(); + } else { + throw new Error("canvas can't be turned into blob"); + } const blob = BlobHandle.fromBlob(nativeBlob); return new ImageHandle(blob, scaledWidth, scaledHeight, null); } From 47147f2d463381c6d84f98cc5d6e137eca780f26 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Wed, 18 Nov 2020 20:06:34 +0100 Subject: [PATCH 20/36] progress bar for upload, add cancel button, no lightbox while uploading --- .../session/room/timeline/tiles/SimpleTile.js | 5 +++ .../web/ui/css/themes/element/theme.css | 29 ++++++++++++-- src/platform/web/ui/general/html.js | 2 +- .../web/ui/session/room/timeline/ImageView.js | 39 ++++++++++++------- 4 files changed, 56 insertions(+), 19 deletions(-) diff --git a/src/domain/session/room/timeline/tiles/SimpleTile.js b/src/domain/session/room/timeline/tiles/SimpleTile.js index ec60bbba..f29f46d6 100644 --- a/src/domain/session/room/timeline/tiles/SimpleTile.js +++ b/src/domain/session/room/timeline/tiles/SimpleTile.js @@ -46,6 +46,11 @@ export class SimpleTile extends ViewModel { get isPending() { return this._entry.isPending; } + + abortSending() { + this._entry.pendingEvent?.abort(); + } + // TilesCollection contract below setUpdateEmit(emitUpdate) { this.updateOptions({emitChange: paramName => { diff --git a/src/platform/web/ui/css/themes/element/theme.css b/src/platform/web/ui/css/themes/element/theme.css index 9961dedd..8fd72ef3 100644 --- a/src/platform/web/ui/css/themes/element/theme.css +++ b/src/platform/web/ui/css/themes/element/theme.css @@ -557,11 +557,17 @@ ul.Timeline > li.messageStatus .message-container > p { .message-container .picture { display: grid; - text-decoration: none; margin-top: 4px; width: 100%; } + +.message-container .picture > a { + text-decoration: none; + width: 100%; + display: block; +} + /* .spacer grows with an inline padding-top to the size of the image, so the timeline doesn't jump when the image loads */ .message-container .picture > * { @@ -569,24 +575,41 @@ so the timeline doesn't jump when the image loads */ grid-column: 1; } -.message-container .picture > img { +.message-container .picture img { width: 100%; height: auto; /* for IE11 to still scale even though the spacer is too tall */ align-self: start; border-radius: 4px; + display: block; } /* stretch the image (to the spacer) on platforms where we can trust the spacer to always have the correct height, otherwise the image starts with height 0 and with loading=lazy only loads when the top comes into view*/ -.hydrogen:not(.legacy) .message-container .picture > img { +.hydrogen:not(.legacy) .message-container .picture img { align-self: stretch; } +.message-container .picture > .sendStatus { + align-self: end; + justify-self: start; + font-size: 0.8em; +} + +.message-container .picture > progress { + align-self: center; + justify-self: center; + width: 75%; +} + .message-container .picture > time { align-self: end; justify-self: end; +} + +.message-container .picture > time, +.message-container .picture > .sendStatus { color: #2e2f32; display: block; padding: 2px; diff --git a/src/platform/web/ui/general/html.js b/src/platform/web/ui/general/html.js index 5ab33e86..96f52c25 100644 --- a/src/platform/web/ui/general/html.js +++ b/src/platform/web/ui/general/html.js @@ -94,7 +94,7 @@ export const TAG_NAMES = { [HTML_NS]: [ "br", "a", "ol", "ul", "li", "div", "h1", "h2", "h3", "h4", "h5", "h6", "p", "strong", "em", "span", "img", "section", "main", "article", "aside", - "pre", "button", "time", "input", "textarea", "label", "form"], + "pre", "button", "time", "input", "textarea", "label", "form", "progress"], [SVG_NS]: ["svg", "circle"] }; diff --git a/src/platform/web/ui/session/room/timeline/ImageView.js b/src/platform/web/ui/session/room/timeline/ImageView.js index 058cda45..be7bef8d 100644 --- a/src/platform/web/ui/session/room/timeline/ImageView.js +++ b/src/platform/web/ui/session/room/timeline/ImageView.js @@ -16,7 +16,6 @@ limitations under the License. import {TemplateView} from "../../../general/TemplateView.js"; import {renderMessage} from "./common.js"; -import {spinner} from "../../../common.js"; export class ImageView extends TemplateView { render(t, vm) { @@ -32,26 +31,36 @@ export class ImageView extends TemplateView { // can slow down rendering, and was bleeding through the lightbox. spacerStyle = `height: ${vm.thumbnailHeight}px`; } + const img = t.img({ + loading: "lazy", + src: vm => vm.thumbnailUrl, + alt: vm => vm.label, + title: vm => vm.label, + style: `max-width: ${vm.thumbnailWidth}px; max-height: ${vm.thumbnailHeight}px;` + }); const children = [ + vm.isPending ? img : t.a({href: vm.lightboxUrl}, img), t.div({className: "spacer", style: spacerStyle}), - t.img({ - loading: "lazy", - src: vm => vm.thumbnailUrl, - alt: vm => vm.label, - title: vm => vm.label, - style: `max-width: ${vm.thumbnailWidth}px; max-height: ${vm.thumbnailHeight}px;` - }), t.time(vm.date + " " + vm.time), ]; - if (vm.isUploading) { - const uploadStatus = t.div({className: "uploadStatus"}, [ - spinner(t), - vm => vm.uploadStatus - ]); - children.push(uploadStatus); + if (vm.isPending) { + const cancel = t.button({onClick: () => vm.abortSending(), className: "link"}, vm.i18n`Cancel`); + const sendStatus = t.div({ + className: { + sendStatus: true, + hidden: vm => !vm.sendStatus + }, + }, [vm => vm.sendStatus, " ", cancel]); + const progress = t.progress({ + min: 0, + max: 100, + value: vm => vm.uploadPercentage, + className: {hidden: vm => !vm.isUploading} + }); + children.push(sendStatus, progress); } return renderMessage(t, vm, [ - t.a({href: vm.lightboxUrl, className: "picture", style: `max-width: ${vm.thumbnailWidth}px`}, children), + t.div({className: "picture", style: `max-width: ${vm.thumbnailWidth}px`}, children), t.if(vm => vm.error, t.createTemplate((t, vm) => t.p({className: "error"}, vm.error))) ]); } From f6dbb23f79908cf8465ecc0899dff7063e26e302 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Wed, 18 Nov 2020 20:07:31 +0100 Subject: [PATCH 21/36] better error handling for file and picture upload --- src/domain/session/room/RoomViewModel.js | 72 ++++++++++++++---------- src/platform/web/Platform.js | 2 +- 2 files changed, 42 insertions(+), 32 deletions(-) diff --git a/src/domain/session/room/RoomViewModel.js b/src/domain/session/room/RoomViewModel.js index 6e6ad176..7a179c0f 100644 --- a/src/domain/session/room/RoomViewModel.js +++ b/src/domain/session/room/RoomViewModel.js @@ -165,13 +165,19 @@ export class RoomViewModel extends ViewModel { return false; } - async _sendFile() { - let file; + async _pickAndSendFile() { try { - file = await this.platform.openFile(); + const file = await this.platform.openFile(); + if (!file) { + return; + } + return this._sendFile(file); } catch (err) { - return; + console.error(err); } + } + + async _sendFile(file) { const content = { body: file.name, msgtype: "m.file" @@ -179,36 +185,40 @@ export class RoomViewModel extends ViewModel { await this._room.sendEvent("m.room.message", content, { "url": this._room.createAttachment(file.blob, file.name) }); - // TODO: dispose file.blob (in the attachment, after upload) } - async _sendPicture() { - if (!this.platform.hasReadPixelPermission()) { - alert("Please allow canvas image data access, so we can scale your images down."); - return; - } - let file; + async _pickAndSendPicture() { try { - file = await this.platform.openFile("image/*"); + if (!this.platform.hasReadPixelPermission()) { + alert("Please allow canvas image data access, so we can scale your images down."); + return; + } + const file = await this.platform.openFile("image/*"); + if (!file) { + return; + } + if (!file.blob.mimeType.startsWith("image/")) { + return this._sendFile(file); + } + const image = await this.platform.loadImage(file.blob); + const content = { + body: file.name, + msgtype: "m.image", + info: imageToInfo(image) + }; + const attachments = { + "url": this._room.createAttachment(image.blob, file.name), + }; + if (image.maxDimension > 600) { + const thumbnail = await image.scale(400); + content.info.thumbnail_info = imageToInfo(thumbnail); + attachments["info.thumbnail_url"] = + this._room.createAttachment(thumbnail.blob, file.name); + } + await this._room.sendEvent("m.room.message", content, attachments); } catch (err) { - return; + console.error(err); } - const image = await this.platform.loadImage(file.blob); - const content = { - body: file.name, - msgtype: "m.image", - info: imageToInfo(image) - }; - const attachments = { - "url": this._room.createAttachment(file.blob, file.name), - }; - if (image.maxDimension > 600) { - const thumbnail = await image.scale(400); - content.info.thumbnail_info = imageToInfo(thumbnail); - attachments["info.thumbnail_url"] = - this._room.createAttachment(thumbnail.blob, file.name); - } - await this._room.sendEvent("m.room.message", content, attachments); } @@ -238,11 +248,11 @@ class ComposerViewModel extends ViewModel { } sendPicture() { - this._roomVM._sendPicture(); + this._roomVM._pickAndSendPicture(); } sendFile() { - this._roomVM._sendFile(); + this._roomVM._pickAndSendFile(); } get canSend() { diff --git a/src/platform/web/Platform.js b/src/platform/web/Platform.js index a49073c9..98a37f22 100644 --- a/src/platform/web/Platform.js +++ b/src/platform/web/Platform.js @@ -159,7 +159,7 @@ export class Platform { if (file) { resolve({name: file.name, blob: BlobHandle.fromBlob(file)}); } else { - reject(new Error("No file selected")); + resolve(); } } input.addEventListener("change", checkFile, true); From fba5877b3bc5b9b079e3bfe22e8f04eab963815c Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Wed, 18 Nov 2020 20:08:42 +0100 Subject: [PATCH 22/36] dispose attachment blobs when pending event is removed/closing session --- src/matrix/room/AttachmentUpload.js | 5 +++++ src/matrix/room/Room.js | 1 + src/matrix/room/sending/PendingEvent.js | 8 ++++++++ src/matrix/room/sending/SendQueue.js | 8 ++++++++ 4 files changed, 22 insertions(+) diff --git a/src/matrix/room/AttachmentUpload.js b/src/matrix/room/AttachmentUpload.js index dc25ce16..a66fbd45 100644 --- a/src/matrix/room/AttachmentUpload.js +++ b/src/matrix/room/AttachmentUpload.js @@ -89,6 +89,11 @@ export class AttachmentUpload { setPath(`${prefix}url`, content, this._mxcUrl); } } + + dispose() { + this._unencryptedBlob.dispose(); + this._transferredBlob.dispose(); + } } function setPath(path, content, value) { diff --git a/src/matrix/room/Room.js b/src/matrix/room/Room.js index f2635492..4cf9106e 100644 --- a/src/matrix/room/Room.js +++ b/src/matrix/room/Room.js @@ -640,6 +640,7 @@ export class Room extends EventEmitter { dispose() { this._roomEncryption?.dispose(); this._timeline?.dispose(); + this._sendQueue.dispose(); } } diff --git a/src/matrix/room/sending/PendingEvent.js b/src/matrix/room/sending/PendingEvent.js index a1e906ae..550cde8d 100644 --- a/src/matrix/room/sending/PendingEvent.js +++ b/src/matrix/room/sending/PendingEvent.js @@ -152,4 +152,12 @@ export class PendingEvent { this._status = SendStatus.Sent; this._emitUpdate("status"); } + + dispose() { + if (this._attachments) { + for (const attachment of Object.values(this._attachments)) { + attachment.dispose(); + } + } + } } diff --git a/src/matrix/room/sending/SendQueue.js b/src/matrix/room/sending/SendQueue.js index aa3a29b9..4a678cfe 100644 --- a/src/matrix/room/sending/SendQueue.js +++ b/src/matrix/room/sending/SendQueue.js @@ -121,6 +121,7 @@ export class SendQueue { await txn.complete(); this._pendingEvents.remove(idx); } + pendingEvent.dispose(); } emitRemovals(pendingEvents) { @@ -129,6 +130,7 @@ export class SendQueue { if (idx !== -1) { this._pendingEvents.remove(idx); } + pendingEvent.dispose(); } } @@ -201,4 +203,10 @@ export class SendQueue { await txn.complete(); return pendingEvent; } + + dispose() { + for (const pe in this._pendingEvents.array) { + pe.dispose(); + } + } } From a23075a32617dbb9aaf4302e57b53340b2b41155 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Wed, 18 Nov 2020 20:09:07 +0100 Subject: [PATCH 23/36] make safe to call when no attachments present --- src/matrix/room/sending/PendingEvent.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/matrix/room/sending/PendingEvent.js b/src/matrix/room/sending/PendingEvent.js index 550cde8d..ad0e0566 100644 --- a/src/matrix/room/sending/PendingEvent.js +++ b/src/matrix/room/sending/PendingEvent.js @@ -85,11 +85,11 @@ export class PendingEvent { get error() { return this._error; } get attachmentsTotalBytes() { - return Object.values(this._attachments).reduce((t, a) => t + a.size, 0); + return this._attachments && Object.values(this._attachments).reduce((t, a) => t + a.size, 0); } get attachmentsSentBytes() { - return Object.values(this._attachments).reduce((t, a) => t + a.sentBytes, 0); + return this._attachments && Object.values(this._attachments).reduce((t, a) => t + a.sentBytes, 0); } async uploadAttachments(hsApi) { From 05810663500be48ed5799cbfa5ea06b3fe69e1ee Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Wed, 18 Nov 2020 20:09:27 +0100 Subject: [PATCH 24/36] upload smallest attachments first so you don't get a delay at the end of the upload when starting a new request --- src/matrix/room/sending/PendingEvent.js | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/matrix/room/sending/PendingEvent.js b/src/matrix/room/sending/PendingEvent.js index ad0e0566..a4fbfc03 100644 --- a/src/matrix/room/sending/PendingEvent.js +++ b/src/matrix/room/sending/PendingEvent.js @@ -108,7 +108,10 @@ export class PendingEvent { } this._status = SendStatus.UploadingAttachments; this._emitUpdate("status"); - for (const [urlPath, attachment] of Object.entries(this._attachments)) { + const entries = Object.entries(this._attachments); + // upload smallest attachments first + entries.sort(([, a1], [, a2]) => a1.size - a2.size); + for (const [urlPath, attachment] of entries) { await attachment.upload(hsApi, () => { this._emitUpdate("attachmentsSentBytes"); }); From 59a92bdf97c2837e134735d1483b00da6c375d96 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Wed, 18 Nov 2020 20:10:05 +0100 Subject: [PATCH 25/36] vm changes for improved image view --- .../session/room/timeline/tiles/ImageTile.js | 42 ++++++++----------- 1 file changed, 17 insertions(+), 25 deletions(-) diff --git a/src/domain/session/room/timeline/tiles/ImageTile.js b/src/domain/session/room/timeline/tiles/ImageTile.js index c2704d06..fc70551d 100644 --- a/src/domain/session/room/timeline/tiles/ImageTile.js +++ b/src/domain/session/room/timeline/tiles/ImageTile.js @@ -26,7 +26,9 @@ export class ImageTile extends MessageTile { this._decryptedThumbail = null; this._decryptedImage = null; this._error = null; - this.load(); + if (!this.isPending) { + this.tryLoadEncryptedThumbnail(); + } this._lightboxUrl = this.urlCreator.urlForSegments([ // ensure the right room is active if in grid view this.navigation.segment("room", this._room.id), @@ -43,7 +45,7 @@ export class ImageTile extends MessageTile { return this.track(blob); } - async load() { + async tryLoadEncryptedThumbnail() { try { const thumbnailFile = this._getContent().info?.thumbnail_file; const file = this._getContent().file; @@ -61,33 +63,33 @@ export class ImageTile extends MessageTile { } get lightboxUrl() { - if (!this.isUploading) { + if (!this.isPending) { return this._lightboxUrl; } return ""; } get isUploading() { - return this._entry.isPending; + return this.isPending && this._entry.pendingEvent.status === SendStatus.UploadingAttachments; } - get uploadStatus() { + get uploadPercentage() { + const {pendingEvent} = this._entry; + return pendingEvent && Math.round((pendingEvent.attachmentsSentBytes / pendingEvent.attachmentsTotalBytes) * 100); + } + + get sendStatus() { const {pendingEvent} = this._entry; switch (pendingEvent?.status) { case SendStatus.Waiting: - return this.i18n`Waiting`; + return this.i18n`Waiting…`; case SendStatus.EncryptingAttachments: - return this.i18n`Encrypting image`; - case SendStatus.UploadingAttachments: { - const percent = Math.round((pendingEvent.attachmentsSentBytes / pendingEvent.attachmentsTotalBytes) * 100); - return this.i18n`Uploading image (${percent}%)`; - } case SendStatus.Encrypting: - return this.i18n`Encrypting message`; + return this.i18n`Encrypting…`; + case SendStatus.UploadingAttachments: + return this.i18n`Uploading…`; case SendStatus.Sending: - return this.i18n`Sending message`; - case SendStatus.Sent: - return this.i18n`Message sent`; + return this.i18n`Sending…`; case SendStatus.Error: return this.i18n`Error: ${pendingEvent.error.message}`; default: @@ -112,16 +114,6 @@ export class ImageTile extends MessageTile { return ""; } - async loadImageUrl() { - if (!this._decryptedImage) { - const file = this._getContent().file; - if (file) { - this._decryptedImage = await this._loadEncryptedFile(file); - } - } - return this._decryptedImage?.url || ""; - } - _scaleFactor() { const info = this._getContent()?.info; const scaleHeightFactor = MAX_HEIGHT / info?.h; From 18407e17a8c79b9233ce13d26d9c9f376de0755c Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Wed, 18 Nov 2020 20:18:09 +0100 Subject: [PATCH 26/36] toggle popup menu --- src/platform/web/ui/general/Popup.js | 19 +++++--- .../web/ui/session/room/MessageComposer.js | 45 ++++++++++--------- 2 files changed, 38 insertions(+), 26 deletions(-) diff --git a/src/platform/web/ui/general/Popup.js b/src/platform/web/ui/general/Popup.js index e6d18033..51b53e6f 100644 --- a/src/platform/web/ui/general/Popup.js +++ b/src/platform/web/ui/general/Popup.js @@ -60,14 +60,21 @@ export class Popup { }, 10); } + get isOpen() { + return !!this._view; + } + close() { - this._view.unmount(); - this._trackingTemplateView.removeSubView(this); - if (this._scroller) { - document.body.removeEventListener("scroll", this, true); + if (this._view) { + this._view.unmount(); + this._trackingTemplateView.removeSubView(this); + if (this._scroller) { + document.body.removeEventListener("scroll", this, true); + } + document.body.removeEventListener("click", this, false); + this._popup.remove(); + this._view = null; } - document.body.removeEventListener("click", this, false); - this._popup.remove(); } get _popup() { diff --git a/src/platform/web/ui/session/room/MessageComposer.js b/src/platform/web/ui/session/room/MessageComposer.js index 0609285b..32f3fc05 100644 --- a/src/platform/web/ui/session/room/MessageComposer.js +++ b/src/platform/web/ui/session/room/MessageComposer.js @@ -22,6 +22,7 @@ export class MessageComposer extends TemplateView { constructor(viewModel) { super(viewModel); this._input = null; + this._attachmentPopup = null; } render(t, vm) { @@ -35,7 +36,7 @@ export class MessageComposer extends TemplateView { t.button({ className: "sendFile", title: vm.i18n`Pick attachment`, - onClick: evt => this._showAttachmentMenu(evt), + onClick: evt => this._toggleAttachmentMenu(evt), }, vm.i18n`Send file`), t.button({ className: "send", @@ -59,24 +60,28 @@ export class MessageComposer extends TemplateView { } } - _showAttachmentMenu(evt) { - const vm = this.value; - const popup = new Popup(new Menu([ - Menu.option(vm.i18n`Send picture`, () => vm.sendPicture()).setIcon("picture"), - Menu.option(vm.i18n`Send file`, () => vm.sendFile()).setIcon("file"), - ])); - popup.trackInTemplateView(this); - popup.showRelativeTo(evt.target, { - horizontal: { - relativeTo: "end", - align: "start", - after: 0 - }, - vertical: { - relativeTo: "end", - align: "start", - before: 8, - } - }); + _toggleAttachmentMenu(evt) { + if (this._attachmentPopup && this._attachmentPopup.isOpen) { + this._attachmentPopup.close(); + } else { + const vm = this.value; + this._attachmentPopup = new Popup(new Menu([ + Menu.option(vm.i18n`Send picture`, () => vm.sendPicture()).setIcon("picture"), + Menu.option(vm.i18n`Send file`, () => vm.sendFile()).setIcon("file"), + ])); + this._attachmentPopup.trackInTemplateView(this); + this._attachmentPopup.showRelativeTo(evt.target, { + horizontal: { + relativeTo: "end", + align: "start", + after: 0 + }, + vertical: { + relativeTo: "end", + align: "start", + before: 8, + } + }); + } } } From 8b8d06cf3e44ba129ff487c9642a7d559547da7b Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 19 Nov 2020 14:42:29 +0100 Subject: [PATCH 27/36] show error for attachments still need upload but missing after refresh --- .../timeline/tiles/MissingAttachmentTile.js | 33 +++++++++++++++++++ .../session/room/timeline/tilesCreator.js | 3 ++ src/matrix/room/sending/PendingEvent.js | 7 ++++ .../web/ui/session/room/TimelineList.js | 2 ++ .../room/timeline/MissingAttachmentView.js | 25 ++++++++++++++ .../web/ui/session/room/timeline/common.js | 2 +- 6 files changed, 71 insertions(+), 1 deletion(-) create mode 100644 src/domain/session/room/timeline/tiles/MissingAttachmentTile.js create mode 100644 src/platform/web/ui/session/room/timeline/MissingAttachmentView.js diff --git a/src/domain/session/room/timeline/tiles/MissingAttachmentTile.js b/src/domain/session/room/timeline/tiles/MissingAttachmentTile.js new file mode 100644 index 00000000..0a9b5976 --- /dev/null +++ b/src/domain/session/room/timeline/tiles/MissingAttachmentTile.js @@ -0,0 +1,33 @@ +/* +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"; + +export class MissingAttachmentTile extends MessageTile { + get shape() { + return "missing-attachment" + } + + get label() { + const name = this._getContent().body; + const msgtype = this._getContent().msgtype; + if (msgtype === "m.image") { + return this.i18n`The image ${name} wasn't fully sent previously and could not be recovered.`; + } else { + return this.i18n`The file ${name} wasn't fully sent previously and could not be recovered.`; + } + } +} diff --git a/src/domain/session/room/timeline/tilesCreator.js b/src/domain/session/room/timeline/tilesCreator.js index 9ac27a54..1901efe2 100644 --- a/src/domain/session/room/timeline/tilesCreator.js +++ b/src/domain/session/room/timeline/tilesCreator.js @@ -23,12 +23,15 @@ import {RoomNameTile} from "./tiles/RoomNameTile.js"; import {RoomMemberTile} from "./tiles/RoomMemberTile.js"; import {EncryptedEventTile} from "./tiles/EncryptedEventTile.js"; import {EncryptionEnabledTile} from "./tiles/EncryptionEnabledTile.js"; +import {MissingAttachmentTile} from "./tiles/MissingAttachmentTile.js"; export function tilesCreator(baseOptions) { return function tilesCreator(entry, emitUpdate) { const options = Object.assign({entry, emitUpdate}, baseOptions); if (entry.isGap) { return new GapTile(options); + } else if (entry.isPending && entry.pendingEvent.isMissingAttachments) { + return new MissingAttachmentTile(options); } else if (entry.eventType) { switch (entry.eventType) { case "m.room.message": { diff --git a/src/matrix/room/sending/PendingEvent.js b/src/matrix/room/sending/PendingEvent.js index a4fbfc03..554ed06a 100644 --- a/src/matrix/room/sending/PendingEvent.js +++ b/src/matrix/room/sending/PendingEvent.js @@ -64,6 +64,10 @@ export class PendingEvent { return this._data.needsUpload && !this.aborted; } + get isMissingAttachments() { + return this.needsUpload && !this._attachments; + } + setEncrypting() { this._status = SendStatus.Encrypting; this._emitUpdate("status"); @@ -96,6 +100,9 @@ export class PendingEvent { if (!this.needsUpload) { return; } + if (!this._attachments) { + throw new Error("attachments missing"); + } if (this.needsEncryption) { this._status = SendStatus.EncryptingAttachments; this._emitUpdate("status"); diff --git a/src/platform/web/ui/session/room/TimelineList.js b/src/platform/web/ui/session/room/TimelineList.js index 5d0f3fbe..9d946fce 100644 --- a/src/platform/web/ui/session/room/TimelineList.js +++ b/src/platform/web/ui/session/room/TimelineList.js @@ -19,6 +19,7 @@ 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 {MissingAttachmentView} from "./timeline/MissingAttachmentView.js"; import {AnnouncementView} from "./timeline/AnnouncementView.js"; function viewClassForEntry(entry) { @@ -30,6 +31,7 @@ function viewClassForEntry(entry) { return TextMessageView; case "image": return ImageView; case "file": return FileView; + case "missing-attachment": return MissingAttachmentView; } } diff --git a/src/platform/web/ui/session/room/timeline/MissingAttachmentView.js b/src/platform/web/ui/session/room/timeline/MissingAttachmentView.js new file mode 100644 index 00000000..329a82e9 --- /dev/null +++ b/src/platform/web/ui/session/room/timeline/MissingAttachmentView.js @@ -0,0 +1,25 @@ +/* +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 {TemplateView} from "../../../general/TemplateView.js"; +import {renderMessage} from "./common.js"; + +export class MissingAttachmentView extends TemplateView { + render(t, vm) { + const cancel = t.button({className: "link", onClick: () => vm.abortSending()}, vm.i18n`Cancel`); + return renderMessage(t, vm, t.p([vm.label, " ", cancel])); + } +} diff --git a/src/platform/web/ui/session/room/timeline/common.js b/src/platform/web/ui/session/room/timeline/common.js index 50b6a0cd..6f275141 100644 --- a/src/platform/web/ui/session/room/timeline/common.js +++ b/src/platform/web/ui/session/room/timeline/common.js @@ -24,7 +24,7 @@ export function renderMessage(t, vm, children) { pending: vm.isPending, unverified: vm.isUnverified, continuation: vm => vm.isContinuation, - messageStatus: vm => vm.shape === "message-status", + messageStatus: vm => vm.shape === "message-status" || vm.shape === "missing-attachment", }; const profile = t.div({className: "profile"}, [ From 628a3b65c6d0fe4506a75c0bf4ee1989811a1df7 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 19 Nov 2020 15:02:59 +0100 Subject: [PATCH 28/36] cancel > remove --- .../web/ui/session/room/timeline/MissingAttachmentView.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/platform/web/ui/session/room/timeline/MissingAttachmentView.js b/src/platform/web/ui/session/room/timeline/MissingAttachmentView.js index 329a82e9..8df90131 100644 --- a/src/platform/web/ui/session/room/timeline/MissingAttachmentView.js +++ b/src/platform/web/ui/session/room/timeline/MissingAttachmentView.js @@ -19,7 +19,7 @@ import {renderMessage} from "./common.js"; export class MissingAttachmentView extends TemplateView { render(t, vm) { - const cancel = t.button({className: "link", onClick: () => vm.abortSending()}, vm.i18n`Cancel`); - return renderMessage(t, vm, t.p([vm.label, " ", cancel])); + const remove = t.button({className: "link", onClick: () => vm.abortSending()}, vm.i18n`Remove`); + return renderMessage(t, vm, t.p([vm.label, " ", remove])); } } From f7a07a9e79160401be7bde3223830b10995757a7 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Fri, 20 Nov 2020 11:45:14 +0100 Subject: [PATCH 29/36] adjust fileview/tile to pendingevent changes --- .../session/room/timeline/tiles/FileTile.js | 69 +++++++++---------- .../web/ui/session/room/timeline/FileView.js | 16 +++-- .../web/ui/session/room/timeline/common.js | 2 +- 3 files changed, 45 insertions(+), 42 deletions(-) diff --git a/src/domain/session/room/timeline/tiles/FileTile.js b/src/domain/session/room/timeline/tiles/FileTile.js index c87b090f..f3a31e21 100644 --- a/src/domain/session/room/timeline/tiles/FileTile.js +++ b/src/domain/session/room/timeline/tiles/FileTile.js @@ -17,21 +17,17 @@ limitations under the License. import {MessageTile} from "./MessageTile.js"; import {formatSize} from "../../../../../utils/formatSize.js"; +import {SendStatus} from "../../../../../matrix/room/sending/PendingEvent.js"; export class FileTile extends MessageTile { constructor(options) { super(options); - this._error = null; + this._downloadError = null; this._downloading = false; - if (this._isUploading) { - this.track(this._entry.attachments.url.status.subscribe(() => { - this.emitChange("label"); - })); - } } async download() { - if (this._downloading || this._isUploading) { + if (this._downloading || this.isPending) { return; } const content = this._getContent(); @@ -43,7 +39,7 @@ export class FileTile extends MessageTile { blob = await this._mediaRepository.downloadAttachment(content); this.platform.saveFileAs(blob, filename); } catch (err) { - this._error = err; + this._downloadError = err; } finally { blob?.dispose(); this._downloading = false; @@ -51,39 +47,40 @@ export class FileTile extends MessageTile { this.emitChange("label"); } - get size() { - if (this._isUploading) { - return this._entry.attachments.url.localPreview.size; - } else { - return this._getContent().info?.size; - } - } - - get _isUploading() { - return this._entry.attachments?.url && !this._entry.attachments.url.isUploaded; - } - get label() { - if (this._error) { - return `Could not decrypt file: ${this._error.message}`; - } - if (this._entry.attachments?.url?.error) { - return `Failed to upload: ${this._entry.attachments.url.error.message}`; + if (this._downloadError) { + return `Could not download file: ${this._downloadError.message}`; } const content = this._getContent(); const filename = content.body; - const size = formatSize(this.size); - if (this._isUploading) { - return this.i18n`Uploading (${this._entry.attachments.url.status.get()}) ${filename} (${size})…`; - } else if (this._downloading) { - return this.i18n`Downloading ${filename} (${size})…`; - } else { - return this.i18n`Download ${filename} (${size})`; - } - } - get error() { - return null; + if (this._entry.isPending) { + const {pendingEvent} = this._entry; + switch (pendingEvent?.status) { + case SendStatus.Waiting: + return this.i18n`Waiting to send ${filename}…`; + case SendStatus.EncryptingAttachments: + case SendStatus.Encrypting: + return this.i18n`Encrypting ${filename}…`; + case SendStatus.UploadingAttachments:{ + const percent = Math.round((pendingEvent.attachmentsSentBytes / pendingEvent.attachmentsTotalBytes) * 100); + return this.i18n`Uploading ${filename}: ${percent}%`; + } + case SendStatus.Sending: + return this.i18n`Sending ${filename}…`; + case SendStatus.Error: + return this.i18n`Error: could not send ${filename}: ${pendingEvent.error.message}`; + default: + return `Unknown send status for ${filename}`; + } + } else { + const size = formatSize(this._getContent().info?.size); + if (this._downloading) { + return this.i18n`Downloading ${filename} (${size})…`; + } else { + return this.i18n`Download ${filename} (${size})`; + } + } } get shape() { diff --git a/src/platform/web/ui/session/room/timeline/FileView.js b/src/platform/web/ui/session/room/timeline/FileView.js index cb99dd0a..62760b3e 100644 --- a/src/platform/web/ui/session/room/timeline/FileView.js +++ b/src/platform/web/ui/session/room/timeline/FileView.js @@ -19,11 +19,17 @@ import {renderMessage} from "./common.js"; export class FileView extends TemplateView { render(t, vm) { - return renderMessage(t, vm, [ - t.p([ + if (vm.isPending) { + return renderMessage(t, vm, t.p([ + vm => vm.label, + " ", + t.button({className: "link", onClick: () => vm.abortSending()}, vm.i18n`Cancel`), + ])); + } else { + 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) - ]) - ]); + t.time(vm.date + " " + vm.time) + ])); + } } } diff --git a/src/platform/web/ui/session/room/timeline/common.js b/src/platform/web/ui/session/room/timeline/common.js index 6f275141..d49e845b 100644 --- a/src/platform/web/ui/session/room/timeline/common.js +++ b/src/platform/web/ui/session/room/timeline/common.js @@ -24,7 +24,7 @@ export function renderMessage(t, vm, children) { pending: vm.isPending, unverified: vm.isUnverified, continuation: vm => vm.isContinuation, - messageStatus: vm => vm.shape === "message-status" || vm.shape === "missing-attachment", + messageStatus: vm => vm.shape === "message-status" || vm.shape === "missing-attachment" || vm.shape === "file", }; const profile = t.div({className: "profile"}, [ From 764cddcdec4d13220fc3d335774ef85958f81244 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Fri, 20 Nov 2020 11:45:38 +0100 Subject: [PATCH 30/36] can be cached --- src/matrix/room/sending/PendingEvent.js | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/matrix/room/sending/PendingEvent.js b/src/matrix/room/sending/PendingEvent.js index 554ed06a..1980b30b 100644 --- a/src/matrix/room/sending/PendingEvent.js +++ b/src/matrix/room/sending/PendingEvent.js @@ -38,6 +38,10 @@ export class PendingEvent { this._aborted = false; this._status = SendStatus.Waiting; this._sendRequest = null; + this._attachmentsTotalBytes = 0; + if (this._attachments) { + this._attachmentsTotalBytes = Object.values(this._attachments).reduce((t, a) => t + a.size, 0); + } } get roomId() { return this._data.roomId; } @@ -89,7 +93,7 @@ export class PendingEvent { get error() { return this._error; } get attachmentsTotalBytes() { - return this._attachments && Object.values(this._attachments).reduce((t, a) => t + a.size, 0); + return this._attachmentsTotalBytes; } get attachmentsSentBytes() { From 9ab81eea02c2a426cb5285b5753f27d99f9ab043 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Fri, 20 Nov 2020 15:51:16 +0100 Subject: [PATCH 31/36] add setting for image compression level --- src/domain/session/room/RoomViewModel.js | 6 ++- .../session/settings/SettingsViewModel.js | 16 ++++++++ src/platform/web/Platform.js | 3 +- src/platform/web/dom/SettingsStorage.js | 37 +++++++++++++++++++ .../web/ui/css/themes/element/theme.css | 7 ++++ src/platform/web/ui/general/html.js | 2 +- .../web/ui/session/settings/SettingsView.js | 20 ++++++++++ 7 files changed, 88 insertions(+), 3 deletions(-) create mode 100644 src/platform/web/dom/SettingsStorage.js diff --git a/src/domain/session/room/RoomViewModel.js b/src/domain/session/room/RoomViewModel.js index 7a179c0f..5e6ee998 100644 --- a/src/domain/session/room/RoomViewModel.js +++ b/src/domain/session/room/RoomViewModel.js @@ -200,7 +200,11 @@ export class RoomViewModel extends ViewModel { if (!file.blob.mimeType.startsWith("image/")) { return this._sendFile(file); } - const image = await this.platform.loadImage(file.blob); + let image = await this.platform.loadImage(file.blob); + const limit = await this.platform.settingsStorage.getInt("sentImageSizeLimit"); + if (limit && image.maxDimension > limit) { + image = await image.scale(limit); + } const content = { body: file.name, msgtype: "m.image", diff --git a/src/domain/session/settings/SettingsViewModel.js b/src/domain/session/settings/SettingsViewModel.js index 0a1f223c..2c072305 100644 --- a/src/domain/session/settings/SettingsViewModel.js +++ b/src/domain/session/settings/SettingsViewModel.js @@ -36,10 +36,26 @@ export class SettingsViewModel extends ViewModel { this._sessionBackupViewModel = this.track(new SessionBackupViewModel(this.childOptions({session}))); this._closeUrl = this.urlCreator.urlUntilSegment("session"); this._estimate = null; + + this.sentImageSizeLimit = null; + this.minSentImageSizeLimit = 400; + this.maxSentImageSizeLimit = 4000; + } + + setSentImageSizeLimit(size) { + if (size > this.maxSentImageSizeLimit || size < this.minSentImageSizeLimit) { + this.sentImageSizeLimit = null; + this.platform.settingsStorage.remove("sentImageSizeLimit"); + } else { + this.sentImageSizeLimit = Math.round(size); + this.platform.settingsStorage.setInt("sentImageSizeLimit", size); + } + this.emitChange("sentImageSizeLimit"); } async load() { this._estimate = await this.platform.estimateStorageUsage(); + this.sentImageSizeLimit = await this.platform.settingsStorage.getInt("sentImageSizeLimit"); this.emitChange(""); } diff --git a/src/platform/web/Platform.js b/src/platform/web/Platform.js index 98a37f22..1b2226f2 100644 --- a/src/platform/web/Platform.js +++ b/src/platform/web/Platform.js @@ -18,6 +18,7 @@ import {createFetchRequest} from "./dom/request/fetch.js"; import {xhrRequest} from "./dom/request/xhr.js"; import {StorageFactory} from "../../matrix/storage/idb/StorageFactory.js"; import {SessionInfoStorage} from "../../matrix/sessioninfo/localstorage/SessionInfoStorage.js"; +import {SettingsStorage} from "./dom/SettingsStorage.js"; import {OlmWorker} from "../../matrix/e2ee/OlmWorker.js"; import {RootView} from "./ui/RootView.js"; import {Clock} from "./dom/Clock.js"; @@ -78,7 +79,6 @@ async function loadOlmWorker(paths) { return olmWorker; } - export class Platform { constructor(container, paths, cryptoExtras = null) { this._paths = paths; @@ -94,6 +94,7 @@ export class Platform { this.crypto = new Crypto(cryptoExtras); this.storageFactory = new StorageFactory(this._serviceWorkerHandler); this.sessionInfoStorage = new SessionInfoStorage("hydrogen_sessions_v1"); + this.settingsStorage = new SettingsStorage("hydrogen_setting_v1_"); this.estimateStorageUsage = estimateStorageUsage; this.random = Math.random; if (typeof fetch === "function") { diff --git a/src/platform/web/dom/SettingsStorage.js b/src/platform/web/dom/SettingsStorage.js new file mode 100644 index 00000000..0b3e81a8 --- /dev/null +++ b/src/platform/web/dom/SettingsStorage.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 class SettingsStorage { + constructor(prefix) { + this._prefix = prefix; + } + + async setInt(key, value) { + window.localStorage.setItem(`${this._prefix}${key}`, value); + } + + async getInt(key) { + const value = window.localStorage.getItem(`${this._prefix}${key}`); + if (typeof value === "string") { + return parseInt(value, 10); + } + return; + } + + async remove(key) { + window.localStorage.removeItem(`${this._prefix}${key}`); + } +} diff --git a/src/platform/web/ui/css/themes/element/theme.css b/src/platform/web/ui/css/themes/element/theme.css index 8fd72ef3..a4d7c4e6 100644 --- a/src/platform/web/ui/css/themes/element/theme.css +++ b/src/platform/web/ui/css/themes/element/theme.css @@ -677,6 +677,7 @@ only loads when the top comes into view*/ .Settings .row .content { margin-left: 4px; + flex: 1; } .Settings .row.code .content { @@ -688,6 +689,12 @@ only loads when the top comes into view*/ margin: 0 8px; } +.Settings .row .content input[type=range] { + width: 100%; + max-width: 300px; + min-width: 160px; +} + .Settings .row { margin: 4px 0px; display: flex; diff --git a/src/platform/web/ui/general/html.js b/src/platform/web/ui/general/html.js index 96f52c25..a965a6ee 100644 --- a/src/platform/web/ui/general/html.js +++ b/src/platform/web/ui/general/html.js @@ -94,7 +94,7 @@ export const TAG_NAMES = { [HTML_NS]: [ "br", "a", "ol", "ul", "li", "div", "h1", "h2", "h3", "h4", "h5", "h6", "p", "strong", "em", "span", "img", "section", "main", "article", "aside", - "pre", "button", "time", "input", "textarea", "label", "form", "progress"], + "pre", "button", "time", "input", "textarea", "label", "form", "progress", "output"], [SVG_NS]: ["svg", "circle"] }; diff --git a/src/platform/web/ui/session/settings/SettingsView.js b/src/platform/web/ui/session/settings/SettingsView.js index e350d806..564424e8 100644 --- a/src/platform/web/ui/session/settings/SettingsView.js +++ b/src/platform/web/ui/session/settings/SettingsView.js @@ -46,10 +46,30 @@ export class SettingsView extends TemplateView { row(vm.i18n`Session key`, vm.fingerprintKey, "code"), t.h3("Session Backup"), t.view(new SessionBackupSettingsView(vm.sessionBackupViewModel)), + t.h3("Preferences"), + row(vm.i18n`Compress images when sending`, this._imageCompressionRange(t, vm)), t.h3("Application"), row(vm.i18n`Version`, version), row(vm.i18n`Storage usage`, vm => `${vm.storageUsage} / ${vm.storageQuota}`), ]) ]); } + + _imageCompressionRange(t, vm) { + const step = 32; + const min = Math.ceil(vm.minSentImageSizeLimit / step) * step; + const max = (Math.floor(vm.maxSentImageSizeLimit / step) + 1) * step; + return [t.input({ + type: "range", + step, + min, + max, + value: vm => vm.sentImageSizeLimit || max, + onInput: evt => vm.setSentImageSizeLimit(parseInt(evt.target.value, 10)), + }), " ", t.output(vm => { + return vm.sentImageSizeLimit ? + vm.i18n`resize to ${vm.sentImageSizeLimit}px` : + vm.i18n`no compression`; + })]; + } } From 3eec89763055ddda5a48a427ada2b12e98841724 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Fri, 20 Nov 2020 16:06:05 +0100 Subject: [PATCH 32/36] better wording --- src/platform/web/ui/session/settings/SettingsView.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/platform/web/ui/session/settings/SettingsView.js b/src/platform/web/ui/session/settings/SettingsView.js index 564424e8..1ee7b360 100644 --- a/src/platform/web/ui/session/settings/SettingsView.js +++ b/src/platform/web/ui/session/settings/SettingsView.js @@ -47,7 +47,7 @@ export class SettingsView extends TemplateView { t.h3("Session Backup"), t.view(new SessionBackupSettingsView(vm.sessionBackupViewModel)), t.h3("Preferences"), - row(vm.i18n`Compress images when sending`, this._imageCompressionRange(t, vm)), + row(vm.i18n`Scale images down when sending`, this._imageCompressionRange(t, vm)), t.h3("Application"), row(vm.i18n`Version`, version), row(vm.i18n`Storage usage`, vm => `${vm.storageUsage} / ${vm.storageQuota}`), @@ -69,7 +69,7 @@ export class SettingsView extends TemplateView { }), " ", t.output(vm => { return vm.sentImageSizeLimit ? vm.i18n`resize to ${vm.sentImageSizeLimit}px` : - vm.i18n`no compression`; + vm.i18n`no resizing`; })]; } } From 45dd5391798754e877c3627e3fca3beac0872ccc Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Fri, 20 Nov 2020 16:06:16 +0100 Subject: [PATCH 33/36] also update preference onchange for IE --- src/platform/web/ui/session/settings/SettingsView.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/platform/web/ui/session/settings/SettingsView.js b/src/platform/web/ui/session/settings/SettingsView.js index 1ee7b360..fd64593e 100644 --- a/src/platform/web/ui/session/settings/SettingsView.js +++ b/src/platform/web/ui/session/settings/SettingsView.js @@ -59,13 +59,15 @@ export class SettingsView extends TemplateView { const step = 32; const min = Math.ceil(vm.minSentImageSizeLimit / step) * step; const max = (Math.floor(vm.maxSentImageSizeLimit / step) + 1) * step; + const updateSetting = evt => vm.setSentImageSizeLimit(parseInt(evt.target.value, 10)); return [t.input({ type: "range", step, min, max, value: vm => vm.sentImageSizeLimit || max, - onInput: evt => vm.setSentImageSizeLimit(parseInt(evt.target.value, 10)), + onInput: updateSetting, + onChange: updateSetting, }), " ", t.output(vm => { return vm.sentImageSizeLimit ? vm.i18n`resize to ${vm.sentImageSizeLimit}px` : From 83d54351b13dee38db6f44e2440125b6444b9768 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Fri, 20 Nov 2020 16:14:32 +0100 Subject: [PATCH 34/36] put spacer behind rather than before link to open lightbox --- src/platform/web/ui/session/room/timeline/ImageView.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/platform/web/ui/session/room/timeline/ImageView.js b/src/platform/web/ui/session/room/timeline/ImageView.js index be7bef8d..855ab23f 100644 --- a/src/platform/web/ui/session/room/timeline/ImageView.js +++ b/src/platform/web/ui/session/room/timeline/ImageView.js @@ -39,8 +39,8 @@ export class ImageView extends TemplateView { style: `max-width: ${vm.thumbnailWidth}px; max-height: ${vm.thumbnailHeight}px;` }); const children = [ - vm.isPending ? img : t.a({href: vm.lightboxUrl}, img), t.div({className: "spacer", style: spacerStyle}), + vm.isPending ? img : t.a({href: vm.lightboxUrl}, img), t.time(vm.date + " " + vm.time), ]; if (vm.isPending) { From 7bf02f42b7e8fd60d000a10a91482d51b0e5acd3 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Fri, 20 Nov 2020 16:32:13 +0100 Subject: [PATCH 35/36] don't use grid to put lightbox on top, as it broke again in IE11 --- src/platform/web/ui/css/layout.css | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/src/platform/web/ui/css/layout.css b/src/platform/web/ui/css/layout.css index 8755e2a3..60c3eafa 100644 --- a/src/platform/web/ui/css/layout.css +++ b/src/platform/web/ui/css/layout.css @@ -111,12 +111,11 @@ main { } .lightbox { - /* cover left and middle panel, not status view - use numeric positions because named grid areas - are not present in mobile layout */ - grid-area: 2 / 1 / 3 / 3; - /* this should not be necessary, but chrome seems to have a bug when there are scrollbars in other grid items, - it seems to put the scroll areas on top of the other grid items unless they have a z-index */ + position: absolute; + top: 0; + bottom: 0; + left: 0; + right: 0; z-index: 1; } From b7aca076bae911885b9a71561bc739db7edb877c Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Fri, 20 Nov 2020 16:32:33 +0100 Subject: [PATCH 36/36] better wording --- src/platform/web/ui/session/settings/SettingsView.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/platform/web/ui/session/settings/SettingsView.js b/src/platform/web/ui/session/settings/SettingsView.js index fd64593e..0b8a4837 100644 --- a/src/platform/web/ui/session/settings/SettingsView.js +++ b/src/platform/web/ui/session/settings/SettingsView.js @@ -47,7 +47,7 @@ export class SettingsView extends TemplateView { t.h3("Session Backup"), t.view(new SessionBackupSettingsView(vm.sessionBackupViewModel)), t.h3("Preferences"), - row(vm.i18n`Scale images down when sending`, this._imageCompressionRange(t, vm)), + row(vm.i18n`Scale down images when sending`, this._imageCompressionRange(t, vm)), t.h3("Application"), row(vm.i18n`Version`, version), row(vm.i18n`Storage usage`, vm => `${vm.storageUsage} / ${vm.storageQuota}`),