Merge pull request #1 from bwindels/bwindels/gaps

Implements gap filling & timeline fragments
This commit is contained in:
Bruno Windels 2019-06-02 22:35:22 +00:00 committed by GitHub
commit 257714f9a7
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
72 changed files with 3841 additions and 725 deletions

88
doc/FRAGMENTS.md Normal file
View file

@ -0,0 +1,88 @@
- DONE: write FragmentIndex
- DONE: adapt SortKey ... naming! :
- FragmentIdIndex (index as in db index)
- compare(idA, idB)
- SortKey
- FragmentId
- EventIndex
- DONE: write fragmentStore
- load all fragments
- add a fragment (live on limited sync, or /context)
- connect two fragments
- update token on fragment (when filling gap or connecting two fragments)
fragments can need connecting when filling a gap or creating a new /context fragment
- DONE: adapt timelineStore
how will fragments be exposed in timeline store?
- all read operations are passed a fragment id
- adapt persister
- DONE: persist fragments in /sync
- DONE: fill gaps / fragment filling
- DONE: load n items before and after key,
- DONE: need to add fragments as we come across boundaries
- DONE: also cache fragments? not for now ...
- DONE: not doing any of the above, just reloading and rebuilding for now
- DONE: adapt Timeline
- DONE: turn ObservableArray into ObservableSortedArray
- upsert already sorted sections
- DONE: upsert single entry
- adapt TilesCollection & Tile to entry changes
- add live fragment id optimization if we haven't done so already
- lets try to not have to have the fragmentindex in memory if the timeline isn't loaded
- could do this by only loading all fragments into index when filling gaps, backpaginating, ... and on persister load only load the last fragment. This wouldn't even need a FragmentIndex?
# Leftover items
implement SortedArray::setManySorted in a performant manner
implement FragmentIdComparator::add in a performant manner
there is some duplication (also in memory) between SortedArray and TilesCollection. Both keep a sorted list based on fragmentId/eventIndex... TilesCollection doesn't use the index in the event handlers at all. we could allow timeline to export a structure that just emits "these entries are a thing (now)" and not have to go through sorting twice. Timeline would have to keep track of the earliest key so it can use it in loadAtTop, but that should be easy. Hmmm. also, Timeline might want to be in charge of unloading parts of the loaded timeline, and for that it would need to know the order of entries. So maybe not ... we'll see.
check: do /sync events not have a room_id and /messages do???
so a gap is two connected fragments where either the first fragment has a nextToken and/or the second fragment has a previousToken. It can be both, so we can have a gap where you can fill in from the top, from the bottom (like when limited sync) or both.
also, filling gaps and storing /context, how do we find the fragment we could potentially merge with to look for overlapping events?
with /sync this is all fine and dandy, but with /context is there a way where we don't need to look up every event_id in the store to see if it's already there?
we can do a anyOf(event_id) on timelineStore.index("by_index") by sorting the event ids according to IndexedDb.cmp and passing the next value to cursor.continue(nextId).
so we'll need to remove previous/nextEvent on the timeline store and come up with a method to find the first matched event in a list of eventIds.
so we'll need to map all event ids to an event and return the first one that is not null. If we haven't read all events but we know that all the previous ones are null, then we can already return the result.
we can call this findFirstEventIn(roomId, [event ids])
thoughts:
- ranges in timeline store with fragmentId might not make sense anymore as doing queries over multiple fragment ids doesn't make sense anymore ... still makes sense to have them part of SortKey though ...
- we need a test for querytarget::lookup, or make sure it works well ...
# Reading the timeline with fragments
- what format does the persister return newEntries after persisting sync or a gap fill?
- a new fragment can be created during a limited sync
- when doing a /context or /messages call, we could have joined with another fragment
- don't think we need to describe a result spanning multiple fragments here
so:
in case of limited sync, we just say there was a limited sync, this is the fragment that was created for it so we can show a gap in the timeline
in case of a gap fill, we need to return what was changed to the fragment (was it joined with another fragment, what's the new token), and which events were actually added.
we return entries! fragmentboundaryentry(start or end) or evententry. so looks much like the gaps we had before, but now they are not stored in the timeline store, but based on fragments.
- where do we translate from fragments to gap entries? and back? in the timeline object?
that would make sense, that seems to be the only place we need that translation
# SortKey
so, it feels simpler to store fragmentId and eventIndex as fields on the entry instead of an array/arraybuffer in the field sortKey. Currently, the tiles code somewhat relies on having sortKeys but nothing too hard to change.
so, what we could do:
- we create EventKey(fragmentId, eventIndex) that has the nextKey methods.
- we create a class EventEntry that wraps what is stored in the timeline store. This has a reference to the fragmentindex and has an opaque compare method. Tiles delegate to this method. EventEntry could later on also contain methods like MatrixEvent has in the riot js-sdk, e.g. something to safely dig into the event object.

19
doc/QUESTIONS.md Normal file
View file

@ -0,0 +1,19 @@
remaining problems to resolve:
how to store timelime fragments that we don't yet know how they should be sorted wrt the other events and gaps. the case with event permalinks and showing the replied to event when rendering a reply (anything from /context).
either we could put timeline pieces that were the result of /context in something that is not the timeline. Gaps also don't really make sense there ... You can just paginate backwards and forwards. Or maybe still in the timeline but in a different scope not part of the sortKey, scope: live, or scope: piece-1204. While paginating, we could keep the start and end event_id of all the scopes in memory, and set a marker on them to stitch them together?
Hmmm, I can see the usefullness of the concept of timeline set with multiple timelines in it for this. for the live timeline it's less convenient as you're not bothered so much by the stitching up, but for /context pieces that run into the live timeline while paginating it seems more useful... we could have a marker entry that refers to the next or previous scope ... this way we could also use gap entries for /context timelines, just one on either end.
the start and end event_id of a scope, keeping that in memory, how do we make sure this is safe taking transactions into account? our preferred strategy so far has been to read everything from store inside a txn to make sure we don't have any stale caches or races. Would be nice to keep this.
so while paginating, you'd check the event_id of the event against the start/end event_id of every scope to see if stitching is in order, and add marker entries if so. Perhaps marker entries could also be used to stitch up rooms that have changed versioning?
What does all of this mean for using sortKey as an identifier? Will we need to take scope into account as well everywhere?
we'll need to at least contemplate how room state will be handled with all of the above.
how do we deal with the fact that an event can be rendered (and updated) multiple times in the timeline as part of replies.
room state...

View file

@ -25,6 +25,11 @@
- DONE: support timeline
- DONE: clicking on a room list, you see messages (userId -> body)
- DONE: style minimal UI
- DONE: implement gap filling and fragments (see FRAGMENTS.md)
- allow collection items (especially tiles) to self-update
- improve fragmentidcomparer::add
- send messages
- better UI
- fix MappedMap update mechanism
- see if in BaseObservableMap we need to change ...params
- put sync button and status label inside SessionView
@ -32,6 +37,8 @@
- find out if `(this._emitCollectionUpdate)(this)` is different than `this._emitCollectionUpdate(this)`
- got "database tried to mutate when not allowed" or something error as well
- find out why when RoomPersister.(\_createGapEntry/\_createEventEntry) we remove .buffer the transaction fails (good), but upon fixing and refreshing is missing a message! syncToken should not be saved, so why isn't this again in the sync response and now the txn does succeed?
- DONE: take access token out of IDB? this way it can be stored in a more secure thing for non-web clients, together wit encryption key for olm sessions ... ? like macos keychain, gnome keyring, ... maybe using https://www.npmjs.com/package/keytar
- experiment with using just a normal array with 2 numbers for sortkeys, to work in Edge as well.
- send messages
- fill gaps with call to /messages
- create sync filter

47
doc/architecture.md Normal file
View file

@ -0,0 +1,47 @@
The matrix layer consists of a `Session`, which represents a logged in user session. It's the root object you can get rooms off. It can persist and load itself from storage, at which point it's ready to be displayed. It doesn't sync it's own though, and you need to create and start a Sync object for updates to be pushed and persisted to the session. `Sync` is the thing (although not the only thing) that mutates the `Session`, with `Session` being unaware of `Sync`.
The matrix layer assumes a transaction-based storage layer, modelled much to how IndexedDB works. The idea is that any logical operation like process sync response, send a message, ... runs completely in a transaction that gets aborted if anything goes wrong. This helps the storage to always be in a consistent state. For this reason you'll often see transactions (txn) being passed in the code. Also, the idea is to not emit any events until readwrite transactions have been committed.
- Reduce the chance that errors (in the event handlers) abort the transaction. You *could* catch & rethrow but it can get messy.
- Try to keep transactions as short-lived as possible, to not block other transactions.
For this reason a `Room` processes a sync response in two phases: `persistSync` & `emitSync`, with the return value of the former being passed into the latter to avoid double processing.
## Timeline, fragments & event indices.
A room in matrix is a DAG (directed, acyclic graph) of events, also known as the timeline. Morpheus is only aware of fragments of this graph, and can be unaware how these fragments relate to each other until a common event is found while paginating a fragment. After doing an initial sync, you start with one fragment. When looking up an event with the `/context` endpoint (for fetching a replied to message, or navigating to a given event id, e.g. through a permalink), a new, unconnected, fragment is created. Also, when receiving a limited sync response during incremental sync, a new fragment is created. Here, the relationship is clear, so they are immediately linked up at creation. Events in morpheus are identified within a room by `[fragment_id, event_index]`. The `event_index` is an unique number within a fragment to sort events in chronological order in the timeline. `fragment_id` cannot be directly compared for sorting (as the relationship may be unknown), but with help of the `FragmentIndex`, one can attempt to sort events by their `FragmentIndex([fragment_id, event_index])`.
A fragment is the following data structure:
```
let fragment := {
roomId: string
id: number
previousId: number?
nextId: number?
previousToken: string?
nextToken: string?
}
```
## Observing the session
`Room`s on the `Session` are exposed as an `ObservableMap` collection, which is like an ordinary `Map` but emits events when it is modified (here when a room is added, removed, or the properties of a room change). `ObservableMap` can have different operators applied to it like `mapValues()`, `filterValues()` each returning a new `ObservableMap`-like object, and also `sortValues()` returning an `ObservableList` (emitting events when a room at an index is added, removed, moved or changes properties).
So for example, the room list, `Room` objects from `Session.rooms` are mapped to a `RoomTileViewModel` and then sorted. This gives us fine-grained events at the end of the collection chain that can be easily and efficiently rendered by the `ListView` component.
On that note, view components are just a simple convention, having these methods:
- `mount()` - prepare to become part of the document and interactive, ensure `root()` returns a valid DOM node.
- `root()` - the room DOM node for the component. Only valid to be called between `mount()` and `unmount()`.
- `update(attributes)` (to be renamed to `setAttributes(attributes)`) - update the attributes for this component. Not all components support all attributes to be updated. For example most components expect a viewModel, but if you want a component with a different view model, you'd just create a new one.
- `unmount()` - tear down after having been removed from the document.
The initial attributes are usually received by the constructor in the first argument. Other arguments are usually freeform, `ListView` accepting a closure to create a child component from a collection value.
Templating and one-way databinding are neccesary improvements, but not assumed by the component contract.
Updates from view models can come in two ways. View models emit a change event, that can be listened to from a view. This usually includes the name of the property that changed. This is the mechanism used to update the room name in the room header of the currently active room for example.
For view models part of an observable collection (and to be rendered by a ListView), updates can also propagate through the collection and delivered by the ListView to the view in question. This avoids every child component in a ListView having to attach a listener to it's viewModel. This is the mechanism to update the room name in a RoomTile in the room list for example.
TODO: specify how the collection based updates work. (not specified yet, we'd need a way to derive a key from a value to emit an update from within a collection, but haven't found a nice way of specifying that in an api)

View file

@ -1,7 +1,7 @@
# persistance vs model update of a room
## persist first, return update object, update model with update object
-
- we went with this
## update model first, return update object, persist with update object
- not all models exist at all times (timeline only when room is "open"),
so model to create timeline update object might not exist for persistence need
@ -15,4 +15,4 @@
## persist first, read from storage to update model
+ guaranteed consistency between what is on screen and in storage
- slower as we need to reread what was just synced every time (big accounts with frequent updates)
- slower as we need to reread what was just synced every time (big accounts with frequent updates)

View file

@ -45,6 +45,10 @@
flex: 1;
overflow-y: scroll;
}
.RoomView_error {
color: red;
}
</style>
</head>
<body>

6
package-lock.json generated
View file

