1use 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#[derive(Clone, Debug, Eq, PartialEq)]
33pub struct RoomDescription {
34 pub room_id: OwnedRoomId,
36 pub name: Option<String>,
38 pub topic: Option<String>,
40 pub alias: Option<OwnedRoomAliasId>,
42 pub avatar_url: Option<OwnedMxcUri>,
44 pub join_rule: JoinRuleKind,
46 pub is_world_readable: bool,
48 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 Next(String),
72 End,
74 #[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#[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 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 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 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 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 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 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 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 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}