diff --git a/README.md b/README.md index 09676d19..e95beeb9 100644 --- a/README.md +++ b/README.md @@ -1,16 +1,15 @@ # Brawl -A javascript matrix client prototype, trying to minize RAM usage by offloading as much as possible to IndexedDB + +A minimal [Matrix](https://matrix.org/) chat client, focused on performance, offline functionality and working on my Lumia 950 Windows Phone. ## Status -Syncing & storing rooms with state and timeline, with a minimal UI syncing room list and timeline on screen. Filling gaps supported, detecting overlapping events. The `[0/1]` in the gif below is the local event key, consisting of a fragment id and event index. No sending yet. Using Fractal here to update the room name and send messages: +Brawl can currently log you in, or pick an existing session, sync already joined rooms, fill gaps in the timeline, and send text messages. Everything is stored locally. -![Rooms and timeline syncing on-screen, gaps filling](https://bwindels.github.io/brawl-chat/images/morpheus-gaps.gif) +![Showing multiple sessions, and sending messages](https://bwindels.github.io/brawl-chat/images/brawl-sending.gif) -## Features that this approach would be well suited for +## Why - - store all fetched messages, not just synced ones - - fast local search (with words index) - - scroll timeline with date tooltip? - - jump to timestamp - - multi-account +I started writing Brawl both to have a functional matrix client on my aging phone, and to play around with some ideas I had how to use indexeddb optimally in a matrix client. For every interaction or network response (syncing, filling a gap), Brawl starts a transaction in indexedb, and only commits it once everything went well. This helps to keep your storage always in a consistent state. As little data is kept in memory as well, and while scrolling in the above GIF, everything is loaded straight from the storage. + +If you find this interesting, feel free to reach me at `@bwindels:matrix.org`. diff --git a/doc/LOGIN.md b/doc/LOGIN.md new file mode 100644 index 00000000..c5a8915e --- /dev/null +++ b/doc/LOGIN.md @@ -0,0 +1,11 @@ +LoginView + LoginViewModel +SessionPickerView + SessionPickerViewModel + +matrix: + SessionStorage (could be in keychain, ... for now we go with localstorage) + getAll() + + Login + \ No newline at end of file diff --git a/doc/viewhierarchy.md b/doc/viewhierarchy.md index 0605297d..69c69679 100644 --- a/doc/viewhierarchy.md +++ b/doc/viewhierarchy.md @@ -16,11 +16,3 @@ view hierarchy: SessionPickView LoginView ``` - - - DONE: support isOwn on message view model - - DONE: put syncstatusbar in sessionview - - DONE: apply css to app - - DONE: keep scroll at bottom - - DONE: hide sender if repeated - - DONE: show date somehow - - DONE: start scrolled down when opening room diff --git a/src/domain/BrawlViewModel.js b/src/domain/BrawlViewModel.js new file mode 100644 index 00000000..1d8d2f09 --- /dev/null +++ b/src/domain/BrawlViewModel.js @@ -0,0 +1,158 @@ +import Session from "../matrix/session.js"; +import Sync from "../matrix/sync.js"; +import SessionViewModel from "./session/SessionViewModel.js"; +import LoginViewModel from "./LoginViewModel.js"; +import SessionPickerViewModel from "./SessionPickerViewModel.js"; +import EventEmitter from "../EventEmitter.js"; + +export default class BrawlViewModel extends EventEmitter { + constructor({createStorage, sessionStore, createHsApi, clock}) { + super(); + this._createStorage = createStorage; + this._sessionStore = sessionStore; + this._createHsApi = createHsApi; + this._clock = clock; + + this._loading = false; + this._error = null; + this._sessionViewModel = null; + this._loginViewModel = null; + this._sessionPickerViewModel = null; + } + + async load() { + if (await this._sessionStore.hasAnySession()) { + this._showPicker(); + } else { + this._showLogin(); + } + } + + async _showPicker() { + this._clearSections(); + this._sessionPickerViewModel = new SessionPickerViewModel({ + sessionStore: this._sessionStore, + sessionCallback: sessionInfo => this._onSessionPicked(sessionInfo) + }); + this.emit("change", "activeSection"); + try { + await this._sessionPickerViewModel.load(); + } catch (err) { + this._clearSections(); + this._error = err; + this.emit("change", "activeSection"); + } + } + + _showLogin() { + this._clearSections(); + this._loginViewModel = new LoginViewModel({ + createHsApi: this._createHsApi, + defaultHomeServer: "matrix.org", + loginCallback: loginData => this._onLoginFinished(loginData) + }); + this.emit("change", "activeSection"); + + } + + _showSession(session, sync) { + this._clearSections(); + this._sessionViewModel = new SessionViewModel({session, sync}); + this.emit("change", "activeSection"); + } + + _clearSections() { + this._error = null; + this._loading = false; + this._sessionViewModel = null; + this._loginViewModel = null; + this._sessionPickerViewModel = null; + } + + get activeSection() { + if (this._error) { + return "error"; + } else if(this._loading) { + return "loading"; + } else if (this._sessionViewModel) { + return "session"; + } else if (this._loginViewModel) { + return "login"; + } else { + return "picker"; + } + } + + get loadingText() { return this._loadingText; } + get sessionViewModel() { return this._sessionViewModel; } + get loginViewModel() { return this._loginViewModel; } + get sessionPickerViewModel() { return this._sessionPickerViewModel; } + get errorText() { return this._error && this._error.message; } + + async _onLoginFinished(loginData) { + if (loginData) { + // TODO: extract random() as it is a source of non-determinism + const sessionId = (Math.floor(Math.random() * Number.MAX_SAFE_INTEGER)).toString(); + const sessionInfo = { + id: sessionId, + deviceId: loginData.device_id, + userId: loginData.user_id, + homeServer: loginData.home_server, + accessToken: loginData.access_token, + lastUsed: this._clock.now() + }; + await this._sessionStore.add(sessionInfo); + this._loadSession(sessionInfo); + } else { + this._showPicker(); + } + } + + _onSessionPicked(sessionInfo) { + if (sessionInfo) { + this._loadSession(sessionInfo); + this._sessionStore.updateLastUsed(sessionInfo.id, this._clock.now()); + } else { + this._showLogin(); + } + } + + async _loadSession(sessionInfo) { + try { + this._loading = true; + this._loadingText = "Loading your conversations…"; + const hsApi = this._createHsApi(sessionInfo.homeServer, sessionInfo.accessToken); + const storage = await this._createStorage(sessionInfo.id); + // no need to pass access token to session + const filteredSessionInfo = { + deviceId: sessionInfo.deviceId, + userId: sessionInfo.userId, + homeServer: sessionInfo.homeServer, + }; + const session = new Session({storage, sessionInfo: filteredSessionInfo, hsApi}); + // show spinner now, with title loading stored data? + + this.emit("change", "activeSection"); + await session.load(); + const sync = new Sync({hsApi, storage, session}); + + const needsInitialSync = !session.syncToken; + if (!needsInitialSync) { + this._showSession(session, sync); + } + this._loadingText = "Getting your conversations from the server…"; + this.emit("change", "loadingText"); + // update spinner title to initial sync + await sync.start(); + if (needsInitialSync) { + this._showSession(session, sync); + } + // start sending pending messages + session.notifyNetworkAvailable(); + } catch (err) { + console.error(err); + this._error = err; + } + this.emit("change", "activeSection"); + } +} diff --git a/src/domain/LoginViewModel.js b/src/domain/LoginViewModel.js new file mode 100644 index 00000000..c6bc6356 --- /dev/null +++ b/src/domain/LoginViewModel.js @@ -0,0 +1,38 @@ +import EventEmitter from "../EventEmitter.js"; + +export default class LoginViewModel extends EventEmitter { + constructor({loginCallback, defaultHomeServer, createHsApi}) { + super(); + this._loginCallback = loginCallback; + this._defaultHomeServer = defaultHomeServer; + this._createHsApi = createHsApi; + this._loading = false; + this._error = null; + } + + get usernamePlaceholder() { return "Username"; } + get passwordPlaceholder() { return "Password"; } + get hsPlaceholder() { return "Your matrix homeserver"; } + get defaultHomeServer() { return this._defaultHomeServer; } + get error() { return this._error; } + get loading() { return this._loading; } + + async login(username, password, homeserver) { + const hsApi = this._createHsApi(homeserver); + try { + this._loading = true; + this.emit("change", "loading"); + const loginData = await hsApi.passwordLogin(username, password).response(); + this._loginCallback(loginData); + // wait for parent view model to switch away here + } catch (err) { + this._error = err; + this._loading = false; + this.emit("change", "loading"); + } + } + + cancel() { + this._loginCallback(); + } +} diff --git a/src/domain/SessionPickerViewModel.js b/src/domain/SessionPickerViewModel.js new file mode 100644 index 00000000..3c7dff80 --- /dev/null +++ b/src/domain/SessionPickerViewModel.js @@ -0,0 +1,29 @@ +import {SortedArray} from "../observable/index.js"; + +export default class SessionPickerViewModel { + constructor({sessionStore, sessionCallback}) { + this._sessionStore = sessionStore; + this._sessionCallback = sessionCallback; + this._sessions = new SortedArray((s1, s2) => (s1.lastUsed || 0) - (s2.lastUsed || 0)); + } + + async load() { + const sessions = await this._sessionStore.getAll(); + this._sessions.setManyUnsorted(sessions); + } + + pick(id) { + const session = this._sessions.array.find(s => s.id === id); + if (session) { + this._sessionCallback(session); + } + } + + get sessions() { + return this._sessions; + } + + cancel() { + this._sessionCallback(); + } +} diff --git a/src/domain/session/SessionViewModel.js b/src/domain/session/SessionViewModel.js index 65750280..28bc1277 100644 --- a/src/domain/session/SessionViewModel.js +++ b/src/domain/session/SessionViewModel.js @@ -4,7 +4,7 @@ import RoomViewModel from "./room/RoomViewModel.js"; import SyncStatusViewModel from "./SyncStatusViewModel.js"; export default class SessionViewModel extends EventEmitter { - constructor(session, sync) { + constructor({session, sync}) { super(); this._session = session; this._syncStatusViewModel = new SyncStatusViewModel(sync); diff --git a/src/main.js b/src/main.js index 9f957513..78f5f881 100644 --- a/src/main.js +++ b/src/main.js @@ -1,83 +1,20 @@ import HomeServerApi from "./matrix/hs-api.js"; -import Session from "./matrix/session.js"; import createIdbStorage from "./matrix/storage/idb/create.js"; -import Sync from "./matrix/sync.js"; -import SessionView from "./ui/web/session/SessionView.js"; -import SessionViewModel from "./domain/session/SessionViewModel.js"; - -const HOST = "127.0.0.1"; -const HOMESERVER = `http://${HOST}:8008`; -const USERNAME = "bruno1"; -const USER_ID = `@${USERNAME}:localhost`; -const PASSWORD = "testtest"; - -function getSessionInfo(userId) { - const sessionsJson = localStorage.getItem("brawl_sessions_v1"); - if (sessionsJson) { - const sessions = JSON.parse(sessionsJson); - const session = sessions.find(session => session.userId === userId); - if (session) { - return session; - } - } -} - -function storeSessionInfo(loginData) { - const sessionsJson = localStorage.getItem("brawl_sessions_v1"); - const sessions = sessionsJson ? JSON.parse(sessionsJson) : []; - const sessionId = (Math.floor(Math.random() * Number.MAX_SAFE_INTEGER)).toString(); - const sessionInfo = { - id: sessionId, - deviceId: loginData.device_id, - userId: loginData.user_id, - homeServer: loginData.home_server, - accessToken: loginData.access_token, - }; - sessions.push(sessionInfo); - localStorage.setItem("brawl_sessions_v1", JSON.stringify(sessions)); - return sessionInfo; -} - -async function login(username, password, homeserver) { - const hsApi = new HomeServerApi(homeserver); - const loginData = await hsApi.passwordLogin(username, password).response(); - return storeSessionInfo(loginData); -} - -function showSession(container, session, sync) { - const vm = new SessionViewModel(session, sync); - const view = new SessionView(vm); - container.appendChild(view.mount()); -} +import SessionsStore from "./matrix/sessions-store/localstorage/SessionsStore.js"; +import BrawlViewModel from "./domain/BrawlViewModel.js"; +import BrawlView from "./ui/web/BrawlView.js"; export default async function main(container) { try { - let sessionInfo = getSessionInfo(USER_ID); - if (!sessionInfo) { - sessionInfo = await login(USERNAME, PASSWORD, HOMESERVER); - } - const storage = await createIdbStorage(`brawl_session_${sessionInfo.id}`); - const hsApi = new HomeServerApi(HOMESERVER, sessionInfo.accessToken); - const session = new Session({storage, hsApi, sessionInfo: { - deviceId: sessionInfo.deviceId, - userId: sessionInfo.userId, - homeServer: sessionInfo.homeServer, //only pass relevant fields to Session - }}); - await session.load(); - console.log("session loaded"); - const sync = new Sync(hsApi, session, storage); - const needsInitialSync = !session.syncToken; - if (needsInitialSync) { - console.log("session needs initial sync"); - } else { - showSession(container, session, sync); - } - await sync.start(); - if (needsInitialSync) { - showSession(container, session, sync); - } - // this will start sending unsent messages - session.notifyNetworkAvailable(); + const vm = new BrawlViewModel({ + createStorage: sessionId => createIdbStorage(`brawl_session_${sessionId}`), + createHsApi: (homeServer, accessToken = null) => new HomeServerApi(`https://${homeServer}`, accessToken), + sessionStore: new SessionsStore("brawl_sessions_v1"), + clock: Date //just for `now` fn + }); + await vm.load(); + const view = new BrawlView(vm); + container.appendChild(view.mount()); } catch(err) { console.error(`${err.message}:\n${err.stack}`); } diff --git a/src/matrix/sessions-store/localstorage/SessionsStore.js b/src/matrix/sessions-store/localstorage/SessionsStore.js new file mode 100644 index 00000000..424dac62 --- /dev/null +++ b/src/matrix/sessions-store/localstorage/SessionsStore.js @@ -0,0 +1,45 @@ +export default class SessionsStore { + constructor(name) { + this._name = name; + } + + getAll() { + const sessionsJson = localStorage.getItem(this._name); + if (sessionsJson) { + const sessions = JSON.parse(sessionsJson); + if (Array.isArray(sessions)) { + return Promise.resolve(sessions); + } + } + return Promise.resolve([]); + } + + async hasAnySession() { + const all = await this.getAll(); + return all && all.length > 0; + } + + async updateLastUsed(id, timestamp) { + const sessions = await this.getAll(); + if (sessions) { + const session = sessions.find(session => session.id === id); + if (session) { + session.lastUsed = timestamp; + localStorage.setItem(this._name, JSON.stringify(sessions)); + } + } + } + + async get(id) { + const sessions = await this.getAll(); + if (sessions) { + return sessions.find(session => session.id === id); + } + } + + async add(sessionInfo) { + const sessions = await this.getAll(); + sessions.push(sessionInfo); + localStorage.setItem(this._name, JSON.stringify(sessions)); + } +} diff --git a/src/matrix/sync.js b/src/matrix/sync.js index 270d72e3..33711c91 100644 --- a/src/matrix/sync.js +++ b/src/matrix/sync.js @@ -20,7 +20,7 @@ function parseRooms(roomsSection, roomCallback) { } export default class Sync extends EventEmitter { - constructor(hsApi, session, storage) { + constructor({hsApi, session, storage}) { super(); this._hsApi = hsApi; this._session = session; diff --git a/src/ui/web/BrawlView.js b/src/ui/web/BrawlView.js new file mode 100644 index 00000000..c5642e15 --- /dev/null +++ b/src/ui/web/BrawlView.js @@ -0,0 +1,64 @@ +import SessionView from "./session/SessionView.js"; +import LoginView from "./login/LoginView.js"; +import SessionPickerView from "./login/SessionPickerView.js"; +import TemplateView from "./general/TemplateView.js"; +import SwitchView from "./general/SwitchView.js"; + +export default class BrawlView { + constructor(vm) { + this._vm = vm; + this._switcher = null; + this._root = null; + this._onViewModelChange = this._onViewModelChange.bind(this); + } + + _getView() { + switch (this._vm.activeSection) { + case "error": + return new StatusView({header: "Something went wrong", message: this._vm.errorText}); + case "loading": + return new StatusView({header: "Loading", message: this._vm.loadingText}); + case "session": + return new SessionView(this._vm.sessionViewModel); + case "login": + return new LoginView(this._vm.loginViewModel); + case "picker": + return new SessionPickerView(this._vm.sessionPickerViewModel); + default: + throw new Error(`Unknown section: ${this._vm.activeSection}`); + } + } + + _onViewModelChange(prop) { + if (prop === "activeSection") { + this._switcher.switch(this._getView()); + } + } + + mount() { + this._switcher = new SwitchView(this._getView()); + this._root = this._switcher.mount(); + this._vm.on("change", this._onViewModelChange); + return this._root; + } + + unmount() { + this._vm.off("change", this._onViewModelChange); + this._switcher.unmount(); + } + + root() { + return this._root; + } + + update() {} +} + +class StatusView extends TemplateView { + render(t, vm) { + return t.div({className: "StatusView"}, [ + t.h1(vm.header), + t.p(vm.message), + ]); + } +} diff --git a/src/ui/web/css/main.css b/src/ui/web/css/main.css index 18e1ba6c..64ba8698 100644 --- a/src/ui/web/css/main.css +++ b/src/ui/web/css/main.css @@ -26,3 +26,35 @@ body { justify-content: center; flex-direction: row; } + +.SessionPickerView { + padding: 0.4em; +} + +.SessionPickerView ul { + list-style: none; + padding: 0; +} + +.SessionPickerView li { + margin: 0.4em 0; + font-size: 1.2em; + background-color: grey; + padding: 0.5em; + cursor: pointer; +} + +.LoginView { + padding: 0.4em; +} + +.form > div { + margin: 0.4em 0; +} + +.form input { + display: block; + width: 100%; + box-sizing: border-box; +} + diff --git a/src/ui/web/login/LoginView.js b/src/ui/web/login/LoginView.js index a9ccf667..1285020c 100644 --- a/src/ui/web/login/LoginView.js +++ b/src/ui/web/login/LoginView.js @@ -1,19 +1,25 @@ -import TemplateView from "./general/TemplateView.js"; +import TemplateView from "../general/TemplateView.js"; export default class LoginView extends TemplateView { + constructor(vm) { + super(vm, true); + } + render(t, vm) { const username = t.input({type: "text", placeholder: vm.usernamePlaceholder}); - const password = t.input({type: "password", placeholder: vm.usernamePlaceholder}); - const homeserver = t.input({type: "text", placeholder: vm.hsPlaceholder, value: vm.defaultHS}); - return t.div({className: "login form"}, [ + const password = t.input({type: "password", placeholder: vm.passwordPlaceholder}); + const homeserver = t.input({type: "text", placeholder: vm.hsPlaceholder, value: vm.defaultHomeServer}); + return t.div({className: "LoginView form"}, [ + t.h1(["Log in to your homeserver"]), t.if(vm => vm.error, t => t.div({className: "error"}, vm => vm.error)), t.div(username), t.div(password), t.div(homeserver), t.div(t.button({ onClick: () => vm.login(username.value, password.value, homeserver.value), - disabled: vm => vm.isBusy - }, "Log In")) + disabled: vm => vm.loading + }, "Log In")), + t.div(t.button({onClick: () => vm.cancel()}, ["Pick an existing session"])) ]); } } diff --git a/src/ui/web/login/SessionPickerView.js b/src/ui/web/login/SessionPickerView.js new file mode 100644 index 00000000..521c2f92 --- /dev/null +++ b/src/ui/web/login/SessionPickerView.js @@ -0,0 +1,35 @@ +import ListView from "../general/ListView.js"; +import TemplateView from "../general/TemplateView.js"; + +class SessionPickerItem extends TemplateView { + render(t) { + return t.li([vm => vm.userId]); + } +} + +export default class SessionPickerView extends TemplateView { + mount() { + this._sessionList = new ListView({ + list: this.viewModel.sessions, + onItemClick: (item) => { + this.viewModel.pick(item.viewModel.id); + }, + }, sessionInfo => { + return new SessionPickerItem(sessionInfo); + }); + return super.mount(); + } + + render(t) { + return t.div({className: "SessionPickerView"}, [ + t.h1(["Pick a session"]), + this._sessionList.mount(), + t.button({onClick: () => this.viewModel.cancel()}, ["Log in to a new session instead"]) + ]); + } + + unmount() { + super.unmount(); + this._sessionList.unmount(); + } +}