deltachat/
reaction.rs

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