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);