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
+
+