diff --git a/bindings/matrix-sdk-ffi/Cargo.toml b/bindings/matrix-sdk-ffi/Cargo.toml index 9df19a57e56..b439896813a 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", "matrix-sdk-ui/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-ui/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/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/bindings/matrix-sdk-ffi/changelog.d/6704.added.md b/bindings/matrix-sdk-ffi/changelog.d/6704.added.md new file mode 100644 index 00000000000..cc7b5ffee32 --- /dev/null +++ b/bindings/matrix-sdk-ffi/changelog.d/6704.added.md @@ -0,0 +1,4 @@ +Add `status` and `call` fields to the FFI `RoomMember` and `ProfileDetails`, +exposing the user's +[MSC4426](https://github.com/matrix-org/matrix-spec-proposals/pull/4426) user +status and call indicator from their global profile. diff --git a/bindings/matrix-sdk-ffi/src/client.rs b/bindings/matrix-sdk-ffi/src/client.rs index f4829d6bef3..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::{ @@ -125,6 +127,8 @@ use super::{ room::{Room, room_info::RoomInfo}, session_verification::SessionVerificationController, }; +#[cfg(feature = "unstable-msc4426")] +use crate::ruma::{UserCall, UserStatus}; use crate::{ ClientError, authentication::{ @@ -2494,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 { @@ -2504,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, + }) } } @@ -2514,10 +2542,40 @@ 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, } } } +#[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/room_member.rs b/bindings/matrix-sdk-ffi/src/room_member.rs index 0be12d4dd85..0920eee8e1a 100644 --- a/bindings/matrix-sdk-ffi/src/room_member.rs +++ b/bindings/matrix-sdk-ffi/src/room_member.rs @@ -2,6 +2,8 @@ use matrix_sdk::room::{RoomMember as SdkRoomMember, RoomMemberRole}; use ruma::{UserId, events::room::power_levels::UserPowerLevel}; use crate::error::{ClientError, NotYetImplemented}; +#[cfg(feature = "unstable-msc4426")] +use crate::ruma::{UserCall, UserStatus}; #[derive(Clone, uniffi::Enum)] pub enum MembershipState { @@ -90,6 +92,10 @@ pub struct RoomMember { pub user_id: String, pub display_name: Option, pub avatar_url: Option, + #[cfg(feature = "unstable-msc4426")] + pub status: Option, + #[cfg(feature = "unstable-msc4426")] + pub call: Option, pub membership: MembershipState, pub is_name_ambiguous: bool, pub power_level: PowerLevel, @@ -107,6 +113,10 @@ impl TryFrom for RoomMember { user_id: m.user_id().to_string(), display_name: m.display_name().map(|s| s.to_owned()), avatar_url: m.avatar_url().map(|a| a.to_string()), + #[cfg(feature = "unstable-msc4426")] + status: m.status().cloned().map(UserStatus::from), + #[cfg(feature = "unstable-msc4426")] + call: m.call().cloned().map(UserCall::from), membership: m.membership().clone().try_into()?, is_name_ambiguous: m.name_ambiguous(), power_level: m.power_level().try_into()?, diff --git a/bindings/matrix-sdk-ffi/src/ruma.rs b/bindings/matrix-sdk-ffi/src/ruma.rs index 591aed60bd1..553b76b6948 100644 --- a/bindings/matrix-sdk-ffi/src/ruma.rs +++ b/bindings/matrix-sdk-ffi/src/ruma.rs @@ -85,6 +85,11 @@ use ruma::{ }, serde::JsonObject, }; +#[cfg(feature = "unstable-msc4426")] +use ruma::{ + SecondsSinceUnixEpoch, + profile::{CallProfileField, StatusProfileField}, +}; use tracing::info; use crate::{ @@ -220,6 +225,56 @@ 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 } + } +} + +/// 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/bindings/matrix-sdk-ffi/src/timeline/mod.rs b/bindings/matrix-sdk-ffi/src/timeline/mod.rs index 2ec184c6859..eb2b875859c 100644 --- a/bindings/matrix-sdk-ffi/src/timeline/mod.rs +++ b/bindings/matrix-sdk-ffi/src/timeline/mod.rs @@ -61,6 +61,8 @@ use tracing::{error, warn}; use uuid::Uuid; pub use self::{content::TimelineItemContent, msg_like::MessageContent}; +#[cfg(feature = "unstable-msc4426")] +use crate::ruma::{UserCall, UserStatus}; use crate::{ error::{ClientError, RoomError}, event::EventOrTransactionId, @@ -1075,8 +1077,18 @@ pub struct EventTimelineItemDebugInfo { pub enum ProfileDetails { Unavailable, Pending, - Ready { display_name: Option, display_name_ambiguous: bool, avatar_url: Option }, - Error { message: String }, + Ready { + display_name: Option, + display_name_ambiguous: bool, + avatar_url: Option, + #[cfg(feature = "unstable-msc4426")] + status: Option, + #[cfg(feature = "unstable-msc4426")] + call: Option, + }, + Error { + message: String, + }, } impl From> for ProfileDetails { @@ -1088,6 +1100,10 @@ impl From> for ProfileDetails { display_name: profile.display_name, display_name_ambiguous: profile.display_name_ambiguous, avatar_url: profile.avatar_url.as_ref().map(ToString::to_string), + #[cfg(feature = "unstable-msc4426")] + status: profile.status.map(UserStatus::from), + #[cfg(feature = "unstable-msc4426")] + call: profile.call.map(UserCall::from), }, TimelineDetails::Error(e) => Self::Error { message: e.to_string() }, } @@ -1103,6 +1119,10 @@ impl From<&TimelineDetails> for ProfileDetails { display_name: profile.display_name.clone(), display_name_ambiguous: profile.display_name_ambiguous, avatar_url: profile.avatar_url.as_ref().map(ToString::to_string), + #[cfg(feature = "unstable-msc4426")] + status: profile.status.clone().map(UserStatus::from), + #[cfg(feature = "unstable-msc4426")] + call: profile.call.clone().map(UserCall::from), }, TimelineDetails::Error(e) => Self::Error { message: e.to_string() }, } diff --git a/crates/matrix-sdk-base/Cargo.toml b/crates/matrix-sdk-base/Cargo.toml index 624e440eb51..2bb76b5f1fb 100644 --- a/crates/matrix-sdk-base/Cargo.toml +++ b/crates/matrix-sdk-base/Cargo.toml @@ -68,6 +68,9 @@ testing = [ # Add support for inline media galleries via msgtypes unstable-msc4274 = [] +# Add support for user status profile fields (m.status, m.call). +unstable-msc4426 = ["ruma/unstable-msc4426"] + experimental-element-recent-emojis = [] [dependencies] diff --git a/crates/matrix-sdk-base/changelog.d/6704.added.md b/crates/matrix-sdk-base/changelog.d/6704.added.md new file mode 100644 index 00000000000..2e61c4e5eed --- /dev/null +++ b/crates/matrix-sdk-base/changelog.d/6704.added.md @@ -0,0 +1,3 @@ +Surface the [MSC4426](https://github.com/matrix-org/matrix-spec-proposals/pull/4426) +user status and call indicator on `RoomMember`, sourced from the user's global +profile. Read them with `RoomMember::status()` and `RoomMember::call()`. diff --git a/crates/matrix-sdk-base/src/client.rs b/crates/matrix-sdk-base/src/client.rs index c6a326c4f6f..d92354ae346 100644 --- a/crates/matrix-sdk-base/src/client.rs +++ b/crates/matrix-sdk-base/src/client.rs @@ -119,6 +119,10 @@ pub struct BaseClient { /// Observable of when a user is ignored/unignored. pub(crate) ignore_user_list_changes: SharedObservable>, + /// Broadcasts the user IDs whose global profile changed during a sync. + /// Requires the Profiles sliding sync extension to be enabled. + pub(crate) global_profile_updates_sender: broadcast::Sender>, + /// The strategy to use for picking recipient devices, when sending an /// encrypted message. #[cfg(feature = "e2e-encryption")] @@ -198,6 +202,7 @@ impl BaseClient { #[cfg(feature = "e2e-encryption")] olm_machine: Default::default(), ignore_user_list_changes: Default::default(), + global_profile_updates_sender: broadcast::Sender::new(16), #[cfg(feature = "e2e-encryption")] room_key_recipient_strategy: Default::default(), #[cfg(feature = "e2e-encryption")] @@ -235,6 +240,7 @@ impl BaseClient { crypto_store: self.crypto_store.clone(), olm_machine: self.olm_machine.clone(), ignore_user_list_changes: Default::default(), + global_profile_updates_sender: broadcast::Sender::new(16), room_key_recipient_strategy: self.room_key_recipient_strategy.clone(), decryption_settings: self.decryption_settings.clone(), handle_verification_events, @@ -1114,6 +1120,17 @@ impl BaseClient { self.state_store.room_info_notable_update_sender.subscribe() } + /// Returns a receiver of the user IDs whose global profile changed during a + /// sync. Consumers can use this as a trigger to e.g. merge any global + /// fields into a user's room profile. + /// + /// Requires the Profiles sliding sync extension to be enabled. + pub fn subscribe_to_global_profile_updates( + &self, + ) -> broadcast::Receiver> { + self.global_profile_updates_sender.subscribe() + } + /// Checks whether the provided `user_id` belongs to an ignored user. pub async fn is_user_ignored(&self, user_id: &UserId) -> bool { match self.state_store.get_account_data_event_static::().await @@ -1263,6 +1280,8 @@ mod tests { BOB, InvitedRoomBuilder, LeftRoomBuilder, SyncResponseBuilder, async_test, event_factory::EventFactory, ruma_response_from_json, }; + #[cfg(feature = "unstable-msc4426")] + use ruma::profile::{ProfileFieldValue, StatusProfileField, UserProfileUpdate}; use ruma::{ api::client::{self as api, sync::sync_events::v5}, event_id, @@ -1280,6 +1299,8 @@ mod tests { store::{RoomLoadSettings, StateStoreExt, StoreConfig}, test_utils::logged_in_base_client, }; + #[cfg(feature = "unstable-msc4426")] + use crate::{RoomMemberships, store::StateChanges}; #[test] fn test_requested_required_states() { @@ -1756,6 +1777,69 @@ mod tests { assert_eq!(member.avatar_url().unwrap().to_string(), "mxc://localhost/fewjilfewjil42"); } + #[cfg(feature = "unstable-msc4426")] + #[async_test] + async fn test_room_member_carries_global_profile_status() { + let user_id = user_id!("@alice:example.org"); + let room_id = room_id!("!ithpyNKDtmhneaTQja:example.org"); + + let client = BaseClient::new( + StoreConfig::new(CrossProcessLockConfig::SingleProcess), + ThreadingSupport::Disabled, + DmRoomDefinition::default(), + ); + client + .activate( + SessionMeta { user_id: user_id.to_owned(), device_id: "FOOBAR".into() }, + RoomLoadSettings::default(), + #[cfg(feature = "e2e-encryption")] + None, + ) + .await + .unwrap(); + + // Let the SDK know about the room, with the user as a joined member. + let f = EventFactory::new().sender(user_id); + let mut sync_builder = SyncResponseBuilder::new(); + let response = sync_builder + .add_joined_room( + matrix_sdk_test::JoinedRoomBuilder::new(room_id).add_state_event(f.member(user_id)), + ) + .build_sync_response(); + client.receive_sync_response(response).await.unwrap(); + + let room = client.get_room(room_id).unwrap(); + + // Without a global profile, the member has no status. + let member = room.get_member(user_id).await.expect("ok").expect("exists"); + assert!(member.status().is_none()); + + // Save a global profile carrying an `m.status` for the member. + let mut changes = StateChanges::default(); + changes.global_profiles.insert( + user_id.to_owned(), + UserProfileUpdate::from_iter([ProfileFieldValue::Status(StatusProfileField::new( + "Working".to_owned(), + "💻".to_owned(), + ))]), + ); + client.state_store().save_changes(&changes).await.unwrap(); + + // `get_member` surfaces the status from the global profile. + let member = room.get_member(user_id).await.expect("ok").expect("exists"); + let status = member.status().expect("status is set"); + assert_eq!(status.text, "Working"); + assert_eq!(status.emoji, "💻"); + + // `members` surfaces it too. + let members = room.members(RoomMemberships::JOIN).await.unwrap(); + let member = + members.iter().find(|m| m.user_id() == user_id).expect("member is in the list"); + let status = member.status().expect("status is set"); + assert_eq!(status.text, "Working"); + assert_eq!(status.emoji, "💻"); + } + #[async_test] async fn test_ignored_user_list_changes() { let user_id = user_id!("@alice:example.org"); diff --git a/crates/matrix-sdk-base/src/room/members.rs b/crates/matrix-sdk-base/src/room/members.rs index 81a2739dcdf..b645bad5275 100644 --- a/crates/matrix-sdk-base/src/room/members.rs +++ b/crates/matrix-sdk-base/src/room/members.rs @@ -20,6 +20,8 @@ use std::{ use bitflags::bitflags; use futures_util::future; +#[cfg(feature = "unstable-msc4426")] +use ruma::profile::{Call, CallProfileField, Status, StatusProfileField, UserProfile}; use ruma::{ Int, MxcUri, OwnedUserId, UserId, events::{ @@ -93,6 +95,9 @@ impl Room { let mut profiles = self.store.get_profiles(self.room_id(), &user_ids).await?; + #[cfg(feature = "unstable-msc4426")] + let mut global_profiles = self.store.get_global_profiles(&user_ids).await?; + let mut presences = self .store .get_presence_events(&user_ids) @@ -110,8 +115,17 @@ impl Room { for event in member_events { let profile = profiles.remove(event.user_id()); + #[cfg(feature = "unstable-msc4426")] + let global_profile = global_profiles.remove(event.user_id()); let presence = presences.remove(event.user_id()); - members.push(RoomMember::from_parts(event, profile, presence, &room_info)) + members.push(RoomMember::from_parts( + event, + profile, + #[cfg(feature = "unstable-msc4426")] + global_profile, + presence, + &room_info, + )) } Ok(members) @@ -157,6 +171,16 @@ impl Room { let profile = async { self.store.get_profile(self.room_id(), user_id).await }; + #[cfg(feature = "unstable-msc4426")] + let (Some(event), presence, profile, global_profile) = + future::try_join4(event, presence, profile, async { + self.store.get_global_profile(user_id).await + }) + .await? + else { + return Ok(None); + }; + #[cfg(not(feature = "unstable-msc4426"))] let (Some(event), presence, profile) = future::try_join3(event, presence, profile).await? else { return Ok(None); @@ -165,7 +189,14 @@ impl Room { let display_names = [event.display_name()]; let room_info = self.member_room_info(&display_names).await?; - Ok(Some(RoomMember::from_parts(event, profile, presence, &room_info))) + Ok(Some(RoomMember::from_parts( + event, + profile, + #[cfg(feature = "unstable-msc4426")] + global_profile, + presence, + &room_info, + ))) } /// The current `MemberRoomInfo` for this room. @@ -212,6 +243,12 @@ pub struct RoomMember { // Stored in addition to the latest member event overall to get displayname // and avatar from, which should be ignored on events sent by others. pub(crate) profile: Arc>, + // The user's status, taken from their global profile. + #[cfg(feature = "unstable-msc4426")] + pub(crate) status: Option, + // The user's call indicator, taken from their global profile. + #[cfg(feature = "unstable-msc4426")] + pub(crate) call: Option, #[allow(dead_code)] pub(crate) presence: Arc>, pub(crate) power_levels: Arc, @@ -225,6 +262,7 @@ impl RoomMember { pub(crate) fn from_parts( event: MemberEvent, profile: Option, + #[cfg(feature = "unstable-msc4426")] global_profile: Option, presence: Option, room_info: &MemberRoomInfo<'_>, ) -> Self { @@ -244,9 +282,19 @@ impl RoomMember { let is_ignored = ignored_users.as_ref().is_some_and(|s| s.contains(event.user_id())); let is_service_member = service_members.as_ref().is_some_and(|s| s.contains(&user_id)); + // Extract global profile fields, swallowing any deserialization errors. + #[cfg(feature = "unstable-msc4426")] + let status = global_profile.as_ref().and_then(|p| p.get_static::().ok().flatten()); + #[cfg(feature = "unstable-msc4426")] + let call = global_profile.as_ref().and_then(|p| p.get_static::().ok().flatten()); + Self { event: event.into(), profile: profile.into(), + #[cfg(feature = "unstable-msc4426")] + status, + #[cfg(feature = "unstable-msc4426")] + call, presence: presence.into(), power_levels: power_levels.clone(), max_power_level: *max_power_level, @@ -292,6 +340,30 @@ impl RoomMember { } } + /// Get the user's status, if any. + /// + /// This is the user-set status (emoji + text) taken from the user's global + /// profile. + /// + /// Global profiles are only populated when syncing via sliding sync with + /// the profiles extension enabled; this is always `None` under sync v2. + #[cfg(feature = "unstable-msc4426")] + pub fn status(&self) -> Option<&StatusProfileField> { + self.status.as_ref() + } + + /// Get the user's call indicator, if any. + /// + /// This indicates the user is currently in a call (and optionally how long + /// they've been in it). It is taken from the user's global profile. + /// + /// Global profiles are only populated when syncing via sliding sync with + /// the profiles extension enabled; this is always `None` under sync v2. + #[cfg(feature = "unstable-msc4426")] + pub fn call(&self) -> Option<&CallProfileField> { + self.call.as_ref() + } + /// Get the normalized power level of this member. /// /// The normalized power level depends on the maximum power level that can @@ -509,7 +581,16 @@ pub fn normalize_power_level(power_level: Int, max_power_level: i64) -> Int { #[cfg(test)] mod tests { + #[cfg(feature = "unstable-msc4426")] + use matrix_sdk_common::ROOM_VERSION_RULES_FALLBACK; + #[cfg(feature = "unstable-msc4426")] + use matrix_sdk_test::event_factory::EventFactory; use proptest::prelude::*; + #[cfg(feature = "unstable-msc4426")] + use ruma::{ + SecondsSinceUnixEpoch, events::room::power_levels::RoomPowerLevelsSource, + profile::ProfileFieldValue, user_id, + }; use super::*; @@ -557,4 +638,49 @@ mod tests { assert!(normalized >= 0); assert!(normalized <= 100); } + + #[cfg(feature = "unstable-msc4426")] + #[test] + fn test_global_profile_fields() { + let user_id = user_id!("@alice:example.org"); + let event = MemberEvent::Sync(EventFactory::new().sender(user_id).member(user_id).into()); + let room_info = MemberRoomInfo { + power_levels: Arc::new(RoomPowerLevels::new( + RoomPowerLevelsSource::None, + &ROOM_VERSION_RULES_FALLBACK.authorization, + std::iter::empty::(), + )), + max_power_level: 100, + users_display_names: HashMap::new(), + ignored_users: None, + service_members: None, + }; + + // Without a global profile, neither field is set. + let member = RoomMember::from_parts(event.clone(), None, None, None, &room_info); + assert!(member.status().is_none()); + assert!(member.call().is_none()); + + // A global profile carrying both m.status and m.call is extracted into + // both fields. + let mut call = CallProfileField::new(); + call.call_joined_ts = Some(SecondsSinceUnixEpoch(1_700_000_000u32.into())); + let global_profile = UserProfile::from_iter([ + ProfileFieldValue::Status(StatusProfileField::new( + "Working".to_owned(), + "💻".to_owned(), + )), + ProfileFieldValue::Call(call), + ]); + + let member = RoomMember::from_parts(event, None, Some(global_profile), None, &room_info); + + let status = member.status().expect("status is set"); + assert_eq!(status.text, "Working"); + assert_eq!(status.emoji, "💻"); + assert_eq!( + member.call().expect("call is set").call_joined_ts, + Some(SecondsSinceUnixEpoch(1_700_000_000u32.into())) + ); + } } diff --git a/crates/matrix-sdk-base/src/sliding_sync.rs b/crates/matrix-sdk-base/src/sliding_sync.rs index c02fb8bb4ae..d25d69032fb 100644 --- a/crates/matrix-sdk-base/src/sliding_sync.rs +++ b/crates/matrix-sdk-base/src/sliding_sync.rs @@ -219,6 +219,14 @@ impl BaseClient { ) .await?; + // Notify subscribers (e.g. open timelines) about global profile updates, which + // don't otherwise touch any room and so trigger no other broadcast. + if !extensions.profiles.is_empty() { + let _ = self + .global_profile_updates_sender + .send(extensions.profiles.keys().cloned().collect()); + } + let mut context = processors::Context::default(); // Now that all the rooms information have been saved, update the display name @@ -520,6 +528,64 @@ mod tests { assert_eq!(bob_map.get("displayname"), Some(&json!("Bob"))); } + #[async_test] + async fn test_profiles_extension_broadcasts_global_profile_updates() { + let client = logged_in_base_client(None).await; + + let alice = user_id!("@alice:e.uk"); + let bob = user_id!("@bob:e.uk"); + + // Given a subscriber to global profile updates. + let mut global_profile_updates = client.subscribe_to_global_profile_updates(); + + // When a sliding sync response carries the profiles extension for two users. + let mut response = http::Response::new("0".to_owned()); + response.extensions.profiles.insert( + alice.to_owned(), + UserProfileUpdate::from_iter([("displayname".to_owned(), json!("Alice"))]), + ); + response.extensions.profiles.insert( + bob.to_owned(), + UserProfileUpdate::from_iter([("displayname".to_owned(), json!("Bob"))]), + ); + client + .process_sliding_sync( + &response, + &RequestedRequiredStates::default(), + &client.state_store_lock().lock().await, + ) + .await + .expect("Failed to process sync"); + + // Then the changed user IDs are broadcast. + let users = + global_profile_updates.recv().await.expect("should receive a global profile update"); + assert_eq!(users.len(), 2); + assert!(users.contains(alice)); + assert!(users.contains(bob)); + + // When a subsequent response only carries an update for Alice. + let mut response = http::Response::new("1".to_owned()); + response.extensions.profiles.insert( + alice.to_owned(), + UserProfileUpdate::from_iter([("displayname".to_owned(), json!("Alice Updated"))]), + ); + client + .process_sliding_sync( + &response, + &RequestedRequiredStates::default(), + &client.state_store_lock().lock().await, + ) + .await + .expect("Failed to process sync"); + + // Then only Alice is broadcast. + let users = + global_profile_updates.recv().await.expect("should receive a global profile update"); + assert_eq!(users.len(), 1); + assert!(users.contains(alice)); + } + #[async_test] async fn test_room_with_unspecified_state_is_added_to_client_and_joined_list() { // Given a logged-in client diff --git a/crates/matrix-sdk-ui/Cargo.toml b/crates/matrix-sdk-ui/Cargo.toml index 605dabd89ea..d02d5cf4ff9 100644 --- a/crates/matrix-sdk-ui/Cargo.toml +++ b/crates/matrix-sdk-ui/Cargo.toml @@ -21,6 +21,9 @@ unstable-msc3956 = ["ruma/unstable-msc3956"] # Add support for inline media galleries via msgtypes unstable-msc4274 = ["matrix-sdk/unstable-msc4274"] +# Add support for user status profile fields (m.status, m.call). +unstable-msc4426 = ["matrix-sdk/unstable-msc4426"] + # Enable the global, reactive search service built on top of the SDK's # message search. experimental-search = ["matrix-sdk/experimental-search"] diff --git a/crates/matrix-sdk-ui/changelog.d/6704.added.md b/crates/matrix-sdk-ui/changelog.d/6704.added.md new file mode 100644 index 00000000000..5043458f4ea --- /dev/null +++ b/crates/matrix-sdk-ui/changelog.d/6704.added.md @@ -0,0 +1,3 @@ +Add `status` and `call` fields to the timeline `Profile`, carrying the sender's +[MSC4426](https://github.com/matrix-org/matrix-spec-proposals/pull/4426) user +status and call indicator from their global profile. diff --git a/crates/matrix-sdk-ui/src/timeline/builder.rs b/crates/matrix-sdk-ui/src/timeline/builder.rs index 8584ca2f766..68d2873d0be 100644 --- a/crates/matrix-sdk-ui/src/timeline/builder.rs +++ b/crates/matrix-sdk-ui/src/timeline/builder.rs @@ -23,6 +23,8 @@ use super::{ DateDividerMode, Error, Timeline, TimelineDropHandle, TimelineFocus, controller::{TimelineController, TimelineSettings}, }; +#[cfg(feature = "unstable-msc4426")] +use crate::timeline::tasks::global_profile_updates_task; use crate::{ timeline::{ TimelineReadReceiptTracking, @@ -237,6 +239,19 @@ impl TimelineBuilder { .abort_on_drop() }; + #[cfg(feature = "unstable-msc4426")] + let global_profile_updates_handle = room + .client() + .task_monitor() + .spawn_infinite_task( + "timeline::global_profile_updates", + global_profile_updates_task( + room.client().subscribe_to_global_profile_updates(), + controller.clone(), + ), + ) + .abort_on_drop(); + let crypto_drop_handles = spawn_crypto_tasks(controller.clone()).await; let timeline = Timeline { @@ -244,6 +259,8 @@ impl TimelineBuilder { drop_handle: Arc::new(TimelineDropHandle { _crypto_drop_handles: crypto_drop_handles, _room_update_join_handle: room_update_join_handle, + #[cfg(feature = "unstable-msc4426")] + _global_profile_updates_handle: global_profile_updates_handle, _local_echo_listener_handle: local_echo_listener_handle, _focus_drop_handle: focus_task, _event_cache_drop_handle: event_cache_drop, diff --git a/crates/matrix-sdk-ui/src/timeline/event_item/mod.rs b/crates/matrix-sdk-ui/src/timeline/event_item/mod.rs index f2877a47fc9..008e8a715f8 100644 --- a/crates/matrix-sdk-ui/src/timeline/event_item/mod.rs +++ b/crates/matrix-sdk-ui/src/timeline/event_item/mod.rs @@ -25,6 +25,8 @@ use matrix_sdk::{ send_queue::{SendHandle, SendReactionHandle}, }; use matrix_sdk_base::deserialized_responses::ShieldStateCode; +#[cfg(feature = "unstable-msc4426")] +use ruma::profile::{CallProfileField, StatusProfileField}; use ruma::{ EventId, MilliSecondsSinceUnixEpoch, OwnedEventId, OwnedMxcUri, OwnedTransactionId, OwnedUserId, TransactionId, UserId, @@ -701,6 +703,14 @@ pub struct Profile { /// The avatar URL, if set. pub avatar_url: Option, + + /// The user's status, taken from their global profile, if set. + #[cfg(feature = "unstable-msc4426")] + pub status: Option, + + /// The user's call indicator, taken from their global profile, if set. + #[cfg(feature = "unstable-msc4426")] + pub call: Option, } impl Profile { @@ -710,6 +720,10 @@ impl Profile { display_name: member.display_name().map(ToOwned::to_owned), display_name_ambiguous: member.name_ambiguous(), avatar_url: member.avatar_url().map(ToOwned::to_owned), + #[cfg(feature = "unstable-msc4426")] + status: member.status().cloned(), + #[cfg(feature = "unstable-msc4426")] + call: member.call().cloned(), }), Ok(None) if room.are_members_synced() => Some(Profile::default()), Ok(None) => None, diff --git a/crates/matrix-sdk-ui/src/timeline/mod.rs b/crates/matrix-sdk-ui/src/timeline/mod.rs index 9e26f3c0f66..a360ac452f5 100644 --- a/crates/matrix-sdk-ui/src/timeline/mod.rs +++ b/crates/matrix-sdk-ui/src/timeline/mod.rs @@ -885,6 +885,8 @@ impl Timeline { #[derive(Debug)] struct TimelineDropHandle { _room_update_join_handle: BackgroundTaskHandle, + #[cfg(feature = "unstable-msc4426")] + _global_profile_updates_handle: BackgroundTaskHandle, _local_echo_listener_handle: BackgroundTaskHandle, _event_cache_drop_handle: Arc, _focus_drop_handle: Option, diff --git a/crates/matrix-sdk-ui/src/timeline/tasks.rs b/crates/matrix-sdk-ui/src/timeline/tasks.rs index 8cc59f99372..ccb97d3e23c 100644 --- a/crates/matrix-sdk-ui/src/timeline/tasks.rs +++ b/crates/matrix-sdk-ui/src/timeline/tasks.rs @@ -24,6 +24,8 @@ use matrix_sdk::{ send_queue::RoomSendQueueUpdate, }; use ruma::OwnedEventId; +#[cfg(feature = "unstable-msc4426")] +use ruma::{OwnedUserId, UserId}; use tokio::sync::broadcast::{Receiver, error::RecvError}; use tracing::{error, instrument, trace, warn}; @@ -286,6 +288,35 @@ pub(in crate::timeline) async fn room_event_cache_updates_task( } } +/// Long-lived task that refreshes displayed sender profiles when the users' +/// global profiles change. The controller filters to the senders it shows. +#[cfg(feature = "unstable-msc4426")] +pub(in crate::timeline) async fn global_profile_updates_task( + mut global_profile_updates_stream: Receiver>, + timeline_controller: TimelineController, +) { + trace!("spawned the global profile updates task!"); + + loop { + match global_profile_updates_stream.recv().await { + Ok(user_ids) => { + let sender_ids: BTreeSet<&UserId> = + user_ids.iter().map(|user_id| user_id.as_ref()).collect(); + timeline_controller.force_update_sender_profiles(&sender_ids).await; + } + + Err(RecvError::Lagged(num_missed)) => { + warn!("missed {num_missed} global profile updates, ignoring those missed"); + } + + Err(RecvError::Closed) => { + trace!("channel closed, exiting the global profile updates handler"); + break; + } + } + } +} + /// Long-lived task that forwards [`RoomSendQueueUpdate`]s (local echoes) to the /// timeline. pub(in crate::timeline) async fn room_send_queue_update_task( diff --git a/crates/matrix-sdk-ui/tests/integration/timeline/sliding_sync.rs b/crates/matrix-sdk-ui/tests/integration/timeline/sliding_sync.rs index 94a9216ca3e..f170442fd2b 100644 --- a/crates/matrix-sdk-ui/tests/integration/timeline/sliding_sync.rs +++ b/crates/matrix-sdk-ui/tests/integration/timeline/sliding_sync.rs @@ -649,3 +649,109 @@ async fn test_timeline_read_receipts_are_updated_live() -> Result<()> { Ok(()) } + +#[cfg(feature = "unstable-msc4426")] +#[async_test] +async fn test_timeline_refreshes_sender_profile_on_global_profile_update() -> Result<()> { + use matrix_sdk_ui::timeline::TimelineDetails; + use ruma::profile::StatusProfileField; + + let (client, server, sliding_sync) = new_sliding_sync(vec![ + SlidingSyncList::builder("foo") + .sync_mode(SlidingSyncMode::new_selective().add_range(0..=10)), + ]) + .await?; + + let stream = sliding_sync.sync(); + pin_mut!(stream); + + let room_id = room_id!("!foo:bar.org"); + + create_one_room(&server, &mut stream, room_id, "Room Name".to_owned()).await?; + + server.mock_room_state_encryption().plain().mount().await; + + let sdk_room = client.get_room(room_id).context("room should exist")?; + let timeline = TimelineBuilder::new(&sdk_room) + .track_read_marker_and_receipts(TimelineReadReceiptTracking::AllEvents) + .build() + .await?; + let (_, mut timeline_stream) = timeline.subscribe().await; + + // Alice is a member of the room and has sent a message. + receive_response! { + [server.server(), stream] + { + "pos": "1", + "lists": {}, + "rooms": { + room_id: { + "required_state": [ + { + "type": "m.room.member", + "state_key": "@alice:bar.org", + "sender": "@alice:bar.org", + "content": { "membership": "join", "displayname": "Alice" }, + "event_id": "$alice_member:bar.org", + "origin_server_ts": 0 + } + ], + "timeline": [ timeline_event!("$msg:bar.org" at 1 sec) ] + } + } + } + }; + + // Consume the initial updates for Alice's message. + assert_timeline_stream! { + [timeline_stream] + append "$msg:bar.org"; + prepend --- date divider ---; + }; + + // Baseline: Alice's profile is known, but carries no status yet. + let items = timeline.items().await; + let alice_message = items + .iter() + .filter_map(|item| item.as_event()) + .find(|event| event.event_id().map(|id| id.as_str()) == Some("$msg:bar.org")) + .context("Alice's message should be in the timeline")?; + assert_let!(TimelineDetails::Ready(profile) = alice_message.sender_profile()); + assert!(profile.status.is_none(), "The status should be unset before the profile update."); + + // A sync carrying only a global profiles update for Alice with a new status. + receive_response! { + [server.server(), stream] + { + "pos": "2", + "lists": {}, + "rooms": {}, + "extensions": { + "users": { + "@alice:bar.org": { + "org.matrix.msc4426.status": { "text": "Away", "emoji": "🌴" } + } + } + } + } + }; + + // Wait for the sync response to be processed. + assert_let_timeout!(Some(_) = timeline_stream.next()); + + // The same message now carries Alice's new status. + let items = timeline.items().await; + let alice_message = items + .iter() + .filter_map(|item| item.as_event()) + .find(|event| event.event_id().map(|id| id.as_str()) == Some("$msg:bar.org")) + .context("Alice's message should be in the timeline")?; + assert_let!(TimelineDetails::Ready(profile) = alice_message.sender_profile()); + assert_eq!( + profile.status.as_ref(), + Some(&StatusProfileField::new("Away".to_owned(), "🌴".to_owned())), + "The timeline should refresh Alice's sender profile with the new status." + ); + + Ok(()) +} diff --git a/crates/matrix-sdk/Cargo.toml b/crates/matrix-sdk/Cargo.toml index 3bd4e841fa3..62dd02c1694 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 = ["matrix-sdk-base/unstable-msc4426"] + experimental-search = ["matrix-sdk-search"] experimental-element-recent-emojis = ["matrix-sdk-base/experimental-element-recent-emojis"] 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. 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/src/client/mod.rs b/crates/matrix-sdk/src/client/mod.rs index fb282a93eee..62d09b25fde 100644 --- a/crates/matrix-sdk/src/client/mod.rs +++ b/crates/matrix-sdk/src/client/mod.rs @@ -709,6 +709,17 @@ impl Client { self.base_client().room_info_notable_update_receiver() } + /// Returns a receiver of the user IDs whose global profile changed during a + /// sync. Consumers can use this as a trigger to e.g. merge any global + /// fields into a user's room profile. + /// + /// Requires the Profiles sliding sync extension to be enabled. + pub fn subscribe_to_global_profile_updates( + &self, + ) -> broadcast::Receiver> { + self.base_client().subscribe_to_global_profile_updates() + } + /// Performs a search for users. /// The search is performed case-insensitively on user IDs and display names /// diff --git a/crates/matrix-sdk/tests/integration/account.rs b/crates/matrix-sdk/tests/integration/account.rs index 36f6247511c..1f61d8669d2 100644 --- a/crates/matrix-sdk/tests/integration/account.rs +++ b/crates/matrix-sdk/tests/integration/account.rs @@ -322,3 +322,153 @@ 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(); +} + +#[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); +}