From ef428809b5d0567572f68f68e1621a0a015a10f3 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Tue, 6 Oct 2020 18:01:56 +0200 Subject: [PATCH 01/56] implement navigation stack --- src/domain/navigation/Navigation.js | 99 +++++++++++++++++++++++++++++ 1 file changed, 99 insertions(+) create mode 100644 src/domain/navigation/Navigation.js diff --git a/src/domain/navigation/Navigation.js b/src/domain/navigation/Navigation.js new file mode 100644 index 00000000..5e482c87 --- /dev/null +++ b/src/domain/navigation/Navigation.js @@ -0,0 +1,99 @@ +/* +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 {ObservableValue} from "../../observable/ObservableValue.js"; + +export class Navigation { + constructor(allowsChild) { + this._allowsChild = allowsChild; + this._path = new Path([], allowsChild); + this._observables = new Map(); + } + + get path() { + return this._path; + } + + applyPath(path) { + this._path = path; + for (const [type, observable] of this._observables) { + // if the value did not change, this won't emit + observable.set(this._path.get(type)?.value); + } + } + + observe(type) { + let observable = this._observables.get(type); + if (!observable) { + observable = new ObservableValue(this._path.get(type)?.value); + this._observables.set(type, observable); + } + return observable; + } + + pathFrom(segments) { + let parent; + let i; + for (i = 0; i < segments.length; i += 1) { + if (!this._allowsChild(parent, segments[i])) { + return new Path(segments.slice(0, i), this._allowsChild); + } + parent = segments[i]; + } + return new Path(segments, this._allowsChild); + } +} + +export class Segment { + constructor(type, value = true) { + this.type = type; + this.value = value; + } +} + +class Path { + constructor(segments = [], allowsChild) { + this._segments = segments; + this._allowsChild = allowsChild; + } + + clone() { + return new Path(this._segments.slice(), this._allowsChild); + } + + with(segment) { + let index = this._segments.length - 1; + do { + if (this._allowsChild(this._segments[index], segment)) { + // pop the elements that didn't allow the new segment as a child + const newSegments = this._segments.slice(0, index + 1); + newSegments.push(segment); + return new Path(newSegments, this._allowsChild); + } + index -= 1; + } while(index >= -1); + // allow -1 as well so we check if the segment is allowed as root + return null; + } + + get(type) { + return this._segments.find(s => s.type === type); + } + + get segments() { + return this._segments; + } +} From b8dcb249ffce44784051450923a5b9a2e9173a76 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Tue, 6 Oct 2020 18:03:12 +0200 Subject: [PATCH 02/56] implement url router --- src/domain/navigation/URLRouter.js | 74 ++++++++++++++++++++++++++++++ src/ui/web/dom/HashObservable.js | 59 ++++++++++++++++++++++++ 2 files changed, 133 insertions(+) create mode 100644 src/domain/navigation/URLRouter.js create mode 100644 src/ui/web/dom/HashObservable.js diff --git a/src/domain/navigation/URLRouter.js b/src/domain/navigation/URLRouter.js new file mode 100644 index 00000000..5dcd9944 --- /dev/null +++ b/src/domain/navigation/URLRouter.js @@ -0,0 +1,74 @@ +/* +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 {Segment} from "./Navigation.js"; + +export class URLRouter { + constructor(pathObservable, navigation) { + this._subscription = null; + this._pathObservable = pathObservable; + this._navigation = navigation; + } + + start() { + this._subscription = this._pathObservable.subscribe(url => { + const segments = this._segmentsFromUrl(url); + const path = this._navigation.pathFrom(segments); + this._navigation.applyPath(path); + }); + } + + stop() { + this._subscription = this._subscription(); + } + + _segmentsFromUrl(path) { + const parts = path.split("/"); + let index = 0; + const segments = []; + while (index < parts.length) { + const type = parts[index]; + if ((index + 1) < parts.length) { + index += 1; + const value = parts[index]; + segments.push(new Segment(type, value)); + } else { + segments.push(new Segment(type)); + } + index += 1; + } + return segments; + } + + urlForSegment(type, value) { + const path = this._navigation.path.with(new Segment(type, value)); + if (path) { + return this.urlForPath(path); + } + } + + urlForPath(path) { + let url = "#"; + for (const {type, value} of path.segments) { + if (typeof value === "boolean") { + url += `/${type}`; + } else { + url += `/${type}/${value}`; + } + } + return url; + } +} diff --git a/src/ui/web/dom/HashObservable.js b/src/ui/web/dom/HashObservable.js new file mode 100644 index 00000000..4239f6ac --- /dev/null +++ b/src/ui/web/dom/HashObservable.js @@ -0,0 +1,59 @@ +/* +Copyright 2020 Bruno Windels + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import {BaseObservableValue} from "../../../observable/ObservableValue.js"; + +export class HashObservable extends BaseObservableValue { + constructor() { + super(); + this._boundOnHashChange = null; + this._expectSetEcho = false; + } + + _onHashChange() { + if (this._expectSetEcho) { + this._expectSetEcho = false; + return; + } + this.emit(this.get()); + } + + get() { + const hash = document.location.hash; + if (hash.length) { + // trim '#' + return hash.substr(1); + } + return hash; + } + + set(hash) { + if (this._boundOnHashChange) { + this._expectSetEcho = true; + } + document.location.hash = hash; + } + + onSubscribeFirst() { + this._boundOnHashChange = this._onHashChange.bind(this); + window.addEventListener('hashchange', this._boundOnHashChange); + } + + onUnsubscribeLast() { + window.removeEventListener('hashchange', this._boundOnHashChange); + this._boundOnHashChange = null; + } +} From 514095da7ad4f8144b2bac784604a2f0d0d448b1 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Tue, 6 Oct 2020 18:04:34 +0200 Subject: [PATCH 03/56] move waitFor and get to BaseObservableValue --- src/observable/ObservableValue.js | 19 +++++++++++-------- src/ui/web/dom/OnlineStatus.js | 2 +- 2 files changed, 12 insertions(+), 9 deletions(-) diff --git a/src/observable/ObservableValue.js b/src/observable/ObservableValue.js index f1786dbd..3fbfe463 100644 --- a/src/observable/ObservableValue.js +++ b/src/observable/ObservableValue.js @@ -25,6 +25,17 @@ export class BaseObservableValue extends BaseObservable { } } + get() { + throw new Error("unimplemented"); + } + + waitFor(predicate) { + if (predicate(this.get())) { + return new ResolvedWaitForHandle(Promise.resolve(this.get())); + } else { + return new WaitForHandle(this, predicate); + } + } } class WaitForHandle { @@ -81,14 +92,6 @@ export class ObservableValue extends BaseObservableValue { this.emit(this._value); } } - - waitFor(predicate) { - if (predicate(this.get())) { - return new ResolvedWaitForHandle(Promise.resolve(this.get())); - } else { - return new WaitForHandle(this, predicate); - } - } } export function tests() { diff --git a/src/ui/web/dom/OnlineStatus.js b/src/ui/web/dom/OnlineStatus.js index e1d7843a..588c0815 100644 --- a/src/ui/web/dom/OnlineStatus.js +++ b/src/ui/web/dom/OnlineStatus.js @@ -31,7 +31,7 @@ export class OnlineStatus extends BaseObservableValue { this.emit(true); } - get value() { + get() { return navigator.onLine; } From 5ab947e7de4420a65193c185d6d78c7ae6c96bd5 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Tue, 6 Oct 2020 18:05:02 +0200 Subject: [PATCH 04/56] pass urlRouter and navigation as view model options --- src/domain/ViewModel.js | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/src/domain/ViewModel.js b/src/domain/ViewModel.js index 7f973ad7..b1192424 100644 --- a/src/domain/ViewModel.js +++ b/src/domain/ViewModel.js @@ -22,15 +22,16 @@ import {EventEmitter} from "../utils/EventEmitter.js"; import {Disposables} from "../utils/Disposables.js"; export class ViewModel extends EventEmitter { - constructor({clock, emitChange} = {}) { + constructor(options = {}) { super(); this.disposables = null; this._isDisposed = false; - this._options = {clock, emitChange}; + this._options = options; } childOptions(explicitOptions) { - return Object.assign({}, this._options, explicitOptions); + const {navigation, urlRouter, clock} = this._options; + return Object.assign({navigation, urlRouter, clock}, explicitOptions); } track(disposable) { @@ -90,4 +91,12 @@ export class ViewModel extends EventEmitter { get clock() { return this._options.clock; } + + get urlRouter() { + return this._options.urlRouter; + } + + get navigation() { + return this._options.navigation; + } } From 221b94adbf21af2ece940d9829a4bbe403f07cf9 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Tue, 6 Oct 2020 18:05:38 +0200 Subject: [PATCH 05/56] room tile should be passed child options --- src/domain/session/leftpanel/LeftPanelViewModel.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/domain/session/leftpanel/LeftPanelViewModel.js b/src/domain/session/leftpanel/LeftPanelViewModel.js index 50864b4c..b90bacf6 100644 --- a/src/domain/session/leftpanel/LeftPanelViewModel.js +++ b/src/domain/session/leftpanel/LeftPanelViewModel.js @@ -25,11 +25,11 @@ export class LeftPanelViewModel extends ViewModel { super(options); const {rooms, openRoom} = options; const roomTileVMs = rooms.mapValues((room, emitChange) => { - return new RoomTileViewModel({ + return new RoomTileViewModel(this.childOptions({ room, emitChange, emitOpen: openRoom - }); + })); }); this._roomListFilterMap = new ApplyMap(roomTileVMs); this._roomList = this._roomListFilterMap.sortValues((a, b) => a.compare(b)); From b373a177ec816c247b15d0dcf697bee27b6d7c28 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Tue, 6 Oct 2020 18:05:52 +0200 Subject: [PATCH 06/56] create room url in room tile --- src/domain/session/leftpanel/RoomTileViewModel.js | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/domain/session/leftpanel/RoomTileViewModel.js b/src/domain/session/leftpanel/RoomTileViewModel.js index 86f832c4..2b25294b 100644 --- a/src/domain/session/leftpanel/RoomTileViewModel.js +++ b/src/domain/session/leftpanel/RoomTileViewModel.js @@ -31,6 +31,7 @@ export class RoomTileViewModel extends ViewModel { this._isOpen = false; this._wasUnreadWhenOpening = false; this._hidden = false; + this._url = this.urlRouter.urlForSegment("room", this._room.id); } get hidden() { @@ -61,6 +62,10 @@ export class RoomTileViewModel extends ViewModel { } } + get url() { + return this._url; + } + compare(other) { /* put unread rooms first From 404de53c756e97fa60ce72e2e2da1d87a3a85443 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Tue, 6 Oct 2020 18:06:11 +0200 Subject: [PATCH 07/56] basic url router and navigation bootstrapping --- src/main.js | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/src/main.js b/src/main.js index 2e7c80df..05767a34 100644 --- a/src/main.js +++ b/src/main.js @@ -22,8 +22,11 @@ import {SessionContainer} from "./matrix/SessionContainer.js"; import {StorageFactory} from "./matrix/storage/idb/StorageFactory.js"; import {SessionInfoStorage} from "./matrix/sessioninfo/localstorage/SessionInfoStorage.js"; import {BrawlViewModel} from "./domain/BrawlViewModel.js"; +import {Navigation} from "./domain/navigation/Navigation.js"; +import {URLRouter} from "./domain/navigation/URLRouter.js"; import {BrawlView} from "./ui/web/BrawlView.js"; import {Clock} from "./ui/web/dom/Clock.js"; +import {HashObservable} from "./ui/web/dom/HashObservable.js"; import {OnlineStatus} from "./ui/web/dom/OnlineStatus.js"; import {CryptoDriver} from "./ui/web/dom/CryptoDriver.js"; import {WorkerPool} from "./utils/WorkerPool.js"; @@ -115,6 +118,21 @@ export async function main(container, paths, legacyExtras) { workerPromise = loadOlmWorker(paths); } + const navigation = new Navigation(function allowsChild(parent, child) { + const {type} = child; + switch (parent?.type) { + case undefined: + // allowed root segments + return type === "login" || type === "session"; + case "session": + return type === "room" || type === "settings"; + default: + return false; + } + }); + const urlRouter = new URLRouter(new HashObservable(), navigation); + urlRouter.start(); + const vm = new BrawlViewModel({ createSessionContainer: () => { return new SessionContainer({ @@ -132,6 +150,8 @@ export async function main(container, paths, legacyExtras) { sessionInfoStorage, storageFactory, clock, + urlRouter, + navigation }); window.__brawlViewModel = vm; await vm.load(); From 01ff806b86afc665698f6eb1c3ed645b6c0441c0 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Wed, 7 Oct 2020 09:40:51 +0200 Subject: [PATCH 08/56] WIP --- src/domain/BrawlViewModel.js | 13 +++++++++++++ src/domain/navigation/URLRouter.js | 13 +++++++++---- 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/src/domain/BrawlViewModel.js b/src/domain/BrawlViewModel.js index 4c3a97bb..2ff1abae 100644 --- a/src/domain/BrawlViewModel.js +++ b/src/domain/BrawlViewModel.js @@ -34,6 +34,19 @@ export class BrawlViewModel extends ViewModel { this._sessionContainer = null; this._sessionCallback = this._sessionCallback.bind(this); + + this.track(this.navigation.observe("login").subscribe(value => { + if (value) { + this._showLogin(); + } + })); + this.track(this.navigation.observe("session").subscribe(value => { + if (value === true) { + this._showPicker(); + } else if (value) { + alert("showing session " + value); + } + })); } async load() { diff --git a/src/domain/navigation/URLRouter.js b/src/domain/navigation/URLRouter.js index 5dcd9944..c82b814b 100644 --- a/src/domain/navigation/URLRouter.js +++ b/src/domain/navigation/URLRouter.js @@ -25,10 +25,15 @@ export class URLRouter { start() { this._subscription = this._pathObservable.subscribe(url => { - const segments = this._segmentsFromUrl(url); - const path = this._navigation.pathFrom(segments); - this._navigation.applyPath(path); + this._applyUrl(url); }); + this._applyUrl(this._pathObservable.get()); + } + + _applyUrl(url) { + const segments = this._segmentsFromUrl(url); + const path = this._navigation.pathFrom(segments); + this._navigation.applyPath(path); } stop() { @@ -36,7 +41,7 @@ export class URLRouter { } _segmentsFromUrl(path) { - const parts = path.split("/"); + const parts = path.split("/").filter(p => !!p); let index = 0; const segments = []; while (index < parts.length) { From 4fe971775c7368ec28a0f579f8696a74d4b24e9a Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 8 Oct 2020 18:22:36 +0200 Subject: [PATCH 09/56] more url router work, differentiate between path and url rename hashobservable to history --- src/domain/navigation/URLRouter.js | 23 +++++++----- src/main.js | 4 +- .../web/dom/{HashObservable.js => History.js} | 37 +++++++++++-------- 3 files changed, 37 insertions(+), 27 deletions(-) rename src/ui/web/dom/{HashObservable.js => History.js} (69%) diff --git a/src/domain/navigation/URLRouter.js b/src/domain/navigation/URLRouter.js index c82b814b..2b7e8513 100644 --- a/src/domain/navigation/URLRouter.js +++ b/src/domain/navigation/URLRouter.js @@ -17,17 +17,17 @@ limitations under the License. import {Segment} from "./Navigation.js"; export class URLRouter { - constructor(pathObservable, navigation) { + constructor(history, navigation) { this._subscription = null; - this._pathObservable = pathObservable; + this._history = history; this._navigation = navigation; } start() { - this._subscription = this._pathObservable.subscribe(url => { + this._subscription = this._history.subscribe(url => { this._applyUrl(url); }); - this._applyUrl(this._pathObservable.get()); + this._applyUrl(this._history.get()); } _applyUrl(url) { @@ -40,7 +40,8 @@ export class URLRouter { this._subscription = this._subscription(); } - _segmentsFromUrl(path) { + _segmentsFromUrl(url) { + const path = this._history.urlAsPath(url); const parts = path.split("/").filter(p => !!p); let index = 0; const segments = []; @@ -58,6 +59,10 @@ export class URLRouter { return segments; } + replaceUrl(url) { + this._history.replaceUrl(url); + } + urlForSegment(type, value) { const path = this._navigation.path.with(new Segment(type, value)); if (path) { @@ -66,14 +71,14 @@ export class URLRouter { } urlForPath(path) { - let url = "#"; + let urlPath = ""; for (const {type, value} of path.segments) { if (typeof value === "boolean") { - url += `/${type}`; + urlPath += `/${type}`; } else { - url += `/${type}/${value}`; + urlPath += `/${type}/${value}`; } } - return url; + return this._history.pathAsUrl(urlPath); } } diff --git a/src/main.js b/src/main.js index 05767a34..2ff9b4ad 100644 --- a/src/main.js +++ b/src/main.js @@ -26,7 +26,7 @@ import {Navigation} from "./domain/navigation/Navigation.js"; import {URLRouter} from "./domain/navigation/URLRouter.js"; import {BrawlView} from "./ui/web/BrawlView.js"; import {Clock} from "./ui/web/dom/Clock.js"; -import {HashObservable} from "./ui/web/dom/HashObservable.js"; +import {History} from "./ui/web/dom/History.js"; import {OnlineStatus} from "./ui/web/dom/OnlineStatus.js"; import {CryptoDriver} from "./ui/web/dom/CryptoDriver.js"; import {WorkerPool} from "./utils/WorkerPool.js"; @@ -130,7 +130,7 @@ export async function main(container, paths, legacyExtras) { return false; } }); - const urlRouter = new URLRouter(new HashObservable(), navigation); + const urlRouter = new URLRouter(new History(), navigation); urlRouter.start(); const vm = new BrawlViewModel({ diff --git a/src/ui/web/dom/HashObservable.js b/src/ui/web/dom/History.js similarity index 69% rename from src/ui/web/dom/HashObservable.js rename to src/ui/web/dom/History.js index 4239f6ac..08bd6ed0 100644 --- a/src/ui/web/dom/HashObservable.js +++ b/src/ui/web/dom/History.js @@ -16,35 +16,40 @@ limitations under the License. import {BaseObservableValue} from "../../../observable/ObservableValue.js"; -export class HashObservable extends BaseObservableValue { +export class History extends BaseObservableValue { constructor() { super(); this._boundOnHashChange = null; - this._expectSetEcho = false; } _onHashChange() { - if (this._expectSetEcho) { - this._expectSetEcho = false; - return; - } this.emit(this.get()); } get() { - const hash = document.location.hash; - if (hash.length) { - // trim '#' - return hash.substr(1); - } - return hash; + return document.location.hash; } - set(hash) { - if (this._boundOnHashChange) { - this._expectSetEcho = true; + replaceUrl(url) { + window.history.replaceState(null, null, url); + // replaceState does not cause hashchange + this.emit(url); + } + + pushUrl(url) { + document.location.hash = this.urlAsPath(url); + } + + urlAsPath(url) { + if (url.startsWith("#")) { + return url.substr(1); + } else { + return url; } - document.location.hash = hash; + } + + pathAsUrl(path) { + return `#${path}`; } onSubscribeFirst() { From 087aff4ef423b0b19d5aa1c77f4eda91a7e275d3 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 8 Oct 2020 18:23:08 +0200 Subject: [PATCH 10/56] set to true if undefined passed in as well --- src/domain/navigation/Navigation.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/domain/navigation/Navigation.js b/src/domain/navigation/Navigation.js index 5e482c87..6520114c 100644 --- a/src/domain/navigation/Navigation.js +++ b/src/domain/navigation/Navigation.js @@ -58,9 +58,9 @@ export class Navigation { } export class Segment { - constructor(type, value = true) { + constructor(type, value) { this.type = type; - this.value = value; + this.value = value === undefined ? true : value; } } From 00bd4364f0eef15d8a6d1bffccb0140f4151131a Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 8 Oct 2020 18:23:37 +0200 Subject: [PATCH 11/56] early draft of loading session via url scheme --- src/domain/BrawlViewModel.js | 70 ++++++++++++++----- .../localstorage/SessionInfoStorage.js | 5 -- 2 files changed, 53 insertions(+), 22 deletions(-) diff --git a/src/domain/BrawlViewModel.js b/src/domain/BrawlViewModel.js index 2ff1abae..71aebb44 100644 --- a/src/domain/BrawlViewModel.js +++ b/src/domain/BrawlViewModel.js @@ -15,6 +15,7 @@ limitations under the License. */ import {SessionViewModel} from "./session/SessionViewModel.js"; +import {SessionLoadViewModel} from "./SessionLoadViewModel.js"; import {LoginViewModel} from "./LoginViewModel.js"; import {SessionPickerViewModel} from "./SessionPickerViewModel.js"; import {ViewModel} from "./ViewModel.js"; @@ -35,25 +36,41 @@ export class BrawlViewModel extends ViewModel { this._sessionContainer = null; this._sessionCallback = this._sessionCallback.bind(this); - this.track(this.navigation.observe("login").subscribe(value => { - if (value) { - this._showLogin(); - } - })); - this.track(this.navigation.observe("session").subscribe(value => { - if (value === true) { - this._showPicker(); - } else if (value) { - alert("showing session " + value); - } - })); } async load() { - if (await this._sessionInfoStorage.hasAnySession()) { - this._showPicker(); - } else { + this.track(this.navigation.observe("login").subscribe(shown => { + if (shown) { + this._showLogin(); + } + })); + this.track(this.navigation.observe("session").subscribe(sessionId => { + if (sessionId === true) { + this._showPicker(); + } else if (sessionId) { + this._showSessionLoader(sessionId); + } + })); + + const isLogin = this.navigation.observe("login").get(); + const sessionId = this.navigation.observe("session").get(); + if (isLogin) { this._showLogin(); + } else if (sessionId === true) { + this._showPicker(); + } else if (sessionId) { + this._showSessionLoader(sessionId); + } else { + const sessionInfos = await this._sessionInfoStorage.getAll(); + let url; + if (sessionInfos.length === 0) { + url = this.urlRouter.urlForSegment("login"); + } else if (sessionInfos.length === 1) { + url = this.urlRouter.urlForSegment("session", sessionInfos[0].id); + } else { + url = this.urlRouter.urlForSegment("session"); + } + this.urlRouter.replaceUrl(url); } } @@ -97,8 +114,21 @@ export class BrawlViewModel extends ViewModel { createSessionContainer: this._createSessionContainer, sessionCallback: this._sessionCallback, }); - }) + }); + } + _showSessionLoader(sessionId) { + this._setSection(() => { + this._sessionLoadViewModel = new SessionLoadViewModel({ + createAndStartSessionContainer: () => { + const sessionContainer = this._createSessionContainer(); + sessionContainer.startWithExistingSession(sessionId); + return sessionContainer; + }, + sessionCallback: sessionContainer => this._sessionCallback(sessionContainer) + }); + this._sessionLoadViewModel.start(); + }); } get activeSection() { @@ -108,8 +138,12 @@ export class BrawlViewModel extends ViewModel { return "session"; } else if (this._loginViewModel) { return "login"; - } else { + } else if (this._sessionPickerViewModel) { return "picker"; + } else if (this._sessionLoadViewModel) { + return "loading"; + } else { + return "redirecting"; } } @@ -119,6 +153,7 @@ export class BrawlViewModel extends ViewModel { this._sessionViewModel = null; this._loginViewModel = null; this._sessionPickerViewModel = null; + this._sessionLoadViewModel = null; if (this._sessionContainer) { this._sessionContainer.stop(); @@ -133,4 +168,5 @@ export class BrawlViewModel extends ViewModel { get sessionViewModel() { return this._sessionViewModel; } get loginViewModel() { return this._loginViewModel; } get sessionPickerViewModel() { return this._sessionPickerViewModel; } + get sessionLoadViewModel() { return this._sessionLoadViewModel; } } diff --git a/src/matrix/sessioninfo/localstorage/SessionInfoStorage.js b/src/matrix/sessioninfo/localstorage/SessionInfoStorage.js index ba59a6f3..ce795916 100644 --- a/src/matrix/sessioninfo/localstorage/SessionInfoStorage.js +++ b/src/matrix/sessioninfo/localstorage/SessionInfoStorage.js @@ -30,11 +30,6 @@ export class SessionInfoStorage { return Promise.resolve([]); } - async hasAnySession() { - const all = await this.getAll(); - return all && all.length > 0; - } - async updateLastUsed(id, timestamp) { const sessions = await this.getAll(); if (sessions) { From 0d54f886703302856d38b137cf25db67fdf91293 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 8 Oct 2020 18:23:53 +0200 Subject: [PATCH 12/56] early draft of showing load progress of a session loaded through url --- src/ui/web/BrawlView.js | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/ui/web/BrawlView.js b/src/ui/web/BrawlView.js index ec84c716..21ae539c 100644 --- a/src/ui/web/BrawlView.js +++ b/src/ui/web/BrawlView.js @@ -16,8 +16,10 @@ limitations under the License. import {SessionView} from "./session/SessionView.js"; import {LoginView} from "./login/LoginView.js"; +import {SessionLoadView} from "./login/SessionLoadView.js"; import {SessionPickerView} from "./login/SessionPickerView.js"; import {TemplateView} from "./general/TemplateView.js"; +import {StaticView} from "./general/StaticView.js"; import {SwitchView} from "./general/SwitchView.js"; export class BrawlView { @@ -38,6 +40,10 @@ export class BrawlView { return new LoginView(this._vm.loginViewModel); case "picker": return new SessionPickerView(this._vm.sessionPickerViewModel); + case "redirecting": + return new StaticView(t => t.p("Redirecting...")); + case "loading": + return new SessionLoadView(this._vm.sessionLoadViewModel); default: throw new Error(`Unknown section: ${this._vm.activeSection}`); } From 696e7856f8ad2d597bca9b2139a3f2db928a5abd Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Fri, 9 Oct 2020 09:56:01 +0200 Subject: [PATCH 13/56] some cleanup --- src/domain/BrawlViewModel.js | 3 ++- src/domain/navigation/URLRouter.js | 3 ++- src/domain/navigation/index.js | 32 ++++++++++++++++++++++++++++++ src/main.js | 17 +++------------- 4 files changed, 39 insertions(+), 16 deletions(-) create mode 100644 src/domain/navigation/index.js diff --git a/src/domain/BrawlViewModel.js b/src/domain/BrawlViewModel.js index 71aebb44..dde9bc44 100644 --- a/src/domain/BrawlViewModel.js +++ b/src/domain/BrawlViewModel.js @@ -39,6 +39,7 @@ export class BrawlViewModel extends ViewModel { } async load() { + // TODO: deduplicate code here this.track(this.navigation.observe("login").subscribe(shown => { if (shown) { this._showLogin(); @@ -125,7 +126,7 @@ export class BrawlViewModel extends ViewModel { sessionContainer.startWithExistingSession(sessionId); return sessionContainer; }, - sessionCallback: sessionContainer => this._sessionCallback(sessionContainer) + sessionCallback: this._sessionCallback }); this._sessionLoadViewModel.start(); }); diff --git a/src/domain/navigation/URLRouter.js b/src/domain/navigation/URLRouter.js index 2b7e8513..d0f66fe6 100644 --- a/src/domain/navigation/URLRouter.js +++ b/src/domain/navigation/URLRouter.js @@ -23,7 +23,7 @@ export class URLRouter { this._navigation = navigation; } - start() { + attach() { this._subscription = this._history.subscribe(url => { this._applyUrl(url); }); @@ -60,6 +60,7 @@ export class URLRouter { } replaceUrl(url) { + // TODO: we don't want this to always to trigger an update this._history.replaceUrl(url); } diff --git a/src/domain/navigation/index.js b/src/domain/navigation/index.js new file mode 100644 index 00000000..b4b9890e --- /dev/null +++ b/src/domain/navigation/index.js @@ -0,0 +1,32 @@ +/* +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 {Navigation} from "./Navigation.js"; + +export function createNavigation() { + return new Navigation(function allowsChild(parent, child) { + const {type} = child; + switch (parent?.type) { + case undefined: + // allowed root segments + return type === "login" || type === "session"; + case "session": + return type === "room" || type === "rooms" || type === "settings"; + default: + return false; + } + }); +} diff --git a/src/main.js b/src/main.js index 2ff9b4ad..13bab3ef 100644 --- a/src/main.js +++ b/src/main.js @@ -22,7 +22,7 @@ import {SessionContainer} from "./matrix/SessionContainer.js"; import {StorageFactory} from "./matrix/storage/idb/StorageFactory.js"; import {SessionInfoStorage} from "./matrix/sessioninfo/localstorage/SessionInfoStorage.js"; import {BrawlViewModel} from "./domain/BrawlViewModel.js"; -import {Navigation} from "./domain/navigation/Navigation.js"; +import {createNavigation} from "./domain/navigation/index.js"; import {URLRouter} from "./domain/navigation/URLRouter.js"; import {BrawlView} from "./ui/web/BrawlView.js"; import {Clock} from "./ui/web/dom/Clock.js"; @@ -118,20 +118,9 @@ export async function main(container, paths, legacyExtras) { workerPromise = loadOlmWorker(paths); } - const navigation = new Navigation(function allowsChild(parent, child) { - const {type} = child; - switch (parent?.type) { - case undefined: - // allowed root segments - return type === "login" || type === "session"; - case "session": - return type === "room" || type === "settings"; - default: - return false; - } - }); + const navigation = createNavigation(); const urlRouter = new URLRouter(new History(), navigation); - urlRouter.start(); + urlRouter.attach(); const vm = new BrawlViewModel({ createSessionContainer: () => { From c9bb18b87ec1cbfd7e6da42b554bbf1fbbfc72aa Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Fri, 9 Oct 2020 09:56:13 +0200 Subject: [PATCH 14/56] make loading screen a bit prettier by wrapping it in a view with common pre-session chrome this renames the existing SessionLoadView to SessionLoadStatusView so we can call the wrapper the former. --- src/ui/web/css/login.css | 8 +++--- src/ui/web/login/LoginView.js | 4 +-- src/ui/web/login/SessionLoadStatusView.js | 30 +++++++++++++++++++++++ src/ui/web/login/SessionLoadView.js | 15 +++++++----- src/ui/web/login/SessionPickerView.js | 4 +-- src/ui/web/view-gallery.html | 11 ++++++++- 6 files changed, 57 insertions(+), 15 deletions(-) create mode 100644 src/ui/web/login/SessionLoadStatusView.js diff --git a/src/ui/web/css/login.css b/src/ui/web/css/login.css index 192ccc13..db67e141 100644 --- a/src/ui/web/css/login.css +++ b/src/ui/web/css/login.css @@ -52,19 +52,19 @@ limitations under the License. padding: 0.4em; } -.SessionLoadView { +.SessionLoadStatusView { display: flex; } -.SessionLoadView > :not(:first-child) { +.SessionLoadStatusView > :not(:first-child) { margin-left: 12px; } -.SessionLoadView p { +.SessionLoadStatusView p { flex: 1; margin: 0; } -.SessionLoadView .spinner { +.SessionLoadStatusView .spinner { --size: 20px; } diff --git a/src/ui/web/login/LoginView.js b/src/ui/web/login/LoginView.js index ef2afbb6..00d52da8 100644 --- a/src/ui/web/login/LoginView.js +++ b/src/ui/web/login/LoginView.js @@ -16,7 +16,7 @@ limitations under the License. import {TemplateView} from "../general/TemplateView.js"; import {hydrogenGithubLink} from "./common.js"; -import {SessionLoadView} from "./SessionLoadView.js"; +import {SessionLoadStatusView} from "./SessionLoadStatusView.js"; export class LoginView extends TemplateView { render(t, vm) { @@ -49,7 +49,7 @@ export class LoginView extends TemplateView { t.div({className: "form-row"}, [t.label({for: "username"}, vm.i18n`Username`), username]), t.div({className: "form-row"}, [t.label({for: "password"}, vm.i18n`Password`), password]), t.div({className: "form-row"}, [t.label({for: "homeserver"}, vm.i18n`Homeserver`), homeserver]), - t.mapView(vm => vm.loadViewModel, loadViewModel => loadViewModel ? new SessionLoadView(loadViewModel) : null), + t.mapView(vm => vm.loadViewModel, loadViewModel => loadViewModel ? new SessionLoadStatusView(loadViewModel) : null), t.div({className: "button-row"}, [ t.button({ className: "styled secondary", diff --git a/src/ui/web/login/SessionLoadStatusView.js b/src/ui/web/login/SessionLoadStatusView.js new file mode 100644 index 00000000..888e46b4 --- /dev/null +++ b/src/ui/web/login/SessionLoadStatusView.js @@ -0,0 +1,30 @@ +/* +Copyright 2020 Bruno Windels + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import {TemplateView} from "../general/TemplateView.js"; +import {spinner} from "../common.js"; + +/** a view used both in the login view and the loading screen +to show the current state of loading the session. +Just a spinner and a label, meant to be used as a paragraph */ +export class SessionLoadStatusView extends TemplateView { + render(t) { + return t.div({className: "SessionLoadStatusView"}, [ + spinner(t, {hiddenWithLayout: vm => !vm.loading}), + t.p(vm => vm.loadLabel) + ]); + } +} diff --git a/src/ui/web/login/SessionLoadView.js b/src/ui/web/login/SessionLoadView.js index 637c204c..30489335 100644 --- a/src/ui/web/login/SessionLoadView.js +++ b/src/ui/web/login/SessionLoadView.js @@ -1,5 +1,5 @@ /* -Copyright 2020 Bruno Windels +Copyright 2020 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -15,13 +15,16 @@ limitations under the License. */ import {TemplateView} from "../general/TemplateView.js"; -import {spinner} from "../common.js"; +import {SessionLoadStatusView} from "./SessionLoadStatusView.js"; export class SessionLoadView extends TemplateView { - render(t) { - return t.div({className: "SessionLoadView"}, [ - spinner(t, {hiddenWithLayout: vm => !vm.loading}), - t.p(vm => vm.loadLabel) + render(t, vm) { + return t.div({className: "PreSessionScreen"}, [ + t.div({className: "logo"}), + t.div({className: "SessionLoadView"}, [ + t.h1(vm.i18n`Loading…`), + t.view(new SessionLoadStatusView(vm)) + ]) ]); } } diff --git a/src/ui/web/login/SessionPickerView.js b/src/ui/web/login/SessionPickerView.js index 8b051dcc..420a151f 100644 --- a/src/ui/web/login/SessionPickerView.js +++ b/src/ui/web/login/SessionPickerView.js @@ -17,7 +17,7 @@ limitations under the License. import {ListView} from "../general/ListView.js"; import {TemplateView} from "../general/TemplateView.js"; import {hydrogenGithubLink} from "./common.js"; -import {SessionLoadView} from "./SessionLoadView.js"; +import {SessionLoadStatusView} from "./SessionLoadStatusView.js"; function selectFileAsText(mimeType) { const input = document.createElement("input"); @@ -117,7 +117,7 @@ export class SessionPickerView extends TemplateView { onClick: () => vm.cancel() }, vm.i18n`Sign In`) ]), - t.if(vm => vm.loadViewModel, vm => new SessionLoadView(vm.loadViewModel)), + t.if(vm => vm.loadViewModel, vm => new SessionLoadStatusView(vm.loadViewModel)), t.p(hydrogenGithubLink(t)) ]) ]); diff --git a/src/ui/web/view-gallery.html b/src/ui/web/view-gallery.html index ecf328db..625a8b39 100644 --- a/src/ui/web/view-gallery.html +++ b/src/ui/web/view-gallery.html @@ -63,6 +63,15 @@ })); document.getElementById("login-loading").appendChild(view.mount()); - +

Session Loading

+
+ From 41c1c9a6c3370d30ce3d5189b94350795a7cd9d0 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Fri, 9 Oct 2020 09:59:59 +0200 Subject: [PATCH 15/56] rename Brawl -> Root one of the last occurrences of brawl --- src/domain/{BrawlViewModel.js => RootViewModel.js} | 2 +- src/main.js | 8 ++++---- src/ui/web/{BrawlView.js => RootView.js} | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) rename src/domain/{BrawlViewModel.js => RootViewModel.js} (99%) rename src/ui/web/{BrawlView.js => RootView.js} (99%) diff --git a/src/domain/BrawlViewModel.js b/src/domain/RootViewModel.js similarity index 99% rename from src/domain/BrawlViewModel.js rename to src/domain/RootViewModel.js index dde9bc44..94a71292 100644 --- a/src/domain/BrawlViewModel.js +++ b/src/domain/RootViewModel.js @@ -20,7 +20,7 @@ import {LoginViewModel} from "./LoginViewModel.js"; import {SessionPickerViewModel} from "./SessionPickerViewModel.js"; import {ViewModel} from "./ViewModel.js"; -export class BrawlViewModel extends ViewModel { +export class RootViewModel extends ViewModel { constructor(options) { super(options); const {createSessionContainer, sessionInfoStorage, storageFactory} = options; diff --git a/src/main.js b/src/main.js index 13bab3ef..6b302c34 100644 --- a/src/main.js +++ b/src/main.js @@ -21,10 +21,10 @@ import {xhrRequest} from "./matrix/net/request/xhr.js"; import {SessionContainer} from "./matrix/SessionContainer.js"; import {StorageFactory} from "./matrix/storage/idb/StorageFactory.js"; import {SessionInfoStorage} from "./matrix/sessioninfo/localstorage/SessionInfoStorage.js"; -import {BrawlViewModel} from "./domain/BrawlViewModel.js"; +import {RootViewModel} from "./domain/RootViewModel.js"; import {createNavigation} from "./domain/navigation/index.js"; import {URLRouter} from "./domain/navigation/URLRouter.js"; -import {BrawlView} from "./ui/web/BrawlView.js"; +import {RootView} from "./ui/web/RootView.js"; import {Clock} from "./ui/web/dom/Clock.js"; import {History} from "./ui/web/dom/History.js"; import {OnlineStatus} from "./ui/web/dom/OnlineStatus.js"; @@ -122,7 +122,7 @@ export async function main(container, paths, legacyExtras) { const urlRouter = new URLRouter(new History(), navigation); urlRouter.attach(); - const vm = new BrawlViewModel({ + const vm = new RootViewModel({ createSessionContainer: () => { return new SessionContainer({ random: Math.random, @@ -145,7 +145,7 @@ export async function main(container, paths, legacyExtras) { window.__brawlViewModel = vm; await vm.load(); // TODO: replace with platform.createAndMountRootView(vm, container); - const view = new BrawlView(vm); + const view = new RootView(vm); container.appendChild(view.mount()); } catch(err) { console.error(`${err.message}:\n${err.stack}`); diff --git a/src/ui/web/BrawlView.js b/src/ui/web/RootView.js similarity index 99% rename from src/ui/web/BrawlView.js rename to src/ui/web/RootView.js index 21ae539c..d7f0be47 100644 --- a/src/ui/web/BrawlView.js +++ b/src/ui/web/RootView.js @@ -22,7 +22,7 @@ import {TemplateView} from "./general/TemplateView.js"; import {StaticView} from "./general/StaticView.js"; import {SwitchView} from "./general/SwitchView.js"; -export class BrawlView { +export class RootView { constructor(vm) { this._vm = vm; this._switcher = null; From 4ae622bdd3389ebfbab59417c7d2922ff7807fcc Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Fri, 9 Oct 2020 16:58:53 +0200 Subject: [PATCH 16/56] make pushUrl silent again --- src/ui/web/dom/History.js | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/src/ui/web/dom/History.js b/src/ui/web/dom/History.js index 08bd6ed0..797f108f 100644 --- a/src/ui/web/dom/History.js +++ b/src/ui/web/dom/History.js @@ -20,9 +20,14 @@ export class History extends BaseObservableValue { constructor() { super(); this._boundOnHashChange = null; + this._expectSetEcho = false; } _onHashChange() { + if (this._expectSetEcho) { + this._expectSetEcho = false; + return; + } this.emit(this.get()); } @@ -37,7 +42,19 @@ export class History extends BaseObservableValue { } pushUrl(url) { - document.location.hash = this.urlAsPath(url); + const hash = this.urlAsPath(url); + // important to check before we expect an echo + // as setting the hash to it's current value doesn't + // trigger onhashchange + if (hash === document.location.hash) { + return; + } + // this operation is silent, + // so avoid emitting on echo hashchange event + if (this._boundOnHashChange) { + this._expectSetEcho = true; + } + document.location.hash = hash; } urlAsPath(url) { From 35b83b79762a00ed3b6809be951fe282be048f25 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Fri, 9 Oct 2020 16:59:34 +0200 Subject: [PATCH 17/56] make SessionLoadViewModel transfer ownership of container with callback also, deleting the session when navigating away is now done by LoginViewModel --- src/domain/LoginViewModel.js | 42 +++++++++++++++-------------- src/domain/SessionLoadViewModel.js | 43 ++++++++++++++---------------- 2 files changed, 42 insertions(+), 43 deletions(-) diff --git a/src/domain/LoginViewModel.js b/src/domain/LoginViewModel.js index ed6ed67a..11ba3d73 100644 --- a/src/domain/LoginViewModel.js +++ b/src/domain/LoginViewModel.js @@ -20,10 +20,11 @@ import {SessionLoadViewModel} from "./SessionLoadViewModel.js"; export class LoginViewModel extends ViewModel { constructor(options) { super(options); - const {sessionCallback, defaultHomeServer, createSessionContainer} = options; + const {ready, defaultHomeServer, createSessionContainer} = options; this._createSessionContainer = createSessionContainer; - this._sessionCallback = sessionCallback; + this._ready = ready; this._defaultHomeServer = defaultHomeServer; + this._sessionContainer = null; this._loadViewModel = null; this._loadViewModelSubscription = null; } @@ -45,25 +46,19 @@ export class LoginViewModel extends ViewModel { if (this._loadViewModel) { this._loadViewModel.cancel(); } - this._loadViewModel = new SessionLoadViewModel({ + this._loadViewModel = this.track(new SessionLoadViewModel({ createAndStartSessionContainer: () => { - const sessionContainer = this._createSessionContainer(); - sessionContainer.startWithLogin(homeserver, username, password); - return sessionContainer; + this._sessionContainer = this._createSessionContainer(); + this._sessionContainer.startWithLogin(homeserver, username, password); + return this._sessionContainer; }, - sessionCallback: sessionContainer => { - if (sessionContainer) { - // make parent view model move away - this._sessionCallback(sessionContainer); - } else { - // show list of session again - this._loadViewModel = null; - this.emitChange("loadViewModel"); - } + ready: sessionContainer => { + // make sure we don't delete the session in dispose when navigating away + this._sessionContainer = null; + this._ready(sessionContainer); }, - deleteSessionOnCancel: true, homeserver, - }); + })); this._loadViewModel.start(); this.emitChange("loadViewModel"); this._loadViewModelSubscription = this.track(this._loadViewModel.disposableOn("change", () => { @@ -74,9 +69,16 @@ export class LoginViewModel extends ViewModel { })); } - cancel() { - if (!this.isBusy) { - this._sessionCallback(); + get cancelUrl() { + return this.urlRouter.urlForSegment("session"); + } + + dispose() { + super.dispose(); + if (this._sessionContainer) { + // if we move away before we're done with initial sync + // delete the session + this._sessionContainer.deleteSession(); } } } diff --git a/src/domain/SessionLoadViewModel.js b/src/domain/SessionLoadViewModel.js index e9454a24..d8736077 100644 --- a/src/domain/SessionLoadViewModel.js +++ b/src/domain/SessionLoadViewModel.js @@ -21,9 +21,9 @@ import {ViewModel} from "./ViewModel.js"; export class SessionLoadViewModel extends ViewModel { constructor(options) { super(options); - const {createAndStartSessionContainer, sessionCallback, homeserver, deleteSessionOnCancel} = options; + const {createAndStartSessionContainer, ready, homeserver, deleteSessionOnCancel} = options; this._createAndStartSessionContainer = createAndStartSessionContainer; - this._sessionCallback = sessionCallback; + this._ready = ready; this._homeserver = homeserver; this._deleteSessionOnCancel = deleteSessionOnCancel; this._loading = false; @@ -60,11 +60,17 @@ export class SessionLoadViewModel extends ViewModel { // did it finish or get stuck at LoginFailed or Error? const loadStatus = this._sessionContainer.loadStatus.get(); + const loadError = this._sessionContainer.loadError; if (loadStatus === LoadStatus.FirstSync || loadStatus === LoadStatus.Ready) { - this._sessionCallback(this._sessionContainer); + const sessionContainer = this._sessionContainer; + // session container is ready, + // don't dispose it anymore when + // we get disposed + this._sessionContainer = null; + this._ready(sessionContainer); } - if (this._sessionContainer.loadError) { - console.error("session load error", this._sessionContainer.loadError); + if (loadError) { + console.error("session load error", loadError); } } catch (err) { this._error = err; @@ -77,24 +83,15 @@ export class SessionLoadViewModel extends ViewModel { } - async cancel() { - try { - if (this._sessionContainer) { - this._sessionContainer.dispose(); - if (this._deleteSessionOnCancel) { - await this._sessionContainer.deleteSession(); - } - this._sessionContainer = null; - } - if (this._waitHandle) { - // rejects with AbortError - this._waitHandle.dispose(); - this._waitHandle = null; - } - this._sessionCallback(); - } catch (err) { - this._error = err; - this.emitChange(); + dispose() { + if (this._sessionContainer) { + this._sessionContainer.dispose(); + this._sessionContainer = null; + } + if (this._waitHandle) { + // rejects with AbortError + this._waitHandle.dispose(); + this._waitHandle = null; } } From d48bbd122acc79547213ee21f16dce8ab49dea1e Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Fri, 9 Oct 2020 17:00:19 +0200 Subject: [PATCH 18/56] make applyUrl and history public so we don't need to bother with silent flags in replaceUrl --- src/domain/navigation/URLRouter.js | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/src/domain/navigation/URLRouter.js b/src/domain/navigation/URLRouter.js index d0f66fe6..5757ca10 100644 --- a/src/domain/navigation/URLRouter.js +++ b/src/domain/navigation/URLRouter.js @@ -25,12 +25,12 @@ export class URLRouter { attach() { this._subscription = this._history.subscribe(url => { - this._applyUrl(url); + this.applyUrl(url); }); - this._applyUrl(this._history.get()); + this.applyUrl(this._history.get()); } - _applyUrl(url) { + applyUrl(url) { const segments = this._segmentsFromUrl(url); const path = this._navigation.pathFrom(segments); this._navigation.applyPath(path); @@ -59,9 +59,8 @@ export class URLRouter { return segments; } - replaceUrl(url) { - // TODO: we don't want this to always to trigger an update - this._history.replaceUrl(url); + get history() { + return this._history; } urlForSegment(type, value) { From 0f8f558889caa02f4df8a206acefa6a063d8bdf4 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Fri, 9 Oct 2020 17:01:22 +0200 Subject: [PATCH 19/56] dispose session container from view model --- src/domain/session/SessionViewModel.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/domain/session/SessionViewModel.js b/src/domain/session/SessionViewModel.js index beb35872..f49d5ff1 100644 --- a/src/domain/session/SessionViewModel.js +++ b/src/domain/session/SessionViewModel.js @@ -25,14 +25,14 @@ export class SessionViewModel extends ViewModel { constructor(options) { super(options); const {sessionContainer} = options; - this._session = sessionContainer.session; + this._sessionContainer = this.track(sessionContainer); this._sessionStatusViewModel = this.track(new SessionStatusViewModel(this.childOptions({ sync: sessionContainer.sync, reconnector: sessionContainer.reconnector, session: sessionContainer.session, }))); this._leftPanelViewModel = new LeftPanelViewModel(this.childOptions({ - rooms: this._session.rooms, + rooms: this._sessionContainer.session.rooms, openRoom: this._openRoom.bind(this), gridEnabled: { get: () => !!this._gridViewModel, @@ -116,7 +116,7 @@ export class SessionViewModel extends ViewModel { } const roomVM = new RoomViewModel(this.childOptions({ room, - ownUserId: this._session.user.id, + ownUserId: this._sessionContainer.session.user.id, closeCallback: () => { if (this._closeCurrentRoom()) { this.emitChange("currentRoom"); From 1289f065d6114177c92824ba915bf35796e1f3e3 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Fri, 9 Oct 2020 17:01:39 +0200 Subject: [PATCH 20/56] implement root view as template view --- src/ui/web/RootView.js | 70 ++++++++++++------------------------------ 1 file changed, 20 insertions(+), 50 deletions(-) diff --git a/src/ui/web/RootView.js b/src/ui/web/RootView.js index d7f0be47..34ea1b2c 100644 --- a/src/ui/web/RootView.js +++ b/src/ui/web/RootView.js @@ -20,58 +20,28 @@ import {SessionLoadView} from "./login/SessionLoadView.js"; import {SessionPickerView} from "./login/SessionPickerView.js"; import {TemplateView} from "./general/TemplateView.js"; import {StaticView} from "./general/StaticView.js"; -import {SwitchView} from "./general/SwitchView.js"; -export class RootView { - constructor(vm) { - this._vm = vm; - this._switcher = null; - this._root = null; - this._onViewModelChange = this._onViewModelChange.bind(this); +export class RootView extends TemplateView { + render(t, vm) { + return t.mapView(vm => vm.activeSection, activeSection => { + switch (activeSection) { + case "error": + return new StatusView({header: "Something went wrong", message: vm.errorText}); + case "session": + return new SessionView(vm.sessionViewModel); + case "login": + return new LoginView(vm.loginViewModel); + case "picker": + return new SessionPickerView(vm.sessionPickerViewModel); + case "redirecting": + return new StaticView(t => t.p("Redirecting...")); + case "loading": + return new SessionLoadView(vm.sessionLoadViewModel); + default: + throw new Error(`Unknown section: ${vm.activeSection}`); + } + }); } - - _getView() { - switch (this._vm.activeSection) { - case "error": - return new StatusView({header: "Something went wrong", message: this._vm.errorText}); - case "session": - return new SessionView(this._vm.sessionViewModel); - case "login": - return new LoginView(this._vm.loginViewModel); - case "picker": - return new SessionPickerView(this._vm.sessionPickerViewModel); - case "redirecting": - return new StaticView(t => t.p("Redirecting...")); - case "loading": - return new SessionLoadView(this._vm.sessionLoadViewModel); - default: - throw new Error(`Unknown section: ${this._vm.activeSection}`); - } - } - - _onViewModelChange(prop) { - if (prop === "activeSection") { - this._switcher.switch(this._getView()); - } - } - - mount() { - this._switcher = new SwitchView(this._getView()); - this._root = this._switcher.mount(); - this._vm.on("change", this._onViewModelChange); - return this._root; - } - - unmount() { - this._vm.off("change", this._onViewModelChange); - this._switcher.unmount(); - } - - root() { - return this._root; - } - - update() {} } class StatusView extends TemplateView { From 3ab68ef4381f106a483a2b48ab8776b10e1195e3 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Fri, 9 Oct 2020 17:01:54 +0200 Subject: [PATCH 21/56] throw when something tracked is not disposable, fail early --- src/utils/Disposables.js | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/utils/Disposables.js b/src/utils/Disposables.js index bd13abc2..f682bc08 100644 --- a/src/utils/Disposables.js +++ b/src/utils/Disposables.js @@ -22,6 +22,10 @@ function disposeValue(value) { } } +function isDisposable(value) { + return value && (typeof value === "function" || typeof value.dispose === "function"); +} + export class Disposables { constructor() { this._disposables = []; @@ -31,6 +35,9 @@ export class Disposables { if (this.isDisposed) { throw new Error("Already disposed, check isDisposed after await if needed"); } + if (!isDisposable(disposable)) { + throw new Error("Not a disposable"); + } this._disposables.push(disposable); return disposable; } From 7b86b483ed4c005691138725934b305aa6a9e3a1 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Fri, 9 Oct 2020 17:02:19 +0200 Subject: [PATCH 22/56] confirm before clearing --- src/ui/web/login/SessionPickerView.js | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/ui/web/login/SessionPickerView.js b/src/ui/web/login/SessionPickerView.js index 420a151f..e421e56c 100644 --- a/src/ui/web/login/SessionPickerView.js +++ b/src/ui/web/login/SessionPickerView.js @@ -50,6 +50,12 @@ class SessionPickerItemView extends TemplateView { } } + _onClearClick() { + if (confirm("Are you sure?")) { + this.value.clear(); + } + } + render(t, vm) { const deleteButton = t.button({ className: "destructive", @@ -58,7 +64,7 @@ class SessionPickerItemView extends TemplateView { }, "Sign Out"); const clearButton = t.button({ disabled: vm => vm.isClearing, - onClick: () => vm.clear(), + onClick: this._onClearClick.bind(this), }, "Clear"); const exportButton = t.button({ disabled: vm => vm.isClearing, From 35e85c55e09047313d64d53bd1870c87a2283fe6 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Fri, 9 Oct 2020 17:02:53 +0200 Subject: [PATCH 23/56] use urls instead of callbacks to navigate in session picker --- src/domain/SessionPickerViewModel.js | 54 +++++++-------------------- src/ui/web/login/SessionPickerView.js | 13 ++----- 2 files changed, 18 insertions(+), 49 deletions(-) diff --git a/src/domain/SessionPickerViewModel.js b/src/domain/SessionPickerViewModel.js index 6e33883b..79f97578 100644 --- a/src/domain/SessionPickerViewModel.js +++ b/src/domain/SessionPickerViewModel.js @@ -15,15 +15,14 @@ limitations under the License. */ import {SortedArray} from "../observable/index.js"; -import {SessionLoadViewModel} from "./SessionLoadViewModel.js"; import {ViewModel} from "./ViewModel.js"; import {avatarInitials, getIdentifierColorNumber} from "./avatar.js"; class SessionItemViewModel extends ViewModel { - constructor(sessionInfo, pickerVM) { - super({}); + constructor(options, pickerVM) { + super(options); this._pickerVM = pickerVM; - this._sessionInfo = sessionInfo; + this._sessionInfo = options.sessionInfo; this._isDeleting = false; this._isClearing = false; this._error = null; @@ -76,6 +75,10 @@ class SessionItemViewModel extends ViewModel { return this._sessionInfo.id; } + get openUrl() { + return this.urlRouter.urlForSegment("session", this.id); + } + get label() { const {userId, comment} = this._sessionInfo; if (comment) { @@ -127,11 +130,10 @@ class SessionItemViewModel extends ViewModel { export class SessionPickerViewModel extends ViewModel { constructor(options) { super(options); - const {storageFactory, sessionInfoStorage, sessionCallback, createSessionContainer} = options; + const {storageFactory, sessionInfoStorage, sessionInfoCallback} = options; this._storageFactory = storageFactory; this._sessionInfoStorage = sessionInfoStorage; - this._sessionCallback = sessionCallback; - this._createSessionContainer = createSessionContainer; + this._sessionInfoCallback = sessionInfoCallback; this._sessions = new SortedArray((s1, s2) => s1.id.localeCompare(s2.id)); this._loadViewModel = null; this._error = null; @@ -140,7 +142,9 @@ export class SessionPickerViewModel extends ViewModel { // this loads all the sessions async load() { const sessions = await this._sessionInfoStorage.getAll(); - this._sessions.setManyUnsorted(sessions.map(s => new SessionItemViewModel(s, this))); + this._sessions.setManyUnsorted(sessions.map(s => { + return new SessionItemViewModel(this.childOptions({sessionInfo: s}), this); + })); } // for the loading of 1 picked session @@ -148,34 +152,6 @@ export class SessionPickerViewModel extends ViewModel { return this._loadViewModel; } - async pick(id) { - if (this._loadViewModel) { - return; - } - const sessionVM = this._sessions.array.find(s => s.id === id); - if (sessionVM) { - this._loadViewModel = new SessionLoadViewModel({ - createAndStartSessionContainer: () => { - const sessionContainer = this._createSessionContainer(); - sessionContainer.startWithExistingSession(sessionVM.id); - return sessionContainer; - }, - sessionCallback: sessionContainer => { - if (sessionContainer) { - // make parent view model move away - this._sessionCallback(sessionContainer); - } else { - // show list of session again - this._loadViewModel = null; - this.emitChange("loadViewModel"); - } - } - }); - this._loadViewModel.start(); - this.emitChange("loadViewModel"); - } - } - async _exportData(id) { const sessionInfo = await this._sessionInfoStorage.get(id); const stores = await this._storageFactory.export(id); @@ -213,9 +189,7 @@ export class SessionPickerViewModel extends ViewModel { return this._sessions; } - cancel() { - if (!this._loadViewModel) { - this._sessionCallback(); - } + get cancelUrl() { + return this.urlRouter.urlForSegment("login"); } } diff --git a/src/ui/web/login/SessionPickerView.js b/src/ui/web/login/SessionPickerView.js index e421e56c..4bb9b6b7 100644 --- a/src/ui/web/login/SessionPickerView.js +++ b/src/ui/web/login/SessionPickerView.js @@ -79,7 +79,7 @@ class SessionPickerItemView extends TemplateView { })); const errorMessage = t.if(vm => vm.error, t.createTemplate(t => t.p({className: "error"}, vm => vm.error))); return t.li([ - t.div({className: "session-info"}, [ + t.a({className: "session-info", href: vm.openUrl}, [ t.div({className: `avatar usercolor${vm.avatarColorNumber}`}, vm => vm.avatarInitials), t.div({className: "user-id"}, vm => vm.label), ]), @@ -98,11 +98,6 @@ export class SessionPickerView extends TemplateView { render(t, vm) { const sessionList = new ListView({ list: vm.sessions, - onItemClick: (item, event) => { - if (event.target.closest(".session-info")) { - vm.pick(item.value.id); - } - }, parentProvidesUpdates: false, }, sessionInfo => { return new SessionPickerItemView(sessionInfo); @@ -118,9 +113,9 @@ export class SessionPickerView extends TemplateView { className: "styled secondary", onClick: async () => vm.import(await selectFileAsText("application/json")) }, vm.i18n`Import a session`), - t.button({ - className: "styled primary", - onClick: () => vm.cancel() + t.a({ + className: "button styled primary", + href: vm.cancelUrl }, vm.i18n`Sign In`) ]), t.if(vm => vm.loadViewModel, vm => new SessionLoadStatusView(vm.loadViewModel)), From 33627edcb3f48ca65a3254b4eed004dd238d961c Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Fri, 9 Oct 2020 17:03:24 +0200 Subject: [PATCH 24/56] use url to go back in login view --- src/ui/web/login/LoginView.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/ui/web/login/LoginView.js b/src/ui/web/login/LoginView.js index 00d52da8..d1fc8930 100644 --- a/src/ui/web/login/LoginView.js +++ b/src/ui/web/login/LoginView.js @@ -51,9 +51,9 @@ export class LoginView extends TemplateView { t.div({className: "form-row"}, [t.label({for: "homeserver"}, vm.i18n`Homeserver`), homeserver]), t.mapView(vm => vm.loadViewModel, loadViewModel => loadViewModel ? new SessionLoadStatusView(loadViewModel) : null), t.div({className: "button-row"}, [ - t.button({ - className: "styled secondary", - onClick: () => vm.cancel(), disabled + t.a({ + className: "button styled secondary", + href: vm.cancelUrl }, [vm.i18n`Go Back`]), t.button({ className: "styled primary", From 927e8134d352adbe22b4dfc79b0eb27e65e3b033 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Fri, 9 Oct 2020 17:03:38 +0200 Subject: [PATCH 25/56] use url navigation in root view model also track all view models as it is important everything is properly disposed now --- src/domain/RootViewModel.js | 111 +++++++++++++++------------------ src/matrix/SessionContainer.js | 4 ++ 2 files changed, 56 insertions(+), 59 deletions(-) diff --git a/src/domain/RootViewModel.js b/src/domain/RootViewModel.js index 94a71292..3b6c24cd 100644 --- a/src/domain/RootViewModel.js +++ b/src/domain/RootViewModel.js @@ -29,77 +29,60 @@ export class RootViewModel extends ViewModel { this._storageFactory = storageFactory; this._error = null; - this._sessionViewModel = null; - this._loginViewModel = null; this._sessionPickerViewModel = null; - - this._sessionContainer = null; - this._sessionCallback = this._sessionCallback.bind(this); - + this._sessionLoadViewModel = null; + this._loginViewModel = null; + this._sessionViewModel = null; } async load() { - // TODO: deduplicate code here - this.track(this.navigation.observe("login").subscribe(shown => { - if (shown) { - this._showLogin(); + this.track(this.navigation.observe("login").subscribe(() => this._applyNavigation())); + this.track(this.navigation.observe("session").subscribe(() => this._applyNavigation())); + if (!this._applyNavigation()) { + try { + // redirect depending on what sessions are already present + const sessionInfos = await this._sessionInfoStorage.getAll(); + const url = this._urlForSessionInfos(sessionInfos); + this.urlRouter.history.replaceUrl(url); + this.urlRouter.applyUrl(url); + } catch (err) { + this._setSection(() => this._error = err); } - })); - this.track(this.navigation.observe("session").subscribe(sessionId => { - if (sessionId === true) { - this._showPicker(); - } else if (sessionId) { - this._showSessionLoader(sessionId); - } - })); + } + } + _applyNavigation() { const isLogin = this.navigation.observe("login").get(); const sessionId = this.navigation.observe("session").get(); if (isLogin) { this._showLogin(); + return true; } else if (sessionId === true) { this._showPicker(); + return true; } else if (sessionId) { this._showSessionLoader(sessionId); - } else { - const sessionInfos = await this._sessionInfoStorage.getAll(); - let url; - if (sessionInfos.length === 0) { - url = this.urlRouter.urlForSegment("login"); - } else if (sessionInfos.length === 1) { - url = this.urlRouter.urlForSegment("session", sessionInfos[0].id); - } else { - url = this.urlRouter.urlForSegment("session"); - } - this.urlRouter.replaceUrl(url); + return true; } + return false; } - _sessionCallback(sessionContainer) { - if (sessionContainer) { - this._setSection(() => { - this._sessionContainer = sessionContainer; - this._sessionViewModel = new SessionViewModel(this.childOptions({sessionContainer})); - this._sessionViewModel.start(); - }); + _urlForSessionInfos(sessionInfos) { + if (sessionInfos.length === 0) { + return this.urlRouter.urlForSegment("login"); + } else if (sessionInfos.length === 1) { + return this.urlRouter.urlForSegment("session", sessionInfos[0].id); } else { - // switch between picker and login - if (this.activeSection === "login") { - this._showPicker(); - } else { - this._showLogin(); - } + return this.urlRouter.urlForSegment("session"); } } async _showPicker() { this._setSection(() => { - this._sessionPickerViewModel = new SessionPickerViewModel({ + this._sessionPickerViewModel = new SessionPickerViewModel(this.childOptions({ sessionInfoStorage: this._sessionInfoStorage, storageFactory: this._storageFactory, - createSessionContainer: this._createSessionContainer, - sessionCallback: this._sessionCallback, - }); + })); }); try { await this._sessionPickerViewModel.load(); @@ -110,11 +93,22 @@ export class RootViewModel extends ViewModel { _showLogin() { this._setSection(() => { - this._loginViewModel = new LoginViewModel({ + this._loginViewModel = new LoginViewModel(this.childOptions({ defaultHomeServer: "https://matrix.org", createSessionContainer: this._createSessionContainer, - sessionCallback: this._sessionCallback, - }); + ready: sessionContainer => { + const url = this.urlRouter.urlForSegment("session", sessionContainer.sessionId); + this.urlRouter.replaceUrl(url); + this._showSession(sessionContainer); + }, + })); + }); + } + + _showSession(sessionContainer) { + this._setSection(() => { + this._sessionViewModel = new SessionViewModel(this.childOptions({sessionContainer})); + this._sessionViewModel.start(); }); } @@ -126,7 +120,7 @@ export class RootViewModel extends ViewModel { sessionContainer.startWithExistingSession(sessionId); return sessionContainer; }, - sessionCallback: this._sessionCallback + ready: sessionContainer => this._showSession(sessionContainer) }); this._sessionLoadViewModel.start(); }); @@ -151,17 +145,16 @@ export class RootViewModel extends ViewModel { _setSection(setter) { // clear all members the activeSection depends on this._error = null; - this._sessionViewModel = null; - this._loginViewModel = null; - this._sessionPickerViewModel = null; - this._sessionLoadViewModel = null; - - if (this._sessionContainer) { - this._sessionContainer.stop(); - this._sessionContainer = null; - } + this._sessionPickerViewModel = this.disposeTracked(this._sessionPickerViewModel); + this._sessionLoadViewModel = this.disposeTracked(this._sessionLoadViewModel); + this._loginViewModel = this.disposeTracked(this._loginViewModel); + this._sessionViewModel = this.disposeTracked(this._sessionViewModel); // now set it again setter(); + this._sessionPickerViewModel && this.track(this._sessionPickerViewModel); + this._sessionLoadViewModel && this.track(this._sessionLoadViewModel); + this._loginViewModel && this.track(this._loginViewModel); + this._sessionViewModel && this.track(this._sessionViewModel); this.emitChange("activeSection"); } diff --git a/src/matrix/SessionContainer.js b/src/matrix/SessionContainer.js index dff6e38c..a9a6dea6 100644 --- a/src/matrix/SessionContainer.js +++ b/src/matrix/SessionContainer.js @@ -70,6 +70,10 @@ export class SessionContainer { return (Math.floor(this._random() * Number.MAX_SAFE_INTEGER)).toString(); } + get sessionId() { + return this._sessionId; + } + async startWithExistingSession(sessionId) { if (this._status.get() !== LoadStatus.NotLoading) { return; From 56e51fc2aa19cf06bf4f84a76b4ec395c4bfb02a Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Fri, 9 Oct 2020 17:04:17 +0200 Subject: [PATCH 26/56] idea comment --- src/ui/web/general/TemplateView.js | 1 + 1 file changed, 1 insertion(+) diff --git a/src/ui/web/general/TemplateView.js b/src/ui/web/general/TemplateView.js index 4e897cf0..80c2cf2e 100644 --- a/src/ui/web/general/TemplateView.js +++ b/src/ui/web/general/TemplateView.js @@ -37,6 +37,7 @@ function objHasFns(obj) { - className binding returning object with className => enabled map - add subviews inside the template */ +// TODO: should we rename this to BoundView or something? As opposed to StaticView ... export class TemplateView { constructor(value, render = undefined) { this._value = value; From 83ac3eccc5c16fe15f19a91fc82c2264d7e5e861 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Fri, 9 Oct 2020 17:04:25 +0200 Subject: [PATCH 27/56] apply url changes to view gallery --- src/ui/web/view-gallery.html | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/ui/web/view-gallery.html b/src/ui/web/view-gallery.html index 625a8b39..46b69e16 100644 --- a/src/ui/web/view-gallery.html +++ b/src/ui/web/view-gallery.html @@ -45,7 +45,7 @@ const view = new LoginView(vm({ defaultHomeServer: "https://hs.tld", login: () => alert("Logging in!"), - goBack: () => alert("Going back"), + cancelUrl: "#/session" })); document.getElementById("login").appendChild(view.mount()); @@ -59,6 +59,7 @@ loadLabel: "Doing something important...", loading: true, }), + cancelUrl: "#/session", defaultHomeServer: "https://hs.tld", })); document.getElementById("login-loading").appendChild(view.mount()); From 6c2c29a7da2b1c5d49ccbf64d0e3248e580cc231 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Fri, 9 Oct 2020 19:43:11 +0200 Subject: [PATCH 28/56] WIP --- src/domain/navigation/URLRouter.js | 5 +- src/domain/navigation/index.js | 60 ++++++++++++++++- src/domain/session/RoomGridViewModel.js | 33 ++++----- src/domain/session/SessionViewModel.js | 67 ++++++++++--------- .../session/leftpanel/RoomTileViewModel.js | 1 - src/ui/web/session/SessionView.js | 6 +- 6 files changed, 114 insertions(+), 58 deletions(-) diff --git a/src/domain/navigation/URLRouter.js b/src/domain/navigation/URLRouter.js index 5757ca10..05da4ea7 100644 --- a/src/domain/navigation/URLRouter.js +++ b/src/domain/navigation/URLRouter.js @@ -17,10 +17,11 @@ limitations under the License. import {Segment} from "./Navigation.js"; export class URLRouter { - constructor(history, navigation) { + constructor({history, navigation, redirect}) { this._subscription = null; this._history = history; this._navigation = navigation; + this._redirect = redirect; } attach() { @@ -32,7 +33,7 @@ export class URLRouter { applyUrl(url) { const segments = this._segmentsFromUrl(url); - const path = this._navigation.pathFrom(segments); + const path = this._redirect(segments, this._navigation); this._navigation.applyPath(path); } diff --git a/src/domain/navigation/index.js b/src/domain/navigation/index.js index b4b9890e..36627763 100644 --- a/src/domain/navigation/index.js +++ b/src/domain/navigation/index.js @@ -14,7 +14,8 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {Navigation} from "./Navigation.js"; +import {Navigation, Segment} from "./Navigation.js"; +import {URLRouter} from "./URLRouter.js"; export function createNavigation() { return new Navigation(function allowsChild(parent, child) { @@ -25,8 +26,65 @@ export function createNavigation() { return type === "login" || type === "session"; case "session": return type === "room" || type === "rooms" || type === "settings"; + case "rooms": + // downside of the approach: both of these will control which tile is selected + return type === "room" || type === "empty-grid-tile"; default: return false; } }); } + +export function createRouter({history, navigation}) { + return new URLRouter({history, navigation, redirect}); +} + +function redirect(urlParts, navigation) { + const {path} = navigation; + const segments = urlParts.reduce((output, s) => { + // redirect open-room action to grid/non-grid url + if (s.type === "open-room") { + const rooms = path.get("rooms"); + if (rooms) { + output = output.concat(roomsSegmentWithRoom(rooms, s.value, path)); + } + return rooms.concat(new Segment("room", s.value)); + } + return output.concat(s); + }, []); + return navigation.pathFrom(segments); +} + +function roomsSegmentWithRoom(rooms, roomId, path) { + // find the index of either the current room, + // or the current selected empty tile, + // to put the new room in + + // TODO: is rooms.value a string or an array? + const room = path.get("room"); + let index = 0; + if (room) { + index = rooms.value.indexOf(room.value); + } else { + const emptyGridTile = path.get("empty-grid-tile"); + if (emptyGridTile) { + index = emptyGridTile.value; + } + } + const newRooms = rooms.slice(); + newRooms[index] = roomId; + return new Segment("rooms", newRooms); +} + +function parseUrlValue(type, iterator) { + if (type === "rooms") { + const roomIds = iterator.next().value.split(","); + const selectedIndex = parseInt(iterator.next().value, 10); + const roomId = roomIds[selectedIndex]; + if (roomId) { + return [new Segment(type, roomIds), new Segment("room", roomId)]; + } else { + return [new Segment(type, roomIds), new Segment("empty-grid-tile", selectedIndex)]; + } + } +} diff --git a/src/domain/session/RoomGridViewModel.js b/src/domain/session/RoomGridViewModel.js index 9d91cd99..13fbcd44 100644 --- a/src/domain/session/RoomGridViewModel.js +++ b/src/domain/session/RoomGridViewModel.js @@ -26,7 +26,7 @@ export class RoomGridViewModel extends ViewModel { } roomViewModelAt(i) { - return this._viewModels[i]?.vm; + return this._viewModels[i]; } get focusIndex() { @@ -37,14 +37,9 @@ export class RoomGridViewModel extends ViewModel { if (idx === this._selectedIndex) { return; } - const oldItem = this._viewModels[this._selectedIndex]; - oldItem?.tileVM?.close(); this._selectedIndex = idx; - const newItem = this._viewModels[this._selectedIndex]; - if (newItem) { - newItem.vm.focus(); - newItem.tileVM.open(); - } + const vm = this._viewModels[this._selectedIndex]; + vm?.focus(); this.emitChange("focusedIndex"); } get width() { @@ -58,14 +53,12 @@ export class RoomGridViewModel extends ViewModel { /** * Sets a pair of room and room tile view models at the current index * @param {RoomViewModel} vm - * @param {RoomTileViewModel} tileVM * @package */ - setRoomViewModel(vm, tileVM) { + setRoomViewModel(vm) { const old = this._viewModels[this._selectedIndex]; - this.disposeTracked(old?.vm); - old?.tileVM?.close(); - this._viewModels[this._selectedIndex] = {vm: this.track(vm), tileVM}; + this.disposeTracked(old); + this._viewModels[this._selectedIndex] = this.track(vm); this.emitChange(`${this._selectedIndex}`); } @@ -73,7 +66,7 @@ export class RoomGridViewModel extends ViewModel { * @package */ tryFocusRoom(roomId) { - const index = this._viewModels.findIndex(vms => vms?.vm.id === roomId); + const index = this._viewModels.findIndex(vm => vm.id === roomId); if (index >= 0) { this.setFocusIndex(index); return true; @@ -82,15 +75,15 @@ export class RoomGridViewModel extends ViewModel { } /** - * Returns the first set of room and room tile vm, - * and untracking them so they are not owned by this view model anymore. + * Returns the first set of room vm, + * and untracking it so it is not owned by this view model anymore. * @package */ getAndUntrackFirst() { - for (const item of this._viewModels) { - if (item) { - this.untrack(item.vm); - return item; + for (const vm of this._viewModels) { + if (vm) { + this.untrack(vm); + return vm; } } } diff --git a/src/domain/session/SessionViewModel.js b/src/domain/session/SessionViewModel.js index f49d5ff1..8acab44a 100644 --- a/src/domain/session/SessionViewModel.js +++ b/src/domain/session/SessionViewModel.js @@ -33,22 +33,33 @@ export class SessionViewModel extends ViewModel { }))); this._leftPanelViewModel = new LeftPanelViewModel(this.childOptions({ rooms: this._sessionContainer.session.rooms, - openRoom: this._openRoom.bind(this), + // this will go over navigation as well gridEnabled: { get: () => !!this._gridViewModel, set: value => this._enabledGrid(value) } })); - this._currentRoomTileViewModel = null; this._currentRoomViewModel = null; this._gridViewModel = null; + // this gives us the active room, also in the grid? + this.track(this.navigator.observe("room").subscribe(roomId => { + + })); + // this gives us a set of room ids in the grid + this.track(this.navigator.observe("rooms").subscribe(value => { + if (value) { + const roomIds = typeof value === "string" ? value.split(",") : []; + // also update grid + this._enabledGrid(roomIds); + } + })); } start() { this._sessionStatusViewModel.start(); } - get selectionId() { + get activeSection() { if (this._currentRoomViewModel) { return this._currentRoomViewModel.id; } else if (this._gridViewModel) { @@ -73,62 +84,56 @@ export class SessionViewModel extends ViewModel { return this._roomList; } - get currentRoom() { + get currentRoomViewModel() { return this._currentRoomViewModel; } + // TODO: this should also happen based on URLs _enabledGrid(enabled) { if (enabled) { this._gridViewModel = this.track(new RoomGridViewModel(this.childOptions({width: 3, height: 2}))); // transfer current room if (this._currentRoomViewModel) { this.untrack(this._currentRoomViewModel); - this._gridViewModel.setRoomViewModel(this._currentRoomViewModel, this._currentRoomTileViewModel); + this._gridViewModel.setRoomViewModel(this._currentRoomViewModel); this._currentRoomViewModel = null; - this._currentRoomTileViewModel = null; } } else { - const VMs = this._gridViewModel.getAndUntrackFirst(); - if (VMs) { - this._currentRoomViewModel = this.track(VMs.vm); - this._currentRoomTileViewModel = VMs.tileVM; - this._currentRoomTileViewModel.open(); + const vm = this._gridViewModel.getAndUntrackFirst(); + if (vm) { + this._currentRoomViewModel = this.track(vm); } this._gridViewModel = this.disposeTracked(this._gridViewModel); } this.emitChange("middlePanelViewType"); } - _closeCurrentRoom() { - // no closing in grid for now as it is disabled on narrow viewports - if (!this._gridViewModel) { - this._currentRoomTileViewModel?.close(); - this._currentRoomViewModel = this.disposeTracked(this._currentRoomViewModel); - return true; - } - } - - _openRoom(room, roomTileVM) { - if (this._gridViewModel?.tryFocusRoom(room.id)) { + _openRoom(roomId) { + // already open? + if (this._gridViewModel?.tryFocusRoom(roomId)) { return; - } else if (this._currentRoomViewModel?.id === room.id) { + } else if (this._currentRoomViewModel?.id === roomId) { + return; + } + const room = this._session.rooms.get(roomId); + // not found? close current room and show placeholder + if (!room) { + if (this._gridViewModel) { + this._gridViewModel.setRoomViewModel(null); + } else { + this._currentRoomViewModel = this.disposeTracked(this._currentRoomViewModel); + } return; } const roomVM = new RoomViewModel(this.childOptions({ room, ownUserId: this._sessionContainer.session.user.id, - closeCallback: () => { - if (this._closeCurrentRoom()) { - this.emitChange("currentRoom"); - } - }, })); roomVM.load(); if (this._gridViewModel) { - this._gridViewModel.setRoomViewModel(roomVM, roomTileVM); + this._gridViewModel.setRoomViewModel(roomVM); } else { - this._closeCurrentRoom(); - this._currentRoomTileViewModel = roomTileVM; + this._currentRoomViewModel = this.disposeTracked(this._currentRoomViewModel); this._currentRoomViewModel = this.track(roomVM); this.emitChange("currentRoom"); } diff --git a/src/domain/session/leftpanel/RoomTileViewModel.js b/src/domain/session/leftpanel/RoomTileViewModel.js index 2b25294b..112e1ffa 100644 --- a/src/domain/session/leftpanel/RoomTileViewModel.js +++ b/src/domain/session/leftpanel/RoomTileViewModel.js @@ -45,7 +45,6 @@ export class RoomTileViewModel extends ViewModel { } } - // called by parent for now (later should integrate with router) close() { if (this._isOpen) { this._isOpen = false; diff --git a/src/ui/web/session/SessionView.js b/src/ui/web/session/SessionView.js index a85ff3dd..2c6885de 100644 --- a/src/ui/web/session/SessionView.js +++ b/src/ui/web/session/SessionView.js @@ -32,14 +32,14 @@ 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.selectionId, selectionId => { - switch (selectionId) { + 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.`))); default: //room id - return new RoomView(vm.currentRoom); + return new RoomView(vm.currentRoomViewModel); } }) ]) From b2d6b7014b786211bfaf7d7e78c144c41d3b075e Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Mon, 12 Oct 2020 17:49:06 +0200 Subject: [PATCH 29/56] first draft of url navigation for grid --- src/domain/RootViewModel.js | 2 +- src/domain/navigation/Navigation.js | 51 ++++- src/domain/navigation/URLRouter.js | 87 ++++---- src/domain/navigation/index.js | 206 ++++++++++++++---- src/domain/session/RoomGridViewModel.js | 147 ++++++++++--- src/domain/session/SessionViewModel.js | 146 +++++++++---- .../session/leftpanel/LeftPanelViewModel.js | 37 +++- .../session/leftpanel/RoomTileViewModel.js | 2 +- src/ui/web/dom/History.js | 31 +-- src/ui/web/session/RoomGridView.js | 4 +- src/ui/web/session/leftpanel/RoomTileView.js | 27 +-- 11 files changed, 539 insertions(+), 201 deletions(-) diff --git a/src/domain/RootViewModel.js b/src/domain/RootViewModel.js index 3b6c24cd..a5d0b0f2 100644 --- a/src/domain/RootViewModel.js +++ b/src/domain/RootViewModel.js @@ -98,7 +98,7 @@ export class RootViewModel extends ViewModel { createSessionContainer: this._createSessionContainer, ready: sessionContainer => { const url = this.urlRouter.urlForSegment("session", sessionContainer.sessionId); - this.urlRouter.replaceUrl(url); + this.urlRouter.history.replaceUrl(url); this._showSession(sessionContainer); }, })); diff --git a/src/domain/navigation/Navigation.js b/src/domain/navigation/Navigation.js index 6520114c..70c79a01 100644 --- a/src/domain/navigation/Navigation.js +++ b/src/domain/navigation/Navigation.js @@ -28,10 +28,26 @@ export class Navigation { } applyPath(path) { + const oldPath = this._path; this._path = path; - for (const [type, observable] of this._observables) { - // if the value did not change, this won't emit - observable.set(this._path.get(type)?.value); + // clear values not in the new path in reverse order of path + for (let i = oldPath.segments.length - 1; i >= 0; i -= 1) { + const segment = oldPath[i]; + if (!this._path.get(segment.type)) { + const observable = this._observables.get(segment.type); + if (observable) { + observable.set(segment.type, undefined); + } + } + } + // change values in order of path + for (const segment of this._path.segments) { + const observable = this._observables.get(segment.type); + if (observable) { + if (!segmentValueEqual(segment?.value, observable.get())) { + observable.set(segment.type, segment.value); + } + } } } @@ -55,6 +71,27 @@ export class Navigation { } return new Path(segments, this._allowsChild); } + + segment(type, value) { + return new Segment(type, value); + } +} + +function segmentValueEqual(a, b) { + if (a === b) { + return true; + } + // allow (sparse) arrays + if (Array.isArray(a) && Array.isArray(b)) { + const len = Math.max(a.length, b.length); + for (let i = 0; i < len; i += 1) { + if (a[i] !== b[i]) { + return false; + } + } + return true; + } + return false; } export class Segment { @@ -89,6 +126,14 @@ class Path { return null; } + until(type) { + const index = this._segments.findIndex(s => s.type === type); + if (index !== -1) { + return new Path(this._segments.slice(0, index + 1), this._allowsChild) + } + return new Path([], this._allowsChild); + } + get(type) { return this._segments.find(s => s.type === type); } diff --git a/src/domain/navigation/URLRouter.js b/src/domain/navigation/URLRouter.js index 05da4ea7..6d7dc385 100644 --- a/src/domain/navigation/URLRouter.js +++ b/src/domain/navigation/URLRouter.js @@ -17,69 +17,78 @@ limitations under the License. import {Segment} from "./Navigation.js"; export class URLRouter { - constructor({history, navigation, redirect}) { + constructor({history, navigation, parseUrlPath, stringifyPath}) { this._subscription = null; this._history = history; this._navigation = navigation; - this._redirect = redirect; + this._parseUrlPath = parseUrlPath; + this._stringifyPath = stringifyPath; } attach() { this._subscription = this._history.subscribe(url => { - this.applyUrl(url); + const redirectedUrl = this.applyUrl(url); + if (redirectedUrl !== url) { + this._history.replaceUrl(redirectedUrl); + } }); this.applyUrl(this._history.get()); } - applyUrl(url) { - const segments = this._segmentsFromUrl(url); - const path = this._redirect(segments, this._navigation); - this._navigation.applyPath(path); - } - - stop() { + dispose() { this._subscription = this._subscription(); } - _segmentsFromUrl(url) { - const path = this._history.urlAsPath(url); - const parts = path.split("/").filter(p => !!p); - let index = 0; - const segments = []; - while (index < parts.length) { - const type = parts[index]; - if ((index + 1) < parts.length) { - index += 1; - const value = parts[index]; - segments.push(new Segment(type, value)); - } else { - segments.push(new Segment(type)); - } - index += 1; - } - return segments; + applyUrl(url) { + const urlPath = this._history.urlAsPath(url) + const navPath = this._navigation.pathFrom(this._parseUrlPath(urlPath)); + this._navigation.applyPath(navPath); + return this._history.pathAsUrl(this._stringifyPath(navPath)); } get history() { return this._history; } - urlForSegment(type, value) { - const path = this._navigation.path.with(new Segment(type, value)); - if (path) { - return this.urlForPath(path); + urlForSegments(segments) { + let path = this._navigation.path; + for (const segment of segments) { + path = path.with(segment); + if (!path) { + return; + } } + return this.urlForPath(path); + } + + urlForSegment(type, value) { + return this.urlForSegments([this._navigation.segment(type, value)]); } urlForPath(path) { - let urlPath = ""; - for (const {type, value} of path.segments) { - if (typeof value === "boolean") { - urlPath += `/${type}`; - } else { - urlPath += `/${type}/${value}`; - } - } + return this.history.pathAsUrl(this._stringifyPath(path)); + } + + openRoomActionUrl(roomId) { + // not a segment to navigation knowns about, so append it manually + const urlPath = `${this._stringifyPath(this._navigation.path.until("session"))}/open-room/${roomId}`; return this._history.pathAsUrl(urlPath); } + + disableGridUrl() { + + } + + enableGridUrl() { + let path = this._navigation.path.until("session"); + const room = this._navigation.get("room"); + if (room) { + path = path.with(this._navigation.segment("rooms", [room.value])); + path = path.with(room); + } else { + path = path.with(this._navigation.segment("rooms", [])); + path = path.with(this._navigation.segment("empty-grid-tile", 0)); + } + return this.urlForPath(path); + } } diff --git a/src/domain/navigation/index.js b/src/domain/navigation/index.js index 36627763..1a635293 100644 --- a/src/domain/navigation/index.js +++ b/src/domain/navigation/index.js @@ -18,49 +18,30 @@ import {Navigation, Segment} from "./Navigation.js"; import {URLRouter} from "./URLRouter.js"; export function createNavigation() { - return new Navigation(function allowsChild(parent, child) { - const {type} = child; - switch (parent?.type) { - case undefined: - // allowed root segments - return type === "login" || type === "session"; - case "session": - return type === "room" || type === "rooms" || type === "settings"; - case "rooms": - // downside of the approach: both of these will control which tile is selected - return type === "room" || type === "empty-grid-tile"; - default: - return false; - } - }); + return new Navigation(allowsChild); } export function createRouter({history, navigation}) { - return new URLRouter({history, navigation, redirect}); + return new URLRouter({history, navigation, stringifyPath, parseUrlPath}); } -function redirect(urlParts, navigation) { - const {path} = navigation; - const segments = urlParts.reduce((output, s) => { - // redirect open-room action to grid/non-grid url - if (s.type === "open-room") { - const rooms = path.get("rooms"); - if (rooms) { - output = output.concat(roomsSegmentWithRoom(rooms, s.value, path)); - } - return rooms.concat(new Segment("room", s.value)); - } - return output.concat(s); - }, []); - return navigation.pathFrom(segments); +function allowsChild(parent, child) { + const {type} = child; + switch (parent?.type) { + case undefined: + // allowed root segments + return type === "login" || type === "session"; + case "session": + return type === "room" || type === "rooms" || type === "settings"; + case "rooms": + // downside of the approach: both of these will control which tile is selected + return type === "room" || type === "empty-grid-tile"; + default: + return false; + } } function roomsSegmentWithRoom(rooms, roomId, path) { - // find the index of either the current room, - // or the current selected empty tile, - // to put the new room in - - // TODO: is rooms.value a string or an array? const room = path.get("room"); let index = 0; if (room) { @@ -71,20 +52,157 @@ function roomsSegmentWithRoom(rooms, roomId, path) { index = emptyGridTile.value; } } - const newRooms = rooms.slice(); + const newRooms = rooms.value.slice(); newRooms[index] = roomId; return new Segment("rooms", newRooms); } -function parseUrlValue(type, iterator) { - if (type === "rooms") { - const roomIds = iterator.next().value.split(","); - const selectedIndex = parseInt(iterator.next().value, 10); - const roomId = roomIds[selectedIndex]; - if (roomId) { - return [new Segment(type, roomIds), new Segment("room", roomId)]; +export function parseUrlPath(urlPath, currentNavPath) { + // substr(1) to take of initial / + const parts = urlPath.substr(1).split("/"); + const iterator = parts[Symbol.iterator](); + const segments = []; + let next; + while (!(next = iterator.next()).done) { + const type = next.value; + if (type === "rooms") { + const roomsValue = iterator.next().value; + if (!roomsValue) { break; } + const roomIds = roomsValue.split(","); + segments.push(new Segment(type, roomIds)); + const selectedIndex = parseInt(iterator.next().value || "0", 10); + const roomId = roomIds[selectedIndex]; + if (roomId) { + segments.push(new Segment("room", roomId)); + } else { + segments.push(new Segment("empty-grid-tile", selectedIndex)); + } + } else if (type === "open-room") { + const roomId = iterator.next().value; + if (!roomId) { break; } + const rooms = currentNavPath.get("rooms"); + if (rooms) { + segments.push(roomsSegmentWithRoom(rooms, roomId, currentNavPath)); + } + segments.push(new Segment("room", roomId)); } else { - return [new Segment(type, roomIds), new Segment("empty-grid-tile", selectedIndex)]; + // might be undefined, which will be turned into true by Segment + const value = iterator.next().value; + segments.push(new Segment(type, value)); + } + } + return segments; +} + +export function stringifyPath(path) { + let urlPath = ""; + let prevSegment; + for (const segment of path.segments) { + switch (segment.type) { + case "rooms": + urlPath += `/rooms/${segment.value.join(",")}`; + break; + case "empty-grid-tile": + urlPath += `/${segment.value}`; + break; + case "room": + if (prevSegment?.type === "rooms") { + const index = prevSegment.value.indexOf(segment.value); + urlPath += `/${index}`; + } else { + urlPath += `/${segment.type}/${segment.value}`; + } + break; + default: + urlPath += `/${segment.type}`; + if (segment.value && segment.value !== true) { + urlPath += `/${segment.value}`; + } + } + prevSegment = segment; + } + return urlPath; +} + +export function tests() { + return { + "stringify grid url with focused empty tile": assert => { + const nav = new Navigation(allowsChild); + const path = nav.pathFrom([ + new Segment("session", 1), + new Segment("rooms", ["a", "b", "c"]), + new Segment("empty-grid-tile", 3) + ]); + const urlPath = stringifyPath(path); + assert.equal(urlPath, "/session/1/rooms/a,b,c/3"); + }, + "stringify grid url with focused room": assert => { + const nav = new Navigation(allowsChild); + const path = nav.pathFrom([ + new Segment("session", 1), + new Segment("rooms", ["a", "b", "c"]), + new Segment("room", "b") + ]); + const urlPath = stringifyPath(path); + assert.equal(urlPath, "/session/1/rooms/a,b,c/1"); + }, + "parse grid url path with focused empty tile": assert => { + const segments = parseUrlPath("/session/1/rooms/a,b,c/3"); + assert.equal(segments.length, 3); + assert.equal(segments[0].type, "session"); + assert.equal(segments[0].value, "1"); + assert.equal(segments[1].type, "rooms"); + assert.deepEqual(segments[1].value, ["a", "b", "c"]); + assert.equal(segments[2].type, "empty-grid-tile"); + assert.equal(segments[2].value, 3); + }, + "parse grid url path with focused room": assert => { + const segments = parseUrlPath("/session/1/rooms/a,b,c/1"); + assert.equal(segments.length, 3); + assert.equal(segments[0].type, "session"); + assert.equal(segments[0].value, "1"); + assert.equal(segments[1].type, "rooms"); + assert.deepEqual(segments[1].value, ["a", "b", "c"]); + assert.equal(segments[2].type, "room"); + assert.equal(segments[2].value, "b"); + }, + "parse open-room action replacing the current focused room": assert => { + const nav = new Navigation(allowsChild); + const path = nav.pathFrom([ + new Segment("session", 1), + new Segment("rooms", ["a", "b", "c"]), + new Segment("room", "b") + ]); + const segments = parseUrlPath("/session/1/open-room/d", path); + assert.equal(segments.length, 3); + assert.equal(segments[0].type, "session"); + assert.equal(segments[0].value, "1"); + assert.equal(segments[1].type, "rooms"); + assert.deepEqual(segments[1].value, ["a", "d", "c"]); + assert.equal(segments[2].type, "room"); + assert.equal(segments[2].value, "d"); + }, + "parse open-room action setting a room in an empty tile": assert => { + const nav = new Navigation(allowsChild); + const path = nav.pathFrom([ + new Segment("session", 1), + new Segment("rooms", ["a", "b", "c"]), + new Segment("empty-grid-tile", 4) + ]); + const segments = parseUrlPath("/session/1/open-room/d", path); + assert.equal(segments.length, 3); + assert.equal(segments[0].type, "session"); + assert.equal(segments[0].value, "1"); + assert.equal(segments[1].type, "rooms"); + assert.deepEqual(segments[1].value, ["a", "b", "c", , "d"]); //eslint-disable-line no-sparse-arrays + assert.equal(segments[2].type, "room"); + assert.equal(segments[2].value, "d"); + }, + "parse session url path without id": assert => { + const segments = parseUrlPath("/session"); + assert.equal(segments.length, 1); + assert.equal(segments[0].type, "session"); + assert.strictEqual(segments[0].value, true); } } } diff --git a/src/domain/session/RoomGridViewModel.js b/src/domain/session/RoomGridViewModel.js index 13fbcd44..67cbf04c 100644 --- a/src/domain/session/RoomGridViewModel.js +++ b/src/domain/session/RoomGridViewModel.js @@ -19,10 +19,46 @@ import {ViewModel} from "../ViewModel.js"; export class RoomGridViewModel extends ViewModel { constructor(options) { super(options); + this._width = options.width; this._height = options.height; + this._createRoomViewModel = options.createRoomViewModel; + this._selectedIndex = 0; - this._viewModels = []; + this._viewModels = (options.roomIds || []).map(roomId => { + if (roomId) { + const vm = this._createRoomViewModel(roomId); + if (vm) { + return this.track(vm); + } + } + }); + this._setupNavigation(); + } + + _setupNavigation() { + const focusTileIndex = this.navigation.observe("empty-grid-tile"); + this.track(focusTileIndex.subscribe(index => { + if (typeof index === "number") { + this._setFocusIndex(index); + } + })); + if (typeof focusTileIndex.get() === "number") { + this._selectedIndex = focusTileIndex.get(); + } + + const focusedRoom = this.navigation.get("room"); + this.track(focusedRoom.subscribe(roomId => { + if (roomId) { + this._openRoom(roomId); + } + })); + if (focusedRoom.get()) { + const index = this._viewModels.findIndex(vm => vm && vm.id === focusedRoom.get()); + if (index >= 0) { + this._selectedIndex = index; + } + } } roomViewModelAt(i) { @@ -33,15 +69,6 @@ export class RoomGridViewModel extends ViewModel { return this._selectedIndex; } - setFocusIndex(idx) { - if (idx === this._selectedIndex) { - return; - } - this._selectedIndex = idx; - const vm = this._viewModels[this._selectedIndex]; - vm?.focus(); - this.emitChange("focusedIndex"); - } get width() { return this._width; } @@ -50,41 +77,91 @@ export class RoomGridViewModel extends ViewModel { return this._height; } - /** - * Sets a pair of room and room tile view models at the current index - * @param {RoomViewModel} vm - * @package - */ - setRoomViewModel(vm) { - const old = this._viewModels[this._selectedIndex]; - this.disposeTracked(old); - this._viewModels[this._selectedIndex] = this.track(vm); - this.emitChange(`${this._selectedIndex}`); + focusTile(index) { + if (index === this._selectedIndex) { + return; + } + let path = this.navigation.path; + const vm = this._viewModels[index]; + if (vm) { + path = path.with(this.navigation.segment("room", vm.id)); + } else { + path = path.with(this.navigation.segment("empty-grid-tile", index)); + } + let url = this.urlRouter.urlForPath(path); + url = this.urlRouter.applyUrl(url); + this.urlRouter.history.pushUrl(url); } - /** - * @package - */ - tryFocusRoom(roomId) { + /** called from SessionViewModel */ + setRoomIds(roomIds) { + let changed = false; + const len = this._height * this._width; + for (let i = 0; i < len; i += 1) { + const newId = roomIds[i]; + const vm = this._viewModels[i]; + if (newId && !vm) { + this._viewModels[i] = this.track(this._createRoomViewModel(newId)); + changed = true; + } else if (newId !== vm?.id) { + this._viewModels[i] = this.disposeTracked(this._viewModels[i]); + if (newId) { + this._viewModels[i] = this.track(this._createRoomViewModel(newId)); + } + changed = true; + } + } + if (changed) { + this.emitChange(); + } + } + + /** called from SessionViewModel */ + transferRoomViewModel(index, roomVM) { + const oldVM = this._viewModels[index]; + this.disposeTracked(oldVM); + this._viewModels[index] = this.track(roomVM); + } + + /** called from SessionViewModel */ + releaseRoomViewModel(roomId) { + const index = this._viewModels.findIndex(vm => vm.id === roomId); + if (index !== -1) { + const vm = this._viewModels[index]; + this.untrack(vm); + this._viewModels[index] = null; + return vm; + } + } + + _setFocusIndex(idx) { + if (idx === this._selectedIndex || idx >= (this._width * this._height)) { + return; + } + this._selectedIndex = idx; + const vm = this._viewModels[this._selectedIndex]; + vm?.focus(); + this.emitChange("focusedIndex"); + } + + _setFocusRoom(roomId) { const index = this._viewModels.findIndex(vm => vm.id === roomId); if (index >= 0) { - this.setFocusIndex(index); + this._setFocusIndex(index); return true; } return false; } - - /** - * Returns the first set of room vm, - * and untracking it so it is not owned by this view model anymore. - * @package - */ - getAndUntrackFirst() { - for (const vm of this._viewModels) { + + _openRoom(roomId) { + if (!this._setFocusRoom(roomId)) { + // replace vm at focused index + const vm = this._viewModels[this._selectedIndex]; if (vm) { - this.untrack(vm); - return vm; + this.disposeTracked(vm); } + this._viewModels[this._selectedIndex] = this.track(this._createRoomViewModel(roomId)); + this.emitChange(); } } } diff --git a/src/domain/session/SessionViewModel.js b/src/domain/session/SessionViewModel.js index 8acab44a..56a22b75 100644 --- a/src/domain/session/SessionViewModel.js +++ b/src/domain/session/SessionViewModel.js @@ -32,27 +32,33 @@ export class SessionViewModel extends ViewModel { session: sessionContainer.session, }))); this._leftPanelViewModel = new LeftPanelViewModel(this.childOptions({ - rooms: this._sessionContainer.session.rooms, - // this will go over navigation as well - gridEnabled: { - get: () => !!this._gridViewModel, - set: value => this._enabledGrid(value) - } + rooms: this._sessionContainer.session.rooms })); this._currentRoomViewModel = null; this._gridViewModel = null; - // this gives us the active room, also in the grid? - this.track(this.navigator.observe("room").subscribe(roomId => { + this._setupNavigation(); + } - })); + _setupNavigation() { + const gridRooms = this.navigation.observe("rooms"); // this gives us a set of room ids in the grid - this.track(this.navigator.observe("rooms").subscribe(value => { - if (value) { - const roomIds = typeof value === "string" ? value.split(",") : []; - // also update grid - this._enabledGrid(roomIds); + this.track(gridRooms.subscribe(roomIds => { + this._updateGrid(roomIds); + })); + if (gridRooms.get()) { + this._updateGrid(gridRooms.get()); + } + + const currentRoomId = this.navigation.observe("room"); + // this gives us the active room + this.track(currentRoomId.subscribe(roomId => { + if (!this._gridViewModel) { + this._openRoom(roomId); } })); + if (currentRoomId.get() && !this._gridViewModel) { + this._openRoom(currentRoomId.get()); + } } start() { @@ -88,53 +94,109 @@ export class SessionViewModel extends ViewModel { return this._currentRoomViewModel; } - // TODO: this should also happen based on URLs - _enabledGrid(enabled) { - if (enabled) { - this._gridViewModel = this.track(new RoomGridViewModel(this.childOptions({width: 3, height: 2}))); - // transfer current room - if (this._currentRoomViewModel) { - this.untrack(this._currentRoomViewModel); - this._gridViewModel.setRoomViewModel(this._currentRoomViewModel); - this._currentRoomViewModel = null; + // _transitionToGrid() { + // if (this._gridViewModel) { + // return; + // } + // this._gridViewModel = this.track(new RoomGridViewModel(this.childOptions({width: 3, height: 2}))); + // let path; + // if (this._currentRoomViewModel) { + // this.untrack(this._currentRoomViewModel); + // this._gridViewModel.transferRoomViewModel(0, this._currentRoomViewModel); + // const roomId = this._currentRoomViewModel.id; + // this._currentRoomViewModel = null; + // path = this.navigation.path + // .with(this.navigation.segment("rooms", [roomId])) + // .with(this.navigation.segment("room", roomId)); + // } else { + // path = this.navigation.path + // .with(this.navigation.segment("rooms", [])) + // .with(this.navigation.segment("empty-grid-tile", 0)); + // } + // const url = this.urlRouter.urlForPath(path); + // this.urlRouter.history.pushUrl(url); + // this.emitChange("middlePanelViewType"); + // this.navigation.applyPath(path); + // } + + // _transitionFromGrid() { + // if (!this._gridViewModel) { + // return; + // } + // const vm = this._gridViewModel.releaseFirstRoomViewModel(); + // let path = this.navigation.path.until("session"); + // if (vm) { + // path = path.with(this.navigation.segment("room", vm.id)); + // this._currentRoomViewModel = this.track(vm); + // } + // this._gridViewModel = this.disposeTracked(this._gridViewModel); + + // const url = this.urlRouter.urlForPath(path); + // this.urlRouter.history.pushUrl(url); + // this.emitChange("middlePanelViewType"); + // this.navigation.applyPath(path); + // } + + _updateGrid(roomIds) { + const changed = !(this._gridViewModel && roomIds); + const currentRoomId = this.navigation.path.get("room"); + if (roomIds) { + if (!this._gridViewModel) { + this._gridViewModel = this.track(new RoomGridViewModel(this.childOptions({ + width: 3, + height: 2, + createRoomViewModel: roomId => this._createRoomViewModel(roomId), + roomIds: roomIds + }))); + const vm = this._currentRoomViewModel; + const index = roomIds.indexOf(vm.id); + if (vm && index !== -1) { + this.untrack(vm); + this._gridViewModel.transferRoomViewModel(index, vm); + this._currentRoomViewModel = null; + } + } else { + this._gridViewModel.setRoomIds(roomIds); } - } else { - const vm = this._gridViewModel.getAndUntrackFirst(); - if (vm) { + } else if (this._gridViewModel && !roomIds) { + if (currentRoomId) { + const vm = this._gridViewModel.releaseRoomViewModel(currentRoomId.value); this._currentRoomViewModel = this.track(vm); } this._gridViewModel = this.disposeTracked(this._gridViewModel); } - this.emitChange("middlePanelViewType"); + if (changed) { + this.emitChange("middlePanelViewType"); + } } - _openRoom(roomId) { - // already open? - if (this._gridViewModel?.tryFocusRoom(roomId)) { - return; - } else if (this._currentRoomViewModel?.id === roomId) { - return; - } + _createRoomViewModel(roomId) { const room = this._session.rooms.get(roomId); - // not found? close current room and show placeholder if (!room) { - if (this._gridViewModel) { - this._gridViewModel.setRoomViewModel(null); - } else { - this._currentRoomViewModel = this.disposeTracked(this._currentRoomViewModel); - } - return; + return null; } const roomVM = new RoomViewModel(this.childOptions({ room, ownUserId: this._sessionContainer.session.user.id, })); roomVM.load(); + return roomVM; + } + + _openRoom(roomId) { + // already open? + if (this._currentRoomViewModel?.id === roomId) { + return; + } + this._currentRoomViewModel = this.disposeTracked(this._currentRoomViewModel); + const roomVM = this._createRoomViewModel(roomId); + if (roomVM) { + this._currentRoomViewModel = this.track(roomVM); + } if (this._gridViewModel) { this._gridViewModel.setRoomViewModel(roomVM); } else { this._currentRoomViewModel = this.disposeTracked(this._currentRoomViewModel); - this._currentRoomViewModel = this.track(roomVM); this.emitChange("currentRoom"); } } diff --git a/src/domain/session/leftpanel/LeftPanelViewModel.js b/src/domain/session/leftpanel/LeftPanelViewModel.js index 50569b2e..6dfce6e1 100644 --- a/src/domain/session/leftpanel/LeftPanelViewModel.js +++ b/src/domain/session/leftpanel/LeftPanelViewModel.js @@ -34,15 +34,44 @@ export class LeftPanelViewModel extends ViewModel { }); this._roomListFilterMap = new ApplyMap(roomTileVMs); this._roomList = this._roomListFilterMap.sortValues((a, b) => a.compare(b)); + this._currentTileVM = null; + this._setupNavigation(); } - get gridEnabled() { - return this._gridEnabled.get(); + _setupNavigation() { + const roomObservable = this.navigation.observe("room"); + this.track(roomObservable.subscribe(roomId => this._open(roomId))); + this._open(roomObservable.get()); + + const gridObservable = this.navigation.observe("rooms"); + this.gridEnabled = !!gridObservable.get(); + this.track(gridObservable.subscribe(roomIds => { + const changed = this.gridEnabled ^ !!roomIds; + this.gridEnabled = !!roomIds; + if (changed) { + this.emitChange("gridEnabled"); + } + })); + } + + _open(roomId) { + this._currentTileVM?.close(); + this._currentTileVM = null; + if (roomId) { + this._currentTileVM = this._roomListFilterMap.get(roomId); + this._currentTileVM?.open(); + } } toggleGrid() { - this._gridEnabled.set(!this._gridEnabled.get()); - this.emitChange("gridEnabled"); + let url; + if (this._gridEnabled) { + url = this.urlRouter.disableGridUrl(); + } else { + url = this.urlRouter.enableGridUrl(); + } + url = this.urlRouter.applyUrl(url); + this.urlRouter.history.pushUrl(url); } get roomList() { diff --git a/src/domain/session/leftpanel/RoomTileViewModel.js b/src/domain/session/leftpanel/RoomTileViewModel.js index 112e1ffa..9b4260ae 100644 --- a/src/domain/session/leftpanel/RoomTileViewModel.js +++ b/src/domain/session/leftpanel/RoomTileViewModel.js @@ -31,7 +31,7 @@ export class RoomTileViewModel extends ViewModel { this._isOpen = false; this._wasUnreadWhenOpening = false; this._hidden = false; - this._url = this.urlRouter.urlForSegment("room", this._room.id); + this._url = this.urlRouter.openRoomActionUrl(this._room.id); } get hidden() { diff --git a/src/ui/web/dom/History.js b/src/ui/web/dom/History.js index 797f108f..5a5794ae 100644 --- a/src/ui/web/dom/History.js +++ b/src/ui/web/dom/History.js @@ -35,26 +35,27 @@ export class History extends BaseObservableValue { return document.location.hash; } + /** does not emit */ replaceUrl(url) { window.history.replaceState(null, null, url); - // replaceState does not cause hashchange - this.emit(url); } + /** does not emit */ pushUrl(url) { - const hash = this.urlAsPath(url); - // important to check before we expect an echo - // as setting the hash to it's current value doesn't - // trigger onhashchange - if (hash === document.location.hash) { - return; - } - // this operation is silent, - // so avoid emitting on echo hashchange event - if (this._boundOnHashChange) { - this._expectSetEcho = true; - } - document.location.hash = hash; + window.history.pushState(null, null, url); + // const hash = this.urlAsPath(url); + // // important to check before we expect an echo + // // as setting the hash to it's current value doesn't + // // trigger onhashchange + // if (hash === document.location.hash) { + // return; + // } + // // this operation is silent, + // // so avoid emitting on echo hashchange event + // if (this._boundOnHashChange) { + // this._expectSetEcho = true; + // } + // document.location.hash = hash; } urlAsPath(url) { diff --git a/src/ui/web/session/RoomGridView.js b/src/ui/web/session/RoomGridView.js index 29eeb329..88e3e9ab 100644 --- a/src/ui/web/session/RoomGridView.js +++ b/src/ui/web/session/RoomGridView.js @@ -23,8 +23,8 @@ export class RoomGridView extends TemplateView { const children = []; for (let i = 0; i < (vm.height * vm.width); i+=1) { children.push(t.div({ - onClick: () => vm.setFocusIndex(i), - onFocusin: () => vm.setFocusIndex(i), + onClick: () => vm.focusTile(i), + onFocusin: () => vm.focusTile(i), className: { "container": true, [`tile${i}`]: true, diff --git a/src/ui/web/session/leftpanel/RoomTileView.js b/src/ui/web/session/leftpanel/RoomTileView.js index 31c49b66..fde02c25 100644 --- a/src/ui/web/session/leftpanel/RoomTileView.js +++ b/src/ui/web/session/leftpanel/RoomTileView.js @@ -25,22 +25,19 @@ export class RoomTileView extends TemplateView { "hidden": vm => vm.hidden }; return t.li({"className": classes}, [ - renderAvatar(t, vm, 32), - t.div({className: "description"}, [ - t.div({className: {"name": true, unread: vm => vm.isUnread}}, vm => vm.name), - t.div({ - className: { - "badge": true, - highlighted: vm => vm.isHighlighted, - hidden: vm => !vm.badgeCount - } - }, vm => vm.badgeCount), + t.a({href: vm.url}, [ + renderAvatar(t, vm, 32), + t.div({className: "description"}, [ + t.div({className: {"name": true, unread: vm => vm.isUnread}}, vm => vm.name), + t.div({ + className: { + "badge": true, + highlighted: vm => vm.isHighlighted, + hidden: vm => !vm.badgeCount + } + }, vm => vm.badgeCount), + ]) ]) ]); } - - // called from ListView - clicked() { - this.value.open(); - } } From 14d2dcbc601114939913ef444509a5c9d6762aeb Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Mon, 12 Oct 2020 17:49:41 +0200 Subject: [PATCH 30/56] remove dead code --- src/domain/session/SessionViewModel.js | 43 -------------------------- 1 file changed, 43 deletions(-) diff --git a/src/domain/session/SessionViewModel.js b/src/domain/session/SessionViewModel.js index 56a22b75..2ad2bb73 100644 --- a/src/domain/session/SessionViewModel.js +++ b/src/domain/session/SessionViewModel.js @@ -94,49 +94,6 @@ export class SessionViewModel extends ViewModel { return this._currentRoomViewModel; } - // _transitionToGrid() { - // if (this._gridViewModel) { - // return; - // } - // this._gridViewModel = this.track(new RoomGridViewModel(this.childOptions({width: 3, height: 2}))); - // let path; - // if (this._currentRoomViewModel) { - // this.untrack(this._currentRoomViewModel); - // this._gridViewModel.transferRoomViewModel(0, this._currentRoomViewModel); - // const roomId = this._currentRoomViewModel.id; - // this._currentRoomViewModel = null; - // path = this.navigation.path - // .with(this.navigation.segment("rooms", [roomId])) - // .with(this.navigation.segment("room", roomId)); - // } else { - // path = this.navigation.path - // .with(this.navigation.segment("rooms", [])) - // .with(this.navigation.segment("empty-grid-tile", 0)); - // } - // const url = this.urlRouter.urlForPath(path); - // this.urlRouter.history.pushUrl(url); - // this.emitChange("middlePanelViewType"); - // this.navigation.applyPath(path); - // } - - // _transitionFromGrid() { - // if (!this._gridViewModel) { - // return; - // } - // const vm = this._gridViewModel.releaseFirstRoomViewModel(); - // let path = this.navigation.path.until("session"); - // if (vm) { - // path = path.with(this.navigation.segment("room", vm.id)); - // this._currentRoomViewModel = this.track(vm); - // } - // this._gridViewModel = this.disposeTracked(this._gridViewModel); - - // const url = this.urlRouter.urlForPath(path); - // this.urlRouter.history.pushUrl(url); - // this.emitChange("middlePanelViewType"); - // this.navigation.applyPath(path); - // } - _updateGrid(roomIds) { const changed = !(this._gridViewModel && roomIds); const currentRoomId = this.navigation.path.get("room"); From 4e8e9eae265a8a9ad2369ac72ba19d8c13da8e0b Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Mon, 12 Oct 2020 18:31:55 +0200 Subject: [PATCH 31/56] first round of fixes --- src/domain/navigation/Navigation.js | 6 +++--- src/domain/navigation/URLRouter.js | 11 ++++++++--- src/domain/session/RoomGridViewModel.js | 2 +- src/domain/session/SessionViewModel.js | 18 +++++++++--------- .../session/leftpanel/LeftPanelViewModel.js | 14 ++++++-------- .../session/leftpanel/RoomTileViewModel.js | 4 +--- src/main.js | 6 +++--- src/observable/map/MappedMap.js | 4 ++++ src/ui/web/session/leftpanel/LeftPanelView.js | 1 - 9 files changed, 35 insertions(+), 31 deletions(-) diff --git a/src/domain/navigation/Navigation.js b/src/domain/navigation/Navigation.js index 70c79a01..40ce1806 100644 --- a/src/domain/navigation/Navigation.js +++ b/src/domain/navigation/Navigation.js @@ -32,11 +32,11 @@ export class Navigation { this._path = path; // clear values not in the new path in reverse order of path for (let i = oldPath.segments.length - 1; i >= 0; i -= 1) { - const segment = oldPath[i]; + const segment = oldPath.segments[i]; if (!this._path.get(segment.type)) { const observable = this._observables.get(segment.type); if (observable) { - observable.set(segment.type, undefined); + observable.set(undefined); } } } @@ -45,7 +45,7 @@ export class Navigation { const observable = this._observables.get(segment.type); if (observable) { if (!segmentValueEqual(segment?.value, observable.get())) { - observable.set(segment.type, segment.value); + observable.set(segment.value); } } } diff --git a/src/domain/navigation/URLRouter.js b/src/domain/navigation/URLRouter.js index 6d7dc385..e27c0fef 100644 --- a/src/domain/navigation/URLRouter.js +++ b/src/domain/navigation/URLRouter.js @@ -41,7 +41,7 @@ export class URLRouter { applyUrl(url) { const urlPath = this._history.urlAsPath(url) - const navPath = this._navigation.pathFrom(this._parseUrlPath(urlPath)); + const navPath = this._navigation.pathFrom(this._parseUrlPath(urlPath, this._navigation.path)); this._navigation.applyPath(navPath); return this._history.pathAsUrl(this._stringifyPath(navPath)); } @@ -76,12 +76,17 @@ export class URLRouter { } disableGridUrl() { - + let path = this._navigation.path.until("session"); + const room = this._navigation.path.get("room"); + if (room) { + path = path.with(room); + } + return this.urlForPath(path); } enableGridUrl() { let path = this._navigation.path.until("session"); - const room = this._navigation.get("room"); + const room = this._navigation.path.get("room"); if (room) { path = path.with(this._navigation.segment("rooms", [room.value])); path = path.with(room); diff --git a/src/domain/session/RoomGridViewModel.js b/src/domain/session/RoomGridViewModel.js index 67cbf04c..d8a1f90a 100644 --- a/src/domain/session/RoomGridViewModel.js +++ b/src/domain/session/RoomGridViewModel.js @@ -47,7 +47,7 @@ export class RoomGridViewModel extends ViewModel { this._selectedIndex = focusTileIndex.get(); } - const focusedRoom = this.navigation.get("room"); + const focusedRoom = this.navigation.observe("room"); this.track(focusedRoom.subscribe(roomId => { if (roomId) { this._openRoom(roomId); diff --git a/src/domain/session/SessionViewModel.js b/src/domain/session/SessionViewModel.js index 2ad2bb73..5269b62b 100644 --- a/src/domain/session/SessionViewModel.js +++ b/src/domain/session/SessionViewModel.js @@ -99,15 +99,20 @@ export class SessionViewModel extends ViewModel { const currentRoomId = this.navigation.path.get("room"); if (roomIds) { if (!this._gridViewModel) { + const vm = this._currentRoomViewModel; + const index = roomIds.indexOf(vm.id); + const shouldTransfer = vm && index !== -1; + if (shouldTransfer) { + roomIds = roomIds.slice(); + roomIds[index] = undefined; + } this._gridViewModel = this.track(new RoomGridViewModel(this.childOptions({ width: 3, height: 2, createRoomViewModel: roomId => this._createRoomViewModel(roomId), roomIds: roomIds }))); - const vm = this._currentRoomViewModel; - const index = roomIds.indexOf(vm.id); - if (vm && index !== -1) { + if (shouldTransfer) { this.untrack(vm); this._gridViewModel.transferRoomViewModel(index, vm); this._currentRoomViewModel = null; @@ -128,7 +133,7 @@ export class SessionViewModel extends ViewModel { } _createRoomViewModel(roomId) { - const room = this._session.rooms.get(roomId); + const room = this._sessionContainer.session.rooms.get(roomId); if (!room) { return null; } @@ -149,11 +154,6 @@ export class SessionViewModel extends ViewModel { const roomVM = this._createRoomViewModel(roomId); if (roomVM) { this._currentRoomViewModel = this.track(roomVM); - } - if (this._gridViewModel) { - this._gridViewModel.setRoomViewModel(roomVM); - } else { - this._currentRoomViewModel = this.disposeTracked(this._currentRoomViewModel); this.emitChange("currentRoom"); } } diff --git a/src/domain/session/leftpanel/LeftPanelViewModel.js b/src/domain/session/leftpanel/LeftPanelViewModel.js index 6dfce6e1..15c30218 100644 --- a/src/domain/session/leftpanel/LeftPanelViewModel.js +++ b/src/domain/session/leftpanel/LeftPanelViewModel.js @@ -23,16 +23,14 @@ import {ApplyMap} from "../../../observable/map/ApplyMap.js"; export class LeftPanelViewModel extends ViewModel { constructor(options) { super(options); - const {rooms, openRoom, gridEnabled} = options; - this._gridEnabled = gridEnabled; - const roomTileVMs = rooms.mapValues((room, emitChange) => { + const {rooms} = options; + this._roomTileViewModels = rooms.mapValues((room, emitChange) => { return new RoomTileViewModel(this.childOptions({ room, - emitChange, - emitOpen: openRoom + emitChange })); }); - this._roomListFilterMap = new ApplyMap(roomTileVMs); + this._roomListFilterMap = new ApplyMap(this._roomTileViewModels); this._roomList = this._roomListFilterMap.sortValues((a, b) => a.compare(b)); this._currentTileVM = null; this._setupNavigation(); @@ -58,14 +56,14 @@ export class LeftPanelViewModel extends ViewModel { this._currentTileVM?.close(); this._currentTileVM = null; if (roomId) { - this._currentTileVM = this._roomListFilterMap.get(roomId); + this._currentTileVM = this._roomTileViewModels.get(roomId); this._currentTileVM?.open(); } } toggleGrid() { let url; - if (this._gridEnabled) { + if (this.gridEnabled) { url = this.urlRouter.disableGridUrl(); } else { url = this.urlRouter.enableGridUrl(); diff --git a/src/domain/session/leftpanel/RoomTileViewModel.js b/src/domain/session/leftpanel/RoomTileViewModel.js index 9b4260ae..a1e43d9f 100644 --- a/src/domain/session/leftpanel/RoomTileViewModel.js +++ b/src/domain/session/leftpanel/RoomTileViewModel.js @@ -25,9 +25,8 @@ function isSortedAsUnread(vm) { export class RoomTileViewModel extends ViewModel { constructor(options) { super(options); - const {room, emitOpen} = options; + const {room} = options; this._room = room; - this._emitOpen = emitOpen; this._isOpen = false; this._wasUnreadWhenOpening = false; this._hidden = false; @@ -57,7 +56,6 @@ export class RoomTileViewModel extends ViewModel { this._isOpen = true; this._wasUnreadWhenOpening = this._room.isUnread; this.emitChange("isOpen"); - this._emitOpen(this._room, this); } } diff --git a/src/main.js b/src/main.js index 6b302c34..4daeccf7 100644 --- a/src/main.js +++ b/src/main.js @@ -22,8 +22,7 @@ import {SessionContainer} from "./matrix/SessionContainer.js"; import {StorageFactory} from "./matrix/storage/idb/StorageFactory.js"; import {SessionInfoStorage} from "./matrix/sessioninfo/localstorage/SessionInfoStorage.js"; import {RootViewModel} from "./domain/RootViewModel.js"; -import {createNavigation} from "./domain/navigation/index.js"; -import {URLRouter} from "./domain/navigation/URLRouter.js"; +import {createNavigation, createRouter} from "./domain/navigation/index.js"; import {RootView} from "./ui/web/RootView.js"; import {Clock} from "./ui/web/dom/Clock.js"; import {History} from "./ui/web/dom/History.js"; @@ -119,8 +118,9 @@ export async function main(container, paths, legacyExtras) { } const navigation = createNavigation(); - const urlRouter = new URLRouter(new History(), navigation); + const urlRouter = createRouter({navigation, history: new History()}); urlRouter.attach(); + console.log("starting with navigation path", navigation.path); const vm = new RootViewModel({ createSessionContainer: () => { diff --git a/src/observable/map/MappedMap.js b/src/observable/map/MappedMap.js index 28b7d1e8..48a1d1ad 100644 --- a/src/observable/map/MappedMap.js +++ b/src/observable/map/MappedMap.js @@ -84,4 +84,8 @@ export class MappedMap extends BaseObservableMap { get size() { return this._mappedValues.size; } + + get(key) { + return this._mappedValues.get(key); + } } diff --git a/src/ui/web/session/leftpanel/LeftPanelView.js b/src/ui/web/session/leftpanel/LeftPanelView.js index 3eeaaada..efd97703 100644 --- a/src/ui/web/session/leftpanel/LeftPanelView.js +++ b/src/ui/web/session/leftpanel/LeftPanelView.js @@ -83,7 +83,6 @@ export class LeftPanelView extends TemplateView { { className: "RoomList", list: vm.roomList, - onItemClick: (roomTile, event) => roomTile.clicked(event) }, roomTileVM => new RoomTileView(roomTileVM) )) From db5c98a706ece29a9909b89f421e052d83738763 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Tue, 13 Oct 2020 13:10:27 +0200 Subject: [PATCH 32/56] support reassigning to null from untrack --- src/domain/ViewModel.js | 1 + src/utils/Disposables.js | 1 + 2 files changed, 2 insertions(+) diff --git a/src/domain/ViewModel.js b/src/domain/ViewModel.js index dc7b1f36..cccdb847 100644 --- a/src/domain/ViewModel.js +++ b/src/domain/ViewModel.js @@ -45,6 +45,7 @@ export class ViewModel extends EventEmitter { if (this.disposables) { return this.disposables.untrack(disposable); } + return null; } dispose() { diff --git a/src/utils/Disposables.js b/src/utils/Disposables.js index f682bc08..8bb24843 100644 --- a/src/utils/Disposables.js +++ b/src/utils/Disposables.js @@ -47,6 +47,7 @@ export class Disposables { if (idx >= 0) { this._disposables.splice(idx, 1); } + return null; } dispose() { From c8dd7b42e717688073213502d1eafe4892b9e77d Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Tue, 13 Oct 2020 13:10:35 +0200 Subject: [PATCH 33/56] encountered this very unlikely race while testing, so prevent it --- src/matrix/room/timeline/Timeline.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/matrix/room/timeline/Timeline.js b/src/matrix/room/timeline/Timeline.js index 6247b4c7..8cad17a1 100644 --- a/src/matrix/room/timeline/Timeline.js +++ b/src/matrix/room/timeline/Timeline.js @@ -75,6 +75,9 @@ export class Timeline { // tries to prepend `amount` entries to the `entries` list. async loadAtTop(amount) { + if (this._disposables.isDisposed) { + return; + } const firstEventEntry = this._remoteEntries.array.find(e => !!e.eventType); if (!firstEventEntry) { return; From 77a58041eb54f21cd2e350a32ed435726fdd5447 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Tue, 13 Oct 2020 13:11:19 +0200 Subject: [PATCH 34/56] clean-up room grid initialization with vm transfering also handle duplicate rooms, and add unit tests for grid vm --- src/domain/session/RoomGridViewModel.js | 261 ++++++++++++++++++++---- src/domain/session/SessionViewModel.js | 16 +- 2 files changed, 221 insertions(+), 56 deletions(-) diff --git a/src/domain/session/RoomGridViewModel.js b/src/domain/session/RoomGridViewModel.js index d8a1f90a..9e8880e4 100644 --- a/src/domain/session/RoomGridViewModel.js +++ b/src/domain/session/RoomGridViewModel.js @@ -16,6 +16,16 @@ limitations under the License. import {ViewModel} from "../ViewModel.js"; +function dedupeSparse(roomIds) { + return roomIds.map((id, idx) => { + if (roomIds.slice(0, idx).includes(id)) { + return undefined; + } else { + return id; + } + }); +} + export class RoomGridViewModel extends ViewModel { constructor(options) { super(options); @@ -25,14 +35,7 @@ export class RoomGridViewModel extends ViewModel { this._createRoomViewModel = options.createRoomViewModel; this._selectedIndex = 0; - this._viewModels = (options.roomIds || []).map(roomId => { - if (roomId) { - const vm = this._createRoomViewModel(roomId); - if (vm) { - return this.track(vm); - } - } - }); + this._viewModels = []; this._setupNavigation(); } @@ -50,15 +53,14 @@ export class RoomGridViewModel extends ViewModel { const focusedRoom = this.navigation.observe("room"); this.track(focusedRoom.subscribe(roomId => { if (roomId) { - this._openRoom(roomId); + // as the room will be in the "rooms" observable + // (monitored by the parent vm) as well, + // we only change the focus here and trust + // setRoomIds to have created the vm already + this._setFocusRoom(roomId); } })); - if (focusedRoom.get()) { - const index = this._viewModels.findIndex(vm => vm && vm.id === focusedRoom.get()); - if (index >= 0) { - this._selectedIndex = index; - } - } + // initial focus for a room is set by initializeRoomIdsAndTransferVM } roomViewModelAt(i) { @@ -93,18 +95,42 @@ export class RoomGridViewModel extends ViewModel { this.urlRouter.history.pushUrl(url); } + /** called from SessionViewModel */ + initializeRoomIdsAndTransferVM(roomIds, existingRoomVM) { + roomIds = dedupeSparse(roomIds); + let transfered = false; + if (existingRoomVM) { + const index = roomIds.indexOf(existingRoomVM.id); + if (index !== -1) { + this._viewModels[index] = this.track(existingRoomVM); + transfered = true; + } + } + this.setRoomIds(roomIds); + // now all view models exist, set the focus to the selected room + const focusedRoom = this.navigation.path.get("room"); + if (focusedRoom) { + const index = this._viewModels.findIndex(vm => vm && vm.id === focusedRoom.value); + if (index !== -1) { + this._selectedIndex = index; + } + } + return transfered; + } + /** called from SessionViewModel */ setRoomIds(roomIds) { + roomIds = dedupeSparse(roomIds); let changed = false; const len = this._height * this._width; for (let i = 0; i < len; i += 1) { const newId = roomIds[i]; const vm = this._viewModels[i]; - if (newId && !vm) { - this._viewModels[i] = this.track(this._createRoomViewModel(newId)); - changed = true; - } else if (newId !== vm?.id) { - this._viewModels[i] = this.disposeTracked(this._viewModels[i]); + // did anything change? + if ((!vm && newId) || (vm && vm.id !== newId)) { + if (vm) { + this._viewModels[i] = this.disposeTracked(vm); + } if (newId) { this._viewModels[i] = this.track(this._createRoomViewModel(newId)); } @@ -114,18 +140,12 @@ export class RoomGridViewModel extends ViewModel { if (changed) { this.emitChange(); } - } - - /** called from SessionViewModel */ - transferRoomViewModel(index, roomVM) { - const oldVM = this._viewModels[index]; - this.disposeTracked(oldVM); - this._viewModels[index] = this.track(roomVM); + return changed; } /** called from SessionViewModel */ releaseRoomViewModel(roomId) { - const index = this._viewModels.findIndex(vm => vm.id === roomId); + const index = this._viewModels.findIndex(vm => vm && vm.id === roomId); if (index !== -1) { const vm = this._viewModels[index]; this.untrack(vm); @@ -141,27 +161,180 @@ export class RoomGridViewModel extends ViewModel { this._selectedIndex = idx; const vm = this._viewModels[this._selectedIndex]; vm?.focus(); - this.emitChange("focusedIndex"); + this.emitChange("focusIndex"); } _setFocusRoom(roomId) { - const index = this._viewModels.findIndex(vm => vm.id === roomId); + const index = this._viewModels.findIndex(vm => vm?.id === roomId); if (index >= 0) { this._setFocusIndex(index); - return true; - } - return false; - } - - _openRoom(roomId) { - if (!this._setFocusRoom(roomId)) { - // replace vm at focused index - const vm = this._viewModels[this._selectedIndex]; - if (vm) { - this.disposeTracked(vm); - } - this._viewModels[this._selectedIndex] = this.track(this._createRoomViewModel(roomId)); - this.emitChange(); } } } + +import {createNavigation} from "../navigation/index.js"; +export function tests() { + class RoomVMMock { + constructor(id) { + this.id = id; + this.disposed = false; + this.focused = false; + } + dispose() { + this.disposed = true; + } + focus() { + this.focused = true; + } + } + + function createNavigationForRoom(rooms, room) { + const navigation = createNavigation(); + navigation.applyPath(navigation.pathFrom([ + navigation.segment("session", "1"), + navigation.segment("rooms", rooms), + navigation.segment("room", room), + ])); + return navigation; + } + + function createNavigationForEmptyTile(rooms, idx) { + const navigation = createNavigation(); + navigation.applyPath(navigation.pathFrom([ + navigation.segment("session", "1"), + navigation.segment("rooms", rooms), + navigation.segment("empty-grid-tile", idx), + ])); + return navigation; + } + + return { + "initialize with duplicate set of rooms": assert => { + const navigation = createNavigationForRoom(["c", "a", "b", undefined, "a"], "a"); + const gridVM = new RoomGridViewModel({ + createRoomViewModel: id => new RoomVMMock(id), + navigation, + width: 3, + height: 2, + }); + gridVM.initializeRoomIdsAndTransferVM(navigation.path.get("rooms").value); + assert.equal(gridVM.focusIndex, 1); + assert.equal(gridVM.roomViewModelAt(0).id, "c"); + assert.equal(gridVM.roomViewModelAt(1).id, "a"); + assert.equal(gridVM.roomViewModelAt(2).id, "b"); + assert.equal(gridVM.roomViewModelAt(3), undefined); + assert.equal(gridVM.roomViewModelAt(4), undefined); + assert.equal(gridVM.roomViewModelAt(5), undefined); + }, + "transfer room view model": assert => { + const navigation = createNavigationForRoom(["a"], "a"); + const gridVM = new RoomGridViewModel({ + createRoomViewModel: () => assert.fail("no vms should be created"), + navigation, + width: 3, + height: 2, + }); + const existingRoomVM = new RoomVMMock("a"); + const transfered = gridVM.initializeRoomIdsAndTransferVM(navigation.path.get("rooms").value, existingRoomVM); + assert.equal(transfered, true); + assert.equal(gridVM.focusIndex, 0); + assert.equal(gridVM.roomViewModelAt(0).id, "a"); + }, + "reject transfer for non-matching room view model": assert => { + const navigation = createNavigationForRoom(["a"], "a"); + const gridVM = new RoomGridViewModel({ + createRoomViewModel: id => new RoomVMMock(id), + navigation, + width: 3, + height: 2, + }); + const existingRoomVM = new RoomVMMock("f"); + const transfered = gridVM.initializeRoomIdsAndTransferVM(navigation.path.get("rooms").value, existingRoomVM); + assert.equal(transfered, false); + assert.equal(gridVM.focusIndex, 0); + assert.equal(gridVM.roomViewModelAt(0).id, "a"); + }, + "created & released room view model is not disposed": assert => { + const navigation = createNavigationForRoom(["a"], "a"); + const gridVM = new RoomGridViewModel({ + createRoomViewModel: id => new RoomVMMock(id), + navigation, + width: 3, + height: 2, + }); + const transfered = gridVM.initializeRoomIdsAndTransferVM(navigation.path.get("rooms").value); + assert.equal(transfered, false); + const releasedVM = gridVM.releaseRoomViewModel("a"); + gridVM.dispose(); + assert.equal(releasedVM.disposed, false); + }, + "transfered & released room view model is not disposed": assert => { + const navigation = createNavigationForRoom([undefined, "a"], "a"); + const gridVM = new RoomGridViewModel({ + createRoomViewModel: () => assert.fail("no vms should be created"), + navigation, + width: 3, + height: 2, + }); + const existingRoomVM = new RoomVMMock("a"); + const transfered = gridVM.initializeRoomIdsAndTransferVM(navigation.path.get("rooms").value, existingRoomVM); + assert.equal(transfered, true); + const releasedVM = gridVM.releaseRoomViewModel("a"); + gridVM.dispose(); + assert.equal(releasedVM.disposed, false); + }, + "try release non-existing room view model is": assert => { + const navigation = createNavigationForEmptyTile([undefined, "b"], 3); + const gridVM = new RoomGridViewModel({ + createRoomViewModel: id => new RoomVMMock(id), + navigation, + width: 3, + height: 2, + }); + gridVM.initializeRoomIdsAndTransferVM(navigation.path.get("rooms").value); + const releasedVM = gridVM.releaseRoomViewModel("c"); + assert(!releasedVM); + }, + "initial focus is set to empty tile": assert => { + const navigation = createNavigationForEmptyTile(["a"], 1); + const gridVM = new RoomGridViewModel({ + createRoomViewModel: id => new RoomVMMock(id), + navigation, + width: 3, + height: 2, + }); + gridVM.initializeRoomIdsAndTransferVM(navigation.path.get("rooms").value); + assert.equal(gridVM.focusIndex, 1); + assert.equal(gridVM.roomViewModelAt(0).id, "a"); + }, + "change room ids after creation": assert => { + const navigation = createNavigationForRoom(["a", "b"], "a"); + const gridVM = new RoomGridViewModel({ + createRoomViewModel: id => new RoomVMMock(id), + navigation, + width: 3, + height: 2, + }); + navigation.observe("rooms").subscribe(roomIds => { + gridVM.setRoomIds(roomIds); + }); + gridVM.initializeRoomIdsAndTransferVM(navigation.path.get("rooms").value); + const oldA = gridVM.roomViewModelAt(0); + const oldB = gridVM.roomViewModelAt(1); + assert.equal(oldA.id, "a"); + assert.equal(oldB.id, "b"); + navigation.applyPath(navigation.path + .with(navigation.segment("rooms", ["b", "c", "b"])) + .with(navigation.segment("room", "c")) + ); + assert.equal(oldA.disposed, true); + assert.equal(oldB.disposed, true); + assert.equal(gridVM.focusIndex, 1); + assert.equal(gridVM.roomViewModelAt(0).id, "b"); + assert.equal(gridVM.roomViewModelAt(0).disposed, false); + assert.equal(gridVM.roomViewModelAt(1).id, "c"); + assert.equal(gridVM.roomViewModelAt(1).focused, true); + assert.equal(gridVM.roomViewModelAt(2), undefined); + } + }; +} diff --git a/src/domain/session/SessionViewModel.js b/src/domain/session/SessionViewModel.js index 5269b62b..38b72968 100644 --- a/src/domain/session/SessionViewModel.js +++ b/src/domain/session/SessionViewModel.js @@ -99,23 +99,15 @@ export class SessionViewModel extends ViewModel { const currentRoomId = this.navigation.path.get("room"); if (roomIds) { if (!this._gridViewModel) { - const vm = this._currentRoomViewModel; - const index = roomIds.indexOf(vm.id); - const shouldTransfer = vm && index !== -1; - if (shouldTransfer) { - roomIds = roomIds.slice(); - roomIds[index] = undefined; - } this._gridViewModel = this.track(new RoomGridViewModel(this.childOptions({ width: 3, height: 2, createRoomViewModel: roomId => this._createRoomViewModel(roomId), - roomIds: roomIds }))); - if (shouldTransfer) { - this.untrack(vm); - this._gridViewModel.transferRoomViewModel(index, vm); - this._currentRoomViewModel = null; + if (this._gridViewModel.initializeRoomIdsAndTransferVM(roomIds, this._currentRoomViewModel)) { + this._currentRoomViewModel = this.untrack(this._currentRoomViewModel); + } else if (this._currentRoomViewModel) { + this._currentRoomViewModel = this.disposeTracked(this._currentRoomViewModel); } } else { this._gridViewModel.setRoomIds(roomIds); From 379d65edf1438fdf251252972c9ef756e6960e7a Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Tue, 13 Oct 2020 13:12:30 +0200 Subject: [PATCH 35/56] also create new vm when coming for grid if transfering vm fails --- src/domain/session/SessionViewModel.js | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/domain/session/SessionViewModel.js b/src/domain/session/SessionViewModel.js index 38b72968..0d3a94d5 100644 --- a/src/domain/session/SessionViewModel.js +++ b/src/domain/session/SessionViewModel.js @@ -115,7 +115,11 @@ export class SessionViewModel extends ViewModel { } else if (this._gridViewModel && !roomIds) { if (currentRoomId) { const vm = this._gridViewModel.releaseRoomViewModel(currentRoomId.value); - this._currentRoomViewModel = this.track(vm); + if (vm) { + this._currentRoomViewModel = this.track(vm); + } else { + this._currentRoomViewModel = this.track(this._createRoomViewModel(currentRoomId.value)); + } } this._gridViewModel = this.disposeTracked(this._gridViewModel); } From a360d0a2b9487da0429c7489787cbf4ff4e4d302 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Tue, 13 Oct 2020 13:12:49 +0200 Subject: [PATCH 36/56] also unset room vm if moving away from room --- src/domain/session/SessionViewModel.js | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/domain/session/SessionViewModel.js b/src/domain/session/SessionViewModel.js index 0d3a94d5..2e1ec2ae 100644 --- a/src/domain/session/SessionViewModel.js +++ b/src/domain/session/SessionViewModel.js @@ -142,6 +142,13 @@ export class SessionViewModel extends ViewModel { } _openRoom(roomId) { + if (!roomId) { + if (this._currentRoomViewModel) { + this._currentRoomViewModel = this.disposeTracked(this._currentRoomViewModel); + this.emitChange("currentRoom"); + } + return; + } // already open? if (this._currentRoomViewModel?.id === roomId) { return; From 7b13e45e19c05d212ee4eaa7a0f31f011bbfc11e Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Tue, 13 Oct 2020 13:13:15 +0200 Subject: [PATCH 37/56] fix creating the rooms segment --- src/domain/navigation/index.js | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/src/domain/navigation/index.js b/src/domain/navigation/index.js index 1a635293..eca2c95f 100644 --- a/src/domain/navigation/index.js +++ b/src/domain/navigation/index.js @@ -42,19 +42,21 @@ function allowsChild(parent, child) { } function roomsSegmentWithRoom(rooms, roomId, path) { - const room = path.get("room"); - let index = 0; - if (room) { - index = rooms.value.indexOf(room.value); - } else { + if(!rooms.value.includes(roomId)) { const emptyGridTile = path.get("empty-grid-tile"); + const oldRoom = path.get("room"); + let index = 0; if (emptyGridTile) { index = emptyGridTile.value; + } else if (oldRoom) { + index = rooms.value.indexOf(oldRoom.value); } - } - const newRooms = rooms.value.slice(); - newRooms[index] = roomId; - return new Segment("rooms", newRooms); + const roomIds = rooms.value.slice(); + roomIds[index] = roomId; + return new Segment("rooms", roomIds); + } else { + return rooms; + } } export function parseUrlPath(urlPath, currentNavPath) { From d9c8a6339e0e939179da067ccdb742d754fca204 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Tue, 13 Oct 2020 13:13:35 +0200 Subject: [PATCH 38/56] support empty strings for rooms segment --- src/domain/navigation/index.js | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/src/domain/navigation/index.js b/src/domain/navigation/index.js index eca2c95f..a1e65d4c 100644 --- a/src/domain/navigation/index.js +++ b/src/domain/navigation/index.js @@ -69,7 +69,7 @@ export function parseUrlPath(urlPath, currentNavPath) { const type = next.value; if (type === "rooms") { const roomsValue = iterator.next().value; - if (!roomsValue) { break; } + if (roomsValue === undefined) { break; } const roomIds = roomsValue.split(","); segments.push(new Segment(type, roomIds)); const selectedIndex = parseInt(iterator.next().value || "0", 10); @@ -168,6 +168,26 @@ export function tests() { assert.equal(segments[2].type, "room"); assert.equal(segments[2].value, "b"); }, + "parse empty grid url": assert => { + const segments = parseUrlPath("/session/1/rooms/"); + assert.equal(segments.length, 3); + assert.equal(segments[0].type, "session"); + assert.equal(segments[0].value, "1"); + assert.equal(segments[1].type, "rooms"); + assert.deepEqual(segments[1].value, [""]); + assert.equal(segments[2].type, "empty-grid-tile"); + assert.equal(segments[2].value, 0); + }, + "parse empty grid url with focus": assert => { + const segments = parseUrlPath("/session/1/rooms//1"); + assert.equal(segments.length, 3); + assert.equal(segments[0].type, "session"); + assert.equal(segments[0].value, "1"); + assert.equal(segments[1].type, "rooms"); + assert.deepEqual(segments[1].value, [""]); + assert.equal(segments[2].type, "empty-grid-tile"); + assert.equal(segments[2].value, 1); + }, "parse open-room action replacing the current focused room": assert => { const nav = new Navigation(allowsChild); const path = nav.pathFrom([ From 1780f334eabd4e863e017696ab8cbce86be2365c Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Tue, 13 Oct 2020 13:13:47 +0200 Subject: [PATCH 39/56] more tests --- src/domain/navigation/index.js | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/src/domain/navigation/index.js b/src/domain/navigation/index.js index a1e65d4c..ec593122 100644 --- a/src/domain/navigation/index.js +++ b/src/domain/navigation/index.js @@ -204,6 +204,22 @@ export function tests() { assert.equal(segments[2].type, "room"); assert.equal(segments[2].value, "d"); }, + "parse open-room action changing focus to an existing room": assert => { + const nav = new Navigation(allowsChild); + const path = nav.pathFrom([ + new Segment("session", 1), + new Segment("rooms", ["a", "b", "c"]), + new Segment("room", "b") + ]); + const segments = parseUrlPath("/session/1/open-room/a", path); + assert.equal(segments.length, 3); + assert.equal(segments[0].type, "session"); + assert.equal(segments[0].value, "1"); + assert.equal(segments[1].type, "rooms"); + assert.deepEqual(segments[1].value, ["a", "b", "c"]); + assert.equal(segments[2].type, "room"); + assert.equal(segments[2].value, "a"); + }, "parse open-room action setting a room in an empty tile": assert => { const nav = new Navigation(allowsChild); const path = nav.pathFrom([ From 7f3e0f237b71a631c8aa3cd923bafc7245cd98a9 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Tue, 13 Oct 2020 14:43:31 +0200 Subject: [PATCH 40/56] also do redirect after initial navigation --- src/domain/RootViewModel.js | 36 ++++++++++++++------------ src/domain/session/SessionViewModel.js | 4 +++ 2 files changed, 23 insertions(+), 17 deletions(-) diff --git a/src/domain/RootViewModel.js b/src/domain/RootViewModel.js index a5d0b0f2..6e3d86e9 100644 --- a/src/domain/RootViewModel.js +++ b/src/domain/RootViewModel.js @@ -38,7 +38,25 @@ export class RootViewModel extends ViewModel { async load() { this.track(this.navigation.observe("login").subscribe(() => this._applyNavigation())); this.track(this.navigation.observe("session").subscribe(() => this._applyNavigation())); - if (!this._applyNavigation()) { + this._applyNavigation(); + } + + async _applyNavigation() { + const isLogin = this.navigation.observe("login").get(); + const sessionId = this.navigation.observe("session").get(); + if (isLogin) { + if (this.activeSection !== "login") { + this._showLogin(); + } + } else if (sessionId === true) { + if (this.activeSection !== "picker") { + this._showPicker(); + } + } else if (sessionId) { + if (!this._sessionViewModel || this._sessionViewModel.id !== sessionId) { + this._showSessionLoader(sessionId); + } + } else { try { // redirect depending on what sessions are already present const sessionInfos = await this._sessionInfoStorage.getAll(); @@ -51,22 +69,6 @@ export class RootViewModel extends ViewModel { } } - _applyNavigation() { - const isLogin = this.navigation.observe("login").get(); - const sessionId = this.navigation.observe("session").get(); - if (isLogin) { - this._showLogin(); - return true; - } else if (sessionId === true) { - this._showPicker(); - return true; - } else if (sessionId) { - this._showSessionLoader(sessionId); - return true; - } - return false; - } - _urlForSessionInfos(sessionInfos) { if (sessionInfos.length === 0) { return this.urlRouter.urlForSegment("login"); diff --git a/src/domain/session/SessionViewModel.js b/src/domain/session/SessionViewModel.js index 2e1ec2ae..f0adb395 100644 --- a/src/domain/session/SessionViewModel.js +++ b/src/domain/session/SessionViewModel.js @@ -61,6 +61,10 @@ export class SessionViewModel extends ViewModel { } } + get id() { + return this._sessionContainer.sessionId; + } + start() { this._sessionStatusViewModel.start(); } From d1d675ed7ae1cdfc1ae9490a13ed0a2c93f7760e Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Tue, 13 Oct 2020 14:54:57 +0200 Subject: [PATCH 41/56] make navigation observables already return new value before they emit --- src/domain/navigation/Navigation.js | 97 ++++++++++++++++++++++++++--- 1 file changed, 87 insertions(+), 10 deletions(-) diff --git a/src/domain/navigation/Navigation.js b/src/domain/navigation/Navigation.js index 40ce1806..f7222ec2 100644 --- a/src/domain/navigation/Navigation.js +++ b/src/domain/navigation/Navigation.js @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {ObservableValue} from "../../observable/ObservableValue.js"; +import {BaseObservableValue} from "../../observable/ObservableValue.js"; export class Navigation { constructor(allowsChild) { @@ -28,6 +28,8 @@ export class Navigation { } applyPath(path) { + // Path is not exported, so you can only create a Path through Navigation, + // so we assume it respects the allowsChild rules const oldPath = this._path; this._path = path; // clear values not in the new path in reverse order of path @@ -35,26 +37,20 @@ export class Navigation { const segment = oldPath.segments[i]; if (!this._path.get(segment.type)) { const observable = this._observables.get(segment.type); - if (observable) { - observable.set(undefined); - } + observable?.emitIfChanged(); } } // change values in order of path for (const segment of this._path.segments) { const observable = this._observables.get(segment.type); - if (observable) { - if (!segmentValueEqual(segment?.value, observable.get())) { - observable.set(segment.value); - } - } + observable?.emitIfChanged(); } } observe(type) { let observable = this._observables.get(type); if (!observable) { - observable = new ObservableValue(this._path.get(type)?.value); + observable = new SegmentObservable(this, type); this._observables.set(type, observable); } return observable; @@ -142,3 +138,84 @@ class Path { return this._segments; } } + +/** + * custom observable so it always returns what is in navigation.path, even if we haven't emitted the change yet. + * This ensures that observers of a segment can also read the most recent value of other segments. + */ +class SegmentObservable extends BaseObservableValue { + constructor(navigation, type) { + super(); + this._navigation = navigation; + this._type = type; + this._lastSetValue = navigation.path.get(type)?.value; + } + + get() { + const path = this._navigation.path; + const segment = path.get(this._type); + const value = segment?.value; + return value; + } + + emitIfChanged() { + const newValue = this.get(); + if (!segmentValueEqual(newValue, this._lastSetValue)) { + this._lastSetValue = newValue; + this.emit(newValue); + } + } +} + +export function tests() { + + function createMockNavigation() { + return new Navigation((parent, {type}) => { + switch (parent?.type) { + case undefined: + return type === "1" || "2"; + case "1": + return type === "1.1"; + case "1.1": + return type === "1.1.1"; + case "2": + return type === "2.1" || "2.2"; + default: + return false; + } + }); + } + + function observeTypes(nav, types) { + const changes = []; + for (const type of types) { + nav.observe(type).subscribe(value => { + changes.push({type, value}); + }); + } + return changes; + } + + return { + "applying a path emits an event on the observable": assert => { + const nav = createMockNavigation(); + const path = nav.pathFrom([ + new Segment("2", 7), + new Segment("2.2", 8), + ]); + assert.equal(path.segments.length, 2); + let changes = observeTypes(nav, ["2", "2.2"]); + nav.applyPath(path); + assert.equal(changes.length, 2); + assert.equal(changes[0].type, "2"); + assert.equal(changes[0].value, 7); + assert.equal(changes[1].type, "2.2"); + assert.equal(changes[1].value, 8); + }, + "path.get": assert => { + const path = new Path([new Segment("foo", 5), new Segment("bar", 6)], () => true); + assert.equal(path.get("foo").value, 5); + assert.equal(path.get("bar").value, 6); + } + }; +} From a14795e3f8fabdf6e0c17f28be871c2515ae0a17 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Tue, 13 Oct 2020 15:05:11 +0200 Subject: [PATCH 42/56] unused --- src/domain/SessionPickerViewModel.js | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/domain/SessionPickerViewModel.js b/src/domain/SessionPickerViewModel.js index 79f97578..ca9430b3 100644 --- a/src/domain/SessionPickerViewModel.js +++ b/src/domain/SessionPickerViewModel.js @@ -130,10 +130,9 @@ class SessionItemViewModel extends ViewModel { export class SessionPickerViewModel extends ViewModel { constructor(options) { super(options); - const {storageFactory, sessionInfoStorage, sessionInfoCallback} = options; + const {storageFactory, sessionInfoStorage} = options; this._storageFactory = storageFactory; this._sessionInfoStorage = sessionInfoStorage; - this._sessionInfoCallback = sessionInfoCallback; this._sessions = new SortedArray((s1, s2) => s1.id.localeCompare(s2.id)); this._loadViewModel = null; this._error = null; From 081820d8fb638bb4e86a6273fadd86bd3881f5f2 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Tue, 13 Oct 2020 15:14:07 +0200 Subject: [PATCH 43/56] also update view when going to an not-found room --- src/domain/session/SessionViewModel.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/domain/session/SessionViewModel.js b/src/domain/session/SessionViewModel.js index f0adb395..1d305b20 100644 --- a/src/domain/session/SessionViewModel.js +++ b/src/domain/session/SessionViewModel.js @@ -161,7 +161,7 @@ export class SessionViewModel extends ViewModel { const roomVM = this._createRoomViewModel(roomId); if (roomVM) { this._currentRoomViewModel = this.track(roomVM); - this.emitChange("currentRoom"); } + this.emitChange("currentRoom"); } } From afbfb0c82cc1293fa022e9186b3cc4f9555b7e55 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Tue, 13 Oct 2020 15:18:13 +0200 Subject: [PATCH 44/56] anticipate non-found rooms --- src/domain/session/RoomGridViewModel.js | 5 ++++- src/domain/session/SessionViewModel.js | 5 ++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/src/domain/session/RoomGridViewModel.js b/src/domain/session/RoomGridViewModel.js index 9e8880e4..04e810fb 100644 --- a/src/domain/session/RoomGridViewModel.js +++ b/src/domain/session/RoomGridViewModel.js @@ -132,7 +132,10 @@ export class RoomGridViewModel extends ViewModel { this._viewModels[i] = this.disposeTracked(vm); } if (newId) { - this._viewModels[i] = this.track(this._createRoomViewModel(newId)); + const newVM = this._createRoomViewModel(newId); + if (newVM) { + this._viewModels[i] = this.track(newVM); + } } changed = true; } diff --git a/src/domain/session/SessionViewModel.js b/src/domain/session/SessionViewModel.js index 1d305b20..07fc3792 100644 --- a/src/domain/session/SessionViewModel.js +++ b/src/domain/session/SessionViewModel.js @@ -122,7 +122,10 @@ export class SessionViewModel extends ViewModel { if (vm) { this._currentRoomViewModel = this.track(vm); } else { - this._currentRoomViewModel = this.track(this._createRoomViewModel(currentRoomId.value)); + const newVM = this._createRoomViewModel(currentRoomId.value); + if (newVM) { + this._currentRoomViewModel = this.track(newVM); + } } } this._gridViewModel = this.disposeTracked(this._gridViewModel); From ab354e9f38d95abcf6b01d078cc51bb70e80343f Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Tue, 13 Oct 2020 15:19:54 +0200 Subject: [PATCH 45/56] update prop type --- src/domain/session/SessionViewModel.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/domain/session/SessionViewModel.js b/src/domain/session/SessionViewModel.js index 07fc3792..38a36e8c 100644 --- a/src/domain/session/SessionViewModel.js +++ b/src/domain/session/SessionViewModel.js @@ -131,7 +131,7 @@ export class SessionViewModel extends ViewModel { this._gridViewModel = this.disposeTracked(this._gridViewModel); } if (changed) { - this.emitChange("middlePanelViewType"); + this.emitChange("activeSection"); } } From 2333a959a28f4760446cf6a72ce44493bcd2da38 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Tue, 13 Oct 2020 17:10:28 +0200 Subject: [PATCH 46/56] actually apply the /session/ url after login! --- src/domain/RootViewModel.js | 1 + 1 file changed, 1 insertion(+) diff --git a/src/domain/RootViewModel.js b/src/domain/RootViewModel.js index 6e3d86e9..f81591db 100644 --- a/src/domain/RootViewModel.js +++ b/src/domain/RootViewModel.js @@ -100,6 +100,7 @@ export class RootViewModel extends ViewModel { createSessionContainer: this._createSessionContainer, ready: sessionContainer => { const url = this.urlRouter.urlForSegment("session", sessionContainer.sessionId); + this.urlRouter.applyUrl(url); this.urlRouter.history.replaceUrl(url); this._showSession(sessionContainer); }, From 2b2392f375faf6576745b92780d27b5b3e32fc7a Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Tue, 13 Oct 2020 17:10:45 +0200 Subject: [PATCH 47/56] prototype for hash changes in IE11 --- prototypes/ie11-hashchange.html | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 prototypes/ie11-hashchange.html diff --git a/prototypes/ie11-hashchange.html b/prototypes/ie11-hashchange.html new file mode 100644 index 00000000..cd1dc0db --- /dev/null +++ b/prototypes/ie11-hashchange.html @@ -0,0 +1,24 @@ + + + + + + +
    + +

    + foo + bar + baz +

    + + From 3d327b087540d89f82e9eeefb9d3dc9feb754c42 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Wed, 14 Oct 2020 10:20:00 +0200 Subject: [PATCH 48/56] also open the initial room in the room list --- src/domain/session/leftpanel/LeftPanelViewModel.js | 13 +++++++++++-- src/domain/session/leftpanel/RoomTileViewModel.js | 3 +++ 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/src/domain/session/leftpanel/LeftPanelViewModel.js b/src/domain/session/leftpanel/LeftPanelViewModel.js index 15c30218..8dc3d224 100644 --- a/src/domain/session/leftpanel/LeftPanelViewModel.js +++ b/src/domain/session/leftpanel/LeftPanelViewModel.js @@ -25,10 +25,20 @@ export class LeftPanelViewModel extends ViewModel { super(options); const {rooms} = options; this._roomTileViewModels = rooms.mapValues((room, emitChange) => { - return new RoomTileViewModel(this.childOptions({ + const isOpen = this.navigation.path.get("room")?.value === room.id; + const vm = new RoomTileViewModel(this.childOptions({ + isOpen, room, emitChange })); + // need to also update the current vm here as + // we can't call `_open` from the ctor as the map + // is only populated when the view subscribes. + if (isOpen) { + this._currentTileVM?.close(); + this._currentTileVM = vm; + } + return vm; }); this._roomListFilterMap = new ApplyMap(this._roomTileViewModels); this._roomList = this._roomListFilterMap.sortValues((a, b) => a.compare(b)); @@ -39,7 +49,6 @@ export class LeftPanelViewModel extends ViewModel { _setupNavigation() { const roomObservable = this.navigation.observe("room"); this.track(roomObservable.subscribe(roomId => this._open(roomId))); - this._open(roomObservable.get()); const gridObservable = this.navigation.observe("rooms"); this.gridEnabled = !!gridObservable.get(); diff --git a/src/domain/session/leftpanel/RoomTileViewModel.js b/src/domain/session/leftpanel/RoomTileViewModel.js index a1e43d9f..09cb6372 100644 --- a/src/domain/session/leftpanel/RoomTileViewModel.js +++ b/src/domain/session/leftpanel/RoomTileViewModel.js @@ -31,6 +31,9 @@ export class RoomTileViewModel extends ViewModel { this._wasUnreadWhenOpening = false; this._hidden = false; this._url = this.urlRouter.openRoomActionUrl(this._room.id); + if (options.isOpen) { + this.open(); + } } get hidden() { From 5fe479e8a958fb73a0adc2a3ebbda79650f56501 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Wed, 14 Oct 2020 10:20:20 +0200 Subject: [PATCH 49/56] fix styling for room list items --- src/ui/web/css/left-panel.css | 2 +- src/ui/web/css/themes/element/theme.css | 16 ++++++++++++---- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/src/ui/web/css/left-panel.css b/src/ui/web/css/left-panel.css index 3b49ff51..f00c1572 100644 --- a/src/ui/web/css/left-panel.css +++ b/src/ui/web/css/left-panel.css @@ -40,7 +40,7 @@ limitations under the License. overscroll-behavior: contain; } -.RoomList li { +.RoomList > li > a { display: flex; align-items: center; } diff --git a/src/ui/web/css/themes/element/theme.css b/src/ui/web/css/themes/element/theme.css index cd3ed8c6..b4a1b546 100644 --- a/src/ui/web/css/themes/element/theme.css +++ b/src/ui/web/css/themes/element/theme.css @@ -235,15 +235,23 @@ button.utility.grid.on { margin-right: -8px; } -.RoomList li { +.RoomList > li { margin: 0; - padding-right: 8px; + padding: 4px 8px 4px 0; + /* vertical align */ + align-items: center; +} + +.RoomList > li > a { + text-decoration: none; /* vertical align */ align-items: center; } .RoomList li:not(:first-child) { - margin-top: 12px; + /* space between items is 12px but we take 4px padding + on each side for the background of the active state*/ + margin-top: 4px; } .RoomList li.active { @@ -251,7 +259,7 @@ button.utility.grid.on { border-radius: 5px; } -.RoomList li > * { +.RoomList li > a > * { margin-right: 8px; } From 7e477b3c8dee5c8c1b71c2a7264368d5b7218bba Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Wed, 14 Oct 2020 10:33:48 +0200 Subject: [PATCH 50/56] rename button.styled to .button-action so it can also be used for links --- src/ui/web/css/themes/element/theme.css | 21 ++++++++++++++------- src/ui/web/login/LoginView.js | 4 ++-- src/ui/web/login/SessionPickerView.js | 4 ++-- 3 files changed, 18 insertions(+), 11 deletions(-) diff --git a/src/ui/web/css/themes/element/theme.css b/src/ui/web/css/themes/element/theme.css index b4a1b546..c87b1f72 100644 --- a/src/ui/web/css/themes/element/theme.css +++ b/src/ui/web/css/themes/element/theme.css @@ -71,7 +71,7 @@ limitations under the License. margin-right: 0px; } -.button-row button { +.button-row .button-action { margin: 10px 0; flex: 1 0 auto; } @@ -92,32 +92,39 @@ limitations under the License. display: block; } -button.styled.secondary { +a.button-action { + text-decoration: none; + text-align: center; + display: block; +} + +.button-action.secondary { color: #03B381; } -button.styled.primary { +.button-action.primary { background-color: #03B381; border-radius: 8px; color: white; } -button.styled.primary.destructive { +.button-action.primary.destructive { background-color: #FF4B55; } -button.styled.secondary.destructive { +.button-action.secondary.destructive { color: #FF4B55; } -button.styled { +.button-action { border: none; padding: 10px; background: none; font-weight: 500; } -button.utility { +.button-utility { + cursor: pointer; width: 32px; height: 32px; background-position: center; diff --git a/src/ui/web/login/LoginView.js b/src/ui/web/login/LoginView.js index d1fc8930..e03eab6b 100644 --- a/src/ui/web/login/LoginView.js +++ b/src/ui/web/login/LoginView.js @@ -52,11 +52,11 @@ export class LoginView extends TemplateView { t.mapView(vm => vm.loadViewModel, loadViewModel => loadViewModel ? new SessionLoadStatusView(loadViewModel) : null), t.div({className: "button-row"}, [ t.a({ - className: "button styled secondary", + className: "button-action secondary", href: vm.cancelUrl }, [vm.i18n`Go Back`]), t.button({ - className: "styled primary", + className: "button-action primary", onClick: () => vm.login(username.value, password.value, homeserver.value), disabled }, vm.i18n`Log In`), diff --git a/src/ui/web/login/SessionPickerView.js b/src/ui/web/login/SessionPickerView.js index 4bb9b6b7..279135ac 100644 --- a/src/ui/web/login/SessionPickerView.js +++ b/src/ui/web/login/SessionPickerView.js @@ -110,11 +110,11 @@ export class SessionPickerView extends TemplateView { t.view(sessionList), t.div({className: "button-row"}, [ t.button({ - className: "styled secondary", + className: "button-action secondary", onClick: async () => vm.import(await selectFileAsText("application/json")) }, vm.i18n`Import a session`), t.a({ - className: "button styled primary", + className: "button-action primary", href: vm.cancelUrl }, vm.i18n`Sign In`) ]), From 692fa808e48469f86741621a8cf44a145e1e81a4 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Wed, 14 Oct 2020 10:34:11 +0200 Subject: [PATCH 51/56] make utility buttons also useable for links --- src/ui/web/css/themes/element/theme.css | 4 ++-- src/ui/web/session/leftpanel/LeftPanelView.js | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/ui/web/css/themes/element/theme.css b/src/ui/web/css/themes/element/theme.css index c87b1f72..e739a7d5 100644 --- a/src/ui/web/css/themes/element/theme.css +++ b/src/ui/web/css/themes/element/theme.css @@ -134,11 +134,11 @@ a.button-action { border-radius: 100%; } -button.utility.grid { +.button-utility.grid { background-image: url('icons/enable-grid.svg'); } -button.utility.grid.on { +.button-utility.grid.on { background-image: url('icons/disable-grid.svg'); } diff --git a/src/ui/web/session/leftpanel/LeftPanelView.js b/src/ui/web/session/leftpanel/LeftPanelView.js index efd97703..d2f38923 100644 --- a/src/ui/web/session/leftpanel/LeftPanelView.js +++ b/src/ui/web/session/leftpanel/LeftPanelView.js @@ -68,7 +68,7 @@ export class LeftPanelView extends TemplateView { t.button({ onClick: () => vm.toggleGrid(), className: { - utility: true, + "button-utility": true, grid: true, on: vm => vm.gridEnabled }, From 789a41c671621b7a3435a3f477884b4c884ab4ac Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Wed, 14 Oct 2020 10:34:25 +0200 Subject: [PATCH 52/56] fix styling for session info entries --- src/ui/web/css/themes/element/theme.css | 1 + 1 file changed, 1 insertion(+) diff --git a/src/ui/web/css/themes/element/theme.css b/src/ui/web/css/themes/element/theme.css index e739a7d5..98093652 100644 --- a/src/ui/web/css/themes/element/theme.css +++ b/src/ui/web/css/themes/element/theme.css @@ -327,6 +327,7 @@ a { } .SessionPickerView .session-info { + text-decoration: none; padding: 12px; border: 1px solid rgba(141, 151, 165, 0.15); border-radius: 8px; From b29b776e9fad939bedcd776116100be6a7b443c5 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Wed, 14 Oct 2020 11:25:49 +0200 Subject: [PATCH 53/56] fix olm session cache being leaked when closing session --- src/matrix/e2ee/RoomEncryption.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/matrix/e2ee/RoomEncryption.js b/src/matrix/e2ee/RoomEncryption.js index 69a93e57..bc17fa09 100644 --- a/src/matrix/e2ee/RoomEncryption.js +++ b/src/matrix/e2ee/RoomEncryption.js @@ -377,6 +377,8 @@ export class RoomEncryption { dispose() { this._disposed = true; + this._megolmBackfillCache.dispose(); + this._megolmSyncCache.dispose(); } } From fa8bec0b56dbec51c1af43898fca207141be6004 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Wed, 14 Oct 2020 11:26:10 +0200 Subject: [PATCH 54/56] fix room view model being leaked when closing session --- src/domain/session/room/RoomViewModel.js | 1 + 1 file changed, 1 insertion(+) diff --git a/src/domain/session/room/RoomViewModel.js b/src/domain/session/room/RoomViewModel.js index 7c8df7bc..985ad32c 100644 --- a/src/domain/session/room/RoomViewModel.js +++ b/src/domain/session/room/RoomViewModel.js @@ -76,6 +76,7 @@ export class RoomViewModel extends ViewModel { dispose() { super.dispose(); + this._room.off("change", this._onRoomChange); if (this._clearUnreadTimout) { this._clearUnreadTimout.abort(); this._clearUnreadTimout = null; From de83d7740b4a94988eac98cb6433cdb1571cd8f4 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Wed, 14 Oct 2020 11:26:39 +0200 Subject: [PATCH 55/56] replace custom error view with staticview --- src/ui/web/RootView.js | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/src/ui/web/RootView.js b/src/ui/web/RootView.js index 34ea1b2c..f60bb984 100644 --- a/src/ui/web/RootView.js +++ b/src/ui/web/RootView.js @@ -26,7 +26,12 @@ export class RootView extends TemplateView { return t.mapView(vm => vm.activeSection, activeSection => { switch (activeSection) { case "error": - return new StatusView({header: "Something went wrong", message: vm.errorText}); + return new StaticView(t => { + return t.div({className: "StatusView"}, [ + t.h1("Something went wrong"), + t.p(vm.errorText), + ]) + }); case "session": return new SessionView(vm.sessionViewModel); case "login": @@ -43,12 +48,3 @@ export class RootView extends TemplateView { }); } } - -class StatusView extends TemplateView { - render(t, vm) { - return t.div({className: "StatusView"}, [ - t.h1(vm.header), - t.p(vm.message), - ]); - } -} From 6ad7be87266885a215ececd373624eb0bb4cad03 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Wed, 14 Oct 2020 11:34:34 +0200 Subject: [PATCH 56/56] fix leaking left panel view model after closing session --- src/domain/session/SessionViewModel.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/domain/session/SessionViewModel.js b/src/domain/session/SessionViewModel.js index 38a36e8c..de110fa1 100644 --- a/src/domain/session/SessionViewModel.js +++ b/src/domain/session/SessionViewModel.js @@ -31,9 +31,9 @@ export class SessionViewModel extends ViewModel { reconnector: sessionContainer.reconnector, session: sessionContainer.session, }))); - this._leftPanelViewModel = new LeftPanelViewModel(this.childOptions({ + this._leftPanelViewModel = this.track(new LeftPanelViewModel(this.childOptions({ rooms: this._sessionContainer.session.rooms - })); + }))); this._currentRoomViewModel = null; this._gridViewModel = null; this._setupNavigation();