diff --git a/deltachat-ffi/deltachat.h b/deltachat-ffi/deltachat.h index 5b4254b5c..339bc0f12 100644 --- a/deltachat-ffi/deltachat.h +++ b/deltachat-ffi/deltachat.h @@ -7296,6 +7296,22 @@ void dc_event_unref(dc_event_t* event); /// `%1$s` will be replaced by the provider's domain. #define DC_STR_INVALID_UNENCRYPTED_MAIL 174 +/// "You reacted %1$s to '%2$s'" +/// +/// `%1$s` will be replaced by the reaction, usually an emoji +/// `%2$s` will be replaced by the summary of the message the reaction refers to +/// +/// Used in summaries. +#define DC_STR_YOU_REACTED 176 + +/// "%1$s reacted %2$s to '%3$s'" +/// +/// `%1$s` will be replaced by the name the contact who reacted +/// `%2$s` will be replaced by the reaction, usually an emoji +/// `%3$s` will be replaced by the summary of the message the reaction refers to +/// +/// Used in summaries. +#define DC_STR_REACTED_BY 177 /** * @} diff --git a/src/chatlist.rs b/src/chatlist.rs index 99a50a33b..256bf4aef 100644 --- a/src/chatlist.rs +++ b/src/chatlist.rs @@ -416,7 +416,7 @@ impl Chatlist { if chat.id.is_archived_link() { Ok(Default::default()) } else if let Some(lastmsg) = lastmsg.filter(|msg| msg.from_id != ContactId::UNDEFINED) { - Ok(Summary::new(context, &lastmsg, chat, lastcontact.as_ref()).await) + Summary::new(context, &lastmsg, chat, lastcontact.as_ref()).await } else { Ok(Summary { text: stock_str::no_messages(context).await, diff --git a/src/message.rs b/src/message.rs index 912cef51a..6d680b8af 100644 --- a/src/message.rs +++ b/src/message.rs @@ -796,7 +796,7 @@ impl Message { None }; - Ok(Summary::new(context, self, chat, contact.as_ref()).await) + Summary::new(context, self, chat, contact.as_ref()).await } // It's a little unfortunate that the UI has to first call `dc_msg_get_override_sender_name` and then if it was `NULL`, call diff --git a/src/param.rs b/src/param.rs index 19f2eb8fa..3066a4479 100644 --- a/src/param.rs +++ b/src/param.rs @@ -64,6 +64,15 @@ pub enum Param { /// For Messages: the message is a reaction. Reaction = b'x', + /// For Chats: the timestamp of the last reaction. + LastReactionTimestamp = b'y', + + /// For Chats: Message ID of the last reaction. + LastReactionMsgId = b'Y', + + /// For Chats: Contact ID of the last reaction. + LastReactionContactId = b'1', + /// For Messages: a message with "Auto-Submitted: auto-generated" header ("bot"). Bot = b'b', diff --git a/src/reaction.rs b/src/reaction.rs index 15ac3a355..c7924aa0a 100644 --- a/src/reaction.rs +++ b/src/reaction.rs @@ -20,11 +20,12 @@ use std::fmt; use anyhow::Result; -use crate::chat::{send_msg, ChatId}; +use crate::chat::{send_msg, Chat, ChatId}; use crate::contact::ContactId; use crate::context::Context; use crate::events::EventType; use crate::message::{rfc724_mid_exists, Message, MsgId, Viewtype}; +use crate::param::Param; /// A single reaction consisting of multiple emoji sequences. /// @@ -170,6 +171,7 @@ async fn set_msg_id_reaction( msg_id: MsgId, chat_id: ChatId, contact_id: ContactId, + timestamp: i64, reaction: Reaction, ) -> Result<()> { if reaction.is_empty() { @@ -194,6 +196,17 @@ async fn set_msg_id_reaction( (msg_id, contact_id, reaction.as_str()), ) .await?; + let mut chat = Chat::load_from_db(context, chat_id).await?; + if chat + .param + .update_timestamp(Param::LastReactionTimestamp, timestamp)? + { + chat.param + .set_i64(Param::LastReactionMsgId, i64::from(msg_id.to_u32())); + chat.param + .set_i64(Param::LastReactionContactId, i64::from(contact_id.to_u32())); + chat.update_param(context).await?; + } } context.emit_event(EventType::ReactionsChanged { @@ -223,7 +236,15 @@ pub async fn send_reaction(context: &Context, msg_id: MsgId, reaction: &str) -> let reaction_msg_id = send_msg(context, chat_id, &mut reaction_msg).await?; // Only set reaction if we successfully sent the message. - set_msg_id_reaction(context, msg_id, msg.chat_id, ContactId::SELF, reaction).await?; + set_msg_id_reaction( + context, + msg_id, + msg.chat_id, + ContactId::SELF, + reaction_msg.timestamp_sort, + reaction, + ) + .await?; Ok(reaction_msg_id) } @@ -250,10 +271,11 @@ pub(crate) async fn set_msg_reaction( in_reply_to: &str, chat_id: ChatId, contact_id: ContactId, + timestamp: i64, reaction: Reaction, ) -> Result<()> { if let Some((msg_id, _)) = rfc724_mid_exists(context, in_reply_to).await? { - set_msg_id_reaction(context, msg_id, chat_id, contact_id, reaction).await + set_msg_id_reaction(context, msg_id, chat_id, contact_id, timestamp, reaction).await } else { info!( context, @@ -307,18 +329,68 @@ pub async fn get_msg_reactions(context: &Context, msg_id: MsgId) -> Result Result> { + if let Some(reaction_timestamp) = self.param.get_i64(Param::LastReactionTimestamp) { + if reaction_timestamp > timestamp { + let reaction_msg = Message::load_from_db( + context, + MsgId::new( + self.param + .get_int(Param::LastReactionMsgId) + .unwrap_or_default() as u32, + ), + ) + .await?; + if !reaction_msg.chat_id.is_trash() { + let reaction_contact_id = ContactId::new( + self.param + .get_int(Param::LastReactionContactId) + .unwrap_or_default() as u32, + ); + if let Some(reaction) = context + .sql + .query_row_optional( + r#"SELECT reaction FROM reactions WHERE msg_id=? AND contact_id=?"#, + (reaction_msg.id, reaction_contact_id), + |row| { + let reaction: String = row.get(0)?; + Ok(reaction) + }, + ) + .await? + { + return Ok(Some((reaction_msg, reaction_contact_id, reaction))); + } + } + } + } + Ok(None) + } +} + #[cfg(test)] mod tests { use super::*; use crate::chat::{get_chat_msgs, send_text_msg}; + use crate::chatlist::Chatlist; use crate::config::Config; use crate::constants::DC_CHAT_ID_TRASH; use crate::contact::{Contact, ContactAddress, Origin}; use crate::download::DownloadState; - use crate::message::MessageState; + use crate::message::{delete_msgs, MessageState}; use crate::receive_imf::{receive_imf, receive_imf_from_inbox}; use crate::test_utils::TestContext; use crate::test_utils::TestContextManager; + use crate::tools::SystemTime; + use std::time::Duration; #[test] fn test_parse_reaction() { @@ -549,6 +621,107 @@ Here's my footer -- bob@example.net" Ok(()) } + async fn assert_summary(t: &TestContext, expected: &str) { + let chatlist = Chatlist::try_load(t, 0, None, None).await.unwrap(); + let summary = chatlist.get_summary(t, 0, None).await.unwrap(); + assert_eq!(summary.text, expected); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn test_reaction_summary() -> Result<()> { + let alice = TestContext::new_alice().await; + let bob = TestContext::new_bob().await; + alice.set_config(Config::Displayname, Some("ALICE")).await?; + bob.set_config(Config::Displayname, Some("BOB")).await?; + + // Alice sends message to Bob + let alice_chat = alice.create_chat(&bob).await; + let alice_msg1 = alice.send_text(alice_chat.id, "Party?").await; + let bob_msg1 = bob.recv_msg(&alice_msg1).await; + + // Bob reacts to Alice's message, this is shown in the summaries + SystemTime::shift(Duration::from_secs(10)); + bob_msg1.chat_id.accept(&bob).await?; + send_reaction(&bob, bob_msg1.id, "๐Ÿ‘").await?; + let bob_send_reaction = bob.pop_sent_msg().await; + let alice_rcvd_reaction = alice.recv_msg(&bob_send_reaction).await; + assert!(alice_rcvd_reaction.get_timestamp() > bob_msg1.get_timestamp()); + + let chatlist = Chatlist::try_load(&bob, 0, None, None).await?; + let summary = chatlist.get_summary(&bob, 0, None).await?; + assert_eq!(summary.text, "You reacted ๐Ÿ‘ to \"Party?\""); + assert_eq!(summary.timestamp, bob_msg1.get_timestamp()); // time refers to message, not to reaction + assert_eq!(summary.state, MessageState::InFresh); // state refers to message, not to reaction + assert!(summary.prefix.is_none()); + assert!(summary.thumbnail_path.is_none()); + assert_summary(&alice, "BOB reacted ๐Ÿ‘ to \"Party?\"").await; + + // Alice reacts to own message as well + SystemTime::shift(Duration::from_secs(10)); + send_reaction(&alice, alice_msg1.sender_msg_id, "๐Ÿฟ").await?; + let alice_send_reaction = alice.pop_sent_msg().await; + bob.recv_msg(&alice_send_reaction).await; + + assert_summary(&alice, "You reacted ๐Ÿฟ to \"Party?\"").await; + assert_summary(&bob, "ALICE reacted ๐Ÿฟ to \"Party?\"").await; + + // Alice sends a newer message, this overwrites reaction summaries + SystemTime::shift(Duration::from_secs(10)); + let alice_msg2 = alice.send_text(alice_chat.id, "kewl").await; + bob.recv_msg(&alice_msg2).await; + + assert_summary(&alice, "kewl").await; + assert_summary(&bob, "kewl").await; + + // Reactions to older messages still overwrite newer messages + SystemTime::shift(Duration::from_secs(10)); + send_reaction(&alice, alice_msg1.sender_msg_id, "๐Ÿค˜").await?; + let alice_send_reaction = alice.pop_sent_msg().await; + bob.recv_msg(&alice_send_reaction).await; + + assert_summary(&alice, "You reacted ๐Ÿค˜ to \"Party?\"").await; + assert_summary(&bob, "ALICE reacted ๐Ÿค˜ to \"Party?\"").await; + + // Retracted reactions remove all summary reactions + SystemTime::shift(Duration::from_secs(10)); + send_reaction(&alice, alice_msg1.sender_msg_id, "").await?; + let alice_remove_reaction = alice.pop_sent_msg().await; + bob.recv_msg(&alice_remove_reaction).await; + + assert_summary(&alice, "kewl").await; + assert_summary(&bob, "kewl").await; + + // Alice adds another reaction and then deletes the message reacted to; this will also delete reaction summary + SystemTime::shift(Duration::from_secs(10)); + send_reaction(&alice, alice_msg1.sender_msg_id, "๐Ÿงน").await?; + assert_summary(&alice, "You reacted ๐Ÿงน to \"Party?\"").await; + + delete_msgs(&alice, &[alice_msg1.sender_msg_id]).await?; + assert_summary(&alice, "kewl").await; + + Ok(()) + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn test_reaction_self_chat_multidevice_summary() -> Result<()> { + let alice0 = TestContext::new_alice().await; + let alice1 = TestContext::new_alice().await; + let chat = alice0.get_self_chat().await; + + let msg_id = send_text_msg(&alice0, chat.id, "mom's birthday!".to_string()).await?; + alice1.recv_msg(&alice0.pop_sent_msg().await).await; + + SystemTime::shift(Duration::from_secs(10)); + send_reaction(&alice0, msg_id, "๐Ÿ‘†").await?; + let sync = alice0.pop_sent_msg().await; + receive_imf(&alice1, sync.payload().as_bytes(), false).await?; + + assert_summary(&alice0, "You reacted ๐Ÿ‘† to \"mom's birthday!\"").await; + assert_summary(&alice1, "You reacted ๐Ÿ‘† to \"mom's birthday!\"").await; + + Ok(()) + } + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn test_partial_download_and_reaction() -> Result<()> { let alice = TestContext::new_alice().await; diff --git a/src/receive_imf.rs b/src/receive_imf.rs index 1c51cdcd5..bafc6eec0 100644 --- a/src/receive_imf.rs +++ b/src/receive_imf.rs @@ -1374,6 +1374,7 @@ async fn add_parts( &mime_in_reply_to, orig_chat_id.unwrap_or_default(), from_id, + sort_timestamp, Reaction::from(reaction_str.as_str()), ) .await?; diff --git a/src/stock_str.rs b/src/stock_str.rs index d957640d5..579982571 100644 --- a/src/stock_str.rs +++ b/src/stock_str.rs @@ -429,6 +429,12 @@ pub enum StockMessage { fallback = "โš ๏ธ It seems you are using Delta Chat on multiple devices that cannot decrypt each other's outgoing messages. To fix this, on the older device use \"Settings / Add Second Device\" and follow the instructions." ))] CantDecryptOutgoingMsgs = 175, + + #[strum(props(fallback = "You reacted %1$s to \"%2$s\""))] + MsgYouReacted = 176, + + #[strum(props(fallback = "%1$s reacted %2$s to \"%3$s\""))] + MsgReactedBy = 177, } impl StockMessage { @@ -730,6 +736,27 @@ pub(crate) async fn msg_group_left_local(context: &Context, by_contact: ContactI } } +/// Stock string: `You reacted %1$s to "%2$s"` or `%1$s reacted %2$s to "%3$s"`. +pub(crate) async fn msg_reacted( + context: &Context, + by_contact: ContactId, + reaction: &str, + summary: &str, +) -> String { + if by_contact == ContactId::SELF { + translated(context, StockMessage::MsgYouReacted) + .await + .replace1(reaction) + .replace2(summary) + } else { + translated(context, StockMessage::MsgReactedBy) + .await + .replace1(&by_contact.get_stock_name(context).await) + .replace2(reaction) + .replace3(summary) + } +} + /// Stock string: `GIF`. pub(crate) async fn gif(context: &Context) -> String { translated(context, StockMessage::Gif).await diff --git a/src/summary.rs b/src/summary.rs index 2a2e15e8f..6d018c064 100644 --- a/src/summary.rs +++ b/src/summary.rs @@ -10,7 +10,9 @@ use crate::context::Context; use crate::message::{Message, MessageState, Viewtype}; use crate::mimeparser::SystemMessage; use crate::stock_str; +use crate::stock_str::msg_reacted; use crate::tools::truncate; +use anyhow::Result; /// Prefix displayed before message and separated by ":" in the chatlist. #[derive(Debug)] @@ -62,7 +64,24 @@ impl Summary { msg: &Message, chat: &Chat, contact: Option<&Contact>, - ) -> Self { + ) -> Result { + if let Some((reaction_msg, reaction_contact_id, reaction)) = chat + .get_last_reaction_if_newer_than(context, msg.timestamp_sort) + .await? + { + // there is a reaction newer than the latest message, show that. + // sorting and therefore date is still the one of the last message, + // the reaction is is more sth. that overlays temporarily. + let summary = reaction_msg.get_summary_text(context).await; + return Ok(Summary { + prefix: None, + text: msg_reacted(context, reaction_contact_id, &reaction, &summary).await, + timestamp: msg.get_timestamp(), // message timestamp (not reaction) to make timestamps more consistent with chats ordering + state: msg.state, // message state (not reaction) - indicating if it was me sending the last message + thumbnail_path: None, + }); + } + let prefix = if msg.state == MessageState::OutDraft { Some(SummaryPrefix::Draft(stock_str::draft(context).await)) } else if msg.from_id == ContactId::SELF { @@ -102,13 +121,13 @@ impl Summary { None }; - Self { + Ok(Summary { prefix, text, timestamp: msg.get_timestamp(), state: msg.state, thumbnail_path, - } + }) } /// Returns the [`Summary::text`] attribute truncated to an approximate length.