From 059b180ba720d2c8b3136fb1d6cd0c6bb4c0bfe3 Mon Sep 17 00:00:00 2001 From: Midhun Suresh <midhunr@element.io> Date: Tue, 4 Jan 2022 22:56:32 +0530 Subject: [PATCH] UI WIP --- src/Chatterbox.ts | 48 ----------- src/Hydrogen.ts | 104 ------------------------ src/main.ts | 15 +++- src/random.ts | 3 + src/types/IMatrixClient.ts | 21 ----- src/ui/views/AccountSetupView.ts | 25 ++++++ src/ui/views/ChatterboxView.ts | 13 +++ src/ui/views/RootView.ts | 28 +++++++ src/viewmodels/AccountSetupViewModel.ts | 67 +++++++++++++++ src/viewmodels/ChatterboxViewModel.ts | 58 +++++++++++++ src/viewmodels/RootViewModel.ts | 86 ++++++++++++++++++++ tsconfig.json | 2 +- 12 files changed, 293 insertions(+), 177 deletions(-) delete mode 100644 src/Chatterbox.ts delete mode 100644 src/Hydrogen.ts delete mode 100644 src/types/IMatrixClient.ts create mode 100644 src/ui/views/AccountSetupView.ts create mode 100644 src/ui/views/ChatterboxView.ts create mode 100644 src/ui/views/RootView.ts create mode 100644 src/viewmodels/AccountSetupViewModel.ts create mode 100644 src/viewmodels/ChatterboxViewModel.ts create mode 100644 src/viewmodels/RootViewModel.ts diff --git a/src/Chatterbox.ts b/src/Chatterbox.ts deleted file mode 100644 index 1e8c687..0000000 --- a/src/Chatterbox.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { IChatterboxConfig } from "./types/IChatterboxConfig"; -import { Hydrogen } from "./Hydrogen"; -import { generateRandomString } from "./random"; - - -export class Chatterbox { - private _config: IChatterboxConfig; - private _hydrogen: Hydrogen; - - constructor(config: IChatterboxConfig, root: HTMLDivElement) { - this._config = config; - this._hydrogen = new Hydrogen(this._homeserver, root); - } - - async start(): Promise<void> { - console.log("Checking if session already exists"); - const sessionAlreadyExists = await this._hydrogen.attemptStartWithExistingSession(); - if (sessionAlreadyExists) { - console.log("Starting hydrogen with existing session"); - } else { - console.log("Session does not exist!"); - await this._registerAndLogin(); - } - - console.log("Attempting to mount Timeline"); - await this._hydrogen.mountTimeline(this._roomToJoin); - console.log("Mounted Timeline"); - } - - private async _registerAndLogin(): Promise<void> { - const username = generateRandomString(7); - const password = generateRandomString(10); - console.log( `Attempting to register with username = ${username} and password = ${password}`); - await this._hydrogen.register( username, password, "Chatterbox"); - console.log("Registration done"); - console.log("Attempting to login with same credentials"); - await this._hydrogen.login(username, password); - console.log("Login successful"); - } - - private get _homeserver(): string { - return this._config.homeserver; - } - - private get _roomToJoin(): string { - return this._config.auto_join_room; - } -} diff --git a/src/Hydrogen.ts b/src/Hydrogen.ts deleted file mode 100644 index 0486491..0000000 --- a/src/Hydrogen.ts +++ /dev/null @@ -1,104 +0,0 @@ -import { Platform, Client, LoadStatus, createNavigation, createRouter, RoomViewModel, TimelineView, ComposerViewModel, MessageComposer } from "hydrogen-view-sdk"; -import assetPaths from "hydrogen-view-sdk/paths/vite"; -import "hydrogen-view-sdk/style.css"; -import { IMatrixClient } from "./types/IMatrixClient"; - -export class Hydrogen implements IMatrixClient { - private readonly _homeserver: string; - private _platform: Platform; - private _client: Client; - private _urlRouter: ReturnType<createRouter>; - private _navigation: ReturnType<createNavigation>; - private _container: HTMLDivElement; - - constructor(homeserver: string, container: HTMLDivElement) { - this._homeserver = homeserver; - this._container = container; - this._platform = new Platform(container, assetPaths, {}, { development: import.meta.env.DEV }); - this._navigation = createNavigation(); - this._platform.setNavigation(this._navigation); - this._urlRouter = createRouter({ navigation: this._navigation, history: this._platform.history }); - this._urlRouter.attach(); - this._client = new Client(this._platform); - } - - async register(username: string, password: string, initialDeviceDisplayName: string): Promise<void> { - let stage = await this._client.startRegistration(this._homeserver, username, password, initialDeviceDisplayName); - while (stage !== true) { - stage = await stage.complete(); - } - } - - async login(username: string, password: string): Promise<void> { - const loginOptions = await this._client.queryLogin(this._homeserver).result; - this._client.startWithLogin(loginOptions.password(username, password)); - - await this._client.loadStatus.waitFor((status: string) => { - return status === LoadStatus.Ready || - status === LoadStatus.Error || - status === LoadStatus.LoginFailed; - }).promise; - - if (this._client.loginFailure) { - throw new Error("login failed: " + this._client.loginFailure); - } else if (this._client.loadError) { - throw new Error("load failed: " + this._client.loadError.message); - } - } - - async mountTimeline(roomId: string): Promise<void> { - const room = this._session.rooms.get(roomId) ?? await this._joinRoom(roomId); - const roomVm = new RoomViewModel({ - room, - ownUserId: this._session.userId, - platform: this._platform, - urlCreator: this._urlRouter, - navigation: this._navigation, - }); - await roomVm.load(); - const roomView = new TimelineView(roomVm.timelineViewModel); - this._container.appendChild(roomView.mount()); - const composerVm = new ComposerViewModel(roomVm); - const composerView = new MessageComposer(composerVm); - this._container.appendChild(composerView.mount()); - } - - /** - * Try to start Hydrogen based on an existing hydrogen session. - * If multiple sessions exist, this method chooses the most recent one. - */ - async attemptStartWithExistingSession(): Promise<boolean> { - const sessionIds = await this._platform.sessionInfoStorage.getAll(); - const { id } = sessionIds.pop(); - if (id) { - await this._client.startWithExistingSession(id); - return true; - } - return false; - } - - private async _joinRoom(roomId: string): Promise<any> { - await this._session.joinRoom(roomId); - // even though we've joined the room, we need to wait till the next sync to get the room - await this._waitForRoomFromSync(roomId); - return this._session.rooms.get(roomId); - - } - - private _waitForRoomFromSync(roomId: string): Promise<void> { - let resolve: () => void; - const promise: Promise<void> = new Promise(r => { resolve = r; }) - const subscription = { - onAdd: (_: string, value: {id: string}) => { - if (value.id === roomId) { - this._session.rooms.unsubscribe(subscription); - resolve(); - } - }, - }; - this._session.rooms.subscribe(subscription); - return promise; - } - - private get _session() { return this._client.session; } -} diff --git a/src/main.ts b/src/main.ts index f697989..a34e6b8 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,5 +1,8 @@ -import { Chatterbox } from "./Chatterbox"; import type { IChatterboxConfig } from "./types/IChatterboxConfig"; +import { Platform, createNavigation, createRouter } from "hydrogen-view-sdk"; +import { RootViewModel } from "./viewmodels/RootViewModel"; +import { RootView } from "./ui/views/RootView"; +import assetPaths from "hydrogen-view-sdk/paths/vite"; const rootDivId = "#chatterbox"; @@ -18,8 +21,14 @@ async function main() { throw new Error("No element with id as 'chatterbox' found!"); } const config = await fetchConfig(root); - const chatterbox = new Chatterbox(config, root); - chatterbox.start(); + const platform = new Platform(root, assetPaths, {}, { development: import.meta.env.DEV }); + const navigation = createNavigation(); + platform.setNavigation(navigation); + const urlRouter = createRouter({ navigation, history: platform.history }); + urlRouter.attach(); + const rootViewModel = new RootViewModel(config, {platform, navigation, urlCreator: urlRouter}); + const rootView = new RootView(rootViewModel); + root.appendChild(rootView.mount()); } main(); diff --git a/src/random.ts b/src/random.ts index 0854c1d..b3f2412 100644 --- a/src/random.ts +++ b/src/random.ts @@ -1,4 +1,7 @@ // todo: do we need something better than this? +// todo: usernames can't start with _ +// todo: lookup grammar for mxids + export function generateRandomString(length: number): string { let result = ""; const characters = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"; diff --git a/src/types/IMatrixClient.ts b/src/types/IMatrixClient.ts deleted file mode 100644 index a1d6df1..0000000 --- a/src/types/IMatrixClient.ts +++ /dev/null @@ -1,21 +0,0 @@ -export interface IMatrixClient { - /** - * Register an account with the given credentials; this method must complete all the stages with no further user interaction. - */ - register(username: string, password: string, initialDeviceDisplayName: string): Promise<void>; - - login(username: string, password: string): Promise<void>; - - /** - * Try to start the client with a previous login - * @returns true if successful, false otherwise - */ - attemptStartWithExistingSession(): Promise<boolean>; - - /** - * Renders a timeline and message composer for the given room. - * @remarks This method should join the room if needed. - * @param roomId internal room-id, not alias - */ - mountTimeline(roomId: string): Promise<void>; -} diff --git a/src/ui/views/AccountSetupView.ts b/src/ui/views/AccountSetupView.ts new file mode 100644 index 0000000..7ee7414 --- /dev/null +++ b/src/ui/views/AccountSetupView.ts @@ -0,0 +1,25 @@ +import { TemplateView } from "hydrogen-view-sdk"; +import { Builder } from "hydrogen-view-sdk/types/platform/web/ui/general/TemplateView"; +import { AccountSetupViewModel } from "../../viewmodels/AccountSetupViewModel"; + +export class AccountSetupView extends TemplateView<AccountSetupViewModel> { + render(t: Builder<AccountSetupViewModel>, vm: AccountSetupViewModel) { + return t.div(t.mapView(vm => vm.privacyPolicyLink, link => link ? new PolicyAgreementView(vm) : null)); + } +} + +class PolicyAgreementView extends TemplateView<AccountSetupViewModel> { + render(t: Builder<AccountSetupViewModel>, vm: AccountSetupViewModel) { + return t.div({ className: "PolicyAgreementView" }, [ + t.div([ + "By continuing you agree to the terms and conditions laid out by the following documents:", + t.a({href: vm.privacyPolicyLink}, "Privacy Policy") + ]), + t.div([ + t.input({ type: "checkbox", name: "agree" }), + t.label({for: "agree"}, "I agree") + ]), + t.button({onClick: () => vm.completeRegistration()}, "Next") + ]); + } +} diff --git a/src/ui/views/ChatterboxView.ts b/src/ui/views/ChatterboxView.ts new file mode 100644 index 0000000..c8be32f --- /dev/null +++ b/src/ui/views/ChatterboxView.ts @@ -0,0 +1,13 @@ +import { TemplateView, TimelineView } from "hydrogen-view-sdk"; +import { Builder } from "hydrogen-view-sdk/types/platform/web/ui/general/TemplateView"; +import { MessageComposer } from "hydrogen-view-sdk"; +import { ChatterboxViewModel } from "../../viewmodels/ChatterboxViewModel"; + +export class ChatterboxView extends TemplateView<ChatterboxViewModel> { + render(t: Builder<ChatterboxViewModel>) { + return t.div([ + t.mapView(vm => vm.timelineViewModel, vm => vm ? new TimelineView(vm) : null), + t.mapView(vm => vm.messageComposerViewModel, vm => vm ? new MessageComposer(vm) : null) + ]); + } +} diff --git a/src/ui/views/RootView.ts b/src/ui/views/RootView.ts new file mode 100644 index 0000000..36aaeb9 --- /dev/null +++ b/src/ui/views/RootView.ts @@ -0,0 +1,28 @@ +import { TemplateView } from "hydrogen-view-sdk"; +import { Builder } from "hydrogen-view-sdk/types/platform/web/ui/general/TemplateView"; +import { RootViewModel } from "../../viewmodels/RootViewModel"; +import { AccountSetupView } from "./AccountSetupView"; +import { ChatterboxView } from "./ChatterboxView"; + +export class RootView extends TemplateView<RootViewModel> { + render(t: Builder<RootViewModel>, vm: RootViewModel) { + return t.mapView(vm => vm.activeSection, section => { + switch(section) { + case "start": + return new StartView(vm); + case "account-setup": + return new AccountSetupView(vm.accountSetupViewModel); + case "timeline": + return new ChatterboxView(vm.chatterboxViewModel); + } + return null; + }) + } +} + + +class StartView extends TemplateView<RootViewModel> { + render(t: Builder<RootViewModel>, vm: RootViewModel) { + return t.button({ className: "StartChat", onClick: () => vm.start() }, "Start Chat"); + } +} diff --git a/src/viewmodels/AccountSetupViewModel.ts b/src/viewmodels/AccountSetupViewModel.ts new file mode 100644 index 0000000..91c0c7b --- /dev/null +++ b/src/viewmodels/AccountSetupViewModel.ts @@ -0,0 +1,67 @@ +import { ViewModel, Client, ObservableValue, LoadStatus } from "hydrogen-view-sdk"; +import { IChatterboxConfig } from "../types/IChatterboxConfig"; +import { generateRandomString } from "../random"; +import "hydrogen-view-sdk/style.css"; + + +export class AccountSetupViewModel extends ViewModel { + private _config: IChatterboxConfig; + private _client: Client; + private _state: ObservableValue<string>; + private _termsStage?: any; + private _username: string; + private _password: string; + + constructor(options) { + super(options); + this._client = options.client; + this._config = options.config; + this._state = options.state; + this._startRegistration(); + } + + private async _startRegistration(): Promise<void> { + this._username = generateRandomString(7); + this._password = generateRandomString(10); + let stage = await this._client.startRegistration(this._homeserver, this._username, this._password, "Chatterbox"); + if (stage.type === "m.login.terms") { + this._termsStage = stage; + this.emitChange("termsStage"); + } + } + + async completeRegistration() { + let stage = this._termsStage; + while (stage !== true) { + stage = await stage.complete(); + } + await this.login(this._username, this._password); + } + + async login(username: string, password: string): Promise<void> { + const loginOptions = await this._client.queryLogin(this._homeserver).result; + this._client.startWithLogin(loginOptions.password(username, password)); + + await this._client.loadStatus.waitFor((status: string) => { + return status === LoadStatus.Ready || + status === LoadStatus.Error || + status === LoadStatus.LoginFailed; + }).promise; + + if (this._client.loginFailure) { + throw new Error("login failed: " + this._client.loginFailure); + } else if (this._client.loadError) { + throw new Error("load failed: " + this._client.loadError.message); + } + + this._state.set("timeline"); + } + + private get _homeserver(): string { + return this._config.homeserver; + } + + get privacyPolicyLink() { + return this._termsStage?.privacyPolicy.en?.url; + } +} diff --git a/src/viewmodels/ChatterboxViewModel.ts b/src/viewmodels/ChatterboxViewModel.ts new file mode 100644 index 0000000..c62f6fe --- /dev/null +++ b/src/viewmodels/ChatterboxViewModel.ts @@ -0,0 +1,58 @@ +import { RoomViewModel, ViewModel, TimelineViewModel, ComposerViewModel} from "hydrogen-view-sdk"; + +export class ChatterboxViewModel extends ViewModel { + private readonly _session: any; + private _timelineViewModel?: TimelineViewModel; + private _messageComposerViewModel?: ComposerViewModel; + + constructor(options) { + super(options); + this._session = options.session; + } + + async loadRoom() { + const roomId = this._options.config["auto_join_room"]; + const room = this._session.rooms.get(roomId) ?? await this._joinRoom(roomId); + const roomVm = new RoomViewModel({ + room, + ownUserId: this._session.userId, + platform: this.platform, + urlCreator: this.urlCreator, + navigation: this.navigation, + }); + await roomVm.load(); + this._timelineViewModel = roomVm.timelineViewModel; + this._messageComposerViewModel = new ComposerViewModel(roomVm); + } + + private async _joinRoom(roomId: string): Promise<any> { + await this._session.joinRoom(roomId); + // even though we've joined the room, we need to wait till the next sync to get the room + await this._waitForRoomFromSync(roomId); + return this._session.rooms.get(roomId); + + } + + private _waitForRoomFromSync(roomId: string): Promise<void> { + let resolve: () => void; + const promise: Promise<void> = new Promise(r => { resolve = r; }) + const subscription = { + onAdd: (_: string, value: {id: string}) => { + if (value.id === roomId) { + this._session.rooms.unsubscribe(subscription); + resolve(); + } + }, + }; + this._session.rooms.subscribe(subscription); + return promise; + } + + get timelineViewModel() { + return this._timelineViewModel; + } + + get messageComposerViewModel() { + return this._messageComposerViewModel; + } +} diff --git a/src/viewmodels/RootViewModel.ts b/src/viewmodels/RootViewModel.ts new file mode 100644 index 0000000..ef4e184 --- /dev/null +++ b/src/viewmodels/RootViewModel.ts @@ -0,0 +1,86 @@ +import { ViewModel, Client, createNavigation, createRouter, Platform, ObservableValue } from "hydrogen-view-sdk"; +import { IChatterboxConfig } from "../types/IChatterboxConfig"; +import { ChatterboxViewModel } from "./ChatterboxViewModel"; +import "hydrogen-view-sdk/style.css"; +import { AccountSetupViewModel } from "./AccountSetupViewModel"; + +type Options = { platform: Platform, urlCreator: ReturnType<createRouter>, navigation: ReturnType<createNavigation> }; + +export class RootViewModel extends ViewModel { + private _config: IChatterboxConfig; + private _client: Client; + private _chatterBoxViewModel?: ChatterboxViewModel; + private _accountSetupViewModel?: AccountSetupViewModel; + private _state: ObservableValue<string> = new ObservableValue(""); + private _activeSection: string = "start"; + + constructor(config: IChatterboxConfig, options: Options) { + super(options); + this._config = config; + this._client = new Client(this.platform); + } + + async start() { + this._state.subscribe(stage => this._applyNavigation(stage)); + const sessionAlreadyExists = await this.attemptStartWithExistingSession(); + if (sessionAlreadyExists) { + this._showTimeline(); + return; + } + this._showAccountSetup(); + } + + private _applyNavigation(stage: string) { + switch (stage) { + case "timeline": + this._showTimeline(); + break; + } + } + + private async _showTimeline() { + this._activeSection = "timeline"; + this._chatterBoxViewModel = new ChatterboxViewModel(this.childOptions({ session: this._client.session, config: this._config, state: this._state })); + await this._chatterBoxViewModel.loadRoom(); + this.emitChange("activeSection"); + } + + private _showAccountSetup() { + this._activeSection = "account-setup"; + this._accountSetupViewModel = new AccountSetupViewModel( + this.childOptions({ + client: this._client, + config: this._config, + state: this._state, + }) + ); + this.emitChange("activeSection"); + } + + /** + * Try to start Hydrogen based on an existing hydrogen session. + * If multiple sessions exist, this method chooses the most recent one. + */ + async attemptStartWithExistingSession(): Promise<boolean> { + const sessionIds = await this.platform.sessionInfoStorage.getAll(); + const session = sessionIds.pop(); + if (session) { + const { id } = session; + await this._client.startWithExistingSession(id); + return true; + } + return false; + } + + get chatterboxViewModel() { + return this._chatterBoxViewModel; + } + + get accountSetupViewModel() { + return this._accountSetupViewModel; + } + + get activeSection() { + return this._activeSection; + } +} diff --git a/tsconfig.json b/tsconfig.json index 8cdbb2a..8d6f34d 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -5,7 +5,7 @@ "module": "ESNext", "lib": ["ESNext", "DOM"], "moduleResolution": "Node", - "strict": true, + // "strict": true, "sourceMap": true, "resolveJsonModule": true, "esModuleInterop": true, -- GitLab