Merge pull request #486 from vector-im/bwindels/fix-scroll-jumps

Fix scroll jumps and loading of gaps not at top of the timeline
This commit is contained in:
Bruno Windels 2021-09-16 17:14:17 +02:00 committed by GitHub
commit 9a9b206bf5
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
58 changed files with 598 additions and 332 deletions

View file

@ -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) // 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 // 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"; import {Disposables} from "../utils/Disposables.js";
export class ViewModel extends EventEmitter { export class ViewModel extends EventEmitter {

View file

@ -25,7 +25,7 @@ export class ComposerViewModel extends ViewModel {
} }
setReplyingTo(entry) { setReplyingTo(entry) {
const changed = this._replyVM?.internalId !== entry?.asEventKey().toString(); const changed = this._replyVM?.id?.equals(entry?.asEventKey());
if (changed) { if (changed) {
this._replyVM = this.disposeTracked(this._replyVM); this._replyVM = this.disposeTracked(this._replyVM);
if (entry) { if (entry) {

View file

@ -236,6 +236,21 @@ export class TilesCollection extends BaseObservableList {
getFirst() { getFirst() {
return this._tiles[0]; 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"; import {ObservableArray} from "../../../../observable/list/ObservableArray.js";

View file

@ -40,40 +40,74 @@ export class TimelineViewModel extends ViewModel {
const {timeline, tilesCreator} = options; const {timeline, tilesCreator} = options;
this._timeline = this.track(timeline); this._timeline = this.track(timeline);
this._tiles = new TilesCollection(timeline.entries, tilesCreator); 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;
} }
/** /** if this.tiles is empty, call this with undefined for both startTile and endTile */
* @return {bool} startReached if the start of the timeline was reached setVisibleTileRange(startTile, endTile) {
*/ // don't clear these once done as they are used to check
async loadAtTop() { // for more tiles once loadAtTop finishes
if (this.isDisposed) { this._requestedStartTile = startTile;
// stop loading more, we switched room this._requestedEndTile = endTile;
return true; 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 { } else {
const topReached = await this._timeline.loadAtTop(10); // tiles collection is empty, load more at top
return topReached; loadTop = true;
this._setShowJumpDown(false);
} }
}
unloadAtTop(/*tileAmount*/) { if (loadTop && !this._topLoadingPromise) {
// get lowerSortKey for tile at index tileAmount - 1 this._topLoadingPromise = this._timeline.loadAtTop(10).then(hasReachedEnd => {
// tell timeline to unload till there (included given key) this._topLoadingPromise = null;
} if (!hasReachedEnd) {
// check if more items need to be loaded by recursing
loadAtBottom() { // 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);
}
unloadAtBottom(/*tileAmount*/) { });
// get upperSortKey for tile at index tiles.length - tileAmount }
// tell timeline to unload till there (included given key)
} }
get tiles() { get tiles() {
return this._tiles; return this._tiles;
} }
_setShowJumpDown(show) {
if (this._showJumpDown !== show) {
this._showJumpDown = show;
this.emitChange("showJumpDown");
}
}
get showJumpDown() {
return this._showJumpDown;
}
} }

View file

@ -22,11 +22,11 @@ export class GapTile extends SimpleTile {
super(options); super(options);
this._loading = false; this._loading = false;
this._error = null; this._error = null;
this._isAtTop = true;
} }
async fill() { async fill() {
// prevent doing this twice if (!this._loading && !this._entry.edgeReached) {
if (!this._loading) {
this._loading = true; this._loading = true;
this.emitChange("isLoading"); this.emitChange("isLoading");
try { try {
@ -43,8 +43,25 @@ export class GapTile extends SimpleTile {
this.emitChange("isLoading"); 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) { updateEntry(entry, params) {

View file

@ -40,8 +40,8 @@ export class SimpleTile extends ViewModel {
return false; return false;
} }
get internalId() { get id() {
return this._entry.asEventKey().toString(); return this._entry.asEventKey();
} }
get isPending() { get isPending() {
@ -83,6 +83,10 @@ export class SimpleTile extends ViewModel {
return this._entry; return this._entry;
} }
compare(tile) {
return this.upperEntry.compare(tile.upperEntry);
}
compareEntry(entry) { compareEntry(entry) {
return this._entry.compare(entry); return this._entry.compare(entry);
} }
@ -119,6 +123,8 @@ export class SimpleTile extends ViewModel {
} }
notifyVisible() {}
dispose() { dispose() {
this.setUpdateEmit(null); this.setUpdateEmit(null);
super.dispose(); super.dispose();

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 {EventEmitter} from "../../utils/EventEmitter.js"; import {EventEmitter} from "../../utils/EventEmitter";
import {RoomSummary} from "./RoomSummary.js"; import {RoomSummary} from "./RoomSummary.js";
import {GapWriter} from "./timeline/persistence/GapWriter.js"; import {GapWriter} from "./timeline/persistence/GapWriter.js";
import {RelationWriter} from "./timeline/persistence/RelationWriter.js"; import {RelationWriter} from "./timeline/persistence/RelationWriter.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 {EventEmitter} from "../../utils/EventEmitter.js"; import {EventEmitter} from "../../utils/EventEmitter";
import {SummaryData, processStateEvent} from "./RoomSummary.js"; import {SummaryData, processStateEvent} from "./RoomSummary.js";
import {Heroes} from "./members/Heroes.js"; import {Heroes} from "./members/Heroes.js";
import {MemberChange, RoomMember, EVENT_TYPE as MEMBER_EVENT_TYPE} from "./members/RoomMember.js"; import {MemberChange, RoomMember, EVENT_TYPE as MEMBER_EVENT_TYPE} from "./members/RoomMember.js";

View file

@ -72,8 +72,10 @@ export class Timeline {
// as they should only populate once the view subscribes to it // as they should only populate once the view subscribes to it
// if they are populated already, the sender profile would be empty // if they are populated already, the sender profile would be empty
// 30 seems to be a good amount to fill the entire screen // choose good amount here between showing messages initially and
const readerRequest = this._disposables.track(this._timelineReader.readFromEnd(30, txn, log)); // 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 { try {
const entries = await readerRequest.complete(); const entries = await readerRequest.complete();
this._setupEntries(entries); this._setupEntries(entries);

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 {BaseUpdateView} from "./general/BaseUpdateView.js"; import {BaseUpdateView} from "./general/BaseUpdateView";
import {renderStaticAvatar, renderImg} from "./avatar.js"; import {renderStaticAvatar, renderImg} from "./avatar.js";
/* /*
@ -66,7 +66,7 @@ export class AvatarView extends BaseUpdateView {
this._avatarTitleChanged(); this._avatarTitleChanged();
this._root = renderStaticAvatar(this.value, this._size); this._root = renderStaticAvatar(this.value, this._size);
// takes care of update being called when needed // takes care of update being called when needed
super.mount(options); this.subscribeOnMount(options);
return this._root; return this._root;
} }

View file

@ -18,7 +18,7 @@ import {SessionView} from "./session/SessionView.js";
import {LoginView} from "./login/LoginView.js"; import {LoginView} from "./login/LoginView.js";
import {SessionLoadView} from "./login/SessionLoadView.js"; import {SessionLoadView} from "./login/SessionLoadView.js";
import {SessionPickerView} from "./login/SessionPickerView.js"; import {SessionPickerView} from "./login/SessionPickerView.js";
import {TemplateView} from "./general/TemplateView.js"; import {TemplateView} from "./general/TemplateView";
import {StaticView} from "./general/StaticView.js"; import {StaticView} from "./general/StaticView.js";
export class RootView extends TemplateView { export class RootView extends TemplateView {

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 {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 {Object} vm view model with {avatarUrl, avatarColorNumber, avatarTitle, avatarLetter}
* @param {Number} size * @param {Number} size

View file

@ -0,0 +1,36 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
width="17"
height="9"
viewBox="0 0 17 9"
fill="none"
version="1.1"
id="svg839"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<g
clip-path="url(#clip0)"
id="g832"
transform="rotate(-90,4.3001277,4.8826258)">
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M 8.20723,2.70711 C 8.59775,3.09763 8.59878,3.73182 8.20952,4.1236 L 3.27581,9.08934 8.22556,14.0391 c 0.39052,0.3905 0.39155,1.0247 0.00229,1.4165 -0.38926,0.3918 -1.0214,0.3928 -1.41192,0.0023 L 1.15907,9.80101 C 0.768549,9.41049 0.767523,8.7763 1.15678,8.38452 L 6.79531,2.70939 C 7.18457,2.31761 7.8167,2.31658 8.20723,2.70711 Z"
fill="#8d99a5"
id="path830" />
</g>
<defs
id="defs837">
<clipPath
id="clip0">
<rect
width="8"
height="17"
fill="#ffffff"
transform="rotate(180,4.25,8.5)"
id="rect834"
x="0"
y="0" />
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1 KiB

View file

@ -15,6 +15,20 @@ See the License for the specific language governing permissions and
limitations under the License. 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 { .Timeline_message {
display: grid; display: grid;
grid-template: grid-template:
@ -362,3 +376,11 @@ only loads when the top comes into view*/
.GapView > :not(:first-child) { .GapView > :not(:first-child) {
margin-left: 12px; margin-left: 12px;
} }
.GapView {
padding: 52px 20px;
}
.GapView.isAtTop {
padding: 52px 20px 12px 20px;
}

View file

@ -14,13 +14,35 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
.Timeline {
display: flex;
flex-direction: column;
position: relative;
}
.RoomView_body > ul { .Timeline_jumpDown {
overflow-y: auto; position: absolute;
overscroll-behavior: contain; }
list-style: none;
.Timeline_scroller {
overflow-y: scroll;
overscroll-behavior-y: contain;
overflow-anchor: none;
padding: 0; padding: 0;
margin: 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 { .message-container {
@ -49,13 +71,7 @@ limitations under the License.
} }
.GapView { .GapView {
visibility: hidden;
display: flex; display: flex;
padding: 10px 20px;
}
.GapView.isLoading {
visibility: visible;
} }
.GapView > :nth-child(2) { .GapView > :nth-child(2) {

View file

@ -1,5 +1,6 @@
/* /*
Copyright 2021 The Matrix.org Foundation C.I.C. Copyright 2021 The Matrix.org Foundation C.I.C.
Copyright 2021 Daniel Fedorin <danila.fedorin@gmail.com>
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,40 +15,54 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
export class BaseUpdateView { import {IMountArgs, ViewNode, IView} from "./types";
constructor(value) {
export interface IObservableValue {
on?(event: "change", handler: (props?: string[]) => void): void;
off?(event: "change", handler: (props?: string[]) => void): void;
}
export abstract class BaseUpdateView<T extends IObservableValue> 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; this._value = value;
// TODO: can avoid this if we adopt the handleEvent pattern in our EventListener // TODO: can avoid this if we adopt the handleEvent pattern in our EventListener
this._boundUpdateFromValue = null; this._boundUpdateFromValue = null;
} }
mount(options) { subscribeOnMount(options?: IMountArgs): void {
const parentProvidesUpdates = options && options.parentProvidesUpdates; const parentProvidesUpdates = options && options.parentProvidesUpdates;
if (!parentProvidesUpdates) { if (!parentProvidesUpdates) {
this._subscribe(); this._subscribe();
} }
} }
unmount() { unmount(): void {
this._unsubscribe(); this._unsubscribe();
} }
get value() { get value(): T {
return this._value; return this._value;
} }
_updateFromValue(changedProps) { _updateFromValue(changedProps?: string[]) {
this.update(this._value, changedProps); this.update(this._value, changedProps);
} }
_subscribe() { _subscribe(): void {
if (typeof this._value?.on === "function") { 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); this._value.on("change", this._boundUpdateFromValue);
} }
} }
_unsubscribe() { _unsubscribe(): void {
if (this._boundUpdateFromValue) { if (this._boundUpdateFromValue) {
if (typeof this._value.off === "function") { if (typeof this._value.off === "function") {
this._value.off("change", this._boundUpdateFromValue); this._value.off("change", this._boundUpdateFromValue);

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 {el} from "./html.js"; import {el} from "./html";
import {mountView} from "./utils"; import {mountView} from "./utils";
import {ListView} from "./ListView"; import {ListView} from "./ListView";
import {insertAt} from "./utils"; import {insertAt} from "./utils";

View file

@ -14,10 +14,10 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import {el} from "./html.js"; import {el} from "./html";
import {mountView, insertAt} from "./utils"; import {mountView, insertAt} from "./utils";
import {BaseObservableList as ObservableList} from "../../../../observable/list/BaseObservableList.js"; import {BaseObservableList as ObservableList} from "../../../../observable/list/BaseObservableList.js";
import {UIView, IMountArgs} from "./types"; import {IView, IMountArgs} from "./types";
interface IOptions<T, V> { interface IOptions<T, V> {
list: ObservableList<T>, list: ObservableList<T>,
@ -29,13 +29,13 @@ interface IOptions<T, V> {
type SubscriptionHandle = () => undefined; type SubscriptionHandle = () => undefined;
export class ListView<T, V extends UIView> implements UIView { export class ListView<T, V extends IView> implements IView {
private _onItemClick?: (childView: V, evt: UIEvent) => void; private _onItemClick?: (childView: V, evt: UIEvent) => void;
private _list: ObservableList<T>; private _list: ObservableList<T>;
private _className?: string; private _className?: string;
private _tagName: string; private _tagName: string;
private _root?: HTMLElement; private _root?: Element;
private _subscription?: SubscriptionHandle; private _subscription?: SubscriptionHandle;
private _childCreator: (value: T) => V; private _childCreator: (value: T) => V;
private _childInstances?: V[]; private _childInstances?: V[];
@ -56,9 +56,9 @@ export class ListView<T, V extends UIView> implements UIView {
this._mountArgs = {parentProvidesUpdates}; this._mountArgs = {parentProvidesUpdates};
} }
root(): HTMLElement { root(): Element | undefined {
// won't be undefined when called between mount and unmount // won't be undefined when called between mount and unmount
return this._root!; return this._root;
} }
update(attributes: IOptions<T, V>) { update(attributes: IOptions<T, V>) {
@ -74,17 +74,17 @@ export class ListView<T, V extends UIView> implements UIView {
} }
} }
mount(): HTMLElement { mount(): Element {
const attr: {[name: string]: any} = {}; const attr: {[name: string]: any} = {};
if (this._className) { if (this._className) {
attr.className = this._className; attr.className = this._className;
} }
this._root = el(this._tagName, attr); const root = this._root = el(this._tagName, attr);
this.loadList(); this.loadList();
if (this._onItemClick) { if (this._onItemClick) {
this._root!.addEventListener("click", this); root.addEventListener("click", this);
} }
return this._root!; return root;
} }
handleEvent(evt: Event) { handleEvent(evt: Event) {
@ -138,28 +138,22 @@ export class ListView<T, V extends UIView> implements UIView {
} }
protected onAdd(idx: number, value: T) { protected onAdd(idx: number, value: T) {
this.onBeforeListChanged();
const child = this._childCreator(value); const child = this._childCreator(value);
this._childInstances!.splice(idx, 0, child); this._childInstances!.splice(idx, 0, child);
insertAt(this._root!, idx, mountView(child, this._mountArgs)); insertAt(this._root!, idx, mountView(child, this._mountArgs));
this.onListChanged();
} }
protected onRemove(idx: number, value: T) { protected onRemove(idx: number, value: T) {
this.onBeforeListChanged();
const [child] = this._childInstances!.splice(idx, 1); const [child] = this._childInstances!.splice(idx, 1);
child.root().remove(); child.root()!.remove();
child.unmount(); child.unmount();
this.onListChanged();
} }
protected onMove(fromIdx: number, toIdx: number, value: T) { protected onMove(fromIdx: number, toIdx: number, value: T) {
this.onBeforeListChanged();
const [child] = this._childInstances!.splice(fromIdx, 1); const [child] = this._childInstances!.splice(fromIdx, 1);
this._childInstances!.splice(toIdx, 0, child); this._childInstances!.splice(toIdx, 0, child);
child.root().remove(); child.root()!.remove();
insertAt(this._root!, toIdx, child.root()); insertAt(this._root!, toIdx, child.root()! as Element);
this.onListChanged();
} }
protected onUpdate(i: number, value: T, params: any) { protected onUpdate(i: number, value: T, params: any) {
@ -176,16 +170,13 @@ export class ListView<T, V extends UIView> implements UIView {
this.onRemove(index, value); this.onRemove(index, value);
} else { } else {
const [oldChild] = this._childInstances!.splice(index, 1, child); 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(); oldChild.unmount();
} }
} }
} }
protected onBeforeListChanged() {} public getChildInstanceByIndex(idx: number): V | undefined {
protected onListChanged() {}
protected getChildInstanceByIndex(idx: number): V | undefined {
return this._childInstances?.[idx]; return this._childInstances?.[idx];
} }
} }

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 {TemplateView} from "./TemplateView.js"; import {TemplateView} from "./TemplateView";
import {spinner} from "../common.js"; import {spinner} from "../common.js";
export class LoadingView extends TemplateView { export class LoadingView extends TemplateView {

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 {TemplateView} from "./TemplateView.js"; import {TemplateView} from "./TemplateView";
export class Menu extends TemplateView { export class Menu extends TemplateView {
static option(label, callback) { static option(label, callback) {

View file

@ -169,7 +169,7 @@ export class Popup {
return true; 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() { root() {
return this._fakeRoot; return this._fakeRoot;
} }

View file

@ -15,7 +15,7 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import {tag} from "../general/html.js"; import {tag} from "../general/html";
export class StaticView { export class StaticView {
constructor(value, render = undefined) { constructor(value, render = undefined) {

View file

@ -1,5 +1,6 @@
/* /*
Copyright 2020 Bruno Windels <bruno@windels.cloud> Copyright 2020 Bruno Windels <bruno@windels.cloud>
Copyright 2021 Daniel Fedorin <danila.fedorin@gmail.com>
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 +15,12 @@ See the License for the specific language governing permissions and
limitations under the License. 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 {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<unknown>): obj is { [className: string]: boolean } {
for(const value of Object.values(obj)) { for(const value of Object.values(obj)) {
if (typeof value === "function") { if (typeof value === "function") {
return true; return true;
@ -26,6 +28,17 @@ function objHasFns(obj) {
} }
return false; return false;
} }
export type RenderFn<T> = (t: Builder<T>, vm: T) => ViewNode;
type EventHandler = ((event: Event) => void);
type AttributeStaticValue = string | boolean;
type AttributeBinding<T> = (value: T) => AttributeStaticValue;
export type AttrValue<T> = AttributeStaticValue | AttributeBinding<T> | EventHandler | ClassNames<T>;
export type Attributes<T> = { [attribute: string]: AttrValue<T> };
type ElementFn<T> = (attributes?: Attributes<T> | Child | Child[], children?: Child | Child[]) => Element;
export type Builder<T> = TemplateBuilder<T> & { [tagName in typeof TAG_NAMES[string][number]]: ElementFn<T> };
/** /**
Bindable template. Renders once, and allows bindings for given nodes. If you need Bindable template. Renders once, and allows bindings for given nodes. If you need
to change the structure on a condition, use a subtemplate (if) to change the structure on a condition, use a subtemplate (if)
@ -39,18 +52,21 @@ function objHasFns(obj) {
- add subviews inside the template - add subviews inside the template
*/ */
// TODO: should we rename this to BoundView or something? As opposed to StaticView ... // TODO: should we rename this to BoundView or something? As opposed to StaticView ...
export class TemplateView extends BaseUpdateView { export class TemplateView<T extends IObservableValue> extends BaseUpdateView<T> {
constructor(value, render = undefined) { private _render?: RenderFn<T>;
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<T>) {
super(value); super(value);
// TODO: can avoid this if we have a separate class for inline templates vs class template views // TODO: can avoid this if we have a separate class for inline templates vs class template views
this._render = render; this._render = render;
this._eventListeners = null;
this._bindings = null;
this._subViews = null;
this._root = null;
} }
_attach() { _attach(): void {
if (this._eventListeners) { if (this._eventListeners) {
for (let {node, name, fn, useCapture} of this._eventListeners) { for (let {node, name, fn, useCapture} of this._eventListeners) {
node.addEventListener(name, fn, useCapture); node.addEventListener(name, fn, useCapture);
@ -58,7 +74,7 @@ export class TemplateView extends BaseUpdateView {
} }
} }
_detach() { _detach(): void {
if (this._eventListeners) { if (this._eventListeners) {
for (let {node, name, fn, useCapture} of this._eventListeners) { for (let {node, name, fn, useCapture} of this._eventListeners) {
node.removeEventListener(name, fn, useCapture); node.removeEventListener(name, fn, useCapture);
@ -66,13 +82,13 @@ export class TemplateView extends BaseUpdateView {
} }
} }
mount(options) { mount(options?: IMountArgs): ViewNode {
const builder = new TemplateBuilder(this); const builder = new TemplateBuilder(this) as Builder<T>;
try { try {
if (this._render) { if (this._render) {
this._root = this._render(builder, this._value); this._root = this._render(builder, this._value);
} else if (this.render) { // overriden in subclass } else if (this["render"]) { // overriden in subclass
this._root = this.render(builder, this._value); this._root = this["render"](builder, this._value);
} else { } else {
throw new Error("no render function passed in, or overriden in subclass"); throw new Error("no render function passed in, or overriden in subclass");
} }
@ -80,12 +96,12 @@ export class TemplateView extends BaseUpdateView {
builder.close(); builder.close();
} }
// takes care of update being called when needed // takes care of update being called when needed
super.mount(options); this.subscribeOnMount(options);
this._attach(); this._attach();
return this._root; return this._root!;
} }
unmount() { unmount(): void {
this._detach(); this._detach();
super.unmount(); super.unmount();
if (this._subViews) { if (this._subViews) {
@ -95,11 +111,11 @@ export class TemplateView extends BaseUpdateView {
} }
} }
root() { root(): ViewNode | undefined {
return this._root; return this._root;
} }
update(value) { update(value: T, props?: string[]): void {
this._value = value; this._value = value;
if (this._bindings) { if (this._bindings) {
for (const binding of 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) { if (!this._eventListeners) {
this._eventListeners = []; this._eventListeners = [];
} }
this._eventListeners.push({node, name, fn, useCapture}); this._eventListeners.push({node, name, fn, useCapture});
} }
_addBinding(bindingFn) { _addBinding(bindingFn: () => void): void {
if (!this._bindings) { if (!this._bindings) {
this._bindings = []; this._bindings = [];
} }
this._bindings.push(bindingFn); this._bindings.push(bindingFn);
} }
addSubView(view) { addSubView(view: IView): void {
if (!this._subViews) { if (!this._subViews) {
this._subViews = []; this._subViews = [];
} }
this._subViews.push(view); this._subViews.push(view);
} }
removeSubView(view) { removeSubView(view: IView): void {
if (!this._subViews) { return; }
const idx = this._subViews.indexOf(view); const idx = this._subViews.indexOf(view);
if (idx !== -1) { if (idx !== -1) {
this._subViews.splice(idx, 1); this._subViews.splice(idx, 1);
} }
} }
updateSubViews(value, props) { updateSubViews(value: IObservableValue, props: string[]) {
if (this._subViews) { if (this._subViews) {
for (const v of this._subViews) { for (const v of this._subViews) {
v.update(value, props); v.update(value, props);
@ -146,33 +163,35 @@ export class TemplateView extends BaseUpdateView {
} }
// what is passed to render // what is passed to render
class TemplateBuilder { export class TemplateBuilder<T extends IObservableValue> {
constructor(templateView) { private _templateView: TemplateView<T>;
private _closed: boolean = false;
constructor(templateView: TemplateView<T>) {
this._templateView = templateView; this._templateView = templateView;
this._closed = false;
} }
close() { close(): void {
this._closed = true; this._closed = true;
} }
_addBinding(fn) { _addBinding(fn: () => void): void {
if (this._closed) { if (this._closed) {
console.trace("Adding a binding after render will likely cause memory leaks"); console.trace("Adding a binding after render will likely cause memory leaks");
} }
this._templateView._addBinding(fn); this._templateView._addBinding(fn);
} }
get _value() { get _value(): T {
return this._templateView._value; 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); this._templateView._addEventListener(node, name, fn, useCapture);
} }
_addAttributeBinding(node, name, fn) { _addAttributeBinding(node: Element, name: string, fn: (value: T) => boolean | string): void {
let prevValue = undefined; let prevValue: string | boolean | undefined = undefined;
const binding = () => { const binding = () => {
const newValue = fn(this._value); const newValue = fn(this._value);
if (prevValue !== newValue) { if (prevValue !== newValue) {
@ -184,11 +203,11 @@ class TemplateBuilder {
binding(); binding();
} }
_addClassNamesBinding(node, obj) { _addClassNamesBinding(node: Element, obj: ClassNames<T>): void {
this._addAttributeBinding(node, "className", value => classNames(obj, value)); this._addAttributeBinding(node, "className", value => classNames(obj, value));
} }
_addTextBinding(fn) { _addTextBinding(fn: (value: T) => string): Text {
const initialValue = fn(this._value); const initialValue = fn(this._value);
const node = text(initialValue); const node = text(initialValue);
let prevValue = initialValue; let prevValue = initialValue;
@ -204,21 +223,30 @@ class TemplateBuilder {
return node; return node;
} }
_setNodeAttributes(node, attributes) { _isEventHandler(key: string, value: AttrValue<T>): 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<T>): void {
for(let [key, value] of Object.entries(attributes)) { for(let [key, value] of Object.entries(attributes)) {
const isFn = typeof value === "function";
// binding for className as object of className => enabled // 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)) { if (objHasFns(value)) {
this._addClassNamesBinding(node, value); this._addClassNamesBinding(node, value);
} else { } 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 eventName = key.substr(2, 1).toLowerCase() + key.substr(3);
const handler = value; const handler = value;
this._templateView._addEventListener(node, eventName, handler); this._templateView._addEventListener(node, eventName, handler);
} else if (isFn) { } else if (typeof value === "function") {
this._addAttributeBinding(node, key, value); this._addAttributeBinding(node, key, value);
} else { } else {
setAttribute(node, key, value); setAttribute(node, key, value);
@ -226,14 +254,14 @@ class TemplateBuilder {
} }
} }
_setNodeChildren(node, children) { _setNodeChildren(node: Element, children: Child | Child[]): void{
if (!Array.isArray(children)) { if (!Array.isArray(children)) {
children = [children]; children = [children];
} }
for (let child of children) { for (let child of children) {
if (typeof child === "function") { if (typeof child === "function") {
child = this._addTextBinding(child); child = this._addTextBinding(child);
} else if (!child.nodeType) { } else if (typeof child === "string") {
// not a DOM node, turn into text // not a DOM node, turn into text
child = text(child); child = text(child);
} }
@ -241,7 +269,7 @@ class TemplateBuilder {
} }
} }
_addReplaceNodeBinding(fn, renderNode) { _addReplaceNodeBinding<R>(fn: (value: T) => R, renderNode: (old: ViewNode | null) => ViewNode): ViewNode {
let prevValue = fn(this._value); let prevValue = fn(this._value);
let node = renderNode(null); let node = renderNode(null);
@ -260,14 +288,14 @@ class TemplateBuilder {
return node; return node;
} }
el(name, attributes, children) { el(name: string, attributes?: Attributes<T> | Child | Child[], children?: Child | Child[]): ViewNode {
return this.elNS(HTML_NS, name, attributes, children); return this.elNS(HTML_NS, name, attributes, children);
} }
elNS(ns, name, attributes, children) { elNS(ns: string, name: string, attributes?: Attributes<T> | Child | Child[], children?: Child | Child[]): ViewNode {
if (attributes && isChildren(attributes)) { if (attributes !== undefined && isChildren(attributes)) {
children = attributes; children = attributes;
attributes = null; attributes = undefined;
} }
const node = document.createElementNS(ns, name); 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 // 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). // 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); this._templateView.addSubView(view);
return mountView(view, mountOptions); return mountView(view, mountOptions);
} }
// map a value to a view, every time the value changes // map a value to a view, every time the value changes
mapView(mapFn, viewCreator) { mapView<R>(mapFn: (value: T) => R, viewCreator: (mapped: R) => IView | null): ViewNode {
return this._addReplaceNodeBinding(mapFn, (prevNode) => { return this._addReplaceNodeBinding(mapFn, (prevNode) => {
if (prevNode && prevNode.nodeType !== Node.COMMENT_NODE) { if (prevNode && prevNode.nodeType !== Node.COMMENT_NODE) {
const subViews = this._templateView._subViews; const subViews = this._templateView._subViews;
const viewIdx = subViews.findIndex(v => v.root() === prevNode); if (subViews) {
if (viewIdx !== -1) { const viewIdx = subViews.findIndex(v => v.root() === prevNode);
const [view] = subViews.splice(viewIdx, 1); if (viewIdx !== -1) {
view.unmount(); const [view] = subViews.splice(viewIdx, 1);
view.unmount();
}
} }
} }
const view = viewCreator(mapFn(this._value)); const view = viewCreator(mapFn(this._value));
@ -312,7 +342,7 @@ class TemplateBuilder {
// Special case of mapView for a TemplateView. // Special case of mapView for a TemplateView.
// Always creates a TemplateView, if this is optional depending // Always creates a TemplateView, if this is optional depending
// on mappedValue, use `if` or `mapView` // on mappedValue, use `if` or `mapView`
map(mapFn, renderFn) { map<R>(mapFn: (value: T) => R, renderFn: (mapped: R, t: Builder<T>, vm: T) => ViewNode): ViewNode {
return this.mapView(mapFn, mappedValue => { return this.mapView(mapFn, mappedValue => {
return new TemplateView(this._value, (t, vm) => { return new TemplateView(this._value, (t, vm) => {
const rootNode = renderFn(mappedValue, 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( return this.mapView(
value => !!predicate(value), value => !!predicate(value),
enabled => enabled ? viewCreator(this._value) : null enabled => enabled ? viewCreator(this._value) : null
@ -335,7 +365,7 @@ class TemplateBuilder {
// creates a conditional subtemplate // creates a conditional subtemplate
// use mapView if you need to map to a different view class // use mapView if you need to map to a different view class
if(predicate, renderFn) { if(predicate: (value: T) => boolean, renderFn: (t: Builder<T>, vm: T) => ViewNode) {
return this.ifView(predicate, vm => new TemplateView(vm, renderFn)); 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, This should only be used if the side-effect won't add any bindings,
event handlers, ... event handlers, ...
You should not call the TemplateBuilder (e.g. `t.xxx()`) at all from the side effect, 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. */ instead use tags from html.ts to help you construct any DOM you need. */
mapSideEffect(mapFn, sideEffect) { mapSideEffect<R>(mapFn: (value: T) => R, sideEffect: (newV: R, oldV: R | undefined) => void) {
let prevValue = mapFn(this._value); let prevValue = mapFn(this._value);
const binding = () => { const binding = () => {
const newValue = mapFn(this._value); const newValue = mapFn(this._value);

View file

@ -1,5 +1,6 @@
/* /*
Copyright 2020 Bruno Windels <bruno@windels.cloud> Copyright 2020 Bruno Windels <bruno@windels.cloud>
Copyright 2021 Daniel Fedorin <danila.fedorin@gmail.com>
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.
@ -16,12 +17,18 @@ limitations under the License.
// DOM helper functions // DOM helper functions
export function isChildren(children) { import {ViewNode} from "./types";
export type ClassNames<T> = { [className: string]: boolean | ((value: T) => boolean) }
export type BasicAttributes<T> = { [attribute: string]: ClassNames<T> | 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 // 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<T>(obj: ClassNames<T>, value: T): string {
return Object.entries(obj).reduce((cn, [name, enabled]) => { return Object.entries(obj).reduce((cn, [name, enabled]) => {
if (typeof enabled === "function") { if (typeof enabled === "function") {
enabled = enabled(value); 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") { if (name === "className") {
name = "class"; 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<never> | Child | Child[], children?: Child | Child[]): Element {
return elNS(HTML_NS, elementName, attributes, children); return elNS(HTML_NS, elementName, attributes, children);
} }
export function elNS(ns, elementName, attributes, children) { export function elNS(ns: string, elementName: string, attributes?: BasicAttributes<never> | Child | Child[], children?: Child | Child[]): Element {
if (attributes && isChildren(attributes)) { if (attributes && isChildren(attributes)) {
children = attributes; children = attributes;
attributes = null; attributes = undefined;
} }
const e = document.createElementNS(ns, elementName); const e = document.createElementNS(ns, elementName);
if (attributes) { if (attributes) {
for (let [name, value] of Object.entries(attributes)) { for (let [name, value] of Object.entries(attributes)) {
if (name === "className" && typeof value === "object" && value !== null) { if (typeof value === "object") {
value = classNames(value); // 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); setAttribute(e, name, value);
} }
@ -74,7 +83,7 @@ export function elNS(ns, elementName, attributes, children) {
children = [children]; children = [children];
} }
for (let c of children) { for (let c of children) {
if (!c.nodeType) { if (typeof c === "string") {
c = text(c); c = text(c);
} }
e.appendChild(c); e.appendChild(c);
@ -83,12 +92,12 @@ export function elNS(ns, elementName, attributes, children) {
return e; return e;
} }
export function text(str) { export function text(str: string): Text {
return document.createTextNode(str); return document.createTextNode(str);
} }
export const HTML_NS = "http://www.w3.org/1999/xhtml"; export const HTML_NS: string = "http://www.w3.org/1999/xhtml";
export const SVG_NS = "http://www.w3.org/2000/svg"; export const SVG_NS: string = "http://www.w3.org/2000/svg";
export const TAG_NAMES = { export const TAG_NAMES = {
[HTML_NS]: [ [HTML_NS]: [
@ -97,10 +106,9 @@ export const TAG_NAMES = {
"table", "thead", "tbody", "tr", "th", "td", "hr", "table", "thead", "tbody", "tr", "th", "td", "hr",
"pre", "code", "button", "time", "input", "textarea", "label", "form", "progress", "output", "video"], "pre", "code", "button", "time", "input", "textarea", "label", "form", "progress", "output", "video"],
[SVG_NS]: ["svg", "circle"] [SVG_NS]: ["svg", "circle"]
}; } as const;
export const tag = {};
export const tag: { [tagName in typeof TAG_NAMES[string][number]]: (attributes?: BasicAttributes<never> | Child | Child[], children?: Child | Child[]) => Element } = {} as any;
for (const [ns, tags] of Object.entries(TAG_NAMES)) { for (const [ns, tags] of Object.entries(TAG_NAMES)) {
for (const tagName of tags) { for (const tagName of tags) {

View file

@ -1,5 +1,6 @@
/* /*
Copyright 2021 The Matrix.org Foundation C.I.C. Copyright 2021 The Matrix.org Foundation C.I.C.
Copyright 2021 Daniel Fedorin <danila.fedorin@gmail.com>
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.
@ -15,12 +16,15 @@ limitations under the License.
*/ */
export interface IMountArgs { export interface IMountArgs {
// if true, the parent will call update() rather than the view updating itself by binding to a data source. // 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 { // Comment nodes can be used as temporary placeholders for Elements, like TemplateView does.
mount(args?: IMountArgs): HTMLElement; export type ViewNode = Element | Comment;
root(): HTMLElement; // should only be called between mount() and unmount()
export interface IView {
mount(args?: IMountArgs): ViewNode;
root(): ViewNode | undefined; // should only be called between mount() and unmount()
unmount(): void; unmount(): void;
update(...any); // this isn't really standarized yet update(...any); // this isn't really standarized yet
} }

View file

@ -14,10 +14,10 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import {UIView, IMountArgs} from "./types"; import {IView, IMountArgs, ViewNode} from "./types";
import {tag} from "./html.js"; import {tag} from "./html";
export function mountView(view: UIView, mountArgs: IMountArgs): HTMLElement { export function mountView(view: IView, mountArgs?: IMountArgs): ViewNode {
let node; let node;
try { try {
node = view.mount(mountArgs); node = view.mount(mountArgs);
@ -27,7 +27,7 @@ export function mountView(view: UIView, mountArgs: IMountArgs): HTMLElement {
return node; return node;
} }
export function errorToDOM(error: Error): HTMLElement { export function errorToDOM(error: Error): Element {
const stack = new Error().stack; const stack = new Error().stack;
let callee: string | null = null; let callee: string | null = null;
if (stack) { 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; const isLast = idx === parentNode.childElementCount;
if (isLast) { if (isLast) {
parentNode.appendChild(childNode); parentNode.appendChild(childNode);

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 {TemplateView} from "../general/TemplateView.js"; import {TemplateView} from "../general/TemplateView";
import {SessionLoadStatusView} from "./SessionLoadStatusView.js"; import {SessionLoadStatusView} from "./SessionLoadStatusView.js";
export class CompleteSSOView extends TemplateView { export class CompleteSSOView extends TemplateView {

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 {TemplateView} from "../general/TemplateView.js"; import {TemplateView} from "../general/TemplateView";
import {hydrogenGithubLink} from "./common.js"; import {hydrogenGithubLink} from "./common.js";
import {PasswordLoginView} from "./PasswordLoginView.js"; import {PasswordLoginView} from "./PasswordLoginView.js";
import {CompleteSSOView} from "./CompleteSSOView.js"; import {CompleteSSOView} from "./CompleteSSOView.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 {TemplateView} from "../general/TemplateView.js"; import {TemplateView} from "../general/TemplateView";
export class PasswordLoginView extends TemplateView { export class PasswordLoginView extends TemplateView {
render(t, vm) { render(t, vm) {

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 {TemplateView} from "../general/TemplateView.js"; import {TemplateView} from "../general/TemplateView";
import {spinner} from "../common.js"; import {spinner} from "../common.js";
/** a view used both in the login view and the loading screen /** a view used both in the login view and the loading screen

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 {TemplateView} from "../general/TemplateView.js"; import {TemplateView} from "../general/TemplateView";
import {SessionLoadStatusView} from "./SessionLoadStatusView.js"; import {SessionLoadStatusView} from "./SessionLoadStatusView.js";
export class SessionLoadView extends TemplateView { export class SessionLoadView extends TemplateView {

View file

@ -15,7 +15,7 @@ limitations under the License.
*/ */
import {ListView} from "../general/ListView"; import {ListView} from "../general/ListView";
import {TemplateView} from "../general/TemplateView.js"; import {TemplateView} from "../general/TemplateView";
import {hydrogenGithubLink} from "./common.js"; import {hydrogenGithubLink} from "./common.js";
import {SessionLoadStatusView} from "./SessionLoadStatusView.js"; import {SessionLoadStatusView} from "./SessionLoadStatusView.js";

View file

@ -16,7 +16,7 @@ limitations under the License.
import {RoomView} from "./room/RoomView.js"; import {RoomView} from "./room/RoomView.js";
import {InviteView} from "./room/InviteView.js"; import {InviteView} from "./room/InviteView.js";
import {TemplateView} from "../general/TemplateView.js"; import {TemplateView} from "../general/TemplateView";
import {StaticView} from "../general/StaticView.js"; import {StaticView} from "../general/StaticView.js";
export class RoomGridView extends TemplateView { export class RoomGridView extends TemplateView {

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 {TemplateView} from "../general/TemplateView.js"; import {TemplateView} from "../general/TemplateView";
import {spinner} from "../common.js"; import {spinner} from "../common.js";
export class SessionStatusView extends TemplateView { export class SessionStatusView extends TemplateView {

View file

@ -20,7 +20,7 @@ import {RoomView} from "./room/RoomView.js";
import {UnknownRoomView} from "./room/UnknownRoomView.js"; import {UnknownRoomView} from "./room/UnknownRoomView.js";
import {InviteView} from "./room/InviteView.js"; import {InviteView} from "./room/InviteView.js";
import {LightboxView} from "./room/LightboxView.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 {StaticView} from "../general/StaticView.js";
import {SessionStatusView} from "./SessionStatusView.js"; import {SessionStatusView} from "./SessionStatusView.js";
import {RoomGridView} from "./RoomGridView.js"; import {RoomGridView} from "./RoomGridView.js";

View file

@ -15,7 +15,7 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import {TemplateView} from "../../general/TemplateView.js"; import {TemplateView} from "../../general/TemplateView";
import {renderStaticAvatar} from "../../avatar.js"; import {renderStaticAvatar} from "../../avatar.js";
import {spinner} from "../../common.js"; import {spinner} from "../../common.js";

View file

@ -15,7 +15,7 @@ limitations under the License.
*/ */
import {ListView} from "../../general/ListView"; import {ListView} from "../../general/ListView";
import {TemplateView} from "../../general/TemplateView.js"; import {TemplateView} from "../../general/TemplateView";
import {RoomTileView} from "./RoomTileView.js"; import {RoomTileView} from "./RoomTileView.js";
import {InviteTileView} from "./InviteTileView.js"; import {InviteTileView} from "./InviteTileView.js";

View file

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

View file

@ -15,7 +15,7 @@ limitations under the License.
*/ */
import {AvatarView} from "../../AvatarView.js"; import {AvatarView} from "../../AvatarView.js";
import {TemplateView} from "../../general/TemplateView.js"; import {TemplateView} from "../../general/TemplateView";
export class MemberDetailsView extends TemplateView { export class MemberDetailsView extends TemplateView {
render(t, vm) { render(t, vm) {

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 {TemplateView} from "../../general/TemplateView.js"; import {TemplateView} from "../../general/TemplateView";
import {AvatarView} from "../../AvatarView.js"; import {AvatarView} from "../../AvatarView.js";
export class MemberTileView extends TemplateView { export class MemberTileView extends TemplateView {

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 {TemplateView} from "../../general/TemplateView.js"; import {TemplateView} from "../../general/TemplateView";
import {RoomDetailsView} from "./RoomDetailsView.js"; import {RoomDetailsView} from "./RoomDetailsView.js";
import {MemberListView} from "./MemberListView.js"; import {MemberListView} from "./MemberListView.js";
import {LoadingView} from "../../general/LoadingView.js"; import {LoadingView} from "../../general/LoadingView.js";

View file

@ -14,8 +14,8 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import {TemplateView} from "../../general/TemplateView.js"; import {TemplateView} from "../../general/TemplateView";
import {classNames, tag} from "../../general/html.js"; import {classNames, tag} from "../../general/html";
import {AvatarView} from "../../AvatarView.js"; import {AvatarView} from "../../AvatarView.js";
export class RoomDetailsView extends TemplateView { export class RoomDetailsView extends TemplateView {

View file

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

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 {TemplateView} from "../../general/TemplateView.js"; import {TemplateView} from "../../general/TemplateView";
import {spinner} from "../../common.js"; import {spinner} from "../../common.js";
export class LightboxView extends TemplateView { export class LightboxView extends TemplateView {

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 {TemplateView} from "../../general/TemplateView.js"; import {TemplateView} from "../../general/TemplateView";
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 "./TimelineView" import {viewClassForEntry} from "./TimelineView"

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 {TemplateView} from "../../general/TemplateView.js"; import {TemplateView} from "../../general/TemplateView";
export class RoomArchivedView extends TemplateView { export class RoomArchivedView extends TemplateView {
render(t) { render(t) {

View file

@ -15,7 +15,7 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import {TemplateView} from "../../general/TemplateView.js"; import {TemplateView} from "../../general/TemplateView";
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 {TimelineView} from "./TimelineView"; import {TimelineView} from "./TimelineView";

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 {TemplateView} from "../../general/TemplateView.js"; import {TemplateView} from "../../general/TemplateView";
import {spinner} from "../../common.js"; import {spinner} from "../../common.js";
export class TimelineLoadingView extends TemplateView { export class TimelineLoadingView extends TemplateView {

View file

@ -15,6 +15,8 @@ limitations under the License.
*/ */
import {ListView} from "../../general/ListView"; import {ListView} from "../../general/ListView";
import {TemplateView, Builder} from "../../general/TemplateView";
import {IObservableValue} from "../../general/BaseUpdateView";
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";
@ -24,7 +26,14 @@ 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 {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<SimpleTile>;
setVisibleTileRange(start?: SimpleTile, end?: SimpleTile);
}
type TileView = GapView | AnnouncementView | TextMessageView | type TileView = GapView | AnnouncementView | TextMessageView |
ImageView | VideoView | FileView | MissingAttachmentView | RedactedView; ImageView | VideoView | FileView | MissingAttachmentView | RedactedView;
@ -46,16 +55,152 @@ export function viewClassForEntry(entry: SimpleTile): TileViewConstructor | unde
} }
} }
export class TimelineView extends ListView<SimpleTile, TileView> { function bottom(node: HTMLElement): number {
return node.offsetTop + node.clientHeight;
}
private _atBottom: boolean; function findFirstNodeIndexAtOrBelow(tiles: HTMLElement, top: number, startIndex: number = (tiles.children.length - 1)): number {
private _topLoadingPromise?: Promise<boolean>; for (var i = startIndex; i >= 0; i--) {
private _viewModel: TimelineViewModel; 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<TimelineViewModel> {
private anchoredNode?: HTMLElement;
private anchoredBottom: number = 0;
private stickToBottom: boolean = true;
private tilesView?: TilesListView;
private resizeObserver?: ResizeObserver;
render(t: Builder<TimelineViewModel>, 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<SimpleTile, TileView> {
private onChanged: () => void;
constructor(tiles: ObservableList<SimpleTile>, onChanged: () => void) {
const options = { const options = {
className: "Timeline bottom-aligned-scroll", list: tiles,
list: viewModel.tiles,
onItemClick: (tileView, evt) => tileView.onClick(evt), onItemClick: (tileView, evt) => tileView.onClick(evt),
}; };
super(options, entry => { super(options, entry => {
@ -64,108 +209,10 @@ export class TimelineView extends ListView<SimpleTile, TileView> {
return new View(entry); return new View(entry);
} }
}); });
this._atBottom = false; this.onChanged = onChanged;
this._topLoadingPromise = undefined;
this._viewModel = viewModel;
} }
override handleEvent(evt: Event) { protected onUpdate(index: number, value: SimpleTile, param: any) {
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) {
if (param === "shape") { if (param === "shape") {
const ExpectedClass = viewClassForEntry(value); const ExpectedClass = viewClassForEntry(value);
const child = this.getChildInstanceByIndex(index); const child = this.getChildInstanceByIndex(index);
@ -178,5 +225,21 @@ export class TimelineView extends ListView<SimpleTile, TileView> {
} }
} }
super.onUpdate(index, value, param); 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();
} }
} }

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 {TemplateView} from "../../general/TemplateView.js"; import {TemplateView} from "../../general/TemplateView";
export class UnknownRoomView extends TemplateView { export class UnknownRoomView extends TemplateView {
render(t, vm) { render(t, vm) {

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 {TemplateView} from "../../../general/TemplateView.js"; import {TemplateView} from "../../../general/TemplateView";
export class AnnouncementView extends TemplateView { export class AnnouncementView extends TemplateView {
render(t) { render(t) {

View file

@ -16,9 +16,9 @@ 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";
import {mountView} from "../../../general/utils"; import {mountView} from "../../../general/utils";
import {TemplateView} from "../../../general/TemplateView.js"; import {TemplateView} from "../../../general/TemplateView";
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 {ReactionsView} from "./ReactionsView.js"; import {ReactionsView} from "./ReactionsView.js";

View file

@ -14,19 +14,23 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import {TemplateView} from "../../../general/TemplateView.js"; import {TemplateView} from "../../../general/TemplateView";
import {spinner} from "../../../common.js"; import {spinner} from "../../../common.js";
export class GapView extends TemplateView { export class GapView extends TemplateView {
render(t, vm) { render(t) {
const className = { const className = {
GapView: true, GapView: true,
isLoading: vm => vm.isLoading isLoading: vm => vm.isLoading,
isAtTop: vm => vm.isAtTop,
}; };
return t.li({className}, [ return t.li({className}, [
spinner(t), 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)) 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() {}
} }

View file

@ -15,7 +15,7 @@ limitations under the License.
*/ */
import {ListView} from "../../../general/ListView"; import {ListView} from "../../../general/ListView";
import {TemplateView} from "../../../general/TemplateView.js"; import {TemplateView} from "../../../general/TemplateView";
export class ReactionsView extends ListView { export class ReactionsView extends ListView {
constructor(reactionsViewModel) { constructor(reactionsViewModel) {

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 {tag, text} from "../../../general/html.js"; import {tag, text} from "../../../general/html";
import {BaseMessageView} from "./BaseMessageView.js"; import {BaseMessageView} from "./BaseMessageView.js";
export class TextMessageView extends BaseMessageView { export class TextMessageView extends BaseMessageView {

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 {TemplateView} from "../../general/TemplateView.js"; import {TemplateView} from "../../general/TemplateView";
import {StaticView} from "../../general/StaticView.js"; import {StaticView} from "../../general/StaticView.js";
export class SessionBackupSettingsView extends TemplateView { export class SessionBackupSettingsView extends TemplateView {

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 {TemplateView} from "../../general/TemplateView.js"; import {TemplateView} from "../../general/TemplateView";
import {SessionBackupSettingsView} from "./SessionBackupSettingsView.js" import {SessionBackupSettingsView} from "./SessionBackupSettingsView.js"
export class SettingsView extends TemplateView { export class SettingsView extends TemplateView {

View file

@ -1,5 +1,6 @@
/* /*
Copyright 2020 Bruno Windels <bruno@windels.cloud> Copyright 2020 Bruno Windels <bruno@windels.cloud>
Copyright 2021 Daniel Fedorin <danila.fedorin@gmail.com>
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,28 +15,30 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
export class EventEmitter { type Handler<T> = (value?: T) => void;
export class EventEmitter<T> {
private _handlersByName: { [event in keyof T]?: Set<Handler<T[event]>> }
constructor() { constructor() {
this._handlersByName = {}; this._handlersByName = {};
} }
emit(name, ...values) { emit<K extends keyof T>(name: K, value?: T[K]): void {
const handlers = this._handlersByName[name]; const handlers = this._handlersByName[name];
if (handlers) { if (handlers) {
for(const h of handlers) { handlers.forEach(h => h(value));
h(...values);
}
} }
} }
disposableOn(name, callback) { disposableOn<K extends keyof T>(name: K, callback: Handler<T[K]>): () => void {
this.on(name, callback); this.on(name, callback);
return () => { return () => {
this.off(name, callback); this.off(name, callback);
} }
} }
on(name, callback) { on<K extends keyof T>(name: K, callback: Handler<T[K]>): void {
let handlers = this._handlersByName[name]; let handlers = this._handlersByName[name];
if (!handlers) { if (!handlers) {
this.onFirstSubscriptionAdded(name); this.onFirstSubscriptionAdded(name);
@ -44,27 +47,27 @@ export class EventEmitter {
handlers.add(callback); handlers.add(callback);
} }
off(name, callback) { off<K extends keyof T>(name: K, callback: Handler<T[K]>): void {
const handlers = this._handlersByName[name]; const handlers = this._handlersByName[name];
if (handlers) { if (handlers) {
handlers.delete(callback); handlers.delete(callback);
if (handlers.length === 0) { if (handlers.size === 0) {
delete this._handlersByName[name]; delete this._handlersByName[name];
this.onLastSubscriptionRemoved(name); this.onLastSubscriptionRemoved(name);
} }
} }
} }
onFirstSubscriptionAdded(/* name */) {} onFirstSubscriptionAdded<K extends keyof T>(name: K): void {}
onLastSubscriptionRemoved(/* name */) {} onLastSubscriptionRemoved<K extends keyof T>(name: K): void {}
} }
export function tests() { export function tests() {
return { return {
test_on_off(assert) { test_on_off(assert) {
let counter = 0; let counter = 0;
const e = new EventEmitter(); const e = new EventEmitter<{ change: never }>();
const callback = () => counter += 1; const callback = () => counter += 1;
e.on("change", callback); e.on("change", callback);
e.emit("change"); e.emit("change");
@ -75,7 +78,7 @@ export function tests() {
test_emit_value(assert) { test_emit_value(assert) {
let value = 0; let value = 0;
const e = new EventEmitter(); const e = new EventEmitter<{ change: number }>();
const callback = (v) => value = v; const callback = (v) => value = v;
e.on("change", callback); e.on("change", callback);
e.emit("change", 5); e.emit("change", 5);
@ -85,7 +88,7 @@ export function tests() {
test_double_on(assert) { test_double_on(assert) {
let counter = 0; let counter = 0;
const e = new EventEmitter(); const e = new EventEmitter<{ change: never }>();
const callback = () => counter += 1; const callback = () => counter += 1;
e.on("change", callback); e.on("change", callback);
e.on("change", callback); e.on("change", callback);