Compare commits

...

3 Commits

Author SHA1 Message Date
biörn
68d50055f9 Update deltachat-ffi/deltachat.h
Co-authored-by: iequidoo <117991069+iequidoo@users.noreply.github.com>
2026-03-16 09:26:34 +01:00
B. Petersen
386204e92e feat: mark messages as "fresh"
this adds an api to make the newest incoming message of a chat as "fresh",
so that UI can offer a "mark chat unread" option as usual for messengers
(eg. swipe right on iOS toggels between "read" and "unread").

"mark unread" is one of the most requested missing features,
used by many ppl to organize their every day messenger usage -
tho "pinning" and "saved messages" are similar,
it seems to be missed often.

we follow a very simple approach here
and just reset the state to `MessageState::InFresh`.
this does not introduce new states or flows.

therefore, chats without any incoming message cannot be marked as fresh.
in practise, this is probably not really an issue,
as the "mark fresh" is usually used to undo a "mark noticed" operation -
and then you have incoming message.
also, most status messages as "all messages are e2ee" count as incoming.

to avoid double sending of MDN,
we remove `Param::WantsMdn` once the MDN is scheduled.
in case MDN are used for syncing, MDN is still sent as before.

many other messenger show a "badge without number",
if we want that as well,
we can always track the "manually set as fresh" state in a parameter.
but for now, it is fine without and showing a "1", which alsso makes sense as badges may be summed up.

there is an iOS pr that uses this new feature,
jsonrpc is left out until api is settled.

also out of scope is synchronisation -
main reason is that "mark noticed" is not synced as well, so we avoid an imbalance here.
both, "mark noticed" as well as "mark fresh" should be synced however,
as soon as this feature is merged.
2026-03-16 09:26:34 +01:00
link2xt
822a99ea9c fix: do not send MDNs for hidden messages
Hidden messages are marked as seen
when chat is marked as noticed.
MDNs to such messages should not be sent
as this notifies the hidden message sender
that the chat was opened.

The issue discovered by Frank Seifferth.
2026-03-15 20:54:50 +00:00
5 changed files with 239 additions and 2 deletions

View File

