diff --git a/Cargo.lock b/Cargo.lock index 36fb56dec1a..ac2f7d71988 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3110,6 +3110,7 @@ dependencies = [ "mime2ext", "once_cell", "openidconnect", + "percent-encoding", "pin-project-lite", "proptest", "rand", diff --git a/bindings/matrix-sdk-ffi/src/client.rs b/bindings/matrix-sdk-ffi/src/client.rs index 54e6ac79bda..af8ddb4389e 100644 --- a/bindings/matrix-sdk-ffi/src/client.rs +++ b/bindings/matrix-sdk-ffi/src/client.rs @@ -1152,17 +1152,6 @@ impl Client { let alias = RoomAliasId::parse(alias)?; self.inner.is_room_alias_available(&alias).await.map_err(Into::into) } - - /// Creates a new room alias associated with the provided room id. - pub async fn create_room_alias( - &self, - room_alias: String, - room_id: String, - ) -> Result<(), ClientError> { - let room_alias = RoomAliasId::parse(room_alias)?; - let room_id = RoomId::parse(room_id)?; - self.inner.create_room_alias(&room_alias, &room_id).await.map_err(Into::into) - } } #[matrix_sdk_ffi_macros::export(callback_interface)] @@ -1462,6 +1451,9 @@ pub enum RoomVisibility { /// Indicates that the room will not be shown in the published room list. Private, + + /// A custom value that's not present in the spec. + Custom { value: String }, } impl From for Visibility { @@ -1469,6 +1461,17 @@ impl From for Visibility { match value { RoomVisibility::Public => Self::Public, RoomVisibility::Private => Self::Private, + RoomVisibility::Custom { value } => value.as_str().into(), + } + } +} + +impl From for RoomVisibility { + fn from(value: Visibility) -> Self { + match value { + Visibility::Public => Self::Public, + Visibility::Private => Self::Private, + _ => Self::Custom { value: value.as_str().to_owned() }, } } } diff --git a/bindings/matrix-sdk-ffi/src/error.rs b/bindings/matrix-sdk-ffi/src/error.rs index 9d793b07020..5ce1bf48e33 100644 --- a/bindings/matrix-sdk-ffi/src/error.rs +++ b/bindings/matrix-sdk-ffi/src/error.rs @@ -155,6 +155,12 @@ impl From for ClientError { } } +impl From for ClientError { + fn from(_: NotYetImplemented) -> Self { + Self::new("This functionality is not implemented yet.") + } +} + /// Bindings version of the sdk type replacing OwnedUserId/DeviceIds with simple /// String. /// diff --git a/bindings/matrix-sdk-ffi/src/room.rs b/bindings/matrix-sdk-ffi/src/room.rs index e76395b381e..62f7b678021 100644 --- a/bindings/matrix-sdk-ffi/src/room.rs +++ b/bindings/matrix-sdk-ffi/src/room.rs @@ -20,7 +20,8 @@ use ruma::{ call::notify, room::{ avatar::ImageInfo as RumaAvatarImageInfo, - message::RoomMessageEventContentWithoutRelation, + history_visibility::HistoryVisibility as RumaHistoryVisibility, + join_rules::JoinRule as RumaJoinRule, message::RoomMessageEventContentWithoutRelation, power_levels::RoomPowerLevels as RumaPowerLevels, MediaSource, }, AnyMessageLikeEventContent, AnySyncTimelineEvent, TimelineEventType, @@ -33,7 +34,8 @@ use tracing::error; use super::RUNTIME; use crate::{ chunk_iterator::ChunkIterator, - error::{ClientError, MediaInfoError, RoomError}, + client::{JoinRule, RoomVisibility}, + error::{ClientError, MediaInfoError, NotYetImplemented, RoomError}, event::{MessageLikeEventType, RoomMessageEventMessageType, StateEventType}, identity_status_change::IdentityStatusChange, room_info::RoomInfo, @@ -345,7 +347,7 @@ impl Room { } pub async fn room_info(&self) -> Result { - Ok(RoomInfo::new(&self.inner).await?) + RoomInfo::new(&self.inner).await } pub fn subscribe_to_room_info_updates( @@ -941,6 +943,105 @@ impl Room { let (cache, _drop_guards) = self.inner.event_cache().await?; Ok(cache.debug_string().await) } + + /// Update the canonical alias of the room. + /// + /// Note that publishing the alias in the room directory is done separately. + pub async fn update_canonical_alias( + &self, + alias: Option, + alt_aliases: Vec, + ) -> Result<(), ClientError> { + let new_alias = alias.map(TryInto::try_into).transpose()?; + let new_alt_aliases = + alt_aliases.into_iter().map(RoomAliasId::parse).collect::>()?; + self.inner + .privacy_settings() + .update_canonical_alias(new_alias, new_alt_aliases) + .await + .map_err(Into::into) + } + + /// Publish a new room alias for this room in the room directory. + /// + /// Returns: + /// - `true` if the room alias didn't exist and it's now published. + /// - `false` if the room alias was already present so it couldn't be + /// published. + pub async fn publish_room_alias_in_room_directory( + &self, + alias: String, + ) -> Result { + let new_alias = RoomAliasId::parse(alias)?; + self.inner + .privacy_settings() + .publish_room_alias_in_room_directory(&new_alias) + .await + .map_err(Into::into) + } + + /// Remove an existing room alias for this room in the room directory. + /// + /// Returns: + /// - `true` if the room alias was present and it's now removed from the + /// room directory. + /// - `false` if the room alias didn't exist so it couldn't be removed. + pub async fn remove_room_alias_from_room_directory( + &self, + alias: String, + ) -> Result { + let alias = RoomAliasId::parse(alias)?; + self.inner + .privacy_settings() + .remove_room_alias_from_room_directory(&alias) + .await + .map_err(Into::into) + } + + /// Enable End-to-end encryption in this room. + pub async fn enable_encryption(&self) -> Result<(), ClientError> { + self.inner.enable_encryption().await.map_err(Into::into) + } + + /// Update room history visibility for this room. + pub async fn update_history_visibility( + &self, + visibility: RoomHistoryVisibility, + ) -> Result<(), ClientError> { + let visibility: RumaHistoryVisibility = visibility.try_into()?; + self.inner + .privacy_settings() + .update_room_history_visibility(visibility) + .await + .map_err(Into::into) + } + + /// Update the join rule for this room. + pub async fn update_join_rules(&self, new_rule: JoinRule) -> Result<(), ClientError> { + let new_rule: RumaJoinRule = new_rule.try_into()?; + self.inner.privacy_settings().update_join_rule(new_rule).await.map_err(Into::into) + } + + /// Update the room's visibility in the room directory. + pub async fn update_room_visibility( + &self, + visibility: RoomVisibility, + ) -> Result<(), ClientError> { + self.inner + .privacy_settings() + .update_room_visibility(visibility.into()) + .await + .map_err(Into::into) + } + + /// Returns the visibility for this room in the room directory. + /// + /// [Public](`RoomVisibility::Public`) rooms are listed in the room + /// directory and can be found using it. + pub async fn get_room_visibility(&self) -> Result { + let visibility = self.inner.privacy_settings().get_room_visibility().await?; + Ok(visibility.into()) + } } impl From for KnockRequest { @@ -1254,3 +1355,62 @@ impl TryFrom for SdkComposerDraftType { Ok(draft_type) } } + +#[derive(Debug, Clone, uniffi::Enum)] +pub enum RoomHistoryVisibility { + /// Previous events are accessible to newly joined members from the point + /// they were invited onwards. + /// + /// Events stop being accessible when the member's state changes to + /// something other than *invite* or *join*. + Invited, + + /// Previous events are accessible to newly joined members from the point + /// they joined the room onwards. + /// Events stop being accessible when the member's state changes to + /// something other than *join*. + Joined, + + /// Previous events are always accessible to newly joined members. + /// + /// All events in the room are accessible, even those sent when the member + /// was not a part of the room. + Shared, + + /// All events while this is the `HistoryVisibility` value may be shared by + /// any participating homeserver with anyone, regardless of whether they + /// have ever joined the room. + WorldReadable, + + /// A custom visibility value. + Custom { value: String }, +} + +impl TryFrom for RoomHistoryVisibility { + type Error = NotYetImplemented; + fn try_from(value: RumaHistoryVisibility) -> Result { + match value { + RumaHistoryVisibility::Invited => Ok(RoomHistoryVisibility::Invited), + RumaHistoryVisibility::Shared => Ok(RoomHistoryVisibility::Shared), + RumaHistoryVisibility::WorldReadable => Ok(RoomHistoryVisibility::WorldReadable), + RumaHistoryVisibility::Joined => Ok(RoomHistoryVisibility::Joined), + RumaHistoryVisibility::_Custom(_) => { + Ok(RoomHistoryVisibility::Custom { value: value.to_string() }) + } + _ => Err(NotYetImplemented), + } + } +} + +impl TryFrom for RumaHistoryVisibility { + type Error = NotYetImplemented; + fn try_from(value: RoomHistoryVisibility) -> Result { + match value { + RoomHistoryVisibility::Invited => Ok(RumaHistoryVisibility::Invited), + RoomHistoryVisibility::Shared => Ok(RumaHistoryVisibility::Shared), + RoomHistoryVisibility::Joined => Ok(RumaHistoryVisibility::Joined), + RoomHistoryVisibility::WorldReadable => Ok(RumaHistoryVisibility::WorldReadable), + RoomHistoryVisibility::Custom { .. } => Err(NotYetImplemented), + } + } +} diff --git a/bindings/matrix-sdk-ffi/src/room_info.rs b/bindings/matrix-sdk-ffi/src/room_info.rs index 1b0f27e7114..1ed6a48ddb9 100644 --- a/bindings/matrix-sdk-ffi/src/room_info.rs +++ b/bindings/matrix-sdk-ffi/src/room_info.rs @@ -5,8 +5,9 @@ use tracing::warn; use crate::{ client::JoinRule, + error::ClientError, notification_settings::RoomNotificationMode, - room::{Membership, RoomHero}, + room::{Membership, RoomHero, RoomHistoryVisibility}, room_member::RoomMember, }; @@ -60,10 +61,12 @@ pub struct RoomInfo { pinned_event_ids: Vec, /// The join rule for this room, if known. join_rule: Option, + /// The history visibility for this room, if known. + history_visibility: RoomHistoryVisibility, } impl RoomInfo { - pub(crate) async fn new(room: &matrix_sdk::Room) -> matrix_sdk::Result { + pub(crate) async fn new(room: &matrix_sdk::Room) -> Result { let unread_notification_counts = room.unread_notification_counts(); let power_levels_map = room.users_with_power_levels().await; @@ -128,6 +131,7 @@ impl RoomInfo { num_unread_mentions: room.num_unread_mentions(), pinned_event_ids, join_rule: join_rule.ok(), + history_visibility: room.history_visibility_or_default().try_into()?, }) } } diff --git a/bindings/matrix-sdk-ffi/src/room_list.rs b/bindings/matrix-sdk-ffi/src/room_list.rs index 1c193b0ac8d..09e49da741d 100644 --- a/bindings/matrix-sdk-ffi/src/room_list.rs +++ b/bindings/matrix-sdk-ffi/src/room_list.rs @@ -566,7 +566,7 @@ impl RoomListItem { } async fn room_info(&self) -> Result { - Ok(RoomInfo::new(self.inner.inner_room()).await?) + RoomInfo::new(self.inner.inner_room()).await } /// The room's current membership state. diff --git a/crates/matrix-sdk/CHANGELOG.md b/crates/matrix-sdk/CHANGELOG.md index 6673f1e2a9b..a07382e6940 100644 --- a/crates/matrix-sdk/CHANGELOG.md +++ b/crates/matrix-sdk/CHANGELOG.md @@ -15,6 +15,7 @@ All notable changes to this project will be documented in this file. - Expose `Client::server_versions()` publicly to allow users of the library to get the versions of Matrix supported by the homeserver. ([#4519](https://github.com/matrix-org/matrix-rust-sdk/pull/4519)) +- Create `RoomPrivacySettings` helper to group room settings functionality related to room access and visibility ([#4401](https://github.com/matrix-org/matrix-rust-sdk/pull/4401)). ### Refactor diff --git a/crates/matrix-sdk/Cargo.toml b/crates/matrix-sdk/Cargo.toml index 9a1b62dbb1e..a62365f63d1 100644 --- a/crates/matrix-sdk/Cargo.toml +++ b/crates/matrix-sdk/Cargo.toml @@ -94,6 +94,7 @@ matrix-sdk-test = { workspace = true, optional = true } mime = { workspace = true } mime2ext = "0.1.53" once_cell = { workspace = true } +percent-encoding = "2.3.1" pin-project-lite = { workspace = true } rand = { workspace = true , optional = true } ruma = { workspace = true, features = [ diff --git a/crates/matrix-sdk/src/client/mod.rs b/crates/matrix-sdk/src/client/mod.rs index 5c9396105e3..f2daad94aee 100644 --- a/crates/matrix-sdk/src/client/mod.rs +++ b/crates/matrix-sdk/src/client/mod.rs @@ -41,7 +41,7 @@ use ruma::{ api::{ client::{ account::whoami, - alias::{create_alias, get_alias}, + alias::{create_alias, delete_alias, get_alias}, device::{delete_devices, get_devices, update_device}, directory::{get_public_rooms, get_public_rooms_filtered}, discovery::{ @@ -1201,13 +1201,20 @@ impl Client { } } - /// Creates a new room alias associated with a room. + /// Adds a new room alias associated with a room to the room directory. pub async fn create_room_alias(&self, alias: &RoomAliasId, room_id: &RoomId) -> HttpResult<()> { let request = create_alias::v3::Request::new(alias.to_owned(), room_id.to_owned()); self.send(request).await?; Ok(()) } + /// Removes a room alias from the room directory. + pub async fn remove_room_alias(&self, alias: &RoomAliasId) -> HttpResult<()> { + let request = delete_alias::v3::Request::new(alias.to_owned()); + self.send(request).await?; + Ok(()) + } + /// Update the homeserver from the login response well-known if needed. /// /// # Arguments @@ -3163,7 +3170,7 @@ pub(crate) mod tests { let server = MatrixMockServer::new().await; let client = server.client_builder().build().await; - server.mock_create_room_alias().ok().expect(1).mount().await; + server.mock_room_directory_create_room_alias().ok().expect(1).mount().await; let ret = client .create_room_alias( diff --git a/crates/matrix-sdk/src/room/mod.rs b/crates/matrix-sdk/src/room/mod.rs index 6ea0470e7a9..28ca6523da4 100644 --- a/crates/matrix-sdk/src/room/mod.rs +++ b/crates/matrix-sdk/src/room/mod.rs @@ -145,6 +145,7 @@ use crate::{ room::{ knock_requests::{KnockRequest, KnockRequestMemberInfo}, power_levels::{RoomPowerLevelChanges, RoomPowerLevelsExt}, + privacy_settings::RoomPrivacySettings, }, sync::RoomUpdate, utils::{IntoRawMessageLikeEventContent, IntoRawStateEventContent}, @@ -162,6 +163,9 @@ mod member; mod messages; pub mod power_levels; +/// Contains all the functionality for modifying the privacy settings in a room. +pub mod privacy_settings; + /// A struct containing methods that are common for Joined, Invited and Left /// Rooms #[derive(Debug, Clone)] @@ -3357,6 +3361,11 @@ impl Room { }) .collect()) } + + /// Access the room settings related to privacy and visibility. + pub fn privacy_settings(&self) -> RoomPrivacySettings<'_> { + RoomPrivacySettings::new(&self.inner, &self.client) + } } #[cfg(all(feature = "e2e-encryption", not(target_arch = "wasm32")))] diff --git a/crates/matrix-sdk/src/room/privacy_settings.rs b/crates/matrix-sdk/src/room/privacy_settings.rs new file mode 100644 index 00000000000..8964bfd35d3 --- /dev/null +++ b/crates/matrix-sdk/src/room/privacy_settings.rs @@ -0,0 +1,423 @@ +use matrix_sdk_base::Room as BaseRoom; +use ruma::{ + api::client::{ + directory::{get_room_visibility, set_room_visibility}, + room::Visibility, + state::send_state_event, + }, + assign, + events::{ + room::{ + canonical_alias::RoomCanonicalAliasEventContent, + history_visibility::{HistoryVisibility, RoomHistoryVisibilityEventContent}, + join_rules::{JoinRule, RoomJoinRulesEventContent}, + }, + EmptyStateKey, + }, + OwnedRoomAliasId, RoomAliasId, +}; + +use crate::{Client, Result}; + +/// A helper to group the methods in [Room](crate::Room) related to the room's +/// visibility and access. +#[derive(Debug)] +pub struct RoomPrivacySettings<'a> { + room: &'a BaseRoom, + client: &'a Client, +} + +impl<'a> RoomPrivacySettings<'a> { + pub(crate) fn new(room: &'a BaseRoom, client: &'a Client) -> Self { + Self { room, client } + } + + /// Publish a new room alias for this room in the room directory. + /// + /// Returns: + /// - `true` if the room alias didn't exist and it's now published. + /// - `false` if the room alias was already present so it couldn't be + /// published. + pub async fn publish_room_alias_in_room_directory( + &'a self, + alias: &RoomAliasId, + ) -> Result { + if self.client.is_room_alias_available(alias).await? { + self.client.create_room_alias(alias, self.room.room_id()).await?; + return Ok(true); + } + + Ok(false) + } + + /// Remove an existing room alias for this room in the room directory. + /// + /// Returns: + /// - `true` if the room alias was present and it's now removed from the + /// room directory. + /// - `false` if the room alias didn't exist so it couldn't be removed. + pub async fn remove_room_alias_from_room_directory( + &'a self, + alias: &RoomAliasId, + ) -> Result { + if self.client.resolve_room_alias(alias).await.is_ok() { + self.client.remove_room_alias(alias).await?; + return Ok(true); + } + + Ok(false) + } + + /// Update the canonical alias of the room. + /// + /// # Arguments: + /// * `alias` - The new main alias to use for the room. A `None` value + /// removes the existing main canonical alias. + /// * `alt_aliases` - The list of alternative aliases for this room. + /// + /// See for more info about the canonical alias. + /// + /// Note that publishing the alias in the room directory is done separately, + /// and a room alias must have already been published before it can be set + /// as the canonical alias. + pub async fn update_canonical_alias( + &'a self, + alias: Option, + alt_aliases: Vec, + ) -> Result<()> { + // Create a new alias event combining both the new and previous values + let content = assign!( + RoomCanonicalAliasEventContent::new(), + { alias, alt_aliases } + ); + + // Send the state event + let request = send_state_event::v3::Request::new( + self.room.room_id().to_owned(), + &EmptyStateKey, + &content, + )?; + self.client.send(request).await?; + + Ok(()) + } + + /// Update room history visibility for this room. + /// + /// The history visibility controls whether a user can see the events that + /// happened in a room before they joined. + /// + /// See for more info. + pub async fn update_room_history_visibility( + &'a self, + new_value: HistoryVisibility, + ) -> Result<()> { + let request = send_state_event::v3::Request::new( + self.room.room_id().to_owned(), + &EmptyStateKey, + &RoomHistoryVisibilityEventContent::new(new_value), + )?; + self.client.send(request).await?; + Ok(()) + } + + /// Update the join rule for this room. + /// + /// The join rules controls if and how a new user can get access to the + /// room. + /// + /// See for more info. + pub async fn update_join_rule(&'a self, new_rule: JoinRule) -> Result<()> { + let request = send_state_event::v3::Request::new( + self.room.room_id().to_owned(), + &EmptyStateKey, + &RoomJoinRulesEventContent::new(new_rule), + )?; + self.client.send(request).await?; + Ok(()) + } + + /// Returns the visibility for this room in the room directory. + /// + /// [Public](`Visibility::Public`) rooms are listed in the room directory + /// and can be found using it. + pub async fn get_room_visibility(&'a self) -> Result { + let request = get_room_visibility::v3::Request::new(self.room.room_id().to_owned()); + let response = self.client.send(request).await?; + Ok(response.visibility) + } + + /// Update the visibility for this room in the room directory. + /// + /// [Public](`Visibility::Public`) rooms are listed in the room directory + /// and can be found using it. + pub async fn update_room_visibility(&'a self, visibility: Visibility) -> Result<()> { + let request = + set_room_visibility::v3::Request::new(self.room.room_id().to_owned(), visibility); + + self.client.send(request).await?; + + Ok(()) + } +} + +#[cfg(all(test, not(target_arch = "wasm32")))] +mod tests { + use std::ops::Not; + + use matrix_sdk_test::{async_test, JoinedRoomBuilder, StateTestEvent}; + use ruma::{ + api::client::room::Visibility, + event_id, + events::{ + room::{history_visibility::HistoryVisibility, join_rules::JoinRule}, + StateEventType, + }, + owned_room_alias_id, room_id, + }; + + use crate::test_utils::mocks::MatrixMockServer; + + #[async_test] + async fn test_publish_room_alias_to_room_directory() { + let server = MatrixMockServer::new().await; + let client = server.client_builder().build().await; + + let room_id = room_id!("!a:b.c"); + let room = server.sync_joined_room(&client, room_id).await; + + let room_alias = owned_room_alias_id!("#a:b.c"); + + // First we'd check if the new alias needs to be created + server + .mock_room_directory_resolve_alias() + .for_alias(room_alias.to_string()) + .not_found() + .mock_once() + .mount() + .await; + + // After that, we'd create a new room alias association in the room directory + server.mock_room_directory_create_room_alias().ok().mock_once().mount().await; + + let published = room + .privacy_settings() + .publish_room_alias_in_room_directory(&room_alias) + .await + .expect("we should get a result value, not an error"); + assert!(published); + } + + #[async_test] + async fn test_publish_room_alias_to_room_directory_when_alias_exists() { + let server = MatrixMockServer::new().await; + let client = server.client_builder().build().await; + + let room_id = room_id!("!a:b.c"); + let room = server.sync_joined_room(&client, room_id).await; + + let room_alias = owned_room_alias_id!("#a:b.c"); + + // First we'd check if the new alias needs to be created. It does not. + server + .mock_room_directory_resolve_alias() + .for_alias(room_alias.to_string()) + .ok(room_id.as_ref(), Vec::new()) + .mock_once() + .mount() + .await; + + // Since the room alias already exists we won't create it again. + server.mock_room_directory_create_room_alias().ok().never().mount().await; + + let published = room + .privacy_settings() + .publish_room_alias_in_room_directory(&room_alias) + .await + .expect("we should get a result value, not an error"); + assert!(published.not()); + } + + #[async_test] + async fn test_remove_room_alias() { + let server = MatrixMockServer::new().await; + let client = server.client_builder().build().await; + + let room_id = room_id!("!a:b.c"); + let joined_room_builder = + JoinedRoomBuilder::new(room_id).add_state_event(StateTestEvent::Alias); + let room = server.sync_room(&client, joined_room_builder).await; + + let room_alias = owned_room_alias_id!("#a:b.c"); + + // First we'd check if the alias exists + server + .mock_room_directory_resolve_alias() + .for_alias(room_alias.to_string()) + .ok(room_id.as_ref(), Vec::new()) + .mock_once() + .mount() + .await; + + // After that we'd remove it + server.mock_room_directory_remove_room_alias().ok().mock_once().mount().await; + + let removed = room + .privacy_settings() + .remove_room_alias_from_room_directory(&room_alias) + .await + .expect("we should get a result value, not an error"); + assert!(removed); + } + + #[async_test] + async fn test_remove_room_alias_if_it_does_not_exist() { + let server = MatrixMockServer::new().await; + let client = server.client_builder().build().await; + + let room_id = room_id!("!a:b.c"); + let joined_room_builder = + JoinedRoomBuilder::new(room_id).add_state_event(StateTestEvent::Alias); + let room = server.sync_room(&client, joined_room_builder).await; + + let room_alias = owned_room_alias_id!("#a:b.c"); + + // First we'd check if the alias exists. It doesn't. + server + .mock_room_directory_resolve_alias() + .for_alias(room_alias.to_string()) + .not_found() + .mock_once() + .mount() + .await; + + // So we can't remove it after the check. + server.mock_room_directory_remove_room_alias().ok().never().mount().await; + + let removed = room + .privacy_settings() + .remove_room_alias_from_room_directory(&room_alias) + .await + .expect("we should get a result value, not an error"); + assert!(removed.not()); + } + + #[async_test] + async fn test_update_canonical_alias_with_some_value() { + let server = MatrixMockServer::new().await; + let client = server.client_builder().build().await; + + let room_id = room_id!("!a:b.c"); + let room = server.sync_joined_room(&client, room_id).await; + + server + .mock_room_send_state() + .for_type(StateEventType::RoomCanonicalAlias) + .ok(event_id!("$a:b.c")) + .mock_once() + .mount() + .await; + + let room_alias = owned_room_alias_id!("#a:b.c"); + let ret = room + .privacy_settings() + .update_canonical_alias(Some(room_alias.clone()), Vec::new()) + .await; + assert!(ret.is_ok()); + } + + #[async_test] + async fn test_update_canonical_alias_with_no_value() { + let server = MatrixMockServer::new().await; + let client = server.client_builder().build().await; + + let room_id = room_id!("!a:b.c"); + let room = server.sync_joined_room(&client, room_id).await; + + server + .mock_room_send_state() + .for_type(StateEventType::RoomCanonicalAlias) + .ok(event_id!("$a:b.c")) + .mock_once() + .mount() + .await; + + let ret = room.privacy_settings().update_canonical_alias(None, Vec::new()).await; + assert!(ret.is_ok()); + } + + #[async_test] + async fn test_update_room_history_visibility() { + let server = MatrixMockServer::new().await; + let client = server.client_builder().build().await; + + let room_id = room_id!("!a:b.c"); + let room = server.sync_joined_room(&client, room_id).await; + + server + .mock_room_send_state() + .for_type(StateEventType::RoomHistoryVisibility) + .ok(event_id!("$a:b.c")) + .mock_once() + .mount() + .await; + + let ret = + room.privacy_settings().update_room_history_visibility(HistoryVisibility::Joined).await; + assert!(ret.is_ok()); + } + + #[async_test] + async fn test_update_join_rule() { + let server = MatrixMockServer::new().await; + let client = server.client_builder().build().await; + + let room_id = room_id!("!a:b.c"); + let room = server.sync_joined_room(&client, room_id).await; + + server + .mock_room_send_state() + .for_type(StateEventType::RoomJoinRules) + .ok(event_id!("$a:b.c")) + .mock_once() + .mount() + .await; + + let ret = room.privacy_settings().update_join_rule(JoinRule::Public).await; + assert!(ret.is_ok()); + } + + #[async_test] + async fn test_get_room_visibility() { + let server = MatrixMockServer::new().await; + let client = server.client_builder().build().await; + + let room_id = room_id!("!a:b.c"); + let room = server.sync_joined_room(&client, room_id).await; + + server + .mock_room_send_state() + .for_type(StateEventType::RoomJoinRules) + .ok(event_id!("$a:b.c")) + .mock_once() + .mount() + .await; + + let ret = room.privacy_settings().update_join_rule(JoinRule::Public).await; + assert!(ret.is_ok()); + } + + #[async_test] + async fn test_update_room_visibility() { + let server = MatrixMockServer::new().await; + let client = server.client_builder().build().await; + + let room_id = room_id!("!a:b.c"); + let room = server.sync_joined_room(&client, room_id).await; + + server.mock_room_directory_set_room_visibility().ok().mock_once().mount().await; + + let ret = room.privacy_settings().update_room_visibility(Visibility::Private).await; + assert!(ret.is_ok()); + } +} diff --git a/crates/matrix-sdk/src/test_utils/mocks.rs b/crates/matrix-sdk/src/test_utils/mocks.rs index 4891a76bdef..45557784a58 100644 --- a/crates/matrix-sdk/src/test_utils/mocks.rs +++ b/crates/matrix-sdk/src/test_utils/mocks.rs @@ -27,7 +27,9 @@ use matrix_sdk_test::{ test_json, InvitedRoomBuilder, JoinedRoomBuilder, KnockedRoomBuilder, LeftRoomBuilder, SyncResponseBuilder, }; +use percent_encoding::{AsciiSet, CONTROLS}; use ruma::{ + api::client::room::Visibility, directory::PublicRoomsChunk, events::{ room::member::RoomMemberEvent, AnyStateEvent, AnyTimelineEvent, MessageLikeEventType, @@ -509,19 +511,109 @@ impl MatrixMockServer { } /// Create a prebuilt mock for resolving room aliases. + /// + /// # Examples + /// + /// ``` + /// # tokio_test::block_on(async { + /// use matrix_sdk::{ + /// ruma::{owned_room_id, room_alias_id}, + /// test_utils::mocks::MatrixMockServer, + /// }; + /// let mock_server = MatrixMockServer::new().await; + /// let client = mock_server.client_builder().build().await; + /// + /// mock_server + /// .mock_room_directory_resolve_alias() + /// .ok("!a:b.c", Vec::new()) + /// .mock_once() + /// .mount() + /// .await; + /// + /// let res = client + /// .resolve_room_alias(room_alias_id!("#a:b.c")) + /// .await + /// .expect("We should be able to resolve the room alias"); + /// assert_eq!(res.room_id, owned_room_id!("!a:b.c")); + /// # anyhow::Ok(()) }); + /// ``` pub fn mock_room_directory_resolve_alias(&self) -> MockEndpoint<'_, ResolveRoomAliasEndpoint> { let mock = Mock::given(method("GET")).and(path_regex(r"/_matrix/client/v3/directory/room/.*")); MockEndpoint { mock, server: &self.server, endpoint: ResolveRoomAliasEndpoint } } - /// Create a prebuilt mock for creating room aliases. - pub fn mock_create_room_alias(&self) -> MockEndpoint<'_, CreateRoomAliasEndpoint> { + /// Create a prebuilt mock for publishing room aliases in the room + /// directory. + /// + /// # Examples + /// + /// ``` + /// # tokio_test::block_on(async { + /// use matrix_sdk::{ + /// ruma::{room_alias_id, room_id}, + /// test_utils::mocks::MatrixMockServer, + /// }; + /// + /// let mock_server = MatrixMockServer::new().await; + /// let client = mock_server.client_builder().build().await; + /// + /// mock_server + /// .mock_room_directory_create_room_alias() + /// .ok() + /// .mock_once() + /// .mount() + /// .await; + /// + /// client + /// .create_room_alias(room_alias_id!("#a:b.c"), room_id!("!a:b.c")) + /// .await + /// .expect("We should be able to create a room alias"); + /// # anyhow::Ok(()) }); + /// ``` + pub fn mock_room_directory_create_room_alias( + &self, + ) -> MockEndpoint<'_, CreateRoomAliasEndpoint> { let mock = Mock::given(method("PUT")).and(path_regex(r"/_matrix/client/v3/directory/room/.*")); MockEndpoint { mock, server: &self.server, endpoint: CreateRoomAliasEndpoint } } + /// Create a prebuilt mock for removing room aliases from the room + /// directory. + /// + /// # Examples + /// + /// ``` + /// # tokio_test::block_on(async { + /// use matrix_sdk::{ + /// ruma::room_alias_id, test_utils::mocks::MatrixMockServer, + /// }; + /// + /// let mock_server = MatrixMockServer::new().await; + /// let client = mock_server.client_builder().build().await; + /// + /// mock_server + /// .mock_room_directory_remove_room_alias() + /// .ok() + /// .mock_once() + /// .mount() + /// .await; + /// + /// client + /// .remove_room_alias(room_alias_id!("#a:b.c")) + /// .await + /// .expect("We should be able to remove the room alias"); + /// # anyhow::Ok(()) }); + /// ``` + pub fn mock_room_directory_remove_room_alias( + &self, + ) -> MockEndpoint<'_, RemoveRoomAliasEndpoint> { + let mock = + Mock::given(method("DELETE")).and(path_regex(r"/_matrix/client/v3/directory/room/.*")); + MockEndpoint { mock, server: &self.server, endpoint: RemoveRoomAliasEndpoint } + } + /// Create a prebuilt mock for listing public rooms. /// /// # Examples @@ -565,6 +657,84 @@ impl MatrixMockServer { MockEndpoint { mock, server: &self.server, endpoint: PublicRoomsEndpoint } } + /// Create a prebuilt mock for setting a room's visibility in the room + /// directory. + /// + /// # Examples + /// + /// ``` + /// # tokio_test::block_on(async { + /// use matrix_sdk::{ruma::room_id, test_utils::mocks::MatrixMockServer}; + /// use ruma::api::client::room::Visibility; + /// + /// let mock_server = MatrixMockServer::new().await; + /// let client = mock_server.client_builder().build().await; + /// + /// mock_server + /// .mock_room_directory_set_room_visibility() + /// .ok() + /// .mock_once() + /// .mount() + /// .await; + /// + /// let room = mock_server + /// .sync_joined_room(&client, room_id!("!room_id:localhost")) + /// .await; + /// + /// room.privacy_settings() + /// .update_room_visibility(Visibility::Private) + /// .await + /// .expect("We should be able to update the room's visibility"); + /// # anyhow::Ok(()) }); + /// ``` + pub fn mock_room_directory_set_room_visibility( + &self, + ) -> MockEndpoint<'_, SetRoomVisibilityEndpoint> { + let mock = Mock::given(method("PUT")) + .and(path_regex(r"^/_matrix/client/v3/directory/list/room/.*$")); + MockEndpoint { mock, server: &self.server, endpoint: SetRoomVisibilityEndpoint } + } + + /// Create a prebuilt mock for getting a room's visibility in the room + /// directory. + /// + /// # Examples + /// + /// ``` + /// # tokio_test::block_on(async { + /// use matrix_sdk::{ruma::room_id, test_utils::mocks::MatrixMockServer}; + /// use ruma::api::client::room::Visibility; + /// + /// let mock_server = MatrixMockServer::new().await; + /// let client = mock_server.client_builder().build().await; + /// + /// mock_server + /// .mock_room_directory_get_room_visibility() + /// .ok(Visibility::Public) + /// .mock_once() + /// .mount() + /// .await; + /// + /// let room = mock_server + /// .sync_joined_room(&client, room_id!("!room_id:localhost")) + /// .await; + /// + /// let visibility = room + /// .privacy_settings() + /// .get_room_visibility() + /// .await + /// .expect("We should be able to get the room's visibility"); + /// assert_eq!(visibility, Visibility::Public); + /// # anyhow::Ok(()) }); + /// ``` + pub fn mock_room_directory_get_room_visibility( + &self, + ) -> MockEndpoint<'_, GetRoomVisibilityEndpoint> { + let mock = Mock::given(method("GET")) + .and(path_regex(r"^/_matrix/client/v3/directory/list/room/.*$")); + MockEndpoint { mock, server: &self.server, endpoint: GetRoomVisibilityEndpoint } + } + /// Create a prebuilt mock for fetching information about key storage /// backups. /// @@ -792,6 +962,29 @@ impl From for AnyRoomBuilder { } } +/// The [path percent-encode set] as defined in the WHATWG URL standard + `/` +/// since we always encode single segments of the path. +/// +/// [path percent-encode set]: https://url.spec.whatwg.org/#path-percent-encode-set +/// +/// Copied from Ruma: +/// https://github.com/ruma/ruma/blob/e4cb409ff3aaa16f31a7fe1e61fee43b2d144f7b/crates/ruma-common/src/percent_encode.rs#L7 +const PATH_PERCENT_ENCODE_SET: &AsciiSet = &CONTROLS + .add(b' ') + .add(b'"') + .add(b'#') + .add(b'<') + .add(b'>') + .add(b'?') + .add(b'`') + .add(b'{') + .add(b'}') + .add(b'/'); + +fn percent_encoded_path(path: &str) -> String { + percent_encoding::utf8_percent_encode(path, PATH_PERCENT_ENCODE_SET).to_string() +} + /// A wrapper for a [`Mock`] as well as a [`MockServer`], allowing us to call /// [`Mock::mount`] or [`Mock::mount_as_scoped`] without having to pass the /// [`MockServer`] reference (i.e. call `mount()` instead of `mount(&server)`). @@ -832,6 +1025,11 @@ impl MatrixMock<'_> { Self { mock: self.mock.up_to_n_times(1).expect(1), ..self } } + /// Makes sure the endpoint is never reached. + pub fn never(self) -> Self { + Self { mock: self.mock.expect(0), ..self } + } + /// Specify an upper limit to the number of times you would like this /// [`MatrixMock`] to respond to incoming requests that satisfy the /// conditions imposed by your matchers. @@ -1737,6 +1935,19 @@ impl<'a> MockEndpoint<'a, UploadEndpoint> { pub struct ResolveRoomAliasEndpoint; impl<'a> MockEndpoint<'a, ResolveRoomAliasEndpoint> { + /// Sets up the endpoint to only intercept requests for the given room + /// alias. + pub fn for_alias(self, alias: impl Into) -> Self { + let alias = alias.into(); + Self { + mock: self.mock.and(path_regex(format!( + r"^/_matrix/client/v3/directory/room/{}", + percent_encoded_path(&alias) + ))), + ..self + } + } + /// Returns a data endpoint with a resolved room alias. pub fn ok(self, room_id: &str, servers: Vec) -> MatrixMock<'a> { let mock = self.mock.respond_with(ResponseTemplate::new(200).set_body_json(json!({ @@ -1767,6 +1978,17 @@ impl<'a> MockEndpoint<'a, CreateRoomAliasEndpoint> { } } +/// A prebuilt mock for removing a room alias. +pub struct RemoveRoomAliasEndpoint; + +impl<'a> MockEndpoint<'a, RemoveRoomAliasEndpoint> { + /// Returns a data endpoint for removing a room alias. + pub fn ok(self) -> MatrixMock<'a> { + let mock = self.mock.respond_with(ResponseTemplate::new(200).set_body_json(json!({}))); + MatrixMock { server: self.server, mock } + } +} + /// A prebuilt mock for paginating the public room list. pub struct PublicRoomsEndpoint; @@ -1820,6 +2042,30 @@ impl<'a> MockEndpoint<'a, PublicRoomsEndpoint> { } } +/// A prebuilt mock for getting the room's visibility in the room directory. +pub struct GetRoomVisibilityEndpoint; + +impl<'a> MockEndpoint<'a, GetRoomVisibilityEndpoint> { + /// Returns an endpoint that get the room's public visibility. + pub fn ok(self, visibility: Visibility) -> MatrixMock<'a> { + let mock = self.mock.respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "visibility": visibility, + }))); + MatrixMock { server: self.server, mock } + } +} + +/// A prebuilt mock for setting the room's visibility in the room directory. +pub struct SetRoomVisibilityEndpoint; + +impl<'a> MockEndpoint<'a, SetRoomVisibilityEndpoint> { + /// Returns an endpoint that updates the room's visibility. + pub fn ok(self) -> MatrixMock<'a> { + let mock = self.mock.respond_with(ResponseTemplate::new(200).set_body_json(json!({}))); + MatrixMock { server: self.server, mock } + } +} + /// A prebuilt mock for `GET room_keys/version`: storage ("backup") of room /// keys. pub struct RoomKeysVersionEndpoint; diff --git a/testing/matrix-sdk-integration-testing/src/tests.rs b/testing/matrix-sdk-integration-testing/src/tests.rs index cd85e97d604..67f77483210 100644 --- a/testing/matrix-sdk-integration-testing/src/tests.rs +++ b/testing/matrix-sdk-integration-testing/src/tests.rs @@ -6,5 +6,6 @@ mod redaction; mod repeated_join; mod room; mod room_directory_search; +mod room_privacy; mod sliding_sync; mod timeline; diff --git a/testing/matrix-sdk-integration-testing/src/tests/room_privacy.rs b/testing/matrix-sdk-integration-testing/src/tests/room_privacy.rs new file mode 100644 index 00000000000..6bce878c555 --- /dev/null +++ b/testing/matrix-sdk-integration-testing/src/tests/room_privacy.rs @@ -0,0 +1,242 @@ +// Copyright 2025 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 assert_matches2::assert_matches; +use assign::assign; +use matrix_sdk::{ + config::SyncSettings, + ruma::{ + api::client::{ + directory::get_public_rooms_filtered, + room::{create_room::v3::Request as CreateRoomRequest, Visibility}, + }, + directory::Filter, + events::room::{ + canonical_alias::{InitialRoomCanonicalAliasEvent, RoomCanonicalAliasEventContent}, + history_visibility::{ + HistoryVisibility, InitialRoomHistoryVisibilityEvent, + RoomHistoryVisibilityEventContent, + }, + }, + serde::Raw, + RoomAliasId, + }, +}; +use matrix_sdk_base::ruma::events::room::canonical_alias::SyncRoomCanonicalAliasEvent; +use rand::random; +use tokio::sync::mpsc::unbounded_channel; + +use crate::helpers::TestClientBuilder; + +#[tokio::test] +async fn test_publishing_room_alias() -> anyhow::Result<()> { + let client = TestClientBuilder::new("alice").build().await?; + let server_name = client.user_id().expect("A user id should exist").server_name(); + + let sync_handle = tokio::task::spawn({ + let client = client.clone(); + async move { + client.sync(SyncSettings::default()).await.expect("Sync should not fail"); + } + }); + + // The room can only be visible in the public room directory later if its join + // rule is one of [public, knock, knock_restricted] or its history + // visibility is `world_readable`. Let's use this last option. + let room_history_visibility = InitialRoomHistoryVisibilityEvent::new( + RoomHistoryVisibilityEventContent::new(HistoryVisibility::WorldReadable), + ) + .to_raw_any(); + let room_id = client + .create_room(assign!(CreateRoomRequest::new(), { + initial_state: vec![ + room_history_visibility, + ] + })) + .await? + .room_id() + .to_owned(); + + // Wait for the room to be synced + let room = client.await_room_remote_echo(&room_id).await; + + // Initial checks for the room's state + let room_visibility = room.privacy_settings().get_room_visibility().await?; + assert_matches!(room_visibility, Visibility::Private); + + let canonical_alias = room.canonical_alias(); + assert!(canonical_alias.is_none()); + + let alternative_aliases = room.alt_aliases(); + assert!(alternative_aliases.is_empty()); + + // We'll add a room alias to it + let random_id: u128 = random(); + let raw_room_alias = format!("#a-room-alias-{random_id}:{server_name}"); + let room_alias = RoomAliasId::parse(raw_room_alias).expect("The room alias should be valid"); + + // We publish the room alias + let published = + room.privacy_settings().publish_room_alias_in_room_directory(&room_alias).await?; + assert!(published); + + // We can't publish it again + let published = + room.privacy_settings().publish_room_alias_in_room_directory(&room_alias).await?; + assert!(!published); + + // We can publish an alternative alias too + let alt_alias = RoomAliasId::parse(format!("#alt-alias-{random_id}:{server_name}")) + .expect("The alt room alias should be valid"); + let published = + room.privacy_settings().publish_room_alias_in_room_directory(&alt_alias).await?; + assert!(published); + + // Since we only published the room alias, the canonical alias is not set yet + let canonical_alias = room.canonical_alias(); + assert!(canonical_alias.is_none()); + + // We update the canonical alias now + room.privacy_settings() + .update_canonical_alias(Some(room_alias.clone()), vec![alt_alias.clone()]) + .await?; + + // Wait until we receive the canonical alias event through sync + let (tx, mut rx) = unbounded_channel(); + let handle = room.add_event_handler(move |_: Raw| { + let _ = tx.send(()); + async {} + }); + let _ = tokio::time::timeout(Duration::from_secs(2), rx.recv()).await?; + client.remove_event_handler(handle); + + // And we can check it actually changed the aliases + let canonical_alias = room.canonical_alias(); + assert!(canonical_alias.is_some()); + assert_eq!(canonical_alias.unwrap(), room_alias); + + let alternative_aliases = room.alt_aliases(); + assert_eq!(alternative_aliases, vec![alt_alias]); + + // Since the room is still not public, the room directory can't find it + let public_rooms_filter = assign!(Filter::new(), { + generic_search_term: Some(room_alias.to_string()), + }); + let public_rooms_request = assign!(get_public_rooms_filtered::v3::Request::new(), { + filter: public_rooms_filter, + }); + let results = client.public_rooms_filtered(public_rooms_request.clone()).await?.chunk; + assert!(results.is_empty()); + + // We can set the room as visible now in the public room directory + room.privacy_settings().update_room_visibility(Visibility::Public).await?; + + // And confirm it's public + let room_visibility = room.privacy_settings().get_room_visibility().await?; + assert_matches!(room_visibility, Visibility::Public); + + // We can check again the public room directory and we should have some results + let results = client.public_rooms_filtered(public_rooms_request).await?.chunk; + assert_eq!(results.len(), 1); + + sync_handle.abort(); + + Ok(()) +} + +#[tokio::test] +async fn test_removing_published_room_alias() -> anyhow::Result<()> { + let client = TestClientBuilder::new("alice").build().await?; + let server_name = client.user_id().expect("A user id should exist").server_name(); + + let sync_handle = tokio::task::spawn({ + let client = client.clone(); + async move { + client.sync(SyncSettings::default()).await.expect("Sync should not fail"); + } + }); + + // We'll add a room alias to it + let random_id: u128 = random(); + let local_part_room_alias = format!("a-room-alias-{}", random_id); + let raw_room_alias = format!("#{local_part_room_alias}:{server_name}"); + let room_alias = RoomAliasId::parse(raw_room_alias).expect("The room alias should be valid"); + + // The room can only be visible in the public room directory later if its join + // rule is one of [public, knock, knock_restricted] or its history + // visibility is `world_readable`. Let's use this last option. + // This room will be created with a room alias and being visible in the public + // room directory. + let room_history_visibility = InitialRoomHistoryVisibilityEvent::new( + RoomHistoryVisibilityEventContent::new(HistoryVisibility::WorldReadable), + ) + .to_raw_any(); + let canonical_alias = InitialRoomCanonicalAliasEvent::new( + assign!(RoomCanonicalAliasEventContent::new(), { alias: Some(room_alias.clone()) }), + ) + .to_raw_any(); + let room_id = client + .create_room(assign!(CreateRoomRequest::new(), { + room_alias_name: Some(local_part_room_alias), + initial_state: vec![ + room_history_visibility, + canonical_alias, + ], + visibility: Visibility::Public, + })) + .await? + .room_id() + .to_owned(); + + // Wait for the room to be synced + let room = client.await_room_remote_echo(&room_id).await; + + // Initial checks for the room's state + let room_visibility = room.privacy_settings().get_room_visibility().await?; + assert_matches!(room_visibility, Visibility::Public); + + let alternative_aliases = room.alt_aliases(); + assert!(alternative_aliases.is_empty()); + + // We can check the room is published + let public_rooms_filter = assign!(Filter::new(), { + generic_search_term: Some(room_alias.to_string()), + }); + let public_rooms_request = assign!(get_public_rooms_filtered::v3::Request::new(), { + filter: public_rooms_filter, + }); + let results = client.public_rooms_filtered(public_rooms_request.clone()).await?.chunk; + assert_eq!(results.len(), 1); + + // We remove the room alias + let removed = + room.privacy_settings().remove_room_alias_from_room_directory(&room_alias).await?; + assert!(removed); + + // We can't remove it again + let removed = + room.privacy_settings().remove_room_alias_from_room_directory(&room_alias).await?; + assert!(!removed); + + // If we check the public room list again using the room alias as the search + // term, we don't have any results now + let results = client.public_rooms_filtered(public_rooms_request.clone()).await?.chunk; + assert!(results.is_empty()); + + sync_handle.abort(); + + Ok(()) +}