diff --git a/src/domain/session/room/timeline/TilesCollection.js b/src/domain/session/room/timeline/TilesCollection.js new file mode 100644 index 00000000..f4ef2236 --- /dev/null +++ b/src/domain/session/room/timeline/TilesCollection.js @@ -0,0 +1,86 @@ +import BaseObservableList from "../../../../observable/list/BaseObservableList.js"; + +// maps 1..n entries to 0..1 tile. Entries are what is stored in the timeline, either an event or gap +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); + 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); + } + } + + _findTileIndex(sortKey) { + return sortedIndex(this._tiles, sortKey, (key, tile) => { + return tile.compareSortKey(key); + }); + } + + 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, value) { + // 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, value, params) { + // 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 for this use case ... + + // also emit update for tile + } + + onRemove(index, value) { + // find tile, if any + // remove entry from tile + // emit update or remove (if empty now) on 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(); + } +} diff --git a/src/domain/session/room/timeline/TimelineViewModel.js b/src/domain/session/room/timeline/TimelineViewModel.js new file mode 100644 index 00000000..5c179673 --- /dev/null +++ b/src/domain/session/room/timeline/TimelineViewModel.js @@ -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; + } +} diff --git a/src/domain/session/room/timeline/tiles/GapTile.js b/src/domain/session/room/timeline/tiles/GapTile.js new file mode 100644 index 00000000..95e59a27 --- /dev/null +++ b/src/domain/session/room/timeline/tiles/GapTile.js @@ -0,0 +1,13 @@ +import SimpleTile from "./SimpleTile"; + +export default class GapTile extends SimpleTile { + constructor(entry, timeline) { + super(entry); + this._timeline = timeline; + } + + // GapTile specific behaviour + fill() { + return this._timeline.fillGap(this._entry, 10); + } +} diff --git a/src/domain/session/room/timeline/tiles/SimpleTile.js b/src/domain/session/room/timeline/tiles/SimpleTile.js new file mode 100644 index 00000000..018bacbd --- /dev/null +++ b/src/domain/session/room/timeline/tiles/SimpleTile.js @@ -0,0 +1,52 @@ +export default class SimpleTile { + constructor(entry) { + this._entry = entry; + } + // 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; + } + + get upperSortKey() { + return this._entry.sortKey; + } + + get lowerSortKey() { + return this._entry.sortKey; + } + + // TilesCollection contract + compareSortKey(key) { + return this._entry.sortKey.compare(key); + } + + // update received for already included (falls within sort keys) entry + updateEntry(entry) { + + } + + // simple entry 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) { + + } +} diff --git a/src/domain/session/room/timeline/tilesCreator.js b/src/domain/session/room/timeline/tilesCreator.js new file mode 100644 index 00000000..74375f1c --- /dev/null +++ b/src/domain/session/room/timeline/tilesCreator.js @@ -0,0 +1,35 @@ +import GapTile from "./tiles/GapTile.js"; +import TextTile from "./tiles/TextTile.js"; +import ImageTile from "./tiles/ImageTile.js"; +import RoomNameTile from "./tiles/RoomNameTile.js"; +import RoomMemberTile from "./tiles/RoomMemberTile.js"; + +export default function ({timeline}) { + return function tilesCreator(entry) { + if (entry.gap) { + return new GapTile(entry, 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": + return new TextTile(entry); + case "m.image": + return new ImageTile(entry); + default: + return null; // unknown tile types are not rendered? + } + } + case "m.room.name": + return new RoomNameTile(entry); + case "m.room.member": + return new RoomMemberTile(entry); + default: + return null; + } + } + } +}