diff --git a/src/domain/session/room/timeline/TilesCollection.js b/src/domain/session/room/timeline/TilesCollection.js index 173b0cf6..8dfbf2a1 100644 --- a/src/domain/session/room/timeline/TilesCollection.js +++ b/src/domain/session/room/timeline/TilesCollection.js @@ -16,6 +16,7 @@ limitations under the License. import {BaseObservableList} from "../../../../observable/list/BaseObservableList"; import {sortedIndex} from "../../../../utils/sortedIndex"; +import {TileShape} from "./tiles/ITile"; // maps 1..n entries to 0..1 tile. Entries are what is stored in the timeline, either an event or fragmentboundary // for now, tileClassForEntry should be stable in whether it returns a tile or not. @@ -51,12 +52,14 @@ export class TilesCollection extends BaseObservableList { } _populateTiles() { + this._silent = true; this._tiles = []; let currentTile = null; for (let entry of this._entries) { if (!currentTile || !currentTile.tryIncludeEntry(entry)) { currentTile = this._createTile(entry); if (currentTile) { + console.log("adding initial tile", currentTile.shape, currentTile.eventId, "at", this._tiles.length); this._tiles.push(currentTile); } } @@ -72,11 +75,20 @@ export class TilesCollection extends BaseObservableList { if (prevTile) { prevTile.updateNextSibling(null); } + // add date headers here + for (let idx = 0; idx < this._tiles.length; idx += 1) { + const tile = this._tiles[idx]; + if (tile.needsDateSeparator) { + this._addTileAt(idx, tile.createDateSeparator(), true); + idx += 1; // tile's index moved one up, don't process it again + } + } // now everything is wired up, // allow tiles to emit updates for (const tile of this._tiles) { tile.setUpdateEmit(this._emitSpontanousUpdate); } + this._silent = false; } _findTileIdx(entry) { @@ -130,25 +142,61 @@ export class TilesCollection extends BaseObservableList { const newTile = this._createTile(entry); if (newTile) { - if (prevTile) { - prevTile.updateNextSibling(newTile); - // this emits an update while the add hasn't been emitted yet - newTile.updatePreviousSibling(prevTile); - } - if (nextTile) { - newTile.updateNextSibling(nextTile); - nextTile.updatePreviousSibling(newTile); - } - this._tiles.splice(tileIdx, 0, newTile); - this.emitAdd(tileIdx, newTile); - // add event is emitted, now the tile - // can emit updates - newTile.setUpdateEmit(this._emitSpontanousUpdate); + console.log("adding tile", newTile.shape, newTile.eventId, "at", tileIdx); + this._addTileAt(tileIdx, newTile); + this._evaluateDateHeaderAtIdx(tileIdx); } // 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)? } + _evaluateDateHeaderAtIdx(tileIdx) { + //console.log("_evaluateDateHeaderAtIdx", tileIdx); + // consider the two adjacent tiles where the previous sibling changed: + // the new tile and the next tile + for (let i = 0; i < 5; i += 1) { + const idx = Math.max(tileIdx + i - 2, 0); + const tile = this._tiles[idx]; + const prevTile = idx > 0 ? this._tiles[idx - 1] : undefined; + const hasDateSeparator = prevTile?.shape === TileShape.DateHeader; + if (tile.needsDateSeparator) { + if (hasDateSeparator) { + // TODO: replace this by return UpdateAction from updateNextSibling + // and do this in onAdd + //console.log(" update", idx - 1, prevTile?.shape, prevTile?.eventId); + this.emitUpdate(idx - 1, prevTile, "date"); + } else { + //console.log(" add", idx, tile.shape, tile.eventId); + tileIdx += 1; + this._addTileAt(idx, tile.createDateSeparator()); + } + // TODO must be looking at the wrong index to find the old date separator?? + } else if (hasDateSeparator) { + // this is never triggered because needsDateSeparator is not cleared + // when loading more items because we don't do anything once the + // direct sibling is a DateTile + //console.log(" remove", idx -1, prevTile?.shape, prevTile?.eventId); + this._removeTile(idx - 1, prevTile); + } + } + } + + _addTileAt(idx, newTile, silent = false) { + const prevTile = idx > 0 ? this._tiles[idx - 1] : undefined; + const nextTile = this._tiles[idx]; + prevTile?.updateNextSibling(newTile); + newTile.updatePreviousSibling(prevTile); + newTile.updateNextSibling(nextTile); + nextTile?.updatePreviousSibling(newTile); + this._tiles.splice(idx, 0, newTile); + if (!silent) { + this.emitAdd(idx, newTile); + } + // add event is emitted, now the tile + // can emit updates + newTile.setUpdateEmit(this._emitSpontanousUpdate); + } + onUpdate(index, entry, params) { // if an update is emitted while calling source.subscribe() from onSubscribeFirst, ignore it if (!this._tiles) { diff --git a/src/domain/session/room/timeline/tiles/BaseMessageTile.js b/src/domain/session/room/timeline/tiles/BaseMessageTile.js index cfa27a94..1ad1ba44 100644 --- a/src/domain/session/room/timeline/tiles/BaseMessageTile.js +++ b/src/domain/session/room/timeline/tiles/BaseMessageTile.js @@ -15,13 +15,13 @@ limitations under the License. */ import {SimpleTile} from "./SimpleTile.js"; +import {TileShape} from "./ITile"; import {ReactionsViewModel} from "../ReactionsViewModel.js"; import {getIdentifierColorNumber, avatarInitials, getAvatarHttpUrl} from "../../../../avatar"; export class BaseMessageTile extends SimpleTile { constructor(entry, options) { super(entry, options); - this._date = this._entry.timestamp ? new Date(this._entry.timestamp) : null; this._isContinuation = false; this._reactions = null; this._replyTile = null; @@ -78,6 +78,7 @@ export class BaseMessageTile extends SimpleTile { return this.sender; } + // TODO: remove? get date() { return this._date && this._date.toLocaleDateString({}, {month: "numeric", day: "numeric"}); } diff --git a/src/domain/session/room/timeline/tiles/DateTile.ts b/src/domain/session/room/timeline/tiles/DateTile.ts new file mode 100644 index 00000000..413ec2b2 --- /dev/null +++ b/src/domain/session/room/timeline/tiles/DateTile.ts @@ -0,0 +1,141 @@ +import {ITile, TileShape, EmitUpdateFn} from "./ITile"; +import {UpdateAction} from "../UpdateAction"; +import {BaseEntry} from "../../../../../matrix/room/timeline/entries/BaseEntry"; +import {BaseEventEntry} from "../../../../../matrix/room/timeline/entries/BaseEventEntry"; +import {ViewModel} from "../../../../ViewModel"; +import type {Options} from "../../../../ViewModel"; + +/** + * edge cases: + * - be able to remove the tile in response to the sibling changing, + * probably by letting updateNextSibling/updatePreviousSibling + * return an UpdateAction and change TilesCollection accordingly. + * this is relevant when next becomes undefined there when + * a pending event is removed on remote echo. + * */ + +export class DateTile extends ViewModel implements ITile { + private _emitUpdate?: EmitUpdateFn; + private _dateString?: string; + + constructor(private _firstTileInDay: ITile, options: Options) { + super(options); + } + + setUpdateEmit(emitUpdate: EmitUpdateFn): void { + this._emitUpdate = emitUpdate; + } + + get upperEntry(): BaseEventEntry { + return this.refEntry; + } + + get lowerEntry(): BaseEventEntry { + return this.refEntry; + } + + /** the entry reference by this datetile, e.g. the entry of the first tile for this day */ + private get refEntry(): BaseEventEntry { + // lowerEntry is the first entry... i think? + // so given the date header always comes before, + // this is our closest entry. + return this._firstTileInDay.lowerEntry; + } + + compare(tile: ITile): number { + return this.compareEntry(tile.upperEntry); + } + + get date(): string { + if (!this._dateString) { + const date = new Date(this.refEntry.timestamp); + this._dateString = date.toLocaleDateString({}, { + weekday: 'long', + year: 'numeric', + month: 'long', + day: 'numeric' + }); + } + return this._dateString; + } + + get shape(): TileShape { + return TileShape.DateHeader; + } + + get needsDateSeparator(): boolean { + return false; + } + + createDateSeparator(): undefined { + return undefined; + } + +/** + * _findTileIdx in TilesCollection should never return + * the index of a DateTile as that is mainly used + * for mapping incoming event indices coming from the Timeline + * to the tile index to propage the event. + * This is not a path that is relevant to date headers as they + * are added as a side-effect of adding other tiles and are generally + * not updated (only removed in some cases). _findTileIdx is also + * used for emitting spontanous updates, but that should also not be + * needed for a DateTile. + * The problem is basically that _findTileIdx maps an entry to + * a tile, and DateTile adopts the entry of it's sibling tile (_firstTileInDay) + * so now we have the entry pointing to two tiles. So we should avoid + * returning the DateTile itself from the compare method. + * We will always return -1 or 1 from here to signal an entry comes before or after us, + * never 0 + * */ + compareEntry(entry: BaseEntry): number { + const result = this.refEntry.compare(entry); + if (result === 0) { + // if it's a match for the reference entry (e.g. _firstTileInDay), + // say it comes after us as the date tile always comes at the top + // of the day. + return -1; + } + // otherwise, assume the given entry is never for ourselves + // as we don't have our own entry, we only borrow one from _firstTileInDay + return result; + } + + // update received for already included (falls within sort keys) entry + updateEntry(entry, param): UpdateAction { + return UpdateAction.Nothing(); + } + + // return whether the tile should be removed + // as SimpleTile only has one entry, the tile should be removed + removeEntry(entry: BaseEntry): boolean { + return false; + } + + // SimpleTile can only contain 1 entry + tryIncludeEntry(): boolean { + return false; + } + + // let item know it has a new sibling + updatePreviousSibling(prev: ITile | undefined): void { + this._firstTileInDay.updatePreviousSibling(prev); + } + + // let item know it has a new sibling + updateNextSibling(next: ITile | undefined): UpdateAction { + // TODO: next can be undefined when a pending event is removed + // TODO: we need a way to remove this date header + this._firstTileInDay = next!; + this._dateString = undefined; + // TODO: do we need to reevaluate our date here and emit an update? + } + + notifyVisible(): void { + // trigger sticky logic here? + } + + dispose(): void { + + } +} diff --git a/src/domain/session/room/timeline/tiles/GapTile.js b/src/domain/session/room/timeline/tiles/GapTile.js index bb7d8086..1e6bdd08 100644 --- a/src/domain/session/room/timeline/tiles/GapTile.js +++ b/src/domain/session/room/timeline/tiles/GapTile.js @@ -19,6 +19,7 @@ import {UpdateAction} from "../UpdateAction.js"; import {ConnectionError} from "../../../../../matrix/error.js"; import {ConnectionStatus} from "../../../../../matrix/net/Reconnector"; +// TODO: should this become an ITile and SimpleTile become EventTile? export class GapTile extends SimpleTile { constructor(entry, options) { super(entry, options); @@ -29,6 +30,10 @@ export class GapTile extends SimpleTile { this._showSpinner = false; } + get needsDateSeparator() { + return false; + } + async fill() { if (!this._loading && !this._entry.edgeReached) { this._loading = true; diff --git a/src/domain/session/room/timeline/tiles/ITile.ts b/src/domain/session/room/timeline/tiles/ITile.ts new file mode 100644 index 00000000..dd6c0f81 --- /dev/null +++ b/src/domain/session/room/timeline/tiles/ITile.ts @@ -0,0 +1,44 @@ +import {UpdateAction} from "../UpdateAction.js"; +import {BaseEntry} from "../../../../../matrix/room/timeline/entries/BaseEntry"; +import {BaseEventEntry} from "../../../../../matrix/room/timeline/entries/BaseEventEntry"; +import {IDisposable} from "../../../../../utils/Disposables"; + +export type EmitUpdateFn = (tile: ITile, props: any) => void + +export enum TileShape { + Message = "message", + MessageStatus = "message-status", + Announcement = "announcement", + File = "file", + Gap = "gap", + Image = "image", + Location = "location", + MissingAttachment = "missing-attachment", + Redacted = "redacted", + Video = "video", + DateHeader = "date-header" +} + +// TODO: should we imply inheriting from view model here? +export interface ITile extends IDisposable { + setUpdateEmit(emitUpdate: EmitUpdateFn): void; + get upperEntry(): E; + get lowerEntry(): E; + compare(tile: ITile): number; + compareEntry(entry: BaseEntry): number; + // update received for already included (falls within sort keys) entry + updateEntry(entry: BaseEntry, param: any): UpdateAction; + // return whether the tile should be removed + // as SimpleTile only has one entry, the tile should be removed + removeEntry(entry: BaseEntry): boolean + // SimpleTile can only contain 1 entry + tryIncludeEntry(): boolean; + // let item know it has a new sibling + updatePreviousSibling(prev: ITile | undefined): void; + // let item know it has a new sibling + updateNextSibling(next: ITile | undefined): void; + notifyVisible(): void; + get needsDateSeparator(): boolean; + createDateSeparator(): ITile | undefined; + get shape(): TileShape; +} diff --git a/src/domain/session/room/timeline/tiles/SimpleTile.js b/src/domain/session/room/timeline/tiles/SimpleTile.js index 04141576..7e394d25 100644 --- a/src/domain/session/room/timeline/tiles/SimpleTile.js +++ b/src/domain/session/room/timeline/tiles/SimpleTile.js @@ -15,13 +15,17 @@ limitations under the License. */ import {UpdateAction} from "../UpdateAction.js"; +import {TileShape} from "./ITile"; import {ViewModel} from "../../../../ViewModel"; import {SendStatus} from "../../../../../matrix/room/sending/PendingEvent.js"; +import {DateTile} from "./DateTile"; export class SimpleTile extends ViewModel { constructor(entry, options) { super(options); this._entry = entry; + this._date = this._entry.timestamp ? new Date(this._entry.timestamp) : undefined; + this._needsDateSeparator = false; this._emitUpdate = undefined; } // view model props for all subclasses @@ -37,8 +41,26 @@ export class SimpleTile extends ViewModel { return false; } - get hasDateSeparator() { - return false; + get needsDateSeparator() { + return this._needsDateSeparator; + } + + createDateSeparator() { + return new DateTile(this, this.childOptions({})); + } + + _updateDateSeparator(prev) { + if (prev && prev._date && this._date) { + const neededDateSeparator = this._needsDateSeparator; + this._needsDateSeparator = prev._date.getFullYear() !== this._date.getFullYear() || + prev._date.getMonth() !== this._date.getMonth() || + prev._date.getDate() !== this._date.getDate(); + if (neededDateSeparator && !this._needsDateSeparator) { + console.log("clearing needsDateSeparator", {this: this._entry.content, prev: prev.content}); + } + } else { + this._needsDateSeparator = !!this._date; + } } get id() { @@ -123,8 +145,10 @@ export class SimpleTile extends ViewModel { return false; } // let item know it has a new sibling - updatePreviousSibling(/*prev*/) { - + updatePreviousSibling(prev) { + if (prev?.shape !== TileShape.DateHeader) { + this._updateDateSeparator(prev); + } } // let item know it has a new sibling diff --git a/src/domain/session/room/timeline/tiles/index.ts b/src/domain/session/room/timeline/tiles/index.ts index 242bea2f..6822a91d 100644 --- a/src/domain/session/room/timeline/tiles/index.ts +++ b/src/domain/session/room/timeline/tiles/index.ts @@ -27,7 +27,7 @@ import {EncryptedEventTile} from "./EncryptedEventTile.js"; import {EncryptionEnabledTile} from "./EncryptionEnabledTile.js"; import {MissingAttachmentTile} from "./MissingAttachmentTile.js"; -import type {SimpleTile} from "./SimpleTile.js"; +import type {ITile, TileShape} from "./ITile"; import type {Room} from "../../../../../matrix/room/Room"; import type {Timeline} from "../../../../../matrix/room/timeline/Timeline"; import type {FragmentBoundaryEntry} from "../../../../../matrix/room/timeline/entries/FragmentBoundaryEntry"; @@ -42,7 +42,7 @@ export type Options = ViewModelOptions & { timeline: Timeline tileClassForEntry: TileClassForEntryFn; }; -export type TileConstructor = new (entry: TimelineEntry, options: Options) => SimpleTile; +export type TileConstructor = new (entry: TimelineEntry, options: Options) => ITile; export function tileClassForEntry(entry: TimelineEntry): TileConstructor | undefined { if (entry.isGap) { diff --git a/src/platform/web/ui/session/room/common.ts b/src/platform/web/ui/session/room/common.ts index 7b62630f..6a47ff90 100644 --- a/src/platform/web/ui/session/room/common.ts +++ b/src/platform/web/ui/session/room/common.ts @@ -22,31 +22,34 @@ import {LocationView} from "./timeline/LocationView.js"; import {MissingAttachmentView} from "./timeline/MissingAttachmentView.js"; import {AnnouncementView} from "./timeline/AnnouncementView.js"; import {RedactedView} from "./timeline/RedactedView.js"; -import {SimpleTile} from "../../../../../domain/session/room/timeline/tiles/SimpleTile.js"; +import {ITile, TileShape} from "../../../../../domain/session/room/timeline/tiles/ITile.js"; import {GapView} from "./timeline/GapView.js"; +import {DateHeaderView} from "./timeline/DateHeaderView"; import type {TileViewConstructor, ViewClassForEntryFn} from "./TimelineView"; -export function viewClassForTile(vm: SimpleTile): TileViewConstructor { +export function viewClassForTile(vm: ITile): TileViewConstructor { switch (vm.shape) { - case "gap": + case TileShape.Gap: return GapView; - case "announcement": + case TileShape.Announcement: return AnnouncementView; - case "message": - case "message-status": + case TileShape.Message: + case TileShape.MessageStatus: return TextMessageView; - case "image": + case TileShape.Image: return ImageView; - case "video": + case TileShape.Video: return VideoView; - case "file": + case TileShape.File: return FileView; - case "location": + case TileShape.Location: return LocationView; - case "missing-attachment": + case TileShape.MissingAttachment: return MissingAttachmentView; - case "redacted": + case TileShape.Redacted: return RedactedView; + case TileShape.DateHeader: + return DateHeaderView; default: throw new Error(`Tiles of shape "${vm.shape}" are not supported, check the tileClassForEntry function in the view model`); } diff --git a/src/platform/web/ui/session/room/timeline/DateHeaderView.ts b/src/platform/web/ui/session/room/timeline/DateHeaderView.ts new file mode 100644 index 00000000..63003e91 --- /dev/null +++ b/src/platform/web/ui/session/room/timeline/DateHeaderView.ts @@ -0,0 +1,33 @@ +/* +Copyright 2020 Bruno Windels + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import {TemplateView} from "../../../general/TemplateView"; +import {spinner} from "../../../common.js"; +import type {DateTile} from "../../../../../../domain/session/room/timeline/tiles/DateTile"; + +export class DateHeaderView extends TemplateView { + // ignore other argument + constructor(vm) { + super(vm); + } + + render(t, vm) { + return t.div({className: "DateHeader"}, t.div(vm.date)); + } + + /* This is called by the parent ListView, which just has 1 listener for the whole list */ + onClick() {} +}