ソースを参照

Merge pull request #1377 from deltachat/broadcasts

broadcasts
cyBerta 3 年 前
コミット
6993019685

+ 9 - 1
DcCore/DcCore/DC/Wrapper.swift

@@ -256,6 +256,10 @@ public class DcContext {
         return Int(dc_create_group_chat(contextPointer, verified ? 1 : 0, name))
     }
 
+    public func createBroadcastList() -> Int {
+        return Int(dc_create_broadcast_list(contextPointer))
+    }
+
     public func addContactToChat(chatId: Int, contactId: Int) -> Bool {
         return dc_add_contact_to_chat(contextPointer, UInt32(chatId), UInt32(contactId)) == 1
     }
@@ -819,13 +823,17 @@ public class DcChat {
         // isMultiUser() might fit better,
         // however, would result in lots of code changes, so we leave this as is for now.
         let type = Int(dc_chat_get_type(chatPointer))
-        return type == DC_CHAT_TYPE_GROUP || type == DC_CHAT_TYPE_MAILINGLIST
+        return type == DC_CHAT_TYPE_GROUP || type == DC_CHAT_TYPE_MAILINGLIST || type == DC_CHAT_TYPE_BROADCAST
     }
 
     public var isMailinglist: Bool {
         return Int(dc_chat_get_type(chatPointer)) == DC_CHAT_TYPE_MAILINGLIST
     }
 
+    public var isBroadcast: Bool {
+        return Int(dc_chat_get_type(chatPointer)) == DC_CHAT_TYPE_BROADCAST
+    }
+
     public var isSelfTalk: Bool {
         return Int(dc_chat_is_self_talk(chatPointer)) != 0
     }

+ 5 - 1
deltachat-ios/Chat/ChatViewController.swift

