From a78e9af8fc8b19f2f4f465606f9ea68f9b2f87c1 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 3 Jun 2021 16:45:56 +0200 Subject: [PATCH] Support (de)aggregating annotation relations in relation writer When deaggregating on redacting an annotation relation, we remove the relation and aggregate the other relations for that key again, so we can reliably detect the first timestamp (and count and me as well to lesser extent). as a consequence, more than one event can get updated when redacting a relation (the relation is updated, as well as the relation target), so account for that by returning an array of entries that have updated. --- .../room/timeline/entries/EventEntry.js | 3 +- .../room/timeline/persistence/GapWriter.js | 6 +- .../timeline/persistence/RelationWriter.js | 163 +++++++++++++++--- .../room/timeline/persistence/SyncWriter.js | 6 +- src/matrix/room/timeline/relations.js | 47 +++++ 5 files changed, 193 insertions(+), 32 deletions(-) create mode 100644 src/matrix/room/timeline/relations.js diff --git a/src/matrix/room/timeline/entries/EventEntry.js b/src/matrix/room/timeline/entries/EventEntry.js index 95440471..b19415c5 100644 --- a/src/matrix/room/timeline/entries/EventEntry.js +++ b/src/matrix/room/timeline/entries/EventEntry.js @@ -16,6 +16,7 @@ limitations under the License. import {BaseEventEntry} from "./BaseEventEntry.js"; import {getPrevContentFromStateEvent} from "../../common.js"; +import {getRelatedEventId} from "../relations.js"; export class EventEntry extends BaseEventEntry { constructor(eventEntry, fragmentIdComparer) { @@ -110,7 +111,7 @@ export class EventEntry extends BaseEventEntry { } get relatedEventId() { - return this._eventEntry.event.redacts; + return getRelatedEventId(this.event); } get isRedacted() { diff --git a/src/matrix/room/timeline/persistence/GapWriter.js b/src/matrix/room/timeline/persistence/GapWriter.js index 5e49695a..7b6e7600 100644 --- a/src/matrix/room/timeline/persistence/GapWriter.js +++ b/src/matrix/room/timeline/persistence/GapWriter.js @@ -122,9 +122,9 @@ export class GapWriter { txn.timelineEvents.insert(eventStorageEntry); const eventEntry = new EventEntry(eventStorageEntry, this._fragmentIdComparer); directionalAppend(entries, eventEntry, direction); - const updatedRelationTargetEntry = await this._relationWriter.writeRelation(eventEntry, txn, log); - if (updatedRelationTargetEntry) { - updatedEntries.push(updatedRelationTargetEntry); + const updatedRelationTargetEntries = await this._relationWriter.writeRelation(eventEntry, txn, log); + if (updatedRelationTargetEntries) { + updatedEntries.push(...updatedRelationTargetEntries); } } return {entries, updatedEntries}; diff --git a/src/matrix/room/timeline/persistence/RelationWriter.js b/src/matrix/room/timeline/persistence/RelationWriter.js index 305fc8eb..b15f2800 100644 --- a/src/matrix/room/timeline/persistence/RelationWriter.js +++ b/src/matrix/room/timeline/persistence/RelationWriter.js @@ -16,6 +16,7 @@ limitations under the License. import {EventEntry} from "../entries/EventEntry.js"; import {REDACTION_TYPE} from "../../common.js"; +import {ANNOTATION_RELATION_TYPE, getRelation} from "../relations.js"; export class RelationWriter { constructor({roomId, ownUserId, fragmentIdComparer}) { @@ -26,49 +27,161 @@ export class RelationWriter { // this needs to happen again after decryption too for edits async writeRelation(sourceEntry, txn, log) { - if (sourceEntry.relatedEventId) { - const target = await txn.timelineEvents.getByEventId(this._roomId, sourceEntry.relatedEventId); + const {relatedEventId} = sourceEntry; + if (relatedEventId) { + const relation = getRelation(sourceEntry.event); + if (relation) { + txn.timelineRelations.add(this._roomId, relation.event_id, relation.rel_type, sourceEntry.id); + } + const target = await txn.timelineEvents.getByEventId(this._roomId, relatedEventId); if (target) { - if (this._applyRelation(sourceEntry, target, log)) { - txn.timelineEvents.update(target); - return new EventEntry(target, this._fragmentIdComparer); + const updatedStorageEntries = await this._applyRelation(sourceEntry, target, txn, log); + if (updatedStorageEntries) { + return updatedStorageEntries.map(e => { + txn.timelineEvents.update(e); + return new EventEntry(e, this._fragmentIdComparer); + }); } } } - return; + // TODO: check if sourceEntry is in timelineRelations as a target, and if so reaggregate it + return null; } - _applyRelation(sourceEntry, targetEntry, log) { + /** + * @param {EventEntry} sourceEntry + * @param {Object} targetStorageEntry event entry as stored in the timelineEvents store + * @return {[Object]} array of event storage entries that have been updated + * */ + async _applyRelation(sourceEntry, targetStorageEntry, txn, log) { if (sourceEntry.eventType === REDACTION_TYPE) { - return log.wrap("redact", log => this._applyRedaction(sourceEntry.event, targetEntry.event, log)); + return log.wrap("redact", async log => { + const redactedEvent = targetStorageEntry.event; + const relation = getRelation(redactedEvent); // get this before redacting + const redacted = this._applyRedaction(sourceEntry.event, targetStorageEntry, txn, log); + if (redacted) { + const updated = [targetStorageEntry]; + if (relation) { + const relationTargetStorageEntry = await this._reaggregateRelation(redactedEvent, relation, txn, log); + if (relationTargetStorageEntry) { + updated.push(relationTargetStorageEntry); + } + } + return updated; + } + return null; + }); } else { - return false; - } - } - - _applyRedaction(redactionEvent, targetEvent, log) { - log.set("redactionId", redactionEvent.event_id); - log.set("id", targetEvent.event_id); - // TODO: should we make efforts to preserve the decrypted event type? - // probably ok not to, as we'll show whatever is deleted as "deleted message" - // reactions are the only thing that comes to mind, but we don't encrypt those (for now) - for (const key of Object.keys(targetEvent)) { - if (!_REDACT_KEEP_KEY_MAP[key]) { - delete targetEvent[key]; + const relation = getRelation(sourceEntry.event); + if (relation) { + const relType = relation.rel_type; + if (relType === ANNOTATION_RELATION_TYPE) { + const aggregated = log.wrap("react", log => { + return this._aggregateAnnotation(sourceEntry.event, targetStorageEntry, log); + }); + if (aggregated) { + return [targetStorageEntry]; + } + } } } - const {content} = targetEvent; - const keepMap = _REDACT_KEEP_CONTENT_MAP[targetEvent.type]; + return null; + } + + _applyRedaction(redactionEvent, redactedStorageEntry, txn, log) { + const redactedEvent = redactedStorageEntry.event; + log.set("redactionId", redactionEvent.event_id); + log.set("id", redactedEvent.event_id); + + const relation = getRelation(redactedEvent); + if (relation) { + txn.timelineRelations.remove(this._roomId, relation.event_id, relation.rel_type, redactedEvent.event_id); + } + // check if we're the target of a relation and remove all relations then as well + txn.timelineRelations.removeAllForTarget(this._roomId, redactedEvent.event_id); + + for (const key of Object.keys(redactedEvent)) { + if (!_REDACT_KEEP_KEY_MAP[key]) { + delete redactedEvent[key]; + } + } + const {content} = redactedEvent; + const keepMap = _REDACT_KEEP_CONTENT_MAP[redactedEvent.type]; for (const key of Object.keys(content)) { if (!keepMap?.[key]) { delete content[key]; } } - targetEvent.unsigned = targetEvent.unsigned || {}; - targetEvent.unsigned.redacted_because = redactionEvent; + redactedEvent.unsigned = redactedEvent.unsigned || {}; + redactedEvent.unsigned.redacted_because = redactionEvent; + + delete redactedStorageEntry.annotations; return true; } + + _aggregateAnnotation(annotationEvent, targetStorageEntry, log) { + // TODO: do we want to verify it is a m.reaction event somehow? + const relation = getRelation(annotationEvent); + if (!relation) { + return false; + } + + let {annotations} = targetStorageEntry; + if (!annotations) { + targetStorageEntry.annotations = annotations = {}; + } + let annotation = annotations[relation.key]; + if (!annotation) { + annotations[relation.key] = annotation = { + count: 0, + me: false, + firstTimestamp: Number.MAX_SAFE_INTEGER + }; + } + const sentByMe = annotationEvent.sender === this._ownUserId; + + annotation.me = annotation.me || sentByMe; + annotation.count += 1; + annotation.firstTimestamp = Math.min( + annotation.firstTimestamp, + annotationEvent.origin_server_ts + ); + + return true; + } + + async _reaggregateRelation(redactedRelationEvent, redactedRelation, txn, log) { + if (redactedRelation.rel_type === ANNOTATION_RELATION_TYPE) { + return log.wrap("reaggregate annotations", log => this._reaggregateAnnotation( + redactedRelation.event_id, + redactedRelation.key, + txn, log + )); + } + return null; + } + + async _reaggregateAnnotation(targetId, key, txn, log) { + const target = await txn.timelineEvents.getByEventId(this._roomId, targetId); + if (!target) { + return null; + } + log.set("id", targetId); + const relations = await txn.timelineRelations.getForTargetAndType(this._roomId, targetId, ANNOTATION_RELATION_TYPE); + log.set("relations", relations.length); + delete target.annotations[key]; + await Promise.all(relations.map(async relation => { + const annotation = await txn.timelineEvents.getByEventId(this._roomId, relation.sourceEventId); + if (!annotation) { + log.log({l: "missing annotation", id: relation.sourceEventId}); + } + if (getRelation(annotation.event).key === key) { + this._aggregateAnnotation(annotation.event, target, log); + } + })); + return target; + } } // copied over from matrix-js-sdk, copyright 2016 OpenMarket Ltd diff --git a/src/matrix/room/timeline/persistence/SyncWriter.js b/src/matrix/room/timeline/persistence/SyncWriter.js index 39d341ef..96551056 100644 --- a/src/matrix/room/timeline/persistence/SyncWriter.js +++ b/src/matrix/room/timeline/persistence/SyncWriter.js @@ -172,9 +172,9 @@ export class SyncWriter { txn.timelineEvents.insert(storageEntry); const entry = new EventEntry(storageEntry, this._fragmentIdComparer); entries.push(entry); - const updatedRelationTargetEntry = await this._relationWriter.writeRelation(entry, txn, log); - if (updatedRelationTargetEntry) { - updatedEntries.push(updatedRelationTargetEntry); + const updatedRelationTargetEntries = await this._relationWriter.writeRelation(entry, txn, log); + if (updatedRelationTargetEntries) { + updatedEntries.push(...updatedRelationTargetEntries); } // update state events after writing event, so for a member event, // we only update the member info after having written the member event diff --git a/src/matrix/room/timeline/relations.js b/src/matrix/room/timeline/relations.js new file mode 100644 index 00000000..ed9da586 --- /dev/null +++ b/src/matrix/room/timeline/relations.js @@ -0,0 +1,47 @@ +/* +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 {REDACTION_TYPE} from "../common.js"; + +export const REACTION_TYPE = "m.reaction"; +export const ANNOTATION_RELATION_TYPE = "m.annotation"; + +export function createAnnotation(targetId, key) { + return { + "m.relates_to": { + "event_id": targetId, + key, + "rel_type": ANNOTATION_RELATION_TYPE + } + }; +} + +export function getRelatedEventId(event) { + if (event.type === REDACTION_TYPE) { + return event.redacts; + } else { + const relation = getRelation(event); + if (relation) { + return relation.event_id; + } + } + return null; +} + +export function getRelation(event) { + return event.content?.["m.relates_to"]; +} +