Skip to content
Snippets Groups Projects
Commit 059b180b authored by Midhun Suresh's avatar Midhun Suresh
Browse files

UI WIP

parent 895efb92
No related branches found
No related tags found
No related merge requests found
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;
}
}
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; }
}
import { Chatterbox } from "./Chatterbox";
import type { IChatterboxConfig } from "./types/IChatterboxConfig"; 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"; const rootDivId = "#chatterbox";
...@@ -18,8 +21,14 @@ async function main() { ...@@ -18,8 +21,14 @@ async function main() {
throw new Error("No element with id as 'chatterbox' found!"); throw new Error("No element with id as 'chatterbox' found!");
} }
const config = await fetchConfig(root); const config = await fetchConfig(root);
const chatterbox = new Chatterbox(config, root); const platform = new Platform(root, assetPaths, {}, { development: import.meta.env.DEV });
chatterbox.start(); 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(); main();
// todo: do we need something better than this? // 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 { export function generateRandomString(length: number): string {
let result = ""; let result = "";
const characters = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"; const characters = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
......
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>;
}
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")
]);
}
}
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)
]);
}
}
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");
}
}
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;
}
}
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;
}
}
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;
}
}
...@@ -5,7 +5,7 @@ ...@@ -5,7 +5,7 @@
"module": "ESNext", "module": "ESNext",
"lib": ["ESNext", "DOM"], "lib": ["ESNext", "DOM"],
"moduleResolution": "Node", "moduleResolution": "Node",
"strict": true, // "strict": true,
"sourceMap": true, "sourceMap": true,
"resolveJsonModule": true, "resolveJsonModule": true,
"esModuleInterop": true, "esModuleInterop": true,
......
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment