diff --git a/deltachat-ffi/deltachat.h b/deltachat-ffi/deltachat.h index 9d1d32fea..9e1cd449c 100644 --- a/deltachat-ffi/deltachat.h +++ b/deltachat-ffi/deltachat.h @@ -6213,7 +6213,24 @@ void dc_event_unref(dc_event_t* event); * This event is only emitted by the account manager */ -#define DC_EVENT_ACCOUNTS_BACKGROUND_FETCH_DONE 2200 +#define DC_EVENT_ACCOUNTS_BACKGROUND_FETCH_DONE 2200 + +/** + * Inform that set of chats or the order of the chats in the chatlist has changed. + * + * Sometimes this is emitted together with `DC_EVENT_CHATLIST_ITEM_CHANGED`. + */ + +#define DC_EVENT_CHATLIST_CHANGED 2300 + +/** + * Inform that all or a single chat list item changed and needs to be rerendered + * If `chat_id` is set to 0, then all currently visible chats need to be rerendered, and all not-visible items need to be cleared from cache if the UI has a cache. + * + * @param data1 (int) chat_id chat id of chatlist item to be rerendered, if chat_id = 0 all (cached & visible) items need to be rerendered + */ + +#define DC_EVENT_CHATLIST_ITEM_CHANGED 2301 /** * @} diff --git a/deltachat-ffi/src/lib.rs b/deltachat-ffi/src/lib.rs index 88dc3819d..4f6874897 100644 --- a/deltachat-ffi/src/lib.rs +++ b/deltachat-ffi/src/lib.rs @@ -564,6 +564,8 @@ pub unsafe extern "C" fn dc_event_get_id(event: *mut dc_event_t) -> libc::c_int EventType::WebxdcStatusUpdate { .. } => 2120, EventType::WebxdcInstanceDeleted { .. } => 2121, EventType::AccountsBackgroundFetchDone => 2200, + EventType::ChatlistChanged => 2300, + EventType::ChatlistItemChanged { .. } => 2301, } } @@ -593,6 +595,7 @@ pub unsafe extern "C" fn dc_event_get_data1_int(event: *mut dc_event_t) -> libc: | EventType::IncomingMsgBunch { .. } | EventType::ErrorSelfNotInGroup(_) | EventType::AccountsBackgroundFetchDone => 0, + EventType::ChatlistChanged => 0, EventType::MsgsChanged { chat_id, .. } | EventType::ReactionsChanged { chat_id, .. } | EventType::IncomingMsg { chat_id, .. } @@ -617,6 +620,9 @@ pub unsafe extern "C" fn dc_event_get_data1_int(event: *mut dc_event_t) -> libc: } EventType::WebxdcStatusUpdate { msg_id, .. } => msg_id.to_u32() as libc::c_int, EventType::WebxdcInstanceDeleted { msg_id, .. } => msg_id.to_u32() as libc::c_int, + EventType::ChatlistItemChanged { chat_id } => { + chat_id.unwrap_or_default().to_u32() as libc::c_int + } } } @@ -653,6 +659,8 @@ pub unsafe extern "C" fn dc_event_get_data2_int(event: *mut dc_event_t) -> libc: | EventType::IncomingMsgBunch { .. } | EventType::SelfavatarChanged | EventType::AccountsBackgroundFetchDone + | EventType::ChatlistChanged + | EventType::ChatlistItemChanged { .. } | EventType::ConfigSynced { .. } => 0, EventType::ChatModified(_) => 0, EventType::MsgsChanged { msg_id, .. } @@ -717,7 +725,9 @@ pub unsafe extern "C" fn dc_event_get_data2_str(event: *mut dc_event_t) -> *mut | EventType::WebxdcInstanceDeleted { .. } | EventType::AccountsBackgroundFetchDone | EventType::ChatEphemeralTimerModified { .. } - | EventType::IncomingMsgBunch { .. } => ptr::null_mut(), + | EventType::IncomingMsgBunch { .. } + | EventType::ChatlistItemChanged { .. } + | EventType::ChatlistChanged => ptr::null_mut(), EventType::ConfigureProgress { comment, .. } => { if let Some(comment) = comment { comment.to_c_string().unwrap_or_default().into_raw() diff --git a/deltachat-jsonrpc/src/api.rs b/deltachat-jsonrpc/src/api.rs index 74cf7631e..c0c4b783c 100644 --- a/deltachat-jsonrpc/src/api.rs +++ b/deltachat-jsonrpc/src/api.rs @@ -1626,6 +1626,9 @@ impl CommandApi { /// the current device. /// /// Can be cancelled by stopping the ongoing process. + /// + /// Do not forget to call start_io on the account after a successful import, + /// otherwise it will not connect to the email server. async fn get_backup(&self, account_id: u32, qr_text: String) -> Result<()> { let ctx = self.get_context(account_id).await?; let qr = qr::check_qr(&ctx, &qr_text).await?; diff --git a/deltachat-jsonrpc/src/api/types/events.rs b/deltachat-jsonrpc/src/api/types/events.rs index 7235ba252..7aa2e394e 100644 --- a/deltachat-jsonrpc/src/api/types/events.rs +++ b/deltachat-jsonrpc/src/api/types/events.rs @@ -250,6 +250,15 @@ pub enum EventType { /// /// This event is only emitted by the account manager AccountsBackgroundFetchDone, + /// Inform that set of chats or the order of the chats in the chatlist has changed. + /// + /// Sometimes this is emitted together with `UIChatlistItemChanged`. + ChatlistChanged, + + /// Inform that a single chat list item changed and needs to be rerendered. + /// If `chat_id` is set to None, then all currently visible chats need to be rerendered, and all not-visible items need to be cleared from cache if the UI has a cache. + #[serde(rename_all = "camelCase")] + ChatlistItemChanged { chat_id: Option }, } impl From for EventType { @@ -357,6 +366,10 @@ impl From for EventType { msg_id: msg_id.to_u32(), }, CoreEventType::AccountsBackgroundFetchDone => AccountsBackgroundFetchDone, + CoreEventType::ChatlistItemChanged { chat_id } => ChatlistItemChanged { + chat_id: chat_id.map(|id| id.to_u32()), + }, + CoreEventType::ChatlistChanged => ChatlistChanged, } } } diff --git a/deltachat-rpc-client/src/deltachat_rpc_client/account.py b/deltachat-rpc-client/src/deltachat_rpc_client/account.py index dbdb1dbfa..e22f4ff7f 100644 --- a/deltachat-rpc-client/src/deltachat_rpc_client/account.py +++ b/deltachat-rpc-client/src/deltachat_rpc_client/account.py @@ -30,6 +30,10 @@ class Account: """Wait until the next event and return it.""" return AttrDict(self._rpc.wait_for_event(self.id)) + def clear_all_events(self): + """Removes all queued-up events for a given account. Useful for tests.""" + self._rpc.clear_all_events(self.id) + def remove(self) -> None: """Remove the account.""" self._rpc.remove_account(self.id) diff --git a/deltachat-rpc-client/src/deltachat_rpc_client/const.py b/deltachat-rpc-client/src/deltachat_rpc_client/const.py index f54e1c5f4..2a27b0a9a 100644 --- a/deltachat-rpc-client/src/deltachat_rpc_client/const.py +++ b/deltachat-rpc-client/src/deltachat_rpc_client/const.py @@ -59,6 +59,8 @@ class EventType(str, Enum): SELFAVATAR_CHANGED = "SelfavatarChanged" WEBXDC_STATUS_UPDATE = "WebxdcStatusUpdate" WEBXDC_INSTANCE_DELETED = "WebxdcInstanceDeleted" + CHATLIST_CHANGED = "ChatlistChanged" + CHATLIST_ITEM_CHANGED = "ChatlistItemChanged" class ChatId(IntEnum): diff --git a/deltachat-rpc-client/src/deltachat_rpc_client/rpc.py b/deltachat-rpc-client/src/deltachat_rpc_client/rpc.py index da95d42b8..fc8dba557 100644 --- a/deltachat-rpc-client/src/deltachat_rpc_client/rpc.py +++ b/deltachat-rpc-client/src/deltachat_rpc_client/rpc.py @@ -6,7 +6,7 @@ import logging import os import subprocess import sys -from queue import Queue +from queue import Empty, Queue from threading import Event, Thread from typing import Any, Iterator, Optional @@ -188,5 +188,14 @@ class Rpc: queue = self.get_queue(account_id) return queue.get() + def clear_all_events(self, account_id: int): + """Removes all queued-up events for a given account. Useful for tests.""" + queue = self.get_queue(account_id) + try: + while True: + queue.get_nowait() + except Empty: + pass + def __getattr__(self, attr: str): return RpcMethod(self, attr) diff --git a/deltachat-rpc-client/tests/test_chatlist_events.py b/deltachat-rpc-client/tests/test_chatlist_events.py new file mode 100644 index 000000000..1a7ec6344 --- /dev/null +++ b/deltachat-rpc-client/tests/test_chatlist_events.py @@ -0,0 +1,218 @@ +from __future__ import annotations + +import base64 +import os +from typing import TYPE_CHECKING + +from deltachat_rpc_client import Account, EventType, const + +if TYPE_CHECKING: + from deltachat_rpc_client.pytestplugin import ACFactory + + +def wait_for_chatlist_and_specific_item(account, chat_id): + first_event = "" + while True: + event = account.wait_for_event() + if event.kind == EventType.CHATLIST_CHANGED: + first_event = "change" + break + if event.kind == EventType.CHATLIST_ITEM_CHANGED and event.chat_id == chat_id: + first_event = "item_change" + break + while True: + event = account.wait_for_event() + if event.kind == EventType.CHATLIST_CHANGED and first_event == "item_change": + break + if event.kind == EventType.CHATLIST_ITEM_CHANGED and event.chat_id == chat_id and first_event == "change": + break + + +def wait_for_chatlist_specific_item(account, chat_id): + while True: + event = account.wait_for_event() + if event.kind == EventType.CHATLIST_ITEM_CHANGED and event.chat_id == chat_id: + break + + +def wait_for_chatlist(account): + while True: + event = account.wait_for_event() + if event.kind == EventType.CHATLIST_CHANGED: + break + + +def test_delivery_status(acfactory: ACFactory) -> None: + """ + Test change status on chatlistitem when status changes (delivered, read) + """ + alice, bob = acfactory.get_online_accounts(2) + + bob_addr = bob.get_config("addr") + alice_contact_bob = alice.create_contact(bob_addr, "Bob") + alice_chat_bob = alice_contact_bob.create_chat() + + alice.clear_all_events() + bob.stop_io() + alice.stop_io() + alice_chat_bob.send_text("hi") + wait_for_chatlist_and_specific_item(alice, chat_id=alice_chat_bob.id) + + alice.clear_all_events() + alice.start_io() + wait_for_chatlist_specific_item(alice, chat_id=alice_chat_bob.id) + + bob.clear_all_events() + bob.start_io() + + event = bob.wait_for_incoming_msg_event() + msg = bob.get_message_by_id(event.msg_id) + msg.get_snapshot().chat.accept() + msg.mark_seen() + + chat_item = alice._rpc.get_chatlist_items_by_entries(alice.id, [alice_chat_bob.id])[str(alice_chat_bob.id)] + assert chat_item["summaryStatus"] == const.MessageState.OUT_DELIVERED + + alice.clear_all_events() + + while True: + event = alice.wait_for_event() + if event.kind == EventType.MSG_READ: + break + + wait_for_chatlist_specific_item(alice, chat_id=alice_chat_bob.id) + chat_item = alice._rpc.get_chatlist_items_by_entries(alice.id, [alice_chat_bob.id])[str(alice_chat_bob.id)] + assert chat_item["summaryStatus"] == const.MessageState.OUT_MDN_RCVD + + +def test_delivery_status_failed(acfactory: ACFactory) -> None: + """ + Test change status on chatlistitem when status changes failed + """ + (alice,) = acfactory.get_online_accounts(1) + + invalid_contact = alice.create_contact("example@example.com", "invalid address") + invalid_chat = alice.get_chat_by_id(alice._rpc.create_chat_by_contact_id(alice.id, invalid_contact.id)) + + alice.clear_all_events() + + failing_message = invalid_chat.send_text("test") + + wait_for_chatlist_and_specific_item(alice, invalid_chat.id) + + assert failing_message.get_snapshot().state == const.MessageState.OUT_PENDING + + while True: + event = alice.wait_for_event() + if event.kind == EventType.MSG_FAILED: + break + + wait_for_chatlist_specific_item(alice, invalid_chat.id) + + assert failing_message.get_snapshot().state == const.MessageState.OUT_FAILED + + +def test_download_on_demand(acfactory: ACFactory) -> None: + """ + Test if download on demand emits chatlist update events. + This is only needed for last message in chat, but finding that out is too expensive, so it's always emitted + """ + alice, bob = acfactory.get_online_accounts(2) + + bob_addr = bob.get_config("addr") + alice_contact_bob = alice.create_contact(bob_addr, "Bob") + alice_chat_bob = alice_contact_bob.create_chat() + alice_chat_bob.send_text("hi") + + alice.set_config("download_limit", "1") + + event = bob.wait_for_incoming_msg_event() + msg = bob.get_message_by_id(event.msg_id) + chat_id = msg.get_snapshot().chat_id + msg.get_snapshot().chat.accept() + bob.get_chat_by_id(chat_id).send_message( + "Hello World, this message is bigger than 5 bytes", + html=base64.b64encode(os.urandom(300000)).decode("utf-8"), + ) + + msg_id = alice.wait_for_incoming_msg_event().msg_id + + assert alice.get_message_by_id(msg_id).get_snapshot().download_state == const.DownloadState.AVAILABLE + + alice.clear_all_events() + chat_id = alice.get_message_by_id(msg_id).get_snapshot().chat_id + alice._rpc.download_full_message(alice.id, msg_id) + + wait_for_chatlist_specific_item(alice, chat_id) + + +def get_multi_account_test_setup(acfactory: ACFactory) -> [Account, Account, Account]: + alice, bob = acfactory.get_online_accounts(2) + + bob_addr = bob.get_config("addr") + alice_contact_bob = alice.create_contact(bob_addr, "Bob") + alice_chat_bob = alice_contact_bob.create_chat() + alice_chat_bob.send_text("hi") + + bob.wait_for_incoming_msg_event() + + alice_second_device: Account = acfactory.get_unconfigured_account() + + alice._rpc.provide_backup.future(alice.id) + backup_code = alice._rpc.get_backup_qr(alice.id) + alice_second_device._rpc.get_backup(alice_second_device.id, backup_code) + alice_second_device.start_io() + alice.clear_all_events() + alice_second_device.clear_all_events() + bob.clear_all_events() + return [alice, alice_second_device, bob, alice_chat_bob] + + +def test_imap_sync_seen_msgs(acfactory: ACFactory) -> None: + """ + Test that chatlist changed events are emitted for the second device + when the message is marked as read on the first device + """ + alice, alice_second_device, bob, alice_chat_bob = get_multi_account_test_setup(acfactory) + + alice_chat_bob.send_text("hello") + + event = bob.wait_for_incoming_msg_event() + msg = bob.get_message_by_id(event.msg_id) + bob_chat_id = msg.get_snapshot().chat_id + msg.get_snapshot().chat.accept() + + alice.clear_all_events() + alice_second_device.clear_all_events() + bob.get_chat_by_id(bob_chat_id).send_text("hello") + + # make sure alice_second_device already received the message + alice_second_device.wait_for_incoming_msg_event() + + event = alice.wait_for_incoming_msg_event() + msg = alice.get_message_by_id(event.msg_id) + alice_second_device.clear_all_events() + msg.mark_seen() + + wait_for_chatlist_specific_item(bob, bob_chat_id) + wait_for_chatlist_specific_item(alice, alice_chat_bob.id) + + +def test_multidevice_sync_chat(acfactory: ACFactory) -> None: + """ + Test multidevice sync: syncing chat visibility and muting across multiple devices + """ + alice, alice_second_device, bob, alice_chat_bob = get_multi_account_test_setup(acfactory) + + alice_chat_bob.archive() + wait_for_chatlist_specific_item(alice_second_device, alice_chat_bob.id) + assert alice_second_device.get_chat_by_id(alice_chat_bob.id).get_basic_snapshot().archived + + alice_second_device.clear_all_events() + alice_chat_bob.pin() + wait_for_chatlist_specific_item(alice_second_device, alice_chat_bob.id) + + alice_second_device.clear_all_events() + alice_chat_bob.mute() + wait_for_chatlist_specific_item(alice_second_device, alice_chat_bob.id) + assert alice_second_device.get_chat_by_id(alice_chat_bob.id).get_basic_snapshot().is_muted diff --git a/node/constants.js b/node/constants.js index dda93f6b8..32160355a 100644 --- a/node/constants.js +++ b/node/constants.js @@ -30,6 +30,8 @@ module.exports = { DC_DOWNLOAD_IN_PROGRESS: 1000, DC_DOWNLOAD_UNDECIPHERABLE: 30, DC_EVENT_ACCOUNTS_BACKGROUND_FETCH_DONE: 2200, + DC_EVENT_CHATLIST_CHANGED: 2300, + DC_EVENT_CHATLIST_ITEM_CHANGED: 2301, DC_EVENT_CHAT_EPHEMERAL_TIMER_MODIFIED: 2021, DC_EVENT_CHAT_MODIFIED: 2020, DC_EVENT_CONFIGURE_PROGRESS: 2041, diff --git a/node/events.js b/node/events.js index a5c9281b9..15596a307 100644 --- a/node/events.js +++ b/node/events.js @@ -37,5 +37,7 @@ module.exports = { 2111: 'DC_EVENT_CONFIG_SYNCED', 2120: 'DC_EVENT_WEBXDC_STATUS_UPDATE', 2121: 'DC_EVENT_WEBXDC_INSTANCE_DELETED', - 2200: 'DC_EVENT_ACCOUNTS_BACKGROUND_FETCH_DONE' + 2200: 'DC_EVENT_ACCOUNTS_BACKGROUND_FETCH_DONE', + 2300: 'DC_EVENT_CHATLIST_CHANGED', + 2301: 'DC_EVENT_CHATLIST_ITEM_CHANGED' } diff --git a/node/lib/constants.ts b/node/lib/constants.ts index db0cbfdc3..fa78b634a 100644 --- a/node/lib/constants.ts +++ b/node/lib/constants.ts @@ -30,6 +30,8 @@ export enum C { DC_DOWNLOAD_IN_PROGRESS = 1000, DC_DOWNLOAD_UNDECIPHERABLE = 30, DC_EVENT_ACCOUNTS_BACKGROUND_FETCH_DONE = 2200, + DC_EVENT_CHATLIST_CHANGED = 2300, + DC_EVENT_CHATLIST_ITEM_CHANGED = 2301, DC_EVENT_CHAT_EPHEMERAL_TIMER_MODIFIED = 2021, DC_EVENT_CHAT_MODIFIED = 2020, DC_EVENT_CONFIGURE_PROGRESS = 2041, @@ -333,4 +335,6 @@ export const EventId2EventName: { [key: number]: string } = { 2120: 'DC_EVENT_WEBXDC_STATUS_UPDATE', 2121: 'DC_EVENT_WEBXDC_INSTANCE_DELETED', 2200: 'DC_EVENT_ACCOUNTS_BACKGROUND_FETCH_DONE', + 2300: 'DC_EVENT_CHATLIST_CHANGED', + 2301: 'DC_EVENT_CHATLIST_ITEM_CHANGED', } diff --git a/src/chat.rs b/src/chat.rs index b3616753d..c7c353553 100644 --- a/src/chat.rs +++ b/src/chat.rs @@ -15,6 +15,7 @@ use strum_macros::EnumIter; use crate::aheader::EncryptPreference; use crate::blob::BlobObject; use crate::chatlist::Chatlist; +use crate::chatlist_events; use crate::color::str_to_color; use crate::config::Config; use crate::constants::{ @@ -295,6 +296,8 @@ impl ChatId { } }; context.emit_msgs_changed_without_ids(); + chatlist_events::emit_chatlist_changed(context); + chatlist_events::emit_chatlist_item_changed(context, chat_id); Ok(chat_id) } @@ -411,6 +414,7 @@ impl ChatId { } } } + chatlist_events::emit_chatlist_changed(context); if sync.into() { // NB: For a 1:1 chat this currently triggers `Contact::block()` on other devices. @@ -433,6 +437,8 @@ impl ChatId { pub(crate) async fn unblock_ex(self, context: &Context, sync: sync::Sync) -> Result<()> { self.set_blocked(context, Blocked::Not).await?; + chatlist_events::emit_chatlist_changed(context); + if sync.into() { let chat = Chat::load_from_db(context, self).await?; // TODO: For a 1:1 chat this currently triggers `Contact::unblock()` on other devices. @@ -443,6 +449,7 @@ impl ChatId { .log_err(context) .ok(); } + Ok(()) } @@ -486,6 +493,7 @@ impl ChatId { if self.set_blocked(context, Blocked::Not).await? { context.emit_event(EventType::ChatModified(self)); + chatlist_events::emit_chatlist_item_changed(context, self); } if sync.into() { @@ -528,6 +536,7 @@ impl ChatId { .await?; context.emit_event(EventType::ChatModified(self)); + chatlist_events::emit_chatlist_item_changed(context, self); // make sure, the receivers will get all keys self.reset_gossiped_timestamp(context).await?; @@ -576,6 +585,7 @@ impl ChatId { if protection_status_modified { self.add_protection_msg(context, protect, contact_id, timestamp_sort) .await?; + chatlist_events::emit_chatlist_item_changed(context, self); } Ok(()) } @@ -662,6 +672,8 @@ impl ChatId { .await?; context.emit_msgs_changed_without_ids(); + chatlist_events::emit_chatlist_changed(context); + chatlist_events::emit_chatlist_item_changed(context, self); if sync.into() { let chat = Chat::load_from_db(context, self).await?; @@ -768,6 +780,7 @@ impl ChatId { .await?; context.emit_msgs_changed_without_ids(); + chatlist_events::emit_chatlist_changed(context); context .set_config_internal(Config::LastHousekeeping, None) @@ -779,6 +792,7 @@ impl ChatId { msg.text = stock_str::self_deleted_msg_body(context).await; add_device_msg(context, None, Some(&mut msg)).await?; } + chatlist_events::emit_chatlist_changed(context); Ok(()) } @@ -3103,7 +3117,9 @@ pub async fn marknoticed_chat(context: &Context, chat_id: ChatId) -> Result<()> .await?; for chat_id_in_archive in chat_ids_in_archive { context.emit_event(EventType::MsgsNoticed(chat_id_in_archive)); + chatlist_events::emit_chatlist_item_changed(context, chat_id_in_archive); } + chatlist_events::emit_chatlist_item_changed(context, DC_CHAT_ID_ARCHIVED_LINK); } else { let exists = context .sql @@ -3130,6 +3146,7 @@ pub async fn marknoticed_chat(context: &Context, chat_id: ChatId) -> Result<()> } context.emit_event(EventType::MsgsNoticed(chat_id)); + chatlist_events::emit_chatlist_item_changed(context, chat_id); Ok(()) } @@ -3197,6 +3214,7 @@ pub(crate) async fn mark_old_messages_as_noticed( for c in changed_chats { context.emit_event(EventType::MsgsNoticed(c)); + chatlist_events::emit_chatlist_item_changed(context, c); } Ok(()) @@ -3359,6 +3377,8 @@ pub async fn create_group_chat( } context.emit_msgs_changed_without_ids(); + chatlist_events::emit_chatlist_changed(context); + chatlist_events::emit_chatlist_item_changed(context, chat_id); if protect == ProtectionStatus::Protected { chat_id @@ -3446,11 +3466,14 @@ pub(crate) async fn create_broadcast_list_ex( let chat_id = ChatId::new(u32::try_from(row_id)?); context.emit_msgs_changed_without_ids(); + chatlist_events::emit_chatlist_changed(context); + if sync.into() { let id = SyncId::Grpid(grpid); let action = SyncAction::CreateBroadcast(chat_name); self::sync(context, id, action).await.log_err(context).ok(); } + Ok(chat_id) } @@ -3721,6 +3744,7 @@ pub(crate) async fn set_muted_ex( .await .context(format!("Failed to set mute duration for {chat_id}"))?; context.emit_event(EventType::ChatModified(chat_id)); + chatlist_events::emit_chatlist_item_changed(context, chat_id); if sync.into() { let chat = Chat::load_from_db(context, chat_id).await?; chat.sync(context, SyncAction::SetMuted(duration)) @@ -3881,6 +3905,7 @@ async fn rename_ex( sync = Nosync; } context.emit_event(EventType::ChatModified(chat_id)); + chatlist_events::emit_chatlist_item_changed(context, chat_id); success = true; } } @@ -3941,6 +3966,7 @@ pub async fn set_chat_profile_image( context.emit_msgs_changed(chat_id, msg.id); } context.emit_event(EventType::ChatModified(chat_id)); + chatlist_events::emit_chatlist_item_changed(context, chat_id); Ok(()) } @@ -4087,6 +4113,8 @@ pub async fn resend_msgs(context: &Context, msg_ids: &[MsgId]) -> Result<()> { msg_id: msg.id, }); msg.timestamp_sort = create_smeared_timestamp(context); + // note(treefit): only matters if it is the last message in chat (but probably to expensive to check, debounce also solves it) + chatlist_events::emit_chatlist_item_changed(context, msg.chat_id); if !create_send_msg_jobs(context, &mut msg).await?.is_empty() { context.scheduler.interrupt_smtp().await; } diff --git a/src/contact.rs b/src/contact.rs index 2abcfab7d..34fcdd538 100644 --- a/src/contact.rs +++ b/src/contact.rs @@ -37,7 +37,7 @@ use crate::tools::{ duration_to_str, get_abs_path, improve_single_line_input, strip_rtlo_characters, time, EmailAddress, SystemTime, }; -use crate::{chat, stock_str}; +use crate::{chat, chatlist_events, stock_str}; /// Time during which a contact is considered as seen recently. const SEEN_RECENTLY_SECONDS: i64 = 600; @@ -760,6 +760,7 @@ impl Contact { if count > 0 { // Chat name updated context.emit_event(EventType::ChatModified(chat_id)); + chatlist_events::emit_chatlist_items_changed_for_contact(context, contact_id); } } } @@ -796,7 +797,9 @@ impl Contact { Ok(row_id) }).await?; - Ok((ContactId::new(row_id), sth_modified)) + let contact_id = ContactId::new(row_id); + + Ok((contact_id, sth_modified)) } /// Add a number of contacts. @@ -1524,6 +1527,7 @@ WHERE type=? AND id IN ( } } + chatlist_events::emit_chatlist_changed(context); Ok(()) } @@ -1574,6 +1578,7 @@ pub(crate) async fn set_profile_image( if changed { contact.update_param(context).await?; context.emit_event(EventType::ContactsChanged(Some(contact_id))); + chatlist_events::emit_chatlist_item_changed_for_contact_chat(context, contact_id).await; } Ok(()) } @@ -1786,6 +1791,11 @@ impl RecentlySeenLoop { // Timeout, notify about contact. if let Some(contact_id) = contact_id { context.emit_event(EventType::ContactsChanged(Some(*contact_id))); + chatlist_events::emit_chatlist_item_changed_for_contact_chat( + &context, + *contact_id, + ) + .await; unseen_queue.pop(); } } @@ -1818,6 +1828,11 @@ impl RecentlySeenLoop { // Event is already in the past. if let Some(contact_id) = contact_id { context.emit_event(EventType::ContactsChanged(Some(*contact_id))); + chatlist_events::emit_chatlist_item_changed_for_contact_chat( + &context, + *contact_id, + ) + .await; } unseen_queue.pop(); } diff --git a/src/context.rs b/src/context.rs index aa193e0bf..4682abebf 100644 --- a/src/context.rs +++ b/src/context.rs @@ -16,6 +16,7 @@ use tokio::sync::{Mutex, Notify, RwLock}; use crate::aheader::EncryptPreference; use crate::chat::{get_chat_cnt, ChatId, ProtectionStatus}; +use crate::chatlist_events; use crate::config::Config; use crate::constants::{ self, DC_BACKGROUND_FETCH_QUOTA_CHECK_RATELIMIT, DC_CHAT_ID_TRASH, DC_VERSION_STR, @@ -593,11 +594,15 @@ impl Context { /// Emits a MsgsChanged event with specified chat and message ids pub fn emit_msgs_changed(&self, chat_id: ChatId, msg_id: MsgId) { self.emit_event(EventType::MsgsChanged { chat_id, msg_id }); + chatlist_events::emit_chatlist_changed(self); + chatlist_events::emit_chatlist_item_changed(self, chat_id); } /// Emits an IncomingMsg event with specified chat and message ids pub fn emit_incoming_msg(&self, chat_id: ChatId, msg_id: MsgId) { self.emit_event(EventType::IncomingMsg { chat_id, msg_id }); + chatlist_events::emit_chatlist_changed(self); + chatlist_events::emit_chatlist_item_changed(self, chat_id); } /// Returns a receiver for emitted events. diff --git a/src/download.rs b/src/download.rs index d8df94d38..8e5b3f803 100644 --- a/src/download.rs +++ b/src/download.rs @@ -13,7 +13,7 @@ use crate::imap::session::Session; use crate::message::{Message, MsgId, Viewtype}; use crate::mimeparser::{MimeMessage, Part}; use crate::tools::time; -use crate::{stock_str, EventType}; +use crate::{chatlist_events, stock_str, EventType}; /// Download limits should not be used below `MIN_DOWNLOAD_LIMIT`. /// @@ -115,6 +115,7 @@ impl MsgId { chat_id: msg.chat_id, msg_id: self, }); + chatlist_events::emit_chatlist_item_changed(context, msg.chat_id); Ok(()) } } diff --git a/src/events.rs b/src/events.rs index e1a9e8957..62b369d31 100644 --- a/src/events.rs +++ b/src/events.rs @@ -3,6 +3,7 @@ use async_channel::{self as channel, Receiver, Sender, TrySendError}; use pin_project::pin_project; +pub(crate) mod chatlist_events; mod payload; pub use self::payload::EventType; diff --git a/src/events/chatlist_events.rs b/src/events/chatlist_events.rs new file mode 100644 index 000000000..f6f3a8f3c --- /dev/null +++ b/src/events/chatlist_events.rs @@ -0,0 +1,635 @@ +use crate::{chat::ChatId, contact::ContactId, context::Context, EventType}; + +/// order or content of chatlist changes (chat ids, not the actual chatlist item) +pub(crate) fn emit_chatlist_changed(context: &Context) { + context.emit_event(EventType::ChatlistChanged); +} + +/// Chatlist item of a specific chat changed +pub(crate) fn emit_chatlist_item_changed(context: &Context, chat_id: ChatId) { + context.emit_event(EventType::ChatlistItemChanged { + chat_id: Some(chat_id), + }); +} + +/// Used when you don't know which chatlist items changed, this reloads all cached chatlist items in the UI +/// +/// Avoid calling this when you can find out the affected chat ids easialy (without extra expensive db queries). +/// +/// This method is not public, so you have to define and document your new case here in this file. +fn emit_unknown_chatlist_items_changed(context: &Context) { + context.emit_event(EventType::ChatlistItemChanged { chat_id: None }); +} + +/// update event for the 1:1 chat with the contact +/// used when recently seen changes and when profile image changes +pub(crate) async fn emit_chatlist_item_changed_for_contact_chat( + context: &Context, + contact_id: ContactId, +) { + match ChatId::lookup_by_contact(context, contact_id).await { + Ok(Some(chat_id)) => self::emit_chatlist_item_changed(context, chat_id), + Ok(None) => {} + Err(error) => context.emit_event(EventType::Error(format!( + "failed to find chat id for contact for chatlist event: {error:?}" + ))), + } +} + +/// update items for chats that have the contact +/// used when contact changes their name or did AEAP for example +/// +/// The most common case is that the contact changed their name +/// and their name should be updated in the chatlistitems for the chats +/// where they sent the last message as there their name is shown in the summary on those +pub(crate) fn emit_chatlist_items_changed_for_contact(context: &Context, _contact_id: ContactId) { + // note:(treefit): it is too expensive to find the right chats + // so we'll just tell ui to reload every loaded item + emit_unknown_chatlist_items_changed(context) + // note:(treefit): in the future we could instead emit an extra event for this and also store contact id in the chatlistitems + // (contact id for dm chats and contact id of contact that wrote the message in the summary) + // the ui could then look for this info in the cache and only reload the needed chats. +} + +/// Tests for chatlist events +/// +/// Only checks if the events are emitted, +/// does not check for excess/too-many events +#[cfg(test)] +mod test_chatlist_events { + + use std::{ + sync::atomic::{AtomicBool, Ordering}, + time::Duration, + }; + + use crate::{ + chat::{ + self, create_broadcast_list, create_group_chat, set_muted, ChatId, ChatVisibility, + MuteDuration, ProtectionStatus, + }, + config::Config, + constants::*, + contact::Contact, + message::{self, Message, MessageState}, + reaction, + receive_imf::receive_imf, + securejoin::{get_securejoin_qr, join_securejoin}, + test_utils::{TestContext, TestContextManager}, + EventType, + }; + + use anyhow::Result; + + async fn wait_for_chatlist_and_specific_item(context: &TestContext, chat_id: ChatId) { + let first_event_is_item = AtomicBool::new(false); + context + .evtracker + .get_matching(|evt| match evt { + EventType::ChatlistItemChanged { + chat_id: Some(ev_chat_id), + } => { + if ev_chat_id == &chat_id { + first_event_is_item.store(true, Ordering::Relaxed); + true + } else { + false + } + } + EventType::ChatlistChanged => true, + _ => false, + }) + .await; + if first_event_is_item.load(Ordering::Relaxed) { + wait_for_chatlist(context).await; + } else { + wait_for_chatlist_specific_item(context, chat_id).await; + } + } + + async fn wait_for_chatlist_specific_item(context: &TestContext, chat_id: ChatId) { + context + .evtracker + .get_matching(|evt| match evt { + EventType::ChatlistItemChanged { + chat_id: Some(ev_chat_id), + } => ev_chat_id == &chat_id, + _ => false, + }) + .await; + } + + async fn wait_for_chatlist_all_items(context: &TestContext) { + context + .evtracker + .get_matching(|evt| matches!(evt, EventType::ChatlistItemChanged { chat_id: None })) + .await; + } + + async fn wait_for_chatlist(context: &TestContext) { + context + .evtracker + .get_matching(|evt| matches!(evt, EventType::ChatlistChanged)) + .await; + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn test_change_chat_visibility() -> Result<()> { + let mut tcm = TestContextManager::new(); + let alice = tcm.alice().await; + let chat_id = create_group_chat( + &alice, + crate::chat::ProtectionStatus::Unprotected, + "my_group", + ) + .await?; + + chat_id + .set_visibility(&alice, ChatVisibility::Pinned) + .await?; + wait_for_chatlist_and_specific_item(&alice, chat_id).await; + + chat_id + .set_visibility(&alice, ChatVisibility::Archived) + .await?; + wait_for_chatlist_and_specific_item(&alice, chat_id).await; + + chat_id + .set_visibility(&alice, ChatVisibility::Normal) + .await?; + wait_for_chatlist_and_specific_item(&alice, chat_id).await; + + Ok(()) + } + + /// mute a chat, archive it, then use another account to send a message to it, the counter on the archived chatlist item should change + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn test_archived_counter_increases_for_muted_chats() -> Result<()> { + let mut tcm = TestContextManager::new(); + let alice = tcm.alice().await; + let bob = tcm.bob().await; + + let chat = alice.create_chat(&bob).await; + let sent_msg = alice.send_text(chat.id, "moin").await; + bob.recv_msg(&sent_msg).await; + + let bob_chat = bob.create_chat(&alice).await; + bob_chat + .id + .set_visibility(&bob, ChatVisibility::Archived) + .await?; + set_muted(&bob, bob_chat.id, MuteDuration::Forever).await?; + + bob.evtracker.clear_events(); + + let sent_msg = alice.send_text(chat.id, "moin2").await; + bob.recv_msg(&sent_msg).await; + + bob.evtracker + .get_matching(|evt| match evt { + EventType::ChatlistItemChanged { + chat_id: Some(chat_id), + } => chat_id.is_archived_link(), + _ => false, + }) + .await; + + Ok(()) + } + + /// Mark noticed on archive-link chatlistitem should update the unread counter on it + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn test_archived_counter_update_on_mark_noticed() -> Result<()> { + let mut tcm = TestContextManager::new(); + let alice = tcm.alice().await; + let bob = tcm.bob().await; + let chat = alice.create_chat(&bob).await; + let sent_msg = alice.send_text(chat.id, "moin").await; + bob.recv_msg(&sent_msg).await; + let bob_chat = bob.create_chat(&alice).await; + bob_chat + .id + .set_visibility(&bob, ChatVisibility::Archived) + .await?; + set_muted(&bob, bob_chat.id, MuteDuration::Forever).await?; + let sent_msg = alice.send_text(chat.id, "moin2").await; + bob.recv_msg(&sent_msg).await; + + bob.evtracker.clear_events(); + chat::marknoticed_chat(&bob, DC_CHAT_ID_ARCHIVED_LINK).await?; + wait_for_chatlist_specific_item(&bob, DC_CHAT_ID_ARCHIVED_LINK).await; + + Ok(()) + } + + /// Contact name update - expect all chats to update + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn test_contact_name_update() -> Result<()> { + let mut tcm = TestContextManager::new(); + let alice = tcm.alice().await; + let bob = tcm.bob().await; + let alice_to_bob_chat = alice.create_chat(&bob).await; + let sent_msg = alice.send_text(alice_to_bob_chat.id, "hello").await; + bob.recv_msg(&sent_msg).await; + + bob.evtracker.clear_events(); + // set alice name then receive messagefrom her with bob + alice.set_config(Config::Displayname, Some("Alice")).await?; + let sent_msg = alice + .send_text(alice_to_bob_chat.id, "hello, I set a displayname") + .await; + bob.recv_msg(&sent_msg).await; + let alice_on_bob = bob.add_or_lookup_contact(&alice).await; + assert!(alice_on_bob.get_display_name() == "Alice"); + + wait_for_chatlist_all_items(&bob).await; + + bob.evtracker.clear_events(); + // set name + let addr = alice_on_bob.get_addr(); + Contact::create(&bob, "Alice2", addr).await?; + assert!(bob.add_or_lookup_contact(&alice).await.get_display_name() == "Alice2"); + + wait_for_chatlist_all_items(&bob).await; + + Ok(()) + } + + /// Contact changed avatar + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn test_contact_changed_avatar() -> Result<()> { + let mut tcm = TestContextManager::new(); + let alice = tcm.alice().await; + let bob = tcm.bob().await; + let alice_to_bob_chat = alice.create_chat(&bob).await; + let sent_msg = alice.send_text(alice_to_bob_chat.id, "hello").await; + bob.recv_msg(&sent_msg).await; + + bob.evtracker.clear_events(); + // set alice avatar then receive messagefrom her with bob + let file = alice.dir.path().join("avatar.png"); + let bytes = include_bytes!("../../test-data/image/avatar64x64.png"); + tokio::fs::write(&file, bytes).await?; + alice + .set_config(Config::Selfavatar, Some(file.to_str().unwrap())) + .await?; + let sent_msg = alice + .send_text(alice_to_bob_chat.id, "hello, I have a new avatar") + .await; + bob.recv_msg(&sent_msg).await; + let alice_on_bob = bob.add_or_lookup_contact(&alice).await; + assert!(alice_on_bob.get_profile_image(&bob).await?.is_some()); + + wait_for_chatlist_specific_item(&bob, bob.create_chat(&alice).await.id).await; + Ok(()) + } + + /// Delete chat + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn test_delete_chat() -> Result<()> { + let mut tcm = TestContextManager::new(); + let alice = tcm.alice().await; + let chat = create_group_chat(&alice, ProtectionStatus::Protected, "My Group").await?; + + alice.evtracker.clear_events(); + chat.delete(&alice).await?; + wait_for_chatlist(&alice).await; + Ok(()) + } + + /// Create group chat + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn test_create_group_chat() -> Result<()> { + let mut tcm = TestContextManager::new(); + let alice = tcm.alice().await; + alice.evtracker.clear_events(); + let chat = create_group_chat(&alice, ProtectionStatus::Protected, "My Group").await?; + wait_for_chatlist_and_specific_item(&alice, chat).await; + Ok(()) + } + + /// Create broadcastlist + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn test_create_broadcastlist() -> Result<()> { + let mut tcm = TestContextManager::new(); + let alice = tcm.alice().await; + alice.evtracker.clear_events(); + create_broadcast_list(&alice).await?; + wait_for_chatlist(&alice).await; + Ok(()) + } + + /// Mute chat + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn test_mute_chat() -> Result<()> { + let mut tcm = TestContextManager::new(); + let alice = tcm.alice().await; + let chat = create_group_chat(&alice, ProtectionStatus::Protected, "My Group").await?; + + alice.evtracker.clear_events(); + chat::set_muted(&alice, chat, MuteDuration::Forever).await?; + wait_for_chatlist_specific_item(&alice, chat).await; + + alice.evtracker.clear_events(); + chat::set_muted(&alice, chat, MuteDuration::NotMuted).await?; + wait_for_chatlist_specific_item(&alice, chat).await; + + Ok(()) + } + + /// Expiry of mute should also trigger an event + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + #[ignore = "does not work yet"] + async fn test_mute_chat_expired() -> Result<()> { + let mut tcm = TestContextManager::new(); + let alice = tcm.alice().await; + let chat = create_group_chat(&alice, ProtectionStatus::Protected, "My Group").await?; + + let mute_duration = MuteDuration::Until( + std::time::SystemTime::now() + .checked_add(Duration::from_secs(2)) + .unwrap(), + ); + chat::set_muted(&alice, chat, mute_duration).await?; + alice.evtracker.clear_events(); + tokio::time::sleep(Duration::from_secs(3)).await; + wait_for_chatlist_specific_item(&alice, chat).await; + + Ok(()) + } + + /// Change chat name + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn test_change_chat_name() -> Result<()> { + let mut tcm = TestContextManager::new(); + let alice = tcm.alice().await; + let chat = create_group_chat(&alice, ProtectionStatus::Protected, "My Group").await?; + + alice.evtracker.clear_events(); + chat::set_chat_name(&alice, chat, "New Name").await?; + wait_for_chatlist_specific_item(&alice, chat).await; + + Ok(()) + } + + /// Change chat profile image + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn test_change_chat_profile_image() -> Result<()> { + let mut tcm = TestContextManager::new(); + let alice = tcm.alice().await; + let chat = create_group_chat(&alice, ProtectionStatus::Protected, "My Group").await?; + + alice.evtracker.clear_events(); + let file = alice.dir.path().join("avatar.png"); + let bytes = include_bytes!("../../test-data/image/avatar64x64.png"); + tokio::fs::write(&file, bytes).await?; + chat::set_chat_profile_image(&alice, chat, file.to_str().unwrap()).await?; + wait_for_chatlist_specific_item(&alice, chat).await; + + Ok(()) + } + + /// Receive group and receive name change + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn test_receiving_group_and_group_changes() -> Result<()> { + let mut tcm = TestContextManager::new(); + let alice = tcm.alice().await; + let bob = tcm.bob().await; + let chat = alice + .create_group_with_members(ProtectionStatus::Unprotected, "My Group", &[&bob]) + .await; + + let sent_msg = alice.send_text(chat, "Hello").await; + let chat_id_for_bob = bob.recv_msg(&sent_msg).await.chat_id; + wait_for_chatlist_specific_item(&bob, chat_id_for_bob).await; + chat_id_for_bob.accept(&bob).await?; + + bob.evtracker.clear_events(); + chat::set_chat_name(&alice, chat, "New Name").await?; + let sent_msg = alice.send_text(chat, "Hello").await; + bob.recv_msg(&sent_msg).await; + wait_for_chatlist_specific_item(&bob, chat_id_for_bob).await; + + Ok(()) + } + + /// Accept contact request + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn test_accept_contact_request() -> Result<()> { + let mut tcm = TestContextManager::new(); + let alice = tcm.alice().await; + let bob = tcm.bob().await; + let chat = alice + .create_group_with_members(ProtectionStatus::Unprotected, "My Group", &[&bob]) + .await; + let sent_msg = alice.send_text(chat, "Hello").await; + let chat_id_for_bob = bob.recv_msg(&sent_msg).await.chat_id; + + bob.evtracker.clear_events(); + chat_id_for_bob.accept(&bob).await?; + wait_for_chatlist_specific_item(&bob, chat_id_for_bob).await; + + Ok(()) + } + + /// Block contact request + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn test_block_contact_request() -> Result<()> { + let mut tcm = TestContextManager::new(); + let alice = tcm.alice().await; + let bob = tcm.bob().await; + let chat = alice + .create_group_with_members(ProtectionStatus::Unprotected, "My Group", &[&bob]) + .await; + let sent_msg = alice.send_text(chat, "Hello").await; + let chat_id_for_bob = bob.recv_msg(&sent_msg).await.chat_id; + + bob.evtracker.clear_events(); + chat_id_for_bob.block(&bob).await?; + wait_for_chatlist(&bob).await; + + Ok(()) + } + + /// Delete message + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn test_delete_message() -> Result<()> { + let mut tcm = TestContextManager::new(); + let alice = tcm.alice().await; + let chat = create_group_chat(&alice, ProtectionStatus::Protected, "My Group").await?; + let message = chat::send_text_msg(&alice, chat, "Hello World".to_owned()).await?; + + alice.evtracker.clear_events(); + message::delete_msgs(&alice, &[message]).await?; + wait_for_chatlist_specific_item(&alice, chat).await; + + Ok(()) + } + + /// Click on chat should remove the unread count (on msgs noticed) + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn test_msgs_noticed_on_chat() -> Result<()> { + let mut tcm = TestContextManager::new(); + let alice = tcm.alice().await; + let bob = tcm.bob().await; + + let chat = alice + .create_group_with_members(ProtectionStatus::Unprotected, "My Group", &[&bob]) + .await; + let sent_msg = alice.send_text(chat, "Hello").await; + let chat_id_for_bob = bob.recv_msg(&sent_msg).await.chat_id; + chat_id_for_bob.accept(&bob).await?; + + let sent_msg = alice.send_text(chat, "New Message").await; + let chat_id_for_bob = bob.recv_msg(&sent_msg).await.chat_id; + assert!(chat_id_for_bob.get_fresh_msg_cnt(&bob).await? >= 1); + + bob.evtracker.clear_events(); + chat::marknoticed_chat(&bob, chat_id_for_bob).await?; + wait_for_chatlist_specific_item(&bob, chat_id_for_bob).await; + + Ok(()) + } + + // Block and Unblock contact + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn test_unblock_contact() -> Result<()> { + let mut tcm = TestContextManager::new(); + let alice = tcm.alice().await; + let contact_id = Contact::create(&alice, "example", "example@example.com").await?; + let _ = ChatId::create_for_contact(&alice, contact_id).await; + + alice.evtracker.clear_events(); + Contact::block(&alice, contact_id).await?; + wait_for_chatlist(&alice).await; + + alice.evtracker.clear_events(); + Contact::unblock(&alice, contact_id).await?; + wait_for_chatlist(&alice).await; + + Ok(()) + } + + /// ephemeral / disappearing messages + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn test_update_after_ephemeral_messages() -> Result<()> { + let mut tcm = TestContextManager::new(); + let alice = tcm.alice().await; + let chat = create_group_chat(&alice, ProtectionStatus::Protected, "My Group").await?; + chat.set_ephemeral_timer(&alice, crate::ephemeral::Timer::Enabled { duration: 1 }) + .await?; + let _ = chat::send_text_msg(&alice, chat, "Hello".to_owned()).await?; + + alice.evtracker.clear_events(); + tokio::time::sleep(Duration::from_secs(2)).await; + wait_for_chatlist_and_specific_item(&alice, chat).await; + + Ok(()) + } + + /// AdHoc (Groups without a group ID.) group receiving + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn test_adhoc_group() -> Result<()> { + let alice = TestContext::new_alice().await; + let mime = br#"Subject: First thread +Message-ID: first@example.org +To: Alice , Bob +From: Claire +Content-Type: text/plain; charset=utf-8; format=flowed; delsp=no + +First thread."#; + + alice.evtracker.clear_events(); + receive_imf(&alice, mime, false).await?; + wait_for_chatlist(&alice).await; + + Ok(()) + } + + /// Test both direction of securejoin + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn test_secure_join_group() -> Result<()> { + let mut tcm = TestContextManager::new(); + let alice = tcm.alice().await; + let bob = tcm.bob().await; + + let alice_chatid = + chat::create_group_chat(&alice.ctx, ProtectionStatus::Protected, "the chat").await?; + + // Step 1: Generate QR-code, secure-join implied by chatid + let qr = get_securejoin_qr(&alice.ctx, Some(alice_chatid)).await?; + + // Step 2: Bob scans QR-code, sends vg-request + bob.evtracker.clear_events(); + let bob_chatid = join_securejoin(&bob.ctx, &qr).await?; + wait_for_chatlist(&bob).await; + + let sent = bob.pop_sent_msg().await; + + // Step 3: Alice receives vg-request, sends vg-auth-required + alice.evtracker.clear_events(); + alice.recv_msg(&sent).await; + + let sent = alice.pop_sent_msg().await; + + // Step 4: Bob receives vg-auth-required, sends vg-request-with-auth + bob.evtracker.clear_events(); + bob.recv_msg(&sent).await; + wait_for_chatlist_and_specific_item(&bob, bob_chatid).await; + + let sent = bob.pop_sent_msg().await; + + // Step 5+6: Alice receives vg-request-with-auth, sends vg-member-added + alice.evtracker.clear_events(); + alice.recv_msg(&sent).await; + wait_for_chatlist_and_specific_item(&alice, alice_chatid).await; + + let sent = alice.pop_sent_msg().await; + + // Step 7: Bob receives vg-member-added + bob.evtracker.clear_events(); + bob.recv_msg(&sent).await; + wait_for_chatlist_and_specific_item(&bob, bob_chatid).await; + + Ok(()) + } + + /// Call Resend on message + /// + /// (the event is technically only needed if it is the last message in the chat, but checking that would be too expensive so the event is always emitted) + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn test_resend_message() -> Result<()> { + let mut tcm = TestContextManager::new(); + let alice = tcm.alice().await; + let chat = create_group_chat(&alice, ProtectionStatus::Protected, "My Group").await?; + + let msg_id = chat::send_text_msg(&alice, chat, "Hello".to_owned()).await?; + let _ = alice.pop_sent_msg().await; + + let message = Message::load_from_db(&alice, msg_id).await?; + assert_eq!(message.get_state(), MessageState::OutDelivered); + + alice.evtracker.clear_events(); + chat::resend_msgs(&alice, &[msg_id]).await?; + wait_for_chatlist_specific_item(&alice, chat).await; + + Ok(()) + } + + /// test that setting a reaction emits chatlistitem update event + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn test_reaction() -> Result<()> { + let mut tcm = TestContextManager::new(); + let alice = tcm.alice().await; + let chat = create_group_chat(&alice, ProtectionStatus::Protected, "My Group").await?; + let msg_id = chat::send_text_msg(&alice, chat, "Hello".to_owned()).await?; + let _ = alice.pop_sent_msg().await; + + alice.evtracker.clear_events(); + reaction::send_reaction(&alice, msg_id, "👍").await?; + let _ = alice.pop_sent_msg().await; + wait_for_chatlist_specific_item(&alice, chat).await; + + Ok(()) + } +} diff --git a/src/events/payload.rs b/src/events/payload.rs index d980831e7..47e3526c2 100644 --- a/src/events/payload.rs +++ b/src/events/payload.rs @@ -291,4 +291,15 @@ pub enum EventType { /// /// This event is only emitted by the account manager AccountsBackgroundFetchDone, + /// Inform that set of chats or the order of the chats in the chatlist has changed. + /// + /// Sometimes this is emitted together with `UIChatlistItemChanged`. + ChatlistChanged, + + /// Inform that a single chat list item changed and needs to be rerendered. + /// If `chat_id` is set to None, then all currently visible chats need to be rerendered, and all not-visible items need to be cleared from cache if the UI has a cache. + ChatlistItemChanged { + /// ID of the changed chat + chat_id: Option, + }, } diff --git a/src/imap.rs b/src/imap.rs index d1c158671..34e43119c 100644 --- a/src/imap.rs +++ b/src/imap.rs @@ -22,6 +22,7 @@ use ratelimit::Ratelimit; use tokio::sync::RwLock; use crate::chat::{self, ChatId, ChatIdBlocked}; +use crate::chatlist_events; use crate::config::Config; use crate::constants::{self, Blocked, Chattype, ShowEmails}; use crate::contact::{normalize_name, Contact, ContactAddress, ContactId, Modifier, Origin}; @@ -1170,6 +1171,7 @@ impl Session { .with_context(|| format!("failed to set MODSEQ for folder {folder}"))?; for updated_chat_id in updated_chat_ids { context.emit_event(EventType::MsgsNoticed(updated_chat_id)); + chatlist_events::emit_chatlist_item_changed(context, updated_chat_id); } Ok(()) diff --git a/src/location.rs b/src/location.rs index d5a2337c1..5f2706c8a 100644 --- a/src/location.rs +++ b/src/location.rs @@ -13,8 +13,8 @@ use crate::context::Context; use crate::events::EventType; use crate::message::{Message, MsgId, Viewtype}; use crate::mimeparser::SystemMessage; -use crate::stock_str; use crate::tools::{duration_to_str, time}; +use crate::{chatlist_events, stock_str}; /// Location record. #[derive(Debug, Clone, Default)] @@ -290,6 +290,7 @@ pub async fn send_locations_to_chat( chat::add_info_msg(context, chat_id, &stock_str, now).await?; } context.emit_event(EventType::ChatModified(chat_id)); + chatlist_events::emit_chatlist_item_changed(context, chat_id); if 0 != seconds { context.scheduler.interrupt_location().await; } @@ -802,6 +803,7 @@ async fn maybe_send_locations(context: &Context) -> Result> { let stock_str = stock_str::msg_location_disabled(context).await; chat::add_info_msg(context, chat_id, &stock_str, now).await?; context.emit_event(EventType::ChatModified(chat_id)); + chatlist_events::emit_chatlist_item_changed(context, chat_id); } } diff --git a/src/message.rs b/src/message.rs index 9cd4f7a0d..fd36961c6 100644 --- a/src/message.rs +++ b/src/message.rs @@ -9,6 +9,7 @@ use serde::{Deserialize, Serialize}; use crate::blob::BlobObject; use crate::chat::{Chat, ChatId}; +use crate::chatlist_events; use crate::config::Config; use crate::constants::{ Blocked, Chattype, VideochatType, DC_CHAT_ID_TRASH, DC_DESIRED_TEXT_LEN, DC_MSG_ID_LAST_SPECIAL, @@ -138,6 +139,7 @@ WHERE id=?; chat_id, msg_id: self, }); + chatlist_events::emit_chatlist_item_changed(context, chat_id); Ok(()) } @@ -1527,9 +1529,12 @@ pub async fn delete_msgs(context: &Context, msg_ids: &[MsgId]) -> Result<()> { for modified_chat_id in modified_chat_ids { context.emit_msgs_changed(modified_chat_id, MsgId::new(0)); + chatlist_events::emit_chatlist_item_changed(context, modified_chat_id); } if !msg_ids.is_empty() { + context.emit_msgs_changed_without_ids(); + chatlist_events::emit_chatlist_changed(context); // Run housekeeping to delete unused blobs. context .set_config_internal(Config::LastHousekeeping, None) @@ -1664,6 +1669,7 @@ pub async fn markseen_msgs(context: &Context, msg_ids: Vec) -> Result<()> for updated_chat_id in updated_chat_ids { context.emit_event(EventType::MsgsNoticed(updated_chat_id)); + chatlist_events::emit_chatlist_item_changed(context, updated_chat_id); } Ok(()) @@ -1724,6 +1730,7 @@ pub(crate) async fn set_msg_failed( chat_id: msg.chat_id, msg_id: msg.id, }); + chatlist_events::emit_chatlist_item_changed(context, msg.chat_id); Ok(()) } diff --git a/src/mimeparser.rs b/src/mimeparser.rs index b603b1b01..0a4eea4e0 100644 --- a/src/mimeparser.rs +++ b/src/mimeparser.rs @@ -32,13 +32,12 @@ use crate::message::{ use crate::param::{Param, Params}; use crate::peerstate::Peerstate; use crate::simplify::{simplify, SimplifiedText}; -use crate::stock_str; use crate::sync::SyncItems; use crate::tools::{ create_smeared_timestamp, get_filemeta, parse_receive_headers, smeared_time, strip_rtlo_characters, truncate_by_lines, }; -use crate::{location, tools}; +use crate::{chatlist_events, location, stock_str, tools}; /// A parsed MIME message. /// @@ -2153,6 +2152,8 @@ async fn handle_mdn( { update_msg_state(context, msg_id, MessageState::OutMdnRcvd).await?; context.emit_event(EventType::MsgRead { chat_id, msg_id }); + // note(treefit): only matters if it is the last message in chat (but probably too expensive to check, debounce also solves it) + chatlist_events::emit_chatlist_item_changed(context, chat_id); } Ok(()) } diff --git a/src/peerstate.rs b/src/peerstate.rs index 5145d0a91..ffd11bbe4 100644 --- a/src/peerstate.rs +++ b/src/peerstate.rs @@ -17,7 +17,7 @@ use crate::key::{DcKey, Fingerprint, SignedPublicKey}; use crate::message::Message; use crate::mimeparser::SystemMessage; use crate::sql::Sql; -use crate::stock_str; +use crate::{chatlist_events, stock_str}; /// Type of the public key stored inside the peerstate. #[derive(Debug)] @@ -722,6 +722,9 @@ impl Peerstate { .await?; } + chatlist_events::emit_chatlist_changed(context); + // update the chats the contact is part of + chatlist_events::emit_chatlist_items_changed_for_contact(context, contact_id); Ok(()) } diff --git a/src/reaction.rs b/src/reaction.rs index 6bbf94de1..7c924c332 100644 --- a/src/reaction.rs +++ b/src/reaction.rs @@ -21,6 +21,7 @@ use std::fmt; use anyhow::Result; use crate::chat::{send_msg, Chat, ChatId}; +use crate::chatlist_events; use crate::contact::ContactId; use crate::context::Context; use crate::events::EventType; @@ -214,6 +215,7 @@ async fn set_msg_id_reaction( msg_id, contact_id, }); + chatlist_events::emit_chatlist_item_changed(context, chat_id); Ok(()) } diff --git a/src/receive_imf.rs b/src/receive_imf.rs index 512302602..e8a74f4be 100644 --- a/src/receive_imf.rs +++ b/src/receive_imf.rs @@ -22,7 +22,6 @@ use crate::ephemeral::{stock_ephemeral_timer_changed, Timer as EphemeralTimer}; use crate::events::EventType; use crate::headerdef::{HeaderDef, HeaderDefMap}; use crate::imap::{markseen_on_imap_table, GENERATED_PREFIX}; -use crate::location; use crate::log::LogExt; use crate::message::{ self, rfc724_mid_exists, rfc724_mid_exists_and, Message, MessageState, MessengerMessage, MsgId, @@ -40,6 +39,7 @@ use crate::sync::Sync::*; use crate::tools::{ self, buf_compress, extract_grpid_from_rfc724_mid, strip_rtlo_characters, validate_id, }; +use crate::{chatlist_events, location}; use crate::{contact, imap}; /// This is the struct that is returned after receiving one email (aka MIME message). @@ -1903,6 +1903,8 @@ async fn create_or_lookup_group( chat::add_to_chat_contacts_table(context, new_chat_id, &members).await?; context.emit_event(EventType::ChatModified(new_chat_id)); + chatlist_events::emit_chatlist_changed(context); + chatlist_events::emit_chatlist_item_changed(context, new_chat_id); } if let Some(chat_id) = chat_id { @@ -2216,6 +2218,7 @@ async fn apply_group_changes( if send_event_chat_modified { context.emit_event(EventType::ChatModified(chat_id)); + chatlist_events::emit_chatlist_item_changed(context, chat_id); } Ok((group_changes_msgs, better_msg)) } @@ -2518,6 +2521,8 @@ async fn create_adhoc_group( chat::add_to_chat_contacts_table(context, new_chat_id, member_ids).await?; context.emit_event(EventType::ChatModified(new_chat_id)); + chatlist_events::emit_chatlist_changed(context); + chatlist_events::emit_chatlist_item_changed(context, new_chat_id); Ok(Some(new_chat_id)) } diff --git a/src/securejoin.rs b/src/securejoin.rs index 35a380100..8ed164f21 100644 --- a/src/securejoin.rs +++ b/src/securejoin.rs @@ -5,6 +5,7 @@ use percent_encoding::{utf8_percent_encode, AsciiSet, NON_ALPHANUMERIC}; use crate::aheader::EncryptPreference; use crate::chat::{self, Chat, ChatId, ChatIdBlocked, ProtectionStatus}; +use crate::chatlist_events; use crate::config::Config; use crate::constants::Blocked; use crate::contact::{Contact, ContactId, Origin}; @@ -680,6 +681,7 @@ async fn secure_connection_established( ) .await?; context.emit_event(EventType::ChatModified(chat_id)); + chatlist_events::emit_chatlist_item_changed(context, chat_id); Ok(()) } diff --git a/src/test_utils.rs b/src/test_utils.rs index 4354976cf..c9940b438 100644 --- a/src/test_utils.rs +++ b/src/test_utils.rs @@ -1022,6 +1022,11 @@ impl EventTracker { self.get_matching(|evt| matches!(evt, EventType::IncomingMsg { .. })) .await; } + + /// Clears event queue + pub fn clear_events(&self) { + while self.try_recv().is_ok() {} + } } /// Gets a specific message from a chat and asserts that the chat has a specific length.