Skip to main content

matrix_sdk_ui/spaces/
room.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;
16
17use matrix_sdk::{Room, RoomHero, RoomHeroWithProfile, RoomState};
18use ruma::{
19    MilliSecondsSinceUnixEpoch, OwnedMxcUri, OwnedRoomAliasId, OwnedRoomId, OwnedServerName,
20    OwnedSpaceChildOrder, RoomId,
21    events::{
22        room::{guest_access::GuestAccess, history_visibility::HistoryVisibility},
23        space::child::HierarchySpaceChildEvent,
24    },
25    room::{JoinRuleSummary, RoomSummary, RoomType},
26};
27
28/// Structure representing a room in a space and aggregated information relevant
29/// to the UI layer.
30#[derive(Debug, Clone, PartialEq)]
31pub struct SpaceRoom {
32    /// The ID of the room.
33    pub room_id: OwnedRoomId,
34    /// The canonical alias of the room, if any.
35    pub canonical_alias: Option<OwnedRoomAliasId>,
36    /// The name of the room, if any.
37    pub name: Option<String>,
38    /// Calculated display name based on the room's name, aliases, and members.
39    pub display_name: String,
40    /// The topic of the room, if any.
41    pub topic: Option<String>,
42    /// The URL for the room's avatar, if one is set.
43    pub avatar_url: Option<OwnedMxcUri>,
44    /// The type of room from `m.room.create`, if any.
45    pub room_type: Option<RoomType>,
46    /// The number of members joined to the room.
47    pub num_joined_members: u64,
48    /// The join rule of the room.
49    pub join_rule: Option<JoinRuleSummary>,
50    /// Whether the room may be viewed by users without joining.
51    pub world_readable: Option<bool>,
52    /// Whether guest users may join the room and participate in it.
53    pub guest_can_join: bool,
54
55    /// Whether this room is a direct room.
56    ///
57    /// Only set if the room is known to the client otherwise we assume DMs
58    /// shouldn't be exposed publicly in spaces.
59    pub is_direct: Option<bool>,
60    /// The number of children room this has, if a space.
61    pub children_count: u64,
62    /// Whether this room is joined, left etc.
63    pub state: Option<RoomState>,
64    /// A list of room members considered to be heroes.
65    pub heroes: Option<Vec<RoomHeroWithProfile>>,
66    /// The via parameters of the room.
67    pub via: Vec<OwnedServerName>,
68    /// Whether the room is suggested by the space administrators.
69    ///
70    /// Defaults to `false` if not specified in the `m.space.child` event.
71    pub suggested: bool,
72    /// Whether this room is a DM, if known. Note this value can be calculated
73    /// following some assumptions and is not guaranteed to be accurate.
74    pub is_dm: Option<bool>,
75}
76
77impl SpaceRoom {
78    /// Build a `SpaceRoom` from a `RoomSummary` received from the /hierarchy
79    /// endpoint.
80    pub(crate) async fn new_from_summary(
81        summary: &RoomSummary,
82        known_room: Option<Room>,
83        children_count: u64,
84        via: Vec<OwnedServerName>,
85        suggested: bool,
86    ) -> Self {
87        let num_joined_service_members = if let Some(known_room) = &known_room {
88            num_joined_service_members_or_default(known_room).await
89        } else {
90            0
91        };
92
93        let heroes =
94            if let Some(known_room) = &known_room { Some(known_room.heroes().await) } else { None };
95
96        let num_joined_members: u64 = summary.num_joined_members.into();
97        let display_name = matrix_sdk_base::Room::compute_display_name_with_fields(
98            summary.name.clone(),
99            summary.canonical_alias.as_deref(),
100            heroes.iter().flatten().map(RoomHero::from).collect(),
101            num_joined_members.saturating_sub(num_joined_service_members),
102        )
103        .to_string();
104
105        Self {
106            room_id: summary.room_id.clone(),
107            canonical_alias: summary.canonical_alias.clone(),
108            name: summary.name.clone(),
109            display_name,
110            topic: summary.topic.clone(),
111            avatar_url: summary.avatar_url.clone(),
112            room_type: summary.room_type.clone(),
113            num_joined_members: summary.num_joined_members.into(),
114            join_rule: Some(summary.join_rule.clone()),
115            world_readable: Some(summary.world_readable),
116            guest_can_join: summary.guest_can_join,
117            is_direct: known_room.as_ref().map(|r| r.direct_targets_length() != 0),
118            children_count,
119            state: known_room.as_ref().map(|r| r.state()),
120            heroes,
121            via,
122            suggested,
123            is_dm: known_room.as_ref().map(|r| r.is_dm()),
124        }
125    }
126
127    /// Build a `SpaceRoom` from a room already known to this client.
128    pub(crate) async fn new_from_known(known_room: &Room, children_count: u64) -> Self {
129        let room_info = known_room.clone_info();
130
131        let name = room_info.name().map(ToOwned::to_owned);
132        let joined_service_members_count = num_joined_service_members_or_default(known_room).await;
133
134        let heroes = known_room.heroes().await;
135
136        let display_name = matrix_sdk_base::Room::compute_display_name_with_fields(
137            name.clone(),
138            room_info.canonical_alias(),
139            heroes.iter().map(RoomHero::from).collect(),
140            known_room.joined_members_count().saturating_sub(joined_service_members_count),
141        )
142        .to_string();
143
144        Self {
145            room_id: room_info.room_id().to_owned(),
146            canonical_alias: room_info.canonical_alias().map(ToOwned::to_owned),
147            name,
148            display_name,
149            topic: room_info.topic().map(ToOwned::to_owned),
150            avatar_url: room_info.avatar_url().map(ToOwned::to_owned),
151            room_type: room_info.room_type().cloned(),
152            num_joined_members: known_room.joined_members_count(),
153            join_rule: room_info.join_rule().cloned().map(Into::into),
154            world_readable: room_info
155                .history_visibility()
156                .map(|vis| *vis == HistoryVisibility::WorldReadable),
157            guest_can_join: known_room.guest_access() == GuestAccess::CanJoin,
158            is_direct: Some(known_room.direct_targets_length() != 0),
159            children_count,
160            state: Some(known_room.state()),
161            heroes: Some(heroes),
162            via: vec![],
163            suggested: false,
164            is_dm: known_room.compute_is_dm().await.ok(),
165        }
166    }
167
168    /// Sorts space rooms by various criteria as defined in
169    /// https://spec.matrix.org/latest/client-server-api/#ordering-of-children-within-a-space
170    pub(crate) fn compare_rooms(
171        a: (&RoomId, Option<&SpaceRoomChildState>),
172        b: (&RoomId, Option<&SpaceRoomChildState>),
173    ) -> Ordering {
174        let (a_room_id, a_state) = a;
175        let (b_room_id, b_state) = b;
176
177        match (a_state, b_state) {
178            (Some(a_state), Some(b_state)) => match (&a_state.order, &b_state.order) {
179                (Some(a_order), Some(b_order)) => a_order
180                    .cmp(b_order)
181                    .then(a_state.origin_server_ts.cmp(&b_state.origin_server_ts))
182                    .then(a_room_id.cmp(b_room_id)),
183                (Some(_), None) => Ordering::Less,
184                (None, Some(_)) => Ordering::Greater,
185                (None, None) => a_state
186                    .origin_server_ts
187                    .cmp(&b_state.origin_server_ts)
188                    .then(a_room_id.cmp(b_room_id)),
189            },
190            (None, Some(_)) => Ordering::Greater,
191            (Some(_), None) => Ordering::Less,
192            (None, None) => a_room_id.cmp(b_room_id),
193        }
194    }
195}
196
197#[derive(Clone, Debug)]
198pub(crate) struct SpaceRoomChildState {
199    pub(crate) order: Option<OwnedSpaceChildOrder>,
200    pub(crate) origin_server_ts: MilliSecondsSinceUnixEpoch,
201}
202
203impl From<&HierarchySpaceChildEvent> for SpaceRoomChildState {
204    fn from(event: &HierarchySpaceChildEvent) -> Self {
205        SpaceRoomChildState {
206            order: event.content.order.clone(),
207            origin_server_ts: event.origin_server_ts,
208        }
209    }
210}
211
212async fn num_joined_service_members_or_default(room: &Room) -> u64 {
213    match room.compute_joined_service_members().await {
214        Ok(Some(service_members)) => service_members.len() as u64,
215        // If we can't compute the joined service members count, assume all of
216        // them joined the room
217        _ => room.service_members().map(|members| members.len() as u64).unwrap_or_default(),
218    }
219}
220
221#[cfg(test)]
222mod tests {
223    use std::cmp::Ordering;
224
225    use matrix_sdk_test::async_test;
226    use proptest::prelude::*;
227    use ruma::{
228        MilliSecondsSinceUnixEpoch, OwnedRoomId, RoomId, SpaceChildOrder, UInt, room_id, uint,
229    };
230
231    use crate::spaces::{SpaceRoom, room::SpaceRoomChildState};
232
233    #[async_test]
234    async fn test_room_list_sorting() {
235        // Rooms without a `m.space.child` state event should be sorted by their
236        // `room_id`
237        assert_eq!(
238            SpaceRoom::compare_rooms((room_id!("!A:a.b"), None), (room_id!("!B:a.b"), None),),
239            Ordering::Less
240        );
241
242        assert_eq!(
243            SpaceRoom::compare_rooms(
244                (room_id!("!Marțolea:a.b"), None),
245                (room_id!("!Luana:a.b"), None),
246            ),
247            Ordering::Greater
248        );
249
250        // Rooms without an order provided through the `children_state` should
251        // be sorted by their `m.space.child` `origin_server_ts`
252        assert_eq!(
253            SpaceRoom::compare_rooms(
254                (
255                    room_id!("!Luana:a.b"),
256                    Some(&SpaceRoomChildState {
257                        origin_server_ts: MilliSecondsSinceUnixEpoch(uint!(1)),
258                        order: None
259                    })
260                ),
261                (
262                    room_id!("!Marțolea:a.b"),
263                    Some(&SpaceRoomChildState {
264                        origin_server_ts: MilliSecondsSinceUnixEpoch(uint!(0)),
265                        order: None
266                    })
267                )
268            ),
269            Ordering::Greater
270        );
271
272        // The `m.space.child` `content.order` field should be used if provided
273        assert_eq!(
274            SpaceRoom::compare_rooms(
275                (
276                    room_id!("!Joiana:a.b"),
277                    Some(&SpaceRoomChildState {
278                        origin_server_ts: MilliSecondsSinceUnixEpoch(uint!(123)),
279                        order: Some(SpaceChildOrder::parse("second").unwrap())
280                    })
281                ),
282                (
283                    room_id!("!Mioara:a.b"),
284                    Some(&SpaceRoomChildState {
285                        origin_server_ts: MilliSecondsSinceUnixEpoch(uint!(234)),
286                        order: Some(SpaceChildOrder::parse("first").unwrap())
287                    })
288                ),
289            ),
290            Ordering::Greater
291        );
292
293        // The timestamp should be used when the `order` is the same
294        assert_eq!(
295            SpaceRoom::compare_rooms(
296                (
297                    room_id!("!Joiana:a.b"),
298                    Some(&SpaceRoomChildState {
299                        origin_server_ts: MilliSecondsSinceUnixEpoch(uint!(1)),
300                        order: Some(SpaceChildOrder::parse("Same pasture").unwrap())
301                    })
302                ),
303                (
304                    room_id!("!Mioara:a.b"),
305                    Some(&SpaceRoomChildState {
306                        origin_server_ts: MilliSecondsSinceUnixEpoch(uint!(0)),
307                        order: Some(SpaceChildOrder::parse("Same pasture").unwrap())
308                    })
309                ),
310            ),
311            Ordering::Greater
312        );
313
314        // And the `room_id` should be used when both the `order` and the
315        // `timestamp` are equal
316        assert_eq!(
317            SpaceRoom::compare_rooms(
318                (
319                    room_id!("!Joiana:a.b"),
320                    Some(&SpaceRoomChildState {
321                        origin_server_ts: MilliSecondsSinceUnixEpoch(uint!(0)),
322                        order: Some(SpaceChildOrder::parse("Same pasture").unwrap())
323                    })
324                ),
325                (
326                    room_id!("!Mioara:a.b"),
327                    Some(&SpaceRoomChildState {
328                        origin_server_ts: MilliSecondsSinceUnixEpoch(uint!(0)),
329                        order: Some(SpaceChildOrder::parse("Same pasture").unwrap())
330                    })
331                ),
332            ),
333            Ordering::Less
334        );
335
336        // When one of the rooms is missing `children_state` data the other one
337        // should take precedence
338        assert_eq!(
339            SpaceRoom::compare_rooms(
340                (room_id!("!Viola:a.b"), None),
341                (
342                    room_id!("!Sâmbotina:a.b"),
343                    Some(&SpaceRoomChildState {
344                        origin_server_ts: MilliSecondsSinceUnixEpoch(uint!(0)),
345                        order: None
346                    })
347                ),
348            ),
349            Ordering::Greater
350        );
351
352        // If the `order` is missing from one of the rooms but `children_state`
353        // is present then the other one should come first
354        assert_eq!(
355            SpaceRoom::compare_rooms(
356                (
357                    room_id!("!Sâmbotina:a.b"),
358                    Some(&SpaceRoomChildState {
359                        origin_server_ts: MilliSecondsSinceUnixEpoch(uint!(1)),
360                        order: None
361                    })
362                ),
363                (
364                    room_id!("!Dumana:a.b"),
365                    Some(&SpaceRoomChildState {
366                        origin_server_ts: MilliSecondsSinceUnixEpoch(uint!(1)),
367                        order: Some(SpaceChildOrder::parse("Some pasture").unwrap())
368                    })
369                ),
370            ),
371            Ordering::Greater
372        );
373    }
374
375    /// This test was written because the [`SpaceRoom::compare_rooms`] method
376    /// wasn't adhering to a total order.
377    ///
378    /// More precisely it wasn't transitive. This was because as soon as the
379    /// [SpaceRoomChildState] for one room was set to `None` we would fall back
380    /// to comparing only room IDs.
381    ///
382    /// The correct way to preserve transitivity was to only fall back to room
383    /// IDs if both rooms don't have a state.
384    #[test]
385    fn test_compare_rooms_minimal_transitive_failure() {
386        let (a_room_id, a_state) = (room_id!("!Q"), None);
387
388        let (b_room_id, b_state) = (
389            room_id!("!A"),
390            Some(SpaceRoomChildState {
391                order: None,
392                origin_server_ts: MilliSecondsSinceUnixEpoch(uint!(10)),
393            }),
394        );
395
396        let (c_room_id, c_state) = (
397            room_id!("!a"),
398            Some(SpaceRoomChildState {
399                order: None,
400                origin_server_ts: MilliSecondsSinceUnixEpoch(uint!(0)),
401            }),
402        );
403
404        let a = (a_room_id, a_state.as_ref());
405        let b = (b_room_id, b_state.as_ref());
406        let c = (c_room_id, c_state.as_ref());
407
408        let ab = SpaceRoom::compare_rooms(a, b);
409        let bc = SpaceRoom::compare_rooms(b, c);
410        let ac = SpaceRoom::compare_rooms(a, c);
411
412        assert_eq!(ab, Ordering::Greater, "a > b should hold");
413        assert_eq!(bc, Ordering::Greater, "b > c should hold");
414        assert_eq!(ac, Ordering::Greater, "therefore a > c should be true as well");
415    }
416
417    fn any_room_id_and_space_room_order()
418    -> impl Strategy<Value = (OwnedRoomId, Option<SpaceRoomChildState>)> {
419        let room_id = "[a-zA-Z]{1,5}".prop_map(|r| {
420            RoomId::new_v2(&r).expect("Any string starting with ! should be a valid room ID")
421        });
422
423        let timestamp = any::<u8>().prop_map(|t| MilliSecondsSinceUnixEpoch(UInt::from(t)));
424
425        let order = prop::option::of("[a-zA-Z]{1,5}").prop_map(|order| {
426            order.map(|o| SpaceChildOrder::parse(o).expect("Any string should be a valid order"))
427        });
428
429        let state = (order, timestamp)
430            .prop_map(|(o, t)| SpaceRoomChildState { order: o, origin_server_ts: t });
431
432        let state = prop::option::of(state);
433
434        (room_id, state)
435    }
436
437    proptest! {
438        #[test]
439        fn test_sort_space_room_children_never_panics(mut v in prop::collection::vec(any_room_id_and_space_room_order(), 0..100)) {
440            v.sort_by(|a, b| {
441                let (a_room_id, a_state) = a;
442                let (b_room_id, b_state) = b;
443
444                let a = (a_room_id.as_ref(), a_state.as_ref());
445                let b = (b_room_id.as_ref(), b_state.as_ref());
446
447                SpaceRoom::compare_rooms(a, b)
448            })
449        }
450
451        #[test]
452        fn test_compare_rooms_reflexive(a in any_room_id_and_space_room_order()) {
453            let (a_room_id, a_state) = a;
454            let a = (a_room_id.as_ref(), a_state.as_ref());
455
456            prop_assert_eq!(SpaceRoom::compare_rooms(a, a), Ordering::Equal);
457        }
458
459        #[test]
460        fn test_compare_rooms_antisymmetric(a in any_room_id_and_space_room_order(), b in any_room_id_and_space_room_order()) {
461            let (a_room_id, a_state) = a;
462            let (b_room_id, b_state) = b;
463
464            let a = (a_room_id.as_ref(), a_state.as_ref());
465            let b = (b_room_id.as_ref(), b_state.as_ref());
466
467            let ab = SpaceRoom::compare_rooms(a, b);
468            let ba = SpaceRoom::compare_rooms(b, a);
469
470            prop_assert_eq!(ab, ba.reverse());
471        }
472
473        #[test]
474        fn test_compare_rooms_transitive(
475            a in any_room_id_and_space_room_order(),
476            b in any_room_id_and_space_room_order(),
477            c in any_room_id_and_space_room_order()
478        ) {
479            let (a_room_id, a_state) = a;
480            let (b_room_id, b_state) = b;
481            let (c_room_id, c_state) = c;
482
483            let a = (a_room_id.as_ref(), a_state.as_ref());
484            let b = (b_room_id.as_ref(), b_state.as_ref());
485            let c = (c_room_id.as_ref(), c_state.as_ref());
486
487            let ab = SpaceRoom::compare_rooms(a, b);
488            let bc = SpaceRoom::compare_rooms(b, c);
489            let ac = SpaceRoom::compare_rooms(a, c);
490
491            if ab == Ordering::Less && bc == Ordering::Less {
492                prop_assert_eq!(ac, Ordering::Less);
493            }
494
495            if ab == Ordering::Equal && bc == Ordering::Equal {
496                prop_assert_eq!(ac, Ordering::Equal);
497            }
498
499            if ab == Ordering::Greater && bc == Ordering::Greater {
500                prop_assert_eq!(ac, Ordering::Greater);
501            }
502        }
503    }
504}