atagen

matrix-rust-sdk

public
Star0
Watch0

Commit 00eb9bff2d7b

tarball

atagen <boss@atagen.co>2026-10-22 12:30 UTC

14 files changed+284-33
timeline: carry the msc4027 shortcode on reactions
Modified file: crates/matrix-sdk-base/src/store/integration_tests.rs+2 additions−2 deletions
@@ -1794,7 +1794,7 @@ impl StateStoreIntegrationTests for DynStateStore {
17941794 self.update_dependent_queued_request(
17951795 room_id,
17961796 &child_txn,
1797- DependentQueuedRequestKind::ReactEvent { key: "👍".to_owned() },
1797+ DependentQueuedRequestKind::ReactEvent { key: "👍".to_owned(), shortcode: None },
17981798 )
17991799 .await?;
18001800
@@ -1806,7 +1806,7 @@ impl StateStoreIntegrationTests for DynStateStore {
18061806 assert!(dependents[0].parent_key.is_none());
18071807 assert_matches!(
18081808 &dependents[0].kind,
1809- DependentQueuedRequestKind::ReactEvent { key } => {
1809+ DependentQueuedRequestKind::ReactEvent { key, .. } => {
18101810 assert_eq!(key, "👍");
18111811 }
18121812 );
Modified file: crates/matrix-sdk-base/src/store/send_queue.rs+6 additions
@@ -237,6 +237,12 @@ pub enum DependentQueuedRequestKind {
237237 ReactEvent {
238238 /// Key used for the reaction.
239239 key: String,
240+
241+ /// Shortcode of the custom emote used as the key, as per [MSC4027].
242+ ///
243+ /// [MSC4027]: https://github.com/matrix-org/matrix-spec-proposals/pull/4027
244+ #[serde(default, skip_serializing_if = "Option::is_none")]
245+ shortcode: Option<String>,
240246 },
241247
242248 /// Upload a file or thumbnail depending on another file or thumbnail
Modified file: crates/matrix-sdk-ui/src/timeline/controller/aggregations.rs+11 additions−2 deletions
@@ -116,6 +116,11 @@ pub(crate) enum AggregationKind {
116116 Reaction {
117117 /// The reaction "key" displayed by the client, often an emoji.
118118 key: String,
119+ /// The shortcode of the custom emote used as the key, as per
120+ /// [MSC4027].
121+ ///
122+ /// [MSC4027]: https://github.com/matrix-org/matrix-spec-proposals/pull/4027
123+ shortcode: Option<String>,
119124 /// Sender of the reaction.
120125 sender: OwnedUserId,
121126 /// Timestamp at which the reaction has been sent.
@@ -286,7 +291,7 @@ impl Aggregation {
286291 Err(err) => ApplyAggregationResult::Error(err),
287292 },
288293
289- AggregationKind::Reaction { key, sender, timestamp, reaction_status } => {
294+ AggregationKind::Reaction { key, shortcode, sender, timestamp, reaction_status } => {
290295 let Some(reactions) = event.content().reactions() else {
291296 // An item that can't hold any reactions.
292297 return ApplyAggregationResult::LeftItemIntact;
@@ -325,7 +330,11 @@ impl Aggregation {
325330
326331 reactions.entry(key.clone()).or_default().insert(
327332 sender.clone(),
328- ReactionInfo { timestamp: *timestamp, status: reaction_status.clone() },
333+ ReactionInfo {
334+ timestamp: *timestamp,
335+ status: reaction_status.clone(),
336+ shortcode: shortcode.clone(),
337+ },
329338 );
330339
331340 ApplyAggregationResult::UpdatedItem
Modified file: crates/matrix-sdk-ui/src/timeline/controller/mod.rs+51 additions−10 deletions
@@ -30,7 +30,8 @@ use matrix_sdk::{
3030 TimelineVectorDiffs,
3131 },
3232 send_queue::{
33- LocalEcho, LocalEchoContent, RoomSendQueueUpdate, SendHandle, SendReactionHandle,
33+ LocalEcho, LocalEchoContent, RoomSendQueueStorageError, RoomSendQueueUpdate, SendHandle,
34+ SendReactionHandle,
3435 },
3536 task_monitor::BackgroundTaskHandle,
3637 };
@@ -71,6 +72,7 @@ use super::{
7172 TimelineFocus, TimelineItem, TimelineItemContent, TimelineItemKind,
7273 TimelineReadReceiptTracking, VirtualTimelineItem,
7374 algorithms::{rfind_event_by_id, rfind_event_item},
75+ event_handler::{REACTION_SHORTCODE_FIELD, reaction_shortcode},
7476 event_item::{ReactionStatus, RemoteEventOrigin},
7577 item::TimelineUniqueId,
7678 subscriber::TimelineSubscriber,
@@ -492,6 +494,7 @@ impl<P: RoomDataProvider> TimelineController<P> {
492494 &self,
493495 item_id: &TimelineEventItemId,
494496 key: &str,
497+ shortcode: Option<&str>,
495498 ) -> Result<bool, Error> {
496499 let mut state = self.state.write().await;
497500
@@ -511,7 +514,7 @@ impl<P: RoomDataProvider> TimelineController<P> {
511514 match item.handle() {
512515 TimelineItemHandle::Local(send_handle) => {
513516 if send_handle
514- .react(key.to_owned())
517+ .react_with_shortcode(key.to_owned(), shortcode.map(ToOwned::to_owned))
515518 .await
516519 .map_err(|err| Error::SendQueueError(err.into()))?
517520 .is_some()
@@ -530,9 +533,27 @@ impl<P: RoomDataProvider> TimelineController<P> {
530533 // take care of it.
531534 trace!("adding a reaction to a remote echo");
532535 let annotation = Annotation::new(event_id.to_owned(), key.to_owned());
533- self.room_data_provider
534- .send(ReactionEventContent::from(annotation).into())
535- .await?;
536+ let content = ReactionEventContent::from(annotation);
537+ if let Some(shortcode) = shortcode {
538+ let content = serde_json::to_value(&content)
539+ .and_then(|mut content| {
540+ content[REACTION_SHORTCODE_FIELD] = shortcode.into();
541+ Raw::new(&content)
542+ })
543+ .map_err(|err| {
544+ Error::SendQueueError(
545+ RoomSendQueueStorageError::JsonSerialization(err).into(),
546+ )
547+ })?;
548+ self.room_data_provider
549+ .send_raw(
550+ content.cast_unchecked(),
551+ MessageLikeEventType::Reaction.to_string(),
552+ )
553+ .await?;
554+ } else {
555+ self.room_data_provider.send(content.into()).await?;
556+ }
536557 return Ok(true);
537558 }
538559 }
@@ -746,6 +767,7 @@ impl<P: RoomDataProvider> TimelineController<P> {
746767 &self,
747768 txn_id: OwnedTransactionId,
748769 content: AnyMessageLikeEventContent,
770+ reaction_shortcode: Option<String>,
749771 send_handle: Option<SendHandle>,
750772 ) {
751773 let sender = self.room_data_provider.own_user_id().to_owned();
@@ -755,7 +777,15 @@ impl<P: RoomDataProvider> TimelineController<P> {
755777
756778 let mut state = self.state.write().await;
757779 state
758- .handle_local_event(sender, profile, date_divider_mode, txn_id, send_handle, content)
780+ .handle_local_event(
781+ sender,
782+ profile,
783+ date_divider_mode,
784+ txn_id,
785+ send_handle,
786+ content,
787+ reaction_shortcode,
788+ )
759789 .await;
760790 }
761791
@@ -1130,8 +1160,17 @@ impl<P: RoomDataProvider> TimelineController<P> {
11301160 }
11311161 };
11321162
1133- self.handle_local_event(echo.transaction_id.clone(), content, Some(send_handle))
1134- .await;
1163+ let shortcode = matches!(content, AnyMessageLikeEventContent::Reaction(_))
1164+ .then(|| reaction_shortcode(serialized_event.raw().0))
1165+ .flatten();
1166+
1167+ self.handle_local_event(
1168+ echo.transaction_id.clone(),
1169+ content,
1170+ shortcode,
1171+ Some(send_handle),
1172+ )
1173+ .await;
11351174
11361175 if let Some(send_error) = send_error {
11371176 self.update_event_send_state(
@@ -1147,8 +1186,8 @@ impl<P: RoomDataProvider> TimelineController<P> {
11471186 }
11481187 }
11491188
1150- LocalEchoContent::React { key, send_handle, applies_to } => {
1151- self.handle_local_reaction(key, send_handle, applies_to).await;
1189+ LocalEchoContent::React { key, shortcode, send_handle, applies_to } => {
1190+ self.handle_local_reaction(key, shortcode, send_handle, applies_to).await;
11521191 }
11531192
11541193 LocalEchoContent::Redaction { redacts, send_error, .. } => {
@@ -1175,6 +1214,7 @@ impl<P: RoomDataProvider> TimelineController<P> {
11751214 async fn handle_local_reaction(
11761215 &self,
11771216 reaction_key: String,
1217+ shortcode: Option<String>,
11781218 send_handle: SendReactionHandle,
11791219 applies_to: OwnedTransactionId,
11801220 ) {
@@ -1189,6 +1229,7 @@ impl<P: RoomDataProvider> TimelineController<P> {
11891229 TimelineEventItemId::TransactionId(reaction_txn_id),
11901230 AggregationKind::Reaction {
11911231 key: reaction_key.clone(),
1232+ shortcode,
11921233 sender: self.room_data_provider.own_user_id().to_owned(),
11931234 timestamp: MilliSecondsSinceUnixEpoch::now(),
11941235 reaction_status,
Modified file: crates/matrix-sdk-ui/src/timeline/controller/state.rs+3 additions−1 deletions
@@ -157,6 +157,7 @@ impl<P: RoomDataProvider> TimelineState<P> {
157157 txn_id: OwnedTransactionId,
158158 send_handle: Option<SendHandle>,
159159 content: AnyMessageLikeEventContent,
160+ reaction_shortcode: Option<String>,
160161 ) {
161162 let mut txn = self.transaction();
162163
@@ -194,7 +195,8 @@ impl<P: RoomDataProvider> TimelineState<P> {
194195 should_add_new_items,
195196 };
196197
197- let timeline_action = TimelineAction::from_content(content, in_reply_to, thread_root, None);
198+ let timeline_action = TimelineAction::from_content(content, in_reply_to, thread_root, None)
199+ .with_reaction_shortcode(|| reaction_shortcode);
198200 TimelineEventHandler::new(&mut txn, ctx)
199201 .handle_event(&mut date_divider_adjuster, timeline_action, None)
200202 .await;
Modified file: crates/matrix-sdk-ui/src/timeline/event_handler.rs+50 additions−5 deletions
@@ -65,6 +65,19 @@ use crate::{
6565 unable_to_decrypt_hook::UtdHookManager,
6666 };
6767
68+/// The field holding the shortcode of a custom emote reaction, as per
69+/// [MSC4027], which ruma's `ReactionEventContent` has no field for.
70+///
71+/// [MSC4027]: https://github.com/matrix-org/matrix-spec-proposals/pull/4027
72+pub(super) const REACTION_SHORTCODE_FIELD: &str = "com.beeper.reaction.shortcode";
73+
74+/// Reads the [MSC4027] shortcode from a reaction's raw content.
75+///
76+/// [MSC4027]: https://github.com/matrix-org/matrix-spec-proposals/pull/4027
77+pub(super) fn reaction_shortcode(content: &Raw<AnyMessageLikeEventContent>) -> Option<String> {
78+ content.get_field(REACTION_SHORTCODE_FIELD).ok().flatten()
79+}
80+
6881 /// When adding an event, useful information related to the source of the event.
6982 pub(super) enum Flow {
7083 /// The event was locally created.
@@ -140,7 +153,7 @@ pub(super) struct TimelineEventContext {
140153 #[derive(Clone, Debug)]
141154 pub(super) enum HandleAggregationKind {
142155 /// Adding a reaction to the related event.
143- Reaction { key: String },
156+ Reaction { key: String, shortcode: Option<String> },
144157
145158 /// Redacting (removing) the related event.
146159 Redaction,
@@ -221,6 +234,22 @@ impl TimelineAction {
221234 Self::AddItem { content }
222235 }
223236
237+ /// If this is a reaction, attach the shortcode computed by `shortcode`,
238+ /// that [`TimelineAction::from_content`] can't see in typed content.
239+ pub(super) fn with_reaction_shortcode(
240+ mut self,
241+ shortcode: impl FnOnce() -> Option<String>,
242+ ) -> Self {
243+ if let Self::HandleAggregation {
244+ kind: HandleAggregationKind::Reaction { shortcode: slot, .. },
245+ ..
246+ } = &mut self
247+ {
248+ *slot = shortcode();
249+ }
250+ self
251+ }
252+
224253 /// Create a new [`TimelineAction`] from a given remote event.
225254 ///
226255 /// The return value may be `None` if the event was a redacted reaction.
@@ -298,6 +327,13 @@ impl TimelineAction {
298327
299328 Some(content) => {
300329 Self::from_content(content, in_reply_to, thread_root, thread_summary)
330+ .with_reaction_shortcode(|| {
331+ raw_event
332+ .get_field::<Raw<AnyMessageLikeEventContent>>("content")
333+ .ok()
334+ .flatten()
335+ .and_then(|content| reaction_shortcode(&content))
336+ })
301337 }
302338
303339 None => Self::add_item(redacted_message_or_none(ev.event_type())?),
@@ -382,7 +418,10 @@ impl TimelineAction {
382418 // This is a reaction to a message.
383419 Self::HandleAggregation {
384420 related_event: c.relates_to.event_id.clone(),
385- kind: HandleAggregationKind::Reaction { key: c.relates_to.key },
421+ kind: HandleAggregationKind::Reaction {
422+ key: c.relates_to.key,
423+ shortcode: None,
424+ },
386425 }
387426 }
388427
@@ -626,8 +665,8 @@ impl<'a, 'o> TimelineEventHandler<'a, 'o> {
626665 }
627666
628667 TimelineAction::HandleAggregation { related_event, kind } => match kind {
629- HandleAggregationKind::Reaction { key } => {
630- self.handle_reaction(related_event, key);
668+ HandleAggregationKind::Reaction { key, shortcode } => {
669+ self.handle_reaction(related_event, key, shortcode);
631670 }
632671 HandleAggregationKind::Redaction => {
633672 self.handle_redaction(related_event);
@@ -715,7 +754,12 @@ impl<'a, 'o> TimelineEventHandler<'a, 'o> {
715754 /// Reactions to local events are applied in
716755 /// [`crate::timeline::TimelineController::handle_local_echo`].
717756 #[instrument(skip(self))]
718- fn handle_reaction(&mut self, relates_to: OwnedEventId, reaction_key: String) {
757+ fn handle_reaction(
758+ &mut self,
759+ relates_to: OwnedEventId,
760+ reaction_key: String,
761+ shortcode: Option<String>,
762+ ) {
719763 let target = TimelineEventItemId::EventId(relates_to);
720764
721765 // Add the aggregation to the manager.
@@ -734,6 +778,7 @@ impl<'a, 'o> TimelineEventHandler<'a, 'o> {
734778 self.ctx.flow.timeline_item_id(),
735779 AggregationKind::Reaction {
736780 key: reaction_key,
781+ shortcode,
737782 sender: self.ctx.sender.clone(),
738783 timestamp: self.ctx.timestamp,
739784 reaction_status,
Modified file: crates/matrix-sdk-ui/src/timeline/event_item/mod.rs+4 additions
@@ -799,6 +799,10 @@ pub struct ReactionInfo {
799799 pub timestamp: MilliSecondsSinceUnixEpoch,
800800 /// Current status of this reaction.
801801 pub status: ReactionStatus,
802+ /// The shortcode of the custom emote used as the key, as per [MSC4027].
803+ ///
804+ /// [MSC4027]: https://github.com/matrix-org/matrix-spec-proposals/pull/4027
805+ pub shortcode: Option<String>,
802806 }
803807
804808 /// Reactions grouped by key first, then by sender.
Modified file: crates/matrix-sdk-ui/src/timeline/mod.rs+19 additions−1 deletions
@@ -556,7 +556,25 @@ impl Timeline {
556556 item_id: &TimelineEventItemId,
557557 reaction_key: &str,
558558 ) -> Result<bool, Error> {
559- self.controller.toggle_reaction_local(item_id, reaction_key).await
559+ self.toggle_reaction_with_shortcode(item_id, reaction_key, None).await
560+ }
561+
562+ /// Toggle a reaction on an event, like [`Self::toggle_reaction`].
563+ ///
564+ /// For a custom emote reaction, `reaction_key` is the emote's MXC URI and
565+ /// `shortcode` its shortcode, sent along as per [MSC4027]. The shortcode
566+ /// is ignored when the reaction is removed.
567+ ///
568+ /// Returns `true` if the reaction was added, `false` if it was removed.
569+ ///
570+ /// [MSC4027]: https://github.com/matrix-org/matrix-spec-proposals/pull/4027
571+ pub async fn toggle_reaction_with_shortcode(
572+ &self,
573+ item_id: &TimelineEventItemId,
574+ reaction_key: &str,
575+ shortcode: Option<&str>,
576+ ) -> Result<bool, Error> {
577+ self.controller.toggle_reaction_local(item_id, reaction_key, shortcode).await
560578 }
561579
562580 /// Sends an attachment to the room.
Modified file: crates/matrix-sdk-ui/src/timeline/tests/mod.rs+14 additions−2 deletions
@@ -169,7 +169,7 @@ impl TestTimeline {
169169
170170 async fn handle_local_event(&self, content: AnyMessageLikeEventContent) -> OwnedTransactionId {
171171 let txn_id = TransactionId::new();
172- self.controller.handle_local_event(txn_id.clone(), content, None).await;
172+ self.controller.handle_local_event(txn_id.clone(), content, None, None).await;
173173 txn_id
174174 }
175175
@@ -206,7 +206,7 @@ impl TestTimeline {
206206 item_id: &TimelineEventItemId,
207207 key: &str,
208208 ) -> Result<(), super::Error> {
209- if self.controller.toggle_reaction_local(item_id, key).await? {
209+ if self.controller.toggle_reaction_local(item_id, key, None).await? {
210210 let items = self.controller.items().await;
211211 if let Some(event_id) = rfind_event_by_item_id(&items, item_id)
212212 .and_then(|(_pos, item)| item.event_id().map(ToOwned::to_owned))
@@ -255,6 +255,9 @@ struct TestRoomDataProvider {
255255 /// Events sent with that room data provider.
256256 pub sent_events: Arc<RwLock<Vec<AnyMessageLikeEventContent>>>,
257257
258+ /// Raw events sent with that room data provider.
259+ pub sent_raw_events: Arc<RwLock<Vec<Raw<AnyMessageLikeEventContent>>>>,
260+
258261 /// Events redacted with that room data providier.
259262 pub redacted: Arc<RwLock<Vec<OwnedEventId>>>,
260263 }
@@ -368,6 +371,15 @@ impl RoomDataProvider for TestRoomDataProvider {
368371 Ok(())
369372 }
370373
374+ async fn send_raw(
375+ &self,
376+ content: Raw<AnyMessageLikeEventContent>,
377+ _event_type: String,
378+ ) -> Result<(), super::Error> {
379+ self.sent_raw_events.write().await.push(content);
380+ Ok(())
381+ }
382+
371383 async fn redact<'a>(
372384 &'a self,
373385 event_id: &'a EventId,
Modified file: crates/matrix-sdk-ui/src/timeline/tests/reactions.rs+53 additions−2 deletions
@@ -19,11 +19,13 @@ use eyeball_im::VectorDiff;
1919 use futures_core::Stream;
2020 use futures_util::{FutureExt as _, StreamExt as _};
2121 use imbl::vector;
22-use matrix_sdk::assert_next_matches_with_timeout;
22+use matrix_sdk::{assert_next_matches_with_timeout, deserialized_responses::TimelineEvent};
2323 use matrix_sdk_test::{ALICE, BOB, async_test};
2424 use ruma::{
2525 EventId, MilliSecondsSinceUnixEpoch, OwnedEventId, event_id,
26- events::AnyMessageLikeEventContent, server_name, uint,
26+ events::{AnyMessageLikeEventContent, reaction::ReactionEventContent},
27+ serde::Raw,
28+ server_name, uint,
2729 };
2830 use stream_assert::{assert_next_matches, assert_pending};
2931 use tokio::time::timeout;
@@ -125,6 +127,55 @@ async fn test_add_reaction_success() {
125127 }
126128
127129 #[async_test]
130+async fn test_add_reaction_with_shortcode_sends_it() {
131+ let timeline = TestTimeline::new();
132+ let mut stream = timeline.subscribe().await;
133+ let (item_id, _, _) = send_first_message(&timeline, &mut stream).await;
134+
135+ timeline
136+ .controller
137+ .toggle_reaction_local(&item_id, "mxc://example.org/blobcat", Some("blobcat"))
138+ .await
139+ .unwrap();
140+
141+ assert!(timeline.data().sent_events.read().await.is_empty());
142+ let sent_raw_events = &timeline.data().sent_raw_events.read().await;
143+ assert_eq!(sent_raw_events.len(), 1);
144+ let reaction = sent_raw_events[0].deserialize_as_unchecked::<ReactionEventContent>().unwrap();
145+ assert_eq!(reaction.relates_to.key, "mxc://example.org/blobcat");
146+ assert_eq!(
147+ sent_raw_events[0].get_field::<String>("com.beeper.reaction.shortcode").unwrap().as_deref(),
148+ Some("blobcat")
149+ );
150+}
151+
152+#[async_test]
153+async fn test_remote_reaction_shortcode_is_surfaced() {
154+ let timeline = TestTimeline::new();
155+ let mut stream = timeline.subscribe().await;
156+ let (_, event_id, item_pos) = send_first_message(&timeline, &mut stream).await;
157+
158+ let mut reaction = timeline
159+ .factory
160+ .reaction(&event_id, "mxc://example.org/blobcat")
161+ .sender(*ALICE)
162+ .into_raw_sync()
163+ .deserialize_as_unchecked::<serde_json::Value>()
164+ .unwrap();
165+ reaction["content"]["com.beeper.reaction.shortcode"] = "blobcat".into();
166+ timeline
167+ .handle_live_event(TimelineEvent::from_plaintext(
168+ Raw::new(&reaction).unwrap().cast_unchecked(),
169+ ))
170+ .await;
171+
172+ let event = assert_item_update!(stream, &event_id, item_pos);
173+ let reactions = event.content().reactions().cloned().unwrap_or_default();
174+ let reaction = reactions.get("mxc://example.org/blobcat").unwrap().get(*ALICE).unwrap();
175+ assert_eq!(reaction.shortcode.as_deref(), Some("blobcat"));
176+}
177+
178+#[async_test]
128179 async fn test_redact_reaction_success() {
129180 let timeline = TestTimeline::new();
130181 let f = &timeline.factory;
Modified file: crates/matrix-sdk-ui/src/timeline/traits.rs+17 additions
@@ -30,6 +30,7 @@ use ruma::{
3030 receipt::{Receipt, ReceiptThread, ReceiptType},
3131 },
3232 room_version_rules::RoomVersionRules,
33+ serde::Raw,
3334 };
3435 use tracing::error;
3536
@@ -133,6 +134,13 @@ pub(super) trait RoomDataProvider:
133134 content: AnyMessageLikeEventContent,
134135 ) -> impl Future<Output = Result<(), super::Error>> + SendOutsideWasm + '_;
135136
137+ /// Send an event to that room, from its raw content.
138+ fn send_raw(
139+ &self,
140+ content: Raw<AnyMessageLikeEventContent>,
141+ event_type: String,
142+ ) -> impl Future<Output = Result<(), super::Error>> + SendOutsideWasm + '_;
143+
136144 /// Redact an event from that room.
137145 fn redact<'a>(
138146 &'a self,
@@ -223,6 +231,15 @@ impl RoomDataProvider for Room {
223231 Ok(())
224232 }
225233
234+ async fn send_raw(
235+ &self,
236+ content: Raw<AnyMessageLikeEventContent>,
237+ event_type: String,
238+ ) -> Result<(), super::Error> {
239+ let _ = self.send_queue().send_raw(content, event_type).await?;
240+ Ok(())
241+ }
242+
226243 async fn redact<'a>(
227244 &'a self,
228245 event_id: &'a EventId,
Modified file: crates/matrix-sdk/src/latest_events/latest_event/builder.rs+1 additions
@@ -2855,6 +2855,7 @@ mod builder_tests {
28552855 let transaction_id = OwnedTransactionId::from("txnid1");
28562856 let content = LocalEchoContent::React {
28572857 key: "<< 1".to_owned(),
2858+ shortcode: None,
28582859 send_handle: SendReactionHandle::new(
28592860 room_send_queue.clone(),
28602861 ChildTransactionId::new(),
Modified file: crates/matrix-sdk/src/send_queue/mod.rs+52 additions−8 deletions
@@ -1940,6 +1940,7 @@ impl QueueStorage {
19401940 &self,
19411941 transaction_id: &TransactionId,
19421942 key: String,
1943+ shortcode: Option<String>,
19431944 created_at: MilliSecondsSinceUnixEpoch,
19441945 ) -> Result<Option<ChildTransactionId>, RoomSendQueueStorageError> {
19451946 let guard = self.store.lock().await;
@@ -1971,7 +1972,7 @@ impl QueueStorage {
19711972 transaction_id,
19721973 reaction_txn_id.clone(),
19731974 created_at,
1974- DependentQueuedRequestKind::ReactEvent { key },
1975+ DependentQueuedRequestKind::ReactEvent { key, shortcode },
19751976 )
19761977 .await?;
19771978
@@ -2036,10 +2037,11 @@ impl QueueStorage {
20362037 None
20372038 }
20382039
2039- DependentQueuedRequestKind::ReactEvent { key } => Some(LocalEcho {
2040+ DependentQueuedRequestKind::ReactEvent { key, shortcode } => Some(LocalEcho {
20402041 transaction_id: dep.own_transaction_id.clone().into(),
20412042 content: LocalEchoContent::React {
20422043 key,
2044+ shortcode,
20432045 send_handle: SendReactionHandle {
20442046 room: room.clone(),
20452047 transaction_id: dep.own_transaction_id,
@@ -2271,7 +2273,7 @@ impl QueueStorage {
22712273 }
22722274 }
22732275
2274- DependentQueuedRequestKind::ReactEvent { key } => {
2276+ DependentQueuedRequestKind::ReactEvent { key, shortcode } => {
22752277 if let Some(parent_key) = parent_key {
22762278 let Some(parent_event_id) = parent_key.into_event_id() else {
22772279 return Err(RoomSendQueueError::StorageError(
@@ -2282,9 +2284,14 @@ impl QueueStorage {
22822284 // Queue the reaction event in the send queue 🧠.
22832285 let react_event =
22842286 ReactionEventContent::new(Annotation::new(parent_event_id, key)).into();
2287+ let mut raw = Raw::new(&react_event)
2288+ .map_err(RoomSendQueueStorageError::JsonSerialization)?;
2289+ if let Some(shortcode) = shortcode {
2290+ raw = with_reaction_shortcode(raw, shortcode)
2291+ .map_err(RoomSendQueueStorageError::JsonSerialization)?;
2292+ }
22852293 let serializable = SerializableEventContent::from_raw(
2286- Raw::new(&react_event)
2287- .map_err(RoomSendQueueStorageError::JsonSerialization)?,
2294+ raw,
22882295 react_event.event_type().to_string(),
22892296 );
22902297
@@ -2485,6 +2492,11 @@ pub enum LocalEchoContent {
24852492 React {
24862493 /// The key with which the local echo has been reacted to.
24872494 key: String,
2495+ /// The shortcode of the custom emote used as the key, as per
2496+ /// [MSC4027].
2497+ ///
2498+ /// [MSC4027]: https://github.com/matrix-org/matrix-spec-proposals/pull/4027
2499+ shortcode: Option<String>,
24882500 /// A handle to manipulate the sending of the reaction.
24892501 send_handle: SendReactionHandle,
24902502 /// The local echo which has been reacted to.
@@ -2903,11 +2915,28 @@ impl SendHandle {
29032915 &self,
29042916 key: String,
29052917 ) -> Result<Option<SendReactionHandle>, RoomSendQueueStorageError> {
2918+ self.react_with_shortcode(key, None).await
2919+ }
2920+
2921+ /// Like [`Self::react`], for a custom emote reaction whose key is the
2922+ /// emote's MXC URI, carrying its shortcode as per [MSC4027].
2923+ ///
2924+ /// [MSC4027]: https://github.com/matrix-org/matrix-spec-proposals/pull/4027
2925+ #[instrument(skip(self), fields(room_id = %self.room.inner.room.room_id(), txn_id = %self.transaction_id))]
2926+ pub async fn react_with_shortcode(
2927+ &self,
2928+ key: String,
2929+ shortcode: Option<String>,
2930+ ) -> Result<Option<SendReactionHandle>, RoomSendQueueStorageError> {
29062931 trace!("received an intent to react");
29072932
29082933 let created_at = MilliSecondsSinceUnixEpoch::now();
2909- if let Some(reaction_txn_id) =
2910- self.room.inner.queue.react(&self.transaction_id, key.clone(), created_at).await?
2934+ if let Some(reaction_txn_id) = self
2935+ .room
2936+ .inner
2937+ .queue
2938+ .react(&self.transaction_id, key.clone(), shortcode.clone(), created_at)
2939+ .await?
29112940 {
29122941 trace!("successfully queued react");
29132942
@@ -2926,6 +2955,7 @@ impl SendHandle {
29262955 transaction_id: reaction_txn_id.into(),
29272956 content: LocalEchoContent::React {
29282957 key,
2958+ shortcode,
29292959 send_handle: send_handle.clone(),
29302960 applies_to: self.transaction_id.clone(),
29312961 },
@@ -3086,6 +3116,20 @@ fn canonicalize_dependent_requests(
30863116 by_txn.into_values().flat_map(|entries| entries.into_iter().cloned()).collect()
30873117 }
30883118
3119+/// Adds the [MSC4027] shortcode of a custom emote to a reaction's content,
3120+/// which ruma's [`ReactionEventContent`] has no field for.
3121+///
3122+/// [MSC4027]: https://github.com/matrix-org/matrix-spec-proposals/pull/4027
3123+fn with_reaction_shortcode(
3124+ content: Raw<AnyMessageLikeEventContent>,
3125+ shortcode: String,
3126+) -> Result<Raw<AnyMessageLikeEventContent>, serde_json::Error> {
3127+ let mut content =
3128+ content.deserialize_as_unchecked::<serde_json::Map<String, serde_json::Value>>()?;
3129+ content.insert("com.beeper.reaction.shortcode".to_owned(), shortcode.into());
3130+ Ok(Raw::new(&content)?.cast_unchecked())
3131+}
3132+
30893133 #[cfg(all(test, not(target_family = "wasm")))]
30903134 mod tests {
30913135 use std::{sync::Arc, time::Duration};
@@ -3344,7 +3388,7 @@ mod tests {
33443388 let react_id = ChildTransactionId::new();
33453389 let react = DependentQueuedRequest {
33463390 own_transaction_id: react_id.clone(),
3347- kind: DependentQueuedRequestKind::ReactEvent { key: "🧠".to_owned() },
3391+ kind: DependentQueuedRequestKind::ReactEvent { key: "🧠".to_owned(), shortcode: None },
33483392 parent_transaction_id: txn.clone(),
33493393 parent_key: None,
33503394 created_at: MilliSecondsSinceUnixEpoch::now(),
Modified file: crates/matrix-sdk/tests/integration/send_queue.rs+1 additions
@@ -232,6 +232,7 @@ macro_rules! assert_update {
232232 key,
233233 applies_to,
234234 send_handle: _,
235+ ..
235236 },
236237 transaction_id: txn,
237238 }))) = timeout(Duration::from_secs(1), $watch.recv()).await