edit message's text (#6550)

> _greetings from the ice of the deutsche bahn 🚂🚃🚃🚃 always a pleasure to
see how well delta chat meanwhile performs in bad networks :)_

this PR adds an API to request other chat members to replace the message
text of an already sent message. scope is mainly to fix typos. this
feature is known from whatsapp, telegram, signal, and is
[requested](https://support.delta.chat/t/retract-edit-sent-messages/1918)
[since](https://support.delta.chat/t/edit-messages-in-delta-chat/899)
[years](https://github.com/deltachat/deltachat-android/issues/198).

technically, a message with an
[`Obsoletes:`](https://datatracker.ietf.org/doc/html/rfc2076#section-3.6)
header is sent out.

```
From: alice@nine
To: bob@nine
Message-ID: 2000@nine
In-Reply-To: 1000@nine
Obsoletes: 1000@nine

Edited: this is the new text
```

the body is the new text, prefixed by the static text `Edited:` (which
is not a header). the latter is to make the message appear more nicely
in Non-Delta-MUA. save for the `In-Reply-To` header. the `Edited:`
prefix is removed by Delta Chat on receiving.

headers should be protected and moved to e2ee part as usual.

corrected message text is flagged, and UI should show this state, in
practise as "Edited" beside the date.

in case, the original message is not found, nothing happens and the
correction message is trashes (assuming the original was deleted).
question: is the `Obsoletes:` header a good choice? i _thought_ there is
some more specifica RFC, but i cannot find sth. in any case, it should
be an header that is not used otherwise by MUA, to make sure no wanted
messages disappear.

what is NOT done and out of scope:
- optimise if messages are not yet sent out. this is doable, but
introduces quite some cornercaes and may not be worth the effort
- replaces images or other attachments. this is also a bit cornercasy
and beyond "typo fixing", and better be handled by "delete for me and
others" (which may come soon, having the idea now, it seems easy :)
- get edit history in any way. not sure if this is worth the effort,
remember, as being a private messenger, we assume trust among chat
members. it is also questionable wrt privacy, seized devices etc.
- add text where nothing was before; again, scope is "typo fixing",
better avoid cornercases
- saved messages are not edited (this is anyway questionable)
- quoted texts, that are used for the case the original message is
deleted, are not updated
- edits are ignored when the original message is not there yet (out of
order, not yet downloaded)
- message status indicator does not show if edits are sent out or not -
similar to reactions, webxdc updates, sync messages. signal has the same
issue :) still, connectivity should show if there are messages pending

<img width="366" alt="Screenshot 2025-02-17 at 17 25 02"
src="https://github.com/user-attachments/assets/a4a53996-438b-47ef-9004-2c9062eea5d7"
/>

corresponding iOS branch (no PR yet):
https://github.com/deltachat/deltachat-ios/compare/main...r10s/edit-messages

---------

Co-authored-by: l <link2xt@testrun.org>
This commit is contained in:
bjoern
2025-02-21 16:25:42 +01:00
committed by GitHub
parent 85cd3836e0
commit 85cbfde6e4
11 changed files with 254 additions and 5 deletions

View File

@@ -25,7 +25,7 @@ use crate::color::str_to_color;
use crate::config::Config;
use crate::constants::{
self, Blocked, Chattype, DC_CHAT_ID_ALLDONE_HINT, DC_CHAT_ID_ARCHIVED_LINK,
DC_CHAT_ID_LAST_SPECIAL, DC_CHAT_ID_TRASH, DC_RESEND_USER_AVATAR_DAYS,
DC_CHAT_ID_LAST_SPECIAL, DC_CHAT_ID_TRASH, DC_RESEND_USER_AVATAR_DAYS, EDITED_PREFIX,
TIMESTAMP_SENT_TOLERANCE,
};
use crate::contact::{self, Contact, ContactId, Origin};
@@ -3142,6 +3142,65 @@ pub async fn send_text_msg(
send_msg(context, chat_id, &mut msg).await
}
/// Sends chat members a request to edit the given message's text.
pub async fn send_edit_request(context: &Context, msg_id: MsgId, new_text: String) -> Result<()> {
let mut original_msg = Message::load_from_db(context, msg_id).await?;
ensure!(
original_msg.from_id == ContactId::SELF,
"Can edit only own messages"
);
ensure!(!original_msg.is_info(), "Cannot edit info messages");
ensure!(
original_msg.viewtype != Viewtype::VideochatInvitation,
"Cannot edit videochat invitations"
);
ensure!(
!original_msg.text.is_empty(), // avoid complexity in UI element changes. focus is typos and rewordings
"Cannot add text"
);
ensure!(!new_text.trim().is_empty(), "Edited text cannot be empty");
if original_msg.text == new_text {
info!(context, "Text unchanged.");
return Ok(());
}
save_text_edit_to_db(context, &mut original_msg, &new_text).await?;
let mut edit_msg = Message::new_text(EDITED_PREFIX.to_owned() + &new_text); // prefix only set for nicer display in Non-Delta-MUAs
edit_msg.set_quote(context, Some(&original_msg)).await?; // quote only set for nicer display in Non-Delta-MUAs
if original_msg.get_showpadlock() {
edit_msg.param.set_int(Param::GuaranteeE2ee, 1);
}
edit_msg
.param
.set(Param::TextEditFor, original_msg.rfc724_mid);
edit_msg.hidden = true;
send_msg(context, original_msg.chat_id, &mut edit_msg).await?;
Ok(())
}
pub(crate) async fn save_text_edit_to_db(
context: &Context,
original_msg: &mut Message,
new_text: &str,
) -> Result<()> {
original_msg.param.set_int(Param::IsEdited, 1);
context
.sql
.execute(
"UPDATE msgs SET txt=?, txt_normalized=?, param=? WHERE id=?",
(
new_text,
message::normalize_text(new_text),
original_msg.param.to_string(),
original_msg.id,
),
)
.await?;
context.emit_msgs_changed(original_msg.chat_id, original_msg.id);
Ok(())
}
/// Sends invitation to a videochat.
pub async fn send_videochat_invitation(context: &Context, chat_id: ChatId) -> Result<MsgId> {
ensure!(

View File

@@ -3645,3 +3645,68 @@ async fn test_one_to_one_chat_no_group_member_timestamps() {
let payload = sent.payload;
assert!(!payload.contains("Chat-Group-Member-Timestamps:"));
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_send_edit_request() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = &tcm.alice().await;
let bob = &tcm.bob().await;
let alice_chat = alice.create_chat(bob).await;
// Alice sends a message with typos, followed by a correction message
let sent1 = alice.send_text(alice_chat.id, "zext me in delra.cat").await;
let alice_msg = sent1.load_from_db().await;
assert_eq!(alice_msg.text, "zext me in delra.cat");
send_edit_request(alice, alice_msg.id, "Text me on Delta.Chat".to_string()).await?;
let sent2 = alice.pop_sent_msg().await;
let test = Message::load_from_db(alice, alice_msg.id).await?;
assert_eq!(test.text, "Text me on Delta.Chat");
// Bob receives both messages and has the correct text at the end
let bob_msg = bob.recv_msg(&sent1).await;
assert_eq!(bob_msg.text, "zext me in delra.cat");
bob.recv_msg_opt(&sent2).await;
let test = Message::load_from_db(bob, bob_msg.id).await?;
assert_eq!(test.text, "Text me on Delta.Chat");
// alice has another device, and sees the correction also there
let alice2 = tcm.alice().await;
let alice2_msg = alice2.recv_msg(&sent1).await;
assert_eq!(alice2_msg.text, "zext me in delra.cat");
alice2.recv_msg_opt(&sent2).await;
let test = Message::load_from_db(&alice2, alice2_msg.id).await?;
assert_eq!(test.text, "Text me on Delta.Chat");
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_receive_edit_request_after_removal() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = &tcm.alice().await;
let bob = &tcm.bob().await;
let alice_chat = alice.create_chat(bob).await;
// Alice sends a messag with typos, followed by a correction message
let sent1 = alice.send_text(alice_chat.id, "zext me in delra.cat").await;
let alice_msg = sent1.load_from_db().await;
send_edit_request(alice, alice_msg.id, "Text me on Delta.Chat".to_string()).await?;
let sent2 = alice.pop_sent_msg().await;
// Bob receives first message, deletes it and then ignores the correction
let bob_msg = bob.recv_msg(&sent1).await;
let bob_chat_id = bob_msg.chat_id;
assert_eq!(bob_msg.text, "zext me in delra.cat");
assert_eq!(bob_chat_id.get_msg_cnt(bob).await?, 1);
delete_msgs(bob, &[bob_msg.id]).await?;
assert_eq!(bob_chat_id.get_msg_cnt(bob).await?, 0);
bob.recv_msg_trash(&sent2).await;
assert_eq!(bob_chat_id.get_msg_cnt(bob).await?, 0);
Ok(())
}

View File

@@ -234,6 +234,10 @@ pub(crate) const TIMESTAMP_SENT_TOLERANCE: i64 = 60;
/// on mobile devices. See also [`crate::chat::CantSendReason::SecurejoinWait`].
pub(crate) const SECUREJOIN_WAIT_TIMEOUT: u64 = 15;
// To make text edits clearer for Non-Delta-MUA or old Delta Chats, edited text will be prefixed by EDITED_PREFIX.
// Newer Delta Chats will remove the prefix as needed.
pub(crate) const EDITED_PREFIX: &str = "✏️";
#[cfg(test)]
mod tests {
use num_traits::FromPrimitive;

View File

@@ -80,6 +80,9 @@ pub enum HeaderDef {
ChatDispositionNotificationTo,
ChatWebrtcRoom,
/// This message obsoletes the text of the message defined here by rfc724_mid.
ChatEdit,
/// [Autocrypt](https://autocrypt.org/) header.
Autocrypt,
AutocryptGossip,

View File

@@ -931,6 +931,11 @@ impl Message {
0 != self.param.get_int(Param::Forwarded).unwrap_or_default()
}
/// Returns true if the message is edited.
pub fn is_edited(&self) -> bool {
self.param.get_bool(Param::IsEdited).unwrap_or_default()
}
/// Returns true if the message is an informational message.
pub fn is_info(&self) -> bool {
let cmd = self.param.get_cmd();

View File

@@ -725,6 +725,18 @@ impl MimeFactory {
}
}
if let Loaded::Message { msg, .. } = &self.loaded {
if let Some(original_rfc724_mid) = msg.param.get(Param::TextEditFor) {
headers.push((
"Chat-Edit",
mail_builder::headers::message_id::MessageId::new(
original_rfc724_mid.to_string(),
)
.into(),
));
}
}
// Non-standard headers.
headers.push((
"Chat-Version",
@@ -849,7 +861,7 @@ impl MimeFactory {
if header_name == "message-id" {
unprotected_headers.push(header.clone());
hidden_headers.push(header.clone());
} else if header_name == "chat-user-avatar" {
} else if header_name == "chat-user-avatar" || header_name == "chat-edit" {
hidden_headers.push(header.clone());
} else if header_name == "autocrypt"
&& !context.get_config_bool(Config::ProtectAutocrypt).await?

View File

@@ -290,7 +290,9 @@ impl MimeMessage {
// For now only avatar headers can be hidden.
if !headers.contains_key(&key)
&& (key == "chat-user-avatar" || key == "chat-group-avatar")
&& (key == "chat-user-avatar"
|| key == "chat-group-avatar"
|| key == "chat-edit")
{
headers.insert(key.to_string(), field.get_value());
}
@@ -448,6 +450,7 @@ impl MimeMessage {
HeaderDef::ChatGroupMemberAdded,
HeaderDef::ChatGroupMemberTimestamps,
HeaderDef::ChatGroupPastMembers,
HeaderDef::ChatEdit,
] {
headers.remove(h.get_headername());
}

View File

@@ -205,7 +205,12 @@ pub enum Param {
/// For messages: Whether [crate::message::Viewtype::Sticker] should be forced.
ForceSticker = b'X',
// 'L' was defined as ProtectionSettingsTimestamp for Chats, however, never used in production.
/// For messages: Message is a text edit message. the value of this parameter is the rfc724_mid of the original message.
TextEditFor = b'I',
/// For messages: Message text was edited.
IsEdited = b'L',
}
/// An object for handling key=value parameter lists.

View File

@@ -15,7 +15,7 @@ use regex::Regex;
use crate::aheader::EncryptPreference;
use crate::chat::{self, Chat, ChatId, ChatIdBlocked, ProtectionStatus};
use crate::config::Config;
use crate::constants::{Blocked, Chattype, ShowEmails, DC_CHAT_ID_TRASH};
use crate::constants::{Blocked, Chattype, ShowEmails, DC_CHAT_ID_TRASH, EDITED_PREFIX};
use crate::contact::{Contact, ContactId, Origin};
use crate::context::Context;
use crate::debug_logging::maybe_set_logging_xdc_inner;
@@ -1500,6 +1500,41 @@ async fn add_parts(
}
}
if let Some(rfc724_mid) = mime_parser.get_header(HeaderDef::ChatEdit) {
chat_id = DC_CHAT_ID_TRASH;
if let Some((original_msg_id, _)) = rfc724_mid_exists(context, rfc724_mid).await? {
if let Some(mut original_msg) =
Message::load_from_db_optional(context, original_msg_id).await?
{
if original_msg.from_id == from_id {
if let Some(part) = mime_parser.parts.first() {
let edit_msg_showpadlock = part
.param
.get_bool(Param::GuaranteeE2ee)
.unwrap_or_default();
if edit_msg_showpadlock || !original_msg.get_showpadlock() {
let new_text =
part.msg.strip_prefix(EDITED_PREFIX).unwrap_or(&part.msg);
chat::save_text_edit_to_db(context, &mut original_msg, new_text)
.await?;
} else {
warn!(context, "Edit message: Not encrypted.");
}
}
} else {
warn!(context, "Edit message: Bad sender.");
}
} else {
warn!(context, "Edit message: Database entry does not exist.");
}
} else {
warn!(
context,
"Edit message: rfc724_mid {rfc724_mid:?} not found."
);
}
}
let mut parts = mime_parser.parts.iter().peekable();
while let Some(part) = parts.next() {
if part.is_reaction {