From 378b75c98a7626184f7394186839ce07fbfac8ad Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 9 Apr 2020 23:19:49 +0200 Subject: [PATCH] more WIP and breakage --- src/domain/BrawlViewModel.js | 117 +++++++----------- src/domain/ViewModel.js | 19 +++ src/domain/session/SessionLoadViewModel.js | 43 +++++++ src/domain/session/SessionViewModel.js | 7 ++ src/matrix/Reconnector.js | 14 +-- src/matrix/SessionContainer.js | 95 ++++++++++++++ src/observable/BaseObservableCollection.js | 9 ++ .../DOMClock.js => ui/web/dom/Clock.js} | 12 +- src/ui/web/dom/Online.js | 29 +++++ src/ui/web/general/SwitchView.js | 35 ++++++ src/utils/Disposables.js | 37 ++++++ 11 files changed, 326 insertions(+), 91 deletions(-) create mode 100644 src/domain/ViewModel.js create mode 100644 src/domain/session/SessionLoadViewModel.js create mode 100644 src/matrix/SessionContainer.js rename src/{utils/DOMClock.js => ui/web/dom/Clock.js} (82%) create mode 100644 src/ui/web/dom/Online.js create mode 100644 src/utils/Disposables.js diff --git a/src/domain/BrawlViewModel.js b/src/domain/BrawlViewModel.js index 20bddd7f..3ed861fe 100644 --- a/src/domain/BrawlViewModel.js +++ b/src/domain/BrawlViewModel.js @@ -20,6 +20,7 @@ export default class BrawlViewModel extends EventEmitter { this._loading = false; this._error = null; this._sessionViewModel = null; + this._sessionSubscription = null; this._loginViewModel = null; this._sessionPickerViewModel = null; } @@ -33,45 +34,35 @@ export default class BrawlViewModel extends EventEmitter { } async _showPicker() { - this._clearSections(); - this._sessionPickerViewModel = new SessionPickerViewModel({ - sessionStore: this._sessionStore, - storageFactory: this._storageFactory, - sessionCallback: sessionInfo => this._onSessionPicked(sessionInfo) + this._setSection(() => { + this._sessionPickerViewModel = new SessionPickerViewModel({ + sessionStore: this._sessionStore, + storageFactory: this._storageFactory, + 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"); + this._setSection(() => this._error = err); } } _showLogin() { - this._clearSections(); - this._loginViewModel = new LoginViewModel({ - createHsApi: this._createHsApi, - defaultHomeServer: "https://matrix.org", - loginCallback: loginData => this._onLoginFinished(loginData) - }); - this.emit("change", "activeSection"); + this._setSection(() => { + this._loginViewModel = new LoginViewModel({ + createHsApi: this._createHsApi, + defaultHomeServer: "https://matrix.org", + loginCallback: loginData => this._onLoginFinished(loginData) + }); + }) } _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; + this._setSection(() => { + this._sessionViewModel = new SessionViewModel({session, sync}); + }); } get activeSection() { @@ -88,6 +79,25 @@ export default class BrawlViewModel extends EventEmitter { } } + _setSection(setter) { + const oldSection = this.activeSection; + // clear all members the activeSection depends on + this._error = null; + this._loading = false; + this._sessionViewModel = null; + this._loginViewModel = null; + this._sessionPickerViewModel = null; + // now set it again + setter(); + const newSection = this.activeSection; + // remove session subscription when navigating away + if (oldSection === "session" && newSection !== oldSection) { + this._sessionSubscription(); + this._sessionSubscription = null; + } + this.emit("change", "activeSection"); + } + get loadingText() { return this._loadingText; } get sessionViewModel() { return this._sessionViewModel; } get loginViewModel() { return this._loginViewModel; } @@ -123,51 +133,12 @@ export default class BrawlViewModel extends EventEmitter { } async _loadSession(sessionInfo) { - try { - this._loading = true; - this._loadingText = "Loading your conversations…"; - const reconnector = new Reconnector( - new ExponentialRetryDelay(2000, this._clock.createTimeout), - this._clock.createMeasure - ); - const hsApi = this._createHsApi(sessionInfo.homeServer, sessionInfo.accessToken, reconnector); - const storage = await this._storageFactory.create(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}); - - reconnector.on("state", state => { - if (state === ConnectionState.Online) { - sync.start(); - session.notifyNetworkAvailable(reconnector.lastVersionsResponse); - } - }); - - 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"); + this._setSection(() => { + // TODO this is pseudo code-ish + const container = this._createSessionContainer(); + this._sessionViewModel = new SessionViewModel({session, sync}); + this._sessionSubscription = this._activeSessionContainer.subscribe(this._updateSessionState); + this._activeSessionContainer.start(sessionInfo); + }); } } diff --git a/src/domain/ViewModel.js b/src/domain/ViewModel.js new file mode 100644 index 00000000..2e26faae --- /dev/null +++ b/src/domain/ViewModel.js @@ -0,0 +1,19 @@ +export class ViewModel extends ObservableValue { + constructor(options) { + super(); + this.disposables = new Disposables(); + this._options = options; + } + + childOptions(explicitOptions) { + return Object.assign({}, this._options, explicitOptions); + } + + track(disposable) { + this.disposables.track(disposable); + } + + dispose() { + this.disposables.dispose(); + } +} diff --git a/src/domain/session/SessionLoadViewModel.js b/src/domain/session/SessionLoadViewModel.js new file mode 100644 index 00000000..3f3edba6 --- /dev/null +++ b/src/domain/session/SessionLoadViewModel.js @@ -0,0 +1,43 @@ +import EventEmitter from "../../EventEmitter.js"; +import RoomTileViewModel from "./roomlist/RoomTileViewModel.js"; +import RoomViewModel from "./room/RoomViewModel.js"; +import SyncStatusViewModel from "./SyncStatusViewModel.js"; + +export default class SessionLoadViewModel extends ViewModel { + constructor(options) { + super(options); + this._sessionContainer = options.sessionContainer; + this._updateState(); + } + + onSubscribeFirst() { + this.track(this._sessionContainer.subscribe(this._updateState)); + } + + _updateState(previousState) { + const state = this._sessionContainer.state; + if (previousState !== LoadState.Ready && state === LoadState.Ready) { + this._sessionViewModel = new SessionViewModel(this.childOptions({ + sessionContainer: this._sessionContainer + })); + this.track(this._sessionViewModel); + } else if (previousState === LoadState.Ready && state !== LoadState.Ready) { + this.disposables.disposeTracked(this._sessionViewModel); + this._sessionViewModel = null; + } + this.emit(); + } + + get isLoading() { + const state = this._sessionContainer.state; + return state === LoadState.Loading || state === LoadState.InitialSync; + } + + get loadingLabel() { + switch (this._sessionContainer.state) { + case LoadState.Loading: return "Loading your conversations…"; + case LoadState.InitialSync: return "Getting your conversations from the server…"; + default: return null; + } + } +} diff --git a/src/domain/session/SessionViewModel.js b/src/domain/session/SessionViewModel.js index 28bc1277..74e1f00c 100644 --- a/src/domain/session/SessionViewModel.js +++ b/src/domain/session/SessionViewModel.js @@ -31,6 +31,13 @@ export default class SessionViewModel extends EventEmitter { return this._currentRoomViewModel; } + dispose() { + if (this._currentRoomViewModel) { + this._currentRoomViewModel.dispose(); + this._currentRoomViewModel = null; + } + } + _closeCurrentRoom() { if (this._currentRoomViewModel) { this._currentRoomViewModel.dispose(); diff --git a/src/matrix/Reconnector.js b/src/matrix/Reconnector.js index 2125380e..0cb85851 100644 --- a/src/matrix/Reconnector.js +++ b/src/matrix/Reconnector.js @@ -1,13 +1,3 @@ -// need to prevent memory leaks here! -export class DomOnlineDetected { - constructor(reconnector) { - // window.addEventListener('offline', () => appendOnlineStatus(false)); - // window.addEventListener('online', () => appendOnlineStatus(true)); - // appendOnlineStatus(navigator.onLine); - // on online, reconnector.tryNow() - } -} - export class ExponentialRetryDelay { constructor(start = 2000, createTimeout) { this._start = start; @@ -76,7 +66,7 @@ export const ConnectionState = createEnum( "Online" ); -export class Reconnector { +export class Reconnector extends ObservableValue { constructor({retryDelay, createTimeMeasure}) { this._online this._retryDelay = retryDelay; @@ -124,7 +114,7 @@ export class Reconnector { } else { this._stateSince = null; } - this.emit("change", state); + this.emit(state); } } diff --git a/src/matrix/SessionContainer.js b/src/matrix/SessionContainer.js new file mode 100644 index 00000000..86be76d9 --- /dev/null +++ b/src/matrix/SessionContainer.js @@ -0,0 +1,95 @@ +const factory = { + Clock: () => new DOMClock(), + Request: () => fetchRequest, + Online: () => new DOMOnline(), + HomeServerApi: () +} + +export const LoadState = createEnum( + "Loading", + "InitialSync", + "Migrating", //not used atm, but would fit here + "Error", + "Ready", +); + +class SessionContainer extends ObservableValue { + constructor({clock, random, isOnline, request, storageFactory, factory}) { + this.disposables = new Disposables(); + } + + dispose() { + this.disposables.dispose(); + } + + get state() { + return this._state; + } + + _setState(state) { + if (state !== this._state) { + const previousState = this._state; + this._state = state; + this.emit(previousState); + } + } + + get sync() { + return this._sync; + } + + get session() { + return this._session; + } + + _createReconnector() { + const reconnector = new Reconnector( + new ExponentialRetryDelay(2000, this._clock.createTimeout), + this._clock.createMeasure + ); + // retry connection immediatly when online is detected + this.disposables.track(isOnline.subscribe(online => { + if(online) { + reconnector.tryNow(); + } + })); + return reconnector; + } + + async start(sessionInfo) { + try { + this._setState(LoadState.Loading); + this._reconnector = this._createReconnector(); + const hsApi = this._createHsApi(sessionInfo.homeServer, sessionInfo.accessToken, this._reconnector); + const storage = await this._storageFactory.create(sessionInfo.id); + // no need to pass access token to session + const filteredSessionInfo = { + deviceId: sessionInfo.deviceId, + userId: sessionInfo.userId, + homeServer: sessionInfo.homeServer, + }; + this._session = new Session({storage, sessionInfo: filteredSessionInfo, hsApi}); + await this._session.load(); + this._sync = new Sync({hsApi, storage, this._session}); + + // notify sync and session when back online + this.disposables.track(reconnector.subscribe(state => { + this._sync.start(); + session.notifyNetworkAvailable(reconnector.lastVersionsResponse); + })); + + const needsInitialSync = !this._session.syncToken; + if (!needsInitialSync) { + this._setState(LoadState.Ready); + } else { + this._setState(LoadState.InitialSync); + } + await this._sync.start(); + this._setState(LoadState.Ready); + this._session.notifyNetworkAvailable(); + } catch (err) { + this._error = err; + this._setState(LoadState.Error); + } + } +} diff --git a/src/observable/BaseObservableCollection.js b/src/observable/BaseObservableCollection.js index f9370f10..2a8a2336 100644 --- a/src/observable/BaseObservableCollection.js +++ b/src/observable/BaseObservableCollection.js @@ -31,6 +31,15 @@ export default class BaseObservableCollection { // Add iterator over handlers here } +// like an EventEmitter, but doesn't have an event type +export class ObservableValue extends BaseObservableCollection { + emit(argument) { + for (const h of this._handlers) { + h(argument); + } + } +} + export function tests() { class Collection extends BaseObservableCollection { constructor() { diff --git a/src/utils/DOMClock.js b/src/ui/web/dom/Clock.js similarity index 82% rename from src/utils/DOMClock.js rename to src/ui/web/dom/Clock.js index c943b109..a23d6c01 100644 --- a/src/utils/DOMClock.js +++ b/src/ui/web/dom/Clock.js @@ -1,6 +1,6 @@ -import {AbortError} from "./error.js"; +import {AbortError} from "../utils/error.js"; -class DOMTimeout { +class Timeout { constructor(ms) { this._reject = null; this._handle = null; @@ -27,7 +27,7 @@ class DOMTimeout { } } -class DOMTimeMeasure { +class TimeMeasure { constructor() { this._start = window.performance.now(); } @@ -37,13 +37,13 @@ class DOMTimeMeasure { } } -export class DOMClock { +export class Clock { createMeasure() { - return new DOMTimeMeasure(); + return new TimeMeasure(); } createTimeout(ms) { - return new DOMTimeout(ms); + return new Timeout(ms); } now() { diff --git a/src/ui/web/dom/Online.js b/src/ui/web/dom/Online.js new file mode 100644 index 00000000..0fa5ee7f --- /dev/null +++ b/src/ui/web/dom/Online.js @@ -0,0 +1,29 @@ +export class Online extends ObservableValue { + constructor() { + super(); + this._onOffline = this._onOffline.bind(this); + this._onOnline = this._onOnline.bind(this); + } + + _onOffline() { + this.emit(false); + } + + _onOnline() { + this.emit(true); + } + + get value() { + return navigator.onLine; + } + + onSubscribeFirst() { + window.addEventListener('offline', this._onOffline); + window.addEventListener('online', this._onOnline); + } + + onUnsubscribeLast() { + window.removeEventListener('offline', this._onOffline); + window.removeEventListener('online', this._onOnline); + } +} diff --git a/src/ui/web/general/SwitchView.js b/src/ui/web/general/SwitchView.js index 7789cb1e..6ab669e0 100644 --- a/src/ui/web/general/SwitchView.js +++ b/src/ui/web/general/SwitchView.js @@ -34,3 +34,38 @@ export default class SwitchView { return this._childView; } } + +// SessionLoadView +// should this be the new switch view? +// and the other one be the BasicSwitchView? +new BoundSwitchView(vm, vm => vm.isLoading, (loading, vm) => { + if (loading) { + return new InlineTemplateView(vm, t => { + return t.div({className: "loading"}, [ + t.span({className: "spinner"}), + t.span(vm => vm.loadingText) + ]); + }); + } else { + return new SessionView(vm.sessionViewModel); + } +}); + +class BoundSwitchView extends SwitchView { + constructor(value, mapper, viewCreator) { + super(viewCreator(mapper(value), value)); + this._mapper = mapper; + this._viewCreator = viewCreator; + this._mappedValue = mapper(value); + } + + update(value) { + const mappedValue = this._mapper(value); + if (mappedValue !== this._mappedValue) { + this._mappedValue = mappedValue; + this.switch(this._viewCreator(this._mappedValue, value)); + } else { + super.update(value); + } + } +} diff --git a/src/utils/Disposables.js b/src/utils/Disposables.js new file mode 100644 index 00000000..739fbe8b --- /dev/null +++ b/src/utils/Disposables.js @@ -0,0 +1,37 @@ +function disposeValue(value) { + if (typeof d === "function") { + value(); + } else { + value.dispose(); + } +} + +export class Disposables { + constructor() { + this._disposables = []; + } + + track(disposable) { + this._disposables.push(disposable); + } + + dispose() { + if (this._disposables) { + for (const d of this._disposables) { + disposeValue(d); + } + this._disposables = null; + } + } + + + disposeTracked(value) { + const idx = this._disposables.indexOf(value); + if (idx !== -1) { + const [foundValue] = this._disposables.splice(idx, 1); + disposeValue(foundValue); + return true; + } + return false; + } +}