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