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

Implement MSC3925: changes to bundling of edits #14811

Merged
merged 4 commits into from
Jan 10, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/14811.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Per [MSC3925](https://github.com/matrix-org/matrix-spec-proposals/pull/3925), bundle the whole of the replacement with any edited events, and optionally inhibit server-side replacement.
3 changes: 3 additions & 0 deletions synapse/config/experimental.py
Original file line number Diff line number Diff line change
Expand Up @@ -139,3 +139,6 @@ def read_config(self, config: JsonDict, **kwargs: Any) -> None:

# MSC3391: Removing account data.
self.msc3391_enabled = experimental.get("msc3391_enabled", False)

# MSC3925: do not replace events with their edits
self.msc3925_inhibit_edit = experimental.get("msc3925_inhibit_edit", False)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh the double negative is getting to me, we default this to False and then later check not self._inhibit_replacement_via_edits (== defaulting to True), so this will keep the same behavior as today. 👍

31 changes: 24 additions & 7 deletions synapse/events/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -403,6 +403,14 @@ class EventClientSerializer:
clients.
"""

def __init__(self, inhibit_replacement_via_edits: bool = False):
"""
Args:
inhibit_replacement_via_edits: If this is set to True, then events are
never replaced by their edits.
"""
self._inhibit_replacement_via_edits = inhibit_replacement_via_edits

def serialize_event(
self,
event: Union[JsonDict, EventBase],
Expand All @@ -422,6 +430,8 @@ def serialize_event(
into the event.
apply_edits: Whether the content of the event should be modified to reflect
any replacement in `bundle_aggregations[<event_id>].replace`.
See also the `inhibit_replacement_via_edits` constructor arg: if that is
set to True, then this argument is ignored.
Returns:
The serialized event
"""
Expand Down Expand Up @@ -495,7 +505,8 @@ def _inject_bundled_aggregations(
again for additional events in a recursive manner.
serialized_event: The serialized event which may be modified.
apply_edits: Whether the content of the event should be modified to reflect
any replacement in `aggregations.replace`.
any replacement in `aggregations.replace` (subject to the
`inhibit_replacement_via_edits` constructor arg).
"""

# We have already checked that aggregations exist for this event.
Expand All @@ -518,15 +529,21 @@ def _inject_bundled_aggregations(
if event_aggregations.replace:
# If there is an edit, optionally apply it to the event.
edit = event_aggregations.replace
if apply_edits:
if apply_edits and not self._inhibit_replacement_via_edits:
self._apply_edit(event, serialized_event, edit)

# Include information about it in the relations dict.
serialized_aggregations[RelationTypes.REPLACE] = {
"event_id": edit.event_id,
"origin_server_ts": edit.origin_server_ts,
"sender": edit.sender,
}
#
# Matrix spec v1.5 (https://spec.matrix.org/v1.5/client-server-api/#server-side-aggregation-of-mreplace-relationships)
# said that we should only include the `event_id`, `origin_server_ts` and
# `sender` of the edit; however MSC3925 proposes extending it to the whole
# of the edit, which is what we do here.
serialized_aggregations[RelationTypes.REPLACE] = self.serialize_event(
edit,
time_now,
config=config,
clokep marked this conversation as resolved.
Show resolved Hide resolved
apply_edits=False,
)

# Include any threaded replies to this event.
if event_aggregations.thread:
Expand Down
2 changes: 1 addition & 1 deletion synapse/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -743,7 +743,7 @@ def get_oidc_handler(self) -> "OidcHandler":

@cache_in_self
def get_event_client_serializer(self) -> EventClientSerializer:
return EventClientSerializer()
return EventClientSerializer(self.config.experimental.msc3925_inhibit_edit)

@cache_in_self
def get_password_policy_handler(self) -> PasswordPolicyHandler:
Expand Down
185 changes: 130 additions & 55 deletions tests/rest/client/test_relations.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
from tests.server import FakeChannel
from tests.test_utils import make_awaitable
from tests.test_utils.event_injection import inject_event
from tests.unittest import override_config


class BaseRelationsTestCase(unittest.HomeserverTestCase):
Expand Down Expand Up @@ -355,30 +356,67 @@ def test_ignore_invalid_room(self) -> None:
self.assertEqual(200, channel.code, channel.json_body)
self.assertNotIn("m.relations", channel.json_body["unsigned"])

def _assert_edit_bundle(
self, event_json: JsonDict, edit_event_id: str, edit_event_content: JsonDict
) -> None:
"""
Assert that the given event has a correctly-serialised edit event in its
bundled aggregations

Args:
event_json: the serialised event to be checked
edit_event_id: the ID of the edit event that we expect to be bundled
edit_event_content: the content of that event, excluding the 'm.relates_to`
property
"""
relations_dict = event_json["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",
"content",
"type",
"unsigned",
]:
self.assertIn(key, m_replace_dict)

expected_edit_content = {
"m.relates_to": {
"event_id": event_json["event_id"],
"rel_type": "m.replace",
}
}
expected_edit_content.update(edit_event_content)

self.assert_dict(
{
"event_id": edit_event_id,
"sender": self.user_id,
"content": expected_edit_content,
"type": "m.room.message",
},
m_replace_dict,
)

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

new_body = {"msgtype": "m.text", "body": "I've been edited!"}
edit_event_content = {
"msgtype": "m.text",
"body": "foo",
"m.new_content": new_body,
}
channel = self._send_relation(
RelationTypes.REPLACE,
"m.room.message",
content={"msgtype": "m.text", "body": "foo", "m.new_content": new_body},
content=edit_event_content,
)
edit_event_id = channel.json_body["event_id"]

def assert_bundle(event_json: JsonDict) -> None:
"""Assert the expected values of the bundled aggregations."""
relations_dict = event_json["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
)

# /event should return the *original* event
channel = self.make_request(
"GET",
Expand All @@ -389,7 +427,7 @@ def assert_bundle(event_json: JsonDict) -> None:
self.assertEqual(
channel.json_body["content"], {"body": "Hi!", "msgtype": "m.text"}
)
assert_bundle(channel.json_body)
self._assert_edit_bundle(channel.json_body, edit_event_id, edit_event_content)

# Request the room messages.
channel = self.make_request(
Expand All @@ -398,7 +436,11 @@ def assert_bundle(event_json: JsonDict) -> None:
access_token=self.user_token,
)
self.assertEqual(200, channel.code, channel.json_body)
assert_bundle(self._find_event_in_chunk(channel.json_body["chunk"]))
self._assert_edit_bundle(
self._find_event_in_chunk(channel.json_body["chunk"]),
edit_event_id,
edit_event_content,
)

# Request the room context.
# /context should return the edited event.
Expand All @@ -408,7 +450,9 @@ def assert_bundle(event_json: JsonDict) -> None:
access_token=self.user_token,
)
self.assertEqual(200, channel.code, channel.json_body)
assert_bundle(channel.json_body["event"])
self._assert_edit_bundle(
channel.json_body["event"], edit_event_id, edit_event_content
)
self.assertEqual(channel.json_body["event"]["content"], new_body)

# Request sync, but limit the timeline so it becomes limited (and includes
Expand All @@ -420,7 +464,11 @@ def assert_bundle(event_json: JsonDict) -> None:
self.assertEqual(200, channel.code, channel.json_body)
room_timeline = channel.json_body["rooms"]["join"][self.room]["timeline"]
self.assertTrue(room_timeline["limited"])
assert_bundle(self._find_event_in_chunk(room_timeline["events"]))
self._assert_edit_bundle(
self._find_event_in_chunk(room_timeline["events"]),
edit_event_id,
edit_event_content,
)

# Request search.
channel = self.make_request(
Expand All @@ -437,7 +485,45 @@ def assert_bundle(event_json: JsonDict) -> None:
"results"
]
]
assert_bundle(self._find_event_in_chunk(chunk))
self._assert_edit_bundle(
self._find_event_in_chunk(chunk),
edit_event_id,
edit_event_content,
)

@override_config({"experimental_features": {"msc3925_inhibit_edit": True}})
def test_edit_inhibit_replace(self) -> None:
"""
If msc3925_inhibit_edit is enabled, then the original event should not be
replaced.
"""

new_body = {"msgtype": "m.text", "body": "I've been edited!"}
edit_event_content = {
"msgtype": "m.text",
"body": "foo",
"m.new_content": new_body,
}
channel = self._send_relation(
RelationTypes.REPLACE,
"m.room.message",
content=edit_event_content,
)
edit_event_id = channel.json_body["event_id"]

# /context should return the *original* event.
channel = self.make_request(
"GET",
f"/rooms/{self.room}/context/{self.parent_id}",
access_token=self.user_token,
)
self.assertEqual(200, channel.code, channel.json_body)
self.assertEqual(
channel.json_body["event"]["content"], {"body": "Hi!", "msgtype": "m.text"}
)
self._assert_edit_bundle(
channel.json_body["event"], edit_event_id, edit_event_content
)

def test_multi_edit(self) -> None:
"""Test that multiple edits, including attempts by people who
Expand All @@ -455,10 +541,15 @@ def test_multi_edit(self) -> None:
)

new_body = {"msgtype": "m.text", "body": "I've been edited!"}
edit_event_content = {
"msgtype": "m.text",
"body": "foo",
"m.new_content": new_body,
}
channel = self._send_relation(
RelationTypes.REPLACE,
"m.room.message",
content={"msgtype": "m.text", "body": "foo", "m.new_content": new_body},
content=edit_event_content,
)
edit_event_id = channel.json_body["event_id"]

Expand All @@ -480,16 +571,8 @@ def test_multi_edit(self) -> None:
self.assertEqual(200, channel.code, channel.json_body)

self.assertEqual(channel.json_body["event"]["content"], new_body)

relations_dict = channel.json_body["event"]["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
self._assert_edit_bundle(
channel.json_body["event"], edit_event_id, edit_event_content
)

def test_edit_reply(self) -> None:
Expand All @@ -502,11 +585,15 @@ def test_edit_reply(self) -> None:
)
reply = channel.json_body["event_id"]

new_body = {"msgtype": "m.text", "body": "I've been edited!"}
edit_event_content = {
"msgtype": "m.text",
"body": "foo",
"m.new_content": {"msgtype": "m.text", "body": "I've been edited!"},
}
channel = self._send_relation(
RelationTypes.REPLACE,
"m.room.message",
content={"msgtype": "m.text", "body": "foo", "m.new_content": new_body},
content=edit_event_content,
parent_id=reply,
)
edit_event_id = channel.json_body["event_id"]
Expand Down Expand Up @@ -549,28 +636,22 @@ def test_edit_reply(self) -> None:

# We expect that the edit relation appears in the unsigned relations
# section.
relations_dict = result_event_dict["unsigned"].get("m.relations")
self.assertIn(RelationTypes.REPLACE, relations_dict, desc)

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

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

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

Expand Down Expand Up @@ -599,8 +680,7 @@ def test_edit_edit(self) -> None:
)

# 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)
self._assert_edit_bundle(channel.json_body, edit_event_id, edit_event_content)

# /context should return the event updated for the *first* edit
# (The edit to the edit should be ignored.)
Expand All @@ -611,13 +691,8 @@ def test_edit_edit(self) -> None:
)
self.assertEqual(200, channel.code, channel.json_body)
self.assertEqual(channel.json_body["event"]["content"], new_body)

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
self._assert_edit_bundle(
channel.json_body["event"], edit_event_id, edit_event_content
)

# Directly requesting the edit should not have the edit to the edit applied.
Expand Down