broadcasts (#2707)

* add Chattype::Broadcast and create_broadcast_list()

* do not disclose recipients for broadcasts

* allow sending/add-/remove-member for broadcast

* set broadcast subject same as for one-to-one chats

* broadcast-recipient-list does not include SELF

* use special icon for broadcast groups

* generate initial broadcast names

* make clippy happy

* send BCC message unencrypted to avoid unexpected disclosing; encryption is opportunistic anyway. if we have 'protected chats' at some point, we can think that over.

* reword 'To:'-group

* simplify can-send-check

* add broadcast tests

* tweak comments

* Update deltachat-ffi/deltachat.h

Co-authored-by: Hocuri <hocuri@gmx.de>

* change name of can_edit() to is_self_in_chat()

Co-authored-by: Hocuri <hocuri@gmx.de>
This commit is contained in:
bjoern
2021-09-30 13:56:05 +02:00
committed by GitHub
parent 30a3da97da
commit bcadd0cd5c
13 changed files with 449 additions and 80 deletions

View File

@@ -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<bool> {
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<String> {
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<String> {
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<ChatId> {
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(())
}
}

View File

@@ -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))
}

View File

@@ -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]

View File

@@ -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)

View File

@@ -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());

View File

@@ -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].
///

View File

@@ -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 {