350 lines
13 KiB
JavaScript
350 lines
13 KiB
JavaScript
|
|
import _defineProperty from "@babel/runtime/helpers/defineProperty";
|
||
|
|
/*
|
||
|
|
Copyright 2019, 2021, 2023 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 { EventStatus, MatrixEventEvent } from "./event.js";
|
||
|
|
import { logger } from "../logger.js";
|
||
|
|
import { RelationType } from "../@types/event.js";
|
||
|
|
import { TypedEventEmitter } from "./typed-event-emitter.js";
|
||
|
|
import { Room } from "./room.js";
|
||
|
|
export let RelationsEvent = /*#__PURE__*/function (RelationsEvent) {
|
||
|
|
RelationsEvent["Add"] = "Relations.add";
|
||
|
|
RelationsEvent["Remove"] = "Relations.remove";
|
||
|
|
RelationsEvent["Redaction"] = "Relations.redaction";
|
||
|
|
return RelationsEvent;
|
||
|
|
}({});
|
||
|
|
const matchesEventType = (eventType, targetEventType, altTargetEventTypes = []) => [targetEventType, ...altTargetEventTypes].includes(eventType);
|
||
|
|
|
||
|
|
/**
|
||
|
|
* A container for relation events that supports easy access to common ways of
|
||
|
|
* aggregating such events. Each instance holds events that of a single relation
|
||
|
|
* type and event type. All of the events also relate to the same original event.
|
||
|
|
*
|
||
|
|
* The typical way to get one of these containers is via
|
||
|
|
* EventTimelineSet#getRelationsForEvent.
|
||
|
|
*/
|
||
|
|
export class Relations extends TypedEventEmitter {
|
||
|
|
/**
|
||
|
|
* @param relationType - The type of relation involved, such as "m.annotation", "m.reference", "m.replace", etc.
|
||
|
|
* @param eventType - The relation event's type, such as "m.reaction", etc.
|
||
|
|
* @param client - The client which created this instance. For backwards compatibility also accepts a Room.
|
||
|
|
* @param altEventTypes - alt event types for relation events, for example to support unstable prefixed event types
|
||
|
|
*/
|
||
|
|
constructor(relationType, eventType, client, altEventTypes) {
|
||
|
|
super();
|
||
|
|
_defineProperty(this, "relationEventIds", new Set());
|
||
|
|
_defineProperty(this, "relations", new Set());
|
||
|
|
_defineProperty(this, "annotationsByKey", {});
|
||
|
|
_defineProperty(this, "annotationsBySender", {});
|
||
|
|
_defineProperty(this, "sortedAnnotationsByKey", []);
|
||
|
|
_defineProperty(this, "targetEvent", null);
|
||
|
|
_defineProperty(this, "creationEmitted", false);
|
||
|
|
_defineProperty(this, "replacementUpdateId", 0);
|
||
|
|
_defineProperty(this, "client", void 0);
|
||
|
|
/**
|
||
|
|
* Listens for event status changes to remove cancelled events.
|
||
|
|
*
|
||
|
|
* @param event - The event whose status has changed
|
||
|
|
* @param status - The new status
|
||
|
|
*/
|
||
|
|
_defineProperty(this, "onEventStatus", (event, status) => {
|
||
|
|
if (!event.isSending()) {
|
||
|
|
// Sending is done, so we don't need to listen anymore
|
||
|
|
event.removeListener(MatrixEventEvent.Status, this.onEventStatus);
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
if (status !== EventStatus.CANCELLED) {
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
// Event was cancelled, remove from the collection
|
||
|
|
event.removeListener(MatrixEventEvent.Status, this.onEventStatus);
|
||
|
|
this.removeEvent(event);
|
||
|
|
});
|
||
|
|
/**
|
||
|
|
* For relations that have been redacted, we want to remove them from
|
||
|
|
* aggregation data sets and emit an update event.
|
||
|
|
*
|
||
|
|
* To do so, we listen for `Event.beforeRedaction`, which happens:
|
||
|
|
* - after the server accepted the redaction and remote echoed back to us
|
||
|
|
* - before the original event has been marked redacted in the client
|
||
|
|
*
|
||
|
|
* @param redactedEvent - The original relation event that is about to be redacted.
|
||
|
|
*/
|
||
|
|
_defineProperty(this, "onBeforeRedaction", async redactedEvent => {
|
||
|
|
if (!this.relations.has(redactedEvent)) {
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
this.relations.delete(redactedEvent);
|
||
|
|
if (this.relationType === RelationType.Annotation) {
|
||
|
|
// Remove the redacted annotation from aggregation by key
|
||
|
|
this.removeAnnotationFromAggregation(redactedEvent);
|
||
|
|
} else if (this.relationType === RelationType.Replace) {
|
||
|
|
await this.updateTargetEventReplacement();
|
||
|
|
}
|
||
|
|
redactedEvent.removeListener(MatrixEventEvent.BeforeRedaction, this.onBeforeRedaction);
|
||
|
|
this.emit(RelationsEvent.Redaction, redactedEvent);
|
||
|
|
});
|
||
|
|
this.relationType = relationType;
|
||
|
|
this.eventType = eventType;
|
||
|
|
this.altEventTypes = altEventTypes;
|
||
|
|
this.client = client instanceof Room ? client.client : client;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Add relation events to this collection.
|
||
|
|
*
|
||
|
|
* @param event - The new relation event to be added.
|
||
|
|
*/
|
||
|
|
async addEvent(event) {
|
||
|
|
if (this.relationEventIds.has(event.getId())) {
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
const relation = event.getRelation();
|
||
|
|
if (!relation) {
|
||
|
|
logger.error("Event must have relation info");
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
const relationType = relation.rel_type;
|
||
|
|
const eventType = event.getType();
|
||
|
|
if (this.relationType !== relationType || !matchesEventType(eventType, this.eventType, this.altEventTypes)) {
|
||
|
|
logger.error("Event relation info doesn't match this container");
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
// If the event is in the process of being sent, listen for cancellation
|
||
|
|
// so we can remove the event from the collection.
|
||
|
|
if (event.isSending()) {
|
||
|
|
event.on(MatrixEventEvent.Status, this.onEventStatus);
|
||
|
|
}
|
||
|
|
this.relations.add(event);
|
||
|
|
this.relationEventIds.add(event.getId());
|
||
|
|
if (this.relationType === RelationType.Annotation) {
|
||
|
|
this.addAnnotationToAggregation(event);
|
||
|
|
} else if (this.relationType === RelationType.Replace) {
|
||
|
|
await this.updateTargetEventReplacement();
|
||
|
|
}
|
||
|
|
event.on(MatrixEventEvent.BeforeRedaction, this.onBeforeRedaction);
|
||
|
|
this.emit(RelationsEvent.Add, event);
|
||
|
|
this.maybeEmitCreated();
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Remove relation event from this collection.
|
||
|
|
*
|
||
|
|
* @param event - The relation event to remove.
|
||
|
|
*/
|
||
|
|
async removeEvent(event) {
|
||
|
|
if (!this.relations.has(event)) {
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
this.relations.delete(event);
|
||
|
|
if (this.relationType === RelationType.Annotation) {
|
||
|
|
this.removeAnnotationFromAggregation(event);
|
||
|
|
} else if (this.relationType === RelationType.Replace) {
|
||
|
|
await this.updateTargetEventReplacement();
|
||
|
|
}
|
||
|
|
this.emit(RelationsEvent.Remove, event);
|
||
|
|
}
|
||
|
|
/**
|
||
|
|
* Get all relation events in this collection.
|
||
|
|
*
|
||
|
|
* These are currently in the order of insertion to this collection, which
|
||
|
|
* won't match timeline order in the case of scrollback.
|
||
|
|
* TODO: Tweak `addEvent` to insert correctly for scrollback.
|
||
|
|
*
|
||
|
|
* Relation events in insertion order.
|
||
|
|
*/
|
||
|
|
getRelations() {
|
||
|
|
return [...this.relations];
|
||
|
|
}
|
||
|
|
addAnnotationToAggregation(event) {
|
||
|
|
const {
|
||
|
|
key
|
||
|
|
} = event.getRelation() ?? {};
|
||
|
|
if (!key) return;
|
||
|
|
let eventsForKey = this.annotationsByKey[key];
|
||
|
|
if (!eventsForKey) {
|
||
|
|
eventsForKey = this.annotationsByKey[key] = new Set();
|
||
|
|
this.sortedAnnotationsByKey.push([key, eventsForKey]);
|
||
|
|
}
|
||
|
|
// Add the new event to the set for this key
|
||
|
|
eventsForKey.add(event);
|
||
|
|
// Re-sort the [key, events] pairs in descending order of event count
|
||
|
|
this.sortedAnnotationsByKey.sort((a, b) => {
|
||
|
|
const aEvents = a[1];
|
||
|
|
const bEvents = b[1];
|
||
|
|
return bEvents.size - aEvents.size;
|
||
|
|
});
|
||
|
|
const sender = event.getSender();
|
||
|
|
let eventsFromSender = this.annotationsBySender[sender];
|
||
|
|
if (!eventsFromSender) {
|
||
|
|
eventsFromSender = this.annotationsBySender[sender] = new Set();
|
||
|
|
}
|
||
|
|
// Add the new event to the set for this sender
|
||
|
|
eventsFromSender.add(event);
|
||
|
|
}
|
||
|
|
removeAnnotationFromAggregation(event) {
|
||
|
|
const {
|
||
|
|
key
|
||
|
|
} = event.getRelation() ?? {};
|
||
|
|
if (!key) return;
|
||
|
|
const eventsForKey = this.annotationsByKey[key];
|
||
|
|
if (eventsForKey) {
|
||
|
|
eventsForKey.delete(event);
|
||
|
|
|
||
|
|
// Re-sort the [key, events] pairs in descending order of event count
|
||
|
|
this.sortedAnnotationsByKey.sort((a, b) => {
|
||
|
|
const aEvents = a[1];
|
||
|
|
const bEvents = b[1];
|
||
|
|
return bEvents.size - aEvents.size;
|
||
|
|
});
|
||
|
|
}
|
||
|
|
const sender = event.getSender();
|
||
|
|
const eventsFromSender = this.annotationsBySender[sender];
|
||
|
|
if (eventsFromSender) {
|
||
|
|
eventsFromSender.delete(event);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
/**
|
||
|
|
* Get all events in this collection grouped by key and sorted by descending
|
||
|
|
* event count in each group.
|
||
|
|
*
|
||
|
|
* This is currently only supported for the annotation relation type.
|
||
|
|
*
|
||
|
|
* An array of [key, events] pairs sorted by descending event count.
|
||
|
|
* The events are stored in a Set (which preserves insertion order).
|
||
|
|
*/
|
||
|
|
getSortedAnnotationsByKey() {
|
||
|
|
if (this.relationType !== RelationType.Annotation) {
|
||
|
|
// Other relation types are not grouped currently.
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
return this.sortedAnnotationsByKey;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Get all events in this collection grouped by sender.
|
||
|
|
*
|
||
|
|
* This is currently only supported for the annotation relation type.
|
||
|
|
*
|
||
|
|
* An object with each relation sender as a key and the matching Set of
|
||
|
|
* events for that sender as a value.
|
||
|
|
*/
|
||
|
|
getAnnotationsBySender() {
|
||
|
|
if (this.relationType !== RelationType.Annotation) {
|
||
|
|
// Other relation types are not grouped currently.
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
return this.annotationsBySender;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Returns the most recent (and allowed) m.replace relation, if any.
|
||
|
|
*
|
||
|
|
* This is currently only supported for the m.replace relation type,
|
||
|
|
* once the target event is known, see `addEvent`.
|
||
|
|
*/
|
||
|
|
async getLastReplacement() {
|
||
|
|
if (this.relationType !== RelationType.Replace) {
|
||
|
|
// Aggregating on last only makes sense for this relation type
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
if (!this.targetEvent) {
|
||
|
|
// Don't know which replacements to accept yet.
|
||
|
|
// This method shouldn't be called before the original
|
||
|
|
// event is known anyway.
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
|
||
|
|
// the all-knowning server tells us that the event at some point had
|
||
|
|
// this timestamp for its replacement, so any following replacement should definitely not be less
|
||
|
|
const replaceRelation = this.targetEvent.getServerAggregatedRelation(RelationType.Replace);
|
||
|
|
const minTs = replaceRelation?.origin_server_ts;
|
||
|
|
const lastReplacement = this.getRelations().reduce((last, event) => {
|
||
|
|
if (event.getSender() !== this.targetEvent.getSender()) {
|
||
|
|
return last;
|
||
|
|
}
|
||
|
|
if (minTs && minTs > event.getTs()) {
|
||
|
|
return last;
|
||
|
|
}
|
||
|
|
if (last && last.getTs() > event.getTs()) {
|
||
|
|
return last;
|
||
|
|
}
|
||
|
|
return event;
|
||
|
|
}, null);
|
||
|
|
if (lastReplacement?.shouldAttemptDecryption() && this.client.getCrypto()) {
|
||
|
|
// Dirty but we are expecting to pass the cryptoBackend which is not accessible here
|
||
|
|
await lastReplacement.attemptDecryption(this.client.getCrypto());
|
||
|
|
} else if (lastReplacement?.isBeingDecrypted()) {
|
||
|
|
await lastReplacement.getDecryptionPromise();
|
||
|
|
}
|
||
|
|
return lastReplacement;
|
||
|
|
}
|
||
|
|
|
||
|
|
/*
|
||
|
|
* @param targetEvent - the event the relations are related to.
|
||
|
|
*/
|
||
|
|
async setTargetEvent(event) {
|
||
|
|
if (this.targetEvent === event) {
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
this.targetEvent = event;
|
||
|
|
if (this.relationType === RelationType.Replace) {
|
||
|
|
await this.updateTargetEventReplacement();
|
||
|
|
}
|
||
|
|
this.maybeEmitCreated();
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Updates the target event with the latest replacement.
|
||
|
|
*
|
||
|
|
* Multiple replacement updates can be triggered concurrently (for example
|
||
|
|
* while edits are still being decrypted). A monotonic update counter guards
|
||
|
|
* against older async resolutions overriding newer replacement selections.
|
||
|
|
*/
|
||
|
|
async updateTargetEventReplacement() {
|
||
|
|
if (!this.targetEvent || this.targetEvent.isState()) {
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
const targetEvent = this.targetEvent;
|
||
|
|
const updateId = ++this.replacementUpdateId;
|
||
|
|
const lastReplacement = await this.getLastReplacement();
|
||
|
|
|
||
|
|
// If a newer update started while we were awaiting, discard this stale result.
|
||
|
|
if (updateId !== this.replacementUpdateId || this.targetEvent !== targetEvent) {
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
// Avoid emitting Event.replaced when there is no replacement and none currently set.
|
||
|
|
if (!lastReplacement && !targetEvent.replacingEvent()) {
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
targetEvent.makeReplaced(lastReplacement ?? undefined);
|
||
|
|
}
|
||
|
|
maybeEmitCreated() {
|
||
|
|
if (this.creationEmitted) {
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
// Only emit we're "created" once we have a target event instance _and_
|
||
|
|
// at least one related event.
|
||
|
|
if (!this.targetEvent || !this.relations.size) {
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
this.creationEmitted = true;
|
||
|
|
this.targetEvent.emit(MatrixEventEvent.RelationsCreated, this.relationType, this.eventType);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
//# sourceMappingURL=relations.js.map
|