@ -95,9 +95,9 @@
}
},
"impunity": {
"version": "0.0.5",
"resolved": "https://registry.npmjs.org/impunity/-/impunity-0.0.5.tgz",
"integrity": "sha512-ro+enrZPFTyY2U1sV9NytsyejE2tS5theAArM95iPYYQHUvO9YN0VjgfXP0KJfxwh4Xb6vBTRBmHIgx9GUx2Xg==",
"version": "0.0.7",
"resolved": "https://registry.npmjs.org/impunity/-/impunity-0.0.7.tgz",
"integrity": "sha512-+DhzXSWrzqI1KNroKt3y1LkLTn/aoJpt4DzxWN+hair+Jfb+iJAbTEsSFkYUG7kASP9TF9GvI0hIBUul6PjpKg==",
"dev": true,
"requires": {
"colors": "^1.3.3",

View file

@ -22,7 +22,7 @@
"homepage": "https://github.com/bwindels/morpheusjs#readme",
"devDependencies": {
"finalhandler": "^1.1.1",
"impunity": "^0.0.5",
"impunity": "^0.0.7",
"serve-static": "^1.13.2"
}
}

View file

@ -0,0 +1,165 @@
<html>
<head><meta charset="utf-8"></head>
<body>
<script type="text/javascript">
function reqAsPromise(req) {
return new Promise((resolve, reject) => {
req.onsuccess = () => resolve(req);
req.onerror = (err) => reject(err);
});
}
function txnAsPromise(txn) {
return new Promise((resolve, reject) => {
txn.addEventListener("complete", resolve);
txn.addEventListener("abort", reject);
});
}
function iterateCursor(cursor, processValue) {
// TODO: does cursor already have a value here??
return new Promise((resolve, reject) => {
cursor.onerror = (event) => {
reject(new Error("Query failed: " + event.target.errorCode));
};
// collect results
cursor.onsuccess = (event) => {
const cursor = event.target.result;
if (!cursor) {
resolve(false);
return; // end of results
}
const {done, jumpTo} = processValue(cursor.value, cursor.key);
if (done) {
resolve(true);
} else {
cursor.continue(jumpTo);
}
};
});
}
/**
* Checks if a given set of keys exist.
* Calls `callback(key, found)` for each key in `keys`, in an unspecified order.
* If the callback returns true, the search is halted and callback won't be called again.
*/
async function findKeys(target, keys, backwards, callback) {
const direction = backwards ? "prev" : "next";
const compareKeys = (a, b) => backwards ? -indexedDB.cmp(a, b) : indexedDB.cmp(a, b);
const sortedKeys = keys.slice().sort(compareKeys);
console.log(sortedKeys);
const firstKey = backwards ? sortedKeys[sortedKeys.length - 1] : sortedKeys[0];
const lastKey = backwards ? sortedKeys[0] : sortedKeys[sortedKeys.length - 1];
const cursor = target.openKeyCursor(IDBKeyRange.bound(firstKey, lastKey), direction);
let i = 0;
let consumerDone = false;
await iterateCursor(cursor, (value, key) => {
// while key is larger than next key, advance and report false
while(i < sortedKeys.length && compareKeys(sortedKeys[i], key) < 0 && !consumerDone) {
console.log("before match", sortedKeys[i]);
consumerDone = callback(sortedKeys[i], false);
++i;
}
if (i < sortedKeys.length && compareKeys(sortedKeys[i], key) === 0 && !consumerDone) {
console.log("match", sortedKeys[i]);
consumerDone = callback(sortedKeys[i], true);
++i;
}
const done = consumerDone || i >= sortedKeys.length;
const jumpTo = !done && sortedKeys[i];
return {done, jumpTo};
});
// report null for keys we didn't to at the end
while (!consumerDone && i < sortedKeys.length) {
console.log("afterwards", sortedKeys[i]);
consumerDone = callback(sortedKeys[i], false);
++i;
}
}
async function findFirstOrLastOccurringEventId(target, roomId, eventIds, findLast = false) {
const keys = eventIds.map(eventId => [roomId, eventId]);
const results = new Array(keys.length);
let firstFoundEventId;
// find first result that is found and has no undefined results before it
function firstFoundAndPrecedingResolved() {
let inc = findLast ? -1 : 1;
let start = findLast ? results.length - 1 : 0;
for(let i = start; i >= 0 && i < results.length; i += inc) {
if (results[i] === undefined) {
return;
} else if(results[i] === true) {
return keys[i];
}
}
}
await findKeys(target, keys, findLast, (key, found) => {
const index = keys.indexOf(key);
results[index] = found;
firstFoundEventId = firstFoundAndPrecedingResolved();
return !!firstFoundEventId;
});
return firstFoundEventId;
}
(async () => {
let db;
let isNew = false;
// open db
{
const req = window.indexedDB.open("prototype-idb-continue-key");
req.onupgradeneeded = (ev) => {
const db = ev.target.result;
db.createObjectStore("timeline", {keyPath: ["roomId", "eventId"]});
isNew = true;
};
db = (await reqAsPromise(req)).result;
}
const roomId = "!abcdef:localhost";
if (isNew) {
const txn = db.transaction(["timeline"], "readwrite");
const store = txn.objectStore("timeline");
for (var i = 1; i <= 100; ++i) {
store.add({roomId, eventId: `$${i * 10}`});
}
await txnAsPromise(txn);
}
console.log("show all in order we get them");
{
const txn = db.transaction(["timeline"], "readonly");
const store = txn.objectStore("timeline");
const cursor = store.openKeyCursor();
await iterateCursor(cursor, (value, key) => {
console.log(key);
return {done: false};
});
}
console.log("run findKeys");
{
const txn = db.transaction(["timeline"], "readonly");
const store = txn.objectStore("timeline");
const eventIds = ["$992", "$1000", "$1010", "$991", "$500", "$990"];
// const eventIds = ["$992", "$1010"];
const keys = eventIds.map(eventId => [roomId, eventId]);
await findKeys(store, keys, false, (key, found) => {
console.log(key, found);
});
}
console.log("run findFirstOrLastOccurringEventId");
{
const txn = db.transaction(["timeline"], "readonly");
const store = txn.objectStore("timeline");
const eventIds = ["$992", "$1000", "$1010", "$991", "$500", "$990", "$123"];
const firstMatch = await findFirstOrLastOccurringEventId(store, roomId, eventIds, false);
console.log("firstMatch", firstMatch);
const lastMatch = await findFirstOrLastOccurringEventId(store, roomId, eventIds, true);
console.log("lastMatch", lastMatch);
}
})();
</script>
</body>
</html>

View file

@ -0,0 +1,4 @@
CACHE MANIFEST
# v1
/responsive-layout-flex.html
/me.jpg

BIN
prototypes/me.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

View file

@ -0,0 +1,505 @@
<!DOCTYPE html>
<html manifest="manifest.appcache">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, user-scalable=no">
<meta name="application-name" content="Talk to Bruno"/>
<meta name="msapplication-navbutton-color" content="red"/>
<style type="text/css">
html {
height: 100%;
display: flex;
min-height: 0;
}
body {
flex: 1;
margin: 0;
display: flex;
flex-direction: column;
font-family: sans-serif;
}
.log {
flex: 0 0 50px;
overflow-y: scroll;
margin: 0;
background: white;
color: black;
word-wrap: anywhere;
display: none;
}
.container {
flex: 1;
display: flex;
min-height: 0;
min-width: 0;
width: 100vw;
}
/* mobile layout */
@media screen and (max-width: 800px) {
.back { display: block !important; }
.room-panel, .room-panel-placeholder { display: none; }
.room-shown .room-panel { display: unset; }
.room-shown .left-panel { display: none; }
.right-shown .timeline-panel { display: none; }
}
.left-panel {
flex: 1;
background: red;
color: white;
overflow-y: auto;
}
.left-panel ul {
list-style: none;
padding: 0;
margin: 0;
}
.left-panel li {
margin: 5px;
padding: 10px;
background: darkred;
display: flex;
align-items: center;
}
.left-panel li > * {
margin-right: 10px;
}
.left-panel div.description {
margin: 0;
flex: 1 1 0;
min-width: 0;
}
.description > * {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.description .last-message {
font-size: 0.8em;
}
.room-panel-placeholder, .room-panel {
flex: 3;
color: white;
background: black;
}
.room-panel {
min-width: 0;
display: none;
}
.room-shown .room-panel {
display: flex;
}
.room-shown .room-panel-placeholder {
display: none;
}
.right-panel {
flex: 1;
background: green;
color: white;
display: none;
}
.timeline-panel {
flex: 3;
min-height: 0;
display: flex;
flex-direction: column;
}
.right-shown .right-panel {
display: block;
}
.room-header {
display: flex;
padding: 10px;
}
.room-header *:last-child {
margin-right: 0 !important;
}
.room-header > * {
margin-right: 10px !important;
}
.room-header button {
width: 40px;
height: 40px;
display: none;
font-size: 1.5em;
padding: 0;
display: block;
background: white;
border: none;
font-weight: bolder;
line-height: 40px;
}
.room-header button.back {
display: none;
}
.room-header .topic {
font-size: 0.8em;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.back::before {
content: "☰";
}
.more::before {
content: "⋮";
}
.room-header {
align-items: center;
}
.room-header .room-description {
flex: 1 1 auto;
min-width: 0;
}
.room-header h2 {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
margin: 0;
}
.timeline-panel ul {
flex: 1;
overflow-y: auto;
list-style: none;
padding: 0;
margin: 0;
}
.timeline-panel li {
background: blue;
padding: 10px;
margin: 10px;
}
.composer {
display: flex;
}
.composer input {
display: block;
flex: 1;
font-size: 1.2em;
border: none;
padding: 10px;
}
.avatar {
--avatar-size: 32px;
width: var(--avatar-size);
height: var(--avatar-size);
border-radius: 100px;
overflow: hidden;
flex-shrink: 0;
line-height: var(--avatar-size);
font-size: calc(var(--avatar-size) * 0.6);
text-align: center;
letter-spacing: calc(var(--avatar-size) * -0.05);
background: white;
color: black;
}
.avatar.large {
--avatar-size: 40px;
}
.avatar img {
width: 100%;
height: 100%;
}
</style>
</head>
<body>
<ul class="log"></ul>
<div class="container">
<div class="left-panel">
<ul>
<li>
<div class="avatar medium"><img src="me.jpg" alt="Avatar for some room"/></div>
<div class="description">
<div class="name">Room 1</div>
<div class="last-message">Message 12, message 12, message 12</div>
</div>
</li>
<li>
<div class="avatar medium"><img src="me.jpg" alt="Avatar for some room"/></div>
<div class="description">
<div class="name">Room 2</div>
<div class="last-message">Message 12, message 12, message 12</div>
</div>
</li>
<li>
<div class="avatar medium">R3</div>
<div class="description">
<div class="name">Room 3</div>
<div class="last-message">Message 12, message 12, message 12</div>
</div>
</li>
<li>
<div class="avatar medium"><img src="me.jpg" alt="Avatar for some room"/></div>
<div class="description">
<div class="name">Room 4</div>
<div class="last-message">Message 12, message 12, message 12</div>
</div>
</li>
<li>
<div class="avatar medium"><img src="me.jpg" alt="Avatar for some room"/></div>
<div class="description">
<div class="name">Room 5</div>
<div class="last-message">Message 12, message 12, message 12</div>
</div>
</li>
<li>
<div class="avatar medium"><img src="me.jpg" alt="Avatar for some room"/></div>
<div class="description">
<div class="name">Room 6</div>
<div class="last-message">Message 12, message 12, message 12</div>
</div>
</li>
<li>
<div class="avatar medium"><img src="me.jpg" alt="Avatar for some room"/></div>
<div class="description">
<div class="name">Room 7</div>
<div class="last-message">Message 12, message 12, message 12, message 12, message 12, message 12, message 12, message 12</div>
</div>
</li>
<li>
<div class="avatar medium">BW</div>
<div class="description">
<div class="name">Room 8</div>
<div class="last-message">Message 12, message 12, message 12</div>
</div>
</li>
<li>
<div class="avatar medium"><img src="me.jpg" alt="Avatar for some room"/></div>
<div class="description">
<div class="name">Room 9</div>
<div class="last-message">Message 12, message 12, message 12</div>
</div>
</li>
<li>
<div class="avatar medium"><img src="me.jpg" alt="Avatar for some room"/></div>
<div class="description">
<div class="name">Room 10</div>
<div class="last-message">Message 12, message 12, message 12</div>
</div>
</li>
<li>
<div class="avatar medium"><img src="me.jpg" alt="Avatar for some room"/></div>
<div class="description">
<div class="name">Room 11</div>
<div class="last-message">Message 12, message 12, message 12</div>
</div>
</li>
<li>
<div class="avatar medium">🍔</div>
<div class="description">
<div class="name">Room 12</div>
<div class="last-message">Message 12, message 12, message 12</div>
</div>
</li>
<li>
<div class="avatar medium"><img src="me.jpg" alt="Avatar for some room"/></div>
<div class="description">
<div class="name">Room 13</div>
<div class="last-message">Message 12, message 12, message 12</div>
</div>
</li>
<li>
<div class="avatar medium"><img src="me.jpg" alt="Avatar for some room"/></div>
<div class="description">
<div class="name">Room 14</div>
<div class="last-message">Message 12, message 12, message 12</div>
</div>
</li>
</ul>
</div>
<div class="room-panel-placeholder">
<h2>Select a room on the left side</h2>
</div>
<div class="room-panel">
<div class="timeline-panel">
<div class="room-header">
<button class="back"></button>
<div class="avatar large"><img src="me.jpg" alt="Avatar for some room"/></div>
<div class="room-description">
<h2>Talk to Bruno</h2>
<div class="topic">The room to talk to Bruno</div>
</div>
<button class="more"></button>
</div>
<ul>
<li>Message 1, message 1, message 1, message 1, message 1, message 1, message 1, message 1</li>
<li>Message 2, message 2, message 2, message 2, message 2, message 2, message 2, message 2</li>
<li>Message 3, message 3, message 3, message 3, message 3, message 3, message 3, message 3</li>
<li>Message 4, message 4, message 4, message 4, message 4, message 4, message 4, message 4</li>
<li>Message 5, message 5, message 5, message 5, message 5, message 5, message 5, message 5</li>
<li>Message 6, message 6, message 6, message 6, message 6, message 6, message 6, message 6</li>
<li>Message 7, message 7, message 7, message 7, message 7, message 7, message 7, message 7</li>
<li>Message 8, message 8, message 8, message 8, message 8, message 8, message 8, message 8</li>
<li>Message 9, message 9, message 9, message 9, message 9, message 9, message 9, message 9</li>
<li>Message 10, message 10, message 10, message 10, message 10, message 10, message 10, message 10</li>
<li>Message 11, message 11, message 11, message 11, message 11, message 11, message 11, message 11</li>
<li>Message 12, message 12, message 12, message 12, message 12, message 12, message 12, message 12</li>
<li>Message 12, message 12, message 12, message 12, message 12, message 12, message 12, message 12</li>
<li>Message 12, message 12, message 12, message 12, message 12, message 12, message 12, message 12</li>
<li>Message 12, message 12, message 12, message 12, message 12, message 12, message 12, message 12</li>
<li>Message 12, message 12, message 12, message 12, message 12, message 12, message 12, message 12</li>
<li>Message 12, message 12, message 12, message 12, message 12, message 12, message 12, message 12</li>
<li>Message 12, message 12, message 12, message 12, message 12, message 12, message 12, message 12</li>
<li>Message 12, message 12, message 12, message 12, message 12, message 12, message 12, message 12</li>
<li>Message 12, message 12, message 12, message 12, message 12, message 12, message 12, message 12</li>
<li>Message 12, message 12, message 12, message 12, message 12, message 12, message 12, message 12</li>
<li>Message 12, message 12, message 12, message 12, message 12, message 12, message 12, message 12</li>
<li>Message 12, message 12, message 12, message 12, message 12, message 12, message 12, message 12</li>
<li>Message 12, message 12, message 12, message 12, message 12, message 12, message 12, message 12</li>
<li>Message 12, message 12, message 12, message 12, message 12, message 12, message 12, message 12</li>
<li>Message 12, message 12, message 12, message 12, message 12, message 12, message 12, message 12</li>
<li>Message 12, message 12, message 12, message 12, message 12, message 12, message 12, message 12</li>
<li>Message 12, message 12, message 12, message 12, message 12, message 12, message 12, message 12</li>
<li>Message 12, message 12, message 12, message 12, message 12, message 12, message 12, message 12</li>
<li>Message 12, message 12, message 12, message 12, message 12, message 12, message 12, message 12</li>
<li>Message 12, message 12, message 12, message 12, message 12, message 12, message 12, message 12</li>
<li>Message 12, message 12, message 12, message 12, message 12, message 12, message 12, message 12</li>
</ul>
<div class="composer"><input type="text" placeholder="Send a message" /></div>
</div>
<div class="right-panel">
<h2>Bruno</h2>
<p>Ban | Kick | Mock</p>
</div>
</div>
</div>
<script type="text/javascript">
const left = document.querySelector(".left-panel");
const room = document.querySelector(".room-panel");
const timeline = document.querySelector(".timeline-panel ul");
const right = document.querySelector(".right-panel");
const back = document.querySelector(".back");
const more = document.querySelector(".more");
const container = document.querySelector(".container");
const logNode = document.querySelector(".log");
const composer = document.querySelector(".composer input");
function fullscreen() {
try {
document.body.webkitRequestFullscreen();
} catch (err) {
log(`could not set fullscreen: ${err.message}`);
}
}
function log(text) {
return;
const li = document.createElement("li");
li.appendChild(document.createTextNode(text));
if (logNode.childElementCount) {
logNode.insertBefore(li, logNode.firstChild);
} else {
logNode.appendChild(li);
}
}
left.addEventListener("click", (e) => {
container.classList.toggle("room-shown");
e.preventDefault();
e.stopPropagation();
});
timeline.addEventListener("click", (e) => {
room.classList.add("right-shown");
e.preventDefault();
e.stopPropagation();
});
right.addEventListener("click", (e) => {
room.classList.remove("right-shown");
e.preventDefault();
e.stopPropagation();
});
back.addEventListener("click", () => {
container.classList.remove("room-shown");
});
more.addEventListener("click", fullscreen);
const isMyPhone = navigator.userAgent.match(/Windows Phone/i);
if (isMyPhone) {
log("on my phone");
} else {
log("not on my phone");
}
window.addEventListener("load", () => {
fullscreen();
setTimeout(() => {
log("hello!");
fullscreen();
}, 100);
});
if (window.applicationCache) {
window.applicationCache.oncached = () => {
log("app is cached now!");
};
window.applicationCache.onupdateready = () => {
log("app has update ready!");
};
}
if (isMyPhone) {
composer.addEventListener("blur", () => {
log(`composer blurred, clearing maxHeight`);
container.style.removeProperty("maxHeight");
container.style.maxHeight = "";
container.style.maxHeight = null;
});
composer.addEventListener("focus", () => {
const h = 640 - 260;
log(`composer focused, setting container height to ${h}`);
container.style.maxHeight = `${h}px`;
});
}
document.scrollingElement.addEventListener("scroll", () => {
log(`window scrolled to ${document.scrollingElement.scrollTop}`);
});
window.addEventListener("resize", () => {
// document.body.style.height = `${window.innerHeight - 100}px`;
// document.scrollingElement.style.background = "red";
// window.scrollTo(0, 0);
log(`window resize ${window.innerHeight}`);
});
</script>
</body>
</html>

View file

@ -0,0 +1,133 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style type="text/css">
body {
height: 100vh;
margin: 0;
display: grid;
}
body { grid-template-columns: 1fr 0 0; }
body.middle-shown { grid-template-columns: 0 1fr 0; }
body.right-shown { grid-template-columns: 0 0 1fr; }
@media(min-width: 800px) {
/*
body without middle-shown is used when having middle-panel-placeholder,
which shouldn't be the panel shown on < 800px)
*/
body, body.middle-shown { grid-template-columns: 2fr 3fr ; }
body.right-shown { grid-template-columns: 0 3fr 2fr; }
}
@media(min-width: 1024px) {
body {
grid-template-columns: 1fr 3fr 0;
}
body.right-shown {
grid-template-columns: 1fr 3fr 1fr;
}
}
.left-panel {
grid-column: 1;
background: red;
color: white;
}
.middle-panel-placeholder, .middle-panel {
color: white;
background: blue;
grid-column: 2;
}
.middle-panel {
display: none;
}
.right-panel {
grid-column: 3;
background: green;
color: white;
display: none;
}
</style>
</head>
<body>
<div class="left-panel">
<ul>
<li>Room 1</li>
<li>Room 2</li>
<li>Room 3</li>
<li>Room 4</li>
<li>Room 5</li>
<li>Room 6</li>
<li>Room 7</li>
<li>Room 8</li>
<li>Room 9</li>
<li>Room 10</li>
<li>Room 11</li>
<li>Room 12</li>
</ul>
</div>
<div class="middle-panel-placeholder">
<h2>Select a room on the left side</h2>
</div>
<div class="middle-panel">
<ul>
<li>Message 1, message 1, message 1, message 1, message 1, message 1, message 1, message 1</li>
<li>Message 2, message 2, message 2, message 2, message 2, message 2, message 2, message 2</li>
<li>Message 3, message 3, message 3, message 3, message 3, message 3, message 3, message 3</li>
<li>Message 4, message 4, message 4, message 4, message 4, message 4, message 4, message 4</li>
<li>Message 5, message 5, message 5, message 5, message 5, message 5, message 5, message 5</li>
<li>Message 6, message 6, message 6, message 6, message 6, message 6, message 6, message 6</li>
<li>Message 7, message 7, message 7, message 7, message 7, message 7, message 7, message 7</li>
<li>Message 8, message 8, message 8, message 8, message 8, message 8, message 8, message 8</li>
<li>Message 9, message 9, message 9, message 9, message 9, message 9, message 9, message 9</li>
<li>Message 10, message 10, message 10, message 10, message 10, message 10, message 10, message 10</li>
<li>Message 11, message 11, message 11, message 11, message 11, message 11, message 11, message 11</li>
<li>Message 12, message 12, message 12, message 12, message 12, message 12, message 12, message 12</li>
</ul>
</div>
<div class="right-panel">
<h2>Bruno</h2>
<p>Ban | Kick | Mock</p>
</div>
<script type="text/javascript">
const left = document.querySelector(".left-panel");
const middle = document.querySelector(".middle-panel");
const middlePlaceholder = document.querySelector(".middle-panel-placeholder");
const right = document.querySelector(".right-panel");
const container = document.body;
left.addEventListener("click", () => {
// middle or right shown
if (container.className) {
container.className = "";
middle.style.display = "";
right.style.display = "";
middlePlaceholder.style.display = "block";
} else {
container.className = "middle-shown";
middle.style.display = "block";
middlePlaceholder.style.display = "none";
right.style.display = "";
}
});
middle.addEventListener("click", () => {
if (container.className === "right-shown") {
container.className = "middle-shown";
right.style.display = "";
} else {
container.className = "right-shown";
right.style.display = "block";
}
});
</script>
</body>
</html>

View file

@ -1,6 +1,6 @@
import EventEmitter from "../../EventEmitter.js";
import RoomTileViewModel from "./RoomTileViewModel.js";
import RoomViewModel from "./RoomViewModel.js";
import RoomTileViewModel from "./roomlist/RoomTileViewModel.js";
import RoomViewModel from "./room/RoomViewModel.js";
export default class SessionViewModel extends EventEmitter {
constructor(session) {

View file

@ -0,0 +1,54 @@
import EventEmitter from "../../../EventEmitter.js";
import TimelineViewModel from "./timeline/TimelineViewModel.js";
export default class RoomViewModel extends EventEmitter {
constructor(room) {
super();
this._room = room;
this._timeline = null;
this._timelineVM = null;
this._onRoomChange = this._onRoomChange.bind(this);
this._timelineError = null;
}
async enable() {
this._room.on("change", this._onRoomChange);
try {
this._timeline = await this._room.openTimeline();
this._timelineVM = new TimelineViewModel(this._timeline);
this.emit("change", "timelineViewModel");
} catch (err) {
console.error(`room.openTimeline(): ${err.message}:\n${err.stack}`);
this._timelineError = err;
this.emit("change", "error");
}
}
disable() {
if (this._timeline) {
// will stop the timeline from delivering updates on entries
this._timeline.close();
}
}
// room doesn't tell us yet which fields changed,
// so emit all fields originating from summary
_onRoomChange() {
this.emit("change", "name");
}
get name() {
return this._room.name;
}
get timelineViewModel() {
return this._timelineVM;
}
get error() {
if (this._timelineError) {
return `Something went wrong loading the timeline: ${this._timelineError.message}`;
}
return null;
}
}

View file

@ -0,0 +1,153 @@
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
export default class TilesCollection extends BaseObservableList {
constructor(entries, tileCreator) {
super();
this._entries = entries;
this._tiles = null;
this._entrySubscription = null;
this._tileCreator = tileCreator;
}
onSubscribeFirst() {
this._entrySubscription = this._entries.subscribe(this);
this._populateTiles();
}
_populateTiles() {
this._tiles = [];
let currentTile = null;
for (let entry of this._entries) {
if (!currentTile || !currentTile.tryIncludeEntry(entry)) {
currentTile = this._tileCreator(entry);
if (currentTile) {
this._tiles.push(currentTile);
}
}
}
let prevTile = null;
for (let tile of this._tiles) {
if (prevTile) {
prevTile.updateNextSibling(tile);
}
tile.updatePreviousSibling(prevTile);
prevTile = tile;
}
if (prevTile) {
prevTile.updateNextSibling(null);
}
}
_findTileIdx(entry) {
return sortedIndex(this._tiles, entry, (entry, tile) => {
// negate result because we're switching the order of the params
return -tile.compareEntry(entry);
});
}
_findTileAtIdx(entry, idx) {
const tile = this._getTileAtIdx(idx);
if (tile && tile.compareEntry(entry) === 0) {
return tile;
}
}
_getTileAtIdx(tileIdx) {
if (tileIdx >= 0 && tileIdx < this._tiles.length) {
return this._tiles[tileIdx];
}
return null;
}
onUnsubscribeLast() {
this._entrySubscription = this._entrySubscription();
this._tiles = null;
}
onReset() {
// if TileViewModel were disposable, dispose here, or is that for views to do? views I suppose ...
this._buildInitialTiles();
this.emitReset();
}
onAdd(index, entry) {
const tileIdx = this._findTileIdx(entry);
const prevTile = this._getTileAtIdx(tileIdx - 1);
if (prevTile && prevTile.tryIncludeEntry(entry)) {
this.emitUpdate(tileIdx - 1, prevTile);
return;
}
// not + 1 because this entry hasn't been added yet
const nextTile = this._getTileAtIdx(tileIdx);
if (nextTile && nextTile.tryIncludeEntry(entry)) {
this.emitUpdate(tileIdx, nextTile);
return;
}
const newTile = this._tileCreator(entry);
if (newTile) {
prevTile && prevTile.updateNextSibling(newTile);
nextTile && nextTile.updatePreviousSibling(newTile);
this._tiles.splice(tileIdx, 0, newTile);
this.emitAdd(tileIdx, newTile);
}
// find position by sort key
// ask siblings to be included? both? yes, twice: a (insert c here) b, ask a(c), if yes ask b(a), else ask b(c)? if yes then b(a)?
}
onUpdate(index, entry, params) {
const tileIdx = this._findTileIdx(entry);
const tile = this._findTileAtIdx(entry, tileIdx);
if (tile) {
const newParams = tile.updateEntry(entry, params);
if (newParams) {
this.emitUpdate(tileIdx, tile, newParams);
}
}
// technically we should handle adding a tile here as well
// in case before we didn't have a tile for it but now we do
// but in reality we don't have this use case as the type and msgtype
// doesn't change. Decryption maybe is the exception?
// outcomes here can be
// tiles should be removed (got redacted and we don't want it in the timeline)
// tile should be added where there was none before ... ?
// entry should get it's own tile now
// merge with neighbours? ... hard to imagine use case for this ...
}
// would also be called when unloading a part of the timeline
onRemove(index, entry) {
const tileIdx = this._findTileIdx(entry);
const tile = this._findTileAtIdx(entry, tileIdx);
if (tile) {
const removeTile = tile.removeEntry(entry);
if (removeTile) {
const prevTile = this._getTileAtIdx(tileIdx - 1);
const nextTile = this._getTileAtIdx(tileIdx + 1);
this._tiles.splice(tileIdx, 1);
prevTile && prevTile.updateNextSibling(nextTile);
nextTile && nextTile.updatePreviousSibling(prevTile);
this.emitRemove(tileIdx, tile);
} else {
this.emitUpdate(tileIdx, tile);
}
}
}
onMove(fromIdx, toIdx, value) {
// this ... cannot happen in the timeline?
// should be sorted by sortKey and sortKey is immutable
}
[Symbol.iterator]() {
return this._tiles.values();
}
get length() {
return this._tiles.length;
}
}

View file

@ -0,0 +1,52 @@
/*
need better naming, but
entry = event or gap from matrix layer
tile = item on visual timeline like event, date separator?, group of joined events
shall we put date separators as marker in EventViewItem or separate item? binary search will be complicated ...
pagination ...
on the timeline viewmodel (containing the TilesCollection?) we'll have a method to (un)load a tail or head of
the timeline (counted in tiles), which results to a range in sortKeys we want on the screen. We pass that range
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";
export default class TimelineViewModel {
constructor(timeline) {
this._timeline = timeline;
// once we support sending messages we could do
// timeline.entries.concat(timeline.pendingEvents)
// for an ObservableList that also contains local echos
this._tiles = new TilesCollection(timeline.entries, tilesCreator({timeline}));
}
// doesn't fill gaps, only loads stored entries/tiles
loadAtTop() {
// load 100 entries, which may result in 0..100 tiles
return this._timeline.loadAtTop(100);
}
unloadAtTop(tileAmount) {
// get lowerSortKey for tile at index tileAmount - 1
// tell timeline to unload till there (included given key)
}
loadAtBottom() {
}
unloadAtBottom(tileAmount) {
// get upperSortKey for tile at index tiles.length - tileAmount
// tell timeline to unload till there (included given key)
}
get tiles() {
return this._tiles;
}
}

View file

@ -0,0 +1,52 @@
import SimpleTile from "./SimpleTile.js";
export default class GapTile extends SimpleTile {
constructor(options, timeline) {
super(options);
this._timeline = timeline;
this._loading = false;
this._error = null;
}
async fill() {
// prevent doing this twice
if (!this._loading) {
this._loading = true;
// this._emitUpdate("isLoading");
try {
await this._timeline.fillGap(this._entry, 10);
} catch (err) {
console.error(`timeline.fillGap(): ${err.message}:\n${err.stack}`);
this._error = err;
// this._emitUpdate("error");
} finally {
this._loading = false;
// this._emitUpdate("isLoading");
}
}
}
get shape() {
return "gap";
}
get isLoading() {
return this._loading;
}
get isUp() {
return this._entry.direction.isBackward;
}
get isDown() {
return this._entry.direction.isForward;
}
get error() {
if (this._error) {
const dir = this._entry.prev_batch ? "previous" : "next";
return `Could not load ${dir} messages: ${this._error.message}`;
}
return null;
}
}

View file

@ -0,0 +1,26 @@
import MessageTile from "./MessageTile.js";
export default class ImageTile extends MessageTile {
constructor(options) {
super(options);
// we start loading the image here,
// and call this._emitUpdate once it's loaded?
// or maybe we have an becameVisible() callback on tiles where we start loading it?
}
get src() {
return "";
}
get width() {
return 200;
}
get height() {
return 200;
}
get label() {
return "this is an image";
}
}

View file

@ -0,0 +1,20 @@
import MessageTile from "./MessageTile.js";
/*
map urls:
apple: https://developer.apple.com/library/archive/featuredarticles/iPhoneURLScheme_Reference/MapLinks/MapLinks.html
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 {
get mapsLink() {
const geoUri = this._getContent().geo_uri;
const [lat, long] = geoUri.split(":")[1].split(",");
return `maps:${lat} ${long}`;
}
get label() {
return `${this.sender} sent their location, click to see it in maps.`;
}
}

View file

@ -0,0 +1,30 @@
import SimpleTile from "./SimpleTile.js";
export default class MessageTile extends SimpleTile {
constructor(options) {
super(options);
this._date = new Date(this._entry.event.origin_server_ts);
}
get shape() {
return "message";
}
get sender() {
return this._entry.event.sender;
}
get date() {
return this._date.toLocaleDateString();
}
get time() {
return this._date.toLocaleTimeString();
}
_getContent() {
const event = this._entry.event;
return event && event.content;
}
}

View file

@ -0,0 +1,14 @@
import SimpleTile from "./SimpleTile.js";
export default class RoomNameTile extends SimpleTile {
get shape() {
return "announcement";
}
get label() {
const event = this._entry.event;
const content = event.content;
return `${event.sender} changed membership to ${content.membership}`;
}
}

View file

@ -0,0 +1,14 @@
import SimpleTile from "./SimpleTile.js";
export default class RoomNameTile extends SimpleTile {
get shape() {
return "announcement";
}
get label() {
const event = this._entry.event;
const content = event.content;
return `${event.sender} changed the room name to "${content.name}"`
}
}

View file

@ -0,0 +1,65 @@
export default class SimpleTile {
constructor({entry, emitUpdate}) {
this._entry = entry;
this._emitUpdate = emitUpdate;
}
// view model props for all subclasses
// hmmm, could also do instanceof ... ?
get shape() {
// "gap" | "message" | "image" | ... ?
}
// don't show display name / avatar
// probably only for MessageTiles of some sort?
get isContinuation() {
return false;
}
get hasDateSeparator() {
return false;
}
// TilesCollection contract? unused atm
get upperEntry() {
return this._entry;
}
// TilesCollection contract? unused atm
get lowerEntry() {
return this._entry;
}
// TilesCollection contract
compareEntry(entry) {
return this._entry.compare(entry);
}
// update received for already included (falls within sort keys) entry
updateEntry(entry) {
// return names of props updated, or true for all, or null for no changes caused
return true;
}
// return whether the tile should be removed
// as SimpleTile only has one entry, the tile should be removed
removeEntry(entry) {
return true;
}
// SimpleTile can only contain 1 entry
tryIncludeEntry() {
return false;
}
// let item know it has a new sibling
updatePreviousSibling(prev) {
}
// let item know it has a new sibling
updateNextSibling(next) {
}
get internalId() {
return this._entry.asEventKey().toString();
}
}

View file

@ -0,0 +1,14 @@
import MessageTile from "./MessageTile.js";
export default class TextTile extends MessageTile {
get label() {
const content = this._getContent();
const body = content && content.body;
const sender = this._entry.event.sender;
if (this._entry.type === "m.emote") {
return `* ${sender} ${body}`;
} else {
return `${sender}: ${body}`;
}
}
}

View file

@ -0,0 +1,43 @@
import GapTile from "./tiles/GapTile.js";
import TextTile from "./tiles/TextTile.js";
import ImageTile from "./tiles/ImageTile.js";
import LocationTile from "./tiles/LocationTile.js";
import RoomNameTile from "./tiles/RoomNameTile.js";
import RoomMemberTile from "./tiles/RoomMemberTile.js";
export default function ({timeline, emitUpdate}) {
return function tilesCreator(entry) {
const options = {entry, emitUpdate};
if (entry.isGap) {
return new GapTile(options, timeline);
} else if (entry.event) {
const event = entry.event;
switch (event.type) {
case "m.room.message": {
const content = event.content;
const msgtype = content && content.msgtype;
switch (msgtype) {
case "m.text":
case "m.notice":
case "m.emote":
return new TextTile(options);
case "m.image":
return new ImageTile(options);
case "m.location":
return new LocationTile(options);
default:
// unknown msgtype not rendered
return null;
}
}
case "m.room.name":
return new RoomNameTile(options);
case "m.room.member":
return new RoomMemberTile(options);
default:
// unknown type not rendered
return null;
}
}
}
}

View file

@ -3,7 +3,7 @@ import Session from "./matrix/session.js";
import createIdbStorage from "./matrix/storage/idb/create.js";
import Sync from "./matrix/sync.js";
import SessionView from "./ui/web/SessionView.js";
import SessionViewModel from "./ui/viewmodels/SessionViewModel.js";
import SessionViewModel from "./domain/session/SessionViewModel.js";
const HOST = "localhost";
const HOMESERVER = `http://${HOST}:8008`;
@ -11,50 +11,61 @@ const USERNAME = "bruno1";
const USER_ID = `@${USERNAME}:${HOST}`;
const PASSWORD = "testtest";
function getSessionId(userId) {
function getSessionInfo(userId) {
const sessionsJson = localStorage.getItem("morpheus_sessions_v1");
if (sessionsJson) {
const sessions = JSON.parse(sessionsJson);
const session = sessions.find(session => session.userId === userId);
if (session) {
return session.id;
return session;
}
}
}
function storeSessionInfo(loginData) {
const sessionsJson = localStorage.getItem("morpheus_sessions_v1");
const sessions = sessionsJson ? JSON.parse(sessionsJson) : [];
const sessionId = (Math.floor(Math.random() * Number.MAX_SAFE_INTEGER)).toString();
const sessionInfo = {
id: sessionId,
deviceId: loginData.device_id,
userId: loginData.user_id,
homeServer: loginData.home_server,
accessToken: loginData.access_token,
};
sessions.push(sessionInfo);
localStorage.setItem("morpheus_sessions_v1", JSON.stringify(sessions));
return sessionInfo;
}
async function login(username, password, homeserver) {
const hsApi = new HomeServerApi(homeserver);
const loginData = await hsApi.passwordLogin(username, password).response();
const sessionsJson = localStorage.getItem("morpheus_sessions_v1");
const sessions = sessionsJson ? JSON.parse(sessionsJson) : [];
const sessionId = (Math.floor(Math.random() * Number.MAX_SAFE_INTEGER)).toString();
console.log(loginData);
sessions.push({userId: loginData.user_id, id: sessionId});
localStorage.setItem("morpheus_sessions_v1", JSON.stringify(sessions));
return {sessionId, loginData};
return storeSessionInfo(loginData);
}
function showSession(container, session) {
const vm = new SessionViewModel(session);
const view = new SessionView(vm);
container.appendChild(view.mount());
view.mount();
container.appendChild(view.root());
}
// eslint-disable-next-line no-unused-vars
export default async function main(label, button, container) {
try {
let sessionId = getSessionId(USER_ID);
let loginData;
if (!sessionId) {
({sessionId, loginData} = await login(USERNAME, PASSWORD, HOMESERVER));
let sessionInfo = getSessionInfo(USER_ID);
if (!sessionInfo) {
sessionInfo = await login(USERNAME, PASSWORD, HOMESERVER);
}
const storage = await createIdbStorage(`morpheus_session_${sessionId}`);
const session = new Session(storage);
if (loginData) {
await session.setLoginData(loginData);
}
await session.load();
const hsApi = new HomeServerApi(HOMESERVER, session.accessToken);
const storage = await createIdbStorage(`morpheus_session_${sessionInfo.id}`);
const hsApi = new HomeServerApi(HOMESERVER, sessionInfo.accessToken);
const session = new Session({storage, hsApi, sessionInfo: {
deviceId: sessionInfo.deviceId,
userId: sessionInfo.userId,
homeServer: sessionInfo.homeServer, //only pass relevant fields to Session
}});
await session.load();
console.log("session loaded");
const needsInitialSync = !session.syncToken;
if (needsInitialSync) {
@ -77,6 +88,6 @@ export default async function main(label, button, container) {
label.innerText = "sync stopped";
});
} catch(err) {
console.error(err);
console.error(`${err.message}:\n${err.stack}`);
}
}

View file

@ -9,5 +9,7 @@ export class StorageError extends Error {
}
export class RequestAbortError extends Error {
}
}
export class NetworkError extends Error {
}

View file

@ -1,6 +1,7 @@
import {
HomeServerError,
RequestAbortError
RequestAbortError,
NetworkError
} from "./error.js";
class RequestWrapper {
@ -20,7 +21,9 @@ class RequestWrapper {
export default class HomeServerApi {
constructor(homeserver, accessToken) {
this._homeserver = homeserver;
// 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;
}
@ -30,7 +33,7 @@ export default class HomeServerApi {
_request(method, csPath, queryParams = {}, body) {
const queryString = Object.entries(queryParams)
.filter(([name, value]) => value !== undefined)
.filter(([, value]) => value !== undefined)
.map(([name, value]) => `${encodeURIComponent(name)}=${encodeURIComponent(value)}`)
.join("&");
const url = this._url(`${csPath}?${queryString}`);
@ -62,10 +65,18 @@ export default class HomeServerApi {
}
}
}, err => {
switch (err.name) {
case "AbortError": throw new RequestAbortError();
default: throw err; //new Error(`Unrecognized DOMException: ${err.name}`);
}
if (err.name === "AbortError") {
throw new RequestAbortError();
} 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(err.message);
}
throw err;
});
return new RequestWrapper(promise, controller);
}
@ -82,6 +93,11 @@ export default class HomeServerApi {
return this._get("/sync", {since, timeout, filter});
}
// params is from, dir and optionally to, limit, filter.
messages(roomId, params) {
return this._get(`/rooms/${roomId}/messages`, params);
}
passwordLogin(username, password) {
return this._post("/login", undefined, {
"type": "m.login.password",
@ -92,4 +108,4 @@ export default class HomeServerApi {
"password": password
});
}
}
}

View file

@ -1,88 +0,0 @@
import SortKey from "../storage/sortkey.js";
export default class RoomPersister {
constructor(roomId) {
this._roomId = roomId;
this._lastSortKey = new SortKey();
}
async load(txn) {
//fetch key here instead?
const [lastEvent] = await txn.roomTimeline.lastEvents(this._roomId, 1);
if (lastEvent) {
this._lastSortKey = new SortKey(lastEvent.sortKey);
console.log("room persister load", this._roomId, this._lastSortKey.toString());
} else {
console.warn("could not recover last sort key for ", this._roomId);
}
}
// async persistGapFill(...) {
// }
persistSync(roomResponse, txn) {
let nextKey = this._lastSortKey;
const timeline = roomResponse.timeline;
const entries = [];
// is limited true for initial sync???? or do we need to handle that as a special case?
// I suppose it will, yes
if (timeline.limited) {
nextKey = nextKey.nextKeyWithGap();
entries.push(this._createGapEntry(nextKey, timeline.prev_batch));
}
// const startOfChunkSortKey = nextKey;
if (timeline.events) {
for(const event of timeline.events) {
nextKey = nextKey.nextKey();
entries.push(this._createEventEntry(nextKey, event));
}
}
// write to store
for(const entry of entries) {
txn.roomTimeline.append(entry);
}
// right thing to do? if the txn fails, not sure we'll continue anyways ...
// only advance the key once the transaction has
// succeeded
txn.complete().then(() => {
console.log("txn complete, setting key");
this._lastSortKey = nextKey;
});
// persist state
const state = roomResponse.state;
if (state.events) {
for (const event of state.events) {
txn.roomState.setStateEvent(this._roomId, event)
}
}
if (timeline.events) {
for (const event of timeline.events) {
if (typeof event.state_key === "string") {
txn.roomState.setStateEvent(this._roomId, event);
}
}
}
return entries;
}
_createGapEntry(sortKey, prevBatch) {
return {
roomId: this._roomId,
sortKey: sortKey.buffer,
event: null,
gap: {prev_batch: prevBatch}
};
}
_createEventEntry(sortKey, event) {
return {
roomId: this._roomId,
sortKey: sortKey.buffer,
event: event,
gap: null
};
}
}

View file

@ -1,22 +1,25 @@
import EventEmitter from "../../EventEmitter.js";
import RoomSummary from "./summary.js";
import RoomPersister from "./persister.js";
import Timeline from "./timeline.js";
import SyncWriter from "./timeline/persistence/SyncWriter.js";
import Timeline from "./timeline/Timeline.js";
import FragmentIdComparer from "./timeline/FragmentIdComparer.js";
export default class Room extends EventEmitter {
constructor(roomId, storage, emitCollectionChange) {
constructor({roomId, storage, hsApi, emitCollectionChange}) {
super();
this._roomId = roomId;
this._storage = storage;
this._roomId = roomId;
this._storage = storage;
this._hsApi = hsApi;
this._summary = new RoomSummary(roomId);
this._persister = new RoomPersister(roomId);
this._fragmentIdComparer = new FragmentIdComparer([]);
this._syncWriter = new SyncWriter({roomId, storage, fragmentIdComparer: this._fragmentIdComparer});
this._emitCollectionChange = emitCollectionChange;
this._timeline = null;
}
persistSync(roomResponse, membership, txn) {
async persistSync(roomResponse, membership, txn) {
const summaryChanged = this._summary.applySync(roomResponse, membership, txn);
const newTimelineEntries = this._persister.persistSync(roomResponse, txn);
const newTimelineEntries = await this._syncWriter.writeSync(roomResponse, txn);
return {summaryChanged, newTimelineEntries};
}
@ -32,7 +35,7 @@ export default class Room extends EventEmitter {
load(summary, txn) {
this._summary.load(summary);
return this._persister.load(txn);
return this._syncWriter.load(txn);
}
get name() {
@ -50,6 +53,8 @@ export default class Room extends EventEmitter {
this._timeline = new Timeline({
roomId: this.id,
storage: this._storage,
hsApi: this._hsApi,
fragmentIdComparer: this._fragmentIdComparer,
closeCallback: () => this._timeline = null,
});
await this._timeline.load();

View file

@ -1,36 +0,0 @@
import { ObservableArray } from "../../observable/index.js";
export default class Timeline {
constructor({roomId, storage, closeCallback}) {
this._roomId = roomId;
this._storage = storage;
this._closeCallback = closeCallback;
this._entriesList = new ObservableArray();
}
/** @package */
async load() {
const txn = await this._storage.readTxn([this._storage.storeNames.roomTimeline]);
const entries = await txn.roomTimeline.lastEvents(this._roomId, 100);
for (const entry of entries) {
this._entriesList.append(entry);
}
}
/** @package */
appendLiveEntries(newEntries) {
for (const entry of newEntries) {
this._entriesList.append(entry);
}
}
/** @public */
get entries() {
return this._entriesList;
}
/** @public */
close() {
this._closeCallback();
}
}

View file

@ -0,0 +1,30 @@
export default class Direction {
constructor(isForward) {
this._isForward = isForward;
}
get isForward() {
return this._isForward;
}
get isBackward() {
return !this.isForward;
}
asApiString() {
return this.isForward ? "f" : "b";
}
static get Forward() {
return _forward;
}
static get Backward() {
return _backward;
}
}
const _forward = Object.freeze(new Direction(true));
const _backward = Object.freeze(new Direction(false));

View file

@ -0,0 +1,158 @@
const DEFAULT_LIVE_FRAGMENT_ID = 0;
const MIN_EVENT_INDEX = Number.MIN_SAFE_INTEGER + 1;
const MAX_EVENT_INDEX = Number.MAX_SAFE_INTEGER - 1;
const MID_EVENT_INDEX = 0;
// key for events in the timelineEvents store
export default class EventKey {
constructor(fragmentId, eventIndex) {
this.fragmentId = fragmentId;
this.eventIndex = eventIndex;
}
nextFragmentKey() {
// could take MIN_EVENT_INDEX here if it can't be paged back
return new EventKey(this.fragmentId + 1, MID_EVENT_INDEX);
}
nextKeyForDirection(direction) {
if (direction.isForward) {
return this.nextKey();
} else {
return this.previousKey();
}
}
previousKey() {
return new EventKey(this.fragmentId, this.eventIndex - 1);
}
nextKey() {
return new EventKey(this.fragmentId, this.eventIndex + 1);
}
static get maxKey() {
return new EventKey(Number.MAX_SAFE_INTEGER, Number.MAX_SAFE_INTEGER);
}
static get minKey() {
return new EventKey(Number.MIN_SAFE_INTEGER, Number.MIN_SAFE_INTEGER);
}
static get defaultLiveKey() {
return new EventKey(DEFAULT_LIVE_FRAGMENT_ID, MID_EVENT_INDEX);
}
toString() {
return `[${this.fragmentId}/${this.eventIndex}]`;
}
}
//#ifdef TESTS
export function xtests() {
const fragmentIdComparer = {compare: (a, b) => a - b};
return {
test_no_fragment_index(assert) {
const min = EventKey.minKey;
const max = EventKey.maxKey;
const a = new EventKey();
a.eventIndex = 1;
a.fragmentId = 1;
assert(min.compare(min) === 0);
assert(max.compare(max) === 0);
assert(a.compare(a) === 0);
assert(min.compare(max) < 0);
assert(max.compare(min) > 0);
assert(min.compare(a) < 0);
assert(a.compare(min) > 0);
assert(max.compare(a) > 0);
assert(a.compare(max) < 0);
},
test_default_key(assert) {
const k = new EventKey(fragmentIdComparer);
assert.equal(k.fragmentId, MID);
assert.equal(k.eventIndex, MID);
},
test_inc(assert) {
const a = new EventKey(fragmentIdComparer);
const b = a.nextKey();
assert.equal(a.fragmentId, b.fragmentId);
assert.equal(a.eventIndex + 1, b.eventIndex);
const c = b.previousKey();
assert.equal(b.fragmentId, c.fragmentId);
assert.equal(c.eventIndex + 1, b.eventIndex);
assert.equal(a.eventIndex, c.eventIndex);
},
test_min_key(assert) {
const minKey = EventKey.minKey;
const k = new EventKey(fragmentIdComparer);
assert(minKey.fragmentId <= k.fragmentId);
assert(minKey.eventIndex <= k.eventIndex);
assert(k.compare(minKey) > 0);
assert(minKey.compare(k) < 0);
},
test_max_key(assert) {
const maxKey = EventKey.maxKey;
const k = new EventKey(fragmentIdComparer);
assert(maxKey.fragmentId >= k.fragmentId);
assert(maxKey.eventIndex >= k.eventIndex);
assert(k.compare(maxKey) < 0);
assert(maxKey.compare(k) > 0);
},
test_immutable(assert) {
const a = new EventKey(fragmentIdComparer);
const fragmentId = a.fragmentId;
const eventIndex = a.eventIndex;
a.nextFragmentKey();
assert.equal(a.fragmentId, fragmentId);
assert.equal(a.eventIndex, eventIndex);
},
test_cmp_fragmentid_first(assert) {
const a = new EventKey(fragmentIdComparer);
const b = new EventKey(fragmentIdComparer);
a.fragmentId = 2;
a.eventIndex = 1;
b.fragmentId = 1;
b.eventIndex = 100000;
assert(a.compare(b) > 0);
},
test_cmp_eventindex_second(assert) {
const a = new EventKey(fragmentIdComparer);
const b = new EventKey(fragmentIdComparer);
a.fragmentId = 1;
a.eventIndex = 100000;
b.fragmentId = 1;
b.eventIndex = 2;
assert(a.compare(b) > 0);
assert(b.compare(a) < 0);
},
test_cmp_max_larger_than_min(assert) {
assert(EventKey.minKey.compare(EventKey.maxKey) < 0);
},
test_cmp_fragmentid_first_large(assert) {
const a = new EventKey(fragmentIdComparer);
const b = new EventKey(fragmentIdComparer);
a.fragmentId = MAX;
a.eventIndex = MIN;
b.fragmentId = MIN;
b.eventIndex = MAX;
assert(b < a);
assert(a > b);
}
};
}
//#endif

View file

@ -0,0 +1,252 @@
/*
lookups will be far more frequent than changing fragment order,
so data structure should be optimized for fast lookup
we can have a Map: fragmentId to sortIndex
changing the order, we would need to rebuild the index
lets do this the stupid way for now, changing any fragment rebuilds all islands
to build this:
first load all fragments
put them in a map by id
now iterate through them
until no more fragments
get the first
create an island array, and add to list with islands
going backwards and forwards
get and remove sibling and prepend/append it to island array
stop when no more previous/next
return list with islands
*/
import {isValidFragmentId} from "./common.js";
function findBackwardSiblingFragments(current, byId) {
const sortedSiblings = [];
while (isValidFragmentId(current.previousId)) {
const previous = byId.get(current.previousId);
if (!previous) {
break;
}
if (previous.nextId !== current.id) {
throw new Error(`Previous fragment ${previous.id} doesn't point back to ${current.id}`);
}
byId.delete(current.previousId);
sortedSiblings.unshift(previous);
current = previous;
}
return sortedSiblings;
}
function findForwardSiblingFragments(current, byId) {
const sortedSiblings = [];
while (isValidFragmentId(current.nextId)) {
const next = byId.get(current.nextId);
if (!next) {
break;
}
if (next.previousId !== current.id) {
throw new Error(`Next fragment ${next.id} doesn't point back to ${current.id}`);
}
byId.delete(current.nextId);
sortedSiblings.push(next);
current = next;
}
return sortedSiblings;
}
function createIslands(fragments) {
const byId = new Map();
for(let f of fragments) {
byId.set(f.id, f);
}
const islands = [];
while(byId.size) {
const current = byId.values().next().value;
byId.delete(current.id);
// new island
const previousSiblings = findBackwardSiblingFragments(current, byId);
const nextSiblings = findForwardSiblingFragments(current, byId);
const island = previousSiblings.concat(current, nextSiblings);
islands.push(island);
}
return islands.map(a => new Island(a));
}
class Island {
constructor(sortedFragments) {
this._idToSortIndex = new Map();
sortedFragments.forEach((f, i) => {
this._idToSortIndex.set(f.id, i);
});
}
compare(idA, idB) {
const sortIndexA = this._idToSortIndex.get(idA);
if (sortIndexA === undefined) {
throw new Error(`first id ${idA} isn't part of this island`);
}
const sortIndexB = this._idToSortIndex.get(idB);
if (sortIndexB === undefined) {
throw new Error(`second id ${idB} isn't part of this island`);
}
return sortIndexA - sortIndexB;
}
get fragmentIds() {
return this._idToSortIndex.keys();
}
}
/*
index for fast lookup of how two fragments can be sorted
*/
export default class FragmentIdComparer {
constructor(fragments) {
this._fragmentsById = fragments.reduce((map, f) => {map.set(f.id, f); return map;}, new Map());
this.rebuild(fragments);
}
_getIsland(id) {
const island = this._idToIsland.get(id);
if (island === undefined) {
throw new Error(`Unknown fragment id ${id}`);
}
return island;
}
compare(idA, idB) {
if (idA === idB) {
return 0;
}
const islandA = this._getIsland(idA);
const islandB = this._getIsland(idB);
if (islandA !== islandB) {
throw new Error(`${idA} and ${idB} are on different islands, can't tell order`);
}
return islandA.compare(idA, idB);
}
rebuild(fragments) {
const islands = createIslands(fragments);
this._idToIsland = new Map();
for(let island of islands) {
for(let id of island.fragmentIds) {
this._idToIsland.set(id, island);
}
}
}
add(fragment) {
this._fragmentsById.set(fragment.id, fragment);
this.rebuild(this._fragmentsById.values());
}
}
//#ifdef TESTS
export function tests() {
return {
test_1_island_3_fragments(assert) {
const index = new FragmentIdComparer([
{id: 3, previousId: 2},
{id: 1, nextId: 2},
{id: 2, nextId: 3, previousId: 1},
]);
assert(index.compare(1, 2) < 0);
assert(index.compare(2, 1) > 0);
assert(index.compare(1, 3) < 0);
assert(index.compare(3, 1) > 0);
assert(index.compare(2, 3) < 0);
assert(index.compare(3, 2) > 0);
assert.equal(index.compare(1, 1), 0);
},
test_falsy_id(assert) {
const index = new FragmentIdComparer([
{id: 0, nextId: 1},
{id: 1, previousId: 0},
]);
assert(index.compare(0, 1) < 0);
assert(index.compare(1, 0) > 0);
},
test_falsy_id_reverse(assert) {
const index = new FragmentIdComparer([
{id: 1, previousId: 0},
{id: 0, nextId: 1},
]);
assert(index.compare(0, 1) < 0);
assert(index.compare(1, 0) > 0);
},
test_allow_unknown_id(assert) {
// as we tend to load fragments incrementally
// as events come into view, we need to allow
// unknown previousId/nextId in the fragments that we do load
assert.doesNotThrow(() => {
new FragmentIdComparer([
{id: 1, previousId: 2},
{id: 0, nextId: 3},
]);
});
},
test_throw_on_link_mismatch(assert) {
// as we tend to load fragments incrementally
// as events come into view, we need to allow
// unknown previousId/nextId in the fragments that we do load
assert.throws(() => {
new FragmentIdComparer([
{id: 1, previousId: 0},
{id: 0, nextId: 2},
]);
});
},
test_2_island_dont_compare(assert) {
const index = new FragmentIdComparer([
{id: 1},
{id: 2},
]);
assert.throws(() => index.compare(1, 2));
assert.throws(() => index.compare(2, 1));
},
test_2_island_compare_internally(assert) {
const index = new FragmentIdComparer([
{id: 1, nextId: 2},
{id: 2, previousId: 1},
{id: 11, nextId: 12},
{id: 12, previousId: 11},
]);
assert(index.compare(1, 2) < 0);
assert(index.compare(11, 12) < 0);
assert.throws(() => index.compare(1, 11));
assert.throws(() => index.compare(12, 2));
},
test_unknown_id(assert) {
const index = new FragmentIdComparer([{id: 1}]);
assert.throws(() => index.compare(1, 2));
assert.throws(() => index.compare(2, 1));
},
test_rebuild_flushes_old_state(assert) {
const index = new FragmentIdComparer([
{id: 1, nextId: 2},
{id: 2, previousId: 1},
]);
index.rebuild([
{id: 11, nextId: 12},
{id: 12, previousId: 11},
]);
assert.throws(() => index.compare(1, 2));
assert(index.compare(11, 12) < 0);
},
}
}
//#endif

View file

@ -0,0 +1,71 @@
import { SortedArray } from "../../../observable/index.js";
import Direction from "./Direction.js";
import GapWriter from "./persistence/GapWriter.js";
import TimelineReader from "./persistence/TimelineReader.js";
export default class Timeline {
constructor({roomId, storage, closeCallback, fragmentIdComparer, hsApi}) {
this._roomId = roomId;
this._storage = storage;
this._closeCallback = closeCallback;
this._fragmentIdComparer = fragmentIdComparer;
this._hsApi = hsApi;
this._entriesList = new SortedArray((a, b) => a.compare(b));
this._timelineReader = new TimelineReader({
roomId: this._roomId,
storage: this._storage,
fragmentIdComparer: this._fragmentIdComparer
});
}
/** @package */
async load() {
const entries = await this._timelineReader.readFromEnd(100);
this._entriesList.setManySorted(entries);
}
/** @package */
appendLiveEntries(newEntries) {
this._entriesList.setManySorted(newEntries);
}
/** @public */
async fillGap(fragmentEntry, amount) {
const response = await this._hsApi.messages(this._roomId, {
from: fragmentEntry.token,
dir: fragmentEntry.direction.asApiString(),
limit: amount
}).response();
const gapWriter = new GapWriter({
roomId: this._roomId,
storage: this._storage,
fragmentIdComparer: this._fragmentIdComparer
});
const newEntries = await gapWriter.writeFragmentFill(fragmentEntry, response);
this._entriesList.setManySorted(newEntries);
}
// tries to prepend `amount` entries to the `entries` list.
async loadAtTop(amount) {
if (this._entriesList.length() === 0) {
return;
}
const firstEntry = this._entriesList.array()[0];
const entries = await this._timelineReader.readFrom(
firstEntry.asEventKey(),
Direction.Backward,
amount
);
this._entriesList.setManySorted(entries);
}
/** @public */
get entries() {
return this._entriesList;
}
/** @public */
close() {
this._closeCallback();
}
}

View file

@ -0,0 +1,3 @@
export function isValidFragmentId(id) {
return typeof id === "number";
}

View file

@ -0,0 +1,29 @@
//entries can be sorted, first by fragment, then by entry index.
import EventKey from "../EventKey.js";
export default class BaseEntry {
constructor(fragmentIdComparer) {
this._fragmentIdComparer = fragmentIdComparer;
}
get fragmentId() {
throw new Error("unimplemented");
}
get entryIndex() {
throw new Error("unimplemented");
}
compare(otherEntry) {
if (this.fragmentId === otherEntry.fragmentId) {
return this.entryIndex - otherEntry.entryIndex;
} else {
// This might throw if the relation of two fragments is unknown.
return this._fragmentIdComparer.compare(this.fragmentId, otherEntry.fragmentId);
}
}
asEventKey() {
return new EventKey(this.fragmentId, this.entryIndex);
}
}

View file

@ -0,0 +1,32 @@
import BaseEntry from "./BaseEntry.js";
export default class EventEntry extends BaseEntry {
constructor(eventEntry, fragmentIdComparer) {
super(fragmentIdComparer);
this._eventEntry = eventEntry;
}
get fragmentId() {
return this._eventEntry.fragmentId;
}
get entryIndex() {
return this._eventEntry.eventIndex;
}
get content() {
return this._eventEntry.event.content;
}
get event() {
return this._eventEntry.event;
}
get type() {
return this._eventEntry.event.type;
}
get id() {
return this._eventEntry.event.event_id;
}
}

View file

@ -0,0 +1,100 @@
import BaseEntry from "./BaseEntry.js";
import Direction from "../Direction.js";
import {isValidFragmentId} from "../common.js";
export default class FragmentBoundaryEntry extends BaseEntry {
constructor(fragment, isFragmentStart, fragmentIdComparer) {
super(fragmentIdComparer);
this._fragment = fragment;
// TODO: should isFragmentStart be Direction instead of bool?
this._isFragmentStart = isFragmentStart;
}
static start(fragment, fragmentIdComparer) {
return new FragmentBoundaryEntry(fragment, true, fragmentIdComparer);
}
static end(fragment, fragmentIdComparer) {
return new FragmentBoundaryEntry(fragment, false, fragmentIdComparer);
}
get started() {
return this._isFragmentStart;
}
get hasEnded() {
return !this.started;
}
get fragment() {
return this._fragment;
}
get fragmentId() {
return this._fragment.id;
}
get entryIndex() {
if (this.started) {
return Number.MIN_SAFE_INTEGER;
} else {
return Number.MAX_SAFE_INTEGER;
}
}
get isGap() {
return !!this.token;
}
get token() {
if (this.started) {
return this.fragment.previousToken;
} else {
return this.fragment.nextToken;
}
}
set token(token) {
if (this.started) {
this.fragment.previousToken = token;
} else {
this.fragment.nextToken = token;
}
}
get linkedFragmentId() {
if (this.started) {
return this.fragment.previousId;
} else {
return this.fragment.nextId;
}
}
set linkedFragmentId(id) {
if (this.started) {
this.fragment.previousId = id;
} else {
this.fragment.nextId = id;
}
}
get hasLinkedFragment() {
return isValidFragmentId(this.linkedFragmentId);
}
get direction() {
if (this.started) {
return Direction.Backward;
} else {
return Direction.Forward;
}
}
withUpdatedFragment(fragment) {
return new FragmentBoundaryEntry(fragment, this._isFragmentStart, this._fragmentIdComparer);
}
createNeighbourEntry(neighbour) {
return new FragmentBoundaryEntry(neighbour, !this._isFragmentStart, this._fragmentIdComparer);
}
}

View file

@ -0,0 +1,229 @@
import EventKey from "../EventKey.js";
import EventEntry from "../entries/EventEntry.js";
import {createEventEntry, directionalAppend} from "./common.js";
export default class GapWriter {
constructor({roomId, storage, fragmentIdComparer}) {
this._roomId = roomId;
this._storage = storage;
this._fragmentIdComparer = fragmentIdComparer;
}
// events is in reverse-chronological order (last event comes at index 0) if backwards
async _findOverlappingEvents(fragmentEntry, events, txn) {
const eventIds = events.map(e => e.event_id);
let nonOverlappingEvents = events;
let neighbourFragmentEntry;
const neighbourEventId = await txn.timelineEvents.findFirstOccurringEventId(this._roomId, eventIds);
if (neighbourEventId) {
// trim overlapping events
const neighbourEventIndex = events.findIndex(e => e.event_id === neighbourEventId);
nonOverlappingEvents = events.slice(0, neighbourEventIndex);
// get neighbour fragment to link it up later on
const neighbourEvent = await txn.timelineEvents.getByEventId(this._roomId, neighbourEventId);
const neighbourFragment = await txn.timelineFragments.get(this._roomId, neighbourEvent.fragmentId);
neighbourFragmentEntry = fragmentEntry.createNeighbourEntry(neighbourFragment);
}
return {nonOverlappingEvents, neighbourFragmentEntry};
}
async _findLastFragmentEventKey(fragmentEntry, txn) {
const {fragmentId, direction} = fragmentEntry;
if (direction.isBackward) {
const [firstEvent] = await txn.timelineEvents.firstEvents(this._roomId, fragmentId, 1);
return new EventKey(firstEvent.fragmentId, firstEvent.eventIndex);
} else {
const [lastEvent] = await txn.timelineEvents.lastEvents(this._roomId, fragmentId, 1);
return new EventKey(lastEvent.fragmentId, lastEvent.eventIndex);
}
}
_storeEvents(events, startKey, direction, txn) {
const entries = [];
// events is in reverse chronological order for backwards pagination,
// e.g. order is moving away from the `from` point.
let key = startKey;
for(let event of events) {
key = key.nextKeyForDirection(direction);
const eventStorageEntry = createEventEntry(key, this._roomId, event);
txn.timelineEvents.insert(eventStorageEntry);
const eventEntry = new EventEntry(eventStorageEntry, this._fragmentIdComparer);
directionalAppend(entries, eventEntry, direction);
}
return entries;
}
async _updateFragments(fragmentEntry, neighbourFragmentEntry, end, entries, txn) {
const {direction} = fragmentEntry;
directionalAppend(entries, fragmentEntry, direction);
// set `end` as token, and if we found an event in the step before, link up the fragments in the fragment entry
if (neighbourFragmentEntry) {
fragmentEntry.linkedFragmentId = neighbourFragmentEntry.fragmentId;
neighbourFragmentEntry.linkedFragmentId = fragmentEntry.fragmentId;
// if neighbourFragmentEntry was found, it means the events were overlapping,
// so no pagination should happen anymore.
neighbourFragmentEntry.token = null;
fragmentEntry.token = null;
txn.timelineFragments.update(neighbourFragmentEntry.fragment);
directionalAppend(entries, neighbourFragmentEntry, direction);
// update fragmentIdComparer here after linking up fragments
this._fragmentIdComparer.add(fragmentEntry.fragment);
this._fragmentIdComparer.add(neighbourFragmentEntry.fragment);
} else {
fragmentEntry.token = end;
}
txn.timelineFragments.update(fragmentEntry.fragment);
}
async writeFragmentFill(fragmentEntry, response) {
const {fragmentId, direction} = fragmentEntry;
// chunk is in reverse-chronological order when backwards
const {chunk, start, end} = response;
let entries;
if (!Array.isArray(chunk)) {
throw new Error("Invalid chunk in response");
}
if (typeof end !== "string") {
throw new Error("Invalid end token in response");
}
const txn = await this._storage.readWriteTxn([
this._storage.storeNames.timelineEvents,
this._storage.storeNames.timelineFragments,
]);
try {
// make sure we have the latest fragment from the store
const fragment = await txn.timelineFragments.get(this._roomId, fragmentId);
if (!fragment) {
throw new Error(`Unknown fragment: ${fragmentId}`);
}
fragmentEntry = fragmentEntry.withUpdatedFragment(fragment);
// check that the request was done with the token we are aware of (extra care to avoid timeline corruption)
if (fragmentEntry.token !== start) {
throw new Error("start is not equal to prev_batch or next_batch");
}
// find last event in fragment so we get the eventIndex to begin creating keys at
let lastKey = await this._findLastFragmentEventKey(fragmentEntry, txn);
// find out if any event in chunk is already present using findFirstOrLastOccurringEventId
const {
nonOverlappingEvents,
neighbourFragmentEntry
} = await this._findOverlappingEvents(fragmentEntry, chunk, txn);
// create entries for all events in chunk, add them to entries
entries = this._storeEvents(nonOverlappingEvents, lastKey, direction, txn);
await this._updateFragments(fragmentEntry, neighbourFragmentEntry, end, entries, txn);
} catch (err) {
txn.abort();
throw err;
}
await txn.complete();
return entries;
}
}
//#ifdef TESTS
//import MemoryStorage from "../storage/memory/MemoryStorage.js";
export function xtests() {
const roomId = "!abc:hs.tld";
// sets sortKey and roomId on an array of entries
function createTimeline(roomId, entries) {
let key = new SortKey();
for (let entry of entries) {
if (entry.gap && entry.gap.prev_batch) {
key = key.nextKeyWithGap();
}
entry.sortKey = key;
if (entry.gap && entry.gap.next_batch) {
key = key.nextKeyWithGap();
} else if (!entry.gap) {
key = key.nextKey();
}
entry.roomId = roomId;
}
}
function areSorted(entries) {
for (var i = 1; i < entries.length; i++) {
const isSorted = entries[i - 1].sortKey.compare(entries[i].sortKey) < 0;
if(!isSorted) {
return false
}
}
return true;
}
return {
"test backwards gap fill with overlapping neighbouring event": async function(assert) {
const currentPaginationToken = "abc";
const gap = {gap: {prev_batch: currentPaginationToken}};
const storage = new MemoryStorage({roomTimeline: createTimeline(roomId, [
{event: {event_id: "b"}},
{gap: {next_batch: "ghi"}},
gap,
])});
const persister = new RoomPersister({roomId, storage});
const response = {
start: currentPaginationToken,
end: "def",
chunk: [
{event_id: "a"},
{event_id: "b"},
{event_id: "c"},
{event_id: "d"},
]
};
const {newEntries, replacedEntries} = await persister.persistGapFill(gap, response);
// should only have taken events up till existing event
assert.equal(newEntries.length, 2);
assert.equal(newEntries[0].event.event_id, "c");
assert.equal(newEntries[1].event.event_id, "d");
assert.equal(replacedEntries.length, 2);
assert.equal(replacedEntries[0].gap.next_batch, "hij");
assert.equal(replacedEntries[1].gap.prev_batch, currentPaginationToken);
assert(areSorted(newEntries));
assert(areSorted(replacedEntries));
},
"test backwards gap fill with non-overlapping neighbouring event": async function(assert) {
const currentPaginationToken = "abc";
const newPaginationToken = "def";
const gap = {gap: {prev_batch: currentPaginationToken}};
const storage = new MemoryStorage({roomTimeline: createTimeline(roomId, [
{event: {event_id: "a"}},
{gap: {next_batch: "ghi"}},
gap,
])});
const persister = new RoomPersister({roomId, storage});
const response = {
start: currentPaginationToken,
end: newPaginationToken,
chunk: [
{event_id: "c"},
{event_id: "d"},
{event_id: "e"},
{event_id: "f"},
]
};
const {newEntries, replacedEntries} = await persister.persistGapFill(gap, response);
// should only have taken events up till existing event
assert.equal(newEntries.length, 5);
assert.equal(newEntries[0].gap.prev_batch, newPaginationToken);
assert.equal(newEntries[1].event.event_id, "c");
assert.equal(newEntries[2].event.event_id, "d");
assert.equal(newEntries[3].event.event_id, "e");
assert.equal(newEntries[4].event.event_id, "f");
assert(areSorted(newEntries));
assert.equal(replacedEntries.length, 1);
assert.equal(replacedEntries[0].gap.prev_batch, currentPaginationToken);
},
}
}
//#endif

View file

@ -0,0 +1,224 @@
import EventKey from "../EventKey.js";
import EventEntry from "../entries/EventEntry.js";
import FragmentBoundaryEntry from "../entries/FragmentBoundaryEntry.js";
import {createEventEntry} from "./common.js";
export default class SyncWriter {
constructor({roomId, storage, fragmentIdComparer}) {
this._roomId = roomId;
this._storage = storage;
this._fragmentIdComparer = fragmentIdComparer;
this._lastLiveKey = null;
}
async load(txn) {
const liveFragment = await txn.timelineFragments.liveFragment(this._roomId);
if (liveFragment) {
const [lastEvent] = await txn.timelineEvents.lastEvents(this._roomId, liveFragment.id, 1);
// sorting and identifying (e.g. sort key and pk to insert) are a bit intertwined here
// we could split it up into a SortKey (only with compare) and
// a EventKey (no compare or fragment index) with nextkey methods and getters/setters for eventIndex/fragmentId
// we probably need to convert from one to the other though, so bother?
this._lastLiveKey = new EventKey(liveFragment.id, lastEvent.eventIndex);
}
// if there is no live fragment, we don't create it here because load gets a readonly txn.
// this is on purpose, load shouldn't modify the store
console.log("room persister load", this._roomId, this._lastLiveKey && this._lastLiveKey.toString());
}
async _createLiveFragment(txn, previousToken) {
const liveFragment = await txn.timelineFragments.liveFragment(this._roomId);
if (!liveFragment) {
if (!previousToken) {
previousToken = null;
}
const fragment = {
roomId: this._roomId,
id: EventKey.defaultLiveKey.fragmentId,
previousId: null,
nextId: null,
previousToken: previousToken,
nextToken: null
};
txn.timelineFragments.add(fragment);
this._fragmentIdComparer.add(fragment);
return fragment;
} else {
return liveFragment;
}
}
async _replaceLiveFragment(oldFragmentId, newFragmentId, previousToken, txn) {
const oldFragment = await txn.timelineFragments.get(this._roomId, oldFragmentId);
if (!oldFragment) {
throw new Error(`old live fragment doesn't exist: ${oldFragmentId}`);
}
oldFragment.nextId = newFragmentId;
txn.timelineFragments.update(oldFragment);
const newFragment = {
roomId: this._roomId,
id: newFragmentId,
previousId: oldFragmentId,
nextId: null,
previousToken: previousToken,
nextToken: null
};
txn.timelineFragments.add(newFragment);
this._fragmentIdComparer.add(newFragment);
return {oldFragment, newFragment};
}
async writeSync(roomResponse, txn) {
const entries = [];
const timeline = roomResponse.timeline;
if (!this._lastLiveKey) {
// means we haven't synced this room yet (just joined or did initial sync)
// as this is probably a limited sync, prev_batch should be there
// (but don't fail if it isn't, we won't be able to back-paginate though)
let liveFragment = await this._createLiveFragment(txn, timeline.prev_batch);
this._lastLiveKey = new EventKey(liveFragment.id, EventKey.defaultLiveKey.eventIndex);
entries.push(FragmentBoundaryEntry.start(liveFragment, this._fragmentIdComparer));
} else if (timeline.limited) {
// replace live fragment for limited sync, *only* if we had a live fragment already
const oldFragmentId = this._lastLiveKey.fragmentId;
this._lastLiveKey = this._lastLiveKey.nextFragmentKey();
const {oldFragment, newFragment} = await this._replaceLiveFragment(oldFragmentId, this._lastLiveKey.fragmentId, timeline.prev_batch, txn);
entries.push(FragmentBoundaryEntry.end(oldFragment, this._fragmentIdComparer));
entries.push(FragmentBoundaryEntry.start(newFragment, this._fragmentIdComparer));
}
let currentKey = this._lastLiveKey;
if (timeline.events) {
for(const event of timeline.events) {
currentKey = currentKey.nextKey();
const entry = createEventEntry(currentKey, this._roomId, event);
txn.timelineEvents.insert(entry);
entries.push(new EventEntry(entry, this._fragmentIdComparer));
}
}
// right thing to do? if the txn fails, not sure we'll continue anyways ...
// only advance the key once the transaction has succeeded
txn.complete().then(() => {
this._lastLiveKey = currentKey;
})
// persist state
const state = roomResponse.state;
if (state.events) {
for (const event of state.events) {
txn.roomState.setStateEvent(this._roomId, event);
}
}
// persist live state events in timeline
if (timeline.events) {
for (const event of timeline.events) {
if (typeof event.state_key === "string") {
txn.roomState.setStateEvent(this._roomId, event);
}
}
}
return entries;
}
}
//#ifdef TESTS
//import MemoryStorage from "../storage/memory/MemoryStorage.js";
export function xtests() {
const roomId = "!abc:hs.tld";
// sets sortKey and roomId on an array of entries
function createTimeline(roomId, entries) {
let key = new SortKey();
for (let entry of entries) {
if (entry.gap && entry.gap.prev_batch) {
key = key.nextKeyWithGap();
}
entry.sortKey = key;
if (entry.gap && entry.gap.next_batch) {
key = key.nextKeyWithGap();
} else if (!entry.gap) {
key = key.nextKey();
}
entry.roomId = roomId;
}
}
function areSorted(entries) {
for (var i = 1; i < entries.length; i++) {
const isSorted = entries[i - 1].sortKey.compare(entries[i].sortKey) < 0;
if(!isSorted) {
return false
}
}
return true;
}
return {
"test backwards gap fill with overlapping neighbouring event": async function(assert) {
const currentPaginationToken = "abc";
const gap = {gap: {prev_batch: currentPaginationToken}};
const storage = new MemoryStorage({roomTimeline: createTimeline(roomId, [
{event: {event_id: "b"}},
{gap: {next_batch: "ghi"}},
gap,
])});
const persister = new RoomPersister({roomId, storage});
const response = {
start: currentPaginationToken,
end: "def",
chunk: [
{event_id: "a"},
{event_id: "b"},
{event_id: "c"},
{event_id: "d"},
]
};
const {newEntries, replacedEntries} = await persister.persistGapFill(gap, response);
// should only have taken events up till existing event
assert.equal(newEntries.length, 2);
assert.equal(newEntries[0].event.event_id, "c");
assert.equal(newEntries[1].event.event_id, "d");
assert.equal(replacedEntries.length, 2);
assert.equal(replacedEntries[0].gap.next_batch, "hij");
assert.equal(replacedEntries[1].gap.prev_batch, currentPaginationToken);
assert(areSorted(newEntries));
assert(areSorted(replacedEntries));
},
"test backwards gap fill with non-overlapping neighbouring event": async function(assert) {
const currentPaginationToken = "abc";
const newPaginationToken = "def";
const gap = {gap: {prev_batch: currentPaginationToken}};
const storage = new MemoryStorage({roomTimeline: createTimeline(roomId, [
{event: {event_id: "a"}},
{gap: {next_batch: "ghi"}},
gap,
])});
const persister = new RoomPersister({roomId, storage});
const response = {
start: currentPaginationToken,
end: newPaginationToken,
chunk: [
{event_id: "c"},
{event_id: "d"},
{event_id: "e"},
{event_id: "f"},
]
};
const {newEntries, replacedEntries} = await persister.persistGapFill(gap, response);
// should only have taken events up till existing event
assert.equal(newEntries.length, 5);
assert.equal(newEntries[0].gap.prev_batch, newPaginationToken);
assert.equal(newEntries[1].event.event_id, "c");
assert.equal(newEntries[2].event.event_id, "d");
assert.equal(newEntries[3].event.event_id, "e");
assert.equal(newEntries[4].event.event_id, "f");
assert(areSorted(newEntries));
assert.equal(replacedEntries.length, 1);
assert.equal(replacedEntries[0].gap.prev_batch, currentPaginationToken);
},
}
}
//#endif

View file

@ -0,0 +1,84 @@
import {directionalConcat, directionalAppend} from "./common.js";
import EventKey from "../EventKey.js";
import Direction from "../Direction.js";
import EventEntry from "../entries/EventEntry.js";
import FragmentBoundaryEntry from "../entries/FragmentBoundaryEntry.js";
export default class TimelineReader {
constructor({roomId, storage, fragmentIdComparer}) {
this._roomId = roomId;
this._storage = storage;
this._fragmentIdComparer = fragmentIdComparer;
}
_openTxn() {
return this._storage.readTxn([
this._storage.storeNames.timelineEvents,
this._storage.storeNames.timelineFragments,
]);
}
async readFrom(eventKey, direction, amount) {
const txn = await this._openTxn();
return this._readFrom(eventKey, direction, amount, txn);
}
async _readFrom(eventKey, direction, amount, txn) {
let entries = [];
const timelineStore = txn.timelineEvents;
const fragmentStore = txn.timelineFragments;
while (entries.length < amount && eventKey) {
let eventsWithinFragment;
if (direction.isForward) {
eventsWithinFragment = await timelineStore.eventsAfter(this._roomId, eventKey, amount);
} else {
eventsWithinFragment = await timelineStore.eventsBefore(this._roomId, eventKey, amount);
}
const eventEntries = eventsWithinFragment.map(e => new EventEntry(e, this._fragmentIdComparer));
entries = directionalConcat(entries, eventEntries, direction);
// prepend or append eventsWithinFragment to entries, and wrap them in EventEntry
if (entries.length < amount) {
const fragment = await fragmentStore.get(this._roomId, eventKey.fragmentId);
// this._fragmentIdComparer.addFragment(fragment);
let fragmentEntry = new FragmentBoundaryEntry(fragment, direction.isBackward, this._fragmentIdComparer);
// append or prepend fragmentEntry, reuse func from GapWriter?
directionalAppend(entries, fragmentEntry, direction);
// don't count it in amount perhaps? or do?
if (fragmentEntry.hasLinkedFragment) {
const nextFragment = await fragmentStore.get(this._roomId, fragmentEntry.linkedFragmentId);
this._fragmentIdComparer.add(nextFragment);
const nextFragmentEntry = new FragmentBoundaryEntry(nextFragment, direction.isForward, this._fragmentIdComparer);
directionalAppend(entries, nextFragmentEntry, direction);
eventKey = nextFragmentEntry.asEventKey();
} else {
eventKey = null;
}
}
}
return entries;
}
async readFromEnd(amount) {
const txn = await this._openTxn();
const liveFragment = await txn.timelineFragments.liveFragment(this._roomId);
// room hasn't been synced yet
if (!liveFragment) {
return [];
}
this._fragmentIdComparer.add(liveFragment);
const liveFragmentEntry = FragmentBoundaryEntry.end(liveFragment, this._fragmentIdComparer);
const eventKey = liveFragmentEntry.asEventKey();
const entries = await this._readFrom(eventKey, Direction.Backward, amount, txn);
entries.unshift(liveFragmentEntry);
return entries;
}
// reads distance up and down from eventId
// or just expose eventIdToKey?
readAtEventId(eventId, distance) {
return null;
}
}

View file

@ -0,0 +1,24 @@
export function createEventEntry(key, roomId, event) {
return {
fragmentId: key.fragmentId,
eventIndex: key.eventIndex,
roomId,
event: event,
};
}
export function directionalAppend(array, value, direction) {
if (direction.isForward) {
array.push(value);
} else {
array.unshift(value);
}
}
export function directionalConcat(array, otherArray, direction) {
if (direction.isForward) {
return array.concat(otherArray);
} else {
return otherArray.concat(array);
}
}

View file

@ -2,64 +2,61 @@ import Room from "./room/room.js";
import { ObservableMap } from "../observable/index.js";
export default class Session {
constructor(storage) {
this._storage = storage;
this._session = null;
this._rooms = new ObservableMap();
// sessionInfo contains deviceId, userId and homeServer
constructor({storage, hsApi, sessionInfo}) {
this._storage = storage;
this._hsApi = hsApi;
this._session = null;
this._sessionInfo = sessionInfo;
this._rooms = new ObservableMap();
this._roomUpdateCallback = (room, params) => this._rooms.update(room.id, params);
}
// should be called before load
// loginData has device_id, user_id, home_server, access_token
async setLoginData(loginData) {
console.log("session.setLoginData");
const txn = await this._storage.readWriteTxn([this._storage.storeNames.session]);
const session = {loginData};
txn.session.set(session);
await txn.complete();
}
}
async load() {
const txn = await this._storage.readTxn([
this._storage.storeNames.session,
this._storage.storeNames.roomSummary,
this._storage.storeNames.roomState,
this._storage.storeNames.roomTimeline,
]);
// restore session object
this._session = await txn.session.get();
if (!this._session) {
throw new Error("session store is empty");
}
// load rooms
const rooms = await txn.roomSummary.getAll();
await Promise.all(rooms.map(summary => {
const room = this.createRoom(summary.roomId);
return room.load(summary, txn);
}));
}
async load() {
const txn = await this._storage.readTxn([
this._storage.storeNames.session,
this._storage.storeNames.roomSummary,
this._storage.storeNames.roomState,
this._storage.storeNames.timelineEvents,
this._storage.storeNames.timelineFragments,
]);
// restore session object
this._session = await txn.session.get();
if (!this._session) {
this._session = {};
return;
}
// load rooms
const rooms = await txn.roomSummary.getAll();
await Promise.all(rooms.map(summary => {
const room = this.createRoom(summary.roomId);
return room.load(summary, txn);
}));
}
get rooms() {
return this._rooms;
}
createRoom(roomId) {
const room = new Room(roomId, this._storage, this._roomUpdateCallback);
this._rooms.add(roomId, room);
return room;
}
createRoom(roomId) {
const room = new Room({
roomId,
storage: this._storage,
emitCollectionChange: this._roomUpdateCallback,
hsApi: this._hsApi,
});
this._rooms.add(roomId, room);
return room;
}
applySync(syncToken, accountData, txn) {
if (syncToken !== this._session.syncToken) {
this._session.syncToken = syncToken;
txn.session.set(this._session);
}
}
persistSync(syncToken, accountData, txn) {
if (syncToken !== this._session.syncToken) {
this._session.syncToken = syncToken;
txn.session.set(this._session);
}
}
get syncToken() {
return this._session.syncToken;
}
get accessToken() {
return this._session.loginData.access_token;
}
get syncToken() {
return this._session.syncToken;
}
}

View file

@ -0,0 +1,6 @@
export const STORE_NAMES = Object.freeze(["session", "roomState", "roomSummary", "timelineEvents", "timelineFragments"]);
export const STORE_MAP = Object.freeze(STORE_NAMES.reduce((nameMap, name) => {
nameMap[name] = name;
return nameMap;
}, {}));

View file

@ -2,31 +2,33 @@ import Storage from "./storage.js";
import { openDatabase } from "./utils.js";
export default async function createIdbStorage(databaseName) {
const db = await openDatabase(databaseName, createStores, 1);
return new Storage(db);
const db = await openDatabase(databaseName, createStores, 1);
return new Storage(db);
}
function createStores(db) {
db.createObjectStore("session", {keyPath: "key"});
// any way to make keys unique here? (just use put?)
db.createObjectStore("roomSummary", {keyPath: "roomId"});
// needs roomId separate because it might hold a gap and no event
const timeline = db.createObjectStore("roomTimeline", {keyPath: ["roomId", "sortKey"]});
timeline.createIndex("byEventId", [
"roomId",
"event.event_id"
], {unique: true});
db.createObjectStore("session", {keyPath: "key"});
// any way to make keys unique here? (just use put?)
db.createObjectStore("roomSummary", {keyPath: "roomId"});
db.createObjectStore("roomState", {keyPath: [
"roomId",
"event.type",
"event.state_key"
]});
// const roomMembers = db.createObjectStore("roomMembers", {keyPath: [
// "event.room_id",
// "event.content.membership",
// "event.state_key"
// ]});
// roomMembers.createIndex("byName", ["room_id", "content.name"]);
}
// need index to find live fragment? prooobably ok without for now
db.createObjectStore("timelineFragments", {keyPath: ["roomId", "id"]});
const timelineEvents = db.createObjectStore("timelineEvents", {keyPath: ["roomId", "fragmentId", "eventIndex"]});
timelineEvents.createIndex("byEventId", [
"roomId",
"event.event_id"
], {unique: true});
db.createObjectStore("roomState", {keyPath: [
"roomId",
"event.type",
"event.state_key"
]});
// const roomMembers = db.createObjectStore("roomMembers", {keyPath: [
// "event.room_id",
// "event.content.membership",
// "event.state_key"
// ]});
// roomMembers.createIndex("byName", ["room_id", "content.name"]);
}

View file

@ -1,16 +1,20 @@
import {iterateCursor} from "./utils.js";
import {iterateCursor, reqAsPromise} from "./utils.js";
export default class QueryTarget {
constructor(target) {
this._target = target;
}
get(key) {
return reqAsPromise(this._target.get(key));
}
reduce(range, reducer, initialValue) {
return this._reduce(range, reducer, initialValue, "next");
}
reduceReverse(range, reducer, initialValue) {
return this._reduce(range, reducer, initialValue, "next");
return this._reduce(range, reducer, initialValue, "prev");
}
selectLimit(range, amount) {
@ -34,7 +38,7 @@ export default class QueryTarget {
const results = [];
await iterateCursor(cursor, (value) => {
results.push(value);
return false;
return {done: false};
});
return results;
}
@ -55,12 +59,48 @@ export default class QueryTarget {
return this._find(range, predicate, "prev");
}
/**
* Checks if a given set of keys exist.
* Calls `callback(key, found)` for each key in `keys`, in key sorting order (or reversed if backwards=true).
* If the callback returns true, the search is halted and callback won't be called again.
* `callback` is called with the same instances of the key as given in `keys`, so direct comparison can be used.
*/
async findExistingKeys(keys, backwards, callback) {
const direction = backwards ? "prev" : "next";
const compareKeys = (a, b) => backwards ? -indexedDB.cmp(a, b) : indexedDB.cmp(a, b);
const sortedKeys = keys.slice().sort(compareKeys);
const firstKey = backwards ? sortedKeys[sortedKeys.length - 1] : sortedKeys[0];
const lastKey = backwards ? sortedKeys[0] : sortedKeys[sortedKeys.length - 1];
const cursor = this._target.openKeyCursor(IDBKeyRange.bound(firstKey, lastKey), direction);
let i = 0;
let consumerDone = false;
await iterateCursor(cursor, (value, key) => {
// while key is larger than next key, advance and report false
while(i < sortedKeys.length && compareKeys(sortedKeys[i], key) < 0 && !consumerDone) {
consumerDone = callback(sortedKeys[i], false);
++i;
}
if (i < sortedKeys.length && compareKeys(sortedKeys[i], key) === 0 && !consumerDone) {
consumerDone = callback(sortedKeys[i], true);
++i;
}
const done = consumerDone || i >= sortedKeys.length;
const jumpTo = !done && sortedKeys[i];
return {done, jumpTo};
});
// report null for keys we didn't to at the end
while (!consumerDone && i < sortedKeys.length) {
consumerDone = callback(sortedKeys[i], false);
++i;
}
}
_reduce(range, reducer, initialValue, direction) {
let reducedValue = initialValue;
const cursor = this._target.openCursor(range, direction);
return iterateCursor(cursor, (value) => {
reducedValue = reducer(reducedValue, value);
return true;
return {done: false};
});
}
@ -75,7 +115,7 @@ export default class QueryTarget {
const results = [];
await iterateCursor(cursor, (value) => {
results.push(value);
return predicate(results);
return {done: predicate(results)};
});
return results;
}
@ -88,10 +128,10 @@ export default class QueryTarget {
if (found) {
result = value;
}
return found;
return {done: found};
});
if (found) {
return result;
}
}
}
}

View file

@ -1,6 +1,5 @@
import Transaction from "./transaction.js";
export const STORE_NAMES = ["session", "roomState", "roomSummary", "roomTimeline"];
import { STORE_NAMES } from "../common.js";
export default class Storage {
constructor(idbDatabase) {
@ -30,4 +29,4 @@ export default class Storage {
const txn = this._db.transaction(storeNames, "readwrite");
return new Transaction(txn, storeNames);
}
}
}

View file

@ -1,64 +0,0 @@
import SortKey from "../../sortkey.js";
export default class RoomTimelineStore {
constructor(timelineStore) {
this._timelineStore = timelineStore;
}
async lastEvents(roomId, amount) {
return this.eventsBefore(roomId, SortKey.maxKey, amount);
}
async firstEvents(roomId, amount) {
return this.eventsAfter(roomId, SortKey.minKey, amount);
}
eventsAfter(roomId, sortKey, amount) {
const range = IDBKeyRange.bound([roomId, sortKey.buffer], [roomId, SortKey.maxKey.buffer], true, false);
return this._timelineStore.selectLimit(range, amount);
}
async eventsBefore(roomId, sortKey, amount) {
const range = IDBKeyRange.bound([roomId, SortKey.minKey.buffer], [roomId, sortKey.buffer], false, true);
const events = await this._timelineStore.selectLimitReverse(range, amount);
events.reverse(); // because we fetched them backwards
return events;
}
// entry should have roomId, sortKey, event & gap keys
append(entry) {
this._timelineStore.add(entry);
}
// should this happen as part of a transaction that stores all synced in changes?
// e.g.:
// - timeline events for all rooms
// - latest sync token
// - new members
// - new room state
// - updated/new account data
appendGap(roomId, sortKey, gap) {
this._timelineStore.add({
roomId: roomId,
sortKey: sortKey.buffer,
event: null,
gap: gap,
});
}
appendEvent(roomId, sortKey, event) {
console.info(`appending event for room ${roomId} with key ${sortKey}`);
this._timelineStore.add({
roomId: roomId,
sortKey: sortKey.buffer,
event: event,
gap: null,
});
}
// could be gap or event
async removeEntry(roomId, sortKey) {
this._timelineStore.delete([roomId, sortKey.buffer]);
}
}

View file

@ -0,0 +1,228 @@
import EventKey from "../../../room/timeline/EventKey.js";
class Range {
constructor(only, lower, upper, lowerOpen, upperOpen) {
this._only = only;
this._lower = lower;
this._upper = upper;
this._lowerOpen = lowerOpen;
this._upperOpen = upperOpen;
}
asIDBKeyRange(roomId) {
// only
if (this._only) {
return IDBKeyRange.only([roomId, this._only.fragmentId, this._only.eventIndex]);
}
// lowerBound
// also bound as we don't want to move into another roomId
if (this._lower && !this._upper) {
return IDBKeyRange.bound(
[roomId, this._lower.fragmentId, this._lower.eventIndex],
[roomId, this._lower.fragmentId, EventKey.maxKey.eventIndex],
this._lowerOpen,
false
);
}
// upperBound
// also bound as we don't want to move into another roomId
if (!this._lower && this._upper) {
return IDBKeyRange.bound(
[roomId, this._upper.fragmentId, EventKey.minKey.eventIndex],
[roomId, this._upper.fragmentId, this._upper.eventIndex],
false,
this._upperOpen
);
}
// bound
if (this._lower && this._upper) {
return IDBKeyRange.bound(
[roomId, this._lower.fragmentId, this._lower.eventIndex],
[roomId, this._upper.fragmentId, this._upper.eventIndex],
this._lowerOpen,
this._upperOpen
);
}
}
}
/*
* @typedef {Object} Gap
* @property {?string} prev_batch the pagination token for this backwards facing gap
* @property {?string} next_batch the pagination token for this forwards facing gap
*
* @typedef {Object} Event
* @property {string} event_id the id of the event
* @property {string} type the
* @property {?string} state_key the state key of this state event
*
* @typedef {Object} Entry
* @property {string} roomId
* @property {EventKey} eventKey
* @property {?Event} event if an event entry, the event
* @property {?Gap} gap if a gap entry, the gap
*/
export default class TimelineEventStore {
constructor(timelineStore) {
this._timelineStore = timelineStore;
}
/** Creates a range that only includes the given key
* @param {EventKey} eventKey the key
* @return {Range} the created range
*/
onlyRange(eventKey) {
return new Range(eventKey);
}
/** Creates a range that includes all keys before eventKey, and optionally also the key itself.
* @param {EventKey} eventKey the key
* @param {boolean} [open=false] whether the key is included (false) or excluded (true) from the range at the upper end.
* @return {Range} the created range
*/
upperBoundRange(eventKey, open=false) {
return new Range(undefined, undefined, eventKey, undefined, open);
}
/** Creates a range that includes all keys after eventKey, and optionally also the key itself.
* @param {EventKey} eventKey the key
* @param {boolean} [open=false] whether the key is included (false) or excluded (true) from the range at the lower end.
* @return {Range} the created range
*/
lowerBoundRange(eventKey, open=false) {
return new Range(undefined, eventKey, undefined, open);
}
/** Creates a range that includes all keys between `lower` and `upper`, and optionally the given keys as well.
* @param {EventKey} lower the lower key
* @param {EventKey} upper the upper key
* @param {boolean} [lowerOpen=false] whether the lower key is included (false) or excluded (true) from the range.
* @param {boolean} [upperOpen=false] whether the upper key is included (false) or excluded (true) from the range.
* @return {Range} the created range
*/
boundRange(lower, upper, lowerOpen=false, upperOpen=false) {
return new Range(undefined, lower, upper, lowerOpen, upperOpen);
}
/** Looks up the last `amount` entries in the timeline for `roomId`.
* @param {string} roomId
* @param {number} fragmentId
* @param {number} amount
* @return {Promise<Entry[]>} a promise resolving to an array with 0 or more entries, in ascending order.
*/
async lastEvents(roomId, fragmentId, amount) {
const eventKey = EventKey.maxKey;
eventKey.fragmentId = fragmentId;
return this.eventsBefore(roomId, eventKey, amount);
}
/** Looks up the first `amount` entries in the timeline for `roomId`.
* @param {string} roomId
* @param {number} fragmentId
* @param {number} amount
* @return {Promise<Entry[]>} a promise resolving to an array with 0 or more entries, in ascending order.
*/
async firstEvents(roomId, fragmentId, amount) {
const eventKey = EventKey.minKey;
eventKey.fragmentId = fragmentId;
return this.eventsAfter(roomId, eventKey, amount);
}
/** Looks up `amount` entries after `eventKey` in the timeline for `roomId` within the same fragment.
* The entry for `eventKey` is not included.
* @param {string} roomId
* @param {EventKey} eventKey
* @param {number} amount
* @return {Promise<Entry[]>} a promise resolving to an array with 0 or more entries, in ascending order.
*/
eventsAfter(roomId, eventKey, amount) {
const idbRange = this.lowerBoundRange(eventKey, true).asIDBKeyRange(roomId);
return this._timelineStore.selectLimit(idbRange, amount);
}
/** Looks up `amount` entries before `eventKey` in the timeline for `roomId` within the same fragment.
* The entry for `eventKey` is not included.
* @param {string} roomId
* @param {EventKey} eventKey
* @param {number} amount
* @return {Promise<Entry[]>} a promise resolving to an array with 0 or more entries, in ascending order.
*/
async eventsBefore(roomId, eventKey, amount) {
const range = this.upperBoundRange(eventKey, true).asIDBKeyRange(roomId);
const events = await this._timelineStore.selectLimitReverse(range, amount);
events.reverse(); // because we fetched them backwards
return events;
}
/** Finds the first eventId that occurs in the store, if any.
* For optimal performance, `eventIds` should be in chronological order.
*
* The order in which results are returned might be different than `eventIds`.
* Call the return value to obtain the next {id, event} pair.
* @param {string} roomId
* @param {string[]} eventIds
* @return {Function<Promise>}
*/
// performance comment from above refers to the fact that there *might*
// be a correlation between event_id sorting order and chronology.
// In that case we could avoid running over all eventIds, as the reported order by findExistingKeys
// would match the order of eventIds. That's why findLast is also passed as backwards to keysExist.
// also passing them in chronological order makes sense as that's how we'll receive them almost always.
async findFirstOccurringEventId(roomId, eventIds) {
const byEventId = this._timelineStore.index("byEventId");
const keys = eventIds.map(eventId => [roomId, eventId]);
const results = new Array(keys.length);
let firstFoundKey;
// find first result that is found and has no undefined results before it
function firstFoundAndPrecedingResolved() {
for(let i = 0; i < results.length; ++i) {
if (results[i] === undefined) {
return;
} else if(results[i] === true) {
return keys[i];
}
}
}
await byEventId.findExistingKeys(keys, false, (key, found) => {
const index = keys.indexOf(key);
results[index] = found;
firstFoundKey = firstFoundAndPrecedingResolved();
return !!firstFoundKey;
});
// key of index is [roomId, eventId], so pick out eventId
return firstFoundKey && firstFoundKey[1];
}
/** Inserts a new entry into the store. The combination of roomId and eventKey should not exist yet, or an error is thrown.
* @param {Entry} entry the entry to insert
* @return {Promise<>} a promise resolving to undefined if the operation was successful, or a StorageError if not.
* @throws {StorageError} ...
*/
insert(entry) {
// TODO: map error? or in idb/store?
return this._timelineStore.add(entry);
}
/** Updates the entry into the store with the given [roomId, eventKey] combination.
* If not yet present, will insert. Might be slower than add.
* @param {Entry} entry the entry to update.
* @return {Promise<>} a promise resolving to undefined if the operation was successful, or a StorageError if not.
*/
update(entry) {
return this._timelineStore.put(entry);
}
get(roomId, eventKey) {
return this._timelineStore.get([roomId, eventKey.fragmentId, eventKey.eventIndex]);
}
// returns the entries as well!! (or not always needed? I guess not always needed, so extra method)
removeRange(roomId, range) {
// TODO: read the entries!
return this._timelineStore.delete(range.asIDBKeyRange(roomId));
}
getByEventId(roomId, eventId) {
return this._timelineStore.index("byEventId").get([roomId, eventId]);
}
}

View file

@ -0,0 +1,46 @@
export default class RoomFragmentStore {
constructor(store) {
this._store = store;
}
_allRange(roomId) {
return IDBKeyRange.bound(
[roomId, Number.MIN_SAFE_INTEGER],
[roomId, Number.MAX_SAFE_INTEGER]
);
}
all(roomId) {
return this._store.selectAll(this._allRange(roomId));
}
/** Returns the fragment without a nextToken and without nextId,
if any, with the largest id if there are multiple (which should not happen) */
liveFragment(roomId) {
// why do we need this?
// Ok, take the case where you've got a /context fragment and a /sync fragment
// They are not connected. So, upon loading the persister, which one do we take? We can't sort them ...
// we assume that the one without a nextToken and without a nextId is a live one
// there should really be only one like this
// reverse because assuming live fragment has bigger id than non-live ones
return this._store.findReverse(this._allRange(roomId), fragment => {
return typeof fragment.nextId !== "number" && typeof fragment.nextToken !== "string";
});
}
// should generate an id an return it?
// depends if we want to do anything smart with fragment ids,
// like give them meaning depending on range. not for now probably ...
add(fragment) {
return this._store.add(fragment);
}
update(fragment) {
return this._store.put(fragment);
}
get(roomId, fragmentId) {
return this._store.get([roomId, fragmentId]);
}
}

View file

@ -2,8 +2,9 @@ import {txnAsPromise} from "./utils.js";
import Store from "./store.js";
import SessionStore from "./stores/SessionStore.js";
import RoomSummaryStore from "./stores/RoomSummaryStore.js";
import RoomTimelineStore from "./stores/RoomTimelineStore.js";
import TimelineEventStore from "./stores/TimelineEventStore.js";
import RoomStateStore from "./stores/RoomStateStore.js";
import TimelineFragmentStore from "./stores/TimelineFragmentStore.js";
export default class Transaction {
constructor(txn, allowedStoreNames) {
@ -41,8 +42,12 @@ export default class Transaction {
return this._store("roomSummary", idbStore => new RoomSummaryStore(idbStore));
}
get roomTimeline() {
return this._store("roomTimeline", idbStore => new RoomTimelineStore(idbStore));
get timelineFragments() {
return this._store("timelineFragments", idbStore => new TimelineFragmentStore(idbStore));
}
get timelineEvents() {
return this._store("timelineEvents", idbStore => new TimelineEventStore(idbStore));
}
get roomState() {
@ -56,4 +61,4 @@ export default class Transaction {
abort() {
this._txn.abort();
}
}
}

View file

@ -35,11 +35,11 @@ export function iterateCursor(cursor, processValue) {
resolve(false);
return; // end of results
}
const isDone = processValue(cursor.value);
if (isDone) {
const {done, jumpTo} = processValue(cursor.value, cursor.key);
if (done) {
resolve(true);
} else {
cursor.continue();
cursor.continue(jumpTo);
}
};
});
@ -49,7 +49,7 @@ export async function fetchResults(cursor, isDone) {
const results = [];
await iterateCursor(cursor, (value) => {
results.push(value);
return isDone(results);
return {done: isDone(results)};
});
return results;
}
@ -100,4 +100,4 @@ export async function findStoreValue(db, storeName, toCursor, matchesValue) {
throw new Error("Value not found");
}
return match;
}
}

