Merge pull request #676 from vector-im/ts-conversion-domain-navigation

Convert /domain/navigation to typescript
This commit is contained in:
Bruno Windels 2022-07-29 14:21:17 +00:00 committed by GitHub
commit b40ce6137e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
10 changed files with 268 additions and 165 deletions

View file

@ -16,10 +16,11 @@ limitations under the License.
import {Options as BaseOptions, ViewModel} from "./ViewModel"; import {Options as BaseOptions, ViewModel} from "./ViewModel";
import {Client} from "../matrix/Client.js"; import {Client} from "../matrix/Client.js";
import {SegmentType} from "./navigation/index";
type Options = { sessionId: string; } & BaseOptions; type Options = { sessionId: string; } & BaseOptions;
export class LogoutViewModel extends ViewModel<Options> { export class LogoutViewModel extends ViewModel<SegmentType, Options> {
private _sessionId: string; private _sessionId: string;
private _busy: boolean; private _busy: boolean;
private _showConfirm: boolean; private _showConfirm: boolean;
@ -41,7 +42,7 @@ export class LogoutViewModel extends ViewModel<Options> {
return this._busy; return this._busy;
} }
get cancelUrl(): string { get cancelUrl(): string | undefined {
return this.urlCreator.urlForSegment("session", true); return this.urlCreator.urlForSegment("session", true);
} }

View file

@ -27,17 +27,19 @@ import type {Platform} from "../platform/web/Platform";
import type {Clock} from "../platform/web/dom/Clock"; import type {Clock} from "../platform/web/dom/Clock";
import type {ILogger} from "../logging/types"; import type {ILogger} from "../logging/types";
import type {Navigation} from "./navigation/Navigation"; import type {Navigation} from "./navigation/Navigation";
import type {URLRouter} from "./navigation/URLRouter"; import type {SegmentType} from "./navigation/index";
import type {IURLRouter} from "./navigation/URLRouter";
export type Options = { export type Options<T extends object = SegmentType> = {
platform: Platform platform: Platform;
logger: ILogger logger: ILogger;
urlCreator: URLRouter urlCreator: IURLRouter<T>;
navigation: Navigation navigation: Navigation<T>;
emitChange?: (params: any) => void emitChange?: (params: any) => void;
} }
export class ViewModel<O extends Options = Options> extends EventEmitter<{change: never}> {
export class ViewModel<N extends object = SegmentType, O extends Options<N> = Options<N>> extends EventEmitter<{change: never}> {
private disposables?: Disposables; private disposables?: Disposables;
private _isDisposed = false; private _isDisposed = false;
private _options: Readonly<O>; private _options: Readonly<O>;
@ -47,7 +49,7 @@ export class ViewModel<O extends Options = Options> extends EventEmitter<{change
this._options = options; this._options = options;
} }
childOptions<T extends Object>(explicitOptions: T): T & Options { childOptions<T extends Object>(explicitOptions: T): T & Options<N> {
return Object.assign({}, this._options, explicitOptions); return Object.assign({}, this._options, explicitOptions);
} }
@ -58,9 +60,9 @@ export class ViewModel<O extends Options = Options> extends EventEmitter<{change
return this._options[name]; return this._options[name];
} }
observeNavigation(type: string, onChange: (value: string | true | undefined, type: string) => void): void { observeNavigation<T extends keyof N>(type: T, onChange: (value: N[T], type: T) => void): void {
const segmentObservable = this.navigation.observe(type); const segmentObservable = this.navigation.observe(type);
const unsubscribe = segmentObservable.subscribe((value: string | true | undefined) => { const unsubscribe = segmentObservable.subscribe((value: N[T]) => {
onChange(value, type); onChange(value, type);
}); });
this.track(unsubscribe); this.track(unsubscribe);
@ -135,11 +137,12 @@ export class ViewModel<O extends Options = Options> extends EventEmitter<{change
return this.platform.logger; return this.platform.logger;
} }
get urlCreator(): URLRouter { get urlCreator(): IURLRouter<N> {
return this._options.urlCreator; return this._options.urlCreator;
} }
get navigation(): Navigation { get navigation(): Navigation<N> {
return this._options.navigation; // typescript needs a little help here
return this._options.navigation as unknown as Navigation<N>;
} }
} }

View file

