mirror of
https://github.com/vector-im/hydrogen-web.git
synced 2025-02-02 07:31:38 +01:00
Merge pull request #938 from vector-im/bwindels/dateheaders-with-model
Add date headers in timeline (second stab)
This commit is contained in:
commit
23a325b18d
@ -29,6 +29,7 @@ import type {ILogger} from "../logging/types";
|
|||||||
import type {Navigation} from "./navigation/Navigation";
|
import type {Navigation} from "./navigation/Navigation";
|
||||||
import type {SegmentType} from "./navigation/index";
|
import type {SegmentType} from "./navigation/index";
|
||||||
import type {IURLRouter} from "./navigation/URLRouter";
|
import type {IURLRouter} from "./navigation/URLRouter";
|
||||||
|
import type { ITimeFormatter } from "../platform/types/types";
|
||||||
|
|
||||||
export type Options<T extends object = SegmentType> = {
|
export type Options<T extends object = SegmentType> = {
|
||||||
platform: Platform;
|
platform: Platform;
|
||||||
@ -145,4 +146,8 @@ export class ViewModel<N extends object = SegmentType, O extends Options<N> = Op
|
|||||||
// typescript needs a little help here
|
// typescript needs a little help here
|
||||||
return this._options.navigation as unknown as Navigation<N>;
|
return this._options.navigation as unknown as Navigation<N>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get timeFormatter(): ITimeFormatter {
|
||||||
|
return this._options.platform.timeFormatter;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -16,6 +16,7 @@ limitations under the License.
|
|||||||
|
|
||||||
import {BaseObservableList} from "../../../../observable/list/BaseObservableList";
|
import {BaseObservableList} from "../../../../observable/list/BaseObservableList";
|
||||||
import {sortedIndex} from "../../../../utils/sortedIndex";
|
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
|
// 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.
|
// for now, tileClassForEntry should be stable in whether it returns a tile or not.
|
||||||
@ -51,6 +52,7 @@ export class TilesCollection extends BaseObservableList {
|
|||||||
}
|
}
|
||||||
|
|
||||||
_populateTiles() {
|
_populateTiles() {
|
||||||
|
this._silent = true;
|
||||||
this._tiles = [];
|
this._tiles = [];
|
||||||
let currentTile = null;
|
let currentTile = null;
|
||||||
for (let entry of this._entries) {
|
for (let entry of this._entries) {
|
||||||
@ -72,11 +74,20 @@ export class TilesCollection extends BaseObservableList {
|
|||||||
if (prevTile) {
|
if (prevTile) {
|
||||||
prevTile.updateNextSibling(null);
|
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,
|
// now everything is wired up,
|
||||||
// allow tiles to emit updates
|
// allow tiles to emit updates
|
||||||
for (const tile of this._tiles) {
|
for (const tile of this._tiles) {
|
||||||
tile.setUpdateEmit(this._emitSpontanousUpdate);
|
tile.setUpdateEmit(this._emitSpontanousUpdate);
|
||||||
}
|
}
|
||||||
|
this._silent = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
_findTileIdx(entry) {
|
_findTileIdx(entry) {
|
||||||
@ -130,25 +141,57 @@ export class TilesCollection extends BaseObservableList {
|
|||||||
|
|
||||||
const newTile = this._createTile(entry);
|
const newTile = this._createTile(entry);
|
||||||
if (newTile) {
|
if (newTile) {
|
||||||
if (prevTile) {
|
this._addTileAt(tileIdx, newTile);
|
||||||
prevTile.updateNextSibling(newTile);
|
this._evaluateDateHeaderAtIdx(tileIdx);
|
||||||
// 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);
|
|
||||||
}
|
}
|
||||||
// find position by sort key
|
// 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)?
|
// 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) {
|
onUpdate(index, entry, params) {
|
||||||
// if an update is emitted while calling source.subscribe() from onSubscribeFirst, ignore it
|
// if an update is emitted while calling source.subscribe() from onSubscribeFirst, ignore it
|
||||||
if (!this._tiles) {
|
if (!this._tiles) {
|
||||||
@ -210,11 +253,16 @@ export class TilesCollection extends BaseObservableList {
|
|||||||
this.emitRemove(tileIdx, tile);
|
this.emitRemove(tileIdx, tile);
|
||||||
prevTile?.updateNextSibling(nextTile);
|
prevTile?.updateNextSibling(nextTile);
|
||||||
nextTile?.updatePreviousSibling(prevTile);
|
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
|
// would also be called when unloading a part of the timeline
|
||||||
onRemove(index, entry) {
|
onRemove(index, entry) {
|
||||||
const tileIdx = this._findTileIdx(entry);
|
const tileIdx = this._findTileIdx(entry);
|
||||||
|
|
||||||
const tile = this._findTileAtIdx(entry, tileIdx);
|
const tile = this._findTileAtIdx(entry, tileIdx);
|
||||||
if (tile) {
|
if (tile) {
|
||||||
const removeTile = tile.removeEntry(entry);
|
const removeTile = tile.removeEntry(entry);
|
||||||
@ -268,6 +316,7 @@ export function tests() {
|
|||||||
constructor(entry) {
|
constructor(entry) {
|
||||||
this.entry = entry;
|
this.entry = entry;
|
||||||
this.update = null;
|
this.update = null;
|
||||||
|
this.needsDateSeparator = false;
|
||||||
}
|
}
|
||||||
setUpdateEmit(update) {
|
setUpdateEmit(update) {
|
||||||
this.update = update;
|
this.update = update;
|
||||||
@ -297,6 +346,34 @@ export function tests() {
|
|||||||
dispose() {}
|
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 {
|
return {
|
||||||
"don't emit update before add": assert => {
|
"don't emit update before add": assert => {
|
||||||
class UpdateOnSiblingTile extends TestTile {
|
class UpdateOnSiblingTile extends TestTile {
|
||||||
@ -355,6 +432,73 @@ export function tests() {
|
|||||||
});
|
});
|
||||||
entries.remove(1);
|
entries.remove(1);
|
||||||
assert.deepEqual(events, ["remove", "update"]);
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -21,7 +21,6 @@ import {getIdentifierColorNumber, avatarInitials, getAvatarHttpUrl} from "../../
|
|||||||
export class BaseMessageTile extends SimpleTile {
|
export class BaseMessageTile extends SimpleTile {
|
||||||
constructor(entry, options) {
|
constructor(entry, options) {
|
||||||
super(entry, options);
|
super(entry, options);
|
||||||
this._date = this._entry.timestamp ? new Date(this._entry.timestamp) : null;
|
|
||||||
this._isContinuation = false;
|
this._isContinuation = false;
|
||||||
this._reactions = null;
|
this._reactions = null;
|
||||||
this._replyTile = null;
|
this._replyTile = null;
|
||||||
@ -78,12 +77,8 @@ export class BaseMessageTile extends SimpleTile {
|
|||||||
return this.sender;
|
return this.sender;
|
||||||
}
|
}
|
||||||
|
|
||||||
get date() {
|
|
||||||
return this._date && this._date.toLocaleDateString({}, {month: "numeric", day: "numeric"});
|
|
||||||
}
|
|
||||||
|
|
||||||
get time() {
|
get time() {
|
||||||
return this._date && this._date.toLocaleTimeString({}, {hour: "numeric", minute: "2-digit"});
|
return this._date && this.timeFormatter.formatTime(this._date);
|
||||||
}
|
}
|
||||||
|
|
||||||
get isOwn() {
|
get isOwn() {
|
||||||
|
181
src/domain/session/room/timeline/tiles/DateTile.ts
Normal file
181
src/domain/session/room/timeline/tiles/DateTile.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -19,6 +19,7 @@ import {UpdateAction} from "../UpdateAction.js";
|
|||||||
import {ConnectionError} from "../../../../../matrix/error.js";
|
import {ConnectionError} from "../../../../../matrix/error.js";
|
||||||
import {ConnectionStatus} from "../../../../../matrix/net/Reconnector";
|
import {ConnectionStatus} from "../../../../../matrix/net/Reconnector";
|
||||||
|
|
||||||
|
// TODO: should this become an ITile and SimpleTile become EventTile?
|
||||||
export class GapTile extends SimpleTile {
|
export class GapTile extends SimpleTile {
|
||||||
constructor(entry, options) {
|
constructor(entry, options) {
|
||||||
super(entry, options);
|
super(entry, options);
|
||||||
@ -29,6 +30,10 @@ export class GapTile extends SimpleTile {
|
|||||||
this._showSpinner = false;
|
this._showSpinner = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get needsDateSeparator() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
async fill() {
|
async fill() {
|
||||||
if (!this._loading && !this._entry.edgeReached) {
|
if (!this._loading && !this._entry.edgeReached) {
|
||||||
this._loading = true;
|
this._loading = true;
|
||||||
|
44
src/domain/session/room/timeline/tiles/ITile.ts
Normal file
44
src/domain/session/room/timeline/tiles/ITile.ts
Normal 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;
|
||||||
|
}
|
@ -15,13 +15,17 @@ limitations under the License.
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import {UpdateAction} from "../UpdateAction.js";
|
import {UpdateAction} from "../UpdateAction.js";
|
||||||
|
import {TileShape} from "./ITile";
|
||||||
import {ViewModel} from "../../../../ViewModel";
|
import {ViewModel} from "../../../../ViewModel";
|
||||||
import {SendStatus} from "../../../../../matrix/room/sending/PendingEvent.js";
|
import {SendStatus} from "../../../../../matrix/room/sending/PendingEvent.js";
|
||||||
|
import {DateTile} from "./DateTile";
|
||||||
|
|
||||||
export class SimpleTile extends ViewModel {
|
export class SimpleTile extends ViewModel {
|
||||||
constructor(entry, options) {
|
constructor(entry, options) {
|
||||||
super(options);
|
super(options);
|
||||||
this._entry = entry;
|
this._entry = entry;
|
||||||
|
this._date = this._entry.timestamp ? new Date(this._entry.timestamp) : undefined;
|
||||||
|
this._needsDateSeparator = false;
|
||||||
this._emitUpdate = undefined;
|
this._emitUpdate = undefined;
|
||||||
}
|
}
|
||||||
// view model props for all subclasses
|
// view model props for all subclasses
|
||||||
@ -37,8 +41,22 @@ export class SimpleTile extends ViewModel {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
get hasDateSeparator() {
|
get needsDateSeparator() {
|
||||||
return false;
|
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() {
|
get id() {
|
||||||
@ -123,8 +141,10 @@ export class SimpleTile extends ViewModel {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
// let item know it has a new sibling
|
// 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
|
// let item know it has a new sibling
|
||||||
@ -160,3 +180,65 @@ export class SimpleTile extends ViewModel {
|
|||||||
return this._options.timeline.me;
|
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);
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
@ -27,7 +27,7 @@ import {EncryptedEventTile} from "./EncryptedEventTile.js";
|
|||||||
import {EncryptionEnabledTile} from "./EncryptionEnabledTile.js";
|
import {EncryptionEnabledTile} from "./EncryptionEnabledTile.js";
|
||||||
import {MissingAttachmentTile} from "./MissingAttachmentTile.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 {Room} from "../../../../../matrix/room/Room";
|
||||||
import type {Timeline} from "../../../../../matrix/room/timeline/Timeline";
|
import type {Timeline} from "../../../../../matrix/room/timeline/Timeline";
|
||||||
import type {FragmentBoundaryEntry} from "../../../../../matrix/room/timeline/entries/FragmentBoundaryEntry";
|
import type {FragmentBoundaryEntry} from "../../../../../matrix/room/timeline/entries/FragmentBoundaryEntry";
|
||||||
@ -42,7 +42,7 @@ export type Options = ViewModelOptions & {
|
|||||||
timeline: Timeline
|
timeline: Timeline
|
||||||
tileClassForEntry: TileClassForEntryFn;
|
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 {
|
export function tileClassForEntry(entry: TimelineEntry): TileConstructor | undefined {
|
||||||
if (entry.isGap) {
|
if (entry.isGap) {
|
||||||
|
@ -16,7 +16,7 @@ limitations under the License.
|
|||||||
|
|
||||||
import type {RequestResult} from "../web/dom/request/fetch.js";
|
import type {RequestResult} from "../web/dom/request/fetch.js";
|
||||||
import type {RequestBody} from "../../matrix/net/common";
|
import type {RequestBody} from "../../matrix/net/common";
|
||||||
import type {ILogItem} from "../../logging/types";
|
import type { BaseObservableValue } from "../../observable/ObservableValue";
|
||||||
|
|
||||||
export interface IRequestOptions {
|
export interface IRequestOptions {
|
||||||
uploadProgress?: (loadedBytes: number) => void;
|
uploadProgress?: (loadedBytes: number) => void;
|
||||||
@ -43,3 +43,9 @@ export type File = {
|
|||||||
readonly name: string;
|
readonly name: string;
|
||||||
readonly blob: IBlobHandle;
|
readonly blob: IBlobHandle;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ITimeFormatter {
|
||||||
|
formatTime(date: Date): string;
|
||||||
|
formatRelativeDate(date: Date): string;
|
||||||
|
formatMachineReadableDate(date: Date): string;
|
||||||
|
}
|
@ -39,6 +39,7 @@ import {Disposables} from "../../utils/Disposables";
|
|||||||
import {parseHTML} from "./parsehtml.js";
|
import {parseHTML} from "./parsehtml.js";
|
||||||
import {handleAvatarError} from "./ui/avatar";
|
import {handleAvatarError} from "./ui/avatar";
|
||||||
import {ThemeLoader} from "./theming/ThemeLoader";
|
import {ThemeLoader} from "./theming/ThemeLoader";
|
||||||
|
import {TimeFormatter} from "./dom/TimeFormatter";
|
||||||
|
|
||||||
function addScript(src) {
|
function addScript(src) {
|
||||||
return new Promise(function (resolve, reject) {
|
return new Promise(function (resolve, reject) {
|
||||||
@ -139,6 +140,7 @@ export class Platform {
|
|||||||
this._createLogger(options?.development);
|
this._createLogger(options?.development);
|
||||||
this.history = new History();
|
this.history = new History();
|
||||||
this.onlineStatus = new OnlineStatus();
|
this.onlineStatus = new OnlineStatus();
|
||||||
|
this.timeFormatter = new TimeFormatter();
|
||||||
this._serviceWorkerHandler = null;
|
this._serviceWorkerHandler = null;
|
||||||
if (assetPaths.serviceWorker && "serviceWorker" in navigator) {
|
if (assetPaths.serviceWorker && "serviceWorker" in navigator) {
|
||||||
this._serviceWorkerHandler = new ServiceWorkerHandler();
|
this._serviceWorkerHandler = new ServiceWorkerHandler();
|
||||||
|
83
src/platform/web/dom/TimeFormatter.ts
Normal file
83
src/platform/web/dom/TimeFormatter.ts
Normal 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);
|
||||||
|
}
|
@ -422,3 +422,22 @@ only loads when the top comes into view*/
|
|||||||
.GapView.isAtTop {
|
.GapView.isAtTop {
|
||||||
padding: 52px 20px 12px 20px;
|
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;
|
||||||
|
}
|
@ -22,31 +22,34 @@ import {LocationView} from "./timeline/LocationView.js";
|
|||||||
import {MissingAttachmentView} from "./timeline/MissingAttachmentView.js";
|
import {MissingAttachmentView} from "./timeline/MissingAttachmentView.js";
|
||||||
import {AnnouncementView} from "./timeline/AnnouncementView.js";
|
import {AnnouncementView} from "./timeline/AnnouncementView.js";
|
||||||
import {RedactedView} from "./timeline/RedactedView.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 {GapView} from "./timeline/GapView.js";
|
||||||
|
import {DateHeaderView} from "./timeline/DateHeaderView";
|
||||||
import type {TileViewConstructor, ViewClassForEntryFn} from "./TimelineView";
|
import type {TileViewConstructor, ViewClassForEntryFn} from "./TimelineView";
|
||||||
|
|
||||||
export function viewClassForTile(vm: SimpleTile): TileViewConstructor {
|
export function viewClassForTile(vm: ITile): TileViewConstructor {
|
||||||
switch (vm.shape) {
|
switch (vm.shape) {
|
||||||
case "gap":
|
case TileShape.Gap:
|
||||||
return GapView;
|
return GapView;
|
||||||
case "announcement":
|
case TileShape.Announcement:
|
||||||
return AnnouncementView;
|
return AnnouncementView;
|
||||||
case "message":
|
case TileShape.Message:
|
||||||
case "message-status":
|
case TileShape.MessageStatus:
|
||||||
return TextMessageView;
|
return TextMessageView;
|
||||||
case "image":
|
case TileShape.Image:
|
||||||
return ImageView;
|
return ImageView;
|
||||||
case "video":
|
case TileShape.Video:
|
||||||
return VideoView;
|
return VideoView;
|
||||||
case "file":
|
case TileShape.File:
|
||||||
return FileView;
|
return FileView;
|
||||||
case "location":
|
case TileShape.Location:
|
||||||
return LocationView;
|
return LocationView;
|
||||||
case "missing-attachment":
|
case TileShape.MissingAttachment:
|
||||||
return MissingAttachmentView;
|
return MissingAttachmentView;
|
||||||
case "redacted":
|
case TileShape.Redacted:
|
||||||
return RedactedView;
|
return RedactedView;
|
||||||
|
case TileShape.DateHeader:
|
||||||
|
return DateHeaderView;
|
||||||
default:
|
default:
|
||||||
throw new Error(`Tiles of shape "${vm.shape}" are not supported, check the tileClassForEntry function in the view model`);
|
throw new Error(`Tiles of shape "${vm.shape}" are not supported, check the tileClassForEntry function in the view model`);
|
||||||
}
|
}
|
||||||
|
@ -34,7 +34,7 @@ export class BaseMediaView extends BaseMessageView {
|
|||||||
const children = [
|
const children = [
|
||||||
t.div({className: "spacer", style: spacerStyle}),
|
t.div({className: "spacer", style: spacerStyle}),
|
||||||
this.renderMedia(t, vm),
|
this.renderMedia(t, vm),
|
||||||
t.time(vm.date + " " + vm.time),
|
t.time(vm.time),
|
||||||
];
|
];
|
||||||
const status = t.div({
|
const status = t.div({
|
||||||
className: {
|
className: {
|
||||||
|
33
src/platform/web/ui/session/room/timeline/DateHeaderView.ts
Normal file
33
src/platform/web/ui/session/room/timeline/DateHeaderView.ts
Normal 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() {}
|
||||||
|
}
|
@ -24,7 +24,7 @@ export class FileView extends BaseMessageView {
|
|||||||
} else {
|
} else {
|
||||||
children.push(
|
children.push(
|
||||||
t.button({className: "link", onClick: () => vm.download()}, vm => vm.label),
|
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);
|
return t.p({className: "Timeline_messageBody statusMessage"}, children);
|
||||||
|
@ -21,7 +21,7 @@ export class LocationView extends BaseMessageView {
|
|||||||
return t.p({className: "Timeline_messageBody statusMessage"}, [
|
return t.p({className: "Timeline_messageBody statusMessage"}, [
|
||||||
t.span(vm.label),
|
t.span(vm.label),
|
||||||
t.a({className: "Timeline_locationLink", href: vm.mapsLink, target: "_blank", rel: "noopener"}, vm.i18n`Open in maps`),
|
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)
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -20,7 +20,7 @@ import {ReplyPreviewError, ReplyPreviewView} from "./ReplyPreviewView.js";
|
|||||||
|
|
||||||
export class TextMessageView extends BaseMessageView {
|
export class TextMessageView extends BaseMessageView {
|
||||||
renderMessageBody(t, vm) {
|
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({
|
const container = t.div({
|
||||||
className: {
|
className: {
|
||||||
"Timeline_messageBody": true,
|
"Timeline_messageBody": true,
|
||||||
|
Loading…
x
Reference in New Issue
Block a user