diff --git a/doc/CSS.md b/doc/CSS.md new file mode 100644 index 00000000..7365ec5b --- /dev/null +++ b/doc/CSS.md @@ -0,0 +1,83 @@ +https://nio.chat/ looks nice. + +We could do top to bottom gradients in default avatars to make them look a bit cooler. Automatically generate them from a single color, e.g. from slightly lighter to slightly darker. + +## How to organize the CSS? + +Can take ideas/adopt from OOCSS and SMACSS. + +### Root + - maybe we should not assume `body` is the root, but rather a `.brawl` class. The root is where we'd set root level css variables, fonts?, etc. Should we scope all css to this root class? That could get painful with just vanilla css. We could use something like https://github.com/domwashburn/postcss-parent-selector to only do this at build time. Other useful plugin for postcss: https://github.com/postcss/postcss-selector-parser + +We would still you `rem` for size units though. + +### Class names + +#### View + - view name? + +#### Not quite a View + +Some things might not be a view, as they don't have their own view model. + + - a spinner, has .spinner for now + - avatar + +#### modifier classes + +are these modifiers? + - contrast-hi, contrast-mid, contrast-low + - font-large, font-medium, font-small + + - large, medium, small (for spinner and avatar) + - hidden: hides the element, can be useful when not wanting to use an if-binding to databind on a css class + - inline: can be applied to any item if it needs to look good in an inline layout + - flex: can be applied to any item if it is placed in a flex container. You'd combine this with some other class to set a `flex` that makes sense, e.g.: +```css +.spinner.flex, +.avatar.flex, +.icon.flex, +button.flex { + flex: 0; +} +``` +you could end up with a lot of these though? + +well... for flex we don't really need a class, as `flex` doesn't do anything if the parent is not a flex container. + +Modifier classes can be useful though. Should we prefix them? + +### Theming + +do we want as system with HSL or RGBA to define shades and contrasts? + +we could define colors as HS and have a separate value for L: + +``` +/* for dark theme */ +--lightness-mod: -1; +--accent-shade: 310, 70%; +/* then at every level */ +--lightness: 60%; +/* add/remove (based on dark theme) 20% lightness */ +--lightness: calc(var(--lightness) + calc(var(--lightness-mod) * 20%)); +--bg-color: hsl(var(-accent-shade), var(--lightness)); +``` + +this makes it easy to derive colors, but if there is no override with rga values, could be limiting. +I guess --fg-color and --bg-color can be those overrides? + +what theme color variables do we want? + + - accent color + - avatar/name colors + - background color (panels are shades of this?) + +Themes are specified as JSON and need javascript to be set. The JSON contains colors in rgb, the theme code will generate css variables containing shades as specified? Well, that could be custom theming, but built-in themes should have full css flexibility. + +what hierarchical variables do we want? + + - `--fg-color` (we use this instead of color so icons and borders can also take the color, we could use the `currentcolor` constant for this though!) + - `--bg-color` (we use this instead of background so icons and borders can also take the color) + - `--lightness` + - `--size` for things like spinner, avatar diff --git a/doc/impl-thoughts/CATCHUP-BACKFILL.md b/doc/impl-thoughts/CATCHUP-BACKFILL.md new file mode 100644 index 00000000..12c6ca3b --- /dev/null +++ b/doc/impl-thoughts/CATCHUP-BACKFILL.md @@ -0,0 +1,7 @@ +we should automatically fill gaps (capped at a certain (large) amount of events, 5000?) after a limited sync for a room + +## E2EE rooms + +during these fills (once supported), we should calculate push actions and trigger notifications, as we would otherwise have received this through sync. + +we could also trigger notifications when just backfilling on initial sync up to a certain amount of time in the past? diff --git a/doc/impl-thoughts/DESIGN.md b/doc/impl-thoughts/DESIGN.md new file mode 100644 index 00000000..5fc345a2 --- /dev/null +++ b/doc/impl-thoughts/DESIGN.md @@ -0,0 +1,3 @@ +use mock view models or even a mock session to render different states of the app in a static html document, where we can somehow easily tweak the css (just browser tools, or do something in the page?) how to persist css after changes? + +Also dialogs, forms, ... could be shown on this page. diff --git a/doc/impl-thoughts/LOCAL-ECHO-STATE.md b/doc/impl-thoughts/LOCAL-ECHO-STATE.md new file mode 100644 index 00000000..4d5a7215 --- /dev/null +++ b/doc/impl-thoughts/LOCAL-ECHO-STATE.md @@ -0,0 +1,16 @@ +# Local echo + +## Remote vs local state for account_data, etc ... + +For things like account data, and other requests that might fail, we could persist what we are sending next to the last remote version we have (with a flag for which one is remote and local, part of the key). E.g. for account data the key would be: [type, localOrRemoteFlag] + +localOrRemoteFlag would be 1 of 3: + - Remote + - (Local)Unsent + - (Local)Sent + +although we only want 1 remote and 1 local value for a given key, perhaps a second field where localOrRemoteFlag is a boolean, and a sent=boolean field as well? We need this to know if we need to retry. + +This will allow resending of these requests if needed. Once the request goes through, we remove the local version. + +then we can also see what the current value is with or without the pending local changes, and we don't have to wait for remote echo... diff --git a/doc/impl-thoughts/RECONNECTING.md b/doc/impl-thoughts/RECONNECTING.md index fb9ff506..857858e2 100644 --- a/doc/impl-thoughts/RECONNECTING.md +++ b/doc/impl-thoughts/RECONNECTING.md @@ -1 +1,79 @@ -# Reconnecting \ No newline at end of file +# Reconnecting + +`HomeServerApi` notifies `Reconnector` of network call failure + +`Reconnector` listens for online/offline event + +`Reconnector` polls `/versions` with a `RetryDelay` (implemented as ExponentialRetryDelay, also used by SendScheduler if no retry_after_ms is given) + +`Reconnector` emits an event when sync and message sending should retry + +`Sync` listen to `Reconnector` +`Sync` notifies when the catchup sync has happened + +`Reconnector` has state: + - disconnected (and retrying at x seconds from timestamp) + - reconnecting (call /versions, and if successful /sync) + - connected + +`Reconnector` has a method to try to connect now + +`SessionStatus` can be: + - disconnected (and retrying at x seconds from timestamp) + - reconnecting + - connected (and syncing) + + - doing catchup sync + - sending x / y messages + +rooms should report how many messages they have queued up, and each time they sent one? + +`SendReporter` (passed from `Session` to `Room`, passed down to `SendQueue`), with: + - setPendingEventCount(roomId, count). This should probably use the generic Room updating mechanism, e.g. a pendingMessageCount on Room that is updated. Then session listens for this in `_roomUpdateCallback`. + +`Session` listens to `Reconnector` to update it's status, but perhaps we wait to send messages until catchup sync is done + + +# TODO + + - DONE: finish (Base)ObservableValue + - put in own file + - add waitFor (won't this leak if the promise never resolves?) + - decide whether we want to inherit (no?) + - DONE: cleanup Reconnector with recent changes, move generic code, make imports work + - DONE: add SyncStatus as ObservableValue of enum in Sync + - DONE: cleanup SessionContainer + - DONE: move all imports to non-default + - DONE: remove #ifdef + - DONE: move EventEmitter to utils + - DONE: move all lower-cased files + - DONE: change main.js to pass in a creation function of a SessionContainer instead of everything it is replacing + - DONE: adjust BrawlViewModel, SessionPickViewModel and LoginViewModel to use a SessionContainer + - DONE: show load progress in LoginView/SessionPickView and do away with loading screen + - DONE: rename SessionsStore to SessionInfoStorage + - make sure we've renamed all \*State enums and fields to \*Status + - add pendingMessageCount prop to SendQueue and Room, aggregate this in Session + - DONE: add completedFirstSync to Sync, so we can check if the catchup or initial sync is still in progress + - DONE: update SyncStatusViewModel to use reconnector.connectionStatus, sync.completedFirstSync, session.syncToken (is initial sync?) and session.pendingMessageCount to show these messages: + - DONE: disconnected, retrying in x seconds. [try now]. + - DONE: reconnecting... + - DONE: doing catchup sync + - syncing, sending x messages + - DONE: syncing + + perhaps we will want to put this as an ObservableValue on the SessionContainer ? + + NO: When connected, syncing and not sending anything, just hide the thing for now? although when you send messages it will just pop in and out all the time. + + - see if it makes sense for SendScheduler to use the same RetryDelay as Reconnector + - DONE: finally adjust all file names to their class names? e.g. camel case + - see if we want more dependency injection + - for classes from outside sdk + - for internal sdk classes? probably not yet + + + + +thought: do we want to retry a request a couple of times when we can't reach the server before handing it over to the reconnector? Not that some requests may succeed while others may fail, like when matrix.org is really slow, some requests may timeout and others may not. Although starting a service like sync while it is still succeeding should be mostly fine. Perhaps we can pass a canRetry flag to the HomeServerApi that if we get a ConnectionError, we will retry. Only when the flag is not set, we'd call the Reconnector. The downside of this is that if 2 parts are doing requests, 1 retries and 1 does not, and the both requests fail, the other part of the code would still be retrying when the reconnector already kicked in. The HomeServerApi should perhaps tell the retryer if it should give up if a non-retrying request already caused the reconnector to kick in? + +CatchupSync should also use timeout 0, in case there is nothing to report we spend 30s with a catchup spinner. Riot-web sync also says something about using a 0 timeout until there are no more to_device messages as they are queued up by the server and not all returned at once if there are a lot? This is needed for crypto to be aware of all to_device messages. diff --git a/doc/impl-thoughts/RELATIONS.md b/doc/impl-thoughts/RELATIONS.md index b1146b6c..c7886024 100644 --- a/doc/impl-thoughts/RELATIONS.md +++ b/doc/impl-thoughts/RELATIONS.md @@ -10,3 +10,5 @@ alternatively, SyncWriter/SendQueue could have a section with updatedEntries apa SendQueue will need to pass the non-sent state (redactions & relations) about an event that has it's remote echo received to the SyncWriter so it doesn't flash while redactions and relations for it still have to be synced. Also, related ids should be processed recursively. If event 3 is a redaction of event 2, a reaction to event 1, all 3 entries should be considered as updated. + +As a UI for reactions, we could show (👍 14 + 1) where the + 1 is our own local echo (perhaps style it pulsating and/or in grey?). Clicking it again would just show 14 and when the remote echo comes in it would turn into 15. diff --git a/doc/impl-thoughts/VIEW-UPDATES.md b/doc/impl-thoughts/VIEW-UPDATES.md new file mode 100644 index 00000000..93a909f5 --- /dev/null +++ b/doc/impl-thoughts/VIEW-UPDATES.md @@ -0,0 +1,23 @@ +# View updates + +## Current situation + + - arguments of View.update are not standardized, it's either: + - name of property that was updated on viewmodel + - names of property that was updated on viewmodel + - map of updated values + - we have 2 update mechanisms: + - listening on viewmodel change event + - through ObservableCollection which parent view listens on and calls `update(newValue)` on the child view. This is an optimization to prevent every view in a collection to need to subscribe and unsubscribe to a viewmodel. + + - should updates on a template value propagate to subviews? + - either a view listens on the view model, ... + - or waits for updates from parent view: + - item view in a list view + - subtemplate (not needed, we could just have 2 subscriptions!!) + +ok, we always subscribe in a (sub)template. But for example RoomTile and it's viewmodel; RoomTileViewModel doesn't extend EventEmitter or ObservableValue today because it (would) emit(s) updates through the parent collection. So today it's view would not subscribe to it. But if it wants to extend ViewModel to have all the other infrastructure, you'd receive double updates. + +I think we might need to make it explicit whether or not the parent will provide updates for the children or not. Maybe as a mount() parameter? Yeah, I like that. ListView would pass in `true`. Most other things would pass in `false`/`undefined`. `Template` can then choose to bind or not based on that param. + +Should we make a base/subclass of Template that does not do event binding to save a few bytes in memory for the event subscription fields that are not needed? Not now, this is less ergonimic, and a small optimization. We can always do that later, and we'd just have to replace the base class of the few views that appear in a `ListView`. diff --git a/doc/impl-thoughts/html-messages.md b/doc/impl-thoughts/html-messages.md new file mode 100644 index 00000000..f217c82d --- /dev/null +++ b/doc/impl-thoughts/html-messages.md @@ -0,0 +1,4 @@ +message model: + - paragraphs (p, h1, code block, quote, ...) + - lines + - parts (inline markup), which can be recursive diff --git a/doc/impl-thoughts/session-container.md b/doc/impl-thoughts/session-container.md new file mode 100644 index 00000000..d84d8ce9 --- /dev/null +++ b/doc/impl-thoughts/session-container.md @@ -0,0 +1,18 @@ +what should this new container be called? + - Client + - SessionContainer + + +it is what is returned from bootstrapping a ... thing +it allows you to replace classes within the client through IoC? +it wires up the different components +it unwires the components when you're done with the thing +it could hold all the dependencies for setting up a client, even before login + - online detection api + - clock + - homeserver + - requestFn + +we'll be explicitly making its parts public though, like session, sync, reconnector + +merge the connectionstate and diff --git a/index.html b/index.html index 7778f94c..ae62da91 100644 --- a/index.html +++ b/index.html @@ -10,7 +10,7 @@ - + diff --git a/package.json b/package.json index 29c3a9a9..20c64e75 100644 --- a/package.json +++ b/package.json @@ -24,7 +24,7 @@ "devDependencies": { "cheerio": "^1.0.0-rc.3", "finalhandler": "^1.1.1", - "impunity": "^0.0.10", + "impunity": "^0.0.11", "postcss": "^7.0.18", "postcss-import": "^12.0.1", "rollup": "^1.15.6", diff --git a/src/Platform.js b/src/Platform.js index b68d821d..d8b4a7a9 100644 --- a/src/Platform.js +++ b/src/Platform.js @@ -1,5 +1 @@ -//#ifdef PLATFORM_GNOME -//##export {default} from "./ui/gnome/GnomePlatform.js"; -//#else -export {default} from "./ui/web/WebPlatform.js"; -//#endif +export {WebPlatform as Platform} from "./ui/web/WebPlatform.js"; diff --git a/src/domain/BrawlViewModel.js b/src/domain/BrawlViewModel.js index c9ebfa91..2b41b1e7 100644 --- a/src/domain/BrawlViewModel.js +++ b/src/domain/BrawlViewModel.js @@ -1,84 +1,80 @@ -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"; +import {SessionViewModel} from "./session/SessionViewModel.js"; +import {LoginViewModel} from "./LoginViewModel.js"; +import {SessionPickerViewModel} from "./SessionPickerViewModel.js"; +import {ViewModel} from "./ViewModel.js"; -export function createNewSessionId() { - return (Math.floor(Math.random() * Number.MAX_SAFE_INTEGER)).toString(); -} - -export default class BrawlViewModel extends EventEmitter { - constructor({storageFactory, sessionStore, createHsApi, clock}) { - super(); +export class BrawlViewModel extends ViewModel { + constructor(options) { + super(options); + const {createSessionContainer, sessionInfoStorage, storageFactory} = options; + this._createSessionContainer = createSessionContainer; + this._sessionInfoStorage = sessionInfoStorage; this._storageFactory = storageFactory; - this._sessionStore = sessionStore; - this._createHsApi = createHsApi; - this._clock = clock; - this._loading = false; this._error = null; this._sessionViewModel = null; this._loginViewModel = null; this._sessionPickerViewModel = null; + + this._sessionContainer = null; + this._sessionCallback = this._sessionCallback.bind(this); } async load() { - if (await this._sessionStore.hasAnySession()) { + if (await this._sessionInfoStorage.hasAnySession()) { this._showPicker(); } else { this._showLogin(); } } + _sessionCallback(sessionContainer) { + if (sessionContainer) { + this._setSection(() => { + this._sessionContainer = sessionContainer; + this._sessionViewModel = new SessionViewModel(this.childOptions({sessionContainer})); + this._sessionViewModel.start(); + }); + } else { + // switch between picker and login + if (this.activeSection === "login") { + this._showPicker(); + } else { + this._showLogin(); + } + } + } + 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({ + sessionInfoStorage: this._sessionInfoStorage, + storageFactory: this._storageFactory, + createSessionContainer: this._createSessionContainer, + sessionCallback: this._sessionCallback, + }); }); - 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({ + defaultHomeServer: "https://matrix.org", + createSessionContainer: this._createSessionContainer, + sessionCallback: this._sessionCallback, + }); + }) } - _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) { @@ -88,76 +84,24 @@ export default class BrawlViewModel extends EventEmitter { } } - get loadingText() { return this._loadingText; } + _setSection(setter) { + // clear all members the activeSection depends on + this._error = null; + this._sessionViewModel = null; + this._loginViewModel = null; + this._sessionPickerViewModel = null; + + if (this._sessionContainer) { + this._sessionContainer.stop(); + this._sessionContainer = null; + } + // now set it again + setter(); + this.emitChange("activeSection"); + } + + get error() { return this._error; } 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 = createNewSessionId(); - const sessionInfo = { - id: sessionId, - deviceId: loginData.device_id, - userId: loginData.user_id, - homeServer: loginData.homeServerUrl, - 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._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}); - - 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"); - } } diff --git a/src/domain/LoginViewModel.js b/src/domain/LoginViewModel.js index 5d0a9ef2..0393582c 100644 --- a/src/domain/LoginViewModel.js +++ b/src/domain/LoginViewModel.js @@ -1,39 +1,66 @@ -import EventEmitter from "../EventEmitter.js"; +import {ViewModel} from "./ViewModel.js"; +import {SessionLoadViewModel} from "./SessionLoadViewModel.js"; -export default class LoginViewModel extends EventEmitter { - constructor({loginCallback, defaultHomeServer, createHsApi}) { - super(); - this._loginCallback = loginCallback; +export class LoginViewModel extends ViewModel { + constructor(options) { + super(options); + const {sessionCallback, defaultHomeServer, createSessionContainer} = options; + this._createSessionContainer = createSessionContainer; + this._sessionCallback = sessionCallback; this._defaultHomeServer = defaultHomeServer; - this._createHsApi = createHsApi; - this._loading = false; - this._error = null; + this._loadViewModel = null; + this._loadViewModelSubscription = 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(); - loginData.homeServerUrl = homeserver; - this._loginCallback(loginData); - // wait for parent view model to switch away here - } catch (err) { - this._error = err; - this._loading = false; - this.emit("change", "loading"); + get loadViewModel() {return this._loadViewModel; } + + get isBusy() { + if (!this._loadViewModel) { + return false; + } else { + return this._loadViewModel.loading; } } + async login(username, password, homeserver) { + this._loadViewModelSubscription = this.disposeTracked(this._loadViewModelSubscription); + if (this._loadViewModel) { + this._loadViewModel.cancel(); + } + this._loadViewModel = new SessionLoadViewModel({ + createAndStartSessionContainer: () => { + const sessionContainer = this._createSessionContainer(); + sessionContainer.startWithLogin(homeserver, username, password); + return sessionContainer; + }, + sessionCallback: sessionContainer => { + if (sessionContainer) { + // make parent view model move away + this._sessionCallback(sessionContainer); + } else { + // show list of session again + this._loadViewModel = null; + this.emitChange("loadViewModel"); + } + }, + deleteSessionOnCancel: true, + homeserver, + }); + this._loadViewModel.start(); + this.emitChange("loadViewModel"); + this._loadViewModelSubscription = this.track(this._loadViewModel.disposableOn("change", () => { + if (!this._loadViewModel.loading) { + this._loadViewModelSubscription = this.disposeTracked(this._loadViewModelSubscription); + } + this.emitChange("isBusy"); + })); + } + cancel() { - this._loginCallback(); + if (!this.isBusy) { + this._sessionCallback(); + } } } diff --git a/src/domain/SessionLoadViewModel.js b/src/domain/SessionLoadViewModel.js new file mode 100644 index 00000000..e4a1fc0a --- /dev/null +++ b/src/domain/SessionLoadViewModel.js @@ -0,0 +1,120 @@ +import {LoadStatus, LoginFailure} from "../matrix/SessionContainer.js"; +import {SyncStatus} from "../matrix/Sync.js"; +import {ViewModel} from "./ViewModel.js"; + +export class SessionLoadViewModel extends ViewModel { + constructor(options) { + super(options); + const {createAndStartSessionContainer, sessionCallback, homeserver, deleteSessionOnCancel} = options; + this._createAndStartSessionContainer = createAndStartSessionContainer; + this._sessionCallback = sessionCallback; + this._homeserver = homeserver; + this._deleteSessionOnCancel = deleteSessionOnCancel; + this._loading = false; + this._error = null; + } + + async start() { + if (this._loading) { + return; + } + try { + this._loading = true; + this.emitChange(); + this._sessionContainer = this._createAndStartSessionContainer(); + this._waitHandle = this._sessionContainer.loadStatus.waitFor(s => { + this.emitChange(); + // wait for initial sync, but not catchup sync + const isCatchupSync = s === LoadStatus.FirstSync && + this._sessionContainer.sync.status.get() === SyncStatus.CatchupSync; + return isCatchupSync || + s === LoadStatus.LoginFailed || + s === LoadStatus.Error || + s === LoadStatus.Ready; + }); + try { + await this._waitHandle.promise; + } catch (err) { + return; // aborted by goBack + } + // TODO: should we deal with no connection during initial sync + // and we're retrying as well here? + // e.g. show in the label what is going on wrt connectionstatus + // much like we will once you are in the app. Probably a good idea + + // did it finish or get stuck at LoginFailed or Error? + const loadStatus = this._sessionContainer.loadStatus.get(); + if (loadStatus === LoadStatus.FirstSync || loadStatus === LoadStatus.Ready) { + this._sessionCallback(this._sessionContainer); + } + } catch (err) { + this._error = err; + } finally { + this._loading = false; + this.emitChange(); + } + } + + + async cancel() { + try { + if (this._sessionContainer) { + this._sessionContainer.stop(); + if (this._deleteSessionOnCancel) { + await this._sessionContainer.deletSession(); + } + this._sessionContainer = null; + } + if (this._waitHandle) { + // rejects with AbortError + this._waitHandle.dispose(); + this._waitHandle = null; + } + this._sessionCallback(); + } catch (err) { + this._error = err; + this.emitChange(); + } + } + + // to show a spinner or not + get loading() { + return this._loading; + } + + get loadLabel() { + const sc = this._sessionContainer; + const error = this._error || (sc && sc.loadError); + + if (error || (sc && sc.loadStatus.get() === LoadStatus.Error)) { + return `Something went wrong: ${error && error.message}.`; + } + + if (sc) { + switch (sc.loadStatus.get()) { + case LoadStatus.NotLoading: + return `Preparing…`; + case LoadStatus.Login: + return `Checking your login and password…`; + case LoadStatus.LoginFailed: + switch (sc.loginFailure) { + case LoginFailure.LoginFailure: + return `Your username and/or password don't seem to be correct.`; + case LoginFailure.Connection: + return `Can't connect to ${this._homeserver}.`; + case LoginFailure.Unknown: + return `Something went wrong while checking your login and password.`; + } + break; + case LoadStatus.Loading: + return `Loading your conversations…`; + case LoadStatus.FirstSync: + return `Getting your conversations from the server…`; + default: + return this._sessionContainer.loadStatus.get(); + } + } + + return `Preparing…`; + } +} diff --git a/src/domain/SessionPickerViewModel.js b/src/domain/SessionPickerViewModel.js index 97efdcf3..78cb5bac 100644 --- a/src/domain/SessionPickerViewModel.js +++ b/src/domain/SessionPickerViewModel.js @@ -1,10 +1,10 @@ import {SortedArray} from "../observable/index.js"; -import EventEmitter from "../EventEmitter.js"; -import {createNewSessionId} from "./BrawlViewModel.js" +import {SessionLoadViewModel} from "./SessionLoadViewModel.js"; +import {ViewModel} from "./ViewModel.js"; -class SessionItemViewModel extends EventEmitter { +class SessionItemViewModel extends ViewModel { constructor(sessionInfo, pickerVM) { - super(); + super({}); this._pickerVM = pickerVM; this._sessionInfo = sessionInfo; this._isDeleting = false; @@ -19,31 +19,31 @@ class SessionItemViewModel extends EventEmitter { async delete() { this._isDeleting = true; - this.emit("change", "isDeleting"); + this.emitChange("isDeleting"); try { await this._pickerVM.delete(this.id); } catch(err) { this._error = err; console.error(err); - this.emit("change", "error"); + this.emitChange("error"); } finally { this._isDeleting = false; - this.emit("change", "isDeleting"); + this.emitChange("isDeleting"); } } async clear() { this._isClearing = true; - this.emit("change"); + this.emitChange(); try { await this._pickerVM.clear(this.id); } catch(err) { this._error = err; console.error(err); - this.emit("change", "error"); + this.emitChange("error"); } finally { this._isClearing = false; - this.emit("change", "isClearing"); + this.emitChange("isClearing"); } } @@ -82,7 +82,7 @@ class SessionItemViewModel extends EventEmitter { const json = JSON.stringify(data, undefined, 2); const blob = new Blob([json], {type: "application/json"}); this._exportDataUrl = URL.createObjectURL(blob); - this.emit("change", "exportDataUrl"); + this.emitChange("exportDataUrl"); } catch (err) { alert(err.message); console.error(err); @@ -93,33 +93,66 @@ class SessionItemViewModel extends EventEmitter { if (this._exportDataUrl) { URL.revokeObjectURL(this._exportDataUrl); this._exportDataUrl = null; - this.emit("change", "exportDataUrl"); + this.emitChange("exportDataUrl"); } } } -export default class SessionPickerViewModel { - constructor({storageFactory, sessionStore, sessionCallback}) { + +export class SessionPickerViewModel extends ViewModel { + constructor(options) { + super(options); + const {storageFactory, sessionInfoStorage, sessionCallback, createSessionContainer} = options; this._storageFactory = storageFactory; - this._sessionStore = sessionStore; + this._sessionInfoStorage = sessionInfoStorage; this._sessionCallback = sessionCallback; + this._createSessionContainer = createSessionContainer; this._sessions = new SortedArray((s1, s2) => s1.id.localeCompare(s2.id)); + this._loadViewModel = null; + this._error = null; } + // this loads all the sessions async load() { - const sessions = await this._sessionStore.getAll(); + const sessions = await this._sessionInfoStorage.getAll(); this._sessions.setManyUnsorted(sessions.map(s => new SessionItemViewModel(s, this))); } - pick(id) { + // for the loading of 1 picked session + get loadViewModel() { + return this._loadViewModel; + } + + async pick(id) { + if (this._loadViewModel) { + return; + } const sessionVM = this._sessions.array.find(s => s.id === id); if (sessionVM) { - this._sessionCallback(sessionVM.sessionInfo); + this._loadViewModel = new SessionLoadViewModel({ + createAndStartSessionContainer: () => { + const sessionContainer = this._createSessionContainer(); + sessionContainer.startWithExistingSession(sessionVM.id); + return sessionContainer; + }, + sessionCallback: sessionContainer => { + if (sessionContainer) { + // make parent view model move away + this._sessionCallback(sessionContainer); + } else { + // show list of session again + this._loadViewModel = null; + this.emitChange("loadViewModel"); + } + } + }); + this._loadViewModel.start(); + this.emitChange("loadViewModel"); } } async _exportData(id) { - const sessionInfo = await this._sessionStore.get(id); + const sessionInfo = await this._sessionInfoStorage.get(id); const stores = await this._storageFactory.export(id); const data = {sessionInfo, stores}; return data; @@ -129,15 +162,15 @@ export default class SessionPickerViewModel { const data = JSON.parse(json); const {sessionInfo} = data; sessionInfo.comment = `Imported on ${new Date().toLocaleString()} from id ${sessionInfo.id}.`; - sessionInfo.id = createNewSessionId(); + sessionInfo.id = this._createSessionContainer().createNewSessionId(); await this._storageFactory.import(sessionInfo.id, data.stores); - await this._sessionStore.add(sessionInfo); + await this._sessionInfoStorage.add(sessionInfo); this._sessions.set(new SessionItemViewModel(sessionInfo, this)); } async delete(id) { const idx = this._sessions.array.findIndex(s => s.id === id); - await this._sessionStore.delete(id); + await this._sessionInfoStorage.delete(id); await this._storageFactory.delete(id); this._sessions.remove(idx); } @@ -151,6 +184,8 @@ export default class SessionPickerViewModel { } cancel() { - this._sessionCallback(); + if (!this._loadViewModel) { + this._sessionCallback(); + } } } diff --git a/src/domain/ViewModel.js b/src/domain/ViewModel.js new file mode 100644 index 00000000..7e08f8d3 --- /dev/null +++ b/src/domain/ViewModel.js @@ -0,0 +1,64 @@ +// ViewModel should just be an eventemitter, not an ObservableValue +// as in some cases it would really be more convenient to have multiple events (like telling the timeline to scroll down) +// we do need to return a disposable from EventEmitter.on, or at least have a method here to easily track a subscription to an EventEmitter + +import {EventEmitter} from "../utils/EventEmitter.js"; +import {Disposables} from "../utils/Disposables.js"; + +export class ViewModel extends EventEmitter { + constructor({clock} = {}) { + super(); + this.disposables = null; + this._options = {clock}; + } + + childOptions(explicitOptions) { + return Object.assign({}, this._options, explicitOptions); + } + + track(disposable) { + if (!this.disposables) { + this.disposables = new Disposables(); + } + this.disposables.track(disposable); + return disposable; + } + + dispose() { + if (this.disposables) { + this.disposables.dispose(); + } + } + + disposeTracked(disposable) { + if (this.disposables) { + return this.disposables.disposeTracked(disposable); + } + return null; + } + + // TODO: this will need to support binding + // if any of the expr is a function, assume the function is a binding, and return a binding function ourselves + // + // translated string should probably always be bindings, unless we're fine with a refresh when changing the language? + // we probably are, if we're using routing with a url, we could just refresh. + i18n(parts, ...expr) { + // just concat for now + let result = ""; + for (let i = 0; i < parts.length; ++i) { + result = result + parts[i]; + if (i < expr.length) { + result = result + expr[i]; + } + } + return result; + } + + emitChange(changedProps) { + this.emit("change", changedProps); + } + + get clock() { + return this._options.clock; + } +} diff --git a/src/domain/session/SessionStatusViewModel.js b/src/domain/session/SessionStatusViewModel.js new file mode 100644 index 00000000..20864255 --- /dev/null +++ b/src/domain/session/SessionStatusViewModel.js @@ -0,0 +1,114 @@ +import {ViewModel} from "../ViewModel.js"; +import {createEnum} from "../../utils/enum.js"; +import {ConnectionStatus} from "../../matrix/net/Reconnector.js"; +import {SyncStatus} from "../../matrix/Sync.js"; + +const SessionStatus = createEnum( + "Disconnected", + "Connecting", + "FirstSync", + "Sending", + "Syncing", + "SyncError" +); + +export class SessionStatusViewModel extends ViewModel { + constructor(options) { + super(options); + const {sync, reconnector} = options; + this._sync = sync; + this._reconnector = reconnector; + this._status = this._calculateState(reconnector.connectionStatus.get(), sync.status.get()); + + } + + start() { + const update = () => this._updateStatus(); + this.track(this._sync.status.subscribe(update)); + this.track(this._reconnector.connectionStatus.subscribe(update)); + } + + get isShown() { + return this._status !== SessionStatus.Syncing; + } + + get statusLabel() { + switch (this._status) { + case SessionStatus.Disconnected:{ + const retryIn = Math.round(this._reconnector.retryIn / 1000); + return this.i18n`Disconnected, trying to reconnect in ${retryIn}s…`; + } + case SessionStatus.Connecting: + return this.i18n`Trying to reconnect now…`; + case SessionStatus.FirstSync: + return this.i18n`Catching up with your conversations…`; + case SessionStatus.SyncError: + return this.i18n`Sync failed because of ${this._sync.error}`; + } + return ""; + } + + get isWaiting() { + switch (this._status) { + case SessionStatus.Connecting: + case SessionStatus.FirstSync: + return true; + default: + return false; + } + } + + _updateStatus() { + const newStatus = this._calculateState( + this._reconnector.connectionStatus.get(), + this._sync.status.get() + ); + if (newStatus !== this._status) { + if (newStatus === SessionStatus.Disconnected) { + this._retryTimer = this.track(this.clock.createInterval(() => { + this.emitChange("statusLabel"); + }, 1000)); + } else { + this._retryTimer = this.disposeTracked(this._retryTimer); + } + this._status = newStatus; + console.log("newStatus", newStatus); + this.emitChange(); + } + } + + _calculateState(connectionStatus, syncStatus) { + if (connectionStatus !== ConnectionStatus.Online) { + switch (connectionStatus) { + case ConnectionStatus.Reconnecting: + return SessionStatus.Connecting; + case ConnectionStatus.Waiting: + return SessionStatus.Disconnected; + } + } else if (syncStatus !== SyncStatus.Syncing) { + switch (syncStatus) { + // InitialSync should be awaited in the SessionLoadViewModel, + // but include it here anyway + case SyncStatus.InitialSync: + case SyncStatus.CatchupSync: + return SessionStatus.FirstSync; + case SyncStatus.Stopped: + return SessionStatus.SyncError; + } + } /* else if (session.pendingMessageCount) { + return SessionStatus.Sending; + } */ else { + return SessionStatus.Syncing; + } + } + + get isConnectNowShown() { + return this._status === SessionStatus.Disconnected; + } + + connectNow() { + if (this.isConnectNowShown) { + this._reconnector.tryNow(); + } + } +} diff --git a/src/domain/session/SessionViewModel.js b/src/domain/session/SessionViewModel.js index 28bc1277..f014f362 100644 --- a/src/domain/session/SessionViewModel.js +++ b/src/domain/session/SessionViewModel.js @@ -1,13 +1,17 @@ -import EventEmitter from "../../EventEmitter.js"; -import RoomTileViewModel from "./roomlist/RoomTileViewModel.js"; -import RoomViewModel from "./room/RoomViewModel.js"; -import SyncStatusViewModel from "./SyncStatusViewModel.js"; +import {RoomTileViewModel} from "./roomlist/RoomTileViewModel.js"; +import {RoomViewModel} from "./room/RoomViewModel.js"; +import {SessionStatusViewModel} from "./SessionStatusViewModel.js"; +import {ViewModel} from "../ViewModel.js"; -export default class SessionViewModel extends EventEmitter { - constructor({session, sync}) { - super(); - this._session = session; - this._syncStatusViewModel = new SyncStatusViewModel(sync); +export class SessionViewModel extends ViewModel { + constructor(options) { + super(options); + const {sessionContainer} = options; + this._session = sessionContainer.session; + this._sessionStatusViewModel = this.track(new SessionStatusViewModel(this.childOptions({ + sync: sessionContainer.sync, + reconnector: sessionContainer.reconnector + }))); this._currentRoomViewModel = null; const roomTileVMs = this._session.rooms.mapValues((room, emitUpdate) => { return new RoomTileViewModel({ @@ -19,8 +23,12 @@ export default class SessionViewModel extends EventEmitter { this._roomList = roomTileVMs.sortValues((a, b) => a.compare(b)); } - get syncStatusViewModel() { - return this._syncStatusViewModel; + start() { + this._sessionStatusViewModel.start(); + } + + get sessionStatusViewModel() { + return this._sessionStatusViewModel; } get roomList() { @@ -33,23 +41,22 @@ export default class SessionViewModel extends EventEmitter { _closeCurrentRoom() { if (this._currentRoomViewModel) { - this._currentRoomViewModel.dispose(); - this._currentRoomViewModel = null; - this.emit("change", "currentRoom"); + this._currentRoomViewModel = this.disposeTracked(this._currentRoomViewModel); + this.emitChange("currentRoom"); } } _openRoom(room) { if (this._currentRoomViewModel) { - this._currentRoomViewModel.dispose(); + this._currentRoomViewModel = this.disposeTracked(this._currentRoomViewModel); } - this._currentRoomViewModel = new RoomViewModel({ + this._currentRoomViewModel = this.track(new RoomViewModel(this.childOptions({ room, ownUserId: this._session.user.id, closeCallback: () => this._closeCurrentRoom(), - }); + }))); this._currentRoomViewModel.load(); - this.emit("change", "currentRoom"); + this.emitChange("currentRoom"); } } diff --git a/src/domain/session/SyncStatusViewModel.js b/src/domain/session/SyncStatusViewModel.js deleted file mode 100644 index ef227873..00000000 --- a/src/domain/session/SyncStatusViewModel.js +++ /dev/null @@ -1,51 +0,0 @@ -import EventEmitter from "../../EventEmitter.js"; - -export default class SyncStatusViewModel extends EventEmitter { - constructor(sync) { - super(); - this._sync = sync; - this._onStatus = this._onStatus.bind(this); - } - - _onStatus(status, err) { - if (status === "error") { - this._error = err; - } else if (status === "started") { - this._error = null; - } - this.emit("change"); - } - - onFirstSubscriptionAdded(name) { - if (name === "change") { - this._sync.on("status", this._onStatus); - } - } - - onLastSubscriptionRemoved(name) { - if (name === "change") { - this._sync.on("status", this._onStatus); - } - } - - trySync() { - this._sync.start(); - this.emit("change"); - } - - get status() { - if (!this.isSyncing) { - if (this._error) { - return `Error while syncing: ${this._error.message}`; - } else { - return "Sync stopped"; - } - } else { - return "Sync running"; - } - } - - get isSyncing() { - return this._sync.isSyncing; - } -} diff --git a/src/domain/session/room/RoomViewModel.js b/src/domain/session/room/RoomViewModel.js index 19a58944..2d585a0d 100644 --- a/src/domain/session/room/RoomViewModel.js +++ b/src/domain/session/room/RoomViewModel.js @@ -1,10 +1,11 @@ -import EventEmitter from "../../../EventEmitter.js"; -import TimelineViewModel from "./timeline/TimelineViewModel.js"; +import {TimelineViewModel} from "./timeline/TimelineViewModel.js"; import {avatarInitials} from "../avatar.js"; +import {ViewModel} from "../../ViewModel.js"; -export default class RoomViewModel extends EventEmitter { - constructor({room, ownUserId, closeCallback}) { - super(); +export class RoomViewModel extends ViewModel { + constructor(options) { + super(options); + const {room, ownUserId, closeCallback} = options; this._room = room; this._ownUserId = ownUserId; this._timeline = null; @@ -13,18 +14,23 @@ export default class RoomViewModel extends EventEmitter { this._timelineError = null; this._sendError = null; this._closeCallback = closeCallback; + this._composerVM = new ComposerViewModel(this); } async load() { this._room.on("change", this._onRoomChange); try { this._timeline = await this._room.openTimeline(); - this._timelineVM = new TimelineViewModel(this._room, this._timeline, this._ownUserId); - this.emit("change", "timelineViewModel"); + this._timelineVM = new TimelineViewModel(this.childOptions({ + room: this._room, + timeline: this._timeline, + ownUserId: this._ownUserId, + })); + this.emitChange("timelineViewModel"); } catch (err) { console.error(`room.openTimeline(): ${err.message}:\n${err.stack}`); this._timelineError = err; - this.emit("change", "error"); + this.emitChange("error"); } } @@ -43,7 +49,7 @@ export default class RoomViewModel extends EventEmitter { // room doesn't tell us yet which fields changed, // so emit all fields originating from summary _onRoomChange() { - this.emit("change", "name"); + this.emitChange("name"); } get name() { @@ -68,7 +74,9 @@ export default class RoomViewModel extends EventEmitter { return avatarInitials(this._room.name); } - async sendMessage(message) { + + + async _sendMessage(message) { if (message) { try { await this._room.sendEvent("m.room.message", {msgtype: "m.text", body: message}); @@ -76,11 +84,25 @@ export default class RoomViewModel extends EventEmitter { console.error(`room.sendMessage(): ${err.message}:\n${err.stack}`); this._sendError = err; this._timelineError = null; - this.emit("change", "error"); + this.emitChange("error"); return false; } return true; } return false; } + + get composerViewModel() { + return this._composerVM; + } +} + +class ComposerViewModel { + constructor(roomVM) { + this._roomVM = roomVM; + } + + sendMessage(message) { + return this._roomVM._sendMessage(message); + } } diff --git a/src/domain/session/room/timeline/TilesCollection.js b/src/domain/session/room/timeline/TilesCollection.js index cb2621d6..ce96f00e 100644 --- a/src/domain/session/room/timeline/TilesCollection.js +++ b/src/domain/session/room/timeline/TilesCollection.js @@ -1,12 +1,12 @@ -import BaseObservableList from "../../../../observable/list/BaseObservableList.js"; -import sortedIndex from "../../../../utils/sortedIndex.js"; +import {BaseObservableList} from "../../../../observable/list/BaseObservableList.js"; +import {sortedIndex} from "../../../../utils/sortedIndex.js"; // maps 1..n entries to 0..1 tile. Entries are what is stored in the timeline, either an event or fragmentboundary // for now, tileCreator should be stable in whether it returns a tile or not. // e.g. the decision to create a tile or not should be based on properties // not updated later on (e.g. event type) // also see big comment in onUpdate -export default class TilesCollection extends BaseObservableList { +export class TilesCollection extends BaseObservableList { constructor(entries, tileCreator) { super(); this._entries = entries; @@ -187,8 +187,8 @@ export default class TilesCollection extends BaseObservableList { } } -import ObservableArray from "../../../../observable/list/ObservableArray.js"; -import UpdateAction from "./UpdateAction.js"; +import {ObservableArray} from "../../../../observable/list/ObservableArray.js"; +import {UpdateAction} from "./UpdateAction.js"; export function tests() { class TestTile { diff --git a/src/domain/session/room/timeline/TimelineViewModel.js b/src/domain/session/room/timeline/TimelineViewModel.js index 2e952a76..4550d7bd 100644 --- a/src/domain/session/room/timeline/TimelineViewModel.js +++ b/src/domain/session/room/timeline/TimelineViewModel.js @@ -14,11 +14,11 @@ the timeline (counted in tiles), which results to a range in sortKeys we want on to the room timeline, which unload entries from memory. when loading, it just reads events from a sortkey backwards or forwards... */ -import TilesCollection from "./TilesCollection.js"; -import tilesCreator from "./tilesCreator.js"; +import {TilesCollection} from "./TilesCollection.js"; +import {tilesCreator} from "./tilesCreator.js"; -export default class TimelineViewModel { - constructor(room, timeline, ownUserId) { +export class TimelineViewModel { + constructor({room, timeline, ownUserId}) { this._timeline = timeline; // once we support sending messages we could do // timeline.entries.concat(timeline.pendingEvents) diff --git a/src/domain/session/room/timeline/UpdateAction.js b/src/domain/session/room/timeline/UpdateAction.js index 1421cbd6..ed09560f 100644 --- a/src/domain/session/room/timeline/UpdateAction.js +++ b/src/domain/session/room/timeline/UpdateAction.js @@ -1,4 +1,4 @@ -export default class UpdateAction { +export class UpdateAction { constructor(remove, update, updateParams) { this._remove = remove; this._update = update; diff --git a/src/domain/session/room/timeline/tiles/GapTile.js b/src/domain/session/room/timeline/tiles/GapTile.js index 8e4fa0f4..b0aaca95 100644 --- a/src/domain/session/room/timeline/tiles/GapTile.js +++ b/src/domain/session/room/timeline/tiles/GapTile.js @@ -1,7 +1,7 @@ -import SimpleTile from "./SimpleTile.js"; -import UpdateAction from "../UpdateAction.js"; +import {SimpleTile} from "./SimpleTile.js"; +import {UpdateAction} from "../UpdateAction.js"; -export default class GapTile extends SimpleTile { +export class GapTile extends SimpleTile { constructor(options, timeline) { super(options); this._timeline = timeline; diff --git a/src/domain/session/room/timeline/tiles/ImageTile.js b/src/domain/session/room/timeline/tiles/ImageTile.js index ebe8d022..8aee454b 100644 --- a/src/domain/session/room/timeline/tiles/ImageTile.js +++ b/src/domain/session/room/timeline/tiles/ImageTile.js @@ -1,6 +1,6 @@ -import MessageTile from "./MessageTile.js"; +import {MessageTile} from "./MessageTile.js"; -export default class ImageTile extends MessageTile { +export class ImageTile extends MessageTile { constructor(options) { super(options); diff --git a/src/domain/session/room/timeline/tiles/LocationTile.js b/src/domain/session/room/timeline/tiles/LocationTile.js index 69dbc629..4a176233 100644 --- a/src/domain/session/room/timeline/tiles/LocationTile.js +++ b/src/domain/session/room/timeline/tiles/LocationTile.js @@ -1,4 +1,4 @@ -import MessageTile from "./MessageTile.js"; +import {MessageTile} from "./MessageTile.js"; /* map urls: @@ -7,7 +7,7 @@ android: https://developers.google.com/maps/documentation/urls/guide wp: maps:49.275267 -122.988617 https://www.habaneroconsulting.com/stories/insights/2011/opening-native-map-apps-from-the-mobile-browser */ -export default class LocationTile extends MessageTile { +export class LocationTile extends MessageTile { get mapsLink() { const geoUri = this._getContent().geo_uri; const [lat, long] = geoUri.split(":")[1].split(","); diff --git a/src/domain/session/room/timeline/tiles/MessageTile.js b/src/domain/session/room/timeline/tiles/MessageTile.js index bb862047..0ca74128 100644 --- a/src/domain/session/room/timeline/tiles/MessageTile.js +++ b/src/domain/session/room/timeline/tiles/MessageTile.js @@ -1,6 +1,6 @@ -import SimpleTile from "./SimpleTile.js"; +import {SimpleTile} from "./SimpleTile.js"; -export default class MessageTile extends SimpleTile { +export class MessageTile extends SimpleTile { constructor(options) { super(options); diff --git a/src/domain/session/room/timeline/tiles/RoomMemberTile.js b/src/domain/session/room/timeline/tiles/RoomMemberTile.js index f841a2ed..536cbeb5 100644 --- a/src/domain/session/room/timeline/tiles/RoomMemberTile.js +++ b/src/domain/session/room/timeline/tiles/RoomMemberTile.js @@ -1,6 +1,6 @@ -import SimpleTile from "./SimpleTile.js"; +import {SimpleTile} from "./SimpleTile.js"; -export default class RoomNameTile extends SimpleTile { +export class RoomMemberTile extends SimpleTile { get shape() { return "announcement"; diff --git a/src/domain/session/room/timeline/tiles/RoomNameTile.js b/src/domain/session/room/timeline/tiles/RoomNameTile.js index 36ad7934..d37255ae 100644 --- a/src/domain/session/room/timeline/tiles/RoomNameTile.js +++ b/src/domain/session/room/timeline/tiles/RoomNameTile.js @@ -1,6 +1,6 @@ -import SimpleTile from "./SimpleTile.js"; +import {SimpleTile} from "./SimpleTile.js"; -export default class RoomNameTile extends SimpleTile { +export class RoomNameTile extends SimpleTile { get shape() { return "announcement"; diff --git a/src/domain/session/room/timeline/tiles/SimpleTile.js b/src/domain/session/room/timeline/tiles/SimpleTile.js index 7b1ed91f..da5ba575 100644 --- a/src/domain/session/room/timeline/tiles/SimpleTile.js +++ b/src/domain/session/room/timeline/tiles/SimpleTile.js @@ -1,6 +1,6 @@ -import UpdateAction from "../UpdateAction.js"; +import {UpdateAction} from "../UpdateAction.js"; -export default class SimpleTile { +export class SimpleTile { constructor({entry}) { this._entry = entry; this._emitUpdate = null; diff --git a/src/domain/session/room/timeline/tiles/TextTile.js b/src/domain/session/room/timeline/tiles/TextTile.js index 47680ef0..a6144b1b 100644 --- a/src/domain/session/room/timeline/tiles/TextTile.js +++ b/src/domain/session/room/timeline/tiles/TextTile.js @@ -1,6 +1,6 @@ -import MessageTile from "./MessageTile.js"; +import {MessageTile} from "./MessageTile.js"; -export default class TextTile extends MessageTile { +export class TextTile extends MessageTile { get text() { const content = this._getContent(); const body = content && content.body; diff --git a/src/domain/session/room/timeline/tilesCreator.js b/src/domain/session/room/timeline/tilesCreator.js index 87d70238..9f53a378 100644 --- a/src/domain/session/room/timeline/tilesCreator.js +++ b/src/domain/session/room/timeline/tilesCreator.js @@ -1,10 +1,10 @@ -import GapTile from "./tiles/GapTile.js"; -import TextTile from "./tiles/TextTile.js"; -import LocationTile from "./tiles/LocationTile.js"; -import RoomNameTile from "./tiles/RoomNameTile.js"; -import RoomMemberTile from "./tiles/RoomMemberTile.js"; +import {GapTile} from "./tiles/GapTile.js"; +import {TextTile} from "./tiles/TextTile.js"; +import {LocationTile} from "./tiles/LocationTile.js"; +import {RoomNameTile} from "./tiles/RoomNameTile.js"; +import {RoomMemberTile} from "./tiles/RoomMemberTile.js"; -export default function ({room, ownUserId}) { +export function tilesCreator({room, ownUserId}) { return function tilesCreator(entry, emitUpdate) { const options = {entry, emitUpdate, ownUserId}; if (entry.isGap) { diff --git a/src/domain/session/roomlist/RoomTileViewModel.js b/src/domain/session/roomlist/RoomTileViewModel.js index 110b88b2..cd12242f 100644 --- a/src/domain/session/roomlist/RoomTileViewModel.js +++ b/src/domain/session/roomlist/RoomTileViewModel.js @@ -1,6 +1,6 @@ import {avatarInitials} from "../avatar.js"; -export default class RoomTileViewModel { +export class RoomTileViewModel { // we use callbacks to parent VM instead of emit because // it would be annoying to keep track of subscriptions in // parent for all RoomTileViewModels diff --git a/src/main.js b/src/main.js index 9e973bb9..7c8d46b6 100644 --- a/src/main.js +++ b/src/main.js @@ -1,10 +1,12 @@ -import HomeServerApi from "./matrix/hs-api.js"; -// import {RecordRequester, ReplayRequester} from "./matrix/net/replay.js"; -import fetchRequest from "./matrix/net/fetch.js"; -import StorageFactory from "./matrix/storage/idb/create.js"; -import SessionsStore from "./matrix/sessions-store/localstorage/SessionsStore.js"; -import BrawlViewModel from "./domain/BrawlViewModel.js"; -import BrawlView from "./ui/web/BrawlView.js"; +// import {RecordRequester, ReplayRequester} from "./matrix/net/request/replay.js"; +import {fetchRequest} from "./matrix/net/request/fetch.js"; +import {SessionContainer} from "./matrix/SessionContainer.js"; +import {StorageFactory} from "./matrix/storage/idb/StorageFactory.js"; +import {SessionInfoStorage} from "./matrix/sessioninfo/localstorage/SessionInfoStorage.js"; +import {BrawlViewModel} from "./domain/BrawlViewModel.js"; +import {BrawlView} from "./ui/web/BrawlView.js"; +import {Clock} from "./ui/web/dom/Clock.js"; +import {OnlineStatus} from "./ui/web/dom/OnlineStatus.js"; export default async function main(container) { try { @@ -17,15 +19,28 @@ export default async function main(container) { // const recorder = new RecordRequester(fetchRequest); // const request = recorder.request; // window.getBrawlFetchLog = () => recorder.log(); - // normal network: const request = fetchRequest; + const sessionInfoStorage = new SessionInfoStorage("brawl_sessions_v1"); + const clock = new Clock(); + const storageFactory = new StorageFactory(); + const vm = new BrawlViewModel({ - storageFactory: new StorageFactory(), - createHsApi: (homeServer, accessToken = null) => new HomeServerApi({homeServer, accessToken, request}), - sessionStore: new SessionsStore("brawl_sessions_v1"), - clock: Date //just for `now` fn + createSessionContainer: () => { + return new SessionContainer({ + random: Math.random, + onlineStatus: new OnlineStatus(), + storageFactory, + sessionInfoStorage, + request, + clock, + }); + }, + sessionInfoStorage, + storageFactory, + clock, }); + window.__brawlViewModel = vm; await vm.load(); const view = new BrawlView(vm); container.appendChild(view.mount()); diff --git a/src/matrix/SendScheduler.js b/src/matrix/SendScheduler.js index 5340d093..1b572cc2 100644 --- a/src/matrix/SendScheduler.js +++ b/src/matrix/SendScheduler.js @@ -1,5 +1,5 @@ -import Platform from "../Platform.js"; -import {HomeServerError, NetworkError} from "./error.js"; +import {Platform} from "../Platform.js"; +import {HomeServerError, ConnectionError} from "./error.js"; export class RateLimitingBackoff { constructor() { @@ -48,7 +48,7 @@ export class SendScheduler { this._hsApi = hsApi; this._sendRequests = []; this._sendScheduled = false; - this._offline = false; + this._stopped = false; this._waitTime = 0; this._backoff = backoff; /* @@ -62,6 +62,18 @@ export class SendScheduler { // this._enabled; } + stop() { + // TODO: abort current requests and set offline + } + + start() { + this._stopped = false; + } + + get isStarted() { + return !this._stopped; + } + // this should really be per roomId to avoid head-of-line blocking // // takes a callback instead of returning a promise with the slot @@ -70,7 +82,7 @@ export class SendScheduler { let request; const promise = new Promise((resolve, reject) => request = {resolve, reject, sendCallback}); this._sendRequests.push(request); - if (!this._sendScheduled && !this._offline) { + if (!this._sendScheduled && !this._stopped) { this._sendLoop(); } return promise; @@ -84,10 +96,10 @@ export class SendScheduler { // this can throw! result = await this._doSend(request.sendCallback); } catch (err) { - if (err instanceof NetworkError) { + if (err instanceof ConnectionError) { // we're offline, everybody will have // to re-request slots when we come back online - this._offline = true; + this._stopped = true; for (const r of this._sendRequests) { r.reject(err); } diff --git a/src/matrix/session.js b/src/matrix/Session.js similarity index 86% rename from src/matrix/session.js rename to src/matrix/Session.js index 33cd16aa..4186d5f3 100644 --- a/src/matrix/session.js +++ b/src/matrix/Session.js @@ -1,9 +1,9 @@ -import Room from "./room/room.js"; +import {Room} from "./room/Room.js"; import { ObservableMap } from "../observable/index.js"; import { SendScheduler, RateLimitingBackoff } from "./SendScheduler.js"; -import User from "./User.js"; +import {User} from "./User.js"; -export default class Session { +export class Session { // sessionInfo contains deviceId, userId and homeServer constructor({storage, hsApi, sessionInfo}) { this._storage = storage; @@ -40,7 +40,28 @@ export default class Session { })); } - notifyNetworkAvailable() { + get isStarted() { + return this._sendScheduler.isStarted; + } + + stop() { + this._sendScheduler.stop(); + } + + async start(lastVersionResponse) { + if (lastVersionResponse) { + // store /versions response + const txn = await this._storage.readWriteTxn([ + this._storage.storeNames.session + ]); + const newSessionData = Object.assign({}, this._session, {serverVersions: lastVersionResponse}); + txn.session.set(newSessionData); + // TODO: what can we do if this throws? + await txn.complete(); + this._session = newSessionData; + } + + this._sendScheduler.start(); for (const [, room] of this._rooms) { room.resumeSending(); } diff --git a/src/matrix/SessionContainer.js b/src/matrix/SessionContainer.js new file mode 100644 index 00000000..04df8680 --- /dev/null +++ b/src/matrix/SessionContainer.js @@ -0,0 +1,237 @@ +import {createEnum} from "../utils/enum.js"; +import {ObservableValue} from "../observable/ObservableValue.js"; +import {HomeServerApi} from "./net/HomeServerApi.js"; +import {Reconnector, ConnectionStatus} from "./net/Reconnector.js"; +import {ExponentialRetryDelay} from "./net/ExponentialRetryDelay.js"; +import {HomeServerError, ConnectionError, AbortError} from "./error.js"; +import {Sync, SyncStatus} from "./Sync.js"; +import {Session} from "./Session.js"; + +export const LoadStatus = createEnum( + "NotLoading", + "Login", + "LoginFailed", + "Loading", + "Migrating", //not used atm, but would fit here + "FirstSync", + "Error", + "Ready", +); + +export const LoginFailure = createEnum( + "Connection", + "Credentials", + "Unknown", +); + +export class SessionContainer { + constructor({clock, random, onlineStatus, request, storageFactory, sessionInfoStorage}) { + this._random = random; + this._clock = clock; + this._onlineStatus = onlineStatus; + this._request = request; + this._storageFactory = storageFactory; + this._sessionInfoStorage = sessionInfoStorage; + + this._status = new ObservableValue(LoadStatus.NotLoading); + this._error = null; + this._loginFailure = null; + this._reconnector = null; + this._session = null; + this._sync = null; + this._sessionId = null; + this._storage = null; + } + + createNewSessionId() { + return (Math.floor(this._random() * Number.MAX_SAFE_INTEGER)).toString(); + } + + async startWithExistingSession(sessionId) { + if (this._status.get() !== LoadStatus.NotLoading) { + return; + } + this._status.set(LoadStatus.Loading); + try { + const sessionInfo = await this._sessionInfoStorage.get(sessionId); + if (!sessionInfo) { + throw new Error("Invalid session id: " + sessionId); + } + await this._loadSessionInfo(sessionInfo); + } catch (err) { + this._error = err; + this._status.set(LoadStatus.Error); + } + } + + async startWithLogin(homeServer, username, password) { + if (this._status.get() !== LoadStatus.NotLoading) { + return; + } + this._status.set(LoadStatus.Login); + let sessionInfo; + try { + const hsApi = new HomeServerApi({homeServer, request: this._request, createTimeout: this._clock.createTimeout}); + const loginData = await hsApi.passwordLogin(username, password).response(); + const sessionId = this.createNewSessionId(); + sessionInfo = { + id: sessionId, + deviceId: loginData.device_id, + userId: loginData.user_id, + homeServer: homeServer, + accessToken: loginData.access_token, + lastUsed: this._clock.now() + }; + await this._sessionInfoStorage.add(sessionInfo); + } catch (err) { + this._error = err; + if (err instanceof HomeServerError) { + if (err.errcode === "M_FORBIDDEN") { + this._loginFailure = LoginFailure.Credentials; + } else { + this._loginFailure = LoginFailure.Unknown; + } + this._status.set(LoadStatus.LoginFailed); + } else if (err instanceof ConnectionError) { + this._loginFailure = LoginFailure.Connection; + this._status.set(LoadStatus.LoginFailure); + } else { + this._status.set(LoadStatus.Error); + } + return; + } + // loading the session can only lead to + // LoadStatus.Error in case of an error, + // so separate try/catch + try { + await this._loadSessionInfo(sessionInfo); + } catch (err) { + this._error = err; + this._status.set(LoadStatus.Error); + } + } + + async _loadSessionInfo(sessionInfo) { + this._status.set(LoadStatus.Loading); + this._reconnector = new Reconnector({ + onlineStatus: this._onlineStatus, + retryDelay: new ExponentialRetryDelay(this._clock.createTimeout), + createMeasure: this._clock.createMeasure + }); + const hsApi = new HomeServerApi({ + homeServer: sessionInfo.homeServer, + accessToken: sessionInfo.accessToken, + request: this._request, + reconnector: this._reconnector, + createTimeout: this._clock.createTimeout + }); + this._sessionId = sessionInfo.id; + this._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: this._storage, sessionInfo: filteredSessionInfo, hsApi}); + await this._session.load(); + + this._sync = new Sync({hsApi, storage: this._storage, session: this._session}); + // notify sync and session when back online + this._reconnectSubscription = this._reconnector.connectionStatus.subscribe(state => { + if (state === ConnectionStatus.Online) { + this._sync.start(); + this._session.start(this._reconnector.lastVersionsResponse); + } + }); + await this._waitForFirstSync(); + + this._status.set(LoadStatus.Ready); + + // if the sync failed, and then the reconnector + // restored the connection, it would have already + // started to session, so check first + // to prevent an extra /versions request + if (this._session.isStarted) { + const lastVersionsResponse = await hsApi.versions({timeout: 10000}).response(); + this._session.start(lastVersionsResponse); + } + } + + async _waitForFirstSync() { + try { + this._sync.start(); + this._status.set(LoadStatus.FirstSync); + } catch (err) { + // swallow ConnectionError here and continue, + // as the reconnector above will call + // sync.start again to retry in this case + if (!(err instanceof ConnectionError)) { + throw err; + } + } + // only transition into Ready once the first sync has succeeded + this._waitForFirstSyncHandle = this._sync.status.waitFor(s => s === SyncStatus.Syncing); + try { + await this._waitForFirstSyncHandle.promise; + } catch (err) { + // if dispose is called from stop, bail out + if (err instanceof AbortError) { + return; + } + throw err; + } finally { + this._waitForFirstSyncHandle = null; + } + } + + + get loadStatus() { + return this._status; + } + + get loadError() { + return this._error; + } + + /** only set at loadStatus InitialSync, CatchupSync or Ready */ + get sync() { + return this._sync; + } + + /** only set at loadStatus InitialSync, CatchupSync or Ready */ + get session() { + return this._session; + } + + get reconnector() { + return this._reconnector; + } + + stop() { + this._reconnectSubscription(); + this._reconnectSubscription = null; + this._sync.stop(); + this._session.stop(); + if (this._waitForFirstSyncHandle) { + this._waitForFirstSyncHandle.dispose(); + this._waitForFirstSyncHandle = null; + } + if (this._storage) { + this._storage.close(); + this._storage = null; + } + } + + async deleteSession() { + if (this._sessionId) { + // if one fails, don't block the other from trying + // also, run in parallel + await Promise.all([ + this._storageFactory.delete(this._sessionId), + this._sessionInfoStorage.delete(this._sessionId), + ]); + this._sessionId = null; + } + } +} diff --git a/src/matrix/sync.js b/src/matrix/Sync.js similarity index 68% rename from src/matrix/sync.js rename to src/matrix/Sync.js index fb31e422..de079e4d 100644 --- a/src/matrix/sync.js +++ b/src/matrix/Sync.js @@ -1,9 +1,17 @@ -import {RequestAbortError} from "./error.js"; -import EventEmitter from "../EventEmitter.js"; +import {AbortError} from "./error.js"; +import {ObservableValue} from "../observable/ObservableValue.js"; +import {createEnum} from "../utils/enum.js"; const INCREMENTAL_TIMEOUT = 30000; const SYNC_EVENT_LIMIT = 10; +export const SyncStatus = createEnum( + "InitialSync", + "CatchupSync", + "Syncing", + "Stopped" +); + function parseRooms(roomsSection, roomCallback) { if (roomsSection) { const allMemberships = ["join", "invite", "leave"]; @@ -19,60 +27,64 @@ function parseRooms(roomsSection, roomCallback) { return []; } -export default class Sync extends EventEmitter { +export class Sync { constructor({hsApi, session, storage}) { - super(); this._hsApi = hsApi; this._session = session; this._storage = storage; - this._isSyncing = false; this._currentRequest = null; + this._status = new ObservableValue(SyncStatus.Stopped); + this._error = null; } - get isSyncing() { - return this._isSyncing; + get status() { + return this._status; } - // returns when initial sync is done - async start() { - if (this._isSyncing) { + /** the error that made the sync stop */ + get error() { + return this._error; + } + + start() { + // not already syncing? + if (this._status.get() !== SyncStatus.Stopped) { return; } - this._isSyncing = true; - this.emit("status", "started"); let syncToken = this._session.syncToken; - // do initial sync if needed - if (!syncToken) { - // need to create limit filter here - syncToken = await this._syncRequest(); + if (syncToken) { + this._status.set(SyncStatus.CatchupSync); + } else { + this._status.set(SyncStatus.InitialSync); } this._syncLoop(syncToken); } async _syncLoop(syncToken) { // if syncToken is falsy, it will first do an initial sync ... - while(this._isSyncing) { + while(this._status.get() !== SyncStatus.Stopped) { try { console.log(`starting sync request with since ${syncToken} ...`); - syncToken = await this._syncRequest(syncToken, INCREMENTAL_TIMEOUT); + const timeout = syncToken ? INCREMENTAL_TIMEOUT : undefined; + syncToken = await this._syncRequest(syncToken, timeout); + this._status.set(SyncStatus.Syncing); } catch (err) { - this._isSyncing = false; - if (!(err instanceof RequestAbortError)) { - console.error("stopping sync because of error"); - console.error(err); - this.emit("status", "error", err); + if (!(err instanceof AbortError)) { + this._error = err; + this._status.set(SyncStatus.Stopped); } } } - this.emit("status", "stopped"); } async _syncRequest(syncToken, timeout) { let {syncFilterId} = this._session; if (typeof syncFilterId !== "string") { - syncFilterId = (await this._hsApi.createFilter(this._session.user.id, {room: {state: {lazy_load_members: true}}}).response()).filter_id; + this._currentRequest = this._hsApi.createFilter(this._session.user.id, {room: {state: {lazy_load_members: true}}}); + syncFilterId = (await this._currentRequest.response()).filter_id; } - this._currentRequest = this._hsApi.sync(syncToken, syncFilterId, timeout); + const totalRequestTimeout = timeout + (80 * 1000); // same as riot-web, don't get stuck on wedged long requests + this._currentRequest = this._hsApi.sync(syncToken, syncFilterId, timeout, {timeout: totalRequestTimeout}); const response = await this._currentRequest.response(); syncToken = response.next_batch; const storeNames = this._storage.storeNames; @@ -127,10 +139,10 @@ export default class Sync extends EventEmitter { } stop() { - if (!this._isSyncing) { + if (this._status.get() === SyncStatus.Stopped) { return; } - this._isSyncing = false; + this._status.set(SyncStatus.Stopped); if (this._currentRequest) { this._currentRequest.abort(); this._currentRequest = null; diff --git a/src/matrix/User.js b/src/matrix/User.js index 5c0aa37f..6db27e78 100644 --- a/src/matrix/User.js +++ b/src/matrix/User.js @@ -1,4 +1,4 @@ -export default class User { +export class User { constructor(userId) { this._userId = userId; } diff --git a/src/matrix/error.js b/src/matrix/error.js index cf6db9c5..f8a0c57c 100644 --- a/src/matrix/error.js +++ b/src/matrix/error.js @@ -3,17 +3,23 @@ export class HomeServerError extends Error { super(`${body ? body.error : status} on ${method} ${url}`); this.errcode = body ? body.errcode : null; this.retry_after_ms = body ? body.retry_after_ms : 0; + this.statusCode = status; } - get isFatal() { - switch (this.errcode) { - - } + get name() { + return "HomeServerError"; } } -export class RequestAbortError extends Error { -} +export {AbortError} from "../utils/error.js"; -export class NetworkError extends Error { +export class ConnectionError extends Error { + constructor(message, isTimeout) { + super(message || "ConnectionError"); + this.isTimeout = isTimeout; + } + + get name() { + return "ConnectionError"; + } } diff --git a/src/matrix/hs-api.js b/src/matrix/hs-api.js deleted file mode 100644 index ec8adad0..00000000 --- a/src/matrix/hs-api.js +++ /dev/null @@ -1,111 +0,0 @@ -import { - HomeServerError, -} from "./error.js"; - -class RequestWrapper { - constructor(method, url, requestResult) { - this._requestResult = requestResult; - this._promise = this._requestResult.response().then(response => { - // ok? - if (response.status >= 200 && response.status < 300) { - return response.body; - } else { - switch (response.status) { - default: - throw new HomeServerError(method, url, response.body, response.status); - } - } - }); - } - - abort() { - return this._requestResult.abort(); - } - - response() { - return this._promise; - } -} - -export default class HomeServerApi { - constructor({homeServer, accessToken, request}) { - // store these both in a closure somehow so it's harder to get at in case of XSS? - // one could change the homeserver as well so the token gets sent there, so both must be protected from read/write - this._homeserver = homeServer; - this._accessToken = accessToken; - this._requestFn = request; - } - - _url(csPath) { - return `${this._homeserver}/_matrix/client/r0${csPath}`; - } - - _request(method, csPath, queryParams = {}, body) { - const queryString = Object.entries(queryParams) - .filter(([, value]) => value !== undefined) - .map(([name, value]) => { - if (typeof value === "object") { - value = JSON.stringify(value); - } - return `${encodeURIComponent(name)}=${encodeURIComponent(value)}`; - }) - .join("&"); - const url = this._url(`${csPath}?${queryString}`); - let bodyString; - const headers = new Headers(); - if (this._accessToken) { - headers.append("Authorization", `Bearer ${this._accessToken}`); - } - headers.append("Accept", "application/json"); - if (body) { - headers.append("Content-Type", "application/json"); - bodyString = JSON.stringify(body); - } - const requestResult = this._requestFn(url, { - method, - headers, - body: bodyString, - }); - return new RequestWrapper(method, url, requestResult); - } - - _post(csPath, queryParams, body) { - return this._request("POST", csPath, queryParams, body); - } - - _put(csPath, queryParams, body) { - return this._request("PUT", csPath, queryParams, body); - } - - _get(csPath, queryParams, body) { - return this._request("GET", csPath, queryParams, body); - } - - sync(since, filter, timeout) { - return this._get("/sync", {since, timeout, filter}); - } - - // params is from, dir and optionally to, limit, filter. - messages(roomId, params) { - return this._get(`/rooms/${encodeURIComponent(roomId)}/messages`, params); - } - - send(roomId, eventType, txnId, content) { - return this._put(`/rooms/${encodeURIComponent(roomId)}/send/${encodeURIComponent(eventType)}/${encodeURIComponent(txnId)}`, {}, content); - } - - passwordLogin(username, password) { - return this._post("/login", undefined, { - "type": "m.login.password", - "identifier": { - "type": "m.id.user", - "user": username - }, - "password": password - }); - } - - createFilter(userId, filter) { - return this._post(`/user/${encodeURIComponent(userId)}/filter`, undefined, filter); - } -} diff --git a/src/matrix/net/ExponentialRetryDelay.js b/src/matrix/net/ExponentialRetryDelay.js new file mode 100644 index 00000000..a1bae822 --- /dev/null +++ b/src/matrix/net/ExponentialRetryDelay.js @@ -0,0 +1,108 @@ +import {AbortError} from "../../utils/error.js"; + +export class ExponentialRetryDelay { + constructor(createTimeout) { + const start = 2000; + this._start = start; + this._current = start; + this._createTimeout = createTimeout; + this._max = 60 * 5 * 1000; //5 min + this._timeout = null; + } + + async waitForRetry() { + this._timeout = this._createTimeout(this._current); + try { + await this._timeout.elapsed(); + // only increase delay if we didn't get interrupted + const next = 2 * this._current; + this._current = Math.min(this._max, next); + } catch(err) { + // swallow AbortError, means abort was called + if (!(err instanceof AbortError)) { + throw err; + } + } finally { + this._timeout = null; + } + } + + abort() { + if (this._timeout) { + this._timeout.abort(); + } + } + + reset() { + this._current = this._start; + this.abort(); + } + + get nextValue() { + return this._current; + } +} + + +import {Clock as MockClock} from "../../mocks/Clock.js"; + +export function tests() { + return { + "test sequence": async assert => { + const clock = new MockClock(); + const retryDelay = new ExponentialRetryDelay(clock.createTimeout); + let promise; + + assert.strictEqual(retryDelay.nextValue, 2000); + promise = retryDelay.waitForRetry(); + clock.elapse(2000); + await promise; + + assert.strictEqual(retryDelay.nextValue, 4000); + promise = retryDelay.waitForRetry(); + clock.elapse(4000); + await promise; + + assert.strictEqual(retryDelay.nextValue, 8000); + promise = retryDelay.waitForRetry(); + clock.elapse(8000); + await promise; + + assert.strictEqual(retryDelay.nextValue, 16000); + promise = retryDelay.waitForRetry(); + clock.elapse(16000); + await promise; + + assert.strictEqual(retryDelay.nextValue, 32000); + promise = retryDelay.waitForRetry(); + clock.elapse(32000); + await promise; + + assert.strictEqual(retryDelay.nextValue, 64000); + promise = retryDelay.waitForRetry(); + clock.elapse(64000); + await promise; + + assert.strictEqual(retryDelay.nextValue, 128000); + promise = retryDelay.waitForRetry(); + clock.elapse(128000); + await promise; + + assert.strictEqual(retryDelay.nextValue, 256000); + promise = retryDelay.waitForRetry(); + clock.elapse(256000); + await promise; + + assert.strictEqual(retryDelay.nextValue, 300000); + promise = retryDelay.waitForRetry(); + clock.elapse(300000); + await promise; + + assert.strictEqual(retryDelay.nextValue, 300000); + promise = retryDelay.waitForRetry(); + clock.elapse(300000); + await promise; + }, + } + +} diff --git a/src/matrix/net/HomeServerApi.js b/src/matrix/net/HomeServerApi.js new file mode 100644 index 00000000..20a1dd92 --- /dev/null +++ b/src/matrix/net/HomeServerApi.js @@ -0,0 +1,193 @@ +import { + HomeServerError, + ConnectionError, + AbortError +} from "../error.js"; + +class RequestWrapper { + constructor(method, url, requestResult, responsePromise) { + this._requestResult = requestResult; + this._promise = responsePromise.then(response => { + // ok? + if (response.status >= 200 && response.status < 300) { + return response.body; + } else { + switch (response.status) { + default: + throw new HomeServerError(method, url, response.body, response.status); + } + } + }); + } + + abort() { + return this._requestResult.abort(); + } + + response() { + return this._promise; + } +} + +export class HomeServerApi { + constructor({homeServer, accessToken, request, createTimeout, reconnector}) { + // store these both in a closure somehow so it's harder to get at in case of XSS? + // one could change the homeserver as well so the token gets sent there, so both must be protected from read/write + this._homeserver = homeServer; + this._accessToken = accessToken; + this._requestFn = request; + this._createTimeout = createTimeout; + this._reconnector = reconnector; + } + + _url(csPath) { + return `${this._homeserver}/_matrix/client/r0${csPath}`; + } + + _abortOnTimeout(timeoutAmount, requestResult, responsePromise) { + const timeout = this._createTimeout(timeoutAmount); + // abort request if timeout finishes first + let timedOut = false; + timeout.elapsed().then( + () => { + timedOut = true; + requestResult.abort(); + }, + () => {} // ignore AbortError + ); + // abort timeout if request finishes first + return responsePromise.then( + response => { + timeout.abort(); + return response; + }, + err => { + timeout.abort(); + // map error to TimeoutError + if (err instanceof AbortError && timedOut) { + throw new ConnectionError(`Request timed out after ${timeoutAmount}ms`, true); + } else { + throw err; + } + } + ); + } + + _request(method, url, queryParams, body, options) { + const queryString = Object.entries(queryParams || {}) + .filter(([, value]) => value !== undefined) + .map(([name, value]) => { + if (typeof value === "object") { + value = JSON.stringify(value); + } + return `${encodeURIComponent(name)}=${encodeURIComponent(value)}`; + }) + .join("&"); + url = `${url}?${queryString}`; + let bodyString; + const headers = new Map(); + if (this._accessToken) { + headers.set("Authorization", `Bearer ${this._accessToken}`); + } + headers.set("Accept", "application/json"); + if (body) { + headers.set("Content-Type", "application/json"); + bodyString = JSON.stringify(body); + } + const requestResult = this._requestFn(url, { + method, + headers, + body: bodyString, + }); + + let responsePromise = requestResult.response(); + + if (options && options.timeout) { + responsePromise = this._abortOnTimeout( + options.timeout, + requestResult, + responsePromise + ); + } + + const wrapper = new RequestWrapper(method, url, requestResult, responsePromise); + + if (this._reconnector) { + wrapper.response().catch(err => { + if (err.name === "ConnectionError") { + this._reconnector.onRequestFailed(this); + } + }); + } + + return wrapper; + } + + _post(csPath, queryParams, body, options) { + return this._request("POST", this._url(csPath), queryParams, body, options); + } + + _put(csPath, queryParams, body, options) { + return this._request("PUT", this._url(csPath), queryParams, body, options); + } + + _get(csPath, queryParams, body, options) { + return this._request("GET", this._url(csPath), queryParams, body, options); + } + + sync(since, filter, timeout, options = null) { + return this._get("/sync", {since, timeout, filter}, null, options); + } + + // params is from, dir and optionally to, limit, filter. + messages(roomId, params, options = null) { + return this._get(`/rooms/${encodeURIComponent(roomId)}/messages`, params, null, options); + } + + send(roomId, eventType, txnId, content, options = null) { + return this._put(`/rooms/${encodeURIComponent(roomId)}/send/${encodeURIComponent(eventType)}/${encodeURIComponent(txnId)}`, {}, content, options); + } + + passwordLogin(username, password, options = null) { + return this._post("/login", null, { + "type": "m.login.password", + "identifier": { + "type": "m.id.user", + "user": username + }, + "password": password + }, options); + } + + createFilter(userId, filter, options = null) { + return this._post(`/user/${encodeURIComponent(userId)}/filter`, null, filter, options); + } + + versions(options = null) { + return this._request("GET", `${this._homeserver}/_matrix/client/versions`, null, null, options); + } +} + +export function tests() { + function createRequestMock(result) { + return function() { + return { + abort() {}, + response() { + return Promise.resolve(result); + } + } + } + } + + return { + "superficial happy path for GET": async assert => { + const hsApi = new HomeServerApi({ + request: createRequestMock({body: 42, status: 200}), + homeServer: "https://hs.tld" + }); + const result = await hsApi._get("foo", null, null, null).response(); + assert.strictEqual(result, 42); + } + } +} diff --git a/src/matrix/net/Reconnector.js b/src/matrix/net/Reconnector.js new file mode 100644 index 00000000..298920f7 --- /dev/null +++ b/src/matrix/net/Reconnector.js @@ -0,0 +1,168 @@ +import {createEnum} from "../../utils/enum.js"; +import {ObservableValue} from "../../observable/ObservableValue.js"; + +export const ConnectionStatus = createEnum( + "Waiting", + "Reconnecting", + "Online" +); + +export class Reconnector { + constructor({retryDelay, createMeasure, onlineStatus}) { + this._onlineStatus = onlineStatus; + this._retryDelay = retryDelay; + this._createTimeMeasure = createMeasure; + // assume online, and do our thing when something fails + this._state = new ObservableValue(ConnectionStatus.Online); + this._isReconnecting = false; + this._versionsResponse = null; + } + + get lastVersionsResponse() { + return this._versionsResponse; + } + + get connectionStatus() { + return this._state; + } + + get retryIn() { + if (this._state.get() === ConnectionStatus.Waiting) { + return this._retryDelay.nextValue - this._stateSince.measure(); + } + return 0; + } + + async onRequestFailed(hsApi) { + if (!this._isReconnecting) { + this._isReconnecting = true; + + const onlineStatusSubscription = this._onlineStatus && this._onlineStatus.subscribe(online => { + if (online) { + this.tryNow(); + } + }); + + try { + await this._reconnectLoop(hsApi); + } catch (err) { + // nothing is catching the error above us, + // so just log here + console.error(err); + } finally { + if (onlineStatusSubscription) { + // unsubscribe from this._onlineStatus + onlineStatusSubscription(); + } + this._isReconnecting = false; + } + } + } + + tryNow() { + if (this._retryDelay) { + // this will interrupt this._retryDelay.waitForRetry() in _reconnectLoop + this._retryDelay.abort(); + } + } + + _setState(state) { + if (state !== this._state.get()) { + if (state === ConnectionStatus.Waiting) { + this._stateSince = this._createTimeMeasure(); + } else { + this._stateSince = null; + } + this._state.set(state); + } + } + + async _reconnectLoop(hsApi) { + this._versionsResponse = null; + this._retryDelay.reset(); + + while (!this._versionsResponse) { + try { + this._setState(ConnectionStatus.Reconnecting); + // use 30s timeout, as a tradeoff between not giving up + // too quickly on a slow server, and not waiting for + // a stale connection when we just came online again + const versionsRequest = hsApi.versions({timeout: 30000}); + this._versionsResponse = await versionsRequest.response(); + this._setState(ConnectionStatus.Online); + } catch (err) { + if (err.name === "ConnectionError") { + this._setState(ConnectionStatus.Waiting); + await this._retryDelay.waitForRetry(); + } else { + throw err; + } + } + } + } +} + + +import {Clock as MockClock} from "../../mocks/Clock.js"; +import {ExponentialRetryDelay} from "./ExponentialRetryDelay.js"; +import {ConnectionError} from "../error.js" + +export function tests() { + function createHsApiMock(remainingFailures) { + return { + versions() { + return { + response() { + if (remainingFailures) { + remainingFailures -= 1; + return Promise.reject(new ConnectionError()); + } else { + return Promise.resolve(42); + } + } + }; + } + } + } + + return { + "test reconnecting with 1 failure": async assert => { + const clock = new MockClock(); + const {createMeasure} = clock; + const onlineStatus = new ObservableValue(false); + const retryDelay = new ExponentialRetryDelay(clock.createTimeout); + const reconnector = new Reconnector({retryDelay, onlineStatus, createMeasure}); + const {connectionStatus} = reconnector; + const statuses = []; + const subscription = reconnector.connectionStatus.subscribe(s => { + statuses.push(s); + }); + reconnector.onRequestFailed(createHsApiMock(1)); + await connectionStatus.waitFor(s => s === ConnectionStatus.Waiting).promise; + clock.elapse(2000); + await connectionStatus.waitFor(s => s === ConnectionStatus.Online).promise; + assert.deepEqual(statuses, [ + ConnectionStatus.Reconnecting, + ConnectionStatus.Waiting, + ConnectionStatus.Reconnecting, + ConnectionStatus.Online + ]); + assert.strictEqual(reconnector.lastVersionsResponse, 42); + subscription(); + }, + "test reconnecting with onlineStatus": async assert => { + const clock = new MockClock(); + const {createMeasure} = clock; + const onlineStatus = new ObservableValue(false); + const retryDelay = new ExponentialRetryDelay(clock.createTimeout); + const reconnector = new Reconnector({retryDelay, onlineStatus, createMeasure}); + const {connectionStatus} = reconnector; + reconnector.onRequestFailed(createHsApiMock(1)); + await connectionStatus.waitFor(s => s === ConnectionStatus.Waiting).promise; + onlineStatus.set(true); //skip waiting + await connectionStatus.waitFor(s => s === ConnectionStatus.Online).promise; + assert.equal(connectionStatus.get(), ConnectionStatus.Online); + assert.strictEqual(reconnector.lastVersionsResponse, 42); + }, + } +} diff --git a/src/matrix/net/fetch.js b/src/matrix/net/request/fetch.js similarity index 78% rename from src/matrix/net/fetch.js rename to src/matrix/net/request/fetch.js index 48a13969..886e8195 100644 --- a/src/matrix/net/fetch.js +++ b/src/matrix/net/request/fetch.js @@ -1,7 +1,7 @@ import { - RequestAbortError, - NetworkError -} from "../error.js"; + AbortError, + ConnectionError +} from "../../error.js"; class RequestResult { constructor(promise, controller) { @@ -31,7 +31,7 @@ class RequestResult { } } -export default function fetchRequest(url, options) { +export function fetchRequest(url, options) { const controller = typeof AbortController === "function" ? new AbortController() : null; if (controller) { options = Object.assign(options, { @@ -44,21 +44,28 @@ export default function fetchRequest(url, options) { referrer: "no-referrer", cache: "no-cache", }); + if (options.headers) { + const headers = new Headers(); + for(const [name, value] of options.headers.entries()) { + headers.append(name, value); + } + options.headers = headers; + } const promise = fetch(url, options).then(async response => { const {status} = response; const body = await response.json(); return {status, body}; }, err => { if (err.name === "AbortError") { - throw new RequestAbortError(); + throw new AbortError(); } else if (err instanceof TypeError) { // Network errors are reported as TypeErrors, see // https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API/Using_Fetch#Checking_that_the_fetch_was_successful // this can either mean user is offline, server is offline, or a CORS error (server misconfiguration). // // One could check navigator.onLine to rule out the first - // but the 2 later ones are indistinguishable from javascript. - throw new NetworkError(`${options.method} ${url}: ${err.message}`); + // but the 2 latter ones are indistinguishable from javascript. + throw new ConnectionError(`${options.method} ${url}: ${err.message}`); } throw err; }); diff --git a/src/matrix/net/replay.js b/src/matrix/net/request/replay.js similarity index 90% rename from src/matrix/net/replay.js rename to src/matrix/net/request/replay.js index b6ddcb74..c6f12269 100644 --- a/src/matrix/net/replay.js +++ b/src/matrix/net/request/replay.js @@ -1,7 +1,7 @@ import { - RequestAbortError, - NetworkError -} from "../error.js"; + AbortError, + ConnectionError +} from "../../error.js"; class RequestLogItem { constructor(url, options) { @@ -23,8 +23,8 @@ class RequestLogItem { handleError(err) { this.end = performance.now(); this.error = { - aborted: err instanceof RequestAbortError, - network: err instanceof NetworkError, + aborted: err instanceof AbortError, + network: err instanceof ConnectionError, message: err.message, }; } @@ -96,9 +96,9 @@ class ReplayRequestResult { if (this._item.error || this._aborted) { const error = this._item.error; if (error.aborted || this._aborted) { - throw new RequestAbortError(error.message); + throw new AbortError(error.message); } else if (error.network) { - throw new NetworkError(error.message); + throw new ConnectionError(error.message); } else { throw new Error(error.message); } diff --git a/src/matrix/room/room.js b/src/matrix/room/Room.js similarity index 86% rename from src/matrix/room/room.js rename to src/matrix/room/Room.js index 721eefe9..60983df2 100644 --- a/src/matrix/room/room.js +++ b/src/matrix/room/Room.js @@ -1,12 +1,12 @@ -import EventEmitter from "../../EventEmitter.js"; -import RoomSummary from "./summary.js"; -import SyncWriter from "./timeline/persistence/SyncWriter.js"; -import GapWriter from "./timeline/persistence/GapWriter.js"; -import Timeline from "./timeline/Timeline.js"; -import FragmentIdComparer from "./timeline/FragmentIdComparer.js"; -import SendQueue from "./sending/SendQueue.js"; +import {EventEmitter} from "../../utils/EventEmitter.js"; +import {RoomSummary} from "./RoomSummary.js"; +import {SyncWriter} from "./timeline/persistence/SyncWriter.js"; +import {GapWriter} from "./timeline/persistence/GapWriter.js"; +import {Timeline} from "./timeline/Timeline.js"; +import {FragmentIdComparer} from "./timeline/FragmentIdComparer.js"; +import {SendQueue} from "./sending/SendQueue.js"; -export default class Room extends EventEmitter { +export class Room extends EventEmitter { constructor({roomId, storage, hsApi, emitCollectionChange, sendScheduler, pendingEvents, user}) { super(); this._roomId = roomId; @@ -115,12 +115,16 @@ export default class Room extends EventEmitter { if (this._timeline) { throw new Error("not dealing with load race here for now"); } + console.log(`opening the timeline for ${this._roomId}`); this._timeline = new Timeline({ roomId: this.id, storage: this._storage, fragmentIdComparer: this._fragmentIdComparer, pendingEvents: this._sendQueue.pendingEvents, - closeCallback: () => this._timeline = null, + closeCallback: () => { + console.log(`closing the timeline for ${this._roomId}`); + this._timeline = null; + }, user: this._user, }); await this._timeline.load(); diff --git a/src/matrix/room/summary.js b/src/matrix/room/RoomSummary.js similarity index 99% rename from src/matrix/room/summary.js rename to src/matrix/room/RoomSummary.js index b29ee9bd..177c1bc4 100644 --- a/src/matrix/room/summary.js +++ b/src/matrix/room/RoomSummary.js @@ -99,7 +99,7 @@ class SummaryData { } } -export default class RoomSummary { +export class RoomSummary { constructor(roomId) { this._data = new SummaryData(null, roomId); } diff --git a/src/matrix/room/sending/PendingEvent.js b/src/matrix/room/sending/PendingEvent.js index 105da49b..a87efc98 100644 --- a/src/matrix/room/sending/PendingEvent.js +++ b/src/matrix/room/sending/PendingEvent.js @@ -1,4 +1,4 @@ -export default class PendingEvent { +export class PendingEvent { constructor(data) { this._data = data; } diff --git a/src/matrix/room/sending/SendQueue.js b/src/matrix/room/sending/SendQueue.js index 3c9f90c3..d4ee9828 100644 --- a/src/matrix/room/sending/SendQueue.js +++ b/src/matrix/room/sending/SendQueue.js @@ -1,6 +1,6 @@ -import SortedArray from "../../../observable/list/SortedArray.js"; -import {NetworkError} from "../../error.js"; -import PendingEvent from "./PendingEvent.js"; +import {SortedArray} from "../../../observable/list/SortedArray.js"; +import {ConnectionError} from "../../error.js"; +import {PendingEvent} from "./PendingEvent.js"; function makeTxnId() { const n = Math.floor(Math.random() * Number.MAX_SAFE_INTEGER); @@ -8,7 +8,7 @@ function makeTxnId() { return "t" + "0".repeat(14 - str.length) + str; } -export default class SendQueue { +export class SendQueue { constructor({roomId, storage, sendScheduler, pendingEvents}) { pendingEvents = pendingEvents || []; this._roomId = roomId; @@ -31,7 +31,6 @@ export default class SendQueue { while (this._amountSent < this._pendingEvents.length) { const pendingEvent = this._pendingEvents.get(this._amountSent); console.log("trying to send", pendingEvent.content.body); - this._amountSent += 1; if (pendingEvent.remoteId) { continue; } @@ -50,9 +49,10 @@ export default class SendQueue { console.log("writing remoteId now"); await this._tryUpdateEvent(pendingEvent); console.log("keep sending?", this._amountSent, "<", this._pendingEvents.length); + this._amountSent += 1; } } catch(err) { - if (err instanceof NetworkError) { + if (err instanceof ConnectionError) { this._offline = true; } } finally { diff --git a/src/matrix/room/timeline/Direction.js b/src/matrix/room/timeline/Direction.js index 9c6fa8cd..05fcc0dc 100644 --- a/src/matrix/room/timeline/Direction.js +++ b/src/matrix/room/timeline/Direction.js @@ -1,6 +1,4 @@ - - -export default class Direction { +export class Direction { constructor(isForward) { this._isForward = isForward; } diff --git a/src/matrix/room/timeline/EventKey.js b/src/matrix/room/timeline/EventKey.js index e7837cbd..885efba0 100644 --- a/src/matrix/room/timeline/EventKey.js +++ b/src/matrix/room/timeline/EventKey.js @@ -1,7 +1,7 @@ -import Platform from "../../../Platform.js"; +import {Platform} from "../../../Platform.js"; // key for events in the timelineEvents store -export default class EventKey { +export class EventKey { constructor(fragmentId, eventIndex) { this.fragmentId = fragmentId; this.eventIndex = eventIndex; @@ -49,7 +49,6 @@ export default class EventKey { } } -//#ifdef TESTS export function xtests() { const fragmentIdComparer = {compare: (a, b) => a - b}; @@ -156,4 +155,3 @@ export function xtests() { } }; } -//#endif diff --git a/src/matrix/room/timeline/FragmentIdComparer.js b/src/matrix/room/timeline/FragmentIdComparer.js index d1a11564..da3b2243 100644 --- a/src/matrix/room/timeline/FragmentIdComparer.js +++ b/src/matrix/room/timeline/FragmentIdComparer.js @@ -114,7 +114,7 @@ class Island { /* index for fast lookup of how two fragments can be sorted */ -export default class FragmentIdComparer { +export class FragmentIdComparer { constructor(fragments) { this._fragmentsById = fragments.reduce((map, f) => {map.set(f.id, f); return map;}, new Map()); this.rebuild(fragments); @@ -180,7 +180,6 @@ export default class FragmentIdComparer { } } -//#ifdef TESTS export function tests() { return { test_1_island_3_fragments(assert) { @@ -297,4 +296,3 @@ export function tests() { } } } -//#endif diff --git a/src/matrix/room/timeline/Timeline.js b/src/matrix/room/timeline/Timeline.js index a13fa304..bd4874c5 100644 --- a/src/matrix/room/timeline/Timeline.js +++ b/src/matrix/room/timeline/Timeline.js @@ -1,9 +1,9 @@ -import { SortedArray, MappedList, ConcatList } from "../../../observable/index.js"; -import Direction from "./Direction.js"; -import TimelineReader from "./persistence/TimelineReader.js"; -import PendingEventEntry from "./entries/PendingEventEntry.js"; +import {SortedArray, MappedList, ConcatList} from "../../../observable/index.js"; +import {Direction} from "./Direction.js"; +import {TimelineReader} from "./persistence/TimelineReader.js"; +import {PendingEventEntry} from "./entries/PendingEventEntry.js"; -export default class Timeline { +export class Timeline { constructor({roomId, storage, closeCallback, fragmentIdComparer, pendingEvents, user}) { this._roomId = roomId; this._storage = storage; @@ -29,6 +29,9 @@ export default class Timeline { this._remoteEntries.setManySorted(entries); } + // TODO: should we rather have generic methods for + // - adding new entries + // - updating existing entries (redaction, relations) /** @package */ appendLiveEntries(newEntries) { this._remoteEntries.setManySorted(newEntries); @@ -60,6 +63,9 @@ export default class Timeline { /** @public */ close() { - this._closeCallback(); + if (this._closeCallback) { + this._closeCallback(); + this._closeCallback = null; + } } } diff --git a/src/matrix/room/timeline/entries/BaseEntry.js b/src/matrix/room/timeline/entries/BaseEntry.js index 6c55788c..bd129cf2 100644 --- a/src/matrix/room/timeline/entries/BaseEntry.js +++ b/src/matrix/room/timeline/entries/BaseEntry.js @@ -1,8 +1,8 @@ //entries can be sorted, first by fragment, then by entry index. -import EventKey from "../EventKey.js"; +import {EventKey} from "../EventKey.js"; export const PENDING_FRAGMENT_ID = Number.MAX_SAFE_INTEGER; -export default class BaseEntry { +export class BaseEntry { constructor(fragmentIdComparer) { this._fragmentIdComparer = fragmentIdComparer; } diff --git a/src/matrix/room/timeline/entries/EventEntry.js b/src/matrix/room/timeline/entries/EventEntry.js index 9156d466..041c392c 100644 --- a/src/matrix/room/timeline/entries/EventEntry.js +++ b/src/matrix/room/timeline/entries/EventEntry.js @@ -1,6 +1,6 @@ -import BaseEntry from "./BaseEntry.js"; +import {BaseEntry} from "./BaseEntry.js"; -export default class EventEntry extends BaseEntry { +export class EventEntry extends BaseEntry { constructor(eventEntry, fragmentIdComparer) { super(fragmentIdComparer); this._eventEntry = eventEntry; diff --git a/src/matrix/room/timeline/entries/FragmentBoundaryEntry.js b/src/matrix/room/timeline/entries/FragmentBoundaryEntry.js index 4dc0ab61..c84ddede 100644 --- a/src/matrix/room/timeline/entries/FragmentBoundaryEntry.js +++ b/src/matrix/room/timeline/entries/FragmentBoundaryEntry.js @@ -1,9 +1,9 @@ -import BaseEntry from "./BaseEntry.js"; -import Direction from "../Direction.js"; +import {BaseEntry} from "./BaseEntry.js"; +import {Direction} from "../Direction.js"; import {isValidFragmentId} from "../common.js"; -import Platform from "../../../../Platform.js"; +import {Platform} from "../../../../Platform.js"; -export default class FragmentBoundaryEntry extends BaseEntry { +export class FragmentBoundaryEntry extends BaseEntry { constructor(fragment, isFragmentStart, fragmentIdComparer) { super(fragmentIdComparer); this._fragment = fragment; diff --git a/src/matrix/room/timeline/entries/PendingEventEntry.js b/src/matrix/room/timeline/entries/PendingEventEntry.js index 63e8ba84..0d2c9ae1 100644 --- a/src/matrix/room/timeline/entries/PendingEventEntry.js +++ b/src/matrix/room/timeline/entries/PendingEventEntry.js @@ -1,6 +1,6 @@ -import BaseEntry, {PENDING_FRAGMENT_ID} from "./BaseEntry.js"; +import {BaseEntry, PENDING_FRAGMENT_ID} from "./BaseEntry.js"; -export default class PendingEventEntry extends BaseEntry { +export class PendingEventEntry extends BaseEntry { constructor({pendingEvent, user}) { super(null); this._pendingEvent = pendingEvent; diff --git a/src/matrix/room/timeline/persistence/GapWriter.js b/src/matrix/room/timeline/persistence/GapWriter.js index 36080270..36bb1256 100644 --- a/src/matrix/room/timeline/persistence/GapWriter.js +++ b/src/matrix/room/timeline/persistence/GapWriter.js @@ -1,8 +1,8 @@ -import EventKey from "../EventKey.js"; -import EventEntry from "../entries/EventEntry.js"; +import {EventKey} from "../EventKey.js"; +import {EventEntry} from "../entries/EventEntry.js"; import {createEventEntry, directionalAppend} from "./common.js"; -export default class GapWriter { +export class GapWriter { constructor({roomId, storage, fragmentIdComparer}) { this._roomId = roomId; this._storage = storage; @@ -178,7 +178,6 @@ export default class GapWriter { } } -//#ifdef TESTS //import MemoryStorage from "../storage/memory/MemoryStorage.js"; export function xtests() { @@ -277,4 +276,3 @@ export function xtests() { }, } } -//#endif diff --git a/src/matrix/room/timeline/persistence/SyncWriter.js b/src/matrix/room/timeline/persistence/SyncWriter.js index 8143ced0..92907b5a 100644 --- a/src/matrix/room/timeline/persistence/SyncWriter.js +++ b/src/matrix/room/timeline/persistence/SyncWriter.js @@ -1,6 +1,6 @@ -import EventKey from "../EventKey.js"; -import EventEntry from "../entries/EventEntry.js"; -import FragmentBoundaryEntry from "../entries/FragmentBoundaryEntry.js"; +import {EventKey} from "../EventKey.js"; +import {EventEntry} from "../entries/EventEntry.js"; +import {FragmentBoundaryEntry} from "../entries/FragmentBoundaryEntry.js"; import {createEventEntry} from "./common.js"; // Synapse bug? where the m.room.create event appears twice in sync response @@ -17,7 +17,7 @@ function deduplicateEvents(events) { }); } -export default class SyncWriter { +export class SyncWriter { constructor({roomId, fragmentIdComparer}) { this._roomId = roomId; this._fragmentIdComparer = fragmentIdComparer; @@ -134,7 +134,6 @@ export default class SyncWriter { } } -//#ifdef TESTS //import MemoryStorage from "../storage/memory/MemoryStorage.js"; export function xtests() { @@ -233,4 +232,3 @@ export function xtests() { }, } } -//#endif diff --git a/src/matrix/room/timeline/persistence/TimelineReader.js b/src/matrix/room/timeline/persistence/TimelineReader.js index 01c9f693..3262550a 100644 --- a/src/matrix/room/timeline/persistence/TimelineReader.js +++ b/src/matrix/room/timeline/persistence/TimelineReader.js @@ -1,9 +1,9 @@ import {directionalConcat, directionalAppend} from "./common.js"; -import Direction from "../Direction.js"; -import EventEntry from "../entries/EventEntry.js"; -import FragmentBoundaryEntry from "../entries/FragmentBoundaryEntry.js"; +import {Direction} from "../Direction.js"; +import {EventEntry} from "../entries/EventEntry.js"; +import {FragmentBoundaryEntry} from "../entries/FragmentBoundaryEntry.js"; -export default class TimelineReader { +export class TimelineReader { constructor({roomId, storage, fragmentIdComparer}) { this._roomId = roomId; this._storage = storage; diff --git a/src/matrix/sessions-store/localstorage/SessionsStore.js b/src/matrix/sessioninfo/localstorage/SessionInfoStorage.js similarity index 97% rename from src/matrix/sessions-store/localstorage/SessionsStore.js rename to src/matrix/sessioninfo/localstorage/SessionInfoStorage.js index dbe6fda5..29c4be94 100644 --- a/src/matrix/sessions-store/localstorage/SessionsStore.js +++ b/src/matrix/sessioninfo/localstorage/SessionInfoStorage.js @@ -1,4 +1,4 @@ -export default class SessionsStore { +export class SessionInfoStorage { constructor(name) { this._name = name; } diff --git a/src/matrix/storage/idb/query-target.js b/src/matrix/storage/idb/QueryTarget.js similarity index 99% rename from src/matrix/storage/idb/query-target.js rename to src/matrix/storage/idb/QueryTarget.js index 7e848218..1948b685 100644 --- a/src/matrix/storage/idb/query-target.js +++ b/src/matrix/storage/idb/QueryTarget.js @@ -1,6 +1,6 @@ import {iterateCursor, reqAsPromise} from "./utils.js"; -export default class QueryTarget { +export class QueryTarget { constructor(target) { this._target = target; } diff --git a/src/matrix/storage/idb/storage.js b/src/matrix/storage/idb/Storage.js similarity index 91% rename from src/matrix/storage/idb/storage.js rename to src/matrix/storage/idb/Storage.js index 5466ab76..812e1cc8 100644 --- a/src/matrix/storage/idb/storage.js +++ b/src/matrix/storage/idb/Storage.js @@ -1,7 +1,7 @@ -import Transaction from "./transaction.js"; +import {Transaction} from "./Transaction.js"; import { STORE_NAMES, StorageError } from "../common.js"; -export default class Storage { +export class Storage { constructor(idbDatabase) { this._db = idbDatabase; const nameMap = STORE_NAMES.reduce((nameMap, name) => { @@ -37,4 +37,8 @@ export default class Storage { throw new StorageError("readWriteTxn failed", err); } } + + close() { + this._db.close(); + } } diff --git a/src/matrix/storage/idb/create.js b/src/matrix/storage/idb/StorageFactory.js similarity index 96% rename from src/matrix/storage/idb/create.js rename to src/matrix/storage/idb/StorageFactory.js index 6056409f..8f5babcd 100644 --- a/src/matrix/storage/idb/create.js +++ b/src/matrix/storage/idb/StorageFactory.js @@ -1,11 +1,11 @@ -import Storage from "./storage.js"; +import {Storage} from "./Storage.js"; import { openDatabase, reqAsPromise } from "./utils.js"; import { exportSession, importSession } from "./export.js"; const sessionName = sessionId => `brawl_session_${sessionId}`; const openDatabaseWithSessionId = sessionId => openDatabase(sessionName(sessionId), createStores, 1); -export default class StorageFactory { +export class StorageFactory { async create(sessionId) { const db = await openDatabaseWithSessionId(sessionId); return new Storage(db); diff --git a/src/matrix/storage/idb/store.js b/src/matrix/storage/idb/Store.js similarity index 96% rename from src/matrix/storage/idb/store.js rename to src/matrix/storage/idb/Store.js index f2169fe3..7acf9411 100644 --- a/src/matrix/storage/idb/store.js +++ b/src/matrix/storage/idb/Store.js @@ -1,4 +1,4 @@ -import QueryTarget from "./query-target.js"; +import {QueryTarget} from "./QueryTarget.js"; import { reqAsPromise } from "./utils.js"; import { StorageError } from "../common.js"; @@ -80,7 +80,7 @@ class QueryTargetWrapper { } } -export default class Store extends QueryTarget { +export class Store extends QueryTarget { constructor(idbStore) { super(new QueryTargetWrapper(idbStore)); } diff --git a/src/matrix/storage/idb/transaction.js b/src/matrix/storage/idb/Transaction.js similarity index 80% rename from src/matrix/storage/idb/transaction.js rename to src/matrix/storage/idb/Transaction.js index 1c2c8286..dc18321b 100644 --- a/src/matrix/storage/idb/transaction.js +++ b/src/matrix/storage/idb/Transaction.js @@ -1,14 +1,14 @@ import {txnAsPromise} from "./utils.js"; import {StorageError} from "../common.js"; -import Store from "./store.js"; -import SessionStore from "./stores/SessionStore.js"; -import RoomSummaryStore from "./stores/RoomSummaryStore.js"; -import TimelineEventStore from "./stores/TimelineEventStore.js"; -import RoomStateStore from "./stores/RoomStateStore.js"; -import TimelineFragmentStore from "./stores/TimelineFragmentStore.js"; -import PendingEventStore from "./stores/PendingEventStore.js"; +import {Store} from "./Store.js"; +import {SessionStore} from "./stores/SessionStore.js"; +import {RoomSummaryStore} from "./stores/RoomSummaryStore.js"; +import {TimelineEventStore} from "./stores/TimelineEventStore.js"; +import {RoomStateStore} from "./stores/RoomStateStore.js"; +import {TimelineFragmentStore} from "./stores/TimelineFragmentStore.js"; +import {PendingEventStore} from "./stores/PendingEventStore.js"; -export default class Transaction { +export class Transaction { constructor(txn, allowedStoreNames) { this._txn = txn; this._allowedStoreNames = allowedStoreNames; diff --git a/src/matrix/storage/idb/stores/member.js b/src/matrix/storage/idb/stores/MemberStore.js similarity index 100% rename from src/matrix/storage/idb/stores/member.js rename to src/matrix/storage/idb/stores/MemberStore.js diff --git a/src/matrix/storage/idb/stores/PendingEventStore.js b/src/matrix/storage/idb/stores/PendingEventStore.js index d413ec63..7aa5408a 100644 --- a/src/matrix/storage/idb/stores/PendingEventStore.js +++ b/src/matrix/storage/idb/stores/PendingEventStore.js @@ -1,5 +1,5 @@ import { encodeUint32, decodeUint32 } from "../utils.js"; -import Platform from "../../../../Platform.js"; +import {Platform} from "../../../../Platform.js"; function encodeKey(roomId, queueIndex) { return `${roomId}|${encodeUint32(queueIndex)}`; @@ -11,7 +11,7 @@ function decodeKey(key) { return {roomId, queueIndex}; } -export default class PendingEventStore { +export class PendingEventStore { constructor(eventStore) { this._eventStore = eventStore; } diff --git a/src/matrix/storage/idb/stores/RoomStateStore.js b/src/matrix/storage/idb/stores/RoomStateStore.js index 09f3cd6d..21c17c5a 100644 --- a/src/matrix/storage/idb/stores/RoomStateStore.js +++ b/src/matrix/storage/idb/stores/RoomStateStore.js @@ -1,4 +1,4 @@ -export default class RoomStateStore { +export class RoomStateStore { constructor(idbStore) { this._roomStateStore = idbStore; } diff --git a/src/matrix/storage/idb/stores/RoomSummaryStore.js b/src/matrix/storage/idb/stores/RoomSummaryStore.js index 5d4c99ec..45cf8468 100644 --- a/src/matrix/storage/idb/stores/RoomSummaryStore.js +++ b/src/matrix/storage/idb/stores/RoomSummaryStore.js @@ -11,7 +11,7 @@ store contains: inviteCount joinCount */ -export default class RoomSummaryStore { +export class RoomSummaryStore { constructor(summaryStore) { this._summaryStore = summaryStore; } diff --git a/src/matrix/storage/idb/stores/SessionStore.js b/src/matrix/storage/idb/stores/SessionStore.js index 405f9794..72af611d 100644 --- a/src/matrix/storage/idb/stores/SessionStore.js +++ b/src/matrix/storage/idb/stores/SessionStore.js @@ -14,7 +14,7 @@ store contains: avatarUrl lastSynced */ -export default class SessionStore { +export class SessionStore { constructor(sessionStore) { this._sessionStore = sessionStore; } diff --git a/src/matrix/storage/idb/stores/TimelineEventStore.js b/src/matrix/storage/idb/stores/TimelineEventStore.js index f54fc758..7ccb0883 100644 --- a/src/matrix/storage/idb/stores/TimelineEventStore.js +++ b/src/matrix/storage/idb/stores/TimelineEventStore.js @@ -1,7 +1,7 @@ -import EventKey from "../../../room/timeline/EventKey.js"; +import {EventKey} from "../../../room/timeline/EventKey.js"; import { StorageError } from "../../common.js"; import { encodeUint32 } from "../utils.js"; -import Platform from "../../../../Platform.js"; +import {Platform} from "../../../../Platform.js"; function encodeKey(roomId, fragmentId, eventIndex) { return `${roomId}|${encodeUint32(fragmentId)}|${encodeUint32(eventIndex)}`; @@ -81,7 +81,7 @@ class Range { * @property {?Event} event if an event entry, the event * @property {?Gap} gap if a gap entry, the gap */ -export default class TimelineEventStore { +export class TimelineEventStore { constructor(timelineStore) { this._timelineStore = timelineStore; } diff --git a/src/matrix/storage/idb/stores/TimelineFragmentStore.js b/src/matrix/storage/idb/stores/TimelineFragmentStore.js index 064daed7..4dc33c13 100644 --- a/src/matrix/storage/idb/stores/TimelineFragmentStore.js +++ b/src/matrix/storage/idb/stores/TimelineFragmentStore.js @@ -1,12 +1,12 @@ import { StorageError } from "../../common.js"; -import Platform from "../../../../Platform.js"; +import {Platform} from "../../../../Platform.js"; import { encodeUint32 } from "../utils.js"; function encodeKey(roomId, fragmentId) { return `${roomId}|${encodeUint32(fragmentId)}`; } -export default class RoomFragmentStore { +export class TimelineFragmentStore { constructor(store) { this._store = store; } diff --git a/src/matrix/storage/memory/Storage.js b/src/matrix/storage/memory/Storage.js index fb178b01..206c95ca 100644 --- a/src/matrix/storage/memory/Storage.js +++ b/src/matrix/storage/memory/Storage.js @@ -1,7 +1,7 @@ -import Transaction from "./transaction.js"; +import {Transaction} from "./Transaction.js"; import { STORE_MAP, STORE_NAMES } from "../common.js"; -export default class Storage { +export class Storage { constructor(initialStoreValues = {}) { this._validateStoreNames(Object.keys(initialStoreValues)); this.storeNames = STORE_MAP; diff --git a/src/matrix/storage/memory/Transaction.js b/src/matrix/storage/memory/Transaction.js index 437962da..b37a53fc 100644 --- a/src/matrix/storage/memory/Transaction.js +++ b/src/matrix/storage/memory/Transaction.js @@ -1,6 +1,6 @@ -import RoomTimelineStore from "./stores/RoomTimelineStore.js"; +import {RoomTimelineStore} from "./stores/RoomTimelineStore.js"; -export default class Transaction { +export class Transaction { constructor(storeValues, writable) { this._storeValues = storeValues; this._txnStoreValues = {}; diff --git a/src/matrix/storage/memory/stores/RoomTimelineStore.js b/src/matrix/storage/memory/stores/RoomTimelineStore.js index 3c17c045..6152daa7 100644 --- a/src/matrix/storage/memory/stores/RoomTimelineStore.js +++ b/src/matrix/storage/memory/stores/RoomTimelineStore.js @@ -1,6 +1,6 @@ -import SortKey from "../../room/timeline/SortKey.js"; -import sortedIndex from "../../../utils/sortedIndex.js"; -import Store from "./Store.js"; +import {SortKey} from "../../room/timeline/SortKey.js"; +import {sortedIndex} from "../../../utils/sortedIndex.js"; +import {Store} from "./Store.js"; function compareKeys(key, entry) { if (key.roomId === entry.roomId) { @@ -65,7 +65,7 @@ class Range { } } -export default class RoomTimelineStore extends Store { +export class RoomTimelineStore extends Store { constructor(timeline, writable) { super(timeline || [], writable); } diff --git a/src/matrix/storage/memory/stores/Store.js b/src/matrix/storage/memory/stores/Store.js index 8028a783..c04ac258 100644 --- a/src/matrix/storage/memory/stores/Store.js +++ b/src/matrix/storage/memory/stores/Store.js @@ -1,4 +1,4 @@ -export default class Store { +export class Store { constructor(storeValue, writable) { this._storeValue = storeValue; this._writable = writable; diff --git a/src/mocks/Clock.js b/src/mocks/Clock.js new file mode 100644 index 00000000..6bbf5a1c --- /dev/null +++ b/src/mocks/Clock.js @@ -0,0 +1,119 @@ +import {ObservableValue} from "../observable/ObservableValue.js"; + +class Timeout { + constructor(elapsed, ms) { + this._reject = null; + this._handle = null; + const timeoutValue = elapsed.get() + ms; + this._waitHandle = elapsed.waitFor(t => t >= timeoutValue); + } + + elapsed() { + return this._waitHandle.promise; + } + + abort() { + // will reject with AbortError + this._waitHandle.dispose(); + } +} + +class Interval { + constructor(elapsed, ms, callback) { + this._start = elapsed.get(); + this._last = this._start; + this._interval = ms; + this._callback = callback; + this._subscription = elapsed.subscribe(this._update.bind(this)); + } + + _update(elapsed) { + const prevAmount = Math.floor((this._last - this._start) / this._interval); + const newAmount = Math.floor((elapsed - this._start) / this._interval); + const amountDiff = Math.max(0, newAmount - prevAmount); + this._last = elapsed; + + for (let i = 0; i < amountDiff; ++i) { + this._callback(); + } + } + + dispose() { + if (this._subscription) { + this._subscription(); + this._subscription = null; + } + } +} + +class TimeMeasure { + constructor(elapsed) { + this._elapsed = elapsed; + this._start = elapsed.get(); + } + + measure() { + return this._elapsed.get() - this._start; + } +} + +export class Clock { + constructor(baseTimestamp = 0) { + this._baseTimestamp = baseTimestamp; + this._elapsed = new ObservableValue(0); + // should be callable as a function as well as a method + this.createMeasure = this.createMeasure.bind(this); + this.createTimeout = this.createTimeout.bind(this); + this.now = this.now.bind(this); + } + + createMeasure() { + return new TimeMeasure(this._elapsed); + } + + createTimeout(ms) { + return new Timeout(this._elapsed, ms); + } + + createInterval(callback, ms) { + return new Interval(this._elapsed, ms, callback); + } + + now() { + return this._baseTimestamp + this.elapsed; + } + + elapse(ms) { + this._elapsed.set(this._elapsed.get() + Math.max(0, ms)); + } + + get elapsed() { + return this._elapsed.get(); + } +} + +export function tests() { + return { + "test timeout": async assert => { + const clock = new Clock(); + Promise.resolve().then(() => { + clock.elapse(500); + clock.elapse(500); + }).catch(assert.fail); + const timeout = clock.createTimeout(1000); + const promise = timeout.elapsed(); + assert(promise instanceof Promise); + await promise; + }, + "test interval": assert => { + const clock = new Clock(); + let counter = 0; + const interval = clock.createInterval(() => counter += 1, 200); + clock.elapse(150); + assert.strictEqual(counter, 0); + clock.elapse(500); + assert.strictEqual(counter, 3); + interval.dispose(); + } + } +} diff --git a/src/observable/BaseObservableCollection.js b/src/observable/BaseObservable.js similarity index 72% rename from src/observable/BaseObservableCollection.js rename to src/observable/BaseObservable.js index f9370f10..3afdd4c0 100644 --- a/src/observable/BaseObservableCollection.js +++ b/src/observable/BaseObservable.js @@ -1,4 +1,4 @@ -export default class BaseObservableCollection { +export class BaseObservable { constructor() { this._handlers = new Set(); } @@ -17,22 +17,26 @@ export default class BaseObservableCollection { this.onSubscribeFirst(); } return () => { - if (handler) { - this._handlers.delete(handler); - if (this._handlers.size === 0) { - this.onUnsubscribeLast(); - } - handler = null; - } - return null; + return this.unsubscribe(handler); }; } + unsubscribe(handler) { + if (handler) { + this._handlers.delete(handler); + if (this._handlers.size === 0) { + this.onUnsubscribeLast(); + } + handler = null; + } + return null; + } + // Add iterator over handlers here } export function tests() { - class Collection extends BaseObservableCollection { + class Collection extends BaseObservable { constructor() { super(); this.firstSubscribeCalls = 0; diff --git a/src/observable/ObservableValue.js b/src/observable/ObservableValue.js new file mode 100644 index 00000000..b9fa4c4d --- /dev/null +++ b/src/observable/ObservableValue.js @@ -0,0 +1,120 @@ +import {AbortError} from "../utils/error.js"; +import {BaseObservable} from "./BaseObservable.js"; + +// like an EventEmitter, but doesn't have an event type +export class BaseObservableValue extends BaseObservable { + emit(argument) { + for (const h of this._handlers) { + h(argument); + } + } + +} + +class WaitForHandle { + constructor(observable, predicate) { + this._promise = new Promise((resolve, reject) => { + this._reject = reject; + this._subscription = observable.subscribe(v => { + if (predicate(v)) { + this._reject = null; + resolve(v); + this.dispose(); + } + }); + }); + } + + get promise() { + return this._promise; + } + + dispose() { + if (this._subscription) { + this._subscription(); + this._subscription = null; + } + if (this._reject) { + this._reject(new AbortError()); + this._reject = null; + } + } +} + +class ResolvedWaitForHandle { + constructor(promise) { + this.promise = promise; + } + + dispose() {} +} + +export class ObservableValue extends BaseObservableValue { + constructor(initialValue) { + super(); + this._value = initialValue; + } + + get() { + return this._value; + } + + set(value) { + if (value !== this._value) { + this._value = value; + this.emit(this._value); + } + } + + waitFor(predicate) { + if (predicate(this.get())) { + return new ResolvedWaitForHandle(Promise.resolve(this.get())); + } else { + return new WaitForHandle(this, predicate); + } + } +} + +export function tests() { + return { + "set emits an update": assert => { + const a = new ObservableValue(); + let fired = false; + const subscription = a.subscribe(v => { + fired = true; + assert.strictEqual(v, 5); + }); + a.set(5); + assert(fired); + subscription(); + }, + "set doesn't emit if value hasn't changed": assert => { + const a = new ObservableValue(5); + let fired = false; + const subscription = a.subscribe(() => { + fired = true; + }); + a.set(5); + a.set(5); + assert(!fired); + subscription(); + }, + "waitFor promise resolves on matching update": async assert => { + const a = new ObservableValue(5); + const handle = a.waitFor(v => v === 6); + Promise.resolve().then(() => { + a.set(6); + }); + await handle.promise; + assert.strictEqual(a.get(), 6); + }, + "waitFor promise rejects when disposed": async assert => { + const a = new ObservableValue(); + const handle = a.waitFor(() => false); + Promise.resolve().then(() => { + handle.dispose(); + }); + await assert.rejects(handle.promise, AbortError); + }, + } +} diff --git a/src/observable/index.js b/src/observable/index.js index 5444e27e..2497a7db 100644 --- a/src/observable/index.js +++ b/src/observable/index.js @@ -1,13 +1,13 @@ -import SortedMapList from "./list/SortedMapList.js"; -import FilteredMap from "./map/FilteredMap.js"; -import MappedMap from "./map/MappedMap.js"; -import BaseObservableMap from "./map/BaseObservableMap.js"; +import {SortedMapList} from "./list/SortedMapList.js"; +import {FilteredMap} from "./map/FilteredMap.js"; +import {MappedMap} from "./map/MappedMap.js"; +import {BaseObservableMap} from "./map/BaseObservableMap.js"; // re-export "root" (of chain) collections -export { default as ObservableArray } from "./list/ObservableArray.js"; -export { default as SortedArray } from "./list/SortedArray.js"; -export { default as MappedList } from "./list/MappedList.js"; -export { default as ConcatList } from "./list/ConcatList.js"; -export { default as ObservableMap } from "./map/ObservableMap.js"; +export { ObservableArray } from "./list/ObservableArray.js"; +export { SortedArray } from "./list/SortedArray.js"; +export { MappedList } from "./list/MappedList.js"; +export { ConcatList } from "./list/ConcatList.js"; +export { ObservableMap } from "./map/ObservableMap.js"; // avoid circular dependency between these classes // and BaseObservableMap (as they extend it) diff --git a/src/observable/list/BaseObservableList.js b/src/observable/list/BaseObservableList.js index cdab32f3..4f15d02a 100644 --- a/src/observable/list/BaseObservableList.js +++ b/src/observable/list/BaseObservableList.js @@ -1,6 +1,6 @@ -import BaseObservableCollection from "../BaseObservableCollection.js"; +import {BaseObservable} from "../BaseObservable.js"; -export default class BaseObservableList extends BaseObservableCollection { +export class BaseObservableList extends BaseObservable { emitReset() { for(let h of this._handlers) { h.onReset(this); diff --git a/src/observable/list/ConcatList.js b/src/observable/list/ConcatList.js index 6177f6f3..987dbb80 100644 --- a/src/observable/list/ConcatList.js +++ b/src/observable/list/ConcatList.js @@ -1,6 +1,6 @@ -import BaseObservableList from "./BaseObservableList.js"; +import {BaseObservableList} from "./BaseObservableList.js"; -export default class ConcatList extends BaseObservableList { +export class ConcatList extends BaseObservableList { constructor(...sourceLists) { super(); this._sourceLists = sourceLists; @@ -86,7 +86,7 @@ export default class ConcatList extends BaseObservableList { } } -import ObservableArray from "./ObservableArray.js"; +import {ObservableArray} from "./ObservableArray.js"; export async function tests() { return { test_length(assert) { diff --git a/src/observable/list/MappedList.js b/src/observable/list/MappedList.js index a2adcdbd..55b8bd30 100644 --- a/src/observable/list/MappedList.js +++ b/src/observable/list/MappedList.js @@ -1,6 +1,6 @@ -import BaseObservableList from "./BaseObservableList.js"; +import {BaseObservableList} from "./BaseObservableList.js"; -export default class MappedList extends BaseObservableList { +export class MappedList extends BaseObservableList { constructor(sourceList, mapper, updater) { super(); this._sourceList = sourceList; diff --git a/src/observable/list/ObservableArray.js b/src/observable/list/ObservableArray.js index 47b0e24a..afbc0144 100644 --- a/src/observable/list/ObservableArray.js +++ b/src/observable/list/ObservableArray.js @@ -1,6 +1,6 @@ -import BaseObservableList from "./BaseObservableList.js"; +import {BaseObservableList} from "./BaseObservableList.js"; -export default class ObservableArray extends BaseObservableList { +export class ObservableArray extends BaseObservableList { constructor(initialValues = []) { super(); this._items = initialValues; diff --git a/src/observable/list/SortedArray.js b/src/observable/list/SortedArray.js index 6b34afdf..5bb89297 100644 --- a/src/observable/list/SortedArray.js +++ b/src/observable/list/SortedArray.js @@ -1,7 +1,7 @@ -import BaseObservableList from "./BaseObservableList.js"; -import sortedIndex from "../../utils/sortedIndex.js"; +import {BaseObservableList} from "./BaseObservableList.js"; +import {sortedIndex} from "../../utils/sortedIndex.js"; -export default class SortedArray extends BaseObservableList { +export class SortedArray extends BaseObservableList { constructor(comparator) { super(); this._comparator = comparator; diff --git a/src/observable/list/SortedMapList.js b/src/observable/list/SortedMapList.js index ebd7e86a..154febc3 100644 --- a/src/observable/list/SortedMapList.js +++ b/src/observable/list/SortedMapList.js @@ -1,5 +1,5 @@ -import BaseObservableList from "./BaseObservableList.js"; -import sortedIndex from "../../utils/sortedIndex.js"; +import {BaseObservableList} from "./BaseObservableList.js"; +import {sortedIndex} from "../../utils/sortedIndex.js"; /* @@ -29,7 +29,7 @@ with a node containing {value, leftCount, rightCount, leftNode, rightNode, paren // types modified outside of the collection (and affecting sort order) or not // no duplicates allowed for now -export default class SortedMapList extends BaseObservableList { +export class SortedMapList extends BaseObservableList { constructor(sourceMap, comparator) { super(); this._sourceMap = sourceMap; @@ -113,8 +113,7 @@ export default class SortedMapList extends BaseObservableList { } } -//#ifdef TESTS -import ObservableMap from "../map/ObservableMap.js"; +import {ObservableMap} from "../map/ObservableMap.js"; export function tests() { return { @@ -250,4 +249,3 @@ export function tests() { }, } } -//#endif diff --git a/src/observable/map/BaseObservableMap.js b/src/observable/map/BaseObservableMap.js index fb8a6a3f..c825af8e 100644 --- a/src/observable/map/BaseObservableMap.js +++ b/src/observable/map/BaseObservableMap.js @@ -1,6 +1,6 @@ -import BaseObservableCollection from "../BaseObservableCollection.js"; +import {BaseObservable} from "../BaseObservable.js"; -export default class BaseObservableMap extends BaseObservableCollection { +export class BaseObservableMap extends BaseObservable { emitReset() { for(let h of this._handlers) { h.onReset(); diff --git a/src/observable/map/FilteredMap.js b/src/observable/map/FilteredMap.js index b48008b8..17500ecc 100644 --- a/src/observable/map/FilteredMap.js +++ b/src/observable/map/FilteredMap.js @@ -1,6 +1,6 @@ -import BaseObservableMap from "./BaseObservableMap.js"; +import {BaseObservableMap} from "./BaseObservableMap.js"; -export default class FilteredMap extends BaseObservableMap { +export class FilteredMap extends BaseObservableMap { constructor(source, mapper, updater) { super(); this._source = source; diff --git a/src/observable/map/MappedMap.js b/src/observable/map/MappedMap.js index 4b16373b..14f46c44 100644 --- a/src/observable/map/MappedMap.js +++ b/src/observable/map/MappedMap.js @@ -1,9 +1,9 @@ -import BaseObservableMap from "./BaseObservableMap.js"; +import {BaseObservableMap} from "./BaseObservableMap.js"; /* so a mapped value can emit updates on it's own with this._updater that is passed in the mapping function how should the mapped value be notified of an update though? and can it then decide to not propagate the update? */ -export default class MappedMap extends BaseObservableMap { +export class MappedMap extends BaseObservableMap { constructor(source, mapper) { super(); this._source = source; diff --git a/src/observable/map/ObservableMap.js b/src/observable/map/ObservableMap.js index cd864ef4..cfc366ab 100644 --- a/src/observable/map/ObservableMap.js +++ b/src/observable/map/ObservableMap.js @@ -1,6 +1,6 @@ -import BaseObservableMap from "./BaseObservableMap.js"; +import {BaseObservableMap} from "./BaseObservableMap.js"; -export default class ObservableMap extends BaseObservableMap { +export class ObservableMap extends BaseObservableMap { constructor(initialValues) { super(); this._values = new Map(initialValues); @@ -56,7 +56,6 @@ export default class ObservableMap extends BaseObservableMap { } } -//#ifdef TESTS export function tests() { return { test_initial_values(assert) { @@ -152,4 +151,3 @@ export function tests() { }, } } -//#endif diff --git a/src/ui/web/BrawlView.js b/src/ui/web/BrawlView.js index c5642e15..27779dc6 100644 --- a/src/ui/web/BrawlView.js +++ b/src/ui/web/BrawlView.js @@ -1,10 +1,10 @@ -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"; +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 { +export class BrawlView { constructor(vm) { this._vm = vm; this._switcher = null; @@ -16,8 +16,6 @@ export default class BrawlView { 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": diff --git a/src/ui/web/WebPlatform.js b/src/ui/web/WebPlatform.js index 4f3d9e06..453e36e6 100644 --- a/src/ui/web/WebPlatform.js +++ b/src/ui/web/WebPlatform.js @@ -1,4 +1,4 @@ -export default { +export const WebPlatform = { get minStorageKey() { // for indexeddb, we use unsigned 32 bit integers as keys return 0; diff --git a/src/ui/web/common.js b/src/ui/web/common.js new file mode 100644 index 00000000..28faccef --- /dev/null +++ b/src/ui/web/common.js @@ -0,0 +1,5 @@ +export function spinner(t, extraClasses = undefined) { + return t.svg({className: Object.assign({"spinner": true}, extraClasses), viewBox:"0 0 100 100"}, + t.circle({cx:"50%", cy:"50%", r:"45%", pathLength:"100"}) + ); +} diff --git a/src/ui/web/css/login.css b/src/ui/web/css/login.css new file mode 100644 index 00000000..3a12f849 --- /dev/null +++ b/src/ui/web/css/login.css @@ -0,0 +1,12 @@ +.SessionLoadView { + display: flex; +} + +.SessionLoadView p { + flex: 1; + margin: 0 0 0 10px; +} + +.SessionLoadView .spinner { + --size: 20px; +} diff --git a/src/ui/web/css/main.css b/src/ui/web/css/main.css index 1ba316a7..7a7f2f74 100644 --- a/src/ui/web/css/main.css +++ b/src/ui/web/css/main.css @@ -1,25 +1,45 @@ @import url('layout.css'); +@import url('login.css'); @import url('left-panel.css'); @import url('room.css'); @import url('timeline.css'); @import url('avatar.css'); +@import url('spinner.css'); -body { +.brawl { margin: 0; - font-family: sans-serif; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Ubuntu, Cantarell, sans-serif, + "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; background-color: black; color: white; } -.SyncStatusBar { - background-color: #555; - display: none; +.hiddenWithLayout { + visibility: hidden; } -.SyncStatusBar_shown { - display: unset; +.hidden { + display: none !important; } +.SessionStatusView { + display: flex; + padding: 5px; + background-color: #555; +} + +.SessionStatusView p { + margin: 0 10px; +} + +.SessionStatusView button { + border: none; + background: none; + color: currentcolor; + text-decoration: underline; +} + + .RoomPlaceholderView { display: flex; align-items: center; diff --git a/src/ui/web/css/spinner.css b/src/ui/web/css/spinner.css new file mode 100644 index 00000000..be8192a6 --- /dev/null +++ b/src/ui/web/css/spinner.css @@ -0,0 +1,34 @@ +@keyframes spinner { + 0% { + transform: rotate(0); + stroke-dasharray: 0 0 10 90; + } + 45% { + stroke-dasharray: 0 0 90 10; + } + 75% { + stroke-dasharray: 0 50 50 0; + } + 100% { + transform: rotate(360deg); + stroke-dasharray: 10 90 0 0; + } +} + +.spinner circle { + transform-origin: 50% 50%; + animation-name: spinner; + animation-duration: 2s; + animation-iteration-count: infinite; + animation-timing-function: linear; + fill: none; + stroke: currentcolor; + stroke-width: 12; + stroke-linecap: butt; +} + +.spinner { + --size: 20px; + width: var(--size); + height: var(--size); +} diff --git a/src/ui/web/dom/Clock.js b/src/ui/web/dom/Clock.js new file mode 100644 index 00000000..f5cc6ed0 --- /dev/null +++ b/src/ui/web/dom/Clock.js @@ -0,0 +1,74 @@ +import {AbortError} from "../../../utils/error.js"; + +class Timeout { + constructor(ms) { + this._reject = null; + this._handle = null; + this._promise = new Promise((resolve, reject) => { + this._reject = reject; + this._handle = setTimeout(() => { + this._reject = null; + resolve(); + }, ms); + }); + } + + elapsed() { + return this._promise; + } + + abort() { + if (this._reject) { + this._reject(new AbortError()); + clearTimeout(this._handle); + this._handle = null; + this._reject = null; + } + } + + dispose() { + this.abort(); + } +} + +class Interval { + constructor(ms, callback) { + this._handle = setInterval(callback, ms); + } + + dispose() { + if (this._handle) { + clearInterval(this._handle); + this._handle = null; + } + } +} + + +class TimeMeasure { + constructor() { + this._start = window.performance.now(); + } + + measure() { + return window.performance.now() - this._start; + } +} + +export class Clock { + createMeasure() { + return new TimeMeasure(); + } + + createTimeout(ms) { + return new Timeout(ms); + } + + createInterval(callback, ms) { + return new Interval(ms, callback); + } + + now() { + return Date.now(); + } +} diff --git a/src/ui/web/dom/OnlineStatus.js b/src/ui/web/dom/OnlineStatus.js new file mode 100644 index 00000000..1cd3a9b5 --- /dev/null +++ b/src/ui/web/dom/OnlineStatus.js @@ -0,0 +1,31 @@ +import {BaseObservableValue} from "../../../observable/ObservableValue.js"; + +export class OnlineStatus extends BaseObservableValue { + 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/ListView.js b/src/ui/web/general/ListView.js index fb5be255..18fc524b 100644 --- a/src/ui/web/general/ListView.js +++ b/src/ui/web/general/ListView.js @@ -1,13 +1,5 @@ import {tag} from "./html.js"; -class UIView { - mount() {} - unmount() {} - update(_value) {} - // can only be called between a call to mount and unmount - root() {} -} - function insertAt(parentNode, idx, childNode) { const isLast = idx === parentNode.childElementCount; if (isLast) { @@ -18,7 +10,9 @@ function insertAt(parentNode, idx, childNode) { } } -export default class ListView { +const MOUNT_ARGS = {parentProvidesUpdates: true}; + +export class ListView { constructor({list, onItemClick, className}, childCreator) { this._onItemClick = onItemClick; this._list = list; @@ -61,7 +55,9 @@ export default class ListView { } unmount() { - this._unloadList(); + if (this._list) { + this._unloadList(); + } } _onClick(event) { @@ -94,7 +90,7 @@ export default class ListView { for (let item of this._list) { const child = this._childCreator(item); this._childInstances.push(child); - const childDomNode = child.mount(); + const childDomNode = child.mount(MOUNT_ARGS); this._root.appendChild(childDomNode); } } @@ -103,7 +99,7 @@ export default class ListView { this.onBeforeListChanged(); const child = this._childCreator(value); this._childInstances.splice(idx, 0, child); - insertAt(this._root, idx, child.mount()); + insertAt(this._root, idx, child.mount(MOUNT_ARGS)); this.onListChanged(); } diff --git a/src/ui/web/general/SwitchView.js b/src/ui/web/general/SwitchView.js index 7789cb1e..66950052 100644 --- a/src/ui/web/general/SwitchView.js +++ b/src/ui/web/general/SwitchView.js @@ -1,4 +1,6 @@ -export default class SwitchView { +import {errorToDOM} from "./error.js"; + +export class SwitchView { constructor(defaultView) { this._childView = defaultView; } @@ -23,7 +25,12 @@ export default class SwitchView { const oldRoot = this.root(); this._childView.unmount(); this._childView = newView; - const newRoot = this._childView.mount(); + let newRoot; + try { + newRoot = this._childView.mount(); + } catch (err) { + newRoot = errorToDOM(err); + } const parent = oldRoot.parentElement; if (parent) { parent.replaceChild(newRoot, oldRoot); @@ -34,3 +41,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); + } +}); +*/ +export 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/ui/web/general/Template.js b/src/ui/web/general/Template.js deleted file mode 100644 index b8c566c2..00000000 --- a/src/ui/web/general/Template.js +++ /dev/null @@ -1,226 +0,0 @@ -import { setAttribute, text, isChildren, classNames, TAG_NAMES } from "./html.js"; - - -function objHasFns(obj) { - for(const value of Object.values(obj)) { - if (typeof value === "function") { - return true; - } - } - return false; -} -/** - Bindable template. Renders once, and allows bindings for given nodes. If you need - to change the structure on a condition, use a subtemplate (if) - - supports - - event handlers (attribute fn value with name that starts with on) - - one way binding of attributes (other attribute fn value) - - one way binding of text values (child fn value) - - refs to get dom nodes - - className binding returning object with className => enabled map - missing: - - create views -*/ -export default class Template { - constructor(value, render) { - this._value = value; - this._eventListeners = null; - this._bindings = null; - this._subTemplates = null; - this._root = render(this, this._value); - this._attach(); - } - - root() { - return this._root; - } - - update(value) { - this._value = value; - if (this._bindings) { - for (const binding of this._bindings) { - binding(); - } - } - if (this._subTemplates) { - for (const sub of this._subTemplates) { - sub.update(value); - } - } - } - - dispose() { - if (this._eventListeners) { - for (let {node, name, fn} of this._eventListeners) { - node.removeEventListener(name, fn); - } - } - if (this._subTemplates) { - for (const sub of this._subTemplates) { - sub.dispose(); - } - } - } - - _attach() { - if (this._eventListeners) { - for (let {node, name, fn} of this._eventListeners) { - node.addEventListener(name, fn); - } - } - } - - _addEventListener(node, name, fn) { - if (!this._eventListeners) { - this._eventListeners = []; - } - this._eventListeners.push({node, name, fn}); - } - - _addBinding(bindingFn) { - if (!this._bindings) { - this._bindings = []; - } - this._bindings.push(bindingFn); - } - - _addSubTemplate(t) { - if (!this._subTemplates) { - this._subTemplates = []; - } - this._subTemplates.push(t); - } - - _addAttributeBinding(node, name, fn) { - let prevValue = undefined; - const binding = () => { - const newValue = fn(this._value); - if (prevValue !== newValue) { - prevValue = newValue; - setAttribute(node, name, newValue); - } - }; - this._addBinding(binding); - binding(); - } - - _addClassNamesBinding(node, obj) { - this._addAttributeBinding(node, "className", value => classNames(obj, value)); - } - - _addTextBinding(fn) { - const initialValue = fn(this._value); - const node = text(initialValue); - let prevValue = initialValue; - const binding = () => { - const newValue = fn(this._value); - if (prevValue !== newValue) { - prevValue = newValue; - node.textContent = newValue+""; - } - }; - - this._addBinding(binding); - return node; - } - - el(name, attributes, children) { - if (attributes && isChildren(attributes)) { - children = attributes; - attributes = null; - } - - const node = document.createElement(name); - - if (attributes) { - this._setNodeAttributes(node, attributes); - } - if (children) { - this._setNodeChildren(node, children); - } - - return node; - } - - _setNodeAttributes(node, attributes) { - for(let [key, value] of Object.entries(attributes)) { - const isFn = typeof value === "function"; - // binding for className as object of className => enabled - if (key === "className" && typeof value === "object" && value !== null) { - if (objHasFns(value)) { - this._addClassNamesBinding(node, value); - } else { - setAttribute(node, key, classNames(value)); - } - } else if (key.startsWith("on") && key.length > 2 && isFn) { - const eventName = key.substr(2, 1).toLowerCase() + key.substr(3); - const handler = value; - this._addEventListener(node, eventName, handler); - } else if (isFn) { - this._addAttributeBinding(node, key, value); - } else { - setAttribute(node, key, value); - } - } - } - - _setNodeChildren(node, children) { - if (!Array.isArray(children)) { - children = [children]; - } - for (let child of children) { - if (typeof child === "function") { - child = this._addTextBinding(child); - } else if (!child.nodeType) { - // not a DOM node, turn into text - child = text(child); - } - node.appendChild(child); - } - } - - _addReplaceNodeBinding(fn, renderNode) { - let prevValue = fn(this._value); - let node = renderNode(null); - - const binding = () => { - const newValue = fn(this._value); - if (prevValue !== newValue) { - prevValue = newValue; - const newNode = renderNode(node); - if (node.parentElement) { - node.parentElement.replaceChild(newNode, node); - } - node = newNode; - } - }; - this._addBinding(binding); - return node; - } - - // creates a conditional subtemplate - if(fn, render) { - const boolFn = value => !!fn(value); - return this._addReplaceNodeBinding(boolFn, (prevNode) => { - if (prevNode && prevNode.nodeType !== Node.COMMENT_NODE) { - const templateIdx = this._subTemplates.findIndex(t => t.root() === prevNode); - const [template] = this._subTemplates.splice(templateIdx, 1); - template.dispose(); - } - if (boolFn(this._value)) { - const template = new Template(this._value, render); - this._addSubTemplate(template); - return template.root(); - } else { - return document.createComment("if placeholder"); - } - }); - } -} - -for (const tag of TAG_NAMES) { - Template.prototype[tag] = function(attributes, children) { - return this.el(tag, attributes, children); - }; -} diff --git a/src/ui/web/general/TemplateView.js b/src/ui/web/general/TemplateView.js index f6be9292..8cdccdf1 100644 --- a/src/ui/web/general/TemplateView.js +++ b/src/ui/web/general/TemplateView.js @@ -1,39 +1,315 @@ -import Template from "./Template.js"; +import { setAttribute, text, isChildren, classNames, TAG_NAMES, HTML_NS } from "./html.js"; +import {errorToDOM} from "./error.js"; -export default class TemplateView { - constructor(vm, bindToChangeEvent) { - this.viewModel = vm; - this._changeEventHandler = bindToChangeEvent ? this.update.bind(this, this.viewModel) : null; - this._template = null; - } - - render() { - throw new Error("render not implemented"); - } - - mount() { - if (this._changeEventHandler) { - this.viewModel.on("change", this._changeEventHandler); +function objHasFns(obj) { + for(const value of Object.values(obj)) { + if (typeof value === "function") { + return true; } - this._template = new Template(this.viewModel, (t, value) => this.render(t, value)); - return this.root(); + } + return false; +} +/** + Bindable template. Renders once, and allows bindings for given nodes. If you need + to change the structure on a condition, use a subtemplate (if) + + supports + - event handlers (attribute fn value with name that starts with on) + - one way binding of attributes (other attribute fn value) + - one way binding of text values (child fn value) + - refs to get dom nodes + - className binding returning object with className => enabled map + - add subviews inside the template +*/ +export class TemplateView { + constructor(value, render = undefined) { + this._value = value; + this._render = render; + this._eventListeners = null; + this._bindings = null; + // this should become _subViews and also include templates. + // How do we know which ones we should update though? + // Wrapper class? + this._subViews = null; + this._root = null; + this._boundUpdateFromValue = null; } - root() { - return this._template.root(); + get value() { + return this._value; + } + + _subscribe() { + if (typeof this._value.on === "function") { + this._boundUpdateFromValue = this._updateFromValue.bind(this); + this._value.on("change", this._boundUpdateFromValue); + } + } + + _unsubscribe() { + if (this._boundUpdateFromValue) { + if (typeof this._value.off === "function") { + this._value.off("change", this._boundUpdateFromValue); + } + this._boundUpdateFromValue = null; + } + } + + _attach() { + if (this._eventListeners) { + for (let {node, name, fn} of this._eventListeners) { + node.addEventListener(name, fn); + } + } + } + + _detach() { + if (this._eventListeners) { + for (let {node, name, fn} of this._eventListeners) { + node.removeEventListener(name, fn); + } + } + } + + mount(options) { + const builder = new TemplateBuilder(this); + if (this._render) { + this._root = this._render(builder, this._value); + } else if (this.render) { // overriden in subclass + this._root = this.render(builder, this._value); + } else { + throw new Error("no render function passed in, or overriden in subclass"); + } + const parentProvidesUpdates = options && options.parentProvidesUpdates; + if (!parentProvidesUpdates) { + this._subscribe(); + } + this._attach(); + return this._root; } unmount() { - if (this._changeEventHandler) { - this.viewModel.off("change", this._changeEventHandler); + this._detach(); + this._unsubscribe(); + if (this._subViews) { + for (const v of this._subViews) { + v.unmount(); + } } - this._template.dispose(); - this._template = null; } - update(value, prop) { - if (this._template) { - this._template.update(value); + root() { + return this._root; + } + + _updateFromValue(changedProps) { + this.update(this._value, changedProps); + } + + update(value) { + this._value = value; + if (this._bindings) { + for (const binding of this._bindings) { + binding(); + } } } + + _addEventListener(node, name, fn) { + if (!this._eventListeners) { + this._eventListeners = []; + } + this._eventListeners.push({node, name, fn}); + } + + _addBinding(bindingFn) { + if (!this._bindings) { + this._bindings = []; + } + this._bindings.push(bindingFn); + } + + _addSubView(view) { + if (!this._subViews) { + this._subViews = []; + } + this._subViews.push(view); + } +} + +// what is passed to render +class TemplateBuilder { + constructor(templateView) { + this._templateView = templateView; + } + + get _value() { + return this._templateView._value; + } + + _addAttributeBinding(node, name, fn) { + let prevValue = undefined; + const binding = () => { + const newValue = fn(this._value); + if (prevValue !== newValue) { + prevValue = newValue; + setAttribute(node, name, newValue); + } + }; + this._templateView._addBinding(binding); + binding(); + } + + _addClassNamesBinding(node, obj) { + this._addAttributeBinding(node, "className", value => classNames(obj, value)); + } + + _addTextBinding(fn) { + const initialValue = fn(this._value); + const node = text(initialValue); + let prevValue = initialValue; + const binding = () => { + const newValue = fn(this._value); + if (prevValue !== newValue) { + prevValue = newValue; + node.textContent = newValue+""; + } + }; + + this._templateView._addBinding(binding); + return node; + } + + _setNodeAttributes(node, attributes) { + for(let [key, value] of Object.entries(attributes)) { + const isFn = typeof value === "function"; + // binding for className as object of className => enabled + if (key === "className" && typeof value === "object" && value !== null) { + if (objHasFns(value)) { + this._addClassNamesBinding(node, value); + } else { + setAttribute(node, key, classNames(value)); + } + } else if (key.startsWith("on") && key.length > 2 && isFn) { + const eventName = key.substr(2, 1).toLowerCase() + key.substr(3); + const handler = value; + this._templateView._addEventListener(node, eventName, handler); + } else if (isFn) { + this._addAttributeBinding(node, key, value); + } else { + setAttribute(node, key, value); + } + } + } + + _setNodeChildren(node, children) { + if (!Array.isArray(children)) { + children = [children]; + } + for (let child of children) { + if (typeof child === "function") { + child = this._addTextBinding(child); + } else if (!child.nodeType) { + // not a DOM node, turn into text + child = text(child); + } + node.appendChild(child); + } + } + + _addReplaceNodeBinding(fn, renderNode) { + let prevValue = fn(this._value); + let node = renderNode(null); + + const binding = () => { + const newValue = fn(this._value); + if (prevValue !== newValue) { + prevValue = newValue; + const newNode = renderNode(node); + if (node.parentElement) { + node.parentElement.replaceChild(newNode, node); + } + node = newNode; + } + }; + this._templateView._addBinding(binding); + return node; + } + + el(name, attributes, children) { + return this.elNS(HTML_NS, name, attributes, children); + } + + elNS(ns, name, attributes, children) { + if (attributes && isChildren(attributes)) { + children = attributes; + attributes = null; + } + + const node = document.createElementNS(ns, name); + + if (attributes) { + this._setNodeAttributes(node, attributes); + } + if (children) { + this._setNodeChildren(node, children); + } + + return node; + } + + // this insert a view, and is not a view factory for `if`, so returns the root element to insert in the template + // you should not call t.view() and not use the result (e.g. attach the result to the template DOM tree). + view(view) { + let root; + try { + root = view.mount(); + } catch (err) { + return errorToDOM(err); + } + this._templateView._addSubView(view); + return root; + } + + // sugar + createTemplate(render) { + return vm => new TemplateView(vm, render); + } + + // map a value to a view, every time the value changes + mapView(mapFn, viewCreator) { + return this._addReplaceNodeBinding(mapFn, (prevNode) => { + if (prevNode && prevNode.nodeType !== Node.COMMENT_NODE) { + const subViews = this._templateView._subViews; + const viewIdx = subViews.findIndex(v => v.root() === prevNode); + if (viewIdx !== -1) { + const [view] = subViews.splice(viewIdx, 1); + view.unmount(); + } + } + const view = viewCreator(mapFn(this._value)); + if (view) { + return this.view(view); + } else { + return document.createComment("node binding placeholder"); + } + }); + } + + // creates a conditional subtemplate + if(fn, viewCreator) { + return this.mapView( + value => !!fn(value), + enabled => enabled ? viewCreator(this._value) : null + ); + } +} + + +for (const [ns, tags] of Object.entries(TAG_NAMES)) { + for (const tag of tags) { + TemplateBuilder.prototype[tag] = function(attributes, children) { + return this.elNS(ns, tag, attributes, children); + }; + } } diff --git a/src/ui/web/general/error.js b/src/ui/web/general/error.js new file mode 100644 index 00000000..c218504e --- /dev/null +++ b/src/ui/web/general/error.js @@ -0,0 +1,12 @@ +import {tag} from "./html.js"; + +export function errorToDOM(error) { + const stack = new Error().stack; + const callee = stack.split("\n")[1]; + return tag.div([ + tag.h2("Something went wrong…"), + tag.h3(error.message), + tag.p(`This occurred while running ${callee}.`), + tag.pre(error.stack), + ]); +} diff --git a/src/ui/web/general/html.js b/src/ui/web/general/html.js index 9bea640f..7cb001c0 100644 --- a/src/ui/web/general/html.js +++ b/src/ui/web/general/html.js @@ -33,12 +33,16 @@ export function setAttribute(el, name, value) { } export function el(elementName, attributes, children) { + return elNS(HTML_NS, elementName, attributes, children); +} + +export function elNS(ns, elementName, attributes, children) { if (attributes && isChildren(attributes)) { children = attributes; attributes = null; } - const e = document.createElement(elementName); + const e = document.createElementNS(ns, elementName); if (attributes) { for (let [name, value] of Object.entries(attributes)) { @@ -67,15 +71,24 @@ export function text(str) { return document.createTextNode(str); } -export const TAG_NAMES = [ - "a", "ol", "ul", "li", "div", "h1", "h2", "h3", "h4", "h5", "h6", - "p", "strong", "em", "span", "img", "section", "main", "article", "aside", - "pre", "button", "time", "input", "textarea"]; +export const HTML_NS = "http://www.w3.org/1999/xhtml"; +export const SVG_NS = "http://www.w3.org/2000/svg"; + +export const TAG_NAMES = { + [HTML_NS]: [ + "a", "ol", "ul", "li", "div", "h1", "h2", "h3", "h4", "h5", "h6", + "p", "strong", "em", "span", "img", "section", "main", "article", "aside", + "pre", "button", "time", "input", "textarea"], + [SVG_NS]: ["svg", "circle"] +}; export const tag = {}; -for (const tagName of TAG_NAMES) { - tag[tagName] = function(attributes, children) { - return el(tagName, attributes, children); + +for (const [ns, tags] of Object.entries(TAG_NAMES)) { + for (const tagName of tags) { + tag[tagName] = function(attributes, children) { + return elNS(ns, tagName, attributes, children); + } } } diff --git a/src/ui/web/login/LoginView.js b/src/ui/web/login/LoginView.js index e5b97da6..18d99f65 100644 --- a/src/ui/web/login/LoginView.js +++ b/src/ui/web/login/LoginView.js @@ -1,27 +1,27 @@ -import TemplateView from "../general/TemplateView.js"; +import {TemplateView} from "../general/TemplateView.js"; import {brawlGithubLink} from "./common.js"; +import {SessionLoadView} from "./SessionLoadView.js"; -export default class LoginView extends TemplateView { - constructor(vm) { - super(vm, true); - } - +export class LoginView extends TemplateView { render(t, vm) { - const username = t.input({type: "text", placeholder: vm.usernamePlaceholder}); - const password = t.input({type: "password", placeholder: vm.passwordPlaceholder}); - const homeserver = t.input({type: "text", placeholder: vm.hsPlaceholder, value: vm.defaultHomeServer}); + const disabled = vm => !!vm.isBusy; + const username = t.input({type: "text", placeholder: vm.i18n`Username`, disabled}); + const password = t.input({type: "password", placeholder: vm.i18n`Password`, disabled}); + const homeserver = t.input({type: "text", placeholder: vm.i18n`Your matrix homeserver`, value: vm.defaultHomeServer, disabled}); 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.h1([vm.i18n`Log in to your homeserver`]), + t.if(vm => vm.error, t.createTemplate(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.loading - }, "Log In")), - t.div(t.button({onClick: () => vm.cancel()}, ["Pick an existing session"])), + disabled + }, vm.i18n`Log In`)), + t.div(t.button({onClick: () => vm.cancel(), disabled}, [vm.i18n`Pick an existing session`])), + t.mapView(vm => vm.loadViewModel, loadViewModel => loadViewModel ? new SessionLoadView(loadViewModel) : null), t.p(brawlGithubLink(t)) ]); } } + diff --git a/src/ui/web/login/SessionLoadView.js b/src/ui/web/login/SessionLoadView.js new file mode 100644 index 00000000..5340844b --- /dev/null +++ b/src/ui/web/login/SessionLoadView.js @@ -0,0 +1,11 @@ +import {TemplateView} from "../general/TemplateView.js"; +import {spinner} from "../common.js"; + +export class SessionLoadView extends TemplateView { + render(t) { + return t.div({className: "SessionLoadView"}, [ + spinner(t, {hiddenWithLayout: vm => !vm.loading}), + t.p(vm => vm.loadLabel) + ]); + } +} diff --git a/src/ui/web/login/SessionPickerView.js b/src/ui/web/login/SessionPickerView.js index 1dca2624..d81247ac 100644 --- a/src/ui/web/login/SessionPickerView.js +++ b/src/ui/web/login/SessionPickerView.js @@ -1,6 +1,7 @@ -import ListView from "../general/ListView.js"; -import TemplateView from "../general/TemplateView.js"; +import {ListView} from "../general/ListView.js"; +import {TemplateView} from "../general/TemplateView.js"; import {brawlGithubLink} from "./common.js"; +import {SessionLoadView} from "./SessionLoadView.js"; function selectFileAsText(mimeType) { const input = document.createElement("input"); @@ -27,39 +28,35 @@ function selectFileAsText(mimeType) { class SessionPickerItemView extends TemplateView { - constructor(vm) { - super(vm, true); - } - _onDeleteClick() { if (confirm("Are you sure?")) { - this.viewModel.delete(); + this.value.delete(); } } - render(t) { + render(t, vm) { const deleteButton = t.button({ disabled: vm => vm.isDeleting, onClick: this._onDeleteClick.bind(this), }, "Delete"); const clearButton = t.button({ disabled: vm => vm.isClearing, - onClick: () => this.viewModel.clear(), + onClick: () => vm.clear(), }, "Clear"); const exportButton = t.button({ disabled: vm => vm.isClearing, - onClick: () => this.viewModel.export(), + onClick: () => vm.export(), }, "Export"); - const downloadExport = t.if(vm => vm.exportDataUrl, (t, vm) => { + const downloadExport = t.if(vm => vm.exportDataUrl, t.createTemplate((t, vm) => { return t.a({ href: vm.exportDataUrl, - download: `brawl-session-${this.viewModel.id}.json`, - onClick: () => setTimeout(() => this.viewModel.clearExport(), 100), + download: `brawl-session-${vm.id}.json`, + onClick: () => setTimeout(() => vm.clearExport(), 100), }, "Download"); - }); + })); const userName = t.span({className: "userId"}, vm => vm.label); - const errorMessage = t.if(vm => vm.error, t => t.span({className: "error"}, vm => vm.error)); + const errorMessage = t.if(vm => vm.error, t.createTemplate(t => t.span({className: "error"}, vm => vm.error))); return t.li([t.div({className: "sessionInfo"}, [ userName, errorMessage, @@ -71,33 +68,26 @@ class SessionPickerItemView extends TemplateView { } } -export default class SessionPickerView extends TemplateView { - mount() { - this._sessionList = new ListView({ - list: this.viewModel.sessions, +export class SessionPickerView extends TemplateView { + render(t, vm) { + const sessionList = new ListView({ + list: vm.sessions, onItemClick: (item, event) => { if (event.target.closest(".userId")) { - this.viewModel.pick(item.viewModel.id); + vm.pick(item.value.id); } }, }, sessionInfo => { return new SessionPickerItemView(sessionInfo); }); - return super.mount(); - } - render(t) { return t.div({className: "SessionPickerView"}, [ t.h1(["Pick a session"]), - this._sessionList.mount(), - t.p(t.button({onClick: () => this.viewModel.cancel()}, ["Log in to a new session instead"])), - t.p(t.button({onClick: async () => this.viewModel.import(await selectFileAsText("application/json"))}, "Import")), + t.view(sessionList), + t.p(t.button({onClick: () => vm.cancel()}, ["Log in to a new session instead"])), + t.p(t.button({onClick: async () => vm.import(await selectFileAsText("application/json"))}, "Import")), + t.if(vm => vm.loadViewModel, vm => new SessionLoadView(vm.loadViewModel)), t.p(brawlGithubLink(t)) ]); } - - unmount() { - super.unmount(); - this._sessionList.unmount(); - } } diff --git a/src/ui/web/session/RoomPlaceholderView.js b/src/ui/web/session/RoomPlaceholderView.js index d4cb7e0e..3a8f3e27 100644 --- a/src/ui/web/session/RoomPlaceholderView.js +++ b/src/ui/web/session/RoomPlaceholderView.js @@ -1,6 +1,6 @@ import {tag} from "../general/html.js"; -export default class RoomPlaceholderView { +export class RoomPlaceholderView { constructor() { this._root = null; } diff --git a/src/ui/web/session/RoomTile.js b/src/ui/web/session/RoomTile.js index 59c76cd8..c7f3edd9 100644 --- a/src/ui/web/session/RoomTile.js +++ b/src/ui/web/session/RoomTile.js @@ -1,6 +1,6 @@ -import TemplateView from "../general/TemplateView.js"; +import {TemplateView} from "../general/TemplateView.js"; -export default class RoomTile extends TemplateView { +export class RoomTile extends TemplateView { render(t) { return t.li([ t.div({className: "avatar medium"}, vm => vm.avatarInitials), @@ -10,6 +10,6 @@ export default class RoomTile extends TemplateView { // called from ListView clicked() { - this.viewModel.open(); + this.value.open(); } } diff --git a/src/ui/web/session/SessionStatusView.js b/src/ui/web/session/SessionStatusView.js new file mode 100644 index 00000000..84c44dd7 --- /dev/null +++ b/src/ui/web/session/SessionStatusView.js @@ -0,0 +1,16 @@ +import {TemplateView} from "../general/TemplateView.js"; +import {spinner} from "../common.js"; + +export class SessionStatusView extends TemplateView { + render(t, vm) { + return t.div({className: { + "SessionStatusView": true, + "hidden": vm => !vm.isShown, + }}, [ + spinner(t, {hidden: vm => !vm.isWaiting}), + t.p(vm => vm.statusLabel), + t.if(vm => vm.isConnectNowShown, t.createTemplate(t => t.button({onClick: () => vm.connectNow()}, "Retry now"))), + window.DEBUG ? t.button({id: "showlogs"}, "Show logs") : "" + ]); + } +} diff --git a/src/ui/web/session/SessionView.js b/src/ui/web/session/SessionView.js index 9b97ac19..f7925f31 100644 --- a/src/ui/web/session/SessionView.js +++ b/src/ui/web/session/SessionView.js @@ -1,12 +1,12 @@ -import ListView from "../general/ListView.js"; -import RoomTile from "./RoomTile.js"; -import RoomView from "./room/RoomView.js"; -import SwitchView from "../general/SwitchView.js"; -import RoomPlaceholderView from "./RoomPlaceholderView.js"; -import SyncStatusBar from "./SyncStatusBar.js"; +import {ListView} from "../general/ListView.js"; +import {RoomTile} from "./RoomTile.js"; +import {RoomView} from "./room/RoomView.js"; +import {SwitchView} from "../general/SwitchView.js"; +import {RoomPlaceholderView} from "./RoomPlaceholderView.js"; +import {SessionStatusView} from "./SessionStatusView.js"; import {tag} from "../general/html.js"; -export default class SessionView { +export class SessionView { constructor(viewModel) { this._viewModel = viewModel; this._middleSwitcher = null; @@ -22,7 +22,7 @@ export default class SessionView { mount() { this._viewModel.on("change", this._onViewModelChange); - this._syncStatusBar = new SyncStatusBar(this._viewModel.syncStatusViewModel); + this._sessionStatusBar = new SessionStatusView(this._viewModel.sessionStatusViewModel); this._roomList = new ListView( { className: "RoomList", @@ -34,7 +34,7 @@ export default class SessionView { this._middleSwitcher = new SwitchView(new RoomPlaceholderView()); this._root = tag.div({className: "SessionView"}, [ - this._syncStatusBar.mount(), + this._sessionStatusBar.mount(), tag.div({className: "main"}, [ tag.div({className: "LeftPanel"}, this._roomList.mount()), this._middleSwitcher.mount() diff --git a/src/ui/web/session/SyncStatusBar.js b/src/ui/web/session/SyncStatusBar.js deleted file mode 100644 index 792aaa24..00000000 --- a/src/ui/web/session/SyncStatusBar.js +++ /dev/null @@ -1,18 +0,0 @@ -import TemplateView from "../general/TemplateView.js"; - -export default class SyncStatusBar extends TemplateView { - constructor(vm) { - super(vm, true); - } - - render(t, vm) { - return t.div({className: { - "SyncStatusBar": true, - "SyncStatusBar_shown": true, - }}, [ - vm => vm.status, - t.if(vm => !vm.isSyncing, t => t.button({onClick: () => vm.trySync()}, "Try syncing")), - window.DEBUG ? t.button({id: "showlogs"}, "Show logs") : "" - ]); - } -} diff --git a/src/ui/web/session/room/MessageComposer.js b/src/ui/web/session/room/MessageComposer.js index 79e2fd5e..13211965 100644 --- a/src/ui/web/session/room/MessageComposer.js +++ b/src/ui/web/session/room/MessageComposer.js @@ -1,6 +1,6 @@ -import TemplateView from "../../general/TemplateView.js"; +import {TemplateView} from "../../general/TemplateView.js"; -export default class MessageComposer extends TemplateView { +export class MessageComposer extends TemplateView { constructor(viewModel) { super(viewModel); this._input = null; @@ -16,7 +16,7 @@ export default class MessageComposer extends TemplateView { _onKeyDown(event) { if (event.key === "Enter") { - if (this.viewModel.sendMessage(this._input.value)) { + if (this.value.sendMessage(this._input.value)) { this._input.value = ""; } } diff --git a/src/ui/web/session/room/RoomView.js b/src/ui/web/session/room/RoomView.js index f431c16c..fbf6bd10 100644 --- a/src/ui/web/session/room/RoomView.js +++ b/src/ui/web/session/room/RoomView.js @@ -1,14 +1,15 @@ -import TemplateView from "../../general/TemplateView.js"; -import TimelineList from "./TimelineList.js"; -import MessageComposer from "./MessageComposer.js"; +import {TemplateView} from "../../general/TemplateView.js"; +import {TimelineList} from "./TimelineList.js"; +import {MessageComposer} from "./MessageComposer.js"; -export default class RoomView extends TemplateView { +export class RoomView extends TemplateView { constructor(viewModel) { - super(viewModel, true); + super(viewModel); this._timelineList = null; } render(t, vm) { + this._timelineList = new TimelineList(); return t.div({className: "RoomView"}, [ t.div({className: "TimelinePanel"}, [ t.div({className: "RoomHeader"}, [ @@ -19,28 +20,16 @@ export default class RoomView extends TemplateView { ]), ]), t.div({className: "RoomView_error"}, vm => vm.error), - this._timelineList.mount(), - this._composer.mount(), + t.view(this._timelineList), + t.view(new MessageComposer(this.value.composerViewModel)), ]) ]); } - mount() { - this._composer = new MessageComposer(this.viewModel); - this._timelineList = new TimelineList(); - return super.mount(); - } - - unmount() { - this._composer.unmount(); - this._timelineList.unmount(); - super.unmount(); - } - update(value, prop) { super.update(value, prop); if (prop === "timelineViewModel") { - this._timelineList.update({viewModel: this.viewModel.timelineViewModel}); + this._timelineList.update({viewModel: this.value.timelineViewModel}); } } } diff --git a/src/ui/web/session/room/TimelineList.js b/src/ui/web/session/room/TimelineList.js index ab5eee0d..8d042212 100644 --- a/src/ui/web/session/room/TimelineList.js +++ b/src/ui/web/session/room/TimelineList.js @@ -1,9 +1,9 @@ -import ListView from "../../general/ListView.js"; -import GapView from "./timeline/GapView.js"; -import TextMessageView from "./timeline/TextMessageView.js"; -import AnnouncementView from "./timeline/AnnouncementView.js"; +import {ListView} from "../../general/ListView.js"; +import {GapView} from "./timeline/GapView.js"; +import {TextMessageView} from "./timeline/TextMessageView.js"; +import {AnnouncementView} from "./timeline/AnnouncementView.js"; -export default class TimelineList extends ListView { +export class TimelineList extends ListView { constructor(options = {}) { options.className = "Timeline"; super(options, entry => { diff --git a/src/ui/web/session/room/timeline/AnnouncementView.js b/src/ui/web/session/room/timeline/AnnouncementView.js index fff0d081..b377a1c0 100644 --- a/src/ui/web/session/room/timeline/AnnouncementView.js +++ b/src/ui/web/session/room/timeline/AnnouncementView.js @@ -1,6 +1,6 @@ -import TemplateView from "../../../general/TemplateView.js"; +import {TemplateView} from "../../../general/TemplateView.js"; -export default class AnnouncementView extends TemplateView { +export class AnnouncementView extends TemplateView { render(t) { return t.li({className: "AnnouncementView"}, t.div(vm => vm.announcement)); } diff --git a/src/ui/web/session/room/timeline/GapView.js b/src/ui/web/session/room/timeline/GapView.js index 62cde6a6..5687ce58 100644 --- a/src/ui/web/session/room/timeline/GapView.js +++ b/src/ui/web/session/room/timeline/GapView.js @@ -1,6 +1,6 @@ -import TemplateView from "../../../general/TemplateView.js"; +import {TemplateView} from "../../../general/TemplateView.js"; -export default class GapView extends TemplateView { +export class GapView extends TemplateView { render(t, vm) { const className = { GapView: true, @@ -9,10 +9,10 @@ export default class GapView extends TemplateView { const label = (vm.isUp ? "🠝" : "🠟") + " fill gap"; //no binding return t.li({className}, [ t.button({ - onClick: () => this.viewModel.fill(), + onClick: () => vm.fill(), disabled: vm => vm.isLoading }, label), - t.if(vm => vm.error, t => t.strong(vm => vm.error)) + t.if(vm => vm.error, t.createTemplate(t => t.strong(vm => vm.error))) ]); } } diff --git a/src/ui/web/session/room/timeline/TextMessageView.js b/src/ui/web/session/room/timeline/TextMessageView.js index a4d698eb..7cf39cce 100644 --- a/src/ui/web/session/room/timeline/TextMessageView.js +++ b/src/ui/web/session/room/timeline/TextMessageView.js @@ -1,6 +1,6 @@ -import TemplateView from "../../../general/TemplateView.js"; +import {TemplateView} from "../../../general/TemplateView.js"; -export default class TextMessageView extends TemplateView { +export class TextMessageView extends TemplateView { render(t, vm) { // no bindings ... should this be a template view? return t.li( diff --git a/src/ui/web/session/room/timeline/TimelineTile.js b/src/ui/web/session/room/timeline/TimelineTile.js index 4e87f182..003f3191 100644 --- a/src/ui/web/session/room/timeline/TimelineTile.js +++ b/src/ui/web/session/room/timeline/TimelineTile.js @@ -1,6 +1,6 @@ import {tag} from "../../../general/html.js"; -export default class TimelineTile { +export class TimelineTile { constructor(tileVM) { this._tileVM = tileVM; this._root = null; diff --git a/src/ui/web/view-gallery.html b/src/ui/web/view-gallery.html new file mode 100644 index 00000000..4cd8fd79 --- /dev/null +++ b/src/ui/web/view-gallery.html @@ -0,0 +1,66 @@ + + + + + + + + +

