Skip to main content

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