mirror of
https://github.com/vector-im/hydrogen-web.git
synced 2024-12-23 03:25:12 +01:00
Merge pull request #140 from vector-im/bwindels/url-routing
Url-based routing & navigation
This commit is contained in:
commit
dc80276e99
24
prototypes/ie11-hashchange.html
Normal file
24
prototypes/ie11-hashchange.html
Normal 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>
|
@ -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; }
|
||||
}
|
@ -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
169
src/domain/RootViewModel.js
Normal 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; }
|
||||
}
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
221
src/domain/navigation/Navigation.js
Normal file
221
src/domain/navigation/Navigation.js
Normal 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);
|
||||
}
|
||||
};
|
||||
}
|
99
src/domain/navigation/URLRouter.js
Normal file
99
src/domain/navigation/URLRouter.js
Normal 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);
|
||||
}
|
||||
}
|
246
src/domain/navigation/index.js
Normal file
246
src/domain/navigation/index.js
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
@ -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}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* @package
|
||||
*/
|
||||
tryFocusRoom(roomId) {
|
||||
const index = this._viewModels.findIndex(vms => vms?.vm.id === roomId);
|
||||
if (index >= 0) {
|
||||
this.setFocusIndex(index);
|
||||
return true;
|
||||
focusTile(index) {
|
||||
if (index === this._selectedIndex) {
|
||||
return;
|
||||
}
|
||||
return false;
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 */
|
||||
initializeRoomIdsAndTransferVM(roomIds, existingRoomVM) {
|
||||
roomIds = dedupeSparse(roomIds);
|
||||
let transfered = false;
|
||||
if (existingRoomVM) {
|
||||
const index = roomIds.indexOf(existingRoomVM.id);
|
||||
if (index !== -1) {
|
||||
this._viewModels[index] = this.track(existingRoomVM);
|
||||
transfered = true;
|
||||
}
|
||||
}
|
||||
this.setRoomIds(roomIds);
|
||||
// now all view models exist, set the focus to the selected room
|
||||
const focusedRoom = this.navigation.path.get("room");
|
||||
if (focusedRoom) {
|
||||
const index = this._viewModels.findIndex(vm => vm && vm.id === focusedRoom.value);
|
||||
if (index !== -1) {
|
||||
this._selectedIndex = index;
|
||||
}
|
||||
}
|
||||
return transfered;
|
||||
}
|
||||
|
||||
/** called from SessionViewModel */
|
||||
setRoomIds(roomIds) {
|
||||
roomIds = dedupeSparse(roomIds);
|
||||
let changed = false;
|
||||
const len = this._height * this._width;
|
||||
for (let i = 0; i < len; i += 1) {
|
||||
const newId = roomIds[i];
|
||||
const vm = this._viewModels[i];
|
||||
// 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;
|
||||
}
|
||||
|
||||
/** 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);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
@ -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() {
|
||||
|
@ -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
|
||||
|
@ -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;
|
||||
|
17
src/main.js
17
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}`);
|
||||
|
@ -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;
|
||||
|
@ -377,6 +377,8 @@ export class RoomEncryption {
|
||||
|
||||
dispose() {
|
||||
this._disposed = true;
|
||||
this._megolmBackfillCache.dispose();
|
||||
this._megolmSyncCache.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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;
|
||||
|
@ -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) {
|
||||
|
@ -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() {
|
||||
|
@ -84,4 +84,8 @@ export class MappedMap extends BaseObservableMap {
|
||||
get size() {
|
||||
return this._mappedValues.size;
|
||||
}
|
||||
|
||||
get(key) {
|
||||
return this._mappedValues.get(key);
|
||||
}
|
||||
}
|
||||
|
@ -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
50
src/ui/web/RootView.js
Normal 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}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
@ -40,7 +40,7 @@ limitations under the License.
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
.RoomList li {
|
||||
.RoomList > li > a {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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
82
src/ui/web/dom/History.js
Normal 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;
|
||||
}
|
||||
}
|
@ -31,7 +31,7 @@ export class OnlineStatus extends BaseObservableValue {
|
||||
this.emit(true);
|
||||
}
|
||||
|
||||
get value() {
|
||||
get() {
|
||||
return navigator.onLine;
|
||||
}
|
||||
|
||||
|
@ -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;
|
||||
|
@ -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`),
|
||||
|
30
src/ui/web/login/SessionLoadStatusView.js
Normal file
30
src/ui/web/login/SessionLoadStatusView.js
Normal 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)
|
||||
]);
|
||||
}
|
||||
}
|
@ -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))
|
||||
])
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
@ -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))
|
||||
])
|
||||
]);
|
||||
|
@ -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,
|
||||
|
@ -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);
|
||||
}
|
||||
})
|
||||
])
|
||||
|
@ -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)
|
||||
))
|
||||
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
@ -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>
|
||||
|
@ -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() {
|
||||
|
Loading…
Reference in New Issue
Block a user