deltachat/
reaction.rs

1//! # Reactions.
2//!
3//! Reactions are short messages representing an emoji sent in reply to
4//! messages. Unlike normal messages which are added to the end of the chat,
5//! reactions are supposed to be displayed near the original messages.
6//!
7//! RFC 9078 specifies how reactions are transmitted in MIME messages.
8//!
9//! Reaction update semantics is not well-defined in RFC 9078, so
10//! Delta Chat uses the same semantics as in
11//! [XEP-0444](https://xmpp.org/extensions/xep-0444.html) section
12//! "3.2 Updating reactions to a message". Received reactions override
13//! all previously received reactions from the same user and it is
14//! possible to remove the reaction by sending an empty string as a reaction,
15//! even though RFC 9078 requires at least one emoji to be sent.
16
17use std::cmp::Ordering;
18use std::collections::BTreeMap;
19use std::fmt;
20
21use anyhow::Result;
22use serde::{Deserialize, Serialize};
23
24use crate::chat::{Chat, ChatId, send_msg};
25use crate::chatlist_events;
26use crate::contact::ContactId;
27use crate::context::Context;
28use crate::events::EventType;
29use crate::message::{Message, MsgId, rfc724_mid_exists};
30use crate::param::Param;
31
32/// A single reaction.
33#[derive(Debug, Default, Clone, Deserialize, Eq, PartialEq, Serialize)]
34pub struct Reaction {
35    /// Canonical representation of reaction as a string of space-separated emojis.
36    reaction: String,
37}
38
39// We implement From<&str> instead of std::str::FromStr, because
40// FromStr requires error type and reaction parsing never returns an
41// error.
42impl From<&str> for Reaction {
43    /// Convert a `&str` into a `Reaction`.
44    /// Everything after the first whitespace is ignored.
45    ///
46    /// Any short enough string is accepted as a reaction to avoid the
47    /// complexity of validating emoji sequences as required by RFC
48    /// 9078. On the sender side UI is responsible to provide only
49    /// valid emoji sequences via reaction picker. On the receiver
50    /// side, abuse of the possibility to use arbitrary strings as
51    /// reactions is not different from other kinds of spam attacks
52    /// such as sending large numbers of large messages, and should be
53    /// dealt with the same way, e.g. by blocking the user.
54    fn from(reaction: &str) -> Self {
55        let reaction: &str = reaction
56            .split_ascii_whitespace()
57            .next()
58            .filter(|&emoji| emoji.len() < 30)
59            .unwrap_or("");
60        Self {
61            reaction: reaction.to_string(),
62        }
63    }
64}
65
66impl Reaction {
67    /// Returns true if reaction contains no emoji.
68    pub fn is_empty(&self) -> bool {
69        self.reaction.is_empty()
70    }
71
72    /// Returns a string representing the emoji.
73    pub fn as_str(&self) -> &str {
74        &self.reaction
75    }
76}
77
78/// Structure representing all reactions to a particular message.
79#[derive(Debug)]
80pub struct Reactions {
81    /// Map from a contact to its reaction to message.
82    reactions: BTreeMap<ContactId, Reaction>,
83}
84
85impl Reactions {
86    /// Returns vector of contacts that reacted to the message.
87    pub fn contacts(&self) -> Vec<ContactId> {
88        self.reactions.keys().copied().collect()
89    }
90
91    /// Returns reaction of a given contact to message.
92    ///
93    /// If contact did not react to message or removed the reaction,
94    /// this method returns an empty reaction.
95    pub fn get(&self, contact_id: ContactId) -> Reaction {
96        self.reactions.get(&contact_id).cloned().unwrap_or_default()
97    }
98
99    /// Returns true if the message has no reactions.
100    pub fn is_empty(&self) -> bool {
101        self.reactions.is_empty()
102    }
103
104    /// Returns a map from emojis to their frequencies.
105    #[expect(clippy::arithmetic_side_effects)]
106    pub fn emoji_frequencies(&self) -> BTreeMap<String, usize> {
107        let mut emoji_frequencies: BTreeMap<String, usize> = BTreeMap::new();
108        for reaction in self.reactions.values() {
109            emoji_frequencies
110                .entry(reaction.as_str().to_string())
111                .and_modify(|x| *x += 1)
112                .or_insert(1);
113        }
114        emoji_frequencies
115    }
116
117    /// Returns a vector of emojis
118    /// sorted in descending order of frequencies.
119    ///
120    /// This function can be used to display the reactions in
121    /// the message bubble in the UIs.
122    pub fn emoji_sorted_by_frequency(&self) -> Vec<(String, usize)> {
123        let mut emoji_frequencies: Vec<(String, usize)> =
124            self.emoji_frequencies().into_iter().collect();
125        emoji_frequencies.sort_by(|(a, a_count), (b, b_count)| {
126            match a_count.cmp(b_count).reverse() {
127                Ordering::Equal => a.cmp(b),
128                other => other,
129            }
130        });
131        emoji_frequencies
132    }
133
134    /// Returns an iterator of the contacts that reacted and their corresponding reactions.
135    pub fn iter(&self) -> impl Iterator<Item = (&ContactId, &Reaction)> {
136        self.reactions.iter()
137    }
138}
139
140impl fmt::Display for Reactions {
141    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
142        let emoji_frequencies = self.emoji_sorted_by_frequency();
143        let mut first = true;
144        for (emoji, frequency) in emoji_frequencies {
145            if !first {
146                write!(f, " ")?;
147            }
148            first = false;
149            write!(f, "{emoji}{frequency}")?;
150        }
151        Ok(())
152    }
153}
154
155async fn set_msg_id_reaction(
156    context: &Context,
157    msg_id: MsgId,
158    chat_id: ChatId,
159    contact_id: ContactId,
160    timestamp: i64,
161    reaction: &Reaction,
162) -> Result<()> {
163    if reaction.is_empty() {
164        // Simply remove the record instead of setting it to empty string.
165        context
166            .sql
167            .execute(
168                "DELETE FROM reactions
169                 WHERE msg_id = ?1
170                 AND contact_id = ?2",
171                (msg_id, contact_id),
172            )
173            .await?;
174    } else {
175        context
176            .sql
177            .execute(
178                "INSERT INTO reactions (msg_id, contact_id, reaction)
179                 VALUES (?1, ?2, ?3)
180                 ON CONFLICT(msg_id, contact_id)
181                 DO UPDATE SET reaction=excluded.reaction",
182                (msg_id, contact_id, reaction.as_str()),
183            )
184            .await?;
185        let mut chat = Chat::load_from_db(context, chat_id).await?;
186        if chat
187            .param
188            .update_timestamp(Param::LastReactionTimestamp, timestamp)?
189        {
190            chat.param
191                .set_i64(Param::LastReactionMsgId, i64::from(msg_id.to_u32()));
192            chat.param
193                .set_i64(Param::LastReactionContactId, i64::from(contact_id.to_u32()));
194            chat.update_param(context).await?;
195        }
196    }
197
198    context.emit_event(EventType::ReactionsChanged {
199        chat_id,
200        msg_id,
201        contact_id,
202    });
203    chatlist_events::emit_chatlist_item_changed(context, chat_id);
204    Ok(())
205}
206
207/// Sends a reaction to message `msg_id`, overriding previously sent reactions.
208///
209/// `reaction` is a string consisting of a single emoji. Use
210/// empty string to retract a reaction.
211pub async fn send_reaction(context: &Context, msg_id: MsgId, reaction: &str) -> Result<MsgId> {
212    let msg = Message::load_from_db(context, msg_id).await?;
213    let chat_id = msg.chat_id;
214
215    let reaction: Reaction = reaction.into();
216    let mut reaction_msg = Message::new_text(reaction.as_str().to_string());
217    reaction_msg.set_reaction();
218    reaction_msg.in_reply_to = Some(msg.rfc724_mid);
219    reaction_msg.hidden = true;
220
221    // Send message first.
222    let reaction_msg_id = send_msg(context, chat_id, &mut reaction_msg).await?;
223
224    // Only set reaction if we successfully sent the message.
225    set_msg_id_reaction(
226        context,
227        msg_id,
228        msg.chat_id,
229        ContactId::SELF,
230        reaction_msg.timestamp_sort,
231        &reaction,
232    )
233    .await?;
234    Ok(reaction_msg_id)
235}
236
237/// Updates reaction of `contact_id` on the message with `in_reply_to`
238/// Message-ID. If no such message is found in the database, reaction
239/// is ignored.
240///
241/// `reaction` is string representing the emoji. It can be empty
242/// if contact wants to remove the reaction.
243pub(crate) async fn set_msg_reaction(
244    context: &Context,
245    in_reply_to: &str,
246    chat_id: ChatId,
247    contact_id: ContactId,
248    timestamp: i64,
249    reaction: Reaction,
250    is_incoming_fresh: bool,
251) -> Result<()> {
252    if let Some(msg_id) = rfc724_mid_exists(context, in_reply_to).await? {
253        set_msg_id_reaction(context, msg_id, chat_id, contact_id, timestamp, &reaction).await?;
254
255        if is_incoming_fresh
256            && !reaction.is_empty()
257            && msg_id.get_state(context).await?.is_outgoing()
258        {
259            context.emit_event(EventType::IncomingReaction {
260                chat_id,
261                contact_id,
262                msg_id,
263                reaction,
264            });
265        }
266    } else {
267        info!(
268            context,
269            "Can't assign reaction to unknown message with Message-ID {}", in_reply_to
270        );
271    }
272    Ok(())
273}
274
275/// Returns a structure containing all reactions to the message.
276pub async fn get_msg_reactions(context: &Context, msg_id: MsgId) -> Result<Reactions> {
277    let mut reactions: BTreeMap<ContactId, Reaction> = context
278        .sql
279        .query_map_collect(
280            "SELECT contact_id, reaction FROM reactions WHERE msg_id=?",
281            (msg_id,),
282            |row| {
283                let contact_id: ContactId = row.get(0)?;
284                let reaction: String = row.get(1)?;
285                Ok((contact_id, Reaction::from(reaction.as_str())))
286            },
287        )
288        .await?;
289    reactions.retain(|_contact, reaction| !reaction.is_empty());
290    Ok(Reactions { reactions })
291}
292
293impl Chat {
294    /// Check if there is a reaction newer than the given timestamp.
295    ///
296    /// If so, reaction details are returned and can be used to create a summary string.
297    pub async fn get_last_reaction_if_newer_than(
298        &self,
299        context: &Context,
300        timestamp: i64,
301    ) -> Result<Option<(Message, ContactId, String)>> {
302        if self
303            .param
304            .get_i64(Param::LastReactionTimestamp)
305            .is_none_or(|reaction_timestamp| reaction_timestamp <= timestamp)
306        {
307            return Ok(None);
308        };
309        let reaction_msg_id = MsgId::new(
310            self.param
311                .get_int(Param::LastReactionMsgId)
312                .unwrap_or_default() as u32,
313        );
314        let Some(reaction_msg) = Message::load_from_db_optional(context, reaction_msg_id).await?
315        else {
316            // The message reacted to may be deleted.
317            // These are no errors as `Param::LastReaction*` are just weak pointers.
318            // Instead, just return `Ok(None)` and let the caller create another summary.
319            return Ok(None);
320        };
321        let reaction_contact_id = ContactId::new(
322            self.param
323                .get_int(Param::LastReactionContactId)
324                .unwrap_or_default() as u32,
325        );
326        if let Some(reaction) = context
327            .sql
328            .query_get_value(
329                "SELECT reaction FROM reactions WHERE msg_id=? AND contact_id=?",
330                (reaction_msg.id, reaction_contact_id),
331            )
332            .await?
333        {
334            Ok(Some((reaction_msg, reaction_contact_id, reaction)))
335        } else {
336            Ok(None)
337        }
338    }
339}
340
341#[cfg(test)]
342mod tests {
343    use deltachat_contact_tools::ContactAddress;
344
345    use super::*;
346    use crate::chat::{forward_msgs, get_chat_msgs, marknoticed_chat, send_text_msg};
347    use crate::chatlist::Chatlist;
348    use crate::config::Config;
349    use crate::contact::{Contact, Origin};
350    use crate::key::{load_self_public_key, load_self_secret_key};
351    use crate::message::{MessageState, Viewtype, delete_msgs, markseen_msgs};
352    use crate::pgp::{SeipdVersion, pk_encrypt};
353    use crate::receive_imf::receive_imf;
354    use crate::sql::housekeeping;
355    use crate::test_utils::E2EE_INFO_MSGS;
356    use crate::test_utils::TestContext;
357    use crate::test_utils::TestContextManager;
358    use crate::tools::SystemTime;
359    use std::time::Duration;
360
361    #[test]
362    fn test_parse_reaction() {
363        // Check that basic set of emojis from RFC 9078 is supported.
364        assert_eq!(Reaction::from("๐Ÿ‘").as_str(), "๐Ÿ‘");
365        assert_eq!(Reaction::from("๐Ÿ‘Ž").as_str(), "๐Ÿ‘Ž");
366        assert_eq!(Reaction::from("๐Ÿ˜€").as_str(), "๐Ÿ˜€");
367        assert_eq!(Reaction::from("โ˜น").as_str(), "โ˜น");
368        assert_eq!(Reaction::from("๐Ÿ˜ข").as_str(), "๐Ÿ˜ข");
369
370        // Empty string can be used to remove all reactions.
371        assert!(Reaction::from("").is_empty());
372
373        // Short strings can be used as emojis, could be used to add
374        // support for custom emojis via emoji shortcodes.
375        assert_eq!(Reaction::from(":deltacat:").as_str(), ":deltacat:");
376
377        // Check that long strings are not valid emojis.
378        assert!(
379            Reaction::from(":foobarbazquuxaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:").is_empty()
380        );
381
382        // Multiple reactions separated by spaces or tabs are not supported.
383        assert_eq!(Reaction::from("๐Ÿ‘ โค").as_str(), "๐Ÿ‘");
384        assert_eq!(Reaction::from("๐Ÿ‘\tโค").as_str(), "๐Ÿ‘");
385
386        assert_eq!(Reaction::from("๐Ÿ‘\t:foo: โค").as_str(), "๐Ÿ‘");
387        assert_eq!(Reaction::from("๐Ÿ‘\t:foo: โค").as_str(), "๐Ÿ‘");
388
389        assert_eq!(Reaction::from("๐Ÿ‘ ๐Ÿ‘").as_str(), "๐Ÿ‘");
390    }
391
392    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
393    async fn test_receive_reaction() -> Result<()> {
394        let alice = TestContext::new_alice().await;
395
396        // Alice receives BCC-self copy of a message sent to Bob.
397        receive_imf(
398            &alice,
399            "To: bob@example.net\n\
400From: alice@example.org\n\
401Date: Today, 29 February 2021 00:00:00 -800\n\
402Message-ID: 12345@example.org\n\
403Subject: Meeting\n\
404\n\
405Can we chat at 1pm pacific, today?"
406                .as_bytes(),
407            false,
408        )
409        .await?;
410        let msg = alice.get_last_msg().await;
411        assert_eq!(msg.state, MessageState::OutDelivered);
412        let reactions = get_msg_reactions(&alice, msg.id).await?;
413        let contacts = reactions.contacts();
414        assert_eq!(contacts.len(), 0);
415
416        let bob_id = Contact::add_or_lookup(
417            &alice,
418            "",
419            &ContactAddress::new("bob@example.net")?,
420            Origin::ManuallyCreated,
421        )
422        .await?
423        .0;
424        let bob_reaction = reactions.get(bob_id);
425        assert!(bob_reaction.is_empty()); // Bob has not reacted to message yet.
426
427        // Alice receives reaction to her message from Bob.
428        receive_imf(
429            &alice,
430            "To: alice@example.org\n\
431From: bob@example.net\n\
432Date: Today, 29 February 2021 00:00:10 -800\n\
433Message-ID: 56789@example.net\n\
434In-Reply-To: 12345@example.org\n\
435Subject: Meeting\n\
436Mime-Version: 1.0 (1.0)\n\
437Content-Type: text/plain; charset=utf-8\n\
438Content-Disposition: reaction\n\
439\n\
440\u{1F44D}"
441                .as_bytes(),
442            false,
443        )
444        .await?;
445
446        let reactions = get_msg_reactions(&alice, msg.id).await?;
447        assert_eq!(reactions.to_string(), "๐Ÿ‘1");
448
449        let contacts = reactions.contacts();
450        assert_eq!(contacts.len(), 1);
451
452        assert_eq!(contacts.first(), Some(&bob_id));
453        let bob_reaction = reactions.get(bob_id);
454        assert_eq!(bob_reaction.is_empty(), false);
455        assert_eq!(bob_reaction.as_str(), "๐Ÿ‘");
456        assert_eq!(bob_reaction.as_str(), "๐Ÿ‘");
457
458        // Alice receives reaction to her message from Bob with a footer.
459        receive_imf(
460            &alice,
461            "To: alice@example.org\n\
462From: bob@example.net\n\
463Date: Today, 29 February 2021 00:00:10 -800\n\
464Message-ID: 56790@example.net\n\
465In-Reply-To: 12345@example.org\n\
466Subject: Meeting\n\
467Mime-Version: 1.0 (1.0)\n\
468Content-Type: text/plain; charset=utf-8\n\
469Content-Disposition: reaction\n\
470\n\
471๐Ÿ˜€\n\
472\n\
473--\n\
474_______________________________________________\n\
475Here's my footer -- bob@example.net"
476                .as_bytes(),
477            false,
478        )
479        .await?;
480
481        let reactions = get_msg_reactions(&alice, msg.id).await?;
482        assert_eq!(reactions.to_string(), "๐Ÿ˜€1");
483
484        // Alice receives a message with reaction to her message from Bob.
485        let msg_bob = receive_imf(
486            &alice,
487            "To: alice@example.org\n\
488From: bob@example.net\n\
489Date: Today, 29 February 2021 00:00:10 -800\n\
490Message-ID: 56791@example.net\n\
491In-Reply-To: 12345@example.org\n\
492Mime-Version: 1.0\n\
493Content-Type: multipart/mixed; boundary=\"YiEDa0DAkWCtVeE4\"\n\
494Content-Disposition: inline\n\
495\n\
496--YiEDa0DAkWCtVeE4\n\
497Content-Type: text/plain; charset=utf-8\n\
498Content-Disposition: inline\n\
499\n\
500Reply + reaction\n\
501\n\
502--YiEDa0DAkWCtVeE4\n\
503Content-Type: text/plain; charset=utf-8\n\
504Content-Disposition: reaction\n\
505\n\
506\u{1F44D}\n\
507\n\
508--YiEDa0DAkWCtVeE4--"
509                .as_bytes(),
510            false,
511        )
512        .await?
513        .unwrap();
514        let msg_bob = Message::load_from_db(&alice, msg_bob.msg_ids[0]).await?;
515        assert_eq!(msg_bob.from_id, bob_id);
516        assert_eq!(msg_bob.chat_id, msg.chat_id);
517        assert_eq!(msg_bob.viewtype, Viewtype::Text);
518        assert_eq!(msg_bob.state, MessageState::InFresh);
519        assert_eq!(msg_bob.hidden, false);
520        assert_eq!(msg_bob.text, "Reply + reaction");
521        let reactions = get_msg_reactions(&alice, msg.id).await?;
522        assert_eq!(reactions.to_string(), "๐Ÿ‘1");
523
524        Ok(())
525    }
526
527    async fn expect_reactions_changed_event(
528        t: &TestContext,
529        expected_chat_id: ChatId,
530        expected_msg_id: MsgId,
531        expected_contact_id: ContactId,
532    ) -> Result<()> {
533        let event = t
534            .evtracker
535            .get_matching(|evt| {
536                matches!(
537                    evt,
538                    EventType::ReactionsChanged { .. } | EventType::IncomingMsg { .. }
539                )
540            })
541            .await;
542        match event {
543            EventType::ReactionsChanged {
544                chat_id,
545                msg_id,
546                contact_id,
547            } => {
548                assert_eq!(chat_id, expected_chat_id);
549                assert_eq!(msg_id, expected_msg_id);
550                assert_eq!(contact_id, expected_contact_id);
551            }
552            _ => panic!("Unexpected event {event:?}."),
553        }
554        Ok(())
555    }
556
557    async fn expect_incoming_reactions_event(
558        t: &TestContext,
559        expected_chat_id: ChatId,
560        expected_msg_id: MsgId,
561        expected_contact_id: ContactId,
562        expected_reaction: &str,
563    ) -> Result<()> {
564        let event = t
565            .evtracker
566            // Check for absence of `IncomingMsg` events -- it appeared that it's quite easy to make
567            // bugs when `IncomingMsg` is issued for reactions.
568            .get_matching(|evt| {
569                matches!(
570                    evt,
571                    EventType::IncomingReaction { .. } | EventType::IncomingMsg { .. }
572                )
573            })
574            .await;
575        match event {
576            EventType::IncomingReaction {
577                chat_id,
578                msg_id,
579                contact_id,
580                reaction,
581            } => {
582                assert_eq!(chat_id, expected_chat_id);
583                assert_eq!(msg_id, expected_msg_id);
584                assert_eq!(contact_id, expected_contact_id);
585                assert_eq!(reaction, Reaction::from(expected_reaction));
586            }
587            _ => panic!("Unexpected event {event:?}."),
588        }
589        Ok(())
590    }
591
592    /// Checks that no unwanted events remain after expecting "wanted" reaction events.
593    async fn expect_no_unwanted_events(t: &TestContext) {
594        let ev = t
595            .evtracker
596            .get_matching_opt(t, |evt| {
597                matches!(
598                    evt,
599                    EventType::IncomingReaction { .. }
600                        | EventType::IncomingMsg { .. }
601                        | EventType::MsgsChanged { .. }
602                )
603            })
604            .await;
605        if let Some(ev) = ev {
606            panic!("Unwanted event {ev:?}.")
607        }
608    }
609
610    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
611    async fn test_send_reaction() -> Result<()> {
612        let alice = TestContext::new_alice().await;
613        let bob = TestContext::new_bob().await;
614
615        // Test that the status does not get mixed up into reactions.
616        alice
617            .set_config(
618                Config::Selfstatus,
619                Some("Buy Delta Chat today and make this banner go away!"),
620            )
621            .await?;
622        bob.set_config(Config::Selfstatus, Some("Sent from my Delta Chat Pro. ๐Ÿ‘"))
623            .await?;
624
625        let chat_alice = alice.create_chat(&bob).await;
626        let alice_msg = alice.send_text(chat_alice.id, "Hi!").await;
627        let bob_msg = bob.recv_msg(&alice_msg).await;
628        assert_eq!(
629            get_chat_msgs(&alice, chat_alice.id).await?.len(),
630            E2EE_INFO_MSGS + 1
631        );
632        assert_eq!(
633            get_chat_msgs(&bob, bob_msg.chat_id).await?.len(),
634            E2EE_INFO_MSGS + 1
635        );
636
637        let alice_msg2 = alice.send_text(chat_alice.id, "Hi again!").await;
638        bob.recv_msg(&alice_msg2).await;
639        assert_eq!(
640            get_chat_msgs(&alice, chat_alice.id).await?.len(),
641            E2EE_INFO_MSGS + 2
642        );
643        assert_eq!(
644            get_chat_msgs(&bob, bob_msg.chat_id).await?.len(),
645            E2EE_INFO_MSGS + 2
646        );
647
648        bob_msg.chat_id.accept(&bob).await?;
649
650        bob.evtracker.clear_events();
651        send_reaction(&bob, bob_msg.id, "๐Ÿ‘").await.unwrap();
652        expect_reactions_changed_event(&bob, bob_msg.chat_id, bob_msg.id, ContactId::SELF).await?;
653        expect_no_unwanted_events(&bob).await;
654        assert_eq!(
655            get_chat_msgs(&bob, bob_msg.chat_id).await?.len(),
656            E2EE_INFO_MSGS + 2
657        );
658
659        let bob_reaction_msg = bob.pop_sent_msg().await;
660        let alice_reaction_msg = alice.recv_msg_hidden(&bob_reaction_msg).await;
661        assert_eq!(alice_reaction_msg.state, MessageState::InFresh);
662        assert_eq!(
663            get_chat_msgs(&alice, chat_alice.id).await?.len(),
664            E2EE_INFO_MSGS + 2
665        );
666
667        let reactions = get_msg_reactions(&alice, alice_msg.sender_msg_id).await?;
668        assert_eq!(reactions.to_string(), "๐Ÿ‘1");
669        let contacts = reactions.contacts();
670        assert_eq!(contacts.len(), 1);
671        let bob_id = contacts.first().unwrap();
672        let bob_reaction = reactions.get(*bob_id);
673        assert_eq!(bob_reaction.is_empty(), false);
674        assert_eq!(bob_reaction.as_str(), "๐Ÿ‘");
675        assert_eq!(bob_reaction.as_str(), "๐Ÿ‘");
676        expect_reactions_changed_event(&alice, chat_alice.id, alice_msg.sender_msg_id, *bob_id)
677            .await?;
678        expect_incoming_reactions_event(
679            &alice,
680            chat_alice.id,
681            alice_msg.sender_msg_id,
682            *bob_id,
683            "๐Ÿ‘",
684        )
685        .await?;
686        expect_no_unwanted_events(&alice).await;
687
688        marknoticed_chat(&alice, chat_alice.id).await?;
689        assert_eq!(
690            alice_reaction_msg.id.get_state(&alice).await?,
691            MessageState::InSeen
692        );
693        // Reactions don't request MDNs, but an MDN to self is sent.
694        assert_eq!(
695            alice
696                .sql
697                .count("SELECT COUNT(*) FROM smtp_mdns", ())
698                .await?,
699            1
700        );
701        assert_eq!(
702            alice
703                .sql
704                .count(
705                    "SELECT COUNT(*) FROM smtp_mdns WHERE from_id=?",
706                    (ContactId::SELF,)
707                )
708                .await?,
709            1
710        );
711
712        // Alice reacts to own message.
713        // Trying to set multiple reactions at once is not allowed.
714        send_reaction(&alice, alice_msg.sender_msg_id, "๐Ÿ‘ ๐Ÿ˜€")
715            .await
716            .unwrap();
717        let reactions = get_msg_reactions(&alice, alice_msg.sender_msg_id).await?;
718        assert_eq!(reactions.to_string(), "๐Ÿ‘2");
719
720        assert_eq!(
721            reactions.emoji_sorted_by_frequency(),
722            vec![("๐Ÿ‘".to_string(), 2)]
723        );
724
725        Ok(())
726    }
727
728    async fn assert_summary(t: &TestContext, expected: &str) {
729        let chatlist = Chatlist::try_load(t, 0, None, None).await.unwrap();
730        let summary = chatlist.get_summary(t, 0, None).await.unwrap();
731        assert_eq!(summary.text, expected);
732    }
733
734    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
735    async fn test_reaction_summary() -> Result<()> {
736        let mut tcm = TestContextManager::new();
737        let alice = tcm.alice().await;
738        let bob = tcm.bob().await;
739        alice.set_config(Config::Displayname, Some("ALICE")).await?;
740        bob.set_config(Config::Displayname, Some("BOB")).await?;
741        let alice_bob_id = alice.add_or_lookup_contact_id(&bob).await;
742
743        // Alice sends message to Bob
744        let alice_chat = alice.create_chat(&bob).await;
745        let alice_msg1 = alice.send_text(alice_chat.id, "Party?").await;
746        let bob_msg1 = bob.recv_msg(&alice_msg1).await;
747
748        // Bob reacts to Alice's message, this is shown in the summaries
749        SystemTime::shift(Duration::from_secs(10));
750        bob_msg1.chat_id.accept(&bob).await?;
751        send_reaction(&bob, bob_msg1.id, "๐Ÿ‘").await?;
752        let bob_send_reaction = bob.pop_sent_msg().await;
753        alice.recv_msg_hidden(&bob_send_reaction).await;
754        expect_incoming_reactions_event(
755            &alice,
756            alice_chat.id,
757            alice_msg1.sender_msg_id,
758            alice_bob_id,
759            "๐Ÿ‘",
760        )
761        .await?;
762        expect_no_unwanted_events(&alice).await;
763
764        let chatlist = Chatlist::try_load(&bob, 0, None, None).await?;
765        let summary = chatlist.get_summary(&bob, 0, None).await?;
766        assert_eq!(summary.text, "You reacted ๐Ÿ‘ to \"Party?\"");
767        assert_eq!(summary.timestamp, bob_msg1.get_timestamp()); // time refers to message, not to reaction
768        assert_eq!(summary.state, MessageState::InFresh); // state refers to message, not to reaction
769        assert!(summary.prefix.is_none());
770        assert!(summary.thumbnail_path.is_none());
771        assert_summary(&alice, "BOB reacted ๐Ÿ‘ to \"Party?\"").await;
772
773        // Alice reacts to own message as well
774        SystemTime::shift(Duration::from_secs(10));
775        send_reaction(&alice, alice_msg1.sender_msg_id, "๐Ÿฟ").await?;
776        let alice_send_reaction = alice.pop_sent_msg().await;
777        bob.evtracker.clear_events();
778        bob.recv_msg_opt(&alice_send_reaction).await;
779        expect_no_unwanted_events(&bob).await;
780
781        assert_summary(&alice, "You reacted ๐Ÿฟ to \"Party?\"").await;
782        assert_summary(&bob, "ALICE reacted ๐Ÿฟ to \"Party?\"").await;
783
784        // Alice sends a newer message, this overwrites reaction summaries
785        SystemTime::shift(Duration::from_secs(10));
786        let alice_msg2 = alice.send_text(alice_chat.id, "kewl").await;
787        bob.recv_msg(&alice_msg2).await;
788
789        assert_summary(&alice, "kewl").await;
790        assert_summary(&bob, "kewl").await;
791
792        // Reactions to older messages still overwrite newer messages
793        SystemTime::shift(Duration::from_secs(10));
794        send_reaction(&alice, alice_msg1.sender_msg_id, "๐Ÿค˜").await?;
795        let alice_send_reaction = alice.pop_sent_msg().await;
796        bob.recv_msg_opt(&alice_send_reaction).await;
797
798        assert_summary(&alice, "You reacted ๐Ÿค˜ to \"Party?\"").await;
799        assert_summary(&bob, "ALICE reacted ๐Ÿค˜ to \"Party?\"").await;
800
801        // Retracted reactions remove all summary reactions
802        SystemTime::shift(Duration::from_secs(10));
803        send_reaction(&alice, alice_msg1.sender_msg_id, "").await?;
804        let alice_remove_reaction = alice.pop_sent_msg().await;
805        bob.recv_msg_opt(&alice_remove_reaction).await;
806
807        assert_summary(&alice, "kewl").await;
808        assert_summary(&bob, "kewl").await;
809
810        // Alice adds another reaction and then deletes the message reacted to; this will also delete reaction summary
811        SystemTime::shift(Duration::from_secs(10));
812        send_reaction(&alice, alice_msg1.sender_msg_id, "๐Ÿงน").await?;
813        assert_summary(&alice, "You reacted ๐Ÿงน to \"Party?\"").await;
814
815        delete_msgs(&alice, &[alice_msg1.sender_msg_id]).await?; // this will leave a tombstone
816        assert_summary(&alice, "kewl").await;
817        housekeeping(&alice).await?; // this will delete the tombstone
818        assert_summary(&alice, "kewl").await;
819
820        Ok(())
821    }
822
823    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
824    async fn test_reaction_forwarded_summary() -> Result<()> {
825        let alice = TestContext::new_alice().await;
826
827        // Alice adds a message to "Saved Messages"
828        let self_chat = alice.get_self_chat().await;
829        let msg_id = send_text_msg(&alice, self_chat.id, "foo".to_string()).await?;
830        assert_summary(&alice, "foo").await;
831
832        // Alice reacts to that message
833        SystemTime::shift(Duration::from_secs(10));
834        send_reaction(&alice, msg_id, "๐Ÿซ").await?;
835        assert_summary(&alice, "You reacted ๐Ÿซ to \"foo\"").await;
836        let reactions = get_msg_reactions(&alice, msg_id).await?;
837        assert_eq!(reactions.reactions.len(), 1);
838
839        // Alice forwards that message to Bob: Reactions are not forwarded, the message is prefixed by "Forwarded".
840        let bob_id = Contact::create(&alice, "", "bob@example.net").await?;
841        let bob_chat_id = ChatId::create_for_contact(&alice, bob_id).await?;
842        forward_msgs(&alice, &[msg_id], bob_chat_id).await?;
843        assert_summary(&alice, "Forwarded: foo").await; // forwarded messages are prefixed
844        let chatlist = Chatlist::try_load(&alice, 0, None, None).await.unwrap();
845        let forwarded_msg_id = chatlist.get_msg_id(0)?.unwrap();
846        let reactions = get_msg_reactions(&alice, forwarded_msg_id).await?;
847        assert!(reactions.reactions.is_empty()); // reactions are not forwarded
848
849        // Alice reacts to forwarded message:
850        // For reaction summary neither original message author nor "Forwarded" prefix is shown
851        SystemTime::shift(Duration::from_secs(10));
852        send_reaction(&alice, forwarded_msg_id, "๐Ÿณ").await?;
853        assert_summary(&alice, "You reacted ๐Ÿณ to \"foo\"").await;
854        let reactions = get_msg_reactions(&alice, msg_id).await?;
855        assert_eq!(reactions.reactions.len(), 1);
856
857        Ok(())
858    }
859
860    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
861    async fn test_reaction_self_chat_multidevice_summary() -> Result<()> {
862        let alice0 = TestContext::new_alice().await;
863        let alice1 = TestContext::new_alice().await;
864        let chat = alice0.get_self_chat().await;
865
866        let msg_id = send_text_msg(&alice0, chat.id, "mom's birthday!".to_string()).await?;
867        alice1.recv_msg(&alice0.pop_sent_msg().await).await;
868
869        SystemTime::shift(Duration::from_secs(10));
870        send_reaction(&alice0, msg_id, "๐Ÿ‘†").await?;
871        let sync = alice0.pop_sent_msg().await;
872        receive_imf(&alice1, sync.payload().as_bytes(), false).await?;
873
874        assert_summary(&alice0, "You reacted ๐Ÿ‘† to \"mom's birthday!\"").await;
875        assert_summary(&alice1, "You reacted ๐Ÿ‘† to \"mom's birthday!\"").await;
876
877        Ok(())
878    }
879
880    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
881    async fn test_send_reaction_multidevice() -> Result<()> {
882        let mut tcm = TestContextManager::new();
883        let alice0 = tcm.alice().await;
884        let alice1 = tcm.alice().await;
885        let bob = tcm.bob().await;
886        let chat_id = alice0.create_chat(&bob).await.id;
887
888        let alice0_msg_id = send_text_msg(&alice0, chat_id, "foo".to_string()).await?;
889        let alice1_msg = alice1.recv_msg(&alice0.pop_sent_msg().await).await;
890
891        send_reaction(&alice0, alice0_msg_id, "๐Ÿ‘€").await?;
892        alice1.recv_msg_hidden(&alice0.pop_sent_msg().await).await;
893
894        expect_reactions_changed_event(&alice0, chat_id, alice0_msg_id, ContactId::SELF).await?;
895        expect_reactions_changed_event(&alice1, alice1_msg.chat_id, alice1_msg.id, ContactId::SELF)
896            .await?;
897        for a in [&alice0, &alice1] {
898            expect_no_unwanted_events(a).await;
899        }
900        Ok(())
901    }
902
903    /// Tests that if reaction requests a read receipt,
904    /// no read receipt is sent when the chat is marked as noticed.
905    ///
906    /// Reactions create hidden messages in the chat,
907    /// and when marking the chat as noticed marks
908    /// such messages as seen, read receipts should never be sent
909    /// to avoid the sender of reaction from learning
910    /// that receiver opened the chat.
911    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
912    async fn test_reaction_request_mdn() -> Result<()> {
913        let mut tcm = TestContextManager::new();
914        let alice = &tcm.alice().await;
915        let bob = &tcm.bob().await;
916
917        let alice_chat_id = alice.create_chat_id(bob).await;
918        let alice_sent_msg = alice.send_text(alice_chat_id, "Hello!").await;
919
920        let bob_msg = bob.recv_msg(&alice_sent_msg).await;
921        bob_msg.chat_id.accept(bob).await?;
922        assert_eq!(bob_msg.state, MessageState::InFresh);
923        let bob_chat_id = bob_msg.chat_id;
924        bob_chat_id.accept(bob).await?;
925
926        markseen_msgs(bob, vec![bob_msg.id]).await?;
927        assert_eq!(
928            bob.sql
929                .count(
930                    "SELECT COUNT(*) FROM smtp_mdns WHERE from_id!=?",
931                    (ContactId::SELF,)
932                )
933                .await?,
934            1
935        );
936        bob.sql.execute("DELETE FROM smtp_mdns", ()).await?;
937
938        // Construct reaction with an MDN request.
939        // Note the `Chat-Disposition-Notification-To` header.
940        let known_id = bob_msg.rfc724_mid;
941        let new_id = "e2b6e69e-4124-4e2a-b79f-e4f1be667165@localhost";
942
943        let plain_text = format!(
944            "Content-Type: text/plain; charset=\"utf-8\"; protected-headers=\"v1\"; \r
945        hp=\"cipher\"\r
946Content-Disposition: reaction\r
947From: \"Alice\" <alice@example.org>\r
948To: \"Bob\" <bob@example.net>\r
949Subject: Message from Alice\r
950Date: Sat, 14 Mar 2026 01:02:03 +0000\r
951In-Reply-To: <{known_id}>\r
952References: <{known_id}>\r
953Chat-Version: 1.0\r
954Chat-Disposition-Notification-To: alice@example.org\r
955Message-ID: <{new_id}>\r
956HP-Outer: From: <alice@example.org>\r
957HP-Outer: To: \"hidden-recipients\": ;\r
958HP-Outer: Subject: [...]\r
959HP-Outer: Date: Sat, 14 Mar 2026 01:02:03 +0000\r
960HP-Outer: Message-ID: <{new_id}>\r
961HP-Outer: In-Reply-To: <{known_id}>\r
962HP-Outer: References: <{known_id}>\r
963HP-Outer: Chat-Version: 1.0\r
964Content-Transfer-Encoding: base64\r
965\r
9668J+RgA==\r
967"
968        );
969
970        let alice_public_key = load_self_public_key(alice).await?;
971        let bob_public_key = load_self_public_key(bob).await?;
972        let alice_secret_key = load_self_secret_key(alice).await?;
973        let public_keys_for_encryption = vec![alice_public_key, bob_public_key];
974        let compress = true;
975        let encrypted_payload = pk_encrypt(
976            plain_text.as_bytes().to_vec(),
977            public_keys_for_encryption,
978            alice_secret_key,
979            compress,
980            SeipdVersion::V2,
981        )
982        .await?;
983
984        let boundary = "boundary123";
985        let rcvd_mail = format!(
986            "From: <alice@example.org>\r
987To: \"hidden-recipients\": ;\r
988Subject: [...]\r
989Date: Sat, 14 Mar 2026 01:02:03 +0000\r
990Message-ID: <{new_id}>\r
991In-Reply-To: <{known_id}>\r
992References: <{known_id}>\r
993Content-Type: multipart/encrypted; protocol=\"application/pgp-encrypted\";\r
994        boundary=\"{boundary}\"\r
995MIME-Version: 1.0\r
996\r
997--{boundary}\r
998Content-Type: application/pgp-encrypted; charset=\"utf-8\"\r
999Content-Description: PGP/MIME version identification\r
1000Content-Transfer-Encoding: 7bit\r
1001\r
1002Version: 1\r
1003\r
1004--{boundary}\r
1005Content-Type: application/octet-stream; name=\"encrypted.asc\";\r
1006        charset=\"utf-8\"\r
1007Content-Description: OpenPGP encrypted message\r
1008Content-Disposition: inline; filename=\"encrypted.asc\";\r
1009Content-Transfer-Encoding: 7bit\r
1010\r
1011{encrypted_payload}
1012--{boundary}--\r
1013"
1014        );
1015
1016        let received = receive_imf(bob, rcvd_mail.as_bytes(), false)
1017            .await?
1018            .unwrap();
1019        let bob_hidden_msg = Message::load_from_db(bob, *received.msg_ids.last().unwrap())
1020            .await
1021            .unwrap();
1022        assert!(bob_hidden_msg.hidden);
1023        assert_eq!(bob_hidden_msg.chat_id, bob_chat_id);
1024
1025        // Bob does not see new message and cannot mark it as seen directly,
1026        // but can mark the chat as noticed when opening it.
1027        marknoticed_chat(bob, bob_chat_id).await?;
1028
1029        assert_eq!(
1030            bob.sql
1031                .count(
1032                    "SELECT COUNT(*) FROM smtp_mdns WHERE from_id!=?",
1033                    (ContactId::SELF,)
1034                )
1035                .await?,
1036            0,
1037            "Bob should not send MDN to Alice"
1038        );
1039
1040        // MDN request was ignored, but reaction was not.
1041        let reactions = get_msg_reactions(bob, bob_msg.id).await?;
1042        assert_eq!(reactions.reactions.len(), 1);
1043        assert_eq!(
1044            reactions.emoji_sorted_by_frequency(),
1045            vec![("๐Ÿ‘€".to_string(), 1)]
1046        );
1047
1048        Ok(())
1049    }
1050}