View file

@ -0,0 +1,37 @@
import Transaction from "./transaction.js";
import { STORE_MAP, STORE_NAMES } from "../common.js";
export default class Storage {
constructor(initialStoreValues = {}) {
this._validateStoreNames(Object.keys(initialStoreValues));
this.storeNames = STORE_MAP;
this._storeValues = STORE_NAMES.reduce((values, name) => {
values[name] = initialStoreValues[name] || null;
}, {});
}
_validateStoreNames(storeNames) {
const idx = storeNames.findIndex(name => !STORE_MAP.hasOwnProperty(name));
if (idx !== -1) {
throw new Error(`Invalid store name ${storeNames[idx]}`);
}
}
_createTxn(storeNames, writable) {
this._validateStoreNames(storeNames);
const storeValues = storeNames.reduce((values, name) => {
return values[name] = this._storeValues[name];
}, {});
return Promise.resolve(new Transaction(storeValues, writable));
}
readTxn(storeNames) {
// TODO: avoid concurrency
return this._createTxn(storeNames, false);
}
readWriteTxn(storeNames) {
// TODO: avoid concurrency
return this._createTxn(storeNames, true);
}
}

View file

@ -0,0 +1,57 @@
import RoomTimelineStore from "./stores/RoomTimelineStore.js";
export default class Transaction {
constructor(storeValues, writable) {
this._storeValues = storeValues;
this._txnStoreValues = {};
this._writable = writable;
}
_store(name, mapper) {
if (!this._txnStoreValues.hasOwnProperty(name)) {
if (!this._storeValues.hasOwnProperty(name)) {
throw new Error(`Transaction wasn't opened for store ${name}`);
}
const store = mapper(this._storeValues[name]);
const clone = store.cloneStoreValue();
// extra prevention for writing
if (!this._writable) {
Object.freeze(clone);
}
this._txnStoreValues[name] = clone;
}
return mapper(this._txnStoreValues[name]);
}
get session() {
throw new Error("not yet implemented");
// return this._store("session", storeValue => new SessionStore(storeValue));
}
get roomSummary() {
throw new Error("not yet implemented");
// return this._store("roomSummary", storeValue => new RoomSummaryStore(storeValue));
}
get roomTimeline() {
return this._store("roomTimeline", storeValue => new RoomTimelineStore(storeValue));
}
get roomState() {
throw new Error("not yet implemented");
// return this._store("roomState", storeValue => new RoomStateStore(storeValue));
}
complete() {
for(let name of Object.keys(this._txnStoreValues)) {
this._storeValues[name] = this._txnStoreValues[name];
}
this._txnStoreValues = null;
return Promise.resolve();
}
abort() {
this._txnStoreValues = null;
return Promise.resolve();
}
}

