Merge pull request #488 from vector-im/bwindels/list-and-timeline-view-to-ts

Convert ListView and TimelineView to typescript
This commit is contained in:
Bruno Windels 2021-09-16 16:08:34 +02:00 committed by GitHub
commit b1994918fe
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
14 changed files with 283 additions and 217 deletions

View file

@ -22,7 +22,7 @@ export class EncryptedEventTile extends BaseTextTile {
const parentResult = super.updateEntry(entry, params); const parentResult = super.updateEntry(entry, params);
// event got decrypted, recreate the tile and replace this one with it // event got decrypted, recreate the tile and replace this one with it
if (entry.eventType !== "m.room.encrypted") { 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"); return UpdateAction.Replace("shape");
} else { } else {
return parentResult; return parentResult;

View file

@ -15,8 +15,9 @@ limitations under the License.
*/ */
import {el} from "./html.js"; import {el} from "./html.js";
import {mountView} from "./utils.js"; import {mountView} from "./utils";
import {insertAt, ListView} from "./ListView.js"; import {ListView} from "./ListView";
import {insertAt} from "./utils";
class ItemRange { class ItemRange {
constructor(topCount, renderCount, bottomCount) { constructor(topCount, renderCount, bottomCount) {

View file

@ -1,163 +0,0 @@
/*
Copyright 2020 Bruno Windels <bruno@windels.cloud>
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import {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() {}
}

View file

@ -0,0 +1,191 @@
/*
Copyright 2020 Bruno Windels <bruno@windels.cloud>
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import {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<T, V> {
list: ObservableList<T>,
onItemClick?: (childView: V, evt: UIEvent) => void,
className?: string,
tagName?: string,
parentProvidesUpdates?: boolean
}
type SubscriptionHandle = () => undefined;
export class ListView<T, V extends UIView> implements UIView {
private _onItemClick?: (childView: V, evt: UIEvent) => void;
private _list: ObservableList<T>;
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<T, V>,
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<T, V>) {
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];
}
}

View file

@ -15,7 +15,7 @@ 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.js";
import {mountView} from "./utils.js"; import {mountView} from "./utils";
import {BaseUpdateView} from "./BaseUpdateView.js"; import {BaseUpdateView} from "./BaseUpdateView.js";
function objHasFns(obj) { function objHasFns(obj) {

View file

@ -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 See the License for the specific language governing permissions and
limitations under the License. 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 interface UIView {
mount(args?: IMountArgs): HTMLElement;
export function mountView(view, mountArgs = undefined) { root(): HTMLElement; // should only be called between mount() and unmount()
let node; unmount(): void;
try { update(...any); // this isn't really standarized yet
node = view.mount(mountArgs);
} catch (err) {
node = errorToDOM(err);
}
return node;
} }

View file

@ -1,5 +1,5 @@
/* /*
Copyright 2020 Bruno Windels <bruno@windels.cloud> Copyright 2021 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with 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. limitations under the License.
*/ */
import {UIView, IMountArgs} from "./types";
import {tag} from "./html.js"; 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; const stack = new Error().stack;
let callee = null; let callee: string | null = null;
if (stack) { if (stack) {
callee = stack.split("\n")[1]; callee = stack.split("\n")[1];
} }
@ -29,3 +40,13 @@ export function errorToDOM(error) {
tag.pre(error.stack), 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);
}
}

View file

@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import {ListView} from "../general/ListView.js"; import {ListView} from "../general/ListView";
import {TemplateView} from "../general/TemplateView.js"; import {TemplateView} from "../general/TemplateView.js";
import {hydrogenGithubLink} from "./common.js"; import {hydrogenGithubLink} from "./common.js";
import {SessionLoadStatusView} from "./SessionLoadStatusView.js"; import {SessionLoadStatusView} from "./SessionLoadStatusView.js";

View file

@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import {ListView} from "../../general/ListView.js"; import {ListView} from "../../general/ListView";
import {TemplateView} from "../../general/TemplateView.js"; import {TemplateView} from "../../general/TemplateView.js";
import {RoomTileView} from "./RoomTileView.js"; import {RoomTileView} from "./RoomTileView.js";
import {InviteTileView} from "./InviteTileView.js"; import {InviteTileView} from "./InviteTileView.js";

View file

@ -17,7 +17,7 @@ limitations under the License.
import {TemplateView} from "../../general/TemplateView.js"; import {TemplateView} from "../../general/TemplateView.js";
import {Popup} from "../../general/Popup.js"; import {Popup} from "../../general/Popup.js";
import {Menu} from "../../general/Menu.js"; import {Menu} from "../../general/Menu.js";
import {viewClassForEntry} from "./TimelineList.js" import {viewClassForEntry} from "./TimelineView"
export class MessageComposer extends TemplateView { export class MessageComposer extends TemplateView {
constructor(viewModel) { constructor(viewModel) {

View file

@ -18,7 +18,7 @@ limitations under the License.
import {TemplateView} from "../../general/TemplateView.js"; import {TemplateView} from "../../general/TemplateView.js";
import {Popup} from "../../general/Popup.js"; import {Popup} from "../../general/Popup.js";
import {Menu} from "../../general/Menu.js"; import {Menu} from "../../general/Menu.js";
import {TimelineList} from "./TimelineList.js"; import {TimelineView} from "./TimelineView";
import {TimelineLoadingView} from "./TimelineLoadingView.js"; import {TimelineLoadingView} from "./TimelineLoadingView.js";
import {MessageComposer} from "./MessageComposer.js"; import {MessageComposer} from "./MessageComposer.js";
import {RoomArchivedView} from "./RoomArchivedView.js"; import {RoomArchivedView} from "./RoomArchivedView.js";
@ -54,7 +54,7 @@ export class RoomView extends TemplateView {
t.div({className: "RoomView_error"}, vm => vm.error), t.div({className: "RoomView_error"}, vm => vm.error),
t.mapView(vm => vm.timelineViewModel, timelineViewModel => { t.mapView(vm => vm.timelineViewModel, timelineViewModel => {
return timelineViewModel ? return timelineViewModel ?
new TimelineList(timelineViewModel) : new TimelineView(timelineViewModel) :
new TimelineLoadingView(vm); // vm is just needed for i18n new TimelineLoadingView(vm); // vm is just needed for i18n
}), }),
t.view(bottomView), t.view(bottomView),

View file

@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import {ListView} from "../../general/ListView.js"; import {ListView} from "../../general/ListView";
import {GapView} from "./timeline/GapView.js"; import {GapView} from "./timeline/GapView.js";
import {TextMessageView} from "./timeline/TextMessageView.js"; import {TextMessageView} from "./timeline/TextMessageView.js";
import {ImageView} from "./timeline/ImageView.js"; import {ImageView} from "./timeline/ImageView.js";
@ -23,8 +23,14 @@ import {FileView} from "./timeline/FileView.js";
import {MissingAttachmentView} from "./timeline/MissingAttachmentView.js"; import {MissingAttachmentView} from "./timeline/MissingAttachmentView.js";
import {AnnouncementView} from "./timeline/AnnouncementView.js"; import {AnnouncementView} from "./timeline/AnnouncementView.js";
import {RedactedView} from "./timeline/RedactedView.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) { switch (entry.shape) {
case "gap": return GapView; case "gap": return GapView;
case "announcement": return AnnouncementView; case "announcement": return AnnouncementView;
@ -40,13 +46,18 @@ export function viewClassForEntry(entry) {
} }
} }
export class TimelineList extends ListView { export class TimelineView extends ListView<SimpleTile, TileView> {
constructor(viewModel) {
private _atBottom: boolean;
private _topLoadingPromise?: Promise<boolean>;
private _viewModel: TimelineViewModel;
constructor(viewModel: TimelineViewModel) {
const options = { const options = {
className: "Timeline bottom-aligned-scroll", className: "Timeline bottom-aligned-scroll",
list: viewModel.tiles, list: viewModel.tiles,
onItemClick: (tileView, evt) => tileView.onClick(evt), onItemClick: (tileView, evt) => tileView.onClick(evt),
} };
super(options, entry => { super(options, entry => {
const View = viewClassForEntry(entry); const View = viewClassForEntry(entry);
if (View) { if (View) {
@ -54,12 +65,19 @@ export class TimelineList extends ListView {
} }
}); });
this._atBottom = false; this._atBottom = false;
this._onScroll = this._onScroll.bind(this); this._topLoadingPromise = undefined;
this._topLoadingPromise = null;
this._viewModel = viewModel; 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) { if (this._topLoadingPromise) {
return; return;
} }
@ -78,11 +96,11 @@ export class TimelineList extends ListView {
//ignore error, as it is handled in the VM //ignore error, as it is handled in the VM
} }
finally { finally {
this._topLoadingPromise = null; this._topLoadingPromise = undefined;
} }
} }
async _onScroll() { async _handleScroll(evt: Event) {
const PAGINATE_OFFSET = 100; const PAGINATE_OFFSET = 100;
const root = this.root(); const root = this.root();
if (root.scrollTop < PAGINATE_OFFSET && !this._topLoadingPromise && this._viewModel) { if (root.scrollTop < PAGINATE_OFFSET && !this._topLoadingPromise && this._viewModel) {
@ -94,25 +112,26 @@ export class TimelineList extends ListView {
this._loadAtTopWhile(() => { this._loadAtTopWhile(() => {
const contentHeight = root.scrollHeight; const contentHeight = root.scrollHeight;
const amountGrown = contentHeight - beforeContentHeight; const amountGrown = contentHeight - beforeContentHeight;
root.scrollTop = root.scrollTop + (contentHeight - lastContentHeight); const topDiff = contentHeight - lastContentHeight;
root.scrollBy(0, topDiff);
lastContentHeight = contentHeight; lastContentHeight = contentHeight;
return amountGrown < PAGINATE_OFFSET; return amountGrown < PAGINATE_OFFSET;
}); });
} }
} }
mount() { override mount() {
const root = super.mount(); const root = super.mount();
root.addEventListener("scroll", this._onScroll); root.addEventListener("scroll", this);
return root; return root;
} }
unmount() { override unmount() {
this.root().removeEventListener("scroll", this._onScroll); this.root().removeEventListener("scroll", this);
super.unmount(); super.unmount();
} }
async loadList() { override async loadList() {
super.loadList(); super.loadList();
const root = this.root(); const root = this.root();
// yield so the browser can render the list // yield so the browser can render the list
@ -129,7 +148,7 @@ export class TimelineList extends ListView {
}); });
} }
onBeforeListChanged() { override onBeforeListChanged() {
const fromBottom = this._distanceFromBottom(); const fromBottom = this._distanceFromBottom();
this._atBottom = fromBottom < 1; this._atBottom = fromBottom < 1;
} }
@ -139,25 +158,23 @@ export class TimelineList extends ListView {
return root.scrollHeight - root.scrollTop - root.clientHeight; return root.scrollHeight - root.scrollTop - root.clientHeight;
} }
onListChanged() { override onListChanged() {
const root = this.root(); const root = this.root();
if (this._atBottom) { if (this._atBottom) {
root.scrollTop = root.scrollHeight; root.scrollTop = root.scrollHeight;
} }
} }
onUpdate(index, value, param) { override onUpdate(index: number, value: SimpleTile, param: any) {
if (param === "shape") { if (param === "shape") {
if (this._childInstances) { const ExpectedClass = viewClassForEntry(value);
const ExpectedClass = viewClassForEntry(value); const child = this.getChildInstanceByIndex(index);
const child = this._childInstances[index]; if (!ExpectedClass || !(child instanceof ExpectedClass)) {
if (!ExpectedClass || !(child instanceof ExpectedClass)) { // shape was updated, so we need to recreate the tile view,
// shape was updated, so we need to recreate the tile view, // the shape parameter is set in EncryptedEventTile.updateEntry
// the shape parameter is set in EncryptedEventTile.updateEntry // (and perhaps elsewhere by the time you read this)
// (and perhaps elsewhere by the time you read this) super.recreateItem(index, value);
super.recreateItem(index, value); return;
return;
}
} }
} }
super.onUpdate(index, value, param); super.onUpdate(index, value, param);

View file

@ -17,7 +17,7 @@ limitations under the License.
import {renderStaticAvatar} from "../../../avatar.js"; import {renderStaticAvatar} from "../../../avatar.js";
import {tag} from "../../../general/html.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 {TemplateView} from "../../../general/TemplateView.js";
import {Popup} from "../../../general/Popup.js"; import {Popup} from "../../../general/Popup.js";
import {Menu} from "../../../general/Menu.js"; import {Menu} from "../../../general/Menu.js";

View file

@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import {ListView} from "../../../general/ListView.js"; import {ListView} from "../../../general/ListView";
import {TemplateView} from "../../../general/TemplateView.js"; import {TemplateView} from "../../../general/TemplateView.js";
export class ReactionsView extends ListView { export class ReactionsView extends ListView {