From ef53148b3392d4736453046d260df8177e49e285 Mon Sep 17 00:00:00 2001 From: Matthew Chen Date: Tue, 7 Apr 2020 10:04:52 -0300 Subject: [PATCH] Add "replace admin" flow. --- Signal.xcodeproj/project.pbxproj | 4 + .../ConversationSettingsViewController.swift | 99 +++++++++++++++++-- .../ReplaceAdminViewController.swift | 89 +++++++++++++++++ Signal/src/call/CallService.swift | 2 +- .../translations/en.lproj/Localizable.strings | 15 +++ .../groups/GroupsV2ChangeSetImpl.swift | 38 +++++-- SignalMessaging/groups/GroupsV2Test.swift | 18 ++-- .../src/groups/GroupMembership.swift | 2 +- SignalServiceKit/src/groups/GroupsV2.swift | 1 + 9 files changed, 242 insertions(+), 26 deletions(-) create mode 100644 Signal/src/ViewControllers/ThreadSettings/ReplaceAdminViewController.swift diff --git a/Signal.xcodeproj/project.pbxproj b/Signal.xcodeproj/project.pbxproj index 26a0a5deed..50be817df2 100644 --- a/Signal.xcodeproj/project.pbxproj +++ b/Signal.xcodeproj/project.pbxproj @@ -194,6 +194,7 @@ 347B83F92436820C0019A52C /* GroupViewHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 347B83F82436820C0019A52C /* GroupViewHelper.swift */; }; 347B83FB243779270019A52C /* OWSFormat.swift in Sources */ = {isa = PBXBuildFile; fileRef = 347B83FA243779270019A52C /* OWSFormat.swift */; }; 347B83FD24378DDF0019A52C /* PendingGroupMembersViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 347B83FC24378DDE0019A52C /* PendingGroupMembersViewController.swift */; }; + 348433DF243CA94600C7F64A /* ReplaceAdminViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 348433DE243CA94500C7F64A /* ReplaceAdminViewController.swift */; }; 34843B2421432293004DED45 /* SignalBaseTest.m in Sources */ = {isa = PBXBuildFile; fileRef = 34843B2221432292004DED45 /* SignalBaseTest.m */; }; 34843B26214327C9004DED45 /* OWSOrphanDataCleanerTest.m in Sources */ = {isa = PBXBuildFile; fileRef = 34843B25214327C9004DED45 /* OWSOrphanDataCleanerTest.m */; }; 34843B2C214FE296004DED45 /* MockEnvironment.m in Sources */ = {isa = PBXBuildFile; fileRef = 34843B2A214FE295004DED45 /* MockEnvironment.m */; }; @@ -1072,6 +1073,7 @@ 347B83F82436820C0019A52C /* GroupViewHelper.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GroupViewHelper.swift; sourceTree = ""; }; 347B83FA243779270019A52C /* OWSFormat.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OWSFormat.swift; sourceTree = ""; }; 347B83FC24378DDE0019A52C /* PendingGroupMembersViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PendingGroupMembersViewController.swift; sourceTree = ""; }; + 348433DE243CA94500C7F64A /* ReplaceAdminViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ReplaceAdminViewController.swift; sourceTree = ""; }; 34843B2221432292004DED45 /* SignalBaseTest.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = SignalBaseTest.m; sourceTree = ""; }; 34843B2321432293004DED45 /* SignalBaseTest.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = SignalBaseTest.h; sourceTree = ""; }; 34843B25214327C9004DED45 /* OWSOrphanDataCleanerTest.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = OWSOrphanDataCleanerTest.m; sourceTree = ""; }; @@ -1979,6 +1981,7 @@ 3425648723E214BC0016905B /* OWSConversationSettingsViewController.swift */, 340FC899204DAC8D007AEB0F /* OWSConversationSettingsViewDelegate.h */, 347B83FC24378DDE0019A52C /* PendingGroupMembersViewController.swift */, + 348433DE243CA94500C7F64A /* ReplaceAdminViewController.swift */, 340FC89E204DAC8D007AEB0F /* ShowGroupMembersViewController.h */, 340FC8A6204DAC8D007AEB0F /* ShowGroupMembersViewController.m */, 340FC8A3204DAC8D007AEB0F /* UpdateGroupViewController.h */, @@ -4544,6 +4547,7 @@ 340FC8AF204DAC8D007AEB0F /* OWSLinkDeviceViewController.m in Sources */, 8806F21B23E90B0B00692638 /* MessageRequests.swift in Sources */, 3434AE1E22AEF5A4002EE04E /* CircularProgressView.swift in Sources */, + 348433DF243CA94600C7F64A /* ReplaceAdminViewController.swift in Sources */, 34E3EF0D1EFC235B007F6822 /* DebugUIDiskUsage.m in Sources */, 887EEC1F23F0B20600F8C26D /* PinReminderMegaphone.swift in Sources */, 454A84042059C787008B8C75 /* MediaTileViewController.swift in Sources */, diff --git a/Signal/src/ViewControllers/ThreadSettings/ConversationSettingsViewController.swift b/Signal/src/ViewControllers/ThreadSettings/ConversationSettingsViewController.swift index 7b0954dc7e..939f22eca0 100644 --- a/Signal/src/ViewControllers/ThreadSettings/ConversationSettingsViewController.swift +++ b/Signal/src/ViewControllers/ThreadSettings/ConversationSettingsViewController.swift @@ -89,12 +89,6 @@ class ConversationSettingsViewController: OWSTableViewController { groupViewHelper.delegate = self } - @available(*, unavailable, message:"use other constructor instead.") - @objc - public required init(coder aDecoder: NSCoder) { - notImplemented() - } - private func observeNotifications() { NotificationCenter.default.addObserver(self, selector: #selector(identityStateDidChange(notification:)), @@ -523,11 +517,22 @@ class ConversationSettingsViewController: OWSTableViewController { } func didTapLeaveGroup() { + if isLastAdminInV2Group { + showReplaceAdminAlert() + } else { + showLeaveGroupConfirmAlert() + } + } - let alert = ActionSheetController(title: NSLocalizedString("CONFIRM_LEAVE_GROUP_TITLE", comment: "Alert title"), - message: NSLocalizedString("CONFIRM_LEAVE_GROUP_DESCRIPTION", comment: "Alert body")) + func showLeaveGroupConfirmAlert() { - let leaveAction = ActionSheetAction(title: NSLocalizedString("LEAVE_BUTTON_TITLE", comment: "Confirmation button within contextual alert"), + let alert = ActionSheetController(title: NSLocalizedString("CONFIRM_LEAVE_GROUP_TITLE", + comment: "Alert title"), + message: NSLocalizedString("CONFIRM_LEAVE_GROUP_DESCRIPTION", + comment: "Alert body")) + + let leaveAction = ActionSheetAction(title: NSLocalizedString("LEAVE_BUTTON_TITLE", + comment: "Confirmation button within contextual alert"), accessibilityIdentifier: UIView.accessibilityIdentifier(in: self, name: "leave_group_confirm"), style: .destructive) { _ in self.leaveGroup() @@ -538,6 +543,74 @@ class ConversationSettingsViewController: OWSTableViewController { presentActionSheet(alert) } + func showReplaceAdminAlert() { + let candidates = self.replacementAdminCandidates + guard !candidates.isEmpty else { + // TODO: We could offer a "delete group locally" option here. + OWSActionSheets.showErrorAlert(message: NSLocalizedString("GROUPS_CANT_REPLACE_ADMIN_ALERT_MESSAGE", + comment: "Message for the 'can't replace group admin' alert.")) + return + } + + let alert = ActionSheetController(title: NSLocalizedString("GROUPS_REPLACE_ADMIN_ALERT_TITLE", + comment: "Title for the 'replace group admin' alert."), + message: NSLocalizedString("GROUPS_REPLACE_ADMIN_ALERT_MESSAGE", + comment: "Message for the 'replace group admin' alert.")) + + alert.addAction(ActionSheetAction(title: NSLocalizedString("GROUPS_REPLACE_ADMIN_BUTTON", + comment: "Label for the 'replace group admin' button."), + accessibilityIdentifier: UIView.accessibilityIdentifier(in: self, name: "replace_admin_alert"), + style: .destructive) { _ in + self.showReplaceAdminView(candidates: candidates) + }) + alert.addAction(OWSActionSheets.cancelAction) + presentActionSheet(alert) + } + + func showReplaceAdminView(candidates: Set) { + assert(!candidates.isEmpty) + let replaceAdminViewController = ReplaceAdminViewController(candidates: candidates, + replaceAdminViewControllerDelegate: self) + navigationController?.pushViewController(replaceAdminViewController, animated: true) + } + + private var isLastAdminInV2Group: Bool { + guard let groupThread = thread as? TSGroupThread else { + owsFailDebug("Invalid thread.") + return false + } + guard let groupModelV2 = groupThread.groupModel as? TSGroupModelV2 else { + return false + } + guard let localAddress = tsAccountManager.localAddress else { + owsFailDebug("missing local address") + return false + } + let groupMembership = groupModelV2.groupMembership + guard groupMembership.isNonPendingMember(localAddress), + groupMembership.isAdministrator(localAddress) else { + return false + } + return true + } + + private var replacementAdminCandidates: Set { + guard let groupThread = thread as? TSGroupThread else { + owsFailDebug("Invalid thread.") + return [] + } + guard let groupModelV2 = groupThread.groupModel as? TSGroupModelV2 else { + return [] + } + guard let localAddress = tsAccountManager.localAddress else { + owsFailDebug("missing local address") + return [] + } + var candidates = groupModelV2.groupMembership.nonPendingAdministrators + candidates.remove(localAddress) + return candidates + } + private func leaveGroup() { guard let groupThread = thread as? TSGroupThread else { owsFailDebug("Invalid thread.") @@ -933,3 +1006,11 @@ extension ConversationSettingsViewController: GroupViewHelperDelegate { return self } } + +// MARK: - + +extension ConversationSettingsViewController: ReplaceAdminViewControllerDelegate { + func replaceAdmin(address: SignalServiceAddress) { + // TODO: + } +} diff --git a/Signal/src/ViewControllers/ThreadSettings/ReplaceAdminViewController.swift b/Signal/src/ViewControllers/ThreadSettings/ReplaceAdminViewController.swift new file mode 100644 index 0000000000..9dbdfaea65 --- /dev/null +++ b/Signal/src/ViewControllers/ThreadSettings/ReplaceAdminViewController.swift @@ -0,0 +1,89 @@ +// +// Copyright (c) 2020 Open Whisper Systems. All rights reserved. +// + +import Foundation +import UIKit + +protocol ReplaceAdminViewControllerDelegate: class { + func replaceAdmin(address: SignalServiceAddress) +} + +// MARK: - + +class ReplaceAdminViewController: OWSTableViewController { + + // MARK: - Dependencies + + private var contactsManager: OWSContactsManager { + return Environment.shared.contactsManager + } + + private var databaseStorage: SDSDatabaseStorage { + return SDSDatabaseStorage.shared + } + + // MARK: - + + weak var replaceAdminViewControllerDelegate: ReplaceAdminViewControllerDelegate? + + private let candidates: Set + + required init(candidates: Set, + replaceAdminViewControllerDelegate: ReplaceAdminViewControllerDelegate) { + assert(!candidates.isEmpty) + + self.candidates = candidates + self.replaceAdminViewControllerDelegate = replaceAdminViewControllerDelegate + + super.init() + } + + // MARK: - View Lifecycle + + @objc + public override func viewDidLoad() { + super.viewDidLoad() + + title = NSLocalizedString("REPLACE_ADMIN_VIEW_TITLE", + comment: "The title for the 'replace group admin' view.") + + view.backgroundColor = Theme.backgroundColor + tableView.backgroundColor = Theme.backgroundColor + tableView.separatorColor = .clear + + updateTableContents() + } + + private func updateTableContents() { + let contents = OWSTableContents() + + let section = OWSTableSection() + + let sortedCandidates = databaseStorage.uiRead { transaction in + self.contactsManager.sortSignalServiceAddresses(Array(self.candidates), transaction: transaction) + } + for address in sortedCandidates { + section.add(OWSTableItem(customCellBlock: { + let cell = ContactTableViewCell() + + let imageView = UIImageView() + imageView.setTemplateImageName("empty-circle-outline-24", tintColor: .ows_gray25) + cell.ows_setAccessoryView(imageView) + + cell.configure(withRecipientAddress: address) + + return cell + }, + customRowHeight: UITableView.automaticDimension, + actionBlock: { [weak self] in + self?.replaceAdminViewControllerDelegate?.replaceAdmin(address: address) + } + )) + } + + contents.addSection(section) + + self.contents = contents + } +} diff --git a/Signal/src/call/CallService.swift b/Signal/src/call/CallService.swift index a718fa973b..1d3a9cd708 100644 --- a/Signal/src/call/CallService.swift +++ b/Signal/src/call/CallService.swift @@ -154,7 +154,7 @@ extension SignalCall: CallManagerCallReference { } return AppEnvironment.shared.notificationPresenter } - var databaseStorage: SDSDatabaseStorage { + private var databaseStorage: SDSDatabaseStorage { return SDSDatabaseStorage.shared } diff --git a/Signal/translations/en.lproj/Localizable.strings b/Signal/translations/en.lproj/Localizable.strings index 45e29029e6..fa37f815af 100644 --- a/Signal/translations/en.lproj/Localizable.strings +++ b/Signal/translations/en.lproj/Localizable.strings @@ -1562,6 +1562,9 @@ /* Message indicating that the local user left the group. */ "GROUP_YOU_LEFT" = "You have left the group."; +/* Message for the 'can't replace group admin' alert. */ +"GROUPS_CANT_REPLACE_ADMIN_ALERT_MESSAGE" = "Choose a new admin for this group before you leave."; + /* Error indicating that an error occurred while accepting an invite. */ "GROUPS_INVITE_ACCEPT_INVITE_FAILED" = "Could not accept invite."; @@ -1574,6 +1577,15 @@ /* Label for 'block inviter' button in group invite view. Embeds {{name of user who invited you}}. */ "GROUPS_INVITE_BLOCK_INVITER_FORMAT" = "Block %@"; +/* Message for the 'replace group admin' alert. */ +"GROUPS_REPLACE_ADMIN_ALERT_MESSAGE" = "Before you leave, choose a new admin for this group."; + +/* Title for the 'replace group admin' alert. */ +"GROUPS_REPLACE_ADMIN_ALERT_TITLE" = "Choose New Admin"; + +/* Label for the 'replace group admin' button. */ +"GROUPS_REPLACE_ADMIN_BUTTON" = "Choose new admin"; + /* Label for 'archived conversations' button. */ "HOME_VIEW_ARCHIVED_CONVERSATIONS" = "Archived Conversations"; @@ -2912,6 +2924,9 @@ /* Alert title after wrong guess for 'two-factor auth pin' reminder activity */ "REMINDER_2FA_WRONG_PIN_ALERT_TITLE" = "That is not the correct PIN."; +/* The title for the 'replace group admin' view. */ +"REPLACE_ADMIN_VIEW_TITLE" = "Choose New Admin"; + /* No comment provided by engineer. */ "REREGISTER_FOR_PUSH" = "Re-register for push notifications"; diff --git a/SignalMessaging/groups/GroupsV2ChangeSetImpl.swift b/SignalMessaging/groups/GroupsV2ChangeSetImpl.swift index c9d2c689ec..7ebda48d7a 100644 --- a/SignalMessaging/groups/GroupsV2ChangeSetImpl.swift +++ b/SignalMessaging/groups/GroupsV2ChangeSetImpl.swift @@ -361,6 +361,8 @@ public class GroupsV2ChangeSetImpl: NSObject, GroupsV2ChangeSet { Logger.verbose("Version: \(oldVersion) -> \(newVersion)") actionsBuilder.setVersion(newVersion) + var nonPendingAdministratorCount: Int = currentGroupModel.groupMembership.nonPendingAdministrators.count + var didChange = false if let title = self.title { @@ -400,7 +402,7 @@ public class GroupsV2ChangeSetImpl: NSObject, GroupsV2ChangeSet { continue } guard let profileKeyCredential = profileKeyCredentialMap[uuid] else { - throw OWSAssertionError("Missing profile key credential]: \(uuid)") + throw OWSAssertionError("Missing profile key credential: \(uuid)") } let actionBuilder = GroupsProtoGroupChangeActionsAddMemberAction.builder() actionBuilder.setAdded(try GroupsV2Protos.buildMemberProto(profileKeyCredential: profileKeyCredential, @@ -408,6 +410,10 @@ public class GroupsV2ChangeSetImpl: NSObject, GroupsV2ChangeSet { groupV2Params: groupV2Params)) actionsBuilder.addAddMembers(try actionBuilder.build()) didChange = true + + if role == .administrator { + nonPendingAdministratorCount += 1 + } } for (uuid, role) in self.pendingMembersToAdd { @@ -433,6 +439,10 @@ public class GroupsV2ChangeSetImpl: NSObject, GroupsV2ChangeSet { actionBuilder.setDeletedUserID(userId) actionsBuilder.addDeleteMembers(try actionBuilder.build()) didChange = true + + if currentGroupMembership.isAdministrator(SignalServiceAddress(uuid: uuid)) { + nonPendingAdministratorCount -= 1 + } } else if currentGroupMembership.isPendingMember(uuid) { let actionBuilder = GroupsProtoGroupChangeActionsDeletePendingMemberAction.builder() let userId = try groupV2Params.userId(forUuid: uuid) @@ -447,12 +457,13 @@ public class GroupsV2ChangeSetImpl: NSObject, GroupsV2ChangeSet { } } - for (uuid, role) in self.membersToChangeRole { + for (uuid, newRole) in self.membersToChangeRole { guard currentGroupMembership.isNonPendingMember(uuid) else { // User is no longer a member. throw GroupsV2Error.conflictingChange } - guard currentGroupMembership.role(for: SignalServiceAddress(uuid: uuid)) != role else { + let currentRole = currentGroupMembership.role(for: SignalServiceAddress(uuid: uuid)) + guard currentRole != newRole else { // Another user has already modifed the role of this member. // We don't treat that as a conflict. continue @@ -460,9 +471,15 @@ public class GroupsV2ChangeSetImpl: NSObject, GroupsV2ChangeSet { let actionBuilder = GroupsProtoGroupChangeActionsModifyMemberRoleAction.builder() let userId = try groupV2Params.userId(forUuid: uuid) actionBuilder.setUserID(userId) - actionBuilder.setRole(role.asProtoRole) + actionBuilder.setRole(newRole.asProtoRole) actionsBuilder.addModifyMemberRoles(try actionBuilder.build()) didChange = true + + if currentRole == .administrator { + nonPendingAdministratorCount -= 1 + } else if newRole == .administrator { + nonPendingAdministratorCount += 1 + } } let currentAccess = currentGroupModel.access @@ -493,7 +510,7 @@ public class GroupsV2ChangeSetImpl: NSObject, GroupsV2ChangeSet { throw GroupsV2Error.redundantChange } guard let profileKeyCredential = profileKeyCredentialMap[localUuid] else { - throw OWSAssertionError("Missing profile key credential]: \(localUuid)") + throw OWSAssertionError("Missing profile key credential: \(localUuid)") } let actionBuilder = GroupsProtoGroupChangeActionsPromotePendingMemberAction.builder() actionBuilder.setPresentation(try GroupsV2Protos.presentationData(profileKeyCredential: profileKeyCredential, @@ -503,6 +520,15 @@ public class GroupsV2ChangeSetImpl: NSObject, GroupsV2ChangeSet { } if self.shouldLeaveGroupDeclineInvite { + let isLastAdminInV2Group = (currentGroupMembership.isNonPendingMember(localAddress) && + currentGroupMembership.isAdministrator(localAddress) && + nonPendingAdministratorCount == 1) + guard !isLastAdminInV2Group else { + // This could happen if the last two admins leave at the same time + // and race. + throw GroupsV2Error.lastAdminCantLeaveGroup + } + // Check that we are still invited or in group. if currentGroupMembership.pendingMembers.contains(localAddress) { // Decline invite @@ -544,7 +570,7 @@ public class GroupsV2ChangeSetImpl: NSObject, GroupsV2ChangeSet { if shouldUpdateLocalProfileKey { guard let profileKeyCredential = profileKeyCredentialMap[localUuid] else { - throw OWSAssertionError("Missing profile key credential]: \(localUuid)") + throw OWSAssertionError("Missing profile key credential: \(localUuid)") } let actionBuilder = GroupsProtoGroupChangeActionsModifyMemberProfileKeyAction.builder() actionBuilder.setPresentation(try GroupsV2Protos.presentationData(profileKeyCredential: profileKeyCredential, diff --git a/SignalMessaging/groups/GroupsV2Test.swift b/SignalMessaging/groups/GroupsV2Test.swift index 813c76f2b8..b4cb1339ba 100644 --- a/SignalMessaging/groups/GroupsV2Test.swift +++ b/SignalMessaging/groups/GroupsV2Test.swift @@ -107,8 +107,8 @@ public class GroupsV2Test: NSObject { throw OWSAssertionError("Unexpected members: \(groupMembership.nonPendingMembers).") } let expectedAdministrators = expectedMembers - guard groupMembership.administrators == expectedAdministrators else { - throw OWSAssertionError("Unexpected administrators: \(groupMembership.administrators).") + guard groupMembership.nonPendingAdministrators == expectedAdministrators else { + throw OWSAssertionError("Unexpected administrators: \(groupMembership.nonPendingAdministrators).") } guard groupV2Snapshot.accessControlForMembers == .member else { throw OWSAssertionError("Unexpected accessControlForMembers: \(groupV2Snapshot.accessControlForMembers).") @@ -120,7 +120,7 @@ public class GroupsV2Test: NSObject { }.then(on: .global()) { (groupId: Data) throws -> Promise in let (groupThread, dmConfiguration) = try self.fetchGroupThread(groupId: groupId) let oldGroupModel = groupThread.groupModel as! TSGroupModelV2 - guard oldGroupModel.groupMembership.administrators == localAddressSet else { + guard oldGroupModel.groupMembership.nonPendingAdministrators == localAddressSet else { throw OWSAssertionError("Unexpected groupMembership.") } guard oldGroupModel.groupMembership.nonAdminMembers.isEmpty else { @@ -196,8 +196,8 @@ public class GroupsV2Test: NSObject { throw OWSAssertionError("Unexpected members: \(groupMembership.nonPendingMembers).") } let expectedAdministrators = localAddressSet - guard groupMembership.administrators == expectedAdministrators else { - throw OWSAssertionError("Unexpected administrators: \(groupMembership.administrators).") + guard groupMembership.nonPendingAdministrators == expectedAdministrators else { + throw OWSAssertionError("Unexpected administrators: \(groupMembership.nonPendingAdministrators).") } guard groupV2Snapshot.accessControlForMembers == .member else { throw OWSAssertionError("Unexpected accessControlForMembers: \(groupV2Snapshot.accessControlForMembers).") @@ -211,7 +211,7 @@ public class GroupsV2Test: NSObject { guard let oldGroupModel = groupThread.groupModel as? TSGroupModelV2 else { throw OWSAssertionError("Invalid group model.") } - guard oldGroupModel.groupMembership.administrators == localAddressSet else { + guard oldGroupModel.groupMembership.nonPendingAdministrators == localAddressSet else { throw OWSAssertionError("Unexpected groupMembership.") } guard oldGroupModel.groupMembership.nonAdminMembers == otherAddresses else { @@ -285,8 +285,8 @@ public class GroupsV2Test: NSObject { throw OWSAssertionError("Unexpected members: \(groupMembership.nonPendingMembers).") } let expectedAdministrators = localAddressSet - guard groupMembership.administrators == expectedAdministrators else { - throw OWSAssertionError("Unexpected administrators: \(groupMembership.administrators).") + guard groupMembership.nonPendingAdministrators == expectedAdministrators else { + throw OWSAssertionError("Unexpected administrators: \(groupMembership.nonPendingAdministrators).") } guard groupV2Snapshot.accessControlForMembers == .member else { throw OWSAssertionError("Unexpected accessControlForMembers: \(groupV2Snapshot.accessControlForMembers).") @@ -298,7 +298,7 @@ public class GroupsV2Test: NSObject { }.map(on: .global()) { (groupId: Data) throws -> Data in let (groupThread, dmConfiguration) = try self.fetchGroupThread(groupId: groupId) let groupModel = groupThread.groupModel as! TSGroupModelV2 - guard groupModel.groupMembership.administrators == localAddressSet else { + guard groupModel.groupMembership.nonPendingAdministrators == localAddressSet else { throw OWSAssertionError("Unexpected groupMembership.") } guard groupModel.groupMembership.nonAdminMembers.isEmpty else { diff --git a/SignalServiceKit/src/groups/GroupMembership.swift b/SignalServiceKit/src/groups/GroupMembership.swift index ca6ebac4fb..f91d25b749 100644 --- a/SignalServiceKit/src/groups/GroupMembership.swift +++ b/SignalServiceKit/src/groups/GroupMembership.swift @@ -86,7 +86,7 @@ public class GroupMembership: MTLModel { return Set(memberStateMap.filter { !$0.value.isAdministrator && !$0.value.isPending }.keys) } @objc - public var administrators: Set { + public var nonPendingAdministrators: Set { return Set(memberStateMap.filter { $0.value.isAdministrator && !$0.value.isPending }.keys) } @objc diff --git a/SignalServiceKit/src/groups/GroupsV2.swift b/SignalServiceKit/src/groups/GroupsV2.swift index 5e0e07e636..43c3eba7b2 100644 --- a/SignalServiceKit/src/groups/GroupsV2.swift +++ b/SignalServiceKit/src/groups/GroupsV2.swift @@ -17,6 +17,7 @@ public enum GroupsV2Error: Error { case timeout case localUserNotInGroup case conflictingChange + case lastAdminCantLeaveGroup } @objc