View file

@ -0,0 +1,228 @@
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) {
return key.sortKey.compare(entry.sortKey);
} else {
return key.roomId < entry.roomId ? -1 : 1;
}
}
class Range {
constructor(timeline, lower, upper, lowerOpen, upperOpen) {
this._timeline = timeline;
this._lower = lower;
this._upper = upper;
this._lowerOpen = lowerOpen;
this._upperOpen = upperOpen;
}
/** projects the range onto the timeline array */
project(roomId, maxCount = Number.MAX_SAFE_INTEGER) {
// determine lowest and highest allowed index.
// Important not to bleed into other roomIds here.
const lowerKey = {roomId, sortKey: this._lower || SortKey.minKey };
// apply lower key being open (excludes given key)
let minIndex = sortedIndex(this._timeline, lowerKey, compareKeys);
if (this._lowerOpen && minIndex < this._timeline.length && compareKeys(lowerKey, this._timeline[minIndex]) === 0) {
minIndex += 1;
}
const upperKey = {roomId, sortKey: this._upper || SortKey.maxKey };
// apply upper key being open (excludes given key)
let maxIndex = sortedIndex(this._timeline, upperKey, compareKeys);
if (this._upperOpen && maxIndex < this._timeline.length && compareKeys(upperKey, this._timeline[maxIndex]) === 0) {
maxIndex -= 1;
}
// find out from which edge we should grow
// if upper or lower bound
// again, important not to go below minIndex or above maxIndex
// to avoid bleeding into other rooms
let startIndex, endIndex;
if (!this._lower && this._upper) {
startIndex = Math.max(minIndex, maxIndex - maxCount);
endIndex = maxIndex;
} else if (this._lower && !this._upper) {
startIndex = minIndex;
endIndex = Math.min(maxIndex, minIndex + maxCount);
} else {
startIndex = minIndex;
endIndex = maxIndex;
}
// if startIndex is out of range, make range empty
if (startIndex === this._timeline.length) {
startIndex = endIndex = 0;
}
const count = endIndex - startIndex;
return {startIndex, count};
}
select(roomId, maxCount) {
const {startIndex, count} = this.project(roomId, this._timeline, maxCount);
return this._timeline.slice(startIndex, startIndex + count);
}
}
export default class RoomTimelineStore extends Store {
constructor(timeline, writable) {
super(timeline || [], writable);
}
get _timeline() {
return this._storeValue;
}
/** Creates a range that only includes the given key
* @param {SortKey} sortKey the key
* @return {Range} the created range
*/
onlyRange(sortKey) {
return new Range(this._timeline, sortKey, sortKey);
}
/** Creates a range that includes all keys before sortKey, and optionally also the key itself.
* @param {SortKey} sortKey the key
* @param {boolean} [open=false] whether the key is included (false) or excluded (true) from the range at the upper end.
* @return {Range} the created range
*/
upperBoundRange(sortKey, open=false) {
return new Range(this._timeline, undefined, sortKey, undefined, open);
}
/** Creates a range that includes all keys after sortKey, and optionally also the key itself.
* @param {SortKey} sortKey the key
* @param {boolean} [open=false] whether the key is included (false) or excluded (true) from the range at the lower end.
* @return {Range} the created range
*/
lowerBoundRange(sortKey, open=false) {
return new Range(this._timeline, sortKey, undefined, open);
}
/** Creates a range that includes all keys between `lower` and `upper`, and optionally the given keys as well.
* @param {SortKey} lower the lower key
* @param {SortKey} upper the upper key
* @param {boolean} [lowerOpen=false] whether the lower key is included (false) or excluded (true) from the range.
* @param {boolean} [upperOpen=false] whether the upper key is included (false) or excluded (true) from the range.
* @return {Range} the created range
*/
boundRange(lower, upper, lowerOpen=false, upperOpen=false) {
return new Range(this._timeline, lower, upper, lowerOpen, upperOpen);
}
/** Looks up the last `amount` entries in the timeline for `roomId`.
* @param {string} roomId
* @param {number} amount
* @return {Promise<Entry[]>} a promise resolving to an array with 0 or more entries, in ascending order.
*/
lastEvents(roomId, amount) {
return this.eventsBefore(roomId, SortKey.maxKey, amount);
}
/** Looks up the first `amount` entries in the timeline for `roomId`.
* @param {string} roomId
* @param {number} amount
* @return {Promise<Entry[]>} a promise resolving to an array with 0 or more entries, in ascending order.
*/
firstEvents(roomId, amount) {
return this.eventsAfter(roomId, SortKey.minKey, amount);
}
/** Looks up `amount` entries after `sortKey` in the timeline for `roomId`.
* The entry for `sortKey` is not included.
* @param {string} roomId
* @param {SortKey} sortKey
* @param {number} amount
* @return {Promise<Entry[]>} a promise resolving to an array with 0 or more entries, in ascending order.
*/
eventsAfter(roomId, sortKey, amount) {
const events = this.lowerBoundRange(sortKey, true).select(roomId, amount);
return Promise.resolve(events);
}
/** Looks up `amount` entries before `sortKey` in the timeline for `roomId`.
* The entry for `sortKey` is not included.
* @param {string} roomId
* @param {SortKey} sortKey
* @param {number} amount
* @return {Promise<Entry[]>} a promise resolving to an array with 0 or more entries, in ascending order.
*/
eventsBefore(roomId, sortKey, amount) {
const events = this.upperBoundRange(sortKey, true).select(roomId, amount);
return Promise.resolve(events);
}
/** Looks up the first, if any, event entry (so excluding gap entries) after `sortKey`.
* @param {string} roomId
* @param {SortKey} sortKey
* @return {Promise<(?Entry)>} a promise resolving to entry, if any.
*/
nextEvent(roomId, sortKey) {
const searchSpace = this.lowerBoundRange(sortKey, true).select(roomId);
const event = searchSpace.find(entry => !!entry.event);
return Promise.resolve(event);
}
/** Looks up the first, if any, event entry (so excluding gap entries) before `sortKey`.
* @param {string} roomId
* @param {SortKey} sortKey
* @return {Promise<(?Entry)>} a promise resolving to entry, if any.
*/
previousEvent(roomId, sortKey) {
const searchSpace = this.upperBoundRange(sortKey, true).select(roomId);
const event = searchSpace.reverse().find(entry => !!entry.event);
return Promise.resolve(event);
}
/** Inserts a new entry into the store. The combination of roomId and sortKey should not exist yet, or an error is thrown.
* @param {Entry} entry the entry to insert
* @return {Promise<>} a promise resolving to undefined if the operation was successful, or a StorageError if not.
* @throws {StorageError} ...
*/
insert(entry) {
this.assertWritable();
const insertIndex = sortedIndex(this._timeline, entry, compareKeys);
if (insertIndex < this._timeline.length) {
const existingEntry = this._timeline[insertIndex];
if (compareKeys(entry, existingEntry) === 0) {
return Promise.reject(new Error("entry already exists"));
}
}
this._timeline.splice(insertIndex, 0, entry);
return Promise.resolve();
}
/** Updates the entry into the store with the given [roomId, sortKey] combination.
* If not yet present, will insert. Might be slower than add.
* @param {Entry} entry the entry to update.
* @return {Promise<>} a promise resolving to undefined if the operation was successful, or a StorageError if not.
*/
update(entry) {
this.assertWritable();
let update = false;
const updateIndex = sortedIndex(this._timeline, entry, compareKeys);
if (updateIndex < this._timeline.length) {
const existingEntry = this._timeline[updateIndex];
if (compareKeys(entry, existingEntry) === 0) {
update = true;
}
}
this._timeline.splice(updateIndex, update ? 1 : 0, entry);
return Promise.resolve();
}
get(roomId, sortKey) {
const range = this.onlyRange(sortKey);
const {startIndex, count} = range.project(roomId);
const event = count ? this._timeline[startIndex] : undefined;
return Promise.resolve(event);
}
removeRange(roomId, range) {
this.assertWritable();
const {startIndex, count} = range.project(roomId);
const removedEntries = this._timeline.splice(startIndex, count);
return Promise.resolve(removedEntries);
}
}

