This commit is contained in:
Bruno Windels 2021-06-10 18:29:10 +02:00
parent cb051ad161
commit 757e08c62c
12 changed files with 374 additions and 178 deletions

View file

@ -125,8 +125,7 @@ export class BaseMessageTile extends SimpleTile {
react(key, log = null) {
return this.logger.wrapOrRun(log, "react", async log => {
const existingAnnotation = await this._entry.getOwnAnnotationEntry(this._timeline, key);
const redaction = existingAnnotation?.pendingRedaction;
const redaction = this._entry.getAnnotationPendingRedaction(key);
if (redaction && !redaction.pendingEvent.hasStartedSending) {
log.set("abort_redaction", true);
await redaction.pendingEvent.abort();
@ -138,9 +137,16 @@ export class BaseMessageTile extends SimpleTile {
redactReaction(key, log = null) {
return this.logger.wrapOrRun(log, "redactReaction", async log => {
const redaction = this._entry.getAnnotationPendingRedaction(key);
if (redaction) {
log.set("already_redacting", true);
return;
}
const entry = await this._entry.getOwnAnnotationEntry(this._timeline, key);
if (entry) {
await this._room.sendRedaction(entry.id, null, log);
} else {
log.set("no_reaction", true);
}
});
}

View file

@ -452,23 +452,6 @@ export class BaseRoom extends EventEmitter {
return observable;
}
async getOwnAnnotationEntry(targetId, key) {
const txn = await this._storage.readWriteTxn([
this._storage.storeNames.timelineEvents,
this._storage.storeNames.timelineRelations,
]);
const relations = await txn.timelineRelations.getForTargetAndType(this.id, targetId, ANNOTATION_RELATION_TYPE);
for (const relation of relations) {
const annotation = await txn.timelineEvents.getByEventId(this.id, relation.sourceEventId);
if (annotation.event.sender === this._user.id && getRelation(annotation.event).key === key) {
const eventEntry = new EventEntry(annotation, this._fragmentIdComparer);
// add local relations
return eventEntry;
}
}
return null;
}
async _readEventById(eventId) {
let stores = [this._storage.storeNames.timelineEvents];
if (this.isEncrypted) {

View file

@ -19,35 +19,33 @@ import {getRelationFromContent} from "./relations.js";
export class PendingAnnotations {
constructor() {
this.aggregatedAnnotations = new Map();
// this contains both pending annotation entries, and pending redactions of remote annotation entries
this._entries = [];
}
/** adds either a pending annotation entry, or a remote annotation entry with a pending redaction */
add(annotationEntry) {
const relation = getRelationFromContent(annotationEntry.content);
const key = relation.key;
add(entry) {
const {key} = entry.ownOrRedactedRelation;
if (!key) {
return;
}
const count = this.aggregatedAnnotations.get(key) || 0;
const addend = annotationEntry.isRedacted ? -1 : 1;
console.log("add", count, addend);
const addend = entry.isRedaction ? -1 : 1;
this.aggregatedAnnotations.set(key, count + addend);
this._entries.push(annotationEntry);
this._entries.push(entry);
}
/** removes either a pending annotation entry, or a remote annotation entry with a pending redaction */
remove(annotationEntry) {
const idx = this._entries.indexOf(annotationEntry);
remove(entry) {
const idx = this._entries.indexOf(entry);
if (idx === -1) {
return;
}
this._entries.splice(idx, 1);
const relation = getRelationFromContent(annotationEntry.content);
const key = relation.key;
const {key} = entry.ownOrRedactedRelation;
let count = this.aggregatedAnnotations.get(key);
if (count !== undefined) {
const addend = annotationEntry.isRedacted ? 1 : -1;
const addend = entry.isRedaction ? 1 : -1;
count += addend;
if (count <= 0) {
this.aggregatedAnnotations.delete(key);
@ -60,13 +58,22 @@ export class PendingAnnotations {
findForKey(key) {
return this._entries.find(e => {
const relation = getRelationFromContent(e.content);
if (relation.key === key) {
if (relation && relation.key === key) {
return e;
}
});
}
findRedactionForKey(key) {
return this._entries.find(e => {
const relation = e.redactingRelation;
if (relation && relation.key === key) {
return e;
}
});
}
get isEmpty() {
return this._entries.length;
return this._entries.length === 0;
}
}

View file

@ -15,7 +15,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import {SortedArray, MappedList, ConcatList, ObservableArray} from "../../../observable/index.js";
import {SortedArray, AsyncMappedList, ConcatList, ObservableArray} from "../../../observable/index.js";
import {Disposables} from "../../../utils/Disposables.js";
import {Direction} from "./Direction.js";
import {TimelineReader} from "./persistence/TimelineReader.js";
@ -101,85 +101,65 @@ export class Timeline {
_setupEntries(timelineEntries) {
this._remoteEntries.setManySorted(timelineEntries);
if (this._pendingEvents) {
this._localEntries = new MappedList(this._pendingEvents, pe => {
const pee = new PendingEventEntry({pendingEvent: pe, member: this._ownMember, clock: this._clock});
this._onAddPendingEvent(pee);
return pee;
}, (pee, params) => {
// is sending but redacted, who do we detect that here to remove the relation?
pee.notifyUpdate(params);
}, pee => this._onRemovePendingEvent(pee));
this._localEntries = new AsyncMappedList(this._pendingEvents,
pe => this._mapPendingEventToEntry(pe),
(pee, params) => {
// is sending but redacted, who do we detect that here to remove the relation?
pee.notifyUpdate(params);
},
pee => this._applyAndEmitLocalRelationChange(pee, target => target.removeLocalRelation(pee))
);
} else {
this._localEntries = new ObservableArray();
}
this._allEntries = new ConcatList(this._remoteEntries, this._localEntries);
}
_onAddPendingEvent(pee) {
let redactedEntry;
this._applyAndEmitLocalRelationChange(pee.pendingEvent, target => {
const wasRedacted = target.isRedacted;
const params = target.addLocalRelation(pee);
if (!wasRedacted && target.isRedacted) {
redactedEntry = target;
}
return params;
});
if (redactedEntry) {
this._addLocallyRedactedRelationToTarget(redactedEntry);
async _mapPendingEventToEntry(pe) {
// we load the remote redaction target for pending events,
// so if we are redacting a relation, we can pass the redaction
// to the relation target and the removal of the relation can
// be taken into account for local echo.
let redactionTarget;
if (pe.eventType === REDACTION_TYPE && pe.relatedEventId) {
const txn = await this._storage.readWriteTxn([
this._storage.storeNames.timelineEvents,
]);
const redactionTargetEntry = await txn.timelineEvents.getByEventId(this._roomId, pe.relatedEventId);
redactionTarget = redactionTargetEntry?.event;
}
const pee = new PendingEventEntry({pendingEvent: pe, member: this._ownMember, clock: this._clock, redactionTarget});
this._applyAndEmitLocalRelationChange(pee, target => target.addLocalRelation(pee));
return pee;
}
_addLocallyRedactedRelationToTarget(redactedEntry) {
const redactedRelation = getRelationFromContent(redactedEntry.content);
if (redactedRelation?.event_id) {
const found = this._remoteEntries.findAndUpdate(
e => e.id === redactedRelation.event_id,
relationTarget => relationTarget.addLocalRelation(redactedEntry) || false
);
}
}
_onRemovePendingEvent(pee) {
let unredactedEntry;
this._applyAndEmitLocalRelationChange(pee.pendingEvent, target => {
const wasRedacted = target.isRedacted;
const params = target.removeLocalRelation(pee);
if (wasRedacted && !target.isRedacted) {
unredactedEntry = target;
}
return params;
});
if (unredactedEntry) {
const redactedRelation = getRelationFromContent(unredactedEntry.content);
if (redactedRelation?.event_id) {
this._remoteEntries.findAndUpdate(
e => e.id === redactedRelation.event_id,
relationTarget => relationTarget.removeLocalRelation(unredactedEntry) || false
);
}
}
}
_applyAndEmitLocalRelationChange(pe, updater) {
_applyAndEmitLocalRelationChange(pee, updater) {
const updateOrFalse = e => {
const params = updater(e);
return params ? params : false;
};
let found = false;
const {relatedTxnId} = pee.pendingEvent;
// first, look in local entries based on txn id
if (pe.relatedTxnId) {
const found = this._localEntries.findAndUpdate(
e => e.id === pe.relatedTxnId,
if (relatedTxnId) {
found = this._localEntries.findAndUpdate(
e => e.id === relatedTxnId,
updateOrFalse,
);
if (found) {
return;
}
}
// now look in remote entries based on event id
if (pe.relatedEventId) {
if (!found && pee.relatedEventId) {
this._remoteEntries.findAndUpdate(
e => e.id === pe.relatedEventId,
e => e.id === pee.relatedEventId,
updateOrFalse
);
}
// also look for a relation target to update with this redaction
if (pee.redactingRelation) {
const eventId = pee.redactingRelation.event_id;
const found = this._remoteEntries.findAndUpdate(
e => e.id === eventId,
updateOrFalse
);
}
@ -231,32 +211,17 @@ export class Timeline {
for (const pee of this._localEntries) {
// this will work because we set relatedEventId when removing remote echos
if (pee.relatedEventId) {
const relationTarget = entries.find(e => e.id === pee.relatedEventId);
if (relationTarget) {
const wasRedacted = relationTarget.isRedacted;
// no need to emit here as this entry is about to be added
relationTarget.addLocalRelation(pee);
if (!wasRedacted && relationTarget.isRedacted) {
this._addLocallyRedactedRelationToTarget(relationTarget);
}
} else if (pee.eventType === REDACTION_TYPE) {
// if pee is a redaction, we need to lookup the event it is redacting,
// and see if that is a relation of one of the entries
const redactedEntry = this.getByEventId(pee.relatedEventId);
if (redactedEntry) {
const relation = getRelation(redactedEntry);
if (relation) {
const redactedRelationTarget = entries.find(e => e.id === relation.event_id);
redactedRelationTarget?.addLocalRelation(redactedEntry);
}
}
} else {
// TODO: errors are swallowed here
// console.log(`could not find target for pee ${pee.relatedEventId} ` + entries.filter(e => !["m.reaction", "m.room.redaction"].includes(e.eventType)).map(e => `${e.id}: ${e.content?.body}`).join(","));
// console.log(`could not find target for pee ${pee.relatedEventId} ` + entries.filter(e => "m.reaction" === e.eventType).map(e => `${e.id}: ${getRelation(e)?.key}`).join(","));
// console.log(`could not find target for pee ${pee.relatedEventId} ` + entries.map(e => `${e.id}: ${e._eventEntry.key.substr(e._eventEntry.key.lastIndexOf("|") + 1)}`).join(","));
}
}
if (pee.redactingRelation) {
const eventId = pee.redactingRelation.event_id;
const relationTarget = entries.find(e => e.id === eventId);
if (relationTarget) {
relationTarget.addLocalRelation(pee);
}
}
}
@ -344,6 +309,7 @@ export class Timeline {
}
import {FragmentIdComparer} from "./FragmentIdComparer.js";
import {poll} from "../../../mocks/poll.js";
import {Clock as MockClock} from "../../../mocks/Clock.js";
import {createMockStorage} from "../../../mocks/Storage.js";
import {createEvent, withTextBody, withSender} from "../../../mocks/event.js";
@ -355,6 +321,14 @@ import {PendingEvent} from "../sending/PendingEvent.js";
export function tests() {
const fragmentIdComparer = new FragmentIdComparer([]);
const roomId = "$abc";
const noopHandler = {};
noopHandler.onAdd =
noopHandler.onUpdate =
noopHandler.onRemove =
noopHandler.onMove =
noopHandler.onReset =
function() {};
return {
"adding or replacing entries before subscribing to entries does not loose local relations": async assert => {
const pendingEvents = new ObservableArray();
@ -384,10 +358,11 @@ export function tests() {
content: {},
relatedEventId: event2.event_id
}}));
// 4. subscribe (it's now safe to iterate timeline.entries)
timeline.entries.subscribe({});
// 4. subscribe (it's now safe to iterate timeline.entries)
timeline.entries.subscribe(noopHandler);
// 5. check the local relation got correctly aggregated
assert.equal(Array.from(timeline.entries)[0].isRedacting, true);
const locallyRedacted = await poll(() => Array.from(timeline.entries)[0].isRedacting);
assert.equal(locallyRedacted, true);
}
}
}

View file

@ -34,6 +34,10 @@ export class BaseEventEntry extends BaseEntry {
return this.isRedacting;
}
get isRedaction() {
return this.eventType === REDACTION_TYPE;
}
get redactionReason() {
if (this._pendingRedactions) {
return this._pendingRedactions[0].content?.reason;
@ -46,7 +50,7 @@ export class BaseEventEntry extends BaseEntry {
@return [string] returns the name of the field that has changed, if any
*/
addLocalRelation(entry) {
if (entry.eventType === REDACTION_TYPE) {
if (entry.eventType === REDACTION_TYPE && entry.relatedEventId === this.id) {
if (!this._pendingRedactions) {
this._pendingRedactions = [];
}
@ -55,23 +59,25 @@ export class BaseEventEntry extends BaseEntry {
return "isRedacted";
}
} else {
const relation = getRelationFromContent(entry.content);
if (relation && relation.rel_type === ANNOTATION_RELATION_TYPE) {
if (!this._pendingAnnotations) {
this._pendingAnnotations = new PendingAnnotations();
const relation = entry.ownOrRedactedRelation;
if (relation && relation.event_id === this.id) {
if (relation.rel_type === ANNOTATION_RELATION_TYPE) {
if (!this._pendingAnnotations) {
this._pendingAnnotations = new PendingAnnotations();
}
this._pendingAnnotations.add(entry);
return "pendingAnnotations";
}
this._pendingAnnotations.add(entry);
return "pendingAnnotations";
}
}
}
/**
deaggregates local relation.
deaggregates local relation or a local redaction of a remote relation.
@return [string] returns the name of the field that has changed, if any
*/
removeLocalRelation(entry) {
if (entry.eventType === REDACTION_TYPE && this._pendingRedactions) {
if (entry.eventType === REDACTION_TYPE && entry.relatedEventId === this.id && this._pendingRedactions) {
const countBefore = this._pendingRedactions.length;
this._pendingRedactions = this._pendingRedactions.filter(e => e !== entry);
if (this._pendingRedactions.length === 0) {
@ -81,13 +87,15 @@ export class BaseEventEntry extends BaseEntry {
}
}
} else {
const relation = getRelationFromContent(entry.content);
if (relation && relation.rel_type === ANNOTATION_RELATION_TYPE && this._pendingAnnotations) {
this._pendingAnnotations.remove(entry);
if (this._pendingAnnotations.isEmpty) {
this._pendingAnnotations = null;
const relation = entry.ownOrRedactedRelation;
if (relation && relation.event_id === this.id) {
if (relation.rel_type === ANNOTATION_RELATION_TYPE && this._pendingAnnotations) {
this._pendingAnnotations.remove(entry);
if (this._pendingAnnotations.isEmpty) {
this._pendingAnnotations = null;
}
return "pendingAnnotations";
}
return "pendingAnnotations";
}
}
}
@ -120,4 +128,8 @@ export class BaseEventEntry extends BaseEntry {
async getOwnAnnotationEntry(timeline, key) {
return this._pendingAnnotations?.findForKey(key);
}
getAnnotationPendingRedaction(key) {
return this._pendingAnnotations?.findRedactionForKey(key);
}
}

View file

@ -16,14 +16,16 @@ limitations under the License.
import {PENDING_FRAGMENT_ID} from "./BaseEntry.js";
import {BaseEventEntry} from "./BaseEventEntry.js";
import {getRelationFromContent} from "../relations.js";
export class PendingEventEntry extends BaseEventEntry {
constructor({pendingEvent, member, clock}) {
constructor({pendingEvent, member, clock, redactionTarget}) {
super(null);
this._pendingEvent = pendingEvent;
/** @type {RoomMember} */
this._member = member;
this._clock = clock;
this._redactionTarget = redactionTarget;
}
get fragmentId() {
@ -86,6 +88,24 @@ export class PendingEventEntry extends BaseEventEntry {
return this._pendingEvent.relatedEventId;
}
get redactingRelation() {
if (this._redactionTarget) {
return getRelationFromContent(this._redactionTarget.content);
}
}
/**
* returns either the relationship on this entry,
* or the relationship this entry is redacting.
*
* Useful while aggregating relations for local echo. */
get ownOrRedactedRelation() {
if (this._redactionTarget) {
return getRelationFromContent(this._redactionTarget.content);
} else {
return getRelationFromContent(this._pendingEvent.content);
}
}
getOwnAnnotationId(_, key) {
// TODO: implement this once local reactions are implemented
return null;

View file

@ -23,6 +23,7 @@ import {BaseObservableMap} from "./map/BaseObservableMap.js";
export { ObservableArray } from "./list/ObservableArray.js";
export { SortedArray } from "./list/SortedArray.js";
export { MappedList } from "./list/MappedList.js";
export { AsyncMappedList } from "./list/AsyncMappedList.js";
export { ConcatList } from "./list/ConcatList.js";
export { ObservableMap } from "./map/ObservableMap.js";

View file

@ -0,0 +1,150 @@
/*
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.
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 {BaseMappedList, runAdd, runUpdate, runRemove, runMove, runReset} from "./BaseMappedList.js";
export class AsyncMappedList extends BaseMappedList {
constructor(sourceList, mapper, updater, removeCallback) {
super(sourceList, mapper, updater, removeCallback);
this._eventQueue = null;
}
onSubscribeFirst() {
this._sourceUnsubscribe = this._sourceList.subscribe(this);
this._eventQueue = [];
this._mappedValues = [];
let idx = 0;
for (const item of this._sourceList) {
this._eventQueue.push(new AddEvent(idx, item));
idx += 1;
}
this._flush();
}
async _flush() {
if (this._flushing) {
return;
}
this._flushing = true;
try {
while (this._eventQueue.length) {
const event = this._eventQueue.shift();
await event.run(this);
}
} finally {
this._flushing = false;
}
}
onReset() {
if (this._eventQueue) {
this._eventQueue.push(new ResetEvent());
this._flush();
}
}
onAdd(index, value) {
if (this._eventQueue) {
this._eventQueue.push(new AddEvent(index, value));
this._flush();
}
}
onUpdate(index, value, params) {
if (this._eventQueue) {
this._eventQueue.push(new UpdateEvent(index, value, params));
this._flush();
}
}
onRemove(index) {
if (this._eventQueue) {
this._eventQueue.push(new RemoveEvent(index));
this._flush();
}
}
onMove(fromIdx, toIdx) {
if (this._eventQueue) {
this._eventQueue.push(new MoveEvent(fromIdx, toIdx));
this._flush();
}
}
onUnsubscribeLast() {
this._sourceUnsubscribe();
this._eventQueue = null;
this._mappedValues = null;
}
}
class AddEvent {
constructor(index, value) {
this.index = index;
this.value = value;
}
async run(list) {
const mappedValue = await list._mapper(this.value);
runAdd(list, this.index, mappedValue);
}
}
class UpdateEvent {
constructor(index, value, params) {
this.index = index;
this.value = value;
this.params = params;
}
async run(list) {
runUpdate(list, this.index, this.value, this.params);
}
}
class RemoveEvent {
constructor(index) {
this.index = index;
}
async run(list) {
runRemove(list, this.index);
}
}
class MoveEvent {
constructor(fromIdx, toIdx) {
this.fromIdx = fromIdx;
this.toIdx = toIdx;
}
async run(list) {
runMove(list, this.fromIdx, this.toIdx);
}
}
class ResetEvent {
async run(list) {
runReset(list);
}
}
export function tests() {
return {
}
}

View file

@ -0,0 +1,77 @@
/*
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.
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 {BaseObservableList} from "./BaseObservableList.js";
import {findAndUpdateInArray} from "./common.js";
export class BaseMappedList extends BaseObservableList {
constructor(sourceList, mapper, updater, removeCallback) {
super();
this._sourceList = sourceList;
this._mapper = mapper;
this._updater = updater;
this._removeCallback = removeCallback;
this._mappedValues = null;
this._sourceUnsubscribe = null;
}
findAndUpdate(predicate, updater) {
return findAndUpdateInArray(predicate, this._mappedValues, this, updater);
}
get length() {
return this._mappedValues.length;
}
[Symbol.iterator]() {
return this._mappedValues.values();
}
}
export function runAdd(list, index, mappedValue) {
list._mappedValues.splice(index, 0, mappedValue);
list.emitAdd(index, mappedValue);
}
export function runUpdate(list, index, value, params) {
const mappedValue = list._mappedValues[index];
if (list._updater) {
list._updater(mappedValue, params, value);
}
list.emitUpdate(index, mappedValue, params);
}
export function runRemove(list, index) {
const mappedValue = list._mappedValues[index];
list._mappedValues.splice(index, 1);
if (list._removeCallback) {
list._removeCallback(mappedValue);
}
list.emitRemove(index, mappedValue);
}
export function runMove(list, fromIdx, toIdx) {
const mappedValue = list._mappedValues[fromIdx];
list._mappedValues.splice(fromIdx, 1);
list._mappedValues.splice(toIdx, 0, mappedValue);
list.emitMove(fromIdx, toIdx, mappedValue);
}
export function runReset(list) {
list._mappedValues = [];
list.emitReset();
}

View file

@ -15,20 +15,9 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import {BaseObservableList} from "./BaseObservableList.js";
import {findAndUpdateInArray} from "./common.js";
export class MappedList extends BaseObservableList {
constructor(sourceList, mapper, updater, removeCallback) {
super();
this._sourceList = sourceList;
this._mapper = mapper;
this._updater = updater;
this._removeCallback = removeCallback;
this._sourceUnsubscribe = null;
this._mappedValues = null;
}
import {BaseMappedList, runAdd, runUpdate, runRemove, runMove, runReset} from "./BaseMappedList.js";
export class MappedList extends BaseMappedList {
onSubscribeFirst() {
this._sourceUnsubscribe = this._sourceList.subscribe(this);
this._mappedValues = [];
@ -38,14 +27,12 @@ export class MappedList extends BaseObservableList {
}
onReset() {
this._mappedValues = [];
this.emitReset();
runReset(this);
}
onAdd(index, value) {
const mappedValue = this._mapper(value);
this._mappedValues.splice(index, 0, mappedValue);
this.emitAdd(index, mappedValue);
runAdd(this, index, mappedValue);
}
onUpdate(index, value, params) {
@ -53,47 +40,24 @@ export class MappedList extends BaseObservableList {
if (!this._mappedValues) {
return;
}
const mappedValue = this._mappedValues[index];
if (this._updater) {
this._updater(mappedValue, params, value);
}
this.emitUpdate(index, mappedValue, params);
runUpdate(this, index, value, params);
}
onRemove(index) {
const mappedValue = this._mappedValues[index];
this._mappedValues.splice(index, 1);
if (this._removeCallback) {
this._removeCallback(mappedValue);
}
this.emitRemove(index, mappedValue);
runRemove(this, index);
}
onMove(fromIdx, toIdx) {
const mappedValue = this._mappedValues[fromIdx];
this._mappedValues.splice(fromIdx, 1);
this._mappedValues.splice(toIdx, 0, mappedValue);
this.emitMove(fromIdx, toIdx, mappedValue);
runMove(this, fromIdx, toIdx);
}
onUnsubscribeLast() {
this._sourceUnsubscribe();
}
findAndUpdate(predicate, updater) {
return findAndUpdateInArray(predicate, this._mappedValues, this, updater);
}
get length() {
return this._mappedValues.length;
}
[Symbol.iterator]() {
return this._mappedValues.values();
}
}
import {ObservableArray} from "./ObservableArray.js";
import {BaseObservableList} from "./BaseObservableList.js";
export async function tests() {
class MockList extends BaseObservableList {

View file

@ -243,7 +243,7 @@ only loads when the top comes into view*/
.Timeline_messageReactions button.haveReacted.isPending {
animation-name: glow-reaction-border;
animation-duration: 0.8s;
animation-duration: 0.5s;
animation-direction: alternate;
animation-iteration-count: infinite;
animation-timing-function: linear;

View file

@ -74,6 +74,7 @@ export class TimelineList extends ListView {
}
}
catch (err) {
console.error(err);
//ignore error, as it is handled in the VM
}
finally {