diff --git a/doc/TODO.md b/doc/TODO.md
index 7dec2265..33ccd93e 100644
--- a/doc/TODO.md
+++ b/doc/TODO.md
@@ -21,6 +21,8 @@
- build a very basic interface with
- a start/stop sync button
- a room list sorted alphabetically
+ - do some preprocessing on sync response which can then be used by persister, summary, timeline
+ - support timeline
- clicking on a room list, you see messages (userId -> body)
- send messages
- fill gaps with call to /messages
@@ -28,3 +30,4 @@
- lazy loading members
- decide denormalized data in summary vs reading from multiple stores PER room on load
- allow Room/Summary class to be subclassed and store additional data?
+ - store account data, support read markers
diff --git a/src/event-emitter.js b/src/EventEmitter.js
similarity index 100%
rename from src/event-emitter.js
rename to src/EventEmitter.js
diff --git a/src/matrix/room/room.js b/src/matrix/room/room.js
index 1fadccaf..5d4fcd98 100644
--- a/src/matrix/room/room.js
+++ b/src/matrix/room/room.js
@@ -1,21 +1,28 @@
import RoomSummary from "./summary.js";
import RoomPersister from "./persister.js";
+import EventEmitter from "../../EventEmitter.js";
-export default class Room {
- constructor(roomId, storage) {
+export default class Room extends EventEmitter {
+ constructor(roomId, storage, emitCollectionChange) {
+ super();
this._roomId = roomId;
this._storage = storage;
this._summary = new RoomSummary(roomId);
this._persister = new RoomPersister(roomId);
+ this._emitCollectionChange = emitCollectionChange;
}
async applySync(roomResponse, membership, txn) {
- this._summary.applySync(roomResponse, membership, txn);
+ const changed = this._summary.applySync(roomResponse, membership, txn);
this._persister.persistSync(roomResponse, txn);
+ if (changed) {
+ this.emit("change");
+ (this._emitCollectionChange)();
+ }
}
load(summary, txn) {
this._summary.load(summary);
return this._persister.load(txn);
}
-}
\ No newline at end of file
+}
diff --git a/src/matrix/session.js b/src/matrix/session.js
index 49b6b7cf..49019ed3 100644
--- a/src/matrix/session.js
+++ b/src/matrix/session.js
@@ -1,11 +1,12 @@
import Room from "./room/room.js";
+import ObservableMap from "../observable/map.js";
export default class Session {
constructor(storage) {
this._storage = storage;
this._session = null;
// use Map here?
- this._rooms = {};
+ this._rooms = new ObservableMap();
}
// should be called before load
// loginData has device_id, user_id, home_server, access_token
@@ -37,13 +38,18 @@ export default class Session {
}));
}
+ get rooms() {
+ return this._rooms;
+ }
+
getRoom(roomId) {
- return this._rooms[roomId];
+ return this._rooms.get(roomId);
}
createRoom(roomId) {
- const room = new Room(roomId, this._storage);
- this._rooms[roomId] = room;
+ const updateCallback = (params) => this._rooms.update(roomId, params);
+ const room = new Room(roomId, this._storage, updateCallback);
+ this._rooms.add(roomId, room);
return room;
}
@@ -61,4 +67,4 @@ export default class Session {
get accessToken() {
return this._session.loginData.access_token;
}
-}
\ No newline at end of file
+}
diff --git a/src/matrix/sync.js b/src/matrix/sync.js
index 61659820..542b64b4 100644
--- a/src/matrix/sync.js
+++ b/src/matrix/sync.js
@@ -3,7 +3,7 @@ import {
HomeServerError,
StorageError
} from "./error.js";
-import EventEmitter from "../event-emitter.js";
+import EventEmitter from "../EventEmitter.js";
const INCREMENTAL_TIMEOUT = 30000;
const SYNC_EVENT_LIMIT = 10;
@@ -117,4 +117,4 @@ export default class Sync extends EventEmitter {
this._currentRequest = null;
}
}
-}
\ No newline at end of file
+}
diff --git a/src/observable/map.js b/src/observable/map.js
new file mode 100644
index 00000000..e2044410
--- /dev/null
+++ b/src/observable/map.js
@@ -0,0 +1,55 @@
+import BaseObservableMap from "./map/BaseObservableMap.js";
+
+export default class ObservableMap extends BaseObservableMap {
+ constructor(initialValues) {
+ super();
+ this._values = new Map(initialValues);
+ }
+
+ update(key, params) {
+ const value = this._values.get(key);
+ if (value !== undefined) {
+ this._values.add(key, value);
+ this.emitChange(key, value, params);
+ return true;
+ }
+ return false; // or return existing value?
+ }
+
+ add(key, value) {
+ if (!this._values.has(key)) {
+ this._values.add(key, value);
+ this.emitAdd(key, value);
+ return true;
+ }
+ return false; // or return existing value?
+ }
+
+ remove(key) {
+ const value = this._values.get(key);
+ if (value !== undefined) {
+ this._values.delete(key);
+ this.emitRemove(key, value);
+ return true;
+ } else {
+ return false;
+ }
+ }
+
+ reset() {
+ this._values.clear();
+ this.emitReset();
+ }
+
+ get(key) {
+ return this._values.get(key);
+ }
+
+ get size() {
+ return this._values.size;
+ }
+
+ [Symbol.iterator]() {
+ return this._values.entries()[Symbol.iterator];
+ }
+}
diff --git a/src/observable/map/BaseObservableMap.js b/src/observable/map/BaseObservableMap.js
new file mode 100644
index 00000000..dc7c7199
--- /dev/null
+++ b/src/observable/map/BaseObservableMap.js
@@ -0,0 +1,66 @@
+import MapOperator from "./operators/MapOperator.js";
+import SortOperator from "./operators/SortOperator.js";
+
+export default class BaseObservableMap {
+ constructor() {
+ this._handlers = new Set();
+ }
+
+ emitReset() {
+ for(let h of this._handlers) {
+ h.onReset();
+ }
+ }
+ // we need batch events, mostly on index based collection though?
+ // maybe we should get started without?
+ emitAdd(key, value) {
+ for(let h of this._handlers) {
+ h.onAdd(key, value);
+ }
+ }
+
+ emitChange(key, value, ...params) {
+ for(let h of this._handlers) {
+ h.onChange(key, value, ...params);
+ }
+ }
+
+ emitRemove(key, value) {
+ for(let h of this._handlers) {
+ h.onRemove(key, value);
+ }
+ }
+
+ onSubscribeFirst() {
+
+ }
+
+ onUnsubscribeLast() {
+
+ }
+
+ subscribe(handler) {
+ this._handlers.add(handler);
+ if (this._handlers.length === 1) {
+ this.onSubscribeFirst();
+ }
+ return () => {
+ if (handler) {
+ this._handlers.delete(this._handler);
+ if (this._handlers.length === 0) {
+ this.onUnsubscribeLast();
+ }
+ handler = null;
+ }
+ return null;
+ };
+ }
+
+ map(mapper, updater) {
+ return new MapOperator(this, mapper, updater);
+ }
+
+ sort(comparator) {
+ return new SortOperator(this, comparator);
+ }
+}
diff --git a/src/observable/map/Operator.js b/src/observable/map/Operator.js
new file mode 100644
index 00000000..e17f63d8
--- /dev/null
+++ b/src/observable/map/Operator.js
@@ -0,0 +1,22 @@
+// import BaseObservableMap from "./BaseObservableMap.js";
+
+export default class Operator /* extends BaseObservableMap */ {
+ constructor(source) {
+ // super();
+ this._source = source;
+ }
+
+ onSubscribeFirst() {
+ this._sourceSubscription = this._source.subscribe(this);
+ }
+
+ onUnsubscribeLast() {
+ this._sourceSubscription();
+ this._sourceSubscription = null;
+ }
+
+ onRemove(key, value) {}
+ onAdd(key, value) {}
+ onChange(key, value, params) {}
+ onReset() {}
+}
diff --git a/src/observable/map/operators/FilterOperator.js b/src/observable/map/operators/FilterOperator.js
new file mode 100644
index 00000000..84b3336c
--- /dev/null
+++ b/src/observable/map/operators/FilterOperator.js
@@ -0,0 +1,55 @@
+import Operator from "../Operator.js";
+
+export default class FilterOperator extends Operator {
+ constructor(source, mapper, updater) {
+ super(source);
+ this._mapper = mapper;
+ this._updater = updater;
+ this._mappedValues = new Map();
+ }
+
+ onAdd(key, value) {
+ const mappedValue = this._mapper(value);
+ this._mappedValues.set(key, mappedValue);
+ this.emitAdd(key, mappedValue);
+ }
+
+ onRemove(key, _value) {
+ const mappedValue = this._mappedValues.get(key);
+ if (this._mappedValues.delete(key)) {
+ this.emitRemove(key, mappedValue);
+ }
+ }
+
+ onChange(key, value, params) {
+ const mappedValue = this._mappedValues.get(key);
+ if (mappedValue !== undefined) {
+ const newParams = this._updater(value, params);
+ if (newParams !== undefined) {
+ this.emitChange(key, mappedValue, newParams);
+ }
+ }
+ }
+
+ onSubscribeFirst() {
+ for (let [key, value] of this._source) {
+ const mappedValue = this._mapper(value);
+ this._mappedValues.set(key, mappedValue);
+ }
+ super.onSubscribeFirst();
+ }
+
+ onUnsubscribeLast() {
+ super.onUnsubscribeLast();
+ this._mappedValues.clear();
+ }
+
+ onReset() {
+ this._mappedValues.clear();
+ this.emitReset();
+ }
+
+ [Symbol.iterator]() {
+ return this._mappedValues.entries()[Symbol.iterator];
+ }
+}
diff --git a/src/observable/map/operators/MapOperator.js b/src/observable/map/operators/MapOperator.js
new file mode 100644
index 00000000..ee4118e4
--- /dev/null
+++ b/src/observable/map/operators/MapOperator.js
@@ -0,0 +1,55 @@
+import Operator from "../Operator.js";
+
+export default class MapOperator extends Operator {
+ constructor(source, mapper, updater) {
+ super(source);
+ this._mapper = mapper;
+ this._updater = updater;
+ this._mappedValues = new Map();
+ }
+
+ onAdd(key, value) {
+ const mappedValue = this._mapper(value);
+ this._mappedValues.set(key, mappedValue);
+ this.emitAdd(key, mappedValue);
+ }
+
+ onRemove(key, _value) {
+ const mappedValue = this._mappedValues.get(key);
+ if (this._mappedValues.delete(key)) {
+ this.emitRemove(key, mappedValue);
+ }
+ }
+
+ onChange(key, value, params) {
+ const mappedValue = this._mappedValues.get(key);
+ if (mappedValue !== undefined) {
+ const newParams = this._updater(value, params);
+ if (newParams !== undefined) {
+ this.emitChange(key, mappedValue, newParams);
+ }
+ }
+ }
+
+ onSubscribeFirst() {
+ for (let [key, value] of this._source) {
+ const mappedValue = this._mapper(value);
+ this._mappedValues.set(key, mappedValue);
+ }
+ super.onSubscribeFirst();
+ }
+
+ onUnsubscribeLast() {
+ super.onUnsubscribeLast();
+ this._mappedValues.clear();
+ }
+
+ onReset() {
+ this._mappedValues.clear();
+ this.emitReset();
+ }
+
+ [Symbol.iterator]() {
+ return this._mappedValues.entries()[Symbol.iterator];
+ }
+}
diff --git a/src/observable/map/operators/SortOperator.js b/src/observable/map/operators/SortOperator.js
new file mode 100644
index 00000000..aacc29e2
--- /dev/null
+++ b/src/observable/map/operators/SortOperator.js
@@ -0,0 +1,119 @@
+import Operator from "../Operator.js";
+
+/**
+ * @license
+ * Based off baseSortedIndex function in Lodash
+ * Copyright JS Foundation and other contributors
+ * Released under MIT license
+ * Based on Underscore.js 1.8.3
+ * 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;
+}
+
+// TODO: this should not inherit from an BaseObservableMap, as it's a list
+export default class SortOperator extends Operator {
+ constructor(sourceMap, comparator) {
+ super(sourceMap);
+ this._comparator = comparator;
+ this._sortedValues = [];
+ this._keyIndex = new Map();
+ }
+
+ onAdd(key, value) {
+ const idx = sortedIndex(this._sortedValues, value, this._comparator);
+ this._sortedValues.splice(idx, 0, value);
+ this._keyIndex.set(key, idx);
+ this.emitAdd(idx, value);
+ }
+
+ onRemove(key, _value) {
+ const idx = sortedIndex(this._sortedValues, value, this._comparator);
+ this._sortedValues.splice(idx, 0, value);
+ this._keyIndex.set(key, idx);
+ this.emitAdd(idx, value);
+ }
+
+ onChange(key, value, params) {
+ // index could have moved if other items got added in the meantime
+ const oldIdx = this._keyIndex.get(key);
+ this._sortedValues.splice(oldIdx, 1);
+ const idx = sortedIndex(this._sortedValues, value, this._comparator);
+
+ }
+
+ onSubscribeFirst() {
+ this._sortedValues = new Array(this._source.size);
+ let i = 0;
+ for (let [key, value] of this._source) {
+ this._sortedValues[i] = value;
+ this._keyIndex.set(key, i);
+ ++i;
+ }
+ this._sortedValues.sort(this._comparator);
+ super.onSubscribeFirst();
+ }
+
+ onUnsubscribeLast() {
+ super.onUnsubscribeLast();
+ this._sortedValues = null;
+ }
+
+ onReset() {
+ this._sortedValues = [];
+ this.emitReset();
+ }
+
+ get length() {
+ return this._source.size;
+ }
+
+ [Symbol.iterator]() {
+ return this._sortedValues;
+ }
+}
+
+//#ifdef TESTS
+export function tests() {
+ return {
+ test_sortIndex(assert) {
+ let idx = sortedIndex([1, 5, 6, 8], 0, (a, b) => a - b);
+ assert.equal(idx, 0);
+ idx = sortedIndex([1, 5, 6, 8], 3, (a, b) => a - b);
+ assert.equal(idx, 1);
+ idx = sortedIndex([1, 5, 6, 8], 8, (a, b) => a - b);
+ assert.equal(idx, 3);
+ },
+
+ test_sortIndex_reverse(assert) {
+ let idx = sortedIndex([8, 6, 5, 1], 6, (a, b) => b - a);
+ assert.equal(idx, 1);
+ },
+
+ test_sortIndex_likeArraySort(assert) {
+ const a = [5, 1, 8, 2];
+ const cmp = (a, b) => a - b;
+ a.sort(cmp);
+ assert.deepEqual(a, [1, 2, 5, 8]);
+ let idx = sortedIndex(a, 2, cmp);
+ assert.equal(idx, 1);
+ }
+ }
+}
+//#endif
diff --git a/src/live-collections/live-map.js b/src/observable/misc.js
similarity index 71%
rename from src/live-collections/live-map.js
rename to src/observable/misc.js
index beb3b1c7..4b30d0f1 100644
--- a/src/live-collections/live-map.js
+++ b/src/observable/misc.js
@@ -1,6 +1,4 @@
-import EventEmitter from "./event-emitter.js";
-
-class LiveMap {
+class ObservableMap {
constructor() {
this._handlers = new Set();
}
@@ -30,43 +28,46 @@ class LiveMap {
}
}
+ onSubscribeFirst() {
+
+ }
+
+ onUnsubscribeLast() {
+
+ }
+
subscribe(handler) {
this._handlers.add(handler);
+ if (this._handlers.length === 1) {
+ this.onSubscribeFirst();
+ }
return () => {
if (handler) {
this._handlers.delete(this._handler);
+ if (this._handlers.length === 0) {
+ this.onUnsubscribeLast();
+ }
handler = null;
}
return null;
};
}
-
- [Symbol.iterator]() {
-
- }
}
-class Operator extends LiveMap {
+class Operator extends ObservableMap {
constructor(source) {
super();
this._source = source;
}
- subscribe(handler) {
- this.onSubscribe(this._source);
- let subscription = super.subscribe(handler);
- let sourceSubscription = this._source.subscribe(this);
- return () => {
- sourceSubscription = sourceSubscription && sourceSubscription();
- subscription = subscription && subscription();
- // this.onUnsubscribe(); ?
- return null;
- };
- }
+ onSubscribeFirst() {
+ this._sourceSubscription = this._source.subscribe(this);
+ }
- onSubscribe() {
-
- }
+ onUnsubscribeLast() {
+ this._sourceSubscription();
+ this._sourceSubscription = null;
+ }
onRemove(key, value) {}
onAdd(key, value) {}
@@ -74,7 +75,7 @@ class Operator extends LiveMap {
onReset() {}
}
-export default class LiveMapCollection extends LiveMap {
+export default class ObservableMapCollection extends ObservableMap {
constructor(initialValues) {
super();
this._values = new Map(initialValues);
@@ -124,7 +125,7 @@ export default class LiveMapCollection extends LiveMap {
}
}
-class LiveMapOperator extends Operator {
+class ObservableMapOperator extends Operator {
constructor(source, mapper, updater) {
super(source);
this._mapper = mapper;
@@ -132,13 +133,6 @@ class LiveMapOperator extends Operator {
this._mappedValues = new Map();
}
- onSubscribe(source) {
- for (let [key, value] of source) {
- const mappedValue = this._mapper(value);
- this._mappedValues.set(key, mappedValue);
- }
- }
-
onAdd(key, value) {
const mappedValue = this._mapper(value);
this._mappedValues.set(key, mappedValue);
@@ -162,6 +156,19 @@ class LiveMapOperator extends Operator {
}
}
+ onSubscribeFirst() {
+ for (let [key, value] of this._source) {
+ const mappedValue = this._mapper(value);
+ this._mappedValues.set(key, mappedValue);
+ }
+ super.onSubscribeFirst();
+ }
+
+ onUnsubscribeLast() {
+ super.onUnsubscribeLast();
+ this._mappedValues.clear();
+ }
+
onReset() {
this._mappedValues.clear();
this.emitReset();
@@ -172,12 +179,12 @@ class LiveMapOperator extends Operator {
}
}
-class FilterOperator extends LiveMapOperator {
+class FilterOperator extends ObservableMapOperator {
}
class SortSet {
- constructor(liveMap) {
+ constructor(ObservableMap) {
}
}
@@ -188,4 +195,4 @@ export function tests() {
},
};
-}
\ No newline at end of file
+}
diff --git a/src/ui/web/ListView.js b/src/ui/web/ListView.js
new file mode 100644
index 00000000..ba4f230f
--- /dev/null
+++ b/src/ui/web/ListView.js
@@ -0,0 +1,78 @@
+import * as html from "./html.js";
+
+class UIView {
+ mount(initialValue) {
+
+ }
+
+ unmount() {
+
+ }
+
+ update() {
+
+ }
+}
+
+export default class ListView {
+ constructor(collection, childCreator) {
+ this._collection = collection;
+ this._root = null;
+ this._subscription = null;
+ this._childCreator = childCreator;
+ this._childInstances = null;
+ }
+
+ getDOMNode() {
+ return this._root;
+ }
+
+ mount() {
+ this._subscription = this._collection.subscribe(this);
+ this._root = html.ul({className: "ListView"});
+ this._childInstances = new Array(this._collection.length);
+ for (let item of this._collection) {
+ const child = this._childCreator(item);
+ this._childInstances.push(child);
+ const childDomNode = child.mount();
+ this._root.appendChild(childDomNode);
+ }
+ return this._root;
+ }
+
+ unmount() {
+ this._subscription = this._subscription();
+ for (let child of this._childInstances) {
+ child.unmount();
+ }
+ this._childInstances = null;
+ }
+
+ onAdd(i, value) {
+ const child = this._childCreator(value);
+ const childDomNode = child.mount();
+ this._childInstances.splice(i, 0, child);
+ const isLast = i === this._collection.length - 1;
+ if (isLast) {
+ this._root.appendChild(childDomNode);
+ } else {
+ const nextDomNode = this._childInstances[i + 1].getDOMNode();
+ this._root.insertBefore(childDomNode, nextDomNode);
+ }
+
+ }
+
+ onRemove(i, _value) {
+ const [child] = this._childInstances.splice(i, 1);
+ child.getDOMNode().remove();
+ child.unmount();
+ }
+
+ onMove(fromIdx, toIdx, value) {
+
+ }
+
+ onChange(i, value) {
+ this._childInstances[i].update(value);
+ }
+}
diff --git a/src/ui/web/html.js b/src/ui/web/html.js
new file mode 100644
index 00000000..0335ca69
--- /dev/null
+++ b/src/ui/web/html.js
@@ -0,0 +1,43 @@
+export function setAttribute(el, name, value) {
+ el.setAttribute(name, value);
+}
+
+export function el(elementName, attrs, children) {
+ const e = document.createElement(elementName);
+ if (typeof attrs === "object") {
+ for (let [name, value] of Object.entries(attrs)) {
+ setAttribute(e, name, value);
+ }
+ }
+ if (Array.isArray(children)) {
+ // TODO: use fragment here?
+ for (let c of children) {
+ e.appendChild(c);
+ }
+ }
+ return e;
+}
+
+export function text(str) {
+ return document.createTextNode(str);
+}
+
+export function ol(... params) { return el("ol", ... params); }
+export function ul(... params) { return el("ul", ... params); }
+export function li(... params) { return el("li", ... params); }
+export function div(... params) { return el("div", ... params); }
+export function h1(... params) { return el("h1", ... params); }
+export function h2(... params) { return el("h2", ... params); }
+export function h3(... params) { return el("h3", ... params); }
+export function h4(... params) { return el("h4", ... params); }
+export function h5(... params) { return el("h5", ... params); }
+export function h6(... params) { return el("h6", ... params); }
+export function p(... params) { return el("p", ... params); }
+export function strong(... params) { return el("strong", ... params); }
+export function em(... params) { return el("em", ... params); }
+export function span(... params) { return el("span", ... params); }
+export function img(... params) { return el("img", ... params); }
+export function section(... params) { return el("section", ... params); }
+export function main(... params) { return el("main", ... params); }
+export function article(... params) { return el("article", ... params); }
+export function aside(... params) { return el("aside", ... params); }