diff --git a/package.json b/package.json index 3116863c..aaf7e30e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "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", "main": "index.js", "directories": { diff --git a/src/domain/SessionPickerViewModel.js b/src/domain/SessionPickerViewModel.js index 3bbbcef7..fc669df1 100644 --- a/src/domain/SessionPickerViewModel.js +++ b/src/domain/SessionPickerViewModel.js @@ -150,7 +150,9 @@ export class SessionPickerViewModel extends ViewModel { async _exportData(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}; return data; } @@ -161,7 +163,9 @@ export class SessionPickerViewModel extends ViewModel { const {sessionInfo} = data; sessionInfo.comment = `Imported on ${new Date().toLocaleString()} from id ${sessionInfo.id}.`; 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); this._sessions.set(new SessionItemViewModel(sessionInfo, this)); } catch (err) { diff --git a/src/domain/ViewModel.js b/src/domain/ViewModel.js index 97a91700..f0e109f8 100644 --- a/src/domain/ViewModel.js +++ b/src/domain/ViewModel.js @@ -18,7 +18,7 @@ limitations under the License. // as in some cases it would really be more convenient to have multiple events (like telling the timeline to scroll down) // we do need to return a disposable from EventEmitter.on, or at least have a method here to easily track a subscription to an EventEmitter -import {EventEmitter} from "../utils/EventEmitter.js"; +import {EventEmitter} from "../utils/EventEmitter"; import {Disposables} from "../utils/Disposables.js"; export class ViewModel extends EventEmitter { diff --git a/src/domain/session/room/ComposerViewModel.js b/src/domain/session/room/ComposerViewModel.js index ba4627ca..81a8fc17 100644 --- a/src/domain/session/room/ComposerViewModel.js +++ b/src/domain/session/room/ComposerViewModel.js @@ -25,7 +25,7 @@ export class ComposerViewModel extends ViewModel { } setReplyingTo(entry) { - const changed = this._replyVM?.internalId !== entry?.asEventKey().toString(); + const changed = new Boolean(entry) !== new Boolean(this._replyVM) || !this._replyVM?.id.equals(entry.asEventKey()); if (changed) { this._replyVM = this.disposeTracked(this._replyVM); if (entry) { diff --git a/src/domain/session/room/timeline/TilesCollection.js b/src/domain/session/room/timeline/TilesCollection.js index 10062af2..497c0b0d 100644 --- a/src/domain/session/room/timeline/TilesCollection.js +++ b/src/domain/session/room/timeline/TilesCollection.js @@ -236,6 +236,21 @@ export class TilesCollection extends BaseObservableList { getFirst() { return this._tiles[0]; } + + getTileIndex(searchTile) { + const idx = sortedIndex(this._tiles, searchTile, (searchTile, tile) => { + return searchTile.compare(tile); + }); + const foundTile = this._tiles[idx]; + if (foundTile?.compare(searchTile) === 0) { + return idx; + } + return -1; + } + + sliceIterator(start, end) { + return this._tiles.slice(start, end)[Symbol.iterator](); + } } import {ObservableArray} from "../../../../observable/list/ObservableArray.js"; diff --git a/src/domain/session/room/timeline/TimelineViewModel.js b/src/domain/session/room/timeline/TimelineViewModel.js index a08ab060..9c936218 100644 --- a/src/domain/session/room/timeline/TimelineViewModel.js +++ b/src/domain/session/room/timeline/TimelineViewModel.js @@ -40,40 +40,74 @@ export class TimelineViewModel extends ViewModel { const {timeline, tilesCreator} = options; this._timeline = this.track(timeline); this._tiles = new TilesCollection(timeline.entries, tilesCreator); + this._startTile = null; + this._endTile = null; + this._topLoadingPromise = null; + this._requestedStartTile = null; + this._requestedEndTile = null; + this._requestScheduled = false; + this._showJumpDown = false; } - /** - * @return {bool} startReached if the start of the timeline was reached - */ - async loadAtTop() { - if (this.isDisposed) { - // stop loading more, we switched room - return true; + /** if this.tiles is empty, call this with undefined for both startTile and endTile */ + setVisibleTileRange(startTile, endTile) { + // don't clear these once done as they are used to check + // for more tiles once loadAtTop finishes + this._requestedStartTile = startTile; + this._requestedEndTile = endTile; + if (!this._requestScheduled) { + Promise.resolve().then(() => { + this._setVisibleTileRange(this._requestedStartTile, this._requestedEndTile); + this._requestScheduled = false; + }); + this._requestScheduled = true; } - const firstTile = this._tiles.getFirst(); - if (firstTile?.shape === "gap") { - return await firstTile.fill(); + } + + _setVisibleTileRange(startTile, endTile) { + let loadTop; + if (startTile && endTile) { + // old tiles could have been removed from tilescollection once we support unloading + this._startTile = startTile; + this._endTile = endTile; + const startIndex = this._tiles.getTileIndex(this._startTile); + const endIndex = this._tiles.getTileIndex(this._endTile); + for (const tile of this._tiles.sliceIterator(startIndex, endIndex + 1)) { + tile.notifyVisible(); + } + loadTop = startIndex < 10; + this._setShowJumpDown(endIndex < (this._tiles.length - 1)); } else { - const topReached = await this._timeline.loadAtTop(10); - return topReached; + // tiles collection is empty, load more at top + loadTop = true; + this._setShowJumpDown(false); } - } - unloadAtTop(/*tileAmount*/) { - // get lowerSortKey for tile at index tileAmount - 1 - // tell timeline to unload till there (included given key) - } - - loadAtBottom() { - - } - - unloadAtBottom(/*tileAmount*/) { - // get upperSortKey for tile at index tiles.length - tileAmount - // tell timeline to unload till there (included given key) + if (loadTop && !this._topLoadingPromise) { + this._topLoadingPromise = this._timeline.loadAtTop(10).then(hasReachedEnd => { + this._topLoadingPromise = null; + if (!hasReachedEnd) { + // check if more items need to be loaded by recursing + // use the requested start / end tile, + // so we don't end up overwriting a newly requested visible range here + this.setVisibleTileRange(this._requestedStartTile, this._requestedEndTile); + } + }); + } } get tiles() { return this._tiles; } + + _setShowJumpDown(show) { + if (this._showJumpDown !== show) { + this._showJumpDown = show; + this.emitChange("showJumpDown"); + } + } + + get showJumpDown() { + return this._showJumpDown; + } } diff --git a/src/domain/session/room/timeline/tiles/EncryptedEventTile.js b/src/domain/session/room/timeline/tiles/EncryptedEventTile.js index 07dd763b..b96e2d85 100644 --- a/src/domain/session/room/timeline/tiles/EncryptedEventTile.js +++ b/src/domain/session/room/timeline/tiles/EncryptedEventTile.js @@ -22,7 +22,7 @@ export class EncryptedEventTile extends BaseTextTile { const parentResult = super.updateEntry(entry, params); // event got decrypted, recreate the tile and replace this one with it 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"); } else { return parentResult; diff --git a/src/domain/session/room/timeline/tiles/GapTile.js b/src/domain/session/room/timeline/tiles/GapTile.js index 32d632bf..5b4bcea4 100644 --- a/src/domain/session/room/timeline/tiles/GapTile.js +++ b/src/domain/session/room/timeline/tiles/GapTile.js @@ -22,11 +22,11 @@ export class GapTile extends SimpleTile { super(options); this._loading = false; this._error = null; + this._isAtTop = true; } async fill() { - // prevent doing this twice - if (!this._loading) { + if (!this._loading && !this._entry.edgeReached) { this._loading = true; this.emitChange("isLoading"); try { @@ -43,8 +43,25 @@ export class GapTile extends SimpleTile { this.emitChange("isLoading"); } } - // edgeReached will have been updated by fillGap - return this._entry.edgeReached; + } + + notifyVisible() { + this.fill(); + } + + get isAtTop() { + return this._isAtTop; + } + + updatePreviousSibling(prev) { + console.log("GapTile.updatePreviousSibling", prev); + super.updatePreviousSibling(prev); + const isAtTop = !prev; + if (this._isAtTop !== isAtTop) { + this._isAtTop = isAtTop; + console.log("isAtTop", this._isAtTop); + this.emitChange("isAtTop"); + } } updateEntry(entry, params) { diff --git a/src/domain/session/room/timeline/tiles/SimpleTile.js b/src/domain/session/room/timeline/tiles/SimpleTile.js index 3c370b72..4c1c1de0 100644 --- a/src/domain/session/room/timeline/tiles/SimpleTile.js +++ b/src/domain/session/room/timeline/tiles/SimpleTile.js @@ -40,8 +40,8 @@ export class SimpleTile extends ViewModel { return false; } - get internalId() { - return this._entry.asEventKey().toString(); + get id() { + return this._entry.asEventKey(); } get isPending() { @@ -83,6 +83,10 @@ export class SimpleTile extends ViewModel { return this._entry; } + compare(tile) { + return this.upperEntry.compare(tile.upperEntry); + } + compareEntry(entry) { return this._entry.compare(entry); } @@ -119,6 +123,8 @@ export class SimpleTile extends ViewModel { } + notifyVisible() {} + dispose() { this.setUpdateEmit(null); super.dispose(); diff --git a/src/logging/LogItem.js b/src/logging/LogItem.js index fcb88eff..90747964 100644 --- a/src/logging/LogItem.js +++ b/src/logging/LogItem.js @@ -43,12 +43,16 @@ export class LogItem { /** logs a reference to a different log item, usually obtained from runDetached. This is useful if the referenced operation can't be awaited. */ refDetached(logItem, logLevel = null) { - if (!logItem._values.refId) { - logItem.set("refId", this._logger._createRefId()); - } + logItem.ensureRefId(); 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`. */ @@ -231,4 +235,8 @@ export class LogItem { this._children.push(item); return item; } + + get logger() { + return this._logger; + } } diff --git a/src/logging/NullLogger.js b/src/logging/NullLogger.js index 202d01f3..c0f3a143 100644 --- a/src/logging/NullLogger.js +++ b/src/logging/NullLogger.js @@ -71,6 +71,8 @@ export class NullLogItem { refDetached() {} + ensureRefId() {} + get level() { return LogLevel; } diff --git a/src/matrix/Sync.js b/src/matrix/Sync.js index ed9889b1..bcd29ab4 100644 --- a/src/matrix/Sync.js +++ b/src/matrix/Sync.js @@ -296,14 +296,10 @@ export class Sync { // avoid corrupting state by only // storing the sync up till the point // the exception occurred - try { - syncTxn.abort(); - } catch (abortErr) { - log.set("couldNotAbortTxn", true); - } - throw err; + syncTxn.abort(log); + throw syncTxn.getCause(err); } - await syncTxn.complete(); + await syncTxn.complete(log); } _afterSync(sessionState, inviteStates, roomStates, archivedRoomStates, log) { diff --git a/src/matrix/room/BaseRoom.js b/src/matrix/room/BaseRoom.js index ab82ff74..81aa44da 100644 --- a/src/matrix/room/BaseRoom.js +++ b/src/matrix/room/BaseRoom.js @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {EventEmitter} from "../../utils/EventEmitter.js"; +import {EventEmitter} from "../../utils/EventEmitter"; import {RoomSummary} from "./RoomSummary.js"; import {GapWriter} from "./timeline/persistence/GapWriter.js"; import {RelationWriter} from "./timeline/persistence/RelationWriter.js"; diff --git a/src/matrix/room/Invite.js b/src/matrix/room/Invite.js index 9bf818ae..b8190322 100644 --- a/src/matrix/room/Invite.js +++ b/src/matrix/room/Invite.js @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {EventEmitter} from "../../utils/EventEmitter.js"; +import {EventEmitter} from "../../utils/EventEmitter"; import {SummaryData, processStateEvent} from "./RoomSummary.js"; import {Heroes} from "./members/Heroes.js"; import {MemberChange, RoomMember, EVENT_TYPE as MEMBER_EVENT_TYPE} from "./members/RoomMember.js"; diff --git a/src/matrix/room/timeline/Timeline.js b/src/matrix/room/timeline/Timeline.js index 022eda4e..54eabf96 100644 --- a/src/matrix/room/timeline/Timeline.js +++ b/src/matrix/room/timeline/Timeline.js @@ -72,8 +72,10 @@ export class Timeline { // as they should only populate once the view subscribes to it // if they are populated already, the sender profile would be empty - // 30 seems to be a good amount to fill the entire screen - const readerRequest = this._disposables.track(this._timelineReader.readFromEnd(30, txn, log)); + // choose good amount here between showing messages initially and + // not spending too much time decrypting messages before showing the timeline. + // more messages should be loaded automatically until the viewport is full by the view if needed. + const readerRequest = this._disposables.track(this._timelineReader.readFromEnd(20, txn, log)); try { const entries = await readerRequest.complete(); this._setupEntries(entries); diff --git a/src/matrix/room/timeline/persistence/SyncWriter.js b/src/matrix/room/timeline/persistence/SyncWriter.js index 8fd9d16a..9d212b7d 100644 --- a/src/matrix/room/timeline/persistence/SyncWriter.js +++ b/src/matrix/room/timeline/persistence/SyncWriter.js @@ -163,7 +163,7 @@ export class SyncWriter { storageEntry.displayName = member.displayName; storageEntry.avatarUrl = member.avatarUrl; } - txn.timelineEvents.insert(storageEntry); + txn.timelineEvents.insert(storageEntry, log); const entry = new EventEntry(storageEntry, this._fragmentIdComparer); entries.push(entry); const updatedRelationTargetEntries = await this._relationWriter.writeRelation(entry, txn, log); diff --git a/src/matrix/storage/idb/Storage.ts b/src/matrix/storage/idb/Storage.ts index 7e02c111..72be55ce 100644 --- a/src/matrix/storage/idb/Storage.ts +++ b/src/matrix/storage/idb/Storage.ts @@ -17,22 +17,26 @@ limitations under the License. import {Transaction} from "./Transaction"; import { STORE_NAMES, StoreNames, StorageError } from "../common"; import { reqAsPromise } from "./utils"; +import { BaseLogger } from "../../../logging/BaseLogger.js"; const WEBKITEARLYCLOSETXNBUG_BOGUS_KEY = "782rh281re38-boguskey"; export class Storage { private _db: IDBDatabase; 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._idbFactory = idbFactory; - this._IDBKeyRange = _IDBKeyRange; + this.idbFactory = idbFactory; + this.IDBKeyRange = _IDBKeyRange; this._hasWebkitEarlyCloseTxnBug = hasWebkitEarlyCloseTxnBug; this.storeNames = StoreNames; + this.logger = logger; } _validateStoreNames(storeNames: StoreNames[]): void { @@ -51,7 +55,7 @@ export class Storage { if (this._hasWebkitEarlyCloseTxnBug) { 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) { throw new StorageError("readTxn failed", err); } @@ -66,7 +70,7 @@ export class Storage { if (this._hasWebkitEarlyCloseTxnBug) { 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) { throw new StorageError("readWriteTxn failed", err); } diff --git a/src/matrix/storage/idb/StorageFactory.ts b/src/matrix/storage/idb/StorageFactory.ts index 55e6f604..59c988a0 100644 --- a/src/matrix/storage/idb/StorageFactory.ts +++ b/src/matrix/storage/idb/StorageFactory.ts @@ -20,9 +20,10 @@ import { exportSession, importSession, Export } from "./export"; import { schema } from "./schema"; import { detectWebkitEarlyCloseTxnBug } from "./quirks"; import { BaseLogger } from "../../../logging/BaseLogger.js"; +import { LogItem } from "../../../logging/LogItem.js"; 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); return openDatabase(sessionName(sessionId), create, schema.length, idbFactory); } @@ -59,7 +60,7 @@ export class StorageFactory { this._IDBKeyRange = _IDBKeyRange; } - async create(sessionId: string, log?: BaseLogger): Promise { + async create(sessionId: string, log: LogItem): Promise { await this._serviceWorkerHandler?.preventConcurrentSessionAccess(sessionId); requestPersistedStorage().then(persisted => { // 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 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 { @@ -79,18 +80,18 @@ export class StorageFactory { return reqAsPromise(req); } - async export(sessionId: string): Promise { - const db = await openDatabaseWithSessionId(sessionId, this._idbFactory); + async export(sessionId: string, log: LogItem): Promise { + const db = await openDatabaseWithSessionId(sessionId, this._idbFactory, log); return await exportSession(db); } - async import(sessionId: string, data: Export): Promise { - const db = await openDatabaseWithSessionId(sessionId, this._idbFactory); + async import(sessionId: string, data: Export, log: LogItem): Promise { + const db = await openDatabaseWithSessionId(sessionId, this._idbFactory, log); return await importSession(db, data); } } -async function createStores(db: IDBDatabase, txn: IDBTransaction, oldVersion: number | null, version: number, log?: BaseLogger): Promise { +async function createStores(db: IDBDatabase, txn: IDBTransaction, oldVersion: number | null, version: number, log: LogItem): Promise { const startIdx = oldVersion || 0; return log.wrap({l: "storage migration", oldVersion, version}, async log => { for(let i = startIdx; i < version; ++i) { diff --git a/src/matrix/storage/idb/Store.ts b/src/matrix/storage/idb/Store.ts index 0930888c..662dad26 100644 --- a/src/matrix/storage/idb/Store.ts +++ b/src/matrix/storage/idb/Store.ts @@ -18,6 +18,7 @@ import {QueryTarget, IDBQuery} from "./QueryTarget"; import {IDBRequestAttemptError} from "./error"; import {reqAsPromise} from "./utils"; import {Transaction} from "./Transaction"; +import {LogItem} from "../../../logging/LogItem.js"; const LOG_REQUESTS = false; @@ -140,7 +141,7 @@ export class Store extends QueryTarget { return new QueryTarget(new QueryTargetWrapper(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, // which is the behaviour we want. Therefore, it is ok to not create a promise for this // request and await it. @@ -151,16 +152,52 @@ export class Store extends QueryTarget { // // Note that this can still throw synchronously, like it does for TransactionInactiveError, // 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`. - this._idbStore.add(value); + const request = this._idbStore.add(value); + this._prepareErrorLog(request, log, "add", undefined, value); } - delete(keyOrKeyRange: IDBValidKey | IDBKeyRange): Promise { + delete(keyOrKeyRange: IDBValidKey | IDBKeyRange, log?: LogItem): void { // 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; + } } } diff --git a/src/matrix/storage/idb/Transaction.ts b/src/matrix/storage/idb/Transaction.ts index d0a8748f..fd2c75a3 100644 --- a/src/matrix/storage/idb/Transaction.ts +++ b/src/matrix/storage/idb/Transaction.ts @@ -18,6 +18,7 @@ import {StoreNames} from "../common"; import {txnAsPromise} from "./utils"; import {StorageError} from "../common"; import {Store} from "./Store"; +import {Storage} from "./Storage"; import {SessionStore} from "./stores/SessionStore"; import {RoomSummaryStore} from "./stores/RoomSummaryStore"; import {InviteStore} from "./stores/InviteStore"; @@ -35,20 +36,46 @@ import {OutboundGroupSessionStore} from "./stores/OutboundGroupSessionStore"; import {GroupSessionDecryptionStore} from "./stores/GroupSessionDecryptionStore"; import {OperationStore} from "./stores/OperationStore"; 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 { private _txn: IDBTransaction; private _allowedStoreNames: StoreNames[]; private _stores: { [storeName in StoreNames]?: any }; - idbFactory: IDBFactory - IDBKeyRange: typeof IDBKeyRange + private _storage: Storage; + private _writeErrors: WriteErrorInfo[]; - constructor(txn: IDBTransaction, allowedStoreNames: StoreNames[], _idbFactory: IDBFactory, _IDBKeyRange: typeof IDBKeyRange) { + constructor(txn: IDBTransaction, allowedStoreNames: StoreNames[], storage: Storage) { this._txn = txn; this._allowedStoreNames = allowedStoreNames; this._stores = {}; - this.idbFactory = _idbFactory; - this.IDBKeyRange = _IDBKeyRange; + this._txn = txn; + 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 { @@ -139,12 +166,66 @@ export class Transaction { return this._store(StoreNames.accountData, idbStore => new AccountDataStore(idbStore)); } - complete(): Promise { - return txnAsPromise(this._txn); + async complete(log?: LogItem): Promise { + 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? - 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); + } } } diff --git a/src/matrix/storage/idb/stores/DeviceIdentityStore.ts b/src/matrix/storage/idb/stores/DeviceIdentityStore.ts index 9800fcca..6b9f5332 100644 --- a/src/matrix/storage/idb/stores/DeviceIdentityStore.ts +++ b/src/matrix/storage/idb/stores/DeviceIdentityStore.ts @@ -78,14 +78,14 @@ export class DeviceIdentityStore { return this._store.index("byCurve25519Key").get(curve25519Key); } - remove(userId: string, deviceId: string): Promise { - return this._store.delete(encodeKey(userId, deviceId)); + remove(userId: string, deviceId: string): void { + this._store.delete(encodeKey(userId, deviceId)); } - removeAllForUser(userId: string): Promise { + removeAllForUser(userId: string): void { // 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 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); } } diff --git a/src/matrix/storage/idb/stores/GroupSessionDecryptionStore.ts b/src/matrix/storage/idb/stores/GroupSessionDecryptionStore.ts index b6636f13..1627e44a 100644 --- a/src/matrix/storage/idb/stores/GroupSessionDecryptionStore.ts +++ b/src/matrix/storage/idb/stores/GroupSessionDecryptionStore.ts @@ -44,11 +44,11 @@ export class GroupSessionDecryptionStore { this._store.put(decryption as GroupSessionEntry); } - removeAllForRoom(roomId: string): Promise { + removeAllForRoom(roomId: string): void { const range = this._store.IDBKeyRange.bound( encodeKey(roomId, MIN_UNICODE, MIN_UNICODE), encodeKey(roomId, MAX_UNICODE, MAX_UNICODE) ); - return this._store.delete(range); + this._store.delete(range); } } diff --git a/src/matrix/storage/idb/stores/InboundGroupSessionStore.ts b/src/matrix/storage/idb/stores/InboundGroupSessionStore.ts index 286eaf4e..5dc0205f 100644 --- a/src/matrix/storage/idb/stores/InboundGroupSessionStore.ts +++ b/src/matrix/storage/idb/stores/InboundGroupSessionStore.ts @@ -53,11 +53,11 @@ export class InboundGroupSessionStore { this._store.put(session); } - removeAllForRoom(roomId: string): Promise { + removeAllForRoom(roomId: string) { const range = this._store.IDBKeyRange.bound( encodeKey(roomId, MIN_UNICODE, MIN_UNICODE), encodeKey(roomId, MAX_UNICODE, MAX_UNICODE) ); - return this._store.delete(range); + this._store.delete(range); } } diff --git a/src/matrix/storage/idb/stores/OlmSessionStore.ts b/src/matrix/storage/idb/stores/OlmSessionStore.ts index 1daa0b21..3310215e 100644 --- a/src/matrix/storage/idb/stores/OlmSessionStore.ts +++ b/src/matrix/storage/idb/stores/OlmSessionStore.ts @@ -71,7 +71,7 @@ export class OlmSessionStore { this._store.put(session as OlmSessionEntry); } - remove(senderKey: string, sessionId: string): Promise { - return this._store.delete(encodeKey(senderKey, sessionId)); + remove(senderKey: string, sessionId: string): void { + this._store.delete(encodeKey(senderKey, sessionId)); } } diff --git a/src/matrix/storage/idb/stores/OperationStore.ts b/src/matrix/storage/idb/stores/OperationStore.ts index cccd8e2d..0e103e8c 100644 --- a/src/matrix/storage/idb/stores/OperationStore.ts +++ b/src/matrix/storage/idb/stores/OperationStore.ts @@ -73,8 +73,8 @@ export class OperationStore { this._store.put(operation as OperationEntry); } - remove(id: string): Promise { - return this._store.delete(id); + remove(id: string): void { + this._store.delete(id); } async removeAllForScope(scope: string): Promise { diff --git a/src/matrix/storage/idb/stores/OutboundGroupSessionStore.ts b/src/matrix/storage/idb/stores/OutboundGroupSessionStore.ts index 82a87625..9712c717 100644 --- a/src/matrix/storage/idb/stores/OutboundGroupSessionStore.ts +++ b/src/matrix/storage/idb/stores/OutboundGroupSessionStore.ts @@ -28,8 +28,8 @@ export class OutboundGroupSessionStore { this._store = store; } - remove(roomId: string): Promise { - return this._store.delete(roomId); + remove(roomId: string): void { + this._store.delete(roomId); } get(roomId: string): Promise { diff --git a/src/matrix/storage/idb/stores/PendingEventStore.ts b/src/matrix/storage/idb/stores/PendingEventStore.ts index 01991ce8..d4bef05a 100644 --- a/src/matrix/storage/idb/stores/PendingEventStore.ts +++ b/src/matrix/storage/idb/stores/PendingEventStore.ts @@ -62,9 +62,9 @@ export class PendingEventStore { } } - remove(roomId: string, queueIndex: number): Promise { + remove(roomId: string, queueIndex: number) { 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 { @@ -86,10 +86,10 @@ export class PendingEventStore { return this._eventStore.selectAll(); } - removeAllForRoom(roomId: string): Promise { + removeAllForRoom(roomId: string): void { const minKey = encodeKey(roomId, KeyLimits.minStorageKey); const maxKey = encodeKey(roomId, KeyLimits.maxStorageKey); const range = this._eventStore.IDBKeyRange.bound(minKey, maxKey); - return this._eventStore.delete(range); + this._eventStore.delete(range); } } diff --git a/src/matrix/storage/idb/stores/RoomStateStore.ts b/src/matrix/storage/idb/stores/RoomStateStore.ts index 441d61e4..b7ece0f7 100644 --- a/src/matrix/storage/idb/stores/RoomStateStore.ts +++ b/src/matrix/storage/idb/stores/RoomStateStore.ts @@ -47,10 +47,10 @@ export class RoomStateStore { this._roomStateStore.put(entry); } - removeAllForRoom(roomId: string): Promise { + removeAllForRoom(roomId: string): void { // 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 const range = this._roomStateStore.IDBKeyRange.bound(roomId, `${roomId}|${MAX_UNICODE}`, true, true); - return this._roomStateStore.delete(range); + this._roomStateStore.delete(range); } } diff --git a/src/matrix/storage/idb/stores/RoomSummaryStore.ts b/src/matrix/storage/idb/stores/RoomSummaryStore.ts index bd911572..43c62fdb 100644 --- a/src/matrix/storage/idb/stores/RoomSummaryStore.ts +++ b/src/matrix/storage/idb/stores/RoomSummaryStore.ts @@ -55,7 +55,7 @@ export class RoomSummaryStore { return roomId === fetchedKey; } - remove(roomId: string): Promise { - return this._summaryStore.delete(roomId); + remove(roomId: string): void { + this._summaryStore.delete(roomId); } } diff --git a/src/matrix/storage/idb/stores/TimelineEventStore.ts b/src/matrix/storage/idb/stores/TimelineEventStore.ts index 49892771..535b8f7e 100644 --- a/src/matrix/storage/idb/stores/TimelineEventStore.ts +++ b/src/matrix/storage/idb/stores/TimelineEventStore.ts @@ -20,6 +20,7 @@ import { encodeUint32 } from "../utils"; import {KeyLimits} from "../../common"; import {Store} from "../Store"; import {TimelineEvent, StateEvent} from "../../types"; +import {LogItem} from "../../../../logging/LogItem.js"; interface Annotation { 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. * @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).eventIdKey = encodeEventIdKey(entry.roomId, entry.event.event_id); - // TODO: map error? or in idb/store? - this._timelineStore.add(entry as TimelineEventStorageEntry); + this._timelineStore.add(entry as TimelineEventStorageEntry, log); } /** Updates the entry into the store with the given [roomId, eventKey] combination. diff --git a/src/matrix/storage/idb/stores/TimelineFragmentStore.ts b/src/matrix/storage/idb/stores/TimelineFragmentStore.ts index 802d6e98..b822700b 100644 --- a/src/matrix/storage/idb/stores/TimelineFragmentStore.ts +++ b/src/matrix/storage/idb/stores/TimelineFragmentStore.ts @@ -100,7 +100,7 @@ export class TimelineFragmentStore { return this._store.get(encodeKey(roomId, fragmentId)); } - removeAllForRoom(roomId: string): Promise { - return this._store.delete(this._allRange(roomId)); + removeAllForRoom(roomId: string): void { + this._store.delete(this._allRange(roomId)); } } diff --git a/src/matrix/storage/idb/stores/TimelineRelationStore.ts b/src/matrix/storage/idb/stores/TimelineRelationStore.ts index 6772864a..88ef86ae 100644 --- a/src/matrix/storage/idb/stores/TimelineRelationStore.ts +++ b/src/matrix/storage/idb/stores/TimelineRelationStore.ts @@ -43,18 +43,18 @@ export class TimelineRelationStore { this._store.add({key: encodeKey(roomId, targetEventId, relType, sourceEventId)}); } - remove(roomId: string, targetEventId: string, relType: string, sourceEventId: string): Promise { - return this._store.delete(encodeKey(roomId, targetEventId, relType, sourceEventId)); + remove(roomId: string, targetEventId: string, relType: string, sourceEventId: string): void { + this._store.delete(encodeKey(roomId, targetEventId, relType, sourceEventId)); } - removeAllForTarget(roomId: string, targetId: string): Promise { + removeAllForTarget(roomId: string, targetId: string): void { const range = this._store.IDBKeyRange.bound( encodeKey(roomId, targetId, MIN_UNICODE, MIN_UNICODE), encodeKey(roomId, targetId, MAX_UNICODE, MAX_UNICODE), true, true ); - return this._store.delete(range); + this._store.delete(range); } async getForTargetAndType(roomId: string, targetId: string, relType: string): Promise { diff --git a/src/matrix/storage/idb/stores/UserIdentityStore.ts b/src/matrix/storage/idb/stores/UserIdentityStore.ts index 1d94a666..692d8384 100644 --- a/src/matrix/storage/idb/stores/UserIdentityStore.ts +++ b/src/matrix/storage/idb/stores/UserIdentityStore.ts @@ -36,7 +36,7 @@ export class UserIdentityStore { this._store.put(userIdentity); } - remove(userId: string): Promise { - return this._store.delete(userId); + remove(userId: string): void { + this._store.delete(userId); } } diff --git a/src/matrix/storage/idb/utils.ts b/src/matrix/storage/idb/utils.ts index bd1683ea..f9209f56 100644 --- a/src/matrix/storage/idb/utils.ts +++ b/src/matrix/storage/idb/utils.ts @@ -17,6 +17,7 @@ limitations under the License. import { IDBRequestError } from "./error"; import { StorageError } from "../common"; +import { AbortError } from "../../../utils/error.js"; let needsSyncPromise = false; @@ -112,22 +113,8 @@ export function txnAsPromise(txn): Promise { // @ts-ignore 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 => { - if (!error) { - 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); + reject(new AbortError()); // @ts-ignore needsSyncPromise && Promise._flush && Promise._flush(); }); diff --git a/src/platform/web/ui/AvatarView.js b/src/platform/web/ui/AvatarView.js index 30fe761f..f2d94e3b 100644 --- a/src/platform/web/ui/AvatarView.js +++ b/src/platform/web/ui/AvatarView.js @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {BaseUpdateView} from "./general/BaseUpdateView.js"; +import {BaseUpdateView} from "./general/BaseUpdateView"; import {renderStaticAvatar, renderImg} from "./avatar.js"; /* @@ -66,7 +66,7 @@ export class AvatarView extends BaseUpdateView { this._avatarTitleChanged(); this._root = renderStaticAvatar(this.value, this._size); // takes care of update being called when needed - super.mount(options); + this.subscribeOnMount(options); return this._root; } diff --git a/src/platform/web/ui/RootView.js b/src/platform/web/ui/RootView.js index f60bb984..a44100a8 100644 --- a/src/platform/web/ui/RootView.js +++ b/src/platform/web/ui/RootView.js @@ -18,7 +18,7 @@ import {SessionView} from "./session/SessionView.js"; import {LoginView} from "./login/LoginView.js"; import {SessionLoadView} from "./login/SessionLoadView.js"; import {SessionPickerView} from "./login/SessionPickerView.js"; -import {TemplateView} from "./general/TemplateView.js"; +import {TemplateView} from "./general/TemplateView"; import {StaticView} from "./general/StaticView.js"; export class RootView extends TemplateView { diff --git a/src/platform/web/ui/avatar.js b/src/platform/web/ui/avatar.js index 2e2b0142..5bc019cb 100644 --- a/src/platform/web/ui/avatar.js +++ b/src/platform/web/ui/avatar.js @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {tag, text, classNames, setAttribute} from "./general/html.js"; +import {tag, text, classNames, setAttribute} from "./general/html"; /** * @param {Object} vm view model with {avatarUrl, avatarColorNumber, avatarTitle, avatarLetter} * @param {Number} size diff --git a/src/platform/web/ui/css/themes/element/icons/chevron-down.svg b/src/platform/web/ui/css/themes/element/icons/chevron-down.svg new file mode 100644 index 00000000..6db33a25 --- /dev/null +++ b/src/platform/web/ui/css/themes/element/icons/chevron-down.svg @@ -0,0 +1,36 @@ + + + + + + + + + + + diff --git a/src/platform/web/ui/css/themes/element/timeline.css b/src/platform/web/ui/css/themes/element/timeline.css index 408d10cc..d2885bca 100644 --- a/src/platform/web/ui/css/themes/element/timeline.css +++ b/src/platform/web/ui/css/themes/element/timeline.css @@ -15,6 +15,20 @@ See the License for the specific language governing permissions and limitations under the License. */ +.Timeline_jumpDown { + width: 40px; + height: 40px; + bottom: 16px; + right: 32px; + border-radius: 100%; + border: 1px solid #8d99a5; + background-image: url("./icons/chevron-down.svg"); + background-position: center; + background-color: white; + background-repeat: no-repeat; + cursor: pointer; +} + .Timeline_message { display: grid; grid-template: @@ -362,3 +376,11 @@ only loads when the top comes into view*/ .GapView > :not(:first-child) { margin-left: 12px; } + +.GapView { + padding: 52px 20px; +} + +.GapView.isAtTop { + padding: 52px 20px 12px 20px; +} diff --git a/src/platform/web/ui/css/timeline.css b/src/platform/web/ui/css/timeline.css index af2d6c14..f214c98f 100644 --- a/src/platform/web/ui/css/timeline.css +++ b/src/platform/web/ui/css/timeline.css @@ -14,13 +14,36 @@ See the License for the specific language governing permissions and limitations under the License. */ +.Timeline { + display: flex; + flex-direction: column; + position: relative; + min-height: 0; +} -.RoomView_body > ul { - overflow-y: auto; - overscroll-behavior: contain; - list-style: none; +.Timeline_jumpDown { + position: absolute; +} + +.Timeline_scroller { + overflow-y: scroll; + overscroll-behavior-y: contain; + overflow-anchor: none; padding: 0; margin: 0; + /* need to read the offsetTop of tiles relative to this element in TimelineView */ + position: relative; + min-height: 0; + flex: 1 0 0; +} + +.Timeline_scroller > ul { + list-style: none; + /* use small horizontal padding so first/last children margin isn't collapsed + at the edge and a scrollbar shows up when setting margin-top to bottom-align + content when there are not yet enough tiles to fill the viewport */ + padding: 1px 0; + margin: 0; } .message-container { @@ -49,13 +72,7 @@ limitations under the License. } .GapView { - visibility: hidden; display: flex; - padding: 10px 20px; -} - -.GapView.isLoading { - visibility: visible; } .GapView > :nth-child(2) { diff --git a/src/platform/web/ui/general/BaseUpdateView.js b/src/platform/web/ui/general/BaseUpdateView.ts similarity index 63% rename from src/platform/web/ui/general/BaseUpdateView.js rename to src/platform/web/ui/general/BaseUpdateView.ts index 4f346499..2eb4d40f 100644 --- a/src/platform/web/ui/general/BaseUpdateView.js +++ b/src/platform/web/ui/general/BaseUpdateView.ts @@ -1,5 +1,6 @@ /* Copyright 2021 The Matrix.org Foundation C.I.C. +Copyright 2021 Daniel Fedorin Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -14,40 +15,54 @@ See the License for the specific language governing permissions and limitations under the License. */ -export class BaseUpdateView { - constructor(value) { +import {IMountArgs, ViewNode, IView} from "./types"; + +export interface IObservableValue { + on?(event: "change", handler: (props?: string[]) => void): void; + off?(event: "change", handler: (props?: string[]) => void): void; +} + +export abstract class BaseUpdateView implements IView { + protected _value: T + protected _boundUpdateFromValue: ((props?: string[]) => void) | null + + abstract mount(args?: IMountArgs): ViewNode; + abstract root(): ViewNode | undefined; + abstract update(...any); + + constructor(value :T) { this._value = value; // TODO: can avoid this if we adopt the handleEvent pattern in our EventListener this._boundUpdateFromValue = null; } - mount(options) { + subscribeOnMount(options?: IMountArgs): void { const parentProvidesUpdates = options && options.parentProvidesUpdates; if (!parentProvidesUpdates) { this._subscribe(); } } - unmount() { + unmount(): void { this._unsubscribe(); } - get value() { + get value(): T { return this._value; } - _updateFromValue(changedProps) { + _updateFromValue(changedProps?: string[]) { this.update(this._value, changedProps); } - _subscribe() { + _subscribe(): void { if (typeof this._value?.on === "function") { - this._boundUpdateFromValue = this._updateFromValue.bind(this); + this._boundUpdateFromValue = this._updateFromValue.bind(this) as (props?: string[]) => void; this._value.on("change", this._boundUpdateFromValue); } } - _unsubscribe() { + _unsubscribe(): void { if (this._boundUpdateFromValue) { if (typeof this._value.off === "function") { this._value.off("change", this._boundUpdateFromValue); diff --git a/src/platform/web/ui/general/LazyListView.js b/src/platform/web/ui/general/LazyListView.js index 4795ce5a..4426d97c 100644 --- a/src/platform/web/ui/general/LazyListView.js +++ b/src/platform/web/ui/general/LazyListView.js @@ -14,9 +14,10 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {el} from "./html.js"; -import {mountView} from "./utils.js"; -import {insertAt, ListView} from "./ListView.js"; +import {el} from "./html"; +import {mountView} from "./utils"; +import {ListView} from "./ListView"; +import {insertAt} from "./utils"; class ItemRange { constructor(topCount, renderCount, bottomCount) { diff --git a/src/platform/web/ui/general/ListView.js b/src/platform/web/ui/general/ListView.js deleted file mode 100644 index 74aa9d87..00000000 --- a/src/platform/web/ui/general/ListView.js +++ /dev/null @@ -1,163 +0,0 @@ -/* -Copyright 2020 Bruno Windels - -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() {} -} diff --git a/src/platform/web/ui/general/ListView.ts b/src/platform/web/ui/general/ListView.ts new file mode 100644 index 00000000..cdfdbf76 --- /dev/null +++ b/src/platform/web/ui/general/ListView.ts @@ -0,0 +1,182 @@ +/* +Copyright 2020 Bruno Windels + +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 { + list: ObservableList, + onItemClick?: (childView: V, evt: UIEvent) => void, + className?: string, + tagName?: string, + parentProvidesUpdates?: boolean +} + +type SubscriptionHandle = () => undefined; + +export class ListView implements IView { + + private _onItemClick?: (childView: V, evt: UIEvent) => void; + private _list: ObservableList; + 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, + 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) { + 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]; + } +} diff --git a/src/platform/web/ui/general/LoadingView.js b/src/platform/web/ui/general/LoadingView.js index b85eed04..f2ab20cc 100644 --- a/src/platform/web/ui/general/LoadingView.js +++ b/src/platform/web/ui/general/LoadingView.js @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {TemplateView} from "./TemplateView.js"; +import {TemplateView} from "./TemplateView"; import {spinner} from "../common.js"; export class LoadingView extends TemplateView { diff --git a/src/platform/web/ui/general/Menu.js b/src/platform/web/ui/general/Menu.js index b846b0e5..6094cbfb 100644 --- a/src/platform/web/ui/general/Menu.js +++ b/src/platform/web/ui/general/Menu.js @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {TemplateView} from "./TemplateView.js"; +import {TemplateView} from "./TemplateView"; export class Menu extends TemplateView { static option(label, callback) { diff --git a/src/platform/web/ui/general/Popup.js b/src/platform/web/ui/general/Popup.js index ac5e3160..e630d398 100644 --- a/src/platform/web/ui/general/Popup.js +++ b/src/platform/web/ui/general/Popup.js @@ -169,7 +169,7 @@ export class Popup { return true; } - /* fake UIView api, so it can be tracked by a template view as a subview */ + /* fake IView api, so it can be tracked by a template view as a subview */ root() { return this._fakeRoot; } diff --git a/src/platform/web/ui/general/StaticView.js b/src/platform/web/ui/general/StaticView.js index c87a0f3c..1c3f3ea2 100644 --- a/src/platform/web/ui/general/StaticView.js +++ b/src/platform/web/ui/general/StaticView.js @@ -15,7 +15,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {tag} from "../general/html.js"; +import {tag} from "../general/html"; export class StaticView { constructor(value, render = undefined) { diff --git a/src/platform/web/ui/general/TemplateView.js b/src/platform/web/ui/general/TemplateView.ts similarity index 64% rename from src/platform/web/ui/general/TemplateView.js rename to src/platform/web/ui/general/TemplateView.ts index 4b2bcf74..93dc8172 100644 --- a/src/platform/web/ui/general/TemplateView.js +++ b/src/platform/web/ui/general/TemplateView.ts @@ -1,5 +1,6 @@ /* Copyright 2020 Bruno Windels +Copyright 2021 Daniel Fedorin Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -14,11 +15,12 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { setAttribute, text, isChildren, classNames, TAG_NAMES, HTML_NS } from "./html.js"; -import {mountView} from "./utils.js"; -import {BaseUpdateView} from "./BaseUpdateView.js"; +import { setAttribute, text, isChildren, classNames, TAG_NAMES, HTML_NS, ClassNames, Child} from "./html"; +import {mountView} from "./utils"; +import {BaseUpdateView, IObservableValue} from "./BaseUpdateView"; +import {IMountArgs, ViewNode, IView} from "./types"; -function objHasFns(obj) { +function objHasFns(obj: ClassNames): obj is { [className: string]: boolean } { for(const value of Object.values(obj)) { if (typeof value === "function") { return true; @@ -26,6 +28,17 @@ function objHasFns(obj) { } return false; } + + +export type RenderFn = (t: Builder, vm: T) => ViewNode; +type EventHandler = ((event: Event) => void); +type AttributeStaticValue = string | boolean; +type AttributeBinding = (value: T) => AttributeStaticValue; +export type AttrValue = AttributeStaticValue | AttributeBinding | EventHandler | ClassNames; +export type Attributes = { [attribute: string]: AttrValue }; +type ElementFn = (attributes?: Attributes | Child | Child[], children?: Child | Child[]) => Element; +export type Builder = TemplateBuilder & { [tagName in typeof TAG_NAMES[string][number]]: ElementFn }; + /** Bindable template. Renders once, and allows bindings for given nodes. If you need to change the structure on a condition, use a subtemplate (if) @@ -39,18 +52,21 @@ function objHasFns(obj) { - add subviews inside the template */ // TODO: should we rename this to BoundView or something? As opposed to StaticView ... -export class TemplateView extends BaseUpdateView { - constructor(value, render = undefined) { +export class TemplateView extends BaseUpdateView { + private _render?: RenderFn; + private _eventListeners?: { node: Element, name: string, fn: EventHandler, useCapture: boolean }[] = undefined; + private _bindings?: (() => void)[] = undefined; + private _root?: ViewNode = undefined; + // public because used by TemplateBuilder + _subViews?: IView[] = undefined; + + constructor(value: T, render?: RenderFn) { super(value); // TODO: can avoid this if we have a separate class for inline templates vs class template views this._render = render; - this._eventListeners = null; - this._bindings = null; - this._subViews = null; - this._root = null; } - _attach() { + _attach(): void { if (this._eventListeners) { for (let {node, name, fn, useCapture} of this._eventListeners) { node.addEventListener(name, fn, useCapture); @@ -58,7 +74,7 @@ export class TemplateView extends BaseUpdateView { } } - _detach() { + _detach(): void { if (this._eventListeners) { for (let {node, name, fn, useCapture} of this._eventListeners) { node.removeEventListener(name, fn, useCapture); @@ -66,13 +82,13 @@ export class TemplateView extends BaseUpdateView { } } - mount(options) { - const builder = new TemplateBuilder(this); + mount(options?: IMountArgs): ViewNode { + const builder = new TemplateBuilder(this) as Builder; try { if (this._render) { this._root = this._render(builder, this._value); - } else if (this.render) { // overriden in subclass - this._root = this.render(builder, this._value); + } else if (this["render"]) { // overriden in subclass + this._root = this["render"](builder, this._value); } else { throw new Error("no render function passed in, or overriden in subclass"); } @@ -80,12 +96,12 @@ export class TemplateView extends BaseUpdateView { builder.close(); } // takes care of update being called when needed - super.mount(options); + this.subscribeOnMount(options); this._attach(); - return this._root; + return this._root!; } - unmount() { + unmount(): void { this._detach(); super.unmount(); if (this._subViews) { @@ -95,11 +111,11 @@ export class TemplateView extends BaseUpdateView { } } - root() { + root(): ViewNode | undefined { return this._root; } - update(value) { + update(value: T, props?: string[]): void { this._value = value; if (this._bindings) { for (const binding of this._bindings) { @@ -108,35 +124,36 @@ export class TemplateView extends BaseUpdateView { } } - _addEventListener(node, name, fn, useCapture = false) { + _addEventListener(node: Element, name: string, fn: (event: Event) => void, useCapture: boolean = false): void { if (!this._eventListeners) { this._eventListeners = []; } this._eventListeners.push({node, name, fn, useCapture}); } - _addBinding(bindingFn) { + _addBinding(bindingFn: () => void): void { if (!this._bindings) { this._bindings = []; } this._bindings.push(bindingFn); } - addSubView(view) { + addSubView(view: IView): void { if (!this._subViews) { this._subViews = []; } this._subViews.push(view); } - removeSubView(view) { + removeSubView(view: IView): void { + if (!this._subViews) { return; } const idx = this._subViews.indexOf(view); if (idx !== -1) { this._subViews.splice(idx, 1); } } - updateSubViews(value, props) { + updateSubViews(value: IObservableValue, props: string[]) { if (this._subViews) { for (const v of this._subViews) { v.update(value, props); @@ -146,33 +163,35 @@ export class TemplateView extends BaseUpdateView { } // what is passed to render -class TemplateBuilder { - constructor(templateView) { +export class TemplateBuilder { + private _templateView: TemplateView; + private _closed: boolean = false; + + constructor(templateView: TemplateView) { this._templateView = templateView; - this._closed = false; } - close() { + close(): void { this._closed = true; } - _addBinding(fn) { + _addBinding(fn: () => void): void { if (this._closed) { console.trace("Adding a binding after render will likely cause memory leaks"); } this._templateView._addBinding(fn); } - get _value() { - return this._templateView._value; + get _value(): T { + return this._templateView.value; } - addEventListener(node, name, fn, useCapture = false) { + addEventListener(node: Element, name: string, fn: (event: Event) => void, useCapture: boolean = false): void { this._templateView._addEventListener(node, name, fn, useCapture); } - _addAttributeBinding(node, name, fn) { - let prevValue = undefined; + _addAttributeBinding(node: Element, name: string, fn: (value: T) => boolean | string): void { + let prevValue: string | boolean | undefined = undefined; const binding = () => { const newValue = fn(this._value); if (prevValue !== newValue) { @@ -184,11 +203,11 @@ class TemplateBuilder { binding(); } - _addClassNamesBinding(node, obj) { + _addClassNamesBinding(node: Element, obj: ClassNames): void { this._addAttributeBinding(node, "className", value => classNames(obj, value)); } - _addTextBinding(fn) { + _addTextBinding(fn: (value: T) => string): Text { const initialValue = fn(this._value); const node = text(initialValue); let prevValue = initialValue; @@ -204,21 +223,30 @@ class TemplateBuilder { return node; } - _setNodeAttributes(node, attributes) { + _isEventHandler(key: string, value: AttrValue): value is (event: Event) => void { + // This isn't actually safe, but it's incorrect to feed event handlers to + // non-on* attributes. + return key.startsWith("on") && key.length > 2 && typeof value === "function"; + } + + _setNodeAttributes(node: Element, attributes: Attributes): void { for(let [key, value] of Object.entries(attributes)) { - const isFn = typeof value === "function"; // binding for className as object of className => enabled - if (key === "className" && typeof value === "object" && value !== null) { + if (typeof value === "object") { + if (key !== "className" || value === null) { + // Ignore non-className objects. + continue; + } if (objHasFns(value)) { this._addClassNamesBinding(node, value); } else { - setAttribute(node, key, classNames(value)); + setAttribute(node, key, classNames(value, this._value)); } - } else if (key.startsWith("on") && key.length > 2 && isFn) { + } else if (this._isEventHandler(key, value)) { const eventName = key.substr(2, 1).toLowerCase() + key.substr(3); const handler = value; this._templateView._addEventListener(node, eventName, handler); - } else if (isFn) { + } else if (typeof value === "function") { this._addAttributeBinding(node, key, value); } else { setAttribute(node, key, value); @@ -226,14 +254,14 @@ class TemplateBuilder { } } - _setNodeChildren(node, children) { + _setNodeChildren(node: Element, children: Child | Child[]): void{ if (!Array.isArray(children)) { children = [children]; } for (let child of children) { if (typeof child === "function") { child = this._addTextBinding(child); - } else if (!child.nodeType) { + } else if (typeof child === "string") { // not a DOM node, turn into text child = text(child); } @@ -241,7 +269,7 @@ class TemplateBuilder { } } - _addReplaceNodeBinding(fn, renderNode) { + _addReplaceNodeBinding(fn: (value: T) => R, renderNode: (old: ViewNode | null) => ViewNode): ViewNode { let prevValue = fn(this._value); let node = renderNode(null); @@ -260,14 +288,14 @@ class TemplateBuilder { return node; } - el(name, attributes, children) { + el(name: string, attributes?: Attributes | Child | Child[], children?: Child | Child[]): ViewNode { return this.elNS(HTML_NS, name, attributes, children); } - elNS(ns, name, attributes, children) { - if (attributes && isChildren(attributes)) { + elNS(ns: string, name: string, attributes?: Attributes | Child | Child[], children?: Child | Child[]): ViewNode { + if (attributes !== undefined && isChildren(attributes)) { children = attributes; - attributes = null; + attributes = undefined; } const node = document.createElementNS(ns, name); @@ -284,20 +312,22 @@ class TemplateBuilder { // this inserts a view, and is not a view factory for `if`, so returns the root element to insert in the template // you should not call t.view() and not use the result (e.g. attach the result to the template DOM tree). - view(view, mountOptions = undefined) { + view(view: IView, mountOptions?: IMountArgs): ViewNode { this._templateView.addSubView(view); return mountView(view, mountOptions); } // map a value to a view, every time the value changes - mapView(mapFn, viewCreator) { + mapView(mapFn: (value: T) => R, viewCreator: (mapped: R) => IView | null): ViewNode { return this._addReplaceNodeBinding(mapFn, (prevNode) => { if (prevNode && prevNode.nodeType !== Node.COMMENT_NODE) { const subViews = this._templateView._subViews; - const viewIdx = subViews.findIndex(v => v.root() === prevNode); - if (viewIdx !== -1) { - const [view] = subViews.splice(viewIdx, 1); - view.unmount(); + if (subViews) { + const viewIdx = subViews.findIndex(v => v.root() === prevNode); + if (viewIdx !== -1) { + const [view] = subViews.splice(viewIdx, 1); + view.unmount(); + } } } const view = viewCreator(mapFn(this._value)); @@ -312,7 +342,7 @@ class TemplateBuilder { // Special case of mapView for a TemplateView. // Always creates a TemplateView, if this is optional depending // on mappedValue, use `if` or `mapView` - map(mapFn, renderFn) { + map(mapFn: (value: T) => R, renderFn: (mapped: R, t: Builder, vm: T) => ViewNode): ViewNode { return this.mapView(mapFn, mappedValue => { return new TemplateView(this._value, (t, vm) => { const rootNode = renderFn(mappedValue, t, vm); @@ -326,7 +356,7 @@ class TemplateBuilder { }); } - ifView(predicate, viewCreator) { + ifView(predicate: (value: T) => boolean, viewCreator: (value: T) => IView): ViewNode { return this.mapView( value => !!predicate(value), enabled => enabled ? viewCreator(this._value) : null @@ -335,7 +365,7 @@ class TemplateBuilder { // creates a conditional subtemplate // use mapView if you need to map to a different view class - if(predicate, renderFn) { + if(predicate: (value: T) => boolean, renderFn: (t: Builder, vm: T) => ViewNode) { return this.ifView(predicate, vm => new TemplateView(vm, renderFn)); } @@ -345,8 +375,8 @@ class TemplateBuilder { This should only be used if the side-effect won't add any bindings, event handlers, ... You should not call the TemplateBuilder (e.g. `t.xxx()`) at all from the side effect, - instead use tags from html.js to help you construct any DOM you need. */ - mapSideEffect(mapFn, sideEffect) { + instead use tags from html.ts to help you construct any DOM you need. */ + mapSideEffect(mapFn: (value: T) => R, sideEffect: (newV: R, oldV: R | undefined) => void) { let prevValue = mapFn(this._value); const binding = () => { const newValue = mapFn(this._value); diff --git a/src/platform/web/ui/general/html.js b/src/platform/web/ui/general/html.ts similarity index 60% rename from src/platform/web/ui/general/html.js rename to src/platform/web/ui/general/html.ts index f12ad306..9ebcfaaf 100644 --- a/src/platform/web/ui/general/html.js +++ b/src/platform/web/ui/general/html.ts @@ -1,5 +1,6 @@ /* Copyright 2020 Bruno Windels +Copyright 2021 Daniel Fedorin Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -16,12 +17,18 @@ limitations under the License. // DOM helper functions -export function isChildren(children) { +import {ViewNode} from "./types"; + +export type ClassNames = { [className: string]: boolean | ((value: T) => boolean) } +export type BasicAttributes = { [attribute: string]: ClassNames | boolean | string } +export type Child = string | Text | ViewNode; + +export function isChildren(children: object | Child | Child[]): children is Child | Child[] { // children should be an not-object (that's the attributes), or a domnode, or an array - return typeof children !== "object" || !!children.nodeType || Array.isArray(children); + return typeof children !== "object" || "nodeType" in children || Array.isArray(children); } -export function classNames(obj, value) { +export function classNames(obj: ClassNames, value: T): string { return Object.entries(obj).reduce((cn, [name, enabled]) => { if (typeof enabled === "function") { enabled = enabled(value); @@ -34,7 +41,7 @@ export function classNames(obj, value) { }, ""); } -export function setAttribute(el, name, value) { +export function setAttribute(el: Element, name: string, value: string | boolean): void { if (name === "className") { name = "class"; } @@ -48,22 +55,24 @@ export function setAttribute(el, name, value) { } } -export function el(elementName, attributes, children) { +export function el(elementName: string, attributes?: BasicAttributes | Child | Child[], children?: Child | Child[]): Element { return elNS(HTML_NS, elementName, attributes, children); } -export function elNS(ns, elementName, attributes, children) { +export function elNS(ns: string, elementName: string, attributes?: BasicAttributes | Child | Child[], children?: Child | Child[]): Element { if (attributes && isChildren(attributes)) { children = attributes; - attributes = null; + attributes = undefined; } const e = document.createElementNS(ns, elementName); if (attributes) { for (let [name, value] of Object.entries(attributes)) { - if (name === "className" && typeof value === "object" && value !== null) { - value = classNames(value); + if (typeof value === "object") { + // Only className should ever be an object; be careful + // here anyway and ignore object-valued non-className attributes. + value = (value !== null && name === "className") ? classNames(value, undefined) : false; } setAttribute(e, name, value); } @@ -74,7 +83,7 @@ export function elNS(ns, elementName, attributes, children) { children = [children]; } for (let c of children) { - if (!c.nodeType) { + if (typeof c === "string") { c = text(c); } e.appendChild(c); @@ -83,12 +92,12 @@ export function elNS(ns, elementName, attributes, children) { return e; } -export function text(str) { +export function text(str: string): Text { return document.createTextNode(str); } -export const HTML_NS = "http://www.w3.org/1999/xhtml"; -export const SVG_NS = "http://www.w3.org/2000/svg"; +export const HTML_NS: string = "http://www.w3.org/1999/xhtml"; +export const SVG_NS: string = "http://www.w3.org/2000/svg"; export const TAG_NAMES = { [HTML_NS]: [ @@ -97,10 +106,9 @@ export const TAG_NAMES = { "table", "thead", "tbody", "tr", "th", "td", "hr", "pre", "code", "button", "time", "input", "textarea", "label", "form", "progress", "output", "video"], [SVG_NS]: ["svg", "circle"] -}; - -export const tag = {}; +} as const; +export const tag: { [tagName in typeof TAG_NAMES[string][number]]: (attributes?: BasicAttributes | Child | Child[], children?: Child | Child[]) => Element } = {} as any; for (const [ns, tags] of Object.entries(TAG_NAMES)) { for (const tagName of tags) { diff --git a/src/platform/web/ui/general/types.ts b/src/platform/web/ui/general/types.ts new file mode 100644 index 00000000..1d9122aa --- /dev/null +++ b/src/platform/web/ui/general/types.ts @@ -0,0 +1,30 @@ +/* +Copyright 2021 The Matrix.org Foundation C.I.C. +Copyright 2021 Daniel Fedorin + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +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 +} diff --git a/src/platform/web/ui/general/utils.js b/src/platform/web/ui/general/utils.js deleted file mode 100644 index d74de690..00000000 --- a/src/platform/web/ui/general/utils.js +++ /dev/null @@ -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; -} \ No newline at end of file diff --git a/src/platform/web/ui/general/error.js b/src/platform/web/ui/general/utils.ts similarity index 51% rename from src/platform/web/ui/general/error.js rename to src/platform/web/ui/general/utils.ts index 48728a4b..7eb1d7f9 100644 --- a/src/platform/web/ui/general/error.js +++ b/src/platform/web/ui/general/utils.ts @@ -1,5 +1,5 @@ /* -Copyright 2020 Bruno Windels +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. @@ -14,11 +14,22 @@ See the License for the specific language governing permissions and 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; - let callee = null; + let callee: string | null = null; if (stack) { callee = stack.split("\n")[1]; } @@ -29,3 +40,13 @@ export function errorToDOM(error) { 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); + } +} diff --git a/src/platform/web/ui/login/CompleteSSOView.js b/src/platform/web/ui/login/CompleteSSOView.js index 63614acf..20f4c0ad 100644 --- a/src/platform/web/ui/login/CompleteSSOView.js +++ b/src/platform/web/ui/login/CompleteSSOView.js @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {TemplateView} from "../general/TemplateView.js"; +import {TemplateView} from "../general/TemplateView"; import {SessionLoadStatusView} from "./SessionLoadStatusView.js"; export class CompleteSSOView extends TemplateView { diff --git a/src/platform/web/ui/login/LoginView.js b/src/platform/web/ui/login/LoginView.js index aa89ccca..e8e1a0bf 100644 --- a/src/platform/web/ui/login/LoginView.js +++ b/src/platform/web/ui/login/LoginView.js @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {TemplateView} from "../general/TemplateView.js"; +import {TemplateView} from "../general/TemplateView"; import {hydrogenGithubLink} from "./common.js"; import {PasswordLoginView} from "./PasswordLoginView.js"; import {CompleteSSOView} from "./CompleteSSOView.js"; diff --git a/src/platform/web/ui/login/PasswordLoginView.js b/src/platform/web/ui/login/PasswordLoginView.js index 130f30ae..4360cc0f 100644 --- a/src/platform/web/ui/login/PasswordLoginView.js +++ b/src/platform/web/ui/login/PasswordLoginView.js @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {TemplateView} from "../general/TemplateView.js"; +import {TemplateView} from "../general/TemplateView"; export class PasswordLoginView extends TemplateView { render(t, vm) { diff --git a/src/platform/web/ui/login/SessionLoadStatusView.js b/src/platform/web/ui/login/SessionLoadStatusView.js index 888e46b4..d3ff2c9a 100644 --- a/src/platform/web/ui/login/SessionLoadStatusView.js +++ b/src/platform/web/ui/login/SessionLoadStatusView.js @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {TemplateView} from "../general/TemplateView.js"; +import {TemplateView} from "../general/TemplateView"; import {spinner} from "../common.js"; /** a view used both in the login view and the loading screen diff --git a/src/platform/web/ui/login/SessionLoadView.js b/src/platform/web/ui/login/SessionLoadView.js index 6837cf21..4f546b70 100644 --- a/src/platform/web/ui/login/SessionLoadView.js +++ b/src/platform/web/ui/login/SessionLoadView.js @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {TemplateView} from "../general/TemplateView.js"; +import {TemplateView} from "../general/TemplateView"; import {SessionLoadStatusView} from "./SessionLoadStatusView.js"; export class SessionLoadView extends TemplateView { diff --git a/src/platform/web/ui/login/SessionPickerView.js b/src/platform/web/ui/login/SessionPickerView.js index 4bb69ee2..775aa19b 100644 --- a/src/platform/web/ui/login/SessionPickerView.js +++ b/src/platform/web/ui/login/SessionPickerView.js @@ -14,8 +14,8 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {ListView} from "../general/ListView.js"; -import {TemplateView} from "../general/TemplateView.js"; +import {ListView} from "../general/ListView"; +import {TemplateView} from "../general/TemplateView"; import {hydrogenGithubLink} from "./common.js"; import {SessionLoadStatusView} from "./SessionLoadStatusView.js"; diff --git a/src/platform/web/ui/session/RoomGridView.js b/src/platform/web/ui/session/RoomGridView.js index 043137fb..fc6497da 100644 --- a/src/platform/web/ui/session/RoomGridView.js +++ b/src/platform/web/ui/session/RoomGridView.js @@ -16,7 +16,7 @@ limitations under the License. import {RoomView} from "./room/RoomView.js"; import {InviteView} from "./room/InviteView.js"; -import {TemplateView} from "../general/TemplateView.js"; +import {TemplateView} from "../general/TemplateView"; import {StaticView} from "../general/StaticView.js"; export class RoomGridView extends TemplateView { diff --git a/src/platform/web/ui/session/SessionStatusView.js b/src/platform/web/ui/session/SessionStatusView.js index fff25453..bd8c6dbb 100644 --- a/src/platform/web/ui/session/SessionStatusView.js +++ b/src/platform/web/ui/session/SessionStatusView.js @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {TemplateView} from "../general/TemplateView.js"; +import {TemplateView} from "../general/TemplateView"; import {spinner} from "../common.js"; export class SessionStatusView extends TemplateView { diff --git a/src/platform/web/ui/session/SessionView.js b/src/platform/web/ui/session/SessionView.js index 0cda8428..f2ac2971 100644 --- a/src/platform/web/ui/session/SessionView.js +++ b/src/platform/web/ui/session/SessionView.js @@ -20,7 +20,7 @@ import {RoomView} from "./room/RoomView.js"; import {UnknownRoomView} from "./room/UnknownRoomView.js"; import {InviteView} from "./room/InviteView.js"; import {LightboxView} from "./room/LightboxView.js"; -import {TemplateView} from "../general/TemplateView.js"; +import {TemplateView} from "../general/TemplateView"; import {StaticView} from "../general/StaticView.js"; import {SessionStatusView} from "./SessionStatusView.js"; import {RoomGridView} from "./RoomGridView.js"; diff --git a/src/platform/web/ui/session/leftpanel/InviteTileView.js b/src/platform/web/ui/session/leftpanel/InviteTileView.js index 09b9401f..b99ab1c6 100644 --- a/src/platform/web/ui/session/leftpanel/InviteTileView.js +++ b/src/platform/web/ui/session/leftpanel/InviteTileView.js @@ -15,7 +15,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {TemplateView} from "../../general/TemplateView.js"; +import {TemplateView} from "../../general/TemplateView"; import {renderStaticAvatar} from "../../avatar.js"; import {spinner} from "../../common.js"; diff --git a/src/platform/web/ui/session/leftpanel/LeftPanelView.js b/src/platform/web/ui/session/leftpanel/LeftPanelView.js index 8757ad42..7742b93e 100644 --- a/src/platform/web/ui/session/leftpanel/LeftPanelView.js +++ b/src/platform/web/ui/session/leftpanel/LeftPanelView.js @@ -14,8 +14,8 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {ListView} from "../../general/ListView.js"; -import {TemplateView} from "../../general/TemplateView.js"; +import {ListView} from "../../general/ListView"; +import {TemplateView} from "../../general/TemplateView"; import {RoomTileView} from "./RoomTileView.js"; import {InviteTileView} from "./InviteTileView.js"; diff --git a/src/platform/web/ui/session/leftpanel/RoomTileView.js b/src/platform/web/ui/session/leftpanel/RoomTileView.js index 228addba..28957541 100644 --- a/src/platform/web/ui/session/leftpanel/RoomTileView.js +++ b/src/platform/web/ui/session/leftpanel/RoomTileView.js @@ -15,7 +15,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {TemplateView} from "../../general/TemplateView.js"; +import {TemplateView} from "../../general/TemplateView"; import {AvatarView} from "../../AvatarView.js"; export class RoomTileView extends TemplateView { diff --git a/src/platform/web/ui/session/rightpanel/MemberDetailsView.js b/src/platform/web/ui/session/rightpanel/MemberDetailsView.js index 5ada0912..c249515a 100644 --- a/src/platform/web/ui/session/rightpanel/MemberDetailsView.js +++ b/src/platform/web/ui/session/rightpanel/MemberDetailsView.js @@ -15,7 +15,7 @@ limitations under the License. */ import {AvatarView} from "../../AvatarView.js"; -import {TemplateView} from "../../general/TemplateView.js"; +import {TemplateView} from "../../general/TemplateView"; export class MemberDetailsView extends TemplateView { render(t, vm) { diff --git a/src/platform/web/ui/session/rightpanel/MemberTileView.js b/src/platform/web/ui/session/rightpanel/MemberTileView.js index df9f597a..52a14cd5 100644 --- a/src/platform/web/ui/session/rightpanel/MemberTileView.js +++ b/src/platform/web/ui/session/rightpanel/MemberTileView.js @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {TemplateView} from "../../general/TemplateView.js"; +import {TemplateView} from "../../general/TemplateView"; import {AvatarView} from "../../AvatarView.js"; export class MemberTileView extends TemplateView { diff --git a/src/platform/web/ui/session/rightpanel/RightPanelView.js b/src/platform/web/ui/session/rightpanel/RightPanelView.js index b94d306e..5297d2ef 100644 --- a/src/platform/web/ui/session/rightpanel/RightPanelView.js +++ b/src/platform/web/ui/session/rightpanel/RightPanelView.js @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {TemplateView} from "../../general/TemplateView.js"; +import {TemplateView} from "../../general/TemplateView"; import {RoomDetailsView} from "./RoomDetailsView.js"; import {MemberListView} from "./MemberListView.js"; import {LoadingView} from "../../general/LoadingView.js"; diff --git a/src/platform/web/ui/session/rightpanel/RoomDetailsView.js b/src/platform/web/ui/session/rightpanel/RoomDetailsView.js index a9c4c109..a9524e09 100644 --- a/src/platform/web/ui/session/rightpanel/RoomDetailsView.js +++ b/src/platform/web/ui/session/rightpanel/RoomDetailsView.js @@ -14,8 +14,8 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {TemplateView} from "../../general/TemplateView.js"; -import {classNames, tag} from "../../general/html.js"; +import {TemplateView} from "../../general/TemplateView"; +import {classNames, tag} from "../../general/html"; import {AvatarView} from "../../AvatarView.js"; export class RoomDetailsView extends TemplateView { diff --git a/src/platform/web/ui/session/room/InviteView.js b/src/platform/web/ui/session/room/InviteView.js index 1d1e7db4..9d808abf 100644 --- a/src/platform/web/ui/session/room/InviteView.js +++ b/src/platform/web/ui/session/room/InviteView.js @@ -15,7 +15,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {TemplateView} from "../../general/TemplateView.js"; +import {TemplateView} from "../../general/TemplateView"; import {renderStaticAvatar} from "../../avatar.js"; export class InviteView extends TemplateView { diff --git a/src/platform/web/ui/session/room/LightboxView.js b/src/platform/web/ui/session/room/LightboxView.js index 16d5666f..9fbf392a 100644 --- a/src/platform/web/ui/session/room/LightboxView.js +++ b/src/platform/web/ui/session/room/LightboxView.js @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {TemplateView} from "../../general/TemplateView.js"; +import {TemplateView} from "../../general/TemplateView"; import {spinner} from "../../common.js"; export class LightboxView extends TemplateView { diff --git a/src/platform/web/ui/session/room/MessageComposer.js b/src/platform/web/ui/session/room/MessageComposer.js index c6e8a1ee..b82e0ffd 100644 --- a/src/platform/web/ui/session/room/MessageComposer.js +++ b/src/platform/web/ui/session/room/MessageComposer.js @@ -14,10 +14,10 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {TemplateView} from "../../general/TemplateView.js"; +import {TemplateView} from "../../general/TemplateView"; import {Popup} from "../../general/Popup.js"; import {Menu} from "../../general/Menu.js"; -import {viewClassForEntry} from "./TimelineList.js" +import {viewClassForEntry} from "./TimelineView" export class MessageComposer extends TemplateView { constructor(viewModel) { diff --git a/src/platform/web/ui/session/room/RoomArchivedView.js b/src/platform/web/ui/session/room/RoomArchivedView.js index 80b18a08..1db1c2d2 100644 --- a/src/platform/web/ui/session/room/RoomArchivedView.js +++ b/src/platform/web/ui/session/room/RoomArchivedView.js @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {TemplateView} from "../../general/TemplateView.js"; +import {TemplateView} from "../../general/TemplateView"; export class RoomArchivedView extends TemplateView { render(t) { diff --git a/src/platform/web/ui/session/room/RoomView.js b/src/platform/web/ui/session/room/RoomView.js index 71eb1e13..f100d796 100644 --- a/src/platform/web/ui/session/room/RoomView.js +++ b/src/platform/web/ui/session/room/RoomView.js @@ -15,10 +15,10 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {TemplateView} from "../../general/TemplateView.js"; +import {TemplateView} from "../../general/TemplateView"; import {Popup} from "../../general/Popup.js"; import {Menu} from "../../general/Menu.js"; -import {TimelineList} from "./TimelineList.js"; +import {TimelineView} from "./TimelineView"; import {TimelineLoadingView} from "./TimelineLoadingView.js"; import {MessageComposer} from "./MessageComposer.js"; import {RoomArchivedView} from "./RoomArchivedView.js"; @@ -54,7 +54,7 @@ export class RoomView extends TemplateView { t.div({className: "RoomView_error"}, vm => vm.error), t.mapView(vm => vm.timelineViewModel, timelineViewModel => { return timelineViewModel ? - new TimelineList(timelineViewModel) : + new TimelineView(timelineViewModel) : new TimelineLoadingView(vm); // vm is just needed for i18n }), t.view(bottomView), diff --git a/src/platform/web/ui/session/room/TimelineList.js b/src/platform/web/ui/session/room/TimelineList.js deleted file mode 100644 index e0179e5d..00000000 --- a/src/platform/web/ui/session/room/TimelineList.js +++ /dev/null @@ -1,165 +0,0 @@ -/* -Copyright 2020 Bruno Windels - -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); - } -} diff --git a/src/platform/web/ui/session/room/TimelineLoadingView.js b/src/platform/web/ui/session/room/TimelineLoadingView.js index 503c2243..0f030421 100644 --- a/src/platform/web/ui/session/room/TimelineLoadingView.js +++ b/src/platform/web/ui/session/room/TimelineLoadingView.js @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {TemplateView} from "../../general/TemplateView.js"; +import {TemplateView} from "../../general/TemplateView"; import {spinner} from "../../common.js"; export class TimelineLoadingView extends TemplateView { diff --git a/src/platform/web/ui/session/room/TimelineView.ts b/src/platform/web/ui/session/room/TimelineView.ts new file mode 100644 index 00000000..3226e05f --- /dev/null +++ b/src/platform/web/ui/session/room/TimelineView.ts @@ -0,0 +1,244 @@ +/* +Copyright 2020 Bruno Windels + +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; + 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 { + + private anchoredNode?: HTMLElement; + private anchoredBottom: number = 0; + private stickToBottom: boolean = true; + private tilesView?: TilesListView; + private resizeObserver?: ResizeObserver; + + render(t: Builder, vm: TimelineViewModel) { + // assume this view will be mounted in the parent DOM straight away + requestAnimationFrame(() => { + // do initial scroll positioning + this.restoreScrollPosition(); + }); + this.tilesView = new TilesListView(vm.tiles, () => this.restoreScrollPosition()); + const root = t.div({className: "Timeline"}, [ + t.div({ + className: "Timeline_scroller bottom-aligned-scroll", + onScroll: () => this.onScroll() + }, t.view(this.tilesView)), + t.button({ + className: { + "Timeline_jumpDown": true, + hidden: vm => !vm.showJumpDown + }, + title: "Jump down", + onClick: () => this.jumpDown() + }) + ]); + + if (typeof ResizeObserver === "function") { + this.resizeObserver = new ResizeObserver(() => { + this.restoreScrollPosition(); + }); + this.resizeObserver.observe(root); + } + + return root; + } + + private get scrollNode(): HTMLElement { + return (this.root()! as HTMLElement).firstElementChild! as HTMLElement; + } + + private get tilesNode(): HTMLElement { + return this.tilesView!.root()! as HTMLElement; + } + + private jumpDown() { + const {scrollNode} = this; + this.stickToBottom = true; + scrollNode.scrollTop = scrollNode.scrollHeight; + } + + public unmount() { + super.unmount(); + if (this.resizeObserver) { + this.resizeObserver.unobserve(this.root()! as Element); + this.resizeObserver = undefined; + } + } + + private restoreScrollPosition() { + const {scrollNode, tilesNode} = this; + + const missingTilesHeight = scrollNode.clientHeight - tilesNode.clientHeight; + if (missingTilesHeight > 0) { + tilesNode.style.setProperty("margin-top", `${missingTilesHeight}px`); + // we don't have enough tiles to fill the viewport, so set all as visible + const len = this.value.tiles.length; + this.updateVisibleRange(0, len - 1); + } else { + tilesNode.style.removeProperty("margin-top"); + if (this.stickToBottom) { + scrollNode.scrollTop = scrollNode.scrollHeight; + } else if (this.anchoredNode) { + const newAnchoredBottom = bottom(this.anchoredNode!); + if (newAnchoredBottom !== this.anchoredBottom) { + const bottomDiff = newAnchoredBottom - this.anchoredBottom; + // scrollBy tends to create less scroll jumps than reassigning scrollTop as it does + // not depend on reading scrollTop, which might be out of date as some platforms + // run scrolling off the main thread. + if (typeof scrollNode.scrollBy === "function") { + scrollNode.scrollBy(0, bottomDiff); + } else { + scrollNode.scrollTop = scrollNode.scrollTop + bottomDiff; + } + this.anchoredBottom = newAnchoredBottom; + } + } + // TODO: should we be updating the visible range here as well as the range might have changed even though + // we restored the bottom tile + } + } + + private onScroll(): void { + const {scrollNode, tilesNode} = this; + const {scrollHeight, scrollTop, clientHeight} = scrollNode; + + let bottomNodeIndex; + this.stickToBottom = Math.abs(scrollHeight - (scrollTop + clientHeight)) < 1; + if (this.stickToBottom) { + const len = this.value.tiles.length; + bottomNodeIndex = len - 1; + } else { + const viewportBottom = scrollTop + clientHeight; + const anchoredNodeIndex = findFirstNodeIndexAtOrBelow(tilesNode, viewportBottom); + this.anchoredNode = tilesNode.childNodes[anchoredNodeIndex] as HTMLElement; + this.anchoredBottom = bottom(this.anchoredNode!); + bottomNodeIndex = anchoredNodeIndex; + } + let topNodeIndex = findFirstNodeIndexAtOrBelow(tilesNode, scrollTop, bottomNodeIndex); + this.updateVisibleRange(topNodeIndex, bottomNodeIndex); + } + + private updateVisibleRange(startIndex: number, endIndex: number) { + // can be undefined, meaning the tiles collection is still empty + const firstVisibleChild = this.tilesView!.getChildInstanceByIndex(startIndex); + const lastVisibleChild = this.tilesView!.getChildInstanceByIndex(endIndex); + this.value.setVisibleTileRange(firstVisibleChild?.value, lastVisibleChild?.value); + } +} + +class TilesListView extends ListView { + + private onChanged: () => void; + + constructor(tiles: ObservableList, onChanged: () => void) { + const options = { + 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(); + } +} diff --git a/src/platform/web/ui/session/room/UnknownRoomView.js b/src/platform/web/ui/session/room/UnknownRoomView.js index 32569d6f..80d857d8 100644 --- a/src/platform/web/ui/session/room/UnknownRoomView.js +++ b/src/platform/web/ui/session/room/UnknownRoomView.js @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {TemplateView} from "../../general/TemplateView.js"; +import {TemplateView} from "../../general/TemplateView"; export class UnknownRoomView extends TemplateView { render(t, vm) { diff --git a/src/platform/web/ui/session/room/timeline/AnnouncementView.js b/src/platform/web/ui/session/room/timeline/AnnouncementView.js index 2dd58b32..268bf0fa 100644 --- a/src/platform/web/ui/session/room/timeline/AnnouncementView.js +++ b/src/platform/web/ui/session/room/timeline/AnnouncementView.js @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {TemplateView} from "../../../general/TemplateView.js"; +import {TemplateView} from "../../../general/TemplateView"; export class AnnouncementView extends TemplateView { render(t) { diff --git a/src/platform/web/ui/session/room/timeline/BaseMessageView.js b/src/platform/web/ui/session/room/timeline/BaseMessageView.js index fb797f98..e1268cb3 100644 --- a/src/platform/web/ui/session/room/timeline/BaseMessageView.js +++ b/src/platform/web/ui/session/room/timeline/BaseMessageView.js @@ -16,9 +16,9 @@ limitations under the License. */ import {renderStaticAvatar} from "../../../avatar.js"; -import {tag} from "../../../general/html.js"; -import {mountView} from "../../../general/utils.js"; -import {TemplateView} from "../../../general/TemplateView.js"; +import {tag} from "../../../general/html"; +import {mountView} from "../../../general/utils"; +import {TemplateView} from "../../../general/TemplateView"; import {Popup} from "../../../general/Popup.js"; import {Menu} from "../../../general/Menu.js"; import {ReactionsView} from "./ReactionsView.js"; @@ -33,6 +33,10 @@ export class BaseMessageView extends TemplateView { } 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: { "Timeline_message": true, own: vm.isOwn, @@ -40,13 +44,7 @@ export class BaseMessageView extends TemplateView { unverified: vm.isUnverified, disabled: !this._interactive, continuation: vm => vm.isContinuation, - }}, [ - // 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)]); + }}, children); // given that there can be many tiles, we don't add // unneeded DOM nodes in case of a continuation, and we add it // 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_messageSender")); } 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(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, diff --git a/src/platform/web/ui/session/room/timeline/GapView.js b/src/platform/web/ui/session/room/timeline/GapView.js index 1e6e0af0..2d3bd6e8 100644 --- a/src/platform/web/ui/session/room/timeline/GapView.js +++ b/src/platform/web/ui/session/room/timeline/GapView.js @@ -14,19 +14,23 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {TemplateView} from "../../../general/TemplateView.js"; +import {TemplateView} from "../../../general/TemplateView"; import {spinner} from "../../../common.js"; export class GapView extends TemplateView { - render(t, vm) { + render(t) { const className = { GapView: true, - isLoading: vm => vm.isLoading + isLoading: vm => vm.isLoading, + isAtTop: vm => vm.isAtTop, }; return t.li({className}, [ spinner(t), - t.div(vm.i18n`Loading more messages …`), + t.div(vm => vm.isLoading ? vm.i18n`Loading more messages …` : vm.i18n`Not loading!`), t.if(vm => vm.error, t => t.strong(vm => vm.error)) ]); } + + /* This is called by the parent ListView, which just has 1 listener for the whole list */ + onClick() {} } diff --git a/src/platform/web/ui/session/room/timeline/ReactionsView.js b/src/platform/web/ui/session/room/timeline/ReactionsView.js index 12f3b428..5e4c97bc 100644 --- a/src/platform/web/ui/session/room/timeline/ReactionsView.js +++ b/src/platform/web/ui/session/room/timeline/ReactionsView.js @@ -14,8 +14,8 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {ListView} from "../../../general/ListView.js"; -import {TemplateView} from "../../../general/TemplateView.js"; +import {ListView} from "../../../general/ListView"; +import {TemplateView} from "../../../general/TemplateView"; export class ReactionsView extends ListView { constructor(reactionsViewModel) { diff --git a/src/platform/web/ui/session/room/timeline/TextMessageView.js b/src/platform/web/ui/session/room/timeline/TextMessageView.js index fcafaf27..c1674501 100644 --- a/src/platform/web/ui/session/room/timeline/TextMessageView.js +++ b/src/platform/web/ui/session/room/timeline/TextMessageView.js @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {tag, text} from "../../../general/html.js"; +import {tag, text} from "../../../general/html"; import {BaseMessageView} from "./BaseMessageView.js"; export class TextMessageView extends BaseMessageView { diff --git a/src/platform/web/ui/session/settings/SessionBackupSettingsView.js b/src/platform/web/ui/session/settings/SessionBackupSettingsView.js index eae4a8e1..b38517ab 100644 --- a/src/platform/web/ui/session/settings/SessionBackupSettingsView.js +++ b/src/platform/web/ui/session/settings/SessionBackupSettingsView.js @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {TemplateView} from "../../general/TemplateView.js"; +import {TemplateView} from "../../general/TemplateView"; import {StaticView} from "../../general/StaticView.js"; export class SessionBackupSettingsView extends TemplateView { diff --git a/src/platform/web/ui/session/settings/SettingsView.js b/src/platform/web/ui/session/settings/SettingsView.js index 725f0e2b..8fbc6812 100644 --- a/src/platform/web/ui/session/settings/SettingsView.js +++ b/src/platform/web/ui/session/settings/SettingsView.js @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {TemplateView} from "../../general/TemplateView.js"; +import {TemplateView} from "../../general/TemplateView"; import {SessionBackupSettingsView} from "./SessionBackupSettingsView.js" export class SettingsView extends TemplateView { diff --git a/src/utils/EventEmitter.js b/src/utils/EventEmitter.ts similarity index 71% rename from src/utils/EventEmitter.js rename to src/utils/EventEmitter.ts index 5dd56ac3..ea065d85 100644 --- a/src/utils/EventEmitter.js +++ b/src/utils/EventEmitter.ts @@ -1,5 +1,6 @@ /* Copyright 2020 Bruno Windels +Copyright 2021 Daniel Fedorin Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -14,28 +15,30 @@ See the License for the specific language governing permissions and limitations under the License. */ -export class EventEmitter { +type Handler = (value?: T) => void; + +export class EventEmitter { + private _handlersByName: { [event in keyof T]?: Set> } + constructor() { this._handlersByName = {}; } - emit(name, ...values) { + emit(name: K, value?: T[K]): void { const handlers = this._handlersByName[name]; if (handlers) { - for(const h of handlers) { - h(...values); - } + handlers.forEach(h => h(value)); } } - disposableOn(name, callback) { + disposableOn(name: K, callback: Handler): () => void { this.on(name, callback); return () => { this.off(name, callback); } } - on(name, callback) { + on(name: K, callback: Handler): void { let handlers = this._handlersByName[name]; if (!handlers) { this.onFirstSubscriptionAdded(name); @@ -44,27 +47,27 @@ export class EventEmitter { handlers.add(callback); } - off(name, callback) { + off(name: K, callback: Handler): void { const handlers = this._handlersByName[name]; if (handlers) { handlers.delete(callback); - if (handlers.length === 0) { + if (handlers.size === 0) { delete this._handlersByName[name]; this.onLastSubscriptionRemoved(name); } } } - onFirstSubscriptionAdded(/* name */) {} + onFirstSubscriptionAdded(name: K): void {} - onLastSubscriptionRemoved(/* name */) {} + onLastSubscriptionRemoved(name: K): void {} } export function tests() { return { test_on_off(assert) { let counter = 0; - const e = new EventEmitter(); + const e = new EventEmitter<{ change: never }>(); const callback = () => counter += 1; e.on("change", callback); e.emit("change"); @@ -75,7 +78,7 @@ export function tests() { test_emit_value(assert) { let value = 0; - const e = new EventEmitter(); + const e = new EventEmitter<{ change: number }>(); const callback = (v) => value = v; e.on("change", callback); e.emit("change", 5); @@ -85,7 +88,7 @@ export function tests() { test_double_on(assert) { let counter = 0; - const e = new EventEmitter(); + const e = new EventEmitter<{ change: never }>(); const callback = () => counter += 1; e.on("change", callback); e.on("change", callback);