Merge pull request #6 from bwindels/bwindels/login

Support login and picking a preexisting session
This commit is contained in:
Bruno Windels 2019-09-08 08:52:59 +00:00 committed by GitHub
commit 991dd5aa6f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 446 additions and 100 deletions

View File

@ -1,16 +1,15 @@
# Brawl # 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 ## 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 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.
- fast local search (with words index)
- scroll timeline with date tooltip? If you find this interesting, feel free to reach me at `@bwindels:matrix.org`.
- jump to timestamp
- multi-account

11
doc/LOGIN.md Normal file
View File

@ -0,0 +1,11 @@
LoginView
LoginViewModel
SessionPickerView
SessionPickerViewModel
matrix:
SessionStorage (could be in keychain, ... for now we go with localstorage)
getAll()
Login

View File

@ -16,11 +16,3 @@ view hierarchy:
SessionPickView SessionPickView
LoginView 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

View File

@ -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");
}
}

View File

@ -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();
}
}

View File

@ -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();
}
}

View File

@ -4,7 +4,7 @@ import RoomViewModel from "./room/RoomViewModel.js";
import SyncStatusViewModel from "./SyncStatusViewModel.js"; import SyncStatusViewModel from "./SyncStatusViewModel.js";
export default class SessionViewModel extends EventEmitter { export default class SessionViewModel extends EventEmitter {
constructor(session, sync) { constructor({session, sync}) {
super(); super();
this._session = session; this._session = session;
this._syncStatusViewModel = new SyncStatusViewModel(sync); this._syncStatusViewModel = new SyncStatusViewModel(sync);

View File

@ -1,83 +1,20 @@
import HomeServerApi from "./matrix/hs-api.js"; import HomeServerApi from "./matrix/hs-api.js";
import Session from "./matrix/session.js";
import createIdbStorage from "./matrix/storage/idb/create.js"; import createIdbStorage from "./matrix/storage/idb/create.js";
import Sync from "./matrix/sync.js"; import SessionsStore from "./matrix/sessions-store/localstorage/SessionsStore.js";
import SessionView from "./ui/web/session/SessionView.js"; import BrawlViewModel from "./domain/BrawlViewModel.js";
import SessionViewModel from "./domain/session/SessionViewModel.js"; import BrawlView from "./ui/web/BrawlView.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());
}
export default async function main(container) { export default async function main(container) {
try { try {
let sessionInfo = getSessionInfo(USER_ID); const vm = new BrawlViewModel({
if (!sessionInfo) { createStorage: sessionId => createIdbStorage(`brawl_session_${sessionId}`),
sessionInfo = await login(USERNAME, PASSWORD, HOMESERVER); createHsApi: (homeServer, accessToken = null) => new HomeServerApi(`https://${homeServer}`, accessToken),
} sessionStore: new SessionsStore("brawl_sessions_v1"),
const storage = await createIdbStorage(`brawl_session_${sessionInfo.id}`); clock: Date //just for `now` fn
const hsApi = new HomeServerApi(HOMESERVER, sessionInfo.accessToken); });
const session = new Session({storage, hsApi, sessionInfo: { await vm.load();
deviceId: sessionInfo.deviceId, const view = new BrawlView(vm);
userId: sessionInfo.userId, container.appendChild(view.mount());
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();
} catch(err) { } catch(err) {
console.error(`${err.message}:\n${err.stack}`); console.error(`${err.message}:\n${err.stack}`);
} }

View File

@ -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));
}
}

View File

@ -20,7 +20,7 @@ function parseRooms(roomsSection, roomCallback) {
} }
export default class Sync extends EventEmitter { export default class Sync extends EventEmitter {
constructor(hsApi, session, storage) { constructor({hsApi, session, storage}) {
super(); super();
this._hsApi = hsApi; this._hsApi = hsApi;
this._session = session; this._session = session;

64
src/ui/web/BrawlView.js Normal file
View File

@ -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),
]);
}
}

View File

@ -26,3 +26,35 @@ body {
justify-content: center; justify-content: center;
flex-direction: row; 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;
}

View File

@ -1,19 +1,25 @@
import TemplateView from "./general/TemplateView.js"; import TemplateView from "../general/TemplateView.js";
export default class LoginView extends TemplateView { export default class LoginView extends TemplateView {
constructor(vm) {
super(vm, true);
}
render(t, vm) { render(t, vm) {
const username = t.input({type: "text", placeholder: vm.usernamePlaceholder}); const username = t.input({type: "text", placeholder: vm.usernamePlaceholder});
const password = t.input({type: "password", placeholder: vm.usernamePlaceholder}); const password = t.input({type: "password", placeholder: vm.passwordPlaceholder});
const homeserver = t.input({type: "text", placeholder: vm.hsPlaceholder, value: vm.defaultHS}); const homeserver = t.input({type: "text", placeholder: vm.hsPlaceholder, value: vm.defaultHomeServer});
return t.div({className: "login form"}, [ 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.if(vm => vm.error, t => t.div({className: "error"}, vm => vm.error)),
t.div(username), t.div(username),
t.div(password), t.div(password),
t.div(homeserver), t.div(homeserver),
t.div(t.button({ t.div(t.button({
onClick: () => vm.login(username.value, password.value, homeserver.value), onClick: () => vm.login(username.value, password.value, homeserver.value),
disabled: vm => vm.isBusy disabled: vm => vm.loading
}, "Log In")) }, "Log In")),
t.div(t.button({onClick: () => vm.cancel()}, ["Pick an existing session"]))
]); ]);
} }
} }

View File

@ -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();
}
}