From 300b06e2a9df93909504bae96dae7ac310576a73 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 29 Apr 2026 03:39:30 +0000 Subject: [PATCH] Add admin Close Group for GV2 (remove all, leave, soft-delete) Group admins with membership edit rights get a Close Group row in conversation settings. Confirming runs one GV2 update that removes every other member (same ban semantics as kick) and leaves, then soft-deletes the thread locally with delete-for-me sync. Co-authored-by: abedawi --- ...ationSettingsViewController+Contents.swift | 22 +++++ .../ConversationSettingsViewController.swift | 86 +++++++++++++++++++ .../translations/en.lproj/Localizable.strings | 12 +++ SignalServiceKit/Groups/GroupManager.swift | 26 ++++++ 4 files changed, 146 insertions(+) diff --git a/Signal/src/ViewControllers/ThreadSettings/ConversationSettingsViewController+Contents.swift b/Signal/src/ViewControllers/ThreadSettings/ConversationSettingsViewController+Contents.swift index 95216cba383..809a31e8ecc 100644 --- a/Signal/src/ViewControllers/ThreadSettings/ConversationSettingsViewController+Contents.swift +++ b/Signal/src/ViewControllers/ThreadSettings/ConversationSettingsViewController+Contents.swift @@ -523,6 +523,28 @@ extension ConversationSettingsViewController { private func buildBlockAndLeaveSection() -> OWSTableSection { let section = OWSTableSection() + if canCloseGroupAsAdmin { + section.add(OWSTableItem(customCellBlock: { [weak self] in + guard let self = self else { + owsFailDebug("Missing self") + return OWSTableItem.newCell() + } + + return OWSTableItem.buildCell( + icon: .groupMemberRemoveFromGroup, + itemName: OWSLocalizedString( + "CLOSE_GROUP_ACTION", + comment: "table cell label in conversation settings for an admin to close the group for everyone" + ), + customColor: UIColor.ows_accentRed, + accessibilityIdentifier: UIView.accessibilityIdentifier(in: self, name: "close_group") + ) + }, + actionBlock: { [weak self] in + self?.didTapCloseGroup() + })) + } + if isGroupThread, isLocalUserFullOrInvitedMember { section.add(OWSTableItem(customCellBlock: { [weak self] in guard let self = self else { diff --git a/Signal/src/ViewControllers/ThreadSettings/ConversationSettingsViewController.swift b/Signal/src/ViewControllers/ThreadSettings/ConversationSettingsViewController.swift index 2c693240d27..f9148f164c2 100644 --- a/Signal/src/ViewControllers/ThreadSettings/ConversationSettingsViewController.swift +++ b/Signal/src/ViewControllers/ThreadSettings/ConversationSettingsViewController.swift @@ -145,6 +145,20 @@ class ConversationSettingsViewController: OWSTableViewController2, BadgeCollecti thread.isGroupThread } + /// Whether the local user can use "Close group" (GV2 admin with membership-edit rights). + var canCloseGroupAsAdmin: Bool { + guard let groupThread = thread as? TSGroupThread else { + return false + } + guard groupThread.isGroupV2Thread else { + return false + } + guard groupViewHelper.canEditConversationMembership else { + return false + } + return groupThread.isLocalUserFullMemberAndAdministrator + } + // MARK: - View Lifecycle public override func viewDidLoad() { @@ -680,6 +694,78 @@ class ConversationSettingsViewController: OWSTableViewController2, BadgeCollecti } } + func didTapCloseGroup() { + showCloseGroupConfirmAlert() + } + + func showCloseGroupConfirmAlert() { + let alert = ActionSheetController( + title: OWSLocalizedString( + "CLOSE_GROUP_CONFIRM_TITLE", + comment: "Title for the confirmation alert when an admin closes a group for everyone." + ), + message: OWSLocalizedString( + "CLOSE_GROUP_CONFIRM_MESSAGE", + comment: "Message for the confirmation alert when an admin closes a group. Explains that all members will be removed, the admin will leave, and the action cannot be undone." + ) + ) + let closeAction = ActionSheetAction( + title: OWSLocalizedString( + "CLOSE_GROUP_CONFIRM_BUTTON", + comment: "Confirmation button to close a group for everyone." + ), + accessibilityIdentifier: UIView.accessibilityIdentifier(in: self, name: "close_group_confirm"), + style: .destructive + ) { [weak self] _ in + self?.performCloseGroupForEveryone() + } + alert.addAction(closeAction) + alert.addAction(OWSActionSheets.cancelAction) + presentActionSheet(alert) + } + + private func performCloseGroupForEveryone() { + guard let groupThread = thread as? TSGroupThread else { + owsFailDebug("Invalid thread.") + return + } + guard let groupModelV2 = groupThread.groupModel as? TSGroupModelV2 else { + owsFailDebug("Invalid group model.") + return + } + guard let navigationController = self.navigationController else { + owsFailDebug("Invalid navigationController.") + return + } + let viewControllers = navigationController.viewControllers + guard let index = viewControllers.firstIndex(of: self), + index > 0 else { + owsFailDebug("Invalid navigation stack.") + return + } + let conversationViewController = viewControllers[index - 1] + + GroupViewUtils.updateGroupWithActivityIndicator( + fromViewController: self, + updateBlock: { + try await GroupManager.closeGroupForEveryoneV2(groupModel: groupModelV2) + }, + completion: { [weak self] in + guard let self else { return } + let threadToSoftDelete = self.thread + SSKEnvironment.shared.databaseStorageRef.write { transaction in + DependenciesBridge.shared.threadSoftDeleteManager.softDelete( + threads: [threadToSoftDelete], + sendDeleteForMeSyncMessage: true, + tx: transaction + ) + } + NotificationCenter.default.post(name: ChatListViewController.clearSearch, object: nil) + self.navigationController?.popToViewController(conversationViewController, animated: true) + } + ) + } + func didTapUnblockThread(completion: @escaping () -> Void = {}) { BlockListUIUtils.showUnblockThreadActionSheet(thread, from: self) { [weak self] _ in self?.reloadThreadAndUpdateContent() diff --git a/Signal/translations/en.lproj/Localizable.strings b/Signal/translations/en.lproj/Localizable.strings index d9fb3168c85..8cbbc4d3b59 100644 --- a/Signal/translations/en.lproj/Localizable.strings +++ b/Signal/translations/en.lproj/Localizable.strings @@ -4135,6 +4135,18 @@ /* Error indicating that a group could not be left. */ "LEAVE_GROUP_FAILED" = "An error occurred while leaving the group."; +/* table cell label in conversation settings for an admin to close the group for everyone */ +"CLOSE_GROUP_ACTION" = "Close Group"; + +/* Title for the confirmation alert when an admin closes a group for everyone. */ +"CLOSE_GROUP_CONFIRM_TITLE" = "Close this group?"; + +/* Message for the confirmation alert when an admin closes a group. Explains that all members will be removed, the admin will leave, and the action cannot be undone. */ +"CLOSE_GROUP_CONFIRM_MESSAGE" = "Everyone will be removed from the group, you will leave, and this chat will be deleted from your device. This action cannot be undone."; + +/* Confirmation button to close a group for everyone. */ +"CLOSE_GROUP_CONFIRM_BUTTON" = "Close Group"; + /* Text in a sheet explaining details about 'Legacy Groups'. */ "LEGACY_GROUP_UNSUPPORTED_LEARN_MORE_BODY" = "\"Legacy Groups\" are groups that do not support features like @mentions and admins. Most Legacy Groups were upgraded either automatically or manually to \"New Groups.\" Due to a long period of inactivity, this group can no longer be upgraded.\n\nTo continue this chat, you must create a new group."; diff --git a/SignalServiceKit/Groups/GroupManager.swift b/SignalServiceKit/Groups/GroupManager.swift index aa448020687..eb806ba1301 100644 --- a/SignalServiceKit/Groups/GroupManager.swift +++ b/SignalServiceKit/Groups/GroupManager.swift @@ -565,6 +565,32 @@ public class GroupManager: NSObject { } } + /// Removes every other member, invite, and join request from the group, then leaves. + /// Call only when the local user is a full administrator; mirrors per-member removal semantics + /// from `removeFromGroupOrRevokeInviteV2` in a single GV2 change so the last-admin leave check passes. + public static func closeGroupForEveryoneV2(groupModel: TSGroupModelV2) async throws { + guard let localIdentifiers = DependenciesBridge.shared.tsAccountManager.localIdentifiersWithMaybeSneakyTransaction else { + throw OWSAssertionError("Missing localIdentifiers.") + } + var localServiceIds: Set = [localIdentifiers.aci] + if let pni = localIdentifiers.pni { + localServiceIds.insert(pni) + } + + let membership = groupModel.groupMembership + let serviceIdsToRemove = membership.allMembersOfAnyKindServiceIds.subtracting(localServiceIds) + + try await updateGroupV2(groupModel: groupModel, description: "Close group for everyone") { groupChangeSet in + for serviceId in serviceIdsToRemove { + groupChangeSet.removeMember(serviceId) + if let aci = serviceId as? Aci, !membership.isInvitedMember(serviceId) { + groupChangeSet.addBannedMember(aci) + } + } + groupChangeSet.setShouldLeaveGroupDeclineInvite() + } + } + public static func revokeInvalidInvites(groupModel: TSGroupModelV2) async throws { try await updateGroupV2(groupModel: groupModel, description: "Revoke invalid invites") { groupChangeSet in groupChangeSet.revokeInvalidInvites()