Skip to content
This repository has been archived by the owner on Apr 26, 2024. It is now read-only.

Bundle relations of relations into the /relations result. #11284

Merged
merged 12 commits into from
Nov 30, 2021
1 change: 1 addition & 0 deletions changelog.d/11284.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Include bundled relations in response to the `/relations` API, per [MSC3440](https://github.com/matrix-org/matrix-doc/pull/3440).
clokep marked this conversation as resolved.
Show resolved Hide resolved
144 changes: 83 additions & 61 deletions synapse/events/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -398,7 +398,7 @@ async def serialize_event(
"""Serializes a single event.

Args:
event
event: The event being serialized.
time_now: The current time in milliseconds
bundle_aggregations: Whether to bundle in related events
clokep marked this conversation as resolved.
Show resolved Hide resolved
**kwargs: Arguments to pass to `serialize_event`
Expand All @@ -410,7 +410,6 @@ async def serialize_event(
if not isinstance(event, EventBase):
return event

event_id = event.event_id
serialized_event = serialize_event(event, time_now, **kwargs)

# If MSC1849 is enabled then we need to look if there are any relations
Expand All @@ -419,68 +418,91 @@ async def serialize_event(
if not event.internal_metadata.is_redacted() and (
self._msc1849_enabled and bundle_aggregations
):
annotations = await self.store.get_aggregation_groups_for_event(event_id)
references = await self.store.get_relations_for_event(
event_id, RelationTypes.REFERENCE, direction="f"
)

if annotations.chunk:
r = serialized_event["unsigned"].setdefault("m.relations", {})
r[RelationTypes.ANNOTATION] = annotations.to_dict()

if references.chunk:
r = serialized_event["unsigned"].setdefault("m.relations", {})
r[RelationTypes.REFERENCE] = references.to_dict()

edit = None
if event.type == EventTypes.Message:
edit = await self.store.get_applicable_edit(event_id)

if edit:
# If there is an edit replace the content, preserving existing
# relations.

# Ensure we take copies of the edit content, otherwise we risk modifying
# the original event.
edit_content = edit.content.copy()

# Unfreeze the event content if necessary, so that we may modify it below
edit_content = unfreeze(edit_content)
serialized_event["content"] = edit_content.get("m.new_content", {})

# Check for existing relations
relations = event.content.get("m.relates_to")
if relations:
# Keep the relations, ensuring we use a dict copy of the original
serialized_event["content"]["m.relates_to"] = relations.copy()
else:
serialized_event["content"].pop("m.relates_to", None)

r = serialized_event["unsigned"].setdefault("m.relations", {})
r[RelationTypes.REPLACE] = {
"event_id": edit.event_id,
"origin_server_ts": edit.origin_server_ts,
"sender": edit.sender,
}

# If this event is the start of a thread, include a summary of the replies.
if self._msc3440_enabled:
(
thread_count,
latest_thread_event,
) = await self.store.get_thread_summary(event_id)
if latest_thread_event:
r = serialized_event["unsigned"].setdefault("m.relations", {})
r[RelationTypes.THREAD] = {
# Don't bundle aggregations as this could recurse forever.
"latest_event": await self.serialize_event(
latest_thread_event, time_now, bundle_aggregations=False
),
"count": thread_count,
}
await self._injected_bundled_relations(event, time_now, serialized_event)
clokep marked this conversation as resolved.
Show resolved Hide resolved

return serialized_event

async def _injected_bundled_relations(
self, event: EventBase, time_now: int, serialized_event: JsonDict
) -> None:
"""Potentially injects bundled relations into the unsigend portion of the serialized event.
clokep marked this conversation as resolved.
Show resolved Hide resolved

Args:
event: The event being serialized.
time_now: The current time in milliseconds
serialized_event: The serialized event which may be modified.

"""
# Do not bundle aggregations for an event which represents an edit or annotation.
richvdh marked this conversation as resolved.
Show resolved Hide resolved
clokep marked this conversation as resolved.
Show resolved Hide resolved
relates_to = event.content.get("m.relates_to")
if isinstance(relates_to, (dict, frozendict)):
relation_type = relates_to.get("rel_type")
if relation_type in (RelationTypes.ANNOTATION, RelationTypes.REPLACE):
return

event_id = event.event_id

annotations = await self.store.get_aggregation_groups_for_event(event_id)
references = await self.store.get_relations_for_event(
event_id, RelationTypes.REFERENCE, direction="f"
)
clokep marked this conversation as resolved.
Show resolved Hide resolved

relations = {}

if annotations.chunk:
relations[RelationTypes.ANNOTATION] = annotations.to_dict()

if references.chunk:
relations[RelationTypes.REFERENCE] = references.to_dict()

edit = None
if event.type == EventTypes.Message:
edit = await self.store.get_applicable_edit(event_id)

if edit:
# If there is an edit replace the content, preserving existing
# relations.

# Ensure we take copies of the edit content, otherwise we risk modifying
# the original event.
edit_content = edit.content.copy()

# Unfreeze the event content if necessary, so that we may modify it below
edit_content = unfreeze(edit_content)
serialized_event["content"] = edit_content.get("m.new_content", {})

# Check for existing relations
if relates_to:
# Keep the relations, ensuring we use a dict copy of the original
serialized_event["content"]["m.relates_to"] = relates_to.copy()
else:
serialized_event["content"].pop("m.relates_to", None)

relations[RelationTypes.REPLACE] = {
"event_id": edit.event_id,
"origin_server_ts": edit.origin_server_ts,
"sender": edit.sender,
}

# If this event is the start of a thread, include a summary of the replies.
if self._msc3440_enabled:
(
thread_count,
latest_thread_event,
) = await self.store.get_thread_summary(event_id)
if latest_thread_event:
relations[RelationTypes.THREAD] = {
# Don't bundle aggregations as this could recurse forever.
"latest_event": await self.serialize_event(
latest_thread_event, time_now, bundle_aggregations=False
),
"count": thread_count,
}

# If any bundled relations were found, include them.
if relations:
serialized_event["unsigned"].setdefault("m.relations", {}).update(relations)

async def serialize_events(
self, events: Iterable[Union[JsonDict, EventBase]], time_now: int, **kwargs: Any
) -> List[JsonDict]:
Expand Down
9 changes: 3 additions & 6 deletions synapse/rest/client/relations.py
Original file line number Diff line number Diff line change
Expand Up @@ -230,12 +230,9 @@ async def on_GET(
original_event = await self._event_serializer.serialize_event(
event, now, bundle_aggregations=False
)
# Similarly, we don't allow relations to be applied to relations, so we
# return the original relations without any aggregations on top of them
# here.
serialized_events = await self._event_serializer.serialize_events(
events, now, bundle_aggregations=False
)
# For any relations applying to the original event they need their
# aggregations applied to them.
richvdh marked this conversation as resolved.
Show resolved Hide resolved
serialized_events = await self._event_serializer.serialize_events(events, now)

return_value = pagination_chunk.to_dict()
return_value["chunk"] = serialized_events
Expand Down
118 changes: 118 additions & 0 deletions tests/rest/client/test_relations.py
Original file line number Diff line number Diff line change
Expand Up @@ -461,6 +461,74 @@ def test_aggregation_get_event(self):
},
)

def test_aggregation_get_event_for_annotation(self):
"""Test that annotations do not get bundled relations included
when directly requested.
"""
channel = self._send_relation(RelationTypes.ANNOTATION, "m.reaction", "a")
self.assertEquals(200, channel.code, channel.json_body)
annotation_id = channel.json_body["event_id"]

# Annotate the annotation.
channel = self._send_relation(
RelationTypes.ANNOTATION, "m.reaction", "a", parent_id=annotation_id
)
self.assertEquals(200, channel.code, channel.json_body)

channel = self.make_request(
"GET",
f"/rooms/{self.room}/event/{annotation_id}",
access_token=self.user_token,
)
self.assertEquals(200, channel.code, channel.json_body)
self.assertIsNone(channel.json_body["unsigned"].get("m.relations"))

def test_aggregation_get_event_for_thread(self):
"""Test that threads get bundled relations included when directly requested."""
channel = self._send_relation(RelationTypes.THREAD, "m.room.test")
self.assertEquals(200, channel.code, channel.json_body)
thread_id = channel.json_body["event_id"]

# Annotate the annotation.
channel = self._send_relation(
RelationTypes.ANNOTATION, "m.reaction", "a", parent_id=thread_id
)
self.assertEquals(200, channel.code, channel.json_body)

channel = self.make_request(
"GET",
f"/rooms/{self.room}/event/{thread_id}",
access_token=self.user_token,
)
self.assertEquals(200, channel.code, channel.json_body)
self.assertEquals(
channel.json_body["unsigned"].get("m.relations"),
{
RelationTypes.ANNOTATION: {
"chunk": [{"count": 1, "key": "a", "type": "m.reaction"}]
},
},
)

# It should also be included when the entire thread is requested.
channel = self.make_request(
"GET",
f"/_matrix/client/unstable/rooms/{self.room}/relations/{self.parent_id}?limit=1",
access_token=self.user_token,
)
self.assertEquals(200, channel.code, channel.json_body)
self.assertEqual(len(channel.json_body["chunk"]), 1)

thread_message = channel.json_body["chunk"][0]
self.assertEquals(
thread_message["unsigned"].get("m.relations"),
{
RelationTypes.ANNOTATION: {
"chunk": [{"count": 1, "key": "a", "type": "m.reaction"}]
},
},
)

def test_edit(self):
"""Test that a simple edit works."""

Expand Down Expand Up @@ -607,6 +675,56 @@ def test_edit_reply(self):
{"event_id": edit_event_id, "sender": self.user_id}, m_replace_dict
)

def test_edit_edit(self):
"""Test that an edit cannot be edited."""
new_body = {"msgtype": "m.text", "body": "Initial edit"}
channel = self._send_relation(
RelationTypes.REPLACE,
"m.room.message",
content={
"msgtype": "m.text",
"body": "Wibble",
"m.new_content": new_body,
},
)
self.assertEquals(200, channel.code, channel.json_body)
edit_event_id = channel.json_body["event_id"]

# Edit the edit event.
channel = self._send_relation(
RelationTypes.REPLACE,
"m.room.message",
content={
"msgtype": "m.text",
"body": "foo",
"m.new_content": {"msgtype": "m.text", "body": "Ignored edit"},
},
parent_id=edit_event_id,
)
self.assertEquals(200, channel.code, channel.json_body)

# Request the original event.
channel = self.make_request(
"GET",
"/rooms/%s/event/%s" % (self.room, self.parent_id),
access_token=self.user_token,
)
self.assertEquals(200, channel.code, channel.json_body)
# The edit to the edit should be ignored.
self.assertEquals(channel.json_body["content"], new_body)

# The relations information should not include the edit to the edit.
relations_dict = channel.json_body["unsigned"].get("m.relations")
self.assertIn(RelationTypes.REPLACE, relations_dict)

m_replace_dict = relations_dict[RelationTypes.REPLACE]
for key in ["event_id", "sender", "origin_server_ts"]:
self.assertIn(key, m_replace_dict)

self.assert_dict(
{"event_id": edit_event_id, "sender": self.user_id}, m_replace_dict
)

def test_relations_redaction_redacts_edits(self):
"""Test that edits of an event are redacted when the original event
is redacted.
Expand Down