From d7ccdd3304f96cf2edaf401421fa50003f7107ce Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Wed, 28 Oct 2020 16:56:20 +0100 Subject: [PATCH 01/20] switch main layout from flexbox to grid so we can overlay a lightbox --- src/platform/web/ui/css/layout.css | 63 ++++++++++++++++------ src/platform/web/ui/session/SessionView.js | 28 +++++----- 2 files changed, 61 insertions(+), 30 deletions(-) diff --git a/src/platform/web/ui/css/layout.css b/src/platform/web/ui/css/layout.css index 2ee584b0..1a32167b 100644 --- a/src/platform/web/ui/css/layout.css +++ b/src/platform/web/ui/css/layout.css @@ -19,6 +19,11 @@ html { height: 100%; } +/* unknown element in IE11 that defaults to inline */ +main { + display: block; +} + @media screen and (min-width: 600px) { .PreSessionScreen { width: 600px; @@ -34,46 +39,60 @@ html { } .SessionView { - display: flex; - flex-direction: column; /* this takes into account whether or not the url bar is hidden on mobile (have tested Firefox Android and Safari on iOS), see https://developers.google.com/web/updates/2016/12/url-bar-resizing */ position: fixed; height: 100%; -} - -.SessionView > .main { - flex: 1; - display: flex; + width: 100%; + display: grid; + grid-template: + "status status" auto + "left middle" 1fr / + 300px 1fr; min-height: 0; min-width: 0; - width: 100vw; } /* hide back button in middle section by default */ .middle .close-middle { display: none; } /* mobile layout */ @media screen and (max-width: 800px) { + .SessionView:not(.middle-shown) { + grid-template: + "status" auto + "left" 1fr / + 1fr; + } + + .SessionView.middle-shown { + grid-template: + "status" auto + "middle" 1fr / + 1fr; + } + + .SessionView:not(.middle-shown) .room-placeholder { display: none; } + .SessionView.middle-shown .LeftPanel { display: none; } + /* show back button */ .middle .close-middle { display: block !important; } /* hide grid button */ .LeftPanel .grid { display: none !important; } - div.middle, div.room-placeholder { display: none; } - div.LeftPanel {flex-grow: 1;} - div.middle-shown div.middle { display: flex; } - div.middle-shown div.LeftPanel { display: none; } - div.right-shown div.TimelinePanel { display: none; } } .LeftPanel { - flex: 0 0 300px; + grid-area: left; min-width: 0; } .room-placeholder, .middle { - flex: 1 0 0; min-width: 0; + grid-area: middle; + /* when room view is inside of a grid, + grid-area middle won't be found, + so set width manually */ + width: 100%; } .RoomView { @@ -81,6 +100,20 @@ html { display: flex; } +.SessionStatusView { + grid-area: status; +} + +.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; + background-color: rgba(0,0,0,0.5); + /* 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 */ + z-index: 1; +} .TimelinePanel { flex: 3; diff --git a/src/platform/web/ui/session/SessionView.js b/src/platform/web/ui/session/SessionView.js index fbd3eb23..cc16b8d8 100644 --- a/src/platform/web/ui/session/SessionView.js +++ b/src/platform/web/ui/session/SessionView.js @@ -32,21 +32,19 @@ export class SessionView extends TemplateView { }, }, [ t.view(new SessionStatusView(vm.sessionStatusViewModel)), - t.div({className: "main"}, [ - t.view(new LeftPanelView(vm.leftPanelViewModel)), - t.mapView(vm => vm.activeSection, activeSection => { - switch (activeSection) { - case "roomgrid": - return new RoomGridView(vm.roomGridViewModel); - case "placeholder": - return new StaticView(t => t.div({className: "room-placeholder"}, t.h2(vm.i18n`Choose a room on the left side.`))); - case "settings": - return new SettingsView(vm.settingsViewModel); - default: //room id - return new RoomView(vm.currentRoomViewModel); - } - }) - ]) + t.view(new LeftPanelView(vm.leftPanelViewModel)), + t.mapView(vm => vm.activeSection, activeSection => { + switch (activeSection) { + case "roomgrid": + return new RoomGridView(vm.roomGridViewModel); + case "placeholder": + return new StaticView(t => t.div({className: "room-placeholder"}, t.h2(vm.i18n`Choose a room on the left side.`))); + case "settings": + return new SettingsView(vm.settingsViewModel); + default: //room id + return new RoomView(vm.currentRoomViewModel); + } + }), ]); } } From fe6e4464fd7e19e1643da46b75799410c9f6dfd1 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Wed, 28 Oct 2020 17:37:45 +0100 Subject: [PATCH 02/20] pass all viewmodel options to tile view models --- src/domain/session/room/timeline/TimelineViewModel.js | 2 +- src/domain/session/room/timeline/tiles/GapTile.js | 11 +++++++---- src/domain/session/room/timeline/tiles/MessageTile.js | 9 ++++++++- src/domain/session/room/timeline/tilesCreator.js | 7 +++---- 4 files changed, 19 insertions(+), 10 deletions(-) diff --git a/src/domain/session/room/timeline/TimelineViewModel.js b/src/domain/session/room/timeline/TimelineViewModel.js index 7366641b..e3d92171 100644 --- a/src/domain/session/room/timeline/TimelineViewModel.js +++ b/src/domain/session/room/timeline/TimelineViewModel.js @@ -43,7 +43,7 @@ export class TimelineViewModel extends ViewModel { // once we support sending messages we could do // timeline.entries.concat(timeline.pendingEvents) // for an ObservableList that also contains local echos - this._tiles = new TilesCollection(timeline.entries, tilesCreator({room, ownUserId, platform: this.platform})); + this._tiles = new TilesCollection(timeline.entries, tilesCreator(this.childOptions({room, ownUserId}))); } async load() { diff --git a/src/domain/session/room/timeline/tiles/GapTile.js b/src/domain/session/room/timeline/tiles/GapTile.js index 98d197b9..c2cf2f56 100644 --- a/src/domain/session/room/timeline/tiles/GapTile.js +++ b/src/domain/session/room/timeline/tiles/GapTile.js @@ -18,22 +18,25 @@ import {SimpleTile} from "./SimpleTile.js"; import {UpdateAction} from "../UpdateAction.js"; export class GapTile extends SimpleTile { - constructor(options, timeline) { + constructor(options) { super(options); - this._timeline = timeline; this._loading = false; this._error = null; } + get _room() { + return this.getOption("room"); + } + async fill() { // prevent doing this twice if (!this._loading) { this._loading = true; this.emitChange("isLoading"); try { - await this._timeline.fillGap(this._entry, 10); + await this._room.fillGap(this._entry, 10); } catch (err) { - console.error(`timeline.fillGap(): ${err.message}:\n${err.stack}`); + console.error(`room.fillGap(): ${err.message}:\n${err.stack}`); this._error = err; this.emitChange("error"); // rethrow so caller of this method diff --git a/src/domain/session/room/timeline/tiles/MessageTile.js b/src/domain/session/room/timeline/tiles/MessageTile.js index 36d08ca7..fe566814 100644 --- a/src/domain/session/room/timeline/tiles/MessageTile.js +++ b/src/domain/session/room/timeline/tiles/MessageTile.js @@ -20,12 +20,19 @@ import {getIdentifierColorNumber, avatarInitials} from "../../../../avatar.js"; export class MessageTile extends SimpleTile { constructor(options) { super(options); - this._mediaRepository = options.mediaRepository; this._isOwn = this._entry.sender === options.ownUserId; this._date = this._entry.timestamp ? new Date(this._entry.timestamp) : null; this._isContinuation = false; } + get _room() { + return this.getOption("room"); + } + + get _mediaRepository() { + return this._room.mediaRepository; + } + get shape() { return "message"; } diff --git a/src/domain/session/room/timeline/tilesCreator.js b/src/domain/session/room/timeline/tilesCreator.js index d682d22e..549ad65d 100644 --- a/src/domain/session/room/timeline/tilesCreator.js +++ b/src/domain/session/room/timeline/tilesCreator.js @@ -23,12 +23,11 @@ import {RoomMemberTile} from "./tiles/RoomMemberTile.js"; import {EncryptedEventTile} from "./tiles/EncryptedEventTile.js"; import {EncryptionEnabledTile} from "./tiles/EncryptionEnabledTile.js"; -export function tilesCreator({room, ownUserId, platform}) { +export function tilesCreator(baseOptions) { return function tilesCreator(entry, emitUpdate) { - const options = {entry, emitUpdate, ownUserId, platform, - mediaRepository: room.mediaRepository}; + const options = Object.assign({entry, emitUpdate}, baseOptions); if (entry.isGap) { - return new GapTile(options, room); + return new GapTile(options); } else if (entry.eventType) { switch (entry.eventType) { case "m.room.message": { From 68a0dd30ca17288db192f3738baeee1fb73fdadd Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Wed, 28 Oct 2020 17:42:18 +0100 Subject: [PATCH 03/20] add lightbox navigation and basic view & view model --- src/domain/navigation/index.js | 2 ++ src/domain/session/SessionViewModel.js | 21 ++++++++++++ src/domain/session/room/LightboxViewModel.js | 33 +++++++++++++++++++ .../session/room/timeline/tiles/ImageTile.js | 9 +++++ src/platform/web/ui/session/SessionView.js | 2 ++ .../web/ui/session/room/LightboxView.js | 23 +++++++++++++ .../web/ui/session/room/timeline/ImageView.js | 1 + 7 files changed, 91 insertions(+) create mode 100644 src/domain/session/room/LightboxViewModel.js create mode 100644 src/platform/web/ui/session/room/LightboxView.js diff --git a/src/domain/navigation/index.js b/src/domain/navigation/index.js index ec593122..44f81026 100644 --- a/src/domain/navigation/index.js +++ b/src/domain/navigation/index.js @@ -36,6 +36,8 @@ function allowsChild(parent, child) { case "rooms": // downside of the approach: both of these will control which tile is selected return type === "room" || type === "empty-grid-tile"; + case "room": + return type === "lightbox"; default: return false; } diff --git a/src/domain/session/SessionViewModel.js b/src/domain/session/SessionViewModel.js index 79d8d87c..5f327ef9 100644 --- a/src/domain/session/SessionViewModel.js +++ b/src/domain/session/SessionViewModel.js @@ -17,6 +17,7 @@ limitations under the License. import {LeftPanelViewModel} from "./leftpanel/LeftPanelViewModel.js"; import {RoomViewModel} from "./room/RoomViewModel.js"; +import {LightboxViewModel} from "./room/LightboxViewModel.js"; import {SessionStatusViewModel} from "./SessionStatusViewModel.js"; import {RoomGridViewModel} from "./RoomGridViewModel.js"; import {SettingsViewModel} from "./settings/SettingsViewModel.js"; @@ -67,6 +68,12 @@ export class SessionViewModel extends ViewModel { this._updateSettings(settingsOpen); })); this._updateSettings(settings.get()); + + const lightbox = this.navigation.observe("lightbox"); + this.track(lightbox.subscribe(eventId => { + this._updateLightbox(eventId); + })); + this._updateLightbox(lightbox.get()); } get id() { @@ -194,4 +201,18 @@ export class SessionViewModel extends ViewModel { } this.emitChange("activeSection"); } + + _updateLightbox(eventId) { + if (this._lightboxViewModel) { + this._lightboxViewModel = this.disposeTracked(this._lightboxViewModel); + } + if (eventId) { + this._lightboxViewModel = this.track(new LightboxViewModel(this.childOptions({eventId}))); + } + this.emitChange("lightboxViewModel"); + } + + get lightboxViewModel() { + return this._lightboxViewModel; + } } diff --git a/src/domain/session/room/LightboxViewModel.js b/src/domain/session/room/LightboxViewModel.js new file mode 100644 index 00000000..00599eca --- /dev/null +++ b/src/domain/session/room/LightboxViewModel.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 {ViewModel} from "../../ViewModel.js"; + +export class LightboxViewModel extends ViewModel { + constructor(options) { + super(options); + this._eventId = options.eventId; + this._closeUrl = this.urlCreator.urlUntilSegment("room"); + } + + get eventId() { + return this._eventId; + } + + get closeUrl() { + return this._closeUrl; + } +} diff --git a/src/domain/session/room/timeline/tiles/ImageTile.js b/src/domain/session/room/timeline/tiles/ImageTile.js index 04cff3c9..f4212912 100644 --- a/src/domain/session/room/timeline/tiles/ImageTile.js +++ b/src/domain/session/room/timeline/tiles/ImageTile.js @@ -27,6 +27,11 @@ export class ImageTile extends MessageTile { this._decryptedImage = null; this._error = null; this.load(); + this._lightboxUrl = this.urlCreator.urlForSegments([ + // ensure the right room is active if in grid view + this.navigation.segment("room", this._room.id), + this.navigation.segment("lightbox", this._entry.id) + ]); } async _loadEncryptedFile(file) { @@ -54,6 +59,10 @@ export class ImageTile extends MessageTile { } } + get lightboxUrl() { + return this._lightboxUrl; + } + get thumbnailUrl() { if (this._decryptedThumbail) { return this._decryptedThumbail.url; diff --git a/src/platform/web/ui/session/SessionView.js b/src/platform/web/ui/session/SessionView.js index cc16b8d8..fa7a492a 100644 --- a/src/platform/web/ui/session/SessionView.js +++ b/src/platform/web/ui/session/SessionView.js @@ -17,6 +17,7 @@ limitations under the License. import {LeftPanelView} from "./leftpanel/LeftPanelView.js"; import {RoomView} from "./room/RoomView.js"; +import {LightboxView} from "./room/LightboxView.js"; import {TemplateView} from "../general/TemplateView.js"; import {StaticView} from "../general/StaticView.js"; import {SessionStatusView} from "./SessionStatusView.js"; @@ -45,6 +46,7 @@ export class SessionView extends TemplateView { return new RoomView(vm.currentRoomViewModel); } }), + t.mapView(vm => vm.lightboxViewModel, lightboxViewModel => lightboxViewModel ? new LightboxView(lightboxViewModel) : null) ]); } } diff --git a/src/platform/web/ui/session/room/LightboxView.js b/src/platform/web/ui/session/room/LightboxView.js new file mode 100644 index 00000000..4c4ef316 --- /dev/null +++ b/src/platform/web/ui/session/room/LightboxView.js @@ -0,0 +1,23 @@ +/* +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"; + +export class LightboxView extends TemplateView { + render(t, vm) { + return t.div({className: "lightbox"}, [vm.eventId, t.br(), t.a({href: vm.closeUrl}, "close")]); + } +} diff --git a/src/platform/web/ui/session/room/timeline/ImageView.js b/src/platform/web/ui/session/room/timeline/ImageView.js index 113fb1e4..90355fe6 100644 --- a/src/platform/web/ui/session/room/timeline/ImageView.js +++ b/src/platform/web/ui/session/room/timeline/ImageView.js @@ -31,6 +31,7 @@ export class ImageView extends TemplateView { title: vm => vm.label, }); const linkContainer = t.a({ + href: vm.lightboxUrl, style: `padding-top: ${heightRatioPercent}%; width: ${vm.thumbnailWidth}px;` }, [ image, From 7d81306a497e172b2f3d6b1a2e596c3b2ba38e2f Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Fri, 30 Oct 2020 15:18:27 +0100 Subject: [PATCH 04/20] return BufferHandles from the media repository BufferHandles are platform specific handles to a buffer. On web, they have a .blob and .url property. --- .../session/room/timeline/tiles/ImageTile.js | 5 +++-- src/matrix/SessionContainer.js | 3 +-- src/matrix/net/MediaRepository.js | 11 +++++------ src/platform/web/Platform.js | 6 +++--- .../web/dom/{BufferURL.js => BufferHandle.js} | 19 ++++++++++++++----- 5 files changed, 26 insertions(+), 18 deletions(-) rename src/platform/web/dom/{BufferURL.js => BufferHandle.js} (89%) diff --git a/src/domain/session/room/timeline/tiles/ImageTile.js b/src/domain/session/room/timeline/tiles/ImageTile.js index f4212912..1e31e414 100644 --- a/src/domain/session/room/timeline/tiles/ImageTile.js +++ b/src/domain/session/room/timeline/tiles/ImageTile.js @@ -35,11 +35,12 @@ export class ImageTile extends MessageTile { } async _loadEncryptedFile(file) { - const buffer = await this._mediaRepository.downloadEncryptedFile(file); + const bufferHandle = await this._mediaRepository.downloadEncryptedFile(file); if (this.isDisposed) { + bufferHandle.dispose(); return; } - return this.track(this.platform.createBufferURL(buffer, file.mimetype)); + return this.track(bufferHandle); } async load() { diff --git a/src/matrix/SessionContainer.js b/src/matrix/SessionContainer.js index e0fcf951..4f28c946 100644 --- a/src/matrix/SessionContainer.js +++ b/src/matrix/SessionContainer.js @@ -167,8 +167,7 @@ export class SessionContainer { this._requestScheduler.start(); const mediaRepository = new MediaRepository({ homeServer: sessionInfo.homeServer, - crypto: this._platform.crypto, - request: this._platform.request, + platform: this._platform, }); this._session = new Session({ storage: this._storage, diff --git a/src/matrix/net/MediaRepository.js b/src/matrix/net/MediaRepository.js index 856c6657..a20b6d1c 100644 --- a/src/matrix/net/MediaRepository.js +++ b/src/matrix/net/MediaRepository.js @@ -18,10 +18,9 @@ import {encodeQueryParams} from "./common.js"; import {decryptAttachment} from "../e2ee/attachment.js"; export class MediaRepository { - constructor({homeServer, crypto, request}) { + constructor({homeServer, platform}) { this._homeServer = homeServer; - this._crypto = crypto; - this._request = request; + this._platform = platform; } mxcUrlThumbnail(url, width, height, method) { @@ -55,8 +54,8 @@ export class MediaRepository { async downloadEncryptedFile(fileEntry) { const url = this.mxcUrl(fileEntry.url); - const {body: encryptedBuffer} = await this._request(url, {method: "GET", format: "buffer", cache: true}).response(); - const decryptedBuffer = await decryptAttachment(this._crypto, encryptedBuffer, fileEntry); - return decryptedBuffer; + const {body: encryptedBuffer} = await this._platform.request(url, {method: "GET", format: "buffer", cache: true}).response(); + const decryptedBuffer = await decryptAttachment(this._platform.crypto, encryptedBuffer, fileEntry); + return this._platform.createBufferHandle(decryptedBuffer, fileEntry.mimetype); } } diff --git a/src/platform/web/Platform.js b/src/platform/web/Platform.js index dc299ef8..fa3451db 100644 --- a/src/platform/web/Platform.js +++ b/src/platform/web/Platform.js @@ -27,7 +27,7 @@ import {OnlineStatus} from "./dom/OnlineStatus.js"; import {Crypto} from "./dom/Crypto.js"; import {estimateStorageUsage} from "./dom/StorageEstimate.js"; import {WorkerPool} from "./dom/WorkerPool.js"; -import {BufferURL} from "./dom/BufferURL.js"; +import {BufferHandle} from "./dom/BufferHandle.js"; function addScript(src) { return new Promise(function (resolve, reject) { @@ -129,7 +129,7 @@ export class Platform { this._serviceWorkerHandler?.setNavigation(navigation); } - createBufferURL(buffer, mimetype) { - return new BufferURL(buffer, mimetype); + createBufferHandle(buffer, mimetype) { + return new BufferHandle(buffer, mimetype); } } diff --git a/src/platform/web/dom/BufferURL.js b/src/platform/web/dom/BufferHandle.js similarity index 89% rename from src/platform/web/dom/BufferURL.js rename to src/platform/web/dom/BufferHandle.js index 28730022..80bb40bb 100644 --- a/src/platform/web/dom/BufferURL.js +++ b/src/platform/web/dom/BufferHandle.js @@ -69,18 +69,27 @@ const ALLOWED_BLOB_MIMETYPES = { 'audio/x-flac': true, }; -export class BufferURL { +export class BufferHandle { constructor(buffer, mimetype) { mimetype = mimetype ? mimetype.split(";")[0].trim() : ''; if (!ALLOWED_BLOB_MIMETYPES[mimetype]) { mimetype = 'application/octet-stream'; } - const blob = new Blob([buffer], {type: mimetype}); - this.url = URL.createObjectURL(blob); + this.blob = new Blob([buffer], {type: mimetype}); + this._url = null; + } + + get url() { + if (!this._url) { + this._url = URL.createObjectURL(this.blob); + } + return this._url; } dispose() { - URL.revokeObjectURL(this.url); - this.url = null; + if (this._url) { + URL.revokeObjectURL(this._url); + this._url = null; + } } } From 137264edcbd94f274bac7a74e2ebb1a426bdb274 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Fri, 30 Oct 2020 15:19:51 +0100 Subject: [PATCH 05/20] implement subscribing to a single event --- src/matrix/room/ObservedEventMap.js | 90 ++++++++++++++++++++++++++++ src/matrix/room/Room.js | 48 ++++++++++++++- src/matrix/room/timeline/Timeline.js | 9 +++ src/observable/BaseObservable.js | 4 ++ 4 files changed, 150 insertions(+), 1 deletion(-) create mode 100644 src/matrix/room/ObservedEventMap.js diff --git a/src/matrix/room/ObservedEventMap.js b/src/matrix/room/ObservedEventMap.js new file mode 100644 index 00000000..1e21df63 --- /dev/null +++ b/src/matrix/room/ObservedEventMap.js @@ -0,0 +1,90 @@ +/* +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 {BaseObservableValue} from "../../observable/ObservableValue.js"; + +export class ObservedEventMap { + constructor(notifyEmpty) { + this._map = new Map(); + this._notifyEmpty = notifyEmpty; + } + + observe(eventId, eventEntry = null) { + let observable = this._map.get(eventId); + if (!observable) { + observable = new ObservedEvent(this, eventEntry); + this._map.set(eventId, observable); + } + return observable; + } + + updateEvents(eventEntries) { + for (let i = 0; i < eventEntries.length; i += 1) { + const entry = eventEntries[i]; + const observable = this._map.get(entry.id); + observable?.update(entry); + } + } + + _remove(observable) { + this._map.delete(observable.get().id); + if (this._map.size === 0) { + this._notifyEmpty(); + } + } +} + +class ObservedEvent extends BaseObservableValue { + constructor(eventMap, entry) { + super(); + this._eventMap = eventMap; + this._entry = entry; + // remove subscription in microtask after creating it + // otherwise ObservedEvents would easily never get + // removed if you never subscribe + Promise.resolve().then(() => { + if (!this.hasSubscriptions) { + this._eventMap.remove(this); + this._eventMap = null; + } + }); + } + + subscribe(handler) { + if (!this._eventMap) { + throw new Error("ObservedEvent expired, subscribe right after calling room.observeEvent()"); + } + return super.subscribe(handler); + } + + onUnsubscribeLast() { + this._eventMap._remove(this); + this._eventMap = null; + super.onUnsubscribeLast(); + } + + update(entry) { + // entries are mostly updated in-place, + // apart from when they are created, + // but doesn't hurt to reassign + this._entry = entry; + this.emit(this._entry); + } + + get() { + return this._entry; + } +} diff --git a/src/matrix/room/Room.js b/src/matrix/room/Room.js index f12da45b..b2d2b635 100644 --- a/src/matrix/room/Room.js +++ b/src/matrix/room/Room.js @@ -29,8 +29,8 @@ import {Heroes} from "./members/Heroes.js"; import {EventEntry} from "./timeline/entries/EventEntry.js"; import {EventKey} from "./timeline/EventKey.js"; import {Direction} from "./timeline/Direction.js"; +import {ObservedEventMap} from "./ObservedEventMap.js"; import {DecryptionSource} from "../e2ee/common.js"; - const EVENT_ENCRYPTED_TYPE = "m.room.encrypted"; export class Room extends EventEmitter { @@ -53,6 +53,7 @@ export class Room extends EventEmitter { this._roomEncryption = null; this._getSyncToken = getSyncToken; this._clock = clock; + this._observedEvents = null; } _readRetryDecryptCandidateEntries(sinceEventKey, txn) { @@ -165,6 +166,9 @@ export class Room extends EventEmitter { } await writeTxn.complete(); decryption.applyToEntries(entries); + if (this._observedEvents) { + this._observedEvents.updateEvents(entries); + } }); return request; } @@ -285,6 +289,9 @@ export class Room extends EventEmitter { if (this._timeline) { this._timeline.appendLiveEntries(newTimelineEntries); } + if (this._observedEvents) { + this._observedEvents.updateEvents(newTimelineEntries); + } if (removedPendingEvents) { this._sendQueue.emitRemovals(removedPendingEvents); } @@ -580,6 +587,45 @@ export class Room extends EventEmitter { this._summary.applyChanges(changes); } + observeEvent(eventId) { + if (!this._observedEvents) { + this._observedEvents = new ObservedEventMap(() => { + this._observedEvents = null; + }); + } + let entry = null; + if (this._timeline) { + entry = this._timeline.getByEventId(eventId); + } + const observable = this._observedEvents.observe(eventId, entry); + if (!entry) { + // update in the background + this._readEventById(eventId).then(entry => { + observable.update(entry); + }).catch(err => { + console.warn(`could not load event ${eventId} from storage`, err); + }); + } + return observable; + } + + async _readEventById(eventId) { + let stores = [this._storage.storeNames.timelineEvents]; + if (this.isEncrypted) { + stores.push(this._storage.storeNames.inboundGroupSessions); + } + const txn = this._storage.readTxn(stores); + const storageEntry = await txn.timelineEvents.getByEventId(this._roomId, eventId); + if (storageEntry) { + const entry = new EventEntry(storageEntry, this._fragmentIdComparer); + if (entry.eventType === EVENT_ENCRYPTED_TYPE) { + const request = this._decryptEntries(DecryptionSource.Timeline, [entry], txn); + await request.complete(); + } + return entry; + } + } + dispose() { this._roomEncryption?.dispose(); this._timeline?.dispose(); diff --git a/src/matrix/room/timeline/Timeline.js b/src/matrix/room/timeline/Timeline.js index 8cad17a1..1b7c8a18 100644 --- a/src/matrix/room/timeline/Timeline.js +++ b/src/matrix/room/timeline/Timeline.js @@ -95,6 +95,15 @@ export class Timeline { } } + getByEventId(eventId) { + for (let i = 0; i < this._remoteEntries.length; i += 1) { + const entry = this._remoteEntries.get(i); + if (entry.id === eventId) { + return entry; + } + } + } + /** @public */ get entries() { return this._allEntries; diff --git a/src/observable/BaseObservable.js b/src/observable/BaseObservable.js index 660f3200..29387020 100644 --- a/src/observable/BaseObservable.js +++ b/src/observable/BaseObservable.js @@ -48,6 +48,10 @@ export class BaseObservable { return null; } + get hasSubscriptions() { + return this._handlers.size !== 0; + } + // Add iterator over handlers here } From c9147e6b9a0bb11ca2d42daacb9af010590778c6 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Fri, 30 Oct 2020 15:20:11 +0100 Subject: [PATCH 06/20] implement first draft of image lightbox --- src/domain/session/SessionViewModel.js | 4 +- src/domain/session/room/LightboxViewModel.js | 68 ++++++++++++++++++- src/platform/web/ui/css/layout.css | 1 - .../web/ui/css/themes/element/theme.css | 60 ++++++++++++++++ src/platform/web/ui/login/LoginView.js | 2 +- .../web/ui/session/room/LightboxView.js | 32 ++++++++- 6 files changed, 161 insertions(+), 6 deletions(-) diff --git a/src/domain/session/SessionViewModel.js b/src/domain/session/SessionViewModel.js index 5f327ef9..2f7e341e 100644 --- a/src/domain/session/SessionViewModel.js +++ b/src/domain/session/SessionViewModel.js @@ -207,7 +207,9 @@ export class SessionViewModel extends ViewModel { this._lightboxViewModel = this.disposeTracked(this._lightboxViewModel); } if (eventId) { - this._lightboxViewModel = this.track(new LightboxViewModel(this.childOptions({eventId}))); + const roomId = this.navigation.path.get("room").value; + const room = this._sessionContainer.session.rooms.get(roomId); + this._lightboxViewModel = this.track(new LightboxViewModel(this.childOptions({eventId, room}))); } this.emitChange("lightboxViewModel"); } diff --git a/src/domain/session/room/LightboxViewModel.js b/src/domain/session/room/LightboxViewModel.js index 00599eca..f6da39b0 100644 --- a/src/domain/session/room/LightboxViewModel.js +++ b/src/domain/session/room/LightboxViewModel.js @@ -20,14 +20,78 @@ export class LightboxViewModel extends ViewModel { constructor(options) { super(options); this._eventId = options.eventId; + this._unencryptedImageUrl = null; + this._decryptedImage = null; this._closeUrl = this.urlCreator.urlUntilSegment("room"); + this._eventEntry = null; + this._date = null; + this._subscribeToEvent(options.room, options.eventId); } - get eventId() { - return this._eventId; + _subscribeToEvent(room, eventId) { + const eventObservable = room.observeEvent(eventId); + this.track(eventObservable.subscribe(eventEntry => { + this._loadEvent(room, eventEntry); + })); + this._loadEvent(room, eventObservable.get()); + } + + async _loadEvent(room, eventEntry) { + if (!eventEntry) { + return; + } + const {mediaRepository} = room; + this._eventEntry = eventEntry; + const {content} = this._eventEntry; + this._date = this._eventEntry.timestamp ? new Date(this._eventEntry.timestamp) : null; + if (content.url) { + this._unencryptedImageUrl = mediaRepository.mxcUrl(content.url); + this.emitChange("imageUrl"); + } else if (content.file) { + this._decryptedImage = this.track(await mediaRepository.downloadEncryptedFile(content.file)); + this.emitChange("imageUrl"); + } + } + + get imageWidth() { + return this._eventEntry?.content?.info?.w; + } + + get imageHeight() { + return this._eventEntry?.content?.info?.h; + } + + get name() { + return this._eventEntry?.content?.body; + } + + get sender() { + return this._eventEntry?.displayName; + } + + get imageUrl() { + if (this._decryptedImage) { + return this._decryptedImage.url; + } else if (this._unencryptedImageUrl) { + return this._unencryptedImageUrl; + } else { + return ""; + } + } + + get date() { + return this._date && this._date.toLocaleDateString({}, { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' }); + } + + get time() { + return this._date && this._date.toLocaleTimeString({}, {hour: "numeric", minute: "2-digit"}); } get closeUrl() { return this._closeUrl; } + + close() { + this.platform.history.pushUrl(this.closeUrl); + } } diff --git a/src/platform/web/ui/css/layout.css b/src/platform/web/ui/css/layout.css index 1a32167b..eb3f8355 100644 --- a/src/platform/web/ui/css/layout.css +++ b/src/platform/web/ui/css/layout.css @@ -109,7 +109,6 @@ main { use numeric positions because named grid areas are not present in mobile layout */ grid-area: 2 / 1 / 3 / 3; - background-color: rgba(0,0,0,0.5); /* 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 */ z-index: 1; diff --git a/src/platform/web/ui/css/themes/element/theme.css b/src/platform/web/ui/css/themes/element/theme.css index 9473b307..33d5c6f0 100644 --- a/src/platform/web/ui/css/themes/element/theme.css +++ b/src/platform/web/ui/css/themes/element/theme.css @@ -632,3 +632,63 @@ button.link { color: #03B381; font-weight: 600; } + +.lightbox { + background-color: rgba(0,0,0,0.75); + display: grid; + grid-template: + "content close" auto + "content details" 1fr / + 1fr auto; + color: white; +} + +@media (max-aspect-ratio: 1/1) { + .lightbox { + grid-template: + "close" auto + "content" 1fr + "details" auto / + 1fr; + } + + .lightbox .details { + width: 100% !important; + } +} + +.lightbox .picture { + grid-area: content; + background-size: contain; + background-position: center; + background-repeat: no-repeat; + width: 100%; + height: 100%; + margin: auto; +} + +.lightbox .loading { + grid-area: content; + margin: auto; +} + +.lightbox .close { + grid-area: close; + margin-left: auto; + background-image: url('icons/dismiss.svg'); + background-position: center; + background-size: 16px; + background-repeat: no-repeat; + width: 16px; + height: 16px; + padding: 12px; +} + +.lightbox .details { + grid-area: details; + padding: 12px; + font-size: 1.5rem; + width: 200px; +} + + diff --git a/src/platform/web/ui/login/LoginView.js b/src/platform/web/ui/login/LoginView.js index e03eab6b..1196295e 100644 --- a/src/platform/web/ui/login/LoginView.js +++ b/src/platform/web/ui/login/LoginView.js @@ -35,7 +35,7 @@ export class LoginView extends TemplateView { }); const homeserver = t.input({ id: "homeserver", - type: "text", + type: "url", placeholder: vm.i18n`Your matrix homeserver`, value: vm.defaultHomeServer, disabled diff --git a/src/platform/web/ui/session/room/LightboxView.js b/src/platform/web/ui/session/room/LightboxView.js index 4c4ef316..4553c3bb 100644 --- a/src/platform/web/ui/session/room/LightboxView.js +++ b/src/platform/web/ui/session/room/LightboxView.js @@ -15,9 +15,39 @@ limitations under the License. */ import {TemplateView} from "../../general/TemplateView.js"; +import {spinner} from "../../common.js"; export class LightboxView extends TemplateView { render(t, vm) { - return t.div({className: "lightbox"}, [vm.eventId, t.br(), t.a({href: vm.closeUrl}, "close")]); + const close = t.a({href: vm.closeUrl, title: vm.i18n`Close`, className: "close"}); + const image = t.div({ + role: "img", + "aria-label": vm => vm.name, + title: vm => vm.name, + className: { + picture: true, + hidden: vm => !vm.imageUrl, + }, + style: vm => `background-image: url('${vm.imageUrl}'); max-width: ${vm.imageWidth}px; max-height: ${vm.imageHeight}px;` + }); + const loading = t.div({ + className: { + loading: true, + hidden: vm => !!vm.imageUrl + } + }, [ + spinner(t), + t.div(vm.i18n`Loading imageā€¦`) + ]); + const details = t.div({ + className: "details" + }, [t.strong(vm => vm.name), t.br(), "uploaded by ", t.strong(vm => vm.sender), vm => ` at ${vm.time} on ${vm.date}.`]); + return t.div({className: "lightbox", onClick: evt => this.close(evt)}, [image, loading, details, close]); + } + + close(evt) { + if (evt.target === this.root()) { + this.value.close(); + } } } From 597a57eae3336d990f33dafbe6dddb3ce62478e2 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Fri, 30 Oct 2020 19:59:21 +0100 Subject: [PATCH 07/20] too much whitespace --- src/matrix/Sync.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/matrix/Sync.js b/src/matrix/Sync.js index 3bdf7d9d..69dbad8e 100644 --- a/src/matrix/Sync.js +++ b/src/matrix/Sync.js @@ -200,7 +200,7 @@ export class Sync { syncTxn.abort(); } catch (abortErr) { console.error("Could not abort sync transaction, the sync response was probably only partially written and may have put storage in a inconsistent state.", abortErr); - } + } throw err; } try { From c847cfb2007a9b1bd4793a45b5e55fdf76ff3a9e Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Fri, 30 Oct 2020 19:59:42 +0100 Subject: [PATCH 08/20] WIP: Esc key on lightbox --- src/platform/web/ui/session/room/LightboxView.js | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/src/platform/web/ui/session/room/LightboxView.js b/src/platform/web/ui/session/room/LightboxView.js index 4553c3bb..e6d613eb 100644 --- a/src/platform/web/ui/session/room/LightboxView.js +++ b/src/platform/web/ui/session/room/LightboxView.js @@ -42,12 +42,22 @@ export class LightboxView extends TemplateView { const details = t.div({ className: "details" }, [t.strong(vm => vm.name), t.br(), "uploaded by ", t.strong(vm => vm.sender), vm => ` at ${vm.time} on ${vm.date}.`]); - return t.div({className: "lightbox", onClick: evt => this.close(evt)}, [image, loading, details, close]); + return t.div({ + className: "lightbox", + onClick: evt => this.clickToClose(evt), + onKeydown: evt => this.closeOnEscKey(evt) + }, [image, loading, details, close]); } - close(evt) { + clickToClose(evt) { if (evt.target === this.root()) { this.value.close(); } } + + closeOnEscKey(evt) { + if (evt.key === "Escape") { + this.value.close(); + } + } } From 581dc95c535e8a7990d2f4b01d7b8d7ff89e747a Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Fri, 30 Oct 2020 22:48:56 +0100 Subject: [PATCH 09/20] allow adding custom event handlers to a template view so you don't have to remove them yourself --- src/platform/web/ui/general/TemplateView.js | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/src/platform/web/ui/general/TemplateView.js b/src/platform/web/ui/general/TemplateView.js index 80c2cf2e..8158fcb3 100644 --- a/src/platform/web/ui/general/TemplateView.js +++ b/src/platform/web/ui/general/TemplateView.js @@ -74,16 +74,16 @@ export class TemplateView { _attach() { if (this._eventListeners) { - for (let {node, name, fn} of this._eventListeners) { - node.addEventListener(name, fn); + for (let {node, name, fn, useCapture} of this._eventListeners) { + node.addEventListener(name, fn, useCapture); } } } _detach() { if (this._eventListeners) { - for (let {node, name, fn} of this._eventListeners) { - node.removeEventListener(name, fn); + for (let {node, name, fn, useCapture} of this._eventListeners) { + node.removeEventListener(name, fn, useCapture); } } } @@ -132,11 +132,11 @@ export class TemplateView { } } - _addEventListener(node, name, fn) { + _addEventListener(node, name, fn, useCapture = false) { if (!this._eventListeners) { this._eventListeners = []; } - this._eventListeners.push({node, name, fn}); + this._eventListeners.push({node, name, fn, useCapture}); } _addBinding(bindingFn) { @@ -164,6 +164,10 @@ class TemplateBuilder { return this._templateView._value; } + addEventListener(node, name, fn, useCapture = false) { + this._templateView._addEventListener(node, name, fn, useCapture); + } + _addAttributeBinding(node, name, fn) { let prevValue = undefined; const binding = () => { From a21765757d06eb7808b0115643f69436466c5fcc Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Fri, 30 Oct 2020 22:50:59 +0100 Subject: [PATCH 10/20] don't use position: absolute for scaling images in the timeline as it bleeds through the lightbox on IE11, and is a bad idea anyway --- .../web/ui/css/themes/element/theme.css | 32 ++++++++++++++++--- src/platform/web/ui/css/timeline.css | 15 ++------- 2 files changed, 30 insertions(+), 17 deletions(-) diff --git a/src/platform/web/ui/css/themes/element/theme.css b/src/platform/web/ui/css/themes/element/theme.css index 33d5c6f0..f11b658e 100644 --- a/src/platform/web/ui/css/themes/element/theme.css +++ b/src/platform/web/ui/css/themes/element/theme.css @@ -505,11 +505,6 @@ ul.Timeline > li.messageStatus .message-container > p { --avatar-size: 25px; } -.message-container img.picture { - margin-top: 4px; - border-radius: 4px; -} - .TextMessageView.continuation .message-container { margin-top: 0; margin-bottom: 0; @@ -538,6 +533,33 @@ ul.Timeline > li.messageStatus .message-container > p { color: #aaa; } + +.message-container a.picture { + display: grid; + text-decoration: none; + overflow: hidden; + margin-top: 4px; + border-radius: 4px; +} + +.message-container a.picture > img { + grid-row: 1 / 2; + grid-column: 1 / 2; +} + +.message-container a.picture > time { + grid-row: 1 / 2; + grid-column: 1 / 2; + align-self: end; + justify-self: end; + color: #2e2f32; + display: block; + padding: 2px; + margin: 4px; + background-color: rgba(255, 255, 255, 0.75); + border-radius: 4px; +} + .TextMessageView.pending .message-container { color: #ccc; } diff --git a/src/platform/web/ui/css/timeline.css b/src/platform/web/ui/css/timeline.css index 44ff29d6..5a45f56b 100644 --- a/src/platform/web/ui/css/timeline.css +++ b/src/platform/web/ui/css/timeline.css @@ -37,23 +37,14 @@ limitations under the License. margin: 5px 0; } -.message-container a { +.message-container a.picture { display: block; - position: relative; - max-width: 100%; - /* width and padding-top set inline to maintain aspect ratio, - replace with css aspect-ratio once supported */ } -.message-container img.picture { +.message-container a.picture > img { display: block; - position: absolute; - top: 0; - bottom: 0; - left: 0; - right: 0; width: 100%; - height: 100%; + height: auto; } .TextMessageView { From 708893022af96986f754308cab82241dd42ff609 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Fri, 30 Oct 2020 22:52:08 +0100 Subject: [PATCH 11/20] use justify/align-self rather than auto margins, works better in IE11 --- src/platform/web/ui/css/themes/element/theme.css | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/platform/web/ui/css/themes/element/theme.css b/src/platform/web/ui/css/themes/element/theme.css index f11b658e..c6f4a620 100644 --- a/src/platform/web/ui/css/themes/element/theme.css +++ b/src/platform/web/ui/css/themes/element/theme.css @@ -686,17 +686,21 @@ button.link { background-repeat: no-repeat; width: 100%; height: 100%; - margin: auto; + align-self: center; + justify-self: center; } .lightbox .loading { grid-area: content; - margin: auto; + align-self: center; + justify-self: center; + display: flex; } .lightbox .close { + display: block; grid-area: close; - margin-left: auto; + justify-self: end; background-image: url('icons/dismiss.svg'); background-position: center; background-size: 16px; From 5a31bc5f2bc64e4765b16210a63d2fb9438db223 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Fri, 30 Oct 2020 22:52:32 +0100 Subject: [PATCH 12/20] basic focus trapping of lightbox --- .../web/ui/session/room/LightboxView.js | 35 ++++++++++++++++++- 1 file changed, 34 insertions(+), 1 deletion(-) diff --git a/src/platform/web/ui/session/room/LightboxView.js b/src/platform/web/ui/session/room/LightboxView.js index e6d613eb..27151617 100644 --- a/src/platform/web/ui/session/room/LightboxView.js +++ b/src/platform/web/ui/session/room/LightboxView.js @@ -42,11 +42,14 @@ export class LightboxView extends TemplateView { const details = t.div({ className: "details" }, [t.strong(vm => vm.name), t.br(), "uploaded by ", t.strong(vm => vm.sender), vm => ` at ${vm.time} on ${vm.date}.`]); - return t.div({ + const dialog = t.div({ + role: "dialog", className: "lightbox", onClick: evt => this.clickToClose(evt), onKeydown: evt => this.closeOnEscKey(evt) }, [image, loading, details, close]); + trapFocus(t, dialog); + return dialog; } clickToClose(evt) { @@ -61,3 +64,33 @@ export class LightboxView extends TemplateView { } } } + +function trapFocus(t, element) { + const elements = focusables(element); + const first = elements[0]; + const last = elements[elements.length - 1]; + + t.addEventListener(element, "keydown", evt => { + if (evt.key === "Tab") { + if (evt.shiftKey) { + if (document.activeElement === first) { + last.focus(); + evt.preventDefault(); + } + } else { + if (document.activeElement === last) { + first.focus(); + evt.preventDefault(); + } + } + } + }, true); + Promise.resolve().then(() => { + first.focus(); + }); +} + +function focusables(element) { + return element.querySelectorAll('a[href], button, textarea, input, select'); +} + From 5aa2c7dc5cc1ce7f4fd5de1cb430e8de3b82cd24 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Fri, 30 Oct 2020 22:52:54 +0100 Subject: [PATCH 13/20] dom changes for timeline image view --- .../web/ui/session/room/timeline/ImageView.js | 33 ++++++++----------- 1 file changed, 13 insertions(+), 20 deletions(-) diff --git a/src/platform/web/ui/session/room/timeline/ImageView.js b/src/platform/web/ui/session/room/timeline/ImageView.js index 90355fe6..b16ea834 100644 --- a/src/platform/web/ui/session/room/timeline/ImageView.js +++ b/src/platform/web/ui/session/room/timeline/ImageView.js @@ -19,27 +19,20 @@ import {renderMessage} from "./common.js"; export class ImageView extends TemplateView { render(t, vm) { - // replace with css aspect-ratio once supported - const heightRatioPercent = (vm.thumbnailHeight / vm.thumbnailWidth) * 100; - const image = t.img({ - className: "picture", - src: vm => vm.thumbnailUrl, - width: vm.thumbnailWidth, - height: vm.thumbnailHeight, - loading: "lazy", - alt: vm => vm.label, - title: vm => vm.label, - }); - const linkContainer = t.a({ - href: vm.lightboxUrl, - style: `padding-top: ${heightRatioPercent}%; width: ${vm.thumbnailWidth}px;` - }, [ - image, - t.if(vm => vm.error, t.createTemplate((t, vm) => t.p({className: "error"}, vm.error))) - ]); - return renderMessage(t, vm, - [t.div(linkContainer), t.p(t.time(vm.date + " " + vm.time))] + t.div([ + t.a({href: vm.lightboxUrl, className: "picture"}, [ + t.img({ + src: vm => vm.thumbnailUrl, + loading: "lazy", + alt: vm => vm.label, + title: vm => vm.label, + style: vm => `max-width: ${vm.thumbnailWidth}px; max-height: ${vm.thumbnailHeight}px;` + }), + t.time(vm.date + " " + vm.time) + ]), + t.if(vm => vm.error, t.createTemplate((t, vm) => t.p({className: "error"}, vm.error))) + ]) ); } } From 8507a3eb1649cc486271ccd6527ecc61e75e58ed Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Sat, 31 Oct 2020 00:25:05 +0100 Subject: [PATCH 14/20] prevent jumps when image loads by adding a spacer --- .../web/ui/css/themes/element/theme.css | 29 +++++++++++++----- src/platform/web/ui/css/timeline.css | 6 ++-- .../web/ui/session/room/timeline/ImageView.js | 30 +++++++++---------- 3 files changed, 39 insertions(+), 26 deletions(-) diff --git a/src/platform/web/ui/css/themes/element/theme.css b/src/platform/web/ui/css/themes/element/theme.css index c6f4a620..3e42fc9c 100644 --- a/src/platform/web/ui/css/themes/element/theme.css +++ b/src/platform/web/ui/css/themes/element/theme.css @@ -494,6 +494,8 @@ ul.Timeline > li.messageStatus .message-container > p { .message-container { padding: 1px 10px 0px 10px; margin: 5px 10px 0 10px; + /* so the .picture can grow horizontally and its spacer can grow vertically */ + width: 100%; } .message-container .profile { @@ -505,6 +507,10 @@ ul.Timeline > li.messageStatus .message-container > p { --avatar-size: 25px; } +.TextMessageView { + width: 100%; +} + .TextMessageView.continuation .message-container { margin-top: 0; margin-bottom: 0; @@ -534,22 +540,28 @@ ul.Timeline > li.messageStatus .message-container > p { } -.message-container a.picture { +.message-container .picture { display: grid; text-decoration: none; overflow: hidden; margin-top: 4px; border-radius: 4px; + width: 100%; } -.message-container a.picture > img { - grid-row: 1 / 2; - grid-column: 1 / 2; +/* .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 > * { + grid-row: 1; + grid-column: 1; } -.message-container a.picture > time { - grid-row: 1 / 2; - grid-column: 1 / 2; +.message-container .picture > img { + width: 100%; + height: auto; +} + +.message-container .picture > time { align-self: end; justify-self: end; color: #2e2f32; @@ -559,6 +571,9 @@ ul.Timeline > li.messageStatus .message-container > p { background-color: rgba(255, 255, 255, 0.75); border-radius: 4px; } +.message-container .picture > .spacer { + width: 100%; +} .TextMessageView.pending .message-container { color: #ccc; diff --git a/src/platform/web/ui/css/timeline.css b/src/platform/web/ui/css/timeline.css index 5a45f56b..ee9ffdbb 100644 --- a/src/platform/web/ui/css/timeline.css +++ b/src/platform/web/ui/css/timeline.css @@ -37,14 +37,12 @@ limitations under the License. margin: 5px 0; } -.message-container a.picture { +.message-container .picture { display: block; } -.message-container a.picture > img { +.message-container .picture > img { display: block; - width: 100%; - height: auto; } .TextMessageView { diff --git a/src/platform/web/ui/session/room/timeline/ImageView.js b/src/platform/web/ui/session/room/timeline/ImageView.js index b16ea834..00669a55 100644 --- a/src/platform/web/ui/session/room/timeline/ImageView.js +++ b/src/platform/web/ui/session/room/timeline/ImageView.js @@ -19,20 +19,20 @@ import {renderMessage} from "./common.js"; export class ImageView extends TemplateView { render(t, vm) { - return renderMessage(t, vm, - t.div([ - t.a({href: vm.lightboxUrl, className: "picture"}, [ - t.img({ - src: vm => vm.thumbnailUrl, - loading: "lazy", - alt: vm => vm.label, - title: vm => vm.label, - style: vm => `max-width: ${vm.thumbnailWidth}px; max-height: ${vm.thumbnailHeight}px;` - }), - t.time(vm.date + " " + vm.time) - ]), - t.if(vm => vm.error, t.createTemplate((t, vm) => t.p({className: "error"}, vm.error))) - ]) - ); + const heightRatioPercent = (vm.thumbnailHeight / vm.thumbnailWidth) * 100; + return renderMessage(t, vm, [ + t.a({href: vm.lightboxUrl, className: "picture", style: `max-width: ${vm.thumbnailWidth}px`}, [ + t.div({className: "spacer", style: `padding-top: ${heightRatioPercent}%;`}), + 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.if(vm => vm.error, t.createTemplate((t, vm) => t.p({className: "error"}, vm.error))) + ]); } } From 4d08e415bdb587a8ac07555d46083fd053dcbe1b Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Sat, 31 Oct 2020 00:25:42 +0100 Subject: [PATCH 15/20] fallback for IE11 --- src/platform/web/ui/session/room/LightboxView.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/platform/web/ui/session/room/LightboxView.js b/src/platform/web/ui/session/room/LightboxView.js index 27151617..16d5666f 100644 --- a/src/platform/web/ui/session/room/LightboxView.js +++ b/src/platform/web/ui/session/room/LightboxView.js @@ -59,7 +59,7 @@ export class LightboxView extends TemplateView { } closeOnEscKey(evt) { - if (evt.key === "Escape") { + if (evt.key === "Escape" || evt.key === "Esc") { this.value.close(); } } From 57da68fb1ed56a669fa8a4ba86e3bcc07f6b7a07 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Sat, 31 Oct 2020 00:25:51 +0100 Subject: [PATCH 16/20] padding for lightbox --- 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 3e42fc9c..ff841a61 100644 --- a/src/platform/web/ui/css/themes/element/theme.css +++ b/src/platform/web/ui/css/themes/element/theme.css @@ -678,6 +678,7 @@ button.link { "content details" 1fr / 1fr auto; color: white; + padding: 4px; } @media (max-aspect-ratio: 1/1) { From fbbdaf7dfafc44d8bcd5f1e126c2febf54f04e28 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Mon, 2 Nov 2020 11:16:19 +0100 Subject: [PATCH 17/20] expose IE11 flag in JS as well --- src/platform/web/Platform.js | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/platform/web/Platform.js b/src/platform/web/Platform.js index fa3451db..87b055cd 100644 --- a/src/platform/web/Platform.js +++ b/src/platform/web/Platform.js @@ -99,6 +99,8 @@ export class Platform { } else { this.request = xhrRequest; } + const isIE11 = !!window.MSInputMethodContext && !!document.documentMode; + this.isIE11 = isIE11; } get updateService() { @@ -116,8 +118,7 @@ export class Platform { } createAndMountRootView(vm) { - const isIE11 = !!window.MSInputMethodContext && !!document.documentMode; - if (isIE11) { + if (this.isIE11) { this._container.className += " legacy"; } window.__hydrogenViewModel = vm; From 7e9e9377426dd7dfc38925f5a1fca4a8ca6d70ad Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Mon, 2 Nov 2020 11:16:35 +0100 Subject: [PATCH 18/20] IE11 doesn't calculate padding percentages based on parent width in grid so do progressive fallback. This won't scale the height of the image tile height, but it will still scale the thumbnail on narrow viewports, leaving a blank space underneath the image. --- src/platform/web/ui/css/themes/element/theme.css | 7 +++++-- .../web/ui/session/room/timeline/ImageView.js | 13 ++++++++++++- 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/src/platform/web/ui/css/themes/element/theme.css b/src/platform/web/ui/css/themes/element/theme.css index ff841a61..8a55e7dd 100644 --- a/src/platform/web/ui/css/themes/element/theme.css +++ b/src/platform/web/ui/css/themes/element/theme.css @@ -543,9 +543,7 @@ ul.Timeline > li.messageStatus .message-container > p { .message-container .picture { display: grid; text-decoration: none; - overflow: hidden; margin-top: 4px; - border-radius: 4px; width: 100%; } @@ -559,6 +557,9 @@ so the timeline doesn't jump when the image loads */ .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; } .message-container .picture > time { @@ -572,7 +573,9 @@ so the timeline doesn't jump when the image loads */ border-radius: 4px; } .message-container .picture > .spacer { + /* TODO: can we implement this with a pseudo element? or perhaps they are not grid items? */ width: 100%; + align-self: start; } .TextMessageView.pending .message-container { diff --git a/src/platform/web/ui/session/room/timeline/ImageView.js b/src/platform/web/ui/session/room/timeline/ImageView.js index 00669a55..eb060e34 100644 --- a/src/platform/web/ui/session/room/timeline/ImageView.js +++ b/src/platform/web/ui/session/room/timeline/ImageView.js @@ -20,9 +20,20 @@ import {renderMessage} from "./common.js"; export class ImageView extends TemplateView { render(t, vm) { const heightRatioPercent = (vm.thumbnailHeight / vm.thumbnailWidth) * 100; + let spacerStyle = `padding-top: ${heightRatioPercent}%;`; + if (vm.platform.isIE11) { + // preserving aspect-ratio in a grid with padding percentages + // does not work in IE11, so we assume people won't use it + // with viewports narrower than 400px where thumbnails will get + // scaled. If they do, the thumbnail will still scale, but + // there will be whitespace underneath the picture + // An alternative would be to use position: absolute but that + // can slow down rendering, and was bleeding through the lightbox. + spacerStyle = `height: ${vm.thumbnailHeight}px`; + } return renderMessage(t, vm, [ t.a({href: vm.lightboxUrl, className: "picture", style: `max-width: ${vm.thumbnailWidth}px`}, [ - t.div({className: "spacer", style: `padding-top: ${heightRatioPercent}%;`}), + t.div({className: "spacer", style: spacerStyle}), t.img({ loading: "lazy", src: vm => vm.thumbnailUrl, From fd28b39e233de8658f038f64c697aa5991875bba Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Mon, 2 Nov 2020 11:29:09 +0100 Subject: [PATCH 19/20] fix spacing between spinner and loading text in lightbox --- src/platform/web/ui/css/themes/element/theme.css | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/platform/web/ui/css/themes/element/theme.css b/src/platform/web/ui/css/themes/element/theme.css index 8a55e7dd..9d1e92d8 100644 --- a/src/platform/web/ui/css/themes/element/theme.css +++ b/src/platform/web/ui/css/themes/element/theme.css @@ -716,6 +716,10 @@ button.link { display: flex; } +.lightbox .loading > :not(:first-child) { + margin-left: 8px; +} + .lightbox .close { display: block; grid-area: close; From f1b557f995bad48dc4443a25ba73f1c8e6c5195a Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Mon, 2 Nov 2020 11:29:22 +0100 Subject: [PATCH 20/20] add comment --- 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 9d1e92d8..a87b3715 100644 --- a/src/platform/web/ui/css/themes/element/theme.css +++ b/src/platform/web/ui/css/themes/element/theme.css @@ -575,6 +575,7 @@ so the timeline doesn't jump when the image loads */ .message-container .picture > .spacer { /* TODO: can we implement this with a pseudo element? or perhaps they are not grid items? */ width: 100%; + /* don't stretch height as it is a spacer, just in case it doesn't match with image height */ align-self: start; }