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,
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}
69
70impl SpaceRoom {
71    /// Build a `SpaceRoom` from a `RoomSummary` received from the /hierarchy
72    /// endpoint.
73    pub(crate) fn new_from_summary(
74        summary: &RoomSummary,
75        known_room: Option<Room>,
76        children_count: u64,
77        via: Vec<OwnedServerName>,
78    ) -> Self {
79        let display_name = matrix_sdk_base::Room::compute_display_name_with_fields(
80            summary.name.clone(),
81            summary.canonical_alias.as_deref(),
82            known_room.as_ref().map(|r| r.heroes().to_vec()).unwrap_or_default(),
83            summary.num_joined_members.into(),
84        )
85        .to_string();
86
87        Self {
88            room_id: summary.room_id.clone(),
89            canonical_alias: summary.canonical_alias.clone(),
90            name: summary.name.clone(),
91            display_name,
92            topic: summary.topic.clone(),
93            avatar_url: summary.avatar_url.clone(),
94            room_type: summary.room_type.clone(),
95            num_joined_members: summary.num_joined_members.into(),
96            join_rule: Some(summary.join_rule.clone()),
97            world_readable: Some(summary.world_readable),
98            guest_can_join: summary.guest_can_join,
99            is_direct: known_room.as_ref().map(|r| r.direct_targets_length() != 0),
100            children_count,
101            state: known_room.as_ref().map(|r| r.state()),
102            heroes: known_room.map(|r| r.heroes()),
103            via,
104        }
105    }
106
107    /// Build a `SpaceRoom` from a room already known to this client.
108    pub(crate) fn new_from_known(known_room: &Room, children_count: u64) -> Self {
109        let room_info = known_room.clone_info();
110
111        let name = room_info.name().map(ToOwned::to_owned);
112        let display_name = matrix_sdk_base::Room::compute_display_name_with_fields(
113            name.clone(),
114            room_info.canonical_alias(),
115            room_info.heroes().to_vec(),
116            known_room.joined_members_count(),
117        )
118        .to_string();
119
120        Self {
121            room_id: room_info.room_id().to_owned(),
122            canonical_alias: room_info.canonical_alias().map(ToOwned::to_owned),
123            name,
124            display_name,
125            topic: room_info.topic().map(ToOwned::to_owned),
126            avatar_url: room_info.avatar_url().map(ToOwned::to_owned),
127            room_type: room_info.room_type().cloned(),
128            num_joined_members: known_room.joined_members_count(),
129            join_rule: room_info.join_rule().cloned().map(Into::into),
130            world_readable: room_info
131                .history_visibility()
132                .map(|vis| *vis == HistoryVisibility::WorldReadable),
133            guest_can_join: known_room.guest_access() == GuestAccess::CanJoin,
134            is_direct: Some(known_room.direct_targets_length() != 0),
135            children_count,
136            state: Some(known_room.state()),
137            heroes: Some(room_info.heroes().to_vec()),
138            via: vec![],
139        }
140    }
141
142    /// Sorts space rooms by various criteria as defined in
143    /// https://spec.matrix.org/latest/client-server-api/#ordering-of-children-within-a-space
144    pub(crate) fn compare_rooms(
145        a: &SpaceRoom,
146        b: &SpaceRoom,
147        a_state: Option<SpaceRoomChildState>,
148        b_state: Option<SpaceRoomChildState>,
149    ) -> Ordering {
150        match (a_state, b_state) {
151            (Some(a_state), Some(b_state)) => match (&a_state.order, &b_state.order) {
152                (Some(a_order), Some(b_order)) => a_order
153                    .cmp(b_order)
154                    .then(a_state.origin_server_ts.cmp(&b_state.origin_server_ts))
155                    .then(a.room_id.cmp(&b.room_id)),
156                (Some(_), None) => Ordering::Less,
157                (None, Some(_)) => Ordering::Greater,
158                (None, None) => a_state
159                    .origin_server_ts
160                    .cmp(&b_state.origin_server_ts)
161                    .then(a.room_id.to_string().cmp(&b.room_id.to_string())),
162            },
163            _ => a.room_id.to_string().cmp(&b.room_id.to_string()),
164        }
165    }
166}
167
168#[derive(Clone, Debug)]
169pub(crate) struct SpaceRoomChildState {
170    pub(crate) order: Option<OwnedSpaceChildOrder>,
171    pub(crate) origin_server_ts: MilliSecondsSinceUnixEpoch,
172}
173
174impl From<&HierarchySpaceChildEvent> for SpaceRoomChildState {
175    fn from(event: &HierarchySpaceChildEvent) -> Self {
176        SpaceRoomChildState {
177            order: event.content.order.clone(),
178            origin_server_ts: event.origin_server_ts,
179        }
180    }
181}
182
183#[cfg(test)]
184mod tests {
185    use std::cmp::Ordering;
186
187    use matrix_sdk_test::async_test;
188    use ruma::{MilliSecondsSinceUnixEpoch, OwnedRoomId, SpaceChildOrder, owned_room_id, uint};
189
190    use crate::spaces::{SpaceRoom, room::SpaceRoomChildState};
191
192    #[async_test]
193    async fn test_room_list_sorting() {
194        // Rooms without a `m.space.child` state event should be sorted by their
195        // `room_id`
196        assert_eq!(
197            SpaceRoom::compare_rooms(
198                &make_space_room(owned_room_id!("!A:a.b")),
199                &make_space_room(owned_room_id!("!B:a.b")),
200                None,
201                None
202            ),
203            Ordering::Less
204        );
205
206        assert_eq!(
207            SpaceRoom::compare_rooms(
208                &make_space_room(owned_room_id!("!Marțolea:a.b")),
209                &make_space_room(owned_room_id!("!Luana:a.b")),
210                None,
211                None,
212            ),
213            Ordering::Greater
214        );
215
216        // Rooms without an order provided through the `children_state` should be
217        // sorted by their `m.space.child` `origin_server_ts`
218        assert_eq!(
219            SpaceRoom::compare_rooms(
220                &make_space_room(owned_room_id!("!Luana:a.b")),
221                &make_space_room(owned_room_id!("!Marțolea:a.b")),
222                Some(SpaceRoomChildState {
223                    origin_server_ts: MilliSecondsSinceUnixEpoch(uint!(1)),
224                    order: None
225                }),
226                Some(SpaceRoomChildState {
227                    origin_server_ts: MilliSecondsSinceUnixEpoch(uint!(0)),
228                    order: None
229                })
230            ),
231            Ordering::Greater
232        );
233
234        // The `m.space.child` `content.order` field should be used if provided
235        assert_eq!(
236            SpaceRoom::compare_rooms(
237                &make_space_room(owned_room_id!("!Joiana:a.b"),),
238                &make_space_room(owned_room_id!("!Mioara:a.b"),),
239                Some(SpaceRoomChildState {
240                    origin_server_ts: MilliSecondsSinceUnixEpoch(uint!(123)),
241                    order: Some(SpaceChildOrder::parse("second").unwrap())
242                }),
243                Some(SpaceRoomChildState {
244                    origin_server_ts: MilliSecondsSinceUnixEpoch(uint!(234)),
245                    order: Some(SpaceChildOrder::parse("first").unwrap())
246                }),
247            ),
248            Ordering::Greater
249        );
250
251        // The timestamp should be used when the `order` is the same
252        assert_eq!(
253            SpaceRoom::compare_rooms(
254                &make_space_room(owned_room_id!("!Joiana:a.b")),
255                &make_space_room(owned_room_id!("!Mioara:a.b")),
256                Some(SpaceRoomChildState {
257                    origin_server_ts: MilliSecondsSinceUnixEpoch(uint!(1)),
258                    order: Some(SpaceChildOrder::parse("Same pasture").unwrap())
259                }),
260                Some(SpaceRoomChildState {
261                    origin_server_ts: MilliSecondsSinceUnixEpoch(uint!(0)),
262                    order: Some(SpaceChildOrder::parse("Same pasture").unwrap())
263                }),
264            ),
265            Ordering::Greater
266        );
267
268        // And the `room_id` should be used when both the `order` and the
269        // `timestamp` are equal
270        assert_eq!(
271            SpaceRoom::compare_rooms(
272                &make_space_room(owned_room_id!("!Joiana:a.b")),
273                &make_space_room(owned_room_id!("!Mioara:a.b")),
274                Some(SpaceRoomChildState {
275                    origin_server_ts: MilliSecondsSinceUnixEpoch(uint!(0)),
276                    order: Some(SpaceChildOrder::parse("Same pasture").unwrap())
277                }),
278                Some(SpaceRoomChildState {
279                    origin_server_ts: MilliSecondsSinceUnixEpoch(uint!(0)),
280                    order: Some(SpaceChildOrder::parse("Same pasture").unwrap())
281                }),
282            ),
283            Ordering::Less
284        );
285
286        // When one of the rooms is missing `children_state` data the other one
287        // should take precedence
288        assert_eq!(
289            SpaceRoom::compare_rooms(
290                &make_space_room(owned_room_id!("!Viola:a.b")),
291                &make_space_room(owned_room_id!("!Sâmbotina:a.b")),
292                None,
293                Some(SpaceRoomChildState {
294                    origin_server_ts: MilliSecondsSinceUnixEpoch(uint!(0)),
295                    order: None
296                }),
297            ),
298            Ordering::Greater
299        );
300
301        // If the `order` is missing from one of the rooms but `children_state`
302        // is present then the other one should come first
303        assert_eq!(
304            SpaceRoom::compare_rooms(
305                &make_space_room(owned_room_id!("!Sâmbotina:a.b")),
306                &make_space_room(owned_room_id!("!Dumana:a.b")),
307                Some(SpaceRoomChildState {
308                    origin_server_ts: MilliSecondsSinceUnixEpoch(uint!(1)),
309                    order: None
310                }),
311                Some(SpaceRoomChildState {
312                    origin_server_ts: MilliSecondsSinceUnixEpoch(uint!(1)),
313                    order: Some(SpaceChildOrder::parse("Some pasture").unwrap())
314                }),
315            ),
316            Ordering::Greater
317        );
318    }
319
320    fn make_space_room(room_id: OwnedRoomId) -> SpaceRoom {
321        SpaceRoom {
322            room_id,
323            canonical_alias: None,
324            name: Some("New room name".to_owned()),
325            display_name: "Empty room".to_owned(),
326            topic: None,
327            avatar_url: None,
328            room_type: None,
329            num_joined_members: 0,
330            join_rule: None,
331            world_readable: None,
332            guest_can_join: false,
333            is_direct: None,
334            children_count: 0,
335            state: None,
336            heroes: None,
337            via: vec![],
338        }
339    }
340}