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

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