View file

@ -0,0 +1,27 @@
export default class Store {
constructor(storeValue, writable) {
this._storeValue = storeValue;
this._writable = writable;
}
// makes a copy deep enough that any modifications in the store
// won't affect the original
// used for transactions
cloneStoreValue() {
// assumes 1 level deep is enough, and that values will be replaced
// rather than updated.
if (Array.isArray(this._storeValue)) {
return this._storeValue.slice();
} else if (typeof this._storeValue === "object") {
return Object.assign({}, this._storeValue);
} else {
return this._storeValue;
}
}
assertWritable() {
if (!this._writable) {
throw new Error("Tried to write in read-only transaction");
}
}
}

View file

@ -1,181 +0,0 @@
const MIN_INT32 = -2147483648;
const MID_INT32 = 0;
const MAX_INT32 = 2147483647;
const MIN_UINT32 = 0;
const MID_UINT32 = 2147483647;
const MAX_UINT32 = 4294967295;
const MIN = MIN_UINT32;
const MID = MID_UINT32;
const MAX = MAX_UINT32;
export default class SortKey {
constructor(buffer) {
if (buffer) {
this._keys = new DataView(buffer);
} else {
this._keys = new DataView(new ArrayBuffer(8));
// start default key right at the middle gap key, min event key
// so we have the same amount of key address space either way
this.gapKey = MID;
this.eventKey = MIN;
}
}
get gapKey() {
return this._keys.getUint32(0, false);
}
set gapKey(value) {
return this._keys.setUint32(0, value, false);
}
get eventKey() {
return this._keys.getUint32(4, false);
}
set eventKey(value) {
return this._keys.setUint32(4, value, false);
}
get buffer() {
return this._keys.buffer;
}
nextKeyWithGap() {
const k = new SortKey();
k.gapKey = this.gapKey + 1;
k.eventKey = MIN;
return k;
}
nextKey() {
const k = new SortKey();
k.gapKey = this.gapKey;
k.eventKey = this.eventKey + 1;
return k;
}
previousKey() {
const k = new SortKey();
k.gapKey = this.gapKey;
k.eventKey = this.eventKey - 1;
return k;
}
clone() {
const k = new SortKey();
k.gapKey = this.gapKey;
k.eventKey = this.eventKey;
return k;
}
static get maxKey() {
const maxKey = new SortKey();
maxKey.gapKey = MAX;
maxKey.eventKey = MAX;
return maxKey;
}
static get minKey() {
const minKey = new SortKey();
minKey.gapKey = MIN;
minKey.eventKey = MIN;
return minKey;
}
compare(otherKey) {
const gapDiff = this.gapKey - otherKey.gapKey;
if (gapDiff === 0) {
return this.eventKey - otherKey.eventKey;
} else {
return gapDiff;
}
}
toString() {
return `[${this.gapKey}/${this.eventKey}]`;
}
}
//#ifdef TESTS
export function tests() {
return {
test_default_key(assert) {
const k = new SortKey();
assert.equal(k.gapKey, MID);
assert.equal(k.eventKey, MIN);
},
test_inc(assert) {
const a = new SortKey();
const b = a.nextKey();
assert.equal(a.gapKey, b.gapKey);
assert.equal(a.eventKey + 1, b.eventKey);
const c = b.previousKey();
assert.equal(b.gapKey, c.gapKey);
assert.equal(c.eventKey + 1, b.eventKey);
assert.equal(a.eventKey, c.eventKey);
},
test_min_key(assert) {
const minKey = SortKey.minKey;
const k = new SortKey();
assert(minKey.gapKey <= k.gapKey);
assert(minKey.eventKey <= k.eventKey);
},
test_max_key(assert) {
const maxKey = SortKey.maxKey;
const k = new SortKey();
assert(maxKey.gapKey >= k.gapKey);
assert(maxKey.eventKey >= k.eventKey);
},
test_immutable(assert) {
const a = new SortKey();
const gapKey = a.gapKey;
const eventKey = a.eventKey;
a.nextKeyWithGap();
assert.equal(a.gapKey, gapKey);
assert.equal(a.eventKey, eventKey);
},
test_cmp_gapkey_first(assert) {
const a = new SortKey();
const b = new SortKey();
a.gapKey = 2;
a.eventKey = 1;
b.gapKey = 1;
b.eventKey = 100000;
assert(a.compare(b) > 0);
},
test_cmp_eventkey_second(assert) {
const a = new SortKey();
const b = new SortKey();
a.gapKey = 1;
a.eventKey = 100000;
b.gapKey = 1;
b.eventKey = 2;
assert(a.compare(b) > 0);
},
test_cmp_max_larger_than_min(assert) {
assert(SortKey.minKey.compare(SortKey.maxKey) < 0);
},
test_cmp_gapkey_first_large(assert) {
const a = new SortKey();
const b = new SortKey();
a.gapKey = MAX;
a.eventKey = MIN;
b.gapKey = MIN;
b.eventKey = MAX;
assert(b < a);
assert(a > b);
}
};
}
//#endif

