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

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