Skip to content

Commit f279b0d

Browse files
committed
feat: Sync user actions for ad-hoc groups across devices (#5065)
Ad-hoc groups don't have grpid-s that can be used to identify them across devices and thus wasn't synced until now. The same problem already exists for assigning messages to ad-hoc groups and this assignment is done by `get_parent_message()` and `lookup_chat_by_reply()`. Let's reuse this logic for the synchronisation, it works well enough and this way we have less surprises than if we try to implement grpids for ad-hoc groups. I.e. add an `Msgids` variant to `chat::SyncId` analogous to the "References" header in messages and put two following Message-IDs to a sync message: - The latest message A having `DownloadState::Done` and the state to be one of `InFresh, InNoticed, InSeen, OutDelivered, OutMdnRcvd`. - The message that A references in `In-Reply-To`. This way the logic is almost the same to what we have in `Chat::prepare_msg_raw()` (the difference is that we don't use the oldest Message-ID) and it's easier to reuse the existing code. NOTE: If a chat has only an OutPending message f.e., the synchronisation wouldn't work, but trying to work in such a corner case has no significant value and isn't worth complicating the code.
1 parent 3207129 commit f279b0d

File tree

3 files changed

+164
-79
lines changed

3 files changed

+164
-79
lines changed

src/chat.rs

Lines changed: 142 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -209,6 +209,30 @@ impl ChatId {
209209
self == DC_CHAT_ID_ALLDONE_HINT
210210
}
211211

