From ba195d2f8826d117dce9e662d8e322f483531d43 Mon Sep 17 00:00:00 2001 From: ganfra Date: Thu, 25 Jun 2026 15:55:28 +0200 Subject: [PATCH 1/5] feat(matrix-sdk): introduce unstable-msc4426 feature flag --- crates/matrix-sdk/Cargo.toml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/crates/matrix-sdk/Cargo.toml b/crates/matrix-sdk/Cargo.toml index 9ce54f397c2..9693540a57f 100644 --- a/crates/matrix-sdk/Cargo.toml +++ b/crates/matrix-sdk/Cargo.toml @@ -87,6 +87,9 @@ docsrs = ["e2e-encryption", "sqlite", "indexeddb", "sso-login", "qrcode", "feder # Add support for inline media galleries via msgtypes unstable-msc4274 = ["ruma/unstable-msc4274", "matrix-sdk-base/unstable-msc4274"] +# Add support for user status profile fields (m.status, m.call). +unstable-msc4426 = ["ruma/unstable-msc4426"] + experimental-search = ["matrix-sdk-search"] experimental-element-recent-emojis = ["matrix-sdk-base/experimental-element-recent-emojis"] From d050bc8b0b3bc8820a3d97df60c5111cc2e27f6b Mon Sep 17 00:00:00 2001 From: ganfra Date: Tue, 30 Jun 2026 11:18:48 +0200 Subject: [PATCH 2/5] chore(deps): bump ruma --- Cargo.lock | 19 ++++++++++--------- Cargo.toml | 2 +- .../src/widget/machine/driver_req.rs | 6 +++--- .../src/widget/machine/from_widget.rs | 6 +++--- .../matrix-sdk/src/widget/machine/incoming.rs | 2 +- crates/matrix-sdk/src/widget/matrix.rs | 6 +++--- 6 files changed, 21 insertions(+), 20 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index ecfc473e165..18616cc168b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5118,7 +5118,7 @@ dependencies = [ [[package]] name = "ruma" version = "0.16.0" -source = "git+https://github.com/ruma/ruma?rev=8beb557b466a43b057c9c155277c77fe8766fadf#8beb557b466a43b057c9c155277c77fe8766fadf" +source = "git+https://github.com/ruma/ruma?rev=a494c58c541c63042b26380a9238a24bdda4d85c#a494c58c541c63042b26380a9238a24bdda4d85c" dependencies = [ "assign", "js_int", @@ -5136,7 +5136,7 @@ dependencies = [ [[package]] name = "ruma-client-api" version = "0.24.0" -source = "git+https://github.com/ruma/ruma?rev=8beb557b466a43b057c9c155277c77fe8766fadf#8beb557b466a43b057c9c155277c77fe8766fadf" +source = "git+https://github.com/ruma/ruma?rev=a494c58c541c63042b26380a9238a24bdda4d85c#a494c58c541c63042b26380a9238a24bdda4d85c" dependencies = [ "as_variant", "assign", @@ -5158,7 +5158,7 @@ dependencies = [ [[package]] name = "ruma-common" version = "0.19.0" -source = "git+https://github.com/ruma/ruma?rev=8beb557b466a43b057c9c155277c77fe8766fadf#8beb557b466a43b057c9c155277c77fe8766fadf" +source = "git+https://github.com/ruma/ruma?rev=a494c58c541c63042b26380a9238a24bdda4d85c#a494c58c541c63042b26380a9238a24bdda4d85c" dependencies = [ "as_variant", "base64", @@ -5191,7 +5191,7 @@ dependencies = [ [[package]] name = "ruma-events" version = "0.34.0" -source = "git+https://github.com/ruma/ruma?rev=8beb557b466a43b057c9c155277c77fe8766fadf#8beb557b466a43b057c9c155277c77fe8766fadf" +source = "git+https://github.com/ruma/ruma?rev=a494c58c541c63042b26380a9238a24bdda4d85c#a494c58c541c63042b26380a9238a24bdda4d85c" dependencies = [ "as_variant", "indexmap", @@ -5215,7 +5215,7 @@ dependencies = [ [[package]] name = "ruma-federation-api" version = "0.15.0" -source = "git+https://github.com/ruma/ruma?rev=8beb557b466a43b057c9c155277c77fe8766fadf#8beb557b466a43b057c9c155277c77fe8766fadf" +source = "git+https://github.com/ruma/ruma?rev=a494c58c541c63042b26380a9238a24bdda4d85c#a494c58c541c63042b26380a9238a24bdda4d85c" dependencies = [ "headers", "http", @@ -5236,7 +5236,7 @@ dependencies = [ [[package]] name = "ruma-html" version = "0.8.0" -source = "git+https://github.com/ruma/ruma?rev=8beb557b466a43b057c9c155277c77fe8766fadf#8beb557b466a43b057c9c155277c77fe8766fadf" +source = "git+https://github.com/ruma/ruma?rev=a494c58c541c63042b26380a9238a24bdda4d85c#a494c58c541c63042b26380a9238a24bdda4d85c" dependencies = [ "as_variant", "html5ever", @@ -5247,7 +5247,7 @@ dependencies = [ [[package]] name = "ruma-identifiers-validation" version = "0.12.1" -source = "git+https://github.com/ruma/ruma?rev=8beb557b466a43b057c9c155277c77fe8766fadf#8beb557b466a43b057c9c155277c77fe8766fadf" +source = "git+https://github.com/ruma/ruma?rev=a494c58c541c63042b26380a9238a24bdda4d85c#a494c58c541c63042b26380a9238a24bdda4d85c" dependencies = [ "js_int", "thiserror 2.0.18", @@ -5256,7 +5256,7 @@ dependencies = [ [[package]] name = "ruma-macros" version = "0.19.0" -source = "git+https://github.com/ruma/ruma?rev=8beb557b466a43b057c9c155277c77fe8766fadf#8beb557b466a43b057c9c155277c77fe8766fadf" +source = "git+https://github.com/ruma/ruma?rev=a494c58c541c63042b26380a9238a24bdda4d85c#a494c58c541c63042b26380a9238a24bdda4d85c" dependencies = [ "as_variant", "cfg-if", @@ -5272,13 +5272,14 @@ dependencies = [ [[package]] name = "ruma-signatures" version = "0.21.0" -source = "git+https://github.com/ruma/ruma?rev=8beb557b466a43b057c9c155277c77fe8766fadf#8beb557b466a43b057c9c155277c77fe8766fadf" +source = "git+https://github.com/ruma/ruma?rev=a494c58c541c63042b26380a9238a24bdda4d85c#a494c58c541c63042b26380a9238a24bdda4d85c" dependencies = [ "base64", "ed25519-dalek", "pkcs8", "rand 0.10.1", "ruma-common", + "ruma-events", "serde_json", "sha2", "thiserror 2.0.18", diff --git a/Cargo.toml b/Cargo.toml index 59fc0eaa9c7..ac9228725fc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -70,7 +70,7 @@ rand = { version = "0.10.1", default-features = false, features = ["std", "std_r regex = { version = "1.12.2", default-features = false } reqwest = { version = "0.13.1", default-features = false } rmp-serde = { version = "1.3.0", default-features = false } -ruma = { git = "https://github.com/ruma/ruma", rev = "8beb557b466a43b057c9c155277c77fe8766fadf", features = [ +ruma = { git = "https://github.com/ruma/ruma", rev = "a494c58c541c63042b26380a9238a24bdda4d85c", features = [ "client-api-c", "compat-unset-avatar", "compat-upload-signatures", diff --git a/crates/matrix-sdk/src/widget/machine/driver_req.rs b/crates/matrix-sdk/src/widget/machine/driver_req.rs index 28bb1c1b34c..7c720ee8793 100644 --- a/crates/matrix-sdk/src/widget/machine/driver_req.rs +++ b/crates/matrix-sdk/src/widget/machine/driver_req.rs @@ -317,7 +317,7 @@ impl MatrixDriverRequest for SendToDeviceRequest { /// and `action`. Defined by [MSC4157](https://github.com/matrix-org/matrix-spec-proposals/pull/4157) #[derive(Deserialize, Debug, Clone)] pub(crate) struct UpdateDelayedEventRequest { - pub(crate) action: update_delayed_event::unstable::UpdateAction, + pub(crate) action: update_delayed_event::UpdateAction, pub(crate) delay_id: String, } @@ -328,10 +328,10 @@ impl From for MatrixDriverRequestData { } impl MatrixDriverRequest for UpdateDelayedEventRequest { - type Response = update_delayed_event::unstable::Response; + type Response = update_delayed_event::unstable_v1::Response; } -impl FromMatrixDriverResponse for update_delayed_event::unstable::Response { +impl FromMatrixDriverResponse for update_delayed_event::unstable_v1::Response { fn from_response(ev: MatrixDriverResponse) -> Option { match ev { MatrixDriverResponse::DelayedEventUpdated(response) => Some(response), diff --git a/crates/matrix-sdk/src/widget/machine/from_widget.rs b/crates/matrix-sdk/src/widget/machine/from_widget.rs index d3146c4c12d..e5264d7ff2c 100644 --- a/crates/matrix-sdk/src/widget/machine/from_widget.rs +++ b/crates/matrix-sdk/src/widget/machine/from_widget.rs @@ -252,15 +252,15 @@ impl From for SendEventResponse { } /// A wrapper type for the empty okay response from -/// [`update_delayed_event`](update_delayed_event::unstable::Response) +/// [`update_delayed_event`](update_delayed_event::unstable_v1::Response) /// which derives Serialize. (The response struct from Ruma does not derive /// serialize) /// This is intentionally an empty tuple struct (not a unit struct), so that it /// serializes to `{}` instead of `Null` when returned to the widget as json. #[derive(Serialize, Debug)] pub(crate) struct UpdateDelayedEventResponse {} -impl From for UpdateDelayedEventResponse { - fn from(_: update_delayed_event::unstable::Response) -> Self { +impl From for UpdateDelayedEventResponse { + fn from(_: update_delayed_event::unstable_v1::Response) -> Self { Self {} } } diff --git a/crates/matrix-sdk/src/widget/machine/incoming.rs b/crates/matrix-sdk/src/widget/machine/incoming.rs index ba59cada52e..d1a707385f5 100644 --- a/crates/matrix-sdk/src/widget/machine/incoming.rs +++ b/crates/matrix-sdk/src/widget/machine/incoming.rs @@ -86,7 +86,7 @@ pub(crate) enum MatrixDriverResponse { ToDeviceSent(SendToDeviceEventResponse), /// Client updated a delayed event. /// A response to a [`MatrixDriverRequestData::UpdateDelayedEvent`] command. - DelayedEventUpdated(delayed_events::update_delayed_event::unstable::Response), + DelayedEventUpdated(delayed_events::update_delayed_event::unstable_v1::Response), /// The client successfully downloaded a file from a widget action. FileDownloaded(DownloadFileResponse), } diff --git a/crates/matrix-sdk/src/widget/matrix.rs b/crates/matrix-sdk/src/widget/matrix.rs index 9c08bd40474..85c8a70c436 100644 --- a/crates/matrix-sdk/src/widget/matrix.rs +++ b/crates/matrix-sdk/src/widget/matrix.rs @@ -28,7 +28,7 @@ use ruma::{ EventId, OwnedDeviceId, OwnedMxcUri, OwnedUserId, RoomId, TransactionId, api::client::{ account::request_openid_token::v3::{Request as OpenIdRequest, Response as OpenIdResponse}, - delayed_events::{self, update_delayed_event::unstable::UpdateAction}, + delayed_events::{self, update_delayed_event::UpdateAction}, filter::RoomEventFilter, to_device::send_event_to_device::v3::Request as RumaToDeviceRequest, }, @@ -219,8 +219,8 @@ impl MatrixDriver { &self, delay_id: String, action: UpdateAction, - ) -> Result { - let r = delayed_events::update_delayed_event::unstable::Request::new(delay_id, action); + ) -> Result { + let r = delayed_events::update_delayed_event::unstable_v1::Request::new(delay_id, action); self.room.client.send(r).await.map_err(|error| Error::Http(Box::new(error))) } From c4e0023a98d6d7257deb49c451ccaddfe6f34656 Mon Sep 17 00:00:00 2001 From: ganfra Date: Tue, 30 Jun 2026 14:03:24 +0200 Subject: [PATCH 3/5] feat(ffi): set/clear user status --- bindings/matrix-sdk-ffi/Cargo.toml | 5 +- bindings/matrix-sdk-ffi/src/client.rs | 28 +++++++ bindings/matrix-sdk-ffi/src/ruma.rs | 24 ++++++ crates/matrix-sdk/src/account.rs | 46 +++++++++++ .../matrix-sdk/tests/integration/account.rs | 80 +++++++++++++++++++ 5 files changed, 182 insertions(+), 1 deletion(-) diff --git a/bindings/matrix-sdk-ffi/Cargo.toml b/bindings/matrix-sdk-ffi/Cargo.toml index f414ad03074..df978afdff5 100644 --- a/bindings/matrix-sdk-ffi/Cargo.toml +++ b/bindings/matrix-sdk-ffi/Cargo.toml @@ -24,7 +24,7 @@ crate-type = [ ] [features] -default = ["bundled-sqlite", "unstable-msc4274", "experimental-element-recent-emojis", "experimental-push-secrets", "experimental-search"] +default = ["bundled-sqlite", "unstable-msc4274", "unstable-msc4426", "experimental-element-recent-emojis", "experimental-push-secrets", "experimental-search"] experimental-search = ["matrix-sdk/experimental-search"] # Use SQLite for the session storage. sqlite = ["matrix-sdk/sqlite"] @@ -33,6 +33,7 @@ bundled-sqlite = ["sqlite", "matrix-sdk/bundled-sqlite"] # Use IndexedDB for the session storage. indexeddb = ["matrix-sdk/indexeddb"] unstable-msc4274 = ["matrix-sdk-ui/unstable-msc4274"] +unstable-msc4426 = ["matrix-sdk/unstable-msc4426"] # Required when targeting a Javascript environment, like Wasm in a browser. js = ["matrix-sdk-ui/js"] # Enable sentry error monitoring, not compatible with Wasm platforms. @@ -88,6 +89,8 @@ ruma = { workspace = true, features = [ "unstable-msc2545", # Room language event type "unstable-msc4334", + # User status profile fields (m.status, m.call). + "unstable-msc4426", "unstable-uniffi", ] } serde.workspace = true diff --git a/bindings/matrix-sdk-ffi/src/client.rs b/bindings/matrix-sdk-ffi/src/client.rs index f4829d6bef3..d036301fc3c 100644 --- a/bindings/matrix-sdk-ffi/src/client.rs +++ b/bindings/matrix-sdk-ffi/src/client.rs @@ -125,6 +125,8 @@ use super::{ room::{Room, room_info::RoomInfo}, session_verification::SessionVerificationController, }; +#[cfg(feature = "unstable-msc4426")] +use crate::ruma::UserStatus; use crate::{ ClientError, authentication::{ @@ -2518,6 +2520,32 @@ impl From<&search_users::v3::User> for UserProfile { } } +#[cfg(feature = "unstable-msc4426")] +#[matrix_sdk_ffi_macros::export] +impl Client { + /// Set the current user's status (MSC4426 `m.status` profile field). + /// + /// Replaces any existing status. Use [`Self::clear_user_status`] to + /// remove it. + pub async fn set_user_status(&self, status: UserStatus) -> Result<(), ClientError> { + self.inner.account().set_status(status.emoji, status.text).await?; + Ok(()) + } + + /// Clear the current user's status (MSC4426). + /// + /// Deletes both `m.status` and `m.call` concurrently. Clearing `m.status` + /// alone would let `m.call` immediately reappear if the user were in a + /// call. + pub async fn clear_user_status(&self) -> Result<(), ClientError> { + let account = self.inner.account(); + let (status_res, call_res) = tokio::join!(account.clear_status(), account.clear_call()); + status_res?; + call_res?; + Ok(()) + } +} + impl Client { fn process_session_change(&self, session_change: SessionChange) { if let Some(delegate_data) = self.delegate_data.get() { diff --git a/bindings/matrix-sdk-ffi/src/ruma.rs b/bindings/matrix-sdk-ffi/src/ruma.rs index 591aed60bd1..5747cdaf991 100644 --- a/bindings/matrix-sdk-ffi/src/ruma.rs +++ b/bindings/matrix-sdk-ffi/src/ruma.rs @@ -20,6 +20,8 @@ use std::{ use extension_trait::extension_trait; use matrix_sdk::attachment::{BaseAudioInfo, BaseFileInfo, BaseImageInfo, BaseVideoInfo}; +#[cfg(feature = "unstable-msc4426")] +use ruma::profile::StatusProfileField; use ruma::{ KeyDerivationAlgorithm as RumaKeyDerivationAlgorithm, MatrixToUri, MatrixUri as RumaMatrixUri, OwnedRoomId, OwnedUserId, UInt, UserId, assign, @@ -220,6 +222,28 @@ impl From for PresenceState { } } +/// A user-set status (MSC4426 `m.status` profile field value). +#[cfg(feature = "unstable-msc4426")] +#[derive(Debug, Clone, uniffi::Record)] +pub struct UserStatus { + pub emoji: String, + pub text: String, +} + +#[cfg(feature = "unstable-msc4426")] +impl From for StatusProfileField { + fn from(value: UserStatus) -> Self { + Self::new(value.text, value.emoji) + } +} + +#[cfg(feature = "unstable-msc4426")] +impl From for UserStatus { + fn from(value: StatusProfileField) -> Self { + Self { emoji: value.emoji, text: value.text } + } +} + #[matrix_sdk_ffi_macros::export] pub fn message_event_content_new( msgtype: MessageType, diff --git a/crates/matrix-sdk/src/account.rs b/crates/matrix-sdk/src/account.rs index 1ff2bc55f2a..acab6efee62 100644 --- a/crates/matrix-sdk/src/account.rs +++ b/crates/matrix-sdk/src/account.rs @@ -28,8 +28,12 @@ use matrix_sdk_base::{ store::StateStoreExt, }; use mime::Mime; +#[cfg(feature = "unstable-msc4426")] +use ruma::SecondsSinceUnixEpoch; #[cfg(feature = "experimental-element-recent-emojis")] use ruma::api::client::config::set_global_account_data::v3::Request as UpdateGlobalAccountDataRequest; +#[cfg(feature = "unstable-msc4426")] +use ruma::profile::{CallProfileField, StatusProfileField}; use ruma::{ ClientSecret, MxcUri, OwnedMxcUri, OwnedRoomId, OwnedUserId, RoomId, SessionId, UInt, UserId, api::{ @@ -446,6 +450,48 @@ impl Account { Ok(response.value) } + /// Set the user's status (MSC4426 `m.status` profile field). + /// + /// Replaces any existing status. Use [`Self::clear_status`] to remove it. + /// + /// # Arguments + /// + /// * `emoji` - the status emoji. The MSC limits this to 32 bytes; not + /// enforced client-side. + /// * `text` - the status text. The MSC limits this to 256 bytes; not + /// enforced client-side. + #[cfg(feature = "unstable-msc4426")] + pub async fn set_status(&self, emoji: String, text: String) -> Result<()> { + let value = StatusProfileField::new(text, emoji); + self.set_profile_field(ProfileFieldValue::Status(value)).await + } + + /// Clear the user's status (deletes the MSC4426 `m.status` profile field). + #[cfg(feature = "unstable-msc4426")] + pub async fn clear_status(&self) -> Result<()> { + self.delete_profile_field(ProfileFieldName::Status).await + } + + /// Set the user's call indicator (MSC4426 `m.call` profile field). + /// + /// # Arguments + /// + /// * `call_joined_ts` - when the user joined the current call, in seconds + /// since the Unix epoch. `None` if the joined time isn't known. + #[cfg(feature = "unstable-msc4426")] + pub async fn set_call(&self, call_joined_ts: Option) -> Result<()> { + let mut value = CallProfileField::new(); + value.call_joined_ts = call_joined_ts; + self.set_profile_field(ProfileFieldValue::Call(value)).await + } + + /// Clear the user's call indicator (deletes the MSC4426 `m.call` profile + /// field). + #[cfg(feature = "unstable-msc4426")] + pub async fn clear_call(&self) -> Result<()> { + self.delete_profile_field(ProfileFieldName::Call).await + } + /// Set the given field of our own user's profile. /// /// [`Client::homeserver_capabilities()`] should be called first to check it diff --git a/crates/matrix-sdk/tests/integration/account.rs b/crates/matrix-sdk/tests/integration/account.rs index 36f6247511c..71a8ab024f9 100644 --- a/crates/matrix-sdk/tests/integration/account.rs +++ b/crates/matrix-sdk/tests/integration/account.rs @@ -322,3 +322,83 @@ async fn test_get_cached_avatar_url() { let res_avatar_url = account.get_cached_avatar_url().await.unwrap(); assert_eq!(res_avatar_url, None); } + +#[cfg(feature = "unstable-msc4426")] +#[async_test] +async fn test_set_status() { + 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::Status) + .ok() + .mock_once() + .named("set org.matrix.msc4426.status profile field") + .mount() + .await; + + let account = client.account(); + account.set_status("🌴".to_owned(), "Away".to_owned()).await.unwrap(); +} + +#[cfg(feature = "unstable-msc4426")] +#[async_test] +async fn test_clear_status() { + 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::Status) + .ok() + .mock_once() + .named("delete org.matrix.msc4426.status profile field") + .mount() + .await; + + let account = client.account(); + account.clear_status().await.unwrap(); +} + +#[cfg(feature = "unstable-msc4426")] +#[async_test] +async fn test_set_call() { + use ruma::{SecondsSinceUnixEpoch, uint}; + + 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(); + + // Two PUTs expected: one with a join ts, one without. + server + .mock_set_profile_field(user_id, ProfileFieldName::Call) + .ok() + .expect(2) + .named("set org.matrix.msc4426.call profile field") + .mount() + .await; + + let account = client.account(); + account.set_call(Some(SecondsSinceUnixEpoch(uint!(1_770_140_640)))).await.unwrap(); + account.set_call(None).await.unwrap(); +} + +#[cfg(feature = "unstable-msc4426")] +#[async_test] +async fn test_clear_call() { + 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::Call) + .ok() + .mock_once() + .named("delete org.matrix.msc4426.call profile field") + .mount() + .await; + + let account = client.account(); + account.clear_call().await.unwrap(); +} From 888ff578bd964b53594a9875728a0c364ae86be7 Mon Sep 17 00:00:00 2001 From: ganfra Date: Tue, 30 Jun 2026 14:04:56 +0200 Subject: [PATCH 4/5] feat(ffi): expose user status in UserProfile --- bindings/matrix-sdk-ffi/src/client.rs | 34 ++++++++- bindings/matrix-sdk-ffi/src/ruma.rs | 35 +++++++++- .../matrix-sdk/tests/integration/account.rs | 70 +++++++++++++++++++ 3 files changed, 135 insertions(+), 4 deletions(-) diff --git a/bindings/matrix-sdk-ffi/src/client.rs b/bindings/matrix-sdk-ffi/src/client.rs index d036301fc3c..2a3ed08ef2c 100644 --- a/bindings/matrix-sdk-ffi/src/client.rs +++ b/bindings/matrix-sdk-ffi/src/client.rs @@ -75,6 +75,8 @@ use matrix_sdk_ui::{ }; use mime::Mime; use oauth2::Scope; +#[cfg(feature = "unstable-msc4426")] +use ruma::api::client::profile::{Call, Status}; use ruma::{ OwnedDeviceId, OwnedMxcUri, OwnedServerName, RoomAliasId, RoomOrAliasId, ServerName, api::{ @@ -126,7 +128,7 @@ use super::{ session_verification::SessionVerificationController, }; #[cfg(feature = "unstable-msc4426")] -use crate::ruma::UserStatus; +use crate::ruma::{UserCall, UserStatus}; use crate::{ ClientError, authentication::{ @@ -2496,6 +2498,17 @@ pub struct UserProfile { pub user_id: String, pub display_name: Option, pub avatar_url: Option, + + /// The user's status (MSC4426 `m.status` profile field), if set. + #[cfg(feature = "unstable-msc4426")] + pub status: Option, + + /// Set when the user is in a call (MSC4426 `m.call` profile field). + /// + /// `None` means the user is not in a call. `Some(UserCall { call_joined_ts: + /// None })` means the user is in a call but the join time wasn't recorded. + #[cfg(feature = "unstable-msc4426")] + pub call: Option, } impl UserProfile { @@ -2506,7 +2519,20 @@ impl UserProfile { let display_name = response.get_static::()?; let avatar_url = response.get_static::()?.map(|url| url.to_string()); - Ok(UserProfile { user_id: user_id.to_string(), display_name, avatar_url }) + #[cfg(feature = "unstable-msc4426")] + let status = response.get_static::()?.map(UserStatus::from); + #[cfg(feature = "unstable-msc4426")] + let call = response.get_static::()?.map(UserCall::from); + + Ok(UserProfile { + user_id: user_id.to_string(), + display_name, + avatar_url, + #[cfg(feature = "unstable-msc4426")] + status, + #[cfg(feature = "unstable-msc4426")] + call, + }) } } @@ -2516,6 +2542,10 @@ impl From<&search_users::v3::User> for UserProfile { user_id: value.user_id.to_string(), display_name: value.display_name.clone(), avatar_url: value.avatar_url.as_ref().map(|url| url.to_string()), + #[cfg(feature = "unstable-msc4426")] + status: None, + #[cfg(feature = "unstable-msc4426")] + call: None, } } } diff --git a/bindings/matrix-sdk-ffi/src/ruma.rs b/bindings/matrix-sdk-ffi/src/ruma.rs index 5747cdaf991..553b76b6948 100644 --- a/bindings/matrix-sdk-ffi/src/ruma.rs +++ b/bindings/matrix-sdk-ffi/src/ruma.rs @@ -20,8 +20,6 @@ use std::{ use extension_trait::extension_trait; use matrix_sdk::attachment::{BaseAudioInfo, BaseFileInfo, BaseImageInfo, BaseVideoInfo}; -#[cfg(feature = "unstable-msc4426")] -use ruma::profile::StatusProfileField; use ruma::{ KeyDerivationAlgorithm as RumaKeyDerivationAlgorithm, MatrixToUri, MatrixUri as RumaMatrixUri, OwnedRoomId, OwnedUserId, UInt, UserId, assign, @@ -87,6 +85,11 @@ use ruma::{ }, serde::JsonObject, }; +#[cfg(feature = "unstable-msc4426")] +use ruma::{ + SecondsSinceUnixEpoch, + profile::{CallProfileField, StatusProfileField}, +}; use tracing::info; use crate::{ @@ -244,6 +247,34 @@ impl From for UserStatus { } } +/// The user's call indicator (MSC4426 `m.call` profile field value). +/// +/// Presence of a `UserCall` value means the user is in a call. The optional +/// `call_joined_ts` is the Unix-epoch seconds when they joined, if known. +#[cfg(feature = "unstable-msc4426")] +#[derive(Debug, Clone, uniffi::Record)] +pub struct UserCall { + pub call_joined_ts: Option, +} + +#[cfg(feature = "unstable-msc4426")] +impl From for UserCall { + fn from(value: CallProfileField) -> Self { + Self { call_joined_ts: value.call_joined_ts.map(|ts| u64::from(ts.get())) } + } +} + +#[cfg(feature = "unstable-msc4426")] +impl From for CallProfileField { + fn from(value: UserCall) -> Self { + let mut field = CallProfileField::new(); + field.call_joined_ts = value + .call_joined_ts + .map(|secs| SecondsSinceUnixEpoch(UInt::try_from(secs).unwrap_or_default())); + field + } +} + #[matrix_sdk_ffi_macros::export] pub fn message_event_content_new( msgtype: MessageType, diff --git a/crates/matrix-sdk/tests/integration/account.rs b/crates/matrix-sdk/tests/integration/account.rs index 71a8ab024f9..1f61d8669d2 100644 --- a/crates/matrix-sdk/tests/integration/account.rs +++ b/crates/matrix-sdk/tests/integration/account.rs @@ -402,3 +402,73 @@ async fn test_clear_call() { let account = client.account(); account.clear_call().await.unwrap(); } + +#[cfg(feature = "unstable-msc4426")] +#[async_test] +async fn test_fetch_user_profile_with_status() { + use ruma::{ + SecondsSinceUnixEpoch, + api::client::profile::{Call, Status}, + profile::{CallProfileField, StatusProfileField}, + uint, + }; + + let server = MatrixMockServer::new().await; + let client = server.client_builder().build().await; + let user_id = client.user_id().unwrap(); + + let mut call_field = CallProfileField::new(); + call_field.call_joined_ts = Some(SecondsSinceUnixEpoch(uint!(1_770_140_640))); + + server + .mock_get_profile(user_id) + .ok_with_fields(vec![ + ProfileFieldValue::DisplayName("Alice".to_owned()), + ProfileFieldValue::AvatarUrl(mxc_uri!("mxc://localhost/abc").to_owned()), + ProfileFieldValue::Status(StatusProfileField::new("Away".to_owned(), "🌴".to_owned())), + ProfileFieldValue::Call(call_field), + ]) + .mock_once() + .named("get profile with status and call") + .mount() + .await; + + let profile = client.account().fetch_user_profile().await.unwrap(); + + assert_eq!(profile.get_static::().unwrap().as_deref(), Some("Alice")); + let status = profile.get_static::().unwrap().unwrap(); + assert_eq!(status.emoji, "🌴"); + assert_eq!(status.text, "Away"); + let call = profile.get_static::().unwrap().unwrap(); + assert_eq!(call.call_joined_ts, Some(SecondsSinceUnixEpoch(uint!(1_770_140_640)))); +} + +#[cfg(feature = "unstable-msc4426")] +#[async_test] +async fn test_fetch_user_profile_call_without_ts() { + use ruma::{ + api::client::profile::{Call, Status}, + profile::CallProfileField, + }; + + 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::DisplayName("Bob".to_owned()), + ProfileFieldValue::Call(CallProfileField::new()), + ]) + .mock_once() + .named("get profile with call but no ts") + .mount() + .await; + + let profile = client.account().fetch_user_profile().await.unwrap(); + + assert_eq!(profile.get_static::().unwrap(), None); + let call = profile.get_static::().unwrap().unwrap(); + assert_eq!(call.call_joined_ts, None); +} From 13a78e04ea02ad459ae3f1e7d49089e4be5f2e85 Mon Sep 17 00:00:00 2001 From: ganfra Date: Tue, 30 Jun 2026 14:04:57 +0200 Subject: [PATCH 5/5] doc: changelog for MSC4426 --- bindings/matrix-sdk-ffi/changelog.d/6616.added.md | 1 + crates/matrix-sdk/changelog.d/6616.added.md | 3 +++ 2 files changed, 4 insertions(+) create mode 100644 bindings/matrix-sdk-ffi/changelog.d/6616.added.md create mode 100644 crates/matrix-sdk/changelog.d/6616.added.md diff --git a/bindings/matrix-sdk-ffi/changelog.d/6616.added.md b/bindings/matrix-sdk-ffi/changelog.d/6616.added.md new file mode 100644 index 00000000000..19972cf6776 --- /dev/null +++ b/bindings/matrix-sdk-ffi/changelog.d/6616.added.md @@ -0,0 +1 @@ +Expose [MSC4426] user-status profile fields through the FFI. \ No newline at end of file diff --git a/crates/matrix-sdk/changelog.d/6616.added.md b/crates/matrix-sdk/changelog.d/6616.added.md new file mode 100644 index 00000000000..6e0ce042873 --- /dev/null +++ b/crates/matrix-sdk/changelog.d/6616.added.md @@ -0,0 +1,3 @@ +Added `Account::set_status`, `Account::clear_status`, `Account::set_call`, and +`Account::clear_call` to write [MSC4426] user-status profile fields (`m.status` +and `m.call`). All four are gated behind the new `unstable-msc4426` feature.