feat: add "pinned messages" API

This commit is contained in:
B. Petersen
2026-08-04 15:30:57 +02:00
committed by biørn
parent 1247d5da36
commit bd0c0b6b4b
14 changed files with 477 additions and 1 deletions

View File

@@ -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_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_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_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, * For the messages that refer to a CONTACT,
* dc_msg_get_info_contact_id() returns the contact ID. * 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_WEBXDC_INFO_MESSAGE 32
#define DC_INFO_CHAT_E2EE 50 #define DC_INFO_CHAT_E2EE 50
#define DC_INFO_GROUP_DESCRIPTION_CHANGED 70 #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 * Used for Webxdc-info-messages
* to jump to the corresponding instance that created the info message. * 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 * For quotes, please use the more specialized
* dc_msg_get_quoted_text() and dc_msg_get_quoted_msg(). * 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); 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 * @class dc_contact_t
* *
@@ -5672,6 +5690,7 @@ void dc_jsonrpc_unref(dc_jsonrpc_instance_t* jsonrpc_instance);
* - getAccountFileSize() * - getAccountFileSize()
* - importVcard(), parseVcard(), makeVcard() * - importVcard(), parseVcard(), makeVcard()
* - sendWebxdcRealtimeData, sendWebxdcRealtimeAdvertisement(), leaveWebxdcRealtime() * - sendWebxdcRealtimeData, sendWebxdcRealtimeAdvertisement(), leaveWebxdcRealtime()
* - setPinnedMessageState(), getPinnedMessages()
* *
* @memberof dc_jsonrpc_instance_t * @memberof dc_jsonrpc_instance_t
* @param jsonrpc_instance jsonrpc instance as returned from dc_jsonrpc_init(). * @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. /// Used when creating text for the "Encryption Info" dialogs.
#define DC_STR_MESSAGES_ARE_E2EE 242 #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
/** /**
* @} * @}
*/ */

View File

