From 89fd0b5e5324e3bdb30d4b5889c2cc63df835b2a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jorge=20Mart=C3=ADn?= Date: Tue, 9 Sep 2025 10:22:13 +0200 Subject: [PATCH] feat(sdk): Allow adding and retrieving recent emojis Include some tests. --- crates/matrix-sdk/Cargo.toml | 2 + crates/matrix-sdk/src/client/mod.rs | 90 +++++++++++++++++++ crates/matrix-sdk/src/test_utils/mocks/mod.rs | 50 +++++++++++ testing/matrix-sdk-test/src/event_factory.rs | 15 +++- 4 files changed, 154 insertions(+), 3 deletions(-) diff --git a/crates/matrix-sdk/Cargo.toml b/crates/matrix-sdk/Cargo.toml index d59e3678f..b08600003 100644 --- a/crates/matrix-sdk/Cargo.toml +++ b/crates/matrix-sdk/Cargo.toml @@ -73,6 +73,8 @@ unstable-msc4274 = ["ruma/unstable-msc4274", "matrix-sdk-base/unstable-msc4274"] experimental-search = ["matrix-sdk-search"] +element-recent-emojis = ["matrix-sdk-base/element-recent-emojis"] + [dependencies] anyhow = { workspace = true, optional = true } anymap2 = "0.13.0" diff --git a/crates/matrix-sdk/src/client/mod.rs b/crates/matrix-sdk/src/client/mod.rs index 7d808798b..98610cf55 100644 --- a/crates/matrix-sdk/src/client/mod.rs +++ b/crates/matrix-sdk/src/client/mod.rs @@ -28,6 +28,8 @@ use eyeball::{SharedObservable, Subscriber}; use eyeball_im::{Vector, VectorDiff}; use futures_core::Stream; use futures_util::StreamExt; +#[cfg(feature = "element-recent-emojis")] +use js_int::uint; #[cfg(feature = "e2e-encryption")] use matrix_sdk_base::crypto::{store::LockableCryptoStore, DecryptionSettings}; use matrix_sdk_base::{ @@ -38,7 +40,11 @@ use matrix_sdk_base::{ BaseClient, RoomInfoNotableUpdate, RoomState, RoomStateFilter, SendOutsideWasm, SessionMeta, StateStoreDataKey, StateStoreDataValue, SyncOutsideWasm, ThreadingSupport, }; +#[cfg(feature = "element-recent-emojis")] +use matrix_sdk_base::{recent_emojis::RecentEmojisContent, store::StateStoreExt}; use matrix_sdk_common::ttl_cache::TtlCache; +#[cfg(feature = "element-recent-emojis")] +use ruma::api::client::config::set_global_account_data::v3::Request as UpdateGlobalAccountDataRequest; #[cfg(feature = "e2e-encryption")] use ruma::events::{room::encryption::RoomEncryptionEventContent, InitialStateEvent}; use ruma::{ @@ -2943,6 +2949,50 @@ impl Client { pub(crate) fn thread_subscription_catchup(&self) -> &ThreadSubscriptionCatchup { self.inner.thread_subscription_catchup.get().unwrap() } + + #[cfg(feature = "element-recent-emojis")] + async fn get_recent_emoji_content(&self) -> Result> { + self.state_store() + .get_account_data_event_static::() + .await? + .map(|raw| raw.deserialize().map(|event| event.content)) + .transpose() + .map_err(Into::into) + } + + #[cfg(feature = "element-recent-emojis")] + /// Adds a recently used emoji to the list and uploads the updated + /// `io.element.recent_emoji` content to the global account data. + pub async fn add_recent_emoji(&self, emoji: &str) -> Result<()> { + let mut content = + self.get_recent_emoji_content().await?.unwrap_or_else(RecentEmojisContent::default); + + let index = content.recent_emoji.iter().position(|(unicode, _)| unicode == emoji); + + let count = + if let Some(index) = index { content.recent_emoji.remove(index).1 } else { uint!(0) }; + + content.recent_emoji.insert(0, (emoji.to_owned(), count + uint!(1))); + + let Some(session_id) = self.user_id() else { + return Err(Error::AuthenticationRequired); + }; + let request = UpdateGlobalAccountDataRequest::new(session_id.to_owned(), &content)?; + let _ = self.send(request).await?; + + Ok(()) + } + + #[cfg(feature = "element-recent-emojis")] + /// Gets the list of recently used emojis from the `io.element.recent_emoji` + /// global account data. + pub async fn get_recent_emojis(&self) -> Result> { + let Some(content) = self.get_recent_emoji_content().await? else { + return Ok(Vec::new()); + }; + + Ok(content.recent_emoji.into_iter().map(|(emoji, count)| (emoji, count.into())).collect()) + } } #[cfg(any(feature = "testing", test))] @@ -3074,6 +3124,10 @@ pub(crate) mod tests { #[cfg(target_family = "wasm")] wasm_bindgen_test::wasm_bindgen_test_configure!(run_in_browser); + #[cfg(feature = "element-recent-emojis")] + use matrix_sdk_base::recent_emojis::RecentEmojisContent; + #[cfg(feature = "element-recent-emojis")] + use ruma::events::GlobalAccountDataEventContent; use ruma::{ api::{ client::{ @@ -4073,4 +4127,40 @@ pub(crate) mod tests { .await .unwrap(); } + + #[cfg(feature = "element-recent-emojis")] + #[async_test] + async fn test_recent_emojis() { + let server = MatrixMockServer::new().await; + let client = server.client_builder().build().await; + + server + .mock_update_global_account_data() + .ok(client.user_id().expect("session_id"), RecentEmojisContent::default().event_type()) + .named("Update recent emojis global account data") + .mock_once() + .mount() + .await; + + let recent_emojis = client.get_recent_emojis().await.expect("recent emojis"); + assert!(recent_emojis.is_empty()); + + client.add_recent_emoji(":)").await.expect("adding emoji"); + + server + .mock_sync() + .ok(|builder| { + let content = RecentEmojisContent::new(vec![(":)".to_owned(), uint!(1))]); + let event_builder = EventFactory::new().global_account_data(content); + builder.add_global_account_data(event_builder); + }) + .named("Sync") + .mount() + .await; + + client.sync_once(SyncSettings::default()).await.expect("sync failed"); + + let recent_emojis = client.get_recent_emojis().await.expect("recent emojis"); + assert_eq!(recent_emojis.len(), 1); + } } diff --git a/crates/matrix-sdk/src/test_utils/mocks/mod.rs b/crates/matrix-sdk/src/test_utils/mocks/mod.rs index 36ed4a2e6..a9f69c3e7 100644 --- a/crates/matrix-sdk/src/test_utils/mocks/mod.rs +++ b/crates/matrix-sdk/src/test_utils/mocks/mod.rs @@ -1211,6 +1211,40 @@ impl MatrixMockServer { self.mock_endpoint(mock, GlobalAccountDataEndpoint).expect_default_access_token() } + /// Create a prebuilt mock for the endpoint that updates the global account + /// data. + /// + /// # Examples + /// + /// ``` + /// use ruma::__private_macros::user_id; + /// tokio_test::block_on(async { + /// use matrix_sdk::test_utils::mocks::MatrixMockServer; + /// use serde_json::json; + /// use ruma::events::media_preview_config::MediaPreviews; + /// + /// let mock_server = MatrixMockServer::new().await; + /// let client = mock_server.client_builder().build().await; + /// + /// mock_server.mock_update_global_account_data().ok( + /// client.user_id().unwrap(), + /// ruma::events::GlobalAccountDataEventType::IgnoredUserList, + /// ) + /// .mock_once() + /// .mount() + /// .await; + /// + /// client.account().ignore_user(user_id!("@a:b.c")).await.unwrap(); + /// + /// # anyhow::Ok(()) }); + /// ``` + pub fn mock_update_global_account_data( + &self, + ) -> MockEndpoint<'_, UpdateGlobalAccountDataEndpoint> { + let mock = Mock::given(method("PUT")); + self.mock_endpoint(mock, UpdateGlobalAccountDataEndpoint).expect_default_access_token() + } + /// Create a prebuilt mock for the endpoint used to send a single receipt. pub fn mock_send_receipt( &self, @@ -3506,6 +3540,22 @@ impl<'a> MockEndpoint<'a, GlobalAccountDataEndpoint> { } } +/// A prebuilt mock for the update global account data endpoint. +pub struct UpdateGlobalAccountDataEndpoint; + +impl<'a> MockEndpoint<'a, UpdateGlobalAccountDataEndpoint> { + /// Returns a mock for a successful global account data event. + pub fn ok(self, user_id: &UserId, event_type: GlobalAccountDataEventType) -> MatrixMock<'a> { + let mock = self + .mock + .and(path_regex(format!( + r"^/_matrix/client/v3/user/{user_id}/account_data/{event_type}" + ))) + .respond_with(ResponseTemplate::new(200).set_body_json(())); + MatrixMock { server: self.server, mock } + } +} + /// A response to a [`RoomRelationsEndpoint`] query. #[derive(Default)] pub struct RoomRelationsResponseTemplate { diff --git a/testing/matrix-sdk-test/src/event_factory.rs b/testing/matrix-sdk-test/src/event_factory.rs index 7773c571c..576d983b1 100644 --- a/testing/matrix-sdk-test/src/event_factory.rs +++ b/testing/matrix-sdk-test/src/event_factory.rs @@ -29,9 +29,9 @@ use ruma::{ TransactionId, UInt, UserId, VoipVersionId, events::{ AnyGlobalAccountDataEvent, AnyStateEvent, AnySyncMessageLikeEvent, AnySyncStateEvent, - AnySyncTimelineEvent, AnyTimelineEvent, BundledMessageLikeRelations, False, Mentions, - RedactedMessageLikeEventContent, RedactedStateEventContent, StateEventContent, - StaticEventContent, + AnySyncTimelineEvent, AnyTimelineEvent, BundledMessageLikeRelations, False, + GlobalAccountDataEventContent, Mentions, RedactedMessageLikeEventContent, + RedactedStateEventContent, StateEventContent, StaticEventContent, beacon::BeaconEventContent, call::{ SessionDescription, @@ -1076,6 +1076,15 @@ impl EventFactory { pub fn set_next_ts(&self, value: u64) { self.next_ts.store(value, SeqCst); } + + pub fn global_account_data(&self, content: C) -> EventBuilder + where + C: GlobalAccountDataEventContent + StaticEventContent, + { + let mut event = self.event(content); + event.is_global = true; + event + } } impl EventBuilder {