From d117532fae56a902a3aeb1a3d7d2122e1868c024 Mon Sep 17 00:00:00 2001 From: Stefan Ceriu Date: Fri, 24 Oct 2025 12:18:29 +0300 Subject: [PATCH] feat(spaces): add support for MSC3230 and top level space order (#5799) This is an unstable feature but as per [MSC3230](https://github.com/matrix-org/matrix-spec-proposals/pull/3230) each space room might have an optional `m.space_order`/`org.matrix.msc3230.space_order` string field in its room account data defining the lexicographical order in which the spaces should be displayed, with spaces missing this field shown at the bottom and ordered by their room id. --- Cargo.toml | 3 +- crates/matrix-sdk-ui/CHANGELOG.md | 4 + crates/matrix-sdk-ui/src/spaces/mod.rs | 113 ++++++++++++++++++++++--- 3 files changed, 107 insertions(+), 13 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 63ade22bc..b8898c812 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -74,6 +74,7 @@ ruma = { git = "https://github.com/ruma/ruma", rev = "bbb4a14e14864d364b78d60553 "compat-encrypted-stickers", "compat-lax-room-create-deser", "compat-lax-room-topic-deser", + "unstable-msc3230", "unstable-msc3401", "unstable-msc3488", "unstable-msc3489", @@ -85,7 +86,7 @@ ruma = { git = "https://github.com/ruma/ruma", rev = "bbb4a14e14864d364b78d60553 "unstable-msc4286", "unstable-msc4306", "unstable-msc4308", - "unstable-msc4310" + "unstable-msc4310", ] } sentry = { version = "0.42.0", default-features = false } sentry-tracing = "0.42.0" diff --git a/crates/matrix-sdk-ui/CHANGELOG.md b/crates/matrix-sdk-ui/CHANGELOG.md index 4d5b08cb9..044caf501 100644 --- a/crates/matrix-sdk-ui/CHANGELOG.md +++ b/crates/matrix-sdk-ui/CHANGELOG.md @@ -6,6 +6,10 @@ All notable changes to this project will be documented in this file. ## [Unreleased] - ReleaseDate +### Features +- Add support for top level space ordering through [MSC3230](https://github.com/matrix-org/matrix-spec-proposals/pull/3230) + and `m.space_order` room account data fields ([#5799](https://github.com/matrix-org/matrix-rust-sdk/pull/5799)) + ### Refactor - `TimelineFocusKind::Event` can now handle both the existing event pagination and thread pagination if the focused diff --git a/crates/matrix-sdk-ui/src/spaces/mod.rs b/crates/matrix-sdk-ui/src/spaces/mod.rs index 01e918d59..b3b67aec1 100644 --- a/crates/matrix-sdk-ui/src/spaces/mod.rs +++ b/crates/matrix-sdk-ui/src/spaces/mod.rs @@ -27,11 +27,12 @@ //! - `SpaceRoomList`: A component for retrieving a space's children rooms and //! their details. -use std::sync::Arc; +use std::{cmp::Ordering, collections::HashMap, sync::Arc}; use eyeball_im::{ObservableVector, VectorSubscriberBatchedStream}; use futures_util::pin_mut; use imbl::Vector; +use itertools::Itertools; use matrix_sdk::{ Client, Error as SDKError, deserialized_responses::SyncOrStrippedState, executor::AbortOnDrop, }; @@ -39,7 +40,7 @@ use matrix_sdk_common::executor::spawn; use ruma::{ OwnedRoomId, RoomId, events::{ - SyncStateEvent, + self, SyncStateEvent, space::{child::SpaceChildEventContent, parent::SpaceParentEventContent}, }, }; @@ -291,20 +292,46 @@ impl SpaceService { let root_nodes = graph.root_nodes(); - let joined_space_rooms = joined_spaces + // Proceed with filtering to the top level spaces, sorting them by their + // (optional) order field (as defined in MSC3230) and then mapping them + // to `SpaceRoom`s. + let top_level_spaces = joined_spaces .iter() - .filter_map(|room| { - let room_id = room.room_id(); + .filter(|room| root_nodes.contains(&room.room_id())) + .collect::>(); - if root_nodes.contains(&room_id) { - Some(SpaceRoom::new_from_known(room, graph.children_of(room_id).len() as u64)) - } else { - None + let mut top_level_space_order = HashMap::new(); + for space in &top_level_spaces { + if let Ok(Some(raw_event)) = + space.account_data_static::().await + && let Ok(event) = raw_event.deserialize() + { + top_level_space_order.insert(space.room_id().to_owned(), event.content.order); + } + } + + let top_level_spaces = top_level_spaces + .iter() + .sorted_by(|a, b| { + // MSC3230: lexicographically by `order` and then by room ID + match ( + top_level_space_order.get(a.room_id()), + top_level_space_order.get(b.room_id()), + ) { + (Some(a_order), Some(b_order)) => { + a_order.cmp(b_order).then(a.room_id().cmp(b.room_id())) + } + (Some(_), None) => Ordering::Less, + (None, Some(_)) => Ordering::Greater, + (None, None) => a.room_id().cmp(b.room_id()), } }) + .map(|room| { + SpaceRoom::new_from_known(room, graph.children_of(room.room_id()).len() as u64) + }) .collect(); - (joined_space_rooms, graph) + (top_level_spaces, graph) } } @@ -315,9 +342,11 @@ mod tests { use futures_util::{StreamExt, pin_mut}; use matrix_sdk::{room::ParentSpace, test_utils::mocks::MatrixMockServer}; use matrix_sdk_test::{ - JoinedRoomBuilder, LeftRoomBuilder, async_test, event_factory::EventFactory, + JoinedRoomBuilder, LeftRoomBuilder, RoomAccountDataTestEvent, async_test, + event_factory::EventFactory, }; - use ruma::{RoomVersionId, owned_room_id, room_id}; + use ruma::{RoomVersionId, UserId, owned_room_id, room_id}; + use serde_json::json; use stream_assert::{assert_next_eq, assert_pending}; use super::*; @@ -554,4 +583,64 @@ mod tests { vec![SpaceRoom::new_from_known(&client.get_room(first_space_id).unwrap(), 0)] ); } + + #[async_test] + async fn test_top_level_space_order() { + let server = MatrixMockServer::new().await; + let client = server.client_builder().build().await; + + server.mock_room_state_encryption().plain().mount().await; + + add_space_rooms_with( + vec![ + (room_id!("!2:a.b"), Some("2")), + (room_id!("!4:a.b"), None), + (room_id!("!3:a.b"), None), + (room_id!("!1:a.b"), Some("1")), + ], + &client, + &server, + &EventFactory::new(), + client.user_id().unwrap(), + ) + .await; + + let space_service = SpaceService::new(client.clone()); + + // Space with an `order` field set should come first in lexicographic + // order and rest sorted by room ID. + assert_eq!( + space_service.joined_spaces().await, + vec![ + SpaceRoom::new_from_known(&client.get_room(room_id!("!1:a.b")).unwrap(), 0), + SpaceRoom::new_from_known(&client.get_room(room_id!("!2:a.b")).unwrap(), 0), + SpaceRoom::new_from_known(&client.get_room(room_id!("!3:a.b")).unwrap(), 0), + SpaceRoom::new_from_known(&client.get_room(room_id!("!4:a.b")).unwrap(), 0), + ] + ); + } + + async fn add_space_rooms_with( + rooms: Vec<(&RoomId, Option<&str>)>, + client: &Client, + server: &MatrixMockServer, + factory: &EventFactory, + user_id: &UserId, + ) { + for (room_id, order) in rooms { + let mut builder = JoinedRoomBuilder::new(room_id) + .add_state_event(factory.create(user_id, RoomVersionId::V1).with_space_type()); + + if let Some(order) = order { + builder = builder.add_account_data(RoomAccountDataTestEvent::Custom(json!({ + "type": "m.space_order", + "content": { + "order": order + } + }))); + } + + server.sync_room(client, builder).await; + } + } }