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, Error, 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        // Try using the room state endpoint, as well as the joined members one.
176        match Self::from_state_events(client, &room_id).await {
177            Ok(res) => return Ok(res),
178            Err(err) => {
179                warn!("error when building room preview from state events: {err}");
180            }
181        }
182
183        // Finally, if everything else fails, try to build the room from information
184        // that the client itself might have about it.
185        if let Some(room) = client.get_room(&room_id) {
186            Ok(Self::from_known_room(&room).await)
187        } else {
188            Err(Error::InsufficientData)
189        }
190    }
191
192    /// Get a [`RoomPreview`] by searching in the room directory for the
193    /// provided room alias or room id and transforming the [`RoomDescription`]
194    /// into a preview.
195    pub(crate) async fn from_room_directory_search(
196        client: &Client,
197        room_id: &RoomId,
198        room_or_alias_id: &RoomOrAliasId,
199        via: Vec<OwnedServerName>,
200    ) -> crate::Result<Option<Self>> {
201        // Get either the room alias or the room id without the leading identifier char
202        let search_term = if room_or_alias_id.is_room_alias_id() {
203            Some(room_or_alias_id.as_str()[1..].to_owned())
204        } else {
205            None
206        };
207
208        // If we have no alias, filtering using a room id is impossible, so just take
209        // the first 100 results and try to find the current room #YOLO
210        let batch_size = if search_term.is_some() { 20 } else { 100 };
211
212        if via.is_empty() {
213            // Just search in the current homeserver
214            search_for_room_preview_in_room_directory(
215                client.clone(),
216                search_term,
217                batch_size,
218                None,
219                room_id,
220            )
221            .await
222        } else {
223            let mut futures = Vec::new();
224            // Search for all servers and retrieve the results
225            for server in via {
226                futures.push(search_for_room_preview_in_room_directory(
227                    client.clone(),
228                    search_term.clone(),
229                    batch_size,
230                    Some(server),
231                    room_id,
232                ));
233            }
234
235            let joined_results = join_all(futures).await;
236
237            Ok(joined_results.into_iter().flatten().next().flatten())
238        }
239    }
240
241    /// Get a [`RoomPreview`] using MSC3266, if available on the remote server.
242    ///
243    /// Will fail with a 404 if the API is not available.
244    ///
245    /// This method is exposed for testing purposes; clients should prefer
246    /// `Client::get_room_preview` in general over this.
247    pub async fn from_room_summary(
248        client: &Client,
249        room_id: OwnedRoomId,
250        room_or_alias_id: &RoomOrAliasId,
251        via: Vec<OwnedServerName>,
252    ) -> crate::Result<Self> {
253        let own_server_name = client.session_meta().map(|s| s.user_id.server_name());
254        let via = ensure_server_names_is_not_empty(own_server_name, via, room_or_alias_id);
255
256        let request = ruma::api::client::room::get_summary::msc3266::Request::new(
257            room_or_alias_id.to_owned(),
258            via,
259        );
260
261        let response = client.send(request).await?;
262
263        // The server returns a `Left` room state for rooms the user has not joined. Be
264        // more precise than that, and set it to `None` if we haven't joined
265        // that room.
266        let cached_room = client.get_room(&room_id);
267        let state = if cached_room.is_none() {
268            None
269        } else {
270            response.membership.map(|membership| RoomState::from(&membership))
271        };
272
273        let num_active_members = cached_room.as_ref().map(|r| r.active_members_count());
274
275        let is_direct = if let Some(cached_room) = &cached_room {
276            cached_room.is_direct().await.ok()
277        } else {
278            None
279        };
280
281        Ok(RoomPreview {
282            room_id,
283            canonical_alias: response.canonical_alias,
284            name: response.name,
285            topic: response.topic,
286            avatar_url: response.avatar_url,
287            num_joined_members: response.num_joined_members.into(),
288            num_active_members,
289            room_type: response.room_type,
290            join_rule: response.join_rule,
291            is_world_readable: Some(response.world_readable),
292            state,
293            is_direct,
294            heroes: cached_room.map(|r| r.heroes()),
295        })
296    }
297
298    /// Get a [`RoomPreview`] using the room state endpoint.
299    ///
300    /// This is always available on a remote server, but will only work if one
301    /// of these two conditions is true:
302    ///
303    /// - the user has joined the room at some point (i.e. they're still joined
304    ///   or they've joined it and left it later).
305    /// - the room has an history visibility set to world-readable.
306    ///
307    /// This method is exposed for testing purposes; clients should prefer
308    /// `Client::get_room_preview` in general over this.
309    pub async fn from_state_events(client: &Client, room_id: &RoomId) -> crate::Result<Self> {
310        let state_request = get_state_events::v3::Request::new(room_id.to_owned());
311        let joined_members_request = joined_members::v3::Request::new(room_id.to_owned());
312
313        let (state, joined_members) =
314            try_join!(async { client.send(state_request).await }, async {
315                client.send(joined_members_request).await
316            })?;
317
318        // Converting from usize to u64 will always work, up to 64-bits devices;
319        // otherwise, assume LOTS of members.
320        let num_joined_members = joined_members.joined.len().try_into().unwrap_or(u64::MAX);
321
322        let mut room_info = RoomInfo::new(room_id, RoomState::Joined);
323
324        for ev in state.room_state {
325            let ev = match ev.deserialize() {
326                Ok(ev) => ev,
327                Err(err) => {
328                    warn!("failed to deserialize state event: {err}");
329                    continue;
330                }
331            };
332            room_info.handle_state_event(&ev.into());
333        }
334
335        let room = client.get_room(room_id);
336        let state = room.as_ref().map(|room| room.state());
337        let num_active_members = room.as_ref().map(|r| r.active_members_count());
338        let is_direct = if let Some(room) = room { room.is_direct().await.ok() } else { None };
339
340        Ok(Self::from_room_info(
341            room_info,
342            is_direct,
343            num_joined_members,
344            num_active_members,
345            state,
346            None,
347        ))
348    }
349}
350
351async fn search_for_room_preview_in_room_directory(
352    client: Client,
353    filter: Option<String>,
354    batch_size: u32,
355    server: Option<OwnedServerName>,
356    expected_room_id: &RoomId,
357) -> crate::Result<Option<RoomPreview>> {
358    let mut directory_search = RoomDirectorySearch::new(client);
359    directory_search.search(filter, batch_size, server).await?;
360
361    let (results, _) = directory_search.results();
362
363    for room_description in results {
364        // Iterate until we find a room description with a matching room id
365        if room_description.room_id != expected_room_id {
366            continue;
367        }
368        return Ok(Some(RoomPreview {
369            room_id: room_description.room_id,
370            canonical_alias: room_description.alias,
371            name: room_description.name,
372            topic: room_description.topic,
373            avatar_url: room_description.avatar_url,
374            num_joined_members: room_description.joined_members,
375            num_active_members: None,
376            // Assume it's a room
377            room_type: None,
378            join_rule: match room_description.join_rule {
379                PublicRoomJoinRule::Public => SpaceRoomJoinRule::Public,
380                PublicRoomJoinRule::Knock => SpaceRoomJoinRule::Knock,
381                PublicRoomJoinRule::_Custom(rule) => SpaceRoomJoinRule::_Custom(rule),
382                _ => {
383                    panic!("Unexpected PublicRoomJoinRule {:?}", room_description.join_rule)
384                }
385            },
386            is_world_readable: Some(room_description.is_world_readable),
387            state: None,
388            is_direct: None,
389            heroes: None,
390        }));
391    }
392
393    Ok(None)
394}
395
396// Make sure the server name of the room id/alias is
397// included in the list of server names to send if no server names are provided
398fn ensure_server_names_is_not_empty(
399    own_server_name: Option<&ServerName>,
400    server_names: Vec<OwnedServerName>,
401    room_or_alias_id: &RoomOrAliasId,
402) -> Vec<OwnedServerName> {
403    let mut server_names = server_names;
404
405    if let Some((own_server, alias_server)) = own_server_name.zip(room_or_alias_id.server_name()) {
406        if server_names.is_empty() && own_server != alias_server {
407            server_names.push(alias_server.to_owned());
408        }
409    }
410
411    server_names
412}
413
414#[cfg(test)]
415mod tests {
416    use ruma::{owned_server_name, room_alias_id, room_id, server_name, RoomOrAliasId, ServerName};
417
418    use crate::room_preview::ensure_server_names_is_not_empty;
419
420    #[test]
421    fn test_ensure_server_names_is_not_empty_when_no_own_server_name_is_provided() {
422        let own_server_name: Option<&ServerName> = None;
423        let room_or_alias_id: &RoomOrAliasId = room_id!("!test:localhost").into();
424
425        let server_names =
426            ensure_server_names_is_not_empty(own_server_name, Vec::new(), room_or_alias_id);
427
428        // There was no own server name to check against, so no additional server name
429        // was added
430        assert!(server_names.is_empty());
431    }
432
433    #[test]
434    fn test_ensure_server_names_is_not_empty_when_room_alias_or_id_has_no_server_name() {
435        let own_server_name: Option<&ServerName> = Some(server_name!("localhost"));
436        let room_or_alias_id: &RoomOrAliasId = room_id!("!test").into();
437
438        let server_names =
439            ensure_server_names_is_not_empty(own_server_name, Vec::new(), room_or_alias_id);
440
441        // The room id has no server name, so nothing could be added
442        assert!(server_names.is_empty());
443    }
444
445    #[test]
446    fn test_ensure_server_names_is_not_empty_with_same_server_name() {
447        let own_server_name: Option<&ServerName> = Some(server_name!("localhost"));
448        let room_or_alias_id: &RoomOrAliasId = room_id!("!test:localhost").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 room id's server name was the same as our own server name, so there's no
454        // need to add it
455        assert!(server_names.is_empty());
456    }
457
458    #[test]
459    fn test_ensure_server_names_is_not_empty_with_different_room_id_server_name() {
460        let own_server_name: Option<&ServerName> = Some(server_name!("localhost"));
461        let room_or_alias_id: &RoomOrAliasId = room_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 id was added
467        assert!(!server_names.is_empty());
468        assert_eq!(server_names[0], owned_server_name!("matrix.org"));
469    }
470
471    #[test]
472    fn test_ensure_server_names_is_not_empty_with_different_room_alias_server_name() {
473        let own_server_name: Option<&ServerName> = Some(server_name!("localhost"));
474        let room_or_alias_id: &RoomOrAliasId = room_alias_id!("#test:matrix.org").into();
475
476        let server_names =
477            ensure_server_names_is_not_empty(own_server_name, Vec::new(), room_or_alias_id);
478
479        // The server name in the room alias was added
480        assert!(!server_names.is_empty());
481        assert_eq!(server_names[0], owned_server_name!("matrix.org"));
482    }
483}