From 0601b05cb776e037b06079100b3a7031cfd22459 Mon Sep 17 00:00:00 2001 From: link2xt Date: Thu, 11 Feb 2021 08:43:50 +0300 Subject: [PATCH] Use footer as a contact status --- deltachat-ffi/deltachat.h | 13 ++++++ deltachat-ffi/src/lib.rs | 10 +++++ python/src/deltachat/contact.py | 8 ++++ python/tests/test_account.py | 17 ++++++++ src/contact.rs | 44 ++++++++++++++++++- src/dc_receive_imf.rs | 11 +++++ src/dehtml.rs | 3 +- src/mimeparser.rs | 10 ++++- src/simplify.rs | 75 +++++++++++++++++++++------------ src/sql.rs | 9 ++++ 10 files changed, 167 insertions(+), 33 deletions(-) diff --git a/deltachat-ffi/deltachat.h b/deltachat-ffi/deltachat.h index ec9434264..47a297867 100644 --- a/deltachat-ffi/deltachat.h +++ b/deltachat-ffi/deltachat.h @@ -4099,6 +4099,19 @@ char* dc_contact_get_profile_image (const dc_contact_t* contact); uint32_t dc_contact_get_color (const dc_contact_t* contact); +/** + * Get the contact's status. + * + * Status is the last signature received in a message from this contact. + * + * @memberof dc_contact_t + * @param contact The contact object. + * @return Contact status, if any. + * Empty string otherwise. + * Must be released by using dc_str_unref() after usage. + */ +char* dc_contact_get_status (const dc_contact_t* contact); + /** * Check if a contact is blocked. * diff --git a/deltachat-ffi/src/lib.rs b/deltachat-ffi/src/lib.rs index a40c8ec6f..9a54da7c2 100644 --- a/deltachat-ffi/src/lib.rs +++ b/deltachat-ffi/src/lib.rs @@ -3275,6 +3275,16 @@ pub unsafe extern "C" fn dc_contact_get_color(contact: *mut dc_contact_t) -> u32 ffi_contact.contact.get_color() } +#[no_mangle] +pub unsafe extern "C" fn dc_contact_get_status(contact: *mut dc_contact_t) -> *mut libc::c_char { + if contact.is_null() { + eprintln!("ignoring careless call to dc_contact_get_status()"); + return "".strdup(); + } + let ffi_contact = &*contact; + ffi_contact.contact.get_status().strdup() +} + #[no_mangle] pub unsafe extern "C" fn dc_contact_is_blocked(contact: *mut dc_contact_t) -> libc::c_int { if contact.is_null() { diff --git a/python/src/deltachat/contact.py b/python/src/deltachat/contact.py index 04a7b8610..477effb35 100644 --- a/python/src/deltachat/contact.py +++ b/python/src/deltachat/contact.py @@ -77,6 +77,14 @@ class Contact(object): return None return from_dc_charpointer(dc_res) + @property + def status(self): + """Get contact status. + + :returns: contact status, empty string if it doesn't exist. + """ + return from_dc_charpointer(lib.dc_contact_get_status(self._dc_contact)) + def create_chat(self): """ create or get an existing 1:1 chat object for the specified contact or contact id. diff --git a/python/tests/test_account.py b/python/tests/test_account.py index b643e6b77..203536492 100644 --- a/python/tests/test_account.py +++ b/python/tests/test_account.py @@ -2167,6 +2167,23 @@ class TestOnlineAccount: updated_name = update_name() assert updated_name == "Renamed" + def test_status(self, acfactory): + """Test that status is transferred over the network.""" + ac1, ac2 = acfactory.get_two_online_accounts() + + chat12 = acfactory.get_accepted_chat(ac1, ac2) + ac1.set_config("selfstatus", "New status") + chat12.send_text("hi") + msg = ac2._evtracker.wait_next_incoming_message() + assert msg.text == "hi" + assert msg.get_sender_contact().status == "New status" + + ac1.set_config("selfstatus", "") + chat12.send_text("hello") + msg = ac2._evtracker.wait_next_incoming_message() + assert msg.text == "hello" + assert msg.get_sender_contact().status == "" + def test_group_quote(self, acfactory, lp): """Test quoting in a group with a new member who have not seen the quoted message.""" ac1, ac2, ac3 = accounts = acfactory.get_many_online_accounts(3) diff --git a/src/contact.rs b/src/contact.rs index 6137335e1..f6dfc3f29 100644 --- a/src/contact.rs +++ b/src/contact.rs @@ -71,6 +71,9 @@ pub struct Contact { /// Parameters as Param::ProfileImage pub param: Params, + + /// Last seen message signature for this contact, to be displayed in the profile. + status: String, } /// Possible origins of a contact. @@ -172,7 +175,7 @@ impl Contact { let mut res = context .sql .query_row( - "SELECT c.name, c.addr, c.origin, c.blocked, c.authname, c.param + "SELECT c.name, c.addr, c.origin, c.blocked, c.authname, c.param, c.status FROM contacts c WHERE c.id=?;", paramsv![contact_id as i32], @@ -185,6 +188,7 @@ impl Contact { blocked: row.get::<_, Option>(3)?.unwrap_or_default() != 0, origin: row.get(2)?, param: row.get::<_, String>(5)?.parse().unwrap_or_default(), + status: row.get(6).unwrap_or_default(), }; Ok(contact) }, @@ -196,6 +200,10 @@ impl Contact { .get_config(Config::ConfiguredAddr) .await .unwrap_or_default(); + res.status = context + .get_config(Config::Selfstatus) + .await + .unwrap_or_default(); } else if contact_id == DC_CONTACT_ID_DEVICE { res.name = context .stock_str(StockMessage::DeviceMessages) @@ -841,7 +849,8 @@ impl Contact { Ok(contact) } - pub async fn update_param(&mut self, context: &Context) -> Result<()> { + /// Updates `param` column in the database. + pub async fn update_param(&self, context: &Context) -> Result<()> { context .sql .execute( @@ -852,6 +861,18 @@ impl Contact { Ok(()) } + /// Updates `status` column in the database. + pub async fn update_status(&self, context: &Context) -> Result<()> { + context + .sql + .execute( + "UPDATE contacts SET status=? WHERE id=?", + paramsv![self.status, self.id as i32], + ) + .await?; + Ok(()) + } + /// Get the ID of the contact. pub fn get_id(&self) -> u32 { self.id @@ -932,6 +953,13 @@ impl Contact { dc_str_to_color(&self.addr) } + /// Gets the contact's status. + /// + /// Status is the last signature received in a message from this contact. + pub fn get_status(&self) -> &str { + self.status.as_str() + } + /// Check if a contact was verified. E.g. by a secure-join QR code scan /// and if the key has not changed since this verification. /// @@ -1163,6 +1191,18 @@ pub(crate) async fn set_profile_image( Ok(()) } +/// Sets contact status. +pub(crate) async fn set_status(context: &Context, contact_id: u32, status: String) -> Result<()> { + let mut contact = Contact::load_from_db(context, contact_id).await?; + + if contact.status != status { + contact.status = status; + contact.update_status(context).await?; + context.emit_event(EventType::ContactsChanged(Some(contact_id))); + } + Ok(()) +} + /// Normalize a name. /// /// - Remove quotes (come from some bad MUA implementations) diff --git a/src/dc_receive_imf.rs b/src/dc_receive_imf.rs index 506b33ee3..251692840 100644 --- a/src/dc_receive_imf.rs +++ b/src/dc_receive_imf.rs @@ -237,6 +237,17 @@ pub(crate) async fn dc_receive_imf_inner( }; } + // Always update the status, even if there is no footer, to allow removing the status. + if let Err(err) = contact::set_status( + &context, + from_id, + mime_parser.footer.clone().unwrap_or_default(), + ) + .await + { + warn!(context, "cannot update contact status: {}", err); + } + // Get user-configured server deletion let delete_server_after = context.get_config_delete_server_after().await; diff --git a/src/dehtml.rs b/src/dehtml.rs index 2eae9547a..39e18188a 100644 --- a/src/dehtml.rs +++ b/src/dehtml.rs @@ -390,10 +390,11 @@ mod tests { let input = include_str!("../test-data/message/gmx-quote-body.eml"); let dehtml = dehtml(input).unwrap(); println!("{}", dehtml); - let (msg, forwarded, cut, top_quote) = simplify(dehtml, false); + let (msg, forwarded, cut, top_quote, footer) = simplify(dehtml, false); assert_eq!(msg, "Test"); assert_eq!(forwarded, false); assert_eq!(cut, false); assert_eq!(top_quote.as_deref(), Some("test")); + assert_eq!(footer, None); } } diff --git a/src/mimeparser.rs b/src/mimeparser.rs index 506975842..1567a3aa3 100644 --- a/src/mimeparser.rs +++ b/src/mimeparser.rs @@ -66,6 +66,9 @@ pub struct MimeMessage { pub(crate) mdn_reports: Vec, pub(crate) failure_report: Option, + /// Standard USENET signature, if any. + pub(crate) footer: Option, + // if this flag is set, the parts/text/etc. are just close to the original mime-message; // clients should offer a way to view the original message in this case pub is_mime_modified: bool, @@ -233,6 +236,7 @@ impl MimeMessage { user_avatar: None, group_avatar: None, failure_report: None, + footer: None, is_mime_modified: false, decoded_data: Vec::new(), }; @@ -752,9 +756,9 @@ impl MimeMessage { let mut dehtml_failed = false; - let (simplified_txt, is_forwarded, is_cut, top_quote) = + let (simplified_txt, is_forwarded, is_cut, top_quote, footer) = if decoded_data.is_empty() { - ("".to_string(), false, false, None) + ("".to_string(), false, false, None, None) } else { let is_html = mime_type == mime::TEXT_HTML; let out = if is_html { @@ -814,6 +818,8 @@ impl MimeMessage { if is_forwarded { self.is_forwarded = true; } + + self.footer = footer; } _ => {} } diff --git a/src/simplify.rs b/src/simplify.rs index 9d440ced6..b4d443ff1 100644 --- a/src/simplify.rs +++ b/src/simplify.rs @@ -17,16 +17,16 @@ pub fn escape_message_footer_marks(text: &str) -> String { } /// Remove standard (RFC 3676, §4.3) footer if it is found. -/// Returns `(lines, is_footer_removed)` tuple; -/// `is_footer_removed` is set to `true` if the footer was actually removed from `lines` +/// Returns `(lines, footer_lines)` tuple; +/// `footer_lines` is set to `Some` if the footer was actually removed from `lines` /// (which is equal to the input array otherwise). #[allow(clippy::indexing_slicing)] -fn remove_message_footer<'a>(lines: &'a [&str]) -> (&'a [&'a str], bool) { +fn remove_message_footer<'a>(lines: &'a [&str]) -> (&'a [&'a str], Option<&'a [&'a str]>) { let mut nearly_standard_footer = None; for (ix, &line) in lines.iter().enumerate() { match line { // some providers encode `-- ` to `-- =20` which results in `-- ` - "-- " | "-- " => return (&lines[..ix], true), + "-- " | "-- " => return (&lines[..ix], lines.get(ix + 1..)), // some providers encode `-- ` to `=2D-` which results in only `--`; // use that only when no other footer is found // and if the line before is empty and the line after is not empty @@ -42,9 +42,9 @@ fn remove_message_footer<'a>(lines: &'a [&str]) -> (&'a [&'a str], bool) { } } if let Some(ix) = nearly_standard_footer { - return (&lines[..ix], true); + return (&lines[..ix], lines.get(ix + 1..)); } - (lines, false) + (lines, None) } /// Remove nonstandard footer and a boolean indicating whether such footer was removed. @@ -73,9 +73,12 @@ pub(crate) fn split_lines(buf: &str) -> Vec<&str> { /// Simplify message text for chat display. /// Remove quotes, signatures, trailing empty lines etc. -/// Returns `(text, is_forwarded, is_cut, quote)` tuple, +/// Returns `(text, is_forwarded, is_cut, quote, footer)` tuple, /// returning the simplified text and some additional information gained from the input. -pub fn simplify(mut input: String, is_chat_message: bool) -> (String, bool, bool, Option) { +pub fn simplify( + mut input: String, + is_chat_message: bool, +) -> (String, bool, bool, Option, Option) { let mut is_cut = false; input.retain(|c| c != '\r'); @@ -84,8 +87,9 @@ pub fn simplify(mut input: String, is_chat_message: bool) -> (String, bool, bool let (lines, mut top_quote) = remove_top_quote(lines); let original_lines = &lines; - let (lines, footer_removed) = remove_message_footer(lines); - is_cut = is_cut || footer_removed; + let (lines, footer_lines) = remove_message_footer(lines); + let footer = footer_lines.map(|footer_lines| render_message(footer_lines, false)); + is_cut = is_cut || footer.is_some(); let text = if is_chat_message { render_message(lines, false) @@ -108,13 +112,13 @@ pub fn simplify(mut input: String, is_chat_message: bool) -> (String, bool, bool if !is_chat_message { top_quote = top_quote.map(|quote| { let quote_lines = split_lines("e); - let (quote_lines, footer_removed) = remove_message_footer("e_lines); - is_cut = is_cut || footer_removed; + let (quote_lines, quote_footer_lines) = remove_message_footer("e_lines); + is_cut = is_cut || quote_footer_lines.is_some(); render_message(quote_lines, false) }); } - (text, is_forwarded, is_cut, top_quote) + (text, is_forwarded, is_cut, top_quote, footer) } /// Skips "forwarded message" header. @@ -269,7 +273,7 @@ mod tests { #[test] // proptest does not support [[:graphical:][:space:]] regex. fn test_simplify_plain_text_fuzzy(input in "[!-~\t \n]+") { - let (output, _is_forwarded, _, _) = simplify(input, true); + let (output, _is_forwarded, _, _, _) = simplify(input, true); assert!(output.split('\n').all(|s| s != "-- ")); } } @@ -277,7 +281,7 @@ mod tests { #[test] fn test_dont_remove_whole_message() { let input = "\n------\nFailed\n------\n\nUh-oh, this workflow did not succeed!\n\nlots of other text".to_string(); - let (plain, is_forwarded, is_cut, _) = simplify(input, false); + let (plain, is_forwarded, is_cut, _, _) = simplify(input, false); assert_eq!( plain, "------\nFailed\n------\n\nUh-oh, this workflow did not succeed!\n\nlots of other text" @@ -289,16 +293,20 @@ mod tests { #[test] fn test_chat_message() { let input = "Hi! How are you?\n\n---\n\nI am good.\n-- \nSent with my Delta Chat Messenger: https://delta.chat".to_string(); - let (plain, is_forwarded, is_cut, _) = simplify(input, true); + let (plain, is_forwarded, is_cut, _, footer) = simplify(input, true); assert_eq!(plain, "Hi! How are you?\n\n---\n\nI am good."); assert!(!is_forwarded); assert!(is_cut); + assert_eq!( + footer.unwrap(), + "Sent with my Delta Chat Messenger: https://delta.chat" + ); } #[test] fn test_simplify_trim() { let input = "line1\n\r\r\rline2".to_string(); - let (plain, is_forwarded, is_cut, _) = simplify(input, false); + let (plain, is_forwarded, is_cut, _, _) = simplify(input, false); assert_eq!(plain, "line1\nline2"); assert!(!is_forwarded); @@ -308,11 +316,12 @@ mod tests { #[test] fn test_simplify_forwarded_message() { let input = "---------- Forwarded message ----------\r\nFrom: test@example.com\r\n\r\nForwarded message\r\n-- \r\nSignature goes here".to_string(); - let (plain, is_forwarded, is_cut, _) = simplify(input, false); + let (plain, is_forwarded, is_cut, _, footer) = simplify(input, false); assert_eq!(plain, "Forwarded message"); assert!(is_forwarded); assert!(is_cut); + assert_eq!(footer.unwrap(), "Signature goes here"); } #[test] @@ -354,50 +363,60 @@ mod tests { #[test] fn test_remove_message_footer() { let input = "text\n--\nno footer".to_string(); - let (plain, _, is_cut, _) = simplify(input, true); + let (plain, _, is_cut, _, footer) = simplify(input, true); assert_eq!(plain, "text\n--\nno footer"); + assert_eq!(footer, None); assert!(!is_cut); let input = "text\n\n--\n\nno footer".to_string(); - let (plain, _, is_cut, _) = simplify(input, true); + let (plain, _, is_cut, _, footer) = simplify(input, true); assert_eq!(plain, "text\n\n--\n\nno footer"); + assert_eq!(footer, None); assert!(!is_cut); let input = "text\n\n-- no footer\n\n".to_string(); - let (plain, _, _, _) = simplify(input, true); + let (plain, _, _, _, footer) = simplify(input, true); assert_eq!(plain, "text\n\n-- no footer"); + assert_eq!(footer, None); let input = "text\n\n--\nno footer\n-- \nfooter".to_string(); - let (plain, _, is_cut, _) = simplify(input, true); + let (plain, _, is_cut, _, footer) = simplify(input, true); assert_eq!(plain, "text\n\n--\nno footer"); assert!(is_cut); + assert_eq!(footer.unwrap(), "footer"); let input = "text\n\n--\ntreated as footer when unescaped".to_string(); - let (plain, _, is_cut, _) = simplify(input.clone(), true); + let (plain, _, is_cut, _, footer) = simplify(input.clone(), true); assert_eq!(plain, "text"); // see remove_message_footer() for some explanations assert!(is_cut); + assert_eq!(footer.unwrap(), "treated as footer when unescaped"); let escaped = escape_message_footer_marks(&input); - let (plain, _, is_cut, _) = simplify(escaped, true); + let (plain, _, is_cut, _, footer) = simplify(escaped, true); assert_eq!(plain, "text\n\n--\ntreated as footer when unescaped"); assert!(!is_cut); + assert_eq!(footer, None); // Nonstandard footer sent by https://siju.es/ let input = "Message text here\n---Desde mi teléfono con SIJÚ\n\nQuote here".to_string(); - let (plain, _, is_cut, _) = simplify(input.clone(), false); + let (plain, _, is_cut, _, footer) = simplify(input.clone(), false); assert_eq!(plain, "Message text here [...]"); assert!(is_cut); - let (plain, _, is_cut, _) = simplify(input.clone(), true); + assert_eq!(footer, None); + let (plain, _, is_cut, _, footer) = simplify(input.clone(), true); assert_eq!(plain, input); assert!(!is_cut); + assert_eq!(footer, None); let input = "--\ntreated as footer when unescaped".to_string(); - let (plain, _, is_cut, _) = simplify(input.clone(), true); + let (plain, _, is_cut, _, footer) = simplify(input.clone(), true); assert_eq!(plain, ""); // see remove_message_footer() for some explanations assert!(is_cut); + assert_eq!(footer.unwrap(), "treated as footer when unescaped"); let escaped = escape_message_footer_marks(&input); - let (plain, _, is_cut, _) = simplify(escaped, true); + let (plain, _, is_cut, _, footer) = simplify(escaped, true); assert_eq!(plain, "--\ntreated as footer when unescaped"); assert!(!is_cut); + assert_eq!(footer, None); } } diff --git a/src/sql.rs b/src/sql.rs index f5877399f..dffaff619 100644 --- a/src/sql.rs +++ b/src/sql.rs @@ -1500,6 +1500,15 @@ CREATE INDEX devmsglabels_index1 ON devmsglabels (label); .await?; sql.set_raw_config_int(context, "dbversion", 74).await?; } + if dbversion < 75 { + info!(context, "[migration] v75"); + sql.execute( + "ALTER TABLE contacts ADD COLUMN status TEXT DEFAULT '';", + paramsv![], + ) + .await?; + sql.set_raw_config_int(context, "dbversion", 75).await?; + } // (2) updates that require high-level objects // (the structure is complete now and all objects are usable)