Merge branch 'master' into DanilaFe/backfill-changes

This commit is contained in:
Danila Fedorin 2021-09-17 15:19:16 -07:00
commit 9f6c48cf0c
86 changed files with 1114 additions and 662 deletions

View file

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

View file

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

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)
// we do need to return a disposable from EventEmitter.on, or at least have a method here to easily track a subscription to an EventEmitter
import {EventEmitter} from "../utils/EventEmitter.js";
import {EventEmitter} from "../utils/EventEmitter";
import {Disposables} from "../utils/Disposables.js";
export class ViewModel extends EventEmitter {

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -296,14 +296,10 @@ export class Sync {
// avoid corrupting state by only
// storing the sync up till the point
// the exception occurred
try {
syncTxn.abort();
} catch (abortErr) {
log.set("couldNotAbortTxn", true);
syncTxn.abort(log);
throw syncTxn.getCause(err);
}
throw err;
}
await syncTxn.complete();
await syncTxn.complete(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.
*/
import {EventEmitter} from "../../utils/EventEmitter.js";
import {EventEmitter} from "../../utils/EventEmitter";
import {RoomSummary} from "./RoomSummary.js";
import {GapWriter} from "./timeline/persistence/GapWriter.js";
import {RelationWriter} from "./timeline/persistence/RelationWriter.js";

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -18,6 +18,7 @@ import {StoreNames} from "../common";
import {txnAsPromise} from "./utils";
import {StorageError} from "../common";
import {Store} from "./Store";
import {Storage} from "./Storage";
import {SessionStore} from "./stores/SessionStore";
import {RoomSummaryStore} from "./stores/RoomSummaryStore";
import {InviteStore} from "./stores/InviteStore";
@ -35,20 +36,46 @@ import {OutboundGroupSessionStore} from "./stores/OutboundGroupSessionStore";
import {GroupSessionDecryptionStore} from "./stores/GroupSessionDecryptionStore";
import {OperationStore} from "./stores/OperationStore";
import {AccountDataStore} from "./stores/AccountDataStore";
import {LogItem} from "../../../logging/LogItem.js";
import {BaseLogger} from "../../../logging/BaseLogger.js";
class WriteErrorInfo {
constructor(
public readonly error: StorageError,
public readonly refItem: LogItem | undefined,
public readonly operationName: string,
public readonly key: IDBValidKey | IDBKeyRange | undefined,
) {}
}
export class Transaction {
private _txn: IDBTransaction;
private _allowedStoreNames: StoreNames[];
private _stores: { [storeName in StoreNames]?: any };
idbFactory: IDBFactory
IDBKeyRange: typeof IDBKeyRange
private _storage: Storage;
private _writeErrors: WriteErrorInfo[];
constructor(txn: IDBTransaction, allowedStoreNames: StoreNames[], _idbFactory: IDBFactory, _IDBKeyRange: typeof IDBKeyRange) {
constructor(txn: IDBTransaction, allowedStoreNames: StoreNames[], storage: Storage) {
this._txn = txn;
this._allowedStoreNames = allowedStoreNames;
this._stores = {};
this.idbFactory = _idbFactory;
this.IDBKeyRange = _IDBKeyRange;
this._txn = txn;
this._allowedStoreNames = allowedStoreNames;
this._stores = {};
this._storage = storage;
this._writeErrors = [];
}
get idbFactory(): IDBFactory {
return this._storage.idbFactory;
}
get IDBKeyRange(): typeof IDBKeyRange {
return this._storage.IDBKeyRange;
}
get logger(): BaseLogger {
return this._storage.logger;
}
_idbStore(name: StoreNames): Store<any> {
@ -139,12 +166,66 @@ export class Transaction {
return this._store(StoreNames.accountData, idbStore => new AccountDataStore(idbStore));
}
complete(): Promise<void> {
return txnAsPromise(this._txn);
async complete(log?: LogItem): Promise<void> {
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?
try {
this._txn.abort();
} catch (abortErr) {
log?.set("couldNotAbortTxn", true);
}
if (this._writeErrors.length) {
this._logWriteErrors(log);
}
}
addWriteError(error: StorageError, refItem: LogItem | undefined, operationName: string, key: IDBValidKey | IDBKeyRange | undefined) {
// don't log subsequent `AbortError`s
if (error.errcode !== "AbortError" || this._writeErrors.length === 0) {
this._writeErrors.push(new WriteErrorInfo(error, refItem, operationName, key));
}
}
private _logWriteErrors(parentItem: LogItem | undefined) {
const callback = errorGroupItem => {
// we don't have context when there is no parentItem, so at least log stores
if (!parentItem) {
errorGroupItem.set("allowedStoreNames", this._allowedStoreNames);
}
for (const info of this._writeErrors) {
errorGroupItem.wrap({l: info.operationName, id: info.key}, item => {
if (info.refItem) {
item.refDetached(info.refItem);
}
item.catch(info.error);
});
}
};
const label = `${this._writeErrors.length} storage write operation(s) failed`;
if (parentItem) {
parentItem.wrap(label, callback);
} else {
this.logger.run(label, callback);
}
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -28,8 +28,8 @@ export class OutboundGroupSessionStore {
this._store = store;
}
remove(roomId: string): Promise<undefined> {
return this._store.delete(roomId);
remove(roomId: string): void {
this._store.delete(roomId);
}
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));
return this._eventStore.delete(keyRange);
this._eventStore.delete(keyRange);
}
async exists(roomId: string, queueIndex: number): Promise<boolean> {
@ -86,10 +86,10 @@ export class PendingEventStore {
return this._eventStore.selectAll();
}
removeAllForRoom(roomId: string): Promise<undefined> {
removeAllForRoom(roomId: string): void {
const minKey = encodeKey(roomId, KeyLimits.minStorageKey);
const maxKey = encodeKey(roomId, KeyLimits.maxStorageKey);
const range = this._eventStore.IDBKeyRange.bound(minKey, maxKey);
return this._eventStore.delete(range);
this._eventStore.delete(range);
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

After

Width:  |  Height:  |  Size: 1 KiB

View file

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

View file

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

View file

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

View file

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

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

View file

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

View file

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

View file

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

View file

@ -1,5 +1,6 @@
/*
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");
you may not use this file except in compliance with the License.
@ -16,12 +17,18 @@ limitations under the License.
// DOM helper functions
export function isChildren(children) {
import {ViewNode} from "./types";
export type ClassNames<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
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]) => {
if (typeof enabled === "function") {
enabled = enabled(value);
@ -34,7 +41,7 @@ export function classNames(obj, value) {
}, "");
}
export function setAttribute(el, name, value) {
export function setAttribute(el: Element, name: string, value: string | boolean): void {
if (name === "className") {
name = "class";
}
@ -48,22 +55,24 @@ export function setAttribute(el, name, value) {
}
}
export function el(elementName, attributes, children) {
export function el(elementName: string, attributes?: BasicAttributes<never> | Child | Child[], children?: Child | Child[]): Element {
return elNS(HTML_NS, elementName, attributes, children);
}
export function elNS(ns, elementName, attributes, children) {
export function elNS(ns: string, elementName: string, attributes?: BasicAttributes<never> | Child | Child[], children?: Child | Child[]): Element {
if (attributes && isChildren(attributes)) {
children = attributes;
attributes = null;
attributes = undefined;
}
const e = document.createElementNS(ns, elementName);
if (attributes) {
for (let [name, value] of Object.entries(attributes)) {
if (name === "className" && typeof value === "object" && value !== null) {
value = classNames(value);
if (typeof value === "object") {
// Only className should ever be an object; be careful
// here anyway and ignore object-valued non-className attributes.
value = (value !== null && name === "className") ? classNames(value, undefined) : false;
}
setAttribute(e, name, value);
}
@ -74,7 +83,7 @@ export function elNS(ns, elementName, attributes, children) {
children = [children];
}
for (let c of children) {
if (!c.nodeType) {
if (typeof c === "string") {
c = text(c);
}
e.appendChild(c);
@ -83,12 +92,12 @@ export function elNS(ns, elementName, attributes, children) {
return e;
}
export function text(str) {
export function text(str: string): Text {
return document.createTextNode(str);
}
export const HTML_NS = "http://www.w3.org/1999/xhtml";
export const SVG_NS = "http://www.w3.org/2000/svg";
export const HTML_NS: string = "http://www.w3.org/1999/xhtml";
export const SVG_NS: string = "http://www.w3.org/2000/svg";
export const TAG_NAMES = {
[HTML_NS]: [
@ -97,10 +106,9 @@ export const TAG_NAMES = {
"table", "thead", "tbody", "tr", "th", "td", "hr",
"pre", "code", "button", "time", "input", "textarea", "label", "form", "progress", "output", "video"],
[SVG_NS]: ["svg", "circle"]
};
export const tag = {};
} as const;
export const tag: { [tagName in typeof TAG_NAMES[string][number]]: (attributes?: BasicAttributes<never> | Child | Child[], children?: Child | Child[]) => Element } = {} as any;
for (const [ns, tags] of Object.entries(TAG_NAMES)) {
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");
you may not use this file except in compliance with the License.
@ -14,11 +14,22 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import {tag} from "./html.js";
import {IView, IMountArgs, ViewNode} from "./types";
import {tag} from "./html";
export function errorToDOM(error) {
export function mountView(view: IView, mountArgs?: IMountArgs): ViewNode {
let node;
try {
node = view.mount(mountArgs);
} catch (err) {
node = errorToDOM(err);
}
return node;
}
export function errorToDOM(error: Error): Element {
const stack = new Error().stack;
let callee = null;
let callee: string | null = null;
if (stack) {
callee = stack.split("\n")[1];
}
@ -29,3 +40,13 @@ export function errorToDOM(error) {
tag.pre(error.stack),
]);
}
export function insertAt(parentNode: Element, idx: number, childNode: Node): void {
const isLast = idx === parentNode.childElementCount;
if (isLast) {
parentNode.appendChild(childNode);
} else {
const nextDomNode = parentNode.children[idx];
parentNode.insertBefore(childNode, nextDomNode);
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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