Skip to main content

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