Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions bindings/matrix-sdk-ffi/changelog.d/6733.changed.md
Original file line number Diff line number Diff line change
@@ -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.
24 changes: 18 additions & 6 deletions bindings/matrix-sdk-ffi/src/room/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -196,8 +198,8 @@ impl Room {
}

/// Returns the room heroes for this room.
pub fn heroes(&self) -> Vec<RoomHero> {
self.inner.heroes().into_iter().map(Into::into).collect()
pub async fn heroes(&self) -> Vec<RoomHero> {
self.inner.heroes().await.into_iter().map(Into::into).collect()
}

/// Is there a non expired membership with application "m.call" and scope
Expand Down Expand Up @@ -1404,14 +1406,24 @@ pub struct RoomHero {
display_name: Option<String>,
/// The avatar URL of the hero.
avatar_url: Option<String>,
/// The hero's user-set status, taken from their global profile.
#[cfg(feature = "unstable-msc4426")]
status: Option<UserStatus>,
/// The hero's call indicator, taken from their global profile.
#[cfg(feature = "unstable-msc4426")]
call: Option<UserCall>,
}

impl From<SdkRoomHero> for RoomHero {
fn from(value: SdkRoomHero) -> Self {
impl From<SdkRoomHeroWithProfile> 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),
}
}
}
Expand Down
2 changes: 1 addition & 1 deletion bindings/matrix-sdk-ffi/src/room/room_info.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
8 changes: 8 additions & 0 deletions crates/matrix-sdk-base/changelog.d/6733.changed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
[**breaking**] `Room::heroes()` is now `async` and returns
`Vec<RoomHeroWithProfile>` instead of `Vec<RoomHero>`. 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.
6 changes: 3 additions & 3 deletions crates/matrix-sdk-base/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
43 changes: 43 additions & 0 deletions crates/matrix-sdk-base/src/room/display_name.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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},
Expand Down Expand Up @@ -402,6 +404,47 @@ pub struct RoomHero {
pub avatar_url: Option<OwnedMxcUri>,
}

/// A [`RoomHero`] augmented with the user's global profile fields.
#[derive(Clone, Debug, PartialEq)]
pub struct RoomHeroWithProfile {

@pixlwave pixlwave Jul 8, 2026

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Claude kept trying to convince me to use RoomHero with a #[serde(skip)] on the profile fields but that felt all kinds of wrong to me so I settled on this route with distinct types for storage vs dto.

Happy to change if this isn't desirable though.

/// The user ID of the hero.
pub user_id: OwnedUserId,
/// The display name of the hero.
pub display_name: Option<String>,
/// The avatar URL of the hero.
pub avatar_url: Option<OwnedMxcUri>,
/// The hero's user-set status (emoji + text), from their global profile.
#[cfg(feature = "unstable-msc4426")]
pub status: Option<StatusProfileField>,
/// The hero's call indicator, from their global profile.
#[cfg(feature = "unstable-msc4426")]
pub call: Option<CallProfileField>,
}

impl From<RoomHero> 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.
///
Expand Down
136 changes: 127 additions & 9 deletions crates/matrix-sdk-base/src/room/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand All @@ -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,
Expand Down Expand Up @@ -467,18 +469,73 @@ 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<OwnedUserId> {
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
/// returned by the homeserver.
pub fn heroes(&self) -> Vec<RoomHero> {
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<RoomHeroWithProfile> {
let heroes: Vec<RoomHero> = {
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::<Vec<_>>();

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::<Status>().ok().flatten(),
profile.get_static::<Call>().ok().flatten(),
)
})
.unwrap_or_default();

RoomHeroWithProfile {
user_id: hero.user_id,
display_name: hero.display_name,
avatar_url: hero.avatar_url,
status,
call,
}
})
.collect()
}
}

Expand Down Expand Up @@ -705,8 +762,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()))
);
}
}
3 changes: 3 additions & 0 deletions crates/matrix-sdk-base/src/room/room_info.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}

Expand Down
Loading