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
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`.

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
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";
export default class SessionViewModel extends EventEmitter {
constructor(session, sync) {
constructor({session, sync}) {
super();
this._session = session;
this._syncStatusViewModel = new SyncStatusViewModel(sync);

View File

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

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 {
constructor(hsApi, session, storage) {
constructor({hsApi, session, storage}) {
super();
this._hsApi = hsApi;
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;
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 {
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"]))
]);
}
}

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