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`.
This commit is contained in:
Jorge Martín
2026-05-14 18:28:59 +02:00
committed by Damir Jelić
parent e5a50089c2
commit 7c13b60e28
14 changed files with 329 additions and 25 deletions

View File

@@ -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(

View File

@@ -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 }
}
}

View File

@@ -66,7 +66,7 @@ pub async fn update_any_room(
) -> Result<Option<(RoomInfo, RoomUpdateKind)>> {
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,
)),
)))
}

View File

@@ -43,7 +43,7 @@ pub async fn update_joined_room(
notification: notification::Notification<'_>,
#[cfg(feature = "e2e-encryption")] e2ee: &e2ee::E2EE<'_>,
) -> Result<JoinedRoomUpdate> {
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<LeftRoomUpdate> {
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")]

View File

@@ -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<RawStateEventWithKeys<AnySyncStateEvent>>,
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<AnySyncStateEvent>,
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 => {

View File

@@ -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,

View File

@@ -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<OwnedRoomId, BTreeMap<OwnedUserId, Option<OwnedMxcUri>>>,
}
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<OwnedMxcUri>,
) {
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<BTreeMap<OwnedUserId, Option<OwnedMxcUri>>> {
self.changes.remove(room_id)
}
}

View File

@@ -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")]

View File

@@ -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<OwnedEventId, AmbiguityChange>,
/// Collection of avatar changes that room member events trigger.
pub avatar_changes: Option<BTreeMap<OwnedUserId, Option<OwnedMxcUri>>>,
}
#[cfg(not(tarpaulin_include))]
@@ -243,8 +245,17 @@ impl JoinedRoomUpdate {
ephemeral: Vec<Raw<AnySyncEphemeralRoomEvent>>,
unread_notifications: UnreadNotificationsCount,
ambiguity_changes: BTreeMap<OwnedEventId, AmbiguityChange>,
avatar_changes: Option<BTreeMap<OwnedUserId, Option<OwnedMxcUri>>>,
) -> Self {
Self { unread_notifications, timeline, state, account_data, ephemeral, ambiguity_changes }
Self {
unread_notifications,
timeline,
state,
account_data,
ephemeral,
ambiguity_changes,
avatar_changes,
}
}
}

View File

@@ -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::<BTreeSet<_>>();
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::<BTreeSet<_>>();
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;
}
}
}

View File

@@ -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<Raw<AnySyncEphemeralRoomEvent>>,
ambiguity_changes: BTreeMap<OwnedEventId, AmbiguityChange>,
avatar_changes: Option<BTreeMap<OwnedUserId, Option<OwnedMxcUri>>>,
) -> 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<Raw<AnySyncEphemeralRoomEvent>>,
ambiguity_changes: BTreeMap<OwnedEventId, AmbiguityChange>,
avatar_changes: Option<BTreeMap<OwnedUserId, Option<OwnedMxcUri>>>,
) -> 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(())

View File

@@ -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<OwnedEventId, AmbiguityChange>,
/// Collection of avatar changes that room member events trigger.
avatar_changes: Option<BTreeMap<OwnedUserId, Option<OwnedMxcUri>>>,
},
/// The room has received updates for the timeline as _diffs_.

View File

@@ -209,6 +209,7 @@ impl Client {
account_data,
ephemeral,
ambiguity_changes: _,
avatar_changes: _,
} = room_info;
let room = Some(&room);

View File

@@ -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() {