login and session picker view models, sessions store

This commit is contained in:
Bruno Windels 2019-07-29 22:39:56 +02:00
parent 378eea8ceb
commit 49a577991b
8 changed files with 285 additions and 12 deletions

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,157 @@
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, sessionsStore, createHsApi, clock}) {
super();
this._createStorage = createStorage;
this._sessionsStore = sessionsStore;
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._sessionsStore.hasAnySession()) {
this._showPicker();
} else {
this._showLogin();
}
}
async _showPicker() {
this._clearSections();
this._sessionPickerViewModel = new SessionPickerViewModel({
sessionsStore: this._sessionsStore,
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._sessionsStore.add(sessionInfo);
this._loadSession(sessionInfo);
} else {
this._showPicker();
}
}
_onSessionPicked(sessionInfo) {
if (sessionInfo) {
this._loadSession(sessionInfo);
this._sessionsStore.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);
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) {
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 "./observables/index.js";
export default class SessionPickerViewModel {
constructor({sessionStore, sessionCallback}) {
this._sessionsStore = sessionStore;
this._sessionCallback = sessionCallback;
this._sessions = new SortedArray((s1, s2) => (s1.lastUsed || 0) - (s2.lastUsed || 0));
}
async load() {
const sessions = await this._sessionsStore.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

@ -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

@ -4,7 +4,7 @@ export default class LoginView extends TemplateView {
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.usernamePlaceholder});
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: "login form"}, [
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),
@ -12,8 +12,9 @@ export default class LoginView extends TemplateView {
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()}), "Cancel")
]); ]);
} }
} }