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"]; +} +