Merge pull request #938 from vector-im/bwindels/dateheaders-with-model

Add date headers in timeline (second stab)
This commit is contained in:
Bruno Windels 2022-11-25 16:02:08 +00:00 committed by GitHub
commit 23a325b18d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
18 changed files with 645 additions and 43 deletions

View File

@ -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<T extends object = SegmentType> = {
platform: Platform;
@ -145,4 +146,8 @@ export class ViewModel<N extends object = SegmentType, O extends Options<N> = Op
// typescript needs a little help here
return this._options.navigation as unknown as Navigation<N>;
}
get timeFormatter(): ITimeFormatter {
return this._options.platform.timeFormatter;
}
}

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

View File

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

View File

@ -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<BaseEventEntry> {
private _emitUpdate?: EmitUpdateFn;
private _dateString?: string;
private _machineReadableString?: 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 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<BaseEntry> | 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<BaseEntry> | 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);
}
}
}

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

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

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

View File

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

View File

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

View File

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

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

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

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

View File

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

View File

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

View File

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