restore most bottom tile in VP on any list change

and tell view model visible range so it can load more or fill gaps, ...
This commit is contained in:
Bruno Windels 2021-09-07 15:17:27 +02:00
parent ad4ec5f04c
commit c78a83d398
4 changed files with 108 additions and 145 deletions

View file

@ -40,37 +40,14 @@ export class TimelineViewModel extends ViewModel {
const {timeline, tilesCreator} = options;
this._timeline = this.track(timeline);
this._tiles = new TilesCollection(timeline.entries, tilesCreator);
this._timeline.loadAtTop(50);
}
/**
* @return {bool} startReached if the start of the timeline was reached
*/
async loadAtTop() {
if (this.isDisposed) {
// stop loading more, we switched room
return true;
setVisibleTileRange(idx, len) {
console.log("setVisibleTileRange", idx, len);
if (idx < 5) {
this._timeline.loadAtTop(10);
}
const firstTile = this._tiles.getFirst();
if (firstTile?.shape === "gap") {
return await firstTile.fill();
} else {
const topReached = await this._timeline.loadAtTop(10);
return topReached;
}
}
unloadAtTop(/*tileAmount*/) {
// get lowerSortKey for tile at index tileAmount - 1
// tell timeline to unload till there (included given key)
}
loadAtBottom() {
}
unloadAtBottom(/*tileAmount*/) {
// get upperSortKey for tile at index tiles.length - tileAmount
// tell timeline to unload till there (included given key)
}
get tiles() {

View file

@ -15,9 +15,14 @@ limitations under the License.
*/
.RoomView_body > ul {
.RoomView_body > .Timeline {
overflow-y: auto;
overscroll-behavior: contain;
padding: 0;
margin: 0;
}
.RoomView_body > .Timeline > ul {
list-style: none;
padding: 0;
margin: 0;

View file

@ -138,28 +138,22 @@ export class ListView<T, V extends UIView> implements UIView {
}
protected onAdd(idx: number, value: T) {
this.onBeforeListChanged();
const child = this._childCreator(value);
this._childInstances!.splice(idx, 0, child);
insertAt(this._root!, idx, mountView(child, this._mountArgs));
this.onListChanged();
}
protected onRemove(idx: number, value: T) {
this.onBeforeListChanged();
const [child] = this._childInstances!.splice(idx, 1);
child.root().remove();
child.unmount();
this.onListChanged();
}
protected onMove(fromIdx: number, toIdx: number, value: T) {
this.onBeforeListChanged();
const [child] = this._childInstances!.splice(fromIdx, 1);
this._childInstances!.splice(toIdx, 0, child);
child.root().remove();
insertAt(this._root!, toIdx, child.root());
this.onListChanged();
}
protected onUpdate(i: number, value: T, params: any) {
@ -182,9 +176,6 @@ export class ListView<T, V extends UIView> implements UIView {
}
}
protected onBeforeListChanged() {}
protected onListChanged() {}
protected getChildInstanceByIndex(idx: number): V | undefined {
return this._childInstances?.[idx];
}

View file

@ -15,6 +15,7 @@ limitations under the License.
*/
import {ListView} from "../../general/ListView";
import {TemplateView, TemplateBuilder} from "../../general/TemplateView.js";
import {GapView} from "./timeline/GapView.js";
import {TextMessageView} from "./timeline/TextMessageView.js";
import {ImageView} from "./timeline/ImageView.js";
@ -25,6 +26,7 @@ import {AnnouncementView} from "./timeline/AnnouncementView.js";
import {RedactedView} from "./timeline/RedactedView.js";
import {SimpleTile} from "../../../../../domain/session/room/timeline/tiles/SimpleTile.js";
import {TimelineViewModel} from "../../../../../domain/session/room/timeline/TimelineViewModel.js";
import {BaseObservableList as ObservableList} from "../../../../../../observable/list/BaseObservableList.js";
type TileView = GapView | AnnouncementView | TextMessageView |
ImageView | VideoView | FileView | MissingAttachmentView | RedactedView;
@ -46,16 +48,86 @@ export function viewClassForEntry(entry: SimpleTile): TileViewConstructor | unde
}
}
export class TimelineView extends ListView<SimpleTile, TileView> {
function bottom(node: HTMLElement): number {
return node.offsetTop + node.clientHeight;
}
private _atBottom: boolean;
private _topLoadingPromise?: Promise<boolean>;
private _viewModel: TimelineViewModel;
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 -1;
}
constructor(viewModel: TimelineViewModel) {
export class TimelineView extends TemplateView<TimelineViewModel> {
private anchoredNode?: HTMLElement;
private anchoredBottom: number = 0;
private stickToBottom: boolean = true;
render(t: TemplateBuilder, vm: TimelineViewModel) {
return t.div({className: "Timeline bottom-aligned-scroll", onScroll: () => this.onScroll()}, [
t.view(new TilesListView(vm.tiles, () => this._restoreScrollPosition()))
]);
}
private _restoreScrollPosition() {
const timeline = this.root() as HTMLElement;
const tiles = timeline.firstElementChild as HTMLElement;
const missingTilesHeight = timeline.clientHeight - tiles.clientHeight;
if (missingTilesHeight > 0) {
tiles.style.setProperty("margin-top", `${missingTilesHeight}px`);
} else {
tiles.style.removeProperty("margin-top");
if (this.stickToBottom) {
timeline.scrollTop = timeline.scrollHeight;
} else if (this.anchoredNode) {
const newAnchoredBottom = bottom(this.anchoredNode!);
if (newAnchoredBottom !== this.anchoredBottom) {
const bottomDiff = newAnchoredBottom - this.anchoredBottom;
console.log(`restore: scroll by ${bottomDiff} as height changed`);
timeline.scrollBy(0, bottomDiff);
this.anchoredBottom = newAnchoredBottom;
} else {
console.log("restore: bottom didn't change, must be below viewport");
}
}
}
}
private onScroll(): void {
const timeline = this.root() as HTMLElement;
const {scrollHeight, scrollTop, clientHeight} = timeline;
const tiles = timeline.firstElementChild as HTMLElement;
this.stickToBottom = Math.abs(scrollHeight - (scrollTop + clientHeight)) < 5;
if (!this.stickToBottom) {
// save bottom node position
const viewportBottom = scrollTop + clientHeight;
const anchoredNodeIndex = findFirstNodeIndexAtOrBelow(tiles, viewportBottom);
let topNodeIndex = findFirstNodeIndexAtOrBelow(tiles, scrollTop, anchoredNodeIndex);
if (topNodeIndex === -1) {
topNodeIndex = 0;
}
this.anchoredNode = tiles.childNodes[anchoredNodeIndex] as HTMLElement;
this.anchoredNode.classList.add("pinned");
this.anchoredBottom = bottom(this.anchoredNode!);
this.value.setVisibleTileRange(topNodeIndex, anchoredNodeIndex - topNodeIndex);
}
}
}
class TilesListView extends ListView<SimpleTile, TileView> {
private onChanged: () => void;
constructor(tiles: ObservableList<SimpleTile>, onChanged: () => void) {
const options = {
className: "Timeline bottom-aligned-scroll",
list: viewModel.tiles,
list: tiles,
onItemClick: (tileView, evt) => tileView.onClick(evt),
};
super(options, entry => {
@ -64,108 +136,10 @@ export class TimelineView extends ListView<SimpleTile, TileView> {
return new View(entry);
}
});
this._atBottom = false;
this._topLoadingPromise = undefined;
this._viewModel = viewModel;
this.onChanged = onChanged;
}
override handleEvent(evt: Event) {
if (evt.type === "scroll") {
this._handleScroll(evt);
} else {
super.handleEvent(evt);
}
}
async _loadAtTopWhile(predicate: () => boolean) {
if (this._topLoadingPromise) {
return;
}
try {
while (predicate()) {
// fill, not enough content to fill timeline
this._topLoadingPromise = this._viewModel.loadAtTop();
const shouldStop = await this._topLoadingPromise;
if (shouldStop) {
break;
}
}
}
catch (err) {
console.error(err);
//ignore error, as it is handled in the VM
}
finally {
this._topLoadingPromise = undefined;
}
}
async _handleScroll(evt: Event) {
const PAGINATE_OFFSET = 100;
const root = this.root();
if (root.scrollTop < PAGINATE_OFFSET && !this._topLoadingPromise && this._viewModel) {
// to calculate total amountGrown to check when we stop loading
let beforeContentHeight = root.scrollHeight;
// to adjust scrollTop every time
let lastContentHeight = beforeContentHeight;
// load until pagination offset is reached again
this._loadAtTopWhile(() => {
const contentHeight = root.scrollHeight;
const amountGrown = contentHeight - beforeContentHeight;
const topDiff = contentHeight - lastContentHeight;
root.scrollBy(0, topDiff);
lastContentHeight = contentHeight;
return amountGrown < PAGINATE_OFFSET;
});
}
}
override mount() {
const root = super.mount();
root.addEventListener("scroll", this);
return root;
}
override unmount() {
this.root().removeEventListener("scroll", this);
super.unmount();
}
override async loadList() {
super.loadList();
const root = this.root();
// yield so the browser can render the list
// and we can measure the content below
await Promise.resolve();
const {scrollHeight, clientHeight} = root;
if (scrollHeight > clientHeight) {
root.scrollTop = root.scrollHeight;
}
// load while viewport is not filled
this._loadAtTopWhile(() => {
const {scrollHeight, clientHeight} = root;
return scrollHeight <= clientHeight;
});
}
override onBeforeListChanged() {
const fromBottom = this._distanceFromBottom();
this._atBottom = fromBottom < 1;
}
_distanceFromBottom() {
const root = this.root();
return root.scrollHeight - root.scrollTop - root.clientHeight;
}
override onListChanged() {
const root = this.root();
if (this._atBottom) {
root.scrollTop = root.scrollHeight;
}
}
override onUpdate(index: number, value: SimpleTile, param: any) {
protected onUpdate(index: number, value: SimpleTile, param: any) {
if (param === "shape") {
const ExpectedClass = viewClassForEntry(value);
const child = this.getChildInstanceByIndex(index);
@ -178,5 +152,21 @@ export class TimelineView extends ListView<SimpleTile, TileView> {
}
}
super.onUpdate(index, value, param);
this.onChanged();
}
protected onAdd(idx: number, value: SimpleTile) {
super.onAdd(idx, value);
this.onChanged();
}
protected onRemove(idx: number, value: SimpleTile) {
super.onRemove(idx, value);
this.onChanged();
}
protected onMove(fromIdx: number, toIdx: number, value: SimpleTile) {
super.onMove(fromIdx, toIdx, value);
this.onChanged();
}
}