Use footer as a contact status

This commit is contained in:
link2xt
2021-02-11 08:43:50 +03:00
committed by link2xt
parent 59f9fc7cbf
commit 0601b05cb7
10 changed files with 167 additions and 33 deletions

View File

@@ -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); 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. * Check if a contact is blocked.
* *

View File

@@ -3275,6 +3275,16 @@ pub unsafe extern "C" fn dc_contact_get_color(contact: *mut dc_contact_t) -> u32
ffi_contact.contact.get_color() 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] #[no_mangle]
pub unsafe extern "C" fn dc_contact_is_blocked(contact: *mut dc_contact_t) -> libc::c_int { pub unsafe extern "C" fn dc_contact_is_blocked(contact: *mut dc_contact_t) -> libc::c_int {
if contact.is_null() { if contact.is_null() {

View File

@@ -77,6 +77,14 @@ class Contact(object):
return None return None
return from_dc_charpointer(dc_res) 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): def create_chat(self):
""" create or get an existing 1:1 chat object for the specified contact or contact id. """ create or get an existing 1:1 chat object for the specified contact or contact id.

View File

@@ -2167,6 +2167,23 @@ class TestOnlineAccount:
updated_name = update_name() updated_name = update_name()
assert updated_name == "Renamed" 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): def test_group_quote(self, acfactory, lp):
"""Test quoting in a group with a new member who have not seen the quoted message.""" """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) ac1, ac2, ac3 = accounts = acfactory.get_many_online_accounts(3)

View File

@@ -71,6 +71,9 @@ pub struct Contact {
/// Parameters as Param::ProfileImage /// Parameters as Param::ProfileImage
pub param: Params, pub param: Params,
/// Last seen message signature for this contact, to be displayed in the profile.
status: String,
} }
/// Possible origins of a contact. /// Possible origins of a contact.
@@ -172,7 +175,7 @@ impl Contact {
let mut res = context let mut res = context
.sql .sql
.query_row( .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 FROM contacts c
WHERE c.id=?;", WHERE c.id=?;",
paramsv![contact_id as i32], paramsv![contact_id as i32],
@@ -185,6 +188,7 @@ impl Contact {
blocked: row.get::<_, Option<i32>>(3)?.unwrap_or_default() != 0, blocked: row.get::<_, Option<i32>>(3)?.unwrap_or_default() != 0,
origin: row.get(2)?, origin: row.get(2)?,
param: row.get::<_, String>(5)?.parse().unwrap_or_default(), param: row.get::<_, String>(5)?.parse().unwrap_or_default(),
status: row.get(6).unwrap_or_default(),
}; };
Ok(contact) Ok(contact)
}, },
@@ -196,6 +200,10 @@ impl Contact {
.get_config(Config::ConfiguredAddr) .get_config(Config::ConfiguredAddr)
.await .await
.unwrap_or_default(); .unwrap_or_default();
res.status = context
.get_config(Config::Selfstatus)
.await
.unwrap_or_default();
} else if contact_id == DC_CONTACT_ID_DEVICE { } else if contact_id == DC_CONTACT_ID_DEVICE {
res.name = context res.name = context
.stock_str(StockMessage::DeviceMessages) .stock_str(StockMessage::DeviceMessages)
@@ -841,7 +849,8 @@ impl Contact {
Ok(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 context
.sql .sql
.execute( .execute(
@@ -852,6 +861,18 @@ impl Contact {
Ok(()) 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. /// Get the ID of the contact.
pub fn get_id(&self) -> u32 { pub fn get_id(&self) -> u32 {
self.id self.id
@@ -932,6 +953,13 @@ impl Contact {
dc_str_to_color(&self.addr) 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 /// 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. /// and if the key has not changed since this verification.
/// ///
@@ -1163,6 +1191,18 @@ pub(crate) async fn set_profile_image(
Ok(()) 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. /// Normalize a name.
/// ///
/// - Remove quotes (come from some bad MUA implementations) /// - Remove quotes (come from some bad MUA implementations)

View File

@@ -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 // Get user-configured server deletion
let delete_server_after = context.get_config_delete_server_after().await; let delete_server_after = context.get_config_delete_server_after().await;

View File

@@ -390,10 +390,11 @@ mod tests {
let input = include_str!("../test-data/message/gmx-quote-body.eml"); let input = include_str!("../test-data/message/gmx-quote-body.eml");
let dehtml = dehtml(input).unwrap(); let dehtml = dehtml(input).unwrap();
println!("{}", dehtml); 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!(msg, "Test");
assert_eq!(forwarded, false); assert_eq!(forwarded, false);
assert_eq!(cut, false); assert_eq!(cut, false);
assert_eq!(top_quote.as_deref(), Some("test")); assert_eq!(top_quote.as_deref(), Some("test"));
assert_eq!(footer, None);
} }
} }

View File

@@ -66,6 +66,9 @@ pub struct MimeMessage {
pub(crate) mdn_reports: Vec<Report>, pub(crate) mdn_reports: Vec<Report>,
pub(crate) failure_report: Option<FailureReport>, pub(crate) failure_report: Option<FailureReport>,
/// Standard USENET signature, if any.
pub(crate) footer: Option<String>,
// if this flag is set, the parts/text/etc. are just close to the original mime-message; // 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 // clients should offer a way to view the original message in this case
pub is_mime_modified: bool, pub is_mime_modified: bool,
@@ -233,6 +236,7 @@ impl MimeMessage {
user_avatar: None, user_avatar: None,
group_avatar: None, group_avatar: None,
failure_report: None, failure_report: None,
footer: None,
is_mime_modified: false, is_mime_modified: false,
decoded_data: Vec::new(), decoded_data: Vec::new(),
}; };
@@ -752,9 +756,9 @@ impl MimeMessage {
let mut dehtml_failed = false; 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() { if decoded_data.is_empty() {
("".to_string(), false, false, None) ("".to_string(), false, false, None, None)
} else { } else {
let is_html = mime_type == mime::TEXT_HTML; let is_html = mime_type == mime::TEXT_HTML;
let out = if is_html { let out = if is_html {
@@ -814,6 +818,8 @@ impl MimeMessage {
if is_forwarded { if is_forwarded {
self.is_forwarded = true; self.is_forwarded = true;
} }
self.footer = footer;
} }
_ => {} _ => {}
} }

View File

@@ -17,16 +17,16 @@ pub fn escape_message_footer_marks(text: &str) -> String {
} }
/// Remove standard (RFC 3676, §4.3) footer if it is found. /// Remove standard (RFC 3676, §4.3) footer if it is found.
/// Returns `(lines, is_footer_removed)` tuple; /// Returns `(lines, footer_lines)` tuple;
/// `is_footer_removed` is set to `true` if the footer was actually removed from `lines` /// `footer_lines` is set to `Some` if the footer was actually removed from `lines`
/// (which is equal to the input array otherwise). /// (which is equal to the input array otherwise).
#[allow(clippy::indexing_slicing)] #[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; let mut nearly_standard_footer = None;
for (ix, &line) in lines.iter().enumerate() { for (ix, &line) in lines.iter().enumerate() {
match line { match line {
// some providers encode `-- ` to `-- =20` which results in `-- ` // 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 `--`; // some providers encode `-- ` to `=2D-` which results in only `--`;
// use that only when no other footer is found // use that only when no other footer is found
// and if the line before is empty and the line after is not empty // 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 { 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. /// 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. /// Simplify message text for chat display.
/// Remove quotes, signatures, trailing empty lines etc. /// 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. /// 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<String>) { pub fn simplify(
mut input: String,
is_chat_message: bool,
) -> (String, bool, bool, Option<String>, Option<String>) {
let mut is_cut = false; let mut is_cut = false;
input.retain(|c| c != '\r'); 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 (lines, mut top_quote) = remove_top_quote(lines);
let original_lines = &lines; let original_lines = &lines;
let (lines, footer_removed) = remove_message_footer(lines); let (lines, footer_lines) = remove_message_footer(lines);
is_cut = is_cut || footer_removed; 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 { let text = if is_chat_message {
render_message(lines, false) 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 { if !is_chat_message {
top_quote = top_quote.map(|quote| { top_quote = top_quote.map(|quote| {
let quote_lines = split_lines(&quote); let quote_lines = split_lines(&quote);
let (quote_lines, footer_removed) = remove_message_footer(&quote_lines); let (quote_lines, quote_footer_lines) = remove_message_footer(&quote_lines);
is_cut = is_cut || footer_removed; is_cut = is_cut || quote_footer_lines.is_some();
render_message(quote_lines, false) render_message(quote_lines, false)
}); });
} }
(text, is_forwarded, is_cut, top_quote) (text, is_forwarded, is_cut, top_quote, footer)
} }
/// Skips "forwarded message" header. /// Skips "forwarded message" header.
@@ -269,7 +273,7 @@ mod tests {
#[test] #[test]
// proptest does not support [[:graphical:][:space:]] regex. // proptest does not support [[:graphical:][:space:]] regex.
fn test_simplify_plain_text_fuzzy(input in "[!-~\t \n]+") { 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 != "-- ")); assert!(output.split('\n').all(|s| s != "-- "));
} }
} }
@@ -277,7 +281,7 @@ mod tests {
#[test] #[test]
fn test_dont_remove_whole_message() { 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 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!( assert_eq!(
plain, plain,
"------\nFailed\n------\n\nUh-oh, this workflow did not succeed!\n\nlots of other text" "------\nFailed\n------\n\nUh-oh, this workflow did not succeed!\n\nlots of other text"
@@ -289,16 +293,20 @@ mod tests {
#[test] #[test]
fn test_chat_message() { 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 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_eq!(plain, "Hi! How are you?\n\n---\n\nI am good.");
assert!(!is_forwarded); assert!(!is_forwarded);
assert!(is_cut); assert!(is_cut);
assert_eq!(
footer.unwrap(),
"Sent with my Delta Chat Messenger: https://delta.chat"
);
} }
#[test] #[test]
fn test_simplify_trim() { fn test_simplify_trim() {
let input = "line1\n\r\r\rline2".to_string(); 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_eq!(plain, "line1\nline2");
assert!(!is_forwarded); assert!(!is_forwarded);
@@ -308,11 +316,12 @@ mod tests {
#[test] #[test]
fn test_simplify_forwarded_message() { 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 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_eq!(plain, "Forwarded message");
assert!(is_forwarded); assert!(is_forwarded);
assert!(is_cut); assert!(is_cut);
assert_eq!(footer.unwrap(), "Signature goes here");
} }
#[test] #[test]
@@ -354,50 +363,60 @@ mod tests {
#[test] #[test]
fn test_remove_message_footer() { fn test_remove_message_footer() {
let input = "text\n--\nno footer".to_string(); 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!(plain, "text\n--\nno footer");
assert_eq!(footer, None);
assert!(!is_cut); assert!(!is_cut);
let input = "text\n\n--\n\nno footer".to_string(); 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!(plain, "text\n\n--\n\nno footer");
assert_eq!(footer, None);
assert!(!is_cut); assert!(!is_cut);
let input = "text\n\n-- no footer\n\n".to_string(); 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!(plain, "text\n\n-- no footer");
assert_eq!(footer, None);
let input = "text\n\n--\nno footer\n-- \nfooter".to_string(); 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_eq!(plain, "text\n\n--\nno footer");
assert!(is_cut); assert!(is_cut);
assert_eq!(footer.unwrap(), "footer");
let input = "text\n\n--\ntreated as footer when unescaped".to_string(); 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_eq!(plain, "text"); // see remove_message_footer() for some explanations
assert!(is_cut); assert!(is_cut);
assert_eq!(footer.unwrap(), "treated as footer when unescaped");
let escaped = escape_message_footer_marks(&input); 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_eq!(plain, "text\n\n--\ntreated as footer when unescaped");
assert!(!is_cut); assert!(!is_cut);
assert_eq!(footer, None);
// Nonstandard footer sent by https://siju.es/ // 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 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_eq!(plain, "Message text here [...]");
assert!(is_cut); 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_eq!(plain, input);
assert!(!is_cut); assert!(!is_cut);
assert_eq!(footer, None);
let input = "--\ntreated as footer when unescaped".to_string(); 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_eq!(plain, ""); // see remove_message_footer() for some explanations
assert!(is_cut); assert!(is_cut);
assert_eq!(footer.unwrap(), "treated as footer when unescaped");
let escaped = escape_message_footer_marks(&input); 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_eq!(plain, "--\ntreated as footer when unescaped");
assert!(!is_cut); assert!(!is_cut);
assert_eq!(footer, None);
} }
} }

View File

@@ -1500,6 +1500,15 @@ CREATE INDEX devmsglabels_index1 ON devmsglabels (label);
.await?; .await?;
sql.set_raw_config_int(context, "dbversion", 74).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 // (2) updates that require high-level objects
// (the structure is complete now and all objects are usable) // (the structure is complete now and all objects are usable)