From c78a83d3989433e9daa041d718a68f36631e19f9 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Tue, 7 Sep 2021 15:17:27 +0200 Subject: [PATCH 01/41] restore most bottom tile in VP on any list change and tell view model visible range so it can load more or fill gaps, ... --- .../room/timeline/TimelineViewModel.js | 33 +-- src/platform/web/ui/css/timeline.css | 7 +- src/platform/web/ui/general/ListView.ts | 9 - .../web/ui/session/room/TimelineView.ts | 204 +++++++++--------- 4 files changed, 108 insertions(+), 145 deletions(-) diff --git a/src/domain/session/room/timeline/TimelineViewModel.js b/src/domain/session/room/timeline/TimelineViewModel.js index a08ab060..84c3d4c9 100644 --- a/src/domain/session/room/timeline/TimelineViewModel.js +++ b/src/domain/session/room/timeline/TimelineViewModel.js @@ -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() { diff --git a/src/platform/web/ui/css/timeline.css b/src/platform/web/ui/css/timeline.css index af2d6c14..28ebf7bd 100644 --- a/src/platform/web/ui/css/timeline.css +++ b/src/platform/web/ui/css/timeline.css @@ -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; diff --git a/src/platform/web/ui/general/ListView.ts b/src/platform/web/ui/general/ListView.ts index 16cbce67..58ed9db3 100644 --- a/src/platform/web/ui/general/ListView.ts +++ b/src/platform/web/ui/general/ListView.ts @@ -138,28 +138,22 @@ export class ListView 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 implements UIView { } } - protected onBeforeListChanged() {} - protected onListChanged() {} - protected getChildInstanceByIndex(idx: number): V | undefined { return this._childInstances?.[idx]; } diff --git a/src/platform/web/ui/session/room/TimelineView.ts b/src/platform/web/ui/session/room/TimelineView.ts index 6768bb8c..6050b02a 100644 --- a/src/platform/web/ui/session/room/TimelineView.ts +++ b/src/platform/web/ui/session/room/TimelineView.ts @@ -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 { +function bottom(node: HTMLElement): number { + return node.offsetTop + node.clientHeight; +} - private _atBottom: boolean; - private _topLoadingPromise?: Promise; - 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 { + + 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 { + + private onChanged: () => void; + + constructor(tiles: ObservableList, 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 { 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 { } } 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(); } } From b3cd2a0e03161fdfe02240d38c893e6f834d8194 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Tue, 7 Sep 2021 17:48:49 +0200 Subject: [PATCH 02/41] express the visible range with EventKeys rather than list indices This is less ambiguous in case the DOM and the ObservableList would be out of sync. --- src/domain/session/room/ComposerViewModel.js | 2 +- .../room/timeline/TimelineViewModel.js | 9 ++-- .../session/room/timeline/tiles/SimpleTile.js | 4 +- src/platform/web/ui/general/ListView.ts | 2 +- .../web/ui/session/room/TimelineView.ts | 41 +++++++++++++------ .../session/room/timeline/AnnouncementView.js | 3 ++ .../session/room/timeline/BaseMessageView.js | 3 ++ .../web/ui/session/room/timeline/GapView.js | 6 +++ 8 files changed, 48 insertions(+), 22 deletions(-) diff --git a/src/domain/session/room/ComposerViewModel.js b/src/domain/session/room/ComposerViewModel.js index ba4627ca..dad0fd68 100644 --- a/src/domain/session/room/ComposerViewModel.js +++ b/src/domain/session/room/ComposerViewModel.js @@ -25,7 +25,7 @@ export class ComposerViewModel extends ViewModel { } setReplyingTo(entry) { - const changed = this._replyVM?.internalId !== entry?.asEventKey().toString(); + const changed = this._replyVM?.id?.equals(entry?.asEventKey()); if (changed) { this._replyVM = this.disposeTracked(this._replyVM); if (entry) { diff --git a/src/domain/session/room/timeline/TimelineViewModel.js b/src/domain/session/room/timeline/TimelineViewModel.js index 84c3d4c9..c4c428d6 100644 --- a/src/domain/session/room/timeline/TimelineViewModel.js +++ b/src/domain/session/room/timeline/TimelineViewModel.js @@ -34,6 +34,8 @@ when loading, it just reads events from a sortkey backwards or forwards... import {TilesCollection} from "./TilesCollection.js"; import {ViewModel} from "../../../ViewModel.js"; + + export class TimelineViewModel extends ViewModel { constructor(options) { super(options); @@ -43,11 +45,8 @@ export class TimelineViewModel extends ViewModel { this._timeline.loadAtTop(50); } - setVisibleTileRange(idx, len) { - console.log("setVisibleTileRange", idx, len); - if (idx < 5) { - this._timeline.loadAtTop(10); - } + setVisibleTileRange(startId, endId) { + console.log("setVisibleTileRange", startId, endId); } get tiles() { diff --git a/src/domain/session/room/timeline/tiles/SimpleTile.js b/src/domain/session/room/timeline/tiles/SimpleTile.js index 3c370b72..412d9737 100644 --- a/src/domain/session/room/timeline/tiles/SimpleTile.js +++ b/src/domain/session/room/timeline/tiles/SimpleTile.js @@ -40,8 +40,8 @@ export class SimpleTile extends ViewModel { return false; } - get internalId() { - return this._entry.asEventKey().toString(); + get id() { + return this._entry.asEventKey(); } get isPending() { diff --git a/src/platform/web/ui/general/ListView.ts b/src/platform/web/ui/general/ListView.ts index 58ed9db3..2c45fb1c 100644 --- a/src/platform/web/ui/general/ListView.ts +++ b/src/platform/web/ui/general/ListView.ts @@ -176,7 +176,7 @@ export class ListView implements UIView { } } - protected getChildInstanceByIndex(idx: number): V | undefined { + public getChildInstanceByIndex(idx: number): V | undefined { return this._childInstances?.[idx]; } } diff --git a/src/platform/web/ui/session/room/TimelineView.ts b/src/platform/web/ui/session/room/TimelineView.ts index 6050b02a..2faad17c 100644 --- a/src/platform/web/ui/session/room/TimelineView.ts +++ b/src/platform/web/ui/session/room/TimelineView.ts @@ -26,7 +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"; +import {BaseObservableList as ObservableList} from "../../../../../observable/list/BaseObservableList.js"; type TileView = GapView | AnnouncementView | TextMessageView | ImageView | VideoView | FileView | MissingAttachmentView | RedactedView; @@ -59,7 +59,8 @@ function findFirstNodeIndexAtOrBelow(tiles: HTMLElement, top: number, startIndex return i; } } - return -1; + // return first item if nothing matched before + return 0; } export class TimelineView extends TemplateView { @@ -67,20 +68,25 @@ export class TimelineView extends TemplateView { private anchoredNode?: HTMLElement; private anchoredBottom: number = 0; private stickToBottom: boolean = true; + private tilesView?: TilesListView; render(t: TemplateBuilder, vm: TimelineViewModel) { + this.tilesView = new TilesListView(vm.tiles, () => this.restoreScrollPosition()); return t.div({className: "Timeline bottom-aligned-scroll", onScroll: () => this.onScroll()}, [ - t.view(new TilesListView(vm.tiles, () => this._restoreScrollPosition())) + t.view(this.tilesView) ]); } - private _restoreScrollPosition() { + private restoreScrollPosition() { const timeline = this.root() as HTMLElement; - const tiles = timeline.firstElementChild as HTMLElement; + const tiles = this.tilesView!.root() as HTMLElement; const missingTilesHeight = timeline.clientHeight - tiles.clientHeight; if (missingTilesHeight > 0) { tiles.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 { tiles.style.removeProperty("margin-top"); if (this.stickToBottom) { @@ -102,21 +108,30 @@ export class TimelineView extends TemplateView { private onScroll(): void { const timeline = this.root() as HTMLElement; const {scrollHeight, scrollTop, clientHeight} = timeline; - const tiles = timeline.firstElementChild as HTMLElement; + const tiles = this.tilesView!.root() as HTMLElement; + let bottomNodeIndex; this.stickToBottom = Math.abs(scrollHeight - (scrollTop + clientHeight)) < 5; - if (!this.stickToBottom) { - // save bottom node position + if (this.stickToBottom) { + const len = this.value.tiles.length; + bottomNodeIndex = len - 1; + } else { 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); + bottomNodeIndex = anchoredNodeIndex; + } + let topNodeIndex = findFirstNodeIndexAtOrBelow(tiles, scrollTop, bottomNodeIndex); + this.updateVisibleRange(topNodeIndex, bottomNodeIndex); + } + + private updateVisibleRange(startIndex: number, endIndex: number) { + const firstVisibleChild = this.tilesView!.getChildInstanceByIndex(startIndex); + const lastVisibleChild = this.tilesView!.getChildInstanceByIndex(endIndex); + if (firstVisibleChild && lastVisibleChild) { + this.value.setVisibleTileRange(firstVisibleChild.id, lastVisibleChild.id); } } } diff --git a/src/platform/web/ui/session/room/timeline/AnnouncementView.js b/src/platform/web/ui/session/room/timeline/AnnouncementView.js index 2dd58b32..c7f47073 100644 --- a/src/platform/web/ui/session/room/timeline/AnnouncementView.js +++ b/src/platform/web/ui/session/room/timeline/AnnouncementView.js @@ -23,4 +23,7 @@ export class AnnouncementView extends TemplateView { /* This is called by the parent ListView, which just has 1 listener for the whole list */ onClick() {} + + /** Used by TimelineView to get the id of a tile when setting the visible range */ + get id() { return this.value.id; } } diff --git a/src/platform/web/ui/session/room/timeline/BaseMessageView.js b/src/platform/web/ui/session/room/timeline/BaseMessageView.js index 9469d201..8f6fa22f 100644 --- a/src/platform/web/ui/session/room/timeline/BaseMessageView.js +++ b/src/platform/web/ui/session/room/timeline/BaseMessageView.js @@ -85,6 +85,9 @@ export class BaseMessageView extends TemplateView { this._toggleMenu(evt.target); } } + + /** Used by TimelineView to get the id of a tile when setting the visible range */ + get id() { return this.value.id; } _toggleMenu(button) { if (this._menuPopup && this._menuPopup.isOpen) { diff --git a/src/platform/web/ui/session/room/timeline/GapView.js b/src/platform/web/ui/session/room/timeline/GapView.js index 1e6e0af0..0055d72c 100644 --- a/src/platform/web/ui/session/room/timeline/GapView.js +++ b/src/platform/web/ui/session/room/timeline/GapView.js @@ -29,4 +29,10 @@ export class GapView extends TemplateView { t.if(vm => vm.error, t => t.strong(vm => vm.error)) ]); } + + /* This is called by the parent ListView, which just has 1 listener for the whole list */ + onClick() {} + + /** Used by TimelineView to get the id of a tile when setting the visible range */ + get id() { return this.value.id; } } From f4b4638ea8a2faad2e28c6a382255cb1a477bdfa Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Tue, 7 Sep 2021 19:10:53 +0200 Subject: [PATCH 03/41] actually, pass in just the tile instances for expressing the range --- src/domain/session/room/timeline/TimelineViewModel.js | 3 +-- src/platform/web/ui/session/room/TimelineView.ts | 3 +-- src/platform/web/ui/session/room/timeline/AnnouncementView.js | 3 --- src/platform/web/ui/session/room/timeline/BaseMessageView.js | 3 --- src/platform/web/ui/session/room/timeline/GapView.js | 3 --- 5 files changed, 2 insertions(+), 13 deletions(-) diff --git a/src/domain/session/room/timeline/TimelineViewModel.js b/src/domain/session/room/timeline/TimelineViewModel.js index c4c428d6..dd115e79 100644 --- a/src/domain/session/room/timeline/TimelineViewModel.js +++ b/src/domain/session/room/timeline/TimelineViewModel.js @@ -45,8 +45,7 @@ export class TimelineViewModel extends ViewModel { this._timeline.loadAtTop(50); } - setVisibleTileRange(startId, endId) { - console.log("setVisibleTileRange", startId, endId); + setVisibleTileRange(startTile, endTile) { } get tiles() { diff --git a/src/platform/web/ui/session/room/TimelineView.ts b/src/platform/web/ui/session/room/TimelineView.ts index 2faad17c..969cd254 100644 --- a/src/platform/web/ui/session/room/TimelineView.ts +++ b/src/platform/web/ui/session/room/TimelineView.ts @@ -119,7 +119,6 @@ export class TimelineView extends TemplateView { const viewportBottom = scrollTop + clientHeight; const anchoredNodeIndex = findFirstNodeIndexAtOrBelow(tiles, viewportBottom); this.anchoredNode = tiles.childNodes[anchoredNodeIndex] as HTMLElement; - this.anchoredNode.classList.add("pinned"); this.anchoredBottom = bottom(this.anchoredNode!); bottomNodeIndex = anchoredNodeIndex; } @@ -131,7 +130,7 @@ export class TimelineView extends TemplateView { const firstVisibleChild = this.tilesView!.getChildInstanceByIndex(startIndex); const lastVisibleChild = this.tilesView!.getChildInstanceByIndex(endIndex); if (firstVisibleChild && lastVisibleChild) { - this.value.setVisibleTileRange(firstVisibleChild.id, lastVisibleChild.id); + this.value.setVisibleTileRange(firstVisibleChild.value, lastVisibleChild.value); } } } diff --git a/src/platform/web/ui/session/room/timeline/AnnouncementView.js b/src/platform/web/ui/session/room/timeline/AnnouncementView.js index c7f47073..2dd58b32 100644 --- a/src/platform/web/ui/session/room/timeline/AnnouncementView.js +++ b/src/platform/web/ui/session/room/timeline/AnnouncementView.js @@ -23,7 +23,4 @@ export class AnnouncementView extends TemplateView { /* This is called by the parent ListView, which just has 1 listener for the whole list */ onClick() {} - - /** Used by TimelineView to get the id of a tile when setting the visible range */ - get id() { return this.value.id; } } diff --git a/src/platform/web/ui/session/room/timeline/BaseMessageView.js b/src/platform/web/ui/session/room/timeline/BaseMessageView.js index 8f6fa22f..9469d201 100644 --- a/src/platform/web/ui/session/room/timeline/BaseMessageView.js +++ b/src/platform/web/ui/session/room/timeline/BaseMessageView.js @@ -85,9 +85,6 @@ export class BaseMessageView extends TemplateView { this._toggleMenu(evt.target); } } - - /** Used by TimelineView to get the id of a tile when setting the visible range */ - get id() { return this.value.id; } _toggleMenu(button) { if (this._menuPopup && this._menuPopup.isOpen) { diff --git a/src/platform/web/ui/session/room/timeline/GapView.js b/src/platform/web/ui/session/room/timeline/GapView.js index 0055d72c..afd22bd3 100644 --- a/src/platform/web/ui/session/room/timeline/GapView.js +++ b/src/platform/web/ui/session/room/timeline/GapView.js @@ -32,7 +32,4 @@ export class GapView extends TemplateView { /* This is called by the parent ListView, which just has 1 listener for the whole list */ onClick() {} - - /** Used by TimelineView to get the id of a tile when setting the visible range */ - get id() { return this.value.id; } } From 7578bfa3d9cff04912570af1399a027bb2776f6d Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Wed, 8 Sep 2021 12:05:19 +0200 Subject: [PATCH 04/41] let tiles know when they become visible & load more tiles close to top --- .../session/room/timeline/TilesCollection.js | 16 +++++++++ .../room/timeline/TimelineViewModel.js | 35 +++++++++++++++++-- .../session/room/timeline/tiles/SimpleTile.js | 8 +++++ .../web/ui/session/room/TimelineView.ts | 8 ++--- 4 files changed, 61 insertions(+), 6 deletions(-) diff --git a/src/domain/session/room/timeline/TilesCollection.js b/src/domain/session/room/timeline/TilesCollection.js index 10062af2..23d863d4 100644 --- a/src/domain/session/room/timeline/TilesCollection.js +++ b/src/domain/session/room/timeline/TilesCollection.js @@ -236,6 +236,22 @@ export class TilesCollection extends BaseObservableList { getFirst() { return this._tiles[0]; } + + getTileIndex(searchTile) { + const idx = sortedIndex(this._tiles, searchTile, (searchTile, tile) => { + // negate result because we're switching the order of the params + return searchTile.compare(tile); + }); + const foundTile = this._tiles[idx]; + if (foundTile?.compare(searchTile) === 0) { + return idx; + } + return -1; + } + + sliceIterator(start, end) { + return this._tiles.slice(start, end)[Symbol.iterator](); + } } import {ObservableArray} from "../../../../observable/list/ObservableArray.js"; diff --git a/src/domain/session/room/timeline/TimelineViewModel.js b/src/domain/session/room/timeline/TimelineViewModel.js index dd115e79..0e28cb6e 100644 --- a/src/domain/session/room/timeline/TimelineViewModel.js +++ b/src/domain/session/room/timeline/TimelineViewModel.js @@ -42,10 +42,41 @@ 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); + this._startTile = null; + this._endTile = null; + this._topLoadingPromise = null; + this._bottomLoadingPromise = null; } - setVisibleTileRange(startTile, endTile) { + setVisibleTileRange(startTile, endTile, isViewportFilled) { + // we should async batch things here? + + // this will prevent a (small) inserted tile from being marked visible, won't it? + if (this._startTile === startTile && this._endTile === endTile) { + return; + } + + // old tiles could have been removed from tilescollection once we support unloading + const oldStartIndex = this._startTile ? this._tiles.getTileIndex(this._startTile) : Number.MAX_SAFE_INTEGER; + const oldEndIndex = this._endTile ? this._tiles.getTileIndex(this._endTile) : Number.MIN_SAFE_INTEGER; + const newStartIndex = this._tiles.getTileIndex(startTile); + const newEndIndex = this._tiles.getTileIndex(endTile); + + const minIndex = Math.min(oldStartIndex, newStartIndex); + const maxIndex = Math.max(oldEndIndex, newEndIndex); + + let index = minIndex; + for (const tile of this._tiles.sliceIterator(minIndex, maxIndex)) { + const isVisible = index >= newStartIndex && index <= newEndIndex; + tile.setVisible(isVisible); + index += 1; + } + + if (!isViewportFilled || (newStartIndex < 5 && !this._topLoadingPromise)) { + this._topLoadingPromise = this._timeline.loadAtTop(10).then(() => { + this._topLoadingPromise = null; + }); + } } get tiles() { diff --git a/src/domain/session/room/timeline/tiles/SimpleTile.js b/src/domain/session/room/timeline/tiles/SimpleTile.js index 412d9737..96679c97 100644 --- a/src/domain/session/room/timeline/tiles/SimpleTile.js +++ b/src/domain/session/room/timeline/tiles/SimpleTile.js @@ -83,6 +83,10 @@ export class SimpleTile extends ViewModel { return this._entry; } + compare(tile) { + return this.upperEntry.compare(tile.upperEntry); + } + compareEntry(entry) { return this._entry.compare(entry); } @@ -119,6 +123,10 @@ export class SimpleTile extends ViewModel { } + setVisible(isVisible) { + + } + dispose() { this.setUpdateEmit(null); super.dispose(); diff --git a/src/platform/web/ui/session/room/TimelineView.ts b/src/platform/web/ui/session/room/TimelineView.ts index 969cd254..1342d877 100644 --- a/src/platform/web/ui/session/room/TimelineView.ts +++ b/src/platform/web/ui/session/room/TimelineView.ts @@ -86,7 +86,7 @@ export class TimelineView extends TemplateView { tiles.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); + this.updateVisibleRange(0, len - 1, false); } else { tiles.style.removeProperty("margin-top"); if (this.stickToBottom) { @@ -123,14 +123,14 @@ export class TimelineView extends TemplateView { bottomNodeIndex = anchoredNodeIndex; } let topNodeIndex = findFirstNodeIndexAtOrBelow(tiles, scrollTop, bottomNodeIndex); - this.updateVisibleRange(topNodeIndex, bottomNodeIndex); + this.updateVisibleRange(topNodeIndex, bottomNodeIndex, true); } - private updateVisibleRange(startIndex: number, endIndex: number) { + private updateVisibleRange(startIndex: number, endIndex: number, isViewportFilled: boolean) { const firstVisibleChild = this.tilesView!.getChildInstanceByIndex(startIndex); const lastVisibleChild = this.tilesView!.getChildInstanceByIndex(endIndex); if (firstVisibleChild && lastVisibleChild) { - this.value.setVisibleTileRange(firstVisibleChild.value, lastVisibleChild.value); + this.value.setVisibleTileRange(firstVisibleChild.value, lastVisibleChild.value, isViewportFilled); } } } From e89f60bac0ffd1995d5d039bf4f94beab006a344 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Wed, 8 Sep 2021 12:05:59 +0200 Subject: [PATCH 05/41] fill gap tiles when they become visible --- src/domain/session/room/timeline/tiles/GapTile.js | 8 ++++++++ src/platform/web/ui/css/timeline.css | 5 ----- src/platform/web/ui/session/room/timeline/GapView.js | 2 +- 3 files changed, 9 insertions(+), 6 deletions(-) diff --git a/src/domain/session/room/timeline/tiles/GapTile.js b/src/domain/session/room/timeline/tiles/GapTile.js index 32d632bf..eee77ef8 100644 --- a/src/domain/session/room/timeline/tiles/GapTile.js +++ b/src/domain/session/room/timeline/tiles/GapTile.js @@ -22,6 +22,7 @@ export class GapTile extends SimpleTile { super(options); this._loading = false; this._error = null; + this._visible = false; } async fill() { @@ -47,6 +48,13 @@ export class GapTile extends SimpleTile { return this._entry.edgeReached; } + setVisible(isVisible) { + this._visible = isVisible; + if (this._visible && !this.isLoading) { + this.fill(); + } + } + updateEntry(entry, params) { super.updateEntry(entry, params); if (!entry.isGap) { diff --git a/src/platform/web/ui/css/timeline.css b/src/platform/web/ui/css/timeline.css index 28ebf7bd..1eb704d9 100644 --- a/src/platform/web/ui/css/timeline.css +++ b/src/platform/web/ui/css/timeline.css @@ -54,15 +54,10 @@ limitations under the License. } .GapView { - visibility: hidden; display: flex; padding: 10px 20px; } -.GapView.isLoading { - visibility: visible; -} - .GapView > :nth-child(2) { flex: 1; } diff --git a/src/platform/web/ui/session/room/timeline/GapView.js b/src/platform/web/ui/session/room/timeline/GapView.js index afd22bd3..07e4d967 100644 --- a/src/platform/web/ui/session/room/timeline/GapView.js +++ b/src/platform/web/ui/session/room/timeline/GapView.js @@ -25,7 +25,7 @@ export class GapView extends TemplateView { }; return t.li({className}, [ spinner(t), - t.div(vm.i18n`Loading more messages …`), + t.div(vm => vm.isLoading ? vm.i18n`Loading more messages …` : vm.i18n`Not loading!`), t.if(vm => vm.error, t => t.strong(vm => vm.error)) ]); } From ab67ac00b11348fcad504b76d0f9b20f9fe5a7c4 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Wed, 8 Sep 2021 12:06:17 +0200 Subject: [PATCH 06/41] restore bottom of timeline initially after attach to DOM this will also load more items if the viewport isn't filled --- src/platform/web/ui/session/room/TimelineView.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/platform/web/ui/session/room/TimelineView.ts b/src/platform/web/ui/session/room/TimelineView.ts index 1342d877..71af5f9f 100644 --- a/src/platform/web/ui/session/room/TimelineView.ts +++ b/src/platform/web/ui/session/room/TimelineView.ts @@ -71,6 +71,11 @@ export class TimelineView extends TemplateView { private tilesView?: TilesListView; render(t: TemplateBuilder, 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()); return t.div({className: "Timeline bottom-aligned-scroll", onScroll: () => this.onScroll()}, [ t.view(this.tilesView) From 98678b991b087a0c66531e74f6ec999b909529af Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Wed, 8 Sep 2021 16:39:46 +0200 Subject: [PATCH 07/41] no need to store visible state on gap tile & don't fill if edge reached --- src/domain/session/room/timeline/tiles/GapTile.js | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/src/domain/session/room/timeline/tiles/GapTile.js b/src/domain/session/room/timeline/tiles/GapTile.js index eee77ef8..c85b6a09 100644 --- a/src/domain/session/room/timeline/tiles/GapTile.js +++ b/src/domain/session/room/timeline/tiles/GapTile.js @@ -22,12 +22,10 @@ export class GapTile extends SimpleTile { super(options); this._loading = false; this._error = null; - this._visible = false; } async fill() { - // prevent doing this twice - if (!this._loading) { + if (!this._loading && !this._entry.edgeReached) { this._loading = true; this.emitChange("isLoading"); try { @@ -44,13 +42,10 @@ export class GapTile extends SimpleTile { this.emitChange("isLoading"); } } - // edgeReached will have been updated by fillGap - return this._entry.edgeReached; } setVisible(isVisible) { - this._visible = isVisible; - if (this._visible && !this.isLoading) { + if (isVisible) { this.fill(); } } From d0f122a2bec933e2527bfade0a9180ff39777f6c Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 9 Sep 2021 17:14:16 +0200 Subject: [PATCH 08/41] WIP-ish, batch visible range requests, allow setting empty ranges and... don't try to notify when the tile becomes invisible again, we don't need it currently --- .../room/timeline/TimelineViewModel.js | 59 ++++++++++++------- .../session/room/timeline/tiles/GapTile.js | 6 +- .../session/room/timeline/tiles/SimpleTile.js | 4 +- .../web/ui/session/room/TimelineView.ts | 7 ++- 4 files changed, 44 insertions(+), 32 deletions(-) diff --git a/src/domain/session/room/timeline/TimelineViewModel.js b/src/domain/session/room/timeline/TimelineViewModel.js index 0e28cb6e..91390fd9 100644 --- a/src/domain/session/room/timeline/TimelineViewModel.js +++ b/src/domain/session/room/timeline/TimelineViewModel.js @@ -46,36 +46,51 @@ export class TimelineViewModel extends ViewModel { this._endTile = null; this._topLoadingPromise = null; this._bottomLoadingPromise = null; + this._requestedStartTile = null; + this._requestedEndTile = null; + this._requestScheduled = false; } - setVisibleTileRange(startTile, endTile, isViewportFilled) { - // we should async batch things here? + /** if this.tiles is empty, call this with undefined for both startTile and endTile */ + setVisibleTileRange(startTile, endTile) { + this._requestedStartTile = startTile; + this._requestedEndTile = endTile; + if (!this._requestScheduled) { + Promise.resolve().then(() => { + this._setVisibleTileRange(this._requestedStartTile, this._requestedEndTile); + this._requestScheduled = false; + }); + this._requestScheduled = true; + } + } - // this will prevent a (small) inserted tile from being marked visible, won't it? - if (this._startTile === startTile && this._endTile === endTile) { - return; + /** if this.tiles is empty, call this with undefined for both startTile and endTile */ + _setVisibleTileRange(startTile, endTile) { + let loadTop; + if (startTile && endTile) { + // old tiles could have been removed from tilescollection once we support unloading + this._startTile = startTile; + this._endTile = endTile; + const startIndex = this._tiles.getTileIndex(this._startTile); + const endIndex = this._tiles.getTileIndex(this._endTile); + for (const tile of this._tiles.sliceIterator(startIndex, endIndex)) { + tile.notifyVisible(); + } + loadTop = startIndex < 5; + console.log("got tiles", startIndex, endIndex, loadTop); + } else { + loadTop = true; + console.log("no tiles, load more at top"); } - // old tiles could have been removed from tilescollection once we support unloading - const oldStartIndex = this._startTile ? this._tiles.getTileIndex(this._startTile) : Number.MAX_SAFE_INTEGER; - const oldEndIndex = this._endTile ? this._tiles.getTileIndex(this._endTile) : Number.MIN_SAFE_INTEGER; - const newStartIndex = this._tiles.getTileIndex(startTile); - const newEndIndex = this._tiles.getTileIndex(endTile); - - const minIndex = Math.min(oldStartIndex, newStartIndex); - const maxIndex = Math.max(oldEndIndex, newEndIndex); - - let index = minIndex; - for (const tile of this._tiles.sliceIterator(minIndex, maxIndex)) { - const isVisible = index >= newStartIndex && index <= newEndIndex; - tile.setVisible(isVisible); - index += 1; - } - - if (!isViewportFilled || (newStartIndex < 5 && !this._topLoadingPromise)) { + if (loadTop && !this._topLoadingPromise) { this._topLoadingPromise = this._timeline.loadAtTop(10).then(() => { this._topLoadingPromise = null; + // check if more items need to be loaded by recursing + this.setVisibleTileRange(this._startTile, this._endTile); }); + } else if (loadTop) { + console.log("loadTop is true but already loading"); } } diff --git a/src/domain/session/room/timeline/tiles/GapTile.js b/src/domain/session/room/timeline/tiles/GapTile.js index c85b6a09..0863b3b6 100644 --- a/src/domain/session/room/timeline/tiles/GapTile.js +++ b/src/domain/session/room/timeline/tiles/GapTile.js @@ -44,10 +44,8 @@ export class GapTile extends SimpleTile { } } - setVisible(isVisible) { - if (isVisible) { - this.fill(); - } + notifyVisible() { + this.fill(); } updateEntry(entry, params) { diff --git a/src/domain/session/room/timeline/tiles/SimpleTile.js b/src/domain/session/room/timeline/tiles/SimpleTile.js index 96679c97..4c1c1de0 100644 --- a/src/domain/session/room/timeline/tiles/SimpleTile.js +++ b/src/domain/session/room/timeline/tiles/SimpleTile.js @@ -123,9 +123,7 @@ export class SimpleTile extends ViewModel { } - setVisible(isVisible) { - - } + notifyVisible() {} dispose() { this.setUpdateEmit(null); diff --git a/src/platform/web/ui/session/room/TimelineView.ts b/src/platform/web/ui/session/room/TimelineView.ts index 71af5f9f..8d95932f 100644 --- a/src/platform/web/ui/session/room/TimelineView.ts +++ b/src/platform/web/ui/session/room/TimelineView.ts @@ -132,11 +132,12 @@ export class TimelineView extends TemplateView { } private updateVisibleRange(startIndex: number, endIndex: number, isViewportFilled: boolean) { + // can be undefined, meaning the tiles collection is still empty const firstVisibleChild = this.tilesView!.getChildInstanceByIndex(startIndex); const lastVisibleChild = this.tilesView!.getChildInstanceByIndex(endIndex); - if (firstVisibleChild && lastVisibleChild) { - this.value.setVisibleTileRange(firstVisibleChild.value, lastVisibleChild.value, isViewportFilled); - } + //if (firstVisibleChild && lastVisibleChild) { + this.value.setVisibleTileRange(firstVisibleChild?.value, lastVisibleChild?.value, isViewportFilled); + //} } } From d1242c4b67c4d0353d3d827507597631df203f20 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 9 Sep 2021 17:15:06 +0200 Subject: [PATCH 09/41] make gaps taller --- src/platform/web/ui/css/timeline.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/platform/web/ui/css/timeline.css b/src/platform/web/ui/css/timeline.css index 1eb704d9..25ba5a10 100644 --- a/src/platform/web/ui/css/timeline.css +++ b/src/platform/web/ui/css/timeline.css @@ -55,7 +55,7 @@ limitations under the License. .GapView { display: flex; - padding: 10px 20px; + padding: 52px 20px 12px 20px; } .GapView > :nth-child(2) { From fe4bb5db4016a48e8bd47bf67025443d73b7c177 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 9 Sep 2021 17:15:28 +0200 Subject: [PATCH 10/41] remove comment --- src/domain/session/room/timeline/TimelineViewModel.js | 1 - 1 file changed, 1 deletion(-) diff --git a/src/domain/session/room/timeline/TimelineViewModel.js b/src/domain/session/room/timeline/TimelineViewModel.js index 91390fd9..5db80976 100644 --- a/src/domain/session/room/timeline/TimelineViewModel.js +++ b/src/domain/session/room/timeline/TimelineViewModel.js @@ -64,7 +64,6 @@ export class TimelineViewModel extends ViewModel { } } - /** if this.tiles is empty, call this with undefined for both startTile and endTile */ _setVisibleTileRange(startTile, endTile) { let loadTop; if (startTile && endTile) { From 98cc1e2715ebcaf1b0d2a5ee584d08d778246e2a Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Fri, 10 Sep 2021 14:17:40 +0200 Subject: [PATCH 11/41] don't try to load more when end of timeline reached --- src/domain/session/room/timeline/TimelineViewModel.js | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/domain/session/room/timeline/TimelineViewModel.js b/src/domain/session/room/timeline/TimelineViewModel.js index 5db80976..222a05b7 100644 --- a/src/domain/session/room/timeline/TimelineViewModel.js +++ b/src/domain/session/room/timeline/TimelineViewModel.js @@ -83,10 +83,12 @@ export class TimelineViewModel extends ViewModel { } if (loadTop && !this._topLoadingPromise) { - this._topLoadingPromise = this._timeline.loadAtTop(10).then(() => { + this._topLoadingPromise = this._timeline.loadAtTop(10).then(hasReachedEnd => { this._topLoadingPromise = null; - // check if more items need to be loaded by recursing - this.setVisibleTileRange(this._startTile, this._endTile); + if (!hasReachedEnd) { + // check if more items need to be loaded by recursing + this.setVisibleTileRange(this._startTile, this._endTile); + } }); } else if (loadTop) { console.log("loadTop is true but already loading"); From 9411e6f06562fdad690794adfcb97f80168d5c4d Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Fri, 10 Sep 2021 14:47:05 +0200 Subject: [PATCH 12/41] WIP --- .../session/room/timeline/TimelineViewModel.js | 16 +++++++--------- src/matrix/room/timeline/Timeline.js | 6 ++++-- src/platform/web/ui/css/timeline.css | 2 ++ src/platform/web/ui/session/room/TimelineView.ts | 13 ++++++------- 4 files changed, 19 insertions(+), 18 deletions(-) diff --git a/src/domain/session/room/timeline/TimelineViewModel.js b/src/domain/session/room/timeline/TimelineViewModel.js index 222a05b7..eafc092c 100644 --- a/src/domain/session/room/timeline/TimelineViewModel.js +++ b/src/domain/session/room/timeline/TimelineViewModel.js @@ -34,8 +34,6 @@ when loading, it just reads events from a sortkey backwards or forwards... import {TilesCollection} from "./TilesCollection.js"; import {ViewModel} from "../../../ViewModel.js"; - - export class TimelineViewModel extends ViewModel { constructor(options) { super(options); @@ -45,7 +43,6 @@ export class TimelineViewModel extends ViewModel { this._startTile = null; this._endTile = null; this._topLoadingPromise = null; - this._bottomLoadingPromise = null; this._requestedStartTile = null; this._requestedEndTile = null; this._requestScheduled = false; @@ -56,7 +53,7 @@ export class TimelineViewModel extends ViewModel { this._requestedStartTile = startTile; this._requestedEndTile = endTile; if (!this._requestScheduled) { - Promise.resolve().then(() => { + requestIdleCallback(() => { this._setVisibleTileRange(this._requestedStartTile, this._requestedEndTile); this._requestScheduled = false; }); @@ -72,14 +69,15 @@ export class TimelineViewModel extends ViewModel { this._endTile = endTile; const startIndex = this._tiles.getTileIndex(this._startTile); const endIndex = this._tiles.getTileIndex(this._endTile); - for (const tile of this._tiles.sliceIterator(startIndex, endIndex)) { + for (const tile of this._tiles.sliceIterator(startIndex, endIndex + 1)) { tile.notifyVisible(); } - loadTop = startIndex < 5; - console.log("got tiles", startIndex, endIndex, loadTop); + loadTop = startIndex < 10; + // console.log("got tiles", startIndex, endIndex, loadTop); } else { + // tiles collection is empty, load more at top loadTop = true; - console.log("no tiles, load more at top"); + // console.log("no tiles, load more at top"); } if (loadTop && !this._topLoadingPromise) { @@ -91,7 +89,7 @@ export class TimelineViewModel extends ViewModel { } }); } else if (loadTop) { - console.log("loadTop is true but already loading"); + // console.log("loadTop is true but already loading"); } } diff --git a/src/matrix/room/timeline/Timeline.js b/src/matrix/room/timeline/Timeline.js index fdfaee48..2a9cfec7 100644 --- a/src/matrix/room/timeline/Timeline.js +++ b/src/matrix/room/timeline/Timeline.js @@ -72,8 +72,10 @@ export class Timeline { // as they should only populate once the view subscribes to it // if they are populated already, the sender profile would be empty - // 30 seems to be a good amount to fill the entire screen - const readerRequest = this._disposables.track(this._timelineReader.readFromEnd(30, txn, log)); + // choose good amount here between showing messages initially and + // not spending too much time decrypting messages before showing the timeline. + // more messages should be loaded automatically until the viewport is full by the view if needed. + const readerRequest = this._disposables.track(this._timelineReader.readFromEnd(5, txn, log)); try { const entries = await readerRequest.complete(); this._setupEntries(entries); diff --git a/src/platform/web/ui/css/timeline.css b/src/platform/web/ui/css/timeline.css index 25ba5a10..97582990 100644 --- a/src/platform/web/ui/css/timeline.css +++ b/src/platform/web/ui/css/timeline.css @@ -20,6 +20,8 @@ limitations under the License. overscroll-behavior: contain; padding: 0; margin: 0; + /* need to read the offsetTop of tiles relative to this element in TimelineView */ + position: relative; } .RoomView_body > .Timeline > ul { diff --git a/src/platform/web/ui/session/room/TimelineView.ts b/src/platform/web/ui/session/room/TimelineView.ts index 8d95932f..06a2788e 100644 --- a/src/platform/web/ui/session/room/TimelineView.ts +++ b/src/platform/web/ui/session/room/TimelineView.ts @@ -91,7 +91,7 @@ export class TimelineView extends TemplateView { tiles.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, false); + this.updateVisibleRange(0, len - 1); } else { tiles.style.removeProperty("margin-top"); if (this.stickToBottom) { @@ -104,7 +104,7 @@ export class TimelineView extends TemplateView { timeline.scrollBy(0, bottomDiff); this.anchoredBottom = newAnchoredBottom; } else { - console.log("restore: bottom didn't change, must be below viewport"); + // console.log("restore: bottom didn't change, must be below viewport"); } } } @@ -122,22 +122,21 @@ export class TimelineView extends TemplateView { bottomNodeIndex = len - 1; } else { const viewportBottom = scrollTop + clientHeight; + // console.log(`viewportBottom: ${viewportBottom} (${scrollTop} + ${clientHeight})`); const anchoredNodeIndex = findFirstNodeIndexAtOrBelow(tiles, viewportBottom); this.anchoredNode = tiles.childNodes[anchoredNodeIndex] as HTMLElement; this.anchoredBottom = bottom(this.anchoredNode!); bottomNodeIndex = anchoredNodeIndex; } let topNodeIndex = findFirstNodeIndexAtOrBelow(tiles, scrollTop, bottomNodeIndex); - this.updateVisibleRange(topNodeIndex, bottomNodeIndex, true); + this.updateVisibleRange(topNodeIndex, bottomNodeIndex); } - private updateVisibleRange(startIndex: number, endIndex: number, isViewportFilled: boolean) { + 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); - //if (firstVisibleChild && lastVisibleChild) { - this.value.setVisibleTileRange(firstVisibleChild?.value, lastVisibleChild?.value, isViewportFilled); - //} + this.value.setVisibleTileRange(firstVisibleChild?.value, lastVisibleChild?.value); } } From 5c40b75eabb8809edc91694e187647ea74021fb8 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Fri, 10 Sep 2021 15:25:19 +0200 Subject: [PATCH 13/41] don't override newly requested ranges when retrying loadattop --- src/domain/session/room/timeline/TimelineViewModel.js | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/domain/session/room/timeline/TimelineViewModel.js b/src/domain/session/room/timeline/TimelineViewModel.js index eafc092c..1c53079d 100644 --- a/src/domain/session/room/timeline/TimelineViewModel.js +++ b/src/domain/session/room/timeline/TimelineViewModel.js @@ -50,6 +50,8 @@ export class TimelineViewModel extends ViewModel { /** if this.tiles is empty, call this with undefined for both startTile and endTile */ setVisibleTileRange(startTile, endTile) { + // don't clear these once done as they are used to check + // for more tiles once loadAtTop finishes this._requestedStartTile = startTile; this._requestedEndTile = endTile; if (!this._requestScheduled) { @@ -85,7 +87,9 @@ export class TimelineViewModel extends ViewModel { this._topLoadingPromise = null; if (!hasReachedEnd) { // check if more items need to be loaded by recursing - this.setVisibleTileRange(this._startTile, this._endTile); + // use the requested start / end tile, + // so we don't end up overwriting a newly requested visible range here + this.setVisibleTileRange(this._requestedStartTile, this._requestedEndTile); } }); } else if (loadTop) { From 02b8b37b4c60ffe37d214faccfad49af3d649f46 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Mon, 13 Sep 2021 13:11:25 +0200 Subject: [PATCH 14/41] disable native scroll anchoring as it interferes with our impl --- src/platform/web/ui/css/timeline.css | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/platform/web/ui/css/timeline.css b/src/platform/web/ui/css/timeline.css index 97582990..29e24bde 100644 --- a/src/platform/web/ui/css/timeline.css +++ b/src/platform/web/ui/css/timeline.css @@ -17,7 +17,8 @@ limitations under the License. .RoomView_body > .Timeline { overflow-y: auto; - overscroll-behavior: contain; + overscroll-behavior-y: contain; + overflow-anchor: none; padding: 0; margin: 0; /* need to read the offsetTop of tiles relative to this element in TimelineView */ From 8858cffc55d187459ee6129d8d1713c507e876b4 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Mon, 13 Sep 2021 14:53:08 +0200 Subject: [PATCH 15/41] fallback from scrollBy() to setting scrollTop on IE11 --- src/platform/web/ui/session/room/TimelineView.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/platform/web/ui/session/room/TimelineView.ts b/src/platform/web/ui/session/room/TimelineView.ts index 06a2788e..f9e0b63d 100644 --- a/src/platform/web/ui/session/room/TimelineView.ts +++ b/src/platform/web/ui/session/room/TimelineView.ts @@ -101,7 +101,11 @@ export class TimelineView extends TemplateView { if (newAnchoredBottom !== this.anchoredBottom) { const bottomDiff = newAnchoredBottom - this.anchoredBottom; console.log(`restore: scroll by ${bottomDiff} as height changed`); - timeline.scrollBy(0, bottomDiff); + if (typeof timeline.scrollBy === "function") { + timeline.scrollBy(0, bottomDiff); + } else { + timeline.scrollTop = timeline.scrollTop + bottomDiff; + } this.anchoredBottom = newAnchoredBottom; } else { // console.log("restore: bottom didn't change, must be below viewport"); From ecccadb77e1a10e7a541435142da03a4772cd2ac Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Mon, 13 Sep 2021 14:53:34 +0200 Subject: [PATCH 16/41] avoid requestIdleCallback as it is not supported on Safari and IE11 --- src/domain/session/room/timeline/TimelineViewModel.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/domain/session/room/timeline/TimelineViewModel.js b/src/domain/session/room/timeline/TimelineViewModel.js index 1c53079d..baf9b6e3 100644 --- a/src/domain/session/room/timeline/TimelineViewModel.js +++ b/src/domain/session/room/timeline/TimelineViewModel.js @@ -55,7 +55,7 @@ export class TimelineViewModel extends ViewModel { this._requestedStartTile = startTile; this._requestedEndTile = endTile; if (!this._requestScheduled) { - requestIdleCallback(() => { + Promise.resolve().then(() => { this._setVisibleTileRange(this._requestedStartTile, this._requestedEndTile); this._requestScheduled = false; }); From 906e5886e11e82712a2031f78ea8b96a6fce5ac0 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Mon, 13 Sep 2021 15:39:56 +0200 Subject: [PATCH 17/41] fix tiny jump when timeline starts filling viewport --- src/platform/web/ui/css/timeline.css | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/platform/web/ui/css/timeline.css b/src/platform/web/ui/css/timeline.css index 29e24bde..3a2377b8 100644 --- a/src/platform/web/ui/css/timeline.css +++ b/src/platform/web/ui/css/timeline.css @@ -27,7 +27,10 @@ limitations under the License. .RoomView_body > .Timeline > ul { list-style: none; - padding: 0; + /* use small horizontal padding so first/last children margin isn't collapsed + at the edge and a scrollbar shows up when setting margin-top to bottom-align + content when there are not yet enough tiles to fill the viewport */ + padding: 1px 0; margin: 0; } From feb0cf7e3971fe034f872142d4710d9bf79ca996 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Mon, 13 Sep 2021 15:40:15 +0200 Subject: [PATCH 18/41] fix viewport changing width when timeline starts filling the viewport otherwise centered tiles like announcementview jump a bit vertically --- src/platform/web/ui/css/timeline.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/platform/web/ui/css/timeline.css b/src/platform/web/ui/css/timeline.css index 3a2377b8..ea669c92 100644 --- a/src/platform/web/ui/css/timeline.css +++ b/src/platform/web/ui/css/timeline.css @@ -16,7 +16,7 @@ limitations under the License. .RoomView_body > .Timeline { - overflow-y: auto; + overflow-y: scroll; overscroll-behavior-y: contain; overflow-anchor: none; padding: 0; From 247d6a2148693611958a0420930bbb2a0263e88d Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Tue, 14 Sep 2021 17:01:30 +0200 Subject: [PATCH 19/41] add comments --- src/platform/web/ui/session/room/TimelineView.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/platform/web/ui/session/room/TimelineView.ts b/src/platform/web/ui/session/room/TimelineView.ts index f9e0b63d..9bcfb333 100644 --- a/src/platform/web/ui/session/room/TimelineView.ts +++ b/src/platform/web/ui/session/room/TimelineView.ts @@ -101,6 +101,9 @@ export class TimelineView extends TemplateView { if (newAnchoredBottom !== this.anchoredBottom) { const bottomDiff = newAnchoredBottom - this.anchoredBottom; console.log(`restore: scroll by ${bottomDiff} as height changed`); + // 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 timeline.scrollBy === "function") { timeline.scrollBy(0, bottomDiff); } else { @@ -111,6 +114,8 @@ export class TimelineView extends TemplateView { // console.log("restore: bottom didn't change, must be below viewport"); } } + // TODO: should we be updating the visible range here as well as the range might have changed even though + // we restored the bottom tile } } From 04edff29cf16c2623402c28e1e31820438f3d5ca Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Wed, 15 Sep 2021 15:57:31 +0200 Subject: [PATCH 20/41] give more top padding to gaps that appear in the middle of the timeline --- .../session/room/timeline/tiles/GapTile.js | 16 ++++++++++++++++ .../web/ui/css/themes/element/timeline.css | 8 ++++++++ src/platform/web/ui/css/timeline.css | 1 - .../web/ui/session/room/timeline/GapView.js | 3 ++- 4 files changed, 26 insertions(+), 2 deletions(-) diff --git a/src/domain/session/room/timeline/tiles/GapTile.js b/src/domain/session/room/timeline/tiles/GapTile.js index 0863b3b6..5b4bcea4 100644 --- a/src/domain/session/room/timeline/tiles/GapTile.js +++ b/src/domain/session/room/timeline/tiles/GapTile.js @@ -22,6 +22,7 @@ export class GapTile extends SimpleTile { super(options); this._loading = false; this._error = null; + this._isAtTop = true; } async fill() { @@ -48,6 +49,21 @@ export class GapTile extends SimpleTile { this.fill(); } + get isAtTop() { + return this._isAtTop; + } + + updatePreviousSibling(prev) { + console.log("GapTile.updatePreviousSibling", prev); + super.updatePreviousSibling(prev); + const isAtTop = !prev; + if (this._isAtTop !== isAtTop) { + this._isAtTop = isAtTop; + console.log("isAtTop", this._isAtTop); + this.emitChange("isAtTop"); + } + } + updateEntry(entry, params) { super.updateEntry(entry, params); if (!entry.isGap) { diff --git a/src/platform/web/ui/css/themes/element/timeline.css b/src/platform/web/ui/css/themes/element/timeline.css index 408d10cc..1caf09d5 100644 --- a/src/platform/web/ui/css/themes/element/timeline.css +++ b/src/platform/web/ui/css/themes/element/timeline.css @@ -362,3 +362,11 @@ only loads when the top comes into view*/ .GapView > :not(:first-child) { margin-left: 12px; } + +.GapView { + padding: 52px 20px; +} + +.GapView.isAtTop { + padding: 52px 20px 12px 20px; +} diff --git a/src/platform/web/ui/css/timeline.css b/src/platform/web/ui/css/timeline.css index ea669c92..dd34ba05 100644 --- a/src/platform/web/ui/css/timeline.css +++ b/src/platform/web/ui/css/timeline.css @@ -61,7 +61,6 @@ limitations under the License. .GapView { display: flex; - padding: 52px 20px 12px 20px; } .GapView > :nth-child(2) { diff --git a/src/platform/web/ui/session/room/timeline/GapView.js b/src/platform/web/ui/session/room/timeline/GapView.js index 07e4d967..07980b42 100644 --- a/src/platform/web/ui/session/room/timeline/GapView.js +++ b/src/platform/web/ui/session/room/timeline/GapView.js @@ -21,7 +21,8 @@ export class GapView extends TemplateView { render(t, vm) { const className = { GapView: true, - isLoading: vm => vm.isLoading + isLoading: vm => vm.isLoading, + isAtTop: vm => vm.isAtTop, }; return t.li({className}, [ spinner(t), From 2c415e37e7282f0080083ce29027ef8d8d96fd90 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Wed, 15 Sep 2021 17:23:28 +0200 Subject: [PATCH 21/41] where ResizeObserver is support, restore anchored node on resize --- .../web/ui/session/room/TimelineView.ts | 20 ++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/src/platform/web/ui/session/room/TimelineView.ts b/src/platform/web/ui/session/room/TimelineView.ts index 9bcfb333..9c12d8d4 100644 --- a/src/platform/web/ui/session/room/TimelineView.ts +++ b/src/platform/web/ui/session/room/TimelineView.ts @@ -69,6 +69,7 @@ export class TimelineView extends TemplateView { private anchoredBottom: number = 0; private stickToBottom: boolean = true; private tilesView?: TilesListView; + private resizeObserver?: ResizeObserver; render(t: TemplateBuilder, vm: TimelineViewModel) { // assume this view will be mounted in the parent DOM straight away @@ -77,9 +78,26 @@ export class TimelineView extends TemplateView { this.restoreScrollPosition(); }); this.tilesView = new TilesListView(vm.tiles, () => this.restoreScrollPosition()); - return t.div({className: "Timeline bottom-aligned-scroll", onScroll: () => this.onScroll()}, [ + const root = t.div({className: "Timeline bottom-aligned-scroll", onScroll: () => this.onScroll()}, [ t.view(this.tilesView) ]); + + if (typeof ResizeObserver === "function") { + this.resizeObserver = new ResizeObserver(() => { + this.restoreScrollPosition(); + }); + this.resizeObserver.observe(root); + } + + return root; + } + + public unmount() { + super.unmount(); + if (this.resizeObserver) { + this.resizeObserver.unobserve(this.root()); + this.resizeObserver = null; + } } private restoreScrollPosition() { From 1df12b8c8950eae2933d25c53e8cd8b6b5a3140c Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Wed, 15 Sep 2021 17:23:53 +0200 Subject: [PATCH 22/41] only allow pixel gaps of < 1px for stick to bottom to prevent eleweb bug https://github.com/matrix-org/matrix-react-sdk/pull/6751 --- src/platform/web/ui/session/room/TimelineView.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/platform/web/ui/session/room/TimelineView.ts b/src/platform/web/ui/session/room/TimelineView.ts index 9c12d8d4..61fbca08 100644 --- a/src/platform/web/ui/session/room/TimelineView.ts +++ b/src/platform/web/ui/session/room/TimelineView.ts @@ -143,7 +143,7 @@ export class TimelineView extends TemplateView { const tiles = this.tilesView!.root() as HTMLElement; let bottomNodeIndex; - this.stickToBottom = Math.abs(scrollHeight - (scrollTop + clientHeight)) < 5; + this.stickToBottom = Math.abs(scrollHeight - (scrollTop + clientHeight)) < 1; if (this.stickToBottom) { const len = this.value.tiles.length; bottomNodeIndex = len - 1; From e4101ece65f1ab8c3d6040a2574fac24c189c92a Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Wed, 15 Sep 2021 18:30:08 +0200 Subject: [PATCH 23/41] add "jump down" button in timeline --- .../room/timeline/TimelineViewModel.js | 14 +++++ .../css/themes/element/icons/chevron-down.svg | 58 +++++++++++++++++++ .../web/ui/css/themes/element/timeline.css | 14 +++++ src/platform/web/ui/css/timeline.css | 15 ++++- .../web/ui/session/room/TimelineView.ts | 44 +++++++++----- 5 files changed, 130 insertions(+), 15 deletions(-) create mode 100644 src/platform/web/ui/css/themes/element/icons/chevron-down.svg diff --git a/src/domain/session/room/timeline/TimelineViewModel.js b/src/domain/session/room/timeline/TimelineViewModel.js index baf9b6e3..5938ada5 100644 --- a/src/domain/session/room/timeline/TimelineViewModel.js +++ b/src/domain/session/room/timeline/TimelineViewModel.js @@ -46,6 +46,7 @@ export class TimelineViewModel extends ViewModel { this._requestedStartTile = null; this._requestedEndTile = null; this._requestScheduled = false; + this._showJumpDown = false; } /** if this.tiles is empty, call this with undefined for both startTile and endTile */ @@ -75,10 +76,12 @@ export class TimelineViewModel extends ViewModel { tile.notifyVisible(); } loadTop = startIndex < 10; + this._setShowJumpDown(endIndex < (this._tiles.length - 1)); // console.log("got tiles", startIndex, endIndex, loadTop); } else { // tiles collection is empty, load more at top loadTop = true; + this._setShowJumpDown(false); // console.log("no tiles, load more at top"); } @@ -100,4 +103,15 @@ export class TimelineViewModel extends ViewModel { get tiles() { return this._tiles; } + + _setShowJumpDown(show) { + if (this._showJumpDown !== show) { + this._showJumpDown = show; + this.emitChange("showJumpDown"); + } + } + + get showJumpDown() { + return this._showJumpDown; + } } diff --git a/src/platform/web/ui/css/themes/element/icons/chevron-down.svg b/src/platform/web/ui/css/themes/element/icons/chevron-down.svg new file mode 100644 index 00000000..d2068199 --- /dev/null +++ b/src/platform/web/ui/css/themes/element/icons/chevron-down.svg @@ -0,0 +1,58 @@ + + + + + + + + + + + + diff --git a/src/platform/web/ui/css/themes/element/timeline.css b/src/platform/web/ui/css/themes/element/timeline.css index 1caf09d5..d68d7ff5 100644 --- a/src/platform/web/ui/css/themes/element/timeline.css +++ b/src/platform/web/ui/css/themes/element/timeline.css @@ -15,6 +15,20 @@ See the License for the specific language governing permissions and limitations under the License. */ +.Timeline_jumpDown { + width: 40px; + height: 40px; + bottom: 16px; + right: 32px; + border-radius: 100%; + border: 1px solid #8d99a5; + background-image: url(icons/chevron-down.svg); + background-position: center; + background-color: white; + background-repeat: no-repeat; + cursor: pointer; +} + .Timeline_message { display: grid; grid-template: diff --git a/src/platform/web/ui/css/timeline.css b/src/platform/web/ui/css/timeline.css index dd34ba05..c4d1459b 100644 --- a/src/platform/web/ui/css/timeline.css +++ b/src/platform/web/ui/css/timeline.css @@ -14,8 +14,17 @@ See the License for the specific language governing permissions and limitations under the License. */ +.Timeline { + display: flex; + flex-direction: column; + position: relative; +} -.RoomView_body > .Timeline { +.Timeline_jumpDown { + position: absolute; +} + +.Timeline_scroller { overflow-y: scroll; overscroll-behavior-y: contain; overflow-anchor: none; @@ -23,9 +32,11 @@ limitations under the License. margin: 0; /* need to read the offsetTop of tiles relative to this element in TimelineView */ position: relative; + min-height: 0; + flex: 1 0 0; } -.RoomView_body > .Timeline > ul { +.Timeline_scroller > ul { list-style: none; /* use small horizontal padding so first/last children margin isn't collapsed at the edge and a scrollbar shows up when setting margin-top to bottom-align diff --git a/src/platform/web/ui/session/room/TimelineView.ts b/src/platform/web/ui/session/room/TimelineView.ts index 61fbca08..7446dbcb 100644 --- a/src/platform/web/ui/session/room/TimelineView.ts +++ b/src/platform/web/ui/session/room/TimelineView.ts @@ -78,8 +78,19 @@ export class TimelineView extends TemplateView { this.restoreScrollPosition(); }); this.tilesView = new TilesListView(vm.tiles, () => this.restoreScrollPosition()); - const root = t.div({className: "Timeline bottom-aligned-scroll", onScroll: () => this.onScroll()}, [ - t.view(this.tilesView) + 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") { @@ -92,6 +103,16 @@ export class TimelineView extends TemplateView { return root; } + private get scroller() { + return this.root().firstElementChild as HTMLElement; + } + + private jumpDown() { + const {scroller} = this; + this.stickToBottom = true; + scroller.scrollTop = scroller.scrollHeight; + } + public unmount() { super.unmount(); if (this.resizeObserver) { @@ -101,10 +122,10 @@ export class TimelineView extends TemplateView { } private restoreScrollPosition() { - const timeline = this.root() as HTMLElement; + const {scroller} = this; const tiles = this.tilesView!.root() as HTMLElement; - const missingTilesHeight = timeline.clientHeight - tiles.clientHeight; + const missingTilesHeight = scroller.clientHeight - tiles.clientHeight; if (missingTilesHeight > 0) { tiles.style.setProperty("margin-top", `${missingTilesHeight}px`); // we don't have enough tiles to fill the viewport, so set all as visible @@ -113,23 +134,20 @@ export class TimelineView extends TemplateView { } else { tiles.style.removeProperty("margin-top"); if (this.stickToBottom) { - timeline.scrollTop = timeline.scrollHeight; + scroller.scrollTop = scroller.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`); // 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 timeline.scrollBy === "function") { - timeline.scrollBy(0, bottomDiff); + if (typeof scroller.scrollBy === "function") { + scroller.scrollBy(0, bottomDiff); } else { - timeline.scrollTop = timeline.scrollTop + bottomDiff; + scroller.scrollTop = scroller.scrollTop + bottomDiff; } this.anchoredBottom = newAnchoredBottom; - } else { - // console.log("restore: bottom didn't change, must be below viewport"); } } // TODO: should we be updating the visible range here as well as the range might have changed even though @@ -138,8 +156,8 @@ export class TimelineView extends TemplateView { } private onScroll(): void { - const timeline = this.root() as HTMLElement; - const {scrollHeight, scrollTop, clientHeight} = timeline; + const {scroller} = this; + const {scrollHeight, scrollTop, clientHeight} = scroller; const tiles = this.tilesView!.root() as HTMLElement; let bottomNodeIndex; From 2396a84c99e2a5e2609e0319e1804ba6baa196f2 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Wed, 15 Sep 2021 18:39:04 +0200 Subject: [PATCH 24/41] leave out svg editor markup from icon --- .../css/themes/element/icons/chevron-down.svg | 22 ------------------- 1 file changed, 22 deletions(-) diff --git a/src/platform/web/ui/css/themes/element/icons/chevron-down.svg b/src/platform/web/ui/css/themes/element/icons/chevron-down.svg index d2068199..6db33a25 100644 --- a/src/platform/web/ui/css/themes/element/icons/chevron-down.svg +++ b/src/platform/web/ui/css/themes/element/icons/chevron-down.svg @@ -6,30 +6,8 @@ fill="none" version="1.1" id="svg839" - sodipodi:docname="chevron-down.svg" - inkscape:version="1.1 (c68e22c387, 2021-05-23)" - xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" - xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" xmlns="http://www.w3.org/2000/svg" xmlns:svg="http://www.w3.org/2000/svg"> - Date: Thu, 16 Sep 2021 10:23:03 +0200 Subject: [PATCH 25/41] copy Daniel's conversion of EventEmitter to TypeScript from microui --- src/domain/ViewModel.js | 2 +- src/matrix/room/BaseRoom.js | 2 +- src/matrix/room/Invite.js | 2 +- .../{EventEmitter.js => EventEmitter.ts} | 31 ++++++++++--------- 4 files changed, 20 insertions(+), 17 deletions(-) rename src/utils/{EventEmitter.js => EventEmitter.ts} (71%) diff --git a/src/domain/ViewModel.js b/src/domain/ViewModel.js index 97a91700..f0e109f8 100644 --- a/src/domain/ViewModel.js +++ b/src/domain/ViewModel.js @@ -18,7 +18,7 @@ limitations under the License. // as in some cases it would really be more convenient to have multiple events (like telling the timeline to scroll down) // we do need to return a disposable from EventEmitter.on, or at least have a method here to easily track a subscription to an EventEmitter -import {EventEmitter} from "../utils/EventEmitter.js"; +import {EventEmitter} from "../utils/EventEmitter"; import {Disposables} from "../utils/Disposables.js"; export class ViewModel extends EventEmitter { diff --git a/src/matrix/room/BaseRoom.js b/src/matrix/room/BaseRoom.js index 4a8f50b4..aac962ac 100644 --- a/src/matrix/room/BaseRoom.js +++ b/src/matrix/room/BaseRoom.js @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {EventEmitter} from "../../utils/EventEmitter.js"; +import {EventEmitter} from "../../utils/EventEmitter"; import {RoomSummary} from "./RoomSummary.js"; import {GapWriter} from "./timeline/persistence/GapWriter.js"; import {RelationWriter} from "./timeline/persistence/RelationWriter.js"; diff --git a/src/matrix/room/Invite.js b/src/matrix/room/Invite.js index 9bf818ae..b8190322 100644 --- a/src/matrix/room/Invite.js +++ b/src/matrix/room/Invite.js @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {EventEmitter} from "../../utils/EventEmitter.js"; +import {EventEmitter} from "../../utils/EventEmitter"; import {SummaryData, processStateEvent} from "./RoomSummary.js"; import {Heroes} from "./members/Heroes.js"; import {MemberChange, RoomMember, EVENT_TYPE as MEMBER_EVENT_TYPE} from "./members/RoomMember.js"; diff --git a/src/utils/EventEmitter.js b/src/utils/EventEmitter.ts similarity index 71% rename from src/utils/EventEmitter.js rename to src/utils/EventEmitter.ts index 5dd56ac3..ea065d85 100644 --- a/src/utils/EventEmitter.js +++ b/src/utils/EventEmitter.ts @@ -1,5 +1,6 @@ /* Copyright 2020 Bruno Windels +Copyright 2021 Daniel Fedorin Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -14,28 +15,30 @@ See the License for the specific language governing permissions and limitations under the License. */ -export class EventEmitter { +type Handler = (value?: T) => void; + +export class EventEmitter { + private _handlersByName: { [event in keyof T]?: Set> } + constructor() { this._handlersByName = {}; } - emit(name, ...values) { + emit(name: K, value?: T[K]): void { const handlers = this._handlersByName[name]; if (handlers) { - for(const h of handlers) { - h(...values); - } + handlers.forEach(h => h(value)); } } - disposableOn(name, callback) { + disposableOn(name: K, callback: Handler): () => void { this.on(name, callback); return () => { this.off(name, callback); } } - on(name, callback) { + on(name: K, callback: Handler): void { let handlers = this._handlersByName[name]; if (!handlers) { this.onFirstSubscriptionAdded(name); @@ -44,27 +47,27 @@ export class EventEmitter { handlers.add(callback); } - off(name, callback) { + off(name: K, callback: Handler): void { const handlers = this._handlersByName[name]; if (handlers) { handlers.delete(callback); - if (handlers.length === 0) { + if (handlers.size === 0) { delete this._handlersByName[name]; this.onLastSubscriptionRemoved(name); } } } - onFirstSubscriptionAdded(/* name */) {} + onFirstSubscriptionAdded(name: K): void {} - onLastSubscriptionRemoved(/* name */) {} + onLastSubscriptionRemoved(name: K): void {} } export function tests() { return { test_on_off(assert) { let counter = 0; - const e = new EventEmitter(); + const e = new EventEmitter<{ change: never }>(); const callback = () => counter += 1; e.on("change", callback); e.emit("change"); @@ -75,7 +78,7 @@ export function tests() { test_emit_value(assert) { let value = 0; - const e = new EventEmitter(); + const e = new EventEmitter<{ change: number }>(); const callback = (v) => value = v; e.on("change", callback); e.emit("change", 5); @@ -85,7 +88,7 @@ export function tests() { test_double_on(assert) { let counter = 0; - const e = new EventEmitter(); + const e = new EventEmitter<{ change: never }>(); const callback = () => counter += 1; e.on("change", callback); e.on("change", callback); From 69672dd99cd3363d2b1fc6e2fda3dced69bc1a5c Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 16 Sep 2021 10:45:06 +0200 Subject: [PATCH 26/41] copy Daniel's conversion of html.js to TypeScript from microui --- src/platform/web/ui/avatar.js | 2 +- src/platform/web/ui/general/LazyListView.js | 2 +- src/platform/web/ui/general/ListView.ts | 4 +- src/platform/web/ui/general/StaticView.js | 2 +- src/platform/web/ui/general/TemplateView.js | 2 +- .../web/ui/general/{html.js => html.ts} | 38 +++++++++++-------- src/platform/web/ui/general/utils.ts | 4 +- .../ui/session/rightpanel/RoomDetailsView.js | 2 +- .../session/room/timeline/BaseMessageView.js | 2 +- .../session/room/timeline/TextMessageView.js | 2 +- 10 files changed, 33 insertions(+), 27 deletions(-) rename src/platform/web/ui/general/{html.js => html.ts} (61%) diff --git a/src/platform/web/ui/avatar.js b/src/platform/web/ui/avatar.js index 2e2b0142..5bc019cb 100644 --- a/src/platform/web/ui/avatar.js +++ b/src/platform/web/ui/avatar.js @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {tag, text, classNames, setAttribute} from "./general/html.js"; +import {tag, text, classNames, setAttribute} from "./general/html"; /** * @param {Object} vm view model with {avatarUrl, avatarColorNumber, avatarTitle, avatarLetter} * @param {Number} size diff --git a/src/platform/web/ui/general/LazyListView.js b/src/platform/web/ui/general/LazyListView.js index 6f565e30..4426d97c 100644 --- a/src/platform/web/ui/general/LazyListView.js +++ b/src/platform/web/ui/general/LazyListView.js @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {el} from "./html.js"; +import {el} from "./html"; import {mountView} from "./utils"; import {ListView} from "./ListView"; import {insertAt} from "./utils"; diff --git a/src/platform/web/ui/general/ListView.ts b/src/platform/web/ui/general/ListView.ts index 2c45fb1c..2bd3b7d3 100644 --- a/src/platform/web/ui/general/ListView.ts +++ b/src/platform/web/ui/general/ListView.ts @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {el} from "./html.js"; +import {el} from "./html"; import {mountView, insertAt} from "./utils"; import {BaseObservableList as ObservableList} from "../../../../observable/list/BaseObservableList.js"; import {UIView, IMountArgs} from "./types"; @@ -79,7 +79,7 @@ export class ListView implements UIView { if (this._className) { attr.className = this._className; } - this._root = el(this._tagName, attr); + this._root = el(this._tagName, attr) as HTMLElement; this.loadList(); if (this._onItemClick) { this._root!.addEventListener("click", this); diff --git a/src/platform/web/ui/general/StaticView.js b/src/platform/web/ui/general/StaticView.js index c87a0f3c..1c3f3ea2 100644 --- a/src/platform/web/ui/general/StaticView.js +++ b/src/platform/web/ui/general/StaticView.js @@ -15,7 +15,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {tag} from "../general/html.js"; +import {tag} from "../general/html"; export class StaticView { constructor(value, render = undefined) { diff --git a/src/platform/web/ui/general/TemplateView.js b/src/platform/web/ui/general/TemplateView.js index 3e9727dc..f6c7247f 100644 --- a/src/platform/web/ui/general/TemplateView.js +++ b/src/platform/web/ui/general/TemplateView.js @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { setAttribute, text, isChildren, classNames, TAG_NAMES, HTML_NS } from "./html.js"; +import { setAttribute, text, isChildren, classNames, TAG_NAMES, HTML_NS } from "./html"; import {mountView} from "./utils"; import {BaseUpdateView} from "./BaseUpdateView.js"; diff --git a/src/platform/web/ui/general/html.js b/src/platform/web/ui/general/html.ts similarity index 61% rename from src/platform/web/ui/general/html.js rename to src/platform/web/ui/general/html.ts index f12ad306..db320017 100644 --- a/src/platform/web/ui/general/html.js +++ b/src/platform/web/ui/general/html.ts @@ -1,5 +1,6 @@ /* Copyright 2020 Bruno Windels +Copyright 2021 Daniel Fedorin Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -16,12 +17,16 @@ limitations under the License. // DOM helper functions -export function isChildren(children) { +export type ClassNames = { [className: string]: boolean | ((value?: T) => boolean) } +export type BasicAttributes = { [attribute: string]: ClassNames | boolean | string } +export type Child = string | Text | Element + +export function isChildren(children: object | Child | Child[]): children is Child | Child[] { // children should be an not-object (that's the attributes), or a domnode, or an array - return typeof children !== "object" || !!children.nodeType || Array.isArray(children); + return typeof children !== "object" || "nodeType" in children || Array.isArray(children); } -export function classNames(obj, value) { +export function classNames(obj: ClassNames, value?: T): string { return Object.entries(obj).reduce((cn, [name, enabled]) => { if (typeof enabled === "function") { enabled = enabled(value); @@ -34,7 +39,7 @@ export function classNames(obj, value) { }, ""); } -export function setAttribute(el, name, value) { +export function setAttribute(el: Element, name: string, value: string | boolean): void { if (name === "className") { name = "class"; } @@ -48,22 +53,24 @@ export function setAttribute(el, name, value) { } } -export function el(elementName, attributes, children) { +export function el(elementName: string, attributes?: BasicAttributes | Child | Child[], children?: Child | Child[]): Element { return elNS(HTML_NS, elementName, attributes, children); } -export function elNS(ns, elementName, attributes, children) { +export function elNS(ns: string, elementName: string, attributes?: BasicAttributes | Child | Child[], children?: Child | Child[]): Element { if (attributes && isChildren(attributes)) { children = attributes; - attributes = null; + attributes = undefined; } const e = document.createElementNS(ns, elementName); if (attributes) { for (let [name, value] of Object.entries(attributes)) { - if (name === "className" && typeof value === "object" && value !== null) { - value = classNames(value); + if (typeof value === "object") { + // Only className should ever be an object; be careful + // here anyway and ignore object-valued non-className attributes. + value = (value !== null && name === "className") ? classNames(value) : false; } setAttribute(e, name, value); } @@ -74,7 +81,7 @@ export function elNS(ns, elementName, attributes, children) { children = [children]; } for (let c of children) { - if (!c.nodeType) { + if (typeof c === "string") { c = text(c); } e.appendChild(c); @@ -83,12 +90,12 @@ export function elNS(ns, elementName, attributes, children) { return e; } -export function text(str) { +export function text(str: string): Text { return document.createTextNode(str); } -export const HTML_NS = "http://www.w3.org/1999/xhtml"; -export const SVG_NS = "http://www.w3.org/2000/svg"; +export const HTML_NS: string = "http://www.w3.org/1999/xhtml"; +export const SVG_NS: string = "http://www.w3.org/2000/svg"; export const TAG_NAMES = { [HTML_NS]: [ @@ -97,10 +104,9 @@ export const TAG_NAMES = { "table", "thead", "tbody", "tr", "th", "td", "hr", "pre", "code", "button", "time", "input", "textarea", "label", "form", "progress", "output", "video"], [SVG_NS]: ["svg", "circle"] -}; - -export const tag = {}; +} as const; +export const tag: { [tagName in typeof TAG_NAMES[string][number]]: (attributes?: BasicAttributes | Child | Child[], children?: Child | Child[]) => Element } = {} as any; for (const [ns, tags] of Object.entries(TAG_NAMES)) { for (const tagName of tags) { diff --git a/src/platform/web/ui/general/utils.ts b/src/platform/web/ui/general/utils.ts index f4469ca1..1e4bbdaa 100644 --- a/src/platform/web/ui/general/utils.ts +++ b/src/platform/web/ui/general/utils.ts @@ -15,7 +15,7 @@ limitations under the License. */ import {UIView, IMountArgs} from "./types"; -import {tag} from "./html.js"; +import {tag} from "./html"; export function mountView(view: UIView, mountArgs: IMountArgs): HTMLElement { let node; @@ -38,7 +38,7 @@ export function errorToDOM(error: Error): HTMLElement { tag.h3(error.message), tag.p(`This occurred while running ${callee}.`), tag.pre(error.stack), - ]); + ]) as HTMLElement; } export function insertAt(parentNode: HTMLElement, idx: number, childNode: HTMLElement): void { diff --git a/src/platform/web/ui/session/rightpanel/RoomDetailsView.js b/src/platform/web/ui/session/rightpanel/RoomDetailsView.js index a9c4c109..763968ca 100644 --- a/src/platform/web/ui/session/rightpanel/RoomDetailsView.js +++ b/src/platform/web/ui/session/rightpanel/RoomDetailsView.js @@ -15,7 +15,7 @@ limitations under the License. */ import {TemplateView} from "../../general/TemplateView.js"; -import {classNames, tag} from "../../general/html.js"; +import {classNames, tag} from "../../general/html"; import {AvatarView} from "../../AvatarView.js"; export class RoomDetailsView extends TemplateView { diff --git a/src/platform/web/ui/session/room/timeline/BaseMessageView.js b/src/platform/web/ui/session/room/timeline/BaseMessageView.js index 9469d201..69ae4bd3 100644 --- a/src/platform/web/ui/session/room/timeline/BaseMessageView.js +++ b/src/platform/web/ui/session/room/timeline/BaseMessageView.js @@ -16,7 +16,7 @@ limitations under the License. */ import {renderStaticAvatar} from "../../../avatar.js"; -import {tag} from "../../../general/html.js"; +import {tag} from "../../../general/html"; import {mountView} from "../../../general/utils"; import {TemplateView} from "../../../general/TemplateView.js"; import {Popup} from "../../../general/Popup.js"; diff --git a/src/platform/web/ui/session/room/timeline/TextMessageView.js b/src/platform/web/ui/session/room/timeline/TextMessageView.js index fcafaf27..c1674501 100644 --- a/src/platform/web/ui/session/room/timeline/TextMessageView.js +++ b/src/platform/web/ui/session/room/timeline/TextMessageView.js @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {tag, text} from "../../../general/html.js"; +import {tag, text} from "../../../general/html"; import {BaseMessageView} from "./BaseMessageView.js"; export class TextMessageView extends BaseMessageView { From 65f69a121b94e1e04958cc519e450d938cd47723 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 16 Sep 2021 14:01:33 +0200 Subject: [PATCH 27/41] copy Daniel's conversion of BaseUpdateView to TypeScript from microui --- src/platform/web/ui/AvatarView.js | 4 +-- .../{BaseUpdateView.js => BaseUpdateView.ts} | 33 ++++++++++++++----- src/platform/web/ui/general/ListView.ts | 18 +++++----- src/platform/web/ui/general/TemplateView.js | 4 +-- src/platform/web/ui/general/types.ts | 10 ++++-- src/platform/web/ui/general/utils.ts | 2 +- 6 files changed, 45 insertions(+), 26 deletions(-) rename src/platform/web/ui/general/{BaseUpdateView.js => BaseUpdateView.ts} (63%) diff --git a/src/platform/web/ui/AvatarView.js b/src/platform/web/ui/AvatarView.js index 30fe761f..f2d94e3b 100644 --- a/src/platform/web/ui/AvatarView.js +++ b/src/platform/web/ui/AvatarView.js @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {BaseUpdateView} from "./general/BaseUpdateView.js"; +import {BaseUpdateView} from "./general/BaseUpdateView"; import {renderStaticAvatar, renderImg} from "./avatar.js"; /* @@ -66,7 +66,7 @@ export class AvatarView extends BaseUpdateView { this._avatarTitleChanged(); this._root = renderStaticAvatar(this.value, this._size); // takes care of update being called when needed - super.mount(options); + this.subscribeOnMount(options); return this._root; } diff --git a/src/platform/web/ui/general/BaseUpdateView.js b/src/platform/web/ui/general/BaseUpdateView.ts similarity index 63% rename from src/platform/web/ui/general/BaseUpdateView.js rename to src/platform/web/ui/general/BaseUpdateView.ts index 4f346499..623df99a 100644 --- a/src/platform/web/ui/general/BaseUpdateView.js +++ b/src/platform/web/ui/general/BaseUpdateView.ts @@ -1,5 +1,6 @@ /* Copyright 2021 The Matrix.org Foundation C.I.C. +Copyright 2021 Daniel Fedorin Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -14,40 +15,54 @@ See the License for the specific language governing permissions and limitations under the License. */ -export class BaseUpdateView { - constructor(value) { +import {IMountArgs, ViewNode, UIView} from "./types"; + +export interface IObservableValue { + on?(event: "change", handler: (props?: string[]) => void): void; + off?(event: "change", handler: (props?: string[]) => void): void; +} + +export abstract class BaseUpdateView implements UIView { + protected _value: T + protected _boundUpdateFromValue: ((props?: string[]) => void) | null + + abstract mount(args?: IMountArgs): ViewNode; + abstract root(): ViewNode | undefined; + abstract update(...any); + + constructor(value :T) { this._value = value; // TODO: can avoid this if we adopt the handleEvent pattern in our EventListener this._boundUpdateFromValue = null; } - mount(options) { + subscribeOnMount(options?: IMountArgs): void { const parentProvidesUpdates = options && options.parentProvidesUpdates; if (!parentProvidesUpdates) { this._subscribe(); } } - unmount() { + unmount(): void { this._unsubscribe(); } - get value() { + get value(): T { return this._value; } - _updateFromValue(changedProps) { + _updateFromValue(changedProps?: string[]) { this.update(this._value, changedProps); } - _subscribe() { + _subscribe(): void { if (typeof this._value?.on === "function") { - this._boundUpdateFromValue = this._updateFromValue.bind(this); + this._boundUpdateFromValue = this._updateFromValue.bind(this) as (props?: string[]) => void; this._value.on("change", this._boundUpdateFromValue); } } - _unsubscribe() { + _unsubscribe(): void { if (this._boundUpdateFromValue) { if (typeof this._value.off === "function") { this._value.off("change", this._boundUpdateFromValue); diff --git a/src/platform/web/ui/general/ListView.ts b/src/platform/web/ui/general/ListView.ts index 2bd3b7d3..eaa5ea90 100644 --- a/src/platform/web/ui/general/ListView.ts +++ b/src/platform/web/ui/general/ListView.ts @@ -35,7 +35,7 @@ export class ListView implements UIView { private _list: ObservableList; private _className?: string; private _tagName: string; - private _root?: HTMLElement; + private _root?: Element; private _subscription?: SubscriptionHandle; private _childCreator: (value: T) => V; private _childInstances?: V[]; @@ -56,9 +56,9 @@ export class ListView implements UIView { this._mountArgs = {parentProvidesUpdates}; } - root(): HTMLElement { + root(): Element | undefined { // won't be undefined when called between mount and unmount - return this._root!; + return this._root; } update(attributes: IOptions) { @@ -74,12 +74,12 @@ export class ListView implements UIView { } } - mount(): HTMLElement { + mount(): Element { const attr: {[name: string]: any} = {}; if (this._className) { attr.className = this._className; } - this._root = el(this._tagName, attr) as HTMLElement; + this._root = el(this._tagName, attr); this.loadList(); if (this._onItemClick) { this._root!.addEventListener("click", this); @@ -145,15 +145,15 @@ export class ListView implements UIView { protected onRemove(idx: number, value: T) { const [child] = this._childInstances!.splice(idx, 1); - child.root().remove(); + child.root()!.remove(); child.unmount(); } protected onMove(fromIdx: number, toIdx: number, value: T) { const [child] = this._childInstances!.splice(fromIdx, 1); this._childInstances!.splice(toIdx, 0, child); - child.root().remove(); - insertAt(this._root!, toIdx, child.root()); + child.root()!.remove(); + insertAt(this._root!, toIdx, child.root()! as Element); } protected onUpdate(i: number, value: T, params: any) { @@ -170,7 +170,7 @@ export class ListView implements UIView { this.onRemove(index, value); } else { const [oldChild] = this._childInstances!.splice(index, 1, child); - this._root!.replaceChild(child.mount(this._mountArgs), oldChild.root()); + this._root!.replaceChild(child.mount(this._mountArgs), oldChild.root()!); oldChild.unmount(); } } diff --git a/src/platform/web/ui/general/TemplateView.js b/src/platform/web/ui/general/TemplateView.js index f6c7247f..d0be8c0c 100644 --- a/src/platform/web/ui/general/TemplateView.js +++ b/src/platform/web/ui/general/TemplateView.js @@ -16,7 +16,7 @@ limitations under the License. import { setAttribute, text, isChildren, classNames, TAG_NAMES, HTML_NS } from "./html"; import {mountView} from "./utils"; -import {BaseUpdateView} from "./BaseUpdateView.js"; +import {BaseUpdateView} from "./BaseUpdateView"; function objHasFns(obj) { for(const value of Object.values(obj)) { @@ -80,7 +80,7 @@ export class TemplateView extends BaseUpdateView { builder.close(); } // takes care of update being called when needed - super.mount(options); + this.subscribeOnMount(options); this._attach(); return this._root; } diff --git a/src/platform/web/ui/general/types.ts b/src/platform/web/ui/general/types.ts index f8f671c7..f38c8a5e 100644 --- a/src/platform/web/ui/general/types.ts +++ b/src/platform/web/ui/general/types.ts @@ -1,5 +1,6 @@ /* Copyright 2021 The Matrix.org Foundation C.I.C. +Copyright 2021 Daniel Fedorin Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -15,12 +16,15 @@ limitations under the License. */ export interface IMountArgs { // if true, the parent will call update() rather than the view updating itself by binding to a data source. - parentProvidesUpdates: boolean + parentProvidesUpdates?: boolean }; +// Comment nodes can be used as temporary placeholders for Elements, like TemplateView does. +export type ViewNode = Element | Comment; + export interface UIView { - mount(args?: IMountArgs): HTMLElement; - root(): HTMLElement; // should only be called between mount() and unmount() + mount(args?: IMountArgs): ViewNode; + root(): ViewNode | undefined; // should only be called between mount() and unmount() unmount(): void; update(...any); // this isn't really standarized yet } diff --git a/src/platform/web/ui/general/utils.ts b/src/platform/web/ui/general/utils.ts index 1e4bbdaa..a3d6e7ac 100644 --- a/src/platform/web/ui/general/utils.ts +++ b/src/platform/web/ui/general/utils.ts @@ -41,7 +41,7 @@ export function errorToDOM(error: Error): HTMLElement { ]) as HTMLElement; } -export function insertAt(parentNode: HTMLElement, idx: number, childNode: HTMLElement): void { +export function insertAt(parentNode: Element, idx: number, childNode: Element): void { const isLast = idx === parentNode.childElementCount; if (isLast) { parentNode.appendChild(childNode); From 060f4aa297c70655718460b5345677a7be7b6f23 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 16 Sep 2021 14:02:36 +0200 Subject: [PATCH 28/41] change extension in preparation for TS conversion --- src/platform/web/ui/general/{TemplateView.js => TemplateView.ts} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename src/platform/web/ui/general/{TemplateView.js => TemplateView.ts} (100%) diff --git a/src/platform/web/ui/general/TemplateView.js b/src/platform/web/ui/general/TemplateView.ts similarity index 100% rename from src/platform/web/ui/general/TemplateView.js rename to src/platform/web/ui/general/TemplateView.ts From 68fb093c9e094175cdf495aece94ea8e3081bc18 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 16 Sep 2021 15:23:48 +0200 Subject: [PATCH 29/41] don't require mount args in mountView, like in UIView interface --- src/platform/web/ui/general/utils.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/platform/web/ui/general/utils.ts b/src/platform/web/ui/general/utils.ts index a3d6e7ac..83d02f4c 100644 --- a/src/platform/web/ui/general/utils.ts +++ b/src/platform/web/ui/general/utils.ts @@ -17,7 +17,7 @@ limitations under the License. import {UIView, IMountArgs} from "./types"; import {tag} from "./html"; -export function mountView(view: UIView, mountArgs: IMountArgs): HTMLElement { +export function mountView(view: UIView, mountArgs?: IMountArgs): HTMLElement { let node; try { node = view.mount(mountArgs); From 040efa970c2cc965fb3830fbc12b2f623696dd9f Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 16 Sep 2021 15:38:44 +0200 Subject: [PATCH 30/41] make className binding always have a value (may be undefined through T) --- src/platform/web/ui/general/html.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/platform/web/ui/general/html.ts b/src/platform/web/ui/general/html.ts index db320017..1c51351f 100644 --- a/src/platform/web/ui/general/html.ts +++ b/src/platform/web/ui/general/html.ts @@ -17,7 +17,7 @@ limitations under the License. // DOM helper functions -export type ClassNames = { [className: string]: boolean | ((value?: T) => boolean) } +export type ClassNames = { [className: string]: boolean | ((value: T) => boolean) } export type BasicAttributes = { [attribute: string]: ClassNames | boolean | string } export type Child = string | Text | Element @@ -26,7 +26,7 @@ export function isChildren(children: object | Child | Child[]): children is Chil return typeof children !== "object" || "nodeType" in children || Array.isArray(children); } -export function classNames(obj: ClassNames, value?: T): string { +export function classNames(obj: ClassNames, value: T): string { return Object.entries(obj).reduce((cn, [name, enabled]) => { if (typeof enabled === "function") { enabled = enabled(value); @@ -70,7 +70,7 @@ export function elNS(ns: string, elementName: string, attributes?: BasicAttribut if (typeof value === "object") { // Only className should ever be an object; be careful // here anyway and ignore object-valued non-className attributes. - value = (value !== null && name === "className") ? classNames(value) : false; + value = (value !== null && name === "className") ? classNames(value, undefined) : false; } setAttribute(e, name, value); } From ea4d833a4373cef6a69cd314ba062eed217eb9fd Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 16 Sep 2021 15:39:25 +0200 Subject: [PATCH 31/41] reuse ViewNode in Child type --- src/platform/web/ui/general/html.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/platform/web/ui/general/html.ts b/src/platform/web/ui/general/html.ts index 1c51351f..9ebcfaaf 100644 --- a/src/platform/web/ui/general/html.ts +++ b/src/platform/web/ui/general/html.ts @@ -17,9 +17,11 @@ limitations under the License. // DOM helper functions +import {ViewNode} from "./types"; + export type ClassNames = { [className: string]: boolean | ((value: T) => boolean) } export type BasicAttributes = { [attribute: string]: ClassNames | boolean | string } -export type Child = string | Text | Element +export type Child = string | Text | ViewNode; export function isChildren(children: object | Child | Child[]): children is Child | Child[] { // children should be an not-object (that's the attributes), or a domnode, or an array From 00aa40ea7b59262b845483d8f849ed5a4f9c8d61 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 16 Sep 2021 15:46:02 +0200 Subject: [PATCH 32/41] copy Daniel's conversion of TemplateView to TypeScript from microui with some minor type adjustments --- src/platform/web/ui/RootView.js | 2 +- src/platform/web/ui/general/LoadingView.js | 2 +- src/platform/web/ui/general/Menu.js | 2 +- src/platform/web/ui/general/TemplateView.ts | 150 +++++++++++------- src/platform/web/ui/login/CompleteSSOView.js | 2 +- src/platform/web/ui/login/LoginView.js | 2 +- .../web/ui/login/PasswordLoginView.js | 2 +- .../web/ui/login/SessionLoadStatusView.js | 2 +- src/platform/web/ui/login/SessionLoadView.js | 2 +- .../web/ui/login/SessionPickerView.js | 2 +- src/platform/web/ui/session/RoomGridView.js | 2 +- .../web/ui/session/SessionStatusView.js | 2 +- src/platform/web/ui/session/SessionView.js | 2 +- .../ui/session/leftpanel/InviteTileView.js | 2 +- .../web/ui/session/leftpanel/LeftPanelView.js | 2 +- .../web/ui/session/leftpanel/RoomTileView.js | 2 +- .../session/rightpanel/MemberDetailsView.js | 2 +- .../ui/session/rightpanel/MemberTileView.js | 2 +- .../ui/session/rightpanel/RightPanelView.js | 2 +- .../ui/session/rightpanel/RoomDetailsView.js | 2 +- .../web/ui/session/room/InviteView.js | 2 +- .../web/ui/session/room/LightboxView.js | 2 +- .../web/ui/session/room/MessageComposer.js | 2 +- .../web/ui/session/room/RoomArchivedView.js | 2 +- src/platform/web/ui/session/room/RoomView.js | 2 +- .../ui/session/room/TimelineLoadingView.js | 2 +- .../web/ui/session/room/UnknownRoomView.js | 2 +- .../session/room/timeline/AnnouncementView.js | 2 +- .../session/room/timeline/BaseMessageView.js | 2 +- .../web/ui/session/room/timeline/GapView.js | 2 +- .../ui/session/room/timeline/ReactionsView.js | 2 +- .../settings/SessionBackupSettingsView.js | 2 +- .../web/ui/session/settings/SettingsView.js | 2 +- 33 files changed, 122 insertions(+), 92 deletions(-) diff --git a/src/platform/web/ui/RootView.js b/src/platform/web/ui/RootView.js index f60bb984..a44100a8 100644 --- a/src/platform/web/ui/RootView.js +++ b/src/platform/web/ui/RootView.js @@ -18,7 +18,7 @@ import {SessionView} from "./session/SessionView.js"; import {LoginView} from "./login/LoginView.js"; import {SessionLoadView} from "./login/SessionLoadView.js"; import {SessionPickerView} from "./login/SessionPickerView.js"; -import {TemplateView} from "./general/TemplateView.js"; +import {TemplateView} from "./general/TemplateView"; import {StaticView} from "./general/StaticView.js"; export class RootView extends TemplateView { diff --git a/src/platform/web/ui/general/LoadingView.js b/src/platform/web/ui/general/LoadingView.js index b85eed04..f2ab20cc 100644 --- a/src/platform/web/ui/general/LoadingView.js +++ b/src/platform/web/ui/general/LoadingView.js @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {TemplateView} from "./TemplateView.js"; +import {TemplateView} from "./TemplateView"; import {spinner} from "../common.js"; export class LoadingView extends TemplateView { diff --git a/src/platform/web/ui/general/Menu.js b/src/platform/web/ui/general/Menu.js index b846b0e5..6094cbfb 100644 --- a/src/platform/web/ui/general/Menu.js +++ b/src/platform/web/ui/general/Menu.js @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {TemplateView} from "./TemplateView.js"; +import {TemplateView} from "./TemplateView"; export class Menu extends TemplateView { static option(label, callback) { diff --git a/src/platform/web/ui/general/TemplateView.ts b/src/platform/web/ui/general/TemplateView.ts index d0be8c0c..2fb17f86 100644 --- a/src/platform/web/ui/general/TemplateView.ts +++ b/src/platform/web/ui/general/TemplateView.ts @@ -1,5 +1,6 @@ /* Copyright 2020 Bruno Windels +Copyright 2021 Daniel Fedorin Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -14,11 +15,12 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { setAttribute, text, isChildren, classNames, TAG_NAMES, HTML_NS } from "./html"; +import { setAttribute, text, isChildren, classNames, TAG_NAMES, HTML_NS, ClassNames, Child} from "./html"; import {mountView} from "./utils"; -import {BaseUpdateView} from "./BaseUpdateView"; +import {BaseUpdateView, IObservableValue} from "./BaseUpdateView"; +import {IMountArgs, ViewNode, UIView} from "./types"; -function objHasFns(obj) { +function objHasFns(obj: ClassNames): obj is { [className: string]: boolean } { for(const value of Object.values(obj)) { if (typeof value === "function") { return true; @@ -26,6 +28,17 @@ function objHasFns(obj) { } return false; } + + +export type RenderFn = (t: Builder, vm: T) => ViewNode; +type EventHandler = ((event: Event) => void); +type AttributeStaticValue = string | boolean; +type AttributeBinding = (value: T) => AttributeStaticValue; +export type AttrValue = AttributeStaticValue | AttributeBinding | EventHandler | ClassNames; +export type Attributes = { [attribute: string]: AttrValue }; +type ElementFn = (attributes?: Attributes | Child | Child[], children?: Child | Child[]) => Element; +export type Builder = TemplateBuilder & { [tagName in typeof TAG_NAMES[string][number]]: ElementFn }; + /** Bindable template. Renders once, and allows bindings for given nodes. If you need to change the structure on a condition, use a subtemplate (if) @@ -39,18 +52,21 @@ function objHasFns(obj) { - add subviews inside the template */ // TODO: should we rename this to BoundView or something? As opposed to StaticView ... -export class TemplateView extends BaseUpdateView { - constructor(value, render = undefined) { +export class TemplateView extends BaseUpdateView { + private _render?: RenderFn; + private _eventListeners?: { node: Element, name: string, fn: EventHandler, useCapture: boolean }[] = undefined; + private _bindings?: (() => void)[] = undefined; + private _root?: ViewNode = undefined; + // public because used by TemplateBuilder + _subViews?: UIView[] = undefined; + + constructor(value: T, render?: RenderFn) { super(value); // TODO: can avoid this if we have a separate class for inline templates vs class template views this._render = render; - this._eventListeners = null; - this._bindings = null; - this._subViews = null; - this._root = null; } - _attach() { + _attach(): void { if (this._eventListeners) { for (let {node, name, fn, useCapture} of this._eventListeners) { node.addEventListener(name, fn, useCapture); @@ -58,7 +74,7 @@ export class TemplateView extends BaseUpdateView { } } - _detach() { + _detach(): void { if (this._eventListeners) { for (let {node, name, fn, useCapture} of this._eventListeners) { node.removeEventListener(name, fn, useCapture); @@ -66,13 +82,13 @@ export class TemplateView extends BaseUpdateView { } } - mount(options) { - const builder = new TemplateBuilder(this); + mount(options?: IMountArgs): ViewNode { + const builder = new TemplateBuilder(this) as Builder; try { if (this._render) { this._root = this._render(builder, this._value); - } else if (this.render) { // overriden in subclass - this._root = this.render(builder, this._value); + } else if (this["render"]) { // overriden in subclass + this._root = this["render"](builder, this._value); } else { throw new Error("no render function passed in, or overriden in subclass"); } @@ -82,10 +98,10 @@ export class TemplateView extends BaseUpdateView { // takes care of update being called when needed this.subscribeOnMount(options); this._attach(); - return this._root; + return this._root!; } - unmount() { + unmount(): void { this._detach(); super.unmount(); if (this._subViews) { @@ -95,11 +111,11 @@ export class TemplateView extends BaseUpdateView { } } - root() { + root(): ViewNode | undefined { return this._root; } - update(value) { + update(value: T, props?: string[]): void { this._value = value; if (this._bindings) { for (const binding of this._bindings) { @@ -108,35 +124,36 @@ export class TemplateView extends BaseUpdateView { } } - _addEventListener(node, name, fn, useCapture = false) { + _addEventListener(node: Element, name: string, fn: (event: Event) => void, useCapture: boolean = false): void { if (!this._eventListeners) { this._eventListeners = []; } this._eventListeners.push({node, name, fn, useCapture}); } - _addBinding(bindingFn) { + _addBinding(bindingFn: () => void): void { if (!this._bindings) { this._bindings = []; } this._bindings.push(bindingFn); } - addSubView(view) { + addSubView(view: UIView): void { if (!this._subViews) { this._subViews = []; } this._subViews.push(view); } - removeSubView(view) { + removeSubView(view: UIView): void { + if (!this._subViews) { return; } const idx = this._subViews.indexOf(view); if (idx !== -1) { this._subViews.splice(idx, 1); } } - updateSubViews(value, props) { + updateSubViews(value: IObservableValue, props: string[]) { if (this._subViews) { for (const v of this._subViews) { v.update(value, props); @@ -146,33 +163,35 @@ export class TemplateView extends BaseUpdateView { } // what is passed to render -class TemplateBuilder { - constructor(templateView) { +export class TemplateBuilder { + private _templateView: TemplateView; + private _closed: boolean = false; + + constructor(templateView: TemplateView) { this._templateView = templateView; - this._closed = false; } - close() { + close(): void { this._closed = true; } - _addBinding(fn) { + _addBinding(fn: () => void): void { if (this._closed) { console.trace("Adding a binding after render will likely cause memory leaks"); } this._templateView._addBinding(fn); } - get _value() { - return this._templateView._value; + get _value(): T { + return this._templateView.value; } - addEventListener(node, name, fn, useCapture = false) { + addEventListener(node: Element, name: string, fn: (event: Event) => void, useCapture: boolean = false): void { this._templateView._addEventListener(node, name, fn, useCapture); } - _addAttributeBinding(node, name, fn) { - let prevValue = undefined; + _addAttributeBinding(node: Element, name: string, fn: (value: T) => boolean | string): void { + let prevValue: string | boolean | undefined = undefined; const binding = () => { const newValue = fn(this._value); if (prevValue !== newValue) { @@ -184,11 +203,11 @@ class TemplateBuilder { binding(); } - _addClassNamesBinding(node, obj) { + _addClassNamesBinding(node: Element, obj: ClassNames): void { this._addAttributeBinding(node, "className", value => classNames(obj, value)); } - _addTextBinding(fn) { + _addTextBinding(fn: (value: T) => string): Text { const initialValue = fn(this._value); const node = text(initialValue); let prevValue = initialValue; @@ -204,21 +223,30 @@ class TemplateBuilder { return node; } - _setNodeAttributes(node, attributes) { + _isEventHandler(key: string, value: AttrValue): value is (event: Event) => void { + // This isn't actually safe, but it's incorrect to feed event handlers to + // non-on* attributes. + return key.startsWith("on") && key.length > 2 && typeof value === "function"; + } + + _setNodeAttributes(node: Element, attributes: Attributes): void { for(let [key, value] of Object.entries(attributes)) { - const isFn = typeof value === "function"; // binding for className as object of className => enabled - if (key === "className" && typeof value === "object" && value !== null) { + if (typeof value === "object") { + if (key !== "className" || value === null) { + // Ignore non-className objects. + continue; + } if (objHasFns(value)) { this._addClassNamesBinding(node, value); } else { - setAttribute(node, key, classNames(value)); + setAttribute(node, key, classNames(value, this._value)); } - } else if (key.startsWith("on") && key.length > 2 && isFn) { + } else if (this._isEventHandler(key, value)) { const eventName = key.substr(2, 1).toLowerCase() + key.substr(3); const handler = value; this._templateView._addEventListener(node, eventName, handler); - } else if (isFn) { + } else if (typeof value === "function") { this._addAttributeBinding(node, key, value); } else { setAttribute(node, key, value); @@ -226,14 +254,14 @@ class TemplateBuilder { } } - _setNodeChildren(node, children) { + _setNodeChildren(node: Element, children: Child | Child[]): void{ if (!Array.isArray(children)) { children = [children]; } for (let child of children) { if (typeof child === "function") { child = this._addTextBinding(child); - } else if (!child.nodeType) { + } else if (typeof child === "string") { // not a DOM node, turn into text child = text(child); } @@ -241,7 +269,7 @@ class TemplateBuilder { } } - _addReplaceNodeBinding(fn, renderNode) { + _addReplaceNodeBinding(fn: (value: T) => R, renderNode: (old: ViewNode | null) => ViewNode): ViewNode { let prevValue = fn(this._value); let node = renderNode(null); @@ -260,14 +288,14 @@ class TemplateBuilder { return node; } - el(name, attributes, children) { + el(name: string, attributes?: Attributes | Child | Child[], children?: Child | Child[]): ViewNode { return this.elNS(HTML_NS, name, attributes, children); } - elNS(ns, name, attributes, children) { - if (attributes && isChildren(attributes)) { + elNS(ns: string, name: string, attributes?: Attributes | Child | Child[], children?: Child | Child[]): ViewNode { + if (attributes !== undefined && isChildren(attributes)) { children = attributes; - attributes = null; + attributes = undefined; } const node = document.createElementNS(ns, name); @@ -284,20 +312,22 @@ class TemplateBuilder { // this inserts a view, and is not a view factory for `if`, so returns the root element to insert in the template // you should not call t.view() and not use the result (e.g. attach the result to the template DOM tree). - view(view, mountOptions = undefined) { + view(view: UIView, mountOptions?: IMountArgs): ViewNode { this._templateView.addSubView(view); return mountView(view, mountOptions); } // map a value to a view, every time the value changes - mapView(mapFn, viewCreator) { + mapView(mapFn: (value: T) => R, viewCreator: (mapped: R) => UIView | null): ViewNode { return this._addReplaceNodeBinding(mapFn, (prevNode) => { if (prevNode && prevNode.nodeType !== Node.COMMENT_NODE) { const subViews = this._templateView._subViews; - const viewIdx = subViews.findIndex(v => v.root() === prevNode); - if (viewIdx !== -1) { - const [view] = subViews.splice(viewIdx, 1); - view.unmount(); + if (subViews) { + const viewIdx = subViews.findIndex(v => v.root() === prevNode); + if (viewIdx !== -1) { + const [view] = subViews.splice(viewIdx, 1); + view.unmount(); + } } } const view = viewCreator(mapFn(this._value)); @@ -312,7 +342,7 @@ class TemplateBuilder { // Special case of mapView for a TemplateView. // Always creates a TemplateView, if this is optional depending // on mappedValue, use `if` or `mapView` - map(mapFn, renderFn) { + map(mapFn: (value: T) => R, renderFn: (mapped: R, t: Builder, vm: T) => ViewNode): ViewNode { return this.mapView(mapFn, mappedValue => { return new TemplateView(this._value, (t, vm) => { const rootNode = renderFn(mappedValue, t, vm); @@ -326,7 +356,7 @@ class TemplateBuilder { }); } - ifView(predicate, viewCreator) { + ifView(predicate: (value: T) => boolean, viewCreator: (value: T) => UIView): ViewNode { return this.mapView( value => !!predicate(value), enabled => enabled ? viewCreator(this._value) : null @@ -335,7 +365,7 @@ class TemplateBuilder { // creates a conditional subtemplate // use mapView if you need to map to a different view class - if(predicate, renderFn) { + if(predicate: (value: T) => boolean, renderFn: (t: Builder, vm: T) => ViewNode) { return this.ifView(predicate, vm => new TemplateView(vm, renderFn)); } @@ -345,8 +375,8 @@ class TemplateBuilder { This should only be used if the side-effect won't add any bindings, event handlers, ... You should not call the TemplateBuilder (e.g. `t.xxx()`) at all from the side effect, - instead use tags from html.js to help you construct any DOM you need. */ - mapSideEffect(mapFn, sideEffect) { + instead use tags from html.ts to help you construct any DOM you need. */ + mapSideEffect(mapFn: (value: T) => R, sideEffect: (newV: R, oldV: R | undefined) => void) { let prevValue = mapFn(this._value); const binding = () => { const newValue = mapFn(this._value); diff --git a/src/platform/web/ui/login/CompleteSSOView.js b/src/platform/web/ui/login/CompleteSSOView.js index 63614acf..20f4c0ad 100644 --- a/src/platform/web/ui/login/CompleteSSOView.js +++ b/src/platform/web/ui/login/CompleteSSOView.js @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {TemplateView} from "../general/TemplateView.js"; +import {TemplateView} from "../general/TemplateView"; import {SessionLoadStatusView} from "./SessionLoadStatusView.js"; export class CompleteSSOView extends TemplateView { diff --git a/src/platform/web/ui/login/LoginView.js b/src/platform/web/ui/login/LoginView.js index aa89ccca..e8e1a0bf 100644 --- a/src/platform/web/ui/login/LoginView.js +++ b/src/platform/web/ui/login/LoginView.js @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {TemplateView} from "../general/TemplateView.js"; +import {TemplateView} from "../general/TemplateView"; import {hydrogenGithubLink} from "./common.js"; import {PasswordLoginView} from "./PasswordLoginView.js"; import {CompleteSSOView} from "./CompleteSSOView.js"; diff --git a/src/platform/web/ui/login/PasswordLoginView.js b/src/platform/web/ui/login/PasswordLoginView.js index 130f30ae..4360cc0f 100644 --- a/src/platform/web/ui/login/PasswordLoginView.js +++ b/src/platform/web/ui/login/PasswordLoginView.js @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {TemplateView} from "../general/TemplateView.js"; +import {TemplateView} from "../general/TemplateView"; export class PasswordLoginView extends TemplateView { render(t, vm) { diff --git a/src/platform/web/ui/login/SessionLoadStatusView.js b/src/platform/web/ui/login/SessionLoadStatusView.js index 888e46b4..d3ff2c9a 100644 --- a/src/platform/web/ui/login/SessionLoadStatusView.js +++ b/src/platform/web/ui/login/SessionLoadStatusView.js @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {TemplateView} from "../general/TemplateView.js"; +import {TemplateView} from "../general/TemplateView"; import {spinner} from "../common.js"; /** a view used both in the login view and the loading screen diff --git a/src/platform/web/ui/login/SessionLoadView.js b/src/platform/web/ui/login/SessionLoadView.js index 6837cf21..4f546b70 100644 --- a/src/platform/web/ui/login/SessionLoadView.js +++ b/src/platform/web/ui/login/SessionLoadView.js @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {TemplateView} from "../general/TemplateView.js"; +import {TemplateView} from "../general/TemplateView"; import {SessionLoadStatusView} from "./SessionLoadStatusView.js"; export class SessionLoadView extends TemplateView { diff --git a/src/platform/web/ui/login/SessionPickerView.js b/src/platform/web/ui/login/SessionPickerView.js index a4feea17..775aa19b 100644 --- a/src/platform/web/ui/login/SessionPickerView.js +++ b/src/platform/web/ui/login/SessionPickerView.js @@ -15,7 +15,7 @@ limitations under the License. */ import {ListView} from "../general/ListView"; -import {TemplateView} from "../general/TemplateView.js"; +import {TemplateView} from "../general/TemplateView"; import {hydrogenGithubLink} from "./common.js"; import {SessionLoadStatusView} from "./SessionLoadStatusView.js"; diff --git a/src/platform/web/ui/session/RoomGridView.js b/src/platform/web/ui/session/RoomGridView.js index 043137fb..fc6497da 100644 --- a/src/platform/web/ui/session/RoomGridView.js +++ b/src/platform/web/ui/session/RoomGridView.js @@ -16,7 +16,7 @@ limitations under the License. import {RoomView} from "./room/RoomView.js"; import {InviteView} from "./room/InviteView.js"; -import {TemplateView} from "../general/TemplateView.js"; +import {TemplateView} from "../general/TemplateView"; import {StaticView} from "../general/StaticView.js"; export class RoomGridView extends TemplateView { diff --git a/src/platform/web/ui/session/SessionStatusView.js b/src/platform/web/ui/session/SessionStatusView.js index fff25453..bd8c6dbb 100644 --- a/src/platform/web/ui/session/SessionStatusView.js +++ b/src/platform/web/ui/session/SessionStatusView.js @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {TemplateView} from "../general/TemplateView.js"; +import {TemplateView} from "../general/TemplateView"; import {spinner} from "../common.js"; export class SessionStatusView extends TemplateView { diff --git a/src/platform/web/ui/session/SessionView.js b/src/platform/web/ui/session/SessionView.js index 0cda8428..f2ac2971 100644 --- a/src/platform/web/ui/session/SessionView.js +++ b/src/platform/web/ui/session/SessionView.js @@ -20,7 +20,7 @@ import {RoomView} from "./room/RoomView.js"; import {UnknownRoomView} from "./room/UnknownRoomView.js"; import {InviteView} from "./room/InviteView.js"; import {LightboxView} from "./room/LightboxView.js"; -import {TemplateView} from "../general/TemplateView.js"; +import {TemplateView} from "../general/TemplateView"; import {StaticView} from "../general/StaticView.js"; import {SessionStatusView} from "./SessionStatusView.js"; import {RoomGridView} from "./RoomGridView.js"; diff --git a/src/platform/web/ui/session/leftpanel/InviteTileView.js b/src/platform/web/ui/session/leftpanel/InviteTileView.js index 09b9401f..b99ab1c6 100644 --- a/src/platform/web/ui/session/leftpanel/InviteTileView.js +++ b/src/platform/web/ui/session/leftpanel/InviteTileView.js @@ -15,7 +15,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {TemplateView} from "../../general/TemplateView.js"; +import {TemplateView} from "../../general/TemplateView"; import {renderStaticAvatar} from "../../avatar.js"; import {spinner} from "../../common.js"; diff --git a/src/platform/web/ui/session/leftpanel/LeftPanelView.js b/src/platform/web/ui/session/leftpanel/LeftPanelView.js index d8f21aa2..7742b93e 100644 --- a/src/platform/web/ui/session/leftpanel/LeftPanelView.js +++ b/src/platform/web/ui/session/leftpanel/LeftPanelView.js @@ -15,7 +15,7 @@ limitations under the License. */ import {ListView} from "../../general/ListView"; -import {TemplateView} from "../../general/TemplateView.js"; +import {TemplateView} from "../../general/TemplateView"; import {RoomTileView} from "./RoomTileView.js"; import {InviteTileView} from "./InviteTileView.js"; diff --git a/src/platform/web/ui/session/leftpanel/RoomTileView.js b/src/platform/web/ui/session/leftpanel/RoomTileView.js index 228addba..28957541 100644 --- a/src/platform/web/ui/session/leftpanel/RoomTileView.js +++ b/src/platform/web/ui/session/leftpanel/RoomTileView.js @@ -15,7 +15,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {TemplateView} from "../../general/TemplateView.js"; +import {TemplateView} from "../../general/TemplateView"; import {AvatarView} from "../../AvatarView.js"; export class RoomTileView extends TemplateView { diff --git a/src/platform/web/ui/session/rightpanel/MemberDetailsView.js b/src/platform/web/ui/session/rightpanel/MemberDetailsView.js index 5ada0912..c249515a 100644 --- a/src/platform/web/ui/session/rightpanel/MemberDetailsView.js +++ b/src/platform/web/ui/session/rightpanel/MemberDetailsView.js @@ -15,7 +15,7 @@ limitations under the License. */ import {AvatarView} from "../../AvatarView.js"; -import {TemplateView} from "../../general/TemplateView.js"; +import {TemplateView} from "../../general/TemplateView"; export class MemberDetailsView extends TemplateView { render(t, vm) { diff --git a/src/platform/web/ui/session/rightpanel/MemberTileView.js b/src/platform/web/ui/session/rightpanel/MemberTileView.js index df9f597a..52a14cd5 100644 --- a/src/platform/web/ui/session/rightpanel/MemberTileView.js +++ b/src/platform/web/ui/session/rightpanel/MemberTileView.js @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {TemplateView} from "../../general/TemplateView.js"; +import {TemplateView} from "../../general/TemplateView"; import {AvatarView} from "../../AvatarView.js"; export class MemberTileView extends TemplateView { diff --git a/src/platform/web/ui/session/rightpanel/RightPanelView.js b/src/platform/web/ui/session/rightpanel/RightPanelView.js index b94d306e..5297d2ef 100644 --- a/src/platform/web/ui/session/rightpanel/RightPanelView.js +++ b/src/platform/web/ui/session/rightpanel/RightPanelView.js @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {TemplateView} from "../../general/TemplateView.js"; +import {TemplateView} from "../../general/TemplateView"; import {RoomDetailsView} from "./RoomDetailsView.js"; import {MemberListView} from "./MemberListView.js"; import {LoadingView} from "../../general/LoadingView.js"; diff --git a/src/platform/web/ui/session/rightpanel/RoomDetailsView.js b/src/platform/web/ui/session/rightpanel/RoomDetailsView.js index 763968ca..a9524e09 100644 --- a/src/platform/web/ui/session/rightpanel/RoomDetailsView.js +++ b/src/platform/web/ui/session/rightpanel/RoomDetailsView.js @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {TemplateView} from "../../general/TemplateView.js"; +import {TemplateView} from "../../general/TemplateView"; import {classNames, tag} from "../../general/html"; import {AvatarView} from "../../AvatarView.js"; diff --git a/src/platform/web/ui/session/room/InviteView.js b/src/platform/web/ui/session/room/InviteView.js index 1d1e7db4..9d808abf 100644 --- a/src/platform/web/ui/session/room/InviteView.js +++ b/src/platform/web/ui/session/room/InviteView.js @@ -15,7 +15,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {TemplateView} from "../../general/TemplateView.js"; +import {TemplateView} from "../../general/TemplateView"; import {renderStaticAvatar} from "../../avatar.js"; export class InviteView extends TemplateView { diff --git a/src/platform/web/ui/session/room/LightboxView.js b/src/platform/web/ui/session/room/LightboxView.js index 16d5666f..9fbf392a 100644 --- a/src/platform/web/ui/session/room/LightboxView.js +++ b/src/platform/web/ui/session/room/LightboxView.js @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {TemplateView} from "../../general/TemplateView.js"; +import {TemplateView} from "../../general/TemplateView"; import {spinner} from "../../common.js"; export class LightboxView extends TemplateView { diff --git a/src/platform/web/ui/session/room/MessageComposer.js b/src/platform/web/ui/session/room/MessageComposer.js index e34c1052..b82e0ffd 100644 --- a/src/platform/web/ui/session/room/MessageComposer.js +++ b/src/platform/web/ui/session/room/MessageComposer.js @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {TemplateView} from "../../general/TemplateView.js"; +import {TemplateView} from "../../general/TemplateView"; import {Popup} from "../../general/Popup.js"; import {Menu} from "../../general/Menu.js"; import {viewClassForEntry} from "./TimelineView" diff --git a/src/platform/web/ui/session/room/RoomArchivedView.js b/src/platform/web/ui/session/room/RoomArchivedView.js index 80b18a08..1db1c2d2 100644 --- a/src/platform/web/ui/session/room/RoomArchivedView.js +++ b/src/platform/web/ui/session/room/RoomArchivedView.js @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {TemplateView} from "../../general/TemplateView.js"; +import {TemplateView} from "../../general/TemplateView"; export class RoomArchivedView extends TemplateView { render(t) { diff --git a/src/platform/web/ui/session/room/RoomView.js b/src/platform/web/ui/session/room/RoomView.js index 3d976ede..f100d796 100644 --- a/src/platform/web/ui/session/room/RoomView.js +++ b/src/platform/web/ui/session/room/RoomView.js @@ -15,7 +15,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {TemplateView} from "../../general/TemplateView.js"; +import {TemplateView} from "../../general/TemplateView"; import {Popup} from "../../general/Popup.js"; import {Menu} from "../../general/Menu.js"; import {TimelineView} from "./TimelineView"; diff --git a/src/platform/web/ui/session/room/TimelineLoadingView.js b/src/platform/web/ui/session/room/TimelineLoadingView.js index 503c2243..0f030421 100644 --- a/src/platform/web/ui/session/room/TimelineLoadingView.js +++ b/src/platform/web/ui/session/room/TimelineLoadingView.js @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {TemplateView} from "../../general/TemplateView.js"; +import {TemplateView} from "../../general/TemplateView"; import {spinner} from "../../common.js"; export class TimelineLoadingView extends TemplateView { diff --git a/src/platform/web/ui/session/room/UnknownRoomView.js b/src/platform/web/ui/session/room/UnknownRoomView.js index 32569d6f..80d857d8 100644 --- a/src/platform/web/ui/session/room/UnknownRoomView.js +++ b/src/platform/web/ui/session/room/UnknownRoomView.js @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {TemplateView} from "../../general/TemplateView.js"; +import {TemplateView} from "../../general/TemplateView"; export class UnknownRoomView extends TemplateView { render(t, vm) { diff --git a/src/platform/web/ui/session/room/timeline/AnnouncementView.js b/src/platform/web/ui/session/room/timeline/AnnouncementView.js index 2dd58b32..268bf0fa 100644 --- a/src/platform/web/ui/session/room/timeline/AnnouncementView.js +++ b/src/platform/web/ui/session/room/timeline/AnnouncementView.js @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {TemplateView} from "../../../general/TemplateView.js"; +import {TemplateView} from "../../../general/TemplateView"; export class AnnouncementView extends TemplateView { render(t) { diff --git a/src/platform/web/ui/session/room/timeline/BaseMessageView.js b/src/platform/web/ui/session/room/timeline/BaseMessageView.js index 69ae4bd3..89127453 100644 --- a/src/platform/web/ui/session/room/timeline/BaseMessageView.js +++ b/src/platform/web/ui/session/room/timeline/BaseMessageView.js @@ -18,7 +18,7 @@ limitations under the License. import {renderStaticAvatar} from "../../../avatar.js"; import {tag} from "../../../general/html"; import {mountView} from "../../../general/utils"; -import {TemplateView} from "../../../general/TemplateView.js"; +import {TemplateView} from "../../../general/TemplateView"; import {Popup} from "../../../general/Popup.js"; import {Menu} from "../../../general/Menu.js"; import {ReactionsView} from "./ReactionsView.js"; diff --git a/src/platform/web/ui/session/room/timeline/GapView.js b/src/platform/web/ui/session/room/timeline/GapView.js index 07980b42..a2bb05f6 100644 --- a/src/platform/web/ui/session/room/timeline/GapView.js +++ b/src/platform/web/ui/session/room/timeline/GapView.js @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {TemplateView} from "../../../general/TemplateView.js"; +import {TemplateView} from "../../../general/TemplateView"; import {spinner} from "../../../common.js"; export class GapView extends TemplateView { diff --git a/src/platform/web/ui/session/room/timeline/ReactionsView.js b/src/platform/web/ui/session/room/timeline/ReactionsView.js index 2a349a76..5e4c97bc 100644 --- a/src/platform/web/ui/session/room/timeline/ReactionsView.js +++ b/src/platform/web/ui/session/room/timeline/ReactionsView.js @@ -15,7 +15,7 @@ limitations under the License. */ import {ListView} from "../../../general/ListView"; -import {TemplateView} from "../../../general/TemplateView.js"; +import {TemplateView} from "../../../general/TemplateView"; export class ReactionsView extends ListView { constructor(reactionsViewModel) { diff --git a/src/platform/web/ui/session/settings/SessionBackupSettingsView.js b/src/platform/web/ui/session/settings/SessionBackupSettingsView.js index eae4a8e1..b38517ab 100644 --- a/src/platform/web/ui/session/settings/SessionBackupSettingsView.js +++ b/src/platform/web/ui/session/settings/SessionBackupSettingsView.js @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {TemplateView} from "../../general/TemplateView.js"; +import {TemplateView} from "../../general/TemplateView"; import {StaticView} from "../../general/StaticView.js"; export class SessionBackupSettingsView extends TemplateView { diff --git a/src/platform/web/ui/session/settings/SettingsView.js b/src/platform/web/ui/session/settings/SettingsView.js index 725f0e2b..8fbc6812 100644 --- a/src/platform/web/ui/session/settings/SettingsView.js +++ b/src/platform/web/ui/session/settings/SettingsView.js @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {TemplateView} from "../../general/TemplateView.js"; +import {TemplateView} from "../../general/TemplateView"; import {SessionBackupSettingsView} from "./SessionBackupSettingsView.js" export class SettingsView extends TemplateView { From d9ddeaf107ff8646a95c3d4af2948c4f2ce982a2 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 16 Sep 2021 15:49:03 +0200 Subject: [PATCH 33/41] fix TS errors in TimelineView --- .../web/ui/session/room/TimelineView.ts | 57 +++++++++++-------- 1 file changed, 33 insertions(+), 24 deletions(-) diff --git a/src/platform/web/ui/session/room/TimelineView.ts b/src/platform/web/ui/session/room/TimelineView.ts index 7446dbcb..de2f44fa 100644 --- a/src/platform/web/ui/session/room/TimelineView.ts +++ b/src/platform/web/ui/session/room/TimelineView.ts @@ -15,7 +15,8 @@ limitations under the License. */ import {ListView} from "../../general/ListView"; -import {TemplateView, TemplateBuilder} from "../../general/TemplateView.js"; +import {TemplateView, Builder} from "../../general/TemplateView"; +import {IObservableValue} from "../../general/BaseUpdateView"; import {GapView} from "./timeline/GapView.js"; import {TextMessageView} from "./timeline/TextMessageView.js"; import {ImageView} from "./timeline/ImageView.js"; @@ -25,9 +26,15 @@ 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 {TimelineViewModel} from "../../../../../domain/session/room/timeline/TimelineViewModel.js"; import {BaseObservableList as ObservableList} from "../../../../../observable/list/BaseObservableList.js"; +//import {TimelineViewModel} from "../../../../../domain/session/room/timeline/TimelineViewModel.js"; +interface TimelineViewModel extends IObservableValue { + showJumpDown: boolean; + tiles: ObservableList; + setVisibleTileRange(start?: SimpleTile, end?: SimpleTile); +} + type TileView = GapView | AnnouncementView | TextMessageView | ImageView | VideoView | FileView | MissingAttachmentView | RedactedView; type TileViewConstructor = (this: TileView, SimpleTile) => void; @@ -71,7 +78,7 @@ export class TimelineView extends TemplateView { private tilesView?: TilesListView; private resizeObserver?: ResizeObserver; - render(t: TemplateBuilder, vm: TimelineViewModel) { + render(t: Builder, vm: TimelineViewModel) { // assume this view will be mounted in the parent DOM straight away requestAnimationFrame(() => { // do initial scroll positioning @@ -103,38 +110,41 @@ export class TimelineView extends TemplateView { return root; } - private get scroller() { - return this.root().firstElementChild as HTMLElement; + 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 {scroller} = this; + const {scrollNode} = this; this.stickToBottom = true; - scroller.scrollTop = scroller.scrollHeight; + scrollNode.scrollTop = scrollNode.scrollHeight; } public unmount() { super.unmount(); if (this.resizeObserver) { - this.resizeObserver.unobserve(this.root()); - this.resizeObserver = null; + this.resizeObserver.unobserve(this.root()! as Element); + this.resizeObserver = undefined; } } private restoreScrollPosition() { - const {scroller} = this; - const tiles = this.tilesView!.root() as HTMLElement; + const {scrollNode, tilesNode} = this; - const missingTilesHeight = scroller.clientHeight - tiles.clientHeight; + const missingTilesHeight = scrollNode.clientHeight - tilesNode.clientHeight; if (missingTilesHeight > 0) { - tiles.style.setProperty("margin-top", `${missingTilesHeight}px`); + 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 { - tiles.style.removeProperty("margin-top"); + tilesNode.style.removeProperty("margin-top"); if (this.stickToBottom) { - scroller.scrollTop = scroller.scrollHeight; + scrollNode.scrollTop = scrollNode.scrollHeight; } else if (this.anchoredNode) { const newAnchoredBottom = bottom(this.anchoredNode!); if (newAnchoredBottom !== this.anchoredBottom) { @@ -142,10 +152,10 @@ export class TimelineView extends TemplateView { // 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 scroller.scrollBy === "function") { - scroller.scrollBy(0, bottomDiff); + if (typeof scrollNode.scrollBy === "function") { + scrollNode.scrollBy(0, bottomDiff); } else { - scroller.scrollTop = scroller.scrollTop + bottomDiff; + scrollNode.scrollTop = scrollNode.scrollTop + bottomDiff; } this.anchoredBottom = newAnchoredBottom; } @@ -156,9 +166,8 @@ export class TimelineView extends TemplateView { } private onScroll(): void { - const {scroller} = this; - const {scrollHeight, scrollTop, clientHeight} = scroller; - const tiles = this.tilesView!.root() as HTMLElement; + const {scrollNode, tilesNode} = this; + const {scrollHeight, scrollTop, clientHeight} = scrollNode; let bottomNodeIndex; this.stickToBottom = Math.abs(scrollHeight - (scrollTop + clientHeight)) < 1; @@ -168,12 +177,12 @@ export class TimelineView extends TemplateView { } else { const viewportBottom = scrollTop + clientHeight; // console.log(`viewportBottom: ${viewportBottom} (${scrollTop} + ${clientHeight})`); - const anchoredNodeIndex = findFirstNodeIndexAtOrBelow(tiles, viewportBottom); - this.anchoredNode = tiles.childNodes[anchoredNodeIndex] as HTMLElement; + const anchoredNodeIndex = findFirstNodeIndexAtOrBelow(tilesNode, viewportBottom); + this.anchoredNode = tilesNode.childNodes[anchoredNodeIndex] as HTMLElement; this.anchoredBottom = bottom(this.anchoredNode!); bottomNodeIndex = anchoredNodeIndex; } - let topNodeIndex = findFirstNodeIndexAtOrBelow(tiles, scrollTop, bottomNodeIndex); + let topNodeIndex = findFirstNodeIndexAtOrBelow(tilesNode, scrollTop, bottomNodeIndex); this.updateVisibleRange(topNodeIndex, bottomNodeIndex); } From 81ec8dca8cdc20d4d0f466189c0bccded8968991 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 16 Sep 2021 15:51:45 +0200 Subject: [PATCH 34/41] improve typing in utils --- src/platform/web/ui/general/utils.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/platform/web/ui/general/utils.ts b/src/platform/web/ui/general/utils.ts index 83d02f4c..cf1a2eb6 100644 --- a/src/platform/web/ui/general/utils.ts +++ b/src/platform/web/ui/general/utils.ts @@ -14,10 +14,10 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {UIView, IMountArgs} from "./types"; +import {UIView, IMountArgs, ViewNode} from "./types"; import {tag} from "./html"; -export function mountView(view: UIView, mountArgs?: IMountArgs): HTMLElement { +export function mountView(view: UIView, mountArgs?: IMountArgs): ViewNode { let node; try { node = view.mount(mountArgs); @@ -27,7 +27,7 @@ export function mountView(view: UIView, mountArgs?: IMountArgs): HTMLElement { return node; } -export function errorToDOM(error: Error): HTMLElement { +export function errorToDOM(error: Error): Element { const stack = new Error().stack; let callee: string | null = null; if (stack) { @@ -38,10 +38,10 @@ export function errorToDOM(error: Error): HTMLElement { tag.h3(error.message), tag.p(`This occurred while running ${callee}.`), tag.pre(error.stack), - ]) as HTMLElement; + ]); } -export function insertAt(parentNode: Element, idx: number, childNode: Element): void { +export function insertAt(parentNode: Element, idx: number, childNode: Node): void { const isLast = idx === parentNode.childElementCount; if (isLast) { parentNode.appendChild(childNode); From b71a26b04a6368921279fd52d26e0a0b21d47e73 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 16 Sep 2021 15:56:57 +0200 Subject: [PATCH 35/41] avoid using ! in ListView --- src/platform/web/ui/general/ListView.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/platform/web/ui/general/ListView.ts b/src/platform/web/ui/general/ListView.ts index eaa5ea90..316bb81c 100644 --- a/src/platform/web/ui/general/ListView.ts +++ b/src/platform/web/ui/general/ListView.ts @@ -79,12 +79,12 @@ export class ListView implements UIView { if (this._className) { attr.className = this._className; } - this._root = el(this._tagName, attr); + const root = this._root = el(this._tagName, attr); this.loadList(); if (this._onItemClick) { - this._root!.addEventListener("click", this); + root.addEventListener("click", this); } - return this._root!; + return root; } handleEvent(evt: Event) { From a6bcfac597a22ae3a26f1d528def45a9a0d36497 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 16 Sep 2021 15:58:48 +0200 Subject: [PATCH 36/41] rename UIView to IView --- src/platform/web/ui/general/BaseUpdateView.ts | 4 ++-- src/platform/web/ui/general/ListView.ts | 4 ++-- src/platform/web/ui/general/Popup.js | 2 +- src/platform/web/ui/general/TemplateView.ts | 14 +++++++------- src/platform/web/ui/general/types.ts | 2 +- src/platform/web/ui/general/utils.ts | 4 ++-- 6 files changed, 15 insertions(+), 15 deletions(-) diff --git a/src/platform/web/ui/general/BaseUpdateView.ts b/src/platform/web/ui/general/BaseUpdateView.ts index 623df99a..2eb4d40f 100644 --- a/src/platform/web/ui/general/BaseUpdateView.ts +++ b/src/platform/web/ui/general/BaseUpdateView.ts @@ -15,14 +15,14 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {IMountArgs, ViewNode, UIView} from "./types"; +import {IMountArgs, ViewNode, IView} from "./types"; export interface IObservableValue { on?(event: "change", handler: (props?: string[]) => void): void; off?(event: "change", handler: (props?: string[]) => void): void; } -export abstract class BaseUpdateView implements UIView { +export abstract class BaseUpdateView implements IView { protected _value: T protected _boundUpdateFromValue: ((props?: string[]) => void) | null diff --git a/src/platform/web/ui/general/ListView.ts b/src/platform/web/ui/general/ListView.ts index 316bb81c..cdfdbf76 100644 --- a/src/platform/web/ui/general/ListView.ts +++ b/src/platform/web/ui/general/ListView.ts @@ -17,7 +17,7 @@ limitations under the License. import {el} from "./html"; import {mountView, insertAt} from "./utils"; import {BaseObservableList as ObservableList} from "../../../../observable/list/BaseObservableList.js"; -import {UIView, IMountArgs} from "./types"; +import {IView, IMountArgs} from "./types"; interface IOptions { list: ObservableList, @@ -29,7 +29,7 @@ interface IOptions { type SubscriptionHandle = () => undefined; -export class ListView implements UIView { +export class ListView implements IView { private _onItemClick?: (childView: V, evt: UIEvent) => void; private _list: ObservableList; diff --git a/src/platform/web/ui/general/Popup.js b/src/platform/web/ui/general/Popup.js index ac5e3160..e630d398 100644 --- a/src/platform/web/ui/general/Popup.js +++ b/src/platform/web/ui/general/Popup.js @@ -169,7 +169,7 @@ export class Popup { return true; } - /* fake UIView api, so it can be tracked by a template view as a subview */ + /* fake IView api, so it can be tracked by a template view as a subview */ root() { return this._fakeRoot; } diff --git a/src/platform/web/ui/general/TemplateView.ts b/src/platform/web/ui/general/TemplateView.ts index 2fb17f86..93dc8172 100644 --- a/src/platform/web/ui/general/TemplateView.ts +++ b/src/platform/web/ui/general/TemplateView.ts @@ -18,7 +18,7 @@ limitations under the License. import { setAttribute, text, isChildren, classNames, TAG_NAMES, HTML_NS, ClassNames, Child} from "./html"; import {mountView} from "./utils"; import {BaseUpdateView, IObservableValue} from "./BaseUpdateView"; -import {IMountArgs, ViewNode, UIView} from "./types"; +import {IMountArgs, ViewNode, IView} from "./types"; function objHasFns(obj: ClassNames): obj is { [className: string]: boolean } { for(const value of Object.values(obj)) { @@ -58,7 +58,7 @@ export class TemplateView extends BaseUpdateView private _bindings?: (() => void)[] = undefined; private _root?: ViewNode = undefined; // public because used by TemplateBuilder - _subViews?: UIView[] = undefined; + _subViews?: IView[] = undefined; constructor(value: T, render?: RenderFn) { super(value); @@ -138,14 +138,14 @@ export class TemplateView extends BaseUpdateView this._bindings.push(bindingFn); } - addSubView(view: UIView): void { + addSubView(view: IView): void { if (!this._subViews) { this._subViews = []; } this._subViews.push(view); } - removeSubView(view: UIView): void { + removeSubView(view: IView): void { if (!this._subViews) { return; } const idx = this._subViews.indexOf(view); if (idx !== -1) { @@ -312,13 +312,13 @@ export class TemplateBuilder { // this inserts a view, and is not a view factory for `if`, so returns the root element to insert in the template // you should not call t.view() and not use the result (e.g. attach the result to the template DOM tree). - view(view: UIView, mountOptions?: IMountArgs): ViewNode { + view(view: IView, mountOptions?: IMountArgs): ViewNode { this._templateView.addSubView(view); return mountView(view, mountOptions); } // map a value to a view, every time the value changes - mapView(mapFn: (value: T) => R, viewCreator: (mapped: R) => UIView | null): ViewNode { + mapView(mapFn: (value: T) => R, viewCreator: (mapped: R) => IView | null): ViewNode { return this._addReplaceNodeBinding(mapFn, (prevNode) => { if (prevNode && prevNode.nodeType !== Node.COMMENT_NODE) { const subViews = this._templateView._subViews; @@ -356,7 +356,7 @@ export class TemplateBuilder { }); } - ifView(predicate: (value: T) => boolean, viewCreator: (value: T) => UIView): ViewNode { + ifView(predicate: (value: T) => boolean, viewCreator: (value: T) => IView): ViewNode { return this.mapView( value => !!predicate(value), enabled => enabled ? viewCreator(this._value) : null diff --git a/src/platform/web/ui/general/types.ts b/src/platform/web/ui/general/types.ts index f38c8a5e..1d9122aa 100644 --- a/src/platform/web/ui/general/types.ts +++ b/src/platform/web/ui/general/types.ts @@ -22,7 +22,7 @@ export interface IMountArgs { // Comment nodes can be used as temporary placeholders for Elements, like TemplateView does. export type ViewNode = Element | Comment; -export interface UIView { +export interface IView { mount(args?: IMountArgs): ViewNode; root(): ViewNode | undefined; // should only be called between mount() and unmount() unmount(): void; diff --git a/src/platform/web/ui/general/utils.ts b/src/platform/web/ui/general/utils.ts index cf1a2eb6..7eb1d7f9 100644 --- a/src/platform/web/ui/general/utils.ts +++ b/src/platform/web/ui/general/utils.ts @@ -14,10 +14,10 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {UIView, IMountArgs, ViewNode} from "./types"; +import {IView, IMountArgs, ViewNode} from "./types"; import {tag} from "./html"; -export function mountView(view: UIView, mountArgs?: IMountArgs): ViewNode { +export function mountView(view: IView, mountArgs?: IMountArgs): ViewNode { let node; try { node = view.mount(mountArgs); From c9f79343efa599aefce36cc386f1171578a6e2fb Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 16 Sep 2021 16:32:59 +0200 Subject: [PATCH 37/41] remove obsolete comment --- src/domain/session/room/timeline/TilesCollection.js | 1 - 1 file changed, 1 deletion(-) diff --git a/src/domain/session/room/timeline/TilesCollection.js b/src/domain/session/room/timeline/TilesCollection.js index 23d863d4..497c0b0d 100644 --- a/src/domain/session/room/timeline/TilesCollection.js +++ b/src/domain/session/room/timeline/TilesCollection.js @@ -239,7 +239,6 @@ export class TilesCollection extends BaseObservableList { getTileIndex(searchTile) { const idx = sortedIndex(this._tiles, searchTile, (searchTile, tile) => { - // negate result because we're switching the order of the params return searchTile.compare(tile); }); const foundTile = this._tiles[idx]; From 6ec2712eec3e6029a01ebf63790d394b46a2c3da Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 16 Sep 2021 16:33:09 +0200 Subject: [PATCH 38/41] remove debug logging --- src/domain/session/room/timeline/TimelineViewModel.js | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/domain/session/room/timeline/TimelineViewModel.js b/src/domain/session/room/timeline/TimelineViewModel.js index 5938ada5..9c936218 100644 --- a/src/domain/session/room/timeline/TimelineViewModel.js +++ b/src/domain/session/room/timeline/TimelineViewModel.js @@ -77,12 +77,10 @@ export class TimelineViewModel extends ViewModel { } loadTop = startIndex < 10; this._setShowJumpDown(endIndex < (this._tiles.length - 1)); - // console.log("got tiles", startIndex, endIndex, loadTop); } else { // tiles collection is empty, load more at top loadTop = true; this._setShowJumpDown(false); - // console.log("no tiles, load more at top"); } if (loadTop && !this._topLoadingPromise) { @@ -95,8 +93,6 @@ export class TimelineViewModel extends ViewModel { this.setVisibleTileRange(this._requestedStartTile, this._requestedEndTile); } }); - } else if (loadTop) { - // console.log("loadTop is true but already loading"); } } From 898d32c6daba2c7fc4178aa04647fe1037fe7709 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 16 Sep 2021 16:34:01 +0200 Subject: [PATCH 39/41] use quotes in css url path --- src/platform/web/ui/css/themes/element/timeline.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/platform/web/ui/css/themes/element/timeline.css b/src/platform/web/ui/css/themes/element/timeline.css index d68d7ff5..d2885bca 100644 --- a/src/platform/web/ui/css/themes/element/timeline.css +++ b/src/platform/web/ui/css/themes/element/timeline.css @@ -22,7 +22,7 @@ limitations under the License. right: 32px; border-radius: 100%; border: 1px solid #8d99a5; - background-image: url(icons/chevron-down.svg); + background-image: url("./icons/chevron-down.svg"); background-position: center; background-color: white; background-repeat: no-repeat; From 381a12db204b25c8c91964ec642933109b35a32b Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 16 Sep 2021 16:34:13 +0200 Subject: [PATCH 40/41] load 20 entries initially in timeline, otherwise it flickers a bit --- src/matrix/room/timeline/Timeline.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/matrix/room/timeline/Timeline.js b/src/matrix/room/timeline/Timeline.js index 2a9cfec7..23980b9d 100644 --- a/src/matrix/room/timeline/Timeline.js +++ b/src/matrix/room/timeline/Timeline.js @@ -75,7 +75,7 @@ export class Timeline { // choose good amount here between showing messages initially and // not spending too much time decrypting messages before showing the timeline. // more messages should be loaded automatically until the viewport is full by the view if needed. - const readerRequest = this._disposables.track(this._timelineReader.readFromEnd(5, txn, log)); + const readerRequest = this._disposables.track(this._timelineReader.readFromEnd(20, txn, log)); try { const entries = await readerRequest.complete(); this._setupEntries(entries); From a62627f6dbee3e60adf8ac22f35adae738aaa817 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 16 Sep 2021 16:39:17 +0200 Subject: [PATCH 41/41] fix lint warning --- src/platform/web/ui/session/room/timeline/GapView.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/platform/web/ui/session/room/timeline/GapView.js b/src/platform/web/ui/session/room/timeline/GapView.js index a2bb05f6..2d3bd6e8 100644 --- a/src/platform/web/ui/session/room/timeline/GapView.js +++ b/src/platform/web/ui/session/room/timeline/GapView.js @@ -18,7 +18,7 @@ import {TemplateView} from "../../../general/TemplateView"; import {spinner} from "../../../common.js"; export class GapView extends TemplateView { - render(t, vm) { + render(t) { const className = { GapView: true, isLoading: vm => vm.isLoading,