212+
/// Returns [`ChatId`] of a chat that `msg` belongs to.
213+
///
214+
/// Checks that `msg` is assigned to the right chat.
215+
pub(crate) fn lookup_by_message(msg: &Message) -> Option<Self> {
216+
if msg.chat_id == DC_CHAT_ID_TRASH {
217+
return None;
218+
}
219+
if msg.download_state != DownloadState::Done
220+
// TODO (2023-09-12): Added for backward compatibility with versions that did not have
221+
// `DownloadState::Undecipherable`. Remove eventually with the comment in
222+
// `MimeMessage::from_bytes()`.
223+
|| msg
224+
.error
225+
.as_ref()
226+
.filter(|e| e.starts_with("Decrypting failed:"))
227+
.is_some()
228+
{
229+
// If `msg` is not fully downloaded or undecipherable, it may have been assigned to the
230+
// wrong chat (they often get assigned to the 1:1 chat with the sender).
231+
return None;
232+
}
233+
Some(msg.chat_id)
234+
}
235+
212236
/// Returns the [`ChatId`] for the 1:1 chat with `contact_id`
213237
/// if it exists and is not blocked.
214238
///
@@ -374,6 +398,7 @@ impl ChatId {
374398

375399
pub(crate) async fn block_ex(self, context: &Context, sync: sync::Sync) -> Result<()> {
376400
let chat = Chat::load_from_db(context, self).await?;
401+
let mut delete = false;
377402

378403
match chat.typ {
379404
Chattype::Broadcast => {
@@ -392,7 +417,7 @@ impl ChatId {
392417
}
393418
Chattype::Group => {
394419
info!(context, "Can't block groups yet, deleting the chat.");
395-
self.delete(context).await?;
420+
delete = true;
396421
}
397422
Chattype::Mailinglist => {
398423
if self.set_blocked(context, Blocked::Yes).await? {
@@ -408,6 +433,9 @@ impl ChatId {
408433
.log_err(context)
409434
.ok();
410435
}
436+
if delete {
437+
self.delete(context).await?;
438+
}
411439
Ok(())
412440
}
413441

@@ -1124,47 +1152,46 @@ impl ChatId {
11241152
Ok(self.get_param(context).await?.exists(Param::Devicetalk))
11251153
}
11261154

1127-
async fn parent_query<T, F>(self, context: &Context, fields: &str, f: F) -> Result<Option<T>>
1155+
async fn parent_query<T, F>(
1156+
self,
1157+
context: &Context,
1158+
fields: &str,
1159+
state_out_min: MessageState,
1160+
f: F,
1161+
) -> Result<Option<T>>
11281162
where
11291163
F: Send + FnOnce(&rusqlite::Row) -> rusqlite::Result<T>,
11301164
T: Send + 'static,
11311165
{
11321166
let sql = &context.sql;
1133-
// Do not reply to not fully downloaded messages. Such a message could be a group chat
1134-
// message that we assigned to 1:1 chat.
11351167
let query = format!(
11361168
"SELECT {fields} \
1137-
FROM msgs WHERE chat_id=? AND state NOT IN (?, ?) AND NOT hidden AND download_state={} \
1169+
FROM msgs \
1170+
WHERE chat_id=? \
1171+
AND ((state BETWEEN {} AND {}) OR (state >= {})) \
1172+
AND NOT hidden \
1173+
AND download_state={} \
11381174
ORDER BY timestamp DESC, id DESC \
11391175
LIMIT 1;",
1140-
DownloadState::Done as u32,
1176+
MessageState::InFresh as u32,
1177+
MessageState::InSeen as u32,
1178+
state_out_min as u32,
1179+
// Do not reply to not fully downloaded messages. Such a message could be a group chat
1180+
// message that we assigned to 1:1 chat.
1181+
DownloadState::Done as u32,
11411182
);
1142-
let row = sql
1143-
.query_row_optional(
1144-
&query,
1145-
(
1146-
self,
1147-
MessageState::OutPreparing,
1148-
MessageState::OutDraft,
1149-
// We don't filter `OutPending` and `OutFailed` messages because the new message
1150-
// for which `parent_query()` is done may assume that it will be received in a
1151-
// context affected by those messages, e.g. they could add new members to a
1152-
// group and the new message will contain them in "To:". Anyway recipients must
1153-
// be prepared to orphaned references.
1154-
),
1155-
f,
1156-
)
1157-
.await?;
1158-
Ok(row)
1183+
sql.query_row_optional(&query, (self,), f).await
11591184
}
11601185

11611186
async fn get_parent_mime_headers(
11621187
self,
11631188
context: &Context,
1189+
state_out_min: MessageState,
11641190
) -> Result<Option<(String, String, String)>> {
11651191
self.parent_query(
11661192
context,
11671193
"rfc724_mid, mime_in_reply_to, mime_references",
1194+
state_out_min,
11681195
|row: &rusqlite::Row| {
11691196
let rfc724_mid: String = row.get(0)?;
11701197
let mime_in_reply_to: String = row.get(1)?;
@@ -1741,7 +1768,15 @@ impl Chat {
17411768
// we do not set In-Reply-To/References in this case.
17421769
if !self.is_self_talk() {
17431770
if let Some((parent_rfc724_mid, parent_in_reply_to, parent_references)) =
1744-
self.id.get_parent_mime_headers(context).await?
1771+
// We don't filter `OutPending` and `OutFailed` messages because the new message for
1772+
// which `parent_query()` is done may assume that it will be received in a context
1773+
// affected by those messages, e.g. they could add new members to a group and the
1774+
// new message will contain them in "To:". Anyway recipients must be prepared to
1775+
// orphaned references.
1776+
self
1777+
.id
1778+
.get_parent_mime_headers(context, MessageState::OutPending)
1779+
.await?
17451780
{
17461781
// "In-Reply-To:" is not changed if it is set manually.
17471782
// This does not affect "References:" header, it will contain "default parent" (the
@@ -1959,10 +1994,26 @@ impl Chat {
19591994
Ok(r)
19601995
}
19611996
Chattype::Broadcast | Chattype::Group | Chattype::Mailinglist => {
1962-
if self.grpid.is_empty() {
1963-
return Ok(None);
1997+
if !self.grpid.is_empty() {
1998+
return Ok(Some(SyncId::Grpid(self.grpid.clone())));
19641999
}
1965-
Ok(Some(SyncId::Grpid(self.grpid.clone())))
2000+
2001+
let Some((parent_rfc724_mid, parent_in_reply_to, _)) = self
2002+
.id
2003+
.get_parent_mime_headers(context, MessageState::OutDelivered)
2004+
.await?
2005+
else {
2006+
warn!(
2007+
context,
2008+
"Chat::get_sync_id({}): No good message identifying the chat found.",
2009+
self.id
2010+
);
2011+
return Ok(None);
2012+
};
2013+
Ok(Some(SyncId::Msgids(vec![
2014+
parent_in_reply_to,
2015+
parent_rfc724_mid,
2016+
])))
19662017
}
19672018
}
19682019
}
@@ -4242,8 +4293,8 @@ async fn set_contacts_by_addrs(context: &Context, id: ChatId, addrs: &[String])
42424293
pub(crate) enum SyncId {
42434294
ContactAddr(String),
42444295
Grpid(String),
4245-
// NOTE: Ad-hoc groups lack an identifier that can be used across devices so
4246-
// block/mute/etc. actions on them are not synchronized to other devices.
4296+
/// "Message-ID"-s, from oldest to latest. Used for ad-hoc groups.
4297+
Msgids(Vec<String>),
42474298
}
42484299

42494300
/// An action synchronised to other devices.
@@ -4266,12 +4317,9 @@ impl Context {
42664317
pub(crate) async fn sync_alter_chat(&self, id: &SyncId, action: &SyncAction) -> Result<()> {
42674318
let chat_id = match id {
42684319
SyncId::ContactAddr(addr) => {
4269-
let Some(contact_id) =
4270-
Contact::lookup_id_by_addr_ex(self, addr, Origin::Unknown, None).await?
4271-
else {
4272-
warn!(self, "sync_alter_chat: No contact for addr '{addr}'.");
4273-
return Ok(());
4274-
};
4320+
let contact_id = Contact::lookup_id_by_addr_ex(self, addr, Origin::Unknown, None)
4321+
.await?
4322+
.with_context(|| format!("No contact for addr '{addr}'"))?;
42754323
match action {
42764324
SyncAction::Block => {
42774325
return contact::set_blocked(self, Nosync, contact_id, true).await
@@ -4281,22 +4329,26 @@ impl Context {
42814329
}
42824330
_ => (),
42834331
}
4284-
let Some(chat_id) = ChatId::lookup_by_contact(self, contact_id).await? else {
4285-
warn!(self, "sync_alter_chat: No chat for addr '{addr}'.");
4286-
return Ok(());
4287-
};
4288-
chat_id
4332+
ChatId::lookup_by_contact(self, contact_id)
4333+
.await?
4334+
.with_context(|| format!("No chat for addr '{addr}'"))?
42894335
}
42904336
SyncId::Grpid(grpid) => {
42914337
if let SyncAction::CreateBroadcast(name) = action {
42924338
create_broadcast_list_ex(self, Nosync, grpid.clone(), name.clone()).await?;
42934339
return Ok(());
42944340
}
4295-
let Some((chat_id, ..)) = get_chat_id_by_grpid(self, grpid).await? else {
4296-
warn!(self, "sync_alter_chat: No chat for grpid '{grpid}'.");
4297-
return Ok(());
4298-
};
4299-
chat_id
4341+
get_chat_id_by_grpid(self, grpid)
4342+
.await?
4343+
.with_context(|| format!("No chat for grpid '{grpid}'"))?
4344+
.0
4345+
}
4346+
SyncId::Msgids(msgids) => {
4347+
let msg = message::get_latest_by_rfc724_mids(self, msgids)
4348+
.await?
4349+
.with_context(|| format!("No message found for Message-IDs {msgids:?}"))?;
4350+
ChatId::lookup_by_message(&msg)
4351+
.with_context(|| format!("No chat found for Message-IDs {msgids:?}"))?
43004352
}
43014353
};
43024354
match action {
@@ -6941,6 +6993,51 @@ mod tests {
69416993
Ok(())
69426994
}
69436995

6996+
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
6997+
async fn test_sync_adhoc_grp() -> Result<()> {
6998+
let alice0 = &TestContext::new_alice().await;
6999+
let alice1 = &TestContext::new_alice().await;
7000+
for a in [alice0, alice1] {
7001+
a.set_config_bool(Config::SyncMsgs, true).await?;
7002+
}
7003+
7004+
let mut chat_ids = Vec::new();
7005+
for a in [alice0, alice1] {
7006+
let msg = receive_imf(
7007+
a,
7008+
b"Subject: =?utf-8?q?Message_from_alice=40example=2Eorg?=\r\n\
7009+
From: alice@example.org\r\n\
7010+
To: <bob@example.net>, <fiona@example.org> \r\n\
7011+
Date: Mon, 2 Dec 2023 16:59:39 +0000\r\n\
7012+
Message-ID: <Mr.alices_original_mail@example.org>\r\n\
7013+
Chat-Version: 1.0\r\n\
7014+
\r\n\
7015+
hi\r\n",
7016+
false,
7017+
)
7018+
.await?
7019+
.unwrap();
7020+
chat_ids.push(msg.chat_id);
7021+
}
7022+
let chat1 = Chat::load_from_db(alice1, chat_ids[1]).await?;
7023+
assert_eq!(chat1.typ, Chattype::Group);
7024+
assert!(chat1.grpid.is_empty());
7025+
7026+
// Test synchronisation on chat blocking because it causes chat deletion currently and thus
7027+
// requires generating a sync message in advance.
7028+
chat_ids[0].block(alice0).await?;
7029+
sync(alice0, alice1).await;
7030+
assert!(Chat::load_from_db(alice1, chat_ids[1]).await.is_err());
7031+
assert!(
7032+
!alice1
7033+
.sql
7034+
.exists("SELECT COUNT(*) FROM chats WHERE id=?", (chat_ids[1],))
7035+
.await?
7036+
);
7037+
7038+
Ok(())
7039+
}
7040+
69447041
/// Tests syncing of chat visibility on a self-chat. This way we test:
69457042
/// - Self-chat synchronisation.
69467043
/// - That sync messages don't unarchive the self-chat.

src/message.rs

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1842,6 +1842,24 @@ pub(crate) async fn rfc724_mid_exists_and(
18421842
Ok(res)
18431843
}
18441844

1845+
/// Given a list of Message-IDs, returns the latest message found in the database.
1846+
///
1847+
/// Only messages that are not in the trash chat are considered.
1848+
pub(crate) async fn get_latest_by_rfc724_mids(
1849+
context: &Context,
1850+
mids: &[String],
1851+
) -> Result<Option<Message>> {
1852+
for id in mids.iter().rev() {
1853+
if let Some(msg_id) = rfc724_mid_exists(context, id).await? {
1854+
let msg = Message::load_from_db(context, msg_id).await?;
1855+
if msg.chat_id != DC_CHAT_ID_TRASH {
1856+
return Ok(Some(msg));
1857+
}
1858+
}
1859+
}
1860+
Ok(None)
1861+
}
1862+
18451863
/// How a message is primarily displayed.
18461864
#[derive(
18471865
Debug,

src/receive_imf.rs

Lines changed: 4 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1544,27 +1544,10 @@ async fn lookup_chat_by_reply(
15441544
let Some(parent) = parent else {
15451545
return Ok(None);
15461546
};
1547-
1548-
let parent_chat = Chat::load_from_db(context, parent.chat_id).await?;
1549-
1550-
if parent.download_state != DownloadState::Done
1551-
// TODO (2023-09-12): Added for backward compatibility with versions that did not have
1552-
// `DownloadState::Undecipherable`. Remove eventually with the comment in
1553-
// `MimeMessage::from_bytes()`.
1554-
|| parent
1555-
.error
1556-
.as_ref()
1557-
.filter(|e| e.starts_with("Decrypting failed:"))
1558-
.is_some()
1559-
{
1560-
// If the parent msg is not fully downloaded or undecipherable, it may have been
1561-
// assigned to the wrong chat (they often get assigned to the 1:1 chat with the sender).
1547+
let Some(parent_chat_id) = ChatId::lookup_by_message(parent) else {
15621548
return Ok(None);
1563-
}
1564-
1565-
if parent_chat.id == DC_CHAT_ID_TRASH {
1566-
return Ok(None);
1567-
}
1549+
};
1550+
let parent_chat = Chat::load_from_db(context, parent_chat_id).await?;
15681551

15691552
// If this was a private message just to self, it was probably a private reply.
15701553
// It should not go into the group then, but into the private chat.
@@ -2558,20 +2541,7 @@ async fn get_previous_message(
25582541
///
25592542
/// Only messages that are not in the trash chat are considered.
25602543
async fn get_rfc724_mid_in_list(context: &Context, mid_list: &str) -> Result<Option<Message>> {
2561-
if mid_list.is_empty() {
2562-
return Ok(None);
2563-
}
2564-
2565-
for id in parse_message_ids(mid_list).iter().rev() {
2566-
if let Some(msg_id) = rfc724_mid_exists(context, id).await? {
2567-
let msg = Message::load_from_db(context, msg_id).await?;
2568-
if msg.chat_id != DC_CHAT_ID_TRASH {
2569-
return Ok(Some(msg));
2570-
}
2571-
}
2572-
}
2573-
2574-
Ok(None)
2544+
message::get_latest_by_rfc724_mids(context, &parse_message_ids(mid_list)).await
25752545
}
25762546

25772547
/// Returns the last message referenced from References: header found in the database.

0 commit comments

Comments
 (0)