From 6b4ed65a57244166b600fc506d7a2182ab4f0b0c Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Wed, 27 Feb 2019 22:50:08 +0100 Subject: [PATCH] show timeline when clicking room in roomlist --- src/main.js | 14 ++-- src/matrix/room/persister.js | 53 ++++++++++----- src/matrix/room/room.js | 31 +++++++-- src/matrix/room/summary.js | 1 + src/matrix/room/timeline.js | 36 ++++++++++ .../storage/idb/stores/RoomTimelineStore.js | 32 +++++---- src/observable/index.js | 1 + src/observable/list/ObservableArray.js | 21 ++++++ src/observable/map/MappedMap.js | 2 +- src/ui/viewmodels/RoomTileViewModel.js | 24 +++++++ src/ui/viewmodels/RoomViewModel.js | 36 ++++++++++ src/ui/viewmodels/SessionViewModel.js | 37 ++++++++++ src/ui/web/ListView.js | 57 +++++++++++++--- src/ui/web/RoomTile.js | 12 ++-- src/ui/web/RoomView.js | 49 ++++++++++++++ src/ui/web/SessionView.js | 67 +++++++++++++++++++ src/ui/web/TimelineTile.js | 56 ++++++++++++++++ src/ui/web/html.js | 1 + 18 files changed, 473 insertions(+), 57 deletions(-) create mode 100644 src/matrix/room/timeline.js create mode 100644 src/observable/list/ObservableArray.js create mode 100644 src/ui/viewmodels/RoomTileViewModel.js create mode 100644 src/ui/viewmodels/RoomViewModel.js create mode 100644 src/ui/viewmodels/SessionViewModel.js create mode 100644 src/ui/web/RoomView.js create mode 100644 src/ui/web/SessionView.js create mode 100644 src/ui/web/TimelineTile.js diff --git a/src/main.js b/src/main.js index 6d2d7412..b5487ae0 100644 --- a/src/main.js +++ b/src/main.js @@ -2,8 +2,8 @@ import HomeServerApi from "./matrix/hs-api.js"; import Session from "./matrix/session.js"; import createIdbStorage from "./matrix/storage/idb/create.js"; import Sync from "./matrix/sync.js"; -import ListView from "./ui/web/ListView.js"; -import RoomTile from "./ui/web/RoomTile.js"; +import SessionView from "./ui/web/SessionView.js"; +import SessionViewModel from "./ui/viewmodels/SessionViewModel.js"; const HOST = "localhost"; const HOMESERVER = `http://${HOST}:8008`; @@ -34,10 +34,10 @@ async function login(username, password, homeserver) { return {sessionId, loginData}; } -function showRooms(container, rooms) { - const sortedRooms = rooms.sortValues((a, b) => a.name.localeCompare(b.name)); - const listView = new ListView(sortedRooms, (room) => new RoomTile(room)); - container.appendChild(listView.mount()); +function showSession(container, session) { + const vm = new SessionViewModel(session); + const view = new SessionView(vm); + container.appendChild(view.mount()); } // eslint-disable-next-line no-unused-vars @@ -54,7 +54,7 @@ export default async function main(label, button, container) { await session.setLoginData(loginData); } await session.load(); - showRooms(container, session.rooms); + showSession(container, session); const hsApi = new HomeServerApi(HOMESERVER, session.accessToken); console.log("session loaded"); if (!session.syncToken) { diff --git a/src/matrix/room/persister.js b/src/matrix/room/persister.js index 0acc0528..c08fbc92 100644 --- a/src/matrix/room/persister.js +++ b/src/matrix/room/persister.js @@ -21,23 +21,27 @@ export default class RoomPersister { // } - async persistSync(roomResponse, txn) { + 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(); - txn.roomTimeline.appendGap(this._roomId, nextKey, {prev_batch: timeline.prev_batch}); - } - // const startOfChunkSortKey = nextKey; - - if (timeline.events) { - for(const event of timeline.events) { - nextKey = nextKey.nextKey(); - txn.roomTimeline.appendEvent(this._roomId, nextKey, event); + 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 @@ -55,13 +59,30 @@ export default class RoomPersister { } if (timeline.events) { - if (state.events) { - for (const event of timeline.events) { - if (typeof event.state_key === "string") { - txn.roomState.setStateEvent(this._roomId, event); - } + for (const event of timeline.events) { + if (typeof event.state_key === "string") { + txn.roomState.setStateEvent(this._roomId, event); } } - } + } + return entries; } -} \ No newline at end of file + + _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 + }; + } +} diff --git a/src/matrix/room/room.js b/src/matrix/room/room.js index 5b5923b6..80633f58 100644 --- a/src/matrix/room/room.js +++ b/src/matrix/room/room.js @@ -1,6 +1,7 @@ +import EventEmitter from "../../EventEmitter.js"; import RoomSummary from "./summary.js"; import RoomPersister from "./persister.js"; -import EventEmitter from "../../EventEmitter.js"; +import Timeline from "./timeline.js"; export default class Room extends EventEmitter { constructor(roomId, storage, emitCollectionChange) { @@ -10,19 +11,23 @@ export default class Room extends EventEmitter { this._summary = new RoomSummary(roomId); this._persister = new RoomPersister(roomId); this._emitCollectionChange = emitCollectionChange; + this._timeline = null; } persistSync(roomResponse, membership, txn) { - const changed = this._summary.applySync(roomResponse, membership, txn); - this._persister.persistSync(roomResponse, txn); - return changed; + const summaryChanged = this._summary.applySync(roomResponse, membership, txn); + const newTimelineEntries = this._persister.persistSync(roomResponse, txn); + return {summaryChanged, newTimelineEntries}; } - emitSync(changed) { - if (changed) { + emitSync({summaryChanged, newTimelineEntries}) { + if (summaryChanged) { this.emit("change"); (this._emitCollectionChange)(this); } + if (this._timeline) { + this._timeline.appendLiveEntries(newTimelineEntries); + } } load(summary, txn) { @@ -37,4 +42,18 @@ export default class Room extends EventEmitter { get id() { return this._roomId; } + + async openTimeline() { + if (this._timeline) { + throw new Error("not dealing with load race here for now"); + } + this._timeline = new Timeline({ + roomId: this.id, + storage: this._storage, + closeCallback: () => this._timeline = null, + }); + await this._timeline.load(); + return this._timeline; + } } + diff --git a/src/matrix/room/summary.js b/src/matrix/room/summary.js index 872d1122..7174fe2a 100644 --- a/src/matrix/room/summary.js +++ b/src/matrix/room/summary.js @@ -87,6 +87,7 @@ export default class RoomSummary { this._membership = membership; changed = true; } + // state comes before timeline if (roomResponse.state) { changed = roomResponse.state.events.reduce((changed, e) => { return this._processEvent(e) || changed; diff --git a/src/matrix/room/timeline.js b/src/matrix/room/timeline.js new file mode 100644 index 00000000..44f5f83d --- /dev/null +++ b/src/matrix/room/timeline.js @@ -0,0 +1,36 @@ +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(); + } +} diff --git a/src/matrix/storage/idb/stores/RoomTimelineStore.js b/src/matrix/storage/idb/stores/RoomTimelineStore.js index cf910687..cd5ad4e5 100644 --- a/src/matrix/storage/idb/stores/RoomTimelineStore.js +++ b/src/matrix/storage/idb/stores/RoomTimelineStore.js @@ -18,20 +18,26 @@ export default class RoomTimelineStore { 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; - } + 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 + - // 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({ diff --git a/src/observable/index.js b/src/observable/index.js index 3f2b4bf3..36e037cd 100644 --- a/src/observable/index.js +++ b/src/observable/index.js @@ -3,6 +3,7 @@ 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 ObservableMap } from "./map/ObservableMap.js"; // avoid circular dependency between these classes diff --git a/src/observable/list/ObservableArray.js b/src/observable/list/ObservableArray.js new file mode 100644 index 00000000..e33dfe1a --- /dev/null +++ b/src/observable/list/ObservableArray.js @@ -0,0 +1,21 @@ +import BaseObservableList from "./BaseObservableList.js"; + +export default class ObservableArray extends BaseObservableList { + constructor() { + super(); + this._items = []; + } + + append(item) { + this._items.push(item); + this.emitAdd(this._items.length - 1, item); + } + + get length() { + return this._items.length; + } + + [Symbol.iterator]() { + return this._items.values(); + } +} diff --git a/src/observable/map/MappedMap.js b/src/observable/map/MappedMap.js index f02ad0a4..efc9d884 100644 --- a/src/observable/map/MappedMap.js +++ b/src/observable/map/MappedMap.js @@ -55,6 +55,6 @@ export default class MappedMap extends BaseObservableMap { } [Symbol.iterator]() { - return this._mappedValues.entries()[Symbol.iterator]; + return this._mappedValues.entries(); } } diff --git a/src/ui/viewmodels/RoomTileViewModel.js b/src/ui/viewmodels/RoomTileViewModel.js new file mode 100644 index 00000000..34c8ea86 --- /dev/null +++ b/src/ui/viewmodels/RoomTileViewModel.js @@ -0,0 +1,24 @@ +export default class RoomTileViewModel { + // we use callbacks to parent VM instead of emit because + // it would be annoying to keep track of subscriptions in + // parent for all RoomTileViewModels + // emitUpdate is ObservableMap/ObservableList update mechanism + constructor({room, emitUpdate, emitOpen}) { + this._room = room; + this._emitUpdate = emitUpdate; + this._emitOpen = emitOpen; + } + + open() { + this._emitOpen(this._room); + } + + compare(other) { + // sort by name for now + return this._room.name.localeCompare(other._room.name); + } + + get name() { + return this._room.name; + } +} diff --git a/src/ui/viewmodels/RoomViewModel.js b/src/ui/viewmodels/RoomViewModel.js new file mode 100644 index 00000000..bc75dea6 --- /dev/null +++ b/src/ui/viewmodels/RoomViewModel.js @@ -0,0 +1,36 @@ +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; + } +} diff --git a/src/ui/viewmodels/SessionViewModel.js b/src/ui/viewmodels/SessionViewModel.js new file mode 100644 index 00000000..470ee517 --- /dev/null +++ b/src/ui/viewmodels/SessionViewModel.js @@ -0,0 +1,37 @@ +import EventEmitter from "../../EventEmitter.js"; +import RoomTileViewModel from "./RoomTileViewModel.js"; +import RoomViewModel from "./RoomViewModel.js"; + +export default class SessionViewModel extends EventEmitter { + constructor(session) { + super(); + this._session = session; + this._currentRoomViewModel = null; + const roomTileVMs = this._session.rooms.mapValues((room, emitUpdate) => { + return new RoomTileViewModel({ + room, + emitUpdate, + emitOpen: room => this._openRoom(room) + }); + }); + this._roomList = roomTileVMs.sortValues((a, b) => a.compare(b)); + } + + get roomList() { + return this._roomList; + } + + get currentRoom() { + return this._currentRoomViewModel; + } + + _openRoom(room) { + if (this._currentRoomViewModel) { + this._currentRoomViewModel.disable(); + } + this._currentRoomViewModel = new RoomViewModel(room); + this._currentRoomViewModel.enable(); + this.emit("change", "currentRoom"); + } +} + diff --git a/src/ui/web/ListView.js b/src/ui/web/ListView.js index 1a03d4db..e504f208 100644 --- a/src/ui/web/ListView.js +++ b/src/ui/web/ListView.js @@ -19,34 +19,57 @@ function insertAt(parentNode, idx, childNode) { } export default class ListView { - constructor(collection, childCreator) { - this._collection = collection; + constructor({list, onItemClick}, childCreator) { + this._onItemClick = onItemClick; + this._list = list; this._root = null; this._subscription = null; this._childCreator = childCreator; this._childInstances = null; + this._onClick = this._onClick.bind(this); } root() { return this._root; } - update() {} + update(attributes) { + if (attributes.hasOwnProperty("list")) { + if (this._subscription) { + this._unloadList(); + while (this._root.lastChild) { + this._root.lastChild.remove(); + } + } + this._list = attributes.list; + this._loadList(); + } + } mount() { - this._subscription = this._collection.subscribe(this); this._root = html.ul({className: "ListView"}); - this._childInstances = []; - for (let item of this._collection) { - const child = this._childCreator(item); - this._childInstances.push(child); - const childDomNode = child.mount(); - this._root.appendChild(childDomNode); + this._loadList(); + if (this._onItemClick) { + this._root.addEventListener("click", this._onClick); } return this._root; } unmount() { + this._unloadList(); + } + + _onClick(event) { + let childNode = event.target; + while (childNode.parentNode !== this._root) { + childNode = childNode.parentNode; + } + const index = Array.prototype.indexOf.call(this._root.childNodes, childNode); + const childView = this._childInstances[index]; + this._onItemClick(childView); + } + + _unloadList() { this._subscription = this._subscription(); for (let child of this._childInstances) { child.unmount(); @@ -54,6 +77,20 @@ export default class ListView { this._childInstances = null; } + _loadList() { + if (!this._list) { + return; + } + this._subscription = this._list.subscribe(this); + this._childInstances = []; + for (let item of this._list) { + const child = this._childCreator(item); + this._childInstances.push(child); + const childDomNode = child.mount(); + this._root.appendChild(childDomNode); + } + } + onAdd(idx, value) { const child = this._childCreator(value); this._childInstances.splice(idx, 0, child); diff --git a/src/ui/web/RoomTile.js b/src/ui/web/RoomTile.js index eeb10555..4beb6f1a 100644 --- a/src/ui/web/RoomTile.js +++ b/src/ui/web/RoomTile.js @@ -1,13 +1,13 @@ import { li } from "./html.js"; export default class RoomTile { - constructor(room) { - this._room = room; + constructor(viewModel) { + this._viewModel = viewModel; this._root = null; } mount() { - this._root = li(null, this._room.name); + this._root = li(null, this._viewModel.name); return this._root; } @@ -16,7 +16,11 @@ export default class RoomTile { update() { // no data-binding yet - this._root.innerText = this._room.name; + this._root.innerText = this._viewModel.name; + } + + clicked() { + this._viewModel.open(); } root() { diff --git a/src/ui/web/RoomView.js b/src/ui/web/RoomView.js new file mode 100644 index 00000000..987bdeeb --- /dev/null +++ b/src/ui/web/RoomView.js @@ -0,0 +1,49 @@ +import TimelineTile from "./TimelineTile.js"; +import ListView from "./ListView.js"; +import * as html from "./html.js"; + +export default class RoomView { + constructor(viewModel) { + this._viewModel = viewModel; + this._root = null; + this._timelineList = null; + this._nameLabel = null; + this._onViewModelUpdate = this._onViewModelUpdate.bind(this); + } + + 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._timelineList.mount(); + + this._root = html.div({className: "RoomView"}, [ + this._nameLabel, + this._timelineList.root() + ]); + + return this._root; + } + + unmount() { + this._timelineList.unmount(); + this._viewModel.off("change", this._onViewModelUpdate); + } + + root() { + return this._root; + } + + _onViewModelUpdate(prop) { + if (prop === "name") { + this._nameLabel.innerText = this._viewModel.name; + } + else if (prop === "timelineEntries") { + this._timelineList.update({list: this._viewModel.timelineEntries}); + } + } + + update() {} +} diff --git a/src/ui/web/SessionView.js b/src/ui/web/SessionView.js new file mode 100644 index 00000000..15970e8e --- /dev/null +++ b/src/ui/web/SessionView.js @@ -0,0 +1,67 @@ +import ListView from "./ListView.js"; +import RoomTile from "./RoomTile.js"; +import RoomView from "./RoomView.js"; +import { div } from "./html.js"; + +export default class SessionView { + constructor(viewModel) { + this._viewModel = viewModel; + this._roomList = null; + this._currentRoom = null; + this._root = null; + this._onViewModelChange = this._onViewModelChange.bind(this); + } + + root() { + return this._root; + } + + mount() { + this._viewModel.on("change", this._onViewModelChange); + + this._root = div({className: "SessionView"}); + this._roomList = new ListView( + { + list: this._viewModel.roomList, + onItemClick: roomTile => roomTile.clicked() + }, + (room) => new RoomTile(room) + ); + this._roomList.mount(); + this._root.appendChild(this._roomList.root()); + + this._updateCurrentRoom(); + return this._root; + } + + unmount() { + this._roomList.unmount(); + if (this._room) { + this._room.unmount(); + } + + this._viewModel.off("change", this._onViewModelChange); + } + + _onViewModelChange(prop) { + if (prop === "currentRoom") { + this._updateCurrentRoom(); + } + } + + // changing viewModel not supported for now + update() {} + + _updateCurrentRoom() { + if (this._currentRoom) { + this._currentRoom.root().remove(); + this._currentRoom.unmount(); + this._currentRoom = null; + } + if (this._viewModel.currentRoom) { + this._currentRoom = new RoomView(this._viewModel.currentRoom); + this._currentRoom.mount(); + this.root().appendChild(this._currentRoom.root()); + } + } +} diff --git a/src/ui/web/TimelineTile.js b/src/ui/web/TimelineTile.js new file mode 100644 index 00000000..7c362d78 --- /dev/null +++ b/src/ui/web/TimelineTile.js @@ -0,0 +1,56 @@ +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; + this._root = null; + } + + root() { + return this._root; + } + + 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); + return this._root; + } + + unmount() {} + + update() {} +} diff --git a/src/ui/web/html.js b/src/ui/web/html.js index c5c0a266..112f76c0 100644 --- a/src/ui/web/html.js +++ b/src/ui/web/html.js @@ -47,3 +47,4 @@ 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); } +export function pre(... params) { return el("pre", ... params); }