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