From 18139ea891e2bff97af7a51d90d55a3ce4e39a0c Mon Sep 17 00:00:00 2001 From: Erwan Leboucher Date: Tue, 4 Aug 2026 17:59:27 +0200 Subject: [PATCH] feat(sync): Add MSC4508 typing extension --- changelog.d/+4508b6f1.feature.md | 1 + src/api/client/sync/v5.rs | 155 +++++++++++++++++++++++++++---- 2 files changed, 140 insertions(+), 16 deletions(-) create mode 100644 changelog.d/+4508b6f1.feature.md diff --git a/changelog.d/+4508b6f1.feature.md b/changelog.d/+4508b6f1.feature.md new file mode 100644 index 000000000..9b0b02616 --- /dev/null +++ b/changelog.d/+4508b6f1.feature.md @@ -0,0 +1 @@ +Added scoped typing updates to simplified Sliding Sync (MSC4508). Contributed by @eleboucher. diff --git a/src/api/client/sync/v5.rs b/src/api/client/sync/v5.rs index c2dd705da..1a616f2d6 100644 --- a/src/api/client/sync/v5.rs +++ b/src/api/client/sync/v5.rs @@ -336,7 +336,8 @@ async fn collect_sync_response( .await?; response.extensions.typing = - collect_typing_events(services, sender_user, body, &todo_rooms).await?; + collect_typing_events(services, sender_user, body, &todo_rooms, &known_room_updates) + .await?; Ok(SyncCollection { response, known_room_updates }) } @@ -365,6 +366,7 @@ fn response_is_empty(response: &sync_events::v5::Response) -> bool { && no_room_data && no_to_device_messages && response.extensions.sticky_events.is_empty() + && response.extensions.typing.is_empty() } async fn commit_sync_collection( @@ -1211,30 +1213,28 @@ async fn collect_typing_events( sender_user: &UserId, body: &sync_events::v5::Request, todo_rooms: &TodoRooms, + known_room_updates: &KnownRoomUpdates, ) -> Result { if !body.extensions.typing.enabled.unwrap_or(false) { return Ok(sync_events::v5::response::Typing::default()); } - let rooms: Vec<_> = body.extensions.typing.rooms.clone().unwrap_or_else(|| { - body.room_subscriptions - .keys() - .cloned() - .map(ExtensionRoomConfig::Room) - .collect() - }); - let lists: Vec<_> = body - .extensions - .typing - .lists - .clone() - .unwrap_or_else(|| body.lists.keys().map(ToOwned::to_owned).collect::>()); - - if rooms.is_empty() && lists.is_empty() { + let typing = &body.extensions.typing; + let scope = extension_scope( + typing.lists.as_deref(), + typing.rooms.as_deref(), + body.lists.keys().map(String::as_str), + known_room_updates, + ); + if scope.is_empty() { return Ok(sync_events::v5::response::Typing::default()); } let mut typing_response = sync_events::v5::response::Typing::default(); for (room_id, (_, _, roomsince)) in todo_rooms { + if !scope.contains(room_id) { + continue; + } + if !services .rooms .state_cache @@ -1269,6 +1269,44 @@ async fn collect_typing_events( Ok(typing_response) } +fn extension_scope<'a>( + lists: Option<&'a [String]>, + rooms: Option<&[ExtensionRoomConfig]>, + all_list_ids: impl Iterator, + known_room_updates: &KnownRoomUpdates, +) -> BTreeSet { + let list_ids: Vec<&str> = lists.map_or_else( + || all_list_ids.collect(), + |lists| lists.iter().map(String::as_str).collect(), + ); + let subscribed_rooms = known_room_updates + .get("subscriptions") + .cloned() + .unwrap_or_default(); + let mut scope = BTreeSet::new(); + for list_id in list_ids { + if let Some(rooms) = known_room_updates.get(list_id) { + scope.extend(rooms.iter().cloned()); + } + } + if let Some(rooms) = rooms { + for room in rooms { + match room { + | ExtensionRoomConfig::AllSubscribed => + scope.extend(subscribed_rooms.iter().cloned()), + | ExtensionRoomConfig::Room(room_id) if subscribed_rooms.contains(room_id) => { + scope.insert(room_id.clone()); + }, + | _ => {}, + } + } + } else { + scope.extend(subscribed_rooms); + } + + scope +} + async fn collect_account_data( services: &Services, (sender_user, _, globalsince, body): (&UserId, &DeviceId, u64, &sync_events::v5::Request), @@ -1553,6 +1591,39 @@ mod tests { fn response() -> sync_events::v5::Response { sync_events::v5::Response::new("1".to_owned()) } + fn typing_scope_request() -> sync_events::v5::Request { + let mut request = sync_events::v5::Request::new(); + request + .lists + .insert("first".to_owned(), sync_events::v5::request::List::default()); + request + .lists + .insert("second".to_owned(), sync_events::v5::request::List::default()); + request.room_subscriptions.insert( + owned_room_id!("!subscription-a:example.com"), + sync_events::v5::request::RoomSubscription::default(), + ); + request.room_subscriptions.insert( + owned_room_id!("!subscription-b:example.com"), + sync_events::v5::request::RoomSubscription::default(), + ); + request + } + + fn known_typing_rooms() -> KnownRoomUpdates { + BTreeMap::from([ + ("first".to_owned(), BTreeSet::from([owned_room_id!("!list-a:example.com")])), + ("second".to_owned(), BTreeSet::from([owned_room_id!("!list-b:example.com")])), + ( + "subscriptions".to_owned(), + BTreeSet::from([ + owned_room_id!("!subscription-a:example.com"), + owned_room_id!("!subscription-b:example.com"), + ]), + ), + ]) + } + #[test] fn absent_bool_filter_matches_either_value() { assert!(matches_bool_filter(true, None)); @@ -1642,6 +1713,58 @@ mod tests { assert!(!response_is_empty(&response)); } + #[test] + fn typing_makes_a_response_non_empty() { + let mut response = response(); + response.extensions.typing.rooms.insert( + owned_room_id!("!a:example.com"), + Raw::from_json_string("{}".to_owned()).unwrap(), + ); + + assert!(!response_is_empty(&response)); + } + + #[test] + fn typing_scope_uses_selected_lists_and_subscriptions() { + let request = typing_scope_request(); + + assert_eq!( + extension_scope( + Some(&["first".to_owned()]), + Some(&[ + ExtensionRoomConfig::Room(owned_room_id!("!list-b:example.com")), + ExtensionRoomConfig::Room(owned_room_id!("!subscription-b:example.com")), + ]), + request.lists.keys().map(String::as_str), + &known_typing_rooms(), + ), + BTreeSet::from([ + owned_room_id!("!list-a:example.com"), + owned_room_id!("!subscription-b:example.com"), + ]) + ); + } + + #[test] + fn typing_scope_defaults_to_all_lists_and_subscriptions() { + let request = typing_scope_request(); + + assert_eq!( + extension_scope( + None, + None, + request.lists.keys().map(String::as_str), + &known_typing_rooms(), + ), + BTreeSet::from([ + owned_room_id!("!list-a:example.com"), + owned_room_id!("!list-b:example.com"), + owned_room_id!("!subscription-a:example.com"), + owned_room_id!("!subscription-b:example.com"), + ]) + ); + } + #[test] fn to_device_without_events_is_empty() { let mut response = response();