diff --git a/deltachat-ffi/deltachat.h b/deltachat-ffi/deltachat.h index edff3dfb2..66ae3b2fd 100644 --- a/deltachat-ffi/deltachat.h +++ b/deltachat-ffi/deltachat.h @@ -1974,6 +1974,36 @@ void dc_delete_msgs (dc_context_t* context, const uint3 void dc_forward_msgs (dc_context_t* context, const uint32_t* msg_ids, int msg_cnt, uint32_t chat_id); +/** + * Save a copy of messages in "Saved Messages". + * + * In contrast to forwarding messages, + * information as author, date and origin are preserved. + * The action completes locally, so "Saved Messages" do not show sending errors in case one is offline. + * Still, a sync message is emitted, so that other devices will save the same message, + * as long as not deleted before. + * + * To check if a message was saved, use dc_msg_get_saved_msg_id(), + * UI may show an indicator and offer an "Unsave" instead of a "Save" button then. + * + * The other way round, from inside the "Saved Messages" chat, + * UI may show the indicator and "Unsave" button checking dc_msg_get_original_msg_id() + * and offer a button to go the original message. + * + * "Unsave" is done by deleting the saved message. + * Webxdc updates are not copied on purpose. + * + * For performance reasons, esp. when saving lots of messages, + * UI should call this function from a background thread. + * + * @memberof dc_context_t + * @param context The context object. + * @param msg_ids An array of uint32_t containing all message IDs that should be saved. + * @param msg_cnt The number of messages IDs in the msg_ids array. + */ +void dc_save_msgs (dc_context_t* context, const uint32_t* msg_ids, int msg_cnt); + + /** * Resend messages and make information available for newly added chat members. * Resending sends out the original message, however, recipients and webxdc-status may differ. @@ -4868,6 +4898,33 @@ dc_msg_t* dc_msg_get_quoted_msg (const dc_msg_t* msg); dc_msg_t* dc_msg_get_parent (const dc_msg_t* msg); +/** + * Get original message ID for a saved message from the "Saved Messages" chat. + * + * Can be used by UI to show a button to go the original message + * and an option to "Unsave" the message. + * + * @param msg The message object. Usually, this refers to a a message inside "Saved Messages". + * @return The message ID of the original message. + * 0 if the given message object is not a "Saved Message" + * or if the original message does no longer exist. + */ +uint32_t dc_msg_get_original_msg_id (const dc_msg_t* msg); + + +/** + * Check if a message was saved and return its ID inside "Saved Messages". + * + * Deleting the returned message will un-save the message. + * The state "is saved" can be used to show some icon to indicate that a message was saved. + * + * @param msg The message object. Usually, this refers to a a message outside "Saved Messages". + * @return The message ID inside "Saved Messages", if any. + * 0 if the given message object is not saved. + */ +uint32_t dc_msg_get_saved_msg_id (const dc_msg_t* msg); + + /** * Force the message to be sent in plain text. * diff --git a/deltachat-ffi/src/lib.rs b/deltachat-ffi/src/lib.rs index bef03049f..aaaf8a4ab 100644 --- a/deltachat-ffi/src/lib.rs +++ b/deltachat-ffi/src/lib.rs @@ -1979,6 +1979,26 @@ pub unsafe extern "C" fn dc_forward_msgs( }) } +#[no_mangle] +pub unsafe extern "C" fn dc_save_msgs( + context: *mut dc_context_t, + msg_ids: *const u32, + msg_cnt: libc::c_int, +) { + if context.is_null() || msg_ids.is_null() || msg_cnt <= 0 { + eprintln!("ignoring careless call to dc_save_msgs()"); + return; + } + let msg_ids = convert_and_prune_message_ids(msg_ids, msg_cnt); + let ctx = &*context; + + block_on(async move { + chat::save_msgs(ctx, &msg_ids[..]) + .await + .unwrap_or_log_default(ctx, "Failed to save message") + }) +} + #[no_mangle] pub unsafe extern "C" fn dc_resend_msgs( context: *mut dc_context_t, @@ -3980,6 +4000,48 @@ pub unsafe extern "C" fn dc_msg_get_parent(msg: *const dc_msg_t) -> *mut dc_msg_ } } +#[no_mangle] +pub unsafe extern "C" fn dc_msg_get_original_msg_id(msg: *const dc_msg_t) -> u32 { + if msg.is_null() { + eprintln!("ignoring careless call to dc_msg_get_original_msg_id()"); + return 0; + } + let ffi_msg: &MessageWrapper = &*msg; + let context = &*ffi_msg.context; + block_on(async move { + ffi_msg + .message + .get_original_msg_id(context) + .await + .context("failed to get original message") + .log_err(context) + .unwrap_or_default() + .map(|id| id.to_u32()) + .unwrap_or(0) + }) +} + +#[no_mangle] +pub unsafe extern "C" fn dc_msg_get_saved_msg_id(msg: *const dc_msg_t) -> u32 { + if msg.is_null() { + eprintln!("ignoring careless call to dc_msg_get_saved_msg_id()"); + return 0; + } + let ffi_msg: &MessageWrapper = &*msg; + let context = &*ffi_msg.context; + block_on(async move { + ffi_msg + .message + .get_saved_msg_id(context) + .await + .context("failed to get original message") + .log_err(context) + .unwrap_or_default() + .map(|id| id.to_u32()) + .unwrap_or(0) + }) +} + #[no_mangle] pub unsafe extern "C" fn dc_msg_force_plaintext(msg: *mut dc_msg_t) { if msg.is_null() { diff --git a/src/chat.rs b/src/chat.rs index 89d4f3e81..8c501b0fa 100644 --- a/src/chat.rs +++ b/src/chat.rs @@ -4209,6 +4209,80 @@ pub async fn forward_msgs(context: &Context, msg_ids: &[MsgId], chat_id: ChatId) Ok(()) } +/// Save a copy of the message in "Saved Messages" +/// and send a sync messages so that other devices save the message as well, unless deleted there. +pub async fn save_msgs(context: &Context, msg_ids: &[MsgId]) -> Result<()> { + for src_msg_id in msg_ids { + let dest_rfc724_mid = create_outgoing_rfc724_mid(); + let src_rfc724_mid = save_copy_in_self_talk(context, src_msg_id, &dest_rfc724_mid).await?; + context + .add_sync_item(SyncData::SaveMessage { + src: src_rfc724_mid, + dest: dest_rfc724_mid, + }) + .await?; + } + context.send_sync_msg().await?; + Ok(()) +} + +/// Saves a copy of the given message in "Saved Messages" using the given RFC724 id. +/// To allow UIs to have a "show in context" button, +/// the copy contains a reference to the original message +/// as well as to the original chat in case the original message gets deleted. +/// Returns data needed to add a `SaveMessage` sync item. +pub(crate) async fn save_copy_in_self_talk( + context: &Context, + src_msg_id: &MsgId, + dest_rfc724_mid: &String, +) -> Result { + let dest_chat_id = ChatId::create_for_contact(context, ContactId::SELF).await?; + let mut msg = Message::load_from_db(context, *src_msg_id).await?; + msg.param.remove(Param::Cmd); + msg.param.remove(Param::WebxdcDocument); + msg.param.remove(Param::WebxdcDocumentTimestamp); + msg.param.remove(Param::WebxdcSummary); + msg.param.remove(Param::WebxdcSummaryTimestamp); + + if !msg.original_msg_id.is_unset() { + bail!("message already saved."); + } + + let copy_fields = "from_id, to_id, timestamp_sent, timestamp_rcvd, type, txt, txt_raw, \ + mime_modified, mime_headers, mime_compressed, mime_in_reply_to, subject, msgrmsg"; + let row_id = context + .sql + .insert( + &format!( + "INSERT INTO msgs ({copy_fields}, chat_id, rfc724_mid, state, timestamp, param, starred) \ + SELECT {copy_fields}, ?, ?, ?, ?, ?, ? \ + FROM msgs WHERE id=?;" + ), + ( + dest_chat_id, + dest_rfc724_mid, + if msg.from_id == ContactId::SELF { + MessageState::OutDelivered + } else { + MessageState::InSeen + }, + create_smeared_timestamp(context), + msg.param.to_string(), + src_msg_id, + src_msg_id, + ), + ) + .await?; + let dest_msg_id = MsgId::new(row_id.try_into()?); + + context.emit_msgs_changed(msg.chat_id, *src_msg_id); + context.emit_msgs_changed(dest_chat_id, dest_msg_id); + chatlist_events::emit_chatlist_changed(context); + chatlist_events::emit_chatlist_item_changed(context, dest_chat_id); + + Ok(msg.rfc724_mid) +} + /// Resends given messages with the same Message-ID. /// /// This is primarily intended to make existing webxdcs available to new chat members. @@ -4703,7 +4777,7 @@ mod tests { use crate::chatlist::get_archived_cnt; use crate::constants::{DC_GCL_ARCHIVED_ONLY, DC_GCL_NO_SPECIALS}; use crate::headerdef::HeaderDef; - use crate::message::delete_msgs; + use crate::message::{delete_msgs, MessengerMessage}; use crate::receive_imf::receive_imf; use crate::test_utils::{sync, TestContext, TestContextManager, TimeShiftFalsePositiveNote}; use strum::IntoEnumIterator; @@ -6836,6 +6910,136 @@ mod tests { Ok(()) } + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn test_save_msgs() -> Result<()> { + let alice = TestContext::new_alice().await; + let bob = TestContext::new_bob().await; + let alice_chat = alice.create_chat(&bob).await; + + let sent = alice.send_text(alice_chat.get_id(), "hi, bob").await; + let sent_msg = Message::load_from_db(&alice, sent.sender_msg_id).await?; + assert!(sent_msg.get_saved_msg_id(&alice).await?.is_none()); + assert!(sent_msg.get_original_msg_id(&alice).await?.is_none()); + + let self_chat = alice.get_self_chat().await; + save_msgs(&alice, &[sent.sender_msg_id]).await?; + + let saved_msg = alice.get_last_msg_in(self_chat.id).await; + assert_ne!(saved_msg.get_id(), sent.sender_msg_id); + assert!(saved_msg.get_saved_msg_id(&alice).await?.is_none()); + assert_eq!( + saved_msg.get_original_msg_id(&alice).await?.unwrap(), + sent.sender_msg_id + ); + assert_eq!(saved_msg.get_text(), "hi, bob"); + assert!(!saved_msg.is_forwarded()); // UI should not flag "saved messages" as "forwarded" + assert_eq!(saved_msg.is_dc_message, MessengerMessage::Yes); + assert_eq!(saved_msg.get_from_id(), ContactId::SELF); + assert_eq!(saved_msg.get_state(), MessageState::OutDelivered); + assert_ne!(saved_msg.rfc724_mid(), sent_msg.rfc724_mid()); + + let sent_msg = Message::load_from_db(&alice, sent.sender_msg_id).await?; + assert_eq!( + sent_msg.get_saved_msg_id(&alice).await?.unwrap(), + saved_msg.id + ); + assert!(sent_msg.get_original_msg_id(&alice).await?.is_none()); + + let rcvd_msg = bob.recv_msg(&sent).await; + let self_chat = bob.get_self_chat().await; + save_msgs(&bob, &[rcvd_msg.id]).await?; + let saved_msg = bob.get_last_msg_in(self_chat.id).await; + assert_ne!(saved_msg.get_id(), rcvd_msg.id); + assert_eq!( + saved_msg.get_original_msg_id(&bob).await?.unwrap(), + rcvd_msg.id + ); + assert_eq!(saved_msg.get_text(), "hi, bob"); + assert!(!saved_msg.is_forwarded()); + assert_eq!(saved_msg.is_dc_message, MessengerMessage::Yes); + assert_ne!(saved_msg.get_from_id(), ContactId::SELF); + assert_eq!(saved_msg.get_state(), MessageState::InSeen); + assert_ne!(saved_msg.rfc724_mid(), rcvd_msg.rfc724_mid()); + + // delete original message + delete_msgs(&bob, &[rcvd_msg.id]).await?; + let saved_msg = Message::load_from_db(&bob, saved_msg.id).await?; + assert!(saved_msg.get_original_msg_id(&bob).await?.is_none()); + + // delete original chat + rcvd_msg.chat_id.delete(&bob).await?; + let msg = Message::load_from_db(&bob, saved_msg.id).await?; + assert!(msg.get_original_msg_id(&bob).await?.is_none()); + + Ok(()) + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn test_saved_msgs_not_added_to_shared_chats() -> Result<()> { + let mut tcm = TestContextManager::new(); + let alice = tcm.alice().await; + let bob = tcm.bob().await; + + let msg = tcm.send_recv_accept(&alice, &bob, "hi, bob").await; + + let self_chat = bob.get_self_chat().await; + save_msgs(&bob, &[msg.id]).await?; + let msg = bob.get_last_msg_in(self_chat.id).await; + let contact = Contact::get_by_id(&bob, msg.get_from_id()).await?; + assert_eq!(contact.get_addr(), "alice@example.org"); + + let shared_chats = Chatlist::try_load(&bob, 0, None, Some(contact.id)).await?; + assert_eq!(shared_chats.len(), 1); + assert_eq!( + shared_chats.get_chat_id(0).unwrap(), + bob.get_chat(&alice).await.id + ); + + Ok(()) + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn test_forward_from_saved_to_saved() -> Result<()> { + let alice = TestContext::new_alice().await; + let bob = TestContext::new_bob().await; + let sent = alice.send_text(alice.create_chat(&bob).await.id, "k").await; + + bob.recv_msg(&sent).await; + let orig = bob.get_last_msg().await; + let self_chat = bob.get_self_chat().await; + save_msgs(&bob, &[orig.id]).await?; + let saved1 = bob.get_last_msg().await; + assert_eq!( + saved1.get_original_msg_id(&bob).await?.unwrap(), + sent.sender_msg_id + ); + assert_ne!(saved1.from_id, ContactId::SELF); + + forward_msgs(&bob, &[saved1.id], self_chat.id).await?; + let saved2 = bob.get_last_msg().await; + assert!(saved2.get_original_msg_id(&bob).await?.is_none(),); + assert_eq!(saved2.from_id, ContactId::SELF); + + Ok(()) + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn test_save_from_saved_to_saved_failing() -> Result<()> { + let alice = TestContext::new_alice().await; + let bob = TestContext::new_bob().await; + let sent = alice.send_text(alice.create_chat(&bob).await.id, "k").await; + + bob.recv_msg(&sent).await; + let orig = bob.get_last_msg().await; + save_msgs(&bob, &[orig.id]).await?; + let saved1 = bob.get_last_msg().await; + + let result = save_msgs(&bob, &[saved1.id]).await; + assert!(result.is_err()); + + Ok(()) + } + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn test_resend_own_message() -> Result<()> { // Alice creates group with Bob and sends an initial message diff --git a/src/html.rs b/src/html.rs index c58f099c8..1e5e5097c 100644 --- a/src/html.rs +++ b/src/html.rs @@ -291,7 +291,7 @@ pub fn new_html_mimepart(html: String) -> PartBuilder { mod tests { use super::*; use crate::chat; - use crate::chat::forward_msgs; + use crate::chat::{forward_msgs, save_msgs}; use crate::config::Config; use crate::contact::ContactId; use crate::message::{MessengerMessage, Viewtype}; @@ -499,6 +499,38 @@ test some special html-characters as < > and & but also " and &#x assert!(html.contains("this is html")); } + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn test_html_save_msg() -> Result<()> { + // Alice receives a non-delta html-message + let alice = TestContext::new_alice().await; + let chat = alice + .create_chat_with_contact("", "sender@testrun.org") + .await; + let raw = include_bytes!("../test-data/message/text_alt_plain_html.eml"); + receive_imf(&alice, raw, false).await?; + let msg = alice.get_last_msg_in(chat.get_id()).await; + + // Alice saves the message + let self_chat = alice.get_self_chat().await; + save_msgs(&alice, &[msg.id]).await?; + let saved_msg = alice.get_last_msg_in(self_chat.get_id()).await; + assert_ne!(saved_msg.id, msg.id); + assert_eq!( + saved_msg.get_original_msg_id(&alice).await?.unwrap(), + msg.id + ); + assert!(!saved_msg.is_forwarded()); // UI should not flag "saved messages" as "forwarded" + assert_ne!(saved_msg.get_from_id(), ContactId::SELF); + assert_eq!(saved_msg.get_from_id(), msg.get_from_id()); + assert_eq!(saved_msg.is_dc_message, MessengerMessage::No); + assert!(saved_msg.get_text().contains("this is plain")); + assert!(saved_msg.has_html()); + let html = saved_msg.get_id().get_html(&alice).await?.unwrap(); + assert!(html.contains("this is html")); + + Ok(()) + } + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn test_html_forwarding_encrypted() { // Alice receives a non-delta html-message diff --git a/src/message.rs b/src/message.rs index b12e3d484..a9c908bd5 100644 --- a/src/message.rs +++ b/src/message.rs @@ -470,6 +470,7 @@ pub struct Message { /// `In-Reply-To` header value. pub(crate) in_reply_to: Option, pub(crate) is_dc_message: MessengerMessage, + pub(crate) original_msg_id: MsgId, pub(crate) mime_modified: bool, pub(crate) chat_blocked: Blocked, pub(crate) location_id: u32, @@ -536,6 +537,7 @@ impl Message { " m.download_state AS download_state,", " m.error AS error,", " m.msgrmsg AS msgrmsg,", + " m.starred AS original_msg_id,", " m.mime_modified AS mime_modified,", " m.txt AS txt,", " m.subject AS subject,", @@ -592,6 +594,7 @@ impl Message { error: Some(row.get::<_, String>("error")?) .filter(|error| !error.is_empty()), is_dc_message: row.get("msgrmsg")?, + original_msg_id: row.get("original_msg_id")?, mime_modified: row.get("mime_modified")?, text, subject: row.get("subject")?, @@ -1256,6 +1259,35 @@ impl Message { Ok(None) } + /// Returns original message ID for message from "Saved Messages". + pub async fn get_original_msg_id(&self, context: &Context) -> Result> { + if !self.original_msg_id.is_special() { + if let Some(msg) = Message::load_from_db_optional(context, self.original_msg_id).await? + { + return if msg.chat_id.is_trash() { + Ok(None) + } else { + Ok(Some(msg.id)) + }; + } + } + Ok(None) + } + + /// Check if the message was saved and returns the corresponding message inside "Saved Messages". + /// UI can use this to show a symbol beside the message, indicating it was saved. + /// The message can be un-saved by deleting the returned message. + pub async fn get_saved_msg_id(&self, context: &Context) -> Result> { + let res: Option = context + .sql + .query_get_value( + "SELECT id FROM msgs WHERE starred=? AND chat_id!=?", + (self.id, DC_CHAT_ID_TRASH), + ) + .await?; + Ok(res) + } + /// Force the message to be sent in plain text. pub fn force_plaintext(&mut self) { self.param.set_int(Param::ForcePlaintext, 1); @@ -2168,7 +2200,8 @@ mod tests { use super::*; use crate::chat::{ - self, add_contact_to_chat, marknoticed_chat, send_text_msg, ChatItem, ProtectionStatus, + self, add_contact_to_chat, forward_msgs, marknoticed_chat, save_msgs, send_text_msg, + ChatItem, ProtectionStatus, }; use crate::chatlist::Chatlist; use crate::config::Config; @@ -2477,6 +2510,41 @@ mod tests { ); } + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn test_get_original_msg_id() -> Result<()> { + let alice = TestContext::new_alice().await; + let bob = TestContext::new_bob().await; + + // normal sending of messages does not have an original ID + let one2one_chat = alice.create_chat(&bob).await; + let sent = alice.send_text(one2one_chat.id, "foo").await; + let orig_msg = Message::load_from_db(&alice, sent.sender_msg_id).await?; + assert!(orig_msg.get_original_msg_id(&alice).await?.is_none()); + assert!(orig_msg.parent(&alice).await?.is_none()); + assert!(orig_msg.quoted_message(&alice).await?.is_none()); + + // forwarding to "Saved Messages", the message gets the original ID attached + let self_chat = alice.get_self_chat().await; + save_msgs(&alice, &[sent.sender_msg_id]).await?; + let saved_msg = alice.get_last_msg_in(self_chat.get_id()).await; + assert_ne!(saved_msg.get_id(), orig_msg.get_id()); + assert_eq!( + saved_msg.get_original_msg_id(&alice).await?.unwrap(), + orig_msg.get_id() + ); + assert!(saved_msg.parent(&alice).await?.is_none()); + assert!(saved_msg.quoted_message(&alice).await?.is_none()); + + // forwarding from "Saved Messages" back to another chat, detaches original ID + forward_msgs(&alice, &[saved_msg.get_id()], one2one_chat.get_id()).await?; + let forwarded_msg = alice.get_last_msg_in(one2one_chat.get_id()).await; + assert_ne!(forwarded_msg.get_id(), saved_msg.get_id()); + assert_ne!(forwarded_msg.get_id(), orig_msg.get_id()); + assert!(forwarded_msg.get_original_msg_id(&alice).await?.is_none()); + + Ok(()) + } + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn test_markseen_msgs() -> Result<()> { let alice = TestContext::new_alice().await; diff --git a/src/sql/migrations.rs b/src/sql/migrations.rs index a7bb6236c..2532d68ad 100644 --- a/src/sql/migrations.rs +++ b/src/sql/migrations.rs @@ -116,8 +116,6 @@ CREATE INDEX msgs_mdns_index1 ON msgs_mdns (msg_id);"#, r#" ALTER TABLE chats ADD COLUMN archived INTEGER DEFAULT 0; CREATE INDEX chats_index2 ON chats (archived); --- 'starred' column is not used currently --- (dropping is not easily doable and stop adding it will make reusing it complicated) ALTER TABLE msgs ADD COLUMN starred INTEGER DEFAULT 0; CREATE INDEX msgs_index5 ON msgs (starred);"#, 17, diff --git a/src/sync.rs b/src/sync.rs index d2671e22b..fbfdf1b07 100644 --- a/src/sync.rs +++ b/src/sync.rs @@ -16,7 +16,7 @@ use crate::param::Param; use crate::sync::SyncData::{AddQrToken, AlterChat, DeleteQrToken}; use crate::token::Namespace; use crate::tools::time; -use crate::{stock_str, token}; +use crate::{message, stock_str, token}; /// Whether to send device sync messages. Aimed for usage in the internal API. #[derive(Debug, PartialEq)] @@ -62,6 +62,10 @@ pub(crate) enum SyncData { key: Config, val: String, }, + SaveMessage { + src: String, // RFC724 id (i.e. "Message-Id" header) + dest: String, // RFC724 id (i.e. "Message-Id" header) + }, } #[derive(Debug, Serialize, Deserialize)] @@ -259,6 +263,7 @@ impl Context { DeleteQrToken(token) => self.delete_qr_token(token).await, AlterChat { id, action } => self.sync_alter_chat(id, action).await, SyncData::Config { key, val } => self.sync_config(key, val).await, + SyncData::SaveMessage { src, dest } => self.save_message(src, dest).await, }, SyncDataOrUnknown::Unknown(data) => { warn!(self, "Ignored unknown sync item: {data}."); @@ -282,6 +287,13 @@ impl Context { token::delete(self, Namespace::Auth, &token.auth).await?; Ok(()) } + + async fn save_message(&self, src_rfc724_mid: &str, dest_rfc724_mid: &String) -> Result<()> { + if let Some((src_msg_id, _)) = message::rfc724_mid_exists(self, src_rfc724_mid).await? { + chat::save_copy_in_self_talk(self, &src_msg_id, dest_rfc724_mid).await?; + } + Ok(()) + } } #[cfg(test)]