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/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..16cbce67 --- /dev/null +++ b/src/platform/web/ui/general/ListView.ts @@ -0,0 +1,191 @@ +/* +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; + } + + protected 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() {} + + protected getChildInstanceByIndex(idx: number): V | undefined { + return this._childInstances?.[idx]; + } +} 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/MessageComposer.js b/src/platform/web/ui/session/room/MessageComposer.js index c6e8a1ee..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 "./TimelineList.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 71eb1e13..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 {TimelineList} from "./TimelineList.js"; +import {TimelineView} from "./TimelineView"; 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.ts similarity index 68% rename from src/platform/web/ui/session/room/TimelineList.js rename to src/platform/web/ui/session/room/TimelineView.ts index e0179e5d..6768bb8c 100644 --- a/src/platform/web/ui/session/room/TimelineList.js +++ b/src/platform/web/ui/session/room/TimelineView.ts @@ -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"; @@ -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 TimelineList 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 TimelineList 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 TimelineList 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) { @@ -94,25 +112,26 @@ 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; }); } } - 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 @@ -129,7 +148,7 @@ export class TimelineList extends ListView { }); } - onBeforeListChanged() { + override onBeforeListChanged() { const fromBottom = this._distanceFromBottom(); this._atBottom = fromBottom < 1; } @@ -139,25 +158,23 @@ export class TimelineList 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); 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 {