forked from mystiq/hydrogen-web
login and session picker view models, sessions store
This commit is contained in:
parent
378eea8ceb
commit
49a577991b
8 changed files with 285 additions and 12 deletions
11
doc/LOGIN.md
Normal file
11
doc/LOGIN.md
Normal file
|
@ -0,0 +1,11 @@
|
||||||
|
LoginView
|
||||||
|
LoginViewModel
|
||||||
|
SessionPickerView
|
||||||
|
SessionPickerViewModel
|
||||||
|
|
||||||
|
matrix:
|
||||||
|
SessionStorage (could be in keychain, ... for now we go with localstorage)
|
||||||
|
getAll()
|
||||||
|
|
||||||
|
Login
|
||||||
|
|
|
@ -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
|
|
||||||
|
|
157
src/domain/BrawlViewModel.js
Normal file
157
src/domain/BrawlViewModel.js
Normal 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");
|
||||||
|
}
|
||||||
|
}
|
38
src/domain/LoginViewModel.js
Normal file
38
src/domain/LoginViewModel.js
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
29
src/domain/SessionPickerViewModel.js
Normal file
29
src/domain/SessionPickerViewModel.js
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
|
@ -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);
|
||||||
|
|
45
src/matrix/sessions-store/localstorage/SessionsStore.js
Normal file
45
src/matrix/sessions-store/localstorage/SessionsStore.js
Normal 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));
|
||||||
|
}
|
||||||
|
}
|
|
@ -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")
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in a new issue