feat(spaces): introduce a SpaceRoomList that allows pagination and provides reactive interfaces to its rooms and pagination state

This commit is contained in:
Stefan Ceriu
2025-08-06 11:41:17 +03:00
committed by Stefan Ceriu
parent a43e42c170
commit b8be1fdb26
5 changed files with 372 additions and 35 deletions

View File

@@ -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<SpaceServiceRoom> {
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<Vec<SpaceServiceRoom>, ClientError> {
) -> Result<SpaceServiceRoomList, ClientError> {
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<dyn SpaceServiceRoomListPaginationStateListener>,
) {
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<SpaceServiceRoom> {
self.inner.rooms().into_iter().map(Into::into).collect()
}
pub fn subscribe_to_room_update(&self, listener: Box<dyn SpaceServiceRoomListEntriesListener>) {
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<UISpaceServiceRoomListPaginationState> 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<SpaceServiceRoom>);
}
#[derive(uniffi::Record)]
pub struct SpaceServiceRoom {
pub room_id: String,

View File

@@ -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::<Vec<_>>()
}
pub async fn top_level_children_for(
&self,
space_id: OwnedRoomId,
) -> Result<Vec<SpaceServiceRoom>, 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::<Vec<_>>())
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(

View File

@@ -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<OwnedRoomAliasId>,

View File

@@ -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<PaginationToken>,
pagination_state: SharedObservable<SpaceServiceRoomListPaginationState>,
rooms: SharedObservable<Vec<SpaceServiceRoom>>,
}
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<SpaceServiceRoomListPaginationState> {
self.pagination_state.subscribe()
}
pub fn rooms(&self) -> Vec<SpaceServiceRoom> {
self.rooms.get()
}
pub fn subscribe_to_room_updates(&self) -> Subscriber<Vec<SpaceServiceRoom>> {
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::<Vec<_>>(),
);
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,
),
]
);
}
}

View File

@@ -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::<Vec<_>>();
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": []
})))
}
}