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