1use 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#[derive(Clone, Debug, Eq, PartialEq)]
32pub struct RoomDescription {
33 pub room_id: OwnedRoomId,
35 pub name: Option<String>,
37 pub topic: Option<String>,
39 pub alias: Option<OwnedRoomAliasId>,
41 pub avatar_url: Option<OwnedMxcUri>,
43 pub join_rule: JoinRuleKind,
45 pub is_world_readable: bool,
47 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 Next(String),
71 End,
73 #[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#[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 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 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 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 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 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 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 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 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}