diff --git a/CHANGELOG.md b/CHANGELOG.md index b7365a560..71076f401 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ ### API-Changes - jsonrpc: typescript client: export constants under `C` enum, similar to how its exported from `deltachat-node` #3681 +- added reactions support #3644 +- jsonrpc: reactions: added reactions to `Message` type and the `sendReaction()` method #3686 ### Changes - simplify `UPSERT` queries #3676 diff --git a/deltachat-ffi/deltachat.h b/deltachat-ffi/deltachat.h index c9eaf19f4..8417a81ef 100644 --- a/deltachat-ffi/deltachat.h +++ b/deltachat-ffi/deltachat.h @@ -11,16 +11,17 @@ extern "C" { #endif -typedef struct _dc_context dc_context_t; -typedef struct _dc_accounts dc_accounts_t; -typedef struct _dc_array dc_array_t; -typedef struct _dc_chatlist dc_chatlist_t; -typedef struct _dc_chat dc_chat_t; -typedef struct _dc_msg dc_msg_t; -typedef struct _dc_contact dc_contact_t; -typedef struct _dc_lot dc_lot_t; -typedef struct _dc_provider dc_provider_t; -typedef struct _dc_event dc_event_t; +typedef struct _dc_context dc_context_t; +typedef struct _dc_accounts dc_accounts_t; +typedef struct _dc_array dc_array_t; +typedef struct _dc_chatlist dc_chatlist_t; +typedef struct _dc_chat dc_chat_t; +typedef struct _dc_msg dc_msg_t; +typedef struct _dc_reactions dc_reactions_t; +typedef struct _dc_contact dc_contact_t; +typedef struct _dc_lot dc_lot_t; +typedef struct _dc_provider dc_provider_t; +typedef struct _dc_event dc_event_t; typedef struct _dc_event_emitter dc_event_emitter_t; typedef struct _dc_jsonrpc_instance dc_jsonrpc_instance_t; @@ -991,6 +992,34 @@ uint32_t dc_send_text_msg (dc_context_t* context, uint32_t ch uint32_t dc_send_videochat_invitation (dc_context_t* context, uint32_t chat_id); +/** + * Send 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. + * + * @memberof dc_context_t + * @param context The context object. + * @param msg_id ID of the message you react to. + * @param reaction A string consisting of emojis separated by spaces. + * @return The ID of the message sent out or 0 for errors. + */ +uint32_t dc_send_reaction (dc_context_t* context, uint32_t msg_id, char *reaction); + + +/** + * Get a structure with reactions to the message. + * + * @memberof dc_context_t + * @param context The context object. + * @param msg_id The message ID to get reactions for. + * @return A structure with all reactions to the message. + */ +dc_reactions_t* dc_get_msg_reactions (dc_context_t *context, int msg_id); + + /** * A webxdc instance sends a status update to its other members. * @@ -4882,7 +4911,49 @@ uint32_t dc_lot_get_id (const dc_lot_t* lot); * @param lot The lot object. * @return The timestamp as defined by the creator of the object. 0 if there is not timestamp or on errors. */ -int64_t dc_lot_get_timestamp (const dc_lot_t* lot); +int64_t dc_lot_get_timestamp (const dc_lot_t* lot); + + +/** + * @class dc_reactions_t + * + * An object representing all reactions for a single message. + */ + +/** + * Returns array of contacts which reacted to the given message. + * + * @memberof dc_reactions_t + * @param reactions The object containing message reactions. + * @return array of contact IDs. Use dc_array_get_cnt() to get array length and + * dc_array_get_id() to get the IDs. Should be freed using `dc_array_unref()` after usage. + */ +dc_array_t* dc_reactions_get_contacts(dc_reactions_t* reactions); + + +/** + * Returns a string containing space-separated reactions of a single contact. + * + * @memberof dc_reactions_t + * @param reactions The object containing message reactions. + * @param contact_id ID of the contact. + * @return Space-separated list of emoji sequences, which could be empty. + * Returned string should not be modified and should be freed + * with dc_str_unref() after usage. + */ +char* dc_reactions_get_by_contact_id(dc_reactions_t* reactions, uint32_t contact_id); + + +/** + * Frees an object containing message reactions. + * + * Reactions objects are created by dc_get_msg_reactions(). + * + * @memberof dc_reactions_t + * @param reactions The object to free. + * If NULL is given, nothing is done. + */ +void dc_reactions_unref (dc_reactions_t* reactions); /** @@ -5533,6 +5604,15 @@ void dc_event_unref(dc_event_t* event); #define DC_EVENT_MSGS_CHANGED 2000 +/** + * Message reactions changed. + * + * @param data1 (int) chat_id ID of the chat affected by the changes. + * @param data2 (int) msg_id ID of the message for which reactions were changed. + */ +#define DC_EVENT_REACTIONS_CHANGED 2001 + + /** * There is a fresh message. Typically, the user will show an notification * when receiving this message. diff --git a/deltachat-ffi/src/dc_array.rs b/deltachat-ffi/src/dc_array.rs index e8997b05a..98def5d0e 100644 --- a/deltachat-ffi/src/dc_array.rs +++ b/deltachat-ffi/src/dc_array.rs @@ -1,5 +1,6 @@ use crate::chat::ChatItem; use crate::constants::DC_MSG_ID_DAYMARKER; +use crate::contact::ContactId; use crate::location::Location; use crate::message::MsgId; @@ -7,6 +8,7 @@ use crate::message::MsgId; #[derive(Debug, Clone)] pub enum dc_array_t { MsgIds(Vec), + ContactIds(Vec), Chat(Vec), Locations(Vec), Uint(Vec), @@ -16,6 +18,7 @@ impl dc_array_t { pub(crate) fn get_id(&self, index: usize) -> u32 { match self { Self::MsgIds(array) => array[index].to_u32(), + Self::ContactIds(array) => array[index].to_u32(), Self::Chat(array) => match array[index] { ChatItem::Message { msg_id } => msg_id.to_u32(), ChatItem::DayMarker { .. } => DC_MSG_ID_DAYMARKER, @@ -28,6 +31,7 @@ impl dc_array_t { pub(crate) fn get_timestamp(&self, index: usize) -> Option { match self { Self::MsgIds(_) => None, + Self::ContactIds(_) => None, Self::Chat(array) => array.get(index).and_then(|item| match item { ChatItem::Message { .. } => None, ChatItem::DayMarker { timestamp } => Some(*timestamp), @@ -40,6 +44,7 @@ impl dc_array_t { pub(crate) fn get_marker(&self, index: usize) -> Option<&str> { match self { Self::MsgIds(_) => None, + Self::ContactIds(_) => None, Self::Chat(_) => None, Self::Locations(array) => array .get(index) @@ -60,6 +65,7 @@ impl dc_array_t { pub(crate) fn len(&self) -> usize { match self { Self::MsgIds(array) => array.len(), + Self::ContactIds(array) => array.len(), Self::Chat(array) => array.len(), Self::Locations(array) => array.len(), Self::Uint(array) => array.len(), @@ -83,6 +89,12 @@ impl From> for dc_array_t { } } +impl From> for dc_array_t { + fn from(array: Vec) -> Self { + dc_array_t::ContactIds(array) + } +} + impl From> for dc_array_t { fn from(array: Vec) -> Self { dc_array_t::Chat(array) diff --git a/deltachat-ffi/src/lib.rs b/deltachat-ffi/src/lib.rs index 1f521dc74..2b1594a7a 100644 --- a/deltachat-ffi/src/lib.rs +++ b/deltachat-ffi/src/lib.rs @@ -37,6 +37,7 @@ use deltachat::context::Context; use deltachat::ephemeral::Timer as EphemeralTimer; use deltachat::key::DcKey; use deltachat::message::MsgId; +use deltachat::reaction::{get_msg_reactions, send_reaction, Reactions}; use deltachat::stock_str::StockMessage; use deltachat::stock_str::StockStrings; use deltachat::webxdc::StatusUpdateSerial; @@ -66,6 +67,8 @@ use deltachat::chatlist::Chatlist; /// Struct representing the deltachat context. pub type dc_context_t = Context; +pub type dc_reactions_t = Reactions; + static RT: Lazy = Lazy::new(|| Runtime::new().expect("unable to create tokio runtime")); fn block_on(fut: T) -> T::Output @@ -498,6 +501,7 @@ pub unsafe extern "C" fn dc_event_get_id(event: *mut dc_event_t) -> libc::c_int EventType::Error(_) => 400, EventType::ErrorSelfNotInGroup(_) => 410, EventType::MsgsChanged { .. } => 2000, + EventType::ReactionsChanged { .. } => 2001, EventType::IncomingMsg { .. } => 2005, EventType::MsgsNoticed { .. } => 2008, EventType::MsgDelivered { .. } => 2010, @@ -542,6 +546,7 @@ pub unsafe extern "C" fn dc_event_get_data1_int(event: *mut dc_event_t) -> libc: | EventType::SelfavatarChanged | EventType::ErrorSelfNotInGroup(_) => 0, EventType::MsgsChanged { chat_id, .. } + | EventType::ReactionsChanged { chat_id, .. } | EventType::IncomingMsg { chat_id, .. } | EventType::MsgsNoticed(chat_id) | EventType::MsgDelivered { chat_id, .. } @@ -598,6 +603,7 @@ pub unsafe extern "C" fn dc_event_get_data2_int(event: *mut dc_event_t) -> libc: | EventType::SelfavatarChanged => 0, EventType::ChatModified(_) => 0, EventType::MsgsChanged { msg_id, .. } + | EventType::ReactionsChanged { msg_id, .. } | EventType::IncomingMsg { msg_id, .. } | EventType::MsgDelivered { msg_id, .. } | EventType::MsgFailed { msg_id, .. } @@ -637,6 +643,7 @@ pub unsafe extern "C" fn dc_event_get_data2_str(event: *mut dc_event_t) -> *mut data2.into_raw() } EventType::MsgsChanged { .. } + | EventType::ReactionsChanged { .. } | EventType::IncomingMsg { .. } | EventType::MsgsNoticed(_) | EventType::MsgDelivered { .. } @@ -948,6 +955,48 @@ pub unsafe extern "C" fn dc_send_videochat_invitation( }) } +#[no_mangle] +pub unsafe extern "C" fn dc_send_reaction( + context: *mut dc_context_t, + msg_id: u32, + reaction: *const libc::c_char, +) -> u32 { + if context.is_null() { + eprintln!("ignoring careless call to dc_send_reaction()"); + return 0; + } + let ctx = &*context; + + block_on(async move { + send_reaction(ctx, MsgId::new(msg_id), &to_string_lossy(reaction)) + .await + .map(|msg_id| msg_id.to_u32()) + .unwrap_or_log_default(ctx, "Failed to send reaction") + }) +} + +#[no_mangle] +pub unsafe extern "C" fn dc_get_msg_reactions( + context: *mut dc_context_t, + msg_id: u32, +) -> *mut dc_reactions_t { + if context.is_null() { + eprintln!("ignoring careless call to dc_get_msg_reactions()"); + return ptr::null_mut(); + } + let ctx = &*context; + + let reactions = if let Ok(reactions) = block_on(get_msg_reactions(ctx, MsgId::new(msg_id))) + .log_err(ctx, "failed dc_get_msg_reactions() call") + { + reactions + } else { + return ptr::null_mut(); + }; + + Box::into_raw(Box::new(reactions)) +} + #[no_mangle] pub unsafe extern "C" fn dc_send_webxdc_status_update( context: *mut dc_context_t, @@ -3988,6 +4037,45 @@ pub unsafe extern "C" fn dc_lot_get_timestamp(lot: *mut dc_lot_t) -> i64 { lot.get_timestamp() } +#[no_mangle] +pub unsafe extern "C" fn dc_reactions_get_contacts( + reactions: *mut dc_reactions_t, +) -> *mut dc_array::dc_array_t { + if reactions.is_null() { + eprintln!("ignoring careless call to dc_reactions_get_contacts()"); + return ptr::null_mut(); + } + + let reactions = &*reactions; + let array: dc_array_t = reactions.contacts().into(); + + Box::into_raw(Box::new(array)) +} + +#[no_mangle] +pub unsafe extern "C" fn dc_reactions_get_by_contact_id( + reactions: *mut dc_reactions_t, + contact_id: u32, +) -> *mut libc::c_char { + if reactions.is_null() { + eprintln!("ignoring careless call to dc_reactions_get_by_contact_id()"); + return ptr::null_mut(); + } + + let reactions = &*reactions; + reactions.get(ContactId::new(contact_id)).as_str().strdup() +} + +#[no_mangle] +pub unsafe extern "C" fn dc_reactions_unref(reactions: *mut dc_reactions_t) { + if reactions.is_null() { + eprintln!("ignoring careless call to dc_reactions_unref()"); + return; + } + + drop(Box::from_raw(reactions)); +} + #[no_mangle] pub unsafe extern "C" fn dc_str_unref(s: *mut libc::c_char) { libc::free(s as *mut _) diff --git a/deltachat-jsonrpc/src/api/events.rs b/deltachat-jsonrpc/src/api/events.rs index 89786697b..9e2a63b67 100644 --- a/deltachat-jsonrpc/src/api/events.rs +++ b/deltachat-jsonrpc/src/api/events.rs @@ -102,6 +102,14 @@ pub enum JSONRPCEventType { msg_id: u32, }, + /// Reactions for the message changed. + #[serde(rename_all = "camelCase")] + ReactionsChanged { + chat_id: u32, + msg_id: u32, + contact_id: u32, + }, + /// There is a fresh message. Typically, the user will show an notification /// when receiving this message. /// @@ -284,6 +292,15 @@ impl From for JSONRPCEventType { chat_id: chat_id.to_u32(), msg_id: msg_id.to_u32(), }, + EventType::ReactionsChanged { + chat_id, + msg_id, + contact_id, + } => ReactionsChanged { + chat_id: chat_id.to_u32(), + msg_id: msg_id.to_u32(), + contact_id: contact_id.to_u32(), + }, EventType::IncomingMsg { chat_id, msg_id } => IncomingMsg { chat_id: chat_id.to_u32(), msg_id: msg_id.to_u32(), diff --git a/deltachat-jsonrpc/src/api/mod.rs b/deltachat-jsonrpc/src/api/mod.rs index 20ad089f5..ba5b9cfd8 100644 --- a/deltachat-jsonrpc/src/api/mod.rs +++ b/deltachat-jsonrpc/src/api/mod.rs @@ -17,6 +17,7 @@ use deltachat::{ provider::get_provider_info, qr, qr_code_generator::get_securejoin_qr_svg, + reaction::send_reaction, securejoin, stock_str::StockMessage, webxdc::StatusUpdateSerial, @@ -1466,6 +1467,23 @@ impl CommandApi { Ok(message_id.to_u32()) } + /// Send 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. + async fn send_reaction( + &self, + account_id: u32, + message_id: u32, + reaction: Vec, + ) -> Result { + let ctx = self.get_context(account_id).await?; + let message_id = send_reaction(&ctx, MsgId::new(message_id), &reaction.join(" ")).await?; + Ok(message_id.to_u32()) + } + // --------------------------------------------- // functions for the composer // the composer is the message input field diff --git a/deltachat-jsonrpc/src/api/types/message.rs b/deltachat-jsonrpc/src/api/types/message.rs index be66ff1fa..181cbb621 100644 --- a/deltachat-jsonrpc/src/api/types/message.rs +++ b/deltachat-jsonrpc/src/api/types/message.rs @@ -8,6 +8,7 @@ use deltachat::download; use deltachat::message::Message; use deltachat::message::MsgId; use deltachat::message::Viewtype; +use deltachat::reaction::get_msg_reactions; use num_traits::cast::ToPrimitive; use serde::Deserialize; use serde::Serialize; @@ -15,6 +16,7 @@ use typescript_type_def::TypeDef; use super::color_int_to_hex_string; use super::contact::ContactObject; +use super::reactions::JSONRPCReactions; use super::webxdc::WebxdcMessageInfo; #[derive(Serialize, TypeDef)] @@ -64,6 +66,8 @@ pub struct MessageObject { webxdc_info: Option, download_state: DownloadState, + + reactions: Option, } #[derive(Serialize, TypeDef)] @@ -139,6 +143,13 @@ impl MessageObject { None }; + let reactions = get_msg_reactions(context, msg_id).await?; + let reactions = if reactions.is_empty() { + None + } else { + Some(reactions.into()) + }; + Ok(MessageObject { id: msg_id.to_u32(), chat_id: message.get_chat_id().to_u32(), @@ -193,6 +204,8 @@ impl MessageObject { webxdc_info, download_state, + + reactions, }) } } diff --git a/deltachat-jsonrpc/src/api/types/mod.rs b/deltachat-jsonrpc/src/api/types/mod.rs index 6f344d498..bb44323fb 100644 --- a/deltachat-jsonrpc/src/api/types/mod.rs +++ b/deltachat-jsonrpc/src/api/types/mod.rs @@ -9,6 +9,7 @@ pub mod contact; pub mod location; pub mod message; pub mod provider_info; +pub mod reactions; pub mod webxdc; pub fn color_int_to_hex_string(color: u32) -> String { diff --git a/deltachat-jsonrpc/src/api/types/reactions.rs b/deltachat-jsonrpc/src/api/types/reactions.rs new file mode 100644 index 000000000..8717ebdc2 --- /dev/null +++ b/deltachat-jsonrpc/src/api/types/reactions.rs @@ -0,0 +1,47 @@ +use std::collections::BTreeMap; + +use deltachat::reaction::Reactions; +use serde::Serialize; +use typescript_type_def::TypeDef; + +/// Structure representing all reactions to a particular message. +#[derive(Serialize, TypeDef)] +#[serde(rename = "Reactions", rename_all = "camelCase")] +pub struct JSONRPCReactions { + /// Map from a contact to it's reaction to message. + reactions_by_contact: BTreeMap>, + /// Unique reactions and their count + reactions: BTreeMap, +} + +impl From for JSONRPCReactions { + fn from(reactions: Reactions) -> Self { + let mut reactions_by_contact: BTreeMap> = BTreeMap::new(); + let mut unique_reactions: 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()); + for emoji in emojis { + if let Some(x) = unique_reactions.get_mut(&emoji) { + *x += 1; + } else { + unique_reactions.insert(emoji, 1); + } + } + } + + JSONRPCReactions { + reactions_by_contact, + reactions: unique_reactions, + } + } +} diff --git a/deltachat-jsonrpc/typescript/generated/client.ts b/deltachat-jsonrpc/typescript/generated/client.ts index 875aa6d19..c243b05d3 100644 --- a/deltachat-jsonrpc/typescript/generated/client.ts +++ b/deltachat-jsonrpc/typescript/generated/client.ts @@ -878,6 +878,18 @@ export class RawClient { return (this._transport.request('send_sticker', [accountId, chatId, stickerPath] as RPC.Params)) as Promise; } + /** + * Send 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. + */ + public sendReaction(accountId: T.U32, messageId: T.U32, reaction: (string)[]): Promise { + return (this._transport.request('send_reaction', [accountId, messageId, reaction] as RPC.Params)) as Promise; + } + public removeDraft(accountId: T.U32, chatId: T.U32): Promise { return (this._transport.request('remove_draft', [accountId, chatId] as RPC.Params)) as Promise; diff --git a/deltachat-jsonrpc/typescript/generated/events.ts b/deltachat-jsonrpc/typescript/generated/events.ts index a483c68cb..287a9150d 100644 --- a/deltachat-jsonrpc/typescript/generated/events.ts +++ b/deltachat-jsonrpc/typescript/generated/events.ts @@ -77,6 +77,10 @@ export type Event=(({ * `msgId` is set if only a single message is affected by the changes, otherwise 0. */ "type":"MsgsChanged";}&{"chatId":U32;"msgId":U32;})|({ +/** + * Reactions for the message changed. + */ +"type":"ReactionsChanged";}&{"chatId":U32;"msgId":U32;"contactId":U32;})|({ /** * There is a fresh message. Typically, the user will show an notification * when receiving this message. diff --git a/deltachat-jsonrpc/typescript/generated/types.ts b/deltachat-jsonrpc/typescript/generated/types.ts index b36ad0d8e..d776ccd7e 100644 --- a/deltachat-jsonrpc/typescript/generated/types.ts +++ b/deltachat-jsonrpc/typescript/generated/types.ts @@ -147,7 +147,24 @@ export type WebxdcMessageInfo={ */ "internetAccess":boolean;}; export type DownloadState=("Done"|"Available"|"Failure"|"InProgress"); -export type Message={"id":U32;"chatId":U32;"fromId":U32;"quote":(MessageQuote|null);"parentId":(U32|null);"text":(string|null);"hasLocation":boolean;"hasHtml":boolean;"viewType":Viewtype;"state":U32;"timestamp":I64;"sortTimestamp":I64;"receivedTimestamp":I64;"hasDeviatingTimestamp":boolean;"subject":string;"showPadlock":boolean;"isSetupmessage":boolean;"isInfo":boolean;"isForwarded":boolean;"duration":I32;"dimensionsHeight":I32;"dimensionsWidth":I32;"videochatType":(U32|null);"videochatUrl":(string|null);"overrideSenderName":(string|null);"sender":Contact;"setupCodeBegin":(string|null);"file":(string|null);"fileMime":(string|null);"fileBytes":U64;"fileName":(string|null);"webxdcInfo":(WebxdcMessageInfo|null);"downloadState":DownloadState;}; + +/** + * Structure representing all reactions to a particular message. + */ +export type Reactions= +/** + * Structure representing all reactions to a particular message. + */ +{ +/** + * Map from a contact to it's reaction to message. + */ +"reactionsByContact":Record; +/** + * Unique reactions and their count + */ +"reactions":Record;}; +export type Message={"id":U32;"chatId":U32;"fromId":U32;"quote":(MessageQuote|null);"parentId":(U32|null);"text":(string|null);"hasLocation":boolean;"hasHtml":boolean;"viewType":Viewtype;"state":U32;"timestamp":I64;"sortTimestamp":I64;"receivedTimestamp":I64;"hasDeviatingTimestamp":boolean;"subject":string;"showPadlock":boolean;"isSetupmessage":boolean;"isInfo":boolean;"isForwarded":boolean;"duration":I32;"dimensionsHeight":I32;"dimensionsWidth":I32;"videochatType":(U32|null);"videochatUrl":(string|null);"overrideSenderName":(string|null);"sender":Contact;"setupCodeBegin":(string|null);"file":(string|null);"fileMime":(string|null);"fileBytes":U64;"fileName":(string|null);"webxdcInfo":(WebxdcMessageInfo|null);"downloadState":DownloadState;"reactions":(Reactions|null);}; export type MessageNotificationInfo={"id":U32;"chatId":U32;"accountId":U32;"image":(string|null);"imageMimeType":(string|null);"chatName":string;"chatProfileImage":(string|null); /** * also known as summary_text1 @@ -160,4 +177,4 @@ export type MessageNotificationInfo={"id":U32;"chatId":U32;"accountId":U32;"imag export type MessageSearchResult={"id":U32;"authorProfileImage":(string|null);"authorName":string;"authorColor":string;"chatName":(string|null);"message":string;"timestamp":I64;}; export type F64=number; export type Location={"locationId":U32;"isIndependent":boolean;"latitude":F64;"longitude":F64;"accuracy":F64;"timestamp":I64;"contactId":U32;"msgId":U32;"chatId":U32;"marker":(string|null);}; -export type __AllTyps=[string,boolean,Record,U32,U32,null,(U32)[],U32,null,(U32|null),(Account)[],null,null,U32,null,U32,null,U32,Account,U32,U64,U32,string,(ProviderInfo|null),U32,boolean,U32,Record,U32,string,(string|null),null,U32,Record,null,U32,string,null,U32,string,Qr,U32,string,(string|null),U32,(string)[],Record,Record,null,U32,null,U32,null,U32,string,(string|null),null,U32,string,(string|null),null,U32,(U32)[],U32,U32,Usize,U32,boolean,I64,Usize,U32,string,U32,U32,string,null,U32,(U32|null),(string|null),(U32|null),(ChatListEntry)[],U32,(ChatListEntry)[],Record,U32,U32,FullChat,U32,U32,BasicChat,U32,U32,null,U32,U32,null,U32,U32,null,U32,U32,string,U32,(U32|null),[string,string],U32,string,U32,U32,U32,null,U32,U32,U32,null,U32,U32,U32,null,U32,U32,(U32)[],U32,string,boolean,U32,U32,U32,U32,U32,string,null,U32,U32,(string|null),null,U32,U32,ChatVisibility,null,U32,U32,U32,null,U32,U32,U32,U32,string,string,U32,U32,U32,null,U32,U32,(U32|null),U32,U32,MuteDuration,null,U32,U32,boolean,U32,(U32)[],null,U32,U32,U32,(U32)[],U32,U32,U32,(MessageListItem)[],U32,U32,Message,U32,U32,(string|null),U32,(U32)[],Record,U32,U32,MessageNotificationInfo,U32,(U32)[],null,U32,U32,string,U32,U32,null,U32,string,(U32|null),(U32)[],U32,(U32)[],Record,U32,U32,Contact,U32,string,(string|null),U32,U32,U32,U32,U32,U32,null,U32,U32,null,U32,(Contact)[],U32,U32,(string|null),(U32)[],U32,U32,(string|null),(Contact)[],U32,(U32)[],Record,U32,U32,boolean,U32,U32,string,null,U32,U32,string,U32,string,(U32|null),U32,(U32|null),Viewtype,(Viewtype|null),(Viewtype|null),(U32)[],U32,U32,Viewtype,(Viewtype|null),(Viewtype|null),[(U32|null),(U32|null)],U32,string,(string|null),null,U32,string,(string|null),null,null,U32,U32,U32,string,U32,(U32|null),(U32|null),I64,I64,(Location)[],U32,U32,string,string,null,U32,U32,U32,string,U32,U32,WebxdcMessageInfo,U32,(U32)[],U32,null,U32,U32,string,U32,U32,U32,null,U32,U32,(Message|null),U32,U32,U32,U32,string,U32,Record,U32,string,U32,U32,U32,U32,(string|null),(string|null),([F64,F64]|null),(U32|null),[U32,Message],U32,U32,(string|null),(string|null),(U32|null),null]; +export type __AllTyps=[string,boolean,Record,U32,U32,null,(U32)[],U32,null,(U32|null),(Account)[],null,null,U32,null,U32,null,U32,Account,U32,U64,U32,string,(ProviderInfo|null),U32,boolean,U32,Record,U32,string,(string|null),null,U32,Record,null,U32,string,null,U32,string,Qr,U32,string,(string|null),U32,(string)[],Record,Record,null,U32,null,U32,null,U32,string,(string|null),null,U32,string,(string|null),null,U32,(U32)[],U32,U32,Usize,U32,boolean,I64,Usize,U32,string,U32,U32,string,null,U32,(U32|null),(string|null),(U32|null),(ChatListEntry)[],U32,(ChatListEntry)[],Record,U32,U32,FullChat,U32,U32,BasicChat,U32,U32,null,U32,U32,null,U32,U32,null,U32,U32,string,U32,(U32|null),[string,string],U32,string,U32,U32,U32,null,U32,U32,U32,null,U32,U32,U32,null,U32,U32,(U32)[],U32,string,boolean,U32,U32,U32,U32,U32,string,null,U32,U32,(string|null),null,U32,U32,ChatVisibility,null,U32,U32,U32,null,U32,U32,U32,U32,string,string,U32,U32,U32,null,U32,U32,(U32|null),U32,U32,MuteDuration,null,U32,U32,boolean,U32,(U32)[],null,U32,U32,U32,(U32)[],U32,U32,U32,(MessageListItem)[],U32,U32,Message,U32,U32,(string|null),U32,(U32)[],Record,U32,U32,MessageNotificationInfo,U32,(U32)[],null,U32,U32,string,U32,U32,null,U32,string,(U32|null),(U32)[],U32,(U32)[],Record,U32,U32,Contact,U32,string,(string|null),U32,U32,U32,U32,U32,U32,null,U32,U32,null,U32,(Contact)[],U32,U32,(string|null),(U32)[],U32,U32,(string|null),(Contact)[],U32,(U32)[],Record,U32,U32,boolean,U32,U32,string,null,U32,U32,string,U32,string,(U32|null),U32,(U32|null),Viewtype,(Viewtype|null),(Viewtype|null),(U32)[],U32,U32,Viewtype,(Viewtype|null),(Viewtype|null),[(U32|null),(U32|null)],U32,string,(string|null),null,U32,string,(string|null),null,null,U32,U32,U32,string,U32,(U32|null),(U32|null),I64,I64,(Location)[],U32,U32,string,string,null,U32,U32,U32,string,U32,U32,WebxdcMessageInfo,U32,(U32)[],U32,null,U32,U32,string,U32,U32,U32,(string)[],U32,U32,U32,null,U32,U32,(Message|null),U32,U32,U32,U32,string,U32,Record,U32,string,U32,U32,U32,U32,(string|null),(string|null),([F64,F64]|null),(U32|null),[U32,Message],U32,U32,(string|null),(string|null),(U32|null),null]; diff --git a/examples/repl/cmdline.rs b/examples/repl/cmdline.rs index ad5a53681..8854cc92e 100644 --- a/examples/repl/cmdline.rs +++ b/examples/repl/cmdline.rs @@ -20,6 +20,7 @@ use deltachat::log::LogExt; use deltachat::message::{self, Message, MessageState, MsgId, Viewtype}; use deltachat::peerstate::*; use deltachat::qr::*; +use deltachat::reaction::send_reaction; use deltachat::receive_imf::*; use deltachat::sql; use deltachat::tools::*; @@ -407,6 +408,7 @@ pub async fn cmdline(context: Context, line: &str, chat_id: &mut ChatId) -> Resu resend \n\ markseen \n\ delmsg \n\ + react []\n\ ===========================Contact commands==\n\ listcontacts []\n\ listverified []\n\ @@ -1121,6 +1123,12 @@ pub async fn cmdline(context: Context, line: &str, chat_id: &mut ChatId) -> Resu ids[0] = MsgId::new(arg1.parse()?); message::delete_msgs(&context, &ids).await?; } + "react" => { + ensure!(!arg1.is_empty(), "Argument missing."); + let msg_id = MsgId::new(arg1.parse()?); + let reaction = arg2; + send_reaction(&context, msg_id, reaction).await?; + } "listcontacts" | "contacts" | "listverified" => { let contacts = Contact::get_all( &context, diff --git a/examples/repl/main.rs b/examples/repl/main.rs index 46c2fb746..f9403d064 100644 --- a/examples/repl/main.rs +++ b/examples/repl/main.rs @@ -72,6 +72,19 @@ fn receive_event(event: EventType) { )) ); } + EventType::ReactionsChanged { + chat_id, + msg_id, + contact_id, + } => { + info!( + "{}", + yellow.paint(format!( + "Received REACTIONS_CHANGED(chat_id={}, msg_id={}, contact_id={})", + chat_id, msg_id, contact_id + )) + ); + } EventType::ContactsChanged(_) => { info!("{}", yellow.paint("Received CONTACTS_CHANGED()")); } @@ -208,7 +221,7 @@ const CHAT_COMMANDS: [&str; 36] = [ "accept", "blockchat", ]; -const MESSAGE_COMMANDS: [&str; 8] = [ +const MESSAGE_COMMANDS: [&str; 9] = [ "listmsgs", "msginfo", "listfresh", @@ -217,6 +230,7 @@ const MESSAGE_COMMANDS: [&str; 8] = [ "markseen", "delmsg", "download", + "react", ]; const CONTACT_COMMANDS: [&str; 9] = [ "listcontacts", diff --git a/node/constants.js b/node/constants.js index 510b22a60..ba5bd1a71 100644 --- a/node/constants.js +++ b/node/constants.js @@ -50,6 +50,7 @@ module.exports = { DC_EVENT_MSG_FAILED: 2012, DC_EVENT_MSG_READ: 2015, DC_EVENT_NEW_BLOB_FILE: 150, + DC_EVENT_REACTIONS_CHANGED: 2001, DC_EVENT_SECUREJOIN_INVITER_PROGRESS: 2060, DC_EVENT_SECUREJOIN_JOINER_PROGRESS: 2061, DC_EVENT_SELFAVATAR_CHANGED: 2110, diff --git a/node/events.js b/node/events.js index 58d13f531..dba37e240 100644 --- a/node/events.js +++ b/node/events.js @@ -14,6 +14,7 @@ module.exports = { 400: 'DC_EVENT_ERROR', 410: 'DC_EVENT_ERROR_SELF_NOT_IN_GROUP', 2000: 'DC_EVENT_MSGS_CHANGED', + 2001: 'DC_EVENT_REACTIONS_CHANGED', 2005: 'DC_EVENT_INCOMING_MSG', 2008: 'DC_EVENT_MSGS_NOTICED', 2010: 'DC_EVENT_MSG_DELIVERED', diff --git a/node/lib/constants.ts b/node/lib/constants.ts index 0a25d69c4..5b32cf7e5 100644 --- a/node/lib/constants.ts +++ b/node/lib/constants.ts @@ -50,6 +50,7 @@ export enum C { DC_EVENT_MSG_FAILED = 2012, DC_EVENT_MSG_READ = 2015, DC_EVENT_NEW_BLOB_FILE = 150, + DC_EVENT_REACTIONS_CHANGED = 2001, DC_EVENT_SECUREJOIN_INVITER_PROGRESS = 2060, DC_EVENT_SECUREJOIN_JOINER_PROGRESS = 2061, DC_EVENT_SELFAVATAR_CHANGED = 2110, @@ -282,6 +283,7 @@ export const EventId2EventName: { [key: number]: string } = { 400: 'DC_EVENT_ERROR', 410: 'DC_EVENT_ERROR_SELF_NOT_IN_GROUP', 2000: 'DC_EVENT_MSGS_CHANGED', + 2001: 'DC_EVENT_REACTIONS_CHANGED', 2005: 'DC_EVENT_INCOMING_MSG', 2008: 'DC_EVENT_MSGS_NOTICED', 2010: 'DC_EVENT_MSG_DELIVERED', diff --git a/spec.md b/spec.md index e2042d6d7..2a9e675be 100644 --- a/spec.md +++ b/spec.md @@ -450,6 +450,16 @@ This allows the receiver to show the time without knowing the file format. Chat-Duration: 10000 +# Reactions + +Messengers MAY implement [RFC 9078](https://tools.ietf.org/html/rfc9078) reactions. +Received reaction should be interpreted as overwriting all previous reactions +received from the same contact. +This semantics is compatible to [XEP-0444](https://xmpp.org/extensions/xep-0444.html). +As an extension to RFC 9078, it is allowed to send empty reaction message, +in which case all previously sent reactions are retracted. + + # Miscellaneous Messengers SHOULD use the header `In-Reply-To` as usual. diff --git a/src/contact.rs b/src/contact.rs index 0d9af4a4c..5b9adceaa 100644 --- a/src/contact.rs +++ b/src/contact.rs @@ -34,7 +34,9 @@ const SEEN_RECENTLY_SECONDS: i64 = 600; /// /// Some contact IDs are reserved to identify special contacts. This /// type can represent both the special as well as normal contacts. -#[derive(Debug, Copy, Clone, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[derive( + Debug, Copy, Clone, Default, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, +)] pub struct ContactId(u32); impl ContactId { diff --git a/src/events.rs b/src/events.rs index 29fb039b2..7ea3c67a3 100644 --- a/src/events.rs +++ b/src/events.rs @@ -173,6 +173,13 @@ pub enum EventType { msg_id: MsgId, }, + /// Reactions for the message changed. + ReactionsChanged { + chat_id: ChatId, + msg_id: MsgId, + contact_id: ContactId, + }, + /// There is a fresh message. Typically, the user will show an notification /// when receiving this message. /// diff --git a/src/lib.rs b/src/lib.rs index 1b510068a..04f2a2013 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -103,6 +103,7 @@ pub mod receive_imf; pub mod tools; pub mod accounts; +pub mod reaction; /// if set imap/incoming and smtp/outgoing MIME messages will be printed pub const DCC_MIME_DEBUG: &str = "DCC_MIME_DEBUG"; diff --git a/src/message.rs b/src/message.rs index 7ff5b3667..3da05a34c 100644 --- a/src/message.rs +++ b/src/message.rs @@ -22,6 +22,7 @@ use crate::imap::markseen_on_imap_table; use crate::mimeparser::{parse_message_id, DeliveryReport, SystemMessage}; use crate::param::{Param, Params}; use crate::pgp::split_armored_data; +use crate::reaction::get_msg_reactions; use crate::scheduler::InterruptInfo; use crate::sql; use crate::stock_str; @@ -751,6 +752,11 @@ impl Message { self.param.set_int(Param::Duration, duration); } + /// Marks the message as reaction. + pub(crate) fn set_reaction(&mut self) { + self.param.set_int(Param::Reaction, 1); + } + pub async fn latefiling_mediasize( &mut self, context: &Context, @@ -1082,6 +1088,11 @@ pub async fn get_msg_info(context: &Context, msg_id: MsgId) -> Result { ret += "\n"; + let reactions = get_msg_reactions(context, msg_id).await?; + if !reactions.is_empty() { + ret += &format!("Reactions: {}\n", reactions); + } + if let Some(error) = msg.error.as_ref() { ret += &format!("Error: {}", error); } diff --git a/src/mimefactory.rs b/src/mimefactory.rs index 0221ff7b6..90c22044c 100644 --- a/src/mimefactory.rs +++ b/src/mimefactory.rs @@ -183,7 +183,10 @@ impl<'a> MimeFactory<'a> { ) .await?; - if !msg.is_system_message() && context.get_config_bool(Config::MdnsEnabled).await? { + if !msg.is_system_message() + && msg.param.get_int(Param::Reaction).unwrap_or_default() == 0 + && context.get_config_bool(Config::MdnsEnabled).await? + { req_mdn = true; } } @@ -1122,6 +1125,11 @@ impl<'a> MimeFactory<'a> { "text/plain; charset=utf-8; format=flowed; delsp=no".to_string(), )) .body(message_text); + + if self.msg.param.get_int(Param::Reaction).unwrap_or_default() != 0 { + main_part = main_part.header(("Content-Disposition", "reaction")); + } + let mut parts = Vec::new(); // add HTML-part, this is needed only if a HTML-message from a non-delta-client is forwarded; diff --git a/src/mimeparser.rs b/src/mimeparser.rs index a8960dfd0..0683a0064 100644 --- a/src/mimeparser.rs +++ b/src/mimeparser.rs @@ -551,7 +551,10 @@ impl MimeMessage { } if prepend_subject && !subject.is_empty() { - let part_with_text = self.parts.iter_mut().find(|part| !part.msg.is_empty()); + let part_with_text = self + .parts + .iter_mut() + .find(|part| !part.msg.is_empty() && !part.is_reaction); if let Some(mut part) = part_with_text { part.msg = format!("{} – {}", subject, part.msg); } @@ -913,6 +916,7 @@ impl MimeMessage { Ok(any_part_added) } + /// Returns true if any part was added, false otherwise. async fn add_single_part_if_known( &mut self, context: &Context, @@ -946,6 +950,30 @@ impl MimeMessage { warn!(context, "Missing attachment"); return Ok(false); } + mime::TEXT + if mail.get_content_disposition().disposition + == DispositionType::Extension("reaction".to_string()) => + { + // Reaction. + let decoded_data = match mail.get_body() { + Ok(decoded_data) => decoded_data, + Err(err) => { + warn!(context, "Invalid body parsed {:?}", err); + // Note that it's not always an error - might be no data + return Ok(false); + } + }; + + let part = Part { + typ: Viewtype::Text, + mimetype: Some(mime_type), + msg: decoded_data, + is_reaction: true, + ..Default::default() + }; + self.do_add_single_part(part); + return Ok(true); + } mime::TEXT | mime::HTML => { let decoded_data = match mail.get_body() { Ok(decoded_data) => decoded_data, @@ -1644,6 +1672,9 @@ pub struct Part { /// note that multipart/related may contain further multipart nestings /// and all of them needs to be marked with `is_related`. pub(crate) is_related: bool, + + /// Part is an RFC 9078 reaction. + pub(crate) is_reaction: bool, } /// return mimetype and viewtype for a parsed mail @@ -3329,4 +3360,39 @@ Message. Ok(()) } + + /// Tests parsing of MIME message containing RFC 9078 reaction. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn test_parse_reaction() -> Result<()> { + let alice = TestContext::new_alice().await; + + let mime_message = MimeMessage::from_bytes( + &alice, + "To: alice@example.org\n\ +From: bob@example.net\n\ +Date: Today, 29 February 2021 00:00:10 -800\n\ +Message-ID: 56789@example.net\n\ +In-Reply-To: 12345@example.org\n\ +Subject: Meeting\n\ +Mime-Version: 1.0 (1.0)\n\ +Content-Type: text/plain; charset=utf-8\n\ +Content-Disposition: reaction\n\ +\n\ +\u{1F44D}" + .as_bytes(), + ) + .await?; + + assert_eq!(mime_message.parts.len(), 1); + assert_eq!(mime_message.parts[0].is_reaction, true); + assert_eq!( + mime_message + .get_header(HeaderDef::InReplyTo) + .and_then(|msgid| parse_message_id(msgid).ok()) + .unwrap(), + "12345@example.org" + ); + + Ok(()) + } } diff --git a/src/param.rs b/src/param.rs index 44c81e598..cd791d502 100644 --- a/src/param.rs +++ b/src/param.rs @@ -59,6 +59,9 @@ pub enum Param { /// For Messages WantsMdn = b'r', + /// For Messages: the message is a reaction. + Reaction = b'x', + /// For Messages: a message with Auto-Submitted header ("bot"). Bot = b'b', diff --git a/src/reaction.rs b/src/reaction.rs new file mode 100644 index 000000000..b00824ba9 --- /dev/null +++ b/src/reaction.rs @@ -0,0 +1,481 @@ +//! # Reactions. +//! +//! Reactions are short messages consisting of emojis 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. +//! +//! RFC 9078 specifies how reactions are transmitted in MIME messages. +//! +//! Reaction update semantics is not well-defined in RFC 9078, so +//! Delta Chat uses the same semantics as in +//! [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, +//! even though RFC 9078 requires at least one emoji to be sent. + +use std::collections::BTreeMap; +use std::fmt; + +use anyhow::Result; + +use crate::chat::{send_msg, ChatId}; +use crate::contact::ContactId; +use crate::context::Context; +use crate::events::EventType; +use crate::message::{rfc724_mid_exists, Message, MsgId, Viewtype}; + +/// A single reaction consisting of multiple emoji sequences. +/// +/// It is guaranteed to have all emojis sorted and deduplicated inside. +#[derive(Debug, Default, Clone)] +pub struct Reaction { + /// Canonical represntation of reaction as a string of space-separated emojis. + reaction: String, +} + +// We implement From<&str> instead of std::str::FromStr, because +// 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. + /// + /// Any short enough string is accepted as a reaction to avoid the + /// complexity of validating emoji sequences as required by RFC + /// 9078. On the sender side UI is responsible to provide only + /// valid emoji sequences via reaction picker. On the receiver + /// side, abuse of the possibility to use arbitrary strings as + /// reactions is not different from other kinds of spam attacks + /// 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 + .split_ascii_whitespace() + .filter(|&emoji| emoji.len() < 30) + .collect(); + emojis.sort(); + emojis.dedup(); + let reaction = emojis.join(" "); + Self { reaction } + } +} + +impl Reaction { + /// Returns true if reaction contains no emojis. + 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 + 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(); + emojis.dedup(); + let reaction = emojis.join(" "); + Self { reaction } + } +} + +/// Structure representing all reactions to a particular message. +#[derive(Debug)] +pub struct Reactions { + /// Map from a contact to its reaction to message. + reactions: BTreeMap, +} + +impl Reactions { + /// Returns vector of contacts that reacted to the message. + pub fn contacts(&self) -> Vec { + self.reactions.keys().copied().collect() + } + + /// Returns reaction of a given contact to message. + /// + /// If contact did not react to message or removed the reaction, + /// this method returns an empty reaction. + pub fn get(&self, contact_id: ContactId) -> Reaction { + self.reactions.get(&contact_id).cloned().unwrap_or_default() + } + + /// Returns true if the message has no reactions. + pub fn is_empty(&self) -> bool { + self.reactions.is_empty() + } +} + +impl fmt::Display for Reactions { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + 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); + } + } + let mut first = true; + for (emoji, frequency) in emoji_frequencies { + if !first { + write!(f, " ")?; + } + first = false; + write!(f, "{}{}", emoji, frequency)?; + } + Ok(()) + } +} + +async fn set_msg_id_reaction( + context: &Context, + msg_id: MsgId, + chat_id: ChatId, + contact_id: ContactId, + reaction: Reaction, +) -> Result<()> { + if reaction.is_empty() { + // Simply remove the record instead of setting it to empty string. + context + .sql + .execute( + "DELETE FROM reactions + WHERE msg_id = ?1 + AND contact_id = ?2", + paramsv![msg_id, contact_id], + ) + .await?; + } else { + context + .sql + .execute( + "INSERT INTO reactions (msg_id, contact_id, reaction) + VALUES (?1, ?2, ?3) + ON CONFLICT(msg_id, contact_id) + DO UPDATE SET reaction=excluded.reaction", + paramsv![msg_id, contact_id, reaction.as_str()], + ) + .await?; + } + + context.emit_event(EventType::ReactionsChanged { + chat_id, + msg_id, + contact_id, + }); + Ok(()) +} + +/// Sends a reaction to message `msg_id`, overriding previously sent reactions. +/// +/// `reaction` is a string consisting of space-separated 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?; + let chat_id = msg.chat_id; + + let reaction: Reaction = reaction.into(); + let mut reaction_msg = Message::new(Viewtype::Text); + reaction_msg.text = Some(reaction.as_str().to_string()); + reaction_msg.set_reaction(); + reaction_msg.in_reply_to = Some(msg.rfc724_mid); + reaction_msg.hidden = true; + + // Send messsage first. + 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?; + 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. +pub(crate) async fn set_msg_reaction( + context: &Context, + in_reply_to: &str, + chat_id: ChatId, + contact_id: ContactId, + 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 + } else { + info!( + context, + "Can't assign reaction to unknown message with Message-ID {}", in_reply_to + ); + 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=?", + paramsv![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 = context + .sql + .query_map( + "SELECT contact_id, reaction FROM reactions WHERE msg_id=?", + paramsv![msg_id], + |row| { + let contact_id: ContactId = row.get(0)?; + let reaction: String = row.get(1)?; + Ok((contact_id, reaction)) + }, + |rows| { + let mut reactions = Vec::new(); + for row in rows { + let (contact_id, reaction) = row?; + reactions.push((contact_id, Reaction::from(reaction.as_str()))); + } + Ok(reactions) + }, + ) + .await? + .into_iter() + .collect(); + Ok(Reactions { reactions }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::chat::get_chat_msgs; + + use crate::config::Config; + use crate::constants::DC_CHAT_ID_TRASH; + use crate::contact::{Contact, Origin}; + use crate::message::MessageState; + use crate::receive_imf::receive_imf; + use crate::test_utils::TestContext; + + #[test] + fn test_parse_reaction() { + // Check that basic set of emojis from RFC 9078 is supported. + assert_eq!(Reaction::from("👍").emojis(), vec!["👍"]); + assert_eq!(Reaction::from("👎").emojis(), vec!["👎"]); + assert_eq!(Reaction::from("😀").emojis(), vec!["😀"]); + assert_eq!(Reaction::from("☹").emojis(), vec!["☹"]); + assert_eq!(Reaction::from("😢").emojis(), vec!["😢"]); + + // Empty string can be used to remove all reactions. + assert!(Reaction::from("").is_empty()); + + // Short strings can be used as emojis, could be used to add + // support for custom emojis via emoji shortcodes. + assert_eq!(Reaction::from(":deltacat:").emojis(), vec![":deltacat:"]); + + // Check that long strings are not valid emojis. + assert!( + Reaction::from(":foobarbazquuxaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:").is_empty() + ); + + // Multiple reactions separated by spaces or tabs are supported. + assert_eq!(Reaction::from("👍 ❤").emojis(), vec!["❤", "👍"]); + assert_eq!(Reaction::from("👍\t❤").emojis(), vec!["❤", "👍"]); + + // Invalid emojis are removed, but valid emojis are retained. + assert_eq!( + Reaction::from("👍\t:foo: ❤").emojis(), + vec![":foo:", "❤", "👍"] + ); + assert_eq!(Reaction::from("👍\t:foo: ❤").as_str(), ":foo: ❤ 👍"); + + // Duplicates are removed. + assert_eq!(Reaction::from("👍 👍").emojis(), vec!["👍"]); + } + + #[test] + fn test_add_reaction() { + let reaction1 = Reaction::from("👍 😀"); + let reaction2 = Reaction::from("❤"); + let reaction_sum = reaction1.add(reaction2); + + assert_eq!(reaction_sum.emojis(), vec!["❤", "👍", "😀"]); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn test_receive_reaction() -> Result<()> { + let alice = TestContext::new_alice().await; + alice.set_config(Config::ShowEmails, Some("2")).await?; + + // Alice receives BCC-self copy of a message sent to Bob. + receive_imf( + &alice, + "To: bob@example.net\n\ +From: alice@example.org\n\ +Date: Today, 29 February 2021 00:00:00 -800\n\ +Message-ID: 12345@example.org\n\ +Subject: Meeting\n\ +\n\ +Can we chat at 1pm pacific, today?" + .as_bytes(), + false, + ) + .await?; + let msg = alice.get_last_msg().await; + assert_eq!(msg.state, MessageState::OutDelivered); + let reactions = get_msg_reactions(&alice, msg.id).await?; + let contacts = reactions.contacts(); + assert_eq!(contacts.len(), 0); + + let bob_id = Contact::add_or_lookup(&alice, "", "bob@example.net", Origin::ManuallyCreated) + .await? + .0; + let bob_reaction = reactions.get(bob_id); + assert!(bob_reaction.is_empty()); // Bob has not reacted to message yet. + + // Alice receives reaction to her message from Bob. + receive_imf( + &alice, + "To: alice@example.org\n\ +From: bob@example.net\n\ +Date: Today, 29 February 2021 00:00:10 -800\n\ +Message-ID: 56789@example.net\n\ +In-Reply-To: 12345@example.org\n\ +Subject: Meeting\n\ +Mime-Version: 1.0 (1.0)\n\ +Content-Type: text/plain; charset=utf-8\n\ +Content-Disposition: reaction\n\ +\n\ +\u{1F44D}" + .as_bytes(), + false, + ) + .await?; + + let reactions = get_msg_reactions(&alice, msg.id).await?; + assert_eq!(reactions.to_string(), "👍1"); + + let contacts = reactions.contacts(); + assert_eq!(contacts.len(), 1); + + assert_eq!(contacts.get(0), Some(&bob_id)); + let bob_reaction = reactions.get(bob_id); + assert_eq!(bob_reaction.is_empty(), false); + assert_eq!(bob_reaction.emojis(), vec!["👍"]); + assert_eq!(bob_reaction.as_str(), "👍"); + + Ok(()) + } + + async fn expect_reactions_changed_event( + t: &TestContext, + expected_chat_id: ChatId, + expected_msg_id: MsgId, + expected_contact_id: ContactId, + ) -> Result<()> { + let event = t + .evtracker + .get_matching(|evt| matches!(evt, EventType::ReactionsChanged { .. })) + .await; + match event { + EventType::ReactionsChanged { + chat_id, + msg_id, + contact_id, + } => { + assert_eq!(chat_id, expected_chat_id); + assert_eq!(msg_id, expected_msg_id); + assert_eq!(contact_id, expected_contact_id); + } + _ => unreachable!(), + } + Ok(()) + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn test_send_reaction() -> Result<()> { + let alice = TestContext::new_alice().await; + let bob = TestContext::new_bob().await; + + let chat_alice = alice.create_chat(&bob).await; + let alice_msg = alice.send_text(chat_alice.id, "Hi!").await; + let bob_msg = bob.recv_msg(&alice_msg).await; + assert_eq!(get_chat_msgs(&alice, chat_alice.id, 0).await?.len(), 1); + assert_eq!(get_chat_msgs(&bob, bob_msg.chat_id, 0).await?.len(), 1); + + let alice_msg2 = alice.send_text(chat_alice.id, "Hi again!").await; + bob.recv_msg(&alice_msg2).await; + assert_eq!(get_chat_msgs(&alice, chat_alice.id, 0).await?.len(), 2); + assert_eq!(get_chat_msgs(&bob, bob_msg.chat_id, 0).await?.len(), 2); + + bob_msg.chat_id.accept(&bob).await?; + + send_reaction(&bob, bob_msg.id, "👍").await.unwrap(); + expect_reactions_changed_event(&bob, bob_msg.chat_id, bob_msg.id, ContactId::SELF).await?; + assert_eq!(get_chat_msgs(&bob, bob_msg.chat_id, 0).await?.len(), 2); + + let bob_reaction_msg = bob.pop_sent_msg().await; + let alice_reaction_msg = alice.recv_msg_opt(&bob_reaction_msg).await.unwrap(); + assert_eq!(alice_reaction_msg.chat_id, DC_CHAT_ID_TRASH); + assert_eq!(get_chat_msgs(&alice, chat_alice.id, 0).await?.len(), 2); + + let reactions = get_msg_reactions(&alice, alice_msg.sender_msg_id).await?; + assert_eq!(reactions.to_string(), "👍1"); + let contacts = reactions.contacts(); + assert_eq!(contacts.len(), 1); + let bob_id = contacts.get(0).unwrap(); + let bob_reaction = reactions.get(*bob_id); + assert_eq!(bob_reaction.is_empty(), false); + assert_eq!(bob_reaction.emojis(), vec!["👍"]); + assert_eq!(bob_reaction.as_str(), "👍"); + expect_reactions_changed_event(&alice, chat_alice.id, alice_msg.sender_msg_id, *bob_id) + .await?; + + // Alice reacts to own message. + send_reaction(&alice, alice_msg.sender_msg_id, "👍 😀") + .await + .unwrap(); + let reactions = get_msg_reactions(&alice, alice_msg.sender_msg_id).await?; + assert_eq!(reactions.to_string(), "👍2 😀1"); + + Ok(()) + } +} diff --git a/src/receive_imf.rs b/src/receive_imf.rs index 3880452ec..7e71d0081 100644 --- a/src/receive_imf.rs +++ b/src/receive_imf.rs @@ -33,6 +33,7 @@ use crate::mimeparser::{ }; use crate::param::{Param, Params}; use crate::peerstate::{Peerstate, PeerstateKeyType, PeerstateVerifiedStatus}; +use crate::reaction::{set_msg_reaction, Reaction}; use crate::securejoin::{self, handle_securejoin_handshake, observe_securejoin_on_other_device}; use crate::sql; use crate::stock_str; @@ -430,8 +431,9 @@ async fn add_parts( }; // incoming non-chat messages may be discarded - let location_kml_is = mime_parser.location_kml.is_some(); + let is_location_kml = mime_parser.location_kml.is_some(); let is_mdn = !mime_parser.mdn_reports.is_empty(); + let is_reaction = mime_parser.parts.iter().any(|part| part.is_reaction); let show_emails = ShowEmails::from_i32(context.get_config_int(Config::ShowEmails).await?).unwrap_or_default(); @@ -450,7 +452,7 @@ async fn add_parts( ShowEmails::All => allow_creation = !is_mdn, } } else { - allow_creation = !is_mdn; + allow_creation = !is_mdn && !is_reaction; } // check if the message introduces a new chat: @@ -689,7 +691,8 @@ async fn add_parts( state = if seen || fetching_existing_messages || is_mdn - || location_kml_is + || is_reaction + || is_location_kml || securejoin_seen || chat_id_blocked == Blocked::Yes { @@ -841,14 +844,15 @@ async fn add_parts( } } - if is_mdn { - chat_id = Some(DC_CHAT_ID_TRASH); - } - - let chat_id = chat_id.unwrap_or_else(|| { - info!(context, "No chat id for message (TRASH)"); + let orig_chat_id = chat_id; + let chat_id = if is_mdn || is_reaction { DC_CHAT_ID_TRASH - }); + } else { + chat_id.unwrap_or_else(|| { + info!(context, "No chat id for message (TRASH)"); + DC_CHAT_ID_TRASH + }) + }; // Extract ephemeral timer from the message or use the existing timer if the message is not fully downloaded. let mut ephemeral_timer = if is_partial_download.is_some() { @@ -1053,6 +1057,17 @@ async fn add_parts( let conn = context.sql.get_conn().await?; for part in &mime_parser.parts { + if part.is_reaction { + set_msg_reaction( + context, + &mime_in_reply_to, + orig_chat_id.unwrap_or_default(), + from_id, + Reaction::from(part.msg.as_str()), + ) + .await?; + } + let mut txt_raw = "".to_string(); let mut stmt = conn.prepare_cached( r#" @@ -1113,7 +1128,7 @@ INSERT INTO msgs // If you change which information is skipped if the message is trashed, // also change `MsgId::trash()` and `delete_expired_messages()` - let trash = chat_id.is_trash() || (location_kml_is && msg.is_empty()); + let trash = chat_id.is_trash() || (is_location_kml && msg.is_empty()); stmt.execute(paramsv![ rfc724_mid, diff --git a/src/sql/migrations.rs b/src/sql/migrations.rs index 03703c357..73abbe2cf 100644 --- a/src/sql/migrations.rs +++ b/src/sql/migrations.rs @@ -596,6 +596,19 @@ CREATE INDEX smtp_messageid ON imap(rfc724_mid); ) .await?; } + if dbversion < 92 { + sql.execute_migration( + r#"CREATE TABLE reactions ( + msg_id INTEGER NOT NULL, -- id of the message reacted to + contact_id INTEGER NOT NULL, -- id of the contact reacting to the message + reaction TEXT DEFAULT '' NOT NULL, -- a sequence of emojis separated by spaces + PRIMARY KEY(msg_id, contact_id), + FOREIGN KEY(msg_id) REFERENCES msgs(id) ON DELETE CASCADE -- delete reactions when message is deleted + FOREIGN KEY(contact_id) REFERENCES contacts(id) ON DELETE CASCADE -- delete reactions when contact is deleted + )"#, + 92 + ).await?; + } let new_version = sql .get_raw_config_int(VERSION_CFG) diff --git a/standards.md b/standards.md index 87f1f8d20..916038bca 100644 --- a/standards.md +++ b/standards.md @@ -8,6 +8,7 @@ Transport | IMAP v4 ([RFC 3501](https://tools.ietf.org/ht Proxy | SOCKS5 ([RFC 1928](https://tools.ietf.org/html/rfc1928)) Embedded media | MIME Document Series ([RFC 2045](https://tools.ietf.org/html/rfc2045), [RFC 2046](https://tools.ietf.org/html/rfc2046)), Content-Disposition Header ([RFC 2183](https://tools.ietf.org/html/rfc2183)), Multipart/Related ([RFC 2387](https://tools.ietf.org/html/rfc2387)) Text and Quote encoding | Fixed, Flowed ([RFC 3676](https://tools.ietf.org/html/rfc3676)) +Reactions | Reaction: Indicating Summary Reaction to a Message [RFC 9078](https://datatracker.ietf.org/doc/rfc9078/) Filename encoding | Encoded Words ([RFC 2047](https://tools.ietf.org/html/rfc2047)), Encoded Word Extensions ([RFC 2231](https://tools.ietf.org/html/rfc2231)) Identify server folders | IMAP LIST Extension ([RFC 6154](https://tools.ietf.org/html/rfc6154)) Push | IMAP IDLE ([RFC 2177](https://tools.ietf.org/html/rfc2177))