matrix_sdk/
room_preview.rs

1// Copyright 2024 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 the specific language governing permissions and
13// limitations under the License.
14
15//! Preview of a room, whether we've joined it/left it/been invited to it, or
16//! not.
17//!
18//! This offers a few capabilities for previewing the content of the room as
19//! well.
20
21use futures_util::future::join_all;
22use matrix_sdk_base::{RoomHero, RoomInfo, RoomState};
23use ruma::{
24    api::client::{membership::joined_members, state::get_state_events},
25    directory::PublicRoomJoinRule,
26    events::room::{history_visibility::HistoryVisibility, join_rules::JoinRule},
27    room::RoomType,
28    space::SpaceRoomJoinRule,
29    OwnedMxcUri, OwnedRoomAliasId, OwnedRoomId, OwnedServerName, RoomId, RoomOrAliasId, ServerName,
30};
31use tokio::try_join;
32use tracing::{instrument, warn};
33
34use crate::{room_directory_search::RoomDirectorySearch, Client, Room};
35
36/// The preview of a room, be it invited/joined/left, or not.
37#[derive(Debug, Clone)]
38pub struct RoomPreview {
39    /// The actual room id for this room.
40    ///
41    /// Remember the room preview can be fetched from a room alias id, so we
42    /// might not know ahead of time what the room id is.
43    pub room_id: OwnedRoomId,
44
45    /// The canonical alias for the room.
46    pub canonical_alias: Option<OwnedRoomAliasId>,
47
48    /// The room's name, if set.
49    pub name: Option<String>,
50
51    /// The room's topic, if set.
52    pub topic: Option<String>,
53
54    /// The MXC URI to the room's avatar, if set.
55    pub avatar_url: Option<OwnedMxcUri>,
56
57    /// The number of joined members.
58    pub num_joined_members: u64,
59
60    /// The number of active members, if known (joined + invited).
61    pub num_active_members: Option<u64>,
62
63    /// The room type (space, custom) or nothing, if it's a regular room.
64    pub room_type: Option<RoomType>,
65
66    /// What's the join rule for this room?
67    pub join_rule: SpaceRoomJoinRule,
68
69    /// Is the room world-readable (i.e. is its history_visibility set to
70    /// world_readable)?
71    pub is_world_readable: Option<bool>,
72
73    /// Has the current user been invited/joined/left this room?
74    ///
75    /// Set to `None` if the room is unknown to the user.
76    pub state: Option<RoomState>,
77
78    /// The `m.room.direct` state of the room, if known.
79    pub is_direct: Option<bool>,
80
81    /// Room heroes.
82    pub heroes: Option<Vec<RoomHero>>,
83}
84
85impl RoomPreview {
86    /// Constructs a [`RoomPreview`] from the associated room info.
87    ///
88    /// Note: not using the room info's state/count of joined members, because
89    /// we can do better than that.
90    fn from_room_info(
91        room_info: RoomInfo,
92        is_direct: Option<bool>,
93        num_joined_members: u64,
94        num_active_members: Option<u64>,
95        state: Option<RoomState>,
96        computed_display_name: Option<String>,
97    ) -> Self {
98        RoomPreview {
99            room_id: room_info.room_id().to_owned(),
100            canonical_alias: room_info.canonical_alias().map(ToOwned::to_owned),
101            name: computed_display_name.or_else(|| room_info.name().map(ToOwned::to_owned)),
102            topic: room_info.topic().map(ToOwned::to_owned),
103            avatar_url: room_info.avatar_url().map(ToOwned::to_owned),
104            room_type: room_info.room_type().cloned(),
105            join_rule: match room_info.join_rule() {
106                JoinRule::Invite => SpaceRoomJoinRule::Invite,
107                JoinRule::Knock => SpaceRoomJoinRule::Knock,
108                JoinRule::Private => SpaceRoomJoinRule::Private,
109                JoinRule::Restricted(_) => SpaceRoomJoinRule::Restricted,
110                JoinRule::KnockRestricted(_) => SpaceRoomJoinRule::KnockRestricted,
111                JoinRule::Public => SpaceRoomJoinRule::Public,
112                _ => {
113                    // The JoinRule enum is non-exhaustive. Let's do a white lie and pretend it's
114                    // private (a cautious choice).
115                    SpaceRoomJoinRule::Private
116                }
117            },
118            is_world_readable: room_info
119                .history_visibility()
120                .map(|vis| *vis == HistoryVisibility::WorldReadable),
121            num_joined_members,
122            num_active_members,
123            state,
124            is_direct,
125            heroes: Some(room_info.heroes().to_vec()),
126        }
127    }
128
129    /// Create a room preview from a known room.
130    ///
131    /// Note this shouldn't be used with invited or knocked rooms, since the
132    /// local info may be out of date and no longer represent the latest room
133    /// state.
134    pub(crate) async fn from_known_room(room: &Room) -> Self {
135        let is_direct = room.is_direct().await.ok();
136
137        let display_name = room.display_name().await.ok().map(|name| name.to_string());
138
139        Self::from_room_info(
140            room.clone_info(),
141            is_direct,
142            room.joined_members_count(),
143            Some(room.active_members_count()),
144            Some(room.state()),
145            display_name,
146        )
147    }
148
149    #[instrument(skip(client))]
150    pub(crate) async fn from_remote_room(
151        client: &Client,
152        room_id: OwnedRoomId,
153        room_or_alias_id: &RoomOrAliasId,
154        via: Vec<OwnedServerName>,
155    ) -> crate::Result<Self> {
156        // Use the room summary endpoint, if available, as described in
157        // https://github.com/deepbluev7/matrix-doc/blob/room-summaries/proposals/3266-room-summary.md
158        match Self::from_room_summary(client, room_id.clone(), room_or_alias_id, via.clone()).await
159        {
160            Ok(res) => return Ok(res),
161            Err(err) => {
162                warn!("error when previewing room from the room summary endpoint: {err}");
163            }
164        }
165
166        // Try room directory search next.
167        match Self::from_room_directory_search(client, &room_id, room_or_alias_id, via).await {
168            Ok(Some(res)) => return Ok(res),
169            Ok(None) => warn!("Room '{room_or_alias_id}' not found in room directory search."),
170            Err(err) => {
171                warn!("Searching for '{room_or_alias_id}' in room directory search failed: {err}");
172            }
173        }
174
175        // Resort to using the room state endpoint, as well as the joined members one.
176        Self::from_state_events(client, &room_id).await
177    }
178
179    /// Get a [`RoomPreview`] by searching in the room directory for the
180    /// provided room alias or room id and transforming the [`RoomDescription`]
181    /// into a preview.
182    pub(crate) async fn from_room_directory_search(
183        client: &Client,
184        room_id: &RoomId,
185        room_or_alias_id: &RoomOrAliasId,
186        via: Vec<OwnedServerName>,
187    ) -> crate::Result<Option<Self>> {
188        // Get either the room alias or the room id without the leading identifier char
189        let search_term = if room_or_alias_id.is_room_alias_id() {
190            Some(room_or_alias_id.as_str()[1..].to_owned())
191        } else {
192            None
193        };
194
195        // If we have no alias, filtering using a room id is impossible, so just take
196        // the first 100 results and try to find the current room #YOLO
197        let batch_size = if search_term.is_some() { 20 } else { 100 };
198
199        if via.is_empty() {
200            // Just search in the current homeserver
201            search_for_room_preview_in_room_directory(
202                client.clone(),
203                search_term,
204                batch_size,
205                None,
206                room_id,
207            )
208            .await
209        } else {
210            let mut futures = Vec::new();
211            // Search for all servers and retrieve the results
212            for server in via {
213                futures.push(search_for_room_preview_in_room_directory(
214                    client.clone(),
215                    search_term.clone(),
216                    batch_size,
217                    Some(server),
218                    room_id,
219                ));
220            }
221
222            let joined_results = join_all(futures).await;
223
224            Ok(joined_results.into_iter().flatten().next().flatten())
225        }
226    }
227
228    /// Get a [`RoomPreview`] using MSC3266, if available on the remote server.
229    ///
230    /// Will fail with a 404 if the API is not available.
231    ///
232    /// This method is exposed for testing purposes; clients should prefer
233    /// `Client::get_room_preview` in general over this.
234    pub async fn from_room_summary(
235        client: &Client,
236        room_id: OwnedRoomId,
237        room_or_alias_id: &RoomOrAliasId,
238        via: Vec<OwnedServerName>,
239    ) -> crate::Result<Self> {
240        let own_server_name = client.session_meta().map(|s| s.user_id.server_name());
241        let via = ensure_server_names_is_not_empty(own_server_name, via, room_or_alias_id);
242
243        let request = ruma::api::client::room::get_summary::msc3266::Request::new(
244            room_or_alias_id.to_owned(),
245            via,
246        );
247
248        let response = client.send(request).await?;
249
250        // The server returns a `Left` room state for rooms the user has not joined. Be
251        // more precise than that, and set it to `None` if we haven't joined
252        // that room.
253        let cached_room = client.get_room(&room_id);
254        let state = if cached_room.is_none() {
255            None
256        } else {
257            response.membership.map(|membership| RoomState::from(&membership))
258        };
259
260        let num_active_members = cached_room.as_ref().map(|r| r.active_members_count());
261
262        let is_direct = if let Some(cached_room) = &cached_room {
263            cached_room.is_direct().await.ok()
264        } else {
265            None
266        };
267
268        Ok(RoomPreview {
269            room_id,
270            canonical_alias: response.canonical_alias,
271            name: response.name,
272            topic: response.topic,
273            avatar_url: response.avatar_url,
274            num_joined_members: response.num_joined_members.into(),
275            num_active_members,
276            room_type: response.room_type,
277            join_rule: response.join_rule,
278            is_world_readable: Some(response.world_readable),
279            state,
280            is_direct,
281            heroes: cached_room.map(|r| r.heroes()),
282        })
283    }
284
285    /// Get a [`RoomPreview`] using the room state endpoint.
286    ///
287    /// This is always available on a remote server, but will only work if one
288    /// of these two conditions is true:
289    ///
290    /// - the user has joined the room at some point (i.e. they're still joined
291    ///   or they've joined it and left it later).
292    /// - the room has an history visibility set to world-readable.
293    ///
294    /// This method is exposed for testing purposes; clients should prefer
295    /// `Client::get_room_preview` in general over this.
296    pub async fn from_state_events(client: &Client, room_id: &RoomId) -> crate::Result<Self> {
297        let state_request = get_state_events::v3::Request::new(room_id.to_owned());
298        let joined_members_request = joined_members::v3::Request::new(room_id.to_owned());
299
300        let (state, joined_members) =
301            try_join!(async { client.send(state_request).await }, async {
302                client.send(joined_members_request).await
303            })?;
304
305        // Converting from usize to u64 will always work, up to 64-bits devices;
306        // otherwise, assume LOTS of members.
307        let num_joined_members = joined_members.joined.len().try_into().unwrap_or(u64::MAX);
308
309        let mut room_info = RoomInfo::new(room_id, RoomState::Joined);
310
311        for ev in state.room_state {
312            let ev = match ev.deserialize() {
313                Ok(ev) => ev,
314                Err(err) => {
315                    warn!("failed to deserialize state event: {err}");
316                    continue;
317                }
318            };
319            room_info.handle_state_event(&ev.into());
320        }
321
322        let room = client.get_room(room_id);
323        let state = room.as_ref().map(|room| room.state());
324        let num_active_members = room.as_ref().map(|r| r.active_members_count());
325        let is_direct = if let Some(room) = room { room.is_direct().await.ok() } else { None };
326
327        Ok(Self::from_room_info(
328            room_info,
329            is_direct,
330            num_joined_members,
331            num_active_members,
332            state,
333            None,
334        ))
335    }
336}
337
338async fn search_for_room_preview_in_room_directory(
339    client: Client,
340    filter: Option<String>,
341    batch_size: u32,
342    server: Option<OwnedServerName>,
343    expected_room_id: &RoomId,
344) -> crate::Result<Option<RoomPreview>> {
345    let mut directory_search = RoomDirectorySearch::new(client);
346    directory_search.search(filter, batch_size, server).await?;
347
348    let (results, _) = directory_search.results();
349
350    for room_description in results {
351        // Iterate until we find a room description with a matching room id
352        if room_description.room_id != expected_room_id {
353            continue;
354        }
355        return Ok(Some(RoomPreview {
356            room_id: room_description.room_id,
357            canonical_alias: room_description.alias,
358            name: room_description.name,
359            topic: room_description.topic,
360            avatar_url: room_description.avatar_url,
361            num_joined_members: room_description.joined_members,
362            num_active_members: None,
363            // Assume it's a room
364            room_type: None,
365            join_rule: match room_description.join_rule {
366                PublicRoomJoinRule::Public => SpaceRoomJoinRule::Public,
367                PublicRoomJoinRule::Knock => SpaceRoomJoinRule::Knock,
368                PublicRoomJoinRule::_Custom(rule) => SpaceRoomJoinRule::_Custom(rule),
369                _ => {
370                    panic!("Unexpected PublicRoomJoinRule {:?}", room_description.join_rule)
371                }
372            },
373            is_world_readable: Some(room_description.is_world_readable),
374            state: None,
375            is_direct: None,
376            heroes: None,
377        }));
378    }
379
380    Ok(None)
381}
382
383// Make sure the server name of the room id/alias is
384// included in the list of server names to send if no server names are provided
385fn ensure_server_names_is_not_empty(
386    own_server_name: Option<&ServerName>,
387    server_names: Vec<OwnedServerName>,
388    room_or_alias_id: &RoomOrAliasId,
389) -> Vec<OwnedServerName> {
390    let mut server_names = server_names;
391
392    if let Some((own_server, alias_server)) = own_server_name.zip(room_or_alias_id.server_name()) {
393        if server_names.is_empty() && own_server != alias_server {
394            server_names.push(alias_server.to_owned());
395        }
396    }
397
398    server_names
399}
400
401#[cfg(test)]
402mod tests {
403    use ruma::{owned_server_name, room_alias_id, room_id, server_name, RoomOrAliasId, ServerName};
404
405    use crate::room_preview::ensure_server_names_is_not_empty;
406
407    #[test]
408    fn test_ensure_server_names_is_not_empty_when_no_own_server_name_is_provided() {
409        let own_server_name: Option<&ServerName> = None;
410        let room_or_alias_id: &RoomOrAliasId = room_id!("!test:localhost").into();
411
412        let server_names =
413            ensure_server_names_is_not_empty(own_server_name, Vec::new(), room_or_alias_id);
414
415        // There was no own server name to check against, so no additional server name
416        // was added
417        assert!(server_names.is_empty());
418    }
419
420    #[test]
421    fn test_ensure_server_names_is_not_empty_when_room_alias_or_id_has_no_server_name() {
422        let own_server_name: Option<&ServerName> = Some(server_name!("localhost"));
423        let room_or_alias_id: &RoomOrAliasId = room_id!("!test").into();
424
425        let server_names =
426            ensure_server_names_is_not_empty(own_server_name, Vec::new(), room_or_alias_id);
427
428        // The room id has no server name, so nothing could be added
429        assert!(server_names.is_empty());
430    }
431
432    #[test]
433    fn test_ensure_server_names_is_not_empty_with_same_server_name() {
434        let own_server_name: Option<&ServerName> = Some(server_name!("localhost"));
435        let room_or_alias_id: &RoomOrAliasId = room_id!("!test:localhost").into();
436
437        let server_names =
438            ensure_server_names_is_not_empty(own_server_name, Vec::new(), room_or_alias_id);
439
440        // The room id's server name was the same as our own server name, so there's no
441        // need to add it
442        assert!(server_names.is_empty());
443    }
444
445    #[test]
446    fn test_ensure_server_names_is_not_empty_with_different_room_id_server_name() {
447        let own_server_name: Option<&ServerName> = Some(server_name!("localhost"));
448        let room_or_alias_id: &RoomOrAliasId = room_id!("!test:matrix.org").into();
449
450        let server_names =
451            ensure_server_names_is_not_empty(own_server_name, Vec::new(), room_or_alias_id);
452
453        // The server name in the room id was added
454        assert!(!server_names.is_empty());
455        assert_eq!(server_names[0], owned_server_name!("matrix.org"));
456    }
457
458    #[test]
459    fn test_ensure_server_names_is_not_empty_with_different_room_alias_server_name() {
460        let own_server_name: Option<&ServerName> = Some(server_name!("localhost"));
461        let room_or_alias_id: &RoomOrAliasId = room_alias_id!("#test:matrix.org").into();
462
463        let server_names =
464            ensure_server_names_is_not_empty(own_server_name, Vec::new(), room_or_alias_id);
465
466        // The server name in the room alias was added
467        assert!(!server_names.is_empty());
468        assert_eq!(server_names[0], owned_server_name!("matrix.org"));
469    }
470}