diff --git a/assets/icon-broadcast.png b/assets/icon-broadcast.png new file mode 100644 index 000000000..aa761885e Binary files /dev/null and b/assets/icon-broadcast.png differ diff --git a/assets/icon-broadcast.svg b/assets/icon-broadcast.svg new file mode 100644 index 000000000..733e48c1d --- /dev/null +++ b/assets/icon-broadcast.svg @@ -0,0 +1,149 @@ + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/deltachat-ffi/deltachat.h b/deltachat-ffi/deltachat.h index 9848fc70d..7ea527518 100644 --- a/deltachat-ffi/deltachat.h +++ b/deltachat-ffi/deltachat.h @@ -1299,6 +1299,8 @@ void dc_accept_chat (dc_context_t* context, uint32_t ch * 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 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. * (we do not know all members and there is not always a global mailing list address, @@ -1408,6 +1410,36 @@ dc_chat_t* dc_get_chat (dc_context_t* context, uint32_t ch uint32_t dc_create_group_chat (dc_context_t* context, int protect, const char* name); +/** + * Create a new broadcast list. + * + * Broadcast lists are similar to groups on the sending device, + * however, recipients get the messages in normal one-to-one chats + * and will not be aware of other members. + * + * Replies to broadcasts go only to the sender + * and not to all broadcast recipients. + * Moreover, replies will not appear in the broadcast list + * but in the one-to-one chat with the person answering. + * + * The name and the image of the broadcast list is set automatically + * and is visible to the sender only. + * Not asking for these data allows more focused creation + * and we bypass the question who will get which data. + * Also, many users will have at most one broadcast list + * so, a generic name and image is sufficient at the first place. + * + * Later on, however, the name can be changed using dc_set_chat_name(). + * The image cannot be changed to have a unique, recognizable icon in the chat lists. + * All in all, this is also what other messengers are doing here. + * + * @memberof dc_context_t + * @param context The context object. + * @return The chat ID of the new broadcast list, 0 on errors. + */ +uint32_t dc_create_broadcast_list (dc_context_t* context); + + /** * Check if a given contact ID is a member of a group chat. * @@ -3044,6 +3076,11 @@ uint32_t dc_chat_get_id (const dc_chat_t* chat); * and cannot be changed using this api. * moreover, for now, mailist lists are read-only. * + * - @ref DC_CHAT_TYPE_BROADCAST - a broadcast list, + * the recipients will get messages in a one-to-one chats and + * the sender will get answers in a one-to-one as well. + * chats_contacts contain all recipients but DC_CONTACT_ID_SELF + * * @memberof dc_chat_t * @param chat The chat object. * @return Chat type. @@ -4744,6 +4781,11 @@ 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. + */ +#define DC_CHAT_TYPE_BROADCAST 160 + /** * @} */ @@ -5967,6 +6009,11 @@ void dc_event_unref(dc_event_t* event); /// Used as a subtitle in quota context; can be plural always. #define DC_STR_MESSAGES 114 +/// "Broadcast List" +/// +/// Used as the default name for broadcast lists; a number may be added. +#define DC_STR_BROADCAST_LIST 115 + /** * @} */ diff --git a/deltachat-ffi/src/lib.rs b/deltachat-ffi/src/lib.rs index e66c5da30..c000a174c 100644 --- a/deltachat-ffi/src/lib.rs +++ b/deltachat-ffi/src/lib.rs @@ -1330,6 +1330,19 @@ pub unsafe extern "C" fn dc_create_group_chat( }) } +#[no_mangle] +pub unsafe extern "C" fn dc_create_broadcast_list(context: *mut dc_context_t) -> u32 { + if context.is_null() { + eprintln!("ignoring careless call to dc_create_broadcast_list()"); + return 0; + } + let ctx = &*context; + block_on(chat::create_broadcast_list(ctx)) + .log_err(ctx, "Failed to create broadcast list") + .map(|id| id.to_u32()) + .unwrap_or(0) +} + #[no_mangle] pub unsafe extern "C" fn dc_is_contact_in_chat( context: *mut dc_context_t, diff --git a/examples/repl/cmdline.rs b/examples/repl/cmdline.rs index 23ea29b2b..4556e84db 100644 --- a/examples/repl/cmdline.rs +++ b/examples/repl/cmdline.rs @@ -370,6 +370,7 @@ pub async fn cmdline(context: Context, line: &str, chat_id: &mut ChatId) -> Resu chat [|0]\n\ createchat \n\ creategroup \n\ + createbroadcast\n\ createprotected \n\ addmember \n\ removemember \n\ @@ -714,6 +715,11 @@ pub async fn cmdline(context: Context, line: &str, chat_id: &mut ChatId) -> Resu println!("Group#{} created successfully.", chat_id); } + "createbroadcast" => { + let chat_id = chat::create_broadcast_list(&context).await?; + + println!("Broadcast#{} created successfully.", chat_id); + } "createprotected" => { ensure!(!arg1.is_empty(), "Argument missing."); let chat_id = diff --git a/examples/repl/main.rs b/examples/repl/main.rs index 6c7622419..da6f4e957 100644 --- a/examples/repl/main.rs +++ b/examples/repl/main.rs @@ -167,13 +167,14 @@ const DB_COMMANDS: [&str; 10] = [ "housekeeping", ]; -const CHAT_COMMANDS: [&str; 33] = [ +const CHAT_COMMANDS: [&str; 34] = [ "listchats", "listarchived", "chat", "createchat", "creategroup", - "createverified", + "createbroadcast", + "createprotected", "addmember", "removemember", "groupname", diff --git a/src/chat.rs b/src/chat.rs index 5e974633e..b3f2fcc5e 100644 --- a/src/chat.rs +++ b/src/chat.rs @@ -271,7 +271,9 @@ impl ChatId { let chat = Chat::load_from_db(context, self).await?; match chat.typ { - Chattype::Undefined => bail!("Can't block chat of undefined chattype"), + Chattype::Undefined | Chattype::Broadcast => { + bail!("Can't block chat of type {:?}", chat.typ) + } Chattype::Single => { for contact_id in get_chat_contacts(context, self).await? { if contact_id != DC_CONTACT_ID_SELF { @@ -311,7 +313,7 @@ impl ChatId { match chat.typ { Chattype::Undefined => bail!("Can't accept chat of undefined chattype"), - Chattype::Single | Chattype::Group => { + Chattype::Single | Chattype::Group | Chattype::Broadcast => { // User has "created a chat" with all these contacts. // // Previously accepting a chat literally created a chat because unaccepted chats @@ -355,7 +357,7 @@ impl ChatId { match protect { ProtectionStatus::Protected => match chat.typ { - Chattype::Single | Chattype::Group => { + Chattype::Single | Chattype::Group | Chattype::Broadcast => { let contact_ids = get_chat_contacts(context, self).await?; for contact_id in contact_ids.into_iter() { let contact = Contact::get_by_id(context, contact_id).await?; @@ -980,8 +982,18 @@ impl Chat { && !self.is_device_talk() && !self.is_mailing_list() && !self.is_contact_request() - && (self.typ == Chattype::Single - || is_contact_in_chat(context, self.id, DC_CONTACT_ID_SELF).await?)) + && self.is_self_in_chat(context).await?) + } + + /// Checks if the user is part of a chat + /// and has basically the permissions to edit the chat therefore. + /// 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::Group => is_contact_in_chat(context, self.id, DC_CONTACT_ID_SELF).await, + Chattype::Undefined => Ok(false), + } } pub async fn update_param(&mut self, context: &Context) -> Result<()> { @@ -1023,8 +1035,11 @@ impl Chat { return contact.get_profile_image(context).await; } } + } else if self.typ == Chattype::Broadcast { + if let Ok(image_rel) = get_broadcast_icon(context).await { + return Ok(Some(dc_get_abs_path(context, image_rel))); + } } - Ok(None) } @@ -1126,18 +1141,18 @@ impl Chat { let mut to_id = 0; let mut location_id = 0; - if !(self.typ == Chattype::Single || self.typ == Chattype::Group) { - error!(context, "Cannot send to chat type #{}.", self.typ,); - bail!("Cannot set to chat type #{}", self.typ); - } - - if self.typ == Chattype::Group - && !is_contact_in_chat(context, self.id, DC_CONTACT_ID_SELF).await? - { - context.emit_event(EventType::ErrorSelfNotInGroup( - "Cannot send message; self not in group.".into(), - )); - bail!("Cannot set message; self not in group."); + if !self.can_send(context).await? { + if self.typ == Chattype::Group + && !is_contact_in_chat(context, self.id, DC_CONTACT_ID_SELF).await? + { + context.emit_event(EventType::ErrorSelfNotInGroup( + "Cannot send message; self not in group.".into(), + )); + bail!("Cannot set message; self not in group."); + } else { + error!(context, "Cannot send to chat type #{}.", self.typ,); + bail!("Cannot send to chat type #{}", self.typ); + } } let from = context @@ -1461,6 +1476,21 @@ pub(crate) async fn update_device_icon(context: &Context) -> Result<()> { Ok(()) } +pub(crate) async fn get_broadcast_icon(context: &Context) -> Result { + if let Some(icon) = context.sql.get_raw_config("icon-broadcast").await? { + return Ok(icon); + } + + let icon = include_bytes!("../assets/icon-broadcast.png"); + let blob = BlobObject::create(context, "icon-broadcast.png", icon).await?; + let icon = blob.as_name().to_string(); + context + .sql + .set_raw_config("icon-broadcast", Some(&icon)) + .await?; + Ok(icon) +} + async fn update_special_chat_name(context: &Context, contact_id: u32, name: String) -> Result<()> { if let Some(chat_id) = ChatId::lookup_by_contact(context, contact_id).await? { // the `!= name` condition avoids unneeded writes @@ -2221,6 +2251,56 @@ 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=?;", + paramsv![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?; + let grpid = dc_create_id(); + let row_id = context + .sql + .insert( + "INSERT INTO chats + (type, name, grpid, param, created_timestamp) + VALUES(?, ?, ?, \'U=1\', ?);", + paramsv![ + Chattype::Broadcast, + chat_name, + grpid, + dc_create_smeared_timestamp(context).await, + ], + ) + .await?; + let chat_id = ChatId::new(u32::try_from(row_id)?); + + context.emit_event(EventType::MsgsChanged { + msg_id: MsgId::new(0), + chat_id: ChatId::new(0), + }); + Ok(chat_id) +} + /// Adds a contact to the `chats_contacts` table. pub(crate) async fn add_to_chat_contacts_table( context: &Context, @@ -2278,8 +2358,8 @@ pub(crate) async fn add_contact_to_chat_ex( /*this also makes sure, not contacts are added to special or normal chats*/ let mut chat = Chat::load_from_db(context, chat_id).await?; ensure!( - chat.typ == Chattype::Group, - "{} is not a group where one can add members", + chat.typ == Chattype::Group || chat.typ == Chattype::Broadcast, + "{} is not a group/broadcast where one can add members", chat_id ); ensure!( @@ -2288,14 +2368,18 @@ pub(crate) async fn add_contact_to_chat_ex( contact_id ); ensure!(!chat.is_mailing_list(), "Mailing lists can't be changed"); + ensure!( + chat.typ != Chattype::Broadcast || contact_id != DC_CONTACT_ID_SELF, + "Cannot add SELF to broadcast." + ); - if !is_contact_in_chat(context, chat_id, DC_CONTACT_ID_SELF).await? { - /* we should respect this - whatever we send to the group, it gets discarded anyway! */ + if !chat.is_self_in_chat(context).await? { context.emit_event(EventType::ErrorSelfNotInGroup( "Cannot add contact to group; self not in group.".into(), )); bail!("can not add contact because our account is not part of it"); } + if from_handshake && chat.param.get_int(Param::Unpromoted).unwrap_or_default() == 1 { chat.param.remove(Param::Unpromoted); chat.update_param(context).await?; @@ -2334,7 +2418,7 @@ pub(crate) async fn add_contact_to_chat_ex( } add_to_chat_contacts_table(context, chat_id, contact_id).await?; } - if chat.param.get_int(Param::Unpromoted).unwrap_or_default() == 0 { + if chat.typ == Chattype::Group && chat.is_promoted() { msg.viewtype = Viewtype::Text; msg.text = Some( @@ -2499,14 +2583,14 @@ pub async fn remove_contact_from_chat( /* we do not check if "contact_id" exists but just delete all records with the id from chats_contacts */ /* this allows to delete pending references to deleted contacts. Of course, this should _not_ happen. */ if let Ok(chat) = Chat::load_from_db(context, chat_id).await { - if chat.typ == Chattype::Group { - if !is_contact_in_chat(context, chat_id, DC_CONTACT_ID_SELF).await? { + if chat.typ == Chattype::Group || chat.typ == Chattype::Broadcast { + if !chat.is_self_in_chat(context).await? { context.emit_event(EventType::ErrorSelfNotInGroup( "Cannot remove contact from chat; self not in group.".into(), )); } else { if let Ok(contact) = Contact::get_by_id(context, contact_id).await { - if chat.is_promoted() { + if chat.typ == Chattype::Group && chat.is_promoted() { msg.viewtype = Viewtype::Text; if contact.id == DC_CONTACT_ID_SELF { set_group_explicitly_left(context, &chat.grpid).await?; @@ -2593,48 +2677,47 @@ pub async fn set_chat_name(context: &Context, chat_id: ChatId, new_name: &str) - let chat = Chat::load_from_db(context, chat_id).await?; let mut msg = Message::default(); - if chat.typ == Chattype::Group || chat.typ == Chattype::Mailinglist { + if chat.typ == Chattype::Group + || chat.typ == Chattype::Mailinglist + || chat.typ == Chattype::Broadcast + { if chat.name == new_name { success = true; - } else if !is_contact_in_chat(context, chat_id, DC_CONTACT_ID_SELF).await? { + } else if !chat.is_self_in_chat(context).await? { context.emit_event(EventType::ErrorSelfNotInGroup( "Cannot set chat name; self not in group".into(), )); } else { - /* we should respect this - whatever we send to the group, it gets discarded anyway! */ - if context + context .sql .execute( "UPDATE chats SET name=? WHERE id=?;", paramsv![new_name.to_string(), chat_id], ) - .await - .is_ok() - { - if chat.is_promoted() && !chat.is_mailing_list() { - msg.viewtype = Viewtype::Text; - msg.text = Some( - stock_str::msg_grp_name( - context, - &chat.name, - &new_name, - DC_CONTACT_ID_SELF as u32, - ) - .await, - ); - msg.param.set_cmd(SystemMessage::GroupNameChanged); - if !chat.name.is_empty() { - msg.param.set(Param::Arg, &chat.name); - } - msg.id = send_msg(context, chat_id, &mut msg).await?; - context.emit_event(EventType::MsgsChanged { - chat_id, - msg_id: msg.id, - }); + .await?; + if chat.is_promoted() && !chat.is_mailing_list() && chat.typ != Chattype::Broadcast { + msg.viewtype = Viewtype::Text; + msg.text = Some( + stock_str::msg_grp_name( + context, + &chat.name, + &new_name, + DC_CONTACT_ID_SELF as u32, + ) + .await, + ); + msg.param.set_cmd(SystemMessage::GroupNameChanged); + if !chat.name.is_empty() { + msg.param.set(Param::Arg, &chat.name); } - context.emit_event(EventType::ChatModified(chat_id)); - success = true; + msg.id = send_msg(context, chat_id, &mut msg).await?; + context.emit_event(EventType::MsgsChanged { + chat_id, + msg_id: msg.id, + }); } + context.emit_event(EventType::ChatModified(chat_id)); + success = true; } } @@ -4243,4 +4326,49 @@ mod tests { ); Ok(()) } + + #[async_std::test] + async fn test_broadcast() -> Result<()> { + // create two context, send two messages so both know the other + let alice = TestContext::new_alice().await; + let bob = TestContext::new_bob().await; + + let chat_alice = alice.create_chat(&bob).await; + send_text_msg(&alice, chat_alice.id, "hi!".to_string()).await?; + bob.recv_msg(&alice.pop_sent_msg().await).await; + + let chat_bob = bob.create_chat(&alice).await; + send_text_msg(&bob, chat_bob.id, "ho!".to_string()).await?; + alice.recv_msg(&bob.pop_sent_msg().await).await; + let msg = alice.get_last_msg().await; + assert!(msg.get_showpadlock()); + + // test broadcast list + let broadcast_id = create_broadcast_list(&alice).await?; + add_contact_to_chat( + &alice, + broadcast_id, + get_chat_contacts(&alice, chat_bob.id).await?.pop().unwrap(), + ) + .await?; + let chat = Chat::load_from_db(&alice, broadcast_id).await?; + assert_eq!(chat.typ, Chattype::Broadcast); + assert_eq!(chat.name, stock_str::broadcast_list(&alice).await); + assert!(!chat.is_self_talk()); + + send_text_msg(&alice, broadcast_id, "ola!".to_string()).await?; + let msg = alice.get_last_msg().await; + assert_eq!(msg.chat_id, chat.id); + + bob.recv_msg(&alice.pop_sent_msg().await).await; + let msg = bob.get_last_msg().await; + assert_eq!(msg.get_text(), Some("ola!".to_string())); + assert!(!msg.get_showpadlock()); // avoid leaking recipients in encryption data + let chat = Chat::load_from_db(&bob, msg.chat_id).await?; + assert_eq!(chat.typ, Chattype::Single); + assert_eq!(chat.id, chat_bob.id); + assert!(!chat.is_self_talk()); + + Ok(()) + } } diff --git a/src/chatlist.rs b/src/chatlist.rs index c33ff8df7..a8cd888e7 100644 --- a/src/chatlist.rs +++ b/src/chatlist.rs @@ -329,7 +329,7 @@ impl Chatlist { (Some(lastmsg), None) } else { match chat.typ { - Chattype::Group | Chattype::Mailinglist => { + Chattype::Group | Chattype::Broadcast | Chattype::Mailinglist => { let lastcontact = Contact::load_from_db(context, lastmsg.from_id).await?; (Some(lastmsg), Some(lastcontact)) } diff --git a/src/constants.rs b/src/constants.rs index 5f1e990ba..108351cc0 100644 --- a/src/constants.rs +++ b/src/constants.rs @@ -153,6 +153,7 @@ pub enum Chattype { Single = 100, Group = 120, Mailinglist = 140, + Broadcast = 160, } impl Default for Chattype { @@ -348,6 +349,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()); } #[test] diff --git a/src/message.rs b/src/message.rs index 3674cb969..c7ebeca5e 100644 --- a/src/message.rs +++ b/src/message.rs @@ -604,7 +604,7 @@ impl Message { let contact = if self.from_id != DC_CONTACT_ID_SELF { match chat.typ { - Chattype::Group | Chattype::Mailinglist => { + Chattype::Group | Chattype::Broadcast | Chattype::Mailinglist => { Some(Contact::get_by_id(context, self.from_id).await?) } Chattype::Single | Chattype::Undefined => None, @@ -1599,7 +1599,7 @@ async fn ndn_maybe_add_info_msg( chat_type: Chattype, ) -> Result<()> { match chat_type { - Chattype::Group => { + Chattype::Group | Chattype::Broadcast => { if let Some(failed_recipient) = &failed.failed_recipient { let contact_id = Contact::lookup_id_by_addr(context, failed_recipient, Origin::Unknown) diff --git a/src/mimefactory.rs b/src/mimefactory.rs index 79c1fb926..210de7eb0 100644 --- a/src/mimefactory.rs +++ b/src/mimefactory.rs @@ -316,6 +316,10 @@ impl<'a> MimeFactory<'a> { Loaded::Message { chat } => { if chat.is_protected() { false + } else if chat.typ == Chattype::Broadcast { + // encryption may disclose recipients; + // this is probably a worse issue than not opportunistically (!) encrypting + true } else { self.msg .param @@ -393,8 +397,6 @@ impl<'a> MimeFactory<'a> { } if chat.typ == Chattype::Group && quoted_msg_subject.is_none_or_empty() { - // If we have a `quoted_msg_subject`, we use the subject of the quoted message - // instead of the group name let re = if self.in_reply_to.is_empty() { "" } else { @@ -403,22 +405,22 @@ impl<'a> MimeFactory<'a> { return Ok(format!("{}{}", re, chat.name)); } - let parent_subject = if quoted_msg_subject.is_none_or_empty() { - chat.param.get(Param::LastSubject) - } else { - quoted_msg_subject.as_deref() - }; - - if let Some(last_subject) = parent_subject { - format!("Re: {}", remove_subject_prefix(last_subject)) - } else { - let self_name = match context.get_config(Config::Displayname).await? { - Some(name) => name, - None => context.get_config(Config::Addr).await?.unwrap_or_default(), + if chat.typ != Chattype::Broadcast { + let parent_subject = if quoted_msg_subject.is_none_or_empty() { + chat.param.get(Param::LastSubject) + } else { + quoted_msg_subject.as_deref() }; - - stock_str::subject_for_new_contact(context, self_name).await + if let Some(last_subject) = parent_subject { + return Ok(format!("Re: {}", remove_subject_prefix(last_subject))); + } } + + let self_name = match context.get_config(Config::Displayname).await? { + Some(name) => name, + None => context.get_config(Config::Addr).await?.unwrap_or_default(), + }; + stock_str::subject_for_new_contact(context, self_name).await } Loaded::Mdn { .. } => stock_str::read_rcpt(context).await, }; @@ -555,9 +557,21 @@ impl<'a> MimeFactory<'a> { render_rfc724_mid(&rfc724_mid), )); - headers - .unprotected - .push(Header::new_with_value("To".into(), to).unwrap()); + let undisclosed_recipients = match &self.loaded { + Loaded::Message { chat } => chat.typ == Chattype::Broadcast, + Loaded::Mdn { .. } => false, + }; + + if undisclosed_recipients { + headers + .unprotected + .push(Header::new("To".into(), "hidden-recipients: ;".to_string())); + } else { + headers + .unprotected + .push(Header::new_with_value("To".into(), to).unwrap()); + } + headers .unprotected .push(Header::new_with_value("From".into(), vec![from]).unwrap()); diff --git a/src/stock_str.rs b/src/stock_str.rs index 27fe437e3..e9fd1e621 100644 --- a/src/stock_str.rs +++ b/src/stock_str.rs @@ -311,6 +311,9 @@ pub enum StockMessage { #[strum(props(fallback = "Messages"))] Messages = 114, + + #[strum(props(fallback = "Broadcast List"))] + BroadcastList = 115, } impl StockMessage { @@ -985,6 +988,12 @@ pub(crate) async fn messages(context: &Context) -> String { translated(context, StockMessage::Messages).await } +/// 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 +} + impl Context { /// Set the stock string for the [StockMessage]. /// diff --git a/src/summary.rs b/src/summary.rs index e6fe559d3..885ff2a85 100644 --- a/src/summary.rs +++ b/src/summary.rs @@ -72,7 +72,7 @@ impl Summary { } } else { match chat.typ { - Chattype::Group | Chattype::Mailinglist => { + Chattype::Group | Chattype::Broadcast | Chattype::Mailinglist => { if msg.is_info() || contact.is_none() { None } else {