Format message lines starting with > as quotes

This makes quotes created by user display properly in other MUAs like
Thunderbird and not start with space in MUAs that don't support format=flowed.

To distinguish user-created quotes from top-quote inserted by Delta
Chat, a newline is inserted if there is no top-quote and the first
line starts with ">".

Nested quotes are not supported, e.g. line starting with "> >" will
start with ">" on the next line if wrapped.
This commit is contained in:
link2xt
2022-06-17 23:09:25 +00:00
parent 377fa01e98
commit 525b04e69e
4 changed files with 71 additions and 29 deletions

View File

@@ -6,6 +6,7 @@
- limit the rate of MDN sending #3402 - limit the rate of MDN sending #3402
- ignore ratelimits for bots #3439 - ignore ratelimits for bots #3439
- remove `msgs_mdns` references to deleted messages during housekeeping #3387 - remove `msgs_mdns` references to deleted messages during housekeeping #3387
- format message lines starting with `>` as quotes #3434
### Fixes ### Fixes
- set a default error if NDN does not provide an error - set a default error if NDN does not provide an error

View File

@@ -51,14 +51,26 @@ fn format_line_flowed(line: &str, prefix: &str) -> String {
result + &buffer result + &buffer
} }
fn format_flowed_prefix(text: &str, prefix: &str) -> String { /// Returns text formatted according to RFC 3767 (format=flowed).
///
/// This function accepts text separated by LF, but returns text
/// separated by CRLF.
///
/// RFC 2646 technique is used to insert soft line breaks, so DelSp
/// SHOULD be set to "no" when sending.
pub(crate) fn format_flowed(text: &str) -> String {
let mut result = String::new(); let mut result = String::new();
for line in text.split('\n') { for line in text.split('\n') {
if !result.is_empty() { if !result.is_empty() {
result += "\r\n"; result += "\r\n";
} }
let line = line.trim_end();
let line_no_prefix = line.strip_prefix('>');
let is_quote = line_no_prefix.is_some();
let line = line_no_prefix.unwrap_or(line).trim();
let prefix = if is_quote { "> " } else { "" };
if prefix.len() + line.len() > 78 { if prefix.len() + line.len() > 78 {
result += &format_line_flowed(line, prefix); result += &format_line_flowed(line, prefix);
} else { } else {
@@ -70,23 +82,23 @@ fn format_flowed_prefix(text: &str, prefix: &str) -> String {
result += line; result += line;
} }
} }
result
}
/// Returns text formatted according to RFC 3767 (format=flowed). result
///
/// This function accepts text separated by LF, but returns text
/// separated by CRLF.
///
/// RFC 2646 technique is used to insert soft line breaks, so DelSp
/// SHOULD be set to "no" when sending.
pub fn format_flowed(text: &str) -> String {
format_flowed_prefix(text, "")
} }
/// Same as format_flowed(), but adds "> " prefix to each line. /// Same as format_flowed(), but adds "> " prefix to each line.
pub fn format_flowed_quote(text: &str) -> String { pub fn format_flowed_quote(text: &str) -> String {
format_flowed_prefix(text, "> ") let mut result = String::new();
for line in text.split('\n') {
if !result.is_empty() {
result += "\n";
}
result += "> ";
result += line;
}
format_flowed(&result)
} }
/// Joins lines in format=flowed text. /// Joins lines in format=flowed text.
@@ -129,6 +141,7 @@ pub fn unformat_flowed(text: &str, delsp: bool) -> String {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use crate::test_utils::TestContext;
#[test] #[test]
fn test_format_flowed() { fn test_format_flowed() {
@@ -144,18 +157,18 @@ mod tests {
client and enter the setup code presented on the generating device."; client and enter the setup code presented on the generating device.";
assert_eq!(format_flowed(text), expected); assert_eq!(format_flowed(text), expected);
let text = "> Not a quote"; let text = "> A quote";
assert_eq!(format_flowed(text), " > Not a quote"); assert_eq!(format_flowed(text), "> A quote");
// Test space stuffing of wrapped lines // Test space stuffing of wrapped lines
let text = "> This is the Autocrypt Setup Message used to transfer your key between clients.\n\ let text = "> This is the Autocrypt Setup Message used to transfer your key between clients.\n\
> \n\ > \n\
> To decrypt and use your key, open the message in an Autocrypt-compliant client and enter the setup code presented on the generating device."; > To decrypt and use your key, open the message in an Autocrypt-compliant client and enter the setup code presented on the generating device.";
let expected = "\x20> This is the Autocrypt Setup Message used to transfer your key between \r\n\ let expected = "> This is the Autocrypt Setup Message used to transfer your key between \r\n\
clients.\r\n\ > clients.\r\n\
\x20>\r\n\ > \r\n\
\x20> To decrypt and use your key, open the message in an Autocrypt-compliant \r\n\ > To decrypt and use your key, open the message in an Autocrypt-compliant \r\n\
client and enter the setup code presented on the generating device."; > client and enter the setup code presented on the generating device.";
assert_eq!(format_flowed(text), expected); assert_eq!(format_flowed(text), expected);
} }
@@ -175,6 +188,10 @@ mod tests {
let expected = "> this is a quoted line"; let expected = "> this is a quoted line";
assert_eq!(format_flowed_quote(quote), expected); assert_eq!(format_flowed_quote(quote), expected);
let quote = "first quoted line\nsecond quoted line";
let expected = "> first quoted line\r\n> second quoted line";
assert_eq!(format_flowed_quote(quote), expected);
let quote = "> foo bar baz"; let quote = "> foo bar baz";
let expected = "> > foo bar baz"; let expected = "> > foo bar baz";
assert_eq!(format_flowed_quote(quote), expected); assert_eq!(format_flowed_quote(quote), expected);
@@ -185,4 +202,25 @@ mod tests {
> unwrapped on the receiver"; > unwrapped on the receiver";
assert_eq!(format_flowed_quote(quote), expected); assert_eq!(format_flowed_quote(quote), expected);
} }
#[async_std::test]
async fn test_send_quotes() -> anyhow::Result<()> {
let alice = TestContext::new_alice().await;
let bob = TestContext::new_bob().await;
let chat = alice.create_chat(&bob).await;
let sent = alice.send_text(chat.id, "> First quote").await;
let received = bob.recv_msg(&sent).await;
assert_eq!(received.text.as_deref(), Some("> First quote"));
assert!(received.quoted_text().is_none());
assert!(received.quoted_message(&bob).await?.is_none());
let sent = alice.send_text(chat.id, "> Second quote").await;
let received = bob.recv_msg(&sent).await;
assert_eq!(received.text.as_deref(), Some("> Second quote"));
assert!(received.quoted_text().is_none());
assert!(received.quoted_message(&bob).await?.is_none());
Ok(())
}
} }

View File

@@ -1085,10 +1085,15 @@ impl<'a> MimeFactory<'a> {
} }
}; };
let quoted_text = self let mut quoted_text = self
.msg .msg
.quoted_text() .quoted_text()
.map(|quote| format_flowed_quote(&quote) + "\r\n\r\n"); .map(|quote| format_flowed_quote(&quote) + "\r\n\r\n");
if quoted_text.is_none() && final_text.starts_with('>') {
// Insert empty line to avoid receiver treating user-sent quote as topquote inserted by
// Delta Chat.
quoted_text = Some("\r\n".to_string());
}
let flowed_text = format_flowed(final_text); let flowed_text = format_flowed(final_text);
let footer = &self.selfstatus; let footer = &self.selfstatus;

View File

@@ -204,13 +204,11 @@ fn remove_top_quote<'a>(lines: &'a [&str]) -> (&'a [&'a str], Option<String>) {
first_quoted_line = l; first_quoted_line = l;
} }
last_quoted_line = Some(l) last_quoted_line = Some(l)
} else if !is_empty_line(line) { } else if is_quoted_headline(line) && !has_quoted_headline && last_quoted_line.is_none() {
if is_quoted_headline(line) && !has_quoted_headline && last_quoted_line.is_none() { has_quoted_headline = true
has_quoted_headline = true } else {
} else { /* non-quoting line found */
/* non-quoting line found */ break;
break;
}
} }
} }
if let Some(last_quoted_line) = last_quoted_line { if let Some(last_quoted_line) = last_quoted_line {