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

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