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 {
|
export class LoginViewModel extends ViewModel {
|
||||||
constructor(options) {
|
constructor(options) {
|
||||||
super(options);
|
super(options);
|
||||||
const {sessionCallback, defaultHomeServer, createSessionContainer} = options;
|
const {ready, defaultHomeServer, createSessionContainer} = options;
|
||||||
this._createSessionContainer = createSessionContainer;
|
this._createSessionContainer = createSessionContainer;
|
||||||
this._sessionCallback = sessionCallback;
|
this._ready = ready;
|
||||||
this._defaultHomeServer = defaultHomeServer;
|
this._defaultHomeServer = defaultHomeServer;
|
||||||
|
this._sessionContainer = null;
|
||||||
this._loadViewModel = null;
|
this._loadViewModel = null;
|
||||||
this._loadViewModelSubscription = null;
|
this._loadViewModelSubscription = null;
|
||||||
}
|
}
|
||||||
@ -45,25 +46,19 @@ export class LoginViewModel extends ViewModel {
|
|||||||
if (this._loadViewModel) {
|
if (this._loadViewModel) {
|
||||||
this._loadViewModel.cancel();
|
this._loadViewModel.cancel();
|
||||||
}
|
}
|
||||||
this._loadViewModel = new SessionLoadViewModel({
|
this._loadViewModel = this.track(new SessionLoadViewModel({
|
||||||
createAndStartSessionContainer: () => {
|
createAndStartSessionContainer: () => {
|
||||||
const sessionContainer = this._createSessionContainer();
|
this._sessionContainer = this._createSessionContainer();
|
||||||
sessionContainer.startWithLogin(homeserver, username, password);
|
this._sessionContainer.startWithLogin(homeserver, username, password);
|
||||||
return sessionContainer;
|
return this._sessionContainer;
|
||||||
},
|
},
|
||||||
sessionCallback: sessionContainer => {
|
ready: sessionContainer => {
|
||||||
if (sessionContainer) {
|
// make sure we don't delete the session in dispose when navigating away
|
||||||
// make parent view model move away
|
this._sessionContainer = null;
|
||||||
this._sessionCallback(sessionContainer);
|
this._ready(sessionContainer);
|
||||||
} else {
|
|
||||||
// show list of session again
|
|
||||||
this._loadViewModel = null;
|
|
||||||
this.emitChange("loadViewModel");
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
deleteSessionOnCancel: true,
|
|
||||||
homeserver,
|
homeserver,
|
||||||
});
|
}));
|
||||||
this._loadViewModel.start();
|
this._loadViewModel.start();
|
||||||
this.emitChange("loadViewModel");
|
this.emitChange("loadViewModel");
|
||||||
this._loadViewModelSubscription = this.track(this._loadViewModel.disposableOn("change", () => {
|
this._loadViewModelSubscription = this.track(this._loadViewModel.disposableOn("change", () => {
|
||||||
@ -74,9 +69,16 @@ export class LoginViewModel extends ViewModel {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
cancel() {
|
get cancelUrl() {
|
||||||
if (!this.isBusy) {
|
return this.urlRouter.urlForSegment("session");
|
||||||
this._sessionCallback();
|
}
|
||||||
|
|
||||||
|
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 {
|
export class SessionLoadViewModel extends ViewModel {
|
||||||
constructor(options) {
|
constructor(options) {
|
||||||
super(options);
|
super(options);
|
||||||
const {createAndStartSessionContainer, sessionCallback, homeserver, deleteSessionOnCancel} = options;
|
const {createAndStartSessionContainer, ready, homeserver, deleteSessionOnCancel} = options;
|
||||||
this._createAndStartSessionContainer = createAndStartSessionContainer;
|
this._createAndStartSessionContainer = createAndStartSessionContainer;
|
||||||
this._sessionCallback = sessionCallback;
|
this._ready = ready;
|
||||||
this._homeserver = homeserver;
|
this._homeserver = homeserver;
|
||||||
this._deleteSessionOnCancel = deleteSessionOnCancel;
|
this._deleteSessionOnCancel = deleteSessionOnCancel;
|
||||||
this._loading = false;
|
this._loading = false;
|
||||||
@ -60,11 +60,17 @@ export class SessionLoadViewModel extends ViewModel {
|
|||||||
|
|
||||||
// did it finish or get stuck at LoginFailed or Error?
|
// did it finish or get stuck at LoginFailed or Error?
|
||||||
const loadStatus = this._sessionContainer.loadStatus.get();
|
const loadStatus = this._sessionContainer.loadStatus.get();
|
||||||
|
const loadError = this._sessionContainer.loadError;
|
||||||
if (loadStatus === LoadStatus.FirstSync || loadStatus === LoadStatus.Ready) {
|
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) {
|
if (loadError) {
|
||||||
console.error("session load error", this._sessionContainer.loadError);
|
console.error("session load error", loadError);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this._error = err;
|
this._error = err;
|
||||||
@ -77,24 +83,15 @@ export class SessionLoadViewModel extends ViewModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
async cancel() {
|
dispose() {
|
||||||
try {
|
if (this._sessionContainer) {
|
||||||
if (this._sessionContainer) {
|
this._sessionContainer.dispose();
|
||||||
this._sessionContainer.dispose();
|
this._sessionContainer = null;
|
||||||
if (this._deleteSessionOnCancel) {
|
}
|
||||||
await this._sessionContainer.deleteSession();
|
if (this._waitHandle) {
|
||||||
}
|
// rejects with AbortError
|
||||||
this._sessionContainer = null;
|
this._waitHandle.dispose();
|
||||||
}
|
this._waitHandle = null;
|
||||||
if (this._waitHandle) {
|
|
||||||
// rejects with AbortError
|
|
||||||
this._waitHandle.dispose();
|
|
||||||
this._waitHandle = null;
|
|
||||||
}
|
|
||||||
this._sessionCallback();
|
|
||||||
} catch (err) {
|
|
||||||
this._error = err;
|
|
||||||
this.emitChange();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -15,15 +15,14 @@ limitations under the License.
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import {SortedArray} from "../observable/index.js";
|
import {SortedArray} from "../observable/index.js";
|
||||||
import {SessionLoadViewModel} from "./SessionLoadViewModel.js";
|
|
||||||
import {ViewModel} from "./ViewModel.js";
|
import {ViewModel} from "./ViewModel.js";
|
||||||
import {avatarInitials, getIdentifierColorNumber} from "./avatar.js";
|
import {avatarInitials, getIdentifierColorNumber} from "./avatar.js";
|
||||||
|
|
||||||
class SessionItemViewModel extends ViewModel {
|
class SessionItemViewModel extends ViewModel {
|
||||||
constructor(sessionInfo, pickerVM) {
|
constructor(options, pickerVM) {
|
||||||
super({});
|
super(options);
|
||||||
this._pickerVM = pickerVM;
|
this._pickerVM = pickerVM;
|
||||||
this._sessionInfo = sessionInfo;
|
this._sessionInfo = options.sessionInfo;
|
||||||
this._isDeleting = false;
|
this._isDeleting = false;
|
||||||
this._isClearing = false;
|
this._isClearing = false;
|
||||||
this._error = null;
|
this._error = null;
|
||||||
@ -76,6 +75,10 @@ class SessionItemViewModel extends ViewModel {
|
|||||||
return this._sessionInfo.id;
|
return this._sessionInfo.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get openUrl() {
|
||||||
|
return this.urlRouter.urlForSegment("session", this.id);
|
||||||
|
}
|
||||||
|
|
||||||
get label() {
|
get label() {
|
||||||
const {userId, comment} = this._sessionInfo;
|
const {userId, comment} = this._sessionInfo;
|
||||||
if (comment) {
|
if (comment) {
|
||||||
@ -127,11 +130,9 @@ class SessionItemViewModel extends ViewModel {
|
|||||||
export class SessionPickerViewModel extends ViewModel {
|
export class SessionPickerViewModel extends ViewModel {
|
||||||
constructor(options) {
|
constructor(options) {
|
||||||
super(options);
|
super(options);
|
||||||
const {storageFactory, sessionInfoStorage, sessionCallback, createSessionContainer} = options;
|
const {storageFactory, sessionInfoStorage} = options;
|
||||||
this._storageFactory = storageFactory;
|
this._storageFactory = storageFactory;
|
||||||
this._sessionInfoStorage = sessionInfoStorage;
|
this._sessionInfoStorage = sessionInfoStorage;
|
||||||
this._sessionCallback = sessionCallback;
|
|
||||||
this._createSessionContainer = createSessionContainer;
|
|
||||||
this._sessions = new SortedArray((s1, s2) => s1.id.localeCompare(s2.id));
|
this._sessions = new SortedArray((s1, s2) => s1.id.localeCompare(s2.id));
|
||||||
this._loadViewModel = null;
|
this._loadViewModel = null;
|
||||||
this._error = null;
|
this._error = null;
|
||||||
@ -140,7 +141,9 @@ export class SessionPickerViewModel extends ViewModel {
|
|||||||
// this loads all the sessions
|
// this loads all the sessions
|
||||||
async load() {
|
async load() {
|
||||||
const sessions = await this._sessionInfoStorage.getAll();
|
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
|
// for the loading of 1 picked session
|
||||||
@ -148,34 +151,6 @@ export class SessionPickerViewModel extends ViewModel {
|
|||||||
return this._loadViewModel;
|
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) {
|
async _exportData(id) {
|
||||||
const sessionInfo = await this._sessionInfoStorage.get(id);
|
const sessionInfo = await this._sessionInfoStorage.get(id);
|
||||||
const stores = await this._storageFactory.export(id);
|
const stores = await this._storageFactory.export(id);
|
||||||
@ -213,9 +188,7 @@ export class SessionPickerViewModel extends ViewModel {
|
|||||||
return this._sessions;
|
return this._sessions;
|
||||||
}
|
}
|
||||||
|
|
||||||
cancel() {
|
get cancelUrl() {
|
||||||
if (!this._loadViewModel) {
|
return this.urlRouter.urlForSegment("login");
|
||||||
this._sessionCallback();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -22,15 +22,16 @@ import {EventEmitter} from "../utils/EventEmitter.js";
|
|||||||
import {Disposables} from "../utils/Disposables.js";
|
import {Disposables} from "../utils/Disposables.js";
|
||||||
|
|
||||||
export class ViewModel extends EventEmitter {
|
export class ViewModel extends EventEmitter {
|
||||||
constructor({clock, emitChange} = {}) {
|
constructor(options = {}) {
|
||||||
super();
|
super();
|
||||||
this.disposables = null;
|
this.disposables = null;
|
||||||
this._isDisposed = false;
|
this._isDisposed = false;
|
||||||
this._options = {clock, emitChange};
|
this._options = options;
|
||||||
}
|
}
|
||||||
|
|
||||||
childOptions(explicitOptions) {
|
childOptions(explicitOptions) {
|
||||||
return Object.assign({}, this._options, explicitOptions);
|
const {navigation, urlRouter, clock} = this._options;
|
||||||
|
return Object.assign({navigation, urlRouter, clock}, explicitOptions);
|
||||||
}
|
}
|
||||||
|
|
||||||
track(disposable) {
|
track(disposable) {
|
||||||
@ -44,6 +45,7 @@ export class ViewModel extends EventEmitter {
|
|||||||
if (this.disposables) {
|
if (this.disposables) {
|
||||||
return this.disposables.untrack(disposable);
|
return this.disposables.untrack(disposable);
|
||||||
}
|
}
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
dispose() {
|
dispose() {
|
||||||
@ -96,4 +98,12 @@ export class ViewModel extends EventEmitter {
|
|||||||
get clock() {
|
get clock() {
|
||||||
return this._options.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";
|
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 {
|
export class RoomGridViewModel extends ViewModel {
|
||||||
constructor(options) {
|
constructor(options) {
|
||||||
super(options);
|
super(options);
|
||||||
|
|
||||||
this._width = options.width;
|
this._width = options.width;
|
||||||
this._height = options.height;
|
this._height = options.height;
|
||||||
|
this._createRoomViewModel = options.createRoomViewModel;
|
||||||
|
|
||||||
this._selectedIndex = 0;
|
this._selectedIndex = 0;
|
||||||
this._viewModels = [];
|
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) {
|
roomViewModelAt(i) {
|
||||||
return this._viewModels[i]?.vm;
|
return this._viewModels[i];
|
||||||
}
|
}
|
||||||
|
|
||||||
get focusIndex() {
|
get focusIndex() {
|
||||||
return this._selectedIndex;
|
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() {
|
get width() {
|
||||||
return this._width;
|
return this._width;
|
||||||
}
|
}
|
||||||
@ -55,43 +79,265 @@ export class RoomGridViewModel extends ViewModel {
|
|||||||
return this._height;
|
return this._height;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
focusTile(index) {
|
||||||
* Sets a pair of room and room tile view models at the current index
|
if (index === this._selectedIndex) {
|
||||||
* @param {RoomViewModel} vm
|
return;
|
||||||
* @param {RoomTileViewModel} tileVM
|
}
|
||||||
* @package
|
let path = this.navigation.path;
|
||||||
*/
|
const vm = this._viewModels[index];
|
||||||
setRoomViewModel(vm, tileVM) {
|
if (vm) {
|
||||||
const old = this._viewModels[this._selectedIndex];
|
path = path.with(this.navigation.segment("room", vm.id));
|
||||||
this.disposeTracked(old?.vm);
|
} else {
|
||||||
old?.tileVM?.close();
|
path = path.with(this.navigation.segment("empty-grid-tile", index));
|
||||||
this._viewModels[this._selectedIndex] = {vm: this.track(vm), tileVM};
|
}
|
||||||
this.emitChange(`${this._selectedIndex}`);
|
let url = this.urlRouter.urlForPath(path);
|
||||||
|
url = this.urlRouter.applyUrl(url);
|
||||||
|
this.urlRouter.history.pushUrl(url);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** called from SessionViewModel */
|
||||||
* @package
|
initializeRoomIdsAndTransferVM(roomIds, existingRoomVM) {
|
||||||
*/
|
roomIds = dedupeSparse(roomIds);
|
||||||
tryFocusRoom(roomId) {
|
let transfered = false;
|
||||||
const index = this._viewModels.findIndex(vms => vms?.vm.id === roomId);
|
if (existingRoomVM) {
|
||||||
if (index >= 0) {
|
const index = roomIds.indexOf(existingRoomVM.id);
|
||||||
this.setFocusIndex(index);
|
if (index !== -1) {
|
||||||
return true;
|
this._viewModels[index] = this.track(existingRoomVM);
|
||||||
|
transfered = true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return false;
|
this.setRoomIds(roomIds);
|
||||||
|
// now all view models exist, set the focus to the selected room
|
||||||
|
const focusedRoom = this.navigation.path.get("room");
|
||||||
|
if (focusedRoom) {
|
||||||
|
const index = this._viewModels.findIndex(vm => vm && vm.id === focusedRoom.value);
|
||||||
|
if (index !== -1) {
|
||||||
|
this._selectedIndex = index;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return transfered;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** called from SessionViewModel */
|
||||||
|
setRoomIds(roomIds) {
|
||||||
|
roomIds = dedupeSparse(roomIds);
|
||||||
|
let changed = false;
|
||||||
|
const len = this._height * this._width;
|
||||||
|
for (let i = 0; i < len; i += 1) {
|
||||||
|
const newId = roomIds[i];
|
||||||
|
const vm = this._viewModels[i];
|
||||||
|
// did anything change?
|
||||||
|
if ((!vm && newId) || (vm && vm.id !== newId)) {
|
||||||
|
if (vm) {
|
||||||
|
this._viewModels[i] = this.disposeTracked(vm);
|
||||||
|
}
|
||||||
|
if (newId) {
|
||||||
|
const newVM = this._createRoomViewModel(newId);
|
||||||
|
if (newVM) {
|
||||||
|
this._viewModels[i] = this.track(newVM);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (changed) {
|
||||||
|
this.emitChange();
|
||||||
|
}
|
||||||
|
return changed;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** called from SessionViewModel */
|
||||||
* Returns the first set of room and room tile vm,
|
releaseRoomViewModel(roomId) {
|
||||||
* and untracking them so they are not owned by this view model anymore.
|
const index = this._viewModels.findIndex(vm => vm && vm.id === roomId);
|
||||||
* @package
|
if (index !== -1) {
|
||||||
*/
|
const vm = this._viewModels[index];
|
||||||
getAndUntrackFirst() {
|
this.untrack(vm);
|
||||||
for (const item of this._viewModels) {
|
this._viewModels[index] = null;
|
||||||
if (item) {
|
return vm;
|
||||||
this.untrack(item.vm);
|
}
|
||||||
return item;
|
}
|
||||||
}
|
|
||||||
|
_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) {
|
constructor(options) {
|
||||||
super(options);
|
super(options);
|
||||||
const {sessionContainer} = options;
|
const {sessionContainer} = options;
|
||||||
this._session = sessionContainer.session;
|
this._sessionContainer = this.track(sessionContainer);
|
||||||
this._sessionStatusViewModel = this.track(new SessionStatusViewModel(this.childOptions({
|
this._sessionStatusViewModel = this.track(new SessionStatusViewModel(this.childOptions({
|
||||||
sync: sessionContainer.sync,
|
sync: sessionContainer.sync,
|
||||||
reconnector: sessionContainer.reconnector,
|
reconnector: sessionContainer.reconnector,
|
||||||
session: sessionContainer.session,
|
session: sessionContainer.session,
|
||||||
})));
|
})));
|
||||||
this._leftPanelViewModel = new LeftPanelViewModel(this.childOptions({
|
this._leftPanelViewModel = this.track(new LeftPanelViewModel(this.childOptions({
|
||||||
rooms: this._session.rooms,
|
rooms: this._sessionContainer.session.rooms
|
||||||
openRoom: this._openRoom.bind(this),
|
})));
|
||||||
gridEnabled: {
|
|
||||||
get: () => !!this._gridViewModel,
|
|
||||||
set: value => this._enabledGrid(value)
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
this._currentRoomTileViewModel = null;
|
|
||||||
this._currentRoomViewModel = null;
|
this._currentRoomViewModel = null;
|
||||||
this._gridViewModel = 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() {
|
start() {
|
||||||
this._sessionStatusViewModel.start();
|
this._sessionStatusViewModel.start();
|
||||||
}
|
}
|
||||||
|
|
||||||
get selectionId() {
|
get activeSection() {
|
||||||
if (this._currentRoomViewModel) {
|
if (this._currentRoomViewModel) {
|
||||||
return this._currentRoomViewModel.id;
|
return this._currentRoomViewModel.id;
|
||||||
} else if (this._gridViewModel) {
|
} else if (this._gridViewModel) {
|
||||||
@ -73,64 +94,77 @@ export class SessionViewModel extends ViewModel {
|
|||||||
return this._roomList;
|
return this._roomList;
|
||||||
}
|
}
|
||||||
|
|
||||||
get currentRoom() {
|
get currentRoomViewModel() {
|
||||||
return this._currentRoomViewModel;
|
return this._currentRoomViewModel;
|
||||||
}
|
}
|
||||||
|
|
||||||
_enabledGrid(enabled) {
|
_updateGrid(roomIds) {
|
||||||
if (enabled) {
|
const changed = !(this._gridViewModel && roomIds);
|
||||||
this._gridViewModel = this.track(new RoomGridViewModel(this.childOptions({width: 3, height: 2})));
|
const currentRoomId = this.navigation.path.get("room");
|
||||||
// transfer current room
|
if (roomIds) {
|
||||||
if (this._currentRoomViewModel) {
|
if (!this._gridViewModel) {
|
||||||
this.untrack(this._currentRoomViewModel);
|
this._gridViewModel = this.track(new RoomGridViewModel(this.childOptions({
|
||||||
this._gridViewModel.setRoomViewModel(this._currentRoomViewModel, this._currentRoomTileViewModel);
|
width: 3,
|
||||||
this._currentRoomViewModel = null;
|
height: 2,
|
||||||
this._currentRoomTileViewModel = null;
|
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 {
|
} else if (this._gridViewModel && !roomIds) {
|
||||||
const VMs = this._gridViewModel.getAndUntrackFirst();
|
if (currentRoomId) {
|
||||||
if (VMs) {
|
const vm = this._gridViewModel.releaseRoomViewModel(currentRoomId.value);
|
||||||
this._currentRoomViewModel = this.track(VMs.vm);
|
if (vm) {
|
||||||
this._currentRoomTileViewModel = VMs.tileVM;
|
this._currentRoomViewModel = this.track(vm);
|
||||||
this._currentRoomTileViewModel.open();
|
} else {
|
||||||
|
const newVM = this._createRoomViewModel(currentRoomId.value);
|
||||||
|
if (newVM) {
|
||||||
|
this._currentRoomViewModel = this.track(newVM);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
this._gridViewModel = this.disposeTracked(this._gridViewModel);
|
this._gridViewModel = this.disposeTracked(this._gridViewModel);
|
||||||
}
|
}
|
||||||
this.emitChange("middlePanelViewType");
|
if (changed) {
|
||||||
}
|
this.emitChange("activeSection");
|
||||||
|
|
||||||
_closeCurrentRoom() {
|
|
||||||
// no closing in grid for now as it is disabled on narrow viewports
|
|
||||||
if (!this._gridViewModel) {
|
|
||||||
this._currentRoomTileViewModel?.close();
|
|
||||||
this._currentRoomViewModel = this.disposeTracked(this._currentRoomViewModel);
|
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_openRoom(room, roomTileVM) {
|
_createRoomViewModel(roomId) {
|
||||||
if (this._gridViewModel?.tryFocusRoom(room.id)) {
|
const room = this._sessionContainer.session.rooms.get(roomId);
|
||||||
return;
|
if (!room) {
|
||||||
} else if (this._currentRoomViewModel?.id === room.id) {
|
return null;
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
const roomVM = new RoomViewModel(this.childOptions({
|
const roomVM = new RoomViewModel(this.childOptions({
|
||||||
room,
|
room,
|
||||||
ownUserId: this._session.user.id,
|
ownUserId: this._sessionContainer.session.user.id,
|
||||||
closeCallback: () => {
|
|
||||||
if (this._closeCurrentRoom()) {
|
|
||||||
this.emitChange("currentRoom");
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}));
|
}));
|
||||||
roomVM.load();
|
roomVM.load();
|
||||||
if (this._gridViewModel) {
|
return roomVM;
|
||||||
this._gridViewModel.setRoomViewModel(roomVM, roomTileVM);
|
}
|
||||||
} else {
|
|
||||||
this._closeCurrentRoom();
|
_openRoom(roomId) {
|
||||||
this._currentRoomTileViewModel = roomTileVM;
|
if (!roomId) {
|
||||||
this._currentRoomViewModel = this.track(roomVM);
|
if (this._currentRoomViewModel) {
|
||||||
this.emitChange("currentRoom");
|
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 {
|
export class LeftPanelViewModel extends ViewModel {
|
||||||
constructor(options) {
|
constructor(options) {
|
||||||
super(options);
|
super(options);
|
||||||
const {rooms, openRoom, gridEnabled} = options;
|
const {rooms} = options;
|
||||||
this._gridEnabled = gridEnabled;
|
this._roomTileViewModels = rooms.mapValues((room, emitChange) => {
|
||||||
const roomTileVMs = rooms.mapValues((room, emitChange) => {
|
const isOpen = this.navigation.path.get("room")?.value === room.id;
|
||||||
return new RoomTileViewModel({
|
const vm = new RoomTileViewModel(this.childOptions({
|
||||||
|
isOpen,
|
||||||
room,
|
room,
|
||||||
emitChange,
|
emitChange
|
||||||
emitOpen: openRoom
|
}));
|
||||||
});
|
// 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._roomList = this._roomListFilterMap.sortValues((a, b) => a.compare(b));
|
||||||
|
this._currentTileVM = null;
|
||||||
|
this._setupNavigation();
|
||||||
}
|
}
|
||||||
|
|
||||||
get gridEnabled() {
|
_setupNavigation() {
|
||||||
return this._gridEnabled.get();
|
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() {
|
toggleGrid() {
|
||||||
this._gridEnabled.set(!this._gridEnabled.get());
|
let url;
|
||||||
this.emitChange("gridEnabled");
|
if (this.gridEnabled) {
|
||||||
|
url = this.urlRouter.disableGridUrl();
|
||||||
|
} else {
|
||||||
|
url = this.urlRouter.enableGridUrl();
|
||||||
|
}
|
||||||
|
url = this.urlRouter.applyUrl(url);
|
||||||
|
this.urlRouter.history.pushUrl(url);
|
||||||
}
|
}
|
||||||
|
|
||||||
get roomList() {
|
get roomList() {
|
||||||
|
@ -25,12 +25,15 @@ function isSortedAsUnread(vm) {
|
|||||||
export class RoomTileViewModel extends ViewModel {
|
export class RoomTileViewModel extends ViewModel {
|
||||||
constructor(options) {
|
constructor(options) {
|
||||||
super(options);
|
super(options);
|
||||||
const {room, emitOpen} = options;
|
const {room} = options;
|
||||||
this._room = room;
|
this._room = room;
|
||||||
this._emitOpen = emitOpen;
|
|
||||||
this._isOpen = false;
|
this._isOpen = false;
|
||||||
this._wasUnreadWhenOpening = false;
|
this._wasUnreadWhenOpening = false;
|
||||||
this._hidden = false;
|
this._hidden = false;
|
||||||
|
this._url = this.urlRouter.openRoomActionUrl(this._room.id);
|
||||||
|
if (options.isOpen) {
|
||||||
|
this.open();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
get hidden() {
|
get hidden() {
|
||||||
@ -44,7 +47,6 @@ export class RoomTileViewModel extends ViewModel {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// called by parent for now (later should integrate with router)
|
|
||||||
close() {
|
close() {
|
||||||
if (this._isOpen) {
|
if (this._isOpen) {
|
||||||
this._isOpen = false;
|
this._isOpen = false;
|
||||||
@ -57,10 +59,13 @@ export class RoomTileViewModel extends ViewModel {
|
|||||||
this._isOpen = true;
|
this._isOpen = true;
|
||||||
this._wasUnreadWhenOpening = this._room.isUnread;
|
this._wasUnreadWhenOpening = this._room.isUnread;
|
||||||
this.emitChange("isOpen");
|
this.emitChange("isOpen");
|
||||||
this._emitOpen(this._room, this);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get url() {
|
||||||
|
return this._url;
|
||||||
|
}
|
||||||
|
|
||||||
compare(other) {
|
compare(other) {
|
||||||
/*
|
/*
|
||||||
put unread rooms first
|
put unread rooms first
|
||||||
|
@ -76,6 +76,7 @@ export class RoomViewModel extends ViewModel {
|
|||||||
|
|
||||||
dispose() {
|
dispose() {
|
||||||
super.dispose();
|
super.dispose();
|
||||||
|
this._room.off("change", this._onRoomChange);
|
||||||
if (this._clearUnreadTimout) {
|
if (this._clearUnreadTimout) {
|
||||||
this._clearUnreadTimout.abort();
|
this._clearUnreadTimout.abort();
|
||||||
this._clearUnreadTimout = null;
|
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 {SessionContainer} from "./matrix/SessionContainer.js";
|
||||||
import {StorageFactory} from "./matrix/storage/idb/StorageFactory.js";
|
import {StorageFactory} from "./matrix/storage/idb/StorageFactory.js";
|
||||||
import {SessionInfoStorage} from "./matrix/sessioninfo/localstorage/SessionInfoStorage.js";
|
import {SessionInfoStorage} from "./matrix/sessioninfo/localstorage/SessionInfoStorage.js";
|
||||||
import {BrawlViewModel} from "./domain/BrawlViewModel.js";
|
import {RootViewModel} from "./domain/RootViewModel.js";
|
||||||
import {BrawlView} from "./ui/web/BrawlView.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 {Clock} from "./ui/web/dom/Clock.js";
|
||||||
|
import {History} from "./ui/web/dom/History.js";
|
||||||
import {OnlineStatus} from "./ui/web/dom/OnlineStatus.js";
|
import {OnlineStatus} from "./ui/web/dom/OnlineStatus.js";
|
||||||
import {CryptoDriver} from "./ui/web/dom/CryptoDriver.js";
|
import {CryptoDriver} from "./ui/web/dom/CryptoDriver.js";
|
||||||
import {WorkerPool} from "./utils/WorkerPool.js";
|
import {WorkerPool} from "./utils/WorkerPool.js";
|
||||||
@ -115,7 +117,12 @@ export async function main(container, paths, legacyExtras) {
|
|||||||
workerPromise = loadOlmWorker(paths);
|
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: () => {
|
createSessionContainer: () => {
|
||||||
return new SessionContainer({
|
return new SessionContainer({
|
||||||
random: Math.random,
|
random: Math.random,
|
||||||
@ -132,11 +139,13 @@ export async function main(container, paths, legacyExtras) {
|
|||||||
sessionInfoStorage,
|
sessionInfoStorage,
|
||||||
storageFactory,
|
storageFactory,
|
||||||
clock,
|
clock,
|
||||||
|
urlRouter,
|
||||||
|
navigation
|
||||||
});
|
});
|
||||||
window.__brawlViewModel = vm;
|
window.__brawlViewModel = vm;
|
||||||
await vm.load();
|
await vm.load();
|
||||||
// TODO: replace with platform.createAndMountRootView(vm, container);
|
// TODO: replace with platform.createAndMountRootView(vm, container);
|
||||||
const view = new BrawlView(vm);
|
const view = new RootView(vm);
|
||||||
container.appendChild(view.mount());
|
container.appendChild(view.mount());
|
||||||
} catch(err) {
|
} catch(err) {
|
||||||
console.error(`${err.message}:\n${err.stack}`);
|
console.error(`${err.message}:\n${err.stack}`);
|
||||||
|
@ -70,6 +70,10 @@ export class SessionContainer {
|
|||||||
return (Math.floor(this._random() * Number.MAX_SAFE_INTEGER)).toString();
|
return (Math.floor(this._random() * Number.MAX_SAFE_INTEGER)).toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get sessionId() {
|
||||||
|
return this._sessionId;
|
||||||
|
}
|
||||||
|
|
||||||
async startWithExistingSession(sessionId) {
|
async startWithExistingSession(sessionId) {
|
||||||
if (this._status.get() !== LoadStatus.NotLoading) {
|
if (this._status.get() !== LoadStatus.NotLoading) {
|
||||||
return;
|
return;
|
||||||
|
@ -377,6 +377,8 @@ export class RoomEncryption {
|
|||||||
|
|
||||||
dispose() {
|
dispose() {
|
||||||
this._disposed = true;
|
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.
|
// tries to prepend `amount` entries to the `entries` list.
|
||||||
async loadAtTop(amount) {
|
async loadAtTop(amount) {
|
||||||
|
if (this._disposables.isDisposed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
const firstEventEntry = this._remoteEntries.array.find(e => !!e.eventType);
|
const firstEventEntry = this._remoteEntries.array.find(e => !!e.eventType);
|
||||||
if (!firstEventEntry) {
|
if (!firstEventEntry) {
|
||||||
return;
|
return;
|
||||||
|
@ -30,11 +30,6 @@ export class SessionInfoStorage {
|
|||||||
return Promise.resolve([]);
|
return Promise.resolve([]);
|
||||||
}
|
}
|
||||||
|
|
||||||
async hasAnySession() {
|
|
||||||
const all = await this.getAll();
|
|
||||||
return all && all.length > 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
async updateLastUsed(id, timestamp) {
|
async updateLastUsed(id, timestamp) {
|
||||||
const sessions = await this.getAll();
|
const sessions = await this.getAll();
|
||||||
if (sessions) {
|
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 {
|
class WaitForHandle {
|
||||||
@ -81,14 +92,6 @@ export class ObservableValue extends BaseObservableValue {
|
|||||||
this.emit(this._value);
|
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() {
|
export function tests() {
|
||||||
|
@ -84,4 +84,8 @@ export class MappedMap extends BaseObservableMap {
|
|||||||
get size() {
|
get size() {
|
||||||
return this._mappedValues.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;
|
overscroll-behavior: contain;
|
||||||
}
|
}
|
||||||
|
|
||||||
.RoomList li {
|
.RoomList > li > a {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
@ -52,19 +52,19 @@ limitations under the License.
|
|||||||
padding: 0.4em;
|
padding: 0.4em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.SessionLoadView {
|
.SessionLoadStatusView {
|
||||||
display: flex;
|
display: flex;
|
||||||
}
|
}
|
||||||
|
|
||||||
.SessionLoadView > :not(:first-child) {
|
.SessionLoadStatusView > :not(:first-child) {
|
||||||
margin-left: 12px;
|
margin-left: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.SessionLoadView p {
|
.SessionLoadStatusView p {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.SessionLoadView .spinner {
|
.SessionLoadStatusView .spinner {
|
||||||
--size: 20px;
|
--size: 20px;
|
||||||
}
|
}
|
||||||
|
@ -71,7 +71,7 @@ limitations under the License.
|
|||||||
margin-right: 0px;
|
margin-right: 0px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.button-row button {
|
.button-row .button-action {
|
||||||
margin: 10px 0;
|
margin: 10px 0;
|
||||||
flex: 1 0 auto;
|
flex: 1 0 auto;
|
||||||
}
|
}
|
||||||
@ -92,32 +92,39 @@ limitations under the License.
|
|||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
button.styled.secondary {
|
a.button-action {
|
||||||
|
text-decoration: none;
|
||||||
|
text-align: center;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-action.secondary {
|
||||||
color: #03B381;
|
color: #03B381;
|
||||||
}
|
}
|
||||||
|
|
||||||
button.styled.primary {
|
.button-action.primary {
|
||||||
background-color: #03B381;
|
background-color: #03B381;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
color: white;
|
color: white;
|
||||||
}
|
}
|
||||||
|
|
||||||
button.styled.primary.destructive {
|
.button-action.primary.destructive {
|
||||||
background-color: #FF4B55;
|
background-color: #FF4B55;
|
||||||
}
|
}
|
||||||
|
|
||||||
button.styled.secondary.destructive {
|
.button-action.secondary.destructive {
|
||||||
color: #FF4B55;
|
color: #FF4B55;
|
||||||
}
|
}
|
||||||
|
|
||||||
button.styled {
|
.button-action {
|
||||||
border: none;
|
border: none;
|
||||||
padding: 10px;
|
padding: 10px;
|
||||||
background: none;
|
background: none;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
button.utility {
|
.button-utility {
|
||||||
|
cursor: pointer;
|
||||||
width: 32px;
|
width: 32px;
|
||||||
height: 32px;
|
height: 32px;
|
||||||
background-position: center;
|
background-position: center;
|
||||||
@ -127,11 +134,11 @@ button.utility {
|
|||||||
border-radius: 100%;
|
border-radius: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
button.utility.grid {
|
.button-utility.grid {
|
||||||
background-image: url('icons/enable-grid.svg');
|
background-image: url('icons/enable-grid.svg');
|
||||||
}
|
}
|
||||||
|
|
||||||
button.utility.grid.on {
|
.button-utility.grid.on {
|
||||||
background-image: url('icons/disable-grid.svg');
|
background-image: url('icons/disable-grid.svg');
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -235,15 +242,23 @@ button.utility.grid.on {
|
|||||||
margin-right: -8px;
|
margin-right: -8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.RoomList li {
|
.RoomList > li {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding-right: 8px;
|
padding: 4px 8px 4px 0;
|
||||||
|
/* vertical align */
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.RoomList > li > a {
|
||||||
|
text-decoration: none;
|
||||||
/* vertical align */
|
/* vertical align */
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.RoomList li:not(:first-child) {
|
.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 {
|
.RoomList li.active {
|
||||||
@ -251,7 +266,7 @@ button.utility.grid.on {
|
|||||||
border-radius: 5px;
|
border-radius: 5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.RoomList li > * {
|
.RoomList li > a > * {
|
||||||
margin-right: 8px;
|
margin-right: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -312,6 +327,7 @@ a {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.SessionPickerView .session-info {
|
.SessionPickerView .session-info {
|
||||||
|
text-decoration: none;
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
border: 1px solid rgba(141, 151, 165, 0.15);
|
border: 1px solid rgba(141, 151, 165, 0.15);
|
||||||
border-radius: 8px;
|
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);
|
this.emit(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
get value() {
|
get() {
|
||||||
return navigator.onLine;
|
return navigator.onLine;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -37,6 +37,7 @@ function objHasFns(obj) {
|
|||||||
- className binding returning object with className => enabled map
|
- className binding returning object with className => enabled map
|
||||||
- add subviews inside the template
|
- add subviews inside the template
|
||||||
*/
|
*/
|
||||||
|
// TODO: should we rename this to BoundView or something? As opposed to StaticView ...
|
||||||
export class TemplateView {
|
export class TemplateView {
|
||||||
constructor(value, render = undefined) {
|
constructor(value, render = undefined) {
|
||||||
this._value = value;
|
this._value = value;
|
||||||
|
@ -16,7 +16,7 @@ limitations under the License.
|
|||||||
|
|
||||||
import {TemplateView} from "../general/TemplateView.js";
|
import {TemplateView} from "../general/TemplateView.js";
|
||||||
import {hydrogenGithubLink} from "./common.js";
|
import {hydrogenGithubLink} from "./common.js";
|
||||||
import {SessionLoadView} from "./SessionLoadView.js";
|
import {SessionLoadStatusView} from "./SessionLoadStatusView.js";
|
||||||
|
|
||||||
export class LoginView extends TemplateView {
|
export class LoginView extends TemplateView {
|
||||||
render(t, vm) {
|
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: "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: "password"}, vm.i18n`Password`), password]),
|
||||||
t.div({className: "form-row"}, [t.label({for: "homeserver"}, vm.i18n`Homeserver`), homeserver]),
|
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.div({className: "button-row"}, [
|
||||||
t.button({
|
t.a({
|
||||||
className: "styled secondary",
|
className: "button-action secondary",
|
||||||
onClick: () => vm.cancel(), disabled
|
href: vm.cancelUrl
|
||||||
}, [vm.i18n`Go Back`]),
|
}, [vm.i18n`Go Back`]),
|
||||||
t.button({
|
t.button({
|
||||||
className: "styled primary",
|
className: "button-action primary",
|
||||||
onClick: () => vm.login(username.value, password.value, homeserver.value),
|
onClick: () => vm.login(username.value, password.value, homeserver.value),
|
||||||
disabled
|
disabled
|
||||||
}, vm.i18n`Log In`),
|
}, 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");
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
you may not use this file except in compliance with 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 {TemplateView} from "../general/TemplateView.js";
|
||||||
import {spinner} from "../common.js";
|
import {SessionLoadStatusView} from "./SessionLoadStatusView.js";
|
||||||
|
|
||||||
export class SessionLoadView extends TemplateView {
|
export class SessionLoadView extends TemplateView {
|
||||||
render(t) {
|
render(t, vm) {
|
||||||
return t.div({className: "SessionLoadView"}, [
|
return t.div({className: "PreSessionScreen"}, [
|
||||||
spinner(t, {hiddenWithLayout: vm => !vm.loading}),
|
t.div({className: "logo"}),
|
||||||
t.p(vm => vm.loadLabel)
|
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 {ListView} from "../general/ListView.js";
|
||||||
import {TemplateView} from "../general/TemplateView.js";
|
import {TemplateView} from "../general/TemplateView.js";
|
||||||
import {hydrogenGithubLink} from "./common.js";
|
import {hydrogenGithubLink} from "./common.js";
|
||||||
import {SessionLoadView} from "./SessionLoadView.js";
|
import {SessionLoadStatusView} from "./SessionLoadStatusView.js";
|
||||||
|
|
||||||
function selectFileAsText(mimeType) {
|
function selectFileAsText(mimeType) {
|
||||||
const input = document.createElement("input");
|
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) {
|
render(t, vm) {
|
||||||
const deleteButton = t.button({
|
const deleteButton = t.button({
|
||||||
className: "destructive",
|
className: "destructive",
|
||||||
@ -58,7 +64,7 @@ class SessionPickerItemView extends TemplateView {
|
|||||||
}, "Sign Out");
|
}, "Sign Out");
|
||||||
const clearButton = t.button({
|
const clearButton = t.button({
|
||||||
disabled: vm => vm.isClearing,
|
disabled: vm => vm.isClearing,
|
||||||
onClick: () => vm.clear(),
|
onClick: this._onClearClick.bind(this),
|
||||||
}, "Clear");
|
}, "Clear");
|
||||||
const exportButton = t.button({
|
const exportButton = t.button({
|
||||||
disabled: vm => vm.isClearing,
|
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)));
|
const errorMessage = t.if(vm => vm.error, t.createTemplate(t => t.p({className: "error"}, vm => vm.error)));
|
||||||
return t.li([
|
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: `avatar usercolor${vm.avatarColorNumber}`}, vm => vm.avatarInitials),
|
||||||
t.div({className: "user-id"}, vm => vm.label),
|
t.div({className: "user-id"}, vm => vm.label),
|
||||||
]),
|
]),
|
||||||
@ -92,11 +98,6 @@ export class SessionPickerView extends TemplateView {
|
|||||||
render(t, vm) {
|
render(t, vm) {
|
||||||
const sessionList = new ListView({
|
const sessionList = new ListView({
|
||||||
list: vm.sessions,
|
list: vm.sessions,
|
||||||
onItemClick: (item, event) => {
|
|
||||||
if (event.target.closest(".session-info")) {
|
|
||||||
vm.pick(item.value.id);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
parentProvidesUpdates: false,
|
parentProvidesUpdates: false,
|
||||||
}, sessionInfo => {
|
}, sessionInfo => {
|
||||||
return new SessionPickerItemView(sessionInfo);
|
return new SessionPickerItemView(sessionInfo);
|
||||||
@ -109,15 +110,15 @@ export class SessionPickerView extends TemplateView {
|
|||||||
t.view(sessionList),
|
t.view(sessionList),
|
||||||
t.div({className: "button-row"}, [
|
t.div({className: "button-row"}, [
|
||||||
t.button({
|
t.button({
|
||||||
className: "styled secondary",
|
className: "button-action secondary",
|
||||||
onClick: async () => vm.import(await selectFileAsText("application/json"))
|
onClick: async () => vm.import(await selectFileAsText("application/json"))
|
||||||
}, vm.i18n`Import a session`),
|
}, vm.i18n`Import a session`),
|
||||||
t.button({
|
t.a({
|
||||||
className: "styled primary",
|
className: "button-action primary",
|
||||||
onClick: () => vm.cancel()
|
href: vm.cancelUrl
|
||||||
}, vm.i18n`Sign In`)
|
}, 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))
|
t.p(hydrogenGithubLink(t))
|
||||||
])
|
])
|
||||||
]);
|
]);
|
||||||
|
@ -23,8 +23,8 @@ export class RoomGridView extends TemplateView {
|
|||||||
const children = [];
|
const children = [];
|
||||||
for (let i = 0; i < (vm.height * vm.width); i+=1) {
|
for (let i = 0; i < (vm.height * vm.width); i+=1) {
|
||||||
children.push(t.div({
|
children.push(t.div({
|
||||||
onClick: () => vm.setFocusIndex(i),
|
onClick: () => vm.focusTile(i),
|
||||||
onFocusin: () => vm.setFocusIndex(i),
|
onFocusin: () => vm.focusTile(i),
|
||||||
className: {
|
className: {
|
||||||
"container": true,
|
"container": true,
|
||||||
[`tile${i}`]: true,
|
[`tile${i}`]: true,
|
||||||
|
@ -32,14 +32,14 @@ export class SessionView extends TemplateView {
|
|||||||
t.view(new SessionStatusView(vm.sessionStatusViewModel)),
|
t.view(new SessionStatusView(vm.sessionStatusViewModel)),
|
||||||
t.div({className: "main"}, [
|
t.div({className: "main"}, [
|
||||||
t.view(new LeftPanelView(vm.leftPanelViewModel)),
|
t.view(new LeftPanelView(vm.leftPanelViewModel)),
|
||||||
t.mapView(vm => vm.selectionId, selectionId => {
|
t.mapView(vm => vm.activeSection, activeSection => {
|
||||||
switch (selectionId) {
|
switch (activeSection) {
|
||||||
case "roomgrid":
|
case "roomgrid":
|
||||||
return new RoomGridView(vm.roomGridViewModel);
|
return new RoomGridView(vm.roomGridViewModel);
|
||||||
case "placeholder":
|
case "placeholder":
|
||||||
return new StaticView(t => t.div({className: "room-placeholder"}, t.h2(vm.i18n`Choose a room on the left side.`)));
|
return new StaticView(t => t.div({className: "room-placeholder"}, t.h2(vm.i18n`Choose a room on the left side.`)));
|
||||||
default: //room id
|
default: //room id
|
||||||
return new RoomView(vm.currentRoom);
|
return new RoomView(vm.currentRoomViewModel);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
])
|
])
|
||||||
|
@ -68,7 +68,7 @@ export class LeftPanelView extends TemplateView {
|
|||||||
t.button({
|
t.button({
|
||||||
onClick: () => vm.toggleGrid(),
|
onClick: () => vm.toggleGrid(),
|
||||||
className: {
|
className: {
|
||||||
utility: true,
|
"button-utility": true,
|
||||||
grid: true,
|
grid: true,
|
||||||
on: vm => vm.gridEnabled
|
on: vm => vm.gridEnabled
|
||||||
},
|
},
|
||||||
@ -83,7 +83,6 @@ export class LeftPanelView extends TemplateView {
|
|||||||
{
|
{
|
||||||
className: "RoomList",
|
className: "RoomList",
|
||||||
list: vm.roomList,
|
list: vm.roomList,
|
||||||
onItemClick: (roomTile, event) => roomTile.clicked(event)
|
|
||||||
},
|
},
|
||||||
roomTileVM => new RoomTileView(roomTileVM)
|
roomTileVM => new RoomTileView(roomTileVM)
|
||||||
))
|
))
|
||||||
|
@ -25,22 +25,19 @@ export class RoomTileView extends TemplateView {
|
|||||||
"hidden": vm => vm.hidden
|
"hidden": vm => vm.hidden
|
||||||
};
|
};
|
||||||
return t.li({"className": classes}, [
|
return t.li({"className": classes}, [
|
||||||
renderAvatar(t, vm, 32),
|
t.a({href: vm.url}, [
|
||||||
t.div({className: "description"}, [
|
renderAvatar(t, vm, 32),
|
||||||
t.div({className: {"name": true, unread: vm => vm.isUnread}}, vm => vm.name),
|
t.div({className: "description"}, [
|
||||||
t.div({
|
t.div({className: {"name": true, unread: vm => vm.isUnread}}, vm => vm.name),
|
||||||
className: {
|
t.div({
|
||||||
"badge": true,
|
className: {
|
||||||
highlighted: vm => vm.isHighlighted,
|
"badge": true,
|
||||||
hidden: vm => !vm.badgeCount
|
highlighted: vm => vm.isHighlighted,
|
||||||
}
|
hidden: vm => !vm.badgeCount
|
||||||
}, vm => vm.badgeCount),
|
}
|
||||||
|
}, vm => vm.badgeCount),
|
||||||
|
])
|
||||||
])
|
])
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
// called from ListView
|
|
||||||
clicked() {
|
|
||||||
this.value.open();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
@ -45,7 +45,7 @@
|
|||||||
const view = new LoginView(vm({
|
const view = new LoginView(vm({
|
||||||
defaultHomeServer: "https://hs.tld",
|
defaultHomeServer: "https://hs.tld",
|
||||||
login: () => alert("Logging in!"),
|
login: () => alert("Logging in!"),
|
||||||
goBack: () => alert("Going back"),
|
cancelUrl: "#/session"
|
||||||
}));
|
}));
|
||||||
document.getElementById("login").appendChild(view.mount());
|
document.getElementById("login").appendChild(view.mount());
|
||||||
</script>
|
</script>
|
||||||
@ -59,10 +59,20 @@
|
|||||||
loadLabel: "Doing something important...",
|
loadLabel: "Doing something important...",
|
||||||
loading: true,
|
loading: true,
|
||||||
}),
|
}),
|
||||||
|
cancelUrl: "#/session",
|
||||||
defaultHomeServer: "https://hs.tld",
|
defaultHomeServer: "https://hs.tld",
|
||||||
}));
|
}));
|
||||||
document.getElementById("login-loading").appendChild(view.mount());
|
document.getElementById("login-loading").appendChild(view.mount());
|
||||||
</script>
|
</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>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
@ -22,6 +22,10 @@ function disposeValue(value) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isDisposable(value) {
|
||||||
|
return value && (typeof value === "function" || typeof value.dispose === "function");
|
||||||
|
}
|
||||||
|
|
||||||
export class Disposables {
|
export class Disposables {
|
||||||
constructor() {
|
constructor() {
|
||||||
this._disposables = [];
|
this._disposables = [];
|
||||||
@ -31,6 +35,9 @@ export class Disposables {
|
|||||||
if (this.isDisposed) {
|
if (this.isDisposed) {
|
||||||
throw new Error("Already disposed, check isDisposed after await if needed");
|
throw new Error("Already disposed, check isDisposed after await if needed");
|
||||||
}
|
}
|
||||||
|
if (!isDisposable(disposable)) {
|
||||||
|
throw new Error("Not a disposable");
|
||||||
|
}
|
||||||
this._disposables.push(disposable);
|
this._disposables.push(disposable);
|
||||||
return disposable;
|
return disposable;
|
||||||
}
|
}
|
||||||
@ -40,6 +47,7 @@ export class Disposables {
|
|||||||
if (idx >= 0) {
|
if (idx >= 0) {
|
||||||
this._disposables.splice(idx, 1);
|
this._disposables.splice(idx, 1);
|
||||||
}
|
}
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
dispose() {
|
dispose() {
|
||||||
|
Loading…
Reference in New Issue
Block a user