Skip to content
Open
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
10 changes: 10 additions & 0 deletions client/sync.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,9 @@ type SyncReq struct {
// since will be returned.
// By default, this is false.
FullState bool
// Controls whether to set MSC422 `use_state_after` request parameter to get
// `state_after` in the reponse (alternative to `state`).
UseStateAfter bool
// Controls whether the client is automatically marked as online by polling this API. If this
// parameter is omitted then the client is automatically marked as online when it uses this API.
// Otherwise if the parameter is set to “offline” then the client is not marked as being online
Expand Down Expand Up @@ -173,6 +176,13 @@ func (c *CSAPI) Sync(t ct.TestLike, syncReq SyncReq) (gjson.Result, *http.Respon
if syncReq.FullState {
query["full_state"] = []string{"true"}
}
if syncReq.UseStateAfter {
// The spec is already stabilized
query["use_state_after"] = []string{"true"}
// FIXME: Some implementations haven't stabilized yet (Synapse) so we'll keep this
// here until then.
query["org.matrix.msc4222.use_state_after"] = []string{"true"}
Comment on lines +182 to +184
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

See other discussion #842 (comment)

}
if syncReq.SetPresence != "" {
query["set_presence"] = []string{syncReq.SetPresence}
}
Expand Down
11 changes: 11 additions & 0 deletions tests/msc4222/main_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package tests

import (
"testing"

"github.com/matrix-org/complement"
)

func TestMain(m *testing.M) {
complement.TestMain(m, "msc4222")
}
175 changes: 175 additions & 0 deletions tests/msc4222/msc4222_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
package tests

import (
"maps"
"slices"
"testing"

"github.com/matrix-org/complement"
"github.com/matrix-org/complement/client"
"github.com/matrix-org/complement/helpers"
"github.com/matrix-org/complement/should"
"github.com/tidwall/gjson"
)

func TestSync(t *testing.T) {
deployment := complement.Deploy(t, 1)
defer deployment.Destroy(t)
alice := deployment.Register(t, "hs1", helpers.RegistrationOpts{LocalpartSuffix: "alice"})
bob := deployment.Register(t, "hs1", helpers.RegistrationOpts{LocalpartSuffix: "bob"})

t.Run("parallel", func(t *testing.T) {
// When lazy-loading room members is enabled, for a public room, the `state_after`
// in an initial sync request should include membership from every `sender` in the
// `timeline`
//
// We're specifically testing the scenario where a new "DM" is created and the other person
// joins without speaking yet.
t.Run("Initial sync with lazy-loading room members -> public room `state_after` includes all members from timeline", func(t *testing.T) {
t.Parallel()

// Alice creates a room
roomID := alice.MustCreateRoom(t, map[string]interface{}{"preset": "public_chat"})
alice.MustSyncUntil(t, client.SyncReq{}, client.SyncJoinedTo(alice.UserID, roomID))

// Bob joins the room
bob.MustJoinRoom(t, roomID, nil)

// Make double sure that bob is joined to the room
alice.MustSyncUntil(t, client.SyncReq{}, client.SyncJoinedTo(bob.UserID, roomID))

// Ensure `state_after` looks correct
expectedSendersFromTimeline := []string{ alice.UserID, bob.UserID }
syncFilter := `{
"room": {
"timeline": { "limit": 20 },
"state": { "lazy_load_members": true }
}
}`
testInitialSyncStateAfterIncludesTimelineSenders(t, alice, roomID, expectedSendersFromTimeline, syncFilter)
Copy link
Collaborator Author

@MadLittleMods MadLittleMods Feb 13, 2026

Choose a reason for hiding this comment

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

For public rooms, things appear to work fine in Synapse ✅ :

Example rooms.join.<room_id> part of the initial sync response: (includes m.room.member events for both alice and bob in timeline and org.matrix.msc4222.state_after fields)

{"timeline":{"events":[{"type":"m.room.create","sender":"@user-1-alice:hs1","content":{"room_version":"10","creator":"@user-1-alice:hs1"},"state_key":"","origin_server_ts":1770930300664,"unsigned":{"membership":"leave","age":618},"event_id":"$qp-zP4IHGRfkc0IVa_kN6tX_sBJyV0ffkTF3RaWey4c"},{"type":"m.room.member","sender":"@user-1-alice:hs1","content":{"displayname":"user-1-alice","membership":"join"},"state_key":"@user-1-alice:hs1","origin_server_ts":1770930300805,"unsigned":{"membership":"join","age":477},"event_id":"$59iC8AUKcjIZHIxjq7HglPsxDZBzGcKy1WP5g48FSRI"},{"type":"m.room.power_levels","sender":"@user-1-alice:hs1","content":{"users":{"@user-1-alice:hs1":100},"users_default":0,"events":{"m.room.name":50,"m.room.power_levels":100,"m.room.history_visibility":100,"m.room.canonical_alias":50,"m.room.avatar":50,"m.room.tombstone":100,"m.room.server_acl":100,"m.room.encryption":100},"events_default":0,"state_default":50,"ban":50,"kick":50,"redact":50,"invite":50,"historical":100,"m.call.invite":50},"state_key":"","origin_server_ts":1770930300916,"unsigned":{"membership":"join","age":366},"event_id":"$gbu10XtbHx4qEN0Zwho3k248Sho2n5SWlR29ISc5FKw"},{"type":"m.room.join_rules","sender":"@user-1-alice:hs1","content":{"join_rule":"public"},"state_key":"","origin_server_ts":1770930300921,"unsigned":{"membership":"join","age":361},"event_id":"$dWnerY4XQ-eVhLmGmy9prqMgRwIF5E8n5TOYkAmAT4k"},{"type":"m.room.history_visibility","sender":"@user-1-alice:hs1","content":{"history_visibility":"shared"},"state_key":"","origin_server_ts":1770930300921,"unsigned":{"membership":"join","age":361},"event_id":"$3fe6abisM3FkfrWK9CDRlV32dOzmk2kb9FaG8wEDOjs"},{"type":"m.room.member","sender":"@user-2-bob:hs1","content":{"displayname":"user-2-bob","membership":"join"},"state_key":"@user-2-bob:hs1","origin_server_ts":1770930301142,"unsigned":{"membership":"join","age":140},"event_id":"$Ug1lAQ7BCQANKhBtSfPrRnImxzaZOyFyi6P2qVjtfok"}],"prev_batch":"s7_2_0_1_1_1_1_3_0_1_1_1","limited":false},"org.matrix.msc4222.state_after":{"events":[{"type":"m.room.create","sender":"@user-1-alice:hs1","content":{"room_version":"10","creator":"@user-1-alice:hs1"},"state_key":"","origin_server_ts":1770930300664,"unsigned":{"age":618},"event_id":"$qp-zP4IHGRfkc0IVa_kN6tX_sBJyV0ffkTF3RaWey4c"},{"type":"m.room.history_visibility","sender":"@user-1-alice:hs1","content":{"history_visibility":"shared"},"state_key":"","origin_server_ts":1770930300921,"unsigned":{"age":361},"event_id":"$3fe6abisM3FkfrWK9CDRlV32dOzmk2kb9FaG8wEDOjs"},{"type":"m.room.join_rules","sender":"@user-1-alice:hs1","content":{"join_rule":"public"},"state_key":"","origin_server_ts":1770930300921,"unsigned":{"age":361},"event_id":"$dWnerY4XQ-eVhLmGmy9prqMgRwIF5E8n5TOYkAmAT4k"},{"type":"m.room.member","sender":"@user-1-alice:hs1","content":{"displayname":"user-1-alice","membership":"join"},"state_key":"@user-1-alice:hs1","origin_server_ts":1770930300805,"unsigned":{"age":477},"event_id":"$59iC8AUKcjIZHIxjq7HglPsxDZBzGcKy1WP5g48FSRI"},{"type":"m.room.member","sender":"@user-2-bob:hs1","content":{"displayname":"user-2-bob","membership":"join"},"state_key":"@user-2-bob:hs1","origin_server_ts":1770930301142,"unsigned":{"age":140},"event_id":"$Ug1lAQ7BCQANKhBtSfPrRnImxzaZOyFyi6P2qVjtfok"},{"type":"m.room.power_levels","sender":"@user-1-alice:hs1","content":{"users":{"@user-1-alice:hs1":100},"users_default":0,"events":{"m.room.name":50,"m.room.power_levels":100,"m.room.history_visibility":100,"m.room.canonical_alias":50,"m.room.avatar":50,"m.room.tombstone":100,"m.room.server_acl":100,"m.room.encryption":100},"events_default":0,"state_default":50,"ban":50,"kick":50,"redact":50,"invite":50,"historical":100,"m.call.invite":50},"state_key":"","origin_server_ts":1770930300916,"unsigned":{"age":366},"event_id":"$gbu10XtbHx4qEN0Zwho3k248Sho2n5SWlR29ISc5FKw"}]},"account_data":{"events":[]},"ephemeral":{"events":[]},"unread_notifications":{"notification_count":0,"highlight_count":0},"summary":{"m.joined_member_count":2,"m.invited_member_count":0,"m.heroes":["@user-2-bob:hs1"]}}

})

// When lazy-loading room members is enabled, for a private room, the `state_after`
// in an initial sync request should include membership from every `sender` in the
// `timeline`
//
// We're specifically testing the scenario where a new "DM" is created and the other person
// joins without speaking yet.
t.Run("Initial sync with lazy-loading room members -> private room `state_after` includes all members from timeline", func(t *testing.T) {
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Seems like the room being private and the other member being invited makes a difference - I've updated your complement PR to make it like that and it now seems to fail

-- @dbkr, element-hq/synapse#19455 (comment)

I've updated things so we have both tests (for public and private)

t.Parallel()

// Alice creates a room
roomID := alice.MustCreateRoom(t, map[string]interface{}{"preset": "private_chat"})
alice.MustSyncUntil(t, client.SyncReq{}, client.SyncJoinedTo(alice.UserID, roomID))

// Alice invites Bob
alice.MustInviteRoom(t, roomID, bob.UserID)

// Bob must get the invite
bob.MustSyncUntil(t, client.SyncReq{}, client.SyncInvitedTo(bob.UserID, roomID))

// Bob joins the room
bob.MustJoinRoom(t, roomID, nil)

// Make double sure that bob is joined to the room
alice.MustSyncUntil(t, client.SyncReq{}, client.SyncJoinedTo(bob.UserID, roomID))

// Ensure `state_after` looks correct
expectedSendersFromTimeline := []string{ alice.UserID, bob.UserID }
syncFilter := `{
"room": {
"timeline": { "limit": 20 },
"state": { "lazy_load_members": true }
}
}`
testInitialSyncStateAfterIncludesTimelineSenders(t, alice, roomID, expectedSendersFromTimeline, syncFilter)
Copy link
Collaborator Author

@MadLittleMods MadLittleMods Feb 13, 2026

Choose a reason for hiding this comment

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

For private rooms, things currently fail in Synapse ❌. Reproduces element-hq/synapse#19455

Example rooms.join.<room_id> part of the initial sync response: (missing membership event for bob in the state_after)

{"next_batch":"s9_3_0_1_1_1_1_3_0_1_1_1","account_data":{"events":[{"type":"m.push_rules","content":{"global":{"underride":[{"conditions":[{"kind":"event_match","key":"type","pattern":"m.call.invite"}],"actions":["notify",{"set_tweak":"sound","value":"ring"},{"set_tweak":"highlight","value":false}],"rule_id":".m.rule.call","default":true,"enabled":true},{"conditions":[{"kind":"event_match","key":"type","pattern":"m.room.encrypted"},{"kind":"room_member_count","is":"2"}],"actions":["notify",{"set_tweak":"sound","value":"default"},{"set_tweak":"highlight","value":false}],"rule_id":".m.rule.encrypted_room_one_to_one","default":true,"enabled":true},{"conditions":[{"kind":"event_match","key":"type","pattern":"m.room.message"},{"kind":"room_member_count","is":"2"}],"actions":["notify",{"set_tweak":"sound","value":"default"},{"set_tweak":"highlight","value":false}],"rule_id":".m.rule.room_one_to_one","default":true,"enabled":true},{"conditions":[{"kind":"event_match","key":"type","pattern":"m.room.message"}],"actions":["notify",{"set_tweak":"highlight","value":false}],"rule_id":".m.rule.message","default":true,"enabled":true},{"conditions":[{"kind":"event_match","key":"type","pattern":"m.room.encrypted"}],"actions":["notify",{"set_tweak":"highlight","value":false}],"rule_id":".m.rule.encrypted","default":true,"enabled":true},{"conditions":[{"kind":"event_match","key":"type","pattern":"im.vector.modular.widgets"},{"kind":"event_match","key":"content.type","pattern":"jitsi"},{"kind":"event_match","key":"state_key","pattern":"*"}],"actions":["notify",{"set_tweak":"highlight","value":false}],"rule_id":".im.vector.jitsi","default":true,"enabled":true},{"conditions":[{"kind":"room_member_count","is":"2"},{"kind":"event_match","key":"type","pattern":"org.matrix.msc3381.poll.start"}],"actions":["notify",{"set_tweak":"sound","value":"default"}],"rule_id":".org.matrix.msc3930.rule.poll_start_one_to_one","default":true,"enabled":true},{"conditions":[{"kind":"event_match","key":"type","pattern":"org.matrix.msc3381.poll.start"}],"actions":["notify"],"rule_id":".org.matrix.msc3930.rule.poll_start","default":true,"enabled":true},{"conditions":[{"kind":"room_member_count","is":"2"},{"kind":"event_match","key":"type","pattern":"org.matrix.msc3381.poll.end"}],"actions":["notify",{"set_tweak":"sound","value":"default"}],"rule_id":".org.matrix.msc3930.rule.poll_end_one_to_one","default":true,"enabled":true},{"conditions":[{"kind":"event_match","key":"type","pattern":"org.matrix.msc3381.poll.end"}],"actions":["notify"],"rule_id":".org.matrix.msc3930.rule.poll_end","default":true,"enabled":true}],"sender":[],"room":[],"postcontent":[{"conditions":[{"kind":"io.element.msc4306.thread_subscription","subscribed":false}],"actions":[],"rule_id":".io.element.msc4306.rule.unsubscribed_thread","default":true,"enabled":true},{"conditions":[{"kind":"io.element.msc4306.thread_subscription","subscribed":true}],"actions":["notify",{"set_tweak":"sound","value":"default"}],"rule_id":".io.element.msc4306.rule.subscribed_thread","default":true,"enabled":true}],"content":[{"actions":["notify",{"set_tweak":"highlight"},{"set_tweak":"sound","value":"default"}],"rule_id":".m.rule.contains_user_name","default":true,"pattern":"user-1-alice","enabled":true}],"override":[{"conditions":[],"actions":[],"rule_id":".m.rule.master","default":true,"enabled":false},{"conditions":[{"kind":"event_match","key":"content.msgtype","pattern":"m.notice"}],"actions":[],"rule_id":".m.rule.suppress_notices","default":true,"enabled":true},{"conditions":[{"kind":"event_match","key":"type","pattern":"m.room.member"},{"kind":"event_match","key":"content.membership","pattern":"invite"},{"kind":"event_match","key":"state_key","pattern":"@user-1-alice:hs1"}],"actions":["notify",{"set_tweak":"highlight","value":false},{"set_tweak":"sound","value":"default"}],"rule_id":".m.rule.invite_for_me","default":true,"enabled":true},{"conditions":[{"kind":"event_match","key":"type","pattern":"m.room.member"}],"actions":[],"rule_id":".m.rule.member_event","default":true,"enabled":true},{"conditions":[{"kind":"event_property_contains","key":"content.m\\.mentions.user_ids","value":"@user-1-alice:hs1"}],"actions":["notify",{"set_tweak":"highlight"},{"set_tweak":"sound","value":"default"}],"rule_id":".m.rule.is_user_mention","default":true,"enabled":true},{"conditions":[{"kind":"contains_display_name"}],"actions":["notify",{"set_tweak":"highlight"},{"set_tweak":"sound","value":"default"}],"rule_id":".m.rule.contains_display_name","default":true,"enabled":true},{"conditions":[{"kind":"event_property_is","key":"content.m\\.mentions.room","value":true},{"kind":"sender_notification_permission","key":"room"}],"actions":["notify",{"set_tweak":"highlight"}],"rule_id":".m.rule.is_room_mention","default":true,"enabled":true},{"conditions":[{"kind":"sender_notification_permission","key":"room"},{"kind":"event_match","key":"content.body","pattern":"@room"}],"actions":["notify",{"set_tweak":"highlight"}],"rule_id":".m.rule.roomnotif","default":true,"enabled":true},{"conditions":[{"kind":"event_match","key":"type","pattern":"m.room.tombstone"},{"kind":"event_match","key":"state_key","pattern":""}],"actions":["notify",{"set_tweak":"highlight"}],"rule_id":".m.rule.tombstone","default":true,"enabled":true},{"conditions":[{"kind":"event_match","key":"type","pattern":"m.reaction"}],"actions":[],"rule_id":".m.rule.reaction","default":true,"enabled":true},{"conditions":[{"kind":"event_match","key":"type","pattern":"m.room.server_acl"},{"kind":"event_match","key":"state_key","pattern":""}],"actions":[],"rule_id":".m.rule.room.server_acl","default":true,"enabled":true},{"conditions":[{"kind":"event_property_is","key":"content.m\\.relates_to.rel_type","value":"m.replace"}],"actions":[],"rule_id":".m.rule.suppress_edits","default":true,"enabled":true},{"conditions":[{"kind":"event_match","key":"type","pattern":"org.matrix.msc3381.poll.response"}],"actions":[],"rule_id":".org.matrix.msc3930.rule.poll_response","default":true,"enabled":true}]}}}]},"presence":{"events":[{"type":"m.presence","sender":"@user-1-alice:hs1","content":{"presence":"online","last_active_ago":3,"currently_active":true}},{"type":"m.presence","sender":"@user-2-bob:hs1","content":{"presence":"online","last_active_ago":179,"currently_active":true}}]},"device_one_time_keys_count":{"signed_curve25519":0},"device_unused_fallback_key_types":[],"rooms":{"join":{"!gdBDkMRnmIypMjFmBh:hs1":{"timeline":{"events":[{"type":"m.room.create","sender":"@user-1-alice:hs1","content":{"room_version":"10","creator":"@user-1-alice:hs1"},"state_key":"","origin_server_ts":1771007574475,"unsigned":{"membership":"leave","age":824},"event_id":"$y5d9TkIOmtx_Bgs6emLOkuAsMeKGF8BdBNTfb_MOyVQ"},{"type":"m.room.member","sender":"@user-1-alice:hs1","content":{"displayname":"user-1-alice","membership":"join"},"state_key":"@user-1-alice:hs1","origin_server_ts":1771007574619,"unsigned":{"membership":"join","age":680},"event_id":"$1_a1Y1rddZC9qQuILgeZymyTfGS9hQia5dYj82h1O_I"},{"type":"m.room.power_levels","sender":"@user-1-alice:hs1","content":{"users":{"@user-1-alice:hs1":100},"users_default":0,"events":{"m.room.name":50,"m.room.power_levels":100,"m.room.history_visibility":100,"m.room.canonical_alias":50,"m.room.avatar":50,"m.room.tombstone":100,"m.room.server_acl":100,"m.room.encryption":100},"events_default":0,"state_default":50,"ban":50,"kick":50,"redact":50,"invite":0,"historical":100},"state_key":"","origin_server_ts":1771007574733,"unsigned":{"membership":"join","age":566},"event_id":"$qc1MGLT23L35gJCnFGTV3QkL7SmvODwe6PSFqFo3x9I"},{"type":"m.room.join_rules","sender":"@user-1-alice:hs1","content":{"join_rule":"invite"},"state_key":"","origin_server_ts":1771007574738,"unsigned":{"membership":"join","age":561},"event_id":"$KVG2BvhxOJw3wN0iTdOmqV7jONh44dokydRrATbrCvI"},{"type":"m.room.history_visibility","sender":"@user-1-alice:hs1","content":{"history_visibility":"shared"},"state_key":"","origin_server_ts":1771007574738,"unsigned":{"membership":"join","age":561},"event_id":"$JdttXi3VDucYC7HcGAw0OTBwm2K6mJn_7GfkewcHEhc"},{"type":"m.room.guest_access","sender":"@user-1-alice:hs1","content":{"guest_access":"can_join"},"state_key":"","origin_server_ts":1771007574738,"unsigned":{"membership":"join","age":561},"event_id":"$cgkV_w3r4_piAlf4g1X8kg7ba6yh37eBdQuXLWWyxzE"},{"type":"m.room.member","sender":"@user-1-alice:hs1","content":{"displayname":"user-2-bob","membership":"invite"},"state_key":"@user-2-bob:hs1","origin_server_ts":1771007574962,"unsigned":{"membership":"join","age":337},"event_id":"$rzywv46JfX2c30FWTTRPBhEMDmnWwOrtabxycJPOHuE"},{"type":"m.room.member","sender":"@user-2-bob:hs1","content":{"displayname":"user-2-bob","membership":"join"},"state_key":"@user-2-bob:hs1","origin_server_ts":1771007575156,"unsigned":{"replaces_state":"$rzywv46JfX2c30FWTTRPBhEMDmnWwOrtabxycJPOHuE","prev_content":{"displayname":"user-2-bob","membership":"invite"},"prev_sender":"@user-1-alice:hs1","membership":"join","age":143},"event_id":"$iMb1zckAPk3WERBUuMNJRIS32F-q3nUbUK_TdYcNTv4"}],"prev_batch":"s9_3_0_1_1_1_1_3_0_1_1_1","limited":false},"org.matrix.msc4222.state_after":{"events":[{"type":"m.room.create","sender":"@user-1-alice:hs1","content":{"room_version":"10","creator":"@user-1-alice:hs1"},"state_key":"","origin_server_ts":1771007574475,"unsigned":{"age":824},"event_id":"$y5d9TkIOmtx_Bgs6emLOkuAsMeKGF8BdBNTfb_MOyVQ"},{"type":"m.room.guest_access","sender":"@user-1-alice:hs1","content":{"guest_access":"can_join"},"state_key":"","origin_server_ts":1771007574738,"unsigned":{"age":561},"event_id":"$cgkV_w3r4_piAlf4g1X8kg7ba6yh37eBdQuXLWWyxzE"},{"type":"m.room.history_visibility","sender":"@user-1-alice:hs1","content":{"history_visibility":"shared"},"state_key":"","origin_server_ts":1771007574738,"unsigned":{"age":561},"event_id":"$JdttXi3VDucYC7HcGAw0OTBwm2K6mJn_7GfkewcHEhc"},{"type":"m.room.join_rules","sender":"@user-1-alice:hs1","content":{"join_rule":"invite"},"state_key":"","origin_server_ts":1771007574738,"unsigned":{"age":561},"event_id":"$KVG2BvhxOJw3wN0iTdOmqV7jONh44dokydRrATbrCvI"},{"type":"m.room.member","sender":"@user-1-alice:hs1","content":{"displayname":"user-1-alice","membership":"join"},"state_key":"@user-1-alice:hs1","origin_server_ts":1771007574619,"unsigned":{"age":680},"event_id":"$1_a1Y1rddZC9qQuILgeZymyTfGS9hQia5dYj82h1O_I"},{"type":"m.room.power_levels","sender":"@user-1-alice:hs1","content":{"users":{"@user-1-alice:hs1":100},"users_default":0,"events":{"m.room.name":50,"m.room.power_levels":100,"m.room.history_visibility":100,"m.room.canonical_alias":50,"m.room.avatar":50,"m.room.tombstone":100,"m.room.server_acl":100,"m.room.encryption":100},"events_default":0,"state_default":50,"ban":50,"kick":50,"redact":50,"invite":0,"historical":100},"state_key":"","origin_server_ts":1771007574733,"unsigned":{"age":566},"event_id":"$qc1MGLT23L35gJCnFGTV3QkL7SmvODwe6PSFqFo3x9I"}]},"account_data":{"events":[]},"ephemeral":{"events":[]},"unread_notifications":{"notification_count":0,"highlight_count":0},"summary":{"m.joined_member_count":2,"m.invited_member_count":0,"m.heroes":["@user-2-bob:hs1"]}}}}}

})
})
}


// The `state_after` in an initial sync request should at-least include membership from
// every `sender` in the `timeline`.
func testInitialSyncStateAfterIncludesTimelineSenders(
t *testing.T,
syncingUser *client.CSAPI,
roomID string,
expectedSendersFromTimeline []string,
syncFilter string,
) {
t.Helper()

// `syncingUser` makes an initial sync request with lazy-loading members enabled
//
// The spec says `lazy_load_members` is valid field for both `timeline` and
// `state` but as far as I can tell, only makes sense for `state` and that's
// what Synapse keys off of.
res, _ := syncingUser.MustSync(t, client.SyncReq{UseStateAfter: true, Filter: syncFilter})
joinedRoomRes := res.Get("rooms.join." + client.GjsonEscape(roomID))
if !joinedRoomRes.Exists() {
t.Fatalf("Unable to find roomID=%s in the join part of the sync response: %s", roomID, res)
}

// Collect the senders of all the time timeline events.
roomTimelineRes := joinedRoomRes.Get("timeline.events");
if !roomTimelineRes.IsArray() {
t.Fatalf("Timeline events is not an array (found %s) %s", roomTimelineRes.Type.String(), res)
}
sendersFromTimeline := make(map[string]struct{}, 0)
for _, event := range roomTimelineRes.Array() {
sendersFromTimeline[event.Get("sender").Str] = struct{}{}
}
// We expect to see timeline events from `expectedSendersFromTimeline`
err := should.ContainSubset(
slices.Collect(maps.Keys(sendersFromTimeline)),
expectedSendersFromTimeline,
)
if err != nil {
t.Fatalf(
"Expected to see timeline events from (%s) but only saw %s. " +
"Got error: %s. join part of the sync response: %s",
expectedSendersFromTimeline,
slices.Collect(maps.Keys(sendersFromTimeline)),
err.Error(),
res,
)
}

// Collect the `m.room.membership` from `state_after`
//
// Try looking up the stable variant `state_after` first, then fallback to the
// unstable version
roomStateAfterResStable := joinedRoomRes.Get("state_after.events");
roomStateAfterResUnstable := joinedRoomRes.Get("org\\.matrix\\.msc4222\\.state_after.events");
var roomStateAfterRes gjson.Result
if roomStateAfterResStable.Exists() {
roomStateAfterRes = roomStateAfterResStable
} else if roomStateAfterResUnstable.Exists() {
roomStateAfterRes = roomStateAfterResUnstable
}
Comment on lines +140 to +149
Copy link
Collaborator Author

@MadLittleMods MadLittleMods Feb 13, 2026

Choose a reason for hiding this comment

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

Not a great piece of code but since we only do this in one spot I'm tempted to leave it rather than create a bad abstraction.

And it's unclear how things like this should work once MSC's have stabilized but homeservers haven't yet.

// Sanity check syntax
if !roomStateAfterRes.IsArray() {
t.Fatalf("state_after events is not an array (found %s) %s", roomStateAfterRes.Type.String(), res)
}
membershipFromState := make(map[string]struct{}, 0)
for _, event := range roomStateAfterRes.Array() {
if event.Get("type").Str == "m.room.member" {
membershipFromState[event.Get("sender").Str] = struct{}{}
}
}
// We should see membership state from every `sender` in the `timeline`.
err = should.ContainSubset(
slices.Collect(maps.Keys(membershipFromState)),
slices.Collect(maps.Keys(sendersFromTimeline)),
)
if err != nil {
t.Fatalf(
"Expected to see membership state (%s) from every sender in the timeline (%s). " +
"Got error: %s. join part of the sync response: %s",
slices.Collect(maps.Keys(membershipFromState)),
slices.Collect(maps.Keys(sendersFromTimeline)),
err.Error(),
res,
)
}
}
Loading