From b8be1fdb2677dda63626889e2a653e738be07e40 Mon Sep 17 00:00:00 2001 From: Stefan Ceriu Date: Wed, 6 Aug 2025 11:41:17 +0300 Subject: [PATCH] feat(spaces): introduce a `SpaceRoomList` that allows pagination and provides reactive interfaces to its rooms and pagination state --- bindings/matrix-sdk-ffi/src/spaces.rs | 98 +++++++- crates/matrix-sdk-ui/src/spaces/mod.rs | 35 +-- crates/matrix-sdk-ui/src/spaces/room.rs | 11 +- crates/matrix-sdk-ui/src/spaces/room_list.rs | 223 ++++++++++++++++++ crates/matrix-sdk/src/test_utils/mocks/mod.rs | 40 ++++ 5 files changed, 372 insertions(+), 35 deletions(-) create mode 100644 crates/matrix-sdk-ui/src/spaces/room_list.rs diff --git a/bindings/matrix-sdk-ffi/src/spaces.rs b/bindings/matrix-sdk-ffi/src/spaces.rs index d6dfff242..661077a6c 100644 --- a/bindings/matrix-sdk-ffi/src/spaces.rs +++ b/bindings/matrix-sdk-ffi/src/spaces.rs @@ -12,8 +12,14 @@ // See the License for the specific language governing permissions and // limitations under the License. +use std::{fmt::Debug, sync::Arc}; + +use futures_util::{pin_mut, StreamExt}; +use matrix_sdk_common::{SendOutsideWasm, SyncOutsideWasm}; use matrix_sdk_ui::spaces::{ + room_list::SpaceServiceRoomListPaginationState as UISpaceServiceRoomListPaginationState, SpaceService as UISpaceService, SpaceServiceRoom as UISpaceServiceRoom, + SpaceServiceRoomList as UISpaceServiceRoomList, }; use ruma::RoomId; @@ -22,6 +28,8 @@ use crate::{ error::ClientError, room::{Membership, RoomHero}, room_preview::RoomType, + runtime::get_runtime_handle, + TaskHandle, }; #[derive(uniffi::Object)] @@ -30,7 +38,6 @@ pub struct SpaceService { } impl SpaceService { - /// Create a new instance of `SpaceService`. pub fn new(inner: UISpaceService) -> Self { Self { inner } } @@ -38,22 +45,99 @@ impl SpaceService { #[matrix_sdk_ffi_macros::export] impl SpaceService { - /// Get the list of joined spaces. pub fn joined_spaces(&self) -> Vec { self.inner.joined_spaces().into_iter().map(Into::into).collect() } - /// Get the top-level children for a given space. - pub async fn top_level_children_for( + pub fn top_level_children_for( &self, space_id: String, - ) -> Result, ClientError> { + ) -> Result { let space_id = RoomId::parse(space_id)?; - let children = self.inner.top_level_children_for(space_id).await?; - Ok(children.into_iter().map(Into::into).collect()) + Ok(SpaceServiceRoomList::new(self.inner.space_room_list(space_id))) } } +#[derive(uniffi::Object)] +pub struct SpaceServiceRoomList { + inner: UISpaceServiceRoomList, +} + +impl SpaceServiceRoomList { + pub fn new(inner: UISpaceServiceRoomList) -> Self { + Self { inner } + } +} + +#[matrix_sdk_ffi_macros::export] +impl SpaceServiceRoomList { + pub fn pagination_state(&self) -> SpaceServiceRoomListPaginationState { + self.inner.pagination_state().into() + } + + pub fn subscribe_to_pagination_state_updates( + &self, + listener: Box, + ) { + 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.into()); + } + }))); + } + + pub fn rooms(&self) -> Vec { + self.inner.rooms().into_iter().map(Into::into).collect() + } + + pub fn subscribe_to_room_update(&self, listener: Box) { + let entries_stream = self.inner.subscribe_to_room_updates(); + + Arc::new(TaskHandle::new(get_runtime_handle().spawn(async move { + pin_mut!(entries_stream); + + while let Some(rooms) = entries_stream.next().await { + listener.on_update(rooms.into_iter().map(Into::into).collect()); + } + }))); + } +} + +#[derive(uniffi::Enum)] +pub enum SpaceServiceRoomListPaginationState { + Idle { end_reached: bool }, + Loading, +} + +impl From for SpaceServiceRoomListPaginationState { + fn from(state: UISpaceServiceRoomListPaginationState) -> Self { + match state { + UISpaceServiceRoomListPaginationState::Idle { end_reached } => { + SpaceServiceRoomListPaginationState::Idle { end_reached } + } + UISpaceServiceRoomListPaginationState::Loading => { + SpaceServiceRoomListPaginationState::Loading + } + } + } +} + +#[matrix_sdk_ffi_macros::export(callback_interface)] +pub trait SpaceServiceRoomListPaginationStateListener: + SendOutsideWasm + SyncOutsideWasm + Debug +{ + fn on_update(&self, pagination_state: SpaceServiceRoomListPaginationState); +} + +#[matrix_sdk_ffi_macros::export(callback_interface)] +pub trait SpaceServiceRoomListEntriesListener: SendOutsideWasm + SyncOutsideWasm + Debug { + fn on_update(&self, rooms: Vec); +} + #[derive(uniffi::Record)] pub struct SpaceServiceRoom { pub room_id: String, diff --git a/crates/matrix-sdk-ui/src/spaces/mod.rs b/crates/matrix-sdk-ui/src/spaces/mod.rs index fb217c8c4..59abce074 100644 --- a/crates/matrix-sdk-ui/src/spaces/mod.rs +++ b/crates/matrix-sdk-ui/src/spaces/mod.rs @@ -14,15 +14,15 @@ //! High level interfaces for working with Spaces //! -//! See [`SpaceDiscoveryService`] for details. +//! See [`SpaceService`] for details. -use matrix_sdk::{Client, Error}; +use matrix_sdk::Client; use ruma::OwnedRoomId; -use ruma::api::client::space::get_hierarchy; -pub use crate::spaces::room::SpaceServiceRoom; +pub use crate::spaces::{room::SpaceServiceRoom, room_list::SpaceServiceRoomList}; pub mod room; +pub mod room_list; pub struct SpaceService { client: Client, @@ -38,36 +38,25 @@ impl SpaceService { .joined_rooms() .into_iter() .filter_map(|room| if room.is_space() { Some(room) } else { None }) - .map(|room| SpaceServiceRoom::new_from_known(room)) + .map(SpaceServiceRoom::new_from_known) .collect::>() } - pub async fn top_level_children_for( - &self, - space_id: OwnedRoomId, - ) -> Result, Error> { - let request = get_hierarchy::v1::Request::new(space_id.clone()); - - let result = self.client.send(request).await?; - - Ok(result - .rooms - .iter() - .map(|room| (&room.summary, self.client.get_room(&room.summary.room_id))) - .map(|(summary, room)| SpaceServiceRoom::new_from_summary(summary, room)) - .collect::>()) + pub fn space_room_list(&self, space_id: OwnedRoomId) -> SpaceServiceRoomList { + SpaceServiceRoomList::new(self.client.clone(), space_id) } } #[cfg(test)] mod tests { - use super::*; use assert_matches2::assert_let; use matrix_sdk::{room::ParentSpace, test_utils::mocks::MatrixMockServer}; use matrix_sdk_test::{JoinedRoomBuilder, async_test, event_factory::EventFactory}; use ruma::{RoomVersionId, room_id}; use tokio_stream::StreamExt; + use super::*; + #[async_test] async fn test_spaces_hierarchy() { let server = MatrixMockServer::new().await; @@ -80,9 +69,9 @@ mod tests { // Given one parent space with 2 children spaces - let parent_space_id = room_id!("!3:example.org"); - let child_space_id_1 = room_id!("!1:example.org"); - let child_space_id_2 = room_id!("!2:example.org"); + 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( diff --git a/crates/matrix-sdk-ui/src/spaces/room.rs b/crates/matrix-sdk-ui/src/spaces/room.rs index 2df6b6161..8bc778ca5 100644 --- a/crates/matrix-sdk-ui/src/spaces/room.rs +++ b/crates/matrix-sdk-ui/src/spaces/room.rs @@ -13,12 +13,13 @@ // limitations under the License. use matrix_sdk::{Room, RoomHero, RoomState}; -use ruma::events::room::guest_access::GuestAccess; -use ruma::events::room::history_visibility::HistoryVisibility; -use ruma::room::{JoinRuleSummary, RoomSummary, RoomType}; -use ruma::{OwnedMxcUri, OwnedRoomAliasId, OwnedRoomId}; +use ruma::{ + OwnedMxcUri, OwnedRoomAliasId, OwnedRoomId, + events::room::{guest_access::GuestAccess, history_visibility::HistoryVisibility}, + room::{JoinRuleSummary, RoomSummary, RoomType}, +}; -#[derive(Debug)] +#[derive(Debug, Clone, PartialEq)] pub struct SpaceServiceRoom { pub room_id: OwnedRoomId, pub canonical_alias: Option, 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 000000000..0fbdc9305 --- /dev/null +++ b/crates/matrix-sdk-ui/src/spaces/room_list.rs @@ -0,0 +1,223 @@ +// 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::Mutex; + +use eyeball::{SharedObservable, Subscriber}; +use matrix_sdk::{Client, Error, paginators::PaginationToken}; +use ruma::{OwnedRoomId, api::client::space::get_hierarchy}; + +use crate::spaces::SpaceServiceRoom; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum SpaceServiceRoomListPaginationState { + Idle { end_reached: bool }, + Loading, +} + +pub struct SpaceServiceRoomList { + client: Client, + + parent_space_id: OwnedRoomId, + + token: Mutex, + + pagination_state: SharedObservable, + + rooms: SharedObservable>, +} + +impl SpaceServiceRoomList { + pub fn new(client: Client, parent_space_id: OwnedRoomId) -> Self { + Self { + client, + parent_space_id, + token: Mutex::new(None.into()), + pagination_state: SharedObservable::new(SpaceServiceRoomListPaginationState::Idle { + end_reached: false, + }), + rooms: SharedObservable::new(Vec::new()), + } + } + + pub fn pagination_state(&self) -> SpaceServiceRoomListPaginationState { + self.pagination_state.get() + } + + pub fn subscribe_to_pagination_state_updates( + &self, + ) -> Subscriber { + self.pagination_state.subscribe() + } + + pub fn rooms(&self) -> Vec { + self.rooms.get() + } + + pub fn subscribe_to_room_updates(&self) -> Subscriber> { + self.rooms.subscribe() + } + + pub async fn paginate(&self) -> Result<(), Error> { + match *self.pagination_state.read() { + SpaceServiceRoomListPaginationState::Idle { end_reached } if end_reached => { + return Ok(()); + } + SpaceServiceRoomListPaginationState::Loading => { + return Ok(()); + } + _ => {} + } + + self.pagination_state.set(SpaceServiceRoomListPaginationState::Loading); + + let mut request = get_hierarchy::v1::Request::new(self.parent_space_id.clone()); + + if let PaginationToken::HasMore(ref token) = *self.token.lock().unwrap() { + request.from = Some(token.clone()); + } + + match self.client.send(request).await { + Ok(result) => { + let mut token = self.token.lock().unwrap(); + *token = match &result.next_batch { + Some(val) => PaginationToken::HasMore(val.clone()), + None => PaginationToken::HitEnd, + }; + + let mut current_rooms = Vec::new(); + + (self.rooms.read()).iter().for_each(|room| { + current_rooms.push(room.clone()); + }); + + current_rooms.extend( + result + .rooms + .iter() + .map(|room| (&room.summary, self.client.get_room(&room.summary.room_id))) + .map(|(summary, room)| SpaceServiceRoom::new_from_summary(summary, room)) + .collect::>(), + ); + + self.rooms.set(current_rooms.clone()); + + self.pagination_state.set(SpaceServiceRoomListPaginationState::Idle { + end_reached: result.next_batch.is_none(), + }); + + Ok(()) + } + Err(err) => { + self.pagination_state + .set(SpaceServiceRoomListPaginationState::Idle { end_reached: false }); + Err(err.into()) + } + } + } +} + +#[cfg(test)] +mod tests { + use assert_matches2::assert_matches; + use futures_util::pin_mut; + use matrix_sdk::test_utils::mocks::MatrixMockServer; + use matrix_sdk_test::async_test; + use ruma::{ + room::{JoinRuleSummary, RoomSummary}, + room_id, uint, + }; + use stream_assert::{assert_next_eq, assert_next_matches, assert_pending}; + + use crate::spaces::{ + SpaceService, SpaceServiceRoom, room_list::SpaceServiceRoomListPaginationState, + }; + + #[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(), + SpaceServiceRoomListPaginationState::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(vec![child_space_id_1, child_space_id_2]) + .mount() + .await; + + room_list.paginate().await.unwrap(); + + // informs that the pagination reached the end + assert_next_matches!( + pagination_state_subscriber, + SpaceServiceRoomListPaginationState::Idle { end_reached: true } + ); + + // yields results + assert_next_eq!( + rooms_subscriber, + vec![ + SpaceServiceRoom::new_from_summary( + &RoomSummary::new( + child_space_id_1.to_owned(), + JoinRuleSummary::Public, + false, + uint!(1), + false, + ), + None, + ), + SpaceServiceRoom::new_from_summary( + &RoomSummary::new( + child_space_id_2.to_owned(), + JoinRuleSummary::Public, + false, + uint!(1), + false, + ), + None, + ), + ] + ); + } +} diff --git a/crates/matrix-sdk/src/test_utils/mocks/mod.rs b/crates/matrix-sdk/src/test_utils/mocks/mod.rs index e8e3cf821..1c7c89fa1 100644 --- a/crates/matrix-sdk/src/test_utils/mocks/mod.rs +++ b/crates/matrix-sdk/src/test_utils/mocks/mod.rs @@ -1448,6 +1448,13 @@ impl MatrixMockServer { self.mock_endpoint(mock, GetThreadSubscriptionsEndpoint::default()) .expect_default_access_token() } + + /// 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() + } } /// A specification for a push rule ID. @@ -4210,3 +4217,36 @@ impl<'a> MockEndpoint<'a, GetThreadSubscriptionsEndpoint> { 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 with an empty list of rooms. + pub fn ok(self) -> MatrixMock<'a> { + self.respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "rooms": [] + }))) + } +}