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