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