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(
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 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 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 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 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 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 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}