View file

@ -1,7 +1,7 @@
import {
RequestAbortError,
HomeServerError,
StorageError
RequestAbortError,
HomeServerError,
StorageError
} from "./error.js";
import EventEmitter from "../EventEmitter.js";
@ -9,119 +9,120 @@ const INCREMENTAL_TIMEOUT = 30000;
const SYNC_EVENT_LIMIT = 10;
function parseRooms(roomsSection, roomCallback) {
if (!roomsSection) {
return;
}
const allMemberships = ["join", "invite", "leave"];
for(const membership of allMemberships) {
const membershipSection = roomsSection[membership];
if (membershipSection) {
const rooms = Object.entries(membershipSection)
for (const [roomId, roomResponse] of rooms) {
roomCallback(roomId, roomResponse, membership);
}
}
}
if (roomsSection) {
const allMemberships = ["join", "invite", "leave"];
for(const membership of allMemberships) {
const membershipSection = roomsSection[membership];
if (membershipSection) {
return Object.entries(membershipSection).map(([roomId, roomResponse]) => {
return roomCallback(roomId, roomResponse, membership);
});
}
}
}
return [];
}
export default class Sync extends EventEmitter {
constructor(hsApi, session, storage) {
super();
this._hsApi = hsApi;
this._session = session;
this._storage = storage;
this._isSyncing = false;
this._currentRequest = null;
}
// returns when initial sync is done
async start() {
if (this._isSyncing) {
return;
}
this._isSyncing = true;
let syncToken = this._session.syncToken;
// do initial sync if needed
if (!syncToken) {
// need to create limit filter here
syncToken = await this._syncRequest();
}
this._syncLoop(syncToken);
}
constructor(hsApi, session, storage) {
super();
this._hsApi = hsApi;
this._session = session;
this._storage = storage;
this._isSyncing = false;
this._currentRequest = null;
}
// returns when initial sync is done
async start() {
if (this._isSyncing) {
return;
}
this._isSyncing = true;
let syncToken = this._session.syncToken;
// do initial sync if needed
if (!syncToken) {
// need to create limit filter here
syncToken = await this._syncRequest();
}
this._syncLoop(syncToken);
}
async _syncLoop(syncToken) {
// if syncToken is falsy, it will first do an initial sync ...
while(this._isSyncing) {
try {
console.log(`starting sync request with since ${syncToken} ...`);
syncToken = await this._syncRequest(syncToken, INCREMENTAL_TIMEOUT);
} catch (err) {
this._isSyncing = false;
if (!(err instanceof RequestAbortError)) {
console.warn("stopping sync because of error");
this.emit("error", err);
}
}
}
this.emit("stopped");
}
async _syncLoop(syncToken) {
// if syncToken is falsy, it will first do an initial sync ...
while(this._isSyncing) {
try {
console.log(`starting sync request with since ${syncToken} ...`);
syncToken = await this._syncRequest(syncToken, INCREMENTAL_TIMEOUT);
} catch (err) {
this._isSyncing = false;
if (!(err instanceof RequestAbortError)) {
console.warn("stopping sync because of error");
this.emit("error", err);
}
}
}
this.emit("stopped");
}
async _syncRequest(syncToken, timeout) {
this._currentRequest = this._hsApi.sync(syncToken, undefined, timeout);
const response = await this._currentRequest.response();
syncToken = response.next_batch;
const storeNames = this._storage.storeNames;
const syncTxn = await this._storage.readWriteTxn([
storeNames.session,
storeNames.roomSummary,
storeNames.roomTimeline,
storeNames.roomState,
]);
async _syncRequest(syncToken, timeout) {
this._currentRequest = this._hsApi.sync(syncToken, undefined, timeout);
const response = await this._currentRequest.response();
syncToken = response.next_batch;
const storeNames = this._storage.storeNames;
const syncTxn = await this._storage.readWriteTxn([
storeNames.session,
storeNames.roomSummary,
storeNames.roomState,
storeNames.timelineEvents,
storeNames.timelineFragments,
]);
const roomChanges = [];
try {
this._session.applySync(syncToken, response.account_data, syncTxn);
this._session.persistSync(syncToken, response.account_data, syncTxn);
// to_device
// presence
if (response.rooms) {
parseRooms(response.rooms, (roomId, roomResponse, membership) => {
let room = this._session.rooms.get(roomId);
if (!room) {
room = this._session.createRoom(roomId);
}
console.log(` * applying sync response to room ${roomId} ...`);
const changes = room.persistSync(roomResponse, membership, syncTxn);
if (response.rooms) {
const promises = parseRooms(response.rooms, async (roomId, roomResponse, membership) => {
let room = this._session.rooms.get(roomId);
if (!room) {
room = this._session.createRoom(roomId);
}
console.log(` * applying sync response to room ${roomId} ...`);
const changes = await room.persistSync(roomResponse, membership, syncTxn);
roomChanges.push({room, changes});
});
}
} catch(err) {
console.warn("aborting syncTxn because of error");
// avoid corrupting state by only
// storing the sync up till the point
// the exception occurred
syncTxn.abort();
throw err;
}
try {
await syncTxn.complete();
console.info("syncTxn committed!!");
} catch (err) {
throw new StorageError("unable to commit sync tranaction", err);
}
});
await Promise.all(promises);
}
} catch(err) {
console.warn("aborting syncTxn because of error");
// avoid corrupting state by only
// storing the sync up till the point
// the exception occurred
syncTxn.abort();
throw err;
}
try {
await syncTxn.complete();
console.info("syncTxn committed!!");
} catch (err) {
throw new StorageError("unable to commit sync tranaction", err);
}
// emit room related events after txn has been closed
for(let {room, changes} of roomChanges) {
room.emitSync(changes);
}
return syncToken;
}
return syncToken;
}
stop() {
if (!this._isSyncing) {
return;
}
this._isSyncing = false;
if (this._currentRequest) {
this._currentRequest.abort();
this._currentRequest = null;
}
}
stop() {
if (!this._isSyncing) {
return;
}
this._isSyncing = false;
if (this._currentRequest) {
this._currentRequest.abort();
this._currentRequest = null;
}
}
}

