Merge pull request #43 from bwindels/bwindels/reconnect

Reconnect after network failure
This commit is contained in:
Bruno Windels 2020-05-07 18:06:41 +00:00 committed by GitHub
commit 32710de12a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
129 changed files with 2847 additions and 1025 deletions

83
doc/CSS.md Normal file
View File

@ -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

View File

@ -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?

View File

@ -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.

View File

@ -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...

View File

@ -1 +1,79 @@
# 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.

View File

@ -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.

View File

@ -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`.

View File

@ -0,0 +1,4 @@
message model:
- paragraphs (p, h1, code block, quote, ...)
- lines
- parts (inline markup), which can be recursive

View File

@ -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

View File

@ -10,7 +10,7 @@
<meta name="description" content="A matrix chat application">
<link rel="stylesheet" type="text/css" href="src/ui/web/css/main.css">
</head>
<body>
<body class="brawl">
<script id="version" type="disabled">
window.BRAWL_VERSION = "%%VERSION%%";
</script>

View File

@ -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",

View File

@ -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";

View File

@ -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();
}
}
async _showPicker() {
this._clearSections();
this._sessionPickerViewModel = new SessionPickerViewModel({
sessionStore: this._sessionStore,
storageFactory: this._storageFactory,
sessionCallback: sessionInfo => this._onSessionPicked(sessionInfo)
_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._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._setSection(() => {
this._loginViewModel = new LoginViewModel({
createHsApi: this._createHsApi,
defaultHomeServer: "https://matrix.org",
loginCallback: loginData => this._onLoginFinished(loginData)
createSessionContainer: this._createSessionContainer,
sessionCallback: this._sessionCallback,
});
this.emit("change", "activeSection");
})
}
_showSession(session, sync) {
this._clearSections();
this._sessionViewModel = new SessionViewModel({session, sync});
this.emit("change", "activeSection");
}
_clearSections() {
this._error = null;
this._loading = false;
this._sessionViewModel = null;
this._loginViewModel = null;
this._sessionPickerViewModel = null;
}
get activeSection() {
if (this._error) {
return "error";
} else if(this._loading) {
return "loading";
} else if (this._sessionViewModel) {
return "session";
} else if (this._loginViewModel) {
@ -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");
}
}

View File

@ -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; }
get loadViewModel() {return this._loadViewModel; }
get isBusy() {
if (!this._loadViewModel) {
return false;
} else {
return this._loadViewModel.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");
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();
}
}
}

View File

@ -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…`;
}
}

View File

@ -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() {
if (!this._loadViewModel) {
this._sessionCallback();
}
}
}

64
src/domain/ViewModel.js Normal file
View File

@ -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;
}
}

View File

@ -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();
}
}
}

View File

@ -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");
}
}

View File

@ -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;
}
}

View File

@ -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);
}
}

View File

@ -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 {

View File

@ -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)

View File

@ -1,4 +1,4 @@
export default class UpdateAction {
export class UpdateAction {
constructor(remove, update, updateParams) {
this._remove = remove;
this._update = update;

View File

@ -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;

View File

@ -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);

View File

@ -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(",");

View File

@ -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);

View File

@ -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";

View File

@ -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";

View File

@ -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;

View File

@ -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;

View File

@ -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) {

View File

@ -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

View File

@ -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());

View File

@ -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);
}

View File

@ -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();
}

View File

@ -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;
}
}
}

View File

@ -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;

View File

@ -1,4 +1,4 @@
export default class User {
export class User {
constructor(userId) {
this._userId = userId;
}

View File

@ -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";
}
}

View File

@ -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);
}
}

View File

@ -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;
},
}
}

View File

@ -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);
}
}
}

View File

@ -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);
},
}
}

View File

@ -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;
});

View File

@ -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);
}

View File

@ -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();

View File

@ -99,7 +99,7 @@ class SummaryData {
}
}
export default class RoomSummary {
export class RoomSummary {
constructor(roomId) {
this._data = new SummaryData(null, roomId);
}

View File

@ -1,4 +1,4 @@
export default class PendingEvent {
export class PendingEvent {
constructor(data) {
this._data = data;
}

View File

@ -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 {

View File

@ -1,6 +1,4 @@
export default class Direction {
export class Direction {
constructor(isForward) {
this._isForward = isForward;
}

View File

@ -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

View File

@ -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

View File

@ -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() {
if (this._closeCallback) {
this._closeCallback();
this._closeCallback = null;
}
}
}

View File

@ -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;
}

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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

View File

@ -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

View File

@ -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;

View File

@ -1,4 +1,4 @@
export default class SessionsStore {
export class SessionInfoStorage {
constructor(name) {
this._name = name;
}

View File

@ -1,6 +1,6 @@
import {iterateCursor, reqAsPromise} from "./utils.js";
export default class QueryTarget {
export class QueryTarget {
constructor(target) {
this._target = target;
}

View File

@ -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();
}
}

View File

@ -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);

View File

@ -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));
}

View File

@ -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;

View File

@ -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;
}

View File

@ -1,4 +1,4 @@
export default class RoomStateStore {
export class RoomStateStore {
constructor(idbStore) {
this._roomStateStore = idbStore;
}

View File

@ -11,7 +11,7 @@ store contains:
inviteCount
joinCount
*/
export default class RoomSummaryStore {
export class RoomSummaryStore {
constructor(summaryStore) {
this._summaryStore = summaryStore;
}

View File

@ -14,7 +14,7 @@ store contains:
avatarUrl
lastSynced
*/
export default class SessionStore {
export class SessionStore {
constructor(sessionStore) {
this._sessionStore = sessionStore;
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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;

View File

@ -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 = {};

View File

@ -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);
}

View File

@ -1,4 +1,4 @@
export default class Store {
export class Store {
constructor(storeValue, writable) {
this._storeValue = storeValue;
this._writable = writable;

119
src/mocks/Clock.js Normal file
View File

@ -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();
}
}
}

View File

@ -1,4 +1,4 @@
export default class BaseObservableCollection {
export class BaseObservable {
constructor() {
this._handlers = new Set();
}
@ -17,6 +17,11 @@ export default class BaseObservableCollection {
this.onSubscribeFirst();
}
return () => {
return this.unsubscribe(handler);
};
}
unsubscribe(handler) {
if (handler) {
this._handlers.delete(handler);
if (this._handlers.size === 0) {
@ -25,14 +30,13 @@ export default class BaseObservableCollection {
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;

View File

@ -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);
},
}
}

View File

@ -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)

View File

@ -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);

View File

@ -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) {

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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

View File

@ -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();

View File

@ -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;

View File

@ -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;

View File

@ -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

View File

@ -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":

View File

@ -1,4 +1,4 @@
export default {
export const WebPlatform = {
get minStorageKey() {
// for indexeddb, we use unsigned 32 bit integers as keys
return 0;

5
src/ui/web/common.js Normal file
View File

@ -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"})
);
}

12
src/ui/web/css/login.css Normal file
View File

@ -0,0 +1,12 @@
.SessionLoadView {
display: flex;
}
.SessionLoadView p {
flex: 1;
margin: 0 0 0 10px;
}
.SessionLoadView .spinner {
--size: 20px;
}

View File

@ -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;

View File

@ -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);
}

74
src/ui/web/dom/Clock.js Normal file
View File

@ -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();
}
}

Some files were not shown because too many files have changed in this diff Show More