diff --git a/.github/workflows/node-tests.yml b/.github/workflows/node-tests.yml index 8beb22b17..6f90530da 100644 --- a/.github/workflows/node-tests.yml +++ b/.github/workflows/node-tests.yml @@ -52,6 +52,7 @@ jobs: npm install --verbose - name: Test + timeout-minutes: 10 if: runner.os != 'Windows' run: | cd node @@ -59,6 +60,7 @@ jobs: env: DCC_NEW_TMP_EMAIL: ${{ secrets.DCC_NEW_TMP_EMAIL }} - name: Run tests on Windows, except lint + timeout-minutes: 10 if: runner.os == 'Windows' run: | cd node diff --git a/CHANGELOG.md b/CHANGELOG.md index b06084a8f..bb0da7804 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,19 @@ ## Unreleased +### 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 + +### Fixes + + +## 1.97.0 + ### API-Changes - jsonrpc: add function: #3641, #3645, #3653 - `getChatContacts()` @@ -33,6 +46,8 @@ - `stopIo()` - `exportBackup()` - `importBackup()` + - `getMessageHtml()` #3671 + - `miscGetStickerFolder` and `miscGetStickers` #3672 - breaking: jsonrpc: remove function `messageListGetMessageIds()`, it is replaced by `getMessageIds()` and `getMessageListItems()` the latter returns a new `MessageListItem` type, which is the now prefered way of using the message list. - jsonrpc: add type: #3641, #3645 - `MessageSearchResult` diff --git a/Cargo.lock b/Cargo.lock index 097964508..ae5d06eb6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -895,7 +895,7 @@ dependencies = [ [[package]] name = "deltachat" -version = "1.96.0" +version = "1.97.0" dependencies = [ "ansi_term", "anyhow", @@ -967,7 +967,7 @@ dependencies = [ [[package]] name = "deltachat-jsonrpc" -version = "1.96.0" +version = "1.97.0" dependencies = [ "anyhow", "async-channel", @@ -996,7 +996,7 @@ dependencies = [ [[package]] name = "deltachat_ffi" -version = "1.96.0" +version = "1.97.0" dependencies = [ "anyhow", "deltachat", @@ -1263,9 +1263,9 @@ checksum = "b5320ae4c3782150d900b79807611a59a99fc9a1d61d686faafc24b93fc8d7ca" [[package]] name = "enum-as-inner" -version = "0.4.0" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "21cdad81446a7f7dc43f6a77409efeb9733d2fa65553efef6018ef257c959b73" +checksum = "c9720bba047d567ffc8a3cba48bf19126600e249ab7f128e9233e6376976a116" dependencies = [ "heck", "proc-macro2", @@ -2431,9 +2431,9 @@ dependencies = [ [[package]] name = "percent-encoding" -version = "2.1.0" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4fd5641d01c8f18a23da7b6fe29298ff4b55afcccdf78973b24cf3175fee32e" +checksum = "478c572c3d73181ff3c2539045f6eb99e5491218eae919370993b890cdbdd98e" [[package]] name = "pgp" @@ -3660,9 +3660,9 @@ dependencies = [ [[package]] name = "trust-dns-proto" -version = "0.21.2" +version = "0.22.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c31f240f59877c3d4bb3b3ea0ec5a6a0cff07323580ff8c7a605cd7d08b255d" +checksum = "4f7f83d1e4a0e4358ac54c5c3681e5d7da5efc5a7a632c90bb6d6669ddd9bc26" dependencies = [ "async-trait", "cfg-if", @@ -3674,32 +3674,32 @@ dependencies = [ "idna", "ipnet", "lazy_static", - "log", "rand 0.8.5", "smallvec", "thiserror", "tinyvec", "tokio", + "tracing", "url", ] [[package]] name = "trust-dns-resolver" -version = "0.21.2" +version = "0.22.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e4ba72c2ea84515690c9fcef4c6c660bb9df3036ed1051686de84605b74fd558" +checksum = "aff21aa4dcefb0a1afbfac26deb0adc93888c7d295fb63ab273ef276ba2b7cfe" dependencies = [ "cfg-if", "futures-util", "ipconfig", "lazy_static", - "log", "lru-cache", "parking_lot", "resolv-conf", "smallvec", "thiserror", "tokio", + "tracing", "trust-dns-proto", ] diff --git a/Cargo.toml b/Cargo.toml index bb11973b8..f99f0fac0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "deltachat" -version = "1.96.0" +version = "1.97.0" authors = ["Delta Chat Developers (ML) "] edition = "2021" license = "MPL-2.0" @@ -23,7 +23,7 @@ anyhow = "1" async-imap = { git = "https://github.com/async-email/async-imap", branch = "master", default-features = false, features = ["runtime-tokio"] } async-native-tls = { version = "0.4", default-features = false, features = ["runtime-tokio"] } async-smtp = { version = "0.5", default-features = false, features = ["smtp-transport", "socks5", "runtime-tokio"] } -trust-dns-resolver = "0.21" +trust-dns-resolver = "0.22" tokio = { version = "1", features = ["fs", "rt-multi-thread", "macros"] } tokio-tar = { version = "0.3" } # TODO: integrate tokio into async-tar backtrace = "0.3" @@ -47,7 +47,7 @@ num_cpus = "1.13" num-derive = "0.3" num-traits = "0.2" once_cell = "1.15.0" -percent-encoding = "2.0" +percent-encoding = "2.2" pgp = { version = "0.8", default-features = false } pretty_env_logger = { version = "0.4", optional = true } quick-xml = "0.23" diff --git a/deltachat-ffi/Cargo.toml b/deltachat-ffi/Cargo.toml index 8d52b53cd..d744f1736 100644 --- a/deltachat-ffi/Cargo.toml +++ b/deltachat-ffi/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "deltachat_ffi" -version = "1.96.0" +version = "1.97.0" description = "Deltachat FFI" authors = ["Delta Chat Developers (ML) "] edition = "2018" 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 a8c7f20f3..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 _) @@ -4515,7 +4603,7 @@ mod jsonrpc { return; } (*jsonrpc_instance).event_thread.abort(); - Box::from_raw(jsonrpc_instance); + drop(Box::from_raw(jsonrpc_instance)); } #[no_mangle] diff --git a/deltachat-jsonrpc/Cargo.toml b/deltachat-jsonrpc/Cargo.toml index 1eece97cf..65578f890 100644 --- a/deltachat-jsonrpc/Cargo.toml +++ b/deltachat-jsonrpc/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "deltachat-jsonrpc" -version = "1.96.0" +version = "1.97.0" description = "DeltaChat JSON-RPC API" authors = ["Delta Chat Developers (ML) "] edition = "2021" 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 1978b7aac..31f09e43f 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, @@ -24,7 +25,7 @@ use deltachat::{ use std::collections::BTreeMap; use std::sync::Arc; use std::{collections::HashMap, str::FromStr}; -use tokio::sync::RwLock; +use tokio::{fs, sync::RwLock}; use walkdir::WalkDir; use yerpc::rpc; @@ -34,7 +35,7 @@ pub mod events; pub mod types; use crate::api::types::chat_list::{get_chat_list_item_by_id, ChatListItemFetchResult}; -use crate::api::types::QrObject; +use crate::api::types::qr::QrObject; use types::account::Account; use types::chat::FullChat; @@ -915,6 +916,11 @@ impl CommandApi { MessageObject::from_message_id(&ctx, message_id).await } + async fn get_message_html(&self, account_id: u32, message_id: u32) -> Result> { + let ctx = self.get_context(account_id).await?; + MsgId::new(message_id).get_html(&ctx).await + } + async fn message_get_messages( &self, account_id: u32, @@ -1461,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 @@ -1495,6 +1518,63 @@ impl CommandApi { // that might get removed later again // --------------------------------------------- + async fn misc_get_sticker_folder(&self, account_id: u32) -> Result { + let ctx = self.get_context(account_id).await?; + let account_folder = ctx + .get_dbfile() + .parent() + .context("account folder not found")?; + let sticker_folder_path = account_folder.join("./stickers"); + fs::create_dir_all(&sticker_folder_path).await?; + sticker_folder_path + .to_str() + .map(|s| s.to_owned()) + .context("path conversion to string failed") + } + + /// for desktop, get stickers from stickers folder, + /// grouped by the folder they are in. + async fn misc_get_stickers(&self, account_id: u32) -> Result>> { + let ctx = self.get_context(account_id).await?; + let account_folder = ctx + .get_dbfile() + .parent() + .context("account folder not found")?; + let sticker_folder_path = account_folder.join("./stickers"); + fs::create_dir_all(&sticker_folder_path).await?; + let mut result = HashMap::new(); + + let mut packs = tokio::fs::read_dir(sticker_folder_path).await?; + while let Some(entry) = packs.next_entry().await? { + if !entry.file_type().await?.is_dir() { + continue; + } + let pack_name = entry.file_name().into_string().unwrap_or_default(); + let mut stickers = tokio::fs::read_dir(entry.path()).await?; + let mut sticker_paths = Vec::new(); + while let Some(sticker_entry) = stickers.next_entry().await? { + if !sticker_entry.file_type().await?.is_file() { + continue; + } + let sticker_name = sticker_entry.file_name().into_string().unwrap_or_default(); + if sticker_name.ends_with(".png") || sticker_name.ends_with(".webp") { + sticker_paths.push( + sticker_entry + .path() + .to_str() + .map(|s| s.to_owned()) + .context("path conversion to string failed")?, + ); + } + } + if !sticker_paths.is_empty() { + result.insert(pack_name, sticker_paths); + } + } + + Ok(result) + } + /// Returns the messageid of the sent message async fn misc_send_text_message( &self, 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..5f8b541bd 100644 --- a/deltachat-jsonrpc/src/api/types/mod.rs +++ b/deltachat-jsonrpc/src/api/types/mod.rs @@ -1,7 +1,3 @@ -use deltachat::qr::Qr; -use serde::Serialize; -use typescript_type_def::TypeDef; - pub mod account; pub mod chat; pub mod chat_list; @@ -9,6 +5,8 @@ pub mod contact; pub mod location; pub mod message; pub mod provider_info; +pub mod qr; +pub mod reactions; pub mod webxdc; pub fn color_int_to_hex_string(color: u32) -> String { @@ -22,213 +20,3 @@ fn maybe_empty_string_to_option(string: String) -> Option { Some(string) } } - -#[derive(Serialize, TypeDef)] -#[serde(rename = "Qr", rename_all = "camelCase")] -#[serde(tag = "type")] -pub enum QrObject { - AskVerifyContact { - contact_id: u32, - fingerprint: String, - invitenumber: String, - authcode: String, - }, - AskVerifyGroup { - grpname: String, - grpid: String, - contact_id: u32, - fingerprint: String, - invitenumber: String, - authcode: String, - }, - FprOk { - contact_id: u32, - }, - FprMismatch { - contact_id: Option, - }, - FprWithoutAddr { - fingerprint: String, - }, - Account { - domain: String, - }, - WebrtcInstance { - domain: String, - instance_pattern: String, - }, - Addr { - contact_id: u32, - draft: Option, - }, - Url { - url: String, - }, - Text { - text: String, - }, - WithdrawVerifyContact { - contact_id: u32, - fingerprint: String, - invitenumber: String, - authcode: String, - }, - WithdrawVerifyGroup { - grpname: String, - grpid: String, - contact_id: u32, - fingerprint: String, - invitenumber: String, - authcode: String, - }, - ReviveVerifyContact { - contact_id: u32, - fingerprint: String, - invitenumber: String, - authcode: String, - }, - ReviveVerifyGroup { - grpname: String, - grpid: String, - contact_id: u32, - fingerprint: String, - invitenumber: String, - authcode: String, - }, - Login { - address: String, - }, -} - -impl From for QrObject { - fn from(qr: Qr) -> Self { - match qr { - Qr::AskVerifyContact { - contact_id, - fingerprint, - invitenumber, - authcode, - } => { - let contact_id = contact_id.to_u32(); - let fingerprint = fingerprint.to_string(); - QrObject::AskVerifyContact { - contact_id, - fingerprint, - invitenumber, - authcode, - } - } - Qr::AskVerifyGroup { - grpname, - grpid, - contact_id, - fingerprint, - invitenumber, - authcode, - } => { - let contact_id = contact_id.to_u32(); - let fingerprint = fingerprint.to_string(); - QrObject::AskVerifyGroup { - grpname, - grpid, - contact_id, - fingerprint, - invitenumber, - authcode, - } - } - Qr::FprOk { contact_id } => { - let contact_id = contact_id.to_u32(); - QrObject::FprOk { contact_id } - } - Qr::FprMismatch { contact_id } => { - let contact_id = contact_id.map(|contact_id| contact_id.to_u32()); - QrObject::FprMismatch { contact_id } - } - Qr::FprWithoutAddr { fingerprint } => QrObject::FprWithoutAddr { fingerprint }, - Qr::Account { domain } => QrObject::Account { domain }, - Qr::WebrtcInstance { - domain, - instance_pattern, - } => QrObject::WebrtcInstance { - domain, - instance_pattern, - }, - Qr::Addr { contact_id, draft } => { - let contact_id = contact_id.to_u32(); - QrObject::Addr { contact_id, draft } - } - Qr::Url { url } => QrObject::Url { url }, - Qr::Text { text } => QrObject::Text { text }, - Qr::WithdrawVerifyContact { - contact_id, - fingerprint, - invitenumber, - authcode, - } => { - let contact_id = contact_id.to_u32(); - let fingerprint = fingerprint.to_string(); - QrObject::WithdrawVerifyContact { - contact_id, - fingerprint, - invitenumber, - authcode, - } - } - Qr::WithdrawVerifyGroup { - grpname, - grpid, - contact_id, - fingerprint, - invitenumber, - authcode, - } => { - let contact_id = contact_id.to_u32(); - let fingerprint = fingerprint.to_string(); - QrObject::WithdrawVerifyGroup { - grpname, - grpid, - contact_id, - fingerprint, - invitenumber, - authcode, - } - } - Qr::ReviveVerifyContact { - contact_id, - fingerprint, - invitenumber, - authcode, - } => { - let contact_id = contact_id.to_u32(); - let fingerprint = fingerprint.to_string(); - QrObject::ReviveVerifyContact { - contact_id, - fingerprint, - invitenumber, - authcode, - } - } - Qr::ReviveVerifyGroup { - grpname, - grpid, - contact_id, - fingerprint, - invitenumber, - authcode, - } => { - let contact_id = contact_id.to_u32(); - let fingerprint = fingerprint.to_string(); - QrObject::ReviveVerifyGroup { - grpname, - grpid, - contact_id, - fingerprint, - invitenumber, - authcode, - } - } - Qr::Login { address, .. } => QrObject::Login { address }, - } - } -} diff --git a/deltachat-jsonrpc/src/api/types/qr.rs b/deltachat-jsonrpc/src/api/types/qr.rs new file mode 100644 index 000000000..0a38d43ce --- /dev/null +++ b/deltachat-jsonrpc/src/api/types/qr.rs @@ -0,0 +1,213 @@ +use deltachat::qr::Qr; +use serde::Serialize; +use typescript_type_def::TypeDef; + +#[derive(Serialize, TypeDef)] +#[serde(rename = "Qr", rename_all = "camelCase")] +#[serde(tag = "type")] +pub enum QrObject { + AskVerifyContact { + contact_id: u32, + fingerprint: String, + invitenumber: String, + authcode: String, + }, + AskVerifyGroup { + grpname: String, + grpid: String, + contact_id: u32, + fingerprint: String, + invitenumber: String, + authcode: String, + }, + FprOk { + contact_id: u32, + }, + FprMismatch { + contact_id: Option, + }, + FprWithoutAddr { + fingerprint: String, + }, + Account { + domain: String, + }, + WebrtcInstance { + domain: String, + instance_pattern: String, + }, + Addr { + contact_id: u32, + draft: Option, + }, + Url { + url: String, + }, + Text { + text: String, + }, + WithdrawVerifyContact { + contact_id: u32, + fingerprint: String, + invitenumber: String, + authcode: String, + }, + WithdrawVerifyGroup { + grpname: String, + grpid: String, + contact_id: u32, + fingerprint: String, + invitenumber: String, + authcode: String, + }, + ReviveVerifyContact { + contact_id: u32, + fingerprint: String, + invitenumber: String, + authcode: String, + }, + ReviveVerifyGroup { + grpname: String, + grpid: String, + contact_id: u32, + fingerprint: String, + invitenumber: String, + authcode: String, + }, + Login { + address: String, + }, +} + +impl From for QrObject { + fn from(qr: Qr) -> Self { + match qr { + Qr::AskVerifyContact { + contact_id, + fingerprint, + invitenumber, + authcode, + } => { + let contact_id = contact_id.to_u32(); + let fingerprint = fingerprint.to_string(); + QrObject::AskVerifyContact { + contact_id, + fingerprint, + invitenumber, + authcode, + } + } + Qr::AskVerifyGroup { + grpname, + grpid, + contact_id, + fingerprint, + invitenumber, + authcode, + } => { + let contact_id = contact_id.to_u32(); + let fingerprint = fingerprint.to_string(); + QrObject::AskVerifyGroup { + grpname, + grpid, + contact_id, + fingerprint, + invitenumber, + authcode, + } + } + Qr::FprOk { contact_id } => { + let contact_id = contact_id.to_u32(); + QrObject::FprOk { contact_id } + } + Qr::FprMismatch { contact_id } => { + let contact_id = contact_id.map(|contact_id| contact_id.to_u32()); + QrObject::FprMismatch { contact_id } + } + Qr::FprWithoutAddr { fingerprint } => QrObject::FprWithoutAddr { fingerprint }, + Qr::Account { domain } => QrObject::Account { domain }, + Qr::WebrtcInstance { + domain, + instance_pattern, + } => QrObject::WebrtcInstance { + domain, + instance_pattern, + }, + Qr::Addr { contact_id, draft } => { + let contact_id = contact_id.to_u32(); + QrObject::Addr { contact_id, draft } + } + Qr::Url { url } => QrObject::Url { url }, + Qr::Text { text } => QrObject::Text { text }, + Qr::WithdrawVerifyContact { + contact_id, + fingerprint, + invitenumber, + authcode, + } => { + let contact_id = contact_id.to_u32(); + let fingerprint = fingerprint.to_string(); + QrObject::WithdrawVerifyContact { + contact_id, + fingerprint, + invitenumber, + authcode, + } + } + Qr::WithdrawVerifyGroup { + grpname, + grpid, + contact_id, + fingerprint, + invitenumber, + authcode, + } => { + let contact_id = contact_id.to_u32(); + let fingerprint = fingerprint.to_string(); + QrObject::WithdrawVerifyGroup { + grpname, + grpid, + contact_id, + fingerprint, + invitenumber, + authcode, + } + } + Qr::ReviveVerifyContact { + contact_id, + fingerprint, + invitenumber, + authcode, + } => { + let contact_id = contact_id.to_u32(); + let fingerprint = fingerprint.to_string(); + QrObject::ReviveVerifyContact { + contact_id, + fingerprint, + invitenumber, + authcode, + } + } + Qr::ReviveVerifyGroup { + grpname, + grpid, + contact_id, + fingerprint, + invitenumber, + authcode, + } => { + let contact_id = contact_id.to_u32(); + let fingerprint = fingerprint.to_string(); + QrObject::ReviveVerifyGroup { + grpname, + grpid, + contact_id, + fingerprint, + invitenumber, + authcode, + } + } + Qr::Login { address, .. } => QrObject::Login { address }, + } + } +} 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 4c7e79be0..c243b05d3 100644 --- a/deltachat-jsonrpc/typescript/generated/client.ts +++ b/deltachat-jsonrpc/typescript/generated/client.ts @@ -598,6 +598,11 @@ export class RawClient { } + public getMessageHtml(accountId: T.U32, messageId: T.U32): Promise<(string|null)> { + return (this._transport.request('get_message_html', [accountId, messageId] as RPC.Params)) as Promise<(string|null)>; + } + + public messageGetMessages(accountId: T.U32, messageIds: (T.U32)[]): Promise> { return (this._transport.request('message_get_messages', [accountId, messageIds] as RPC.Params)) as Promise>; } @@ -873,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; @@ -890,6 +907,19 @@ export class RawClient { return (this._transport.request('send_videochat_invitation', [accountId, chatId] as RPC.Params)) as Promise; } + + public miscGetStickerFolder(accountId: T.U32): Promise { + return (this._transport.request('misc_get_sticker_folder', [accountId] as RPC.Params)) as Promise; + } + + /** + * for desktop, get stickers from stickers folder, + * grouped by the folder they are in. + */ + public miscGetStickers(accountId: T.U32): Promise> { + return (this._transport.request('misc_get_stickers', [accountId] as RPC.Params)) as Promise>; + } + /** * Returns the messageid of the sent message */ diff --git a/deltachat-jsonrpc/typescript/generated/constants.ts b/deltachat-jsonrpc/typescript/generated/constants.ts new file mode 100644 index 000000000..3f50b6653 --- /dev/null +++ b/deltachat-jsonrpc/typescript/generated/constants.ts @@ -0,0 +1,201 @@ +// Generated! + +export enum C { + DC_CERTCK_ACCEPT_INVALID_CERTIFICATES = 3, + DC_CERTCK_AUTO = 0, + DC_CERTCK_STRICT = 1, + DC_CHAT_ID_ALLDONE_HINT = 7, + DC_CHAT_ID_ARCHIVED_LINK = 6, + DC_CHAT_ID_LAST_SPECIAL = 9, + DC_CHAT_ID_TRASH = 3, + DC_CHAT_TYPE_BROADCAST = 160, + DC_CHAT_TYPE_GROUP = 120, + DC_CHAT_TYPE_MAILINGLIST = 140, + DC_CHAT_TYPE_SINGLE = 100, + DC_CHAT_TYPE_UNDEFINED = 0, + DC_CONNECTIVITY_CONNECTED = 4000, + DC_CONNECTIVITY_CONNECTING = 2000, + DC_CONNECTIVITY_NOT_CONNECTED = 1000, + DC_CONNECTIVITY_WORKING = 3000, + DC_CONTACT_ID_DEVICE = 5, + DC_CONTACT_ID_INFO = 2, + DC_CONTACT_ID_LAST_SPECIAL = 9, + DC_CONTACT_ID_SELF = 1, + DC_GCL_ADD_ALLDONE_HINT = 4, + DC_GCL_ADD_SELF = 2, + DC_GCL_ARCHIVED_ONLY = 1, + DC_GCL_FOR_FORWARDING = 8, + DC_GCL_NO_SPECIALS = 2, + DC_GCL_VERIFIED_ONLY = 1, + DC_GCM_ADDDAYMARKER = 1, + DC_GCM_INFO_ONLY = 2, + DC_INFO_PROTECTION_DISABLED = 12, + DC_INFO_PROTECTION_ENABLED = 11, + DC_KEY_GEN_DEFAULT = 0, + DC_KEY_GEN_ED25519 = 2, + DC_KEY_GEN_RSA2048 = 1, + DC_LP_AUTH_NORMAL = 4, + DC_LP_AUTH_OAUTH2 = 2, + DC_MEDIA_QUALITY_BALANCED = 0, + DC_MEDIA_QUALITY_WORSE = 1, + DC_MSG_ID_DAYMARKER = 9, + DC_MSG_ID_LAST_SPECIAL = 9, + DC_MSG_ID_MARKER1 = 1, + DC_PROVIDER_STATUS_BROKEN = 3, + DC_PROVIDER_STATUS_OK = 1, + DC_PROVIDER_STATUS_PREPARATION = 2, + DC_SHOW_EMAILS_ACCEPTED_CONTACTS = 1, + DC_SHOW_EMAILS_ALL = 2, + DC_SHOW_EMAILS_OFF = 0, + DC_SOCKET_AUTO = 0, + DC_SOCKET_PLAIN = 3, + DC_SOCKET_SSL = 1, + DC_SOCKET_STARTTLS = 2, + DC_STATE_IN_FRESH = 10, + DC_STATE_IN_NOTICED = 13, + DC_STATE_IN_SEEN = 16, + DC_STATE_OUT_DELIVERED = 26, + DC_STATE_OUT_DRAFT = 19, + DC_STATE_OUT_FAILED = 24, + DC_STATE_OUT_MDN_RCVD = 28, + DC_STATE_OUT_PENDING = 20, + DC_STATE_OUT_PREPARING = 18, + DC_STATE_UNDEFINED = 0, + DC_STR_AC_SETUP_MSG_BODY = 43, + DC_STR_AC_SETUP_MSG_SUBJECT = 42, + DC_STR_ADD_MEMBER_BY_OTHER = 129, + DC_STR_ADD_MEMBER_BY_YOU = 128, + DC_STR_AEAP_ADDR_CHANGED = 122, + DC_STR_AEAP_EXPLANATION_AND_LINK = 123, + DC_STR_ARCHIVEDCHATS = 40, + DC_STR_AUDIO = 11, + DC_STR_BAD_TIME_MSG_BODY = 85, + DC_STR_BROADCAST_LIST = 115, + DC_STR_CANNOT_LOGIN = 60, + DC_STR_CANTDECRYPT_MSG_BODY = 29, + DC_STR_CONFIGURATION_FAILED = 84, + DC_STR_CONNECTED = 107, + DC_STR_CONNTECTING = 108, + DC_STR_CONTACT_NOT_VERIFIED = 36, + DC_STR_CONTACT_SETUP_CHANGED = 37, + DC_STR_CONTACT_VERIFIED = 35, + DC_STR_DEVICE_MESSAGES = 68, + DC_STR_DEVICE_MESSAGES_HINT = 70, + DC_STR_DOWNLOAD_AVAILABILITY = 100, + DC_STR_DRAFT = 3, + DC_STR_E2E_AVAILABLE = 25, + DC_STR_E2E_PREFERRED = 34, + DC_STR_ENCRYPTEDMSG = 24, + DC_STR_ENCR_NONE = 28, + DC_STR_ENCR_TRANSP = 27, + DC_STR_EPHEMERAL_DAY = 79, + DC_STR_EPHEMERAL_DAYS = 95, + DC_STR_EPHEMERAL_DISABLED = 75, + DC_STR_EPHEMERAL_FOUR_WEEKS = 81, + DC_STR_EPHEMERAL_HOUR = 78, + DC_STR_EPHEMERAL_HOURS = 94, + DC_STR_EPHEMERAL_MINUTE = 77, + DC_STR_EPHEMERAL_MINUTES = 93, + DC_STR_EPHEMERAL_SECONDS = 76, + DC_STR_EPHEMERAL_TIMER_1_DAY_BY_OTHER = 147, + DC_STR_EPHEMERAL_TIMER_1_DAY_BY_YOU = 146, + DC_STR_EPHEMERAL_TIMER_1_HOUR_BY_OTHER = 145, + DC_STR_EPHEMERAL_TIMER_1_HOUR_BY_YOU = 144, + DC_STR_EPHEMERAL_TIMER_1_MINUTE_BY_OTHER = 143, + DC_STR_EPHEMERAL_TIMER_1_MINUTE_BY_YOU = 142, + DC_STR_EPHEMERAL_TIMER_1_WEEK_BY_OTHER = 149, + DC_STR_EPHEMERAL_TIMER_1_WEEK_BY_YOU = 148, + DC_STR_EPHEMERAL_TIMER_DAYS_BY_OTHER = 155, + DC_STR_EPHEMERAL_TIMER_DAYS_BY_YOU = 154, + DC_STR_EPHEMERAL_TIMER_DISABLED_BY_OTHER = 139, + DC_STR_EPHEMERAL_TIMER_DISABLED_BY_YOU = 138, + DC_STR_EPHEMERAL_TIMER_HOURS_BY_OTHER = 153, + DC_STR_EPHEMERAL_TIMER_HOURS_BY_YOU = 152, + DC_STR_EPHEMERAL_TIMER_MINUTES_BY_OTHER = 151, + DC_STR_EPHEMERAL_TIMER_MINUTES_BY_YOU = 150, + DC_STR_EPHEMERAL_TIMER_SECONDS_BY_OTHER = 141, + DC_STR_EPHEMERAL_TIMER_SECONDS_BY_YOU = 140, + DC_STR_EPHEMERAL_TIMER_WEEKS_BY_OTHER = 157, + DC_STR_EPHEMERAL_TIMER_WEEKS_BY_YOU = 156, + DC_STR_EPHEMERAL_WEEK = 80, + DC_STR_EPHEMERAL_WEEKS = 96, + DC_STR_ERROR = 112, + DC_STR_ERROR_NO_NETWORK = 87, + DC_STR_FAILED_SENDING_TO = 74, + DC_STR_FILE = 12, + DC_STR_FINGERPRINTS = 30, + DC_STR_FORWARDED = 97, + DC_STR_GIF = 23, + DC_STR_GROUP_IMAGE_CHANGED_BY_OTHER = 127, + DC_STR_GROUP_IMAGE_CHANGED_BY_YOU = 126, + DC_STR_GROUP_IMAGE_DELETED_BY_OTHER = 135, + DC_STR_GROUP_IMAGE_DELETED_BY_YOU = 134, + DC_STR_GROUP_LEFT_BY_OTHER = 133, + DC_STR_GROUP_LEFT_BY_YOU = 132, + DC_STR_GROUP_NAME_CHANGED_BY_OTHER = 125, + DC_STR_GROUP_NAME_CHANGED_BY_YOU = 124, + DC_STR_IMAGE = 9, + DC_STR_INCOMING_MESSAGES = 103, + DC_STR_LAST_MSG_SENT_SUCCESSFULLY = 111, + DC_STR_LOCATION = 66, + DC_STR_LOCATION_ENABLED_BY_OTHER = 137, + DC_STR_LOCATION_ENABLED_BY_YOU = 136, + DC_STR_MESSAGES = 114, + DC_STR_MSGACTIONBYME = 63, + DC_STR_MSGACTIONBYUSER = 62, + DC_STR_MSGADDMEMBER = 17, + DC_STR_MSGDELMEMBER = 18, + DC_STR_MSGGROUPLEFT = 19, + DC_STR_MSGGRPIMGCHANGED = 16, + DC_STR_MSGGRPIMGDELETED = 33, + DC_STR_MSGGRPNAME = 15, + DC_STR_MSGLOCATIONDISABLED = 65, + DC_STR_MSGLOCATIONENABLED = 64, + DC_STR_NOMESSAGES = 1, + DC_STR_NOT_CONNECTED = 121, + DC_STR_NOT_SUPPORTED_BY_PROVIDER = 113, + DC_STR_ONE_MOMENT = 106, + DC_STR_OUTGOING_MESSAGES = 104, + DC_STR_PARTIAL_DOWNLOAD_MSG_BODY = 99, + DC_STR_PART_OF_TOTAL_USED = 116, + DC_STR_PROTECTION_DISABLED = 89, + DC_STR_PROTECTION_DISABLED_BY_OTHER = 161, + DC_STR_PROTECTION_DISABLED_BY_YOU = 160, + DC_STR_PROTECTION_ENABLED = 88, + DC_STR_PROTECTION_ENABLED_BY_OTHER = 159, + DC_STR_PROTECTION_ENABLED_BY_YOU = 158, + DC_STR_QUOTA_EXCEEDING_MSG_BODY = 98, + DC_STR_READRCPT = 31, + DC_STR_READRCPT_MAILBODY = 32, + DC_STR_REMOVE_MEMBER_BY_OTHER = 131, + DC_STR_REMOVE_MEMBER_BY_YOU = 130, + DC_STR_REPLY_NOUN = 90, + DC_STR_SAVED_MESSAGES = 69, + DC_STR_SECURE_JOIN_GROUP_QR_DESC = 120, + DC_STR_SECURE_JOIN_REPLIES = 118, + DC_STR_SECURE_JOIN_STARTED = 117, + DC_STR_SELF = 2, + DC_STR_SELF_DELETED_MSG_BODY = 91, + DC_STR_SENDING = 110, + DC_STR_SERVER_TURNED_OFF = 92, + DC_STR_SETUP_CONTACT_QR_DESC = 119, + DC_STR_STICKER = 67, + DC_STR_STORAGE_ON_DOMAIN = 105, + DC_STR_SUBJECT_FOR_NEW_CONTACT = 73, + DC_STR_SYNC_MSG_BODY = 102, + DC_STR_SYNC_MSG_SUBJECT = 101, + DC_STR_UNKNOWN_SENDER_FOR_CHAT = 72, + DC_STR_UPDATE_REMINDER_MSG_BODY = 86, + DC_STR_UPDATING = 109, + DC_STR_VIDEO = 10, + DC_STR_VIDEOCHAT_INVITATION = 82, + DC_STR_VIDEOCHAT_INVITE_MSG_BODY = 83, + DC_STR_VOICEMESSAGE = 7, + DC_STR_WELCOME_MESSAGE = 71, + DC_TEXT1_DRAFT = 1, + DC_TEXT1_SELF = 3, + DC_TEXT1_USERNAME = 2, + DC_VIDEOCHATTYPE_BASICWEBRTC = 1, + DC_VIDEOCHATTYPE_JITSI = 2, + DC_VIDEOCHATTYPE_UNKNOWN = 0, +} diff --git a/deltachat-jsonrpc/typescript/generated/events.ts b/deltachat-jsonrpc/typescript/generated/events.ts index 2f0fa5084..287a9150d 100644 --- a/deltachat-jsonrpc/typescript/generated/events.ts +++ b/deltachat-jsonrpc/typescript/generated/events.ts @@ -52,7 +52,7 @@ export type Event=(({ * should not be disturbed by a dialog or so. Instead, use a bubble or so. * * However, for ongoing processes (eg. configure()) - * or for functions that are expected to fail (eg. dc_continue_key_transfer()) + * or for functions that are expected to fail (eg. autocryptContinueKeyTransfer()) * it might be better to delay showing these events until the function has really * failed (returned false). It should be sufficient to report only the *last* error * in a messasge box then. @@ -61,9 +61,9 @@ export type Event=(({ /** * An action cannot be performed because the user is not in the group. * Reported eg. after a call to - * dc_set_chat_name(), dc_set_chat_profile_image(), - * dc_add_contact_to_chat(), dc_remove_contact_from_chat(), - * dc_send_text_msg() or another sending function. + * setChatName(), setChatProfileImage(), + * addContactToChat(), removeContactFromChat(), + * and messages sending functions. */ "type":"ErrorSelfNotInGroup";}&{"msg":string;})|({ /** @@ -73,10 +73,14 @@ export type Event=(({ * - Chats created, deleted or archived * - A draft has been set * - * `chat_id` is set if only a single chat is affected by the changes, otherwise 0. - * `msg_id` is set if only a single message is affected by the changes, otherwise 0. + * `chatId` is set if only a single chat is affected by the changes, otherwise 0. + * `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. @@ -91,24 +95,24 @@ export type Event=(({ "type":"MsgsNoticed";}&{"chatId":U32;})|({ /** * A single message is sent successfully. State changed from DC_STATE_OUT_PENDING to - * DC_STATE_OUT_DELIVERED, see dc_msg_get_state(). + * DC_STATE_OUT_DELIVERED, see `Message.state`. */ "type":"MsgDelivered";}&{"chatId":U32;"msgId":U32;})|({ /** * A single message could not be sent. State changed from DC_STATE_OUT_PENDING or DC_STATE_OUT_DELIVERED to - * DC_STATE_OUT_FAILED, see dc_msg_get_state(). + * DC_STATE_OUT_FAILED, see `Message.state`. */ "type":"MsgFailed";}&{"chatId":U32;"msgId":U32;})|({ /** * A single message is read by the receiver. State changed from DC_STATE_OUT_DELIVERED to - * DC_STATE_OUT_MDN_RCVD, see dc_msg_get_state(). + * DC_STATE_OUT_MDN_RCVD, see `Message.state`. */ "type":"MsgRead";}&{"chatId":U32;"msgId":U32;})|({ /** * Chat changed. The name or the image of a chat group was changed or members were added or removed. * Or the verify state of a chat has changed. - * See dc_set_chat_name(), dc_set_chat_profile_image(), dc_add_contact_to_chat() - * and dc_remove_contact_from_chat(). + * See setChatName(), setChatProfileImage(), addContactToChat() + * and removeContactFromChat(). * * This event does not include ephemeral timer modification, which * is a separate event. @@ -129,7 +133,7 @@ export type Event=(({ * * @param data1 (u32) contact_id of the contact for which the location has changed. * If the locations of several contacts have been changed, - * eg. after calling dc_delete_all_locations(), this parameter is set to `None`. + * this parameter is set to `None`. */ "type":"LocationChanged";}&{"contactId":(U32|null);})|({ /** @@ -168,7 +172,7 @@ export type Event=(({ * (Alice, the person who shows the QR code). * * These events are typically sent after a joiner has scanned the QR code - * generated by dc_get_securejoin_qr(). + * generated by getChatSecurejoinQrCodeSvg(). * * @param data1 (int) ID of the contact that wants to join. * @param data2 (int) Progress as: @@ -181,7 +185,7 @@ export type Event=(({ /** * Progress information of a secure-join handshake from the view of the joiner * (Bob, the person who scans the QR code). - * The events are typically sent while dc_join_securejoin(), which + * The events are typically sent while secureJoin(), which * may take some time, is executed. * @param data1 (int) ID of the inviting contact. * @param data2 (int) Progress as: @@ -192,8 +196,8 @@ export type Event=(({ /** * The connectivity to the server changed. * This means that you should refresh the connectivity view - * and possibly the connectivtiy HTML; see dc_get_connectivity() and - * dc_get_connectivity_html() for details. + * and possibly the connectivtiy HTML; see getConnectivity() and + * getConnectivityHtml() for details. */ "type":"ConnectivityChanged";}|{"type":"SelfavatarChanged";}|({"type":"WebxdcStatusUpdate";}&{"msgId":U32;"statusUpdateSerial":U32;})|({ /** diff --git a/deltachat-jsonrpc/typescript/generated/types.ts b/deltachat-jsonrpc/typescript/generated/types.ts index a28cd136d..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)[],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,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/deltachat-jsonrpc/typescript/package.json b/deltachat-jsonrpc/typescript/package.json index 17124b5b4..8aa538f09 100644 --- a/deltachat-jsonrpc/typescript/package.json +++ b/deltachat-jsonrpc/typescript/package.json @@ -28,7 +28,7 @@ "main": "dist/deltachat.js", "name": "@deltachat/jsonrpc-client", "scripts": { - "build": "run-s generate-bindings build:tsc build:bundle", + "build": "run-s generate-bindings extract-constants build:tsc build:bundle", "build:bundle": "esbuild --format=esm --bundle dist/deltachat.js --outfile=dist/deltachat.bundle.js", "build:tsc": "tsc", "docs": "typedoc --out docs deltachat.ts", @@ -37,6 +37,7 @@ "example:dev": "esbuild example/example.ts --bundle --outfile=dist/example.bundle.js --servedir=.", "example:start": "http-server .", "generate-bindings": "cargo test", + "extract-constants": "node ./scripts/generate-constants.js", "prettier:check": "prettier --check **.ts", "prettier:fix": "prettier --write **.ts", "test": "run-s test:prepare test:run-coverage test:report-coverage", @@ -47,5 +48,5 @@ }, "type": "module", "types": "dist/deltachat.d.ts", - "version": "1.96.0" + "version": "1.97.0" } \ No newline at end of file diff --git a/deltachat-jsonrpc/typescript/scripts/generate-constants.js b/deltachat-jsonrpc/typescript/scripts/generate-constants.js new file mode 100755 index 000000000..525a9d42b --- /dev/null +++ b/deltachat-jsonrpc/typescript/scripts/generate-constants.js @@ -0,0 +1,53 @@ +#!/usr/bin/env node +import { readFileSync, writeFileSync } from "fs"; +import { resolve } from "path"; +import { fileURLToPath } from "url"; +import { dirname } from "path"; +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +const data = []; +const header = resolve(__dirname, "../../../deltachat-ffi/deltachat.h"); + +console.log("Generating constants..."); + +const header_data = readFileSync(header, "UTF-8"); +const regex = /^#define\s+(\w+)\s+(\w+)/gm; +let match; +while (null != (match = regex.exec(header_data))) { + const key = match[1]; + const value = parseInt(match[2]); + if (!isNaN(value)) { + data.push({ key, value }); + } +} + +const constants = data + .filter( + ({ key }) => key.toUpperCase()[0] === key[0] // check if define name is uppercase + ) + .sort((lhs, rhs) => { + if (lhs.key < rhs.key) return -1; + else if (lhs.key > rhs.key) return 1; + return 0; + }) + .filter(({ key }) => { + // filter out what we don't need it + return !( + key.startsWith("DC_EVENT_") || + key.startsWith("DC_IMEX_") || + key.startsWith("DC_CHAT_VISIBILITY") || + key.startsWith("DC_DOWNLOAD") || + (key.startsWith("DC_MSG") && !key.startsWith("DC_MSG_ID")) || + key.startsWith("DC_QR_") + ); + }) + .map((row) => { + return ` ${row.key}: ${row.value}`; + }) + .join(",\n"); + +writeFileSync( + resolve(__dirname, "../generated/constants.ts"), + `// Generated!\n\nexport enum C {\n${constants.replace(/:/g, " =")},\n}\n` +); diff --git a/deltachat-jsonrpc/typescript/src/client.ts b/deltachat-jsonrpc/typescript/src/client.ts index 993ced6b4..8da65c055 100644 --- a/deltachat-jsonrpc/typescript/src/client.ts +++ b/deltachat-jsonrpc/typescript/src/client.ts @@ -28,6 +28,7 @@ type ContextEvents = { ALL: (event: Event) => void } & { }; export type DcEvent = Event; +export type DcEventType = Extract export class BaseDeltaChat< Transport extends BaseTransport diff --git a/deltachat-jsonrpc/typescript/src/lib.ts b/deltachat-jsonrpc/typescript/src/lib.ts index 153c1d6a2..473d2bd33 100644 --- a/deltachat-jsonrpc/typescript/src/lib.ts +++ b/deltachat-jsonrpc/typescript/src/lib.ts @@ -4,3 +4,4 @@ export * from "../generated/events.js"; export { RawClient } from "../generated/client.js"; export * from "./client.js"; export * as yerpc from "yerpc"; +export { C } from "../generated/constants.js"; 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/node/test/test.js b/node/test/test.js index ee9aa3bad..c15f5e7d3 100644 --- a/node/test/test.js +++ b/node/test/test.js @@ -89,7 +89,11 @@ describe('JSON RPC', function () { const { dc } = DeltaChat.newTemporary() let promise_resolve const promise = new Promise((res, _rej) => { - promise_resolve = res + promise_resolve = (response) => { + // ignore events + const answer = JSON.parse(response) + if (answer['method'] !== 'event') res(answer) + } }) dc.startJsonRpcHandler(promise_resolve) dc.jsonRpcRequest( @@ -106,7 +110,7 @@ describe('JSON RPC', function () { id: 2, result: [1], }, - JSON.parse(await promise) + await promise ) dc.close() }) diff --git a/package.json b/package.json index 4b56c86e6..9e2c316cb 100644 --- a/package.json +++ b/package.json @@ -57,8 +57,8 @@ "prebuildify": "cd node && prebuildify -t 16.13.0 --napi --strip --postinstall \"node scripts/postinstall.js --prebuild\"", "test": "npm run test:lint && npm run test:mocha", "test:lint": "npm run lint", - "test:mocha": "mocha -r esm node/test/test.js --growl --reporter=spec --bail" + "test:mocha": "mocha -r esm node/test/test.js --growl --reporter=spec --bail --exit" }, "types": "node/dist/index.d.ts", - "version": "1.96.0" + "version": "1.97.0" } \ No newline at end of file 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/download.rs b/src/download.rs index e64c26d4e..a05a35e53 100644 --- a/src/download.rs +++ b/src/download.rs @@ -11,7 +11,7 @@ use crate::imap::{Imap, ImapActionResult}; use crate::job::{self, Action, Job, Status}; use crate::message::{Message, MsgId, Viewtype}; use crate::mimeparser::{MimeMessage, Part}; -use crate::param::{Param, Params}; +use crate::param::Params; use crate::tools::time; use crate::{job_try, stock_str, EventType}; use std::cmp::max; @@ -69,42 +69,6 @@ impl Context { Ok(Some(max(MIN_DOWNLOAD_LIMIT, download_limit as u32))) } } - - // Merges the two messages to `placeholder_msg_id`; - // `full_msg_id` is no longer used afterwards. - pub(crate) async fn merge_messages( - &self, - full_msg_id: MsgId, - placeholder_msg_id: MsgId, - ) -> Result<()> { - let placeholder = Message::load_from_db(self, placeholder_msg_id).await?; - self.sql - .transaction(move |transaction| { - transaction - .execute("DELETE FROM msgs WHERE id=?;", paramsv![placeholder_msg_id])?; - transaction.execute( - "UPDATE msgs SET id=? WHERE id=?", - paramsv![placeholder_msg_id, full_msg_id], - )?; - Ok(()) - }) - .await?; - let mut full = Message::load_from_db(self, placeholder_msg_id).await?; - - for key in [ - Param::WebxdcSummary, - Param::WebxdcSummaryTimestamp, - Param::WebxdcDocument, - Param::WebxdcDocumentTimestamp, - ] { - if let Some(value) = placeholder.param.get(key) { - full.param.set(key, value); - } - } - full.update_param(self).await?; - - Ok(()) - } } impl MsgId { 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/imap.rs b/src/imap.rs index 08042f5b2..b732bfbd9 100644 --- a/src/imap.rs +++ b/src/imap.rs @@ -2157,8 +2157,8 @@ pub(crate) async fn set_uid_next(context: &Context, folder: &str, uid_next: u32) .sql .execute( "INSERT INTO imap_sync (folder, uid_next) VALUES (?,?) - ON CONFLICT(folder) DO UPDATE SET uid_next=? WHERE folder=?;", - paramsv![folder, uid_next, uid_next, folder], + ON CONFLICT(folder) DO UPDATE SET uid_next=excluded.uid_next", + paramsv![folder, uid_next], ) .await?; Ok(()) @@ -2189,8 +2189,8 @@ pub(crate) async fn set_uidvalidity( .sql .execute( "INSERT INTO imap_sync (folder, uidvalidity) VALUES (?,?) - ON CONFLICT(folder) DO UPDATE SET uidvalidity=? WHERE folder=?;", - paramsv![folder, uidvalidity, uidvalidity, folder], + ON CONFLICT(folder) DO UPDATE SET uidvalidity=excluded.uidvalidity", + paramsv![folder, uidvalidity], ) .await?; Ok(()) @@ -2212,8 +2212,8 @@ pub(crate) async fn set_modseq(context: &Context, folder: &str, modseq: u64) -> .sql .execute( "INSERT INTO imap_sync (folder, modseq) VALUES (?,?) - ON CONFLICT(folder) DO UPDATE SET modseq=? WHERE folder=?;", - paramsv![folder, modseq, modseq, folder], + ON CONFLICT(folder) DO UPDATE SET modseq=excluded.modseq", + paramsv![folder, modseq], ) .await?; Ok(()) diff --git a/src/lib.rs b/src/lib.rs index aa7da4797..8912edacd 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -104,6 +104,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 3f5deafd5..2a08e4ed4 100644 --- a/src/mimeparser.rs +++ b/src/mimeparser.rs @@ -557,7 +557,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); } @@ -919,6 +922,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, @@ -952,6 +956,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, @@ -1650,6 +1678,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 @@ -3335,4 +3366,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..f463e3f00 --- /dev/null +++ b/src/reaction.rs @@ -0,0 +1,551 @@ +//! # 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::download::DownloadState; + use crate::message::MessageState; + use crate::receive_imf::{receive_imf, receive_imf_inner}; + 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(()) + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn test_partial_download_and_reaction() -> Result<()> { + let alice = TestContext::new_alice().await; + let bob = TestContext::new_bob().await; + + alice + .create_chat_with_contact("Bob", "bob@example.net") + .await; + + let msg_header = "From: Bob \n\ + To: Alice \n\ + Chat-Version: 1.0\n\ + Subject: subject\n\ + Message-ID: \n\ + Date: Sun, 14 Nov 2021 00:10:00 +0000\ + Content-Type: text/plain"; + let msg_full = format!("{}\n\n100k text...", msg_header); + + // Alice downloads message from Bob partially. + let alice_received_message = receive_imf_inner( + &alice, + "first@example.org", + msg_header.as_bytes(), + false, + Some(100000), + false, + ) + .await? + .unwrap(); + let alice_msg_id = *alice_received_message.msg_ids.get(0).unwrap(); + + // Bob downloads own message on the other device. + let bob_received_message = receive_imf(&bob, msg_full.as_bytes(), false) + .await? + .unwrap(); + let bob_msg_id = *bob_received_message.msg_ids.get(0).unwrap(); + + // Bob reacts to own message. + send_reaction(&bob, bob_msg_id, "👍").await.unwrap(); + let bob_reaction_msg = bob.pop_sent_msg().await; + + // Alice receives a reaction. + alice.recv_msg_opt(&bob_reaction_msg).await.unwrap(); + + let reactions = get_msg_reactions(&alice, alice_msg_id).await?; + assert_eq!(reactions.to_string(), "👍1"); + let msg = Message::load_from_db(&alice, alice_msg_id).await?; + assert_eq!(msg.download_state(), DownloadState::Available); + + // Alice downloads full message. + receive_imf_inner( + &alice, + "first@example.org", + msg_full.as_bytes(), + false, + None, + false, + ) + .await?; + + // Check that reaction is still on the message after full download. + let msg = Message::load_from_db(&alice, alice_msg_id).await?; + assert_eq!(msg.download_state(), DownloadState::Done); + let reactions = get_msg_reactions(&alice, alice_msg_id).await?; + assert_eq!(reactions.to_string(), "👍1"); + + Ok(()) + } +} diff --git a/src/receive_imf.rs b/src/receive_imf.rs index 3880452ec..ddf33ab35 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; @@ -404,7 +405,7 @@ async fn add_parts( from_id: ContactId, seen: bool, is_partial_download: Option, - replace_msg_id: Option, + mut replace_msg_id: Option, fetching_existing_messages: bool, prevent_rename: bool, ) -> Result { @@ -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,11 +1057,41 @@ 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 param = part.param.clone(); + if is_system_message != SystemMessage::Unknown { + param.set_int(Param::Cmd, is_system_message as i32); + } + if let Some(replace_msg_id) = replace_msg_id { + let placeholder = Message::load_from_db(context, replace_msg_id).await?; + for key in [ + Param::WebxdcSummary, + Param::WebxdcSummaryTimestamp, + Param::WebxdcDocument, + Param::WebxdcDocumentTimestamp, + ] { + if let Some(value) = placeholder.param.get(key) { + param.set(key, value); + } + } + } + let mut txt_raw = "".to_string(); let mut stmt = conn.prepare_cached( r#" INSERT INTO msgs ( + id, rfc724_mid, chat_id, from_id, to_id, timestamp, timestamp_sent, timestamp_rcvd, type, state, msgrmsg, @@ -1067,13 +1101,22 @@ INSERT INTO msgs ephemeral_timestamp, download_state, hop_info ) VALUES ( + ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? - ); + ) +ON CONFLICT (id) DO UPDATE +SET rfc724_mid=excluded.rfc724_mid, chat_id=excluded.chat_id, + from_id=excluded.from_id, to_id=excluded.to_id, timestamp=excluded.timestamp, timestamp_sent=excluded.timestamp_sent, + timestamp_rcvd=excluded.timestamp_rcvd, type=excluded.type, state=excluded.state, msgrmsg=excluded.msgrmsg, + txt=excluded.txt, subject=excluded.subject, txt_raw=excluded.txt_raw, param=excluded.param, + bytes=excluded.bytes, mime_headers=excluded.mime_headers, mime_in_reply_to=excluded.mime_in_reply_to, + mime_references=excluded.mime_references, mime_modified=excluded.mime_modified, error=excluded.error, ephemeral_timer=excluded.ephemeral_timer, + ephemeral_timestamp=excluded.ephemeral_timestamp, download_state=excluded.download_state, hop_info=excluded.hop_info "#, )?; @@ -1095,11 +1138,6 @@ INSERT INTO msgs txt_raw = format!("{}\n\n{}", subject, msg_raw); } - let mut param = part.param.clone(); - if is_system_message != SystemMessage::Unknown { - param.set_int(Param::Cmd, is_system_message as i32); - } - let ephemeral_timestamp = if in_fresh { 0 } else { @@ -1113,9 +1151,10 @@ 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![ + replace_msg_id, rfc724_mid, if trash { DC_CHAT_ID_TRASH } else { chat_id }, if trash { ContactId::UNDEFINED } else { from_id }, @@ -1154,6 +1193,10 @@ INSERT INTO msgs }, mime_parser.hop_info ])?; + + // We only replace placeholder with a first part, + // afterwards insert additional parts. + replace_msg_id = None; let row_id = conn.last_insert_rowid(); drop(stmt); @@ -1162,14 +1205,8 @@ INSERT INTO msgs drop(conn); if let Some(replace_msg_id) = replace_msg_id { - if let Some(created_msg_id) = created_db_entries.pop() { - context - .merge_messages(created_msg_id, replace_msg_id) - .await?; - created_db_entries.push(replace_msg_id); - } else { - replace_msg_id.delete_from_db(context).await?; - } + // "Replace" placeholder with a message that has no parts. + replace_msg_id.delete_from_db(context).await?; } chat_id.unarchive_if_not_muted(context).await?; diff --git a/src/sql.rs b/src/sql.rs index 0a56ada1f..ce31b5995 100644 --- a/src/sql.rs +++ b/src/sql.rs @@ -396,7 +396,7 @@ impl Sql { } /// Used for executing `SELECT COUNT` statements only. Returns the resulting count. - pub async fn count(&self, query: &str, params: impl rusqlite::Params) -> anyhow::Result { + pub async fn count(&self, query: &str, params: impl rusqlite::Params) -> Result { let count: isize = self.query_row(query, params, |row| row.get(0)).await?; Ok(usize::try_from(count)?) } @@ -429,10 +429,10 @@ impl Sql { /// /// If the function returns an error, the transaction will be rolled back. If it does not return an /// error, the transaction will be committed. - pub async fn transaction(&self, callback: G) -> anyhow::Result + pub async fn transaction(&self, callback: G) -> Result where H: Send + 'static, - G: Send + 'static + FnOnce(&mut rusqlite::Transaction<'_>) -> anyhow::Result, + G: Send + 'static + FnOnce(&mut rusqlite::Transaction<'_>) -> Result, { let mut conn = self.get_conn().await?; tokio::task::block_in_place(move || { @@ -453,7 +453,7 @@ impl Sql { } /// Query the database if the requested table already exists. - pub async fn table_exists(&self, name: &str) -> anyhow::Result { + pub async fn table_exists(&self, name: &str) -> Result { let conn = self.get_conn().await?; tokio::task::block_in_place(move || { let mut exists = false; @@ -468,7 +468,7 @@ impl Sql { } /// Check if a column exists in a given table. - pub async fn col_exists(&self, table_name: &str, col_name: &str) -> anyhow::Result { + pub async fn col_exists(&self, table_name: &str, col_name: &str) -> Result { let conn = self.get_conn().await?; tokio::task::block_in_place(move || { let mut exists = false; @@ -492,7 +492,7 @@ impl Sql { sql: &str, params: impl rusqlite::Params, f: F, - ) -> anyhow::Result> + ) -> Result> where F: FnOnce(&rusqlite::Row) -> rusqlite::Result, { @@ -516,7 +516,7 @@ impl Sql { &self, query: &str, params: impl rusqlite::Params, - ) -> anyhow::Result> + ) -> Result> where T: rusqlite::types::FromSql, { diff --git a/src/sql/migrations.rs b/src/sql/migrations.rs index 7bcf0a6f2..a32bd0abd 100644 --- a/src/sql/migrations.rs +++ b/src/sql/migrations.rs @@ -597,9 +597,22 @@ 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?; + } + if dbversion < 93 { sql.execute_migration( "CREATE TABLE sending_domains(domain TEXT PRIMARY KEY, dkim_works INTEGER DEFAULT 0);", - 92, + 93, ) .await?; } 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))