From bd0c0b6b4bc74c48016dbee6fca20f24e214c42a Mon Sep 17 00:00:00 2001 From: "B. Petersen" Date: Tue, 4 Aug 2026 15:30:57 +0200 Subject: [PATCH] feat: add "pinned messages" API --- deltachat-ffi/deltachat.h | 25 ++ deltachat-ffi/src/lib.rs | 10 + deltachat-jsonrpc/src/api.rs | 20 ++ deltachat-jsonrpc/src/api/types/message.rs | 7 + src/chat.rs | 1 + src/lib.rs | 1 + src/message.rs | 10 + src/mimefactory.rs | 14 + src/mimeparser.rs | 10 + src/pinned_messages.rs | 328 +++++++++++++++++++++ src/receive_imf.rs | 15 + src/sql/migrations.rs | 18 ++ src/stock_str.rs | 17 ++ src/tests/pre_messages/legacy.rs | 2 +- 14 files changed, 477 insertions(+), 1 deletion(-) create mode 100644 src/pinned_messages.rs diff --git a/deltachat-ffi/deltachat.h b/deltachat-ffi/deltachat.h index 90cbec384..d7d6caee9 100644 --- a/deltachat-ffi/deltachat.h +++ b/deltachat-ffi/deltachat.h @@ -4473,6 +4473,7 @@ int dc_msg_is_info (const dc_msg_t* msg); * - DC_INFO_WEBXDC_INFO_MESSAGE (32) - Info-message created by webxdc app sending `update.info` * - DC_INFO_CHAT_E2EE (50) - Info-message for "Chat is end-to-end-encrypted" * - DC_INFO_GROUP_DESCRIPTION_CHANGED (70) - Info-message "Description changed", UI should open the profile with the description + * - DC_INFO_MESSAGE_PINNED (71) - Message pinned, UI should scroll to the pinned message returned by dc_msg_get_parent() * * For the messages that refer to a CONTACT, * dc_msg_get_info_contact_id() returns the contact ID. @@ -4532,6 +4533,7 @@ uint32_t dc_msg_get_info_contact_id (const dc_msg_t* msg); #define DC_INFO_WEBXDC_INFO_MESSAGE 32 #define DC_INFO_CHAT_E2EE 50 #define DC_INFO_GROUP_DESCRIPTION_CHANGED 70 +#define DC_INFO_MESSAGE_PINNED 71 /** @@ -4849,6 +4851,8 @@ dc_msg_t* dc_msg_get_quoted_msg (const dc_msg_t* msg); * Used for Webxdc-info-messages * to jump to the corresponding instance that created the info message. * + * For Pinned-info-messages, this refers to the pinned message. + * * For quotes, please use the more specialized * dc_msg_get_quoted_text() and dc_msg_get_quoted_msg(). * @@ -4888,6 +4892,20 @@ uint32_t dc_msg_get_original_msg_id (const dc_msg_t* msg); */ uint32_t dc_msg_get_saved_msg_id (const dc_msg_t* msg); + +/** + * Check if the message is pinned. + * + * Pinned messages should be marked by a pin needle in the UI. + * To pin messages or get all pinned messages, use jsonrpc's "setPinnedMessageState" and "getPinnedMessages". + * + * @memberof dc_msg_t + * @param msg The message object. + * @return 1=message is pinned, 0=message not pinned. + */ + int dc_msg_is_pinned (const dc_msg_t* msg); + + /** * @class dc_contact_t * @@ -5672,6 +5690,7 @@ void dc_jsonrpc_unref(dc_jsonrpc_instance_t* jsonrpc_instance); * - getAccountFileSize() * - importVcard(), parseVcard(), makeVcard() * - sendWebxdcRealtimeData, sendWebxdcRealtimeAdvertisement(), leaveWebxdcRealtime() + * - setPinnedMessageState(), getPinnedMessages() * * @memberof dc_jsonrpc_instance_t * @param jsonrpc_instance jsonrpc instance as returned from dc_jsonrpc_init(). @@ -7249,6 +7268,12 @@ void dc_event_unref(dc_event_t* event); /// Used when creating text for the "Encryption Info" dialogs. #define DC_STR_MESSAGES_ARE_E2EE 242 +/// "You pinned a message." +#define DC_STR_MESSAGE_PINNED_BY_YOU 243 + +/// "Message pinned by %1$s." +#define DC_STR_MESSAGE_PINNED_BY_OTHER 244 + /** * @} */ diff --git a/deltachat-ffi/src/lib.rs b/deltachat-ffi/src/lib.rs index 183989b5f..54b75b6e1 100644 --- a/deltachat-ffi/src/lib.rs +++ b/deltachat-ffi/src/lib.rs @@ -3904,6 +3904,16 @@ pub unsafe extern "C" fn dc_msg_get_saved_msg_id(msg: *const dc_msg_t) -> u32 { .unwrap_or(0) } +#[unsafe(no_mangle)] +pub unsafe extern "C" fn dc_msg_is_pinned(msg: *mut dc_msg_t) -> libc::c_int { + if msg.is_null() { + eprintln!("ignoring careless call to dc_msg_is_pinned()"); + return 0; + } + let ffi_msg = unsafe { &*msg }; + ffi_msg.message.is_pinned().into() +} + // dc_contact_t /// FFI struct for [dc_contact_t] diff --git a/deltachat-jsonrpc/src/api.rs b/deltachat-jsonrpc/src/api.rs index 84c2c994a..9cccc6f81 100644 --- a/deltachat-jsonrpc/src/api.rs +++ b/deltachat-jsonrpc/src/api.rs @@ -1508,6 +1508,26 @@ impl CommandApi { MessageNotificationInfo::from_msg_id(&ctx, MsgId::new(message_id)).await } + /// Sets the "pinned" state for a message. + async fn set_pinned_message_state( + &self, + account_id: u32, + message_id: u32, + pinned_state: bool, + ) -> Result<()> { + let ctx = self.get_context(account_id).await?; + deltachat::pinned_messages::set_pinned_state(&ctx, MsgId::new(message_id), pinned_state) + .await + } + + /// Returns all pinned messages of a chat. + async fn get_pinned_messages(&self, account_id: u32, chat_id: u32) -> Result> { + let ctx = self.get_context(account_id).await?; + let msg_ids = + deltachat::pinned_messages::get_pinned_messages(&ctx, ChatId::new(chat_id)).await?; + Ok(msg_ids.into_iter().map(|id| id.to_u32()).collect()) + } + /// Delete messages. The messages are deleted on the current device and /// on the IMAP server. async fn delete_messages(&self, account_id: u32, message_ids: Vec) -> Result<()> { diff --git a/deltachat-jsonrpc/src/api/types/message.rs b/deltachat-jsonrpc/src/api/types/message.rs index 40640880a..f33091e98 100644 --- a/deltachat-jsonrpc/src/api/types/message.rs +++ b/deltachat-jsonrpc/src/api/types/message.rs @@ -103,6 +103,8 @@ pub struct MessageObject { saved_message_id: Option, + is_pinned: bool, + reactions: Option, vcard_contact: Option, @@ -263,6 +265,7 @@ impl MessageObject { .await? .map(|id| id.to_u32()), + is_pinned: message.is_pinned(), reactions, vcard_contact: vcard_contacts.first().cloned(), @@ -425,6 +428,8 @@ pub enum SystemMessageType { CallAccepted, CallEnded, + MessagePinned, + MessageUnpinned, } impl From for SystemMessageType { @@ -454,6 +459,8 @@ impl From for SystemMessageType { SystemMessage::SecurejoinWaitTimeout => SystemMessageType::SecurejoinWaitTimeout, SystemMessage::CallAccepted => SystemMessageType::CallAccepted, SystemMessage::CallEnded => SystemMessageType::CallEnded, + SystemMessage::MessagePinned => SystemMessageType::MessagePinned, + SystemMessage::MessageUnpinned => SystemMessageType::MessageUnpinned, } } } diff --git a/src/chat.rs b/src/chat.rs index f8a37a332..80ac95572 100644 --- a/src/chat.rs +++ b/src/chat.rs @@ -4612,6 +4612,7 @@ pub async fn forward_msgs_2ctx( msg.rfc724_mid = create_outgoing_rfc724_mid(); msg.pre_rfc724_mid.clear(); msg.timestamp_sort = now; + msg.pinned = false; chat.prepare_msg_raw(ctx_dst, &mut msg, None).await?; if !create_send_msg_jobs(ctx_dst, &mut msg).await?.is_empty() { diff --git a/src/lib.rs b/src/lib.rs index 92b84e87a..f73fa7b8f 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -81,6 +81,7 @@ mod param; mod pgp; #[cfg(feature = "internals")] pub mod pgp; +pub mod pinned_messages; pub mod provider; pub mod qr; pub mod qr_code_generator; diff --git a/src/message.rs b/src/message.rs index 550e0ad5e..2e1026ac2 100644 --- a/src/message.rs +++ b/src/message.rs @@ -461,6 +461,7 @@ pub struct Message { pub(crate) in_reply_to: Option, pub(crate) is_dc_message: MessengerMessage, pub(crate) original_msg_id: MsgId, + pub(crate) pinned: bool, pub(crate) mime_modified: bool, pub(crate) chat_visibility: ChatVisibility, pub(crate) chat_blocked: Blocked, @@ -530,6 +531,7 @@ impl Message { m.error AS error, m.msgrmsg AS msgrmsg, m.starred AS original_msg_id, + m.pinned AS pinned, m.mime_modified AS mime_modified, m.txt AS txt, m.subject AS subject, @@ -588,6 +590,7 @@ impl Message { .filter(|error| !error.is_empty()), is_dc_message: row.get("msgrmsg")?, original_msg_id: row.get("original_msg_id")?, + pinned: row.get("pinned")?, mime_modified: row.get("mime_modified")?, text, additional_text: String::new(), @@ -1080,6 +1083,8 @@ impl Message { | SystemMessage::IrohNodeAddr | SystemMessage::CallAccepted | SystemMessage::CallEnded + | SystemMessage::MessagePinned // UI should scroll to pinned message on tapping + | SystemMessage::MessageUnpinned // UI should scroll to unpinned message on tapping | SystemMessage::Unknown => Ok(None), } } @@ -1346,6 +1351,11 @@ impl Message { Ok(res) } + /// Returns true if the message is pinned. + pub fn is_pinned(&self) -> bool { + self.pinned + } + /// Force the message to be sent in plain text. pub(crate) fn force_plaintext(&mut self) { self.param.set_int(Param::ForcePlaintext, 1); diff --git a/src/mimefactory.rs b/src/mimefactory.rs index c16f7c085..37b36658a 100644 --- a/src/mimefactory.rs +++ b/src/mimefactory.rs @@ -1812,6 +1812,8 @@ impl MimeFactory { SystemMessage::ChatE2ee => {} SystemMessage::CallAccepted => {} SystemMessage::CallEnded => {} + SystemMessage::MessagePinned => {} + SystemMessage::MessageUnpinned => {} } if command == SystemMessage::GroupDescriptionChanged @@ -1937,6 +1939,18 @@ impl MimeFactory { mail_builder::headers::raw::Raw::new("call-ended").into(), )); } + SystemMessage::MessagePinned => { + headers.push(( + "Chat-Content", + mail_builder::headers::raw::Raw::new("message-pinned").into(), + )); + } + SystemMessage::MessageUnpinned => { + headers.push(( + "Chat-Content", + mail_builder::headers::raw::Raw::new("message-unpinned").into(), + )); + } _ => {} } diff --git a/src/mimeparser.rs b/src/mimeparser.rs index d4d6fc7d7..3a7730b40 100644 --- a/src/mimeparser.rs +++ b/src/mimeparser.rs @@ -263,6 +263,12 @@ pub enum SystemMessage { /// Group or broadcast channel description changed. GroupDescriptionChanged = 70, + + /// Message pinned. The pinned message is referred in `In-Reply-To:` header. + MessagePinned = 71, + + /// Message unpinned. The unpinned message is referred in `In-Reply-To:` header. + MessageUnpinned = 72, } impl MimeMessage { @@ -741,6 +747,10 @@ impl MimeMessage { self.is_system_message = SystemMessage::CallAccepted; } else if value == "call-ended" { self.is_system_message = SystemMessage::CallEnded; + } else if value == "message-pinned" { + self.is_system_message = SystemMessage::MessagePinned; + } else if value == "message-unpinned" { + self.is_system_message = SystemMessage::MessageUnpinned; } } else if self.get_header(HeaderDef::ChatGroupMemberRemoved).is_some() { self.is_system_message = SystemMessage::MemberRemovedFromGroup; diff --git a/src/pinned_messages.rs b/src/pinned_messages.rs new file mode 100644 index 000000000..54f94d0ac --- /dev/null +++ b/src/pinned_messages.rs @@ -0,0 +1,328 @@ +//! # Handle pinned messages. +//! +//! Pinned messages can be used in all types of chats +//! and for all but info-messages. +//! +//! Pinned messages are synchronized for all chat members by sending an info-message +//! that refers the pinned message in the `In-Reply-To:` header. +//! The info-message is only shown for pinning (not for unpinning). +//! Unpinning is an action that does not require so much attention; internally it is a hidden info-message. + +use anyhow::{Result, ensure}; + +use crate::chat::{ChatId, send_msg}; +use crate::contact::ContactId; +use crate::context::Context; +use crate::message::{Message, MessageState, MsgId, Viewtype}; +use crate::mimeparser::SystemMessage; +use crate::stock_str; + +/// Check if the given message is pinnable in general. +/// This does not mean the local user is allowed to pin/unpin it themselves, +/// e.g. messages in broadcast channels may be pinnable - but cannot be pinned by the local user. +fn is_pinnable(msg: &Message) -> bool { + !msg.id.is_special() + && !msg.is_info() + && !msg.hidden + && msg.state != MessageState::OutDraft + && msg.state != MessageState::OutFailed // Some user did not get the message, pinning it raises wrong expectations + && !msg.chat_id.is_special() +} + +/// Pin or unpin a message. +/// +/// If the message is not pinnable, an error is returned. +/// If pinning changes, `EventType::MsgsChanged`` event is fired to show/hide the pinning needle. +pub async fn set_pinned_state( + context: &Context, + msg_id: MsgId, + new_pinned_state: bool, +) -> Result<()> { + let msg = Message::load_from_db(context, msg_id).await?; + ensure!(is_pinnable(&msg), "Message is not pinnable."); + if msg.is_pinned() == new_pinned_state { + return Ok(()); + } + + let mut info_msg = Message::new(Viewtype::Text); + info_msg.text = if new_pinned_state { + stock_str::msg_pinned(context, ContactId::SELF).await + } else { + "Message unpinned.".to_string() // no need to localize, "unpinned" messages are not visible + }; + info_msg.hidden = !new_pinned_state; + info_msg.in_reply_to = Some(msg.rfc724_mid.clone()); + info_msg.param.set_cmd(if new_pinned_state { + SystemMessage::MessagePinned + } else { + SystemMessage::MessageUnpinned + }); + send_msg(context, msg.chat_id, &mut info_msg).await?; + + // alter database only after we successfully sent the message + update_pinned_state_in_db(context, &msg, new_pinned_state).await?; + + Ok(()) +} + +async fn update_pinned_state_in_db( + context: &Context, + msg: &Message, + new_pinned_state: bool, +) -> Result<()> { + context + .sql + .execute( + "UPDATE msgs SET pinned=? WHERE id=?", + (new_pinned_state, msg.id), + ) + .await?; + context.emit_msgs_changed(msg.chat_id, msg.id); + + Ok(()) +} + +/// Returns all pinned messages of a chat. +/// +/// The list is ordered by message date, not by pinning date, +/// and starts with the oldest message - same as the normal message view. +/// +/// When a chat is opened, UI should show the newest message in the "pinned banner". +/// The scrollbar of the "pinned banner" will be scrolled down all the way, same as the whole chat. +/// The pinned message is shown using `Message::get_summary()`, enriched by thumbnails and a "Start" button for webxdc. +/// +/// Once the banner is tapped, UI should scroll to that message and replace the banner by pinned message one position less. +/// When the position is 0, UI should wrap and show the newest message again. +/// +/// By that, usually scrolling the message view and the pinned view have the same direction. +pub async fn get_pinned_messages(context: &Context, chat_id: ChatId) -> Result> { + ensure!(!chat_id.is_special(), "Invalid chat ID."); + + let pinned_msg_ids = context + .sql + .query_map_vec( + "SELECT id + FROM msgs + WHERE pinned=1 AND hidden=0 AND chat_id=? + ORDER BY timestamp, id;", + (chat_id,), + |row| { + let msg_id: MsgId = row.get(0)?; + Ok(msg_id) + }, + ) + .await?; + Ok(pinned_msg_ids) +} + +/// Handle pinned state received from the wire, e.g. by an info message. +/// +/// This function checks and updates the state and sends events, +/// but does not add a info message or sync otherwise. +/// If the message is not pinnable, an error is returned. +pub(crate) async fn handle_pinned_state_from_wire( + context: &Context, + msg: &Message, + new_pinned_state: bool, +) -> Result<()> { + ensure!(is_pinnable(msg), "Message is not pinnable."); + if msg.is_pinned() == new_pinned_state { + return Ok(()); + } + update_pinned_state_in_db(context, msg, new_pinned_state).await?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::chat::{ChatItem, create_broadcast, get_chat_msgs}; + use crate::config::Config; + use crate::securejoin::get_securejoin_qr; + use crate::test_utils::{TestContextManager, sync}; + use std::time::Duration; + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn test_pinned_messages() -> Result<()> { + let mut tcm = TestContextManager::new(); + let alice = &tcm.alice().await; + let alice2 = &tcm.alice().await; // Alice's second device + let bob = &tcm.bob().await; + + alice.set_config_bool(Config::SyncMsgs, true).await?; + alice2.set_config_bool(Config::SyncMsgs, true).await?; + + // Alice creates all chat types upfront, with Bob as member if possible + let single_chat_id = alice.create_chat(bob).await.id; + let group_chat_id = alice.create_group_with_members("Group", &[bob]).await; + let broadcast_chat_id = create_broadcast(alice, "Channel".to_string()).await?; + let qr = get_securejoin_qr(alice, Some(broadcast_chat_id)).await?; + tcm.exec_securejoin_qr(bob, alice, &qr).await; + let self_chat_id = alice.get_self_chat().await.id; + sync(alice, alice2).await; + + for alice_chat_id in [ + single_chat_id, + group_chat_id, + broadcast_chat_id, + self_chat_id, + ] { + let pinned = get_pinned_messages(alice, alice_chat_id).await?; + assert!(pinned.is_empty()); + + // Alice sends message "Foo" and pins it + let sent1 = alice.send_text(alice_chat_id, "Foo").await; + let msg1 = sent1.load_from_db().await; + assert!(!msg1.is_pinned()); + + set_pinned_state(alice, msg1.id, true).await?; + let sent2 = alice.pop_sent_msg().await; + assert!(sent1.load_from_db().await.is_pinned()); + + let info_msg = sent2.load_from_db().await; + assert!(info_msg.is_info()); + assert!(!info_msg.hidden); + assert_eq!(info_msg.get_info_type(), SystemMessage::MessagePinned); + assert!(info_msg.get_info_contact_id(alice).await?.is_none()); // contact not needed, tapping shall jump to message + + let pinned = get_pinned_messages(alice, alice_chat_id).await?; + assert_eq!(pinned.len(), 1); + assert_eq!(pinned[0], msg1.id); + + // Pinning an info message does not work + assert!(set_pinned_state(alice, info_msg.id, true).await.is_err()); + + // Unpin the initially pinned message. + // Before, send another message "Bar". To test, no visible info message is added this time, + let sent3 = alice.send_text(alice_chat_id, "Bar").await; + + set_pinned_state(alice, msg1.id, false).await?; + let sent4 = alice.pop_sent_msg().await; + assert!(!sent1.load_from_db().await.is_pinned()); + + let pinned = get_pinned_messages(alice, alice_chat_id).await?; + assert!(pinned.is_empty()); + + let msg3 = sent3.load_from_db().await; + assert!(!msg3.is_info()); + assert!(!msg3.is_pinned()); + assert_eq!(alice.get_last_msg_id_in(msg3.chat_id).await, msg3.id); // last message is still "Bar", not an info message + + if alice_chat_id != self_chat_id { + // Bob receives message "Foo" + let msg1 = bob.recv_msg(&sent1).await; + assert!(!msg1.is_pinned()); + let pinned = get_pinned_messages(bob, msg1.chat_id).await?; + assert!(pinned.is_empty()); + + // Bob receives info message to pin "Foo" + bob.recv_msg(&sent2).await; + assert!(Message::load_from_db(bob, msg1.id).await?.is_pinned()); + + let pinned = get_pinned_messages(bob, msg1.chat_id).await?; + assert_eq!(pinned.len(), 1); + assert_eq!(pinned[0], msg1.id); + + let info_msg = + Message::load_from_db(bob, bob.get_last_msg_id_in(msg1.chat_id).await).await?; + assert!(info_msg.is_info()); + assert!(!info_msg.hidden); + assert_eq!(info_msg.get_info_type(), SystemMessage::MessagePinned); + assert!(info_msg.get_info_contact_id(bob).await?.is_none()); + + // Bob receives message "Bar" and hidden message to unpin message "Foo" + bob.recv_msg(&sent3).await; + bob.recv_msg_trash(&sent4).await; + assert!(!Message::load_from_db(bob, msg1.id).await?.is_pinned()); + + let pinned = get_pinned_messages(bob, msg1.chat_id).await?; + assert!(pinned.is_empty()); + + let no_info_msg = + Message::load_from_db(bob, bob.get_last_msg_id_in(msg1.chat_id).await).await?; + assert!(!no_info_msg.is_info()); + assert_eq!(no_info_msg.text, "Bar"); + } + + // Alice's second device receives all four messages and ends up in the same state + let msg1 = alice2.recv_msg(&sent1).await; + alice2.recv_msg(&sent2).await; + assert!(Message::load_from_db(alice2, msg1.id).await?.is_pinned()); + + alice2.recv_msg(&sent3).await; + alice2.recv_msg_trash(&sent4).await; + assert!(!Message::load_from_db(alice2, msg1.id).await?.is_pinned()); + + let no_info_msg = + Message::load_from_db(alice2, alice2.get_last_msg_id_in(msg1.chat_id).await) + .await?; + assert!(!no_info_msg.is_info()); + assert_eq!(no_info_msg.text, "Bar"); + } + + Ok(()) + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn test_get_pinned_messages_order() -> Result<()> { + let mut tcm = TestContextManager::new(); + let alice = &tcm.alice().await; + let chat_id = alice.get_self_chat().await.id; + let boilerplate_msg_count = get_chat_msgs(alice, chat_id).await?.len(); + + // create three messages, sent1 and sent2 have different timestamp, sent2 and sent3 may differ by ID only + let sent1 = alice.send_text(chat_id, "1").await; + tokio::time::sleep(Duration::from_millis(1100)).await; + let sent2 = alice.send_text(chat_id, "2").await; + let sent3 = alice.send_text(chat_id, "3").await; + + // get_chat_msgs() start with the oldest message + let chat_msgs = get_chat_msgs(alice, chat_id).await?; + let msg_ids: Vec<_> = chat_msgs + .into_iter() + .filter_map(|item| match item { + ChatItem::Message { msg_id } => Some(msg_id), + ChatItem::DayMarker { .. } => None, + }) + .collect(); + assert_eq!( + &msg_ids[boilerplate_msg_count..], + &[ + sent1.sender_msg_id, + sent2.sender_msg_id, + sent3.sender_msg_id + ] + ); + + // get_pinned_messages() has the same order, also starting with the oldest message + set_pinned_state(alice, sent1.sender_msg_id, true).await?; + set_pinned_state(alice, sent2.sender_msg_id, true).await?; + set_pinned_state(alice, sent3.sender_msg_id, true).await?; + let pinned = get_pinned_messages(alice, chat_id).await?; + assert_eq!(pinned.len(), 3); + assert_eq!(pinned[0], sent1.sender_msg_id); + assert_eq!(pinned[1], sent2.sender_msg_id); + assert_eq!(pinned[2], sent3.sender_msg_id); + + // order of pinning does not affect the order of pinned messages. + // this is to keep scrolling direction of chat bubbles and pinned banner scrollbar in sync, + // and not jumping wildly around. + // this is also what most other messengers are doing. + set_pinned_state(alice, sent1.sender_msg_id, false).await?; + set_pinned_state(alice, sent2.sender_msg_id, false).await?; + set_pinned_state(alice, sent3.sender_msg_id, false).await?; + let pinned = get_pinned_messages(alice, chat_id).await?; + assert_eq!(pinned.len(), 0); + + set_pinned_state(alice, sent3.sender_msg_id, true).await?; + set_pinned_state(alice, sent2.sender_msg_id, true).await?; + set_pinned_state(alice, sent1.sender_msg_id, true).await?; + let pinned = get_pinned_messages(alice, chat_id).await?; + assert_eq!(pinned.len(), 3); + assert_eq!(pinned[0], sent1.sender_msg_id); + assert_eq!(pinned[1], sent2.sender_msg_id); + assert_eq!(pinned[2], sent3.sender_msg_id); + + Ok(()) + } +} diff --git a/src/receive_imf.rs b/src/receive_imf.rs index 13c99fbb8..922251f6e 100644 --- a/src/receive_imf.rs +++ b/src/receive_imf.rs @@ -41,6 +41,7 @@ use crate::mimeparser::{ }; use crate::param::{Param, Params}; use crate::peer_channels::{add_gossip_peer_from_header, insert_topic_stub, iroh_topic_from_str}; +use crate::pinned_messages::handle_pinned_state_from_wire; use crate::reaction::broadcast_reactions::receive_broadcast_reactions; use crate::reaction::{Reaction, set_msg_reaction}; use crate::rusqlite::OptionalExtension; @@ -1202,6 +1203,9 @@ async fn decide_chat_assignment( { info!(context, "Call state changed (TRASH)."); true + } else if mime_parser.is_system_message == SystemMessage::MessageUnpinned { + info!(context, "Message unpinned (TRASH)."); + true } else if let Some(ref decryption_error) = mime_parser.decryption_error && !mime_parser.incoming { @@ -1990,6 +1994,8 @@ async fn add_parts( ephemeral_timer = EphemeralTimer::Disabled; Some(better_msg) + } else if mime_parser.is_system_message == SystemMessage::MessagePinned { + Some(stock_str::msg_pinned(context, from_id).await) // message unpinned info is trashed in decide_chat_assignment() } else { None }; @@ -2132,6 +2138,15 @@ async fn add_parts( } } + if (mime_parser.is_system_message == SystemMessage::MessagePinned + || mime_parser.is_system_message == SystemMessage::MessageUnpinned) + && let Some(msg_to_change) = + get_parent_message(context, None, mime_parser.get_header(HeaderDef::InReplyTo)).await? + { + let new_pinned_state = mime_parser.is_system_message == SystemMessage::MessagePinned; + handle_pinned_state_from_wire(context, &msg_to_change, new_pinned_state).await?; + } + let hidden = mime_parser.parts.iter().all(|part| part.is_reaction); let mut parts = mime_parser.parts.iter().peekable(); while let Some(part) = parts.next() { diff --git a/src/sql/migrations.rs b/src/sql/migrations.rs index 79d0a29f0..b72b9d0e4 100644 --- a/src/sql/migrations.rs +++ b/src/sql/migrations.rs @@ -2592,6 +2592,24 @@ UPDATE msgs SET state=24 WHERE state=18; -- Change OutPreparing to OutFailed. .await?; } + inc_and_check(&mut migration_version, 163)?; + if dbversion < migration_version { + // store pinned state as a column rather than in a separate table, + // because pinned state must be read together with mostly every message (to show the "pin needle"), + // so a LEFT JOIN would add per-row overhead on every message load - + // even though pinned messages themselves are rare. + // this mirrors how "starred" and "hidden" are handled. + // + // the partial index `WHERE pinned=1` keeps the index small and useful, + // since the vast majority of rows are `pinned=0`. + sql.execute_migration( + "ALTER TABLE msgs ADD COLUMN pinned INTEGER DEFAULT 0; + CREATE INDEX msgs_index10 ON msgs (pinned) WHERE pinned=1;", + 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 886f57b6b..0da7f2683 100644 --- a/src/stock_str.rs +++ b/src/stock_str.rs @@ -421,6 +421,12 @@ https://delta.chat/donate"))] #[strum(props(fallback = "Messages are end-to-end encrypted."))] MessagesAreE2ee = 242, + + #[strum(props(fallback = "You pinned a message."))] + MsgYouPinnedAMessage = 243, + + #[strum(props(fallback = "Message pinned by %1$s."))] + MsgMessagePinnedBy = 244, } impl StockMessage { @@ -606,6 +612,17 @@ pub(crate) async fn msg_chat_description_changed( } } +/// Stock strings for pinning a message; used in info messages, once tapped, UI scrolled to the pinned message. +/// For unpinning a message, we do not add a visible info message as this is of fewer interest. +pub(crate) async fn msg_pinned(context: &Context, by_contact: ContactId) -> String { + if by_contact == ContactId::SELF { + translated(context, StockMessage::MsgYouPinnedAMessage) + } else { + translated(context, StockMessage::MsgMessagePinnedBy) + .replace1(&by_contact.get_stock_name(context).await) + } +} + /// Stock string: `Member %1$s added.`, `You added member %1$s.` or `Member %1$s added by %2$s.`. /// /// The `added_member` and `by_contact` contacts diff --git a/src/tests/pre_messages/legacy.rs b/src/tests/pre_messages/legacy.rs index 0674b0252..4f9ce13e0 100644 --- a/src/tests/pre_messages/legacy.rs +++ b/src/tests/pre_messages/legacy.rs @@ -34,7 +34,7 @@ async fn test_download_stub_message() -> Result<()> { 11001,'Mr.12345678901@example.com','',0, 11001,11001,1,1763151754,10,10,1,0, '[97.66 KiB message]','','',0,1763151754,1763151754,0,X'', - '','',1,0,'',0,0,0,'foo',10,replace('Hop: From: userid; Date: Mon, 4 Dec 2006 13:51:39 +0000\n\nDKIM Results: Passed=true','\n',char(10)),1,NULL,0,''); + '','',1,0,'',0,0,0,'foo',10,replace('Hop: From: userid; Date: Mon, 4 Dec 2006 13:51:39 +0000\n\nDKIM Results: Passed=true','\n',char(10)),1,NULL,0,'',0); "#, ()).await?; let msg = t.get_last_msg().await; assert_eq!(msg.download_state(), DownloadState::Available);