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