From 8ff205b7549f0a4d93a85e3fcdc39ecbd887633d Mon Sep 17 00:00:00 2001 From: Paul Chobert Date: Fri, 30 Oct 2026 10:53:57 +0100 Subject: [PATCH] Add support for tagging as synthetic member --- synapse/handlers/sync.py | 15 +++++----- synapse/rest/client/tags.py | 8 +++-- tests/rest/client/test_sync.py | 53 ++++++++++++++++++++++++++++++++++ 3 files changed, 66 insertions(+), 10 deletions(-) diff --git a/synapse/handlers/sync.py b/synapse/handlers/sync.py index aec3d1fd3b..a5a9a2801d 100644 --- a/synapse/handlers/sync.py +++ b/synapse/handlers/sync.py @@ -2981,13 +2981,13 @@ class SyncHandler: # 3. Work out which rooms need reporting in the sync response. ignored_users = await self.store.ignored_users(user_id) if since_token: + tags_by_room = await self.store.get_updated_tags( + user_id, since_token.account_data_key + ) room_changes = await self._get_room_changes_for_incremental_sync( sync_result_builder, ignored_users, - account_data_room_ids=account_data_by_room.keys(), - ) - tags_by_room = await self.store.get_updated_tags( - user_id, since_token.account_data_key + account_data_room_ids=account_data_by_room.keys() | tags_by_room.keys(), ) else: room_changes = await self._get_room_changes_for_initial_sync( @@ -3382,9 +3382,10 @@ class SyncHandler: room_ids: The announcement rooms to consider. left_room_ids: Rooms among `room_ids` that the user left or was removed from since the `since_token` of `sync_result_builder`. - account_data_room_ids: Rooms whose room account data for the user - changed since the `since_token` of `sync_result_builder`. - They may include rooms that are not in `room_ids`. + account_data_room_ids: Rooms whose room account data for the user, + including tags, changed since the `since_token` of + `sync_result_builder`. They may include rooms that are not in + `room_ids`. """ since_token = sync_result_builder.since_token now_token = sync_result_builder.now_token diff --git a/synapse/rest/client/tags.py b/synapse/rest/client/tags.py index f7272754e6..39f4f2eda5 100644 --- a/synapse/rest/client/tags.py +++ b/synapse/rest/client/tags.py @@ -82,6 +82,7 @@ class TagServlet(RestServlet): self.auth = hs.get_auth() self.handler = hs.get_account_data_handler() self.room_member_handler = hs.get_room_member_handler() + self.store = hs.get_datastores().main async def on_PUT( self, request: SynapseRequest, user_id: str, room_id: str, tag: str @@ -102,9 +103,10 @@ class TagServlet(RestServlet): # Check if the user has any membership in the room and raise error if not. # Although it's not harmful for users to tag random rooms, it's just superfluous # data we don't need to track or allow. - await self.room_member_handler.check_for_any_membership_in_room( - user_id=user_id, room_id=room_id - ) + if await self.store.get_server_announcement_id(room_id) is None: + await self.room_member_handler.check_for_any_membership_in_room( + user_id=user_id, room_id=room_id + ) body = parse_json_object_from_request(request) diff --git a/tests/rest/client/test_sync.py b/tests/rest/client/test_sync.py index 418c2810dd..dd67bc0701 100644 --- a/tests/rest/client/test_sync.py +++ b/tests/rest/client/test_sync.py @@ -47,6 +47,7 @@ from synapse.rest.client import ( receipts, room, sync, + tags, ) from synapse.server import HomeServer from synapse.types import JsonDict, RoomStreamToken, StreamKeyType, StreamToken @@ -2212,6 +2213,7 @@ class ServerAnnouncementsSyncTestCase(unittest.HomeserverTestCase): room.register_servlets, sync.register_servlets, account_data.register_servlets, + tags.register_servlets, ] def prepare(self, reactor: MemoryReactor, clock: Clock, hs: HomeServer) -> None: @@ -2611,6 +2613,57 @@ class ServerAnnouncementsSyncTestCase(unittest.HomeserverTestCase): [{"type": "m.marked_unread", "content": {"unread": True}}], ) + def test_tag_without_new_events(self) -> None: + """A non-member who tags an announcement room gets `m.tag` on their next + incremental sync, even when nothing else happened in the room. + """ + self._announce("hello") + since = self._sync(self.bob_tok)["next_batch"] + + channel = self.make_request( + "PUT", + f"/user/{self.bob}/rooms/{self.room_id}/tags/m.favourite", + {"order": 0.5}, + access_token=self.bob_tok, + ) + self.assertEqual(channel.code, 200, channel.json_body) + + room = self._assert_delivered(self._sync(self.bob_tok, since), self.bob) + self.assertEqual(self._timeline_ids(room), []) + self.assertEqual( + room["account_data"]["events"], + [{"type": "m.tag", "content": {"tags": {"m.favourite": {"order": 0.5}}}}], + ) + + def test_tag_removed_without_new_events(self) -> None: + """A non-member who removes their last tag from an announcement room gets + an empty `m.tag` on their next incremental sync, even when nothing else + happened in the room. + """ + self._announce("hello") + channel = self.make_request( + "PUT", + f"/user/{self.bob}/rooms/{self.room_id}/tags/m.favourite", + {"order": 0.5}, + access_token=self.bob_tok, + ) + self.assertEqual(channel.code, 200, channel.json_body) + since = self._sync(self.bob_tok)["next_batch"] + + channel = self.make_request( + "DELETE", + f"/user/{self.bob}/rooms/{self.room_id}/tags/m.favourite", + access_token=self.bob_tok, + ) + self.assertEqual(channel.code, 200, channel.json_body) + + room = self._assert_delivered(self._sync(self.bob_tok, since), self.bob) + self.assertEqual(self._timeline_ids(room), []) + self.assertEqual( + room["account_data"]["events"], + [{"type": "m.tag", "content": {"tags": {}}}], + ) + def test_state_after(self) -> None: """With `use_state_after`, a non-member gets the synthetic join in `state_after`, including a former member whose real leave is in the