View Gallery

+

Session Status Bar

+
+ +

Login

+
+ +

Login Loading

+
+ + + + diff --git a/src/utils/Disposables.js b/src/utils/Disposables.js new file mode 100644 index 00000000..bd84ccf0 --- /dev/null +++ b/src/utils/Disposables.js @@ -0,0 +1,40 @@ +function disposeValue(value) { + if (typeof value === "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) { + if (value === undefined || value === null) { + return null; + } + const idx = this._disposables.indexOf(value); + if (idx !== -1) { + const [foundValue] = this._disposables.splice(idx, 1); + disposeValue(foundValue); + } else { + console.warn("disposable not found, did it leak?", value); + } + return null; + } +} diff --git a/src/EventEmitter.js b/src/utils/EventEmitter.js similarity index 91% rename from src/EventEmitter.js rename to src/utils/EventEmitter.js index fd155a7e..04944bc8 100644 --- a/src/EventEmitter.js +++ b/src/utils/EventEmitter.js @@ -1,4 +1,4 @@ -export default class EventEmitter { +export class EventEmitter { constructor() { this._handlersByName = {}; } @@ -12,6 +12,13 @@ export default class EventEmitter { } } + disposableOn(name, callback) { + this.on(name, callback); + return () => { + this.off(name, callback); + } + } + on(name, callback) { let handlers = this._handlersByName[name]; if (!handlers) { @@ -36,7 +43,7 @@ export default class EventEmitter { onLastSubscriptionRemoved(name) {} } -//#ifdef TESTS + export function tests() { return { test_on_off(assert) { @@ -72,4 +79,3 @@ export function tests() { } }; } -//#endif diff --git a/src/utils/enum.js b/src/utils/enum.js new file mode 100644 index 00000000..3500ef01 --- /dev/null +++ b/src/utils/enum.js @@ -0,0 +1,7 @@ +export function createEnum(...values) { + const obj = {}; + for (const value of values) { + obj[value] = value; + } + return Object.freeze(obj); +} diff --git a/src/utils/error.js b/src/utils/error.js new file mode 100644 index 00000000..98bea1ce --- /dev/null +++ b/src/utils/error.js @@ -0,0 +1,5 @@ +export class AbortError extends Error { + get name() { + return "AbortError"; + } +} diff --git a/src/utils/sortedIndex.js b/src/utils/sortedIndex.js index 70eaa9b8..ac002d81 100644 --- a/src/utils/sortedIndex.js +++ b/src/utils/sortedIndex.js @@ -6,7 +6,7 @@ * Based on Underscore.js 1.8.3 * Copyright Jeremy Ashkenas, DocumentCloud and Investigative Reporters & Editors */ -export default function sortedIndex(array, value, comparator) { +export function sortedIndex(array, value, comparator) { let low = 0; let high = array.length; diff --git a/yarn.lock b/yarn.lock index bc0ca49c..bac88dfc 100644 --- a/yarn.lock +++ b/yarn.lock @@ -234,10 +234,10 @@ http-errors@~1.7.2: statuses ">= 1.5.0 < 2" toidentifier "1.0.0" -impunity@^0.0.10: - version "0.0.10" - resolved "https://registry.yarnpkg.com/impunity/-/impunity-0.0.10.tgz#b4e47c85db53279ca7fcf2e07f7ffb111b050e49" - integrity sha512-orL7IaDV//74U6GDyw7j7wcLwxhhLpXStyZ+Pz4O1UEYx1zlCojfpBNuq26Mzbaw0HMEwrMMi4JnLQ9lz3HVFg== +impunity@^0.0.11: + version "0.0.11" + resolved "https://registry.yarnpkg.com/impunity/-/impunity-0.0.11.tgz#216da6860ad17dd360fdaa2b15d7006579b5dd8a" + integrity sha512-EZUlc/Qx7oaRXZY+PtewrPby63sWZQsEtjGFB05XfbL/20SBkR8ksFnBahkeOD2/ErNkO3vh8AV0oDbdSSS8jQ== dependencies: colors "^1.3.3" commander "^2.19.0"