Skip to main content

matrix_sdk/
room_directory_search.rs

1// Copyright 2024 Mauro Romito
2// Copyright 2024 The Matrix.org Foundation C.I.C.
3//
4// Licensed under the Apache License, Version 2.0 (the "License");
5// you may not use this file except in compliance with the License.
6// You may obtain a copy of the License at
7//
8//     http://www.apache.org/licenses/LICENSE-2.0
9//
10// Unless required by applicable law or agreed to in writing, software
11// distributed under the License is distributed on an "AS IS" BASIS,
12// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13// See the License for the specific language governing permissions and
14// limitations under the License.
15
16//! Types for searching the public room directory.
17
18use eyeball_im::{ObservableVector, VectorDiff};
19use futures_core::Stream;
20use imbl::Vector;
21use ruma::{
22    OwnedMxcUri, OwnedRoomAliasId, OwnedRoomId,
23    api::client::directory::get_public_rooms_filtered::v3::Request as PublicRoomsFilterRequest,
24    directory::Filter, room::JoinRuleKind,
25};
26
27use crate::{Client, OwnedServerName, Result};
28
29/// This struct represents a single result of a room directory search.
30///
31/// It's produced by [`RoomDirectorySearch::results`].
32#[derive(Clone, Debug, Eq, PartialEq)]
33pub struct RoomDescription {
34    /// The room's ID.
35    pub room_id: OwnedRoomId,
36    /// The name of the room, if any.
37    pub name: Option<String>,
38    /// The topic of the room, if any.
39    pub topic: Option<String>,
40    /// The canonical alias of the room, if any.
41    pub alias: Option<OwnedRoomAliasId>,
42    /// The room's avatar URL, if any.
43    pub avatar_url: Option<OwnedMxcUri>,
44    /// The room's join rule.
45    pub join_rule: JoinRuleKind,
46    /// Whether can be previewed
47    pub is_world_readable: bool,
48    /// The number of members that have joined the room.
49    pub joined_members: u64,
50}
51
52impl From<ruma::directory::PublicRoomsChunk> for RoomDescription {
53    fn from(value: ruma::directory::PublicRoomsChunk) -> Self {
54        Self {
55            room_id: value.room_id,
56            name: value.name,
57            topic: value.topic,
58            alias: value.canonical_alias,
59            avatar_url: value.avatar_url,
60            join_rule: value.join_rule,
61            is_world_readable: value.world_readable,
62            joined_members: value.num_joined_members.into(),
63        }
64    }
65}
66
67#[derive(Default, Debug)]
68enum SearchState {
69    /// The search has more pages and contains the next token to be used in the
70    /// next page request.
71    Next(String),
72    /// The search has reached the end.
73    End,
74    /// The search is in a starting state, and has yet to fetch the first page.
75    #[default]
76    Start,
77}
78
79impl SearchState {
80    fn next_token(&self) -> Option<&str> {
81        if let Self::Next(next_token) = &self { Some(next_token) } else { None }
82    }
83
84    fn is_at_end(&self) -> bool {
85        matches!(self, Self::End)
86    }
87}
88
89/// `RoomDirectorySearch` allows searching the public room directory, with the
90/// capability of using a filter and a batch_size. This struct is also
91/// responsible for keeping the current state of the search, and exposing an
92/// update of stream of the results, reset the search, or ask for the next page.
93///
94/// ⚠️ Users must take great care when using the public room search since the
95/// results might contains NSFW content.
96///
97/// # Example
98///
99/// ```no_run
100/// use matrix_sdk::{Client, room_directory_search::RoomDirectorySearch};
101/// use url::Url;
102///
103/// async {
104///     let homeserver = Url::parse("http://localhost:8080")?;
105///     let client = Client::new(homeserver).await?;
106///     let mut room_directory_search = RoomDirectorySearch::new(client);
107///     room_directory_search.search(None, 10, None).await?;
108///     let (results, mut stream) = room_directory_search.results();
109///     room_directory_search.next_page().await?;
110///     anyhow::Ok(())
111/// };
112/// ```
113#[derive(Debug)]
114pub struct RoomDirectorySearch {
115    batch_size: u32,
116    filter: Option<String>,
117    server: Option<OwnedServerName>,
118    search_state: SearchState,
119    client: Client,
120    results: ObservableVector<RoomDescription>,
121}
122
123impl RoomDirectorySearch {
124    /// Constructor for the `RoomDirectorySearch`, requires a `Client`.
125    pub fn new(client: Client) -> Self {
126        Self {
127            batch_size: 0,
128            filter: None,
129            server: None,
130            search_state: Default::default(),
131            client,
132            results: ObservableVector::new(),
133        }
134    }
135
136    /// Starts a filtered search for the server.
137    ///
138    /// If the `filter` is not provided it will search for all the rooms. You
139    /// can specify a `batch_size` to control the number of rooms to fetch per
140    /// request.
141    ///
142    /// If the `via_server` is not provided it will search in the current
143    /// homeserver by default.
144    ///
145    /// This method will clear the current search results and start a new one.
146    // Should never be used concurrently with another `next_page` or a `search`.
147    pub async fn search(
148        &mut self,
149        filter: Option<String>,
150        batch_size: u32,
151        via_server: Option<OwnedServerName>,
152    ) -> Result<()> {
153        self.filter = filter;
154        self.batch_size = batch_size;
155        self.search_state = Default::default();
156        self.results.clear();
157        self.server = via_server;
158        self.next_page().await
159    }
160
161    /// Asks the server for the next page of the current search.
162    // Should never be used concurrently with another `next_page` or a `search`.
163    pub async fn next_page(&mut self) -> Result<()> {
164        if self.search_state.is_at_end() {
165            return Ok(());
166        }
167
168        let mut filter = Filter::new();
169        filter.generic_search_term = self.filter.clone();
170
171        let mut request = PublicRoomsFilterRequest::new();
172        request.filter = filter;
173        request.server = self.server.clone();
174        request.limit = Some(self.batch_size.into());
175        request.since = self.search_state.next_token().map(ToOwned::to_owned);
176
177        let response = self.client.public_rooms_filtered(request).await?;
178
179        if let Some(next_token) = response.next_batch {
180            self.search_state = SearchState::Next(next_token);
181        } else {
182            self.search_state = SearchState::End;
183        }
184
185        self.results.append(response.chunk.into_iter().map(Into::into).collect());
186        Ok(())
187    }
188
189    /// Get the initial values of the current stored room descriptions in the
190    /// search, and a stream of updates for them.
191    pub fn results(
192        &self,
193    ) -> (Vector<RoomDescription>, impl Stream<Item = Vec<VectorDiff<RoomDescription>>> + use<>)
194    {
195        self.results.subscribe().into_values_and_batched_stream()
196    }
197
198    /// Get the number of pages that have been loaded so far.
199    pub fn loaded_pages(&self) -> usize {
200        if self.batch_size == 0 {
201            return 0;
202        }
203        (self.results.len() as f64 / self.batch_size as f64).ceil() as usize
204    }
205
206    /// Get whether the search is at the last page.
207    pub fn is_at_last_page(&self) -> bool {
208        self.search_state.is_at_end()
209    }
210}
211
212#[cfg(all(test, not(target_family = "wasm")))]
213mod tests {
214    use assert_matches::assert_matches;
215    use eyeball_im::VectorDiff;
216    use futures_util::StreamExt;
217    use matrix_sdk_test::{async_test, test_json};
218    use ruma::{
219        RoomAliasId, RoomId, directory::Filter, owned_server_name, room::JoinRuleKind, serde::Raw,
220    };
221    use serde_json::Value as JsonValue;
222    use stream_assert::assert_pending;
223    use wiremock::{
224        Match, Mock, MockServer, Request, ResponseTemplate,
225        matchers::{method, path_regex},
226    };
227
228    use crate::{
229        Client,
230        room_directory_search::{RoomDescription, RoomDirectorySearch},
231        test_utils::logged_in_client,
232    };
233
234    struct RoomDirectorySearchMatcher {
235        next_token: Option<String>,
236        filter_term: Option<String>,
237        limit: u32,
238    }
239
240    impl Match for RoomDirectorySearchMatcher {
241        fn matches(&self, request: &Request) -> bool {
242            let Ok(body) = request.body_json::<Raw<JsonValue>>() else {
243                return false;
244            };
245
246            // The body's `since` field is set equal to the matcher's
247            // next_token.
248            if !body.get_field::<String>("since").is_ok_and(|s| s == self.next_token) {
249                return false;
250            }
251
252            if !body.get_field::<u32>("limit").is_ok_and(|s| s == Some(self.limit)) {
253                return false;
254            }
255
256            // The body's `filter` field has `generic_search_term` equal to the
257            // matcher's next_token.
258            if !body.get_field::<Filter>("filter").is_ok_and(|s| {
259                if self.filter_term.is_none() {
260                    s.is_none() || s.is_some_and(|s| s.generic_search_term.is_none())
261                } else {
262                    s.is_some_and(|s| s.generic_search_term == self.filter_term)
263                }
264            }) {
265                return false;
266            }
267
268            method("POST").matches(request)
269                && path_regex("/_matrix/client/../publicRooms").matches(request)
270        }
271    }
272
273    fn get_first_page_description() -> RoomDescription {
274        RoomDescription {
275            room_id: RoomId::parse("!ol19s:bleecker.street").unwrap(),
276            name: Some("CHEESE".into()),
277            topic: Some("Tasty tasty cheese".into()),
278            alias: None,
279            avatar_url: Some("mxc://bleeker.street/CHEDDARandBRIE".into()),
280            join_rule: JoinRuleKind::Public,
281            is_world_readable: true,
282            joined_members: 37,
283        }
284    }
285
286    fn get_second_page_description() -> RoomDescription {
287        RoomDescription {
288            room_id: RoomId::parse("!ca18r:bleecker.street").unwrap(),
289            name: Some("PEAR".into()),
290            topic: Some("Tasty tasty pear".into()),
291            alias: RoomAliasId::parse("#murrays:pear.bar").ok(),
292            avatar_url: Some("mxc://bleeker.street/pear".into()),
293            join_rule: JoinRuleKind::Knock,
294            is_world_readable: false,
295            joined_members: 20,
296        }
297    }
298
299    async fn new_server_and_client() -> (MockServer, Client) {
300        let server = MockServer::start().await;
301        let client = logged_in_client(Some(server.uri())).await;
302        (server, client)
303    }
304
305    #[async_test]
306    async fn test_search_success() {
307        let (server, client) = new_server_and_client().await;
308
309        let mut room_directory_search = RoomDirectorySearch::new(client);
310        Mock::given(RoomDirectorySearchMatcher { next_token: None, filter_term: None, limit: 1 })
311            .respond_with(ResponseTemplate::new(200).set_body_json(&*test_json::PUBLIC_ROOMS))
312            .mount(&server)
313            .await;
314
315        let via_server = owned_server_name!("some.server.org");
316        room_directory_search.search(None, 1, Some(via_server)).await.unwrap();
317        let (results, mut stream) = room_directory_search.results();
318        assert_pending!(stream);
319        assert_eq!(results.len(), 1);
320        assert_eq!(results[0], get_first_page_description());
321        assert!(!room_directory_search.is_at_last_page());
322        assert_eq!(room_directory_search.loaded_pages(), 1);
323    }
324
325    #[async_test]
326    async fn test_search_success_paginated() {
327        let (server, client) = new_server_and_client().await;
328
329        let mut room_directory_search = RoomDirectorySearch::new(client);
330        Mock::given(RoomDirectorySearchMatcher { next_token: None, filter_term: None, limit: 1 })
331            .respond_with(ResponseTemplate::new(200).set_body_json(&*test_json::PUBLIC_ROOMS))
332            .mount(&server)
333            .await;
334
335        room_directory_search.search(None, 1, None).await.unwrap();
336        let (initial_results, mut stream) = room_directory_search.results();
337        assert_eq!(initial_results, vec![get_first_page_description()].into());
338        assert!(!room_directory_search.is_at_last_page());
339        assert_eq!(room_directory_search.loaded_pages(), 1);
340
341        Mock::given(RoomDirectorySearchMatcher {
342            next_token: Some("p190q".into()),
343            filter_term: None,
344            limit: 1,
345        })
346        .respond_with(
347            ResponseTemplate::new(200).set_body_json(&*test_json::PUBLIC_ROOMS_FINAL_PAGE),
348        )
349        .mount(&server)
350        .await;
351
352        room_directory_search.next_page().await.unwrap();
353
354        let results_batch: Vec<VectorDiff<RoomDescription>> = stream.next().await.unwrap();
355        assert_matches!(&results_batch[0], VectorDiff::Append { values } => { assert_eq!(values, &vec![get_second_page_description()].into()); });
356        assert!(room_directory_search.is_at_last_page());
357        assert_eq!(room_directory_search.loaded_pages(), 2);
358        assert_pending!(stream);
359    }
360
361    #[async_test]
362    async fn test_search_fails() {
363        let (server, client) = new_server_and_client().await;
364
365        let mut room_directory_search = RoomDirectorySearch::new(client);
366        Mock::given(RoomDirectorySearchMatcher { next_token: None, filter_term: None, limit: 1 })
367            .respond_with(ResponseTemplate::new(404))
368            .mount(&server)
369            .await;
370
371        assert!(room_directory_search.next_page().await.is_err());
372
373        let (results, mut stream) = room_directory_search.results();
374        assert_eq!(results.len(), 0);
375        assert!(!room_directory_search.is_at_last_page());
376        assert_eq!(room_directory_search.loaded_pages(), 0);
377        assert_pending!(stream);
378    }
379
380    #[async_test]
381    async fn test_search_fails_when_paginating() {
382        let (server, client) = new_server_and_client().await;
383
384        let mut room_directory_search = RoomDirectorySearch::new(client);
385        Mock::given(RoomDirectorySearchMatcher { next_token: None, filter_term: None, limit: 1 })
386            .respond_with(ResponseTemplate::new(200).set_body_json(&*test_json::PUBLIC_ROOMS))
387            .mount(&server)
388            .await;
389
390        room_directory_search.search(None, 1, None).await.unwrap();
391
392        let (results, mut stream) = room_directory_search.results();
393        assert_eq!(results, vec![get_first_page_description()].into());
394        assert!(!room_directory_search.is_at_last_page());
395        assert_eq!(room_directory_search.loaded_pages(), 1);
396        assert_pending!(stream);
397
398        Mock::given(RoomDirectorySearchMatcher {
399            next_token: Some("p190q".into()),
400            filter_term: None,
401            limit: 1,
402        })
403        .respond_with(ResponseTemplate::new(404))
404        .mount(&server)
405        .await;
406
407        assert!(room_directory_search.next_page().await.is_err());
408        assert_eq!(results, vec![get_first_page_description()].into());
409        assert!(!room_directory_search.is_at_last_page());
410        assert_eq!(room_directory_search.loaded_pages(), 1);
411        assert_pending!(stream);
412    }
413
414    #[async_test]
415    async fn test_search_success_paginated_with_filter() {
416        let (server, client) = new_server_and_client().await;
417
418        let mut room_directory_search = RoomDirectorySearch::new(client);
419        Mock::given(RoomDirectorySearchMatcher {
420            next_token: None,
421            filter_term: Some("bleecker.street".into()),
422            limit: 1,
423        })
424        .respond_with(ResponseTemplate::new(200).set_body_json(&*test_json::PUBLIC_ROOMS))
425        .mount(&server)
426        .await;
427
428        room_directory_search.search(Some("bleecker.street".into()), 1, None).await.unwrap();
429        let (initial_results, mut stream) = room_directory_search.results();
430        assert_eq!(initial_results, vec![get_first_page_description()].into());
431        assert!(!room_directory_search.is_at_last_page());
432        assert_eq!(room_directory_search.loaded_pages(), 1);
433
434        Mock::given(RoomDirectorySearchMatcher {
435            next_token: Some("p190q".into()),
436            filter_term: Some("bleecker.street".into()),
437            limit: 1,
438        })
439        .respond_with(
440            ResponseTemplate::new(200).set_body_json(&*test_json::PUBLIC_ROOMS_FINAL_PAGE),
441        )
442        .mount(&server)
443        .await;
444
445        room_directory_search.next_page().await.unwrap();
446
447        let results_batch: Vec<VectorDiff<RoomDescription>> = stream.next().await.unwrap();
448        assert_matches!(&results_batch[0], VectorDiff::Append { values } => { assert_eq!(values, &vec![get_second_page_description()].into()); });
449        assert!(room_directory_search.is_at_last_page());
450        assert_eq!(room_directory_search.loaded_pages(), 2);
451        assert_pending!(stream);
452    }
453
454    #[async_test]
455    async fn test_search_followed_by_another_search_with_filter() {
456        let (server, client) = new_server_and_client().await;
457
458        let mut room_directory_search = RoomDirectorySearch::new(client);
459        Mock::given(RoomDirectorySearchMatcher { next_token: None, filter_term: None, limit: 1 })
460            .respond_with(ResponseTemplate::new(200).set_body_json(&*test_json::PUBLIC_ROOMS))
461            .mount(&server)
462            .await;
463
464        room_directory_search.search(None, 1, None).await.unwrap();
465        let (initial_results, mut stream) = room_directory_search.results();
466        assert_eq!(initial_results, vec![get_first_page_description()].into());
467        assert!(!room_directory_search.is_at_last_page());
468        assert_eq!(room_directory_search.loaded_pages(), 1);
469
470        Mock::given(RoomDirectorySearchMatcher {
471            next_token: None,
472            filter_term: Some("bleecker.street".into()),
473            limit: 1,
474        })
475        .respond_with(ResponseTemplate::new(200).set_body_json(&*test_json::PUBLIC_ROOMS))
476        .mount(&server)
477        .await;
478
479        room_directory_search.search(Some("bleecker.street".into()), 1, None).await.unwrap();
480
481        let results_batch: Vec<VectorDiff<RoomDescription>> = stream.next().await.unwrap();
482        assert_matches!(&results_batch[0], VectorDiff::Clear);
483        assert_matches!(&results_batch[1], VectorDiff::Append { values } => { assert_eq!(values, &vec![get_first_page_description()].into()); });
484        assert!(!room_directory_search.is_at_last_page());
485        assert_eq!(room_directory_search.loaded_pages(), 1);
486        assert_pending!(stream);
487    }
488}