diff --git a/Cargo.lock b/Cargo.lock index c0ce6f048d8..a44bc67e1c0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4753,7 +4753,7 @@ dependencies = [ [[package]] name = "ruma" version = "0.11.1" -source = "git+https://github.com/ruma/ruma.git?rev=35fda7f2f7811156df2e60b223dbf136fc143bc8#35fda7f2f7811156df2e60b223dbf136fc143bc8" +source = "git+https://github.com/ruma/ruma?rev=c91499fc464adc865a7c99d0ce0b35982ad96711#c91499fc464adc865a7c99d0ce0b35982ad96711" dependencies = [ "assign", "js_int", @@ -4770,7 +4770,7 @@ dependencies = [ [[package]] name = "ruma-client-api" version = "0.19.0" -source = "git+https://github.com/ruma/ruma.git?rev=35fda7f2f7811156df2e60b223dbf136fc143bc8#35fda7f2f7811156df2e60b223dbf136fc143bc8" +source = "git+https://github.com/ruma/ruma?rev=c91499fc464adc865a7c99d0ce0b35982ad96711#c91499fc464adc865a7c99d0ce0b35982ad96711" dependencies = [ "as_variant", "assign", @@ -4793,7 +4793,7 @@ dependencies = [ [[package]] name = "ruma-common" version = "0.14.1" -source = "git+https://github.com/ruma/ruma.git?rev=35fda7f2f7811156df2e60b223dbf136fc143bc8#35fda7f2f7811156df2e60b223dbf136fc143bc8" +source = "git+https://github.com/ruma/ruma?rev=c91499fc464adc865a7c99d0ce0b35982ad96711#c91499fc464adc865a7c99d0ce0b35982ad96711" dependencies = [ "as_variant", "base64 0.22.1", @@ -4825,7 +4825,7 @@ dependencies = [ [[package]] name = "ruma-events" version = "0.29.1" -source = "git+https://github.com/ruma/ruma.git?rev=35fda7f2f7811156df2e60b223dbf136fc143bc8#35fda7f2f7811156df2e60b223dbf136fc143bc8" +source = "git+https://github.com/ruma/ruma?rev=c91499fc464adc865a7c99d0ce0b35982ad96711#c91499fc464adc865a7c99d0ce0b35982ad96711" dependencies = [ "as_variant", "indexmap 2.6.0", @@ -4850,7 +4850,7 @@ dependencies = [ [[package]] name = "ruma-federation-api" version = "0.10.0" -source = "git+https://github.com/ruma/ruma.git?rev=35fda7f2f7811156df2e60b223dbf136fc143bc8#35fda7f2f7811156df2e60b223dbf136fc143bc8" +source = "git+https://github.com/ruma/ruma?rev=c91499fc464adc865a7c99d0ce0b35982ad96711#c91499fc464adc865a7c99d0ce0b35982ad96711" dependencies = [ "http", "js_int", @@ -4864,7 +4864,7 @@ dependencies = [ [[package]] name = "ruma-html" version = "0.3.0" -source = "git+https://github.com/ruma/ruma.git?rev=35fda7f2f7811156df2e60b223dbf136fc143bc8#35fda7f2f7811156df2e60b223dbf136fc143bc8" +source = "git+https://github.com/ruma/ruma?rev=c91499fc464adc865a7c99d0ce0b35982ad96711#c91499fc464adc865a7c99d0ce0b35982ad96711" dependencies = [ "as_variant", "html5ever", @@ -4876,7 +4876,7 @@ dependencies = [ [[package]] name = "ruma-identifiers-validation" version = "0.10.0" -source = "git+https://github.com/ruma/ruma.git?rev=35fda7f2f7811156df2e60b223dbf136fc143bc8#35fda7f2f7811156df2e60b223dbf136fc143bc8" +source = "git+https://github.com/ruma/ruma?rev=c91499fc464adc865a7c99d0ce0b35982ad96711#c91499fc464adc865a7c99d0ce0b35982ad96711" dependencies = [ "js_int", "thiserror 2.0.3", @@ -4885,7 +4885,7 @@ dependencies = [ [[package]] name = "ruma-macros" version = "0.14.0" -source = "git+https://github.com/ruma/ruma.git?rev=35fda7f2f7811156df2e60b223dbf136fc143bc8#35fda7f2f7811156df2e60b223dbf136fc143bc8" +source = "git+https://github.com/ruma/ruma?rev=c91499fc464adc865a7c99d0ce0b35982ad96711#c91499fc464adc865a7c99d0ce0b35982ad96711" dependencies = [ "cfg-if", "once_cell", @@ -4901,7 +4901,7 @@ dependencies = [ [[package]] name = "ruma-push-gateway-api" version = "0.10.0" -source = "git+https://github.com/ruma/ruma.git?rev=35fda7f2f7811156df2e60b223dbf136fc143bc8#35fda7f2f7811156df2e60b223dbf136fc143bc8" +source = "git+https://github.com/ruma/ruma?rev=c91499fc464adc865a7c99d0ce0b35982ad96711#c91499fc464adc865a7c99d0ce0b35982ad96711" dependencies = [ "js_int", "ruma-common", diff --git a/Cargo.toml b/Cargo.toml index 0b2fb061b6d..79ae1c835cc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,7 +18,7 @@ default-members = ["benchmarks", "crates/*", "labs/*"] resolver = "2" [workspace.package] -rust-version = "1.80" +rust-version = "1.82" [workspace.dependencies] anyhow = "1.0.93" @@ -56,7 +56,7 @@ proptest = { version = "1.5.0", default-features = false, features = ["std"] } rand = "0.8.5" reqwest = { version = "0.12.4", default-features = false } rmp-serde = "1.3.0" -ruma = { git = "https://github.com/ruma/ruma.git", rev = "35fda7f2f7811156df2e60b223dbf136fc143bc8", features = [ +ruma = { git = "https://github.com/ruma/ruma", rev = "c91499fc464adc865a7c99d0ce0b35982ad96711", features = [ "client-api-c", "compat-upload-signatures", "compat-user-id", @@ -69,8 +69,9 @@ ruma = { git = "https://github.com/ruma/ruma.git", rev = "35fda7f2f7811156df2e60 "unstable-msc3489", "unstable-msc4075", "unstable-msc4140", + "unstable-msc4171", ] } -ruma-common = { git = "https://github.com/ruma/ruma.git", rev = "35fda7f2f7811156df2e60b223dbf136fc143bc8" } +ruma-common = { git = "https://github.com/ruma/ruma", rev = "c91499fc464adc865a7c99d0ce0b35982ad96711" } serde = "1.0.151" serde_html_form = "0.2.0" serde_json = "1.0.91" diff --git a/crates/matrix-sdk-base/CHANGELOG.md b/crates/matrix-sdk-base/CHANGELOG.md index 2e596658ffc..862d8a3fa0b 100644 --- a/crates/matrix-sdk-base/CHANGELOG.md +++ b/crates/matrix-sdk-base/CHANGELOG.md @@ -6,6 +6,14 @@ All notable changes to this project will be documented in this file. ## [Unreleased] - ReleaseDate +### Features + +- Introduced support for + [MSC4171](https://github.com/matrix-org/matrix-rust-sdk/pull/4335), enabling + the designation of certain users as service members. These flagged users are + excluded from the room display name calculation. + ([#4335](https://github.com/matrix-org/matrix-rust-sdk/pull/4335)) + ### Bug Fixes - Fix an off-by-one error in the `ObservableMap` when the `remove()` method is diff --git a/crates/matrix-sdk-base/src/rooms/normal.rs b/crates/matrix-sdk-base/src/rooms/normal.rs index 91367d42675..1cdfa7dba21 100644 --- a/crates/matrix-sdk-base/src/rooms/normal.rs +++ b/crates/matrix-sdk-base/src/rooms/normal.rs @@ -20,6 +20,7 @@ use std::{ sync::{atomic::AtomicBool, Arc}, }; +use as_variant::as_variant; use bitflags::bitflags; use eyeball::{SharedObservable, Subscriber}; use futures_util::{Stream, StreamExt}; @@ -35,6 +36,7 @@ use ruma::{ call::member::{CallMemberStateKey, MembershipData}, direct::OwnedDirectUserIdentifier, ignored_user_list::IgnoredUserListEventContent, + member_hints::MemberHintsEventContent, receipt::{Receipt, ReceiptThread, ReceiptType}, room::{ avatar::{self, RoomAvatarEventContent}, @@ -50,7 +52,7 @@ use ruma::{ }, tag::{TagEventContent, Tags}, AnyRoomAccountDataEvent, AnyStrippedStateEvent, AnySyncStateEvent, - RoomAccountDataEventType, + RoomAccountDataEventType, SyncStateEvent, }, room::RoomType, serde::Raw, @@ -59,7 +61,7 @@ use ruma::{ }; use serde::{Deserialize, Serialize}; use tokio::sync::broadcast; -use tracing::{debug, field::debug, info, instrument, warn}; +use tracing::{debug, field::debug, info, instrument, trace, warn}; use super::{ members::MemberRoomInfo, BaseRoomInfo, RoomCreateWithCreatorEventContent, RoomDisplayName, @@ -68,7 +70,9 @@ use super::{ #[cfg(feature = "experimental-sliding-sync")] use crate::latest_event::LatestEvent; use crate::{ - deserialized_responses::{DisplayName, MemberEvent, RawSyncOrStrippedState}, + deserialized_responses::{ + DisplayName, MemberEvent, RawSyncOrStrippedState, SyncOrStrippedState, + }, notification_settings::RoomNotificationMode, read_receipts::RoomReadReceipts, store::{DynStateStore, Result as StoreResult, StateStoreExt}, @@ -222,6 +226,18 @@ impl From<&MembershipState> for RoomState { /// try to behave similarly here. const NUM_HEROES: usize = 5; +/// A filter to remove our own user and the users specified in the member hints +/// state event, so called service members, from the list of heroes. +/// +/// The heroes will then be used to calculate a display name for the room if one +/// wasn't explicitly defined. +fn heroes_filter<'a>( + own_user_id: &'a UserId, + member_hints: &'a MemberHintsEventContent, +) -> impl Fn(&UserId) -> bool + use<'a> { + move |user_id| user_id != own_user_id && !member_hints.service_members.contains(user_id) +} + impl Room { /// The size of the latest_encrypted_events RingBuffer // SAFETY: `new_unchecked` is safe because 10 is not zero. @@ -678,12 +694,16 @@ impl Room { /// /// Returns the display names as a list of strings. async fn extract_heroes(&self, heroes: &[RoomHero]) -> StoreResult> { - let own_user_id = self.own_user_id().as_str(); - let mut names = Vec::with_capacity(heroes.len()); - let heroes = heroes.iter().filter(|hero| hero.user_id != own_user_id); + let own_user_id = self.own_user_id(); + let member_hints = self.get_member_hints().await?; + + // Construct a filter that is specific to this own user id, set of member hints, + // and accepts a `RoomHero` type. + let heroes_filter = heroes_filter(own_user_id, &member_hints); + let heroes_filter = |hero: &&RoomHero| heroes_filter(&hero.user_id); - for hero in heroes { + for hero in heroes.iter().filter(heroes_filter) { if let Some(display_name) = &hero.display_name { names.push(display_name.clone()); } else { @@ -710,12 +730,30 @@ impl Room { /// /// Returns a `(heroes_names, num_joined_invited)` tuple. async fn compute_summary(&self) -> StoreResult<(Vec, u64)> { + let member_hints = self.get_member_hints().await?; + + // Construct a filter that is specific to this own user id, set of member hints, + // and accepts a `RoomMember` type. + let heroes_filter = heroes_filter(&self.own_user_id, &member_hints); + let heroes_filter = |u: &RoomMember| heroes_filter(u.user_id()); + let mut members = self.members(RoomMemberships::JOIN | RoomMemberships::INVITE).await?; + // If we have some service members, they shouldn't count to the number of + // joined/invited members, otherwise we'll wrongly assume that there are more + // members in the room than they are for the "Bob and 2 others" case. + let num_service_members = members + .iter() + .filter(|member| member_hints.service_members.contains(member.user_id())) + .count(); + // We can make a good prediction of the total number of joined and invited // members here. This might be incorrect if the database info is // outdated. - let num_joined_invited = members.len() as u64; + // + // Note: Subtracting here is fine because `num_service_members` is a subset of + // `members.len()` due to the above filter operation. + let num_joined_invited = members.len() - num_service_members; if num_joined_invited == 0 || (num_joined_invited == 1 && members[0].user_id() == self.own_user_id) @@ -729,12 +767,34 @@ impl Room { let heroes = members .into_iter() - .filter(|u| u.user_id() != self.own_user_id) + .filter(heroes_filter) .take(NUM_HEROES) .map(|u| u.name().to_owned()) .collect(); - Ok((heroes, num_joined_invited)) + trace!( + ?heroes, + num_joined_invited, + num_service_members, + "Computed a room summary since we didn't receive one." + ); + + Ok((heroes, num_joined_invited as u64)) + } + + async fn get_member_hints(&self) -> StoreResult { + Ok(self + .store + .get_state_event_static::(self.room_id()) + .await? + .and_then(|event| { + event + .deserialize() + .inspect_err(|e| warn!("Couldn't deserialize the member hints event: {e}")) + .ok() + }) + .and_then(|event| as_variant!(event, SyncOrStrippedState::Sync(SyncStateEvent::Original(e)) => e.content)) + .unwrap_or_default()) } /// Returns the cached computed display name, if available. @@ -1854,6 +1914,7 @@ fn compute_display_name_from_heroes( #[cfg(test)] mod tests { use std::{ + collections::BTreeSet, ops::{Not, Sub}, str::FromStr, sync::Arc, @@ -1894,6 +1955,7 @@ mod tests { OwnedEventId, OwnedUserId, UserId, }; use serde_json::json; + use similar_asserts::assert_eq; use stream_assert::{assert_pending, assert_ready}; use super::{compute_display_name_from_heroes, Room, RoomHero, RoomInfo, RoomState, SyncInfo}; @@ -2546,6 +2608,53 @@ mod tests { ); } + #[async_test] + async fn test_display_name_dm_joined_service_members() { + let (store, room) = make_room_test_helper(RoomState::Joined); + let room_id = room_id!("!test:localhost"); + + let matthew = user_id!("@sahasrhala:example.org"); + let me = user_id!("@me:example.org"); + let bot = user_id!("@bot:example.org"); + + let mut changes = StateChanges::new("".to_owned()); + let summary = assign!(RumaSummary::new(), { + joined_member_count: Some(2u32.into()), + heroes: vec![me.to_owned(), matthew.to_owned(), bot.to_owned()], + }); + + let f = EventFactory::new().room(room_id!("!test:localhost")); + + let members = changes + .state + .entry(room_id.to_owned()) + .or_default() + .entry(StateEventType::RoomMember) + .or_default(); + members.insert(matthew.into(), f.member(matthew).display_name("Matthew").into_raw()); + members.insert(me.into(), f.member(me).display_name("Me").into_raw()); + members.insert(bot.into(), f.member(bot).display_name("Bot").into_raw()); + + let member_hints_content = + f.member_hints(BTreeSet::from([bot.to_owned()])).sender(me).into_raw(); + changes + .state + .entry(room_id.to_owned()) + .or_default() + .entry(StateEventType::MemberHints) + .or_default() + .insert("".to_owned(), member_hints_content); + + store.save_changes(&changes).await.unwrap(); + + room.inner.update_if(|info| info.update_from_ruma_summary(&summary)); + // Bot should not contribute to the display name. + assert_eq!( + room.compute_display_name().await.unwrap(), + RoomDisplayName::Calculated("Matthew".to_owned()) + ); + } + #[async_test] async fn test_display_name_dm_joined_no_heroes() { let (store, room) = make_room_test_helper(RoomState::Joined); @@ -2573,6 +2682,47 @@ mod tests { ); } + #[async_test] + async fn test_display_name_dm_joined_no_heroes_service_members() { + let (store, room) = make_room_test_helper(RoomState::Joined); + let room_id = room_id!("!test:localhost"); + + let matthew = user_id!("@matthew:example.org"); + let me = user_id!("@me:example.org"); + let bot = user_id!("@bot:example.org"); + + let mut changes = StateChanges::new("".to_owned()); + + let f = EventFactory::new().room(room_id!("!test:localhost")); + + let members = changes + .state + .entry(room_id.to_owned()) + .or_default() + .entry(StateEventType::RoomMember) + .or_default(); + members.insert(matthew.into(), f.member(matthew).display_name("Matthew").into_raw()); + members.insert(me.into(), f.member(me).display_name("Me").into_raw()); + members.insert(bot.into(), f.member(bot).display_name("Bot").into_raw()); + + let member_hints_content = + f.member_hints(BTreeSet::from([bot.to_owned()])).sender(me).into_raw(); + changes + .state + .entry(room_id.to_owned()) + .or_default() + .entry(StateEventType::MemberHints) + .or_default() + .insert("".to_owned(), member_hints_content); + + store.save_changes(&changes).await.unwrap(); + + assert_eq!( + room.compute_display_name().await.unwrap(), + RoomDisplayName::Calculated("Matthew".to_owned()) + ); + } + #[async_test] async fn test_display_name_deterministic() { let (store, room) = make_room_test_helper(RoomState::Joined); diff --git a/testing/matrix-sdk-test/src/event_factory.rs b/testing/matrix-sdk-test/src/event_factory.rs index 1055215a029..82c25b427aa 100644 --- a/testing/matrix-sdk-test/src/event_factory.rs +++ b/testing/matrix-sdk-test/src/event_factory.rs @@ -14,7 +14,10 @@ #![allow(missing_docs)] -use std::sync::atomic::{AtomicU64, Ordering::SeqCst}; +use std::{ + collections::BTreeSet, + sync::atomic::{AtomicU64, Ordering::SeqCst}, +}; use as_variant::as_variant; use matrix_sdk_common::deserialized_responses::{ @@ -22,6 +25,7 @@ use matrix_sdk_common::deserialized_responses::{ }; use ruma::{ events::{ + member_hints::MemberHintsEventContent, message::TextContentBlock, poll::{ end::PollEndEventContent, @@ -399,6 +403,34 @@ impl EventFactory { event } + /// Create a new `m.member_hints` event with the given service members. + /// + /// ``` + /// use std::collections::BTreeSet; + /// + /// use matrix_sdk_test::event_factory::EventFactory; + /// use ruma::{ + /// events::{member_hints::MemberHintsEventContent, SyncStateEvent}, + /// owned_user_id, room_id, + /// serde::Raw, + /// user_id, + /// }; + /// + /// let factory = EventFactory::new().room(room_id!("!test:localhost")); + /// + /// let event: Raw> = factory + /// .member_hints(BTreeSet::from([owned_user_id!("@alice:localhost")])) + /// .sender(user_id!("@alice:localhost")) + /// .into_raw(); + /// ``` + pub fn member_hints( + &self, + service_members: BTreeSet, + ) -> EventBuilder { + // The `m.member_hints` event always has an empty state key, so let's set it. + self.event(MemberHintsEventContent::new(service_members)).state_key("") + } + /// Create a new plain/html `m.room.message`. pub fn text_html( &self,