diff --git a/src/domain/ViewModel.ts b/src/domain/ViewModel.ts index 8dbc37ea..878b43ba 100644 --- a/src/domain/ViewModel.ts +++ b/src/domain/ViewModel.ts @@ -29,6 +29,7 @@ import type {ILogger} from "../logging/types"; import type {Navigation} from "./navigation/Navigation"; import type {SegmentType} from "./navigation/index"; import type {IURLRouter} from "./navigation/URLRouter"; +import type { ITimeFormatter } from "../platform/types/types"; export type Options = { platform: Platform; @@ -145,4 +146,8 @@ export class ViewModel = Op // typescript needs a little help here return this._options.navigation as unknown as Navigation; } + + get timeFormatter(): ITimeFormatter { + return this._options.platform.timeFormatter; + } } diff --git a/src/domain/session/room/timeline/TilesCollection.js b/src/domain/session/room/timeline/TilesCollection.js index 173b0cf6..458697ca 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,6 +52,7 @@ export class TilesCollection extends BaseObservableList { } _populateTiles() { + this._silent = true; this._tiles = []; let currentTile = null; for (let entry of this._entries) { @@ -72,11 +74,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 +141,57 @@ 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); + 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) { + // consider two tiles after the inserted tile, because + // the first of the two tiles may be a DateTile in which case, + // we remove it after looking at the needsDateSeparator prop of the + // next next tile + for (let i = 0; i < 3; i += 1) { + const idx = tileIdx + i; + if (idx >= this._tiles.length) { + break; + } + const tile = this._tiles[idx]; + const prevTile = idx > 0 ? this._tiles[idx - 1] : undefined; + const hasDateSeparator = prevTile?.shape === TileShape.DateHeader; + if (tile.needsDateSeparator && !hasDateSeparator) { + // adding a tile shift all the indices we need to consider + // especially given we consider removals for the tile that + // comes after a datetile + tileIdx += 1; + this._addTileAt(idx, tile.createDateSeparator()); + } else if (!tile.needsDateSeparator && 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 + 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) { @@ -210,11 +253,16 @@ export class TilesCollection extends BaseObservableList { this.emitRemove(tileIdx, tile); prevTile?.updateNextSibling(nextTile); nextTile?.updatePreviousSibling(prevTile); + + if (prevTile && prevTile.shape === TileShape.DateHeader && (!nextTile || !nextTile.needsDateSeparator)) { + this._removeTile(tileIdx - 1, prevTile); + } } // would also be called when unloading a part of the timeline onRemove(index, entry) { const tileIdx = this._findTileIdx(entry); + const tile = this._findTileAtIdx(entry, tileIdx); if (tile) { const removeTile = tile.removeEntry(entry); @@ -268,6 +316,7 @@ export function tests() { constructor(entry) { this.entry = entry; this.update = null; + this.needsDateSeparator = false; } setUpdateEmit(update) { this.update = update; @@ -297,6 +346,34 @@ export function tests() { dispose() {} } + class DateHeaderTile extends TestTile { + get shape() { return TileShape.DateHeader; } + updateNextSibling(next) { + this.next = next; + } + updatePreviousSibling(prev) { + this.next?.updatePreviousSibling(prev); + } + compareEntry(b) { + // important that date tiles as sorted before their next item, but after their previous sibling + return this.next.compareEntry(b) - 0.5; + } + } + + class MessageNeedingDateHeaderTile extends TestTile { + get shape() { return TileShape.Message; } + + createDateSeparator() { + return new DateHeaderTile(this.entry); + } + updatePreviousSibling(prev) { + if (prev?.shape !== TileShape.DateHeader) { + // 1 day is 10 + this.needsDateSeparator = !prev || Math.floor(prev.entry.n / 10) !== Math.floor(this.entry.n / 10); + } + } + } + return { "don't emit update before add": assert => { class UpdateOnSiblingTile extends TestTile { @@ -355,6 +432,73 @@ export function tests() { }); entries.remove(1); assert.deepEqual(events, ["remove", "update"]); + }, + "date tile is added when needed when populating": assert => { + const entries = new ObservableArray([{n: 15}]); + const tileOptions = { + tileClassForEntry: () => MessageNeedingDateHeaderTile, + }; + const tiles = new TilesCollection(entries, tileOptions); + tiles.subscribe({}); + const tilesArray = Array.from(tiles); + assert.equal(tilesArray.length, 2); + assert.equal(tilesArray[0].shape, TileShape.DateHeader); + assert.equal(tilesArray[1].shape, TileShape.Message); + }, + "date header is added when receiving addition": assert => { + const entries = new ObservableArray([{n: 15}]); + const tileOptions = { + tileClassForEntry: () => MessageNeedingDateHeaderTile, + }; + const tiles = new TilesCollection(entries, tileOptions); + tiles.subscribe({ + onAdd() {}, + onRemove() {} + }); + entries.insert(0, {n: 5}); + const tilesArray = Array.from(tiles); + assert.equal(tilesArray[0].shape, TileShape.DateHeader); + assert.equal(tilesArray[1].shape, TileShape.Message); + assert.equal(tilesArray[2].shape, TileShape.DateHeader); + assert.equal(tilesArray[3].shape, TileShape.Message); + assert.equal(tilesArray.length, 4); + }, + "date header is removed and added when loading more messages for the same day": assert => { + const entries = new ObservableArray([{n: 15}]); + const tileOptions = { + tileClassForEntry: () => MessageNeedingDateHeaderTile, + }; + const tiles = new TilesCollection(entries, tileOptions); + tiles.subscribe({ + onAdd() {}, + onRemove() {} + }); + entries.insert(0, {n: 12}); + const tilesArray = Array.from(tiles); + assert.equal(tilesArray[0].shape, TileShape.DateHeader); + assert.equal(tilesArray[1].shape, TileShape.Message); + assert.equal(tilesArray[2].shape, TileShape.Message); + assert.equal(tilesArray.length, 3); + }, + "date header is removed at the end of the timeline": assert => { + const entries = new ObservableArray([{n: 5}, {n: 15}]); + const tileOptions = { + tileClassForEntry: () => MessageNeedingDateHeaderTile, + }; + const tiles = new TilesCollection(entries, tileOptions); + let removals = 0; + tiles.subscribe({ + onAdd() {}, + onRemove() { + removals += 1; + } + }); + entries.remove(1); + const tilesArray = Array.from(tiles); + assert.equal(tilesArray[0].shape, TileShape.DateHeader); + assert.equal(tilesArray[1].shape, TileShape.Message); + assert.equal(tilesArray.length, 2); + assert.equal(removals, 2); } } } diff --git a/src/domain/session/room/timeline/tiles/BaseMessageTile.js b/src/domain/session/room/timeline/tiles/BaseMessageTile.js index cfa27a94..a7dc82cd 100644 --- a/src/domain/session/room/timeline/tiles/BaseMessageTile.js +++ b/src/domain/session/room/timeline/tiles/BaseMessageTile.js @@ -21,7 +21,6 @@ import {getIdentifierColorNumber, avatarInitials, getAvatarHttpUrl} from "../../ 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,12 +77,8 @@ export class BaseMessageTile extends SimpleTile { return this.sender; } - get date() { - return this._date && this._date.toLocaleDateString({}, {month: "numeric", day: "numeric"}); - } - get time() { - return this._date && this._date.toLocaleTimeString({}, {hour: "numeric", minute: "2-digit"}); + return this._date && this.timeFormatter.formatTime(this._date); } get isOwn() { 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..ac4e6329 --- /dev/null +++ b/src/domain/session/room/timeline/tiles/DateTile.ts @@ -0,0 +1,181 @@ +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; + private _machineReadableString?: 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 relativeDate(): string { + if (!this._dateString) { + this._dateString = this.timeFormatter.formatRelativeDate(new Date(this.refEntry.timestamp)); + } + return this._dateString; + } + + get machineReadableDate(): string { + if (!this._machineReadableString) { + this._machineReadableString = this.timeFormatter.formatMachineReadableDate(new Date(this.refEntry.timestamp)); + } + return this._machineReadableString; + } + + 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 { + // forward the sibling update to our next tile, so it is informed + // about it's previous sibling beyond the date header (which is it's direct previous sibling) + // so it can recalculate whether it still needs a date header + this._firstTileInDay.updatePreviousSibling(prev); + } + + // let item know it has a new sibling + updateNextSibling(next: ITile | undefined): UpdateAction { + if(!next) { + // If we are the DateTile for the last tile in the timeline, + // and that tile gets removed, next would be undefined + // and this DateTile would be removed as well, + // so do nothing + return; + } + this._firstTileInDay = next; + const prevDateString = this._dateString; + this._dateString = undefined; + this._machineReadableString = undefined; + if (prevDateString && prevDateString !== this.relativeDate) { + this._emitUpdate?.(this, "relativeDate"); + } + } + + notifyVisible(): void { + // trigger sticky logic here? + } + + dispose(): void { + + } +} + +import { EventEntry } from "../../../../../matrix/room/timeline/entries/EventEntry.js"; +import { SimpleTile } from "./SimpleTile"; + +export function tests() { + return { + "date tile sorts before reference tile": assert => { + const a = new SimpleTile(new EventEntry({ + event: {}, + eventIndex: 2, + fragmentId: 1 + }, undefined), {}); + const b = new SimpleTile(new EventEntry({ + event: {}, + eventIndex: 3, + fragmentId: 1 + }, undefined), {}); + const d = new DateTile(b, {} as any); + const tiles = [d, b, a]; + tiles.sort((a, b) => a.compare(b)); + assert.equal(tiles[0], a); + assert.equal(tiles[1], d); + assert.equal(tiles[2], b); + } + } +} \ No newline at end of file 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..7cb9617d 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,22 @@ 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) { + this._needsDateSeparator = prev._date.getFullYear() !== this._date.getFullYear() || + prev._date.getMonth() !== this._date.getMonth() || + prev._date.getDate() !== this._date.getDate(); + } else { + this._needsDateSeparator = !!this._date; + } } get id() { @@ -123,8 +141,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 @@ -160,3 +180,65 @@ export class SimpleTile extends ViewModel { return this._options.timeline.me; } } + +import { EventEntry } from "../../../../../matrix/room/timeline/entries/EventEntry.js"; + +export function tests() { + return { + "needsDateSeparator is false when previous sibling is for same date": assert => { + const fridayEntry = new EventEntry({ + event: { + origin_server_ts: 1669376446222, + type: "m.room.message", + content: {} + } + }, undefined); + const thursdayEntry = new EventEntry({ + event: { + origin_server_ts: fridayEntry.timestamp - (60 * 60 * 8 * 1000), + type: "m.room.message", + content: {} + } + }, undefined); + const fridayTile = new SimpleTile(fridayEntry, {}); + const thursdayTile = new SimpleTile(thursdayEntry, {}); + assert.equal(fridayTile.needsDateSeparator, false); + fridayTile.updatePreviousSibling(thursdayTile); + assert.equal(fridayTile.needsDateSeparator, false); + }, + "needsDateSeparator is true when previous sibling is for different date": assert => { + const fridayEntry = new EventEntry({ + event: { + origin_server_ts: 1669376446222, + type: "m.room.message", + content: {} + } + }, undefined); + const thursdayEntry = new EventEntry({ + event: { + origin_server_ts: fridayEntry.timestamp - (60 * 60 * 24 * 1000), + type: "m.room.message", + content: {} + } + }, undefined); + const fridayTile = new SimpleTile(fridayEntry, {}); + const thursdayTile = new SimpleTile(thursdayEntry, {}); + assert.equal(fridayTile.needsDateSeparator, false); + fridayTile.updatePreviousSibling(thursdayTile); + assert.equal(fridayTile.needsDateSeparator, true); + }, + "needsDateSeparator is true when previous sibling is undefined": assert => { + const fridayEntry = new EventEntry({ + event: { + origin_server_ts: 1669376446222, + type: "m.room.message", + content: {} + } + }, undefined); + const fridayTile = new SimpleTile(fridayEntry, {}); + assert.equal(fridayTile.needsDateSeparator, false); + fridayTile.updatePreviousSibling(undefined); + assert.equal(fridayTile.needsDateSeparator, true); + }, + } +} \ No newline at end of file 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/types/types.ts b/src/platform/types/types.ts index 1d359a09..df7ce6ac 100644 --- a/src/platform/types/types.ts +++ b/src/platform/types/types.ts @@ -16,7 +16,7 @@ limitations under the License. import type {RequestResult} from "../web/dom/request/fetch.js"; import type {RequestBody} from "../../matrix/net/common"; -import type {ILogItem} from "../../logging/types"; +import type { BaseObservableValue } from "../../observable/ObservableValue"; export interface IRequestOptions { uploadProgress?: (loadedBytes: number) => void; @@ -43,3 +43,9 @@ export type File = { readonly name: string; readonly blob: IBlobHandle; } + +export interface ITimeFormatter { + formatTime(date: Date): string; + formatRelativeDate(date: Date): string; + formatMachineReadableDate(date: Date): string; +} \ No newline at end of file diff --git a/src/platform/web/Platform.js b/src/platform/web/Platform.js index 29a83e1f..0d95e585 100644 --- a/src/platform/web/Platform.js +++ b/src/platform/web/Platform.js @@ -39,6 +39,7 @@ import {Disposables} from "../../utils/Disposables"; import {parseHTML} from "./parsehtml.js"; import {handleAvatarError} from "./ui/avatar"; import {ThemeLoader} from "./theming/ThemeLoader"; +import {TimeFormatter} from "./dom/TimeFormatter"; function addScript(src) { return new Promise(function (resolve, reject) { @@ -139,6 +140,7 @@ export class Platform { this._createLogger(options?.development); this.history = new History(); this.onlineStatus = new OnlineStatus(); + this.timeFormatter = new TimeFormatter(); this._serviceWorkerHandler = null; if (assetPaths.serviceWorker && "serviceWorker" in navigator) { this._serviceWorkerHandler = new ServiceWorkerHandler(); diff --git a/src/platform/web/dom/TimeFormatter.ts b/src/platform/web/dom/TimeFormatter.ts new file mode 100644 index 00000000..7db879ea --- /dev/null +++ b/src/platform/web/dom/TimeFormatter.ts @@ -0,0 +1,83 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +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 type { ITimeFormatter } from "../../types/types"; +import {Clock} from "./Clock"; + +enum TimeScope { + Minute = 60 * 1000, + Day = 24 * 60 * 60 * 1000, +} + +export class TimeFormatter implements ITimeFormatter { + + private todayMidnight: Date; + private relativeDayFormatter: Intl.RelativeTimeFormat; + private weekdayFormatter: Intl.DateTimeFormat; + private currentYearFormatter: Intl.DateTimeFormat; + private otherYearFormatter: Intl.DateTimeFormat; + private timeFormatter: Intl.DateTimeFormat; + + constructor(private clock: Clock) { + // don't use the clock time here as the DOM relative formatters don't support setting the reference date anyway + this.todayMidnight = new Date(); + this.todayMidnight.setHours(0, 0, 0, 0); + this.relativeDayFormatter = new Intl.RelativeTimeFormat(undefined, {numeric: "auto"}); + this.weekdayFormatter = new Intl.DateTimeFormat(undefined, {weekday: 'long'}); + this.currentYearFormatter = new Intl.DateTimeFormat(undefined, { + weekday: 'long', + month: 'long', + day: 'numeric' + }); + this.otherYearFormatter = new Intl.DateTimeFormat(undefined, { + weekday: 'long', + year: 'numeric', + month: 'long', + day: 'numeric' + }); + this.timeFormatter = new Intl.DateTimeFormat(undefined, {hour: "numeric", minute: "2-digit"}); + } + + formatTime(date: Date): string { + return this.timeFormatter.format(date); + } + + formatMachineReadableDate(date: Date): string { + return `${date.getFullYear()}-${date.getMonth() + 1}-${date.getDate()}`; + } + + formatRelativeDate(date: Date): string { + let daysDiff = Math.floor((date.getTime() - this.todayMidnight.getTime()) / TimeScope.Day); + console.log("formatRelativeDate daysDiff", daysDiff, date); + if (daysDiff >= -1 && daysDiff <= 1) { + // Tomorrow, Today, Yesterday + return capitalizeFirstLetter(this.relativeDayFormatter.format(daysDiff, "day")); + } else if (daysDiff > -7 && daysDiff < 0) { + // Wednesday + return this.weekdayFormatter.format(date); + } else if (this.todayMidnight.getFullYear() === date.getFullYear()) { + // Friday, November 6 + return this.currentYearFormatter.format(date); + } else { + // Friday, November 5, 2021 + return this.otherYearFormatter.format(date); + } + } +} + +function capitalizeFirstLetter(str: string) { + return str.slice(0, 1).toLocaleUpperCase() + str.slice(1); +} \ No newline at end of file diff --git a/src/platform/web/ui/css/themes/element/timeline.css b/src/platform/web/ui/css/themes/element/timeline.css index 43c57d19..8645bc3f 100644 --- a/src/platform/web/ui/css/themes/element/timeline.css +++ b/src/platform/web/ui/css/themes/element/timeline.css @@ -422,3 +422,22 @@ only loads when the top comes into view*/ .GapView.isAtTop { padding: 52px 20px 12px 20px; } + +.DateHeader { + font-weight: bold; + font-size: 1.5rem; + position: sticky; + top: 5px; +} + +.DateHeader time { + margin: 0 auto; + padding: 12px 4px; + width: 250px; + padding: 12px; + display: block; + color: var(--light-text-color); + background-color: var(--background-color-primary); + border-radius: 8px; + text-align: center; + } \ No newline at end of file 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/BaseMediaView.js b/src/platform/web/ui/session/room/timeline/BaseMediaView.js index 9d534fd1..bc49b3f6 100644 --- a/src/platform/web/ui/session/room/timeline/BaseMediaView.js +++ b/src/platform/web/ui/session/room/timeline/BaseMediaView.js @@ -34,7 +34,7 @@ export class BaseMediaView extends BaseMessageView { const children = [ t.div({className: "spacer", style: spacerStyle}), this.renderMedia(t, vm), - t.time(vm.date + " " + vm.time), + t.time(vm.time), ]; const status = t.div({ className: { 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..3d640568 --- /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.h2({className: "DateHeader"}, t.time({dateTime: vm.machineReadableDate}, vm.relativeDate)); + } + + /* This is called by the parent ListView, which just has 1 listener for the whole list */ + onClick() {} +} diff --git a/src/platform/web/ui/session/room/timeline/FileView.js b/src/platform/web/ui/session/room/timeline/FileView.js index 6a2d418e..ca0eb10e 100644 --- a/src/platform/web/ui/session/room/timeline/FileView.js +++ b/src/platform/web/ui/session/room/timeline/FileView.js @@ -24,7 +24,7 @@ export class FileView extends BaseMessageView { } else { children.push( t.button({className: "link", onClick: () => vm.download()}, vm => vm.label), - t.time(vm.date + " " + vm.time) + t.time(vm.time) ); } return t.p({className: "Timeline_messageBody statusMessage"}, children); diff --git a/src/platform/web/ui/session/room/timeline/LocationView.js b/src/platform/web/ui/session/room/timeline/LocationView.js index de605c6a..e0d2656c 100644 --- a/src/platform/web/ui/session/room/timeline/LocationView.js +++ b/src/platform/web/ui/session/room/timeline/LocationView.js @@ -21,7 +21,7 @@ export class LocationView extends BaseMessageView { return t.p({className: "Timeline_messageBody statusMessage"}, [ t.span(vm.label), t.a({className: "Timeline_locationLink", href: vm.mapsLink, target: "_blank", rel: "noopener"}, vm.i18n`Open in maps`), - t.time(vm.date + " " + vm.time) + t.time(vm.time) ]); } } diff --git a/src/platform/web/ui/session/room/timeline/TextMessageView.js b/src/platform/web/ui/session/room/timeline/TextMessageView.js index 8d6cb4dc..a6741de7 100644 --- a/src/platform/web/ui/session/room/timeline/TextMessageView.js +++ b/src/platform/web/ui/session/room/timeline/TextMessageView.js @@ -20,7 +20,7 @@ import {ReplyPreviewError, ReplyPreviewView} from "./ReplyPreviewView.js"; export class TextMessageView extends BaseMessageView { renderMessageBody(t, vm) { - const time = t.time({className: {hidden: !vm.date}}, vm.date + " " + vm.time); + const time = t.time({className: {hidden: !vm.time}}, vm.time); const container = t.div({ className: { "Timeline_messageBody": true,