This commit is contained in:
Bruno Windels 2022-11-18 23:26:59 +01:00
parent 12e378eb62
commit 3f7c1577e0
9 changed files with 332 additions and 33 deletions

View File

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

View File

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

View File

@ -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<BaseEventEntry> {
private _emitUpdate?: EmitUpdateFn;
private _dateString?: string;
constructor(private _firstTileInDay: ITile<BaseEventEntry>, 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<BaseEntry>): 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<BaseEntry> | undefined): void {
this._firstTileInDay.updatePreviousSibling(prev);
}
// let item know it has a new sibling
updateNextSibling(next: ITile<BaseEntry> | 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 {
}
}

View File

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

View File

@ -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<BaseEntry>, 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<E extends BaseEntry = BaseEntry> extends IDisposable {
setUpdateEmit(emitUpdate: EmitUpdateFn): void;
get upperEntry(): E;
get lowerEntry(): E;
compare(tile: ITile<BaseEntry>): 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<BaseEntry> | undefined): void;
// let item know it has a new sibling
updateNextSibling(next: ITile<BaseEntry> | undefined): void;
notifyVisible(): void;
get needsDateSeparator(): boolean;
createDateSeparator(): ITile<BaseEntry> | undefined;
get shape(): TileShape;
}

View File

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

View File

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

View File

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

View File

@ -0,0 +1,33 @@
/*
Copyright 2020 Bruno Windels <bruno@windels.cloud>
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<DateTile> {
// 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() {}
}