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
172        // information 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
190        // identifier char
191        let search_term = if room_or_alias_id.is_room_alias_id() {
192            Some(room_or_alias_id.as_str()[1..].to_owned())
193        } else {
194            None
195        };
196
197        // If we have no alias, filtering using a room id is impossible, so just
198        // take the first 100 results and try to find the current room #YOLO
199        let batch_size = if search_term.is_some() { 20 } else { 100 };
200
201        if via.is_empty() {
202            // Just search in the current homeserver
203            search_for_room_preview_in_room_directory(
204                client.clone(),
205                search_term,
206                batch_size,
207                None,
208                room_id,
209            )
210            .await
211        } else {
212            let mut futures = Vec::new();
213            // Search for all servers and retrieve the results
214            for server in via {
215                futures.push(search_for_room_preview_in_room_directory(
216                    client.clone(),
217                    search_term.clone(),
218                    batch_size,
219                    Some(server),
220                    room_id,
221                ));
222            }
223
224            let joined_results = join_all(futures).await;
225
226            Ok(joined_results.into_iter().flatten().next().flatten())
227        }
228    }
229
230    /// Get a [`RoomPreview`] using MSC3266, if available on the remote server.
231    ///
232    /// Will fail with a 404 if the API is not available.
233    ///
234    /// This method is exposed for testing purposes; clients should prefer
235    /// `Client::get_room_preview` in general over this.
236    pub async fn from_room_summary(
237        client: &Client,
238        room_id: OwnedRoomId,
239        room_or_alias_id: &RoomOrAliasId,
240        via: Vec<OwnedServerName>,
241    ) -> crate::Result<Self> {
242        let own_server_name = client.session_meta().map(|s| s.user_id.server_name());
243        let via = ensure_server_names_is_not_empty(own_server_name, via, room_or_alias_id);
244
245        let request = ruma::api::client::room::get_summary::v1::Request::new(
246            room_or_alias_id.to_owned(),
247            via,
248        );
249
250        let response = client.send(request).await?;
251
252        // The server returns a `Left` room state for rooms the user has not
253        // joined. Be more precise than that, and set it to `None` if we haven't
254        // joined that room.
255        let cached_room = client.get_room(&room_id);
256        let state = if cached_room.is_none() {
257            None
258        } else {
259            response.membership.map(|membership| RoomState::from(&membership))
260        };
261
262        let num_active_members = cached_room.as_ref().map(|r| r.active_members_count());
263
264        let is_direct = if let Some(cached_room) = &cached_room {
265            cached_room.is_direct().await.ok()
266        } else {
267            None
268        };
269
270        let heroes = if let Some(cached_room) = &cached_room {
271            Some(cached_room.heroes().await)
272        } else {
273            None
274        };
275
276        let summary = response.summary;
277
278        Ok(RoomPreview {
279            room_id,
280            canonical_alias: summary.canonical_alias,
281            name: summary.name,
282            topic: summary.topic,
283            avatar_url: summary.avatar_url,
284            num_joined_members: summary.num_joined_members.into(),
285            num_active_members,
286            room_type: summary.room_type,
287            join_rule: Some(summary.join_rule),
288            is_world_readable: Some(summary.world_readable),
289            state,
290            is_direct,
291            heroes,
292        })
293    }
294
295    /// Get a [`RoomPreview`] using the room state endpoint.
296    ///
297    /// This is always available on a remote server, but will only work if one
298    /// of these two conditions is true:
299    ///
300    /// - the user has joined the room at some point (i.e. they're still joined
301    ///   or they've joined it and left it later).
302    /// - the room has an history visibility set to world-readable.
303    ///
304    /// This method is exposed for testing purposes; clients should prefer
305    /// `Client::get_room_preview` in general over this.
306    pub async fn from_state_events(client: &Client, room_id: &RoomId) -> crate::Result<Self> {
307        let state_request = get_state_events::v3::Request::new(room_id.to_owned());
308        let joined_members_request = joined_members::v3::Request::new(room_id.to_owned());
309
310        let (state, joined_members) =
311            try_join!(async { client.send(state_request).await }, async {
312                client.send(joined_members_request).await
313            })?;
314
315        // Converting from usize to u64 will always work, up to 64-bits devices;
316        // otherwise, assume LOTS of members.
317        let num_joined_members = joined_members.joined.len().try_into().unwrap_or(u64::MAX);
318
319        let mut room_info = RoomInfo::new(room_id, RoomState::Joined);
320
321        for ev in state.room_state {
322            if let Some(mut raw_event) = RawStateEventWithKeys::try_from_raw_state_event(ev.cast())
323            {
324                room_info.handle_state_event(&mut raw_event);
325            }
326        }
327
328        let room = client.get_room(room_id);
329        let state = room.as_ref().map(|room| room.state());
330        let num_active_members = room.as_ref().map(|r| r.active_members_count());
331        let is_direct = if let Some(room) = room { room.is_direct().await.ok() } else { None };
332
333        Ok(Self::from_room_info(
334            room_info,
335            is_direct,
336            num_joined_members,
337            num_active_members,
338            state,
339            None,
340        ))
341    }
342}
343
344async fn search_for_room_preview_in_room_directory(
345    client: Client,
346    filter: Option<String>,
347    batch_size: u32,
348    server: Option<OwnedServerName>,
349    expected_room_id: &RoomId,
350) -> crate::Result<Option<RoomPreview>> {
351    let mut directory_search = RoomDirectorySearch::new(client);
352    directory_search.search(filter, batch_size, server).await?;
353
354    let (results, _) = directory_search.results();
355
356    for room_description in results {
357        // Iterate until we find a room description with a matching room id
358        if room_description.room_id != expected_room_id {
359            continue;
360        }
361        return Ok(Some(RoomPreview {
362            room_id: room_description.room_id,
363            canonical_alias: room_description.alias,
364            name: room_description.name,
365            topic: room_description.topic,
366            avatar_url: room_description.avatar_url,
367            num_joined_members: room_description.joined_members,
368            num_active_members: None,
369            // Assume it's a room
370            room_type: None,
371            join_rule: Some(room_description.join_rule.into()),
372            is_world_readable: Some(room_description.is_world_readable),
373            state: None,
374            is_direct: None,
375            heroes: None,
376        }));
377    }
378
379    Ok(None)
380}
381
382// Make sure the server name of the room id/alias is included in the list of
383// server names to send if no server names are provided
384fn ensure_server_names_is_not_empty(
385    own_server_name: Option<&ServerName>,
386    server_names: Vec<OwnedServerName>,
387    room_or_alias_id: &RoomOrAliasId,
388) -> Vec<OwnedServerName> {
389    let mut server_names = server_names;
390
391    if let Some((own_server, alias_server)) = own_server_name.zip(room_or_alias_id.server_name())
392        && server_names.is_empty()
393        && own_server != alias_server
394    {
395        server_names.push(alias_server.to_owned());
396    }
397
398    server_names
399}
400
401#[cfg(test)]
402mod tests {
403    use ruma::{RoomOrAliasId, ServerName, room_alias_id, room_id, server_name};
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
416        // server name 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
441        // there's no 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_eq!(server_names, &["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_eq!(server_names, &["matrix.org"]);
467    }
468}