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:
parent
41fb30c68b
commit
a78e9af8fc
5 changed files with 193 additions and 32 deletions
|
@ -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() {
|
||||||
|
|
|
@ -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};
|
||||||
|
|
|
@ -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 => {
|
||||||
|
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 {
|
} else {
|
||||||
return false;
|
const relation = getRelation(sourceEntry.event);
|
||||||
}
|
if (relation) {
|
||||||
}
|
const relType = relation.rel_type;
|
||||||
|
if (relType === ANNOTATION_RELATION_TYPE) {
|
||||||
_applyRedaction(redactionEvent, targetEvent, log) {
|
const aggregated = log.wrap("react", log => {
|
||||||
log.set("redactionId", redactionEvent.event_id);
|
return this._aggregateAnnotation(sourceEntry.event, targetStorageEntry, log);
|
||||||
log.set("id", targetEvent.event_id);
|
});
|
||||||
// TODO: should we make efforts to preserve the decrypted event type?
|
if (aggregated) {
|
||||||
// probably ok not to, as we'll show whatever is deleted as "deleted message"
|
return [targetStorageEntry];
|
||||||
// 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 {content} = targetEvent;
|
return null;
|
||||||
const keepMap = _REDACT_KEEP_CONTENT_MAP[targetEvent.type];
|
}
|
||||||
|
|
||||||
|
_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)) {
|
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
|
||||||
|
|
|
@ -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
|
||||||
|
|
47
src/matrix/room/timeline/relations.js
Normal file
47
src/matrix/room/timeline/relations.js
Normal 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"];
|
||||||
|
}
|
||||||
|
|
Reference in a new issue