1use 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#[derive(Debug, Default, Clone, Deserialize, Eq, PartialEq, Serialize)]
34pub struct Reaction {
35 reaction: String,
37}
38
39impl From<&str> for Reaction {
43 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 pub fn is_empty(&self) -> bool {
69 self.reaction.is_empty()
70 }
71
72 pub fn as_str(&self) -> &str {
74 &self.reaction
75 }
76}
77
78#[derive(Debug)]
80pub struct Reactions {
81 reactions: BTreeMap<ContactId, Reaction>,
83}
84
85impl Reactions {
86 pub fn contacts(&self) -> Vec<ContactId> {
88 self.reactions.keys().copied().collect()
89 }
90
91 pub fn get(&self, contact_id: ContactId) -> Reaction {
96 self.reactions.get(&contact_id).cloned().unwrap_or_default()
97 }
98
99 pub fn is_empty(&self) -> bool {
101 self.reactions.is_empty()
102 }
103
104 #[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 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 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 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
207pub 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 let reaction_msg_id = send_msg(context, chat_id, &mut reaction_msg).await?;
223
224 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
237pub(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
275pub 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 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 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 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 assert!(Reaction::from("").is_empty());
372
373 assert_eq!(Reaction::from(":deltacat:").as_str(), ":deltacat:");
376
377 assert!(
379 Reaction::from(":foobarbazquuxaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:").is_empty()
380 );
381
382 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 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()); 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 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 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 .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 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 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 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 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 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 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()); assert_eq!(summary.state, MessageState::InFresh); assert!(summary.prefix.is_none());
770 assert!(summary.thumbnail_path.is_none());
771 assert_summary(&alice, "BOB reacted ๐ to \"Party?\"").await;
772
773 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 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 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 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 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?; assert_summary(&alice, "kewl").await;
817 housekeeping(&alice).await?; 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 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 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 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; 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()); 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 #[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 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 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 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}