View file

@ -3,7 +3,8 @@ 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 ObservableArray } from "./list/ObservableArray.js";
export { default as SortedArray } from "./list/SortedArray.js";
export { default as ObservableMap } from "./map/ObservableMap.js";
// avoid circular dependency between these classes

View file

@ -34,4 +34,11 @@ export default class BaseObservableList extends BaseObservableCollection {
}
}
[Symbol.iterator]() {
throw new Error("unimplemented");
}
get length() {
throw new Error("unimplemented");
}
}

View file

@ -11,6 +11,28 @@ export default class ObservableArray extends BaseObservableList {
this.emitAdd(this._items.length - 1, item);
}
insertMany(idx, items) {
for(let item of items) {
this.insert(idx, item);
idx += 1;
}
}
insert(idx, item) {
this._items.splice(idx, 0, item);
this.emitAdd(idx, item);
}
get array() {
return this._items;
}
at(idx) {
if (this._items && idx >= 0 && idx < this._items.length) {
return this._items[idx];
}
}
get length() {
return this._items.length;
}

View file

@ -0,0 +1,50 @@
import BaseObservableList from "./BaseObservableList.js";
import sortedIndex from "../../utils/sortedIndex.js";
export default class SortedArray extends BaseObservableList {
constructor(comparator) {
super();
this._comparator = comparator;
this._items = [];
}
setManySorted(items) {
// TODO: we can make this way faster by only looking up the first and last key,
// and merging whatever is inbetween with items
// if items is not sorted, 💩🌀 will follow!
// should we check?
// Also, once bulk events are supported in collections,
// we can do a bulk add event here probably if there are no updates
// BAD CODE!
for(let item of items) {
this.set(item);
}
}
set(item, updateParams = null) {
const idx = sortedIndex(this._items, item, this._comparator);
if (idx >= this._items.length || this._comparator(this._items[idx], item) !== 0) {
this._items.splice(idx, 0, item);
this.emitAdd(idx, item)
} else {
this._items[idx] = item;
this.emitUpdate(idx, item, updateParams);
}
}
remove(item) {
throw new Error("unimplemented");
}
get array() {
return this._items;
}
get length() {
return this._items.length;
}
[Symbol.iterator]() {
return this._items.values();
}
}

