This repository has been archived on 2022-08-19. You can view files and clone it, but cannot push or open issues or pull requests.
hydrogen-web/src/platform/web/ui/session/room/TimelineView.ts
Bruno Windels 457c096c9b render date separator in base class for all tiles
apart from gap, which doesn't have date

since we add a container when the date separator needs to be shown, and
we don't want to rerender the whole tile, we always render timeline
tiles with a DIV rather than an LI (same for the UL).
2022-06-25 12:50:53 +02:00

239 lines
9 KiB
TypeScript

/*
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 {ListView} from "../../general/ListView";
import type {IView} from "../../general/types";
import {TemplateView, Builder} from "../../general/TemplateView";
import {IObservableValue} from "../../general/BaseUpdateView";
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 {BaseObservableList as ObservableList} from "../../../../../observable/list/BaseObservableList";
export interface TileView extends IView {
readonly value: SimpleTile;
onClick(event: UIEvent);
}
export type TileViewConstructor = new (
tile: SimpleTile,
viewClassForTile: ViewClassForEntryFn,
renderFlags?: { reply?: boolean, interactive?: boolean }
) => TileView;
export type ViewClassForEntryFn = (tile: SimpleTile) => TileViewConstructor;
//import {TimelineViewModel} from "../../../../../domain/session/room/timeline/TimelineViewModel.js";
export interface TimelineViewModel extends IObservableValue {
showJumpDown: boolean;
tiles: ObservableList<SimpleTile>;
setVisibleTileRange(start?: SimpleTile, end?: SimpleTile);
}
function bottom(node: HTMLElement): number {
return node.offsetTop + node.clientHeight;
}
function findFirstNodeIndexAtOrBelow(tiles: HTMLElement, top: number, startIndex: number = (tiles.children.length - 1)): number {
for (var i = startIndex; i >= 0; i--) {
const node = tiles.children[i] as HTMLElement;
if (node.offsetTop < top) {
return i;
}
}
// return first item if nothing matched before
return 0;
}
export class TimelineView extends TemplateView<TimelineViewModel> {
private anchoredNode?: HTMLElement;
private anchoredBottom: number = 0;
private stickToBottom: boolean = true;
private tilesView?: TilesListView;
private resizeObserver?: ResizeObserver;
constructor(vm: TimelineViewModel, private readonly viewClassForTile: ViewClassForEntryFn) {
super(vm);
}
render(t: Builder<TimelineViewModel>, vm: TimelineViewModel) {
// assume this view will be mounted in the parent DOM straight away
requestAnimationFrame(() => {
// do initial scroll positioning
this.restoreScrollPosition();
});
this.tilesView = new TilesListView(vm.tiles, () => this.restoreScrollPosition(), this.viewClassForTile);
const root = t.div({className: "Timeline"}, [
t.div({
className: "Timeline_scroller bottom-aligned-scroll",
onScroll: () => this.onScroll()
}, t.view(this.tilesView)),
t.button({
className: {
"Timeline_jumpDown": true,
hidden: vm => !vm.showJumpDown
},
title: "Jump down",
onClick: () => this.jumpDown()
})
]);
if (typeof ResizeObserver === "function") {
this.resizeObserver = new ResizeObserver(() => {
this.restoreScrollPosition();
});
this.resizeObserver.observe(root);
}
return root;
}
private get scrollNode(): HTMLElement {
return (this.root()! as HTMLElement).firstElementChild! as HTMLElement;
}
private get tilesNode(): HTMLElement {
return this.tilesView!.root()! as HTMLElement;
}
private jumpDown() {
const {scrollNode} = this;
this.stickToBottom = true;
scrollNode.scrollTop = scrollNode.scrollHeight;
}
public unmount() {
super.unmount();
if (this.resizeObserver) {
this.resizeObserver.unobserve(this.root()! as Element);
this.resizeObserver = undefined;
}
}
private restoreScrollPosition() {
const {scrollNode, tilesNode} = this;
const missingTilesHeight = scrollNode.clientHeight - tilesNode.clientHeight;
if (missingTilesHeight > 0) {
tilesNode.style.setProperty("margin-top", `${missingTilesHeight}px`);
// we don't have enough tiles to fill the viewport, so set all as visible
const len = this.value.tiles.length;
this.updateVisibleRange(0, len - 1);
} else {
tilesNode.style.removeProperty("margin-top");
if (this.stickToBottom) {
scrollNode.scrollTop = scrollNode.scrollHeight;
} else if (this.anchoredNode) {
const newAnchoredBottom = bottom(this.anchoredNode!);
if (newAnchoredBottom !== this.anchoredBottom) {
const bottomDiff = newAnchoredBottom - this.anchoredBottom;
// scrollBy tends to create less scroll jumps than reassigning scrollTop as it does
// not depend on reading scrollTop, which might be out of date as some platforms
// run scrolling off the main thread.
if (typeof scrollNode.scrollBy === "function") {
scrollNode.scrollBy(0, bottomDiff);
} else {
scrollNode.scrollTop = scrollNode.scrollTop + bottomDiff;
}
this.anchoredBottom = newAnchoredBottom;
}
}
// TODO: should we be updating the visible range here as well as the range might have changed even though
// we restored the bottom tile
}
}
private onScroll(): void {
const {scrollNode, tilesNode} = this;
const {scrollHeight, scrollTop, clientHeight} = scrollNode;
let bottomNodeIndex;
this.stickToBottom = Math.abs(scrollHeight - (scrollTop + clientHeight)) < 1;
if (this.stickToBottom) {
const len = this.value.tiles.length;
bottomNodeIndex = len - 1;
} else {
const viewportBottom = scrollTop + clientHeight;
const anchoredNodeIndex = findFirstNodeIndexAtOrBelow(tilesNode, viewportBottom);
this.anchoredNode = tilesNode.childNodes[anchoredNodeIndex] as HTMLElement;
this.anchoredBottom = bottom(this.anchoredNode!);
bottomNodeIndex = anchoredNodeIndex;
}
let topNodeIndex = findFirstNodeIndexAtOrBelow(tilesNode, scrollTop, bottomNodeIndex);
this.updateVisibleRange(topNodeIndex, bottomNodeIndex);
}
private updateVisibleRange(startIndex: number, endIndex: number) {
// can be undefined, meaning the tiles collection is still empty
const firstVisibleChild = this.tilesView!.getChildInstanceByIndex(startIndex);
const lastVisibleChild = this.tilesView!.getChildInstanceByIndex(endIndex);
this.value.setVisibleTileRange(firstVisibleChild?.value, lastVisibleChild?.value);
}
}
class TilesListView extends ListView<SimpleTile, TileView> {
private onChanged: () => void;
constructor(tiles: ObservableList<SimpleTile>, onChanged: () => void, private readonly viewClassForTile: ViewClassForEntryFn) {
super({
list: tiles,
onItemClick: (tileView, evt) => tileView.onClick(evt),
tagName: "div"
}, tile => {
const TileView = viewClassForTile(tile);
return new TileView(tile, viewClassForTile);
});
this.onChanged = onChanged;
}
onReset() {
super.onReset();
this.onChanged();
}
onUpdate(index: number, value: SimpleTile, param: any) {
if (param === "shape") {
const ExpectedClass = this.viewClassForTile(value);
const child = this.getChildInstanceByIndex(index);
if (!ExpectedClass || !(child instanceof ExpectedClass)) {
// shape was updated, so we need to recreate the tile view,
// the shape parameter is set in EncryptedEventTile.updateEntry
// (and perhaps elsewhere by the time you read this)
super.recreateItem(index, value);
return;
}
}
super.onUpdate(index, value, param);
this.onChanged();
}
onAdd(idx: number, value: SimpleTile) {
super.onAdd(idx, value);
this.onChanged();
}
onRemove(idx: number, value: SimpleTile) {
super.onRemove(idx, value);
this.onChanged();
}
onMove(fromIdx: number, toIdx: number, value: SimpleTile) {
super.onMove(fromIdx, toIdx, value);
this.onChanged();
}
}