Skip to content

Commit 85cbfde

Browse files
r10slink2xt
andauthored
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](deltachat/deltachat-android#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): deltachat/deltachat-ios@main...r10s/edit-messages --------- Co-authored-by: l <link2xt@testrun.org>
1 parent 85cd383 commit 85cbfde

File tree

11 files changed

+254
-5
lines changed

11 files changed

+254
-5
lines changed

deltachat-ffi/deltachat.h

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1039,6 +1039,23 @@ uint32_t dc_send_msg_sync (dc_context_t* context, uint32
10391039
uint32_t dc_send_text_msg (dc_context_t* context, uint32_t chat_id, const char* text_to_send);
10401040

10411041

1042+
/**
1043+
* Send chat members a request to edit the given message's text.
1044+
*
1045+
* Only outgoing messages sent by self can be edited.
1046+
* Edited messages should be flagged as such in the UI, see dc_msg_is_edited().
1047+
* UI is informed about changes using the event #DC_EVENT_MSGS_CHANGED.
1048+
* If the text is not changed, no event and no edit request message are sent.
1049+
*
1050+
* @memberof dc_context_t
1051+
* @param context The context object as returned from dc_context_new().
1052+
* @param msg_id The message ID of the message to edit.
1053+
* @param new_text The new text.
1054+
* This must not be NULL nor empty.
1055+
*/
1056+
void dc_send_edit_request (dc_context_t* context, uint32_t msg_id, const char* new_text);
1057+
1058+
10421059
/**
10431060
* Send invitation to a videochat.
10441061
*
@@ -4432,6 +4449,20 @@ int dc_msg_is_sent (const dc_msg_t* msg);
44324449
int dc_msg_is_forwarded (const dc_msg_t* msg);
44334450

44344451

4452+
/**
4453+
* Check if the message was edited.
4454+
*
4455+
* Edited messages should be marked by the UI as such,
4456+
* e.g. by the text "Edited" beside the time.
4457+
* To edit messages, use dc_send_edit_request().
4458+
*
4459+
* @memberof dc_msg_t
4460+
* @param msg The message object.
4461+
* @return 1=message is edited, 0=message not edited.
4462+
*/
4463+
int dc_msg_is_edited (const dc_msg_t* msg);
4464+
4465+
44354466
/**
44364467
* Check if the message is an informational message, created by the
44374468
* device or by another users. Such messages are not "typed" by the user but

deltachat-ffi/src/lib.rs

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1041,6 +1041,23 @@ pub unsafe extern "C" fn dc_send_text_msg(
10411041
})
10421042
}
10431043

1044+
#[no_mangle]
1045+
pub unsafe extern "C" fn dc_send_edit_request(
1046+
context: *mut dc_context_t,
1047+
msg_id: u32,
1048+
new_text: *const libc::c_char,
1049+
) {
1050+
if context.is_null() || new_text.is_null() {
1051+
eprintln!("ignoring careless call to dc_send_edit_request()");
1052+
return;
1053+
}
1054+
let ctx = &*context;
1055+
let new_text = to_string_lossy(new_text);
1056+
1057+
block_on(chat::send_edit_request(ctx, MsgId::new(msg_id), new_text))
1058+
.unwrap_or_log_default(ctx, "Failed to send text edit")
1059+
}
1060+
10441061
#[no_mangle]
10451062
pub unsafe extern "C" fn dc_send_videochat_invitation(
10461063
context: *mut dc_context_t,
@@ -3683,6 +3700,16 @@ pub unsafe extern "C" fn dc_msg_is_forwarded(msg: *mut dc_msg_t) -> libc::c_int
36833700
ffi_msg.message.is_forwarded().into()
36843701
}
36853702

3703+
#[no_mangle]
3704+
pub unsafe extern "C" fn dc_msg_is_edited(msg: *mut dc_msg_t) -> libc::c_int {
3705+
if msg.is_null() {
3706+
eprintln!("ignoring careless call to dc_msg_is_edited()");
3707+
return 0;
3708+
}
3709+
let ffi_msg = &*msg;
3710+
ffi_msg.message.is_edited().into()
3711+
}
3712+
36863713
#[no_mangle]
36873714
pub unsafe extern "C" fn dc_msg_is_info(msg: *mut dc_msg_t) -> libc::c_int {
36883715
if msg.is_null() {

src/chat.rs

Lines changed: 60 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ use crate::color::str_to_color;
2525
use crate::config::Config;
2626
use crate::constants::{
2727
self, Blocked, Chattype, DC_CHAT_ID_ALLDONE_HINT, DC_CHAT_ID_ARCHIVED_LINK,
28-
DC_CHAT_ID_LAST_SPECIAL, DC_CHAT_ID_TRASH, DC_RESEND_USER_AVATAR_DAYS,
28+
DC_CHAT_ID_LAST_SPECIAL, DC_CHAT_ID_TRASH, DC_RESEND_USER_AVATAR_DAYS, EDITED_PREFIX,
2929
TIMESTAMP_SENT_TOLERANCE,
3030
};
3131
use crate::contact::{self, Contact, ContactId, Origin};
@@ -3142,6 +3142,65 @@ pub async fn send_text_msg(
31423142
send_msg(context, chat_id, &mut msg).await
31433143
}
31443144

3145+
/// Sends chat members a request to edit the given message's text.
3146+
pub async fn send_edit_request(context: &Context, msg_id: MsgId, new_text: String) -> Result<()> {
3147+
let mut original_msg = Message::load_from_db(context, msg_id).await?;
3148+
ensure!(
3149+
original_msg.from_id == ContactId::SELF,
3150+
"Can edit only own messages"
3151+
);
3152+
ensure!(!original_msg.is_info(), "Cannot edit info messages");
3153+
ensure!(
3154+
original_msg.viewtype != Viewtype::VideochatInvitation,
3155+
"Cannot edit videochat invitations"
3156+
);
3157+
ensure!(
3158+
!original_msg.text.is_empty(), // avoid complexity in UI element changes. focus is typos and rewordings
3159+
"Cannot add text"
3160+
);
3161+
ensure!(!new_text.trim().is_empty(), "Edited text cannot be empty");
3162+
if original_msg.text == new_text {
3163+
info!(context, "Text unchanged.");
3164+
return Ok(());
3165+
}
3166+
3167+
save_text_edit_to_db(context, &mut original_msg, &new_text).await?;
3168+
3169+
let mut edit_msg = Message::new_text(EDITED_PREFIX.to_owned() + &new_text); // prefix only set for nicer display in Non-Delta-MUAs
3170+
edit_msg.set_quote(context, Some(&original_msg)).await?; // quote only set for nicer display in Non-Delta-MUAs
3171+
if original_msg.get_showpadlock() {
3172+
edit_msg.param.set_int(Param::GuaranteeE2ee, 1);
3173+
}
3174+
edit_msg
3175+
.param
3176+
.set(Param::TextEditFor, original_msg.rfc724_mid);
3177+
edit_msg.hidden = true;
3178+
send_msg(context, original_msg.chat_id, &mut edit_msg).await?;
3179+
Ok(())
3180+
}
3181+
3182+
pub(crate) async fn save_text_edit_to_db(
3183+
context: &Context,
3184+
original_msg: &mut Message,
3185+
new_text: &str,
3186+
) -> Result<()> {
3187+
original_msg.param.set_int(Param::IsEdited, 1);
3188+
context
3189+
.sql
3190+
.execute(
3191+
"UPDATE msgs SET txt=?, txt_normalized=?, param=? WHERE id=?",
3192+
(
3193+
new_text,
3194+
message::normalize_text(new_text),
3195+
original_msg.param.to_string(),
3196+
original_msg.id,
3197+
),
3198+
)
3199+
.await?;
3200+
context.emit_msgs_changed(original_msg.chat_id, original_msg.id);
3201+
Ok(())
3202+
}
3203+
31453204
/// Sends invitation to a videochat.
31463205
pub async fn send_videochat_invitation(context: &Context, chat_id: ChatId) -> Result<MsgId> {
31473206
ensure!(

src/chat/chat_tests.rs

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3645,3 +3645,68 @@ async fn test_one_to_one_chat_no_group_member_timestamps() {
36453645
let payload = sent.payload;
36463646
assert!(!payload.contains("Chat-Group-Member-Timestamps:"));
36473647
}
3648+
3649+
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
3650+
async fn test_send_edit_request() -> Result<()> {
3651+
let mut tcm = TestContextManager::new();
3652+
let alice = &tcm.alice().await;
3653+
let bob = &tcm.bob().await;
3654+
let alice_chat = alice.create_chat(bob).await;
3655+
3656+
// Alice sends a message with typos, followed by a correction message
3657+
let sent1 = alice.send_text(alice_chat.id, "zext me in delra.cat").await;
3658+
let alice_msg = sent1.load_from_db().await;
3659+
assert_eq!(alice_msg.text, "zext me in delra.cat");
3660+
3661+
send_edit_request(alice, alice_msg.id, "Text me on Delta.Chat".to_string()).await?;
3662+
let sent2 = alice.pop_sent_msg().await;
3663+
let test = Message::load_from_db(alice, alice_msg.id).await?;
3664+
assert_eq!(test.text, "Text me on Delta.Chat");
3665+
3666+
// Bob receives both messages and has the correct text at the end
3667+
let bob_msg = bob.recv_msg(&sent1).await;
3668+
assert_eq!(bob_msg.text, "zext me in delra.cat");
3669+
3670+
bob.recv_msg_opt(&sent2).await;
3671+
let test = Message::load_from_db(bob, bob_msg.id).await?;
3672+
assert_eq!(test.text, "Text me on Delta.Chat");
3673+
3674+
// alice has another device, and sees the correction also there
3675+
let alice2 = tcm.alice().await;
3676+
let alice2_msg = alice2.recv_msg(&sent1).await;
3677+
assert_eq!(alice2_msg.text, "zext me in delra.cat");
3678+
3679+
alice2.recv_msg_opt(&sent2).await;
3680+
let test = Message::load_from_db(&alice2, alice2_msg.id).await?;
3681+
assert_eq!(test.text, "Text me on Delta.Chat");
3682+
3683+
Ok(())
3684+
}
3685+
3686+
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
3687+
async fn test_receive_edit_request_after_removal() -> Result<()> {
3688+
let mut tcm = TestContextManager::new();
3689+
let alice = &tcm.alice().await;
3690+
let bob = &tcm.bob().await;
3691+
let alice_chat = alice.create_chat(bob).await;
3692+
3693+
// Alice sends a messag with typos, followed by a correction message
3694+
let sent1 = alice.send_text(alice_chat.id, "zext me in delra.cat").await;
3695+
let alice_msg = sent1.load_from_db().await;
3696+
send_edit_request(alice, alice_msg.id, "Text me on Delta.Chat".to_string()).await?;
3697+
let sent2 = alice.pop_sent_msg().await;
3698+
3699+
// Bob receives first message, deletes it and then ignores the correction
3700+
let bob_msg = bob.recv_msg(&sent1).await;
3701+
let bob_chat_id = bob_msg.chat_id;
3702+
assert_eq!(bob_msg.text, "zext me in delra.cat");
3703+
assert_eq!(bob_chat_id.get_msg_cnt(bob).await?, 1);
3704+
3705+
delete_msgs(bob, &[bob_msg.id]).await?;
3706+
assert_eq!(bob_chat_id.get_msg_cnt(bob).await?, 0);
3707+
3708+
bob.recv_msg_trash(&sent2).await;
3709+
assert_eq!(bob_chat_id.get_msg_cnt(bob).await?, 0);
3710+
3711+
Ok(())
3712+
}

src/constants.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -234,6 +234,10 @@ pub(crate) const TIMESTAMP_SENT_TOLERANCE: i64 = 60;
234234
/// on mobile devices. See also [`crate::chat::CantSendReason::SecurejoinWait`].
235235
pub(crate) const SECUREJOIN_WAIT_TIMEOUT: u64 = 15;
236236

237+
// To make text edits clearer for Non-Delta-MUA or old Delta Chats, edited text will be prefixed by EDITED_PREFIX.
238+
// Newer Delta Chats will remove the prefix as needed.
239+
pub(crate) const EDITED_PREFIX: &str = "✏️";
240+
237241
#[cfg(test)]
238242
mod tests {
239243
use num_traits::FromPrimitive;

src/headerdef.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,9 @@ pub enum HeaderDef {
8080
ChatDispositionNotificationTo,
8181
ChatWebrtcRoom,
8282

83+
/// This message obsoletes the text of the message defined here by rfc724_mid.
84+
ChatEdit,
85+
8386
/// [Autocrypt](https://autocrypt.org/) header.
8487
Autocrypt,
8588
AutocryptGossip,

src/message.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -931,6 +931,11 @@ impl Message {
931931
0 != self.param.get_int(Param::Forwarded).unwrap_or_default()
932932
}
933933

934+
/// Returns true if the message is edited.
935+
pub fn is_edited(&self) -> bool {
936+
self.param.get_bool(Param::IsEdited).unwrap_or_default()
937+
}
938+
934939
/// Returns true if the message is an informational message.
935940
pub fn is_info(&self) -> bool {
936941
let cmd = self.param.get_cmd();

src/mimefactory.rs

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -725,6 +725,18 @@ impl MimeFactory {
725725
}
726726
}
727727

728+
if let Loaded::Message { msg, .. } = &self.loaded {
729+
if let Some(original_rfc724_mid) = msg.param.get(Param::TextEditFor) {
730+
headers.push((
731+
"Chat-Edit",
732+
mail_builder::headers::message_id::MessageId::new(
733+
original_rfc724_mid.to_string(),
734+
)
735+
.into(),
736+
));
737+
}
738+
}
739+
728740
// Non-standard headers.
729741
headers.push((
730742
"Chat-Version",
@@ -849,7 +861,7 @@ impl MimeFactory {
849861
if header_name == "message-id" {
850862
unprotected_headers.push(header.clone());
851863
hidden_headers.push(header.clone());
852-
} else if header_name == "chat-user-avatar" {
864+
} else if header_name == "chat-user-avatar" || header_name == "chat-edit" {
853865
hidden_headers.push(header.clone());
854866
} else if header_name == "autocrypt"
855867
&& !context.get_config_bool(Config::ProtectAutocrypt).await?

src/mimeparser.rs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -290,7 +290,9 @@ impl MimeMessage {
290290

291291
// For now only avatar headers can be hidden.
292292
if !headers.contains_key(&key)
293-
&& (key == "chat-user-avatar" || key == "chat-group-avatar")
293+
&& (key == "chat-user-avatar"
294+
|| key == "chat-group-avatar"
295+
|| key == "chat-edit")
294296
{
295297
headers.insert(key.to_string(), field.get_value());
296298
}
@@ -448,6 +450,7 @@ impl MimeMessage {
448450
HeaderDef::ChatGroupMemberAdded,
449451
HeaderDef::ChatGroupMemberTimestamps,
450452
HeaderDef::ChatGroupPastMembers,
453+
HeaderDef::ChatEdit,
451454
] {
452455
headers.remove(h.get_headername());
453456
}

src/param.rs

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -205,7 +205,12 @@ pub enum Param {
205205

206206
/// For messages: Whether [crate::message::Viewtype::Sticker] should be forced.
207207
ForceSticker = b'X',
208-
// 'L' was defined as ProtectionSettingsTimestamp for Chats, however, never used in production.
208+
209+
/// For messages: Message is a text edit message. the value of this parameter is the rfc724_mid of the original message.
210+
TextEditFor = b'I',
211+
212+
/// For messages: Message text was edited.
213+
IsEdited = b'L',
209214
}
210215

211216
/// An object for handling key=value parameter lists.

0 commit comments

Comments
 (0)