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, PublicRoomJoinRule},
24 OwnedMxcUri, OwnedRoomAliasId, OwnedRoomId,
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: PublicRoomJoinRule,
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 {
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#[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 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 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 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 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 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 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 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 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}