diff --git a/deltachat-ffi/deltachat.h b/deltachat-ffi/deltachat.h index 1098e46b9..218daa929 100644 --- a/deltachat-ffi/deltachat.h +++ b/deltachat-ffi/deltachat.h @@ -5744,9 +5744,33 @@ int64_t dc_lot_get_timestamp (const dc_lot_t* lot); #define DC_CHAT_TYPE_MAILINGLIST 140 /** - * A broadcast list. See dc_chat_get_type() for details. + * Outgoing broadcast channel, called "Channel" in the UI. + * + * The user can send into this chat, + * and all recipients will receive messages + * in a `DC_CHAT_TYPE_IN_BROADCAST`. + * + * Called `broadcast` here rather than `channel`, + * because the word "channel" already appears a lot in the code, + * which would make it hard to grep for it. */ -#define DC_CHAT_TYPE_BROADCAST 160 +#define DC_CHAT_TYPE_OUT_BROADCAST 160 + +/** + * Incoming broadcast channel, called "Channel" in the UI. + * + * This chat is read-only, + * and we do not know who the other recipients are. + * + * This is similar to `DC_CHAT_TYPE_MAILINGLIST`, + * with the main difference being that + * broadcasts are encrypted. + * + * Called `broadcast` here rather than `channel`, + * because the word "channel" already appears a lot in the code, + * which would make it hard to grep for it. + */ +#define DC_CHAT_TYPE_IN_BROADCAST 165 /** * @} diff --git a/deltachat-ffi/src/lib.rs b/deltachat-ffi/src/lib.rs index c8a7316bd..93894829a 100644 --- a/deltachat-ffi/src/lib.rs +++ b/deltachat-ffi/src/lib.rs @@ -1692,8 +1692,8 @@ pub unsafe extern "C" fn dc_create_broadcast_list(context: *mut dc_context_t) -> return 0; } let ctx = &*context; - block_on(chat::create_broadcast_list(ctx)) - .context("Failed to create broadcast list") + block_on(chat::create_broadcast(ctx, "Channel".to_string())) + .context("Failed to create broadcast channel") .log_err(ctx) .map(|id| id.to_u32()) .unwrap_or(0) diff --git a/deltachat-jsonrpc/src/api.rs b/deltachat-jsonrpc/src/api.rs index 558643df6..76dceb0a9 100644 --- a/deltachat-jsonrpc/src/api.rs +++ b/deltachat-jsonrpc/src/api.rs @@ -926,7 +926,7 @@ impl CommandApi { /// explicitly as it may happen that oneself gets removed from a still existing /// group /// - /// - for broadcasts, all recipients are returned, DC_CONTACT_ID_SELF is not included + /// - for broadcast channels, all recipients are returned, DC_CONTACT_ID_SELF is not included /// /// - for mailing lists, the behavior is not documented currently, we will decide on that later. /// for now, the UI should not show the list for mailing lists. @@ -975,18 +975,30 @@ impl CommandApi { .map(|id| id.to_u32()) } - /// Create a new broadcast list. - /// - /// Broadcast lists are similar to groups on the sending device, - /// however, recipients get the messages in a read-only chat - /// and will see who the other members are. - /// - /// For historical reasons, this function does not take a name directly, - /// instead you have to set the name using dc_set_chat_name() - /// after creating the broadcast list. + /// Deprecated 2025-07 in favor of create_broadcast(). async fn create_broadcast_list(&self, account_id: u32) -> Result { + self.create_broadcast(account_id, "Channel".to_string()) + .await + } + + /// Create a new **broadcast channel** + /// (called "Channel" in the UI). + /// + /// Broadcast channels are similar to groups on the sending device, + /// however, recipients get the messages in a read-only chat + /// and will not see who the other members are. + /// + /// Called `broadcast` here rather than `channel`, + /// because the word "channel" already appears a lot in the code, + /// which would make it hard to grep for it. + /// + /// After creation, the chat contains no recipients and is in _unpromoted_ state; + /// see [`CommandApi::create_group_chat`] for more information on the unpromoted state. + /// + /// Returns the created chat's id. + async fn create_broadcast(&self, account_id: u32, chat_name: String) -> Result { let ctx = self.get_context(account_id).await?; - chat::create_broadcast_list(&ctx) + chat::create_broadcast(&ctx, chat_name) .await .map(|id| id.to_u32()) } diff --git a/deltachat-jsonrpc/src/api/types/chat_list.rs b/deltachat-jsonrpc/src/api/types/chat_list.rs index d229d8c1f..7f5b4ed5d 100644 --- a/deltachat-jsonrpc/src/api/types/chat_list.rs +++ b/deltachat-jsonrpc/src/api/types/chat_list.rs @@ -64,8 +64,10 @@ pub enum ChatListItemFetchResult { is_pinned: bool, is_muted: bool, is_contact_request: bool, - /// true when chat is a broadcastlist + /// Deprecated 2025-07, alias for is_out_broadcast is_broadcast: bool, + /// true if the chat type is OutBroadcast + is_out_broadcast: bool, /// contact id if this is a dm chat (for view profile entry in context menu) dm_chat_contact: Option, was_seen_recently: bool, @@ -172,7 +174,8 @@ pub(crate) async fn get_chat_list_item_by_id( is_pinned: visibility == ChatVisibility::Pinned, is_muted: chat.is_muted(), is_contact_request: chat.is_contact_request(), - is_broadcast: chat.get_type() == Chattype::Broadcast, + is_broadcast: chat.get_type() == Chattype::OutBroadcast, + is_out_broadcast: chat.get_type() == Chattype::OutBroadcast, dm_chat_contact, was_seen_recently, last_message_type: message_type, diff --git a/deltachat-repl/src/cmdline.rs b/deltachat-repl/src/cmdline.rs index b4bac7004..26dacb139 100644 --- a/deltachat-repl/src/cmdline.rs +++ b/deltachat-repl/src/cmdline.rs @@ -749,7 +749,8 @@ pub async fn cmdline(context: Context, line: &str, chat_id: &mut ChatId) -> Resu println!("Group#{chat_id} created successfully."); } "createbroadcast" => { - let chat_id = chat::create_broadcast_list(&context).await?; + ensure!(!arg1.is_empty(), "Argument missing."); + let chat_id = chat::create_broadcast(&context, arg1.to_string()).await?; println!("Broadcast#{chat_id} created successfully."); } diff --git a/deltachat-rpc-client/src/deltachat_rpc_client/account.py b/deltachat-rpc-client/src/deltachat_rpc_client/account.py index 11d76ff04..174205714 100644 --- a/deltachat-rpc-client/src/deltachat_rpc_client/account.py +++ b/deltachat-rpc-client/src/deltachat_rpc_client/account.py @@ -288,10 +288,46 @@ class Account: def create_group(self, name: str, protect: bool = False) -> Chat: """Create a new group chat. - After creation, the group has only self-contact as member and is in unpromoted state. + After creation, + the group has only self-contact as member one member (see `SpecialContactId.SELF`) + and is in _unpromoted_ state. + This means, you can add or remove members, change the name, + the group image and so on without messages being sent to all group members. + + This changes as soon as the first message is sent to the group members + and the group becomes _promoted_. + After that, all changes are synced with all group members + by sending status message. + + To check, if a chat is still unpromoted, you can look at the `is_unpromoted` property of a chat + (see `get_full_snapshot()` / `get_basic_snapshot()`). + This may be useful if you want to show some help for just created groups. + + :param protect: If set to 1 the function creates group with protection initially enabled. + Only verified members are allowed in these groups + and end-to-end-encryption is always enabled. """ return Chat(self, self._rpc.create_group_chat(self.id, name, protect)) + def create_broadcast(self, name: str) -> Chat: + """Create a new **broadcast channel** + (called "Channel" in the UI). + + Broadcast channels are similar to groups on the sending device, + however, recipients get the messages in a read-only chat + and will not see who the other members are. + + Called `broadcast` here rather than `channel`, + because the word "channel" already appears a lot in the code, + which would make it hard to grep for it. + + After creation, the chat contains no recipients and is in _unpromoted_ state; + see `create_group()` for more information on the unpromoted state. + + Returns the created chat. + """ + return Chat(self, self._rpc.create_broadcast(self.id, name)) + def get_chat_by_id(self, chat_id: int) -> Chat: """Return the Chat instance with the given ID.""" return Chat(self, chat_id) diff --git a/deltachat-rpc-client/src/deltachat_rpc_client/const.py b/deltachat-rpc-client/src/deltachat_rpc_client/const.py index 6ba25bdeb..95d1b90a8 100644 --- a/deltachat-rpc-client/src/deltachat_rpc_client/const.py +++ b/deltachat-rpc-client/src/deltachat_rpc_client/const.py @@ -90,10 +90,40 @@ class ChatType(IntEnum): """Chat type.""" UNDEFINED = 0 + SINGLE = 100 + """1:1 chat, i.e. a direct chat with a single contact""" + GROUP = 120 + MAILINGLIST = 140 - BROADCAST = 160 + + OUT_BROADCAST = 160 + """Outgoing broadcast channel, called "Channel" in the UI. + + The user can send into this channel, + and all recipients will receive messages + in an `IN_BROADCAST`. + + Called `broadcast` here rather than `channel`, + because the word "channel" already appears a lot in the code, + which would make it hard to grep for it. + """ + + IN_BROADCAST = 165 + """Incoming broadcast channel, called "Channel" in the UI. + + This channel is read-only, + and we do not know who the other recipients are. + + This is similar to a `MAILINGLIST`, + with the main difference being that + `IN_BROADCAST`s are encrypted. + + Called `broadcast` here rather than `channel`, + because the word "channel" already appears a lot in the code, + which would make it hard to grep for it. + """ class ChatVisibility(str, Enum): diff --git a/deltachat-rpc-client/tests/test_something.py b/deltachat-rpc-client/tests/test_something.py index b7b2b683c..35a8e0a36 100644 --- a/deltachat-rpc-client/tests/test_something.py +++ b/deltachat-rpc-client/tests/test_something.py @@ -11,7 +11,7 @@ from unittest.mock import MagicMock import pytest from deltachat_rpc_client import Contact, EventType, Message, events -from deltachat_rpc_client.const import DownloadState, MessageState +from deltachat_rpc_client.const import ChatType, DownloadState, MessageState from deltachat_rpc_client.rpc import JsonRpcError @@ -846,3 +846,36 @@ def test_delete_deltachat_folder(acfactory, direct_imap): assert msg.text == "hello" assert "DeltaChat" in ac1_direct_imap.list_folders() + + +def test_broadcast(acfactory): + alice, bob = acfactory.get_online_accounts(2) + + alice_chat = alice.create_broadcast("My great channel") + snapshot = alice_chat.get_basic_snapshot() + assert snapshot.name == "My great channel" + assert snapshot.is_unpromoted + assert snapshot.is_encrypted + assert snapshot.chat_type == ChatType.OUT_BROADCAST + + alice_contact_bob = alice.create_contact(bob, "Bob") + alice_chat.add_contact(alice_contact_bob) + + alice_msg = alice_chat.send_message(text="hello").get_snapshot() + assert alice_msg.text == "hello" + assert alice_msg.show_padlock + + bob_msg = bob.wait_for_incoming_msg().get_snapshot() + assert bob_msg.text == "hello" + assert bob_msg.show_padlock + assert bob_msg.error is None + + bob_chat = bob.get_chat_by_id(bob_msg.chat_id) + bob_chat_snapshot = bob_chat.get_basic_snapshot() + assert bob_chat_snapshot.name == "My great channel" + assert not bob_chat_snapshot.is_unpromoted + assert bob_chat_snapshot.is_encrypted + assert bob_chat_snapshot.chat_type == ChatType.IN_BROADCAST + assert bob_chat_snapshot.is_contact_request + + assert not bob_chat.can_send() diff --git a/src/blob/blob_tests.rs b/src/blob/blob_tests.rs index 5c22ed7e0..d3fab2397 100644 --- a/src/blob/blob_tests.rs +++ b/src/blob/blob_tests.rs @@ -4,7 +4,7 @@ use super::*; use crate::message::{Message, Viewtype}; use crate::param::Param; use crate::sql; -use crate::test_utils::{self, TestContext}; +use crate::test_utils::{self, AVATAR_64x64_BYTES, AVATAR_64x64_DEDUPLICATED, TestContext}; use crate::tools::SystemTime; fn check_image_size(path: impl AsRef, width: u32, height: u32) -> image::DynamicImage { @@ -241,9 +241,8 @@ async fn test_selfavatar_in_blobdir() { async fn test_selfavatar_copy_without_recode() { let t = TestContext::new().await; let avatar_src = t.dir.path().join("avatar.png"); - let avatar_bytes = include_bytes!("../../test-data/image/avatar64x64.png"); - fs::write(&avatar_src, avatar_bytes).await.unwrap(); - let avatar_blob = t.get_blobdir().join("e9b6c7a78aa2e4f415644f55a553e73.png"); + fs::write(&avatar_src, AVATAR_64x64_BYTES).await.unwrap(); + let avatar_blob = t.get_blobdir().join(AVATAR_64x64_DEDUPLICATED); assert!(!avatar_blob.exists()); t.set_config(Config::Selfavatar, Some(avatar_src.to_str().unwrap())) .await @@ -251,7 +250,7 @@ async fn test_selfavatar_copy_without_recode() { assert!(avatar_blob.exists()); assert_eq!( fs::metadata(&avatar_blob).await.unwrap().len(), - avatar_bytes.len() as u64 + AVATAR_64x64_BYTES.len() as u64 ); let avatar_cfg = t.get_config(Config::Selfavatar).await.unwrap(); assert_eq!(avatar_cfg, avatar_blob.to_str().map(|s| s.to_string())); diff --git a/src/chat.rs b/src/chat.rs index 743547ef6..37adccadf 100644 --- a/src/chat.rs +++ b/src/chat.rs @@ -123,6 +123,9 @@ pub(crate) enum CantSendReason { /// Mailing list without known List-Post header. ReadOnlyMailingList, + /// Incoming broadcast channel where the user can't send messages. + InBroadcast, + /// Not a member of the chat. NotAMember, @@ -146,6 +149,9 @@ impl fmt::Display for CantSendReason { Self::ReadOnlyMailingList => { write!(f, "mailing list does not have a know post address") } + Self::InBroadcast => { + write!(f, "Broadcast channel is read-only") + } Self::NotAMember => write!(f, "not a member of the chat"), Self::MissingKey => write!(f, "key is missing"), } @@ -395,7 +401,7 @@ impl ChatId { let mut delete = false; match chat.typ { - Chattype::Broadcast => { + Chattype::OutBroadcast => { bail!("Can't block chat of type {:?}", chat.typ) } Chattype::Single => { @@ -413,7 +419,7 @@ impl ChatId { info!(context, "Can't block groups yet, deleting the chat."); delete = true; } - Chattype::Mailinglist => { + Chattype::Mailinglist | Chattype::InBroadcast => { if self.set_blocked(context, Blocked::Yes).await? { context.emit_event(EventType::ChatModified(self)); } @@ -479,7 +485,7 @@ impl ChatId { .inner_set_protection(context, ProtectionStatus::Unprotected) .await?; } - Chattype::Single | Chattype::Group | Chattype::Broadcast => { + Chattype::Single | Chattype::Group | Chattype::OutBroadcast | Chattype::InBroadcast => { // User has "created a chat" with all these contacts. // // Previously accepting a chat literally created a chat because unaccepted chats @@ -529,7 +535,10 @@ impl ChatId { match protect { ProtectionStatus::Protected => match chat.typ { - Chattype::Single | Chattype::Group | Chattype::Broadcast => {} + Chattype::Single + | Chattype::Group + | Chattype::OutBroadcast + | Chattype::InBroadcast => {} Chattype::Mailinglist => bail!("Cannot protect mailing lists"), }, ProtectionStatus::Unprotected | ProtectionStatus::ProtectionBroken => {} @@ -1659,6 +1668,12 @@ impl Chat { return Ok(Some(reason)); } } + if self.typ == Chattype::InBroadcast { + let reason = InBroadcast; + if !skip_fn(&reason) { + return Ok(Some(reason)); + } + } // Do potentially slow checks last and after calls to `skip_fn` which should be fast. let reason = NotAMember; @@ -1692,8 +1707,9 @@ impl Chat { /// The function does not check if the chat type allows editing of concrete elements. pub(crate) async fn is_self_in_chat(&self, context: &Context) -> Result { match self.typ { - Chattype::Single | Chattype::Broadcast | Chattype::Mailinglist => Ok(true), + Chattype::Single | Chattype::OutBroadcast | Chattype::Mailinglist => Ok(true), Chattype::Group => is_contact_in_chat(context, self.id, ContactId::SELF).await, + Chattype::InBroadcast => Ok(false), } } @@ -1758,8 +1774,6 @@ impl Chat { if !image_rel.is_empty() { return Ok(Some(get_abs_path(context, Path::new(&image_rel)))); } - } else if self.typ == Chattype::Broadcast { - return Ok(Some(get_broadcast_icon(context).await?)); } Ok(None) } @@ -1872,7 +1886,7 @@ impl Chat { !self.grpid.is_empty() } Chattype::Mailinglist => false, - Chattype::Broadcast => true, + Chattype::OutBroadcast | Chattype::InBroadcast => true, }; Ok(is_encrypted) } @@ -1970,7 +1984,7 @@ impl Chat { ); bail!("Cannot set message, contact for {} not found.", self.id); } - } else if self.typ == Chattype::Group + } else if matches!(self.typ, Chattype::Group | Chattype::OutBroadcast) && self.param.get_int(Param::Unpromoted).unwrap_or_default() == 1 { msg.param.set_int(Param::AttachGroupImage, 1); @@ -2291,7 +2305,10 @@ impl Chat { } Ok(r) } - Chattype::Broadcast | Chattype::Group | Chattype::Mailinglist => { + Chattype::OutBroadcast + | Chattype::InBroadcast + | Chattype::Group + | Chattype::Mailinglist => { if !self.grpid.is_empty() { return Ok(Some(SyncId::Grpid(self.grpid.clone()))); } @@ -2465,15 +2482,6 @@ pub(crate) async fn get_device_icon(context: &Context) -> Result { .await } -pub(crate) async fn get_broadcast_icon(context: &Context) -> Result { - get_asset_icon( - context, - "icon-broadcast", - include_bytes!("../assets/icon-broadcast.png"), - ) - .await -} - pub(crate) async fn get_archive_icon(context: &Context) -> Result { get_asset_icon( context, @@ -3614,37 +3622,27 @@ pub async fn create_group_chat( Ok(chat_id) } -/// Finds an unused name for a new broadcast list. -async fn find_unused_broadcast_list_name(context: &Context) -> Result { - let base_name = stock_str::broadcast_list(context).await; - for attempt in 1..1000 { - let better_name = if attempt > 1 { - format!("{base_name} {attempt}") - } else { - base_name.clone() - }; - if !context - .sql - .exists( - "SELECT COUNT(*) FROM chats WHERE type=? AND name=?;", - (Chattype::Broadcast, &better_name), - ) - .await? - { - return Ok(better_name); - } - } - Ok(base_name) -} - -/// Creates a new broadcast list. -pub async fn create_broadcast_list(context: &Context) -> Result { - let chat_name = find_unused_broadcast_list_name(context).await?; +/// Create a new **broadcast channel** +/// (called "Channel" in the UI). +/// +/// Broadcast channels are similar to groups on the sending device, +/// however, recipients get the messages in a read-only chat +/// and will not see who the other members are. +/// +/// Called `broadcast` here rather than `channel`, +/// because the word "channel" already appears a lot in the code, +/// which would make it hard to grep for it. +/// +/// After creation, the chat contains no recipients and is in _unpromoted_ state; +/// see [`create_group_chat`] for more information on the unpromoted state. +/// +/// Returns the created chat's id. +pub async fn create_broadcast(context: &Context, chat_name: String) -> Result { let grpid = create_id(); - create_broadcast_list_ex(context, Sync, grpid, chat_name).await + create_broadcast_ex(context, Sync, grpid, chat_name).await } -pub(crate) async fn create_broadcast_list_ex( +pub(crate) async fn create_broadcast_ex( context: &Context, sync: sync::Sync, grpid: String, @@ -3659,7 +3657,7 @@ pub(crate) async fn create_broadcast_list_ex( if cnt == 1 { return Ok(t.query_row( "SELECT id FROM chats WHERE grpid=? AND type=?", - (grpid, Chattype::Broadcast), + (grpid, Chattype::OutBroadcast), |row| { let id: isize = row.get(0)?; Ok(id) @@ -3671,7 +3669,7 @@ pub(crate) async fn create_broadcast_list_ex( (type, name, grpid, param, created_timestamp) \ VALUES(?, ?, ?, \'U=1\', ?);", ( - Chattype::Broadcast, + Chattype::OutBroadcast, &chat_name, &grpid, create_smeared_timestamp(context), @@ -3809,7 +3807,7 @@ pub(crate) async fn add_contact_to_chat_ex( // this also makes sure, no contacts are added to special or normal chats let mut chat = Chat::load_from_db(context, chat_id).await?; ensure!( - chat.typ == Chattype::Group || chat.typ == Chattype::Broadcast, + chat.typ == Chattype::Group || chat.typ == Chattype::OutBroadcast, "{} is not a group/broadcast where one can add members", chat_id ); @@ -3820,8 +3818,8 @@ pub(crate) async fn add_contact_to_chat_ex( ); ensure!(!chat.is_mailing_list(), "Mailing lists can't be changed"); ensure!( - chat.typ != Chattype::Broadcast || contact_id != ContactId::SELF, - "Cannot add SELF to broadcast." + chat.typ != Chattype::OutBroadcast || contact_id != ContactId::SELF, + "Cannot add SELF to broadcast channel." ); ensure!( chat.is_encrypted(context).await? == contact.is_key_contact(), @@ -4040,7 +4038,7 @@ pub async fn remove_contact_from_chat( let mut msg = Message::new(Viewtype::default()); let chat = Chat::load_from_db(context, chat_id).await?; - if chat.typ == Chattype::Group || chat.typ == Chattype::Broadcast { + if chat.typ == Chattype::Group || chat.typ == Chattype::OutBroadcast { if !chat.is_self_in_chat(context).await? { let err_msg = format!( "Cannot remove contact {contact_id} from chat {chat_id}: self not in group." @@ -4148,7 +4146,7 @@ async fn rename_ex( if chat.typ == Chattype::Group || chat.typ == Chattype::Mailinglist - || chat.typ == Chattype::Broadcast + || chat.typ == Chattype::OutBroadcast { if chat.name == new_name { success = true; @@ -4166,7 +4164,6 @@ async fn rename_ex( .await?; if chat.is_promoted() && !chat.is_mailing_list() - && chat.typ != Chattype::Broadcast && sanitize_single_line(&chat.name) != new_name { msg.viewtype = Viewtype::Text; @@ -4212,15 +4209,15 @@ pub async fn set_chat_profile_image( ensure!(!chat_id.is_special(), "Invalid chat ID"); let mut chat = Chat::load_from_db(context, chat_id).await?; ensure!( - chat.typ == Chattype::Group, - "Can only set profile image for group chats" + chat.typ == Chattype::Group || chat.typ == Chattype::OutBroadcast, + "Can only set profile image for groups / broadcasts" ); ensure!( !chat.grpid.is_empty(), "Cannot set profile image for ad hoc groups" ); /* we should respect this - whatever we send to the group, it gets discarded anyway! */ - if !is_contact_in_chat(context, chat_id, ContactId::SELF).await? { + if !chat.is_self_in_chat(context).await? { context.emit_event(EventType::ErrorSelfNotInGroup( "Cannot set chat profile image; self not in group.".into(), )); @@ -4287,10 +4284,6 @@ pub async fn forward_msgs(context: &Context, msg_ids: &[MsgId], chat_id: ChatId) bail!("cannot forward drafts."); } - // we tested a sort of broadcast - // by not marking own forwarded messages as such, - // however, this turned out to be to confusing and unclear. - if msg.get_viewtype() != Viewtype::Sticker { msg.param .set_int(Param::Forwarded, src_msg_id.to_u32() as i32); @@ -4787,7 +4780,7 @@ async fn set_contacts_by_addrs(context: &Context, id: ChatId, addrs: &[String]) "Cannot add address-contacts to encrypted chat {id}" ); ensure!( - chat.typ == Chattype::Broadcast, + chat.typ == Chattype::OutBroadcast, "{id} is not a broadcast list", ); let mut contacts = HashSet::new(); @@ -4808,7 +4801,7 @@ async fn set_contacts_by_addrs(context: &Context, id: ChatId, addrs: &[String]) transaction.execute("DELETE FROM chats_contacts WHERE chat_id=?", (id,))?; // We do not care about `add_timestamp` column - // because timestamps are not used for broadcast lists. + // because timestamps are not used for broadcast channels. let mut statement = transaction .prepare("INSERT INTO chats_contacts (chat_id, contact_id) VALUES (?, ?)")?; for contact_id in &contacts { @@ -4835,7 +4828,7 @@ async fn set_contacts_by_fingerprints( "Cannot add key-contacts to unencrypted chat {id}" ); ensure!( - chat.typ == Chattype::Broadcast, + chat.typ == Chattype::OutBroadcast, "{id} is not a broadcast list", ); let mut contacts = HashSet::new(); @@ -4857,7 +4850,7 @@ async fn set_contacts_by_fingerprints( transaction.execute("DELETE FROM chats_contacts WHERE chat_id=?", (id,))?; // We do not care about `add_timestamp` column - // because timestamps are not used for broadcast lists. + // because timestamps are not used for broadcast channels. let mut statement = transaction .prepare("INSERT INTO chats_contacts (chat_id, contact_id) VALUES (?, ?)")?; for contact_id in &contacts { @@ -4895,7 +4888,7 @@ pub(crate) enum SyncAction { Accept, SetVisibility(ChatVisibility), SetMuted(MuteDuration), - /// Create broadcast list with the given name. + /// Create broadcast channel with the given name. CreateBroadcast(String), Rename(String), /// Set chat contacts by their addresses. @@ -4960,7 +4953,7 @@ impl Context { } SyncId::Grpid(grpid) => { if let SyncAction::CreateBroadcast(name) = action { - create_broadcast_list_ex(self, Nosync, grpid.clone(), name.clone()).await?; + create_broadcast_ex(self, Nosync, grpid.clone(), name.clone()).await?; return Ok(()); } get_chat_id_by_grpid(self, grpid) diff --git a/src/chat/chat_tests.rs b/src/chat/chat_tests.rs index 69be5608f..4d142018e 100644 --- a/src/chat/chat_tests.rs +++ b/src/chat/chat_tests.rs @@ -6,7 +6,10 @@ use crate::headerdef::HeaderDef; use crate::imex::{ImexMode, has_backup, imex}; use crate::message::{MessengerMessage, delete_msgs}; use crate::receive_imf::receive_imf; -use crate::test_utils::{TestContext, TestContextManager, TimeShiftFalsePositiveNote, sync}; +use crate::test_utils::{ + AVATAR_64x64_BYTES, AVATAR_64x64_DEDUPLICATED, TestContext, TestContextManager, + TimeShiftFalsePositiveNote, sync, +}; use pretty_assertions::assert_eq; use strum::IntoEnumIterator; use tokio::fs; @@ -2259,7 +2262,7 @@ async fn test_only_minimal_data_are_forwarded() -> Result<()> { let single_id = ChatId::create_for_contact(&bob, charlie_id).await?; let group_id = create_group_chat(&bob, ProtectionStatus::Unprotected, "group2").await?; add_contact_to_chat(&bob, group_id, charlie_id).await?; - let broadcast_id = create_broadcast_list(&bob).await?; + let broadcast_id = create_broadcast(&bob, "Channel".to_string()).await?; add_contact_to_chat(&bob, broadcast_id, charlie_id).await?; for chat_id in &[single_id, group_id, broadcast_id] { forward_msgs(&bob, &[orig_msg.id], *chat_id).await?; @@ -2619,8 +2622,8 @@ async fn test_broadcast() -> Result<()> { let msg = alice.recv_msg(&bob.pop_sent_msg().await).await; assert!(msg.get_showpadlock()); - // test broadcast list - let broadcast_id = create_broadcast_list(&alice).await?; + // test broadcast channel + let broadcast_id = create_broadcast(&alice, "Channel".to_string()).await?; add_contact_to_chat( &alice, broadcast_id, @@ -2629,11 +2632,11 @@ async fn test_broadcast() -> Result<()> { .await?; let fiona_contact_id = alice.add_or_lookup_contact_id(&fiona).await; add_contact_to_chat(&alice, broadcast_id, fiona_contact_id).await?; - set_chat_name(&alice, broadcast_id, "Broadcast list").await?; + set_chat_name(&alice, broadcast_id, "Broadcast channel").await?; { let chat = Chat::load_from_db(&alice, broadcast_id).await?; - assert_eq!(chat.typ, Chattype::Broadcast); - assert_eq!(chat.name, "Broadcast list"); + assert_eq!(chat.typ, Chattype::OutBroadcast); + assert_eq!(chat.name, "Broadcast channel"); assert!(!chat.is_self_talk()); send_text_msg(&alice, broadcast_id, "ola!".to_string()).await?; @@ -2649,12 +2652,13 @@ async fn test_broadcast() -> Result<()> { assert!(!msg.header_exists(HeaderDef::AutocryptGossip)); let msg = bob.recv_msg(&sent_msg).await; assert_eq!(msg.get_text(), "ola!"); - assert_eq!(msg.subject, "Broadcast list"); + assert_eq!(msg.subject, "Broadcast channel"); assert!(msg.get_showpadlock()); + assert!(msg.get_override_sender_name().is_none()); let chat = Chat::load_from_db(&bob, msg.chat_id).await?; - assert_eq!(chat.typ, Chattype::Mailinglist); + assert_eq!(chat.typ, Chattype::InBroadcast); assert_ne!(chat.id, chat_bob.id); - assert_eq!(chat.name, "Broadcast list"); + assert_eq!(chat.name, "Broadcast channel"); assert!(!chat.is_self_talk()); } @@ -2672,6 +2676,14 @@ async fn test_broadcast() -> Result<()> { Ok(()) } +/// - Alice has multiple devices +/// - Alice creates a broadcast and sends a message into it +/// - Alice's second device sees the broadcast +/// - Alice adds Bob to the broadcast +/// - Synchronization is only implemented via sync messages for now, +/// which are not enabled in tests by default, +/// so, Alice's second device doesn't see the change yet. +/// `test_sync_broadcast()` tests that synchronization works via sync messages. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn test_broadcast_multidev() -> Result<()> { let alices = [ @@ -2681,9 +2693,9 @@ async fn test_broadcast_multidev() -> Result<()> { let bob = TestContext::new_bob().await; let a1b_contact_id = alices[1].add_or_lookup_contact(&bob).await.id; - let a0_broadcast_id = create_broadcast_list(&alices[0]).await?; + let a0_broadcast_id = create_broadcast(&alices[0], "Channel".to_string()).await?; let a0_broadcast_chat = Chat::load_from_db(&alices[0], a0_broadcast_id).await?; - set_chat_name(&alices[0], a0_broadcast_id, "Broadcast list 42").await?; + set_chat_name(&alices[0], a0_broadcast_id, "Broadcast channel 42").await?; let sent_msg = alices[0].send_text(a0_broadcast_id, "hi").await; let msg = alices[1].recv_msg(&sent_msg).await; let a1_broadcast_id = get_chat_id_by_grpid(&alices[1], &a0_broadcast_chat.grpid) @@ -2692,8 +2704,8 @@ async fn test_broadcast_multidev() -> Result<()> { .0; assert_eq!(msg.chat_id, a1_broadcast_id); let a1_broadcast_chat = Chat::load_from_db(&alices[1], a1_broadcast_id).await?; - assert_eq!(a1_broadcast_chat.get_type(), Chattype::Broadcast); - assert_eq!(a1_broadcast_chat.get_name(), "Broadcast list 42"); + assert_eq!(a1_broadcast_chat.get_type(), Chattype::OutBroadcast); + assert_eq!(a1_broadcast_chat.get_name(), "Broadcast channel 42"); assert!( get_chat_contacts(&alices[1], a1_broadcast_id) .await? @@ -2701,13 +2713,13 @@ async fn test_broadcast_multidev() -> Result<()> { ); add_contact_to_chat(&alices[1], a1_broadcast_id, a1b_contact_id).await?; - set_chat_name(&alices[1], a1_broadcast_id, "Broadcast list 43").await?; + set_chat_name(&alices[1], a1_broadcast_id, "Broadcast channel 43").await?; let sent_msg = alices[1].send_text(a1_broadcast_id, "hi").await; let msg = alices[0].recv_msg(&sent_msg).await; assert_eq!(msg.chat_id, a0_broadcast_id); let a0_broadcast_chat = Chat::load_from_db(&alices[0], a0_broadcast_id).await?; - assert_eq!(a0_broadcast_chat.get_type(), Chattype::Broadcast); - assert_eq!(a0_broadcast_chat.get_name(), "Broadcast list 42"); + assert_eq!(a0_broadcast_chat.get_type(), Chattype::OutBroadcast); + assert_eq!(a0_broadcast_chat.get_name(), "Broadcast channel 42"); assert!( get_chat_contacts(&alices[0], a0_broadcast_id) .await? @@ -2717,6 +2729,161 @@ async fn test_broadcast_multidev() -> Result<()> { Ok(()) } +/// - Create a broadcast channel +/// - Send a message into it in order to promote it +/// - Add a contact +/// - Rename it +/// - the change should be visible on the receiver's side immediately +/// - Change the avatar +/// - The change should be visible on the receiver's side immediately +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_broadcasts_name_and_avatar() -> Result<()> { + let mut tcm = TestContextManager::new(); + let alice = &tcm.alice().await; + alice.set_config(Config::Displayname, Some("Alice")).await?; + let bob = &tcm.bob().await; + let alice_bob_contact_id = alice.add_or_lookup_contact_id(bob).await; + + tcm.section("Create a broadcast channel"); + let alice_chat_id = create_broadcast(alice, "My Channel".to_string()).await?; + let alice_chat = Chat::load_from_db(alice, alice_chat_id).await?; + assert_eq!(alice_chat.typ, Chattype::OutBroadcast); + + let alice_chat = Chat::load_from_db(alice, alice_chat_id).await?; + assert_eq!(alice_chat.is_promoted(), false); + let sent = alice.send_text(alice_chat_id, "Hi nobody").await; + let alice_chat = Chat::load_from_db(alice, alice_chat_id).await?; + assert_eq!(alice_chat.is_promoted(), true); + assert_eq!(sent.recipients, "alice@example.org"); + + tcm.section("Add a contact to the chat and send a message"); + add_contact_to_chat(alice, alice_chat_id, alice_bob_contact_id).await?; + let sent = alice.send_text(alice_chat_id, "Hi somebody").await; + + assert_eq!(sent.recipients, "bob@example.net alice@example.org"); + let rcvd = bob.recv_msg(&sent).await; + assert!(rcvd.get_override_sender_name().is_none()); + assert_eq!(rcvd.text, "Hi somebody"); + let bob_chat = Chat::load_from_db(bob, rcvd.chat_id).await?; + assert_eq!(bob_chat.typ, Chattype::InBroadcast); + assert_eq!(bob_chat.name, "My Channel"); + assert_eq!(bob_chat.get_profile_image(bob).await?, None); + + tcm.section("Change broadcast channel name, and check that receivers see it"); + set_chat_name(alice, alice_chat_id, "New Channel name").await?; + let sent = alice.pop_sent_msg().await; + let rcvd = bob.recv_msg(&sent).await; + assert!(rcvd.get_override_sender_name().is_none()); + assert_eq!(rcvd.get_info_type(), SystemMessage::GroupNameChanged); + assert_eq!( + rcvd.text, + r#"Group name changed from "My Channel" to "New Channel name" by Alice."# + ); + let bob_chat = Chat::load_from_db(bob, bob_chat.id).await?; + assert_eq!(bob_chat.name, "New Channel name"); + + tcm.section("Set a broadcast channel avatar, and check that receivers see it"); + let file = alice.get_blobdir().join("avatar.png"); + tokio::fs::write(&file, AVATAR_64x64_BYTES).await?; + set_chat_profile_image(alice, alice_chat_id, file.to_str().unwrap()).await?; + let sent = alice.pop_sent_msg().await; + + let bob_chat = Chat::load_from_db(bob, bob_chat.id).await?; + assert_eq!(bob_chat.get_profile_image(bob).await?, None); + + let rcvd = bob.recv_msg(&sent).await; + assert!(rcvd.get_override_sender_name().is_none()); + assert_eq!(rcvd.get_info_type(), SystemMessage::GroupImageChanged); + assert_eq!(rcvd.text, "Group image changed by Alice."); + assert_eq!(rcvd.chat_id, bob_chat.id); + + let bob_chat = Chat::load_from_db(bob, bob_chat.id).await?; + let avatar = bob_chat.get_profile_image(bob).await?.unwrap(); + assert_eq!( + avatar.file_name().unwrap().to_str().unwrap(), + AVATAR_64x64_DEDUPLICATED + ); + + tcm.section("Check that Bob can't modify the broadcast channel"); + set_chat_profile_image(bob, bob_chat.id, file.to_str().unwrap()) + .await + .unwrap_err(); + set_chat_name(bob, bob_chat.id, "Bob Channel name") + .await + .unwrap_err(); + + Ok(()) +} + +/// - Create a broadcast channel +/// - Block it +/// - Check that the broadcast channel appears in the list of blocked contacts +/// - A message is sent into the broadcast channel, but it is blocked +/// - Unblock it +/// - Receive a message again in the now-unblocked broadcast channel +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_block_broadcast() -> Result<()> { + let mut tcm = TestContextManager::new(); + let alice = &tcm.alice().await; + let bob = &tcm.bob().await; + let alice_bob_contact_id = alice.add_or_lookup_contact_id(bob).await; + + tcm.section("Create a broadcast channel with Bob, and send a message"); + let alice_chat_id = create_broadcast(alice, "My Channel".to_string()).await?; + add_contact_to_chat(alice, alice_chat_id, alice_bob_contact_id).await?; + let sent = alice.send_text(alice_chat_id, "Hi somebody").await; + let rcvd = bob.recv_msg(&sent).await; + + let chats = Chatlist::try_load(bob, DC_GCL_NO_SPECIALS, None, None).await?; + assert_eq!(chats.len(), 1); + assert_eq!(chats.get_chat_id(0)?, rcvd.chat_id); + + assert_eq!(rcvd.chat_blocked, Blocked::Request); + let blocked = Contact::get_all_blocked(bob).await.unwrap(); + assert_eq!(blocked.len(), 0); + + tcm.section("Bob blocks the chat"); + rcvd.chat_id.block(bob).await?; + let chat = Chat::load_from_db(bob, rcvd.chat_id).await?; + assert_eq!(chat.blocked, Blocked::Yes); + let blocked = Contact::get_all_blocked(bob).await.unwrap(); + assert_eq!(blocked.len(), 1); + let blocked = Contact::get_by_id(bob, blocked[0]).await?; + assert!(blocked.is_key_contact()); + assert_eq!(blocked.origin, Origin::MailinglistAddress); + assert_eq!(blocked.get_name(), "My Channel"); + + let sent = alice.send_text(alice_chat_id, "Second message").await; + let rcvd2 = bob.recv_msg(&sent).await; + assert_eq!(rcvd2.chat_id, rcvd.chat_id); + assert_eq!(rcvd2.chat_blocked, Blocked::Yes); + + let chats = Chatlist::try_load(bob, DC_GCL_NO_SPECIALS, None, None).await?; + assert_eq!(chats.len(), 0); + + tcm.section("Bob unblocks the chat"); + Contact::unblock(bob, blocked.id).await?; + + let sent = alice.send_text(alice_chat_id, "Third message").await; + let rcvd3 = bob.recv_msg(&sent).await; + assert_eq!(rcvd3.chat_id, rcvd.chat_id); + assert_eq!(rcvd3.chat_blocked, Blocked::Not); + + let blocked = Contact::get_all_blocked(bob).await.unwrap(); + assert_eq!(blocked.len(), 0); + + let chats = Chatlist::try_load(bob, DC_GCL_NO_SPECIALS, None, None).await?; + assert_eq!(chats.len(), 1); + assert_eq!(chats.get_chat_id(0)?, rcvd.chat_id); + + let chat = Chat::load_from_db(bob, rcvd3.chat_id).await?; + assert_eq!(chat.blocked, Blocked::Not); + assert_eq!(chat.name, "My Channel"); + assert_eq!(chat.typ, Chattype::InBroadcast); + + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn test_create_for_contact_with_blocked() -> Result<()> { let t = TestContext::new().await; @@ -3403,6 +3570,7 @@ async fn test_sync_muted() -> Result<()> { Ok(()) } +/// Tests that synchronizing broadcast channels via sync-messages works #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn test_sync_broadcast() -> Result<()> { let mut tcm = TestContextManager::new(); @@ -3414,7 +3582,7 @@ async fn test_sync_broadcast() -> Result<()> { let bob = &tcm.bob().await; let a0b_contact_id = alice0.add_or_lookup_contact(bob).await.id; - let a0_broadcast_id = create_broadcast_list(alice0).await?; + let a0_broadcast_id = create_broadcast(alice0, "Channel".to_string()).await?; sync(alice0, alice1).await; let a0_broadcast_chat = Chat::load_from_db(alice0, a0_broadcast_id).await?; let a1_broadcast_id = get_chat_id_by_grpid(alice1, &a0_broadcast_chat.grpid) @@ -3422,7 +3590,7 @@ async fn test_sync_broadcast() -> Result<()> { .unwrap() .0; let a1_broadcast_chat = Chat::load_from_db(alice1, a1_broadcast_id).await?; - assert_eq!(a1_broadcast_chat.get_type(), Chattype::Broadcast); + assert_eq!(a1_broadcast_chat.get_type(), Chattype::OutBroadcast); assert_eq!(a1_broadcast_chat.get_name(), a0_broadcast_chat.get_name()); assert!(get_chat_contacts(alice1, a1_broadcast_id).await?.is_empty()); add_contact_to_chat(alice0, a0_broadcast_id, a0b_contact_id).await?; @@ -3440,7 +3608,7 @@ async fn test_sync_broadcast() -> Result<()> { let sent_msg = alice1.send_text(a1_broadcast_id, "hi").await; let msg = bob.recv_msg(&sent_msg).await; let chat = Chat::load_from_db(bob, msg.chat_id).await?; - assert_eq!(chat.get_type(), Chattype::Mailinglist); + assert_eq!(chat.get_type(), Chattype::InBroadcast); let msg = alice0.recv_msg(&sent_msg).await; assert_eq!(msg.chat_id, a0_broadcast_id); remove_contact_from_chat(alice0, a0_broadcast_id, a0b_contact_id).await?; @@ -3465,18 +3633,18 @@ async fn test_sync_name() -> Result<()> { for a in [alice0, alice1] { a.set_config_bool(Config::SyncMsgs, true).await?; } - let a0_broadcast_id = create_broadcast_list(alice0).await?; + let a0_broadcast_id = create_broadcast(alice0, "Channel".to_string()).await?; sync(alice0, alice1).await; let a0_broadcast_chat = Chat::load_from_db(alice0, a0_broadcast_id).await?; - set_chat_name(alice0, a0_broadcast_id, "Broadcast list 42").await?; + set_chat_name(alice0, a0_broadcast_id, "Broadcast channel 42").await?; sync(alice0, alice1).await; let a1_broadcast_id = get_chat_id_by_grpid(alice1, &a0_broadcast_chat.grpid) .await? .unwrap() .0; let a1_broadcast_chat = Chat::load_from_db(alice1, a1_broadcast_id).await?; - assert_eq!(a1_broadcast_chat.get_type(), Chattype::Broadcast); - assert_eq!(a1_broadcast_chat.get_name(), "Broadcast list 42"); + assert_eq!(a1_broadcast_chat.get_type(), Chattype::OutBroadcast); + assert_eq!(a1_broadcast_chat.get_name(), "Broadcast channel 42"); Ok(()) } diff --git a/src/chatlist.rs b/src/chatlist.rs index 70e0a7b6f..8da78cf40 100644 --- a/src/chatlist.rs +++ b/src/chatlist.rs @@ -409,7 +409,8 @@ impl Chatlist { if lastmsg.from_id == ContactId::SELF { None } else if chat.typ == Chattype::Group - || chat.typ == Chattype::Broadcast + || chat.typ == Chattype::OutBroadcast + || chat.typ == Chattype::InBroadcast || chat.typ == Chattype::Mailinglist || chat.is_self_talk() { diff --git a/src/constants.rs b/src/constants.rs index 884b498dc..ae5ef022b 100644 --- a/src/constants.rs +++ b/src/constants.rs @@ -126,17 +126,46 @@ pub const DC_CHAT_ID_LAST_SPECIAL: ChatId = ChatId::new(9); )] #[repr(u32)] pub enum Chattype { - /// 1:1 chat. + /// A 1:1 chat, i.e. a normal chat with a single contact. + /// + /// Created by [`ChatId::create_for_contact`]. Single = 100, /// Group chat. + /// + /// Created by [`crate::chat::create_group_chat`]. Group = 120, - /// Mailing list. + /// An (unencrypted) mailing list, + /// created by an incoming mailing list email. Mailinglist = 140, - /// Broadcast list. - Broadcast = 160, + /// Outgoing broadcast channel, called "Channel" in the UI. + /// + /// The user can send into this chat, + /// and all recipients will receive messages + /// in an `InBroadcast`. + /// + /// Called `broadcast` here rather than `channel`, + /// because the word "channel" already appears a lot in the code, + /// which would make it hard to grep for it. + /// + /// Created by [`crate::chat::create_broadcast`]. + OutBroadcast = 160, + + /// Incoming broadcast channel, called "Channel" in the UI. + /// + /// This chat is read-only, + /// and we do not know who the other recipients are. + /// + /// This is similar to a `MailingList`, + /// with the main difference being that + /// `InBroadcast`s are encrypted. + /// + /// Called `broadcast` here rather than `channel`, + /// because the word "channel" already appears a lot in the code, + /// which would make it hard to grep for it. + InBroadcast = 165, } pub const DC_MSG_ID_DAYMARKER: u32 = 9; @@ -239,7 +268,7 @@ mod tests { assert_eq!(Chattype::Single, Chattype::from_i32(100).unwrap()); assert_eq!(Chattype::Group, Chattype::from_i32(120).unwrap()); assert_eq!(Chattype::Mailinglist, Chattype::from_i32(140).unwrap()); - assert_eq!(Chattype::Broadcast, Chattype::from_i32(160).unwrap()); + assert_eq!(Chattype::OutBroadcast, Chattype::from_i32(160).unwrap()); } #[test] diff --git a/src/contact.rs b/src/contact.rs index c6d8591a3..fe5618d25 100644 --- a/src/contact.rs +++ b/src/contact.rs @@ -1179,7 +1179,7 @@ impl Contact { Ok(ret) } - /// Adds blocked mailinglists as contacts + /// Adds blocked mailinglists and broadcast channels as pseudo-contacts /// to allow unblocking them as if they are contacts /// (this way, only one unblock-ffi is needed and only one set of ui-functions, /// from the users perspective, @@ -1188,15 +1188,20 @@ impl Contact { context .sql .transaction(move |transaction| { - let mut stmt = transaction - .prepare("SELECT name, grpid FROM chats WHERE type=? AND blocked=?")?; - let rows = stmt.query_map((Chattype::Mailinglist, Blocked::Yes), |row| { - let name: String = row.get(0)?; - let grpid: String = row.get(1)?; - Ok((name, grpid)) - })?; + let mut stmt = transaction.prepare( + "SELECT name, grpid, type FROM chats WHERE (type=? OR type=?) AND blocked=?", + )?; + let rows = stmt.query_map( + (Chattype::Mailinglist, Chattype::InBroadcast, Blocked::Yes), + |row| { + let name: String = row.get(0)?; + let grpid: String = row.get(1)?; + let typ: Chattype = row.get(2)?; + Ok((name, grpid, typ)) + }, + )?; let blocked_mailinglists = rows.collect::, _>>()?; - for (name, grpid) in blocked_mailinglists { + for (name, grpid, typ) in blocked_mailinglists { let count = transaction.query_row( "SELECT COUNT(id) FROM contacts WHERE addr=?", [&grpid], @@ -1209,10 +1214,17 @@ impl Contact { transaction.execute("INSERT INTO contacts (addr) VALUES (?)", [&grpid])?; } + let fingerprint = if typ == Chattype::InBroadcast { + // Set some fingerprint so that is_pgp_contact() returns true, + // and the contact isn't marked with a letter icon. + "Blocked_broadcast" + } else { + "" + }; // Always do an update in case the blocking is reset or name is changed. transaction.execute( - "UPDATE contacts SET name=?, origin=?, blocked=1 WHERE addr=?", - (&name, Origin::MailinglistAddress, &grpid), + "UPDATE contacts SET name=?, origin=?, blocked=1, fingerprint=? WHERE addr=?", + (&name, Origin::MailinglistAddress, fingerprint, &grpid), )?; } Ok(()) diff --git a/src/events/chatlist_events.rs b/src/events/chatlist_events.rs index 840c5d5df..87af72942 100644 --- a/src/events/chatlist_events.rs +++ b/src/events/chatlist_events.rs @@ -66,7 +66,7 @@ mod test_chatlist_events { use crate::{ EventType, chat::{ - self, ChatId, ChatVisibility, MuteDuration, ProtectionStatus, create_broadcast_list, + self, ChatId, ChatVisibility, MuteDuration, ProtectionStatus, create_broadcast, create_group_chat, set_muted, }, config::Config, @@ -308,13 +308,13 @@ mod test_chatlist_events { Ok(()) } - /// Create broadcastlist + /// Create broadcast channel #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn test_create_broadcastlist() -> Result<()> { + async fn test_create_broadcast() -> Result<()> { let mut tcm = TestContextManager::new(); let alice = tcm.alice().await; alice.evtracker.clear_events(); - create_broadcast_list(&alice).await?; + create_broadcast(&alice, "Channel".to_string()).await?; wait_for_chatlist(&alice).await; Ok(()) } diff --git a/src/message.rs b/src/message.rs index 5cbb42e68..e80177183 100644 --- a/src/message.rs +++ b/src/message.rs @@ -855,9 +855,10 @@ impl Message { let contact = if self.from_id != ContactId::SELF { match chat.typ { - Chattype::Group | Chattype::Broadcast | Chattype::Mailinglist => { - Some(Contact::get_by_id(context, self.from_id).await?) - } + Chattype::Group + | Chattype::OutBroadcast + | Chattype::InBroadcast + | Chattype::Mailinglist => Some(Contact::get_by_id(context, self.from_id).await?), Chattype::Single => None, } } else { diff --git a/src/mimefactory.rs b/src/mimefactory.rs index f31c6c0c3..4aa967668 100644 --- a/src/mimefactory.rs +++ b/src/mimefactory.rs @@ -80,7 +80,7 @@ pub struct MimeFactory { /// because in case of "member removed" message /// removed member is in the recipient list, /// but not in the `To` header. - /// In case of broadcast lists there are multiple recipients, + /// In case of broadcast channels there are multiple recipients, /// but the `To` header has no members. /// /// If `bcc_self` configuration is enabled, @@ -98,7 +98,7 @@ pub struct MimeFactory { /// Vector of pairs of recipient name and address that goes into the `To` field. /// /// The list of actual message recipient addresses may be different, - /// e.g. if members are hidden for broadcast lists + /// e.g. if members are hidden for broadcast channels /// or if the keys for some recipients are missing /// and encrypted message cannot be sent to them. to: Vec<(String, String)>, @@ -178,7 +178,7 @@ impl MimeFactory { let now = time(); let chat = Chat::load_from_db(context, msg.chat_id).await?; let attach_profile_data = Self::should_attach_profile_data(&msg); - let undisclosed_recipients = chat.typ == Chattype::Broadcast; + let undisclosed_recipients = chat.typ == Chattype::OutBroadcast; let from_addr = context.get_primary_self_addr().await?; let config_displayname = context @@ -599,7 +599,7 @@ impl MimeFactory { return Ok(msg.subject.clone()); } - if (chat.typ == Chattype::Group || chat.typ == Chattype::Broadcast) + if (chat.typ == Chattype::Group || chat.typ == Chattype::OutBroadcast) && quoted_msg_subject.is_none_or_empty() { let re = if self.in_reply_to.is_empty() { @@ -791,7 +791,7 @@ impl MimeFactory { } if let Loaded::Message { chat, .. } = &self.loaded { - if chat.typ == Chattype::Broadcast { + if chat.typ == Chattype::OutBroadcast { headers.push(( "List-ID", mail_builder::headers::text::Text::new(format!( @@ -1035,7 +1035,7 @@ impl MimeFactory { match &self.loaded { Loaded::Message { chat, msg } => { - if chat.typ != Chattype::Broadcast { + if chat.typ != Chattype::OutBroadcast { for (addr, key) in &encryption_keys { let fingerprint = key.dc_fingerprint().hex(); let cmd = msg.param.get_cmd(); @@ -1300,9 +1300,9 @@ impl MimeFactory { let send_verified_headers = match chat.typ { Chattype::Single => true, Chattype::Group => true, - // Mailinglists and broadcast lists can actually never be verified: + // Mailinglists and broadcast channels can actually never be verified: Chattype::Mailinglist => false, - Chattype::Broadcast => false, + Chattype::OutBroadcast | Chattype::InBroadcast => false, }; if chat.is_protected() && send_verified_headers { headers.push(( @@ -1311,7 +1311,7 @@ impl MimeFactory { )); } - if chat.typ == Chattype::Group { + if chat.typ == Chattype::Group || chat.typ == Chattype::OutBroadcast { // Send group ID unless it is an ad hoc group that has no ID. if !chat.grpid.is_empty() { headers.push(( diff --git a/src/receive_imf.rs b/src/receive_imf.rs index c6a517429..86d1577ac 100644 --- a/src/receive_imf.rs +++ b/src/receive_imf.rs @@ -4,7 +4,7 @@ use std::collections::{HashMap, HashSet}; use std::iter; use std::sync::LazyLock; -use anyhow::{Context as _, Result}; +use anyhow::{Context as _, Result, ensure}; use data_encoding::BASE32_NOPAD; use deltachat_contact_tools::{ ContactAddress, addr_cmp, addr_normalize, may_be_valid_addr, sanitize_single_line, @@ -93,10 +93,10 @@ enum ChatAssignment { /// assign to encrypted group. GroupChat { grpid: String }, - /// Mailing list or broadcast list. + /// Mailing list or broadcast channel. /// /// Mailing lists don't have members. - /// Broadcast lists have members + /// Broadcast channels have members /// on the sender side, /// but their addresses don't go into /// the `To` field. @@ -107,7 +107,7 @@ enum ChatAssignment { /// up except the `from_id` /// which may be an email address contact /// or a key-contact. - MailingList, + MailingListOrBroadcast, /// Group chat without a Group ID. /// @@ -261,7 +261,7 @@ async fn get_to_and_past_contact_ids( None } ChatAssignment::ExistingChat { chat_id, .. } => Some(*chat_id), - ChatAssignment::MailingList => None, + ChatAssignment::MailingListOrBroadcast => None, ChatAssignment::OneOneChat => { if is_partial_download.is_none() && !mime_parser.incoming { parent_message.as_ref().map(|m| m.chat_id) @@ -326,7 +326,7 @@ async fn get_to_and_past_contact_ids( .await?; } } - ChatAssignment::Trash | ChatAssignment::MailingList => { + ChatAssignment::Trash | ChatAssignment::MailingListOrBroadcast => { to_ids = Vec::new(); past_ids = Vec::new(); } @@ -597,8 +597,8 @@ pub(crate) async fn receive_imf_inner( return Ok(None); }; - let prevent_rename = - mime_parser.is_mailinglist_message() || mime_parser.get_header(HeaderDef::Sender).is_some(); + let prevent_rename = (mime_parser.is_mailinglist_message() && !mime_parser.was_encrypted()) + || mime_parser.get_header(HeaderDef::Sender).is_some(); // get From: (it can be an address list!) and check if it is known (for known From:'s we add // the other To:/Cc: in the 3rd pass) @@ -1201,6 +1201,8 @@ async fn decide_chat_assignment( let chat_assignment = if should_trash { ChatAssignment::Trash + } else if mime_parser.get_mailinglist_header().is_some() { + ChatAssignment::MailingListOrBroadcast } else if let Some(grpid) = mime_parser.get_chat_group_id() { if mime_parser.was_encrypted() { ChatAssignment::GroupChat { @@ -1228,8 +1230,6 @@ async fn decide_chat_assignment( // Group ID is ignored, however. ChatAssignment::AdHocGroup } - } else if mime_parser.get_mailinglist_header().is_some() { - ChatAssignment::MailingList } else if let Some(parent) = &parent_message { if let Some((chat_id, chat_id_blocked)) = lookup_chat_by_reply(context, mime_parser, parent, is_partial_download).await? @@ -1333,15 +1333,17 @@ async fn do_chat_assignment( } } } - ChatAssignment::MailingList => { + ChatAssignment::MailingListOrBroadcast => { if let Some(mailinglist_header) = mime_parser.get_mailinglist_header() { - if let Some((new_chat_id, new_chat_id_blocked)) = create_or_lookup_mailinglist( - context, - allow_creation, - mailinglist_header, - mime_parser, - ) - .await? + if let Some((new_chat_id, new_chat_id_blocked)) = + create_or_lookup_mailinglist_or_broadcast( + context, + allow_creation, + mailinglist_header, + from_id, + mime_parser, + ) + .await? { chat_id = Some(new_chat_id); chat_id_blocked = new_chat_id_blocked; @@ -1380,7 +1382,7 @@ async fn do_chat_assignment( // unblock the chat if chat_id_blocked != Blocked::Not && create_blocked != Blocked::Yes - && !matches!(chat_assignment, ChatAssignment::MailingList) + && !matches!(chat_assignment, ChatAssignment::MailingListOrBroadcast) { if let Some(chat_id) = chat_id { chat_id.set_blocked(context, create_blocked).await?; @@ -1510,8 +1512,9 @@ async fn do_chat_assignment( chat_id = Some(*new_chat_id); chat_id_blocked = *new_chat_id_blocked; } - ChatAssignment::MailingList => { - // Check if the message belongs to a broadcast list. + ChatAssignment::MailingListOrBroadcast => { + // Check if the message belongs to a broadcast channel + // (it can't be a mailing list, since it's outgoing) if let Some(mailinglist_header) = mime_parser.get_mailinglist_header() { let listid = mailinglist_header_listid(mailinglist_header)?; chat_id = Some( @@ -1521,7 +1524,7 @@ async fn do_chat_assignment( } else { let name = compute_mailinglist_name(mailinglist_header, &listid, mime_parser); - chat::create_broadcast_list_ex(context, Nosync, listid, name).await? + chat::create_broadcast_ex(context, Nosync, listid, name).await? }, ); } @@ -1662,16 +1665,28 @@ async fn add_parts( let is_location_kml = mime_parser.location_kml.is_some(); let is_mdn = !mime_parser.mdn_reports.is_empty(); - let mut group_changes = apply_group_changes( - context, - mime_parser, - chat_id, - from_id, - to_ids, - past_ids, - &verified_encryption, - ) - .await?; + let mut chat = Chat::load_from_db(context, chat_id).await?; + let mut group_changes = match chat.typ { + _ if chat.id.is_special() => GroupChangesInfo::default(), + Chattype::Single => GroupChangesInfo::default(), + Chattype::Mailinglist => GroupChangesInfo::default(), + Chattype::OutBroadcast => GroupChangesInfo::default(), + Chattype::Group => { + apply_group_changes( + context, + mime_parser, + &mut chat, + from_id, + to_ids, + past_ids, + &verified_encryption, + ) + .await? + } + Chattype::InBroadcast => { + apply_broadcast_changes(context, mime_parser, &mut chat, from_id).await? + } + }; let rfc724_mid_orig = &mime_parser .get_rfc724_mid() @@ -2771,21 +2786,15 @@ struct GroupChangesInfo { async fn apply_group_changes( context: &Context, mime_parser: &mut MimeMessage, - chat_id: ChatId, + chat: &mut Chat, from_id: ContactId, to_ids: &[Option], past_ids: &[Option], verified_encryption: &VerifiedEncryption, ) -> Result { let to_ids_flat: Vec = to_ids.iter().filter_map(|x| *x).collect(); - if chat_id.is_special() { - // Do not apply group changes to the trash chat. - return Ok(GroupChangesInfo::default()); - } - let mut chat = Chat::load_from_db(context, chat_id).await?; - if chat.typ != Chattype::Group { - return Ok(GroupChangesInfo::default()); - } + ensure!(chat.typ == Chattype::Group); + ensure!(!chat.id.is_special()); let mut send_event_chat_modified = false; let (mut removed_id, mut added_id) = (None, None); @@ -2801,7 +2810,7 @@ async fn apply_group_changes( }; let chat_contacts = - HashSet::::from_iter(chat::get_chat_contacts(context, chat_id).await?); + HashSet::::from_iter(chat::get_chat_contacts(context, chat.id).await?); let is_from_in_chat = !chat_contacts.contains(&ContactId::SELF) || chat_contacts.contains(&from_id); @@ -2814,11 +2823,12 @@ async fn apply_group_changes( } else { warn!( context, - "Not marking chat {chat_id} as protected due to verification problem: {err:#}." + "Not marking chat {} as protected due to verification problem: {err:#}.", + chat.id ); } } else if !chat.is_protected() { - chat_id + chat.id .set_protection( context, ProtectionStatus::Protected, @@ -2838,7 +2848,7 @@ async fn apply_group_changes( // rather than old display name. // This could be fixed by looking up the contact with the highest // `remove_timestamp` after applying Chat-Group-Member-Timestamps. - removed_id = lookup_key_contact_by_address(context, removed_addr, Some(chat_id)).await?; + removed_id = lookup_key_contact_by_address(context, removed_addr, Some(chat.id)).await?; if let Some(id) = removed_id { better_msg = if id == from_id { silent = true; @@ -2872,70 +2882,15 @@ async fn apply_group_changes( } } - let group_name_timestamp = mime_parser - .get_header(HeaderDef::ChatGroupNameTimestamp) - .and_then(|s| s.parse::().ok()); - if let Some(old_name) = mime_parser - .get_header(HeaderDef::ChatGroupNameChanged) - .map(|s| s.trim()) - .or(match group_name_timestamp { - Some(0) => None, - Some(_) => Some(chat.name.as_str()), - None => None, - }) - { - if let Some(grpname) = mime_parser - .get_header(HeaderDef::ChatGroupName) - .map(|grpname| grpname.trim()) - .filter(|grpname| grpname.len() < 200) - { - let grpname = &sanitize_single_line(grpname); - - let chat_group_name_timestamp = - chat.param.get_i64(Param::GroupNameTimestamp).unwrap_or(0); - let group_name_timestamp = group_name_timestamp.unwrap_or(mime_parser.timestamp_sent); - // To provide group name consistency, compare names if timestamps are equal. - if (chat_group_name_timestamp, grpname) < (group_name_timestamp, &chat.name) - && chat_id - .update_timestamp(context, Param::GroupNameTimestamp, group_name_timestamp) - .await? - && grpname != &chat.name - { - info!(context, "Updating grpname for chat {chat_id}."); - context - .sql - .execute("UPDATE chats SET name=? WHERE id=?;", (grpname, chat_id)) - .await?; - send_event_chat_modified = true; - } - if mime_parser - .get_header(HeaderDef::ChatGroupNameChanged) - .is_some() - { - let old_name = &sanitize_single_line(old_name); - better_msg.get_or_insert( - stock_str::msg_grp_name(context, old_name, grpname, from_id).await, - ); - } - } - } - - if let (Some(value), None) = (mime_parser.get_header(HeaderDef::ChatContent), &better_msg) { - if value == "group-avatar-changed" { - if let Some(avatar_action) = &mime_parser.group_avatar { - // this is just an explicit message containing the group-avatar, - // apart from that, the group-avatar is send along with various other messages - better_msg = match avatar_action { - AvatarAction::Delete => { - Some(stock_str::msg_grp_img_deleted(context, from_id).await) - } - AvatarAction::Change(_) => { - Some(stock_str::msg_grp_img_changed(context, from_id).await) - } - }; - } - } - } + apply_chat_name_and_avatar_changes( + context, + mime_parser, + from_id, + chat, + &mut send_event_chat_modified, + &mut better_msg, + ) + .await?; if is_from_in_chat { if chat.member_list_is_stale(context).await? { @@ -2954,7 +2909,7 @@ async fn apply_group_changes( transaction.execute( "DELETE FROM chats_contacts WHERE chat_id=?", - (chat_id,), + (chat.id,), )?; // Insert contacts with default timestamps of 0. @@ -2963,7 +2918,7 @@ async fn apply_group_changes( VALUES (?, ?)", )?; for contact_id in &new_members { - statement.execute((chat_id, contact_id))?; + statement.execute((chat.id, contact_id))?; } Ok(()) @@ -2975,7 +2930,7 @@ async fn apply_group_changes( { send_event_chat_modified |= update_chats_contacts_timestamps( context, - chat_id, + chat.id, Some(from_id), to_ids, past_ids, @@ -3015,7 +2970,7 @@ async fn apply_group_changes( chat::update_chat_contacts_table( context, mime_parser.timestamp_sent, - chat_id, + chat.id, &new_members, ) .await?; @@ -3023,7 +2978,7 @@ async fn apply_group_changes( } } - chat_id + chat.id .update_timestamp( context, Param::MemberListTimestamp, @@ -3033,7 +2988,7 @@ async fn apply_group_changes( } let new_chat_contacts = HashSet::::from_iter( - chat::get_chat_contacts(context, chat_id) + chat::get_chat_contacts(context, chat.id) .await? .iter() .copied(), @@ -3063,43 +3018,12 @@ async fn apply_group_changes( let group_changes_msgs = if self_added { Vec::new() } else { - group_changes_msgs(context, &added_ids, &removed_ids, chat_id).await? + group_changes_msgs(context, &added_ids, &removed_ids, chat.id).await? }; - if let Some(avatar_action) = &mime_parser.group_avatar { - if !new_chat_contacts.contains(&ContactId::SELF) { - warn!( - context, - "Received group avatar update for group chat {chat_id} we are not a member of." - ); - } else if !new_chat_contacts.contains(&from_id) { - warn!( - context, - "Contact {from_id} attempts to modify group chat {chat_id} avatar without being a member.", - ); - } else { - info!(context, "Group-avatar change for {chat_id}."); - if chat - .param - .update_timestamp(Param::AvatarTimestamp, mime_parser.timestamp_sent)? - { - match avatar_action { - AvatarAction::Change(profile_image) => { - chat.param.set(Param::ProfileImage, profile_image); - } - AvatarAction::Delete => { - chat.param.remove(Param::ProfileImage); - } - }; - chat.update_param(context).await?; - send_event_chat_modified = true; - } - } - } - if send_event_chat_modified { - context.emit_event(EventType::ChatModified(chat_id)); - chatlist_events::emit_chatlist_item_changed(context, chat_id); + context.emit_event(EventType::ChatModified(chat.id)); + chatlist_events::emit_chatlist_item_changed(context, chat.id); } Ok(GroupChangesInfo { better_msg, @@ -3113,6 +3037,109 @@ async fn apply_group_changes( }) } +/// Applies incoming changes to the group's or broadcast channel's name and avatar. +/// +/// - `send_event_chat_modified` is set to `true` if ChatModified event should be sent +/// - `better_msg` is filled with an info message about name change, if necessary +async fn apply_chat_name_and_avatar_changes( + context: &Context, + mime_parser: &MimeMessage, + from_id: ContactId, + chat: &mut Chat, + send_event_chat_modified: &mut bool, + better_msg: &mut Option, +) -> Result<()> { + // ========== Apply chat name changes ========== + + let group_name_timestamp = mime_parser + .get_header(HeaderDef::ChatGroupNameTimestamp) + .and_then(|s| s.parse::().ok()); + + if let Some(old_name) = mime_parser + .get_header(HeaderDef::ChatGroupNameChanged) + .map(|s| s.trim()) + .or(match group_name_timestamp { + Some(0) => None, + Some(_) => Some(chat.name.as_str()), + None => None, + }) + { + if let Some(grpname) = mime_parser + .get_header(HeaderDef::ChatGroupName) + .map(|grpname| grpname.trim()) + .filter(|grpname| grpname.len() < 200) + { + let grpname = &sanitize_single_line(grpname); + + let chat_group_name_timestamp = + chat.param.get_i64(Param::GroupNameTimestamp).unwrap_or(0); + let group_name_timestamp = group_name_timestamp.unwrap_or(mime_parser.timestamp_sent); + // To provide group name consistency, compare names if timestamps are equal. + if (chat_group_name_timestamp, grpname) < (group_name_timestamp, &chat.name) + && chat + .id + .update_timestamp(context, Param::GroupNameTimestamp, group_name_timestamp) + .await? + && grpname != &chat.name + { + info!(context, "Updating grpname for chat {}.", chat.id); + context + .sql + .execute("UPDATE chats SET name=? WHERE id=?;", (grpname, chat.id)) + .await?; + *send_event_chat_modified = true; + } + if mime_parser + .get_header(HeaderDef::ChatGroupNameChanged) + .is_some() + { + let old_name = &sanitize_single_line(old_name); + better_msg.get_or_insert( + stock_str::msg_grp_name(context, old_name, grpname, from_id).await, + ); + } + } + } + + // ========== Apply chat avatar changes ========== + + if let (Some(value), None) = (mime_parser.get_header(HeaderDef::ChatContent), &better_msg) { + if value == "group-avatar-changed" { + if let Some(avatar_action) = &mime_parser.group_avatar { + // this is just an explicit message containing the group-avatar, + // apart from that, the group-avatar is send along with various other messages + better_msg.get_or_insert(match avatar_action { + AvatarAction::Delete => stock_str::msg_grp_img_deleted(context, from_id).await, + AvatarAction::Change(_) => { + stock_str::msg_grp_img_changed(context, from_id).await + } + }); + } + } + } + + if let Some(avatar_action) = &mime_parser.group_avatar { + info!(context, "Group-avatar change for {}.", chat.id); + if chat + .param + .update_timestamp(Param::AvatarTimestamp, mime_parser.timestamp_sent)? + { + match avatar_action { + AvatarAction::Change(profile_image) => { + chat.param.set(Param::ProfileImage, profile_image); + } + AvatarAction::Delete => { + chat.param.remove(Param::ProfileImage); + } + }; + chat.update_param(context).await?; + *send_event_chat_modified = true; + } + } + + Ok(()) +} + /// Returns a list of strings that should be shown as info messages, informing about group membership changes. async fn group_changes_msgs( context: &Context, @@ -3165,7 +3192,7 @@ fn mailinglist_header_listid(list_id_header: &str) -> Result { .to_string()) } -/// Create or lookup a mailing list chat. +/// Create or lookup a mailing list or incoming broadcast channel chat. /// /// `list_id_header` contains the Id that must be used for the mailing list /// and has the form `Name `, `` or just `Id`. @@ -3174,10 +3201,11 @@ fn mailinglist_header_listid(list_id_header: &str) -> Result { /// /// `mime_parser` is the corresponding message /// and is used to figure out the mailing list name from different header fields. -async fn create_or_lookup_mailinglist( +async fn create_or_lookup_mailinglist_or_broadcast( context: &Context, allow_creation: bool, list_id_header: &str, + from_id: ContactId, mime_parser: &MimeMessage, ) -> Result> { let listid = mailinglist_header_listid(list_id_header)?; @@ -3186,7 +3214,19 @@ async fn create_or_lookup_mailinglist( return Ok(Some((chat_id, blocked))); } - let name = compute_mailinglist_name(list_id_header, &listid, mime_parser); + let chattype = if mime_parser.was_encrypted() { + Chattype::InBroadcast + } else { + Chattype::Mailinglist + }; + + let name = if chattype == Chattype::InBroadcast { + mime_parser + .get_header(HeaderDef::ChatGroupName) + .unwrap_or("Broadcast Channel") + } else { + &compute_mailinglist_name(list_id_header, &listid, mime_parser) + }; if allow_creation { // list does not exist but should be created @@ -3204,9 +3244,9 @@ async fn create_or_lookup_mailinglist( }; let chat_id = ChatId::create_multiuser_record( context, - Chattype::Mailinglist, + chattype, &listid, - &name, + name, blocked, ProtectionStatus::Unprotected, param, @@ -3227,6 +3267,15 @@ async fn create_or_lookup_mailinglist( &[ContactId::SELF], ) .await?; + if chattype == Chattype::InBroadcast { + chat::add_to_chat_contacts_table( + context, + mime_parser.timestamp_sent, + chat_id, + &[from_id], + ) + .await?; + } Ok(Some((chat_id, blocked))) } else { info!(context, "Creating list forbidden by caller."); @@ -3372,6 +3421,39 @@ async fn apply_mailinglist_changes( Ok(()) } +async fn apply_broadcast_changes( + context: &Context, + mime_parser: &MimeMessage, + chat: &mut Chat, + from_id: ContactId, +) -> Result { + ensure!(chat.typ == Chattype::InBroadcast); + + let mut send_event_chat_modified = false; + let mut better_msg = None; + + apply_chat_name_and_avatar_changes( + context, + mime_parser, + from_id, + chat, + &mut send_event_chat_modified, + &mut better_msg, + ) + .await?; + + if send_event_chat_modified { + context.emit_event(EventType::ChatModified(chat.id)); + chatlist_events::emit_chatlist_item_changed(context, chat.id); + } + Ok(GroupChangesInfo { + better_msg, + added_removed_id: None, + silent: false, + extra_msgs: vec![], + }) +} + /// Creates ad-hoc group and returns chat ID on success. async fn create_adhoc_group( context: &Context, diff --git a/src/sql/migrations.rs b/src/sql/migrations.rs index 1cb06297d..76ea8ee69 100644 --- a/src/sql/migrations.rs +++ b/src/sql/migrations.rs @@ -1711,7 +1711,7 @@ fn migrate_key_contacts( continue; } - // Broadcast list + // Broadcast channel 160 => old_members .iter() .map(|(original, _)| { diff --git a/src/stock_str.rs b/src/stock_str.rs index 461436a1b..26a5d17dd 100644 --- a/src/stock_str.rs +++ b/src/stock_str.rs @@ -237,9 +237,6 @@ pub enum StockMessage { #[strum(props(fallback = "Messages"))] Messages = 114, - #[strum(props(fallback = "Broadcast List"))] - BroadcastList = 115, - #[strum(props(fallback = "%1$s of %2$s used"))] PartOfTotallUsed = 116, @@ -1221,12 +1218,6 @@ pub(crate) async fn part_of_total_used(context: &Context, part: &str, total: &st .replace2(total) } -/// Stock string: `Broadcast List`. -/// Used as the default name for broadcast lists; a number may be added. -pub(crate) async fn broadcast_list(context: &Context) -> String { - translated(context, StockMessage::BroadcastList).await -} - /// Stock string: `⚠️ Your email provider %1$s requires end-to-end encryption which is not setup yet. Tap to learn more.`. pub(crate) async fn unencrypted_email(context: &Context, provider: &str) -> String { translated(context, StockMessage::InvalidUnencryptedMail) diff --git a/src/summary.rs b/src/summary.rs index c420e976e..6601e8982 100644 --- a/src/summary.rs +++ b/src/summary.rs @@ -103,7 +103,8 @@ impl Summary { Some(SummaryPrefix::Me(stock_str::self_msg(context).await)) } } else if chat.typ == Chattype::Group - || chat.typ == Chattype::Broadcast + || chat.typ == Chattype::OutBroadcast + || chat.typ == Chattype::InBroadcast || chat.typ == Chattype::Mailinglist || chat.is_self_talk() { diff --git a/src/test_utils.rs b/src/test_utils.rs index 755504ac7..c6da615d4 100644 --- a/src/test_utils.rs +++ b/src/test_utils.rs @@ -45,6 +45,15 @@ use crate::tools::time; #[allow(non_upper_case_globals)] pub const AVATAR_900x900_BYTES: &[u8] = include_bytes!("../test-data/image/avatar900x900.png"); +#[allow(non_upper_case_globals)] +pub const AVATAR_64x64_BYTES: &[u8] = include_bytes!("../test-data/image/avatar64x64.png"); + +/// The filename of [`AVATAR_64x64_BYTES`], +/// after it has been saved +/// by [`crate::blob::BlobObject::create_and_deduplicate`]. +#[allow(non_upper_case_globals)] +pub const AVATAR_64x64_DEDUPLICATED: &str = "e9b6c7a78aa2e4f415644f55a553e73.png"; + /// Map of context IDs to names for [`TestContext`]s. static CONTEXT_NAMES: LazyLock>> = LazyLock::new(|| std::sync::RwLock::new(BTreeMap::new())); @@ -1147,7 +1156,7 @@ impl Drop for InnerLogSink { #[derive(Debug, Clone)] pub struct SentMessage<'a> { pub payload: String, - recipients: String, + pub recipients: String, pub sender_msg_id: MsgId, sender_context: &'a Context, } diff --git a/src/webxdc/webxdc_tests.rs b/src/webxdc/webxdc_tests.rs index 597aebeb0..61fca71b8 100644 --- a/src/webxdc/webxdc_tests.rs +++ b/src/webxdc/webxdc_tests.rs @@ -5,7 +5,7 @@ use serde_json::json; use super::*; use crate::chat::{ - ChatId, ProtectionStatus, add_contact_to_chat, create_broadcast_list, create_group_chat, + ChatId, ProtectionStatus, add_contact_to_chat, create_broadcast, create_group_chat, forward_msgs, remove_contact_from_chat, resend_msgs, send_msg, send_text_msg, }; use crate::chatlist::Chatlist; @@ -1621,7 +1621,7 @@ async fn test_webxdc_no_internet_access() -> Result<()> { let self_id = t.get_self_chat().await.id; let single_id = t.create_chat_with_contact("bob", "bob@e.com").await.id; let group_id = create_group_chat(&t, ProtectionStatus::Unprotected, "chat").await?; - let broadcast_id = create_broadcast_list(&t).await?; + let broadcast_id = create_broadcast(&t, "Channel".to_string()).await?; for chat_id in [self_id, single_id, group_id, broadcast_id] { for internet_xdc in [true, false] {