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/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/TilesCollection.js b/src/domain/session/room/timeline/TilesCollection.js index 10062af2..497c0b0d 100644 --- a/src/domain/session/room/timeline/TilesCollection.js +++ b/src/domain/session/room/timeline/TilesCollection.js @@ -236,6 +236,21 @@ export class TilesCollection extends BaseObservableList { getFirst() { return this._tiles[0]; } + + getTileIndex(searchTile) { + const idx = sortedIndex(this._tiles, searchTile, (searchTile, tile) => { + 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 a08ab060..9c936218 100644 --- a/src/domain/session/room/timeline/TimelineViewModel.js +++ b/src/domain/session/room/timeline/TimelineViewModel.js @@ -40,40 +40,74 @@ export class TimelineViewModel extends ViewModel { const {timeline, tilesCreator} = options; this._timeline = this.track(timeline); this._tiles = new TilesCollection(timeline.entries, tilesCreator); + this._startTile = null; + this._endTile = null; + this._topLoadingPromise = null; + this._requestedStartTile = null; + this._requestedEndTile = null; + this._requestScheduled = false; + this._showJumpDown = false; } - /** - * @return {bool} startReached if the start of the timeline was reached - */ - async loadAtTop() { - if (this.isDisposed) { - // stop loading more, we switched room - return true; + /** 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) { + Promise.resolve().then(() => { + this._setVisibleTileRange(this._requestedStartTile, this._requestedEndTile); + this._requestScheduled = false; + }); + this._requestScheduled = true; } - const firstTile = this._tiles.getFirst(); - if (firstTile?.shape === "gap") { - return await firstTile.fill(); + } + + _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 + 1)) { + tile.notifyVisible(); + } + loadTop = startIndex < 10; + this._setShowJumpDown(endIndex < (this._tiles.length - 1)); } else { - const topReached = await this._timeline.loadAtTop(10); - return topReached; + // tiles collection is empty, load more at top + loadTop = true; + this._setShowJumpDown(false); } - } - 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) + if (loadTop && !this._topLoadingPromise) { + this._topLoadingPromise = this._timeline.loadAtTop(10).then(hasReachedEnd => { + this._topLoadingPromise = null; + if (!hasReachedEnd) { + // check if more items need to be loaded by recursing + // 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); + } + }); + } } 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/domain/session/room/timeline/tiles/GapTile.js b/src/domain/session/room/timeline/tiles/GapTile.js index 32d632bf..5b4bcea4 100644 --- a/src/domain/session/room/timeline/tiles/GapTile.js +++ b/src/domain/session/room/timeline/tiles/GapTile.js @@ -22,11 +22,11 @@ export class GapTile extends SimpleTile { super(options); this._loading = false; this._error = null; + this._isAtTop = true; } async fill() { - // prevent doing this twice - if (!this._loading) { + if (!this._loading && !this._entry.edgeReached) { this._loading = true; this.emitChange("isLoading"); try { @@ -43,8 +43,25 @@ export class GapTile extends SimpleTile { this.emitChange("isLoading"); } } - // edgeReached will have been updated by fillGap - return this._entry.edgeReached; + } + + notifyVisible() { + 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) { diff --git a/src/domain/session/room/timeline/tiles/SimpleTile.js b/src/domain/session/room/timeline/tiles/SimpleTile.js index 3c370b72..4c1c1de0 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() { @@ -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,8 @@ export class SimpleTile extends ViewModel { } + notifyVisible() {} + dispose() { this.setUpdateEmit(null); super.dispose(); 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/matrix/room/timeline/Timeline.js b/src/matrix/room/timeline/Timeline.js index 022eda4e..54eabf96 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(20, txn, log)); try { const entries = await readerRequest.complete(); this._setupEntries(entries); 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/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/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/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..6db33a25 --- /dev/null +++ b/src/platform/web/ui/css/themes/element/icons/chevron-down.svg @@ -0,0 +1,36 @@ + + + + + + + + + + + diff --git a/src/platform/web/ui/css/themes/element/timeline.css b/src/platform/web/ui/css/themes/element/timeline.css index 408d10cc..d2885bca 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: @@ -362,3 +376,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 af2d6c14..c4d1459b 100644 --- a/src/platform/web/ui/css/timeline.css +++ b/src/platform/web/ui/css/timeline.css @@ -14,13 +14,35 @@ See the License for the specific language governing permissions and limitations under the License. */ +.Timeline { + display: flex; + flex-direction: column; + position: relative; +} -.RoomView_body > ul { - overflow-y: auto; - overscroll-behavior: contain; - list-style: none; +.Timeline_jumpDown { + position: absolute; +} + +.Timeline_scroller { + overflow-y: scroll; + overscroll-behavior-y: contain; + overflow-anchor: none; padding: 0; margin: 0; + /* need to read the offsetTop of tiles relative to this element in TimelineView */ + position: relative; + min-height: 0; + flex: 1 0 0; +} + +.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 + content when there are not yet enough tiles to fill the viewport */ + padding: 1px 0; + margin: 0; } .message-container { @@ -49,13 +71,7 @@ limitations under the License. } .GapView { - visibility: hidden; display: flex; - padding: 10px 20px; -} - -.GapView.isLoading { - visibility: visible; } .GapView > :nth-child(2) { 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..2eb4d40f 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, 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 IView { + 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/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 16cbce67..cdfdbf76 100644 --- a/src/platform/web/ui/general/ListView.ts +++ b/src/platform/web/ui/general/ListView.ts @@ -14,10 +14,10 @@ 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"; +import {IView, IMountArgs} from "./types"; interface IOptions { list: ObservableList, @@ -29,13 +29,13 @@ 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; 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,17 +74,17 @@ 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); + 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) { @@ -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.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(); + child.root()!.remove(); + insertAt(this._root!, toIdx, child.root()! as Element); } protected onUpdate(i: number, value: T, params: any) { @@ -176,16 +170,13 @@ 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(); } } } - protected onBeforeListChanged() {} - protected onListChanged() {} - - protected getChildInstanceByIndex(idx: number): V | undefined { + public getChildInstanceByIndex(idx: number): V | undefined { return this._childInstances?.[idx]; } } 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/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/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.ts similarity index 64% rename from src/platform/web/ui/general/TemplateView.js rename to src/platform/web/ui/general/TemplateView.ts index 3e9727dc..93dc8172 100644 --- a/src/platform/web/ui/general/TemplateView.js +++ 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.js"; +import { setAttribute, text, isChildren, classNames, TAG_NAMES, HTML_NS, ClassNames, Child} from "./html"; import {mountView} from "./utils"; -import {BaseUpdateView} from "./BaseUpdateView.js"; +import {BaseUpdateView, IObservableValue} from "./BaseUpdateView"; +import {IMountArgs, ViewNode, IView} 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?: IView[] = 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"); } @@ -80,12 +96,12 @@ 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; + 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: IView): void { if (!this._subViews) { this._subViews = []; } this._subViews.push(view); } - removeSubView(view) { + removeSubView(view: IView): 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: 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, viewCreator) { + 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; - 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) => IView): 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/general/html.js b/src/platform/web/ui/general/html.ts similarity index 60% rename from src/platform/web/ui/general/html.js rename to src/platform/web/ui/general/html.ts index f12ad306..9ebcfaaf 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,18 @@ limitations under the License. // DOM helper functions -export function isChildren(children) { +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 | 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 - 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 +41,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 +55,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, undefined) : false; } setAttribute(e, name, value); } @@ -74,7 +83,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 +92,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 +106,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/types.ts b/src/platform/web/ui/general/types.ts index f8f671c7..1d9122aa 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 }; -export interface UIView { - mount(args?: IMountArgs): HTMLElement; - root(): HTMLElement; // should only be called between mount() and unmount() +// Comment nodes can be used as temporary placeholders for Elements, like TemplateView does. +export type ViewNode = Element | Comment; + +export interface IView { + 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 f4469ca1..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} from "./types"; -import {tag} from "./html.js"; +import {IView, IMountArgs, ViewNode} from "./types"; +import {tag} from "./html"; -export function mountView(view: UIView, mountArgs: IMountArgs): HTMLElement { +export function mountView(view: IView, 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) { @@ -41,7 +41,7 @@ export function errorToDOM(error: Error): HTMLElement { ]); } -export function insertAt(parentNode: HTMLElement, idx: number, childNode: HTMLElement): void { +export function insertAt(parentNode: Element, idx: number, childNode: Node): void { const isLast = idx === parentNode.childElementCount; if (isLast) { parentNode.appendChild(childNode); 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 a9c4c109..a9524e09 100644 --- a/src/platform/web/ui/session/rightpanel/RoomDetailsView.js +++ b/src/platform/web/ui/session/rightpanel/RoomDetailsView.js @@ -14,8 +14,8 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {TemplateView} from "../../general/TemplateView.js"; -import {classNames, tag} from "../../general/html.js"; +import {TemplateView} from "../../general/TemplateView"; +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/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/TimelineView.ts b/src/platform/web/ui/session/room/TimelineView.ts index 6768bb8c..de2f44fa 100644 --- a/src/platform/web/ui/session/room/TimelineView.ts +++ b/src/platform/web/ui/session/room/TimelineView.ts @@ -15,6 +15,8 @@ limitations under the License. */ import {ListView} from "../../general/ListView"; +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"; @@ -24,7 +26,14 @@ 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; @@ -46,16 +55,152 @@ 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 first item if nothing matched before + return 0; +} - constructor(viewModel: TimelineViewModel) { +export class TimelineView extends TemplateView { + + private anchoredNode?: HTMLElement; + private anchoredBottom: number = 0; + private stickToBottom: boolean = true; + private tilesView?: TilesListView; + private resizeObserver?: ResizeObserver; + + render(t: Builder, 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()); + 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") { + this.resizeObserver = new ResizeObserver(() => { + this.restoreScrollPosition(); + }); + this.resizeObserver.observe(root); + } + + return root; + } + + 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 {scrollNode} = this; + this.stickToBottom = true; + scrollNode.scrollTop = scrollNode.scrollHeight; + } + + public unmount() { + super.unmount(); + if (this.resizeObserver) { + this.resizeObserver.unobserve(this.root()! as Element); + this.resizeObserver = undefined; + } + } + + private restoreScrollPosition() { + const {scrollNode, tilesNode} = this; + + const missingTilesHeight = scrollNode.clientHeight - tilesNode.clientHeight; + if (missingTilesHeight > 0) { + 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 { + tilesNode.style.removeProperty("margin-top"); + if (this.stickToBottom) { + scrollNode.scrollTop = scrollNode.scrollHeight; + } else if (this.anchoredNode) { + const newAnchoredBottom = bottom(this.anchoredNode!); + if (newAnchoredBottom !== this.anchoredBottom) { + const bottomDiff = newAnchoredBottom - this.anchoredBottom; + // 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 scrollNode.scrollBy === "function") { + scrollNode.scrollBy(0, bottomDiff); + } else { + scrollNode.scrollTop = scrollNode.scrollTop + bottomDiff; + } + this.anchoredBottom = newAnchoredBottom; + } + } + // TODO: should we be updating the visible range here as well as the range might have changed even though + // we restored the bottom tile + } + } + + private onScroll(): void { + const {scrollNode, tilesNode} = this; + const {scrollHeight, scrollTop, clientHeight} = scrollNode; + + let bottomNodeIndex; + this.stickToBottom = Math.abs(scrollHeight - (scrollTop + clientHeight)) < 1; + if (this.stickToBottom) { + const len = this.value.tiles.length; + 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!); + bottomNodeIndex = anchoredNodeIndex; + } + let topNodeIndex = findFirstNodeIndexAtOrBelow(tilesNode, scrollTop, bottomNodeIndex); + this.updateVisibleRange(topNodeIndex, bottomNodeIndex); + } + + 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); + this.value.setVisibleTileRange(firstVisibleChild?.value, lastVisibleChild?.value); + } +} + +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 +209,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 +225,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(); } } 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 9469d201..89127453 100644 --- a/src/platform/web/ui/session/room/timeline/BaseMessageView.js +++ b/src/platform/web/ui/session/room/timeline/BaseMessageView.js @@ -16,9 +16,9 @@ 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 {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 1e6e0af0..2d3bd6e8 100644 --- a/src/platform/web/ui/session/room/timeline/GapView.js +++ b/src/platform/web/ui/session/room/timeline/GapView.js @@ -14,19 +14,23 @@ 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 { - render(t, vm) { + render(t) { const className = { GapView: true, - isLoading: vm => vm.isLoading + isLoading: vm => vm.isLoading, + isAtTop: vm => vm.isAtTop, }; 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)) ]); } + + /* This is called by the parent ListView, which just has 1 listener for the whole list */ + onClick() {} } 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/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 { 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 { 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);