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 +

+ + diff --git a/src/domain/BrawlViewModel.js b/src/domain/BrawlViewModel.js deleted file mode 100644 index 4c3a97bb..00000000 --- a/src/domain/BrawlViewModel.js +++ /dev/null @@ -1,123 +0,0 @@ -/* -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 {SessionViewModel} from "./session/SessionViewModel.js"; -import {LoginViewModel} from "./LoginViewModel.js"; -import {SessionPickerViewModel} from "./SessionPickerViewModel.js"; -import {ViewModel} from "./ViewModel.js"; - -export class BrawlViewModel extends ViewModel { - constructor(options) { - super(options); - const {createSessionContainer, sessionInfoStorage, storageFactory} = options; - this._createSessionContainer = createSessionContainer; - this._sessionInfoStorage = sessionInfoStorage; - this._storageFactory = storageFactory; - - this._error = null; - this._sessionViewModel = null; - this._loginViewModel = null; - this._sessionPickerViewModel = null; - - this._sessionContainer = null; - this._sessionCallback = this._sessionCallback.bind(this); - } - - async load() { - if (await this._sessionInfoStorage.hasAnySession()) { - this._showPicker(); - } else { - this._showLogin(); - } - } - - _sessionCallback(sessionContainer) { - if (sessionContainer) { - this._setSection(() => { - this._sessionContainer = sessionContainer; - this._sessionViewModel = new SessionViewModel(this.childOptions({sessionContainer})); - this._sessionViewModel.start(); - }); - } else { - // switch between picker and login - if (this.activeSection === "login") { - this._showPicker(); - } else { - this._showLogin(); - } - } - } - - async _showPicker() { - this._setSection(() => { - this._sessionPickerViewModel = new SessionPickerViewModel({ - sessionInfoStorage: this._sessionInfoStorage, - storageFactory: this._storageFactory, - createSessionContainer: this._createSessionContainer, - sessionCallback: this._sessionCallback, - }); - }); - try { - await this._sessionPickerViewModel.load(); - } catch (err) { - this._setSection(() => this._error = err); - } - } - - _showLogin() { - this._setSection(() => { - this._loginViewModel = new LoginViewModel({ - defaultHomeServer: "https://matrix.org", - createSessionContainer: this._createSessionContainer, - sessionCallback: this._sessionCallback, - }); - }) - - } - - get activeSection() { - if (this._error) { - return "error"; - } else if (this._sessionViewModel) { - return "session"; - } else if (this._loginViewModel) { - return "login"; - } else { - return "picker"; - } - } - - _setSection(setter) { - // clear all members the activeSection depends on - this._error = null; - this._sessionViewModel = null; - this._loginViewModel = null; - this._sessionPickerViewModel = null; - - if (this._sessionContainer) { - this._sessionContainer.stop(); - this._sessionContainer = null; - } - // now set it again - setter(); - this.emitChange("activeSection"); - } - - get error() { return this._error; } - get sessionViewModel() { return this._sessionViewModel; } - get loginViewModel() { return this._loginViewModel; } - get sessionPickerViewModel() { return this._sessionPickerViewModel; } -} 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/RootViewModel.js b/src/domain/RootViewModel.js new file mode 100644 index 00000000..f81591db --- /dev/null +++ b/src/domain/RootViewModel.js @@ -0,0 +1,169 @@ +/* +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 {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"; + +export class RootViewModel extends ViewModel { + constructor(options) { + super(options); + const {createSessionContainer, sessionInfoStorage, storageFactory} = options; + this._createSessionContainer = createSessionContainer; + this._sessionInfoStorage = sessionInfoStorage; + this._storageFactory = storageFactory; + + this._error = null; + this._sessionPickerViewModel = null; + this._sessionLoadViewModel = null; + this._loginViewModel = null; + this._sessionViewModel = null; + } + + async load() { + this.track(this.navigation.observe("login").subscribe(() => this._applyNavigation())); + this.track(this.navigation.observe("session").subscribe(() => 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(); + const url = this._urlForSessionInfos(sessionInfos); + this.urlRouter.history.replaceUrl(url); + this.urlRouter.applyUrl(url); + } catch (err) { + this._setSection(() => this._error = err); + } + } + } + + _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 { + return this.urlRouter.urlForSegment("session"); + } + } + + async _showPicker() { + this._setSection(() => { + this._sessionPickerViewModel = new SessionPickerViewModel(this.childOptions({ + sessionInfoStorage: this._sessionInfoStorage, + storageFactory: this._storageFactory, + })); + }); + try { + await this._sessionPickerViewModel.load(); + } catch (err) { + this._setSection(() => this._error = err); + } + } + + _showLogin() { + this._setSection(() => { + this._loginViewModel = new LoginViewModel(this.childOptions({ + defaultHomeServer: "https://matrix.org", + 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); + }, + })); + }); + } + + _showSession(sessionContainer) { + this._setSection(() => { + this._sessionViewModel = new SessionViewModel(this.childOptions({sessionContainer})); + this._sessionViewModel.start(); + }); + } + + _showSessionLoader(sessionId) { + this._setSection(() => { + this._sessionLoadViewModel = new SessionLoadViewModel({ + createAndStartSessionContainer: () => { + const sessionContainer = this._createSessionContainer(); + sessionContainer.startWithExistingSession(sessionId); + return sessionContainer; + }, + ready: sessionContainer => this._showSession(sessionContainer) + }); + this._sessionLoadViewModel.start(); + }); + } + + get activeSection() { + if (this._error) { + return "error"; + } else if (this._sessionViewModel) { + return "session"; + } else if (this._loginViewModel) { + return "login"; + } else if (this._sessionPickerViewModel) { + return "picker"; + } else if (this._sessionLoadViewModel) { + return "loading"; + } else { + return "redirecting"; + } + } + + _setSection(setter) { + // clear all members the activeSection depends on + this._error = 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"); + } + + get error() { return this._error; } + get sessionViewModel() { return this._sessionViewModel; } + get loginViewModel() { return this._loginViewModel; } + get sessionPickerViewModel() { return this._sessionPickerViewModel; } + get sessionLoadViewModel() { return this._sessionLoadViewModel; } +} 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; } } diff --git a/src/domain/SessionPickerViewModel.js b/src/domain/SessionPickerViewModel.js index 6e33883b..ca9430b3 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,9 @@ class SessionItemViewModel extends ViewModel { export class SessionPickerViewModel extends ViewModel { constructor(options) { super(options); - const {storageFactory, sessionInfoStorage, sessionCallback, createSessionContainer} = options; + const {storageFactory, sessionInfoStorage} = options; this._storageFactory = storageFactory; this._sessionInfoStorage = sessionInfoStorage; - this._sessionCallback = sessionCallback; - this._createSessionContainer = createSessionContainer; this._sessions = new SortedArray((s1, s2) => s1.id.localeCompare(s2.id)); this._loadViewModel = null; this._error = null; @@ -140,7 +141,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 +151,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 +188,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/domain/ViewModel.js b/src/domain/ViewModel.js index bb651d87..cccdb847 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) { @@ -44,6 +45,7 @@ export class ViewModel extends EventEmitter { if (this.disposables) { return this.disposables.untrack(disposable); } + return null; } dispose() { @@ -96,4 +98,12 @@ export class ViewModel extends EventEmitter { get clock() { return this._options.clock; } + + get urlRouter() { + return this._options.urlRouter; + } + + get navigation() { + return this._options.navigation; + } } diff --git a/src/domain/navigation/Navigation.js b/src/domain/navigation/Navigation.js new file mode 100644 index 00000000..f7222ec2 --- /dev/null +++ b/src/domain/navigation/Navigation.js @@ -0,0 +1,221 @@ +/* +Copyright 2020 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import {BaseObservableValue} from "../../observable/ObservableValue.js"; + +export class Navigation { + constructor(allowsChild) { + this._allowsChild = allowsChild; + this._path = new Path([], allowsChild); + this._observables = new Map(); + } + + get path() { + return this._path; + } + + 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 + for (let i = oldPath.segments.length - 1; i >= 0; i -= 1) { + const segment = oldPath.segments[i]; + if (!this._path.get(segment.type)) { + const observable = this._observables.get(segment.type); + observable?.emitIfChanged(); + } + } + // change values in order of path + for (const segment of this._path.segments) { + const observable = this._observables.get(segment.type); + observable?.emitIfChanged(); + } + } + + observe(type) { + let observable = this._observables.get(type); + if (!observable) { + observable = new SegmentObservable(this, type); + 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); + } + + 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 { + constructor(type, value) { + this.type = type; + this.value = value === undefined ? true : 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; + } + + 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); + } + + get segments() { + 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); + } + }; +} diff --git a/src/domain/navigation/URLRouter.js b/src/domain/navigation/URLRouter.js new file mode 100644 index 00000000..e27c0fef --- /dev/null +++ b/src/domain/navigation/URLRouter.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 {Segment} from "./Navigation.js"; + +export class URLRouter { + constructor({history, navigation, parseUrlPath, stringifyPath}) { + this._subscription = null; + this._history = history; + this._navigation = navigation; + this._parseUrlPath = parseUrlPath; + this._stringifyPath = stringifyPath; + } + + attach() { + this._subscription = this._history.subscribe(url => { + const redirectedUrl = this.applyUrl(url); + if (redirectedUrl !== url) { + this._history.replaceUrl(redirectedUrl); + } + }); + this.applyUrl(this._history.get()); + } + + dispose() { + this._subscription = this._subscription(); + } + + applyUrl(url) { + const urlPath = this._history.urlAsPath(url) + const navPath = this._navigation.pathFrom(this._parseUrlPath(urlPath, this._navigation.path)); + this._navigation.applyPath(navPath); + return this._history.pathAsUrl(this._stringifyPath(navPath)); + } + + get history() { + return this._history; + } + + 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) { + 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() { + 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.path.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 new file mode 100644 index 00000000..ec593122 --- /dev/null +++ b/src/domain/navigation/index.js @@ -0,0 +1,246 @@ +/* +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, Segment} from "./Navigation.js"; +import {URLRouter} from "./URLRouter.js"; + +export function createNavigation() { + return new Navigation(allowsChild); +} + +export function createRouter({history, navigation}) { + return new URLRouter({history, navigation, stringifyPath, parseUrlPath}); +} + +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) { + 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 roomIds = rooms.value.slice(); + roomIds[index] = roomId; + return new Segment("rooms", roomIds); + } else { + return rooms; + } +} + +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 === undefined) { 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 { + // 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 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([ + 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 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([ + 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 9d91cd99..04e810fb 100644 --- a/src/domain/session/RoomGridViewModel.js +++ b/src/domain/session/RoomGridViewModel.js @@ -16,37 +16,61 @@ 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); + this._width = options.width; this._height = options.height; + this._createRoomViewModel = options.createRoomViewModel; + this._selectedIndex = 0; this._viewModels = []; + 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.observe("room"); + this.track(focusedRoom.subscribe(roomId => { + if (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); + } + })); + // initial focus for a room is set by initializeRoomIdsAndTransferVM } roomViewModelAt(i) { - return this._viewModels[i]?.vm; + return this._viewModels[i]; } get focusIndex() { return this._selectedIndex; } - setFocusIndex(idx) { - 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(); - } - this.emitChange("focusedIndex"); - } get width() { return this._width; } @@ -55,43 +79,265 @@ 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 - * @param {RoomTileViewModel} tileVM - * @package - */ - setRoomViewModel(vm, tileVM) { - const old = this._viewModels[this._selectedIndex]; - this.disposeTracked(old?.vm); - old?.tileVM?.close(); - this._viewModels[this._selectedIndex] = {vm: this.track(vm), tileVM}; - 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) { - const index = this._viewModels.findIndex(vms => vms?.vm.id === roomId); - if (index >= 0) { - this.setFocusIndex(index); - return true; + /** 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; + } } - return false; + 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]; + // did anything change? + if ((!vm && newId) || (vm && vm.id !== newId)) { + if (vm) { + this._viewModels[i] = this.disposeTracked(vm); + } + if (newId) { + const newVM = this._createRoomViewModel(newId); + if (newVM) { + this._viewModels[i] = this.track(newVM); + } + } + changed = true; + } + } + if (changed) { + this.emitChange(); + } + return changed; } - /** - * Returns the first set of room and room tile vm, - * and untracking them so they are not owned by this view model anymore. - * @package - */ - getAndUntrackFirst() { - for (const item of this._viewModels) { - if (item) { - this.untrack(item.vm); - return item; - } + /** called from SessionViewModel */ + releaseRoomViewModel(roomId) { + const index = this._viewModels.findIndex(vm => 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("focusIndex"); + } + + _setFocusRoom(roomId) { + const index = this._viewModels.findIndex(vm => vm?.id === roomId); + if (index >= 0) { + this._setFocusIndex(index); } } } + +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 beb35872..de110fa1 100644 --- a/src/domain/session/SessionViewModel.js +++ b/src/domain/session/SessionViewModel.js @@ -25,30 +25,51 @@ 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, - openRoom: this._openRoom.bind(this), - gridEnabled: { - get: () => !!this._gridViewModel, - set: value => this._enabledGrid(value) - } - })); - this._currentRoomTileViewModel = null; + this._leftPanelViewModel = this.track(new LeftPanelViewModel(this.childOptions({ + rooms: this._sessionContainer.session.rooms + }))); this._currentRoomViewModel = null; this._gridViewModel = null; + this._setupNavigation(); + } + + _setupNavigation() { + const gridRooms = this.navigation.observe("rooms"); + // this gives us a set of room ids in the grid + 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()); + } + } + + get id() { + return this._sessionContainer.sessionId; } start() { this._sessionStatusViewModel.start(); } - get selectionId() { + get activeSection() { if (this._currentRoomViewModel) { return this._currentRoomViewModel.id; } else if (this._gridViewModel) { @@ -73,64 +94,77 @@ export class SessionViewModel extends ViewModel { return this._roomList; } - get currentRoom() { + get currentRoomViewModel() { return this._currentRoomViewModel; } - _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._currentRoomViewModel = null; - this._currentRoomTileViewModel = null; + _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), + }))); + 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); } - } else { - const VMs = this._gridViewModel.getAndUntrackFirst(); - if (VMs) { - this._currentRoomViewModel = this.track(VMs.vm); - this._currentRoomTileViewModel = VMs.tileVM; - this._currentRoomTileViewModel.open(); + } else if (this._gridViewModel && !roomIds) { + if (currentRoomId) { + const vm = this._gridViewModel.releaseRoomViewModel(currentRoomId.value); + if (vm) { + this._currentRoomViewModel = this.track(vm); + } else { + const newVM = this._createRoomViewModel(currentRoomId.value); + if (newVM) { + this._currentRoomViewModel = this.track(newVM); + } + } } 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; + if (changed) { + this.emitChange("activeSection"); } } - _openRoom(room, roomTileVM) { - if (this._gridViewModel?.tryFocusRoom(room.id)) { - return; - } else if (this._currentRoomViewModel?.id === room.id) { - return; + _createRoomViewModel(roomId) { + const room = this._sessionContainer.session.rooms.get(roomId); + if (!room) { + return null; } const roomVM = new RoomViewModel(this.childOptions({ room, - ownUserId: this._session.user.id, - closeCallback: () => { - if (this._closeCurrentRoom()) { - this.emitChange("currentRoom"); - } - }, + ownUserId: this._sessionContainer.session.user.id, })); roomVM.load(); - if (this._gridViewModel) { - this._gridViewModel.setRoomViewModel(roomVM, roomTileVM); - } else { - this._closeCurrentRoom(); - this._currentRoomTileViewModel = roomTileVM; - this._currentRoomViewModel = this.track(roomVM); - this.emitChange("currentRoom"); + return roomVM; + } + + _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; + } + this._currentRoomViewModel = this.disposeTracked(this._currentRoomViewModel); + const roomVM = this._createRoomViewModel(roomId); + if (roomVM) { + 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 fe5ce05b..8dc3d224 100644 --- a/src/domain/session/leftpanel/LeftPanelViewModel.js +++ b/src/domain/session/leftpanel/LeftPanelViewModel.js @@ -23,26 +23,62 @@ 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) => { - return new RoomTileViewModel({ + const {rooms} = options; + this._roomTileViewModels = rooms.mapValues((room, emitChange) => { + const isOpen = this.navigation.path.get("room")?.value === room.id; + const vm = new RoomTileViewModel(this.childOptions({ + isOpen, room, - emitChange, - emitOpen: openRoom - }); + 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(roomTileVMs); + this._roomListFilterMap = new ApplyMap(this._roomTileViewModels); 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))); + + 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._roomTileViewModels.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 86f832c4..09cb6372 100644 --- a/src/domain/session/leftpanel/RoomTileViewModel.js +++ b/src/domain/session/leftpanel/RoomTileViewModel.js @@ -25,12 +25,15 @@ 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; + this._url = this.urlRouter.openRoomActionUrl(this._room.id); + if (options.isOpen) { + this.open(); + } } get hidden() { @@ -44,7 +47,6 @@ export class RoomTileViewModel extends ViewModel { } } - // called by parent for now (later should integrate with router) close() { if (this._isOpen) { this._isOpen = false; @@ -57,10 +59,13 @@ export class RoomTileViewModel extends ViewModel { this._isOpen = true; this._wasUnreadWhenOpening = this._room.isUnread; this.emitChange("isOpen"); - this._emitOpen(this._room, this); } } + get url() { + return this._url; + } + compare(other) { /* put unread rooms first 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; diff --git a/src/main.js b/src/main.js index 2e7c80df..4daeccf7 100644 --- a/src/main.js +++ b/src/main.js @@ -21,9 +21,11 @@ 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 {BrawlView} from "./ui/web/BrawlView.js"; +import {RootViewModel} from "./domain/RootViewModel.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"; import {OnlineStatus} from "./ui/web/dom/OnlineStatus.js"; import {CryptoDriver} from "./ui/web/dom/CryptoDriver.js"; import {WorkerPool} from "./utils/WorkerPool.js"; @@ -115,7 +117,12 @@ export async function main(container, paths, legacyExtras) { workerPromise = loadOlmWorker(paths); } - const vm = new BrawlViewModel({ + const navigation = createNavigation(); + const urlRouter = createRouter({navigation, history: new History()}); + urlRouter.attach(); + console.log("starting with navigation path", navigation.path); + + const vm = new RootViewModel({ createSessionContainer: () => { return new SessionContainer({ random: Math.random, @@ -132,11 +139,13 @@ export async function main(container, paths, legacyExtras) { sessionInfoStorage, storageFactory, clock, + urlRouter, + navigation }); 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/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; diff --git a/src/matrix/e2ee/RoomEncryption.js b/src/matrix/e2ee/RoomEncryption.js index f47b92ed..90c63b4a 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(); } } 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; 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) { 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/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/BrawlView.js b/src/ui/web/BrawlView.js deleted file mode 100644 index ec84c716..00000000 --- a/src/ui/web/BrawlView.js +++ /dev/null @@ -1,78 +0,0 @@ -/* -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 {SessionView} from "./session/SessionView.js"; -import {LoginView} from "./login/LoginView.js"; -import {SessionPickerView} from "./login/SessionPickerView.js"; -import {TemplateView} from "./general/TemplateView.js"; -import {SwitchView} from "./general/SwitchView.js"; - -export class BrawlView { - constructor(vm) { - this._vm = vm; - this._switcher = null; - this._root = null; - this._onViewModelChange = this._onViewModelChange.bind(this); - } - - _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); - 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 { - render(t, vm) { - return t.div({className: "StatusView"}, [ - t.h1(vm.header), - t.p(vm.message), - ]); - } -} diff --git a/src/ui/web/RootView.js b/src/ui/web/RootView.js new file mode 100644 index 00000000..f60bb984 --- /dev/null +++ b/src/ui/web/RootView.js @@ -0,0 +1,50 @@ +/* +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 {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"; + +export class RootView extends TemplateView { + render(t, vm) { + return t.mapView(vm => vm.activeSection, activeSection => { + switch (activeSection) { + case "error": + 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": + 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}`); + } + }); + } +} 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/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/css/themes/element/theme.css b/src/ui/web/css/themes/element/theme.css index cd3ed8c6..98093652 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; @@ -127,11 +134,11 @@ button.utility { 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'); } @@ -235,15 +242,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 +266,7 @@ button.utility.grid.on { border-radius: 5px; } -.RoomList li > * { +.RoomList li > a > * { margin-right: 8px; } @@ -312,6 +327,7 @@ a { } .SessionPickerView .session-info { + text-decoration: none; padding: 12px; border: 1px solid rgba(141, 151, 165, 0.15); border-radius: 8px; diff --git a/src/ui/web/dom/History.js b/src/ui/web/dom/History.js new file mode 100644 index 00000000..5a5794ae --- /dev/null +++ b/src/ui/web/dom/History.js @@ -0,0 +1,82 @@ +/* +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 History extends BaseObservableValue { + constructor() { + super(); + this._boundOnHashChange = null; + this._expectSetEcho = false; + } + + _onHashChange() { + if (this._expectSetEcho) { + this._expectSetEcho = false; + return; + } + this.emit(this.get()); + } + + get() { + return document.location.hash; + } + + /** does not emit */ + replaceUrl(url) { + window.history.replaceState(null, null, url); + } + + /** does not emit */ + pushUrl(url) { + 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) { + if (url.startsWith("#")) { + return url.substr(1); + } else { + return url; + } + } + + pathAsUrl(path) { + return `#${path}`; + } + + onSubscribeFirst() { + this._boundOnHashChange = this._onHashChange.bind(this); + window.addEventListener('hashchange', this._boundOnHashChange); + } + + onUnsubscribeLast() { + window.removeEventListener('hashchange', this._boundOnHashChange); + this._boundOnHashChange = null; + } +} 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; } 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; diff --git a/src/ui/web/login/LoginView.js b/src/ui/web/login/LoginView.js index ef2afbb6..e03eab6b 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,14 +49,14 @@ 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", - onClick: () => vm.cancel(), disabled + t.a({ + 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/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..279135ac 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"); @@ -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, @@ -73,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), ]), @@ -92,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); @@ -109,15 +110,15 @@ 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.button({ - className: "styled primary", - onClick: () => vm.cancel() + t.a({ + className: "button-action primary", + href: vm.cancelUrl }, 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/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/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); } }) ]) diff --git a/src/ui/web/session/leftpanel/LeftPanelView.js b/src/ui/web/session/leftpanel/LeftPanelView.js index 3eeaaada..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 }, @@ -83,7 +83,6 @@ export class LeftPanelView extends TemplateView { { className: "RoomList", list: vm.roomList, - onItemClick: (roomTile, event) => roomTile.clicked(event) }, roomTileVM => new RoomTileView(roomTileVM) )) 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(); - } } diff --git a/src/ui/web/view-gallery.html b/src/ui/web/view-gallery.html index ecf328db..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,10 +59,20 @@ loadLabel: "Doing something important...", loading: true, }), + cancelUrl: "#/session", defaultHomeServer: "https://hs.tld", })); document.getElementById("login-loading").appendChild(view.mount()); - +

Session Loading

+
+ diff --git a/src/utils/Disposables.js b/src/utils/Disposables.js index bd13abc2..8bb24843 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; } @@ -40,6 +47,7 @@ export class Disposables { if (idx >= 0) { this._disposables.splice(idx, 1); } + return null; } dispose() {