A2: Add the MSC4428 member_info map, anchored on events

Add `org.matrix.msc4428.member_info` to `/sync` (joined and left rooms),
sliding sync, `/members`, and `user_id` to `/joined_members` entries. A new
`MemberInfoHandler` computes the map; entries are only produced where the
mapping differs from the stable ID itself, so it only names erased users
(`user_id: null`, via `are_users_erased`).

A room's map covers the stable IDs the response mentions: senders of its
timeline, state and sticky events, targets of its membership events, and
the room's heroes. On an initial sync without lazy-loaded members that is
every member; a lazy-loading client gets the entries for members it loads
later from `/members`, which always carries the map. On sync the map is
omitted when empty, and `member_info` counts towards whether a room is
included in the response. Changes with no event anchor (the erasure
itself) are delivered by the next event from or about that member, or on
the next full sync; a stream for them is A3.

With `msc4428_enabled` off the responses are unchanged.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Kegan DougalandClaude Fable 5.1 committed 2026-10-15 09:22:38 +02:00
1 parent 45cfb3bc93
commit 9a26683b32
10 files changed
+482 -12

No files matched your search

+10
View File
@@ -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."""
+98
View File
@@ -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:
# <https://www.gnu.org/licenses/agpl-3.0.html>.
#
"""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
+20 -2
View File
@@ -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
+10
View File
@@ -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
+28 -1
View File
@@ -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)
+14 -1
View File
@@ -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?
+15 -1
View File
@@ -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
+5
View File
@@ -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)
+5
View File
@@ -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)
+277 -7
View File
@@ -12,12 +12,11 @@
# <https://www.gnu.org/licenses/agpl-3.0.html>.
#
"""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.<id>.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"},
)