From f61ed2ce9a72179cb4196ed1f4dd0f509f0ee74b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?K=C3=A9vin=20Commaille?= Date: Fri, 10 Oct 2025 10:43:09 +0200 Subject: [PATCH 1/6] feat(sdk): Add support for fetching custom profile fields MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Kévin Commaille --- crates/matrix-sdk/src/account.rs | 96 ++++++++++++++++--- crates/matrix-sdk/src/test_utils/mocks/mod.rs | 31 ++++++ .../matrix-sdk/tests/integration/account.rs | 54 +++++++++++ 3 files changed, 167 insertions(+), 14 deletions(-) diff --git a/crates/matrix-sdk/src/account.rs b/crates/matrix-sdk/src/account.rs index efd3f579e9d..f1ebc56fde0 100644 --- a/crates/matrix-sdk/src/account.rs +++ b/crates/matrix-sdk/src/account.rs @@ -23,7 +23,7 @@ use js_int::uint; #[cfg(feature = "experimental-element-recent-emojis")] use matrix_sdk_base::recent_emojis::RecentEmojisContent; use matrix_sdk_base::{ - StateStoreDataKey, StateStoreDataValue, + SendOutsideWasm, StateStoreDataKey, StateStoreDataValue, SyncOutsideWasm, media::{MediaFormat, MediaRequestParameters}, store::StateStoreExt, }; @@ -40,7 +40,8 @@ use ruma::{ config::{get_global_account_data, set_global_account_data}, error::ErrorKind, profile::{ - get_avatar_url, get_display_name, get_profile, set_avatar_url, set_display_name, + AvatarUrl, DisplayName, ProfileFieldName, ProfileFieldValue, StaticProfileField, + get_profile, get_profile_field, set_avatar_url, set_display_name, }, uiaa::AuthData, }, @@ -107,11 +108,7 @@ impl Account { /// ``` pub async fn get_display_name(&self) -> Result> { let user_id = self.client.user_id().ok_or(Error::AuthenticationRequired)?; - #[allow(deprecated)] - let request = get_display_name::v3::Request::new(user_id.to_owned()); - let request_config = self.client.request_config().force_auth(); - let response = self.client.send(request).with_request_config(request_config).await?; - Ok(response.displayname) + self.fetch_profile_field_of_static::(user_id.to_owned()).await } /// Set the display name of the account. @@ -163,13 +160,10 @@ impl Account { /// ``` pub async fn get_avatar_url(&self) -> Result> { let user_id = self.client.user_id().ok_or(Error::AuthenticationRequired)?; - #[allow(deprecated)] - let request = get_avatar_url::v3::Request::new(user_id.to_owned()); - - let config = Some(RequestConfig::new().force_auth()); + let avatar_url = + self.fetch_profile_field_of_static::(user_id.to_owned()).await?; - let response = self.client.send(request).with_request_config(config).await?; - if let Some(url) = response.avatar_url.clone() { + if let Some(url) = avatar_url.clone() { // If an avatar is found cache it. let _ = self .client @@ -187,7 +181,7 @@ impl Account { .remove_kv_data(StateStoreDataKey::UserAvatarUrl(user_id)) .await; } - Ok(response.avatar_url) + Ok(avatar_url) } /// Get the URL of the account's avatar, if is stored in cache. @@ -328,6 +322,80 @@ impl Account { .await?) } + /// Get the given field from the given user's profile. + /// + /// # Arguments + /// + /// * `user_id` - The ID of the user to get the profile field of. + /// + /// * `field` - The name of the profile field to get. + /// + /// # Returns + /// + /// Returns an error if the request fails or if deserialization of the + /// response fails. + /// + /// If the field is not set, the server should respond with an error with an + /// [`ErrorCode::NotFound`], but it might also respond with an empty + /// response, which would result in `Ok(None)`. Note that this error code + /// might also mean that the given user ID doesn't exist. + /// + /// [`ErrorCode::NotFound`]: ruma::api::client::error::ErrorCode::NotFound + pub async fn fetch_profile_field_of( + &self, + user_id: OwnedUserId, + field: ProfileFieldName, + ) -> Result> { + let request = get_profile_field::v3::Request::new(user_id, field); + let response = self + .client + .send(request) + .with_request_config(RequestConfig::short_retry().force_auth()) + .await?; + + Ok(response.value) + } + + /// Get the given statically-known field from the given user's profile. + /// + /// # Arguments + /// + /// * `user_id` - The ID of the user to get the profile field of. + /// + /// # Returns + /// + /// Returns an error if the request fails or if deserialization of the + /// response fails. + /// + /// If the field is not set, the server should respond with an error with an + /// [`ErrorCode::NotFound`], but it might also respond with an empty + /// response, which would result in `Ok(None)`. Note that this error code + /// might also mean that the given user ID doesn't exist. + /// + /// [`ErrorCode::NotFound`]: ruma::api::client::error::ErrorCode::NotFound + pub async fn fetch_profile_field_of_static( + &self, + user_id: OwnedUserId, + ) -> Result> + where + F: StaticProfileField + + std::fmt::Debug + + Clone + + SendOutsideWasm + + SyncOutsideWasm + + 'static, + F::Value: SendOutsideWasm + SyncOutsideWasm, + { + let request = get_profile_field::v3::Request::new_static::(user_id); + let response = self + .client + .send(request) + .with_request_config(RequestConfig::short_retry().force_auth()) + .await?; + + Ok(response.value) + } + /// Change the password of the account. /// /// # Arguments diff --git a/crates/matrix-sdk/src/test_utils/mocks/mod.rs b/crates/matrix-sdk/src/test_utils/mocks/mod.rs index c71aa2ca05c..283c1f06f35 100644 --- a/crates/matrix-sdk/src/test_utils/mocks/mod.rs +++ b/crates/matrix-sdk/src/test_utils/mocks/mod.rs @@ -35,6 +35,7 @@ use ruma::{ DeviceId, EventId, MilliSecondsSinceUnixEpoch, MxcUri, OwnedDeviceId, OwnedEventId, OwnedOneTimeKeyId, OwnedRoomId, OwnedUserId, RoomId, ServerName, UserId, api::client::{ + profile::ProfileFieldName, receipt::create_receipt::v3::ReceiptType, room::Visibility, sync::sync_events::v5, @@ -1622,6 +1623,17 @@ impl MatrixMockServer { Mock::given(method("GET")).and(path_regex(r"^/_matrix/client/v1/rooms/.*/hierarchy")); self.mock_endpoint(mock, GetHierarchyEndpoint).expect_default_access_token() } + + /// Create a prebuilt mock for the endpoint used to get a profile field. + pub fn mock_get_profile_field( + &self, + user_id: &UserId, + field: ProfileFieldName, + ) -> MockEndpoint<'_, GetProfileFieldEndpoint> { + let mock = Mock::given(method("GET")) + .and(path(format!("/_matrix/client/v3/profile/{user_id}/{field}"))); + self.mock_endpoint(mock, GetProfileFieldEndpoint { field }) + } } /// A specification for a push rule ID. @@ -4658,3 +4670,22 @@ impl<'a> MockEndpoint<'a, SlidingSyncEndpoint> { let _summary = sliding_sync.sync_once().await.unwrap(); } } + +/// A prebuilt mock for `GET /_matrix/client/*/profile/{user_id}/{key_name}`. +pub struct GetProfileFieldEndpoint { + field: ProfileFieldName, +} + +impl<'a> MockEndpoint<'a, GetProfileFieldEndpoint> { + /// Returns a successful response containing the given value, if any. + pub fn ok_with_value(self, value: Option) -> MatrixMock<'a> { + if let Some(value) = value { + let field = self.endpoint.field.to_string(); + self.respond_with(ResponseTemplate::new(200).set_body_json(json!({ + field: value, + }))) + } else { + self.ok_empty_json() + } + } +} diff --git a/crates/matrix-sdk/tests/integration/account.rs b/crates/matrix-sdk/tests/integration/account.rs index db5bdcbab03..b52fd4e4981 100644 --- a/crates/matrix-sdk/tests/integration/account.rs +++ b/crates/matrix-sdk/tests/integration/account.rs @@ -1,4 +1,10 @@ +use assert_matches2::assert_matches; +use matrix_sdk::test_utils::mocks::MatrixMockServer; use matrix_sdk_test::async_test; +use ruma::api::{ + MatrixVersion, + client::profile::{ProfileFieldName, ProfileFieldValue, TimeZone}, +}; use serde_json::json; use wiremock::{ Mock, Request, ResponseTemplate, @@ -57,3 +63,51 @@ async fn test_account_deactivation() { assert!(client.account().deactivate(None, None, true).await.is_ok()); } } + +#[async_test] +async fn test_fetch_profile_field() { + let tz = "Africa/Bujumbura"; + let display_name = "Alice"; + + let server = MatrixMockServer::new().await; + let client = server.client_builder().server_versions(vec![MatrixVersion::V1_16]).build().await; + let user_id = client.user_id().unwrap(); + + server + .mock_get_profile_field(user_id, ProfileFieldName::TimeZone) + .ok_with_value(Some(tz.into())) + .expect(2) + .named("get m.tz profile field") + .mount() + .await; + server + .mock_get_profile_field(user_id, ProfileFieldName::DisplayName) + .ok_with_value(Some(display_name.into())) + .mock_once() + .named("get displayname profile field") + .mount() + .await; + server + .mock_get_profile_field(user_id, ProfileFieldName::AvatarUrl) + .ok_with_value(None) + .mock_once() + .named("get avatar_url profile field") + .mount() + .await; + + let account = client.account(); + + let res_avatar_url = account.get_avatar_url().await.unwrap(); + assert_eq!(res_avatar_url, None); + let res_display_name = account.get_display_name().await.unwrap(); + assert_eq!(res_display_name.as_deref(), Some(display_name)); + let res_value = account + .fetch_profile_field_of(user_id.to_owned(), ProfileFieldName::TimeZone) + .await + .unwrap(); + assert_matches!(res_value, Some(ProfileFieldValue::TimeZone(res_tz))); + assert_eq!(res_tz, tz); + let res_tz = + account.fetch_profile_field_of_static::(user_id.to_owned()).await.unwrap(); + assert_eq!(res_tz.as_deref(), Some(tz)); +} From 6b584e9526d1cd1d632f756b991e2e8ed20f447e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?K=C3=A9vin=20Commaille?= Date: Sat, 11 Oct 2025 13:55:28 +0200 Subject: [PATCH 2/6] feat(sdk): Add support for setting custom profile fields MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Kévin Commaille --- Cargo.lock | 18 +++---- Cargo.toml | 2 +- crates/matrix-sdk/src/account.rs | 22 ++++++++- crates/matrix-sdk/src/test_utils/mocks/mod.rs | 21 ++++++++ .../matrix-sdk/tests/integration/account.rs | 48 +++++++++++++++++-- 5 files changed, 97 insertions(+), 14 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 3ff4cd0d5e0..833a3a94cbf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4820,7 +4820,7 @@ dependencies = [ [[package]] name = "ruma" version = "0.13.0" -source = "git+https://github.com/ruma/ruma?rev=c441eccb92a36467217ff929cd71462fbeeeaf1a#c441eccb92a36467217ff929cd71462fbeeeaf1a" +source = "git+https://github.com/ruma/ruma?rev=44170bb35f6fb54874ae7b2b9a4d046c80131651#44170bb35f6fb54874ae7b2b9a4d046c80131651" dependencies = [ "assign", "js_int", @@ -4837,7 +4837,7 @@ dependencies = [ [[package]] name = "ruma-client-api" version = "0.21.0" -source = "git+https://github.com/ruma/ruma?rev=c441eccb92a36467217ff929cd71462fbeeeaf1a#c441eccb92a36467217ff929cd71462fbeeeaf1a" +source = "git+https://github.com/ruma/ruma?rev=44170bb35f6fb54874ae7b2b9a4d046c80131651#44170bb35f6fb54874ae7b2b9a4d046c80131651" dependencies = [ "as_variant", "assign", @@ -4860,7 +4860,7 @@ dependencies = [ [[package]] name = "ruma-common" version = "0.16.0" -source = "git+https://github.com/ruma/ruma?rev=c441eccb92a36467217ff929cd71462fbeeeaf1a#c441eccb92a36467217ff929cd71462fbeeeaf1a" +source = "git+https://github.com/ruma/ruma?rev=44170bb35f6fb54874ae7b2b9a4d046c80131651#44170bb35f6fb54874ae7b2b9a4d046c80131651" dependencies = [ "as_variant", "base64", @@ -4893,7 +4893,7 @@ dependencies = [ [[package]] name = "ruma-events" version = "0.31.0" -source = "git+https://github.com/ruma/ruma?rev=c441eccb92a36467217ff929cd71462fbeeeaf1a#c441eccb92a36467217ff929cd71462fbeeeaf1a" +source = "git+https://github.com/ruma/ruma?rev=44170bb35f6fb54874ae7b2b9a4d046c80131651#44170bb35f6fb54874ae7b2b9a4d046c80131651" dependencies = [ "as_variant", "indexmap", @@ -4919,7 +4919,7 @@ dependencies = [ [[package]] name = "ruma-federation-api" version = "0.12.0" -source = "git+https://github.com/ruma/ruma?rev=c441eccb92a36467217ff929cd71462fbeeeaf1a#c441eccb92a36467217ff929cd71462fbeeeaf1a" +source = "git+https://github.com/ruma/ruma?rev=44170bb35f6fb54874ae7b2b9a4d046c80131651#44170bb35f6fb54874ae7b2b9a4d046c80131651" dependencies = [ "headers", "http", @@ -4939,7 +4939,7 @@ dependencies = [ [[package]] name = "ruma-html" version = "0.5.0" -source = "git+https://github.com/ruma/ruma?rev=c441eccb92a36467217ff929cd71462fbeeeaf1a#c441eccb92a36467217ff929cd71462fbeeeaf1a" +source = "git+https://github.com/ruma/ruma?rev=44170bb35f6fb54874ae7b2b9a4d046c80131651#44170bb35f6fb54874ae7b2b9a4d046c80131651" dependencies = [ "as_variant", "html5ever", @@ -4950,7 +4950,7 @@ dependencies = [ [[package]] name = "ruma-identifiers-validation" version = "0.11.0" -source = "git+https://github.com/ruma/ruma?rev=c441eccb92a36467217ff929cd71462fbeeeaf1a#c441eccb92a36467217ff929cd71462fbeeeaf1a" +source = "git+https://github.com/ruma/ruma?rev=44170bb35f6fb54874ae7b2b9a4d046c80131651#44170bb35f6fb54874ae7b2b9a4d046c80131651" dependencies = [ "js_int", "thiserror 2.0.16", @@ -4959,7 +4959,7 @@ dependencies = [ [[package]] name = "ruma-macros" version = "0.16.0" -source = "git+https://github.com/ruma/ruma?rev=c441eccb92a36467217ff929cd71462fbeeeaf1a#c441eccb92a36467217ff929cd71462fbeeeaf1a" +source = "git+https://github.com/ruma/ruma?rev=44170bb35f6fb54874ae7b2b9a4d046c80131651#44170bb35f6fb54874ae7b2b9a4d046c80131651" dependencies = [ "cfg-if", "proc-macro-crate", @@ -4974,7 +4974,7 @@ dependencies = [ [[package]] name = "ruma-signatures" version = "0.18.0" -source = "git+https://github.com/ruma/ruma?rev=c441eccb92a36467217ff929cd71462fbeeeaf1a#c441eccb92a36467217ff929cd71462fbeeeaf1a" +source = "git+https://github.com/ruma/ruma?rev=44170bb35f6fb54874ae7b2b9a4d046c80131651#44170bb35f6fb54874ae7b2b9a4d046c80131651" dependencies = [ "base64", "ed25519-dalek", diff --git a/Cargo.toml b/Cargo.toml index 9967cef71b9..892c8187c1d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -66,7 +66,7 @@ rand = "0.8.5" regex = "1.11.2" reqwest = { version = "0.12.23", default-features = false } rmp-serde = "1.3.0" -ruma = { git = "https://github.com/ruma/ruma", rev = "c441eccb92a36467217ff929cd71462fbeeeaf1a", features = [ +ruma = { git = "https://github.com/ruma/ruma", rev = "44170bb35f6fb54874ae7b2b9a4d046c80131651", features = [ "client-api-c", "compat-upload-signatures", "compat-arbitrary-length-ids", diff --git a/crates/matrix-sdk/src/account.rs b/crates/matrix-sdk/src/account.rs index f1ebc56fde0..9d86ef6a7d1 100644 --- a/crates/matrix-sdk/src/account.rs +++ b/crates/matrix-sdk/src/account.rs @@ -41,7 +41,7 @@ use ruma::{ error::ErrorKind, profile::{ AvatarUrl, DisplayName, ProfileFieldName, ProfileFieldValue, StaticProfileField, - get_profile, get_profile_field, set_avatar_url, set_display_name, + get_profile, get_profile_field, set_avatar_url, set_display_name, set_profile_field, }, uiaa::AuthData, }, @@ -396,6 +396,26 @@ impl Account { Ok(response.value) } + /// Set the given field of our own user's profile. + /// + /// [`Client::get_capabilities()`] should be called first to check it the + /// field can be set on the homeserver. + /// + /// # Arguments + /// + /// * `value` - The value of the profile field to set. + /// + /// # Returns + /// + /// Returns an error if the request fails. + pub async fn set_profile_field(&self, value: ProfileFieldValue) -> Result<()> { + let user_id = self.client.user_id().ok_or(Error::AuthenticationRequired)?; + let request = set_profile_field::v3::Request::new(user_id.to_owned(), value); + self.client.send(request).await?; + + Ok(()) + } + /// Change the password of the account. /// /// # Arguments diff --git a/crates/matrix-sdk/src/test_utils/mocks/mod.rs b/crates/matrix-sdk/src/test_utils/mocks/mod.rs index 283c1f06f35..d7441d6042e 100644 --- a/crates/matrix-sdk/src/test_utils/mocks/mod.rs +++ b/crates/matrix-sdk/src/test_utils/mocks/mod.rs @@ -1634,6 +1634,17 @@ impl MatrixMockServer { .and(path(format!("/_matrix/client/v3/profile/{user_id}/{field}"))); self.mock_endpoint(mock, GetProfileFieldEndpoint { field }) } + + /// Create a prebuilt mock for the endpoint used to set a profile field. + pub fn mock_set_profile_field( + &self, + user_id: &UserId, + field: ProfileFieldName, + ) -> MockEndpoint<'_, SetProfileFieldEndpoint> { + let mock = Mock::given(method("PUT")) + .and(path(format!("/_matrix/client/v3/profile/{user_id}/{field}"))); + self.mock_endpoint(mock, SetProfileFieldEndpoint).expect_default_access_token() + } } /// A specification for a push rule ID. @@ -4689,3 +4700,13 @@ impl<'a> MockEndpoint<'a, GetProfileFieldEndpoint> { } } } + +/// A prebuilt mock for `PUT /_matrix/client/*/profile/{user_id}/{key_name}`. +pub struct SetProfileFieldEndpoint; + +impl<'a> MockEndpoint<'a, SetProfileFieldEndpoint> { + /// Returns a successful empty response. + pub fn ok(self) -> MatrixMock<'a> { + self.ok_empty_json() + } +} diff --git a/crates/matrix-sdk/tests/integration/account.rs b/crates/matrix-sdk/tests/integration/account.rs index b52fd4e4981..3532f8f3889 100644 --- a/crates/matrix-sdk/tests/integration/account.rs +++ b/crates/matrix-sdk/tests/integration/account.rs @@ -1,9 +1,12 @@ use assert_matches2::assert_matches; use matrix_sdk::test_utils::mocks::MatrixMockServer; use matrix_sdk_test::async_test; -use ruma::api::{ - MatrixVersion, - client::profile::{ProfileFieldName, ProfileFieldValue, TimeZone}, +use ruma::{ + api::{ + MatrixVersion, + client::profile::{ProfileFieldName, ProfileFieldValue, TimeZone}, + }, + mxc_uri, }; use serde_json::json; use wiremock::{ @@ -111,3 +114,42 @@ async fn test_fetch_profile_field() { account.fetch_profile_field_of_static::(user_id.to_owned()).await.unwrap(); assert_eq!(res_tz.as_deref(), Some(tz)); } + +#[async_test] +async fn test_set_profile_field() { + let tz = "Africa/Bujumbura"; + let display_name = "Alice"; + let avatar_url = mxc_uri!("mxc://localhost/1mA63"); + + let server = MatrixMockServer::new().await; + let client = server.client_builder().server_versions(vec![MatrixVersion::V1_16]).build().await; + let user_id = client.user_id().unwrap(); + + server + .mock_set_profile_field(user_id, ProfileFieldName::TimeZone) + .ok() + .mock_once() + .named("set m.tz profile field") + .mount() + .await; + server + .mock_set_profile_field(user_id, ProfileFieldName::DisplayName) + .ok() + .mock_once() + .named("set displayname profile field") + .mount() + .await; + server + .mock_set_profile_field(user_id, ProfileFieldName::AvatarUrl) + .ok() + .mock_once() + .named("set avatar_url profile field") + .mount() + .await; + + let account = client.account(); + + account.set_avatar_url(Some(avatar_url)).await.unwrap(); + account.set_display_name(Some(display_name)).await.unwrap(); + account.set_profile_field(ProfileFieldValue::TimeZone(tz.to_owned())).await.unwrap(); +} From 622952ed2d2b6ebfe11a7a66c3757ae2f802282c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?K=C3=A9vin=20Commaille?= Date: Sat, 11 Oct 2025 14:03:27 +0200 Subject: [PATCH 3/6] feat(sdk): Add support for deleting custom profile fields MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Kévin Commaille --- crates/matrix-sdk/src/account.rs | 24 ++++++++++++- crates/matrix-sdk/src/test_utils/mocks/mod.rs | 21 +++++++++++ .../matrix-sdk/tests/integration/account.rs | 35 +++++++++++++++++++ 3 files changed, 79 insertions(+), 1 deletion(-) diff --git a/crates/matrix-sdk/src/account.rs b/crates/matrix-sdk/src/account.rs index 9d86ef6a7d1..73fbcf3fc17 100644 --- a/crates/matrix-sdk/src/account.rs +++ b/crates/matrix-sdk/src/account.rs @@ -41,7 +41,8 @@ use ruma::{ error::ErrorKind, profile::{ AvatarUrl, DisplayName, ProfileFieldName, ProfileFieldValue, StaticProfileField, - get_profile, get_profile_field, set_avatar_url, set_display_name, set_profile_field, + delete_profile_field, get_profile, get_profile_field, set_avatar_url, set_display_name, + set_profile_field, }, uiaa::AuthData, }, @@ -416,6 +417,27 @@ impl Account { Ok(()) } + /// Delete the given field of our own user's profile. + /// + /// [`Client::get_capabilities()`] should be called first to check it the + /// field can be modified on the homeserver. + /// + /// # Arguments + /// + /// * `field` - The profile field to delete. + /// + /// # Returns + /// + /// Returns an error if the server doesn't support extended profile fields + /// of if the request fails in some other way. + pub async fn delete_profile_field(&self, field: ProfileFieldName) -> Result<()> { + let user_id = self.client.user_id().ok_or(Error::AuthenticationRequired)?; + let request = delete_profile_field::v3::Request::new(user_id.to_owned(), field); + self.client.send(request).await?; + + Ok(()) + } + /// Change the password of the account. /// /// # Arguments diff --git a/crates/matrix-sdk/src/test_utils/mocks/mod.rs b/crates/matrix-sdk/src/test_utils/mocks/mod.rs index d7441d6042e..46a5f0bebce 100644 --- a/crates/matrix-sdk/src/test_utils/mocks/mod.rs +++ b/crates/matrix-sdk/src/test_utils/mocks/mod.rs @@ -1645,6 +1645,17 @@ impl MatrixMockServer { .and(path(format!("/_matrix/client/v3/profile/{user_id}/{field}"))); self.mock_endpoint(mock, SetProfileFieldEndpoint).expect_default_access_token() } + + /// Create a prebuilt mock for the endpoint used to delete a profile field. + pub fn mock_delete_profile_field( + &self, + user_id: &UserId, + field: ProfileFieldName, + ) -> MockEndpoint<'_, DeleteProfileFieldEndpoint> { + let mock = Mock::given(method("DELETE")) + .and(path(format!("/_matrix/client/v3/profile/{user_id}/{field}"))); + self.mock_endpoint(mock, DeleteProfileFieldEndpoint).expect_default_access_token() + } } /// A specification for a push rule ID. @@ -4710,3 +4721,13 @@ impl<'a> MockEndpoint<'a, SetProfileFieldEndpoint> { self.ok_empty_json() } } + +/// A prebuilt mock for `DELETE /_matrix/client/*/profile/{user_id}/{key_name}`. +pub struct DeleteProfileFieldEndpoint; + +impl<'a> MockEndpoint<'a, DeleteProfileFieldEndpoint> { + /// Returns a successful empty response. + pub fn ok(self) -> MatrixMock<'a> { + self.ok_empty_json() + } +} diff --git a/crates/matrix-sdk/tests/integration/account.rs b/crates/matrix-sdk/tests/integration/account.rs index 3532f8f3889..19e240abb19 100644 --- a/crates/matrix-sdk/tests/integration/account.rs +++ b/crates/matrix-sdk/tests/integration/account.rs @@ -153,3 +153,38 @@ async fn test_set_profile_field() { account.set_display_name(Some(display_name)).await.unwrap(); account.set_profile_field(ProfileFieldValue::TimeZone(tz.to_owned())).await.unwrap(); } + +#[async_test] +async fn test_delete_profile_field() { + let server = MatrixMockServer::new().await; + let client = server.client_builder().server_versions(vec![MatrixVersion::V1_16]).build().await; + let user_id = client.user_id().unwrap(); + + server + .mock_delete_profile_field(user_id, ProfileFieldName::TimeZone) + .ok() + .mock_once() + .named("delete m.tz profile field") + .mount() + .await; + server + .mock_set_profile_field(user_id, ProfileFieldName::DisplayName) + .ok() + .mock_once() + .named("set displayname profile field") + .mount() + .await; + server + .mock_set_profile_field(user_id, ProfileFieldName::AvatarUrl) + .ok() + .mock_once() + .named("set avatar_url profile field") + .mount() + .await; + + let account = client.account(); + + account.set_avatar_url(None).await.unwrap(); + account.set_display_name(None).await.unwrap(); + account.delete_profile_field(ProfileFieldName::TimeZone).await.unwrap(); +} From 3374573b797ccdb00e3e392ad5b1fef441907815 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?K=C3=A9vin=20Commaille?= Date: Sat, 11 Oct 2025 14:15:24 +0200 Subject: [PATCH 4/6] refactor(sdk): Prefer DELETE HTTP method for profile fields MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When it is supported by the homeserver. Signed-off-by: Kévin Commaille --- crates/matrix-sdk/src/account.rs | 55 +++++++++--- .../matrix-sdk/tests/integration/account.rs | 86 +++++++++++++------ 2 files changed, 102 insertions(+), 39 deletions(-) diff --git a/crates/matrix-sdk/src/account.rs b/crates/matrix-sdk/src/account.rs index 73fbcf3fc17..6fd423498d4 100644 --- a/crates/matrix-sdk/src/account.rs +++ b/crates/matrix-sdk/src/account.rs @@ -32,19 +32,22 @@ use mime::Mime; use ruma::api::client::config::set_global_account_data::v3::Request as UpdateGlobalAccountDataRequest; use ruma::{ ClientSecret, MxcUri, OwnedMxcUri, OwnedRoomId, OwnedUserId, RoomId, SessionId, UInt, UserId, - api::client::{ - account::{ - add_3pid, change_password, deactivate, delete_3pid, get_3pids, - request_3pid_management_token_via_email, request_3pid_management_token_via_msisdn, + api::{ + OutgoingRequest, + client::{ + account::{ + add_3pid, change_password, deactivate, delete_3pid, get_3pids, + request_3pid_management_token_via_email, request_3pid_management_token_via_msisdn, + }, + config::{get_global_account_data, set_global_account_data}, + error::ErrorKind, + profile::{ + AvatarUrl, DisplayName, ProfileFieldName, ProfileFieldValue, StaticProfileField, + delete_profile_field, get_profile, get_profile_field, set_avatar_url, + set_display_name, set_profile_field, + }, + uiaa::AuthData, }, - config::{get_global_account_data, set_global_account_data}, - error::ErrorKind, - profile::{ - AvatarUrl, DisplayName, ProfileFieldName, ProfileFieldValue, StaticProfileField, - delete_profile_field, get_profile, get_profile_field, set_avatar_url, set_display_name, - set_profile_field, - }, - uiaa::AuthData, }, assign, events::{ @@ -130,10 +133,24 @@ impl Account { /// ``` pub async fn set_display_name(&self, name: Option<&str>) -> Result<()> { let user_id = self.client.user_id().ok_or(Error::AuthenticationRequired)?; + + // Prefer the endpoint to delete profile fields, if it is supported. + if name.is_none() { + let versions = self.client.supported_versions().await?; + + if delete_profile_field::v3::Request::is_supported(&versions) { + return self.delete_profile_field(ProfileFieldName::DisplayName).await; + } + } + + // If name is `Some(_)`, this endpoint is the same as `set_profile_field`, but + // we still need to use it in case it is `None` and the server doesn't support + // the delete endpoint yet. #[allow(deprecated)] let request = set_display_name::v3::Request::new(user_id.to_owned(), name.map(ToOwned::to_owned)); self.client.send(request).await?; + Ok(()) } @@ -201,10 +218,24 @@ impl Account { /// The avatar is unset if `url` is `None`. pub async fn set_avatar_url(&self, url: Option<&MxcUri>) -> Result<()> { let user_id = self.client.user_id().ok_or(Error::AuthenticationRequired)?; + + // Prefer the endpoint to delete profile fields, if it is supported. + if url.is_none() { + let versions = self.client.supported_versions().await?; + + if delete_profile_field::v3::Request::is_supported(&versions) { + return self.delete_profile_field(ProfileFieldName::AvatarUrl).await; + } + } + + // If url is `Some(_)`, this endpoint is the same as `set_profile_field`, but + // we still need to use it in case it is `None` and the server doesn't support + // the delete endpoint yet. #[allow(deprecated)] let request = set_avatar_url::v3::Request::new(user_id.to_owned(), url.map(ToOwned::to_owned)); self.client.send(request).await?; + Ok(()) } diff --git a/crates/matrix-sdk/tests/integration/account.rs b/crates/matrix-sdk/tests/integration/account.rs index 19e240abb19..c0a1ee049e6 100644 --- a/crates/matrix-sdk/tests/integration/account.rs +++ b/crates/matrix-sdk/tests/integration/account.rs @@ -157,34 +157,66 @@ async fn test_set_profile_field() { #[async_test] async fn test_delete_profile_field() { let server = MatrixMockServer::new().await; - let client = server.client_builder().server_versions(vec![MatrixVersion::V1_16]).build().await; - let user_id = client.user_id().unwrap(); - server - .mock_delete_profile_field(user_id, ProfileFieldName::TimeZone) - .ok() - .mock_once() - .named("delete m.tz profile field") - .mount() - .await; - server - .mock_set_profile_field(user_id, ProfileFieldName::DisplayName) - .ok() - .mock_once() - .named("set displayname profile field") - .mount() - .await; - server - .mock_set_profile_field(user_id, ProfileFieldName::AvatarUrl) - .ok() - .mock_once() - .named("set avatar_url profile field") - .mount() - .await; + // Test with server that does NOT support deleting custom fields. + { + let client = + server.client_builder().server_versions(vec![MatrixVersion::V1_15]).build().await; + let user_id = client.user_id().unwrap(); + + let _guard = server + .mock_set_profile_field(user_id, ProfileFieldName::DisplayName) + .ok() + .mock_once() + .named("set displayname profile field") + .mount_as_scoped() + .await; + let _guard = server + .mock_set_profile_field(user_id, ProfileFieldName::AvatarUrl) + .ok() + .mock_once() + .named("set avatar_url profile field") + .mount_as_scoped() + .await; - let account = client.account(); + let account = client.account(); - account.set_avatar_url(None).await.unwrap(); - account.set_display_name(None).await.unwrap(); - account.delete_profile_field(ProfileFieldName::TimeZone).await.unwrap(); + account.set_avatar_url(None).await.unwrap(); + account.set_display_name(None).await.unwrap(); + } + + // Test with server that supports deleting custom fields. + { + let client = + server.client_builder().server_versions(vec![MatrixVersion::V1_16]).build().await; + let user_id = client.user_id().unwrap(); + + let _guard = server + .mock_delete_profile_field(user_id, ProfileFieldName::AvatarUrl) + .ok() + .mock_once() + .named("delete m.tz profile field") + .mount_as_scoped() + .await; + let _guard = server + .mock_delete_profile_field(user_id, ProfileFieldName::DisplayName) + .ok() + .mock_once() + .named("delete m.tz profile field") + .mount_as_scoped() + .await; + let _guard = server + .mock_delete_profile_field(user_id, ProfileFieldName::TimeZone) + .ok() + .mock_once() + .named("delete m.tz profile field") + .mount_as_scoped() + .await; + + let account = client.account(); + + account.set_avatar_url(None).await.unwrap(); + account.set_display_name(None).await.unwrap(); + account.delete_profile_field(ProfileFieldName::TimeZone).await.unwrap(); + } } From 607d49be2b79a63be326242e7021b220863409c5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?K=C3=A9vin=20Commaille?= Date: Sat, 11 Oct 2025 14:26:37 +0200 Subject: [PATCH 5/6] Add changelog for extended profile fields MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Kévin Commaille --- crates/matrix-sdk/CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/crates/matrix-sdk/CHANGELOG.md b/crates/matrix-sdk/CHANGELOG.md index b46512f5938..910da88db9b 100644 --- a/crates/matrix-sdk/CHANGELOG.md +++ b/crates/matrix-sdk/CHANGELOG.md @@ -8,6 +8,10 @@ All notable changes to this project will be documented in this file. ### Features +- Add support for extended profile fields with `Account::fetch_profile_field_of()`, + `Account::fetch_profile_field_of_static()`, `Account::set_profile_field()` and + `Account::delete_profile_field()`. + ([#5771](https://github.com/matrix-org/matrix-rust-sdk/pull/5771)) - [**breaking**] Add `encryption::secret_storage::SecretStorageError::ImportError` to indicate an error that occurred when importing a secret from secret storage. ([#5647](https://github.com/matrix-org/matrix-rust-sdk/pull/5647)) From 1149656363afd12d79320dec93e732fd1251f4e4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?K=C3=A9vin=20Commaille?= Date: Sat, 11 Oct 2025 15:14:47 +0200 Subject: [PATCH 6/6] bonus(sdk): Add more profile tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Kévin Commaille --- crates/matrix-sdk/src/test_utils/mocks/mod.rs | 23 +++++- .../matrix-sdk/tests/integration/account.rs | 80 ++++++++++++++++++- 2 files changed, 101 insertions(+), 2 deletions(-) diff --git a/crates/matrix-sdk/src/test_utils/mocks/mod.rs b/crates/matrix-sdk/src/test_utils/mocks/mod.rs index 46a5f0bebce..2dd4e58b53c 100644 --- a/crates/matrix-sdk/src/test_utils/mocks/mod.rs +++ b/crates/matrix-sdk/src/test_utils/mocks/mod.rs @@ -35,7 +35,7 @@ use ruma::{ DeviceId, EventId, MilliSecondsSinceUnixEpoch, MxcUri, OwnedDeviceId, OwnedEventId, OwnedOneTimeKeyId, OwnedRoomId, OwnedUserId, RoomId, ServerName, UserId, api::client::{ - profile::ProfileFieldName, + profile::{ProfileFieldName, ProfileFieldValue}, receipt::create_receipt::v3::ReceiptType, room::Visibility, sync::sync_events::v5, @@ -1656,6 +1656,13 @@ impl MatrixMockServer { .and(path(format!("/_matrix/client/v3/profile/{user_id}/{field}"))); self.mock_endpoint(mock, DeleteProfileFieldEndpoint).expect_default_access_token() } + + /// Create a prebuilt mock for the endpoint used to get a profile. + pub fn mock_get_profile(&self, user_id: &UserId) -> MockEndpoint<'_, GetProfileEndpoint> { + let mock = + Mock::given(method("GET")).and(path(format!("/_matrix/client/v3/profile/{user_id}"))); + self.mock_endpoint(mock, GetProfileEndpoint) + } } /// A specification for a push rule ID. @@ -4731,3 +4738,17 @@ impl<'a> MockEndpoint<'a, DeleteProfileFieldEndpoint> { self.ok_empty_json() } } + +/// A prebuilt mock for `GET /_matrix/client/*/profile/{user_id}`. +pub struct GetProfileEndpoint; + +impl<'a> MockEndpoint<'a, GetProfileEndpoint> { + /// Returns a successful empty response. + pub fn ok_with_fields(self, fields: Vec) -> MatrixMock<'a> { + let profile = fields + .iter() + .map(|field| (field.field_name(), field.value())) + .collect::>(); + self.respond_with(ResponseTemplate::new(200).set_body_json(profile)) + } +} diff --git a/crates/matrix-sdk/tests/integration/account.rs b/crates/matrix-sdk/tests/integration/account.rs index c0a1ee049e6..11635b111e3 100644 --- a/crates/matrix-sdk/tests/integration/account.rs +++ b/crates/matrix-sdk/tests/integration/account.rs @@ -4,7 +4,7 @@ use matrix_sdk_test::async_test; use ruma::{ api::{ MatrixVersion, - client::profile::{ProfileFieldName, ProfileFieldValue, TimeZone}, + client::profile::{AvatarUrl, DisplayName, ProfileFieldName, ProfileFieldValue, TimeZone}, }, mxc_uri, }; @@ -220,3 +220,81 @@ async fn test_delete_profile_field() { account.delete_profile_field(ProfileFieldName::TimeZone).await.unwrap(); } } + +#[async_test] +async fn test_fetch_user_profile() { + let tz = "Africa/Bujumbura"; + let display_name = "Alice"; + + let server = MatrixMockServer::new().await; + let client = server.client_builder().build().await; + let user_id = client.user_id().unwrap(); + + server + .mock_get_profile(user_id) + .ok_with_fields(vec![ + ProfileFieldValue::TimeZone(tz.to_owned()), + ProfileFieldValue::DisplayName(display_name.to_owned()), + ]) + .mock_once() + .named("get profile") + .mount() + .await; + + let profile = client.account().fetch_user_profile().await.unwrap(); + + assert_eq!(profile.get_static::().unwrap().as_deref(), Some(tz)); + assert_eq!(profile.get_static::().unwrap().as_deref(), Some(display_name)); + assert_eq!(profile.get_static::().unwrap(), None); +} + +#[async_test] +async fn test_get_cached_avatar_url() { + let avatar_url = mxc_uri!("mxc://localhost/1mA63"); + + let server = MatrixMockServer::new().await; + let client = server.client_builder().build().await; + let user_id = client.user_id().unwrap(); + + let account = client.account(); + + // The cache is empty. + let res_avatar_url = account.get_cached_avatar_url().await.unwrap(); + assert_eq!(res_avatar_url, None); + + // Fetch it from the homeserver, it should fill the cache. + { + let _guard = server + .mock_get_profile_field(user_id, ProfileFieldName::AvatarUrl) + .ok_with_value(Some(avatar_url.as_str().into())) + .mock_once() + .named("get avatar_url profile field with value") + .mount_as_scoped() + .await; + + let res_avatar_url = account.get_avatar_url().await.unwrap(); + assert_eq!(res_avatar_url.as_deref(), Some(avatar_url)); + } + + // The cache was filled. + let res_avatar_url = account.get_cached_avatar_url().await.unwrap(); + assert_eq!(res_avatar_url.as_deref(), Some(avatar_url)); + + // Fetch it again from the homeserver, a missing value should empty the cache. + { + let _guard = server + .mock_get_profile_field(user_id, ProfileFieldName::AvatarUrl) + .ok_with_value(None) + .mock_once() + .named("get avatar_url profile field without value") + .mount_as_scoped() + .await; + + let res_avatar_url = account.get_avatar_url().await.unwrap(); + assert_eq!(res_avatar_url, None); + } + + // The cache was emptied. + let res_avatar_url = account.get_cached_avatar_url().await.unwrap(); + assert_eq!(res_avatar_url, None); +}