diff --git a/Signal.xcodeproj/project.pbxproj b/Signal.xcodeproj/project.pbxproj index 59fa537332..23129aa464 100644 --- a/Signal.xcodeproj/project.pbxproj +++ b/Signal.xcodeproj/project.pbxproj @@ -235,6 +235,7 @@ 349EA07C2162AEA800F7B17F /* OWS111UDAttributesMigration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 349EA07B2162AEA700F7B17F /* OWS111UDAttributesMigration.swift */; }; 349ED990221B0194008045B0 /* Onboarding2FAViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 349ED98F221B0194008045B0 /* Onboarding2FAViewController.swift */; }; 349ED992221EE80D008045B0 /* AppPreferences.swift in Sources */ = {isa = PBXBuildFile; fileRef = 349ED991221EE80D008045B0 /* AppPreferences.swift */; }; + 34A17D81253F7237009F8C02 /* ConversationSettingsViewController+LegacyGroups.swift in Sources */ = {isa = PBXBuildFile; fileRef = 34A17D80253F7236009F8C02 /* ConversationSettingsViewController+LegacyGroups.swift */; }; 34A4C61E221613D00042EF2E /* OnboardingVerificationViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 34A4C61D221613D00042EF2E /* OnboardingVerificationViewController.swift */; }; 34A4D56F24E4D342002F8044 /* UnfairLockPerformanceTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 34A4D56E24E4D341002F8044 /* UnfairLockPerformanceTest.swift */; }; 34A6C28021E503E700B5B12E /* OWSImagePickerController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 34A6C27F21E503E600B5B12E /* OWSImagePickerController.swift */; }; @@ -1191,6 +1192,7 @@ 349EA07B2162AEA700F7B17F /* OWS111UDAttributesMigration.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OWS111UDAttributesMigration.swift; sourceTree = ""; }; 349ED98F221B0194008045B0 /* Onboarding2FAViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Onboarding2FAViewController.swift; sourceTree = ""; }; 349ED991221EE80D008045B0 /* AppPreferences.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppPreferences.swift; sourceTree = ""; }; + 34A17D80253F7236009F8C02 /* ConversationSettingsViewController+LegacyGroups.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "ConversationSettingsViewController+LegacyGroups.swift"; sourceTree = ""; }; 34A4C61D221613D00042EF2E /* OnboardingVerificationViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OnboardingVerificationViewController.swift; sourceTree = ""; }; 34A4D56E24E4D341002F8044 /* UnfairLockPerformanceTest.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UnfairLockPerformanceTest.swift; sourceTree = ""; }; 34A6C27F21E503E600B5B12E /* OWSImagePickerController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OWSImagePickerController.swift; sourceTree = ""; }; @@ -2176,6 +2178,7 @@ 34235F3724213550008C74CB /* ConversationSettingsViewController.swift */, 34E20D4D2425672A002C011E /* ConversationSettingsViewController+Contents.swift */, 34E20D4B24256563002C011E /* ConversationSettingsViewController+Header.swift */, + 34A17D80253F7236009F8C02 /* ConversationSettingsViewController+LegacyGroups.swift */, 340FC89D204DAC8D007AEB0F /* FingerprintViewController.h */, 340FC8A2204DAC8D007AEB0F /* FingerprintViewController.m */, 340FC8A5204DAC8D007AEB0F /* FingerprintViewScanController.h */, @@ -5003,6 +5006,7 @@ 88A4CC19246CE6810082211F /* DeviceTransferInitialViewController.swift in Sources */, 340FC8A9204DAC8D007AEB0F /* NotificationSettingsOptionsViewController.m in Sources */, 8835DE01230DBF7E00DC6B66 /* ComposeViewController.swift in Sources */, + 34A17D81253F7237009F8C02 /* ConversationSettingsViewController+LegacyGroups.swift in Sources */, 452037D11EE84975004E4CDF /* DebugUISessionState.m in Sources */, 88A4CC12246CE2F50082211F /* QRCodeView.swift in Sources */, D221A09A169C9E5E00537ABF /* main.m in Sources */, diff --git a/Signal/src/ViewControllers/ConversationView/Cells/ConversationViewCell.h b/Signal/src/ViewControllers/ConversationView/Cells/ConversationViewCell.h index fc3eae2533..555ad8b206 100644 --- a/Signal/src/ViewControllers/ConversationView/Cells/ConversationViewCell.h +++ b/Signal/src/ViewControllers/ConversationView/Cells/ConversationViewCell.h @@ -11,6 +11,7 @@ NS_ASSUME_NONNULL_BEGIN @class TSAttachmentStream; @class TSCall; @class TSErrorMessage; +@class TSInfoMessage; @class TSInteraction; @class TSInvalidIdentityKeyErrorMessage; @class TSMessage; @@ -59,6 +60,7 @@ NS_ASSUME_NONNULL_BEGIN - (void)showFingerprintWithAddress:(SignalServiceAddress *)address; - (void)showConversationSettings; - (void)showConversationSettingsAndShowMemberRequests; +- (void)showGroupMigrationLearnMoreActionSheetWithInfoMessage:(TSInfoMessage *)infoMessage; - (void)handleCallTap:(TSCall *)call; - (void)updateSystemContactWithAddress:(SignalServiceAddress *)address withNewNameComponents:(NSPersonNameComponents *)newNameComponents; diff --git a/Signal/src/ViewControllers/ConversationView/Cells/OWSSystemMessageCell.m b/Signal/src/ViewControllers/ConversationView/Cells/OWSSystemMessageCell.m index 8eb1b3c483..bc0b6e13d0 100644 --- a/Signal/src/ViewControllers/ConversationView/Cells/OWSSystemMessageCell.m +++ b/Signal/src/ViewControllers/ConversationView/Cells/OWSSystemMessageCell.m @@ -384,7 +384,7 @@ typedef void (^SystemMessageActionBlock)(void); return result; } -- (NSString *)iconNameForGroupUpdate:(GroupUpdateType)type +- (nullable NSString *)iconNameForGroupUpdate:(GroupUpdateType)type { NSString *iconName; @@ -431,6 +431,15 @@ typedef void (^SystemMessageActionBlock)(void); case GroupUpdateTypeDisappearingMessagesState_disabled: iconName = [Theme iconName:ThemeIconTimerDisabled16]; break; + case GroupUpdateTypeGroupMigrated: + iconName = [Theme iconName:ThemeIconMegaphone16]; + break; + case GroupUpdateTypeGroupMigrated_usersInvited: + iconName = [Theme iconName:ThemeIconMemberAdded16]; + break; + case GroupUpdateTypeGroupMigrated_usersDropped: + iconName = [Theme iconName:ThemeIconGroup16]; + break; } return iconName; @@ -447,12 +456,15 @@ typedef void (^SystemMessageActionBlock)(void); if (self.viewItem.systemMessageGroupUpdates.count > 0) { for (GroupUpdateCopyItem *update in self.viewItem.systemMessageGroupUpdates) { - NSString *iconName = [self iconNameForGroupUpdate:update.type]; - - [labelText appendTemplatedImageNamed:iconName - font:label.font - heightReference:ImageAttachmentHeightReferenceLineHeight]; - [labelText append:@" " attributes:@{}]; + NSString *_Nullable iconName = [self iconNameForGroupUpdate:update.type]; + if (iconName != nil) { + [labelText appendTemplatedImageNamed:iconName + font:label.font + heightReference:ImageAttachmentHeightReferenceLineHeight]; + [labelText append:@" " attributes:@{}]; + } else { + OWSFailDebug(@"Missing iconName."); + } [labelText append:update.text attributes:@{}]; if (![update isEqual:self.viewItem.systemMessageGroupUpdates.lastObject]) { @@ -655,6 +667,21 @@ typedef void (^SystemMessageActionBlock)(void); TSGroupModel *_Nullable oldGroupModel = infoMessage.oldGroupModel; TSGroupModel *_Nullable newGroupModel = infoMessage.newGroupModel; if (oldGroupModel != nil && newGroupModel != nil) { + + for (GroupUpdateCopyItem *update in self.viewItem.systemMessageGroupUpdates) { + if (update.type == GroupUpdateTypeGroupMigrated) { + return [SystemMessageAction + actionWithTitle:CommonStrings.learnMore + block:^{ + [weakSelf.delegate + showGroupMigrationLearnMoreActionSheetWithInfoMessage: + infoMessage]; + } + accessibilityIdentifier:ACCESSIBILITY_IDENTIFIER_WITH_NAME( + self, @"group_migration_learn_more")]; + } + } + NSMutableSet *newlyRequestingMembers = [NSMutableSet new]; [newlyRequestingMembers unionSet:newGroupModel.groupMembership.requestingMembers]; [newlyRequestingMembers minusSet:oldGroupModel.groupMembership.requestingMembers]; diff --git a/Signal/src/ViewControllers/ConversationView/ConversationViewController.m b/Signal/src/ViewControllers/ConversationView/ConversationViewController.m index 3b0ff5b962..896ac58e81 100644 --- a/Signal/src/ViewControllers/ConversationView/ConversationViewController.m +++ b/Signal/src/ViewControllers/ConversationView/ConversationViewController.m @@ -1057,7 +1057,7 @@ typedef enum : NSUInteger { } BOOL hasMultiple = noLongerVerifiedAddresses.count > 1; - ActionSheetController *actionSheet = [[ActionSheetController alloc] initWithTitle:nil message:nil]; + ActionSheetController *actionSheet = [ActionSheetController new]; __weak ConversationViewController *weakSelf = self; ActionSheetAction *verifyAction = [[ActionSheetAction alloc] @@ -1719,6 +1719,10 @@ typedef enum : NSUInteger { [self.navigationController setViewControllers:viewControllers animated:YES]; } +- (void)showGroupMigrationLearnMoreActionSheetWithInfoMessage:(TSInfoMessage *)infoMessage +{ +} + - (NSArray *)viewControllersUpToSelf { OWSAssertIsOnMainThread(); diff --git a/Signal/src/ViewControllers/DebugUI/DebugUIGroupsV2.swift b/Signal/src/ViewControllers/DebugUI/DebugUIGroupsV2.swift index e2515e33b9..b1901d6db3 100644 --- a/Signal/src/ViewControllers/DebugUI/DebugUIGroupsV2.swift +++ b/Signal/src/ViewControllers/DebugUI/DebugUIGroupsV2.swift @@ -89,17 +89,25 @@ class DebugUIGroupsV2: DebugUIPage { } if let groupThread = thread as? TSGroupThread { - sectionItems.append(OWSTableItem(title: "Try to migrate group (if already migrated).") { + sectionItems.append(OWSTableItem(title: "Try to migrate group (already migrated on service).") { Self.migrate(groupThread: groupThread, migrationMode: .alreadyMigratedOnService) }) - sectionItems.append(OWSTableItem(title: "Try to migrate group (polite).") { + sectionItems.append(OWSTableItem(title: "Try to migrate group (polite manual migration).") { Self.migrate(groupThread: groupThread, - migrationMode: .migrateToServicePolite) + migrationMode: .manualMigrationPolite) }) - sectionItems.append(OWSTableItem(title: "Try to migrate group (aggressive).") { + sectionItems.append(OWSTableItem(title: "Try to migrate group (aggressive manual migration).") { Self.migrate(groupThread: groupThread, - migrationMode: .migrateToServiceAggressive) + migrationMode: .manualMigrationAggressive) + }) + sectionItems.append(OWSTableItem(title: "Try to migrate group (polite auto migration).") { + Self.migrate(groupThread: groupThread, + migrationMode: .autoMigrationPolite) + }) + sectionItems.append(OWSTableItem(title: "Try to migrate group (aggressive auto migration).") { + Self.migrate(groupThread: groupThread, + migrationMode: .autoMigrationAggressive) }) } diff --git a/Signal/src/ViewControllers/NewGroupView/BaseGroupMemberViewController.swift b/Signal/src/ViewControllers/NewGroupView/BaseGroupMemberViewController.swift index f902b2325a..fbbfeb5d6a 100644 --- a/Signal/src/ViewControllers/NewGroupView/BaseGroupMemberViewController.swift +++ b/Signal/src/ViewControllers/NewGroupView/BaseGroupMemberViewController.swift @@ -139,15 +139,17 @@ public class BaseGroupMemberViewController: OWSViewController { } memberCountWrapper.isHidden = false - let format = NSLocalizedString("GROUP_MEMBER_COUNT_FORMAT", - comment: "Format string for the group member count indicator. Embeds {{ %1$@ the number of members in the group, %2$@ the maximum number of members in the group. }}.") + let format = NSLocalizedString("GROUP_MEMBER_COUNT_WITHOUT_LIMIT_FORMAT", + comment: "Format string for the group member count indicator. Embeds {{ the number of members in the group }}.") let memberCount = groupMemberViewDelegate.groupMemberViewGroupMemberCountForDisplay() - // maxGroupsV2MemberCount will have values like 101, 151 to - // leave a slot for the local user. We render the max count - 1. memberCountLabel.text = String(format: format, - OWSFormat.formatInt(memberCount), - OWSFormat.formatUInt(GroupManager.maxGroupsV2MemberCount - 1)) + OWSFormat.formatInt(memberCount)) + if memberCount >= GroupManager.groupsV2MaxGroupSizeRecommended { + memberCountLabel.textColor = .ows_accentRed + } else { + memberCountLabel.textColor = Theme.primaryTextColor + } } public func removeRecipient(_ recipient: PickedRecipient) { diff --git a/Signal/src/ViewControllers/NewGroupView/NewGroupMembersViewController.swift b/Signal/src/ViewControllers/NewGroupView/NewGroupMembersViewController.swift index 9552392385..b82b1f6a11 100644 --- a/Signal/src/ViewControllers/NewGroupView/NewGroupMembersViewController.swift +++ b/Signal/src/ViewControllers/NewGroupView/NewGroupMembersViewController.swift @@ -93,7 +93,7 @@ extension NewGroupMembersViewController: GroupMemberViewDelegate { } func groupMemberViewIsGroupFull() -> Bool { - return groupMemberViewGroupMemberCount(withSelf: true) >= GroupManager.maxGroupsV2MemberCount + return groupMemberViewGroupMemberCount(withSelf: true) >= GroupManager.groupsV2MaxGroupSizeHardLimit } func groupMemberViewIsPreExistingMember(_ recipient: PickedRecipient) -> Bool { diff --git a/Signal/src/ViewControllers/ThreadSettings/AddGroupMembersViewController.swift b/Signal/src/ViewControllers/ThreadSettings/AddGroupMembersViewController.swift index 6f2fbe2658..cfd8a03d1b 100644 --- a/Signal/src/ViewControllers/ThreadSettings/AddGroupMembersViewController.swift +++ b/Signal/src/ViewControllers/ThreadSettings/AddGroupMembersViewController.swift @@ -236,13 +236,7 @@ extension AddGroupMembersViewController: GroupMemberViewDelegate { guard groupThread.isGroupV2Thread else { return false } - return groupMemberViewGroupMemberCountForDisplay() >= GroupManager.maxGroupsV2MemberCount - } - - func groupMemberViewMaxMemberCount() -> UInt? { - return (groupThread.isGroupV2Thread - ? GroupManager.maxGroupsV2MemberCount - : nil) + return groupMemberViewGroupMemberCountForDisplay() >= GroupManager.groupsV2MaxGroupSizeHardLimit } func groupMemberViewIsPreExistingMember(_ recipient: PickedRecipient) -> Bool { diff --git a/Signal/src/ViewControllers/ThreadSettings/ConversationSettingsViewController+Header.swift b/Signal/src/ViewControllers/ThreadSettings/ConversationSettingsViewController+Header.swift index a2bff59f90..799abcc2ea 100644 --- a/Signal/src/ViewControllers/ThreadSettings/ConversationSettingsViewController+Header.swift +++ b/Signal/src/ViewControllers/ThreadSettings/ConversationSettingsViewController+Header.swift @@ -86,35 +86,16 @@ extension ConversationSettingsViewController { subviews.append(buildHeaderSubtitleLabel(attributedText: attributedText, font: font)) } - mutating func addLegacyGroupView() -> UIView { + mutating func addLegacyGroupView(groupThread: TSGroupThread, + viewController: ConversationSettingsViewController) { subviews.append(UIView.spacer(withHeight: 12)) - let bubbleView = UIView() - bubbleView.backgroundColor = Theme.secondaryBackgroundColor - bubbleView.layer.cornerRadius = 4 - bubbleView.layoutMargins = UIEdgeInsets(top: 10, leading: 12, bottom: 10, trailing: 12) - subviews.append(bubbleView) - - let label = UILabel() - let format = NSLocalizedString("GROUPS_LEGACY_GROUP_DESCRIPTION_FORMAT", - comment: "Brief explanation of legacy groups. Embeds {{ a \"learn more\" link. }}.") - let learnMoreText = NSLocalizedString("GROUPS_LEGACY_GROUP_LEARN_MORE_LINK", - comment: "A \"learn more\" link with more information about legacy groups.") - let text = String(format: format, learnMoreText) - let attributedString = NSMutableAttributedString(string: text) - attributedString.setAttributes([ - .foregroundColor: Theme.accentBlueColor - ], - forSubstring: learnMoreText) - label.textColor = Theme.secondaryTextAndIconColor - label.font = .ows_dynamicTypeFootnote - label.attributedText = attributedString - label.numberOfLines = 0 - label.lineBreakMode = .byWordWrapping - bubbleView.addSubview(label) - label.autoPinEdgesToSuperviewMargins() - - return bubbleView + let migrationInfo = GroupsV2Migration.migrationInfoForManualMigration(groupThread: groupThread) + let legacyGroupView = LegacyGroupView(groupThread: groupThread, + migrationInfo: migrationInfo, + viewController: viewController) + legacyGroupView.configure() + subviews.append(legacyGroupView) } func buildHeaderSubtitleLabel(attributedText: NSAttributedString, @@ -187,10 +168,8 @@ extension ConversationSettingsViewController { } if groupThread.isGroupV1Thread { - let legacyGroupView = builder.addLegacyGroupView() - legacyGroupView.isUserInteractionEnabled = true - legacyGroupView.addGestureRecognizer(UITapGestureRecognizer(target: self, - action: #selector(didTapLegacyGroupView))) + builder.addLegacyGroupView(groupThread: groupThread, + viewController: self) } builder.addLastSubviews() @@ -274,112 +253,4 @@ extension ConversationSettingsViewController { return UIView() } } - - // MARK: - Events - - @objc - func didTapLegacyGroupView(sender: UIGestureRecognizer) { - ExistingLegacyGroupView().present(fromViewController: self) - } -} - -// MARK: - - -class ExistingLegacyGroupView: UIView { - - weak var actionSheetController: ActionSheetController? - - init() { - super.init(frame: .zero) - } - - required init(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - func present(fromViewController: UIViewController) { - let buildLabel = { () -> UILabel in - let label = UILabel() - label.textColor = Theme.primaryTextColor - label.numberOfLines = 0 - label.lineBreakMode = .byWordWrapping - return label - } - - let titleLabel = buildLabel() - titleLabel.font = UIFont.ows_dynamicTypeTitle2.ows_semibold - titleLabel.text = NSLocalizedString("GROUPS_LEGACY_GROUP_ALERT_TITLE", - comment: "Title for the 'legacy group' alert view.") - - let section1TitleLabel = buildLabel() - section1TitleLabel.font = UIFont.ows_dynamicTypeBody.ows_semibold - section1TitleLabel.text = NSLocalizedString("GROUPS_LEGACY_GROUP_ALERT_SECTION_1_TITLE", - comment: "Title for the first section of the 'legacy group' alert view.") - - let section1BodyLabel = buildLabel() - section1BodyLabel.font = .ows_dynamicTypeBody - section1BodyLabel.text = NSLocalizedString("GROUPS_LEGACY_GROUP_ALERT_SECTION_1_BODY", - comment: "Body text for the first section of the 'legacy group' alert view.") - - let section2TitleLabel = buildLabel() - section2TitleLabel.font = UIFont.ows_dynamicTypeBody.ows_semibold - section2TitleLabel.text = NSLocalizedString("GROUPS_LEGACY_GROUP_ALERT_SECTION_2_TITLE", - comment: "Title for the second section of the 'legacy group' alert view.") - - let section2BodyLabel = buildLabel() - section2BodyLabel.font = .ows_dynamicTypeBody - section2BodyLabel.text = NSLocalizedString("GROUPS_LEGACY_GROUP_ALERT_SECTION_2_BODY", - comment: "Body text for the second section of the 'legacy group' alert view.") - - let section3BodyLabel = buildLabel() - section3BodyLabel.font = .ows_dynamicTypeBody - section3BodyLabel.text = NSLocalizedString("GROUPS_LEGACY_GROUP_ALERT_SECTION_3_BODY", - comment: "Body text for the third section of the 'legacy group' alert view.") - - let buttonFont = UIFont.ows_dynamicTypeBodyClamped.ows_semibold - let buttonHeight = OWSFlatButton.heightForFont(buttonFont) - let okayButton = OWSFlatButton.button(title: CommonStrings.okayButton, - font: buttonFont, - titleColor: .white, - backgroundColor: .ows_accentBlue, - target: self, - selector: #selector(dismissAlert)) - okayButton.autoSetDimension(.height, toSize: buttonHeight) - - let stackView = UIStackView(arrangedSubviews: [ - titleLabel, - UIView.spacer(withHeight: 28), - section1TitleLabel, - UIView.spacer(withHeight: 4), - section1BodyLabel, - UIView.spacer(withHeight: 21), - section2TitleLabel, - UIView.spacer(withHeight: 4), - section2BodyLabel, - UIView.spacer(withHeight: 24), - section3BodyLabel, - UIView.spacer(withHeight: 28), - okayButton - ]) - stackView.axis = .vertical - stackView.alignment = .fill - stackView.layoutMargins = UIEdgeInsets(top: 48, leading: 20, bottom: 38, trailing: 24) - stackView.isLayoutMarginsRelativeArrangement = true - stackView.addBackgroundView(withBackgroundColor: Theme.backgroundColor) - - layoutMargins = .zero - addSubview(stackView) - stackView.autoPinEdgesToSuperviewMargins() - - let actionSheetController = ActionSheetController() - actionSheetController.customHeader = self - actionSheetController.isCancelable = true - fromViewController.presentActionSheet(actionSheetController) - self.actionSheetController = actionSheetController - } - - @objc - func dismissAlert() { - actionSheetController?.dismiss(animated: true) - } } diff --git a/Signal/src/ViewControllers/ThreadSettings/ConversationSettingsViewController+LegacyGroups.swift b/Signal/src/ViewControllers/ThreadSettings/ConversationSettingsViewController+LegacyGroups.swift new file mode 100644 index 0000000000..d6f7c5482e --- /dev/null +++ b/Signal/src/ViewControllers/ThreadSettings/ConversationSettingsViewController+LegacyGroups.swift @@ -0,0 +1,635 @@ +// +// Copyright (c) 2020 Open Whisper Systems. All rights reserved. +// + +import Foundation +import UIKit +import PromiseKit + +class LegacyGroupView: UIView { + + private let groupThread: TSGroupThread + private let migrationInfo: GroupsV2MigrationInfo + private weak var viewController: UIViewController? + + required init(groupThread: TSGroupThread, + migrationInfo: GroupsV2MigrationInfo, + viewController: UIViewController) { + self.groupThread = groupThread + self.migrationInfo = migrationInfo + self.viewController = viewController + + super.init(frame: .zero) + } + + @available(*, unavailable, message: "use other init() instead.") + required public init(coder aDecoder: NSCoder) { + notImplemented() + } + + private let label = UILabel() + + func configureLabel(format: String, highlightedSubstring: String) { + let text = String(format: format, highlightedSubstring) + let attributedString = NSMutableAttributedString(string: text) + attributedString.setAttributes([ + .foregroundColor: Theme.accentBlueColor + ], + forSubstring: highlightedSubstring) + label.attributedText = attributedString + } + + func configureDefaultLabelContents() { + let format = NSLocalizedString("GROUPS_LEGACY_GROUP_DESCRIPTION_FORMAT", + comment: "Brief explanation of legacy groups. Embeds {{ a \"learn more\" link. }}.") + let learnMoreText = NSLocalizedString("GROUPS_LEGACY_GROUP_LEARN_MORE_LINK", + comment: "A \"learn more\" link with more information about legacy groups.") + configureLabel(format: format, highlightedSubstring: learnMoreText) + + isUserInteractionEnabled = true + addGestureRecognizer(UITapGestureRecognizer(target: self, + action: #selector(didTapLearnMore))) + } + + func configureCantUpgradeDueToMembersContents() { + let format = NSLocalizedString("GROUPS_LEGACY_GROUP_DESCRIPTION_MEMBERS_CANT_BE_MIGRATED_FORMAT", + comment: "Indicates that a legacy group can't be upgraded because some members can't be migrated. Embeds {{ an \"learn more\" link. }}.") + let learnMoreText = NSLocalizedString("GROUPS_LEGACY_GROUP_LEARN_MORE_LINK", + comment: "A \"learn more\" link with more information about legacy groups.") + configureLabel(format: format, highlightedSubstring: learnMoreText) + + isUserInteractionEnabled = true + addGestureRecognizer(UITapGestureRecognizer(target: self, + action: #selector(didTapCantUpgradeDueToMemberState))) + } + + public func configure() { + backgroundColor = Theme.secondaryBackgroundColor + layer.cornerRadius = 4 + layoutMargins = UIEdgeInsets(top: 10, leading: 12, bottom: 10, trailing: 12) + + label.textColor = Theme.secondaryTextAndIconColor + label.font = .ows_dynamicTypeFootnote + label.numberOfLines = 0 + label.lineBreakMode = .byWordWrapping + addSubview(label) + label.autoPinEdgesToSuperviewMargins() + + switch migrationInfo.state { + case .canBeMigrated: + let format = NSLocalizedString("GROUPS_LEGACY_GROUP_DESCRIPTION_WITH_UPGRADE_OFFER_FORMAT", + comment: "Explanation of legacy groups. Embeds {{ an \"upgrade\" link. }}.") + let upgradeText = NSLocalizedString("GROUPS_LEGACY_GROUP_UPGRADE_LINK", + comment: "An \"upgrade\" link for upgrading legacy groups to new groups.") + configureLabel(format: format, highlightedSubstring: upgradeText) + + isUserInteractionEnabled = true + addGestureRecognizer(UITapGestureRecognizer(target: self, + action: #selector(didTapUpgrade))) + case .cantBeMigrated_FeatureNotEnabled: + configureDefaultLabelContents() + case .cantBeMigrated_NotAV1Group: + owsFailDebug("Unexpected group.") + configureDefaultLabelContents() + case .cantBeMigrated_NotRegistered: + owsFailDebug("Not registered.") + configureDefaultLabelContents() + case .cantBeMigrated_LocalUserIsNotAMember: + Logger.verbose("Local user is not a member.") + configureDefaultLabelContents() + case .cantBeMigrated_NotInProfileWhitelist: + // TODO: Should we special-case this? + Logger.verbose("Local user has not accepted message request.") + configureDefaultLabelContents() + case .cantBeMigrated_TooManyMembers: + let format = NSLocalizedString("GROUPS_LEGACY_GROUP_DESCRIPTION_TOO_MANY_MEMBERS_FORMAT", + comment: "Indicates that a legacy group can't be upgraded because it has too many members. Embeds {{ an \"learn more\" link. }}.") + let learnMoreText = NSLocalizedString("GROUPS_LEGACY_GROUP_LEARN_MORE_LINK", + comment: "A \"learn more\" link with more information about legacy groups.") + configureLabel(format: format, highlightedSubstring: learnMoreText) + + isUserInteractionEnabled = true + addGestureRecognizer(UITapGestureRecognizer(target: self, + action: #selector(didTapTooManyMembers))) + case .cantBeMigrated_MembersWithoutUuids, + .cantBeMigrated_MembersWithoutCapabilities: + configureCantUpgradeDueToMembersContents() + case .cantBeMigrated_MembersWithoutProfileKey: + owsFailDebug("Manual migrations should ignore missing profile keys.") + configureCantUpgradeDueToMembersContents() + } + } + + // MARK: - Events + + @objc + public func didTapLearnMore() { + guard let viewController = viewController else { + owsFailDebug("Missing viewController.") + return + } + LegacyGroupViewLearnMoreView().present(fromViewController: viewController) + } + + @objc + public func didTapUpgrade() { + showMigrationAlert(mode: .upgradeGroup) + } + + @objc + public func didTapTooManyMembers() { + showMigrationAlert(mode: .tooManyMembers) + } + + @objc + public func didTapCantUpgradeDueToMemberState() { + showMigrationAlert(mode: .someMembersCantMigrate) + } + + private func showMigrationAlert(mode: LegacyGroupMigrationView.Mode) { + guard let viewController = viewController else { + owsFailDebug("Missing viewController.") + return + } + let view = LegacyGroupMigrationView(groupThread: groupThread, + mode: mode, + migrationInfo: migrationInfo) + view.present(fromViewController: viewController) + } +} + +// MARK: - + +private class LegacyGroupViewLearnMoreView: UIView { + + weak var actionSheetController: ActionSheetController? + + init() { + super.init(frame: .zero) + } + + required init(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func present(fromViewController: UIViewController) { + let buildLabel = { () -> UILabel in + let label = UILabel() + label.textColor = Theme.primaryTextColor + label.numberOfLines = 0 + label.lineBreakMode = .byWordWrapping + return label + } + + let titleLabel = buildLabel() + titleLabel.font = UIFont.ows_dynamicTypeTitle2.ows_semibold + titleLabel.text = NSLocalizedString("GROUPS_LEGACY_GROUP_ALERT_TITLE", + comment: "Title for the 'legacy group' alert view.") + + let section1TitleLabel = buildLabel() + section1TitleLabel.font = UIFont.ows_dynamicTypeBody.ows_semibold + section1TitleLabel.text = NSLocalizedString("GROUPS_LEGACY_GROUP_ALERT_SECTION_1_TITLE", + comment: "Title for the first section of the 'legacy group' alert view.") + + let section1BodyLabel = buildLabel() + section1BodyLabel.font = .ows_dynamicTypeBody + section1BodyLabel.text = NSLocalizedString("GROUPS_LEGACY_GROUP_ALERT_SECTION_1_BODY", + comment: "Body text for the first section of the 'legacy group' alert view.") + + let section2TitleLabel = buildLabel() + section2TitleLabel.font = UIFont.ows_dynamicTypeBody.ows_semibold + section2TitleLabel.text = NSLocalizedString("GROUPS_LEGACY_GROUP_ALERT_SECTION_2_TITLE", + comment: "Title for the second section of the 'legacy group' alert view.") + + let section2BodyLabel = buildLabel() + section2BodyLabel.font = .ows_dynamicTypeBody + section2BodyLabel.text = NSLocalizedString("GROUPS_LEGACY_GROUP_ALERT_SECTION_2_BODY", + comment: "Body text for the second section of the 'legacy group' alert view.") + + let section3BodyLabel = buildLabel() + section3BodyLabel.font = .ows_dynamicTypeBody + section3BodyLabel.text = NSLocalizedString("GROUPS_LEGACY_GROUP_ALERT_SECTION_3_BODY", + comment: "Body text for the third section of the 'legacy group' alert view.") + + let buttonFont = UIFont.ows_dynamicTypeBodyClamped.ows_semibold + let buttonHeight = OWSFlatButton.heightForFont(buttonFont) + let okayButton = OWSFlatButton.button(title: CommonStrings.okayButton, + font: buttonFont, + titleColor: .white, + backgroundColor: .ows_accentBlue, + target: self, + selector: #selector(dismissAlert)) + okayButton.autoSetDimension(.height, toSize: buttonHeight) + + let stackView = UIStackView(arrangedSubviews: [ + titleLabel, + UIView.spacer(withHeight: 28), + section1TitleLabel, + UIView.spacer(withHeight: 4), + section1BodyLabel, + UIView.spacer(withHeight: 21), + section2TitleLabel, + UIView.spacer(withHeight: 4), + section2BodyLabel, + UIView.spacer(withHeight: 24), + section3BodyLabel, + UIView.spacer(withHeight: 28), + okayButton + ]) + stackView.axis = .vertical + stackView.alignment = .fill + stackView.layoutMargins = UIEdgeInsets(top: 48, leading: 20, bottom: 38, trailing: 24) + stackView.isLayoutMarginsRelativeArrangement = true + stackView.addBackgroundView(withBackgroundColor: Theme.backgroundColor) + + layoutMargins = .zero + addSubview(stackView) + stackView.autoPinEdgesToSuperviewMargins() + + let actionSheetController = ActionSheetController() + actionSheetController.customHeader = self + actionSheetController.isCancelable = true + fromViewController.presentActionSheet(actionSheetController) + self.actionSheetController = actionSheetController + } + + // MARK: - Events + + @objc + func dismissAlert() { + actionSheetController?.dismiss(animated: true) + } +} + +// MARK: - + +private class LegacyGroupMigrationView: UIView { + + enum Mode { + case upgradeGroup + case tooManyMembers + case someMembersCantMigrate + } + + private let groupThread: TSGroupThread + private let mode: Mode + private let migrationInfo: GroupsV2MigrationInfo + + weak var actionSheetController: ActionSheetController? + + required init(groupThread: TSGroupThread, + mode: Mode, + migrationInfo: GroupsV2MigrationInfo) { + self.groupThread = groupThread + self.mode = mode + self.migrationInfo = migrationInfo + + super.init(frame: .zero) + } + + required init(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func present(fromViewController: UIViewController) { + let subviews = buildContents() + + let stackView = UIStackView(arrangedSubviews: subviews) + stackView.axis = .vertical + stackView.alignment = .fill + stackView.layoutMargins = UIEdgeInsets(top: 48, leading: 20, bottom: 38, trailing: 24) + stackView.isLayoutMarginsRelativeArrangement = true + stackView.addBackgroundView(withBackgroundColor: Theme.backgroundColor) + + layoutMargins = .zero + addSubview(stackView) + stackView.autoPinEdgesToSuperviewMargins() + + let actionSheetController = ActionSheetController(isFullWidth: true) + actionSheetController.customHeader = self + actionSheetController.isCancelable = true + fromViewController.presentActionSheet(actionSheetController) + self.actionSheetController = actionSheetController + } + + private struct Builder { + + // MARK: - Dependencies + + private static var contactsManager: OWSContactsManager { + return Environment.shared.contactsManager + } + + // MARK: - + + var subviews = [UIView]() + + func buildLabel() -> UILabel { + let label = UILabel() + label.textColor = Theme.primaryTextColor + label.numberOfLines = 0 + label.lineBreakMode = .byWordWrapping + return label + } + + func buildTitleLabel(text: String) -> UILabel { + let label = UILabel() + label.text = text + label.textColor = Theme.primaryTextColor + label.font = UIFont.ows_dynamicTypeTitle2.ows_semibold + label.numberOfLines = 0 + label.lineBreakMode = .byWordWrapping + label.textAlignment = .center + return label + } + + mutating func addTitleLabel(text: String) { + subviews.append(buildTitleLabel(text: text)) + } + + mutating func addVerticalSpacer(height: CGFloat) { + subviews.append(UIView.spacer(withHeight: height)) + } + + mutating func addRow(subview: UIView, hasBulletPoint: Bool) { + + let bulletSize = CGSize(width: 5, height: 11) + let bulletWrapper = UIView.container() + bulletWrapper.autoSetDimension(.width, toSize: bulletSize.width) + + if hasBulletPoint { + let bullet = UIView() + bullet.autoSetDimensions(to: bulletSize) + // TODO: Dark theme value? + bullet.backgroundColor = UIColor(rgbHex: 0xdedede) + bulletWrapper.addSubview(bullet) + bullet.autoPinEdge(toSuperviewEdge: .top, withInset: 4) + bullet.autoPinEdge(toSuperviewEdge: .leading) + bullet.autoPinEdge(toSuperviewEdge: .trailing) + } + + bulletWrapper.setContentHuggingHorizontalHigh() + subview.setContentHuggingHorizontalLow() + subview.setCompressionResistanceVerticalHigh() + + let row = UIStackView(arrangedSubviews: [bulletWrapper, subview]) + row.axis = .horizontal + row.alignment = .top + row.spacing = 20 + row.setCompressionResistanceVerticalHigh() + subviews.append(row) + } + + mutating func addBodyLabel(_ text: String) { + let label = buildLabel() + label.font = .ows_dynamicTypeBody + label.text = text + addRow(subview: label, hasBulletPoint: true) + } + + mutating func addMemberRow(address: SignalServiceAddress, + transaction: SDSAnyReadTransaction) { + + let avatarSize: UInt = 28 + let conversationColorName = TSContactThread.conversationColorName(forContactAddress: address, + transaction: transaction) + let avatarBuilder = OWSContactAvatarBuilder(address: address, + colorName: conversationColorName, + diameter: avatarSize, + transaction: transaction) + let avatar = avatarBuilder.build(with: transaction) + + let avatarView = AvatarImageView() + avatarView.image = avatar + avatarView.autoSetDimensions(to: CGSize(square: CGFloat(avatarSize))) + avatarView.setContentHuggingHorizontalHigh() + + let label = buildLabel() + label.font = .ows_dynamicTypeBody + label.text = Self.contactsManager.displayName(for: address, transaction: transaction) + label.setContentHuggingHorizontalLow() + + let row = UIStackView(arrangedSubviews: [avatarView, label]) + row.axis = .horizontal + row.alignment = .center + row.spacing = 6 + row.setContentHuggingHorizontalLow() + + addRow(subview: row, hasBulletPoint: false) + } + + mutating func addBottomButton(title: String, + titleColor: UIColor, + backgroundColor: UIColor, + target: Any, + selector: Selector) { + let buttonFont = UIFont.ows_dynamicTypeBodyClamped.ows_semibold + let buttonHeight = OWSFlatButton.heightForFont(buttonFont) + let upgradeButton = OWSFlatButton.button(title: title, + font: buttonFont, + titleColor: titleColor, + backgroundColor: backgroundColor, + target: target, + selector: selector) + upgradeButton.autoSetDimension(.height, toSize: buttonHeight) + subviews.append(upgradeButton) + } + + mutating func addOkayButton(target: Any, selector: Selector) { + addBottomButton(title: CommonStrings.okayButton, + titleColor: .white, + backgroundColor: .ows_accentBlue, + target: target, + selector: selector) + } + } + + private func buildContents() -> [UIView] { + switch mode { + case .upgradeGroup: + return buildUpgradeGroupContents() + case .tooManyMembers: + return buildTooManyMembersContents() + case .someMembersCantMigrate: + return buildSomeMembersCantMigrateContents() + } + } + + private func buildUpgradeGroupContents() -> [UIView] { + var builder = Builder() + + builder.addTitleLabel(text: NSLocalizedString("GROUPS_LEGACY_GROUP_UPGRADE_ALERT_TITLE", + comment: "Title for the 'upgrade legacy group' alert view.")) + builder.addVerticalSpacer(height: 28) + + builder.addBodyLabel(NSLocalizedString("GROUPS_LEGACY_GROUP_NEW_GROUP_DESCRIPTION", + comment: "Explanation of new groups in the 'legacy group' alert views.")) + builder.addVerticalSpacer(height: 20) + builder.addBodyLabel(NSLocalizedString("GROUPS_LEGACY_GROUP_UPGRADE_ALERT_SECTION_2_BODY", + comment: "Body text for the second section of the 'upgrade legacy group' alert view.")) + + let migrationInfo = self.migrationInfo + databaseStorage.read { transaction in + // TODO: We need to break these out into separate sections. + // TODO: Scroll view? + // let members = (migrationInfo.membersWithoutUuids + + // migrationInfo.membersWithoutCapabilities + + // migrationInfo.membersWithoutProfileKeys) + var members = (migrationInfo.membersWithoutUuids + + migrationInfo.membersWithoutCapabilities + + migrationInfo.membersWithoutProfileKeys) + members = members + members + members = members + members + members = members + members + members = members + members + if !members.isEmpty { + builder.addVerticalSpacer(height: 20) + builder.addBodyLabel(NSLocalizedString("GROUPS_LEGACY_GROUP_UPGRADE_ALERT_SECTION_INVITED_MEMBERS", + comment: "Body text for the 'invites members' section of the 'upgrade legacy group' alert view.")) + for address in members { + builder.addVerticalSpacer(height: 16) + builder.addMemberRow(address: address, transaction: transaction) + } + } + } + + builder.addVerticalSpacer(height: 40) + + builder.addBottomButton(title: NSLocalizedString("GROUPS_LEGACY_GROUP_UPGRADE_ALERT_UPGRADE_BUTTON", + comment: "Label for the 'upgrade this group' button in the 'upgrade legacy group' alert view."), + titleColor: .white, + backgroundColor: .ows_accentBlue, + target: self, + selector: #selector(upgradeGroup)) + builder.addVerticalSpacer(height: 5) + builder.addBottomButton(title: CommonStrings.cancelButton, + titleColor: .ows_accentBlue, + backgroundColor: .white, + target: self, + selector: #selector(dismissAlert)) + + return builder.subviews + } + + private func buildTooManyMembersContents() -> [UIView] { + var builder = Builder() + + builder.addTitleLabel(text: NSLocalizedString("GROUPS_LEGACY_GROUP_CANT_UPGRADE_ALERT_TITLE", + comment: "Title for the 'can't upgrade legacy group' alert view.")) + builder.addVerticalSpacer(height: 28) + + builder.addBodyLabel(NSLocalizedString("GROUPS_LEGACY_GROUP_NEW_GROUP_DESCRIPTION", + comment: "Explanation of new groups in the 'legacy group' alert views.")) + builder.addVerticalSpacer(height: 20) + let descriptionFormat = NSLocalizedString("GROUPS_LEGACY_GROUP_CANT_UPGRADE_ALERT_TOO_MANY_MEMBERS_FORMAT", + comment: "Text indicating that a legacy group can't be upgraded because it has too many members. Embeds {{ The maximum number of members allowed in a group. }}.") + let maxMemberCount = OWSFormat.formatUInt(RemoteConfig.groupsV2MaxGroupSizeHardLimit - 1) + let description = String(format: descriptionFormat, maxMemberCount) + builder.addBodyLabel(description) + + builder.addVerticalSpacer(height: 40) + + builder.addOkayButton(target: self, selector: #selector(dismissAlert)) + + return builder.subviews + } + + private func buildSomeMembersCantMigrateContents() -> [UIView] { + var builder = Builder() + + builder.addTitleLabel(text: NSLocalizedString("GROUPS_LEGACY_GROUP_NEW_GROUPS_ALERT_TITLE", + comment: "Title for the 'new groups' alert view.")) + builder.addVerticalSpacer(height: 28) + + builder.addBodyLabel(NSLocalizedString("GROUPS_LEGACY_GROUP_NEW_GROUP_DESCRIPTION", + comment: "Explanation of new groups in the 'legacy group' alert views.")) + builder.addVerticalSpacer(height: 20) + builder.addBodyLabel(NSLocalizedString("GROUPS_LEGACY_GROUP_CANT_UPGRADE_YET_1", + comment: "Explanation group migration for groups that can't yet be migrated in the 'legacy group' alert views.")) + builder.addVerticalSpacer(height: 20) + builder.addBodyLabel(NSLocalizedString("GROUPS_LEGACY_GROUP_CANT_UPGRADE_YET_2", + comment: "Explanation group migration for groups that can't yet be migrated in the 'legacy group' alert views.")) + + builder.addVerticalSpacer(height: 40) + + builder.addOkayButton(target: self, selector: #selector(dismissAlert)) + + return builder.subviews + } + + // MARK: - Events + + @objc + func dismissAlert() { + actionSheetController?.dismiss(animated: true) + } + + @objc + func upgradeGroup() { + guard let actionSheetController = actionSheetController else { + owsFailDebug("Missing actionSheetController.") + return + } + ModalActivityIndicatorViewController.present(fromViewController: actionSheetController, + canCancel: false) { modalActivityIndicator in + firstly { + self.upgradePromise() + }.done { (_) in + modalActivityIndicator.dismiss { + self.dismissAndShowUpgradeSuccessToast() + } + }.catch { error in + owsFailDebug("Error: \(error)") + + modalActivityIndicator.dismiss { + self.showUpgradeFailedAlert(error: error) + } + } + } + } + + private func upgradePromise() -> Promise { + GroupsV2Migration.tryManualMigration(groupThread: groupThread).asVoid() + } + + private func dismissAndShowUpgradeSuccessToast() { + AssertIsOnMainThread() + + guard let actionSheetController = actionSheetController else { + owsFailDebug("Missing actionSheetController.") + return + } + + actionSheetController.dismiss(animated: true) { + self.showUpgradeSuccessToast() + } + } + + private func showUpgradeSuccessToast() { + guard let viewController = UIApplication.shared.frontmostViewController else { + owsFailDebug("Missing frontmostViewController.") + return + } + + let text = NSLocalizedString("GROUPS_LEGACY_GROUP_UPGRADE_ALERT_UPGRADE_SUCCEEDED", + comment: "Message indicating the group update succeeded.") + let toastController = ToastController(text: text) + let toastInset = viewController.bottomLayoutGuide.length + 8 + toastController.presentToastView(fromBottomOfView: viewController.view, inset: toastInset) + } + + private func showUpgradeFailedAlert(error: Error) { + AssertIsOnMainThread() + + guard let actionSheetController = actionSheetController else { + owsFailDebug("Missing actionSheetController.") + return + } + + let title: String + // TODO: We need final copy. + title = NSLocalizedString("GROUPS_LEGACY_GROUP_UPGRADE_ALERT_UPGRADE_FAILED_ERROR", + comment: "Error indicating the group update failed.") + OWSActionSheets.showActionSheet(title: title, fromViewController: actionSheetController) + } +} diff --git a/Signal/src/util/RegistrationUtils.m b/Signal/src/util/RegistrationUtils.m index 52a88d24ea..20135f14c0 100644 --- a/Signal/src/util/RegistrationUtils.m +++ b/Signal/src/util/RegistrationUtils.m @@ -53,7 +53,7 @@ NS_ASSUME_NONNULL_BEGIN return; } - ActionSheetController *actionSheet = [[ActionSheetController alloc] initWithTitle:nil message:nil]; + ActionSheetController *actionSheet = [ActionSheetController new]; [actionSheet addAction:[[ActionSheetAction alloc] diff --git a/Signal/translations/en.lproj/Localizable.strings b/Signal/translations/en.lproj/Localizable.strings index 9fcda3e61d..ef7918ed4f 100644 --- a/Signal/translations/en.lproj/Localizable.strings +++ b/Signal/translations/en.lproj/Localizable.strings @@ -1810,9 +1810,6 @@ /* Label indicating that a group member is an admin. */ "GROUP_MEMBER_ADMIN_INDICATOR" = "Admin"; -/* Format string for the group member count indicator. Embeds {{ %1$@ the number of members in the group, %2$@ the maximum number of members in the group. }}. */ -"GROUP_MEMBER_COUNT_FORMAT" = "%1$@/%2$@"; - /* The 'group member count' indicator when there are no members in the group. */ "GROUP_MEMBER_COUNT_LABEL_0" = "No members"; @@ -1822,6 +1819,9 @@ /* Format for the 'group member count' indicator. Embeds {the number of group members}. */ "GROUP_MEMBER_COUNT_LABEL_FORMAT" = "%@ members"; +/* Format string for the group member count indicator. Embeds {{ the number of members in the group }}. */ +"GROUP_MEMBER_COUNT_WITHOUT_LIMIT_FORMAT" = "%@ Members"; + /* Label indicating the local user. */ "GROUP_MEMBER_LOCAL_USER" = "You"; @@ -2014,6 +2014,21 @@ /* Message indicating that the group's name was changed. Embeds {{new group name}}. */ "GROUP_UPDATED_NAME_UPDATED_FORMAT" = "Group name is now “%@”."; +/* Message indicating that the group was migrated. */ +"GROUP_WAS_MIGRATED" = "This group was upgraded to a New Group."; + +/* Message indicating that 1 user was dropped while migrating the group. */ +"GROUP_WAS_MIGRATED_USERS_DROPPED_1" = "A member couldn’t be added to the New Group and has been removed from the group."; + +/* Message indicating that N users were dropped while migrating the group. Embeds {{ the number of dropped users }}. */ +"GROUP_WAS_MIGRATED_USERS_DROPPED_N_FORMAT" = "%@ members couldn’t be added to the New Group and have been removed from the group."; + +/* Message indicating that 1 user was invited while migrating the group. */ +"GROUP_WAS_MIGRATED_USERS_INVITED_1" = "A member couldn’t be added to the New Group and has been invited to join"; + +/* Message indicating that N users were invited while migrating the group. Embeds {{ the number of invited users }}. */ +"GROUP_WAS_MIGRATED_USERS_INVITED_N_FORMAT" = "%@ members couldn’t be added to the New Group and have been invited to join."; + /* Message indicating that the local user left the group. */ "GROUP_YOU_LEFT" = "You have left the group."; @@ -2053,6 +2068,18 @@ /* Title for the 'legacy group' alert view. */ "GROUPS_LEGACY_GROUP_ALERT_TITLE" = "Legacy vs. New Groups"; +/* Title for the 'can't upgrade legacy group' alert view. */ +"GROUPS_LEGACY_GROUP_CANT_UPGRADE_ALERT_TITLE" = "Can’t Upgrade to New Group"; + +/* Text indicating that a legacy group can't be upgraded because it has too many members. Embeds {{ The maximum number of members allowed in a group. }}. */ +"GROUPS_LEGACY_GROUP_CANT_UPGRADE_ALERT_TOO_MANY_MEMBERS_FORMAT" = "This group can’t be upgraded to a New Group because it has too many members. The maximum group size that can be upgraded is %@."; + +/* Explanation group migration for groups that can't yet be migrated in the 'legacy group' alert views. */ +"GROUPS_LEGACY_GROUP_CANT_UPGRADE_YET_1" = "When all current group members are using a version of Signal capable of joining a New Group, this group will be upgraded."; + +/* Explanation group migration for groups that can't yet be migrated in the 'legacy group' alert views. */ +"GROUPS_LEGACY_GROUP_CANT_UPGRADE_YET_2" = "All message history and media will be kept from before the upgrade."; + /* Title for alert that explains that a new group will be a legacy group because 1 member does not support v2 groups. */ "GROUPS_LEGACY_GROUP_CREATION_WARNING_ALERT_TITLE_1" = "A Legacy Group will be created because 1 member is using an old version of Signal. You can create a New Style Group with them after they update Signal, or remove them before creating the group."; @@ -2068,12 +2095,48 @@ /* Brief explanation of legacy groups. Embeds {{ a \"learn more\" link. }}. */ "GROUPS_LEGACY_GROUP_DESCRIPTION_FORMAT" = "This is a Legacy Group. Features like group admins are only available for New Groups. %@"; +/* Indicates that a legacy group can't be upgraded because some members can't be migrated. Embeds {{ an \"learn more\" link. }}. */ +"GROUPS_LEGACY_GROUP_DESCRIPTION_MEMBERS_CANT_BE_MIGRATED_FORMAT" = "This Legacy Group is not yet ready to be upgraded to a New Group. %@."; + +/* Indicates that a legacy group can't be upgraded because it has too many members. Embeds {{ an \"learn more\" link. }}. */ +"GROUPS_LEGACY_GROUP_DESCRIPTION_TOO_MANY_MEMBERS_FORMAT" = "This Legacy Group can’t be upgraded to a New Group because it is too large. %@."; + +/* Explanation of legacy groups. Embeds {{ an \"upgrade\" link. }}. */ +"GROUPS_LEGACY_GROUP_DESCRIPTION_WITH_UPGRADE_OFFER_FORMAT" = "This is a Legacy Group. To access new features like @mentions and admins, %@."; + /* Label indicating a legacy group. */ "GROUPS_LEGACY_GROUP_INDICATOR" = "Legacy Group"; /* A \"learn more\" link with more information about legacy groups. */ "GROUPS_LEGACY_GROUP_LEARN_MORE_LINK" = "Learn More"; +/* Explanation of new groups in the 'legacy group' alert views. */ +"GROUPS_LEGACY_GROUP_NEW_GROUP_DESCRIPTION" = "New Groups have features like @mentions and group admins, and will support more features in the future."; + +/* Title for the 'new groups' alert view. */ +"GROUPS_LEGACY_GROUP_NEW_GROUPS_ALERT_TITLE" = "New Groups"; + +/* Body text for the second section of the 'upgrade legacy group' alert view. */ +"GROUPS_LEGACY_GROUP_UPGRADE_ALERT_SECTION_2_BODY" = "All message history and media has been kept from before the upgrade."; + +/* Body text for the 'invites members' section of the 'upgrade legacy group' alert view. */ +"GROUPS_LEGACY_GROUP_UPGRADE_ALERT_SECTION_INVITED_MEMBERS" = "These members will need to accept an invite to join this group again, and will not receive group messages until they accept:"; + +/* Title for the 'upgrade legacy group' alert view. */ +"GROUPS_LEGACY_GROUP_UPGRADE_ALERT_TITLE" = "Upgrade to New Group"; + +/* Label for the 'upgrade this group' button in the 'upgrade legacy group' alert view. */ +"GROUPS_LEGACY_GROUP_UPGRADE_ALERT_UPGRADE_BUTTON" = "Upgrade this Group"; + +/* Error indicating the group update failed. */ +"GROUPS_LEGACY_GROUP_UPGRADE_ALERT_UPGRADE_FAILED_ERROR" = "Upgrade Failed. Please try again."; + +/* Message indicating the group update succeeded. */ +"GROUPS_LEGACY_GROUP_UPGRADE_ALERT_UPGRADE_SUCCEEDED" = "Upgraded to New Group."; + +/* An \"upgrade\" link for upgrading legacy groups to new groups. */ +"GROUPS_LEGACY_GROUP_UPGRADE_LINK" = "upgrade this group"; + /* Message for the 'replace group admin' alert. */ "GROUPS_REPLACE_ADMIN_ALERT_MESSAGE" = "Before you leave, choose a new admin for this group."; diff --git a/SignalMessaging/ViewControllers/ActionSheetController.swift b/SignalMessaging/ViewControllers/ActionSheetController.swift index 847eac9930..e784d62ae0 100644 --- a/SignalMessaging/ViewControllers/ActionSheetController.swift +++ b/SignalMessaging/ViewControllers/ActionSheetController.swift @@ -7,6 +7,8 @@ import UIKit @objc open class ActionSheetController: OWSViewController { + private var isFullWidth = false + private let contentView = UIView() private let stackView = UIStackView() private let scrollView = UIScrollView() @@ -62,7 +64,13 @@ open class ActionSheetController: OWSViewController { @objc public convenience init(title: String? = nil, message: String? = nil) { + self.init(title: title, message: message, isFullWidth: false) + } + + @objc + public convenience init(title: String? = nil, message: String? = nil, isFullWidth: Bool = false) { self.init() + self.isFullWidth = isFullWidth createHeader(title: title, message: message) } @@ -113,7 +121,12 @@ open class ActionSheetController: OWSViewController { scrollView.clipsToBounds = false scrollView.showsVerticalScrollIndicator = false scrollView.autoPinEdge(toSuperviewEdge: .bottom) - scrollView.autoHCenterInSuperview() + if isFullWidth { + scrollView.autoPinEdge(toSuperviewEdge: .leading) + scrollView.autoPinEdge(toSuperviewEdge: .trailing) + } else { + scrollView.autoHCenterInSuperview() + } scrollView.autoMatch(.height, to: .height, of: view, withOffset: 0, relation: .lessThanOrEqual) // Prefer to be full width, but don't exceed the maximum width diff --git a/SignalMessaging/Views/Mentions/MentionPicker.swift b/SignalMessaging/Views/Mentions/MentionPicker.swift index 42a3b443a1..9154a6af85 100644 --- a/SignalMessaging/Views/Mentions/MentionPicker.swift +++ b/SignalMessaging/Views/Mentions/MentionPicker.swift @@ -40,9 +40,8 @@ class MentionPicker: UIView { address: address, username: Self.profileManager.username(for: address, transaction: transaction), displayName: Self.contactsManager.displayName(for: address, transaction: transaction), - conversationColorName: ConversationColorName( - rawValue: TSContactThread.conversationColorName(forContactAddress: address, transaction: transaction) - ) + conversationColorName: TSContactThread.conversationColorName(forContactAddress: address, + transaction: transaction) ) } } diff --git a/SignalMessaging/contacts/OWSContactsManager.h b/SignalMessaging/contacts/OWSContactsManager.h index 51ff5236f7..7d277f5de0 100644 --- a/SignalMessaging/contacts/OWSContactsManager.h +++ b/SignalMessaging/contacts/OWSContactsManager.h @@ -92,7 +92,6 @@ extern NSString *const OWSContactsManagerSignalAccountsDidChangeNotification; transaction:(SDSAnyReadTransaction *)transaction; - (NSString *)comparableNameForAddress:(SignalServiceAddress *)address transaction:(SDSAnyReadTransaction *)transaction; -- (nullable UIImage *)systemContactOrSyncedImageForAddress:(nullable SignalServiceAddress *)address; - (nullable UIImage *)profileImageForAddressWithSneakyTransaction:(nullable SignalServiceAddress *)address; - (nullable NSData *)profileImageDataForAddressWithSneakyTransaction:(nullable SignalServiceAddress *)address; - (nullable UIImage *)imageForAddress:(nullable SignalServiceAddress *)address diff --git a/SignalMessaging/contacts/OWSContactsManager.m b/SignalMessaging/contacts/OWSContactsManager.m index d620854c20..bb9c0ada5b 100644 --- a/SignalMessaging/contacts/OWSContactsManager.m +++ b/SignalMessaging/contacts/OWSContactsManager.m @@ -1193,13 +1193,14 @@ NSString *const OWSContactsManagerKeyNextFullIntersectionDate = @"OWSContactsMan } - (nullable UIImage *)systemContactOrSyncedImageForAddress:(nullable SignalServiceAddress *)address + transaction:(SDSAnyReadTransaction *)transaction { if (address == nil) { OWSFailDebug(@"address was unexpectedly nil"); return nil; } - NSString *_Nullable phoneNumber = [self phoneNumberForAddress:address]; + NSString *_Nullable phoneNumber = [self phoneNumberForAddress:address transaction:transaction]; Contact *_Nullable contact = self.allContactsMap[phoneNumber]; if (contact != nil && contact.cnContactId != nil) { @@ -1210,7 +1211,7 @@ NSString *const OWSContactsManagerKeyNextFullIntersectionDate = @"OWSContactsMan } // If we haven't loaded system contacts yet, we may have a cached copy in the db - SignalAccount *signalAccount = [self fetchSignalAccountForAddress:address]; + SignalAccount *signalAccount = [self fetchSignalAccountForAddress:address transaction:transaction]; if (signalAccount == nil) { return nil; } @@ -1266,13 +1267,11 @@ NSString *const OWSContactsManagerKeyNextFullIntersectionDate = @"OWSContactsMan return nil; } - __block UIImage *_Nullable image = [self systemContactOrSyncedImageForAddress:address]; - if (image != nil) { - return image; - } - - // Else try to use the image from their profile - return [self profileImageForAddressWithSneakyTransaction:address]; + __block UIImage *_Nullable image; + [self.databaseStorage readWithBlock:^(SDSAnyReadTransaction *transaction) { + image = [self imageForAddress:address transaction:transaction]; + }]; + return image; } - (nullable UIImage *)imageForAddress:(nullable SignalServiceAddress *)address @@ -1283,7 +1282,7 @@ NSString *const OWSContactsManagerKeyNextFullIntersectionDate = @"OWSContactsMan return nil; } - __block UIImage *_Nullable image = [self systemContactOrSyncedImageForAddress:address]; + __block UIImage *_Nullable image = [self systemContactOrSyncedImageForAddress:address transaction:transaction]; if (image != nil) { return image; } diff --git a/SignalMessaging/groups/GroupsV2ChangeSetImpl.swift b/SignalMessaging/groups/GroupsV2ChangeSetImpl.swift index 9ae5e78c35..1ac9ee47c1 100644 --- a/SignalMessaging/groups/GroupsV2ChangeSetImpl.swift +++ b/SignalMessaging/groups/GroupsV2ChangeSetImpl.swift @@ -556,7 +556,7 @@ public class GroupsV2ChangeSetImpl: NSObject, GroupsV2ChangeSet { continue } - guard allMemberCount <= GroupManager.maxGroupsV2MemberCount else { + guard allMemberCount <= GroupManager.groupsV2MaxGroupSizeHardLimit else { throw GroupsV2Error.tooManyMembers } diff --git a/SignalMessaging/groups/GroupsV2Migration.swift b/SignalMessaging/groups/GroupsV2Migration.swift index f4c89137d4..3deadd28c3 100644 --- a/SignalMessaging/groups/GroupsV2Migration.swift +++ b/SignalMessaging/groups/GroupsV2Migration.swift @@ -40,15 +40,28 @@ public class GroupsV2Migration { public static func v2MasterKey(forV1GroupId v1GroupId: Data) throws -> Data { try calculateMigrationMetadata(forV1GroupId: v1GroupId).v2MasterKey } +} + +// MARK: - + +public extension GroupsV2Migration { // MARK: - + static func tryManualMigration(groupThread: TSGroupThread) -> Promise { + guard FeatureFlags.groupsV2MigrationManualMigrationPolite || + FeatureFlags.groupsV2MigrationManualMigrationAggressive else { + return Promise(error: OWSAssertionError("Manual migration not enabled.")) + } + return tryToMigrate(groupThread: groupThread, migrationMode: manualMigrationMode) + } + // If there is a v1 group in the database that can be // migrated to a v2 group, try to migrate it to a v2 // group. It might or might not already be migrated on // the service. - public static func tryToMigrate(groupThread: TSGroupThread, - migrationMode: GroupsV2MigrationMode) -> Promise { + static func tryToMigrate(groupThread: TSGroupThread, + migrationMode: GroupsV2MigrationMode) -> Promise { firstly(on: .global()) { Self.databaseStorage.read { transaction in Self.canGroupBeMigratedByLocalUser(groupThread: groupThread, @@ -66,7 +79,7 @@ public class GroupsV2Migration { // If there is a corresponding v1 group in the local database, // update it to reflect the v1 group already on the service. - public static func updateAlreadyMigratedGroupIfNecessary(v2GroupId: Data) -> Promise { + static func updateAlreadyMigratedGroupIfNecessary(v2GroupId: Data) -> Promise { firstly(on: .global()) { () -> Promise in guard let groupThread = (Self.databaseStorage.read { transaction in TSGroupThread.fetch(groupId: v2GroupId, transaction: transaction) @@ -85,6 +98,45 @@ public class GroupsV2Migration { }.asVoid() } } + + static func migrationInfoForManualMigration(groupThread: TSGroupThread) -> GroupsV2MigrationInfo { + databaseStorage.read { transaction in + migrationInfoForManualMigration(groupThread: groupThread, + transaction: transaction) + } + } + + // Will return nil if the group cannot be migrated by the local + // user for any reason. + static func migrationInfoForManualMigration(groupThread: TSGroupThread, + transaction: SDSAnyReadTransaction) -> GroupsV2MigrationInfo { + + guard FeatureFlags.groupsV2MigrationManualMigrationPolite || + FeatureFlags.groupsV2MigrationManualMigrationAggressive else { + return .buildCannotBeMigrated(state: .cantBeMigrated_FeatureNotEnabled) + } + return migrationInfo(groupThread: groupThread, + migrationMode: manualMigrationMode, + transaction: transaction) + } + + private static var manualMigrationMode: GroupsV2MigrationMode { + owsAssertDebug(FeatureFlags.groupsV2MigrationManualMigrationPolite || + FeatureFlags.groupsV2MigrationManualMigrationAggressive) + + return (FeatureFlags.groupsV2MigrationManualMigrationAggressive + ? .manualMigrationAggressive + : .manualMigrationPolite) + } + + private static var autoMigrationMode: GroupsV2MigrationMode { + owsAssertDebug(FeatureFlags.groupsV2MigrationAutoMigrationPolite || + FeatureFlags.groupsV2MigrationAutoMigrationAggressive) + + return (FeatureFlags.groupsV2MigrationAutoMigrationAggressive + ? .manualMigrationAggressive + : .manualMigrationPolite) + } } // MARK: - @@ -99,6 +151,12 @@ fileprivate extension GroupsV2Migration { return GroupManager.ensureLocalProfileHasCommitmentIfNecessary() }.map(on: .global()) { () -> UnmigratedState in try Self.loadUnmigratedState(groupId: groupId) + }.then(on: .global()) { (unmigratedState: UnmigratedState) -> Promise in + firstly { + Self.tryToFillInMissingUuids(unmigratedState: unmigratedState) + }.map(on: .global()) { + return unmigratedState + } }.then(on: .global()) { (unmigratedState: UnmigratedState) -> Promise in addMigratingV2GroupId(unmigratedState.migrationMetadata.v1GroupId) addMigratingV2GroupId(unmigratedState.migrationMetadata.v2GroupId) @@ -120,6 +178,25 @@ fileprivate extension GroupsV2Migration { } } + static func tryToFillInMissingUuids(unmigratedState: UnmigratedState) -> Promise { + let groupMembership = unmigratedState.groupThread.groupModel.groupMembership + let membersToMigrate = membersToTryToMigrate(groupMembership: groupMembership) + let phoneNumbersWithoutUuids = membersToMigrate.compactMap { (address: SignalServiceAddress) -> String? in + if address.uuid != nil { + return nil + } + return address.phoneNumber + } + guard !phoneNumbersWithoutUuids.isEmpty else { + return Promise.value(()) + } + + Logger.info("Trying to fill in missing uuids: \(phoneNumbersWithoutUuids.count)") + + let discoveryTask = ContactDiscoveryTask(phoneNumbers: Set(phoneNumbersWithoutUuids)) + return discoveryTask.perform().asVoid() + } + static func attemptToMigrateByPullingFromService(unmigratedState: UnmigratedState, migrationMode: GroupsV2MigrationMode) -> Promise { @@ -190,12 +267,12 @@ fileprivate extension GroupsV2Migration { guard groupThread.isLocalUserFullMember else { throw OWSAssertionError("Local user cannot migrate group; is not a full member.") } - let memberSet = groupThread.groupMembership.allMembersOfAnyKind + let membersToMigrate = membersToTryToMigrate(groupMembership: groupThread.groupMembership) return firstly(on: .global()) { () -> Promise in - GroupManager.tryToEnableGroupsV2(for: Array(memberSet), isBlocking: true, ignoreErrors: true) + GroupManager.tryToEnableGroupsV2(for: Array(membersToMigrate), isBlocking: true, ignoreErrors: true) }.then(on: .global()) { () throws -> Promise in - self.groupsV2.tryToEnsureProfileKeyCredentials(for: Array(memberSet)) + self.groupsV2.tryToEnsureProfileKeyCredentials(for: Array(membersToMigrate)) }.then(on: .global()) { () throws -> Promise in guard let avatarData = unmigratedState.groupThread.groupModel.groupAvatarData else { // No avatar to upload. @@ -284,32 +361,32 @@ fileprivate extension GroupsV2Migration { // The group creator is an administrator; // the other members are normal users. var v2MembershipBuilder = GroupMembership.Builder() - for address in v1GroupModel.groupMembership.allMembersOfAnyKind { + let membersToMigrate = membersToTryToMigrate(groupMembership: v1GroupModel.groupMembership) + for address in membersToMigrate { guard address.uuid != nil else { Logger.warn("Member missing uuid: \(address).") - if migrationMode.skipMembersWithoutUuids { - Logger.warn("Discarding member: \(address).") - continue - } else { - throw OWSAssertionError("Member does not support gv2.") - } + owsAssertDebug(migrationMode.canSkipMembersWithoutUuids) + Logger.warn("Discarding member: \(address).") + continue } if !GroupManager.doesUserHaveGroupsV2Capability(address: address, transaction: transaction) { Logger.warn("Member without Groups v2 capability: \(address).") - owsAssertDebug(migrationMode.allowMembersWithoutCapabilities) + owsAssertDebug(migrationMode.canSkipMembersWithoutCapabilities) + continue } if !GroupManager.doesUserHaveGroupsV2MigrationCapability(address: address, transaction: transaction) { Logger.warn("Member without migration capability: \(address).") - owsAssertDebug(migrationMode.allowMembersWithoutCapabilities) + owsAssertDebug(migrationMode.canSkipMembersWithoutCapabilities) + continue } var isInvited = false if !groupsV2.hasProfileKeyCredential(for: address, transaction: transaction) { Logger.warn("Inviting user with unknown profile key: \(address).") - owsAssertDebug(migrationMode.allowMembersWithoutProfileKey) + owsAssertDebug(migrationMode.canInviteMembersWithoutProfileKey) isInvited = true } @@ -360,96 +437,231 @@ fileprivate extension GroupsV2Migration { static func canGroupBeMigratedByLocalUser(groupThread: TSGroupThread, migrationMode: GroupsV2MigrationMode, transaction: SDSAnyReadTransaction) -> Bool { - if migrationMode.onlyMigrateIfInProfileWhitelist { - guard profileManager.isThread(inProfileWhitelist: groupThread, - transaction: transaction) else { - return false - } - } + migrationInfo(groupThread: groupThread, + migrationMode: migrationMode, + transaction: transaction).canGroupBeMigrated + } + + // This method might be called for any group (v1 or v2). + // It returns a description of whether the group can be + // migrated, and if so under what conditions. + // + // Will return nil if the group cannot be migrated by the local + // user for any reason. + static func migrationInfo(groupThread: TSGroupThread, + migrationMode: GroupsV2MigrationMode, + transaction: SDSAnyReadTransaction) -> GroupsV2MigrationInfo { + guard groupThread.isGroupV1Thread else { - owsFailDebug("Not a v1 group.") - return false + return .buildCannotBeMigrated(state: .cantBeMigrated_NotAV1Group) } - guard groupThread.isLocalUserFullMember else { - return false - } - let groupMembership = groupThread.groupModel.groupMembership + let isLocalUserFullMember = groupThread.isLocalUserFullMember guard let localAddress = tsAccountManager.localAddress else { owsFailDebug("Missing localAddress.") - return false + return .buildCannotBeMigrated(state: .cantBeMigrated_NotRegistered) } - // Build member list. + let isGroupInProfileWhitelist = profileManager.isThread(inProfileWhitelist: groupThread, + transaction: transaction) + + let groupMembership = groupThread.groupModel.groupMembership + let membersToMigrate = membersToTryToMigrate(groupMembership: groupMembership) + + // Inspect member list. // // The group creator is an administrator; // the other members are normal users. - for address in groupMembership.allMembersOfAnyKind { + var membersWithoutUuids = [SignalServiceAddress]() + var membersWithoutCapabilities = [SignalServiceAddress]() + var membersWithoutProfileKeys = [SignalServiceAddress]() + var membersMigrated = [SignalServiceAddress]() + for address in membersToMigrate { if address.isEqualToAddress(localAddress) { continue } guard nil != address.uuid else { - Logger.warn("Member missing uuid: \(address).") - if migrationMode.skipMembersWithoutUuids { - continue - } else { - return false - } + Logger.warn("Member without uuid: \(address).") + membersWithoutUuids.append(address) + continue } if !GroupManager.doesUserHaveGroupsV2Capability(address: address, transaction: transaction) { Logger.warn("Member without Groups v2 capability: \(address).") - if !migrationMode.allowMembersWithoutCapabilities { - return false - } + membersWithoutCapabilities.append(address) + continue } if !GroupManager.doesUserHaveGroupsV2MigrationCapability(address: address, transaction: transaction) { Logger.warn("Member without migration capability: \(address).") - if !migrationMode.allowMembersWithoutCapabilities { - return false - } + membersWithoutCapabilities.append(address) + continue } + membersMigrated.append(address) + if !groupsV2.hasProfileKeyCredential(for: address, transaction: transaction) { Logger.warn("Member without profile key: \(address).") - if !migrationMode.allowMembersWithoutProfileKey { - return false - } + membersWithoutProfileKeys.append(address) + continue } } - return true + let hasTooManyMembers = membersMigrated.count > GroupManager.groupsV2MaxGroupSizeHardLimit + + let state: GroupsV2MigrationState = { + if !migrationMode.canMigrateIfNotMember, + !isLocalUserFullMember { + return .cantBeMigrated_LocalUserIsNotAMember + } + if !migrationMode.canMigrateIfNotInProfileWhitelist, + !isGroupInProfileWhitelist { + return .cantBeMigrated_NotInProfileWhitelist + } + if !migrationMode.canSkipMembersWithoutUuids, + !membersWithoutUuids.isEmpty { + return .cantBeMigrated_MembersWithoutUuids + } + if !migrationMode.canSkipMembersWithoutCapabilities, + !membersWithoutCapabilities.isEmpty { + return .cantBeMigrated_MembersWithoutCapabilities + } + if !migrationMode.canInviteMembersWithoutProfileKey, + !membersWithoutProfileKeys.isEmpty { + return .cantBeMigrated_MembersWithoutProfileKey + } + if !migrationMode.canMigrateWithTooManyMembers, + hasTooManyMembers { + return .cantBeMigrated_TooManyMembers + } + return .canBeMigrated + }() + + return GroupsV2MigrationInfo(isGroupInProfileWhitelist: isGroupInProfileWhitelist, + membersWithoutUuids: membersWithoutUuids, + membersWithoutCapabilities: membersWithoutCapabilities, + membersWithoutProfileKeys: membersWithoutProfileKeys, + state: state) + } + + static func membersToTryToMigrate(groupMembership: GroupMembership) -> Set { + + let allMembers = groupMembership.allMembersOfAnyKind + let addressesWithoutUuids = Array(allMembers).filter { $0.uuid == nil } + let knownUndiscoverable = Set(ContactDiscoveryTask.addressesRecentlyMarkedAsUndiscoverableForGroupMigrations(addressesWithoutUuids)) + + var result = Set() + for address in allMembers { + if nil == address.uuid, knownUndiscoverable.contains(address) { + Logger.warn("Ignoring unregistered member without uuid: \(address).") + continue + } + result.insert(address) + } + return result + } +} + +// MARK: - + +public enum GroupsV2MigrationState { + case canBeMigrated + case cantBeMigrated_FeatureNotEnabled + case cantBeMigrated_NotAV1Group + case cantBeMigrated_NotRegistered + case cantBeMigrated_LocalUserIsNotAMember + case cantBeMigrated_NotInProfileWhitelist + case cantBeMigrated_TooManyMembers + case cantBeMigrated_MembersWithoutUuids + case cantBeMigrated_MembersWithoutCapabilities + case cantBeMigrated_MembersWithoutProfileKey +} + +// MARK: - + +public struct GroupsV2MigrationInfo { + // These properties only have valid values if canGroupBeMigrated is true. + public let isGroupInProfileWhitelist: Bool + public let membersWithoutUuids: [SignalServiceAddress] + public let membersWithoutCapabilities: [SignalServiceAddress] + public let membersWithoutProfileKeys: [SignalServiceAddress] + + // Always consult this property first. + public let state: GroupsV2MigrationState + + public var canGroupBeMigrated: Bool { + state == .canBeMigrated + } + + fileprivate static func buildCannotBeMigrated(state: GroupsV2MigrationState) -> GroupsV2MigrationInfo { + GroupsV2MigrationInfo(isGroupInProfileWhitelist: false, + membersWithoutUuids: [], + membersWithoutCapabilities: [], + membersWithoutProfileKeys: [], + state: state) } } // MARK: - public enum GroupsV2MigrationMode { - case migrateToServicePolite - case migrateToServiceAggressive + // TODO: We may want to rename polite/aggressive to + // auto-migration/manual-migration. + case manualMigrationPolite + case manualMigrationAggressive + case autoMigrationPolite + case autoMigrationAggressive case alreadyMigratedOnService - public var skipMembersWithoutUuids: Bool { - self == .migrateToServiceAggressive + private var isManualMigration: Bool { + self == .manualMigrationPolite || self == .manualMigrationAggressive } - public var allowMembersWithoutCapabilities: Bool { - self == .migrateToServiceAggressive + private var isAutoMigration: Bool { + self == .autoMigrationPolite || self == .autoMigrationAggressive } - public var allowMembersWithoutProfileKey: Bool { - self == .migrateToServiceAggressive + private var isPolite: Bool { + self == .manualMigrationPolite || self == .autoMigrationPolite } - public var onlyMigrateIfInProfileWhitelist: Bool { - self == .migrateToServiceAggressive + private var isAggressive: Bool { + self == .manualMigrationAggressive || self == .autoMigrationAggressive + } + + private var isAlreadyMigratedOnService: Bool { + self == .alreadyMigratedOnService + } + + public var canSkipMembersWithoutUuids: Bool { + isAggressive || isAlreadyMigratedOnService + } + + public var canSkipMembersWithoutCapabilities: Bool { + isAggressive || isAlreadyMigratedOnService + } + + public var canInviteMembersWithoutProfileKey: Bool { + isManualMigration || isAggressive || isAlreadyMigratedOnService + } + + public var canMigrateIfNotInProfileWhitelist: Bool { + // TODO: What about manual? + isAlreadyMigratedOnService } public var canMigrateToService: Bool { - self != .alreadyMigratedOnService + !isAlreadyMigratedOnService + } + + public var canMigrateIfNotMember: Bool { + isAlreadyMigratedOnService + } + + public var canMigrateWithTooManyMembers: Bool { + isAlreadyMigratedOnService } } diff --git a/SignalMessaging/profiles/OWSProfileManager.m b/SignalMessaging/profiles/OWSProfileManager.m index 50d13b9a0f..9410ad6281 100644 --- a/SignalMessaging/profiles/OWSProfileManager.m +++ b/SignalMessaging/profiles/OWSProfileManager.m @@ -1946,7 +1946,7 @@ const NSString *kNSNotificationKey_WasLocallyInitiated = @"kNSNotificationKey_Wa { OWSAssertIsOnMainThread(); - ActionSheetController *actionSheet = [[ActionSheetController alloc] initWithTitle:nil message:nil]; + ActionSheetController *actionSheet = [[ActionSheetController alloc] init]; NSString *shareTitle = NSLocalizedString(@"CONVERSATION_SETTINGS_VIEW_SHARE_PROFILE", @"Button to confirm that user wants to share their profile with a user or group."); diff --git a/SignalMessaging/utils/OWSAvatarBuilder.h b/SignalMessaging/utils/OWSAvatarBuilder.h index b952e63503..90ea375205 100644 --- a/SignalMessaging/utils/OWSAvatarBuilder.h +++ b/SignalMessaging/utils/OWSAvatarBuilder.h @@ -9,6 +9,7 @@ extern const NSUInteger kStandardAvatarSize; extern const NSUInteger kMediumAvatarSize; extern const NSUInteger kLargeAvatarSize; +@class SDSAnyReadTransaction; @class TSThread; @class UIImage; @@ -20,8 +21,10 @@ extern const NSUInteger kLargeAvatarSize; + (nullable UIImage *)buildRandomAvatarWithDiameter:(NSUInteger)diameter; - (nullable UIImage *)buildSavedImage; +- (nullable UIImage *)buildSavedImageWithTransaction:(SDSAnyReadTransaction *)transaction; - (nullable UIImage *)buildDefaultImage; - (nullable UIImage *)build; +- (nullable UIImage *)buildWithTransaction:(SDSAnyReadTransaction *)transaction; + (nullable UIImage *)avatarImageWithInitials:(NSString *)initials backgroundColor:(UIColor *)backgroundColor diff --git a/SignalMessaging/utils/OWSAvatarBuilder.m b/SignalMessaging/utils/OWSAvatarBuilder.m index 60b33a0e79..29b482501e 100644 --- a/SignalMessaging/utils/OWSAvatarBuilder.m +++ b/SignalMessaging/utils/OWSAvatarBuilder.m @@ -285,12 +285,28 @@ typedef void (^OWSAvatarDrawBlock)(CGContextRef context); } } +- (nullable UIImage *)buildWithTransaction:(SDSAnyReadTransaction *)transaction +{ + UIImage *_Nullable savedImage = [self buildSavedImageWithTransaction:transaction]; + if (savedImage) { + return savedImage; + } else { + return [self buildDefaultImage]; + } +} + - (nullable UIImage *)buildSavedImage { OWSAbstractMethod(); return nil; } +- (nullable UIImage *)buildSavedImageWithTransaction:(SDSAnyReadTransaction *)transaction +{ + OWSAbstractMethod(); + return nil; +} + - (nullable UIImage *)buildDefaultImage { OWSAbstractMethod(); diff --git a/SignalMessaging/utils/OWSContactAvatarBuilder.h b/SignalMessaging/utils/OWSContactAvatarBuilder.h index ce165c8824..1a840f6cb0 100644 --- a/SignalMessaging/utils/OWSContactAvatarBuilder.h +++ b/SignalMessaging/utils/OWSContactAvatarBuilder.h @@ -19,6 +19,11 @@ NS_ASSUME_NONNULL_BEGIN colorName:(ConversationColorName)colorName diameter:(NSUInteger)diameter; +- (instancetype)initWithAddress:(SignalServiceAddress *)address + colorName:(ConversationColorName)colorName + diameter:(NSUInteger)diameter + transaction:(SDSAnyReadTransaction *)transaction; + /** * Build an avatar for a non-Signal recipient */ diff --git a/SignalMessaging/utils/OWSContactAvatarBuilder.m b/SignalMessaging/utils/OWSContactAvatarBuilder.m index 6ac97f3972..ef636e4ffb 100644 --- a/SignalMessaging/utils/OWSContactAvatarBuilder.m +++ b/SignalMessaging/utils/OWSContactAvatarBuilder.m @@ -56,6 +56,17 @@ NS_ASSUME_NONNULL_BEGIN return [self initWithAddress:address nameComponents:nameComponents colorName:colorName diameter:diameter]; } +- (instancetype)initWithAddress:(SignalServiceAddress *)address + colorName:(ConversationColorName)colorName + diameter:(NSUInteger)diameter + transaction:(SDSAnyReadTransaction *)transaction +{ + // Components for avatar initials. + NSPersonNameComponents *_Nullable nameComponents = + [OWSContactAvatarBuilder.contactsManager nameComponentsForAddress:address transaction:transaction]; + return [self initWithAddress:address nameComponents:nameComponents colorName:colorName diameter:diameter]; +} + - (instancetype)initWithNonSignalNameComponents:(NSPersonNameComponents *)nonSignalNameComponents colorSeed:(NSString *)colorSeed diameter:(NSUInteger)diameter @@ -90,29 +101,48 @@ NS_ASSUME_NONNULL_BEGIN } if (self.address.isLocalAddress) { - NSString *noteToSelfCacheKey = [NSString stringWithFormat:@"%@:note-to-self", self.cacheKey]; - UIImage *_Nullable cachedAvatar = - [OWSContactAvatarBuilder.contactsManager.avatarCache imageForKey:noteToSelfCacheKey - diameter:(CGFloat)self.diameter]; - if (cachedAvatar) { - return cachedAvatar; - } - - UIImage *image = [self noteToSelfImageWithConversationColorName:self.colorName diameter:(CGFloat)self.diameter]; - if (!image) { - OWSFailDebug(@"Could not generate avatar."); - return nil; - } - - [OWSContactAvatarBuilder.contactsManager.avatarCache setImage:image - forKey:noteToSelfCacheKey - diameter:self.diameter]; - return image; + return self.buildImageForLocalUser; } return [OWSContactAvatarBuilder.contactsManager imageForAddressWithSneakyTransaction:self.address]; } +- (nullable UIImage *)buildSavedImageWithTransaction:(SDSAnyReadTransaction *)transaction +{ + if (!self.address.isValid) { + return nil; + } + + if (self.address.isLocalAddress) { + return self.buildImageForLocalUser; + } + + return [OWSContactAvatarBuilder.contactsManager imageForAddress:self.address transaction:transaction]; +} + +- (nullable UIImage *)buildImageForLocalUser +{ + OWSCAssertDebug(self.address.isLocalAddress); + NSString *noteToSelfCacheKey = [NSString stringWithFormat:@"%@:note-to-self", self.cacheKey]; + UIImage *_Nullable cachedAvatar = + [OWSContactAvatarBuilder.contactsManager.avatarCache imageForKey:noteToSelfCacheKey + diameter:(CGFloat)self.diameter]; + if (cachedAvatar) { + return cachedAvatar; + } + + UIImage *image = [self noteToSelfImageWithConversationColorName:self.colorName diameter:(CGFloat)self.diameter]; + if (!image) { + OWSFailDebug(@"Could not generate avatar."); + return nil; + } + + [OWSContactAvatarBuilder.contactsManager.avatarCache setImage:image + forKey:noteToSelfCacheKey + diameter:self.diameter]; + return image; +} + - (id)cacheKey { if (self.address.isValid) { diff --git a/SignalMessaging/utils/OWSGroupAvatarBuilder.m b/SignalMessaging/utils/OWSGroupAvatarBuilder.m index bad8a14638..1385f57c71 100644 --- a/SignalMessaging/utils/OWSGroupAvatarBuilder.m +++ b/SignalMessaging/utils/OWSGroupAvatarBuilder.m @@ -47,6 +47,11 @@ NS_ASSUME_NONNULL_BEGIN return self.thread.groupModel.groupAvatarImage; } +- (nullable UIImage *)buildSavedImageWithTransaction:(SDSAnyReadTransaction *)transaction +{ + return self.thread.groupModel.groupAvatarImage; +} + - (nullable UIImage *)buildDefaultImage { return [self.class defaultAvatarForGroupId:self.thread.groupModel.groupId diff --git a/SignalServiceKit/src/Contacts/Discovery/ContactDiscoveryTask.swift b/SignalServiceKit/src/Contacts/Discovery/ContactDiscoveryTask.swift index 9878d81ce2..6594d8ddd4 100644 --- a/SignalServiceKit/src/Contacts/Discovery/ContactDiscoveryTask.swift +++ b/SignalServiceKit/src/Contacts/Discovery/ContactDiscoveryTask.swift @@ -199,10 +199,18 @@ public extension ContactDiscoveryTask { } } - static func addressesRecentlyMarkedAsUndiscoverable(_ addresses: [SignalServiceAddress]) -> [SignalServiceAddress] { + static func addressesRecentlyMarkedAsUndiscoverableForMessageSends(_ addresses: [SignalServiceAddress]) -> [SignalServiceAddress] { guard FeatureFlags.ignoreCDSUndiscoverableUsersInMessageSends else { return [] } + return addressesRecentlyMarkedAsUndiscoverable(addresses) + } + + static func addressesRecentlyMarkedAsUndiscoverableForGroupMigrations(_ addresses: [SignalServiceAddress]) -> [SignalServiceAddress] { + return addressesRecentlyMarkedAsUndiscoverable(addresses) + } + + private static func addressesRecentlyMarkedAsUndiscoverable(_ addresses: [SignalServiceAddress]) -> [SignalServiceAddress] { return unfairLock.withLock { addresses.filter { address in guard let phoneNumber = address.phoneNumber else { diff --git a/SignalServiceKit/src/Contacts/Threads/TSContactThread.h b/SignalServiceKit/src/Contacts/Threads/TSContactThread.h index 69218caa32..c196cc9592 100644 --- a/SignalServiceKit/src/Contacts/Threads/TSContactThread.h +++ b/SignalServiceKit/src/Contacts/Threads/TSContactThread.h @@ -81,8 +81,8 @@ NS_DESIGNATED_INITIALIZER NS_SWIFT_NAME(init(grdbId:uniqueId:conversationColorNa // This method can be used to get the conversation color for a given // recipient without using a read/write transaction to create a // contact thread. -+ (NSString *)conversationColorNameForContactAddress:(SignalServiceAddress *)address - transaction:(SDSAnyReadTransaction *)transaction; ++ (ConversationColorName)conversationColorNameForContactAddress:(SignalServiceAddress *)address + transaction:(SDSAnyReadTransaction *)transaction; @end diff --git a/SignalServiceKit/src/Contacts/Threads/TSContactThread.m b/SignalServiceKit/src/Contacts/Threads/TSContactThread.m index e019e9095a..285f360ffe 100644 --- a/SignalServiceKit/src/Contacts/Threads/TSContactThread.m +++ b/SignalServiceKit/src/Contacts/Threads/TSContactThread.m @@ -228,8 +228,8 @@ lastVisibleSortIdOnScreenPercentage:lastVisibleSortIdOnScreenPercentage return [threadId substringWithRange:NSMakeRange(1, threadId.length - 1)]; } -+ (NSString *)conversationColorNameForContactAddress:(SignalServiceAddress *)address - transaction:(SDSAnyReadTransaction *)transaction ++ (ConversationColorName)conversationColorNameForContactAddress:(SignalServiceAddress *)address + transaction:(SDSAnyReadTransaction *)transaction { OWSAssertDebug(address); diff --git a/SignalServiceKit/src/Messages/Interactions/TSInfoMessage+GroupUpdateCopy.swift b/SignalServiceKit/src/Messages/Interactions/TSInfoMessage+GroupUpdateCopy.swift index cc97dfa858..7442de4671 100644 --- a/SignalServiceKit/src/Messages/Interactions/TSInfoMessage+GroupUpdateCopy.swift +++ b/SignalServiceKit/src/Messages/Interactions/TSInfoMessage+GroupUpdateCopy.swift @@ -38,6 +38,9 @@ public enum GroupUpdateType: Int { case disappearingMessagesState_disabled case groupInviteLink case generic + case groupMigrated + case groupMigrated_usersDropped + case groupMigrated_usersInvited case debug var typeForDeduplication: GroupUpdateType { @@ -161,6 +164,8 @@ struct GroupUpdateCopy { if isReplacingJoinRequestPlaceholder { addMembershipUpdates(oldGroupMembership: oldGroupMembership, forLocalUserOnly: true) + } else if wasJustMigrated { + addMigrationUpdates(oldGroupMembership: oldGroupMembership) } else { addMembershipUpdates(oldGroupMembership: oldGroupMembership) @@ -648,14 +653,20 @@ extension GroupUpdateCopy { } mutating func addUserWasGrantedAdministrator(for address: SignalServiceAddress) { + + if let newGroupModelV2 = newGroupModel as? TSGroupModelV2, + newGroupModelV2.wasJustMigrated { + // All v1 group members become admins when the + // group is migrated to v2. We don't need to + // surface this to the user. + return + } + let isLocalUser = localAddress == address if isLocalUser { switch updater { case .localUser: - if let newGroupModelV2 = newGroupModel as? TSGroupModelV2, - newGroupModelV2.wasJustMigrated { - // Do nothing. - } else if !DebugFlags.permissiveGroupUpdateInfoMessages { + if !DebugFlags.permissiveGroupUpdateInfoMessages { owsFailDebug("Local user made themself administrator.") } else { addItem(.debug, copy: "Error: Local user made themself administrator.") @@ -1062,7 +1073,7 @@ extension GroupUpdateCopy { guard count > 0 else { return } - let countString = String.localizedStringWithFormat("%d", count) + let countString = OWSFormat.formatUInt(count) switch updater { case .localUser: @@ -1272,7 +1283,7 @@ extension GroupUpdateCopy { } addUnnamedUsersWereInvitedPassiveTense(count: count) case .otherUser(let updaterName, _): - let countString = String.localizedStringWithFormat("%d", count) + let countString = OWSFormat.formatUInt(count) if count == 1 { let format = NSLocalizedString("GROUP_REMOTE_USER_INVITED_BY_REMOTE_USER_1_FORMAT", comment: "Message indicating that a single remote user was invited to the group by the local user. Embeds {{ user who invited the user }}.") @@ -1290,7 +1301,7 @@ extension GroupUpdateCopy { } mutating func addUnnamedUsersWereInvitedPassiveTense(count: UInt) { - let countString = String.localizedStringWithFormat("%d", count) + let countString = OWSFormat.formatUInt(count) if count == 1 { self.addItem(.userMembershipState_invitesNew, copy: NSLocalizedString("GROUP_REMOTE_USER_INVITED_1", @@ -1678,6 +1689,58 @@ extension GroupUpdateCopy { } } + // MARK: - Migration + + var wasJustMigrated: Bool { + guard let newGroupModelV2 = newGroupModel as? TSGroupModelV2, + newGroupModelV2.wasJustMigrated else { + return false + } + return true + } + + mutating func addMigrationUpdates(oldGroupMembership: GroupMembership) { + owsAssertDebug(wasJustMigrated) + + addItem(.groupMigrated, + copy: NSLocalizedString("GROUP_WAS_MIGRATED", + comment: "Message indicating that the group was migrated.")) + + let invitedMembers = oldGroupMembership.allMembersOfAnyKind.intersection(newGroupMembership.invitedMembers) + let droppedMembers = oldGroupMembership.allMembersOfAnyKind.subtracting(newGroupMembership.allMembersOfAnyKind) + + if !invitedMembers.isEmpty { + if invitedMembers.count == 1 { + let copy = NSLocalizedString("GROUP_WAS_MIGRATED_USERS_INVITED_1", + comment: "Message indicating that 1 user was invited while migrating the group.") + addItem(.groupMigrated_usersDropped, + copy: copy) + } else { + let format = NSLocalizedString("GROUP_WAS_MIGRATED_USERS_INVITED_N_FORMAT", + comment: "Message indicating that N users were invited while migrating the group. Embeds {{ the number of invited users }}.") + let countString = OWSFormat.formatInt(invitedMembers.count) + addItem(.groupMigrated_usersInvited, + format: format, countString) + } + } + + // TODO: We need final copy here. + if !droppedMembers.isEmpty { + if droppedMembers.count == 1 { + let copy = NSLocalizedString("GROUP_WAS_MIGRATED_USERS_DROPPED_1", + comment: "Message indicating that 1 user was dropped while migrating the group.") + addItem(.groupMigrated_usersDropped, + copy: copy) + } else { + let format = NSLocalizedString("GROUP_WAS_MIGRATED_USERS_DROPPED_N_FORMAT", + comment: "Message indicating that N users were dropped while migrating the group. Embeds {{ the number of dropped users }}.") + let countString = OWSFormat.formatInt(droppedMembers.count) + addItem(.groupMigrated_usersDropped, + format: format, countString) + } + } + } + // MARK: - Membership Status fileprivate enum MembershipStatus { diff --git a/SignalServiceKit/src/Messages/MessageSender.swift b/SignalServiceKit/src/Messages/MessageSender.swift index 791c4e827b..799830653e 100644 --- a/SignalServiceKit/src/Messages/MessageSender.swift +++ b/SignalServiceKit/src/Messages/MessageSender.swift @@ -684,7 +684,7 @@ extension MessageSender { return Promise.value(addresses) } - let knownUndiscoverable = ContactDiscoveryTask.addressesRecentlyMarkedAsUndiscoverable(invalidRecipients) + let knownUndiscoverable = ContactDiscoveryTask.addressesRecentlyMarkedAsUndiscoverableForMessageSends(invalidRecipients) if Set(knownUndiscoverable) == Set(invalidRecipients) { // If CDS has recently indicated that all of the invalid recipients are undiscoverable, // assume they are still undiscoverable and skip them. diff --git a/SignalServiceKit/src/Util/FeatureFlags.swift b/SignalServiceKit/src/Util/FeatureFlags.swift index b20d2e50ef..0003ffa0f2 100644 --- a/SignalServiceKit/src/Util/FeatureFlags.swift +++ b/SignalServiceKit/src/Util/FeatureFlags.swift @@ -155,25 +155,53 @@ public class FeatureFlags: BaseFlags { @objc public static let groupsV2showSplash = build.includes(.qa) + // TODO: Make this a remote config flag? @objc - public static let groupsV2MigrationMigration = build.includes(.dev) + public static let groupsV2Migration = build.includes(.dev) @objc - public static let groupsV2MigrationSetCapability = groupsV2MigrationMigration && false + public static let groupsV2MigrationSetCapability = groupsV2Migration && build.includes(.dev) @objc public static let groupsV2MigrationRequireCapability = false - // Controls whether or not the client will attempt to auto-migrate - // v1 groups to v2 groups. + // Controls whether or not the client will show the manual migration UI. + // Will only offer migrations if all members can be migrated. + // + // TODO: Make this a remote config flag? + // TODO: We might not need this flag. @objc - public static let groupsV2MigrationAutoMigration = groupsV2MigrationMigration && false + public static let groupsV2MigrationManualMigrationPolite = groupsV2Migration && build.includes(.dev) + + // Controls whether or not the client will show the manual migration UI. + // Will offer migrations even if some members cannot be migrated. + // + // TODO: Make this a remote config flag? + // TODO: We might not need this flag. + @objc + public static let groupsV2MigrationManualMigrationAggressive = groupsV2Migration && false + + // Controls whether or not the client will try to auto-migrate groups in the background. + // Will only migrate groups if all members can be migrated. + // + // TODO: Make this a remote config flag? + // TODO: We might not need this flag. + @objc + public static let groupsV2MigrationAutoMigrationPolite = groupsV2Migration && build.includes(.dev) + + // Controls whether or not the client will try to auto-migrate groups in the background. + // Will migrate groups even if some members cannot be migrated. + // + // TODO: Make this a remote config flag? + // TODO: We might not need this flag. + @objc + public static let groupsV2MigrationAutoMigrationAggressive = groupsV2Migration && false @objc public static let linkedPhones = build.includes(.internalPreview) @objc - public static let isUsingProductionService = true + public static let isUsingProductionService = false @objc public static let useOrphanDataCleaner = true diff --git a/SignalServiceKit/src/Util/RemoteConfigManager.swift b/SignalServiceKit/src/Util/RemoteConfigManager.swift index df0c733f5e..0d685db230 100644 --- a/SignalServiceKit/src/Util/RemoteConfigManager.swift +++ b/SignalServiceKit/src/Util/RemoteConfigManager.swift @@ -49,13 +49,34 @@ public class RemoteConfig: BaseFlags { } @objc - public static var maxGroupsV2MemberCount: UInt { + public static var groupsV2MaxGroupSizeRecommended: UInt { let defaultValue: UInt = 151 guard AppReadiness.isAppReady else { owsFailDebug("Storage is not yet ready.") return defaultValue } - guard let rawValue: AnyObject = value(.maxGroupsV2MemberCount) else { + guard let rawValue: AnyObject = value(.groupsV2MaxGroupSizeRecommended) else { + return defaultValue + } + guard let stringValue = rawValue as? String else { + owsFailDebug("Unexpected value.") + return defaultValue + } + guard let uintValue = UInt(stringValue) else { + owsFailDebug("Invalid value.") + return defaultValue + } + return uintValue + } + + @objc + public static var groupsV2MaxGroupSizeHardLimit: UInt { + let defaultValue: UInt = 1001 + guard AppReadiness.isAppReady else { + owsFailDebug("Storage is not yet ready.") + return defaultValue + } + guard let rawValue: AnyObject = value(.groupsV2MaxGroupSizeHardLimit) else { return defaultValue } guard let stringValue = rawValue as? String else { @@ -257,7 +278,8 @@ private struct Flags { // Values defined in this array remain set once they are // set regardless of the remote state. enum StickyValuesFlags: String, FlagType { - case maxGroupsV2MemberCount + case groupsV2MaxGroupSizeRecommended + case groupsV2MaxGroupSizeHardLimit } // We filter the received config down to just the supported values. @@ -265,7 +287,8 @@ private struct Flags { // set because we cached a value before it went public. e.g. if we set // a sticky value to X in beta then remove it before going to production. enum SupportedValuesFlags: String, FlagType { - case maxGroupsV2MemberCount + case groupsV2MaxGroupSizeRecommended + case groupsV2MaxGroupSizeHardLimit case clientExpiration case researchMegaphone } @@ -284,7 +307,8 @@ private protocol FlagType: CaseIterable { private extension FlagType { var rawFlag: String { switch rawValue { - case "maxGroupsV2MemberCount": return "global.maxGroupSize" + case "groupsV2MaxGroupSizeRecommended": return "global.groupsv2.maxGroupSize" + case "groupsV2MaxGroupSizeHardLimit": return "global.groupsv2.groupSizeHardLimit" case "researchMegaphone": return "research.megaphone.1" default: return Flags.prefix + rawValue } diff --git a/SignalServiceKit/src/groups/GroupManager.swift b/SignalServiceKit/src/groups/GroupManager.swift index 61adeffac4..3abaf7d662 100644 --- a/SignalServiceKit/src/groups/GroupManager.swift +++ b/SignalServiceKit/src/groups/GroupManager.swift @@ -95,8 +95,12 @@ public class GroupManager: NSObject { // GroupsV2 TODO: Finalize this value with the designers. public static let groupUpdateTimeoutDuration: TimeInterval = 30 - public static var maxGroupsV2MemberCount: UInt { - return RemoteConfig.maxGroupsV2MemberCount + public static var groupsV2MaxGroupSizeRecommended: UInt { + return RemoteConfig.groupsV2MaxGroupSizeRecommended + } + + public static var groupsV2MaxGroupSizeHardLimit: UInt { + return RemoteConfig.groupsV2MaxGroupSizeHardLimit } public static let maxGroupNameLength: Int = 32 @@ -1912,7 +1916,7 @@ public class GroupManager: NSObject { let didDMConfigChange = oldDMConfiguration.asToken != newDMConfiguration.asToken let didGroupModelChange = !oldGroupModelV1.isEqual(to: newGroupModelV2, - comparisonMode: .userFacingOnly) + comparisonMode: .migration) if didDMConfigChange || didGroupModelChange { insertGroupUpdateInfoMessage(groupThread: groupThreadV2, oldGroupModel: oldGroupModelV1, diff --git a/SignalServiceKit/src/groups/TSGroupModel.h b/SignalServiceKit/src/groups/TSGroupModel.h index 578d5f32ce..4e50230b42 100644 --- a/SignalServiceKit/src/groups/TSGroupModel.h +++ b/SignalServiceKit/src/groups/TSGroupModel.h @@ -21,7 +21,12 @@ typedef NS_CLOSED_ENUM( typedef NS_CLOSED_ENUM(NSUInteger, TSGroupModelComparisonMode) { TSGroupModelComparisonMode_CompareAll, - TSGroupModelComparisonMode_UserFacingOnly + TSGroupModelComparisonMode_UserFacingOnly, + // Migration mode only considers user-facing changes, + // but ignores changes in roles. All v1 group members + // become v2 admins, but we don't need to surface that + // to users. + TSGroupModelComparisonMode_Migration }; // NOTE: This class is tightly coupled to TSGroupModelBuilder. diff --git a/SignalServiceKit/src/groups/TSGroupModel.m b/SignalServiceKit/src/groups/TSGroupModel.m index 30085903d9..713071796d 100644 --- a/SignalServiceKit/src/groups/TSGroupModel.m +++ b/SignalServiceKit/src/groups/TSGroupModel.m @@ -174,6 +174,7 @@ NSUInteger const TSGroupModelSchemaVersion = 1; } break; case TSGroupModelComparisonMode_UserFacingOnly: + case TSGroupModelComparisonMode_Migration: break; } @@ -183,7 +184,8 @@ NSUInteger const TSGroupModelSchemaVersion = 1; if (![NSObject isNullableObject:self.groupAvatarData equalTo:other.groupAvatarData]) { return NO; } - if (![NSObject isNullableObject:self.addedByAddress equalTo:other.addedByAddress]) { + if (comparisonMode != TSGroupModelComparisonMode_Migration + && ![NSObject isNullableObject:self.addedByAddress equalTo:other.addedByAddress]) { return NO; } NSSet *myGroupMembersSet = [NSSet setWithArray:_groupMembers]; diff --git a/SignalServiceKit/src/groups/TSGroupModel.swift b/SignalServiceKit/src/groups/TSGroupModel.swift index 33b0fdc3ea..3e806e697a 100644 --- a/SignalServiceKit/src/groups/TSGroupModel.swift +++ b/SignalServiceKit/src/groups/TSGroupModel.swift @@ -102,7 +102,7 @@ public class TSGroupModelV2: TSGroupModel { switch comparisonMode { case .compareAll: return false - case .userFacingOnly: + case .userFacingOnly, .migration: return true } }