From 462f76792ddecae7df0b71580911c26e04ccf5a3 Mon Sep 17 00:00:00 2001 From: Hocuri Date: Thu, 5 Feb 2026 15:00:06 +0100 Subject: [PATCH 01/19] [WIP] Group and broadcast channel descriptions --- deltachat-ffi/deltachat.h | 45 ++++++++-- deltachat-ffi/src/lib.rs | 31 +++++++ deltachat-jsonrpc/src/api.rs | 10 +++ deltachat-jsonrpc/src/api/types/chat.rs | 4 + deltachat-jsonrpc/src/api/types/message.rs | 2 + deltachat-repl/src/cmdline.rs | 8 ++ deltachat-repl/src/main.rs | 1 + src/chat.rs | 98 +++++++++++++++++++++- src/chat/chat_tests.rs | 45 ++++++++++ src/headerdef.rs | 3 + src/message.rs | 1 + src/mimefactory.rs | 22 +++++ src/mimeparser.rs | 5 ++ src/param.rs | 3 + src/receive_imf.rs | 53 +++++++++++- src/sql/migrations.rs | 9 ++ src/stock_str.rs | 19 +++++ 17 files changed, 351 insertions(+), 8 deletions(-) diff --git a/deltachat-ffi/deltachat.h b/deltachat-ffi/deltachat.h index aed6b6e8ed..4e826fe77a 100644 --- a/deltachat-ffi/deltachat.h +++ b/deltachat-ffi/deltachat.h @@ -1854,21 +1854,42 @@ int dc_remove_contact_from_chat (dc_context_t* context, uint32_t ch /** - * Set group name. + * Set the name of a group or broadcast channel. * * If the group is already _promoted_ (any message was sent to the group), - * all group members are informed by a special status message that is sent automatically by this function. + * or if this is a brodacast channel, + * all members are informed by a special status message that is sent automatically by this function. * * Sends out #DC_EVENT_CHAT_MODIFIED and #DC_EVENT_MSGS_CHANGED if a status message was sent. * * @memberof dc_context_t - * @param chat_id The chat ID to set the name for. Must be a group chat. + * @param chat_id The chat ID to set the name for. Must be a group chat or broadcast channel. * @param name New name of the group. * @param context The context object. * @return 1=success, 0=error */ int dc_set_chat_name (dc_context_t* context, uint32_t chat_id, const char* name); + +/** + * Set group or broadcast channel description. + * + * If the group is already _promoted_ (any message was sent to the group), + * or if this is a brodacast channel, + * all members are informed by a special status message that is sent automatically by this function. + * + * Sends out #DC_EVENT_CHAT_MODIFIED and #DC_EVENT_MSGS_CHANGED if a status message was sent. + * + * To find out the description of a chat, use dc_chat_get_description(). + * + * @memberof dc_context_t + * @param context The context object. + * @param chat_id The chat ID to set the description for. Must be a group chat or broadcast channel. + * @param image New description. + * @return 1=success, 0=error + */ +int dc_set_chat_description (dc_context_t* context, uint32_t chat_id, const char* description); + /** * Set the chat's ephemeral message timer. * @@ -1889,10 +1910,11 @@ int dc_set_chat_name (dc_context_t* context, uint32_t ch int dc_set_chat_ephemeral_timer (dc_context_t* context, uint32_t chat_id, uint32_t timer); /** - * Set group profile image. + * Set group or broadcast channel profile image. * * If the group is already _promoted_ (any message was sent to the group), - * all group members are informed by a special status message that is sent automatically by this function. + * or if this is a brodacast channel, + * all members are informed by a special status message that is sent automatically by this function. * * Sends out #DC_EVENT_CHAT_MODIFIED and #DC_EVENT_MSGS_CHANGED if a status message was sent. * @@ -1900,7 +1922,7 @@ int dc_set_chat_ephemeral_timer (dc_context_t* context, uint32_t chat_id, uint32 * * @memberof dc_context_t * @param context The context object. - * @param chat_id The chat ID to set the image for. + * @param chat_id The chat ID to set the image for. Must be a group chat or broadcast channel. * @param image Full path of the image to use as the group image. The image will immediately be copied to the * `blobdir`; the original image will not be needed anymore. * If you pass NULL here, the group image is deleted (for promoted groups, all members are informed about @@ -3792,6 +3814,17 @@ char* dc_chat_get_mailinglist_addr (const dc_chat_t* chat); char* dc_chat_get_name (const dc_chat_t* chat); +/** + * Get chat description. + * + * @memberof dc_chat_t + * @param chat The chat object. + * @return Returns the chat description. For single chats, this is the contact's status. + * The returned string must be released using dc_str_unref(). NULL is never returned. + */ +char* dc_chat_get_description (const dc_chat_t* chat); + + /** * Get the chat's profile image. * For groups, this is the image set by any group member diff --git a/deltachat-ffi/src/lib.rs b/deltachat-ffi/src/lib.rs index 07010d72a3..b9975d679c 100644 --- a/deltachat-ffi/src/lib.rs +++ b/deltachat-ffi/src/lib.rs @@ -1841,6 +1841,27 @@ pub unsafe extern "C" fn dc_set_chat_name( }) } +#[no_mangle] +pub unsafe extern "C" fn dc_set_chat_description( + context: *mut dc_context_t, + chat_id: u32, + description: *const libc::c_char, +) -> libc::c_int { + if context.is_null() || description.is_null() { + eprintln!("ignoring careless call to dc_set_chat_description()"); + return 0; + } + let ctx = &*context; + let description = to_string_lossy(description); + + block_on(async move { + chat::set_chat_description(ctx, ChatId::new(chat_id), &description) + .await + .map(|_| 1) + .unwrap_or_log_default(ctx, "Failed set_chat_description") + }) +} + #[no_mangle] pub unsafe extern "C" fn dc_set_chat_profile_image( context: *mut dc_context_t, @@ -3066,6 +3087,16 @@ pub unsafe extern "C" fn dc_chat_get_name(chat: *mut dc_chat_t) -> *mut libc::c_ ffi_chat.chat.get_name().strdup() } +#[no_mangle] +pub unsafe extern "C" fn dc_chat_get_description(chat: *mut dc_chat_t) -> *mut libc::c_char { + if chat.is_null() { + eprintln!("ignoring careless call to dc_chat_get_description()"); + return "".strdup(); + } + let ffi_chat = &*chat; + ffi_chat.chat.get_description().strdup() +} + #[no_mangle] pub unsafe extern "C" fn dc_chat_get_mailinglist_addr(chat: *mut dc_chat_t) -> *mut libc::c_char { if chat.is_null() { diff --git a/deltachat-jsonrpc/src/api.rs b/deltachat-jsonrpc/src/api.rs index 4f59dd6428..5ff7d614fd 100644 --- a/deltachat-jsonrpc/src/api.rs +++ b/deltachat-jsonrpc/src/api.rs @@ -1076,6 +1076,16 @@ impl CommandApi { chat::set_chat_name(&ctx, ChatId::new(chat_id), &new_name).await } + async fn set_chat_description( + &self, + account_id: u32, + chat_id: u32, + new_description: String, + ) -> Result<()> { + let ctx = self.get_context(account_id).await?; + chat::set_chat_description(&ctx, ChatId::new(chat_id), &new_description).await + } + /// Set group profile image. /// /// If the group is already _promoted_ (any message was sent to the group), diff --git a/deltachat-jsonrpc/src/api/types/chat.rs b/deltachat-jsonrpc/src/api/types/chat.rs index 42d69ffa60..6b9b7efe6a 100644 --- a/deltachat-jsonrpc/src/api/types/chat.rs +++ b/deltachat-jsonrpc/src/api/types/chat.rs @@ -16,6 +16,7 @@ use super::color_int_to_hex_string; pub struct FullChat { id: u32, name: String, + description: String, /// True if the chat is encrypted. /// This means that all messages in the chat are encrypted, @@ -109,6 +110,7 @@ impl FullChat { Ok(FullChat { id: chat_id, name: chat.name.clone(), + description: chat.description.clone(), is_encrypted: chat.is_encrypted(context).await?, profile_image, //BLOBS ? archived: chat.get_visibility() == chat::ChatVisibility::Archived, @@ -146,6 +148,7 @@ impl FullChat { pub struct BasicChat { id: u32, name: String, + description: String, /// True if the chat is encrypted. /// This means that all messages in the chat are encrypted, @@ -197,6 +200,7 @@ impl BasicChat { Ok(BasicChat { id: chat_id, name: chat.name.clone(), + description: chat.description.clone(), is_encrypted: chat.is_encrypted(context).await?, profile_image, //BLOBS ? archived: chat.get_visibility() == chat::ChatVisibility::Archived, diff --git a/deltachat-jsonrpc/src/api/types/message.rs b/deltachat-jsonrpc/src/api/types/message.rs index 384d6ee212..3a66a0f98a 100644 --- a/deltachat-jsonrpc/src/api/types/message.rs +++ b/deltachat-jsonrpc/src/api/types/message.rs @@ -388,6 +388,7 @@ impl From for DownloadState { pub enum SystemMessageType { Unknown, GroupNameChanged, + GroupDescriptionChanged, GroupImageChanged, MemberAddedToGroup, MemberRemovedFromGroup, @@ -440,6 +441,7 @@ impl From for SystemMessageType { match system_message_type { SystemMessage::Unknown => SystemMessageType::Unknown, SystemMessage::GroupNameChanged => SystemMessageType::GroupNameChanged, + SystemMessage::GroupDescriptionChanged => SystemMessageType::GroupDescriptionChanged, SystemMessage::GroupImageChanged => SystemMessageType::GroupImageChanged, SystemMessage::MemberAddedToGroup => SystemMessageType::MemberAddedToGroup, SystemMessage::MemberRemovedFromGroup => SystemMessageType::MemberRemovedFromGroup, diff --git a/deltachat-repl/src/cmdline.rs b/deltachat-repl/src/cmdline.rs index 1eecbe1464..4dd13f94dd 100644 --- a/deltachat-repl/src/cmdline.rs +++ b/deltachat-repl/src/cmdline.rs @@ -343,6 +343,7 @@ pub async fn cmdline(context: Context, line: &str, chat_id: &mut ChatId) -> Resu addmember \n\ removemember \n\ groupname \n\ + groupdescription \n\ groupimage \n\ chatinfo\n\ sendlocations \n\ @@ -770,6 +771,13 @@ pub async fn cmdline(context: Context, line: &str, chat_id: &mut ChatId) -> Resu println!("Chat name set"); } + "groupdescription" => { + ensure!(sel_chat.is_some(), "No chat selected."); + ensure!(!arg1.is_empty(), "Argument missing."); + chat::set_chat_description(&context, sel_chat.as_ref().unwrap().get_id(), arg1).await?; + + println!("Chat description set"); + } "groupimage" => { ensure!(sel_chat.is_some(), "No chat selected."); ensure!(!arg1.is_empty(), "Argument missing."); diff --git a/deltachat-repl/src/main.rs b/deltachat-repl/src/main.rs index 518ea8deea..312dabd303 100644 --- a/deltachat-repl/src/main.rs +++ b/deltachat-repl/src/main.rs @@ -192,6 +192,7 @@ const CHAT_COMMANDS: [&str; 39] = [ "addmember", "removemember", "groupname", + "groupdescription", "groupimage", "chatinfo", "sendlocations", diff --git a/src/chat.rs b/src/chat.rs index 7023b03adf..ee40ad50de 100644 --- a/src/chat.rs +++ b/src/chat.rs @@ -1330,6 +1330,9 @@ pub struct Chat { /// Chat name. pub name: String, + /// Chat description. + pub description: String, + /// Whether the chat is archived or pinned. pub visibility: ChatVisibility, @@ -1357,7 +1360,7 @@ impl Chat { .sql .query_row( "SELECT c.type, c.name, c.grpid, c.param, c.archived, - c.blocked, c.locations_send_until, c.muted_until + c.blocked, c.locations_send_until, c.muted_until, c.description FROM chats c WHERE c.id=?;", (chat_id,), @@ -1372,6 +1375,9 @@ impl Chat { blocked: row.get::<_, Option<_>>(5)?.unwrap_or_default(), is_sending_locations: row.get(6)?, mute_duration: row.get(7)?, + // TODO I'm not sure if we actually want to load the description every time the chat is opened + // Probably there should be just an extra call to load it + description: row.get::<_, String>(8)?, }; Ok(c) }, @@ -1539,6 +1545,11 @@ impl Chat { &self.name } + /// Returns chat description. + pub fn get_description(&self) -> &str { + &self.description + } + /// Returns mailing list address where messages are sent to. pub fn get_mailinglist_addr(&self) -> Option<&str> { self.param.get(Param::ListPost) @@ -4188,6 +4199,87 @@ async fn send_member_removal_msg( } /// Sets group or mailing list chat name. +pub async fn set_chat_description( + context: &Context, + chat_id: ChatId, + new_description: &str, +) -> Result<()> { + set_chat_description_ex(context, Sync, chat_id, new_description).await +} + +// TODO compare with set_chat_profile_image() +async fn set_chat_description_ex( + context: &Context, + mut sync: sync::Sync, + chat_id: ChatId, + new_description: &str, +) -> Result<()> { + let new_description = sanitize_bidi_characters(new_description.trim()); + let mut success = false; + + ensure!(!chat_id.is_special(), "Invalid chat ID"); + + let chat = Chat::load_from_db(context, chat_id).await?; + ensure!( + !chat.grpid.is_empty(), + "Cannot set description for ad hoc groups" + ); + + if chat.typ == Chattype::Group || chat.typ == Chattype::OutBroadcast { + if chat.description == new_description { + success = true; + } else if !chat.is_self_in_chat(context).await? { + context.emit_event(EventType::ErrorSelfNotInGroup( + "Cannot set chat description; self not in group".into(), + )); + } else { + context + .sql + .execute( + "UPDATE chats SET description=? WHERE id=?", + (&new_description, chat_id), + ) + .await?; + + if chat.is_promoted() { + let mut msg = Message::new(Viewtype::Text); + msg.text = stock_str::msg_chat_description_changed(context, ContactId::SELF).await; + msg.param.set_cmd(SystemMessage::GroupDescriptionChanged); + // msg.param.set(Param::Arg, &new_description); // TODO I don't think we need this + let timestamp = time(); + // TODO not sure if we need this timestamp here - + // we don't have it for the chat name + msg.param + .set_i64(Param::ChatDescriptionTimestamp, timestamp); + + let mut chat = chat.clone(); + chat.param + .set_i64(Param::ChatDescriptionTimestamp, timestamp); + chat.description = new_description.to_string(); + chat.update_param(context).await?; + + msg.id = send_msg(context, chat_id, &mut msg).await?; + context.emit_msgs_changed(chat_id, msg.id); + sync = Nosync; + } + context.emit_event(EventType::ChatModified(chat_id)); + success = true; + } + } + + if !success { + bail!("Failed to set chat description"); + } + if sync.into() && chat.description != new_description { + chat.sync(context, SyncAction::SetDescription(new_description)) + .await + .log_err(context) + .ok(); + } + + Ok(()) +} + pub async fn set_chat_name(context: &Context, chat_id: ChatId, new_name: &str) -> Result<()> { rename_ex(context, Sync, chat_id, new_name).await } @@ -5015,6 +5107,7 @@ pub(crate) enum SyncAction { /// /// The list is a list of pairs of fingerprint and address. SetPgpContacts(Vec<(String, String)>), + SetDescription(String), Delete, } @@ -5113,6 +5206,9 @@ impl Context { Err(anyhow!("sync_alter_chat({id:?}, {action:?}): Bad request.")) } SyncAction::Rename(to) => rename_ex(self, Nosync, chat_id, to).await, + SyncAction::SetDescription(to) => { + set_chat_description_ex(self, Nosync, chat_id, to).await + } SyncAction::SetContacts(addrs) => set_contacts_by_addrs(self, chat_id, addrs).await, SyncAction::SetPgpContacts(fingerprint_addrs) => { set_contacts_by_fingerprints(self, chat_id, fingerprint_addrs).await diff --git a/src/chat/chat_tests.rs b/src/chat/chat_tests.rs index 5befc53cff..10da13987c 100644 --- a/src/chat/chat_tests.rs +++ b/src/chat/chat_tests.rs @@ -3156,6 +3156,51 @@ async fn test_broadcasts_name_and_avatar() -> Result<()> { Ok(()) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_chat_description() -> Result<()> { + let mut tcm = TestContextManager::new(); + let alice = &tcm.alice().await; + let alice2 = &tcm.alice().await; + let bob = &tcm.bob().await; + + // alice.set_config_bool(Config::SyncMsgs, true).await?; + + tcm.section("Create a group chat, and add Bob"); + let alice_chat_id = create_group(alice, "My Group").await?; + let qr = get_securejoin_qr(alice, Some(alice_chat_id)).await.unwrap(); + tcm.exec_securejoin_qr(bob, alice, &qr).await; + + tcm.section("Alice sets a chat description"); + set_chat_description(alice, alice_chat_id, "This is a cool group").await?; + let sent = alice.pop_sent_msg().await; + + tcm.section("Bob receives the description change"); + let rcvd = bob.recv_msg(&sent).await; + assert_eq!(rcvd.get_info_type(), SystemMessage::GroupDescriptionChanged); + assert_eq!( + rcvd.text, + "Group description changed to \"This is a cool group\" by alice@example.org." + ); + + let bob_chat = Chat::load_from_db(bob, rcvd.chat_id).await?; + assert_eq!(bob_chat.get_description(), "This is a cool group"); + + tcm.section("Check Alice's second device"); + alice2.recv_msg(&sent).await; + let alice2_chat_id = get_chat_id_by_grpid( + alice2, + &Chat::load_from_db(alice, alice_chat_id).await?.grpid, + ) + .await? + .unwrap() + .0; + let alice2_chat = Chat::load_from_db(alice2, alice2_chat_id).await?; + println!("alice2 chat description: {}", alice2_chat.get_description()); + assert_eq!(alice2_chat.get_description(), "This is a cool group"); + + Ok(()) +} + /// Tests that directly after broadcast-securejoin, /// the brodacast is shown correctly on both devices. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] diff --git a/src/headerdef.rs b/src/headerdef.rs index 979cb8749f..166815f0f0 100644 --- a/src/headerdef.rs +++ b/src/headerdef.rs @@ -60,6 +60,9 @@ pub enum HeaderDef { ChatGroupName, ChatGroupNameChanged, ChatGroupNameTimestamp, + ChatGroupDescription, + ChatGroupDescriptionChanged, + ChatGroupDescriptionTimestamp, ChatVerified, ChatGroupAvatar, ChatUserAvatar, diff --git a/src/message.rs b/src/message.rs index 0676daf7d8..65c591361a 100644 --- a/src/message.rs +++ b/src/message.rs @@ -1005,6 +1005,7 @@ impl Message { pub async fn get_info_contact_id(&self, context: &Context) -> Result> { match self.param.get_cmd() { SystemMessage::GroupNameChanged + | SystemMessage::GroupDescriptionChanged | SystemMessage::GroupImageChanged | SystemMessage::EphemeralTimerChanged => { if self.from_id != ContactId::INFO { diff --git a/src/mimefactory.rs b/src/mimefactory.rs index 6ff3375336..b7068ad01b 100644 --- a/src/mimefactory.rs +++ b/src/mimefactory.rs @@ -1602,6 +1602,20 @@ impl MimeFactory { )); } + // TODO we don't want to send the description in every message + if !chat.description.is_empty() { + headers.push(( + "Chat-Group-Description", + mail_builder::headers::text::Text::new(chat.description.clone()).into(), + )); + } + if let Some(ts) = chat.param.get_i64(Param::ChatDescriptionTimestamp) { + headers.push(( + "Chat-Group-Description-Timestamp", + mail_builder::headers::text::Text::new(ts.to_string()).into(), + )); + } + match command { SystemMessage::MemberRemovedFromGroup => { let email_to_remove = msg.param.get(Param::Arg).unwrap_or_default(); @@ -1669,6 +1683,14 @@ impl MimeFactory { mail_builder::headers::text::Text::new(old_name).into(), )); } + SystemMessage::GroupDescriptionChanged => { + let old_description = msg.param.get(Param::Arg).unwrap_or_default().to_string(); + // TODO this is unnecessary + headers.push(( + "Chat-Group-Description-Changed", + mail_builder::headers::text::Text::new(old_description).into(), + )); + } SystemMessage::GroupImageChanged => { headers.push(( "Chat-Content", diff --git a/src/mimeparser.rs b/src/mimeparser.rs index 4de47dd9a4..31ffbd1197 100644 --- a/src/mimeparser.rs +++ b/src/mimeparser.rs @@ -231,6 +231,9 @@ pub enum SystemMessage { /// send messages. SecurejoinWaitTimeout = 15, + /// Group description changed. + GroupDescriptionChanged = 19, + /// Self-sent-message that contains only json used for multi-device-sync; /// if possible, we attach that to other messages as for locations. MultiDeviceSync = 20, @@ -773,6 +776,8 @@ impl MimeMessage { self.is_system_message = SystemMessage::MemberAddedToGroup; } else if self.get_header(HeaderDef::ChatGroupNameChanged).is_some() { self.is_system_message = SystemMessage::GroupNameChanged; + } else if self.get_header(HeaderDef::ChatGroupDescriptionChanged).is_some() { + self.is_system_message = SystemMessage::GroupDescriptionChanged; } } diff --git a/src/param.rs b/src/param.rs index 30c4f008a7..fe614b020d 100644 --- a/src/param.rs +++ b/src/param.rs @@ -219,6 +219,9 @@ pub enum Param { /// For Chats: timestamp of group name update. GroupNameTimestamp = b'g', + /// For Chats: timestamp of chat description update. + ChatDescriptionTimestamp = b'Z', + /// For Chats: timestamp of member list update. MemberListTimestamp = b'k', diff --git a/src/receive_imf.rs b/src/receive_imf.rs index ff1c2ed570..99ce1cb184 100644 --- a/src/receive_imf.rs +++ b/src/receive_imf.rs @@ -8,7 +8,8 @@ use std::sync::LazyLock; use anyhow::{Context as _, Result, ensure}; use data_encoding::BASE32_NOPAD; use deltachat_contact_tools::{ - ContactAddress, addr_cmp, addr_normalize, may_be_valid_addr, sanitize_single_line, + ContactAddress, addr_cmp, addr_normalize, may_be_valid_addr, sanitize_bidi_characters, + sanitize_single_line, }; use iroh_gossip::proto::TopicId; use mailparse::SingleInfo; @@ -3339,6 +3340,56 @@ async fn apply_chat_name_and_avatar_changes( } } + // ========== Apply chat description changes ========== + + let chat_description_timestamp = mime_parser + .get_header(HeaderDef::ChatGroupDescriptionTimestamp) + .and_then(|s| s.parse::().ok()); + + if let Some(new_description) = mime_parser + .get_header(HeaderDef::ChatGroupDescription) + .map(|d| d.trim()) + { + let new_description = sanitize_bidi_characters(new_description.trim()); + + let chat_group_description_timestamp = chat + .param + .get_i64(Param::ChatDescriptionTimestamp) + .unwrap_or(0); + let chat_description_timestamp = + chat_description_timestamp.unwrap_or(mime_parser.timestamp_sent); + // To provide group description consistency, compare descriptions if timestamps are equal. + if (chat_group_description_timestamp, &new_description) + < (chat_description_timestamp, &chat.description) + && chat + .id + .update_timestamp( + context, + Param::ChatDescriptionTimestamp, + chat_description_timestamp, + ) + .await? + && new_description != chat.description + { + info!(context, "Updating description for chat {}.", chat.id); + context + .sql + .execute( + "UPDATE chats SET description=? WHERE id=?", + (&new_description, chat.id), + ) + .await?; + *send_event_chat_modified = true; + } + if mime_parser + .get_header(HeaderDef::ChatGroupDescriptionChanged) + .is_some() + { + better_msg + .get_or_insert(stock_str::msg_chat_description_changed(context, from_id).await); + } + } + // ========== Apply chat avatar changes ========== if let (Some(value), None) = (mime_parser.get_header(HeaderDef::ChatContent), &better_msg) diff --git a/src/sql/migrations.rs b/src/sql/migrations.rs index b1cd08f667..d2ef5ae4b2 100644 --- a/src/sql/migrations.rs +++ b/src/sql/migrations.rs @@ -1558,6 +1558,15 @@ ALTER TABLE contacts ADD COLUMN name_normalized TEXT; .await?; } + inc_and_check(&mut migration_version, 147)?; + if dbversion < migration_version { + sql.execute_migration( + "ALTER TABLE chats ADD COLUMN description TEXT DEFAULT '';", + migration_version, + ) + .await?; + } + let new_version = sql .get_raw_config_int(VERSION_CFG) .await? diff --git a/src/stock_str.rs b/src/stock_str.rs index c38b766911..254aa6d260 100644 --- a/src/stock_str.rs +++ b/src/stock_str.rs @@ -423,6 +423,12 @@ https://delta.chat/donate"))] #[strum(props(fallback = "Incoming video call"))] IncomingVideoCall = 235, + + #[strum(props(fallback = "Chat description changed by %1$s."))] + MsgYouChangedDescription = 240, + + #[strum(props(fallback = "You changed the chat description."))] + MsgYouChatDescriptionChangedBy = 241, } impl StockMessage { @@ -601,6 +607,19 @@ pub(crate) async fn msg_grp_img_changed(context: &Context, by_contact: ContactId } } +pub(crate) async fn msg_chat_description_changed( + context: &Context, + by_contact: ContactId, +) -> String { + if by_contact == ContactId::SELF { + translated(context, StockMessage::MsgYouChangedDescription).await + } else { + translated(context, StockMessage::MsgYouChangedDescription) + .await + .replace1(&by_contact.get_stock_name(context).await) + } +} + /// Stock string: `You added member %1$s.` or `Member %1$s added by %2$s.`. /// /// The `added_member_addr` parameter should be an email address and is looked up in the From 055cc50584afc36509df8bd78a052632729b39be Mon Sep 17 00:00:00 2001 From: Hocuri Date: Thu, 5 Feb 2026 15:28:21 +0100 Subject: [PATCH 02/19] Resolve some TODOs --- src/chat.rs | 85 ++++++++++++++++++++---------------------- src/chat/chat_tests.rs | 5 +-- 2 files changed, 42 insertions(+), 48 deletions(-) diff --git a/src/chat.rs b/src/chat.rs index ee40ad50de..089d6f2542 100644 --- a/src/chat.rs +++ b/src/chat.rs @@ -4207,7 +4207,6 @@ pub async fn set_chat_description( set_chat_description_ex(context, Sync, chat_id, new_description).await } -// TODO compare with set_chat_profile_image() async fn set_chat_description_ex( context: &Context, mut sync: sync::Sync, @@ -4215,61 +4214,50 @@ async fn set_chat_description_ex( new_description: &str, ) -> Result<()> { let new_description = sanitize_bidi_characters(new_description.trim()); - let mut success = false; ensure!(!chat_id.is_special(), "Invalid chat ID"); - let chat = Chat::load_from_db(context, chat_id).await?; + let mut chat = Chat::load_from_db(context, chat_id).await?; + ensure!( + chat.typ == Chattype::Group || chat.typ == Chattype::OutBroadcast, + "Can only set profile image for groups / broadcasts" + ); ensure!( !chat.grpid.is_empty(), "Cannot set description for ad hoc groups" ); - if chat.typ == Chattype::Group || chat.typ == Chattype::OutBroadcast { - if chat.description == new_description { - success = true; - } else if !chat.is_self_in_chat(context).await? { - context.emit_event(EventType::ErrorSelfNotInGroup( - "Cannot set chat description; self not in group".into(), - )); - } else { - context - .sql - .execute( - "UPDATE chats SET description=? WHERE id=?", - (&new_description, chat_id), - ) - .await?; - - if chat.is_promoted() { - let mut msg = Message::new(Viewtype::Text); - msg.text = stock_str::msg_chat_description_changed(context, ContactId::SELF).await; - msg.param.set_cmd(SystemMessage::GroupDescriptionChanged); - // msg.param.set(Param::Arg, &new_description); // TODO I don't think we need this - let timestamp = time(); - // TODO not sure if we need this timestamp here - - // we don't have it for the chat name - msg.param - .set_i64(Param::ChatDescriptionTimestamp, timestamp); - - let mut chat = chat.clone(); - chat.param - .set_i64(Param::ChatDescriptionTimestamp, timestamp); - chat.description = new_description.to_string(); - chat.update_param(context).await?; + if chat.description == new_description { + // Nothing to do + } else if !chat.is_self_in_chat(context).await? { + context.emit_event(EventType::ErrorSelfNotInGroup( + "Cannot set chat description; self not in group".into(), + )); + bail!("Failed to set profile image"); + } else { + context + .sql + .execute( + "UPDATE chats SET description=? WHERE id=?", + (&new_description, chat_id), + ) + .await?; - msg.id = send_msg(context, chat_id, &mut msg).await?; - context.emit_msgs_changed(chat_id, msg.id); - sync = Nosync; - } - context.emit_event(EventType::ChatModified(chat_id)); - success = true; + if chat.is_promoted() { + let mut msg = Message::new(Viewtype::Text); + msg.text = stock_str::msg_chat_description_changed(context, ContactId::SELF).await; + msg.param.set_cmd(SystemMessage::GroupDescriptionChanged); + chat.param.set_i64(Param::ChatDescriptionTimestamp, time()); + chat.description = new_description.to_string(); + chat.update_param(context).await?; + + msg.id = send_msg(context, chat_id, &mut msg).await?; + context.emit_msgs_changed(chat_id, msg.id); + sync = Nosync; } + context.emit_event(EventType::ChatModified(chat_id)); } - if !success { - bail!("Failed to set chat description"); - } if sync.into() && chat.description != new_description { chat.sync(context, SyncAction::SetDescription(new_description)) .await @@ -4280,6 +4268,15 @@ async fn set_chat_description_ex( Ok(()) } +/// Set group or broadcast channel description. +/// +/// If the group is already _promoted_ (any message was sent to the group), +/// or if this is a brodacast channel, +/// all members are informed by a special status message that is sent automatically by this function. +/// +/// Sends out #DC_EVENT_CHAT_MODIFIED and #DC_EVENT_MSGS_CHANGED if a status message was sent. +/// +/// To find out the description of a chat, use dc_chat_get_description(). pub async fn set_chat_name(context: &Context, chat_id: ChatId, new_name: &str) -> Result<()> { rename_ex(context, Sync, chat_id, new_name).await } diff --git a/src/chat/chat_tests.rs b/src/chat/chat_tests.rs index 10da13987c..7cea518999 100644 --- a/src/chat/chat_tests.rs +++ b/src/chat/chat_tests.rs @@ -3177,10 +3177,7 @@ async fn test_chat_description() -> Result<()> { tcm.section("Bob receives the description change"); let rcvd = bob.recv_msg(&sent).await; assert_eq!(rcvd.get_info_type(), SystemMessage::GroupDescriptionChanged); - assert_eq!( - rcvd.text, - "Group description changed to \"This is a cool group\" by alice@example.org." - ); + assert_eq!(rcvd.text, "Chat description changed by alice@example.org."); let bob_chat = Chat::load_from_db(bob, rcvd.chat_id).await?; assert_eq!(bob_chat.get_description(), "This is a cool group"); From c8803540a917c03139c629c623ed3360846877ef Mon Sep 17 00:00:00 2001 From: Hocuri Date: Thu, 5 Feb 2026 15:52:43 +0100 Subject: [PATCH 03/19] perf: Don't load the description every time a chat is loaded --- deltachat-ffi/deltachat.h | 30 ----------------------- deltachat-ffi/src/lib.rs | 31 ------------------------ deltachat-jsonrpc/src/api.rs | 24 +++++++++++++++++-- deltachat-jsonrpc/src/api/types/chat.rs | 4 ---- deltachat-repl/src/main.rs | 2 +- src/chat.rs | 32 +++++++++++++------------ src/chat/chat_tests.rs | 16 +++++++++---- src/mimefactory.rs | 11 ++++----- src/receive_imf.rs | 5 ++-- src/sql/migrations.rs | 2 +- 10 files changed, 60 insertions(+), 97 deletions(-) diff --git a/deltachat-ffi/deltachat.h b/deltachat-ffi/deltachat.h index 4e826fe77a..6c8185d566 100644 --- a/deltachat-ffi/deltachat.h +++ b/deltachat-ffi/deltachat.h @@ -1871,25 +1871,6 @@ int dc_remove_contact_from_chat (dc_context_t* context, uint32_t ch int dc_set_chat_name (dc_context_t* context, uint32_t chat_id, const char* name); -/** - * Set group or broadcast channel description. - * - * If the group is already _promoted_ (any message was sent to the group), - * or if this is a brodacast channel, - * all members are informed by a special status message that is sent automatically by this function. - * - * Sends out #DC_EVENT_CHAT_MODIFIED and #DC_EVENT_MSGS_CHANGED if a status message was sent. - * - * To find out the description of a chat, use dc_chat_get_description(). - * - * @memberof dc_context_t - * @param context The context object. - * @param chat_id The chat ID to set the description for. Must be a group chat or broadcast channel. - * @param image New description. - * @return 1=success, 0=error - */ -int dc_set_chat_description (dc_context_t* context, uint32_t chat_id, const char* description); - /** * Set the chat's ephemeral message timer. * @@ -3814,17 +3795,6 @@ char* dc_chat_get_mailinglist_addr (const dc_chat_t* chat); char* dc_chat_get_name (const dc_chat_t* chat); -/** - * Get chat description. - * - * @memberof dc_chat_t - * @param chat The chat object. - * @return Returns the chat description. For single chats, this is the contact's status. - * The returned string must be released using dc_str_unref(). NULL is never returned. - */ -char* dc_chat_get_description (const dc_chat_t* chat); - - /** * Get the chat's profile image. * For groups, this is the image set by any group member diff --git a/deltachat-ffi/src/lib.rs b/deltachat-ffi/src/lib.rs index b9975d679c..07010d72a3 100644 --- a/deltachat-ffi/src/lib.rs +++ b/deltachat-ffi/src/lib.rs @@ -1841,27 +1841,6 @@ pub unsafe extern "C" fn dc_set_chat_name( }) } -#[no_mangle] -pub unsafe extern "C" fn dc_set_chat_description( - context: *mut dc_context_t, - chat_id: u32, - description: *const libc::c_char, -) -> libc::c_int { - if context.is_null() || description.is_null() { - eprintln!("ignoring careless call to dc_set_chat_description()"); - return 0; - } - let ctx = &*context; - let description = to_string_lossy(description); - - block_on(async move { - chat::set_chat_description(ctx, ChatId::new(chat_id), &description) - .await - .map(|_| 1) - .unwrap_or_log_default(ctx, "Failed set_chat_description") - }) -} - #[no_mangle] pub unsafe extern "C" fn dc_set_chat_profile_image( context: *mut dc_context_t, @@ -3087,16 +3066,6 @@ pub unsafe extern "C" fn dc_chat_get_name(chat: *mut dc_chat_t) -> *mut libc::c_ ffi_chat.chat.get_name().strdup() } -#[no_mangle] -pub unsafe extern "C" fn dc_chat_get_description(chat: *mut dc_chat_t) -> *mut libc::c_char { - if chat.is_null() { - eprintln!("ignoring careless call to dc_chat_get_description()"); - return "".strdup(); - } - let ffi_chat = &*chat; - ffi_chat.chat.get_description().strdup() -} - #[no_mangle] pub unsafe extern "C" fn dc_chat_get_mailinglist_addr(chat: *mut dc_chat_t) -> *mut libc::c_char { if chat.is_null() { diff --git a/deltachat-jsonrpc/src/api.rs b/deltachat-jsonrpc/src/api.rs index 5ff7d614fd..8a780ccd1d 100644 --- a/deltachat-jsonrpc/src/api.rs +++ b/deltachat-jsonrpc/src/api.rs @@ -1068,7 +1068,8 @@ impl CommandApi { /// Set group name. /// /// If the group is already _promoted_ (any message was sent to the group), - /// all group members are informed by a special status message that is sent automatically by this function. + /// or if this is a brodacast channel, + /// all members are informed by a special status message that is sent automatically by this function. /// /// Sends out #DC_EVENT_CHAT_MODIFIED and #DC_EVENT_MSGS_CHANGED if a status message was sent. async fn set_chat_name(&self, account_id: u32, chat_id: u32, new_name: String) -> Result<()> { @@ -1076,6 +1077,15 @@ impl CommandApi { chat::set_chat_name(&ctx, ChatId::new(chat_id), &new_name).await } + /// Set group or broadcast channel description. + /// + /// If the group is already _promoted_ (any message was sent to the group), + /// or if this is a brodacast channel, + /// all members are informed by a special status message that is sent automatically by this function. + /// + /// Sends out #DC_EVENT_CHAT_MODIFIED and #DC_EVENT_MSGS_CHANGED if a status message was sent. + /// + /// See also [`Self::get_chat_description`] / `getChatDescription()`. async fn set_chat_description( &self, account_id: u32, @@ -1086,10 +1096,20 @@ impl CommandApi { chat::set_chat_description(&ctx, ChatId::new(chat_id), &new_description).await } + /// Load the chat description from the database. + /// + /// This should be shown in the profile page of the chat, + /// and is settable by [`Self::set_chat_description`] / `setChatDescription()`. + async fn get_chat_description(&self, account_id: u32, chat_id: u32) -> Result { + let ctx = self.get_context(account_id).await?; + ChatId::new(chat_id).get_description(&ctx).await + } + /// Set group profile image. /// /// If the group is already _promoted_ (any message was sent to the group), - /// all group members are informed by a special status message that is sent automatically by this function. + /// or if this is a brodacast channel, + /// all members are informed by a special status message that is sent automatically by this function. /// /// Sends out #DC_EVENT_CHAT_MODIFIED and #DC_EVENT_MSGS_CHANGED if a status message was sent. /// diff --git a/deltachat-jsonrpc/src/api/types/chat.rs b/deltachat-jsonrpc/src/api/types/chat.rs index 6b9b7efe6a..42d69ffa60 100644 --- a/deltachat-jsonrpc/src/api/types/chat.rs +++ b/deltachat-jsonrpc/src/api/types/chat.rs @@ -16,7 +16,6 @@ use super::color_int_to_hex_string; pub struct FullChat { id: u32, name: String, - description: String, /// True if the chat is encrypted. /// This means that all messages in the chat are encrypted, @@ -110,7 +109,6 @@ impl FullChat { Ok(FullChat { id: chat_id, name: chat.name.clone(), - description: chat.description.clone(), is_encrypted: chat.is_encrypted(context).await?, profile_image, //BLOBS ? archived: chat.get_visibility() == chat::ChatVisibility::Archived, @@ -148,7 +146,6 @@ impl FullChat { pub struct BasicChat { id: u32, name: String, - description: String, /// True if the chat is encrypted. /// This means that all messages in the chat are encrypted, @@ -200,7 +197,6 @@ impl BasicChat { Ok(BasicChat { id: chat_id, name: chat.name.clone(), - description: chat.description.clone(), is_encrypted: chat.is_encrypted(context).await?, profile_image, //BLOBS ? archived: chat.get_visibility() == chat::ChatVisibility::Archived, diff --git a/deltachat-repl/src/main.rs b/deltachat-repl/src/main.rs index 312dabd303..8740678d61 100644 --- a/deltachat-repl/src/main.rs +++ b/deltachat-repl/src/main.rs @@ -179,7 +179,7 @@ const DB_COMMANDS: [&str; 11] = [ "housekeeping", ]; -const CHAT_COMMANDS: [&str; 39] = [ +const CHAT_COMMANDS: [&str; 40] = [ "listchats", "listarchived", "start-realtime", diff --git a/src/chat.rs b/src/chat.rs index 089d6f2542..2c79b09dea 100644 --- a/src/chat.rs +++ b/src/chat.rs @@ -1272,6 +1272,19 @@ SELECT id, rfc724_mid, pre_rfc724_mid, timestamp, ?, 1 FROM msgs WHERE chat_id=? Ok(sort_timestamp) } + + /// Returns chat description. + pub async fn get_description(&self, context: &Context) -> Result { + let description = context + .sql + .query_get_value( + "SELECT description FROM chat_descriptions WHERE id=?", + (self,), + ) + .await? + .unwrap_or_default(); + Ok(description) + } } impl std::fmt::Display for ChatId { @@ -1330,9 +1343,6 @@ pub struct Chat { /// Chat name. pub name: String, - /// Chat description. - pub description: String, - /// Whether the chat is archived or pinned. pub visibility: ChatVisibility, @@ -1360,7 +1370,7 @@ impl Chat { .sql .query_row( "SELECT c.type, c.name, c.grpid, c.param, c.archived, - c.blocked, c.locations_send_until, c.muted_until, c.description + c.blocked, c.locations_send_until, c.muted_until FROM chats c WHERE c.id=?;", (chat_id,), @@ -1375,9 +1385,6 @@ impl Chat { blocked: row.get::<_, Option<_>>(5)?.unwrap_or_default(), is_sending_locations: row.get(6)?, mute_duration: row.get(7)?, - // TODO I'm not sure if we actually want to load the description every time the chat is opened - // Probably there should be just an extra call to load it - description: row.get::<_, String>(8)?, }; Ok(c) }, @@ -1545,11 +1552,6 @@ impl Chat { &self.name } - /// Returns chat description. - pub fn get_description(&self) -> &str { - &self.description - } - /// Returns mailing list address where messages are sent to. pub fn get_mailinglist_addr(&self) -> Option<&str> { self.param.get(Param::ListPost) @@ -4218,6 +4220,7 @@ async fn set_chat_description_ex( ensure!(!chat_id.is_special(), "Invalid chat ID"); let mut chat = Chat::load_from_db(context, chat_id).await?; + let old_description = chat_id.get_description(context).await?; ensure!( chat.typ == Chattype::Group || chat.typ == Chattype::OutBroadcast, "Can only set profile image for groups / broadcasts" @@ -4227,7 +4230,7 @@ async fn set_chat_description_ex( "Cannot set description for ad hoc groups" ); - if chat.description == new_description { + if old_description == new_description { // Nothing to do } else if !chat.is_self_in_chat(context).await? { context.emit_event(EventType::ErrorSelfNotInGroup( @@ -4248,7 +4251,6 @@ async fn set_chat_description_ex( msg.text = stock_str::msg_chat_description_changed(context, ContactId::SELF).await; msg.param.set_cmd(SystemMessage::GroupDescriptionChanged); chat.param.set_i64(Param::ChatDescriptionTimestamp, time()); - chat.description = new_description.to_string(); chat.update_param(context).await?; msg.id = send_msg(context, chat_id, &mut msg).await?; @@ -4258,7 +4260,7 @@ async fn set_chat_description_ex( context.emit_event(EventType::ChatModified(chat_id)); } - if sync.into() && chat.description != new_description { + if sync.into() && old_description != new_description { chat.sync(context, SyncAction::SetDescription(new_description)) .await .log_err(context) diff --git a/src/chat/chat_tests.rs b/src/chat/chat_tests.rs index 7cea518999..c3a7d84a1d 100644 --- a/src/chat/chat_tests.rs +++ b/src/chat/chat_tests.rs @@ -3179,8 +3179,10 @@ async fn test_chat_description() -> Result<()> { assert_eq!(rcvd.get_info_type(), SystemMessage::GroupDescriptionChanged); assert_eq!(rcvd.text, "Chat description changed by alice@example.org."); - let bob_chat = Chat::load_from_db(bob, rcvd.chat_id).await?; - assert_eq!(bob_chat.get_description(), "This is a cool group"); + assert_eq!( + rcvd.chat_id.get_description(bob).await?, + "This is a cool group" + ); tcm.section("Check Alice's second device"); alice2.recv_msg(&sent).await; @@ -3191,9 +3193,13 @@ async fn test_chat_description() -> Result<()> { .await? .unwrap() .0; - let alice2_chat = Chat::load_from_db(alice2, alice2_chat_id).await?; - println!("alice2 chat description: {}", alice2_chat.get_description()); - assert_eq!(alice2_chat.get_description(), "This is a cool group"); + + assert_eq!( + alice2_chat_id.get_description(alice2).await?, + "This is a cool group" + ); + + //TODO check deleting again Ok(()) } diff --git a/src/mimefactory.rs b/src/mimefactory.rs index b7068ad01b..aa3fc5e76e 100644 --- a/src/mimefactory.rs +++ b/src/mimefactory.rs @@ -1602,13 +1602,12 @@ impl MimeFactory { )); } + let description = chat.id.get_description(context).await?; // TODO we don't want to send the description in every message - if !chat.description.is_empty() { - headers.push(( - "Chat-Group-Description", - mail_builder::headers::text::Text::new(chat.description.clone()).into(), - )); - } + headers.push(( + "Chat-Group-Description", + mail_builder::headers::text::Text::new(description.clone()).into(), + )); if let Some(ts) = chat.param.get_i64(Param::ChatDescriptionTimestamp) { headers.push(( "Chat-Group-Description-Timestamp", diff --git a/src/receive_imf.rs b/src/receive_imf.rs index 99ce1cb184..a7acdb41d6 100644 --- a/src/receive_imf.rs +++ b/src/receive_imf.rs @@ -3351,6 +3351,7 @@ async fn apply_chat_name_and_avatar_changes( .map(|d| d.trim()) { let new_description = sanitize_bidi_characters(new_description.trim()); + let old_description = chat.id.get_description(context).await?; let chat_group_description_timestamp = chat .param @@ -3360,7 +3361,7 @@ async fn apply_chat_name_and_avatar_changes( chat_description_timestamp.unwrap_or(mime_parser.timestamp_sent); // To provide group description consistency, compare descriptions if timestamps are equal. if (chat_group_description_timestamp, &new_description) - < (chat_description_timestamp, &chat.description) + < (chat_description_timestamp, &old_description) && chat .id .update_timestamp( @@ -3369,7 +3370,7 @@ async fn apply_chat_name_and_avatar_changes( chat_description_timestamp, ) .await? - && new_description != chat.description + && new_description != old_description { info!(context, "Updating description for chat {}.", chat.id); context diff --git a/src/sql/migrations.rs b/src/sql/migrations.rs index d2ef5ae4b2..efff26d77f 100644 --- a/src/sql/migrations.rs +++ b/src/sql/migrations.rs @@ -1561,7 +1561,7 @@ ALTER TABLE contacts ADD COLUMN name_normalized TEXT; inc_and_check(&mut migration_version, 147)?; if dbversion < migration_version { sql.execute_migration( - "ALTER TABLE chats ADD COLUMN description TEXT DEFAULT '';", + "CREATE TABLE chats_descriptions (id INTEGER PRIMARY KEY AUTOINCREMENT, description TEXT NOT NULL DEFAULT '') STRICT;", migration_version, ) .await?; From ddbf8b573636186fbcbb40bf0f1db136d7af586a Mon Sep 17 00:00:00 2001 From: Hocuri Date: Thu, 5 Feb 2026 17:19:00 +0100 Subject: [PATCH 04/19] Add test and fix things --- src/chat.rs | 6 ++-- src/chat/chat_tests.rs | 71 +++++++++++++++++++++++++++++------------- src/receive_imf.rs | 28 +++++++---------- 3 files changed, 63 insertions(+), 42 deletions(-) diff --git a/src/chat.rs b/src/chat.rs index 2c79b09dea..0031f32fea 100644 --- a/src/chat.rs +++ b/src/chat.rs @@ -1278,7 +1278,7 @@ SELECT id, rfc724_mid, pre_rfc724_mid, timestamp, ?, 1 FROM msgs WHERE chat_id=? let description = context .sql .query_get_value( - "SELECT description FROM chat_descriptions WHERE id=?", + "SELECT description FROM chats_descriptions WHERE id=?", (self,), ) .await? @@ -4241,8 +4241,8 @@ async fn set_chat_description_ex( context .sql .execute( - "UPDATE chats SET description=? WHERE id=?", - (&new_description, chat_id), + "INSERT OR REPLACE INTO chats_descriptions(id, description) VALUES(?, ?)", + (chat_id, &new_description), ) .await?; diff --git a/src/chat/chat_tests.rs b/src/chat/chat_tests.rs index c3a7d84a1d..a99155d9bd 100644 --- a/src/chat/chat_tests.rs +++ b/src/chat/chat_tests.rs @@ -3157,35 +3157,34 @@ async fn test_broadcasts_name_and_avatar() -> Result<()> { } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn test_chat_description() -> Result<()> { +async fn test_chat_description_basic() { + test_chat_description("").await.unwrap() +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_chat_description_unpromoted_description() { + test_chat_description("Unpromoted description in the bedinning") + .await + .unwrap() +} + +async fn test_chat_description(initial_description: &str) -> Result<()> { let mut tcm = TestContextManager::new(); let alice = &tcm.alice().await; let alice2 = &tcm.alice().await; let bob = &tcm.bob().await; - // alice.set_config_bool(Config::SyncMsgs, true).await?; + alice.set_config_bool(Config::SyncMsgs, true).await?; + alice2.set_config_bool(Config::SyncMsgs, true).await?; tcm.section("Create a group chat, and add Bob"); let alice_chat_id = create_group(alice, "My Group").await?; - let qr = get_securejoin_qr(alice, Some(alice_chat_id)).await.unwrap(); - tcm.exec_securejoin_qr(bob, alice, &qr).await; - - tcm.section("Alice sets a chat description"); - set_chat_description(alice, alice_chat_id, "This is a cool group").await?; - let sent = alice.pop_sent_msg().await; - tcm.section("Bob receives the description change"); - let rcvd = bob.recv_msg(&sent).await; - assert_eq!(rcvd.get_info_type(), SystemMessage::GroupDescriptionChanged); - assert_eq!(rcvd.text, "Chat description changed by alice@example.org."); - - assert_eq!( - rcvd.chat_id.get_description(bob).await?, - "This is a cool group" - ); + if !initial_description.is_empty() { + set_chat_description(alice, alice_chat_id, initial_description).await?; + } + sync(alice, alice2).await; - tcm.section("Check Alice's second device"); - alice2.recv_msg(&sent).await; let alice2_chat_id = get_chat_id_by_grpid( alice2, &Chat::load_from_db(alice, alice_chat_id).await?.grpid, @@ -3193,13 +3192,41 @@ async fn test_chat_description() -> Result<()> { .await? .unwrap() .0; - assert_eq!( alice2_chat_id.get_description(alice2).await?, - "This is a cool group" + initial_description ); - //TODO check deleting again + let qr = get_securejoin_qr(alice, Some(alice_chat_id)).await.unwrap(); + let bob_chat_id = tcm.exec_securejoin_qr(bob, alice, &qr).await; + assert_eq!(bob_chat_id.get_description(bob).await?, initial_description); + + for description in ["This is a cool group", ""] { + tcm.section(&format!( + "Alice sets the chat description to '{description}'" + )); + set_chat_description(alice, alice_chat_id, description).await?; + let sent = alice.pop_sent_msg().await; + + tcm.section("Bob receives the description change"); + let rcvd = bob.recv_msg(&sent).await; + assert_eq!(rcvd.get_info_type(), SystemMessage::GroupDescriptionChanged); + assert_eq!(rcvd.text, "Chat description changed by alice@example.org."); + + assert_eq!(rcvd.chat_id.get_description(bob).await?, description); + + tcm.section("Check Alice's second device"); + alice2.recv_msg(&sent).await; + let alice2_chat_id = get_chat_id_by_grpid( + alice2, + &Chat::load_from_db(alice, alice_chat_id).await?.grpid, + ) + .await? + .unwrap() + .0; + + assert_eq!(alice2_chat_id.get_description(alice2).await?, description); + } Ok(()) } diff --git a/src/receive_imf.rs b/src/receive_imf.rs index a7acdb41d6..11f0e6352f 100644 --- a/src/receive_imf.rs +++ b/src/receive_imf.rs @@ -3342,10 +3342,6 @@ async fn apply_chat_name_and_avatar_changes( // ========== Apply chat description changes ========== - let chat_description_timestamp = mime_parser - .get_header(HeaderDef::ChatGroupDescriptionTimestamp) - .and_then(|s| s.parse::().ok()); - if let Some(new_description) = mime_parser .get_header(HeaderDef::ChatGroupDescription) .map(|d| d.trim()) @@ -3353,22 +3349,20 @@ async fn apply_chat_name_and_avatar_changes( let new_description = sanitize_bidi_characters(new_description.trim()); let old_description = chat.id.get_description(context).await?; - let chat_group_description_timestamp = chat + let old_timestamp = chat .param .get_i64(Param::ChatDescriptionTimestamp) .unwrap_or(0); - let chat_description_timestamp = - chat_description_timestamp.unwrap_or(mime_parser.timestamp_sent); - // To provide group description consistency, compare descriptions if timestamps are equal. - if (chat_group_description_timestamp, &new_description) - < (chat_description_timestamp, &old_description) + let timestamp_in_header = mime_parser + .get_header(HeaderDef::ChatGroupDescriptionTimestamp) + .and_then(|s| s.parse::().ok()); + + let new_timestamp = timestamp_in_header.unwrap_or(mime_parser.timestamp_sent); + // To provide consistency, compare descriptions if timestamps are equal. + if (new_timestamp, &new_description) > (old_timestamp, &old_description) && chat .id - .update_timestamp( - context, - Param::ChatDescriptionTimestamp, - chat_description_timestamp, - ) + .update_timestamp(context, Param::ChatDescriptionTimestamp, old_timestamp) .await? && new_description != old_description { @@ -3376,8 +3370,8 @@ async fn apply_chat_name_and_avatar_changes( context .sql .execute( - "UPDATE chats SET description=? WHERE id=?", - (&new_description, chat.id), + "INSERT OR REPLACE INTO chats_descriptions(id, description) VALUES(?, ?)", + (chat.id, &new_description), ) .await?; *send_event_chat_modified = true; From 710ac2612f3ad7f9621f0fb7f1e8681c8aaa019f Mon Sep 17 00:00:00 2001 From: Hocuri Date: Thu, 5 Feb 2026 17:25:32 +0100 Subject: [PATCH 05/19] Don't send the group description in every message --- deltachat-jsonrpc/src/api/types/message.rs | 2 +- src/mimefactory.rs | 33 +++++++++++----------- 2 files changed, 18 insertions(+), 17 deletions(-) diff --git a/deltachat-jsonrpc/src/api/types/message.rs b/deltachat-jsonrpc/src/api/types/message.rs index 3a66a0f98a..ed2ccea22a 100644 --- a/deltachat-jsonrpc/src/api/types/message.rs +++ b/deltachat-jsonrpc/src/api/types/message.rs @@ -388,7 +388,7 @@ impl From for DownloadState { pub enum SystemMessageType { Unknown, GroupNameChanged, - GroupDescriptionChanged, + GroupDescriptionChanged, // TODO maybe rename to ChatDescriptionChanged GroupImageChanged, MemberAddedToGroup, MemberRemovedFromGroup, diff --git a/src/mimefactory.rs b/src/mimefactory.rs index aa3fc5e76e..57a8f6a9c1 100644 --- a/src/mimefactory.rs +++ b/src/mimefactory.rs @@ -1602,19 +1602,6 @@ impl MimeFactory { )); } - let description = chat.id.get_description(context).await?; - // TODO we don't want to send the description in every message - headers.push(( - "Chat-Group-Description", - mail_builder::headers::text::Text::new(description.clone()).into(), - )); - if let Some(ts) = chat.param.get_i64(Param::ChatDescriptionTimestamp) { - headers.push(( - "Chat-Group-Description-Timestamp", - mail_builder::headers::text::Text::new(ts.to_string()).into(), - )); - } - match command { SystemMessage::MemberRemovedFromGroup => { let email_to_remove = msg.param.get(Param::Arg).unwrap_or_default(); @@ -1683,11 +1670,9 @@ impl MimeFactory { )); } SystemMessage::GroupDescriptionChanged => { - let old_description = msg.param.get(Param::Arg).unwrap_or_default().to_string(); - // TODO this is unnecessary headers.push(( "Chat-Group-Description-Changed", - mail_builder::headers::text::Text::new(old_description).into(), + mail_builder::headers::text::Text::new("").into(), )); } SystemMessage::GroupImageChanged => { @@ -1704,6 +1689,22 @@ impl MimeFactory { } _ => {} } + + if command == SystemMessage::GroupDescriptionChanged + || command == SystemMessage::MemberAddedToGroup + { + let description = chat.id.get_description(context).await?; + headers.push(( + "Chat-Group-Description", + mail_builder::headers::text::Text::new(description.clone()).into(), + )); + if let Some(ts) = chat.param.get_i64(Param::ChatDescriptionTimestamp) { + headers.push(( + "Chat-Group-Description-Timestamp", + mail_builder::headers::text::Text::new(ts.to_string()).into(), + )); + } + } } match command { From 1e33ad2cb7d90aca81e683bc0c98524abd340dcc Mon Sep 17 00:00:00 2001 From: Hocuri Date: Thu, 5 Feb 2026 17:30:20 +0100 Subject: [PATCH 06/19] documentation --- src/chat.rs | 16 ++++++++++++++-- src/mimeparser.rs | 5 ++++- 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/src/chat.rs b/src/chat.rs index 0031f32fea..426b62a239 100644 --- a/src/chat.rs +++ b/src/chat.rs @@ -1273,7 +1273,11 @@ SELECT id, rfc724_mid, pre_rfc724_mid, timestamp, ?, 1 FROM msgs WHERE chat_id=? Ok(sort_timestamp) } - /// Returns chat description. + // TODO maybe move to be a free function + /// Load the chat description from the database. + /// + /// This should be shown in the profile page of the chat, + /// and is settable by [`set_chat_description`] pub async fn get_description(&self, context: &Context) -> Result { let description = context .sql @@ -4200,7 +4204,15 @@ async fn send_member_removal_msg( send_msg(context, chat.id, &mut msg).await } -/// Sets group or mailing list chat name. +/// Set group or broadcast channel description. +/// +/// If the group is already _promoted_ (any message was sent to the group), +/// or if this is a brodacast channel, +/// all members are informed by a special status message that is sent automatically by this function. +/// +/// Sends out #DC_EVENT_CHAT_MODIFIED and #DC_EVENT_MSGS_CHANGED if a status message was sent. +/// +/// See also [`ChatId::get_description`] pub async fn set_chat_description( context: &Context, chat_id: ChatId, diff --git a/src/mimeparser.rs b/src/mimeparser.rs index 31ffbd1197..ff668e4af2 100644 --- a/src/mimeparser.rs +++ b/src/mimeparser.rs @@ -776,7 +776,10 @@ impl MimeMessage { self.is_system_message = SystemMessage::MemberAddedToGroup; } else if self.get_header(HeaderDef::ChatGroupNameChanged).is_some() { self.is_system_message = SystemMessage::GroupNameChanged; - } else if self.get_header(HeaderDef::ChatGroupDescriptionChanged).is_some() { + } else if self + .get_header(HeaderDef::ChatGroupDescriptionChanged) + .is_some() + { self.is_system_message = SystemMessage::GroupDescriptionChanged; } } From aa6e892bc68225256e27ad246904cf989a1bff4b Mon Sep 17 00:00:00 2001 From: Hocuri Date: Thu, 5 Feb 2026 22:52:40 +0100 Subject: [PATCH 07/19] Move get_chat_description() to a free function for consistency with set_chat_description() --- deltachat-jsonrpc/src/api.rs | 2 +- src/chat.rs | 42 ++++++++++++++++++------------------ src/chat/chat_tests.rs | 14 ++++++++---- src/mimefactory.rs | 4 ++-- src/param.rs | 2 +- src/receive_imf.rs | 6 +++--- 6 files changed, 38 insertions(+), 32 deletions(-) diff --git a/deltachat-jsonrpc/src/api.rs b/deltachat-jsonrpc/src/api.rs index 8a780ccd1d..c39a117f33 100644 --- a/deltachat-jsonrpc/src/api.rs +++ b/deltachat-jsonrpc/src/api.rs @@ -1102,7 +1102,7 @@ impl CommandApi { /// and is settable by [`Self::set_chat_description`] / `setChatDescription()`. async fn get_chat_description(&self, account_id: u32, chat_id: u32) -> Result { let ctx = self.get_context(account_id).await?; - ChatId::new(chat_id).get_description(&ctx).await + chat::get_chat_description(&ctx, ChatId::new(chat_id)).await } /// Set group profile image. diff --git a/src/chat.rs b/src/chat.rs index 426b62a239..0dd1c79b0e 100644 --- a/src/chat.rs +++ b/src/chat.rs @@ -1272,23 +1272,6 @@ SELECT id, rfc724_mid, pre_rfc724_mid, timestamp, ?, 1 FROM msgs WHERE chat_id=? Ok(sort_timestamp) } - - // TODO maybe move to be a free function - /// Load the chat description from the database. - /// - /// This should be shown in the profile page of the chat, - /// and is settable by [`set_chat_description`] - pub async fn get_description(&self, context: &Context) -> Result { - let description = context - .sql - .query_get_value( - "SELECT description FROM chats_descriptions WHERE id=?", - (self,), - ) - .await? - .unwrap_or_default(); - Ok(description) - } } impl std::fmt::Display for ChatId { @@ -4212,7 +4195,7 @@ async fn send_member_removal_msg( /// /// Sends out #DC_EVENT_CHAT_MODIFIED and #DC_EVENT_MSGS_CHANGED if a status message was sent. /// -/// See also [`ChatId::get_description`] +/// See also [`get_chat_description`] pub async fn set_chat_description( context: &Context, chat_id: ChatId, @@ -4232,7 +4215,7 @@ async fn set_chat_description_ex( ensure!(!chat_id.is_special(), "Invalid chat ID"); let mut chat = Chat::load_from_db(context, chat_id).await?; - let old_description = chat_id.get_description(context).await?; + let old_description = get_chat_description(context, chat_id).await?; ensure!( chat.typ == Chattype::Group || chat.typ == Chattype::OutBroadcast, "Can only set profile image for groups / broadcasts" @@ -4262,7 +4245,7 @@ async fn set_chat_description_ex( let mut msg = Message::new(Viewtype::Text); msg.text = stock_str::msg_chat_description_changed(context, ContactId::SELF).await; msg.param.set_cmd(SystemMessage::GroupDescriptionChanged); - chat.param.set_i64(Param::ChatDescriptionTimestamp, time()); + chat.param.set_i64(Param::GroupDescriptionTimestamp, time()); chat.update_param(context).await?; msg.id = send_msg(context, chat_id, &mut msg).await?; @@ -4282,6 +4265,23 @@ async fn set_chat_description_ex( Ok(()) } +// TODO maybe move to be a free function +/// Load the chat description from the database. +/// +/// This should be shown in the profile page of the chat, +/// and is settable by [`set_chat_description`] +pub async fn get_chat_description(context: &Context, chat_id: ChatId) -> Result { + let description = context + .sql + .query_get_value( + "SELECT description FROM chats_descriptions WHERE id=?", + (chat_id,), + ) + .await? + .unwrap_or_default(); + Ok(description) +} + /// Set group or broadcast channel description. /// /// If the group is already _promoted_ (any message was sent to the group), @@ -4290,7 +4290,7 @@ async fn set_chat_description_ex( /// /// Sends out #DC_EVENT_CHAT_MODIFIED and #DC_EVENT_MSGS_CHANGED if a status message was sent. /// -/// To find out the description of a chat, use dc_chat_get_description(). +/// See [`get_chat_description`]. pub async fn set_chat_name(context: &Context, chat_id: ChatId, new_name: &str) -> Result<()> { rename_ex(context, Sync, chat_id, new_name).await } diff --git a/src/chat/chat_tests.rs b/src/chat/chat_tests.rs index a99155d9bd..2d28f4d856 100644 --- a/src/chat/chat_tests.rs +++ b/src/chat/chat_tests.rs @@ -3193,13 +3193,16 @@ async fn test_chat_description(initial_description: &str) -> Result<()> { .unwrap() .0; assert_eq!( - alice2_chat_id.get_description(alice2).await?, + get_chat_description(alice2, alice2_chat_id).await?, initial_description ); let qr = get_securejoin_qr(alice, Some(alice_chat_id)).await.unwrap(); let bob_chat_id = tcm.exec_securejoin_qr(bob, alice, &qr).await; - assert_eq!(bob_chat_id.get_description(bob).await?, initial_description); + assert_eq!( + get_chat_description(bob, bob_chat_id).await?, + initial_description + ); for description in ["This is a cool group", ""] { tcm.section(&format!( @@ -3213,7 +3216,7 @@ async fn test_chat_description(initial_description: &str) -> Result<()> { assert_eq!(rcvd.get_info_type(), SystemMessage::GroupDescriptionChanged); assert_eq!(rcvd.text, "Chat description changed by alice@example.org."); - assert_eq!(rcvd.chat_id.get_description(bob).await?, description); + assert_eq!(get_chat_description(bob, rcvd.chat_id).await?, description); tcm.section("Check Alice's second device"); alice2.recv_msg(&sent).await; @@ -3225,7 +3228,10 @@ async fn test_chat_description(initial_description: &str) -> Result<()> { .unwrap() .0; - assert_eq!(alice2_chat_id.get_description(alice2).await?, description); + assert_eq!( + get_chat_description(alice2, alice2_chat_id).await?, + description + ); } Ok(()) diff --git a/src/mimefactory.rs b/src/mimefactory.rs index 57a8f6a9c1..08f6487930 100644 --- a/src/mimefactory.rs +++ b/src/mimefactory.rs @@ -1693,12 +1693,12 @@ impl MimeFactory { if command == SystemMessage::GroupDescriptionChanged || command == SystemMessage::MemberAddedToGroup { - let description = chat.id.get_description(context).await?; + let description = chat::get_chat_description(context, chat.id).await?; headers.push(( "Chat-Group-Description", mail_builder::headers::text::Text::new(description.clone()).into(), )); - if let Some(ts) = chat.param.get_i64(Param::ChatDescriptionTimestamp) { + if let Some(ts) = chat.param.get_i64(Param::GroupDescriptionTimestamp) { headers.push(( "Chat-Group-Description-Timestamp", mail_builder::headers::text::Text::new(ts.to_string()).into(), diff --git a/src/param.rs b/src/param.rs index fe614b020d..cb051ebaab 100644 --- a/src/param.rs +++ b/src/param.rs @@ -220,7 +220,7 @@ pub enum Param { GroupNameTimestamp = b'g', /// For Chats: timestamp of chat description update. - ChatDescriptionTimestamp = b'Z', + GroupDescriptionTimestamp = b'6', /// For Chats: timestamp of member list update. MemberListTimestamp = b'k', diff --git a/src/receive_imf.rs b/src/receive_imf.rs index 11f0e6352f..dcda7a2e67 100644 --- a/src/receive_imf.rs +++ b/src/receive_imf.rs @@ -3347,11 +3347,11 @@ async fn apply_chat_name_and_avatar_changes( .map(|d| d.trim()) { let new_description = sanitize_bidi_characters(new_description.trim()); - let old_description = chat.id.get_description(context).await?; + let old_description = chat::get_chat_description(context, chat.id).await?; let old_timestamp = chat .param - .get_i64(Param::ChatDescriptionTimestamp) + .get_i64(Param::GroupDescriptionTimestamp) .unwrap_or(0); let timestamp_in_header = mime_parser .get_header(HeaderDef::ChatGroupDescriptionTimestamp) @@ -3362,7 +3362,7 @@ async fn apply_chat_name_and_avatar_changes( if (new_timestamp, &new_description) > (old_timestamp, &old_description) && chat .id - .update_timestamp(context, Param::ChatDescriptionTimestamp, old_timestamp) + .update_timestamp(context, Param::GroupDescriptionTimestamp, old_timestamp) .await? && new_description != old_description { From 0caf49799a3125d4f990dd0c749de79e155e21cc Mon Sep 17 00:00:00 2001 From: Hocuri Date: Thu, 5 Feb 2026 22:53:37 +0100 Subject: [PATCH 08/19] Remove TODO --- deltachat-jsonrpc/src/api/types/message.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/deltachat-jsonrpc/src/api/types/message.rs b/deltachat-jsonrpc/src/api/types/message.rs index ed2ccea22a..3a66a0f98a 100644 --- a/deltachat-jsonrpc/src/api/types/message.rs +++ b/deltachat-jsonrpc/src/api/types/message.rs @@ -388,7 +388,7 @@ impl From for DownloadState { pub enum SystemMessageType { Unknown, GroupNameChanged, - GroupDescriptionChanged, // TODO maybe rename to ChatDescriptionChanged + GroupDescriptionChanged, GroupImageChanged, MemberAddedToGroup, MemberRemovedFromGroup, From da911a9eb0f2181dcfea03ee9f79921686e7d427 Mon Sep 17 00:00:00 2001 From: Hocuri Date: Thu, 5 Feb 2026 22:54:33 +0100 Subject: [PATCH 09/19] Remove accidentally-added whitespace --- deltachat-ffi/deltachat.h | 1 - 1 file changed, 1 deletion(-) diff --git a/deltachat-ffi/deltachat.h b/deltachat-ffi/deltachat.h index 6c8185d566..2ab5b40085 100644 --- a/deltachat-ffi/deltachat.h +++ b/deltachat-ffi/deltachat.h @@ -1870,7 +1870,6 @@ int dc_remove_contact_from_chat (dc_context_t* context, uint32_t ch */ int dc_set_chat_name (dc_context_t* context, uint32_t chat_id, const char* name); - /** * Set the chat's ephemeral message timer. * From 2d4f1233698c1d740a51b1f13b512c7b3cd9cdcf Mon Sep 17 00:00:00 2001 From: Hocuri Date: Thu, 5 Feb 2026 23:38:07 +0100 Subject: [PATCH 10/19] Some more fixes. Unfortunately, tests don't pass :/ --- deltachat-jsonrpc/src/api.rs | 4 ++-- src/chat.rs | 11 ++++------- src/chat/chat_tests.rs | 2 +- src/mimeparser.rs | 10 +++++----- src/receive_imf.rs | 4 ++-- 5 files changed, 14 insertions(+), 17 deletions(-) diff --git a/deltachat-jsonrpc/src/api.rs b/deltachat-jsonrpc/src/api.rs index c39a117f33..978ddd68e1 100644 --- a/deltachat-jsonrpc/src/api.rs +++ b/deltachat-jsonrpc/src/api.rs @@ -1098,8 +1098,8 @@ impl CommandApi { /// Load the chat description from the database. /// - /// This should be shown in the profile page of the chat, - /// and is settable by [`Self::set_chat_description`] / `setChatDescription()`. + /// UIs show this in the profile page of the chat, + /// it is settable by [`Self::set_chat_description`] / `setChatDescription()`. async fn get_chat_description(&self, account_id: u32, chat_id: u32) -> Result { let ctx = self.get_context(account_id).await?; chat::get_chat_description(&ctx, ChatId::new(chat_id)).await diff --git a/src/chat.rs b/src/chat.rs index 0dd1c79b0e..cd2bd3643e 100644 --- a/src/chat.rs +++ b/src/chat.rs @@ -4218,7 +4218,7 @@ async fn set_chat_description_ex( let old_description = get_chat_description(context, chat_id).await?; ensure!( chat.typ == Chattype::Group || chat.typ == Chattype::OutBroadcast, - "Can only set profile image for groups / broadcasts" + "Can only set description for groups / broadcasts" ); ensure!( !chat.grpid.is_empty(), @@ -4265,11 +4265,10 @@ async fn set_chat_description_ex( Ok(()) } -// TODO maybe move to be a free function /// Load the chat description from the database. /// -/// This should be shown in the profile page of the chat, -/// and is settable by [`set_chat_description`] +/// UIs show this in the profile page of the chat, +/// it is settable by [`set_chat_description`] pub async fn get_chat_description(context: &Context, chat_id: ChatId) -> Result { let description = context .sql @@ -4282,15 +4281,13 @@ pub async fn get_chat_description(context: &Context, chat_id: ChatId) -> Result< Ok(description) } -/// Set group or broadcast channel description. +/// Sets group, mailing list, or broadcast channel chat name. /// /// If the group is already _promoted_ (any message was sent to the group), /// or if this is a brodacast channel, /// all members are informed by a special status message that is sent automatically by this function. /// /// Sends out #DC_EVENT_CHAT_MODIFIED and #DC_EVENT_MSGS_CHANGED if a status message was sent. -/// -/// See [`get_chat_description`]. pub async fn set_chat_name(context: &Context, chat_id: ChatId, new_name: &str) -> Result<()> { rename_ex(context, Sync, chat_id, new_name).await } diff --git a/src/chat/chat_tests.rs b/src/chat/chat_tests.rs index 2d28f4d856..55b7ad694c 100644 --- a/src/chat/chat_tests.rs +++ b/src/chat/chat_tests.rs @@ -3163,7 +3163,7 @@ async fn test_chat_description_basic() { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn test_chat_description_unpromoted_description() { - test_chat_description("Unpromoted description in the bedinning") + test_chat_description("Unpromoted description in the beginning") .await .unwrap() } diff --git a/src/mimeparser.rs b/src/mimeparser.rs index ff668e4af2..a04b5621fc 100644 --- a/src/mimeparser.rs +++ b/src/mimeparser.rs @@ -186,10 +186,10 @@ pub enum SystemMessage { #[default] Unknown = 0, - /// Group name changed. + /// Group or broadcast channel name changed. GroupNameChanged = 2, - /// Group avatar changed. + /// Group or broadcast channel avatar changed. GroupImageChanged = 3, /// Member was added to the group. @@ -231,9 +231,6 @@ pub enum SystemMessage { /// send messages. SecurejoinWaitTimeout = 15, - /// Group description changed. - GroupDescriptionChanged = 19, - /// Self-sent-message that contains only json used for multi-device-sync; /// if possible, we attach that to other messages as for locations. MultiDeviceSync = 20, @@ -257,6 +254,9 @@ pub enum SystemMessage { /// Message indicating that a call was ended. CallEnded = 67, + + /// Group or broadcast channel description changed. + GroupDescriptionChanged = 70, } const MIME_AC_SETUP_FILE: &str = "application/autocrypt-setup"; diff --git a/src/receive_imf.rs b/src/receive_imf.rs index dcda7a2e67..8c5627f31b 100644 --- a/src/receive_imf.rs +++ b/src/receive_imf.rs @@ -3359,10 +3359,10 @@ async fn apply_chat_name_and_avatar_changes( let new_timestamp = timestamp_in_header.unwrap_or(mime_parser.timestamp_sent); // To provide consistency, compare descriptions if timestamps are equal. - if (new_timestamp, &new_description) > (old_timestamp, &old_description) + if (new_timestamp, &new_description) >= (old_timestamp, &old_description) && chat .id - .update_timestamp(context, Param::GroupDescriptionTimestamp, old_timestamp) + .update_timestamp(context, Param::GroupDescriptionTimestamp, new_timestamp) .await? && new_description != old_description { From 11de145f10b99088a2a2474b361d61f6d7e94dfc Mon Sep 17 00:00:00 2001 From: Hocuri Date: Fri, 6 Feb 2026 14:32:56 +0100 Subject: [PATCH 11/19] Fix timestamp problem The problem was inconsistent timestamp smearing: Alice sent description updates with unsmeared timestamps. This led Bob to reject description updates as "older" than his current group state. --- src/chat.rs | 15 +++++++++++---- src/receive_imf.rs | 2 +- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/src/chat.rs b/src/chat.rs index cd2bd3643e..fa079c21fb 100644 --- a/src/chat.rs +++ b/src/chat.rs @@ -2807,9 +2807,18 @@ async fn render_mime_message_and_pre_message( /// /// The caller has to interrupt SMTP loop or otherwise process new rows. pub(crate) async fn create_send_msg_jobs(context: &Context, msg: &mut Message) -> Result> { - if msg.param.get_cmd() == SystemMessage::GroupNameChanged { + let cmd = msg.param.get_cmd(); + if cmd == SystemMessage::GroupNameChanged || cmd == SystemMessage::GroupDescriptionChanged { msg.chat_id - .update_timestamp(context, Param::GroupNameTimestamp, msg.timestamp_sort) + .update_timestamp( + context, + if cmd == SystemMessage::GroupNameChanged { + Param::GroupNameTimestamp + } else { + Param::GroupDescriptionTimestamp + }, + msg.timestamp_sort, + ) .await?; } @@ -4245,8 +4254,6 @@ async fn set_chat_description_ex( let mut msg = Message::new(Viewtype::Text); msg.text = stock_str::msg_chat_description_changed(context, ContactId::SELF).await; msg.param.set_cmd(SystemMessage::GroupDescriptionChanged); - chat.param.set_i64(Param::GroupDescriptionTimestamp, time()); - chat.update_param(context).await?; msg.id = send_msg(context, chat_id, &mut msg).await?; context.emit_msgs_changed(chat_id, msg.id); diff --git a/src/receive_imf.rs b/src/receive_imf.rs index 8c5627f31b..d9d68597ba 100644 --- a/src/receive_imf.rs +++ b/src/receive_imf.rs @@ -3359,7 +3359,7 @@ async fn apply_chat_name_and_avatar_changes( let new_timestamp = timestamp_in_header.unwrap_or(mime_parser.timestamp_sent); // To provide consistency, compare descriptions if timestamps are equal. - if (new_timestamp, &new_description) >= (old_timestamp, &old_description) + if (old_timestamp, &old_description) < (new_timestamp, &new_description) && chat .id .update_timestamp(context, Param::GroupDescriptionTimestamp, new_timestamp) From 229c66af3bc1f993aaacecc040ddec796291c56b Mon Sep 17 00:00:00 2001 From: Hocuri Date: Fri, 6 Feb 2026 14:36:05 +0100 Subject: [PATCH 12/19] clippy --- src/chat.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/chat.rs b/src/chat.rs index fa079c21fb..450e879f69 100644 --- a/src/chat.rs +++ b/src/chat.rs @@ -4223,7 +4223,7 @@ async fn set_chat_description_ex( ensure!(!chat_id.is_special(), "Invalid chat ID"); - let mut chat = Chat::load_from_db(context, chat_id).await?; + let chat = Chat::load_from_db(context, chat_id).await?; let old_description = get_chat_description(context, chat_id).await?; ensure!( chat.typ == Chattype::Group || chat.typ == Chattype::OutBroadcast, From f03fd835faf531ac574caaf498cdd02c01c8bd2b Mon Sep 17 00:00:00 2001 From: Hocuri Date: Sat, 7 Feb 2026 11:19:45 +0100 Subject: [PATCH 13/19] fix: Use correct stock string --- src/chat/chat_tests.rs | 4 ++++ src/stock_str.rs | 8 ++++---- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/src/chat/chat_tests.rs b/src/chat/chat_tests.rs index 55b7ad694c..38d6c66cb0 100644 --- a/src/chat/chat_tests.rs +++ b/src/chat/chat_tests.rs @@ -3210,6 +3210,10 @@ async fn test_chat_description(initial_description: &str) -> Result<()> { )); set_chat_description(alice, alice_chat_id, description).await?; let sent = alice.pop_sent_msg().await; + assert_eq!( + sent.load_from_db().await.text, + "You changed the chat description." + ); tcm.section("Bob receives the description change"); let rcvd = bob.recv_msg(&sent).await; diff --git a/src/stock_str.rs b/src/stock_str.rs index 254aa6d260..b2830955ea 100644 --- a/src/stock_str.rs +++ b/src/stock_str.rs @@ -424,11 +424,11 @@ https://delta.chat/donate"))] #[strum(props(fallback = "Incoming video call"))] IncomingVideoCall = 235, - #[strum(props(fallback = "Chat description changed by %1$s."))] + #[strum(props(fallback = "You changed the chat description."))] MsgYouChangedDescription = 240, - #[strum(props(fallback = "You changed the chat description."))] - MsgYouChatDescriptionChangedBy = 241, + #[strum(props(fallback = "Chat description changed by %1$s."))] + MsgChatDescriptionChangedBy = 241, } impl StockMessage { @@ -614,7 +614,7 @@ pub(crate) async fn msg_chat_description_changed( if by_contact == ContactId::SELF { translated(context, StockMessage::MsgYouChangedDescription).await } else { - translated(context, StockMessage::MsgYouChangedDescription) + translated(context, StockMessage::MsgChatDescriptionChangedBy) .await .replace1(&by_contact.get_stock_name(context).await) } From 206247ef0a4e3fc2f1969b7f2c4ecdc1d5d8ae96 Mon Sep 17 00:00:00 2001 From: Hocuri Date: Sat, 7 Feb 2026 11:23:37 +0100 Subject: [PATCH 14/19] fix: Make GroupDescriptionTimestamp more symmetrical to GroupNameTimestamp --- src/chat.rs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/chat.rs b/src/chat.rs index 450e879f69..c0cf76e96a 100644 --- a/src/chat.rs +++ b/src/chat.rs @@ -1766,7 +1766,8 @@ impl Chat { msg.param.set_int(Param::AttachGroupImage, 1); self.param .remove(Param::Unpromoted) - .set_i64(Param::GroupNameTimestamp, msg.timestamp_sort); + .set_i64(Param::GroupNameTimestamp, msg.timestamp_sort) + .set_i64(Param::GroupDescriptionTimestamp, msg.timestamp_sort); self.update_param(context).await?; // TODO: Remove this compat code needed because Core <= v1.143: // - doesn't accept synchronization of QR code tokens for unpromoted groups, so we also @@ -3891,9 +3892,11 @@ pub(crate) async fn add_contact_to_chat_ex( let sync_qr_code_tokens; if from_handshake && chat.param.get_int(Param::Unpromoted).unwrap_or_default() == 1 { + let smeared_time = smeared_time(context); chat.param .remove(Param::Unpromoted) - .set_i64(Param::GroupNameTimestamp, smeared_time(context)); + .set_i64(Param::GroupNameTimestamp, smeared_time) + .set_i64(Param::GroupDescriptionTimestamp, smeared_time); chat.update_param(context).await?; sync_qr_code_tokens = true; } else { From d5e6909e682bb10743bb4e62035d84595e0e47e9 Mon Sep 17 00:00:00 2001 From: Hocuri Date: Mon, 9 Feb 2026 16:57:52 +0100 Subject: [PATCH 15/19] iequidoo's review --- deltachat-jsonrpc/src/api.rs | 4 +-- src/chat.rs | 50 ++++++++++++++++++------------------ src/receive_imf.rs | 2 +- src/sql/migrations.rs | 2 +- 4 files changed, 29 insertions(+), 29 deletions(-) diff --git a/deltachat-jsonrpc/src/api.rs b/deltachat-jsonrpc/src/api.rs index 978ddd68e1..19c0c1b2f1 100644 --- a/deltachat-jsonrpc/src/api.rs +++ b/deltachat-jsonrpc/src/api.rs @@ -1090,10 +1090,10 @@ impl CommandApi { &self, account_id: u32, chat_id: u32, - new_description: String, + description: String, ) -> Result<()> { let ctx = self.get_context(account_id).await?; - chat::set_chat_description(&ctx, ChatId::new(chat_id), &new_description).await + chat::set_chat_description(&ctx, ChatId::new(chat_id), &description).await } /// Load the chat description from the database. diff --git a/src/chat.rs b/src/chat.rs index c0cf76e96a..ff658b5b09 100644 --- a/src/chat.rs +++ b/src/chat.rs @@ -4227,7 +4227,6 @@ async fn set_chat_description_ex( ensure!(!chat_id.is_special(), "Invalid chat ID"); let chat = Chat::load_from_db(context, chat_id).await?; - let old_description = get_chat_description(context, chat_id).await?; ensure!( chat.typ == Chattype::Group || chat.typ == Chattype::OutBroadcast, "Can only set description for groups / broadcasts" @@ -4236,36 +4235,37 @@ async fn set_chat_description_ex( !chat.grpid.is_empty(), "Cannot set description for ad hoc groups" ); - - if old_description == new_description { - // Nothing to do - } else if !chat.is_self_in_chat(context).await? { + if !chat.is_self_in_chat(context).await? { context.emit_event(EventType::ErrorSelfNotInGroup( "Cannot set chat description; self not in group".into(), )); - bail!("Failed to set profile image"); - } else { - context - .sql - .execute( - "INSERT OR REPLACE INTO chats_descriptions(id, description) VALUES(?, ?)", - (chat_id, &new_description), - ) - .await?; + bail!("Failed to set chat description"); + } - if chat.is_promoted() { - let mut msg = Message::new(Viewtype::Text); - msg.text = stock_str::msg_chat_description_changed(context, ContactId::SELF).await; - msg.param.set_cmd(SystemMessage::GroupDescriptionChanged); + let affected_rows = context + .sql + .execute( + "INSERT OR REPLACE INTO chats_descriptions(chat_id, description) VALUES(?, ?)", + (chat_id, &new_description), + ) + .await?; - msg.id = send_msg(context, chat_id, &mut msg).await?; - context.emit_msgs_changed(chat_id, msg.id); - sync = Nosync; - } - context.emit_event(EventType::ChatModified(chat_id)); + if affected_rows == 0 { + return Ok(()); + } + + if chat.is_promoted() { + let mut msg = Message::new(Viewtype::Text); + msg.text = stock_str::msg_chat_description_changed(context, ContactId::SELF).await; + msg.param.set_cmd(SystemMessage::GroupDescriptionChanged); + + msg.id = send_msg(context, chat_id, &mut msg).await?; + context.emit_msgs_changed(chat_id, msg.id); + sync = Nosync; } + context.emit_event(EventType::ChatModified(chat_id)); - if sync.into() && old_description != new_description { + if sync.into() { chat.sync(context, SyncAction::SetDescription(new_description)) .await .log_err(context) @@ -4283,7 +4283,7 @@ pub async fn get_chat_description(context: &Context, chat_id: ChatId) -> Result< let description = context .sql .query_get_value( - "SELECT description FROM chats_descriptions WHERE id=?", + "SELECT description FROM chats_descriptions WHERE chat_id=?", (chat_id,), ) .await? diff --git a/src/receive_imf.rs b/src/receive_imf.rs index d9d68597ba..fdcbab4de0 100644 --- a/src/receive_imf.rs +++ b/src/receive_imf.rs @@ -3370,7 +3370,7 @@ async fn apply_chat_name_and_avatar_changes( context .sql .execute( - "INSERT OR REPLACE INTO chats_descriptions(id, description) VALUES(?, ?)", + "INSERT OR REPLACE INTO chats_descriptions(chat_id, description) VALUES(?, ?)", (chat.id, &new_description), ) .await?; diff --git a/src/sql/migrations.rs b/src/sql/migrations.rs index efff26d77f..11a2fb48b8 100644 --- a/src/sql/migrations.rs +++ b/src/sql/migrations.rs @@ -1561,7 +1561,7 @@ ALTER TABLE contacts ADD COLUMN name_normalized TEXT; inc_and_check(&mut migration_version, 147)?; if dbversion < migration_version { sql.execute_migration( - "CREATE TABLE chats_descriptions (id INTEGER PRIMARY KEY AUTOINCREMENT, description TEXT NOT NULL DEFAULT '') STRICT;", + "CREATE TABLE chats_descriptions (chat_id INTEGER PRIMARY KEY AUTOINCREMENT, description TEXT NOT NULL DEFAULT '') STRICT;", migration_version, ) .await?; From 08dd65a352c76afd70720df8dad534bb2bdfc071 Mon Sep 17 00:00:00 2001 From: Hocuri Date: Mon, 9 Feb 2026 17:01:43 +0100 Subject: [PATCH 16/19] Rename apply_chat_name_and_avatar_changes -> apply_chat_name_avatar_and_description_changes --- src/receive_imf.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/receive_imf.rs b/src/receive_imf.rs index fdcbab4de0..906489c9b1 100644 --- a/src/receive_imf.rs +++ b/src/receive_imf.rs @@ -3102,7 +3102,7 @@ async fn apply_group_changes( } if is_from_in_chat { - apply_chat_name_and_avatar_changes( + apply_chat_name_avatar_and_description_changes( context, mime_parser, from_id, @@ -3281,7 +3281,7 @@ async fn apply_group_changes( /// /// - `send_event_chat_modified` is set to `true` if ChatModified event should be sent /// - `better_msg` is filled with an info message about name change, if necessary -async fn apply_chat_name_and_avatar_changes( +async fn apply_chat_name_avatar_and_description_changes( context: &Context, mime_parser: &MimeMessage, from_id: ContactId, @@ -3713,7 +3713,7 @@ async fn apply_out_broadcast_changes( let mut better_msg = None; if from_id == ContactId::SELF { - apply_chat_name_and_avatar_changes( + apply_chat_name_avatar_and_description_changes( context, mime_parser, from_id, @@ -3803,7 +3803,7 @@ async fn apply_in_broadcast_changes( let mut send_event_chat_modified = false; let mut better_msg = None; - apply_chat_name_and_avatar_changes( + apply_chat_name_avatar_and_description_changes( context, mime_parser, from_id, From 1c9121867eb02c4de5e6d81b25b9a981f2fad6f2 Mon Sep 17 00:00:00 2001 From: Hocuri Date: Mon, 9 Feb 2026 19:56:14 +0100 Subject: [PATCH 17/19] fix: Send description when promoting a group --- src/chat.rs | 2 +- src/chat/chat_tests.rs | 29 ++++++++++++++++++++++++----- src/mimefactory.rs | 6 +++++- src/param.rs | 2 +- 4 files changed, 31 insertions(+), 8 deletions(-) diff --git a/src/chat.rs b/src/chat.rs index ff658b5b09..c4e1dcb00c 100644 --- a/src/chat.rs +++ b/src/chat.rs @@ -1763,7 +1763,7 @@ impl Chat { } else if matches!(self.typ, Chattype::Group | Chattype::OutBroadcast) && self.param.get_int(Param::Unpromoted).unwrap_or_default() == 1 { - msg.param.set_int(Param::AttachGroupImage, 1); + msg.param.set_int(Param::AttachChatAvatarAndDescription, 1); self.param .remove(Param::Unpromoted) .set_i64(Param::GroupNameTimestamp, msg.timestamp_sort) diff --git a/src/chat/chat_tests.rs b/src/chat/chat_tests.rs index 38d6c66cb0..7a51cd04d1 100644 --- a/src/chat/chat_tests.rs +++ b/src/chat/chat_tests.rs @@ -3158,17 +3158,29 @@ async fn test_broadcasts_name_and_avatar() -> Result<()> { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn test_chat_description_basic() { - test_chat_description("").await.unwrap() + test_chat_description("", false).await.unwrap() } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn test_chat_description_unpromoted_description() { - test_chat_description("Unpromoted description in the beginning") + test_chat_description("Unpromoted description in the beginning", false) .await .unwrap() } -async fn test_chat_description(initial_description: &str) -> Result<()> { +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_chat_description_qr() { + test_chat_description("", true).await.unwrap() +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_chat_description_unpromoted_description_qr() { + test_chat_description("Unpromoted description in the beginning", true) + .await + .unwrap() +} + +async fn test_chat_description(initial_description: &str, join_via_qr: bool) -> Result<()> { let mut tcm = TestContextManager::new(); let alice = &tcm.alice().await; let alice2 = &tcm.alice().await; @@ -3197,8 +3209,15 @@ async fn test_chat_description(initial_description: &str) -> Result<()> { initial_description ); - let qr = get_securejoin_qr(alice, Some(alice_chat_id)).await.unwrap(); - let bob_chat_id = tcm.exec_securejoin_qr(bob, alice, &qr).await; + let bob_chat_id = if join_via_qr { + let qr = get_securejoin_qr(alice, Some(alice_chat_id)).await.unwrap(); + tcm.exec_securejoin_qr(bob, alice, &qr).await + } else { + let alice_bob_id = alice.add_or_lookup_contact_id(bob).await; + add_contact_to_chat(alice, alice_chat_id, alice_bob_id).await?; + let sent = alice.send_text(alice_chat_id, "promoting the group").await; + bob.recv_msg(&sent).await.chat_id + }; assert_eq!( get_chat_description(bob, bob_chat_id).await?, initial_description diff --git a/src/mimefactory.rs b/src/mimefactory.rs index 08f6487930..71fdf0d7df 100644 --- a/src/mimefactory.rs +++ b/src/mimefactory.rs @@ -663,7 +663,7 @@ impl MimeFactory { if msg .param - .get_bool(Param::AttachGroupImage) + .get_bool(Param::AttachChatAvatarAndDescription) .unwrap_or_default() { return chat.param.get(Param::ProfileImage).map(Into::into); @@ -1692,6 +1692,10 @@ impl MimeFactory { if command == SystemMessage::GroupDescriptionChanged || command == SystemMessage::MemberAddedToGroup + || msg + .param + .get_bool(Param::AttachChatAvatarAndDescription) + .unwrap_or_default() { let description = chat::get_chat_description(context, chat.id).await?; headers.push(( diff --git a/src/param.rs b/src/param.rs index cb051ebaab..dfd48d31fc 100644 --- a/src/param.rs +++ b/src/param.rs @@ -140,7 +140,7 @@ pub enum Param { Arg4 = b'H', /// For Messages - AttachGroupImage = b'A', + AttachChatAvatarAndDescription = b'A', /// For Messages WebrtcRoom = b'V', From 6330a7bf8bee5782c7d3ae3f4573eb56ea391676 Mon Sep 17 00:00:00 2001 From: Hocuri Date: Tue, 10 Feb 2026 22:08:10 +0100 Subject: [PATCH 18/19] Update src/chat.rs Co-authored-by: iequidoo <117991069+iequidoo@users.noreply.github.com> --- src/chat.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/chat.rs b/src/chat.rs index c4e1dcb00c..41726f97aa 100644 --- a/src/chat.rs +++ b/src/chat.rs @@ -4239,7 +4239,7 @@ async fn set_chat_description_ex( context.emit_event(EventType::ErrorSelfNotInGroup( "Cannot set chat description; self not in group".into(), )); - bail!("Failed to set chat description"); + bail!("Cannot set chat description; self not in group"); } let affected_rows = context From bfe3937597d9e3f4d06512063de5ac77c19aa482 Mon Sep 17 00:00:00 2001 From: Hocuri Date: Tue, 10 Feb 2026 22:10:13 +0100 Subject: [PATCH 19/19] fix: Ignore set_chat_description() calls that don't actually change the description --- src/chat.rs | 4 +++- src/chat/chat_tests.rs | 11 ++++++++++- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/src/chat.rs b/src/chat.rs index 41726f97aa..06cf1712ea 100644 --- a/src/chat.rs +++ b/src/chat.rs @@ -4245,7 +4245,9 @@ async fn set_chat_description_ex( let affected_rows = context .sql .execute( - "INSERT OR REPLACE INTO chats_descriptions(chat_id, description) VALUES(?, ?)", + "INSERT INTO chats_descriptions(chat_id, description) VALUES(?, ?) + ON CONFLICT(chat_id) DO UPDATE + SET description=excluded.description WHERE description<>excluded.description", (chat_id, &new_description), ) .await?; diff --git a/src/chat/chat_tests.rs b/src/chat/chat_tests.rs index 7a51cd04d1..e5e5a05407 100644 --- a/src/chat/chat_tests.rs +++ b/src/chat/chat_tests.rs @@ -3223,7 +3223,7 @@ async fn test_chat_description(initial_description: &str, join_via_qr: bool) -> initial_description ); - for description in ["This is a cool group", ""] { + for description in ["This is a cool group", "", "ä ẟ 😂"] { tcm.section(&format!( "Alice sets the chat description to '{description}'" )); @@ -3257,6 +3257,15 @@ async fn test_chat_description(initial_description: &str, join_via_qr: bool) -> ); } + tcm.section("Alice calls set_chat_description() without actually changing the description"); + set_chat_description(alice, alice_chat_id, "ä ẟ 😂").await?; + assert!( + alice + .pop_sent_msg_opt(Duration::from_secs(0)) + .await + .is_none() + ); + Ok(()) }