Merge branch 'DanilaFe/backfill-changes' into context-api

This commit is contained in:
Danila Fedorin 2021-09-17 15:22:45 -07:00
commit 6d524384e9
86 changed files with 1114 additions and 662 deletions

View File

@ -1,6 +1,6 @@
{ {
"name": "hydrogen-web", "name": "hydrogen-web",
"version": "0.2.8", "version": "0.2.11",
"description": "A javascript matrix client prototype, trying to minize RAM usage by offloading as much as possible to IndexedDB", "description": "A javascript matrix client prototype, trying to minize RAM usage by offloading as much as possible to IndexedDB",
"main": "index.js", "main": "index.js",
"directories": { "directories": {

View File

@ -150,7 +150,9 @@ export class SessionPickerViewModel extends ViewModel {
async _exportData(id) { async _exportData(id) {
const sessionInfo = await this.platform.sessionInfoStorage.get(id); const sessionInfo = await this.platform.sessionInfoStorage.get(id);
const stores = await this.platform.storageFactory.export(id); const stores = await this.logger.run("export", log => {
return this.platform.storageFactory.export(id, log);
});
const data = {sessionInfo, stores}; const data = {sessionInfo, stores};
return data; return data;
} }
@ -161,7 +163,9 @@ export class SessionPickerViewModel extends ViewModel {
const {sessionInfo} = data; const {sessionInfo} = data;
sessionInfo.comment = `Imported on ${new Date().toLocaleString()} from id ${sessionInfo.id}.`; sessionInfo.comment = `Imported on ${new Date().toLocaleString()} from id ${sessionInfo.id}.`;
sessionInfo.id = this._createSessionContainer().createNewSessionId(); sessionInfo.id = this._createSessionContainer().createNewSessionId();
await this.platform.storageFactory.import(sessionInfo.id, data.stores); await this.logger.run("import", log => {
return this.platform.storageFactory.import(sessionInfo.id, data.stores, log);
});
await this.platform.sessionInfoStorage.add(sessionInfo); await this.platform.sessionInfoStorage.add(sessionInfo);
this._sessions.set(new SessionItemViewModel(sessionInfo, this)); this._sessions.set(new SessionItemViewModel(sessionInfo, this));
} catch (err) { } catch (err) {

View File

@ -18,7 +18,7 @@ limitations under the License.
// as in some cases it would really be more convenient to have multiple events (like telling the timeline to scroll down) // as in some cases it would really be more convenient to have multiple events (like telling the timeline to scroll down)
// we do need to return a disposable from EventEmitter.on, or at least have a method here to easily track a subscription to an EventEmitter // we do need to return a disposable from EventEmitter.on, or at least have a method here to easily track a subscription to an EventEmitter
import {EventEmitter} from "../utils/EventEmitter.js"; import {EventEmitter} from "../utils/EventEmitter";
import {Disposables} from "../utils/Disposables.js"; import {Disposables} from "../utils/Disposables.js";
export class ViewModel extends EventEmitter { export class ViewModel extends EventEmitter {

View File

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

View File

@ -236,6 +236,21 @@ export class TilesCollection extends BaseObservableList {
getFirst() { getFirst() {
return this._tiles[0]; return this._tiles[0];
} }
getTileIndex(searchTile) {
const idx = sortedIndex(this._tiles, searchTile, (searchTile, tile) => {
return searchTile.compare(tile);
});
const foundTile = this._tiles[idx];
if (foundTile?.compare(searchTile) === 0) {
return idx;
}
return -1;
}
sliceIterator(start, end) {
return this._tiles.slice(start, end)[Symbol.iterator]();
}
} }
import {ObservableArray} from "../../../../observable/list/ObservableArray.js"; import {ObservableArray} from "../../../../observable/list/ObservableArray.js";

View File

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

View File

@ -22,7 +22,7 @@ export class EncryptedEventTile extends BaseTextTile {
const parentResult = super.updateEntry(entry, params); const parentResult = super.updateEntry(entry, params);
// event got decrypted, recreate the tile and replace this one with it // event got decrypted, recreate the tile and replace this one with it
if (entry.eventType !== "m.room.encrypted") { if (entry.eventType !== "m.room.encrypted") {
// the "shape" parameter trigger tile recreation in TimelineList // the "shape" parameter trigger tile recreation in TimelineView
return UpdateAction.Replace("shape"); return UpdateAction.Replace("shape");
} else { } else {
return parentResult; return parentResult;

View File

@ -22,11 +22,11 @@ export class GapTile extends SimpleTile {
super(options); super(options);
this._loading = false; this._loading = false;
this._error = null; this._error = null;
this._isAtTop = true;
} }
async fill() { async fill() {
// prevent doing this twice if (!this._loading && !this._entry.edgeReached) {
if (!this._loading) {
this._loading = true; this._loading = true;
this.emitChange("isLoading"); this.emitChange("isLoading");
try { try {
@ -43,8 +43,25 @@ export class GapTile extends SimpleTile {
this.emitChange("isLoading"); this.emitChange("isLoading");
} }
} }
// edgeReached will have been updated by fillGap }
return this._entry.edgeReached;
notifyVisible() {
this.fill();
}
get isAtTop() {
return this._isAtTop;
}
updatePreviousSibling(prev) {
console.log("GapTile.updatePreviousSibling", prev);
super.updatePreviousSibling(prev);
const isAtTop = !prev;
if (this._isAtTop !== isAtTop) {
this._isAtTop = isAtTop;
console.log("isAtTop", this._isAtTop);
this.emitChange("isAtTop");
}
} }
updateEntry(entry, params) { updateEntry(entry, params) {

View File

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

View File

@ -43,12 +43,16 @@ export class LogItem {
/** logs a reference to a different log item, usually obtained from runDetached. /** logs a reference to a different log item, usually obtained from runDetached.
This is useful if the referenced operation can't be awaited. */ This is useful if the referenced operation can't be awaited. */
refDetached(logItem, logLevel = null) { refDetached(logItem, logLevel = null) {
if (!logItem._values.refId) { logItem.ensureRefId();
logItem.set("refId", this._logger._createRefId());
}
return this.log({ref: logItem._values.refId}, logLevel); return this.log({ref: logItem._values.refId}, logLevel);
} }
ensureRefId() {
if (!this._values.refId) {
this.set("refId", this._logger._createRefId());
}
}
/** /**
* Creates a new child item and runs it in `callback`. * Creates a new child item and runs it in `callback`.
*/ */
@ -231,4 +235,8 @@ export class LogItem {
this._children.push(item); this._children.push(item);
return item; return item;
} }
get logger() {
return this._logger;
}
} }

View File

@ -71,6 +71,8 @@ export class NullLogItem {
refDetached() {} refDetached() {}
ensureRefId() {}
get level() { get level() {
return LogLevel; return LogLevel;
} }

View File

@ -296,14 +296,10 @@ export class Sync {
// avoid corrupting state by only // avoid corrupting state by only
// storing the sync up till the point // storing the sync up till the point
// the exception occurred // the exception occurred
try { syncTxn.abort(log);
syncTxn.abort(); throw syncTxn.getCause(err);
} catch (abortErr) {
log.set("couldNotAbortTxn", true);
} }
throw err; await syncTxn.complete(log);
}
await syncTxn.complete();
} }
_afterSync(sessionState, inviteStates, roomStates, archivedRoomStates, log) { _afterSync(sessionState, inviteStates, roomStates, archivedRoomStates, log) {

View File

@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import {EventEmitter} from "../../utils/EventEmitter.js"; import {EventEmitter} from "../../utils/EventEmitter";
import {RoomSummary} from "./RoomSummary.js"; import {RoomSummary} from "./RoomSummary.js";
import {GapWriter} from "./timeline/persistence/GapWriter.js"; import {GapWriter} from "./timeline/persistence/GapWriter.js";
import {RelationWriter} from "./timeline/persistence/RelationWriter.js"; import {RelationWriter} from "./timeline/persistence/RelationWriter.js";

View File

@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import {EventEmitter} from "../../utils/EventEmitter.js"; import {EventEmitter} from "../../utils/EventEmitter";
import {SummaryData, processStateEvent} from "./RoomSummary.js"; import {SummaryData, processStateEvent} from "./RoomSummary.js";
import {Heroes} from "./members/Heroes.js"; import {Heroes} from "./members/Heroes.js";
import {MemberChange, RoomMember, EVENT_TYPE as MEMBER_EVENT_TYPE} from "./members/RoomMember.js"; import {MemberChange, RoomMember, EVENT_TYPE as MEMBER_EVENT_TYPE} from "./members/RoomMember.js";

View File

@ -72,8 +72,10 @@ export class Timeline {
// as they should only populate once the view subscribes to it // as they should only populate once the view subscribes to it
// if they are populated already, the sender profile would be empty // if they are populated already, the sender profile would be empty
// 30 seems to be a good amount to fill the entire screen // choose good amount here between showing messages initially and
const readerRequest = this._disposables.track(this._timelineReader.readFromEnd(30, txn, log)); // not spending too much time decrypting messages before showing the timeline.
// more messages should be loaded automatically until the viewport is full by the view if needed.
const readerRequest = this._disposables.track(this._timelineReader.readFromEnd(20, txn, log));
try { try {
const entries = await readerRequest.complete(); const entries = await readerRequest.complete();
this._setupEntries(entries); this._setupEntries(entries);

View File

@ -163,7 +163,7 @@ export class SyncWriter {
storageEntry.displayName = member.displayName; storageEntry.displayName = member.displayName;
storageEntry.avatarUrl = member.avatarUrl; storageEntry.avatarUrl = member.avatarUrl;
} }
txn.timelineEvents.insert(storageEntry); txn.timelineEvents.insert(storageEntry, log);
const entry = new EventEntry(storageEntry, this._fragmentIdComparer); const entry = new EventEntry(storageEntry, this._fragmentIdComparer);
entries.push(entry); entries.push(entry);
const updatedRelationTargetEntries = await this._relationWriter.writeRelation(entry, txn, log); const updatedRelationTargetEntries = await this._relationWriter.writeRelation(entry, txn, log);

View File

@ -17,22 +17,26 @@ limitations under the License.
import {Transaction} from "./Transaction"; import {Transaction} from "./Transaction";
import { STORE_NAMES, StoreNames, StorageError } from "../common"; import { STORE_NAMES, StoreNames, StorageError } from "../common";
import { reqAsPromise } from "./utils"; import { reqAsPromise } from "./utils";
import { BaseLogger } from "../../../logging/BaseLogger.js";
const WEBKITEARLYCLOSETXNBUG_BOGUS_KEY = "782rh281re38-boguskey"; const WEBKITEARLYCLOSETXNBUG_BOGUS_KEY = "782rh281re38-boguskey";
export class Storage { export class Storage {
private _db: IDBDatabase; private _db: IDBDatabase;
private _hasWebkitEarlyCloseTxnBug: boolean; private _hasWebkitEarlyCloseTxnBug: boolean;
private _idbFactory: IDBFactory
private _IDBKeyRange: typeof IDBKeyRange
storeNames: typeof StoreNames;
constructor(idbDatabase: IDBDatabase, idbFactory: IDBFactory, _IDBKeyRange: typeof IDBKeyRange, hasWebkitEarlyCloseTxnBug: boolean) { readonly logger: BaseLogger;
readonly idbFactory: IDBFactory
readonly IDBKeyRange: typeof IDBKeyRange;
readonly storeNames: typeof StoreNames;
constructor(idbDatabase: IDBDatabase, idbFactory: IDBFactory, _IDBKeyRange: typeof IDBKeyRange, hasWebkitEarlyCloseTxnBug: boolean, logger: BaseLogger) {
this._db = idbDatabase; this._db = idbDatabase;
this._idbFactory = idbFactory; this.idbFactory = idbFactory;
this._IDBKeyRange = _IDBKeyRange; this.IDBKeyRange = _IDBKeyRange;
this._hasWebkitEarlyCloseTxnBug = hasWebkitEarlyCloseTxnBug; this._hasWebkitEarlyCloseTxnBug = hasWebkitEarlyCloseTxnBug;
this.storeNames = StoreNames; this.storeNames = StoreNames;
this.logger = logger;
} }
_validateStoreNames(storeNames: StoreNames[]): void { _validateStoreNames(storeNames: StoreNames[]): void {
@ -51,7 +55,7 @@ export class Storage {
if (this._hasWebkitEarlyCloseTxnBug) { if (this._hasWebkitEarlyCloseTxnBug) {
await reqAsPromise(txn.objectStore(storeNames[0]).get(WEBKITEARLYCLOSETXNBUG_BOGUS_KEY)); await reqAsPromise(txn.objectStore(storeNames[0]).get(WEBKITEARLYCLOSETXNBUG_BOGUS_KEY));
} }
return new Transaction(txn, storeNames, this._idbFactory, this._IDBKeyRange); return new Transaction(txn, storeNames, this);
} catch(err) { } catch(err) {
throw new StorageError("readTxn failed", err); throw new StorageError("readTxn failed", err);
} }
@ -66,7 +70,7 @@ export class Storage {
if (this._hasWebkitEarlyCloseTxnBug) { if (this._hasWebkitEarlyCloseTxnBug) {
await reqAsPromise(txn.objectStore(storeNames[0]).get(WEBKITEARLYCLOSETXNBUG_BOGUS_KEY)); await reqAsPromise(txn.objectStore(storeNames[0]).get(WEBKITEARLYCLOSETXNBUG_BOGUS_KEY));
} }
return new Transaction(txn, storeNames, this._idbFactory, this._IDBKeyRange); return new Transaction(txn, storeNames, this);
} catch(err) { } catch(err) {
throw new StorageError("readWriteTxn failed", err); throw new StorageError("readWriteTxn failed", err);
} }

View File

@ -20,9 +20,10 @@ import { exportSession, importSession, Export } from "./export";
import { schema } from "./schema"; import { schema } from "./schema";
import { detectWebkitEarlyCloseTxnBug } from "./quirks"; import { detectWebkitEarlyCloseTxnBug } from "./quirks";
import { BaseLogger } from "../../../logging/BaseLogger.js"; import { BaseLogger } from "../../../logging/BaseLogger.js";
import { LogItem } from "../../../logging/LogItem.js";
const sessionName = (sessionId: string) => `hydrogen_session_${sessionId}`; const sessionName = (sessionId: string) => `hydrogen_session_${sessionId}`;
const openDatabaseWithSessionId = function(sessionId: string, idbFactory: IDBFactory, log?: BaseLogger) { const openDatabaseWithSessionId = function(sessionId: string, idbFactory: IDBFactory, log: LogItem) {
const create = (db, txn, oldVersion, version) => createStores(db, txn, oldVersion, version, log); const create = (db, txn, oldVersion, version) => createStores(db, txn, oldVersion, version, log);
return openDatabase(sessionName(sessionId), create, schema.length, idbFactory); return openDatabase(sessionName(sessionId), create, schema.length, idbFactory);
} }
@ -59,7 +60,7 @@ export class StorageFactory {
this._IDBKeyRange = _IDBKeyRange; this._IDBKeyRange = _IDBKeyRange;
} }
async create(sessionId: string, log?: BaseLogger): Promise<Storage> { async create(sessionId: string, log: LogItem): Promise<Storage> {
await this._serviceWorkerHandler?.preventConcurrentSessionAccess(sessionId); await this._serviceWorkerHandler?.preventConcurrentSessionAccess(sessionId);
requestPersistedStorage().then(persisted => { requestPersistedStorage().then(persisted => {
// Firefox lies here though, and returns true even if the user denied the request // Firefox lies here though, and returns true even if the user denied the request
@ -70,7 +71,7 @@ export class StorageFactory {
const hasWebkitEarlyCloseTxnBug = await detectWebkitEarlyCloseTxnBug(this._idbFactory); const hasWebkitEarlyCloseTxnBug = await detectWebkitEarlyCloseTxnBug(this._idbFactory);
const db = await openDatabaseWithSessionId(sessionId, this._idbFactory, log); const db = await openDatabaseWithSessionId(sessionId, this._idbFactory, log);
return new Storage(db, this._idbFactory, this._IDBKeyRange, hasWebkitEarlyCloseTxnBug); return new Storage(db, this._idbFactory, this._IDBKeyRange, hasWebkitEarlyCloseTxnBug, log.logger);
} }
delete(sessionId: string): Promise<IDBDatabase> { delete(sessionId: string): Promise<IDBDatabase> {
@ -79,18 +80,18 @@ export class StorageFactory {
return reqAsPromise(req); return reqAsPromise(req);
} }
async export(sessionId: string): Promise<Export> { async export(sessionId: string, log: LogItem): Promise<Export> {
const db = await openDatabaseWithSessionId(sessionId, this._idbFactory); const db = await openDatabaseWithSessionId(sessionId, this._idbFactory, log);
return await exportSession(db); return await exportSession(db);
} }
async import(sessionId: string, data: Export): Promise<void> { async import(sessionId: string, data: Export, log: LogItem): Promise<void> {
const db = await openDatabaseWithSessionId(sessionId, this._idbFactory); const db = await openDatabaseWithSessionId(sessionId, this._idbFactory, log);
return await importSession(db, data); return await importSession(db, data);
} }
} }
async function createStores(db: IDBDatabase, txn: IDBTransaction, oldVersion: number | null, version: number, log?: BaseLogger): Promise<void> { async function createStores(db: IDBDatabase, txn: IDBTransaction, oldVersion: number | null, version: number, log: LogItem): Promise<void> {
const startIdx = oldVersion || 0; const startIdx = oldVersion || 0;
return log.wrap({l: "storage migration", oldVersion, version}, async log => { return log.wrap({l: "storage migration", oldVersion, version}, async log => {
for(let i = startIdx; i < version; ++i) { for(let i = startIdx; i < version; ++i) {

View File

@ -18,6 +18,7 @@ import {QueryTarget, IDBQuery} from "./QueryTarget";
import {IDBRequestAttemptError} from "./error"; import {IDBRequestAttemptError} from "./error";
import {reqAsPromise} from "./utils"; import {reqAsPromise} from "./utils";
import {Transaction} from "./Transaction"; import {Transaction} from "./Transaction";
import {LogItem} from "../../../logging/LogItem.js";
const LOG_REQUESTS = false; const LOG_REQUESTS = false;
@ -140,7 +141,7 @@ export class Store<T> extends QueryTarget<T> {
return new QueryTarget<T>(new QueryTargetWrapper<T>(this._idbStore.index(indexName)), this._transaction); return new QueryTarget<T>(new QueryTargetWrapper<T>(this._idbStore.index(indexName)), this._transaction);
} }
put(value: T): void { put(value: T, log?: LogItem): void {
// If this request fails, the error will bubble up to the transaction and abort it, // If this request fails, the error will bubble up to the transaction and abort it,
// which is the behaviour we want. Therefore, it is ok to not create a promise for this // which is the behaviour we want. Therefore, it is ok to not create a promise for this
// request and await it. // request and await it.
@ -151,16 +152,52 @@ export class Store<T> extends QueryTarget<T> {
// //
// Note that this can still throw synchronously, like it does for TransactionInactiveError, // Note that this can still throw synchronously, like it does for TransactionInactiveError,
// see https://www.w3.org/TR/IndexedDB-2/#transaction-lifetime-concept // see https://www.w3.org/TR/IndexedDB-2/#transaction-lifetime-concept
this._idbStore.put(value); const request = this._idbStore.put(value);
this._prepareErrorLog(request, log, "put", undefined, value);
} }
add(value: T): void { add(value: T, log?: LogItem): void {
// ok to not monitor result of request, see comment in `put`. // ok to not monitor result of request, see comment in `put`.
this._idbStore.add(value); const request = this._idbStore.add(value);
this._prepareErrorLog(request, log, "add", undefined, value);
} }
delete(keyOrKeyRange: IDBValidKey | IDBKeyRange): Promise<undefined> { delete(keyOrKeyRange: IDBValidKey | IDBKeyRange, log?: LogItem): void {
// ok to not monitor result of request, see comment in `put`. // ok to not monitor result of request, see comment in `put`.
return reqAsPromise(this._idbStore.delete(keyOrKeyRange)); const request = this._idbStore.delete(keyOrKeyRange);
this._prepareErrorLog(request, log, "delete", keyOrKeyRange, undefined);
}
private _prepareErrorLog(request: IDBRequest, log: LogItem | undefined, operationName: string, key: IDBValidKey | IDBKeyRange | undefined, value: T | undefined) {
if (log) {
log.ensureRefId();
}
reqAsPromise(request).catch(err => {
try {
if (!key && value) {
key = this._getKey(value);
}
} catch {
key = "getKey failed";
}
this._transaction.addWriteError(err, log, operationName, key);
});
}
private _getKey(value: T): IDBValidKey {
const {keyPath} = this._idbStore;
if (Array.isArray(keyPath)) {
let field: any = value;
for (const part of keyPath) {
if (typeof field === "object") {
field = field[part];
} else {
break;
}
}
return field as IDBValidKey;
} else {
return value[keyPath] as IDBValidKey;
}
} }
} }

View File

@ -18,6 +18,7 @@ import {StoreNames} from "../common";
import {txnAsPromise} from "./utils"; import {txnAsPromise} from "./utils";
import {StorageError} from "../common"; import {StorageError} from "../common";
import {Store} from "./Store"; import {Store} from "./Store";
import {Storage} from "./Storage";
import {SessionStore} from "./stores/SessionStore"; import {SessionStore} from "./stores/SessionStore";
import {RoomSummaryStore} from "./stores/RoomSummaryStore"; import {RoomSummaryStore} from "./stores/RoomSummaryStore";
import {InviteStore} from "./stores/InviteStore"; import {InviteStore} from "./stores/InviteStore";
@ -35,20 +36,46 @@ import {OutboundGroupSessionStore} from "./stores/OutboundGroupSessionStore";
import {GroupSessionDecryptionStore} from "./stores/GroupSessionDecryptionStore"; import {GroupSessionDecryptionStore} from "./stores/GroupSessionDecryptionStore";
import {OperationStore} from "./stores/OperationStore"; import {OperationStore} from "./stores/OperationStore";
import {AccountDataStore} from "./stores/AccountDataStore"; import {AccountDataStore} from "./stores/AccountDataStore";
import {LogItem} from "../../../logging/LogItem.js";
import {BaseLogger} from "../../../logging/BaseLogger.js";
class WriteErrorInfo {
constructor(
public readonly error: StorageError,
public readonly refItem: LogItem | undefined,
public readonly operationName: string,
public readonly key: IDBValidKey | IDBKeyRange | undefined,
) {}
}
export class Transaction { export class Transaction {
private _txn: IDBTransaction; private _txn: IDBTransaction;
private _allowedStoreNames: StoreNames[]; private _allowedStoreNames: StoreNames[];
private _stores: { [storeName in StoreNames]?: any }; private _stores: { [storeName in StoreNames]?: any };
idbFactory: IDBFactory private _storage: Storage;
IDBKeyRange: typeof IDBKeyRange private _writeErrors: WriteErrorInfo[];
constructor(txn: IDBTransaction, allowedStoreNames: StoreNames[], _idbFactory: IDBFactory, _IDBKeyRange: typeof IDBKeyRange) { constructor(txn: IDBTransaction, allowedStoreNames: StoreNames[], storage: Storage) {
this._txn = txn; this._txn = txn;
this._allowedStoreNames = allowedStoreNames; this._allowedStoreNames = allowedStoreNames;
this._stores = {}; this._stores = {};
this.idbFactory = _idbFactory; this._txn = txn;
this.IDBKeyRange = _IDBKeyRange; this._allowedStoreNames = allowedStoreNames;
this._stores = {};
this._storage = storage;
this._writeErrors = [];
}
get idbFactory(): IDBFactory {
return this._storage.idbFactory;
}
get IDBKeyRange(): typeof IDBKeyRange {
return this._storage.IDBKeyRange;
}
get logger(): BaseLogger {
return this._storage.logger;
} }
_idbStore(name: StoreNames): Store<any> { _idbStore(name: StoreNames): Store<any> {
@ -139,12 +166,66 @@ export class Transaction {
return this._store(StoreNames.accountData, idbStore => new AccountDataStore(idbStore)); return this._store(StoreNames.accountData, idbStore => new AccountDataStore(idbStore));
} }
complete(): Promise<void> { async complete(log?: LogItem): Promise<void> {
return txnAsPromise(this._txn); try {
await txnAsPromise(this._txn);
} catch (err) {
if (this._writeErrors.length) {
this._logWriteErrors(log);
throw this._writeErrors[0].error;
}
throw err;
}
} }
abort(): void { getCause(error: Error) {
if (error instanceof StorageError) {
if (error.errcode === "AbortError" && this._writeErrors.length) {
return this._writeErrors[0].error;
}
}
return error;
}
abort(log?: LogItem): void {
// TODO: should we wrap the exception in a StorageError? // TODO: should we wrap the exception in a StorageError?
try {
this._txn.abort(); 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);
}
} }
} }

View File

@ -78,14 +78,14 @@ export class DeviceIdentityStore {
return this._store.index("byCurve25519Key").get(curve25519Key); return this._store.index("byCurve25519Key").get(curve25519Key);
} }
remove(userId: string, deviceId: string): Promise<undefined> { remove(userId: string, deviceId: string): void {
return this._store.delete(encodeKey(userId, deviceId)); this._store.delete(encodeKey(userId, deviceId));
} }
removeAllForUser(userId: string): Promise<undefined> { removeAllForUser(userId: string): void {
// exclude both keys as they are theoretical min and max, // exclude both keys as they are theoretical min and max,
// but we should't have a match for just the room id, or room id with max // but we should't have a match for just the room id, or room id with max
const range = this._store.IDBKeyRange.bound(encodeKey(userId, MIN_UNICODE), encodeKey(userId, MAX_UNICODE), true, true); const range = this._store.IDBKeyRange.bound(encodeKey(userId, MIN_UNICODE), encodeKey(userId, MAX_UNICODE), true, true);
return this._store.delete(range); this._store.delete(range);
} }
} }

View File

@ -44,11 +44,11 @@ export class GroupSessionDecryptionStore {
this._store.put(decryption as GroupSessionEntry); this._store.put(decryption as GroupSessionEntry);
} }
removeAllForRoom(roomId: string): Promise<undefined> { removeAllForRoom(roomId: string): void {
const range = this._store.IDBKeyRange.bound( const range = this._store.IDBKeyRange.bound(
encodeKey(roomId, MIN_UNICODE, MIN_UNICODE), encodeKey(roomId, MIN_UNICODE, MIN_UNICODE),
encodeKey(roomId, MAX_UNICODE, MAX_UNICODE) encodeKey(roomId, MAX_UNICODE, MAX_UNICODE)
); );
return this._store.delete(range); this._store.delete(range);
} }
} }

View File

@ -53,11 +53,11 @@ export class InboundGroupSessionStore {
this._store.put(session); this._store.put(session);
} }
removeAllForRoom(roomId: string): Promise<undefined> { removeAllForRoom(roomId: string) {
const range = this._store.IDBKeyRange.bound( const range = this._store.IDBKeyRange.bound(
encodeKey(roomId, MIN_UNICODE, MIN_UNICODE), encodeKey(roomId, MIN_UNICODE, MIN_UNICODE),
encodeKey(roomId, MAX_UNICODE, MAX_UNICODE) encodeKey(roomId, MAX_UNICODE, MAX_UNICODE)
); );
return this._store.delete(range); this._store.delete(range);
} }
} }

View File

@ -71,7 +71,7 @@ export class OlmSessionStore {
this._store.put(session as OlmSessionEntry); this._store.put(session as OlmSessionEntry);
} }
remove(senderKey: string, sessionId: string): Promise<undefined> { remove(senderKey: string, sessionId: string): void {
return this._store.delete(encodeKey(senderKey, sessionId)); this._store.delete(encodeKey(senderKey, sessionId));
} }
} }

View File

@ -73,8 +73,8 @@ export class OperationStore {
this._store.put(operation as OperationEntry); this._store.put(operation as OperationEntry);
} }
remove(id: string): Promise<undefined> { remove(id: string): void {
return this._store.delete(id); this._store.delete(id);
} }
async removeAllForScope(scope: string): Promise<undefined> { async removeAllForScope(scope: string): Promise<undefined> {

View File

@ -28,8 +28,8 @@ export class OutboundGroupSessionStore {
this._store = store; this._store = store;
} }
remove(roomId: string): Promise<undefined> { remove(roomId: string): void {
return this._store.delete(roomId); this._store.delete(roomId);
} }
get(roomId: string): Promise<OutboundSession | null> { get(roomId: string): Promise<OutboundSession | null> {

View File

@ -62,9 +62,9 @@ export class PendingEventStore {
} }
} }
remove(roomId: string, queueIndex: number): Promise<undefined> { remove(roomId: string, queueIndex: number) {
const keyRange = this._eventStore.IDBKeyRange.only(encodeKey(roomId, queueIndex)); const keyRange = this._eventStore.IDBKeyRange.only(encodeKey(roomId, queueIndex));
return this._eventStore.delete(keyRange); this._eventStore.delete(keyRange);
} }
async exists(roomId: string, queueIndex: number): Promise<boolean> { async exists(roomId: string, queueIndex: number): Promise<boolean> {
@ -86,10 +86,10 @@ export class PendingEventStore {
return this._eventStore.selectAll(); return this._eventStore.selectAll();
} }
removeAllForRoom(roomId: string): Promise<undefined> { removeAllForRoom(roomId: string): void {
const minKey = encodeKey(roomId, KeyLimits.minStorageKey); const minKey = encodeKey(roomId, KeyLimits.minStorageKey);
const maxKey = encodeKey(roomId, KeyLimits.maxStorageKey); const maxKey = encodeKey(roomId, KeyLimits.maxStorageKey);
const range = this._eventStore.IDBKeyRange.bound(minKey, maxKey); const range = this._eventStore.IDBKeyRange.bound(minKey, maxKey);
return this._eventStore.delete(range); this._eventStore.delete(range);
} }
} }

View File

@ -47,10 +47,10 @@ export class RoomStateStore {
this._roomStateStore.put(entry); this._roomStateStore.put(entry);
} }
removeAllForRoom(roomId: string): Promise<undefined> { removeAllForRoom(roomId: string): void {
// exclude both keys as they are theoretical min and max, // exclude both keys as they are theoretical min and max,
// but we should't have a match for just the room id, or room id with max // but we should't have a match for just the room id, or room id with max
const range = this._roomStateStore.IDBKeyRange.bound(roomId, `${roomId}|${MAX_UNICODE}`, true, true); const range = this._roomStateStore.IDBKeyRange.bound(roomId, `${roomId}|${MAX_UNICODE}`, true, true);
return this._roomStateStore.delete(range); this._roomStateStore.delete(range);
} }
} }

View File

@ -55,7 +55,7 @@ export class RoomSummaryStore {
return roomId === fetchedKey; return roomId === fetchedKey;
} }
remove(roomId: string): Promise<undefined> { remove(roomId: string): void {
return this._summaryStore.delete(roomId); this._summaryStore.delete(roomId);
} }
} }

View File

@ -20,6 +20,7 @@ import { encodeUint32 } from "../utils";
import {KeyLimits} from "../../common"; import {KeyLimits} from "../../common";
import {Store} from "../Store"; import {Store} from "../Store";
import {TimelineEvent, StateEvent} from "../../types"; import {TimelineEvent, StateEvent} from "../../types";
import {LogItem} from "../../../../logging/LogItem.js";
interface Annotation { interface Annotation {
count: number; count: number;
@ -265,11 +266,10 @@ export class TimelineEventStore {
* @return nothing. To wait for the operation to finish, await the transaction it's part of. * @return nothing. To wait for the operation to finish, await the transaction it's part of.
* @throws {StorageError} ... * @throws {StorageError} ...
*/ */
insert(entry: TimelineEventEntry): void { insert(entry: TimelineEventEntry, log: LogItem): void {
(entry as TimelineEventStorageEntry).key = encodeKey(entry.roomId, entry.fragmentId, entry.eventIndex); (entry as TimelineEventStorageEntry).key = encodeKey(entry.roomId, entry.fragmentId, entry.eventIndex);
(entry as TimelineEventStorageEntry).eventIdKey = encodeEventIdKey(entry.roomId, entry.event.event_id); (entry as TimelineEventStorageEntry).eventIdKey = encodeEventIdKey(entry.roomId, entry.event.event_id);
// TODO: map error? or in idb/store? this._timelineStore.add(entry as TimelineEventStorageEntry, log);
this._timelineStore.add(entry as TimelineEventStorageEntry);
} }
/** Updates the entry into the store with the given [roomId, eventKey] combination. /** Updates the entry into the store with the given [roomId, eventKey] combination.

View File

@ -100,7 +100,7 @@ export class TimelineFragmentStore {
return this._store.get(encodeKey(roomId, fragmentId)); return this._store.get(encodeKey(roomId, fragmentId));
} }
removeAllForRoom(roomId: string): Promise<undefined> { removeAllForRoom(roomId: string): void {
return this._store.delete(this._allRange(roomId)); this._store.delete(this._allRange(roomId));
} }
} }

View File

@ -43,18 +43,18 @@ export class TimelineRelationStore {
this._store.add({key: encodeKey(roomId, targetEventId, relType, sourceEventId)}); this._store.add({key: encodeKey(roomId, targetEventId, relType, sourceEventId)});
} }
remove(roomId: string, targetEventId: string, relType: string, sourceEventId: string): Promise<undefined> { remove(roomId: string, targetEventId: string, relType: string, sourceEventId: string): void {
return this._store.delete(encodeKey(roomId, targetEventId, relType, sourceEventId)); this._store.delete(encodeKey(roomId, targetEventId, relType, sourceEventId));
} }
removeAllForTarget(roomId: string, targetId: string): Promise<undefined> { removeAllForTarget(roomId: string, targetId: string): void {
const range = this._store.IDBKeyRange.bound( const range = this._store.IDBKeyRange.bound(
encodeKey(roomId, targetId, MIN_UNICODE, MIN_UNICODE), encodeKey(roomId, targetId, MIN_UNICODE, MIN_UNICODE),
encodeKey(roomId, targetId, MAX_UNICODE, MAX_UNICODE), encodeKey(roomId, targetId, MAX_UNICODE, MAX_UNICODE),
true, true,
true true
); );
return this._store.delete(range); this._store.delete(range);
} }
async getForTargetAndType(roomId: string, targetId: string, relType: string): Promise<RelationEntry[]> { async getForTargetAndType(roomId: string, targetId: string, relType: string): Promise<RelationEntry[]> {

View File

@ -36,7 +36,7 @@ export class UserIdentityStore {
this._store.put(userIdentity); this._store.put(userIdentity);
} }
remove(userId: string): Promise<undefined> { remove(userId: string): void {
return this._store.delete(userId); this._store.delete(userId);
} }
} }

View File

@ -17,6 +17,7 @@ limitations under the License.
import { IDBRequestError } from "./error"; import { IDBRequestError } from "./error";
import { StorageError } from "../common"; import { StorageError } from "../common";
import { AbortError } from "../../../utils/error.js";
let needsSyncPromise = false; let needsSyncPromise = false;
@ -112,22 +113,8 @@ export function txnAsPromise(txn): Promise<void> {
// @ts-ignore // @ts-ignore
needsSyncPromise && Promise._flush && Promise._flush(); needsSyncPromise && Promise._flush && Promise._flush();
}); });
txn.addEventListener("error", event => {
const request = event.target;
// catch first error here, but don't reject yet,
// as we don't have access to the failed request in the abort event handler
if (!error && request) {
error = new IDBRequestError(request);
}
});
txn.addEventListener("abort", event => { txn.addEventListener("abort", event => {
if (!error) { reject(new AbortError());
const txn = event.target;
const dbName = txn.db.name;
const storeNames = Array.from(txn.objectStoreNames).join(", ")
error = new StorageError(`Transaction on ${dbName} with stores ${storeNames} was aborted.`);
}
reject(error);
// @ts-ignore // @ts-ignore
needsSyncPromise && Promise._flush && Promise._flush(); needsSyncPromise && Promise._flush && Promise._flush();
}); });

View File

@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import {BaseUpdateView} from "./general/BaseUpdateView.js"; import {BaseUpdateView} from "./general/BaseUpdateView";
import {renderStaticAvatar, renderImg} from "./avatar.js"; import {renderStaticAvatar, renderImg} from "./avatar.js";
/* /*
@ -66,7 +66,7 @@ export class AvatarView extends BaseUpdateView {
this._avatarTitleChanged(); this._avatarTitleChanged();
this._root = renderStaticAvatar(this.value, this._size); this._root = renderStaticAvatar(this.value, this._size);
// takes care of update being called when needed // takes care of update being called when needed
super.mount(options); this.subscribeOnMount(options);
return this._root; return this._root;
} }

View File

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

View File

@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import {tag, text, classNames, setAttribute} from "./general/html.js"; import {tag, text, classNames, setAttribute} from "./general/html";
/** /**
* @param {Object} vm view model with {avatarUrl, avatarColorNumber, avatarTitle, avatarLetter} * @param {Object} vm view model with {avatarUrl, avatarColorNumber, avatarTitle, avatarLetter}
* @param {Number} size * @param {Number} size

View File

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

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

@ -15,6 +15,20 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
.Timeline_jumpDown {
width: 40px;
height: 40px;
bottom: 16px;
right: 32px;
border-radius: 100%;
border: 1px solid #8d99a5;
background-image: url("./icons/chevron-down.svg");
background-position: center;
background-color: white;
background-repeat: no-repeat;
cursor: pointer;
}
.Timeline_message { .Timeline_message {
display: grid; display: grid;
grid-template: grid-template:
@ -362,3 +376,11 @@ only loads when the top comes into view*/
.GapView > :not(:first-child) { .GapView > :not(:first-child) {
margin-left: 12px; margin-left: 12px;
} }
.GapView {
padding: 52px 20px;
}
.GapView.isAtTop {
padding: 52px 20px 12px 20px;
}

View File

@ -14,13 +14,36 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
.Timeline {
display: flex;
flex-direction: column;
position: relative;
min-height: 0;
}
.RoomView_body > ul { .Timeline_jumpDown {
overflow-y: auto; position: absolute;
overscroll-behavior: contain; }
list-style: none;
.Timeline_scroller {
overflow-y: scroll;
overscroll-behavior-y: contain;
overflow-anchor: none;
padding: 0; padding: 0;
margin: 0; margin: 0;
/* need to read the offsetTop of tiles relative to this element in TimelineView */
position: relative;
min-height: 0;
flex: 1 0 0;
}
.Timeline_scroller > ul {
list-style: none;
/* use small horizontal padding so first/last children margin isn't collapsed
at the edge and a scrollbar shows up when setting margin-top to bottom-align
content when there are not yet enough tiles to fill the viewport */
padding: 1px 0;
margin: 0;
} }
.message-container { .message-container {
@ -49,13 +72,7 @@ limitations under the License.
} }
.GapView { .GapView {
visibility: hidden;
display: flex; display: flex;
padding: 10px 20px;
}
.GapView.isLoading {
visibility: visible;
} }
.GapView > :nth-child(2) { .GapView > :nth-child(2) {

View File

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

View File

@ -14,9 +14,10 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import {el} from "./html.js"; import {el} from "./html";
import {mountView} from "./utils.js"; import {mountView} from "./utils";
import {insertAt, ListView} from "./ListView.js"; import {ListView} from "./ListView";
import {insertAt} from "./utils";
class ItemRange { class ItemRange {
constructor(topCount, renderCount, bottomCount) { constructor(topCount, renderCount, bottomCount) {

View File

@ -1,163 +0,0 @@
/*
Copyright 2020 Bruno Windels <bruno@windels.cloud>
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import {el} from "./html.js";
import {mountView} from "./utils.js";
export function insertAt(parentNode, idx, childNode) {
const isLast = idx === parentNode.childElementCount;
if (isLast) {
parentNode.appendChild(childNode);
} else {
const nextDomNode = parentNode.children[idx];
parentNode.insertBefore(childNode, nextDomNode);
}
}
export class ListView {
constructor({list, onItemClick, className, tagName = "ul", parentProvidesUpdates = true}, childCreator) {
this._onItemClick = onItemClick;
this._list = list;
this._className = className;
this._tagName = tagName;
this._root = null;
this._subscription = null;
this._childCreator = childCreator;
this._childInstances = null;
this._mountArgs = {parentProvidesUpdates};
this._onClick = this._onClick.bind(this);
}
root() {
return this._root;
}
update(attributes) {
if (attributes.hasOwnProperty("list")) {
if (this._subscription) {
this._unloadList();
while (this._root.lastChild) {
this._root.lastChild.remove();
}
}
this._list = attributes.list;
this.loadList();
}
}
mount() {
const attr = {};
if (this._className) {
attr.className = this._className;
}
this._root = el(this._tagName, attr);
this.loadList();
if (this._onItemClick) {
this._root.addEventListener("click", this._onClick);
}
return this._root;
}
unmount() {
if (this._list) {
this._unloadList();
}
}
_onClick(event) {
if (event.target === this._root) {
return;
}
let childNode = event.target;
while (childNode.parentNode !== this._root) {
childNode = childNode.parentNode;
}
const index = Array.prototype.indexOf.call(this._root.childNodes, childNode);
const childView = this._childInstances[index];
this._onItemClick(childView, event);
}
_unloadList() {
this._subscription = this._subscription();
for (let child of this._childInstances) {
child.unmount();
}
this._childInstances = null;
}
loadList() {
if (!this._list) {
return;
}
this._subscription = this._list.subscribe(this);
this._childInstances = [];
const fragment = document.createDocumentFragment();
for (let item of this._list) {
const child = this._childCreator(item);
this._childInstances.push(child);
fragment.appendChild(mountView(child, this._mountArgs));
}
this._root.appendChild(fragment);
}
onAdd(idx, value) {
this.onBeforeListChanged();
const child = this._childCreator(value);
this._childInstances.splice(idx, 0, child);
insertAt(this._root, idx, mountView(child, this._mountArgs));
this.onListChanged();
}
onRemove(idx/*, _value*/) {
this.onBeforeListChanged();
const [child] = this._childInstances.splice(idx, 1);
child.root().remove();
child.unmount();
this.onListChanged();
}
onMove(fromIdx, toIdx/*, value*/) {
this.onBeforeListChanged();
const [child] = this._childInstances.splice(fromIdx, 1);
this._childInstances.splice(toIdx, 0, child);
child.root().remove();
insertAt(this._root, toIdx, child.root());
this.onListChanged();
}
onUpdate(i, value, params) {
if (this._childInstances) {
const instance = this._childInstances[i];
instance && instance.update(value, params);
}
}
recreateItem(index, value) {
if (this._childInstances) {
const child = this._childCreator(value);
if (!child) {
this.onRemove(index, value);
} else {
const [oldChild] = this._childInstances.splice(index, 1, child);
this._root.replaceChild(child.mount(this._mountArgs), oldChild.root());
oldChild.unmount();
}
}
}
onBeforeListChanged() {}
onListChanged() {}
}

View File

@ -0,0 +1,182 @@
/*
Copyright 2020 Bruno Windels <bruno@windels.cloud>
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import {el} from "./html";
import {mountView, insertAt} from "./utils";
import {BaseObservableList as ObservableList} from "../../../../observable/list/BaseObservableList.js";
import {IView, IMountArgs} from "./types";
interface IOptions<T, V> {
list: ObservableList<T>,
onItemClick?: (childView: V, evt: UIEvent) => void,
className?: string,
tagName?: string,
parentProvidesUpdates?: boolean
}
type SubscriptionHandle = () => undefined;
export class ListView<T, V extends IView> implements IView {
private _onItemClick?: (childView: V, evt: UIEvent) => void;
private _list: ObservableList<T>;
private _className?: string;
private _tagName: string;
private _root?: Element;
private _subscription?: SubscriptionHandle;
private _childCreator: (value: T) => V;
private _childInstances?: V[];
private _mountArgs: IMountArgs;
constructor(
{list, onItemClick, className, tagName = "ul", parentProvidesUpdates = true}: IOptions<T, V>,
childCreator: (value: T) => V
) {
this._onItemClick = onItemClick;
this._list = list;
this._className = className;
this._tagName = tagName;
this._root = undefined;
this._subscription = undefined;
this._childCreator = childCreator;
this._childInstances = undefined;
this._mountArgs = {parentProvidesUpdates};
}
root(): Element | undefined {
// won't be undefined when called between mount and unmount
return this._root;
}
update(attributes: IOptions<T, V>) {
if (attributes.list) {
if (this._subscription) {
this._unloadList();
while (this._root!.lastChild) {
this._root!.lastChild.remove();
}
}
this._list = attributes.list;
this.loadList();
}
}
mount(): Element {
const attr: {[name: string]: any} = {};
if (this._className) {
attr.className = this._className;
}
const root = this._root = el(this._tagName, attr);
this.loadList();
if (this._onItemClick) {
root.addEventListener("click", this);
}
return root;
}
handleEvent(evt: Event) {
if (evt.type === "click") {
this._handleClick(evt as UIEvent);
}
}
unmount(): void {
if (this._list) {
this._unloadList();
}
}
private _handleClick(event: UIEvent) {
if (event.target === this._root || !this._onItemClick) {
return;
}
let childNode = event.target as Element;
while (childNode.parentNode !== this._root) {
childNode = childNode.parentNode as Element;
}
const index = Array.prototype.indexOf.call(this._root!.childNodes, childNode);
const childView = this._childInstances![index];
if (childView) {
this._onItemClick(childView, event);
}
}
private _unloadList() {
this._subscription = this._subscription!();
for (let child of this._childInstances!) {
child.unmount();
}
this._childInstances = undefined;
}
protected loadList() {
if (!this._list) {
return;
}
this._subscription = this._list.subscribe(this);
this._childInstances = [];
const fragment = document.createDocumentFragment();
for (let item of this._list) {
const child = this._childCreator(item);
this._childInstances!.push(child);
fragment.appendChild(mountView(child, this._mountArgs));
}
this._root!.appendChild(fragment);
}
protected onAdd(idx: number, value: T) {
const child = this._childCreator(value);
this._childInstances!.splice(idx, 0, child);
insertAt(this._root!, idx, mountView(child, this._mountArgs));
}
protected onRemove(idx: number, value: T) {
const [child] = this._childInstances!.splice(idx, 1);
child.root()!.remove();
child.unmount();
}
protected onMove(fromIdx: number, toIdx: number, value: T) {
const [child] = this._childInstances!.splice(fromIdx, 1);
this._childInstances!.splice(toIdx, 0, child);
child.root()!.remove();
insertAt(this._root!, toIdx, child.root()! as Element);
}
protected onUpdate(i: number, value: T, params: any) {
if (this._childInstances) {
const instance = this._childInstances![i];
instance && instance.update(value, params);
}
}
protected recreateItem(index: number, value: T) {
if (this._childInstances) {
const child = this._childCreator(value);
if (!child) {
this.onRemove(index, value);
} else {
const [oldChild] = this._childInstances!.splice(index, 1, child);
this._root!.replaceChild(child.mount(this._mountArgs), oldChild.root()!);
oldChild.unmount();
}
}
}
public getChildInstanceByIndex(idx: number): V | undefined {
return this._childInstances?.[idx];
}
}

View File

@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import {TemplateView} from "./TemplateView.js"; import {TemplateView} from "./TemplateView";
import {spinner} from "../common.js"; import {spinner} from "../common.js";
export class LoadingView extends TemplateView { export class LoadingView extends TemplateView {

View File

@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import {TemplateView} from "./TemplateView.js"; import {TemplateView} from "./TemplateView";
export class Menu extends TemplateView { export class Menu extends TemplateView {
static option(label, callback) { static option(label, callback) {

View File

@ -169,7 +169,7 @@ export class Popup {
return true; return true;
} }
/* fake UIView api, so it can be tracked by a template view as a subview */ /* fake IView api, so it can be tracked by a template view as a subview */
root() { root() {
return this._fakeRoot; return this._fakeRoot;
} }

View File

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

View File

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

View File

@ -1,5 +1,6 @@
/* /*
Copyright 2020 Bruno Windels <bruno@windels.cloud> Copyright 2020 Bruno Windels <bruno@windels.cloud>
Copyright 2021 Daniel Fedorin <danila.fedorin@gmail.com>
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.
@ -16,12 +17,18 @@ limitations under the License.
// DOM helper functions // DOM helper functions
export function isChildren(children) { import {ViewNode} from "./types";
export type ClassNames<T> = { [className: string]: boolean | ((value: T) => boolean) }
export type BasicAttributes<T> = { [attribute: string]: ClassNames<T> | boolean | string }
export type Child = string | Text | ViewNode;
export function isChildren(children: object | Child | Child[]): children is Child | Child[] {
// children should be an not-object (that's the attributes), or a domnode, or an array // children should be an not-object (that's the attributes), or a domnode, or an array
return typeof children !== "object" || !!children.nodeType || Array.isArray(children); return typeof children !== "object" || "nodeType" in children || Array.isArray(children);
} }
export function classNames(obj, value) { export function classNames<T>(obj: ClassNames<T>, value: T): string {
return Object.entries(obj).reduce((cn, [name, enabled]) => { return Object.entries(obj).reduce((cn, [name, enabled]) => {
if (typeof enabled === "function") { if (typeof enabled === "function") {
enabled = enabled(value); enabled = enabled(value);
@ -34,7 +41,7 @@ export function classNames(obj, value) {
}, ""); }, "");
} }
export function setAttribute(el, name, value) { export function setAttribute(el: Element, name: string, value: string | boolean): void {
if (name === "className") { if (name === "className") {
name = "class"; name = "class";
} }
@ -48,22 +55,24 @@ export function setAttribute(el, name, value) {
} }
} }
export function el(elementName, attributes, children) { export function el(elementName: string, attributes?: BasicAttributes<never> | Child | Child[], children?: Child | Child[]): Element {
return elNS(HTML_NS, elementName, attributes, children); return elNS(HTML_NS, elementName, attributes, children);
} }
export function elNS(ns, elementName, attributes, children) { export function elNS(ns: string, elementName: string, attributes?: BasicAttributes<never> | Child | Child[], children?: Child | Child[]): Element {
if (attributes && isChildren(attributes)) { if (attributes && isChildren(attributes)) {
children = attributes; children = attributes;
attributes = null; attributes = undefined;
} }
const e = document.createElementNS(ns, elementName); const e = document.createElementNS(ns, elementName);
if (attributes) { if (attributes) {
for (let [name, value] of Object.entries(attributes)) { for (let [name, value] of Object.entries(attributes)) {
if (name === "className" && typeof value === "object" && value !== null) { if (typeof value === "object") {
value = classNames(value); // Only className should ever be an object; be careful
// here anyway and ignore object-valued non-className attributes.
value = (value !== null && name === "className") ? classNames(value, undefined) : false;
} }
setAttribute(e, name, value); setAttribute(e, name, value);
} }
@ -74,7 +83,7 @@ export function elNS(ns, elementName, attributes, children) {
children = [children]; children = [children];
} }
for (let c of children) { for (let c of children) {
if (!c.nodeType) { if (typeof c === "string") {
c = text(c); c = text(c);
} }
e.appendChild(c); e.appendChild(c);
@ -83,12 +92,12 @@ export function elNS(ns, elementName, attributes, children) {
return e; return e;
} }
export function text(str) { export function text(str: string): Text {
return document.createTextNode(str); return document.createTextNode(str);
} }
export const HTML_NS = "http://www.w3.org/1999/xhtml"; export const HTML_NS: string = "http://www.w3.org/1999/xhtml";
export const SVG_NS = "http://www.w3.org/2000/svg"; export const SVG_NS: string = "http://www.w3.org/2000/svg";
export const TAG_NAMES = { export const TAG_NAMES = {
[HTML_NS]: [ [HTML_NS]: [
@ -97,10 +106,9 @@ export const TAG_NAMES = {
"table", "thead", "tbody", "tr", "th", "td", "hr", "table", "thead", "tbody", "tr", "th", "td", "hr",
"pre", "code", "button", "time", "input", "textarea", "label", "form", "progress", "output", "video"], "pre", "code", "button", "time", "input", "textarea", "label", "form", "progress", "output", "video"],
[SVG_NS]: ["svg", "circle"] [SVG_NS]: ["svg", "circle"]
}; } as const;
export const tag = {};
export const tag: { [tagName in typeof TAG_NAMES[string][number]]: (attributes?: BasicAttributes<never> | Child | Child[], children?: Child | Child[]) => Element } = {} as any;
for (const [ns, tags] of Object.entries(TAG_NAMES)) { for (const [ns, tags] of Object.entries(TAG_NAMES)) {
for (const tagName of tags) { for (const tagName of tags) {

View File

@ -0,0 +1,30 @@
/*
Copyright 2021 The Matrix.org Foundation C.I.C.
Copyright 2021 Daniel Fedorin <danila.fedorin@gmail.com>
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
export interface IMountArgs {
// if true, the parent will call update() rather than the view updating itself by binding to a data source.
parentProvidesUpdates?: boolean
};
// Comment nodes can be used as temporary placeholders for Elements, like TemplateView does.
export type ViewNode = Element | Comment;
export interface IView {
mount(args?: IMountArgs): ViewNode;
root(): ViewNode | undefined; // should only be called between mount() and unmount()
unmount(): void;
update(...any); // this isn't really standarized yet
}

View File

@ -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;
}

View File

@ -1,5 +1,5 @@
/* /*
Copyright 2020 Bruno Windels <bruno@windels.cloud> Copyright 2021 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.
@ -14,11 +14,22 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import {tag} from "./html.js"; import {IView, IMountArgs, ViewNode} from "./types";
import {tag} from "./html";
export function errorToDOM(error) { export function mountView(view: IView, mountArgs?: IMountArgs): ViewNode {
let node;
try {
node = view.mount(mountArgs);
} catch (err) {
node = errorToDOM(err);
}
return node;
}
export function errorToDOM(error: Error): Element {
const stack = new Error().stack; const stack = new Error().stack;
let callee = null; let callee: string | null = null;
if (stack) { if (stack) {
callee = stack.split("\n")[1]; callee = stack.split("\n")[1];
} }
@ -29,3 +40,13 @@ export function errorToDOM(error) {
tag.pre(error.stack), tag.pre(error.stack),
]); ]);
} }
export function insertAt(parentNode: Element, idx: number, childNode: Node): void {
const isLast = idx === parentNode.childElementCount;
if (isLast) {
parentNode.appendChild(childNode);
} else {
const nextDomNode = parentNode.children[idx];
parentNode.insertBefore(childNode, nextDomNode);
}
}

View File

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

View File

@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import {TemplateView} from "../general/TemplateView.js"; import {TemplateView} from "../general/TemplateView";
import {hydrogenGithubLink} from "./common.js"; import {hydrogenGithubLink} from "./common.js";
import {PasswordLoginView} from "./PasswordLoginView.js"; import {PasswordLoginView} from "./PasswordLoginView.js";
import {CompleteSSOView} from "./CompleteSSOView.js"; import {CompleteSSOView} from "./CompleteSSOView.js";

View File

@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import {TemplateView} from "../general/TemplateView.js"; import {TemplateView} from "../general/TemplateView";
export class PasswordLoginView extends TemplateView { export class PasswordLoginView extends TemplateView {
render(t, vm) { render(t, vm) {

View File

@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import {TemplateView} from "../general/TemplateView.js"; import {TemplateView} from "../general/TemplateView";
import {spinner} from "../common.js"; import {spinner} from "../common.js";
/** a view used both in the login view and the loading screen /** a view used both in the login view and the loading screen

View File

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

View File

@ -14,8 +14,8 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import {ListView} from "../general/ListView.js"; import {ListView} from "../general/ListView";
import {TemplateView} from "../general/TemplateView.js"; import {TemplateView} from "../general/TemplateView";
import {hydrogenGithubLink} from "./common.js"; import {hydrogenGithubLink} from "./common.js";
import {SessionLoadStatusView} from "./SessionLoadStatusView.js"; import {SessionLoadStatusView} from "./SessionLoadStatusView.js";

View File

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

View File

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

View File

@ -20,7 +20,7 @@ import {RoomView} from "./room/RoomView.js";
import {UnknownRoomView} from "./room/UnknownRoomView.js"; import {UnknownRoomView} from "./room/UnknownRoomView.js";
import {InviteView} from "./room/InviteView.js"; import {InviteView} from "./room/InviteView.js";
import {LightboxView} from "./room/LightboxView.js"; import {LightboxView} from "./room/LightboxView.js";
import {TemplateView} from "../general/TemplateView.js"; import {TemplateView} from "../general/TemplateView";
import {StaticView} from "../general/StaticView.js"; import {StaticView} from "../general/StaticView.js";
import {SessionStatusView} from "./SessionStatusView.js"; import {SessionStatusView} from "./SessionStatusView.js";
import {RoomGridView} from "./RoomGridView.js"; import {RoomGridView} from "./RoomGridView.js";

View File

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

View File

@ -14,8 +14,8 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import {ListView} from "../../general/ListView.js"; import {ListView} from "../../general/ListView";
import {TemplateView} from "../../general/TemplateView.js"; import {TemplateView} from "../../general/TemplateView";
import {RoomTileView} from "./RoomTileView.js"; import {RoomTileView} from "./RoomTileView.js";
import {InviteTileView} from "./InviteTileView.js"; import {InviteTileView} from "./InviteTileView.js";

View File

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

View File

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

View File

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

View File

@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import {TemplateView} from "../../general/TemplateView.js"; import {TemplateView} from "../../general/TemplateView";
import {RoomDetailsView} from "./RoomDetailsView.js"; import {RoomDetailsView} from "./RoomDetailsView.js";
import {MemberListView} from "./MemberListView.js"; import {MemberListView} from "./MemberListView.js";
import {LoadingView} from "../../general/LoadingView.js"; import {LoadingView} from "../../general/LoadingView.js";

View File

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

View File

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

View File

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

View File

@ -14,10 +14,10 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import {TemplateView} from "../../general/TemplateView.js"; import {TemplateView} from "../../general/TemplateView";
import {Popup} from "../../general/Popup.js"; import {Popup} from "../../general/Popup.js";
import {Menu} from "../../general/Menu.js"; import {Menu} from "../../general/Menu.js";
import {viewClassForEntry} from "./TimelineList.js" import {viewClassForEntry} from "./TimelineView"
export class MessageComposer extends TemplateView { export class MessageComposer extends TemplateView {
constructor(viewModel) { constructor(viewModel) {

View File

@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import {TemplateView} from "../../general/TemplateView.js"; import {TemplateView} from "../../general/TemplateView";
export class RoomArchivedView extends TemplateView { export class RoomArchivedView extends TemplateView {
render(t) { render(t) {

View File

@ -15,10 +15,10 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import {TemplateView} from "../../general/TemplateView.js"; import {TemplateView} from "../../general/TemplateView";
import {Popup} from "../../general/Popup.js"; import {Popup} from "../../general/Popup.js";
import {Menu} from "../../general/Menu.js"; import {Menu} from "../../general/Menu.js";
import {TimelineList} from "./TimelineList.js"; import {TimelineView} from "./TimelineView";
import {TimelineLoadingView} from "./TimelineLoadingView.js"; import {TimelineLoadingView} from "./TimelineLoadingView.js";
import {MessageComposer} from "./MessageComposer.js"; import {MessageComposer} from "./MessageComposer.js";
import {RoomArchivedView} from "./RoomArchivedView.js"; import {RoomArchivedView} from "./RoomArchivedView.js";
@ -54,7 +54,7 @@ export class RoomView extends TemplateView {
t.div({className: "RoomView_error"}, vm => vm.error), t.div({className: "RoomView_error"}, vm => vm.error),
t.mapView(vm => vm.timelineViewModel, timelineViewModel => { t.mapView(vm => vm.timelineViewModel, timelineViewModel => {
return timelineViewModel ? return timelineViewModel ?
new TimelineList(timelineViewModel) : new TimelineView(timelineViewModel) :
new TimelineLoadingView(vm); // vm is just needed for i18n new TimelineLoadingView(vm); // vm is just needed for i18n
}), }),
t.view(bottomView), t.view(bottomView),

View File

@ -1,165 +0,0 @@
/*
Copyright 2020 Bruno Windels <bruno@windels.cloud>
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import {ListView} from "../../general/ListView.js";
import {GapView} from "./timeline/GapView.js";
import {TextMessageView} from "./timeline/TextMessageView.js";
import {ImageView} from "./timeline/ImageView.js";
import {VideoView} from "./timeline/VideoView.js";
import {FileView} from "./timeline/FileView.js";
import {MissingAttachmentView} from "./timeline/MissingAttachmentView.js";
import {AnnouncementView} from "./timeline/AnnouncementView.js";
import {RedactedView} from "./timeline/RedactedView.js";
export function viewClassForEntry(entry) {
switch (entry.shape) {
case "gap": return GapView;
case "announcement": return AnnouncementView;
case "message":
case "message-status":
return TextMessageView;
case "image": return ImageView;
case "video": return VideoView;
case "file": return FileView;
case "missing-attachment": return MissingAttachmentView;
case "redacted":
return RedactedView;
}
}
export class TimelineList extends ListView {
constructor(viewModel) {
const options = {
className: "Timeline bottom-aligned-scroll",
list: viewModel.tiles,
onItemClick: (tileView, evt) => tileView.onClick(evt),
}
super(options, entry => {
const View = viewClassForEntry(entry);
if (View) {
return new View(entry);
}
});
this._atBottom = false;
this._onScroll = this._onScroll.bind(this);
this._topLoadingPromise = null;
this._viewModel = viewModel;
}
async _loadAtTopWhile(predicate) {
if (this._topLoadingPromise) {
return;
}
try {
while (predicate()) {
// fill, not enough content to fill timeline
this._topLoadingPromise = this._viewModel.loadAtTop();
const shouldStop = await this._topLoadingPromise;
if (shouldStop) {
break;
}
}
}
catch (err) {
console.error(err);
//ignore error, as it is handled in the VM
}
finally {
this._topLoadingPromise = null;
}
}
async _onScroll() {
const PAGINATE_OFFSET = 100;
const root = this.root();
if (root.scrollTop < PAGINATE_OFFSET && !this._topLoadingPromise && this._viewModel) {
// to calculate total amountGrown to check when we stop loading
let beforeContentHeight = root.scrollHeight;
// to adjust scrollTop every time
let lastContentHeight = beforeContentHeight;
// load until pagination offset is reached again
this._loadAtTopWhile(() => {
const contentHeight = root.scrollHeight;
const amountGrown = contentHeight - beforeContentHeight;
root.scrollTop = root.scrollTop + (contentHeight - lastContentHeight);
lastContentHeight = contentHeight;
return amountGrown < PAGINATE_OFFSET;
});
}
}
mount() {
const root = super.mount();
root.addEventListener("scroll", this._onScroll);
return root;
}
unmount() {
this.root().removeEventListener("scroll", this._onScroll);
super.unmount();
}
async loadList() {
super.loadList();
const root = this.root();
// yield so the browser can render the list
// and we can measure the content below
await Promise.resolve();
const {scrollHeight, clientHeight} = root;
if (scrollHeight > clientHeight) {
root.scrollTop = root.scrollHeight;
}
// load while viewport is not filled
this._loadAtTopWhile(() => {
const {scrollHeight, clientHeight} = root;
return scrollHeight <= clientHeight;
});
}
onBeforeListChanged() {
const fromBottom = this._distanceFromBottom();
this._atBottom = fromBottom < 1;
}
_distanceFromBottom() {
const root = this.root();
return root.scrollHeight - root.scrollTop - root.clientHeight;
}
onListChanged() {
const root = this.root();
if (this._atBottom) {
root.scrollTop = root.scrollHeight;
}
}
onUpdate(index, value, param) {
if (param === "shape") {
if (this._childInstances) {
const ExpectedClass = viewClassForEntry(value);
const child = this._childInstances[index];
if (!ExpectedClass || !(child instanceof ExpectedClass)) {
// shape was updated, so we need to recreate the tile view,
// the shape parameter is set in EncryptedEventTile.updateEntry
// (and perhaps elsewhere by the time you read this)
super.recreateItem(index, value);
return;
}
}
}
super.onUpdate(index, value, param);
}
}

View File

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

View File

@ -0,0 +1,244 @@
/*
Copyright 2020 Bruno Windels <bruno@windels.cloud>
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import {ListView} from "../../general/ListView";
import {TemplateView, Builder} from "../../general/TemplateView";
import {IObservableValue} from "../../general/BaseUpdateView";
import {GapView} from "./timeline/GapView.js";
import {TextMessageView} from "./timeline/TextMessageView.js";
import {ImageView} from "./timeline/ImageView.js";
import {VideoView} from "./timeline/VideoView.js";
import {FileView} from "./timeline/FileView.js";
import {MissingAttachmentView} from "./timeline/MissingAttachmentView.js";
import {AnnouncementView} from "./timeline/AnnouncementView.js";
import {RedactedView} from "./timeline/RedactedView.js";
import {SimpleTile} from "../../../../../domain/session/room/timeline/tiles/SimpleTile.js";
import {BaseObservableList as ObservableList} from "../../../../../observable/list/BaseObservableList.js";
//import {TimelineViewModel} from "../../../../../domain/session/room/timeline/TimelineViewModel.js";
interface TimelineViewModel extends IObservableValue {
showJumpDown: boolean;
tiles: ObservableList<SimpleTile>;
setVisibleTileRange(start?: SimpleTile, end?: SimpleTile);
}
type TileView = GapView | AnnouncementView | TextMessageView |
ImageView | VideoView | FileView | MissingAttachmentView | RedactedView;
type TileViewConstructor = (this: TileView, SimpleTile) => void;
export function viewClassForEntry(entry: SimpleTile): TileViewConstructor | undefined {
switch (entry.shape) {
case "gap": return GapView;
case "announcement": return AnnouncementView;
case "message":
case "message-status":
return TextMessageView;
case "image": return ImageView;
case "video": return VideoView;
case "file": return FileView;
case "missing-attachment": return MissingAttachmentView;
case "redacted":
return RedactedView;
}
}
function bottom(node: HTMLElement): number {
return node.offsetTop + node.clientHeight;
}
function findFirstNodeIndexAtOrBelow(tiles: HTMLElement, top: number, startIndex: number = (tiles.children.length - 1)): number {
for (var i = startIndex; i >= 0; i--) {
const node = tiles.children[i] as HTMLElement;
if (node.offsetTop < top) {
return i;
}
}
// return first item if nothing matched before
return 0;
}
export class TimelineView extends TemplateView<TimelineViewModel> {
private anchoredNode?: HTMLElement;
private anchoredBottom: number = 0;
private stickToBottom: boolean = true;
private tilesView?: TilesListView;
private resizeObserver?: ResizeObserver;
render(t: Builder<TimelineViewModel>, vm: TimelineViewModel) {
// assume this view will be mounted in the parent DOM straight away
requestAnimationFrame(() => {
// do initial scroll positioning
this.restoreScrollPosition();
});
this.tilesView = new TilesListView(vm.tiles, () => this.restoreScrollPosition());
const root = t.div({className: "Timeline"}, [
t.div({
className: "Timeline_scroller bottom-aligned-scroll",
onScroll: () => this.onScroll()
}, t.view(this.tilesView)),
t.button({
className: {
"Timeline_jumpDown": true,
hidden: vm => !vm.showJumpDown
},
title: "Jump down",
onClick: () => this.jumpDown()
})
]);
if (typeof ResizeObserver === "function") {
this.resizeObserver = new ResizeObserver(() => {
this.restoreScrollPosition();
});
this.resizeObserver.observe(root);
}
return root;
}
private get scrollNode(): HTMLElement {
return (this.root()! as HTMLElement).firstElementChild! as HTMLElement;
}
private get tilesNode(): HTMLElement {
return this.tilesView!.root()! as HTMLElement;
}
private jumpDown() {
const {scrollNode} = this;
this.stickToBottom = true;
scrollNode.scrollTop = scrollNode.scrollHeight;
}
public unmount() {
super.unmount();
if (this.resizeObserver) {
this.resizeObserver.unobserve(this.root()! as Element);
this.resizeObserver = undefined;
}
}
private restoreScrollPosition() {
const {scrollNode, tilesNode} = this;
const missingTilesHeight = scrollNode.clientHeight - tilesNode.clientHeight;
if (missingTilesHeight > 0) {
tilesNode.style.setProperty("margin-top", `${missingTilesHeight}px`);
// we don't have enough tiles to fill the viewport, so set all as visible
const len = this.value.tiles.length;
this.updateVisibleRange(0, len - 1);
} else {
tilesNode.style.removeProperty("margin-top");
if (this.stickToBottom) {
scrollNode.scrollTop = scrollNode.scrollHeight;
} else if (this.anchoredNode) {
const newAnchoredBottom = bottom(this.anchoredNode!);
if (newAnchoredBottom !== this.anchoredBottom) {
const bottomDiff = newAnchoredBottom - this.anchoredBottom;
// scrollBy tends to create less scroll jumps than reassigning scrollTop as it does
// not depend on reading scrollTop, which might be out of date as some platforms
// run scrolling off the main thread.
if (typeof scrollNode.scrollBy === "function") {
scrollNode.scrollBy(0, bottomDiff);
} else {
scrollNode.scrollTop = scrollNode.scrollTop + bottomDiff;
}
this.anchoredBottom = newAnchoredBottom;
}
}
// TODO: should we be updating the visible range here as well as the range might have changed even though
// we restored the bottom tile
}
}
private onScroll(): void {
const {scrollNode, tilesNode} = this;
const {scrollHeight, scrollTop, clientHeight} = scrollNode;
let bottomNodeIndex;
this.stickToBottom = Math.abs(scrollHeight - (scrollTop + clientHeight)) < 1;
if (this.stickToBottom) {
const len = this.value.tiles.length;
bottomNodeIndex = len - 1;
} else {
const viewportBottom = scrollTop + clientHeight;
const anchoredNodeIndex = findFirstNodeIndexAtOrBelow(tilesNode, viewportBottom);
this.anchoredNode = tilesNode.childNodes[anchoredNodeIndex] as HTMLElement;
this.anchoredBottom = bottom(this.anchoredNode!);
bottomNodeIndex = anchoredNodeIndex;
}
let topNodeIndex = findFirstNodeIndexAtOrBelow(tilesNode, scrollTop, bottomNodeIndex);
this.updateVisibleRange(topNodeIndex, bottomNodeIndex);
}
private updateVisibleRange(startIndex: number, endIndex: number) {
// can be undefined, meaning the tiles collection is still empty
const firstVisibleChild = this.tilesView!.getChildInstanceByIndex(startIndex);
const lastVisibleChild = this.tilesView!.getChildInstanceByIndex(endIndex);
this.value.setVisibleTileRange(firstVisibleChild?.value, lastVisibleChild?.value);
}
}
class TilesListView extends ListView<SimpleTile, TileView> {
private onChanged: () => void;
constructor(tiles: ObservableList<SimpleTile>, onChanged: () => void) {
const options = {
list: tiles,
onItemClick: (tileView, evt) => tileView.onClick(evt),
};
super(options, entry => {
const View = viewClassForEntry(entry);
if (View) {
return new View(entry);
}
});
this.onChanged = onChanged;
}
protected onUpdate(index: number, value: SimpleTile, param: any) {
if (param === "shape") {
const ExpectedClass = viewClassForEntry(value);
const child = this.getChildInstanceByIndex(index);
if (!ExpectedClass || !(child instanceof ExpectedClass)) {
// shape was updated, so we need to recreate the tile view,
// the shape parameter is set in EncryptedEventTile.updateEntry
// (and perhaps elsewhere by the time you read this)
super.recreateItem(index, value);
return;
}
}
super.onUpdate(index, value, param);
this.onChanged();
}
protected onAdd(idx: number, value: SimpleTile) {
super.onAdd(idx, value);
this.onChanged();
}
protected onRemove(idx: number, value: SimpleTile) {
super.onRemove(idx, value);
this.onChanged();
}
protected onMove(fromIdx: number, toIdx: number, value: SimpleTile) {
super.onMove(fromIdx, toIdx, value);
this.onChanged();
}
}

View File

@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import {TemplateView} from "../../general/TemplateView.js"; import {TemplateView} from "../../general/TemplateView";
export class UnknownRoomView extends TemplateView { export class UnknownRoomView extends TemplateView {
render(t, vm) { render(t, vm) {

View File

@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import {TemplateView} from "../../../general/TemplateView.js"; import {TemplateView} from "../../../general/TemplateView";
export class AnnouncementView extends TemplateView { export class AnnouncementView extends TemplateView {
render(t) { render(t) {

View File

@ -16,9 +16,9 @@ limitations under the License.
*/ */
import {renderStaticAvatar} from "../../../avatar.js"; import {renderStaticAvatar} from "../../../avatar.js";
import {tag} from "../../../general/html.js"; import {tag} from "../../../general/html";
import {mountView} from "../../../general/utils.js"; import {mountView} from "../../../general/utils";
import {TemplateView} from "../../../general/TemplateView.js"; import {TemplateView} from "../../../general/TemplateView";
import {Popup} from "../../../general/Popup.js"; import {Popup} from "../../../general/Popup.js";
import {Menu} from "../../../general/Menu.js"; import {Menu} from "../../../general/Menu.js";
import {ReactionsView} from "./ReactionsView.js"; import {ReactionsView} from "./ReactionsView.js";
@ -33,6 +33,10 @@ export class BaseMessageView extends TemplateView {
} }
render(t, vm) { render(t, vm) {
const children = [this.renderMessageBody(t, vm)];
if (this._interactive) {
children.push(t.button({className: "Timeline_messageOptions"}, "⋯"));
}
const li = t.el(this._tagName, {className: { const li = t.el(this._tagName, {className: {
"Timeline_message": true, "Timeline_message": true,
own: vm.isOwn, own: vm.isOwn,
@ -40,13 +44,7 @@ export class BaseMessageView extends TemplateView {
unverified: vm.isUnverified, unverified: vm.isUnverified,
disabled: !this._interactive, disabled: !this._interactive,
continuation: vm => vm.isContinuation, continuation: vm => vm.isContinuation,
}}, [ }}, children);
// dynamically added and removed nodes are handled below
this.renderMessageBody(t, vm),
// should be after body as it is overlayed on top
this._interactive ? t.button({className: "Timeline_messageOptions"}, "⋯") : [],
]);
const avatar = t.a({href: vm.memberPanelLink, className: "Timeline_messageAvatar"}, [renderStaticAvatar(vm, 30)]);
// given that there can be many tiles, we don't add // given that there can be many tiles, we don't add
// unneeded DOM nodes in case of a continuation, and we add it // unneeded DOM nodes in case of a continuation, and we add it
// with a side-effect binding to not have to create sub views, // with a side-effect binding to not have to create sub views,
@ -57,8 +55,10 @@ export class BaseMessageView extends TemplateView {
li.removeChild(li.querySelector(".Timeline_messageAvatar")); li.removeChild(li.querySelector(".Timeline_messageAvatar"));
li.removeChild(li.querySelector(".Timeline_messageSender")); li.removeChild(li.querySelector(".Timeline_messageSender"));
} else if (!isContinuation) { } else if (!isContinuation) {
const avatar = tag.a({href: vm.memberPanelLink, className: "Timeline_messageAvatar"}, [renderStaticAvatar(vm, 30)]);
const sender = tag.div({className: `Timeline_messageSender usercolor${vm.avatarColorNumber}`}, vm.displayName);
li.insertBefore(avatar, li.firstChild); li.insertBefore(avatar, li.firstChild);
li.insertBefore(tag.div({className: `Timeline_messageSender usercolor${vm.avatarColorNumber}`}, vm.displayName), li.firstChild); li.insertBefore(sender, li.firstChild);
} }
}); });
// similarly, we could do this with a simple ifView, // similarly, we could do this with a simple ifView,

View File

@ -14,19 +14,23 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import {TemplateView} from "../../../general/TemplateView.js"; import {TemplateView} from "../../../general/TemplateView";
import {spinner} from "../../../common.js"; import {spinner} from "../../../common.js";
export class GapView extends TemplateView { export class GapView extends TemplateView {
render(t, vm) { render(t) {
const className = { const className = {
GapView: true, GapView: true,
isLoading: vm => vm.isLoading isLoading: vm => vm.isLoading,
isAtTop: vm => vm.isAtTop,
}; };
return t.li({className}, [ return t.li({className}, [
spinner(t), spinner(t),
t.div(vm.i18n`Loading more messages …`), t.div(vm => vm.isLoading ? vm.i18n`Loading more messages …` : vm.i18n`Not loading!`),
t.if(vm => vm.error, t => t.strong(vm => vm.error)) t.if(vm => vm.error, t => t.strong(vm => vm.error))
]); ]);
} }
/* This is called by the parent ListView, which just has 1 listener for the whole list */
onClick() {}
} }

View File

@ -14,8 +14,8 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import {ListView} from "../../../general/ListView.js"; import {ListView} from "../../../general/ListView";
import {TemplateView} from "../../../general/TemplateView.js"; import {TemplateView} from "../../../general/TemplateView";
export class ReactionsView extends ListView { export class ReactionsView extends ListView {
constructor(reactionsViewModel) { constructor(reactionsViewModel) {

View File

@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import {tag, text} from "../../../general/html.js"; import {tag, text} from "../../../general/html";
import {BaseMessageView} from "./BaseMessageView.js"; import {BaseMessageView} from "./BaseMessageView.js";
export class TextMessageView extends BaseMessageView { export class TextMessageView extends BaseMessageView {

View File

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

View File

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

View File

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