fix: Apply reactions that arrived before the message at later time (#8415)

A new table `pending_reactions` is now used,
to store reactions that arrived before the referenced message.

When receiving a new message, pending_reactions is checked and any
reactions that arrived earlier are applied.

Fixes: #8367

Signed-off-by: Jagoda Ślązak <jslazak@jslazak.com>
This commit is contained in:
Jagoda Estera Ślązak
2026-07-20 13:50:05 +00:00
committed by GitHub
parent 23c216c84b
commit 85db29bdfb
4 changed files with 254 additions and 10 deletions
+211 -8
View File
@@ -18,7 +18,7 @@ use std::cmp::Ordering;
use std::collections::BTreeMap; use std::collections::BTreeMap;
use std::fmt; use std::fmt;
use anyhow::Result; use anyhow::{Result, bail};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use crate::chat::{Chat, ChatId, send_msg}; use crate::chat::{Chat, ChatId, send_msg};
@@ -199,6 +199,39 @@ async fn set_msg_id_reaction(
Ok(()) Ok(())
} }
/// Adds or updates a pending reaction to `pending_reactions` table.
async fn set_pending_reaction(
context: &Context,
rfc724_mid: &str,
contact_id: ContactId,
timestamp: i64,
reaction: &Reaction,
) -> Result<()> {
if reaction.is_empty() {
context
.sql
.execute(
"DELETE FROM pending_reactions
WHERE rfc724_mid = ?1
AND contact_id = ?2",
(rfc724_mid, contact_id),
)
.await?;
} else {
context
.sql
.execute(
"INSERT INTO pending_reactions (rfc724_mid, contact_id, reaction, timestamp)
VALUES (?1, ?2, ?3, ?4)
ON CONFLICT(rfc724_mid, contact_id)
DO UPDATE SET reaction=excluded.reaction, timestamp=excluded.timestamp",
(rfc724_mid, contact_id, reaction.as_str(), timestamp),
)
.await?;
}
Ok(())
}
/// Sends a reaction to message `msg_id`, overriding previously sent reactions. /// Sends a reaction to message `msg_id`, overriding previously sent reactions.
/// ///
/// `reaction` is a string consisting of a single emoji. Use /// `reaction` is a string consisting of a single emoji. Use
@@ -235,6 +268,9 @@ pub async fn send_reaction(context: &Context, msg_id: MsgId, reaction: &str) ->
/// ///
/// `reaction` is string representing the emoji. It can be empty /// `reaction` is string representing the emoji. It can be empty
/// if contact wants to remove the reaction. /// if contact wants to remove the reaction.
///
/// Delegates to [`set_msg_reaction_if_present`],
/// and if message is not present, fallbacks to [`set_pending_reaction`].
pub(crate) async fn set_msg_reaction( pub(crate) async fn set_msg_reaction(
context: &Context, context: &Context,
in_reply_to: &str, in_reply_to: &str,
@@ -244,8 +280,43 @@ pub(crate) async fn set_msg_reaction(
reaction: Reaction, reaction: Reaction,
is_incoming_fresh: bool, is_incoming_fresh: bool,
) -> Result<()> { ) -> Result<()> {
if !set_msg_reaction_if_present(
context,
in_reply_to,
chat_id,
contact_id,
timestamp,
&reaction,
is_incoming_fresh,
)
.await?
{
info!(
context,
"Can't assign reaction to unknown message with Message-ID {}; inserting into pending table.",
in_reply_to
);
let rfc724_mid = in_reply_to.trim_start_matches('<').trim_end_matches('>');
set_pending_reaction(context, rfc724_mid, contact_id, timestamp, &reaction).await?
}
Ok(())
}
/// Similar to [`set_msg_reaction`],
/// but does not create a row in `pending_messages` if message is not present.
///
/// Returns `Ok(true)` if message was present and `Ok(false)` if not.
pub(crate) async fn set_msg_reaction_if_present(
context: &Context,
in_reply_to: &str,
chat_id: ChatId,
contact_id: ContactId,
timestamp: i64,
reaction: &Reaction,
is_incoming_fresh: bool,
) -> Result<bool> {
if let Some(msg_id) = rfc724_mid_exists(context, in_reply_to).await? { if let Some(msg_id) = rfc724_mid_exists(context, in_reply_to).await? {
set_msg_id_reaction(context, msg_id, chat_id, contact_id, timestamp, &reaction).await?; set_msg_id_reaction(context, msg_id, chat_id, contact_id, timestamp, reaction).await?;
if is_incoming_fresh if is_incoming_fresh
&& !reaction.is_empty() && !reaction.is_empty()
@@ -255,15 +326,82 @@ pub(crate) async fn set_msg_reaction(
chat_id, chat_id,
contact_id, contact_id,
msg_id, msg_id,
reaction, reaction: reaction.clone(),
}); });
} }
} else { return Ok(true);
info!(
context,
"Can't assign reaction to unknown message with Message-ID {}", in_reply_to
);
} }
Ok(false)
}
/// Applies pending reactions to message `rfc724_mid`, assuming `chat_id`.
///
/// Does not check if the `chat_id` is correct for this message.
pub(crate) async fn apply_pending_reactions(
context: &Context,
rfc724_mid: &str,
chat_id: ChatId,
) -> Result<()> {
let pending_reactions: BTreeMap<ContactId, (Reaction, i64)> = context
.sql
.query_map_collect(
"SELECT contact_id, reaction, timestamp
FROM pending_reactions
WHERE rfc724_mid=?",
(rfc724_mid,),
|row| {
let contact_id: ContactId = row.get(0)?;
let reaction: Reaction = Reaction::new(row.get::<_, String>(1)?.as_str());
let timestamp: i64 = row.get(2)?;
Ok((contact_id, (reaction, timestamp)))
},
)
.await?;
if pending_reactions.is_empty() {
return Ok(());
}
info!(
context,
"Applying {} pending reactions to {}.",
pending_reactions.len(),
rfc724_mid
);
for (contact_id, (reaction, timestamp)) in pending_reactions {
// We know whether the reaction message is incoming,
// but it is unclear whether it's still fresh (i.e. unnoticed by the user).
// To be safe, always count it as fresh, to notify the user rather once too often than once too few.
// This is only relevant in edge cases, anyway.
let is_incoming_fresh = contact_id != ContactId::SELF;
if !set_msg_reaction_if_present(
context,
rfc724_mid,
chat_id,
contact_id,
timestamp,
&reaction,
is_incoming_fresh,
)
.await?
{
bail!("Message {rfc724_mid} is not present, can't apply pending reactions");
}
}
// Note: race condition can't happen here,
// as at this point the message is already added to the DB,
// so no new pending reactions with this rfc724_mid will be added in meantime.
// (Assuming this function is used after receiving the message.)
context
.sql
.execute(
"DELETE FROM pending_reactions WHERE rfc724_mid=?",
(rfc724_mid,),
)
.await?;
Ok(()) Ok(())
} }
@@ -713,6 +851,71 @@ Content-Disposition: reaction\n\
Ok(()) Ok(())
} }
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_send_out_of_order_reaction() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = tcm.alice().await;
let bob = tcm.bob().await;
let charlie = tcm.charlie().await;
tcm.send_recv_accept(&alice, &bob, "hi").await;
tcm.send_recv_accept(&alice, &charlie, "hi").await;
let chat_alice = alice.create_chat(&bob).await;
let groupchat_alice = alice
.create_group_with_members("test", &[&bob, &charlie])
.await;
// one to one
{
let alice_msg = alice.send_text(chat_alice.id, "test").await;
send_reaction(&alice, alice_msg.sender_msg_id, "👍").await?;
let reaction_msg = alice.pop_sent_msg().await;
bob.recv_msg_hidden(&reaction_msg).await;
let msg = bob.recv_msg(&alice_msg).await;
assert_eq!(get_msg_reactions(&bob, msg.id).await?.reactions.len(), 1);
}
// group
{
let alice_msg = alice.send_text(groupchat_alice, "test").await;
send_reaction(&alice, alice_msg.sender_msg_id, "👍").await?;
let reaction_msg_alice = alice.pop_sent_msg().await;
let charlie_msg = charlie.recv_msg(&alice_msg).await;
send_reaction(&charlie, charlie_msg.id, "👍").await?;
let reaction_msg_charlie = charlie.pop_sent_msg().await;
bob.recv_msg_hidden(&reaction_msg_alice).await;
bob.recv_msg_hidden(&reaction_msg_charlie).await;
let msg = bob.recv_msg(&alice_msg).await;
assert_eq!(get_msg_reactions(&bob, msg.id).await?.reactions.len(), 2);
}
// react and remove reaction
{
let alice_msg = alice.send_text(chat_alice.id, "test").await;
send_reaction(&alice, alice_msg.sender_msg_id, "👍").await?;
let reaction_msg = alice.pop_sent_msg().await;
send_reaction(&alice, alice_msg.sender_msg_id, "").await?;
let remove_reaction_msg = alice.pop_sent_msg().await;
bob.recv_msg_hidden(&reaction_msg).await;
bob.recv_msg_hidden(&remove_reaction_msg).await;
let msg = bob.recv_msg(&alice_msg).await;
assert_eq!(get_msg_reactions(&bob, msg.id).await?.reactions.len(), 0);
}
Ok(())
}
async fn assert_summary(t: &TestContext, expected: &str) { async fn assert_summary(t: &TestContext, expected: &str) {
let chatlist = Chatlist::try_load(t, 0, None, None).await.unwrap(); let chatlist = Chatlist::try_load(t, 0, None, None).await.unwrap();
let summary = chatlist.get_summary(t, 0, None).await.unwrap(); let summary = chatlist.get_summary(t, 0, None).await.unwrap();
+8 -2
View File
@@ -724,7 +724,7 @@ pub(crate) async fn receive_imf_inner(
is_old_contact_request = chat_id_blocked == Blocked::Request && !is_created; is_old_contact_request = chat_id_blocked == Blocked::Request && !is_created;
// Add parts // Add parts
add_parts( let msg = add_parts(
context, context,
&mut mime_parser, &mut mime_parser,
imf_raw, imf_raw,
@@ -740,7 +740,13 @@ pub(crate) async fn receive_imf_inner(
is_created, is_created,
) )
.await .await
.context("add_parts error")? .context("add_parts error")?;
crate::reaction::apply_pending_reactions(context, rfc724_mid, chat_id)
.await
.context("Failed to apply pending reactions")?;
msg
}; };
if !from_id.is_special() { if !from_id.is_special() {
+18
View File
@@ -917,10 +917,28 @@ pub async fn housekeeping(context: &Context) -> Result<()> {
.log_err(context) .log_err(context)
.ok(); .ok();
remove_old_pending_reactions(context)
.await
.context("Failed to remove old pending reactions")
.log_err(context)
.ok();
info!(context, "Housekeeping done."); info!(context, "Housekeeping done.");
Ok(()) Ok(())
} }
/// Removes pending reactions that weren't applied for 90 days.
async fn remove_old_pending_reactions(context: &Context) -> Result<usize> {
let three_months_ago = time().saturating_sub(60 * 60 * 24 * 90);
context
.sql
.execute(
"DELETE FROM pending_reactions WHERE timestamp < ?",
(three_months_ago,),
)
.await
}
/// Removes transports that are hidden (`is_published=0`), /// Removes transports that are hidden (`is_published=0`),
/// and haven't been used to receive new messages for [`UNPUBLISHED_TRANSPORT_KEEP_TIME`] seconds. /// and haven't been used to receive new messages for [`UNPUBLISHED_TRANSPORT_KEEP_TIME`] seconds.
pub(crate) async fn remove_unused_hidden_transports(context: &Context) -> Result<usize> { pub(crate) async fn remove_unused_hidden_transports(context: &Context) -> Result<usize> {
+17
View File
@@ -2505,6 +2505,23 @@ UPDATE msgs SET state=24 WHERE state=18; -- Change OutPreparing to OutFailed.
.await?; .await?;
} }
inc_and_check(&mut migration_version, 158)?;
if dbversion < migration_version {
// Stores reactions to not-yet-received messages.
sql.execute_migration(
"CREATE TABLE pending_reactions (
rfc724_mid TEXT NOT NULL,
contact_id INTEGER NOT NULL,
reaction TEXT NOT NULL,
timestamp INTEGER NOT NULL,
PRIMARY KEY(rfc724_mid, contact_id),
FOREIGN KEY(contact_id) REFERENCES contacts(id) ON DELETE CASCADE
) STRICT",
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?