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.
139    /// You can specify a `batch_size` to control the number of rooms to fetch
140    /// per 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
147    // `search`.
148    pub async fn search(
149        &mut self,
150        filter: Option<String>,
151        batch_size: u32,
152        via_server: Option<OwnedServerName>,
153    ) -> Result<()> {
154        self.filter = filter;
155        self.batch_size = batch_size;
156        self.search_state = Default::default();
157        self.results.clear();
158        self.server = via_server;
159        self.next_page().await
160    }
161
162    /// Asks the server for the next page of the current search.
163    // Should never be used concurrently with another `next_page` or a
164    // `search`.
165    pub async fn next_page(&mut self) -> Result<()> {
166        if self.search_state.is_at_end() {
167            return Ok(());
168        }
169
170        let mut filter = Filter::new();
171        filter.generic_search_term = self.filter.clone();
172
173        let mut request = PublicRoomsFilterRequest::new();
174        request.filter = filter;
175        request.server = self.server.clone();
176        request.limit = Some(self.batch_size.into());
177        request.since = self.search_state.next_token().map(ToOwned::to_owned);
178
179        let response = self.client.public_rooms_filtered(request).await?;
180
181        if let Some(next_token) = response.next_batch {
182            self.search_state = SearchState::Next(next_token);
183        } else {
184            self.search_state = SearchState::End;
185        }
186
187        self.results.append(response.chunk.into_iter().map(Into::into).collect());
188        Ok(())
189    }
190
191    /// Get the initial values of the current stored room descriptions in the
192    /// search, and a stream of updates for them.
193    pub fn results(
194        &self,
195    ) -> (Vector<RoomDescription>, impl Stream<Item = Vec<VectorDiff<RoomDescription>>> + use<>)
196    {
197        self.results.subscribe().into_values_and_batched_stream()
198    }
199
200    /// Get the number of pages that have been loaded so far.
201    pub fn loaded_pages(&self) -> usize {
202        if self.batch_size == 0 {
203            return 0;
204        }
205        (self.results.len() as f64 / self.batch_size as f64).ceil() as usize
206    }
207
208    /// Get whether the search is at the last page.
209    pub fn is_at_last_page(&self) -> bool {
210        self.search_state.is_at_end()
211    }
212}
213
214#[cfg(all(test, not(target_family = "wasm")))]
215mod tests {
216    use assert_matches::assert_matches;
217    use eyeball_im::VectorDiff;
218    use futures_util::StreamExt;
219    use matrix_sdk_test::{async_test, test_json};
220    use ruma::{
221        RoomAliasId, RoomId, directory::Filter, owned_server_name, room::JoinRuleKind, serde::Raw,
222    };
223    use serde_json::Value as JsonValue;
224    use stream_assert::assert_pending;
225    use wiremock::{
226        Match, Mock, MockServer, Request, ResponseTemplate,
227        matchers::{method, path_regex},
228    };
229
230    use crate::{
231        Client,
232        room_directory_search::{RoomDescription, RoomDirectorySearch},
233        test_utils::logged_in_client,
234    };
235
236    struct RoomDirectorySearchMatcher {
237        next_token: Option<String>,
238        filter_term: Option<String>,
239        limit: u32,
240    }
241
242    impl Match for RoomDirectorySearchMatcher {
243        fn matches(&self, request: &Request) -> bool {
244            let Ok(body) = request.body_json::<Raw<JsonValue>>() else {
245                return false;
246            };
247
248            // The body's `since` field is set equal to the matcher's next_token.
249            if !body.get_field::<String>("since").is_ok_and(|s| s == self.next_token) {
250                return false;
251            }
252
253            if !body.get_field::<u32>("limit").is_ok_and(|s| s == Some(self.limit)) {
254                return false;
255            }
256
257            // The body's `filter` field has `generic_search_term` equal to the matcher's
258            // next_token.
259            if !body.get_field::<Filter>("filter").is_ok_and(|s| {
260                if self.filter_term.is_none() {
261                    s.is_none() || s.is_some_and(|s| s.generic_search_term.is_none())
262                } else {
263                    s.is_some_and(|s| s.generic_search_term == self.filter_term)
264                }
265            }) {
266                return false;
267            }
268
269            method("POST").matches(request)
270                && path_regex("/_matrix/client/../publicRooms").matches(request)
271        }
272    }
273
274    fn get_first_page_description() -> RoomDescription {
275        RoomDescription {
276            room_id: RoomId::parse("!ol19s:bleecker.street").unwrap(),
277            name: Some("CHEESE".into()),
278            topic: Some("Tasty tasty cheese".into()),
279            alias: None,
280            avatar_url: Some("mxc://bleeker.street/CHEDDARandBRIE".into()),
281            join_rule: JoinRuleKind::Public,
282            is_world_readable: true,
283            joined_members: 37,
284        }
285    }
286
287    fn get_second_page_description() -> RoomDescription {
288        RoomDescription {
289            room_id: RoomId::parse("!ca18r:bleecker.street").unwrap(),
290            name: Some("PEAR".into()),
291            topic: Some("Tasty tasty pear".into()),
292            alias: RoomAliasId::parse("#murrays:pear.bar").ok(),
293            avatar_url: Some("mxc://bleeker.street/pear".into()),
294            join_rule: JoinRuleKind::Knock,
295            is_world_readable: false,
296            joined_members: 20,
297        }
298    }
299
300    async fn new_server_and_client() -> (MockServer, Client) {
301        let server = MockServer::start().await;
302        let client = logged_in_client(Some(server.uri())).await;
303        (server, client)
304    }
305
306    #[async_test]
307    async fn test_search_success() {
308        let (server, client) = new_server_and_client().await;
309
310        let mut room_directory_search = RoomDirectorySearch::new(client);
311        Mock::given(RoomDirectorySearchMatcher { next_token: None, filter_term: None, limit: 1 })
312            .respond_with(ResponseTemplate::new(200).set_body_json(&*test_json::PUBLIC_ROOMS))
313            .mount(&server)
314            .await;
315
316        let via_server = owned_server_name!("some.server.org");
317        room_directory_search.search(None, 1, Some(via_server)).await.unwrap();
318        let (results, mut stream) = room_directory_search.results();
319        assert_pending!(stream);
320        assert_eq!(results.len(), 1);
321        assert_eq!(results[0], get_first_page_description());
322        assert!(!room_directory_search.is_at_last_page());
323        assert_eq!(room_directory_search.loaded_pages(), 1);
324    }
325
326    #[async_test]
327    async fn test_search_success_paginated() {
328        let (server, client) = new_server_and_client().await;
329
330        let mut room_directory_search = RoomDirectorySearch::new(client);
331        Mock::given(RoomDirectorySearchMatcher { next_token: None, filter_term: None, limit: 1 })
332            .respond_with(ResponseTemplate::new(200).set_body_json(&*test_json::PUBLIC_ROOMS))
333            .mount(&server)
334            .await;
335
336        room_directory_search.search(None, 1, None).await.unwrap();
337        let (initial_results, mut stream) = room_directory_search.results();
338        assert_eq!(initial_results, vec![get_first_page_description()].into());
339        assert!(!room_directory_search.is_at_last_page());
340        assert_eq!(room_directory_search.loaded_pages(), 1);
341
342        Mock::given(RoomDirectorySearchMatcher {
343            next_token: Some("p190q".into()),
344            filter_term: None,
345            limit: 1,
346        })
347        .respond_with(
348            ResponseTemplate::new(200).set_body_json(&*test_json::PUBLIC_ROOMS_FINAL_PAGE),
349        )
350        .mount(&server)
351        .await;
352
353        room_directory_search.next_page().await.unwrap();
354
355        let results_batch: Vec<VectorDiff<RoomDescription>> = stream.next().await.unwrap();
356        assert_matches!(&results_batch[0], VectorDiff::Append { values } => { assert_eq!(values, &vec![get_second_page_description()].into()); });
357        assert!(room_directory_search.is_at_last_page());
358        assert_eq!(room_directory_search.loaded_pages(), 2);
359        assert_pending!(stream);
360    }
361
362    #[async_test]
363    async fn test_search_fails() {
364        let (server, client) = new_server_and_client().await;
365
366        let mut room_directory_search = RoomDirectorySearch::new(client);
367        Mock::given(RoomDirectorySearchMatcher { next_token: None, filter_term: None, limit: 1 })
368            .respond_with(ResponseTemplate::new(404))
369            .mount(&server)
370            .await;
371
372        assert!(room_directory_search.next_page().await.is_err());
373
374        let (results, mut stream) = room_directory_search.results();
375        assert_eq!(results.len(), 0);
376        assert!(!room_directory_search.is_at_last_page());
377        assert_eq!(room_directory_search.loaded_pages(), 0);
378        assert_pending!(stream);
379    }
380
381    #[async_test]
382    async fn test_search_fails_when_paginating() {
383        let (server, client) = new_server_and_client().await;
384
385        let mut room_directory_search = RoomDirectorySearch::new(client);
386        Mock::given(RoomDirectorySearchMatcher { next_token: None, filter_term: None, limit: 1 })
387            .respond_with(ResponseTemplate::new(200).set_body_json(&*test_json::PUBLIC_ROOMS))
388            .mount(&server)
389            .await;
390
391        room_directory_search.search(None, 1, None).await.unwrap();
392
393        let (results, mut stream) = room_directory_search.results();
394        assert_eq!(results, vec![get_first_page_description()].into());
395        assert!(!room_directory_search.is_at_last_page());
396        assert_eq!(room_directory_search.loaded_pages(), 1);
397        assert_pending!(stream);
398
399        Mock::given(RoomDirectorySearchMatcher {
400            next_token: Some("p190q".into()),
401            filter_term: None,
402            limit: 1,
403        })
404        .respond_with(ResponseTemplate::new(404))
405        .mount(&server)
406        .await;
407
408        assert!(room_directory_search.next_page().await.is_err());
409        assert_eq!(results, vec![get_first_page_description()].into());
410        assert!(!room_directory_search.is_at_last_page());
411        assert_eq!(room_directory_search.loaded_pages(), 1);
412        assert_pending!(stream);
413    }
414
415    #[async_test]
416    async fn test_search_success_paginated_with_filter() {
417        let (server, client) = new_server_and_client().await;
418
419        let mut room_directory_search = RoomDirectorySearch::new(client);
420        Mock::given(RoomDirectorySearchMatcher {
421            next_token: None,
422            filter_term: Some("bleecker.street".into()),
423            limit: 1,
424        })
425        .respond_with(ResponseTemplate::new(200).set_body_json(&*test_json::PUBLIC_ROOMS))
426        .mount(&server)
427        .await;
428
429        room_directory_search.search(Some("bleecker.street".into()), 1, None).await.unwrap();
430        let (initial_results, mut stream) = room_directory_search.results();
431        assert_eq!(initial_results, vec![get_first_page_description()].into());
432        assert!(!room_directory_search.is_at_last_page());
433        assert_eq!(room_directory_search.loaded_pages(), 1);
434
435        Mock::given(RoomDirectorySearchMatcher {
436            next_token: Some("p190q".into()),
437            filter_term: Some("bleecker.street".into()),
438            limit: 1,
439        })
440        .respond_with(
441            ResponseTemplate::new(200).set_body_json(&*test_json::PUBLIC_ROOMS_FINAL_PAGE),
442        )
443        .mount(&server)
444        .await;
445
446        room_directory_search.next_page().await.unwrap();
447
448        let results_batch: Vec<VectorDiff<RoomDescription>> = stream.next().await.unwrap();
449        assert_matches!(&results_batch[0], VectorDiff::Append { values } => { assert_eq!(values, &vec![get_second_page_description()].into()); });
450        assert!(room_directory_search.is_at_last_page());
451        assert_eq!(room_directory_search.loaded_pages(), 2);
452        assert_pending!(stream);
453    }
454
455    #[async_test]
456    async fn test_search_followed_by_another_search_with_filter() {
457        let (server, client) = new_server_and_client().await;
458
459        let mut room_directory_search = RoomDirectorySearch::new(client);
460        Mock::given(RoomDirectorySearchMatcher { next_token: None, filter_term: None, limit: 1 })
461            .respond_with(ResponseTemplate::new(200).set_body_json(&*test_json::PUBLIC_ROOMS))
462            .mount(&server)
463            .await;
464
465        room_directory_search.search(None, 1, None).await.unwrap();
466        let (initial_results, mut stream) = room_directory_search.results();
467        assert_eq!(initial_results, vec![get_first_page_description()].into());
468        assert!(!room_directory_search.is_at_last_page());
469        assert_eq!(room_directory_search.loaded_pages(), 1);
470
471        Mock::given(RoomDirectorySearchMatcher {
472            next_token: None,
473            filter_term: Some("bleecker.street".into()),
474            limit: 1,
475        })
476        .respond_with(ResponseTemplate::new(200).set_body_json(&*test_json::PUBLIC_ROOMS))
477        .mount(&server)
478        .await;
479
480        room_directory_search.search(Some("bleecker.street".into()), 1, None).await.unwrap();
481
482        let results_batch: Vec<VectorDiff<RoomDescription>> = stream.next().await.unwrap();
483        assert_matches!(&results_batch[0], VectorDiff::Clear);
484        assert_matches!(&results_batch[1], VectorDiff::Append { values } => { assert_eq!(values, &vec![get_first_page_description()].into()); });
485        assert!(!room_directory_search.is_at_last_page());
486        assert_eq!(room_directory_search.loaded_pages(), 1);
487        assert_pending!(stream);
488    }
489}