From 1c24ad91eb00827795ea9b0dadf2825979484daa Mon Sep 17 00:00:00 2001 From: Hocuri Date: Thu, 16 Apr 2026 16:30:05 +0200 Subject: [PATCH] feat: Remove the largely-unused ability to send multiple reactions to one message (#8131) After talking with r10s: For spring cleaning, remove the largely-unused things that can be done a bit. Most private messengers (WhatsApp/Signal/...) do not have this feature, and we do not want to become Matrix where every client has different, partly-incompatible features. --- deltachat-jsonrpc/src/api.rs | 10 +- deltachat-jsonrpc/src/api/types/reactions.rs | 27 ++-- .../src/deltachat_rpc_client/message.py | 9 +- src/reaction.rs | 146 ++++++------------ 4 files changed, 66 insertions(+), 126 deletions(-) diff --git a/deltachat-jsonrpc/src/api.rs b/deltachat-jsonrpc/src/api.rs index 4be0672a7..781faf452 100644 --- a/deltachat-jsonrpc/src/api.rs +++ b/deltachat-jsonrpc/src/api.rs @@ -2377,12 +2377,12 @@ impl CommandApi { Ok(message_id.to_u32()) } - /// Send a reaction to message. + /// Sends a reaction to message. /// - /// Reaction is a string of emojis separated by spaces. Reaction to a - /// single message can be sent multiple times. The last reaction - /// received overrides all previously received reactions. It is - /// possible to remove all reactions by sending an empty string. + /// A reaction is a string that represents an emoji. + /// You can call this function again to change the emoji; + /// the last sent reaction overrides all previously sent reactions. + /// It is possible to remove the reaction by sending an empty string. async fn send_reaction( &self, account_id: u32, diff --git a/deltachat-jsonrpc/src/api/types/reactions.rs b/deltachat-jsonrpc/src/api/types/reactions.rs index a0916f811..271cf8b9d 100644 --- a/deltachat-jsonrpc/src/api/types/reactions.rs +++ b/deltachat-jsonrpc/src/api/types/reactions.rs @@ -24,6 +24,8 @@ pub struct JsonrpcReaction { #[serde(rename = "Reactions", rename_all = "camelCase")] pub struct JsonrpcReactions { /// Map from a contact to it's reaction to message. + /// There is only a single reaction per contact, + /// but this contains a list of reactions for historical reasons. reactions_by_contact: BTreeMap>, /// Unique reactions and their count, sorted in descending order. reactions: Vec, @@ -31,27 +33,16 @@ pub struct JsonrpcReactions { impl From for JsonrpcReactions { fn from(reactions: Reactions) -> Self { - let mut reactions_by_contact: BTreeMap> = BTreeMap::new(); - - for contact_id in reactions.contacts() { - let reaction = reactions.get(contact_id); - if reaction.is_empty() { - continue; - } - let emojis: Vec = reaction - .emojis() - .into_iter() - .map(|emoji| emoji.to_owned()) - .collect(); - reactions_by_contact.insert(contact_id.to_u32(), emojis.clone()); - } - - let self_reactions = reactions_by_contact.get(&ContactId::SELF.to_u32()); + let reactions_by_contact: BTreeMap> = reactions + .iter() + .map(|(key, value)| (key.to_u32(), vec![value.as_str().to_string()])) + .collect(); + let self_reaction = reactions_by_contact.get(&ContactId::SELF.to_u32()); let mut reactions_v = Vec::new(); for (emoji, count) in reactions.emoji_sorted_by_frequency() { - let is_from_self = if let Some(self_reactions) = self_reactions { - self_reactions.contains(&emoji) + let is_from_self = if let Some(self_reaction) = self_reaction { + self_reaction.contains(&emoji) } else { false }; diff --git a/deltachat-rpc-client/src/deltachat_rpc_client/message.py b/deltachat-rpc-client/src/deltachat_rpc_client/message.py index ff2f06e39..86b354d5a 100644 --- a/deltachat-rpc-client/src/deltachat_rpc_client/message.py +++ b/deltachat-rpc-client/src/deltachat_rpc_client/message.py @@ -25,7 +25,14 @@ class Message: return self.account._rpc def send_reaction(self, *reaction: str) -> "Message": - """Send a reaction to this message.""" + """ + Sends a reaction to message. + + A reaction is a string that represents an emoji. + You can call this function again to change the emoji; + the last sent reaction overrides all previously sent reactions. + It is possible to remove the reaction by sending an empty string. + """ msg_id = self._rpc.send_reaction(self.account.id, self.id, reaction) return Message(self.account, msg_id) diff --git a/src/reaction.rs b/src/reaction.rs index 08f0494ef..515a468e3 100644 --- a/src/reaction.rs +++ b/src/reaction.rs @@ -1,6 +1,6 @@ //! # Reactions. //! -//! Reactions are short messages consisting of emojis sent in reply to +//! Reactions are short messages representing an emoji sent in reply to //! messages. Unlike normal messages which are added to the end of the chat, //! reactions are supposed to be displayed near the original messages. //! @@ -11,7 +11,7 @@ //! [XEP-0444](https://xmpp.org/extensions/xep-0444.html) section //! "3.2 Updating reactions to a message". Received reactions override //! all previously received reactions from the same user and it is -//! possible to remove all reactions by sending an empty string as a reaction, +//! possible to remove the reaction by sending an empty string as a reaction, //! even though RFC 9078 requires at least one emoji to be sent. use std::cmp::Ordering; @@ -29,9 +29,7 @@ use crate::events::EventType; use crate::message::{Message, MsgId, rfc724_mid_exists}; use crate::param::Param; -/// A single reaction consisting of multiple emoji sequences. -/// -/// It is guaranteed to have all emojis sorted and deduplicated inside. +/// A single reaction. #[derive(Debug, Default, Clone, Deserialize, Eq, PartialEq, Serialize)] pub struct Reaction { /// Canonical representation of reaction as a string of space-separated emojis. @@ -42,11 +40,8 @@ pub struct Reaction { // FromStr requires error type and reaction parsing never returns an // error. impl From<&str> for Reaction { - /// Parses a string containing a reaction. - /// - /// Reaction string is separated by spaces or tabs (`WSP` in ABNF), - /// but this function accepts any ASCII whitespace, so even a CRLF at - /// the end of string is acceptable. + /// Convert a `&str` into a `Reaction`. + /// Everything after the first whitespace is ignored. /// /// Any short enough string is accepted as a reaction to avoid the /// complexity of validating emoji sequences as required by RFC @@ -57,42 +52,27 @@ impl From<&str> for Reaction { /// such as sending large numbers of large messages, and should be /// dealt with the same way, e.g. by blocking the user. fn from(reaction: &str) -> Self { - let mut emojis: Vec<&str> = reaction + let reaction: &str = reaction .split_ascii_whitespace() + .next() .filter(|&emoji| emoji.len() < 30) - .collect(); - emojis.sort_unstable(); - emojis.dedup(); - let reaction = emojis.join(" "); - Self { reaction } + .unwrap_or(""); + Self { + reaction: reaction.to_string(), + } } } impl Reaction { - /// Returns true if reaction contains no emojis. + /// Returns true if reaction contains no emoji. pub fn is_empty(&self) -> bool { self.reaction.is_empty() } - /// Returns a vector of emojis composing a reaction. - pub fn emojis(&self) -> Vec<&str> { - self.reaction.split(' ').collect() - } - - /// Returns space-separated string of emojis + /// Returns a string representing the emoji. pub fn as_str(&self) -> &str { &self.reaction } - - /// Appends emojis from another reaction to this reaction. - pub fn add(&self, other: Self) -> Self { - let mut emojis: Vec<&str> = self.emojis(); - emojis.append(&mut other.emojis()); - emojis.sort_unstable(); - emojis.dedup(); - let reaction = emojis.join(" "); - Self { reaction } - } } /// Structure representing all reactions to a particular message. @@ -126,12 +106,10 @@ impl Reactions { pub fn emoji_frequencies(&self) -> BTreeMap { let mut emoji_frequencies: BTreeMap = BTreeMap::new(); for reaction in self.reactions.values() { - for emoji in reaction.emojis() { - emoji_frequencies - .entry(emoji.to_string()) - .and_modify(|x| *x += 1) - .or_insert(1); - } + emoji_frequencies + .entry(reaction.as_str().to_string()) + .and_modify(|x| *x += 1) + .or_insert(1); } emoji_frequencies } @@ -152,6 +130,11 @@ impl Reactions { }); emoji_frequencies } + + /// Returns an iterator of the contacts that reacted and their corresponding reactions. + pub fn iter(&self) -> impl Iterator { + self.reactions.iter() + } } impl fmt::Display for Reactions { @@ -223,7 +206,7 @@ async fn set_msg_id_reaction( /// Sends a reaction to message `msg_id`, overriding previously sent reactions. /// -/// `reaction` is a string consisting of space-separated emoji. Use +/// `reaction` is a string consisting of a single emoji. Use /// empty string to retract a reaction. pub async fn send_reaction(context: &Context, msg_id: MsgId, reaction: &str) -> Result { let msg = Message::load_from_db(context, msg_id).await?; @@ -251,24 +234,12 @@ pub async fn send_reaction(context: &Context, msg_id: MsgId, reaction: &str) -> Ok(reaction_msg_id) } -/// Adds given reaction to message `msg_id` and sends an update. -/// -/// This can be used to implement advanced clients that allow reacting -/// with multiple emojis. For a simple messenger UI, you probably want -/// to use [`send_reaction()`] instead so reacting with a new emoji -/// removes previous emoji at the same time. -pub async fn add_reaction(context: &Context, msg_id: MsgId, reaction: &str) -> Result { - let self_reaction = get_self_reaction(context, msg_id).await?; - let reaction = self_reaction.add(Reaction::from(reaction)); - send_reaction(context, msg_id, reaction.as_str()).await -} - /// Updates reaction of `contact_id` on the message with `in_reply_to` /// Message-ID. If no such message is found in the database, reaction /// is ignored. /// -/// `reaction` is a space-separated string of emojis. It can be empty -/// if contact wants to remove all reactions. +/// `reaction` is string representing the emoji. It can be empty +/// if contact wants to remove the reaction. pub(crate) async fn set_msg_reaction( context: &Context, in_reply_to: &str, @@ -301,26 +272,9 @@ pub(crate) async fn set_msg_reaction( Ok(()) } -/// Get our own reaction for a given message. -async fn get_self_reaction(context: &Context, msg_id: MsgId) -> Result { - let reaction_str: Option = context - .sql - .query_get_value( - "SELECT reaction - FROM reactions - WHERE msg_id=? AND contact_id=?", - (msg_id, ContactId::SELF), - ) - .await?; - Ok(reaction_str - .as_deref() - .map(Reaction::from) - .unwrap_or_default()) -} - /// Returns a structure containing all reactions to the message. pub async fn get_msg_reactions(context: &Context, msg_id: MsgId) -> Result { - let reactions: BTreeMap = context + let mut reactions: BTreeMap = context .sql .query_map_collect( "SELECT contact_id, reaction FROM reactions WHERE msg_id=?", @@ -332,6 +286,7 @@ pub async fn get_msg_reactions(context: &Context, msg_id: MsgId) -> Result