From d31194ce56f07b0f723ed316a40c37f05b82d5db Mon Sep 17 00:00:00 2001 From: Doug Date: Tue, 7 Jul 2026 16:44:54 +0100 Subject: [PATCH 1/2] feat: Add status and call fields to room heroes. --- .../changelog.d/6733.changed.md | 6 + bindings/matrix-sdk-ffi/src/room/mod.rs | 24 +++- bindings/matrix-sdk-ffi/src/room/room_info.rs | 2 +- .../changelog.d/6733.changed.md | 8 ++ crates/matrix-sdk-base/src/lib.rs | 6 +- .../matrix-sdk-base/src/room/display_name.rs | 43 ++++++ crates/matrix-sdk-base/src/room/mod.rs | 129 ++++++++++++++++-- .../matrix-sdk-ui/changelog.d/6733.changed.md | 4 + crates/matrix-sdk-ui/src/spaces/room.rs | 17 ++- crates/matrix-sdk/changelog.d/6733.changed.md | 4 + crates/matrix-sdk/src/lib.rs | 2 +- crates/matrix-sdk/src/room_preview.rs | 16 ++- 12 files changed, 231 insertions(+), 30 deletions(-) create mode 100644 bindings/matrix-sdk-ffi/changelog.d/6733.changed.md create mode 100644 crates/matrix-sdk-base/changelog.d/6733.changed.md create mode 100644 crates/matrix-sdk-ui/changelog.d/6733.changed.md create mode 100644 crates/matrix-sdk/changelog.d/6733.changed.md diff --git a/bindings/matrix-sdk-ffi/changelog.d/6733.changed.md b/bindings/matrix-sdk-ffi/changelog.d/6733.changed.md new file mode 100644 index 00000000000..b7239168411 --- /dev/null +++ b/bindings/matrix-sdk-ffi/changelog.d/6733.changed.md @@ -0,0 +1,6 @@ +[**breaking**] The FFI `Room::heroes()` is now `async`, and the returned +`RoomHero` now exposes the user's +[MSC4426](https://github.com/matrix-org/matrix-spec-proposals/pull/4426) status +and call fields (`status` and `call`), taken from their global profile. These +fields are only populated when syncing via sliding sync with the profiles +extension enabled. diff --git a/bindings/matrix-sdk-ffi/src/room/mod.rs b/bindings/matrix-sdk-ffi/src/room/mod.rs index fd6b790c480..314e409ce93 100644 --- a/bindings/matrix-sdk-ffi/src/room/mod.rs +++ b/bindings/matrix-sdk-ffi/src/room/mod.rs @@ -19,8 +19,8 @@ use futures_util::{StreamExt, pin_mut}; use matrix_sdk::{ ComposerDraft as SdkComposerDraft, ComposerDraftType as SdkComposerDraftType, DraftAttachment as SdkDraftAttachment, DraftAttachmentContent, DraftThumbnail, EncryptionState, - PredecessorRoom as SdkPredecessorRoom, RoomHero as SdkRoomHero, RoomMemberships, RoomState, - SuccessorRoom as SdkSuccessorRoom, + PredecessorRoom as SdkPredecessorRoom, RoomHeroWithProfile as SdkRoomHeroWithProfile, + RoomMemberships, RoomState, SuccessorRoom as SdkSuccessorRoom, encryption::LocalTrust, room::{ Room as SdkRoom, RoomMemberRole, edit::EditedContent, power_levels::RoomPowerLevelChanges, @@ -49,6 +49,8 @@ use ruma::{ use tracing::error; use self::{power_levels::RoomPowerLevels, room_info::RoomInfo}; +#[cfg(feature = "unstable-msc4426")] +use crate::ruma::{UserCall, UserStatus}; use crate::{ TaskHandle, chunk_iterator::ChunkIterator, @@ -196,8 +198,8 @@ impl Room { } /// Returns the room heroes for this room. - pub fn heroes(&self) -> Vec { - self.inner.heroes().into_iter().map(Into::into).collect() + pub async fn heroes(&self) -> Vec { + self.inner.heroes().await.into_iter().map(Into::into).collect() } /// Is there a non expired membership with application "m.call" and scope @@ -1404,14 +1406,24 @@ pub struct RoomHero { display_name: Option, /// The avatar URL of the hero. avatar_url: Option, + /// The hero's user-set status, taken from their global profile. + #[cfg(feature = "unstable-msc4426")] + status: Option, + /// The hero's call indicator, taken from their global profile. + #[cfg(feature = "unstable-msc4426")] + call: Option, } -impl From for RoomHero { - fn from(value: SdkRoomHero) -> Self { +impl From for RoomHero { + fn from(value: SdkRoomHeroWithProfile) -> Self { Self { user_id: value.user_id.to_string(), display_name: value.display_name.clone(), avatar_url: value.avatar_url.as_ref().map(ToString::to_string), + #[cfg(feature = "unstable-msc4426")] + status: value.status.map(UserStatus::from), + #[cfg(feature = "unstable-msc4426")] + call: value.call.map(UserCall::from), } } } diff --git a/bindings/matrix-sdk-ffi/src/room/room_info.rs b/bindings/matrix-sdk-ffi/src/room/room_info.rs index 074b9bd2120..5375b665035 100644 --- a/bindings/matrix-sdk-ffi/src/room/room_info.rs +++ b/bindings/matrix-sdk-ffi/src/room/room_info.rs @@ -181,7 +181,7 @@ impl RoomInfo { .flatten(), _ => None, }, - heroes: room.heroes().into_iter().map(Into::into).collect(), + heroes: room.heroes().await.into_iter().map(Into::into).collect(), active_members_count: room.active_members_count(), invited_members_count: room.invited_members_count(), joined_members_count: room.joined_members_count(), diff --git a/crates/matrix-sdk-base/changelog.d/6733.changed.md b/crates/matrix-sdk-base/changelog.d/6733.changed.md new file mode 100644 index 00000000000..e007ef93d61 --- /dev/null +++ b/crates/matrix-sdk-base/changelog.d/6733.changed.md @@ -0,0 +1,8 @@ +[**breaking**] `Room::heroes()` is now `async` and returns +`Vec` instead of `Vec`. The new +`RoomHeroWithProfile` augments `RoomHero` with the user's +[MSC4426](https://github.com/matrix-org/matrix-spec-proposals/pull/4426) status +and call fields, read fresh from the store on access and never persisted. These +fields are only populated when syncing via sliding sync with the profiles +extension enabled. `RoomHero` is unchanged and remains the type persisted in the +room summary. diff --git a/crates/matrix-sdk-base/src/lib.rs b/crates/matrix-sdk-base/src/lib.rs index 52119f50e39..3922f9aa13d 100644 --- a/crates/matrix-sdk-base/src/lib.rs +++ b/crates/matrix-sdk-base/src/lib.rs @@ -60,9 +60,9 @@ pub use http; pub use matrix_sdk_crypto as crypto; pub use room::{ CallIntentConsensus, EncryptionState, PredecessorRoom, Room, RoomCreateWithCreatorEventContent, - RoomDisplayName, RoomHero, RoomInfo, RoomInfoNotableUpdate, RoomInfoNotableUpdateReasons, - RoomMember, RoomMembersUpdate, RoomMemberships, RoomRecencyStamp, RoomState, RoomStateFilter, - SuccessorRoom, apply_redaction, + RoomDisplayName, RoomHero, RoomHeroWithProfile, RoomInfo, RoomInfoNotableUpdate, + RoomInfoNotableUpdateReasons, RoomMember, RoomMembersUpdate, RoomMemberships, RoomRecencyStamp, + RoomState, RoomStateFilter, SuccessorRoom, apply_redaction, }; pub use store::{ ComposerDraft, ComposerDraftType, DraftAttachment, DraftAttachmentContent, DraftThumbnail, diff --git a/crates/matrix-sdk-base/src/room/display_name.rs b/crates/matrix-sdk-base/src/room/display_name.rs index 0eb8bac037f..9b2186ebced 100644 --- a/crates/matrix-sdk-base/src/room/display_name.rs +++ b/crates/matrix-sdk-base/src/room/display_name.rs @@ -16,6 +16,8 @@ use std::fmt; use as_variant::as_variant; use regex::Regex; +#[cfg(feature = "unstable-msc4426")] +use ruma::profile::{CallProfileField, StatusProfileField}; use ruma::{ OwnedMxcUri, OwnedUserId, RoomAliasId, UserId, events::{SyncStateEvent, member_hints::MemberHintsEventContent}, @@ -402,6 +404,47 @@ pub struct RoomHero { pub avatar_url: Option, } +/// A [`RoomHero`] augmented with the user's global profile fields. +#[derive(Clone, Debug, PartialEq)] +pub struct RoomHeroWithProfile { + /// The user ID of the hero. + pub user_id: OwnedUserId, + /// The display name of the hero. + pub display_name: Option, + /// The avatar URL of the hero. + pub avatar_url: Option, + /// The hero's user-set status (emoji + text), from their global profile. + #[cfg(feature = "unstable-msc4426")] + pub status: Option, + /// The hero's call indicator, from their global profile. + #[cfg(feature = "unstable-msc4426")] + pub call: Option, +} + +impl From for RoomHeroWithProfile { + fn from(hero: RoomHero) -> Self { + Self { + user_id: hero.user_id, + display_name: hero.display_name, + avatar_url: hero.avatar_url, + #[cfg(feature = "unstable-msc4426")] + status: None, + #[cfg(feature = "unstable-msc4426")] + call: None, + } + } +} + +impl From<&RoomHeroWithProfile> for RoomHero { + fn from(hero: &RoomHeroWithProfile) -> Self { + Self { + user_id: hero.user_id.clone(), + display_name: hero.display_name.clone(), + avatar_url: hero.avatar_url.clone(), + } + } +} + /// The number of heroes chosen to compute a room's name, if the room didn't /// have a name set by the users themselves. /// diff --git a/crates/matrix-sdk-base/src/room/mod.rs b/crates/matrix-sdk-base/src/room/mod.rs index 905e897db48..c2236d5c8a7 100644 --- a/crates/matrix-sdk-base/src/room/mod.rs +++ b/crates/matrix-sdk-base/src/room/mod.rs @@ -30,7 +30,7 @@ use std::collections::{BTreeMap, BTreeSet, HashSet}; pub use call::CallIntentConsensus; pub use create::*; -pub use display_name::{RoomDisplayName, RoomHero}; +pub use display_name::{RoomDisplayName, RoomHero, RoomHeroWithProfile}; pub(crate) use display_name::{RoomSummary, UpdatedRoomDisplayName}; pub use encryption::EncryptionState; use eyeball::{AsyncLock, SharedObservable}; @@ -41,6 +41,8 @@ pub use room_info::{ BaseRoomInfo, RoomInfo, RoomInfoNotableUpdate, RoomInfoNotableUpdateReasons, RoomRecencyStamp, apply_redaction, }; +#[cfg(feature = "unstable-msc4426")] +use ruma::profile::{Call, Status}; use ruma::{ EventId, OwnedEventId, OwnedMxcUri, OwnedRoomAliasId, OwnedRoomId, OwnedUserId, RoomId, RoomVersionId, UserId, @@ -471,14 +473,62 @@ impl Room { /// /// This also filters out possible service members from the list of heroes /// returned by the homeserver. - pub fn heroes(&self) -> Vec { - let guard = self.info.read(); - let heroes = guard.heroes(); + #[cfg_attr(not(feature = "unstable-msc4426"), allow(clippy::unused_async))] + pub async fn heroes(&self) -> Vec { + let heroes: Vec = { + let guard = self.info.read(); + let heroes = guard.heroes(); + + if let Some(service_members) = guard.service_members() { + heroes + .iter() + .filter(|hero| !service_members.contains(&hero.user_id)) + .cloned() + .collect() + } else { + heroes.to_vec() + } + }; - if let Some(service_members) = guard.service_members() { - heroes.iter().filter(|hero| !service_members.contains(&hero.user_id)).cloned().collect() - } else { - heroes.to_vec() + // Return with empty profile fields when the user status feature is disabled. + #[cfg(not(feature = "unstable-msc4426"))] + { + heroes.into_iter().map(RoomHeroWithProfile::from).collect() + } + + // Merge any fields from the user's persisted global profile. + #[cfg(feature = "unstable-msc4426")] + { + let user_ids = heroes.iter().map(|hero| hero.user_id.clone()).collect::>(); + + let mut global_profiles = + self.store.get_global_profiles(&user_ids).await.unwrap_or_else(|error| { + tracing::warn!(?error, "Failed to load global profiles for room heroes"); + Default::default() + }); + + heroes + .into_iter() + .map(|hero| { + let (status, call) = global_profiles + .remove(&*hero.user_id) + .map(|profile| { + ( + profile.get_static::().ok().flatten(), + profile.get_static::().ok().flatten(), + ) + }) + .unwrap_or_default(); + + RoomHeroWithProfile { + user_id: hero.user_id, + display_name: hero.display_name, + avatar_url: hero.avatar_url, + status, + call, + } + }) + .collect() } } @@ -705,8 +755,69 @@ mod tests { client.receive_sync_response(response).await.unwrap(); // The service member should be filtered out. - let heroes = room.heroes(); + let heroes = room.heroes().await; + assert_eq!(heroes.len(), 1); + assert_eq!(heroes[0].user_id, alice_id); + } + + #[cfg(feature = "unstable-msc4426")] + #[async_test] + async fn test_room_heroes_carry_global_profile() { + use ruma::{ + SecondsSinceUnixEpoch, + profile::{CallProfileField, ProfileFieldValue, StatusProfileField, UserProfileUpdate}, + }; + + use crate::store::StateChanges; + + let client = logged_in_base_client(None).await; + let alice_id = user_id!("@alice:example.org"); + let room_id = room_id!("!room:example.org"); + + let room = client.get_or_create_room(room_id, RoomState::Joined); + + let mut sync_builder = SyncResponseBuilder::new(); + let response = sync_builder + .add_joined_room(JoinedRoomBuilder::new(room_id).set_room_summary(json!({ + "m.joined_member_count": 2, + "m.invited_member_count": 0, + "m.heroes": [alice_id.to_owned()], + }))) + .build_sync_response(); + client.receive_sync_response(response).await.unwrap(); + + // Without a stored global profile, the hero carries no status or call. + let heroes = room.heroes().await; assert_eq!(heroes.len(), 1); assert_eq!(heroes[0].user_id, alice_id); + assert!(heroes[0].status.is_none()); + assert!(heroes[0].call.is_none()); + + // Store a global profile carrying an `m.status` and `m.call` for the hero. + let mut call = CallProfileField::new(); + call.call_joined_ts = Some(SecondsSinceUnixEpoch(1_700_000_000u32.into())); + let mut changes = StateChanges::default(); + changes.global_profiles.insert( + alice_id.to_owned(), + UserProfileUpdate::from_iter([ + ProfileFieldValue::Status(StatusProfileField::new( + "Working".to_owned(), + "💻".to_owned(), + )), + ProfileFieldValue::Call(call), + ]), + ); + client.state_store().save_changes(&changes).await.unwrap(); + + // The hero now surfaces the status and call from the global profile. + let heroes = room.heroes().await; + let hero = &heroes[0]; + let status = hero.status.as_ref().expect("status is set"); + assert_eq!(status.text, "Working"); + assert_eq!(status.emoji, "💻"); + assert_eq!( + hero.call.as_ref().expect("call is set").call_joined_ts, + Some(SecondsSinceUnixEpoch(1_700_000_000u32.into())) + ); } } diff --git a/crates/matrix-sdk-ui/changelog.d/6733.changed.md b/crates/matrix-sdk-ui/changelog.d/6733.changed.md new file mode 100644 index 00000000000..215c41a3d79 --- /dev/null +++ b/crates/matrix-sdk-ui/changelog.d/6733.changed.md @@ -0,0 +1,4 @@ +[**breaking**] `SpaceRoom::heroes` is now `Option>` +instead of `Option>`, exposing each hero's status and call fields +from their global profile. These fields are only populated when syncing via +sliding sync with the profiles extension enabled. diff --git a/crates/matrix-sdk-ui/src/spaces/room.rs b/crates/matrix-sdk-ui/src/spaces/room.rs index 9ba64c270b3..83cc71b3870 100644 --- a/crates/matrix-sdk-ui/src/spaces/room.rs +++ b/crates/matrix-sdk-ui/src/spaces/room.rs @@ -14,7 +14,7 @@ use std::cmp::Ordering; -use matrix_sdk::{Room, RoomHero, RoomState}; +use matrix_sdk::{Room, RoomHero, RoomHeroWithProfile, RoomState}; use ruma::{ MilliSecondsSinceUnixEpoch, OwnedMxcUri, OwnedRoomAliasId, OwnedRoomId, OwnedServerName, OwnedSpaceChildOrder, RoomId, @@ -62,7 +62,7 @@ pub struct SpaceRoom { /// Whether this room is joined, left etc. pub state: Option, /// A list of room members considered to be heroes. - pub heroes: Option>, + pub heroes: Option>, /// The via parameters of the room. pub via: Vec, /// Whether the room is suggested by the space administrators. @@ -91,11 +91,14 @@ impl SpaceRoom { 0 }; + let heroes = + if let Some(known_room) = &known_room { Some(known_room.heroes().await) } else { None }; + let num_joined_members: u64 = summary.num_joined_members.into(); let display_name = matrix_sdk_base::Room::compute_display_name_with_fields( summary.name.clone(), summary.canonical_alias.as_deref(), - known_room.as_ref().map(|r| r.heroes().to_vec()).unwrap_or_default(), + heroes.iter().flatten().map(RoomHero::from).collect(), num_joined_members - num_joined_service_members, ) .to_string(); @@ -115,7 +118,7 @@ impl SpaceRoom { is_direct: known_room.as_ref().map(|r| r.direct_targets_length() != 0), children_count, state: known_room.as_ref().map(|r| r.state()), - heroes: known_room.as_ref().map(|r| r.heroes()), + heroes, via, suggested, is_dm: known_room.as_ref().map(|r| r.is_dm()), @@ -129,10 +132,12 @@ impl SpaceRoom { let name = room_info.name().map(ToOwned::to_owned); let joined_service_members_count = num_joined_service_members_or_default(known_room).await; + let heroes = known_room.heroes().await; + let display_name = matrix_sdk_base::Room::compute_display_name_with_fields( name.clone(), room_info.canonical_alias(), - known_room.heroes(), + heroes.iter().map(RoomHero::from).collect(), known_room.joined_members_count() - joined_service_members_count, ) .to_string(); @@ -154,7 +159,7 @@ impl SpaceRoom { is_direct: Some(known_room.direct_targets_length() != 0), children_count, state: Some(known_room.state()), - heroes: Some(known_room.heroes()), + heroes: Some(heroes), via: vec![], suggested: false, is_dm: known_room.compute_is_dm().await.ok(), diff --git a/crates/matrix-sdk/changelog.d/6733.changed.md b/crates/matrix-sdk/changelog.d/6733.changed.md new file mode 100644 index 00000000000..f6a3ae5fc91 --- /dev/null +++ b/crates/matrix-sdk/changelog.d/6733.changed.md @@ -0,0 +1,4 @@ +[**breaking**] `RoomPreview::heroes` is now `Option>` +instead of `Option>`, exposing each hero's status and call fields +from their global profile. These fields are only populated when syncing via +sliding sync with the profiles extension enabled. diff --git a/crates/matrix-sdk/src/lib.rs b/crates/matrix-sdk/src/lib.rs index ccd03755bfb..0e04872e679 100644 --- a/crates/matrix-sdk/src/lib.rs +++ b/crates/matrix-sdk/src/lib.rs @@ -24,7 +24,7 @@ pub use bytes; pub use matrix_sdk_base::{ CallIntentConsensus, ComposerDraft, ComposerDraftType, DraftAttachment, DraftAttachmentContent, DraftThumbnail, EncryptionState, PredecessorRoom, QueueWedgeError, Room as BaseRoom, - RoomCreateWithCreatorEventContent, RoomDisplayName, RoomHero, RoomInfo, + RoomCreateWithCreatorEventContent, RoomDisplayName, RoomHero, RoomHeroWithProfile, RoomInfo, RoomMember as BaseRoomMember, RoomMemberships, RoomRecencyStamp, RoomState, SessionMeta, StateChanges, StateStore, StoreError, SuccessorRoom, ThreadingSupport, deserialized_responses, store::{self, DynStateStore, MemoryStore, StateStoreExt}, diff --git a/crates/matrix-sdk/src/room_preview.rs b/crates/matrix-sdk/src/room_preview.rs index 5554f780b5a..da351b480e2 100644 --- a/crates/matrix-sdk/src/room_preview.rs +++ b/crates/matrix-sdk/src/room_preview.rs @@ -19,7 +19,7 @@ //! well. use futures_util::future::join_all; -use matrix_sdk_base::{RawStateEventWithKeys, RoomHero, RoomInfo, RoomState}; +use matrix_sdk_base::{RawStateEventWithKeys, RoomHeroWithProfile, RoomInfo, RoomState}; use ruma::{ OwnedMxcUri, OwnedRoomAliasId, OwnedRoomId, OwnedServerName, RoomId, RoomOrAliasId, ServerName, api::client::{membership::joined_members, state::get_state_events}, @@ -77,7 +77,7 @@ pub struct RoomPreview { pub is_direct: Option, /// Room heroes. - pub heroes: Option>, + pub heroes: Option>, } impl RoomPreview { @@ -108,7 +108,9 @@ impl RoomPreview { num_active_members, state, is_direct, - heroes: Some(room_info.heroes().to_vec()), + heroes: Some( + room_info.heroes().iter().cloned().map(RoomHeroWithProfile::from).collect(), + ), } } @@ -264,6 +266,12 @@ impl RoomPreview { None }; + let heroes = if let Some(cached_room) = &cached_room { + Some(cached_room.heroes().await) + } else { + None + }; + let summary = response.summary; Ok(RoomPreview { @@ -279,7 +287,7 @@ impl RoomPreview { is_world_readable: Some(summary.world_readable), state, is_direct, - heroes: cached_room.map(|r| r.heroes()), + heroes, }) } From 1b13b45566d11dcf9d7e2fa8b22b8e031eed68b2 Mon Sep 17 00:00:00 2001 From: Doug Date: Wed, 8 Jul 2026 10:36:52 +0100 Subject: [PATCH 2/2] fix: Make sure Heroes are reactive to status changes. --- crates/matrix-sdk-base/src/room/mod.rs | 7 ++ crates/matrix-sdk-base/src/room/room_info.rs | 3 + crates/matrix-sdk-base/src/sliding_sync.rs | 77 +++++++++++++++++++- 3 files changed, 85 insertions(+), 2 deletions(-) diff --git a/crates/matrix-sdk-base/src/room/mod.rs b/crates/matrix-sdk-base/src/room/mod.rs index c2236d5c8a7..67bcc043aec 100644 --- a/crates/matrix-sdk-base/src/room/mod.rs +++ b/crates/matrix-sdk-base/src/room/mod.rs @@ -469,6 +469,13 @@ impl Room { self.store.get_user_ids(self.room_id(), RoomMemberships::JOIN).await } + /// The user IDs of this room's heroes, as stored, for cheaply checking + /// hero membership without loading their global profiles. + #[cfg(feature = "unstable-msc4426")] + pub(crate) fn hero_user_ids(&self) -> Vec { + self.info.read().heroes().iter().map(|hero| hero.user_id.clone()).collect() + } + /// Get the heroes for this room. /// /// This also filters out possible service members from the list of heroes diff --git a/crates/matrix-sdk-base/src/room/room_info.rs b/crates/matrix-sdk-base/src/room/room_info.rs index 2edec0df213..4c57b6633e7 100644 --- a/crates/matrix-sdk-base/src/room/room_info.rs +++ b/crates/matrix-sdk-base/src/room/room_info.rs @@ -1458,6 +1458,9 @@ bitflags! { /// The user's `m.fully_read` marker has changed. const FULLY_READ = 0b0000_0001_0000_0000; + + /// A room hero's global profile changed (e.g. their status or call). + const HEROES = 0b0000_0010_0000_0000; } } diff --git a/crates/matrix-sdk-base/src/sliding_sync.rs b/crates/matrix-sdk-base/src/sliding_sync.rs index d25d69032fb..6eb65ff4651 100644 --- a/crates/matrix-sdk-base/src/sliding_sync.rs +++ b/crates/matrix-sdk-base/src/sliding_sync.rs @@ -219,12 +219,29 @@ 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. + // Profile-only updates don't modify any rooms, so nothing else broadcasts + // them. Surface the change so subscribers can react accordingly. if !extensions.profiles.is_empty() { let _ = self .global_profile_updates_sender .send(extensions.profiles.keys().cloned().collect()); + + // Nudge `RoomInfo` so hero status/call fields are re-read. + #[cfg(feature = "unstable-msc4426")] + for room in self.state_store.rooms() { + if room + .hero_user_ids() + .iter() + .any(|user_id| extensions.profiles.contains_key(user_id)) + { + let _ = self.state_store.room_info_notable_update_sender.send( + crate::RoomInfoNotableUpdate { + room_id: room.room_id().to_owned(), + reasons: crate::RoomInfoNotableUpdateReasons::HEROES, + }, + ); + } + } } let mut context = processors::Context::default(); @@ -1643,6 +1660,62 @@ mod tests { ); } + #[cfg(feature = "unstable-msc4426")] + #[async_test] + async fn test_hero_global_profile_update_triggers_notable_update() { + use ruma::profile::UserProfileUpdate; + + let client = logged_in_base_client(None).await; + let room_id = room_id!("!r:e.uk"); + let alice = owned_user_id!("@alice:e.uk"); + + // Given a room where Alice is a hero. + let mut room = http::response::Room::new(); + room.heroes = Some(vec![assign!(http::response::Hero::new(alice.clone()), { + name: Some("Alice".to_owned()), + })]); + let response = response_with_room(room_id, room); + client + .process_sliding_sync( + &response, + &RequestedRequiredStates::default(), + &client.state_store_lock().lock().await, + ) + .await + .expect("Failed to process sync"); + + let mut room_info_notable_update = client.room_info_notable_update_receiver(); + + // When a subsequent sync carries only a profiles-extension update for Alice. + let mut response = http::Response::new("1".to_owned()); + response.extensions.profiles.insert( + alice.clone(), + UserProfileUpdate::from_iter([( + "org.matrix.msc4426.status".to_owned(), + json!({ "text": "Away", "emoji": "🌴" }), + )]), + ); + client + .process_sliding_sync( + &response, + &RequestedRequiredStates::default(), + &client.state_store_lock().lock().await, + ) + .await + .expect("Failed to process sync"); + + // Then a `HEROES` notable update is emitted for the room, so consumers + // re-read the hero profiles. + assert_matches!( + room_info_notable_update.recv().await, + Ok(RoomInfoNotableUpdate { room_id: received_room_id, reasons }) => { + assert_eq!(received_room_id, room_id); + assert!(reasons.contains(RoomInfoNotableUpdateReasons::HEROES)); + } + ); + assert!(room_info_notable_update.is_empty()); + } + #[async_test] async fn test_recency_stamp_is_found_when_processing_sliding_sync_response() { // Given a logged-in client