@@ -1569,7 +1569,7 @@ dc_array_t* dc_wait_next_msgs (dc_context_t* context);
* (read receipts aren't sent for noticed messages).
*
* Calling this function usually results in the event #DC_EVENT_MSGS_NOTICED.
* See also dc_markseen_msgs().
* See also dc_markseen_msgs() and dc_markfresh_chat().
*
* @memberof dc_context_t
* @param context The context object as returned from dc_context_new().
@@ -1578,6 +1578,29 @@ dc_array_t* dc_wait_next_msgs (dc_context_t* context);
void dc_marknoticed_chat (dc_context_t* context, uint32_t chat_id);
/**
* Mark the last incoming message in chat as _fresh_.
*
* UI can use this to offer a "mark unread" option,
* so that already noticed chats (see dc_marknoticed_chat()) get a badge counter again.
*
* dc_get_fresh_msg_cnt() and dc_get_fresh_msgs() usually is increased by one afterwards.
*
* #DC_EVENT_MSGS_CHANGED is fired as usual,
* however, #DC_EVENT_INCOMING_MSG is _not_ fired again.
* This is to not add complexity to incoming messages code,
* e.g. UI usually does not add notifications for manually unread chats.
* If the UI wants to update system badge counters,
* they should do so directly after calling dc_markfresh_chat().
*
* @memberof dc_context_t
* @param context The context object as returned from dc_context_new().
* @param chat_id The chat ID of which the last incoming message should be marked as fresh.
* If the chat does not have incoming messages, nothing happens.
*/
void dc_markfresh_chat (dc_context_t* context, uint32_t chat_id);
/**
* Returns all message IDs of the given types in a given chat or any chat.
* Typically used to show a gallery.

View File

@@ -1523,6 +1523,23 @@ pub unsafe extern "C" fn dc_marknoticed_chat(context: *mut dc_context_t, chat_id
})
}
#[no_mangle]
pub unsafe extern "C" fn dc_markfresh_chat(context: *mut dc_context_t, chat_id: u32) {
if context.is_null() {
eprintln!("ignoring careless call to dc_markfresh_chat()");
return;
}
let ctx = &*context;
block_on(async move {
chat::markfresh_chat(ctx, ChatId::new(chat_id))
.await
.context("Failed markfresh chat")
.log_err(ctx)
.unwrap_or(())
})
}
fn from_prim<S, T>(s: S) -> Option<T>
where
T: FromPrimitive,

View File

@@ -3406,6 +3406,38 @@ pub(crate) async fn mark_old_messages_as_noticed(
Ok(())
}
/// Marks last incoming message in a chat as fresh.
pub async fn markfresh_chat(context: &Context, chat_id: ChatId) -> Result<()> {
let affected_rows = context
.sql
.execute(
"UPDATE msgs
SET state=?1
WHERE id=(SELECT id
FROM msgs
WHERE state IN (?1, ?2, ?3) AND hidden=0 AND chat_id=?4
ORDER BY timestamp DESC, id DESC
LIMIT 1)
AND state!=?1",
(
MessageState::InFresh,
MessageState::InNoticed,
MessageState::InSeen,
chat_id,
),
)
.await?;
if affected_rows == 0 {
return Ok(());
}
context.emit_msgs_changed_without_msg_id(chat_id);
chatlist_events::emit_chatlist_item_changed(context, chat_id);
Ok(())
}
/// Returns all database message IDs of the given types.
///
/// If `chat_id` is None, return messages from any chat.

View File

@@ -1934,10 +1934,22 @@ pub async fn markseen_msgs(context: &Context, msg_ids: Vec<MsgId>) -> Result<()>
// We also don't send read receipts for contact requests.
// Read receipts will not be sent even after accepting the chat.
let to_id = if curr_blocked == Blocked::Not
&& !curr_hidden
&& curr_param.get_bool(Param::WantsMdn).unwrap_or_default()
&& curr_param.get_cmd() == SystemMessage::Unknown
&& context.should_send_mdns().await?
{
// Clear WantsMdn to not handle a MDN twice
// if the state later is InFresh again as markfresh_chat() was called.
// BccSelf MDN messages in the next branch may be sent twice for syncing.
context
.sql
.execute(
"UPDATE msgs SET param=? WHERE id=?",
(curr_param.clone().remove(Param::WantsMdn).to_string(), id),
)
.await
.context("failed to clear WantsMdn")?;
Some(curr_from_id)
} else if context.get_config_bool(Config::BccSelf).await? {
Some(ContactId::SELF)
@@ -1955,6 +1967,7 @@ pub async fn markseen_msgs(context: &Context, msg_ids: Vec<MsgId>) -> Result<()>
.context("failed to insert into smtp_mdns")?;
context.scheduler.interrupt_smtp().await;
}
if !curr_hidden {
updated_chat_ids.insert(curr_chat_id);
}

View File

@@ -393,7 +393,9 @@ mod tests {
use crate::chatlist::Chatlist;
use crate::config::Config;
use crate::contact::{Contact, Origin};
use crate::message::{MessageState, Viewtype, delete_msgs};
use crate::key::{load_self_public_key, load_self_secret_key};
use crate::message::{MessageState, Viewtype, delete_msgs, markseen_msgs};
use crate::pgp::{SeipdVersion, pk_encrypt};
use crate::receive_imf::receive_imf;
use crate::sql::housekeeping;
use crate::test_utils::E2EE_INFO_MSGS;
@@ -956,4 +958,154 @@ Content-Disposition: reaction\n\
}
Ok(())
}
/// Tests that if reaction requests a read receipt,
/// no read receipt is sent when the chat is marked as noticed.
///
/// Reactions create hidden messages in the chat,
/// and when marking the chat as noticed marks
/// such messages as seen, read receipts should never be sent
/// to avoid the sender of reaction from learning
/// that receiver opened the chat.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_reaction_request_mdn() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = &tcm.alice().await;
let bob = &tcm.bob().await;
let alice_chat_id = alice.create_chat_id(bob).await;
let alice_sent_msg = alice.send_text(alice_chat_id, "Hello!").await;
let bob_msg = bob.recv_msg(&alice_sent_msg).await;
bob_msg.chat_id.accept(bob).await?;
assert_eq!(bob_msg.state, MessageState::InFresh);
let bob_chat_id = bob_msg.chat_id;
bob_chat_id.accept(bob).await?;
markseen_msgs(bob, vec![bob_msg.id]).await?;
assert_eq!(
bob.sql
.count(
"SELECT COUNT(*) FROM smtp_mdns WHERE from_id!=?",
(ContactId::SELF,)
)
.await?,
1
);
bob.sql.execute("DELETE FROM smtp_mdns", ()).await?;
// Construct reaction with an MDN request.
// Note the `Chat-Disposition-Notification-To` header.
let known_id = bob_msg.rfc724_mid;
let new_id = "e2b6e69e-4124-4e2a-b79f-e4f1be667165@localhost";
let plain_text = format!(
"Content-Type: text/plain; charset=\"utf-8\"; protected-headers=\"v1\"; \r
hp=\"cipher\"\r
Content-Disposition: reaction\r
From: \"Alice\" <alice@example.org>\r
To: \"Bob\" <bob@example.net>\r
Subject: Message from Alice\r
Date: Sat, 14 Mar 2026 01:02:03 +0000\r
In-Reply-To: <{known_id}>\r
References: <{known_id}>\r
Chat-Version: 1.0\r
Chat-Disposition-Notification-To: alice@example.org\r
Message-ID: <{new_id}>\r
HP-Outer: From: <alice@example.org>\r
HP-Outer: To: \"hidden-recipients\": ;\r
HP-Outer: Subject: [...]\r
HP-Outer: Date: Sat, 14 Mar 2026 01:02:03 +0000\r
HP-Outer: Message-ID: <{new_id}>\r
HP-Outer: In-Reply-To: <{known_id}>\r
HP-Outer: References: <{known_id}>\r
HP-Outer: Chat-Version: 1.0\r
Content-Transfer-Encoding: base64\r
\r
8J+RgA==\r
"
);
let alice_public_key = load_self_public_key(alice).await?;
let bob_public_key = load_self_public_key(bob).await?;
let alice_secret_key = load_self_secret_key(alice).await?;
let public_keys_for_encryption = vec![alice_public_key, bob_public_key];
let compress = true;
let anonymous_recipients = true;
let encrypted_payload = pk_encrypt(
plain_text.as_bytes().to_vec(),
public_keys_for_encryption,
alice_secret_key,
compress,
anonymous_recipients,
SeipdVersion::V2,
)
.await?;
let boundary = "boundary123";
let rcvd_mail = format!(
"From: <alice@example.org>\r
To: \"hidden-recipients\": ;\r
Subject: [...]\r
Date: Sat, 14 Mar 2026 01:02:03 +0000\r
Message-ID: <{new_id}>\r
In-Reply-To: <{known_id}>\r
References: <{known_id}>\r
Content-Type: multipart/encrypted; protocol=\"application/pgp-encrypted\";\r
boundary=\"{boundary}\"\r
MIME-Version: 1.0\r
\r
--{boundary}\r
Content-Type: application/pgp-encrypted; charset=\"utf-8\"\r
Content-Description: PGP/MIME version identification\r
Content-Transfer-Encoding: 7bit\r
\r
Version: 1\r
\r
--{boundary}\r
Content-Type: application/octet-stream; name=\"encrypted.asc\";\r
charset=\"utf-8\"\r
Content-Description: OpenPGP encrypted message\r
Content-Disposition: inline; filename=\"encrypted.asc\";\r
Content-Transfer-Encoding: 7bit\r
\r
{encrypted_payload}
--{boundary}--\r
"
);
let received = receive_imf(bob, rcvd_mail.as_bytes(), false)
.await?
.unwrap();
let bob_hidden_msg = Message::load_from_db(bob, *received.msg_ids.last().unwrap())
.await
.unwrap();
assert!(bob_hidden_msg.hidden);
assert_eq!(bob_hidden_msg.chat_id, bob_chat_id);
// Bob does not see new message and cannot mark it as seen directly,
// but can mark the chat as noticed when opening it.
marknoticed_chat(bob, bob_chat_id).await?;
assert_eq!(
bob.sql
.count(
"SELECT COUNT(*) FROM smtp_mdns WHERE from_id!=?",
(ContactId::SELF,)
)
.await?,
0,
"Bob should not send MDN to Alice"
);
// MDN request was ignored, but reaction was not.
let reactions = get_msg_reactions(bob, bob_msg.id).await?;
assert_eq!(reactions.reactions.len(), 1);
assert_eq!(
reactions.emoji_sorted_by_frequency(),
vec![("👀".to_string(), 1)]
);
Ok(())
}
}