From 97ccd3bb01e281dd94df2923a77270fe4b7f5207 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jorge=20Mart=C3=ADn?= Date: Thu, 14 May 2026 18:28:59 +0200 Subject: [PATCH] fix: Refresh timeline items when their sender's avatar URL changes This is similar to what happens when a user display name changes, its ambiguity is calculated and if it changes, it reloads the associated timeline events. In fact, this change tries to follow the same strategy as `AmbiguityCache`. --- crates/matrix-sdk-base/src/client.rs | 9 +- .../src/response_processors/room/mod.rs | 9 +- .../response_processors/room/msc4186/mod.rs | 5 +- .../src/response_processors/room/sync_v2.rs | 7 +- .../src/response_processors/state_events.rs | 8 +- crates/matrix-sdk-base/src/sliding_sync.rs | 4 +- .../matrix-sdk-base/src/store/avatar_cache.rs | 101 +++++++++++++ crates/matrix-sdk-base/src/store/mod.rs | 3 + crates/matrix-sdk-base/src/sync.rs | 15 +- crates/matrix-sdk-ui/src/timeline/tasks.rs | 23 ++- .../src/event_cache/caches/room/mod.rs | 26 +++- .../src/event_cache/caches/room/updates.rs | 8 +- crates/matrix-sdk/src/sync.rs | 1 + crates/matrix-sdk/tests/integration/client.rs | 135 +++++++++++++++++- 14 files changed, 329 insertions(+), 25 deletions(-) create mode 100644 crates/matrix-sdk-base/src/store/avatar_cache.rs diff --git a/crates/matrix-sdk-base/src/client.rs b/crates/matrix-sdk-base/src/client.rs index 4ac08f0eeb7..bb2633934d8 100644 --- a/crates/matrix-sdk-base/src/client.rs +++ b/crates/matrix-sdk-base/src/client.rs @@ -65,9 +65,9 @@ use crate::{ Room, RoomInfoNotableUpdate, RoomInfoNotableUpdateReasons, RoomMembersUpdate, RoomState, }, store::{ - BaseStateStore, DynStateStore, MemoryStore, Result as StoreResult, RoomLoadSettings, - StateChanges, StateStoreDataKey, StateStoreDataValue, StateStoreExt, StoreConfig, - ambiguity_map::AmbiguityCache, + AvatarCache, BaseStateStore, DynStateStore, MemoryStore, Result as StoreResult, + RoomLoadSettings, StateChanges, StateStoreDataKey, StateStoreDataValue, StateStoreExt, + StoreConfig, ambiguity_map::AmbiguityCache, }, sync::{RoomUpdates, SyncResponse}, }; @@ -644,6 +644,7 @@ impl BaseClient { .collect(); let mut ambiguity_cache = AmbiguityCache::new(self.state_store.inner.clone()); + let mut avatar_cache = AvatarCache::new(self.state_store.inner.clone()); let global_account_data_processor = processors::account_data::global(&response.account_data.events); @@ -670,6 +671,7 @@ impl BaseClient { &room_id, requested_required_states, &mut ambiguity_cache, + &mut avatar_cache, ), joined_room, &mut updated_members_in_room, @@ -693,6 +695,7 @@ impl BaseClient { &room_id, requested_required_states, &mut ambiguity_cache, + &mut avatar_cache, ), left_room, processors::notification::Notification::new( diff --git a/crates/matrix-sdk-base/src/response_processors/room/mod.rs b/crates/matrix-sdk-base/src/response_processors/room/mod.rs index 8db448b6f1c..eb5d63ebbc2 100644 --- a/crates/matrix-sdk-base/src/response_processors/room/mod.rs +++ b/crates/matrix-sdk-base/src/response_processors/room/mod.rs @@ -14,7 +14,10 @@ use ruma::RoomId; -use crate::{RequestedRequiredStates, store::ambiguity_map::AmbiguityCache}; +use crate::{ + RequestedRequiredStates, + store::{AvatarCache, ambiguity_map::AmbiguityCache}, +}; pub mod display_name; pub mod msc4186; @@ -25,6 +28,7 @@ pub struct RoomCreationData<'a> { room_id: &'a RoomId, requested_required_states: &'a RequestedRequiredStates, ambiguity_cache: &'a mut AmbiguityCache, + avatar_cache: &'a mut AvatarCache, } impl<'a> RoomCreationData<'a> { @@ -32,7 +36,8 @@ impl<'a> RoomCreationData<'a> { room_id: &'a RoomId, requested_required_states: &'a RequestedRequiredStates, ambiguity_cache: &'a mut AmbiguityCache, + avatar_cache: &'a mut AvatarCache, ) -> Self { - Self { room_id, requested_required_states, ambiguity_cache } + Self { room_id, requested_required_states, ambiguity_cache, avatar_cache } } } diff --git a/crates/matrix-sdk-base/src/response_processors/room/msc4186/mod.rs b/crates/matrix-sdk-base/src/response_processors/room/msc4186/mod.rs index af026d2eeea..320c85d2641 100644 --- a/crates/matrix-sdk-base/src/response_processors/room/msc4186/mod.rs +++ b/crates/matrix-sdk-base/src/response_processors/room/msc4186/mod.rs @@ -66,7 +66,7 @@ pub async fn update_any_room( ) -> Result> { let _timer = timer!(tracing::Level::TRACE, "update_any_room"); - let RoomCreationData { room_id, requested_required_states, ambiguity_cache } = + let RoomCreationData { room_id, requested_required_states, ambiguity_cache, avatar_cache } = room_creation_data; // Read state events from the `required_state` field. @@ -118,6 +118,7 @@ pub async fn update_any_room( raw_state_events, &mut room_info, ambiguity_cache, + avatar_cache, &mut new_user_ids, state_store, #[cfg(feature = "experimental-encrypted-state-events")] @@ -170,6 +171,7 @@ pub async fn update_any_room( room_info.update_notification_count(notification_count); let ambiguity_changes = ambiguity_cache.changes.remove(room_id).unwrap_or_default(); + let avatar_changes = avatar_cache.remove_changes(room_id); let room_account_data = rooms_account_data.get(room_id); match (room_info.state(), maybe_room_update_kind) { @@ -188,6 +190,7 @@ pub async fn update_any_room( ephemeral, notification_count, ambiguity_changes, + avatar_changes, )), ))) } diff --git a/crates/matrix-sdk-base/src/response_processors/room/sync_v2.rs b/crates/matrix-sdk-base/src/response_processors/room/sync_v2.rs index 4b7b21dd753..b8aa909fce9 100644 --- a/crates/matrix-sdk-base/src/response_processors/room/sync_v2.rs +++ b/crates/matrix-sdk-base/src/response_processors/room/sync_v2.rs @@ -43,7 +43,7 @@ pub async fn update_joined_room( notification: notification::Notification<'_>, #[cfg(feature = "e2e-encryption")] e2ee: &e2ee::E2EE<'_>, ) -> Result { - let RoomCreationData { room_id, requested_required_states, ambiguity_cache } = + let RoomCreationData { room_id, requested_required_states, ambiguity_cache, avatar_cache } = room_creation_data; let state_store = notification.state_store; @@ -68,6 +68,7 @@ pub async fn update_joined_room( raw_state_events, &mut room_info, ambiguity_cache, + avatar_cache, &mut new_user_ids, state_store, #[cfg(feature = "experimental-encrypted-state-events")] @@ -137,6 +138,7 @@ pub async fn update_joined_room( joined_room.ephemeral.events, notification_count, ambiguity_cache.changes.remove(room_id).unwrap_or_default(), + avatar_cache.remove_changes(room_id), )) } @@ -149,7 +151,7 @@ pub async fn update_left_room( notification: notification::Notification<'_>, #[cfg(feature = "e2e-encryption")] e2ee: &e2ee::E2EE<'_>, ) -> Result { - let RoomCreationData { room_id, requested_required_states, ambiguity_cache } = + let RoomCreationData { room_id, requested_required_states, ambiguity_cache, avatar_cache } = room_creation_data; #[cfg(feature = "e2e-encryption")] @@ -172,6 +174,7 @@ pub async fn update_left_room( raw_state_events, &mut room_info, ambiguity_cache, + avatar_cache, &mut (), state_store, #[cfg(feature = "experimental-encrypted-state-events")] diff --git a/crates/matrix-sdk-base/src/response_processors/state_events.rs b/crates/matrix-sdk-base/src/response_processors/state_events.rs index f82fcb13507..a0b51696518 100644 --- a/crates/matrix-sdk-base/src/response_processors/state_events.rs +++ b/crates/matrix-sdk-base/src/response_processors/state_events.rs @@ -45,7 +45,9 @@ pub mod sync { use crate::response_processors::e2ee; use crate::{ RoomInfo, RoomInfoNotableUpdateReasons, RoomState, - store::{BaseStateStore, Result as StoreResult, ambiguity_map::AmbiguityCache}, + store::{ + AvatarCache, BaseStateStore, Result as StoreResult, ambiguity_map::AmbiguityCache, + }, sync::State, utils::RawStateEventWithKeys, }; @@ -94,6 +96,7 @@ pub mod sync { raw_events: Vec>, room_info: &mut RoomInfo, ambiguity_cache: &mut AmbiguityCache, + avatar_cache: &mut AvatarCache, new_users: &mut U, state_store: &BaseStateStore, #[cfg(feature = "experimental-encrypted-state-events")] e2ee: &e2ee::E2EE<'_>, @@ -111,6 +114,7 @@ pub mod sync { &room_info.room_id, &mut raw_event, ambiguity_cache, + avatar_cache, new_users, ) .await?; @@ -177,6 +181,7 @@ pub mod sync { room_id: &RoomId, raw_event: &mut RawStateEventWithKeys, ambiguity_cache: &mut AmbiguityCache, + avatar_cache: &mut AvatarCache, new_users: &mut U, ) -> StoreResult<()> where @@ -189,6 +194,7 @@ pub mod sync { }; ambiguity_cache.handle_event(&context.state_changes, room_id, event).await?; + avatar_cache.handle_event(&context.state_changes, room_id, event).await?; match event.membership() { MembershipState::Join | MembershipState::Invite => { diff --git a/crates/matrix-sdk-base/src/sliding_sync.rs b/crates/matrix-sdk-base/src/sliding_sync.rs index 8a552776a91..f317938d2f3 100644 --- a/crates/matrix-sdk-base/src/sliding_sync.rs +++ b/crates/matrix-sdk-base/src/sliding_sync.rs @@ -29,7 +29,7 @@ use crate::{ RequestedRequiredStates, error::Result, response_processors as processors, - store::ambiguity_map::AmbiguityCache, + store::{AvatarCache, ambiguity_map::AmbiguityCache}, sync::{RoomUpdates, SyncResponse}, }; @@ -120,6 +120,7 @@ impl BaseClient { let state_store = self.state_store.clone(); let mut ambiguity_cache = AmbiguityCache::new(state_store.inner.clone()); + let mut avatar_cache = AvatarCache::new(state_store.inner.clone()); let global_account_data_processor = processors::account_data::global(&extensions.account_data.global); @@ -142,6 +143,7 @@ impl BaseClient { room_id, requested_required_states, &mut ambiguity_cache, + &mut avatar_cache, ), room_response, &extensions.account_data.rooms, diff --git a/crates/matrix-sdk-base/src/store/avatar_cache.rs b/crates/matrix-sdk-base/src/store/avatar_cache.rs new file mode 100644 index 00000000000..f686d55f8f6 --- /dev/null +++ b/crates/matrix-sdk-base/src/store/avatar_cache.rs @@ -0,0 +1,101 @@ +use std::collections::BTreeMap; + +use ruma::{ + MxcUri, OwnedMxcUri, OwnedRoomId, OwnedUserId, RoomId, UserId, + events::room::member::SyncRoomMemberEvent, +}; +use tracing::trace; + +use crate::{StateChanges, StateStore, StoreError, store::SaveLockedStateStore}; + +/// A cache for keeping track of avatar changes in sync responses. +#[derive(Debug)] +pub struct AvatarCache { + store: SaveLockedStateStore, + changes: BTreeMap>>, +} + +impl AvatarCache { + /// Creates a new [`AvatarCache`]. + pub fn new(store: SaveLockedStateStore) -> Self { + Self { store, changes: BTreeMap::new() } + } + + /// Processes the room member event and checks if there was any change in + /// the avatar URL for the room member. + pub async fn handle_event( + &mut self, + state_changes: &StateChanges, + room_id: &RoomId, + member_event: &SyncRoomMemberEvent, + ) -> Result<(), StoreError> { + let user_id = member_event.sender(); + if self.changes.get(room_id).is_some_and(|user_ids| user_ids.contains_key(user_id)) { + return Ok(()); + } + match member_event { + SyncRoomMemberEvent::Original(original_event) => { + let avatar_url = original_event.content.avatar_url.clone(); + self.add_to_changes_if_needed(state_changes, room_id, user_id, avatar_url).await; + } + SyncRoomMemberEvent::Redacted(_) => { + trace!("Redacted event, discarding avatar change for {:?}", user_id); + } + } + Ok(()) + } + + async fn add_to_changes_if_needed( + &mut self, + state_changes: &StateChanges, + room_id: &RoomId, + user_id: &UserId, + avatar: Option, + ) { + if !self.is_same_avatar(state_changes, room_id, user_id, avatar.as_deref()).await { + trace!("Avatar for {} is different, saving to changes", user_id); + let change = self.changes.entry(room_id.to_owned()).or_default(); + change.insert(user_id.to_owned(), avatar); + } else { + trace!("Avatar for {} is the same, not saving", user_id); + } + } + + async fn is_same_avatar( + &self, + state_changes: &StateChanges, + room_id: &RoomId, + user_id: &UserId, + avatar: Option<&MxcUri>, + ) -> bool { + let current_avatar = if let Some(event) = state_changes.member(room_id, user_id) { + event.content.avatar_url + } else { + match self.store.get_profile(room_id, user_id).await { + Ok(Some(profile)) => profile.content.avatar_url, + Ok(None) => None, + Err(_) => None, + } + }; + + trace!( + "Current avatar for {} in {} is: {:?}, new avatar is: {:?}", + user_id, room_id, current_avatar, avatar + ); + + match (current_avatar, avatar) { + (Some(current_avatar), Some(avatar)) => current_avatar == avatar, + (None, None) => true, + _ => false, + } + } + + /// Removes and returns the avatar changes associated with the [`RoomId`], + /// if any. + pub fn remove_changes( + &mut self, + room_id: &RoomId, + ) -> Option>> { + self.changes.remove(room_id) + } +} diff --git a/crates/matrix-sdk-base/src/store/mod.rs b/crates/matrix-sdk-base/src/store/mod.rs index a155ab82aa4..792b446faca 100644 --- a/crates/matrix-sdk-base/src/store/mod.rs +++ b/crates/matrix-sdk-base/src/store/mod.rs @@ -77,10 +77,13 @@ use crate::{ }; pub(crate) mod ambiguity_map; +mod avatar_cache; mod memory_store; pub mod migration_helpers; mod send_queue; +pub use avatar_cache::AvatarCache; + #[cfg(any(test, feature = "testing"))] pub use self::integration_tests::StateStoreIntegrationTests; #[cfg(feature = "unstable-msc4274")] diff --git a/crates/matrix-sdk-base/src/sync.rs b/crates/matrix-sdk-base/src/sync.rs index c1431405351..3ecbbe484a8 100644 --- a/crates/matrix-sdk-base/src/sync.rs +++ b/crates/matrix-sdk-base/src/sync.rs @@ -24,7 +24,7 @@ pub use ruma::api::client::sync::sync_events::v3::{ InvitedRoom as InvitedRoomUpdate, KnockedRoom as KnockedRoomUpdate, }; use ruma::{ - OwnedEventId, OwnedRoomId, + OwnedEventId, OwnedMxcUri, OwnedRoomId, OwnedUserId, api::client::sync::sync_events::UnreadNotificationsCount as RumaUnreadNotificationsCount, events::{ AnyGlobalAccountDataEvent, AnyRoomAccountDataEvent, AnySyncEphemeralRoomEvent, @@ -219,6 +219,8 @@ pub struct JoinedRoomUpdate { /// This is a map of event ID of the `m.room.member` event to the /// details of the ambiguity change. pub ambiguity_changes: BTreeMap, + /// Collection of avatar changes that room member events trigger. + pub avatar_changes: Option>>, } #[cfg(not(tarpaulin_include))] @@ -243,8 +245,17 @@ impl JoinedRoomUpdate { ephemeral: Vec>, unread_notifications: UnreadNotificationsCount, ambiguity_changes: BTreeMap, + avatar_changes: Option>>, ) -> Self { - Self { unread_notifications, timeline, state, account_data, ephemeral, ambiguity_changes } + Self { + unread_notifications, + timeline, + state, + account_data, + ephemeral, + ambiguity_changes, + avatar_changes, + } } } diff --git a/crates/matrix-sdk-ui/src/timeline/tasks.rs b/crates/matrix-sdk-ui/src/timeline/tasks.rs index b05de6bbe24..a5b23ecea16 100644 --- a/crates/matrix-sdk-ui/src/timeline/tasks.rs +++ b/crates/matrix-sdk-ui/src/timeline/tasks.rs @@ -273,15 +273,28 @@ pub(in crate::timeline) async fn room_event_cache_updates_task( timeline_controller.handle_ephemeral_events(events).await; } - RoomEventCacheUpdate::UpdateMembers { ambiguity_changes } => { - if !ambiguity_changes.is_empty() { + RoomEventCacheUpdate::UpdateMembers { ambiguity_changes, avatar_changes } => { + if !ambiguity_changes.is_empty() + || !avatar_changes.as_ref().is_none_or(|avatars| avatars.is_empty()) + { let member_ambiguity_changes = ambiguity_changes .values() .flat_map(|change| change.user_ids()) .collect::>(); - timeline_controller - .force_update_sender_profiles(&member_ambiguity_changes) - .await; + + let mut user_ids_to_update = member_ambiguity_changes; + + if let Some(avatar_changes) = &avatar_changes { + let mut user_ids = + avatar_changes.keys().map(|u| u.as_ref()).collect::>(); + user_ids_to_update.append(&mut user_ids) + } else { + warn!( + "No avatar changes to update for {}, ignoring", + room_event_cache.room_id() + ); + } + timeline_controller.force_update_sender_profiles(&user_ids_to_update).await; } } } diff --git a/crates/matrix-sdk/src/event_cache/caches/room/mod.rs b/crates/matrix-sdk/src/event_cache/caches/room/mod.rs index 2e8a21eaca8..bf791602476 100644 --- a/crates/matrix-sdk/src/event_cache/caches/room/mod.rs +++ b/crates/matrix-sdk/src/event_cache/caches/room/mod.rs @@ -30,7 +30,7 @@ use matrix_sdk_base::{ sync::{JoinedRoomUpdate, LeftRoomUpdate, Timeline}, }; use ruma::{ - EventId, OwnedEventId, OwnedRoomId, RoomId, + EventId, OwnedEventId, OwnedMxcUri, OwnedRoomId, OwnedUserId, RoomId, events::{AnyRoomAccountDataEvent, AnySyncEphemeralRoomEvent, relation::RelationType}, serde::Raw, }; @@ -359,7 +359,12 @@ impl RoomEventCache { #[instrument(skip_all, fields(room_id = %self.room_id()))] pub(super) async fn handle_joined_room_update(&self, updates: JoinedRoomUpdate) -> Result<()> { self.inner - .handle_timeline(updates.timeline, updates.ephemeral.clone(), updates.ambiguity_changes) + .handle_timeline( + updates.timeline, + updates.ephemeral.clone(), + updates.ambiguity_changes, + updates.avatar_changes, + ) .await?; self.inner.handle_account_data(updates.account_data); @@ -369,7 +374,9 @@ impl RoomEventCache { /// Handle a [`LeftRoomUpdate`]. #[instrument(skip_all, fields(room_id = %self.room_id()))] pub(super) async fn handle_left_room_update(&self, updates: LeftRoomUpdate) -> Result<()> { - self.inner.handle_timeline(updates.timeline, Vec::new(), updates.ambiguity_changes).await?; + self.inner + .handle_timeline(updates.timeline, Vec::new(), updates.ambiguity_changes, None) + .await?; Ok(()) } @@ -508,12 +515,14 @@ impl RoomEventCacheInner { timeline: Timeline, ephemeral_events: Vec>, ambiguity_changes: BTreeMap, + avatar_changes: Option>>, ) -> Result<()> { self.handle_timeline_inner( self.state.write().await?, timeline, ephemeral_events, ambiguity_changes, + avatar_changes, ) .await } @@ -534,6 +543,7 @@ impl RoomEventCacheInner { Timeline { limited: false, prev_batch: None, events: vec![event] }, Vec::new(), BTreeMap::new(), + None, ) .await; } @@ -547,11 +557,13 @@ impl RoomEventCacheInner { timeline: Timeline, ephemeral_events: Vec>, ambiguity_changes: BTreeMap, + avatar_changes: Option>>, ) -> Result<()> { if timeline.events.is_empty() && timeline.prev_batch.is_none() && ephemeral_events.is_empty() && ambiguity_changes.is_empty() + && avatar_changes.as_ref().is_none_or(|avatars| avatars.is_empty()) { return Ok(()); } @@ -587,9 +599,11 @@ impl RoomEventCacheInner { .send(RoomEventCacheUpdate::AddEphemeralEvents { events: ephemeral_events }, None); } - if !ambiguity_changes.is_empty() { - self.update_sender - .send(RoomEventCacheUpdate::UpdateMembers { ambiguity_changes }, None); + if !ambiguity_changes.is_empty() || avatar_changes.as_ref().is_some_and(|c| !c.is_empty()) { + self.update_sender.send( + RoomEventCacheUpdate::UpdateMembers { ambiguity_changes, avatar_changes }, + None, + ); } Ok(()) diff --git a/crates/matrix-sdk/src/event_cache/caches/room/updates.rs b/crates/matrix-sdk/src/event_cache/caches/room/updates.rs index 36b928bcc7a..ed47e227dd9 100644 --- a/crates/matrix-sdk/src/event_cache/caches/room/updates.rs +++ b/crates/matrix-sdk/src/event_cache/caches/room/updates.rs @@ -19,7 +19,10 @@ use matrix_sdk_base::{ event_cache::{Event, Gap}, linked_chunk::{self, OwnedLinkedChunkId}, }; -use ruma::{OwnedEventId, OwnedRoomId, events::AnySyncEphemeralRoomEvent, serde::Raw}; +use ruma::{ + OwnedEventId, OwnedMxcUri, OwnedRoomId, OwnedUserId, events::AnySyncEphemeralRoomEvent, + serde::Raw, +}; use tokio::sync::broadcast::{Receiver, Sender}; use super::super::TimelineVectorDiffs; @@ -40,6 +43,9 @@ pub enum RoomEventCacheUpdate { /// This is a map of event ID of the `m.room.member` event to the /// details of the ambiguity change. ambiguity_changes: BTreeMap, + + /// Collection of avatar changes that room member events trigger. + avatar_changes: Option>>, }, /// The room has received updates for the timeline as _diffs_. diff --git a/crates/matrix-sdk/src/sync.rs b/crates/matrix-sdk/src/sync.rs index e21fff47e43..721c876ebf9 100644 --- a/crates/matrix-sdk/src/sync.rs +++ b/crates/matrix-sdk/src/sync.rs @@ -209,6 +209,7 @@ impl Client { account_data, ephemeral, ambiguity_changes: _, + avatar_changes: _, } = room_info; let room = Some(&room); diff --git a/crates/matrix-sdk/tests/integration/client.rs b/crates/matrix-sdk/tests/integration/client.rs index 350be7c43a6..8d8514bd16d 100644 --- a/crates/matrix-sdk/tests/integration/client.rs +++ b/crates/matrix-sdk/tests/integration/client.rs @@ -8,6 +8,7 @@ use matrix_sdk::{ assert_let_timeout, authentication::oauth::{OAuthError, error::OAuthTokenRevocationError}, config::{RequestConfig, StoreConfig, SyncSettings, SyncToken}, + event_cache::RoomEventCacheUpdate, live_locations_observer::BeaconInfoUpdate, sleep::sleep, store::{RoomLoadSettings, ThreadSubscriptionStatus}, @@ -55,7 +56,7 @@ use ruma::{ }, tag::{TagInfo, TagName, Tags}, }, - owned_device_id, owned_event_id, owned_room_id, owned_user_id, + owned_device_id, owned_event_id, owned_mxc_uri, owned_room_id, owned_user_id, room::JoinRule, room_id, serde::Raw, @@ -1266,6 +1267,138 @@ async fn test_test_ambiguity_changes() { assert_pending!(updates); } +#[async_test] +async fn test_avatar_url_changes() { + let (client, server) = logged_in_client_with_server().await; + + let example_id = user_id!("@example:localhost"); + let example_2_id = user_id!("@example_2:localhost"); + + let mut updates = BroadcastStream::new(client.subscribe_to_room_updates(&DEFAULT_TEST_ROOM_ID)); + assert_pending!(updates); + + // Initial sync, adds 2 members. + mock_sync(&server, &*test_json::SYNC, None).await; + let response = + client.sync_once(SyncSettings::default().token(SyncToken::NoToken)).await.unwrap(); + server.reset().await; + + // No changes since the users didn't have any avatar URLs. + assert!(response.rooms.joined.get(*DEFAULT_TEST_ROOM_ID).unwrap().avatar_changes.is_none()); + + let changes = assert_next_matches!(updates, Ok(RoomUpdate::Joined { updates, .. }) => updates.avatar_changes); + assert!(changes.is_none()); + + // Subscribe to the event cache to receive RoomEventCacheUpdate + client.event_cache().subscribe().expect("event cache subscription"); + let room = client.get_room(&DEFAULT_TEST_ROOM_ID).expect("room"); + let (room_cache, _handle) = room.event_cache().await.expect("room cache"); + let (_, mut subscriber) = room_cache.subscribe().await.expect("subscription"); + + // Now we sync a room member with an avatar URL. + let mut sync_builder = SyncResponseBuilder::new(); + let joined_room = JoinedRoomBuilder::new(&DEFAULT_TEST_ROOM_ID).add_state_bulk([ + sync_state_event!({ + "content": { + "avatar_url": "mxc://localhost/avatar", + "displayname": "the first example", + "membership": "join" + }, + "event_id": event_id!("$example_avatar"), + "origin_server_ts": 151800140, + "sender": example_id, + "state_key": example_id, + "type": "m.room.member", + }), + sync_state_event!({ + "content": { + "avatar_url": "mxc://localhost/avatar2", + "displayname": "the second example", + "membership": "join" + }, + "event_id": event_id!("$example_avatar_2"), + "origin_server_ts": 151800140, + "sender": example_2_id, + "state_key": example_2_id, + "type": "m.room.member", + }), + ]); + sync_builder.add_joined_room(joined_room); + + mock_sync(&server, sync_builder.build_json_sync_response(), None).await; + client.sync_once(SyncSettings::default().token(SyncToken::NoToken)).await.unwrap(); + server.reset().await; + + let changes = assert_next_matches!(updates, Ok(RoomUpdate::Joined { updates, .. }) => updates.avatar_changes.expect("avatar changes") ); + assert_eq!(changes.len(), 2); + assert_let!(Some(Some(avatar_url)) = changes.get(example_id)); + assert_eq!(avatar_url, "mxc://localhost/avatar"); + assert_let!(Some(Some(avatar_url)) = changes.get(example_2_id)); + assert_eq!(avatar_url, "mxc://localhost/avatar2"); + + // The room event cache emits a RoomEventCacheUpdate when the avatar URL + // changes. This will trigger a timeline item refresh. + let changes = subscriber.recv().await.expect("subscription event"); + assert_let!(RoomEventCacheUpdate::UpdateMembers { avatar_changes, .. } = changes); + assert!(avatar_changes.is_some()); + assert_eq!( + avatar_changes.unwrap(), + BTreeMap::from([ + (example_id.to_owned(), Some(owned_mxc_uri!("mxc://localhost/avatar"))), + (example_2_id.to_owned(), Some(owned_mxc_uri!("mxc://localhost/avatar2"))) + ]) + ); + + // And after that, receive the first room member without an avatar URL. + let joined_room = + JoinedRoomBuilder::new(&DEFAULT_TEST_ROOM_ID).add_state_bulk([sync_state_event!({ + "content": { + "avatar_url": null, + "displayname": "the first example", + "membership": "join" + }, + "event_id": event_id!("$example_avatar_removal"), + "origin_server_ts": 151800141, + "sender": example_id, + "state_key": example_id, + "type": "m.room.member", + })]); + sync_builder.add_joined_room(joined_room); + + mock_sync(&server, sync_builder.build_json_sync_response(), None).await; + client.sync_once(SyncSettings::default().token(SyncToken::NoToken)).await.unwrap(); + server.reset().await; + + // There is a single change: the avatar is now None + let changes = assert_next_matches!(updates, Ok(RoomUpdate::Joined { updates, .. }) => updates.avatar_changes.expect("avatar changes") ); + assert_eq!(changes.len(), 1); + assert_let!(Some(None) = changes.get(example_id)); + + // If we receive the same event again, nothing should happen + let joined_room = + JoinedRoomBuilder::new(&DEFAULT_TEST_ROOM_ID).add_state_bulk([sync_state_event!({ + "content": { + "avatar_url": null, + "displayname": "the first example", + "membership": "join" + }, + "event_id": event_id!("$example_avatar_removal"), + "origin_server_ts": 151800141, + "sender": example_id, + "state_key": example_id, + "type": "m.room.member", + })]); + sync_builder.add_joined_room(joined_room); + + mock_sync(&server, sync_builder.build_json_sync_response(), None).await; + client.sync_once(SyncSettings::default().token(SyncToken::NoToken)).await.unwrap(); + server.reset().await; + + // There aren't any changes + let changes = assert_next_matches!(updates, Ok(RoomUpdate::Joined { updates, .. }) => updates.avatar_changes ); + assert!(changes.is_none()); +} + #[cfg(not(target_family = "wasm"))] #[async_test] async fn test_rooms_stream() {