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