Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -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()
Expand Down
12 changes: 12 additions & 0 deletions Signal/translations/en.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -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.";

Expand Down
26 changes: 26 additions & 0 deletions SignalServiceKit/Groups/GroupManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<ServiceId> = [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()
Expand Down