@@ -3904,6 +3904,16 @@ pub unsafe extern "C" fn dc_msg_get_saved_msg_id(msg: *const dc_msg_t) -> u32 {
.unwrap_or(0) .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 // dc_contact_t
/// FFI struct for [dc_contact_t] /// FFI struct for [dc_contact_t]

View File

@@ -1508,6 +1508,26 @@ impl CommandApi {
MessageNotificationInfo::from_msg_id(&ctx, MsgId::new(message_id)).await 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<Vec<u32>> {
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 /// Delete messages. The messages are deleted on the current device and
/// on the IMAP server. /// on the IMAP server.
async fn delete_messages(&self, account_id: u32, message_ids: Vec<u32>) -> Result<()> { async fn delete_messages(&self, account_id: u32, message_ids: Vec<u32>) -> Result<()> {

View File

@@ -103,6 +103,8 @@ pub struct MessageObject {
saved_message_id: Option<u32>, saved_message_id: Option<u32>,
is_pinned: bool,
reactions: Option<JsonrpcReactions>, reactions: Option<JsonrpcReactions>,
vcard_contact: Option<VcardContact>, vcard_contact: Option<VcardContact>,
@@ -263,6 +265,7 @@ impl MessageObject {
.await? .await?
.map(|id| id.to_u32()), .map(|id| id.to_u32()),
is_pinned: message.is_pinned(),
reactions, reactions,
vcard_contact: vcard_contacts.first().cloned(), vcard_contact: vcard_contacts.first().cloned(),
@@ -425,6 +428,8 @@ pub enum SystemMessageType {
CallAccepted, CallAccepted,
CallEnded, CallEnded,
MessagePinned,
MessageUnpinned,
} }
impl From<deltachat::mimeparser::SystemMessage> for SystemMessageType { impl From<deltachat::mimeparser::SystemMessage> for SystemMessageType {
@@ -454,6 +459,8 @@ impl From<deltachat::mimeparser::SystemMessage> for SystemMessageType {
SystemMessage::SecurejoinWaitTimeout => SystemMessageType::SecurejoinWaitTimeout, SystemMessage::SecurejoinWaitTimeout => SystemMessageType::SecurejoinWaitTimeout,
SystemMessage::CallAccepted => SystemMessageType::CallAccepted, SystemMessage::CallAccepted => SystemMessageType::CallAccepted,
SystemMessage::CallEnded => SystemMessageType::CallEnded, SystemMessage::CallEnded => SystemMessageType::CallEnded,
SystemMessage::MessagePinned => SystemMessageType::MessagePinned,
SystemMessage::MessageUnpinned => SystemMessageType::MessageUnpinned,
} }
} }
} }

View File

@@ -4612,6 +4612,7 @@ pub async fn forward_msgs_2ctx(
msg.rfc724_mid = create_outgoing_rfc724_mid(); msg.rfc724_mid = create_outgoing_rfc724_mid();
msg.pre_rfc724_mid.clear(); msg.pre_rfc724_mid.clear();
msg.timestamp_sort = now; msg.timestamp_sort = now;
msg.pinned = false;
chat.prepare_msg_raw(ctx_dst, &mut msg, None).await?; chat.prepare_msg_raw(ctx_dst, &mut msg, None).await?;
if !create_send_msg_jobs(ctx_dst, &mut msg).await?.is_empty() { if !create_send_msg_jobs(ctx_dst, &mut msg).await?.is_empty() {

View File

@@ -81,6 +81,7 @@ mod param;
mod pgp; mod pgp;
#[cfg(feature = "internals")] #[cfg(feature = "internals")]
pub mod pgp; pub mod pgp;
pub mod pinned_messages;
pub mod provider; pub mod provider;
pub mod qr; pub mod qr;
pub mod qr_code_generator; pub mod qr_code_generator;

View File

@@ -461,6 +461,7 @@ pub struct Message {
pub(crate) in_reply_to: Option<String>, pub(crate) in_reply_to: Option<String>,
pub(crate) is_dc_message: MessengerMessage, pub(crate) is_dc_message: MessengerMessage,
pub(crate) original_msg_id: MsgId, pub(crate) original_msg_id: MsgId,
pub(crate) pinned: bool,
pub(crate) mime_modified: bool, pub(crate) mime_modified: bool,
pub(crate) chat_visibility: ChatVisibility, pub(crate) chat_visibility: ChatVisibility,
pub(crate) chat_blocked: Blocked, pub(crate) chat_blocked: Blocked,
@@ -530,6 +531,7 @@ impl Message {
m.error AS error, m.error AS error,
m.msgrmsg AS msgrmsg, m.msgrmsg AS msgrmsg,
m.starred AS original_msg_id, m.starred AS original_msg_id,
m.pinned AS pinned,
m.mime_modified AS mime_modified, m.mime_modified AS mime_modified,
m.txt AS txt, m.txt AS txt,
m.subject AS subject, m.subject AS subject,
@@ -588,6 +590,7 @@ impl Message {
.filter(|error| !error.is_empty()), .filter(|error| !error.is_empty()),
is_dc_message: row.get("msgrmsg")?, is_dc_message: row.get("msgrmsg")?,
original_msg_id: row.get("original_msg_id")?, original_msg_id: row.get("original_msg_id")?,
pinned: row.get("pinned")?,
mime_modified: row.get("mime_modified")?, mime_modified: row.get("mime_modified")?,
text, text,
additional_text: String::new(), additional_text: String::new(),
@@ -1080,6 +1083,8 @@ impl Message {
| SystemMessage::IrohNodeAddr | SystemMessage::IrohNodeAddr
| SystemMessage::CallAccepted | SystemMessage::CallAccepted
| SystemMessage::CallEnded | 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), | SystemMessage::Unknown => Ok(None),
} }
} }
@@ -1346,6 +1351,11 @@ impl Message {
Ok(res) 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. /// Force the message to be sent in plain text.
pub(crate) fn force_plaintext(&mut self) { pub(crate) fn force_plaintext(&mut self) {
self.param.set_int(Param::ForcePlaintext, 1); self.param.set_int(Param::ForcePlaintext, 1);

View File

@@ -1812,6 +1812,8 @@ impl MimeFactory {
SystemMessage::ChatE2ee => {} SystemMessage::ChatE2ee => {}
SystemMessage::CallAccepted => {} SystemMessage::CallAccepted => {}
SystemMessage::CallEnded => {} SystemMessage::CallEnded => {}
SystemMessage::MessagePinned => {}
SystemMessage::MessageUnpinned => {}
} }
if command == SystemMessage::GroupDescriptionChanged if command == SystemMessage::GroupDescriptionChanged
@@ -1937,6 +1939,18 @@ impl MimeFactory {
mail_builder::headers::raw::Raw::new("call-ended").into(), 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(),
));
}
_ => {} _ => {}
} }

View File

@@ -263,6 +263,12 @@ pub enum SystemMessage {
/// Group or broadcast channel description changed. /// Group or broadcast channel description changed.
GroupDescriptionChanged = 70, 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 { impl MimeMessage {
@@ -741,6 +747,10 @@ impl MimeMessage {
self.is_system_message = SystemMessage::CallAccepted; self.is_system_message = SystemMessage::CallAccepted;
} else if value == "call-ended" { } else if value == "call-ended" {
self.is_system_message = SystemMessage::CallEnded; 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() { } else if self.get_header(HeaderDef::ChatGroupMemberRemoved).is_some() {
self.is_system_message = SystemMessage::MemberRemovedFromGroup; self.is_system_message = SystemMessage::MemberRemovedFromGroup;

328
src/pinned_messages.rs Normal file
View File

@@ -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<Vec<MsgId>> {
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(())
}
}

View File

@@ -41,6 +41,7 @@ use crate::mimeparser::{
}; };
use crate::param::{Param, Params}; use crate::param::{Param, Params};
use crate::peer_channels::{add_gossip_peer_from_header, insert_topic_stub, iroh_topic_from_str}; 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::broadcast_reactions::receive_broadcast_reactions;
use crate::reaction::{Reaction, set_msg_reaction}; use crate::reaction::{Reaction, set_msg_reaction};
use crate::rusqlite::OptionalExtension; use crate::rusqlite::OptionalExtension;
@@ -1202,6 +1203,9 @@ async fn decide_chat_assignment(
{ {
info!(context, "Call state changed (TRASH)."); info!(context, "Call state changed (TRASH).");
true 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 } else if let Some(ref decryption_error) = mime_parser.decryption_error
&& !mime_parser.incoming && !mime_parser.incoming
{ {
@@ -1990,6 +1994,8 @@ async fn add_parts(
ephemeral_timer = EphemeralTimer::Disabled; ephemeral_timer = EphemeralTimer::Disabled;
Some(better_msg) 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 { } else {
None 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 hidden = mime_parser.parts.iter().all(|part| part.is_reaction);
let mut parts = mime_parser.parts.iter().peekable(); let mut parts = mime_parser.parts.iter().peekable();
while let Some(part) = parts.next() { while let Some(part) = parts.next() {

View File

@@ -2592,6 +2592,24 @@ UPDATE msgs SET state=24 WHERE state=18; -- Change OutPreparing to OutFailed.
.await?; .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 let new_version = sql
.get_raw_config_int(VERSION_CFG) .get_raw_config_int(VERSION_CFG)
.await? .await?

View File

@@ -421,6 +421,12 @@ https://delta.chat/donate"))]
#[strum(props(fallback = "Messages are end-to-end encrypted."))] #[strum(props(fallback = "Messages are end-to-end encrypted."))]
MessagesAreE2ee = 242, MessagesAreE2ee = 242,
#[strum(props(fallback = "You pinned a message."))]
MsgYouPinnedAMessage = 243,
#[strum(props(fallback = "Message pinned by %1$s."))]
MsgMessagePinnedBy = 244,
} }
impl StockMessage { 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.`. /// 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 /// The `added_member` and `by_contact` contacts

View File

@@ -34,7 +34,7 @@ async fn test_download_stub_message() -> Result<()> {
11001,'Mr.12345678901@example.com','',0, 11001,'Mr.12345678901@example.com','',0,
11001,11001,1,1763151754,10,10,1,0, 11001,11001,1,1763151754,10,10,1,0,
'[97.66 KiB message]','','',0,1763151754,1763151754,0,X'', '[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?; "#, ()).await?;
let msg = t.get_last_msg().await; let msg = t.get_last_msg().await;
assert_eq!(msg.download_state(), DownloadState::Available); assert_eq!(msg.download_state(), DownloadState::Available);