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