mirror of
https://github.com/vector-im/hydrogen-web.git
synced 2024-12-23 19:45:05 +01:00
Merge branch 'DanilaFe/backfill-changes' into context-api
This commit is contained in:
commit
6d524384e9
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "hydrogen-web",
|
"name": "hydrogen-web",
|
||||||
"version": "0.2.8",
|
"version": "0.2.11",
|
||||||
"description": "A javascript matrix client prototype, trying to minize RAM usage by offloading as much as possible to IndexedDB",
|
"description": "A javascript matrix client prototype, trying to minize RAM usage by offloading as much as possible to IndexedDB",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"directories": {
|
"directories": {
|
||||||
|
@ -150,7 +150,9 @@ export class SessionPickerViewModel extends ViewModel {
|
|||||||
|
|
||||||
async _exportData(id) {
|
async _exportData(id) {
|
||||||
const sessionInfo = await this.platform.sessionInfoStorage.get(id);
|
const sessionInfo = await this.platform.sessionInfoStorage.get(id);
|
||||||
const stores = await this.platform.storageFactory.export(id);
|
const stores = await this.logger.run("export", log => {
|
||||||
|
return this.platform.storageFactory.export(id, log);
|
||||||
|
});
|
||||||
const data = {sessionInfo, stores};
|
const data = {sessionInfo, stores};
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
@ -161,7 +163,9 @@ export class SessionPickerViewModel extends ViewModel {
|
|||||||
const {sessionInfo} = data;
|
const {sessionInfo} = data;
|
||||||
sessionInfo.comment = `Imported on ${new Date().toLocaleString()} from id ${sessionInfo.id}.`;
|
sessionInfo.comment = `Imported on ${new Date().toLocaleString()} from id ${sessionInfo.id}.`;
|
||||||
sessionInfo.id = this._createSessionContainer().createNewSessionId();
|
sessionInfo.id = this._createSessionContainer().createNewSessionId();
|
||||||
await this.platform.storageFactory.import(sessionInfo.id, data.stores);
|
await this.logger.run("import", log => {
|
||||||
|
return this.platform.storageFactory.import(sessionInfo.id, data.stores, log);
|
||||||
|
});
|
||||||
await this.platform.sessionInfoStorage.add(sessionInfo);
|
await this.platform.sessionInfoStorage.add(sessionInfo);
|
||||||
this._sessions.set(new SessionItemViewModel(sessionInfo, this));
|
this._sessions.set(new SessionItemViewModel(sessionInfo, this));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
@ -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 {
|
||||||
|
@ -25,7 +25,7 @@ export class ComposerViewModel extends ViewModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setReplyingTo(entry) {
|
setReplyingTo(entry) {
|
||||||
const changed = this._replyVM?.internalId !== entry?.asEventKey().toString();
|
const changed = new Boolean(entry) !== new Boolean(this._replyVM) || !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) {
|
||||||
|
@ -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";
|
||||||
|
@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -22,7 +22,7 @@ export class EncryptedEventTile extends BaseTextTile {
|
|||||||
const parentResult = super.updateEntry(entry, params);
|
const parentResult = super.updateEntry(entry, params);
|
||||||
// event got decrypted, recreate the tile and replace this one with it
|
// event got decrypted, recreate the tile and replace this one with it
|
||||||
if (entry.eventType !== "m.room.encrypted") {
|
if (entry.eventType !== "m.room.encrypted") {
|
||||||
// the "shape" parameter trigger tile recreation in TimelineList
|
// the "shape" parameter trigger tile recreation in TimelineView
|
||||||
return UpdateAction.Replace("shape");
|
return UpdateAction.Replace("shape");
|
||||||
} else {
|
} else {
|
||||||
return parentResult;
|
return parentResult;
|
||||||
|
@ -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) {
|
||||||
|
@ -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();
|
||||||
|
@ -43,12 +43,16 @@ export class LogItem {
|
|||||||
/** logs a reference to a different log item, usually obtained from runDetached.
|
/** logs a reference to a different log item, usually obtained from runDetached.
|
||||||
This is useful if the referenced operation can't be awaited. */
|
This is useful if the referenced operation can't be awaited. */
|
||||||
refDetached(logItem, logLevel = null) {
|
refDetached(logItem, logLevel = null) {
|
||||||
if (!logItem._values.refId) {
|
logItem.ensureRefId();
|
||||||
logItem.set("refId", this._logger._createRefId());
|
|
||||||
}
|
|
||||||
return this.log({ref: logItem._values.refId}, logLevel);
|
return this.log({ref: logItem._values.refId}, logLevel);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ensureRefId() {
|
||||||
|
if (!this._values.refId) {
|
||||||
|
this.set("refId", this._logger._createRefId());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a new child item and runs it in `callback`.
|
* Creates a new child item and runs it in `callback`.
|
||||||
*/
|
*/
|
||||||
@ -231,4 +235,8 @@ export class LogItem {
|
|||||||
this._children.push(item);
|
this._children.push(item);
|
||||||
return item;
|
return item;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get logger() {
|
||||||
|
return this._logger;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -71,6 +71,8 @@ export class NullLogItem {
|
|||||||
|
|
||||||
refDetached() {}
|
refDetached() {}
|
||||||
|
|
||||||
|
ensureRefId() {}
|
||||||
|
|
||||||
get level() {
|
get level() {
|
||||||
return LogLevel;
|
return LogLevel;
|
||||||
}
|
}
|
||||||
|
@ -296,14 +296,10 @@ export class Sync {
|
|||||||
// avoid corrupting state by only
|
// avoid corrupting state by only
|
||||||
// storing the sync up till the point
|
// storing the sync up till the point
|
||||||
// the exception occurred
|
// the exception occurred
|
||||||
try {
|
syncTxn.abort(log);
|
||||||
syncTxn.abort();
|
throw syncTxn.getCause(err);
|
||||||
} catch (abortErr) {
|
|
||||||
log.set("couldNotAbortTxn", true);
|
|
||||||
}
|
|
||||||
throw err;
|
|
||||||
}
|
}
|
||||||
await syncTxn.complete();
|
await syncTxn.complete(log);
|
||||||
}
|
}
|
||||||
|
|
||||||
_afterSync(sessionState, inviteStates, roomStates, archivedRoomStates, log) {
|
_afterSync(sessionState, inviteStates, roomStates, archivedRoomStates, log) {
|
||||||
|
@ -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";
|
||||||
|
@ -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";
|
||||||
|
@ -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);
|
||||||
|
@ -163,7 +163,7 @@ export class SyncWriter {
|
|||||||
storageEntry.displayName = member.displayName;
|
storageEntry.displayName = member.displayName;
|
||||||
storageEntry.avatarUrl = member.avatarUrl;
|
storageEntry.avatarUrl = member.avatarUrl;
|
||||||
}
|
}
|
||||||
txn.timelineEvents.insert(storageEntry);
|
txn.timelineEvents.insert(storageEntry, log);
|
||||||
const entry = new EventEntry(storageEntry, this._fragmentIdComparer);
|
const entry = new EventEntry(storageEntry, this._fragmentIdComparer);
|
||||||
entries.push(entry);
|
entries.push(entry);
|
||||||
const updatedRelationTargetEntries = await this._relationWriter.writeRelation(entry, txn, log);
|
const updatedRelationTargetEntries = await this._relationWriter.writeRelation(entry, txn, log);
|
||||||
|
@ -17,22 +17,26 @@ limitations under the License.
|
|||||||
import {Transaction} from "./Transaction";
|
import {Transaction} from "./Transaction";
|
||||||
import { STORE_NAMES, StoreNames, StorageError } from "../common";
|
import { STORE_NAMES, StoreNames, StorageError } from "../common";
|
||||||
import { reqAsPromise } from "./utils";
|
import { reqAsPromise } from "./utils";
|
||||||
|
import { BaseLogger } from "../../../logging/BaseLogger.js";
|
||||||
|
|
||||||
const WEBKITEARLYCLOSETXNBUG_BOGUS_KEY = "782rh281re38-boguskey";
|
const WEBKITEARLYCLOSETXNBUG_BOGUS_KEY = "782rh281re38-boguskey";
|
||||||
|
|
||||||
export class Storage {
|
export class Storage {
|
||||||
private _db: IDBDatabase;
|
private _db: IDBDatabase;
|
||||||
private _hasWebkitEarlyCloseTxnBug: boolean;
|
private _hasWebkitEarlyCloseTxnBug: boolean;
|
||||||
private _idbFactory: IDBFactory
|
|
||||||
private _IDBKeyRange: typeof IDBKeyRange
|
|
||||||
storeNames: typeof StoreNames;
|
|
||||||
|
|
||||||
constructor(idbDatabase: IDBDatabase, idbFactory: IDBFactory, _IDBKeyRange: typeof IDBKeyRange, hasWebkitEarlyCloseTxnBug: boolean) {
|
readonly logger: BaseLogger;
|
||||||
|
readonly idbFactory: IDBFactory
|
||||||
|
readonly IDBKeyRange: typeof IDBKeyRange;
|
||||||
|
readonly storeNames: typeof StoreNames;
|
||||||
|
|
||||||
|
constructor(idbDatabase: IDBDatabase, idbFactory: IDBFactory, _IDBKeyRange: typeof IDBKeyRange, hasWebkitEarlyCloseTxnBug: boolean, logger: BaseLogger) {
|
||||||
this._db = idbDatabase;
|
this._db = idbDatabase;
|
||||||
this._idbFactory = idbFactory;
|
this.idbFactory = idbFactory;
|
||||||
this._IDBKeyRange = _IDBKeyRange;
|
this.IDBKeyRange = _IDBKeyRange;
|
||||||
this._hasWebkitEarlyCloseTxnBug = hasWebkitEarlyCloseTxnBug;
|
this._hasWebkitEarlyCloseTxnBug = hasWebkitEarlyCloseTxnBug;
|
||||||
this.storeNames = StoreNames;
|
this.storeNames = StoreNames;
|
||||||
|
this.logger = logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
_validateStoreNames(storeNames: StoreNames[]): void {
|
_validateStoreNames(storeNames: StoreNames[]): void {
|
||||||
@ -51,7 +55,7 @@ export class Storage {
|
|||||||
if (this._hasWebkitEarlyCloseTxnBug) {
|
if (this._hasWebkitEarlyCloseTxnBug) {
|
||||||
await reqAsPromise(txn.objectStore(storeNames[0]).get(WEBKITEARLYCLOSETXNBUG_BOGUS_KEY));
|
await reqAsPromise(txn.objectStore(storeNames[0]).get(WEBKITEARLYCLOSETXNBUG_BOGUS_KEY));
|
||||||
}
|
}
|
||||||
return new Transaction(txn, storeNames, this._idbFactory, this._IDBKeyRange);
|
return new Transaction(txn, storeNames, this);
|
||||||
} catch(err) {
|
} catch(err) {
|
||||||
throw new StorageError("readTxn failed", err);
|
throw new StorageError("readTxn failed", err);
|
||||||
}
|
}
|
||||||
@ -66,7 +70,7 @@ export class Storage {
|
|||||||
if (this._hasWebkitEarlyCloseTxnBug) {
|
if (this._hasWebkitEarlyCloseTxnBug) {
|
||||||
await reqAsPromise(txn.objectStore(storeNames[0]).get(WEBKITEARLYCLOSETXNBUG_BOGUS_KEY));
|
await reqAsPromise(txn.objectStore(storeNames[0]).get(WEBKITEARLYCLOSETXNBUG_BOGUS_KEY));
|
||||||
}
|
}
|
||||||
return new Transaction(txn, storeNames, this._idbFactory, this._IDBKeyRange);
|
return new Transaction(txn, storeNames, this);
|
||||||
} catch(err) {
|
} catch(err) {
|
||||||
throw new StorageError("readWriteTxn failed", err);
|
throw new StorageError("readWriteTxn failed", err);
|
||||||
}
|
}
|
||||||
|
@ -20,9 +20,10 @@ import { exportSession, importSession, Export } from "./export";
|
|||||||
import { schema } from "./schema";
|
import { schema } from "./schema";
|
||||||
import { detectWebkitEarlyCloseTxnBug } from "./quirks";
|
import { detectWebkitEarlyCloseTxnBug } from "./quirks";
|
||||||
import { BaseLogger } from "../../../logging/BaseLogger.js";
|
import { BaseLogger } from "../../../logging/BaseLogger.js";
|
||||||
|
import { LogItem } from "../../../logging/LogItem.js";
|
||||||
|
|
||||||
const sessionName = (sessionId: string) => `hydrogen_session_${sessionId}`;
|
const sessionName = (sessionId: string) => `hydrogen_session_${sessionId}`;
|
||||||
const openDatabaseWithSessionId = function(sessionId: string, idbFactory: IDBFactory, log?: BaseLogger) {
|
const openDatabaseWithSessionId = function(sessionId: string, idbFactory: IDBFactory, log: LogItem) {
|
||||||
const create = (db, txn, oldVersion, version) => createStores(db, txn, oldVersion, version, log);
|
const create = (db, txn, oldVersion, version) => createStores(db, txn, oldVersion, version, log);
|
||||||
return openDatabase(sessionName(sessionId), create, schema.length, idbFactory);
|
return openDatabase(sessionName(sessionId), create, schema.length, idbFactory);
|
||||||
}
|
}
|
||||||
@ -59,7 +60,7 @@ export class StorageFactory {
|
|||||||
this._IDBKeyRange = _IDBKeyRange;
|
this._IDBKeyRange = _IDBKeyRange;
|
||||||
}
|
}
|
||||||
|
|
||||||
async create(sessionId: string, log?: BaseLogger): Promise<Storage> {
|
async create(sessionId: string, log: LogItem): Promise<Storage> {
|
||||||
await this._serviceWorkerHandler?.preventConcurrentSessionAccess(sessionId);
|
await this._serviceWorkerHandler?.preventConcurrentSessionAccess(sessionId);
|
||||||
requestPersistedStorage().then(persisted => {
|
requestPersistedStorage().then(persisted => {
|
||||||
// Firefox lies here though, and returns true even if the user denied the request
|
// Firefox lies here though, and returns true even if the user denied the request
|
||||||
@ -70,7 +71,7 @@ export class StorageFactory {
|
|||||||
|
|
||||||
const hasWebkitEarlyCloseTxnBug = await detectWebkitEarlyCloseTxnBug(this._idbFactory);
|
const hasWebkitEarlyCloseTxnBug = await detectWebkitEarlyCloseTxnBug(this._idbFactory);
|
||||||
const db = await openDatabaseWithSessionId(sessionId, this._idbFactory, log);
|
const db = await openDatabaseWithSessionId(sessionId, this._idbFactory, log);
|
||||||
return new Storage(db, this._idbFactory, this._IDBKeyRange, hasWebkitEarlyCloseTxnBug);
|
return new Storage(db, this._idbFactory, this._IDBKeyRange, hasWebkitEarlyCloseTxnBug, log.logger);
|
||||||
}
|
}
|
||||||
|
|
||||||
delete(sessionId: string): Promise<IDBDatabase> {
|
delete(sessionId: string): Promise<IDBDatabase> {
|
||||||
@ -79,18 +80,18 @@ export class StorageFactory {
|
|||||||
return reqAsPromise(req);
|
return reqAsPromise(req);
|
||||||
}
|
}
|
||||||
|
|
||||||
async export(sessionId: string): Promise<Export> {
|
async export(sessionId: string, log: LogItem): Promise<Export> {
|
||||||
const db = await openDatabaseWithSessionId(sessionId, this._idbFactory);
|
const db = await openDatabaseWithSessionId(sessionId, this._idbFactory, log);
|
||||||
return await exportSession(db);
|
return await exportSession(db);
|
||||||
}
|
}
|
||||||
|
|
||||||
async import(sessionId: string, data: Export): Promise<void> {
|
async import(sessionId: string, data: Export, log: LogItem): Promise<void> {
|
||||||
const db = await openDatabaseWithSessionId(sessionId, this._idbFactory);
|
const db = await openDatabaseWithSessionId(sessionId, this._idbFactory, log);
|
||||||
return await importSession(db, data);
|
return await importSession(db, data);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function createStores(db: IDBDatabase, txn: IDBTransaction, oldVersion: number | null, version: number, log?: BaseLogger): Promise<void> {
|
async function createStores(db: IDBDatabase, txn: IDBTransaction, oldVersion: number | null, version: number, log: LogItem): Promise<void> {
|
||||||
const startIdx = oldVersion || 0;
|
const startIdx = oldVersion || 0;
|
||||||
return log.wrap({l: "storage migration", oldVersion, version}, async log => {
|
return log.wrap({l: "storage migration", oldVersion, version}, async log => {
|
||||||
for(let i = startIdx; i < version; ++i) {
|
for(let i = startIdx; i < version; ++i) {
|
||||||
|
@ -18,6 +18,7 @@ import {QueryTarget, IDBQuery} from "./QueryTarget";
|
|||||||
import {IDBRequestAttemptError} from "./error";
|
import {IDBRequestAttemptError} from "./error";
|
||||||
import {reqAsPromise} from "./utils";
|
import {reqAsPromise} from "./utils";
|
||||||
import {Transaction} from "./Transaction";
|
import {Transaction} from "./Transaction";
|
||||||
|
import {LogItem} from "../../../logging/LogItem.js";
|
||||||
|
|
||||||
const LOG_REQUESTS = false;
|
const LOG_REQUESTS = false;
|
||||||
|
|
||||||
@ -140,7 +141,7 @@ export class Store<T> extends QueryTarget<T> {
|
|||||||
return new QueryTarget<T>(new QueryTargetWrapper<T>(this._idbStore.index(indexName)), this._transaction);
|
return new QueryTarget<T>(new QueryTargetWrapper<T>(this._idbStore.index(indexName)), this._transaction);
|
||||||
}
|
}
|
||||||
|
|
||||||
put(value: T): void {
|
put(value: T, log?: LogItem): void {
|
||||||
// If this request fails, the error will bubble up to the transaction and abort it,
|
// If this request fails, the error will bubble up to the transaction and abort it,
|
||||||
// which is the behaviour we want. Therefore, it is ok to not create a promise for this
|
// which is the behaviour we want. Therefore, it is ok to not create a promise for this
|
||||||
// request and await it.
|
// request and await it.
|
||||||
@ -151,16 +152,52 @@ export class Store<T> extends QueryTarget<T> {
|
|||||||
//
|
//
|
||||||
// Note that this can still throw synchronously, like it does for TransactionInactiveError,
|
// Note that this can still throw synchronously, like it does for TransactionInactiveError,
|
||||||
// see https://www.w3.org/TR/IndexedDB-2/#transaction-lifetime-concept
|
// see https://www.w3.org/TR/IndexedDB-2/#transaction-lifetime-concept
|
||||||
this._idbStore.put(value);
|
const request = this._idbStore.put(value);
|
||||||
|
this._prepareErrorLog(request, log, "put", undefined, value);
|
||||||
}
|
}
|
||||||
|
|
||||||
add(value: T): void {
|
add(value: T, log?: LogItem): void {
|
||||||
// ok to not monitor result of request, see comment in `put`.
|
// ok to not monitor result of request, see comment in `put`.
|
||||||
this._idbStore.add(value);
|
const request = this._idbStore.add(value);
|
||||||
|
this._prepareErrorLog(request, log, "add", undefined, value);
|
||||||
}
|
}
|
||||||
|
|
||||||
delete(keyOrKeyRange: IDBValidKey | IDBKeyRange): Promise<undefined> {
|
delete(keyOrKeyRange: IDBValidKey | IDBKeyRange, log?: LogItem): void {
|
||||||
// ok to not monitor result of request, see comment in `put`.
|
// ok to not monitor result of request, see comment in `put`.
|
||||||
return reqAsPromise(this._idbStore.delete(keyOrKeyRange));
|
const request = this._idbStore.delete(keyOrKeyRange);
|
||||||
|
this._prepareErrorLog(request, log, "delete", keyOrKeyRange, undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
private _prepareErrorLog(request: IDBRequest, log: LogItem | undefined, operationName: string, key: IDBValidKey | IDBKeyRange | undefined, value: T | undefined) {
|
||||||
|
if (log) {
|
||||||
|
log.ensureRefId();
|
||||||
|
}
|
||||||
|
reqAsPromise(request).catch(err => {
|
||||||
|
try {
|
||||||
|
if (!key && value) {
|
||||||
|
key = this._getKey(value);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
key = "getKey failed";
|
||||||
|
}
|
||||||
|
this._transaction.addWriteError(err, log, operationName, key);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private _getKey(value: T): IDBValidKey {
|
||||||
|
const {keyPath} = this._idbStore;
|
||||||
|
if (Array.isArray(keyPath)) {
|
||||||
|
let field: any = value;
|
||||||
|
for (const part of keyPath) {
|
||||||
|
if (typeof field === "object") {
|
||||||
|
field = field[part];
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return field as IDBValidKey;
|
||||||
|
} else {
|
||||||
|
return value[keyPath] as IDBValidKey;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -18,6 +18,7 @@ import {StoreNames} from "../common";
|
|||||||
import {txnAsPromise} from "./utils";
|
import {txnAsPromise} from "./utils";
|
||||||
import {StorageError} from "../common";
|
import {StorageError} from "../common";
|
||||||
import {Store} from "./Store";
|
import {Store} from "./Store";
|
||||||
|
import {Storage} from "./Storage";
|
||||||
import {SessionStore} from "./stores/SessionStore";
|
import {SessionStore} from "./stores/SessionStore";
|
||||||
import {RoomSummaryStore} from "./stores/RoomSummaryStore";
|
import {RoomSummaryStore} from "./stores/RoomSummaryStore";
|
||||||
import {InviteStore} from "./stores/InviteStore";
|
import {InviteStore} from "./stores/InviteStore";
|
||||||
@ -35,20 +36,46 @@ import {OutboundGroupSessionStore} from "./stores/OutboundGroupSessionStore";
|
|||||||
import {GroupSessionDecryptionStore} from "./stores/GroupSessionDecryptionStore";
|
import {GroupSessionDecryptionStore} from "./stores/GroupSessionDecryptionStore";
|
||||||
import {OperationStore} from "./stores/OperationStore";
|
import {OperationStore} from "./stores/OperationStore";
|
||||||
import {AccountDataStore} from "./stores/AccountDataStore";
|
import {AccountDataStore} from "./stores/AccountDataStore";
|
||||||
|
import {LogItem} from "../../../logging/LogItem.js";
|
||||||
|
import {BaseLogger} from "../../../logging/BaseLogger.js";
|
||||||
|
|
||||||
|
class WriteErrorInfo {
|
||||||
|
constructor(
|
||||||
|
public readonly error: StorageError,
|
||||||
|
public readonly refItem: LogItem | undefined,
|
||||||
|
public readonly operationName: string,
|
||||||
|
public readonly key: IDBValidKey | IDBKeyRange | undefined,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
|
|
||||||
export class Transaction {
|
export class Transaction {
|
||||||
private _txn: IDBTransaction;
|
private _txn: IDBTransaction;
|
||||||
private _allowedStoreNames: StoreNames[];
|
private _allowedStoreNames: StoreNames[];
|
||||||
private _stores: { [storeName in StoreNames]?: any };
|
private _stores: { [storeName in StoreNames]?: any };
|
||||||
idbFactory: IDBFactory
|
private _storage: Storage;
|
||||||
IDBKeyRange: typeof IDBKeyRange
|
private _writeErrors: WriteErrorInfo[];
|
||||||
|
|
||||||
constructor(txn: IDBTransaction, allowedStoreNames: StoreNames[], _idbFactory: IDBFactory, _IDBKeyRange: typeof IDBKeyRange) {
|
constructor(txn: IDBTransaction, allowedStoreNames: StoreNames[], storage: Storage) {
|
||||||
this._txn = txn;
|
this._txn = txn;
|
||||||
this._allowedStoreNames = allowedStoreNames;
|
this._allowedStoreNames = allowedStoreNames;
|
||||||
this._stores = {};
|
this._stores = {};
|
||||||
this.idbFactory = _idbFactory;
|
this._txn = txn;
|
||||||
this.IDBKeyRange = _IDBKeyRange;
|
this._allowedStoreNames = allowedStoreNames;
|
||||||
|
this._stores = {};
|
||||||
|
this._storage = storage;
|
||||||
|
this._writeErrors = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
get idbFactory(): IDBFactory {
|
||||||
|
return this._storage.idbFactory;
|
||||||
|
}
|
||||||
|
|
||||||
|
get IDBKeyRange(): typeof IDBKeyRange {
|
||||||
|
return this._storage.IDBKeyRange;
|
||||||
|
}
|
||||||
|
|
||||||
|
get logger(): BaseLogger {
|
||||||
|
return this._storage.logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
_idbStore(name: StoreNames): Store<any> {
|
_idbStore(name: StoreNames): Store<any> {
|
||||||
@ -139,12 +166,66 @@ export class Transaction {
|
|||||||
return this._store(StoreNames.accountData, idbStore => new AccountDataStore(idbStore));
|
return this._store(StoreNames.accountData, idbStore => new AccountDataStore(idbStore));
|
||||||
}
|
}
|
||||||
|
|
||||||
complete(): Promise<void> {
|
async complete(log?: LogItem): Promise<void> {
|
||||||
return txnAsPromise(this._txn);
|
try {
|
||||||
|
await txnAsPromise(this._txn);
|
||||||
|
} catch (err) {
|
||||||
|
if (this._writeErrors.length) {
|
||||||
|
this._logWriteErrors(log);
|
||||||
|
throw this._writeErrors[0].error;
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
abort(): void {
|
getCause(error: Error) {
|
||||||
|
if (error instanceof StorageError) {
|
||||||
|
if (error.errcode === "AbortError" && this._writeErrors.length) {
|
||||||
|
return this._writeErrors[0].error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return error;
|
||||||
|
}
|
||||||
|
|
||||||
|
abort(log?: LogItem): void {
|
||||||
// TODO: should we wrap the exception in a StorageError?
|
// TODO: should we wrap the exception in a StorageError?
|
||||||
this._txn.abort();
|
try {
|
||||||
|
this._txn.abort();
|
||||||
|
} catch (abortErr) {
|
||||||
|
log?.set("couldNotAbortTxn", true);
|
||||||
|
}
|
||||||
|
if (this._writeErrors.length) {
|
||||||
|
this._logWriteErrors(log);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
addWriteError(error: StorageError, refItem: LogItem | undefined, operationName: string, key: IDBValidKey | IDBKeyRange | undefined) {
|
||||||
|
// don't log subsequent `AbortError`s
|
||||||
|
if (error.errcode !== "AbortError" || this._writeErrors.length === 0) {
|
||||||
|
this._writeErrors.push(new WriteErrorInfo(error, refItem, operationName, key));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private _logWriteErrors(parentItem: LogItem | undefined) {
|
||||||
|
const callback = errorGroupItem => {
|
||||||
|
// we don't have context when there is no parentItem, so at least log stores
|
||||||
|
if (!parentItem) {
|
||||||
|
errorGroupItem.set("allowedStoreNames", this._allowedStoreNames);
|
||||||
|
}
|
||||||
|
for (const info of this._writeErrors) {
|
||||||
|
errorGroupItem.wrap({l: info.operationName, id: info.key}, item => {
|
||||||
|
if (info.refItem) {
|
||||||
|
item.refDetached(info.refItem);
|
||||||
|
}
|
||||||
|
item.catch(info.error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const label = `${this._writeErrors.length} storage write operation(s) failed`;
|
||||||
|
if (parentItem) {
|
||||||
|
parentItem.wrap(label, callback);
|
||||||
|
} else {
|
||||||
|
this.logger.run(label, callback);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -78,14 +78,14 @@ export class DeviceIdentityStore {
|
|||||||
return this._store.index("byCurve25519Key").get(curve25519Key);
|
return this._store.index("byCurve25519Key").get(curve25519Key);
|
||||||
}
|
}
|
||||||
|
|
||||||
remove(userId: string, deviceId: string): Promise<undefined> {
|
remove(userId: string, deviceId: string): void {
|
||||||
return this._store.delete(encodeKey(userId, deviceId));
|
this._store.delete(encodeKey(userId, deviceId));
|
||||||
}
|
}
|
||||||
|
|
||||||
removeAllForUser(userId: string): Promise<undefined> {
|
removeAllForUser(userId: string): void {
|
||||||
// exclude both keys as they are theoretical min and max,
|
// exclude both keys as they are theoretical min and max,
|
||||||
// but we should't have a match for just the room id, or room id with max
|
// but we should't have a match for just the room id, or room id with max
|
||||||
const range = this._store.IDBKeyRange.bound(encodeKey(userId, MIN_UNICODE), encodeKey(userId, MAX_UNICODE), true, true);
|
const range = this._store.IDBKeyRange.bound(encodeKey(userId, MIN_UNICODE), encodeKey(userId, MAX_UNICODE), true, true);
|
||||||
return this._store.delete(range);
|
this._store.delete(range);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -44,11 +44,11 @@ export class GroupSessionDecryptionStore {
|
|||||||
this._store.put(decryption as GroupSessionEntry);
|
this._store.put(decryption as GroupSessionEntry);
|
||||||
}
|
}
|
||||||
|
|
||||||
removeAllForRoom(roomId: string): Promise<undefined> {
|
removeAllForRoom(roomId: string): void {
|
||||||
const range = this._store.IDBKeyRange.bound(
|
const range = this._store.IDBKeyRange.bound(
|
||||||
encodeKey(roomId, MIN_UNICODE, MIN_UNICODE),
|
encodeKey(roomId, MIN_UNICODE, MIN_UNICODE),
|
||||||
encodeKey(roomId, MAX_UNICODE, MAX_UNICODE)
|
encodeKey(roomId, MAX_UNICODE, MAX_UNICODE)
|
||||||
);
|
);
|
||||||
return this._store.delete(range);
|
this._store.delete(range);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -53,11 +53,11 @@ export class InboundGroupSessionStore {
|
|||||||
this._store.put(session);
|
this._store.put(session);
|
||||||
}
|
}
|
||||||
|
|
||||||
removeAllForRoom(roomId: string): Promise<undefined> {
|
removeAllForRoom(roomId: string) {
|
||||||
const range = this._store.IDBKeyRange.bound(
|
const range = this._store.IDBKeyRange.bound(
|
||||||
encodeKey(roomId, MIN_UNICODE, MIN_UNICODE),
|
encodeKey(roomId, MIN_UNICODE, MIN_UNICODE),
|
||||||
encodeKey(roomId, MAX_UNICODE, MAX_UNICODE)
|
encodeKey(roomId, MAX_UNICODE, MAX_UNICODE)
|
||||||
);
|
);
|
||||||
return this._store.delete(range);
|
this._store.delete(range);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -71,7 +71,7 @@ export class OlmSessionStore {
|
|||||||
this._store.put(session as OlmSessionEntry);
|
this._store.put(session as OlmSessionEntry);
|
||||||
}
|
}
|
||||||
|
|
||||||
remove(senderKey: string, sessionId: string): Promise<undefined> {
|
remove(senderKey: string, sessionId: string): void {
|
||||||
return this._store.delete(encodeKey(senderKey, sessionId));
|
this._store.delete(encodeKey(senderKey, sessionId));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -73,8 +73,8 @@ export class OperationStore {
|
|||||||
this._store.put(operation as OperationEntry);
|
this._store.put(operation as OperationEntry);
|
||||||
}
|
}
|
||||||
|
|
||||||
remove(id: string): Promise<undefined> {
|
remove(id: string): void {
|
||||||
return this._store.delete(id);
|
this._store.delete(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
async removeAllForScope(scope: string): Promise<undefined> {
|
async removeAllForScope(scope: string): Promise<undefined> {
|
||||||
|
@ -28,8 +28,8 @@ export class OutboundGroupSessionStore {
|
|||||||
this._store = store;
|
this._store = store;
|
||||||
}
|
}
|
||||||
|
|
||||||
remove(roomId: string): Promise<undefined> {
|
remove(roomId: string): void {
|
||||||
return this._store.delete(roomId);
|
this._store.delete(roomId);
|
||||||
}
|
}
|
||||||
|
|
||||||
get(roomId: string): Promise<OutboundSession | null> {
|
get(roomId: string): Promise<OutboundSession | null> {
|
||||||
|
@ -62,9 +62,9 @@ export class PendingEventStore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
remove(roomId: string, queueIndex: number): Promise<undefined> {
|
remove(roomId: string, queueIndex: number) {
|
||||||
const keyRange = this._eventStore.IDBKeyRange.only(encodeKey(roomId, queueIndex));
|
const keyRange = this._eventStore.IDBKeyRange.only(encodeKey(roomId, queueIndex));
|
||||||
return this._eventStore.delete(keyRange);
|
this._eventStore.delete(keyRange);
|
||||||
}
|
}
|
||||||
|
|
||||||
async exists(roomId: string, queueIndex: number): Promise<boolean> {
|
async exists(roomId: string, queueIndex: number): Promise<boolean> {
|
||||||
@ -86,10 +86,10 @@ export class PendingEventStore {
|
|||||||
return this._eventStore.selectAll();
|
return this._eventStore.selectAll();
|
||||||
}
|
}
|
||||||
|
|
||||||
removeAllForRoom(roomId: string): Promise<undefined> {
|
removeAllForRoom(roomId: string): void {
|
||||||
const minKey = encodeKey(roomId, KeyLimits.minStorageKey);
|
const minKey = encodeKey(roomId, KeyLimits.minStorageKey);
|
||||||
const maxKey = encodeKey(roomId, KeyLimits.maxStorageKey);
|
const maxKey = encodeKey(roomId, KeyLimits.maxStorageKey);
|
||||||
const range = this._eventStore.IDBKeyRange.bound(minKey, maxKey);
|
const range = this._eventStore.IDBKeyRange.bound(minKey, maxKey);
|
||||||
return this._eventStore.delete(range);
|
this._eventStore.delete(range);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -47,10 +47,10 @@ export class RoomStateStore {
|
|||||||
this._roomStateStore.put(entry);
|
this._roomStateStore.put(entry);
|
||||||
}
|
}
|
||||||
|
|
||||||
removeAllForRoom(roomId: string): Promise<undefined> {
|
removeAllForRoom(roomId: string): void {
|
||||||
// exclude both keys as they are theoretical min and max,
|
// exclude both keys as they are theoretical min and max,
|
||||||
// but we should't have a match for just the room id, or room id with max
|
// but we should't have a match for just the room id, or room id with max
|
||||||
const range = this._roomStateStore.IDBKeyRange.bound(roomId, `${roomId}|${MAX_UNICODE}`, true, true);
|
const range = this._roomStateStore.IDBKeyRange.bound(roomId, `${roomId}|${MAX_UNICODE}`, true, true);
|
||||||
return this._roomStateStore.delete(range);
|
this._roomStateStore.delete(range);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -55,7 +55,7 @@ export class RoomSummaryStore {
|
|||||||
return roomId === fetchedKey;
|
return roomId === fetchedKey;
|
||||||
}
|
}
|
||||||
|
|
||||||
remove(roomId: string): Promise<undefined> {
|
remove(roomId: string): void {
|
||||||
return this._summaryStore.delete(roomId);
|
this._summaryStore.delete(roomId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -20,6 +20,7 @@ import { encodeUint32 } from "../utils";
|
|||||||
import {KeyLimits} from "../../common";
|
import {KeyLimits} from "../../common";
|
||||||
import {Store} from "../Store";
|
import {Store} from "../Store";
|
||||||
import {TimelineEvent, StateEvent} from "../../types";
|
import {TimelineEvent, StateEvent} from "../../types";
|
||||||
|
import {LogItem} from "../../../../logging/LogItem.js";
|
||||||
|
|
||||||
interface Annotation {
|
interface Annotation {
|
||||||
count: number;
|
count: number;
|
||||||
@ -265,11 +266,10 @@ export class TimelineEventStore {
|
|||||||
* @return nothing. To wait for the operation to finish, await the transaction it's part of.
|
* @return nothing. To wait for the operation to finish, await the transaction it's part of.
|
||||||
* @throws {StorageError} ...
|
* @throws {StorageError} ...
|
||||||
*/
|
*/
|
||||||
insert(entry: TimelineEventEntry): void {
|
insert(entry: TimelineEventEntry, log: LogItem): void {
|
||||||
(entry as TimelineEventStorageEntry).key = encodeKey(entry.roomId, entry.fragmentId, entry.eventIndex);
|
(entry as TimelineEventStorageEntry).key = encodeKey(entry.roomId, entry.fragmentId, entry.eventIndex);
|
||||||
(entry as TimelineEventStorageEntry).eventIdKey = encodeEventIdKey(entry.roomId, entry.event.event_id);
|
(entry as TimelineEventStorageEntry).eventIdKey = encodeEventIdKey(entry.roomId, entry.event.event_id);
|
||||||
// TODO: map error? or in idb/store?
|
this._timelineStore.add(entry as TimelineEventStorageEntry, log);
|
||||||
this._timelineStore.add(entry as TimelineEventStorageEntry);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Updates the entry into the store with the given [roomId, eventKey] combination.
|
/** Updates the entry into the store with the given [roomId, eventKey] combination.
|
||||||
|
@ -100,7 +100,7 @@ export class TimelineFragmentStore {
|
|||||||
return this._store.get(encodeKey(roomId, fragmentId));
|
return this._store.get(encodeKey(roomId, fragmentId));
|
||||||
}
|
}
|
||||||
|
|
||||||
removeAllForRoom(roomId: string): Promise<undefined> {
|
removeAllForRoom(roomId: string): void {
|
||||||
return this._store.delete(this._allRange(roomId));
|
this._store.delete(this._allRange(roomId));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -43,18 +43,18 @@ export class TimelineRelationStore {
|
|||||||
this._store.add({key: encodeKey(roomId, targetEventId, relType, sourceEventId)});
|
this._store.add({key: encodeKey(roomId, targetEventId, relType, sourceEventId)});
|
||||||
}
|
}
|
||||||
|
|
||||||
remove(roomId: string, targetEventId: string, relType: string, sourceEventId: string): Promise<undefined> {
|
remove(roomId: string, targetEventId: string, relType: string, sourceEventId: string): void {
|
||||||
return this._store.delete(encodeKey(roomId, targetEventId, relType, sourceEventId));
|
this._store.delete(encodeKey(roomId, targetEventId, relType, sourceEventId));
|
||||||
}
|
}
|
||||||
|
|
||||||
removeAllForTarget(roomId: string, targetId: string): Promise<undefined> {
|
removeAllForTarget(roomId: string, targetId: string): void {
|
||||||
const range = this._store.IDBKeyRange.bound(
|
const range = this._store.IDBKeyRange.bound(
|
||||||
encodeKey(roomId, targetId, MIN_UNICODE, MIN_UNICODE),
|
encodeKey(roomId, targetId, MIN_UNICODE, MIN_UNICODE),
|
||||||
encodeKey(roomId, targetId, MAX_UNICODE, MAX_UNICODE),
|
encodeKey(roomId, targetId, MAX_UNICODE, MAX_UNICODE),
|
||||||
true,
|
true,
|
||||||
true
|
true
|
||||||
);
|
);
|
||||||
return this._store.delete(range);
|
this._store.delete(range);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getForTargetAndType(roomId: string, targetId: string, relType: string): Promise<RelationEntry[]> {
|
async getForTargetAndType(roomId: string, targetId: string, relType: string): Promise<RelationEntry[]> {
|
||||||
|
@ -36,7 +36,7 @@ export class UserIdentityStore {
|
|||||||
this._store.put(userIdentity);
|
this._store.put(userIdentity);
|
||||||
}
|
}
|
||||||
|
|
||||||
remove(userId: string): Promise<undefined> {
|
remove(userId: string): void {
|
||||||
return this._store.delete(userId);
|
this._store.delete(userId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -17,6 +17,7 @@ limitations under the License.
|
|||||||
|
|
||||||
import { IDBRequestError } from "./error";
|
import { IDBRequestError } from "./error";
|
||||||
import { StorageError } from "../common";
|
import { StorageError } from "../common";
|
||||||
|
import { AbortError } from "../../../utils/error.js";
|
||||||
|
|
||||||
let needsSyncPromise = false;
|
let needsSyncPromise = false;
|
||||||
|
|
||||||
@ -112,22 +113,8 @@ export function txnAsPromise(txn): Promise<void> {
|
|||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
needsSyncPromise && Promise._flush && Promise._flush();
|
needsSyncPromise && Promise._flush && Promise._flush();
|
||||||
});
|
});
|
||||||
txn.addEventListener("error", event => {
|
|
||||||
const request = event.target;
|
|
||||||
// catch first error here, but don't reject yet,
|
|
||||||
// as we don't have access to the failed request in the abort event handler
|
|
||||||
if (!error && request) {
|
|
||||||
error = new IDBRequestError(request);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
txn.addEventListener("abort", event => {
|
txn.addEventListener("abort", event => {
|
||||||
if (!error) {
|
reject(new AbortError());
|
||||||
const txn = event.target;
|
|
||||||
const dbName = txn.db.name;
|
|
||||||
const storeNames = Array.from(txn.objectStoreNames).join(", ")
|
|
||||||
error = new StorageError(`Transaction on ${dbName} with stores ${storeNames} was aborted.`);
|
|
||||||
}
|
|
||||||
reject(error);
|
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
needsSyncPromise && Promise._flush && Promise._flush();
|
needsSyncPromise && Promise._flush && Promise._flush();
|
||||||
});
|
});
|
||||||
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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 {
|
||||||
|
@ -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
|
||||||
|
@ -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.0 KiB |
@ -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;
|
||||||
|
}
|
||||||
|
@ -14,13 +14,36 @@ 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;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.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 +72,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) {
|
||||||
|
@ -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);
|
@ -14,9 +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} from "./utils.js";
|
import {mountView} from "./utils";
|
||||||
import {insertAt, ListView} from "./ListView.js";
|
import {ListView} from "./ListView";
|
||||||
|
import {insertAt} from "./utils";
|
||||||
|
|
||||||
class ItemRange {
|
class ItemRange {
|
||||||
constructor(topCount, renderCount, bottomCount) {
|
constructor(topCount, renderCount, bottomCount) {
|
||||||
|
@ -1,163 +0,0 @@
|
|||||||
/*
|
|
||||||
Copyright 2020 Bruno Windels <bruno@windels.cloud>
|
|
||||||
|
|
||||||
Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
you may not use this file except in compliance with the License.
|
|
||||||
You may obtain a copy of the License at
|
|
||||||
|
|
||||||
http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
|
|
||||||
Unless required by applicable law or agreed to in writing, software
|
|
||||||
distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
See the License for the specific language governing permissions and
|
|
||||||
limitations under the License.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import {el} from "./html.js";
|
|
||||||
import {mountView} from "./utils.js";
|
|
||||||
|
|
||||||
export function insertAt(parentNode, idx, childNode) {
|
|
||||||
const isLast = idx === parentNode.childElementCount;
|
|
||||||
if (isLast) {
|
|
||||||
parentNode.appendChild(childNode);
|
|
||||||
} else {
|
|
||||||
const nextDomNode = parentNode.children[idx];
|
|
||||||
parentNode.insertBefore(childNode, nextDomNode);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export class ListView {
|
|
||||||
constructor({list, onItemClick, className, tagName = "ul", parentProvidesUpdates = true}, childCreator) {
|
|
||||||
this._onItemClick = onItemClick;
|
|
||||||
this._list = list;
|
|
||||||
this._className = className;
|
|
||||||
this._tagName = tagName;
|
|
||||||
this._root = null;
|
|
||||||
this._subscription = null;
|
|
||||||
this._childCreator = childCreator;
|
|
||||||
this._childInstances = null;
|
|
||||||
this._mountArgs = {parentProvidesUpdates};
|
|
||||||
this._onClick = this._onClick.bind(this);
|
|
||||||
}
|
|
||||||
|
|
||||||
root() {
|
|
||||||
return this._root;
|
|
||||||
}
|
|
||||||
|
|
||||||
update(attributes) {
|
|
||||||
if (attributes.hasOwnProperty("list")) {
|
|
||||||
if (this._subscription) {
|
|
||||||
this._unloadList();
|
|
||||||
while (this._root.lastChild) {
|
|
||||||
this._root.lastChild.remove();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
this._list = attributes.list;
|
|
||||||
this.loadList();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
mount() {
|
|
||||||
const attr = {};
|
|
||||||
if (this._className) {
|
|
||||||
attr.className = this._className;
|
|
||||||
}
|
|
||||||
this._root = el(this._tagName, attr);
|
|
||||||
this.loadList();
|
|
||||||
if (this._onItemClick) {
|
|
||||||
this._root.addEventListener("click", this._onClick);
|
|
||||||
}
|
|
||||||
return this._root;
|
|
||||||
}
|
|
||||||
|
|
||||||
unmount() {
|
|
||||||
if (this._list) {
|
|
||||||
this._unloadList();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
_onClick(event) {
|
|
||||||
if (event.target === this._root) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let childNode = event.target;
|
|
||||||
while (childNode.parentNode !== this._root) {
|
|
||||||
childNode = childNode.parentNode;
|
|
||||||
}
|
|
||||||
const index = Array.prototype.indexOf.call(this._root.childNodes, childNode);
|
|
||||||
const childView = this._childInstances[index];
|
|
||||||
this._onItemClick(childView, event);
|
|
||||||
}
|
|
||||||
|
|
||||||
_unloadList() {
|
|
||||||
this._subscription = this._subscription();
|
|
||||||
for (let child of this._childInstances) {
|
|
||||||
child.unmount();
|
|
||||||
}
|
|
||||||
this._childInstances = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
loadList() {
|
|
||||||
if (!this._list) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this._subscription = this._list.subscribe(this);
|
|
||||||
this._childInstances = [];
|
|
||||||
const fragment = document.createDocumentFragment();
|
|
||||||
for (let item of this._list) {
|
|
||||||
const child = this._childCreator(item);
|
|
||||||
this._childInstances.push(child);
|
|
||||||
fragment.appendChild(mountView(child, this._mountArgs));
|
|
||||||
}
|
|
||||||
this._root.appendChild(fragment);
|
|
||||||
}
|
|
||||||
|
|
||||||
onAdd(idx, value) {
|
|
||||||
this.onBeforeListChanged();
|
|
||||||
const child = this._childCreator(value);
|
|
||||||
this._childInstances.splice(idx, 0, child);
|
|
||||||
insertAt(this._root, idx, mountView(child, this._mountArgs));
|
|
||||||
this.onListChanged();
|
|
||||||
}
|
|
||||||
|
|
||||||
onRemove(idx/*, _value*/) {
|
|
||||||
this.onBeforeListChanged();
|
|
||||||
const [child] = this._childInstances.splice(idx, 1);
|
|
||||||
child.root().remove();
|
|
||||||
child.unmount();
|
|
||||||
this.onListChanged();
|
|
||||||
}
|
|
||||||
|
|
||||||
onMove(fromIdx, toIdx/*, value*/) {
|
|
||||||
this.onBeforeListChanged();
|
|
||||||
const [child] = this._childInstances.splice(fromIdx, 1);
|
|
||||||
this._childInstances.splice(toIdx, 0, child);
|
|
||||||
child.root().remove();
|
|
||||||
insertAt(this._root, toIdx, child.root());
|
|
||||||
this.onListChanged();
|
|
||||||
}
|
|
||||||
|
|
||||||
onUpdate(i, value, params) {
|
|
||||||
if (this._childInstances) {
|
|
||||||
const instance = this._childInstances[i];
|
|
||||||
instance && instance.update(value, params);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
recreateItem(index, value) {
|
|
||||||
if (this._childInstances) {
|
|
||||||
const child = this._childCreator(value);
|
|
||||||
if (!child) {
|
|
||||||
this.onRemove(index, value);
|
|
||||||
} else {
|
|
||||||
const [oldChild] = this._childInstances.splice(index, 1, child);
|
|
||||||
this._root.replaceChild(child.mount(this._mountArgs), oldChild.root());
|
|
||||||
oldChild.unmount();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onBeforeListChanged() {}
|
|
||||||
onListChanged() {}
|
|
||||||
}
|
|
182
src/platform/web/ui/general/ListView.ts
Normal file
182
src/platform/web/ui/general/ListView.ts
Normal file
@ -0,0 +1,182 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2020 Bruno Windels <bruno@windels.cloud>
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {el} from "./html";
|
||||||
|
import {mountView, insertAt} from "./utils";
|
||||||
|
import {BaseObservableList as ObservableList} from "../../../../observable/list/BaseObservableList.js";
|
||||||
|
import {IView, IMountArgs} from "./types";
|
||||||
|
|
||||||
|
interface IOptions<T, V> {
|
||||||
|
list: ObservableList<T>,
|
||||||
|
onItemClick?: (childView: V, evt: UIEvent) => void,
|
||||||
|
className?: string,
|
||||||
|
tagName?: string,
|
||||||
|
parentProvidesUpdates?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
type SubscriptionHandle = () => undefined;
|
||||||
|
|
||||||
|
export class ListView<T, V extends IView> implements IView {
|
||||||
|
|
||||||
|
private _onItemClick?: (childView: V, evt: UIEvent) => void;
|
||||||
|
private _list: ObservableList<T>;
|
||||||
|
private _className?: string;
|
||||||
|
private _tagName: string;
|
||||||
|
private _root?: Element;
|
||||||
|
private _subscription?: SubscriptionHandle;
|
||||||
|
private _childCreator: (value: T) => V;
|
||||||
|
private _childInstances?: V[];
|
||||||
|
private _mountArgs: IMountArgs;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
{list, onItemClick, className, tagName = "ul", parentProvidesUpdates = true}: IOptions<T, V>,
|
||||||
|
childCreator: (value: T) => V
|
||||||
|
) {
|
||||||
|
this._onItemClick = onItemClick;
|
||||||
|
this._list = list;
|
||||||
|
this._className = className;
|
||||||
|
this._tagName = tagName;
|
||||||
|
this._root = undefined;
|
||||||
|
this._subscription = undefined;
|
||||||
|
this._childCreator = childCreator;
|
||||||
|
this._childInstances = undefined;
|
||||||
|
this._mountArgs = {parentProvidesUpdates};
|
||||||
|
}
|
||||||
|
|
||||||
|
root(): Element | undefined {
|
||||||
|
// won't be undefined when called between mount and unmount
|
||||||
|
return this._root;
|
||||||
|
}
|
||||||
|
|
||||||
|
update(attributes: IOptions<T, V>) {
|
||||||
|
if (attributes.list) {
|
||||||
|
if (this._subscription) {
|
||||||
|
this._unloadList();
|
||||||
|
while (this._root!.lastChild) {
|
||||||
|
this._root!.lastChild.remove();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this._list = attributes.list;
|
||||||
|
this.loadList();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
mount(): Element {
|
||||||
|
const attr: {[name: string]: any} = {};
|
||||||
|
if (this._className) {
|
||||||
|
attr.className = this._className;
|
||||||
|
}
|
||||||
|
const root = this._root = el(this._tagName, attr);
|
||||||
|
this.loadList();
|
||||||
|
if (this._onItemClick) {
|
||||||
|
root.addEventListener("click", this);
|
||||||
|
}
|
||||||
|
return root;
|
||||||
|
}
|
||||||
|
|
||||||
|
handleEvent(evt: Event) {
|
||||||
|
if (evt.type === "click") {
|
||||||
|
this._handleClick(evt as UIEvent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
unmount(): void {
|
||||||
|
if (this._list) {
|
||||||
|
this._unloadList();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private _handleClick(event: UIEvent) {
|
||||||
|
if (event.target === this._root || !this._onItemClick) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let childNode = event.target as Element;
|
||||||
|
while (childNode.parentNode !== this._root) {
|
||||||
|
childNode = childNode.parentNode as Element;
|
||||||
|
}
|
||||||
|
const index = Array.prototype.indexOf.call(this._root!.childNodes, childNode);
|
||||||
|
const childView = this._childInstances![index];
|
||||||
|
if (childView) {
|
||||||
|
this._onItemClick(childView, event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private _unloadList() {
|
||||||
|
this._subscription = this._subscription!();
|
||||||
|
for (let child of this._childInstances!) {
|
||||||
|
child.unmount();
|
||||||
|
}
|
||||||
|
this._childInstances = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected loadList() {
|
||||||
|
if (!this._list) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this._subscription = this._list.subscribe(this);
|
||||||
|
this._childInstances = [];
|
||||||
|
const fragment = document.createDocumentFragment();
|
||||||
|
for (let item of this._list) {
|
||||||
|
const child = this._childCreator(item);
|
||||||
|
this._childInstances!.push(child);
|
||||||
|
fragment.appendChild(mountView(child, this._mountArgs));
|
||||||
|
}
|
||||||
|
this._root!.appendChild(fragment);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected onAdd(idx: number, value: T) {
|
||||||
|
const child = this._childCreator(value);
|
||||||
|
this._childInstances!.splice(idx, 0, child);
|
||||||
|
insertAt(this._root!, idx, mountView(child, this._mountArgs));
|
||||||
|
}
|
||||||
|
|
||||||
|
protected onRemove(idx: number, value: T) {
|
||||||
|
const [child] = this._childInstances!.splice(idx, 1);
|
||||||
|
child.root()!.remove();
|
||||||
|
child.unmount();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected onMove(fromIdx: number, toIdx: number, value: T) {
|
||||||
|
const [child] = this._childInstances!.splice(fromIdx, 1);
|
||||||
|
this._childInstances!.splice(toIdx, 0, child);
|
||||||
|
child.root()!.remove();
|
||||||
|
insertAt(this._root!, toIdx, child.root()! as Element);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected onUpdate(i: number, value: T, params: any) {
|
||||||
|
if (this._childInstances) {
|
||||||
|
const instance = this._childInstances![i];
|
||||||
|
instance && instance.update(value, params);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected recreateItem(index: number, value: T) {
|
||||||
|
if (this._childInstances) {
|
||||||
|
const child = this._childCreator(value);
|
||||||
|
if (!child) {
|
||||||
|
this.onRemove(index, value);
|
||||||
|
} else {
|
||||||
|
const [oldChild] = this._childInstances!.splice(index, 1, child);
|
||||||
|
this._root!.replaceChild(child.mount(this._mountArgs), oldChild.root()!);
|
||||||
|
oldChild.unmount();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public getChildInstanceByIndex(idx: number): V | undefined {
|
||||||
|
return this._childInstances?.[idx];
|
||||||
|
}
|
||||||
|
}
|
@ -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 {
|
||||||
|
@ -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) {
|
||||||
|
@ -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;
|
||||||
}
|
}
|
||||||
|
@ -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) {
|
||||||
|
@ -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.js";
|
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);
|
@ -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) {
|
30
src/platform/web/ui/general/types.ts
Normal file
30
src/platform/web/ui/general/types.ts
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
/*
|
||||||
|
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");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
*/
|
||||||
|
export interface IMountArgs {
|
||||||
|
// if true, the parent will call update() rather than the view updating itself by binding to a data source.
|
||||||
|
parentProvidesUpdates?: boolean
|
||||||
|
};
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
@ -1,27 +0,0 @@
|
|||||||
/*
|
|
||||||
Copyright 2021 The Matrix.org Foundation C.I.C.
|
|
||||||
|
|
||||||
Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
you may not use this file except in compliance with the License.
|
|
||||||
You may obtain a copy of the License at
|
|
||||||
|
|
||||||
http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
|
|
||||||
Unless required by applicable law or agreed to in writing, software
|
|
||||||
distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
See the License for the specific language governing permissions and
|
|
||||||
limitations under the License.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import {errorToDOM} from "./error.js";
|
|
||||||
|
|
||||||
export function mountView(view, mountArgs = undefined) {
|
|
||||||
let node;
|
|
||||||
try {
|
|
||||||
node = view.mount(mountArgs);
|
|
||||||
} catch (err) {
|
|
||||||
node = errorToDOM(err);
|
|
||||||
}
|
|
||||||
return node;
|
|
||||||
}
|
|
@ -1,5 +1,5 @@
|
|||||||
/*
|
/*
|
||||||
Copyright 2020 Bruno Windels <bruno@windels.cloud>
|
Copyright 2021 The Matrix.org Foundation C.I.C.
|
||||||
|
|
||||||
Licensed under the Apache License, Version 2.0 (the "License");
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
you may not use this file except in compliance with the License.
|
you may not use this file except in compliance with the License.
|
||||||
@ -14,11 +14,22 @@ See the License for the specific language governing permissions and
|
|||||||
limitations under the License.
|
limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import {tag} from "./html.js";
|
import {IView, IMountArgs, ViewNode} from "./types";
|
||||||
|
import {tag} from "./html";
|
||||||
|
|
||||||
export function errorToDOM(error) {
|
export function mountView(view: IView, mountArgs?: IMountArgs): ViewNode {
|
||||||
|
let node;
|
||||||
|
try {
|
||||||
|
node = view.mount(mountArgs);
|
||||||
|
} catch (err) {
|
||||||
|
node = errorToDOM(err);
|
||||||
|
}
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function errorToDOM(error: Error): Element {
|
||||||
const stack = new Error().stack;
|
const stack = new Error().stack;
|
||||||
let callee = null;
|
let callee: string | null = null;
|
||||||
if (stack) {
|
if (stack) {
|
||||||
callee = stack.split("\n")[1];
|
callee = stack.split("\n")[1];
|
||||||
}
|
}
|
||||||
@ -29,3 +40,13 @@ export function errorToDOM(error) {
|
|||||||
tag.pre(error.stack),
|
tag.pre(error.stack),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function insertAt(parentNode: Element, idx: number, childNode: Node): void {
|
||||||
|
const isLast = idx === parentNode.childElementCount;
|
||||||
|
if (isLast) {
|
||||||
|
parentNode.appendChild(childNode);
|
||||||
|
} else {
|
||||||
|
const nextDomNode = parentNode.children[idx];
|
||||||
|
parentNode.insertBefore(childNode, nextDomNode);
|
||||||
|
}
|
||||||
|
}
|
@ -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 {
|
||||||
|
@ -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";
|
||||||
|
@ -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) {
|
||||||
|
@ -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
|
||||||
|
@ -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 {
|
||||||
|
@ -14,8 +14,8 @@ See the License for the specific language governing permissions and
|
|||||||
limitations under the License.
|
limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import {ListView} from "../general/ListView.js";
|
import {ListView} from "../general/ListView";
|
||||||
import {TemplateView} from "../general/TemplateView.js";
|
import {TemplateView} from "../general/TemplateView";
|
||||||
import {hydrogenGithubLink} from "./common.js";
|
import {hydrogenGithubLink} from "./common.js";
|
||||||
import {SessionLoadStatusView} from "./SessionLoadStatusView.js";
|
import {SessionLoadStatusView} from "./SessionLoadStatusView.js";
|
||||||
|
|
||||||
|
@ -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 {
|
||||||
|
@ -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 {
|
||||||
|
@ -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";
|
||||||
|
@ -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";
|
||||||
|
|
||||||
|
@ -14,8 +14,8 @@ See the License for the specific language governing permissions and
|
|||||||
limitations under the License.
|
limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import {ListView} from "../../general/ListView.js";
|
import {ListView} from "../../general/ListView";
|
||||||
import {TemplateView} from "../../general/TemplateView.js";
|
import {TemplateView} from "../../general/TemplateView";
|
||||||
import {RoomTileView} from "./RoomTileView.js";
|
import {RoomTileView} from "./RoomTileView.js";
|
||||||
import {InviteTileView} from "./InviteTileView.js";
|
import {InviteTileView} from "./InviteTileView.js";
|
||||||
|
|
||||||
|
@ -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 {
|
||||||
|
@ -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) {
|
||||||
|
@ -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 {
|
||||||
|
@ -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";
|
||||||
|
@ -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 {
|
||||||
|
@ -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 {
|
||||||
|
@ -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 {
|
||||||
|
@ -14,10 +14,10 @@ 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 "./TimelineList.js"
|
import {viewClassForEntry} from "./TimelineView"
|
||||||
|
|
||||||
export class MessageComposer extends TemplateView {
|
export class MessageComposer extends TemplateView {
|
||||||
constructor(viewModel) {
|
constructor(viewModel) {
|
||||||
|
@ -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) {
|
||||||
|
@ -15,10 +15,10 @@ 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 {TimelineList} from "./TimelineList.js";
|
import {TimelineView} from "./TimelineView";
|
||||||
import {TimelineLoadingView} from "./TimelineLoadingView.js";
|
import {TimelineLoadingView} from "./TimelineLoadingView.js";
|
||||||
import {MessageComposer} from "./MessageComposer.js";
|
import {MessageComposer} from "./MessageComposer.js";
|
||||||
import {RoomArchivedView} from "./RoomArchivedView.js";
|
import {RoomArchivedView} from "./RoomArchivedView.js";
|
||||||
@ -54,7 +54,7 @@ export class RoomView extends TemplateView {
|
|||||||
t.div({className: "RoomView_error"}, vm => vm.error),
|
t.div({className: "RoomView_error"}, vm => vm.error),
|
||||||
t.mapView(vm => vm.timelineViewModel, timelineViewModel => {
|
t.mapView(vm => vm.timelineViewModel, timelineViewModel => {
|
||||||
return timelineViewModel ?
|
return timelineViewModel ?
|
||||||
new TimelineList(timelineViewModel) :
|
new TimelineView(timelineViewModel) :
|
||||||
new TimelineLoadingView(vm); // vm is just needed for i18n
|
new TimelineLoadingView(vm); // vm is just needed for i18n
|
||||||
}),
|
}),
|
||||||
t.view(bottomView),
|
t.view(bottomView),
|
||||||
|
@ -1,165 +0,0 @@
|
|||||||
/*
|
|
||||||
Copyright 2020 Bruno Windels <bruno@windels.cloud>
|
|
||||||
|
|
||||||
Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
you may not use this file except in compliance with the License.
|
|
||||||
You may obtain a copy of the License at
|
|
||||||
|
|
||||||
http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
|
|
||||||
Unless required by applicable law or agreed to in writing, software
|
|
||||||
distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
See the License for the specific language governing permissions and
|
|
||||||
limitations under the License.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import {ListView} from "../../general/ListView.js";
|
|
||||||
import {GapView} from "./timeline/GapView.js";
|
|
||||||
import {TextMessageView} from "./timeline/TextMessageView.js";
|
|
||||||
import {ImageView} from "./timeline/ImageView.js";
|
|
||||||
import {VideoView} from "./timeline/VideoView.js";
|
|
||||||
import {FileView} from "./timeline/FileView.js";
|
|
||||||
import {MissingAttachmentView} from "./timeline/MissingAttachmentView.js";
|
|
||||||
import {AnnouncementView} from "./timeline/AnnouncementView.js";
|
|
||||||
import {RedactedView} from "./timeline/RedactedView.js";
|
|
||||||
|
|
||||||
export function viewClassForEntry(entry) {
|
|
||||||
switch (entry.shape) {
|
|
||||||
case "gap": return GapView;
|
|
||||||
case "announcement": return AnnouncementView;
|
|
||||||
case "message":
|
|
||||||
case "message-status":
|
|
||||||
return TextMessageView;
|
|
||||||
case "image": return ImageView;
|
|
||||||
case "video": return VideoView;
|
|
||||||
case "file": return FileView;
|
|
||||||
case "missing-attachment": return MissingAttachmentView;
|
|
||||||
case "redacted":
|
|
||||||
return RedactedView;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export class TimelineList extends ListView {
|
|
||||||
constructor(viewModel) {
|
|
||||||
const options = {
|
|
||||||
className: "Timeline bottom-aligned-scroll",
|
|
||||||
list: viewModel.tiles,
|
|
||||||
onItemClick: (tileView, evt) => tileView.onClick(evt),
|
|
||||||
}
|
|
||||||
super(options, entry => {
|
|
||||||
const View = viewClassForEntry(entry);
|
|
||||||
if (View) {
|
|
||||||
return new View(entry);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
this._atBottom = false;
|
|
||||||
this._onScroll = this._onScroll.bind(this);
|
|
||||||
this._topLoadingPromise = null;
|
|
||||||
this._viewModel = viewModel;
|
|
||||||
}
|
|
||||||
|
|
||||||
async _loadAtTopWhile(predicate) {
|
|
||||||
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 = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async _onScroll() {
|
|
||||||
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;
|
|
||||||
root.scrollTop = root.scrollTop + (contentHeight - lastContentHeight);
|
|
||||||
lastContentHeight = contentHeight;
|
|
||||||
return amountGrown < PAGINATE_OFFSET;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
mount() {
|
|
||||||
const root = super.mount();
|
|
||||||
root.addEventListener("scroll", this._onScroll);
|
|
||||||
return root;
|
|
||||||
}
|
|
||||||
|
|
||||||
unmount() {
|
|
||||||
this.root().removeEventListener("scroll", this._onScroll);
|
|
||||||
super.unmount();
|
|
||||||
}
|
|
||||||
|
|
||||||
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;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
onBeforeListChanged() {
|
|
||||||
const fromBottom = this._distanceFromBottom();
|
|
||||||
this._atBottom = fromBottom < 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
_distanceFromBottom() {
|
|
||||||
const root = this.root();
|
|
||||||
return root.scrollHeight - root.scrollTop - root.clientHeight;
|
|
||||||
}
|
|
||||||
|
|
||||||
onListChanged() {
|
|
||||||
const root = this.root();
|
|
||||||
if (this._atBottom) {
|
|
||||||
root.scrollTop = root.scrollHeight;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onUpdate(index, value, param) {
|
|
||||||
if (param === "shape") {
|
|
||||||
if (this._childInstances) {
|
|
||||||
const ExpectedClass = viewClassForEntry(value);
|
|
||||||
const child = this._childInstances[index];
|
|
||||||
if (!ExpectedClass || !(child instanceof ExpectedClass)) {
|
|
||||||
// shape was updated, so we need to recreate the tile view,
|
|
||||||
// the shape parameter is set in EncryptedEventTile.updateEntry
|
|
||||||
// (and perhaps elsewhere by the time you read this)
|
|
||||||
super.recreateItem(index, value);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
super.onUpdate(index, value, param);
|
|
||||||
}
|
|
||||||
}
|
|
@ -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 {
|
||||||
|
244
src/platform/web/ui/session/room/TimelineView.ts
Normal file
244
src/platform/web/ui/session/room/TimelineView.ts
Normal file
@ -0,0 +1,244 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2020 Bruno Windels <bruno@windels.cloud>
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {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";
|
||||||
|
import {VideoView} from "./timeline/VideoView.js";
|
||||||
|
import {FileView} from "./timeline/FileView.js";
|
||||||
|
import {MissingAttachmentView} from "./timeline/MissingAttachmentView.js";
|
||||||
|
import {AnnouncementView} from "./timeline/AnnouncementView.js";
|
||||||
|
import {RedactedView} from "./timeline/RedactedView.js";
|
||||||
|
import {SimpleTile} from "../../../../../domain/session/room/timeline/tiles/SimpleTile.js";
|
||||||
|
import {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 |
|
||||||
|
ImageView | VideoView | FileView | MissingAttachmentView | RedactedView;
|
||||||
|
type TileViewConstructor = (this: TileView, SimpleTile) => void;
|
||||||
|
|
||||||
|
export function viewClassForEntry(entry: SimpleTile): TileViewConstructor | undefined {
|
||||||
|
switch (entry.shape) {
|
||||||
|
case "gap": return GapView;
|
||||||
|
case "announcement": return AnnouncementView;
|
||||||
|
case "message":
|
||||||
|
case "message-status":
|
||||||
|
return TextMessageView;
|
||||||
|
case "image": return ImageView;
|
||||||
|
case "video": return VideoView;
|
||||||
|
case "file": return FileView;
|
||||||
|
case "missing-attachment": return MissingAttachmentView;
|
||||||
|
case "redacted":
|
||||||
|
return RedactedView;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function bottom(node: HTMLElement): number {
|
||||||
|
return node.offsetTop + node.clientHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
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 = {
|
||||||
|
list: tiles,
|
||||||
|
onItemClick: (tileView, evt) => tileView.onClick(evt),
|
||||||
|
};
|
||||||
|
super(options, entry => {
|
||||||
|
const View = viewClassForEntry(entry);
|
||||||
|
if (View) {
|
||||||
|
return new View(entry);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
this.onChanged = onChanged;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected onUpdate(index: number, value: SimpleTile, param: any) {
|
||||||
|
if (param === "shape") {
|
||||||
|
const ExpectedClass = viewClassForEntry(value);
|
||||||
|
const child = this.getChildInstanceByIndex(index);
|
||||||
|
if (!ExpectedClass || !(child instanceof ExpectedClass)) {
|
||||||
|
// shape was updated, so we need to recreate the tile view,
|
||||||
|
// the shape parameter is set in EncryptedEventTile.updateEntry
|
||||||
|
// (and perhaps elsewhere by the time you read this)
|
||||||
|
super.recreateItem(index, value);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
super.onUpdate(index, value, param);
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
@ -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) {
|
||||||
|
@ -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) {
|
||||||
|
@ -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.js";
|
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";
|
||||||
@ -33,6 +33,10 @@ export class BaseMessageView extends TemplateView {
|
|||||||
}
|
}
|
||||||
|
|
||||||
render(t, vm) {
|
render(t, vm) {
|
||||||
|
const children = [this.renderMessageBody(t, vm)];
|
||||||
|
if (this._interactive) {
|
||||||
|
children.push(t.button({className: "Timeline_messageOptions"}, "⋯"));
|
||||||
|
}
|
||||||
const li = t.el(this._tagName, {className: {
|
const li = t.el(this._tagName, {className: {
|
||||||
"Timeline_message": true,
|
"Timeline_message": true,
|
||||||
own: vm.isOwn,
|
own: vm.isOwn,
|
||||||
@ -40,13 +44,7 @@ export class BaseMessageView extends TemplateView {
|
|||||||
unverified: vm.isUnverified,
|
unverified: vm.isUnverified,
|
||||||
disabled: !this._interactive,
|
disabled: !this._interactive,
|
||||||
continuation: vm => vm.isContinuation,
|
continuation: vm => vm.isContinuation,
|
||||||
}}, [
|
}}, children);
|
||||||
// dynamically added and removed nodes are handled below
|
|
||||||
this.renderMessageBody(t, vm),
|
|
||||||
// should be after body as it is overlayed on top
|
|
||||||
this._interactive ? t.button({className: "Timeline_messageOptions"}, "⋯") : [],
|
|
||||||
]);
|
|
||||||
const avatar = t.a({href: vm.memberPanelLink, className: "Timeline_messageAvatar"}, [renderStaticAvatar(vm, 30)]);
|
|
||||||
// given that there can be many tiles, we don't add
|
// given that there can be many tiles, we don't add
|
||||||
// unneeded DOM nodes in case of a continuation, and we add it
|
// unneeded DOM nodes in case of a continuation, and we add it
|
||||||
// with a side-effect binding to not have to create sub views,
|
// with a side-effect binding to not have to create sub views,
|
||||||
@ -57,8 +55,10 @@ export class BaseMessageView extends TemplateView {
|
|||||||
li.removeChild(li.querySelector(".Timeline_messageAvatar"));
|
li.removeChild(li.querySelector(".Timeline_messageAvatar"));
|
||||||
li.removeChild(li.querySelector(".Timeline_messageSender"));
|
li.removeChild(li.querySelector(".Timeline_messageSender"));
|
||||||
} else if (!isContinuation) {
|
} else if (!isContinuation) {
|
||||||
|
const avatar = tag.a({href: vm.memberPanelLink, className: "Timeline_messageAvatar"}, [renderStaticAvatar(vm, 30)]);
|
||||||
|
const sender = tag.div({className: `Timeline_messageSender usercolor${vm.avatarColorNumber}`}, vm.displayName);
|
||||||
li.insertBefore(avatar, li.firstChild);
|
li.insertBefore(avatar, li.firstChild);
|
||||||
li.insertBefore(tag.div({className: `Timeline_messageSender usercolor${vm.avatarColorNumber}`}, vm.displayName), li.firstChild);
|
li.insertBefore(sender, li.firstChild);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
// similarly, we could do this with a simple ifView,
|
// similarly, we could do this with a simple ifView,
|
||||||
|
@ -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() {}
|
||||||
}
|
}
|
||||||
|
@ -14,8 +14,8 @@ See the License for the specific language governing permissions and
|
|||||||
limitations under the License.
|
limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import {ListView} from "../../../general/ListView.js";
|
import {ListView} from "../../../general/ListView";
|
||||||
import {TemplateView} from "../../../general/TemplateView.js";
|
import {TemplateView} from "../../../general/TemplateView";
|
||||||
|
|
||||||
export class ReactionsView extends ListView {
|
export class ReactionsView extends ListView {
|
||||||
constructor(reactionsViewModel) {
|
constructor(reactionsViewModel) {
|
||||||
|
@ -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 {
|
||||||
|
@ -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 {
|
||||||
|
@ -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 {
|
||||||
|
@ -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);
|
Loading…
Reference in New Issue
Block a user