Merge pull request #140 from vector-im/bwindels/url-routing

Url-based routing & navigation
This commit is contained in:
Bruno Windels 2020-10-14 11:57:33 +00:00 committed by GitHub
commit dc80276e99
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
40 changed files with 1568 additions and 489 deletions

View file

@ -0,0 +1,24 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
</head>
<body>
<ul id="changes"></ul>
<script type="text/javascript">
const ul = document.getElementById("changes");
window.onhashchange = function() {
const hash = document.location.hash.substr(1);
const li = document.createElement("li");
li.appendChild(document.createTextNode(hash));
ul.appendChild(li);
window.history.replaceState(null, null, "#" + hash + hash);
}
</script>
<p>
<a href="#foo">foo</a>
<a href="#bar">bar</a>
<a href="#baz">baz</a>
</p>
</body>
</html>

View file

@ -1,123 +0,0 @@
/*
Copyright 2020 Bruno Windels <bruno@windels.cloud>
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; }
}

View file

@ -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();
}
}
}

169
src/domain/RootViewModel.js Normal file
View file

@ -0,0 +1,169 @@
/*
Copyright 2020 Bruno Windels <bruno@windels.cloud>
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; }
}

View file

@ -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;
}
}

View file

@ -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");
}
}

View file

@ -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;
}
}

View file

@ -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);
}
};
}

View file

@ -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);
}
}

View file

@ -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);
}
}
}

View file

@ -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);
}
};
}

View file

@ -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");
}
}

View file

@ -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() {

View file

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

View file

@ -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;

View file

@ -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}`);

View file

@ -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;

View file

@ -377,6 +377,8 @@ export class RoomEncryption {
dispose() {
this._disposed = true;
this._megolmBackfillCache.dispose();
this._megolmSyncCache.dispose();
}
}

View file

@ -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;

View file

@ -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) {

View file

@ -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() {

View file

@ -84,4 +84,8 @@ export class MappedMap extends BaseObservableMap {
get size() {
return this._mappedValues.size;
}
get(key) {
return this._mappedValues.get(key);
}
}

View file

@ -1,78 +0,0 @@
/*
Copyright 2020 Bruno Windels <bruno@windels.cloud>
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),
]);
}
}

50
src/ui/web/RootView.js Normal file
View file

@ -0,0 +1,50 @@
/*
Copyright 2020 Bruno Windels <bruno@windels.cloud>
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}`);
}
});
}
}

View file

@ -40,7 +40,7 @@ limitations under the License.
overscroll-behavior: contain;
}
.RoomList li {
.RoomList > li > a {
display: flex;
align-items: center;
}

View file

@ -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;
}

View file

@ -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;

82
src/ui/web/dom/History.js Normal file
View file

@ -0,0 +1,82 @@
/*
Copyright 2020 Bruno Windels <bruno@windels.cloud>
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;
}
}

View file

@ -31,7 +31,7 @@ export class OnlineStatus extends BaseObservableValue {
this.emit(true);
}
get value() {
get() {
return navigator.onLine;
}

View file

@ -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;

View file

@ -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`),

View file

@ -0,0 +1,30 @@
/*
Copyright 2020 Bruno Windels <bruno@windels.cloud>
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)
]);
}
}

View file

@ -1,5 +1,5 @@
/*
Copyright 2020 Bruno Windels <bruno@windels.cloud>
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))
])
]);
}
}

View file

@ -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))
])
]);

View file

@ -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,

View file

@ -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);
}
})
])

View file

@ -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)
))

View file

@ -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();
}
}

View file

@ -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());
</script>
@ -59,10 +59,20 @@
loadLabel: "Doing something important...",
loading: true,
}),
cancelUrl: "#/session",
defaultHomeServer: "https://hs.tld",
}));
document.getElementById("login-loading").appendChild(view.mount());
</script>
<h2 name="session-loading">Session Loading</h2>
<div id="session-loading" class="hydrogen"></div>
<script id="main" type="module">
import {SessionLoadView} from "./login/SessionLoadView.js";
const view = new SessionLoadView(vm({
loading: true,
loadLabel: "Getting on with loading your session..."
}));
document.getElementById("session-loading").appendChild(view.mount());
</script>
</body>
</html>

View file

@ -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() {