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