@ -21,6 +21,8 @@ import {StartSSOLoginViewModel} from "./StartSSOLoginViewModel.js";
import {CompleteSSOLoginViewModel} from "./CompleteSSOLoginViewModel.js"; import {CompleteSSOLoginViewModel} from "./CompleteSSOLoginViewModel.js";
import {LoadStatus} from "../../matrix/Client.js"; import {LoadStatus} from "../../matrix/Client.js";
import {SessionLoadViewModel} from "../SessionLoadViewModel.js"; import {SessionLoadViewModel} from "../SessionLoadViewModel.js";
import {SegmentType} from "../navigation/index";
import type {PasswordLoginMethod, SSOLoginHelper, TokenLoginMethod, ILoginMethod} from "../../matrix/login"; import type {PasswordLoginMethod, SSOLoginHelper, TokenLoginMethod, ILoginMethod} from "../../matrix/login";
type Options = { type Options = {
@ -29,7 +31,7 @@ type Options = {
loginToken?: string; loginToken?: string;
} & BaseOptions; } & BaseOptions;
export class LoginViewModel extends ViewModel<Options> { export class LoginViewModel extends ViewModel<SegmentType, Options> {
private _ready: ReadyFn; private _ready: ReadyFn;
private _loginToken?: string; private _loginToken?: string;
private _client: Client; private _client: Client;

View file

@ -16,27 +16,49 @@ limitations under the License.
import {BaseObservableValue, ObservableValue} from "../../observable/ObservableValue"; import {BaseObservableValue, ObservableValue} from "../../observable/ObservableValue";
export class Navigation {
constructor(allowsChild) { type AllowsChild<T> = (parent: Segment<T> | undefined, child: Segment<T>) => boolean;
/**
* OptionalValue is basically stating that if SegmentType[type] = true:
* - Allow this type to be optional
* - Give it a default value of undefined
* - Also allow it to be true
* This lets us do:
* const s: Segment<SegmentType> = new Segment("create-room");
* instead of
* const s: Segment<SegmentType> = new Segment("create-room", undefined);
*/
export type OptionalValue<T> = T extends true? [(undefined | true)?]: [T];
export class Navigation<T extends object> {
private readonly _allowsChild: AllowsChild<T>;
private _path: Path<T>;
private readonly _observables: Map<keyof T, SegmentObservable<T>> = new Map();
private readonly _pathObservable: ObservableValue<Path<T>>;
constructor(allowsChild: AllowsChild<T>) {
this._allowsChild = allowsChild; this._allowsChild = allowsChild;
this._path = new Path([], allowsChild); this._path = new Path([], allowsChild);
this._observables = new Map();
this._pathObservable = new ObservableValue(this._path); this._pathObservable = new ObservableValue(this._path);
} }
get pathObservable() { get pathObservable(): ObservableValue<Path<T>> {
return this._pathObservable; return this._pathObservable;
} }
get path() { get path(): Path<T> {
return this._path; return this._path;
} }
push(type, value = undefined) { push<K extends keyof T>(type: K, ...value: OptionalValue<T[K]>): void {
return this.applyPath(this.path.with(new Segment(type, value))); const newPath = this.path.with(new Segment(type, ...value));
if (newPath) {
this.applyPath(newPath);
}
} }
applyPath(path) { applyPath(path: Path<T>): void {
// Path is not exported, so you can only create a Path through Navigation, // Path is not exported, so you can only create a Path through Navigation,
// so we assume it respects the allowsChild rules // so we assume it respects the allowsChild rules
const oldPath = this._path; const oldPath = this._path;
@ -60,7 +82,7 @@ export class Navigation {
this._pathObservable.set(this._path); this._pathObservable.set(this._path);
} }
observe(type) { observe(type: keyof T): SegmentObservable<T> {
let observable = this._observables.get(type); let observable = this._observables.get(type);
if (!observable) { if (!observable) {
observable = new SegmentObservable(this, type); observable = new SegmentObservable(this, type);
@ -69,9 +91,9 @@ export class Navigation {
return observable; return observable;
} }
pathFrom(segments) { pathFrom(segments: Segment<any>[]): Path<T> {
let parent; let parent: Segment<any> | undefined;
let i; let i: number;
for (i = 0; i < segments.length; i += 1) { for (i = 0; i < segments.length; i += 1) {
if (!this._allowsChild(parent, segments[i])) { if (!this._allowsChild(parent, segments[i])) {
return new Path(segments.slice(0, i), this._allowsChild); return new Path(segments.slice(0, i), this._allowsChild);
@ -81,12 +103,12 @@ export class Navigation {
return new Path(segments, this._allowsChild); return new Path(segments, this._allowsChild);
} }
segment(type, value) { segment<K extends keyof T>(type: K, ...value: OptionalValue<T[K]>): Segment<T> {
return new Segment(type, value); return new Segment(type, ...value);
} }
} }
function segmentValueEqual(a, b) { function segmentValueEqual<T>(a?: T[keyof T], b?: T[keyof T]): boolean {
if (a === b) { if (a === b) {
return true; return true;
} }
@ -103,24 +125,29 @@ function segmentValueEqual(a, b) {
return false; return false;
} }
export class Segment {
constructor(type, value) { export class Segment<T, K extends keyof T = any> {
this.type = type; public value: T[K];
this.value = value === undefined ? true : value;
constructor(public type: K, ...value: OptionalValue<T[K]>) {
this.value = (value[0] === undefined ? true : value[0]) as unknown as T[K];
} }
} }
class Path { class Path<T> {
constructor(segments = [], allowsChild) { private readonly _segments: Segment<T, any>[];
private readonly _allowsChild: AllowsChild<T>;
constructor(segments: Segment<T>[] = [], allowsChild: AllowsChild<T>) {
this._segments = segments; this._segments = segments;
this._allowsChild = allowsChild; this._allowsChild = allowsChild;
} }
clone() { clone(): Path<T> {
return new Path(this._segments.slice(), this._allowsChild); return new Path(this._segments.slice(), this._allowsChild);
} }
with(segment) { with(segment: Segment<T>): Path<T> | undefined {
let index = this._segments.length - 1; let index = this._segments.length - 1;
do { do {
if (this._allowsChild(this._segments[index], segment)) { if (this._allowsChild(this._segments[index], segment)) {
@ -132,10 +159,10 @@ class Path {
index -= 1; index -= 1;
} while(index >= -1); } while(index >= -1);
// allow -1 as well so we check if the segment is allowed as root // allow -1 as well so we check if the segment is allowed as root
return null; return undefined;
} }
until(type) { until(type: keyof T): Path<T> {
const index = this._segments.findIndex(s => s.type === type); const index = this._segments.findIndex(s => s.type === type);
if (index !== -1) { if (index !== -1) {
return new Path(this._segments.slice(0, index + 1), this._allowsChild) return new Path(this._segments.slice(0, index + 1), this._allowsChild)
@ -143,11 +170,11 @@ class Path {
return new Path([], this._allowsChild); return new Path([], this._allowsChild);
} }
get(type) { get(type: keyof T): Segment<T> | undefined {
return this._segments.find(s => s.type === type); return this._segments.find(s => s.type === type);
} }
replace(segment) { replace(segment: Segment<T>): Path<T> | undefined {
const index = this._segments.findIndex(s => s.type === segment.type); const index = this._segments.findIndex(s => s.type === segment.type);
if (index !== -1) { if (index !== -1) {
const parent = this._segments[index - 1]; const parent = this._segments[index - 1];
@ -160,10 +187,10 @@ class Path {
} }
} }
} }
return null; return undefined;
} }
get segments() { get segments(): Segment<T>[] {
return this._segments; return this._segments;
} }
} }
@ -172,43 +199,49 @@ class Path {
* custom observable so it always returns what is in navigation.path, even if we haven't emitted the change yet. * 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. * This ensures that observers of a segment can also read the most recent value of other segments.
*/ */
class SegmentObservable extends BaseObservableValue { class SegmentObservable<T extends object> extends BaseObservableValue<T[keyof T] | undefined> {
constructor(navigation, type) { private readonly _navigation: Navigation<T>;
private _type: keyof T;
private _lastSetValue?: T[keyof T];
constructor(navigation: Navigation<T>, type: keyof T) {
super(); super();
this._navigation = navigation; this._navigation = navigation;
this._type = type; this._type = type;
this._lastSetValue = navigation.path.get(type)?.value; this._lastSetValue = navigation.path.get(type)?.value;
} }
get() { get(): T[keyof T] | undefined {
const path = this._navigation.path; const path = this._navigation.path;
const segment = path.get(this._type); const segment = path.get(this._type);
const value = segment?.value; const value = segment?.value;
return value; return value;
} }
emitIfChanged() { emitIfChanged(): void {
const newValue = this.get(); const newValue = this.get();
if (!segmentValueEqual(newValue, this._lastSetValue)) { if (!segmentValueEqual<T>(newValue, this._lastSetValue)) {
this._lastSetValue = newValue; this._lastSetValue = newValue;
this.emit(newValue); this.emit(newValue);
} }
} }
} }
export type {Path};
export function tests() { export function tests() {
function createMockNavigation() { function createMockNavigation() {
return new Navigation((parent, {type}) => { return new Navigation((parent, {type}) => {
switch (parent?.type) { switch (parent?.type) {
case undefined: case undefined:
return type === "1" || "2"; return type === "1" || type === "2";
case "1": case "1":
return type === "1.1"; return type === "1.1";
case "1.1": case "1.1":
return type === "1.1.1"; return type === "1.1.1";
case "2": case "2":
return type === "2.1" || "2.2"; return type === "2.1" || type === "2.2";
default: default:
return false; return false;
} }
@ -216,7 +249,7 @@ export function tests() {
} }
function observeTypes(nav, types) { function observeTypes(nav, types) {
const changes = []; const changes: {type:string, value:any}[] = [];
for (const type of types) { for (const type of types) {
nav.observe(type).subscribe(value => { nav.observe(type).subscribe(value => {
changes.push({type, value}); changes.push({type, value});
@ -225,6 +258,12 @@ export function tests() {
return changes; return changes;
} }
type SegmentType = {
"foo": number;
"bar": number;
"baz": number;
}
return { return {
"applying a path emits an event on the observable": assert => { "applying a path emits an event on the observable": assert => {
const nav = createMockNavigation(); const nav = createMockNavigation();
@ -242,18 +281,18 @@ export function tests() {
assert.equal(changes[1].value, 8); assert.equal(changes[1].value, 8);
}, },
"path.get": assert => { "path.get": assert => {
const path = new Path([new Segment("foo", 5), new Segment("bar", 6)], () => true); const path = new Path<SegmentType>([new Segment("foo", 5), new Segment("bar", 6)], () => true);
assert.equal(path.get("foo").value, 5); assert.equal(path.get("foo")!.value, 5);
assert.equal(path.get("bar").value, 6); assert.equal(path.get("bar")!.value, 6);
}, },
"path.replace success": assert => { "path.replace success": assert => {
const path = new Path([new Segment("foo", 5), new Segment("bar", 6)], () => true); const path = new Path<SegmentType>([new Segment("foo", 5), new Segment("bar", 6)], () => true);
const newPath = path.replace(new Segment("foo", 1)); const newPath = path.replace(new Segment("foo", 1));
assert.equal(newPath.get("foo").value, 1); assert.equal(newPath!.get("foo")!.value, 1);
assert.equal(newPath.get("bar").value, 6); assert.equal(newPath!.get("bar")!.value, 6);
}, },
"path.replace not found": assert => { "path.replace not found": assert => {
const path = new Path([new Segment("foo", 5), new Segment("bar", 6)], () => true); const path = new Path<SegmentType>([new Segment("foo", 5), new Segment("bar", 6)], () => true);
const newPath = path.replace(new Segment("baz", 1)); const newPath = path.replace(new Segment("baz", 1));
assert.equal(newPath, null); assert.equal(newPath, null);
} }

View file

@ -14,28 +14,55 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
export class URLRouter { import type {History} from "../../platform/web/dom/History.js";
constructor({history, navigation, parseUrlPath, stringifyPath}) { import type {Navigation, Segment, Path, OptionalValue} from "./Navigation";
import type {SubscriptionHandle} from "../../observable/BaseObservable";
type ParseURLPath<T> = (urlPath: string, currentNavPath: Path<T>, defaultSessionId?: string) => Segment<T>[];
type StringifyPath<T> = (path: Path<T>) => string;
export interface IURLRouter<T> {
attach(): void;
dispose(): void;
pushUrl(url: string): void;
tryRestoreLastUrl(): boolean;
urlForSegments(segments: Segment<T>[]): string | undefined;
urlForSegment<K extends keyof T>(type: K, ...value: OptionalValue<T[K]>): string | undefined;
urlUntilSegment(type: keyof T): string;
urlForPath(path: Path<T>): string;
openRoomActionUrl(roomId: string): string;
createSSOCallbackURL(): string;
normalizeUrl(): void;
}
export class URLRouter<T extends {session: string | boolean}> implements IURLRouter<T> {
private readonly _history: History;
private readonly _navigation: Navigation<T>;
private readonly _parseUrlPath: ParseURLPath<T>;
private readonly _stringifyPath: StringifyPath<T>;
private _subscription?: SubscriptionHandle;
private _pathSubscription?: SubscriptionHandle;
private _isApplyingUrl: boolean = false;
private _defaultSessionId?: string;
constructor(history: History, navigation: Navigation<T>, parseUrlPath: ParseURLPath<T>, stringifyPath: StringifyPath<T>) {
this._history = history; this._history = history;
this._navigation = navigation; this._navigation = navigation;
this._parseUrlPath = parseUrlPath; this._parseUrlPath = parseUrlPath;
this._stringifyPath = stringifyPath; this._stringifyPath = stringifyPath;
this._subscription = null;
this._pathSubscription = null;
this._isApplyingUrl = false;
this._defaultSessionId = this._getLastSessionId(); this._defaultSessionId = this._getLastSessionId();
} }
_getLastSessionId() { private _getLastSessionId(): string | undefined {
const navPath = this._urlAsNavPath(this._history.getLastSessionUrl() || ""); const navPath = this._urlAsNavPath(this._history.getLastSessionUrl() || "");
const sessionId = navPath.get("session")?.value; const sessionId = navPath.get("session")?.value;
if (typeof sessionId === "string") { if (typeof sessionId === "string") {
return sessionId; return sessionId;
} }
return null; return undefined;
} }
attach() { attach(): void {
this._subscription = this._history.subscribe(url => this._applyUrl(url)); this._subscription = this._history.subscribe(url => this._applyUrl(url));
// subscribe to path before applying initial url // subscribe to path before applying initial url
// so redirects in _applyNavPathToHistory are reflected in url bar // so redirects in _applyNavPathToHistory are reflected in url bar
@ -43,12 +70,12 @@ export class URLRouter {
this._applyUrl(this._history.get()); this._applyUrl(this._history.get());
} }
dispose() { dispose(): void {
this._subscription = this._subscription(); if (this._subscription) { this._subscription = this._subscription(); }
this._pathSubscription = this._pathSubscription(); if (this._pathSubscription) { this._pathSubscription = this._pathSubscription(); }
} }
_applyNavPathToHistory(path) { private _applyNavPathToHistory(path: Path<T>): void {
const url = this.urlForPath(path); const url = this.urlForPath(path);
if (url !== this._history.get()) { if (url !== this._history.get()) {
if (this._isApplyingUrl) { if (this._isApplyingUrl) {
@ -60,7 +87,7 @@ export class URLRouter {
} }
} }
_applyNavPathToNavigation(navPath) { private _applyNavPathToNavigation(navPath: Path<T>): void {
// this will cause _applyNavPathToHistory to be called, // this will cause _applyNavPathToHistory to be called,
// so set a flag whether this request came from ourselves // so set a flag whether this request came from ourselves
// (in which case it is a redirect if the url does not match the current one) // (in which case it is a redirect if the url does not match the current one)
@ -69,21 +96,21 @@ export class URLRouter {
this._isApplyingUrl = false; this._isApplyingUrl = false;
} }
_urlAsNavPath(url) { private _urlAsNavPath(url: string): Path<T> {
const urlPath = this._history.urlAsPath(url); const urlPath = this._history.urlAsPath(url);
return this._navigation.pathFrom(this._parseUrlPath(urlPath, this._navigation.path, this._defaultSessionId)); return this._navigation.pathFrom(this._parseUrlPath(urlPath, this._navigation.path, this._defaultSessionId));
} }
_applyUrl(url) { private _applyUrl(url: string): void {
const navPath = this._urlAsNavPath(url); const navPath = this._urlAsNavPath(url);
this._applyNavPathToNavigation(navPath); this._applyNavPathToNavigation(navPath);
} }
pushUrl(url) { pushUrl(url: string): void {
this._history.pushUrl(url); this._history.pushUrl(url);
} }
tryRestoreLastUrl() { tryRestoreLastUrl(): boolean {
const lastNavPath = this._urlAsNavPath(this._history.getLastSessionUrl() || ""); const lastNavPath = this._urlAsNavPath(this._history.getLastSessionUrl() || "");
if (lastNavPath.segments.length !== 0) { if (lastNavPath.segments.length !== 0) {
this._applyNavPathToNavigation(lastNavPath); this._applyNavPathToNavigation(lastNavPath);
@ -92,8 +119,8 @@ export class URLRouter {
return false; return false;
} }
urlForSegments(segments) { urlForSegments(segments: Segment<T>[]): string | undefined {
let path = this._navigation.path; let path: Path<T> | undefined = this._navigation.path;
for (const segment of segments) { for (const segment of segments) {
path = path.with(segment); path = path.with(segment);
if (!path) { if (!path) {
@ -103,29 +130,29 @@ export class URLRouter {
return this.urlForPath(path); return this.urlForPath(path);
} }
urlForSegment(type, value) { urlForSegment<K extends keyof T>(type: K, ...value: OptionalValue<T[K]>): string | undefined {
return this.urlForSegments([this._navigation.segment(type, value)]); return this.urlForSegments([this._navigation.segment(type, ...value)]);
} }
urlUntilSegment(type) { urlUntilSegment(type: keyof T): string {
return this.urlForPath(this._navigation.path.until(type)); return this.urlForPath(this._navigation.path.until(type));
} }
urlForPath(path) { urlForPath(path: Path<T>): string {
return this._history.pathAsUrl(this._stringifyPath(path)); return this._history.pathAsUrl(this._stringifyPath(path));
} }
openRoomActionUrl(roomId) { openRoomActionUrl(roomId: string): string {
// not a segment to navigation knowns about, so append it manually // not a segment to navigation knowns about, so append it manually
const urlPath = `${this._stringifyPath(this._navigation.path.until("session"))}/open-room/${roomId}`; const urlPath = `${this._stringifyPath(this._navigation.path.until("session"))}/open-room/${roomId}`;
return this._history.pathAsUrl(urlPath); return this._history.pathAsUrl(urlPath);
} }
createSSOCallbackURL() { createSSOCallbackURL(): string {
return window.location.origin; return window.location.origin;
} }
normalizeUrl() { normalizeUrl(): void {
// Remove any queryParameters from the URL // Remove any queryParameters from the URL
// Gets rid of the loginToken after SSO // Gets rid of the loginToken after SSO
this._history.replaceUrlSilently(`${window.location.origin}/${window.location.hash}`); this._history.replaceUrlSilently(`${window.location.origin}/${window.location.hash}`);

View file

@ -14,18 +14,36 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import {Navigation, Segment} from "./Navigation.js"; import {Navigation, Segment} from "./Navigation";
import {URLRouter} from "./URLRouter.js"; import {URLRouter} from "./URLRouter";
import type {Path, OptionalValue} from "./Navigation";
export function createNavigation() { export type SegmentType = {
"login": true;
"session": string | boolean;
"sso": string;
"logout": true;
"room": string;
"rooms": string[];
"settings": true;
"create-room": true;
"empty-grid-tile": number;
"lightbox": string;
"right-panel": true;
"details": true;
"members": true;
"member": string;
};
export function createNavigation(): Navigation<SegmentType> {
return new Navigation(allowsChild); return new Navigation(allowsChild);
} }
export function createRouter({history, navigation}) { export function createRouter({history, navigation}: {history: History, navigation: Navigation<SegmentType>}): URLRouter<SegmentType> {
return new URLRouter({history, navigation, stringifyPath, parseUrlPath}); return new URLRouter(history, navigation, parseUrlPath, stringifyPath);
} }
function allowsChild(parent, child) { function allowsChild(parent: Segment<SegmentType> | undefined, child: Segment<SegmentType>): boolean {
const {type} = child; const {type} = child;
switch (parent?.type) { switch (parent?.type) {
case undefined: case undefined:
@ -45,8 +63,9 @@ function allowsChild(parent, child) {
} }
} }
export function removeRoomFromPath(path, roomId) { export function removeRoomFromPath(path: Path<SegmentType>, roomId: string): Path<SegmentType> | undefined {
const rooms = path.get("rooms"); let newPath: Path<SegmentType> | undefined = path;
const rooms = newPath.get("rooms");
let roomIdGridIndex = -1; let roomIdGridIndex = -1;
// first delete from rooms segment // first delete from rooms segment
if (rooms) { if (rooms) {
@ -54,22 +73,22 @@ export function removeRoomFromPath(path, roomId) {
if (roomIdGridIndex !== -1) { if (roomIdGridIndex !== -1) {
const idsWithoutRoom = rooms.value.slice(); const idsWithoutRoom = rooms.value.slice();
idsWithoutRoom[roomIdGridIndex] = ""; idsWithoutRoom[roomIdGridIndex] = "";
path = path.replace(new Segment("rooms", idsWithoutRoom)); newPath = newPath.replace(new Segment("rooms", idsWithoutRoom));
} }
} }
const room = path.get("room"); const room = newPath!.get("room");
// then from room (which occurs with or without rooms) // then from room (which occurs with or without rooms)
if (room && room.value === roomId) { if (room && room.value === roomId) {
if (roomIdGridIndex !== -1) { if (roomIdGridIndex !== -1) {
path = path.with(new Segment("empty-grid-tile", roomIdGridIndex)); newPath = newPath!.with(new Segment("empty-grid-tile", roomIdGridIndex));
} else { } else {
path = path.until("session"); newPath = newPath!.until("session");
} }
} }
return path; return newPath;
} }
function roomsSegmentWithRoom(rooms, roomId, path) { function roomsSegmentWithRoom(rooms: Segment<SegmentType, "rooms">, roomId: string, path: Path<SegmentType>): Segment<SegmentType, "rooms"> {
if(!rooms.value.includes(roomId)) { if(!rooms.value.includes(roomId)) {
const emptyGridTile = path.get("empty-grid-tile"); const emptyGridTile = path.get("empty-grid-tile");
const oldRoom = path.get("room"); const oldRoom = path.get("room");
@ -87,28 +106,28 @@ function roomsSegmentWithRoom(rooms, roomId, path) {
} }
} }
function pushRightPanelSegment(array, segment, value = true) { function pushRightPanelSegment<T extends keyof SegmentType>(array: Segment<SegmentType>[], segment: T, ...value: OptionalValue<SegmentType[T]>): void {
array.push(new Segment("right-panel")); array.push(new Segment("right-panel"));
array.push(new Segment(segment, value)); array.push(new Segment(segment, ...value));
} }
export function addPanelIfNeeded(navigation, path) { export function addPanelIfNeeded<T extends SegmentType>(navigation: Navigation<T>, path: Path<T>): Path<T> {
const segments = navigation.path.segments; const segments = navigation.path.segments;
const i = segments.findIndex(segment => segment.type === "right-panel"); const i = segments.findIndex(segment => segment.type === "right-panel");
let _path = path; let _path = path;
if (i !== -1) { if (i !== -1) {
_path = path.until("room"); _path = path.until("room");
_path = _path.with(segments[i]); _path = _path.with(segments[i])!;
_path = _path.with(segments[i + 1]); _path = _path.with(segments[i + 1])!;
} }
return _path; return _path;
} }
export function parseUrlPath(urlPath, currentNavPath, defaultSessionId) { export function parseUrlPath(urlPath: string, currentNavPath: Path<SegmentType>, defaultSessionId?: string): Segment<SegmentType>[] {
// substr(1) to take of initial / // substring(1) to take of initial /
const parts = urlPath.substr(1).split("/"); const parts = urlPath.substring(1).split("/");
const iterator = parts[Symbol.iterator](); const iterator = parts[Symbol.iterator]();
const segments = []; const segments: Segment<SegmentType>[] = [];
let next; let next;
while (!(next = iterator.next()).done) { while (!(next = iterator.next()).done) {
const type = next.value; const type = next.value;
@ -170,9 +189,9 @@ export function parseUrlPath(urlPath, currentNavPath, defaultSessionId) {
return segments; return segments;
} }
export function stringifyPath(path) { export function stringifyPath(path: Path<SegmentType>): string {
let urlPath = ""; let urlPath = "";
let prevSegment; let prevSegment: Segment<SegmentType> | undefined;
for (const segment of path.segments) { for (const segment of path.segments) {
switch (segment.type) { switch (segment.type) {
case "rooms": case "rooms":
@ -205,9 +224,15 @@ export function stringifyPath(path) {
} }
export function tests() { export function tests() {
function createEmptyPath() {
const nav: Navigation<SegmentType> = new Navigation(allowsChild);
const path = nav.pathFrom([]);
return path;
}
return { return {
"stringify grid url with focused empty tile": assert => { "stringify grid url with focused empty tile": assert => {
const nav = new Navigation(allowsChild); const nav: Navigation<SegmentType> = new Navigation(allowsChild);
const path = nav.pathFrom([ const path = nav.pathFrom([
new Segment("session", 1), new Segment("session", 1),
new Segment("rooms", ["a", "b", "c"]), new Segment("rooms", ["a", "b", "c"]),
@ -217,7 +242,7 @@ export function tests() {
assert.equal(urlPath, "/session/1/rooms/a,b,c/3"); assert.equal(urlPath, "/session/1/rooms/a,b,c/3");
}, },
"stringify grid url with focused room": assert => { "stringify grid url with focused room": assert => {
const nav = new Navigation(allowsChild); const nav: Navigation<SegmentType> = new Navigation(allowsChild);
const path = nav.pathFrom([ const path = nav.pathFrom([
new Segment("session", 1), new Segment("session", 1),
new Segment("rooms", ["a", "b", "c"]), new Segment("rooms", ["a", "b", "c"]),
@ -227,7 +252,7 @@ export function tests() {
assert.equal(urlPath, "/session/1/rooms/a,b,c/1"); assert.equal(urlPath, "/session/1/rooms/a,b,c/1");
}, },
"stringify url with right-panel and details segment": assert => { "stringify url with right-panel and details segment": assert => {
const nav = new Navigation(allowsChild); const nav: Navigation<SegmentType> = new Navigation(allowsChild);
const path = nav.pathFrom([ const path = nav.pathFrom([
new Segment("session", 1), new Segment("session", 1),
new Segment("rooms", ["a", "b", "c"]), new Segment("rooms", ["a", "b", "c"]),
@ -239,13 +264,15 @@ export function tests() {
assert.equal(urlPath, "/session/1/rooms/a,b,c/1/details"); assert.equal(urlPath, "/session/1/rooms/a,b,c/1/details");
}, },
"Parse loginToken query parameter into SSO segment": assert => { "Parse loginToken query parameter into SSO segment": assert => {
const segments = parseUrlPath("?loginToken=a1232aSD123"); const path = createEmptyPath();
const segments = parseUrlPath("?loginToken=a1232aSD123", path);
assert.equal(segments.length, 1); assert.equal(segments.length, 1);
assert.equal(segments[0].type, "sso"); assert.equal(segments[0].type, "sso");
assert.equal(segments[0].value, "a1232aSD123"); assert.equal(segments[0].value, "a1232aSD123");
}, },
"parse grid url path with focused empty tile": assert => { "parse grid url path with focused empty tile": assert => {
const segments = parseUrlPath("/session/1/rooms/a,b,c/3"); const path = createEmptyPath();
const segments = parseUrlPath("/session/1/rooms/a,b,c/3", path);
assert.equal(segments.length, 3); assert.equal(segments.length, 3);
assert.equal(segments[0].type, "session"); assert.equal(segments[0].type, "session");
assert.equal(segments[0].value, "1"); assert.equal(segments[0].value, "1");
@ -255,7 +282,8 @@ export function tests() {
assert.equal(segments[2].value, 3); assert.equal(segments[2].value, 3);
}, },
"parse grid url path with focused room": assert => { "parse grid url path with focused room": assert => {
const segments = parseUrlPath("/session/1/rooms/a,b,c/1"); const path = createEmptyPath();
const segments = parseUrlPath("/session/1/rooms/a,b,c/1", path);
assert.equal(segments.length, 3); assert.equal(segments.length, 3);
assert.equal(segments[0].type, "session"); assert.equal(segments[0].type, "session");
assert.equal(segments[0].value, "1"); assert.equal(segments[0].value, "1");
@ -265,7 +293,8 @@ export function tests() {
assert.equal(segments[2].value, "b"); assert.equal(segments[2].value, "b");
}, },
"parse empty grid url": assert => { "parse empty grid url": assert => {
const segments = parseUrlPath("/session/1/rooms/"); const path = createEmptyPath();
const segments = parseUrlPath("/session/1/rooms/", path);
assert.equal(segments.length, 3); assert.equal(segments.length, 3);
assert.equal(segments[0].type, "session"); assert.equal(segments[0].type, "session");
assert.equal(segments[0].value, "1"); assert.equal(segments[0].value, "1");
@ -275,7 +304,8 @@ export function tests() {
assert.equal(segments[2].value, 0); assert.equal(segments[2].value, 0);
}, },
"parse empty grid url with focus": assert => { "parse empty grid url with focus": assert => {
const segments = parseUrlPath("/session/1/rooms//1"); const path = createEmptyPath();
const segments = parseUrlPath("/session/1/rooms//1", path);
assert.equal(segments.length, 3); assert.equal(segments.length, 3);
assert.equal(segments[0].type, "session"); assert.equal(segments[0].type, "session");
assert.equal(segments[0].value, "1"); assert.equal(segments[0].value, "1");
@ -285,7 +315,7 @@ export function tests() {
assert.equal(segments[2].value, 1); assert.equal(segments[2].value, 1);
}, },
"parse open-room action replacing the current focused room": assert => { "parse open-room action replacing the current focused room": assert => {
const nav = new Navigation(allowsChild); const nav: Navigation<SegmentType> = new Navigation(allowsChild);
const path = nav.pathFrom([ const path = nav.pathFrom([
new Segment("session", 1), new Segment("session", 1),
new Segment("rooms", ["a", "b", "c"]), new Segment("rooms", ["a", "b", "c"]),
@ -301,7 +331,7 @@ export function tests() {
assert.equal(segments[2].value, "d"); assert.equal(segments[2].value, "d");
}, },
"parse open-room action changing focus to an existing room": assert => { "parse open-room action changing focus to an existing room": assert => {
const nav = new Navigation(allowsChild); const nav: Navigation<SegmentType> = new Navigation(allowsChild);
const path = nav.pathFrom([ const path = nav.pathFrom([
new Segment("session", 1), new Segment("session", 1),
new Segment("rooms", ["a", "b", "c"]), new Segment("rooms", ["a", "b", "c"]),
@ -317,7 +347,7 @@ export function tests() {
assert.equal(segments[2].value, "a"); assert.equal(segments[2].value, "a");
}, },
"parse open-room action changing focus to an existing room with details open": assert => { "parse open-room action changing focus to an existing room with details open": assert => {
const nav = new Navigation(allowsChild); const nav: Navigation<SegmentType> = new Navigation(allowsChild);
const path = nav.pathFrom([ const path = nav.pathFrom([
new Segment("session", 1), new Segment("session", 1),
new Segment("rooms", ["a", "b", "c"]), new Segment("rooms", ["a", "b", "c"]),
@ -339,7 +369,7 @@ export function tests() {
assert.equal(segments[4].value, true); assert.equal(segments[4].value, true);
}, },
"open-room action should only copy over previous segments if there are no parts after open-room": assert => { "open-room action should only copy over previous segments if there are no parts after open-room": assert => {
const nav = new Navigation(allowsChild); const nav: Navigation<SegmentType> = new Navigation(allowsChild);
const path = nav.pathFrom([ const path = nav.pathFrom([
new Segment("session", 1), new Segment("session", 1),
new Segment("rooms", ["a", "b", "c"]), new Segment("rooms", ["a", "b", "c"]),
@ -361,7 +391,7 @@ export function tests() {
assert.equal(segments[4].value, "foo"); assert.equal(segments[4].value, "foo");
}, },
"parse open-room action setting a room in an empty tile": assert => { "parse open-room action setting a room in an empty tile": assert => {
const nav = new Navigation(allowsChild); const nav: Navigation<SegmentType> = new Navigation(allowsChild);
const path = nav.pathFrom([ const path = nav.pathFrom([
new Segment("session", 1), new Segment("session", 1),
new Segment("rooms", ["a", "b", "c"]), new Segment("rooms", ["a", "b", "c"]),
@ -377,82 +407,83 @@ export function tests() {
assert.equal(segments[2].value, "d"); assert.equal(segments[2].value, "d");
}, },
"parse session url path without id": assert => { "parse session url path without id": assert => {
const segments = parseUrlPath("/session"); const path = createEmptyPath();
const segments = parseUrlPath("/session", path);
assert.equal(segments.length, 1); assert.equal(segments.length, 1);
assert.equal(segments[0].type, "session"); assert.equal(segments[0].type, "session");
assert.strictEqual(segments[0].value, true); assert.strictEqual(segments[0].value, true);
}, },
"remove active room from grid path turns it into empty tile": assert => { "remove active room from grid path turns it into empty tile": assert => {
const nav = new Navigation(allowsChild); const nav: Navigation<SegmentType> = new Navigation(allowsChild);
const path = nav.pathFrom([ const path = nav.pathFrom([
new Segment("session", 1), new Segment("session", 1),
new Segment("rooms", ["a", "b", "c"]), new Segment("rooms", ["a", "b", "c"]),
new Segment("room", "b") new Segment("room", "b")
]); ]);
const newPath = removeRoomFromPath(path, "b"); const newPath = removeRoomFromPath(path, "b");
assert.equal(newPath.segments.length, 3); assert.equal(newPath?.segments.length, 3);
assert.equal(newPath.segments[0].type, "session"); assert.equal(newPath?.segments[0].type, "session");
assert.equal(newPath.segments[0].value, 1); assert.equal(newPath?.segments[0].value, 1);
assert.equal(newPath.segments[1].type, "rooms"); assert.equal(newPath?.segments[1].type, "rooms");
assert.deepEqual(newPath.segments[1].value, ["a", "", "c"]); assert.deepEqual(newPath?.segments[1].value, ["a", "", "c"]);
assert.equal(newPath.segments[2].type, "empty-grid-tile"); assert.equal(newPath?.segments[2].type, "empty-grid-tile");
assert.equal(newPath.segments[2].value, 1); assert.equal(newPath?.segments[2].value, 1);
}, },
"remove inactive room from grid path": assert => { "remove inactive room from grid path": assert => {
const nav = new Navigation(allowsChild); const nav: Navigation<SegmentType> = new Navigation(allowsChild);
const path = nav.pathFrom([ const path = nav.pathFrom([
new Segment("session", 1), new Segment("session", 1),
new Segment("rooms", ["a", "b", "c"]), new Segment("rooms", ["a", "b", "c"]),
new Segment("room", "b") new Segment("room", "b")
]); ]);
const newPath = removeRoomFromPath(path, "a"); const newPath = removeRoomFromPath(path, "a");
assert.equal(newPath.segments.length, 3); assert.equal(newPath?.segments.length, 3);
assert.equal(newPath.segments[0].type, "session"); assert.equal(newPath?.segments[0].type, "session");
assert.equal(newPath.segments[0].value, 1); assert.equal(newPath?.segments[0].value, 1);
assert.equal(newPath.segments[1].type, "rooms"); assert.equal(newPath?.segments[1].type, "rooms");
assert.deepEqual(newPath.segments[1].value, ["", "b", "c"]); assert.deepEqual(newPath?.segments[1].value, ["", "b", "c"]);
assert.equal(newPath.segments[2].type, "room"); assert.equal(newPath?.segments[2].type, "room");
assert.equal(newPath.segments[2].value, "b"); assert.equal(newPath?.segments[2].value, "b");
}, },
"remove inactive room from grid path with empty tile": assert => { "remove inactive room from grid path with empty tile": assert => {
const nav = new Navigation(allowsChild); const nav: Navigation<SegmentType> = new Navigation(allowsChild);
const path = nav.pathFrom([ const path = nav.pathFrom([
new Segment("session", 1), new Segment("session", 1),
new Segment("rooms", ["a", "b", ""]), new Segment("rooms", ["a", "b", ""]),
new Segment("empty-grid-tile", 3) new Segment("empty-grid-tile", 3)
]); ]);
const newPath = removeRoomFromPath(path, "b"); const newPath = removeRoomFromPath(path, "b");
assert.equal(newPath.segments.length, 3); assert.equal(newPath?.segments.length, 3);
assert.equal(newPath.segments[0].type, "session"); assert.equal(newPath?.segments[0].type, "session");
assert.equal(newPath.segments[0].value, 1); assert.equal(newPath?.segments[0].value, 1);
assert.equal(newPath.segments[1].type, "rooms"); assert.equal(newPath?.segments[1].type, "rooms");
assert.deepEqual(newPath.segments[1].value, ["a", "", ""]); assert.deepEqual(newPath?.segments[1].value, ["a", "", ""]);
assert.equal(newPath.segments[2].type, "empty-grid-tile"); assert.equal(newPath?.segments[2].type, "empty-grid-tile");
assert.equal(newPath.segments[2].value, 3); assert.equal(newPath?.segments[2].value, 3);
}, },
"remove active room": assert => { "remove active room": assert => {
const nav = new Navigation(allowsChild); const nav: Navigation<SegmentType> = new Navigation(allowsChild);
const path = nav.pathFrom([ const path = nav.pathFrom([
new Segment("session", 1), new Segment("session", 1),
new Segment("room", "b") new Segment("room", "b")
]); ]);
const newPath = removeRoomFromPath(path, "b"); const newPath = removeRoomFromPath(path, "b");
assert.equal(newPath.segments.length, 1); assert.equal(newPath?.segments.length, 1);
assert.equal(newPath.segments[0].type, "session"); assert.equal(newPath?.segments[0].type, "session");
assert.equal(newPath.segments[0].value, 1); assert.equal(newPath?.segments[0].value, 1);
}, },
"remove inactive room doesn't do anything": assert => { "remove inactive room doesn't do anything": assert => {
const nav = new Navigation(allowsChild); const nav: Navigation<SegmentType> = new Navigation(allowsChild);
const path = nav.pathFrom([ const path = nav.pathFrom([
new Segment("session", 1), new Segment("session", 1),
new Segment("room", "b") new Segment("room", "b")
]); ]);
const newPath = removeRoomFromPath(path, "a"); const newPath = removeRoomFromPath(path, "a");
assert.equal(newPath.segments.length, 2); assert.equal(newPath?.segments.length, 2);
assert.equal(newPath.segments[0].type, "session"); assert.equal(newPath?.segments[0].type, "session");
assert.equal(newPath.segments[0].value, 1); assert.equal(newPath?.segments[0].value, 1);
assert.equal(newPath.segments[1].type, "room"); assert.equal(newPath?.segments[1].type, "room");
assert.equal(newPath.segments[1].value, "b"); assert.equal(newPath?.segments[1].value, "b");
}, },
} }

View file

@ -15,7 +15,7 @@ limitations under the License.
*/ */
import {ViewModel} from "../ViewModel"; import {ViewModel} from "../ViewModel";
import {addPanelIfNeeded} from "../navigation/index.js"; import {addPanelIfNeeded} from "../navigation/index";
function dedupeSparse(roomIds) { function dedupeSparse(roomIds) {
return roomIds.map((id, idx) => { return roomIds.map((id, idx) => {
@ -185,7 +185,7 @@ export class RoomGridViewModel extends ViewModel {
} }
} }
import {createNavigation} from "../navigation/index.js"; import {createNavigation} from "../navigation/index";
import {ObservableValue} from "../../observable/ObservableValue"; import {ObservableValue} from "../../observable/ObservableValue";
export function tests() { export function tests() {

View file

@ -21,7 +21,7 @@ import {InviteTileViewModel} from "./InviteTileViewModel.js";
import {RoomBeingCreatedTileViewModel} from "./RoomBeingCreatedTileViewModel.js"; import {RoomBeingCreatedTileViewModel} from "./RoomBeingCreatedTileViewModel.js";
import {RoomFilter} from "./RoomFilter.js"; import {RoomFilter} from "./RoomFilter.js";
import {ApplyMap} from "../../../observable/map/ApplyMap.js"; import {ApplyMap} from "../../../observable/map/ApplyMap.js";
import {addPanelIfNeeded} from "../../navigation/index.js"; import {addPanelIfNeeded} from "../../navigation/index";
export class LeftPanelViewModel extends ViewModel { export class LeftPanelViewModel extends ViewModel {
constructor(options) { constructor(options) {

View file

@ -18,7 +18,7 @@ export {Platform} from "./platform/web/Platform.js";
export {Client, LoadStatus} from "./matrix/Client.js"; export {Client, LoadStatus} from "./matrix/Client.js";
export {RoomStatus} from "./matrix/room/common"; export {RoomStatus} from "./matrix/room/common";
// export main view & view models // export main view & view models
export {createNavigation, createRouter} from "./domain/navigation/index.js"; export {createNavigation, createRouter} from "./domain/navigation/index";
export {RootViewModel} from "./domain/RootViewModel.js"; export {RootViewModel} from "./domain/RootViewModel.js";
export {RootView} from "./platform/web/ui/RootView.js"; export {RootView} from "./platform/web/ui/RootView.js";
export {SessionViewModel} from "./domain/session/SessionViewModel.js"; export {SessionViewModel} from "./domain/session/SessionViewModel.js";

View file

@ -17,7 +17,7 @@ limitations under the License.
// import {RecordRequester, ReplayRequester} from "./matrix/net/request/replay"; // import {RecordRequester, ReplayRequester} from "./matrix/net/request/replay";
import {RootViewModel} from "../../domain/RootViewModel.js"; import {RootViewModel} from "../../domain/RootViewModel.js";
import {createNavigation, createRouter} from "../../domain/navigation/index.js"; import {createNavigation, createRouter} from "../../domain/navigation/index";
// Don't use a default export here, as we use multiple entries during legacy build, // Don't use a default export here, as we use multiple entries during legacy build,
// which does not support default exports, // which does not support default exports,
// see https://github.com/rollup/plugins/tree/master/packages/multi-entry // see https://github.com/rollup/plugins/tree/master/packages/multi-entry