@@ -793,6 +793,8 @@ class ChatViewController: UITableViewController {
         let chatContactIds = chat.getContactIds(dcContext)
         if chat.isMailinglist {
             subtitle = String.localized("mailing_list")
+        } else if chat.isBroadcast {
+            subtitle = String.localized(stringID: "n_recipients", count: chatContactIds.count)
         } else if chat.isGroup {
             subtitle = String.localized(stringID: "n_members", count: chatContactIds.count)
         } else if chat.isDeviceTalk {
@@ -920,7 +922,9 @@ class ChatViewController: UITableViewController {
         if show {
             let dcChat = dcContext.getChat(chatId: chatId)
             if dcChat.isGroup {
-                if dcChat.isUnpromoted {
+                if dcChat.isBroadcast {
+                    emptyStateView.text = String.localized("chat_new_broadcast_hint")
+                } else if dcChat.isUnpromoted {
                     emptyStateView.text = String.localized("chat_new_group_hint")
                 } else {
                     emptyStateView.text = String.localized("chat_no_messages")

+ 5 - 9
deltachat-ios/Controller/AddGroupMembersViewController.swift

@@ -4,10 +4,7 @@ import DcCore
 class AddGroupMembersViewController: GroupMembersViewController {
     var onMembersSelected: ((Set<Int>) -> Void)?
     lazy var isVerifiedGroup: Bool = false
-
-    lazy var isNewGroup: Bool = {
-        return chat == nil
-    }()
+    private var isBroadcast: Bool = false
 
     private lazy var sections: [AddGroupMemberSections] = {
         if isVerifiedGroup {
@@ -56,9 +53,10 @@ class AddGroupMembersViewController: GroupMembersViewController {
     }()
 
     // add members of new group, no chat object yet
-    init(dcContext: DcContext, preselected: Set<Int>, isVerified: Bool) {
+    init(dcContext: DcContext, preselected: Set<Int>, isVerified: Bool, isBroadcast: Bool) {
         super.init(dcContext: dcContext)
         isVerifiedGroup = isVerified
+        self.isBroadcast = isBroadcast
         numberOfSections = sections.count
         selectedContactIds = preselected
     }
@@ -68,6 +66,7 @@ class AddGroupMembersViewController: GroupMembersViewController {
         self.chatId = chatId
         super.init(dcContext: dcContext)
         isVerifiedGroup = chat?.isProtected ?? false
+        isBroadcast = chat?.isBroadcast ?? false
         numberOfSections = sections.count
         selectedContactIds = Set(dcContext.getChat(chatId: chatId).getContactIds(dcContext))
     }
@@ -79,7 +78,7 @@ class AddGroupMembersViewController: GroupMembersViewController {
     // MARK: - lifecycle
     override func viewDidLoad() {
         super.viewDidLoad()
-        title = String.localized("group_add_members")
+        title = String.localized(isBroadcast ? "add_recipients" : "group_add_members")
         navigationItem.rightBarButtonItem = doneButton
         navigationItem.leftBarButtonItem = cancelButton
         contactIds = loadMemberCandidates()
@@ -91,9 +90,6 @@ class AddGroupMembersViewController: GroupMembersViewController {
 
     @objc func doneButtonPressed() {
         if let onMembersSelected = onMembersSelected {
-            if isNewGroup {
-                selectedContactIds.insert(Int(DC_CONTACT_ID_SELF))
-            }
             onMembersSelected(selectedContactIds)
         }
         navigationController?.popViewController(animated: true)

+ 18 - 8
deltachat-ios/Controller/EditGroupViewController.swift

@@ -7,9 +7,13 @@ class EditGroupViewController: UITableViewController, MediaPickerDelegate {
 
     private var changeGroupImage: UIImage?
     private var deleteGroupImage: Bool = false
+    private var useGroupWording: Bool
 
-    private let rowGroupName = 0
-    private let rowAvatar = 1
+    enum EditRows {
+         case name
+         case avatar
+    }
+    private let editRows: [EditRows]
 
     var avatarSelectionCell: AvatarSelectionCell
 
@@ -20,7 +24,7 @@ class EditGroupViewController: UITableViewController, MediaPickerDelegate {
     }()
 
     lazy var groupNameCell: TextFieldCell = {
-        let cell = TextFieldCell(description: String.localized("group_name"), placeholder: self.chat.name)
+        let cell = TextFieldCell(description: String.localized(useGroupWording ? "group_name" : "name_desktop"), placeholder: self.chat.name)
         cell.setText(text: self.chat.name)
         cell.onTextFieldChange = self.groupNameEdited(_:)
         return cell
@@ -41,10 +45,16 @@ class EditGroupViewController: UITableViewController, MediaPickerDelegate {
         self.dcContext = dcContext
         self.chat = chat
         self.avatarSelectionCell = AvatarSelectionCell(image: chat.profileImage)
+        self.useGroupWording = !chat.isBroadcast && !chat.isMailinglist
+        if chat.isBroadcast {
+            self.editRows = [.name]
+        } else {
+            self.editRows = [.name, .avatar]
+        }
         super.init(style: .grouped)
-        self.avatarSelectionCell.hintLabel.text = String.localized("group_avatar")
+        self.avatarSelectionCell.hintLabel.text = String.localized(useGroupWording ? "group_avatar" : "image")
         self.avatarSelectionCell.onAvatarTapped = onAvatarTapped
-        title = String.localized("menu_edit_group")
+        title = String.localized(useGroupWording ? "menu_edit_group" : "global_menu_edit_desktop")
     }
 
     required init?(coder aDecoder: NSCoder) {
@@ -59,7 +69,7 @@ class EditGroupViewController: UITableViewController, MediaPickerDelegate {
     }
 
     override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
-        if indexPath.row == rowAvatar {
+        if editRows[indexPath.row] == .avatar {
             return avatarSelectionCell
         } else {
             return groupNameCell
@@ -71,7 +81,7 @@ class EditGroupViewController: UITableViewController, MediaPickerDelegate {
     }
 
     override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
-        return 2
+        return editRows.count
     }
     
     @objc func saveContactButtonPressed() {
@@ -94,7 +104,7 @@ class EditGroupViewController: UITableViewController, MediaPickerDelegate {
     }
 
     private func onAvatarTapped() {
-        let alert = UIAlertController(title: String.localized("group_avatar"), message: nil, preferredStyle: .safeActionSheet)
+        let alert = UIAlertController(title: String.localized(useGroupWording ? "group_avatar" : "image"), message: nil, preferredStyle: .safeActionSheet)
             alert.addAction(PhotoPickerAlertAction(title: String.localized("camera"), style: .default, handler: cameraButtonPressed(_:)))
             alert.addAction(PhotoPickerAlertAction(title: String.localized("gallery"), style: .default, handler: galleryButtonPressed(_:)))
             if avatarSelectionCell.isAvatarSet() {

+ 39 - 37
deltachat-ios/Controller/GroupChatDetailViewController.swift

@@ -29,19 +29,15 @@ class GroupChatDetailViewController: UIViewController {
 
     private let membersRowAddMembers = 0
     private let membersRowQrInvite = 1
-    private let memberManagementRows = 2
+    private let memberManagementRows: Int
 
     private let dcContext: DcContext
 
     private let sections: [ProfileSections]
 
-    private var currentUser: DcContact? {
-        let myId = groupMemberIds.filter { dcContext.getContact(id: $0).email == dcContext.addr }.first
-        guard let currentUserId = myId else {
-            return nil
-        }
-        return dcContext.getContact(id: currentUserId)
-    }
+    private lazy var canEdit: Bool = {
+        return chat.isMailinglist || chat.canSend
+    }()
 
     private var chatId: Int
     private var chat: DcChat {
@@ -169,10 +165,17 @@ class GroupChatDetailViewController: UIViewController {
         if chat.isMailinglist {
             self.chatOptions = [.gallery, .documents, .muteChat]
             self.chatActions = [.archiveChat, .deleteChat]
+            self.memberManagementRows = 2
             self.sections = [.chatOptions, .chatActions]
+        } else if chat.isBroadcast {
+            self.chatOptions = [.gallery, .documents]
+            self.chatActions = [.archiveChat, .deleteChat]
+            self.memberManagementRows = 1
+            self.sections = [.chatOptions, .members, .chatActions]
         } else {
             self.chatOptions = [.gallery, .documents, .ephemeralMessages, .muteChat]
             self.chatActions = [.archiveChat, .leaveGroup, .deleteChat]
+            self.memberManagementRows = 2
             self.sections = [.chatOptions, .members, .chatActions]
         }
 
@@ -199,6 +202,8 @@ class GroupChatDetailViewController: UIViewController {
         super.viewDidLoad()
         if chat.isMailinglist {
             title = String.localized("mailing_list")
+        } else if chat.isBroadcast {
+            title = String.localized("broadcast_list")
         } else {
             title = String.localized("tab_group")
         }
@@ -211,7 +216,7 @@ class GroupChatDetailViewController: UIViewController {
         // update chat object, maybe chat name was edited
         updateGroupMembers()
         tableView.reloadData() // to display updates
-        editBarButtonItem.isEnabled = currentUser != nil
+        editBarButtonItem.isEnabled = canEdit
         setupObservers()
         updateHeader()
         updateMediaCellValues()
@@ -286,11 +291,7 @@ class GroupChatDetailViewController: UIViewController {
     }
 
     private func updateHeader() {
-        groupHeader.updateDetails(
-            title: chat.name,
-            subtitle: chat.isMailinglist ?
-                nil : String.localizedStringWithFormat(String.localized("n_members"), chat.getContactIds(dcContext).count)
-        )
+        groupHeader.updateDetails(title: chat.name, subtitle: nil)
         if let img = chat.profileImage {
             groupHeader.setImage(img)
         } else {
@@ -432,7 +433,7 @@ extension GroupChatDetailViewController: UITableViewDelegate, UITableViewDataSou
     func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
         let sectionType = sections[indexPath.section]
         let row = indexPath.row
-        if sectionType == .members && row != membersRowAddMembers && row != membersRowQrInvite {
+        if sectionType == .members && !isMemberManagementRow(row: row) {
             return ContactCell.cellHeight
         } else {
             return UITableView.automaticDimension
@@ -455,13 +456,13 @@ extension GroupChatDetailViewController: UITableViewDelegate, UITableViewDataSou
                 return muteChatCell
             }
         case .members:
-            if row == membersRowAddMembers || row == membersRowQrInvite {
+            if isMemberManagementRow(row: row) {
                 guard let actionCell = tableView.dequeueReusableCell(withIdentifier: "actionCell", for: indexPath) as? ActionCell else {
                 safe_fatalError("could not dequeue action cell")
                 break
                 }
                 if row == membersRowAddMembers {
-                    actionCell.actionTitle = String.localized("group_add_members")
+                    actionCell.actionTitle = String.localized(chat.isBroadcast ? "add_recipients" : "group_add_members")
                     actionCell.actionColor = UIColor.systemBlue
                 } else if row == membersRowQrInvite {
                     actionCell.actionTitle = String.localized("qrshow_join_group_title")
@@ -520,10 +521,12 @@ extension GroupChatDetailViewController: UITableViewDelegate, UITableViewDataSou
                 }
             }
         case .members:
-            if row == membersRowAddMembers {
-                showAddGroupMember(chatId: chat.id)
-            } else if row == membersRowQrInvite {
-                showQrCodeInvite(chatId: chat.id)
+            if isMemberManagementRow(row: row) {
+                if row == membersRowAddMembers {
+                    showAddGroupMember(chatId: chat.id)
+                } else if row == membersRowQrInvite {
+                    showQrCodeInvite(chatId: chat.id)
+                }
             } else {
                 let memberId = getGroupMemberIdFor(row)
                 if memberId == DC_CONTACT_ID_SELF {
@@ -549,39 +552,40 @@ extension GroupChatDetailViewController: UITableViewDelegate, UITableViewDataSou
 
     func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? {
         if sections[section] == .members {
-            return String.localized("tab_members")
+            return String.localizedStringWithFormat(String.localized(chat.isBroadcast ? "n_recipients" : "n_members"),
+                                                    chat.getContactIds(dcContext).count)
         }
         return nil
     }
 
     func tableView(_: UITableView, canEditRowAt indexPath: IndexPath) -> Bool {
-        guard let currentUser = self.currentUser else {
+        if !chat.canSend {
             return false
         }
         let row = indexPath.row
         let sectionType = sections[indexPath.section]
         if sectionType == .members &&
             !isMemberManagementRow(row: row) &&
-            getGroupMemberIdFor(row) != currentUser.id {
+            getGroupMemberIdFor(row) != DC_CONTACT_ID_SELF {
             return true
         }
         return false
     }
 
     func tableView(_ tableView: UITableView, editActionsForRowAt indexPath: IndexPath) -> [UITableViewRowAction]? {
-        guard let currentUser = self.currentUser else {
+        if !chat.canSend {
             return nil
         }
         let row = indexPath.row
         let sectionType = sections[indexPath.section]
         if sectionType == .members &&
             !isMemberManagementRow(row: row) &&
-            getGroupMemberIdFor(row) != currentUser.id {
+            getGroupMemberIdFor(row) != DC_CONTACT_ID_SELF {
             // action set for members except for current user
             let delete = UITableViewRowAction(style: .destructive, title: String.localized("remove_desktop")) { [weak self] _, indexPath in
                 guard let self = self else { return }
                 let contact = self.getGroupMember(at: row)
-                let title = String.localizedStringWithFormat(String.localized("ask_remove_members"), contact.nameNAddr)
+                let title = String.localizedStringWithFormat(String.localized(self.chat.isBroadcast ? "ask_remove_from_broadcast" : "ask_remove_members"), contact.nameNAddr)
                 let alert = UIAlertController(title: title, message: nil, preferredStyle: .safeActionSheet)
                 alert.addAction(UIAlertAction(title: String.localized("remove_desktop"), style: .destructive, handler: { _ in
                     let success = self.dcContext.removeContactFromChat(chatId: self.chat.id, contactId: contact.id)
@@ -654,15 +658,13 @@ extension GroupChatDetailViewController {
     }
 
     private func showLeaveGroupConfirmationAlert() {
-        if let userId = currentUser?.id {
-            let alert = UIAlertController(title: String.localized("ask_leave_group"), message: nil, preferredStyle: .safeActionSheet)
-            alert.addAction(UIAlertAction(title: String.localized("menu_leave_group"), style: .destructive, handler: { _ in
-                _ = self.dcContext.removeContactFromChat(chatId: self.chat.id, contactId: userId)
-                self.editBarButtonItem.isEnabled = false
-                self.updateGroupMembers()
-            }))
-            alert.addAction(UIAlertAction(title: String.localized("cancel"), style: .cancel, handler: nil))
-            present(alert, animated: true, completion: nil)
-        }
+        let alert = UIAlertController(title: String.localized("ask_leave_group"), message: nil, preferredStyle: .safeActionSheet)
+        alert.addAction(UIAlertAction(title: String.localized("menu_leave_group"), style: .destructive, handler: { _ in
+            _ = self.dcContext.removeContactFromChat(chatId: self.chat.id, contactId: Int(DC_CONTACT_ID_SELF))
+            self.editBarButtonItem.isEnabled = false
+            self.updateGroupMembers()
+        }))
+        alert.addAction(UIAlertAction(title: String.localized("cancel"), style: .cancel, handler: nil))
+        present(alert, animated: true, completion: nil)
     }
 }

+ 8 - 3
deltachat-ios/Controller/NewChatViewController.swift

@@ -9,7 +9,8 @@ class NewChatViewController: UITableViewController {
     private let sectionNewRowNewContact = 0
     private let sectionNewRowNewGroup = 1
     private let sectionNewRowNewVerifiedGroup = 2
-    private let sectionNewRowCount = 3
+    private let sectionNewRowBroadcastList = 3
+    private var sectionNewRowCount: Int { return UserDefaults.standard.bool(forKey: "broadcast_lists") ? 4 : 3 }
 
     private let sectionImportedContacts = 1
 
@@ -147,6 +148,8 @@ class NewChatViewController: UITableViewController {
                     actionCell.actionTitle = String.localized("menu_new_group")
                 case sectionNewRowNewVerifiedGroup:
                     actionCell.actionTitle = String.localized("menu_new_verified_group")
+                case sectionNewRowBroadcastList:
+                    actionCell.actionTitle = String.localized("new_broadcast_list")
                 default:
                     actionCell.actionTitle = String.localized("menu_new_contact")
                 }
@@ -188,6 +191,8 @@ class NewChatViewController: UITableViewController {
                 showNewGroupController(isVerified: false)
             } else if row == sectionNewRowNewVerifiedGroup {
                 showNewGroupController(isVerified: true)
+            } else if row == sectionNewRowBroadcastList {
+                showNewGroupController(isVerified: false, createBroadcast: true)
             } else if row == sectionNewRowNewContact {
                 showNewContactController()
             }
@@ -308,8 +313,8 @@ class NewChatViewController: UITableViewController {
     }
 
     // MARK: - coordinator
-    private func showNewGroupController(isVerified: Bool) {
-        let newGroupController = NewGroupController(dcContext: dcContext, isVerified: isVerified)
+    private func showNewGroupController(isVerified: Bool, createBroadcast: Bool = false) {
+        let newGroupController = NewGroupController(dcContext: dcContext, isVerified: isVerified, createBroadcast: createBroadcast)
         navigationController?.pushViewController(newGroupController, animated: true)
     }
 

+ 104 - 61
deltachat-ios/Controller/NewGroupController.swift

@@ -13,18 +13,28 @@ class NewGroupController: UITableViewController, MediaPickerDelegate {
     private var deleteGroupImage: Bool = false
 
     let isVerifiedGroup: Bool
+    let createBroadcast: Bool
     let dcContext: DcContext
     private var contactAddedObserver: NSObjectProtocol?
 
-    private let sectionGroupDetails = 0
-    private let sectionGroupDetailsRowName = 0
-    private let sectionGroupDetailsRowAvatar = 1
-    private let countSectionGroupDetails = 2
-    private let sectionInvite = 1
-    private let sectionInviteRowAddMembers = 0
-    private let sectionInviteRowShowQrCode = 1
-    private lazy var countSectionInvite: Int = 2
-    private let sectionGroupMembers = 2
+    enum DetailsRows {
+        case name
+        case avatar
+    }
+    private let detailsRows: [DetailsRows]
+
+    enum InviteRows {
+        case addMembers
+        case showQrCode
+    }
+    private let inviteRows: [InviteRows]
+
+    enum NewGroupSections {
+        case details
+        case invite
+        case members
+    }
+    private let sections: [NewGroupSections]
 
     private lazy var mediaPicker: MediaPicker? = {
         let mediaPicker = MediaPicker(navigationController: navigationController)
@@ -51,11 +61,22 @@ class NewGroupController: UITableViewController, MediaPickerDelegate {
 
     var qrInviteCodeCell: ActionCell?
 
-    init(dcContext: DcContext, isVerified: Bool) {
-        self.contactIdsForGroup = [Int(DC_CONTACT_ID_SELF)]
-        self.groupContactIds = Array(contactIdsForGroup)
+    init(dcContext: DcContext, isVerified: Bool, createBroadcast: Bool) {
         self.isVerifiedGroup = isVerified
+        self.createBroadcast = createBroadcast
         self.dcContext = dcContext
+        if createBroadcast {
+            self.sections = [.invite, .members]
+            self.detailsRows = []
+            self.inviteRows = [.addMembers]
+            self.contactIdsForGroup = []
+        } else {
+            self.sections = [.details, .invite, .members]
+            self.detailsRows = [.name, .avatar]
+            self.inviteRows = [.addMembers, .showQrCode]
+            self.contactIdsForGroup = [Int(DC_CONTACT_ID_SELF)]
+        }
+        self.groupContactIds = Array(contactIdsForGroup)
         super.init(style: .grouped)
     }
 
@@ -65,13 +86,19 @@ class NewGroupController: UITableViewController, MediaPickerDelegate {
 
     override func viewDidLoad() {
         super.viewDidLoad()
-        title = isVerifiedGroup ? String.localized("menu_new_verified_group") : String.localized("menu_new_group")
+        if isVerifiedGroup {
+            title = String.localized("menu_new_verified_group")
+        } else if createBroadcast {
+            title = String.localized("new_broadcast_list")
+        } else {
+            title = String.localized("menu_new_group")
+        }
         doneButton = UIBarButtonItem(barButtonSystemItem: .done, target: self, action: #selector(doneButtonPressed))
         navigationItem.rightBarButtonItem = doneButton
-        doneButton.isEnabled = false
         tableView.register(ContactCell.self, forCellReuseIdentifier: "contactCell")
         tableView.register(ActionCell.self, forCellReuseIdentifier: "actionCell")
         self.hideKeyboardOnTap()
+        checkDoneButton()
     }
 
     override func viewWillAppear(_ animated: Bool) {
@@ -99,53 +126,55 @@ class NewGroupController: UITableViewController, MediaPickerDelegate {
         }
     }
 
+    private func checkDoneButton() {
+        var nameOk = true
+        if !createBroadcast {
+            let name = groupNameCell.textField.text ?? ""
+            nameOk = !name.isEmpty
+        }
+        doneButton.isEnabled = nameOk && contactIdsForGroup.count >= 1
+    }
 
     @objc func doneButtonPressed() {
-        if groupChatId == 0 {
+        if createBroadcast {
+            groupChatId = dcContext.createBroadcastList()
+        } else if groupChatId == 0 {
             groupChatId = dcContext.createGroupChat(verified: isVerifiedGroup, name: groupName)
         } else {
             _ = dcContext.setChatName(chatId: groupChatId, name: groupName)
         }
 
         for contactId in contactIdsForGroup {
-            let success = dcContext.addContactToChat(chatId: groupChatId, contactId: contactId)
-
-            if let groupImage = changeGroupImage {
-                AvatarHelper.saveChatAvatar(dcContext: dcContext, image: groupImage, for: groupChatId)
-            } else if deleteGroupImage {
-                AvatarHelper.saveChatAvatar(dcContext: dcContext, image: nil, for: groupChatId)
-            }
-
-            if success {
-                logger.info("successfully added \(contactId) to group \(groupName)")
-            } else {
-                logger.error("failed to add \(contactId) to group \(groupName)")
-            }
+            _ = dcContext.addContactToChat(chatId: groupChatId, contactId: contactId)
+        }
+        if let groupImage = changeGroupImage {
+            AvatarHelper.saveChatAvatar(dcContext: dcContext, image: groupImage, for: groupChatId)
+        } else if deleteGroupImage {
+            AvatarHelper.saveChatAvatar(dcContext: dcContext, image: nil, for: groupChatId)
         }
 
         showGroupChat(chatId: Int(groupChatId))
     }
 
     override func numberOfSections(in _: UITableView) -> Int {
-        return 3
+        return sections.count
     }
 
     override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
-        let section = indexPath.section
         let row = indexPath.row
 
-        switch section {
-        case sectionGroupDetails:
-             if row == sectionGroupDetailsRowAvatar {
+        switch sections[indexPath.section] {
+        case .details:
+            if detailsRows[row] == .avatar {
                 return avatarSelectionCell
             } else {
                 return groupNameCell
             }
-        case sectionInvite:
-            if row == sectionInviteRowAddMembers {
+        case .invite:
+            if inviteRows[row] == .addMembers {
                 let cell = tableView.dequeueReusableCell(withIdentifier: "actionCell", for: indexPath)
                 if let actionCell = cell as? ActionCell {
-                    actionCell.actionTitle = String.localized("group_add_members")
+                    actionCell.actionTitle = String.localized(createBroadcast ? "add_recipients" : "group_add_members")
                     actionCell.actionColor = UIColor.systemBlue
                     actionCell.isUserInteractionEnabled = true
                 }
@@ -160,7 +189,7 @@ class NewGroupController: UITableViewController, MediaPickerDelegate {
                 }
                 return cell
             }
-        default:
+        case .members:
             let cell = tableView.dequeueReusableCell(withIdentifier: "contactCell", for: indexPath)
             if let contactCell = cell as? ContactCell {
                 let contact = dcContext.getContact(id: groupContactIds[row])
@@ -180,47 +209,49 @@ class NewGroupController: UITableViewController, MediaPickerDelegate {
     }
 
     override func tableView(_ tableView: UITableView, titleForFooterInSection section: Int) -> String? {
-        if section == sectionGroupDetails && isVerifiedGroup {
+        if sections[section] == .details && isVerifiedGroup {
             return String.localized("verified_group_explain")
+        } else if sections[section] == .invite && createBroadcast {
+            return String.localized("chat_new_broadcast_hint")
         }
         return nil
     }
 
     override func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
-        let section = indexPath.section
-        switch section {
-        case sectionGroupDetails, sectionInvite:
+        switch sections[indexPath.section] {
+        case .details, .invite:
             return UITableView.automaticDimension
-        default:
+        case .members:
             return ContactCell.cellHeight
         }
     }
 
     override func tableView(_: UITableView, numberOfRowsInSection section: Int) -> Int {
-        switch section {
-        case sectionGroupDetails:
-            return countSectionGroupDetails
-        case sectionInvite:
-            return countSectionInvite
-        default:
+        switch sections[section] {
+        case .details:
+            return detailsRows.count
+        case .invite:
+            return inviteRows.count
+        case .members:
             return contactIdsForGroup.count
         }
     }
 
     override func tableView(_: UITableView, titleForHeaderInSection section: Int) -> String? {
-        if section == sectionGroupMembers {
-            return String.localized("in_this_group_desktop")
-        } else {
-            return nil
+        if sections[section] == .members && !contactIdsForGroup.isEmpty {
+            if createBroadcast {
+                return String.localized(stringID: "n_recipients", count: contactIdsForGroup.count)
+            } else {
+                return String.localized(stringID: "n_members", count: contactIdsForGroup.count)
+            }
         }
+        return nil
     }
 
     override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
-        let section = indexPath.section
-        let row = indexPath.row
-        if section == sectionInvite {
+        if sections[indexPath.section] == .invite {
             tableView.deselectRow(at: indexPath, animated: false)
-            if row == sectionInviteRowAddMembers {
+            if inviteRows[indexPath.row] == .addMembers {
                 var contactsWithoutSelf = contactIdsForGroup
                 contactsWithoutSelf.remove(Int(DC_CONTACT_ID_SELF))
                 showAddMembers(preselectedMembers: contactsWithoutSelf, isVerified: self.isVerifiedGroup)
@@ -232,11 +263,10 @@ class NewGroupController: UITableViewController, MediaPickerDelegate {
     }
 
     override func tableView(_ tableView: UITableView, editActionsForRowAt indexPath: IndexPath) -> [UITableViewRowAction]? {
-        let section = indexPath.section
         let row = indexPath.row
 
         // swipe by delete
-        if section == sectionGroupMembers, groupContactIds[row] != DC_CONTACT_ID_SELF {
+        if sections[indexPath.section] == .members, groupContactIds[indexPath.row] != DC_CONTACT_ID_SELF {
             let delete = UITableViewRowAction(style: .destructive, title: String.localized("remove_desktop")) { [weak self] _, indexPath in
                 guard let self = self else { return }
                 if self.groupChatId != 0,
@@ -256,13 +286,12 @@ class NewGroupController: UITableViewController, MediaPickerDelegate {
         }
     }
 
-
     private func updateGroupName(textView: UITextField) {
         let name = textView.text ?? ""
         groupName = name
-        doneButton.isEnabled = name.containsCharacters()
         qrInviteCodeCell?.isUserInteractionEnabled = name.containsCharacters()
         qrInviteCodeCell?.actionColor = groupName.isEmpty ? DcColors.colorDisabled : UIColor.systemBlue
+        checkDoneButton()
     }
 
     private func onAvatarTapped() {
@@ -302,6 +331,7 @@ class NewGroupController: UITableViewController, MediaPickerDelegate {
         }
         groupContactIds = Array(contactIdsForGroup)
         self.tableView.reloadData()
+        checkDoneButton()
     }
 
     func updateGroupContactIdsOnListSelection(_ members: Set<Int>) {
@@ -314,13 +344,21 @@ class NewGroupController: UITableViewController, MediaPickerDelegate {
         contactIdsForGroup = members
         groupContactIds = Array(members)
         self.tableView.reloadData()
+        checkDoneButton()
     }
 
     func removeGroupContactFromList(at indexPath: IndexPath) {
         let row = indexPath.row
         self.contactIdsForGroup.remove(self.groupContactIds[row])
         self.groupContactIds.remove(at: row)
+
+        CATransaction.begin()
+        CATransaction.setCompletionBlock {
+            self.tableView.reloadData() // needed to update the "N members"-title, however do not interrupt the nice delete-animation
+            self.checkDoneButton()
+        }
         tableView.deleteRows(at: [indexPath], with: .fade)
+        CATransaction.commit()
     }
 
     // MARK: - coordinator
@@ -355,9 +393,14 @@ class NewGroupController: UITableViewController, MediaPickerDelegate {
     private func showAddMembers(preselectedMembers: Set<Int>, isVerified: Bool) {
         let newGroupController = AddGroupMembersViewController(dcContext: dcContext,
                                                                preselected: preselectedMembers,
-                                                               isVerified: isVerified)
+                                                               isVerified: isVerified,
+                                                               isBroadcast: createBroadcast)
         newGroupController.onMembersSelected = { [weak self] (memberIds: Set<Int>) -> Void in
             guard let self = self else { return }
+            var memberIds = memberIds
+            if !self.createBroadcast {
+                memberIds.insert(Int(DC_CONTACT_ID_SELF))
+            }
             self.updateGroupContactIdsOnListSelection(memberIds)
         }
         navigationController?.pushViewController(newGroupController, animated: true)

+ 16 - 0
deltachat-ios/Controller/SettingsController.swift

@@ -434,6 +434,22 @@ internal final class SettingsViewController: UITableViewController, ProgressAler
     private func showExperimentalDialog() {
         let alert = UIAlertController(title: String.localized("pref_experimental_features"), message: nil, preferredStyle: .safeActionSheet)
 
+        let broadcastLists = UserDefaults.standard.bool(forKey: "broadcast_lists")
+        alert.addAction(UIAlertAction(title: (broadcastLists ? "✔︎ " : "") + String.localized("broadcast_lists"),
+                                      style: .default, handler: { [weak self] _ in
+            guard let self = self else { return }
+            UserDefaults.standard.set(!broadcastLists, forKey: "broadcast_lists")
+            if !broadcastLists {
+                let alert = UIAlertController(title: "Thanks for trying out the experimental feature 🧪 \"Broadcast Lists\"!",
+                                              message: "You can now create new \"Broadcast Lists\" from the \"New Chat\" dialog\n\n"
+                                                + "In case you are using more than one device, broadcast lists are currently not synced between them\n\n"
+                                                + "If you want to quit the experimental feature, you can disable it at \"Settings / Advanced\".",
+                                              preferredStyle: .alert)
+                alert.addAction(UIAlertAction(title: String.localized("ok"), style: .default, handler: nil))
+                self.navigationController?.present(alert, animated: true, completion: nil)
+            }
+        }))
+
         let locationStreaming = UserDefaults.standard.bool(forKey: "location_streaming")
         let title = (locationStreaming ? "✔︎ " : "") + String.localized("pref_on_demand_location_streaming")
         alert.addAction(UIAlertAction(title: title, style: .default, handler: { [weak self] _ in