feat: Show broadcast channels in their own, proper "Channel" chat (#6901)

Part of #6884 

----

- [x] Add new chat type `InBroadcastChannel` and `OutBroadcastChannel`
for incoming / outgoing channels, where the former is similar to a
`Mailinglist` and the latter is similar to a `Broadcast` (which is
removed)
- Consideration for naming: `InChannel`/`OutChannel` (without
"broadcast") would be shorter, but less greppable because we already
have a lot of occurences of `channel` in the code. Consistently calling
them `BcChannel`/`bc_channel` in the code would be both short and
greppable, but a bit arcane when reading it at first. Opinions are
welcome; if I hear none, I'll keep with `BroadcastChannel`.
- [x] api: Add create_broadcast_channel(), deprecate
create_broadcast_list() (or `create_channel()` / `create_bc_channel()`
if we decide to switch)
  - Adjust code comments to match the new behavior.
- [x] Ask Desktop developers what they use `is_broadcast` field for, and
whether it should be true for both outgoing & incoming channels (or look
it up myself)
- I added `is_out_broadcast_channel`, and deprecated `is_broadcast`, for
now
- [x] When the user changes the broadcast channel name, immediately show
this change on receiving devices
- [x] Allow to change brodacast channel avatar, and immediately apply it
on the receiving device
- [x] Make it possible to block InBroadcastChannel
- [x] Make it possible to set the avatar of an OutgoingChannel, and
apply it on the receiving side
- [x] DECIDE whether we still want to use the broadcast icon as the
default icon or whether we want to use the letter-in-a-circle
- We decided to use the letter-in-a-circle for now, because it's easier
to implement, and I need to stay in the time plan
- [x] chat.rs: Return an error if the user tries to modify a
`InBroadcastChannel`
- [x] Add automated regression tests
- [x] Grep for `broadcast` and see whether there is any other work I
need to do
- [x] Bug: Don't show `~` in front of the sender's same in broadcast
lists

----

Note that I removed the following guard:

```rust
        if !new_chat_contacts.contains(&ContactId::SELF) {
            warn!(
                context,
                "Received group avatar update for group chat {} we are not a member of.", chat.id
            );
        } else if !new_chat_contacts.contains(&from_id) {
            warn!(
                context,
                "Contact {from_id} attempts to modify group chat {} avatar without being a member.",
                chat.id,
            );
        } else [...]
```

i.e. with this change, non-members will be able to modify the avatar.
Things were slightly easier this way, and I think that this is in line
with non-members being able to modify the group name and memberlist
(they need to know the Group-Chat-Id, anyway), but I can also change it
back.
This commit is contained in:
Hocuri
2025-07-02 22:40:30 +02:00
committed by GitHub
parent 2ee3f58b69
commit 0a73c2b7ab
23 changed files with 744 additions and 319 deletions

View File

@@ -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
/**
* @}

View File

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

View File

@@ -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<u32> {
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<u32> {
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())
}

View File

@@ -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<u32>,
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,

View File

@@ -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 <name> missing.");
let chat_id = chat::create_broadcast(&context, arg1.to_string()).await?;
println!("Broadcast#{chat_id} created successfully.");
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<bool> {
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<PathBuf> {
.await
}
pub(crate) async fn get_broadcast_icon(context: &Context) -> Result<PathBuf> {
get_asset_icon(
context,
"icon-broadcast",
include_bytes!("../assets/icon-broadcast.png"),
)
.await
}
pub(crate) async fn get_archive_icon(context: &Context) -> Result<PathBuf> {
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<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=?;",
(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?;
/// 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<ChatId> {
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)

View File

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

View File

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

View File

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

View File

@@ -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::<std::result::Result<Vec<_>, _>>()?;
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(())

View File

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

View File

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

View File

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

View File

@@ -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<ContactId>],
past_ids: &[Option<ContactId>],
verified_encryption: &VerifiedEncryption,
) -> Result<GroupChangesInfo> {
let to_ids_flat: Vec<ContactId> = 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::<ContactId>::from_iter(chat::get_chat_contacts(context, chat_id).await?);
HashSet::<ContactId>::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::<i64>().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::<ContactId>::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<String>,
) -> Result<()> {
// ========== Apply chat name changes ==========
let group_name_timestamp = mime_parser
.get_header(HeaderDef::ChatGroupNameTimestamp)
.and_then(|s| s.parse::<i64>().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<String> {
.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 <Id>`, `<Id>` or just `Id`.
@@ -3174,10 +3201,11 @@ fn mailinglist_header_listid(list_id_header: &str) -> Result<String> {
///
/// `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<Option<(ChatId, Blocked)>> {
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<GroupChangesInfo> {
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,

View File

@@ -1711,7 +1711,7 @@ fn migrate_key_contacts(
continue;
}
// Broadcast list
// Broadcast channel
160 => old_members
.iter()
.map(|(original, _)| {

View File

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

View File

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

View File

@@ -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<std::sync::RwLock<BTreeMap<u32, String>>> =
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,
}

View File

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