forked from mystiq/hydrogen-web
Merge pull request #6 from bwindels/bwindels/login
Support login and picking a preexisting session
This commit is contained in:
commit
991dd5aa6f
14 changed files with 446 additions and 100 deletions
17
README.md
17
README.md
|
@ -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
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
|
||||
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
|
||||
|
|
158
src/domain/BrawlViewModel.js
Normal file
158
src/domain/BrawlViewModel.js
Normal 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");
|
||||
}
|
||||
}
|
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 "../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();
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
|
|
87
src/main.js
87
src/main.js
|
@ -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}`);
|
||||
}
|
||||
|
|
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));
|
||||
}
|
||||
}
|
|
@ -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
64
src/ui/web/BrawlView.js
Normal 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),
|
||||
]);
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
@ -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"]))
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
35
src/ui/web/login/SessionPickerView.js
Normal file
35
src/ui/web/login/SessionPickerView.js
Normal 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();
|
||||
}
|
||||
}
|
Loading…
Reference in a new issue