From 66f6c4aba15c555cf0e77b1671e5c3dc92f92df7 Mon Sep 17 00:00:00 2001 From: RMidhunSuresh Date: Wed, 16 Feb 2022 23:37:10 +0530 Subject: [PATCH 001/226] WIP --- .../{Navigation.js => Navigation.ts} | 91 +++++++++++++------ src/domain/navigation/index.js | 2 +- 2 files changed, 63 insertions(+), 30 deletions(-) rename src/domain/navigation/{Navigation.js => Navigation.ts} (76%) diff --git a/src/domain/navigation/Navigation.js b/src/domain/navigation/Navigation.ts similarity index 76% rename from src/domain/navigation/Navigation.js rename to src/domain/navigation/Navigation.ts index 340ae0d5..da6841f8 100644 --- a/src/domain/navigation/Navigation.js +++ b/src/domain/navigation/Navigation.ts @@ -15,28 +15,53 @@ limitations under the License. */ import {BaseObservableValue, ObservableValue} from "../../observable/ObservableValue"; +import type {allowsChild as AllowsChild} from "./index.js"; + +type SegmentType = { + "login": true; + "session": string; + "sso": string; + "logout": true; + "room": string; + "rooms": string[]; + "settings": true; + "create-room": true; + "empty-grid-tile": number; + "lightbox": string; + "right-panel": boolean; + "details": true; + "members": true; + "member": string; +}; export class Navigation { - constructor(allowsChild) { + private readonly _allowsChild: AllowsChild; + private _path: Path; + private readonly _observables: Map = new Map(); + private readonly _pathObservable: ObservableValue; + + constructor(allowsChild: AllowsChild) { this._allowsChild = allowsChild; this._path = new Path([], allowsChild); - this._observables = new Map(); this._pathObservable = new ObservableValue(this._path); } - get pathObservable() { + get pathObservable(): ObservableValue { return this._pathObservable; } - get path() { + get path(): Path { return this._path; } - push(type, value = undefined) { - return this.applyPath(this.path.with(new Segment(type, value))); + push(type, value = undefined): void { + const newPath = this.path.with(new Segment(type, value)); + if (newPath) { + this.applyPath(newPath); + } } - applyPath(path) { + applyPath(path: Path): void { // 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; @@ -60,7 +85,7 @@ export class Navigation { this._pathObservable.set(this._path); } - observe(type) { + observe(type: keyof SegmentType): SegmentObservable { let observable = this._observables.get(type); if (!observable) { observable = new SegmentObservable(this, type); @@ -69,9 +94,9 @@ export class Navigation { return observable; } - pathFrom(segments) { - let parent; - let i; + pathFrom(segments: Segment[]): Path { + let parent: Segment | undefined; + let i: number; for (i = 0; i < segments.length; i += 1) { if (!this._allowsChild(parent, segments[i])) { return new Path(segments.slice(0, i), this._allowsChild); @@ -81,12 +106,12 @@ export class Navigation { return new Path(segments, this._allowsChild); } - segment(type, value) { + segment(type: T, value: SegmentType[T]): Segment { return new Segment(type, value); } } -function segmentValueEqual(a, b) { +function segmentValueEqual(a?: SegmentType[keyof SegmentType], b?: SegmentType[keyof SegmentType]): boolean { if (a === b) { return true; } @@ -103,24 +128,28 @@ function segmentValueEqual(a, b) { return false; } -export class Segment { - constructor(type, value) { - this.type = type; - this.value = value === undefined ? true : value; - } + +export class Segment { + constructor( + public type: T, + public value: SegmentType[T] | true = value === undefined ? true : value + ) {} } class Path { - constructor(segments = [], allowsChild) { + private readonly _segments: Segment[]; + private readonly _allowsChild: AllowsChild; + + constructor(segments: Segment[] = [], allowsChild: AllowsChild) { this._segments = segments; this._allowsChild = allowsChild; } - clone() { + clone(): Path { return new Path(this._segments.slice(), this._allowsChild); } - with(segment) { + with(segment: Segment): Path | null { let index = this._segments.length - 1; do { if (this._allowsChild(this._segments[index], segment)) { @@ -135,7 +164,7 @@ class Path { return null; } - until(type) { + until(type: keyof SegmentType): Path { const index = this._segments.findIndex(s => s.type === type); if (index !== -1) { return new Path(this._segments.slice(0, index + 1), this._allowsChild) @@ -143,11 +172,11 @@ class Path { return new Path([], this._allowsChild); } - get(type) { + get(type: keyof SegmentType): Segment | undefined { return this._segments.find(s => s.type === type); } - replace(segment) { + replace(segment: Segment): Path | null { const index = this._segments.findIndex(s => s.type === segment.type); if (index !== -1) { const parent = this._segments[index - 1]; @@ -163,7 +192,7 @@ class Path { return null; } - get segments() { + get segments(): Segment[] { return this._segments; } } @@ -172,22 +201,26 @@ class Path { * 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) { +class SegmentObservable extends BaseObservableValue { + private readonly _navigation: Navigation; + private _type: keyof SegmentType; + private _lastSetValue?: SegmentType[keyof SegmentType]; + + constructor(navigation: Navigation, type: keyof SegmentType) { super(); this._navigation = navigation; this._type = type; this._lastSetValue = navigation.path.get(type)?.value; } - get() { + get(): SegmentType[keyof SegmentType] | undefined { const path = this._navigation.path; const segment = path.get(this._type); const value = segment?.value; return value; } - emitIfChanged() { + emitIfChanged(): void { const newValue = this.get(); if (!segmentValueEqual(newValue, this._lastSetValue)) { this._lastSetValue = newValue; diff --git a/src/domain/navigation/index.js b/src/domain/navigation/index.js index 086367ce..68fbe993 100644 --- a/src/domain/navigation/index.js +++ b/src/domain/navigation/index.js @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {Navigation, Segment} from "./Navigation.js"; +import {Navigation, Segment} from "./Navigation"; import {URLRouter} from "./URLRouter.js"; export function createNavigation() { From 04d5b9bfda15b085a7aea8377ec9fb0a62681b06 Mon Sep 17 00:00:00 2001 From: RMidhunSuresh Date: Fri, 18 Feb 2022 16:07:18 +0530 Subject: [PATCH 002/226] WIP - 2 --- src/domain/navigation/Navigation.ts | 82 ++++++++++++++++------------- 1 file changed, 44 insertions(+), 38 deletions(-) diff --git a/src/domain/navigation/Navigation.ts b/src/domain/navigation/Navigation.ts index da6841f8..cb6637a9 100644 --- a/src/domain/navigation/Navigation.ts +++ b/src/domain/navigation/Navigation.ts @@ -34,11 +34,11 @@ type SegmentType = { "member": string; }; -export class Navigation { +export class Navigation { private readonly _allowsChild: AllowsChild; - private _path: Path; - private readonly _observables: Map = new Map(); - private readonly _pathObservable: ObservableValue; + private _path: Path; + private readonly _observables: Map> = new Map(); + private readonly _pathObservable: ObservableValue>; constructor(allowsChild: AllowsChild) { this._allowsChild = allowsChild; @@ -46,11 +46,11 @@ export class Navigation { this._pathObservable = new ObservableValue(this._path); } - get pathObservable(): ObservableValue { + get pathObservable(): ObservableValue> { return this._pathObservable; } - get path(): Path { + get path(): Path { return this._path; } @@ -61,7 +61,7 @@ export class Navigation { } } - applyPath(path: Path): void { + applyPath(path: Path): void { // 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; @@ -85,7 +85,7 @@ export class Navigation { this._pathObservable.set(this._path); } - observe(type: keyof SegmentType): SegmentObservable { + observe(type: keyof T): SegmentObservable { let observable = this._observables.get(type); if (!observable) { observable = new SegmentObservable(this, type); @@ -94,7 +94,7 @@ export class Navigation { return observable; } - pathFrom(segments: Segment[]): Path { + pathFrom(segments: Segment[]): Path { let parent: Segment | undefined; let i: number; for (i = 0; i < segments.length; i += 1) { @@ -106,12 +106,12 @@ export class Navigation { return new Path(segments, this._allowsChild); } - segment(type: T, value: SegmentType[T]): Segment { + segment(type: K, value: T[K]): Segment { return new Segment(type, value); } } -function segmentValueEqual(a?: SegmentType[keyof SegmentType], b?: SegmentType[keyof SegmentType]): boolean { +function segmentValueEqual(a?: T[keyof T], b?: T[keyof T]): boolean { if (a === b) { return true; } @@ -129,27 +129,27 @@ function segmentValueEqual(a?: SegmentType[keyof SegmentType], b?: SegmentType[k } -export class Segment { +export class Segment { constructor( - public type: T, - public value: SegmentType[T] | true = value === undefined ? true : value + public type: K, + public value: T[K] = (value === undefined ? true : value) as T[K] ) {} } -class Path { - private readonly _segments: Segment[]; +class Path { + private readonly _segments: Segment[]; private readonly _allowsChild: AllowsChild; - constructor(segments: Segment[] = [], allowsChild: AllowsChild) { + constructor(segments: Segment[] = [], allowsChild: AllowsChild) { this._segments = segments; this._allowsChild = allowsChild; } - clone(): Path { + clone(): Path { return new Path(this._segments.slice(), this._allowsChild); } - with(segment: Segment): Path | null { + with(segment: Segment): Path | null { let index = this._segments.length - 1; do { if (this._allowsChild(this._segments[index], segment)) { @@ -164,7 +164,7 @@ class Path { return null; } - until(type: keyof SegmentType): Path { + until(type: keyof T): Path { const index = this._segments.findIndex(s => s.type === type); if (index !== -1) { return new Path(this._segments.slice(0, index + 1), this._allowsChild) @@ -172,11 +172,11 @@ class Path { return new Path([], this._allowsChild); } - get(type: keyof SegmentType): Segment | undefined { + get(type: keyof T): Segment | undefined { return this._segments.find(s => s.type === type); } - replace(segment: Segment): Path | null { + replace(segment: Segment): Path | null { const index = this._segments.findIndex(s => s.type === segment.type); if (index !== -1) { const parent = this._segments[index - 1]; @@ -192,7 +192,7 @@ class Path { return null; } - get segments(): Segment[] { + get segments(): Segment[] { return this._segments; } } @@ -201,19 +201,19 @@ class Path { * 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 { - private readonly _navigation: Navigation; - private _type: keyof SegmentType; - private _lastSetValue?: SegmentType[keyof SegmentType]; +class SegmentObservable extends BaseObservableValue { + private readonly _navigation: Navigation; + private _type: keyof T; + private _lastSetValue?: T[keyof T]; - constructor(navigation: Navigation, type: keyof SegmentType) { + constructor(navigation: Navigation, type: keyof T) { super(); this._navigation = navigation; this._type = type; this._lastSetValue = navigation.path.get(type)?.value; } - get(): SegmentType[keyof SegmentType] | undefined { + get(): T[keyof T] | undefined { const path = this._navigation.path; const segment = path.get(this._type); const value = segment?.value; @@ -222,7 +222,7 @@ class SegmentObservable extends BaseObservableValue(newValue, this._lastSetValue)) { this._lastSetValue = newValue; this.emit(newValue); } @@ -249,7 +249,7 @@ export function tests() { } function observeTypes(nav, types) { - const changes = []; + const changes: {type:string, value:any}[] = []; for (const type of types) { nav.observe(type).subscribe(value => { changes.push({type, value}); @@ -258,6 +258,12 @@ export function tests() { return changes; } + type SegmentType = { + "foo": number; + "bar": number; + "baz": number; + } + return { "applying a path emits an event on the observable": assert => { const nav = createMockNavigation(); @@ -275,18 +281,18 @@ export function tests() { 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); + 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); }, "path.replace success": assert => { - const path = new Path([new Segment("foo", 5), new Segment("bar", 6)], () => true); + const path = new Path([new Segment("foo", 5), new Segment("bar", 6)], () => true); const newPath = path.replace(new Segment("foo", 1)); - assert.equal(newPath.get("foo").value, 1); - assert.equal(newPath.get("bar").value, 6); + assert.equal(newPath!.get("foo")!.value, 1); + assert.equal(newPath!.get("bar")!.value, 6); }, "path.replace not found": assert => { - const path = new Path([new Segment("foo", 5), new Segment("bar", 6)], () => true); + const path = new Path([new Segment("foo", 5), new Segment("bar", 6)], () => true); const newPath = path.replace(new Segment("baz", 1)); assert.equal(newPath, null); } From 3efc426fedf596697f12c357b5bcca7483871ae0 Mon Sep 17 00:00:00 2001 From: RMidhunSuresh Date: Mon, 21 Feb 2022 17:30:46 +0530 Subject: [PATCH 003/226] Complete converting Navigation.js to ts --- src/domain/navigation/Navigation.ts | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/src/domain/navigation/Navigation.ts b/src/domain/navigation/Navigation.ts index cb6637a9..dfd69bd1 100644 --- a/src/domain/navigation/Navigation.ts +++ b/src/domain/navigation/Navigation.ts @@ -34,6 +34,7 @@ type SegmentType = { "member": string; }; + export class Navigation { private readonly _allowsChild: AllowsChild; private _path: Path; @@ -54,8 +55,8 @@ export class Navigation { return this._path; } - push(type, value = undefined): void { - const newPath = this.path.with(new Segment(type, value)); + push(type: K, ...value: T[K] extends true? [undefined?]: [T[K]]): void { + const newPath = this.path.with(new Segment(type, ...value)); if (newPath) { this.applyPath(newPath); } @@ -106,8 +107,8 @@ export class Navigation { return new Path(segments, this._allowsChild); } - segment(type: K, value: T[K]): Segment { - return new Segment(type, value); + segment(type: K, ...value: T[K] extends true? [undefined?]: [T[K]]): Segment { + return new Segment(type, ...value); } } @@ -130,10 +131,11 @@ function segmentValueEqual(a?: T[keyof T], b?: T[keyof T]): boolean { export class Segment { - constructor( - public type: K, - public value: T[K] = (value === undefined ? true : value) as T[K] - ) {} + public value: T[K]; + + constructor(public type: K, ...value: T[K] extends true? [undefined?]: [T[K]]) { + this.value = (value[0] === undefined ? true : value[0]) as unknown as T[K]; + } } class Path { From 55229252d750a6a00a26bade28b802f1f02e786b Mon Sep 17 00:00:00 2001 From: RMidhunSuresh Date: Mon, 21 Feb 2022 17:37:30 +0530 Subject: [PATCH 004/226] Type allowsChild --- src/domain/navigation/Navigation.ts | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/domain/navigation/Navigation.ts b/src/domain/navigation/Navigation.ts index dfd69bd1..b3b79a89 100644 --- a/src/domain/navigation/Navigation.ts +++ b/src/domain/navigation/Navigation.ts @@ -15,7 +15,6 @@ limitations under the License. */ import {BaseObservableValue, ObservableValue} from "../../observable/ObservableValue"; -import type {allowsChild as AllowsChild} from "./index.js"; type SegmentType = { "login": true; @@ -34,14 +33,15 @@ type SegmentType = { "member": string; }; +type AllowsChild = (parent: Segment | undefined, child: Segment) => boolean; export class Navigation { - private readonly _allowsChild: AllowsChild; + private readonly _allowsChild: AllowsChild; private _path: Path; private readonly _observables: Map> = new Map(); private readonly _pathObservable: ObservableValue>; - constructor(allowsChild: AllowsChild) { + constructor(allowsChild: AllowsChild) { this._allowsChild = allowsChild; this._path = new Path([], allowsChild); this._pathObservable = new ObservableValue(this._path); @@ -140,9 +140,9 @@ export class Segment { class Path { private readonly _segments: Segment[]; - private readonly _allowsChild: AllowsChild; + private readonly _allowsChild: AllowsChild; - constructor(segments: Segment[] = [], allowsChild: AllowsChild) { + constructor(segments: Segment[] = [], allowsChild: AllowsChild) { this._segments = segments; this._allowsChild = allowsChild; } @@ -237,13 +237,13 @@ export function tests() { return new Navigation((parent, {type}) => { switch (parent?.type) { case undefined: - return type === "1" || "2"; + return type === "1" || type === "2"; case "1": return type === "1.1"; case "1.1": return type === "1.1.1"; case "2": - return type === "2.1" || "2.2"; + return type === "2.1" || type === "2.2"; default: return false; } From 92c79c853d2442da004f91744617deb2120e59ef Mon Sep 17 00:00:00 2001 From: RMidhunSuresh Date: Tue, 22 Feb 2022 11:41:36 +0530 Subject: [PATCH 005/226] Convert index.js to typescript --- src/domain/navigation/Navigation.ts | 24 +-- src/domain/navigation/{index.js => index.ts} | 175 +++++++++++------- src/domain/session/RoomGridViewModel.js | 4 +- .../session/leftpanel/LeftPanelViewModel.js | 2 +- src/lib.ts | 2 +- src/platform/web/main.js | 2 +- 6 files changed, 114 insertions(+), 95 deletions(-) rename src/domain/navigation/{index.js => index.ts} (75%) diff --git a/src/domain/navigation/Navigation.ts b/src/domain/navigation/Navigation.ts index b3b79a89..f0b29816 100644 --- a/src/domain/navigation/Navigation.ts +++ b/src/domain/navigation/Navigation.ts @@ -16,22 +16,6 @@ limitations under the License. import {BaseObservableValue, ObservableValue} from "../../observable/ObservableValue"; -type SegmentType = { - "login": true; - "session": string; - "sso": string; - "logout": true; - "room": string; - "rooms": string[]; - "settings": true; - "create-room": true; - "empty-grid-tile": number; - "lightbox": string; - "right-panel": boolean; - "details": true; - "members": true; - "member": string; -}; type AllowsChild = (parent: Segment | undefined, child: Segment) => boolean; @@ -55,7 +39,7 @@ export class Navigation { return this._path; } - push(type: K, ...value: T[K] extends true? [undefined?]: [T[K]]): void { + push(type: K, ...value: T[K] extends true? [(undefined | true)?]: [T[K]]): void { const newPath = this.path.with(new Segment(type, ...value)); if (newPath) { this.applyPath(newPath); @@ -107,7 +91,7 @@ export class Navigation { return new Path(segments, this._allowsChild); } - segment(type: K, ...value: T[K] extends true? [undefined?]: [T[K]]): Segment { + segment(type: K, ...value: T[K] extends true? [(undefined | true)?]: [T[K]]): Segment { return new Segment(type, ...value); } } @@ -133,7 +117,7 @@ function segmentValueEqual(a?: T[keyof T], b?: T[keyof T]): boolean { export class Segment { public value: T[K]; - constructor(public type: K, ...value: T[K] extends true? [undefined?]: [T[K]]) { + constructor(public type: K, ...value: T[K] extends true? [(undefined | true)?]: [T[K]]) { this.value = (value[0] === undefined ? true : value[0]) as unknown as T[K]; } } @@ -231,6 +215,8 @@ class SegmentObservable extends BaseObservableValue { } } +export type { Path }; + export function tests() { function createMockNavigation() { diff --git a/src/domain/navigation/index.js b/src/domain/navigation/index.ts similarity index 75% rename from src/domain/navigation/index.js rename to src/domain/navigation/index.ts index 68fbe993..92cf41a1 100644 --- a/src/domain/navigation/index.js +++ b/src/domain/navigation/index.ts @@ -16,6 +16,24 @@ limitations under the License. import {Navigation, Segment} from "./Navigation"; import {URLRouter} from "./URLRouter.js"; +import type { Path } from "./Navigation"; + +type SegmentType = { + login: true; + session: string; + 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() { return new Navigation(allowsChild); @@ -25,7 +43,7 @@ export function createRouter({history, navigation}) { return new URLRouter({history, navigation, stringifyPath, parseUrlPath}); } -function allowsChild(parent, child) { +function allowsChild(parent: {type: string, value: any} | undefined, child: {type: string, value: any}): boolean { const {type} = child; switch (parent?.type) { case undefined: @@ -45,8 +63,9 @@ function allowsChild(parent, child) { } } -export function removeRoomFromPath(path, roomId) { - const rooms = path.get("rooms"); +export function removeRoomFromPath(path: Path, roomId: string): Path | null { + let newPath: Path | null = path; + const rooms = newPath.get("rooms"); let roomIdGridIndex = -1; // first delete from rooms segment if (rooms) { @@ -54,22 +73,22 @@ export function removeRoomFromPath(path, roomId) { if (roomIdGridIndex !== -1) { const idsWithoutRoom = rooms.value.slice(); 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) if (room && room.value === roomId) { if (roomIdGridIndex !== -1) { - path = path.with(new Segment("empty-grid-tile", roomIdGridIndex)); + newPath = newPath!.with(new Segment("empty-grid-tile", roomIdGridIndex)); } else { - path = path.until("session"); + newPath = newPath!.until("session"); } } - return path; + return newPath; } -function roomsSegmentWithRoom(rooms, roomId, path) { +function roomsSegmentWithRoom(rooms: Segment, roomId: string, path: Path): Segment { if(!rooms.value.includes(roomId)) { const emptyGridTile = path.get("empty-grid-tile"); const oldRoom = path.get("room"); @@ -87,28 +106,29 @@ function roomsSegmentWithRoom(rooms, roomId, path) { } } -function pushRightPanelSegment(array, segment, value = true) { +// todo-self: verify code change here is okay +function pushRightPanelSegment(array: Segment[], segment: T, ...value: SegmentType[T] extends true? [(undefined | true)?]: [SegmentType[T]]) { 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(navigation: Navigation, path: Path): Path { const segments = navigation.path.segments; const i = segments.findIndex(segment => segment.type === "right-panel"); let _path = path; if (i !== -1) { _path = path.until("room"); - _path = _path.with(segments[i]); - _path = _path.with(segments[i + 1]); + _path = _path.with(segments[i])!; + _path = _path.with(segments[i + 1])!; } return _path; } -export function parseUrlPath(urlPath, currentNavPath, defaultSessionId) { +export function parseUrlPath(urlPath: string, currentNavPath: Path, defaultSessionId: string): Segment[] { // substr(1) to take of initial / const parts = urlPath.substr(1).split("/"); const iterator = parts[Symbol.iterator](); - const segments = []; + const segments: Segment[] = []; let next; while (!(next = iterator.next()).done) { const type = next.value; @@ -170,9 +190,9 @@ export function parseUrlPath(urlPath, currentNavPath, defaultSessionId) { return segments; } -export function stringifyPath(path) { +export function stringifyPath(path: Path): string { let urlPath = ""; - let prevSegment; + let prevSegment: Segment | undefined; for (const segment of path.segments) { switch (segment.type) { case "rooms": @@ -205,9 +225,16 @@ export function stringifyPath(path) { } export function tests() { + + function createEmptyPath() { + const nav: Navigation = new Navigation(allowsChild); + const path = nav.pathFrom([]); + return path; + } + return { "stringify grid url with focused empty tile": assert => { - const nav = new Navigation(allowsChild); + const nav: Navigation = new Navigation(allowsChild); const path = nav.pathFrom([ new Segment("session", 1), new Segment("rooms", ["a", "b", "c"]), @@ -217,7 +244,7 @@ export function tests() { assert.equal(urlPath, "/session/1/rooms/a,b,c/3"); }, "stringify grid url with focused room": assert => { - const nav = new Navigation(allowsChild); + const nav: Navigation = new Navigation(allowsChild); const path = nav.pathFrom([ new Segment("session", 1), new Segment("rooms", ["a", "b", "c"]), @@ -227,7 +254,7 @@ export function tests() { assert.equal(urlPath, "/session/1/rooms/a,b,c/1"); }, "stringify url with right-panel and details segment": assert => { - const nav = new Navigation(allowsChild); + const nav: Navigation = new Navigation(allowsChild); const path = nav.pathFrom([ new Segment("session", 1), new Segment("rooms", ["a", "b", "c"]), @@ -239,13 +266,15 @@ export function tests() { assert.equal(urlPath, "/session/1/rooms/a,b,c/1/details"); }, "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[0].type, "sso"); assert.equal(segments[0].value, "a1232aSD123"); }, "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[0].type, "session"); assert.equal(segments[0].value, "1"); @@ -255,7 +284,8 @@ export function tests() { assert.equal(segments[2].value, 3); }, "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[0].type, "session"); assert.equal(segments[0].value, "1"); @@ -265,7 +295,8 @@ export function tests() { assert.equal(segments[2].value, "b"); }, "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[0].type, "session"); assert.equal(segments[0].value, "1"); @@ -275,7 +306,8 @@ export function tests() { assert.equal(segments[2].value, 0); }, "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[0].type, "session"); assert.equal(segments[0].value, "1"); @@ -285,13 +317,13 @@ export function tests() { assert.equal(segments[2].value, 1); }, "parse open-room action replacing the current focused room": assert => { - const nav = new Navigation(allowsChild); + const nav: Navigation = 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); + 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"); @@ -301,13 +333,13 @@ export function tests() { assert.equal(segments[2].value, "d"); }, "parse open-room action changing focus to an existing room": assert => { - const nav = new Navigation(allowsChild); + const nav: Navigation = 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); + 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"); @@ -317,7 +349,7 @@ export function tests() { assert.equal(segments[2].value, "a"); }, "parse open-room action changing focus to an existing room with details open": assert => { - const nav = new Navigation(allowsChild); + const nav: Navigation = new Navigation(allowsChild); const path = nav.pathFrom([ new Segment("session", 1), new Segment("rooms", ["a", "b", "c"]), @@ -325,7 +357,7 @@ export function tests() { new Segment("right-panel", true), new Segment("details", true) ]); - const segments = parseUrlPath("/session/1/open-room/a", path); + const segments = parseUrlPath("/session/1/open-room/a", path, ""); assert.equal(segments.length, 5); assert.equal(segments[0].type, "session"); assert.equal(segments[0].value, "1"); @@ -339,7 +371,7 @@ export function tests() { assert.equal(segments[4].value, true); }, "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 = new Navigation(allowsChild); const path = nav.pathFrom([ new Segment("session", 1), new Segment("rooms", ["a", "b", "c"]), @@ -347,7 +379,7 @@ export function tests() { new Segment("right-panel", true), new Segment("members", true) ]); - const segments = parseUrlPath("/session/1/open-room/a/member/foo", path); + const segments = parseUrlPath("/session/1/open-room/a/member/foo", path, ""); assert.equal(segments.length, 5); assert.equal(segments[0].type, "session"); assert.equal(segments[0].value, "1"); @@ -361,13 +393,13 @@ export function tests() { assert.equal(segments[4].value, "foo"); }, "parse open-room action setting a room in an empty tile": assert => { - const nav = new Navigation(allowsChild); + const nav: Navigation = 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); + 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"); @@ -377,82 +409,83 @@ export function tests() { assert.equal(segments[2].value, "d"); }, "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[0].type, "session"); assert.strictEqual(segments[0].value, true); }, "remove active room from grid path turns it into empty tile": assert => { - const nav = new Navigation(allowsChild); + const nav: Navigation = new Navigation(allowsChild); const path = nav.pathFrom([ new Segment("session", 1), new Segment("rooms", ["a", "b", "c"]), new Segment("room", "b") ]); const newPath = removeRoomFromPath(path, "b"); - assert.equal(newPath.segments.length, 3); - assert.equal(newPath.segments[0].type, "session"); - assert.equal(newPath.segments[0].value, 1); - assert.equal(newPath.segments[1].type, "rooms"); - assert.deepEqual(newPath.segments[1].value, ["a", "", "c"]); - assert.equal(newPath.segments[2].type, "empty-grid-tile"); - assert.equal(newPath.segments[2].value, 1); + assert.equal(newPath?.segments.length, 3); + assert.equal(newPath?.segments[0].type, "session"); + assert.equal(newPath?.segments[0].value, 1); + assert.equal(newPath?.segments[1].type, "rooms"); + assert.deepEqual(newPath?.segments[1].value, ["a", "", "c"]); + assert.equal(newPath?.segments[2].type, "empty-grid-tile"); + assert.equal(newPath?.segments[2].value, 1); }, "remove inactive room from grid path": assert => { - const nav = new Navigation(allowsChild); + const nav: Navigation = new Navigation(allowsChild); const path = nav.pathFrom([ new Segment("session", 1), new Segment("rooms", ["a", "b", "c"]), new Segment("room", "b") ]); const newPath = removeRoomFromPath(path, "a"); - assert.equal(newPath.segments.length, 3); - assert.equal(newPath.segments[0].type, "session"); - assert.equal(newPath.segments[0].value, 1); - assert.equal(newPath.segments[1].type, "rooms"); - assert.deepEqual(newPath.segments[1].value, ["", "b", "c"]); - assert.equal(newPath.segments[2].type, "room"); - assert.equal(newPath.segments[2].value, "b"); + assert.equal(newPath?.segments.length, 3); + assert.equal(newPath?.segments[0].type, "session"); + assert.equal(newPath?.segments[0].value, 1); + assert.equal(newPath?.segments[1].type, "rooms"); + assert.deepEqual(newPath?.segments[1].value, ["", "b", "c"]); + assert.equal(newPath?.segments[2].type, "room"); + assert.equal(newPath?.segments[2].value, "b"); }, "remove inactive room from grid path with empty tile": assert => { - const nav = new Navigation(allowsChild); + const nav: Navigation = new Navigation(allowsChild); const path = nav.pathFrom([ new Segment("session", 1), new Segment("rooms", ["a", "b", ""]), new Segment("empty-grid-tile", 3) ]); const newPath = removeRoomFromPath(path, "b"); - assert.equal(newPath.segments.length, 3); - assert.equal(newPath.segments[0].type, "session"); - assert.equal(newPath.segments[0].value, 1); - assert.equal(newPath.segments[1].type, "rooms"); - assert.deepEqual(newPath.segments[1].value, ["a", "", ""]); - assert.equal(newPath.segments[2].type, "empty-grid-tile"); - assert.equal(newPath.segments[2].value, 3); + assert.equal(newPath?.segments.length, 3); + assert.equal(newPath?.segments[0].type, "session"); + assert.equal(newPath?.segments[0].value, 1); + assert.equal(newPath?.segments[1].type, "rooms"); + assert.deepEqual(newPath?.segments[1].value, ["a", "", ""]); + assert.equal(newPath?.segments[2].type, "empty-grid-tile"); + assert.equal(newPath?.segments[2].value, 3); }, "remove active room": assert => { - const nav = new Navigation(allowsChild); + const nav: Navigation = new Navigation(allowsChild); const path = nav.pathFrom([ new Segment("session", 1), new Segment("room", "b") ]); const newPath = removeRoomFromPath(path, "b"); - assert.equal(newPath.segments.length, 1); - assert.equal(newPath.segments[0].type, "session"); - assert.equal(newPath.segments[0].value, 1); + assert.equal(newPath?.segments.length, 1); + assert.equal(newPath?.segments[0].type, "session"); + assert.equal(newPath?.segments[0].value, 1); }, "remove inactive room doesn't do anything": assert => { - const nav = new Navigation(allowsChild); + const nav: Navigation = new Navigation(allowsChild); const path = nav.pathFrom([ new Segment("session", 1), new Segment("room", "b") ]); const newPath = removeRoomFromPath(path, "a"); - assert.equal(newPath.segments.length, 2); - assert.equal(newPath.segments[0].type, "session"); - assert.equal(newPath.segments[0].value, 1); - assert.equal(newPath.segments[1].type, "room"); - assert.equal(newPath.segments[1].value, "b"); + assert.equal(newPath?.segments.length, 2); + assert.equal(newPath?.segments[0].type, "session"); + assert.equal(newPath?.segments[0].value, 1); + assert.equal(newPath?.segments[1].type, "room"); + assert.equal(newPath?.segments[1].value, "b"); }, } diff --git a/src/domain/session/RoomGridViewModel.js b/src/domain/session/RoomGridViewModel.js index a7d19054..8e443e2d 100644 --- a/src/domain/session/RoomGridViewModel.js +++ b/src/domain/session/RoomGridViewModel.js @@ -15,7 +15,7 @@ limitations under the License. */ import {ViewModel} from "../ViewModel"; -import {addPanelIfNeeded} from "../navigation/index.js"; +import {addPanelIfNeeded} from "../navigation/index"; function dedupeSparse(roomIds) { 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"; export function tests() { diff --git a/src/domain/session/leftpanel/LeftPanelViewModel.js b/src/domain/session/leftpanel/LeftPanelViewModel.js index 2fd3ca7e..8c8d71a2 100644 --- a/src/domain/session/leftpanel/LeftPanelViewModel.js +++ b/src/domain/session/leftpanel/LeftPanelViewModel.js @@ -21,7 +21,7 @@ import {InviteTileViewModel} from "./InviteTileViewModel.js"; import {RoomBeingCreatedTileViewModel} from "./RoomBeingCreatedTileViewModel.js"; import {RoomFilter} from "./RoomFilter.js"; import {ApplyMap} from "../../../observable/map/ApplyMap.js"; -import {addPanelIfNeeded} from "../../navigation/index.js"; +import {addPanelIfNeeded} from "../../navigation/index"; export class LeftPanelViewModel extends ViewModel { constructor(options) { diff --git a/src/lib.ts b/src/lib.ts index 90bf597c..4d1f906f 100644 --- a/src/lib.ts +++ b/src/lib.ts @@ -18,7 +18,7 @@ export {Platform} from "./platform/web/Platform.js"; export {Client, LoadStatus} from "./matrix/Client.js"; export {RoomStatus} from "./matrix/room/common"; // 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 {RootView} from "./platform/web/ui/RootView.js"; export {SessionViewModel} from "./domain/session/SessionViewModel.js"; diff --git a/src/platform/web/main.js b/src/platform/web/main.js index edc2cf14..83644456 100644 --- a/src/platform/web/main.js +++ b/src/platform/web/main.js @@ -17,7 +17,7 @@ limitations under the License. // import {RecordRequester, ReplayRequester} from "./matrix/net/request/replay"; 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, // which does not support default exports, // see https://github.com/rollup/plugins/tree/master/packages/multi-entry From 92e8fc8ad3641ed1d3a955de604059259ae065b0 Mon Sep 17 00:00:00 2001 From: RMidhunSuresh Date: Tue, 22 Feb 2022 11:43:35 +0530 Subject: [PATCH 006/226] Remove deprecated method --- src/domain/navigation/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/domain/navigation/index.ts b/src/domain/navigation/index.ts index 92cf41a1..143ae734 100644 --- a/src/domain/navigation/index.ts +++ b/src/domain/navigation/index.ts @@ -126,7 +126,7 @@ export function addPanelIfNeeded(navigation: Navigation, defaultSessionId: string): Segment[] { // substr(1) to take of initial / - const parts = urlPath.substr(1).split("/"); + const parts = urlPath.substring(1).split("/"); const iterator = parts[Symbol.iterator](); const segments: Segment[] = []; let next; From 646cbe0fff7bbfc9568fef5945e61b818c8193fe Mon Sep 17 00:00:00 2001 From: RMidhunSuresh Date: Tue, 22 Feb 2022 11:48:02 +0530 Subject: [PATCH 007/226] Make all keys string --- src/domain/navigation/index.ts | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/src/domain/navigation/index.ts b/src/domain/navigation/index.ts index 143ae734..4e840bf4 100644 --- a/src/domain/navigation/index.ts +++ b/src/domain/navigation/index.ts @@ -19,20 +19,20 @@ import {URLRouter} from "./URLRouter.js"; import type { Path } from "./Navigation"; type SegmentType = { - login: true; - session: string; - sso: string; - logout: true; - room: string; - rooms: string[]; - settings: true; + "login": true; + "session": string; + "sso": string; + "logout": true; + "room": string; + "rooms": string[]; + "settings": true; "create-room": true; "empty-grid-tile": number; - lightbox: string; + "lightbox": string; "right-panel": true; - details: true; - members: true; - member: string; + "details": true; + "members": true; + "member": string; }; export function createNavigation() { From bf2fb52691a47dad0c4a2ffd414cd5b5979a3646 Mon Sep 17 00:00:00 2001 From: RMidhunSuresh Date: Tue, 22 Feb 2022 11:48:20 +0530 Subject: [PATCH 008/226] Fix formatting --- src/domain/navigation/Navigation.ts | 2 +- src/domain/navigation/index.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/domain/navigation/Navigation.ts b/src/domain/navigation/Navigation.ts index f0b29816..4a0e4969 100644 --- a/src/domain/navigation/Navigation.ts +++ b/src/domain/navigation/Navigation.ts @@ -215,7 +215,7 @@ class SegmentObservable extends BaseObservableValue { } } -export type { Path }; +export type {Path}; export function tests() { diff --git a/src/domain/navigation/index.ts b/src/domain/navigation/index.ts index 4e840bf4..ac856f86 100644 --- a/src/domain/navigation/index.ts +++ b/src/domain/navigation/index.ts @@ -16,7 +16,7 @@ limitations under the License. import {Navigation, Segment} from "./Navigation"; import {URLRouter} from "./URLRouter.js"; -import type { Path } from "./Navigation"; +import type {Path} from "./Navigation"; type SegmentType = { "login": true; From d9bfca10e1085b7f8a8bab0ba6527273b201fc01 Mon Sep 17 00:00:00 2001 From: RMidhunSuresh Date: Tue, 22 Feb 2022 11:51:30 +0530 Subject: [PATCH 009/226] Type function --- src/domain/navigation/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/domain/navigation/index.ts b/src/domain/navigation/index.ts index ac856f86..fb75b04d 100644 --- a/src/domain/navigation/index.ts +++ b/src/domain/navigation/index.ts @@ -35,7 +35,7 @@ type SegmentType = { "member": string; }; -export function createNavigation() { +export function createNavigation(): Navigation { return new Navigation(allowsChild); } From 4c3e0a6ff042b8298751f59a287a7efe8fdbf352 Mon Sep 17 00:00:00 2001 From: RMidhunSuresh Date: Tue, 22 Feb 2022 13:14:27 +0530 Subject: [PATCH 010/226] Convert URLRouter.js to typescript --- src/domain/navigation/Navigation.ts | 2 + .../navigation/{URLRouter.js => URLRouter.ts} | 64 +++++++++++-------- src/domain/navigation/index.ts | 6 +- 3 files changed, 44 insertions(+), 28 deletions(-) rename src/domain/navigation/{URLRouter.js => URLRouter.ts} (65%) diff --git a/src/domain/navigation/Navigation.ts b/src/domain/navigation/Navigation.ts index 4a0e4969..5157f86c 100644 --- a/src/domain/navigation/Navigation.ts +++ b/src/domain/navigation/Navigation.ts @@ -19,6 +19,8 @@ import {BaseObservableValue, ObservableValue} from "../../observable/ObservableV type AllowsChild = (parent: Segment | undefined, child: Segment) => boolean; +export type OptionalValue = T extends true? [(undefined | true)?]: [T]; + export class Navigation { private readonly _allowsChild: AllowsChild; private _path: Path; diff --git a/src/domain/navigation/URLRouter.js b/src/domain/navigation/URLRouter.ts similarity index 65% rename from src/domain/navigation/URLRouter.js rename to src/domain/navigation/URLRouter.ts index 586eec8a..f865a1de 100644 --- a/src/domain/navigation/URLRouter.js +++ b/src/domain/navigation/URLRouter.ts @@ -14,19 +14,33 @@ See the License for the specific language governing permissions and limitations under the License. */ -export class URLRouter { - constructor({history, navigation, parseUrlPath, stringifyPath}) { +import type {History} from "../../platform/web/dom/History.js"; +import type {Navigation, Segment, Path, OptionalValue} from "./Navigation"; +import type {SubscriptionHandle} from "../../observable/BaseObservable"; +import type {SegmentType} from "./index"; + +type ParseURLPath = (urlPath: string, currentNavPath: Path, defaultSessionId: string | null) => Segment[]; +type StringifyPath = (path: Path) => string; + +export class URLRouter { + private readonly _history: History; + private readonly _navigation: Navigation; + private readonly _parseUrlPath: ParseURLPath; + private readonly _stringifyPath: StringifyPath; + private _subscription?: SubscriptionHandle; + private _pathSubscription?: SubscriptionHandle; + private _isApplyingUrl: boolean = false; + private _defaultSessionId: string | null; + + constructor(history: History, navigation: Navigation, parseUrlPath: ParseURLPath, stringifyPath: StringifyPath) { this._history = history; this._navigation = navigation; this._parseUrlPath = parseUrlPath; this._stringifyPath = stringifyPath; - this._subscription = null; - this._pathSubscription = null; - this._isApplyingUrl = false; this._defaultSessionId = this._getLastSessionId(); } - _getLastSessionId() { + _getLastSessionId(): string | null { const navPath = this._urlAsNavPath(this._history.getLastUrl() || ""); const sessionId = navPath.get("session")?.value; if (typeof sessionId === "string") { @@ -35,7 +49,7 @@ export class URLRouter { return null; } - attach() { + attach(): void { this._subscription = this._history.subscribe(url => this._applyUrl(url)); // subscribe to path before applying initial url // so redirects in _applyNavPathToHistory are reflected in url bar @@ -43,12 +57,12 @@ export class URLRouter { this._applyUrl(this._history.get()); } - dispose() { - this._subscription = this._subscription(); - this._pathSubscription = this._pathSubscription(); + dispose(): void { + if (this._subscription) { this._subscription = this._subscription(); } + if (this._pathSubscription) { this._pathSubscription = this._pathSubscription(); } } - _applyNavPathToHistory(path) { + _applyNavPathToHistory(path: Path): void { const url = this.urlForPath(path); if (url !== this._history.get()) { if (this._isApplyingUrl) { @@ -60,7 +74,7 @@ export class URLRouter { } } - _applyNavPathToNavigation(navPath) { + _applyNavPathToNavigation(navPath: Path): void { // this will cause _applyNavPathToHistory to be called, // 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) @@ -69,21 +83,21 @@ export class URLRouter { this._isApplyingUrl = false; } - _urlAsNavPath(url) { + _urlAsNavPath(url: string): Path { const urlPath = this._history.urlAsPath(url); return this._navigation.pathFrom(this._parseUrlPath(urlPath, this._navigation.path, this._defaultSessionId)); } - _applyUrl(url) { + _applyUrl(url: string): void { const navPath = this._urlAsNavPath(url); this._applyNavPathToNavigation(navPath); } - pushUrl(url) { + pushUrl(url: string): void { this._history.pushUrl(url); } - tryRestoreLastUrl() { + tryRestoreLastUrl(): boolean { const lastNavPath = this._urlAsNavPath(this._history.getLastUrl() || ""); if (lastNavPath.segments.length !== 0) { this._applyNavPathToNavigation(lastNavPath); @@ -92,8 +106,8 @@ export class URLRouter { return false; } - urlForSegments(segments) { - let path = this._navigation.path; + urlForSegments(segments: Segment[]): string | undefined { + let path: Path | null = this._navigation.path; for (const segment of segments) { path = path.with(segment); if (!path) { @@ -103,29 +117,29 @@ export class URLRouter { return this.urlForPath(path); } - urlForSegment(type, value) { - return this.urlForSegments([this._navigation.segment(type, value)]); + urlForSegment(type: K, ...value: OptionalValue): string | undefined { + return this.urlForSegments([this._navigation.segment(type, ...value)]); } - urlUntilSegment(type) { + urlUntilSegment(type: keyof T): string { return this.urlForPath(this._navigation.path.until(type)); } - urlForPath(path) { + urlForPath(path: Path): string { return this._history.pathAsUrl(this._stringifyPath(path)); } - openRoomActionUrl(roomId) { + openRoomActionUrl(roomId: string) { // 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); } - createSSOCallbackURL() { + createSSOCallbackURL(): string { return window.location.origin; } - normalizeUrl() { + normalizeUrl(): void { // Remove any queryParameters from the URL // Gets rid of the loginToken after SSO this._history.replaceUrlSilently(`${window.location.origin}/${window.location.hash}`); diff --git a/src/domain/navigation/index.ts b/src/domain/navigation/index.ts index fb75b04d..386eac53 100644 --- a/src/domain/navigation/index.ts +++ b/src/domain/navigation/index.ts @@ -15,10 +15,10 @@ limitations under the License. */ import {Navigation, Segment} from "./Navigation"; -import {URLRouter} from "./URLRouter.js"; +import {URLRouter} from "./URLRouter"; import type {Path} from "./Navigation"; -type SegmentType = { +export type SegmentType = { "login": true; "session": string; "sso": string; @@ -124,7 +124,7 @@ export function addPanelIfNeeded(navigation: Navigation, defaultSessionId: string): Segment[] { +export function parseUrlPath(urlPath: string, currentNavPath: Path, defaultSessionId: string | null): Segment[] { // substr(1) to take of initial / const parts = urlPath.substring(1).split("/"); const iterator = parts[Symbol.iterator](); From 5d42f372f6b2d902332f5df54ec75d3dffafeccf Mon Sep 17 00:00:00 2001 From: RMidhunSuresh Date: Tue, 22 Feb 2022 13:15:30 +0530 Subject: [PATCH 011/226] Pass as separate arguments to constructor --- src/domain/navigation/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/domain/navigation/index.ts b/src/domain/navigation/index.ts index 386eac53..13d2df84 100644 --- a/src/domain/navigation/index.ts +++ b/src/domain/navigation/index.ts @@ -40,7 +40,7 @@ export function createNavigation(): Navigation { } export function createRouter({history, navigation}) { - return new URLRouter({history, navigation, stringifyPath, parseUrlPath}); + return new URLRouter(history, navigation, parseUrlPath, stringifyPath); } function allowsChild(parent: {type: string, value: any} | undefined, child: {type: string, value: any}): boolean { From c14e4f3eed5ff4738f1376ed12a161b37d13f426 Mon Sep 17 00:00:00 2001 From: RMidhunSuresh Date: Tue, 22 Feb 2022 13:16:54 +0530 Subject: [PATCH 012/226] Use segment type --- src/domain/navigation/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/domain/navigation/index.ts b/src/domain/navigation/index.ts index 13d2df84..c90926cf 100644 --- a/src/domain/navigation/index.ts +++ b/src/domain/navigation/index.ts @@ -43,7 +43,7 @@ export function createRouter({history, navigation}) { return new URLRouter(history, navigation, parseUrlPath, stringifyPath); } -function allowsChild(parent: {type: string, value: any} | undefined, child: {type: string, value: any}): boolean { +function allowsChild(parent: Segment | undefined, child: Segment): boolean { const {type} = child; switch (parent?.type) { case undefined: From f28dfc6964e29c8e5b6db5cd7f47dc0135ac67cf Mon Sep 17 00:00:00 2001 From: RMidhunSuresh Date: Tue, 22 Feb 2022 13:22:37 +0530 Subject: [PATCH 013/226] Type createRouter function --- src/domain/navigation/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/domain/navigation/index.ts b/src/domain/navigation/index.ts index c90926cf..740ebf9d 100644 --- a/src/domain/navigation/index.ts +++ b/src/domain/navigation/index.ts @@ -39,7 +39,7 @@ export function createNavigation(): Navigation { return new Navigation(allowsChild); } -export function createRouter({history, navigation}) { +export function createRouter({history, navigation}: {history: History, navigation: Navigation}): URLRouter { return new URLRouter(history, navigation, parseUrlPath, stringifyPath); } From 76d04ee27783ee3316cdd6727d865fbcd5e8e340 Mon Sep 17 00:00:00 2001 From: RMidhunSuresh Date: Tue, 22 Feb 2022 13:26:59 +0530 Subject: [PATCH 014/226] Make defaultSessionId optional --- src/domain/navigation/URLRouter.ts | 8 ++++---- src/domain/navigation/index.ts | 24 ++++++++++++------------ 2 files changed, 16 insertions(+), 16 deletions(-) diff --git a/src/domain/navigation/URLRouter.ts b/src/domain/navigation/URLRouter.ts index f865a1de..06a67c25 100644 --- a/src/domain/navigation/URLRouter.ts +++ b/src/domain/navigation/URLRouter.ts @@ -19,7 +19,7 @@ import type {Navigation, Segment, Path, OptionalValue} from "./Navigation"; import type {SubscriptionHandle} from "../../observable/BaseObservable"; import type {SegmentType} from "./index"; -type ParseURLPath = (urlPath: string, currentNavPath: Path, defaultSessionId: string | null) => Segment[]; +type ParseURLPath = (urlPath: string, currentNavPath: Path, defaultSessionId?: string) => Segment[]; type StringifyPath = (path: Path) => string; export class URLRouter { @@ -30,7 +30,7 @@ export class URLRouter { private _subscription?: SubscriptionHandle; private _pathSubscription?: SubscriptionHandle; private _isApplyingUrl: boolean = false; - private _defaultSessionId: string | null; + private _defaultSessionId?: string; constructor(history: History, navigation: Navigation, parseUrlPath: ParseURLPath, stringifyPath: StringifyPath) { this._history = history; @@ -40,13 +40,13 @@ export class URLRouter { this._defaultSessionId = this._getLastSessionId(); } - _getLastSessionId(): string | null { + _getLastSessionId(): string | undefined { const navPath = this._urlAsNavPath(this._history.getLastUrl() || ""); const sessionId = navPath.get("session")?.value; if (typeof sessionId === "string") { return sessionId; } - return null; + return undefined; } attach(): void { diff --git a/src/domain/navigation/index.ts b/src/domain/navigation/index.ts index 740ebf9d..bcce8936 100644 --- a/src/domain/navigation/index.ts +++ b/src/domain/navigation/index.ts @@ -124,7 +124,7 @@ export function addPanelIfNeeded(navigation: Navigation, defaultSessionId: string | null): Segment[] { +export function parseUrlPath(urlPath: string, currentNavPath: Path, defaultSessionId?: string): Segment[] { // substr(1) to take of initial / const parts = urlPath.substring(1).split("/"); const iterator = parts[Symbol.iterator](); @@ -267,14 +267,14 @@ export function tests() { }, "Parse loginToken query parameter into SSO segment": assert => { const path = createEmptyPath(); - const segments = parseUrlPath("?loginToken=a1232aSD123", path, ""); + const segments = parseUrlPath("?loginToken=a1232aSD123", path); assert.equal(segments.length, 1); assert.equal(segments[0].type, "sso"); assert.equal(segments[0].value, "a1232aSD123"); }, "parse grid url path with focused empty tile": assert => { const path = createEmptyPath(); - const segments = parseUrlPath("/session/1/rooms/a,b,c/3", path, ""); + const segments = parseUrlPath("/session/1/rooms/a,b,c/3", path); assert.equal(segments.length, 3); assert.equal(segments[0].type, "session"); assert.equal(segments[0].value, "1"); @@ -285,7 +285,7 @@ export function tests() { }, "parse grid url path with focused room": assert => { const path = createEmptyPath(); - const segments = parseUrlPath("/session/1/rooms/a,b,c/1", path, ""); + const segments = parseUrlPath("/session/1/rooms/a,b,c/1", path); assert.equal(segments.length, 3); assert.equal(segments[0].type, "session"); assert.equal(segments[0].value, "1"); @@ -296,7 +296,7 @@ export function tests() { }, "parse empty grid url": assert => { const path = createEmptyPath(); - const segments = parseUrlPath("/session/1/rooms/", path, ""); + const segments = parseUrlPath("/session/1/rooms/", path); assert.equal(segments.length, 3); assert.equal(segments[0].type, "session"); assert.equal(segments[0].value, "1"); @@ -307,7 +307,7 @@ export function tests() { }, "parse empty grid url with focus": assert => { const path = createEmptyPath(); - const segments = parseUrlPath("/session/1/rooms//1", path, ""); + const segments = parseUrlPath("/session/1/rooms//1", path); assert.equal(segments.length, 3); assert.equal(segments[0].type, "session"); assert.equal(segments[0].value, "1"); @@ -323,7 +323,7 @@ export function tests() { new Segment("rooms", ["a", "b", "c"]), new Segment("room", "b") ]); - const segments = parseUrlPath("/session/1/open-room/d", path, ""); + 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"); @@ -339,7 +339,7 @@ export function tests() { new Segment("rooms", ["a", "b", "c"]), new Segment("room", "b") ]); - const segments = parseUrlPath("/session/1/open-room/a", path, ""); + 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"); @@ -357,7 +357,7 @@ export function tests() { new Segment("right-panel", true), new Segment("details", true) ]); - const segments = parseUrlPath("/session/1/open-room/a", path, ""); + const segments = parseUrlPath("/session/1/open-room/a", path); assert.equal(segments.length, 5); assert.equal(segments[0].type, "session"); assert.equal(segments[0].value, "1"); @@ -379,7 +379,7 @@ export function tests() { new Segment("right-panel", true), new Segment("members", true) ]); - const segments = parseUrlPath("/session/1/open-room/a/member/foo", path, ""); + const segments = parseUrlPath("/session/1/open-room/a/member/foo", path); assert.equal(segments.length, 5); assert.equal(segments[0].type, "session"); assert.equal(segments[0].value, "1"); @@ -399,7 +399,7 @@ export function tests() { new Segment("rooms", ["a", "b", "c"]), new Segment("empty-grid-tile", 4) ]); - const segments = parseUrlPath("/session/1/open-room/d", path, ""); + 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"); @@ -410,7 +410,7 @@ export function tests() { }, "parse session url path without id": assert => { const path = createEmptyPath(); - const segments = parseUrlPath("/session", path, ""); + const segments = parseUrlPath("/session", path); assert.equal(segments.length, 1); assert.equal(segments[0].type, "session"); assert.strictEqual(segments[0].value, true); From 09bc0f1b603323f8dbfce2a7c832657842e5acb1 Mon Sep 17 00:00:00 2001 From: RMidhunSuresh Date: Tue, 22 Feb 2022 13:30:37 +0530 Subject: [PATCH 015/226] Extract complex type as type alias --- src/domain/navigation/Navigation.ts | 6 +++--- src/domain/navigation/index.ts | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/domain/navigation/Navigation.ts b/src/domain/navigation/Navigation.ts index 5157f86c..6cde56c2 100644 --- a/src/domain/navigation/Navigation.ts +++ b/src/domain/navigation/Navigation.ts @@ -41,7 +41,7 @@ export class Navigation { return this._path; } - push(type: K, ...value: T[K] extends true? [(undefined | true)?]: [T[K]]): void { + push(type: K, ...value: OptionalValue): void { const newPath = this.path.with(new Segment(type, ...value)); if (newPath) { this.applyPath(newPath); @@ -93,7 +93,7 @@ export class Navigation { return new Path(segments, this._allowsChild); } - segment(type: K, ...value: T[K] extends true? [(undefined | true)?]: [T[K]]): Segment { + segment(type: K, ...value: OptionalValue): Segment { return new Segment(type, ...value); } } @@ -119,7 +119,7 @@ function segmentValueEqual(a?: T[keyof T], b?: T[keyof T]): boolean { export class Segment { public value: T[K]; - constructor(public type: K, ...value: T[K] extends true? [(undefined | true)?]: [T[K]]) { + constructor(public type: K, ...value: OptionalValue) { this.value = (value[0] === undefined ? true : value[0]) as unknown as T[K]; } } diff --git a/src/domain/navigation/index.ts b/src/domain/navigation/index.ts index bcce8936..06920e61 100644 --- a/src/domain/navigation/index.ts +++ b/src/domain/navigation/index.ts @@ -16,7 +16,7 @@ limitations under the License. import {Navigation, Segment} from "./Navigation"; import {URLRouter} from "./URLRouter"; -import type {Path} from "./Navigation"; +import type {Path, OptionalValue} from "./Navigation"; export type SegmentType = { "login": true; @@ -107,7 +107,7 @@ function roomsSegmentWithRoom(rooms: Segment, roomId: stri } // todo-self: verify code change here is okay -function pushRightPanelSegment(array: Segment[], segment: T, ...value: SegmentType[T] extends true? [(undefined | true)?]: [SegmentType[T]]) { +function pushRightPanelSegment(array: Segment[], segment: T, ...value: OptionalValue) { array.push(new Segment("right-panel")); array.push(new Segment(segment, ...value)); } From e7f4ce61751769f91ff388dae7f962d779c398cb Mon Sep 17 00:00:00 2001 From: RMidhunSuresh Date: Tue, 22 Feb 2022 16:24:12 +0530 Subject: [PATCH 016/226] Mark methods as private --- src/domain/navigation/URLRouter.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/domain/navigation/URLRouter.ts b/src/domain/navigation/URLRouter.ts index 06a67c25..f250ab74 100644 --- a/src/domain/navigation/URLRouter.ts +++ b/src/domain/navigation/URLRouter.ts @@ -40,7 +40,7 @@ export class URLRouter { this._defaultSessionId = this._getLastSessionId(); } - _getLastSessionId(): string | undefined { + private _getLastSessionId(): string | undefined { const navPath = this._urlAsNavPath(this._history.getLastUrl() || ""); const sessionId = navPath.get("session")?.value; if (typeof sessionId === "string") { @@ -62,7 +62,7 @@ export class URLRouter { if (this._pathSubscription) { this._pathSubscription = this._pathSubscription(); } } - _applyNavPathToHistory(path: Path): void { + private _applyNavPathToHistory(path: Path): void { const url = this.urlForPath(path); if (url !== this._history.get()) { if (this._isApplyingUrl) { @@ -74,7 +74,7 @@ export class URLRouter { } } - _applyNavPathToNavigation(navPath: Path): void { + private _applyNavPathToNavigation(navPath: Path): void { // this will cause _applyNavPathToHistory to be called, // 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) @@ -83,12 +83,12 @@ export class URLRouter { this._isApplyingUrl = false; } - _urlAsNavPath(url: string): Path { + private _urlAsNavPath(url: string): Path { const urlPath = this._history.urlAsPath(url); return this._navigation.pathFrom(this._parseUrlPath(urlPath, this._navigation.path, this._defaultSessionId)); } - _applyUrl(url: string): void { + private _applyUrl(url: string): void { const navPath = this._urlAsNavPath(url); this._applyNavPathToNavigation(navPath); } From 5be00f051f566686b7117c6e184157947b08f3f5 Mon Sep 17 00:00:00 2001 From: RMidhunSuresh Date: Tue, 22 Feb 2022 16:39:33 +0530 Subject: [PATCH 017/226] Use subtype instead of whole SegmentType --- src/domain/navigation/URLRouter.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/domain/navigation/URLRouter.ts b/src/domain/navigation/URLRouter.ts index f250ab74..2d06ca5f 100644 --- a/src/domain/navigation/URLRouter.ts +++ b/src/domain/navigation/URLRouter.ts @@ -17,12 +17,11 @@ limitations under the License. import type {History} from "../../platform/web/dom/History.js"; import type {Navigation, Segment, Path, OptionalValue} from "./Navigation"; import type {SubscriptionHandle} from "../../observable/BaseObservable"; -import type {SegmentType} from "./index"; type ParseURLPath = (urlPath: string, currentNavPath: Path, defaultSessionId?: string) => Segment[]; type StringifyPath = (path: Path) => string; -export class URLRouter { +export class URLRouter { private readonly _history: History; private readonly _navigation: Navigation; private readonly _parseUrlPath: ParseURLPath; From 4ae3a5bf7a05e8805de85100d8e2fb86fb6ec696 Mon Sep 17 00:00:00 2001 From: RMidhunSuresh Date: Tue, 22 Feb 2022 16:44:27 +0530 Subject: [PATCH 018/226] Use undefined instead of null --- src/domain/navigation/Navigation.ts | 8 ++++---- src/domain/navigation/URLRouter.ts | 2 +- src/domain/navigation/index.ts | 4 ++-- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/domain/navigation/Navigation.ts b/src/domain/navigation/Navigation.ts index 6cde56c2..d6ffa3c5 100644 --- a/src/domain/navigation/Navigation.ts +++ b/src/domain/navigation/Navigation.ts @@ -137,7 +137,7 @@ class Path { return new Path(this._segments.slice(), this._allowsChild); } - with(segment: Segment): Path | null { + with(segment: Segment): Path | undefined { let index = this._segments.length - 1; do { if (this._allowsChild(this._segments[index], segment)) { @@ -149,7 +149,7 @@ class Path { index -= 1; } while(index >= -1); // allow -1 as well so we check if the segment is allowed as root - return null; + return undefined; } until(type: keyof T): Path { @@ -164,7 +164,7 @@ class Path { return this._segments.find(s => s.type === type); } - replace(segment: Segment): Path | null { + replace(segment: Segment): Path | undefined { const index = this._segments.findIndex(s => s.type === segment.type); if (index !== -1) { const parent = this._segments[index - 1]; @@ -177,7 +177,7 @@ class Path { } } } - return null; + return undefined; } get segments(): Segment[] { diff --git a/src/domain/navigation/URLRouter.ts b/src/domain/navigation/URLRouter.ts index 2d06ca5f..31dffe58 100644 --- a/src/domain/navigation/URLRouter.ts +++ b/src/domain/navigation/URLRouter.ts @@ -106,7 +106,7 @@ export class URLRouter { } urlForSegments(segments: Segment[]): string | undefined { - let path: Path | null = this._navigation.path; + let path: Path | undefined = this._navigation.path; for (const segment of segments) { path = path.with(segment); if (!path) { diff --git a/src/domain/navigation/index.ts b/src/domain/navigation/index.ts index 06920e61..f6533580 100644 --- a/src/domain/navigation/index.ts +++ b/src/domain/navigation/index.ts @@ -63,8 +63,8 @@ function allowsChild(parent: Segment | undefined, child: Segment, roomId: string): Path | null { - let newPath: Path | null = path; +export function removeRoomFromPath(path: Path, roomId: string): Path | undefined { + let newPath: Path | undefined = path; const rooms = newPath.get("rooms"); let roomIdGridIndex = -1; // first delete from rooms segment From 4fd1918202845a9e38d443d48c98ff0306778679 Mon Sep 17 00:00:00 2001 From: RMidhunSuresh Date: Tue, 22 Feb 2022 16:47:48 +0530 Subject: [PATCH 019/226] Remove comment --- src/domain/navigation/index.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/domain/navigation/index.ts b/src/domain/navigation/index.ts index f6533580..3715d817 100644 --- a/src/domain/navigation/index.ts +++ b/src/domain/navigation/index.ts @@ -106,7 +106,6 @@ function roomsSegmentWithRoom(rooms: Segment, roomId: stri } } -// todo-self: verify code change here is okay function pushRightPanelSegment(array: Segment[], segment: T, ...value: OptionalValue) { array.push(new Segment("right-panel")); array.push(new Segment(segment, ...value)); From 7a24059337056da7a7aa2c5ac21ead6ed52a7e23 Mon Sep 17 00:00:00 2001 From: RMidhunSuresh Date: Tue, 22 Feb 2022 16:48:15 +0530 Subject: [PATCH 020/226] Remove empty line --- src/domain/navigation/index.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/domain/navigation/index.ts b/src/domain/navigation/index.ts index 3715d817..5f084d0f 100644 --- a/src/domain/navigation/index.ts +++ b/src/domain/navigation/index.ts @@ -224,7 +224,6 @@ export function stringifyPath(path: Path): string { } export function tests() { - function createEmptyPath() { const nav: Navigation = new Navigation(allowsChild); const path = nav.pathFrom([]); From 52f0690c702f51de30559bbb59c22c30b5a20dc3 Mon Sep 17 00:00:00 2001 From: RMidhunSuresh Date: Tue, 22 Feb 2022 16:53:52 +0530 Subject: [PATCH 021/226] Add return type --- src/domain/navigation/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/domain/navigation/index.ts b/src/domain/navigation/index.ts index 5f084d0f..0c36e21b 100644 --- a/src/domain/navigation/index.ts +++ b/src/domain/navigation/index.ts @@ -106,7 +106,7 @@ function roomsSegmentWithRoom(rooms: Segment, roomId: stri } } -function pushRightPanelSegment(array: Segment[], segment: T, ...value: OptionalValue) { +function pushRightPanelSegment(array: Segment[], segment: T, ...value: OptionalValue): void { array.push(new Segment("right-panel")); array.push(new Segment(segment, ...value)); } From 263948faa384d132637d567d4c659f5ae1009f1a Mon Sep 17 00:00:00 2001 From: RMidhunSuresh Date: Tue, 22 Feb 2022 16:58:13 +0530 Subject: [PATCH 022/226] Remove unwanted export --- src/domain/navigation/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/domain/navigation/index.ts b/src/domain/navigation/index.ts index 0c36e21b..b8652f18 100644 --- a/src/domain/navigation/index.ts +++ b/src/domain/navigation/index.ts @@ -18,7 +18,7 @@ import {Navigation, Segment} from "./Navigation"; import {URLRouter} from "./URLRouter"; import type {Path, OptionalValue} from "./Navigation"; -export type SegmentType = { +type SegmentType = { "login": true; "session": string; "sso": string; From f49d580d49afbbbb60e6d6186b3b1728902b175b Mon Sep 17 00:00:00 2001 From: RMidhunSuresh Date: Tue, 22 Feb 2022 20:17:51 +0530 Subject: [PATCH 023/226] WIP --- src/domain/ViewModel.ts | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/domain/ViewModel.ts b/src/domain/ViewModel.ts index 0bc52f6e..743815ee 100644 --- a/src/domain/ViewModel.ts +++ b/src/domain/ViewModel.ts @@ -29,15 +29,15 @@ import type {ILogger} from "../logging/types"; import type {Navigation} from "./navigation/Navigation"; import type {URLRouter} from "./navigation/URLRouter"; -export type Options = { +type Options = { platform: Platform logger: ILogger - urlCreator: URLRouter - navigation: Navigation + urlCreator: URLRouter + navigation: Navigation emitChange?: (params: any) => void } -export class ViewModel extends EventEmitter<{change: never}> { +export class ViewModel = Options> extends EventEmitter<{change: never}> { private disposables?: Disposables; private _isDisposed = false; private _options: Readonly; @@ -47,7 +47,7 @@ export class ViewModel extends EventEmitter<{change this._options = options; } - childOptions(explicitOptions: T): T & Options { + childOptions(explicitOptions: T): T & Options { return Object.assign({}, this._options, explicitOptions); } @@ -135,11 +135,11 @@ export class ViewModel extends EventEmitter<{change return this.platform.logger; } - get urlCreator(): URLRouter { + get urlCreator(): URLRouter { return this._options.urlCreator; } - get navigation(): Navigation { + get navigation(): Navigation { return this._options.navigation; } } From 9300347e9bd2b73dd667d9ff42a6ff32b423761c Mon Sep 17 00:00:00 2001 From: RMidhunSuresh Date: Tue, 22 Feb 2022 22:23:52 +0530 Subject: [PATCH 024/226] Give defaultt type --- src/domain/ViewModel.ts | 3 ++- src/domain/navigation/index.ts | 4 ++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/src/domain/ViewModel.ts b/src/domain/ViewModel.ts index 743815ee..5bec39e8 100644 --- a/src/domain/ViewModel.ts +++ b/src/domain/ViewModel.ts @@ -27,6 +27,7 @@ import type {Platform} from "../platform/web/Platform"; import type {Clock} from "../platform/web/dom/Clock"; import type {ILogger} from "../logging/types"; import type {Navigation} from "./navigation/Navigation"; +import type {SegmentType} from "./navigation/index"; import type {URLRouter} from "./navigation/URLRouter"; type Options = { @@ -37,7 +38,7 @@ type Options = { emitChange?: (params: any) => void } -export class ViewModel = Options> extends EventEmitter<{change: never}> { +export class ViewModel = Options> extends EventEmitter<{change: never}> { private disposables?: Disposables; private _isDisposed = false; private _options: Readonly; diff --git a/src/domain/navigation/index.ts b/src/domain/navigation/index.ts index b8652f18..f739b668 100644 --- a/src/domain/navigation/index.ts +++ b/src/domain/navigation/index.ts @@ -18,7 +18,7 @@ import {Navigation, Segment} from "./Navigation"; import {URLRouter} from "./URLRouter"; import type {Path, OptionalValue} from "./Navigation"; -type SegmentType = { +export type SegmentType = { "login": true; "session": string; "sso": string; @@ -124,7 +124,7 @@ export function addPanelIfNeeded(navigation: Navigation, defaultSessionId?: string): Segment[] { - // substr(1) to take of initial / + // substring(1) to take of initial / const parts = urlPath.substring(1).split("/"); const iterator = parts[Symbol.iterator](); const segments: Segment[] = []; From a336623f3ac8cbf8bcf0bb86df8f6e1a563571ea Mon Sep 17 00:00:00 2001 From: RMidhunSuresh Date: Wed, 2 Mar 2022 17:57:03 +0530 Subject: [PATCH 025/226] Generic parameter should extend object --- src/domain/navigation/Navigation.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/domain/navigation/Navigation.ts b/src/domain/navigation/Navigation.ts index d6ffa3c5..c786d7a7 100644 --- a/src/domain/navigation/Navigation.ts +++ b/src/domain/navigation/Navigation.ts @@ -21,7 +21,7 @@ type AllowsChild = (parent: Segment | undefined, child: Segment) => boo export type OptionalValue = T extends true? [(undefined | true)?]: [T]; -export class Navigation { +export class Navigation { private readonly _allowsChild: AllowsChild; private _path: Path; private readonly _observables: Map> = new Map(); @@ -189,7 +189,7 @@ class Path { * 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 { +class SegmentObservable extends BaseObservableValue { private readonly _navigation: Navigation; private _type: keyof T; private _lastSetValue?: T[keyof T]; From ec1cc89cf9555c6f18bee31d1b084266869ee6eb Mon Sep 17 00:00:00 2001 From: RMidhunSuresh Date: Thu, 3 Mar 2022 20:17:15 +0530 Subject: [PATCH 026/226] Make URLRouter in options conditional on generic URLRouter can be passed in option to vm only if the SegmentType used contains session. ViewModel.urlCreator returns undefined when used with a SegmentType that lacks session. --- src/domain/ViewModel.ts | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/src/domain/ViewModel.ts b/src/domain/ViewModel.ts index 5bec39e8..7442d921 100644 --- a/src/domain/ViewModel.ts +++ b/src/domain/ViewModel.ts @@ -30,15 +30,17 @@ import type {Navigation} from "./navigation/Navigation"; import type {SegmentType} from "./navigation/index"; import type {URLRouter} from "./navigation/URLRouter"; -type Options = { +type OptionsWithoutUrlCreator = { platform: Platform logger: ILogger - urlCreator: URLRouter navigation: Navigation emitChange?: (params: any) => void } +type OptionsWithUrlCreator = OptionsWithoutUrlCreator & {urlCreator: URLRouter}; -export class ViewModel = Options> extends EventEmitter<{change: never}> { +type Options = N extends { session: string } ? OptionsWithUrlCreator : OptionsWithoutUrlCreator; + +export class ViewModel = Options> extends EventEmitter<{change: never}> { private disposables?: Disposables; private _isDisposed = false; private _options: Readonly; @@ -136,11 +138,13 @@ export class ViewModel { - return this._options.urlCreator; + get urlCreator(): N extends { session: string }? URLRouter: undefined { + // typescript needs a little help here + return (this._options as unknown as {urlCreator: any}).urlCreator; } get navigation(): Navigation { - return this._options.navigation; + // typescript needs a little help here + return this._options.navigation as unknown as Navigation; } } From fc873757d80c264b9f01995029bfcda093f69ed7 Mon Sep 17 00:00:00 2001 From: RMidhunSuresh Date: Fri, 27 May 2022 22:38:53 +0530 Subject: [PATCH 027/226] WIP --- src/domain/LogoutViewModel.ts | 5 +++-- src/domain/ViewModel.ts | 20 +++++++++----------- src/domain/navigation/URLRouter.ts | 18 ++++++++++++++++-- src/domain/navigation/index.ts | 2 +- 4 files changed, 29 insertions(+), 16 deletions(-) diff --git a/src/domain/LogoutViewModel.ts b/src/domain/LogoutViewModel.ts index 3edfcad5..9a39f601 100644 --- a/src/domain/LogoutViewModel.ts +++ b/src/domain/LogoutViewModel.ts @@ -16,10 +16,11 @@ limitations under the License. import {Options, ViewModel} from "./ViewModel"; import {Client} from "../matrix/Client.js"; +import {SegmentType} from "./navigation/index"; type LogoutOptions = { sessionId: string; } & Options; -export class LogoutViewModel extends ViewModel { +export class LogoutViewModel extends ViewModel { private _sessionId: string; private _busy: boolean; private _showConfirm: boolean; @@ -41,7 +42,7 @@ export class LogoutViewModel extends ViewModel { return this._busy; } - get cancelUrl(): string { + get cancelUrl(): string | undefined { return this.urlCreator.urlForSegment("session", true); } diff --git a/src/domain/ViewModel.ts b/src/domain/ViewModel.ts index 7442d921..64db4266 100644 --- a/src/domain/ViewModel.ts +++ b/src/domain/ViewModel.ts @@ -28,17 +28,16 @@ import type {Clock} from "../platform/web/dom/Clock"; import type {ILogger} from "../logging/types"; import type {Navigation} from "./navigation/Navigation"; import type {SegmentType} from "./navigation/index"; -import type {URLRouter} from "./navigation/URLRouter"; +import type {IURLRouter} from "./navigation/URLRouter"; -type OptionsWithoutUrlCreator = { - platform: Platform - logger: ILogger - navigation: Navigation - emitChange?: (params: any) => void +export type Options = { + platform: Platform; + logger: ILogger; + urlCreator: IURLRouter; + navigation: Navigation; + emitChange?: (params: any) => void; } -type OptionsWithUrlCreator = OptionsWithoutUrlCreator & {urlCreator: URLRouter}; -type Options = N extends { session: string } ? OptionsWithUrlCreator : OptionsWithoutUrlCreator; export class ViewModel = Options> extends EventEmitter<{change: never}> { private disposables?: Disposables; @@ -138,9 +137,8 @@ export class ViewModel = Op return this.platform.logger; } - get urlCreator(): N extends { session: string }? URLRouter: undefined { - // typescript needs a little help here - return (this._options as unknown as {urlCreator: any}).urlCreator; + get urlCreator(): IURLRouter { + return this._options.urlCreator; } get navigation(): Navigation { diff --git a/src/domain/navigation/URLRouter.ts b/src/domain/navigation/URLRouter.ts index 31dffe58..923c9b43 100644 --- a/src/domain/navigation/URLRouter.ts +++ b/src/domain/navigation/URLRouter.ts @@ -21,7 +21,21 @@ import type {SubscriptionHandle} from "../../observable/BaseObservable"; type ParseURLPath = (urlPath: string, currentNavPath: Path, defaultSessionId?: string) => Segment[]; type StringifyPath = (path: Path) => string; -export class URLRouter { +export interface IURLRouter { + attach(): void; + dispose(): void; + pushUrl(url: string): void; + tryRestoreLastUrl(): boolean; + urlForSegments(segments: Segment[]): string | undefined; + urlForSegment(type: K, ...value: OptionalValue): string | undefined; + urlUntilSegment(type: keyof T): string; + urlForPath(path: Path): string; + openRoomActionUrl(roomId: string): string; + createSSOCallbackURL(): string; + normalizeUrl(): void; +} + +export class URLRouter implements IURLRouter { private readonly _history: History; private readonly _navigation: Navigation; private readonly _parseUrlPath: ParseURLPath; @@ -128,7 +142,7 @@ export class URLRouter { return this._history.pathAsUrl(this._stringifyPath(path)); } - openRoomActionUrl(roomId: string) { + openRoomActionUrl(roomId: string): string { // 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); diff --git a/src/domain/navigation/index.ts b/src/domain/navigation/index.ts index f739b668..afba0d86 100644 --- a/src/domain/navigation/index.ts +++ b/src/domain/navigation/index.ts @@ -20,7 +20,7 @@ import type {Path, OptionalValue} from "./Navigation"; export type SegmentType = { "login": true; - "session": string; + "session": string | boolean; "sso": string; "logout": true; "room": string; From ba647d012dc0c778df81e4e152e2be8736297c04 Mon Sep 17 00:00:00 2001 From: RMidhunSuresh Date: Sun, 29 May 2022 20:38:14 +0530 Subject: [PATCH 028/226] Fix type in observeNavigation --- src/domain/ViewModel.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/domain/ViewModel.ts b/src/domain/ViewModel.ts index 64db4266..f63f569d 100644 --- a/src/domain/ViewModel.ts +++ b/src/domain/ViewModel.ts @@ -60,9 +60,9 @@ export class ViewModel = Op return this._options[name]; } - observeNavigation(type: string, onChange: (value: string | true | undefined, type: string) => void) { + observeNavigation(type: T, onChange: (value: N[T], type: T) => void) { const segmentObservable = this.navigation.observe(type); - const unsubscribe = segmentObservable.subscribe((value: string | true | undefined) => { + const unsubscribe = segmentObservable.subscribe((value: N[T]) => { onChange(value, type); }) this.track(unsubscribe); From d31f127982a60ac2bd2df611524d7caca8629664 Mon Sep 17 00:00:00 2001 From: RMidhunSuresh Date: Tue, 7 Jun 2022 13:28:56 +0530 Subject: [PATCH 029/226] Add explaining comment --- src/domain/navigation/Navigation.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/domain/navigation/Navigation.ts b/src/domain/navigation/Navigation.ts index c786d7a7..f5039732 100644 --- a/src/domain/navigation/Navigation.ts +++ b/src/domain/navigation/Navigation.ts @@ -19,6 +19,16 @@ import {BaseObservableValue, ObservableValue} from "../../observable/ObservableV type AllowsChild = (parent: Segment | undefined, child: Segment) => 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 = new Segment("create-room"); + * instead of + * const s: Segment = new Segment("create-room", undefined); + */ export type OptionalValue = T extends true? [(undefined | true)?]: [T]; export class Navigation { From e3372f0f2bfe991156cd1dce4872610ba2076977 Mon Sep 17 00:00:00 2001 From: RMidhunSuresh Date: Mon, 20 Jun 2022 12:54:18 +0530 Subject: [PATCH 030/226] Don't use theme-name in manifest file names --- scripts/build-plugins/rollup-plugin-build-themes.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/scripts/build-plugins/rollup-plugin-build-themes.js b/scripts/build-plugins/rollup-plugin-build-themes.js index 43a21623..e74d3b85 100644 --- a/scripts/build-plugins/rollup-plugin-build-themes.js +++ b/scripts/build-plugins/rollup-plugin-build-themes.js @@ -257,8 +257,10 @@ module.exports = function buildThemes(options) { const derivedVariables = compiledVariables["derived-variables"]; const icon = compiledVariables["icon"]; const builtAssets = {}; + let themeKey; for (const chunk of chunkArray) { const [, name, variant] = chunk.fileName.match(/theme-(.+)-(.+)\.css/); + themeKey = name; builtAssets[`${name}-${variant}`] = assetMap.get(chunk.fileName).fileName; } manifest.source = { @@ -267,7 +269,7 @@ module.exports = function buildThemes(options) { "derived-variables": derivedVariables, "icon": icon }; - const name = `theme-${manifest.name}.json`; + const name = `theme-${themeKey}.json`; manifestLocations.push(`assets/${name}`); this.emitFile({ type: "asset", From 93165cb947ee0ad895e24d15c0ce5410afde3668 Mon Sep 17 00:00:00 2001 From: RMidhunSuresh Date: Mon, 20 Jun 2022 13:46:14 +0530 Subject: [PATCH 031/226] runtime theme chunks should also be stored in map There will be more than one runtime theme file when multiple theme collections exist. --- .../build-plugins/rollup-plugin-build-themes.js | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/scripts/build-plugins/rollup-plugin-build-themes.js b/scripts/build-plugins/rollup-plugin-build-themes.js index e74d3b85..41e78044 100644 --- a/scripts/build-plugins/rollup-plugin-build-themes.js +++ b/scripts/build-plugins/rollup-plugin-build-themes.js @@ -46,7 +46,7 @@ function addThemesToConfig(bundle, manifestLocations, defaultThemes) { function parseBundle(bundle) { const chunkMap = new Map(); const assetMap = new Map(); - let runtimeThemeChunk; + let runtimeThemeChunkMap = new Map(); for (const [fileName, info] of Object.entries(bundle)) { if (!fileName.endsWith(".css")) { continue; @@ -60,18 +60,18 @@ function parseBundle(bundle) { assetMap.set(info.name, info); continue; } + const location = info.facadeModuleId?.match(/(.+)\/.+\.css/)?.[1]; + if (!location) { + throw new Error("Cannot find location of css chunk!"); + } if (info.facadeModuleId?.includes("type=runtime")) { /** * We have a separate field in manifest.source just for the runtime theme, * so store this separately. */ - runtimeThemeChunk = info; + runtimeThemeChunkMap.set(location, info); continue; } - const location = info.facadeModuleId?.match(/(.+)\/.+\.css/)?.[1]; - if (!location) { - throw new Error("Cannot find location of css chunk!"); - } const array = chunkMap.get(location); if (!array) { chunkMap.set(location, [info]); @@ -80,7 +80,7 @@ function parseBundle(bundle) { array.push(info); } } - return { chunkMap, assetMap, runtimeThemeChunk }; + return { chunkMap, assetMap, runtimeThemeChunkMap }; } module.exports = function buildThemes(options) { @@ -249,7 +249,7 @@ module.exports = function buildThemes(options) { // assetMap: Mapping from asset-name (eg: element-dark.css) to AssetInfo // chunkMap: Mapping from theme-location (eg: hydrogen-web/src/.../css/themes/element) to a list of ChunkInfo // types of AssetInfo and ChunkInfo can be found at https://rollupjs.org/guide/en/#generatebundle - const { assetMap, chunkMap, runtimeThemeChunk } = parseBundle(bundle); + const { assetMap, chunkMap, runtimeThemeChunkMap } = parseBundle(bundle); const manifestLocations = []; for (const [location, chunkArray] of chunkMap) { const manifest = require(`${location}/manifest.json`); @@ -263,6 +263,7 @@ module.exports = function buildThemes(options) { themeKey = name; builtAssets[`${name}-${variant}`] = assetMap.get(chunk.fileName).fileName; } + const runtimeThemeChunk = runtimeThemeChunkMap.get(location); manifest.source = { "built-assets": builtAssets, "runtime-asset": assetMap.get(runtimeThemeChunk.fileName).fileName, From 5eec72471217f0770101598659bf4f1e603d7005 Mon Sep 17 00:00:00 2001 From: RMidhunSuresh Date: Mon, 20 Jun 2022 20:35:06 +0530 Subject: [PATCH 032/226] Locations must be relative to manifest --- scripts/build-plugins/rollup-plugin-build-themes.js | 11 ++++++++--- src/platform/web/Platform.js | 2 +- src/platform/web/ThemeLoader.ts | 11 ++++++++--- 3 files changed, 17 insertions(+), 7 deletions(-) diff --git a/scripts/build-plugins/rollup-plugin-build-themes.js b/scripts/build-plugins/rollup-plugin-build-themes.js index 41e78044..89eaeec1 100644 --- a/scripts/build-plugins/rollup-plugin-build-themes.js +++ b/scripts/build-plugins/rollup-plugin-build-themes.js @@ -251,6 +251,8 @@ module.exports = function buildThemes(options) { // types of AssetInfo and ChunkInfo can be found at https://rollupjs.org/guide/en/#generatebundle const { assetMap, chunkMap, runtimeThemeChunkMap } = parseBundle(bundle); const manifestLocations = []; + // Location of the directory containing manifest relative to the root of the build output + const manifestLocation = "assets"; for (const [location, chunkArray] of chunkMap) { const manifest = require(`${location}/manifest.json`); const compiledVariables = options.compiledVariables.get(location); @@ -261,17 +263,20 @@ module.exports = function buildThemes(options) { for (const chunk of chunkArray) { const [, name, variant] = chunk.fileName.match(/theme-(.+)-(.+)\.css/); themeKey = name; - builtAssets[`${name}-${variant}`] = assetMap.get(chunk.fileName).fileName; + const locationRelativeToBuildRoot = assetMap.get(chunk.fileName).fileName; + const locationRelativeToManifest = path.relative(manifestLocation, locationRelativeToBuildRoot); + builtAssets[`${name}-${variant}`] = locationRelativeToManifest; } const runtimeThemeChunk = runtimeThemeChunkMap.get(location); + const runtimeAssetLocation = path.relative(manifestLocation, assetMap.get(runtimeThemeChunk.fileName).fileName); manifest.source = { "built-assets": builtAssets, - "runtime-asset": assetMap.get(runtimeThemeChunk.fileName).fileName, + "runtime-asset": runtimeAssetLocation, "derived-variables": derivedVariables, "icon": icon }; const name = `theme-${themeKey}.json`; - manifestLocations.push(`assets/${name}`); + manifestLocations.push(`${manifestLocation}/${name}`); this.emitFile({ type: "asset", name, diff --git a/src/platform/web/Platform.js b/src/platform/web/Platform.js index e2ca2028..c2eef17e 100644 --- a/src/platform/web/Platform.js +++ b/src/platform/web/Platform.js @@ -338,7 +338,7 @@ export class Platform { document.querySelectorAll(".theme").forEach(e => e.remove()); // add new theme const styleTag = document.createElement("link"); - styleTag.href = `./${newPath}`; + styleTag.href = newPath; styleTag.rel = "stylesheet"; styleTag.type = "text/css"; styleTag.className = "theme"; diff --git a/src/platform/web/ThemeLoader.ts b/src/platform/web/ThemeLoader.ts index 8c9364bc..ee303b49 100644 --- a/src/platform/web/ThemeLoader.ts +++ b/src/platform/web/ThemeLoader.ts @@ -61,11 +61,11 @@ export class ThemeLoader { const results = await Promise.all( manifestLocations.map( location => this._platform.request(location, { method: "GET", format: "json", cache: true, }).response()) ); - results.forEach(({ body }) => this._populateThemeMap(body, log)); + results.forEach(({ body }, i) => this._populateThemeMap(body, manifestLocations[i], log)); }); } - private _populateThemeMap(manifest, log: ILogItem) { + private _populateThemeMap(manifest, manifestLocation: string, log: ILogItem) { log.wrap("populateThemeMap", (l) => { /* After build has finished, the source section of each theme manifest @@ -75,7 +75,12 @@ export class ThemeLoader { const builtAssets: Record = manifest.source?.["built-assets"]; const themeName = manifest.name; let defaultDarkVariant: any = {}, defaultLightVariant: any = {}; - for (const [themeId, cssLocation] of Object.entries(builtAssets)) { + for (let [themeId, cssLocation] of Object.entries(builtAssets)) { + /** + * This cssLocation is relative to the location of the manifest file. + * So we first need to resolve it relative to the root of this hydrogen instance. + */ + cssLocation = new URL(cssLocation, new URL(manifestLocation, window.location.origin)).href; const variant = themeId.match(/.+-(.+)/)?.[1]; const { name: variantName, default: isDefault, dark } = manifest.values.variants[variant!]; const themeDisplayName = `${themeName} ${variantName}`; From fbdd512e0646bdc036f94b858d1d6172f06c13a4 Mon Sep 17 00:00:00 2001 From: RMidhunSuresh Date: Mon, 20 Jun 2022 21:10:11 +0530 Subject: [PATCH 033/226] Split functions into smaller functions --- .../rollup-plugin-build-themes.js | 64 ++++++++++++++----- 1 file changed, 49 insertions(+), 15 deletions(-) diff --git a/scripts/build-plugins/rollup-plugin-build-themes.js b/scripts/build-plugins/rollup-plugin-build-themes.js index 89eaeec1..9ac2befe 100644 --- a/scripts/build-plugins/rollup-plugin-build-themes.js +++ b/scripts/build-plugins/rollup-plugin-build-themes.js @@ -43,10 +43,39 @@ function addThemesToConfig(bundle, manifestLocations, defaultThemes) { } } -function parseBundle(bundle) { +/** + * Returns a mapping from location (of manifest file) to an array containing all the chunks (of css files) generated from that location. + * To understand what chunk means in this context, see https://rollupjs.org/guide/en/#generatebundle. + * @param {*} bundle Mapping from fileName to AssetInfo | ChunkInfo + */ +function getMappingFromLocationToChunkArray(bundle) { const chunkMap = new Map(); + for (const [fileName, info] of Object.entries(bundle)) { + if (!fileName.endsWith(".css") || info.type === "asset" || info.facadeModuleId?.includes("type=runtime")) { + continue; + } + const location = info.facadeModuleId?.match(/(.+)\/.+\.css/)?.[1]; + if (!location) { + throw new Error("Cannot find location of css chunk!"); + } + const array = chunkMap.get(location); + if (!array) { + chunkMap.set(location, [info]); + } + else { + array.push(info); + } + } + return chunkMap; +} + +/** + * Returns a mapping from unhashed file name (of css files) to AssetInfo. + * To understand what AssetInfo means in this context, see https://rollupjs.org/guide/en/#generatebundle. + * @param {*} bundle Mapping from fileName to AssetInfo | ChunkInfo + */ +function getMappingFromFileNameToAssetInfo(bundle) { const assetMap = new Map(); - let runtimeThemeChunkMap = new Map(); for (const [fileName, info] of Object.entries(bundle)) { if (!fileName.endsWith(".css")) { continue; @@ -58,6 +87,20 @@ function parseBundle(bundle) { * searching through the bundle array later. */ assetMap.set(info.name, info); + } + } + return assetMap; +} + +/** + * Returns a mapping from location (of manifest file) to ChunkInfo of the runtime css asset + * To understand what ChunkInfo means in this context, see https://rollupjs.org/guide/en/#generatebundle. + * @param {*} bundle Mapping from fileName to AssetInfo | ChunkInfo + */ +function getMappingFromLocationToRuntimeChunk(bundle) { + let runtimeThemeChunkMap = new Map(); + for (const [fileName, info] of Object.entries(bundle)) { + if (!fileName.endsWith(".css") || info.type === "asset") { continue; } const location = info.facadeModuleId?.match(/(.+)\/.+\.css/)?.[1]; @@ -70,17 +113,9 @@ function parseBundle(bundle) { * so store this separately. */ runtimeThemeChunkMap.set(location, info); - continue; - } - const array = chunkMap.get(location); - if (!array) { - chunkMap.set(location, [info]); - } - else { - array.push(info); } } - return { chunkMap, assetMap, runtimeThemeChunkMap }; + return runtimeThemeChunkMap; } module.exports = function buildThemes(options) { @@ -246,10 +281,9 @@ module.exports = function buildThemes(options) { }, generateBundle(_, bundle) { - // assetMap: Mapping from asset-name (eg: element-dark.css) to AssetInfo - // chunkMap: Mapping from theme-location (eg: hydrogen-web/src/.../css/themes/element) to a list of ChunkInfo - // types of AssetInfo and ChunkInfo can be found at https://rollupjs.org/guide/en/#generatebundle - const { assetMap, chunkMap, runtimeThemeChunkMap } = parseBundle(bundle); + const assetMap = getMappingFromFileNameToAssetInfo(bundle); + const chunkMap = getMappingFromLocationToChunkArray(bundle); + const runtimeThemeChunkMap = getMappingFromLocationToRuntimeChunk(bundle); const manifestLocations = []; // Location of the directory containing manifest relative to the root of the build output const manifestLocation = "assets"; From d688fa47375e999326bb0e1c6a61ca7b203b6bd6 Mon Sep 17 00:00:00 2001 From: RMidhunSuresh Date: Thu, 23 Jun 2022 15:06:22 +0530 Subject: [PATCH 034/226] Get the theme-collection id from manifest --- .../build-plugins/rollup-plugin-build-themes.js | 17 ++++++++++------- .../web/ui/css/themes/element/manifest.json | 1 + vite.config.js | 4 +--- 3 files changed, 12 insertions(+), 10 deletions(-) diff --git a/scripts/build-plugins/rollup-plugin-build-themes.js b/scripts/build-plugins/rollup-plugin-build-themes.js index 9ac2befe..6b8fdae0 100644 --- a/scripts/build-plugins/rollup-plugin-build-themes.js +++ b/scripts/build-plugins/rollup-plugin-build-themes.js @@ -123,6 +123,7 @@ module.exports = function buildThemes(options) { let isDevelopment = false; const virtualModuleId = '@theme/' const resolvedVirtualModuleId = '\0' + virtualModuleId; + const themeToManifestLocation = new Map(); return { name: "build-themes", @@ -137,20 +138,22 @@ module.exports = function buildThemes(options) { async buildStart() { if (isDevelopment) { return; } const { themeConfig } = options; - for (const [name, location] of Object.entries(themeConfig.themes)) { + for (const location of themeConfig.themes) { manifest = require(`${location}/manifest.json`); + const themeCollectionId = manifest.id; + themeToManifestLocation.set(themeCollectionId, location); variants = manifest.values.variants; for (const [variant, details] of Object.entries(variants)) { - const fileName = `theme-${name}-${variant}.css`; - if (name === themeConfig.default && details.default) { + const fileName = `theme-${themeCollectionId}-${variant}.css`; + if (themeCollectionId === themeConfig.default && details.default) { // This is the default theme, stash the file name for later if (details.dark) { defaultDark = fileName; - defaultThemes["dark"] = `${name}-${variant}`; + defaultThemes["dark"] = `${themeCollectionId}-${variant}`; } else { defaultLight = fileName; - defaultThemes["light"] = `${name}-${variant}`; + defaultThemes["light"] = `${themeCollectionId}-${variant}`; } } // emit the css as built theme bundle @@ -164,7 +167,7 @@ module.exports = function buildThemes(options) { this.emitFile({ type: "chunk", id: `${location}/theme.css?type=runtime`, - fileName: `theme-${name}-runtime.css`, + fileName: `theme-${themeCollectionId}-runtime.css`, }); } }, @@ -187,7 +190,7 @@ module.exports = function buildThemes(options) { if (theme === "default") { theme = options.themeConfig.default; } - const location = options.themeConfig.themes[theme]; + const location = themeToManifestLocation.get(theme); const manifest = require(`${location}/manifest.json`); const variants = manifest.values.variants; if (!variant || variant === "default") { diff --git a/src/platform/web/ui/css/themes/element/manifest.json b/src/platform/web/ui/css/themes/element/manifest.json index e183317c..cb21eaad 100644 --- a/src/platform/web/ui/css/themes/element/manifest.json +++ b/src/platform/web/ui/css/themes/element/manifest.json @@ -1,6 +1,7 @@ { "version": 1, "name": "Element", + "id": "element", "values": { "variants": { "light": { diff --git a/vite.config.js b/vite.config.js index 72be0184..10348218 100644 --- a/vite.config.js +++ b/vite.config.js @@ -33,9 +33,7 @@ export default defineConfig(({mode}) => { plugins: [ themeBuilder({ themeConfig: { - themes: { - element: "./src/platform/web/ui/css/themes/element", - }, + themes: ["./src/platform/web/ui/css/themes/element"], default: "element", }, compiledVariables, From 0ab611b0130f821007e4a7c41e5dc98dc99192a9 Mon Sep 17 00:00:00 2001 From: Bruno Windels <274386+bwindels@users.noreply.github.com> Date: Fri, 1 Jul 2022 13:08:50 +0200 Subject: [PATCH 035/226] more detailed docs for IView, TemplateView and ListView --- doc/UI/ui.md | 204 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 204 insertions(+) create mode 100644 doc/UI/ui.md diff --git a/doc/UI/ui.md b/doc/UI/ui.md new file mode 100644 index 00000000..40a8eccd --- /dev/null +++ b/doc/UI/ui.md @@ -0,0 +1,204 @@ +## IView components + +The [interface](https://github.com/vector-im/hydrogen-web/blob/master/src/platform/web/ui/general/types.ts) adopted by view components is agnostic of how they are rendered to the DOM. This has several benefits: + - it allows Hydrogen to not ship a [heavy view framework](https://bundlephobia.com/package/react-dom@18.2.0) that may or may not be used by its SDK users, and also keep bundle size of the app down. + - Given the interface is quite simple, is should be easy to integrate this interface into the render lifecycle of other frameworks. + - The main implementations used in Hydrogen are [`ListView`](https://github.com/vector-im/hydrogen-web/blob/master/src/platform/web/ui/general/ListView.ts) (rendering [`ObservableList`](https://github.com/vector-im/hydrogen-web/blob/master/src/observable/list/BaseObservableList.ts)s) and [`TemplateView`](https://github.com/vector-im/hydrogen-web/blob/master/src/platform/web/ui/general/TemplateView.ts) (easy templating and one-way databinding), each only a few 100 lines of code and tailored towards their specific use-case. They work straight with the DOM API and have no other dependencies. + - a common inteface allows us to mix and match between these different implementations (and gradually shift if need be in the future) with the code. + +## Templates + +### Template language + +TemplateView uses a mini-DSL language in pure javascript to express declarative templates. This template language is available without all the data-binding and event-handling bells and whistles in `ui/general/html.js`, and is basically a very thin wrapper around `document.createElement`, `document.createTextNode`, `node.setAttribute` and `node.appendChild` to quickly create DOM trees. The general syntax is as follows: +```js +tag.tag_name({attribute1: value, attribute2: value, ...}, [child_elements]); +tag.tag_name(child_element); +tag.tag_name([child_elements]); +``` +**tag_name** can be [most HTML or SVG tags](https://github.com/vector-im/hydrogen-web/blob/master/src/platform/web/ui/general/html.ts#L102-L110). + +eg: +Here is an example HTML segment followed with the code to create it in Hydrogen. +```html +
+

Demo

+ +
+``` +```js +tag.section({className: "main-section"},[ + tag.h1("Demo"), + tag.button({className:"btn_cool"}, "Click me") +]); +``` + +All these functions return DOM element nodes, e.g. the result of `document.createElement`. + +### TemplateView + +`TemplateView` builds on top of this by adopting the IView component model and adding easy event handling attributes and one-way databinding. +In views based on `TemplateView`, you will see `t` used instead of `tag`. +`t` is `TemplateBuilder` object passed to the render function in `TemplateView`. As opposed to static templates with `tag`, you always use +`TemplateView` as an instance of a class, as there is some extra state to keep track (bindings, event handlers and subviews). + +You either subclass `TemplateView` and override the `render` method: +```js +class MyView extends TemplateView { + render(t, vm) { + return t.div(...); + } +} +``` + +Or you pass a render function to `InlineTemplateView`: +```js +new InlineTemplateView(vm, (t, vm) => { + return t.div(...); +}); +``` + +**Note:** the render function is only called once to build the initial DOM tree and setup bindings, etc ... Any subsequent updates to the DOM of a component happens through bindings. + +#### Event handlers + +Any attribute starting with `on` and having a function as a value will be attached as an event listener on the given node. The event handler will be removed during unmounting. + +```js +t.button({onClick: evt => { + vm.doSomething(evt.target.value); +}}, "Click me"); +``` + +#### Subviews + +`t.view(instance)` will mount the sub view (can be any IView) and return its root node so it can be attached in the DOM tree. +All subviews will be unmounted when the parent view gets unmounted. + +```js +t.div({className: "Container"}, t.view(new ChildView(vm.childViewModel))); +``` + +#### One-way data-binding + +A binding binds a part of the DOM to a value on the view model. The view model emits an update when any of its properties change to which the view can subscribe. When an update is received by the view, it will reevaluate all the bindings, and update the DOM accordingly. + +A binding can appear in many places where a static value can usually be used in the template tree. +To create a binding, you pass a function that maps the view value to a static value. + +##### Text binding + +```js +t.p(["I've got ", vm => vm.counter, " beans"]) +``` + +##### Attribute binding + +```js +t.button({disabled: vm => vm.isBusy}, "Submit"); +``` + +##### Class-name binding +```js +t.div({className: { + button: true, + active: vm => vm.isActive +}}) +``` +##### Subview binding + +So far, all the bindings can only change node values within our tree, but don't change the structure of the DOM. A sub view binding allows you to conditionally add a subview based on the result of a binding function. + +All sub view bindings return a DOM (element or comment) node and can be directly added to the DOM tree by including them in your template. + +###### map + +`t.mapView` allows you to choose a view based on the result of the binding function: + +```js +t.mapView(vm => vm.count, count => { + return count > 5 ? new LargeView(count) : new SmallView(count); +}); +``` + +Every time the first or binding function returns a different value, the second function is run to create a new view to replace the previous view. + +You can also return `null` or `undefined` from the second function to indicate a view should not be rendered. In this case a comment node will be used as a placeholder. + +There is also a `t.map` which will create a new template view (with the same value) and you directly provide a render function for it: + +```js +t.map(vm => vm.shape, (shape, t, vm) => { + switch (shape) { + case "rect": return t.rect(); + case "circle": return t.circle(); + } +}) +``` + +###### if + +`t.ifView` will render the subview if the binding returns a truthy value: + +```js +t.ifView(vm => vm.isActive, vm => new View(vm.someValue)); +``` + +You equally have `t.if`, which creates a `TemplateView` and passes you the `TemplateBuilder`: + +```js +t.if(vm => vm.isActive, (t, vm) => t.div("active!")); +``` + +##### Side-effects + +Sometimes you want to imperatively modify your DOM tree based on the value of a binding. +`mapSideEffect` makes this easy to do: + +```js +let node = t.div(); +t.mapSideEffect(vm => vm.color, (color, oldColor) => node.style.background = color); +return node; +``` + +**Note:** you shouldn't add any bindings, subviews or event handlers from the side-effect callback, +the safest is to not use the `t` argument at all. +If you do, they will be added every time the callback is run and only cleaned up when the view is unmounted. + +#### `tag` vs `t` + +**Note:** Although syntactically similar, `TemplateBuilder` and `tag` are not functionally equivalent. +Primarily `t` **supports** bindings and event handlers while `tag` **does not**. This is because to remove event listeners, we need to keep track of them, and thus we need to keep this state somewhere which +we can't do with a simple function call but we can insite the TemplateView class. + +```js + // The onClick here wont work!! + tag.button({className:"awesome-btn", onClick: () => this.foo()}); + +class MyView extends TemplateView { + render(t, vm){ + // The onClick works here. + t.button({className:"awesome-btn", onClick: () => this.foo()}); + } +} +``` + +## ListView + +A view component that renders and updates a list of sub views for every item in a `ObservableList`. + +```js +const list = new ListView({ + list: someObservableList +}, listValue => return new ChildView(listValue)) +``` + +As items are added, removed, moved (change position) and updated, the DOM will be kept in sync. + +There is also a `LazyListView` that only renders items in and around the current viewport. + +### Sub view updates + +Unless the `parentProvidesUpdates` option in the constructor is set to `false`, the ListView will call the `update` method on the child `IView` component when it receives an update event for one of the items in the `ObservableList`. + +This way, not every sub view has to have an individual listener on it's view model (a value from the observable list), and all updates go from the observable list to the list view, who then notifies the correct sub view. From d398e490ebf5b85a92a4ec40470a1af91eab9a05 Mon Sep 17 00:00:00 2001 From: Bruno Windels <274386+bwindels@users.noreply.github.com> Date: Fri, 1 Jul 2022 13:59:57 +0200 Subject: [PATCH 036/226] some rewording --- doc/UI/ui.md | 22 ++++++++++++---------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/doc/UI/ui.md b/doc/UI/ui.md index 40a8eccd..fe60cf52 100644 --- a/doc/UI/ui.md +++ b/doc/UI/ui.md @@ -3,18 +3,18 @@ The [interface](https://github.com/vector-im/hydrogen-web/blob/master/src/platform/web/ui/general/types.ts) adopted by view components is agnostic of how they are rendered to the DOM. This has several benefits: - it allows Hydrogen to not ship a [heavy view framework](https://bundlephobia.com/package/react-dom@18.2.0) that may or may not be used by its SDK users, and also keep bundle size of the app down. - Given the interface is quite simple, is should be easy to integrate this interface into the render lifecycle of other frameworks. - - The main implementations used in Hydrogen are [`ListView`](https://github.com/vector-im/hydrogen-web/blob/master/src/platform/web/ui/general/ListView.ts) (rendering [`ObservableList`](https://github.com/vector-im/hydrogen-web/blob/master/src/observable/list/BaseObservableList.ts)s) and [`TemplateView`](https://github.com/vector-im/hydrogen-web/blob/master/src/platform/web/ui/general/TemplateView.ts) (easy templating and one-way databinding), each only a few 100 lines of code and tailored towards their specific use-case. They work straight with the DOM API and have no other dependencies. + - The main implementations used in Hydrogen are [`ListView`](https://github.com/vector-im/hydrogen-web/blob/master/src/platform/web/ui/general/ListView.ts) (rendering [`ObservableList`](https://github.com/vector-im/hydrogen-web/blob/master/src/observable/list/BaseObservableList.ts)s) and [`TemplateView`](https://github.com/vector-im/hydrogen-web/blob/master/src/platform/web/ui/general/TemplateView.ts) (templating and one-way databinding), each only a few 100 lines of code and tailored towards their specific use-case. They work straight with the DOM API and have no other dependencies. - a common inteface allows us to mix and match between these different implementations (and gradually shift if need be in the future) with the code. ## Templates ### Template language -TemplateView uses a mini-DSL language in pure javascript to express declarative templates. This template language is available without all the data-binding and event-handling bells and whistles in `ui/general/html.js`, and is basically a very thin wrapper around `document.createElement`, `document.createTextNode`, `node.setAttribute` and `node.appendChild` to quickly create DOM trees. The general syntax is as follows: +Templates uses a mini-DSL language in pure javascript to express declarative templates. This is basically a very thin wrapper around `document.createElement`, `document.createTextNode`, `node.setAttribute` and `node.appendChild` to quickly create DOM trees. The general syntax is as follows: ```js -tag.tag_name({attribute1: value, attribute2: value, ...}, [child_elements]); -tag.tag_name(child_element); -tag.tag_name([child_elements]); +t.tag_name({attribute1: value, attribute2: value, ...}, [child_elements]); +t.tag_name(child_element); +t.tag_name([child_elements]); ``` **tag_name** can be [most HTML or SVG tags](https://github.com/vector-im/hydrogen-web/blob/master/src/platform/web/ui/general/html.ts#L102-L110). @@ -37,10 +37,9 @@ All these functions return DOM element nodes, e.g. the result of `document.creat ### TemplateView -`TemplateView` builds on top of this by adopting the IView component model and adding easy event handling attributes and one-way databinding. -In views based on `TemplateView`, you will see `t` used instead of `tag`. -`t` is `TemplateBuilder` object passed to the render function in `TemplateView`. As opposed to static templates with `tag`, you always use -`TemplateView` as an instance of a class, as there is some extra state to keep track (bindings, event handlers and subviews). +`TemplateView` builds on top of templating by adopting the IView component model and adding event handling attributes and one-way databinding. +In views based on `TemplateView`, you will see a render method with a `t` argument. +`t` is `TemplateBuilder` object passed to the render function in `TemplateView`. You either subclass `TemplateView` and override the `render` method: ```js @@ -167,7 +166,10 @@ If you do, they will be added every time the callback is run and only cleaned up #### `tag` vs `t` -**Note:** Although syntactically similar, `TemplateBuilder` and `tag` are not functionally equivalent. +If you don't need a view component with data-binding, sub views and event handler attributes, the template language also is available in `ui/general/html.js` without any of these bells and whistles, exported as `tag`. As opposed to static templates with `tag`, you always use +`TemplateView` as an instance of a class, as there is some extra state to keep track (bindings, event handlers and subviews). + +Although syntactically similar, `TemplateBuilder` and `tag` are not functionally equivalent. Primarily `t` **supports** bindings and event handlers while `tag` **does not**. This is because to remove event listeners, we need to keep track of them, and thus we need to keep this state somewhere which we can't do with a simple function call but we can insite the TemplateView class. From fc93acfd8dcd6cf154daab6c0a5f9f5bfdf0aff9 Mon Sep 17 00:00:00 2001 From: Bruno Windels <274386+bwindels@users.noreply.github.com> Date: Fri, 1 Jul 2022 14:09:06 +0200 Subject: [PATCH 037/226] some rewording --- doc/UI/ui.md | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/doc/UI/ui.md b/doc/UI/ui.md index fe60cf52..e8d044e3 100644 --- a/doc/UI/ui.md +++ b/doc/UI/ui.md @@ -10,7 +10,7 @@ The [interface](https://github.com/vector-im/hydrogen-web/blob/master/src/platfo ### Template language -Templates uses a mini-DSL language in pure javascript to express declarative templates. This is basically a very thin wrapper around `document.createElement`, `document.createTextNode`, `node.setAttribute` and `node.appendChild` to quickly create DOM trees. The general syntax is as follows: +Templates use a mini-DSL language in pure javascript to express declarative templates. This is basically a very thin wrapper around `document.createElement`, `document.createTextNode`, `node.setAttribute` and `node.appendChild` to quickly create DOM trees. The general syntax is as follows: ```js t.tag_name({attribute1: value, attribute2: value, ...}, [child_elements]); t.tag_name(child_element); @@ -27,9 +27,9 @@ Here is an example HTML segment followed with the code to create it in Hydrogen. ``` ```js -tag.section({className: "main-section"},[ - tag.h1("Demo"), - tag.button({className:"btn_cool"}, "Click me") +t.section({className: "main-section"},[ + t.h1("Demo"), + t.button({className:"btn_cool"}, "Click me") ]); ``` @@ -37,9 +37,9 @@ All these functions return DOM element nodes, e.g. the result of `document.creat ### TemplateView -`TemplateView` builds on top of templating by adopting the IView component model and adding event handling attributes and one-way databinding. +`TemplateView` builds on top of templating by adopting the IView component model and adding event handling attributes, sub views and one-way databinding. In views based on `TemplateView`, you will see a render method with a `t` argument. -`t` is `TemplateBuilder` object passed to the render function in `TemplateView`. +`t` is `TemplateBuilder` object passed to the render function in `TemplateView`. It also takes a data object to render and bind to, often called `vm`, short for view model from the MVVM pattern Hydrogen uses. You either subclass `TemplateView` and override the `render` method: ```js @@ -80,7 +80,7 @@ t.div({className: "Container"}, t.view(new ChildView(vm.childViewModel))); #### One-way data-binding -A binding binds a part of the DOM to a value on the view model. The view model emits an update when any of its properties change to which the view can subscribe. When an update is received by the view, it will reevaluate all the bindings, and update the DOM accordingly. +A binding couples a part of the DOM to a value on the view model. The view model emits an update when any of its properties change, to which the view can subscribe. When an update is received by the view, it will reevaluate all the bindings, and update the DOM accordingly. A binding can appear in many places where a static value can usually be used in the template tree. To create a binding, you pass a function that maps the view value to a static value. From ca94c65dac7396a7157e0f7b0424aa19de2bc549 Mon Sep 17 00:00:00 2001 From: Bruno Windels <274386+bwindels@users.noreply.github.com> Date: Mon, 4 Jul 2022 10:19:56 +0200 Subject: [PATCH 038/226] clarify LazyListView constraints --- doc/UI/ui.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/UI/ui.md b/doc/UI/ui.md index e8d044e3..d3aa3893 100644 --- a/doc/UI/ui.md +++ b/doc/UI/ui.md @@ -197,7 +197,7 @@ const list = new ListView({ As items are added, removed, moved (change position) and updated, the DOM will be kept in sync. -There is also a `LazyListView` that only renders items in and around the current viewport. +There is also a `LazyListView` that only renders items in and around the current viewport, with the restriction that all items in the list must be rendered with the same height. ### Sub view updates From ea5723cb32f58e4540dbe7437cc87c34224f04d3 Mon Sep 17 00:00:00 2001 From: Quentin Gliech Date: Wed, 2 Feb 2022 15:26:02 +0100 Subject: [PATCH 039/226] Multi-arch capable Dockerfile --- Dockerfile | 15 ++++++++++----- doc/docker.md | 4 +++- 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/Dockerfile b/Dockerfile index f9e32313..4b085ebc 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,9 +1,14 @@ -FROM docker.io/node:alpine as builder +FROM --platform=${BUILDPLATFORM} docker.io/library/node:16.13-alpine3.15 as builder RUN apk add --no-cache git python3 build-base -COPY . /app WORKDIR /app -RUN yarn install \ - && yarn build -FROM docker.io/nginx:alpine +# Install the dependencies first +COPY yarn.lock package.json ./ +RUN yarn install + +# Copy the rest and build the app +COPY . . +RUN yarn build + +FROM --platform=${TARGETPLATFORM} docker.io/library/nginx:alpine COPY --from=builder /app/target /usr/share/nginx/html diff --git a/doc/docker.md b/doc/docker.md index 910938f0..6ca67c02 100644 --- a/doc/docker.md +++ b/doc/docker.md @@ -35,7 +35,9 @@ To stop the container, simply hit `ctrl+c`. In this repository, create a Docker image: -``` +```sh +# Enable BuildKit https://docs.docker.com/develop/develop-images/build_enhancements/ +export DOCKER_BUILDKIT=1 docker build -t hydrogen . ``` From 43b043c56513a054898717f6248ef9e814db8b38 Mon Sep 17 00:00:00 2001 From: Quentin Gliech Date: Wed, 2 Feb 2022 15:33:10 +0100 Subject: [PATCH 040/226] Use non-root nginx base in Docker image --- Dockerfile | 2 +- doc/docker.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Dockerfile b/Dockerfile index 4b085ebc..fade2a24 100644 --- a/Dockerfile +++ b/Dockerfile @@ -10,5 +10,5 @@ RUN yarn install COPY . . RUN yarn build -FROM --platform=${TARGETPLATFORM} docker.io/library/nginx:alpine +FROM --platform=${TARGETPLATFORM} docker.io/nginxinc/nginx-unprivileged:1.21-alpine COPY --from=builder /app/target /usr/share/nginx/html diff --git a/doc/docker.md b/doc/docker.md index 6ca67c02..f7c1b450 100644 --- a/doc/docker.md +++ b/doc/docker.md @@ -55,6 +55,6 @@ Then, start up a container from that image: ``` docker run \ --name hydrogen \ - --publish 80:80 \ + --publish 8080:80 \ hydrogen ``` From 99b067698944049103e5b4cd4e22e93d47007b8d Mon Sep 17 00:00:00 2001 From: Quentin Gliech Date: Wed, 2 Feb 2022 15:54:01 +0100 Subject: [PATCH 041/226] Build and push multi-arch Docker images in CI --- .github/workflows/docker-publish.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 0a322a3d..e02282f8 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -23,6 +23,9 @@ jobs: - name: Checkout repository uses: actions/checkout@v2 + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v1 + - name: Log into registry ${{ env.REGISTRY }} uses: docker/login-action@v1 with: @@ -39,6 +42,7 @@ jobs: - name: Build and push Docker image uses: docker/build-push-action@v2 with: + platforms: linux/amd64,linux/arm64,linux/arm/v7 push: ${{ github.event_name != 'pull_request' }} tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} From dd10919893e39b34e4a4fa07cc2f694ec0aa94f6 Mon Sep 17 00:00:00 2001 From: Quentin Gliech Date: Wed, 2 Feb 2022 16:02:53 +0100 Subject: [PATCH 042/226] Update the documentation to reference the published docker image --- doc/docker.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/doc/docker.md b/doc/docker.md index f7c1b450..c3b1f5ab 100644 --- a/doc/docker.md +++ b/doc/docker.md @@ -41,11 +41,11 @@ export DOCKER_BUILDKIT=1 docker build -t hydrogen . ``` -Or, pull the docker image from GitLab: +Or, pull the Docker image the GitHub Container Registry: ``` -docker pull registry.gitlab.com/jcgruenhage/hydrogen-web -docker tag registry.gitlab.com/jcgruenhage/hydrogen-web hydrogen +docker pull ghcr.io/vector-im/hydrogen +docker tag ghcr.io/vector-im/hydrogen hydrogen ``` ### Start container image From fe72733cd8f8d51940128a078e3eb6df595836c9 Mon Sep 17 00:00:00 2001 From: Quentin Gliech Date: Wed, 2 Feb 2022 15:49:58 +0100 Subject: [PATCH 043/226] Make the Docker image configurable at runtime --- Dockerfile | 16 ++++++++++++++++ docker/config-template.sh | 7 +++++++ docker/config.json.tmpl | 8 ++++++++ 3 files changed, 31 insertions(+) create mode 100755 docker/config-template.sh create mode 100644 docker/config.json.tmpl diff --git a/Dockerfile b/Dockerfile index fade2a24..5bdc6269 100644 --- a/Dockerfile +++ b/Dockerfile @@ -10,5 +10,21 @@ RUN yarn install COPY . . RUN yarn build +# Remove the default config, replace it with a symlink to somewhere that will be updated at runtime +RUN rm -f target/assets/config.json \ + && ln -sf /tmp/config.json target/assets/config.json + FROM --platform=${TARGETPLATFORM} docker.io/nginxinc/nginx-unprivileged:1.21-alpine + +# Copy the config template as well as the templating script +COPY ./docker/config.json.tmpl /config.json.tmpl +COPY ./docker/config-template.sh /docker-entrypoint.d/99-config-template.sh + +# Copy the built app from the first build stage COPY --from=builder /app/target /usr/share/nginx/html + +# Values from the default config that can be overridden at runtime +ENV PUSH_APP_ID="io.element.hydrogen.web" \ + PUSH_GATEWAY_URL="https://matrix.org" \ + PUSH_APPLICATION_SERVER_KEY="BC-gpSdVHEXhvHSHS0AzzWrQoukv2BE7KzpoPO_FfPacqOo3l1pdqz7rSgmB04pZCWaHPz7XRe6fjLaC-WPDopM" \ + DEFAULT_HOMESERVER="matrix.org" diff --git a/docker/config-template.sh b/docker/config-template.sh new file mode 100755 index 00000000..f6cff00c --- /dev/null +++ b/docker/config-template.sh @@ -0,0 +1,7 @@ +#!/bin/sh + +set -eux + +envsubst '$PUSH_APP_ID,$PUSH_GATEWAY_URL,$PUSH_APPLICATION_SERVER_KEY,$DEFAULT_HOMESERVER' \ + < /config.json.tmpl \ + > /tmp/config.json diff --git a/docker/config.json.tmpl b/docker/config.json.tmpl new file mode 100644 index 00000000..94295c43 --- /dev/null +++ b/docker/config.json.tmpl @@ -0,0 +1,8 @@ +{ + "push": { + "appId": "$PUSH_APP_ID", + "gatewayUrl": "$PUSH_GATEWAY_URL", + "applicationServerKey": "$PUSH_APPLICATION_SERVER_KEY" + }, + "defaultHomeServer": "$DEFAULT_HOMESERVER" +} From c9b1c72d5bb4c5f308e0577b50e97d07d27ada7c Mon Sep 17 00:00:00 2001 From: Quentin Gliech Date: Thu, 3 Mar 2022 09:25:58 +0100 Subject: [PATCH 044/226] Native OIDC login --- src/domain/RootViewModel.js | 24 +- .../login/CompleteOIDCLoginViewModel.js | 84 +++++++ src/domain/login/LoginViewModel.js | 36 ++- src/domain/login/StartOIDCLoginViewModel.js | 55 +++++ src/domain/navigation/index.js | 53 ++++- src/matrix/Client.js | 73 +++++- src/matrix/login/OIDCLoginMethod.ts | 67 ++++++ src/matrix/net/HomeServerApi.ts | 20 +- src/matrix/net/OidcApi.ts | 221 ++++++++++++++++++ src/matrix/net/TokenRefresher.ts | 125 ++++++++++ .../localstorage/SessionInfoStorage.ts | 17 ++ src/matrix/well-known.js | 8 +- src/observable/ObservableValue.ts | 32 +++ src/platform/types/types.ts | 1 + src/platform/web/ui/css/login.css | 4 +- src/platform/web/ui/login/LoginView.js | 12 + 16 files changed, 810 insertions(+), 22 deletions(-) create mode 100644 src/domain/login/CompleteOIDCLoginViewModel.js create mode 100644 src/domain/login/StartOIDCLoginViewModel.js create mode 100644 src/matrix/login/OIDCLoginMethod.ts create mode 100644 src/matrix/net/OidcApi.ts create mode 100644 src/matrix/net/TokenRefresher.ts diff --git a/src/domain/RootViewModel.js b/src/domain/RootViewModel.js index 2711cd2f..1bfb5626 100644 --- a/src/domain/RootViewModel.js +++ b/src/domain/RootViewModel.js @@ -38,6 +38,8 @@ export class RootViewModel extends ViewModel { this.track(this.navigation.observe("login").subscribe(() => this._applyNavigation())); this.track(this.navigation.observe("session").subscribe(() => this._applyNavigation())); this.track(this.navigation.observe("sso").subscribe(() => this._applyNavigation())); + this.track(this.navigation.observe("oidc-callback").subscribe(() => this._applyNavigation())); + this.track(this.navigation.observe("oidc-error").subscribe(() => this._applyNavigation())); this._applyNavigation(true); } @@ -46,6 +48,8 @@ export class RootViewModel extends ViewModel { const logoutSessionId = this.navigation.path.get("logout")?.value; const sessionId = this.navigation.path.get("session")?.value; const loginToken = this.navigation.path.get("sso")?.value; + const oidcCallback = this.navigation.path.get("oidc-callback")?.value; + const oidcError = this.navigation.path.get("oidc-error")?.value; if (isLogin) { if (this.activeSection !== "login") { this._showLogin(); @@ -77,7 +81,20 @@ export class RootViewModel extends ViewModel { } else if (loginToken) { this.urlCreator.normalizeUrl(); if (this.activeSection !== "login") { - this._showLogin(loginToken); + this._showLogin({loginToken}); + } + } else if (oidcError) { + this._setSection(() => this._error = new Error(`OIDC error: ${oidcError[1]}`)); + } else if (oidcCallback) { + this._setSection(() => this._error = new Error(`OIDC callback: state=${oidcCallback[0]}, code=${oidcCallback[1]}`)); + this.urlCreator.normalizeUrl(); + if (this.activeSection !== "login") { + this._showLogin({ + oidc: { + state: oidcCallback[0], + code: oidcCallback[1], + } + }); } } else { @@ -109,7 +126,7 @@ export class RootViewModel extends ViewModel { } } - _showLogin(loginToken) { + _showLogin({loginToken, oidc} = {}) { this._setSection(() => { this._loginViewModel = new LoginViewModel(this.childOptions({ defaultHomeserver: this.platform.config["defaultHomeServer"], @@ -125,7 +142,8 @@ export class RootViewModel extends ViewModel { this._pendingClient = client; this.navigation.push("session", client.sessionId); }, - loginToken + loginToken, + oidc, })); }); } diff --git a/src/domain/login/CompleteOIDCLoginViewModel.js b/src/domain/login/CompleteOIDCLoginViewModel.js new file mode 100644 index 00000000..fa0b665e --- /dev/null +++ b/src/domain/login/CompleteOIDCLoginViewModel.js @@ -0,0 +1,84 @@ +/* +Copyright 2021 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 {OidcApi} from "../../matrix/net/OidcApi"; +import {ViewModel} from "../ViewModel"; +import {OIDCLoginMethod} from "../../matrix/login/OIDCLoginMethod"; +import {LoginFailure} from "../../matrix/Client"; + +export class CompleteOIDCLoginViewModel extends ViewModel { + constructor(options) { + super(options); + const { + state, + code, + attemptLogin, + } = options; + this._request = options.platform.request; + this._encoding = options.platform.encoding; + this._state = state; + this._code = code; + this._attemptLogin = attemptLogin; + this._errorMessage = ""; + this.performOIDCLoginCompletion(); + } + + get errorMessage() { return this._errorMessage; } + + _showError(message) { + this._errorMessage = message; + this.emitChange("errorMessage"); + } + + async performOIDCLoginCompletion() { + if (!this._state || !this._code) { + return; + } + const code = this._code; + // TODO: cleanup settings storage + const [startedAt, nonce, codeVerifier, homeserver, issuer] = await Promise.all([ + this.platform.settingsStorage.getInt(`oidc_${this._state}_started_at`), + this.platform.settingsStorage.getString(`oidc_${this._state}_nonce`), + this.platform.settingsStorage.getString(`oidc_${this._state}_code_verifier`), + this.platform.settingsStorage.getString(`oidc_${this._state}_homeserver`), + this.platform.settingsStorage.getString(`oidc_${this._state}_issuer`), + ]); + + const oidcApi = new OidcApi({ + issuer, + clientId: "hydrogen-web", + request: this._request, + encoding: this._encoding, + }); + const method = new OIDCLoginMethod({oidcApi, nonce, codeVerifier, code, homeserver, startedAt}); + const status = await this._attemptLogin(method); + let error = ""; + switch (status) { + case LoginFailure.Credentials: + error = this.i18n`Your login token is invalid.`; + break; + case LoginFailure.Connection: + error = this.i18n`Can't connect to ${homeserver}.`; + break; + case LoginFailure.Unknown: + error = this.i18n`Something went wrong while checking your login token.`; + break; + } + if (error) { + this._showError(error); + } + } +} diff --git a/src/domain/login/LoginViewModel.js b/src/domain/login/LoginViewModel.js index bf77e624..fc66d67b 100644 --- a/src/domain/login/LoginViewModel.js +++ b/src/domain/login/LoginViewModel.js @@ -19,20 +19,25 @@ import {ViewModel} from "../ViewModel"; import {PasswordLoginViewModel} from "./PasswordLoginViewModel.js"; import {StartSSOLoginViewModel} from "./StartSSOLoginViewModel.js"; import {CompleteSSOLoginViewModel} from "./CompleteSSOLoginViewModel.js"; +import {StartOIDCLoginViewModel} from "./StartOIDCLoginViewModel.js"; +import {CompleteOIDCLoginViewModel} from "./CompleteOIDCLoginViewModel.js"; import {LoadStatus} from "../../matrix/Client.js"; import {SessionLoadViewModel} from "../SessionLoadViewModel.js"; export class LoginViewModel extends ViewModel { constructor(options) { super(options); - const {ready, defaultHomeserver, loginToken} = options; + const {ready, defaultHomeserver, loginToken, oidc} = options; this._ready = ready; this._loginToken = loginToken; + this._oidc = oidc; this._client = new Client(this.platform); this._loginOptions = null; this._passwordLoginViewModel = null; this._startSSOLoginViewModel = null; this._completeSSOLoginViewModel = null; + this._startOIDCLoginViewModel = null; + this._completeOIDCLoginViewModel = null; this._loadViewModel = null; this._loadViewModelSubscription = null; this._homeserver = defaultHomeserver; @@ -47,12 +52,14 @@ export class LoginViewModel extends ViewModel { get passwordLoginViewModel() { return this._passwordLoginViewModel; } get startSSOLoginViewModel() { return this._startSSOLoginViewModel; } - get completeSSOLoginViewModel(){ return this._completeSSOLoginViewModel; } + get completeSSOLoginViewModel() { return this._completeSSOLoginViewModel; } + get startOIDCLoginViewModel() { return this._startOIDCLoginViewModel; } + get completeOIDCLoginViewModel() { return this._completeOIDCLoginViewModel; } get homeserver() { return this._homeserver; } get resolvedHomeserver() { return this._loginOptions?.homeserver; } get errorMessage() { return this._errorMessage; } get showHomeserver() { return !this._hideHomeserver; } - get loadViewModel() {return this._loadViewModel; } + get loadViewModel() { return this._loadViewModel; } get isBusy() { return this._isBusy; } get isFetchingLoginOptions() { return !!this._abortQueryOperation; } @@ -72,6 +79,18 @@ export class LoginViewModel extends ViewModel { }))); this.emitChange("completeSSOLoginViewModel"); } + else if (this._oidc) { + this._hideHomeserver = true; + this._completeOIDCLoginViewModel = this.track(new CompleteOIDCLoginViewModel( + this.childOptions( + { + sessionContainer: this._sessionContainer, + attemptLogin: loginMethod => this.attemptLogin(loginMethod), + state: this._oidc.state, + code: this._oidc.code, + }))); + this.emitChange("completeOIDCLoginViewModel"); + } else { await this.queryHomeserver(); } @@ -93,6 +112,14 @@ export class LoginViewModel extends ViewModel { this.emitChange("startSSOLoginViewModel"); } + async _showOIDCLogin() { + this._startOIDCLoginViewModel = this.track( + new StartOIDCLoginViewModel(this.childOptions({loginOptions: this._loginOptions})) + ); + await this._startOIDCLoginViewModel.start(); + this.emitChange("startOIDCLoginViewModel"); + } + _showError(message) { this._errorMessage = message; this.emitChange("errorMessage"); @@ -219,7 +246,8 @@ export class LoginViewModel extends ViewModel { if (this._loginOptions) { if (this._loginOptions.sso) { this._showSSOLogin(); } if (this._loginOptions.password) { this._showPasswordLogin(); } - if (!this._loginOptions.sso && !this._loginOptions.password) { + if (this._loginOptions.oidc) { await this._showOIDCLogin(); } + if (!this._loginOptions.sso && !this._loginOptions.password && !this._loginOptions.oidc) { this._showError("This homeserver supports neither SSO nor password based login flows"); } } diff --git a/src/domain/login/StartOIDCLoginViewModel.js b/src/domain/login/StartOIDCLoginViewModel.js new file mode 100644 index 00000000..e742fe1c --- /dev/null +++ b/src/domain/login/StartOIDCLoginViewModel.js @@ -0,0 +1,55 @@ +/* +Copyright 2021 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 {OidcApi} from "../../matrix/net/OidcApi"; +import {ViewModel} from "../ViewModel"; + +export class StartOIDCLoginViewModel extends ViewModel { + constructor(options) { + super(options); + this._isBusy = true; + this._authorizationEndpoint = null; + this._api = new OidcApi({ + clientId: "hydrogen-web", + issuer: options.loginOptions.oidc.issuer, + request: this.platform.request, + encoding: this.platform.encoding, + }); + this._homeserver = options.loginOptions.homeserver; + } + + get isBusy() { return this._isBusy; } + get authorizationEndpoint() { return this._authorizationEndpoint; } + + async start() { + const p = this._api.generateParams("openid"); + await Promise.all([ + this.platform.settingsStorage.setInt(`oidc_${p.state}_started_at`, Date.now()), + this.platform.settingsStorage.setString(`oidc_${p.state}_nonce`, p.nonce), + this.platform.settingsStorage.setString(`oidc_${p.state}_code_verifier`, p.codeVerifier), + this.platform.settingsStorage.setString(`oidc_${p.state}_homeserver`, this._homeserver), + this.platform.settingsStorage.setString(`oidc_${p.state}_issuer`, this._api.issuer), + ]); + + this._authorizationEndpoint = await this._api.authorizationEndpoint(p); + this._isBusy = false; + } + + setBusy(status) { + this._isBusy = status; + this.emitChange("isBusy"); + } +} diff --git a/src/domain/navigation/index.js b/src/domain/navigation/index.js index 086367ce..7ee75c50 100644 --- a/src/domain/navigation/index.js +++ b/src/domain/navigation/index.js @@ -30,7 +30,7 @@ function allowsChild(parent, child) { switch (parent?.type) { case undefined: // allowed root segments - return type === "login" || type === "session" || type === "sso" || type === "logout"; + return type === "login" || type === "session" || type === "sso" || type === "logout" || type === "oidc-callback" || type === "oidc-error"; case "session": return type === "room" || type === "rooms" || type === "settings" || type === "create-room"; case "rooms": @@ -39,7 +39,7 @@ function allowsChild(parent, child) { case "room": return type === "lightbox" || type === "right-panel"; case "right-panel": - return type === "details"|| type === "members" || type === "member"; + return type === "details" || type === "members" || type === "member"; default: return false; } @@ -105,11 +105,35 @@ export function addPanelIfNeeded(navigation, path) { } export function parseUrlPath(urlPath, currentNavPath, defaultSessionId) { + const segments = []; + + // Special case for OIDC callback + if (urlPath.includes("state")) { + const params = new URLSearchParams(urlPath); + if (params.has("state")) { + // This is a proper OIDC callback + if (params.has("code")) { + segments.push(new Segment("oidc-callback", [ + params.get("state"), + params.get("code"), + ])); + return segments; + } else if (params.has("error")) { + segments.push(new Segment("oidc-error", [ + params.get("state"), + params.get("error"), + params.get("error_description"), + params.get("error_uri"), + ])); + return segments; + } + } + } + // substr(1) to take of initial / const parts = urlPath.substr(1).split("/"); const iterator = parts[Symbol.iterator](); - const segments = []; - let next; + let next; while (!(next = iterator.next()).done) { const type = next.value; if (type === "rooms") { @@ -191,6 +215,8 @@ export function stringifyPath(path) { break; case "right-panel": case "sso": + case "oidc-callback": + case "oidc-error": // Do not put these segments in URL continue; default: @@ -454,6 +480,23 @@ export function tests() { assert.equal(newPath.segments[1].type, "room"); assert.equal(newPath.segments[1].value, "b"); }, - + "Parse OIDC callback": assert => { + const segments = parseUrlPath("state=tc9CnLU7&code=cnmUnwIYtY7V8RrWUyhJa4yvX72jJ5Yx"); + assert.equal(segments.length, 1); + assert.equal(segments[0].type, "oidc-callback"); + assert.deepEqual(segments[0].value, ["tc9CnLU7", "cnmUnwIYtY7V8RrWUyhJa4yvX72jJ5Yx"]); + }, + "Parse OIDC error": assert => { + const segments = parseUrlPath("state=tc9CnLU7&error=invalid_request"); + assert.equal(segments.length, 1); + assert.equal(segments[0].type, "oidc-error"); + assert.deepEqual(segments[0].value, ["tc9CnLU7", "invalid_request", null, null]); + }, + "Parse OIDC error with description": assert => { + const segments = parseUrlPath("state=tc9CnLU7&error=invalid_request&error_description=Unsupported%20response_type%20value"); + assert.equal(segments.length, 1); + assert.equal(segments[0].type, "oidc-error"); + assert.deepEqual(segments[0].value, ["tc9CnLU7", "invalid_request", "Unsupported response_type value", null]); + }, } } diff --git a/src/matrix/Client.js b/src/matrix/Client.js index 21175a7f..0eb380e5 100644 --- a/src/matrix/Client.js +++ b/src/matrix/Client.js @@ -20,6 +20,8 @@ import {lookupHomeserver} from "./well-known.js"; import {AbortableOperation} from "../utils/AbortableOperation"; import {ObservableValue} from "../observable/ObservableValue"; import {HomeServerApi} from "./net/HomeServerApi"; +import {OidcApi} from "./net/OidcApi"; +import {TokenRefresher} from "./net/TokenRefresher"; import {Reconnector, ConnectionStatus} from "./net/Reconnector"; import {ExponentialRetryDelay} from "./net/ExponentialRetryDelay"; import {MediaRepository} from "./net/MediaRepository"; @@ -121,11 +123,29 @@ export class Client { return result; } - queryLogin(homeserver) { + queryLogin(initialHomeserver) { return new AbortableOperation(async setAbortable => { - homeserver = await lookupHomeserver(homeserver, (url, options) => { + const { homeserver, issuer } = await lookupHomeserver(initialHomeserver, (url, options) => { return setAbortable(this._platform.request(url, options)); }); + if (issuer) { + try { + const oidcApi = new OidcApi({ + issuer, + clientId: "hydrogen-web", + request: this._platform.request, + encoding: this._platform.encoding, + }); + await oidcApi.validate(); + + return { + homeserver, + oidc: { issuer }, + }; + } catch (e) { + console.log(e); + } + } const hsApi = new HomeServerApi({homeserver, request: this._platform.request}); const response = await setAbortable(hsApi.getLoginFlows()).response(); return this._parseLoginOptions(response, homeserver); @@ -170,6 +190,19 @@ export class Client { accessToken: loginData.access_token, lastUsed: clock.now() }; + + if (loginData.refresh_token) { + sessionInfo.refreshToken = loginData.refresh_token; + } + + if (loginData.expires_in) { + sessionInfo.accessTokenExpiresAt = clock.now() + loginData.expires_in * 1000; + } + + if (loginData.oidc_issuer) { + sessionInfo.oidcIssuer = loginData.oidc_issuer; + } + log.set("id", sessionId); } catch (err) { this._error = err; @@ -196,7 +229,7 @@ export class Client { sessionInfo.deviceId = dehydratedDevice.deviceId; } } - await this._platform.sessionInfoStorage.add(sessionInfo); + await this._platform.sessionInfoStorage.add(sessionInfo); // loading the session can only lead to // LoadStatus.Error in case of an error, // so separate try/catch @@ -223,9 +256,41 @@ export class Client { retryDelay: new ExponentialRetryDelay(clock.createTimeout), createMeasure: clock.createMeasure }); + + let accessToken; + + if (sessionInfo.oidcIssuer) { + const oidcApi = new OidcApi({ + issuer: sessionInfo.oidcIssuer, + clientId: "hydrogen-web", + request: this._platform.request, + encoding: this._platform.encoding, + }); + + // TODO: stop/pause the refresher? + const tokenRefresher = new TokenRefresher({ + oidcApi, + clock: this._platform.clock, + accessToken: sessionInfo.accessToken, + accessTokenExpiresAt: sessionInfo.accessTokenExpiresAt, + refreshToken: sessionInfo.refreshToken, + anticipation: 30 * 1000, + }); + + tokenRefresher.token.subscribe(t => { + this._platform.sessionInfoStorage.updateToken(sessionInfo.id, t.accessToken, t.accessTokenExpiresAt, t.refreshToken); + }); + + await tokenRefresher.start(); + + accessToken = tokenRefresher.accessToken; + } else { + accessToken = new ObservableValue(sessionInfo.accessToken); + } + const hsApi = new HomeServerApi({ homeserver: sessionInfo.homeServer, - accessToken: sessionInfo.accessToken, + accessToken, request: this._platform.request, reconnector: this._reconnector, }); diff --git a/src/matrix/login/OIDCLoginMethod.ts b/src/matrix/login/OIDCLoginMethod.ts new file mode 100644 index 00000000..0226877a --- /dev/null +++ b/src/matrix/login/OIDCLoginMethod.ts @@ -0,0 +1,67 @@ +/* +Copyright 2021 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 {ILogItem} from "../../logging/types"; +import {ILoginMethod} from "./LoginMethod"; +import {HomeServerApi} from "../net/HomeServerApi.js"; +import {OidcApi} from "../net/OidcApi"; + +export class OIDCLoginMethod implements ILoginMethod { + private readonly _code: string; + private readonly _codeVerifier: string; + private readonly _nonce: string; + private readonly _oidcApi: OidcApi; + public readonly homeserver: string; + + constructor({ + nonce, + codeVerifier, + code, + homeserver, + oidcApi, + }: { + nonce: string, + code: string, + codeVerifier: string, + homeserver: string, + oidcApi: OidcApi, + }) { + this._oidcApi = oidcApi; + this._code = code; + this._codeVerifier = codeVerifier; + this._nonce = nonce; + this.homeserver = homeserver; + } + + async login(hsApi: HomeServerApi, _deviceName: string, log: ILogItem): Promise> { + const { access_token, refresh_token, expires_in } = await this._oidcApi.completeAuthorizationCodeGrant({ + code: this._code, + codeVerifier: this._codeVerifier, + }); + + // TODO: validate the id_token and the nonce claim + + // Do a "whoami" request to find out the user_id and device_id + const { user_id, device_id } = await hsApi.whoami({ + log, + accessTokenOverride: access_token, + }).response(); + + const oidc_issuer = this._oidcApi.issuer; + + return { oidc_issuer, access_token, refresh_token, expires_in, user_id, device_id }; + } +} diff --git a/src/matrix/net/HomeServerApi.ts b/src/matrix/net/HomeServerApi.ts index e9902ef8..d97d9eae 100644 --- a/src/matrix/net/HomeServerApi.ts +++ b/src/matrix/net/HomeServerApi.ts @@ -31,7 +31,7 @@ const DEHYDRATION_PREFIX = "/_matrix/client/unstable/org.matrix.msc2697.v2"; type Options = { homeserver: string; - accessToken: string; + accessToken: BaseObservableValue; request: RequestFunction; reconnector: Reconnector; }; @@ -42,11 +42,12 @@ type BaseRequestOptions = { uploadProgress?: (loadedBytes: number) => void; timeout?: number; prefix?: string; + accessTokenOverride?: string; }; export class HomeServerApi { private readonly _homeserver: string; - private readonly _accessToken: string; + private readonly _accessToken: BaseObservableValue; private readonly _requestFn: RequestFunction; private readonly _reconnector: Reconnector; @@ -63,11 +64,19 @@ export class HomeServerApi { return this._homeserver + prefix + csPath; } - private _baseRequest(method: RequestMethod, url: string, queryParams?: Record, body?: Record, options?: BaseRequestOptions, accessToken?: string): IHomeServerRequest { + private _baseRequest(method: RequestMethod, url: string, queryParams?: Record, body?: Record, options?: BaseRequestOptions, accessTokenSource?: BaseObservableValue): IHomeServerRequest { const queryString = encodeQueryParams(queryParams); url = `${url}?${queryString}`; let encodedBody: EncodedBody["body"]; const headers: Map = new Map(); + + let accessToken: string | null = null; + if (options?.accessTokenOverride) { + accessToken = options.accessTokenOverride; + } else if (accessTokenSource) { + accessToken = accessTokenSource.get(); + } + if (accessToken) { headers.set("Authorization", `Bearer ${accessToken}`); } @@ -279,6 +288,10 @@ export class HomeServerApi { return this._post(`/logout`, {}, {}, options); } + whoami(options?: BaseRequestOptions): IHomeServerRequest { + return this._get(`/account/whoami`, undefined, undefined, options); + } + getDehydratedDevice(options: BaseRequestOptions = {}): IHomeServerRequest { options.prefix = DEHYDRATION_PREFIX; return this._get(`/dehydrated_device`, undefined, undefined, options); @@ -308,6 +321,7 @@ export class HomeServerApi { } import {Request as MockRequest} from "../../mocks/Request.js"; +import {BaseObservableValue} from "../../observable/ObservableValue"; export function tests() { return { diff --git a/src/matrix/net/OidcApi.ts b/src/matrix/net/OidcApi.ts new file mode 100644 index 00000000..3111d65f --- /dev/null +++ b/src/matrix/net/OidcApi.ts @@ -0,0 +1,221 @@ +/* +Copyright 2021 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. +*/ + +const WELL_KNOWN = ".well-known/openid-configuration"; + +const RANDOM_CHARSET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; +const randomChar = () => RANDOM_CHARSET.charAt(Math.floor(Math.random() * 1e10) % RANDOM_CHARSET.length); +const randomString = (length: number) => + Array.from({ length }, randomChar).join(""); + +type BearerToken = { + token_type: "Bearer", + access_token: string, + refresh_token?: string, + expires_in?: number, +} + +const isValidBearerToken = (t: any): t is BearerToken => + typeof t == "object" && + t["token_type"] === "Bearer" && + typeof t["access_token"] === "string" && + (!("refresh_token" in t) || typeof t["refresh_token"] === "string") && + (!("expires_in" in t) || typeof t["expires_in"] === "number"); + + +type AuthorizationParams = { + state: string, + scope: string, + nonce?: string, + codeVerifier?: string, +}; + +function assert(condition: any, message: string): asserts condition { + if (!condition) { + throw new Error(`Assertion failed: ${message}`); + } +}; + +export class OidcApi { + _issuer: string; + _clientId: string; + _requestFn: any; + _base64: any; + _metadataPromise: Promise; + + constructor({ issuer, clientId, request, encoding }) { + this._issuer = issuer; + this._clientId = clientId; + this._requestFn = request; + this._base64 = encoding.base64; + } + + get metadataUrl() { + return new URL(WELL_KNOWN, this._issuer).toString(); + } + + get issuer() { + return this._issuer; + } + + get redirectUri() { + return window.location.origin; + } + + metadata() { + if (!this._metadataPromise) { + this._metadataPromise = (async () => { + const headers = new Map(); + headers.set("Accept", "application/json"); + const req = this._requestFn(this.metadataUrl, { + method: "GET", + headers, + format: "json", + }); + const res = await req.response(); + if (res.status >= 400) { + throw new Error("failed to request metadata"); + } + + return res.body; + })(); + } + return this._metadataPromise; + } + + async validate() { + const m = await this.metadata(); + assert(typeof m.authorization_endpoint === "string", "Has an authorization endpoint"); + assert(typeof m.token_endpoint === "string", "Has a token endpoint"); + assert(Array.isArray(m.response_types_supported) && m.response_types_supported.includes("code"), "Supports the code response type"); + assert(Array.isArray(m.response_modes_supported) && m.response_modes_supported.includes("fragment"), "Supports the fragment response mode"); + assert(Array.isArray(m.grant_types_supported) && m.grant_types_supported.includes("authorization_code"), "Supports the authorization_code grant type"); + assert(Array.isArray(m.code_challenge_methods_supported) && m.code_challenge_methods_supported.includes("S256"), "Supports the authorization_code grant type"); + } + + async _generateCodeChallenge( + codeVerifier: string + ): Promise { + const encoder = new TextEncoder(); + const data = encoder.encode(codeVerifier); + const digest = await window.crypto.subtle.digest("SHA-256", data); + const base64Digest = this._base64.encode(digest); + return base64Digest.replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, ""); + } + + async authorizationEndpoint({ + state, + scope, + nonce, + codeVerifier, + }: AuthorizationParams) { + const metadata = await this.metadata(); + const url = new URL(metadata["authorization_endpoint"]); + url.searchParams.append("response_mode", "fragment"); + url.searchParams.append("response_type", "code"); + url.searchParams.append("redirect_uri", this.redirectUri); + url.searchParams.append("client_id", this._clientId); + url.searchParams.append("state", state); + url.searchParams.append("scope", scope); + if (nonce) { + url.searchParams.append("nonce", nonce); + } + + if (codeVerifier) { + url.searchParams.append("code_challenge_method", "S256"); + url.searchParams.append("code_challenge", await this._generateCodeChallenge(codeVerifier)); + } + + return url.toString(); + } + + async tokenEndpoint() { + const metadata = await this.metadata(); + return metadata["token_endpoint"]; + } + + generateParams(scope: string): AuthorizationParams { + return { + scope, + state: randomString(8), + nonce: randomString(8), + codeVerifier: randomString(32), + }; + } + + async completeAuthorizationCodeGrant({ + codeVerifier, + code, + }: { codeVerifier: string, code: string }): Promise { + const params = new URLSearchParams(); + params.append("grant_type", "authorization_code"); + params.append("client_id", this._clientId); + params.append("code_verifier", codeVerifier); + params.append("redirect_uri", this.redirectUri); + params.append("code", code); + const body = params.toString(); + + const headers = new Map(); + headers.set("Content-Type", "application/x-www-form-urlencoded"); + + const req = this._requestFn(await this.tokenEndpoint(), { + method: "POST", + headers, + format: "json", + body, + }); + + const res = await req.response(); + if (res.status >= 400) { + throw new Error("failed to exchange authorization code"); + } + + const token = res.body; + assert(isValidBearerToken(token), "Got back a valid bearer token"); + + return token; + } + + async refreshToken({ + refreshToken, + }: { refreshToken: string }): Promise { + const params = new URLSearchParams(); + params.append("grant_type", "refresh_token"); + params.append("client_id", this._clientId); + params.append("refresh_token", refreshToken); + const body = params.toString(); + + const headers = new Map(); + headers.set("Content-Type", "application/x-www-form-urlencoded"); + + const req = this._requestFn(await this.tokenEndpoint(), { + method: "POST", + headers, + format: "json", + body, + }); + + const res = await req.response(); + if (res.status >= 400) { + throw new Error("failed to use refresh token"); + } + + const token = res.body; + assert(isValidBearerToken(token), "Got back a valid bearer token"); + + return token; + } +} diff --git a/src/matrix/net/TokenRefresher.ts b/src/matrix/net/TokenRefresher.ts new file mode 100644 index 00000000..489dfb11 --- /dev/null +++ b/src/matrix/net/TokenRefresher.ts @@ -0,0 +1,125 @@ +/* +Copyright 2022 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, ObservableValue} from "../../observable/ObservableValue"; +import type {Clock, Timeout} from "../../platform/web/dom/Clock"; +import {OidcApi} from "./OidcApi"; + +type Token = { + accessToken: string, + accessTokenExpiresAt: number, + refreshToken: string, +}; + + +export class TokenRefresher { + private _token: ObservableValue; + private _accessToken: BaseObservableValue; + private _anticipation: number; + private _clock: Clock; + private _oidcApi: OidcApi; + private _timeout: Timeout + + constructor({ + oidcApi, + refreshToken, + accessToken, + accessTokenExpiresAt, + anticipation, + clock, + }: { + oidcApi: OidcApi, + refreshToken: string, + accessToken: string, + accessTokenExpiresAt: number, + anticipation: number, + clock: Clock, + }) { + this._token = new ObservableValue({ + accessToken, + accessTokenExpiresAt, + refreshToken, + }); + this._accessToken = this._token.map(t => t.accessToken); + + this._anticipation = anticipation; + this._oidcApi = oidcApi; + this._clock = clock; + } + + async start() { + if (this.needsRenewing) { + await this.renew(); + } + + this._renewingLoop(); + } + + stop() { + // TODO + } + + get needsRenewing() { + const remaining = this._token.get().accessTokenExpiresAt - this._clock.now(); + const anticipated = remaining - this._anticipation; + return anticipated < 0; + } + + async _renewingLoop() { + while (true) { + const remaining = + this._token.get().accessTokenExpiresAt - this._clock.now(); + const anticipated = remaining - this._anticipation; + + if (anticipated > 0) { + this._timeout = this._clock.createTimeout(anticipated); + await this._timeout.elapsed(); + } + + await this.renew(); + } + } + + async renew() { + let refreshToken = this._token.get().refreshToken; + const response = await this._oidcApi + .refreshToken({ + refreshToken, + }); + + if (typeof response.expires_in !== "number") { + throw new Error("Refreshed access token does not expire"); + } + + if (response.refresh_token) { + refreshToken = response.refresh_token; + } + + this._token.set({ + refreshToken, + accessToken: response.access_token, + accessTokenExpiresAt: this._clock.now() + response.expires_in * 1000, + }); + } + + get accessToken(): BaseObservableValue { + return this._accessToken; + } + + get token(): BaseObservableValue { + return this._token; + } +} diff --git a/src/matrix/sessioninfo/localstorage/SessionInfoStorage.ts b/src/matrix/sessioninfo/localstorage/SessionInfoStorage.ts index ebe575f6..80443e83 100644 --- a/src/matrix/sessioninfo/localstorage/SessionInfoStorage.ts +++ b/src/matrix/sessioninfo/localstorage/SessionInfoStorage.ts @@ -21,6 +21,9 @@ interface ISessionInfo { homeserver: string; homeServer: string; // deprecate this over time accessToken: string; + accessTokenExpiresAt?: number; + refreshToken?: string; + oidcIssuer?: string; lastUsed: number; } @@ -28,6 +31,7 @@ interface ISessionInfo { interface ISessionInfoStorage { getAll(): Promise; updateLastUsed(id: string, timestamp: number): Promise; + updateToken(id: string, accessToken: string, accessTokenExpiresAt: number, refreshToken: string): Promise; get(id: string): Promise; add(sessionInfo: ISessionInfo): Promise; delete(sessionId: string): Promise; @@ -62,6 +66,19 @@ export class SessionInfoStorage implements ISessionInfoStorage { } } + async updateToken(id: string, accessToken: string, accessTokenExpiresAt: number, refreshToken: string): Promise { + const sessions = await this.getAll(); + if (sessions) { + const session = sessions.find(session => session.id === id); + if (session) { + session.accessToken = accessToken; + session.accessTokenExpiresAt = accessTokenExpiresAt; + session.refreshToken = refreshToken; + localStorage.setItem(this._name, JSON.stringify(sessions)); + } + } + } + async get(id: string): Promise { const sessions = await this.getAll(); if (sessions) { diff --git a/src/matrix/well-known.js b/src/matrix/well-known.js index 00c91f27..6e3bedbf 100644 --- a/src/matrix/well-known.js +++ b/src/matrix/well-known.js @@ -41,6 +41,7 @@ async function getWellKnownResponse(homeserver, request) { export async function lookupHomeserver(homeserver, request) { homeserver = normalizeHomeserver(homeserver); + let issuer = null; const wellKnownResponse = await getWellKnownResponse(homeserver, request); if (wellKnownResponse && wellKnownResponse.status === 200) { const {body} = wellKnownResponse; @@ -48,6 +49,11 @@ export async function lookupHomeserver(homeserver, request) { if (typeof wellKnownHomeserver === "string") { homeserver = normalizeHomeserver(wellKnownHomeserver); } + + const wellKnownIssuer = body["m.authentication"]?.["issuer"]; + if (typeof wellKnownIssuer === "string") { + issuer = wellKnownIssuer; + } } - return homeserver; + return {homeserver, issuer}; } diff --git a/src/observable/ObservableValue.ts b/src/observable/ObservableValue.ts index ad0a226d..8b9b3be6 100644 --- a/src/observable/ObservableValue.ts +++ b/src/observable/ObservableValue.ts @@ -39,6 +39,10 @@ export abstract class BaseObservableValue extends BaseObservable<(value: T) = flatMap(mapper: (value: T) => (BaseObservableValue | undefined)): BaseObservableValue { return new FlatMapObservableValue(this, mapper); } + + map(mapper: (value: T) => C): BaseObservableValue { + return new MappedObservableValue(this, mapper); + } } interface IWaitHandle { @@ -174,6 +178,34 @@ export class FlatMapObservableValue extends BaseObservableValue extends BaseObservableValue { + private sourceSubscription?: SubscriptionHandle; + + constructor( + private readonly source: BaseObservableValue

, + private readonly mapper: (value: P) => C + ) { + super(); + } + + onUnsubscribeLast() { + super.onUnsubscribeLast(); + this.sourceSubscription = this.sourceSubscription!(); + } + + onSubscribeFirst() { + super.onSubscribeFirst(); + this.sourceSubscription = this.source.subscribe(() => { + this.emit(this.get()); + }); + } + + get(): C { + const sourceValue = this.source.get(); + return this.mapper(sourceValue); + } +} + export function tests() { return { "set emits an update": assert => { diff --git a/src/platform/types/types.ts b/src/platform/types/types.ts index 1d359a09..6605f238 100644 --- a/src/platform/types/types.ts +++ b/src/platform/types/types.ts @@ -26,6 +26,7 @@ export interface IRequestOptions { cache?: boolean; method?: string; format?: string; + accessTokenOverride?: string; } export type RequestFunction = (url: string, options: IRequestOptions) => RequestResult; diff --git a/src/platform/web/ui/css/login.css b/src/platform/web/ui/css/login.css index deb16b02..ae706242 100644 --- a/src/platform/web/ui/css/login.css +++ b/src/platform/web/ui/css/login.css @@ -68,13 +68,13 @@ limitations under the License. --size: 20px; } -.StartSSOLoginView { +.StartSSOLoginView, .StartOIDCLoginView { display: flex; flex-direction: column; padding: 0 0.4em 0; } -.StartSSOLoginView_button { +.StartSSOLoginView_button, .StartOIDCLoginView_button { flex: 1; margin-top: 12px; } diff --git a/src/platform/web/ui/login/LoginView.js b/src/platform/web/ui/login/LoginView.js index 88002625..ee8bf169 100644 --- a/src/platform/web/ui/login/LoginView.js +++ b/src/platform/web/ui/login/LoginView.js @@ -57,6 +57,7 @@ export class LoginView extends TemplateView { t.mapView(vm => vm.passwordLoginViewModel, vm => vm ? new PasswordLoginView(vm): null), t.if(vm => vm.passwordLoginViewModel && vm.startSSOLoginViewModel, t => t.p({className: "LoginView_separator"}, vm.i18n`or`)), t.mapView(vm => vm.startSSOLoginViewModel, vm => vm ? new StartSSOLoginView(vm) : null), + t.mapView(vm => vm.startOIDCLoginViewModel, vm => vm ? new StartOIDCLoginView(vm) : null), t.mapView(vm => vm.loadViewModel, loadViewModel => loadViewModel ? new SessionLoadStatusView(loadViewModel) : null), // use t.mapView rather than t.if to create a new view when the view model changes too t.p(hydrogenGithubLink(t)) @@ -76,3 +77,14 @@ class StartSSOLoginView extends TemplateView { ); } } + +class StartOIDCLoginView extends TemplateView { + render(t, vm) { + return t.div({ className: "StartOIDCLoginView" }, + t.a({ + className: "StartOIDCLoginView_button button-action secondary", + href: vm => (vm.isBusy ? "#" : vm.authorizationEndpoint), + }, vm.i18n`Log in via OIDC`) + ); + } +} From dac68f362a6b287e65cbe5775c00ce7caa83c6f2 Mon Sep 17 00:00:00 2001 From: Quentin Gliech Date: Thu, 3 Mar 2022 13:43:16 +0100 Subject: [PATCH 045/226] Only generate the auth URL and start the login flow on click --- src/domain/login/LoginViewModel.js | 5 ++-- src/domain/login/StartOIDCLoginViewModel.js | 30 ++++++++++++--------- src/platform/web/ui/login/LoginView.js | 4 ++- 3 files changed, 23 insertions(+), 16 deletions(-) diff --git a/src/domain/login/LoginViewModel.js b/src/domain/login/LoginViewModel.js index fc66d67b..cc062cea 100644 --- a/src/domain/login/LoginViewModel.js +++ b/src/domain/login/LoginViewModel.js @@ -116,8 +116,8 @@ export class LoginViewModel extends ViewModel { this._startOIDCLoginViewModel = this.track( new StartOIDCLoginViewModel(this.childOptions({loginOptions: this._loginOptions})) ); - await this._startOIDCLoginViewModel.start(); this.emitChange("startOIDCLoginViewModel"); + this._startOIDCLoginViewModel.discover(); } _showError(message) { @@ -129,6 +129,7 @@ export class LoginViewModel extends ViewModel { this._isBusy = status; this._passwordLoginViewModel?.setBusy(status); this._startSSOLoginViewModel?.setBusy(status); + this.startOIDCLoginViewModel?.setBusy(status); this.emitChange("isBusy"); } @@ -246,7 +247,7 @@ export class LoginViewModel extends ViewModel { if (this._loginOptions) { if (this._loginOptions.sso) { this._showSSOLogin(); } if (this._loginOptions.password) { this._showPasswordLogin(); } - if (this._loginOptions.oidc) { await this._showOIDCLogin(); } + if (this._loginOptions.oidc) { this._showOIDCLogin(); } if (!this._loginOptions.sso && !this._loginOptions.password && !this._loginOptions.oidc) { this._showError("This homeserver supports neither SSO nor password based login flows"); } diff --git a/src/domain/login/StartOIDCLoginViewModel.js b/src/domain/login/StartOIDCLoginViewModel.js index e742fe1c..146d81b1 100644 --- a/src/domain/login/StartOIDCLoginViewModel.js +++ b/src/domain/login/StartOIDCLoginViewModel.js @@ -21,35 +21,39 @@ export class StartOIDCLoginViewModel extends ViewModel { constructor(options) { super(options); this._isBusy = true; - this._authorizationEndpoint = null; + this._issuer = options.loginOptions.oidc.issuer; + this._homeserver = options.loginOptions.homeserver; this._api = new OidcApi({ clientId: "hydrogen-web", - issuer: options.loginOptions.oidc.issuer, + issuer: this._issuer, request: this.platform.request, encoding: this.platform.encoding, }); - this._homeserver = options.loginOptions.homeserver; } get isBusy() { return this._isBusy; } - get authorizationEndpoint() { return this._authorizationEndpoint; } - async start() { + setBusy(status) { + this._isBusy = status; + this.emitChange("isBusy"); + } + + async discover() { + // Ask for the metadata once so it gets discovered and cached + await this._api.metadata() + } + + async startOIDCLogin() { const p = this._api.generateParams("openid"); await Promise.all([ this.platform.settingsStorage.setInt(`oidc_${p.state}_started_at`, Date.now()), this.platform.settingsStorage.setString(`oidc_${p.state}_nonce`, p.nonce), this.platform.settingsStorage.setString(`oidc_${p.state}_code_verifier`, p.codeVerifier), this.platform.settingsStorage.setString(`oidc_${p.state}_homeserver`, this._homeserver), - this.platform.settingsStorage.setString(`oidc_${p.state}_issuer`, this._api.issuer), + this.platform.settingsStorage.setString(`oidc_${p.state}_issuer`, this._issuer), ]); - this._authorizationEndpoint = await this._api.authorizationEndpoint(p); - this._isBusy = false; - } - - setBusy(status) { - this._isBusy = status; - this.emitChange("isBusy"); + const link = await this._api.authorizationEndpoint(p); + this.platform.openUrl(link); } } diff --git a/src/platform/web/ui/login/LoginView.js b/src/platform/web/ui/login/LoginView.js index ee8bf169..116c82cd 100644 --- a/src/platform/web/ui/login/LoginView.js +++ b/src/platform/web/ui/login/LoginView.js @@ -83,7 +83,9 @@ class StartOIDCLoginView extends TemplateView { return t.div({ className: "StartOIDCLoginView" }, t.a({ className: "StartOIDCLoginView_button button-action secondary", - href: vm => (vm.isBusy ? "#" : vm.authorizationEndpoint), + type: "button", + onClick: () => vm.startOIDCLogin(), + disabled: vm => vm.isBusy }, vm.i18n`Log in via OIDC`) ); } From d723561d661af563f930ed0e69b14dd6ef923f3b Mon Sep 17 00:00:00 2001 From: Quentin Gliech Date: Thu, 3 Mar 2022 15:16:43 +0100 Subject: [PATCH 046/226] Generate the OIDC redirect URI from the URLRouter This also saves the redirectUri during the flow --- src/domain/login/CompleteOIDCLoginViewModel.js | 5 +++-- src/domain/login/StartOIDCLoginViewModel.js | 6 +++++- src/domain/navigation/URLRouter.js | 4 ++++ src/matrix/login/OIDCLoginMethod.ts | 5 +++++ src/matrix/net/OidcApi.ts | 12 ++++++++---- 5 files changed, 25 insertions(+), 7 deletions(-) diff --git a/src/domain/login/CompleteOIDCLoginViewModel.js b/src/domain/login/CompleteOIDCLoginViewModel.js index fa0b665e..f3a9c441 100644 --- a/src/domain/login/CompleteOIDCLoginViewModel.js +++ b/src/domain/login/CompleteOIDCLoginViewModel.js @@ -49,10 +49,11 @@ export class CompleteOIDCLoginViewModel extends ViewModel { } const code = this._code; // TODO: cleanup settings storage - const [startedAt, nonce, codeVerifier, homeserver, issuer] = await Promise.all([ + const [startedAt, nonce, codeVerifier, redirectUri, homeserver, issuer] = await Promise.all([ this.platform.settingsStorage.getInt(`oidc_${this._state}_started_at`), this.platform.settingsStorage.getString(`oidc_${this._state}_nonce`), this.platform.settingsStorage.getString(`oidc_${this._state}_code_verifier`), + this.platform.settingsStorage.getString(`oidc_${this._state}_redirect_uri`), this.platform.settingsStorage.getString(`oidc_${this._state}_homeserver`), this.platform.settingsStorage.getString(`oidc_${this._state}_issuer`), ]); @@ -63,7 +64,7 @@ export class CompleteOIDCLoginViewModel extends ViewModel { request: this._request, encoding: this._encoding, }); - const method = new OIDCLoginMethod({oidcApi, nonce, codeVerifier, code, homeserver, startedAt}); + const method = new OIDCLoginMethod({oidcApi, nonce, codeVerifier, code, homeserver, startedAt, redirectUri}); const status = await this._attemptLogin(method); let error = ""; switch (status) { diff --git a/src/domain/login/StartOIDCLoginViewModel.js b/src/domain/login/StartOIDCLoginViewModel.js index 146d81b1..89600d58 100644 --- a/src/domain/login/StartOIDCLoginViewModel.js +++ b/src/domain/login/StartOIDCLoginViewModel.js @@ -44,11 +44,15 @@ export class StartOIDCLoginViewModel extends ViewModel { } async startOIDCLogin() { - const p = this._api.generateParams("openid"); + const p = this._api.generateParams({ + scope: "openid", + redirectUri: this.urlCreator.createOIDCRedirectURL(), + }); await Promise.all([ this.platform.settingsStorage.setInt(`oidc_${p.state}_started_at`, Date.now()), this.platform.settingsStorage.setString(`oidc_${p.state}_nonce`, p.nonce), this.platform.settingsStorage.setString(`oidc_${p.state}_code_verifier`, p.codeVerifier), + this.platform.settingsStorage.setString(`oidc_${p.state}_redirect_uri`, p.redirectUri), this.platform.settingsStorage.setString(`oidc_${p.state}_homeserver`, this._homeserver), this.platform.settingsStorage.setString(`oidc_${p.state}_issuer`, this._issuer), ]); diff --git a/src/domain/navigation/URLRouter.js b/src/domain/navigation/URLRouter.js index 586eec8a..00614951 100644 --- a/src/domain/navigation/URLRouter.js +++ b/src/domain/navigation/URLRouter.js @@ -125,6 +125,10 @@ export class URLRouter { return window.location.origin; } + createOIDCRedirectURL() { + return window.location.origin; + } + normalizeUrl() { // Remove any queryParameters from the URL // Gets rid of the loginToken after SSO diff --git a/src/matrix/login/OIDCLoginMethod.ts b/src/matrix/login/OIDCLoginMethod.ts index 0226877a..1e834b64 100644 --- a/src/matrix/login/OIDCLoginMethod.ts +++ b/src/matrix/login/OIDCLoginMethod.ts @@ -23,6 +23,7 @@ export class OIDCLoginMethod implements ILoginMethod { private readonly _code: string; private readonly _codeVerifier: string; private readonly _nonce: string; + private readonly _redirectUri: string; private readonly _oidcApi: OidcApi; public readonly homeserver: string; @@ -31,18 +32,21 @@ export class OIDCLoginMethod implements ILoginMethod { codeVerifier, code, homeserver, + redirectUri, oidcApi, }: { nonce: string, code: string, codeVerifier: string, homeserver: string, + redirectUri: string, oidcApi: OidcApi, }) { this._oidcApi = oidcApi; this._code = code; this._codeVerifier = codeVerifier; this._nonce = nonce; + this._redirectUri = redirectUri; this.homeserver = homeserver; } @@ -50,6 +54,7 @@ export class OIDCLoginMethod implements ILoginMethod { const { access_token, refresh_token, expires_in } = await this._oidcApi.completeAuthorizationCodeGrant({ code: this._code, codeVerifier: this._codeVerifier, + redirectUri: this._redirectUri, }); // TODO: validate the id_token and the nonce claim diff --git a/src/matrix/net/OidcApi.ts b/src/matrix/net/OidcApi.ts index 3111d65f..3dfe4cdd 100644 --- a/src/matrix/net/OidcApi.ts +++ b/src/matrix/net/OidcApi.ts @@ -39,6 +39,7 @@ const isValidBearerToken = (t: any): t is BearerToken => type AuthorizationParams = { state: string, scope: string, + redirectUri: string, nonce?: string, codeVerifier?: string, }; @@ -118,6 +119,7 @@ export class OidcApi { async authorizationEndpoint({ state, + redirectUri, scope, nonce, codeVerifier, @@ -126,7 +128,7 @@ export class OidcApi { const url = new URL(metadata["authorization_endpoint"]); url.searchParams.append("response_mode", "fragment"); url.searchParams.append("response_type", "code"); - url.searchParams.append("redirect_uri", this.redirectUri); + url.searchParams.append("redirect_uri", redirectUri); url.searchParams.append("client_id", this._clientId); url.searchParams.append("state", state); url.searchParams.append("scope", scope); @@ -147,9 +149,10 @@ export class OidcApi { return metadata["token_endpoint"]; } - generateParams(scope: string): AuthorizationParams { + generateParams({ scope, redirectUri }: { scope: string, redirectUri: string }): AuthorizationParams { return { scope, + redirectUri, state: randomString(8), nonce: randomString(8), codeVerifier: randomString(32), @@ -159,12 +162,13 @@ export class OidcApi { async completeAuthorizationCodeGrant({ codeVerifier, code, - }: { codeVerifier: string, code: string }): Promise { + redirectUri, + }: { codeVerifier: string, code: string, redirectUri: string }): Promise { const params = new URLSearchParams(); params.append("grant_type", "authorization_code"); params.append("client_id", this._clientId); params.append("code_verifier", codeVerifier); - params.append("redirect_uri", this.redirectUri); + params.append("redirect_uri", redirectUri); params.append("code", code); const body = params.toString(); From 2086dc8f325f34801f960a1bce4095c830b1cef6 Mon Sep 17 00:00:00 2001 From: Quentin Gliech Date: Thu, 3 Mar 2022 15:30:28 +0100 Subject: [PATCH 047/226] Simplify OIDC callback navigation handling --- src/domain/RootViewModel.js | 26 +++++++++++--------------- src/domain/navigation/index.js | 34 +++++++++++++++++----------------- 2 files changed, 28 insertions(+), 32 deletions(-) diff --git a/src/domain/RootViewModel.js b/src/domain/RootViewModel.js index 1bfb5626..91f144c7 100644 --- a/src/domain/RootViewModel.js +++ b/src/domain/RootViewModel.js @@ -38,8 +38,7 @@ export class RootViewModel extends ViewModel { this.track(this.navigation.observe("login").subscribe(() => this._applyNavigation())); this.track(this.navigation.observe("session").subscribe(() => this._applyNavigation())); this.track(this.navigation.observe("sso").subscribe(() => this._applyNavigation())); - this.track(this.navigation.observe("oidc-callback").subscribe(() => this._applyNavigation())); - this.track(this.navigation.observe("oidc-error").subscribe(() => this._applyNavigation())); + this.track(this.navigation.observe("oidc").subscribe(() => this._applyNavigation())); this._applyNavigation(true); } @@ -48,8 +47,7 @@ export class RootViewModel extends ViewModel { const logoutSessionId = this.navigation.path.get("logout")?.value; const sessionId = this.navigation.path.get("session")?.value; const loginToken = this.navigation.path.get("sso")?.value; - const oidcCallback = this.navigation.path.get("oidc-callback")?.value; - const oidcError = this.navigation.path.get("oidc-error")?.value; + const oidcCallback = this.navigation.path.get("oidc")?.value; if (isLogin) { if (this.activeSection !== "login") { this._showLogin(); @@ -83,18 +81,16 @@ export class RootViewModel extends ViewModel { if (this.activeSection !== "login") { this._showLogin({loginToken}); } - } else if (oidcError) { - this._setSection(() => this._error = new Error(`OIDC error: ${oidcError[1]}`)); } else if (oidcCallback) { - this._setSection(() => this._error = new Error(`OIDC callback: state=${oidcCallback[0]}, code=${oidcCallback[1]}`)); - this.urlCreator.normalizeUrl(); - if (this.activeSection !== "login") { - this._showLogin({ - oidc: { - state: oidcCallback[0], - code: oidcCallback[1], - } - }); + if (oidcCallback.error) { + this._setSection(() => this._error = new Error(`OIDC error: ${oidcCallback.error}`)); + } else { + this.urlCreator.normalizeUrl(); + if (this.activeSection !== "login") { + this._showLogin({ + oidc: oidcCallback, + }); + } } } else { diff --git a/src/domain/navigation/index.js b/src/domain/navigation/index.js index 7ee75c50..bd4dd0db 100644 --- a/src/domain/navigation/index.js +++ b/src/domain/navigation/index.js @@ -30,7 +30,7 @@ function allowsChild(parent, child) { switch (parent?.type) { case undefined: // allowed root segments - return type === "login" || type === "session" || type === "sso" || type === "logout" || type === "oidc-callback" || type === "oidc-error"; + return type === "login" || type === "session" || type === "sso" || type === "logout" || type === "oidc"; case "session": return type === "room" || type === "rooms" || type === "settings" || type === "create-room"; case "rooms": @@ -113,18 +113,18 @@ export function parseUrlPath(urlPath, currentNavPath, defaultSessionId) { if (params.has("state")) { // This is a proper OIDC callback if (params.has("code")) { - segments.push(new Segment("oidc-callback", [ - params.get("state"), - params.get("code"), - ])); + segments.push(new Segment("oidc", { + state: params.get("state"), + code: params.get("code"), + })); return segments; } else if (params.has("error")) { - segments.push(new Segment("oidc-error", [ - params.get("state"), - params.get("error"), - params.get("error_description"), - params.get("error_uri"), - ])); + segments.push(new Segment("oidc", { + state: params.get("state"), + error: params.get("error"), + errorDescription: params.get("error_description"), + errorUri: params.get("error_uri"), + })); return segments; } } @@ -483,20 +483,20 @@ export function tests() { "Parse OIDC callback": assert => { const segments = parseUrlPath("state=tc9CnLU7&code=cnmUnwIYtY7V8RrWUyhJa4yvX72jJ5Yx"); assert.equal(segments.length, 1); - assert.equal(segments[0].type, "oidc-callback"); - assert.deepEqual(segments[0].value, ["tc9CnLU7", "cnmUnwIYtY7V8RrWUyhJa4yvX72jJ5Yx"]); + assert.equal(segments[0].type, "oidc"); + assert.deepEqual(segments[0].value, {state: "tc9CnLU7", code: "cnmUnwIYtY7V8RrWUyhJa4yvX72jJ5Yx"}); }, "Parse OIDC error": assert => { const segments = parseUrlPath("state=tc9CnLU7&error=invalid_request"); assert.equal(segments.length, 1); - assert.equal(segments[0].type, "oidc-error"); - assert.deepEqual(segments[0].value, ["tc9CnLU7", "invalid_request", null, null]); + assert.equal(segments[0].type, "oidc"); + assert.deepEqual(segments[0].value, {state: "tc9CnLU7", error: "invalid_request", errorUri: null, errorDescription: null}); }, "Parse OIDC error with description": assert => { const segments = parseUrlPath("state=tc9CnLU7&error=invalid_request&error_description=Unsupported%20response_type%20value"); assert.equal(segments.length, 1); - assert.equal(segments[0].type, "oidc-error"); - assert.deepEqual(segments[0].value, ["tc9CnLU7", "invalid_request", "Unsupported response_type value", null]); + assert.equal(segments[0].type, "oidc"); + assert.deepEqual(segments[0].value, {state: "tc9CnLU7", error: "invalid_request", errorDescription: "Unsupported response_type value", errorUri: null}); }, } } From ace7ad706541194128757d663ee6d1336ca591ab Mon Sep 17 00:00:00 2001 From: Quentin Gliech Date: Thu, 3 Mar 2022 15:41:40 +0100 Subject: [PATCH 048/226] Use platform APIs for text encoding and hashing --- src/domain/login/CompleteOIDCLoginViewModel.js | 2 ++ src/domain/login/StartOIDCLoginViewModel.js | 1 + src/matrix/Client.js | 2 ++ src/matrix/net/OidcApi.ts | 15 ++++++++------- 4 files changed, 13 insertions(+), 7 deletions(-) diff --git a/src/domain/login/CompleteOIDCLoginViewModel.js b/src/domain/login/CompleteOIDCLoginViewModel.js index f3a9c441..ca65c7c7 100644 --- a/src/domain/login/CompleteOIDCLoginViewModel.js +++ b/src/domain/login/CompleteOIDCLoginViewModel.js @@ -29,6 +29,7 @@ export class CompleteOIDCLoginViewModel extends ViewModel { } = options; this._request = options.platform.request; this._encoding = options.platform.encoding; + this._crypto = options.platform.crypto; this._state = state; this._code = code; this._attemptLogin = attemptLogin; @@ -63,6 +64,7 @@ export class CompleteOIDCLoginViewModel extends ViewModel { clientId: "hydrogen-web", request: this._request, encoding: this._encoding, + crypto: this._crypto, }); const method = new OIDCLoginMethod({oidcApi, nonce, codeVerifier, code, homeserver, startedAt, redirectUri}); const status = await this._attemptLogin(method); diff --git a/src/domain/login/StartOIDCLoginViewModel.js b/src/domain/login/StartOIDCLoginViewModel.js index 89600d58..a06b764f 100644 --- a/src/domain/login/StartOIDCLoginViewModel.js +++ b/src/domain/login/StartOIDCLoginViewModel.js @@ -28,6 +28,7 @@ export class StartOIDCLoginViewModel extends ViewModel { issuer: this._issuer, request: this.platform.request, encoding: this.platform.encoding, + crypto: this.platform.crypto, }); } diff --git a/src/matrix/Client.js b/src/matrix/Client.js index 0eb380e5..7d0d87a0 100644 --- a/src/matrix/Client.js +++ b/src/matrix/Client.js @@ -135,6 +135,7 @@ export class Client { clientId: "hydrogen-web", request: this._platform.request, encoding: this._platform.encoding, + crypto: this._platform.crypto, }); await oidcApi.validate(); @@ -265,6 +266,7 @@ export class Client { clientId: "hydrogen-web", request: this._platform.request, encoding: this._platform.encoding, + crypto: this._platform.crypto, }); // TODO: stop/pause the refresher? diff --git a/src/matrix/net/OidcApi.ts b/src/matrix/net/OidcApi.ts index 3dfe4cdd..f7c08dca 100644 --- a/src/matrix/net/OidcApi.ts +++ b/src/matrix/net/OidcApi.ts @@ -54,14 +54,16 @@ export class OidcApi { _issuer: string; _clientId: string; _requestFn: any; - _base64: any; + _encoding: any; + _crypto: any; _metadataPromise: Promise; - constructor({ issuer, clientId, request, encoding }) { + constructor({ issuer, clientId, request, encoding, crypto }) { this._issuer = issuer; this._clientId = clientId; this._requestFn = request; - this._base64 = encoding.base64; + this._encoding = encoding; + this._crypto = crypto; } get metadataUrl() { @@ -110,10 +112,9 @@ export class OidcApi { async _generateCodeChallenge( codeVerifier: string ): Promise { - const encoder = new TextEncoder(); - const data = encoder.encode(codeVerifier); - const digest = await window.crypto.subtle.digest("SHA-256", data); - const base64Digest = this._base64.encode(digest); + const data = this._encoding.utf8.encode(codeVerifier); + const digest = await this._crypto.digest("SHA-256", data); + const base64Digest = this._encoding.base64.encode(digest); return base64Digest.replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, ""); } From b3e6f4b4947750dae00e5f98d4add610502734e4 Mon Sep 17 00:00:00 2001 From: Quentin Gliech Date: Thu, 3 Mar 2022 16:27:43 +0100 Subject: [PATCH 049/226] Stop the token refresher when disposing the client --- src/matrix/Client.js | 13 ++++++++----- src/matrix/net/OidcApi.ts | 4 +++- src/matrix/net/TokenRefresher.ts | 16 +++++++++++++--- 3 files changed, 24 insertions(+), 9 deletions(-) diff --git a/src/matrix/Client.js b/src/matrix/Client.js index 7d0d87a0..d0530d1f 100644 --- a/src/matrix/Client.js +++ b/src/matrix/Client.js @@ -269,8 +269,7 @@ export class Client { crypto: this._platform.crypto, }); - // TODO: stop/pause the refresher? - const tokenRefresher = new TokenRefresher({ + this._tokenRefresher = new TokenRefresher({ oidcApi, clock: this._platform.clock, accessToken: sessionInfo.accessToken, @@ -279,13 +278,13 @@ export class Client { anticipation: 30 * 1000, }); - tokenRefresher.token.subscribe(t => { + this._tokenRefresher.token.subscribe(t => { this._platform.sessionInfoStorage.updateToken(sessionInfo.id, t.accessToken, t.accessTokenExpiresAt, t.refreshToken); }); - await tokenRefresher.start(); + await this._tokenRefresher.start(); - accessToken = tokenRefresher.accessToken; + accessToken = this._tokenRefresher.accessToken; } else { accessToken = new ObservableValue(sessionInfo.accessToken); } @@ -500,6 +499,10 @@ export class Client { this._sync.stop(); this._sync = null; } + if (this._tokenRefresher) { + this._tokenRefresher.stop(); + this._tokenRefresher = null; + } if (this._session) { this._session.dispose(); this._session = null; diff --git a/src/matrix/net/OidcApi.ts b/src/matrix/net/OidcApi.ts index f7c08dca..023ba485 100644 --- a/src/matrix/net/OidcApi.ts +++ b/src/matrix/net/OidcApi.ts @@ -14,6 +14,8 @@ See the License for the specific language governing permissions and limitations under the License. */ +import type {RequestFunction} from "../../platform/types/types"; + const WELL_KNOWN = ".well-known/openid-configuration"; const RANDOM_CHARSET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; @@ -53,7 +55,7 @@ function assert(condition: any, message: string): asserts condition { export class OidcApi { _issuer: string; _clientId: string; - _requestFn: any; + _requestFn: RequestFunction; _encoding: any; _crypto: any; _metadataPromise: Promise; diff --git a/src/matrix/net/TokenRefresher.ts b/src/matrix/net/TokenRefresher.ts index 489dfb11..2010cebe 100644 --- a/src/matrix/net/TokenRefresher.ts +++ b/src/matrix/net/TokenRefresher.ts @@ -32,6 +32,7 @@ export class TokenRefresher { private _clock: Clock; private _oidcApi: OidcApi; private _timeout: Timeout + private _running: boolean; constructor({ oidcApi, @@ -65,11 +66,15 @@ export class TokenRefresher { await this.renew(); } + this._running = true; this._renewingLoop(); } stop() { - // TODO + this._running = false; + if (this._timeout) { + this._timeout.dispose(); + } } get needsRenewing() { @@ -79,14 +84,19 @@ export class TokenRefresher { } async _renewingLoop() { - while (true) { + while (this._running) { const remaining = this._token.get().accessTokenExpiresAt - this._clock.now(); const anticipated = remaining - this._anticipation; if (anticipated > 0) { this._timeout = this._clock.createTimeout(anticipated); - await this._timeout.elapsed(); + try { + await this._timeout.elapsed(); + } catch { + // The timeout will throw when aborted, so stop the loop if it is the case + return; + } } await this.renew(); From fdf11cc791627136014a638ba0664ccd1b362890 Mon Sep 17 00:00:00 2001 From: Quentin Gliech Date: Thu, 3 Mar 2022 16:38:41 +0100 Subject: [PATCH 050/226] Typo. --- src/domain/login/LoginViewModel.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/domain/login/LoginViewModel.js b/src/domain/login/LoginViewModel.js index cc062cea..63dc1ff3 100644 --- a/src/domain/login/LoginViewModel.js +++ b/src/domain/login/LoginViewModel.js @@ -129,7 +129,7 @@ export class LoginViewModel extends ViewModel { this._isBusy = status; this._passwordLoginViewModel?.setBusy(status); this._startSSOLoginViewModel?.setBusy(status); - this.startOIDCLoginViewModel?.setBusy(status); + this._startOIDCLoginViewModel?.setBusy(status); this.emitChange("isBusy"); } From f1cc22a9203da7f4556c374ee98566e86d89fb4b Mon Sep 17 00:00:00 2001 From: Quentin Gliech Date: Mon, 25 Apr 2022 09:31:00 +0200 Subject: [PATCH 051/226] OIDC dynamic client registration --- .../login/CompleteOIDCLoginViewModel.js | 5 +- src/domain/login/StartOIDCLoginViewModel.js | 5 +- src/domain/navigation/URLRouter.js | 4 ++ src/matrix/Client.js | 4 +- src/matrix/login/OIDCLoginMethod.ts | 3 +- src/matrix/net/OidcApi.ts | 70 ++++++++++++++++--- 6 files changed, 74 insertions(+), 17 deletions(-) diff --git a/src/domain/login/CompleteOIDCLoginViewModel.js b/src/domain/login/CompleteOIDCLoginViewModel.js index ca65c7c7..5d0da980 100644 --- a/src/domain/login/CompleteOIDCLoginViewModel.js +++ b/src/domain/login/CompleteOIDCLoginViewModel.js @@ -50,18 +50,19 @@ export class CompleteOIDCLoginViewModel extends ViewModel { } const code = this._code; // TODO: cleanup settings storage - const [startedAt, nonce, codeVerifier, redirectUri, homeserver, issuer] = await Promise.all([ + const [startedAt, nonce, codeVerifier, redirectUri, homeserver, issuer, clientId] = await Promise.all([ this.platform.settingsStorage.getInt(`oidc_${this._state}_started_at`), this.platform.settingsStorage.getString(`oidc_${this._state}_nonce`), this.platform.settingsStorage.getString(`oidc_${this._state}_code_verifier`), this.platform.settingsStorage.getString(`oidc_${this._state}_redirect_uri`), this.platform.settingsStorage.getString(`oidc_${this._state}_homeserver`), this.platform.settingsStorage.getString(`oidc_${this._state}_issuer`), + this.platform.settingsStorage.getString(`oidc_${this._state}_client_id`), ]); const oidcApi = new OidcApi({ issuer, - clientId: "hydrogen-web", + clientId, request: this._request, encoding: this._encoding, crypto: this._crypto, diff --git a/src/domain/login/StartOIDCLoginViewModel.js b/src/domain/login/StartOIDCLoginViewModel.js index a06b764f..e70a7487 100644 --- a/src/domain/login/StartOIDCLoginViewModel.js +++ b/src/domain/login/StartOIDCLoginViewModel.js @@ -24,11 +24,11 @@ export class StartOIDCLoginViewModel extends ViewModel { this._issuer = options.loginOptions.oidc.issuer; this._homeserver = options.loginOptions.homeserver; this._api = new OidcApi({ - clientId: "hydrogen-web", issuer: this._issuer, request: this.platform.request, encoding: this.platform.encoding, crypto: this.platform.crypto, + urlCreator: this.urlCreator, }); } @@ -42,6 +42,7 @@ export class StartOIDCLoginViewModel extends ViewModel { async discover() { // Ask for the metadata once so it gets discovered and cached await this._api.metadata() + await this._api.ensureRegistered(); } async startOIDCLogin() { @@ -49,6 +50,7 @@ export class StartOIDCLoginViewModel extends ViewModel { scope: "openid", redirectUri: this.urlCreator.createOIDCRedirectURL(), }); + const clientId = await this._api.clientId(); await Promise.all([ this.platform.settingsStorage.setInt(`oidc_${p.state}_started_at`, Date.now()), this.platform.settingsStorage.setString(`oidc_${p.state}_nonce`, p.nonce), @@ -56,6 +58,7 @@ export class StartOIDCLoginViewModel extends ViewModel { this.platform.settingsStorage.setString(`oidc_${p.state}_redirect_uri`, p.redirectUri), this.platform.settingsStorage.setString(`oidc_${p.state}_homeserver`, this._homeserver), this.platform.settingsStorage.setString(`oidc_${p.state}_issuer`, this._issuer), + this.platform.settingsStorage.setString(`oidc_${p.state}_client_id`, clientId), ]); const link = await this._api.authorizationEndpoint(p); diff --git a/src/domain/navigation/URLRouter.js b/src/domain/navigation/URLRouter.js index 00614951..5f521040 100644 --- a/src/domain/navigation/URLRouter.js +++ b/src/domain/navigation/URLRouter.js @@ -129,6 +129,10 @@ export class URLRouter { return window.location.origin; } + absoluteUrlForAsset(asset) { + return (new URL('/assets/' + asset, window.location.origin)).toString(); + } + normalizeUrl() { // Remove any queryParameters from the URL // Gets rid of the loginToken after SSO diff --git a/src/matrix/Client.js b/src/matrix/Client.js index d0530d1f..f76f39c6 100644 --- a/src/matrix/Client.js +++ b/src/matrix/Client.js @@ -132,7 +132,6 @@ export class Client { try { const oidcApi = new OidcApi({ issuer, - clientId: "hydrogen-web", request: this._platform.request, encoding: this._platform.encoding, crypto: this._platform.crypto, @@ -202,6 +201,7 @@ export class Client { if (loginData.oidc_issuer) { sessionInfo.oidcIssuer = loginData.oidc_issuer; + sessionInfo.oidcClientId = loginData.oidc_client_id; } log.set("id", sessionId); @@ -263,7 +263,7 @@ export class Client { if (sessionInfo.oidcIssuer) { const oidcApi = new OidcApi({ issuer: sessionInfo.oidcIssuer, - clientId: "hydrogen-web", + clientId: sessionInfo.oidcClientId, request: this._platform.request, encoding: this._platform.encoding, crypto: this._platform.crypto, diff --git a/src/matrix/login/OIDCLoginMethod.ts b/src/matrix/login/OIDCLoginMethod.ts index 1e834b64..b25689aa 100644 --- a/src/matrix/login/OIDCLoginMethod.ts +++ b/src/matrix/login/OIDCLoginMethod.ts @@ -66,7 +66,8 @@ export class OIDCLoginMethod implements ILoginMethod { }).response(); const oidc_issuer = this._oidcApi.issuer; + const oidc_client_id = await this._oidcApi.clientId(); - return { oidc_issuer, access_token, refresh_token, expires_in, user_id, device_id }; + return { oidc_issuer, oidc_client_id, access_token, refresh_token, expires_in, user_id, device_id }; } } diff --git a/src/matrix/net/OidcApi.ts b/src/matrix/net/OidcApi.ts index 023ba485..319d122f 100644 --- a/src/matrix/net/OidcApi.ts +++ b/src/matrix/net/OidcApi.ts @@ -15,6 +15,7 @@ limitations under the License. */ import type {RequestFunction} from "../../platform/types/types"; +import type {URLRouter} from "../../domain/navigation/URLRouter.js"; const WELL_KNOWN = ".well-known/openid-configuration"; @@ -54,18 +55,35 @@ function assert(condition: any, message: string): asserts condition { export class OidcApi { _issuer: string; - _clientId: string; _requestFn: RequestFunction; _encoding: any; _crypto: any; + _urlCreator: URLRouter; _metadataPromise: Promise; + _registrationPromise: Promise; - constructor({ issuer, clientId, request, encoding, crypto }) { + constructor({ issuer, request, encoding, crypto, urlCreator, clientId }) { this._issuer = issuer; - this._clientId = clientId; this._requestFn = request; this._encoding = encoding; this._crypto = crypto; + this._urlCreator = urlCreator; + + if (clientId) { + this._registrationPromise = Promise.resolve({ client_id: clientId }); + } + } + + get clientMetadata() { + return { + client_name: "Hydrogen Web", + logo_uri: this._urlCreator.absoluteUrlForAsset("icon.png"), + response_types: ["code"], + grant_types: ["authorization_code", "refresh_token"], + redirect_uris: [this._urlCreator.createOIDCRedirectURL()], + id_token_signed_response_alg: "RS256", + token_endpoint_auth_method: "none", + }; } get metadataUrl() { @@ -76,11 +94,35 @@ export class OidcApi { return this._issuer; } - get redirectUri() { - return window.location.origin; + async clientId(): Promise { + return (await this.registration())["client_id"]; } - metadata() { + registration(): Promise { + if (!this._registrationPromise) { + this._registrationPromise = (async () => { + const headers = new Map(); + headers.set("Accept", "application/json"); + headers.set("Content-Type", "application/json"); + const req = this._requestFn(await this.registrationEndpoint(), { + method: "POST", + headers, + format: "json", + body: JSON.stringify(this.clientMetadata), + }); + const res = await req.response(); + if (res.status >= 400) { + throw new Error("failed to register client"); + } + + return res.body; + })(); + } + + return this._registrationPromise; + } + + metadata(): Promise { if (!this._metadataPromise) { this._metadataPromise = (async () => { const headers = new Map(); @@ -105,6 +147,7 @@ export class OidcApi { const m = await this.metadata(); assert(typeof m.authorization_endpoint === "string", "Has an authorization endpoint"); assert(typeof m.token_endpoint === "string", "Has a token endpoint"); + assert(typeof m.registration_endpoint === "string", "Has a registration endpoint"); assert(Array.isArray(m.response_types_supported) && m.response_types_supported.includes("code"), "Supports the code response type"); assert(Array.isArray(m.response_modes_supported) && m.response_modes_supported.includes("fragment"), "Supports the fragment response mode"); assert(Array.isArray(m.grant_types_supported) && m.grant_types_supported.includes("authorization_code"), "Supports the authorization_code grant type"); @@ -126,13 +169,13 @@ export class OidcApi { scope, nonce, codeVerifier, - }: AuthorizationParams) { + }: AuthorizationParams): Promise { const metadata = await this.metadata(); const url = new URL(metadata["authorization_endpoint"]); url.searchParams.append("response_mode", "fragment"); url.searchParams.append("response_type", "code"); url.searchParams.append("redirect_uri", redirectUri); - url.searchParams.append("client_id", this._clientId); + url.searchParams.append("client_id", await this.clientId()); url.searchParams.append("state", state); url.searchParams.append("scope", scope); if (nonce) { @@ -147,11 +190,16 @@ export class OidcApi { return url.toString(); } - async tokenEndpoint() { + async tokenEndpoint(): Promise { const metadata = await this.metadata(); return metadata["token_endpoint"]; } + async registrationEndpoint(): Promise { + const metadata = await this.metadata(); + return metadata["registration_endpoint"]; + } + generateParams({ scope, redirectUri }: { scope: string, redirectUri: string }): AuthorizationParams { return { scope, @@ -169,7 +217,7 @@ export class OidcApi { }: { codeVerifier: string, code: string, redirectUri: string }): Promise { const params = new URLSearchParams(); params.append("grant_type", "authorization_code"); - params.append("client_id", this._clientId); + params.append("client_id", await this.clientId()); params.append("code_verifier", codeVerifier); params.append("redirect_uri", redirectUri); params.append("code", code); @@ -201,7 +249,7 @@ export class OidcApi { }: { refreshToken: string }): Promise { const params = new URLSearchParams(); params.append("grant_type", "refresh_token"); - params.append("client_id", this._clientId); + params.append("client_id", await this.clientId()); params.append("refresh_token", refreshToken); const body = params.toString(); From 897fc54103b8731a5007bd5e95f8049d5f5d4a7b Mon Sep 17 00:00:00 2001 From: Quentin Gliech Date: Fri, 29 Apr 2022 16:30:24 +0200 Subject: [PATCH 052/226] Add client_uri, tos_uri and policy_uri client metadata --- src/domain/navigation/URLRouter.js | 4 ++++ src/matrix/net/OidcApi.ts | 3 +++ 2 files changed, 7 insertions(+) diff --git a/src/domain/navigation/URLRouter.js b/src/domain/navigation/URLRouter.js index 5f521040..6620e8c9 100644 --- a/src/domain/navigation/URLRouter.js +++ b/src/domain/navigation/URLRouter.js @@ -129,6 +129,10 @@ export class URLRouter { return window.location.origin; } + absoluteAppUrl() { + return window.location.origin; + } + absoluteUrlForAsset(asset) { return (new URL('/assets/' + asset, window.location.origin)).toString(); } diff --git a/src/matrix/net/OidcApi.ts b/src/matrix/net/OidcApi.ts index 319d122f..57168622 100644 --- a/src/matrix/net/OidcApi.ts +++ b/src/matrix/net/OidcApi.ts @@ -78,6 +78,9 @@ export class OidcApi { return { client_name: "Hydrogen Web", logo_uri: this._urlCreator.absoluteUrlForAsset("icon.png"), + client_uri: this._urlCreator.absoluteAppUrl(), + tos_uri: "https://element.io/terms-of-service", + policy_uri: "https://element.io/privacy", response_types: ["code"], grant_types: ["authorization_code", "refresh_token"], redirect_uris: [this._urlCreator.createOIDCRedirectURL()], From 12d1760582e157bd423ba0618e20ad52824d167f Mon Sep 17 00:00:00 2001 From: Quentin Gliech Date: Mon, 4 Jul 2022 18:44:31 +0200 Subject: [PATCH 053/226] Make hydrogen generate the device scope --- src/domain/login/StartOIDCLoginViewModel.js | 5 +++-- src/matrix/net/OidcApi.ts | 5 +++++ 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/src/domain/login/StartOIDCLoginViewModel.js b/src/domain/login/StartOIDCLoginViewModel.js index e70a7487..d6424f74 100644 --- a/src/domain/login/StartOIDCLoginViewModel.js +++ b/src/domain/login/StartOIDCLoginViewModel.js @@ -42,12 +42,13 @@ export class StartOIDCLoginViewModel extends ViewModel { async discover() { // Ask for the metadata once so it gets discovered and cached await this._api.metadata() - await this._api.ensureRegistered(); + await this._api.registration(); } async startOIDCLogin() { + const deviceScope = this._api.generateDeviceScope(); const p = this._api.generateParams({ - scope: "openid", + scope: `openid ${deviceScope}`, redirectUri: this.urlCreator.createOIDCRedirectURL(), }); const clientId = await this._api.clientId(); diff --git a/src/matrix/net/OidcApi.ts b/src/matrix/net/OidcApi.ts index 57168622..b8d459b3 100644 --- a/src/matrix/net/OidcApi.ts +++ b/src/matrix/net/OidcApi.ts @@ -203,6 +203,11 @@ export class OidcApi { return metadata["registration_endpoint"]; } + generateDeviceScope(): String { + const deviceId = randomString(10); + return `urn:matrix:device:${deviceId}`; + } + generateParams({ scope, redirectUri }: { scope: string, redirectUri: string }): AuthorizationParams { return { scope, From 8ec0bd7295a85ea8de66d23d1be7e40d9be03a3c Mon Sep 17 00:00:00 2001 From: RMidhunSuresh Date: Tue, 5 Jul 2022 17:55:51 +0530 Subject: [PATCH 054/226] Check if lightbox url is available --- src/platform/web/ui/session/room/timeline/ImageView.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/platform/web/ui/session/room/timeline/ImageView.js b/src/platform/web/ui/session/room/timeline/ImageView.js index 1668b09c..19591606 100644 --- a/src/platform/web/ui/session/room/timeline/ImageView.js +++ b/src/platform/web/ui/session/room/timeline/ImageView.js @@ -24,6 +24,6 @@ export class ImageView extends BaseMediaView { title: vm => vm.label, style: `max-width: ${vm.width}px; max-height: ${vm.height}px;` }); - return vm.isPending ? img : t.a({href: vm.lightboxUrl}, img); + return vm.isPending || !vm.lightboxUrl ? img : t.a({href: vm.lightboxUrl}, img); } } From 3bf6a46a3946b6c5e21dcefdfaeeef106cc22704 Mon Sep 17 00:00:00 2001 From: Bruno Windels <274386+bwindels@users.noreply.github.com> Date: Tue, 5 Jul 2022 16:02:47 +0200 Subject: [PATCH 055/226] release sdk 0.0.14 --- scripts/sdk/base-manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/sdk/base-manifest.json b/scripts/sdk/base-manifest.json index a62888a7..e9887337 100644 --- a/scripts/sdk/base-manifest.json +++ b/scripts/sdk/base-manifest.json @@ -1,7 +1,7 @@ { "name": "hydrogen-view-sdk", "description": "Embeddable matrix client library, including view components", - "version": "0.0.13", + "version": "0.0.14", "main": "./lib-build/hydrogen.cjs.js", "exports": { ".": { From dd82469ab4aa4db0f8a3f24b7c769e7eb2a621da Mon Sep 17 00:00:00 2001 From: RMidhunSuresh Date: Tue, 5 Jul 2022 20:07:48 +0530 Subject: [PATCH 056/226] Don't assume object is available --- scripts/postcss/css-compile-variables.js | 9 ++++++++- scripts/postcss/css-url-to-variables.js | 8 +++++++- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/scripts/postcss/css-compile-variables.js b/scripts/postcss/css-compile-variables.js index 63aef97f..80aedf60 100644 --- a/scripts/postcss/css-compile-variables.js +++ b/scripts/postcss/css-compile-variables.js @@ -112,7 +112,14 @@ function populateMapWithDerivedVariables(map, cssFileLocation, {resolvedMap, ali ...([...resolvedMap.keys()].filter(v => !aliasMap.has(v))), ...([...aliasMap.entries()].map(([alias, variable]) => `${alias}=${variable}`)) ]; - map.set(location, { "derived-variables": derivedVariables }); + const sharedObject = map.get(location); + const output = { "derived-variables": derivedVariables }; + if (sharedObject) { + Object.assign(sharedObject, output); + } + else { + map.set(location, output); + } } /** diff --git a/scripts/postcss/css-url-to-variables.js b/scripts/postcss/css-url-to-variables.js index 82ddae82..f9588434 100644 --- a/scripts/postcss/css-url-to-variables.js +++ b/scripts/postcss/css-url-to-variables.js @@ -55,7 +55,13 @@ function addResolvedVariablesToRootSelector(root, { Rule, Declaration }, urlVari function populateMapWithIcons(map, cssFileLocation, urlVariables) { const location = cssFileLocation.match(/(.+)\/.+\.css/)?.[1]; const sharedObject = map.get(location); - sharedObject["icon"] = Object.fromEntries(urlVariables); + const output = {"icon": Object.fromEntries(urlVariables)}; + if (sharedObject) { + Object.assign(sharedObject, output); + } + else { + map.set(location, output); + } } function *createCounter() { From a85d2c96d67c72d52adc94c43b3eb798875fc830 Mon Sep 17 00:00:00 2001 From: Will Hunt Date: Wed, 6 Jul 2022 10:06:00 +0100 Subject: [PATCH 057/226] Log the error when we can't get storage access This is quite useful when debugging why a session isn't working properly. --- src/matrix/storage/idb/StorageFactory.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/matrix/storage/idb/StorageFactory.ts b/src/matrix/storage/idb/StorageFactory.ts index 5cb1b6e5..1f64baf3 100644 --- a/src/matrix/storage/idb/StorageFactory.ts +++ b/src/matrix/storage/idb/StorageFactory.ts @@ -42,6 +42,7 @@ async function requestPersistedStorage(): Promise { await glob.document.requestStorageAccess(); return true; } catch (err) { + console.warn("requestStorageAccess threw an error:", err); return false; } } else { From 204948db648fec5621cfe77d2dd72b568226f2a7 Mon Sep 17 00:00:00 2001 From: Isaiah Becker-Mayer Date: Wed, 29 Jun 2022 22:18:29 -0400 Subject: [PATCH 058/226] changing filename to ts --- src/domain/login/{LoginViewModel.js => LoginViewModel.ts} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename src/domain/login/{LoginViewModel.js => LoginViewModel.ts} (100%) diff --git a/src/domain/login/LoginViewModel.js b/src/domain/login/LoginViewModel.ts similarity index 100% rename from src/domain/login/LoginViewModel.js rename to src/domain/login/LoginViewModel.ts From efd9f70e928cb2374c6c734ac9002fb77daec628 Mon Sep 17 00:00:00 2001 From: RMidhunSuresh Date: Thu, 7 Jul 2022 16:39:45 +0530 Subject: [PATCH 059/226] WIP --- src/domain/session/room/RoomViewModel.js | 37 ++++++++++++++++++- .../web/ui/css/themes/element/theme.css | 4 +- ...rchivedView.js => DisabledComposerView.js} | 4 +- src/platform/web/ui/session/room/RoomView.js | 19 ++++++---- 4 files changed, 50 insertions(+), 14 deletions(-) rename src/platform/web/ui/session/room/{RoomArchivedView.js => DisabledComposerView.js} (82%) diff --git a/src/domain/session/room/RoomViewModel.js b/src/domain/session/room/RoomViewModel.js index 66042ae5..1cf15969 100644 --- a/src/domain/session/room/RoomViewModel.js +++ b/src/domain/session/room/RoomViewModel.js @@ -37,9 +37,9 @@ export class RoomViewModel extends ViewModel { this._sendError = null; this._composerVM = null; if (room.isArchived) { - this._composerVM = new ArchivedViewModel(this.childOptions({archivedRoom: room})); + this._composerVM = this.track(new ArchivedViewModel(this.childOptions({archivedRoom: room}))); } else { - this._composerVM = new ComposerViewModel(this); + this._watchPowerLevelChange(); } this._clearUnreadTimout = null; this._closeUrl = this.urlCreator.urlUntilSegment("session"); @@ -67,6 +67,29 @@ export class RoomViewModel extends ViewModel { this._clearUnreadAfterDelay(); } + async _watchPowerLevelChange() { + const powerLevelObservable = await this._room.observePowerLevels(); + let oldCanSendMessage = powerLevelObservable.get().canSendType("m.room.message"); + const recreateComposer = newCanSendMessage => { + this._composerVM = this.disposeTracked(this._composerVM); + if (newCanSendMessage) { + this._composerVM = this.track(new ComposerViewModel(this)); + } + else { + this._composerVM = this.track(new LowerPowerLevelViewModel()); + } + this.emitChange("powerLevelObservable") + }; + this.track(powerLevelObservable.subscribe(newPowerLevel => { + const newCanSendMessage = newPowerLevel.canSendType("m.room.message"); + if (oldCanSendMessage !== newCanSendMessage) { + recreateComposer(newCanSendMessage); + oldCanSendMessage = newCanSendMessage; + } + })); + recreateComposer(oldCanSendMessage); + } + async _clearUnreadAfterDelay() { if (this._room.isArchived || this._clearUnreadTimout) { return; @@ -365,3 +388,13 @@ class ArchivedViewModel extends ViewModel { return "archived"; } } + +class LowerPowerLevelViewModel extends ViewModel { + get description() { + return this.i18n`You do not have the powerlevel necessary to send messages`; + } + + get kind() { + return "low-powerlevel"; + } +} diff --git a/src/platform/web/ui/css/themes/element/theme.css b/src/platform/web/ui/css/themes/element/theme.css index 113ea254..632d5bc5 100644 --- a/src/platform/web/ui/css/themes/element/theme.css +++ b/src/platform/web/ui/css/themes/element/theme.css @@ -894,12 +894,12 @@ button.link { width: 100%; } -.RoomArchivedView { +.DisabledComposerView { padding: 12px; background-color: var(--background-color-secondary); } -.RoomArchivedView h3 { +.DisabledComposerView h3 { margin: 0; } diff --git a/src/platform/web/ui/session/room/RoomArchivedView.js b/src/platform/web/ui/session/room/DisabledComposerView.js similarity index 82% rename from src/platform/web/ui/session/room/RoomArchivedView.js rename to src/platform/web/ui/session/room/DisabledComposerView.js index 1db1c2d2..caa8eeb9 100644 --- a/src/platform/web/ui/session/room/RoomArchivedView.js +++ b/src/platform/web/ui/session/room/DisabledComposerView.js @@ -16,8 +16,8 @@ limitations under the License. import {TemplateView} from "../../general/TemplateView"; -export class RoomArchivedView extends TemplateView { +export class DisabledComposerView extends TemplateView { render(t) { - return t.div({className: "RoomArchivedView"}, t.h3(vm => vm.description)); + return t.div({className: "DisabledComposerView"}, t.h3(vm => vm.description)); } } diff --git a/src/platform/web/ui/session/room/RoomView.js b/src/platform/web/ui/session/room/RoomView.js index 76e26eab..5923355c 100644 --- a/src/platform/web/ui/session/room/RoomView.js +++ b/src/platform/web/ui/session/room/RoomView.js @@ -21,7 +21,7 @@ import {Menu} from "../../general/Menu.js"; import {TimelineView} from "./TimelineView"; import {TimelineLoadingView} from "./TimelineLoadingView.js"; import {MessageComposer} from "./MessageComposer.js"; -import {RoomArchivedView} from "./RoomArchivedView.js"; +import {DisabledComposerView} from "./DisabledComposerView.js"; import {AvatarView} from "../../AvatarView.js"; export class RoomView extends TemplateView { @@ -32,12 +32,6 @@ export class RoomView extends TemplateView { } render(t, vm) { - let bottomView; - if (vm.composerViewModel.kind === "composer") { - bottomView = new MessageComposer(vm.composerViewModel, this._viewClassForTile); - } else if (vm.composerViewModel.kind === "archived") { - bottomView = new RoomArchivedView(vm.composerViewModel); - } return t.main({className: "RoomView middle"}, [ t.div({className: "RoomHeader middle-header"}, [ t.a({className: "button-utility close-middle", href: vm.closeUrl, title: vm.i18n`Close room`}), @@ -58,7 +52,16 @@ export class RoomView extends TemplateView { new TimelineView(timelineViewModel, this._viewClassForTile) : new TimelineLoadingView(vm); // vm is just needed for i18n }), - t.view(bottomView), + t.mapView(vm => vm.composerViewModel, + composerViewModel => { + switch (composerViewModel?.kind) { + case "composer": + return new MessageComposer(vm.composerViewModel, this._viewClassForTile); + case "archived": + case "low-powerlevel": + return new DisabledComposerView(vm.composerViewModel); + } + }), ]) ]); } From 3941b7e3f09a213ea03970d1512255fbe0ad2801 Mon Sep 17 00:00:00 2001 From: RMidhunSuresh Date: Thu, 7 Jul 2022 16:45:18 +0530 Subject: [PATCH 060/226] Rename method --- src/domain/session/room/RoomViewModel.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/domain/session/room/RoomViewModel.js b/src/domain/session/room/RoomViewModel.js index 1cf15969..fac0ed9d 100644 --- a/src/domain/session/room/RoomViewModel.js +++ b/src/domain/session/room/RoomViewModel.js @@ -39,7 +39,7 @@ export class RoomViewModel extends ViewModel { if (room.isArchived) { this._composerVM = this.track(new ArchivedViewModel(this.childOptions({archivedRoom: room}))); } else { - this._watchPowerLevelChange(); + this._recreateComposerOnPowerLevelChange(); } this._clearUnreadTimout = null; this._closeUrl = this.urlCreator.urlUntilSegment("session"); @@ -67,7 +67,7 @@ export class RoomViewModel extends ViewModel { this._clearUnreadAfterDelay(); } - async _watchPowerLevelChange() { + async _recreateComposerOnPowerLevelChange() { const powerLevelObservable = await this._room.observePowerLevels(); let oldCanSendMessage = powerLevelObservable.get().canSendType("m.room.message"); const recreateComposer = newCanSendMessage => { From cd9e00b847731d3acb37ab53fb00bff9bd2e6ccb Mon Sep 17 00:00:00 2001 From: RMidhunSuresh Date: Thu, 7 Jul 2022 17:17:05 +0530 Subject: [PATCH 061/226] Support power_level_content_override --- src/matrix/room/RoomBeingCreated.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/matrix/room/RoomBeingCreated.ts b/src/matrix/room/RoomBeingCreated.ts index 78202203..b2c9dafb 100644 --- a/src/matrix/room/RoomBeingCreated.ts +++ b/src/matrix/room/RoomBeingCreated.ts @@ -37,7 +37,8 @@ type CreateRoomPayload = { invite?: string[]; room_alias_name?: string; creation_content?: {"m.federate": boolean}; - initial_state: {type: string; state_key: string; content: Record}[] + initial_state: { type: string; state_key: string; content: Record }[]; + power_level_content_override?: Record; } type ImageInfo = { @@ -62,6 +63,7 @@ type Options = { invites?: string[]; avatar?: Avatar; alias?: string; + powerLevelContentOverride?: Record; } function defaultE2EEStatusForType(type: RoomType): boolean { @@ -151,6 +153,9 @@ export class RoomBeingCreated extends EventEmitter<{change: never}> { "m.federate": false }; } + if (this.options.powerLevelContentOverride) { + createOptions.power_level_content_override = this.options.powerLevelContentOverride; + } if (this.isEncrypted) { createOptions.initial_state.push(createRoomEncryptionEvent()); } From d292e1f5adc8ebdda19cd9c5db93b5148ff5bf00 Mon Sep 17 00:00:00 2001 From: RMidhunSuresh Date: Thu, 7 Jul 2022 17:23:23 +0530 Subject: [PATCH 062/226] Extract into function --- src/domain/session/room/RoomViewModel.js | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/domain/session/room/RoomViewModel.js b/src/domain/session/room/RoomViewModel.js index fac0ed9d..74dda426 100644 --- a/src/domain/session/room/RoomViewModel.js +++ b/src/domain/session/room/RoomViewModel.js @@ -69,7 +69,8 @@ export class RoomViewModel extends ViewModel { async _recreateComposerOnPowerLevelChange() { const powerLevelObservable = await this._room.observePowerLevels(); - let oldCanSendMessage = powerLevelObservable.get().canSendType("m.room.message"); + const canSendMessage = () => powerLevelObservable.get().canSendType("m.room.message"); + let oldCanSendMessage = canSendMessage(); const recreateComposer = newCanSendMessage => { this._composerVM = this.disposeTracked(this._composerVM); if (newCanSendMessage) { @@ -80,8 +81,8 @@ export class RoomViewModel extends ViewModel { } this.emitChange("powerLevelObservable") }; - this.track(powerLevelObservable.subscribe(newPowerLevel => { - const newCanSendMessage = newPowerLevel.canSendType("m.room.message"); + this.track(powerLevelObservable.subscribe(() => { + const newCanSendMessage = canSendMessage(); if (oldCanSendMessage !== newCanSendMessage) { recreateComposer(newCanSendMessage); oldCanSendMessage = newCanSendMessage; From 0bdbb96036699bbbe1810aa0e010a9fbc16bd4e5 Mon Sep 17 00:00:00 2001 From: RMidhunSuresh Date: Thu, 7 Jul 2022 17:26:43 +0530 Subject: [PATCH 063/226] Use same kind --- src/domain/session/room/RoomViewModel.js | 4 ++-- src/platform/web/ui/session/room/RoomView.js | 3 +-- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/src/domain/session/room/RoomViewModel.js b/src/domain/session/room/RoomViewModel.js index 74dda426..60a01d16 100644 --- a/src/domain/session/room/RoomViewModel.js +++ b/src/domain/session/room/RoomViewModel.js @@ -386,7 +386,7 @@ class ArchivedViewModel extends ViewModel { } get kind() { - return "archived"; + return "disabled"; } } @@ -396,6 +396,6 @@ class LowerPowerLevelViewModel extends ViewModel { } get kind() { - return "low-powerlevel"; + return "disabled"; } } diff --git a/src/platform/web/ui/session/room/RoomView.js b/src/platform/web/ui/session/room/RoomView.js index 5923355c..d36466dd 100644 --- a/src/platform/web/ui/session/room/RoomView.js +++ b/src/platform/web/ui/session/room/RoomView.js @@ -57,8 +57,7 @@ export class RoomView extends TemplateView { switch (composerViewModel?.kind) { case "composer": return new MessageComposer(vm.composerViewModel, this._viewClassForTile); - case "archived": - case "low-powerlevel": + case "disabled": return new DisabledComposerView(vm.composerViewModel); } }), From f073f40e3106e60882b44c2bfe61e6dd129e42f0 Mon Sep 17 00:00:00 2001 From: RMidhunSuresh Date: Thu, 7 Jul 2022 18:16:33 +0530 Subject: [PATCH 064/226] Fix error --- src/domain/session/room/RoomViewModel.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/domain/session/room/RoomViewModel.js b/src/domain/session/room/RoomViewModel.js index 60a01d16..94c78286 100644 --- a/src/domain/session/room/RoomViewModel.js +++ b/src/domain/session/room/RoomViewModel.js @@ -77,7 +77,7 @@ export class RoomViewModel extends ViewModel { this._composerVM = this.track(new ComposerViewModel(this)); } else { - this._composerVM = this.track(new LowerPowerLevelViewModel()); + this._composerVM = this.track(new LowerPowerLevelViewModel(this.childOptions())); } this.emitChange("powerLevelObservable") }; From 7b9e681d558f39fd77519bd7a99f8067cacd4ed4 Mon Sep 17 00:00:00 2001 From: Bruno Windels <274386+bwindels@users.noreply.github.com> Date: Thu, 7 Jul 2022 15:25:17 +0200 Subject: [PATCH 065/226] sdk v0.0.15 --- scripts/sdk/base-manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/sdk/base-manifest.json b/scripts/sdk/base-manifest.json index e9887337..25342baa 100644 --- a/scripts/sdk/base-manifest.json +++ b/scripts/sdk/base-manifest.json @@ -1,7 +1,7 @@ { "name": "hydrogen-view-sdk", "description": "Embeddable matrix client library, including view components", - "version": "0.0.14", + "version": "0.0.15", "main": "./lib-build/hydrogen.cjs.js", "exports": { ".": { From 969bdbbf30f1335210068ddb068acc58bc8358db Mon Sep 17 00:00:00 2001 From: Hugh Nimmo-Smith Date: Fri, 8 Jul 2022 15:35:55 +0100 Subject: [PATCH 066/226] Use unstable prefix for MSC2965 issuer discovery --- src/matrix/well-known.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/matrix/well-known.js b/src/matrix/well-known.js index 6e3bedbf..10e78f2c 100644 --- a/src/matrix/well-known.js +++ b/src/matrix/well-known.js @@ -50,7 +50,7 @@ export async function lookupHomeserver(homeserver, request) { homeserver = normalizeHomeserver(wellKnownHomeserver); } - const wellKnownIssuer = body["m.authentication"]?.["issuer"]; + const wellKnownIssuer = body["org.matrix.msc2965.authentication"]?.["issuer"]; if (typeof wellKnownIssuer === "string") { issuer = wellKnownIssuer; } From 1366a02c7e2c62f2e22a1aabfb348074fd6f7f4a Mon Sep 17 00:00:00 2001 From: Hugh Nimmo-Smith Date: Fri, 8 Jul 2022 15:50:49 +0100 Subject: [PATCH 067/226] Rename OIDC login button to Continue --- src/platform/web/ui/login/LoginView.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/platform/web/ui/login/LoginView.js b/src/platform/web/ui/login/LoginView.js index 116c82cd..e1783183 100644 --- a/src/platform/web/ui/login/LoginView.js +++ b/src/platform/web/ui/login/LoginView.js @@ -86,7 +86,7 @@ class StartOIDCLoginView extends TemplateView { type: "button", onClick: () => vm.startOIDCLogin(), disabled: vm => vm.isBusy - }, vm.i18n`Log in via OIDC`) + }, vm.i18n`Continue`) ); } } From 6718198d9cc9e5aac8d9e3c0ca5534440b53bdcd Mon Sep 17 00:00:00 2001 From: RMidhunSuresh Date: Mon, 11 Jul 2022 12:40:24 +0530 Subject: [PATCH 068/226] Continue with other items if this throws --- src/platform/web/ThemeLoader.ts | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/src/platform/web/ThemeLoader.ts b/src/platform/web/ThemeLoader.ts index ee303b49..89430663 100644 --- a/src/platform/web/ThemeLoader.ts +++ b/src/platform/web/ThemeLoader.ts @@ -76,11 +76,16 @@ export class ThemeLoader { const themeName = manifest.name; let defaultDarkVariant: any = {}, defaultLightVariant: any = {}; for (let [themeId, cssLocation] of Object.entries(builtAssets)) { - /** - * This cssLocation is relative to the location of the manifest file. - * So we first need to resolve it relative to the root of this hydrogen instance. - */ - cssLocation = new URL(cssLocation, new URL(manifestLocation, window.location.origin)).href; + try { + /** + * This cssLocation is relative to the location of the manifest file. + * So we first need to resolve it relative to the root of this hydrogen instance. + */ + cssLocation = new URL(cssLocation, new URL(manifestLocation, window.location.origin)).href; + } + catch { + continue; + } const variant = themeId.match(/.+-(.+)/)?.[1]; const { name: variantName, default: isDefault, dark } = manifest.values.variants[variant!]; const themeDisplayName = `${themeName} ${variantName}`; From 88808b0b069bc27276e6fcd9ddcaf513e9d35ed0 Mon Sep 17 00:00:00 2001 From: RMidhunSuresh Date: Tue, 19 Jul 2022 15:50:01 +0530 Subject: [PATCH 069/226] Fix bug preventing yarn start --- .../build-plugins/rollup-plugin-build-themes.js | 17 ++++++----------- 1 file changed, 6 insertions(+), 11 deletions(-) diff --git a/scripts/build-plugins/rollup-plugin-build-themes.js b/scripts/build-plugins/rollup-plugin-build-themes.js index 6b8fdae0..438203b7 100644 --- a/scripts/build-plugins/rollup-plugin-build-themes.js +++ b/scripts/build-plugins/rollup-plugin-build-themes.js @@ -136,7 +136,6 @@ module.exports = function buildThemes(options) { }, async buildStart() { - if (isDevelopment) { return; } const { themeConfig } = options; for (const location of themeConfig.themes) { manifest = require(`${location}/manifest.json`); @@ -157,18 +156,14 @@ module.exports = function buildThemes(options) { } } // emit the css as built theme bundle - this.emitFile({ - type: "chunk", - id: `${location}/theme.css?variant=${variant}${details.dark? "&dark=true": ""}`, - fileName, - }); + if (!isDevelopment) { + this.emitFile({ type: "chunk", id: `${location}/theme.css?variant=${variant}${details.dark ? "&dark=true" : ""}`, fileName, }); + } } // emit the css as runtime theme bundle - this.emitFile({ - type: "chunk", - id: `${location}/theme.css?type=runtime`, - fileName: `theme-${themeCollectionId}-runtime.css`, - }); + if (!isDevelopment) { + this.emitFile({ type: "chunk", id: `${location}/theme.css?type=runtime`, fileName: `theme-${themeCollectionId}-runtime.css`, }); + } } }, From d5e24bf6e8513b2768c61cd0f5837ce72c1c1fb2 Mon Sep 17 00:00:00 2001 From: RMidhunSuresh Date: Mon, 11 Jul 2022 12:03:07 +0530 Subject: [PATCH 070/226] Convert color.js to color.mjs --- scripts/postcss/{color.js => color.mjs} | 5 ++--- vite.common-config.js | 2 +- 2 files changed, 3 insertions(+), 4 deletions(-) rename scripts/postcss/{color.js => color.mjs} (91%) diff --git a/scripts/postcss/color.js b/scripts/postcss/color.mjs similarity index 91% rename from scripts/postcss/color.js rename to scripts/postcss/color.mjs index b1ef7073..bd2ea3ea 100644 --- a/scripts/postcss/color.js +++ b/scripts/postcss/color.mjs @@ -13,10 +13,9 @@ 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 {offColor} from 'off-color'; -const offColor = require("off-color").offColor; - -module.exports.derive = function (value, operation, argument, isDark) { +export function derive(value, operation, argument, isDark) { const argumentAsNumber = parseInt(argument); if (isDark) { // For dark themes, invert the operation diff --git a/vite.common-config.js b/vite.common-config.js index 5d65f8e2..7f0e57a8 100644 --- a/vite.common-config.js +++ b/vite.common-config.js @@ -8,8 +8,8 @@ const path = require("path"); const manifest = require("./package.json"); const version = manifest.version; const compiledVariables = new Map(); -const derive = require("./scripts/postcss/color").derive; const replacer = require("./scripts/postcss/svg-colorizer").buildColorizedSVG; +import {derive} from "./scripts/postcss/color.mjs"; const commonOptions = { logLevel: "warn", From 599e519f229031e4966c446e1be097af4a156883 Mon Sep 17 00:00:00 2001 From: RMidhunSuresh Date: Mon, 11 Jul 2022 12:17:33 +0530 Subject: [PATCH 071/226] Convert color code to use es6 module --- .../{svg-colorizer.js => svg-colorizer.mjs} | 18 +++++++++--------- vite.common-config.js | 2 +- 2 files changed, 10 insertions(+), 10 deletions(-) rename scripts/postcss/{svg-colorizer.js => svg-colorizer.mjs} (79%) diff --git a/scripts/postcss/svg-colorizer.js b/scripts/postcss/svg-colorizer.mjs similarity index 79% rename from scripts/postcss/svg-colorizer.js rename to scripts/postcss/svg-colorizer.mjs index 06b7b14b..fbe48b55 100644 --- a/scripts/postcss/svg-colorizer.js +++ b/scripts/postcss/svg-colorizer.mjs @@ -14,12 +14,12 @@ See the License for the specific language governing permissions and limitations under the License. */ -const fs = require("fs"); -const path = require("path"); -const xxhash = require('xxhashjs'); +import {readFileSync, mkdirSync, writeFileSync} from "fs"; +import {resolve} from "path"; +import {h32} from "xxhashjs"; function createHash(content) { - const hasher = new xxhash.h32(0); + const hasher = new h32(0); hasher.update(content); return hasher.digest(); } @@ -30,8 +30,8 @@ function createHash(content) { * @param {string} primaryColor Primary color for the new svg * @param {string} secondaryColor Secondary color for the new svg */ -module.exports.buildColorizedSVG = function (svgLocation, primaryColor, secondaryColor) { - const svgCode = fs.readFileSync(svgLocation, { encoding: "utf8"}); +export function buildColorizedSVG(svgLocation, primaryColor, secondaryColor) { + const svgCode = readFileSync(svgLocation, { encoding: "utf8"}); let coloredSVGCode = svgCode.replaceAll("#ff00ff", primaryColor); coloredSVGCode = coloredSVGCode.replaceAll("#00ffff", secondaryColor); if (svgCode === coloredSVGCode) { @@ -39,9 +39,9 @@ module.exports.buildColorizedSVG = function (svgLocation, primaryColor, secondar } const fileName = svgLocation.match(/.+[/\\](.+\.svg)/)[1]; const outputName = `${fileName.substring(0, fileName.length - 4)}-${createHash(coloredSVGCode)}.svg`; - const outputPath = path.resolve(__dirname, "../../.tmp"); + const outputPath = resolve(__dirname, "../../.tmp"); try { - fs.mkdirSync(outputPath); + mkdirSync(outputPath); } catch (e) { if (e.code !== "EEXIST") { @@ -49,6 +49,6 @@ module.exports.buildColorizedSVG = function (svgLocation, primaryColor, secondar } } const outputFile = `${outputPath}/${outputName}`; - fs.writeFileSync(outputFile, coloredSVGCode); + writeFileSync(outputFile, coloredSVGCode); return outputFile; } diff --git a/vite.common-config.js b/vite.common-config.js index 7f0e57a8..aaea47a9 100644 --- a/vite.common-config.js +++ b/vite.common-config.js @@ -8,7 +8,7 @@ const path = require("path"); const manifest = require("./package.json"); const version = manifest.version; const compiledVariables = new Map(); -const replacer = require("./scripts/postcss/svg-colorizer").buildColorizedSVG; +import {buildColorizedSVG as replacer} from "./scripts/postcss/svg-colorizer.mjs"; import {derive} from "./scripts/postcss/color.mjs"; const commonOptions = { From 8c02541b6971aec5fd15c65fe0048962538c1551 Mon Sep 17 00:00:00 2001 From: RMidhunSuresh Date: Tue, 28 Jun 2022 12:28:19 +0530 Subject: [PATCH 072/226] WIP - 1 --- src/platform/web/ThemeBuilder.ts | 155 +++++++++++++++++++++++++++++++ src/platform/web/ThemeLoader.ts | 40 +++++--- theme.json | 51 ++++++++++ 3 files changed, 233 insertions(+), 13 deletions(-) create mode 100644 src/platform/web/ThemeBuilder.ts create mode 100644 theme.json diff --git a/src/platform/web/ThemeBuilder.ts b/src/platform/web/ThemeBuilder.ts new file mode 100644 index 00000000..313a8183 --- /dev/null +++ b/src/platform/web/ThemeBuilder.ts @@ -0,0 +1,155 @@ +/* +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 type {ThemeInformation} from "./ThemeLoader"; +import {ColorSchemePreference} from "./ThemeLoader"; +import {offColor} from 'off-color'; + +function derive(value, operation, argument, isDark) { + const argumentAsNumber = parseInt(argument); + if (isDark) { + // For dark themes, invert the operation + if (operation === 'darker') { + operation = "lighter"; + } + else if (operation === 'lighter') { + operation = "darker"; + } + } + switch (operation) { + case "darker": { + const newColorString = offColor(value).darken(argumentAsNumber / 100).hex(); + return newColorString; + } + case "lighter": { + const newColorString = offColor(value).lighten(argumentAsNumber / 100).hex(); + return newColorString; + } + } +} + +export class ThemeBuilder { + // todo: replace any with manifest type when PR is merged + private _idToManifest: Map; + private _themeMapping: Record = {}; + private _themeToVariables: Record = {}; + private _preferredColorScheme?: ColorSchemePreference; + + constructor(manifestMap: Map, preferredColorScheme?: ColorSchemePreference) { + this._idToManifest = manifestMap; + this._preferredColorScheme = preferredColorScheme; + } + + populateDerivedTheme(manifest) { + const { manifest: baseManifest, location } = this._idToManifest.get(manifest.extends); + const runtimeCssLocation = baseManifest.source?.["runtime-asset"]; + const cssLocation = new URL(runtimeCssLocation, new URL(location, window.location.origin)).href; + const derivedVariables = baseManifest.source?.["derived-variables"]; + const themeName = manifest.name; + let defaultDarkVariant: any = {}, defaultLightVariant: any = {}; + for (const [variant, variantDetails] of Object.entries(manifest.values.variants) as [string, any][]) { + const themeId = `${manifest.id}-${variant}`; + const { name: variantName, default: isDefault, dark, variables } = variantDetails; + const resolvedVariables = this.deriveVariables(variables, derivedVariables, dark); + console.log("resolved", resolvedVariables); + Object.assign(variables, resolvedVariables); + const themeDisplayName = `${themeName} ${variantName}`; + if (isDefault) { + /** + * This is a default variant! + * We'll add these to the themeMapping (separately) keyed with just the + * theme-name (i.e "Element" instead of "Element Dark"). + * We need to be able to distinguish them from other variants! + * + * This allows us to render radio-buttons with "dark" and + * "light" options. + */ + const defaultVariant = dark ? defaultDarkVariant : defaultLightVariant; + defaultVariant.variantName = variantName; + defaultVariant.id = themeId; + defaultVariant.cssLocation = cssLocation; + defaultVariant.variables = variables; + continue; + } + // Non-default variants are keyed in themeMapping with "theme_name variant_name" + // eg: "Element Dark" + this._themeMapping[themeDisplayName] = { + cssLocation, + id: themeId, + variables: variables, + }; + } + if (defaultDarkVariant.id && defaultLightVariant.id) { + /** + * As mentioned above, if there's both a default dark and a default light variant, + * add them to themeMapping separately. + */ + const defaultVariant = this._preferredColorScheme === ColorSchemePreference.Dark ? defaultDarkVariant : defaultLightVariant; + this._themeMapping[themeName] = { dark: defaultDarkVariant, light: defaultLightVariant, default: defaultVariant }; + } + else { + /** + * If only one default variant is found (i.e only dark default or light default but not both), + * treat it like any other variant. + */ + const variant = defaultDarkVariant.id ? defaultDarkVariant : defaultLightVariant; + this._themeMapping[`${themeName} ${variant.variantName}`] = { id: variant.id, cssLocation: variant.cssLocation }; + } + } + + get themeMapping() { + return this._themeMapping; + } + + injectCSSVariables(variables: Record) { + const root = document.documentElement; + for (const [variable, value] of Object.entries(variables)) { + root.style.setProperty(`--${variable}`, value); + } + } + + removeCSSVariables(variables: string[]) { + const root = document.documentElement; + for (const variable of variables) { + root.style.removeProperty(`--${variable}`); + } + } + + deriveVariables(variables: Record, derivedVariables: string[], isDark: boolean) { + const aliases: any = {}; + const resolvedVariables: any = {}; + const RE_VARIABLE_VALUE = /(.+)--(.+)-(.+)/; + for (const variable of derivedVariables) { + // If this is an alias, store it for processing later + const [alias, value] = variable.split("="); + if (value) { + aliases[alias] = value; + continue; + } + // Resolve derived variables + const matches = variable.match(RE_VARIABLE_VALUE); + if (matches) { + const [, baseVariable, operation, argument] = matches; + const value = variables[baseVariable]; + const resolvedValue = derive(value, operation, argument, isDark); + resolvedVariables[variable] = resolvedValue; + } + } + for (const [alias, variable] of Object.entries(aliases) as any) { + resolvedVariables[alias] = variables[variable] ?? resolvedVariables[variable]; + } + return resolvedVariables; + } +} diff --git a/src/platform/web/ThemeLoader.ts b/src/platform/web/ThemeLoader.ts index 89430663..9806dd10 100644 --- a/src/platform/web/ThemeLoader.ts +++ b/src/platform/web/ThemeLoader.ts @@ -14,33 +14,29 @@ See the License for the specific language governing permissions and limitations under the License. */ -import type {ILogItem} from "../../logging/types.js"; +import type {ILogItem} from "../../logging/types"; import type {Platform} from "./Platform.js"; +import {ThemeBuilder} from "./ThemeBuilder"; type NormalVariant = { id: string; cssLocation: string; + variables?: any; }; type DefaultVariant = { - dark: { - id: string; - cssLocation: string; + dark: NormalVariant & { variantName: string; }; - light: { - id: string; - cssLocation: string; + light: NormalVariant & { variantName: string; }; - default: { - id: string; - cssLocation: string; + default: NormalVariant & { variantName: string; }; } -type ThemeInformation = NormalVariant | DefaultVariant; +export type ThemeInformation = NormalVariant | DefaultVariant; export enum ColorSchemePreference { Dark, @@ -50,18 +46,31 @@ export enum ColorSchemePreference { export class ThemeLoader { private _platform: Platform; private _themeMapping: Record; + private _themeBuilder: ThemeBuilder; constructor(platform: Platform) { this._platform = platform; } async init(manifestLocations: string[], log?: ILogItem): Promise { + const idToManifest = new Map(); await this._platform.logger.wrapOrRun(log, "ThemeLoader.init", async (log) => { this._themeMapping = {}; const results = await Promise.all( manifestLocations.map( location => this._platform.request(location, { method: "GET", format: "json", cache: true, }).response()) ); - results.forEach(({ body }, i) => this._populateThemeMap(body, manifestLocations[i], log)); + results.forEach(({ body }, i) => idToManifest.set(body.id, { manifest: body, location: manifestLocations[i] })); + this._themeBuilder = new ThemeBuilder(idToManifest, this.preferredColorScheme); + results.forEach(({ body }, i) => { + if (body.extends) { + this._themeBuilder.populateDerivedTheme(body); + } + else { + this._populateThemeMap(body, manifestLocations[i], log); + } + }); + Object.assign(this._themeMapping, this._themeBuilder.themeMapping); + console.log("derived theme mapping", this._themeBuilder.themeMapping); }); } @@ -144,18 +153,23 @@ export class ThemeLoader { setTheme(themeName: string, themeVariant?: "light" | "dark" | "default", log?: ILogItem) { this._platform.logger.wrapOrRun(log, { l: "change theme", name: themeName, variant: themeVariant }, () => { - let cssLocation: string; + let cssLocation: string, variables: Record; let themeDetails = this._themeMapping[themeName]; if ("id" in themeDetails) { cssLocation = themeDetails.cssLocation; + variables = themeDetails.variables; } else { if (!themeVariant) { throw new Error("themeVariant is undefined!"); } cssLocation = themeDetails[themeVariant].cssLocation; + variables = themeDetails[themeVariant].variables; } this._platform.replaceStylesheet(cssLocation); + if (variables) { + this._themeBuilder.injectCSSVariables(variables); + } this._platform.settingsStorage.setString("theme-name", themeName); if (themeVariant) { this._platform.settingsStorage.setString("theme-variant", themeVariant); diff --git a/theme.json b/theme.json new file mode 100644 index 00000000..aee95368 --- /dev/null +++ b/theme.json @@ -0,0 +1,51 @@ +{ + "name": "Customer", + "extends": "element", + "id": "customer", + "values": { + "variants": { + "dark": { + "dark": true, + "default": true, + "name": "Dark", + "variables": { + "background-color-primary": "#21262b", + "background-color-secondary": "#2D3239", + "text-color": "#fff", + "accent-color": "#F03F5B", + "error-color": "#FF4B55", + "fixed-white": "#fff", + "room-badge": "#61708b", + "link-color": "#238cf5" + } + }, + "light": { + "default": true, + "name": "Dark", + "variables": { + "background-color-primary": "#21262b", + "background-color-secondary": "#2D3239", + "text-color": "#fff", + "accent-color": "#F03F5B", + "error-color": "#FF4B55", + "fixed-white": "#fff", + "room-badge": "#61708b", + "link-color": "#238cf5" + } + }, + "red": { + "name": "Red", + "variables": { + "background-color-primary": "#1F1F1F", + "background-color-secondary": "#2B243E", + "text-color": "#fff", + "accent-color": "#F23041", + "error-color": "#FF4B55", + "fixed-white": "#fff", + "room-badge": "#F23030", + "link-color": "#238cf5" + } + } + } + } +} From bf87ed7eae420ef905ceb47a48b0377a015ae8d9 Mon Sep 17 00:00:00 2001 From: RMidhunSuresh Date: Mon, 4 Jul 2022 16:41:06 +0530 Subject: [PATCH 073/226] Do not add variables to root for runtime theme --- scripts/postcss/css-url-to-variables.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/scripts/postcss/css-url-to-variables.js b/scripts/postcss/css-url-to-variables.js index f9588434..9988a10e 100644 --- a/scripts/postcss/css-url-to-variables.js +++ b/scripts/postcss/css-url-to-variables.js @@ -81,7 +81,8 @@ module.exports = (opts = {}) => { const urlVariables = new Map(); const counter = createCounter(); root.walkDecls(decl => findAndReplaceUrl(decl, urlVariables, counter)); - if (urlVariables.size) { + const cssFileLocation = root.source.input.from; + if (urlVariables.size && !cssFileLocation.includes("type=runtime")) { addResolvedVariablesToRootSelector(root, { Rule, Declaration }, urlVariables); } if (opts.compiledVariables){ From 43e8cc9e522413406d6431d36acd407e715ef64c Mon Sep 17 00:00:00 2001 From: RMidhunSuresh Date: Mon, 4 Jul 2022 16:42:02 +0530 Subject: [PATCH 074/226] Add svgo for optimizing svgs as dev dependency --- package.json | 1 + yarn.lock | 50 +++++++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 50 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index 82588f10..dd1a1554 100644 --- a/package.json +++ b/package.json @@ -50,6 +50,7 @@ "postcss-flexbugs-fixes": "^5.0.2", "postcss-value-parser": "^4.2.0", "regenerator-runtime": "^0.13.7", + "svgo": "^2.8.0", "text-encoding": "^0.7.0", "typescript": "^4.3.5", "vite": "^2.9.8", diff --git a/yarn.lock b/yarn.lock index 28efc3bc..1543f8d0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -77,6 +77,11 @@ "@nodelib/fs.scandir" "2.1.5" fastq "^1.6.0" +"@trysound/sax@0.2.0": + version "0.2.0" + resolved "https://registry.yarnpkg.com/@trysound/sax/-/sax-0.2.0.tgz#cccaab758af56761eb7bf37af6f03f326dd798ad" + integrity sha512-L7z9BgrNEcYyUYtF+HaEfiS5ebkh9jXqbszz7pC0hRBPaatV0XjSD3+eHrpqFemQfgwiFF0QPIarnIihIDn7OA== + "@types/json-schema@^7.0.7": version "7.0.9" resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.9.tgz#97edc9037ea0c38585320b28964dde3b39e4660d" @@ -347,6 +352,11 @@ commander@^6.1.0: resolved "https://registry.yarnpkg.com/commander/-/commander-6.2.1.tgz#0792eb682dfbc325999bb2b84fddddba110ac73c" integrity sha512-U7VdrJFnJgo4xjrHpTzu0yrHPGImdsmD95ZlgYSEajAn2JKzDhDTPG9kBTefmObL2w/ngeZnilk+OV9CG3d7UA== +commander@^7.2.0: + version "7.2.0" + resolved "https://registry.yarnpkg.com/commander/-/commander-7.2.0.tgz#a36cb57d0b501ce108e4d20559a150a391d97ab7" + integrity sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw== + concat-map@0.0.1: version "0.0.1" resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b" @@ -382,11 +392,26 @@ css-select@^4.1.3: domutils "^2.6.0" nth-check "^2.0.0" +css-tree@^1.1.2, css-tree@^1.1.3: + version "1.1.3" + resolved "https://registry.yarnpkg.com/css-tree/-/css-tree-1.1.3.tgz#eb4870fb6fd7707327ec95c2ff2ab09b5e8db91d" + integrity sha512-tRpdppF7TRazZrjJ6v3stzv93qxRcSsFmW6cX0Zm2NVKpxE1WV1HblnghVv9TreireHkqI/VDEsfolRF1p6y7Q== + dependencies: + mdn-data "2.0.14" + source-map "^0.6.1" + css-what@^5.0.0: version "5.0.1" resolved "https://registry.yarnpkg.com/css-what/-/css-what-5.0.1.tgz#3efa820131f4669a8ac2408f9c32e7c7de9f4cad" integrity sha512-FYDTSHb/7KXsWICVsxdmiExPjCfRC4qRFBdVwv7Ax9hMnvMmEjP9RfxTEZ3qPZGmADDn2vAKSo9UcN1jKVYscg== +csso@^4.2.0: + version "4.2.0" + resolved "https://registry.yarnpkg.com/csso/-/csso-4.2.0.tgz#ea3a561346e8dc9f546d6febedd50187cf389529" + integrity sha512-wvlcdIbf6pwKEk7vHj8/Bkc0B4ylXZruLvOgs9doS5eOsOpuodOV2zJChSpkp+pRpYQLQMeF04nr3Z68Sta9jA== + dependencies: + css-tree "^1.1.2" + cuint@^0.2.2: version "0.2.2" resolved "https://registry.yarnpkg.com/cuint/-/cuint-0.2.2.tgz#408086d409550c2631155619e9fa7bcadc3b991b" @@ -1197,6 +1222,11 @@ lru-cache@^6.0.0: dependencies: yallist "^4.0.0" +mdn-data@2.0.14: + version "2.0.14" + resolved "https://registry.yarnpkg.com/mdn-data/-/mdn-data-2.0.14.tgz#7113fc4281917d63ce29b43446f701e68c25ba50" + integrity sha512-dn6wd0uw5GsdswPFfsgMp5NSB0/aDe6fK94YJV/AJDYXL6HVLWBsxeq7js7Ad+mU2K9LAlwpk6kN2D5mwCPVow== + mdn-polyfills@^5.20.0: version "5.20.0" resolved "https://registry.yarnpkg.com/mdn-polyfills/-/mdn-polyfills-5.20.0.tgz#ca8247edf20a4f60dec6804372229812b348260b" @@ -1500,7 +1530,7 @@ source-map-js@^1.0.2: resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-1.0.2.tgz#adbc361d9c62df380125e7f161f71c826f1e490c" integrity sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw== -source-map@~0.6.1: +source-map@^0.6.1, source-map@~0.6.1: version "0.6.1" resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.6.1.tgz#74722af32e9614e9c287a8d0bbde48b5e2f1a263" integrity sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g== @@ -1510,6 +1540,11 @@ sprintf-js@~1.0.2: resolved "https://registry.yarnpkg.com/sprintf-js/-/sprintf-js-1.0.3.tgz#04e6926f662895354f3dd015203633b857297e2c" integrity sha1-BOaSb2YolTVPPdAVIDYzuFcpfiw= +stable@^0.1.8: + version "0.1.8" + resolved "https://registry.yarnpkg.com/stable/-/stable-0.1.8.tgz#836eb3c8382fe2936feaf544631017ce7d47a3cf" + integrity sha512-ji9qxRnOVfcuLDySj9qzhGSEFVobyt1kIOSkj1qZzYLzq7Tos/oUUWvotUPQLlrsidqsK6tBH89Bc9kL5zHA6w== + string-width@^4.2.0: version "4.2.2" resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.2.tgz#dafd4f9559a7585cfba529c6a0a4f73488ebd4c5" @@ -1550,6 +1585,19 @@ supports-preserve-symlinks-flag@^1.0.0: resolved "https://registry.yarnpkg.com/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz#6eda4bd344a3c94aea376d4cc31bc77311039e09" integrity sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w== +svgo@^2.8.0: + version "2.8.0" + resolved "https://registry.yarnpkg.com/svgo/-/svgo-2.8.0.tgz#4ff80cce6710dc2795f0c7c74101e6764cfccd24" + integrity sha512-+N/Q9kV1+F+UeWYoSiULYo4xYSDQlTgb+ayMobAXPwMnLvop7oxKMo9OzIrX5x3eS4L4f2UHhc9axXwY8DpChg== + dependencies: + "@trysound/sax" "0.2.0" + commander "^7.2.0" + css-select "^4.1.3" + css-tree "^1.1.3" + csso "^4.2.0" + picocolors "^1.0.0" + stable "^0.1.8" + table@^6.0.9: version "6.7.1" resolved "https://registry.yarnpkg.com/table/-/table-6.7.1.tgz#ee05592b7143831a8c94f3cee6aae4c1ccef33e2" From c8738045435e0335489b401c1b9b397ad564d7b3 Mon Sep 17 00:00:00 2001 From: RMidhunSuresh Date: Mon, 4 Jul 2022 16:42:56 +0530 Subject: [PATCH 075/226] produce asset hashed icons --- .../rollup-plugin-build-themes.js | 43 +++++++++++++++++-- 1 file changed, 40 insertions(+), 3 deletions(-) diff --git a/scripts/build-plugins/rollup-plugin-build-themes.js b/scripts/build-plugins/rollup-plugin-build-themes.js index 6b8fdae0..b523778b 100644 --- a/scripts/build-plugins/rollup-plugin-build-themes.js +++ b/scripts/build-plugins/rollup-plugin-build-themes.js @@ -14,10 +14,10 @@ See the License for the specific language governing permissions and limitations under the License. */ const path = require('path').posix; +const {optimize} = require('svgo'); async function readCSSSource(location) { const fs = require("fs").promises; - const path = require("path"); const resolvedLocation = path.resolve(__dirname, "../../", `${location}/theme.css`); const data = await fs.readFile(resolvedLocation); return data; @@ -43,6 +43,28 @@ function addThemesToConfig(bundle, manifestLocations, defaultThemes) { } } +/** + * Returns an object where keys are the svg file names and the values + * are the svg code (optimized) + * @param {*} icons Object where keys are css variable names and values are locations of the svg + * @param {*} manifestLocation Location of manifest used for resolving path + */ +async function generateIconSourceMap(icons, manifestLocation) { + const sources = {}; + const fs = require("fs").promises; + for (const icon of Object.values(icons)) { + const [location] = icon.split("?"); + const resolvedLocation = path.resolve(__dirname, "../../", manifestLocation, location); + const iconData = await fs.readFile(resolvedLocation); + const svgString = iconData.toString(); + const result = optimize(svgString); + const optimizedSvgString = result.data; + const fileName = path.basename(resolvedLocation); + sources[fileName] = optimizedSvgString; + } + return sources; +} + /** * Returns a mapping from location (of manifest file) to an array containing all the chunks (of css files) generated from that location. * To understand what chunk means in this context, see https://rollupjs.org/guide/en/#generatebundle. @@ -283,7 +305,7 @@ module.exports = function buildThemes(options) { ]; }, - generateBundle(_, bundle) { + async generateBundle(_, bundle) { const assetMap = getMappingFromFileNameToAssetInfo(bundle); const chunkMap = getMappingFromLocationToChunkArray(bundle); const runtimeThemeChunkMap = getMappingFromLocationToRuntimeChunk(bundle); @@ -304,13 +326,28 @@ module.exports = function buildThemes(options) { const locationRelativeToManifest = path.relative(manifestLocation, locationRelativeToBuildRoot); builtAssets[`${name}-${variant}`] = locationRelativeToManifest; } + // Emit the base svg icons as asset + const nameToAssetHashedLocation = []; + const nameToSource = await generateIconSourceMap(icon, location); + for (const [name, source] of Object.entries(nameToSource)) { + const ref = this.emitFile({ type: "asset", name, source }); + const assetHashedName = this.getFileName(ref); + nameToAssetHashedLocation[name] = assetHashedName; + } + for (const [variable, location] of Object.entries(icon)) { + const [locationWithoutQueryParameters, queryParameters] = location.split("?"); + const name = path.basename(locationWithoutQueryParameters); + const locationRelativeToBuildRoot = nameToAssetHashedLocation[name]; + const locationRelativeToManifest = path.relative(manifestLocation, locationRelativeToBuildRoot); + icon[variable] = `${locationRelativeToManifest}?${queryParameters}`; + } const runtimeThemeChunk = runtimeThemeChunkMap.get(location); const runtimeAssetLocation = path.relative(manifestLocation, assetMap.get(runtimeThemeChunk.fileName).fileName); manifest.source = { "built-assets": builtAssets, "runtime-asset": runtimeAssetLocation, "derived-variables": derivedVariables, - "icon": icon + "icon": icon, }; const name = `theme-${themeKey}.json`; manifestLocations.push(`${manifestLocation}/${name}`); From 2947f9f6ff84834c4190616356d40f40460fff73 Mon Sep 17 00:00:00 2001 From: RMidhunSuresh Date: Mon, 4 Jul 2022 17:14:10 +0530 Subject: [PATCH 076/226] Remove console.log --- src/platform/web/ThemeBuilder.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/platform/web/ThemeBuilder.ts b/src/platform/web/ThemeBuilder.ts index 313a8183..a192424c 100644 --- a/src/platform/web/ThemeBuilder.ts +++ b/src/platform/web/ThemeBuilder.ts @@ -63,7 +63,6 @@ export class ThemeBuilder { const themeId = `${manifest.id}-${variant}`; const { name: variantName, default: isDefault, dark, variables } = variantDetails; const resolvedVariables = this.deriveVariables(variables, derivedVariables, dark); - console.log("resolved", resolvedVariables); Object.assign(variables, resolvedVariables); const themeDisplayName = `${themeName} ${variantName}`; if (isDefault) { From 161e29b36e6d3864561ea08aa5ee4a30208875e8 Mon Sep 17 00:00:00 2001 From: RMidhunSuresh Date: Mon, 11 Jul 2022 16:58:22 +0530 Subject: [PATCH 077/226] Use existing code --- src/platform/web/ThemeBuilder.ts | 25 +------------------------ 1 file changed, 1 insertion(+), 24 deletions(-) diff --git a/src/platform/web/ThemeBuilder.ts b/src/platform/web/ThemeBuilder.ts index a192424c..b8e7274b 100644 --- a/src/platform/web/ThemeBuilder.ts +++ b/src/platform/web/ThemeBuilder.ts @@ -15,30 +15,7 @@ limitations under the License. */ import type {ThemeInformation} from "./ThemeLoader"; import {ColorSchemePreference} from "./ThemeLoader"; -import {offColor} from 'off-color'; - -function derive(value, operation, argument, isDark) { - const argumentAsNumber = parseInt(argument); - if (isDark) { - // For dark themes, invert the operation - if (operation === 'darker') { - operation = "lighter"; - } - else if (operation === 'lighter') { - operation = "darker"; - } - } - switch (operation) { - case "darker": { - const newColorString = offColor(value).darken(argumentAsNumber / 100).hex(); - return newColorString; - } - case "lighter": { - const newColorString = offColor(value).lighten(argumentAsNumber / 100).hex(); - return newColorString; - } - } -} +import {derive} from "../../../scripts/postcss/color.mjs"; export class ThemeBuilder { // todo: replace any with manifest type when PR is merged From 1ef382f3a95e85cf03829fc8d674e82a63f45d59 Mon Sep 17 00:00:00 2001 From: RMidhunSuresh Date: Fri, 15 Jul 2022 14:55:06 +0530 Subject: [PATCH 078/226] Add gruvbox color scheme --- theme.json | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/theme.json b/theme.json index aee95368..f5fbf3ed 100644 --- a/theme.json +++ b/theme.json @@ -36,14 +36,14 @@ "red": { "name": "Red", "variables": { - "background-color-primary": "#1F1F1F", - "background-color-secondary": "#2B243E", - "text-color": "#fff", - "accent-color": "#F23041", - "error-color": "#FF4B55", + "background-color-primary": "#282828", + "background-color-secondary": "#3c3836", + "text-color": "#fbf1c7", + "accent-color": "#8ec07c", + "error-color": "#fb4934", "fixed-white": "#fff", - "room-badge": "#F23030", - "link-color": "#238cf5" + "room-badge": "#cc241d", + "link-color": "#fe8019" } } } From 2f3db89e0a43db1e1d0cd7f37deb1506539eaf2d Mon Sep 17 00:00:00 2001 From: RMidhunSuresh Date: Fri, 15 Jul 2022 14:55:21 +0530 Subject: [PATCH 079/226] Let ts know that we can use replaceAll() --- tsconfig.json | 1 + 1 file changed, 1 insertion(+) diff --git a/tsconfig.json b/tsconfig.json index f46cc7eb..8f591a6e 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -5,6 +5,7 @@ "noEmit": true, "target": "ES2020", "module": "ES2020", + "lib": ["ES2021", "DOM"], "moduleResolution": "node", "esModuleInterop": true }, From c5f4a75d4b7b3bee0218cfe7b6527fb66d812fb2 Mon Sep 17 00:00:00 2001 From: RMidhunSuresh Date: Fri, 15 Jul 2022 14:57:00 +0530 Subject: [PATCH 080/226] Split code so that it can be reused --- .../{svg-colorizer.mjs => svg-builder.mjs} | 7 ++---- scripts/postcss/svg-colorizer.js | 24 +++++++++++++++++++ vite.common-config.js | 2 +- 3 files changed, 27 insertions(+), 6 deletions(-) rename scripts/postcss/{svg-colorizer.mjs => svg-builder.mjs} (82%) create mode 100644 scripts/postcss/svg-colorizer.js diff --git a/scripts/postcss/svg-colorizer.mjs b/scripts/postcss/svg-builder.mjs similarity index 82% rename from scripts/postcss/svg-colorizer.mjs rename to scripts/postcss/svg-builder.mjs index fbe48b55..1bbc4010 100644 --- a/scripts/postcss/svg-colorizer.mjs +++ b/scripts/postcss/svg-builder.mjs @@ -17,6 +17,7 @@ limitations under the License. import {readFileSync, mkdirSync, writeFileSync} from "fs"; import {resolve} from "path"; import {h32} from "xxhashjs"; +import {getColoredSvgString} from "./svg-colorizer.js"; function createHash(content) { const hasher = new h32(0); @@ -32,11 +33,7 @@ function createHash(content) { */ export function buildColorizedSVG(svgLocation, primaryColor, secondaryColor) { const svgCode = readFileSync(svgLocation, { encoding: "utf8"}); - let coloredSVGCode = svgCode.replaceAll("#ff00ff", primaryColor); - coloredSVGCode = coloredSVGCode.replaceAll("#00ffff", secondaryColor); - if (svgCode === coloredSVGCode) { - throw new Error("svg-colorizer made no color replacements! The input svg should only contain colors #ff00ff (primary, case-sensitive) and #00ffff (secondary, case-sensitive)."); - } + const coloredSVGCode = getColoredSvgString(svgCode, primaryColor, secondaryColor); const fileName = svgLocation.match(/.+[/\\](.+\.svg)/)[1]; const outputName = `${fileName.substring(0, fileName.length - 4)}-${createHash(coloredSVGCode)}.svg`; const outputPath = resolve(__dirname, "../../.tmp"); diff --git a/scripts/postcss/svg-colorizer.js b/scripts/postcss/svg-colorizer.js new file mode 100644 index 00000000..cb291726 --- /dev/null +++ b/scripts/postcss/svg-colorizer.js @@ -0,0 +1,24 @@ +/* +Copyright 2021 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. +*/ + +export function getColoredSvgString(svgString, primaryColor, secondaryColor) { + let coloredSVGCode = svgString.replaceAll("#ff00ff", primaryColor); + coloredSVGCode = coloredSVGCode.replaceAll("#00ffff", secondaryColor); + if (svgString === coloredSVGCode) { + throw new Error("svg-colorizer made no color replacements! The input svg should only contain colors #ff00ff (primary, case-sensitive) and #00ffff (secondary, case-sensitive)."); + } + return coloredSVGCode; +} diff --git a/vite.common-config.js b/vite.common-config.js index aaea47a9..bcf17115 100644 --- a/vite.common-config.js +++ b/vite.common-config.js @@ -8,7 +8,7 @@ const path = require("path"); const manifest = require("./package.json"); const version = manifest.version; const compiledVariables = new Map(); -import {buildColorizedSVG as replacer} from "./scripts/postcss/svg-colorizer.mjs"; +import {buildColorizedSVG as replacer} from "./scripts/postcss/svg-builder.mjs"; import {derive} from "./scripts/postcss/color.mjs"; const commonOptions = { From 5ba74b1d7588582081f934f3de4e45d3045bff8e Mon Sep 17 00:00:00 2001 From: RMidhunSuresh Date: Fri, 15 Jul 2022 14:58:07 +0530 Subject: [PATCH 081/226] Use script to copy over runtime theme after build --- scripts/test-theme.sh | 5 +++++ 1 file changed, 5 insertions(+) create mode 100755 scripts/test-theme.sh diff --git a/scripts/test-theme.sh b/scripts/test-theme.sh new file mode 100755 index 00000000..9f94d3c3 --- /dev/null +++ b/scripts/test-theme.sh @@ -0,0 +1,5 @@ +#!/bin/zsh +cp theme.json target/assets/theme-customer.json +cat target/config.json | jq '.themeManifests += ["assets/theme-customer.json"]' | cat > target/config.temp.json +rm target/config.json +mv target/config.temp.json target/config.json From f7b302d34fb4f8e82372806e49804a581bf91f95 Mon Sep 17 00:00:00 2001 From: RMidhunSuresh Date: Fri, 15 Jul 2022 14:59:50 +0530 Subject: [PATCH 082/226] Don't optimzie colors --- scripts/build-plugins/rollup-plugin-build-themes.js | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/scripts/build-plugins/rollup-plugin-build-themes.js b/scripts/build-plugins/rollup-plugin-build-themes.js index b523778b..72053a1f 100644 --- a/scripts/build-plugins/rollup-plugin-build-themes.js +++ b/scripts/build-plugins/rollup-plugin-build-themes.js @@ -57,7 +57,16 @@ async function generateIconSourceMap(icons, manifestLocation) { const resolvedLocation = path.resolve(__dirname, "../../", manifestLocation, location); const iconData = await fs.readFile(resolvedLocation); const svgString = iconData.toString(); - const result = optimize(svgString); + const result = optimize(svgString, { + plugins: [ + { + name: "preset-default", + params: { + overrides: { convertColors: false, }, + }, + }, + ], + }); const optimizedSvgString = result.data; const fileName = path.basename(resolvedLocation); sources[fileName] = optimizedSvgString; From d731eab51c10e9e7499c890f35a86a9099d4945f Mon Sep 17 00:00:00 2001 From: RMidhunSuresh Date: Fri, 15 Jul 2022 15:04:54 +0530 Subject: [PATCH 083/226] Support fetching text --- src/platform/web/dom/request/fetch.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/platform/web/dom/request/fetch.js b/src/platform/web/dom/request/fetch.js index 497ad553..eb4caab6 100644 --- a/src/platform/web/dom/request/fetch.js +++ b/src/platform/web/dom/request/fetch.js @@ -115,6 +115,9 @@ export function createFetchRequest(createTimeout, serviceWorkerHandler) { } else if (format === "buffer") { body = await response.arrayBuffer(); } + else if (format === "text") { + body = await response.text(); + } } catch (err) { // some error pages return html instead of json, ignore error if (!(err.name === "SyntaxError" && status >= 400)) { From ac7be0c7a1f66c549e634f082f7a858204d3dbaf Mon Sep 17 00:00:00 2001 From: RMidhunSuresh Date: Fri, 15 Jul 2022 15:05:50 +0530 Subject: [PATCH 084/226] WIP --- src/platform/web/DerivedVariables.ts | 54 +++++++++++++++ src/platform/web/IconColorizer.ts | 79 +++++++++++++++++++++ src/platform/web/ThemeBuilder.ts | 100 +++++++++------------------ src/platform/web/ThemeLoader.ts | 13 ++-- 4 files changed, 174 insertions(+), 72 deletions(-) create mode 100644 src/platform/web/DerivedVariables.ts create mode 100644 src/platform/web/IconColorizer.ts diff --git a/src/platform/web/DerivedVariables.ts b/src/platform/web/DerivedVariables.ts new file mode 100644 index 00000000..4e3a8b46 --- /dev/null +++ b/src/platform/web/DerivedVariables.ts @@ -0,0 +1,54 @@ +/* +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 {derive} from "../../../scripts/postcss/color.mjs"; + +export class DerivedVariables { + private _baseVariables: Record; + private _variablesToDerive: string[] + private _isDark: boolean + + constructor(baseVariables: Record, variablesToDerive: string[], isDark: boolean) { + this._baseVariables = baseVariables; + this._variablesToDerive = variablesToDerive; + this._isDark = isDark; + } + + toVariables(): Record { + const aliases: any = {}; + const resolvedVariables: any = {}; + const RE_VARIABLE_VALUE = /(.+)--(.+)-(.+)/; + for (const variable of this._variablesToDerive) { + // If this is an alias, store it for processing later + const [alias, value] = variable.split("="); + if (value) { + aliases[alias] = value; + continue; + } + // Resolve derived variables + const matches = variable.match(RE_VARIABLE_VALUE); + if (matches) { + const [, baseVariable, operation, argument] = matches; + const value = this._baseVariables[baseVariable]; + const resolvedValue = derive(value, operation, argument, this._isDark); + resolvedVariables[variable] = resolvedValue; + } + } + for (const [alias, variable] of Object.entries(aliases) as any) { + resolvedVariables[alias] = this._baseVariables[variable] ?? resolvedVariables[variable]; + } + return resolvedVariables; + } +} diff --git a/src/platform/web/IconColorizer.ts b/src/platform/web/IconColorizer.ts new file mode 100644 index 00000000..81644603 --- /dev/null +++ b/src/platform/web/IconColorizer.ts @@ -0,0 +1,79 @@ +/* +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 type {Platform} from "./Platform.js"; +import {getColoredSvgString} from "../../../scripts/postcss/svg-colorizer.js"; + +type ParsedStructure = { + [variableName: string]: { + svg: Promise<{ status: number; body: string }>; + primary: string | null; + secondary: string | null; + }; +}; + +export class IconColorizer { + private _iconVariables: Record; + private _resolvedVariables: Record; + private _manifestLocation: string; + private _platform: Platform; + + constructor(platform: Platform, iconVariables: Record, resolvedVariables: Record, manifestLocation: string) { + this._platform = platform; + this._iconVariables = iconVariables; + this._resolvedVariables = resolvedVariables; + this._manifestLocation = manifestLocation; + } + + async toVariables(): Promise> { + const { parsedStructure, promises } = await this._fetchAndParseIcons(); + await Promise.all(promises); + return this._produceColoredIconVariables(parsedStructure); + } + + private async _fetchAndParseIcons(): Promise<{ parsedStructure: ParsedStructure, promises: any[] }> { + const promises: any[] = []; + const parsedStructure: ParsedStructure = {}; + for (const [variable, url] of Object.entries(this._iconVariables)) { + const urlObject = new URL(`https://${url}`); + const pathWithoutQueryParams = urlObject.hostname; + const relativePath = new URL(pathWithoutQueryParams, new URL(this._manifestLocation, window.location.origin)); + const responsePromise = this._platform.request(relativePath, { method: "GET", format: "text", cache: true, }).response() + promises.push(responsePromise); + const searchParams = urlObject.searchParams; + parsedStructure[variable] = { + svg: responsePromise, + primary: searchParams.get("primary"), + secondary: searchParams.get("secondary") + }; + } + return { parsedStructure, promises }; + } + + private async _produceColoredIconVariables(parsedStructure: ParsedStructure): Promise> { + let coloredVariables: Record = {}; + for (const [variable, { svg, primary, secondary }] of Object.entries(parsedStructure)) { + const { body: svgCode } = await svg; + if (!primary) { + throw new Error(`Primary color variable ${primary} not in list of variables!`); + } + const primaryColor = this._resolvedVariables[primary], secondaryColor = this._resolvedVariables[secondary!]; + const coloredSvgCode = getColoredSvgString(svgCode, primaryColor, secondaryColor); + const dataURI = `url('data:image/svg+xml;utf8,${encodeURIComponent(coloredSvgCode)}')`; + coloredVariables[variable] = dataURI; + } + return coloredVariables; + } +} diff --git a/src/platform/web/ThemeBuilder.ts b/src/platform/web/ThemeBuilder.ts index b8e7274b..f05a24b1 100644 --- a/src/platform/web/ThemeBuilder.ts +++ b/src/platform/web/ThemeBuilder.ts @@ -14,72 +14,59 @@ See the License for the specific language governing permissions and limitations under the License. */ import type {ThemeInformation} from "./ThemeLoader"; +import type {Platform} from "./Platform.js"; import {ColorSchemePreference} from "./ThemeLoader"; -import {derive} from "../../../scripts/postcss/color.mjs"; +import {IconColorizer} from "./IconColorizer"; +import {DerivedVariables} from "./DerivedVariables"; export class ThemeBuilder { // todo: replace any with manifest type when PR is merged private _idToManifest: Map; private _themeMapping: Record = {}; - private _themeToVariables: Record = {}; private _preferredColorScheme?: ColorSchemePreference; + private _platform: Platform; + private _injectedVariables?: Record; - constructor(manifestMap: Map, preferredColorScheme?: ColorSchemePreference) { + constructor(platform: Platform, manifestMap: Map, preferredColorScheme?: ColorSchemePreference) { this._idToManifest = manifestMap; this._preferredColorScheme = preferredColorScheme; + this._platform = platform; } - populateDerivedTheme(manifest) { + async populateDerivedTheme(manifest) { const { manifest: baseManifest, location } = this._idToManifest.get(manifest.extends); const runtimeCssLocation = baseManifest.source?.["runtime-asset"]; const cssLocation = new URL(runtimeCssLocation, new URL(location, window.location.origin)).href; const derivedVariables = baseManifest.source?.["derived-variables"]; + const icons = baseManifest.source?.["icon"]; const themeName = manifest.name; let defaultDarkVariant: any = {}, defaultLightVariant: any = {}; for (const [variant, variantDetails] of Object.entries(manifest.values.variants) as [string, any][]) { - const themeId = `${manifest.id}-${variant}`; - const { name: variantName, default: isDefault, dark, variables } = variantDetails; - const resolvedVariables = this.deriveVariables(variables, derivedVariables, dark); - Object.assign(variables, resolvedVariables); - const themeDisplayName = `${themeName} ${variantName}`; - if (isDefault) { - /** - * This is a default variant! - * We'll add these to the themeMapping (separately) keyed with just the - * theme-name (i.e "Element" instead of "Element Dark"). - * We need to be able to distinguish them from other variants! - * - * This allows us to render radio-buttons with "dark" and - * "light" options. - */ - const defaultVariant = dark ? defaultDarkVariant : defaultLightVariant; - defaultVariant.variantName = variantName; - defaultVariant.id = themeId; - defaultVariant.cssLocation = cssLocation; - defaultVariant.variables = variables; + try { + const themeId = `${manifest.id}-${variant}`; + const { name: variantName, default: isDefault, dark, variables } = variantDetails; + const resolvedVariables = new DerivedVariables(variables, derivedVariables, dark).toVariables(); + Object.assign(variables, resolvedVariables); + const iconVariables = await new IconColorizer(this._platform, icons, variables, location).toVariables(); + Object.assign(variables, resolvedVariables, iconVariables); + const themeDisplayName = `${themeName} ${variantName}`; + if (isDefault) { + const defaultVariant = dark ? defaultDarkVariant : defaultLightVariant; + Object.assign(defaultVariant, { variantName, id: themeId, cssLocation, variables }); + continue; + } + this._themeMapping[themeDisplayName] = { cssLocation, id: themeId, variables: variables, }; + } + catch (e) { + console.error(e); continue; } - // Non-default variants are keyed in themeMapping with "theme_name variant_name" - // eg: "Element Dark" - this._themeMapping[themeDisplayName] = { - cssLocation, - id: themeId, - variables: variables, - }; } if (defaultDarkVariant.id && defaultLightVariant.id) { - /** - * As mentioned above, if there's both a default dark and a default light variant, - * add them to themeMapping separately. - */ const defaultVariant = this._preferredColorScheme === ColorSchemePreference.Dark ? defaultDarkVariant : defaultLightVariant; this._themeMapping[themeName] = { dark: defaultDarkVariant, light: defaultLightVariant, default: defaultVariant }; } else { - /** - * If only one default variant is found (i.e only dark default or light default but not both), - * treat it like any other variant. - */ const variant = defaultDarkVariant.id ? defaultDarkVariant : defaultLightVariant; this._themeMapping[`${themeName} ${variant.variantName}`] = { id: variant.id, cssLocation: variant.cssLocation }; } @@ -94,38 +81,17 @@ export class ThemeBuilder { for (const [variable, value] of Object.entries(variables)) { root.style.setProperty(`--${variable}`, value); } + this._injectedVariables = variables; } - removeCSSVariables(variables: string[]) { + removePreviousCSSVariables() { + if (!this._injectedVariables) { + return; + } const root = document.documentElement; - for (const variable of variables) { + for (const variable of Object.keys(this._injectedVariables)) { root.style.removeProperty(`--${variable}`); } - } - - deriveVariables(variables: Record, derivedVariables: string[], isDark: boolean) { - const aliases: any = {}; - const resolvedVariables: any = {}; - const RE_VARIABLE_VALUE = /(.+)--(.+)-(.+)/; - for (const variable of derivedVariables) { - // If this is an alias, store it for processing later - const [alias, value] = variable.split("="); - if (value) { - aliases[alias] = value; - continue; - } - // Resolve derived variables - const matches = variable.match(RE_VARIABLE_VALUE); - if (matches) { - const [, baseVariable, operation, argument] = matches; - const value = variables[baseVariable]; - const resolvedValue = derive(value, operation, argument, isDark); - resolvedVariables[variable] = resolvedValue; - } - } - for (const [alias, variable] of Object.entries(aliases) as any) { - resolvedVariables[alias] = variables[variable] ?? resolvedVariables[variable]; - } - return resolvedVariables; + this._injectedVariables = undefined; } } diff --git a/src/platform/web/ThemeLoader.ts b/src/platform/web/ThemeLoader.ts index 9806dd10..b1e13161 100644 --- a/src/platform/web/ThemeLoader.ts +++ b/src/platform/web/ThemeLoader.ts @@ -60,17 +60,17 @@ export class ThemeLoader { manifestLocations.map( location => this._platform.request(location, { method: "GET", format: "json", cache: true, }).response()) ); results.forEach(({ body }, i) => idToManifest.set(body.id, { manifest: body, location: manifestLocations[i] })); - this._themeBuilder = new ThemeBuilder(idToManifest, this.preferredColorScheme); - results.forEach(({ body }, i) => { + this._themeBuilder = new ThemeBuilder(this._platform, idToManifest, this.preferredColorScheme); + for (let i = 0; i < results.length; ++i) { + const { body } = results[i]; if (body.extends) { - this._themeBuilder.populateDerivedTheme(body); + await this._themeBuilder.populateDerivedTheme(body); } else { this._populateThemeMap(body, manifestLocations[i], log); } - }); + } Object.assign(this._themeMapping, this._themeBuilder.themeMapping); - console.log("derived theme mapping", this._themeBuilder.themeMapping); }); } @@ -170,6 +170,9 @@ export class ThemeLoader { if (variables) { this._themeBuilder.injectCSSVariables(variables); } + else { + this._themeBuilder.removePreviousCSSVariables(); + } this._platform.settingsStorage.setString("theme-name", themeName); if (themeVariant) { this._platform.settingsStorage.setString("theme-variant", themeVariant); From a8cab98666002d0b8a2b80ef49842edd618a30d7 Mon Sep 17 00:00:00 2001 From: RMidhunSuresh Date: Sun, 17 Jul 2022 16:07:52 +0530 Subject: [PATCH 085/226] Add mroe missing types --- src/platform/types/theme.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/platform/types/theme.ts b/src/platform/types/theme.ts index 9a984277..f57432eb 100644 --- a/src/platform/types/theme.ts +++ b/src/platform/types/theme.ts @@ -42,6 +42,12 @@ export type ThemeManifest = Partial<{ "runtime-asset": string; // Array of derived-variables "derived-variables": Array; + /** + * Mapping from icon variable to location of icon in build output with query parameters + * indicating how it should be colored for this particular theme. + * eg: "icon-url-1": "element-logo.86bc8565.svg?primary=accent-color" + */ + icon: Record; }; values: { /** @@ -60,6 +66,8 @@ type Variant = Partial<{ default: boolean; // A user-facing string that is the name for this variant. name: string; + // A boolean indicating whether this is a dark theme or not + dark: boolean; /** * Mapping from css variable to its value. * eg: {"background-color-primary": "#21262b", ...} From f4404578759b8e7bb197c9a9f6e5dade3cc580b5 Mon Sep 17 00:00:00 2001 From: RMidhunSuresh Date: Sun, 17 Jul 2022 16:08:02 +0530 Subject: [PATCH 086/226] Use ThemeManifest type where possible --- src/platform/web/ThemeBuilder.ts | 28 +++++++++++++++++++++------- src/platform/web/ThemeLoader.ts | 12 ++++++++++-- 2 files changed, 31 insertions(+), 9 deletions(-) diff --git a/src/platform/web/ThemeBuilder.ts b/src/platform/web/ThemeBuilder.ts index f05a24b1..b093deb3 100644 --- a/src/platform/web/ThemeBuilder.ts +++ b/src/platform/web/ThemeBuilder.ts @@ -18,10 +18,10 @@ import type {Platform} from "./Platform.js"; import {ColorSchemePreference} from "./ThemeLoader"; import {IconColorizer} from "./IconColorizer"; import {DerivedVariables} from "./DerivedVariables"; +import {ThemeManifest} from "../types/theme"; export class ThemeBuilder { - // todo: replace any with manifest type when PR is merged - private _idToManifest: Map; + private _idToManifest: Map; private _themeMapping: Record = {}; private _preferredColorScheme?: ColorSchemePreference; private _platform: Platform; @@ -34,11 +34,8 @@ export class ThemeBuilder { } async populateDerivedTheme(manifest) { - const { manifest: baseManifest, location } = this._idToManifest.get(manifest.extends); - const runtimeCssLocation = baseManifest.source?.["runtime-asset"]; - const cssLocation = new URL(runtimeCssLocation, new URL(location, window.location.origin)).href; - const derivedVariables = baseManifest.source?.["derived-variables"]; - const icons = baseManifest.source?.["icon"]; + const { manifest: baseManifest, location } = this._idToManifest.get(manifest.extends)!; + const { cssLocation, derivedVariables, icons } = this._getsourceData(baseManifest, location); const themeName = manifest.name; let defaultDarkVariant: any = {}, defaultLightVariant: any = {}; for (const [variant, variantDetails] of Object.entries(manifest.values.variants) as [string, any][]) { @@ -72,6 +69,23 @@ export class ThemeBuilder { } } + private _getsourceData(manifest: ThemeManifest, location: string) { + const runtimeCSSLocation = manifest.source?.["runtime-asset"]; + if (!runtimeCSSLocation) { + throw new Error(`Run-time asset not found in source section for theme at ${location}`); + } + const cssLocation = new URL(runtimeCSSLocation, new URL(location, window.location.origin)).href; + const derivedVariables = manifest.source?.["derived-variables"]; + if (!derivedVariables) { + throw new Error(`Derived variables not found in source section for theme at ${location}`); + } + const icons = manifest.source?.["icon"]; + if (!icons) { + throw new Error(`Icon mapping not found in source section for theme at ${location}`); + } + return { cssLocation, derivedVariables, icons }; + } + get themeMapping() { return this._themeMapping; } diff --git a/src/platform/web/ThemeLoader.ts b/src/platform/web/ThemeLoader.ts index b1e13161..7deeee1b 100644 --- a/src/platform/web/ThemeLoader.ts +++ b/src/platform/web/ThemeLoader.ts @@ -15,6 +15,7 @@ limitations under the License. */ import type {ILogItem} from "../../logging/types"; +import { ThemeManifest } from "../types/theme"; import type {Platform} from "./Platform.js"; import {ThemeBuilder} from "./ThemeBuilder"; @@ -74,7 +75,7 @@ export class ThemeLoader { }); } - private _populateThemeMap(manifest, manifestLocation: string, log: ILogItem) { + private _populateThemeMap(manifest: ThemeManifest, manifestLocation: string, log: ILogItem) { log.wrap("populateThemeMap", (l) => { /* After build has finished, the source section of each theme manifest @@ -83,6 +84,9 @@ export class ThemeLoader { */ const builtAssets: Record = manifest.source?.["built-assets"]; const themeName = manifest.name; + if (!themeName) { + throw new Error(`Theme name not found in manifest at ${manifestLocation}`); + } let defaultDarkVariant: any = {}, defaultLightVariant: any = {}; for (let [themeId, cssLocation] of Object.entries(builtAssets)) { try { @@ -96,7 +100,11 @@ export class ThemeLoader { continue; } const variant = themeId.match(/.+-(.+)/)?.[1]; - const { name: variantName, default: isDefault, dark } = manifest.values.variants[variant!]; + const variantDetails = manifest.values?.variants[variant!]; + if (!variantDetails) { + throw new Error(`Variant ${variant} is missing in manifest at ${manifestLocation}`); + } + const { name: variantName, default: isDefault, dark } = variantDetails; const themeDisplayName = `${themeName} ${variantName}`; if (isDefault) { /** From f15e23762ad2567f19f9ebda2acc2d65e1cdf5e3 Mon Sep 17 00:00:00 2001 From: RMidhunSuresh Date: Sun, 17 Jul 2022 17:34:56 +0530 Subject: [PATCH 087/226] Add more missing keys to type --- src/platform/types/theme.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/platform/types/theme.ts b/src/platform/types/theme.ts index f57432eb..9dd969de 100644 --- a/src/platform/types/theme.ts +++ b/src/platform/types/theme.ts @@ -22,6 +22,13 @@ export type ThemeManifest = Partial<{ version: number; // A user-facing string that is the name for this theme-collection. name: string; + // An identifier for this theme + id: string; + /** + * Id of the theme that this theme derives from. + * Only present for derived/runtime themes. + */ + extends: string; /** * This is added to the manifest during the build process and includes data * that is needed to load themes at runtime. From 80fb9536885b6c8abb00c959c63507cf8d2c4a55 Mon Sep 17 00:00:00 2001 From: RMidhunSuresh Date: Sun, 17 Jul 2022 17:35:29 +0530 Subject: [PATCH 088/226] Don't fail on erros; expect the code to throw! --- src/platform/web/ThemeLoader.ts | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/src/platform/web/ThemeLoader.ts b/src/platform/web/ThemeLoader.ts index 7deeee1b..ec62b2c2 100644 --- a/src/platform/web/ThemeLoader.ts +++ b/src/platform/web/ThemeLoader.ts @@ -64,11 +64,16 @@ export class ThemeLoader { this._themeBuilder = new ThemeBuilder(this._platform, idToManifest, this.preferredColorScheme); for (let i = 0; i < results.length; ++i) { const { body } = results[i]; - if (body.extends) { - await this._themeBuilder.populateDerivedTheme(body); + try { + if (body.extends) { + await this._themeBuilder.populateDerivedTheme(body); + } + else { + this._populateThemeMap(body, manifestLocations[i], log); + } } - else { - this._populateThemeMap(body, manifestLocations[i], log); + catch(e) { + console.error(e); } } Object.assign(this._themeMapping, this._themeBuilder.themeMapping); From 043cc9f12c2a95d97926bc612c21900a2ab31260 Mon Sep 17 00:00:00 2001 From: RMidhunSuresh Date: Sun, 17 Jul 2022 17:36:36 +0530 Subject: [PATCH 089/226] Use ThemeManifest type --- src/platform/web/ThemeBuilder.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/platform/web/ThemeBuilder.ts b/src/platform/web/ThemeBuilder.ts index b093deb3..b6f75be4 100644 --- a/src/platform/web/ThemeBuilder.ts +++ b/src/platform/web/ThemeBuilder.ts @@ -33,12 +33,15 @@ export class ThemeBuilder { this._platform = platform; } - async populateDerivedTheme(manifest) { - const { manifest: baseManifest, location } = this._idToManifest.get(manifest.extends)!; + async populateDerivedTheme(manifest: ThemeManifest) { + const { manifest: baseManifest, location } = this._idToManifest.get(manifest.extends!)!; const { cssLocation, derivedVariables, icons } = this._getsourceData(baseManifest, location); const themeName = manifest.name; + if (!themeName) { + throw new Error(`Theme name not found in manifest!`); + } let defaultDarkVariant: any = {}, defaultLightVariant: any = {}; - for (const [variant, variantDetails] of Object.entries(manifest.values.variants) as [string, any][]) { + for (const [variant, variantDetails] of Object.entries(manifest.values?.variants!) as [string, any][]) { try { const themeId = `${manifest.id}-${variant}`; const { name: variantName, default: isDefault, dark, variables } = variantDetails; From da0a918c180bb7aebbebb4efa568d713bd456883 Mon Sep 17 00:00:00 2001 From: RMidhunSuresh Date: Sun, 17 Jul 2022 17:44:21 +0530 Subject: [PATCH 090/226] This code should only run once --- src/platform/web/ThemeLoader.ts | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/src/platform/web/ThemeLoader.ts b/src/platform/web/ThemeLoader.ts index ec62b2c2..069be84d 100644 --- a/src/platform/web/ThemeLoader.ts +++ b/src/platform/web/ThemeLoader.ts @@ -76,6 +76,17 @@ export class ThemeLoader { console.error(e); } } + //Add the default-theme as an additional option to the mapping + const defaultThemeId = this.getDefaultTheme(); + if (defaultThemeId) { + const themeDetails = this._findThemeDetailsFromId(defaultThemeId); + if (themeDetails) { + this._themeMapping["Default"] = { id: "default", cssLocation: themeDetails.cssLocation }; + } + } + log.log({ l: "Default Theme", theme: defaultThemeId}); + log.log({ l: "Preferred colorscheme", scheme: this.preferredColorScheme === ColorSchemePreference.Dark ? "dark" : "light" }); + log.log({ l: "Result", themeMapping: this._themeMapping }); Object.assign(this._themeMapping, this._themeBuilder.themeMapping); }); } @@ -150,17 +161,6 @@ export class ThemeLoader { const variant = defaultDarkVariant.id ? defaultDarkVariant : defaultLightVariant; this._themeMapping[`${themeName} ${variant.variantName}`] = { id: variant.id, cssLocation: variant.cssLocation }; } - //Add the default-theme as an additional option to the mapping - const defaultThemeId = this.getDefaultTheme(); - if (defaultThemeId) { - const themeDetails = this._findThemeDetailsFromId(defaultThemeId); - if (themeDetails) { - this._themeMapping["Default"] = { id: "default", cssLocation: themeDetails.cssLocation }; - } - } - l.log({ l: "Default Theme", theme: defaultThemeId}); - l.log({ l: "Preferred colorscheme", scheme: this.preferredColorScheme === ColorSchemePreference.Dark ? "dark" : "light" }); - l.log({ l: "Result", themeMapping: this._themeMapping }); }); } From ce5db477085ae05c950fea1c6fb26a1fd0a46a47 Mon Sep 17 00:00:00 2001 From: RMidhunSuresh Date: Sun, 17 Jul 2022 19:42:26 +0530 Subject: [PATCH 091/226] Support using derived theme as default theme --- src/platform/web/ThemeLoader.ts | 32 +++++++++++++++++--------------- 1 file changed, 17 insertions(+), 15 deletions(-) diff --git a/src/platform/web/ThemeLoader.ts b/src/platform/web/ThemeLoader.ts index 069be84d..c04d8e8c 100644 --- a/src/platform/web/ThemeLoader.ts +++ b/src/platform/web/ThemeLoader.ts @@ -25,16 +25,14 @@ type NormalVariant = { variables?: any; }; +type Variant = NormalVariant & { + variantName: string; +}; + type DefaultVariant = { - dark: NormalVariant & { - variantName: string; - }; - light: NormalVariant & { - variantName: string; - }; - default: NormalVariant & { - variantName: string; - }; + dark: Variant; + light: Variant; + default: Variant; } export type ThemeInformation = NormalVariant | DefaultVariant; @@ -76,18 +74,22 @@ export class ThemeLoader { console.error(e); } } + Object.assign(this._themeMapping, this._themeBuilder.themeMapping); //Add the default-theme as an additional option to the mapping const defaultThemeId = this.getDefaultTheme(); if (defaultThemeId) { const themeDetails = this._findThemeDetailsFromId(defaultThemeId); if (themeDetails) { - this._themeMapping["Default"] = { id: "default", cssLocation: themeDetails.cssLocation }; + this._themeMapping["Default"] = { id: "default", cssLocation: themeDetails.themeData.cssLocation! }; + const variables = themeDetails.themeData.variables; + if (variables) { + this._themeMapping["Default"].variables = variables; + } } } log.log({ l: "Default Theme", theme: defaultThemeId}); log.log({ l: "Preferred colorscheme", scheme: this.preferredColorScheme === ColorSchemePreference.Dark ? "dark" : "light" }); log.log({ l: "Result", themeMapping: this._themeMapping }); - Object.assign(this._themeMapping, this._themeBuilder.themeMapping); }); } @@ -222,16 +224,16 @@ export class ThemeLoader { } } - private _findThemeDetailsFromId(themeId: string): {themeName: string, cssLocation: string, variant?: string} | undefined { + private _findThemeDetailsFromId(themeId: string): {themeName: string, themeData: Partial} | undefined { for (const [themeName, themeData] of Object.entries(this._themeMapping)) { if ("id" in themeData && themeData.id === themeId) { - return { themeName, cssLocation: themeData.cssLocation }; + return { themeName, themeData }; } else if ("light" in themeData && themeData.light?.id === themeId) { - return { themeName, cssLocation: themeData.light.cssLocation, variant: "light" }; + return { themeName, themeData: themeData.light }; } else if ("dark" in themeData && themeData.dark?.id === themeId) { - return { themeName, cssLocation: themeData.dark.cssLocation, variant: "dark" }; + return { themeName, themeData: themeData.dark }; } } } From 9e2d355573cf5d580731ef47005225836bec81c7 Mon Sep 17 00:00:00 2001 From: RMidhunSuresh Date: Sun, 17 Jul 2022 20:58:58 +0530 Subject: [PATCH 092/226] Add logging --- src/platform/web/ThemeBuilder.ts | 101 ++++++++++++++++--------------- src/platform/web/ThemeLoader.ts | 3 +- 2 files changed, 55 insertions(+), 49 deletions(-) diff --git a/src/platform/web/ThemeBuilder.ts b/src/platform/web/ThemeBuilder.ts index b6f75be4..8035fe84 100644 --- a/src/platform/web/ThemeBuilder.ts +++ b/src/platform/web/ThemeBuilder.ts @@ -19,6 +19,7 @@ import {ColorSchemePreference} from "./ThemeLoader"; import {IconColorizer} from "./IconColorizer"; import {DerivedVariables} from "./DerivedVariables"; import {ThemeManifest} from "../types/theme"; +import {ILogItem} from "../../logging/types"; export class ThemeBuilder { private _idToManifest: Map; @@ -33,60 +34,64 @@ export class ThemeBuilder { this._platform = platform; } - async populateDerivedTheme(manifest: ThemeManifest) { - const { manifest: baseManifest, location } = this._idToManifest.get(manifest.extends!)!; - const { cssLocation, derivedVariables, icons } = this._getsourceData(baseManifest, location); - const themeName = manifest.name; - if (!themeName) { - throw new Error(`Theme name not found in manifest!`); - } - let defaultDarkVariant: any = {}, defaultLightVariant: any = {}; - for (const [variant, variantDetails] of Object.entries(manifest.values?.variants!) as [string, any][]) { - try { - const themeId = `${manifest.id}-${variant}`; - const { name: variantName, default: isDefault, dark, variables } = variantDetails; - const resolvedVariables = new DerivedVariables(variables, derivedVariables, dark).toVariables(); - Object.assign(variables, resolvedVariables); - const iconVariables = await new IconColorizer(this._platform, icons, variables, location).toVariables(); - Object.assign(variables, resolvedVariables, iconVariables); - const themeDisplayName = `${themeName} ${variantName}`; - if (isDefault) { - const defaultVariant = dark ? defaultDarkVariant : defaultLightVariant; - Object.assign(defaultVariant, { variantName, id: themeId, cssLocation, variables }); + async populateDerivedTheme(manifest: ThemeManifest, log: ILogItem) { + await log.wrap("ThemeBuilder.populateThemeMap", async (l) => { + const {manifest: baseManifest, location} = this._idToManifest.get(manifest.extends!)!; + const {cssLocation, derivedVariables, icons} = this._getSourceData(baseManifest, location, log); + const themeName = manifest.name; + if (!themeName) { + throw new Error(`Theme name not found in manifest!`); + } + let defaultDarkVariant: any = {}, defaultLightVariant: any = {}; + for (const [variant, variantDetails] of Object.entries(manifest.values?.variants!) as [string, any][]) { + try { + const themeId = `${manifest.id}-${variant}`; + const { name: variantName, default: isDefault, dark, variables } = variantDetails; + const resolvedVariables = new DerivedVariables(variables, derivedVariables, dark).toVariables(); + Object.assign(variables, resolvedVariables); + const iconVariables = await new IconColorizer(this._platform, icons, variables, location).toVariables(); + Object.assign(variables, resolvedVariables, iconVariables); + const themeDisplayName = `${themeName} ${variantName}`; + if (isDefault) { + const defaultVariant = dark ? defaultDarkVariant : defaultLightVariant; + Object.assign(defaultVariant, { variantName, id: themeId, cssLocation, variables }); + continue; + } + this._themeMapping[themeDisplayName] = { cssLocation, id: themeId, variables: variables, }; + } + catch (e) { + console.error(e); continue; } - this._themeMapping[themeDisplayName] = { cssLocation, id: themeId, variables: variables, }; } - catch (e) { - console.error(e); - continue; + if (defaultDarkVariant.id && defaultLightVariant.id) { + const defaultVariant = this._preferredColorScheme === ColorSchemePreference.Dark ? defaultDarkVariant : defaultLightVariant; + this._themeMapping[themeName] = { dark: defaultDarkVariant, light: defaultLightVariant, default: defaultVariant }; } - } - if (defaultDarkVariant.id && defaultLightVariant.id) { - const defaultVariant = this._preferredColorScheme === ColorSchemePreference.Dark ? defaultDarkVariant : defaultLightVariant; - this._themeMapping[themeName] = { dark: defaultDarkVariant, light: defaultLightVariant, default: defaultVariant }; - } - else { - const variant = defaultDarkVariant.id ? defaultDarkVariant : defaultLightVariant; - this._themeMapping[`${themeName} ${variant.variantName}`] = { id: variant.id, cssLocation: variant.cssLocation }; - } + else { + const variant = defaultDarkVariant.id ? defaultDarkVariant : defaultLightVariant; + this._themeMapping[`${themeName} ${variant.variantName}`] = { id: variant.id, cssLocation: variant.cssLocation }; + } + }); } - private _getsourceData(manifest: ThemeManifest, location: string) { - const runtimeCSSLocation = manifest.source?.["runtime-asset"]; - if (!runtimeCSSLocation) { - throw new Error(`Run-time asset not found in source section for theme at ${location}`); - } - const cssLocation = new URL(runtimeCSSLocation, new URL(location, window.location.origin)).href; - const derivedVariables = manifest.source?.["derived-variables"]; - if (!derivedVariables) { - throw new Error(`Derived variables not found in source section for theme at ${location}`); - } - const icons = manifest.source?.["icon"]; - if (!icons) { - throw new Error(`Icon mapping not found in source section for theme at ${location}`); - } - return { cssLocation, derivedVariables, icons }; + private _getSourceData(manifest: ThemeManifest, location: string, log:ILogItem) { + return log.wrap("getSourceData", () => { + const runtimeCSSLocation = manifest.source?.["runtime-asset"]; + if (!runtimeCSSLocation) { + throw new Error(`Run-time asset not found in source section for theme at ${location}`); + } + const cssLocation = new URL(runtimeCSSLocation, new URL(location, window.location.origin)).href; + const derivedVariables = manifest.source?.["derived-variables"]; + if (!derivedVariables) { + throw new Error(`Derived variables not found in source section for theme at ${location}`); + } + const icons = manifest.source?.["icon"]; + if (!icons) { + throw new Error(`Icon mapping not found in source section for theme at ${location}`); + } + return { cssLocation, derivedVariables, icons }; + }); } get themeMapping() { diff --git a/src/platform/web/ThemeLoader.ts b/src/platform/web/ThemeLoader.ts index c04d8e8c..da5f9658 100644 --- a/src/platform/web/ThemeLoader.ts +++ b/src/platform/web/ThemeLoader.ts @@ -64,7 +64,7 @@ export class ThemeLoader { const { body } = results[i]; try { if (body.extends) { - await this._themeBuilder.populateDerivedTheme(body); + await this._themeBuilder.populateDerivedTheme(body, log); } else { this._populateThemeMap(body, manifestLocations[i], log); @@ -183,6 +183,7 @@ export class ThemeLoader { } this._platform.replaceStylesheet(cssLocation); if (variables) { + log?.log({l: "Derived Theme", variables}); this._themeBuilder.injectCSSVariables(variables); } else { From 9bdf9c500b60d5a1697f07223845228626012b31 Mon Sep 17 00:00:00 2001 From: RMidhunSuresh Date: Sun, 17 Jul 2022 21:07:04 +0530 Subject: [PATCH 093/226] Add return types --- src/platform/web/ThemeBuilder.ts | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/platform/web/ThemeBuilder.ts b/src/platform/web/ThemeBuilder.ts index 8035fe84..c87e68fb 100644 --- a/src/platform/web/ThemeBuilder.ts +++ b/src/platform/web/ThemeBuilder.ts @@ -34,7 +34,7 @@ export class ThemeBuilder { this._platform = platform; } - async populateDerivedTheme(manifest: ThemeManifest, log: ILogItem) { + async populateDerivedTheme(manifest: ThemeManifest, log: ILogItem): Promise { await log.wrap("ThemeBuilder.populateThemeMap", async (l) => { const {manifest: baseManifest, location} = this._idToManifest.get(manifest.extends!)!; const {cssLocation, derivedVariables, icons} = this._getSourceData(baseManifest, location, log); @@ -75,7 +75,8 @@ export class ThemeBuilder { }); } - private _getSourceData(manifest: ThemeManifest, location: string, log:ILogItem) { + private _getSourceData(manifest: ThemeManifest, location: string, log: ILogItem) + : { cssLocation: string, derivedVariables: string[], icons: Record} { return log.wrap("getSourceData", () => { const runtimeCSSLocation = manifest.source?.["runtime-asset"]; if (!runtimeCSSLocation) { @@ -94,11 +95,11 @@ export class ThemeBuilder { }); } - get themeMapping() { + get themeMapping(): Record { return this._themeMapping; } - injectCSSVariables(variables: Record) { + injectCSSVariables(variables: Record): void { const root = document.documentElement; for (const [variable, value] of Object.entries(variables)) { root.style.setProperty(`--${variable}`, value); @@ -106,7 +107,7 @@ export class ThemeBuilder { this._injectedVariables = variables; } - removePreviousCSSVariables() { + removePreviousCSSVariables(): void { if (!this._injectedVariables) { return; } From b29287c47ee60323b5414bdf60cf696100ed5f41 Mon Sep 17 00:00:00 2001 From: RMidhunSuresh Date: Sun, 17 Jul 2022 22:29:36 +0530 Subject: [PATCH 094/226] await in loop --> Promise.all() --- src/platform/web/ThemeBuilder.ts | 2 +- src/platform/web/ThemeLoader.ts | 9 ++++++--- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/src/platform/web/ThemeBuilder.ts b/src/platform/web/ThemeBuilder.ts index c87e68fb..85d1e336 100644 --- a/src/platform/web/ThemeBuilder.ts +++ b/src/platform/web/ThemeBuilder.ts @@ -35,7 +35,7 @@ export class ThemeBuilder { } async populateDerivedTheme(manifest: ThemeManifest, log: ILogItem): Promise { - await log.wrap("ThemeBuilder.populateThemeMap", async (l) => { + await log.wrap("ThemeBuilder.populateThemeMap", async () => { const {manifest: baseManifest, location} = this._idToManifest.get(manifest.extends!)!; const {cssLocation, derivedVariables, icons} = this._getSourceData(baseManifest, location, log); const themeName = manifest.name; diff --git a/src/platform/web/ThemeLoader.ts b/src/platform/web/ThemeLoader.ts index da5f9658..b899ab5e 100644 --- a/src/platform/web/ThemeLoader.ts +++ b/src/platform/web/ThemeLoader.ts @@ -60,11 +60,13 @@ export class ThemeLoader { ); results.forEach(({ body }, i) => idToManifest.set(body.id, { manifest: body, location: manifestLocations[i] })); this._themeBuilder = new ThemeBuilder(this._platform, idToManifest, this.preferredColorScheme); + const runtimeThemePromises: Promise[] = []; for (let i = 0; i < results.length; ++i) { const { body } = results[i]; try { if (body.extends) { - await this._themeBuilder.populateDerivedTheme(body, log); + const promise = this._themeBuilder.populateDerivedTheme(body, log); + runtimeThemePromises.push(promise); } else { this._populateThemeMap(body, manifestLocations[i], log); @@ -74,8 +76,9 @@ export class ThemeLoader { console.error(e); } } + await Promise.all(runtimeThemePromises); Object.assign(this._themeMapping, this._themeBuilder.themeMapping); - //Add the default-theme as an additional option to the mapping + // Add the default-theme as an additional option to the mapping const defaultThemeId = this.getDefaultTheme(); if (defaultThemeId) { const themeDetails = this._findThemeDetailsFromId(defaultThemeId); @@ -94,7 +97,7 @@ export class ThemeLoader { } private _populateThemeMap(manifest: ThemeManifest, manifestLocation: string, log: ILogItem) { - log.wrap("populateThemeMap", (l) => { + log.wrap("populateThemeMap", () => { /* After build has finished, the source section of each theme manifest contains `built-assets` which is a mapping from the theme-id to From dece42dce3db0dceff48ad1e7810953554622472 Mon Sep 17 00:00:00 2001 From: RMidhunSuresh Date: Mon, 18 Jul 2022 14:55:13 +0530 Subject: [PATCH 095/226] Do not store all the manifests in memory --- src/platform/web/ThemeBuilder.ts | 13 +++++-------- src/platform/web/ThemeLoader.ts | 18 +++++++++++------- 2 files changed, 16 insertions(+), 15 deletions(-) diff --git a/src/platform/web/ThemeBuilder.ts b/src/platform/web/ThemeBuilder.ts index 85d1e336..2ff4108a 100644 --- a/src/platform/web/ThemeBuilder.ts +++ b/src/platform/web/ThemeBuilder.ts @@ -15,29 +15,26 @@ limitations under the License. */ import type {ThemeInformation} from "./ThemeLoader"; import type {Platform} from "./Platform.js"; +import type {ThemeManifest} from "../types/theme"; import {ColorSchemePreference} from "./ThemeLoader"; import {IconColorizer} from "./IconColorizer"; import {DerivedVariables} from "./DerivedVariables"; -import {ThemeManifest} from "../types/theme"; import {ILogItem} from "../../logging/types"; export class ThemeBuilder { - private _idToManifest: Map; private _themeMapping: Record = {}; private _preferredColorScheme?: ColorSchemePreference; private _platform: Platform; private _injectedVariables?: Record; - constructor(platform: Platform, manifestMap: Map, preferredColorScheme?: ColorSchemePreference) { - this._idToManifest = manifestMap; + constructor(platform: Platform, preferredColorScheme?: ColorSchemePreference) { this._preferredColorScheme = preferredColorScheme; this._platform = platform; } - async populateDerivedTheme(manifest: ThemeManifest, log: ILogItem): Promise { + async populateDerivedTheme(manifest: ThemeManifest, baseManifest: ThemeManifest, baseManifestLocation: string, log: ILogItem): Promise { await log.wrap("ThemeBuilder.populateThemeMap", async () => { - const {manifest: baseManifest, location} = this._idToManifest.get(manifest.extends!)!; - const {cssLocation, derivedVariables, icons} = this._getSourceData(baseManifest, location, log); + const {cssLocation, derivedVariables, icons} = this._getSourceData(baseManifest, baseManifestLocation, log); const themeName = manifest.name; if (!themeName) { throw new Error(`Theme name not found in manifest!`); @@ -49,7 +46,7 @@ export class ThemeBuilder { const { name: variantName, default: isDefault, dark, variables } = variantDetails; const resolvedVariables = new DerivedVariables(variables, derivedVariables, dark).toVariables(); Object.assign(variables, resolvedVariables); - const iconVariables = await new IconColorizer(this._platform, icons, variables, location).toVariables(); + const iconVariables = await new IconColorizer(this._platform, icons, variables, baseManifestLocation).toVariables(); Object.assign(variables, resolvedVariables, iconVariables); const themeDisplayName = `${themeName} ${variantName}`; if (isDefault) { diff --git a/src/platform/web/ThemeLoader.ts b/src/platform/web/ThemeLoader.ts index b899ab5e..81114a54 100644 --- a/src/platform/web/ThemeLoader.ts +++ b/src/platform/web/ThemeLoader.ts @@ -15,7 +15,7 @@ limitations under the License. */ import type {ILogItem} from "../../logging/types"; -import { ThemeManifest } from "../types/theme"; +import type {ThemeManifest} from "../types/theme"; import type {Platform} from "./Platform.js"; import {ThemeBuilder} from "./ThemeBuilder"; @@ -52,20 +52,24 @@ export class ThemeLoader { } async init(manifestLocations: string[], log?: ILogItem): Promise { - const idToManifest = new Map(); await this._platform.logger.wrapOrRun(log, "ThemeLoader.init", async (log) => { this._themeMapping = {}; const results = await Promise.all( - manifestLocations.map( location => this._platform.request(location, { method: "GET", format: "json", cache: true, }).response()) + manifestLocations.map(location => this._platform.request(location, { method: "GET", format: "json", cache: true, }).response()) ); - results.forEach(({ body }, i) => idToManifest.set(body.id, { manifest: body, location: manifestLocations[i] })); - this._themeBuilder = new ThemeBuilder(this._platform, idToManifest, this.preferredColorScheme); + this._themeBuilder = new ThemeBuilder(this._platform, this.preferredColorScheme); const runtimeThemePromises: Promise[] = []; for (let i = 0; i < results.length; ++i) { const { body } = results[i]; try { if (body.extends) { - const promise = this._themeBuilder.populateDerivedTheme(body, log); + const indexOfBaseManifest = results.findIndex(manifest => manifest.body.id === body.extends); + if (indexOfBaseManifest === -1) { + throw new Error(`Base manifest for derived theme at ${manifestLocations[i]} not found!`); + } + const {body: baseManifest} = results[indexOfBaseManifest]; + const baseManifestLocation = manifestLocations[indexOfBaseManifest]; + const promise = this._themeBuilder.populateDerivedTheme(body, baseManifest, baseManifestLocation, log); runtimeThemePromises.push(promise); } else { @@ -97,7 +101,7 @@ export class ThemeLoader { } private _populateThemeMap(manifest: ThemeManifest, manifestLocation: string, log: ILogItem) { - log.wrap("populateThemeMap", () => { + log.wrap("ThemeLoader.populateThemeMap", () => { /* After build has finished, the source section of each theme manifest contains `built-assets` which is a mapping from the theme-id to From 081de5afa81f5eb39b8f997c5e45febc19262677 Mon Sep 17 00:00:00 2001 From: RMidhunSuresh Date: Tue, 19 Jul 2022 14:52:53 +0530 Subject: [PATCH 096/226] .js --> .mjs --- scripts/postcss/svg-builder.mjs | 2 +- scripts/postcss/{svg-colorizer.js => svg-colorizer.mjs} | 0 src/platform/web/IconColorizer.ts | 2 +- 3 files changed, 2 insertions(+), 2 deletions(-) rename scripts/postcss/{svg-colorizer.js => svg-colorizer.mjs} (100%) diff --git a/scripts/postcss/svg-builder.mjs b/scripts/postcss/svg-builder.mjs index 1bbc4010..ec94f1e5 100644 --- a/scripts/postcss/svg-builder.mjs +++ b/scripts/postcss/svg-builder.mjs @@ -17,7 +17,7 @@ limitations under the License. import {readFileSync, mkdirSync, writeFileSync} from "fs"; import {resolve} from "path"; import {h32} from "xxhashjs"; -import {getColoredSvgString} from "./svg-colorizer.js"; +import {getColoredSvgString} from "./svg-colorizer.mjs"; function createHash(content) { const hasher = new h32(0); diff --git a/scripts/postcss/svg-colorizer.js b/scripts/postcss/svg-colorizer.mjs similarity index 100% rename from scripts/postcss/svg-colorizer.js rename to scripts/postcss/svg-colorizer.mjs diff --git a/src/platform/web/IconColorizer.ts b/src/platform/web/IconColorizer.ts index 81644603..8baf63b0 100644 --- a/src/platform/web/IconColorizer.ts +++ b/src/platform/web/IconColorizer.ts @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ import type {Platform} from "./Platform.js"; -import {getColoredSvgString} from "../../../scripts/postcss/svg-colorizer.js"; +import {getColoredSvgString} from "../../../scripts/postcss/svg-colorizer.mjs"; type ParsedStructure = { [variableName: string]: { From 07db5450b7be6e22c0db3d1f9b0400fa02424a03 Mon Sep 17 00:00:00 2001 From: RMidhunSuresh Date: Tue, 19 Jul 2022 14:55:42 +0530 Subject: [PATCH 097/226] Aliases can also be derived --- src/platform/web/DerivedVariables.ts | 75 ++++++++++++++++++++++------ 1 file changed, 61 insertions(+), 14 deletions(-) diff --git a/src/platform/web/DerivedVariables.ts b/src/platform/web/DerivedVariables.ts index 4e3a8b46..3ace908c 100644 --- a/src/platform/web/DerivedVariables.ts +++ b/src/platform/web/DerivedVariables.ts @@ -19,6 +19,8 @@ export class DerivedVariables { private _baseVariables: Record; private _variablesToDerive: string[] private _isDark: boolean + private _aliases: Record = {}; + private _derivedAliases: string[] = []; constructor(baseVariables: Record, variablesToDerive: string[], isDark: boolean) { this._baseVariables = baseVariables; @@ -27,28 +29,73 @@ export class DerivedVariables { } toVariables(): Record { - const aliases: any = {}; const resolvedVariables: any = {}; - const RE_VARIABLE_VALUE = /(.+)--(.+)-(.+)/; + this._detectAliases(); + for (const variable of this._variablesToDerive) { + const resolvedValue = this._derive(variable); + if (resolvedValue) { + resolvedVariables[variable] = resolvedValue; + } + } + for (const [alias, variable] of Object.entries(this._aliases) as any) { + resolvedVariables[alias] = this._baseVariables[variable] ?? resolvedVariables[variable]; + } + for (const variable of this._derivedAliases) { + const resolvedValue = this._deriveAlias(variable, resolvedVariables); + if (resolvedValue) { + resolvedVariables[variable] = resolvedValue; + } + } + return resolvedVariables; + } + + private _detectAliases() { + const newVariablesToDerive: string[] = []; for (const variable of this._variablesToDerive) { // If this is an alias, store it for processing later const [alias, value] = variable.split("="); if (value) { - aliases[alias] = value; - continue; + this._aliases[alias] = value; } - // Resolve derived variables - const matches = variable.match(RE_VARIABLE_VALUE); - if (matches) { - const [, baseVariable, operation, argument] = matches; - const value = this._baseVariables[baseVariable]; - const resolvedValue = derive(value, operation, argument, this._isDark); - resolvedVariables[variable] = resolvedValue; + else { + newVariablesToDerive.push(variable); } } - for (const [alias, variable] of Object.entries(aliases) as any) { - resolvedVariables[alias] = this._baseVariables[variable] ?? resolvedVariables[variable]; + this._variablesToDerive = newVariablesToDerive; + } + + private _derive(variable: string): string | undefined { + const RE_VARIABLE_VALUE = /(.+)--(.+)-(.+)/; + const matches = variable.match(RE_VARIABLE_VALUE); + if (matches) { + const [, baseVariable, operation, argument] = matches; + const value = this._baseVariables[baseVariable]; + if (!value ) { + if (this._aliases[baseVariable]) { + this._derivedAliases.push(variable); + return; + } + else { + throw new Error(`Cannot find value for base variable "${baseVariable}"!`); + } + } + const resolvedValue = derive(value, operation, argument, this._isDark); + return resolvedValue; + } + } + + + private _deriveAlias(variable: string, resolvedVariables: Record): string | undefined { + const RE_VARIABLE_VALUE = /(.+)--(.+)-(.+)/; + const matches = variable.match(RE_VARIABLE_VALUE); + if (matches) { + const [, baseVariable, operation, argument] = matches; + const value = resolvedVariables[baseVariable]; + if (!value ) { + throw new Error(`Cannot find value for alias "${baseVariable}" when trying to derive ${variable}!`); + } + const resolvedValue = derive(value, operation, argument, this._isDark); + return resolvedValue; } - return resolvedVariables; } } From 7a1591e0ce87c8a1aae63cc89f615ee1d0bce150 Mon Sep 17 00:00:00 2001 From: RMidhunSuresh Date: Tue, 19 Jul 2022 15:05:07 +0530 Subject: [PATCH 098/226] Move code --- scripts/postcss/svg-builder.mjs | 2 +- src/platform/web/Platform.js | 2 +- src/platform/web/{ => theming}/DerivedVariables.ts | 4 ++-- src/platform/web/{ => theming}/IconColorizer.ts | 4 ++-- src/platform/web/{ => theming}/ThemeBuilder.ts | 6 +++--- src/platform/web/{ => theming}/ThemeLoader.ts | 0 .../postcss => src/platform/web/theming/actions}/color.mjs | 0 .../platform/web/theming/actions}/svg-colorizer.mjs | 0 vite.common-config.js | 2 +- 9 files changed, 10 insertions(+), 10 deletions(-) rename src/platform/web/{ => theming}/DerivedVariables.ts (98%) rename src/platform/web/{ => theming}/IconColorizer.ts (96%) rename src/platform/web/{ => theming}/ThemeBuilder.ts (97%) rename src/platform/web/{ => theming}/ThemeLoader.ts (100%) rename {scripts/postcss => src/platform/web/theming/actions}/color.mjs (100%) rename {scripts/postcss => src/platform/web/theming/actions}/svg-colorizer.mjs (100%) diff --git a/scripts/postcss/svg-builder.mjs b/scripts/postcss/svg-builder.mjs index ec94f1e5..6ef04860 100644 --- a/scripts/postcss/svg-builder.mjs +++ b/scripts/postcss/svg-builder.mjs @@ -17,7 +17,7 @@ limitations under the License. import {readFileSync, mkdirSync, writeFileSync} from "fs"; import {resolve} from "path"; import {h32} from "xxhashjs"; -import {getColoredSvgString} from "./svg-colorizer.mjs"; +import {getColoredSvgString} from "../../src/platform/web/theming/actions/svg-colorizer.mjs"; function createHash(content) { const hasher = new h32(0); diff --git a/src/platform/web/Platform.js b/src/platform/web/Platform.js index c2eef17e..15923a86 100644 --- a/src/platform/web/Platform.js +++ b/src/platform/web/Platform.js @@ -38,7 +38,7 @@ import {downloadInIframe} from "./dom/download.js"; import {Disposables} from "../../utils/Disposables"; import {parseHTML} from "./parsehtml.js"; import {handleAvatarError} from "./ui/avatar"; -import {ThemeLoader} from "./ThemeLoader"; +import {ThemeLoader} from "./theming/ThemeLoader"; function addScript(src) { return new Promise(function (resolve, reject) { diff --git a/src/platform/web/DerivedVariables.ts b/src/platform/web/theming/DerivedVariables.ts similarity index 98% rename from src/platform/web/DerivedVariables.ts rename to src/platform/web/theming/DerivedVariables.ts index 3ace908c..be48a989 100644 --- a/src/platform/web/DerivedVariables.ts +++ b/src/platform/web/theming/DerivedVariables.ts @@ -1,6 +1,6 @@ /* 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 @@ -13,7 +13,7 @@ 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 {derive} from "../../../scripts/postcss/color.mjs"; +import {derive} from "./actions/color.mjs"; export class DerivedVariables { private _baseVariables: Record; diff --git a/src/platform/web/IconColorizer.ts b/src/platform/web/theming/IconColorizer.ts similarity index 96% rename from src/platform/web/IconColorizer.ts rename to src/platform/web/theming/IconColorizer.ts index 8baf63b0..82a9fd45 100644 --- a/src/platform/web/IconColorizer.ts +++ b/src/platform/web/theming/IconColorizer.ts @@ -13,8 +13,8 @@ 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 type {Platform} from "./Platform.js"; -import {getColoredSvgString} from "../../../scripts/postcss/svg-colorizer.mjs"; +import type {Platform} from "../Platform.js"; +import {getColoredSvgString} from "./actions/svg-colorizer.mjs"; type ParsedStructure = { [variableName: string]: { diff --git a/src/platform/web/ThemeBuilder.ts b/src/platform/web/theming/ThemeBuilder.ts similarity index 97% rename from src/platform/web/ThemeBuilder.ts rename to src/platform/web/theming/ThemeBuilder.ts index 2ff4108a..d086b6fc 100644 --- a/src/platform/web/ThemeBuilder.ts +++ b/src/platform/web/theming/ThemeBuilder.ts @@ -14,12 +14,12 @@ See the License for the specific language governing permissions and limitations under the License. */ import type {ThemeInformation} from "./ThemeLoader"; -import type {Platform} from "./Platform.js"; -import type {ThemeManifest} from "../types/theme"; +import type {Platform} from "../Platform.js"; +import type {ThemeManifest} from "../../types/theme"; import {ColorSchemePreference} from "./ThemeLoader"; import {IconColorizer} from "./IconColorizer"; import {DerivedVariables} from "./DerivedVariables"; -import {ILogItem} from "../../logging/types"; +import {ILogItem} from "../../../logging/types"; export class ThemeBuilder { private _themeMapping: Record = {}; diff --git a/src/platform/web/ThemeLoader.ts b/src/platform/web/theming/ThemeLoader.ts similarity index 100% rename from src/platform/web/ThemeLoader.ts rename to src/platform/web/theming/ThemeLoader.ts diff --git a/scripts/postcss/color.mjs b/src/platform/web/theming/actions/color.mjs similarity index 100% rename from scripts/postcss/color.mjs rename to src/platform/web/theming/actions/color.mjs diff --git a/scripts/postcss/svg-colorizer.mjs b/src/platform/web/theming/actions/svg-colorizer.mjs similarity index 100% rename from scripts/postcss/svg-colorizer.mjs rename to src/platform/web/theming/actions/svg-colorizer.mjs diff --git a/vite.common-config.js b/vite.common-config.js index bcf17115..de464941 100644 --- a/vite.common-config.js +++ b/vite.common-config.js @@ -9,7 +9,7 @@ const manifest = require("./package.json"); const version = manifest.version; const compiledVariables = new Map(); import {buildColorizedSVG as replacer} from "./scripts/postcss/svg-builder.mjs"; -import {derive} from "./scripts/postcss/color.mjs"; +import {derive} from "./src/platform/web/theming/actions/color.mjs"; const commonOptions = { logLevel: "warn", From 83b5d3b68e6ee7e497bbdf0e1c407f2be03716d8 Mon Sep 17 00:00:00 2001 From: RMidhunSuresh Date: Tue, 19 Jul 2022 15:11:05 +0530 Subject: [PATCH 099/226] Change directory name --- scripts/postcss/svg-builder.mjs | 2 +- src/platform/web/theming/DerivedVariables.ts | 2 +- src/platform/web/theming/IconColorizer.ts | 2 +- src/platform/web/theming/{actions => shared}/color.mjs | 0 src/platform/web/theming/{actions => shared}/svg-colorizer.mjs | 0 vite.common-config.js | 2 +- 6 files changed, 4 insertions(+), 4 deletions(-) rename src/platform/web/theming/{actions => shared}/color.mjs (100%) rename src/platform/web/theming/{actions => shared}/svg-colorizer.mjs (100%) diff --git a/scripts/postcss/svg-builder.mjs b/scripts/postcss/svg-builder.mjs index 6ef04860..3efff4ae 100644 --- a/scripts/postcss/svg-builder.mjs +++ b/scripts/postcss/svg-builder.mjs @@ -17,7 +17,7 @@ limitations under the License. import {readFileSync, mkdirSync, writeFileSync} from "fs"; import {resolve} from "path"; import {h32} from "xxhashjs"; -import {getColoredSvgString} from "../../src/platform/web/theming/actions/svg-colorizer.mjs"; +import {getColoredSvgString} from "../../src/platform/web/theming/shared/svg-colorizer.mjs"; function createHash(content) { const hasher = new h32(0); diff --git a/src/platform/web/theming/DerivedVariables.ts b/src/platform/web/theming/DerivedVariables.ts index be48a989..619bab94 100644 --- a/src/platform/web/theming/DerivedVariables.ts +++ b/src/platform/web/theming/DerivedVariables.ts @@ -13,7 +13,7 @@ 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 {derive} from "./actions/color.mjs"; +import {derive} from "./shared/color.mjs"; export class DerivedVariables { private _baseVariables: Record; diff --git a/src/platform/web/theming/IconColorizer.ts b/src/platform/web/theming/IconColorizer.ts index 82a9fd45..e02c0971 100644 --- a/src/platform/web/theming/IconColorizer.ts +++ b/src/platform/web/theming/IconColorizer.ts @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ import type {Platform} from "../Platform.js"; -import {getColoredSvgString} from "./actions/svg-colorizer.mjs"; +import {getColoredSvgString} from "./shared/svg-colorizer.mjs"; type ParsedStructure = { [variableName: string]: { diff --git a/src/platform/web/theming/actions/color.mjs b/src/platform/web/theming/shared/color.mjs similarity index 100% rename from src/platform/web/theming/actions/color.mjs rename to src/platform/web/theming/shared/color.mjs diff --git a/src/platform/web/theming/actions/svg-colorizer.mjs b/src/platform/web/theming/shared/svg-colorizer.mjs similarity index 100% rename from src/platform/web/theming/actions/svg-colorizer.mjs rename to src/platform/web/theming/shared/svg-colorizer.mjs diff --git a/vite.common-config.js b/vite.common-config.js index de464941..2fa09d46 100644 --- a/vite.common-config.js +++ b/vite.common-config.js @@ -9,7 +9,7 @@ const manifest = require("./package.json"); const version = manifest.version; const compiledVariables = new Map(); import {buildColorizedSVG as replacer} from "./scripts/postcss/svg-builder.mjs"; -import {derive} from "./src/platform/web/theming/actions/color.mjs"; +import {derive} from "./src/platform/web/theming/shared/color.mjs"; const commonOptions = { logLevel: "warn", From e1ee2586306ef84c09f91e4770da78921f05c236 Mon Sep 17 00:00:00 2001 From: RMidhunSuresh Date: Tue, 19 Jul 2022 16:00:58 +0530 Subject: [PATCH 100/226] Change path --- src/platform/web/theming/ThemeLoader.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/platform/web/theming/ThemeLoader.ts b/src/platform/web/theming/ThemeLoader.ts index 81114a54..1c066577 100644 --- a/src/platform/web/theming/ThemeLoader.ts +++ b/src/platform/web/theming/ThemeLoader.ts @@ -14,9 +14,9 @@ See the License for the specific language governing permissions and limitations under the License. */ -import type {ILogItem} from "../../logging/types"; -import type {ThemeManifest} from "../types/theme"; -import type {Platform} from "./Platform.js"; +import type {ILogItem} from "../../../logging/types"; +import type {ThemeManifest} from "../../types/theme"; +import type {Platform} from "../Platform.js"; import {ThemeBuilder} from "./ThemeBuilder"; type NormalVariant = { From ecb3a66dfcb9c23ca25fde50582c00f7d6052483 Mon Sep 17 00:00:00 2001 From: RMidhunSuresh Date: Tue, 19 Jul 2022 17:33:06 +0530 Subject: [PATCH 101/226] WIP --- src/platform/web/theming/ThemeLoader.ts | 169 ++++++------------ .../web/theming/parsers/BuiltThemeParser.ts | 106 +++++++++++ .../RuntimeThemeParser.ts} | 37 +--- src/platform/web/theming/parsers/types.ts | 38 ++++ 4 files changed, 203 insertions(+), 147 deletions(-) create mode 100644 src/platform/web/theming/parsers/BuiltThemeParser.ts rename src/platform/web/theming/{ThemeBuilder.ts => parsers/RuntimeThemeParser.ts} (78%) create mode 100644 src/platform/web/theming/parsers/types.ts diff --git a/src/platform/web/theming/ThemeLoader.ts b/src/platform/web/theming/ThemeLoader.ts index 1c066577..10717975 100644 --- a/src/platform/web/theming/ThemeLoader.ts +++ b/src/platform/web/theming/ThemeLoader.ts @@ -15,37 +15,16 @@ limitations under the License. */ import type {ILogItem} from "../../../logging/types"; -import type {ThemeManifest} from "../../types/theme"; import type {Platform} from "../Platform.js"; -import {ThemeBuilder} from "./ThemeBuilder"; - -type NormalVariant = { - id: string; - cssLocation: string; - variables?: any; -}; - -type Variant = NormalVariant & { - variantName: string; -}; - -type DefaultVariant = { - dark: Variant; - light: Variant; - default: Variant; -} - -export type ThemeInformation = NormalVariant | DefaultVariant; - -export enum ColorSchemePreference { - Dark, - Light -}; +import {RuntimeThemeParser} from "./parsers/RuntimeThemeParser"; +import type {Variant, ThemeInformation} from "./parsers/types"; +import {ColorSchemePreference} from "./parsers/types"; +import { BuiltThemeParser } from "./parsers/BuiltThemeParser"; export class ThemeLoader { private _platform: Platform; private _themeMapping: Record; - private _themeBuilder: ThemeBuilder; + private _injectedVariables?: Record; constructor(platform: Platform) { this._platform = platform; @@ -53,11 +32,11 @@ export class ThemeLoader { async init(manifestLocations: string[], log?: ILogItem): Promise { await this._platform.logger.wrapOrRun(log, "ThemeLoader.init", async (log) => { - this._themeMapping = {}; const results = await Promise.all( manifestLocations.map(location => this._platform.request(location, { method: "GET", format: "json", cache: true, }).response()) ); - this._themeBuilder = new ThemeBuilder(this._platform, this.preferredColorScheme); + const runtimeThemeParser = new RuntimeThemeParser(this._platform, this.preferredColorScheme); + const builtThemeParser = new BuiltThemeParser(this.preferredColorScheme); const runtimeThemePromises: Promise[] = []; for (let i = 0; i < results.length; ++i) { const { body } = results[i]; @@ -69,11 +48,11 @@ export class ThemeLoader { } const {body: baseManifest} = results[indexOfBaseManifest]; const baseManifestLocation = manifestLocations[indexOfBaseManifest]; - const promise = this._themeBuilder.populateDerivedTheme(body, baseManifest, baseManifestLocation, log); + const promise = runtimeThemeParser.parse(body, baseManifest, baseManifestLocation, log); runtimeThemePromises.push(promise); } else { - this._populateThemeMap(body, manifestLocations[i], log); + builtThemeParser.parse(body, manifestLocations[i], log); } } catch(e) { @@ -81,98 +60,14 @@ export class ThemeLoader { } } await Promise.all(runtimeThemePromises); - Object.assign(this._themeMapping, this._themeBuilder.themeMapping); - // Add the default-theme as an additional option to the mapping - const defaultThemeId = this.getDefaultTheme(); - if (defaultThemeId) { - const themeDetails = this._findThemeDetailsFromId(defaultThemeId); - if (themeDetails) { - this._themeMapping["Default"] = { id: "default", cssLocation: themeDetails.themeData.cssLocation! }; - const variables = themeDetails.themeData.variables; - if (variables) { - this._themeMapping["Default"].variables = variables; - } - } - } - log.log({ l: "Default Theme", theme: defaultThemeId}); + this._themeMapping = { ...builtThemeParser.themeMapping, ...runtimeThemeParser.themeMapping }; + Object.assign(this._themeMapping, builtThemeParser.themeMapping, runtimeThemeParser.themeMapping); + this._addDefaultThemeToMapping(log); log.log({ l: "Preferred colorscheme", scheme: this.preferredColorScheme === ColorSchemePreference.Dark ? "dark" : "light" }); log.log({ l: "Result", themeMapping: this._themeMapping }); }); } - private _populateThemeMap(manifest: ThemeManifest, manifestLocation: string, log: ILogItem) { - log.wrap("ThemeLoader.populateThemeMap", () => { - /* - After build has finished, the source section of each theme manifest - contains `built-assets` which is a mapping from the theme-id to - cssLocation of theme - */ - const builtAssets: Record = manifest.source?.["built-assets"]; - const themeName = manifest.name; - if (!themeName) { - throw new Error(`Theme name not found in manifest at ${manifestLocation}`); - } - let defaultDarkVariant: any = {}, defaultLightVariant: any = {}; - for (let [themeId, cssLocation] of Object.entries(builtAssets)) { - try { - /** - * This cssLocation is relative to the location of the manifest file. - * So we first need to resolve it relative to the root of this hydrogen instance. - */ - cssLocation = new URL(cssLocation, new URL(manifestLocation, window.location.origin)).href; - } - catch { - continue; - } - const variant = themeId.match(/.+-(.+)/)?.[1]; - const variantDetails = manifest.values?.variants[variant!]; - if (!variantDetails) { - throw new Error(`Variant ${variant} is missing in manifest at ${manifestLocation}`); - } - const { name: variantName, default: isDefault, dark } = variantDetails; - const themeDisplayName = `${themeName} ${variantName}`; - if (isDefault) { - /** - * This is a default variant! - * We'll add these to the themeMapping (separately) keyed with just the - * theme-name (i.e "Element" instead of "Element Dark"). - * We need to be able to distinguish them from other variants! - * - * This allows us to render radio-buttons with "dark" and - * "light" options. - */ - const defaultVariant = dark ? defaultDarkVariant : defaultLightVariant; - defaultVariant.variantName = variantName; - defaultVariant.id = themeId - defaultVariant.cssLocation = cssLocation; - continue; - } - // Non-default variants are keyed in themeMapping with "theme_name variant_name" - // eg: "Element Dark" - this._themeMapping[themeDisplayName] = { - cssLocation, - id: themeId - }; - } - if (defaultDarkVariant.id && defaultLightVariant.id) { - /** - * As mentioned above, if there's both a default dark and a default light variant, - * add them to themeMapping separately. - */ - const defaultVariant = this.preferredColorScheme === ColorSchemePreference.Dark ? defaultDarkVariant : defaultLightVariant; - this._themeMapping[themeName] = { dark: defaultDarkVariant, light: defaultLightVariant, default: defaultVariant }; - } - else { - /** - * If only one default variant is found (i.e only dark default or light default but not both), - * treat it like any other variant. - */ - const variant = defaultDarkVariant.id ? defaultDarkVariant : defaultLightVariant; - this._themeMapping[`${themeName} ${variant.variantName}`] = { id: variant.id, cssLocation: variant.cssLocation }; - } - }); - } - setTheme(themeName: string, themeVariant?: "light" | "dark" | "default", log?: ILogItem) { this._platform.logger.wrapOrRun(log, { l: "change theme", name: themeName, variant: themeVariant }, () => { let cssLocation: string, variables: Record; @@ -191,10 +86,10 @@ export class ThemeLoader { this._platform.replaceStylesheet(cssLocation); if (variables) { log?.log({l: "Derived Theme", variables}); - this._themeBuilder.injectCSSVariables(variables); + this._injectCSSVariables(variables); } else { - this._themeBuilder.removePreviousCSSVariables(); + this._removePreviousCSSVariables(); } this._platform.settingsStorage.setString("theme-name", themeName); if (themeVariant) { @@ -206,6 +101,25 @@ export class ThemeLoader { }); } + private _injectCSSVariables(variables: Record): void { + const root = document.documentElement; + for (const [variable, value] of Object.entries(variables)) { + root.style.setProperty(`--${variable}`, value); + } + this._injectedVariables = variables; + } + + private _removePreviousCSSVariables(): void { + if (!this._injectedVariables) { + return; + } + const root = document.documentElement; + for (const variable of Object.keys(this._injectedVariables)) { + root.style.removeProperty(`--${variable}`); + } + this._injectedVariables = undefined; + } + /** Maps theme display name to theme information */ get themeMapping(): Record { return this._themeMapping; @@ -246,6 +160,23 @@ export class ThemeLoader { } } + private _addDefaultThemeToMapping(log: ILogItem) { + log.wrap("addDefaultThemeToMapping", l => { + const defaultThemeId = this.getDefaultTheme(); + if (defaultThemeId) { + const themeDetails = this._findThemeDetailsFromId(defaultThemeId); + if (themeDetails) { + this._themeMapping["Default"] = { id: "default", cssLocation: themeDetails.themeData.cssLocation! }; + const variables = themeDetails.themeData.variables; + if (variables) { + this._themeMapping["Default"].variables = variables; + } + } + } + l.log({ l: "Default Theme", theme: defaultThemeId}); + }); + } + get preferredColorScheme(): ColorSchemePreference | undefined { if (window.matchMedia("(prefers-color-scheme: dark)").matches) { return ColorSchemePreference.Dark; diff --git a/src/platform/web/theming/parsers/BuiltThemeParser.ts b/src/platform/web/theming/parsers/BuiltThemeParser.ts new file mode 100644 index 00000000..fbafadb8 --- /dev/null +++ b/src/platform/web/theming/parsers/BuiltThemeParser.ts @@ -0,0 +1,106 @@ +/* +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 type {ThemeInformation} from "./types"; +import type {ThemeManifest} from "../../../types/theme"; +import type {ILogItem} from "../../../../logging/types"; +import {ColorSchemePreference} from "./types"; + +export class BuiltThemeParser { + private _themeMapping: Record = {}; + private _preferredColorScheme?: ColorSchemePreference; + + constructor(preferredColorScheme?: ColorSchemePreference) { + this._preferredColorScheme = preferredColorScheme; + } + + parse(manifest: ThemeManifest, manifestLocation: string, log: ILogItem) { + log.wrap("BuiltThemeParser.parse", () => { + /* + After build has finished, the source section of each theme manifest + contains `built-assets` which is a mapping from the theme-id to + cssLocation of theme + */ + const builtAssets: Record = manifest.source?.["built-assets"]; + const themeName = manifest.name; + if (!themeName) { + throw new Error(`Theme name not found in manifest at ${manifestLocation}`); + } + let defaultDarkVariant: any = {}, defaultLightVariant: any = {}; + for (let [themeId, cssLocation] of Object.entries(builtAssets)) { + try { + /** + * This cssLocation is relative to the location of the manifest file. + * So we first need to resolve it relative to the root of this hydrogen instance. + */ + cssLocation = new URL(cssLocation, new URL(manifestLocation, window.location.origin)).href; + } + catch { + continue; + } + const variant = themeId.match(/.+-(.+)/)?.[1]; + const variantDetails = manifest.values?.variants[variant!]; + if (!variantDetails) { + throw new Error(`Variant ${variant} is missing in manifest at ${manifestLocation}`); + } + const { name: variantName, default: isDefault, dark } = variantDetails; + const themeDisplayName = `${themeName} ${variantName}`; + if (isDefault) { + /** + * This is a default variant! + * We'll add these to the themeMapping (separately) keyed with just the + * theme-name (i.e "Element" instead of "Element Dark"). + * We need to be able to distinguish them from other variants! + * + * This allows us to render radio-buttons with "dark" and + * "light" options. + */ + const defaultVariant = dark ? defaultDarkVariant : defaultLightVariant; + defaultVariant.variantName = variantName; + defaultVariant.id = themeId + defaultVariant.cssLocation = cssLocation; + continue; + } + // Non-default variants are keyed in themeMapping with "theme_name variant_name" + // eg: "Element Dark" + this._themeMapping[themeDisplayName] = { + cssLocation, + id: themeId + }; + } + if (defaultDarkVariant.id && defaultLightVariant.id) { + /** + * As mentioned above, if there's both a default dark and a default light variant, + * add them to themeMapping separately. + */ + const defaultVariant = this._preferredColorScheme === ColorSchemePreference.Dark ? defaultDarkVariant : defaultLightVariant; + this._themeMapping[themeName] = { dark: defaultDarkVariant, light: defaultLightVariant, default: defaultVariant }; + } + else { + /** + * If only one default variant is found (i.e only dark default or light default but not both), + * treat it like any other variant. + */ + const variant = defaultDarkVariant.id ? defaultDarkVariant : defaultLightVariant; + this._themeMapping[`${themeName} ${variant.variantName}`] = { id: variant.id, cssLocation: variant.cssLocation }; + } + }); + } + + get themeMapping(): Record { + return this._themeMapping; + } +} diff --git a/src/platform/web/theming/ThemeBuilder.ts b/src/platform/web/theming/parsers/RuntimeThemeParser.ts similarity index 78% rename from src/platform/web/theming/ThemeBuilder.ts rename to src/platform/web/theming/parsers/RuntimeThemeParser.ts index d086b6fc..2ab970b6 100644 --- a/src/platform/web/theming/ThemeBuilder.ts +++ b/src/platform/web/theming/parsers/RuntimeThemeParser.ts @@ -13,26 +13,25 @@ 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 type {ThemeInformation} from "./ThemeLoader"; -import type {Platform} from "../Platform.js"; -import type {ThemeManifest} from "../../types/theme"; -import {ColorSchemePreference} from "./ThemeLoader"; -import {IconColorizer} from "./IconColorizer"; -import {DerivedVariables} from "./DerivedVariables"; -import {ILogItem} from "../../../logging/types"; +import type {ThemeInformation} from "./types"; +import type {Platform} from "../../Platform.js"; +import type {ThemeManifest} from "../../../types/theme"; +import {ColorSchemePreference} from "./types"; +import {IconColorizer} from "../IconColorizer"; +import {DerivedVariables} from "../DerivedVariables"; +import {ILogItem} from "../../../../logging/types"; -export class ThemeBuilder { +export class RuntimeThemeParser { private _themeMapping: Record = {}; private _preferredColorScheme?: ColorSchemePreference; private _platform: Platform; - private _injectedVariables?: Record; constructor(platform: Platform, preferredColorScheme?: ColorSchemePreference) { this._preferredColorScheme = preferredColorScheme; this._platform = platform; } - async populateDerivedTheme(manifest: ThemeManifest, baseManifest: ThemeManifest, baseManifestLocation: string, log: ILogItem): Promise { + async parse(manifest: ThemeManifest, baseManifest: ThemeManifest, baseManifestLocation: string, log: ILogItem): Promise { await log.wrap("ThemeBuilder.populateThemeMap", async () => { const {cssLocation, derivedVariables, icons} = this._getSourceData(baseManifest, baseManifestLocation, log); const themeName = manifest.name; @@ -96,22 +95,4 @@ export class ThemeBuilder { return this._themeMapping; } - injectCSSVariables(variables: Record): void { - const root = document.documentElement; - for (const [variable, value] of Object.entries(variables)) { - root.style.setProperty(`--${variable}`, value); - } - this._injectedVariables = variables; - } - - removePreviousCSSVariables(): void { - if (!this._injectedVariables) { - return; - } - const root = document.documentElement; - for (const variable of Object.keys(this._injectedVariables)) { - root.style.removeProperty(`--${variable}`); - } - this._injectedVariables = undefined; - } } diff --git a/src/platform/web/theming/parsers/types.ts b/src/platform/web/theming/parsers/types.ts new file mode 100644 index 00000000..b357cf2c --- /dev/null +++ b/src/platform/web/theming/parsers/types.ts @@ -0,0 +1,38 @@ +/* +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. +*/ + +export type NormalVariant = { + id: string; + cssLocation: string; + variables?: any; +}; + +export type Variant = NormalVariant & { + variantName: string; +}; + +export type DefaultVariant = { + dark: Variant; + light: Variant; + default: Variant; +} + +export type ThemeInformation = NormalVariant | DefaultVariant; + +export enum ColorSchemePreference { + Dark, + Light +}; From 994667205f851074c71063e5e086c1a48020a334 Mon Sep 17 00:00:00 2001 From: RMidhunSuresh Date: Tue, 19 Jul 2022 19:38:36 +0530 Subject: [PATCH 102/226] Remove change --- tsconfig.json | 1 - 1 file changed, 1 deletion(-) diff --git a/tsconfig.json b/tsconfig.json index 8f591a6e..f46cc7eb 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -5,7 +5,6 @@ "noEmit": true, "target": "ES2020", "module": "ES2020", - "lib": ["ES2021", "DOM"], "moduleResolution": "node", "esModuleInterop": true }, From de02456641404c1e4cbb1987f1d71169c1f7c58e Mon Sep 17 00:00:00 2001 From: RMidhunSuresh Date: Tue, 19 Jul 2022 19:46:36 +0530 Subject: [PATCH 103/226] Add explaining comment --- scripts/build-plugins/rollup-plugin-build-themes.js | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/build-plugins/rollup-plugin-build-themes.js b/scripts/build-plugins/rollup-plugin-build-themes.js index 72053a1f..f44406c6 100644 --- a/scripts/build-plugins/rollup-plugin-build-themes.js +++ b/scripts/build-plugins/rollup-plugin-build-themes.js @@ -343,6 +343,7 @@ module.exports = function buildThemes(options) { const assetHashedName = this.getFileName(ref); nameToAssetHashedLocation[name] = assetHashedName; } + // Update icon section in output manifest with paths to the icon in build output for (const [variable, location] of Object.entries(icon)) { const [locationWithoutQueryParameters, queryParameters] = location.split("?"); const name = path.basename(locationWithoutQueryParameters); From 7ac2c7c7faff035248c1e8d6fdc5ce771b3d3784 Mon Sep 17 00:00:00 2001 From: RMidhunSuresh Date: Tue, 19 Jul 2022 21:06:55 +0530 Subject: [PATCH 104/226] Get tests to work --- src/platform/web/theming/shared/color.mjs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/platform/web/theming/shared/color.mjs b/src/platform/web/theming/shared/color.mjs index bd2ea3ea..e777bfac 100644 --- a/src/platform/web/theming/shared/color.mjs +++ b/src/platform/web/theming/shared/color.mjs @@ -13,7 +13,8 @@ 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 {offColor} from 'off-color'; +import pkg from 'off-color'; +const offColor = pkg.offColor; export function derive(value, operation, argument, isDark) { const argumentAsNumber = parseInt(argument); From 8aa96e80310eb431f5cca14affaaf1db38f280c4 Mon Sep 17 00:00:00 2001 From: RMidhunSuresh Date: Tue, 19 Jul 2022 21:19:22 +0530 Subject: [PATCH 105/226] Update log label --- src/platform/web/theming/parsers/RuntimeThemeParser.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/platform/web/theming/parsers/RuntimeThemeParser.ts b/src/platform/web/theming/parsers/RuntimeThemeParser.ts index 2ab970b6..9471740a 100644 --- a/src/platform/web/theming/parsers/RuntimeThemeParser.ts +++ b/src/platform/web/theming/parsers/RuntimeThemeParser.ts @@ -32,7 +32,7 @@ export class RuntimeThemeParser { } async parse(manifest: ThemeManifest, baseManifest: ThemeManifest, baseManifestLocation: string, log: ILogItem): Promise { - await log.wrap("ThemeBuilder.populateThemeMap", async () => { + await log.wrap("RuntimeThemeParser.parse", async () => { const {cssLocation, derivedVariables, icons} = this._getSourceData(baseManifest, baseManifestLocation, log); const themeName = manifest.name; if (!themeName) { From 612b878793ca608a408b70ed3faa34dfe4a911d2 Mon Sep 17 00:00:00 2001 From: RMidhunSuresh Date: Tue, 19 Jul 2022 21:21:35 +0530 Subject: [PATCH 106/226] Update theme name --- theme.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/theme.json b/theme.json index f5fbf3ed..ea7492d4 100644 --- a/theme.json +++ b/theme.json @@ -34,7 +34,7 @@ } }, "red": { - "name": "Red", + "name": "Gruvbox", "variables": { "background-color-primary": "#282828", "background-color-secondary": "#3c3836", From 313e65e00cbc7e2323e87ffbb32081ed21f52ed4 Mon Sep 17 00:00:00 2001 From: RMidhunSuresh Date: Wed, 20 Jul 2022 12:30:41 +0530 Subject: [PATCH 107/226] Write tests --- src/platform/web/theming/DerivedVariables.ts | 32 +++++++++++++++++++- 1 file changed, 31 insertions(+), 1 deletion(-) diff --git a/src/platform/web/theming/DerivedVariables.ts b/src/platform/web/theming/DerivedVariables.ts index 619bab94..e0450e94 100644 --- a/src/platform/web/theming/DerivedVariables.ts +++ b/src/platform/web/theming/DerivedVariables.ts @@ -52,7 +52,6 @@ export class DerivedVariables { private _detectAliases() { const newVariablesToDerive: string[] = []; for (const variable of this._variablesToDerive) { - // If this is an alias, store it for processing later const [alias, value] = variable.split("="); if (value) { this._aliases[alias] = value; @@ -99,3 +98,34 @@ export class DerivedVariables { } } } + +import pkg from "off-color"; +const {offColor} = pkg; + +export function tests() { + return { + "Simple variable derivation": assert => { + const deriver = new DerivedVariables({ "background-color": "#ff00ff" }, ["background-color--darker-5"], false); + const result = deriver.toVariables(); + const resultColor = offColor("#ff00ff").darken(5/100).hex(); + assert.deepEqual(result, {"background-color--darker-5": resultColor}); + }, + + "For dark themes, lighten and darken are inverted": assert => { + const deriver = new DerivedVariables({ "background-color": "#ff00ff" }, ["background-color--darker-5"], true); + const result = deriver.toVariables(); + const resultColor = offColor("#ff00ff").lighten(5/100).hex(); + assert.deepEqual(result, {"background-color--darker-5": resultColor}); + }, + + "Aliases can be derived": assert => { + const deriver = new DerivedVariables({ "background-color": "#ff00ff" }, ["my-awesome-alias=background-color","my-awesome-alias--darker-5"], false); + const result = deriver.toVariables(); + const resultColor = offColor("#ff00ff").darken(5/100).hex(); + assert.deepEqual(result, { + "my-awesome-alias": "#ff00ff", + "my-awesome-alias--darker-5": resultColor, + }); + }, + } +} From 1456e308a836ed397106a05c5644d72652754ac3 Mon Sep 17 00:00:00 2001 From: RMidhunSuresh Date: Wed, 20 Jul 2022 15:36:02 +0530 Subject: [PATCH 108/226] Add type and fix formatting --- src/platform/web/theming/DerivedVariables.ts | 3 +-- src/platform/web/theming/ThemeLoader.ts | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/src/platform/web/theming/DerivedVariables.ts b/src/platform/web/theming/DerivedVariables.ts index e0450e94..5a626bfa 100644 --- a/src/platform/web/theming/DerivedVariables.ts +++ b/src/platform/web/theming/DerivedVariables.ts @@ -49,7 +49,7 @@ export class DerivedVariables { return resolvedVariables; } - private _detectAliases() { + private _detectAliases(): void { const newVariablesToDerive: string[] = []; for (const variable of this._variablesToDerive) { const [alias, value] = variable.split("="); @@ -83,7 +83,6 @@ export class DerivedVariables { } } - private _deriveAlias(variable: string, resolvedVariables: Record): string | undefined { const RE_VARIABLE_VALUE = /(.+)--(.+)-(.+)/; const matches = variable.match(RE_VARIABLE_VALUE); diff --git a/src/platform/web/theming/ThemeLoader.ts b/src/platform/web/theming/ThemeLoader.ts index 10717975..be1bafc0 100644 --- a/src/platform/web/theming/ThemeLoader.ts +++ b/src/platform/web/theming/ThemeLoader.ts @@ -19,7 +19,7 @@ import type {Platform} from "../Platform.js"; import {RuntimeThemeParser} from "./parsers/RuntimeThemeParser"; import type {Variant, ThemeInformation} from "./parsers/types"; import {ColorSchemePreference} from "./parsers/types"; -import { BuiltThemeParser } from "./parsers/BuiltThemeParser"; +import {BuiltThemeParser} from "./parsers/BuiltThemeParser"; export class ThemeLoader { private _platform: Platform; From 7feaa479c0f4c79f012c6690b4fe2ee769f94bb6 Mon Sep 17 00:00:00 2001 From: RMidhunSuresh Date: Wed, 20 Jul 2022 15:55:11 +0530 Subject: [PATCH 109/226] Typescript update to support .mjs files --- package.json | 2 +- tsconfig.json | 2 +- yarn.lock | 8 ++++---- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/package.json b/package.json index dd1a1554..9361098c 100644 --- a/package.json +++ b/package.json @@ -52,7 +52,7 @@ "regenerator-runtime": "^0.13.7", "svgo": "^2.8.0", "text-encoding": "^0.7.0", - "typescript": "^4.3.5", + "typescript": "^4.7.0", "vite": "^2.9.8", "xxhashjs": "^0.2.2" }, diff --git a/tsconfig.json b/tsconfig.json index f46cc7eb..72d9d3ae 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -4,7 +4,7 @@ "noImplicitAny": false, "noEmit": true, "target": "ES2020", - "module": "ES2020", + "module": "Node16", "moduleResolution": "node", "esModuleInterop": true }, diff --git a/yarn.lock b/yarn.lock index 1543f8d0..c48d2719 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1665,10 +1665,10 @@ type-fest@^0.20.2: resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.20.2.tgz#1bf207f4b28f91583666cb5fbd327887301cd5f4" integrity sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ== -typescript@^4.3.5: - version "4.3.5" - resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.3.5.tgz#4d1c37cc16e893973c45a06886b7113234f119f4" - integrity sha512-DqQgihaQ9cUrskJo9kIyW/+g0Vxsk8cDtZ52a3NGh0YNTfpUSArXSohyUGnvbPazEPLu398C0UxmKSOrPumUzA== +typescript@^4.7.0: + version "4.7.4" + resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.7.4.tgz#1a88596d1cf47d59507a1bcdfb5b9dfe4d488235" + integrity sha512-C0WQT0gezHuw6AdY1M2jxUO83Rjf0HP7Sk1DtXj6j1EwkQNZrHAg2XPWlq62oqEhYvONq5pkC2Y9oPljWToLmQ== typeson-registry@^1.0.0-alpha.20: version "1.0.0-alpha.39" From d79e5f7806a2b90c352ce947915dd85fa0597fb0 Mon Sep 17 00:00:00 2001 From: Bruno Windels <274386+bwindels@users.noreply.github.com> Date: Wed, 20 Jul 2022 15:20:23 +0200 Subject: [PATCH 110/226] create key share operations for invitees when history visibility=invited --- src/matrix/e2ee/RoomEncryption.js | 51 +++++++++++++++++-- src/matrix/room/Room.js | 19 ++++--- src/matrix/room/common.ts | 71 +++++++++++++++++++++++++++ src/matrix/room/members/RoomMember.js | 4 ++ 4 files changed, 132 insertions(+), 13 deletions(-) diff --git a/src/matrix/e2ee/RoomEncryption.js b/src/matrix/e2ee/RoomEncryption.js index 80f57507..0ec7212e 100644 --- a/src/matrix/e2ee/RoomEncryption.js +++ b/src/matrix/e2ee/RoomEncryption.js @@ -19,13 +19,23 @@ import {groupEventsBySession} from "./megolm/decryption/utils"; import {mergeMap} from "../../utils/mergeMap"; import {groupBy} from "../../utils/groupBy"; import {makeTxnId} from "../common.js"; +import {iterateResponseStateEvents} from "../room/common"; const ENCRYPTED_TYPE = "m.room.encrypted"; +const ROOM_HISTORY_VISIBILITY_TYPE = "m.room.history_visibility"; // how often ensureMessageKeyIsShared can check if it needs to // create a new outbound session // note that encrypt could still create a new session const MIN_PRESHARE_INTERVAL = 60 * 1000; // 1min +// Use enum when converting to TS +const HistoryVisibility = Object.freeze({ + Joined: "joined", + Invited: "invited", + WorldReadable: "world_readable", + Shared: "shared", +}); + // TODO: this class is a good candidate for splitting up into encryption and decryption, there doesn't seem to be much overlap export class RoomEncryption { constructor({room, deviceTracker, olmEncryption, megolmEncryption, megolmDecryption, encryptionParams, storage, keyBackup, notifyMissingMegolmSession, clock}) { @@ -45,6 +55,7 @@ export class RoomEncryption { this._isFlushingRoomKeyShares = false; this._lastKeyPreShareTime = null; this._keySharePromise = null; + this._historyVisibility = undefined; this._disposed = false; } @@ -77,7 +88,13 @@ export class RoomEncryption { this._senderDeviceCache = new Map(); // purge the sender device cache } - async writeMemberChanges(memberChanges, txn, log) { + async writeSync(roomResponse, memberChanges, txn, log) { + let historyVisibility = this._historyVisibility; + iterateResponseStateEvents(roomResponse, event => { + if(event.state_key === "" && event.type === ROOM_HISTORY_VISIBILITY_TYPE) { + historyVisibility = event?.content?.history_visibility; + } + }); let shouldFlush = false; const memberChangesArray = Array.from(memberChanges.values()); // this also clears our session if we leave the room ourselves @@ -89,10 +106,35 @@ export class RoomEncryption { this._megolmEncryption.discardOutboundSession(this._room.id, txn); } if (memberChangesArray.some(m => m.hasJoined)) { - shouldFlush = await this._addShareRoomKeyOperationForNewMembers(memberChangesArray, txn, log); + const userIds = memberChangesArray.filter(m => m.hasJoined).map(m => m.userId); + shouldFlush = await this._addShareRoomKeyOperationForMembers(userIds, txn, log) + || shouldFlush; } + if (memberChangesArray.some(m => m.wasInvited)) { + historyVisibility = await this._loadHistoryVisibilityIfNeeded(historyVisibility, txn); + if (historyVisibility === HistoryVisibility.Invited) { + const userIds = memberChangesArray.filter(m => m.wasInvited).map(m => m.userId); + shouldFlush = await this._addShareRoomKeyOperationForMembers(userIds, txn, log) + || shouldFlush; + } + } + await this._deviceTracker.writeMemberChanges(this._room, memberChanges, txn); - return shouldFlush; + return {shouldFlush, historyVisibility}; + } + + afterSync({historyVisibility}) { + this._historyVisibility = historyVisibility; + } + + async _loadHistoryVisibilityIfNeeded(historyVisibility, txn) { + if (!historyVisibility) { + const visibilityEntry = await txn.roomState.get(this.id, ROOM_HISTORY_VISIBILITY_TYPE, ""); + if (visibilityEntry) { + return event?.content?.history_visibility; + } + } + return historyVisibility; } async prepareDecryptAll(events, newKeys, source, txn) { @@ -288,8 +330,7 @@ export class RoomEncryption { await this._processShareRoomKeyOperation(operation, hsApi, log); } - async _addShareRoomKeyOperationForNewMembers(memberChangesArray, txn, log) { - const userIds = memberChangesArray.filter(m => m.hasJoined).map(m => m.userId); + async _addShareRoomKeyOperationForMembers(userIds, txn, log) { const roomKeyMessage = await this._megolmEncryption.createRoomKeyMessage( this._room.id, txn); if (roomKeyMessage) { diff --git a/src/matrix/room/Room.js b/src/matrix/room/Room.js index 12c17580..8cc87845 100644 --- a/src/matrix/room/Room.js +++ b/src/matrix/room/Room.js @@ -139,11 +139,11 @@ export class Room extends BaseRoom { } log.set("newEntries", newEntries.length); log.set("updatedEntries", updatedEntries.length); - let shouldFlushKeyShares = false; + let encryptionChanges; // pass member changes to device tracker - if (roomEncryption && this.isTrackingMembers && memberChanges?.size) { - shouldFlushKeyShares = await roomEncryption.writeMemberChanges(memberChanges, txn, log); - log.set("shouldFlushKeyShares", shouldFlushKeyShares); + if (roomEncryption) { + encryptionChanges = await roomEncryption.writeSync(roomResponse, memberChanges, txn, log); + log.set("shouldFlushKeyShares", encryptionChanges.shouldFlush); } const allEntries = newEntries.concat(updatedEntries); // also apply (decrypted) timeline entries to the summary changes @@ -188,7 +188,7 @@ export class Room extends BaseRoom { memberChanges, heroChanges, powerLevelsEvent, - shouldFlushKeyShares, + encryptionChanges, }; } @@ -201,11 +201,14 @@ export class Room extends BaseRoom { const { summaryChanges, newEntries, updatedEntries, newLiveKey, removedPendingEvents, memberChanges, powerLevelsEvent, - heroChanges, roomEncryption + heroChanges, roomEncryption, encryptionChanges } = changes; log.set("id", this.id); this._syncWriter.afterSync(newLiveKey); this._setEncryption(roomEncryption); + if (this._roomEncryption) { + this._roomEncryption.afterSync(encryptionChanges); + } if (memberChanges.size) { if (this._changedMembersDuringSync) { for (const [userId, memberChange] of memberChanges.entries()) { @@ -288,8 +291,8 @@ export class Room extends BaseRoom { } } - needsAfterSyncCompleted({shouldFlushKeyShares}) { - return shouldFlushKeyShares; + needsAfterSyncCompleted({encryptionChanges}) { + return encryptionChanges?.shouldFlush; } /** diff --git a/src/matrix/room/common.ts b/src/matrix/room/common.ts index 57ab7023..a03703a4 100644 --- a/src/matrix/room/common.ts +++ b/src/matrix/room/common.ts @@ -14,6 +14,8 @@ See the License for the specific language governing permissions and limitations under the License. */ +import type {StateEvent} from "../storage/types"; + export function getPrevContentFromStateEvent(event) { // where to look for prev_content is a bit of a mess, // see https://matrix.to/#/!NasysSDfxKxZBzJJoE:matrix.org/$DvrAbZJiILkOmOIuRsNoHmh2v7UO5CWp_rYhlGk34fQ?via=matrix.org&via=pixie.town&via=amorgan.xyz @@ -40,3 +42,72 @@ export enum RoomType { Private, Public } + +type RoomResponse = { + state?: { + events?: Array + }, + timeline?: { + events?: Array + } +} + +/** iterates over any state events in a sync room response, in the order that they should be applied (from older to younger events) */ +export function iterateResponseStateEvents(roomResponse: RoomResponse, callback: (StateEvent) => void) { + // first iterate over state events, they precede the timeline + const stateEvents = roomResponse.state?.events; + if (stateEvents) { + for (let i = 0; i < stateEvents.length; i++) { + callback(stateEvents[i]); + } + } + // now see if there are any state events within the timeline + let timelineEvents = roomResponse.timeline?.events; + if (timelineEvents) { + for (let i = 0; i < timelineEvents.length; i++) { + const event = timelineEvents[i]; + if (typeof event.state_key === "string") { + callback(event); + } + } + } +} + +export function tests() { + return { + "test iterateResponseStateEvents with both state and timeline sections": assert => { + const roomResponse = { + state: { + events: [ + {type: "m.room.member", state_key: "1"}, + {type: "m.room.member", state_key: "2", content: {a: 1}}, + ] + }, + timeline: { + events: [ + {type: "m.room.message"}, + {type: "m.room.member", state_key: "3"}, + {type: "m.room.message"}, + {type: "m.room.member", state_key: "2", content: {a: 2}}, + ] + } + } as unknown as RoomResponse; + const expectedStateKeys = ["1", "2", "3", "2"]; + const expectedAForMember2 = [1, 2]; + iterateResponseStateEvents(roomResponse, event => { + assert.strictEqual(event.type, "m.room.member"); + assert.strictEqual(expectedStateKeys.shift(), event.state_key); + if (event.state_key === "2") { + assert.strictEqual(expectedAForMember2.shift(), event.content.a); + } + }); + assert.strictEqual(expectedStateKeys.length, 0); + assert.strictEqual(expectedAForMember2.length, 0); + }, + "test iterateResponseStateEvents with empty response": assert => { + iterateResponseStateEvents({}, () => { + assert.fail("no events expected"); + }); + } + } +} diff --git a/src/matrix/room/members/RoomMember.js b/src/matrix/room/members/RoomMember.js index dabff972..8e00f5de 100644 --- a/src/matrix/room/members/RoomMember.js +++ b/src/matrix/room/members/RoomMember.js @@ -137,6 +137,10 @@ export class MemberChange { return this.member.membership; } + get wasInvited() { + return this.previousMembership === "invite" && this.membership !== "invite"; + } + get hasLeft() { return this.previousMembership === "join" && this.membership !== "join"; } From c8a8eb10b5aa34467e7894ec3eb525ce7d7ca6d1 Mon Sep 17 00:00:00 2001 From: Bruno Windels <274386+bwindels@users.noreply.github.com> Date: Wed, 20 Jul 2022 15:21:33 +0200 Subject: [PATCH 111/226] get user ids for sharing a new key when the message is sent rather than when the key happens to get sent --- src/matrix/e2ee/RoomEncryption.js | 16 +++++----------- 1 file changed, 5 insertions(+), 11 deletions(-) diff --git a/src/matrix/e2ee/RoomEncryption.js b/src/matrix/e2ee/RoomEncryption.js index 0ec7212e..8839556f 100644 --- a/src/matrix/e2ee/RoomEncryption.js +++ b/src/matrix/e2ee/RoomEncryption.js @@ -316,10 +316,13 @@ export class RoomEncryption { } async _shareNewRoomKey(roomKeyMessage, hsApi, log) { + const devices = await this._deviceTracker.devicesForTrackedRoom(this._room.id, this._historyVisibility, hsApi, log); + const userIds = Array.from(devices.reduce((set, device) => set.add(device.userId), new Set())); + let writeOpTxn = await this._storage.readWriteTxn([this._storage.storeNames.operations]); let operation; try { - operation = this._writeRoomKeyShareOperation(roomKeyMessage, null, writeOpTxn); + operation = this._writeRoomKeyShareOperation(roomKeyMessage, userIds, writeOpTxn); } catch (err) { writeOpTxn.abort(); throw err; @@ -385,16 +388,7 @@ export class RoomEncryption { log.set("id", operation.id); await this._deviceTracker.trackRoom(this._room, log); - let devices; - if (operation.userIds === null) { - devices = await this._deviceTracker.devicesForTrackedRoom(this._room.id, hsApi, log); - const userIds = Array.from(devices.reduce((set, device) => set.add(device.userId), new Set())); - operation.userIds = userIds; - await this._updateOperationsStore(operations => operations.update(operation)); - } else { - devices = await this._deviceTracker.devicesForRoomMembers(this._room.id, operation.userIds, hsApi, log); - } - + const devices = await this._deviceTracker.devicesForRoomMembers(this._room.id, operation.userIds, hsApi, log); const messages = await log.wrap("olm encrypt", log => this._olmEncryption.encrypt( "m.room_key", operation.roomKeyMessage, devices, hsApi, log)); const missingDevices = devices.filter(d => !messages.some(m => m.device === d)); From 8d766ac5048e47221ac7db177bfaa3e3b26eba64 Mon Sep 17 00:00:00 2001 From: RMidhunSuresh Date: Thu, 21 Jul 2022 12:05:10 +0530 Subject: [PATCH 112/226] Remove await within loop --- .../build-plugins/rollup-plugin-build-themes.js | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/scripts/build-plugins/rollup-plugin-build-themes.js b/scripts/build-plugins/rollup-plugin-build-themes.js index f44406c6..bb1c65c1 100644 --- a/scripts/build-plugins/rollup-plugin-build-themes.js +++ b/scripts/build-plugins/rollup-plugin-build-themes.js @@ -51,12 +51,20 @@ function addThemesToConfig(bundle, manifestLocations, defaultThemes) { */ async function generateIconSourceMap(icons, manifestLocation) { const sources = {}; + const fileNames = []; + const promises = []; const fs = require("fs").promises; for (const icon of Object.values(icons)) { const [location] = icon.split("?"); const resolvedLocation = path.resolve(__dirname, "../../", manifestLocation, location); - const iconData = await fs.readFile(resolvedLocation); - const svgString = iconData.toString(); + const iconData = fs.readFile(resolvedLocation); + promises.push(iconData); + const fileName = path.basename(resolvedLocation); + fileNames.push(fileName); + } + const results = await Promise.all(promises); + for (let i = 0; i < results.length; ++i) { + const svgString = results[i].toString(); const result = optimize(svgString, { plugins: [ { @@ -68,8 +76,7 @@ async function generateIconSourceMap(icons, manifestLocation) { ], }); const optimizedSvgString = result.data; - const fileName = path.basename(resolvedLocation); - sources[fileName] = optimizedSvgString; + sources[fileNames[i]] = optimizedSvgString; } return sources; } From faa8cae532ee9899542808e667ee533a078cb270 Mon Sep 17 00:00:00 2001 From: Kaki In <91763754+Kaki-In@users.noreply.github.com> Date: Thu, 21 Jul 2022 13:55:23 +0200 Subject: [PATCH 113/226] Added the possibility to join a room using /join (also added the global commands uses, and some others commands like /shrug .) --- src/domain/session/room/RoomViewModel.js | 80 ++++++++++++++++--- .../web/ui/css/themes/element/theme.css | 12 +++ 2 files changed, 83 insertions(+), 9 deletions(-) diff --git a/src/domain/session/room/RoomViewModel.js b/src/domain/session/room/RoomViewModel.js index 94c78286..dfda5767 100644 --- a/src/domain/session/room/RoomViewModel.js +++ b/src/domain/session/room/RoomViewModel.js @@ -197,18 +197,80 @@ export class RoomViewModel extends ViewModel { } } + async _getMessageInformations (message) { + let msgtype = "m.text"; + if (message.startsWith("/")) { + let command=message.substring(1).split(" "); + switch (command[0]) { + case "me": + message = message.substring(4).trim(); + msgtype = "m.emote"; + break; + case "join": + if (command.length==2) { + let roomname = message.substring(5).trim(); + try { + await this._options.client.session.joinRoom(roomname); + } catch (exc) { + if (exc.statusCode??exc.status==400) { + this._sendError=new Error("/join : '"+roomname+"' was not legal room ID or room alias"); + } else if ((exc.statusCode??exc.status==502) || (exc.message="Internal Server Error")) { + this._sendError=new Error("/join : room not found"); + } else { + this._sendError=new Error("join syntax: /join "); + } + this._timelineError = null; + this.emitChange("error"); + } + } else { + this._sendError=new Error("join syntax: /join "); + this._timelineError = null; + this.emitChange("error"); + } + msgtype=undefined; + message=undefined; + break; + case "shrug": + message="¯\\_(ツ)_/¯ "+message.substring(7); + break; + case "tableflip": + message="(╯°□°)╯︵ ┻━┻ "+message.substring(11); + break; + case "unflip": + message="┬──┬ ノ( ゜-゜ノ) "+message.substring(8); + break; + case "lenny": + message="( ͡° ͜ʖ ͡°) "+message.substring(7); + break; + default: + if (command[0][0]=="/") { + message = message.substring(1).trim(); + break; + } else { + this._sendError=new Error("no command name '"+command[0]+"'"); + this._timelineError = null; + this.emitChange("error"); + msgtype=undefined; + message=undefined; + } + } + } + return [msgtype, message]; + } + async _sendMessage(message, replyingTo) { if (!this._room.isArchived && message) { + let messinfo = await this._getMessageInformations(message); try { - let msgtype = "m.text"; - if (message.startsWith("/me ")) { - message = message.substr(4).trim(); - msgtype = "m.emote"; - } - if (replyingTo) { - await replyingTo.reply(msgtype, message); - } else { - await this._room.sendEvent("m.room.message", {msgtype, body: message}); + let msgtype = messinfo[0]; + let message = messinfo[1]; + console.log("messinfo :",messinfo); + if (msgtype && message) { + if (replyingTo) { + await replyingTo.reply(msgtype, message); + } else { + await this._room.sendEvent("m.room.message", {msgtype, body: message}); + } } } catch (err) { console.error(`room.sendMessage(): ${err.message}:\n${err.stack}`); diff --git a/src/platform/web/ui/css/themes/element/theme.css b/src/platform/web/ui/css/themes/element/theme.css index 632d5bc5..16dfea12 100644 --- a/src/platform/web/ui/css/themes/element/theme.css +++ b/src/platform/web/ui/css/themes/element/theme.css @@ -521,6 +521,18 @@ a { .RoomView_error { color: var(--error-color); + background : #efefef; + padding-right : 20px; + padding-left : 20px; + overflow : hidden; + height : 0px; + transition : 0.25s all ease-out; +} + +.RoomView_error:not(:empty) { + height : auto; + padding-top : 20px; + padding-bottom : 20px; } .MessageComposer_replyPreview .Timeline_message { From 22831e710ca456009eb2f819c86e08b0ed0875bd Mon Sep 17 00:00:00 2001 From: Bruno Windels <274386+bwindels@users.noreply.github.com> Date: Fri, 22 Jul 2022 14:15:26 +0200 Subject: [PATCH 114/226] support async callback in iterateResponseStateEvents --- src/matrix/room/common.ts | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/src/matrix/room/common.ts b/src/matrix/room/common.ts index a03703a4..5a7fc98e 100644 --- a/src/matrix/room/common.ts +++ b/src/matrix/room/common.ts @@ -53,12 +53,20 @@ type RoomResponse = { } /** iterates over any state events in a sync room response, in the order that they should be applied (from older to younger events) */ -export function iterateResponseStateEvents(roomResponse: RoomResponse, callback: (StateEvent) => void) { +export function iterateResponseStateEvents(roomResponse: RoomResponse, callback: (StateEvent) => Promise | void): Promise | void { + let promises: Promise[] | undefined = undefined; + const callCallback = stateEvent => { + const result = callback(stateEvent); + if (result instanceof Promise) { + promises = promises ?? []; + promises.push(result); + } + }; // first iterate over state events, they precede the timeline const stateEvents = roomResponse.state?.events; if (stateEvents) { for (let i = 0; i < stateEvents.length; i++) { - callback(stateEvents[i]); + callCallback(stateEvents[i]); } } // now see if there are any state events within the timeline @@ -67,10 +75,13 @@ export function iterateResponseStateEvents(roomResponse: RoomResponse, callback: for (let i = 0; i < timelineEvents.length; i++) { const event = timelineEvents[i]; if (typeof event.state_key === "string") { - callback(event); + callCallback(event); } } } + if (promises) { + return Promise.all(promises); + } } export function tests() { From fb58d9c9efd9a3efa70dd2ddfa988daffd078036 Mon Sep 17 00:00:00 2001 From: Kaki In <91763754+Kaki-In@users.noreply.github.com> Date: Fri, 22 Jul 2022 16:08:53 +0200 Subject: [PATCH 115/226] Corrected some syntax dismiss --- src/domain/session/room/RoomViewModel.js | 51 ++++++++++++------------ 1 file changed, 26 insertions(+), 25 deletions(-) diff --git a/src/domain/session/room/RoomViewModel.js b/src/domain/session/room/RoomViewModel.js index dfda5767..5ded5108 100644 --- a/src/domain/session/room/RoomViewModel.js +++ b/src/domain/session/room/RoomViewModel.js @@ -200,71 +200,72 @@ export class RoomViewModel extends ViewModel { async _getMessageInformations (message) { let msgtype = "m.text"; if (message.startsWith("/")) { - let command=message.substring(1).split(" "); - switch (command[0]) { + const [commandName, ...args] = message.substring(1).split(" "); + switch (commandName) { case "me": message = message.substring(4).trim(); msgtype = "m.emote"; break; case "join": - if (command.length==2) { - let roomname = message.substring(5).trim(); + if (args.length == 1) { + let roomName = args[0]; try { - await this._options.client.session.joinRoom(roomname); + await this._options.client.session.joinRoom(roomName); + const internalId = await this._options.client.session.joinRoom(roomName); + this.navigation.push("room", internalId); } catch (exc) { - if (exc.statusCode??exc.status==400) { - this._sendError=new Error("/join : '"+roomname+"' was not legal room ID or room alias"); - } else if ((exc.statusCode??exc.status==502) || (exc.message="Internal Server Error")) { - this._sendError=new Error("/join : room not found"); + if (exc.statusCode ?? exc.status === 400) { + this._sendError = new Error(`/join : '${roomName}' was not legal room ID or room alias`); + } else if ((exc.statusCode ?? exc.status === 404) || (exc.statusCode ?? exc.status === 502) || (exc.message == "Internal Server Error")) { + this._sendError = new Error(`/join : room '${roomName}' not found`); } else { - this._sendError=new Error("join syntax: /join "); + this._sendError = new Error("join syntax: /join "); } this._timelineError = null; this.emitChange("error"); } } else { - this._sendError=new Error("join syntax: /join "); + this._sendError = new Error("join syntax: /join "); this._timelineError = null; this.emitChange("error"); } - msgtype=undefined; - message=undefined; + msgtype = undefined; + message = undefined; break; case "shrug": - message="¯\\_(ツ)_/¯ "+message.substring(7); + message = "¯\\_(ツ)_/¯ " + message.substring(7); break; case "tableflip": - message="(╯°□°)╯︵ ┻━┻ "+message.substring(11); + message="(╯°□°)╯︵ ┻━┻ " + message.substring(11); break; case "unflip": - message="┬──┬ ノ( ゜-゜ノ) "+message.substring(8); + message="┬──┬ ノ( ゜-゜ノ) " + message.substring(8); break; case "lenny": - message="( ͡° ͜ʖ ͡°) "+message.substring(7); + message="( ͡° ͜ʖ ͡°) " + message.substring(7); break; default: - if (command[0][0]=="/") { + if (commandName[0] == "/") { message = message.substring(1).trim(); break; } else { - this._sendError=new Error("no command name '"+command[0]+"'"); + this._sendError = new Error(`no command name "${commandName}". To send the message instead of executing, please type "/${message}"`); this._timelineError = null; this.emitChange("error"); - msgtype=undefined; - message=undefined; + msgtype = undefined; + message = undefined; } } } - return [msgtype, message]; + return {type: msgtype, message: message}; } async _sendMessage(message, replyingTo) { if (!this._room.isArchived && message) { let messinfo = await this._getMessageInformations(message); try { - let msgtype = messinfo[0]; - let message = messinfo[1]; - console.log("messinfo :",messinfo); + let msgtype = messinfo.type; + let message = messinfo.message; if (msgtype && message) { if (replyingTo) { await replyingTo.reply(msgtype, message); From 8b393464094f0c2d0b734e6511621440735fa950 Mon Sep 17 00:00:00 2001 From: Kaki In <91763754+Kaki-In@users.noreply.github.com> Date: Fri, 22 Jul 2022 16:34:52 +0200 Subject: [PATCH 116/226] The error message can now be closed --- src/domain/session/room/RoomViewModel.js | 5 ++ .../web/ui/css/themes/element/theme.css | 49 ++++++++++++++++++- src/platform/web/ui/session/room/RoomView.js | 10 +++- 3 files changed, 61 insertions(+), 3 deletions(-) diff --git a/src/domain/session/room/RoomViewModel.js b/src/domain/session/room/RoomViewModel.js index 5ded5108..5415d6dd 100644 --- a/src/domain/session/room/RoomViewModel.js +++ b/src/domain/session/room/RoomViewModel.js @@ -416,6 +416,11 @@ export class RoomViewModel extends ViewModel { this._composerVM.setReplyingTo(entry); } } + + dismissError(evt) { + this._sendError = null; + this.emitChange("error"); + } } function videoToInfo(video) { diff --git a/src/platform/web/ui/css/themes/element/theme.css b/src/platform/web/ui/css/themes/element/theme.css index 16dfea12..f14100b2 100644 --- a/src/platform/web/ui/css/themes/element/theme.css +++ b/src/platform/web/ui/css/themes/element/theme.css @@ -526,15 +526,62 @@ a { padding-left : 20px; overflow : hidden; height : 0px; + font-weight : bold; transition : 0.25s all ease-out; + position : relative; + display : flex; } .RoomView_error:not(:empty) { - height : auto; + height : 40px; + align-items : center; padding-top : 20px; padding-bottom : 20px; } +.RoomView_error p { + position : relative; + display : block; + width : 100%; + height : auto; + margin : 0; +} + +.RoomView_error button { + width : 40px; + padding-top : 20px; + padding-bottom : 20px; + background : none; + border : none; + position : relative; +} + +.RoomView_error button:hover { + background : #cfcfcf; +} + +.RoomView_error button:after { + content:""; + position : absolute; + top : 10px; + right: 16px; + background : red; + width : 5px; + height : 20px; + transform: rotate(45deg); +} + +.RoomView_error button:before { + content:""; + position : absolute; + top : 17px; + left: 10px; + background : red; + width : 20px; + height : 5px; + transform: rotate(45deg); +} + .MessageComposer_replyPreview .Timeline_message { margin: 0; margin-top: 5px; diff --git a/src/platform/web/ui/session/room/RoomView.js b/src/platform/web/ui/session/room/RoomView.js index d36466dd..7cfd7d85 100644 --- a/src/platform/web/ui/session/room/RoomView.js +++ b/src/platform/web/ui/session/room/RoomView.js @@ -46,7 +46,13 @@ export class RoomView extends TemplateView { }) ]), t.div({className: "RoomView_body"}, [ - t.div({className: "RoomView_error"}, vm => vm.error), + t.div({className: "RoomView_error"}, [ + t.if(vm => vm.error, t => t.p({}, vm => vm.error)), + t.if(vm => vm.error, t => t.button({ + className: "RoomView_error_closerButton", + onClick: evt => vm.dismissError(evt) + })) + ]), t.mapView(vm => vm.timelineViewModel, timelineViewModel => { return timelineViewModel ? new TimelineView(timelineViewModel, this._viewClassForTile) : @@ -64,7 +70,7 @@ export class RoomView extends TemplateView { ]) ]); } - + _toggleOptionsMenu(evt) { if (this._optionsPopup && this._optionsPopup.isOpen) { this._optionsPopup.close(); From be8962cec29aa23ea56515ec3cc8a8232b940bd6 Mon Sep 17 00:00:00 2001 From: Kaki In <91763754+Kaki-In@users.noreply.github.com> Date: Fri, 22 Jul 2022 16:59:48 +0200 Subject: [PATCH 117/226] Fixed priority operations when checking request status --- src/domain/session/room/RoomViewModel.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/domain/session/room/RoomViewModel.js b/src/domain/session/room/RoomViewModel.js index 5415d6dd..1786741e 100644 --- a/src/domain/session/room/RoomViewModel.js +++ b/src/domain/session/room/RoomViewModel.js @@ -214,9 +214,9 @@ export class RoomViewModel extends ViewModel { const internalId = await this._options.client.session.joinRoom(roomName); this.navigation.push("room", internalId); } catch (exc) { - if (exc.statusCode ?? exc.status === 400) { + if ((exc.statusCode ?? exc.status) === 400) { this._sendError = new Error(`/join : '${roomName}' was not legal room ID or room alias`); - } else if ((exc.statusCode ?? exc.status === 404) || (exc.statusCode ?? exc.status === 502) || (exc.message == "Internal Server Error")) { + } else if ((exc.statusCode ?? exc.status) === 404 || (exc.statusCode ?? exc.status) === 502 || exc.message == "Internal Server Error") { this._sendError = new Error(`/join : room '${roomName}' not found`); } else { this._sendError = new Error("join syntax: /join "); From e345d0b33e1247833c873382f976dd2b81df8f24 Mon Sep 17 00:00:00 2001 From: Kaki In <91763754+Kaki-In@users.noreply.github.com> Date: Fri, 22 Jul 2022 17:06:09 +0200 Subject: [PATCH 118/226] Added the 403 status when joining an unknown room --- src/domain/session/room/RoomViewModel.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/domain/session/room/RoomViewModel.js b/src/domain/session/room/RoomViewModel.js index 1786741e..66f936ea 100644 --- a/src/domain/session/room/RoomViewModel.js +++ b/src/domain/session/room/RoomViewModel.js @@ -216,7 +216,7 @@ export class RoomViewModel extends ViewModel { } catch (exc) { if ((exc.statusCode ?? exc.status) === 400) { this._sendError = new Error(`/join : '${roomName}' was not legal room ID or room alias`); - } else if ((exc.statusCode ?? exc.status) === 404 || (exc.statusCode ?? exc.status) === 502 || exc.message == "Internal Server Error") { + } else if ((exc.statusCode ?? exc.status) === 403 || (exc.statusCode ?? exc.status) === 404 || (exc.statusCode ?? exc.status) === 502 || exc.message == "Internal Server Error") { this._sendError = new Error(`/join : room '${roomName}' not found`); } else { this._sendError = new Error("join syntax: /join "); From 66a59e6f4d626f04dbc90cf934e6fe8abb8ab354 Mon Sep 17 00:00:00 2001 From: Kaki In <91763754+Kaki-In@users.noreply.github.com> Date: Fri, 22 Jul 2022 17:09:43 +0200 Subject: [PATCH 119/226] Error of interpretation of the 403 status at the last update. Fixed --- src/domain/session/room/RoomViewModel.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/domain/session/room/RoomViewModel.js b/src/domain/session/room/RoomViewModel.js index 66f936ea..1ad92dd5 100644 --- a/src/domain/session/room/RoomViewModel.js +++ b/src/domain/session/room/RoomViewModel.js @@ -216,8 +216,10 @@ export class RoomViewModel extends ViewModel { } catch (exc) { if ((exc.statusCode ?? exc.status) === 400) { this._sendError = new Error(`/join : '${roomName}' was not legal room ID or room alias`); - } else if ((exc.statusCode ?? exc.status) === 403 || (exc.statusCode ?? exc.status) === 404 || (exc.statusCode ?? exc.status) === 502 || exc.message == "Internal Server Error") { + } else if (exc.statusCode ?? exc.status) === 404 || (exc.statusCode ?? exc.status) === 502 || exc.message == "Internal Server Error") { this._sendError = new Error(`/join : room '${roomName}' not found`); + } else if (exc.statusCode ?? exc.status) === 403) { + this._sendError = new Error(`/join : you're not invited to join '${roomName}'`); } else { this._sendError = new Error("join syntax: /join "); } From b7fd22c7f949783cda851c9bc10711f9c3029011 Mon Sep 17 00:00:00 2001 From: Kaki In <91763754+Kaki-In@users.noreply.github.com> Date: Fri, 22 Jul 2022 17:10:29 +0200 Subject: [PATCH 120/226] SyntaxError fixed --- src/domain/session/room/RoomViewModel.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/domain/session/room/RoomViewModel.js b/src/domain/session/room/RoomViewModel.js index 1ad92dd5..b3fc703c 100644 --- a/src/domain/session/room/RoomViewModel.js +++ b/src/domain/session/room/RoomViewModel.js @@ -216,9 +216,9 @@ export class RoomViewModel extends ViewModel { } catch (exc) { if ((exc.statusCode ?? exc.status) === 400) { this._sendError = new Error(`/join : '${roomName}' was not legal room ID or room alias`); - } else if (exc.statusCode ?? exc.status) === 404 || (exc.statusCode ?? exc.status) === 502 || exc.message == "Internal Server Error") { + } else if ((exc.statusCode ?? exc.status) === 404 || (exc.statusCode ?? exc.status) === 502 || exc.message == "Internal Server Error") { this._sendError = new Error(`/join : room '${roomName}' not found`); - } else if (exc.statusCode ?? exc.status) === 403) { + } else if ((exc.statusCode ?? exc.status) === 403) { this._sendError = new Error(`/join : you're not invited to join '${roomName}'`); } else { this._sendError = new Error("join syntax: /join "); From f3379402027a13ff41943aec4d62b77a8490b5eb Mon Sep 17 00:00:00 2001 From: Bruno Windels <274386+bwindels@users.noreply.github.com> Date: Fri, 22 Jul 2022 17:46:29 +0200 Subject: [PATCH 121/226] this migration shouldn't be needed anymore and undoes the export of addRoomToIdentity, which is somewhat internal --- src/matrix/e2ee/DeviceTracker.js | 2 +- src/matrix/storage/idb/schema.ts | 52 ++++---------------------------- 2 files changed, 7 insertions(+), 47 deletions(-) diff --git a/src/matrix/e2ee/DeviceTracker.js b/src/matrix/e2ee/DeviceTracker.js index f8c3bca8..207aee05 100644 --- a/src/matrix/e2ee/DeviceTracker.js +++ b/src/matrix/e2ee/DeviceTracker.js @@ -19,7 +19,7 @@ import {verifyEd25519Signature, SIGNATURE_ALGORITHM} from "./common.js"; const TRACKING_STATUS_OUTDATED = 0; const TRACKING_STATUS_UPTODATE = 1; -export function addRoomToIdentity(identity, userId, roomId) { +function addRoomToIdentity(identity, userId, roomId) { if (!identity) { identity = { userId: userId, diff --git a/src/matrix/storage/idb/schema.ts b/src/matrix/storage/idb/schema.ts index 7819130e..9b875f9c 100644 --- a/src/matrix/storage/idb/schema.ts +++ b/src/matrix/storage/idb/schema.ts @@ -2,7 +2,6 @@ import {IDOMStorage} from "./types"; import {ITransaction} from "./QueryTarget"; import {iterateCursor, NOT_DONE, reqAsPromise} from "./utils"; import {RoomMember, EVENT_TYPE as MEMBER_EVENT_TYPE} from "../../room/members/RoomMember.js"; -import {addRoomToIdentity} from "../../e2ee/DeviceTracker.js"; import {SESSION_E2EE_KEY_PREFIX} from "../../e2ee/common.js"; import {SummaryData} from "../../room/RoomSummary"; import {RoomMemberStore, MemberData} from "./stores/RoomMemberStore"; @@ -183,51 +182,12 @@ function createTimelineRelationsStore(db: IDBDatabase) : void { db.createObjectStore("timelineRelations", {keyPath: "key"}); } -//v11 doesn't change the schema, but ensures all userIdentities have all the roomIds they should (see #470) -async function fixMissingRoomsInUserIdentities(db: IDBDatabase, txn: IDBTransaction, localStorage: IDOMStorage, log: ILogItem) { - const roomSummaryStore = txn.objectStore("roomSummary"); - const trackedRoomIds: string[] = []; - await iterateCursor(roomSummaryStore.openCursor(), roomSummary => { - if (roomSummary.isTrackingMembers) { - trackedRoomIds.push(roomSummary.roomId); - } - return NOT_DONE; - }); - const outboundGroupSessionsStore = txn.objectStore("outboundGroupSessions"); - const userIdentitiesStore: IDBObjectStore = txn.objectStore("userIdentities"); - const roomMemberStore = txn.objectStore("roomMembers"); - for (const roomId of trackedRoomIds) { - let foundMissing = false; - const joinedUserIds: string[] = []; - const memberRange = IDBKeyRange.bound(roomId, `${roomId}|${MAX_UNICODE}`, true, true); - await log.wrap({l: "room", id: roomId}, async log => { - await iterateCursor(roomMemberStore.openCursor(memberRange), member => { - if (member.membership === "join") { - joinedUserIds.push(member.userId); - } - return NOT_DONE; - }); - log.set("joinedUserIds", joinedUserIds.length); - for (const userId of joinedUserIds) { - const identity = await reqAsPromise(userIdentitiesStore.get(userId)); - const originalRoomCount = identity?.roomIds?.length; - const updatedIdentity = addRoomToIdentity(identity, userId, roomId); - if (updatedIdentity) { - log.log({l: `fixing up`, id: userId, - roomsBefore: originalRoomCount, roomsAfter: updatedIdentity.roomIds.length}); - userIdentitiesStore.put(updatedIdentity); - foundMissing = true; - } - } - log.set("foundMissing", foundMissing); - if (foundMissing) { - // clear outbound megolm session, - // so we'll create a new one on the next message that will be properly shared - outboundGroupSessionsStore.delete(roomId); - } - }); - } -} +//v11 doesn't change the schema, +// but ensured all userIdentities have all the roomIds they should (see #470) + +// 2022-07-20: The fix dated from August 2021, and have removed it now because of a +// refactoring needed in the device tracker, which made it inconvenient to expose addRoomToIdentity +function fixMissingRoomsInUserIdentities() {} // v12 move ssssKey to e2ee:ssssKey so it will get backed up in the next step async function changeSSSSKeyPrefix(db: IDBDatabase, txn: IDBTransaction) { From 86c0e9e66936d3c18aca88fa4c9aab53acfd8d3d Mon Sep 17 00:00:00 2001 From: Bruno Windels <274386+bwindels@users.noreply.github.com> Date: Fri, 22 Jul 2022 17:46:53 +0200 Subject: [PATCH 122/226] logic for whether a key should be shared by membership and h. visibility --- src/matrix/e2ee/common.js | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/src/matrix/e2ee/common.js b/src/matrix/e2ee/common.js index 2b9d46b9..cc3bfff5 100644 --- a/src/matrix/e2ee/common.js +++ b/src/matrix/e2ee/common.js @@ -69,3 +69,28 @@ export function createRoomEncryptionEvent() { } } } + + +// Use enum when converting to TS +export const HistoryVisibility = Object.freeze({ + Joined: "joined", + Invited: "invited", + WorldReadable: "world_readable", + Shared: "shared", +}); + +export function shouldShareKey(membership, historyVisibility) { + switch (historyVisibility) { + case HistoryVisibility.WorldReadable: + return true; + case HistoryVisibility.Shared: + // was part of room at some time + return membership !== undefined; + case HistoryVisibility.Joined: + return membership === "join"; + case HistoryVisibility.Invited: + return membership === "invite" || membership === "join"; + default: + return false; + } +} From f6011f3f348215d0829af9e1f8f7e6553bc4a87b Mon Sep 17 00:00:00 2001 From: Bruno Windels <274386+bwindels@users.noreply.github.com> Date: Fri, 22 Jul 2022 17:48:26 +0200 Subject: [PATCH 123/226] take history visibility into account in device tracker and return added and removed userids to their userIdentity for the given room, so room encryption can share and discard the keys for them --- src/matrix/e2ee/DeviceTracker.js | 71 +++++++++++++++++++------------- 1 file changed, 42 insertions(+), 29 deletions(-) diff --git a/src/matrix/e2ee/DeviceTracker.js b/src/matrix/e2ee/DeviceTracker.js index 207aee05..7ce601d2 100644 --- a/src/matrix/e2ee/DeviceTracker.js +++ b/src/matrix/e2ee/DeviceTracker.js @@ -15,6 +15,7 @@ limitations under the License. */ import {verifyEd25519Signature, SIGNATURE_ALGORITHM} from "./common.js"; +import {shouldShareKey} from "./common.js"; const TRACKING_STATUS_OUTDATED = 0; const TRACKING_STATUS_UPTODATE = 1; @@ -79,13 +80,38 @@ export class DeviceTracker { })); } - writeMemberChanges(room, memberChanges, txn) { - return Promise.all(Array.from(memberChanges.values()).map(async memberChange => { - return this._applyMemberChange(memberChange, txn); + /** @return Promise<{added: string[], removed: string[]}> the user ids for who the room was added or removed to the userIdentity, + * and with who a key should be now be shared + **/ + async writeMemberChanges(room, memberChanges, historyVisibility, txn) { + const added = []; + const removed = []; + await Promise.all(Array.from(memberChanges.values()).map(async memberChange => { + // keys should now be shared with this member? + // add the room to the userIdentity if so + if (shouldShareKey(memberChange.membership, historyVisibility)) { + if (await this._addRoomToUserIdentity(memberChange.roomId, memberChange.userId, txn)) { + added.push(memberChange.userId); + } + } else if (memberChange.hasLeft) { + // remove room + const {roomId} = memberChange; + // if we left the room, remove room from all user identities in the room + if (memberChange.userId === this._ownUserId) { + const userIds = await txn.roomMembers.getAllUserIds(roomId); + await Promise.all(userIds.map(userId => { + return this._removeRoomFromUserIdentity(roomId, userId, txn); + })); + } else { + await this._removeRoomFromUserIdentity(roomId, memberChange.userId, txn); + } + removed.push(memberChange.userId); + } })); + return {added, removed}; } - async trackRoom(room, log) { + async trackRoom(room, historyVisibility, log) { if (room.isTrackingMembers || !room.isEncrypted) { return; } @@ -100,7 +126,11 @@ export class DeviceTracker { isTrackingChanges = room.writeIsTrackingMembers(true, txn); const members = Array.from(memberList.members.values()); log.set("members", members.length); - await this._writeJoinedMembers(members, txn); + await Promise.all(members.map(async member => { + if (shouldShareKey(member.membership, historyVisibility)) { + await this._addRoomToUserIdentity(member.roomId, member.userId, txn); + } + })); } catch (err) { txn.abort(); throw err; @@ -120,13 +150,15 @@ export class DeviceTracker { })); } - async _writeMember(member, txn) { + async _addRoomToUserIdentity(roomId, userId, txn) { const {userIdentities} = txn; - const identity = await userIdentities.get(member.userId); - const updatedIdentity = addRoomToIdentity(identity, member.userId, member.roomId); + const identity = await userIdentities.get(userId); + const updatedIdentity = addRoomToIdentity(identity, userId, roomId); if (updatedIdentity) { userIdentities.set(updatedIdentity); + return true; } + return false; } async _removeRoomFromUserIdentity(roomId, userId, txn) { @@ -141,28 +173,9 @@ export class DeviceTracker { } else { userIdentities.set(identity); } + return true; } - } - - async _applyMemberChange(memberChange, txn) { - // TODO: depends whether we encrypt for invited users?? - // add room - if (memberChange.hasJoined) { - await this._writeMember(memberChange.member, txn); - } - // remove room - else if (memberChange.hasLeft) { - const {roomId} = memberChange; - // if we left the room, remove room from all user identities in the room - if (memberChange.userId === this._ownUserId) { - const userIds = await txn.roomMembers.getAllUserIds(roomId); - await Promise.all(userIds.map(userId => { - return this._removeRoomFromUserIdentity(roomId, userId, txn); - })); - } else { - await this._removeRoomFromUserIdentity(roomId, memberChange.userId, txn); - } - } + return false; } async _queryKeys(userIds, hsApi, log) { From 17f42f523a66afa0351ed15c51b62119c97794c8 Mon Sep 17 00:00:00 2001 From: Bruno Windels <274386+bwindels@users.noreply.github.com> Date: Fri, 22 Jul 2022 17:49:26 +0200 Subject: [PATCH 124/226] add write method for when history visibility changes also returning added and removed user ids --- src/matrix/e2ee/DeviceTracker.js | 30 ++++++++++++++++++++++++------ 1 file changed, 24 insertions(+), 6 deletions(-) diff --git a/src/matrix/e2ee/DeviceTracker.js b/src/matrix/e2ee/DeviceTracker.js index 7ce601d2..8bed487d 100644 --- a/src/matrix/e2ee/DeviceTracker.js +++ b/src/matrix/e2ee/DeviceTracker.js @@ -16,6 +16,7 @@ limitations under the License. import {verifyEd25519Signature, SIGNATURE_ALGORITHM} from "./common.js"; import {shouldShareKey} from "./common.js"; +import {RoomMember} from "../room/members/RoomMember.js"; const TRACKING_STATUS_OUTDATED = 0; const TRACKING_STATUS_UPTODATE = 1; @@ -142,12 +143,29 @@ export class DeviceTracker { } } - async _writeJoinedMembers(members, txn) { - await Promise.all(members.map(async member => { - if (member.membership === "join") { - await this._writeMember(member, txn); - } - })); + async writeHistoryVisibility(room, historyVisibility, syncTxn, log) { + const added = []; + const removed = []; + if (room.isTrackingMembers && room.isEncrypted) { + await log.wrap("rewriting userIdentities", async log => { + // don't use room.loadMemberList here because we want to use the syncTxn to load the members + const memberDatas = await syncTxn.roomMembers.getAll(room.id); + const members = memberDatas.map(d => new RoomMember(d)); + log.set("members", members.length); + await Promise.all(members.map(async member => { + if (shouldShareKey(member.membership, historyVisibility)) { + if (await this._addRoomToUserIdentity(member.roomId, member.userId, syncTxn)) { + added.push(member.userId); + } + } else { + if (await this._removeRoomFromUserIdentity(member.roomId, member.userId, syncTxn)) { + removed.push(member.userId); + } + } + })); + }); + } + return {added, removed}; } async _addRoomToUserIdentity(roomId, userId, txn) { From a23df8a5457e63cd0f0c10941f88f1631c504624 Mon Sep 17 00:00:00 2001 From: Bruno Windels <274386+bwindels@users.noreply.github.com> Date: Fri, 22 Jul 2022 17:49:59 +0200 Subject: [PATCH 125/226] pass history visibility to device tracker and delegate adding and removing members to share keys with to it --- src/matrix/e2ee/RoomEncryption.js | 74 +++++++++++++++++-------------- 1 file changed, 41 insertions(+), 33 deletions(-) diff --git a/src/matrix/e2ee/RoomEncryption.js b/src/matrix/e2ee/RoomEncryption.js index 8839556f..a841a6bd 100644 --- a/src/matrix/e2ee/RoomEncryption.js +++ b/src/matrix/e2ee/RoomEncryption.js @@ -28,14 +28,6 @@ const ROOM_HISTORY_VISIBILITY_TYPE = "m.room.history_visibility"; // note that encrypt could still create a new session const MIN_PRESHARE_INTERVAL = 60 * 1000; // 1min -// Use enum when converting to TS -const HistoryVisibility = Object.freeze({ - Joined: "joined", - Invited: "invited", - WorldReadable: "world_readable", - Shared: "shared", -}); - // TODO: this class is a good candidate for splitting up into encryption and decryption, there doesn't seem to be much overlap export class RoomEncryption { constructor({room, deviceTracker, olmEncryption, megolmEncryption, megolmDecryption, encryptionParams, storage, keyBackup, notifyMissingMegolmSession, clock}) { @@ -89,37 +81,49 @@ export class RoomEncryption { } async writeSync(roomResponse, memberChanges, txn, log) { - let historyVisibility = this._historyVisibility; - iterateResponseStateEvents(roomResponse, event => { + let historyVisibility = await this._loadHistoryVisibilityIfNeeded(this._historyVisibility, txn); + const addedMembers = []; + const removedMembers = []; + // update the historyVisibility if needed + await iterateResponseStateEvents(roomResponse, event => { + // TODO: can the same state event appear twice? Hence we would be rewriting the useridentities twice... + // we'll see in the logs if(event.state_key === "" && event.type === ROOM_HISTORY_VISIBILITY_TYPE) { - historyVisibility = event?.content?.history_visibility; + const newHistoryVisibility = event?.content?.history_visibility; + if (newHistoryVisibility !== historyVisibility) { + return log.wrap({ + l: "history_visibility changed", + from: historyVisibility, + to: newHistoryVisibility + }, async log => { + historyVisibility = newHistoryVisibility; + const result = await this._deviceTracker.writeHistoryVisibility(this._room, historyVisibility, txn, log); + addedMembers.push(...result.added); + removedMembers.push(...result.removed); + }); + } } }); - let shouldFlush = false; - const memberChangesArray = Array.from(memberChanges.values()); - // this also clears our session if we leave the room ourselves - if (memberChangesArray.some(m => m.hasLeft)) { + // process member changes + if (memberChanges.size) { + const result = await this._deviceTracker.writeMemberChanges( + this._room, memberChanges, historyVisibility, txn); + addedMembers.push(...result.added); + removedMembers.push(...result.removed); + } + // discard key if somebody (including ourselves) left + if (removedMembers.length) { log.log({ l: "discardOutboundSession", - leftUsers: memberChangesArray.filter(m => m.hasLeft).map(m => m.userId), + leftUsers: removedMembers, }); this._megolmEncryption.discardOutboundSession(this._room.id, txn); } - if (memberChangesArray.some(m => m.hasJoined)) { - const userIds = memberChangesArray.filter(m => m.hasJoined).map(m => m.userId); - shouldFlush = await this._addShareRoomKeyOperationForMembers(userIds, txn, log) - || shouldFlush; + let shouldFlush = false; + // add room to userIdentities if needed, and share the current key with them + if (addedMembers.length) { + shouldFlush = await this._addShareRoomKeyOperationForMembers(addedMembers, txn, log); } - if (memberChangesArray.some(m => m.wasInvited)) { - historyVisibility = await this._loadHistoryVisibilityIfNeeded(historyVisibility, txn); - if (historyVisibility === HistoryVisibility.Invited) { - const userIds = memberChangesArray.filter(m => m.wasInvited).map(m => m.userId); - shouldFlush = await this._addShareRoomKeyOperationForMembers(userIds, txn, log) - || shouldFlush; - } - } - - await this._deviceTracker.writeMemberChanges(this._room, memberChanges, txn); return {shouldFlush, historyVisibility}; } @@ -127,8 +131,11 @@ export class RoomEncryption { this._historyVisibility = historyVisibility; } - async _loadHistoryVisibilityIfNeeded(historyVisibility, txn) { + async _loadHistoryVisibilityIfNeeded(historyVisibility, txn = undefined) { if (!historyVisibility) { + if (!txn) { + txn = await this._storage.readTxn([this._storage.storeNames.roomState]); + } const visibilityEntry = await txn.roomState.get(this.id, ROOM_HISTORY_VISIBILITY_TYPE, ""); if (visibilityEntry) { return event?.content?.history_visibility; @@ -316,6 +323,7 @@ export class RoomEncryption { } async _shareNewRoomKey(roomKeyMessage, hsApi, log) { + this._historyVisibility = await this._loadHistoryVisibilityIfNeeded(this._historyVisibility); const devices = await this._deviceTracker.devicesForTrackedRoom(this._room.id, this._historyVisibility, hsApi, log); const userIds = Array.from(devices.reduce((set, device) => set.add(device.userId), new Set())); @@ -386,8 +394,8 @@ export class RoomEncryption { async _processShareRoomKeyOperation(operation, hsApi, log) { log.set("id", operation.id); - - await this._deviceTracker.trackRoom(this._room, log); + this._historyVisibility = await this._loadHistoryVisibilityIfNeeded(this._historyVisibility); + await this._deviceTracker.trackRoom(this._room, this._historyVisibility, log); const devices = await this._deviceTracker.devicesForRoomMembers(this._room.id, operation.userIds, hsApi, log); const messages = await log.wrap("olm encrypt", log => this._olmEncryption.encrypt( "m.room_key", operation.roomKeyMessage, devices, hsApi, log)); From 8a976861fb15ce7b6b5935dd7862b105e790a348 Mon Sep 17 00:00:00 2001 From: RMidhunSuresh Date: Mon, 25 Jul 2022 11:31:14 +0530 Subject: [PATCH 126/226] Add type --- src/platform/types/config.ts | 63 ++++++++++++++++++++++++++++++++++++ 1 file changed, 63 insertions(+) create mode 100644 src/platform/types/config.ts diff --git a/src/platform/types/config.ts b/src/platform/types/config.ts new file mode 100644 index 00000000..2e08657e --- /dev/null +++ b/src/platform/types/config.ts @@ -0,0 +1,63 @@ +/* +Copyright 2022 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. +*/ + +export type Config = { + /** + * The default homeserver used by Hydrogen; auto filled in the login UI. + * eg: https://matrix.org + * REQUIRED + */ + defaultHomeServer: string; + /** + * The submit endpoint for your preferred rageshake server. + * eg: https://element.io/bugreports/submit + * Read more about rageshake at https://github.com/matrix-org/rageshake + * OPTIONAL + */ + bugReportEndpointUrl: string; + /** + * Paths to theme-manifests + * eg: ["assets/theme-element.json", "assets/theme-awesome.json"] + * REQUIRED + */ + themeManifests: string[]; + /** + * This configures the default theme(s) used by Hydrogen. + * These themes appear as "Default" option in the theme chooser UI and are also + * used as a fallback when other themes fail to load. + * Whether the dark or light variant is used depends on the system preference. + * OPTIONAL + */ + defaultTheme: { + // id of light theme + light: string; + // id of dark theme + dark: string; + }; + /** + * Configuration for push notifications. + * See https://spec.matrix.org/latest/client-server-api/#post_matrixclientv3pushersset + * OPTIONAL + */ + push: { + // See app_id in the request body in above link + appId: string; + // The host used for pushing notification + gatewayUrl: string; + // See pushkey in above link + applicationServerKey: string; + }; +}; From 63bdbee39ccd59f7964ad260cfe254c8eb24fae2 Mon Sep 17 00:00:00 2001 From: RMidhunSuresh Date: Mon, 25 Jul 2022 11:33:22 +0530 Subject: [PATCH 127/226] Make optional fields optional --- src/platform/types/config.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/platform/types/config.ts b/src/platform/types/config.ts index 2e08657e..165bd22b 100644 --- a/src/platform/types/config.ts +++ b/src/platform/types/config.ts @@ -27,7 +27,7 @@ export type Config = { * Read more about rageshake at https://github.com/matrix-org/rageshake * OPTIONAL */ - bugReportEndpointUrl: string; + bugReportEndpointUrl?: string; /** * Paths to theme-manifests * eg: ["assets/theme-element.json", "assets/theme-awesome.json"] @@ -41,7 +41,7 @@ export type Config = { * Whether the dark or light variant is used depends on the system preference. * OPTIONAL */ - defaultTheme: { + defaultTheme?: { // id of light theme light: string; // id of dark theme @@ -52,7 +52,7 @@ export type Config = { * See https://spec.matrix.org/latest/client-server-api/#post_matrixclientv3pushersset * OPTIONAL */ - push: { + push?: { // See app_id in the request body in above link appId: string; // The host used for pushing notification From fdd60a7516efdf7b8ad331119e68a24a66cc593f Mon Sep 17 00:00:00 2001 From: RMidhunSuresh Date: Mon, 25 Jul 2022 11:38:50 +0530 Subject: [PATCH 128/226] Add documentation for derived themes --- doc/THEMING.md | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/doc/THEMING.md b/doc/THEMING.md index b6af27cf..5ffadf44 100644 --- a/doc/THEMING.md +++ b/doc/THEMING.md @@ -167,3 +167,38 @@ To find the theme-id of some theme, you can look at the built-asset section of t This default theme will render as "Default" option in the theme-chooser dropdown. If the device preference is for dark theme, the dark default is selected and vice versa. **You'll need to reload twice so that Hydrogen picks up the config changes!** + +# Derived Theme(Collection) +This allows users to theme Hydrogen without the need for rebuilding. Derived theme collections can be thought of as extensions (derivations) of some existing build time theme. + +## Creating a derived theme: +Here's how you create a new derived theme: +1. You create a new theme manifest file (eg: theme-awesome.json) and mention which build time theme you're basing your new theme on using the `extends` field. The base css file of the mentioned theme is used for your new theme. +2. You configue the theme manifest as usual by populating the `variants` field with your desired colors. +3. You add your new theme manifest to the list of themes in `config.json`. + +Reload Hydrogen twice and the new theme should show up in the theme chooser. + +## How does it work? + +For every theme collection in hydrogen, the build process emits a runtime css file which like the built theme css file contains variables in the css code. But unlike the theme css file, the runtime css file lacks the definition for these variables: + +CSS for the built theme: +```css +:root { + --background-color-primary: #f2f20f; +} + +body { + background-color: var(--background-color-primary); +} +``` +and the corresponding runtime theme: +```css +/* Notice the lack of definiton for --background-color-primary here! */ +body { + background-color: var(--background-color-primary); +} +``` + +When hydrogen loads a derived theme, it takes the runtime css file of the extended theme and dynamically adds the variable definition based on the values specified in the manifest. Icons are also colored dynamically and injected as variables using Data URIs. From b48e6b3fb6ba8efd5b1d7091bf68c5154a592444 Mon Sep 17 00:00:00 2001 From: Hugh Nimmo-Smith Date: Mon, 25 Jul 2022 08:58:04 +0100 Subject: [PATCH 129/226] Request urn:matrix:api:* scope for OIDC --- src/domain/login/StartOIDCLoginViewModel.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/domain/login/StartOIDCLoginViewModel.js b/src/domain/login/StartOIDCLoginViewModel.js index d6424f74..4189e581 100644 --- a/src/domain/login/StartOIDCLoginViewModel.js +++ b/src/domain/login/StartOIDCLoginViewModel.js @@ -48,7 +48,7 @@ export class StartOIDCLoginViewModel extends ViewModel { async startOIDCLogin() { const deviceScope = this._api.generateDeviceScope(); const p = this._api.generateParams({ - scope: `openid ${deviceScope}`, + scope: `openid urn:matrix:api:* ${deviceScope}`, redirectUri: this.urlCreator.createOIDCRedirectURL(), }); const clientId = await this._api.clientId(); From 385b9cc71348108d731a2bc0fb61de384a647c23 Mon Sep 17 00:00:00 2001 From: Hugh Nimmo-Smith Date: Mon, 25 Jul 2022 09:00:16 +0100 Subject: [PATCH 130/226] Try to improve error message on no login method available --- src/domain/login/LoginViewModel.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/domain/login/LoginViewModel.js b/src/domain/login/LoginViewModel.js index 63dc1ff3..c0f79369 100644 --- a/src/domain/login/LoginViewModel.js +++ b/src/domain/login/LoginViewModel.js @@ -249,7 +249,7 @@ export class LoginViewModel extends ViewModel { if (this._loginOptions.password) { this._showPasswordLogin(); } if (this._loginOptions.oidc) { this._showOIDCLogin(); } if (!this._loginOptions.sso && !this._loginOptions.password && !this._loginOptions.oidc) { - this._showError("This homeserver supports neither SSO nor password based login flows"); + this._showError("This homeserver supports neither SSO nor password based login flows or has a usable OIDC Provider"); } } else { From 60c149713bcea4046376c683b7e2868e469cd4c4 Mon Sep 17 00:00:00 2001 From: Hugh Nimmo-Smith Date: Mon, 25 Jul 2022 09:00:30 +0100 Subject: [PATCH 131/226] fix: hide OIDC button when not in use --- src/domain/login/LoginViewModel.js | 1 + 1 file changed, 1 insertion(+) diff --git a/src/domain/login/LoginViewModel.js b/src/domain/login/LoginViewModel.js index c0f79369..6e850ba5 100644 --- a/src/domain/login/LoginViewModel.js +++ b/src/domain/login/LoginViewModel.js @@ -183,6 +183,7 @@ export class LoginViewModel extends ViewModel { this._startSSOLoginViewModel = this.disposeTracked(this._ssoLoginViewModel); this._passwordLoginViewModel = this.disposeTracked(this._passwordLoginViewModel); this._completeSSOLoginViewModel = this.disposeTracked(this._completeSSOLoginViewModel); + this._startOIDCLoginViewModel = this.disposeTracked(this._startOIDCLoginViewModel); this.emitChange("disposeViewModels"); } From a5554b3b1b023a1492642694d6f5092e82408075 Mon Sep 17 00:00:00 2001 From: Hugh Nimmo-Smith Date: Mon, 25 Jul 2022 09:06:30 +0100 Subject: [PATCH 132/226] Use primary styling for OIDC login button --- src/platform/web/ui/login/LoginView.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/platform/web/ui/login/LoginView.js b/src/platform/web/ui/login/LoginView.js index e1783183..b44de2e4 100644 --- a/src/platform/web/ui/login/LoginView.js +++ b/src/platform/web/ui/login/LoginView.js @@ -82,7 +82,7 @@ class StartOIDCLoginView extends TemplateView { render(t, vm) { return t.div({ className: "StartOIDCLoginView" }, t.a({ - className: "StartOIDCLoginView_button button-action secondary", + className: "StartOIDCLoginView_button button-action primary", type: "button", onClick: () => vm.startOIDCLogin(), disabled: vm => vm.isBusy From a5ef671f46efe1537cae73baa2a72acd325bcba7 Mon Sep 17 00:00:00 2001 From: Hugh Nimmo-Smith Date: Mon, 25 Jul 2022 09:06:49 +0100 Subject: [PATCH 133/226] Handle case of OIDC Provider not returning supported_grant_types --- src/matrix/net/OidcApi.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/matrix/net/OidcApi.ts b/src/matrix/net/OidcApi.ts index b8d459b3..832c94e7 100644 --- a/src/matrix/net/OidcApi.ts +++ b/src/matrix/net/OidcApi.ts @@ -153,7 +153,7 @@ export class OidcApi { assert(typeof m.registration_endpoint === "string", "Has a registration endpoint"); assert(Array.isArray(m.response_types_supported) && m.response_types_supported.includes("code"), "Supports the code response type"); assert(Array.isArray(m.response_modes_supported) && m.response_modes_supported.includes("fragment"), "Supports the fragment response mode"); - assert(Array.isArray(m.grant_types_supported) && m.grant_types_supported.includes("authorization_code"), "Supports the authorization_code grant type"); + assert(typeof m.authorization_endpoint === "string" || (Array.isArray(m.grant_types_supported) && m.grant_types_supported.includes("authorization_code")), "Supports the authorization_code grant type"); assert(Array.isArray(m.code_challenge_methods_supported) && m.code_challenge_methods_supported.includes("S256"), "Supports the authorization_code grant type"); } From 778900d0b27d08569e01e057544d8d9ae8f8dc9c Mon Sep 17 00:00:00 2001 From: Hugh Nimmo-Smith Date: Mon, 25 Jul 2022 09:12:48 +0100 Subject: [PATCH 134/226] Handle case of issuer field not ending with / --- src/matrix/net/OidcApi.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/matrix/net/OidcApi.ts b/src/matrix/net/OidcApi.ts index 832c94e7..1d0db462 100644 --- a/src/matrix/net/OidcApi.ts +++ b/src/matrix/net/OidcApi.ts @@ -90,7 +90,7 @@ export class OidcApi { } get metadataUrl() { - return new URL(WELL_KNOWN, this._issuer).toString(); + return new URL(WELL_KNOWN, `${this._issuer}${this._issuer.endsWith('/') ? '' : '/'}`).toString(); } get issuer() { From 97bea400f610bac160d735ce2fbd3be432183ac4 Mon Sep 17 00:00:00 2001 From: Hugh Nimmo-Smith Date: Mon, 25 Jul 2022 09:22:06 +0100 Subject: [PATCH 135/226] Improve error handling for OIDC discovery and registration --- src/domain/login/LoginViewModel.js | 7 ++++++- src/domain/login/StartOIDCLoginViewModel.js | 14 ++++++++++++-- 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/src/domain/login/LoginViewModel.js b/src/domain/login/LoginViewModel.js index 6e850ba5..63a98e76 100644 --- a/src/domain/login/LoginViewModel.js +++ b/src/domain/login/LoginViewModel.js @@ -117,7 +117,12 @@ export class LoginViewModel extends ViewModel { new StartOIDCLoginViewModel(this.childOptions({loginOptions: this._loginOptions})) ); this.emitChange("startOIDCLoginViewModel"); - this._startOIDCLoginViewModel.discover(); + try { + await this._startOIDCLoginViewModel.discover(); + } catch (err) { + this._showError(err.message); + this._disposeViewModels(); + } } _showError(message) { diff --git a/src/domain/login/StartOIDCLoginViewModel.js b/src/domain/login/StartOIDCLoginViewModel.js index 4189e581..70980e32 100644 --- a/src/domain/login/StartOIDCLoginViewModel.js +++ b/src/domain/login/StartOIDCLoginViewModel.js @@ -41,8 +41,18 @@ export class StartOIDCLoginViewModel extends ViewModel { async discover() { // Ask for the metadata once so it gets discovered and cached - await this._api.metadata() - await this._api.registration(); + try { + await this._api.metadata() + } catch (err) { + this.logger.log("Failed to discover OIDC metadata: " + err); + throw new Error("Failed to discover OIDC metadata: " + err.message ); + } + try { + await this._api.registration(); + } catch (err) { + this.logger.log("Failed to register OIDC client: " + err); + throw new Error("Failed to register OIDC client: " + err.message ); + } } async startOIDCLogin() { From 0bf021ea87f24ea029a6c86ea9c8859214aa0a33 Mon Sep 17 00:00:00 2001 From: Kaki In <91763754+Kaki-In@users.noreply.github.com> Date: Mon, 25 Jul 2022 13:37:03 +0200 Subject: [PATCH 136/226] The room is now joined after having actualised the rooms list, to avoid the synchronisations waits that can sometimes disable to enter the room (message "You're not into this room" or simply "You're not in this room yet. *Join the room*") --- src/domain/session/room/RoomViewModel.js | 2 +- src/matrix/Session.js | 17 +++++++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/src/domain/session/room/RoomViewModel.js b/src/domain/session/room/RoomViewModel.js index b3fc703c..a15447b8 100644 --- a/src/domain/session/room/RoomViewModel.js +++ b/src/domain/session/room/RoomViewModel.js @@ -210,8 +210,8 @@ export class RoomViewModel extends ViewModel { if (args.length == 1) { let roomName = args[0]; try { - await this._options.client.session.joinRoom(roomName); const internalId = await this._options.client.session.joinRoom(roomName); + await this._options.client.session.waitForRoomFromSync(internalId); this.navigation.push("room", internalId); } catch (exc) { if ((exc.statusCode ?? exc.status) === 400) { diff --git a/src/matrix/Session.js b/src/matrix/Session.js index ae1dea61..f45cb5b3 100644 --- a/src/matrix/Session.js +++ b/src/matrix/Session.js @@ -948,6 +948,23 @@ export class Session { return body.room_id; }); } + + waitForRoomFromSync(roomId) { + let resolve; + const promise = new Promise(r => { resolve = r; }) + const subscription = { + onAdd: (_, value) => { + if (value.id === roomId) { + this._session.rooms.unsubscribe(subscription); + resolve(); + } + }, + onUpdate: () => undefined, + onRemove: () => undefined, + }; + this._session.rooms.subscribe(subscription); + return promise; + } } export function tests() { From 1e5179f8356ecf16221bb9ca1ddc2057702a60f6 Mon Sep 17 00:00:00 2001 From: Kaki In <91763754+Kaki-In@users.noreply.github.com> Date: Mon, 25 Jul 2022 15:22:06 +0200 Subject: [PATCH 137/226] =?UTF-8?q?=20-=20Application=20des=20diff=C3=A9re?= =?UTF-8?q?nts=20commentaires=20du=20Pull=20Request=20(#809)=20=20-=20Corr?= =?UTF-8?q?ection=20des=20erreurs=20d'indentations.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/domain/session/room/RoomViewModel.js | 124 +++++++++--------- src/matrix/Session.js | 17 --- src/matrix/common.js | 18 +-- .../web/ui/css/themes/element/theme.css | 51 +++---- src/platform/web/ui/session/room/RoomView.js | 12 +- 5 files changed, 105 insertions(+), 117 deletions(-) diff --git a/src/domain/session/room/RoomViewModel.js b/src/domain/session/room/RoomViewModel.js index a15447b8..9b6aa966 100644 --- a/src/domain/session/room/RoomViewModel.js +++ b/src/domain/session/room/RoomViewModel.js @@ -197,77 +197,77 @@ export class RoomViewModel extends ViewModel { } } - async _getMessageInformations (message) { - let msgtype = "m.text"; - if (message.startsWith("/")) { - const [commandName, ...args] = message.substring(1).split(" "); - switch (commandName) { - case "me": - message = message.substring(4).trim(); - msgtype = "m.emote"; - break; - case "join": - if (args.length == 1) { - let roomName = args[0]; - try { - const internalId = await this._options.client.session.joinRoom(roomName); - await this._options.client.session.waitForRoomFromSync(internalId); - this.navigation.push("room", internalId); - } catch (exc) { - if ((exc.statusCode ?? exc.status) === 400) { - this._sendError = new Error(`/join : '${roomName}' was not legal room ID or room alias`); - } else if ((exc.statusCode ?? exc.status) === 404 || (exc.statusCode ?? exc.status) === 502 || exc.message == "Internal Server Error") { - this._sendError = new Error(`/join : room '${roomName}' not found`); - } else if ((exc.statusCode ?? exc.status) === 403) { - this._sendError = new Error(`/join : you're not invited to join '${roomName}'`); - } else { - this._sendError = new Error("join syntax: /join "); - } - this._timelineError = null; - this.emitChange("error"); - } - } else { - this._sendError = new Error("join syntax: /join "); - this._timelineError = null; - this.emitChange("error"); - } - msgtype = undefined; - message = undefined; - break; - case "shrug": - message = "¯\\_(ツ)_/¯ " + message.substring(7); - break; - case "tableflip": - message="(╯°□°)╯︵ ┻━┻ " + message.substring(11); - break; - case "unflip": - message="┬──┬ ノ( ゜-゜ノ) " + message.substring(8); - break; - case "lenny": - message="( ͡° ͜ʖ ͡°) " + message.substring(7); - break; - default: - if (commandName[0] == "/") { - message = message.substring(1).trim(); - break; - } else { - this._sendError = new Error(`no command name "${commandName}". To send the message instead of executing, please type "/${message}"`); + async _processCommand (message) { + let msgtype = undefined; + const [commandName, ...args] = message.substring(1).split(" "); + switch (commandName) { + case "me": + message = message.substring(4).trim(); + msgtype = "m.emote"; + break; + case "join": + if (args.length == 1) { + let roomName = args[0]; + try { + const roomId = await this._options.client.session.joinRoom(roomName); + await session.observeRoomStatus(roomId).waitFor(status === RoomStatus.Joined); + this.navigation.push("room", roomId); + } catch (exc) { + if ((exc.statusCode ?? exc.status) === 400) { + this._sendError = new Error(`/join : '${roomName}' was not legal room ID or room alias`); + } else if ((exc.statusCode ?? exc.status) === 404 || (exc.statusCode ?? exc.status) === 502 || exc.message == "Internal Server Error") { + this._sendError = new Error(`/join : room '${roomName}' not found`); + } else if ((exc.statusCode ?? exc.status) === 403) { + this._sendError = new Error(`/join : you're not invited to join '${roomName}'`); + } else { + this._sendError = new Error("join syntax: /join "); + } this._timelineError = null; this.emitChange("error"); - msgtype = undefined; - message = undefined; - } - } + } + } else { + this._sendError = new Error("join syntax: /join "); + this._timelineError = null; + this.emitChange("error"); + } + break; + case "shrug": + message = "¯\\_(ツ)_/¯ " + message.substring(7); + msgtype = "m.text"; + break; + case "tableflip": + message="(╯°□°)╯︵ ┻━┻ " + message.substring(11); + msgtype = "m.text"; + break; + case "unflip": + message="┬──┬ ノ( ゜-゜ノ) " + message.substring(8); + msgtype = "m.text"; + break; + case "lenny": + message="( ͡° ͜ʖ ͡°) " + message.substring(7); + msgtype = "m.text"; + break; + default: + this._sendError = new Error(`no command name "${commandName}". To send the message instead of executing, please type "/${message}"`); + this._timelineError = null; + this.emitChange("error"); + msgtype = undefined; + message = undefined; } return {type: msgtype, message: message}; } async _sendMessage(message, replyingTo) { if (!this._room.isArchived && message) { - let messinfo = await this._getMessageInformations(message); + let messinfo = {msgtype : "m.text", message : message}; + if (message.startsWith("//")) { + messinfo.message = message.substring(1).trim(); + } else if (message.startsWith("/")) { + messinfo = await this._processCommand(message); + } try { - let msgtype = messinfo.type; - let message = messinfo.message; + const msgtype = messinfo.type; + const message = messinfo.message; if (msgtype && message) { if (replyingTo) { await replyingTo.reply(msgtype, message); diff --git a/src/matrix/Session.js b/src/matrix/Session.js index f45cb5b3..ae1dea61 100644 --- a/src/matrix/Session.js +++ b/src/matrix/Session.js @@ -948,23 +948,6 @@ export class Session { return body.room_id; }); } - - waitForRoomFromSync(roomId) { - let resolve; - const promise = new Promise(r => { resolve = r; }) - const subscription = { - onAdd: (_, value) => { - if (value.id === roomId) { - this._session.rooms.unsubscribe(subscription); - resolve(); - } - }, - onUpdate: () => undefined, - onRemove: () => undefined, - }; - this._session.rooms.subscribe(subscription); - return promise; - } } export function tests() { diff --git a/src/matrix/common.js b/src/matrix/common.js index ba7876ed..abd74a57 100644 --- a/src/matrix/common.js +++ b/src/matrix/common.js @@ -22,16 +22,16 @@ export function makeTxnId() { } export function isTxnId(txnId) { - return txnId.startsWith("t") && txnId.length === 15; + return txnId.startsWith("t") && txnId.length === 15; } export function tests() { - return { - "isTxnId succeeds on result of makeTxnId": assert => { - assert(isTxnId(makeTxnId())); - }, - "isTxnId fails on event id": assert => { - assert(!isTxnId("$yS_n5n3cIO2aTtek0_2ZSlv-7g4YYR2zKrk2mFCW_rm")); - }, - } + return { + "isTxnId succeeds on result of makeTxnId": assert => { + assert(isTxnId(makeTxnId())); + }, + "isTxnId fails on event id": assert => { + assert(!isTxnId("$yS_n5n3cIO2aTtek0_2ZSlv-7g4YYR2zKrk2mFCW_rm")); + }, + } } diff --git a/src/platform/web/ui/css/themes/element/theme.css b/src/platform/web/ui/css/themes/element/theme.css index f14100b2..f611c767 100644 --- a/src/platform/web/ui/css/themes/element/theme.css +++ b/src/platform/web/ui/css/themes/element/theme.css @@ -522,26 +522,31 @@ a { .RoomView_error { color: var(--error-color); background : #efefef; - padding-right : 20px; - padding-left : 20px; - overflow : hidden; height : 0px; font-weight : bold; transition : 0.25s all ease-out; + padding-right : 20px; + padding-left : 20px; +} + +.RoomView_error div{ + overflow : hidden; + height: 100%; + width: 100%; position : relative; display : flex; + align-items : center; } .RoomView_error:not(:empty) { height : 40px; - align-items : center; padding-top : 20px; padding-bottom : 20px; } .RoomView_error p { - position : relative; - display : block; + position : relative; + display : block; width : 100%; height : auto; margin : 0; @@ -557,29 +562,29 @@ a { } .RoomView_error button:hover { - background : #cfcfcf; + background : #cfcfcf; } .RoomView_error button:after { - content:""; - position : absolute; - top : 10px; - right: 16px; - background : red; - width : 5px; - height : 20px; - transform: rotate(45deg); + content:""; + position : absolute; + top : 10px; + right: 16px; + background : red; + width : 5px; + height : 20px; + transform: rotate(45deg); } .RoomView_error button:before { - content:""; - position : absolute; - top : 17px; - left: 10px; - background : red; - width : 20px; - height : 5px; - transform: rotate(45deg); + content:""; + position : absolute; + top : 17px; + left: 10px; + background : red; + width : 20px; + height : 5px; + transform: rotate(45deg); } .MessageComposer_replyPreview .Timeline_message { diff --git a/src/platform/web/ui/session/room/RoomView.js b/src/platform/web/ui/session/room/RoomView.js index 7cfd7d85..e3eb0587 100644 --- a/src/platform/web/ui/session/room/RoomView.js +++ b/src/platform/web/ui/session/room/RoomView.js @@ -47,12 +47,12 @@ export class RoomView extends TemplateView { ]), t.div({className: "RoomView_body"}, [ t.div({className: "RoomView_error"}, [ - t.if(vm => vm.error, t => t.p({}, vm => vm.error)), - t.if(vm => vm.error, t => t.button({ - className: "RoomView_error_closerButton", - onClick: evt => vm.dismissError(evt) - })) - ]), + t.if(vm => vm.error, t => t.div( + [ + t.p({}, vm => vm.error), + t.button({ className: "RoomView_error_closerButton", onClick: evt => vm.dismissError(evt) }) + ]) + )]), t.mapView(vm => vm.timelineViewModel, timelineViewModel => { return timelineViewModel ? new TimelineView(timelineViewModel, this._viewClassForTile) : From 9de574319e46b49bf5d03dcca4f9ce26cbe45ea7 Mon Sep 17 00:00:00 2001 From: Hugh Nimmo-Smith Date: Mon, 25 Jul 2022 15:34:35 +0100 Subject: [PATCH 138/226] Ask OP to revoke tokens on logout --- src/matrix/Client.js | 11 +++++++++++ src/matrix/net/OidcApi.ts | 36 ++++++++++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+) diff --git a/src/matrix/Client.js b/src/matrix/Client.js index f76f39c6..d51f23a3 100644 --- a/src/matrix/Client.js +++ b/src/matrix/Client.js @@ -480,6 +480,17 @@ export class Client { request: this._platform.request }); await hsApi.logout({log}).response(); + const oidcApi = new OidcApi({ + issuer: sessionInfo.oidcIssuer, + clientId: sessionInfo.oidcClientId, + request: this._platform.request, + encoding: this._platform.encoding, + crypto: this._platform.crypto, + }); + await oidcApi.revokeToken({ token: sessionInfo.accessToken, type: "access" }); + if (sessionInfo.refreshToken) { + await oidcApi.revokeToken({ token: sessionInfo.refreshToken, type: "refresh" }); + } } catch (err) {} await this.deleteSession(log); }); diff --git a/src/matrix/net/OidcApi.ts b/src/matrix/net/OidcApi.ts index 1d0db462..5a801952 100644 --- a/src/matrix/net/OidcApi.ts +++ b/src/matrix/net/OidcApi.ts @@ -203,6 +203,11 @@ export class OidcApi { return metadata["registration_endpoint"]; } + async revocationEndpoint(): Promise { + const metadata = await this.metadata(); + return metadata["revocation_endpoint"]; + } + generateDeviceScope(): String { const deviceId = randomString(10); return `urn:matrix:device:${deviceId}`; @@ -281,4 +286,35 @@ export class OidcApi { return token; } + + async revokeToken({ + token, + type, + }: { token: string, type: "refresh" | "access" }): Promise { + const revocationEndpoint = await this.revocationEndpoint(); + if (!revocationEndpoint) { + return; + } + + const params = new URLSearchParams(); + params.append("token_type", type); + params.append("token", token); + params.append("client_id", await this.clientId()); + const body = params.toString(); + + const headers = new Map(); + headers.set("Content-Type", "application/x-www-form-urlencoded"); + + const req = this._requestFn(revocationEndpoint, { + method: "POST", + headers, + format: "json", + body, + }); + + const res = await req.response(); + if (res.status >= 400) { + throw new Error("failed to revoke token"); + } + } } From adfecf0778838b7167c1264b4125e6d2d9fdef73 Mon Sep 17 00:00:00 2001 From: Kaki In <91763754+Kaki-In@users.noreply.github.com> Date: Tue, 26 Jul 2022 10:02:20 +0200 Subject: [PATCH 139/226] Fix restoring the last url at start The last session url is now remembered for being restored at the beginning of the session. Thanks for the help of @bwindels --- src/domain/navigation/URLRouter.js | 4 ++-- src/matrix/common.js | 18 +++++++++--------- src/platform/web/dom/History.js | 5 +++-- 3 files changed, 14 insertions(+), 13 deletions(-) diff --git a/src/domain/navigation/URLRouter.js b/src/domain/navigation/URLRouter.js index 586eec8a..ab5dc5ce 100644 --- a/src/domain/navigation/URLRouter.js +++ b/src/domain/navigation/URLRouter.js @@ -27,7 +27,7 @@ export class URLRouter { } _getLastSessionId() { - const navPath = this._urlAsNavPath(this._history.getLastUrl() || ""); + const navPath = this._urlAsNavPath(this._history.getLastSessionUrl() || ""); const sessionId = navPath.get("session")?.value; if (typeof sessionId === "string") { return sessionId; @@ -84,7 +84,7 @@ export class URLRouter { } tryRestoreLastUrl() { - const lastNavPath = this._urlAsNavPath(this._history.getLastUrl() || ""); + const lastNavPath = this._urlAsNavPath(this._history.getLastSessionUrl() || ""); if (lastNavPath.segments.length !== 0) { this._applyNavPathToNavigation(lastNavPath); return true; diff --git a/src/matrix/common.js b/src/matrix/common.js index ba7876ed..abd74a57 100644 --- a/src/matrix/common.js +++ b/src/matrix/common.js @@ -22,16 +22,16 @@ export function makeTxnId() { } export function isTxnId(txnId) { - return txnId.startsWith("t") && txnId.length === 15; + return txnId.startsWith("t") && txnId.length === 15; } export function tests() { - return { - "isTxnId succeeds on result of makeTxnId": assert => { - assert(isTxnId(makeTxnId())); - }, - "isTxnId fails on event id": assert => { - assert(!isTxnId("$yS_n5n3cIO2aTtek0_2ZSlv-7g4YYR2zKrk2mFCW_rm")); - }, - } + return { + "isTxnId succeeds on result of makeTxnId": assert => { + assert(isTxnId(makeTxnId())); + }, + "isTxnId fails on event id": assert => { + assert(!isTxnId("$yS_n5n3cIO2aTtek0_2ZSlv-7g4YYR2zKrk2mFCW_rm")); + }, + } } diff --git a/src/platform/web/dom/History.js b/src/platform/web/dom/History.js index d51974bb..49102e5c 100644 --- a/src/platform/web/dom/History.js +++ b/src/platform/web/dom/History.js @@ -65,6 +65,7 @@ export class History extends BaseObservableValue { } onSubscribeFirst() { + this._lastSessionHash = window.localStorage?.getItem("hydrogen_last_url_hash"); window.addEventListener('hashchange', this); } @@ -76,7 +77,7 @@ export class History extends BaseObservableValue { window.localStorage?.setItem("hydrogen_last_url_hash", hash); } - getLastUrl() { - return window.localStorage?.getItem("hydrogen_last_url_hash"); + getLastSessionUrl() { + return this._lastSessionHash; } } From 09fd1a5113963079a6fe2f30d225af3bd3736bc0 Mon Sep 17 00:00:00 2001 From: Kaki In <91763754+Kaki-In@users.noreply.github.com> Date: Tue, 26 Jul 2022 10:37:05 +0200 Subject: [PATCH 140/226] Use "args.join" instead of "message.substring" into RoomViewModel._processCommands --- src/domain/session/room/RoomViewModel.js | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/domain/session/room/RoomViewModel.js b/src/domain/session/room/RoomViewModel.js index 9b6aa966..a1939f75 100644 --- a/src/domain/session/room/RoomViewModel.js +++ b/src/domain/session/room/RoomViewModel.js @@ -202,7 +202,7 @@ export class RoomViewModel extends ViewModel { const [commandName, ...args] = message.substring(1).split(" "); switch (commandName) { case "me": - message = message.substring(4).trim(); + message = args.join(" "); msgtype = "m.emote"; break; case "join": @@ -232,19 +232,19 @@ export class RoomViewModel extends ViewModel { } break; case "shrug": - message = "¯\\_(ツ)_/¯ " + message.substring(7); + message = "¯\\_(ツ)_/¯ " + args.join(" "); msgtype = "m.text"; break; case "tableflip": - message="(╯°□°)╯︵ ┻━┻ " + message.substring(11); + message="(╯°□°)╯︵ ┻━┻ " + args.join(" "); msgtype = "m.text"; break; case "unflip": - message="┬──┬ ノ( ゜-゜ノ) " + message.substring(8); + message="┬──┬ ノ( ゜-゜ノ) " + args.join(" "); msgtype = "m.text"; break; case "lenny": - message="( ͡° ͜ʖ ͡°) " + message.substring(7); + message="( ͡° ͜ʖ ͡°) " + args.join(" "); msgtype = "m.text"; break; default: From 0718f1e77ebd3a8af4566df7ec2062254a1843f2 Mon Sep 17 00:00:00 2001 From: Kaki In <91763754+Kaki-In@users.noreply.github.com> Date: Tue, 26 Jul 2022 11:11:16 +0200 Subject: [PATCH 141/226] Fixed the https://github.com/vector-im/hydrogen-web/pull/816#discussion_r929692693 comment Added the _lastSessionHash attribute inside the History constructor --- src/platform/web/dom/History.js | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/platform/web/dom/History.js b/src/platform/web/dom/History.js index 49102e5c..ab47e18a 100644 --- a/src/platform/web/dom/History.js +++ b/src/platform/web/dom/History.js @@ -17,6 +17,12 @@ limitations under the License. import {BaseObservableValue} from "../../../observable/ObservableValue"; export class History extends BaseObservableValue { + + constructor() { + super(); + this._lastSessionHash = null; + } + handleEvent(event) { if (event.type === "hashchange") { this.emit(this.get()); From f9f49b76403482ef657d7e2638660c4d3c48ca8e Mon Sep 17 00:00:00 2001 From: Kaki In <91763754+Kaki-In@users.noreply.github.com> Date: Tue, 26 Jul 2022 14:48:03 +0200 Subject: [PATCH 142/226] Fixed an error and improving css If the /join command success, an error was thrown, because of a copy-pasted command not well integrated The button of the error on "theme.css" contains now an unicode cross. The :after/:before cross was disformed when opening the room informations. --- src/domain/session/room/RoomViewModel.js | 5 ++-- .../web/ui/css/themes/element/theme.css | 24 +++++++------------ 2 files changed, 11 insertions(+), 18 deletions(-) diff --git a/src/domain/session/room/RoomViewModel.js b/src/domain/session/room/RoomViewModel.js index a1939f75..039996cd 100644 --- a/src/domain/session/room/RoomViewModel.js +++ b/src/domain/session/room/RoomViewModel.js @@ -23,6 +23,7 @@ import {imageToInfo} from "../common.js"; // TODO: remove fallback so default isn't included in bundle for SDK users that have their custom tileClassForEntry // this is a breaking SDK change though to make this option mandatory import {tileClassForEntry as defaultTileClassForEntry} from "./timeline/tiles/index"; +import {RoomStatus} from "../../../matrix/room/common"; export class RoomViewModel extends ViewModel { constructor(options) { @@ -210,7 +211,7 @@ export class RoomViewModel extends ViewModel { let roomName = args[0]; try { const roomId = await this._options.client.session.joinRoom(roomName); - await session.observeRoomStatus(roomId).waitFor(status === RoomStatus.Joined); + await (await this._options.client.session.observeRoomStatus(roomId)).waitFor(status => status === RoomStatus.Joined); this.navigation.push("room", roomId); } catch (exc) { if ((exc.statusCode ?? exc.status) === 400) { @@ -220,7 +221,7 @@ export class RoomViewModel extends ViewModel { } else if ((exc.statusCode ?? exc.status) === 403) { this._sendError = new Error(`/join : you're not invited to join '${roomName}'`); } else { - this._sendError = new Error("join syntax: /join "); + this._sendError = exc; } this._timelineError = null; this.emitChange("error"); diff --git a/src/platform/web/ui/css/themes/element/theme.css b/src/platform/web/ui/css/themes/element/theme.css index f611c767..0f40bdf3 100644 --- a/src/platform/web/ui/css/themes/element/theme.css +++ b/src/platform/web/ui/css/themes/element/theme.css @@ -559,32 +559,24 @@ a { background : none; border : none; position : relative; + border-radius : 5px; + transition: 0.1s all ease-out; + cursor: pointer; } .RoomView_error button:hover { background : #cfcfcf; } -.RoomView_error button:after { - content:""; - position : absolute; - top : 10px; - right: 16px; - background : red; - width : 5px; - height : 20px; - transform: rotate(45deg); -} - .RoomView_error button:before { - content:""; + content:"\274c"; position : absolute; - top : 17px; + top : 15px; left: 10px; - background : red; width : 20px; - height : 5px; - transform: rotate(45deg); + height : 10px; + font-size : 10px; + align-self : middle; } .MessageComposer_replyPreview .Timeline_message { From 4c17612b0588143a84d35a2f24928a646a117f70 Mon Sep 17 00:00:00 2001 From: Bruno Windels <274386+bwindels@users.noreply.github.com> Date: Tue, 26 Jul 2022 16:53:02 +0200 Subject: [PATCH 143/226] allow passing txn to loadMembers so we can do it as part of sync txn to rewrite useridentities upon receiving new history visibility --- src/matrix/e2ee/DeviceTracker.js | 13 +++++++------ src/matrix/room/BaseRoom.js | 5 ++++- src/matrix/room/members/load.js | 10 ++++++---- 3 files changed, 17 insertions(+), 11 deletions(-) diff --git a/src/matrix/e2ee/DeviceTracker.js b/src/matrix/e2ee/DeviceTracker.js index 8bed487d..1ba59d0a 100644 --- a/src/matrix/e2ee/DeviceTracker.js +++ b/src/matrix/e2ee/DeviceTracker.js @@ -116,12 +116,13 @@ export class DeviceTracker { if (room.isTrackingMembers || !room.isEncrypted) { return; } - const memberList = await room.loadMemberList(log); + const txn = await this._storage.readWriteTxn([ + this._storage.storeNames.roomSummary, + this._storage.storeNames.userIdentities, + ]); + let memberList; try { - const txn = await this._storage.readWriteTxn([ - this._storage.storeNames.roomSummary, - this._storage.storeNames.userIdentities, - ]); + memberList = await room.loadMemberList(txn, log); let isTrackingChanges; try { isTrackingChanges = room.writeIsTrackingMembers(true, txn); @@ -139,7 +140,7 @@ export class DeviceTracker { await txn.complete(); room.applyIsTrackingMembersChanges(isTrackingChanges); } finally { - memberList.release(); + memberList?.release(); } } diff --git a/src/matrix/room/BaseRoom.js b/src/matrix/room/BaseRoom.js index dda3e2e5..57d2a7b2 100644 --- a/src/matrix/room/BaseRoom.js +++ b/src/matrix/room/BaseRoom.js @@ -243,7 +243,7 @@ export class BaseRoom extends EventEmitter { /** @public */ - async loadMemberList(log = null) { + async loadMemberList(txn = undefined, log = null) { if (this._memberList) { // TODO: also await fetchOrLoadMembers promise here this._memberList.retain(); @@ -254,6 +254,9 @@ export class BaseRoom extends EventEmitter { roomId: this._roomId, hsApi: this._hsApi, storage: this._storage, + // pass in a transaction if we know we won't need to fetch (which would abort the transaction) + // and we want to make this operation part of the larger transaction + txn, syncToken: this._getSyncToken(), // to handle race between /members and /sync setChangedMembersMap: map => this._changedMembersDuringSync = map, diff --git a/src/matrix/room/members/load.js b/src/matrix/room/members/load.js index 5077d793..3d0556fc 100644 --- a/src/matrix/room/members/load.js +++ b/src/matrix/room/members/load.js @@ -17,10 +17,12 @@ limitations under the License. import {RoomMember} from "./RoomMember.js"; -async function loadMembers({roomId, storage}) { - const txn = await storage.readTxn([ - storage.storeNames.roomMembers, - ]); +async function loadMembers({roomId, storage, txn}) { + if (!txn) { + txn = await storage.readTxn([ + storage.storeNames.roomMembers, + ]); + } const memberDatas = await txn.roomMembers.getAll(roomId); return memberDatas.map(d => new RoomMember(d)); } From dea3852425eda1ec668b89f9afaa0df7843d3b26 Mon Sep 17 00:00:00 2001 From: Bruno Windels <274386+bwindels@users.noreply.github.com> Date: Tue, 26 Jul 2022 16:57:28 +0200 Subject: [PATCH 144/226] add some tests for sharing keys with invitees --- src/matrix/e2ee/DeviceTracker.js | 165 +++++++++++++++++++++++++++---- 1 file changed, 144 insertions(+), 21 deletions(-) diff --git a/src/matrix/e2ee/DeviceTracker.js b/src/matrix/e2ee/DeviceTracker.js index 1ba59d0a..582a0e31 100644 --- a/src/matrix/e2ee/DeviceTracker.js +++ b/src/matrix/e2ee/DeviceTracker.js @@ -15,7 +15,7 @@ limitations under the License. */ import {verifyEd25519Signature, SIGNATURE_ALGORITHM} from "./common.js"; -import {shouldShareKey} from "./common.js"; +import {HistoryVisibility, shouldShareKey} from "./common.js"; import {RoomMember} from "../room/members/RoomMember.js"; const TRACKING_STATUS_OUTDATED = 0; @@ -150,20 +150,24 @@ export class DeviceTracker { if (room.isTrackingMembers && room.isEncrypted) { await log.wrap("rewriting userIdentities", async log => { // don't use room.loadMemberList here because we want to use the syncTxn to load the members - const memberDatas = await syncTxn.roomMembers.getAll(room.id); - const members = memberDatas.map(d => new RoomMember(d)); - log.set("members", members.length); - await Promise.all(members.map(async member => { - if (shouldShareKey(member.membership, historyVisibility)) { - if (await this._addRoomToUserIdentity(member.roomId, member.userId, syncTxn)) { - added.push(member.userId); + const memberList = await room.loadMemberList(syncTxn, log); + try { + const members = Array.from(memberList.members.values()); + log.set("members", members.length); + await Promise.all(members.map(async member => { + if (shouldShareKey(member.membership, historyVisibility)) { + if (await this._addRoomToUserIdentity(member.roomId, member.userId, syncTxn)) { + added.push(member.userId); + } + } else { + if (await this._removeRoomFromUserIdentity(member.roomId, member.userId, syncTxn)) { + removed.push(member.userId); + } } - } else { - if (await this._removeRoomFromUserIdentity(member.roomId, member.userId, syncTxn)) { - removed.push(member.userId); - } - } - })); + })); + } finally { + memberList.release(); + } }); } return {added, removed}; @@ -399,6 +403,7 @@ export class DeviceTracker { import {createMockStorage} from "../../mocks/Storage"; import {Instance as NullLoggerInstance} from "../../logging/NullLogger"; +import {MemberChange} from "../room/members/RoomMember"; export function tests() { @@ -407,8 +412,8 @@ export function tests() { isTrackingMembers: false, isEncrypted: true, loadMemberList: () => { - const joinedMembers = joinedUserIds.map(userId => {return {membership: "join", roomId, userId};}); - const invitedMembers = invitedUserIds.map(userId => {return {membership: "invite", roomId, userId};}); + const joinedMembers = joinedUserIds.map(userId => {return RoomMember.fromUserId(roomId, userId, "join");}); + const invitedMembers = invitedUserIds.map(userId => {return RoomMember.fromUserId(roomId, userId, "invite");}); const members = joinedMembers.concat(invitedMembers); const memberMap = members.reduce((map, member) => { map.set(member.userId, member); @@ -472,10 +477,29 @@ export function tests() { } }; } + + async function writeMemberListToStorage(room, storage) { + const txn = await storage.readWriteTxn([ + storage.storeNames.roomMembers, + ]); + const memberList = await room.loadMemberList(txn); + try { + for (const member of memberList.members.values()) { + txn.roomMembers.set(member.serialize()); + } + } catch (err) { + txn.abort(); + throw err; + } finally { + memberList.release(); + } + await txn.complete(); + } + const roomId = "!abc:hs.tld"; return { - "trackRoom only writes joined members": async assert => { + "trackRoom only writes joined members with history visibility of joined": async assert => { const storage = await createMockStorage(); const tracker = new DeviceTracker({ storage, @@ -485,7 +509,7 @@ export function tests() { ownDeviceId: "ABCD", }); const room = createUntrackedRoomMock(roomId, ["@alice:hs.tld", "@bob:hs.tld"], ["@charly:hs.tld"]); - await tracker.trackRoom(room, NullLoggerInstance.item); + await tracker.trackRoom(room, HistoryVisibility.Joined, NullLoggerInstance.item); const txn = await storage.readTxn([storage.storeNames.userIdentities]); assert.deepEqual(await txn.userIdentities.get("@alice:hs.tld"), { userId: "@alice:hs.tld", @@ -509,7 +533,7 @@ export function tests() { ownDeviceId: "ABCD", }); const room = createUntrackedRoomMock(roomId, ["@alice:hs.tld", "@bob:hs.tld"]); - await tracker.trackRoom(room, NullLoggerInstance.item); + await tracker.trackRoom(room, HistoryVisibility.Joined, NullLoggerInstance.item); const hsApi = createQueryKeysHSApiMock(); const devices = await tracker.devicesForRoomMembers(roomId, ["@alice:hs.tld", "@bob:hs.tld"], hsApi, NullLoggerInstance.item); assert.equal(devices.length, 2); @@ -526,7 +550,7 @@ export function tests() { ownDeviceId: "ABCD", }); const room = createUntrackedRoomMock(roomId, ["@alice:hs.tld", "@bob:hs.tld"]); - await tracker.trackRoom(room, NullLoggerInstance.item); + await tracker.trackRoom(room, HistoryVisibility.Joined, NullLoggerInstance.item); const hsApi = createQueryKeysHSApiMock(); // query devices first time await tracker.devicesForRoomMembers(roomId, ["@alice:hs.tld", "@bob:hs.tld"], hsApi, NullLoggerInstance.item); @@ -544,6 +568,105 @@ export function tests() { const txn2 = await storage.readTxn([storage.storeNames.deviceIdentities]); // also check the modified key was not stored assert.equal((await txn2.deviceIdentities.get("@alice:hs.tld", "device1")).ed25519Key, "ed25519:@alice:hs.tld:device1:key"); - } + }, + "change history visibility from joined to invited adds invitees": async assert => { + const storage = await createMockStorage(); + const tracker = new DeviceTracker({ + storage, + getSyncToken: () => "token", + olmUtil: {ed25519_verify: () => {}}, // valid if it does not throw + ownUserId: "@alice:hs.tld", + ownDeviceId: "ABCD", + }); + // alice is joined, bob is invited + const room = await createUntrackedRoomMock(roomId, + ["@alice:hs.tld"], ["@bob:hs.tld"]); + await tracker.trackRoom(room, HistoryVisibility.Joined, NullLoggerInstance.item); + const txn = await storage.readWriteTxn([storage.storeNames.userIdentities, storage.storeNames.deviceIdentities]); + assert.equal(await txn.userIdentities.get("@bob:hs.tld"), undefined); + const {added, removed} = await tracker.writeHistoryVisibility(room, HistoryVisibility.Invited, txn, NullLoggerInstance.item); + assert.equal((await txn.userIdentities.get("@bob:hs.tld")).userId, "@bob:hs.tld"); + assert.deepEqual(added, ["@bob:hs.tld"]); + assert.deepEqual(removed, []); + }, + "change history visibility from invited to joined removes invitees": async assert => { + const storage = await createMockStorage(); + const tracker = new DeviceTracker({ + storage, + getSyncToken: () => "token", + olmUtil: {ed25519_verify: () => {}}, // valid if it does not throw + ownUserId: "@alice:hs.tld", + ownDeviceId: "ABCD", + }); + // alice is joined, bob is invited + const room = await createUntrackedRoomMock(roomId, + ["@alice:hs.tld"], ["@bob:hs.tld"]); + await tracker.trackRoom(room, HistoryVisibility.Invited, NullLoggerInstance.item); + const txn = await storage.readWriteTxn([storage.storeNames.userIdentities, storage.storeNames.deviceIdentities]); + assert.equal((await txn.userIdentities.get("@bob:hs.tld")).userId, "@bob:hs.tld"); + const {added, removed} = await tracker.writeHistoryVisibility(room, HistoryVisibility.Joined, txn, NullLoggerInstance.item); + assert.equal(await txn.userIdentities.get("@bob:hs.tld"), undefined); + assert.deepEqual(added, []); + assert.deepEqual(removed, ["@bob:hs.tld"]); + }, + "adding invitee with history visibility of invited adds room to userIdentities": async assert => { + const storage = await createMockStorage(); + const tracker = new DeviceTracker({ + storage, + getSyncToken: () => "token", + olmUtil: {ed25519_verify: () => {}}, // valid if it does not throw + ownUserId: "@alice:hs.tld", + ownDeviceId: "ABCD", + }); + const room = await createUntrackedRoomMock(roomId, ["@alice:hs.tld"]); + await tracker.trackRoom(room, HistoryVisibility.Invited, NullLoggerInstance.item); + const txn = await storage.readWriteTxn([storage.storeNames.userIdentities, storage.storeNames.deviceIdentities]); + // inviting a new member + const inviteChange = new MemberChange(RoomMember.fromUserId(roomId, "@bob:hs.tld", "invite")); + const {added, removed} = await tracker.writeMemberChanges(room, [inviteChange], HistoryVisibility.Invited, txn); + assert.deepEqual(added, ["@bob:hs.tld"]); + assert.deepEqual(removed, []); + assert.equal((await txn.userIdentities.get("@bob:hs.tld")).userId, "@bob:hs.tld"); + }, + "adding invitee with history visibility of joined doesn't add room": async assert => { + const storage = await createMockStorage(); + const tracker = new DeviceTracker({ + storage, + getSyncToken: () => "token", + olmUtil: {ed25519_verify: () => {}}, // valid if it does not throw + ownUserId: "@alice:hs.tld", + ownDeviceId: "ABCD", + }); + const room = await createUntrackedRoomMock(roomId, ["@alice:hs.tld"]); + await tracker.trackRoom(room, HistoryVisibility.Joined, NullLoggerInstance.item); + const txn = await storage.readWriteTxn([storage.storeNames.userIdentities, storage.storeNames.deviceIdentities]); + // inviting a new member + const inviteChange = new MemberChange(RoomMember.fromUserId(roomId, "@bob:hs.tld", "invite")); + const memberChanges = new Map([[inviteChange.userId, inviteChange]]); + const {added, removed} = await tracker.writeMemberChanges(room, memberChanges, HistoryVisibility.Joined, txn); + assert.deepEqual(added, []); + assert.deepEqual(removed, []); + assert.equal(await txn.userIdentities.get("@bob:hs.tld"), undefined); + }, + "getting all devices after changing history visibility now includes invitees": async assert => { + const storage = await createMockStorage(); + const tracker = new DeviceTracker({ + storage, + getSyncToken: () => "token", + olmUtil: {ed25519_verify: () => {}}, // valid if it does not throw + ownUserId: "@alice:hs.tld", + ownDeviceId: "ABCD", + }); + const room = createUntrackedRoomMock(roomId, ["@alice:hs.tld"], ["@bob:hs.tld"]); + await tracker.trackRoom(room, HistoryVisibility.Invited, NullLoggerInstance.item); + const hsApi = createQueryKeysHSApiMock(); + // write memberlist from room mock to mock storage, + // as devicesForTrackedRoom reads directly from roomMembers store. + await writeMemberListToStorage(room, storage); + const devices = await tracker.devicesForTrackedRoom(roomId, hsApi, NullLoggerInstance.item); + assert.equal(devices.length, 2); + assert.equal(devices.find(d => d.userId === "@alice:hs.tld").ed25519Key, "ed25519:@alice:hs.tld:device1:key"); + assert.equal(devices.find(d => d.userId === "@bob:hs.tld").ed25519Key, "ed25519:@bob:hs.tld:device1:key"); + }, } } From dd878bb8d6e7ef36994d7e8a53b0983112ea98b7 Mon Sep 17 00:00:00 2001 From: Bruno Windels <274386+bwindels@users.noreply.github.com> Date: Tue, 26 Jul 2022 16:58:07 +0200 Subject: [PATCH 145/226] also take rejecting invites into account to remove user identity --- src/matrix/e2ee/DeviceTracker.js | 25 +++++++++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/src/matrix/e2ee/DeviceTracker.js b/src/matrix/e2ee/DeviceTracker.js index 582a0e31..3fe88357 100644 --- a/src/matrix/e2ee/DeviceTracker.js +++ b/src/matrix/e2ee/DeviceTracker.js @@ -94,8 +94,8 @@ export class DeviceTracker { if (await this._addRoomToUserIdentity(memberChange.roomId, memberChange.userId, txn)) { added.push(memberChange.userId); } - } else if (memberChange.hasLeft) { - // remove room + } else if (shouldShareKey(memberChange.previousMembership, historyVisibility)) { + // try to remove room we were previously sharing the key with the member but not anymore const {roomId} = memberChange; // if we left the room, remove room from all user identities in the room if (memberChange.userId === this._ownUserId) { @@ -668,5 +668,26 @@ export function tests() { assert.equal(devices.find(d => d.userId === "@alice:hs.tld").ed25519Key, "ed25519:@alice:hs.tld:device1:key"); assert.equal(devices.find(d => d.userId === "@bob:hs.tld").ed25519Key, "ed25519:@bob:hs.tld:device1:key"); }, + "rejecting invite with history visibility of invited removes room from user identity": async assert => { + const storage = await createMockStorage(); + const tracker = new DeviceTracker({ + storage, + getSyncToken: () => "token", + olmUtil: {ed25519_verify: () => {}}, // valid if it does not throw + ownUserId: "@alice:hs.tld", + ownDeviceId: "ABCD", + }); + // alice is joined, bob is invited + const room = await createUntrackedRoomMock(roomId, ["@alice:hs.tld"], ["@bob:hs.tld"]); + await tracker.trackRoom(room, HistoryVisibility.Invited, NullLoggerInstance.item); + const txn = await storage.readWriteTxn([storage.storeNames.userIdentities, storage.storeNames.deviceIdentities]); + // reject invite + const inviteChange = new MemberChange(RoomMember.fromUserId(roomId, "@bob:hs.tld", "leave"), "invite"); + const memberChanges = new Map([[inviteChange.userId, inviteChange]]); + const {added, removed} = await tracker.writeMemberChanges(room, memberChanges, HistoryVisibility.Invited, txn); + assert.deepEqual(added, []); + assert.deepEqual(removed, ["@bob:hs.tld"]); + assert.equal(await txn.userIdentities.get("@bob:hs.tld"), undefined); + }, } } From 544afef90249c851849f24c405ab8e4b6208ab8f Mon Sep 17 00:00:00 2001 From: Bruno Windels <274386+bwindels@users.noreply.github.com> Date: Tue, 26 Jul 2022 17:41:26 +0200 Subject: [PATCH 146/226] test adding and removing when tracking multiple rooms --- src/matrix/e2ee/DeviceTracker.js | 44 ++++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/src/matrix/e2ee/DeviceTracker.js b/src/matrix/e2ee/DeviceTracker.js index 3fe88357..edf3c133 100644 --- a/src/matrix/e2ee/DeviceTracker.js +++ b/src/matrix/e2ee/DeviceTracker.js @@ -409,6 +409,7 @@ export function tests() { function createUntrackedRoomMock(roomId, joinedUserIds, invitedUserIds = []) { return { + id: roomId, isTrackingMembers: false, isEncrypted: true, loadMemberList: () => { @@ -689,5 +690,48 @@ export function tests() { assert.deepEqual(removed, ["@bob:hs.tld"]); assert.equal(await txn.userIdentities.get("@bob:hs.tld"), undefined); }, + "remove room from user identity sharing multiple rooms with us preserves other room": async assert => { + const storage = await createMockStorage(); + const tracker = new DeviceTracker({ + storage, + getSyncToken: () => "token", + olmUtil: {ed25519_verify: () => {}}, // valid if it does not throw + ownUserId: "@alice:hs.tld", + ownDeviceId: "ABCD", + }); + // alice is joined, bob is invited + const room1 = await createUntrackedRoomMock("!abc:hs.tld", ["@alice:hs.tld", "@bob:hs.tld"]); + const room2 = await createUntrackedRoomMock("!def:hs.tld", ["@alice:hs.tld", "@bob:hs.tld"]); + await tracker.trackRoom(room1, HistoryVisibility.Joined, NullLoggerInstance.item); + await tracker.trackRoom(room2, HistoryVisibility.Joined, NullLoggerInstance.item); + const txn1 = await storage.readTxn([storage.storeNames.userIdentities]); + assert.deepEqual((await txn1.userIdentities.get("@bob:hs.tld")).roomIds, ["!abc:hs.tld", "!def:hs.tld"]); + const leaveChange = new MemberChange(RoomMember.fromUserId(room2.id, "@bob:hs.tld", "leave"), "join"); + const memberChanges = new Map([[leaveChange.userId, leaveChange]]); + const txn2 = await storage.readWriteTxn([storage.storeNames.userIdentities, storage.storeNames.deviceIdentities]); + await tracker.writeMemberChanges(room2, memberChanges, HistoryVisibility.Joined, txn2); + await txn2.complete(); + const txn3 = await storage.readTxn([storage.storeNames.userIdentities]); + assert.deepEqual((await txn3.userIdentities.get("@bob:hs.tld")).roomIds, ["!abc:hs.tld"]); + }, + "add room to user identity sharing multiple rooms with us preserves other room": async assert => { + const storage = await createMockStorage(); + const tracker = new DeviceTracker({ + storage, + getSyncToken: () => "token", + olmUtil: {ed25519_verify: () => {}}, // valid if it does not throw + ownUserId: "@alice:hs.tld", + ownDeviceId: "ABCD", + }); + // alice is joined, bob is invited + const room1 = await createUntrackedRoomMock("!abc:hs.tld", ["@alice:hs.tld", "@bob:hs.tld"]); + const room2 = await createUntrackedRoomMock("!def:hs.tld", ["@alice:hs.tld", "@bob:hs.tld"]); + await tracker.trackRoom(room1, HistoryVisibility.Joined, NullLoggerInstance.item); + const txn1 = await storage.readTxn([storage.storeNames.userIdentities]); + assert.deepEqual((await txn1.userIdentities.get("@bob:hs.tld")).roomIds, ["!abc:hs.tld"]); + await tracker.trackRoom(room2, HistoryVisibility.Joined, NullLoggerInstance.item); + const txn2 = await storage.readTxn([storage.storeNames.userIdentities]); + assert.deepEqual((await txn2.userIdentities.get("@bob:hs.tld")).roomIds, ["!abc:hs.tld", "!def:hs.tld"]); + }, } } From bfaba63f473c84e65413806752b712b87fbc035b Mon Sep 17 00:00:00 2001 From: Bruno Windels <274386+bwindels@users.noreply.github.com> Date: Tue, 26 Jul 2022 17:55:21 +0200 Subject: [PATCH 147/226] fix ts error --- src/matrix/room/common.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/matrix/room/common.ts b/src/matrix/room/common.ts index 5a7fc98e..4556302f 100644 --- a/src/matrix/room/common.ts +++ b/src/matrix/room/common.ts @@ -80,7 +80,7 @@ export function iterateResponseStateEvents(roomResponse: RoomResponse, callback: } } if (promises) { - return Promise.all(promises); + return Promise.all(promises).then(() => undefined); } } From 9b0ab0c8f1d8cbd9fef72b565b635a2be89687d0 Mon Sep 17 00:00:00 2001 From: Kaki In <91763754+Kaki-In@users.noreply.github.com> Date: Wed, 27 Jul 2022 09:19:36 +0200 Subject: [PATCH 148/226] Used "null" instead of "undefined" When creating the this._lastSessionHash attribute of History --- src/platform/web/dom/History.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/platform/web/dom/History.js b/src/platform/web/dom/History.js index ab47e18a..d40f501b 100644 --- a/src/platform/web/dom/History.js +++ b/src/platform/web/dom/History.js @@ -20,7 +20,7 @@ export class History extends BaseObservableValue { constructor() { super(); - this._lastSessionHash = null; + this._lastSessionHash = undefined; } handleEvent(event) { From 50b6ee91d72d69e45cd97d1f480fa6d1127113f7 Mon Sep 17 00:00:00 2001 From: Bruno Windels <274386+bwindels@users.noreply.github.com> Date: Wed, 27 Jul 2022 11:39:36 +0200 Subject: [PATCH 149/226] don't need history visibility here --- src/matrix/e2ee/RoomEncryption.js | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/matrix/e2ee/RoomEncryption.js b/src/matrix/e2ee/RoomEncryption.js index a841a6bd..f49d5c97 100644 --- a/src/matrix/e2ee/RoomEncryption.js +++ b/src/matrix/e2ee/RoomEncryption.js @@ -323,8 +323,7 @@ export class RoomEncryption { } async _shareNewRoomKey(roomKeyMessage, hsApi, log) { - this._historyVisibility = await this._loadHistoryVisibilityIfNeeded(this._historyVisibility); - const devices = await this._deviceTracker.devicesForTrackedRoom(this._room.id, this._historyVisibility, hsApi, log); + const devices = await this._deviceTracker.devicesForTrackedRoom(this._room.id, hsApi, log); const userIds = Array.from(devices.reduce((set, device) => set.add(device.userId), new Set())); let writeOpTxn = await this._storage.readWriteTxn([this._storage.storeNames.operations]); From f18520a2fe2da30a3c4028be00d98c60467cec1a Mon Sep 17 00:00:00 2001 From: Bruno Windels <274386+bwindels@users.noreply.github.com> Date: Wed, 27 Jul 2022 11:39:50 +0200 Subject: [PATCH 150/226] let loadMembers use own txn in case members haven't been fetched yet if they haven't, it will need a network request, meaning that the txn will get closed, so we can't reuse it afterwards --- src/matrix/e2ee/DeviceTracker.js | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/matrix/e2ee/DeviceTracker.js b/src/matrix/e2ee/DeviceTracker.js index edf3c133..55051994 100644 --- a/src/matrix/e2ee/DeviceTracker.js +++ b/src/matrix/e2ee/DeviceTracker.js @@ -116,13 +116,12 @@ export class DeviceTracker { if (room.isTrackingMembers || !room.isEncrypted) { return; } + const memberList = await room.loadMemberList(undefined, log); const txn = await this._storage.readWriteTxn([ this._storage.storeNames.roomSummary, this._storage.storeNames.userIdentities, ]); - let memberList; try { - memberList = await room.loadMemberList(txn, log); let isTrackingChanges; try { isTrackingChanges = room.writeIsTrackingMembers(true, txn); @@ -140,7 +139,7 @@ export class DeviceTracker { await txn.complete(); room.applyIsTrackingMembersChanges(isTrackingChanges); } finally { - memberList?.release(); + memberList.release(); } } From 0df66b5aea6b448ea7ec03c3c26f65ca63c45f93 Mon Sep 17 00:00:00 2001 From: Bruno Windels <274386+bwindels@users.noreply.github.com> Date: Wed, 27 Jul 2022 12:06:55 +0200 Subject: [PATCH 151/226] track room before listing user ids when sharing key --- src/matrix/e2ee/RoomEncryption.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/matrix/e2ee/RoomEncryption.js b/src/matrix/e2ee/RoomEncryption.js index f49d5c97..49ca1a2f 100644 --- a/src/matrix/e2ee/RoomEncryption.js +++ b/src/matrix/e2ee/RoomEncryption.js @@ -323,6 +323,8 @@ export class RoomEncryption { } async _shareNewRoomKey(roomKeyMessage, hsApi, log) { + this._historyVisibility = await this._loadHistoryVisibilityIfNeeded(this._historyVisibility); + await this._deviceTracker.trackRoom(this._room, this._historyVisibility, log); const devices = await this._deviceTracker.devicesForTrackedRoom(this._room.id, hsApi, log); const userIds = Array.from(devices.reduce((set, device) => set.add(device.userId), new Set())); From 550b9db4dc365c3f361e046141119e94fb932562 Mon Sep 17 00:00:00 2001 From: Kaki In <91763754+Kaki-In@users.noreply.github.com> Date: Wed, 27 Jul 2022 12:21:00 +0200 Subject: [PATCH 152/226] Separated the join instructions into a executeJoinCommand method --- src/domain/session/room/RoomViewModel.js | 52 +++++++++++++----------- 1 file changed, 28 insertions(+), 24 deletions(-) diff --git a/src/domain/session/room/RoomViewModel.js b/src/domain/session/room/RoomViewModel.js index 039996cd..82fcc785 100644 --- a/src/domain/session/room/RoomViewModel.js +++ b/src/domain/session/room/RoomViewModel.js @@ -198,6 +198,33 @@ export class RoomViewModel extends ViewModel { } } + async executeJoinCommand(args) { + if (args.length == 1) { + let roomName = args[0]; + try { + const roomId = await this._options.client.session.joinRoom(roomName); + await (await this._options.client.session.observeRoomStatus(roomId)).waitFor(status => status === RoomStatus.Joined); + this.navigation.push("room", roomId); + } catch (exc) { + if ((exc.statusCode ?? exc.status) === 400) { + this._sendError = new Error(`/join : '${roomName}' was not legal room ID or room alias`); + } else if ((exc.statusCode ?? exc.status) === 404 || (exc.statusCode ?? exc.status) === 502 || exc.message == "Internal Server Error") { + this._sendError = new Error(`/join : room '${roomName}' not found`); + } else if ((exc.statusCode ?? exc.status) === 403) { + this._sendError = new Error(`/join : you're not invited to join '${roomName}'`); + } else { + this._sendError = exc; + } + this._timelineError = null; + this.emitChange("error"); + } + } else { + this._sendError = new Error("join syntax: /join "); + this._timelineError = null; + this.emitChange("error"); + } + } + async _processCommand (message) { let msgtype = undefined; const [commandName, ...args] = message.substring(1).split(" "); @@ -207,30 +234,7 @@ export class RoomViewModel extends ViewModel { msgtype = "m.emote"; break; case "join": - if (args.length == 1) { - let roomName = args[0]; - try { - const roomId = await this._options.client.session.joinRoom(roomName); - await (await this._options.client.session.observeRoomStatus(roomId)).waitFor(status => status === RoomStatus.Joined); - this.navigation.push("room", roomId); - } catch (exc) { - if ((exc.statusCode ?? exc.status) === 400) { - this._sendError = new Error(`/join : '${roomName}' was not legal room ID or room alias`); - } else if ((exc.statusCode ?? exc.status) === 404 || (exc.statusCode ?? exc.status) === 502 || exc.message == "Internal Server Error") { - this._sendError = new Error(`/join : room '${roomName}' not found`); - } else if ((exc.statusCode ?? exc.status) === 403) { - this._sendError = new Error(`/join : you're not invited to join '${roomName}'`); - } else { - this._sendError = exc; - } - this._timelineError = null; - this.emitChange("error"); - } - } else { - this._sendError = new Error("join syntax: /join "); - this._timelineError = null; - this.emitChange("error"); - } + await this.executeJoinCommand(args); break; case "shrug": message = "¯\\_(ツ)_/¯ " + args.join(" "); From 2d3b6fe973b2b1e66a5db985d357a60e1674bd71 Mon Sep 17 00:00:00 2001 From: Kaki In <91763754+Kaki-In@users.noreply.github.com> Date: Wed, 27 Jul 2022 12:40:19 +0200 Subject: [PATCH 153/226] Canceled indentation modification. --- src/matrix/common.js | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/src/matrix/common.js b/src/matrix/common.js index abd74a57..ba7876ed 100644 --- a/src/matrix/common.js +++ b/src/matrix/common.js @@ -22,16 +22,16 @@ export function makeTxnId() { } export function isTxnId(txnId) { - return txnId.startsWith("t") && txnId.length === 15; + return txnId.startsWith("t") && txnId.length === 15; } export function tests() { - return { - "isTxnId succeeds on result of makeTxnId": assert => { - assert(isTxnId(makeTxnId())); - }, - "isTxnId fails on event id": assert => { - assert(!isTxnId("$yS_n5n3cIO2aTtek0_2ZSlv-7g4YYR2zKrk2mFCW_rm")); - }, - } + return { + "isTxnId succeeds on result of makeTxnId": assert => { + assert(isTxnId(makeTxnId())); + }, + "isTxnId fails on event id": assert => { + assert(!isTxnId("$yS_n5n3cIO2aTtek0_2ZSlv-7g4YYR2zKrk2mFCW_rm")); + }, + } } From ab64ce02b22d8002ee4874751ecdec9fb2c8424d Mon Sep 17 00:00:00 2001 From: Kaki In <91763754+Kaki-In@users.noreply.github.com> Date: Wed, 27 Jul 2022 15:18:32 +0200 Subject: [PATCH 154/226] Separated the _processCommand and the joinRoom command - renamed executeJoinCommand as joinRoom; - separated the joinRoom process and the parse and result process --- src/domain/session/room/RoomViewModel.js | 57 ++++++++++++++---------- 1 file changed, 33 insertions(+), 24 deletions(-) diff --git a/src/domain/session/room/RoomViewModel.js b/src/domain/session/room/RoomViewModel.js index 82fcc785..b66545c6 100644 --- a/src/domain/session/room/RoomViewModel.js +++ b/src/domain/session/room/RoomViewModel.js @@ -198,30 +198,23 @@ export class RoomViewModel extends ViewModel { } } - async executeJoinCommand(args) { - if (args.length == 1) { - let roomName = args[0]; - try { - const roomId = await this._options.client.session.joinRoom(roomName); - await (await this._options.client.session.observeRoomStatus(roomId)).waitFor(status => status === RoomStatus.Joined); - this.navigation.push("room", roomId); - } catch (exc) { - if ((exc.statusCode ?? exc.status) === 400) { - this._sendError = new Error(`/join : '${roomName}' was not legal room ID or room alias`); - } else if ((exc.statusCode ?? exc.status) === 404 || (exc.statusCode ?? exc.status) === 502 || exc.message == "Internal Server Error") { - this._sendError = new Error(`/join : room '${roomName}' not found`); - } else if ((exc.statusCode ?? exc.status) === 403) { - this._sendError = new Error(`/join : you're not invited to join '${roomName}'`); - } else { - this._sendError = exc; - } - this._timelineError = null; - this.emitChange("error"); + async joinRoom(roomName) { + try { + const roomId = await this._options.client.session.joinRoom(roomName); + const roomStatusObserver = await this._options.client.session.observeRoomStatus(roomId); + await roomStatusObserver.waitFor(status => status === RoomStatus.Joined); + this.navigation.push("room", roomId); + return true; + } catch (exc) { + if ((exc.statusCode ?? exc.status) === 400) { + return `'${roomName}' was not legal room ID or room alias`; + } else if ((exc.statusCode ?? exc.status) === 404 || (exc.statusCode ?? exc.status) === 502 || exc.message == "Internal Server Error") { + return `room '${roomName}' not found`; + } else if ((exc.statusCode ?? exc.status) === 403) { + return `you're not invited to join '${roomName}'`; + } else { + return exc; } - } else { - this._sendError = new Error("join syntax: /join "); - this._timelineError = null; - this.emitChange("error"); } } @@ -234,7 +227,23 @@ export class RoomViewModel extends ViewModel { msgtype = "m.emote"; break; case "join": - await this.executeJoinCommand(args); + if (args.length == 1) { + const roomName = args[0]; + const exc = await this.joinRoom(roomName); + if (exc!==true) { + if (exc && exc.stack && exc.message) { + this._sendError = exc; + } else { + this._sendError = new Error("/join : " + exc); + } + this._timelineError = null; + this.emitChange("error"); + } + } else { + this._sendError = new Error("join syntax: /join "); + this._timelineError = null; + this.emitChange("error"); + } break; case "shrug": message = "¯\\_(ツ)_/¯ " + args.join(" "); From a40bb59dc0461caf39cc1b066978f36da4c3ecf4 Mon Sep 17 00:00:00 2001 From: Kaki In <91763754+Kaki-In@users.noreply.github.com> Date: Wed, 27 Jul 2022 16:36:58 +0200 Subject: [PATCH 155/226] Some fixes : - fixed a pretty syntax miss (a !== b); - fixed a type error : replaced "msgtype" by "type" when instantied the "messinfo" variable; - some indentation fixes --- src/domain/session/room/RoomViewModel.js | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/src/domain/session/room/RoomViewModel.js b/src/domain/session/room/RoomViewModel.js index b66545c6..a6db142b 100644 --- a/src/domain/session/room/RoomViewModel.js +++ b/src/domain/session/room/RoomViewModel.js @@ -230,7 +230,7 @@ export class RoomViewModel extends ViewModel { if (args.length == 1) { const roomName = args[0]; const exc = await this.joinRoom(roomName); - if (exc!==true) { + if (exc !== true) { if (exc && exc.stack && exc.message) { this._sendError = exc; } else { @@ -273,21 +273,21 @@ export class RoomViewModel extends ViewModel { async _sendMessage(message, replyingTo) { if (!this._room.isArchived && message) { - let messinfo = {msgtype : "m.text", message : message}; + let messinfo = {type : "m.text", message : message}; if (message.startsWith("//")) { messinfo.message = message.substring(1).trim(); } else if (message.startsWith("/")) { messinfo = await this._processCommand(message); } try { - const msgtype = messinfo.type; - const message = messinfo.message; - if (msgtype && message) { - if (replyingTo) { - await replyingTo.reply(msgtype, message); - } else { - await this._room.sendEvent("m.room.message", {msgtype, body: message}); - } + const msgtype = messinfo.type; + const message = messinfo.message; + if (msgtype && message) { + if (replyingTo) { + await replyingTo.reply(msgtype, message); + } else { + await this._room.sendEvent("m.room.message", {msgtype, body: message}); + } } } catch (err) { console.error(`room.sendMessage(): ${err.message}:\n${err.stack}`); From 176caf340fb1ddf53ec411a38f33c5e009bf6b76 Mon Sep 17 00:00:00 2001 From: Kaki In <91763754+Kaki-In@users.noreply.github.com> Date: Wed, 27 Jul 2022 16:42:44 +0200 Subject: [PATCH 156/226] Placed the join command outside of the processCommand method --- src/domain/session/room/RoomViewModel.js | 24 ++++++++++++++---------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/src/domain/session/room/RoomViewModel.js b/src/domain/session/room/RoomViewModel.js index a6db142b..74cb3d05 100644 --- a/src/domain/session/room/RoomViewModel.js +++ b/src/domain/session/room/RoomViewModel.js @@ -218,6 +218,19 @@ export class RoomViewModel extends ViewModel { } } + async _processCommandJoin(roomName) { + const exc = await this.joinRoom(roomName); + if (exc !== true) { + if (exc && exc.stack && exc.message) { + this._sendError = exc; + } else { + this._sendError = new Error("/join : " + exc); + } + this._timelineError = null; + this.emitChange("error"); + } + } + async _processCommand (message) { let msgtype = undefined; const [commandName, ...args] = message.substring(1).split(" "); @@ -229,16 +242,7 @@ export class RoomViewModel extends ViewModel { case "join": if (args.length == 1) { const roomName = args[0]; - const exc = await this.joinRoom(roomName); - if (exc !== true) { - if (exc && exc.stack && exc.message) { - this._sendError = exc; - } else { - this._sendError = new Error("/join : " + exc); - } - this._timelineError = null; - this.emitChange("error"); - } + await this._processCommandJoin(roomName); } else { this._sendError = new Error("join syntax: /join "); this._timelineError = null; From d7657dcc4d850094c9cfeedf13d1450cbecc49ef Mon Sep 17 00:00:00 2001 From: Isaiah Becker-Mayer Date: Thu, 30 Jun 2022 21:20:13 -0400 Subject: [PATCH 157/226] first draft of fully typescriptified LoginViewModel.ts --- .ts-eslintrc.js | 3 +- src/domain/LogoutViewModel.ts | 8 +-- src/domain/RootViewModel.js | 4 +- src/domain/ViewModel.ts | 6 +- src/domain/login/LoginViewModel.ts | 97 +++++++++++++++++++----------- src/matrix/Client.js | 10 +-- 6 files changed, 79 insertions(+), 49 deletions(-) diff --git a/.ts-eslintrc.js b/.ts-eslintrc.js index 1974e07b..cf1fc3bf 100644 --- a/.ts-eslintrc.js +++ b/.ts-eslintrc.js @@ -19,6 +19,7 @@ module.exports = { ], rules: { "@typescript-eslint/no-floating-promises": 2, - "@typescript-eslint/no-misused-promises": 2 + "@typescript-eslint/no-misused-promises": 2, + "semi": ["error", "always"] } }; diff --git a/src/domain/LogoutViewModel.ts b/src/domain/LogoutViewModel.ts index 3edfcad5..b0409edd 100644 --- a/src/domain/LogoutViewModel.ts +++ b/src/domain/LogoutViewModel.ts @@ -14,18 +14,18 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {Options, ViewModel} from "./ViewModel"; +import {Options as BaseOptions, ViewModel} from "./ViewModel"; import {Client} from "../matrix/Client.js"; -type LogoutOptions = { sessionId: string; } & Options; +type Options = { sessionId: string; } & BaseOptions; -export class LogoutViewModel extends ViewModel { +export class LogoutViewModel extends ViewModel { private _sessionId: string; private _busy: boolean; private _showConfirm: boolean; private _error?: Error; - constructor(options: LogoutOptions) { + constructor(options: Options) { super(options); this._sessionId = options.sessionId; this._busy = false; diff --git a/src/domain/RootViewModel.js b/src/domain/RootViewModel.js index 2711cd2f..a4729b9e 100644 --- a/src/domain/RootViewModel.js +++ b/src/domain/RootViewModel.js @@ -17,7 +17,7 @@ limitations under the License. import {Client} from "../matrix/Client.js"; import {SessionViewModel} from "./session/SessionViewModel.js"; import {SessionLoadViewModel} from "./SessionLoadViewModel.js"; -import {LoginViewModel} from "./login/LoginViewModel.js"; +import {LoginViewModel} from "./login/LoginViewModel.ts"; import {LogoutViewModel} from "./LogoutViewModel"; import {SessionPickerViewModel} from "./SessionPickerViewModel.js"; import {ViewModel} from "./ViewModel"; @@ -118,7 +118,7 @@ export class RootViewModel extends ViewModel { // but we also want the change of screen to go through the navigation // so we store the session container in a temporary variable that will be // consumed by _applyNavigation, triggered by the navigation change - // + // // Also, we should not call _setSection before the navigation is in the correct state, // as url creation (e.g. in RoomTileViewModel) // won't be using the correct navigation base path. diff --git a/src/domain/ViewModel.ts b/src/domain/ViewModel.ts index 0bc52f6e..63e64676 100644 --- a/src/domain/ViewModel.ts +++ b/src/domain/ViewModel.ts @@ -62,7 +62,7 @@ export class ViewModel extends EventEmitter<{change const segmentObservable = this.navigation.observe(type); const unsubscribe = segmentObservable.subscribe((value: string | true | undefined) => { onChange(value, type); - }) + }); this.track(unsubscribe); } @@ -100,7 +100,7 @@ export class ViewModel extends EventEmitter<{change // TODO: this will need to support binding // if any of the expr is a function, assume the function is a binding, and return a binding function ourselves - // + // // translated string should probably always be bindings, unless we're fine with a refresh when changing the language? // we probably are, if we're using routing with a url, we could just refresh. i18n(parts: TemplateStringsArray, ...expr: any[]) { @@ -115,7 +115,7 @@ export class ViewModel extends EventEmitter<{change return result; } - emitChange(changedProps: any): void { + emitChange(changedProps?: any): void { if (this._options.emitChange) { this._options.emitChange(changedProps); } else { diff --git a/src/domain/login/LoginViewModel.ts b/src/domain/login/LoginViewModel.ts index bf77e624..34c86b2d 100644 --- a/src/domain/login/LoginViewModel.ts +++ b/src/domain/login/LoginViewModel.ts @@ -15,34 +15,51 @@ limitations under the License. */ import {Client} from "../../matrix/Client.js"; -import {ViewModel} from "../ViewModel"; +import {Options as BaseOptions, ViewModel} from "../ViewModel"; import {PasswordLoginViewModel} from "./PasswordLoginViewModel.js"; import {StartSSOLoginViewModel} from "./StartSSOLoginViewModel.js"; import {CompleteSSOLoginViewModel} from "./CompleteSSOLoginViewModel.js"; import {LoadStatus} from "../../matrix/Client.js"; import {SessionLoadViewModel} from "../SessionLoadViewModel.js"; -export class LoginViewModel extends ViewModel { - constructor(options) { +// TODO(isaiah): make these default exported +import {PasswordLoginMethod} from "../../matrix/login/PasswordLoginMethod"; +import {SSOLoginHelper} from "../../matrix/login/SSOLoginHelper"; +import {TokenLoginMethod} from "../../matrix/login/TokenLoginMethod"; + +type Options = { + defaultHomeserver: string; + ready: ReadyFn; + loginToken?: string; +} & BaseOptions; + +export class LoginViewModel extends ViewModel { + private _ready: ReadyFn; + private _loginToken?: string; + private _client: Client; + private _loginOptions?: LoginOptions; + private _passwordLoginViewModel?: PasswordLoginViewModel; + private _startSSOLoginViewModel?: StartSSOLoginViewModel; + private _completeSSOLoginViewModel?: CompleteSSOLoginViewModel; + private _loadViewModel?: SessionLoadViewModel; + private _loadViewModelSubscription?: () => void; + private _homeserver: string; + private _queriedHomeserver?: string; + private _abortHomeserverQueryTimeout?: () => void; + private _abortQueryOperation?: () => void; + + private _hideHomeserver = false; + private _isBusy = false; + private _errorMessage = ""; + + constructor(options: Readonly) { super(options); const {ready, defaultHomeserver, loginToken} = options; this._ready = ready; this._loginToken = loginToken; this._client = new Client(this.platform); - this._loginOptions = null; - this._passwordLoginViewModel = null; - this._startSSOLoginViewModel = null; - this._completeSSOLoginViewModel = null; - this._loadViewModel = null; - this._loadViewModelSubscription = null; this._homeserver = defaultHomeserver; - this._queriedHomeserver = null; - this._errorMessage = ""; - this._hideHomeserver = false; - this._isBusy = false; - this._abortHomeserverQueryTimeout = null; - this._abortQueryOperation = null; - this._initViewModels(); + void this._initViewModels(); } get passwordLoginViewModel() { return this._passwordLoginViewModel; } @@ -67,7 +84,7 @@ export class LoginViewModel extends ViewModel { this.childOptions( { client: this._client, - attemptLogin: loginMethod => this.attemptLogin(loginMethod), + attemptLogin: (loginMethod: PasswordLoginMethod) => this.attemptLogin(loginMethod), loginToken: this._loginToken }))); this.emitChange("completeSSOLoginViewModel"); @@ -81,7 +98,7 @@ export class LoginViewModel extends ViewModel { this._passwordLoginViewModel = this.track(new PasswordLoginViewModel( this.childOptions({ loginOptions: this._loginOptions, - attemptLogin: loginMethod => this.attemptLogin(loginMethod) + attemptLogin: (loginMethod: PasswordLoginMethod) => this.attemptLogin(loginMethod) }))); this.emitChange("passwordLoginViewModel"); } @@ -93,23 +110,23 @@ export class LoginViewModel extends ViewModel { this.emitChange("startSSOLoginViewModel"); } - _showError(message) { + _showError(message: string) { this._errorMessage = message; this.emitChange("errorMessage"); } - _setBusy(status) { + _setBusy(status: boolean) { this._isBusy = status; this._passwordLoginViewModel?.setBusy(status); this._startSSOLoginViewModel?.setBusy(status); this.emitChange("isBusy"); } - async attemptLogin(loginMethod) { + async attemptLogin(loginMethod: PasswordLoginMethod) { this._setBusy(true); - this._client.startWithLogin(loginMethod, {inspectAccountSetup: true}); + await this._client.startWithLogin(loginMethod, {inspectAccountSetup: true}); const loadStatus = this._client.loadStatus; - const handle = loadStatus.waitFor(status => status !== LoadStatus.Login); + const handle = loadStatus.waitFor((status: LoadStatus) => status !== LoadStatus.Login); await handle.promise; this._setBusy(false); const status = loadStatus.get(); @@ -119,11 +136,11 @@ export class LoginViewModel extends ViewModel { this._hideHomeserver = true; this.emitChange("hideHomeserver"); this._disposeViewModels(); - this._createLoadViewModel(); + await this._createLoadViewModel(); return null; } - _createLoadViewModel() { + async _createLoadViewModel() { this._loadViewModelSubscription = this.disposeTracked(this._loadViewModelSubscription); this._loadViewModel = this.disposeTracked(this._loadViewModel); this._loadViewModel = this.track( @@ -139,7 +156,7 @@ export class LoginViewModel extends ViewModel { }) ) ); - this._loadViewModel.start(); + await this._loadViewModel.start(); this.emitChange("loadViewModel"); this._loadViewModelSubscription = this.track( this._loadViewModel.disposableOn("change", () => { @@ -152,7 +169,7 @@ export class LoginViewModel extends ViewModel { } _disposeViewModels() { - this._startSSOLoginViewModel = this.disposeTracked(this._ssoLoginViewModel); + this._startSSOLoginViewModel = this.disposeTracked(this._startSSOLoginViewModel); this._passwordLoginViewModel = this.disposeTracked(this._passwordLoginViewModel); this._completeSSOLoginViewModel = this.disposeTracked(this._completeSSOLoginViewModel); this.emitChange("disposeViewModels"); @@ -161,8 +178,8 @@ export class LoginViewModel extends ViewModel { async setHomeserver(newHomeserver) { this._homeserver = newHomeserver; // clear everything set by queryHomeserver - this._loginOptions = null; - this._queriedHomeserver = null; + this._loginOptions = undefined; + this._queriedHomeserver = undefined; this._showError(""); this._disposeViewModels(); this._abortQueryOperation = this.disposeTracked(this._abortQueryOperation); @@ -181,9 +198,9 @@ export class LoginViewModel extends ViewModel { } } this._abortHomeserverQueryTimeout = this.disposeTracked(this._abortHomeserverQueryTimeout); - this.queryHomeserver(); + await this.queryHomeserver(); } - + async queryHomeserver() { // don't repeat a query we've just done if (this._homeserver === this._queriedHomeserver || this._homeserver === "") { @@ -210,7 +227,7 @@ export class LoginViewModel extends ViewModel { if (e.name === "AbortError") { return; //aborted, bail out } else { - this._loginOptions = null; + this._loginOptions = undefined; } } finally { this._abortQueryOperation = this.disposeTracked(this._abortQueryOperation); @@ -221,19 +238,29 @@ export class LoginViewModel extends ViewModel { if (this._loginOptions.password) { this._showPasswordLogin(); } if (!this._loginOptions.sso && !this._loginOptions.password) { this._showError("This homeserver supports neither SSO nor password based login flows"); - } + } } else { this._showError(`Could not query login methods supported by ${this.homeserver}`); } } - dispose() { + async dispose() { super.dispose(); if (this._client) { // if we move away before we're done with initial sync // delete the session - this._client.deleteSession(); + await this._client.deleteSession(); } } } + +type ReadyFn = (client: Client) => void; + +// TODO: move to Client.js when its converted to typescript. +type LoginOptions = { + homeserver: string; + password?: (username: string, password: string) => PasswordLoginMethod; + sso?: SSOLoginHelper; + token?: (loginToken: string) => TokenLoginMethod; +}; \ No newline at end of file diff --git a/src/matrix/Client.js b/src/matrix/Client.js index 21175a7f..44643cc1 100644 --- a/src/matrix/Client.js +++ b/src/matrix/Client.js @@ -100,6 +100,8 @@ export class Client { }); } + // TODO: When converted to typescript this should return the same type + // as this._loginOptions is in LoginViewModel.ts (LoginOptions). _parseLoginOptions(options, homeserver) { /* Take server response and return new object which has two props password and sso which @@ -136,7 +138,7 @@ export class Client { const request = this._platform.request; const hsApi = new HomeServerApi({homeserver, request}); const registration = new Registration(hsApi, { - username, + username, password, initialDeviceDisplayName, }, @@ -196,7 +198,7 @@ export class Client { sessionInfo.deviceId = dehydratedDevice.deviceId; } } - await this._platform.sessionInfoStorage.add(sessionInfo); + await this._platform.sessionInfoStorage.add(sessionInfo); // loading the session can only lead to // LoadStatus.Error in case of an error, // so separate try/catch @@ -266,7 +268,7 @@ export class Client { this._status.set(LoadStatus.SessionSetup); await log.wrap("createIdentity", log => this._session.createIdentity(log)); } - + this._sync = new Sync({hsApi: this._requestScheduler.hsApi, storage: this._storage, session: this._session, logger: this._platform.logger}); // notify sync and session when back online this._reconnectSubscription = this._reconnector.connectionStatus.subscribe(state => { @@ -311,7 +313,7 @@ export class Client { this._waitForFirstSyncHandle = this._sync.status.waitFor(s => { if (s === SyncStatus.Stopped) { // keep waiting if there is a ConnectionError - // as the reconnector above will call + // as the reconnector above will call // sync.start again to retry in this case return this._sync.error?.name !== "ConnectionError"; } From ad0bd82bdaddd339cea0819dac5064b8106084d6 Mon Sep 17 00:00:00 2001 From: Isaiah Becker-Mayer Date: Thu, 30 Jun 2022 21:36:00 -0400 Subject: [PATCH 158/226] creating default exports --- src/domain/login/LoginViewModel.ts | 6 +----- src/matrix/login/index.ts | 5 +++++ 2 files changed, 6 insertions(+), 5 deletions(-) create mode 100644 src/matrix/login/index.ts diff --git a/src/domain/login/LoginViewModel.ts b/src/domain/login/LoginViewModel.ts index 34c86b2d..57366e7a 100644 --- a/src/domain/login/LoginViewModel.ts +++ b/src/domain/login/LoginViewModel.ts @@ -21,11 +21,7 @@ import {StartSSOLoginViewModel} from "./StartSSOLoginViewModel.js"; import {CompleteSSOLoginViewModel} from "./CompleteSSOLoginViewModel.js"; import {LoadStatus} from "../../matrix/Client.js"; import {SessionLoadViewModel} from "../SessionLoadViewModel.js"; - -// TODO(isaiah): make these default exported -import {PasswordLoginMethod} from "../../matrix/login/PasswordLoginMethod"; -import {SSOLoginHelper} from "../../matrix/login/SSOLoginHelper"; -import {TokenLoginMethod} from "../../matrix/login/TokenLoginMethod"; +import {PasswordLoginMethod, SSOLoginHelper, TokenLoginMethod} from "../../matrix/login"; type Options = { defaultHomeserver: string; diff --git a/src/matrix/login/index.ts b/src/matrix/login/index.ts new file mode 100644 index 00000000..7018449d --- /dev/null +++ b/src/matrix/login/index.ts @@ -0,0 +1,5 @@ +import {PasswordLoginMethod} from "./PasswordLoginMethod"; +import {SSOLoginHelper} from "./SSOLoginHelper"; +import {TokenLoginMethod} from "./TokenLoginMethod"; + +export {PasswordLoginMethod, SSOLoginHelper, TokenLoginMethod}; \ No newline at end of file From 7b7b19476c44b697e240d63efb6302001a41e697 Mon Sep 17 00:00:00 2001 From: Isaiah Becker-Mayer Date: Wed, 6 Jul 2022 20:57:57 -0400 Subject: [PATCH 159/226] updates some signatures to be more verbose, fixes wrong type for attemptLogin --- src/domain/login/LoginViewModel.ts | 63 ++++++++++++++++++++++-------- src/matrix/login/index.ts | 4 +- 2 files changed, 49 insertions(+), 18 deletions(-) diff --git a/src/domain/login/LoginViewModel.ts b/src/domain/login/LoginViewModel.ts index 57366e7a..71cff624 100644 --- a/src/domain/login/LoginViewModel.ts +++ b/src/domain/login/LoginViewModel.ts @@ -21,7 +21,7 @@ import {StartSSOLoginViewModel} from "./StartSSOLoginViewModel.js"; import {CompleteSSOLoginViewModel} from "./CompleteSSOLoginViewModel.js"; import {LoadStatus} from "../../matrix/Client.js"; import {SessionLoadViewModel} from "../SessionLoadViewModel.js"; -import {PasswordLoginMethod, SSOLoginHelper, TokenLoginMethod} from "../../matrix/login"; +import type {PasswordLoginMethod, SSOLoginHelper, TokenLoginMethod, ILoginMethod} from "../../matrix/login"; type Options = { defaultHomeserver: string; @@ -44,9 +44,9 @@ export class LoginViewModel extends ViewModel { private _abortHomeserverQueryTimeout?: () => void; private _abortQueryOperation?: () => void; - private _hideHomeserver = false; - private _isBusy = false; - private _errorMessage = ""; + private _hideHomeserver: boolean = false; + private _isBusy: boolean = false; + private _errorMessage: string = ""; constructor(options: Readonly) { super(options); @@ -58,16 +58,45 @@ export class LoginViewModel extends ViewModel { void this._initViewModels(); } - get passwordLoginViewModel() { return this._passwordLoginViewModel; } - get startSSOLoginViewModel() { return this._startSSOLoginViewModel; } - get completeSSOLoginViewModel(){ return this._completeSSOLoginViewModel; } - get homeserver() { return this._homeserver; } - get resolvedHomeserver() { return this._loginOptions?.homeserver; } - get errorMessage() { return this._errorMessage; } - get showHomeserver() { return !this._hideHomeserver; } - get loadViewModel() {return this._loadViewModel; } - get isBusy() { return this._isBusy; } - get isFetchingLoginOptions() { return !!this._abortQueryOperation; } + get passwordLoginViewModel(): PasswordLoginViewModel { + return this._passwordLoginViewModel; + } + + get startSSOLoginViewModel(): StartSSOLoginViewModel { + return this._startSSOLoginViewModel; + } + + get completeSSOLoginViewModel(): CompleteSSOLoginViewModel { + return this._completeSSOLoginViewModel; + } + + get homeserver(): string { + return this._homeserver; + } + + get resolvedHomeserver(): string | undefined { + return this._loginOptions?.homeserver; + } + + get errorMessage(): string { + return this._errorMessage; + } + + get showHomeserver(): boolean { + return !this._hideHomeserver; + } + + get loadViewModel(): SessionLoadViewModel { + return this._loadViewModel; + } + + get isBusy(): boolean { + return this._isBusy; + } + + get isFetchingLoginOptions(): boolean { + return !!this._abortQueryOperation; + } goBack() { this.navigation.push("session"); @@ -80,7 +109,7 @@ export class LoginViewModel extends ViewModel { this.childOptions( { client: this._client, - attemptLogin: (loginMethod: PasswordLoginMethod) => this.attemptLogin(loginMethod), + attemptLogin: (loginMethod: TokenLoginMethod) => this.attemptLogin(loginMethod), loginToken: this._loginToken }))); this.emitChange("completeSSOLoginViewModel"); @@ -118,7 +147,7 @@ export class LoginViewModel extends ViewModel { this.emitChange("isBusy"); } - async attemptLogin(loginMethod: PasswordLoginMethod) { + async attemptLogin(loginMethod: ILoginMethod) { this._setBusy(true); await this._client.startWithLogin(loginMethod, {inspectAccountSetup: true}); const loadStatus = this._client.loadStatus; @@ -171,7 +200,7 @@ export class LoginViewModel extends ViewModel { this.emitChange("disposeViewModels"); } - async setHomeserver(newHomeserver) { + async setHomeserver(newHomeserver: string) { this._homeserver = newHomeserver; // clear everything set by queryHomeserver this._loginOptions = undefined; diff --git a/src/matrix/login/index.ts b/src/matrix/login/index.ts index 7018449d..ba133a26 100644 --- a/src/matrix/login/index.ts +++ b/src/matrix/login/index.ts @@ -1,5 +1,7 @@ +import {ILoginMethod} from "./LoginMethod"; import {PasswordLoginMethod} from "./PasswordLoginMethod"; import {SSOLoginHelper} from "./SSOLoginHelper"; import {TokenLoginMethod} from "./TokenLoginMethod"; -export {PasswordLoginMethod, SSOLoginHelper, TokenLoginMethod}; \ No newline at end of file + +export {PasswordLoginMethod, SSOLoginHelper, TokenLoginMethod, ILoginMethod}; \ No newline at end of file From aeed9787895da4f517015a1d3ea259f3924588dd Mon Sep 17 00:00:00 2001 From: Isaiah Becker-Mayer Date: Tue, 26 Jul 2022 20:44:15 -0700 Subject: [PATCH 160/226] changes signature of emitChange to require changedProps --- src/domain/RootViewModel.js | 2 +- src/domain/ViewModel.ts | 2 +- src/domain/login/LoginViewModel.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/domain/RootViewModel.js b/src/domain/RootViewModel.js index a4729b9e..4094d864 100644 --- a/src/domain/RootViewModel.js +++ b/src/domain/RootViewModel.js @@ -17,7 +17,7 @@ limitations under the License. import {Client} from "../matrix/Client.js"; import {SessionViewModel} from "./session/SessionViewModel.js"; import {SessionLoadViewModel} from "./SessionLoadViewModel.js"; -import {LoginViewModel} from "./login/LoginViewModel.ts"; +import {LoginViewModel} from "./login/LoginViewModel"; import {LogoutViewModel} from "./LogoutViewModel"; import {SessionPickerViewModel} from "./SessionPickerViewModel.js"; import {ViewModel} from "./ViewModel"; diff --git a/src/domain/ViewModel.ts b/src/domain/ViewModel.ts index 63e64676..7cf21861 100644 --- a/src/domain/ViewModel.ts +++ b/src/domain/ViewModel.ts @@ -115,7 +115,7 @@ export class ViewModel extends EventEmitter<{change return result; } - emitChange(changedProps?: any): void { + emitChange(changedProps: any): void { if (this._options.emitChange) { this._options.emitChange(changedProps); } else { diff --git a/src/domain/login/LoginViewModel.ts b/src/domain/login/LoginViewModel.ts index 71cff624..d9bf2e64 100644 --- a/src/domain/login/LoginViewModel.ts +++ b/src/domain/login/LoginViewModel.ts @@ -208,7 +208,7 @@ export class LoginViewModel extends ViewModel { this._showError(""); this._disposeViewModels(); this._abortQueryOperation = this.disposeTracked(this._abortQueryOperation); - this.emitChange(); // multiple fields changing + this.emitChange("loginViewModels"); // multiple fields changing // also clear the timeout if it is still running this.disposeTracked(this._abortHomeserverQueryTimeout); const timeout = this.clock.createTimeout(1000); From a5b9cb6b95027a01e52c7ab3c95aa6c8b308bc73 Mon Sep 17 00:00:00 2001 From: Isaiah Becker-Mayer Date: Tue, 26 Jul 2022 20:54:06 -0700 Subject: [PATCH 161/226] removes unnecessary awaits --- src/domain/login/LoginViewModel.ts | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/src/domain/login/LoginViewModel.ts b/src/domain/login/LoginViewModel.ts index d9bf2e64..d0ae9d9a 100644 --- a/src/domain/login/LoginViewModel.ts +++ b/src/domain/login/LoginViewModel.ts @@ -147,9 +147,9 @@ export class LoginViewModel extends ViewModel { this.emitChange("isBusy"); } - async attemptLogin(loginMethod: ILoginMethod) { + async attemptLogin(loginMethod: ILoginMethod): Promise { this._setBusy(true); - await this._client.startWithLogin(loginMethod, {inspectAccountSetup: true}); + void this._client.startWithLogin(loginMethod, {inspectAccountSetup: true}); const loadStatus = this._client.loadStatus; const handle = loadStatus.waitFor((status: LoadStatus) => status !== LoadStatus.Login); await handle.promise; @@ -161,11 +161,11 @@ export class LoginViewModel extends ViewModel { this._hideHomeserver = true; this.emitChange("hideHomeserver"); this._disposeViewModels(); - await this._createLoadViewModel(); + void this._createLoadViewModel(); return null; } - async _createLoadViewModel() { + _createLoadViewModel(): void { this._loadViewModelSubscription = this.disposeTracked(this._loadViewModelSubscription); this._loadViewModel = this.disposeTracked(this._loadViewModel); this._loadViewModel = this.track( @@ -181,7 +181,7 @@ export class LoginViewModel extends ViewModel { }) ) ); - await this._loadViewModel.start(); + void this._loadViewModel.start(); this.emitChange("loadViewModel"); this._loadViewModelSubscription = this.track( this._loadViewModel.disposableOn("change", () => { @@ -200,7 +200,7 @@ export class LoginViewModel extends ViewModel { this.emitChange("disposeViewModels"); } - async setHomeserver(newHomeserver: string) { + async setHomeserver(newHomeserver: string): void { this._homeserver = newHomeserver; // clear everything set by queryHomeserver this._loginOptions = undefined; @@ -223,10 +223,10 @@ export class LoginViewModel extends ViewModel { } } this._abortHomeserverQueryTimeout = this.disposeTracked(this._abortHomeserverQueryTimeout); - await this.queryHomeserver(); + void this.queryHomeserver(); } - async queryHomeserver() { + async queryHomeserver(): void { // don't repeat a query we've just done if (this._homeserver === this._queriedHomeserver || this._homeserver === "") { return; @@ -270,12 +270,12 @@ export class LoginViewModel extends ViewModel { } } - async dispose() { + dispose(): void { super.dispose(); if (this._client) { // if we move away before we're done with initial sync // delete the session - await this._client.deleteSession(); + void this._client.deleteSession(); } } } From 8b91d8fac83a238c12dac17804eedfd2bb48c43e Mon Sep 17 00:00:00 2001 From: Isaiah Becker-Mayer Date: Tue, 26 Jul 2022 20:55:17 -0700 Subject: [PATCH 162/226] adds newline --- src/domain/login/LoginViewModel.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/domain/login/LoginViewModel.ts b/src/domain/login/LoginViewModel.ts index d0ae9d9a..2a80465a 100644 --- a/src/domain/login/LoginViewModel.ts +++ b/src/domain/login/LoginViewModel.ts @@ -288,4 +288,4 @@ type LoginOptions = { password?: (username: string, password: string) => PasswordLoginMethod; sso?: SSOLoginHelper; token?: (loginToken: string) => TokenLoginMethod; -}; \ No newline at end of file +}; From cadca709460a5e701c962de20be33ff174f4f55a Mon Sep 17 00:00:00 2001 From: Isaiah Becker-Mayer Date: Tue, 26 Jul 2022 21:05:49 -0700 Subject: [PATCH 163/226] fixes linter errors and removes some unneeded async/await --- src/domain/ViewModel.ts | 4 ++-- src/domain/login/LoginViewModel.ts | 22 +++++++++++----------- 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/src/domain/ViewModel.ts b/src/domain/ViewModel.ts index 7cf21861..00ae847b 100644 --- a/src/domain/ViewModel.ts +++ b/src/domain/ViewModel.ts @@ -58,7 +58,7 @@ export class ViewModel extends EventEmitter<{change return this._options[name]; } - observeNavigation(type: string, onChange: (value: string | true | undefined, type: string) => void) { + observeNavigation(type: string, onChange: (value: string | true | undefined, type: string) => void): void { const segmentObservable = this.navigation.observe(type); const unsubscribe = segmentObservable.subscribe((value: string | true | undefined) => { onChange(value, type); @@ -103,7 +103,7 @@ export class ViewModel extends EventEmitter<{change // // translated string should probably always be bindings, unless we're fine with a refresh when changing the language? // we probably are, if we're using routing with a url, we could just refresh. - i18n(parts: TemplateStringsArray, ...expr: any[]) { + i18n(parts: TemplateStringsArray, ...expr: any[]): string { // just concat for now let result = ""; for (let i = 0; i < parts.length; ++i) { diff --git a/src/domain/login/LoginViewModel.ts b/src/domain/login/LoginViewModel.ts index 2a80465a..67deede8 100644 --- a/src/domain/login/LoginViewModel.ts +++ b/src/domain/login/LoginViewModel.ts @@ -55,7 +55,7 @@ export class LoginViewModel extends ViewModel { this._loginToken = loginToken; this._client = new Client(this.platform); this._homeserver = defaultHomeserver; - void this._initViewModels(); + this._initViewModels(); } get passwordLoginViewModel(): PasswordLoginViewModel { @@ -98,11 +98,11 @@ export class LoginViewModel extends ViewModel { return !!this._abortQueryOperation; } - goBack() { + goBack(): void { this.navigation.push("session"); } - async _initViewModels() { + _initViewModels(): void { if (this._loginToken) { this._hideHomeserver = true; this._completeSSOLoginViewModel = this.track(new CompleteSSOLoginViewModel( @@ -115,11 +115,11 @@ export class LoginViewModel extends ViewModel { this.emitChange("completeSSOLoginViewModel"); } else { - await this.queryHomeserver(); + void this.queryHomeserver(); } } - _showPasswordLogin() { + _showPasswordLogin(): void { this._passwordLoginViewModel = this.track(new PasswordLoginViewModel( this.childOptions({ loginOptions: this._loginOptions, @@ -128,19 +128,19 @@ export class LoginViewModel extends ViewModel { this.emitChange("passwordLoginViewModel"); } - _showSSOLogin() { + _showSSOLogin(): void { this._startSSOLoginViewModel = this.track( new StartSSOLoginViewModel(this.childOptions({loginOptions: this._loginOptions})) ); this.emitChange("startSSOLoginViewModel"); } - _showError(message: string) { + _showError(message: string): void { this._errorMessage = message; this.emitChange("errorMessage"); } - _setBusy(status: boolean) { + _setBusy(status: boolean): void { this._isBusy = status; this._passwordLoginViewModel?.setBusy(status); this._startSSOLoginViewModel?.setBusy(status); @@ -193,14 +193,14 @@ export class LoginViewModel extends ViewModel { ); } - _disposeViewModels() { + _disposeViewModels(): void { this._startSSOLoginViewModel = this.disposeTracked(this._startSSOLoginViewModel); this._passwordLoginViewModel = this.disposeTracked(this._passwordLoginViewModel); this._completeSSOLoginViewModel = this.disposeTracked(this._completeSSOLoginViewModel); this.emitChange("disposeViewModels"); } - async setHomeserver(newHomeserver: string): void { + async setHomeserver(newHomeserver: string): Promise { this._homeserver = newHomeserver; // clear everything set by queryHomeserver this._loginOptions = undefined; @@ -226,7 +226,7 @@ export class LoginViewModel extends ViewModel { void this.queryHomeserver(); } - async queryHomeserver(): void { + async queryHomeserver(): Promise { // don't repeat a query we've just done if (this._homeserver === this._queriedHomeserver || this._homeserver === "") { return; From a82df95b82b417b551eda2177eb1f8fa9c9dd73b Mon Sep 17 00:00:00 2001 From: Isaiah Becker-Mayer Date: Wed, 27 Jul 2022 22:07:46 -0700 Subject: [PATCH 164/226] marking private methods as such --- src/domain/login/LoginViewModel.ts | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/domain/login/LoginViewModel.ts b/src/domain/login/LoginViewModel.ts index 67deede8..aaeca54f 100644 --- a/src/domain/login/LoginViewModel.ts +++ b/src/domain/login/LoginViewModel.ts @@ -102,7 +102,7 @@ export class LoginViewModel extends ViewModel { this.navigation.push("session"); } - _initViewModels(): void { + private _initViewModels(): void { if (this._loginToken) { this._hideHomeserver = true; this._completeSSOLoginViewModel = this.track(new CompleteSSOLoginViewModel( @@ -119,7 +119,7 @@ export class LoginViewModel extends ViewModel { } } - _showPasswordLogin(): void { + private _showPasswordLogin(): void { this._passwordLoginViewModel = this.track(new PasswordLoginViewModel( this.childOptions({ loginOptions: this._loginOptions, @@ -128,19 +128,19 @@ export class LoginViewModel extends ViewModel { this.emitChange("passwordLoginViewModel"); } - _showSSOLogin(): void { + private _showSSOLogin(): void { this._startSSOLoginViewModel = this.track( new StartSSOLoginViewModel(this.childOptions({loginOptions: this._loginOptions})) ); this.emitChange("startSSOLoginViewModel"); } - _showError(message: string): void { + private _showError(message: string): void { this._errorMessage = message; this.emitChange("errorMessage"); } - _setBusy(status: boolean): void { + private _setBusy(status: boolean): void { this._isBusy = status; this._passwordLoginViewModel?.setBusy(status); this._startSSOLoginViewModel?.setBusy(status); @@ -165,7 +165,7 @@ export class LoginViewModel extends ViewModel { return null; } - _createLoadViewModel(): void { + private _createLoadViewModel(): void { this._loadViewModelSubscription = this.disposeTracked(this._loadViewModelSubscription); this._loadViewModel = this.disposeTracked(this._loadViewModel); this._loadViewModel = this.track( @@ -193,7 +193,7 @@ export class LoginViewModel extends ViewModel { ); } - _disposeViewModels(): void { + private _disposeViewModels(): void { this._startSSOLoginViewModel = this.disposeTracked(this._startSSOLoginViewModel); this._passwordLoginViewModel = this.disposeTracked(this._passwordLoginViewModel); this._completeSSOLoginViewModel = this.disposeTracked(this._completeSSOLoginViewModel); From 3c64f7d49b31564bb4e27dd91aab7108d9554372 Mon Sep 17 00:00:00 2001 From: Kaki In <91763754+Kaki-In@users.noreply.github.com> Date: Thu, 28 Jul 2022 09:23:30 +0200 Subject: [PATCH 165/226] Finals checks about https://github.com/vector-im/hydrogen-web/pull/809#pullrequestreview-1053501341 - joined the processJoinRoom and joinRoom methods; - fixed some precisions miss; - removed some useless code; - change the error message height from absolute (40px) to relative (auto) --- src/domain/session/room/RoomViewModel.js | 27 ++++++------------- .../web/ui/css/themes/element/theme.css | 4 +-- 2 files changed, 10 insertions(+), 21 deletions(-) diff --git a/src/domain/session/room/RoomViewModel.js b/src/domain/session/room/RoomViewModel.js index 74cb3d05..47d8c628 100644 --- a/src/domain/session/room/RoomViewModel.js +++ b/src/domain/session/room/RoomViewModel.js @@ -199,40 +199,30 @@ export class RoomViewModel extends ViewModel { } async joinRoom(roomName) { + } + + async _processCommandJoin(roomName) { try { const roomId = await this._options.client.session.joinRoom(roomName); const roomStatusObserver = await this._options.client.session.observeRoomStatus(roomId); await roomStatusObserver.waitFor(status => status === RoomStatus.Joined); this.navigation.push("room", roomId); - return true; } catch (exc) { if ((exc.statusCode ?? exc.status) === 400) { - return `'${roomName}' was not legal room ID or room alias`; + exc = new Error(`/join : '${roomName}' was not legal room ID or room alias`); } else if ((exc.statusCode ?? exc.status) === 404 || (exc.statusCode ?? exc.status) === 502 || exc.message == "Internal Server Error") { - return `room '${roomName}' not found`; + exc = new Error(`/join : room '${roomName}' not found`); } else if ((exc.statusCode ?? exc.status) === 403) { - return `you're not invited to join '${roomName}'`; - } else { - return exc; - } - } - } - - async _processCommandJoin(roomName) { - const exc = await this.joinRoom(roomName); - if (exc !== true) { - if (exc && exc.stack && exc.message) { - this._sendError = exc; - } else { - this._sendError = new Error("/join : " + exc); + exc = new Error(`/join : you're not invited to join '${roomName}'`); } + this._sendError = exc; this._timelineError = null; this.emitChange("error"); } } async _processCommand (message) { - let msgtype = undefined; + let msgtype; const [commandName, ...args] = message.substring(1).split(" "); switch (commandName) { case "me": @@ -269,7 +259,6 @@ export class RoomViewModel extends ViewModel { this._sendError = new Error(`no command name "${commandName}". To send the message instead of executing, please type "/${message}"`); this._timelineError = null; this.emitChange("error"); - msgtype = undefined; message = undefined; } return {type: msgtype, message: message}; diff --git a/src/platform/web/ui/css/themes/element/theme.css b/src/platform/web/ui/css/themes/element/theme.css index 0f40bdf3..05681cbb 100644 --- a/src/platform/web/ui/css/themes/element/theme.css +++ b/src/platform/web/ui/css/themes/element/theme.css @@ -539,7 +539,7 @@ a { } .RoomView_error:not(:empty) { - height : 40px; + height : auto; padding-top : 20px; padding-bottom : 20px; } @@ -572,7 +572,7 @@ a { content:"\274c"; position : absolute; top : 15px; - left: 10px; + left: 9px; width : 20px; height : 10px; font-size : 10px; From fb7932674702fe4bbd35ef186cbb95b70a77b34f Mon Sep 17 00:00:00 2001 From: Kaki In <91763754+Kaki-In@users.noreply.github.com> Date: Thu, 28 Jul 2022 09:26:08 +0200 Subject: [PATCH 166/226] Forgot one change --- src/domain/session/room/RoomViewModel.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/domain/session/room/RoomViewModel.js b/src/domain/session/room/RoomViewModel.js index 47d8c628..124f6e9c 100644 --- a/src/domain/session/room/RoomViewModel.js +++ b/src/domain/session/room/RoomViewModel.js @@ -230,7 +230,7 @@ export class RoomViewModel extends ViewModel { msgtype = "m.emote"; break; case "join": - if (args.length == 1) { + if (args.length === 1) { const roomName = args[0]; await this._processCommandJoin(roomName); } else { From 302131c447d30300ae7411b6e7df31f5acb120c7 Mon Sep 17 00:00:00 2001 From: Kaki In <91763754+Kaki-In@users.noreply.github.com> Date: Thu, 28 Jul 2022 10:14:21 +0200 Subject: [PATCH 167/226] Review last checks --- src/domain/session/room/RoomViewModel.js | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/src/domain/session/room/RoomViewModel.js b/src/domain/session/room/RoomViewModel.js index 124f6e9c..cecbb27f 100644 --- a/src/domain/session/room/RoomViewModel.js +++ b/src/domain/session/room/RoomViewModel.js @@ -198,22 +198,21 @@ export class RoomViewModel extends ViewModel { } } - async joinRoom(roomName) { - } - async _processCommandJoin(roomName) { try { const roomId = await this._options.client.session.joinRoom(roomName); const roomStatusObserver = await this._options.client.session.observeRoomStatus(roomId); await roomStatusObserver.waitFor(status => status === RoomStatus.Joined); this.navigation.push("room", roomId); - } catch (exc) { + } catch (err) { if ((exc.statusCode ?? exc.status) === 400) { - exc = new Error(`/join : '${roomName}' was not legal room ID or room alias`); + const exc = new Error(`/join : '${roomName}' was not legal room ID or room alias`); } else if ((exc.statusCode ?? exc.status) === 404 || (exc.statusCode ?? exc.status) === 502 || exc.message == "Internal Server Error") { - exc = new Error(`/join : room '${roomName}' not found`); + const exc = new Error(`/join : room '${roomName}' not found`); } else if ((exc.statusCode ?? exc.status) === 403) { - exc = new Error(`/join : you're not invited to join '${roomName}'`); + const exc = new Error(`/join : you're not invited to join '${roomName}'`); + } else { + const exc = err; } this._sendError = exc; this._timelineError = null; From f5dacb4e42bd3daf6b6633cebb0613d362494b61 Mon Sep 17 00:00:00 2001 From: Kaki In <91763754+Kaki-In@users.noreply.github.com> Date: Thu, 28 Jul 2022 10:26:59 +0200 Subject: [PATCH 168/226] Fixed last check --- src/domain/session/room/RoomViewModel.js | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/src/domain/session/room/RoomViewModel.js b/src/domain/session/room/RoomViewModel.js index cecbb27f..249d04f0 100644 --- a/src/domain/session/room/RoomViewModel.js +++ b/src/domain/session/room/RoomViewModel.js @@ -205,14 +205,15 @@ export class RoomViewModel extends ViewModel { await roomStatusObserver.waitFor(status => status === RoomStatus.Joined); this.navigation.push("room", roomId); } catch (err) { - if ((exc.statusCode ?? exc.status) === 400) { - const exc = new Error(`/join : '${roomName}' was not legal room ID or room alias`); - } else if ((exc.statusCode ?? exc.status) === 404 || (exc.statusCode ?? exc.status) === 502 || exc.message == "Internal Server Error") { - const exc = new Error(`/join : room '${roomName}' not found`); - } else if ((exc.statusCode ?? exc.status) === 403) { - const exc = new Error(`/join : you're not invited to join '${roomName}'`); + let exc; + if ((err.statusCode ?? err.status) === 400) { + exc = new Error(`/join : '${roomName}' was not legal room ID or room alias`); + } else if ((err.statusCode ?? err.status) === 404 || (err.statusCode ?? err.status) === 502 || err.message == "Internal Server Error") { + exc = new Error(`/join : room '${roomName}' not found`); + } else if ((err.statusCode ?? err.status) === 403) { + exc = new Error(`/join : you're not invited to join '${roomName}'`); } else { - const exc = err; + exc = err; } this._sendError = exc; this._timelineError = null; From 319ec37864017a38abda67c76af0901dcc3bc4d6 Mon Sep 17 00:00:00 2001 From: Bruno Windels <274386+bwindels@users.noreply.github.com> Date: Thu, 28 Jul 2022 11:44:50 +0200 Subject: [PATCH 169/226] fix typos preventing to load the history visibility --- src/matrix/e2ee/RoomEncryption.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/matrix/e2ee/RoomEncryption.js b/src/matrix/e2ee/RoomEncryption.js index 49ca1a2f..fa1ecf6e 100644 --- a/src/matrix/e2ee/RoomEncryption.js +++ b/src/matrix/e2ee/RoomEncryption.js @@ -136,9 +136,9 @@ export class RoomEncryption { if (!txn) { txn = await this._storage.readTxn([this._storage.storeNames.roomState]); } - const visibilityEntry = await txn.roomState.get(this.id, ROOM_HISTORY_VISIBILITY_TYPE, ""); + const visibilityEntry = await txn.roomState.get(this._room.id, ROOM_HISTORY_VISIBILITY_TYPE, ""); if (visibilityEntry) { - return event?.content?.history_visibility; + return visibilityEntry.event?.content?.history_visibility; } } return historyVisibility; From 62b3a67e33c9d53446ac55c79e155728f7dc5255 Mon Sep 17 00:00:00 2001 From: Bruno Windels <274386+bwindels@users.noreply.github.com> Date: Thu, 28 Jul 2022 17:09:41 +0200 Subject: [PATCH 170/226] write unit tests for correctly reading history visibility when needed --- src/matrix/e2ee/RoomEncryption.js | 140 ++++++++++++++++++++++++++++++ 1 file changed, 140 insertions(+) diff --git a/src/matrix/e2ee/RoomEncryption.js b/src/matrix/e2ee/RoomEncryption.js index fa1ecf6e..36424b02 100644 --- a/src/matrix/e2ee/RoomEncryption.js +++ b/src/matrix/e2ee/RoomEncryption.js @@ -551,3 +551,143 @@ class BatchDecryptionResult { })); } } + +import {createMockStorage} from "../../mocks/Storage"; +import {Clock as MockClock} from "../../mocks/Clock"; +import {poll} from "../../mocks/poll"; +import {Instance as NullLoggerInstance} from "../../logging/NullLogger"; +import {ConsoleLogger} from "../../logging/ConsoleLogger"; +import {HomeServer as MockHomeServer} from "../../mocks/HomeServer.js"; + +export function tests() { + const roomId = "!abc:hs.tld"; + return { + "ensureMessageKeyIsShared tracks room and passes correct history visibility to deviceTracker": async assert => { + const storage = await createMockStorage(); + const megolmMock = { + async ensureOutboundSession() { return { }; } + }; + const olmMock = { + async encrypt() { return []; } + } + let isRoomTracked = false; + let isDevicesRequested = false; + const deviceTracker = { + async trackRoom(room, historyVisibility) { + // only assert on first call + if (isRoomTracked) { return; } + assert(!isDevicesRequested); + assert.equal(room.id, roomId); + assert.equal(historyVisibility, "invited"); + isRoomTracked = true; + }, + async devicesForTrackedRoom() { + assert(isRoomTracked); + isDevicesRequested = true; + return []; + }, + async devicesForRoomMembers() { + return []; + } + } + const writeTxn = await storage.readWriteTxn([storage.storeNames.roomState]); + writeTxn.roomState.set(roomId, {state_key: "", type: ROOM_HISTORY_VISIBILITY_TYPE, content: { + history_visibility: "invited" + }}); + await writeTxn.complete(); + const roomEncryption = new RoomEncryption({ + room: {id: roomId}, + megolmEncryption: megolmMock, + olmEncryption: olmMock, + storage, + deviceTracker, + clock: new MockClock() + }); + const homeServer = new MockHomeServer(); + const promise = roomEncryption.ensureMessageKeyIsShared(homeServer.api, NullLoggerInstance.item); + // need to poll because sendToDevice isn't first async step + const request = await poll(() => homeServer.requests.sendToDevice?.[0]); + request.respond({}); + await promise; + assert(isRoomTracked); + assert(isDevicesRequested); + }, + "encrypt tracks room and passes correct history visibility to deviceTracker": async assert => { + const storage = await createMockStorage(); + const megolmMock = { + async encrypt() { return { roomKeyMessage: {} }; } + }; + const olmMock = { + async encrypt() { return []; } + } + let isRoomTracked = false; + let isDevicesRequested = false; + const deviceTracker = { + async trackRoom(room, historyVisibility) { + // only assert on first call + if (isRoomTracked) { return; } + assert(!isDevicesRequested); + assert.equal(room.id, roomId); + assert.equal(historyVisibility, "invited"); + isRoomTracked = true; + }, + async devicesForTrackedRoom() { + assert(isRoomTracked); + isDevicesRequested = true; + return []; + }, + async devicesForRoomMembers() { + return []; + } + } + const writeTxn = await storage.readWriteTxn([storage.storeNames.roomState]); + writeTxn.roomState.set(roomId, {state_key: "", type: ROOM_HISTORY_VISIBILITY_TYPE, content: { + history_visibility: "invited" + }}); + await writeTxn.complete(); + const roomEncryption = new RoomEncryption({ + room: {id: roomId}, + megolmEncryption: megolmMock, + olmEncryption: olmMock, + storage, + deviceTracker + }); + const homeServer = new MockHomeServer(); + const promise = roomEncryption.encrypt("m.room.message", {body: "hello"}, homeServer.api, NullLoggerInstance.item); + // need to poll because sendToDevice isn't first async step + const request = await poll(() => homeServer.requests.sendToDevice?.[0]); + request.respond({}); + await promise; + assert(isRoomTracked); + assert(isDevicesRequested); + }, + "writeSync passes correct history visibility to deviceTracker": async assert => { + const storage = await createMockStorage(); + let isMemberChangesCalled = false; + const deviceTracker = { + async writeMemberChanges(room, memberChanges, historyVisibility, txn) { + assert.equal(historyVisibility, "invited"); + isMemberChangesCalled = true; + return {removed: [], added: []}; + }, + async devicesForRoomMembers() { + return []; + } + } + const writeTxn = await storage.readWriteTxn([storage.storeNames.roomState]); + writeTxn.roomState.set(roomId, {state_key: "", type: ROOM_HISTORY_VISIBILITY_TYPE, content: { + history_visibility: "invited" + }}); + const memberChanges = new Map([["@alice:hs.tld", {}]]); + const roomEncryption = new RoomEncryption({ + room: {id: roomId}, + storage, + deviceTracker + }); + const roomResponse = {}; + const txn = await storage.readWriteTxn([storage.storeNames.roomState]); + await roomEncryption.writeSync(roomResponse, memberChanges, txn, NullLoggerInstance.item); + assert(isMemberChangesCalled); + }, + } +} From 90b5a74e07f9c6c88b61bf30f23124bb53193211 Mon Sep 17 00:00:00 2001 From: Hugh Nimmo-Smith Date: Fri, 29 Jul 2022 08:59:05 +0100 Subject: [PATCH 171/226] Support statically configured OIDC clients --- src/matrix/net/OidcApi.ts | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/src/matrix/net/OidcApi.ts b/src/matrix/net/OidcApi.ts index 5a801952..bdc9352a 100644 --- a/src/matrix/net/OidcApi.ts +++ b/src/matrix/net/OidcApi.ts @@ -53,6 +53,22 @@ function assert(condition: any, message: string): asserts condition { } }; +type IssuerUri = string; +interface ClientConfig { + client_id: string; + client_secret?: string; +} + +// These are statically configured OIDC client IDs for particular issuers: +const clientIds: Record = { + "https://dev-6525741.okta.com/": { + client_id: "0oa5x44w64wpNsxi45d7", + }, + "https://keycloak-oidc.lab.element.dev/realms/master/": { + client_id: "hydrogen-oidc-playground" + }, +}; + export class OidcApi { _issuer: string; _requestFn: RequestFunction; @@ -104,6 +120,13 @@ export class OidcApi { registration(): Promise { if (!this._registrationPromise) { this._registrationPromise = (async () => { + // use static client if available + const authority = `${this.issuer}${this.issuer.endsWith('/') ? '' : '/'}`; + + if (clientIds[authority]) { + return clientIds[authority]; + } + const headers = new Map(); headers.set("Accept", "application/json"); headers.set("Content-Type", "application/json"); From 2f27f83194b0eaea983b162da57ab348d27ddee9 Mon Sep 17 00:00:00 2001 From: Hugh Nimmo-Smith Date: Fri, 29 Jul 2022 09:44:24 +0100 Subject: [PATCH 172/226] Use valid length of code_verifier --- src/matrix/net/OidcApi.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/matrix/net/OidcApi.ts b/src/matrix/net/OidcApi.ts index bdc9352a..103aae82 100644 --- a/src/matrix/net/OidcApi.ts +++ b/src/matrix/net/OidcApi.ts @@ -242,7 +242,7 @@ export class OidcApi { redirectUri, state: randomString(8), nonce: randomString(8), - codeVerifier: randomString(32), + codeVerifier: randomString(64), // https://tools.ietf.org/html/rfc7636#section-4.1 length needs to be 43-128 characters }; } From d937b9b14b3f2ef5c950c59a658db6659d6e3d51 Mon Sep 17 00:00:00 2001 From: Half-Shot Date: Fri, 29 Jul 2022 10:39:41 +0100 Subject: [PATCH 173/226] use logging items --- src/matrix/storage/idb/StorageFactory.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/matrix/storage/idb/StorageFactory.ts b/src/matrix/storage/idb/StorageFactory.ts index 1f64baf3..6d5f2720 100644 --- a/src/matrix/storage/idb/StorageFactory.ts +++ b/src/matrix/storage/idb/StorageFactory.ts @@ -21,6 +21,7 @@ import { exportSession, importSession, Export } from "./export"; import { schema } from "./schema"; import { detectWebkitEarlyCloseTxnBug } from "./quirks"; import { ILogItem } from "../../../logging/types"; +import { LogLevel } from "../../../logging/LogFilter"; const sessionName = (sessionId: string) => `hydrogen_session_${sessionId}`; const openDatabaseWithSessionId = function(sessionId: string, idbFactory: IDBFactory, localStorage: IDOMStorage, log: ILogItem) { @@ -32,7 +33,7 @@ interface ServiceWorkerHandler { preventConcurrentSessionAccess: (sessionId: string) => Promise; } -async function requestPersistedStorage(): Promise { +async function requestPersistedStorage(log: ILogItem): Promise { // don't assume browser so we can run in node with fake-idb const glob = this; if (glob?.navigator?.storage?.persist) { @@ -42,7 +43,8 @@ async function requestPersistedStorage(): Promise { await glob.document.requestStorageAccess(); return true; } catch (err) { - console.warn("requestStorageAccess threw an error:", err); + const item = log.log("requestStorageAccess threw an error:", LogLevel.Warn); + item.error = err; return false; } } else { @@ -65,10 +67,10 @@ export class StorageFactory { async create(sessionId: string, log: ILogItem): Promise { await this._serviceWorkerHandler?.preventConcurrentSessionAccess(sessionId); - requestPersistedStorage().then(persisted => { + requestPersistedStorage(log).then(persisted => { // Firefox lies here though, and returns true even if the user denied the request if (!persisted) { - console.warn("no persisted storage, database can be evicted by browser"); + log.log("no persisted storage, database can be evicted by browser:", LogLevel.Warn); } }); From 58a2d1f34c3d6216e7effdbdfe56a85594d65f35 Mon Sep 17 00:00:00 2001 From: Kaki In <91763754+Kaki-In@users.noreply.github.com> Date: Fri, 29 Jul 2022 11:44:23 +0200 Subject: [PATCH 174/226] Restored the common.js indentation --- src/matrix/common.js | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/src/matrix/common.js b/src/matrix/common.js index abd74a57..ba7876ed 100644 --- a/src/matrix/common.js +++ b/src/matrix/common.js @@ -22,16 +22,16 @@ export function makeTxnId() { } export function isTxnId(txnId) { - return txnId.startsWith("t") && txnId.length === 15; + return txnId.startsWith("t") && txnId.length === 15; } export function tests() { - return { - "isTxnId succeeds on result of makeTxnId": assert => { - assert(isTxnId(makeTxnId())); - }, - "isTxnId fails on event id": assert => { - assert(!isTxnId("$yS_n5n3cIO2aTtek0_2ZSlv-7g4YYR2zKrk2mFCW_rm")); - }, - } + return { + "isTxnId succeeds on result of makeTxnId": assert => { + assert(isTxnId(makeTxnId())); + }, + "isTxnId fails on event id": assert => { + assert(!isTxnId("$yS_n5n3cIO2aTtek0_2ZSlv-7g4YYR2zKrk2mFCW_rm")); + }, + } } From 5b5c8524016c8ab06899c8e43af8b2a7eb40d4cb Mon Sep 17 00:00:00 2001 From: Half-Shot Date: Fri, 29 Jul 2022 10:44:37 +0100 Subject: [PATCH 175/226] Revert "use logging items" This reverts commit d937b9b14b3f2ef5c950c59a658db6659d6e3d51. --- src/matrix/storage/idb/StorageFactory.ts | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/src/matrix/storage/idb/StorageFactory.ts b/src/matrix/storage/idb/StorageFactory.ts index 6d5f2720..1f64baf3 100644 --- a/src/matrix/storage/idb/StorageFactory.ts +++ b/src/matrix/storage/idb/StorageFactory.ts @@ -21,7 +21,6 @@ import { exportSession, importSession, Export } from "./export"; import { schema } from "./schema"; import { detectWebkitEarlyCloseTxnBug } from "./quirks"; import { ILogItem } from "../../../logging/types"; -import { LogLevel } from "../../../logging/LogFilter"; const sessionName = (sessionId: string) => `hydrogen_session_${sessionId}`; const openDatabaseWithSessionId = function(sessionId: string, idbFactory: IDBFactory, localStorage: IDOMStorage, log: ILogItem) { @@ -33,7 +32,7 @@ interface ServiceWorkerHandler { preventConcurrentSessionAccess: (sessionId: string) => Promise; } -async function requestPersistedStorage(log: ILogItem): Promise { +async function requestPersistedStorage(): Promise { // don't assume browser so we can run in node with fake-idb const glob = this; if (glob?.navigator?.storage?.persist) { @@ -43,8 +42,7 @@ async function requestPersistedStorage(log: ILogItem): Promise { await glob.document.requestStorageAccess(); return true; } catch (err) { - const item = log.log("requestStorageAccess threw an error:", LogLevel.Warn); - item.error = err; + console.warn("requestStorageAccess threw an error:", err); return false; } } else { @@ -67,10 +65,10 @@ export class StorageFactory { async create(sessionId: string, log: ILogItem): Promise { await this._serviceWorkerHandler?.preventConcurrentSessionAccess(sessionId); - requestPersistedStorage(log).then(persisted => { + requestPersistedStorage().then(persisted => { // Firefox lies here though, and returns true even if the user denied the request if (!persisted) { - log.log("no persisted storage, database can be evicted by browser:", LogLevel.Warn); + console.warn("no persisted storage, database can be evicted by browser"); } }); From f512bfcfc169928e24831948b228f856317adaa5 Mon Sep 17 00:00:00 2001 From: Kaki In <91763754+Kaki-In@users.noreply.github.com> Date: Fri, 29 Jul 2022 11:47:47 +0200 Subject: [PATCH 176/226] Pretty syntaxed the RoomViewModel --- src/domain/session/room/RoomViewModel.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/domain/session/room/RoomViewModel.js b/src/domain/session/room/RoomViewModel.js index 249d04f0..fcda95fc 100644 --- a/src/domain/session/room/RoomViewModel.js +++ b/src/domain/session/room/RoomViewModel.js @@ -244,15 +244,15 @@ export class RoomViewModel extends ViewModel { msgtype = "m.text"; break; case "tableflip": - message="(╯°□°)╯︵ ┻━┻ " + args.join(" "); + message = "(╯°□°)╯︵ ┻━┻ " + args.join(" "); msgtype = "m.text"; break; case "unflip": - message="┬──┬ ノ( ゜-゜ノ) " + args.join(" "); + message = "┬──┬ ノ( ゜-゜ノ) " + args.join(" "); msgtype = "m.text"; break; case "lenny": - message="( ͡° ͜ʖ ͡°) " + args.join(" "); + message = "( ͡° ͜ʖ ͡°) " + args.join(" "); msgtype = "m.text"; break; default: From 170460f5a956f164578b35319d2d632c668616d6 Mon Sep 17 00:00:00 2001 From: Bruno Windels <274386+bwindels@users.noreply.github.com> Date: Fri, 29 Jul 2022 12:02:09 +0200 Subject: [PATCH 177/226] add link to sygnal webpush docs as well --- src/platform/types/config.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/platform/types/config.ts b/src/platform/types/config.ts index 165bd22b..8a5eabf2 100644 --- a/src/platform/types/config.ts +++ b/src/platform/types/config.ts @@ -50,6 +50,7 @@ export type Config = { /** * Configuration for push notifications. * See https://spec.matrix.org/latest/client-server-api/#post_matrixclientv3pushersset + * and https://github.com/matrix-org/sygnal/blob/main/docs/applications.md#webpush * OPTIONAL */ push?: { From 02bc7d1d7e9eef5915b9cb6284fb289868905e44 Mon Sep 17 00:00:00 2001 From: Bruno Windels <274386+bwindels@users.noreply.github.com> Date: Fri, 29 Jul 2022 10:14:41 +0000 Subject: [PATCH 178/226] fix typo --- doc/THEMING.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/THEMING.md b/doc/THEMING.md index 5ffadf44..39729830 100644 --- a/doc/THEMING.md +++ b/doc/THEMING.md @@ -174,7 +174,7 @@ This allows users to theme Hydrogen without the need for rebuilding. Derived the ## Creating a derived theme: Here's how you create a new derived theme: 1. You create a new theme manifest file (eg: theme-awesome.json) and mention which build time theme you're basing your new theme on using the `extends` field. The base css file of the mentioned theme is used for your new theme. -2. You configue the theme manifest as usual by populating the `variants` field with your desired colors. +2. You configure the theme manifest as usual by populating the `variants` field with your desired colors. 3. You add your new theme manifest to the list of themes in `config.json`. Reload Hydrogen twice and the new theme should show up in the theme chooser. From 06da5a8ae485a0b83f682711306239b14b20d916 Mon Sep 17 00:00:00 2001 From: Bruno Windels <274386+bwindels@users.noreply.github.com> Date: Fri, 29 Jul 2022 10:14:58 +0000 Subject: [PATCH 179/226] clarification --- doc/THEMING.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/THEMING.md b/doc/THEMING.md index 39729830..599434bd 100644 --- a/doc/THEMING.md +++ b/doc/THEMING.md @@ -177,7 +177,7 @@ Here's how you create a new derived theme: 2. You configure the theme manifest as usual by populating the `variants` field with your desired colors. 3. You add your new theme manifest to the list of themes in `config.json`. -Reload Hydrogen twice and the new theme should show up in the theme chooser. +Refresh Hydrogen twice (once to refresh cache, and once to load) and the new theme should show up in the theme chooser. ## How does it work? From b6f795505d90073d1da22eaa2599b51810372d16 Mon Sep 17 00:00:00 2001 From: Bruno Windels <274386+bwindels@users.noreply.github.com> Date: Fri, 29 Jul 2022 12:21:16 +0200 Subject: [PATCH 180/226] fix lint --- src/domain/session/room/RoomViewModel.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/domain/session/room/RoomViewModel.js b/src/domain/session/room/RoomViewModel.js index fcda95fc..75f90730 100644 --- a/src/domain/session/room/RoomViewModel.js +++ b/src/domain/session/room/RoomViewModel.js @@ -426,7 +426,7 @@ export class RoomViewModel extends ViewModel { } } - dismissError(evt) { + dismissError() { this._sendError = null; this.emitChange("error"); } From 708637e390250db305e11bbc2c7b5bf8cc30479e Mon Sep 17 00:00:00 2001 From: RMidhunSuresh Date: Fri, 29 Jul 2022 16:45:25 +0530 Subject: [PATCH 181/226] No need for this complex resolve --- scripts/build-plugins/rollup-plugin-build-themes.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/scripts/build-plugins/rollup-plugin-build-themes.js b/scripts/build-plugins/rollup-plugin-build-themes.js index bb1c65c1..5b913448 100644 --- a/scripts/build-plugins/rollup-plugin-build-themes.js +++ b/scripts/build-plugins/rollup-plugin-build-themes.js @@ -56,7 +56,8 @@ async function generateIconSourceMap(icons, manifestLocation) { const fs = require("fs").promises; for (const icon of Object.values(icons)) { const [location] = icon.split("?"); - const resolvedLocation = path.resolve(__dirname, "../../", manifestLocation, location); + // resolve location against manifestLocation + const resolvedLocation = path.resolve(manifestLocation, location); const iconData = fs.readFile(resolvedLocation); promises.push(iconData); const fileName = path.basename(resolvedLocation); From 39817dc36bb8952aa7ce55045c7ead6f506c776f Mon Sep 17 00:00:00 2001 From: RMidhunSuresh Date: Fri, 29 Jul 2022 17:33:33 +0530 Subject: [PATCH 182/226] Revert back option --- tsconfig.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tsconfig.json b/tsconfig.json index 72d9d3ae..f46cc7eb 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -4,7 +4,7 @@ "noImplicitAny": false, "noEmit": true, "target": "ES2020", - "module": "Node16", + "module": "ES2020", "moduleResolution": "node", "esModuleInterop": true }, From cb0ac846c7bf9691dd1dd8951da33511135e0399 Mon Sep 17 00:00:00 2001 From: Bruno Windels <274386+bwindels@users.noreply.github.com> Date: Fri, 29 Jul 2022 16:22:01 +0200 Subject: [PATCH 183/226] remove obsolete comment --- src/matrix/e2ee/DeviceTracker.js | 1 - 1 file changed, 1 deletion(-) diff --git a/src/matrix/e2ee/DeviceTracker.js b/src/matrix/e2ee/DeviceTracker.js index 55051994..484a6d0b 100644 --- a/src/matrix/e2ee/DeviceTracker.js +++ b/src/matrix/e2ee/DeviceTracker.js @@ -148,7 +148,6 @@ export class DeviceTracker { const removed = []; if (room.isTrackingMembers && room.isEncrypted) { await log.wrap("rewriting userIdentities", async log => { - // don't use room.loadMemberList here because we want to use the syncTxn to load the members const memberList = await room.loadMemberList(syncTxn, log); try { const members = Array.from(memberList.members.values()); From 5e83eca3b9598de03b9898380457853f25cc1ebf Mon Sep 17 00:00:00 2001 From: Bruno Windels <274386+bwindels@users.noreply.github.com> Date: Fri, 29 Jul 2022 16:43:28 +0200 Subject: [PATCH 184/226] move semi-automatic test for runtime themes into dedicated directory --- scripts/{ => test-derived-theme}/test-theme.sh | 4 ++-- theme.json => scripts/test-derived-theme/theme.json | 0 2 files changed, 2 insertions(+), 2 deletions(-) rename scripts/{ => test-derived-theme}/test-theme.sh (67%) rename theme.json => scripts/test-derived-theme/theme.json (100%) diff --git a/scripts/test-theme.sh b/scripts/test-derived-theme/test-theme.sh similarity index 67% rename from scripts/test-theme.sh rename to scripts/test-derived-theme/test-theme.sh index 9f94d3c3..6ac9b128 100755 --- a/scripts/test-theme.sh +++ b/scripts/test-derived-theme/test-theme.sh @@ -1,5 +1,5 @@ -#!/bin/zsh -cp theme.json target/assets/theme-customer.json +#!/bin/sh +cp scripts/test-derived-theme/theme.json target/assets/theme-customer.json cat target/config.json | jq '.themeManifests += ["assets/theme-customer.json"]' | cat > target/config.temp.json rm target/config.json mv target/config.temp.json target/config.json diff --git a/theme.json b/scripts/test-derived-theme/theme.json similarity index 100% rename from theme.json rename to scripts/test-derived-theme/theme.json From db2b4e693c1e130cc0d142ad10a1d128fefe3897 Mon Sep 17 00:00:00 2001 From: Bruno Windels <274386+bwindels@users.noreply.github.com> Date: Fri, 29 Jul 2022 17:10:24 +0200 Subject: [PATCH 185/226] release v0.3.0 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 9361098c..1823ef45 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "hydrogen-web", - "version": "0.2.33", + "version": "0.3.0", "description": "A javascript matrix client prototype, trying to minize RAM usage by offloading as much as possible to IndexedDB", "directories": { "doc": "doc" From 50910907953fc2483757e41f3146c852c254c941 Mon Sep 17 00:00:00 2001 From: RMidhunSuresh Date: Fri, 29 Jul 2022 23:11:17 +0530 Subject: [PATCH 186/226] Produce .tmp directory within root --- scripts/postcss/svg-builder.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/postcss/svg-builder.mjs b/scripts/postcss/svg-builder.mjs index 3efff4ae..cbfd3637 100644 --- a/scripts/postcss/svg-builder.mjs +++ b/scripts/postcss/svg-builder.mjs @@ -36,7 +36,7 @@ export function buildColorizedSVG(svgLocation, primaryColor, secondaryColor) { const coloredSVGCode = getColoredSvgString(svgCode, primaryColor, secondaryColor); const fileName = svgLocation.match(/.+[/\\](.+\.svg)/)[1]; const outputName = `${fileName.substring(0, fileName.length - 4)}-${createHash(coloredSVGCode)}.svg`; - const outputPath = resolve(__dirname, "../../.tmp"); + const outputPath = resolve(__dirname, "./.tmp"); try { mkdirSync(outputPath); } From 4571ecd85177b1f04fa06a218d27bdc77f977a43 Mon Sep 17 00:00:00 2001 From: RMidhunSuresh Date: Fri, 29 Jul 2022 23:45:58 +0530 Subject: [PATCH 187/226] Specify theme as array --- vite.sdk-assets-config.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/vite.sdk-assets-config.js b/vite.sdk-assets-config.js index d7d4d064..5c1f3196 100644 --- a/vite.sdk-assets-config.js +++ b/vite.sdk-assets-config.js @@ -36,7 +36,7 @@ export default mergeOptions(commonOptions, { plugins: [ themeBuilder({ themeConfig: { - themes: { element: "./src/platform/web/ui/css/themes/element" }, + themes: ["./src/platform/web/ui/css/themes/element"], default: "element", }, compiledVariables, From a0ee8a9607381968f001ccb4db155cd40b7dd237 Mon Sep 17 00:00:00 2001 From: Hugh Nimmo-Smith Date: Sun, 31 Jul 2022 10:06:01 +0100 Subject: [PATCH 188/226] Actually make SessionLoadViewModel.logout do something --- src/domain/SessionLoadViewModel.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/domain/SessionLoadViewModel.js b/src/domain/SessionLoadViewModel.js index abc16299..5b2c2a93 100644 --- a/src/domain/SessionLoadViewModel.js +++ b/src/domain/SessionLoadViewModel.js @@ -154,7 +154,8 @@ export class SessionLoadViewModel extends ViewModel { } async logout() { - await this._client.logout(); + const sessionId = this.navigation.path.get("session")?.value; + await this._client.startLogout(sessionId); this.navigation.push("session", true); } From 3dc4a4c6901c617af1f21a242faa28f96e8dfd9a Mon Sep 17 00:00:00 2001 From: Hugh Nimmo-Smith Date: Sun, 31 Jul 2022 17:17:00 +0100 Subject: [PATCH 189/226] Link out to OIDC account management URL if available --- src/domain/login/CompleteOIDCLoginViewModel.js | 5 +++-- src/domain/login/StartOIDCLoginViewModel.js | 2 ++ src/domain/session/settings/SettingsViewModel.js | 8 ++++++++ src/matrix/Client.js | 5 +++-- src/matrix/login/OIDCLoginMethod.ts | 6 +++++- src/matrix/sessioninfo/localstorage/SessionInfoStorage.ts | 1 + src/matrix/well-known.js | 8 +++++++- src/platform/web/ui/session/settings/SettingsView.js | 7 +++++++ 8 files changed, 36 insertions(+), 6 deletions(-) diff --git a/src/domain/login/CompleteOIDCLoginViewModel.js b/src/domain/login/CompleteOIDCLoginViewModel.js index 5d0da980..a544939a 100644 --- a/src/domain/login/CompleteOIDCLoginViewModel.js +++ b/src/domain/login/CompleteOIDCLoginViewModel.js @@ -50,7 +50,7 @@ export class CompleteOIDCLoginViewModel extends ViewModel { } const code = this._code; // TODO: cleanup settings storage - const [startedAt, nonce, codeVerifier, redirectUri, homeserver, issuer, clientId] = await Promise.all([ + const [startedAt, nonce, codeVerifier, redirectUri, homeserver, issuer, clientId, accountManagementUrl] = await Promise.all([ this.platform.settingsStorage.getInt(`oidc_${this._state}_started_at`), this.platform.settingsStorage.getString(`oidc_${this._state}_nonce`), this.platform.settingsStorage.getString(`oidc_${this._state}_code_verifier`), @@ -58,6 +58,7 @@ export class CompleteOIDCLoginViewModel extends ViewModel { this.platform.settingsStorage.getString(`oidc_${this._state}_homeserver`), this.platform.settingsStorage.getString(`oidc_${this._state}_issuer`), this.platform.settingsStorage.getString(`oidc_${this._state}_client_id`), + this.platform.settingsStorage.getString(`oidc_${this._state}_account_management_url`), ]); const oidcApi = new OidcApi({ @@ -67,7 +68,7 @@ export class CompleteOIDCLoginViewModel extends ViewModel { encoding: this._encoding, crypto: this._crypto, }); - const method = new OIDCLoginMethod({oidcApi, nonce, codeVerifier, code, homeserver, startedAt, redirectUri}); + const method = new OIDCLoginMethod({oidcApi, nonce, codeVerifier, code, homeserver, startedAt, redirectUri, accountManagementUrl}); const status = await this._attemptLogin(method); let error = ""; switch (status) { diff --git a/src/domain/login/StartOIDCLoginViewModel.js b/src/domain/login/StartOIDCLoginViewModel.js index 70980e32..07cae075 100644 --- a/src/domain/login/StartOIDCLoginViewModel.js +++ b/src/domain/login/StartOIDCLoginViewModel.js @@ -22,6 +22,7 @@ export class StartOIDCLoginViewModel extends ViewModel { super(options); this._isBusy = true; this._issuer = options.loginOptions.oidc.issuer; + this._accountManagementUrl = options.loginOptions.oidc.account; this._homeserver = options.loginOptions.homeserver; this._api = new OidcApi({ issuer: this._issuer, @@ -70,6 +71,7 @@ export class StartOIDCLoginViewModel extends ViewModel { this.platform.settingsStorage.setString(`oidc_${p.state}_homeserver`, this._homeserver), this.platform.settingsStorage.setString(`oidc_${p.state}_issuer`, this._issuer), this.platform.settingsStorage.setString(`oidc_${p.state}_client_id`, clientId), + this.platform.settingsStorage.setString(`oidc_${p.state}_account_management_url`, this._accountManagementUrl), ]); const link = await this._api.authorizationEndpoint(p); diff --git a/src/domain/session/settings/SettingsViewModel.js b/src/domain/session/settings/SettingsViewModel.js index 4dcdb111..147d7402 100644 --- a/src/domain/session/settings/SettingsViewModel.js +++ b/src/domain/session/settings/SettingsViewModel.js @@ -53,6 +53,7 @@ export class SettingsViewModel extends ViewModel { this.pushNotifications = new PushNotificationStatus(); this._activeTheme = undefined; this._logsFeedbackMessage = undefined; + this._accountManagementUrl = null; } get _session() { @@ -82,9 +83,16 @@ export class SettingsViewModel extends ViewModel { if (!import.meta.env.DEV) { this._activeTheme = await this.platform.themeLoader.getActiveTheme(); } + const {accountManagementUrl} = await this.platform.sessionInfoStorage.get(this._client._sessionId); + this._accountManagementUrl = accountManagementUrl; this.emitChange(""); } + + get accountManagementUrl() { + return this._accountManagementUrl; + } + get closeUrl() { return this._closeUrl; } diff --git a/src/matrix/Client.js b/src/matrix/Client.js index d51f23a3..4597b511 100644 --- a/src/matrix/Client.js +++ b/src/matrix/Client.js @@ -125,7 +125,7 @@ export class Client { queryLogin(initialHomeserver) { return new AbortableOperation(async setAbortable => { - const { homeserver, issuer } = await lookupHomeserver(initialHomeserver, (url, options) => { + const { homeserver, issuer, account } = await lookupHomeserver(initialHomeserver, (url, options) => { return setAbortable(this._platform.request(url, options)); }); if (issuer) { @@ -140,7 +140,7 @@ export class Client { return { homeserver, - oidc: { issuer }, + oidc: { issuer, account }, }; } catch (e) { console.log(e); @@ -202,6 +202,7 @@ export class Client { if (loginData.oidc_issuer) { sessionInfo.oidcIssuer = loginData.oidc_issuer; sessionInfo.oidcClientId = loginData.oidc_client_id; + sessionInfo.accountManagementUrl = loginData.oidc_account_management_url; } log.set("id", sessionId); diff --git a/src/matrix/login/OIDCLoginMethod.ts b/src/matrix/login/OIDCLoginMethod.ts index b25689aa..e0e3f58f 100644 --- a/src/matrix/login/OIDCLoginMethod.ts +++ b/src/matrix/login/OIDCLoginMethod.ts @@ -25,6 +25,7 @@ export class OIDCLoginMethod implements ILoginMethod { private readonly _nonce: string; private readonly _redirectUri: string; private readonly _oidcApi: OidcApi; + private readonly _accountManagementUrl?: string; public readonly homeserver: string; constructor({ @@ -34,6 +35,7 @@ export class OIDCLoginMethod implements ILoginMethod { homeserver, redirectUri, oidcApi, + accountManagementUrl, }: { nonce: string, code: string, @@ -41,6 +43,7 @@ export class OIDCLoginMethod implements ILoginMethod { homeserver: string, redirectUri: string, oidcApi: OidcApi, + accountManagementUrl?: string, }) { this._oidcApi = oidcApi; this._code = code; @@ -48,6 +51,7 @@ export class OIDCLoginMethod implements ILoginMethod { this._nonce = nonce; this._redirectUri = redirectUri; this.homeserver = homeserver; + this._accountManagementUrl = accountManagementUrl; } async login(hsApi: HomeServerApi, _deviceName: string, log: ILogItem): Promise> { @@ -68,6 +72,6 @@ export class OIDCLoginMethod implements ILoginMethod { const oidc_issuer = this._oidcApi.issuer; const oidc_client_id = await this._oidcApi.clientId(); - return { oidc_issuer, oidc_client_id, access_token, refresh_token, expires_in, user_id, device_id }; + return { oidc_issuer, oidc_client_id, access_token, refresh_token, expires_in, user_id, device_id, oidc_account_management_url: this._accountManagementUrl }; } } diff --git a/src/matrix/sessioninfo/localstorage/SessionInfoStorage.ts b/src/matrix/sessioninfo/localstorage/SessionInfoStorage.ts index 80443e83..000879e8 100644 --- a/src/matrix/sessioninfo/localstorage/SessionInfoStorage.ts +++ b/src/matrix/sessioninfo/localstorage/SessionInfoStorage.ts @@ -24,6 +24,7 @@ interface ISessionInfo { accessTokenExpiresAt?: number; refreshToken?: string; oidcIssuer?: string; + accountManagementUrl?: string; lastUsed: number; } diff --git a/src/matrix/well-known.js b/src/matrix/well-known.js index 10e78f2c..9a858f2b 100644 --- a/src/matrix/well-known.js +++ b/src/matrix/well-known.js @@ -42,6 +42,7 @@ async function getWellKnownResponse(homeserver, request) { export async function lookupHomeserver(homeserver, request) { homeserver = normalizeHomeserver(homeserver); let issuer = null; + let account = null; const wellKnownResponse = await getWellKnownResponse(homeserver, request); if (wellKnownResponse && wellKnownResponse.status === 200) { const {body} = wellKnownResponse; @@ -54,6 +55,11 @@ export async function lookupHomeserver(homeserver, request) { if (typeof wellKnownIssuer === "string") { issuer = wellKnownIssuer; } + + const wellKnownAccount = body["org.matrix.msc2965.authentication"]?.["account"]; + if (typeof wellKnownAccount === "string") { + account = wellKnownAccount; + } } - return {homeserver, issuer}; + return {homeserver, issuer, account}; } diff --git a/src/platform/web/ui/session/settings/SettingsView.js b/src/platform/web/ui/session/settings/SettingsView.js index c4405e82..66290357 100644 --- a/src/platform/web/ui/session/settings/SettingsView.js +++ b/src/platform/web/ui/session/settings/SettingsView.js @@ -47,6 +47,13 @@ export class SettingsView extends TemplateView { disabled: vm => vm.isLoggingOut }, vm.i18n`Log out`)), ); + + settingNodes.push( + t.if(vm => vm.accountManagementUrl, t => { + return t.p([vm.i18n`You can manage your account `, t.a({href: vm.accountManagementUrl, target: "_blank"}, vm.i18n`here`), "."]); + }), + ); + settingNodes.push( t.h3("Key backup"), t.view(new KeyBackupSettingsView(vm.keyBackupViewModel)) From ef9f90bc3609e859938633d6f6a107c79184f0c4 Mon Sep 17 00:00:00 2001 From: RMidhunSuresh Date: Fri, 29 Jul 2022 23:40:12 +0530 Subject: [PATCH 190/226] Fix imports breaking on dev --- src/platform/web/theming/DerivedVariables.ts | 2 +- src/platform/web/theming/shared/color.mjs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/platform/web/theming/DerivedVariables.ts b/src/platform/web/theming/DerivedVariables.ts index 5a626bfa..92e5ac20 100644 --- a/src/platform/web/theming/DerivedVariables.ts +++ b/src/platform/web/theming/DerivedVariables.ts @@ -98,7 +98,7 @@ export class DerivedVariables { } } -import pkg from "off-color"; +import * as pkg from "off-color"; const {offColor} = pkg; export function tests() { diff --git a/src/platform/web/theming/shared/color.mjs b/src/platform/web/theming/shared/color.mjs index e777bfac..d4a15e56 100644 --- a/src/platform/web/theming/shared/color.mjs +++ b/src/platform/web/theming/shared/color.mjs @@ -13,7 +13,7 @@ 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 pkg from 'off-color'; +import * as pkg from 'off-color'; const offColor = pkg.offColor; export function derive(value, operation, argument, isDark) { From ba8cdea6b477a8d5a1d499348d033fdcec7ae050 Mon Sep 17 00:00:00 2001 From: RMidhunSuresh Date: Mon, 1 Aug 2022 13:30:17 +0530 Subject: [PATCH 191/226] Use default import if other not found --- src/platform/web/theming/DerivedVariables.ts | 2 +- src/platform/web/theming/shared/color.mjs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/platform/web/theming/DerivedVariables.ts b/src/platform/web/theming/DerivedVariables.ts index 92e5ac20..bc7a8346 100644 --- a/src/platform/web/theming/DerivedVariables.ts +++ b/src/platform/web/theming/DerivedVariables.ts @@ -99,7 +99,7 @@ export class DerivedVariables { } import * as pkg from "off-color"; -const {offColor} = pkg; +const offColor = pkg.offColor ?? pkg.default.offColor; export function tests() { return { diff --git a/src/platform/web/theming/shared/color.mjs b/src/platform/web/theming/shared/color.mjs index d4a15e56..8af76b6b 100644 --- a/src/platform/web/theming/shared/color.mjs +++ b/src/platform/web/theming/shared/color.mjs @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ import * as pkg from 'off-color'; -const offColor = pkg.offColor; +const offColor = pkg.offColor ?? pkg.default.offColor; export function derive(value, operation, argument, isDark) { const argumentAsNumber = parseInt(argument); From 236a4ab49bac40a80503b2b610d2f8826a0ee1cb Mon Sep 17 00:00:00 2001 From: RMidhunSuresh Date: Mon, 1 Aug 2022 16:51:39 +0530 Subject: [PATCH 192/226] Ignore error --- src/platform/web/theming/DerivedVariables.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/platform/web/theming/DerivedVariables.ts b/src/platform/web/theming/DerivedVariables.ts index bc7a8346..ca46a8fd 100644 --- a/src/platform/web/theming/DerivedVariables.ts +++ b/src/platform/web/theming/DerivedVariables.ts @@ -99,6 +99,7 @@ export class DerivedVariables { } import * as pkg from "off-color"; +// @ts-ignore const offColor = pkg.offColor ?? pkg.default.offColor; export function tests() { From 832597447accd0a04f6aec444ec8a64a1d82c90f Mon Sep 17 00:00:00 2001 From: RMidhunSuresh Date: Mon, 1 Aug 2022 17:00:56 +0530 Subject: [PATCH 193/226] Add explaining doc --- doc/IMPORT-ISSUES.md | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 doc/IMPORT-ISSUES.md diff --git a/doc/IMPORT-ISSUES.md b/doc/IMPORT-ISSUES.md new file mode 100644 index 00000000..d4bb62fe --- /dev/null +++ b/doc/IMPORT-ISSUES.md @@ -0,0 +1,11 @@ +## How to import common-js dependency using ES6 syntax +--- +Until [#6632](https://github.com/vitejs/vite/issues/6632) is fixed, such imports should be done as follows: + +```ts +import * as pkg from "off-color"; +// @ts-ignore +const offColor = pkg.offColor ?? pkg.default.offColor; +``` + +This way build, dev server and unit tests should all work. From 97391663d36fb01d24af55e6b211ee7c5dcc796f Mon Sep 17 00:00:00 2001 From: Bruno Windels <274386+bwindels@users.noreply.github.com> Date: Mon, 1 Aug 2022 14:32:26 +0200 Subject: [PATCH 194/226] sdk version 0.1.0 --- scripts/sdk/base-manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/sdk/base-manifest.json b/scripts/sdk/base-manifest.json index 25342baa..b00b0b62 100644 --- a/scripts/sdk/base-manifest.json +++ b/scripts/sdk/base-manifest.json @@ -1,7 +1,7 @@ { "name": "hydrogen-view-sdk", "description": "Embeddable matrix client library, including view components", - "version": "0.0.15", + "version": "0.1.0", "main": "./lib-build/hydrogen.cjs.js", "exports": { ".": { From eec3157c54dfca904be344e273f7cc14e6686e38 Mon Sep 17 00:00:00 2001 From: Quentin Gliech Date: Wed, 2 Feb 2022 15:26:02 +0100 Subject: [PATCH 195/226] Multi-arch capable Dockerfile --- Dockerfile | 15 ++++++++++----- doc/docker.md | 4 +++- 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/Dockerfile b/Dockerfile index f9e32313..4b085ebc 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,9 +1,14 @@ -FROM docker.io/node:alpine as builder +FROM --platform=${BUILDPLATFORM} docker.io/library/node:16.13-alpine3.15 as builder RUN apk add --no-cache git python3 build-base -COPY . /app WORKDIR /app -RUN yarn install \ - && yarn build -FROM docker.io/nginx:alpine +# Install the dependencies first +COPY yarn.lock package.json ./ +RUN yarn install + +# Copy the rest and build the app +COPY . . +RUN yarn build + +FROM --platform=${TARGETPLATFORM} docker.io/library/nginx:alpine COPY --from=builder /app/target /usr/share/nginx/html diff --git a/doc/docker.md b/doc/docker.md index 910938f0..6ca67c02 100644 --- a/doc/docker.md +++ b/doc/docker.md @@ -35,7 +35,9 @@ To stop the container, simply hit `ctrl+c`. In this repository, create a Docker image: -``` +```sh +# Enable BuildKit https://docs.docker.com/develop/develop-images/build_enhancements/ +export DOCKER_BUILDKIT=1 docker build -t hydrogen . ``` From 1883aef25352ffd855c69daf59c442682e40e163 Mon Sep 17 00:00:00 2001 From: Quentin Gliech Date: Wed, 2 Feb 2022 15:33:10 +0100 Subject: [PATCH 196/226] Use non-root nginx base in Docker image --- Dockerfile | 2 +- doc/docker.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Dockerfile b/Dockerfile index 4b085ebc..fade2a24 100644 --- a/Dockerfile +++ b/Dockerfile @@ -10,5 +10,5 @@ RUN yarn install COPY . . RUN yarn build -FROM --platform=${TARGETPLATFORM} docker.io/library/nginx:alpine +FROM --platform=${TARGETPLATFORM} docker.io/nginxinc/nginx-unprivileged:1.21-alpine COPY --from=builder /app/target /usr/share/nginx/html diff --git a/doc/docker.md b/doc/docker.md index 6ca67c02..f7c1b450 100644 --- a/doc/docker.md +++ b/doc/docker.md @@ -55,6 +55,6 @@ Then, start up a container from that image: ``` docker run \ --name hydrogen \ - --publish 80:80 \ + --publish 8080:80 \ hydrogen ``` From d753442aeb60c0c9ad9171ccd83415c05f3ec587 Mon Sep 17 00:00:00 2001 From: Quentin Gliech Date: Wed, 2 Feb 2022 15:54:01 +0100 Subject: [PATCH 197/226] Build and push multi-arch Docker images in CI --- .github/workflows/docker-publish.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 0a322a3d..e02282f8 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -23,6 +23,9 @@ jobs: - name: Checkout repository uses: actions/checkout@v2 + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v1 + - name: Log into registry ${{ env.REGISTRY }} uses: docker/login-action@v1 with: @@ -39,6 +42,7 @@ jobs: - name: Build and push Docker image uses: docker/build-push-action@v2 with: + platforms: linux/amd64,linux/arm64,linux/arm/v7 push: ${{ github.event_name != 'pull_request' }} tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} From 4728b1038202d2c47f64e85d528aa15d68f58484 Mon Sep 17 00:00:00 2001 From: Quentin Gliech Date: Wed, 2 Feb 2022 16:02:53 +0100 Subject: [PATCH 198/226] Update the documentation to reference the published docker image --- doc/docker.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/doc/docker.md b/doc/docker.md index f7c1b450..c3b1f5ab 100644 --- a/doc/docker.md +++ b/doc/docker.md @@ -41,11 +41,11 @@ export DOCKER_BUILDKIT=1 docker build -t hydrogen . ``` -Or, pull the docker image from GitLab: +Or, pull the Docker image the GitHub Container Registry: ``` -docker pull registry.gitlab.com/jcgruenhage/hydrogen-web -docker tag registry.gitlab.com/jcgruenhage/hydrogen-web hydrogen +docker pull ghcr.io/vector-im/hydrogen +docker tag ghcr.io/vector-im/hydrogen hydrogen ``` ### Start container image From 6ebb058e59b3dc75fb1c19ca388a59479e1ddfdf Mon Sep 17 00:00:00 2001 From: Quentin Gliech Date: Wed, 2 Feb 2022 15:49:58 +0100 Subject: [PATCH 199/226] Make the Docker image configurable at runtime --- Dockerfile | 16 ++++++++++++++++ docker/config-template.sh | 7 +++++++ docker/config.json.tmpl | 8 ++++++++ 3 files changed, 31 insertions(+) create mode 100755 docker/config-template.sh create mode 100644 docker/config.json.tmpl diff --git a/Dockerfile b/Dockerfile index fade2a24..d4faba7e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -10,5 +10,21 @@ RUN yarn install COPY . . RUN yarn build +# Remove the default config, replace it with a symlink to somewhere that will be updated at runtime +RUN rm -f target/config.json \ + && ln -sf /tmp/config.json target/config.json + FROM --platform=${TARGETPLATFORM} docker.io/nginxinc/nginx-unprivileged:1.21-alpine + +# Copy the config template as well as the templating script +COPY ./docker/config.json.tmpl /config.json.tmpl +COPY ./docker/config-template.sh /docker-entrypoint.d/99-config-template.sh + +# Copy the built app from the first build stage COPY --from=builder /app/target /usr/share/nginx/html + +# Values from the default config that can be overridden at runtime +ENV PUSH_APP_ID="io.element.hydrogen.web" \ + PUSH_GATEWAY_URL="https://matrix.org" \ + PUSH_APPLICATION_SERVER_KEY="BC-gpSdVHEXhvHSHS0AzzWrQoukv2BE7KzpoPO_FfPacqOo3l1pdqz7rSgmB04pZCWaHPz7XRe6fjLaC-WPDopM" \ + DEFAULT_HOMESERVER="matrix.org" diff --git a/docker/config-template.sh b/docker/config-template.sh new file mode 100755 index 00000000..f6cff00c --- /dev/null +++ b/docker/config-template.sh @@ -0,0 +1,7 @@ +#!/bin/sh + +set -eux + +envsubst '$PUSH_APP_ID,$PUSH_GATEWAY_URL,$PUSH_APPLICATION_SERVER_KEY,$DEFAULT_HOMESERVER' \ + < /config.json.tmpl \ + > /tmp/config.json diff --git a/docker/config.json.tmpl b/docker/config.json.tmpl new file mode 100644 index 00000000..94295c43 --- /dev/null +++ b/docker/config.json.tmpl @@ -0,0 +1,8 @@ +{ + "push": { + "appId": "$PUSH_APP_ID", + "gatewayUrl": "$PUSH_GATEWAY_URL", + "applicationServerKey": "$PUSH_APPLICATION_SERVER_KEY" + }, + "defaultHomeServer": "$DEFAULT_HOMESERVER" +} From 9606de2e0fbe35247023de05953ab0eb4b26fdaf Mon Sep 17 00:00:00 2001 From: Quentin Gliech Date: Thu, 3 Mar 2022 09:25:58 +0100 Subject: [PATCH 200/226] Native OIDC login --- src/domain/RootViewModel.js | 24 +- .../login/CompleteOIDCLoginViewModel.js | 84 +++++++ src/domain/login/LoginViewModel.ts | 44 +++- src/domain/login/StartOIDCLoginViewModel.js | 55 +++++ src/domain/navigation/index.ts | 53 ++++- src/matrix/Client.js | 71 +++++- src/matrix/login/OIDCLoginMethod.ts | 67 ++++++ src/matrix/net/HomeServerApi.ts | 20 +- src/matrix/net/OidcApi.ts | 221 ++++++++++++++++++ src/matrix/net/TokenRefresher.ts | 125 ++++++++++ .../localstorage/SessionInfoStorage.ts | 17 ++ src/matrix/well-known.js | 8 +- src/observable/ObservableValue.ts | 32 +++ src/platform/types/types.ts | 1 + src/platform/web/ui/css/login.css | 4 +- src/platform/web/ui/login/LoginView.js | 12 + 16 files changed, 820 insertions(+), 18 deletions(-) create mode 100644 src/domain/login/CompleteOIDCLoginViewModel.js create mode 100644 src/domain/login/StartOIDCLoginViewModel.js create mode 100644 src/matrix/login/OIDCLoginMethod.ts create mode 100644 src/matrix/net/OidcApi.ts create mode 100644 src/matrix/net/TokenRefresher.ts diff --git a/src/domain/RootViewModel.js b/src/domain/RootViewModel.js index 4094d864..6cc17f35 100644 --- a/src/domain/RootViewModel.js +++ b/src/domain/RootViewModel.js @@ -38,6 +38,8 @@ export class RootViewModel extends ViewModel { this.track(this.navigation.observe("login").subscribe(() => this._applyNavigation())); this.track(this.navigation.observe("session").subscribe(() => this._applyNavigation())); this.track(this.navigation.observe("sso").subscribe(() => this._applyNavigation())); + this.track(this.navigation.observe("oidc-callback").subscribe(() => this._applyNavigation())); + this.track(this.navigation.observe("oidc-error").subscribe(() => this._applyNavigation())); this._applyNavigation(true); } @@ -46,6 +48,8 @@ export class RootViewModel extends ViewModel { const logoutSessionId = this.navigation.path.get("logout")?.value; const sessionId = this.navigation.path.get("session")?.value; const loginToken = this.navigation.path.get("sso")?.value; + const oidcCallback = this.navigation.path.get("oidc-callback")?.value; + const oidcError = this.navigation.path.get("oidc-error")?.value; if (isLogin) { if (this.activeSection !== "login") { this._showLogin(); @@ -77,7 +81,20 @@ export class RootViewModel extends ViewModel { } else if (loginToken) { this.urlCreator.normalizeUrl(); if (this.activeSection !== "login") { - this._showLogin(loginToken); + this._showLogin({loginToken}); + } + } else if (oidcError) { + this._setSection(() => this._error = new Error(`OIDC error: ${oidcError[1]}`)); + } else if (oidcCallback) { + this._setSection(() => this._error = new Error(`OIDC callback: state=${oidcCallback[0]}, code=${oidcCallback[1]}`)); + this.urlCreator.normalizeUrl(); + if (this.activeSection !== "login") { + this._showLogin({ + oidc: { + state: oidcCallback[0], + code: oidcCallback[1], + } + }); } } else { @@ -109,7 +126,7 @@ export class RootViewModel extends ViewModel { } } - _showLogin(loginToken) { + _showLogin({loginToken, oidc} = {}) { this._setSection(() => { this._loginViewModel = new LoginViewModel(this.childOptions({ defaultHomeserver: this.platform.config["defaultHomeServer"], @@ -125,7 +142,8 @@ export class RootViewModel extends ViewModel { this._pendingClient = client; this.navigation.push("session", client.sessionId); }, - loginToken + loginToken, + oidc, })); }); } diff --git a/src/domain/login/CompleteOIDCLoginViewModel.js b/src/domain/login/CompleteOIDCLoginViewModel.js new file mode 100644 index 00000000..fa0b665e --- /dev/null +++ b/src/domain/login/CompleteOIDCLoginViewModel.js @@ -0,0 +1,84 @@ +/* +Copyright 2021 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 {OidcApi} from "../../matrix/net/OidcApi"; +import {ViewModel} from "../ViewModel"; +import {OIDCLoginMethod} from "../../matrix/login/OIDCLoginMethod"; +import {LoginFailure} from "../../matrix/Client"; + +export class CompleteOIDCLoginViewModel extends ViewModel { + constructor(options) { + super(options); + const { + state, + code, + attemptLogin, + } = options; + this._request = options.platform.request; + this._encoding = options.platform.encoding; + this._state = state; + this._code = code; + this._attemptLogin = attemptLogin; + this._errorMessage = ""; + this.performOIDCLoginCompletion(); + } + + get errorMessage() { return this._errorMessage; } + + _showError(message) { + this._errorMessage = message; + this.emitChange("errorMessage"); + } + + async performOIDCLoginCompletion() { + if (!this._state || !this._code) { + return; + } + const code = this._code; + // TODO: cleanup settings storage + const [startedAt, nonce, codeVerifier, homeserver, issuer] = await Promise.all([ + this.platform.settingsStorage.getInt(`oidc_${this._state}_started_at`), + this.platform.settingsStorage.getString(`oidc_${this._state}_nonce`), + this.platform.settingsStorage.getString(`oidc_${this._state}_code_verifier`), + this.platform.settingsStorage.getString(`oidc_${this._state}_homeserver`), + this.platform.settingsStorage.getString(`oidc_${this._state}_issuer`), + ]); + + const oidcApi = new OidcApi({ + issuer, + clientId: "hydrogen-web", + request: this._request, + encoding: this._encoding, + }); + const method = new OIDCLoginMethod({oidcApi, nonce, codeVerifier, code, homeserver, startedAt}); + const status = await this._attemptLogin(method); + let error = ""; + switch (status) { + case LoginFailure.Credentials: + error = this.i18n`Your login token is invalid.`; + break; + case LoginFailure.Connection: + error = this.i18n`Can't connect to ${homeserver}.`; + break; + case LoginFailure.Unknown: + error = this.i18n`Something went wrong while checking your login token.`; + break; + } + if (error) { + this._showError(error); + } + } +} diff --git a/src/domain/login/LoginViewModel.ts b/src/domain/login/LoginViewModel.ts index 8eb11a9e..55e503a9 100644 --- a/src/domain/login/LoginViewModel.ts +++ b/src/domain/login/LoginViewModel.ts @@ -15,19 +15,24 @@ limitations under the License. */ import {Client} from "../../matrix/Client.js"; +import {OidcApi} from "../../matrix/net/OidcApi.js"; import {Options as BaseOptions, ViewModel} from "../ViewModel"; import {PasswordLoginViewModel} from "./PasswordLoginViewModel.js"; import {StartSSOLoginViewModel} from "./StartSSOLoginViewModel.js"; import {CompleteSSOLoginViewModel} from "./CompleteSSOLoginViewModel.js"; +import {StartOIDCLoginViewModel} from "./StartOIDCLoginViewModel.js"; +import {CompleteOIDCLoginViewModel} from "./CompleteOIDCLoginViewModel.js"; import {LoadStatus} from "../../matrix/Client.js"; import {SessionLoadViewModel} from "../SessionLoadViewModel.js"; import {SegmentType} from "../navigation/index"; import type {PasswordLoginMethod, SSOLoginHelper, TokenLoginMethod, ILoginMethod} from "../../matrix/login"; +import { OIDCLoginMethod } from "../../matrix/login/OIDCLoginMethod.js"; type Options = { defaultHomeserver: string; ready: ReadyFn; + oidc?: { state: string, code: string }; loginToken?: string; } & BaseOptions; @@ -35,10 +40,13 @@ export class LoginViewModel extends ViewModel { private _ready: ReadyFn; private _loginToken?: string; private _client: Client; + private _oidc?: { state: string, code: string }; private _loginOptions?: LoginOptions; private _passwordLoginViewModel?: PasswordLoginViewModel; private _startSSOLoginViewModel?: StartSSOLoginViewModel; private _completeSSOLoginViewModel?: CompleteSSOLoginViewModel; + private _startOIDCLoginViewModel?: StartOIDCLoginViewModel; + private _completeOIDCLoginViewModel?: CompleteOIDCLoginViewModel; private _loadViewModel?: SessionLoadViewModel; private _loadViewModelSubscription?: () => void; private _homeserver: string; @@ -52,9 +60,10 @@ export class LoginViewModel extends ViewModel { constructor(options: Readonly) { super(options); - const {ready, defaultHomeserver, loginToken} = options; + const {ready, defaultHomeserver, loginToken, oidc} = options; this._ready = ready; this._loginToken = loginToken; + this._oidc = oidc; this._client = new Client(this.platform); this._homeserver = defaultHomeserver; this._initViewModels(); @@ -72,6 +81,15 @@ export class LoginViewModel extends ViewModel { return this._completeSSOLoginViewModel; } + get startOIDCLoginViewModel(): StartOIDCLoginViewModel { + return this._startOIDCLoginViewModel; + } + + get completeOIDCLoginViewModel(): CompleteOIDCLoginViewModel { + return this._completeOIDCLoginViewModel; + } + + get homeserver(): string { return this._homeserver; } @@ -116,6 +134,18 @@ export class LoginViewModel extends ViewModel { }))); this.emitChange("completeSSOLoginViewModel"); } + else if (this._oidc) { + this._hideHomeserver = true; + this._completeOIDCLoginViewModel = this.track(new CompleteOIDCLoginViewModel( + this.childOptions( + { + client: this._client, + attemptLogin: (loginMethod: OIDCLoginMethod) => this.attemptLogin(loginMethod), + state: this._oidc.state, + code: this._oidc.code, + }))); + this.emitChange("completeOIDCLoginViewModel"); + } else { void this.queryHomeserver(); } @@ -137,6 +167,14 @@ export class LoginViewModel extends ViewModel { this.emitChange("startSSOLoginViewModel"); } + private async _showOIDCLogin(): Promise { + this._startOIDCLoginViewModel = this.track( + new StartOIDCLoginViewModel(this.childOptions({loginOptions: this._loginOptions})) + ); + await this._startOIDCLoginViewModel.start(); + this.emitChange("startOIDCLoginViewModel"); + } + private _showError(message: string): void { this._errorMessage = message; this.emitChange("errorMessage"); @@ -263,7 +301,8 @@ export class LoginViewModel extends ViewModel { if (this._loginOptions) { if (this._loginOptions.sso) { this._showSSOLogin(); } if (this._loginOptions.password) { this._showPasswordLogin(); } - if (!this._loginOptions.sso && !this._loginOptions.password) { + if (this._loginOptions.oidc) { await this._showOIDCLogin(); } + if (!this._loginOptions.sso && !this._loginOptions.password && !this._loginOptions.oidc) { this._showError("This homeserver supports neither SSO nor password based login flows"); } } @@ -289,5 +328,6 @@ type LoginOptions = { homeserver: string; password?: (username: string, password: string) => PasswordLoginMethod; sso?: SSOLoginHelper; + oidc?: { issuer: string }; token?: (loginToken: string) => TokenLoginMethod; }; diff --git a/src/domain/login/StartOIDCLoginViewModel.js b/src/domain/login/StartOIDCLoginViewModel.js new file mode 100644 index 00000000..e742fe1c --- /dev/null +++ b/src/domain/login/StartOIDCLoginViewModel.js @@ -0,0 +1,55 @@ +/* +Copyright 2021 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 {OidcApi} from "../../matrix/net/OidcApi"; +import {ViewModel} from "../ViewModel"; + +export class StartOIDCLoginViewModel extends ViewModel { + constructor(options) { + super(options); + this._isBusy = true; + this._authorizationEndpoint = null; + this._api = new OidcApi({ + clientId: "hydrogen-web", + issuer: options.loginOptions.oidc.issuer, + request: this.platform.request, + encoding: this.platform.encoding, + }); + this._homeserver = options.loginOptions.homeserver; + } + + get isBusy() { return this._isBusy; } + get authorizationEndpoint() { return this._authorizationEndpoint; } + + async start() { + const p = this._api.generateParams("openid"); + await Promise.all([ + this.platform.settingsStorage.setInt(`oidc_${p.state}_started_at`, Date.now()), + this.platform.settingsStorage.setString(`oidc_${p.state}_nonce`, p.nonce), + this.platform.settingsStorage.setString(`oidc_${p.state}_code_verifier`, p.codeVerifier), + this.platform.settingsStorage.setString(`oidc_${p.state}_homeserver`, this._homeserver), + this.platform.settingsStorage.setString(`oidc_${p.state}_issuer`, this._api.issuer), + ]); + + this._authorizationEndpoint = await this._api.authorizationEndpoint(p); + this._isBusy = false; + } + + setBusy(status) { + this._isBusy = status; + this.emitChange("isBusy"); + } +} diff --git a/src/domain/navigation/index.ts b/src/domain/navigation/index.ts index afba0d86..6cfa479c 100644 --- a/src/domain/navigation/index.ts +++ b/src/domain/navigation/index.ts @@ -33,6 +33,8 @@ export type SegmentType = { "details": true; "members": true; "member": string; + "oidc-callback": (string | null)[]; + "oidc-error": (string | null)[]; }; export function createNavigation(): Navigation { @@ -48,7 +50,7 @@ function allowsChild(parent: Segment | undefined, child: Segment | undefined, child: Segment(navigation: Navigation, defaultSessionId?: string): Segment[] { + const segments: Segment[] = []; + + // Special case for OIDC callback + if (urlPath.includes("state")) { + const params = new URLSearchParams(urlPath); + if (params.has("state")) { + // This is a proper OIDC callback + if (params.has("code")) { + segments.push(new Segment("oidc-callback", [ + params.get("state"), + params.get("code"), + ])); + return segments; + } else if (params.has("error")) { + segments.push(new Segment("oidc-error", [ + params.get("state"), + params.get("error"), + params.get("error_description"), + params.get("error_uri"), + ])); + return segments; + } + } + } + // substring(1) to take of initial / const parts = urlPath.substring(1).split("/"); const iterator = parts[Symbol.iterator](); - const segments: Segment[] = []; let next; while (!(next = iterator.next()).done) { const type = next.value; @@ -210,6 +236,8 @@ export function stringifyPath(path: Path): string { break; case "right-panel": case "sso": + case "oidc-callback": + case "oidc-error": // Do not put these segments in URL continue; default: @@ -485,6 +513,23 @@ export function tests() { assert.equal(newPath?.segments[1].type, "room"); assert.equal(newPath?.segments[1].value, "b"); }, - + "Parse OIDC callback": assert => { + const segments = parseUrlPath("state=tc9CnLU7&code=cnmUnwIYtY7V8RrWUyhJa4yvX72jJ5Yx"); + assert.equal(segments.length, 1); + assert.equal(segments[0].type, "oidc-callback"); + assert.deepEqual(segments[0].value, ["tc9CnLU7", "cnmUnwIYtY7V8RrWUyhJa4yvX72jJ5Yx"]); + }, + "Parse OIDC error": assert => { + const segments = parseUrlPath("state=tc9CnLU7&error=invalid_request"); + assert.equal(segments.length, 1); + assert.equal(segments[0].type, "oidc-error"); + assert.deepEqual(segments[0].value, ["tc9CnLU7", "invalid_request", null, null]); + }, + "Parse OIDC error with description": assert => { + const segments = parseUrlPath("state=tc9CnLU7&error=invalid_request&error_description=Unsupported%20response_type%20value"); + assert.equal(segments.length, 1); + assert.equal(segments[0].type, "oidc-error"); + assert.deepEqual(segments[0].value, ["tc9CnLU7", "invalid_request", "Unsupported response_type value", null]); + }, } } diff --git a/src/matrix/Client.js b/src/matrix/Client.js index 44643cc1..64daf727 100644 --- a/src/matrix/Client.js +++ b/src/matrix/Client.js @@ -20,6 +20,8 @@ import {lookupHomeserver} from "./well-known.js"; import {AbortableOperation} from "../utils/AbortableOperation"; import {ObservableValue} from "../observable/ObservableValue"; import {HomeServerApi} from "./net/HomeServerApi"; +import {OidcApi} from "./net/OidcApi"; +import {TokenRefresher} from "./net/TokenRefresher"; import {Reconnector, ConnectionStatus} from "./net/Reconnector"; import {ExponentialRetryDelay} from "./net/ExponentialRetryDelay"; import {MediaRepository} from "./net/MediaRepository"; @@ -123,11 +125,29 @@ export class Client { return result; } - queryLogin(homeserver) { + queryLogin(initialHomeserver) { return new AbortableOperation(async setAbortable => { - homeserver = await lookupHomeserver(homeserver, (url, options) => { + const { homeserver, issuer } = await lookupHomeserver(initialHomeserver, (url, options) => { return setAbortable(this._platform.request(url, options)); }); + if (issuer) { + try { + const oidcApi = new OidcApi({ + issuer, + clientId: "hydrogen-web", + request: this._platform.request, + encoding: this._platform.encoding, + }); + await oidcApi.validate(); + + return { + homeserver, + oidc: { issuer }, + }; + } catch (e) { + console.log(e); + } + } const hsApi = new HomeServerApi({homeserver, request: this._platform.request}); const response = await setAbortable(hsApi.getLoginFlows()).response(); return this._parseLoginOptions(response, homeserver); @@ -172,6 +192,19 @@ export class Client { accessToken: loginData.access_token, lastUsed: clock.now() }; + + if (loginData.refresh_token) { + sessionInfo.refreshToken = loginData.refresh_token; + } + + if (loginData.expires_in) { + sessionInfo.accessTokenExpiresAt = clock.now() + loginData.expires_in * 1000; + } + + if (loginData.oidc_issuer) { + sessionInfo.oidcIssuer = loginData.oidc_issuer; + } + log.set("id", sessionId); } catch (err) { this._error = err; @@ -225,9 +258,41 @@ export class Client { retryDelay: new ExponentialRetryDelay(clock.createTimeout), createMeasure: clock.createMeasure }); + + let accessToken; + + if (sessionInfo.oidcIssuer) { + const oidcApi = new OidcApi({ + issuer: sessionInfo.oidcIssuer, + clientId: "hydrogen-web", + request: this._platform.request, + encoding: this._platform.encoding, + }); + + // TODO: stop/pause the refresher? + const tokenRefresher = new TokenRefresher({ + oidcApi, + clock: this._platform.clock, + accessToken: sessionInfo.accessToken, + accessTokenExpiresAt: sessionInfo.accessTokenExpiresAt, + refreshToken: sessionInfo.refreshToken, + anticipation: 30 * 1000, + }); + + tokenRefresher.token.subscribe(t => { + this._platform.sessionInfoStorage.updateToken(sessionInfo.id, t.accessToken, t.accessTokenExpiresAt, t.refreshToken); + }); + + await tokenRefresher.start(); + + accessToken = tokenRefresher.accessToken; + } else { + accessToken = new ObservableValue(sessionInfo.accessToken); + } + const hsApi = new HomeServerApi({ homeserver: sessionInfo.homeServer, - accessToken: sessionInfo.accessToken, + accessToken, request: this._platform.request, reconnector: this._reconnector, }); diff --git a/src/matrix/login/OIDCLoginMethod.ts b/src/matrix/login/OIDCLoginMethod.ts new file mode 100644 index 00000000..0226877a --- /dev/null +++ b/src/matrix/login/OIDCLoginMethod.ts @@ -0,0 +1,67 @@ +/* +Copyright 2021 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 {ILogItem} from "../../logging/types"; +import {ILoginMethod} from "./LoginMethod"; +import {HomeServerApi} from "../net/HomeServerApi.js"; +import {OidcApi} from "../net/OidcApi"; + +export class OIDCLoginMethod implements ILoginMethod { + private readonly _code: string; + private readonly _codeVerifier: string; + private readonly _nonce: string; + private readonly _oidcApi: OidcApi; + public readonly homeserver: string; + + constructor({ + nonce, + codeVerifier, + code, + homeserver, + oidcApi, + }: { + nonce: string, + code: string, + codeVerifier: string, + homeserver: string, + oidcApi: OidcApi, + }) { + this._oidcApi = oidcApi; + this._code = code; + this._codeVerifier = codeVerifier; + this._nonce = nonce; + this.homeserver = homeserver; + } + + async login(hsApi: HomeServerApi, _deviceName: string, log: ILogItem): Promise> { + const { access_token, refresh_token, expires_in } = await this._oidcApi.completeAuthorizationCodeGrant({ + code: this._code, + codeVerifier: this._codeVerifier, + }); + + // TODO: validate the id_token and the nonce claim + + // Do a "whoami" request to find out the user_id and device_id + const { user_id, device_id } = await hsApi.whoami({ + log, + accessTokenOverride: access_token, + }).response(); + + const oidc_issuer = this._oidcApi.issuer; + + return { oidc_issuer, access_token, refresh_token, expires_in, user_id, device_id }; + } +} diff --git a/src/matrix/net/HomeServerApi.ts b/src/matrix/net/HomeServerApi.ts index e9902ef8..d97d9eae 100644 --- a/src/matrix/net/HomeServerApi.ts +++ b/src/matrix/net/HomeServerApi.ts @@ -31,7 +31,7 @@ const DEHYDRATION_PREFIX = "/_matrix/client/unstable/org.matrix.msc2697.v2"; type Options = { homeserver: string; - accessToken: string; + accessToken: BaseObservableValue; request: RequestFunction; reconnector: Reconnector; }; @@ -42,11 +42,12 @@ type BaseRequestOptions = { uploadProgress?: (loadedBytes: number) => void; timeout?: number; prefix?: string; + accessTokenOverride?: string; }; export class HomeServerApi { private readonly _homeserver: string; - private readonly _accessToken: string; + private readonly _accessToken: BaseObservableValue; private readonly _requestFn: RequestFunction; private readonly _reconnector: Reconnector; @@ -63,11 +64,19 @@ export class HomeServerApi { return this._homeserver + prefix + csPath; } - private _baseRequest(method: RequestMethod, url: string, queryParams?: Record, body?: Record, options?: BaseRequestOptions, accessToken?: string): IHomeServerRequest { + private _baseRequest(method: RequestMethod, url: string, queryParams?: Record, body?: Record, options?: BaseRequestOptions, accessTokenSource?: BaseObservableValue): IHomeServerRequest { const queryString = encodeQueryParams(queryParams); url = `${url}?${queryString}`; let encodedBody: EncodedBody["body"]; const headers: Map = new Map(); + + let accessToken: string | null = null; + if (options?.accessTokenOverride) { + accessToken = options.accessTokenOverride; + } else if (accessTokenSource) { + accessToken = accessTokenSource.get(); + } + if (accessToken) { headers.set("Authorization", `Bearer ${accessToken}`); } @@ -279,6 +288,10 @@ export class HomeServerApi { return this._post(`/logout`, {}, {}, options); } + whoami(options?: BaseRequestOptions): IHomeServerRequest { + return this._get(`/account/whoami`, undefined, undefined, options); + } + getDehydratedDevice(options: BaseRequestOptions = {}): IHomeServerRequest { options.prefix = DEHYDRATION_PREFIX; return this._get(`/dehydrated_device`, undefined, undefined, options); @@ -308,6 +321,7 @@ export class HomeServerApi { } import {Request as MockRequest} from "../../mocks/Request.js"; +import {BaseObservableValue} from "../../observable/ObservableValue"; export function tests() { return { diff --git a/src/matrix/net/OidcApi.ts b/src/matrix/net/OidcApi.ts new file mode 100644 index 00000000..3111d65f --- /dev/null +++ b/src/matrix/net/OidcApi.ts @@ -0,0 +1,221 @@ +/* +Copyright 2021 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. +*/ + +const WELL_KNOWN = ".well-known/openid-configuration"; + +const RANDOM_CHARSET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; +const randomChar = () => RANDOM_CHARSET.charAt(Math.floor(Math.random() * 1e10) % RANDOM_CHARSET.length); +const randomString = (length: number) => + Array.from({ length }, randomChar).join(""); + +type BearerToken = { + token_type: "Bearer", + access_token: string, + refresh_token?: string, + expires_in?: number, +} + +const isValidBearerToken = (t: any): t is BearerToken => + typeof t == "object" && + t["token_type"] === "Bearer" && + typeof t["access_token"] === "string" && + (!("refresh_token" in t) || typeof t["refresh_token"] === "string") && + (!("expires_in" in t) || typeof t["expires_in"] === "number"); + + +type AuthorizationParams = { + state: string, + scope: string, + nonce?: string, + codeVerifier?: string, +}; + +function assert(condition: any, message: string): asserts condition { + if (!condition) { + throw new Error(`Assertion failed: ${message}`); + } +}; + +export class OidcApi { + _issuer: string; + _clientId: string; + _requestFn: any; + _base64: any; + _metadataPromise: Promise; + + constructor({ issuer, clientId, request, encoding }) { + this._issuer = issuer; + this._clientId = clientId; + this._requestFn = request; + this._base64 = encoding.base64; + } + + get metadataUrl() { + return new URL(WELL_KNOWN, this._issuer).toString(); + } + + get issuer() { + return this._issuer; + } + + get redirectUri() { + return window.location.origin; + } + + metadata() { + if (!this._metadataPromise) { + this._metadataPromise = (async () => { + const headers = new Map(); + headers.set("Accept", "application/json"); + const req = this._requestFn(this.metadataUrl, { + method: "GET", + headers, + format: "json", + }); + const res = await req.response(); + if (res.status >= 400) { + throw new Error("failed to request metadata"); + } + + return res.body; + })(); + } + return this._metadataPromise; + } + + async validate() { + const m = await this.metadata(); + assert(typeof m.authorization_endpoint === "string", "Has an authorization endpoint"); + assert(typeof m.token_endpoint === "string", "Has a token endpoint"); + assert(Array.isArray(m.response_types_supported) && m.response_types_supported.includes("code"), "Supports the code response type"); + assert(Array.isArray(m.response_modes_supported) && m.response_modes_supported.includes("fragment"), "Supports the fragment response mode"); + assert(Array.isArray(m.grant_types_supported) && m.grant_types_supported.includes("authorization_code"), "Supports the authorization_code grant type"); + assert(Array.isArray(m.code_challenge_methods_supported) && m.code_challenge_methods_supported.includes("S256"), "Supports the authorization_code grant type"); + } + + async _generateCodeChallenge( + codeVerifier: string + ): Promise { + const encoder = new TextEncoder(); + const data = encoder.encode(codeVerifier); + const digest = await window.crypto.subtle.digest("SHA-256", data); + const base64Digest = this._base64.encode(digest); + return base64Digest.replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, ""); + } + + async authorizationEndpoint({ + state, + scope, + nonce, + codeVerifier, + }: AuthorizationParams) { + const metadata = await this.metadata(); + const url = new URL(metadata["authorization_endpoint"]); + url.searchParams.append("response_mode", "fragment"); + url.searchParams.append("response_type", "code"); + url.searchParams.append("redirect_uri", this.redirectUri); + url.searchParams.append("client_id", this._clientId); + url.searchParams.append("state", state); + url.searchParams.append("scope", scope); + if (nonce) { + url.searchParams.append("nonce", nonce); + } + + if (codeVerifier) { + url.searchParams.append("code_challenge_method", "S256"); + url.searchParams.append("code_challenge", await this._generateCodeChallenge(codeVerifier)); + } + + return url.toString(); + } + + async tokenEndpoint() { + const metadata = await this.metadata(); + return metadata["token_endpoint"]; + } + + generateParams(scope: string): AuthorizationParams { + return { + scope, + state: randomString(8), + nonce: randomString(8), + codeVerifier: randomString(32), + }; + } + + async completeAuthorizationCodeGrant({ + codeVerifier, + code, + }: { codeVerifier: string, code: string }): Promise { + const params = new URLSearchParams(); + params.append("grant_type", "authorization_code"); + params.append("client_id", this._clientId); + params.append("code_verifier", codeVerifier); + params.append("redirect_uri", this.redirectUri); + params.append("code", code); + const body = params.toString(); + + const headers = new Map(); + headers.set("Content-Type", "application/x-www-form-urlencoded"); + + const req = this._requestFn(await this.tokenEndpoint(), { + method: "POST", + headers, + format: "json", + body, + }); + + const res = await req.response(); + if (res.status >= 400) { + throw new Error("failed to exchange authorization code"); + } + + const token = res.body; + assert(isValidBearerToken(token), "Got back a valid bearer token"); + + return token; + } + + async refreshToken({ + refreshToken, + }: { refreshToken: string }): Promise { + const params = new URLSearchParams(); + params.append("grant_type", "refresh_token"); + params.append("client_id", this._clientId); + params.append("refresh_token", refreshToken); + const body = params.toString(); + + const headers = new Map(); + headers.set("Content-Type", "application/x-www-form-urlencoded"); + + const req = this._requestFn(await this.tokenEndpoint(), { + method: "POST", + headers, + format: "json", + body, + }); + + const res = await req.response(); + if (res.status >= 400) { + throw new Error("failed to use refresh token"); + } + + const token = res.body; + assert(isValidBearerToken(token), "Got back a valid bearer token"); + + return token; + } +} diff --git a/src/matrix/net/TokenRefresher.ts b/src/matrix/net/TokenRefresher.ts new file mode 100644 index 00000000..489dfb11 --- /dev/null +++ b/src/matrix/net/TokenRefresher.ts @@ -0,0 +1,125 @@ +/* +Copyright 2022 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, ObservableValue} from "../../observable/ObservableValue"; +import type {Clock, Timeout} from "../../platform/web/dom/Clock"; +import {OidcApi} from "./OidcApi"; + +type Token = { + accessToken: string, + accessTokenExpiresAt: number, + refreshToken: string, +}; + + +export class TokenRefresher { + private _token: ObservableValue; + private _accessToken: BaseObservableValue; + private _anticipation: number; + private _clock: Clock; + private _oidcApi: OidcApi; + private _timeout: Timeout + + constructor({ + oidcApi, + refreshToken, + accessToken, + accessTokenExpiresAt, + anticipation, + clock, + }: { + oidcApi: OidcApi, + refreshToken: string, + accessToken: string, + accessTokenExpiresAt: number, + anticipation: number, + clock: Clock, + }) { + this._token = new ObservableValue({ + accessToken, + accessTokenExpiresAt, + refreshToken, + }); + this._accessToken = this._token.map(t => t.accessToken); + + this._anticipation = anticipation; + this._oidcApi = oidcApi; + this._clock = clock; + } + + async start() { + if (this.needsRenewing) { + await this.renew(); + } + + this._renewingLoop(); + } + + stop() { + // TODO + } + + get needsRenewing() { + const remaining = this._token.get().accessTokenExpiresAt - this._clock.now(); + const anticipated = remaining - this._anticipation; + return anticipated < 0; + } + + async _renewingLoop() { + while (true) { + const remaining = + this._token.get().accessTokenExpiresAt - this._clock.now(); + const anticipated = remaining - this._anticipation; + + if (anticipated > 0) { + this._timeout = this._clock.createTimeout(anticipated); + await this._timeout.elapsed(); + } + + await this.renew(); + } + } + + async renew() { + let refreshToken = this._token.get().refreshToken; + const response = await this._oidcApi + .refreshToken({ + refreshToken, + }); + + if (typeof response.expires_in !== "number") { + throw new Error("Refreshed access token does not expire"); + } + + if (response.refresh_token) { + refreshToken = response.refresh_token; + } + + this._token.set({ + refreshToken, + accessToken: response.access_token, + accessTokenExpiresAt: this._clock.now() + response.expires_in * 1000, + }); + } + + get accessToken(): BaseObservableValue { + return this._accessToken; + } + + get token(): BaseObservableValue { + return this._token; + } +} diff --git a/src/matrix/sessioninfo/localstorage/SessionInfoStorage.ts b/src/matrix/sessioninfo/localstorage/SessionInfoStorage.ts index ebe575f6..80443e83 100644 --- a/src/matrix/sessioninfo/localstorage/SessionInfoStorage.ts +++ b/src/matrix/sessioninfo/localstorage/SessionInfoStorage.ts @@ -21,6 +21,9 @@ interface ISessionInfo { homeserver: string; homeServer: string; // deprecate this over time accessToken: string; + accessTokenExpiresAt?: number; + refreshToken?: string; + oidcIssuer?: string; lastUsed: number; } @@ -28,6 +31,7 @@ interface ISessionInfo { interface ISessionInfoStorage { getAll(): Promise; updateLastUsed(id: string, timestamp: number): Promise; + updateToken(id: string, accessToken: string, accessTokenExpiresAt: number, refreshToken: string): Promise; get(id: string): Promise; add(sessionInfo: ISessionInfo): Promise; delete(sessionId: string): Promise; @@ -62,6 +66,19 @@ export class SessionInfoStorage implements ISessionInfoStorage { } } + async updateToken(id: string, accessToken: string, accessTokenExpiresAt: number, refreshToken: string): Promise { + const sessions = await this.getAll(); + if (sessions) { + const session = sessions.find(session => session.id === id); + if (session) { + session.accessToken = accessToken; + session.accessTokenExpiresAt = accessTokenExpiresAt; + session.refreshToken = refreshToken; + localStorage.setItem(this._name, JSON.stringify(sessions)); + } + } + } + async get(id: string): Promise { const sessions = await this.getAll(); if (sessions) { diff --git a/src/matrix/well-known.js b/src/matrix/well-known.js index 00c91f27..6e3bedbf 100644 --- a/src/matrix/well-known.js +++ b/src/matrix/well-known.js @@ -41,6 +41,7 @@ async function getWellKnownResponse(homeserver, request) { export async function lookupHomeserver(homeserver, request) { homeserver = normalizeHomeserver(homeserver); + let issuer = null; const wellKnownResponse = await getWellKnownResponse(homeserver, request); if (wellKnownResponse && wellKnownResponse.status === 200) { const {body} = wellKnownResponse; @@ -48,6 +49,11 @@ export async function lookupHomeserver(homeserver, request) { if (typeof wellKnownHomeserver === "string") { homeserver = normalizeHomeserver(wellKnownHomeserver); } + + const wellKnownIssuer = body["m.authentication"]?.["issuer"]; + if (typeof wellKnownIssuer === "string") { + issuer = wellKnownIssuer; + } } - return homeserver; + return {homeserver, issuer}; } diff --git a/src/observable/ObservableValue.ts b/src/observable/ObservableValue.ts index ad0a226d..8b9b3be6 100644 --- a/src/observable/ObservableValue.ts +++ b/src/observable/ObservableValue.ts @@ -39,6 +39,10 @@ export abstract class BaseObservableValue extends BaseObservable<(value: T) = flatMap(mapper: (value: T) => (BaseObservableValue | undefined)): BaseObservableValue { return new FlatMapObservableValue(this, mapper); } + + map(mapper: (value: T) => C): BaseObservableValue { + return new MappedObservableValue(this, mapper); + } } interface IWaitHandle { @@ -174,6 +178,34 @@ export class FlatMapObservableValue extends BaseObservableValue extends BaseObservableValue { + private sourceSubscription?: SubscriptionHandle; + + constructor( + private readonly source: BaseObservableValue

, + private readonly mapper: (value: P) => C + ) { + super(); + } + + onUnsubscribeLast() { + super.onUnsubscribeLast(); + this.sourceSubscription = this.sourceSubscription!(); + } + + onSubscribeFirst() { + super.onSubscribeFirst(); + this.sourceSubscription = this.source.subscribe(() => { + this.emit(this.get()); + }); + } + + get(): C { + const sourceValue = this.source.get(); + return this.mapper(sourceValue); + } +} + export function tests() { return { "set emits an update": assert => { diff --git a/src/platform/types/types.ts b/src/platform/types/types.ts index 1d359a09..6605f238 100644 --- a/src/platform/types/types.ts +++ b/src/platform/types/types.ts @@ -26,6 +26,7 @@ export interface IRequestOptions { cache?: boolean; method?: string; format?: string; + accessTokenOverride?: string; } export type RequestFunction = (url: string, options: IRequestOptions) => RequestResult; diff --git a/src/platform/web/ui/css/login.css b/src/platform/web/ui/css/login.css index deb16b02..ae706242 100644 --- a/src/platform/web/ui/css/login.css +++ b/src/platform/web/ui/css/login.css @@ -68,13 +68,13 @@ limitations under the License. --size: 20px; } -.StartSSOLoginView { +.StartSSOLoginView, .StartOIDCLoginView { display: flex; flex-direction: column; padding: 0 0.4em 0; } -.StartSSOLoginView_button { +.StartSSOLoginView_button, .StartOIDCLoginView_button { flex: 1; margin-top: 12px; } diff --git a/src/platform/web/ui/login/LoginView.js b/src/platform/web/ui/login/LoginView.js index 88002625..ee8bf169 100644 --- a/src/platform/web/ui/login/LoginView.js +++ b/src/platform/web/ui/login/LoginView.js @@ -57,6 +57,7 @@ export class LoginView extends TemplateView { t.mapView(vm => vm.passwordLoginViewModel, vm => vm ? new PasswordLoginView(vm): null), t.if(vm => vm.passwordLoginViewModel && vm.startSSOLoginViewModel, t => t.p({className: "LoginView_separator"}, vm.i18n`or`)), t.mapView(vm => vm.startSSOLoginViewModel, vm => vm ? new StartSSOLoginView(vm) : null), + t.mapView(vm => vm.startOIDCLoginViewModel, vm => vm ? new StartOIDCLoginView(vm) : null), t.mapView(vm => vm.loadViewModel, loadViewModel => loadViewModel ? new SessionLoadStatusView(loadViewModel) : null), // use t.mapView rather than t.if to create a new view when the view model changes too t.p(hydrogenGithubLink(t)) @@ -76,3 +77,14 @@ class StartSSOLoginView extends TemplateView { ); } } + +class StartOIDCLoginView extends TemplateView { + render(t, vm) { + return t.div({ className: "StartOIDCLoginView" }, + t.a({ + className: "StartOIDCLoginView_button button-action secondary", + href: vm => (vm.isBusy ? "#" : vm.authorizationEndpoint), + }, vm.i18n`Log in via OIDC`) + ); + } +} From 4b07f048430f4e57d76af874c866a4b54af060a6 Mon Sep 17 00:00:00 2001 From: Quentin Gliech Date: Thu, 3 Mar 2022 13:43:16 +0100 Subject: [PATCH 201/226] Only generate the auth URL and start the login flow on click --- src/domain/login/LoginViewModel.ts | 5 ++-- src/domain/login/StartOIDCLoginViewModel.js | 30 ++++++++++++--------- src/platform/web/ui/login/LoginView.js | 4 ++- 3 files changed, 23 insertions(+), 16 deletions(-) diff --git a/src/domain/login/LoginViewModel.ts b/src/domain/login/LoginViewModel.ts index 55e503a9..bd225d56 100644 --- a/src/domain/login/LoginViewModel.ts +++ b/src/domain/login/LoginViewModel.ts @@ -171,8 +171,8 @@ export class LoginViewModel extends ViewModel { this._startOIDCLoginViewModel = this.track( new StartOIDCLoginViewModel(this.childOptions({loginOptions: this._loginOptions})) ); - await this._startOIDCLoginViewModel.start(); this.emitChange("startOIDCLoginViewModel"); + this._startOIDCLoginViewModel.discover(); } private _showError(message: string): void { @@ -184,6 +184,7 @@ export class LoginViewModel extends ViewModel { this._isBusy = status; this._passwordLoginViewModel?.setBusy(status); this._startSSOLoginViewModel?.setBusy(status); + this.startOIDCLoginViewModel?.setBusy(status); this.emitChange("isBusy"); } @@ -301,7 +302,7 @@ export class LoginViewModel extends ViewModel { if (this._loginOptions) { if (this._loginOptions.sso) { this._showSSOLogin(); } if (this._loginOptions.password) { this._showPasswordLogin(); } - if (this._loginOptions.oidc) { await this._showOIDCLogin(); } + if (this._loginOptions.oidc) { this._showOIDCLogin(); } if (!this._loginOptions.sso && !this._loginOptions.password && !this._loginOptions.oidc) { this._showError("This homeserver supports neither SSO nor password based login flows"); } diff --git a/src/domain/login/StartOIDCLoginViewModel.js b/src/domain/login/StartOIDCLoginViewModel.js index e742fe1c..146d81b1 100644 --- a/src/domain/login/StartOIDCLoginViewModel.js +++ b/src/domain/login/StartOIDCLoginViewModel.js @@ -21,35 +21,39 @@ export class StartOIDCLoginViewModel extends ViewModel { constructor(options) { super(options); this._isBusy = true; - this._authorizationEndpoint = null; + this._issuer = options.loginOptions.oidc.issuer; + this._homeserver = options.loginOptions.homeserver; this._api = new OidcApi({ clientId: "hydrogen-web", - issuer: options.loginOptions.oidc.issuer, + issuer: this._issuer, request: this.platform.request, encoding: this.platform.encoding, }); - this._homeserver = options.loginOptions.homeserver; } get isBusy() { return this._isBusy; } - get authorizationEndpoint() { return this._authorizationEndpoint; } - async start() { + setBusy(status) { + this._isBusy = status; + this.emitChange("isBusy"); + } + + async discover() { + // Ask for the metadata once so it gets discovered and cached + await this._api.metadata() + } + + async startOIDCLogin() { const p = this._api.generateParams("openid"); await Promise.all([ this.platform.settingsStorage.setInt(`oidc_${p.state}_started_at`, Date.now()), this.platform.settingsStorage.setString(`oidc_${p.state}_nonce`, p.nonce), this.platform.settingsStorage.setString(`oidc_${p.state}_code_verifier`, p.codeVerifier), this.platform.settingsStorage.setString(`oidc_${p.state}_homeserver`, this._homeserver), - this.platform.settingsStorage.setString(`oidc_${p.state}_issuer`, this._api.issuer), + this.platform.settingsStorage.setString(`oidc_${p.state}_issuer`, this._issuer), ]); - this._authorizationEndpoint = await this._api.authorizationEndpoint(p); - this._isBusy = false; - } - - setBusy(status) { - this._isBusy = status; - this.emitChange("isBusy"); + const link = await this._api.authorizationEndpoint(p); + this.platform.openUrl(link); } } diff --git a/src/platform/web/ui/login/LoginView.js b/src/platform/web/ui/login/LoginView.js index ee8bf169..116c82cd 100644 --- a/src/platform/web/ui/login/LoginView.js +++ b/src/platform/web/ui/login/LoginView.js @@ -83,7 +83,9 @@ class StartOIDCLoginView extends TemplateView { return t.div({ className: "StartOIDCLoginView" }, t.a({ className: "StartOIDCLoginView_button button-action secondary", - href: vm => (vm.isBusy ? "#" : vm.authorizationEndpoint), + type: "button", + onClick: () => vm.startOIDCLogin(), + disabled: vm => vm.isBusy }, vm.i18n`Log in via OIDC`) ); } From bbfa6de6dc775c47cd73b671613143071b04c13e Mon Sep 17 00:00:00 2001 From: Quentin Gliech Date: Thu, 3 Mar 2022 15:16:43 +0100 Subject: [PATCH 202/226] Generate the OIDC redirect URI from the URLRouter This also saves the redirectUri during the flow --- src/domain/login/CompleteOIDCLoginViewModel.js | 5 +++-- src/domain/login/StartOIDCLoginViewModel.js | 6 +++++- src/domain/navigation/URLRouter.ts | 4 ++++ src/matrix/login/OIDCLoginMethod.ts | 5 +++++ src/matrix/net/OidcApi.ts | 12 ++++++++---- 5 files changed, 25 insertions(+), 7 deletions(-) diff --git a/src/domain/login/CompleteOIDCLoginViewModel.js b/src/domain/login/CompleteOIDCLoginViewModel.js index fa0b665e..f3a9c441 100644 --- a/src/domain/login/CompleteOIDCLoginViewModel.js +++ b/src/domain/login/CompleteOIDCLoginViewModel.js @@ -49,10 +49,11 @@ export class CompleteOIDCLoginViewModel extends ViewModel { } const code = this._code; // TODO: cleanup settings storage - const [startedAt, nonce, codeVerifier, homeserver, issuer] = await Promise.all([ + const [startedAt, nonce, codeVerifier, redirectUri, homeserver, issuer] = await Promise.all([ this.platform.settingsStorage.getInt(`oidc_${this._state}_started_at`), this.platform.settingsStorage.getString(`oidc_${this._state}_nonce`), this.platform.settingsStorage.getString(`oidc_${this._state}_code_verifier`), + this.platform.settingsStorage.getString(`oidc_${this._state}_redirect_uri`), this.platform.settingsStorage.getString(`oidc_${this._state}_homeserver`), this.platform.settingsStorage.getString(`oidc_${this._state}_issuer`), ]); @@ -63,7 +64,7 @@ export class CompleteOIDCLoginViewModel extends ViewModel { request: this._request, encoding: this._encoding, }); - const method = new OIDCLoginMethod({oidcApi, nonce, codeVerifier, code, homeserver, startedAt}); + const method = new OIDCLoginMethod({oidcApi, nonce, codeVerifier, code, homeserver, startedAt, redirectUri}); const status = await this._attemptLogin(method); let error = ""; switch (status) { diff --git a/src/domain/login/StartOIDCLoginViewModel.js b/src/domain/login/StartOIDCLoginViewModel.js index 146d81b1..89600d58 100644 --- a/src/domain/login/StartOIDCLoginViewModel.js +++ b/src/domain/login/StartOIDCLoginViewModel.js @@ -44,11 +44,15 @@ export class StartOIDCLoginViewModel extends ViewModel { } async startOIDCLogin() { - const p = this._api.generateParams("openid"); + const p = this._api.generateParams({ + scope: "openid", + redirectUri: this.urlCreator.createOIDCRedirectURL(), + }); await Promise.all([ this.platform.settingsStorage.setInt(`oidc_${p.state}_started_at`, Date.now()), this.platform.settingsStorage.setString(`oidc_${p.state}_nonce`, p.nonce), this.platform.settingsStorage.setString(`oidc_${p.state}_code_verifier`, p.codeVerifier), + this.platform.settingsStorage.setString(`oidc_${p.state}_redirect_uri`, p.redirectUri), this.platform.settingsStorage.setString(`oidc_${p.state}_homeserver`, this._homeserver), this.platform.settingsStorage.setString(`oidc_${p.state}_issuer`, this._issuer), ]); diff --git a/src/domain/navigation/URLRouter.ts b/src/domain/navigation/URLRouter.ts index bf1c218d..98ae6b0d 100644 --- a/src/domain/navigation/URLRouter.ts +++ b/src/domain/navigation/URLRouter.ts @@ -152,6 +152,10 @@ export class URLRouter implements IURLRou return window.location.origin; } + createOIDCRedirectURL(): string { + return window.location.origin; + } + normalizeUrl(): void { // Remove any queryParameters from the URL // Gets rid of the loginToken after SSO diff --git a/src/matrix/login/OIDCLoginMethod.ts b/src/matrix/login/OIDCLoginMethod.ts index 0226877a..1e834b64 100644 --- a/src/matrix/login/OIDCLoginMethod.ts +++ b/src/matrix/login/OIDCLoginMethod.ts @@ -23,6 +23,7 @@ export class OIDCLoginMethod implements ILoginMethod { private readonly _code: string; private readonly _codeVerifier: string; private readonly _nonce: string; + private readonly _redirectUri: string; private readonly _oidcApi: OidcApi; public readonly homeserver: string; @@ -31,18 +32,21 @@ export class OIDCLoginMethod implements ILoginMethod { codeVerifier, code, homeserver, + redirectUri, oidcApi, }: { nonce: string, code: string, codeVerifier: string, homeserver: string, + redirectUri: string, oidcApi: OidcApi, }) { this._oidcApi = oidcApi; this._code = code; this._codeVerifier = codeVerifier; this._nonce = nonce; + this._redirectUri = redirectUri; this.homeserver = homeserver; } @@ -50,6 +54,7 @@ export class OIDCLoginMethod implements ILoginMethod { const { access_token, refresh_token, expires_in } = await this._oidcApi.completeAuthorizationCodeGrant({ code: this._code, codeVerifier: this._codeVerifier, + redirectUri: this._redirectUri, }); // TODO: validate the id_token and the nonce claim diff --git a/src/matrix/net/OidcApi.ts b/src/matrix/net/OidcApi.ts index 3111d65f..3dfe4cdd 100644 --- a/src/matrix/net/OidcApi.ts +++ b/src/matrix/net/OidcApi.ts @@ -39,6 +39,7 @@ const isValidBearerToken = (t: any): t is BearerToken => type AuthorizationParams = { state: string, scope: string, + redirectUri: string, nonce?: string, codeVerifier?: string, }; @@ -118,6 +119,7 @@ export class OidcApi { async authorizationEndpoint({ state, + redirectUri, scope, nonce, codeVerifier, @@ -126,7 +128,7 @@ export class OidcApi { const url = new URL(metadata["authorization_endpoint"]); url.searchParams.append("response_mode", "fragment"); url.searchParams.append("response_type", "code"); - url.searchParams.append("redirect_uri", this.redirectUri); + url.searchParams.append("redirect_uri", redirectUri); url.searchParams.append("client_id", this._clientId); url.searchParams.append("state", state); url.searchParams.append("scope", scope); @@ -147,9 +149,10 @@ export class OidcApi { return metadata["token_endpoint"]; } - generateParams(scope: string): AuthorizationParams { + generateParams({ scope, redirectUri }: { scope: string, redirectUri: string }): AuthorizationParams { return { scope, + redirectUri, state: randomString(8), nonce: randomString(8), codeVerifier: randomString(32), @@ -159,12 +162,13 @@ export class OidcApi { async completeAuthorizationCodeGrant({ codeVerifier, code, - }: { codeVerifier: string, code: string }): Promise { + redirectUri, + }: { codeVerifier: string, code: string, redirectUri: string }): Promise { const params = new URLSearchParams(); params.append("grant_type", "authorization_code"); params.append("client_id", this._clientId); params.append("code_verifier", codeVerifier); - params.append("redirect_uri", this.redirectUri); + params.append("redirect_uri", redirectUri); params.append("code", code); const body = params.toString(); From 8fbff2fd07f163ed3d2e5de95fcb8a8f4678d82b Mon Sep 17 00:00:00 2001 From: Quentin Gliech Date: Thu, 3 Mar 2022 15:30:28 +0100 Subject: [PATCH 203/226] Simplify OIDC callback navigation handling --- src/domain/RootViewModel.js | 26 +++++++++++--------------- src/domain/navigation/index.ts | 34 +++++++++++++++++----------------- 2 files changed, 28 insertions(+), 32 deletions(-) diff --git a/src/domain/RootViewModel.js b/src/domain/RootViewModel.js index 6cc17f35..38ea845f 100644 --- a/src/domain/RootViewModel.js +++ b/src/domain/RootViewModel.js @@ -38,8 +38,7 @@ export class RootViewModel extends ViewModel { this.track(this.navigation.observe("login").subscribe(() => this._applyNavigation())); this.track(this.navigation.observe("session").subscribe(() => this._applyNavigation())); this.track(this.navigation.observe("sso").subscribe(() => this._applyNavigation())); - this.track(this.navigation.observe("oidc-callback").subscribe(() => this._applyNavigation())); - this.track(this.navigation.observe("oidc-error").subscribe(() => this._applyNavigation())); + this.track(this.navigation.observe("oidc").subscribe(() => this._applyNavigation())); this._applyNavigation(true); } @@ -48,8 +47,7 @@ export class RootViewModel extends ViewModel { const logoutSessionId = this.navigation.path.get("logout")?.value; const sessionId = this.navigation.path.get("session")?.value; const loginToken = this.navigation.path.get("sso")?.value; - const oidcCallback = this.navigation.path.get("oidc-callback")?.value; - const oidcError = this.navigation.path.get("oidc-error")?.value; + const oidcCallback = this.navigation.path.get("oidc")?.value; if (isLogin) { if (this.activeSection !== "login") { this._showLogin(); @@ -83,18 +81,16 @@ export class RootViewModel extends ViewModel { if (this.activeSection !== "login") { this._showLogin({loginToken}); } - } else if (oidcError) { - this._setSection(() => this._error = new Error(`OIDC error: ${oidcError[1]}`)); } else if (oidcCallback) { - this._setSection(() => this._error = new Error(`OIDC callback: state=${oidcCallback[0]}, code=${oidcCallback[1]}`)); - this.urlCreator.normalizeUrl(); - if (this.activeSection !== "login") { - this._showLogin({ - oidc: { - state: oidcCallback[0], - code: oidcCallback[1], - } - }); + if (oidcCallback.error) { + this._setSection(() => this._error = new Error(`OIDC error: ${oidcCallback.error}`)); + } else { + this.urlCreator.normalizeUrl(); + if (this.activeSection !== "login") { + this._showLogin({ + oidc: oidcCallback, + }); + } } } else { diff --git a/src/domain/navigation/index.ts b/src/domain/navigation/index.ts index 6cfa479c..4801d9fa 100644 --- a/src/domain/navigation/index.ts +++ b/src/domain/navigation/index.ts @@ -50,7 +50,7 @@ function allowsChild(parent: Segment | undefined, child: Segment, if (params.has("state")) { // This is a proper OIDC callback if (params.has("code")) { - segments.push(new Segment("oidc-callback", [ - params.get("state"), - params.get("code"), - ])); + segments.push(new Segment("oidc", { + state: params.get("state"), + code: params.get("code"), + })); return segments; } else if (params.has("error")) { - segments.push(new Segment("oidc-error", [ - params.get("state"), - params.get("error"), - params.get("error_description"), - params.get("error_uri"), - ])); + segments.push(new Segment("oidc", { + state: params.get("state"), + error: params.get("error"), + errorDescription: params.get("error_description"), + errorUri: params.get("error_uri"), + })); return segments; } } @@ -516,20 +516,20 @@ export function tests() { "Parse OIDC callback": assert => { const segments = parseUrlPath("state=tc9CnLU7&code=cnmUnwIYtY7V8RrWUyhJa4yvX72jJ5Yx"); assert.equal(segments.length, 1); - assert.equal(segments[0].type, "oidc-callback"); - assert.deepEqual(segments[0].value, ["tc9CnLU7", "cnmUnwIYtY7V8RrWUyhJa4yvX72jJ5Yx"]); + assert.equal(segments[0].type, "oidc"); + assert.deepEqual(segments[0].value, {state: "tc9CnLU7", code: "cnmUnwIYtY7V8RrWUyhJa4yvX72jJ5Yx"}); }, "Parse OIDC error": assert => { const segments = parseUrlPath("state=tc9CnLU7&error=invalid_request"); assert.equal(segments.length, 1); - assert.equal(segments[0].type, "oidc-error"); - assert.deepEqual(segments[0].value, ["tc9CnLU7", "invalid_request", null, null]); + assert.equal(segments[0].type, "oidc"); + assert.deepEqual(segments[0].value, {state: "tc9CnLU7", error: "invalid_request", errorUri: null, errorDescription: null}); }, "Parse OIDC error with description": assert => { const segments = parseUrlPath("state=tc9CnLU7&error=invalid_request&error_description=Unsupported%20response_type%20value"); assert.equal(segments.length, 1); - assert.equal(segments[0].type, "oidc-error"); - assert.deepEqual(segments[0].value, ["tc9CnLU7", "invalid_request", "Unsupported response_type value", null]); + assert.equal(segments[0].type, "oidc"); + assert.deepEqual(segments[0].value, {state: "tc9CnLU7", error: "invalid_request", errorDescription: "Unsupported response_type value", errorUri: null}); }, } } From d5b5c371b4ad62739ea180503af13c6a6f46fb2e Mon Sep 17 00:00:00 2001 From: Quentin Gliech Date: Thu, 3 Mar 2022 15:41:40 +0100 Subject: [PATCH 204/226] Use platform APIs for text encoding and hashing --- src/domain/login/CompleteOIDCLoginViewModel.js | 2 ++ src/domain/login/StartOIDCLoginViewModel.js | 1 + src/matrix/Client.js | 2 ++ src/matrix/net/OidcApi.ts | 15 ++++++++------- 4 files changed, 13 insertions(+), 7 deletions(-) diff --git a/src/domain/login/CompleteOIDCLoginViewModel.js b/src/domain/login/CompleteOIDCLoginViewModel.js index f3a9c441..ca65c7c7 100644 --- a/src/domain/login/CompleteOIDCLoginViewModel.js +++ b/src/domain/login/CompleteOIDCLoginViewModel.js @@ -29,6 +29,7 @@ export class CompleteOIDCLoginViewModel extends ViewModel { } = options; this._request = options.platform.request; this._encoding = options.platform.encoding; + this._crypto = options.platform.crypto; this._state = state; this._code = code; this._attemptLogin = attemptLogin; @@ -63,6 +64,7 @@ export class CompleteOIDCLoginViewModel extends ViewModel { clientId: "hydrogen-web", request: this._request, encoding: this._encoding, + crypto: this._crypto, }); const method = new OIDCLoginMethod({oidcApi, nonce, codeVerifier, code, homeserver, startedAt, redirectUri}); const status = await this._attemptLogin(method); diff --git a/src/domain/login/StartOIDCLoginViewModel.js b/src/domain/login/StartOIDCLoginViewModel.js index 89600d58..a06b764f 100644 --- a/src/domain/login/StartOIDCLoginViewModel.js +++ b/src/domain/login/StartOIDCLoginViewModel.js @@ -28,6 +28,7 @@ export class StartOIDCLoginViewModel extends ViewModel { issuer: this._issuer, request: this.platform.request, encoding: this.platform.encoding, + crypto: this.platform.crypto, }); } diff --git a/src/matrix/Client.js b/src/matrix/Client.js index 64daf727..82b41f02 100644 --- a/src/matrix/Client.js +++ b/src/matrix/Client.js @@ -137,6 +137,7 @@ export class Client { clientId: "hydrogen-web", request: this._platform.request, encoding: this._platform.encoding, + crypto: this._platform.crypto, }); await oidcApi.validate(); @@ -267,6 +268,7 @@ export class Client { clientId: "hydrogen-web", request: this._platform.request, encoding: this._platform.encoding, + crypto: this._platform.crypto, }); // TODO: stop/pause the refresher? diff --git a/src/matrix/net/OidcApi.ts b/src/matrix/net/OidcApi.ts index 3dfe4cdd..f7c08dca 100644 --- a/src/matrix/net/OidcApi.ts +++ b/src/matrix/net/OidcApi.ts @@ -54,14 +54,16 @@ export class OidcApi { _issuer: string; _clientId: string; _requestFn: any; - _base64: any; + _encoding: any; + _crypto: any; _metadataPromise: Promise; - constructor({ issuer, clientId, request, encoding }) { + constructor({ issuer, clientId, request, encoding, crypto }) { this._issuer = issuer; this._clientId = clientId; this._requestFn = request; - this._base64 = encoding.base64; + this._encoding = encoding; + this._crypto = crypto; } get metadataUrl() { @@ -110,10 +112,9 @@ export class OidcApi { async _generateCodeChallenge( codeVerifier: string ): Promise { - const encoder = new TextEncoder(); - const data = encoder.encode(codeVerifier); - const digest = await window.crypto.subtle.digest("SHA-256", data); - const base64Digest = this._base64.encode(digest); + const data = this._encoding.utf8.encode(codeVerifier); + const digest = await this._crypto.digest("SHA-256", data); + const base64Digest = this._encoding.base64.encode(digest); return base64Digest.replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, ""); } From 29d32a23fe8d16b59af9a03614603fcbf6a9f794 Mon Sep 17 00:00:00 2001 From: Quentin Gliech Date: Thu, 3 Mar 2022 16:27:43 +0100 Subject: [PATCH 205/226] Stop the token refresher when disposing the client --- src/matrix/Client.js | 13 ++++++++----- src/matrix/net/OidcApi.ts | 4 +++- src/matrix/net/TokenRefresher.ts | 16 +++++++++++++--- 3 files changed, 24 insertions(+), 9 deletions(-) diff --git a/src/matrix/Client.js b/src/matrix/Client.js index 82b41f02..a8c85cc2 100644 --- a/src/matrix/Client.js +++ b/src/matrix/Client.js @@ -271,8 +271,7 @@ export class Client { crypto: this._platform.crypto, }); - // TODO: stop/pause the refresher? - const tokenRefresher = new TokenRefresher({ + this._tokenRefresher = new TokenRefresher({ oidcApi, clock: this._platform.clock, accessToken: sessionInfo.accessToken, @@ -281,13 +280,13 @@ export class Client { anticipation: 30 * 1000, }); - tokenRefresher.token.subscribe(t => { + this._tokenRefresher.token.subscribe(t => { this._platform.sessionInfoStorage.updateToken(sessionInfo.id, t.accessToken, t.accessTokenExpiresAt, t.refreshToken); }); - await tokenRefresher.start(); + await this._tokenRefresher.start(); - accessToken = tokenRefresher.accessToken; + accessToken = this._tokenRefresher.accessToken; } else { accessToken = new ObservableValue(sessionInfo.accessToken); } @@ -502,6 +501,10 @@ export class Client { this._sync.stop(); this._sync = null; } + if (this._tokenRefresher) { + this._tokenRefresher.stop(); + this._tokenRefresher = null; + } if (this._session) { this._session.dispose(); this._session = null; diff --git a/src/matrix/net/OidcApi.ts b/src/matrix/net/OidcApi.ts index f7c08dca..023ba485 100644 --- a/src/matrix/net/OidcApi.ts +++ b/src/matrix/net/OidcApi.ts @@ -14,6 +14,8 @@ See the License for the specific language governing permissions and limitations under the License. */ +import type {RequestFunction} from "../../platform/types/types"; + const WELL_KNOWN = ".well-known/openid-configuration"; const RANDOM_CHARSET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; @@ -53,7 +55,7 @@ function assert(condition: any, message: string): asserts condition { export class OidcApi { _issuer: string; _clientId: string; - _requestFn: any; + _requestFn: RequestFunction; _encoding: any; _crypto: any; _metadataPromise: Promise; diff --git a/src/matrix/net/TokenRefresher.ts b/src/matrix/net/TokenRefresher.ts index 489dfb11..2010cebe 100644 --- a/src/matrix/net/TokenRefresher.ts +++ b/src/matrix/net/TokenRefresher.ts @@ -32,6 +32,7 @@ export class TokenRefresher { private _clock: Clock; private _oidcApi: OidcApi; private _timeout: Timeout + private _running: boolean; constructor({ oidcApi, @@ -65,11 +66,15 @@ export class TokenRefresher { await this.renew(); } + this._running = true; this._renewingLoop(); } stop() { - // TODO + this._running = false; + if (this._timeout) { + this._timeout.dispose(); + } } get needsRenewing() { @@ -79,14 +84,19 @@ export class TokenRefresher { } async _renewingLoop() { - while (true) { + while (this._running) { const remaining = this._token.get().accessTokenExpiresAt - this._clock.now(); const anticipated = remaining - this._anticipation; if (anticipated > 0) { this._timeout = this._clock.createTimeout(anticipated); - await this._timeout.elapsed(); + try { + await this._timeout.elapsed(); + } catch { + // The timeout will throw when aborted, so stop the loop if it is the case + return; + } } await this.renew(); From d18f48b73c7ede75cbd4ab0050964fa1026de3d4 Mon Sep 17 00:00:00 2001 From: Quentin Gliech Date: Thu, 3 Mar 2022 16:38:41 +0100 Subject: [PATCH 206/226] Typo. --- src/domain/login/LoginViewModel.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/domain/login/LoginViewModel.ts b/src/domain/login/LoginViewModel.ts index bd225d56..2c793550 100644 --- a/src/domain/login/LoginViewModel.ts +++ b/src/domain/login/LoginViewModel.ts @@ -184,7 +184,7 @@ export class LoginViewModel extends ViewModel { this._isBusy = status; this._passwordLoginViewModel?.setBusy(status); this._startSSOLoginViewModel?.setBusy(status); - this.startOIDCLoginViewModel?.setBusy(status); + this._startOIDCLoginViewModel?.setBusy(status); this.emitChange("isBusy"); } From b899b347b6802021287239ac2cd7cc97ce05b7b7 Mon Sep 17 00:00:00 2001 From: Quentin Gliech Date: Mon, 25 Apr 2022 09:31:00 +0200 Subject: [PATCH 207/226] OIDC dynamic client registration --- .../login/CompleteOIDCLoginViewModel.js | 5 +- src/domain/login/StartOIDCLoginViewModel.js | 5 +- src/domain/navigation/URLRouter.ts | 4 ++ src/matrix/Client.js | 4 +- src/matrix/login/OIDCLoginMethod.ts | 3 +- src/matrix/net/OidcApi.ts | 70 ++++++++++++++++--- 6 files changed, 74 insertions(+), 17 deletions(-) diff --git a/src/domain/login/CompleteOIDCLoginViewModel.js b/src/domain/login/CompleteOIDCLoginViewModel.js index ca65c7c7..5d0da980 100644 --- a/src/domain/login/CompleteOIDCLoginViewModel.js +++ b/src/domain/login/CompleteOIDCLoginViewModel.js @@ -50,18 +50,19 @@ export class CompleteOIDCLoginViewModel extends ViewModel { } const code = this._code; // TODO: cleanup settings storage - const [startedAt, nonce, codeVerifier, redirectUri, homeserver, issuer] = await Promise.all([ + const [startedAt, nonce, codeVerifier, redirectUri, homeserver, issuer, clientId] = await Promise.all([ this.platform.settingsStorage.getInt(`oidc_${this._state}_started_at`), this.platform.settingsStorage.getString(`oidc_${this._state}_nonce`), this.platform.settingsStorage.getString(`oidc_${this._state}_code_verifier`), this.platform.settingsStorage.getString(`oidc_${this._state}_redirect_uri`), this.platform.settingsStorage.getString(`oidc_${this._state}_homeserver`), this.platform.settingsStorage.getString(`oidc_${this._state}_issuer`), + this.platform.settingsStorage.getString(`oidc_${this._state}_client_id`), ]); const oidcApi = new OidcApi({ issuer, - clientId: "hydrogen-web", + clientId, request: this._request, encoding: this._encoding, crypto: this._crypto, diff --git a/src/domain/login/StartOIDCLoginViewModel.js b/src/domain/login/StartOIDCLoginViewModel.js index a06b764f..e70a7487 100644 --- a/src/domain/login/StartOIDCLoginViewModel.js +++ b/src/domain/login/StartOIDCLoginViewModel.js @@ -24,11 +24,11 @@ export class StartOIDCLoginViewModel extends ViewModel { this._issuer = options.loginOptions.oidc.issuer; this._homeserver = options.loginOptions.homeserver; this._api = new OidcApi({ - clientId: "hydrogen-web", issuer: this._issuer, request: this.platform.request, encoding: this.platform.encoding, crypto: this.platform.crypto, + urlCreator: this.urlCreator, }); } @@ -42,6 +42,7 @@ export class StartOIDCLoginViewModel extends ViewModel { async discover() { // Ask for the metadata once so it gets discovered and cached await this._api.metadata() + await this._api.ensureRegistered(); } async startOIDCLogin() { @@ -49,6 +50,7 @@ export class StartOIDCLoginViewModel extends ViewModel { scope: "openid", redirectUri: this.urlCreator.createOIDCRedirectURL(), }); + const clientId = await this._api.clientId(); await Promise.all([ this.platform.settingsStorage.setInt(`oidc_${p.state}_started_at`, Date.now()), this.platform.settingsStorage.setString(`oidc_${p.state}_nonce`, p.nonce), @@ -56,6 +58,7 @@ export class StartOIDCLoginViewModel extends ViewModel { this.platform.settingsStorage.setString(`oidc_${p.state}_redirect_uri`, p.redirectUri), this.platform.settingsStorage.setString(`oidc_${p.state}_homeserver`, this._homeserver), this.platform.settingsStorage.setString(`oidc_${p.state}_issuer`, this._issuer), + this.platform.settingsStorage.setString(`oidc_${p.state}_client_id`, clientId), ]); const link = await this._api.authorizationEndpoint(p); diff --git a/src/domain/navigation/URLRouter.ts b/src/domain/navigation/URLRouter.ts index 98ae6b0d..ff9dcd76 100644 --- a/src/domain/navigation/URLRouter.ts +++ b/src/domain/navigation/URLRouter.ts @@ -156,6 +156,10 @@ export class URLRouter implements IURLRou return window.location.origin; } + absoluteUrlForAsset(asset: string): string { + return (new URL('/assets/' + asset, window.location.origin)).toString(); + } + normalizeUrl(): void { // Remove any queryParameters from the URL // Gets rid of the loginToken after SSO diff --git a/src/matrix/Client.js b/src/matrix/Client.js index a8c85cc2..6ba1a4b1 100644 --- a/src/matrix/Client.js +++ b/src/matrix/Client.js @@ -134,7 +134,6 @@ export class Client { try { const oidcApi = new OidcApi({ issuer, - clientId: "hydrogen-web", request: this._platform.request, encoding: this._platform.encoding, crypto: this._platform.crypto, @@ -204,6 +203,7 @@ export class Client { if (loginData.oidc_issuer) { sessionInfo.oidcIssuer = loginData.oidc_issuer; + sessionInfo.oidcClientId = loginData.oidc_client_id; } log.set("id", sessionId); @@ -265,7 +265,7 @@ export class Client { if (sessionInfo.oidcIssuer) { const oidcApi = new OidcApi({ issuer: sessionInfo.oidcIssuer, - clientId: "hydrogen-web", + clientId: sessionInfo.oidcClientId, request: this._platform.request, encoding: this._platform.encoding, crypto: this._platform.crypto, diff --git a/src/matrix/login/OIDCLoginMethod.ts b/src/matrix/login/OIDCLoginMethod.ts index 1e834b64..b25689aa 100644 --- a/src/matrix/login/OIDCLoginMethod.ts +++ b/src/matrix/login/OIDCLoginMethod.ts @@ -66,7 +66,8 @@ export class OIDCLoginMethod implements ILoginMethod { }).response(); const oidc_issuer = this._oidcApi.issuer; + const oidc_client_id = await this._oidcApi.clientId(); - return { oidc_issuer, access_token, refresh_token, expires_in, user_id, device_id }; + return { oidc_issuer, oidc_client_id, access_token, refresh_token, expires_in, user_id, device_id }; } } diff --git a/src/matrix/net/OidcApi.ts b/src/matrix/net/OidcApi.ts index 023ba485..319d122f 100644 --- a/src/matrix/net/OidcApi.ts +++ b/src/matrix/net/OidcApi.ts @@ -15,6 +15,7 @@ limitations under the License. */ import type {RequestFunction} from "../../platform/types/types"; +import type {URLRouter} from "../../domain/navigation/URLRouter.js"; const WELL_KNOWN = ".well-known/openid-configuration"; @@ -54,18 +55,35 @@ function assert(condition: any, message: string): asserts condition { export class OidcApi { _issuer: string; - _clientId: string; _requestFn: RequestFunction; _encoding: any; _crypto: any; + _urlCreator: URLRouter; _metadataPromise: Promise; + _registrationPromise: Promise; - constructor({ issuer, clientId, request, encoding, crypto }) { + constructor({ issuer, request, encoding, crypto, urlCreator, clientId }) { this._issuer = issuer; - this._clientId = clientId; this._requestFn = request; this._encoding = encoding; this._crypto = crypto; + this._urlCreator = urlCreator; + + if (clientId) { + this._registrationPromise = Promise.resolve({ client_id: clientId }); + } + } + + get clientMetadata() { + return { + client_name: "Hydrogen Web", + logo_uri: this._urlCreator.absoluteUrlForAsset("icon.png"), + response_types: ["code"], + grant_types: ["authorization_code", "refresh_token"], + redirect_uris: [this._urlCreator.createOIDCRedirectURL()], + id_token_signed_response_alg: "RS256", + token_endpoint_auth_method: "none", + }; } get metadataUrl() { @@ -76,11 +94,35 @@ export class OidcApi { return this._issuer; } - get redirectUri() { - return window.location.origin; + async clientId(): Promise { + return (await this.registration())["client_id"]; } - metadata() { + registration(): Promise { + if (!this._registrationPromise) { + this._registrationPromise = (async () => { + const headers = new Map(); + headers.set("Accept", "application/json"); + headers.set("Content-Type", "application/json"); + const req = this._requestFn(await this.registrationEndpoint(), { + method: "POST", + headers, + format: "json", + body: JSON.stringify(this.clientMetadata), + }); + const res = await req.response(); + if (res.status >= 400) { + throw new Error("failed to register client"); + } + + return res.body; + })(); + } + + return this._registrationPromise; + } + + metadata(): Promise { if (!this._metadataPromise) { this._metadataPromise = (async () => { const headers = new Map(); @@ -105,6 +147,7 @@ export class OidcApi { const m = await this.metadata(); assert(typeof m.authorization_endpoint === "string", "Has an authorization endpoint"); assert(typeof m.token_endpoint === "string", "Has a token endpoint"); + assert(typeof m.registration_endpoint === "string", "Has a registration endpoint"); assert(Array.isArray(m.response_types_supported) && m.response_types_supported.includes("code"), "Supports the code response type"); assert(Array.isArray(m.response_modes_supported) && m.response_modes_supported.includes("fragment"), "Supports the fragment response mode"); assert(Array.isArray(m.grant_types_supported) && m.grant_types_supported.includes("authorization_code"), "Supports the authorization_code grant type"); @@ -126,13 +169,13 @@ export class OidcApi { scope, nonce, codeVerifier, - }: AuthorizationParams) { + }: AuthorizationParams): Promise { const metadata = await this.metadata(); const url = new URL(metadata["authorization_endpoint"]); url.searchParams.append("response_mode", "fragment"); url.searchParams.append("response_type", "code"); url.searchParams.append("redirect_uri", redirectUri); - url.searchParams.append("client_id", this._clientId); + url.searchParams.append("client_id", await this.clientId()); url.searchParams.append("state", state); url.searchParams.append("scope", scope); if (nonce) { @@ -147,11 +190,16 @@ export class OidcApi { return url.toString(); } - async tokenEndpoint() { + async tokenEndpoint(): Promise { const metadata = await this.metadata(); return metadata["token_endpoint"]; } + async registrationEndpoint(): Promise { + const metadata = await this.metadata(); + return metadata["registration_endpoint"]; + } + generateParams({ scope, redirectUri }: { scope: string, redirectUri: string }): AuthorizationParams { return { scope, @@ -169,7 +217,7 @@ export class OidcApi { }: { codeVerifier: string, code: string, redirectUri: string }): Promise { const params = new URLSearchParams(); params.append("grant_type", "authorization_code"); - params.append("client_id", this._clientId); + params.append("client_id", await this.clientId()); params.append("code_verifier", codeVerifier); params.append("redirect_uri", redirectUri); params.append("code", code); @@ -201,7 +249,7 @@ export class OidcApi { }: { refreshToken: string }): Promise { const params = new URLSearchParams(); params.append("grant_type", "refresh_token"); - params.append("client_id", this._clientId); + params.append("client_id", await this.clientId()); params.append("refresh_token", refreshToken); const body = params.toString(); From 7aa7af4adfb1a2d43ec7bfaa6c75743bbeb2718e Mon Sep 17 00:00:00 2001 From: Quentin Gliech Date: Fri, 29 Apr 2022 16:30:24 +0200 Subject: [PATCH 208/226] Add client_uri, tos_uri and policy_uri client metadata --- src/domain/navigation/URLRouter.ts | 4 ++++ src/matrix/net/OidcApi.ts | 3 +++ 2 files changed, 7 insertions(+) diff --git a/src/domain/navigation/URLRouter.ts b/src/domain/navigation/URLRouter.ts index ff9dcd76..dbd1ceee 100644 --- a/src/domain/navigation/URLRouter.ts +++ b/src/domain/navigation/URLRouter.ts @@ -156,6 +156,10 @@ export class URLRouter implements IURLRou return window.location.origin; } + absoluteAppUrl(): string { + return window.location.origin; + } + absoluteUrlForAsset(asset: string): string { return (new URL('/assets/' + asset, window.location.origin)).toString(); } diff --git a/src/matrix/net/OidcApi.ts b/src/matrix/net/OidcApi.ts index 319d122f..57168622 100644 --- a/src/matrix/net/OidcApi.ts +++ b/src/matrix/net/OidcApi.ts @@ -78,6 +78,9 @@ export class OidcApi { return { client_name: "Hydrogen Web", logo_uri: this._urlCreator.absoluteUrlForAsset("icon.png"), + client_uri: this._urlCreator.absoluteAppUrl(), + tos_uri: "https://element.io/terms-of-service", + policy_uri: "https://element.io/privacy", response_types: ["code"], grant_types: ["authorization_code", "refresh_token"], redirect_uris: [this._urlCreator.createOIDCRedirectURL()], From 4b1cc8d645c57a839dec7c7a2250b22cdd109d58 Mon Sep 17 00:00:00 2001 From: Quentin Gliech Date: Mon, 4 Jul 2022 18:44:31 +0200 Subject: [PATCH 209/226] Make hydrogen generate the device scope --- src/domain/login/StartOIDCLoginViewModel.js | 5 +++-- src/matrix/net/OidcApi.ts | 5 +++++ 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/src/domain/login/StartOIDCLoginViewModel.js b/src/domain/login/StartOIDCLoginViewModel.js index e70a7487..d6424f74 100644 --- a/src/domain/login/StartOIDCLoginViewModel.js +++ b/src/domain/login/StartOIDCLoginViewModel.js @@ -42,12 +42,13 @@ export class StartOIDCLoginViewModel extends ViewModel { async discover() { // Ask for the metadata once so it gets discovered and cached await this._api.metadata() - await this._api.ensureRegistered(); + await this._api.registration(); } async startOIDCLogin() { + const deviceScope = this._api.generateDeviceScope(); const p = this._api.generateParams({ - scope: "openid", + scope: `openid ${deviceScope}`, redirectUri: this.urlCreator.createOIDCRedirectURL(), }); const clientId = await this._api.clientId(); diff --git a/src/matrix/net/OidcApi.ts b/src/matrix/net/OidcApi.ts index 57168622..b8d459b3 100644 --- a/src/matrix/net/OidcApi.ts +++ b/src/matrix/net/OidcApi.ts @@ -203,6 +203,11 @@ export class OidcApi { return metadata["registration_endpoint"]; } + generateDeviceScope(): String { + const deviceId = randomString(10); + return `urn:matrix:device:${deviceId}`; + } + generateParams({ scope, redirectUri }: { scope: string, redirectUri: string }): AuthorizationParams { return { scope, From 2e24685a5fb8b68712b76733428d519c767e3abb Mon Sep 17 00:00:00 2001 From: Hugh Nimmo-Smith Date: Fri, 8 Jul 2022 15:35:55 +0100 Subject: [PATCH 210/226] Use unstable prefix for MSC2965 issuer discovery --- src/matrix/well-known.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/matrix/well-known.js b/src/matrix/well-known.js index 6e3bedbf..10e78f2c 100644 --- a/src/matrix/well-known.js +++ b/src/matrix/well-known.js @@ -50,7 +50,7 @@ export async function lookupHomeserver(homeserver, request) { homeserver = normalizeHomeserver(wellKnownHomeserver); } - const wellKnownIssuer = body["m.authentication"]?.["issuer"]; + const wellKnownIssuer = body["org.matrix.msc2965.authentication"]?.["issuer"]; if (typeof wellKnownIssuer === "string") { issuer = wellKnownIssuer; } From 154bae239a5617605b1ec643e24b953843b474a2 Mon Sep 17 00:00:00 2001 From: Hugh Nimmo-Smith Date: Fri, 8 Jul 2022 15:50:49 +0100 Subject: [PATCH 211/226] Rename OIDC login button to Continue --- src/platform/web/ui/login/LoginView.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/platform/web/ui/login/LoginView.js b/src/platform/web/ui/login/LoginView.js index 116c82cd..e1783183 100644 --- a/src/platform/web/ui/login/LoginView.js +++ b/src/platform/web/ui/login/LoginView.js @@ -86,7 +86,7 @@ class StartOIDCLoginView extends TemplateView { type: "button", onClick: () => vm.startOIDCLogin(), disabled: vm => vm.isBusy - }, vm.i18n`Log in via OIDC`) + }, vm.i18n`Continue`) ); } } From 391e696bedd55b28837fd5f3fb3a5d9f0567d8f5 Mon Sep 17 00:00:00 2001 From: Hugh Nimmo-Smith Date: Mon, 25 Jul 2022 08:58:04 +0100 Subject: [PATCH 212/226] Request urn:matrix:api:* scope for OIDC --- src/domain/login/StartOIDCLoginViewModel.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/domain/login/StartOIDCLoginViewModel.js b/src/domain/login/StartOIDCLoginViewModel.js index d6424f74..4189e581 100644 --- a/src/domain/login/StartOIDCLoginViewModel.js +++ b/src/domain/login/StartOIDCLoginViewModel.js @@ -48,7 +48,7 @@ export class StartOIDCLoginViewModel extends ViewModel { async startOIDCLogin() { const deviceScope = this._api.generateDeviceScope(); const p = this._api.generateParams({ - scope: `openid ${deviceScope}`, + scope: `openid urn:matrix:api:* ${deviceScope}`, redirectUri: this.urlCreator.createOIDCRedirectURL(), }); const clientId = await this._api.clientId(); From 19c0a2949baef88c8f35ac00466915157eefb084 Mon Sep 17 00:00:00 2001 From: Hugh Nimmo-Smith Date: Mon, 25 Jul 2022 09:00:16 +0100 Subject: [PATCH 213/226] Try to improve error message on no login method available --- src/domain/login/LoginViewModel.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/domain/login/LoginViewModel.ts b/src/domain/login/LoginViewModel.ts index 2c793550..d7d38676 100644 --- a/src/domain/login/LoginViewModel.ts +++ b/src/domain/login/LoginViewModel.ts @@ -304,8 +304,8 @@ export class LoginViewModel extends ViewModel { if (this._loginOptions.password) { this._showPasswordLogin(); } if (this._loginOptions.oidc) { this._showOIDCLogin(); } if (!this._loginOptions.sso && !this._loginOptions.password && !this._loginOptions.oidc) { - this._showError("This homeserver supports neither SSO nor password based login flows"); - } + this._showError("This homeserver supports neither SSO nor password based login flows or has a usable OIDC Provider"); + } } else { this._showError(`Could not query login methods supported by ${this.homeserver}`); From fa5ef2795a33a7bda3436a8b961478ca67341d5b Mon Sep 17 00:00:00 2001 From: Hugh Nimmo-Smith Date: Mon, 25 Jul 2022 09:00:30 +0100 Subject: [PATCH 214/226] fix: hide OIDC button when not in use --- src/domain/login/LoginViewModel.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/domain/login/LoginViewModel.ts b/src/domain/login/LoginViewModel.ts index d7d38676..8853f25f 100644 --- a/src/domain/login/LoginViewModel.ts +++ b/src/domain/login/LoginViewModel.ts @@ -238,6 +238,7 @@ export class LoginViewModel extends ViewModel { this._startSSOLoginViewModel = this.disposeTracked(this._startSSOLoginViewModel); this._passwordLoginViewModel = this.disposeTracked(this._passwordLoginViewModel); this._completeSSOLoginViewModel = this.disposeTracked(this._completeSSOLoginViewModel); + this._startOIDCLoginViewModel = this.disposeTracked(this._startOIDCLoginViewModel); this.emitChange("disposeViewModels"); } From dd14f13d2c7dcced1accc80bad2b80940389c553 Mon Sep 17 00:00:00 2001 From: Hugh Nimmo-Smith Date: Mon, 25 Jul 2022 09:06:30 +0100 Subject: [PATCH 215/226] Use primary styling for OIDC login button --- src/platform/web/ui/login/LoginView.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/platform/web/ui/login/LoginView.js b/src/platform/web/ui/login/LoginView.js index e1783183..b44de2e4 100644 --- a/src/platform/web/ui/login/LoginView.js +++ b/src/platform/web/ui/login/LoginView.js @@ -82,7 +82,7 @@ class StartOIDCLoginView extends TemplateView { render(t, vm) { return t.div({ className: "StartOIDCLoginView" }, t.a({ - className: "StartOIDCLoginView_button button-action secondary", + className: "StartOIDCLoginView_button button-action primary", type: "button", onClick: () => vm.startOIDCLogin(), disabled: vm => vm.isBusy From da922220e295e7e285f714dc1ecd42d7ee6ad66f Mon Sep 17 00:00:00 2001 From: Hugh Nimmo-Smith Date: Mon, 25 Jul 2022 09:06:49 +0100 Subject: [PATCH 216/226] Handle case of OIDC Provider not returning supported_grant_types --- src/matrix/net/OidcApi.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/matrix/net/OidcApi.ts b/src/matrix/net/OidcApi.ts index b8d459b3..832c94e7 100644 --- a/src/matrix/net/OidcApi.ts +++ b/src/matrix/net/OidcApi.ts @@ -153,7 +153,7 @@ export class OidcApi { assert(typeof m.registration_endpoint === "string", "Has a registration endpoint"); assert(Array.isArray(m.response_types_supported) && m.response_types_supported.includes("code"), "Supports the code response type"); assert(Array.isArray(m.response_modes_supported) && m.response_modes_supported.includes("fragment"), "Supports the fragment response mode"); - assert(Array.isArray(m.grant_types_supported) && m.grant_types_supported.includes("authorization_code"), "Supports the authorization_code grant type"); + assert(typeof m.authorization_endpoint === "string" || (Array.isArray(m.grant_types_supported) && m.grant_types_supported.includes("authorization_code")), "Supports the authorization_code grant type"); assert(Array.isArray(m.code_challenge_methods_supported) && m.code_challenge_methods_supported.includes("S256"), "Supports the authorization_code grant type"); } From 83843f62b1ee6f2f698c3591717f1e7f2c9ff18e Mon Sep 17 00:00:00 2001 From: Hugh Nimmo-Smith Date: Mon, 25 Jul 2022 09:12:48 +0100 Subject: [PATCH 217/226] Handle case of issuer field not ending with / --- src/matrix/net/OidcApi.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/matrix/net/OidcApi.ts b/src/matrix/net/OidcApi.ts index 832c94e7..1d0db462 100644 --- a/src/matrix/net/OidcApi.ts +++ b/src/matrix/net/OidcApi.ts @@ -90,7 +90,7 @@ export class OidcApi { } get metadataUrl() { - return new URL(WELL_KNOWN, this._issuer).toString(); + return new URL(WELL_KNOWN, `${this._issuer}${this._issuer.endsWith('/') ? '' : '/'}`).toString(); } get issuer() { From 87bbba025321a508a0172214a99bad967aa35c68 Mon Sep 17 00:00:00 2001 From: Hugh Nimmo-Smith Date: Mon, 25 Jul 2022 09:22:06 +0100 Subject: [PATCH 218/226] Improve error handling for OIDC discovery and registration --- src/domain/login/LoginViewModel.ts | 7 ++++++- src/domain/login/StartOIDCLoginViewModel.js | 14 ++++++++++++-- 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/src/domain/login/LoginViewModel.ts b/src/domain/login/LoginViewModel.ts index 8853f25f..2b57389a 100644 --- a/src/domain/login/LoginViewModel.ts +++ b/src/domain/login/LoginViewModel.ts @@ -172,7 +172,12 @@ export class LoginViewModel extends ViewModel { new StartOIDCLoginViewModel(this.childOptions({loginOptions: this._loginOptions})) ); this.emitChange("startOIDCLoginViewModel"); - this._startOIDCLoginViewModel.discover(); + try { + await this._startOIDCLoginViewModel.discover(); + } catch (err) { + this._showError(err.message); + this._disposeViewModels(); + } } private _showError(message: string): void { diff --git a/src/domain/login/StartOIDCLoginViewModel.js b/src/domain/login/StartOIDCLoginViewModel.js index 4189e581..70980e32 100644 --- a/src/domain/login/StartOIDCLoginViewModel.js +++ b/src/domain/login/StartOIDCLoginViewModel.js @@ -41,8 +41,18 @@ export class StartOIDCLoginViewModel extends ViewModel { async discover() { // Ask for the metadata once so it gets discovered and cached - await this._api.metadata() - await this._api.registration(); + try { + await this._api.metadata() + } catch (err) { + this.logger.log("Failed to discover OIDC metadata: " + err); + throw new Error("Failed to discover OIDC metadata: " + err.message ); + } + try { + await this._api.registration(); + } catch (err) { + this.logger.log("Failed to register OIDC client: " + err); + throw new Error("Failed to register OIDC client: " + err.message ); + } } async startOIDCLogin() { From 5296ba1d8a2bb18d248eabb4c4d5b7561ed58b62 Mon Sep 17 00:00:00 2001 From: Hugh Nimmo-Smith Date: Mon, 25 Jul 2022 15:34:35 +0100 Subject: [PATCH 219/226] Ask OP to revoke tokens on logout --- src/matrix/Client.js | 11 +++++++++++ src/matrix/net/OidcApi.ts | 36 ++++++++++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+) diff --git a/src/matrix/Client.js b/src/matrix/Client.js index 6ba1a4b1..def473bb 100644 --- a/src/matrix/Client.js +++ b/src/matrix/Client.js @@ -482,6 +482,17 @@ export class Client { request: this._platform.request }); await hsApi.logout({log}).response(); + const oidcApi = new OidcApi({ + issuer: sessionInfo.oidcIssuer, + clientId: sessionInfo.oidcClientId, + request: this._platform.request, + encoding: this._platform.encoding, + crypto: this._platform.crypto, + }); + await oidcApi.revokeToken({ token: sessionInfo.accessToken, type: "access" }); + if (sessionInfo.refreshToken) { + await oidcApi.revokeToken({ token: sessionInfo.refreshToken, type: "refresh" }); + } } catch (err) {} await this.deleteSession(log); }); diff --git a/src/matrix/net/OidcApi.ts b/src/matrix/net/OidcApi.ts index 1d0db462..5a801952 100644 --- a/src/matrix/net/OidcApi.ts +++ b/src/matrix/net/OidcApi.ts @@ -203,6 +203,11 @@ export class OidcApi { return metadata["registration_endpoint"]; } + async revocationEndpoint(): Promise { + const metadata = await this.metadata(); + return metadata["revocation_endpoint"]; + } + generateDeviceScope(): String { const deviceId = randomString(10); return `urn:matrix:device:${deviceId}`; @@ -281,4 +286,35 @@ export class OidcApi { return token; } + + async revokeToken({ + token, + type, + }: { token: string, type: "refresh" | "access" }): Promise { + const revocationEndpoint = await this.revocationEndpoint(); + if (!revocationEndpoint) { + return; + } + + const params = new URLSearchParams(); + params.append("token_type", type); + params.append("token", token); + params.append("client_id", await this.clientId()); + const body = params.toString(); + + const headers = new Map(); + headers.set("Content-Type", "application/x-www-form-urlencoded"); + + const req = this._requestFn(revocationEndpoint, { + method: "POST", + headers, + format: "json", + body, + }); + + const res = await req.response(); + if (res.status >= 400) { + throw new Error("failed to revoke token"); + } + } } From 4fcbd3bf20b4ebad12d28090b8c6e2c8fc4897f0 Mon Sep 17 00:00:00 2001 From: Hugh Nimmo-Smith Date: Fri, 29 Jul 2022 08:59:05 +0100 Subject: [PATCH 220/226] Support statically configured OIDC clients --- src/matrix/net/OidcApi.ts | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/src/matrix/net/OidcApi.ts b/src/matrix/net/OidcApi.ts index 5a801952..bdc9352a 100644 --- a/src/matrix/net/OidcApi.ts +++ b/src/matrix/net/OidcApi.ts @@ -53,6 +53,22 @@ function assert(condition: any, message: string): asserts condition { } }; +type IssuerUri = string; +interface ClientConfig { + client_id: string; + client_secret?: string; +} + +// These are statically configured OIDC client IDs for particular issuers: +const clientIds: Record = { + "https://dev-6525741.okta.com/": { + client_id: "0oa5x44w64wpNsxi45d7", + }, + "https://keycloak-oidc.lab.element.dev/realms/master/": { + client_id: "hydrogen-oidc-playground" + }, +}; + export class OidcApi { _issuer: string; _requestFn: RequestFunction; @@ -104,6 +120,13 @@ export class OidcApi { registration(): Promise { if (!this._registrationPromise) { this._registrationPromise = (async () => { + // use static client if available + const authority = `${this.issuer}${this.issuer.endsWith('/') ? '' : '/'}`; + + if (clientIds[authority]) { + return clientIds[authority]; + } + const headers = new Map(); headers.set("Accept", "application/json"); headers.set("Content-Type", "application/json"); From 1482ba24bf87cbbad8384242fe9944c4e9568d68 Mon Sep 17 00:00:00 2001 From: Hugh Nimmo-Smith Date: Fri, 29 Jul 2022 09:44:24 +0100 Subject: [PATCH 221/226] Use valid length of code_verifier --- src/matrix/net/OidcApi.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/matrix/net/OidcApi.ts b/src/matrix/net/OidcApi.ts index bdc9352a..103aae82 100644 --- a/src/matrix/net/OidcApi.ts +++ b/src/matrix/net/OidcApi.ts @@ -242,7 +242,7 @@ export class OidcApi { redirectUri, state: randomString(8), nonce: randomString(8), - codeVerifier: randomString(32), + codeVerifier: randomString(64), // https://tools.ietf.org/html/rfc7636#section-4.1 length needs to be 43-128 characters }; } From 887c3d4f89fa64766b6e8207412c264e5a41d21e Mon Sep 17 00:00:00 2001 From: Hugh Nimmo-Smith Date: Sun, 31 Jul 2022 10:06:01 +0100 Subject: [PATCH 222/226] Actually make SessionLoadViewModel.logout do something --- src/domain/SessionLoadViewModel.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/domain/SessionLoadViewModel.js b/src/domain/SessionLoadViewModel.js index abc16299..5b2c2a93 100644 --- a/src/domain/SessionLoadViewModel.js +++ b/src/domain/SessionLoadViewModel.js @@ -154,7 +154,8 @@ export class SessionLoadViewModel extends ViewModel { } async logout() { - await this._client.logout(); + const sessionId = this.navigation.path.get("session")?.value; + await this._client.startLogout(sessionId); this.navigation.push("session", true); } From 30d97d6f41f94a5d96f0ec6395a06e7dfd746048 Mon Sep 17 00:00:00 2001 From: Hugh Nimmo-Smith Date: Sun, 31 Jul 2022 17:17:00 +0100 Subject: [PATCH 223/226] Link out to OIDC account management URL if available --- src/domain/login/CompleteOIDCLoginViewModel.js | 5 +++-- src/domain/login/StartOIDCLoginViewModel.js | 2 ++ src/domain/session/settings/SettingsViewModel.js | 8 ++++++++ src/matrix/Client.js | 5 +++-- src/matrix/login/OIDCLoginMethod.ts | 6 +++++- src/matrix/sessioninfo/localstorage/SessionInfoStorage.ts | 1 + src/matrix/well-known.js | 8 +++++++- src/platform/web/ui/session/settings/SettingsView.js | 7 +++++++ 8 files changed, 36 insertions(+), 6 deletions(-) diff --git a/src/domain/login/CompleteOIDCLoginViewModel.js b/src/domain/login/CompleteOIDCLoginViewModel.js index 5d0da980..a544939a 100644 --- a/src/domain/login/CompleteOIDCLoginViewModel.js +++ b/src/domain/login/CompleteOIDCLoginViewModel.js @@ -50,7 +50,7 @@ export class CompleteOIDCLoginViewModel extends ViewModel { } const code = this._code; // TODO: cleanup settings storage - const [startedAt, nonce, codeVerifier, redirectUri, homeserver, issuer, clientId] = await Promise.all([ + const [startedAt, nonce, codeVerifier, redirectUri, homeserver, issuer, clientId, accountManagementUrl] = await Promise.all([ this.platform.settingsStorage.getInt(`oidc_${this._state}_started_at`), this.platform.settingsStorage.getString(`oidc_${this._state}_nonce`), this.platform.settingsStorage.getString(`oidc_${this._state}_code_verifier`), @@ -58,6 +58,7 @@ export class CompleteOIDCLoginViewModel extends ViewModel { this.platform.settingsStorage.getString(`oidc_${this._state}_homeserver`), this.platform.settingsStorage.getString(`oidc_${this._state}_issuer`), this.platform.settingsStorage.getString(`oidc_${this._state}_client_id`), + this.platform.settingsStorage.getString(`oidc_${this._state}_account_management_url`), ]); const oidcApi = new OidcApi({ @@ -67,7 +68,7 @@ export class CompleteOIDCLoginViewModel extends ViewModel { encoding: this._encoding, crypto: this._crypto, }); - const method = new OIDCLoginMethod({oidcApi, nonce, codeVerifier, code, homeserver, startedAt, redirectUri}); + const method = new OIDCLoginMethod({oidcApi, nonce, codeVerifier, code, homeserver, startedAt, redirectUri, accountManagementUrl}); const status = await this._attemptLogin(method); let error = ""; switch (status) { diff --git a/src/domain/login/StartOIDCLoginViewModel.js b/src/domain/login/StartOIDCLoginViewModel.js index 70980e32..07cae075 100644 --- a/src/domain/login/StartOIDCLoginViewModel.js +++ b/src/domain/login/StartOIDCLoginViewModel.js @@ -22,6 +22,7 @@ export class StartOIDCLoginViewModel extends ViewModel { super(options); this._isBusy = true; this._issuer = options.loginOptions.oidc.issuer; + this._accountManagementUrl = options.loginOptions.oidc.account; this._homeserver = options.loginOptions.homeserver; this._api = new OidcApi({ issuer: this._issuer, @@ -70,6 +71,7 @@ export class StartOIDCLoginViewModel extends ViewModel { this.platform.settingsStorage.setString(`oidc_${p.state}_homeserver`, this._homeserver), this.platform.settingsStorage.setString(`oidc_${p.state}_issuer`, this._issuer), this.platform.settingsStorage.setString(`oidc_${p.state}_client_id`, clientId), + this.platform.settingsStorage.setString(`oidc_${p.state}_account_management_url`, this._accountManagementUrl), ]); const link = await this._api.authorizationEndpoint(p); diff --git a/src/domain/session/settings/SettingsViewModel.js b/src/domain/session/settings/SettingsViewModel.js index 4dcdb111..147d7402 100644 --- a/src/domain/session/settings/SettingsViewModel.js +++ b/src/domain/session/settings/SettingsViewModel.js @@ -53,6 +53,7 @@ export class SettingsViewModel extends ViewModel { this.pushNotifications = new PushNotificationStatus(); this._activeTheme = undefined; this._logsFeedbackMessage = undefined; + this._accountManagementUrl = null; } get _session() { @@ -82,9 +83,16 @@ export class SettingsViewModel extends ViewModel { if (!import.meta.env.DEV) { this._activeTheme = await this.platform.themeLoader.getActiveTheme(); } + const {accountManagementUrl} = await this.platform.sessionInfoStorage.get(this._client._sessionId); + this._accountManagementUrl = accountManagementUrl; this.emitChange(""); } + + get accountManagementUrl() { + return this._accountManagementUrl; + } + get closeUrl() { return this._closeUrl; } diff --git a/src/matrix/Client.js b/src/matrix/Client.js index def473bb..0cf4f5aa 100644 --- a/src/matrix/Client.js +++ b/src/matrix/Client.js @@ -127,7 +127,7 @@ export class Client { queryLogin(initialHomeserver) { return new AbortableOperation(async setAbortable => { - const { homeserver, issuer } = await lookupHomeserver(initialHomeserver, (url, options) => { + const { homeserver, issuer, account } = await lookupHomeserver(initialHomeserver, (url, options) => { return setAbortable(this._platform.request(url, options)); }); if (issuer) { @@ -142,7 +142,7 @@ export class Client { return { homeserver, - oidc: { issuer }, + oidc: { issuer, account }, }; } catch (e) { console.log(e); @@ -204,6 +204,7 @@ export class Client { if (loginData.oidc_issuer) { sessionInfo.oidcIssuer = loginData.oidc_issuer; sessionInfo.oidcClientId = loginData.oidc_client_id; + sessionInfo.accountManagementUrl = loginData.oidc_account_management_url; } log.set("id", sessionId); diff --git a/src/matrix/login/OIDCLoginMethod.ts b/src/matrix/login/OIDCLoginMethod.ts index b25689aa..e0e3f58f 100644 --- a/src/matrix/login/OIDCLoginMethod.ts +++ b/src/matrix/login/OIDCLoginMethod.ts @@ -25,6 +25,7 @@ export class OIDCLoginMethod implements ILoginMethod { private readonly _nonce: string; private readonly _redirectUri: string; private readonly _oidcApi: OidcApi; + private readonly _accountManagementUrl?: string; public readonly homeserver: string; constructor({ @@ -34,6 +35,7 @@ export class OIDCLoginMethod implements ILoginMethod { homeserver, redirectUri, oidcApi, + accountManagementUrl, }: { nonce: string, code: string, @@ -41,6 +43,7 @@ export class OIDCLoginMethod implements ILoginMethod { homeserver: string, redirectUri: string, oidcApi: OidcApi, + accountManagementUrl?: string, }) { this._oidcApi = oidcApi; this._code = code; @@ -48,6 +51,7 @@ export class OIDCLoginMethod implements ILoginMethod { this._nonce = nonce; this._redirectUri = redirectUri; this.homeserver = homeserver; + this._accountManagementUrl = accountManagementUrl; } async login(hsApi: HomeServerApi, _deviceName: string, log: ILogItem): Promise> { @@ -68,6 +72,6 @@ export class OIDCLoginMethod implements ILoginMethod { const oidc_issuer = this._oidcApi.issuer; const oidc_client_id = await this._oidcApi.clientId(); - return { oidc_issuer, oidc_client_id, access_token, refresh_token, expires_in, user_id, device_id }; + return { oidc_issuer, oidc_client_id, access_token, refresh_token, expires_in, user_id, device_id, oidc_account_management_url: this._accountManagementUrl }; } } diff --git a/src/matrix/sessioninfo/localstorage/SessionInfoStorage.ts b/src/matrix/sessioninfo/localstorage/SessionInfoStorage.ts index 80443e83..000879e8 100644 --- a/src/matrix/sessioninfo/localstorage/SessionInfoStorage.ts +++ b/src/matrix/sessioninfo/localstorage/SessionInfoStorage.ts @@ -24,6 +24,7 @@ interface ISessionInfo { accessTokenExpiresAt?: number; refreshToken?: string; oidcIssuer?: string; + accountManagementUrl?: string; lastUsed: number; } diff --git a/src/matrix/well-known.js b/src/matrix/well-known.js index 10e78f2c..9a858f2b 100644 --- a/src/matrix/well-known.js +++ b/src/matrix/well-known.js @@ -42,6 +42,7 @@ async function getWellKnownResponse(homeserver, request) { export async function lookupHomeserver(homeserver, request) { homeserver = normalizeHomeserver(homeserver); let issuer = null; + let account = null; const wellKnownResponse = await getWellKnownResponse(homeserver, request); if (wellKnownResponse && wellKnownResponse.status === 200) { const {body} = wellKnownResponse; @@ -54,6 +55,11 @@ export async function lookupHomeserver(homeserver, request) { if (typeof wellKnownIssuer === "string") { issuer = wellKnownIssuer; } + + const wellKnownAccount = body["org.matrix.msc2965.authentication"]?.["account"]; + if (typeof wellKnownAccount === "string") { + account = wellKnownAccount; + } } - return {homeserver, issuer}; + return {homeserver, issuer, account}; } diff --git a/src/platform/web/ui/session/settings/SettingsView.js b/src/platform/web/ui/session/settings/SettingsView.js index c4405e82..66290357 100644 --- a/src/platform/web/ui/session/settings/SettingsView.js +++ b/src/platform/web/ui/session/settings/SettingsView.js @@ -47,6 +47,13 @@ export class SettingsView extends TemplateView { disabled: vm => vm.isLoggingOut }, vm.i18n`Log out`)), ); + + settingNodes.push( + t.if(vm => vm.accountManagementUrl, t => { + return t.p([vm.i18n`You can manage your account `, t.a({href: vm.accountManagementUrl, target: "_blank"}, vm.i18n`here`), "."]); + }), + ); + settingNodes.push( t.h3("Key backup"), t.view(new KeyBackupSettingsView(vm.keyBackupViewModel)) From f7ffae4576df02ca3d2c4293b547015411f343cc Mon Sep 17 00:00:00 2001 From: Quentin Gliech Date: Mon, 1 Aug 2022 16:23:20 +0200 Subject: [PATCH 224/226] Fix typing and tests --- src/domain/navigation/URLRouter.ts | 3 +++ src/domain/navigation/index.ts | 38 ++++++++++++++++++++---------- src/matrix/net/OidcApi.ts | 7 +++--- 3 files changed, 33 insertions(+), 15 deletions(-) diff --git a/src/domain/navigation/URLRouter.ts b/src/domain/navigation/URLRouter.ts index dbd1ceee..a52f71a0 100644 --- a/src/domain/navigation/URLRouter.ts +++ b/src/domain/navigation/URLRouter.ts @@ -32,6 +32,9 @@ export interface IURLRouter { urlForPath(path: Path): string; openRoomActionUrl(roomId: string): string; createSSOCallbackURL(): string; + createOIDCRedirectURL(): string; + absoluteAppUrl(): string; + absoluteUrlForAsset(asset: string): string; normalizeUrl(): void; } diff --git a/src/domain/navigation/index.ts b/src/domain/navigation/index.ts index 4801d9fa..8ff0aa0b 100644 --- a/src/domain/navigation/index.ts +++ b/src/domain/navigation/index.ts @@ -33,8 +33,16 @@ export type SegmentType = { "details": true; "members": true; "member": string; - "oidc-callback": (string | null)[]; - "oidc-error": (string | null)[]; + "oidc": { + state: string, + } & + ({ + code: string, + } | { + error: string, + errorDescription: string | null, + errorUri: string | null , + }); }; export function createNavigation(): Navigation { @@ -131,18 +139,21 @@ export function parseUrlPath(urlPath: string, currentNavPath: Path, // Special case for OIDC callback if (urlPath.includes("state")) { const params = new URLSearchParams(urlPath); - if (params.has("state")) { + const state = params.get("state"); + const code = params.get("code"); + const error = params.get("error"); + if (state) { // This is a proper OIDC callback - if (params.has("code")) { + if (code) { segments.push(new Segment("oidc", { - state: params.get("state"), - code: params.get("code"), + state, + code, })); return segments; - } else if (params.has("error")) { + } else if (error) { segments.push(new Segment("oidc", { - state: params.get("state"), - error: params.get("error"), + state, + error, errorDescription: params.get("error_description"), errorUri: params.get("error_uri"), })); @@ -514,19 +525,22 @@ export function tests() { assert.equal(newPath?.segments[1].value, "b"); }, "Parse OIDC callback": assert => { - const segments = parseUrlPath("state=tc9CnLU7&code=cnmUnwIYtY7V8RrWUyhJa4yvX72jJ5Yx"); + const path = createEmptyPath(); + const segments = parseUrlPath("state=tc9CnLU7&code=cnmUnwIYtY7V8RrWUyhJa4yvX72jJ5Yx", path); assert.equal(segments.length, 1); assert.equal(segments[0].type, "oidc"); assert.deepEqual(segments[0].value, {state: "tc9CnLU7", code: "cnmUnwIYtY7V8RrWUyhJa4yvX72jJ5Yx"}); }, "Parse OIDC error": assert => { - const segments = parseUrlPath("state=tc9CnLU7&error=invalid_request"); + const path = createEmptyPath(); + const segments = parseUrlPath("state=tc9CnLU7&error=invalid_request", path); assert.equal(segments.length, 1); assert.equal(segments[0].type, "oidc"); assert.deepEqual(segments[0].value, {state: "tc9CnLU7", error: "invalid_request", errorUri: null, errorDescription: null}); }, "Parse OIDC error with description": assert => { - const segments = parseUrlPath("state=tc9CnLU7&error=invalid_request&error_description=Unsupported%20response_type%20value"); + const path = createEmptyPath(); + const segments = parseUrlPath("state=tc9CnLU7&error=invalid_request&error_description=Unsupported%20response_type%20value", path); assert.equal(segments.length, 1); assert.equal(segments[0].type, "oidc"); assert.deepEqual(segments[0].value, {state: "tc9CnLU7", error: "invalid_request", errorDescription: "Unsupported response_type value", errorUri: null}); diff --git a/src/matrix/net/OidcApi.ts b/src/matrix/net/OidcApi.ts index 103aae82..b3bfd817 100644 --- a/src/matrix/net/OidcApi.ts +++ b/src/matrix/net/OidcApi.ts @@ -15,7 +15,8 @@ limitations under the License. */ import type {RequestFunction} from "../../platform/types/types"; -import type {URLRouter} from "../../domain/navigation/URLRouter.js"; +import type {IURLRouter} from "../../domain/navigation/URLRouter.js"; +import type {SegmentType} from "../../domain/navigation"; const WELL_KNOWN = ".well-known/openid-configuration"; @@ -69,12 +70,12 @@ const clientIds: Record = { }, }; -export class OidcApi { +export class OidcApi { _issuer: string; _requestFn: RequestFunction; _encoding: any; _crypto: any; - _urlCreator: URLRouter; + _urlCreator: IURLRouter; _metadataPromise: Promise; _registrationPromise: Promise; From 30bb3fa59b3a735a08690100e44bac9821cccfd6 Mon Sep 17 00:00:00 2001 From: Quentin Gliech Date: Mon, 1 Aug 2022 17:01:25 +0200 Subject: [PATCH 225/226] Fix the runtime config template to include the default theme --- docker/config.json.tmpl | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/docker/config.json.tmpl b/docker/config.json.tmpl index 94295c43..48ecef01 100644 --- a/docker/config.json.tmpl +++ b/docker/config.json.tmpl @@ -4,5 +4,13 @@ "gatewayUrl": "$PUSH_GATEWAY_URL", "applicationServerKey": "$PUSH_APPLICATION_SERVER_KEY" }, - "defaultHomeServer": "$DEFAULT_HOMESERVER" + "defaultHomeServer": "$DEFAULT_HOMESERVER", + "bugReportEndpointUrl": "https://element.io/bugreports/submit", + "themeManifests": [ + "assets/theme-element.json" + ], + "defaultTheme": { + "light": "element-light", + "dark": "element-dark" + } } From 34627f498f32e2e187f260f8ac7a61a94053490d Mon Sep 17 00:00:00 2001 From: Hugh Nimmo-Smith Date: Wed, 3 Aug 2022 17:18:30 +0100 Subject: [PATCH 226/226] Use unstable OIDC scope names --- src/domain/login/StartOIDCLoginViewModel.js | 2 +- src/matrix/net/OidcApi.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/domain/login/StartOIDCLoginViewModel.js b/src/domain/login/StartOIDCLoginViewModel.js index 07cae075..b6a171fa 100644 --- a/src/domain/login/StartOIDCLoginViewModel.js +++ b/src/domain/login/StartOIDCLoginViewModel.js @@ -59,7 +59,7 @@ export class StartOIDCLoginViewModel extends ViewModel { async startOIDCLogin() { const deviceScope = this._api.generateDeviceScope(); const p = this._api.generateParams({ - scope: `openid urn:matrix:api:* ${deviceScope}`, + scope: `openid urn:matrix:org.matrix.msc2967.client:api:* ${deviceScope}`, redirectUri: this.urlCreator.createOIDCRedirectURL(), }); const clientId = await this._api.clientId(); diff --git a/src/matrix/net/OidcApi.ts b/src/matrix/net/OidcApi.ts index 103aae82..cb57c4ab 100644 --- a/src/matrix/net/OidcApi.ts +++ b/src/matrix/net/OidcApi.ts @@ -233,7 +233,7 @@ export class OidcApi { generateDeviceScope(): String { const deviceId = randomString(10); - return `urn:matrix:device:${deviceId}`; + return `urn:matrix:org.matrix.msc2967.client:device:${deviceId}`; } generateParams({ scope, redirectUri }: { scope: string, redirectUri: string }): AuthorizationParams {