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..5518f7e5 --- /dev/null +++ b/src/domain/BrawlViewModel.js @@ -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"); + } +} 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..922e8775 --- /dev/null +++ b/src/domain/SessionPickerViewModel.js @@ -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(); + } +} 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/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/ui/web/login/LoginView.js b/src/ui/web/login/LoginView.js index a9ccf667..77dfde53 100644 --- a/src/ui/web/login/LoginView.js +++ b/src/ui/web/login/LoginView.js @@ -4,7 +4,7 @@ export default class LoginView extends TemplateView { 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}); + const homeserver = t.input({type: "text", placeholder: vm.hsPlaceholder, value: vm.defaultHomeServer}); return t.div({className: "login form"}, [ t.if(vm => vm.error, t => t.div({className: "error"}, vm => vm.error)), t.div(username), @@ -12,8 +12,9 @@ export default class LoginView extends TemplateView { 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()}), "Cancel") ]); } }