From c6b020a9e73696eb24c1ccdab227e2d8e25ea022 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 2 Sep 2021 08:53:44 +0200 Subject: [PATCH 01/57] Rename TimelineList to TimelineView for consistency --- src/domain/session/room/timeline/tiles/EncryptedEventTile.js | 2 +- src/platform/web/ui/session/room/MessageComposer.js | 2 +- src/platform/web/ui/session/room/RoomView.js | 4 ++-- .../web/ui/session/room/{TimelineList.js => TimelineView.js} | 5 +++-- 4 files changed, 7 insertions(+), 6 deletions(-) rename src/platform/web/ui/session/room/{TimelineList.js => TimelineView.js} (97%) diff --git a/src/domain/session/room/timeline/tiles/EncryptedEventTile.js b/src/domain/session/room/timeline/tiles/EncryptedEventTile.js index 07dd763b..b96e2d85 100644 --- a/src/domain/session/room/timeline/tiles/EncryptedEventTile.js +++ b/src/domain/session/room/timeline/tiles/EncryptedEventTile.js @@ -22,7 +22,7 @@ export class EncryptedEventTile extends BaseTextTile { const parentResult = super.updateEntry(entry, params); // event got decrypted, recreate the tile and replace this one with it if (entry.eventType !== "m.room.encrypted") { - // the "shape" parameter trigger tile recreation in TimelineList + // the "shape" parameter trigger tile recreation in TimelineView return UpdateAction.Replace("shape"); } else { return parentResult; diff --git a/src/platform/web/ui/session/room/MessageComposer.js b/src/platform/web/ui/session/room/MessageComposer.js index c6e8a1ee..780baef4 100644 --- a/src/platform/web/ui/session/room/MessageComposer.js +++ b/src/platform/web/ui/session/room/MessageComposer.js @@ -17,7 +17,7 @@ limitations under the License. import {TemplateView} from "../../general/TemplateView.js"; import {Popup} from "../../general/Popup.js"; import {Menu} from "../../general/Menu.js"; -import {viewClassForEntry} from "./TimelineList.js" +import {viewClassForEntry} from "./TimelineView.js" export class MessageComposer extends TemplateView { constructor(viewModel) { diff --git a/src/platform/web/ui/session/room/RoomView.js b/src/platform/web/ui/session/room/RoomView.js index 71eb1e13..faa4637e 100644 --- a/src/platform/web/ui/session/room/RoomView.js +++ b/src/platform/web/ui/session/room/RoomView.js @@ -18,7 +18,7 @@ limitations under the License. import {TemplateView} from "../../general/TemplateView.js"; import {Popup} from "../../general/Popup.js"; import {Menu} from "../../general/Menu.js"; -import {TimelineList} from "./TimelineList.js"; +import {TimelineView} from "./TimelineView.js"; import {TimelineLoadingView} from "./TimelineLoadingView.js"; import {MessageComposer} from "./MessageComposer.js"; import {RoomArchivedView} from "./RoomArchivedView.js"; @@ -54,7 +54,7 @@ export class RoomView extends TemplateView { t.div({className: "RoomView_error"}, vm => vm.error), t.mapView(vm => vm.timelineViewModel, timelineViewModel => { return timelineViewModel ? - new TimelineList(timelineViewModel) : + new TimelineView(timelineViewModel) : new TimelineLoadingView(vm); // vm is just needed for i18n }), t.view(bottomView), diff --git a/src/platform/web/ui/session/room/TimelineList.js b/src/platform/web/ui/session/room/TimelineView.js similarity index 97% rename from src/platform/web/ui/session/room/TimelineList.js rename to src/platform/web/ui/session/room/TimelineView.js index e0179e5d..2bac6c1b 100644 --- a/src/platform/web/ui/session/room/TimelineList.js +++ b/src/platform/web/ui/session/room/TimelineView.js @@ -40,7 +40,7 @@ export function viewClassForEntry(entry) { } } -export class TimelineList extends ListView { +export class TimelineView extends ListView { constructor(viewModel) { const options = { className: "Timeline bottom-aligned-scroll", @@ -94,7 +94,8 @@ export class TimelineList extends ListView { this._loadAtTopWhile(() => { const contentHeight = root.scrollHeight; const amountGrown = contentHeight - beforeContentHeight; - root.scrollTop = root.scrollTop + (contentHeight - lastContentHeight); + const topDiff = contentHeight - lastContentHeight; + root.scrollBy(0, topDiff); lastContentHeight = contentHeight; return amountGrown < PAGINATE_OFFSET; }); From 632d29795ae5a639c8716697f62d0fb14f4346a9 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Mon, 6 Sep 2021 17:12:14 +0200 Subject: [PATCH 02/57] convert ListView to typescript --- src/platform/web/ui/general/LazyListView.js | 5 +- src/platform/web/ui/general/ListView.js | 163 --------------- src/platform/web/ui/general/ListView.ts | 187 ++++++++++++++++++ src/platform/web/ui/general/TemplateView.js | 2 +- .../web/ui/general/{utils.js => types.ts} | 21 +- .../web/ui/general/{error.js => utils.ts} | 27 ++- .../web/ui/login/SessionPickerView.js | 2 +- .../web/ui/session/leftpanel/LeftPanelView.js | 2 +- .../web/ui/session/room/TimelineView.js | 2 +- .../session/room/timeline/BaseMessageView.js | 2 +- .../ui/session/room/timeline/ReactionsView.js | 2 +- 11 files changed, 230 insertions(+), 185 deletions(-) delete mode 100644 src/platform/web/ui/general/ListView.js create mode 100644 src/platform/web/ui/general/ListView.ts rename src/platform/web/ui/general/{utils.js => types.ts} (58%) rename src/platform/web/ui/general/{error.js => utils.ts} (53%) diff --git a/src/platform/web/ui/general/LazyListView.js b/src/platform/web/ui/general/LazyListView.js index 4795ce5a..6f565e30 100644 --- a/src/platform/web/ui/general/LazyListView.js +++ b/src/platform/web/ui/general/LazyListView.js @@ -15,8 +15,9 @@ limitations under the License. */ import {el} from "./html.js"; -import {mountView} from "./utils.js"; -import {insertAt, ListView} from "./ListView.js"; +import {mountView} from "./utils"; +import {ListView} from "./ListView"; +import {insertAt} from "./utils"; class ItemRange { constructor(topCount, renderCount, bottomCount) { diff --git a/src/platform/web/ui/general/ListView.js b/src/platform/web/ui/general/ListView.js deleted file mode 100644 index 74aa9d87..00000000 --- a/src/platform/web/ui/general/ListView.js +++ /dev/null @@ -1,163 +0,0 @@ -/* -Copyright 2020 Bruno Windels - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -import {el} from "./html.js"; -import {mountView} from "./utils.js"; - -export function insertAt(parentNode, idx, childNode) { - const isLast = idx === parentNode.childElementCount; - if (isLast) { - parentNode.appendChild(childNode); - } else { - const nextDomNode = parentNode.children[idx]; - parentNode.insertBefore(childNode, nextDomNode); - } -} - -export class ListView { - constructor({list, onItemClick, className, tagName = "ul", parentProvidesUpdates = true}, childCreator) { - this._onItemClick = onItemClick; - this._list = list; - this._className = className; - this._tagName = tagName; - this._root = null; - this._subscription = null; - this._childCreator = childCreator; - this._childInstances = null; - this._mountArgs = {parentProvidesUpdates}; - this._onClick = this._onClick.bind(this); - } - - root() { - return this._root; - } - - update(attributes) { - if (attributes.hasOwnProperty("list")) { - if (this._subscription) { - this._unloadList(); - while (this._root.lastChild) { - this._root.lastChild.remove(); - } - } - this._list = attributes.list; - this.loadList(); - } - } - - mount() { - const attr = {}; - if (this._className) { - attr.className = this._className; - } - this._root = el(this._tagName, attr); - this.loadList(); - if (this._onItemClick) { - this._root.addEventListener("click", this._onClick); - } - return this._root; - } - - unmount() { - if (this._list) { - this._unloadList(); - } - } - - _onClick(event) { - if (event.target === this._root) { - return; - } - let childNode = event.target; - while (childNode.parentNode !== this._root) { - childNode = childNode.parentNode; - } - const index = Array.prototype.indexOf.call(this._root.childNodes, childNode); - const childView = this._childInstances[index]; - this._onItemClick(childView, event); - } - - _unloadList() { - this._subscription = this._subscription(); - for (let child of this._childInstances) { - child.unmount(); - } - this._childInstances = null; - } - - loadList() { - if (!this._list) { - return; - } - this._subscription = this._list.subscribe(this); - this._childInstances = []; - const fragment = document.createDocumentFragment(); - for (let item of this._list) { - const child = this._childCreator(item); - this._childInstances.push(child); - fragment.appendChild(mountView(child, this._mountArgs)); - } - this._root.appendChild(fragment); - } - - onAdd(idx, value) { - this.onBeforeListChanged(); - const child = this._childCreator(value); - this._childInstances.splice(idx, 0, child); - insertAt(this._root, idx, mountView(child, this._mountArgs)); - this.onListChanged(); - } - - onRemove(idx/*, _value*/) { - this.onBeforeListChanged(); - const [child] = this._childInstances.splice(idx, 1); - child.root().remove(); - child.unmount(); - this.onListChanged(); - } - - onMove(fromIdx, toIdx/*, value*/) { - 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(); - } - - onUpdate(i, value, params) { - if (this._childInstances) { - const instance = this._childInstances[i]; - instance && instance.update(value, params); - } - } - - recreateItem(index, value) { - if (this._childInstances) { - const child = this._childCreator(value); - if (!child) { - this.onRemove(index, value); - } else { - const [oldChild] = this._childInstances.splice(index, 1, child); - this._root.replaceChild(child.mount(this._mountArgs), oldChild.root()); - oldChild.unmount(); - } - } - } - - onBeforeListChanged() {} - onListChanged() {} -} diff --git a/src/platform/web/ui/general/ListView.ts b/src/platform/web/ui/general/ListView.ts new file mode 100644 index 00000000..049ad2a7 --- /dev/null +++ b/src/platform/web/ui/general/ListView.ts @@ -0,0 +1,187 @@ +/* +Copyright 2020 Bruno Windels + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import {el} from "./html.js"; +import {mountView, insertAt} from "./utils"; +import {BaseObservableList as ObservableList} from "../../../../observable/list/BaseObservableList.js"; +import {UIView, IMountArgs} from "./types"; + +interface IOptions { + list: ObservableList, + onItemClick?: (childView: V, evt: UIEvent) => void, + className?: string, + tagName?: string, + parentProvidesUpdates: boolean +} + +type SubscriptionHandle = () => undefined; + +export class ListView implements UIView { + + private _onItemClick?: (childView: V, evt: UIEvent) => void; + private _list: ObservableList; + private _className?: string; + private _tagName: string; + private _root?: HTMLElement; + private _subscription?: SubscriptionHandle; + private _childCreator: (value: T) => V; + private _childInstances?: V[]; + private _mountArgs: IMountArgs; + + constructor( + {list, onItemClick, className, tagName = "ul", parentProvidesUpdates = true}: IOptions, + childCreator: (value: T) => V + ) { + this._onItemClick = onItemClick; + this._list = list; + this._className = className; + this._tagName = tagName; + this._root = undefined; + this._subscription = undefined; + this._childCreator = childCreator; + this._childInstances = undefined; + this._mountArgs = {parentProvidesUpdates}; + } + + root(): HTMLElement { + // won't be undefined when called between mount and unmount + return this._root!; + } + + update(attributes: IOptions) { + if (attributes.list) { + if (this._subscription) { + this._unloadList(); + while (this._root!.lastChild) { + this._root!.lastChild.remove(); + } + } + this._list = attributes.list; + this.loadList(); + } + } + + mount(): HTMLElement { + const attr: {[name: string]: any} = {}; + if (this._className) { + attr.className = this._className; + } + this._root = el(this._tagName, attr); + this.loadList(); + if (this._onItemClick) { + this._root!.addEventListener("click", this); + } + return this._root!; + } + + handleEvent(evt: Event) { + if (evt.type === "click") { + this._handleClick(evt as UIEvent); + } + } + + unmount(): void { + if (this._list) { + this._unloadList(); + } + } + + private _handleClick(event: UIEvent) { + if (event.target === this._root || !this._onItemClick) { + return; + } + let childNode = event.target as Element; + while (childNode.parentNode !== this._root) { + childNode = childNode.parentNode as Element; + } + const index = Array.prototype.indexOf.call(this._root!.childNodes, childNode); + const childView = this._childInstances![index]; + if (childView) { + this._onItemClick(childView, event); + } + } + + private _unloadList() { + this._subscription = this._subscription!(); + for (let child of this._childInstances!) { + child.unmount(); + } + this._childInstances = undefined; + } + + private loadList() { + if (!this._list) { + return; + } + this._subscription = this._list.subscribe(this); + this._childInstances = []; + const fragment = document.createDocumentFragment(); + for (let item of this._list) { + const child = this._childCreator(item); + this._childInstances!.push(child); + fragment.appendChild(mountView(child, this._mountArgs)); + } + this._root!.appendChild(fragment); + } + + 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) { + if (this._childInstances) { + const instance = this._childInstances![i]; + instance && instance.update(value, params); + } + } + + protected recreateItem(index: number, value: T) { + if (this._childInstances) { + const child = this._childCreator(value); + if (!child) { + this.onRemove(index, value); + } else { + const [oldChild] = this._childInstances!.splice(index, 1, child); + this._root!.replaceChild(child.mount(this._mountArgs), oldChild.root()); + oldChild.unmount(); + } + } + } + + protected onBeforeListChanged() {} + protected onListChanged() {} +} diff --git a/src/platform/web/ui/general/TemplateView.js b/src/platform/web/ui/general/TemplateView.js index 4b2bcf74..3e9727dc 100644 --- a/src/platform/web/ui/general/TemplateView.js +++ b/src/platform/web/ui/general/TemplateView.js @@ -15,7 +15,7 @@ limitations under the License. */ import { setAttribute, text, isChildren, classNames, TAG_NAMES, HTML_NS } from "./html.js"; -import {mountView} from "./utils.js"; +import {mountView} from "./utils"; import {BaseUpdateView} from "./BaseUpdateView.js"; function objHasFns(obj) { diff --git a/src/platform/web/ui/general/utils.js b/src/platform/web/ui/general/types.ts similarity index 58% rename from src/platform/web/ui/general/utils.js rename to src/platform/web/ui/general/types.ts index d74de690..f8f671c7 100644 --- a/src/platform/web/ui/general/utils.js +++ b/src/platform/web/ui/general/types.ts @@ -13,15 +13,14 @@ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ +export interface IMountArgs { + // if true, the parent will call update() rather than the view updating itself by binding to a data source. + parentProvidesUpdates: boolean +}; -import {errorToDOM} from "./error.js"; - -export function mountView(view, mountArgs = undefined) { - let node; - try { - node = view.mount(mountArgs); - } catch (err) { - node = errorToDOM(err); - } - return node; -} \ No newline at end of file +export interface UIView { + mount(args?: IMountArgs): HTMLElement; + root(): HTMLElement; // 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/error.js b/src/platform/web/ui/general/utils.ts similarity index 53% rename from src/platform/web/ui/general/error.js rename to src/platform/web/ui/general/utils.ts index 48728a4b..f4469ca1 100644 --- a/src/platform/web/ui/general/error.js +++ b/src/platform/web/ui/general/utils.ts @@ -1,5 +1,5 @@ /* -Copyright 2020 Bruno Windels +Copyright 2021 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -14,11 +14,22 @@ See the License for the specific language governing permissions and limitations under the License. */ +import {UIView, IMountArgs} from "./types"; import {tag} from "./html.js"; -export function errorToDOM(error) { +export function mountView(view: UIView, mountArgs: IMountArgs): HTMLElement { + let node; + try { + node = view.mount(mountArgs); + } catch (err) { + node = errorToDOM(err); + } + return node; +} + +export function errorToDOM(error: Error): HTMLElement { const stack = new Error().stack; - let callee = null; + let callee: string | null = null; if (stack) { callee = stack.split("\n")[1]; } @@ -29,3 +40,13 @@ export function errorToDOM(error) { tag.pre(error.stack), ]); } + +export function insertAt(parentNode: HTMLElement, idx: number, childNode: HTMLElement): void { + const isLast = idx === parentNode.childElementCount; + if (isLast) { + parentNode.appendChild(childNode); + } else { + const nextDomNode = parentNode.children[idx]; + parentNode.insertBefore(childNode, nextDomNode); + } +} diff --git a/src/platform/web/ui/login/SessionPickerView.js b/src/platform/web/ui/login/SessionPickerView.js index 4bb69ee2..a4feea17 100644 --- a/src/platform/web/ui/login/SessionPickerView.js +++ b/src/platform/web/ui/login/SessionPickerView.js @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {ListView} from "../general/ListView.js"; +import {ListView} from "../general/ListView"; import {TemplateView} from "../general/TemplateView.js"; import {hydrogenGithubLink} from "./common.js"; import {SessionLoadStatusView} from "./SessionLoadStatusView.js"; diff --git a/src/platform/web/ui/session/leftpanel/LeftPanelView.js b/src/platform/web/ui/session/leftpanel/LeftPanelView.js index 8757ad42..d8f21aa2 100644 --- a/src/platform/web/ui/session/leftpanel/LeftPanelView.js +++ b/src/platform/web/ui/session/leftpanel/LeftPanelView.js @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {ListView} from "../../general/ListView.js"; +import {ListView} from "../../general/ListView"; import {TemplateView} from "../../general/TemplateView.js"; import {RoomTileView} from "./RoomTileView.js"; import {InviteTileView} from "./InviteTileView.js"; diff --git a/src/platform/web/ui/session/room/TimelineView.js b/src/platform/web/ui/session/room/TimelineView.js index 2bac6c1b..4b2601ce 100644 --- a/src/platform/web/ui/session/room/TimelineView.js +++ b/src/platform/web/ui/session/room/TimelineView.js @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {ListView} from "../../general/ListView.js"; +import {ListView} from "../../general/ListView"; import {GapView} from "./timeline/GapView.js"; import {TextMessageView} from "./timeline/TextMessageView.js"; import {ImageView} from "./timeline/ImageView.js"; diff --git a/src/platform/web/ui/session/room/timeline/BaseMessageView.js b/src/platform/web/ui/session/room/timeline/BaseMessageView.js index fb797f98..9469d201 100644 --- a/src/platform/web/ui/session/room/timeline/BaseMessageView.js +++ b/src/platform/web/ui/session/room/timeline/BaseMessageView.js @@ -17,7 +17,7 @@ limitations under the License. import {renderStaticAvatar} from "../../../avatar.js"; import {tag} from "../../../general/html.js"; -import {mountView} from "../../../general/utils.js"; +import {mountView} from "../../../general/utils"; import {TemplateView} from "../../../general/TemplateView.js"; import {Popup} from "../../../general/Popup.js"; import {Menu} from "../../../general/Menu.js"; diff --git a/src/platform/web/ui/session/room/timeline/ReactionsView.js b/src/platform/web/ui/session/room/timeline/ReactionsView.js index 12f3b428..2a349a76 100644 --- a/src/platform/web/ui/session/room/timeline/ReactionsView.js +++ b/src/platform/web/ui/session/room/timeline/ReactionsView.js @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {ListView} from "../../../general/ListView.js"; +import {ListView} from "../../../general/ListView"; import {TemplateView} from "../../../general/TemplateView.js"; export class ReactionsView extends ListView { From ad4ec5f04ca09b9feb78b9429cb0d1dd8817d3c9 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Mon, 6 Sep 2021 17:51:32 +0200 Subject: [PATCH 03/57] convert TimelineView to typescript --- src/platform/web/ui/general/ListView.ts | 8 ++- .../web/ui/session/room/MessageComposer.js | 2 +- src/platform/web/ui/session/room/RoomView.js | 2 +- .../room/{TimelineView.js => TimelineView.ts} | 70 ++++++++++++------- 4 files changed, 51 insertions(+), 31 deletions(-) rename src/platform/web/ui/session/room/{TimelineView.js => TimelineView.ts} (70%) diff --git a/src/platform/web/ui/general/ListView.ts b/src/platform/web/ui/general/ListView.ts index 049ad2a7..16cbce67 100644 --- a/src/platform/web/ui/general/ListView.ts +++ b/src/platform/web/ui/general/ListView.ts @@ -24,7 +24,7 @@ interface IOptions { onItemClick?: (childView: V, evt: UIEvent) => void, className?: string, tagName?: string, - parentProvidesUpdates: boolean + parentProvidesUpdates?: boolean } type SubscriptionHandle = () => undefined; @@ -122,7 +122,7 @@ export class ListView implements UIView { this._childInstances = undefined; } - private loadList() { + protected loadList() { if (!this._list) { return; } @@ -184,4 +184,8 @@ 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/MessageComposer.js b/src/platform/web/ui/session/room/MessageComposer.js index 780baef4..e34c1052 100644 --- a/src/platform/web/ui/session/room/MessageComposer.js +++ b/src/platform/web/ui/session/room/MessageComposer.js @@ -17,7 +17,7 @@ limitations under the License. import {TemplateView} from "../../general/TemplateView.js"; import {Popup} from "../../general/Popup.js"; import {Menu} from "../../general/Menu.js"; -import {viewClassForEntry} from "./TimelineView.js" +import {viewClassForEntry} from "./TimelineView" export class MessageComposer extends TemplateView { constructor(viewModel) { diff --git a/src/platform/web/ui/session/room/RoomView.js b/src/platform/web/ui/session/room/RoomView.js index faa4637e..3d976ede 100644 --- a/src/platform/web/ui/session/room/RoomView.js +++ b/src/platform/web/ui/session/room/RoomView.js @@ -18,7 +18,7 @@ limitations under the License. import {TemplateView} from "../../general/TemplateView.js"; import {Popup} from "../../general/Popup.js"; import {Menu} from "../../general/Menu.js"; -import {TimelineView} from "./TimelineView.js"; +import {TimelineView} from "./TimelineView"; import {TimelineLoadingView} from "./TimelineLoadingView.js"; import {MessageComposer} from "./MessageComposer.js"; import {RoomArchivedView} from "./RoomArchivedView.js"; diff --git a/src/platform/web/ui/session/room/TimelineView.js b/src/platform/web/ui/session/room/TimelineView.ts similarity index 70% rename from src/platform/web/ui/session/room/TimelineView.js rename to src/platform/web/ui/session/room/TimelineView.ts index 4b2601ce..6768bb8c 100644 --- a/src/platform/web/ui/session/room/TimelineView.js +++ b/src/platform/web/ui/session/room/TimelineView.ts @@ -23,8 +23,14 @@ import {FileView} from "./timeline/FileView.js"; import {MissingAttachmentView} from "./timeline/MissingAttachmentView.js"; import {AnnouncementView} from "./timeline/AnnouncementView.js"; import {RedactedView} from "./timeline/RedactedView.js"; +import {SimpleTile} from "../../../../../domain/session/room/timeline/tiles/SimpleTile.js"; +import {TimelineViewModel} from "../../../../../domain/session/room/timeline/TimelineViewModel.js"; -export function viewClassForEntry(entry) { +type TileView = GapView | AnnouncementView | TextMessageView | + ImageView | VideoView | FileView | MissingAttachmentView | RedactedView; +type TileViewConstructor = (this: TileView, SimpleTile) => void; + +export function viewClassForEntry(entry: SimpleTile): TileViewConstructor | undefined { switch (entry.shape) { case "gap": return GapView; case "announcement": return AnnouncementView; @@ -40,13 +46,18 @@ export function viewClassForEntry(entry) { } } -export class TimelineView extends ListView { - constructor(viewModel) { +export class TimelineView extends ListView { + + private _atBottom: boolean; + private _topLoadingPromise?: Promise; + private _viewModel: TimelineViewModel; + + constructor(viewModel: TimelineViewModel) { const options = { className: "Timeline bottom-aligned-scroll", list: viewModel.tiles, onItemClick: (tileView, evt) => tileView.onClick(evt), - } + }; super(options, entry => { const View = viewClassForEntry(entry); if (View) { @@ -54,12 +65,19 @@ export class TimelineView extends ListView { } }); this._atBottom = false; - this._onScroll = this._onScroll.bind(this); - this._topLoadingPromise = null; + this._topLoadingPromise = undefined; this._viewModel = viewModel; } - async _loadAtTopWhile(predicate) { + override handleEvent(evt: Event) { + if (evt.type === "scroll") { + this._handleScroll(evt); + } else { + super.handleEvent(evt); + } + } + + async _loadAtTopWhile(predicate: () => boolean) { if (this._topLoadingPromise) { return; } @@ -78,11 +96,11 @@ export class TimelineView extends ListView { //ignore error, as it is handled in the VM } finally { - this._topLoadingPromise = null; + this._topLoadingPromise = undefined; } } - async _onScroll() { + async _handleScroll(evt: Event) { const PAGINATE_OFFSET = 100; const root = this.root(); if (root.scrollTop < PAGINATE_OFFSET && !this._topLoadingPromise && this._viewModel) { @@ -102,18 +120,18 @@ export class TimelineView extends ListView { } } - mount() { + override mount() { const root = super.mount(); - root.addEventListener("scroll", this._onScroll); + root.addEventListener("scroll", this); return root; } - unmount() { - this.root().removeEventListener("scroll", this._onScroll); + override unmount() { + this.root().removeEventListener("scroll", this); super.unmount(); } - async loadList() { + override async loadList() { super.loadList(); const root = this.root(); // yield so the browser can render the list @@ -130,7 +148,7 @@ export class TimelineView extends ListView { }); } - onBeforeListChanged() { + override onBeforeListChanged() { const fromBottom = this._distanceFromBottom(); this._atBottom = fromBottom < 1; } @@ -140,25 +158,23 @@ export class TimelineView extends ListView { return root.scrollHeight - root.scrollTop - root.clientHeight; } - onListChanged() { + override onListChanged() { const root = this.root(); if (this._atBottom) { root.scrollTop = root.scrollHeight; } } - onUpdate(index, value, param) { + override onUpdate(index: number, value: SimpleTile, param: any) { if (param === "shape") { - if (this._childInstances) { - const ExpectedClass = viewClassForEntry(value); - const child = this._childInstances[index]; - if (!ExpectedClass || !(child instanceof ExpectedClass)) { - // shape was updated, so we need to recreate the tile view, - // the shape parameter is set in EncryptedEventTile.updateEntry - // (and perhaps elsewhere by the time you read this) - super.recreateItem(index, value); - return; - } + const ExpectedClass = viewClassForEntry(value); + const child = this.getChildInstanceByIndex(index); + if (!ExpectedClass || !(child instanceof ExpectedClass)) { + // shape was updated, so we need to recreate the tile view, + // the shape parameter is set in EncryptedEventTile.updateEntry + // (and perhaps elsewhere by the time you read this) + super.recreateItem(index, value); + return; } } super.onUpdate(index, value, param); From c78a83d3989433e9daa041d718a68f36631e19f9 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Tue, 7 Sep 2021 15:17:27 +0200 Subject: [PATCH 04/57] 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 05/57] 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 06/57] 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 07/57] 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 08/57] 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 09/57] 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 10/57] 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 11/57] 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 12/57] 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 13/57] 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 14/57] 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 15/57] 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 16/57] 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 17/57] 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 18/57] 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 19/57] 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 20/57] 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 21/57] 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 22/57] 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 23/57] 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 24/57] 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 25/57] 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 26/57] 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 27/57] 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 28/57] 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 29/57] 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 30/57] 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 31/57] 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 32/57] 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 33/57] 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 34/57] 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 35/57] 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 36/57] 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 37/57] 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 38/57] 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 39/57] 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 40/57] 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 41/57] 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 42/57] 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 43/57] 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 44/57] 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, From e978202f36d6b843da7cf0bdfa3ae6326bca4bbf Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 16 Sep 2021 17:48:29 +0200 Subject: [PATCH 45/57] add min-height: 0 --- src/platform/web/ui/css/timeline.css | 1 + 1 file changed, 1 insertion(+) diff --git a/src/platform/web/ui/css/timeline.css b/src/platform/web/ui/css/timeline.css index c4d1459b..f214c98f 100644 --- a/src/platform/web/ui/css/timeline.css +++ b/src/platform/web/ui/css/timeline.css @@ -18,6 +18,7 @@ limitations under the License. display: flex; flex-direction: column; position: relative; + min-height: 0; } .Timeline_jumpDown { From ccc5aac02564f5880f6a7d880c8779ab98ed2ebc Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 16 Sep 2021 18:20:25 +0200 Subject: [PATCH 46/57] release v0.2.9 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 3116863c..4f032e5f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "hydrogen-web", - "version": "0.2.8", + "version": "0.2.9", "description": "A javascript matrix client prototype, trying to minize RAM usage by offloading as much as possible to IndexedDB", "main": "index.js", "directories": { From bae8dc7dd7e468c55be4c7afb6752e4b95954013 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 16 Sep 2021 22:28:19 +0200 Subject: [PATCH 47/57] changes should be true when replyVM is not set and we set an entry --- src/domain/session/room/ComposerViewModel.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/domain/session/room/ComposerViewModel.js b/src/domain/session/room/ComposerViewModel.js index dad0fd68..81a8fc17 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?.id?.equals(entry?.asEventKey()); + const changed = new Boolean(entry) !== new Boolean(this._replyVM) || !this._replyVM?.id.equals(entry.asEventKey()); if (changed) { this._replyVM = this.disposeTracked(this._replyVM); if (entry) { From f02d52ce4c9e46d31cc646afc6eaa958851081c7 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 16 Sep 2021 22:29:13 +0200 Subject: [PATCH 48/57] with !interactive, an array of an array is not a valid Child value why didn't this fail before? --- .../web/ui/session/room/timeline/BaseMessageView.js | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/src/platform/web/ui/session/room/timeline/BaseMessageView.js b/src/platform/web/ui/session/room/timeline/BaseMessageView.js index 89127453..73183e05 100644 --- a/src/platform/web/ui/session/room/timeline/BaseMessageView.js +++ b/src/platform/web/ui/session/room/timeline/BaseMessageView.js @@ -33,6 +33,10 @@ export class BaseMessageView extends TemplateView { } render(t, vm) { + const children = [this.renderMessageBody(t, vm)]; + if (this._interactive) { + children.push(t.button({className: "Timeline_messageOptions"}, "⋯")); + } const li = t.el(this._tagName, {className: { "Timeline_message": true, own: vm.isOwn, @@ -40,13 +44,8 @@ export class BaseMessageView extends TemplateView { unverified: vm.isUnverified, disabled: !this._interactive, continuation: vm => vm.isContinuation, - }}, [ - // dynamically added and removed nodes are handled below - this.renderMessageBody(t, vm), - // should be after body as it is overlayed on top - this._interactive ? t.button({className: "Timeline_messageOptions"}, "⋯") : [], - ]); const avatar = t.a({href: vm.memberPanelLink, className: "Timeline_messageAvatar"}, [renderStaticAvatar(vm, 30)]); + }}, children); // given that there can be many tiles, we don't add // unneeded DOM nodes in case of a continuation, and we add it // with a side-effect binding to not have to create sub views, From c4477a81eaea4b2267e159c8411247c831c3291b Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 16 Sep 2021 22:29:48 +0200 Subject: [PATCH 49/57] don't create the avatar node when we won't need it --- src/platform/web/ui/session/room/timeline/BaseMessageView.js | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/platform/web/ui/session/room/timeline/BaseMessageView.js b/src/platform/web/ui/session/room/timeline/BaseMessageView.js index 73183e05..e1268cb3 100644 --- a/src/platform/web/ui/session/room/timeline/BaseMessageView.js +++ b/src/platform/web/ui/session/room/timeline/BaseMessageView.js @@ -44,7 +44,6 @@ export class BaseMessageView extends TemplateView { unverified: vm.isUnverified, disabled: !this._interactive, continuation: vm => vm.isContinuation, - const avatar = t.a({href: vm.memberPanelLink, className: "Timeline_messageAvatar"}, [renderStaticAvatar(vm, 30)]); }}, children); // given that there can be many tiles, we don't add // unneeded DOM nodes in case of a continuation, and we add it @@ -56,8 +55,10 @@ export class BaseMessageView extends TemplateView { li.removeChild(li.querySelector(".Timeline_messageAvatar")); li.removeChild(li.querySelector(".Timeline_messageSender")); } else if (!isContinuation) { + const avatar = tag.a({href: vm.memberPanelLink, className: "Timeline_messageAvatar"}, [renderStaticAvatar(vm, 30)]); + const sender = tag.div({className: `Timeline_messageSender usercolor${vm.avatarColorNumber}`}, vm.displayName); li.insertBefore(avatar, li.firstChild); - li.insertBefore(tag.div({className: `Timeline_messageSender usercolor${vm.avatarColorNumber}`}, vm.displayName), li.firstChild); + li.insertBefore(sender, li.firstChild); } }); // similarly, we could do this with a simple ifView, From 41089e280650a247cca9a96e4b41dfa3f1a3b6a4 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 16 Sep 2021 22:30:03 +0200 Subject: [PATCH 50/57] remove leftover logging --- src/platform/web/ui/session/room/TimelineView.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/platform/web/ui/session/room/TimelineView.ts b/src/platform/web/ui/session/room/TimelineView.ts index de2f44fa..3226e05f 100644 --- a/src/platform/web/ui/session/room/TimelineView.ts +++ b/src/platform/web/ui/session/room/TimelineView.ts @@ -176,7 +176,6 @@ export class TimelineView extends TemplateView { bottomNodeIndex = len - 1; } else { const viewportBottom = scrollTop + clientHeight; - // console.log(`viewportBottom: ${viewportBottom} (${scrollTop} + ${clientHeight})`); const anchoredNodeIndex = findFirstNodeIndexAtOrBelow(tilesNode, viewportBottom); this.anchoredNode = tilesNode.childNodes[anchoredNodeIndex] as HTMLElement; this.anchoredBottom = bottom(this.anchoredNode!); From cce7606960d1e352a5ef0c5ccf8f6eb1efbed7c2 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 16 Sep 2021 22:43:38 +0200 Subject: [PATCH 51/57] release v0.2.10 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 4f032e5f..5012d912 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "hydrogen-web", - "version": "0.2.9", + "version": "0.2.10", "description": "A javascript matrix client prototype, trying to minize RAM usage by offloading as much as possible to IndexedDB", "main": "index.js", "directories": { From aeedb948cc3c219ed24eb5429b7888edee0b4e68 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Fri, 17 Sep 2021 18:19:26 +0200 Subject: [PATCH 52/57] pass logger to Storage and make it available in Transaction --- src/domain/SessionPickerViewModel.js | 8 ++++++-- src/logging/LogItem.js | 4 ++++ src/matrix/storage/idb/Storage.ts | 15 +++++++++------ src/matrix/storage/idb/StorageFactory.ts | 17 +++++++++-------- src/matrix/storage/idb/Transaction.ts | 16 +++++++++++++--- 5 files changed, 41 insertions(+), 19 deletions(-) diff --git a/src/domain/SessionPickerViewModel.js b/src/domain/SessionPickerViewModel.js index 3bbbcef7..fc669df1 100644 --- a/src/domain/SessionPickerViewModel.js +++ b/src/domain/SessionPickerViewModel.js @@ -150,7 +150,9 @@ export class SessionPickerViewModel extends ViewModel { async _exportData(id) { const sessionInfo = await this.platform.sessionInfoStorage.get(id); - const stores = await this.platform.storageFactory.export(id); + const stores = await this.logger.run("export", log => { + return this.platform.storageFactory.export(id, log); + }); const data = {sessionInfo, stores}; return data; } @@ -161,7 +163,9 @@ export class SessionPickerViewModel extends ViewModel { const {sessionInfo} = data; sessionInfo.comment = `Imported on ${new Date().toLocaleString()} from id ${sessionInfo.id}.`; sessionInfo.id = this._createSessionContainer().createNewSessionId(); - await this.platform.storageFactory.import(sessionInfo.id, data.stores); + await this.logger.run("import", log => { + return this.platform.storageFactory.import(sessionInfo.id, data.stores, log); + }); await this.platform.sessionInfoStorage.add(sessionInfo); this._sessions.set(new SessionItemViewModel(sessionInfo, this)); } catch (err) { diff --git a/src/logging/LogItem.js b/src/logging/LogItem.js index fcb88eff..69128270 100644 --- a/src/logging/LogItem.js +++ b/src/logging/LogItem.js @@ -231,4 +231,8 @@ export class LogItem { this._children.push(item); return item; } + + get logger() { + return this._logger; + } } diff --git a/src/matrix/storage/idb/Storage.ts b/src/matrix/storage/idb/Storage.ts index bffdb642..6f7f4c90 100644 --- a/src/matrix/storage/idb/Storage.ts +++ b/src/matrix/storage/idb/Storage.ts @@ -17,20 +17,23 @@ limitations under the License. import {Transaction} from "./Transaction"; import { STORE_NAMES, StoreNames, StorageError } from "../common"; import { reqAsPromise } from "./utils"; +import { BaseLogger } from "../../../logging/BaseLogger.js"; const WEBKITEARLYCLOSETXNBUG_BOGUS_KEY = "782rh281re38-boguskey"; export class Storage { private _db: IDBDatabase; private _hasWebkitEarlyCloseTxnBug: boolean; - private _IDBKeyRange: typeof IDBKeyRange - storeNames: typeof StoreNames; + readonly logger: BaseLogger; + readonly IDBKeyRange: typeof IDBKeyRange; + readonly storeNames: typeof StoreNames; - constructor(idbDatabase: IDBDatabase, _IDBKeyRange: typeof IDBKeyRange, hasWebkitEarlyCloseTxnBug: boolean) { + constructor(idbDatabase: IDBDatabase, _IDBKeyRange: typeof IDBKeyRange, hasWebkitEarlyCloseTxnBug: boolean, logger: BaseLogger) { this._db = idbDatabase; - this._IDBKeyRange = _IDBKeyRange; + this.IDBKeyRange = _IDBKeyRange; this._hasWebkitEarlyCloseTxnBug = hasWebkitEarlyCloseTxnBug; this.storeNames = StoreNames; + this.logger = logger; } _validateStoreNames(storeNames: StoreNames[]): void { @@ -49,7 +52,7 @@ export class Storage { if (this._hasWebkitEarlyCloseTxnBug) { await reqAsPromise(txn.objectStore(storeNames[0]).get(WEBKITEARLYCLOSETXNBUG_BOGUS_KEY)); } - return new Transaction(txn, storeNames, this._IDBKeyRange); + return new Transaction(txn, storeNames, this); } catch(err) { throw new StorageError("readTxn failed", err); } @@ -64,7 +67,7 @@ export class Storage { if (this._hasWebkitEarlyCloseTxnBug) { await reqAsPromise(txn.objectStore(storeNames[0]).get(WEBKITEARLYCLOSETXNBUG_BOGUS_KEY)); } - return new Transaction(txn, storeNames, this._IDBKeyRange); + return new Transaction(txn, storeNames, this); } catch(err) { throw new StorageError("readWriteTxn failed", err); } diff --git a/src/matrix/storage/idb/StorageFactory.ts b/src/matrix/storage/idb/StorageFactory.ts index 6db78c36..38b48a17 100644 --- a/src/matrix/storage/idb/StorageFactory.ts +++ b/src/matrix/storage/idb/StorageFactory.ts @@ -20,9 +20,10 @@ import { exportSession, importSession, Export } from "./export"; import { schema } from "./schema"; import { detectWebkitEarlyCloseTxnBug } from "./quirks"; import { BaseLogger } from "../../../logging/BaseLogger.js"; +import { LogItem } from "../../../logging/LogItem.js"; const sessionName = (sessionId: string) => `hydrogen_session_${sessionId}`; -const openDatabaseWithSessionId = function(sessionId: string, idbFactory: IDBFactory, log?: BaseLogger) { +const openDatabaseWithSessionId = function(sessionId: string, idbFactory: IDBFactory, log: LogItem) { const create = (db, txn, oldVersion, version) => createStores(db, txn, oldVersion, version, log); return openDatabase(sessionName(sessionId), create, schema.length, idbFactory); } @@ -59,7 +60,7 @@ export class StorageFactory { this._IDBKeyRange = _IDBKeyRange; } - async create(sessionId: string, log?: BaseLogger): Promise { + async create(sessionId: string, log: LogItem): Promise { await this._serviceWorkerHandler?.preventConcurrentSessionAccess(sessionId); requestPersistedStorage().then(persisted => { // Firefox lies here though, and returns true even if the user denied the request @@ -70,7 +71,7 @@ export class StorageFactory { const hasWebkitEarlyCloseTxnBug = await detectWebkitEarlyCloseTxnBug(this._idbFactory); const db = await openDatabaseWithSessionId(sessionId, this._idbFactory, log); - return new Storage(db, this._IDBKeyRange, hasWebkitEarlyCloseTxnBug); + return new Storage(db, this._IDBKeyRange, hasWebkitEarlyCloseTxnBug, log.logger); } delete(sessionId: string): Promise { @@ -79,18 +80,18 @@ export class StorageFactory { return reqAsPromise(req); } - async export(sessionId: string): Promise { - const db = await openDatabaseWithSessionId(sessionId, this._idbFactory); + async export(sessionId: string, log: LogItem): Promise { + const db = await openDatabaseWithSessionId(sessionId, this._idbFactory, log); return await exportSession(db); } - async import(sessionId: string, data: Export): Promise { - const db = await openDatabaseWithSessionId(sessionId, this._idbFactory); + async import(sessionId: string, data: Export, log: LogItem): Promise { + const db = await openDatabaseWithSessionId(sessionId, this._idbFactory, log); return await importSession(db, data); } } -async function createStores(db: IDBDatabase, txn: IDBTransaction, oldVersion: number | null, version: number, log?: BaseLogger): Promise { +async function createStores(db: IDBDatabase, txn: IDBTransaction, oldVersion: number | null, version: number, log: LogItem): Promise { const startIdx = oldVersion || 0; return log.wrap({l: "storage migration", oldVersion, version}, async log => { for(let i = startIdx; i < version; ++i) { diff --git a/src/matrix/storage/idb/Transaction.ts b/src/matrix/storage/idb/Transaction.ts index ea21b745..e9b127ff 100644 --- a/src/matrix/storage/idb/Transaction.ts +++ b/src/matrix/storage/idb/Transaction.ts @@ -35,18 +35,28 @@ import {OutboundGroupSessionStore} from "./stores/OutboundGroupSessionStore"; import {GroupSessionDecryptionStore} from "./stores/GroupSessionDecryptionStore"; import {OperationStore} from "./stores/OperationStore"; import {AccountDataStore} from "./stores/AccountDataStore"; +import {BaseLogger} from "../../../logging/BaseLogger.js"; export class Transaction { private _txn: IDBTransaction; private _allowedStoreNames: StoreNames[]; private _stores: { [storeName in StoreNames]?: any }; + private _storage: Storage; - constructor(txn: IDBTransaction, allowedStoreNames: StoreNames[], IDBKeyRange) { + constructor(txn: IDBTransaction, allowedStoreNames: StoreNames[], storage: Storage) { this._txn = txn; this._allowedStoreNames = allowedStoreNames; this._stores = {}; - // @ts-ignore - this.IDBKeyRange = IDBKeyRange; + this._storage = storage; + this._writeErrors = []; + } + + get IDBKeyRange(): typeof IDBKeyRange { + return this._storage.IDBKeyRange; + } + + get logger(): BaseLogger { + return this._storage.logger; } _idbStore(name: StoreNames): Store { From 533b0f40d3acec3f5fe178ff1bb5ee6c74018423 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Fri, 17 Sep 2021 18:21:48 +0200 Subject: [PATCH 53/57] pass write errors in a store to the transaction --- src/logging/LogItem.js | 10 ++++-- src/matrix/storage/idb/Store.ts | 49 +++++++++++++++++++++++---- src/matrix/storage/idb/Transaction.ts | 40 ++++++++++++++++++++++ 3 files changed, 90 insertions(+), 9 deletions(-) diff --git a/src/logging/LogItem.js b/src/logging/LogItem.js index 69128270..90747964 100644 --- a/src/logging/LogItem.js +++ b/src/logging/LogItem.js @@ -43,12 +43,16 @@ export class LogItem { /** logs a reference to a different log item, usually obtained from runDetached. This is useful if the referenced operation can't be awaited. */ refDetached(logItem, logLevel = null) { - if (!logItem._values.refId) { - logItem.set("refId", this._logger._createRefId()); - } + logItem.ensureRefId(); return this.log({ref: logItem._values.refId}, logLevel); } + ensureRefId() { + if (!this._values.refId) { + this.set("refId", this._logger._createRefId()); + } + } + /** * Creates a new child item and runs it in `callback`. */ diff --git a/src/matrix/storage/idb/Store.ts b/src/matrix/storage/idb/Store.ts index e2da0707..42a41ae6 100644 --- a/src/matrix/storage/idb/Store.ts +++ b/src/matrix/storage/idb/Store.ts @@ -18,6 +18,7 @@ import {QueryTarget, IDBQuery} from "./QueryTarget"; import {IDBRequestAttemptError} from "./error"; import {reqAsPromise} from "./utils"; import {Transaction} from "./Transaction"; +import {LogItem} from "../../../logging/LogItem.js"; const LOG_REQUESTS = false; @@ -148,7 +149,7 @@ export class Store extends QueryTarget { return new QueryTarget(new QueryTargetWrapper(this._idbStore.index(indexName))); } - put(value: T): void { + put(value: T, log?: LogItem): void { // If this request fails, the error will bubble up to the transaction and abort it, // which is the behaviour we want. Therefore, it is ok to not create a promise for this // request and await it. @@ -159,16 +160,52 @@ export class Store extends QueryTarget { // // Note that this can still throw synchronously, like it does for TransactionInactiveError, // see https://www.w3.org/TR/IndexedDB-2/#transaction-lifetime-concept - this._idbStore.put(value); + const request = this._idbStore.put(value); + this._prepareErrorLog(request, log, "put", undefined, value); } - add(value: T): void { + add(value: T, log?: LogItem): void { // ok to not monitor result of request, see comment in `put`. - this._idbStore.add(value); + const request = this._idbStore.add(value); + this._prepareErrorLog(request, log, "add", undefined, value); } - delete(keyOrKeyRange: IDBValidKey | IDBKeyRange): Promise { + delete(keyOrKeyRange: IDBValidKey | IDBKeyRange, log?: LogItem): void { // ok to not monitor result of request, see comment in `put`. - return reqAsPromise(this._idbStore.delete(keyOrKeyRange)); + const request = this._idbStore.delete(keyOrKeyRange); + this._prepareErrorLog(request, log, "delete", keyOrKeyRange, undefined); + } + + private _prepareErrorLog(request: IDBRequest, log: LogItem | undefined, operationName: string, key: IDBValidKey | IDBKeyRange | undefined, value: T | undefined) { + if (log) { + log.ensureRefId(); + } + reqAsPromise(request).catch(err => { + try { + if (!key && value) { + key = this._getKey(value); + } + } catch { + key = "getKey failed"; + } + this._transaction.addWriteError(err, log, operationName, key); + }); + } + + private _getKey(value: T): IDBValidKey { + const {keyPath} = this._idbStore; + if (Array.isArray(keyPath)) { + let field: any = value; + for (const part of keyPath) { + if (typeof field === "object") { + field = field[part]; + } else { + break; + } + } + return field as IDBValidKey; + } else { + return value[keyPath] as IDBValidKey; + } } } diff --git a/src/matrix/storage/idb/Transaction.ts b/src/matrix/storage/idb/Transaction.ts index e9b127ff..291226c5 100644 --- a/src/matrix/storage/idb/Transaction.ts +++ b/src/matrix/storage/idb/Transaction.ts @@ -18,6 +18,7 @@ import {StoreNames} from "../common"; import {txnAsPromise} from "./utils"; import {StorageError} from "../common"; import {Store} from "./Store"; +import {Storage} from "./Storage"; import {SessionStore} from "./stores/SessionStore"; import {RoomSummaryStore} from "./stores/RoomSummaryStore"; import {InviteStore} from "./stores/InviteStore"; @@ -35,13 +36,24 @@ import {OutboundGroupSessionStore} from "./stores/OutboundGroupSessionStore"; import {GroupSessionDecryptionStore} from "./stores/GroupSessionDecryptionStore"; import {OperationStore} from "./stores/OperationStore"; import {AccountDataStore} from "./stores/AccountDataStore"; +import {LogItem} from "../../../logging/LogItem.js"; import {BaseLogger} from "../../../logging/BaseLogger.js"; +class WriteErrorInfo { + constructor( + public readonly error: StorageError, + public readonly refItem: LogItem | undefined, + public readonly operationName: string, + public readonly key: IDBValidKey | IDBKeyRange | undefined, + ) {} +} + export class Transaction { private _txn: IDBTransaction; private _allowedStoreNames: StoreNames[]; private _stores: { [storeName in StoreNames]?: any }; private _storage: Storage; + private _writeErrors: WriteErrorInfo[]; constructor(txn: IDBTransaction, allowedStoreNames: StoreNames[], storage: Storage) { this._txn = txn; @@ -154,5 +166,33 @@ export class Transaction { abort(): void { // TODO: should we wrap the exception in a StorageError? this._txn.abort(); + addWriteError(error: StorageError, refItem: LogItem | undefined, operationName: string, key: IDBValidKey | IDBKeyRange | undefined) { + // don't log subsequent `AbortError`s + if (error.errcode !== "AbortError" || this._writeErrors.length === 0) { + this._writeErrors.push(new WriteErrorInfo(error, refItem, operationName, key)); + } + } + + private _logWriteErrors(parentItem: LogItem | undefined) { + const callback = errorGroupItem => { + // we don't have context when there is no parentItem, so at least log stores + if (!parentItem) { + errorGroupItem.set("allowedStoreNames", this._allowedStoreNames); + } + for (const info of this._writeErrors) { + errorGroupItem.wrap({l: info.operationName, id: info.key}, item => { + if (info.refItem) { + item.refDetached(info.refItem); + } + item.catch(info.error); + }); + } + }; + const label = `${this._writeErrors.length} storage write operation(s) failed`; + if (parentItem) { + parentItem.wrap(label, callback); + } else { + this.logger.run(label, callback); + } } } From ad45016b87e98b570f7d983dca5f2a1e6a715af4 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Fri, 17 Sep 2021 18:23:31 +0200 Subject: [PATCH 54/57] process write errors on complete or abort in transaction --- src/matrix/Sync.js | 10 +++----- src/matrix/storage/idb/Transaction.ts | 34 +++++++++++++++++++++++---- src/matrix/storage/idb/utils.ts | 17 ++------------ 3 files changed, 35 insertions(+), 26 deletions(-) diff --git a/src/matrix/Sync.js b/src/matrix/Sync.js index ed9889b1..bcd29ab4 100644 --- a/src/matrix/Sync.js +++ b/src/matrix/Sync.js @@ -296,14 +296,10 @@ export class Sync { // avoid corrupting state by only // storing the sync up till the point // the exception occurred - try { - syncTxn.abort(); - } catch (abortErr) { - log.set("couldNotAbortTxn", true); - } - throw err; + syncTxn.abort(log); + throw syncTxn.getCause(err); } - await syncTxn.complete(); + await syncTxn.complete(log); } _afterSync(sessionState, inviteStates, roomStates, archivedRoomStates, log) { diff --git a/src/matrix/storage/idb/Transaction.ts b/src/matrix/storage/idb/Transaction.ts index 291226c5..84247428 100644 --- a/src/matrix/storage/idb/Transaction.ts +++ b/src/matrix/storage/idb/Transaction.ts @@ -159,13 +159,39 @@ export class Transaction { return this._store(StoreNames.accountData, idbStore => new AccountDataStore(idbStore)); } - complete(): Promise { - return txnAsPromise(this._txn); + async complete(log?: LogItem): Promise { + try { + await txnAsPromise(this._txn); + } catch (err) { + if (this._writeErrors.length) { + this._logWriteErrors(log); + throw this._writeErrors[0].error; + } + throw err; + } } - abort(): void { + getCause(error: Error) { + if (error instanceof StorageError) { + if (error.errcode === "AbortError" && this._writeErrors.length) { + return this._writeErrors[0].error; + } + } + return error; + } + + abort(log?: LogItem): void { // TODO: should we wrap the exception in a StorageError? - this._txn.abort(); + try { + this._txn.abort(); + } catch (abortErr) { + log?.set("couldNotAbortTxn", true); + } + if (this._writeErrors.length) { + this._logWriteErrors(log); + } + } + addWriteError(error: StorageError, refItem: LogItem | undefined, operationName: string, key: IDBValidKey | IDBKeyRange | undefined) { // don't log subsequent `AbortError`s if (error.errcode !== "AbortError" || this._writeErrors.length === 0) { diff --git a/src/matrix/storage/idb/utils.ts b/src/matrix/storage/idb/utils.ts index bd1683ea..f9209f56 100644 --- a/src/matrix/storage/idb/utils.ts +++ b/src/matrix/storage/idb/utils.ts @@ -17,6 +17,7 @@ limitations under the License. import { IDBRequestError } from "./error"; import { StorageError } from "../common"; +import { AbortError } from "../../../utils/error.js"; let needsSyncPromise = false; @@ -112,22 +113,8 @@ export function txnAsPromise(txn): Promise { // @ts-ignore needsSyncPromise && Promise._flush && Promise._flush(); }); - txn.addEventListener("error", event => { - const request = event.target; - // catch first error here, but don't reject yet, - // as we don't have access to the failed request in the abort event handler - if (!error && request) { - error = new IDBRequestError(request); - } - }); txn.addEventListener("abort", event => { - if (!error) { - const txn = event.target; - const dbName = txn.db.name; - const storeNames = Array.from(txn.objectStoreNames).join(", ") - error = new StorageError(`Transaction on ${dbName} with stores ${storeNames} was aborted.`); - } - reject(error); + reject(new AbortError()); // @ts-ignore needsSyncPromise && Promise._flush && Promise._flush(); }); From f5467a653cebacb347cc68014bd36c1f0b2a72f8 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Fri, 17 Sep 2021 18:24:24 +0200 Subject: [PATCH 55/57] fix stores returning the delete promise which isn't returned anymore I checked these aren't awaited in any js file --- src/matrix/storage/idb/stores/DeviceIdentityStore.ts | 8 ++++---- .../storage/idb/stores/GroupSessionDecryptionStore.ts | 4 ++-- src/matrix/storage/idb/stores/InboundGroupSessionStore.ts | 4 ++-- src/matrix/storage/idb/stores/OlmSessionStore.ts | 4 ++-- src/matrix/storage/idb/stores/OperationStore.ts | 4 ++-- .../storage/idb/stores/OutboundGroupSessionStore.ts | 4 ++-- src/matrix/storage/idb/stores/PendingEventStore.ts | 8 ++++---- src/matrix/storage/idb/stores/RoomStateStore.ts | 4 ++-- src/matrix/storage/idb/stores/RoomSummaryStore.ts | 4 ++-- src/matrix/storage/idb/stores/TimelineFragmentStore.ts | 4 ++-- src/matrix/storage/idb/stores/TimelineRelationStore.ts | 8 ++++---- src/matrix/storage/idb/stores/UserIdentityStore.ts | 4 ++-- 12 files changed, 30 insertions(+), 30 deletions(-) diff --git a/src/matrix/storage/idb/stores/DeviceIdentityStore.ts b/src/matrix/storage/idb/stores/DeviceIdentityStore.ts index 9800fcca..6b9f5332 100644 --- a/src/matrix/storage/idb/stores/DeviceIdentityStore.ts +++ b/src/matrix/storage/idb/stores/DeviceIdentityStore.ts @@ -78,14 +78,14 @@ export class DeviceIdentityStore { return this._store.index("byCurve25519Key").get(curve25519Key); } - remove(userId: string, deviceId: string): Promise { - return this._store.delete(encodeKey(userId, deviceId)); + remove(userId: string, deviceId: string): void { + this._store.delete(encodeKey(userId, deviceId)); } - removeAllForUser(userId: string): Promise { + removeAllForUser(userId: string): void { // exclude both keys as they are theoretical min and max, // but we should't have a match for just the room id, or room id with max const range = this._store.IDBKeyRange.bound(encodeKey(userId, MIN_UNICODE), encodeKey(userId, MAX_UNICODE), true, true); - return this._store.delete(range); + this._store.delete(range); } } diff --git a/src/matrix/storage/idb/stores/GroupSessionDecryptionStore.ts b/src/matrix/storage/idb/stores/GroupSessionDecryptionStore.ts index b6636f13..1627e44a 100644 --- a/src/matrix/storage/idb/stores/GroupSessionDecryptionStore.ts +++ b/src/matrix/storage/idb/stores/GroupSessionDecryptionStore.ts @@ -44,11 +44,11 @@ export class GroupSessionDecryptionStore { this._store.put(decryption as GroupSessionEntry); } - removeAllForRoom(roomId: string): Promise { + removeAllForRoom(roomId: string): void { const range = this._store.IDBKeyRange.bound( encodeKey(roomId, MIN_UNICODE, MIN_UNICODE), encodeKey(roomId, MAX_UNICODE, MAX_UNICODE) ); - return this._store.delete(range); + this._store.delete(range); } } diff --git a/src/matrix/storage/idb/stores/InboundGroupSessionStore.ts b/src/matrix/storage/idb/stores/InboundGroupSessionStore.ts index 286eaf4e..5dc0205f 100644 --- a/src/matrix/storage/idb/stores/InboundGroupSessionStore.ts +++ b/src/matrix/storage/idb/stores/InboundGroupSessionStore.ts @@ -53,11 +53,11 @@ export class InboundGroupSessionStore { this._store.put(session); } - removeAllForRoom(roomId: string): Promise { + removeAllForRoom(roomId: string) { const range = this._store.IDBKeyRange.bound( encodeKey(roomId, MIN_UNICODE, MIN_UNICODE), encodeKey(roomId, MAX_UNICODE, MAX_UNICODE) ); - return this._store.delete(range); + this._store.delete(range); } } diff --git a/src/matrix/storage/idb/stores/OlmSessionStore.ts b/src/matrix/storage/idb/stores/OlmSessionStore.ts index 1daa0b21..3310215e 100644 --- a/src/matrix/storage/idb/stores/OlmSessionStore.ts +++ b/src/matrix/storage/idb/stores/OlmSessionStore.ts @@ -71,7 +71,7 @@ export class OlmSessionStore { this._store.put(session as OlmSessionEntry); } - remove(senderKey: string, sessionId: string): Promise { - return this._store.delete(encodeKey(senderKey, sessionId)); + remove(senderKey: string, sessionId: string): void { + this._store.delete(encodeKey(senderKey, sessionId)); } } diff --git a/src/matrix/storage/idb/stores/OperationStore.ts b/src/matrix/storage/idb/stores/OperationStore.ts index cccd8e2d..0e103e8c 100644 --- a/src/matrix/storage/idb/stores/OperationStore.ts +++ b/src/matrix/storage/idb/stores/OperationStore.ts @@ -73,8 +73,8 @@ export class OperationStore { this._store.put(operation as OperationEntry); } - remove(id: string): Promise { - return this._store.delete(id); + remove(id: string): void { + this._store.delete(id); } async removeAllForScope(scope: string): Promise { diff --git a/src/matrix/storage/idb/stores/OutboundGroupSessionStore.ts b/src/matrix/storage/idb/stores/OutboundGroupSessionStore.ts index 82a87625..9712c717 100644 --- a/src/matrix/storage/idb/stores/OutboundGroupSessionStore.ts +++ b/src/matrix/storage/idb/stores/OutboundGroupSessionStore.ts @@ -28,8 +28,8 @@ export class OutboundGroupSessionStore { this._store = store; } - remove(roomId: string): Promise { - return this._store.delete(roomId); + remove(roomId: string): void { + this._store.delete(roomId); } get(roomId: string): Promise { diff --git a/src/matrix/storage/idb/stores/PendingEventStore.ts b/src/matrix/storage/idb/stores/PendingEventStore.ts index 01991ce8..d4bef05a 100644 --- a/src/matrix/storage/idb/stores/PendingEventStore.ts +++ b/src/matrix/storage/idb/stores/PendingEventStore.ts @@ -62,9 +62,9 @@ export class PendingEventStore { } } - remove(roomId: string, queueIndex: number): Promise { + remove(roomId: string, queueIndex: number) { const keyRange = this._eventStore.IDBKeyRange.only(encodeKey(roomId, queueIndex)); - return this._eventStore.delete(keyRange); + this._eventStore.delete(keyRange); } async exists(roomId: string, queueIndex: number): Promise { @@ -86,10 +86,10 @@ export class PendingEventStore { return this._eventStore.selectAll(); } - removeAllForRoom(roomId: string): Promise { + removeAllForRoom(roomId: string): void { const minKey = encodeKey(roomId, KeyLimits.minStorageKey); const maxKey = encodeKey(roomId, KeyLimits.maxStorageKey); const range = this._eventStore.IDBKeyRange.bound(minKey, maxKey); - return this._eventStore.delete(range); + this._eventStore.delete(range); } } diff --git a/src/matrix/storage/idb/stores/RoomStateStore.ts b/src/matrix/storage/idb/stores/RoomStateStore.ts index 441d61e4..b7ece0f7 100644 --- a/src/matrix/storage/idb/stores/RoomStateStore.ts +++ b/src/matrix/storage/idb/stores/RoomStateStore.ts @@ -47,10 +47,10 @@ export class RoomStateStore { this._roomStateStore.put(entry); } - removeAllForRoom(roomId: string): Promise { + removeAllForRoom(roomId: string): void { // exclude both keys as they are theoretical min and max, // but we should't have a match for just the room id, or room id with max const range = this._roomStateStore.IDBKeyRange.bound(roomId, `${roomId}|${MAX_UNICODE}`, true, true); - return this._roomStateStore.delete(range); + this._roomStateStore.delete(range); } } diff --git a/src/matrix/storage/idb/stores/RoomSummaryStore.ts b/src/matrix/storage/idb/stores/RoomSummaryStore.ts index bd911572..43c62fdb 100644 --- a/src/matrix/storage/idb/stores/RoomSummaryStore.ts +++ b/src/matrix/storage/idb/stores/RoomSummaryStore.ts @@ -55,7 +55,7 @@ export class RoomSummaryStore { return roomId === fetchedKey; } - remove(roomId: string): Promise { - return this._summaryStore.delete(roomId); + remove(roomId: string): void { + this._summaryStore.delete(roomId); } } diff --git a/src/matrix/storage/idb/stores/TimelineFragmentStore.ts b/src/matrix/storage/idb/stores/TimelineFragmentStore.ts index 813fc3f3..5753a93e 100644 --- a/src/matrix/storage/idb/stores/TimelineFragmentStore.ts +++ b/src/matrix/storage/idb/stores/TimelineFragmentStore.ts @@ -87,7 +87,7 @@ export class TimelineFragmentStore { return this._store.get(encodeKey(roomId, fragmentId)); } - removeAllForRoom(roomId: string): Promise { - return this._store.delete(this._allRange(roomId)); + removeAllForRoom(roomId: string): void { + this._store.delete(this._allRange(roomId)); } } diff --git a/src/matrix/storage/idb/stores/TimelineRelationStore.ts b/src/matrix/storage/idb/stores/TimelineRelationStore.ts index 6772864a..88ef86ae 100644 --- a/src/matrix/storage/idb/stores/TimelineRelationStore.ts +++ b/src/matrix/storage/idb/stores/TimelineRelationStore.ts @@ -43,18 +43,18 @@ export class TimelineRelationStore { this._store.add({key: encodeKey(roomId, targetEventId, relType, sourceEventId)}); } - remove(roomId: string, targetEventId: string, relType: string, sourceEventId: string): Promise { - return this._store.delete(encodeKey(roomId, targetEventId, relType, sourceEventId)); + remove(roomId: string, targetEventId: string, relType: string, sourceEventId: string): void { + this._store.delete(encodeKey(roomId, targetEventId, relType, sourceEventId)); } - removeAllForTarget(roomId: string, targetId: string): Promise { + removeAllForTarget(roomId: string, targetId: string): void { const range = this._store.IDBKeyRange.bound( encodeKey(roomId, targetId, MIN_UNICODE, MIN_UNICODE), encodeKey(roomId, targetId, MAX_UNICODE, MAX_UNICODE), true, true ); - return this._store.delete(range); + this._store.delete(range); } async getForTargetAndType(roomId: string, targetId: string, relType: string): Promise { diff --git a/src/matrix/storage/idb/stores/UserIdentityStore.ts b/src/matrix/storage/idb/stores/UserIdentityStore.ts index 1d94a666..692d8384 100644 --- a/src/matrix/storage/idb/stores/UserIdentityStore.ts +++ b/src/matrix/storage/idb/stores/UserIdentityStore.ts @@ -36,7 +36,7 @@ export class UserIdentityStore { this._store.put(userIdentity); } - remove(userId: string): Promise { - return this._store.delete(userId); + remove(userId: string): void { + this._store.delete(userId); } } From 53a0d292447c7248cf1367596f7197a3768e4240 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Fri, 17 Sep 2021 18:25:28 +0200 Subject: [PATCH 56/57] example how to get more context for the failure of a write operation --- src/matrix/room/timeline/persistence/SyncWriter.js | 2 +- src/matrix/storage/idb/stores/TimelineEventStore.ts | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/matrix/room/timeline/persistence/SyncWriter.js b/src/matrix/room/timeline/persistence/SyncWriter.js index 1ee2ce7d..07326225 100644 --- a/src/matrix/room/timeline/persistence/SyncWriter.js +++ b/src/matrix/room/timeline/persistence/SyncWriter.js @@ -162,7 +162,7 @@ export class SyncWriter { storageEntry.displayName = member.displayName; storageEntry.avatarUrl = member.avatarUrl; } - txn.timelineEvents.insert(storageEntry); + txn.timelineEvents.insert(storageEntry, log); const entry = new EventEntry(storageEntry, this._fragmentIdComparer); entries.push(entry); const updatedRelationTargetEntries = await this._relationWriter.writeRelation(entry, txn, log); diff --git a/src/matrix/storage/idb/stores/TimelineEventStore.ts b/src/matrix/storage/idb/stores/TimelineEventStore.ts index 49892771..535b8f7e 100644 --- a/src/matrix/storage/idb/stores/TimelineEventStore.ts +++ b/src/matrix/storage/idb/stores/TimelineEventStore.ts @@ -20,6 +20,7 @@ import { encodeUint32 } from "../utils"; import {KeyLimits} from "../../common"; import {Store} from "../Store"; import {TimelineEvent, StateEvent} from "../../types"; +import {LogItem} from "../../../../logging/LogItem.js"; interface Annotation { count: number; @@ -265,11 +266,10 @@ export class TimelineEventStore { * @return nothing. To wait for the operation to finish, await the transaction it's part of. * @throws {StorageError} ... */ - insert(entry: TimelineEventEntry): void { + insert(entry: TimelineEventEntry, log: LogItem): void { (entry as TimelineEventStorageEntry).key = encodeKey(entry.roomId, entry.fragmentId, entry.eventIndex); (entry as TimelineEventStorageEntry).eventIdKey = encodeEventIdKey(entry.roomId, entry.event.event_id); - // TODO: map error? or in idb/store? - this._timelineStore.add(entry as TimelineEventStorageEntry); + this._timelineStore.add(entry as TimelineEventStorageEntry, log); } /** Updates the entry into the store with the given [roomId, eventKey] combination. From 08fe430c4b714426a646bf933ce8480aaeb6f85b Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Fri, 17 Sep 2021 18:38:30 +0200 Subject: [PATCH 57/57] release v0.2.11 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 5012d912..aaf7e30e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "hydrogen-web", - "version": "0.2.10", + "version": "0.2.11", "description": "A javascript matrix client prototype, trying to minize RAM usage by offloading as much as possible to IndexedDB", "main": "index.js", "directories": {