diff --git a/bindings/matrix-sdk-ffi/src/client.rs b/bindings/matrix-sdk-ffi/src/client.rs index 54406134ef0..7e9a4fa8a07 100644 --- a/bindings/matrix-sdk-ffi/src/client.rs +++ b/bindings/matrix-sdk-ffi/src/client.rs @@ -58,6 +58,7 @@ use crate::{ encryption::Encryption, notification::NotificationClientBuilder, notification_settings::NotificationSettings, + room_directory_search::RoomDirectorySearch, sync_service::{SyncService, SyncServiceBuilder}, task_handle::TaskHandle, ClientError, @@ -740,6 +741,12 @@ impl Client { } }))) } + + pub fn room_directory_search(&self) -> Arc { + Arc::new(RoomDirectorySearch::new( + matrix_sdk::room_directory_search::RoomDirectorySearch::new((*self.inner).clone()), + )) + } } #[uniffi::export(callback_interface)] diff --git a/bindings/matrix-sdk-ffi/src/lib.rs b/bindings/matrix-sdk-ffi/src/lib.rs index bcc60db3a56..d90deea3e10 100644 --- a/bindings/matrix-sdk-ffi/src/lib.rs +++ b/bindings/matrix-sdk-ffi/src/lib.rs @@ -32,6 +32,7 @@ mod notification; mod notification_settings; mod platform; mod room; +mod room_directory_search; mod room_info; mod room_list; mod room_member; diff --git a/bindings/matrix-sdk-ffi/src/room_directory_search.rs b/bindings/matrix-sdk-ffi/src/room_directory_search.rs new file mode 100644 index 00000000000..7036d7f5bf0 --- /dev/null +++ b/bindings/matrix-sdk-ffi/src/room_directory_search.rs @@ -0,0 +1,175 @@ +// Copyright 2024 Mauro Romito +// Copyright 2024 The Matrix.org Foundation C.I.C. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +use std::{fmt::Debug, sync::Arc}; + +use eyeball_im::VectorDiff; +use futures_util::StreamExt; +use matrix_sdk::room_directory_search::RoomDirectorySearch as SdkRoomDirectorySearch; +use tokio::sync::RwLock; + +use super::RUNTIME; +use crate::{error::ClientError, task_handle::TaskHandle}; + +#[derive(uniffi::Enum)] +pub enum PublicRoomJoinRule { + Public, + Knock, +} + +impl TryFrom for PublicRoomJoinRule { + type Error = String; + + fn try_from(value: ruma::directory::PublicRoomJoinRule) -> Result { + match value { + ruma::directory::PublicRoomJoinRule::Public => Ok(Self::Public), + ruma::directory::PublicRoomJoinRule::Knock => Ok(Self::Knock), + rule => Err(format!("unsupported join rule: {rule:?}")), + } + } +} + +#[derive(uniffi::Record)] +pub struct RoomDescription { + pub room_id: String, + pub name: Option, + pub topic: Option, + pub alias: Option, + pub avatar_url: Option, + pub join_rule: Option, + pub is_world_readable: bool, + pub joined_members: u64, +} + +impl From for RoomDescription { + fn from(value: matrix_sdk::room_directory_search::RoomDescription) -> Self { + Self { + room_id: value.room_id.to_string(), + name: value.name, + topic: value.topic, + alias: value.alias.map(|alias| alias.to_string()), + avatar_url: value.avatar_url.map(|url| url.to_string()), + join_rule: value.join_rule.try_into().ok(), + is_world_readable: value.is_world_readable, + joined_members: value.joined_members, + } + } +} + +#[derive(uniffi::Object)] +pub struct RoomDirectorySearch { + pub(crate) inner: RwLock, +} + +impl RoomDirectorySearch { + pub fn new(inner: SdkRoomDirectorySearch) -> Self { + Self { inner: RwLock::new(inner) } + } +} + +#[uniffi::export(async_runtime = "tokio")] +impl RoomDirectorySearch { + pub async fn next_page(&self) -> Result<(), ClientError> { + let mut inner = self.inner.write().await; + inner.next_page().await?; + Ok(()) + } + + pub async fn search(&self, filter: Option, batch_size: u32) -> Result<(), ClientError> { + let mut inner = self.inner.write().await; + inner.search(filter, batch_size).await?; + Ok(()) + } + + pub async fn loaded_pages(&self) -> Result { + let inner = self.inner.read().await; + Ok(inner.loaded_pages() as u32) + } + + pub async fn is_at_last_page(&self) -> Result { + let inner = self.inner.read().await; + Ok(inner.is_at_last_page()) + } + + pub async fn results( + &self, + listener: Box, + ) -> TaskHandle { + let (initial_values, mut stream) = self.inner.read().await.results(); + + TaskHandle::new(RUNTIME.spawn(async move { + listener.on_update(vec![RoomDirectorySearchEntryUpdate::Reset { + values: initial_values.into_iter().map(Into::into).collect(), + }]); + + while let Some(diffs) = stream.next().await { + listener.on_update(diffs.into_iter().map(|diff| diff.into()).collect()); + } + })) + } +} + +#[derive(uniffi::Record)] +pub struct RoomDirectorySearchEntriesResult { + pub entries_stream: Arc, +} + +#[derive(uniffi::Enum)] +pub enum RoomDirectorySearchEntryUpdate { + Append { values: Vec }, + Clear, + PushFront { value: RoomDescription }, + PushBack { value: RoomDescription }, + PopFront, + PopBack, + Insert { index: u32, value: RoomDescription }, + Set { index: u32, value: RoomDescription }, + Remove { index: u32 }, + Truncate { length: u32 }, + Reset { values: Vec }, +} + +impl From> + for RoomDirectorySearchEntryUpdate +{ + fn from(diff: VectorDiff) -> Self { + match diff { + VectorDiff::Append { values } => { + Self::Append { values: values.into_iter().map(|v| v.into()).collect() } + } + VectorDiff::Clear => Self::Clear, + VectorDiff::PushFront { value } => Self::PushFront { value: value.into() }, + VectorDiff::PushBack { value } => Self::PushBack { value: value.into() }, + VectorDiff::PopFront => Self::PopFront, + VectorDiff::PopBack => Self::PopBack, + VectorDiff::Insert { index, value } => { + Self::Insert { index: index as u32, value: value.into() } + } + VectorDiff::Set { index, value } => { + Self::Set { index: index as u32, value: value.into() } + } + VectorDiff::Remove { index } => Self::Remove { index: index as u32 }, + VectorDiff::Truncate { length } => Self::Truncate { length: length as u32 }, + VectorDiff::Reset { values } => { + Self::Reset { values: values.into_iter().map(|v| v.into()).collect() } + } + } + } +} + +#[uniffi::export(callback_interface)] +pub trait RoomDirectorySearchEntriesListener: Send + Sync + Debug { + fn on_update(&self, room_entries_update: Vec); +} diff --git a/crates/matrix-sdk/src/lib.rs b/crates/matrix-sdk/src/lib.rs index 63211aa91be..4134fa3c9a3 100644 --- a/crates/matrix-sdk/src/lib.rs +++ b/crates/matrix-sdk/src/lib.rs @@ -48,6 +48,7 @@ pub mod notification_settings; #[cfg(feature = "experimental-oidc")] pub mod oidc; pub mod room; +pub mod room_directory_search; pub mod utils; pub mod futures { //! Named futures returned from methods on types in [the crate root][crate]. diff --git a/crates/matrix-sdk/src/room_directory_search.rs b/crates/matrix-sdk/src/room_directory_search.rs new file mode 100644 index 00000000000..165ca52eae5 --- /dev/null +++ b/crates/matrix-sdk/src/room_directory_search.rs @@ -0,0 +1,477 @@ +// Copyright 2024 Mauro Romito +// Copyright 2024 The Matrix.org Foundation C.I.C. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! Types for searching the public room directory. + +use eyeball_im::{ObservableVector, VectorDiff}; +use futures_core::Stream; +use imbl::Vector; +use ruma::{ + api::client::directory::get_public_rooms_filtered::v3::Request as PublicRoomsFilterRequest, + directory::{Filter, PublicRoomJoinRule}, + OwnedMxcUri, OwnedRoomAliasId, OwnedRoomId, +}; + +use crate::{Client, Result}; + +/// This struct represents a single result of a room directory search. +/// +/// It's produced by [`RoomDirectorySearch::results`]. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct RoomDescription { + /// The room's ID. + pub room_id: OwnedRoomId, + /// The name of the room, if any. + pub name: Option, + /// The topic of the room, if any. + pub topic: Option, + /// The canonical alias of the room, if any. + pub alias: Option, + /// The room's avatar URL, if any. + pub avatar_url: Option, + /// The room's join rule. + pub join_rule: PublicRoomJoinRule, + /// Whether can be previewed + pub is_world_readable: bool, + /// The number of members that have joined the room. + pub joined_members: u64, +} + +impl From for RoomDescription { + fn from(value: ruma::directory::PublicRoomsChunk) -> Self { + Self { + room_id: value.room_id, + name: value.name, + topic: value.topic, + alias: value.canonical_alias, + avatar_url: value.avatar_url, + join_rule: value.join_rule, + is_world_readable: value.world_readable, + joined_members: value.num_joined_members.into(), + } + } +} + +#[derive(Default, Debug)] +enum SearchState { + /// The search has more pages and contains the next token to be used in the + /// next page request. + Next(String), + /// The search has reached the end. + End, + /// The search is in a starting state, and has yet to fetch the first page. + #[default] + Start, +} + +impl SearchState { + fn next_token(&self) -> Option<&str> { + if let Self::Next(next_token) = &self { + Some(next_token) + } else { + None + } + } + + fn is_at_end(&self) -> bool { + matches!(self, Self::End) + } +} + +/// `RoomDirectorySearch` allows searching the public room directory, with the +/// capability of using a filter and a batch_size. This struct is also +/// responsible for keeping the current state of the search, and exposing an +/// update of stream of the results, reset the search, or ask for the next page. +/// +/// ⚠️ Users must take great care when using the public room search since the +/// results might contains NSFW content. +/// +/// # Example +/// +/// ```no_run +/// use matrix_sdk::{room_directory_search::RoomDirectorySearch, Client}; +/// use url::Url; +/// +/// async { +/// let homeserver = Url::parse("http://localhost:8080")?; +/// let client = Client::new(homeserver).await?; +/// let mut room_directory_search = RoomDirectorySearch::new(client); +/// room_directory_search.search(None, 10).await?; +/// let (results, mut stream) = room_directory_search.results(); +/// room_directory_search.next_page().await?; +/// anyhow::Ok(()) +/// }; +/// ``` +#[derive(Debug)] +pub struct RoomDirectorySearch { + batch_size: u32, + filter: Option, + search_state: SearchState, + client: Client, + results: ObservableVector, +} + +impl RoomDirectorySearch { + /// Constructor for the `RoomDirectorySearch`, requires a `Client`. + pub fn new(client: Client) -> Self { + Self { + batch_size: 0, + filter: None, + search_state: Default::default(), + client, + results: ObservableVector::new(), + } + } + + /// Starts a filtered search for the server. + /// + /// If the `filter` is not provided it will search for all the rooms. + /// You can specify a `batch_size`` to control the number of rooms to fetch + /// per request. + /// + /// This method will clear the current search results and start a new one. + // Should never be used concurrently with another `next_page` or a + // `search`. + pub async fn search(&mut self, filter: Option, batch_size: u32) -> Result<()> { + self.filter = filter; + self.batch_size = batch_size; + self.search_state = Default::default(); + self.results.clear(); + self.next_page().await + } + + /// Asks the server for the next page of the current search. + // Should never be used concurrently with another `next_page` or a + // `search`. + pub async fn next_page(&mut self) -> Result<()> { + if self.search_state.is_at_end() { + return Ok(()); + } + + let mut filter = Filter::new(); + filter.generic_search_term = self.filter.clone(); + + let mut request = PublicRoomsFilterRequest::new(); + request.filter = filter; + request.limit = Some(self.batch_size.into()); + request.since = self.search_state.next_token().map(ToOwned::to_owned); + + let response = self.client.public_rooms_filtered(request).await?; + + if let Some(next_token) = response.next_batch { + self.search_state = SearchState::Next(next_token); + } else { + self.search_state = SearchState::End; + } + + self.results.append(response.chunk.into_iter().map(Into::into).collect()); + Ok(()) + } + + /// Get the initial values of the current stored room descriptions in the + /// search, and a stream of updates for them. + pub fn results( + &self, + ) -> (Vector, impl Stream>>) { + self.results.subscribe().into_values_and_batched_stream() + } + + /// Get the number of pages that have been loaded so far. + pub fn loaded_pages(&self) -> usize { + if self.batch_size == 0 { + return 0; + } + (self.results.len() as f64 / self.batch_size as f64).ceil() as usize + } + + /// Get whether the search is at the last page + pub fn is_at_last_page(&self) -> bool { + self.search_state.is_at_end() + } +} + +#[cfg(all(test, not(target_arch = "wasm32")))] +mod tests { + use assert_matches::assert_matches; + use eyeball_im::VectorDiff; + use futures_util::StreamExt; + use matrix_sdk_test::{async_test, test_json}; + use ruma::{directory::Filter, serde::Raw, RoomAliasId, RoomId}; + use serde_json::Value as JsonValue; + use stream_assert::assert_pending; + use wiremock::{ + matchers::{method, path_regex}, + Match, Mock, MockServer, Request, ResponseTemplate, + }; + + use crate::{ + room_directory_search::{RoomDescription, RoomDirectorySearch}, + test_utils::logged_in_client, + Client, + }; + + struct RoomDirectorySearchMatcher { + next_token: Option, + filter_term: Option, + limit: u32, + } + + impl Match for RoomDirectorySearchMatcher { + fn matches(&self, request: &Request) -> bool { + let Ok(body) = request.body_json::>() else { + return false; + }; + + // The body's `since` field is set equal to the matcher's next_token. + if !body.get_field::("since").is_ok_and(|s| s == self.next_token) { + return false; + } + + if !body.get_field::("limit").is_ok_and(|s| s == Some(self.limit)) { + return false; + } + + // The body's `filter` field has `generic_search_term` equal to the matcher's + // next_token. + if !body.get_field::("filter").is_ok_and(|s| { + if self.filter_term.is_none() { + s.is_none() || s.is_some_and(|s| s.generic_search_term.is_none()) + } else { + s.is_some_and(|s| s.generic_search_term == self.filter_term) + } + }) { + return false; + } + + method("POST").matches(request) + && path_regex("/_matrix/client/../publicRooms").matches(request) + } + } + + fn get_first_page_description() -> RoomDescription { + RoomDescription { + room_id: RoomId::parse("!ol19s:bleecker.street").unwrap(), + name: Some("CHEESE".into()), + topic: Some("Tasty tasty cheese".into()), + alias: None, + avatar_url: Some("mxc://bleeker.street/CHEDDARandBRIE".into()), + join_rule: ruma::directory::PublicRoomJoinRule::Public, + is_world_readable: true, + joined_members: 37, + } + } + + fn get_second_page_description() -> RoomDescription { + RoomDescription { + room_id: RoomId::parse("!ca18r:bleecker.street").unwrap(), + name: Some("PEAR".into()), + topic: Some("Tasty tasty pear".into()), + alias: RoomAliasId::parse("#murrays:pear.bar").ok(), + avatar_url: Some("mxc://bleeker.street/pear".into()), + join_rule: ruma::directory::PublicRoomJoinRule::Knock, + is_world_readable: false, + joined_members: 20, + } + } + + async fn new_server_and_client() -> (MockServer, Client) { + let server = MockServer::start().await; + let client = logged_in_client(Some(server.uri())).await; + (server, client) + } + + #[async_test] + async fn search_success() { + let (server, client) = new_server_and_client().await; + + let mut room_directory_search = RoomDirectorySearch::new(client); + Mock::given(RoomDirectorySearchMatcher { next_token: None, filter_term: None, limit: 1 }) + .respond_with(ResponseTemplate::new(200).set_body_json(&*test_json::PUBLIC_ROOMS)) + .mount(&server) + .await; + + room_directory_search.search(None, 1).await.unwrap(); + let (results, mut stream) = room_directory_search.results(); + assert_pending!(stream); + assert_eq!(results.len(), 1); + assert_eq!(results[0], get_first_page_description()); + assert!(!room_directory_search.is_at_last_page()); + assert_eq!(room_directory_search.loaded_pages(), 1); + } + + #[async_test] + async fn search_success_paginated() { + let (server, client) = new_server_and_client().await; + + let mut room_directory_search = RoomDirectorySearch::new(client); + Mock::given(RoomDirectorySearchMatcher { next_token: None, filter_term: None, limit: 1 }) + .respond_with(ResponseTemplate::new(200).set_body_json(&*test_json::PUBLIC_ROOMS)) + .mount(&server) + .await; + + room_directory_search.search(None, 1).await.unwrap(); + let (initial_results, mut stream) = room_directory_search.results(); + assert_eq!(initial_results, vec![get_first_page_description()].into()); + assert!(!room_directory_search.is_at_last_page()); + assert_eq!(room_directory_search.loaded_pages(), 1); + + Mock::given(RoomDirectorySearchMatcher { + next_token: Some("p190q".into()), + filter_term: None, + limit: 1, + }) + .respond_with( + ResponseTemplate::new(200).set_body_json(&*test_json::PUBLIC_ROOMS_FINAL_PAGE), + ) + .mount(&server) + .await; + + room_directory_search.next_page().await.unwrap(); + + let results_batch: Vec> = stream.next().await.unwrap(); + assert_matches!(&results_batch[0], VectorDiff::Append { values } => { assert_eq!(values, &vec![get_second_page_description()].into()); }); + assert!(room_directory_search.is_at_last_page()); + assert_eq!(room_directory_search.loaded_pages(), 2); + assert_pending!(stream); + } + + #[async_test] + async fn search_fails() { + let (server, client) = new_server_and_client().await; + + let mut room_directory_search = RoomDirectorySearch::new(client); + Mock::given(RoomDirectorySearchMatcher { next_token: None, filter_term: None, limit: 1 }) + .respond_with(ResponseTemplate::new(404)) + .mount(&server) + .await; + + assert!(room_directory_search.next_page().await.is_err()); + + let (results, mut stream) = room_directory_search.results(); + assert_eq!(results.len(), 0); + assert!(!room_directory_search.is_at_last_page()); + assert_eq!(room_directory_search.loaded_pages(), 0); + assert_pending!(stream); + } + + #[async_test] + async fn search_fails_when_paginating() { + let (server, client) = new_server_and_client().await; + + let mut room_directory_search = RoomDirectorySearch::new(client); + Mock::given(RoomDirectorySearchMatcher { next_token: None, filter_term: None, limit: 1 }) + .respond_with(ResponseTemplate::new(200).set_body_json(&*test_json::PUBLIC_ROOMS)) + .mount(&server) + .await; + + room_directory_search.search(None, 1).await.unwrap(); + + let (results, mut stream) = room_directory_search.results(); + assert_eq!(results, vec![get_first_page_description()].into()); + assert!(!room_directory_search.is_at_last_page()); + assert_eq!(room_directory_search.loaded_pages(), 1); + assert_pending!(stream); + + Mock::given(RoomDirectorySearchMatcher { + next_token: Some("p190q".into()), + filter_term: None, + limit: 1, + }) + .respond_with(ResponseTemplate::new(404)) + .mount(&server) + .await; + + assert!(room_directory_search.next_page().await.is_err()); + assert_eq!(results, vec![get_first_page_description()].into()); + assert!(!room_directory_search.is_at_last_page()); + assert_eq!(room_directory_search.loaded_pages(), 1); + assert_pending!(stream); + } + + #[async_test] + async fn search_success_paginated_with_filter() { + let (server, client) = new_server_and_client().await; + + let mut room_directory_search = RoomDirectorySearch::new(client); + Mock::given(RoomDirectorySearchMatcher { + next_token: None, + filter_term: Some("bleecker.street".into()), + limit: 1, + }) + .respond_with(ResponseTemplate::new(200).set_body_json(&*test_json::PUBLIC_ROOMS)) + .mount(&server) + .await; + + room_directory_search.search(Some("bleecker.street".into()), 1).await.unwrap(); + let (initial_results, mut stream) = room_directory_search.results(); + assert_eq!(initial_results, vec![get_first_page_description()].into()); + assert!(!room_directory_search.is_at_last_page()); + assert_eq!(room_directory_search.loaded_pages(), 1); + + Mock::given(RoomDirectorySearchMatcher { + next_token: Some("p190q".into()), + filter_term: Some("bleecker.street".into()), + limit: 1, + }) + .respond_with( + ResponseTemplate::new(200).set_body_json(&*test_json::PUBLIC_ROOMS_FINAL_PAGE), + ) + .mount(&server) + .await; + + room_directory_search.next_page().await.unwrap(); + + let results_batch: Vec> = stream.next().await.unwrap(); + assert_matches!(&results_batch[0], VectorDiff::Append { values } => { assert_eq!(values, &vec![get_second_page_description()].into()); }); + assert!(room_directory_search.is_at_last_page()); + assert_eq!(room_directory_search.loaded_pages(), 2); + assert_pending!(stream); + } + + #[async_test] + async fn search_followed_by_another_search_with_filter() { + let (server, client) = new_server_and_client().await; + + let mut room_directory_search = RoomDirectorySearch::new(client); + Mock::given(RoomDirectorySearchMatcher { next_token: None, filter_term: None, limit: 1 }) + .respond_with(ResponseTemplate::new(200).set_body_json(&*test_json::PUBLIC_ROOMS)) + .mount(&server) + .await; + + room_directory_search.search(None, 1).await.unwrap(); + let (initial_results, mut stream) = room_directory_search.results(); + assert_eq!(initial_results, vec![get_first_page_description()].into()); + assert!(!room_directory_search.is_at_last_page()); + assert_eq!(room_directory_search.loaded_pages(), 1); + + Mock::given(RoomDirectorySearchMatcher { + next_token: None, + filter_term: Some("bleecker.street".into()), + limit: 1, + }) + .respond_with(ResponseTemplate::new(200).set_body_json(&*test_json::PUBLIC_ROOMS)) + .mount(&server) + .await; + + room_directory_search.search(Some("bleecker.street".into()), 1).await.unwrap(); + + let results_batch: Vec> = stream.next().await.unwrap(); + assert_matches!(&results_batch[0], VectorDiff::Clear); + assert_matches!(&results_batch[1], VectorDiff::Append { values } => { assert_eq!(values, &vec![get_first_page_description()].into()); }); + assert!(!room_directory_search.is_at_last_page()); + assert_eq!(room_directory_search.loaded_pages(), 1); + assert_pending!(stream); + } +} diff --git a/testing/matrix-sdk-integration-testing/src/tests.rs b/testing/matrix-sdk-integration-testing/src/tests.rs index ee80945ad2f..507d8e4b6ab 100644 --- a/testing/matrix-sdk-integration-testing/src/tests.rs +++ b/testing/matrix-sdk-integration-testing/src/tests.rs @@ -4,4 +4,5 @@ mod invitations; mod reactions; mod redaction; mod repeated_join; +mod room_directory_search; mod sliding_sync; diff --git a/testing/matrix-sdk-integration-testing/src/tests/room_directory_search.rs b/testing/matrix-sdk-integration-testing/src/tests/room_directory_search.rs new file mode 100644 index 00000000000..ff3c3d35ba6 --- /dev/null +++ b/testing/matrix-sdk-integration-testing/src/tests/room_directory_search.rs @@ -0,0 +1,80 @@ +// Copyright 2024 Mauro Romito +// Copyright 2024 The Matrix.org Foundation C.I.C. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +use std::time::Duration; + +use anyhow::Result; +use assert_matches::assert_matches; +use eyeball_im::VectorDiff; +use futures::StreamExt; +use matrix_sdk::{ + room_directory_search::RoomDirectorySearch, + ruma::api::client::room::{create_room::v3::Request as CreateRoomRequest, Visibility}, +}; +use rand::{thread_rng, Rng}; +use stream_assert::assert_pending; +use tokio::time::sleep; +use tracing::warn; + +use crate::helpers::TestClientBuilder; + +#[tokio::test(flavor = "multi_thread")] +async fn test_room_directory_search_filter() -> Result<()> { + let alice = TestClientBuilder::new("alice".to_owned()).use_sqlite().build().await?; + let search_string = random_string(32); + for index in 0..25 { + let mut request: CreateRoomRequest = CreateRoomRequest::new(); + request.visibility = Visibility::Public; + let name = format!("{}_{}", search_string, index); + warn!("room name: {}", name); + request.name = Some(name); + alice.create_room(request).await?; + } + sleep(Duration::from_secs(1)).await; + let mut room_directory_search = RoomDirectorySearch::new(alice); + let (values, mut stream) = room_directory_search.results(); + assert!(values.is_empty()); + room_directory_search.search(Some(search_string), 10).await?; + let results_batch: Vec> = + stream.next().await.unwrap(); + assert_matches!(&results_batch[0], VectorDiff::Clear); + assert_matches!(&results_batch[1], VectorDiff::Append { values } => { assert_eq!(values.len(), 10); }); + + room_directory_search.next_page().await?; + room_directory_search.next_page().await?; + let results_batch = stream.next().await.unwrap(); + assert_eq!(results_batch.len(), 2); + assert_matches!(&results_batch[0], VectorDiff::Append { values } => { assert_eq!(values.len(), 10); }); + assert_matches!(&results_batch[1], VectorDiff::Append { values } => { assert_eq!(values.len(), 5); }); + assert_pending!(stream); + room_directory_search.next_page().await?; + assert_pending!(stream); + + // This should reset the state completely + room_directory_search.search(None, 25).await?; + let results_batch = stream.next().await.unwrap(); + assert_matches!(&results_batch[0], VectorDiff::Clear); + assert_matches!(&results_batch[1], VectorDiff::Append { values } => { assert_eq!(values.len(), 25); }); + assert_pending!(stream); + Ok(()) +} + +fn random_string(length: usize) -> String { + thread_rng() + .sample_iter(&rand::distributions::Alphanumeric) + .take(length) + .map(char::from) + .collect() +} diff --git a/testing/matrix-sdk-test/src/test_json/api_responses.rs b/testing/matrix-sdk-test/src/test_json/api_responses.rs index 8d052f209a7..cc0298ea234 100644 --- a/testing/matrix-sdk-test/src/test_json/api_responses.rs +++ b/testing/matrix-sdk-test/src/test_json/api_responses.rs @@ -239,6 +239,7 @@ pub static NOT_FOUND: Lazy = Lazy::new(|| { }); /// `GET /_matrix/client/v3/publicRooms` +/// `POST /_matrix/client/v3/publicRooms` pub static PUBLIC_ROOMS: Lazy = Lazy::new(|| { json!({ "chunk": [ @@ -261,6 +262,28 @@ pub static PUBLIC_ROOMS: Lazy = Lazy::new(|| { }) }); +/// `GET /_matrix/client/v3/publicRooms` +/// `POST /_matrix/client/v3/publicRooms`` +pub static PUBLIC_ROOMS_FINAL_PAGE: Lazy = Lazy::new(|| { + json!({ + "chunk": [ + { + "canonical_alias": "#murrays:pear.bar", + "avatar_url": "mxc://bleeker.street/pear", + "guest_can_join": false, + "name": "PEAR", + "num_joined_members": 20, + "room_id": "!ca18r:bleecker.street", + "topic": "Tasty tasty pear", + "world_readable": false, + "join_rule": "knock" + } + ], + "prev_batch": "p190q", + "total_room_count_estimate": 115 + }) +}); + /// `POST /_matrix/client/v3/refresh` without new refresh token. pub static REFRESH_TOKEN: Lazy = Lazy::new(|| { json!({ diff --git a/testing/matrix-sdk-test/src/test_json/mod.rs b/testing/matrix-sdk-test/src/test_json/mod.rs index e5a6a5255e1..9c29bba81a1 100644 --- a/testing/matrix-sdk-test/src/test_json/mod.rs +++ b/testing/matrix-sdk-test/src/test_json/mod.rs @@ -18,8 +18,8 @@ pub mod sync_events; pub use api_responses::{ DEVICES, GET_ALIAS, KEYS_QUERY, KEYS_QUERY_TWO_DEVICES_ONE_SIGNED, KEYS_UPLOAD, LOGIN, LOGIN_RESPONSE_ERR, LOGIN_TYPES, LOGIN_WITH_DISCOVERY, LOGIN_WITH_REFRESH_TOKEN, NOT_FOUND, - PUBLIC_ROOMS, REFRESH_TOKEN, REFRESH_TOKEN_WITH_REFRESH_TOKEN, REGISTRATION_RESPONSE_ERR, - UNKNOWN_TOKEN_SOFT_LOGOUT, VERSIONS, WELL_KNOWN, WHOAMI, + PUBLIC_ROOMS, PUBLIC_ROOMS_FINAL_PAGE, REFRESH_TOKEN, REFRESH_TOKEN_WITH_REFRESH_TOKEN, + REGISTRATION_RESPONSE_ERR, UNKNOWN_TOKEN_SOFT_LOGOUT, VERSIONS, WELL_KNOWN, WHOAMI, }; pub use members::MEMBERS; pub use sync::{