Skip to main content

matrix_sdk_ui/spaces/
room_list.rs

1// Copyright 2025 The Matrix.org Foundation C.I.C.
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for that specific language governing permissions and
13// limitations under the License.
14
15use std::{cmp::Ordering, collections::HashMap, sync::Arc};
16
17use eyeball::{ObservableWriteGuard, SharedObservable, Subscriber};
18use eyeball_im::{ObservableVector, VectorSubscriberBatchedStream};
19use futures_util::{future::join_all, pin_mut};
20use imbl::Vector;
21use itertools::Itertools;
22use matrix_sdk::{
23    Client, Error, locks::Mutex, paginators::PaginationToken, task_monitor::BackgroundTaskHandle,
24};
25use ruma::{
26    OwnedRoomId,
27    api::client::space::get_hierarchy,
28    events::space::child::{HierarchySpaceChildEvent, SpaceChildEventContent},
29    uint,
30};
31use tokio::sync::Mutex as AsyncMutex;
32use tracing::{error, warn};
33
34use crate::spaces::SpaceRoom;
35
36#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))]
37#[derive(Clone, Debug, Eq, PartialEq)]
38pub enum SpaceRoomListPaginationState {
39    Idle { end_reached: bool },
40    Loading,
41}
42
43/// The `SpaceRoomList`represents a paginated list of direct rooms
44/// that belong to a particular space.
45///
46/// It can be used to paginate through the list (and have live updates on the
47/// pagination state) as well as subscribe to changes as rooms are joined or
48/// left.
49///
50/// The `SpaceRoomList` also automatically subscribes to client room changes
51/// and updates the list accordingly as rooms are joined or left.
52///
53/// # Examples
54///
55/// ```no_run
56/// use futures_util::StreamExt;
57/// use matrix_sdk::Client;
58/// use matrix_sdk_ui::spaces::{
59///     SpaceService, room_list::SpaceRoomListPaginationState,
60/// };
61/// use ruma::owned_room_id;
62///
63/// # async {
64/// # let client: Client = todo!();
65/// let space_service = SpaceService::new(client.clone()).await;
66///
67/// // Get a list of all the rooms in a particular space
68/// let room_list = space_service
69///     .space_room_list(owned_room_id!("!some_space:example.org"))
70///     .await;
71///
72/// // Start off with an empty and idle list
73/// room_list.rooms().await.is_empty();
74///
75/// assert_eq!(
76///     room_list.pagination_state(),
77///     SpaceRoomListPaginationState::Idle { end_reached: false }
78/// );
79///
80/// // Subscribe to pagination state updates
81/// let pagination_state_stream =
82///     room_list.subscribe_to_pagination_state_updates();
83///
84/// // And to room list updates
85/// let (_, room_stream) = room_list.subscribe_to_room_updates().await;
86///
87/// // Run this in a background task so it doesn't block
88/// while let Some(pagination_state) = pagination_state_stream.next().await {
89///     println!("Received pagination state update: {pagination_state:?}");
90/// }
91///
92/// // Run this in a background task so it doesn't block
93/// while let Some(diffs) = room_stream.next().await {
94///     println!("Received room list update: {diffs:?}");
95/// }
96///
97/// // Ask the room to load the next page
98/// room_list.paginate().await.unwrap();
99///
100/// // And, if successful, rooms are available
101/// let rooms = room_list.rooms();
102/// # anyhow::Ok(()) };
103/// ```
104pub struct SpaceRoomList {
105    client: Client,
106
107    space_id: OwnedRoomId,
108
109    space: SharedObservable<Option<SpaceRoom>>,
110
111    children_state: Mutex<Option<HashMap<OwnedRoomId, HierarchySpaceChildEvent>>>,
112
113    token: AsyncMutex<PaginationToken>,
114
115    pagination_state: SharedObservable<SpaceRoomListPaginationState>,
116
117    rooms: Arc<AsyncMutex<ObservableVector<SpaceRoom>>>,
118
119    _space_update_handle: Option<BackgroundTaskHandle>,
120
121    _room_update_handle: BackgroundTaskHandle,
122}
123
124impl SpaceRoomList {
125    /// Creates a new `SpaceRoomList` for the given space identifier.
126    pub async fn new(client: Client, space_id: OwnedRoomId) -> Self {
127        let rooms = Arc::new(AsyncMutex::new(ObservableVector::<SpaceRoom>::new()));
128
129        let all_room_updates_receiver = client.subscribe_to_all_room_updates();
130
131        let room_update_handle = client
132            .task_monitor()
133            .spawn_infinite_task("space_room_list::room_updates", {
134                let client = client.clone();
135                let rooms = rooms.clone();
136
137                async move {
138                    pin_mut!(all_room_updates_receiver);
139
140                    loop {
141                        match all_room_updates_receiver.recv().await {
142                            Ok(updates) => {
143                                if updates.is_empty() {
144                                    continue;
145                                }
146
147                                let mut mutable_rooms = rooms.lock().await;
148
149                                for updated_room_id in updates.iter_all_room_ids() {
150                                    if let Some((position, room)) = mutable_rooms
151                                        .clone()
152                                        .iter()
153                                        .find_position(|room| &room.room_id == updated_room_id)
154                                        && let Some(updated_room) = client.get_room(updated_room_id)
155                                    {
156                                        mutable_rooms.set(
157                                            position,
158                                            SpaceRoom::new_from_known(
159                                                &updated_room,
160                                                room.children_count,
161                                            )
162                                            .await,
163                                        );
164                                    }
165                                }
166                            }
167                            Err(err) => {
168                                error!("error when listening to room updates: {err}");
169                            }
170                        }
171                    }
172                }
173            })
174            .abort_on_drop();
175
176        let space_observable = SharedObservable::new(None);
177
178        let (space_room, space_update_handle) = if let Some(parent) = client.get_room(&space_id) {
179            let children_count = parent
180                .get_state_events_static::<SpaceChildEventContent>()
181                .await
182                .map_or(0, |c| c.len() as u64);
183
184            let mut subscriber = parent.subscribe_info();
185            let space_update_handle = client
186                .task_monitor()
187                .spawn_infinite_task("space_room_list::space_update", {
188                    let client = client.clone();
189                    let space_id = space_id.clone();
190                    let space_observable = space_observable.clone();
191                    async move {
192                        while subscriber.next().await.is_some() {
193                            if let Some(room) = client.get_room(&space_id) {
194                                space_observable.set(Some(
195                                    SpaceRoom::new_from_known(&room, children_count).await,
196                                ));
197                            }
198                        }
199                    }
200                })
201                .abort_on_drop();
202
203            (
204                Some(SpaceRoom::new_from_known(&parent, children_count).await),
205                Some(space_update_handle),
206            )
207        } else {
208            (None, None)
209        };
210
211        space_observable.set(space_room);
212
213        Self {
214            client,
215            space_id,
216            space: space_observable,
217            children_state: Mutex::new(None),
218            token: AsyncMutex::new(None.into()),
219            pagination_state: SharedObservable::new(SpaceRoomListPaginationState::Idle {
220                end_reached: false,
221            }),
222            rooms,
223            _space_update_handle: space_update_handle,
224            _room_update_handle: room_update_handle,
225        }
226    }
227
228    /// Returns the space of the room list if known.
229    pub fn space(&self) -> Option<SpaceRoom> {
230        self.space.get()
231    }
232
233    /// Subscribe to space updates.
234    pub fn subscribe_to_space_updates(&self) -> Subscriber<Option<SpaceRoom>> {
235        self.space.subscribe()
236    }
237
238    /// Returns if the room list is currently paginating or not.
239    pub fn pagination_state(&self) -> SpaceRoomListPaginationState {
240        self.pagination_state.get()
241    }
242
243    /// Subscribe to pagination updates.
244    pub fn subscribe_to_pagination_state_updates(
245        &self,
246    ) -> Subscriber<SpaceRoomListPaginationState> {
247        self.pagination_state.subscribe()
248    }
249
250    /// Return the current list of rooms.
251    pub async fn rooms(&self) -> Vec<SpaceRoom> {
252        self.rooms.lock().await.iter().cloned().collect_vec()
253    }
254
255    /// Subscribes to room list updates.
256    pub async fn subscribe_to_room_updates(
257        &self,
258    ) -> (Vector<SpaceRoom>, VectorSubscriberBatchedStream<SpaceRoom>) {
259        self.rooms.lock().await.subscribe().into_values_and_batched_stream()
260    }
261
262    /// Ask the list to retrieve the next page if the end hasn't been reached
263    /// yet. Otherwise it no-ops.
264    pub async fn paginate(&self) -> Result<(), Error> {
265        {
266            let mut pagination_state = self.pagination_state.write();
267
268            match *pagination_state {
269                SpaceRoomListPaginationState::Idle { end_reached } if end_reached => {
270                    return Ok(());
271                }
272                SpaceRoomListPaginationState::Loading => {
273                    return Ok(());
274                }
275                _ => {}
276            }
277
278            ObservableWriteGuard::set(&mut pagination_state, SpaceRoomListPaginationState::Loading);
279        }
280
281        let mut request = get_hierarchy::v1::Request::new(self.space_id.clone());
282        request.max_depth = Some(uint!(1)); // We only want the immediate children of the space
283
284        let mut pagination_token = self.token.lock().await;
285
286        if let PaginationToken::HasMore(ref token) = *pagination_token {
287            request.from = Some(token.clone());
288        }
289
290        match self.client.send(request).await {
291            Ok(result) => {
292                *pagination_token = match &result.next_batch {
293                    Some(val) => PaginationToken::HasMore(val.clone()),
294                    None => PaginationToken::HitEnd,
295                };
296
297                // The space is part of the /hierarchy response. Partition the room array
298                // so we can use its details but also filter it out of the room list
299                let (space, children): (Vec<_>, Vec<_>) =
300                    result.rooms.into_iter().partition(|f| f.summary.room_id == self.space_id);
301
302                if let Some(room) = space.first() {
303                    let mut children_state =
304                        HashMap::<OwnedRoomId, HierarchySpaceChildEvent>::new();
305                    for child_state in &room.children_state {
306                        match child_state.deserialize() {
307                            Ok(child) => {
308                                children_state.insert(child.state_key.clone(), child.clone());
309                            }
310                            Err(error) => {
311                                warn!("Failed deserializing space child event: {error}");
312                            }
313                        }
314                    }
315                    *self.children_state.lock() = Some(children_state);
316
317                    let space_is_none = self.space.read().is_none();
318                    if space_is_none {
319                        let space_room = SpaceRoom::new_from_summary(
320                            &room.summary,
321                            self.client.get_room(&room.summary.room_id),
322                            room.children_state.len() as u64,
323                            vec![],
324                            false,
325                        )
326                        .await;
327                        let mut space = self.space.write();
328                        ObservableWriteGuard::set(&mut space, Some(space_room));
329                    }
330                }
331
332                let children_state = (*self.children_state.lock()).clone().unwrap_or_default();
333                let mut rooms = self.rooms.lock().await;
334
335                join_all(children.into_iter().map(|room| {
336                    let children_state = children_state.clone();
337                    async move {
338                        let child_state = children_state.get(&room.summary.room_id);
339                        let via =
340                            child_state.map(|state| state.content.via.clone()).unwrap_or_default();
341                        let suggested =
342                            child_state.map(|state| state.content.suggested).unwrap_or(false);
343                        SpaceRoom::new_from_summary(
344                            &room.summary,
345                            self.client.get_room(&room.summary.room_id),
346                            room.children_state.len() as u64,
347                            via,
348                            suggested,
349                        )
350                        .await
351                    }
352                }))
353                .await
354                .into_iter()
355                .sorted_by(|a, b| Self::compare_rooms(a, b, &children_state))
356                .for_each(|room| rooms.push_back(room));
357
358                self.pagination_state.set(SpaceRoomListPaginationState::Idle {
359                    end_reached: result.next_batch.is_none(),
360                });
361
362                Ok(())
363            }
364            Err(err) => {
365                self.pagination_state
366                    .set(SpaceRoomListPaginationState::Idle { end_reached: false });
367                Err(err.into())
368            }
369        }
370    }
371
372    /// Clears the room list back to its initial state so that any new changes
373    /// to the hierarchy will be included the next time [`Self::paginate`] is
374    /// called.
375    ///
376    /// This is useful when you've added or removed children from the space as
377    /// the list is based on a cached state that lives server-side, meaning
378    /// the /hierarchy request needs to be restarted from scratch to pick up
379    /// the changes.
380    pub async fn reset(&self) {
381        let mut pagination_token = self.token.lock().await;
382        *pagination_token = None.into();
383
384        self.rooms.lock().await.clear();
385        self.children_state.lock().take();
386
387        self.pagination_state.set(SpaceRoomListPaginationState::Idle { end_reached: false });
388    }
389
390    /// Sorts space rooms by various criteria as defined in
391    /// https://spec.matrix.org/latest/client-server-api/#ordering-of-children-within-a-space
392    fn compare_rooms(
393        a: &SpaceRoom,
394        b: &SpaceRoom,
395        children_state: &HashMap<OwnedRoomId, HierarchySpaceChildEvent>,
396    ) -> Ordering {
397        let a_state = children_state.get(&a.room_id);
398        let b_state = children_state.get(&b.room_id);
399
400        SpaceRoom::compare_rooms(
401            (&a.room_id, a_state.map(Into::into).as_ref()),
402            (&b.room_id, b_state.map(Into::into).as_ref()),
403        )
404    }
405}
406
407#[cfg(test)]
408mod tests {
409    use std::{cmp::Ordering, collections::HashMap};
410
411    use assert_matches2::{assert_let, assert_matches};
412    use eyeball_im::VectorDiff;
413    use futures_util::pin_mut;
414    use matrix_sdk::{RoomState, test_utils::mocks::MatrixMockServer};
415    use matrix_sdk_test::{
416        JoinedRoomBuilder, LeftRoomBuilder, async_test, event_factory::EventFactory,
417    };
418    use ruma::{
419        MilliSecondsSinceUnixEpoch, OwnedRoomId, RoomId,
420        events::space::child::HierarchySpaceChildEvent,
421        owned_room_id, owned_server_name,
422        room::{JoinRuleSummary, RoomSummary},
423        room_id, server_name, uint,
424    };
425    use serde_json::{from_value, json};
426    use stream_assert::{assert_next_eq, assert_next_matches, assert_pending, assert_ready};
427    use wiremock::ResponseTemplate;
428
429    use crate::spaces::{
430        SpaceRoom, SpaceRoomList, SpaceService, room_list::SpaceRoomListPaginationState,
431    };
432
433    #[async_test]
434    async fn test_room_list_pagination() {
435        let server = MatrixMockServer::new().await;
436        let client = server.client_builder().build().await;
437        let user_id = client.user_id().unwrap();
438        let space_service = SpaceService::new(client.clone()).await;
439        let factory = EventFactory::new();
440
441        server.mock_room_state_encryption().plain().mount().await;
442
443        let parent_space_id = room_id!("!parent_space:example.org");
444        let child_space_id_1 = room_id!("!1:example.org");
445        let child_space_id_2 = room_id!("!2:example.org");
446
447        server
448            .sync_room(
449                &client,
450                JoinedRoomBuilder::new(parent_space_id)
451                    .add_state_event(
452                        factory
453                            .space_child(parent_space_id.to_owned(), child_space_id_1.to_owned())
454                            .sender(user_id),
455                    )
456                    .add_state_event(
457                        factory
458                            .space_child(parent_space_id.to_owned(), child_space_id_2.to_owned())
459                            .sender(user_id),
460                    ),
461            )
462            .await;
463
464        let room_list = space_service.space_room_list(parent_space_id.to_owned()).await;
465
466        // The space parent is known to the client and should be populated accordingly
467        assert_let!(Some(parent_space) = room_list.space());
468        assert_eq!(parent_space.children_count, 2);
469
470        // Start off idle
471        assert_matches!(
472            room_list.pagination_state(),
473            SpaceRoomListPaginationState::Idle { end_reached: false }
474        );
475
476        // without any rooms
477        assert_eq!(room_list.rooms().await, vec![]);
478
479        // and with pending subscribers
480
481        let pagination_state_subscriber = room_list.subscribe_to_pagination_state_updates();
482        pin_mut!(pagination_state_subscriber);
483        assert_pending!(pagination_state_subscriber);
484
485        let (_, rooms_subscriber) = room_list.subscribe_to_room_updates().await;
486        pin_mut!(rooms_subscriber);
487        assert_pending!(rooms_subscriber);
488
489        // Paginating the room list
490        server
491            .mock_get_hierarchy()
492            .ok_with_room_ids_and_children_state(
493                vec![child_space_id_1, child_space_id_2],
494                vec![(room_id!("!child:example.org"), vec![])],
495            )
496            .mount()
497            .await;
498
499        room_list.paginate().await.unwrap();
500
501        // informs that the pagination reached the end
502        assert_next_matches!(
503            pagination_state_subscriber,
504            SpaceRoomListPaginationState::Idle { end_reached: true }
505        );
506
507        // and yields results
508        assert_next_eq!(
509            rooms_subscriber,
510            vec![
511                VectorDiff::PushBack {
512                    value: SpaceRoom::new_from_summary(
513                        &RoomSummary::new(
514                            child_space_id_1.to_owned(),
515                            JoinRuleSummary::Public,
516                            false,
517                            uint!(1),
518                            false,
519                        ),
520                        None,
521                        1,
522                        vec![],
523                        false,
524                    )
525                    .await
526                },
527                VectorDiff::PushBack {
528                    value: SpaceRoom::new_from_summary(
529                        &RoomSummary::new(
530                            child_space_id_2.to_owned(),
531                            JoinRuleSummary::Public,
532                            false,
533                            uint!(1),
534                            false,
535                        ),
536                        None,
537                        1,
538                        vec![],
539                        false,
540                    )
541                    .await,
542                }
543            ]
544        );
545    }
546
547    #[async_test]
548    async fn test_room_state_updates() {
549        let server = MatrixMockServer::new().await;
550        let client = server.client_builder().build().await;
551        let space_service = SpaceService::new(client.clone()).await;
552
553        let parent_space_id = room_id!("!parent_space:example.org");
554        let child_room_id_1 = room_id!("!1:example.org");
555        let child_room_id_2 = room_id!("!2:example.org");
556
557        server
558            .mock_get_hierarchy()
559            .ok_with_room_ids(vec![child_room_id_1, child_room_id_2])
560            .mount()
561            .await;
562
563        let room_list = space_service.space_room_list(parent_space_id.to_owned()).await;
564
565        room_list.paginate().await.unwrap();
566
567        // This space contains 2 rooms
568        assert_eq!(room_list.rooms().await.first().unwrap().room_id, child_room_id_1);
569        assert_eq!(room_list.rooms().await.last().unwrap().room_id, child_room_id_2);
570
571        // and we don't know about either of them
572        assert_eq!(room_list.rooms().await.first().unwrap().state, None);
573        assert_eq!(room_list.rooms().await.last().unwrap().state, None);
574
575        let (_, rooms_subscriber) = room_list.subscribe_to_room_updates().await;
576        pin_mut!(rooms_subscriber);
577        assert_pending!(rooms_subscriber);
578
579        // Joining one of them though
580        server.sync_room(&client, JoinedRoomBuilder::new(child_room_id_1)).await;
581
582        // Results in an update being pushed through
583        assert_ready!(rooms_subscriber);
584        assert_eq!(room_list.rooms().await.first().unwrap().state, Some(RoomState::Joined));
585        assert_eq!(room_list.rooms().await.last().unwrap().state, None);
586
587        // Same for the second one
588        server.sync_room(&client, JoinedRoomBuilder::new(child_room_id_2)).await;
589        assert_ready!(rooms_subscriber);
590        assert_eq!(room_list.rooms().await.first().unwrap().state, Some(RoomState::Joined));
591        assert_eq!(room_list.rooms().await.last().unwrap().state, Some(RoomState::Joined));
592
593        // And when leaving them
594        server.sync_room(&client, LeftRoomBuilder::new(child_room_id_1)).await;
595        server.sync_room(&client, LeftRoomBuilder::new(child_room_id_2)).await;
596        assert_ready!(rooms_subscriber);
597        assert_eq!(room_list.rooms().await.first().unwrap().state, Some(RoomState::Left));
598        assert_eq!(room_list.rooms().await.last().unwrap().state, Some(RoomState::Left));
599    }
600
601    #[async_test]
602    async fn test_parent_space_updates() {
603        let server = MatrixMockServer::new().await;
604        let client = server.client_builder().build().await;
605        let user_id = client.user_id().unwrap();
606        let space_service = SpaceService::new(client.clone()).await;
607        let factory = EventFactory::new();
608
609        server.mock_room_state_encryption().plain().mount().await;
610
611        let parent_space_id = room_id!("!parent_space:example.org");
612        let child_space_id_1 = room_id!("!1:example.org");
613        let child_space_id_2 = room_id!("!2:example.org");
614
615        // Parent space is unknown to the client and thus not populated yet
616        let room_list = space_service.space_room_list(parent_space_id.to_owned()).await;
617        assert!(room_list.space().is_none());
618
619        let parent_space_subscriber = room_list.subscribe_to_space_updates();
620        pin_mut!(parent_space_subscriber);
621        assert_pending!(parent_space_subscriber);
622
623        server
624            .mock_get_hierarchy()
625            .ok_with_room_ids_and_children_state(
626                vec![parent_space_id, child_space_id_1, child_space_id_2],
627                vec![(
628                    room_id!("!child:example.org"),
629                    vec![server_name!("matrix-client.example.org")],
630                )],
631            )
632            .mount()
633            .await;
634
635        // Pagination will however fetch and populate it from /hierarchy
636        room_list.paginate().await.unwrap();
637        assert_let!(Some(parent_space) = room_list.space());
638        assert_eq!(parent_space.room_id, parent_space_id);
639
640        // And the subscription is informed about the change
641        assert_next_eq!(parent_space_subscriber, Some(parent_space));
642
643        // If the room is already known to the client then the space parent
644        // is populated directly on creation
645        server
646            .sync_room(
647                &client,
648                JoinedRoomBuilder::new(parent_space_id)
649                    .add_state_event(
650                        factory
651                            .space_child(parent_space_id.to_owned(), child_space_id_1.to_owned())
652                            .sender(user_id),
653                    )
654                    .add_state_event(
655                        factory
656                            .space_child(parent_space_id.to_owned(), child_space_id_2.to_owned())
657                            .sender(user_id),
658                    ),
659            )
660            .await;
661
662        let room_list = space_service.space_room_list(parent_space_id.to_owned()).await;
663
664        // The parent space is known to the client and should be populated accordingly
665        assert_let!(Some(parent_space) = room_list.space());
666        assert_eq!(parent_space.children_count, 2);
667    }
668
669    #[async_test]
670    async fn test_parent_space_room_info_update() {
671        let server = MatrixMockServer::new().await;
672        let client = server.client_builder().build().await;
673        let user_id = client.user_id().unwrap();
674        let space_service = SpaceService::new(client.clone()).await;
675        let factory = EventFactory::new();
676
677        server.mock_room_state_encryption().plain().mount().await;
678
679        let parent_space_id = room_id!("!parent_space:example.org");
680
681        server.sync_room(&client, JoinedRoomBuilder::new(parent_space_id)).await;
682
683        let room_list = space_service.space_room_list(parent_space_id.to_owned()).await;
684        assert_let!(Some(parent_space) = room_list.space());
685
686        // The parent space is known to the client
687        let parent_space_subscriber = room_list.subscribe_to_space_updates();
688        pin_mut!(parent_space_subscriber);
689        assert_pending!(parent_space_subscriber);
690
691        // So any room info changes are automatically published
692        server
693            .sync_room(
694                &client,
695                JoinedRoomBuilder::new(parent_space_id)
696                    .add_state_event(factory.room_topic("New room topic").sender(user_id))
697                    .add_state_event(factory.room_name("New room name").sender(user_id)),
698            )
699            .await;
700
701        let mut updated_parent_space = parent_space.clone();
702        updated_parent_space.topic = Some("New room topic".to_owned());
703        updated_parent_space.name = Some("New room name".to_owned());
704        updated_parent_space.display_name = "New room name".to_owned();
705
706        // And the subscription is informed about the change
707        assert_next_eq!(parent_space_subscriber, Some(updated_parent_space));
708    }
709
710    #[async_test]
711    async fn test_via_retrieval() {
712        let server = MatrixMockServer::new().await;
713        let client = server.client_builder().build().await;
714        let space_service = SpaceService::new(client.clone()).await;
715
716        server.mock_room_state_encryption().plain().mount().await;
717
718        let parent_space_id = room_id!("!parent_space:example.org");
719        let child_space_id_1 = room_id!("!1:example.org");
720        let child_space_id_2 = room_id!("!2:example.org");
721
722        let room_list = space_service.space_room_list(parent_space_id.to_owned()).await;
723
724        let (_, rooms_subscriber) = room_list.subscribe_to_room_updates().await;
725        pin_mut!(rooms_subscriber);
726
727        // When retrieving the parent and children via /hierarchy
728        server
729            .mock_get_hierarchy()
730            .ok_with_room_ids_and_children_state(
731                vec![parent_space_id, child_space_id_1, child_space_id_2],
732                vec![
733                    (child_space_id_1, vec![server_name!("matrix-client.example.org")]),
734                    (child_space_id_2, vec![server_name!("other-matrix-client.example.org")]),
735                ],
736            )
737            .mount()
738            .await;
739
740        room_list.paginate().await.unwrap();
741
742        // The parent `children_state` is used to populate children via params
743        assert_next_eq!(
744            rooms_subscriber,
745            vec![
746                VectorDiff::PushBack {
747                    value: SpaceRoom::new_from_summary(
748                        &RoomSummary::new(
749                            child_space_id_1.to_owned(),
750                            JoinRuleSummary::Public,
751                            false,
752                            uint!(1),
753                            false,
754                        ),
755                        None,
756                        2,
757                        vec![owned_server_name!("matrix-client.example.org")],
758                        false,
759                    )
760                    .await
761                },
762                VectorDiff::PushBack {
763                    value: SpaceRoom::new_from_summary(
764                        &RoomSummary::new(
765                            child_space_id_2.to_owned(),
766                            JoinRuleSummary::Public,
767                            false,
768                            uint!(1),
769                            false,
770                        ),
771                        None,
772                        2,
773                        vec![owned_server_name!("other-matrix-client.example.org")],
774                        false,
775                    )
776                    .await,
777                }
778            ]
779        );
780    }
781
782    #[async_test]
783    async fn test_suggested_field() {
784        let server = MatrixMockServer::new().await;
785        let client = server.client_builder().build().await;
786        let space_service = SpaceService::new(client.clone()).await;
787
788        server.mock_room_state_encryption().plain().mount().await;
789
790        let parent_space_id = room_id!("!parent_space:example.org");
791        let suggested_child = room_id!("!suggested:example.org");
792        let not_suggested_child = room_id!("!not_suggested:example.org");
793
794        let room_list = space_service.space_room_list(parent_space_id.to_owned()).await;
795
796        let (_, rooms_subscriber) = room_list.subscribe_to_room_updates().await;
797        pin_mut!(rooms_subscriber);
798
799        // Mock a /hierarchy response where one child is suggested and the other is not.
800        let children_state = vec![
801            json!({
802                "type": "m.space.child",
803                "state_key": suggested_child,
804                "content": { "via": ["example.org"], "suggested": true },
805                "sender": "@admin:example.org",
806                "origin_server_ts": MilliSecondsSinceUnixEpoch::now()
807            }),
808            json!({
809                "type": "m.space.child",
810                "state_key": not_suggested_child,
811                "content": { "via": ["example.org"], "suggested": false },
812                "sender": "@admin:example.org",
813                "origin_server_ts": MilliSecondsSinceUnixEpoch::now()
814            }),
815        ];
816
817        server
818            .mock_get_hierarchy()
819            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
820                "rooms": [
821                    {
822                        "room_id": parent_space_id,
823                        "num_joined_members": 1,
824                        "world_readable": false,
825                        "guest_can_join": false,
826                        "children_state": children_state
827                    },
828                    {
829                        "room_id": suggested_child,
830                        "num_joined_members": 5,
831                        "world_readable": false,
832                        "guest_can_join": false,
833                        "children_state": []
834                    },
835                    {
836                        "room_id": not_suggested_child,
837                        "num_joined_members": 3,
838                        "world_readable": false,
839                        "guest_can_join": false,
840                        "children_state": []
841                    },
842                ]
843            })))
844            .mount()
845            .await;
846
847        room_list.paginate().await.unwrap();
848
849        let rooms_diff = assert_next_matches!(rooms_subscriber, diff => diff);
850        assert_eq!(rooms_diff.len(), 2);
851
852        // Collect the rooms into a map for easier assertion.
853        let mut rooms_by_id = HashMap::new();
854        for diff in rooms_diff {
855            if let VectorDiff::PushBack { value } = diff {
856                rooms_by_id.insert(value.room_id.clone(), value);
857            } else {
858                panic!("Expected PushBack, got {:?}", diff);
859            }
860        }
861
862        // The child with "suggested": true should have suggested == true.
863        let suggested_room = rooms_by_id.get(suggested_child).expect("suggested child not found");
864        assert!(
865            suggested_room.suggested,
866            "Room with 'suggested: true' should have suggested == true"
867        );
868
869        // The child with "suggested": false should have suggested == false.
870        let not_suggested_room =
871            rooms_by_id.get(not_suggested_child).expect("not-suggested child not found");
872        assert!(
873            !not_suggested_room.suggested,
874            "Room with 'suggested: false' should have suggested == false"
875        );
876    }
877
878    #[async_test]
879    async fn test_room_list_sorting() {
880        let mut children_state = HashMap::<OwnedRoomId, HierarchySpaceChildEvent>::new();
881
882        // Rooms not present in the `children_state` should be sorted by their room ID
883        assert_eq!(
884            SpaceRoomList::compare_rooms(
885                &make_space_room(owned_room_id!("!Luana:a.b"), None, None, &mut children_state),
886                &make_space_room(owned_room_id!("!Marțolea:a.b"), None, None, &mut children_state),
887                &children_state,
888            ),
889            Ordering::Less
890        );
891
892        assert_eq!(
893            SpaceRoomList::compare_rooms(
894                &make_space_room(owned_room_id!("!Marțolea:a.b"), None, None, &mut children_state),
895                &make_space_room(owned_room_id!("!Luana:a.b"), None, None, &mut children_state),
896                &children_state,
897            ),
898            Ordering::Greater
899        );
900
901        // Rooms without an order provided through the `children_state` should be
902        // sorted by their `m.space.child` `origin_server_ts`
903        assert_eq!(
904            SpaceRoomList::compare_rooms(
905                &make_space_room(owned_room_id!("!Luana:a.b"), None, Some(1), &mut children_state),
906                &make_space_room(
907                    owned_room_id!("!Marțolea:a.b"),
908                    None,
909                    Some(0),
910                    &mut children_state
911                ),
912                &children_state,
913            ),
914            Ordering::Greater
915        );
916
917        // The `m.space.child` `content.order` field should be used if provided
918        assert_eq!(
919            SpaceRoomList::compare_rooms(
920                &make_space_room(
921                    owned_room_id!("!Joiana:a.b"),
922                    Some("last"),
923                    Some(123),
924                    &mut children_state
925                ),
926                &make_space_room(
927                    owned_room_id!("!Mioara:a.b"),
928                    Some("first"),
929                    Some(234),
930                    &mut children_state
931                ),
932                &children_state,
933            ),
934            Ordering::Greater
935        );
936
937        // The timestamp should be used when the `order` is the same
938        assert_eq!(
939            SpaceRoomList::compare_rooms(
940                &make_space_room(
941                    owned_room_id!("!Joiana:a.b"),
942                    Some("Same pasture"),
943                    Some(1),
944                    &mut children_state
945                ),
946                &make_space_room(
947                    owned_room_id!("!Mioara:a.b"),
948                    Some("Same pasture"),
949                    Some(0),
950                    &mut children_state
951                ),
952                &children_state,
953            ),
954            Ordering::Greater
955        );
956
957        // And the `room_id` should be used when both the `order` and the
958        // `timestamp` are equal
959        assert_eq!(
960            SpaceRoomList::compare_rooms(
961                &make_space_room(
962                    owned_room_id!("!Joiana:a.b"),
963                    Some("same_pasture"),
964                    Some(0),
965                    &mut children_state
966                ),
967                &make_space_room(
968                    owned_room_id!("!Mioara:a.b"),
969                    Some("same_pasture"),
970                    Some(0),
971                    &mut children_state
972                ),
973                &children_state,
974            ),
975            Ordering::Less
976        );
977
978        // When one of the rooms is missing `children_state` data the other one
979        // should take precedence
980        assert_eq!(
981            SpaceRoomList::compare_rooms(
982                &make_space_room(owned_room_id!("!Viola:a.b"), None, None, &mut children_state),
983                &make_space_room(
984                    owned_room_id!("!Sâmbotina:a.b"),
985                    None,
986                    Some(0),
987                    &mut children_state
988                ),
989                &children_state,
990            ),
991            Ordering::Greater
992        );
993
994        // If the `order` is missing from one of the rooms but `children_state`
995        // is present then the other one should come first
996        assert_eq!(
997            SpaceRoomList::compare_rooms(
998                &make_space_room(
999                    owned_room_id!("!Sâmbotina:a.b"),
1000                    None,
1001                    Some(1),
1002                    &mut children_state
1003                ),
1004                &make_space_room(
1005                    owned_room_id!("!Dumana:a.b"),
1006                    Some("Some pasture"),
1007                    Some(1),
1008                    &mut children_state
1009                ),
1010                &children_state,
1011            ),
1012            Ordering::Greater
1013        );
1014    }
1015
1016    #[async_test]
1017    async fn test_reset() {
1018        let server = MatrixMockServer::new().await;
1019        let client = server.client_builder().build().await;
1020        let space_service = SpaceService::new(client.clone()).await;
1021
1022        let parent_space_id = room_id!("!parent_space:example.org");
1023        let child_space_id_1 = room_id!("!1:example.org");
1024
1025        server
1026            .mock_get_hierarchy()
1027            .ok_with_room_ids(vec![child_space_id_1])
1028            .expect(2)
1029            .mount()
1030            .await;
1031
1032        let room_list = space_service.space_room_list(parent_space_id.to_owned()).await;
1033
1034        room_list.paginate().await.unwrap();
1035
1036        // This space contains 1 room
1037        assert_eq!(room_list.rooms().await.len(), 1);
1038
1039        // Resetting the room list
1040        room_list.reset().await;
1041
1042        // Clears the rooms and pagination token
1043        assert_eq!(room_list.rooms().await.len(), 0);
1044        assert_matches!(
1045            room_list.pagination_state(),
1046            SpaceRoomListPaginationState::Idle { end_reached: false }
1047        );
1048
1049        // Allows paginating again
1050        room_list.paginate().await.unwrap();
1051        assert_eq!(room_list.rooms().await.len(), 1);
1052    }
1053
1054    fn make_space_room(
1055        room_id: OwnedRoomId,
1056        order: Option<&str>,
1057        origin_server_ts: Option<u32>,
1058        children_state: &mut HashMap<OwnedRoomId, HierarchySpaceChildEvent>,
1059    ) -> SpaceRoom {
1060        if let Some(origin_server_ts) = origin_server_ts {
1061            children_state.insert(
1062                room_id.clone(),
1063                hierarchy_space_child_event(&room_id, order, origin_server_ts),
1064            );
1065        }
1066        SpaceRoom {
1067            room_id,
1068            canonical_alias: None,
1069            name: Some("New room name".to_owned()),
1070            display_name: "Empty room".to_owned(),
1071            topic: None,
1072            avatar_url: None,
1073            room_type: None,
1074            num_joined_members: 0,
1075            join_rule: None,
1076            world_readable: None,
1077            guest_can_join: false,
1078            is_direct: None,
1079            children_count: 0,
1080            state: None,
1081            heroes: None,
1082            via: vec![],
1083            suggested: false,
1084            is_dm: None,
1085        }
1086    }
1087
1088    fn hierarchy_space_child_event(
1089        room_id: &RoomId,
1090        order: Option<&str>,
1091        origin_server_ts: u32,
1092    ) -> HierarchySpaceChildEvent {
1093        let mut json = json!({
1094            "content": {
1095                "via": []
1096            },
1097            "origin_server_ts": origin_server_ts,
1098            "sender": "@bob:a.b",
1099            "state_key": room_id.to_string(),
1100            "type": "m.space.child"
1101        });
1102
1103        if let Some(order) = order {
1104            json["content"]["order"] = json!(order);
1105        }
1106
1107        from_value::<HierarchySpaceChildEvent>(json).unwrap()
1108    }
1109}