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 Reaction {
40 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 pub fn is_empty(&self) -> bool {
64 self.reaction.is_empty()
65 }
66
67 pub fn as_str(&self) -> &str {
69 &self.reaction
70 }
71}
72
73#[derive(Debug)]
75pub struct Reactions {
76 reactions: BTreeMap<ContactId, Reaction>,
78}
79
80impl Reactions {
81 pub fn contacts(&self) -> Vec<ContactId> {
83 self.reactions.keys().copied().collect()
84 }
85
86 pub fn get(&self, contact_id: ContactId) -> Reaction {
91 self.reactions.get(&contact_id).cloned().unwrap_or_default()
92 }
93
94 pub fn is_empty(&self) -> bool {
96 self.reactions.is_empty()
97 }
98
99 #[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 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 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 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
202pub 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 let reaction_msg_id = send_msg(context, chat_id, &mut reaction_msg).await?;
218
219 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
232pub(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
270pub 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 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 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 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 assert!(Reaction::new("").is_empty());
366
367 assert_eq!(Reaction::new(":deltacat:").as_str(), ":deltacat:");
370
371 assert!(
373 Reaction::new(":foobarbazquuxaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:").is_empty()
374 );
375
376 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 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()); 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 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 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 .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 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 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 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 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 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 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()); assert_eq!(summary.state, MessageState::InFresh); assert!(summary.prefix.is_none());
758 assert!(summary.thumbnail_path.is_none());
759 assert_summary(&alice, "BOB reacted ๐ to \"Party?\"").await;
760
761 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 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 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 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 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?; assert_summary(&alice, "kewl").await;
805 housekeeping(&alice).await?; 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 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 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 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; 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()); 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 #[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 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 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 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}