diff --git a/synapse/api/constants.py b/synapse/api/constants.py index c50120831d..b344bcc89a 100644 --- a/synapse/api/constants.py +++ b/synapse/api/constants.py @@ -332,6 +332,16 @@ class EventUnsignedContentFields: MSC4428_STABLE_ID: Final = "org.matrix.msc4428.stable_id" +class MemberInfo: + """MSC4428: the per-room `member_info` map of stable ID -> user ID.""" + + FIELD: Final = "org.matrix.msc4428.member_info" + """The map's field name on `/sync`, sliding sync and `/members` responses.""" + + USER_ID: Final = "user_id" + """The user ID an entry maps to; `null` once the user is erased.""" + + class MTextFields: """Fields found inside m.text content blocks.""" diff --git a/synapse/handlers/member_info.py b/synapse/handlers/member_info.py new file mode 100644 index 0000000000..91443cecd8 --- /dev/null +++ b/synapse/handlers/member_info.py @@ -0,0 +1,98 @@ +# +# This file is licensed under the Affero General Public License (AGPL) version 3. +# +# Copyright (C) 2026 Element Creations Ltd +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# See the GNU Affero General Public License for more details: +# . +# +"""MSC4428: the per-room `member_info` map. + +`member_info` maps a room member's stable ID (today: their user ID, carried on +events as `unsigned.org.matrix.msc4428.stable_id`) to the user ID it currently +stands for, which may be `None` once the user is erased. Entries whose user ID +equals the stable ID are omitted, as the MSC allows, so in today's rooms the map +only ever names erased users. +""" + +from typing import TYPE_CHECKING, Collection, Iterable + +from synapse.api.constants import EventTypes, MemberInfo +from synapse.events import EventBase +from synapse.types import JsonDict + +if TYPE_CHECKING: + from synapse.server import HomeServer + + +class MemberInfoHandler: + def __init__(self, hs: "HomeServer"): + self._store = hs.get_datastores().main + self.enabled = hs.config.experimental.msc4428_enabled + + @staticmethod + def stable_ids_from_events(events: Iterable[EventBase]) -> set[str]: + """The stable IDs a client learns about from a set of events: the + senders, and the targets of membership events.""" + stable_ids: set[str] = set() + for event in events: + stable_ids.add(event.sender) + if event.type == EventTypes.Member and event.is_state(): + stable_ids.add(event.state_key) + return stable_ids + + async def get_member_info( + self, room_id: str, stable_ids: Collection[str] + ) -> JsonDict: + """The `member_info` entries for the given stable IDs. + + Only stable IDs whose mapping differs from the stable ID itself get an + entry; today that is erased users, whose user ID is `None`. + + Args: + room_id: The room the stable IDs belong to. + stable_ids: The stable IDs to look up. + + Returns: + The map, possibly empty. + """ + if not stable_ids: + return {} + + erased = await self._store.are_users_erased(stable_ids) + return { + stable_id: {MemberInfo.USER_ID: None} + for stable_id, is_erased in erased.items() + if is_erased + } + + async def member_info_for_response( + self, + room_id: str, + events: Iterable[EventBase], + heroes: Iterable[str] = (), + ) -> JsonDict | None: + """The `member_info` map for one room of a sync response. + + The map covers the stable IDs the response mentions: the senders of the + events in it, the targets of its membership events, and the room's + heroes. On an initial sync without lazy-loaded members that is every + member of the room; a lazy-loading client gets the entries for the + members it loads later from `/members`. + + Returns: + The map, or `None` if MSC4428 is disabled or there is nothing to say + (so the field can be omitted from the response). + """ + if not self.enabled: + return None + + stable_ids = self.stable_ids_from_events(events) + stable_ids.update(heroes) + + return await self.get_member_info(room_id, stable_ids) or None diff --git a/synapse/handlers/message.py b/synapse/handlers/message.py index 7a8866c8ed..d3fc4e8fb3 100644 --- a/synapse/handlers/message.py +++ b/synapse/handlers/message.py @@ -35,6 +35,7 @@ from synapse.api.constants import ( GuestAccess, HistoryVisibility, JoinRules, + MemberInfo, Membership, RelationTypes, UserTypes, @@ -112,6 +113,7 @@ class MessageHandler: self._storage_controllers = hs.get_storage_controllers() self._state_storage_controller = self._storage_controllers.state self._event_serializer = hs.get_event_client_serializer() + self._member_info_handler = hs.get_member_info_handler() self._ephemeral_events_enabled = hs.config.server.enable_ephemeral_messages # The scheduled call to self._expire_event. None if no call is currently @@ -340,7 +342,9 @@ class MessageHandler: requester: The user requesting state events. room_id: The room ID to get all state events from. Returns: - A dict of user_id to profile info + A dict of stable ID (today: user ID) to profile info. With MSC4428 + enabled, each entry also carries the `user_id` the stable ID maps + to, `None` once that user is erased. """ if not requester.app_service_id: # We check AS auth after fetching the room membership, as it @@ -381,7 +385,7 @@ class MessageHandler: errcode=Codes.NOT_JOINED, ) - return { + result = { user_id: { "avatar_url": profile.avatar_url, "display_name": profile.display_name, @@ -389,6 +393,20 @@ class MessageHandler: for user_id, profile in users_with_profile.items() } + if self._member_info_handler.enabled: + # MSC4428: the map is keyed by stable ID; tell the client which user + # ID each one maps to (the stable ID itself unless erased). + member_info = await self._member_info_handler.get_member_info( + room_id, list(result.keys()) + ) + for stable_id, entry in result.items(): + info = member_info.get(stable_id) + entry[MemberInfo.USER_ID] = ( + info[MemberInfo.USER_ID] if info is not None else stable_id + ) + + return result + def maybe_schedule_expiry(self, event: EventBase) -> None: """Schedule the expiry of an event if there's not already one scheduled, or if the one running is for an event that will expire after the provided diff --git a/synapse/handlers/sliding_sync/__init__.py b/synapse/handlers/sliding_sync/__init__.py index afb93e42b4..99c326e062 100644 --- a/synapse/handlers/sliding_sync/__init__.py +++ b/synapse/handlers/sliding_sync/__init__.py @@ -106,6 +106,7 @@ class SlidingSyncHandler: self.notifier = hs.get_notifier() self.event_sources = hs.get_event_sources() self.relations_handler = hs.get_relations_handler() + self.member_info_handler = hs.get_member_info_handler() self.rooms_to_exclude_globally = hs.config.server.rooms_to_exclude_from_sync self.is_mine_id = hs.is_mine_id @@ -1499,6 +1500,14 @@ class SlidingSyncHandler: set_tag(SynapseTags.RESULT_PREFIX + "initial", initial) + member_info = await self.member_info_handler.member_info_for_response( + room_id, + itertools.chain( + (ev.event for ev in timeline_events), required_room_state.values() + ), + heroes=(hero.user_id for hero in heroes), + ) + return SlidingSyncResult.RoomResult( name=room_name, avatar=room_avatar, @@ -1521,6 +1530,7 @@ class SlidingSyncHandler: # (encrypted rooms). notification_count=0, highlight_count=0, + member_info=member_info, ) @trace diff --git a/synapse/handlers/sync.py b/synapse/handlers/sync.py index 2eb5eb79c6..5be0f39f93 100644 --- a/synapse/handlers/sync.py +++ b/synapse/handlers/sync.py @@ -267,6 +267,9 @@ class JoinedSyncResult: unread_thread_notifications: JsonDict summary: JsonDict | None unread_count: int + member_info: JsonDict | None + """MSC4428: the stable ID -> user ID entries for this response, or None if + there are none (or the feature is disabled).""" def __bool__(self) -> bool: """Make the result appear empty if there are no updates. This is used @@ -278,6 +281,7 @@ class JoinedSyncResult: or self.ephemeral or self.account_data or self.sticky + or self.member_info # nb the notification count does not, er, count: if there's nothing # else in the result, we don't need to send it. ) @@ -289,12 +293,16 @@ class ArchivedSyncResult: timeline: TimelineBatch state: StateMap[EventBase] account_data: list[JsonDict] + member_info: JsonDict | None + """MSC4428: as for `JoinedSyncResult.member_info`.""" def __bool__(self) -> bool: """Make the result appear empty if there are no updates. This is used to tell if room needs to be part of the sync result. """ - return bool(self.timeline or self.state or self.account_data) + return bool( + self.timeline or self.state or self.account_data or self.member_info + ) @attr.s(slots=True, frozen=True, auto_attribs=True) @@ -420,6 +428,7 @@ class SyncHandler: self.auth_blocking = hs.get_auth_blocking() self._storage_controllers = hs.get_storage_controllers() self._state_storage_controller = self._storage_controllers.state + self._member_info_handler = hs.get_member_info_handler() self._device_handler = hs.get_device_handler() self._task_scheduler = hs.get_task_scheduler() @@ -3500,6 +3509,17 @@ class SyncHandler: # > Any joined user is authorised to see sticky events for the duration they remain sticky. always_include_ids=frozenset(sticky_event_ids), ) + + member_info = await self._member_info_handler.member_info_for_response( + room_id, + itertools.chain( + (ev.event for ev in batch.events), + state.values(), + (ev.event for ev in sticky_events), + ), + heroes=summary.get("m.heroes", ()) if summary else (), + ) + room_sync = JoinedSyncResult( room_id=room_id, timeline=batch, @@ -3511,6 +3531,7 @@ class SyncHandler: summary=summary, unread_count=0, sticky=sticky_events, + member_info=member_info, ) if room_sync or always_include: @@ -3555,11 +3576,17 @@ class SyncHandler: len(state), ) elif room_builder.rtype == "archived": + member_info = await self._member_info_handler.member_info_for_response( + room_id, + itertools.chain((ev.event for ev in batch.events), state.values()), + heroes=summary.get("m.heroes", ()) if summary else (), + ) archived_room_sync = ArchivedSyncResult( room_id=room_id, timeline=batch, state=state, account_data=account_data_events, + member_info=member_info, ) if archived_room_sync or always_include: sync_result_builder.archived.append(archived_room_sync) diff --git a/synapse/rest/client/room.py b/synapse/rest/client/room.py index 4ffb6a89b3..6da1746d1d 100644 --- a/synapse/rest/client/room.py +++ b/synapse/rest/client/room.py @@ -37,6 +37,7 @@ from synapse import event_auth from synapse.api.constants import ( Direction, EventTypes, + MemberInfo, Membership, StickyEvent, StickyEventField, @@ -716,6 +717,7 @@ class RoomMemberListRestServlet(RestServlet): def __init__(self, hs: "HomeServer"): super().__init__() self.message_handler = hs.get_message_handler() + self.member_info_handler = hs.get_member_info_handler() self.auth = hs.get_auth() self.store = hs.get_datastores().main @@ -760,7 +762,18 @@ class RoomMemberListRestServlet(RestServlet): continue chunk.append(event) - return 200, {"chunk": chunk} + response: JsonDict = {"chunk": chunk} + + if self.member_info_handler.enabled: + # MSC4428: the map is always present here, unlike on `/sync`, since + # this is the endpoint a lazy-loading client learns members from. + stable_ids = {event["state_key"] for event in chunk} + stable_ids.update(event["sender"] for event in chunk) + response[MemberInfo.FIELD] = await self.member_info_handler.get_member_info( + room_id, stable_ids + ) + + return 200, response # deprecated in favour of /members?membership=join? diff --git a/synapse/rest/client/sync.py b/synapse/rest/client/sync.py index 83aa00d0b5..c45904f606 100644 --- a/synapse/rest/client/sync.py +++ b/synapse/rest/client/sync.py @@ -24,7 +24,13 @@ from typing import TYPE_CHECKING, Any, Literal, Mapping import attr -from synapse.api.constants import AccountDataTypes, EduTypes, Membership, PresenceState +from synapse.api.constants import ( + AccountDataTypes, + EduTypes, + MemberInfo, + Membership, + PresenceState, +) from synapse.api.errors import Codes, StoreError, SynapseError from synapse.api.filtering import FilterCollection from synapse.api.presence import UserPresenceState @@ -648,6 +654,10 @@ class SyncRestServlet(RestServlet): "account_data": {"events": account_data}, } + if room.member_info is not None: + # MSC4428 + result[MemberInfo.FIELD] = room.member_info + if joined: assert isinstance(room, JoinedSyncResult) ephemeral_events = room.ephemeral @@ -1047,6 +1057,10 @@ class SlidingSyncRestServlet(RestServlet): if room_result.is_dm: serialized_rooms[room_id]["is_dm"] = room_result.is_dm + # MSC4428: omitted when there is nothing to say. + if room_result.member_info is not None: + serialized_rooms[room_id][MemberInfo.FIELD] = room_result.member_info + # Stripped state only applies to invite/knock rooms if ( room_result.stripped_state is not None diff --git a/synapse/server.py b/synapse/server.py index 266440534c..a5e11dff3b 100644 --- a/synapse/server.py +++ b/synapse/server.py @@ -97,6 +97,7 @@ from synapse.handlers.federation import FederationHandler from synapse.handlers.federation_event import FederationEventHandler from synapse.handlers.identity import IdentityHandler from synapse.handlers.initial_sync import InitialSyncHandler +from synapse.handlers.member_info import MemberInfoHandler from synapse.handlers.message import EventCreationHandler, MessageHandler from synapse.handlers.pagination import PaginationHandler from synapse.handlers.password_policy import PasswordPolicyHandler @@ -868,6 +869,10 @@ class HomeServer(metaclass=abc.ABCMeta): def get_sliding_sync_handler(self) -> SlidingSyncHandler: return SlidingSyncHandler(self) + @cache_in_self + def get_member_info_handler(self) -> MemberInfoHandler: + return MemberInfoHandler(self) + @cache_in_self def get_room_list_handler(self) -> RoomListHandler: return RoomListHandler(self) diff --git a/synapse/types/handlers/sliding_sync.py b/synapse/types/handlers/sliding_sync.py index f6d30460b5..0033a5c2a1 100644 --- a/synapse/types/handlers/sliding_sync.py +++ b/synapse/types/handlers/sliding_sync.py @@ -169,6 +169,9 @@ class SlidingSyncResult: as sync v2) highlight_count: The number of unread notifications for this room with the highlight flag set. (same as sync v2) + member_info: MSC4428: the stable ID -> user ID entries for the members + this response mentions, or None if there are none (or the feature + is disabled). """ @attr.s(slots=True, frozen=True, auto_attribs=True) @@ -201,6 +204,7 @@ class SlidingSyncResult: invited_count: int | None notification_count: int highlight_count: int + member_info: JsonDict | None def __bool__(self) -> bool: """Are there any updates that should be returned immediately to @@ -220,6 +224,7 @@ class SlidingSyncResult: or bool(self.required_state) or bool(self.timeline_events) or bool(self.stripped_state) + or bool(self.member_info) ) @attr.s(slots=True, frozen=True, auto_attribs=True) diff --git a/tests/rest/client/test_msc4428.py b/tests/rest/client/test_msc4428.py index 79deb8aa9b..28a03eb494 100644 --- a/tests/rest/client/test_msc4428.py +++ b/tests/rest/client/test_msc4428.py @@ -12,12 +12,11 @@ # . # """Tests for MSC4428 (stable identifiers for room members): the -`unsigned.org.matrix.msc4428.stable_id` field on events served to clients. +`unsigned.org.matrix.msc4428.stable_id` field on events served to clients, and +the per-room `member_info` map. -This stands in for the Complement test "stable_id on every event" (COMPLEMENT.md -§2.2) until that suite exists: every event-returning client endpoint, including -the events embedded in `unsigned` (`redacted_because`, bundled edits and thread -summaries), carries the field. +These stand in for the Complement tests of COMPLEMENT.md §2 (C#2, C#3, C#4 and +C#14) until that suite exists. """ import json @@ -25,14 +24,20 @@ from typing import Iterable from twisted.internet.testing import MemoryReactor -from synapse.api.constants import EventTypes, EventUnsignedContentFields, RelationTypes +from synapse.api.constants import ( + EventTypes, + EventUnsignedContentFields, + MemberInfo, + RelationTypes, +) from synapse.rest import admin from synapse.rest.client import login, relations, room, sync from synapse.server import HomeServer -from synapse.types import JsonDict +from synapse.types import JsonDict, create_requester from synapse.util.clock import Clock from tests import unittest +from tests.rest.client.sliding_sync.test_sliding_sync import SlidingSyncBase STABLE_ID = EventUnsignedContentFields.MSC4428_STABLE_ID @@ -268,3 +273,268 @@ class StableIdDisabledTestCase(unittest.HomeserverTestCase): self.assertEqual(channel.code, 200, channel.json_body) for event in channel.json_body["rooms"]["join"][room_id]["timeline"]["events"]: self.assertNotIn(STABLE_ID, event.get("unsigned", {})) + + +class MemberInfoTestCase(unittest.HomeserverTestCase): + """The `member_info` map: stands in for Complement tests C#3, C#4 and C#14 + (COMPLEMENT.md §2) until that suite exists. In today's rooms stable IDs equal + user IDs, so the map only ever names erased users.""" + + servlets = [ + admin.register_servlets, + login.register_servlets, + room.register_servlets, + sync.register_servlets, + ] + + def default_config(self) -> JsonDict: + config = super().default_config() + config["experimental_features"] = {"msc4428_enabled": True} + return config + + def prepare(self, reactor: MemoryReactor, clock: Clock, hs: HomeServer) -> None: + self.store = hs.get_datastores().main + self.alice = self.register_user("alice", "pass") + self.alice_tok = self.login("alice", "pass") + self.bob = self.register_user("bob", "pass") + self.bob_tok = self.login("bob", "pass") + + self.room_id = self.helper.create_room_as(self.alice, tok=self.alice_tok) + self.helper.join(self.room_id, self.bob, tok=self.bob_tok) + self.helper.send(self.room_id, body="hi", tok=self.bob_tok) + + def _erase(self, user_id: str) -> None: + """Mark a user erased without deactivating them, so they stay joined.""" + self.get_success(self.store.mark_user_erased(user_id)) + + def _sync(self, since: str | None = None, **params: str) -> JsonDict: + query = {**params} + if since is not None: + query["since"] = since + qs = "&".join(f"{k}={v}" for k, v in query.items()) + channel = self.make_request( + "GET", f"/sync?{qs}" if qs else "/sync", access_token=self.alice_tok + ) + self.assertEqual(channel.code, 200, channel.json_body) + return channel.json_body + + def test_sync_omits_map_when_nothing_to_say(self) -> None: + room = self._sync()["rooms"]["join"][self.room_id] + self.assertNotIn(MemberInfo.FIELD, room) + + def test_sync_full_map_names_erased_member(self) -> None: + """C#14: erasure is visible on the next full sync.""" + self._erase(self.bob) + + room = self._sync()["rooms"]["join"][self.room_id] + self.assertEqual(room[MemberInfo.FIELD], {self.bob: {MemberInfo.USER_ID: None}}) + + # Likewise on a `full_state` incremental sync. + since = self._sync()["next_batch"] + room = self._sync(since, full_state="true")["rooms"]["join"][self.room_id] + self.assertEqual(room[MemberInfo.FIELD], {self.bob: {MemberInfo.USER_ID: None}}) + + def test_sync_incremental_only_for_mentioned_members(self) -> None: + """An incremental sync carries entries only for the stable IDs the + response mentions. A change with no event to anchor it (the erasure + itself) waits for the next event from, or about, that member.""" + since = self._sync()["next_batch"] + self._erase(self.bob) + + # Alice's own message mentions nobody but Alice. + self.helper.send(self.room_id, body="alice", tok=self.alice_tok) + body = self._sync(since) + room = body["rooms"]["join"][self.room_id] + self.assertNotIn(MemberInfo.FIELD, room) + since = body["next_batch"] + + # Bob's next message is the anchor. + self.helper.send(self.room_id, body="bob", tok=self.bob_tok) + room = self._sync(since)["rooms"]["join"][self.room_id] + self.assertEqual(room[MemberInfo.FIELD], {self.bob: {MemberInfo.USER_ID: None}}) + + def test_sync_deactivation_leave_anchors_erasure(self) -> None: + """Deactivating with `erase: true` parts the user from their rooms; + the parting leave anchors the `null` entry for other members.""" + since = self._sync()["next_batch"] + + self.get_success( + self.hs.get_deactivate_account_handler().deactivate_account( + self.bob, erase_data=True, requester=create_requester(self.bob) + ) + ) + # Parting rooms happens in the background. + self.pump() + self.reactor.advance(1) + self.pump() + + room = self._sync(since)["rooms"]["join"][self.room_id] + leave = [ + e + for e in room["timeline"]["events"] + if e["type"] == EventTypes.Member and e["state_key"] == self.bob + ] + self.assertEqual(len(leave), 1, room["timeline"]) + self.assertEqual(leave[0]["content"]["membership"], "leave") + self.assertEqual(room[MemberInfo.FIELD], {self.bob: {MemberInfo.USER_ID: None}}) + + def test_sync_left_room(self) -> None: + """The map is also present on rooms under `rooms.leave`.""" + since = self._sync()["next_batch"] + self._erase(self.bob) + self.helper.send(self.room_id, body="bob", tok=self.bob_tok) + self.helper.leave(self.room_id, self.alice, tok=self.alice_tok) + + room = self._sync(since)["rooms"]["leave"][self.room_id] + self.assertEqual(room[MemberInfo.FIELD], {self.bob: {MemberInfo.USER_ID: None}}) + + def test_members(self) -> None: + """C#4: `/members` always carries the map.""" + channel = self.make_request( + "GET", f"/rooms/{self.room_id}/members", access_token=self.alice_tok + ) + self.assertEqual(channel.code, 200, channel.json_body) + self.assertEqual(channel.json_body[MemberInfo.FIELD], {}) + + self._erase(self.bob) + channel = self.make_request( + "GET", f"/rooms/{self.room_id}/members", access_token=self.alice_tok + ) + self.assertEqual(channel.code, 200, channel.json_body) + self.assertEqual( + channel.json_body[MemberInfo.FIELD], {self.bob: {MemberInfo.USER_ID: None}} + ) + + # The map only covers the members in the (filtered) chunk. + channel = self.make_request( + "GET", + f"/rooms/{self.room_id}/members?not_membership=join", + access_token=self.alice_tok, + ) + self.assertEqual(channel.code, 200, channel.json_body) + self.assertEqual(channel.json_body["chunk"], []) + self.assertEqual(channel.json_body[MemberInfo.FIELD], {}) + + def test_joined_members(self) -> None: + """C#4: `/joined_members` is keyed by stable ID and names the user ID.""" + channel = self.make_request( + "GET", f"/rooms/{self.room_id}/joined_members", access_token=self.alice_tok + ) + self.assertEqual(channel.code, 200, channel.json_body) + joined = channel.json_body["joined"] + self.assertEqual(joined[self.alice][MemberInfo.USER_ID], self.alice) + self.assertEqual(joined[self.bob][MemberInfo.USER_ID], self.bob) + self.assertEqual(joined[self.bob]["display_name"], "bob") + + self._erase(self.bob) + channel = self.make_request( + "GET", f"/rooms/{self.room_id}/joined_members", access_token=self.alice_tok + ) + self.assertEqual(channel.code, 200, channel.json_body) + joined = channel.json_body["joined"] + self.assertEqual(joined[self.alice][MemberInfo.USER_ID], self.alice) + self.assertIsNone(joined[self.bob][MemberInfo.USER_ID]) + + +class MemberInfoSlidingSyncTestCase(SlidingSyncBase): + """C#3 for sliding sync: `rooms..member_info`.""" + + servlets = [ + admin.register_servlets, + login.register_servlets, + room.register_servlets, + sync.register_servlets, + ] + + def default_config(self) -> JsonDict: + config = super().default_config() + config["experimental_features"]["msc4428_enabled"] = True + return config + + def prepare(self, reactor: MemoryReactor, clock: Clock, hs: HomeServer) -> None: + super().prepare(reactor, clock, hs) + self.store = hs.get_datastores().main + self.alice = self.register_user("alice", "pass") + self.alice_tok = self.login("alice", "pass") + self.bob = self.register_user("bob", "pass") + self.bob_tok = self.login("bob", "pass") + + self.room_id = self.helper.create_room_as(self.alice, tok=self.alice_tok) + self.helper.join(self.room_id, self.bob, tok=self.bob_tok) + self.helper.send(self.room_id, body="hi", tok=self.bob_tok) + + self.sync_body = { + "lists": { + "foo-list": { + "ranges": [[0, 1]], + "required_state": [[EventTypes.Member, "*"]], + "timeline_limit": 2, + } + } + } + + def test_member_info(self) -> None: + body, pos = self.do_sync(self.sync_body, tok=self.alice_tok) + self.assertTrue(body["rooms"][self.room_id]["initial"]) + self.assertNotIn(MemberInfo.FIELD, body["rooms"][self.room_id]) + + self.get_success(self.store.mark_user_erased(self.bob)) + + # Incrementally: only once Bob is mentioned again. + self.helper.send(self.room_id, body="alice", tok=self.alice_tok) + body, pos = self.do_sync(self.sync_body, since=pos, tok=self.alice_tok) + self.assertNotIn(MemberInfo.FIELD, body["rooms"][self.room_id]) + + self.helper.send(self.room_id, body="bob", tok=self.bob_tok) + body, pos = self.do_sync(self.sync_body, since=pos, tok=self.alice_tok) + self.assertEqual( + body["rooms"][self.room_id][MemberInfo.FIELD], + {self.bob: {MemberInfo.USER_ID: None}}, + ) + + # A fresh connection: the initial response mentions every member. + body, _ = self.do_sync(self.sync_body, tok=self.alice_tok) + self.assertTrue(body["rooms"][self.room_id]["initial"]) + self.assertEqual( + body["rooms"][self.room_id][MemberInfo.FIELD], + {self.bob: {MemberInfo.USER_ID: None}}, + ) + + +class MemberInfoDisabledTestCase(unittest.HomeserverTestCase): + """Without the flag nothing changes on the wire, even with an erased member.""" + + servlets = [ + admin.register_servlets, + login.register_servlets, + room.register_servlets, + sync.register_servlets, + ] + + def test_no_member_info(self) -> None: + alice = self.register_user("alice", "pass") + alice_tok = self.login("alice", "pass") + bob = self.register_user("bob", "pass") + bob_tok = self.login("bob", "pass") + room_id = self.helper.create_room_as(alice, tok=alice_tok) + self.helper.join(room_id, bob, tok=bob_tok) + self.get_success(self.hs.get_datastores().main.mark_user_erased(bob)) + + channel = self.make_request("GET", "/sync", access_token=alice_tok) + self.assertEqual(channel.code, 200, channel.json_body) + self.assertNotIn(MemberInfo.FIELD, channel.json_body["rooms"]["join"][room_id]) + + channel = self.make_request( + "GET", f"/rooms/{room_id}/members", access_token=alice_tok + ) + self.assertEqual(channel.code, 200, channel.json_body) + self.assertEqual(set(channel.json_body.keys()), {"chunk"}) + + channel = self.make_request( + "GET", f"/rooms/{room_id}/joined_members", access_token=alice_tok + ) + self.assertEqual(channel.code, 200, channel.json_body) + self.assertEqual( + set(channel.json_body["joined"][bob].keys()), + {"avatar_url", "display_name"}, + )