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.
This commit is contained in:
Bruno Windels 2021-06-03 16:45:56 +02:00
parent 41fb30c68b
commit a78e9af8fc
5 changed files with 193 additions and 32 deletions

View file

@ -16,6 +16,7 @@ limitations under the License.
import {BaseEventEntry} from "./BaseEventEntry.js"; import {BaseEventEntry} from "./BaseEventEntry.js";
import {getPrevContentFromStateEvent} from "../../common.js"; import {getPrevContentFromStateEvent} from "../../common.js";
import {getRelatedEventId} from "../relations.js";
export class EventEntry extends BaseEventEntry { export class EventEntry extends BaseEventEntry {
constructor(eventEntry, fragmentIdComparer) { constructor(eventEntry, fragmentIdComparer) {
@ -110,7 +111,7 @@ export class EventEntry extends BaseEventEntry {
} }
get relatedEventId() { get relatedEventId() {
return this._eventEntry.event.redacts; return getRelatedEventId(this.event);
} }
get isRedacted() { get isRedacted() {

View file

@ -122,9 +122,9 @@ export class GapWriter {
txn.timelineEvents.insert(eventStorageEntry); txn.timelineEvents.insert(eventStorageEntry);
const eventEntry = new EventEntry(eventStorageEntry, this._fragmentIdComparer); const eventEntry = new EventEntry(eventStorageEntry, this._fragmentIdComparer);
directionalAppend(entries, eventEntry, direction); directionalAppend(entries, eventEntry, direction);
const updatedRelationTargetEntry = await this._relationWriter.writeRelation(eventEntry, txn, log); const updatedRelationTargetEntries = await this._relationWriter.writeRelation(eventEntry, txn, log);
if (updatedRelationTargetEntry) { if (updatedRelationTargetEntries) {
updatedEntries.push(updatedRelationTargetEntry); updatedEntries.push(...updatedRelationTargetEntries);
} }
} }
return {entries, updatedEntries}; return {entries, updatedEntries};

View file

@ -16,6 +16,7 @@ limitations under the License.
import {EventEntry} from "../entries/EventEntry.js"; import {EventEntry} from "../entries/EventEntry.js";
import {REDACTION_TYPE} from "../../common.js"; import {REDACTION_TYPE} from "../../common.js";
import {ANNOTATION_RELATION_TYPE, getRelation} from "../relations.js";
export class RelationWriter { export class RelationWriter {
constructor({roomId, ownUserId, fragmentIdComparer}) { constructor({roomId, ownUserId, fragmentIdComparer}) {
@ -26,49 +27,161 @@ export class RelationWriter {
// this needs to happen again after decryption too for edits // this needs to happen again after decryption too for edits
async writeRelation(sourceEntry, txn, log) { async writeRelation(sourceEntry, txn, log) {
if (sourceEntry.relatedEventId) { const {relatedEventId} = sourceEntry;
const target = await txn.timelineEvents.getByEventId(this._roomId, sourceEntry.relatedEventId); 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 (target) {
if (this._applyRelation(sourceEntry, target, log)) { const updatedStorageEntries = await this._applyRelation(sourceEntry, target, txn, log);
txn.timelineEvents.update(target); if (updatedStorageEntries) {
return new EventEntry(target, this._fragmentIdComparer); 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) { if (sourceEntry.eventType === REDACTION_TYPE) {
return log.wrap("redact", log => this._applyRedaction(sourceEntry.event, targetEntry.event, log)); return log.wrap("redact", async log => {
} else { const redactedEvent = targetStorageEntry.event;
return false; 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 {
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];
}
}
}
}
return null;
}
_applyRedaction(redactionEvent, targetEvent, log) { _applyRedaction(redactionEvent, redactedStorageEntry, txn, log) {
const redactedEvent = redactedStorageEntry.event;
log.set("redactionId", redactionEvent.event_id); log.set("redactionId", redactionEvent.event_id);
log.set("id", targetEvent.event_id); log.set("id", redactedEvent.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" const relation = getRelation(redactedEvent);
// reactions are the only thing that comes to mind, but we don't encrypt those (for now) if (relation) {
for (const key of Object.keys(targetEvent)) { 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]) { if (!_REDACT_KEEP_KEY_MAP[key]) {
delete targetEvent[key]; delete redactedEvent[key];
} }
} }
const {content} = targetEvent; const {content} = redactedEvent;
const keepMap = _REDACT_KEEP_CONTENT_MAP[targetEvent.type]; const keepMap = _REDACT_KEEP_CONTENT_MAP[redactedEvent.type];
for (const key of Object.keys(content)) { for (const key of Object.keys(content)) {
if (!keepMap?.[key]) { if (!keepMap?.[key]) {
delete content[key]; delete content[key];
} }
} }
targetEvent.unsigned = targetEvent.unsigned || {}; redactedEvent.unsigned = redactedEvent.unsigned || {};
targetEvent.unsigned.redacted_because = redactionEvent; redactedEvent.unsigned.redacted_because = redactionEvent;
delete redactedStorageEntry.annotations;
return true; 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 // copied over from matrix-js-sdk, copyright 2016 OpenMarket Ltd

View file

@ -172,9 +172,9 @@ export class SyncWriter {
txn.timelineEvents.insert(storageEntry); txn.timelineEvents.insert(storageEntry);
const entry = new EventEntry(storageEntry, this._fragmentIdComparer); const entry = new EventEntry(storageEntry, this._fragmentIdComparer);
entries.push(entry); entries.push(entry);
const updatedRelationTargetEntry = await this._relationWriter.writeRelation(entry, txn, log); const updatedRelationTargetEntries = await this._relationWriter.writeRelation(entry, txn, log);
if (updatedRelationTargetEntry) { if (updatedRelationTargetEntries) {
updatedEntries.push(updatedRelationTargetEntry); updatedEntries.push(...updatedRelationTargetEntries);
} }
// update state events after writing event, so for a member event, // update state events after writing event, so for a member event,
// we only update the member info after having written the member event // we only update the member info after having written the member event

View file

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