show timeline when clicking room in roomlist

This commit is contained in:
Bruno Windels 2019-02-27 22:50:08 +01:00
parent 5cafb92fee
commit 6b4ed65a57
18 changed files with 473 additions and 57 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -55,6 +55,6 @@ export default class MappedMap extends BaseObservableMap {
}
[Symbol.iterator]() {
return this._mappedValues.entries()[Symbol.iterator];
return this._mappedValues.entries();
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

49
src/ui/web/RoomView.js Normal file
View File

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

67
src/ui/web/SessionView.js Normal file
View File

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

View File

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

View File

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