diff --git a/Cargo.lock b/Cargo.lock index 989369ee286..96d60dffdaa 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3533,6 +3533,7 @@ dependencies = [ "eyeball-im", "eyeball-im-util", "futures-core", + "futures-executor", "futures-util", "fuzzy-matcher", "growable-bloom-filter", diff --git a/bindings/matrix-sdk-ffi/src/client.rs b/bindings/matrix-sdk-ffi/src/client.rs index ef8724be79d..e4c7306bf07 100644 --- a/bindings/matrix-sdk-ffi/src/client.rs +++ b/bindings/matrix-sdk-ffi/src/client.rs @@ -48,6 +48,7 @@ use matrix_sdk_ui::{ NotificationClient as MatrixNotificationClient, NotificationProcessSetup as MatrixNotificationProcessSetup, }, + spaces::SpaceService as UISpaceService, unable_to_decrypt_hook::UtdHookManager, }; use mime::Mime; @@ -111,6 +112,7 @@ use crate::{ MediaPreviews, MediaSource, RoomAccountDataEvent, RoomAccountDataEventType, }, runtime::get_runtime_handle, + spaces::SpaceService, sync_service::{SyncService, SyncServiceBuilder}, task_handle::TaskHandle, utd::{UnableToDecryptDelegate, UtdHook}, @@ -1257,6 +1259,11 @@ impl Client { SyncServiceBuilder::new((*self.inner).clone(), self.utd_hook_manager.get().cloned()) } + pub fn space_service(&self) -> Arc { + let inner = UISpaceService::new((*self.inner).clone()); + Arc::new(SpaceService::new(inner)) + } + pub async fn get_notification_settings(&self) -> Arc { let inner = self.inner.notification_settings().await; diff --git a/bindings/matrix-sdk-ffi/src/lib.rs b/bindings/matrix-sdk-ffi/src/lib.rs index 07077841f53..09fd4a3657e 100644 --- a/bindings/matrix-sdk-ffi/src/lib.rs +++ b/bindings/matrix-sdk-ffi/src/lib.rs @@ -25,6 +25,7 @@ mod room_preview; mod ruma; mod runtime; mod session_verification; +mod spaces; mod sync_service; mod task_handle; mod timeline; diff --git a/bindings/matrix-sdk-ffi/src/room_preview.rs b/bindings/matrix-sdk-ffi/src/room_preview.rs index f14d738c6c1..8b04d708ec1 100644 --- a/bindings/matrix-sdk-ffi/src/room_preview.rs +++ b/bindings/matrix-sdk-ffi/src/room_preview.rs @@ -31,10 +31,10 @@ impl RoomPreview { avatar_url: info.avatar_url.as_ref().map(|url| url.to_string()), num_joined_members: info.num_joined_members, num_active_members: info.num_active_members, - room_type: info.room_type.as_ref().into(), + room_type: info.room_type.clone().into(), is_history_world_readable: info.is_world_readable, membership: info.state.map(|state| state.into()), - join_rule: info.join_rule.as_ref().map(Into::into), + join_rule: info.join_rule.clone().map(Into::into), is_direct: info.is_direct, heroes: info .heroes @@ -116,8 +116,8 @@ pub struct RoomPreviewInfo { pub heroes: Option>, } -impl From<&JoinRuleSummary> for JoinRule { - fn from(join_rule: &JoinRuleSummary) -> Self { +impl From for JoinRule { + fn from(join_rule: JoinRuleSummary) -> Self { match join_rule { JoinRuleSummary::Invite => JoinRule::Invite, JoinRuleSummary::Knock => JoinRule::Knock, @@ -153,8 +153,8 @@ pub enum RoomType { Custom { value: String }, } -impl From> for RoomType { - fn from(value: Option<&RumaRoomType>) -> Self { +impl From> for RoomType { + fn from(value: Option) -> Self { match value { Some(RumaRoomType::Space) => RoomType::Space, Some(RumaRoomType::_Custom(_)) => RoomType::Custom { diff --git a/bindings/matrix-sdk-ffi/src/spaces.rs b/bindings/matrix-sdk-ffi/src/spaces.rs new file mode 100644 index 00000000000..2d1d5460060 --- /dev/null +++ b/bindings/matrix-sdk-ffi/src/spaces.rs @@ -0,0 +1,237 @@ +// Copyright 2025 The Matrix.org Foundation C.I.C. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +use std::{fmt::Debug, sync::Arc}; + +use eyeball_im::VectorDiff; +use futures_util::{pin_mut, StreamExt}; +use matrix_sdk_common::{SendOutsideWasm, SyncOutsideWasm}; +use matrix_sdk_ui::spaces::{ + room_list::SpaceRoomListPaginationState, SpaceRoom as UISpaceRoom, + SpaceRoomList as UISpaceRoomList, SpaceService as UISpaceService, +}; +use ruma::RoomId; + +use crate::{ + client::JoinRule, + error::ClientError, + room::{Membership, RoomHero}, + room_preview::RoomType, + runtime::get_runtime_handle, + TaskHandle, +}; + +#[derive(uniffi::Object)] +pub struct SpaceService { + inner: UISpaceService, +} + +impl SpaceService { + pub(crate) fn new(inner: UISpaceService) -> Self { + Self { inner } + } +} + +#[matrix_sdk_ffi_macros::export] +impl SpaceService { + pub async fn joined_spaces(&self) -> Vec { + self.inner.joined_spaces().await.into_iter().map(Into::into).collect() + } + + #[allow(clippy::unused_async)] + // This method doesn't need to be async but if its not the FFI layer panics + // with "there is no no reactor running, must be called from the context + // of a Tokio 1.x runtime" error because the underlying method spawns an + // async task. + pub async fn subscribe_to_joined_spaces( + &self, + listener: Box, + ) -> Arc { + let (initial_values, mut stream) = self.inner.subscribe_to_joined_spaces(); + + listener.on_update(vec![SpaceListUpdate::Reset { + values: initial_values.into_iter().map(Into::into).collect(), + }]); + + Arc::new(TaskHandle::new(get_runtime_handle().spawn(async move { + while let Some(diffs) = stream.next().await { + listener.on_update(diffs.into_iter().map(Into::into).collect()); + } + }))) + } + #[allow(clippy::unused_async)] + // This method doesn't need to be async but if its not the FFI layer panics + // with "there is no no reactor running, must be called from the context + // of a Tokio 1.x runtime" error because the underlying constructor spawns + // an async task. + pub async fn space_room_list( + &self, + space_id: String, + ) -> Result, ClientError> { + let space_id = RoomId::parse(space_id)?; + Ok(Arc::new(SpaceRoomList::new(self.inner.space_room_list(space_id)))) + } +} + +#[derive(uniffi::Object)] +pub struct SpaceRoomList { + inner: UISpaceRoomList, +} + +impl SpaceRoomList { + fn new(inner: UISpaceRoomList) -> Self { + Self { inner } + } +} + +#[matrix_sdk_ffi_macros::export] +impl SpaceRoomList { + pub fn pagination_state(&self) -> SpaceRoomListPaginationState { + self.inner.pagination_state() + } + + pub fn subscribe_to_pagination_state_updates( + &self, + listener: Box, + ) -> Arc { + let pagination_state = self.inner.subscribe_to_pagination_state_updates(); + + Arc::new(TaskHandle::new(get_runtime_handle().spawn(async move { + pin_mut!(pagination_state); + + while let Some(state) = pagination_state.next().await { + listener.on_update(state); + } + }))) + } + + pub fn rooms(&self) -> Vec { + self.inner.rooms().into_iter().map(Into::into).collect() + } + + pub fn subscribe_to_room_update( + &self, + listener: Box, + ) -> Arc { + let (initial_values, mut stream) = self.inner.subscribe_to_room_updates(); + + listener.on_update(vec![SpaceListUpdate::Reset { + values: initial_values.into_iter().map(Into::into).collect(), + }]); + + Arc::new(TaskHandle::new(get_runtime_handle().spawn(async move { + while let Some(diffs) = stream.next().await { + listener.on_update(diffs.into_iter().map(Into::into).collect()); + } + }))) + } + + pub async fn paginate(&self) -> Result<(), ClientError> { + self.inner.paginate().await.map_err(ClientError::from) + } +} + +#[matrix_sdk_ffi_macros::export(callback_interface)] +pub trait SpaceRoomListPaginationStateListener: SendOutsideWasm + SyncOutsideWasm + Debug { + fn on_update(&self, pagination_state: SpaceRoomListPaginationState); +} + +#[matrix_sdk_ffi_macros::export(callback_interface)] +pub trait SpaceRoomListEntriesListener: SendOutsideWasm + SyncOutsideWasm + Debug { + fn on_update(&self, rooms: Vec); +} + +#[matrix_sdk_ffi_macros::export(callback_interface)] +pub trait SpaceServiceJoinedSpacesListener: SendOutsideWasm + SyncOutsideWasm + Debug { + fn on_update(&self, room_updates: Vec); +} + +#[derive(uniffi::Record)] +pub struct SpaceRoom { + pub room_id: String, + pub canonical_alias: Option, + pub name: Option, + pub topic: Option, + pub avatar_url: Option, + pub room_type: RoomType, + pub num_joined_members: u64, + pub join_rule: Option, + pub world_readable: Option, + pub guest_can_join: bool, + + pub children_count: u64, + pub state: Option, + pub heroes: Option>, +} + +impl From for SpaceRoom { + fn from(room: UISpaceRoom) -> Self { + Self { + room_id: room.room_id.into(), + canonical_alias: room.canonical_alias.map(|alias| alias.into()), + name: room.name, + topic: room.topic, + avatar_url: room.avatar_url.map(|url| url.into()), + room_type: room.room_type.into(), + num_joined_members: room.num_joined_members, + join_rule: room.join_rule.map(Into::into), + world_readable: room.world_readable, + guest_can_join: room.guest_can_join, + children_count: room.children_count, + state: room.state.map(Into::into), + heroes: room.heroes.map(|heroes| heroes.into_iter().map(Into::into).collect()), + } + } +} + +#[derive(uniffi::Enum)] +pub enum SpaceListUpdate { + Append { values: Vec }, + Clear, + PushFront { value: SpaceRoom }, + PushBack { value: SpaceRoom }, + PopFront, + PopBack, + Insert { index: u32, value: SpaceRoom }, + Set { index: u32, value: SpaceRoom }, + Remove { index: u32 }, + Truncate { length: u32 }, + Reset { values: Vec }, +} + +impl From> for SpaceListUpdate { + fn from(diff: VectorDiff) -> Self { + match diff { + VectorDiff::Append { values } => { + Self::Append { values: values.into_iter().map(|v| v.into()).collect() } + } + VectorDiff::Clear => Self::Clear, + VectorDiff::PushFront { value } => Self::PushFront { value: value.into() }, + VectorDiff::PushBack { value } => Self::PushBack { value: value.into() }, + VectorDiff::PopFront => Self::PopFront, + VectorDiff::PopBack => Self::PopBack, + VectorDiff::Insert { index, value } => { + Self::Insert { index: index as u32, value: value.into() } + } + VectorDiff::Set { index, value } => { + Self::Set { index: index as u32, value: value.into() } + } + VectorDiff::Remove { index } => Self::Remove { index: index as u32 }, + VectorDiff::Truncate { length } => Self::Truncate { length: length as u32 }, + VectorDiff::Reset { values } => { + Self::Reset { values: values.into_iter().map(|v| v.into()).collect() } + } + } + } +} diff --git a/bindings/matrix-sdk-ffi/src/sync_service.rs b/bindings/matrix-sdk-ffi/src/sync_service.rs index 9b5d668051a..92d46f96873 100644 --- a/bindings/matrix-sdk-ffi/src/sync_service.rs +++ b/bindings/matrix-sdk-ffi/src/sync_service.rs @@ -90,6 +90,15 @@ impl SyncService { } }))) } + + /// Force expiring both sliding sync sessions. + /// + /// This ensures that the sync service is stopped before expiring both + /// sessions. It should be used sparingly, as it will cause a restart of + /// the sessions on the server as well. + pub async fn expire_sessions(&self) { + self.inner.expire_sessions().await; + } } #[derive(Clone, uniffi::Object)] diff --git a/crates/matrix-sdk-base/src/response_processors/state_events.rs b/crates/matrix-sdk-base/src/response_processors/state_events.rs index 70fcf0067b9..dc565058e03 100644 --- a/crates/matrix-sdk-base/src/response_processors/state_events.rs +++ b/crates/matrix-sdk-base/src/response_processors/state_events.rs @@ -445,15 +445,19 @@ mod tests { let response = response_builder .add_joined_room( JoinedRoomBuilder::new(room_id_0) - .add_timeline_event( - event_factory.create(sender, RoomVersionId::try_from("42")?), - ) - .add_timeline_event( - event_factory.create(sender, RoomVersionId::try_from("43")?), - ), + .add_timeline_event(event_factory.create( + sender, + RoomVersionId::try_from("42")?, + None, + )) + .add_timeline_event(event_factory.create( + sender, + RoomVersionId::try_from("43")?, + None, + )), ) .add_joined_room(JoinedRoomBuilder::new(room_id_1).add_timeline_event( - event_factory.create(sender, RoomVersionId::try_from("44")?), + event_factory.create(sender, RoomVersionId::try_from("44")?, None), )) .add_joined_room(JoinedRoomBuilder::new(room_id_2)) .build_sync_response(); @@ -481,13 +485,13 @@ mod tests { { let response = response_builder .add_joined_room(JoinedRoomBuilder::new(room_id_0).add_timeline_event( - event_factory.create(sender, RoomVersionId::try_from("45")?), + event_factory.create(sender, RoomVersionId::try_from("45")?, None), )) .add_joined_room(JoinedRoomBuilder::new(room_id_1).add_timeline_event( - event_factory.create(sender, RoomVersionId::try_from("46")?), + event_factory.create(sender, RoomVersionId::try_from("46")?, None), )) .add_joined_room(JoinedRoomBuilder::new(room_id_2).add_timeline_event( - event_factory.create(sender, RoomVersionId::try_from("47")?), + event_factory.create(sender, RoomVersionId::try_from("47")?, None), )) .build_sync_response(); @@ -545,7 +549,7 @@ mod tests { let response = response_builder .add_joined_room(JoinedRoomBuilder::new(room_id_0).add_timeline_event( // Room 0 has no predecessor. - event_factory.create(sender, RoomVersionId::try_from("41")?), + event_factory.create(sender, RoomVersionId::try_from("41")?, None), )) .build_sync_response(); @@ -569,7 +573,7 @@ mod tests { JoinedRoomBuilder::new(room_id_1).add_timeline_event( // Predecessor of room 1 is room 0. event_factory - .create(sender, RoomVersionId::try_from("42")?) + .create(sender, RoomVersionId::try_from("42")?, None) .predecessor(room_id_0), ), ) @@ -608,7 +612,7 @@ mod tests { JoinedRoomBuilder::new(room_id_2).add_timeline_event( // Predecessor of room 2 is room 1. event_factory - .create(sender, RoomVersionId::try_from("43")?) + .create(sender, RoomVersionId::try_from("43")?, None) .predecessor(room_id_1), ), ) @@ -664,7 +668,7 @@ mod tests { JoinedRoomBuilder::new(room_id_0) .add_timeline_event( // No predecessor for room 0. - event_factory.create(sender, RoomVersionId::try_from("41")?), + event_factory.create(sender, RoomVersionId::try_from("41")?, None), ) .add_timeline_event( // Successor of room 0 is room 1. @@ -679,7 +683,7 @@ mod tests { .add_timeline_event( // Predecessor of room 1 is room 0. event_factory - .create(sender, RoomVersionId::try_from("42")?) + .create(sender, RoomVersionId::try_from("42")?, None) .predecessor(room_id_0), ) .add_timeline_event( @@ -694,7 +698,7 @@ mod tests { JoinedRoomBuilder::new(room_id_2).add_timeline_event( // Predecessor of room 2 is room 1. event_factory - .create(sender, RoomVersionId::try_from("43")?) + .create(sender, RoomVersionId::try_from("43")?, None) .predecessor(room_id_1), ), ) @@ -809,7 +813,7 @@ mod tests { JoinedRoomBuilder::new(room_id_1).add_timeline_event( // Predecessor of room 1 is room 0. event_factory - .create(sender, RoomVersionId::try_from("42")?) + .create(sender, RoomVersionId::try_from("42")?, None) .predecessor(room_id_0), ), ) @@ -849,7 +853,7 @@ mod tests { .add_timeline_event( // Predecessor of room 2 is room 1. event_factory - .create(sender, RoomVersionId::try_from("43")?) + .create(sender, RoomVersionId::try_from("43")?, None) .predecessor(room_id_1), ) .add_timeline_event( @@ -919,7 +923,7 @@ mod tests { // No successor. JoinedRoomBuilder::new(room_id_0).add_timeline_event( event_factory - .create(sender, RoomVersionId::try_from("42")?) + .create(sender, RoomVersionId::try_from("42")?, None) .predecessor(room_id_0) .event_id(tombstone_event_id), ), @@ -964,7 +968,7 @@ mod tests { .add_timeline_event( // Predecessor of room 0 is room 0 event_factory - .create(sender, RoomVersionId::try_from("42")?) + .create(sender, RoomVersionId::try_from("42")?, None) .predecessor(room_id_0), ), ) @@ -1007,7 +1011,7 @@ mod tests { .add_timeline_event( // Predecessor of room 0 is room 2 event_factory - .create(sender, RoomVersionId::try_from("42")?) + .create(sender, RoomVersionId::try_from("42")?, None) .predecessor(room_id_2), ) .add_timeline_event( @@ -1022,7 +1026,7 @@ mod tests { .add_timeline_event( // Predecessor of room 1 is room 0 event_factory - .create(sender, RoomVersionId::try_from("43")?) + .create(sender, RoomVersionId::try_from("43")?, None) .predecessor(room_id_0), ) .add_timeline_event( @@ -1037,7 +1041,7 @@ mod tests { .add_timeline_event( // Predecessor of room 2 is room 1 event_factory - .create(sender, RoomVersionId::try_from("44")?) + .create(sender, RoomVersionId::try_from("44")?, None) .predecessor(room_id_1), ) .add_timeline_event( diff --git a/crates/matrix-sdk-base/src/room/tombstone.rs b/crates/matrix-sdk-base/src/room/tombstone.rs index 631cf02729f..6cad45d1489 100644 --- a/crates/matrix-sdk-base/src/room/tombstone.rs +++ b/crates/matrix-sdk-base/src/room/tombstone.rs @@ -206,7 +206,7 @@ mod tests { .add_joined_room( JoinedRoomBuilder::new(room_id).add_timeline_event( EventFactory::new() - .create(sender, RoomVersionId::V11) + .create(sender, RoomVersionId::V11, None) // No `predecessor` field! .no_predecessor() .into_raw_sync(), @@ -233,7 +233,7 @@ mod tests { .add_joined_room( JoinedRoomBuilder::new(room_id).add_timeline_event( EventFactory::new() - .create(sender, RoomVersionId::V11) + .create(sender, RoomVersionId::V11, None) .predecessor(predecessor_room_id) .into_raw_sync(), ), diff --git a/crates/matrix-sdk-base/src/sync.rs b/crates/matrix-sdk-base/src/sync.rs index d59d8915b45..a4ab7d875bc 100644 --- a/crates/matrix-sdk-base/src/sync.rs +++ b/crates/matrix-sdk-base/src/sync.rs @@ -90,13 +90,22 @@ impl RoomUpdates { /// Iterate over all room IDs, from [`RoomUpdates::left`], /// [`RoomUpdates::joined`], [`RoomUpdates::invited`] and /// [`RoomUpdates::knocked`]. - pub(crate) fn iter_all_room_ids(&self) -> impl Iterator { + pub fn iter_all_room_ids(&self) -> impl Iterator { self.left .keys() .chain(self.joined.keys()) .chain(self.invited.keys()) .chain(self.knocked.keys()) } + + /// Returns whether or not this update contains any changes to the list + /// of invited, joined, knocked or left rooms. + pub fn is_empty(&self) -> bool { + self.invited.is_empty() + && self.joined.is_empty() + && self.knocked.is_empty() + && self.left.is_empty() + } } #[cfg(test)] diff --git a/crates/matrix-sdk-ui/CHANGELOG.md b/crates/matrix-sdk-ui/CHANGELOG.md index 8ffcb3ab8bb..942805a1f15 100644 --- a/crates/matrix-sdk-ui/CHANGELOG.md +++ b/crates/matrix-sdk-ui/CHANGELOG.md @@ -7,6 +7,9 @@ All notable changes to this project will be documented in this file. ## [Unreleased] - ReleaseDate ### Features +- Add a new [`SpaceService`] that provides high level reactive interfaces for listing + the user's joined top level spaces as long as their children. + ([#5509](https://github.com/matrix-org/matrix-rust-sdk/pull/5509)) - Add `new_filter_low_priority` and `new_filter_non_low_priority` filters to the room list filtering system, allowing clients to filter rooms based on their low priority status. The filters use the `Room::is_low_priority()` method which checks for the `m.lowpriority` room tag. diff --git a/crates/matrix-sdk-ui/Cargo.toml b/crates/matrix-sdk-ui/Cargo.toml index 7341996fb47..4d60f4b3517 100644 --- a/crates/matrix-sdk-ui/Cargo.toml +++ b/crates/matrix-sdk-ui/Cargo.toml @@ -67,6 +67,7 @@ assert-json-diff.workspace = true assert_matches.workspace = true assert_matches2.workspace = true eyeball-im-util.workspace = true +futures-executor.workspace = true matrix-sdk = { workspace = true, features = ["testing", "sqlite"] } matrix-sdk-test.workspace = true matrix-sdk-test-utils.workspace = true diff --git a/crates/matrix-sdk-ui/src/lib.rs b/crates/matrix-sdk-ui/src/lib.rs index 1c7a2a97ab1..05ad2ffa5f4 100644 --- a/crates/matrix-sdk-ui/src/lib.rs +++ b/crates/matrix-sdk-ui/src/lib.rs @@ -21,6 +21,7 @@ use ruma::html::HtmlSanitizerMode; pub mod encryption_sync_service; pub mod notification_client; pub mod room_list_service; +pub mod spaces; pub mod sync_service; pub mod timeline; pub mod unable_to_decrypt_hook; diff --git a/crates/matrix-sdk-ui/src/room_list_service/mod.rs b/crates/matrix-sdk-ui/src/room_list_service/mod.rs index 3e12f905af4..38a2a6c62e9 100644 --- a/crates/matrix-sdk-ui/src/room_list_service/mod.rs +++ b/crates/matrix-sdk-ui/src/room_list_service/mod.rs @@ -96,6 +96,8 @@ const DEFAULT_REQUIRED_STATE: &[(StateEventType, &str)] = &[ (StateEventType::RoomHistoryVisibility, ""), // Required to correctly calculate the room display name. (StateEventType::MemberHints, ""), + (StateEventType::SpaceParent, "*"), + (StateEventType::SpaceChild, "*"), ]; /// The default `required_state` constant value for sliding sync room diff --git a/crates/matrix-sdk-ui/src/spaces/graph.rs b/crates/matrix-sdk-ui/src/spaces/graph.rs new file mode 100644 index 00000000000..28410ef378d --- /dev/null +++ b/crates/matrix-sdk-ui/src/spaces/graph.rs @@ -0,0 +1,217 @@ +// Copyright 2025 The Matrix.org Foundation C.I.C. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for that specific language governing permissions and +// limitations under the License. + +use std::collections::{BTreeMap, BTreeSet}; + +use ruma::OwnedRoomId; + +#[derive(Debug)] +struct SpaceGraphNode { + id: OwnedRoomId, + parents: BTreeSet, + children: BTreeSet, +} + +impl SpaceGraphNode { + fn new(id: OwnedRoomId) -> Self { + Self { id, parents: BTreeSet::new(), children: BTreeSet::new() } + } +} + +#[derive(Debug)] +/// A graph structure representing a space hierarchy. Contains functionality +/// for mapping parent-child relationships between rooms, removing cycles and +/// retrieving top-level parents/roots. +pub struct SpaceGraph { + nodes: BTreeMap, +} + +impl Default for SpaceGraph { + fn default() -> Self { + Self::new() + } +} + +impl SpaceGraph { + pub fn new() -> Self { + Self { nodes: BTreeMap::new() } + } + + /// Returns the root nodes of the graph, which are nodes without any + /// parents. + pub fn root_nodes(&self) -> Vec<&OwnedRoomId> { + self.nodes.values().filter(|node| node.parents.is_empty()).map(|node| &node.id).collect() + } + + /// Returns the children of a given node. If the node does not exist, it + /// returns an empty vector. + pub fn children_of(&self, node_id: &OwnedRoomId) -> Vec<&OwnedRoomId> { + self.nodes.get(node_id).map_or(vec![], |node| node.children.iter().collect()) + } + + /// Adds a node to the graph. If the node already exists, it does nothing. + pub fn add_node(&mut self, node_id: OwnedRoomId) { + self.nodes.entry(node_id.clone()).or_insert(SpaceGraphNode::new(node_id)); + } + + /// Adds a directed edge from `parent_id` to `child_id`, creating nodes if + /// they do not already exist in the graph. + pub fn add_edge(&mut self, parent_id: OwnedRoomId, child_id: OwnedRoomId) { + self.nodes.entry(parent_id.clone()).or_insert(SpaceGraphNode::new(parent_id.clone())); + + self.nodes.entry(child_id.clone()).or_insert(SpaceGraphNode::new(child_id.clone())); + + self.nodes.get_mut(&parent_id).unwrap().children.insert(child_id.clone()); + self.nodes.get_mut(&child_id).unwrap().parents.insert(parent_id); + } + + /// Removes cycles in the graph by performing a depth-first search (DFS) and + /// remembering the visited nodes. If a node is revisited while still in the + /// current path (i.e. it's on the stack), it indicates a cycle. + pub fn remove_cycles(&mut self) { + let mut visited = BTreeSet::new(); + let mut stack = BTreeSet::new(); + + let mut edges_to_remove = Vec::new(); + + for node_id in self.nodes.keys().cloned().collect::>() { + self.dfs_remove_cycles(&node_id, &mut visited, &mut stack, &mut edges_to_remove); + } + + for (parent, child) in edges_to_remove { + if let Some(node) = self.nodes.get_mut(&parent) { + node.children.remove(&child); + } + if let Some(node) = self.nodes.get_mut(&child) { + node.parents.remove(&parent); + } + } + } + + fn dfs_remove_cycles( + &self, + node_id: &OwnedRoomId, + visited: &mut BTreeSet, + stack: &mut BTreeSet, + edges_to_remove: &mut Vec<(OwnedRoomId, OwnedRoomId)>, + ) { + if !visited.insert(node_id.clone()) { + return; + } + + stack.insert(node_id.clone()); + + if let Some(node) = self.nodes.get(node_id) { + for child in &node.children { + if stack.contains(child) { + // Found a cycle → mark this edge for removal + edges_to_remove.push((node_id.clone(), child.clone())); + } else { + self.dfs_remove_cycles(child, visited, stack, edges_to_remove); + } + } + } + + stack.remove(node_id); + } +} + +#[cfg(test)] +mod tests { + use ruma::room_id; + + use super::*; + + #[test] + fn test_add_edge_and_root_nodes() { + let mut graph = SpaceGraph::new(); + + let a = room_id!("!a:example.org").to_owned(); + let b = room_id!("!b:example.org").to_owned(); + let c = room_id!("!c:example.org").to_owned(); + + graph.add_edge(a.clone(), b.clone()); + graph.add_edge(a.clone(), c.clone()); + + assert_eq!(graph.root_nodes(), vec![&a]); + + assert!(graph.nodes[&b].parents.contains(&a)); + assert!(graph.nodes[&c].parents.contains(&a)); + + assert_eq!(graph.children_of(&a), vec![&b, &c]); + } + + #[test] + fn test_remove_cycles() { + let mut graph = SpaceGraph::new(); + + let a = room_id!("!a:example.org").to_owned(); + let b = room_id!("!b:example.org").to_owned(); + let c = room_id!("!c:example.org").to_owned(); + + graph.add_edge(a.clone(), b.clone()); + graph.add_edge(b, c.clone()); + graph.add_edge(c.clone(), a.clone()); // creates a cycle + + assert!(graph.nodes[&c].children.contains(&a)); + + graph.remove_cycles(); + + assert!(!graph.nodes[&c].children.contains(&a)); + assert!(!graph.nodes[&a].parents.contains(&c)); + } + + #[test] + fn test_disconnected_graph_roots() { + let mut graph = SpaceGraph::new(); + + let a = room_id!("!a:example.org").to_owned(); + let b = room_id!("!b:example.org").to_owned(); + graph.add_edge(a.clone(), b); + + let x = room_id!("!x:example.org").to_owned(); + let y = room_id!("!y:example.org").to_owned(); + graph.add_edge(x.clone(), y); + + let mut roots = graph.root_nodes(); + roots.sort_by_key(|key| key.to_string()); + + let expected: Vec<&OwnedRoomId> = vec![&a, &x]; + assert_eq!(roots, expected); + } + + #[test] + fn test_multiple_parents() { + let mut graph = SpaceGraph::new(); + + let a = room_id!("!a:example.org").to_owned(); + let b = room_id!("!b:example.org").to_owned(); + let c = room_id!("!c:example.org").to_owned(); + let d = room_id!("!d:example.org").to_owned(); + + graph.add_edge(a.clone(), c.clone()); + graph.add_edge(b.clone(), c.clone()); + graph.add_edge(c.clone(), d); + + let mut roots = graph.root_nodes(); + roots.sort_by_key(|key| key.to_string()); + + let expected = vec![&a, &b]; + assert_eq!(roots, expected); + + let c_parents = &graph.nodes[&c].parents; + assert!(c_parents.contains(&a)); + assert!(c_parents.contains(&b)); + } +} diff --git a/crates/matrix-sdk-ui/src/spaces/mod.rs b/crates/matrix-sdk-ui/src/spaces/mod.rs new file mode 100644 index 00000000000..914a7204dc3 --- /dev/null +++ b/crates/matrix-sdk-ui/src/spaces/mod.rs @@ -0,0 +1,517 @@ +// Copyright 2025 The Matrix.org Foundation C.I.C. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for that specific language governing permissions and +// limitations under the License. + +//! High level interfaces for working with Spaces +//! +//! The `SpaceService` is an UI oriented, high-level interface for working with +//! Matrix Spaces. It provides methods to retrieve joined spaces, subscribe +//! to updates, and navigate space hierarchies. +//! +//! It consists of 3 main components: +//! - `SpaceService`: The main service for managing spaces. It +//! - `SpaceGraph`: An utility that maps the `m.space.parent` and +//! `m.space.child` fields into a graph structure, removing cycles and +//! providing access to top level parents. +//! - `SpaceRoomList`: A component for retrieving a space's children rooms and +//! their details. + +use std::sync::Arc; + +use eyeball_im::{ObservableVector, VectorSubscriberBatchedStream}; +use futures_util::pin_mut; +use imbl::Vector; +use matrix_sdk::{Client, deserialized_responses::SyncOrStrippedState, locks::Mutex}; +use matrix_sdk_common::executor::{JoinHandle, spawn}; +use ruma::{ + OwnedRoomId, + events::{ + SyncStateEvent, + space::{child::SpaceChildEventContent, parent::SpaceParentEventContent}, + }, +}; +use tracing::error; + +use crate::spaces::graph::SpaceGraph; +pub use crate::spaces::{room::SpaceRoom, room_list::SpaceRoomList}; + +pub mod graph; +pub mod room; +pub mod room_list; + +/// The main entry point into the Spaces facilities. +/// +/// The spaces service is responsible for retrieving one's joined rooms, +/// building a graph out of their `m.space.parent` and `m.space.child` state +/// events, and providing access to the top-level spaces and their children. +/// +/// # Examples +/// +/// ```no_run +/// use futures_util::StreamExt; +/// use matrix_sdk::Client; +/// use matrix_sdk_ui::spaces::SpaceService; +/// use ruma::owned_room_id; +/// +/// # futures_executor::block_on(async { +/// let client: Client = todo!(); +/// let space_service = SpaceService::new(client.clone()); +/// +/// // Get a list of all the joined spaces +/// let joined_spaces = space_service.joined_spaces().await; +/// +/// // And subscribe to changes on them +/// // `initial_values` is equal to `joined_spaces` if nothing changed meanwhile +/// let (initial_values, stream) = space_service.subscribe_to_joined_spaces(); +/// +/// while let Some(diffs) = stream.next().await { +/// println!("Received joined spaces updates: {diffs:?}"); +/// } +/// +/// // Get a list of all the rooms in a particular space +/// let room_list = space_service +/// .space_room_list(owned_room_id!("!some_space:example.org")); +/// +/// // Which can be used to retrieve information about the children rooms +/// let children = room_list.rooms(); +/// # }) +/// ``` +pub struct SpaceService { + client: Client, + + joined_spaces: Arc>>, + + room_update_handle: Mutex>>, +} + +impl Drop for SpaceService { + fn drop(&mut self) { + if let Some(handle) = &*self.room_update_handle.lock() { + handle.abort(); + } + } +} + +impl SpaceService { + pub fn new(client: Client) -> Self { + Self { + client, + joined_spaces: Arc::new(Mutex::new(ObservableVector::new())), + room_update_handle: Mutex::new(None), + } + } + + /// Subscribes to updates on the joined spaces list. If space rooms are + /// joined or left, the stream will yield diffs that reflect the changes. + pub fn subscribe_to_joined_spaces( + &self, + ) -> (Vector, VectorSubscriberBatchedStream) { + if self.room_update_handle.lock().is_none() { + let client = self.client.clone(); + let joined_spaces = Arc::clone(&self.joined_spaces); + let all_room_updates_receiver = self.client.subscribe_to_all_room_updates(); + + *self.room_update_handle.lock() = Some(spawn(async move { + pin_mut!(all_room_updates_receiver); + + loop { + match all_room_updates_receiver.recv().await { + Ok(updates) => { + if updates.is_empty() { + continue; + } + + let new_spaces = Vector::from(Self::joined_spaces_for(&client).await); + Self::update_joined_spaces_if_needed(new_spaces, &joined_spaces); + } + Err(err) => { + error!("error when listening to room updates: {err}"); + } + } + } + })); + } + + self.joined_spaces.lock().subscribe().into_values_and_batched_stream() + } + + /// Returns a list of all the top-level joined spaces. It will eagerly + /// compute the latest version and also notify subscribers if there were + /// any changes. + pub async fn joined_spaces(&self) -> Vec { + let spaces = Self::joined_spaces_for(&self.client).await; + + Self::update_joined_spaces_if_needed(Vector::from(spaces.clone()), &self.joined_spaces); + + spaces + } + + /// Returns a `SpaceRoomList` for the given space ID. + pub fn space_room_list(&self, space_id: OwnedRoomId) -> SpaceRoomList { + SpaceRoomList::new(self.client.clone(), space_id) + } + + fn update_joined_spaces_if_needed( + new_spaces: Vector, + joined_spaces: &Arc>>, + ) { + let old_spaces = joined_spaces.lock().clone(); + + if new_spaces != old_spaces { + joined_spaces.lock().clear(); + joined_spaces.lock().append(new_spaces); + } + } + + async fn joined_spaces_for(client: &Client) -> Vec { + let joined_spaces = client + .joined_rooms() + .into_iter() + .filter_map(|room| room.is_space().then_some(room)) + .collect::>(); + + // Build a graph to hold the parent-child relations + let mut graph = SpaceGraph::new(); + + // Iterate over all joined spaces and populate the graph with edges based + // on `m.space.parent` and `m.space.child` state events. + for space in joined_spaces.iter() { + graph.add_node(space.room_id().to_owned()); + + if let Ok(parents) = space.get_state_events_static::().await { + parents.into_iter() + .flat_map(|parent_event| match parent_event.deserialize() { + Ok(SyncOrStrippedState::Sync(SyncStateEvent::Original(e))) => { + Some(e.state_key) + } + Ok(SyncOrStrippedState::Sync(SyncStateEvent::Redacted(_))) => None, + Ok(SyncOrStrippedState::Stripped(e)) => Some(e.state_key), + Err(e) => { + error!(room_id = ?space.room_id(), "Could not deserialize m.space.parent: {e}"); + None + } + }).for_each(|parent| graph.add_edge(parent, space.room_id().to_owned())); + } else { + error!(room_id = ?space.room_id(), "Could not get m.space.parent events"); + } + + if let Ok(children) = space.get_state_events_static::().await { + children.into_iter() + .flat_map(|child_event| match child_event.deserialize() { + Ok(SyncOrStrippedState::Sync(SyncStateEvent::Original(e))) => { + Some(e.state_key) + } + Ok(SyncOrStrippedState::Sync(SyncStateEvent::Redacted(_))) => None, + Ok(SyncOrStrippedState::Stripped(e)) => Some(e.state_key), + Err(e) => { + error!(room_id = ?space.room_id(), "Could not deserialize m.space.child: {e}"); + None + } + }).for_each(|child| graph.add_edge(space.room_id().to_owned(), child)); + } else { + error!(room_id = ?space.room_id(), "Could not get m.space.child events"); + } + } + + // Remove cycles from the graph. This is important because they are not + // enforced backend side. + graph.remove_cycles(); + + let root_notes = graph.root_nodes(); + + joined_spaces + .iter() + .flat_map(|room| { + let room_id = room.room_id().to_owned(); + + if root_notes.contains(&&room_id) { + Some(SpaceRoom::new_from_known( + room.clone(), + graph.children_of(&room_id).len() as u64, + )) + } else { + None + } + }) + .collect::>() + } +} + +#[cfg(test)] +mod tests { + use assert_matches2::assert_let; + use eyeball_im::VectorDiff; + use futures_util::pin_mut; + use matrix_sdk::{room::ParentSpace, test_utils::mocks::MatrixMockServer}; + use matrix_sdk_test::{ + JoinedRoomBuilder, LeftRoomBuilder, async_test, event_factory::EventFactory, + }; + use ruma::{RoomVersionId, owned_room_id, room::RoomType, room_id}; + use stream_assert::{assert_next_eq, assert_pending}; + use tokio_stream::StreamExt; + + use super::*; + + #[async_test] + async fn test_spaces_hierarchy() { + let server = MatrixMockServer::new().await; + let client = server.client_builder().build().await; + let user_id = client.user_id().unwrap(); + let space_service = SpaceService::new(client.clone()); + let factory = EventFactory::new(); + + server.mock_room_state_encryption().plain().mount().await; + + // Given one parent space with 2 children spaces + + let parent_space_id = room_id!("!parent_space:example.org"); + let child_space_id_1 = room_id!("!child_space_1:example.org"); + let child_space_id_2 = room_id!("!child_space_2:example.org"); + + server + .sync_room( + &client, + JoinedRoomBuilder::new(child_space_id_1) + .add_state_event(factory.create( + user_id, + RoomVersionId::V1, + Some(RoomType::Space), + )) + .add_state_event( + factory + .space_parent(parent_space_id.to_owned(), child_space_id_1.to_owned()) + .sender(user_id), + ), + ) + .await; + + server + .sync_room( + &client, + JoinedRoomBuilder::new(child_space_id_2) + .add_state_event(factory.create( + user_id, + RoomVersionId::V1, + Some(RoomType::Space), + )) + .add_state_event( + factory + .space_parent(parent_space_id.to_owned(), child_space_id_2.to_owned()) + .sender(user_id), + ), + ) + .await; + server + .sync_room( + &client, + JoinedRoomBuilder::new(parent_space_id) + .add_state_event(factory.create( + user_id, + RoomVersionId::V1, + Some(RoomType::Space), + )) + .add_state_event( + factory + .space_child(parent_space_id.to_owned(), child_space_id_1.to_owned()) + .sender(user_id), + ) + .add_state_event( + factory + .space_child(parent_space_id.to_owned(), child_space_id_2.to_owned()) + .sender(user_id), + ), + ) + .await; + + // Only the parent space is returned + assert_eq!( + space_service + .joined_spaces() + .await + .iter() + .map(|s| s.room_id.to_owned()) + .collect::>(), + vec![parent_space_id] + ); + + // and it has 2 children + assert_eq!( + space_service + .joined_spaces() + .await + .iter() + .map(|s| s.children_count) + .collect::>(), + vec![2] + ); + + let parent_space = client.get_room(parent_space_id).unwrap(); + assert!(parent_space.is_space()); + + // And the parent space and the two child spaces are linked + + let spaces: Vec = client + .get_room(child_space_id_1) + .unwrap() + .parent_spaces() + .await + .unwrap() + .map(Result::unwrap) + .collect() + .await; + + assert_let!(ParentSpace::Reciprocal(parent) = spaces.first().unwrap()); + assert_eq!(parent.room_id(), parent_space.room_id()); + + let spaces: Vec = client + .get_room(child_space_id_2) + .unwrap() + .parent_spaces() + .await + .unwrap() + .map(Result::unwrap) + .collect() + .await; + + assert_let!(ParentSpace::Reciprocal(parent) = spaces.last().unwrap()); + assert_eq!(parent.room_id(), parent_space.room_id()); + } + + #[async_test] + async fn test_joined_spaces_updates() { + let server = MatrixMockServer::new().await; + let client = server.client_builder().build().await; + let user_id = client.user_id().unwrap(); + let factory = EventFactory::new(); + + server.mock_room_state_encryption().plain().mount().await; + + let first_space_id = room_id!("!first_space:example.org"); + let second_space_id = room_id!("!second_space:example.org"); + + // Join the first space + server + .sync_room( + &client, + JoinedRoomBuilder::new(first_space_id).add_state_event(factory.create( + user_id, + RoomVersionId::V1, + Some(RoomType::Space), + )), + ) + .await; + + // Build the `SpaceService` and expect the room to show up with no updates + // pending + + let space_service = SpaceService::new(client.clone()); + + let (_, joined_spaces_subscriber) = space_service.subscribe_to_joined_spaces(); + pin_mut!(joined_spaces_subscriber); + assert_pending!(joined_spaces_subscriber); + + assert_eq!( + space_service.joined_spaces().await, + vec![SpaceRoom::new_from_known(client.get_room(first_space_id).unwrap(), 0)] + ); + + assert_next_eq!( + joined_spaces_subscriber, + vec![VectorDiff::Append { + values: vec![SpaceRoom::new_from_known( + client.get_room(first_space_id).unwrap(), + 0 + )] + .into() + }] + ); + + // Join the second space + + server + .sync_room( + &client, + JoinedRoomBuilder::new(second_space_id) + .add_state_event(factory.create( + user_id, + RoomVersionId::V1, + Some(RoomType::Space), + )) + .add_state_event( + factory + .space_child( + second_space_id.to_owned(), + owned_room_id!("!child:example.org"), + ) + .sender(user_id), + ), + ) + .await; + + // And expect the list to update + assert_eq!( + space_service.joined_spaces().await, + vec![ + SpaceRoom::new_from_known(client.get_room(first_space_id).unwrap(), 0), + SpaceRoom::new_from_known(client.get_room(second_space_id).unwrap(), 1) + ] + ); + + assert_next_eq!( + joined_spaces_subscriber, + vec![ + VectorDiff::Clear, + VectorDiff::Append { + values: vec![ + SpaceRoom::new_from_known(client.get_room(first_space_id).unwrap(), 0), + SpaceRoom::new_from_known(client.get_room(second_space_id).unwrap(), 1) + ] + .into() + }, + ] + ); + + server.sync_room(&client, LeftRoomBuilder::new(second_space_id)).await; + + // and when one is left + assert_next_eq!( + joined_spaces_subscriber, + vec![ + VectorDiff::Clear, + VectorDiff::Append { + values: vec![SpaceRoom::new_from_known( + client.get_room(first_space_id).unwrap(), + 0 + )] + .into() + }, + ] + ); + + // but it doesn't when a non-space room gets joined + server + .sync_room( + &client, + JoinedRoomBuilder::new(room_id!("!room:example.org")) + .add_state_event(factory.create(user_id, RoomVersionId::V1, None)), + ) + .await; + + // and the subscriber doesn't yield any updates + assert_pending!(joined_spaces_subscriber); + assert_eq!( + space_service.joined_spaces().await, + vec![SpaceRoom::new_from_known(client.get_room(first_space_id).unwrap(), 0)] + ); + } +} diff --git a/crates/matrix-sdk-ui/src/spaces/room.rs b/crates/matrix-sdk-ui/src/spaces/room.rs new file mode 100644 index 00000000000..d154581f0dd --- /dev/null +++ b/crates/matrix-sdk-ui/src/spaces/room.rs @@ -0,0 +1,86 @@ +// Copyright 2025 The Matrix.org Foundation C.I.C. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for that specific language governing permissions and +// limitations under the License. + +use matrix_sdk::{Room, RoomHero, RoomState}; +use ruma::{ + OwnedMxcUri, OwnedRoomAliasId, OwnedRoomId, + events::room::{guest_access::GuestAccess, history_visibility::HistoryVisibility}, + room::{JoinRuleSummary, RoomSummary, RoomType}, +}; + +/// Structure representing a room in a space and aggregated information +/// relevant to the UI layer. +#[derive(Debug, Clone, PartialEq)] +pub struct SpaceRoom { + pub room_id: OwnedRoomId, + pub canonical_alias: Option, + pub name: Option, + pub topic: Option, + pub avatar_url: Option, + pub room_type: Option, + pub num_joined_members: u64, + pub join_rule: Option, + pub world_readable: Option, + pub guest_can_join: bool, + + pub children_count: u64, + pub state: Option, + pub heroes: Option>, +} + +impl SpaceRoom { + pub fn new_from_summary( + summary: &RoomSummary, + known_room: Option, + children_count: u64, + ) -> Self { + Self { + room_id: summary.room_id.clone(), + canonical_alias: summary.canonical_alias.clone(), + name: summary.name.clone(), + topic: summary.topic.clone(), + avatar_url: summary.avatar_url.clone(), + room_type: summary.room_type.clone(), + num_joined_members: summary.num_joined_members.into(), + join_rule: Some(summary.join_rule.clone()), + world_readable: Some(summary.world_readable), + guest_can_join: summary.guest_can_join, + children_count, + state: known_room.as_ref().map(|r| r.state()), + heroes: known_room.map(|r| r.heroes()), + } + } + + pub fn new_from_known(known_room: Room, children_count: u64) -> Self { + let room_info = known_room.clone_info(); + + Self { + room_id: room_info.room_id().to_owned(), + canonical_alias: room_info.canonical_alias().map(ToOwned::to_owned), + name: room_info.name().map(ToOwned::to_owned), + topic: room_info.topic().map(ToOwned::to_owned), + avatar_url: room_info.avatar_url().map(ToOwned::to_owned), + room_type: room_info.room_type().cloned(), + num_joined_members: known_room.joined_members_count(), + join_rule: room_info.join_rule().cloned().map(Into::into), + world_readable: room_info + .history_visibility() + .map(|vis| *vis == HistoryVisibility::WorldReadable), + guest_can_join: known_room.guest_access() == GuestAccess::CanJoin, + children_count, + state: Some(known_room.state()), + heroes: Some(room_info.heroes().to_vec()), + } + } +} diff --git a/crates/matrix-sdk-ui/src/spaces/room_list.rs b/crates/matrix-sdk-ui/src/spaces/room_list.rs new file mode 100644 index 00000000000..d7338993299 --- /dev/null +++ b/crates/matrix-sdk-ui/src/spaces/room_list.rs @@ -0,0 +1,408 @@ +// Copyright 2025 The Matrix.org Foundation C.I.C. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for that specific language governing permissions and +// limitations under the License. + +use std::sync::Arc; + +use eyeball::{SharedObservable, Subscriber}; +use eyeball_im::{ObservableVector, VectorSubscriberBatchedStream}; +use futures_util::pin_mut; +use imbl::Vector; +use itertools::Itertools; +use matrix_sdk::{Client, Error, locks::Mutex, paginators::PaginationToken}; +use matrix_sdk_common::executor::{JoinHandle, spawn}; +use ruma::{OwnedRoomId, api::client::space::get_hierarchy, uint}; +use tracing::error; + +use crate::spaces::SpaceRoom; + +#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))] +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum SpaceRoomListPaginationState { + Idle { end_reached: bool }, + Loading, +} + +/// The `SpaceRoomList`represents a paginated list of direct rooms +/// that belong to a particular space. +/// +/// It can be used to paginate through the list (and have live updates on the +/// pagination state) as well as subscribe to changes as rooms are joined or +/// left. +/// +/// # Examples +/// +/// ```no_run +/// use futures_util::StreamExt; +/// use matrix_sdk::Client; +/// use matrix_sdk_ui::spaces::{ +/// SpaceService, room_list::SpaceRoomListPaginationState, +/// }; +/// use ruma::owned_room_id; +/// +/// # futures_executor::block_on(async { +/// let client: Client = todo!(); +/// let space_service = SpaceService::new(client.clone()); +/// +/// // Get a list of all the rooms in a particular space +/// let room_list = space_service +/// .space_room_list(owned_room_id!("!some_space:example.org")); +/// +/// // Start off with an empty and idle list +/// room_list.rooms().is_empty(); +/// +/// assert_eq!( +/// room_list.pagination_state(), +/// SpaceRoomListPaginationState::Idle { end_reached: false } +/// ); +/// +/// // Subscribe to pagination state updates +/// let pagination_state_stream = +/// room_list.subscribe_to_pagination_state_updates(); +/// +/// // And to room list updates +/// let (_, room_stream) = room_list.subscribe_to_room_updates(); +/// +/// // spawn { +/// while let Some(pagination_state) = pagination_state_stream.next().await { +/// println!("Received pagination state update: {pagination_state:?}"); +/// } +/// // } +/// +/// // spawn { +/// while let Some(diffs) = room_stream.next().await { +/// println!("Received room list update: {diffs:?}"); +/// } +/// // } +/// +/// // Ask the room to load the next page +/// room_list.paginate().await.unwrap(); +/// +/// // And, if successful, rooms are available +/// let rooms = room_list.rooms(); +/// # }) +/// ``` +pub struct SpaceRoomList { + client: Client, + + parent_space_id: OwnedRoomId, + + token: Mutex, + + pagination_state: SharedObservable, + + rooms: Arc>>, + + room_update_handle: JoinHandle<()>, +} + +impl Drop for SpaceRoomList { + fn drop(&mut self) { + self.room_update_handle.abort(); + } +} +impl SpaceRoomList { + /// Creates a new `SpaceRoomList` for the given space identifier. + pub fn new(client: Client, parent_space_id: OwnedRoomId) -> Self { + let rooms = Arc::new(Mutex::new(ObservableVector::::new())); + + let client_clone = client.clone(); + let rooms_clone = rooms.clone(); + let all_room_updates_receiver = client.subscribe_to_all_room_updates(); + + let handle = spawn(async move { + pin_mut!(all_room_updates_receiver); + + loop { + match all_room_updates_receiver.recv().await { + Ok(updates) => { + if updates.is_empty() { + continue; + } + + let mut mutable_rooms = rooms_clone.lock(); + + updates.iter_all_room_ids().for_each(|updated_room_id| { + if let Some((position, room)) = mutable_rooms + .clone() + .iter() + .find_position(|room| &room.room_id == updated_room_id) + && let Some(update_room) = client_clone.get_room(updated_room_id) + { + mutable_rooms.set( + position, + SpaceRoom::new_from_known(update_room, room.children_count), + ); + } + }) + } + Err(err) => { + error!("error when listening to room updates: {err}"); + } + } + } + }); + + Self { + client, + parent_space_id, + token: Mutex::new(None.into()), + pagination_state: SharedObservable::new(SpaceRoomListPaginationState::Idle { + end_reached: false, + }), + rooms, + room_update_handle: handle, + } + } + + /// Returns the room list is currently paginating or not. + pub fn pagination_state(&self) -> SpaceRoomListPaginationState { + self.pagination_state.get() + } + + /// Subscribe to pagination updates. + pub fn subscribe_to_pagination_state_updates( + &self, + ) -> Subscriber { + self.pagination_state.subscribe() + } + + /// Return the current list of rooms. + pub fn rooms(&self) -> Vec { + self.rooms.lock().iter().cloned().collect_vec() + } + + /// Subscribes to room list updates. + pub fn subscribe_to_room_updates( + &self, + ) -> (Vector, VectorSubscriberBatchedStream) { + self.rooms.lock().subscribe().into_values_and_batched_stream() + } + + /// As the list to retrieve the next page if the end hasn't been reached + /// yet. Otherwise it no-ops. + pub async fn paginate(&self) -> Result<(), Error> { + match *self.pagination_state.read() { + SpaceRoomListPaginationState::Idle { end_reached } if end_reached => { + return Ok(()); + } + SpaceRoomListPaginationState::Loading => { + return Ok(()); + } + _ => {} + } + + self.pagination_state.set(SpaceRoomListPaginationState::Loading); + + let mut request = get_hierarchy::v1::Request::new(self.parent_space_id.clone()); + request.max_depth = Some(uint!(1)); // We only want the immediate children of the space + + if let PaginationToken::HasMore(ref token) = *self.token.lock() { + request.from = Some(token.clone()); + } + + match self.client.send(request).await { + Ok(result) => { + let mut token = self.token.lock(); + *token = match &result.next_batch { + Some(val) => PaginationToken::HasMore(val.clone()), + None => PaginationToken::HitEnd, + }; + + let mut rooms = self.rooms.lock(); + result + .rooms + .iter() + .flat_map(|room| { + if room.summary.room_id == self.parent_space_id { + None + } else { + Some(SpaceRoom::new_from_summary( + &room.summary, + self.client.get_room(&room.summary.room_id), + room.children_state.len() as u64, + )) + } + }) + .for_each(|room| rooms.push_back(room)); + + self.pagination_state.set(SpaceRoomListPaginationState::Idle { + end_reached: result.next_batch.is_none(), + }); + + Ok(()) + } + Err(err) => { + self.pagination_state + .set(SpaceRoomListPaginationState::Idle { end_reached: false }); + Err(err.into()) + } + } + } +} + +#[cfg(test)] +mod tests { + use assert_matches2::assert_matches; + use eyeball_im::VectorDiff; + use futures_util::pin_mut; + use matrix_sdk::{RoomState, test_utils::mocks::MatrixMockServer}; + use matrix_sdk_test::{JoinedRoomBuilder, LeftRoomBuilder, async_test}; + use ruma::{ + room::{JoinRuleSummary, RoomSummary}, + room_id, uint, + }; + use stream_assert::{assert_next_eq, assert_next_matches, assert_pending, assert_ready}; + + use crate::spaces::{SpaceRoom, SpaceService, room_list::SpaceRoomListPaginationState}; + + #[async_test] + async fn test_room_list_pagination() { + let server = MatrixMockServer::new().await; + let client = server.client_builder().build().await; + let space_service = SpaceService::new(client.clone()); + + server.mock_room_state_encryption().plain().mount().await; + + let parent_space_id = room_id!("!parent_space:example.org"); + + let room_list = space_service.space_room_list(parent_space_id.to_owned()); + + // Start off idle + assert_matches!( + room_list.pagination_state(), + SpaceRoomListPaginationState::Idle { end_reached: false } + ); + + // without any rooms + assert_eq!(room_list.rooms(), vec![]); + + // and with pending subscribers + + let pagination_state_subscriber = room_list.subscribe_to_pagination_state_updates(); + pin_mut!(pagination_state_subscriber); + assert_pending!(pagination_state_subscriber); + + let (_, rooms_subscriber) = room_list.subscribe_to_room_updates(); + pin_mut!(rooms_subscriber); + assert_pending!(rooms_subscriber); + + let child_space_id_1 = room_id!("!1:example.org"); + let child_space_id_2 = room_id!("!2:example.org"); + + // Paginating the room list + server + .mock_get_hierarchy() + .ok_with_room_ids_and_children_state( + vec![child_space_id_1, child_space_id_2], + vec![room_id!("!child:example.org")], + ) + .mount() + .await; + + room_list.paginate().await.unwrap(); + + // informs that the pagination reached the end + assert_next_matches!( + pagination_state_subscriber, + SpaceRoomListPaginationState::Idle { end_reached: true } + ); + + // and yields results + assert_next_eq!( + rooms_subscriber, + vec![ + VectorDiff::PushBack { + value: SpaceRoom::new_from_summary( + &RoomSummary::new( + child_space_id_1.to_owned(), + JoinRuleSummary::Public, + false, + uint!(1), + false, + ), + None, + 1 + ) + }, + VectorDiff::PushBack { + value: SpaceRoom::new_from_summary( + &RoomSummary::new( + child_space_id_2.to_owned(), + JoinRuleSummary::Public, + false, + uint!(1), + false, + ), + None, + 1 + ), + } + ] + ); + } + + #[async_test] + async fn test_room_state_updates() { + let server = MatrixMockServer::new().await; + let client = server.client_builder().build().await; + let space_service = SpaceService::new(client.clone()); + + let parent_space_id = room_id!("!parent_space:example.org"); + let child_room_id_1 = room_id!("!1:example.org"); + let child_room_id_2 = room_id!("!2:example.org"); + + server + .mock_get_hierarchy() + .ok_with_room_ids(vec![child_room_id_1, child_room_id_2]) + .mount() + .await; + + let room_list = space_service.space_room_list(parent_space_id.to_owned()); + + room_list.paginate().await.unwrap(); + + // This space contains 2 rooms + assert_eq!(room_list.rooms().first().unwrap().room_id, child_room_id_1); + assert_eq!(room_list.rooms().last().unwrap().room_id, child_room_id_2); + + // and we don't know about either of them + assert_eq!(room_list.rooms().first().unwrap().state, None); + assert_eq!(room_list.rooms().last().unwrap().state, None); + + let (_, rooms_subscriber) = room_list.subscribe_to_room_updates(); + pin_mut!(rooms_subscriber); + assert_pending!(rooms_subscriber); + + // Joining one of them though + server.sync_room(&client, JoinedRoomBuilder::new(child_room_id_1)).await; + + // Results in an update being pushed through + assert_ready!(rooms_subscriber); + assert_eq!(room_list.rooms().first().unwrap().state, Some(RoomState::Joined)); + assert_eq!(room_list.rooms().last().unwrap().state, None); + + // Same for the second one + server.sync_room(&client, JoinedRoomBuilder::new(child_room_id_2)).await; + assert_ready!(rooms_subscriber); + assert_eq!(room_list.rooms().first().unwrap().state, Some(RoomState::Joined)); + assert_eq!(room_list.rooms().last().unwrap().state, Some(RoomState::Joined)); + + // And when leaving them + server.sync_room(&client, LeftRoomBuilder::new(child_room_id_1)).await; + server.sync_room(&client, LeftRoomBuilder::new(child_room_id_2)).await; + assert_ready!(rooms_subscriber); + assert_eq!(room_list.rooms().first().unwrap().state, Some(RoomState::Left)); + assert_eq!(room_list.rooms().last().unwrap().state, Some(RoomState::Left)); + } +} diff --git a/crates/matrix-sdk-ui/tests/integration/notification_client.rs b/crates/matrix-sdk-ui/tests/integration/notification_client.rs index b817f7c8ad7..cc8b4850928 100644 --- a/crates/matrix-sdk-ui/tests/integration/notification_client.rs +++ b/crates/matrix-sdk-ui/tests/integration/notification_client.rs @@ -335,7 +335,7 @@ async fn test_notification_client_sliding_sync() { let event_factory = EventFactory::new().room(room_id); - let room_create_event = event_factory.create(sender, RoomVersionId::V1).into_raw_sync(); + let room_create_event = event_factory.create(sender, RoomVersionId::V1, None).into_raw_sync(); let sender_member_event = event_factory .member(sender) diff --git a/crates/matrix-sdk-ui/tests/integration/room_list_service.rs b/crates/matrix-sdk-ui/tests/integration/room_list_service.rs index 9d496df6048..0ceb2d8d373 100644 --- a/crates/matrix-sdk-ui/tests/integration/room_list_service.rs +++ b/crates/matrix-sdk-ui/tests/integration/room_list_service.rs @@ -369,6 +369,8 @@ async fn test_sync_all_states() -> Result<(), Error> { ["m.room.create", ""], ["m.room.history_visibility", ""], ["io.element.functional_members", ""], + ["m.space.parent", "*"], + ["m.space.child", "*"], ], "filters": {}, "timeline_limit": 1, @@ -2273,6 +2275,8 @@ async fn test_room_subscription() -> Result<(), Error> { ["m.room.create", ""], ["m.room.history_visibility", ""], ["io.element.functional_members", ""], + ["m.space.parent", "*"], + ["m.space.child", "*"], ["m.room.pinned_events", ""], ], "timeline_limit": 20, @@ -2316,6 +2320,8 @@ async fn test_room_subscription() -> Result<(), Error> { ["m.room.create", ""], ["m.room.history_visibility", ""], ["io.element.functional_members", ""], + ["m.space.parent", "*"], + ["m.space.child", "*"], ["m.room.pinned_events", ""], ], "timeline_limit": 20, diff --git a/crates/matrix-sdk/src/room/mod.rs b/crates/matrix-sdk/src/room/mod.rs index d508ef26883..cf3c67db9bb 100644 --- a/crates/matrix-sdk/src/room/mod.rs +++ b/crates/matrix-sdk/src/room/mod.rs @@ -1166,7 +1166,7 @@ impl Room { // Implements this algorithm: // https://spec.matrix.org/v1.8/client-server-api/#mspaceparent-relationships - // Get all m.room.parent events for this room + // Get all m.space.parent events for this room Ok(self .get_state_events_static::() .await? @@ -1179,7 +1179,7 @@ impl Room { Ok(SyncOrStrippedState::Sync(SyncStateEvent::Redacted(_))) => None, Ok(SyncOrStrippedState::Stripped(e)) => Some((e.state_key.to_owned(), e.sender)), Err(e) => { - info!(room_id = ?self.room_id(), "Could not deserialize m.room.parent: {e}"); + info!(room_id = ?self.room_id(), "Could not deserialize m.space.parent: {e}"); None } }) @@ -1190,7 +1190,7 @@ impl Room { // TODO: try peeking into the room return Ok(ParentSpace::Unverifiable(state_key)); }; - // Get the m.room.child state of the parent with this room's id + // Get the m.space.child state of the parent with this room's id // as state key. if let Some(child_event) = parent_room .get_state_event_static_for_key::(self.room_id()) @@ -1198,7 +1198,7 @@ impl Room { { match child_event.deserialize() { Ok(SyncOrStrippedState::Sync(SyncStateEvent::Original(_))) => { - // There is a valid m.room.child in the parent pointing to + // There is a valid m.space.child in the parent pointing to // this room return Ok(ParentSpace::Reciprocal(parent_room)); } @@ -1207,7 +1207,7 @@ impl Room { Err(e) => { info!( room_id = ?self.room_id(), parent_room_id = ?state_key, - "Could not deserialize m.room.child: {e}" + "Could not deserialize m.space.child: {e}" ); } } @@ -1217,7 +1217,7 @@ impl Room { // relationship: https://spec.matrix.org/v1.8/client-server-api/#mspacechild } - // No reciprocal m.room.child found, let's check if the sender has the + // No reciprocal m.space.child found, let's check if the sender has the // power to set it let Some(member) = parent_room.get_member(&sender).await? else { // Sender is not even in the parent room @@ -3964,7 +3964,7 @@ pub enum ParentSpace { Reciprocal(Room), /// The room recognizes the given room as its parent, but the parent does /// not recognizes it as its child. However, the author of the - /// `m.room.parent` event in the room has a sufficient power level in the + /// `m.space.parent` event in the room has a sufficient power level in the /// parent to create the child event. WithPowerlevel(Room), /// The room recognizes the given room as its parent, but the parent does @@ -4710,7 +4710,8 @@ mod tests { let mut user_map = BTreeMap::from([(sender_id.into(), 50.into())]); // Computing the power levels will need these 3 state events: - let room_create_event = f.create(sender_id, RoomVersionId::V1).state_key("").into_raw(); + let room_create_event = + f.create(sender_id, RoomVersionId::V1, None).state_key("").into_raw(); let power_levels_event = f.power_levels(&mut user_map).state_key("").into_raw(); let room_member_event = f.member(sender_id).into_raw(); diff --git a/crates/matrix-sdk/src/test_utils/mocks/mod.rs b/crates/matrix-sdk/src/test_utils/mocks/mod.rs index 1a8d9a9f7b2..6c62e5ccb0f 100644 --- a/crates/matrix-sdk/src/test_utils/mocks/mod.rs +++ b/crates/matrix-sdk/src/test_utils/mocks/mod.rs @@ -1390,6 +1390,13 @@ impl MatrixMockServer { let mock = Mock::given(method("GET")).and(path("/_matrix/federation/v1/version")); self.mock_endpoint(mock, FederationVersionEndpoint) } + + /// Create a prebuilt mock for the endpoint used to retrieve a space tree + pub fn mock_get_hierarchy(&self) -> MockEndpoint<'_, GetHierarchyEndpoint> { + let mock = + Mock::given(method("GET")).and(path_regex(r"^/_matrix/client/v1/rooms/.*/hierarchy")); + self.mock_endpoint(mock, GetHierarchyEndpoint).expect_default_access_token() + } } /// Parameter to [`MatrixMockServer::sync_room`]. @@ -3989,7 +3996,6 @@ impl<'a> MockEndpoint<'a, EnablePushRuleEndpoint> { self.ok_empty_json() } } - /// A prebuilt mock for the federation version endpoint. pub struct FederationVersionEndpoint; @@ -4011,3 +4017,66 @@ impl<'a> MockEndpoint<'a, FederationVersionEndpoint> { self.respond_with(ResponseTemplate::new(200).set_body_json(response_body)) } } + +/// A prebuilt mock for `GET /client/*/rooms/{roomId}/hierarchy` +#[derive(Default)] +pub struct GetHierarchyEndpoint; + +impl<'a> MockEndpoint<'a, GetHierarchyEndpoint> { + /// Returns a successful response containing the given room IDs. + pub fn ok_with_room_ids(self, room_ids: Vec<&RoomId>) -> MatrixMock<'a> { + let rooms = room_ids + .iter() + .map(|id| { + json!({ + "room_id": id, + "num_joined_members": 1, + "world_readable": false, + "guest_can_join": false, + "children_state": [] + }) + }) + .collect::>(); + + self.respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "rooms": rooms, + }))) + } + + /// Returns a successful response containing the given room IDs and children + /// states + pub fn ok_with_room_ids_and_children_state( + self, + room_ids: Vec<&RoomId>, + children_state: Vec<&RoomId>, + ) -> MatrixMock<'a> { + let children_state = children_state + .into_iter() + .map(|id| json!({ "type": "m.space.child", "state_key": id })) + .collect::>(); + + let rooms = room_ids + .iter() + .map(|id| { + json!({ + "room_id": id, + "num_joined_members": 1, + "world_readable": false, + "guest_can_join": false, + "children_state": children_state + }) + }) + .collect::>(); + + self.respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "rooms": rooms, + }))) + } + + /// Returns a successful response with an empty list of rooms. + pub fn ok(self) -> MatrixMock<'a> { + self.respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "rooms": [] + }))) + } +} diff --git a/crates/matrix-sdk/tests/integration/room/common.rs b/crates/matrix-sdk/tests/integration/room/common.rs index 19de66484e2..21509a76960 100644 --- a/crates/matrix-sdk/tests/integration/room/common.rs +++ b/crates/matrix-sdk/tests/integration/room/common.rs @@ -305,7 +305,7 @@ async fn test_room_route() { // Without eligible server sync_builder.add_joined_room( JoinedRoomBuilder::new(room_id).add_timeline_bulk([ - f.create(user_id!("@creator:127.0.0.1"), room_version_id!("6")) + f.create(user_id!("@creator:127.0.0.1"), room_version_id!("6"), None) .event_id(event_id!("$151957878228ekrDs")) .server_ts(15195787) .sender(user_id!("@creator:127.0.0.1")) diff --git a/crates/matrix-sdk/tests/integration/room/joined.rs b/crates/matrix-sdk/tests/integration/room/joined.rs index d9fc5b25592..38a8bcfe59b 100644 --- a/crates/matrix-sdk/tests/integration/room/joined.rs +++ b/crates/matrix-sdk/tests/integration/room/joined.rs @@ -195,7 +195,7 @@ async fn test_leave_room_also_leaves_predecessor() -> Result<(), anyhow::Error> builder.add_joined_room( JoinedRoomBuilder::new(room_a_id).add_state_event( EventFactory::new() - .create(user, RoomVersionId::V1) + .create(user, RoomVersionId::V1, None) .room(room_a_id) .sender(user) .event_id(create_room_a_event_id), @@ -223,7 +223,7 @@ async fn test_leave_room_also_leaves_predecessor() -> Result<(), anyhow::Error> .add_joined_room( JoinedRoomBuilder::new(room_b_id).add_state_event( EventFactory::new() - .create(user, RoomVersionId::V2) + .create(user, RoomVersionId::V2, None) .predecessor(room_a_id) .room(room_b_id) .sender(user) @@ -271,7 +271,7 @@ async fn test_leave_predecessor_before_successor_no_error() -> Result<(), anyhow builder.add_joined_room( JoinedRoomBuilder::new(room_a_id).add_state_event( EventFactory::new() - .create(user, RoomVersionId::V1) + .create(user, RoomVersionId::V1, None) .room(room_a_id) .sender(user) .event_id(create_room_a_event_id), @@ -299,7 +299,7 @@ async fn test_leave_predecessor_before_successor_no_error() -> Result<(), anyhow .add_joined_room( JoinedRoomBuilder::new(room_b_id).add_state_event( EventFactory::new() - .create(user, RoomVersionId::V2) + .create(user, RoomVersionId::V2, None) .predecessor(room_a_id) .room(room_b_id) .sender(user) @@ -352,7 +352,7 @@ async fn test_leave_room_with_fake_predecessor_no_error() -> Result<(), anyhow:: builder.add_joined_room( JoinedRoomBuilder::new(room_id).add_state_event( EventFactory::new() - .create(user, RoomVersionId::V2) + .create(user, RoomVersionId::V2, None) .predecessor(fake_room_id) .room(room_id) .sender(user) @@ -391,7 +391,7 @@ async fn test_leave_room_fails_with_error() -> Result<(), anyhow::Error> { builder.add_joined_room( JoinedRoomBuilder::new(room_id).add_state_event( EventFactory::new() - .create(user, RoomVersionId::V2) + .create(user, RoomVersionId::V2, None) .room(room_id) .sender(user) .event_id(create_room_event_id), diff --git a/testing/matrix-sdk-test/src/event_factory.rs b/testing/matrix-sdk-test/src/event_factory.rs index 4c092e84973..a7e66b12034 100644 --- a/testing/matrix-sdk-test/src/event_factory.rs +++ b/testing/matrix-sdk-test/src/event_factory.rs @@ -69,9 +69,11 @@ use ruma::{ tombstone::RoomTombstoneEventContent, topic::RoomTopicEventContent, }, + space::{child::SpaceChildEventContent, parent::SpaceParentEventContent}, sticker::StickerEventContent, typing::TypingEventContent, }, + room::RoomType, room_version_rules::AuthorizationRules, serde::Raw, server_name, @@ -871,9 +873,11 @@ impl EventFactory { &self, creator_user_id: &UserId, room_version: RoomVersionId, + room_type: Option, ) -> EventBuilder { let mut event = self.event(RoomCreateEventContent::new_v1(creator_user_id.to_owned())); event.content.room_version = room_version; + event.content.room_type = room_type; if self.sender.is_some() { event.sender = self.sender.clone(); @@ -987,6 +991,30 @@ impl EventFactory { self.event(CallNotifyEventContent::new(call_id, application, notify_type, mentions)) } + /// Create a new `m.space.child` state event. + pub fn space_child( + &self, + parent: OwnedRoomId, + child: OwnedRoomId, + ) -> EventBuilder { + let mut event = self.event(SpaceChildEventContent::new(vec![])); + event.room = Some(parent); + event.state_key = Some(child.to_string()); + event + } + + /// Create a new `m.space.parent` state event. + pub fn space_parent( + &self, + parent: OwnedRoomId, + child: OwnedRoomId, + ) -> EventBuilder { + let mut event = self.event(SpaceParentEventContent::new(vec![])); + event.state_key = Some(parent.to_string()); + event.room = Some(child); + event + } + /// Set the next server timestamp. /// /// Timestamps will continue to increase by 1 (millisecond) from that value.