View file

@ -1,5 +1,5 @@
import BaseObservableList from "./BaseObservableList.js";
import sortedIndex from "../../utils/sortedIndex.js";
/*
@ -25,35 +25,6 @@ so key -> Map<key,value> -> value -> node -> *parentNode -> rootNode
with a node containing {value, leftCount, rightCount, leftNode, rightNode, parentNode}
*/
/**
* @license
* Based off baseSortedIndex function in Lodash <https://lodash.com/>
* Copyright JS Foundation and other contributors <https://js.foundation/>
* Released under MIT license <https://lodash.com/license>
* Based on Underscore.js 1.8.3 <http://underscorejs.org/LICENSE>
* Copyright Jeremy Ashkenas, DocumentCloud and Investigative Reporters & Editors
*/
function sortedIndex(array, value, comparator) {
let low = 0;
let high = array.length;
while (low < high) {
let mid = (low + high) >>> 1;
let cmpResult = comparator(value, array[mid]);
if (cmpResult > 0) {
low = mid + 1;
} else if (cmpResult < 0) {
high = mid;
} else {
low = high = mid;
}
}
return high;
}
// does not assume whether or not the values are reference
// types modified outside of the collection (and affecting sort order) or not

View file

@ -1,5 +1,8 @@
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 {
constructor(source, mapper) {
super();

View file

@ -1,36 +0,0 @@
import EventEmitter from "../../EventEmitter.js";
export default class RoomViewModel extends EventEmitter {
constructor(room) {
super();
this._room = room;
this._timeline = null;
this._onRoomChange = this._onRoomChange.bind(this);
}
async enable() {
this._room.on("change", this._onRoomChange);
this._timeline = await this._room.openTimeline();
this.emit("change", "timelineEntries");
}
disable() {
if (this._timeline) {
this._timeline.close();
}
}
// room doesn't tell us yet which fields changed,
// so emit all fields originating from summary
_onRoomChange() {
this.emit("change", "name");
}
get name() {
return this._room.name;
}
get timelineEntries() {
return this._timeline && this._timeline.entries;
}
}

View file

@ -14,13 +14,14 @@ export default class RoomView {
mount() {
this._viewModel.on("change", this._onViewModelUpdate);
this._nameLabel = html.h2(null, this._viewModel.name);
this._timelineList = new ListView({
list: this._viewModel.timelineEntries
}, entry => new TimelineTile(entry));
this._errorLabel = html.div({className: "RoomView_error"});
this._timelineList = new ListView({}, entry => new TimelineTile(entry));
this._timelineList.mount();
this._root = html.div({className: "RoomView"}, [
this._nameLabel,
this._errorLabel,
this._timelineList.root()
]);
@ -40,8 +41,10 @@ export default class RoomView {
if (prop === "name") {
this._nameLabel.innerText = this._viewModel.name;
}
else if (prop === "timelineEntries") {
this._timelineList.update({list: this._viewModel.timelineEntries});
else if (prop === "timelineViewModel") {
this._timelineList.update({list: this._viewModel.timelineViewModel.tiles});
} else if (prop === "error") {
this._errorLabel.innerText = this._viewModel.error;
}
}

View file

@ -1,29 +1,8 @@
import * as html from "./html.js";
function tileText(event) {
const content = event.content;
switch (event.type) {
case "m.room.message": {
const msgtype = content.msgtype;
switch (msgtype) {
case "m.text":
return content.body;
default:
return `unsupported msgtype: ${msgtype}`;
}
}
case "m.room.name":
return `changed the room name to "${content.name}"`;
case "m.room.member":
return `changed membership to ${content.membership}`;
default:
return `unsupported event type: ${event.type}`;
}
}
export default class TimelineTile {
constructor(entry) {
this._entry = entry;
constructor(tileVM) {
this._tileVM = tileVM;
this._root = null;
}
@ -32,21 +11,7 @@ export default class TimelineTile {
}
mount() {
let children;
if (this._entry.gap) {
children = [
html.strong(null, "Gap"),
" with prev_batch ",
html.strong(null, this._entry.gap.prev_batch)
];
} else if (this._entry.event) {
const event = this._entry.event;
children = [
html.strong(null, event.sender),
`: ${tileText(event)}`,
];
}
this._root = html.li(null, children);
this._root = renderTile(this._tileVM);
return this._root;
}
@ -54,3 +19,23 @@ export default class TimelineTile {
update() {}
}
function renderTile(tile) {
switch (tile.shape) {
case "message":
return html.li(null, [html.strong(null, tile.internalId+" "), tile.label]);
case "gap": {
const button = html.button(null, (tile.isUp ? "🠝" : "🠟") + " fill gap");
const handler = () => {
tile.fill();
button.removeEventListener("click", handler);
};
button.addEventListener("click", handler);
return html.li(null, [html.strong(null, tile.internalId+" "), button]);
}
case "announcement":
return html.li(null, [html.strong(null, tile.internalId+" "), tile.label]);
default:
return html.li(null, [html.strong(null, tile.internalId+" "), "unknown tile shape: " + tile.shape]);
}
}

View file

@ -51,3 +51,4 @@ export function main(... params) { return el("main", ... params); }
export function article(... params) { return el("article", ... params); }
export function aside(... params) { return el("aside", ... params); }
export function pre(... params) { return el("pre", ... params); }
export function button(... params) { return el("button", ... params); }

26
src/utils/sortedIndex.js Normal file
View file

@ -0,0 +1,26 @@
/**
* @license
* Based off baseSortedIndex function in Lodash <https://lodash.com/>
* Copyright JS Foundation and other contributors <https://js.foundation/>
* Released under MIT license <https://lodash.com/license>
* Based on Underscore.js 1.8.3 <http://underscorejs.org/LICENSE>
* Copyright Jeremy Ashkenas, DocumentCloud and Investigative Reporters & Editors
*/
export default function sortedIndex(array, value, comparator) {
let low = 0;
let high = array.length;
while (low < high) {
let mid = (low + high) >>> 1;
let cmpResult = comparator(value, array[mid]);
if (cmpResult > 0) {
low = mid + 1;
} else if (cmpResult < 0) {
high = mid;
} else {
low = high = mid;
}
}
return high;
}