From ebab35b4ba9151edbdb3605ee4267de059e89a14 Mon Sep 17 00:00:00 2001 From: Harry <109690906+harry-signal@users.noreply.github.com> Date: Fri, 12 Jan 2024 05:46:20 -0800 Subject: [PATCH] Put all necessary metadata on PersistableGroupUpdateItem, never rely on separate metadata --- Signal.xcodeproj/project.pbxproj | 16 + .../CVComponentSystemMessage.swift | 251 +++++++------- ...eItem+CVComponentSystemMessageAction.swift | 103 ++++++ .../Notifications/AppNotifications.swift | 24 +- .../groups/GroupV2UpdatesImpl.swift | 105 ++++-- SignalMessaging/groups/GroupsV2Impl.swift | 21 +- .../groups/GroupsV2IncomingChanges.swift | 24 +- .../Interactions/GroupUpdateSource.swift | 142 ++++++++ ...ge+DisplayableGroupUpdateItemBuilder.swift | 264 +++++++++------ ...foMessage+PersistableGroupUpdateItem.swift | 195 ++++++++++- ...ge+PersistableGroupUpdateItemUpdater.swift | 107 ++++++ .../Interactions/TSInfoMessage+Swift.swift | 307 +++++++++++------- .../src/Messages/Interactions/TSInfoMessage.h | 8 +- .../src/Messages/Interactions/TSInfoMessage.m | 13 +- .../src/Storage/Database/DeepCopy.swift | 11 + ...GroupManager+GroupUpdateInfoMessages.swift | 26 +- .../src/groups/GroupManager.swift | 39 +-- ...sageInserter+FoldIntoExistingMessage.swift | 164 +++++++--- .../GroupUpdateInfoMessageInserter.swift | 250 ++++++++++++-- ...LegacyPersistablegroupUpdateItemTest.swift | 169 ++++++++++ ...ssage+PersistableGroupUpdateItemTest.swift | 52 ++- 21 files changed, 1792 insertions(+), 499 deletions(-) create mode 100644 Signal/src/ViewControllers/ConversationView/CV/CVComponents/PersistableGroupUpdateItem+CVComponentSystemMessageAction.swift create mode 100644 SignalServiceKit/src/Messages/Interactions/GroupUpdateSource.swift create mode 100644 SignalServiceKit/src/Messages/Interactions/TSInfoMessage+PersistableGroupUpdateItemUpdater.swift create mode 100644 SignalServiceKit/tests/Messages/Interactions/TSInfoMessage+LegacyPersistablegroupUpdateItemTest.swift diff --git a/Signal.xcodeproj/project.pbxproj b/Signal.xcodeproj/project.pbxproj index 1e3c8080b7..020fada36c 100644 --- a/Signal.xcodeproj/project.pbxproj +++ b/Signal.xcodeproj/project.pbxproj @@ -896,6 +896,10 @@ 667664362A43BBCD00716B84 /* OWSFingerprint.swift in Sources */ = {isa = PBXBuildFile; fileRef = 667664352A43BBCD00716B84 /* OWSFingerprint.swift */; }; 667664382A44B6C200716B84 /* OWSFingerprintBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 667664372A44B6C200716B84 /* OWSFingerprintBuilder.swift */; }; 66783C2B29CA4F6C00FC4E4E /* RegistrationTransferQRCodeViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 66783C2A29CA4F6C00FC4E4E /* RegistrationTransferQRCodeViewController.swift */; }; + 667AF9DA2B48A3F3008AEE5D /* TSInfoMessage+PersistableGroupUpdateItemUpdater.swift in Sources */ = {isa = PBXBuildFile; fileRef = 667AF9D92B48A3F3008AEE5D /* TSInfoMessage+PersistableGroupUpdateItemUpdater.swift */; }; + 667AF9DE2B4C5824008AEE5D /* PersistableGroupUpdateItem+CVComponentSystemMessageAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 667AF9DD2B4C5824008AEE5D /* PersistableGroupUpdateItem+CVComponentSystemMessageAction.swift */; }; + 667AF9E02B4C6377008AEE5D /* TSInfoMessage+LegacyPersistablegroupUpdateItemTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 667AF9DF2B4C6377008AEE5D /* TSInfoMessage+LegacyPersistablegroupUpdateItemTest.swift */; }; + 667AF9E22B4DC5EE008AEE5D /* GroupUpdateSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = 667AF9E12B4DC5EE008AEE5D /* GroupUpdateSource.swift */; }; 667E90D028E799D1005FE603 /* MyStorySettingsLearnMoreSheetViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 667E90CF28E799D1005FE603 /* MyStorySettingsLearnMoreSheetViewController.swift */; }; 667EDE6428F8D6B7001FB487 /* YYAnimatedImage+Duration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 667EDE6328F8D6B7001FB487 /* YYAnimatedImage+Duration.swift */; }; 667EDE6628FA0372001FB487 /* StoryBadgeCountManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 667EDE6528FA0372001FB487 /* StoryBadgeCountManager.swift */; }; @@ -3541,6 +3545,10 @@ 667664352A43BBCD00716B84 /* OWSFingerprint.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OWSFingerprint.swift; sourceTree = ""; }; 667664372A44B6C200716B84 /* OWSFingerprintBuilder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OWSFingerprintBuilder.swift; sourceTree = ""; }; 66783C2A29CA4F6C00FC4E4E /* RegistrationTransferQRCodeViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RegistrationTransferQRCodeViewController.swift; sourceTree = ""; }; + 667AF9D92B48A3F3008AEE5D /* TSInfoMessage+PersistableGroupUpdateItemUpdater.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TSInfoMessage+PersistableGroupUpdateItemUpdater.swift"; sourceTree = ""; }; + 667AF9DD2B4C5824008AEE5D /* PersistableGroupUpdateItem+CVComponentSystemMessageAction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PersistableGroupUpdateItem+CVComponentSystemMessageAction.swift"; sourceTree = ""; }; + 667AF9DF2B4C6377008AEE5D /* TSInfoMessage+LegacyPersistablegroupUpdateItemTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TSInfoMessage+LegacyPersistablegroupUpdateItemTest.swift"; sourceTree = ""; }; + 667AF9E12B4DC5EE008AEE5D /* GroupUpdateSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroupUpdateSource.swift; sourceTree = ""; }; 667E90CF28E799D1005FE603 /* MyStorySettingsLearnMoreSheetViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MyStorySettingsLearnMoreSheetViewController.swift; sourceTree = ""; }; 667EDE6328F8D6B7001FB487 /* YYAnimatedImage+Duration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "YYAnimatedImage+Duration.swift"; sourceTree = ""; }; 667EDE6528FA0372001FB487 /* StoryBadgeCountManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoryBadgeCountManager.swift; sourceTree = ""; }; @@ -5795,6 +5803,7 @@ 347C384C252D0FDC00F3D941 /* CVComponentTypingIndicator.swift */, 347C384A252D085900F3D941 /* CVComponentUnreadIndicator.swift */, 348815BF2553291200D4F4C4 /* CVComponentViewOnce.swift */, + 667AF9DD2B4C5824008AEE5D /* PersistableGroupUpdateItem+CVComponentSystemMessageAction.swift */, ); path = CVComponents; sourceTree = ""; @@ -9097,6 +9106,7 @@ isa = PBXGroup; children = ( D92C57542A2925AD00A03BB7 /* TSInfoMessage+DisplayableGroupUpdateItemTest.swift */, + 667AF9DF2B4C6377008AEE5D /* TSInfoMessage+LegacyPersistablegroupUpdateItemTest.swift */, D9CD40612A155C4800545803 /* TSInfoMessage+PersistableGroupUpdateItemTest.swift */, F9426221289B1B5500460798 /* TSMessageTest.swift */, F9426220289B1B5500460798 /* TSOutgoingMessageTest.swift */, @@ -9449,6 +9459,7 @@ F9C5C8D8289453B100548EEE /* Interactions */ = { isa = PBXGroup; children = ( + 667AF9E12B4DC5EE008AEE5D /* GroupUpdateSource.swift */, 50468F2829EE130A00948E02 /* InteractionStore.swift */, F9C5C8EB289453B100548EEE /* MentionFinder.swift */, 763A15FF2AEC3A490081D7E5 /* OWSContact.swift */, @@ -9479,6 +9490,7 @@ D9B0AC7329EF42960070F31C /* TSInfoMessage+DisplayableGroupUpdateItem.swift */, F9C5C8F3289453B100548EEE /* TSInfoMessage+DisplayableGroupUpdateItemBuilder.swift */, F9A042C5289C7468007D08B6 /* TSInfoMessage+PersistableGroupUpdateItem.swift */, + 667AF9D92B48A3F3008AEE5D /* TSInfoMessage+PersistableGroupUpdateItemUpdater.swift */, F9C5C8FA289453B100548EEE /* TSInfoMessage+ProfileChanges.swift */, F9C5C8EE289453B100548EEE /* TSInfoMessage+SDS.swift */, F9C5C8E2289453B100548EEE /* TSInfoMessage+Swift.swift */, @@ -12443,6 +12455,7 @@ 3495FF0F25F9538900959D6E /* PaymentsViewPassphraseSplashViewController.swift in Sources */, 34FB6A5325D2D10400E599B1 /* PaymentsViewUtils.swift in Sources */, D96BE42E292EF04200E4FE1A /* PaypalButton.swift in Sources */, + 667AF9DE2B4C5824008AEE5D /* PersistableGroupUpdateItem+CVComponentSystemMessageAction.swift in Sources */, C176B48A299DA25500B1900D /* PhoneNumberPrivacySettingsViewController.swift in Sources */, 4C5250D221E7BD7D00CE3D95 /* PhoneNumberValidator.swift in Sources */, 3496955D219B605E00DCFE74 /* PhotoAlbumPickerViewController.swift in Sources */, @@ -12883,6 +12896,7 @@ D99A0F6229F35CE1002E02E3 /* GroupUpdateInfoMessageInserter+FoldIntoExistingMessage.swift in Sources */, D9FC835029F71CC300878BC8 /* GroupUpdateInfoMessageInserter+PrecomputedUpdateType.swift in Sources */, D99A0F6029F34FDA002E02E3 /* GroupUpdateInfoMessageInserter.swift in Sources */, + 667AF9E22B4DC5EE008AEE5D /* GroupUpdateSource.swift in Sources */, F9C5CD94289453B300548EEE /* HTMLMetadata.swift in Sources */, F9C5CDC2289453B400548EEE /* HTTPEntities.swift in Sources */, F9C5CDBC289453B400548EEE /* HTTPUtils.m in Sources */, @@ -13435,6 +13449,7 @@ D9B0AC7429EF42960070F31C /* TSInfoMessage+DisplayableGroupUpdateItem.swift in Sources */, F9C5CBE7289453B300548EEE /* TSInfoMessage+DisplayableGroupUpdateItemBuilder.swift in Sources */, F9A042C6289C7468007D08B6 /* TSInfoMessage+PersistableGroupUpdateItem.swift in Sources */, + 667AF9DA2B48A3F3008AEE5D /* TSInfoMessage+PersistableGroupUpdateItemUpdater.swift in Sources */, F9C5CBEE289453B300548EEE /* TSInfoMessage+ProfileChanges.swift in Sources */, F9C5CBE2289453B300548EEE /* TSInfoMessage+SDS.swift in Sources */, F9C5CBD6289453B300548EEE /* TSInfoMessage+Swift.swift in Sources */, @@ -13678,6 +13693,7 @@ F908AA8028CE7F8D00472E68 /* TSGroupThreadTest.swift in Sources */, D92C57552A2925AD00A03BB7 /* TSInfoMessage+DisplayableGroupUpdateItemTest.swift in Sources */, D9AD1D9528B9955C00B42E6F /* TSInfoMessage+GroupUpdateType+NSAttributedStringTest.swift in Sources */, + 667AF9E02B4C6377008AEE5D /* TSInfoMessage+LegacyPersistablegroupUpdateItemTest.swift in Sources */, D9CD40622A155C4800545803 /* TSInfoMessage+PersistableGroupUpdateItemTest.swift in Sources */, F9426258289B1B5500460798 /* TSMessageStorageTests.m in Sources */, F942628A289B1B5600460798 /* TSMessageTest.swift in Sources */, diff --git a/Signal/src/ViewControllers/ConversationView/CV/CVComponents/CVComponentSystemMessage.swift b/Signal/src/ViewControllers/ConversationView/CV/CVComponents/CVComponentSystemMessage.swift index 8a0ec77338..7ae3ce508e 100644 --- a/Signal/src/ViewControllers/ConversationView/CV/CVComponents/CVComponentSystemMessage.swift +++ b/Signal/src/ViewControllers/ConversationView/CV/CVComponents/CVComponentSystemMessage.swift @@ -1146,125 +1146,44 @@ extension CVComponentSystemMessage { owsFailDebug("TSInfoMessageAddGroupToProfileWhitelistOffer") return nil case .typeGroupUpdate: - guard let newGroupModel = infoMessage.newGroupModel else { - return nil - } - - guard - let displayableGroupUpdateItems = infoMessage.displayableGroupUpdateItems(tx: transaction), - !displayableGroupUpdateItems.isEmpty - else { - return nil - } - - for updateItem in displayableGroupUpdateItems { - switch updateItem { - case .inviteFriendsToNewlyCreatedGroup: - return Action( - title: OWSLocalizedString( - "GROUPS_INVITE_FRIENDS_BUTTON", - comment: "Label for 'invite friends to group' button." - ), - accessibilityIdentifier: "group_invite_friends", - action: .didTapGroupInviteLinkPromotion(groupModel: newGroupModel) - ) - case .wasMigrated: - return Action( - title: CommonStrings.learnMore, - accessibilityIdentifier: "group_migration_learn_more", - action: .didTapGroupMigrationLearnMore - ) - case - .descriptionChangedByLocalUser(let newGroupDescription), - .descriptionChangedByOtherUser(let newGroupDescription, _, _), - .descriptionChangedByUnknownUser(let newGroupDescription): - return Action( - title: CommonStrings.viewButton, - accessibilityIdentifier: "group_description_view", - action: .didTapViewGroupDescription(newGroupDescription: newGroupDescription) - ) - case let .sequenceOfInviteLinkRequestAndCancels(_, _, _, isTail): - guard isTail else { return nil } - - guard - let requesterAddress = infoMessage.groupUpdateSourceAddress, - let requesterAci = requesterAddress.serviceId as? Aci - else { - owsFailDebug("Missing parameters for join request sequence") - return nil - } - - guard - let mostRecentGroupModel = TSGroupThread.fetch( - groupId: newGroupModel.groupId, - transaction: transaction - )?.groupModel as? TSGroupModelV2 - else { - owsFailDebug("Missing group thread for join request sequence") - return nil - } - - // Only show the option to ban if we are an admin, and they are - // not already banned. We want to use the most up-to-date group - // model here instead of the one on the info message, since - // group state may have changed since that message. - guard - mostRecentGroupModel.groupMembership.isLocalUserFullMemberAndAdministrator, - !mostRecentGroupModel.groupMembership.isBannedMember(requesterAci) - else { - return nil - } - - return Action( - title: OWSLocalizedString( - "GROUPS_BLOCK_REQUEST_BUTTON", - comment: "Label for button that lets the user block a request to join the group." - ), - accessibilityIdentifier: "block_join_request_button", - action: .didTapBlockRequest( - groupModel: mostRecentGroupModel, - requesterName: contactsManager.shortDisplayName( - for: requesterAddress, - transaction: transaction - ), - requesterAci: requesterAci - ) - ) - default: - break - } - } - - guard let oldGroupModel = infoMessage.oldGroupModel else { - return nil - } - - let newlyRequestingMembers = newGroupModel.groupMembership.requestingMembers - .subtracting(oldGroupModel.groupMembership.requestingMembers) - - guard !newlyRequestingMembers.isEmpty else { - return nil - } - - let title: String = { - if newlyRequestingMembers.count > 1 { - return OWSLocalizedString( - "GROUPS_VIEW_REQUESTS_BUTTON", - comment: "Label for button that lets the user view the requests to join the group." - ) - } else { - return OWSLocalizedString( - "GROUPS_VIEW_REQUEST_BUTTON", - comment: "Label for button that lets the user view the request to join the group." - ) - } - }() - - return Action( - title: title, - accessibilityIdentifier: "show_group_requests_button", - action: .didTapShowConversationSettingsAndShowMemberRequests + let localIdentifiers = DependenciesBridge.shared.tsAccountManager.localIdentifiers( + tx: transaction.asV2Read ) + switch infoMessage.groupUpdateMetadata(localIdentifiers: localIdentifiers) { + + case .legacyRawString, .nonGroupUpdate: + return nil + + case .precomputed(let precomputedItems): + let thread = { infoMessage.thread(tx: transaction) as? TSGroupThread } + return precomputedItems.updateItems + .lazy + .compactMap { + $0.cvComponentAction( + groupThread: thread, + contactsManager: contactsManager, + tx: transaction + ) + } + .first + + case .newGroup(let newGroupModel, let updateMetadata): + return action( + forOldGroupModel: nil, + newGroupModel: newGroupModel.groupModel, + infoMessage: infoMessage, + changeAuthor: updateMetadata.source, + tx: transaction + ) + case .modelDiff(let oldGroupModel, let newGroupModel, let updateMetadata): + return action( + forOldGroupModel: oldGroupModel.groupModel, + newGroupModel: newGroupModel.groupModel, + infoMessage: infoMessage, + changeAuthor: updateMetadata.source, + tx: transaction + ) + } case .typeGroupQuit: return nil case .unknownProtocolVersion: @@ -1402,6 +1321,102 @@ extension CVComponentSystemMessage { } } + private static func action( + forOldGroupModel oldGroupModel: TSGroupModel?, + newGroupModel: TSGroupModel, + infoMessage: TSInfoMessage, + changeAuthor: GroupUpdateSource, + tx: SDSAnyReadTransaction + ) -> Action? { + guard + let displayableGroupUpdateItems = infoMessage.displayableGroupUpdateItems(tx: tx), + !displayableGroupUpdateItems.isEmpty + else { + return nil + } + + for updateItem in displayableGroupUpdateItems { + switch updateItem { + case .inviteFriendsToNewlyCreatedGroup: + return Action( + title: OWSLocalizedString( + "GROUPS_INVITE_FRIENDS_BUTTON", + comment: "Label for 'invite friends to group' button." + ), + accessibilityIdentifier: "group_invite_friends", + action: .didTapGroupInviteLinkPromotion(groupModel: newGroupModel) + ) + case .wasMigrated: + return Action( + title: CommonStrings.learnMore, + accessibilityIdentifier: "group_migration_learn_more", + action: .didTapGroupMigrationLearnMore + ) + case + .descriptionChangedByLocalUser(let newGroupDescription), + .descriptionChangedByOtherUser(let newGroupDescription, _, _), + .descriptionChangedByUnknownUser(let newGroupDescription): + return Action( + title: CommonStrings.viewButton, + accessibilityIdentifier: "group_description_view", + action: .didTapViewGroupDescription(newGroupDescription: newGroupDescription) + ) + case let .sequenceOfInviteLinkRequestAndCancels(_, _, _, isTail): + switch changeAuthor { + case .unknown, .legacyE164, .rejectedInviteToPni: + owsFailDebug("Missing parameters for join request sequence") + return nil + case .aci(let requesterAci): + return .sequenceOfInviteLinkRequestAndCancelsAction( + requester: requesterAci, + isTail: isTail, + groupThread: { + TSGroupThread.fetch( + groupId: newGroupModel.groupId, + transaction: tx + ) + }, + contactsManager: contactsManager, + tx: tx + ) + } + default: + break + } + } + + guard let oldGroupModel else { + return nil + } + + let newlyRequestingMembers = newGroupModel.groupMembership.requestingMembers + .subtracting(oldGroupModel.groupMembership.requestingMembers) + + guard !newlyRequestingMembers.isEmpty else { + return nil + } + + let title: String = { + if newlyRequestingMembers.count > 1 { + return OWSLocalizedString( + "GROUPS_VIEW_REQUESTS_BUTTON", + comment: "Label for button that lets the user view the requests to join the group." + ) + } else { + return OWSLocalizedString( + "GROUPS_VIEW_REQUEST_BUTTON", + comment: "Label for button that lets the user view the request to join the group." + ) + } + }() + + return Action( + title: title, + accessibilityIdentifier: "show_group_requests_button", + action: .didTapShowConversationSettingsAndShowMemberRequests + ) + } + private static func action(forCall call: TSCall, thread: TSThread, transaction: SDSAnyReadTransaction) -> Action? { diff --git a/Signal/src/ViewControllers/ConversationView/CV/CVComponents/PersistableGroupUpdateItem+CVComponentSystemMessageAction.swift b/Signal/src/ViewControllers/ConversationView/CV/CVComponents/PersistableGroupUpdateItem+CVComponentSystemMessageAction.swift new file mode 100644 index 0000000000..180c8556db --- /dev/null +++ b/Signal/src/ViewControllers/ConversationView/CV/CVComponents/PersistableGroupUpdateItem+CVComponentSystemMessageAction.swift @@ -0,0 +1,103 @@ +// +// Copyright 2024 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only +// + +import Foundation +import LibSignalClient +import SignalServiceKit + +extension TSInfoMessage.PersistableGroupUpdateItem { + + func cvComponentAction( + groupThread: () -> TSGroupThread?, + contactsManager: ContactsManagerProtocol, + tx: SDSAnyReadTransaction + ) -> CVComponentSystemMessage.Action? { + switch self { + case let .sequenceOfInviteLinkRequestAndCancels(requester, _, isTail): + return .sequenceOfInviteLinkRequestAndCancelsAction( + requester: requester.wrappedValue, + isTail: isTail, + groupThread: groupThread, + contactsManager: contactsManager, + tx: tx + ) + case .invitedPniPromotedToFullMemberAci: + return nil + case .localUserDeclinedInviteFromInviter: + return nil + case .localUserDeclinedInviteFromUnknownUser: + return nil + case .otherUserDeclinedInviteFromLocalUser: + return nil + case .otherUserDeclinedInviteFromInviter: + return nil + case .otherUserDeclinedInviteFromUnknownUser: + return nil + case .localUserInviteRevoked: + return nil + case .localUserInviteRevokedByUnknownUser: + return nil + case .otherUserInviteRevokedByLocalUser: + return nil + case .unnamedUserInvitesWereRevokedByLocalUser: + return nil + case .unnamedUserInvitesWereRevokedByOtherUser: + return nil + case .unnamedUserInvitesWereRevokedByUnknownUser: + return nil + case .unnamedUserDeclinedInviteFromInviter: + return nil + case .unnamedUserDeclinedInviteFromUnknownUser: + return nil + } + } +} + +extension CVComponentSystemMessage.Action { + + static func sequenceOfInviteLinkRequestAndCancelsAction( + requester: Aci, + isTail: Bool, + groupThread: () -> TSGroupThread?, + contactsManager: ContactsManagerProtocol, + tx: SDSAnyReadTransaction + ) -> Self? { + guard isTail else { return nil } + + guard + let mostRecentGroupModel = groupThread()?.groupModel as? TSGroupModelV2 + else { + owsFailDebug("Missing group thread for join request sequence") + return nil + } + + // Only show the option to ban if we are an admin, and they are + // not already banned. We want to use the most up-to-date group + // model here instead of the one on the info message, since + // group state may have changed since that message. + guard + mostRecentGroupModel.groupMembership.isLocalUserFullMemberAndAdministrator, + !mostRecentGroupModel.groupMembership.isBannedMember(requester) + else { + return nil + } + + return CVComponentSystemMessage.Action( + title: OWSLocalizedString( + "GROUPS_BLOCK_REQUEST_BUTTON", + comment: "Label for button that lets the user block a request to join the group." + ), + accessibilityIdentifier: "block_join_request_button", + action: .didTapBlockRequest( + groupModel: mostRecentGroupModel, + requesterName: contactsManager.shortDisplayName( + for: SignalServiceAddress(requester), + transaction: tx + ), + requesterAci: requester + ) + ) + } +} diff --git a/SignalMessaging/Notifications/AppNotifications.swift b/SignalMessaging/Notifications/AppNotifications.swift index 8ef470ad30..43dd58b8da 100644 --- a/SignalMessaging/Notifications/AppNotifications.swift +++ b/SignalMessaging/Notifications/AppNotifications.swift @@ -1031,9 +1031,31 @@ public class NotificationPresenter: NSObject, NotificationsProtocolSwift { } if let infoMessage = tsInteraction as? TSInfoMessage { + let localIdentifiers = DependenciesBridge.shared.tsAccountManager.localIdentifiers( + tx: transaction.asV2Read + ) switch infoMessage.messageType { case .typeGroupUpdate: - if let groupUpdateAuthor = infoMessage.groupUpdateSourceAddress, + let groupUpdateAuthor: SignalServiceAddress? + switch infoMessage.groupUpdateMetadata(localIdentifiers: localIdentifiers) { + case .legacyRawString, .nonGroupUpdate: + groupUpdateAuthor = nil + case .newGroup(_, let updateMetadata), .modelDiff(_, _, let updateMetadata): + switch updateMetadata.source { + case .unknown: + groupUpdateAuthor = nil + case .legacyE164(let e164): + groupUpdateAuthor = .init(e164) + case .aci(let aci): + groupUpdateAuthor = .init(aci) + case .rejectedInviteToPni(let pni): + groupUpdateAuthor = .init(pni) + } + case .precomputed(let persistableGroupUpdateItemsWrapper): + groupUpdateAuthor = persistableGroupUpdateItemsWrapper + .asSingleUpdateItem?.senderForNotification + } + if let groupUpdateAuthor, let intent = thread.generateSendMessageIntent(context: .senderAddress(groupUpdateAuthor), transaction: transaction) { wrapIntent(intent) } diff --git a/SignalMessaging/groups/GroupV2UpdatesImpl.swift b/SignalMessaging/groups/GroupV2UpdatesImpl.swift index a29141a27a..d70dcc020e 100644 --- a/SignalMessaging/groups/GroupV2UpdatesImpl.swift +++ b/SignalMessaging/groups/GroupV2UpdatesImpl.swift @@ -184,12 +184,12 @@ extension GroupV2UpdatesImpl: GroupV2UpdatesSwift { newGroupModel: changedGroupModel.newGroupModel, newDisappearingMessageToken: changedGroupModel.newDisappearingMessageToken, newlyLearnedPniToAciAssociations: changedGroupModel.newlyLearnedPniToAciAssociations, - groupUpdateSource: changedGroupModel.changeAuthor, + groupUpdateSource: changedGroupModel.updateSource, localIdentifiers: localIdentifiers, transaction: transaction ).groupThread - let authoritativeProfileKeys = changedGroupModel.profileKeys.filter { $0.key == changedGroupModel.changeAuthor } + let authoritativeProfileKeys = changedGroupModel.profileKeys.filter { $0.key == changedGroupModel.updateSource.serviceId() } GroupManager.storeProfileKeysFromGroupProtos( allProfileKeysByAci: changedGroupModel.profileKeys, authoritativeProfileKeysByAci: authoritativeProfileKeys @@ -692,14 +692,13 @@ private extension GroupV2UpdatesImpl { if let applyResult = applyResult, - let changeAuthor = applyResult.changeAuthor, applyResult.wasLocalUserAddedByChange { owsAssertDebug( - localUserWasAddedBy == nil || (index == 0 && localUserWasAddedBy == changeAuthor), + localUserWasAddedBy == .unknown || (index == 0 && localUserWasAddedBy == applyResult.changeAuthor), "Multiple change actions added the user to the group" ) - localUserWasAddedBy = changeAuthor + localUserWasAddedBy = applyResult.changeAuthor } } @@ -708,10 +707,28 @@ private extension GroupV2UpdatesImpl { authoritativeProfileKeysByAci: authoritativeProfileKeysByAci ) - if - let localUserWasAddedBy = localUserWasAddedBy, - self.blockingManager.isAddressBlocked(SignalServiceAddress(localUserWasAddedBy), transaction: transaction) - { + let localUserWasAddedByBlockedUser: Bool + switch localUserWasAddedBy { + case .unknown: + localUserWasAddedByBlockedUser = false + case .legacyE164(let e164): + localUserWasAddedByBlockedUser = self.blockingManager.isAddressBlocked( + .init(e164), + transaction: transaction + ) + case .aci(let aci): + localUserWasAddedByBlockedUser = self.blockingManager.isAddressBlocked( + .init(aci), + transaction: transaction + ) + case .rejectedInviteToPni(let pni): + localUserWasAddedByBlockedUser = self.blockingManager.isAddressBlocked( + .init(pni), + transaction: transaction + ) + } + + if localUserWasAddedByBlockedUser { // If we have been added to the group by a blocked user, we // should automatically leave the group. To that end, enqueue // a leave action after we've finished processing messages. @@ -753,10 +770,10 @@ private extension GroupV2UpdatesImpl { groupModelOptions: TSGroupModelOptions, localIdentifiers: LocalIdentifiers, transaction: SDSAnyWriteTransaction - ) -> (TSGroupThread, addedToNewThreadBy: ServiceId?)? { + ) -> (TSGroupThread, addedToNewThreadBy: GroupUpdateSource)? { if let groupThread = TSGroupThread.fetch(groupId: groupId, transaction: transaction) { - return (groupThread, addedToNewThreadBy: nil) + return (groupThread, addedToNewThreadBy: .unknown) } do { @@ -801,7 +818,7 @@ private extension GroupV2UpdatesImpl { return ( result.groupThread, - addedToNewThreadBy: didAddLocalUserToV2Group ? groupUpdateSource : nil + addedToNewThreadBy: didAddLocalUserToV2Group ? groupUpdateSource : .unknown ) } catch { owsFailDebug("Error: \(error)") @@ -810,7 +827,7 @@ private extension GroupV2UpdatesImpl { } private struct ApplySingleChangeFromServiceResult { - let changeAuthor: ServiceId? + let changeAuthor: GroupUpdateSource let wasLocalUserAddedByChange: Bool } @@ -862,7 +879,7 @@ private extension GroupV2UpdatesImpl { let newDisappearingMessageToken: DisappearingMessageToken? let newProfileKeys: [Aci: Data] let newlyLearnedPniToAciAssociations: [Pni: Aci] - let groupUpdateSource: ServiceId? + let groupUpdateSource: GroupUpdateSource // We should prefer to update models using the change action if we can, // since it contains information about the change author. @@ -883,7 +900,7 @@ private extension GroupV2UpdatesImpl { newDisappearingMessageToken = changedGroupModel.newDisappearingMessageToken newProfileKeys = changedGroupModel.profileKeys newlyLearnedPniToAciAssociations = changedGroupModel.newlyLearnedPniToAciAssociations - groupUpdateSource = changedGroupModel.changeAuthor + groupUpdateSource = changedGroupModel.updateSource } else if let snapshot = groupChange.snapshot { logger.info("Applying snapshot.") @@ -895,7 +912,7 @@ private extension GroupV2UpdatesImpl { newProfileKeys = snapshot.profileKeys newlyLearnedPniToAciAssociations = [:] // Snapshots don't have a single author, so we don't know the source. - groupUpdateSource = nil + groupUpdateSource = .unknown } else if groupChange.changeActionsProto != nil { logger.info("Change action proto was not a single revision update.") @@ -945,11 +962,13 @@ private extension GroupV2UpdatesImpl { transaction: transaction ).groupThread - if - let groupUpdateSourceAci = groupUpdateSource as? Aci, - let groupUpdateProfileKey = newProfileKeys[groupUpdateSourceAci] - { - authoritativeProfileKeysByAci[groupUpdateSourceAci] = groupUpdateProfileKey + switch groupUpdateSource { + case .unknown, .legacyE164, .rejectedInviteToPni: + break + case .aci(let groupUpdateSourceAci): + if let groupUpdateProfileKey = newProfileKeys[groupUpdateSourceAci] { + authoritativeProfileKeysByAci[groupUpdateSourceAci] = groupUpdateProfileKey + } } // Merge known profile keys, always taking latest. @@ -1040,9 +1059,9 @@ private extension GroupV2UpdatesImpl { let newGroupModel = try builder.buildAsV2() let newDisappearingMessageToken = groupV2Snapshot.disappearingMessageToken - // groupUpdateSourceAddress is nil because we don't know the + // groupUpdateSource is unknown because we don't know the // author(s) of changes reflected in the snapshot. - let groupUpdateSource: ServiceId? = nil + let groupUpdateSource: GroupUpdateSource = .unknown let result = try GroupManager.tryToUpsertExistingGroupThreadInDatabaseAndCreateInfoMessage( newGroupModel: newGroupModel, newDisappearingMessageToken: newDisappearingMessageToken, @@ -1272,16 +1291,38 @@ extension GroupsV2Error: IsRetryableProvider { } private extension GroupV2Change { - func author(groupV2Params: GroupV2Params) throws -> ServiceId? { + func author(groupV2Params: GroupV2Params) throws -> GroupUpdateSource { if let changeActionsProto = changeActionsProto { - guard let changeAuthorUuidData = changeActionsProto.sourceUuid else { - owsFailDebug("Explicit changes should always have authors") - return nil - } - - return try groupV2Params.serviceId(for: changeAuthorUuidData) + return try changeActionsProto.updateSource(groupV2Params: groupV2Params) + } + return .unknown + } +} + +public extension GroupsProtoGroupChangeActions { + + func updateSource(groupV2Params: GroupV2Params) throws -> GroupUpdateSource { + guard let changeAuthorUuidData = self.sourceUuid else { + owsFailDebug("Explicit changes should always have authors") + return .unknown + } + + let serviceId = try groupV2Params.serviceId(for: changeAuthorUuidData) + switch serviceId.concreteType { + case .aci(let aci): + return .aci(aci) + case .pni(let pni): + // As of now, the only update with a pni author is + // declining a pni invite. If this changes, differentiate + // state here and split which enum case this becomes. + // This may not be the BEST place to do that differentiation; + // you may need to pass in new params to be able to tell, + // or even just push this up to the callsite. In any case, + // the time to differentiate is when looking at the group updates + // or before/after model we get from the server. + owsAssertDebug(self.deletePendingMembers.count == 1) + owsAssertDebug(self.deletePendingMembers.first?.deletedUserID == Data(pni.serviceIdBinary)) + return .rejectedInviteToPni(pni) } - - return nil } } diff --git a/SignalMessaging/groups/GroupsV2Impl.swift b/SignalMessaging/groups/GroupsV2Impl.swift index dfef0ac0e0..adc6d7e786 100644 --- a/SignalMessaging/groups/GroupsV2Impl.swift +++ b/SignalMessaging/groups/GroupsV2Impl.swift @@ -2042,7 +2042,7 @@ public class GroupsV2Impl: GroupsV2Swift, GroupsV2, Dependencies { oldDisappearingMessageToken: dmToken, newDisappearingMessageToken: dmToken, newlyLearnedPniToAciAssociations: [:], - groupUpdateSource: localIdentifiers.aci, + groupUpdateSource: .aci(localIdentifiers.aci), localIdentifiers: localIdentifiers, transaction: transaction ) @@ -2082,15 +2082,12 @@ public class GroupsV2Impl: GroupsV2Swift, GroupsV2, Dependencies { let dmConfigurationStore = DependenciesBridge.shared.disappearingMessagesConfigurationStore let dmToken = dmConfigurationStore.fetchOrBuildDefault(for: .thread(groupThread), tx: transaction.asV2Read).asToken - GroupManager.insertGroupUpdateInfoMessage( - groupThread: groupThread, - oldGroupModel: nil, - newGroupModel: groupModel, - oldDisappearingMessageToken: nil, - newDisappearingMessageToken: dmToken, - newlyLearnedPniToAciAssociations: [:], - groupUpdateSource: localIdentifiers.aci, + GroupManager.insertGroupUpdateInfoMessageForNewGroup( localIdentifiers: localIdentifiers, + groupThread: groupThread, + groupModel: groupModel, + disappearingMessageToken: dmToken, + groupUpdateSource: .aci(localIdentifiers.aci), transaction: transaction ) @@ -2240,7 +2237,7 @@ public class GroupsV2Impl: GroupsV2Swift, GroupsV2, Dependencies { oldDisappearingMessageToken: dmToken, newDisappearingMessageToken: dmToken, newlyLearnedPniToAciAssociations: [:], - groupUpdateSource: localIdentifiers.aci, + groupUpdateSource: .aci(localIdentifiers.aci), localIdentifiers: localIdentifiers, transaction: transaction ) @@ -2387,7 +2384,7 @@ public class GroupsV2Impl: GroupsV2Swift, GroupsV2, Dependencies { let dmConfigurationStore = DependenciesBridge.shared.disappearingMessagesConfigurationStore let dmToken = dmConfigurationStore.fetchOrBuildDefault(for: .thread(groupThread), tx: transaction.asV2Read).asToken - // groupUpdateSourceAddress is nil; we don't know who did the update. + // groupUpdateSource is unknown; we don't know who did the update. GroupManager.insertGroupUpdateInfoMessage( groupThread: groupThread, oldGroupModel: oldGroupModel, @@ -2395,7 +2392,7 @@ public class GroupsV2Impl: GroupsV2Swift, GroupsV2, Dependencies { oldDisappearingMessageToken: dmToken, newDisappearingMessageToken: dmToken, newlyLearnedPniToAciAssociations: [:], - groupUpdateSource: nil, + groupUpdateSource: .unknown, localIdentifiers: localIdentifiers, transaction: transaction ) diff --git a/SignalMessaging/groups/GroupsV2IncomingChanges.swift b/SignalMessaging/groups/GroupsV2IncomingChanges.swift index c2f1c80054..5c0dc245a2 100644 --- a/SignalMessaging/groups/GroupsV2IncomingChanges.swift +++ b/SignalMessaging/groups/GroupsV2IncomingChanges.swift @@ -12,7 +12,7 @@ public struct ChangedGroupModel { public let newGroupModel: TSGroupModelV2 // newDisappearingMessageToken is only set of DM state changed. public let newDisappearingMessageToken: DisappearingMessageToken? - public let changeAuthor: ServiceId + public let updateSource: GroupUpdateSource public let profileKeys: [Aci: Data] /// Associations between a PNI and ACI that we learned as a part of this @@ -23,14 +23,14 @@ public struct ChangedGroupModel { oldGroupModel: TSGroupModelV2, newGroupModel: TSGroupModelV2, newDisappearingMessageToken: DisappearingMessageToken?, - changeAuthor: ServiceId, + updateSource: GroupUpdateSource, profileKeys: [Aci: Data], newlyLearnedPniToAciAssociations: [Pni: Aci] ) { self.oldGroupModel = oldGroupModel self.newGroupModel = newGroupModel self.newDisappearingMessageToken = newDisappearingMessageToken - self.changeAuthor = changeAuthor + self.updateSource = updateSource self.profileKeys = profileKeys self.newlyLearnedPniToAciAssociations = newlyLearnedPniToAciAssociations } @@ -70,14 +70,18 @@ public class GroupsV2IncomingChanges { throw GroupsV2Error.cantApplyChangesToPlaceholder } let groupV2Params = try oldGroupModel.groupV2Params() - // Many change actions have author info, e.g. addedByUserID. But we can - // safely assume that all actions in the "change actions" have the same author. - guard let changeAuthorUuidData = changeActionsProto.sourceUuid else { + let updateSource = try changeActionsProto.updateSource(groupV2Params: groupV2Params) + let changeAuthor: ServiceId + switch updateSource { + case .unknown, .legacyE164: + // Many change actions have author info, e.g. addedByUserID. But we can + // safely assume that all actions in the "change actions" have the same author. throw OWSAssertionError("Missing changeAuthorUuid.") + case .aci(let aci): + changeAuthor = aci + case .rejectedInviteToPni(let pni): + changeAuthor = pni } - // Some userIds/uuidCiphertexts can be validated by - // the service. This is one. - let changeAuthor = try groupV2Params.serviceId(for: changeAuthorUuidData) guard changeActionsProto.hasRevision else { throw OWSAssertionError("Missing revision.") @@ -655,7 +659,7 @@ public class GroupsV2IncomingChanges { oldGroupModel: oldGroupModel, newGroupModel: newGroupModel, newDisappearingMessageToken: newDisappearingMessageToken, - changeAuthor: changeAuthor, + updateSource: updateSource, profileKeys: profileKeys, newlyLearnedPniToAciAssociations: newlyLearnedPniToAciAssociations ) diff --git a/SignalServiceKit/src/Messages/Interactions/GroupUpdateSource.swift b/SignalServiceKit/src/Messages/Interactions/GroupUpdateSource.swift new file mode 100644 index 0000000000..5c2df3f38a --- /dev/null +++ b/SignalServiceKit/src/Messages/Interactions/GroupUpdateSource.swift @@ -0,0 +1,142 @@ +// +// Copyright 2024 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only +// + +import Foundation +import LibSignalClient + +public enum GroupUpdateSource { + /// No source found. + case unknown + /// Legacy update (pre-gv2) with only an e164. + case legacyE164(E164) + /// Standard case. Most updates come from an aci. + case aci(Aci) + /// Only case at time of writing where an update comes + /// from a pni; a user invited by pni rejected that invite. + case rejectedInviteToPni(Pni) + + // If there are new cases where updates are authored by + // a pni, add them here. This is because pni authors are + // the SUPER rare exception to the rule, and forcing + // every other case to wonder "when is this a Pni?" is + // more onerous than explicitly typing all such cases. +} + +extension GroupUpdateSource { + + public func serviceId() -> ServiceId? { + switch self { + case .unknown: + return nil + case .legacyE164: + return nil + case .aci(let aci): + return aci + case .rejectedInviteToPni(let pni): + return pni + } + } +} + +extension GroupUpdateSource: Equatable {} + +extension TSInfoMessage { + + // MARK: - Serialization + + private enum GroupUpdateSourceRaw: Int { + /// Historically we serialized the updater as a SignalServiceAddress, + /// so it could be an e164, aci, pni, or unknown. + /// If it is a pni, it _must_ be from an update + /// where a pni invitee rejected the invite. All other + /// cases use aci, or were created after the introduction + /// of this type and therefore would not use the legacy case. + case legacy = 0 + case aci = 1 + case rejectedInviteToPni = 2 + } + + internal static func insertGroupUpdateSource( + _ groupUpdateSource: GroupUpdateSource, + intoInfoMessageUserInfoDict userInfoDict: inout [InfoMessageUserInfoKey: Any], + localIdentifiers: LocalIdentifiers + ) { + switch groupUpdateSource { + case .unknown: + // Don't insert anything. + return + case .legacyE164(let e164): + // We really shouldn't be inserting these in the modern day... + owsFailDebug("Serializing e164 group updater!") + userInfoDict[.groupUpdateSourceType] = GroupUpdateSourceRaw.legacy.rawValue + userInfoDict[.groupUpdateSourceLegacyAddress] = SignalServiceAddress(e164) + userInfoDict[.legacyUpdaterKnownToBeLocalUser] = localIdentifiers.contains(phoneNumber: e164) + case .aci(let aci): + userInfoDict[.groupUpdateSourceType] = GroupUpdateSourceRaw.aci.rawValue + userInfoDict[.groupUpdateSourceAciData] = Data(aci.serviceIdBinary) + case .rejectedInviteToPni(let pni): + userInfoDict[.groupUpdateSourceType] = GroupUpdateSourceRaw.rejectedInviteToPni.rawValue + userInfoDict[.groupUpdateSourcePniData] = Data(pni.serviceIdBinary) + } + } + + internal static func groupUpdateSource( + infoMessageUserInfoDict: [InfoMessageUserInfoKey: Any]? + ) -> GroupUpdateSource { + guard let infoMessageUserInfoDict else { + return .unknown + } + + guard let rawType = infoMessageUserInfoDict[.groupUpdateSourceType] as? Int else { + return legacyGroupUpdateSource(infoMessageUserInfoDict: infoMessageUserInfoDict) + } + switch GroupUpdateSourceRaw(rawValue: rawType) { + case .none: + owsFailDebug("Unknown group update source") + return legacyGroupUpdateSource(infoMessageUserInfoDict: infoMessageUserInfoDict) + case .legacy: + return legacyGroupUpdateSource(infoMessageUserInfoDict: infoMessageUserInfoDict) + case .aci: + guard + let aciData = infoMessageUserInfoDict[.groupUpdateSourceAciData] as? Data, + let aci = try? Aci.parseFrom(serviceIdBinary: aciData) + else { + return .unknown + } + return .aci(aci) + case .rejectedInviteToPni: + guard + let pniData = infoMessageUserInfoDict[.groupUpdateSourcePniData] as? Data, + let pni = try? Pni.parseFrom(serviceIdBinary: pniData) + else { + return .unknown + } + return .rejectedInviteToPni(pni) + } + } + + private static func legacyGroupUpdateSource( + infoMessageUserInfoDict: [InfoMessageUserInfoKey: Any] + ) -> GroupUpdateSource { + guard let address = infoMessageUserInfoDict[.groupUpdateSourceLegacyAddress] as? SignalServiceAddress else { + return .unknown + } + if let aci = address.serviceId as? Aci { + return .aci(aci) + } else if let pni = address.serviceId as? Pni { + // When GroupUpdateSource was introduced, the _only_ way to have + // a Pni (and not an aci) be the source address was when the update + // came from someone invited by Pni rejecting that invitation. + // Maybe other cases got added in the future, but if they did they'd + // not use the legacy address storage, so if we find a legacy address + // with a Pni, it _must_ be from the pni invite rejection case. + return .rejectedInviteToPni(pni) + } else if let e164 = address.e164 { + return .legacyE164(e164) + } else { + return .unknown + } + } +} diff --git a/SignalServiceKit/src/Messages/Interactions/TSInfoMessage+DisplayableGroupUpdateItemBuilder.swift b/SignalServiceKit/src/Messages/Interactions/TSInfoMessage+DisplayableGroupUpdateItemBuilder.swift index 0225708b0d..4d01f96f4e 100644 --- a/SignalServiceKit/src/Messages/Interactions/TSInfoMessage+DisplayableGroupUpdateItemBuilder.swift +++ b/SignalServiceKit/src/Messages/Interactions/TSInfoMessage+DisplayableGroupUpdateItemBuilder.swift @@ -16,10 +16,7 @@ public protocol DisplayableGroupUpdateItemBuilder { /// preferred over all others. func displayableUpdateItemsForPrecomputed( precomputedUpdateItems: [TSInfoMessage.PersistableGroupUpdateItem], - oldGroupModel: TSGroupModel?, localIdentifiers: LocalIdentifiers, - groupUpdateSourceAddress: SignalServiceAddress?, - updaterKnownToBeLocalUser: Bool, tx: DBReadTransaction ) -> [DisplayableGroupUpdateItem] @@ -32,7 +29,7 @@ public protocol DisplayableGroupUpdateItemBuilder { newGroupModel: TSGroupModel, newDisappearingMessageToken: DisappearingMessageToken?, localIdentifiers: LocalIdentifiers, - groupUpdateSourceAddress: SignalServiceAddress?, + groupUpdateSource: GroupUpdateSource, updaterKnownToBeLocalUser: Bool, tx: DBReadTransaction ) -> [DisplayableGroupUpdateItem] @@ -49,7 +46,7 @@ public protocol DisplayableGroupUpdateItemBuilder { oldDisappearingMessageToken: DisappearingMessageToken?, newDisappearingMessageToken: DisappearingMessageToken?, localIdentifiers: LocalIdentifiers, - groupUpdateSourceAddress: SignalServiceAddress?, + groupUpdateSource: GroupUpdateSource, updaterKnownToBeLocalUser: Bool, tx: DBReadTransaction ) -> [DisplayableGroupUpdateItem] @@ -57,7 +54,7 @@ public protocol DisplayableGroupUpdateItemBuilder { /// Get a default group update item, if the models required to build more /// specific group updates are not available. func defaultDisplayableUpdateItem( - groupUpdateSourceAddress: SignalServiceAddress?, + groupUpdateSource: GroupUpdateSource, localIdentifiers: LocalIdentifiers?, tx: DBReadTransaction ) -> DisplayableGroupUpdateItem @@ -74,13 +71,13 @@ public struct DisplayableGroupUpdateItemBuilderImpl: DisplayableGroupUpdateItemB newGroupModel: TSGroupModel, newDisappearingMessageToken: DisappearingMessageToken?, localIdentifiers: LocalIdentifiers, - groupUpdateSourceAddress: SignalServiceAddress?, + groupUpdateSource: GroupUpdateSource, updaterKnownToBeLocalUser: Bool, tx: DBReadTransaction ) -> [DisplayableGroupUpdateItem] { - let updater: Updater = .build( + let updater: GroupUpdater = .build( localIdentifiers: localIdentifiers, - groupUpdateSourceAddress: groupUpdateSourceAddress, + groupUpdateSource: groupUpdateSource, updaterKnownToBeLocalUser: updaterKnownToBeLocalUser, contactsManager: contactsManager, tx: tx @@ -104,30 +101,27 @@ public struct DisplayableGroupUpdateItemBuilderImpl: DisplayableGroupUpdateItemB public func displayableUpdateItemsForPrecomputed( precomputedUpdateItems: [TSInfoMessage.PersistableGroupUpdateItem], - oldGroupModel: TSGroupModel?, localIdentifiers: LocalIdentifiers, - groupUpdateSourceAddress: SignalServiceAddress?, - updaterKnownToBeLocalUser: Bool, tx: DBReadTransaction ) -> [DisplayableGroupUpdateItem] { - let updater: Updater = .build( - localIdentifiers: localIdentifiers, - groupUpdateSourceAddress: groupUpdateSourceAddress, - updaterKnownToBeLocalUser: updaterKnownToBeLocalUser, - contactsManager: contactsManager, - tx: tx - ) - let items = PrecomputedGroupUpdateItemBuilder( contactsManager: contactsManager ).buildGroupUpdateItems( precomputedUpdateItems: precomputedUpdateItems, - oldGroupMembership: oldGroupModel?.groupMembership, - updater: updater, localIdentifiers: localIdentifiers, tx: tx ) + let updater: GroupUpdater + if let precomputedUpdateItem = precomputedUpdateItems.first { + updater = precomputedUpdateItem.updater( + localIdentifiers: localIdentifiers, + contactsManager: contactsManager, + tx: tx + ) + } else { + updater = .unknown + } return validateUpdateItemsNotEmpty( tentativeUpdateItems: items, updater: updater @@ -140,13 +134,13 @@ public struct DisplayableGroupUpdateItemBuilderImpl: DisplayableGroupUpdateItemB oldDisappearingMessageToken: DisappearingMessageToken?, newDisappearingMessageToken: DisappearingMessageToken?, localIdentifiers: LocalIdentifiers, - groupUpdateSourceAddress: SignalServiceAddress?, + groupUpdateSource: GroupUpdateSource, updaterKnownToBeLocalUser: Bool, tx: DBReadTransaction ) -> [DisplayableGroupUpdateItem] { - let updater: Updater = .build( + let updater: GroupUpdater = .build( localIdentifiers: localIdentifiers, - groupUpdateSourceAddress: groupUpdateSourceAddress, + groupUpdateSource: groupUpdateSource, updaterKnownToBeLocalUser: updaterKnownToBeLocalUser, contactsManager: contactsManager, tx: tx @@ -159,7 +153,7 @@ public struct DisplayableGroupUpdateItemBuilderImpl: DisplayableGroupUpdateItemB newDisappearingMessageToken: newDisappearingMessageToken, updater: updater, localIdentifiers: localIdentifiers, - groupUpdateSourceAddress: groupUpdateSourceAddress, + groupUpdateSource: groupUpdateSource, contactsManager: contactsManager, tx: tx ).itemList @@ -171,12 +165,12 @@ public struct DisplayableGroupUpdateItemBuilderImpl: DisplayableGroupUpdateItemB } public func defaultDisplayableUpdateItem( - groupUpdateSourceAddress: SignalServiceAddress?, + groupUpdateSource: GroupUpdateSource, localIdentifiers: LocalIdentifiers?, tx: DBReadTransaction ) -> DisplayableGroupUpdateItem { return DefaultGroupUpdateItemBuilder().buildGroupUpdateItem( - groupUpdateSourceAddress: groupUpdateSourceAddress, + groupUpdateSource: groupUpdateSource, localIdentifiers: localIdentifiers, contactsManager: contactsManager, tx: tx @@ -185,7 +179,7 @@ public struct DisplayableGroupUpdateItemBuilderImpl: DisplayableGroupUpdateItemB private func validateUpdateItemsNotEmpty( tentativeUpdateItems: [DisplayableGroupUpdateItem], - updater: Updater, + updater: GroupUpdater, file: String = #file, function: String = #function, line: Int = #line @@ -212,7 +206,7 @@ public struct DisplayableGroupUpdateItemBuilderImpl: DisplayableGroupUpdateItemB // MARK: - -private enum Updater { +public enum GroupUpdater { case localUser case otherUser(updaterName: String, updaterAddress: SignalServiceAddress) case unknown @@ -221,19 +215,27 @@ private enum Updater { /// /// - Parameter updaterKnownToBeLocalUser /// Whether we know ahead of time that the updater is the local user. - static func build( + internal static func build( localIdentifiers: LocalIdentifiers?, - groupUpdateSourceAddress: SignalServiceAddress?, + groupUpdateSource: GroupUpdateSource, updaterKnownToBeLocalUser: Bool, contactsManager: Shims.ContactsManager, tx: DBReadTransaction - ) -> Updater { + ) -> GroupUpdater { if updaterKnownToBeLocalUser { return .localUser } - guard let updaterAddress = groupUpdateSourceAddress else { + let updaterAddress: SignalServiceAddress + switch groupUpdateSource { + case .unknown: return .unknown + case .legacyE164(let e164): + updaterAddress = .init(e164) + case .aci(let aci): + updaterAddress = .init(aci) + case .rejectedInviteToPni(let pni): + updaterAddress = .init(pni) } if let localIdentifiers, localIdentifiers.contains(address: updaterAddress) { @@ -333,58 +335,103 @@ private struct PrecomputedGroupUpdateItemBuilder { func buildGroupUpdateItems( precomputedUpdateItems: [TSInfoMessage.PersistableGroupUpdateItem], - oldGroupMembership: GroupMembership?, - updater: Updater, localIdentifiers: LocalIdentifiers, tx: DBReadTransaction ) -> [DisplayableGroupUpdateItem] { return precomputedUpdateItems.compactMap { persistableGroupUpdateItem -> DisplayableGroupUpdateItem? in + + let updater = persistableGroupUpdateItem.updater( + localIdentifiers: localIdentifiers, + contactsManager: contactsManager, + tx: tx + ) + switch persistableGroupUpdateItem { - case let .sequenceOfInviteLinkRequestAndCancels(count, isTail): + case let .sequenceOfInviteLinkRequestAndCancels(_, count, isTail): return sequenceOfInviteLinkRequestAndCancelsItem( updater: updater, count: count, isTail: isTail ) - case let .invitedPniPromotedToFullMemberAci(pni, aci): - let invitedPniAddress = SignalServiceAddress(pni.wrappedValue) - let fullMemberAciAddress = SignalServiceAddress(aci.wrappedValue) - + case let .invitedPniPromotedToFullMemberAci(newMember, inviter): return DiffingGroupUpdateItemBuilder.userInviteWasAcceptedItem( - invitedAsAddress: invitedPniAddress, - acceptedAsAddress: fullMemberAciAddress, - oldGroupMembership: oldGroupMembership, + acceptedAsAddress: .init(newMember.wrappedValue), + inviterAci: inviter?.wrappedValue, updater: updater, + // Promotions are only authored by the invitee; otherwise + // it would be an add member not a promotion. + updaterKnownToBeInvitee: true, isLocalUserBlock: { localIdentifiers.isLocalUser(address: $0) }, contactsManager: contactsManager, tx: tx ) - case let .inviteRemoved(invitee, wasLocalUser): - var unnamedInviteCounts = UnnamedInviteCounts() - - if let item = DiffingGroupUpdateItemBuilder.userInviteWasDeclinedOrRevokedItem( - inviteeAddress: SignalServiceAddress(invitee.wrappedValue), - inviteeKnownToBeLocalUser: wasLocalUser, - oldGroupMembership: oldGroupMembership, - unnamedInviteCounts: &unnamedInviteCounts, - updater: updater, - isLocalUserBlock: { localIdentifiers.isLocalUser(address: $0) }, - contactsManager: contactsManager, - tx: tx - ) { - return item - } else { - return DiffingGroupUpdateItemBuilder.unnamedUserInvitesWereRevokedItem( - count: unnamedInviteCounts.revokedInviteCount, - updater: updater - ) - } + case .localUserDeclinedInviteFromInviter(let inviterAci): + return .localUserDeclinedInviteFromInviter( + inviterName: contactsManager.displayName( + address: .init(inviterAci.wrappedValue), + tx: tx + ), + inviterAddress: .init(inviterAci.wrappedValue) + ) + case .localUserDeclinedInviteFromUnknownUser: + return .localUserDeclinedInviteFromUnknownUser + case .otherUserDeclinedInviteFromLocalUser(let invitee): + return .otherUserDeclinedInviteFromLocalUser( + userName: contactsManager.displayName( + address: .init(invitee.wrappedValue), + tx: tx + ), + userAddress: .init(invitee.wrappedValue) + ) + case let .otherUserDeclinedInviteFromInviter(_, inviterAci), + let .unnamedUserDeclinedInviteFromInviter(inviterAci): + return .otherUserDeclinedInviteFromInviter( + inviterName: contactsManager.displayName( + address: .init(inviterAci.wrappedValue), + tx: tx + ), + inviterAddress: .init(inviterAci.wrappedValue) + ) + case .otherUserDeclinedInviteFromUnknownUser, + .unnamedUserDeclinedInviteFromUnknownUser: + return .otherUserDeclinedInviteFromUnknownUser + case .localUserInviteRevoked(let revokerAci): + return .localUserInviteRevoked( + revokerName: contactsManager.displayName( + address: .init(revokerAci.wrappedValue), + tx: tx + ), + revokerAddress: .init(revokerAci.wrappedValue) + ) + case .localUserInviteRevokedByUnknownUser: + return .localUserInviteRevokedByUnknownUser + case .otherUserInviteRevokedByLocalUser(let invitee): + return .otherUserInviteRevokedByLocalUser( + userName: contactsManager.displayName( + address: .init(invitee.wrappedValue), + tx: tx + ), + userAddress: .init(invitee.wrappedValue) + ) + case .unnamedUserInvitesWereRevokedByLocalUser(let count): + return .unnamedUserInvitesWereRevokedByLocalUser(count: count) + case let .unnamedUserInvitesWereRevokedByOtherUser(updaterAci, count): + return .unnamedUserInvitesWereRevokedByOtherUser( + updaterName: contactsManager.displayName( + address: .init(updaterAci.wrappedValue), + tx: tx + ), + updaterAddress: .init(updaterAci.wrappedValue), + count: count + ) + case .unnamedUserInvitesWereRevokedByUnknownUser(let count): + return .unnamedUserInvitesWereRevokedByUnknownUser(count: count) } } } private func sequenceOfInviteLinkRequestAndCancelsItem( - updater: Updater, + updater: GroupUpdater, count: UInt, isTail: Bool ) -> DisplayableGroupUpdateItem? { @@ -432,7 +479,7 @@ private struct NewGroupUpdateItemBuilder { func buildGroupUpdateItems( newGroupModel: TSGroupModel, newDisappearingMessageToken: DisappearingMessageToken?, - updater: Updater, + updater: GroupUpdater, localIdentifiers: LocalIdentifiers, tx: DBReadTransaction ) -> [DisplayableGroupUpdateItem] { @@ -474,7 +521,7 @@ private struct NewGroupUpdateItemBuilder { } private func groupWasInsertedItem( - updater: Updater, + updater: GroupUpdater, localIdentifiers: LocalIdentifiers, newGroupModel: TSGroupModel, newGroupMembership: GroupMembership, @@ -565,7 +612,7 @@ private struct DiffingGroupUpdateItemBuilder { private let contactsManager: Shims.ContactsManager private let localIdentifiers: LocalIdentifiers - private let updater: Updater + private let updater: GroupUpdater private let isReplacingJoinRequestPlaceholder: Bool /// The update items, in order. @@ -573,21 +620,21 @@ private struct DiffingGroupUpdateItemBuilder { /// Create a ``GroupUpdateCopy``. /// - /// - Parameter groupUpdateSourceAddress + /// - Parameter groupUpdateSource /// The address to whom this update should be attributed, if known. /// - Parameter updaterKnownToBeLocalUser /// Whether we know, ahead of time, that this update should be attributed to /// the local user. Necessary if we cannot reliably determine attribution - /// via ``groupUpdateSourceAddress`` alone. For example, the update address + /// via ``groupUpdateSource`` alone. For example, the update address /// may refer to a PNI that has moved to another owner. init( oldGroupModel: TSGroupModel, newGroupModel: TSGroupModel, oldDisappearingMessageToken: DisappearingMessageToken?, newDisappearingMessageToken: DisappearingMessageToken?, - updater: Updater, + updater: GroupUpdater, localIdentifiers: LocalIdentifiers, - groupUpdateSourceAddress: SignalServiceAddress?, + groupUpdateSource: GroupUpdateSource, contactsManager: Shims.ContactsManager, tx: DBReadTransaction ) { @@ -607,7 +654,7 @@ private struct DiffingGroupUpdateItemBuilder { newGroupModel: newGroupModel, oldDisappearingMessageToken: oldDisappearingMessageToken, newDisappearingMessageToken: newDisappearingMessageToken, - groupUpdateSourceAddress: groupUpdateSourceAddress, + groupUpdateSource: groupUpdateSource, tx: tx ) @@ -642,7 +689,7 @@ private struct DiffingGroupUpdateItemBuilder { newGroupModel: TSGroupModel, oldDisappearingMessageToken: DisappearingMessageToken?, newDisappearingMessageToken: DisappearingMessageToken?, - groupUpdateSourceAddress: SignalServiceAddress?, + groupUpdateSource: GroupUpdateSource, tx: DBReadTransaction ) { if isReplacingJoinRequestPlaceholder { @@ -650,7 +697,7 @@ private struct DiffingGroupUpdateItemBuilder { oldGroupMembership: oldGroupModel.groupMembership, newGroupMembership: newGroupModel.groupMembership, newGroupModel: newGroupModel, - groupUpdateSourceAddress: groupUpdateSourceAddress, + groupUpdateSource: groupUpdateSource, forLocalUserOnly: true, tx: tx ) @@ -670,7 +717,7 @@ private struct DiffingGroupUpdateItemBuilder { oldGroupMembership: oldGroupModel.groupMembership, newGroupMembership: newGroupModel.groupMembership, newGroupModel: newGroupModel, - groupUpdateSourceAddress: groupUpdateSourceAddress, + groupUpdateSource: groupUpdateSource, forLocalUserOnly: false, tx: tx ) @@ -861,7 +908,7 @@ private struct DiffingGroupUpdateItemBuilder { oldGroupMembership: GroupMembership, newGroupMembership: GroupMembership, newGroupModel: TSGroupModel, - groupUpdateSourceAddress: SignalServiceAddress?, + groupUpdateSource: GroupUpdateSource, forLocalUserOnly: Bool, tx: DBReadTransaction ) { @@ -886,7 +933,18 @@ private struct DiffingGroupUpdateItemBuilder { // If the updater has changed their membership status, ensure it appears _last_. // This trumps the re-ordering of the local user above. - if let updaterAddress = groupUpdateSourceAddress { + let updaterAddress: SignalServiceAddress? + switch groupUpdateSource { + case .unknown: + updaterAddress = nil + case .legacyE164(let e164): + updaterAddress = .init(e164) + case .aci(let aci): + updaterAddress = .init(aci) + case .rejectedInviteToPni(let pni): + updaterAddress = .init(pni) + } + if let updaterAddress { allUsersSorted = allUsersSorted.filter { $0 != updaterAddress } + [updaterAddress] } @@ -1290,11 +1348,23 @@ private struct DiffingGroupUpdateItemBuilder { oldGroupMembership: GroupMembership, tx: DBReadTransaction ) { + let inviterAci = oldGroupMembership.addedByAci(forInvitedMember: invitedAsAddress) + + let updaterKnownToBeInvitee: Bool + switch updater { + case .localUser: + updaterKnownToBeInvitee = localIdentifiers.contains(address: invitedAsAddress) + case .otherUser(_, let updaterAddress): + updaterKnownToBeInvitee = updaterAddress == invitedAsAddress + case .unknown: + updaterKnownToBeInvitee = false + } + addItem(Self.userInviteWasAcceptedItem( - invitedAsAddress: invitedAsAddress, acceptedAsAddress: acceptedAsAddress, - oldGroupMembership: oldGroupMembership, + inviterAci: inviterAci, updater: updater, + updaterKnownToBeInvitee: updaterKnownToBeInvitee, isLocalUserBlock: { isLocalUser(address: $0) }, contactsManager: contactsManager, tx: tx @@ -1302,10 +1372,10 @@ private struct DiffingGroupUpdateItemBuilder { } static func userInviteWasAcceptedItem( - invitedAsAddress: SignalServiceAddress, acceptedAsAddress: SignalServiceAddress, - oldGroupMembership: GroupMembership?, - updater: Updater, + inviterAci: Aci?, + updater: GroupUpdater, + updaterKnownToBeInvitee: Bool, isLocalUserBlock: IsLocalUserBlock, contactsManager: Shims.ContactsManager, tx: DBReadTransaction @@ -1313,7 +1383,7 @@ private struct DiffingGroupUpdateItemBuilder { var inviterName: String? var inviterAddress: SignalServiceAddress? - if let inviterAci = oldGroupMembership?.addedByAci(forInvitedMember: invitedAsAddress) { + if let inviterAci { inviterAddress = SignalServiceAddress(inviterAci) inviterName = contactsManager.displayName(address: SignalServiceAddress(inviterAci), tx: tx) } @@ -1348,10 +1418,10 @@ private struct DiffingGroupUpdateItemBuilder { userAddress: acceptedAsAddress ) case let .otherUser(updaterName, updaterAddress): - if invitedAsAddress == updaterAddress || acceptedAsAddress == updaterAddress { + if updaterKnownToBeInvitee || acceptedAsAddress == updaterAddress { // The update came from the person who was invited. - if isLocalUserBlock(invitedAsAddress) { + if let inviterAddress, isLocalUserBlock(inviterAddress) { return .otherUserAcceptedInviteFromLocalUser( userName: acceptedAsName, userAddress: acceptedAsAddress @@ -1394,10 +1464,12 @@ private struct DiffingGroupUpdateItemBuilder { unnamedInviteCounts: inout UnnamedInviteCounts, tx: DBReadTransaction ) { + let inviterAci = oldGroupMembership.addedByAci(forInvitedMember: inviteeAddress) + Self.userInviteWasDeclinedOrRevokedItem( inviteeAddress: inviteeAddress, inviteeKnownToBeLocalUser: inviteeKnownToBeLocalUser, - oldGroupMembership: oldGroupMembership, + inviterAci: inviterAci, unnamedInviteCounts: &unnamedInviteCounts, updater: updater, isLocalUserBlock: { isLocalUser(address: $0) }, @@ -1418,16 +1490,16 @@ private struct DiffingGroupUpdateItemBuilder { static func userInviteWasDeclinedOrRevokedItem( inviteeAddress: SignalServiceAddress, inviteeKnownToBeLocalUser: Bool, - oldGroupMembership: GroupMembership?, + inviterAci: Aci?, unnamedInviteCounts: inout UnnamedInviteCounts, - updater: Updater, + updater: GroupUpdater, isLocalUserBlock: IsLocalUserBlock, contactsManager: Shims.ContactsManager, tx: DBReadTransaction ) -> DisplayableGroupUpdateItem? { var inviterName: String? var inviterAddress: SignalServiceAddress? - if let inviterAci = oldGroupMembership?.addedByAci(forInvitedMember: inviteeAddress) { + if let inviterAci { inviterAddress = SignalServiceAddress(inviterAci) inviterName = contactsManager.displayName(address: SignalServiceAddress(inviterAci), tx: tx) } @@ -1638,7 +1710,7 @@ private struct DiffingGroupUpdateItemBuilder { static func unnamedUserInvitesWereRevokedItem( count: UInt, - updater: Updater + updater: GroupUpdater ) -> DisplayableGroupUpdateItem? { guard count > 0 else { return nil @@ -1816,7 +1888,7 @@ private struct DiffingGroupUpdateItemBuilder { } static func disappearingMessageUpdateItem( - updater: Updater, + updater: GroupUpdater, oldToken: DisappearingMessageToken?, newToken: DisappearingMessageToken?, forceUnknownAttribution: Bool @@ -2066,14 +2138,14 @@ private struct DefaultGroupUpdateItemBuilder { init() {} func buildGroupUpdateItem( - groupUpdateSourceAddress: SignalServiceAddress?, + groupUpdateSource: GroupUpdateSource, localIdentifiers: LocalIdentifiers?, contactsManager: Shims.ContactsManager, tx: DBReadTransaction ) -> DisplayableGroupUpdateItem { - let updater: Updater = .build( + let updater: GroupUpdater = .build( localIdentifiers: localIdentifiers, - groupUpdateSourceAddress: groupUpdateSourceAddress, + groupUpdateSource: groupUpdateSource, updaterKnownToBeLocalUser: false, contactsManager: contactsManager, tx: tx @@ -2104,7 +2176,7 @@ private extension LocalIdentifiers { // MARK: - Dependencies -private typealias Shims = DisplayableGroupUpdateItemBuilderImpl.Shims +internal typealias Shims = DisplayableGroupUpdateItemBuilderImpl.Shims private typealias IsLocalUserBlock = (SignalServiceAddress) -> Bool extension DisplayableGroupUpdateItemBuilderImpl { diff --git a/SignalServiceKit/src/Messages/Interactions/TSInfoMessage+PersistableGroupUpdateItem.swift b/SignalServiceKit/src/Messages/Interactions/TSInfoMessage+PersistableGroupUpdateItem.swift index 7b9a66a5f9..923f57d704 100644 --- a/SignalServiceKit/src/Messages/Interactions/TSInfoMessage+PersistableGroupUpdateItem.swift +++ b/SignalServiceKit/src/Messages/Interactions/TSInfoMessage+PersistableGroupUpdateItem.swift @@ -8,6 +8,60 @@ import LibSignalClient extension TSInfoMessage { @objc(TSInfoMessageUpdateMessages) + public class LegacyPersistableGroupUpdateItemsWrapper: NSObject, NSCopying, NSSecureCoding { + public let updateItems: [LegacyPersistableGroupUpdateItem] + + public init(_ updateItems: [LegacyPersistableGroupUpdateItem]) { + self.updateItems = updateItems + } + + // MARK: NSCopying + + public func copy(with _: NSZone? = nil) -> Any { + self + } + + // MARK: NSSecureCoding + + public static var supportsSecureCoding: Bool { true } + + private static let messagesKey = "messagesKey" + + public func encode(with aCoder: NSCoder) { + let jsonEncoder = JSONEncoder() + do { + let messagesData = try jsonEncoder.encode(updateItems) + aCoder.encode(messagesData, forKey: Self.messagesKey) + } catch let error { + owsFailDebug("Failed to encode updateItems data: \(error)") + return + } + } + + public required init?(coder aDecoder: NSCoder) { + guard let updateItemsData = aDecoder.decodeObject( + forKey: Self.messagesKey + ) as? Data else { + owsFailDebug("Failed to decode updateItems data") + return nil + } + + let jsonDecoder = JSONDecoder() + do { + updateItems = try jsonDecoder.decode( + [LegacyPersistableGroupUpdateItem].self, + from: updateItemsData + ) + } catch let error { + owsFailDebug("Failed to decode updateItems data: \(error)") + return nil + } + + super.init() + } + } + + @objc(TSInfoMessageUpdateMessagesV2) public class PersistableGroupUpdateItemsWrapper: NSObject, NSCopying, NSSecureCoding { public let updateItems: [PersistableGroupUpdateItem] @@ -65,7 +119,8 @@ extension TSInfoMessage { // MARK: - extension TSInfoMessage { - public enum PersistableGroupUpdateItem: Codable { + + public enum LegacyPersistableGroupUpdateItem: Codable { enum CodingKeys: String, CodingKey { case sequenceOfInviteLinkRequestAndCancels case invitedPniPromotedToFullMemberAci @@ -75,5 +130,143 @@ extension TSInfoMessage { case sequenceOfInviteLinkRequestAndCancels(count: UInt, isTail: Bool) case invitedPniPromotedToFullMemberAci(pni: PniUuid, aci: AciUuid) case inviteRemoved(invitee: ServiceIdUppercaseString, wasLocalUser: Bool) + + func toNewItem( + updater: GroupUpdateSource, + oldGroupModel: TSGroupModel?, + localIdentifiers: LocalIdentifiers? + ) -> PersistableGroupUpdateItem? { + switch self { + case .sequenceOfInviteLinkRequestAndCancels(let count, let isTail): + switch updater { + case .unknown, .legacyE164, .rejectedInviteToPni: + owsFailDebug("How did we get one of these without an updater? that should be impossible") + return nil + case .aci(let aci): + return .sequenceOfInviteLinkRequestAndCancels( + requester: aci.codableUuid, + count: count, + isTail: isTail + ) + } + + case .invitedPniPromotedToFullMemberAci(let pni, let aci): + return .invitedPniPromotedToFullMemberAci( + newMember: aci, + inviter: oldGroupModel?.groupMembership.addedByAci( + forInvitedMember: SignalServiceAddress(pni.wrappedValue) + )?.codableUuid + ) + case .inviteRemoved(let invitee, let wasLocalUser): + let remover: ServiceId + var wasRejectedInvite = false + switch updater { + case .unknown, .legacyE164: + owsFailDebug("Only acis or pnis can remove an invite") + return nil + case .aci(let aci): + remover = aci + case .rejectedInviteToPni(let pni): + remover = pni + wasRejectedInvite = true + } + + let inviterAci = oldGroupModel?.groupMembership.addedByAci( + forInvitedMember: SignalServiceAddress(invitee.wrappedValue) + ) + + if wasLocalUser { + if wasRejectedInvite || (localIdentifiers?.contains(serviceId: remover) ?? false) { + // Local user invite that was rejected. + if let inviterAci { + return .localUserDeclinedInviteFromInviter( + inviterAci: inviterAci.codableUuid + ) + } else { + return .localUserDeclinedInviteFromUnknownUser + } + } else { + // Local user invite that was removed by another user. + if let removerAci = remover as? Aci { + return .localUserInviteRevoked(revokerAci: removerAci.codableUuid) + } else { + return .localUserInviteRevokedByUnknownUser + } + } + } else { + if wasRejectedInvite || invitee.wrappedValue == remover { + // Other user rejected an invite. + if let inviterAci { + if inviterAci == localIdentifiers?.aci { + return .otherUserDeclinedInviteFromLocalUser(invitee: invitee) + } else { + return .otherUserDeclinedInviteFromInviter( + invitee: invitee, + inviterAci: inviterAci.codableUuid + ) + } + } else { + return .otherUserDeclinedInviteFromUnknownUser(invitee: invitee) + } + } else { + // Other user's invite was revoked. + if let removerAci = remover as? Aci { + if removerAci == localIdentifiers?.aci { + return .otherUserInviteRevokedByLocalUser(invitee: invitee) + } else { + return .unnamedUserInvitesWereRevokedByOtherUser( + updaterAci: removerAci.codableUuid, + count: 1 + ) + } + } else { + return .unnamedUserInvitesWereRevokedByUnknownUser(count: 1) + } + } + } + } + } + } + + public enum PersistableGroupUpdateItem: Codable { + enum CodingKeys: String, CodingKey { + case sequenceOfInviteLinkRequestAndCancels + case invitedPniPromotedToFullMemberAci + case localUserDeclinedInviteFromInviter + case localUserDeclinedInviteFromUnknownUser + case otherUserDeclinedInviteFromLocalUser + case otherUserDeclinedInviteFromInviter + case otherUserDeclinedInviteFromUnknownUser + case unnamedUserDeclinedInviteFromInviter + case unnamedUserDeclinedInviteFromUnknownUser + case localUserInviteRevoked + case localUserInviteRevokedByUnknownUser + case otherUserInviteRevokedByLocalUser + case unnamedUserInvitesWereRevokedByLocalUser + case unnamedUserInvitesWereRevokedByOtherUser + case unnamedUserInvitesWereRevokedByUnknownUser + } + + case sequenceOfInviteLinkRequestAndCancels(requester: AciUuid, count: UInt, isTail: Bool) + + case invitedPniPromotedToFullMemberAci(newMember: AciUuid, inviter: AciUuid?) + + case localUserDeclinedInviteFromInviter(inviterAci: AciUuid) + case localUserDeclinedInviteFromUnknownUser + case otherUserDeclinedInviteFromLocalUser(invitee: ServiceIdUppercaseString) + case otherUserDeclinedInviteFromInviter(invitee: ServiceIdUppercaseString, inviterAci: AciUuid) + case otherUserDeclinedInviteFromUnknownUser(invitee: ServiceIdUppercaseString) + case unnamedUserDeclinedInviteFromInviter(inviterAci: AciUuid) + case unnamedUserDeclinedInviteFromUnknownUser + + case localUserInviteRevoked(revokerAci: AciUuid) + case localUserInviteRevokedByUnknownUser + // For a single invite we revoked we keep the invitee. + // For many, or if someone else revoked, we just keep the number. + case otherUserInviteRevokedByLocalUser(invitee: ServiceIdUppercaseString) + case unnamedUserInvitesWereRevokedByLocalUser(count: UInt) + case unnamedUserInvitesWereRevokedByOtherUser(updaterAci: AciUuid, count: UInt) + case unnamedUserInvitesWereRevokedByUnknownUser(count: UInt) + } } diff --git a/SignalServiceKit/src/Messages/Interactions/TSInfoMessage+PersistableGroupUpdateItemUpdater.swift b/SignalServiceKit/src/Messages/Interactions/TSInfoMessage+PersistableGroupUpdateItemUpdater.swift new file mode 100644 index 0000000000..983c718145 --- /dev/null +++ b/SignalServiceKit/src/Messages/Interactions/TSInfoMessage+PersistableGroupUpdateItemUpdater.swift @@ -0,0 +1,107 @@ +// +// Copyright 2024 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only +// + +import Foundation +import LibSignalClient + +extension TSInfoMessage.PersistableGroupUpdateItem { + + func updater( + localIdentifiers: LocalIdentifiers, + contactsManager: Shims.ContactsManager, + tx: DBReadTransaction + ) -> GroupUpdater { + let groupUpdateSource: GroupUpdateSource + switch self { + case .sequenceOfInviteLinkRequestAndCancels(let requester, _, _): + groupUpdateSource = .aci(requester.wrappedValue) + case .invitedPniPromotedToFullMemberAci(let newMember, _): + // Only the invited member themselves can issue this update. + if localIdentifiers.aci == newMember.wrappedValue { + return .localUser + } else { + groupUpdateSource = .aci(newMember.wrappedValue) + } + case .localUserDeclinedInviteFromInviter: + return .localUser + case .localUserDeclinedInviteFromUnknownUser: + return .localUser + case + let .otherUserDeclinedInviteFromLocalUser(invitee), + let .otherUserDeclinedInviteFromInviter(invitee, _), + let .otherUserDeclinedInviteFromUnknownUser(invitee): + switch invitee.wrappedValue.concreteType { + case .aci(let aci): + groupUpdateSource = .aci(aci) + case .pni(let pni): + groupUpdateSource = .rejectedInviteToPni(pni) + } + case .localUserInviteRevoked(revokerAci: let revokerAci): + groupUpdateSource = .aci(revokerAci.wrappedValue) + case .localUserInviteRevokedByUnknownUser: + return .unknown + case .otherUserInviteRevokedByLocalUser: + return .localUser + case .unnamedUserInvitesWereRevokedByLocalUser: + return .localUser + case let .unnamedUserInvitesWereRevokedByOtherUser(updaterAci, _): + groupUpdateSource = .aci(updaterAci.wrappedValue) + case .unnamedUserInvitesWereRevokedByUnknownUser: + return .unknown + case .unnamedUserDeclinedInviteFromInviter: + return .unknown + case .unnamedUserDeclinedInviteFromUnknownUser: + return .unknown + } + return GroupUpdater.build( + localIdentifiers: localIdentifiers, + groupUpdateSource: groupUpdateSource, + updaterKnownToBeLocalUser: false, + contactsManager: contactsManager, + tx: tx + ) + } + + /// When rendering a notification for a group upate, if the update has a specific + /// "sender", we render the notification UI as if sent from that sender. + public var senderForNotification: SignalServiceAddress? { + let serviceId: ServiceId? = { + switch self { + case .sequenceOfInviteLinkRequestAndCancels(let requester, _, _): + return requester.wrappedValue + case .invitedPniPromotedToFullMemberAci(let newMember, _): + return newMember.wrappedValue + case .localUserDeclinedInviteFromInviter: + return nil + case .localUserDeclinedInviteFromUnknownUser: + return nil + case .otherUserDeclinedInviteFromLocalUser(let invitee): + return invitee.wrappedValue + case .otherUserDeclinedInviteFromInviter(let invitee, _): + return invitee.wrappedValue + case .otherUserDeclinedInviteFromUnknownUser(let invitee): + return invitee.wrappedValue + case .localUserInviteRevoked(let revokerAci): + return revokerAci.wrappedValue + case .localUserInviteRevokedByUnknownUser: + return nil + case .otherUserInviteRevokedByLocalUser: + return nil + case .unnamedUserInvitesWereRevokedByLocalUser: + return nil + case .unnamedUserInvitesWereRevokedByOtherUser(let updaterAci, _): + return updaterAci.wrappedValue + case .unnamedUserInvitesWereRevokedByUnknownUser: + return nil + case .unnamedUserDeclinedInviteFromInviter: + return nil + case .unnamedUserDeclinedInviteFromUnknownUser: + return nil + } + }() + + return serviceId.map { SignalServiceAddress($0) } + } +} diff --git a/SignalServiceKit/src/Messages/Interactions/TSInfoMessage+Swift.swift b/SignalServiceKit/src/Messages/Interactions/TSInfoMessage+Swift.swift index 6117821cd1..8864b31956 100644 --- a/SignalServiceKit/src/Messages/Interactions/TSInfoMessage+Swift.swift +++ b/SignalServiceKit/src/Messages/Interactions/TSInfoMessage+Swift.swift @@ -9,42 +9,52 @@ import LibSignalClient // MARK: - Group updates public extension TSInfoMessage { - private var displayableGroupUpdateIemBuilder: DisplayableGroupUpdateItemBuilder { - DisplayableGroupUpdateItemBuilderImpl( + + @objc + func groupUpdateDescription(transaction tx: SDSAnyReadTransaction) -> NSAttributedString { + + let displayableGroupUpdateItemBuilder = DisplayableGroupUpdateItemBuilderImpl( contactsManager: DisplayableGroupUpdateItemBuilderImpl.Wrappers.ContactsManager( contactsManager ) ) - } - private var tsAccountManager: TSAccountManager { - DependenciesBridge.shared.tsAccountManager - } + let localIdentifiers: LocalIdentifiers? = + DependenciesBridge.shared.tsAccountManager.localIdentifiers(tx: tx.asV2Read) - @objc - func groupUpdateDescription(transaction tx: SDSAnyReadTransaction) -> NSAttributedString { - // for legacy group updates we persisted a pre-rendered string, rather than the details - // to generate that string - if let customMessage = self.customMessage { - return NSAttributedString(string: customMessage) - } - - guard - let newGroupModel = self.newGroupModel, - let localIdentifiers = tsAccountManager.localIdentifiers(tx: tx.asV2Read) - else { - return displayableGroupUpdateIemBuilder.defaultDisplayableUpdateItem( - groupUpdateSourceAddress: groupUpdateSourceAddress, - localIdentifiers: nil, + func fallback() -> NSAttributedString { + return displayableGroupUpdateItemBuilder.defaultDisplayableUpdateItem( + groupUpdateSource: .unknown, + localIdentifiers: localIdentifiers, tx: tx.asV2Read ).localizedText } - let updateItems = buildGroupUpdateItems( - newGroupModel: newGroupModel, - localIdentifiers: localIdentifiers, - tx: tx.asV2Read - ) + let updateItems: [DisplayableGroupUpdateItem] + + switch groupUpdateMetadata(localIdentifiers: localIdentifiers) { + + case .nonGroupUpdate: + return fallback() + + case .legacyRawString(let string): + return NSAttributedString(string: string) + + case .newGroup, .modelDiff, .precomputed: + guard let localIdentifiers else { + return fallback() + } + + guard let items = buildGroupUpdateItems( + localIdentifiers: localIdentifiers, + displayableGroupUpdateItemBuilder: displayableGroupUpdateItemBuilder, + tx: tx.asV2Read + ) else { + return fallback() + } + + updateItems = items + } guard let firstUpdateItem = updateItems.first else { owsFailBeta("Should never have an empty update items list!") @@ -63,74 +73,71 @@ public extension TSInfoMessage { } func displayableGroupUpdateItems(tx: SDSAnyReadTransaction) -> [DisplayableGroupUpdateItem]? { - guard - self.customMessage == nil, - let newGroupModel = self.newGroupModel - else { - // Legacy group updates persisted a pre-rendered string. - return nil - } - - guard let localIdentifiers = tsAccountManager.localIdentifiers( - tx: tx.asV2Read - ) else { - owsFailDebug("Missing local identifiers!") - return nil - } - - return buildGroupUpdateItems( - newGroupModel: newGroupModel, - localIdentifiers: localIdentifiers, + let localIdentifiers = DependenciesBridge.shared.tsAccountManager.localIdentifiers( tx: tx.asV2Read ) + switch groupUpdateMetadata(localIdentifiers: localIdentifiers) { + case .legacyRawString, .nonGroupUpdate: + return nil + + case .newGroup, .modelDiff, .precomputed: + guard let localIdentifiers = DependenciesBridge.shared.tsAccountManager.localIdentifiers( + tx: tx.asV2Read + ) else { + owsFailDebug("Missing local identifiers!") + return nil + } + + let displayableGroupUpdateItemBuilder = DisplayableGroupUpdateItemBuilderImpl( + contactsManager: DisplayableGroupUpdateItemBuilderImpl.Wrappers.ContactsManager( + contactsManager + ) + ) + + return buildGroupUpdateItems( + localIdentifiers: localIdentifiers, + displayableGroupUpdateItemBuilder: displayableGroupUpdateItemBuilder, + tx: tx.asV2Read + ) + } } private func buildGroupUpdateItems( - newGroupModel: TSGroupModel, localIdentifiers: LocalIdentifiers, + displayableGroupUpdateItemBuilder: DisplayableGroupUpdateItemBuilder, tx: DBReadTransaction - ) -> [DisplayableGroupUpdateItem] { - let oldGroupModel = self.oldGroupModel - let oldDisappearingMessageToken = self.oldDisappearingMessageToken - let newDisappearingMessageToken = self.newDisappearingMessageToken - let groupUpdateSourceAddress = self.groupUpdateSourceAddress - let updaterKnownToBeLocalUser = self.updaterWasLocalUser + ) -> [DisplayableGroupUpdateItem]? { + switch groupUpdateMetadata(localIdentifiers: localIdentifiers) { - if let precomputedUpdateItems = self.precomputedGroupUpdateItems { - /// If we have precomputed group update items, we should always - /// prefer displaying them. - return displayableGroupUpdateIemBuilder.displayableUpdateItemsForPrecomputed( + case .nonGroupUpdate, .legacyRawString: + return nil + + case .precomputed(let precomputedUpdateItems): + return displayableGroupUpdateItemBuilder.displayableUpdateItemsForPrecomputed( precomputedUpdateItems: precomputedUpdateItems.updateItems, - oldGroupModel: oldGroupModel, localIdentifiers: localIdentifiers, - groupUpdateSourceAddress: groupUpdateSourceAddress, - updaterKnownToBeLocalUser: updaterKnownToBeLocalUser, tx: tx ) - } else if let oldGroupModel { - /// If we don't have precomputed update items, but we do have group - /// models from before/after the group update, we can diff them to - /// compute the update items now. - return displayableGroupUpdateIemBuilder.displayableUpdateItemsByDiffingModels( - oldGroupModel: oldGroupModel, - newGroupModel: newGroupModel, - oldDisappearingMessageToken: oldDisappearingMessageToken, - newDisappearingMessageToken: newDisappearingMessageToken, + + case let .newGroup(newGroupModel, updateMetadata): + return displayableGroupUpdateItemBuilder.displayableUpdateItemsForNewGroup( + newGroupModel: newGroupModel.groupModel, + newDisappearingMessageToken: newGroupModel.dmToken, localIdentifiers: localIdentifiers, - groupUpdateSourceAddress: groupUpdateSourceAddress, - updaterKnownToBeLocalUser: updaterKnownToBeLocalUser, + groupUpdateSource: updateMetadata.source, + updaterKnownToBeLocalUser: updateMetadata.updaterWasLocalUser, tx: tx ) - } else { - /// If we don't have precomputed update items, and there's no - /// "before" group models, this must be a new group and we can - /// compute update items based on the new models. - return displayableGroupUpdateIemBuilder.displayableUpdateItemsForNewGroup( - newGroupModel: newGroupModel, - newDisappearingMessageToken: newDisappearingMessageToken, + + case let .modelDiff(oldGroupModel, newGroupModel, updateMetadata): + return displayableGroupUpdateItemBuilder.displayableUpdateItemsByDiffingModels( + oldGroupModel: oldGroupModel.groupModel, + newGroupModel: newGroupModel.groupModel, + oldDisappearingMessageToken: oldGroupModel.dmToken, + newDisappearingMessageToken: newGroupModel.dmToken, localIdentifiers: localIdentifiers, - groupUpdateSourceAddress: groupUpdateSourceAddress, - updaterKnownToBeLocalUser: updaterWasLocalUser, + groupUpdateSource: updateMetadata.source, + updaterKnownToBeLocalUser: updateMetadata.updaterWasLocalUser, tx: tx ) } @@ -384,39 +391,109 @@ extension TSInfoMessage { return groupModel } - public var precomputedGroupUpdateItems: PersistableGroupUpdateItemsWrapper? { - return infoMessageValue(forKey: .groupUpdateItems) + public enum GroupUpdateMetadata { + public struct UpdateMetadata { + public let source: GroupUpdateSource + /// Whether we determined, at the time we created this info message, that + /// the updater was the local user. + /// - Returns + /// `true` if we knew conclusively that the updater was the local user, and + /// `false` otherwise. + public let updaterWasLocalUser: Bool + } + + public struct GroupModel { + public let groupModel: TSGroupModel + public let dmToken: DisappearingMessageToken? + } + + /// For legacy group updates we persisted a pre-rendered string, rather than the details + /// to generate that string. + case legacyRawString(String) + + // For some time after we would persist the group state before and after + // (or just the new group state) along with other optional metadata. + // This will be soon unused at write time, but can still be present + // in the database as there was no migration done. + case newGroup(GroupModel, updateMetadata: UpdateMetadata) + case modelDiff(old: GroupModel, new: GroupModel, updateMetadata: UpdateMetadata) + + /// Modern group updates are precomputed into an enum and stored with all necessary metadata + /// strongly typed, whether its a new group or an update to an existing group. + /// Group state is NOT stored with these types. + case precomputed(PersistableGroupUpdateItemsWrapper) + + /// This is not a group update. + case nonGroupUpdate } - public var oldGroupModel: TSGroupModel? { - return infoMessageValue(forKey: .oldGroupModel) + public func groupUpdateMetadata(localIdentifiers: LocalIdentifiers?) -> GroupUpdateMetadata { + if let precomputed: PersistableGroupUpdateItemsWrapper = + infoMessageValue(forKey: .groupUpdateItems) + { + return .precomputed(precomputed) + } else if let legacyPrecomputed: LegacyPersistableGroupUpdateItemsWrapper = + infoMessageValue(forKey: .legacyGroupUpdateItems) + { + let updateMetadata = self.updateMetadata + // Convert the legacy items into new items. + let mappedItems: [PersistableGroupUpdateItem] = legacyPrecomputed + .updateItems + .compactMap { legacyItem in + return legacyItem.toNewItem( + updater: updateMetadata.source, + oldGroupModel: infoMessageValue(forKey: .oldGroupModel), + localIdentifiers: localIdentifiers + ) + } + return .precomputed(.init(mappedItems)) + } else if + let newGroupModel: TSGroupModel = infoMessageValue(forKey: .newGroupModel) + { + let updateMetadata = self.updateMetadata + + if let oldGroupModel: TSGroupModel = infoMessageValue(forKey: .oldGroupModel) { + return .modelDiff( + old: .init( + groupModel: oldGroupModel, + dmToken: infoMessageValue(forKey: .oldDisappearingMessageToken) + ), + new: .init( + groupModel: newGroupModel, + dmToken: infoMessageValue(forKey: .newDisappearingMessageToken) + ), + updateMetadata: updateMetadata + ) + } else { + return .newGroup( + .init( + groupModel: newGroupModel, + dmToken: infoMessageValue(forKey: .newDisappearingMessageToken) + ), + updateMetadata: updateMetadata + ) + } + } else if let customMessage { + return .legacyRawString(customMessage) + } else { + if messageType == .typeGroupUpdate { + owsFailDebug("Group update should contain some metadata!") + } + return .nonGroupUpdate + } } - public var newGroupModel: TSGroupModel? { - return infoMessageValue(forKey: .newGroupModel) - } + private var updateMetadata: GroupUpdateMetadata.UpdateMetadata { + let source = Self.groupUpdateSource(infoMessageUserInfoDict: infoMessageUserInfo) + // We grab this legacy value if we have it; its irrelevant for new persistable + // update items which know if they are from the local user or not. + let updaterWasLocalUser: Bool = + infoMessageValue(forKey: .legacyUpdaterKnownToBeLocalUser) ?? false - public var oldDisappearingMessageToken: DisappearingMessageToken? { - return infoMessageValue(forKey: .oldDisappearingMessageToken) - } - - public var newDisappearingMessageToken: DisappearingMessageToken? { - return infoMessageValue(forKey: .newDisappearingMessageToken) - } - - /// The address of the user to whom this update should be attributed, if - /// known. - public var groupUpdateSourceAddress: SignalServiceAddress? { - return infoMessageValue(forKey: .groupUpdateSourceAddress) - } - - /// Whether we determined, at the time we created this info message, that - /// the updater was the local user. - /// - Returns - /// `true` if we knew conclusively that the updater was the local user, and - /// `false` otherwise. - public var updaterWasLocalUser: Bool { - return infoMessageValue(forKey: .updaterKnownToBeLocalUser) ?? false + return GroupUpdateMetadata.UpdateMetadata( + source: source, + updaterWasLocalUser: updaterWasLocalUser + ) } fileprivate var profileChanges: ProfileChanges? { @@ -451,11 +528,17 @@ extension TSInfoMessage { setInfoMessageValue(updateItemsWrapper, forKey: .groupUpdateItems) } - public func setNewGroupModel(_ newGroupModel: TSGroupModel) { - setInfoMessageValue(newGroupModel, forKey: .newGroupModel) - } - - public func setNewDisappearingMessageToken(_ newDisappearingMessageToken: DisappearingMessageToken) { - setInfoMessageValue(newDisappearingMessageToken, forKey: .newDisappearingMessageToken) + /// New group update info messages do not keep a copy of the group model on themselves. Older + /// instances do; this method updates the group model for older instances and leaves newer ones untouched. + public func setNewGroupModelForLegacyMessage(_ newGroupModel: TSGroupModel) { + // No need to pass local identifiers because we don't care about the + // contents, just what type of update metadata it is. + switch groupUpdateMetadata(localIdentifiers: nil) { + case .legacyRawString, .nonGroupUpdate, .precomputed: + owsFailDebug("Should not be updating group model!") + return + case .newGroup, .modelDiff: + setInfoMessageValue(newGroupModel, forKey: .newGroupModel) + } } } diff --git a/SignalServiceKit/src/Messages/Interactions/TSInfoMessage.h b/SignalServiceKit/src/Messages/Interactions/TSInfoMessage.h index 80b0795dc0..a43737ca2d 100644 --- a/SignalServiceKit/src/Messages/Interactions/TSInfoMessage.h +++ b/SignalServiceKit/src/Messages/Interactions/TSInfoMessage.h @@ -38,13 +38,17 @@ typedef NS_CLOSED_ENUM(NSInteger, TSInfoMessageType) { typedef NSString *InfoMessageUserInfoKey NS_STRING_ENUM; +extern InfoMessageUserInfoKey const InfoMessageUserInfoKeyLegacyGroupUpdateItems; extern InfoMessageUserInfoKey const InfoMessageUserInfoKeyGroupUpdateItems; extern InfoMessageUserInfoKey const InfoMessageUserInfoKeyOldGroupModel; extern InfoMessageUserInfoKey const InfoMessageUserInfoKeyNewGroupModel; extern InfoMessageUserInfoKey const InfoMessageUserInfoKeyOldDisappearingMessageToken; extern InfoMessageUserInfoKey const InfoMessageUserInfoKeyNewDisappearingMessageToken; -extern InfoMessageUserInfoKey const InfoMessageUserInfoKeyGroupUpdateSourceAddress; -extern InfoMessageUserInfoKey const InfoMessageUserInfoKeyUpdaterKnownToBeLocalUser; +extern InfoMessageUserInfoKey const InfoMessageUserInfoKeyGroupUpdateSourceLegacyAddress; +extern InfoMessageUserInfoKey const InfoMessageUserInfoKeyGroupUpdateSourceType; +extern InfoMessageUserInfoKey const InfoMessageUserInfoKeyGroupUpdateSourceAciData; +extern InfoMessageUserInfoKey const InfoMessageUserInfoKeyGroupUpdateSourcePniData; +extern InfoMessageUserInfoKey const InfoMessageUserInfoKeyLegacyUpdaterKnownToBeLocalUser; extern InfoMessageUserInfoKey const InfoMessageUserInfoKeyProfileChanges; extern InfoMessageUserInfoKey const InfoMessageUserInfoKeyChangePhoneNumberAciString; extern InfoMessageUserInfoKey const InfoMessageUserInfoKeyChangePhoneNumberOld; diff --git a/SignalServiceKit/src/Messages/Interactions/TSInfoMessage.m b/SignalServiceKit/src/Messages/Interactions/TSInfoMessage.m index cb91c0dccd..10051ca10c 100644 --- a/SignalServiceKit/src/Messages/Interactions/TSInfoMessage.m +++ b/SignalServiceKit/src/Messages/Interactions/TSInfoMessage.m @@ -10,16 +10,23 @@ NS_ASSUME_NONNULL_BEGIN -const InfoMessageUserInfoKey InfoMessageUserInfoKeyGroupUpdateItems = @"InfoMessageUserInfoKeyUpdateMessages"; +const InfoMessageUserInfoKey InfoMessageUserInfoKeyLegacyGroupUpdateItems = @"InfoMessageUserInfoKeyUpdateMessages"; +const InfoMessageUserInfoKey InfoMessageUserInfoKeyGroupUpdateItems = @"InfoMessageUserInfoKeyUpdateMessagesV2"; const InfoMessageUserInfoKey InfoMessageUserInfoKeyOldGroupModel = @"InfoMessageUserInfoKeyOldGroupModel"; const InfoMessageUserInfoKey InfoMessageUserInfoKeyNewGroupModel = @"InfoMessageUserInfoKeyNewGroupModel"; const InfoMessageUserInfoKey InfoMessageUserInfoKeyOldDisappearingMessageToken = @"InfoMessageUserInfoKeyOldDisappearingMessageToken"; const InfoMessageUserInfoKey InfoMessageUserInfoKeyNewDisappearingMessageToken = @"InfoMessageUserInfoKeyNewDisappearingMessageToken"; -const InfoMessageUserInfoKey InfoMessageUserInfoKeyGroupUpdateSourceAddress +const InfoMessageUserInfoKey InfoMessageUserInfoKeyGroupUpdateSourceLegacyAddress = @"InfoMessageUserInfoKeyGroupUpdateSourceAddress"; -const InfoMessageUserInfoKey InfoMessageUserInfoKeyUpdaterKnownToBeLocalUser +const InfoMessageUserInfoKey InfoMessageUserInfoKeyGroupUpdateSourceType + = @"InfoMessageUserInfoKeyGroupUpdateSourceType"; +const InfoMessageUserInfoKey InfoMessageUserInfoKeyGroupUpdateSourceAciData + = @"InfoMessageUserInfoKeyGroupUpdateSourceAci"; +const InfoMessageUserInfoKey InfoMessageUserInfoKeyGroupUpdateSourcePniData + = @"InfoMessageUserInfoKeyGroupUpdateSourcePni"; +const InfoMessageUserInfoKey InfoMessageUserInfoKeyLegacyUpdaterKnownToBeLocalUser = @"InfoMessageUserInfoKeyUpdaterWasLocalUser"; const InfoMessageUserInfoKey InfoMessageUserInfoKeyProfileChanges = @"InfoMessageUserInfoKeyProfileChanges"; const InfoMessageUserInfoKey InfoMessageUserInfoKeyChangePhoneNumberAciString diff --git a/SignalServiceKit/src/Storage/Database/DeepCopy.swift b/SignalServiceKit/src/Storage/Database/DeepCopy.swift index e7e28857e2..9bfd147626 100644 --- a/SignalServiceKit/src/Storage/Database/DeepCopy.swift +++ b/SignalServiceKit/src/Storage/Database/DeepCopy.swift @@ -348,6 +348,14 @@ extension OWSUserProfileBadgeInfo: DeepCopyable { // MARK: - +@objc +extension TSInfoMessage.LegacyPersistableGroupUpdateItemsWrapper: DeepCopyable { + public func deepCopy() throws -> AnyObject { + // This class can use shallow copies. + return try DeepCopies.shallowCopy(self) + } +} + @objc extension TSInfoMessage.PersistableGroupUpdateItemsWrapper: DeepCopyable { public func deepCopy() throws -> AnyObject { @@ -364,6 +372,9 @@ extension NSObject { if let string = value as? String { return string } + if let data = value as? Data { + return data + } guard let deepCopyable = value as? DeepCopyable else { owsFailDebug("Could not copy: \(value)") return nil diff --git a/SignalServiceKit/src/groups/GroupManager+GroupUpdateInfoMessages.swift b/SignalServiceKit/src/groups/GroupManager+GroupUpdateInfoMessages.swift index c9f102dd72..68f545c8f6 100644 --- a/SignalServiceKit/src/groups/GroupManager+GroupUpdateInfoMessages.swift +++ b/SignalServiceKit/src/groups/GroupManager+GroupUpdateInfoMessages.swift @@ -7,6 +7,26 @@ import Foundation import LibSignalClient extension GroupManager { + + /// Inserts an info message into the given thread for a new group. + public static func insertGroupUpdateInfoMessageForNewGroup( + localIdentifiers: LocalIdentifiers, + groupThread: TSGroupThread, + groupModel: TSGroupModel, + disappearingMessageToken: DisappearingMessageToken, + groupUpdateSource: GroupUpdateSource, + transaction: SDSAnyWriteTransaction + ) { + DependenciesBridge.shared.groupUpdateInfoMessageInserter.insertGroupUpdateInfoMessageForNewGroup( + localIdentifiers: localIdentifiers, + groupThread: groupThread, + groupModel: groupModel, + disappearingMessageToken: disappearingMessageToken, + groupUpdateSource: groupUpdateSource, + transaction: transaction.asV2Write + ) + } + /// Inserts an info message into the given thread describing how the thread /// has been updated, given before/after models for the thread. /// @@ -15,12 +35,12 @@ extension GroupManager { /// group update. public static func insertGroupUpdateInfoMessage( groupThread: TSGroupThread, - oldGroupModel: TSGroupModel?, + oldGroupModel: TSGroupModel, newGroupModel: TSGroupModel, - oldDisappearingMessageToken: DisappearingMessageToken?, + oldDisappearingMessageToken: DisappearingMessageToken, newDisappearingMessageToken: DisappearingMessageToken, newlyLearnedPniToAciAssociations: [Pni: Aci], - groupUpdateSource: ServiceId?, + groupUpdateSource: GroupUpdateSource, localIdentifiers: LocalIdentifiers, transaction: SDSAnyWriteTransaction ) { diff --git a/SignalServiceKit/src/groups/GroupManager.swift b/SignalServiceKit/src/groups/GroupManager.swift index 96b5158e2e..9a5d547f85 100644 --- a/SignalServiceKit/src/groups/GroupManager.swift +++ b/SignalServiceKit/src/groups/GroupManager.swift @@ -285,7 +285,7 @@ public class GroupManager: NSObject { let thread = self.insertGroupThreadInDatabaseAndCreateInfoMessage( groupModel: groupModel, disappearingMessageToken: disappearingMessageToken, - groupUpdateSource: localIdentifiers.aci, + groupUpdateSource: .aci(localIdentifiers.aci), localIdentifiers: localIdentifiers, transaction: transaction ) @@ -416,7 +416,7 @@ public class GroupManager: NSObject { return try remoteUpsertExistingGroupForTests( groupModel: groupModel, disappearingMessageToken: nil, - groupUpdateSource: localIdentifiers.aci, + groupUpdateSource: .aci(localIdentifiers.aci), localIdentifiers: localIdentifiers, transaction: transaction ).groupThread @@ -426,7 +426,7 @@ public class GroupManager: NSObject { private static func remoteUpsertExistingGroupForTests( groupModel: TSGroupModel, disappearingMessageToken: DisappearingMessageToken?, - groupUpdateSource: ServiceId?, + groupUpdateSource: GroupUpdateSource, infoMessagePolicy: InfoMessagePolicy = .always, localIdentifiers: LocalIdentifiers, transaction: SDSAnyWriteTransaction @@ -859,7 +859,7 @@ public class GroupManager: NSObject { groupModelBuilder.groupMembership = groupMembershipBuilder.build() let newGroupModel = try groupModelBuilder.build() - // groupUpdateSource is nil because we don't (and can't) know who removed + // groupUpdateSource is unknown because we don't (and can't) know who removed // us or revoked our invite. // // newDisappearingMessageToken is nil because we don't want to change DM @@ -868,7 +868,7 @@ public class GroupManager: NSObject { newGroupModel: newGroupModel, newDisappearingMessageToken: nil, newlyLearnedPniToAciAssociations: [:], - groupUpdateSource: nil, + groupUpdateSource: .unknown, infoMessagePolicy: .always, localIdentifiers: localIdentifiers, transaction: transaction @@ -968,7 +968,7 @@ public class GroupManager: NSObject { public static func insertGroupThreadInDatabaseAndCreateInfoMessage( groupModel: TSGroupModel, disappearingMessageToken: DisappearingMessageToken?, - groupUpdateSource: ServiceId?, + groupUpdateSource: GroupUpdateSource, infoMessagePolicy: InfoMessagePolicy = .always, localIdentifiers: LocalIdentifiers, transaction: SDSAnyWriteTransaction @@ -991,7 +991,7 @@ public class GroupManager: NSObject { token: newDisappearingMessageToken, thread: groupThread, shouldInsertInfoMessage: false, - changeAuthor: groupUpdateSource, + changeAuthor: groupUpdateSource.serviceId(), localIdentifiers: localIdentifiers, transaction: transaction ) @@ -1006,15 +1006,12 @@ public class GroupManager: NSObject { switch infoMessagePolicy { case .always, .insertsOnly: - insertGroupUpdateInfoMessage( - groupThread: groupThread, - oldGroupModel: nil, - newGroupModel: groupModel, - oldDisappearingMessageToken: nil, - newDisappearingMessageToken: newDisappearingMessageToken, - newlyLearnedPniToAciAssociations: [:], - groupUpdateSource: groupUpdateSource, + insertGroupUpdateInfoMessageForNewGroup( localIdentifiers: localIdentifiers, + groupThread: groupThread, + groupModel: groupModel, + disappearingMessageToken: newDisappearingMessageToken, + groupUpdateSource: groupUpdateSource, transaction: transaction ) default: @@ -1039,7 +1036,7 @@ public class GroupManager: NSObject { newGroupModel: TSGroupModel, newDisappearingMessageToken: DisappearingMessageToken?, newlyLearnedPniToAciAssociations: [Pni: Aci], - groupUpdateSource: ServiceId?, + groupUpdateSource: GroupUpdateSource, canInsert: Bool, didAddLocalUserToV2Group: Bool, infoMessagePolicy: InfoMessagePolicy = .always, @@ -1075,7 +1072,7 @@ public class GroupManager: NSObject { let thread = insertGroupThreadInDatabaseAndCreateInfoMessage( groupModel: newGroupModel, disappearingMessageToken: newDisappearingMessageToken, - groupUpdateSource: shouldAttributeAuthor ? groupUpdateSource : nil, + groupUpdateSource: shouldAttributeAuthor ? groupUpdateSource : .unknown, infoMessagePolicy: infoMessagePolicy, localIdentifiers: localIdentifiers, transaction: transaction @@ -1106,7 +1103,7 @@ public class GroupManager: NSObject { newGroupModel: TSGroupModel, newDisappearingMessageToken: DisappearingMessageToken?, newlyLearnedPniToAciAssociations: [Pni: Aci], - groupUpdateSource: ServiceId?, + groupUpdateSource: GroupUpdateSource, infoMessagePolicy: InfoMessagePolicy = .always, localIdentifiers: LocalIdentifiers, transaction: SDSAnyWriteTransaction @@ -1133,7 +1130,7 @@ public class GroupManager: NSObject { token: newDisappearingMessageToken, thread: groupThread, shouldInsertInfoMessage: false, - changeAuthor: groupUpdateSource, + changeAuthor: groupUpdateSource.serviceId(), localIdentifiers: localIdentifiers, transaction: transaction ) @@ -1349,11 +1346,11 @@ public class GroupManager: NSObject { private static func autoWhitelistGroupIfNecessary( oldGroupModel: TSGroupModel?, newGroupModel: TSGroupModel, - groupUpdateSource: ServiceId?, + groupUpdateSource: GroupUpdateSource, localIdentifiers: LocalIdentifiers, tx: SDSAnyWriteTransaction ) { - guard let groupUpdateSource else { + guard let groupUpdateSource = groupUpdateSource.serviceId() else { return } diff --git a/SignalServiceKit/src/groups/GroupUpdateInfoMessageInserter+FoldIntoExistingMessage.swift b/SignalServiceKit/src/groups/GroupUpdateInfoMessageInserter+FoldIntoExistingMessage.swift index 875f83872a..4b0380b058 100644 --- a/SignalServiceKit/src/groups/GroupUpdateInfoMessageInserter+FoldIntoExistingMessage.swift +++ b/SignalServiceKit/src/groups/GroupUpdateInfoMessageInserter+FoldIntoExistingMessage.swift @@ -20,7 +20,7 @@ extension GroupUpdateInfoMessageInserterImpl { func handlePossiblyCollapsibleMembershipChange( precomputedUpdateType: PrecomputedUpdateType, - localAci: Aci, + localIdentifiers: LocalIdentifiers, groupThread: TSGroupThread, oldGroupModel: TSGroupModel, newGroupModel: TSGroupModel, @@ -37,21 +37,20 @@ extension GroupUpdateInfoMessageInserterImpl { return nil } - owsAssertDebug(mostRecentInfoMsg.newGroupModel == oldGroupModel) - switch precomputedUpdateType { case .newJoinRequestFromSingleUser(let requestingAci): - guard localAci != requestingAci else { + guard localIdentifiers.aci != requestingAci else { return nil } return maybeUpdate( mostRecentInfoMsg: mostRecentInfoMsg, withNewJoinRequestFrom: requestingAci, + localIdentifiers: localIdentifiers, transaction: transaction ) case .canceledJoinRequestFromSingleUser(let cancelingAci): - guard localAci != cancelingAci else { + guard localIdentifiers.aci != cancelingAci else { return nil } @@ -60,16 +59,23 @@ extension GroupUpdateInfoMessageInserterImpl { andSecondMostRecentInfoMsg: secondMostRecentInfoMsgMaybe, withCanceledJoinRequestFrom: cancelingAci, newGroupModel: newGroupModel, + localIdentifiers: localIdentifiers, transaction: transaction ) case .bannedMemberChange: - // If we know only banned members changed we don't want to make a - // new info message, and should simply update the most recent info - // message with the new group model so it accurately reflects the - // latest group state, i.e. is aware of the now-banned members. - - mostRecentInfoMsg.setNewGroupModel(newGroupModel) - mostRecentInfoMsg.anyUpsert(transaction: transaction) + switch mostRecentInfoMsg.groupUpdateMetadata(localIdentifiers: localIdentifiers) { + case .legacyRawString, .nonGroupUpdate, .precomputed: + // Nothing to do for these types; they don't keep the group + // model on themselves. + return .updatesCollapsedIntoExistingMessage + case .newGroup, .modelDiff: + // If we know only banned members changed we don't want to make a + // new info message, and should simply update the most recent info + // message with the new group model so it accurately reflects the + // latest group state, i.e. is aware of the now-banned members. + mostRecentInfoMsg.setNewGroupModelForLegacyMessage(newGroupModel) + mostRecentInfoMsg.anyUpsert(transaction: transaction) + } return .updatesCollapsedIntoExistingMessage case @@ -82,6 +88,7 @@ extension GroupUpdateInfoMessageInserterImpl { private func maybeUpdate( mostRecentInfoMsg: TSInfoMessage, withNewJoinRequestFrom requestingAci: Aci, + localIdentifiers: LocalIdentifiers, transaction: SDSAnyWriteTransaction ) -> CollapsibleMembershipChangeResult? { @@ -93,10 +100,18 @@ extension GroupUpdateInfoMessageInserterImpl { // Note that the new message might get collapsed further (into the // most recent message) in the future. + let mostRecentUpdateItem: TSInfoMessage.PersistableGroupUpdateItem? + switch mostRecentInfoMsg.groupUpdateMetadata(localIdentifiers: localIdentifiers) { + case .precomputed(let precomputedItems): + mostRecentUpdateItem = precomputedItems.asSingleUpdateItem + case .modelDiff, .legacyRawString, .newGroup, .nonGroupUpdate: + return nil + } + guard - let mostRecentUpdateItem = mostRecentInfoMsg.precomputedGroupUpdateItems?.asSingleUpdateItem, - case let .sequenceOfInviteLinkRequestAndCancels(count, isTail) = mostRecentUpdateItem, - requestingAci == mostRecentInfoMsg.groupUpdateSourceAddress?.serviceId + let mostRecentUpdateItem, + case let .sequenceOfInviteLinkRequestAndCancels(requester, count, isTail) = mostRecentUpdateItem, + requestingAci == requester.wrappedValue else { return nil } @@ -104,12 +119,20 @@ extension GroupUpdateInfoMessageInserterImpl { owsAssertDebug(isTail) mostRecentInfoMsg.setSingleUpdateItem( - singleUpdateItem: .sequenceOfInviteLinkRequestAndCancels(count: count, isTail: false) + singleUpdateItem: .sequenceOfInviteLinkRequestAndCancels( + requester: requestingAci.codableUuid, + count: count, + isTail: false + ) ) mostRecentInfoMsg.anyUpsert(transaction: transaction) return .updateItemForNewMessage( - .sequenceOfInviteLinkRequestAndCancels(count: 0, isTail: true) + .sequenceOfInviteLinkRequestAndCancels( + requester: requestingAci.codableUuid, + count: 0, + isTail: true + ) ) } @@ -118,6 +141,7 @@ extension GroupUpdateInfoMessageInserterImpl { andSecondMostRecentInfoMsg secondMostRecentInfoMsg: TSInfoMessage?, withCanceledJoinRequestFrom cancelingAci: Aci, newGroupModel: TSGroupModel, + localIdentifiers: LocalIdentifiers, transaction: SDSAnyWriteTransaction ) -> CollapsibleMembershipChangeResult? { @@ -130,37 +154,48 @@ extension GroupUpdateInfoMessageInserterImpl { // message. guard - let mostRecentInfoMsgJoiner = mostRecentInfoMsg.representsSingleRequestToJoin(), + let mostRecentInfoMsgJoiner = mostRecentInfoMsg.representsSingleRequestToJoin( + localIdentifiers: localIdentifiers + ), cancelingAci == mostRecentInfoMsgJoiner else { return nil } - if - let secondMostRecentInfoMsg = secondMostRecentInfoMsg, - let updateItem = secondMostRecentInfoMsg.precomputedGroupUpdateItems?.asSingleUpdateItem, - case let .sequenceOfInviteLinkRequestAndCancels(count, isTail) = updateItem, - cancelingAci == secondMostRecentInfoMsg.groupUpdateSourceAddress?.serviceId - { - switch mostRecentInfoMsg.precomputedGroupUpdateItems?.asSingleUpdateItem { - case .sequenceOfInviteLinkRequestAndCancels(0, true)?: break - default: owsFailDebug("Unexpected state for most recent info message") - } + let secondMostRecentUpdateItem: TSInfoMessage.PersistableGroupUpdateItem? + switch secondMostRecentInfoMsg?.groupUpdateMetadata(localIdentifiers: localIdentifiers) { + case .precomputed(let precomputedItems): + secondMostRecentUpdateItem = precomputedItems.asSingleUpdateItem + case .modelDiff, .legacyRawString, .newGroup, .nonGroupUpdate, .none: + secondMostRecentUpdateItem = nil + } + if + let secondMostRecentInfoMsg, + let secondMostRecentUpdateItem, + case let .sequenceOfInviteLinkRequestAndCancels(requester, count, isTail) = secondMostRecentUpdateItem, + cancelingAci == requester.wrappedValue + { mostRecentInfoMsg.anyRemove(transaction: transaction) owsAssertDebug(!isTail) - secondMostRecentInfoMsg.setNewGroupModel(newGroupModel) secondMostRecentInfoMsg.setSingleUpdateItem( - singleUpdateItem: .sequenceOfInviteLinkRequestAndCancels(count: count + 1, isTail: true) + singleUpdateItem: .sequenceOfInviteLinkRequestAndCancels( + requester: cancelingAci.codableUuid, + count: count + 1, + isTail: true + ) ) secondMostRecentInfoMsg.anyUpsert(transaction: transaction) return .updatesCollapsedIntoExistingMessage } else { - mostRecentInfoMsg.setNewGroupModel(newGroupModel) mostRecentInfoMsg.setSingleUpdateItem( - singleUpdateItem: .sequenceOfInviteLinkRequestAndCancels(count: 1, isTail: true) + singleUpdateItem: .sequenceOfInviteLinkRequestAndCancels( + requester: cancelingAci.codableUuid, + count: 1, + isTail: true + ) ) mostRecentInfoMsg.anyUpsert(transaction: transaction) @@ -208,7 +243,7 @@ extension GroupUpdateInfoMessageInserterImpl { // MARK: TSInfoMessage extension -private extension TSInfoMessage.PersistableGroupUpdateItemsWrapper { +public extension TSInfoMessage.PersistableGroupUpdateItemsWrapper { var asSingleUpdateItem: TSInfoMessage.PersistableGroupUpdateItem? { guard updateItems.count == 1 else { return nil @@ -223,26 +258,53 @@ private extension TSInfoMessage { setGroupUpdateItemsWrapper(PersistableGroupUpdateItemsWrapper([singleUpdateItem])) } - func representsSingleRequestToJoin() -> Aci? { - guard oldDisappearingMessageToken == newDisappearingMessageToken else { + func representsSingleRequestToJoin(localIdentifiers: LocalIdentifiers) -> Aci? { + switch groupUpdateMetadata(localIdentifiers: localIdentifiers) { + case .newGroup, .nonGroupUpdate, .legacyRawString: return nil - } + case .precomputed(let precomputedItems): + switch precomputedItems.asSingleUpdateItem { + case .none, + .invitedPniPromotedToFullMemberAci, + .localUserDeclinedInviteFromInviter, + .localUserDeclinedInviteFromUnknownUser, + .otherUserDeclinedInviteFromLocalUser, + .otherUserDeclinedInviteFromInviter, + .otherUserDeclinedInviteFromUnknownUser, + .unnamedUserDeclinedInviteFromInviter, + .unnamedUserDeclinedInviteFromUnknownUser, + .localUserInviteRevoked, + .localUserInviteRevokedByUnknownUser, + .otherUserInviteRevokedByLocalUser, + .unnamedUserInvitesWereRevokedByLocalUser, + .unnamedUserInvitesWereRevokedByOtherUser, + .unnamedUserInvitesWereRevokedByUnknownUser: + return nil + case let .sequenceOfInviteLinkRequestAndCancels(requester, count, isTail): + guard isTail, count == 0 else { + return nil + } + return requester.wrappedValue + } + case let .modelDiff(oldGroupModel, newGroupModel, updateMetadata): - guard - let oldGroupModel = oldGroupModel, - let newGroupModel = newGroupModel, - let groupUpdateSourceAddress = groupUpdateSourceAddress, - let membershipEvent = GroupUpdateInfoMessageInserterImpl.PrecomputedUpdateType.from( - oldGroupMembership: oldGroupModel.groupMembership, - newGroupMembership: newGroupModel.groupMembership, - newlyLearnedPniToAciAssociations: [:] - ), - case .newJoinRequestFromSingleUser(let requestingAci) = membershipEvent, - requestingAci == groupUpdateSourceAddress.serviceId - else { - return nil - } + guard oldGroupModel.dmToken == newGroupModel.dmToken else { + return nil + } - return requestingAci + guard + let membershipEvent = GroupUpdateInfoMessageInserterImpl.PrecomputedUpdateType.from( + oldGroupMembership: oldGroupModel.groupModel.groupMembership, + newGroupMembership: newGroupModel.groupModel.groupMembership, + newlyLearnedPniToAciAssociations: [:] + ), + case .newJoinRequestFromSingleUser(let requestingAci) = membershipEvent, + requestingAci == updateMetadata.source.serviceId() + else { + return nil + } + + return requestingAci + } } } diff --git a/SignalServiceKit/src/groups/GroupUpdateInfoMessageInserter.swift b/SignalServiceKit/src/groups/GroupUpdateInfoMessageInserter.swift index cf730344d0..6667c0136a 100644 --- a/SignalServiceKit/src/groups/GroupUpdateInfoMessageInserter.swift +++ b/SignalServiceKit/src/groups/GroupUpdateInfoMessageInserter.swift @@ -7,15 +7,24 @@ import Foundation import LibSignalClient public protocol GroupUpdateInfoMessageInserter { + func insertGroupUpdateInfoMessageForNewGroup( + localIdentifiers: LocalIdentifiers, + groupThread: TSGroupThread, + groupModel: TSGroupModel, + disappearingMessageToken: DisappearingMessageToken, + groupUpdateSource: GroupUpdateSource, + transaction: DBWriteTransaction + ) + func insertGroupUpdateInfoMessage( localIdentifiers: LocalIdentifiers, groupThread: TSGroupThread, - oldGroupModel: TSGroupModel?, + oldGroupModel: TSGroupModel, newGroupModel: TSGroupModel, - oldDisappearingMessageToken: DisappearingMessageToken?, + oldDisappearingMessageToken: DisappearingMessageToken, newDisappearingMessageToken: DisappearingMessageToken, newlyLearnedPniToAciAssociations: [Pni: Aci], - groupUpdateSource: ServiceId?, + groupUpdateSource: GroupUpdateSource, transaction: DBWriteTransaction ) } @@ -27,7 +36,52 @@ class GroupUpdateInfoMessageInserterImpl: GroupUpdateInfoMessageInserter { self.notificationsManager = notificationsManager } - func insertGroupUpdateInfoMessage( + public func insertGroupUpdateInfoMessageForNewGroup( + localIdentifiers: LocalIdentifiers, + groupThread: TSGroupThread, + groupModel: TSGroupModel, + disappearingMessageToken: DisappearingMessageToken, + groupUpdateSource: GroupUpdateSource, + transaction v2Transaction: DBWriteTransaction + ) { + _insertGroupUpdateInfoMessage( + localIdentifiers: localIdentifiers, + groupThread: groupThread, + oldGroupModel: nil, + newGroupModel: groupModel, + oldDisappearingMessageToken: nil, + newDisappearingMessageToken: disappearingMessageToken, + newlyLearnedPniToAciAssociations: [:], + groupUpdateSource: groupUpdateSource, + transaction: v2Transaction + ) + } + + public func insertGroupUpdateInfoMessage( + localIdentifiers: LocalIdentifiers, + groupThread: TSGroupThread, + oldGroupModel: TSGroupModel, + newGroupModel: TSGroupModel, + oldDisappearingMessageToken: DisappearingMessageToken, + newDisappearingMessageToken: DisappearingMessageToken, + newlyLearnedPniToAciAssociations: [Pni: Aci], + groupUpdateSource: GroupUpdateSource, + transaction v2Transaction: DBWriteTransaction + ) { + _insertGroupUpdateInfoMessage( + localIdentifiers: localIdentifiers, + groupThread: groupThread, + oldGroupModel: oldGroupModel, + newGroupModel: newGroupModel, + oldDisappearingMessageToken: oldDisappearingMessageToken, + newDisappearingMessageToken: newDisappearingMessageToken, + newlyLearnedPniToAciAssociations: newlyLearnedPniToAciAssociations, + groupUpdateSource: groupUpdateSource, + transaction: v2Transaction + ) + } + + private func _insertGroupUpdateInfoMessage( localIdentifiers: LocalIdentifiers, groupThread: TSGroupThread, oldGroupModel: TSGroupModel?, @@ -35,7 +89,7 @@ class GroupUpdateInfoMessageInserterImpl: GroupUpdateInfoMessageInserter { oldDisappearingMessageToken: DisappearingMessageToken?, newDisappearingMessageToken: DisappearingMessageToken, newlyLearnedPniToAciAssociations: [Pni: Aci], - groupUpdateSource: ServiceId?, + groupUpdateSource: GroupUpdateSource, transaction v2Transaction: DBWriteTransaction ) { let transaction = SDSDB.shimOnlyBridge(v2Transaction) @@ -59,7 +113,7 @@ class GroupUpdateInfoMessageInserterImpl: GroupUpdateInfoMessageInserter { .bannedMemberChange: switch handlePossiblyCollapsibleMembershipChange( precomputedUpdateType: precomputedUpdateType, - localAci: localIdentifiers.aci, + localIdentifiers: localIdentifiers, groupThread: groupThread, oldGroupModel: oldGroupModel, newGroupModel: newGroupModel, @@ -77,17 +131,62 @@ class GroupUpdateInfoMessageInserterImpl: GroupUpdateInfoMessageInserter { case .invitedPnisPromotedToFullMemberAcis(let promotions): for (pni, aci) in promotions { updateItemsForNewMessage.append( - .invitedPniPromotedToFullMemberAci(pni: pni.codableUuid, aci: aci.codableUuid) + .invitedPniPromotedToFullMemberAci( + newMember: aci.codableUuid, + inviter: oldGroupModel.groupMembership.addedByAci( + forInvitedMember: .init(pni) + )?.codableUuid + ) ) } case .invitesRemoved(let inviteeServiceIds): - for removedInviteServiceId in inviteeServiceIds { + // Maps from the remover's aci (or unknown) to count. + var unnamedInvitesRemoved = [Aci?: Int]() + var inviteesRemovedByLocalUser = [ServiceId]() + for inviteeServiceId in inviteeServiceIds { + let item = Self.persistableUpdateForRemovedInvite( + inviteeServiceId: inviteeServiceId, + groupUpdateSource: groupUpdateSource, + oldGroupMembership: oldGroupModel.groupMembership, + localIdentifiers: localIdentifiers, + unnamedInvitesRemoved: &unnamedInvitesRemoved, + inviteesRemovedByLocalUser: &inviteesRemovedByLocalUser + ) + if let item { + updateItemsForNewMessage.append(item) + } + } + if inviteesRemovedByLocalUser.count == 1 { updateItemsForNewMessage.append( - .inviteRemoved( - invitee: removedInviteServiceId.codableUppercaseString, - wasLocalUser: localIdentifiers.contains(serviceId: removedInviteServiceId) + .otherUserInviteRevokedByLocalUser( + invitee: inviteesRemovedByLocalUser[0].codableUppercaseString ) ) + } else if inviteesRemovedByLocalUser.count > 1 { + updateItemsForNewMessage.append( + .unnamedUserInvitesWereRevokedByLocalUser( + count: UInt(inviteesRemovedByLocalUser.count) + ) + ) + } + for (removerAci, removedInviteCount) in unnamedInvitesRemoved { + guard removedInviteCount > 0 else { + continue + } + if let removerAci { + updateItemsForNewMessage.append( + .unnamedUserInvitesWereRevokedByOtherUser( + updaterAci: removerAci.codableUuid, + count: UInt(removedInviteCount) + ) + ) + } else { + updateItemsForNewMessage.append( + .unnamedUserInvitesWereRevokedByUnknownUser( + count: UInt(removedInviteCount) + ) + ) + } } } } @@ -105,18 +204,11 @@ class GroupUpdateInfoMessageInserterImpl: GroupUpdateInfoMessageInserter { userInfoForNewMessage[.oldDisappearingMessageToken] = oldDisappearingMessageToken } - if let groupUpdateSource { - // If we know to whom this update should be attributed, record it. - // Additionally record whether, while processing, we know that the - // local user is the updater. This works around scenarios in which - // the updating address may refer to a different user in the future, - // such as a PNI moving from account to account. - - userInfoForNewMessage[.groupUpdateSourceAddress] = SignalServiceAddress(groupUpdateSource) - userInfoForNewMessage[.updaterKnownToBeLocalUser] = localIdentifiers.contains(serviceId: groupUpdateSource) - } else { - userInfoForNewMessage[.updaterKnownToBeLocalUser] = false - } + TSInfoMessage.insertGroupUpdateSource( + groupUpdateSource, + intoInfoMessageUserInfoDict: &userInfoForNewMessage, + localIdentifiers: localIdentifiers + ) if !updateItemsForNewMessage.isEmpty { userInfoForNewMessage[.groupUpdateItems] = TSInfoMessage.PersistableGroupUpdateItemsWrapper(updateItemsForNewMessage) @@ -132,7 +224,18 @@ class GroupUpdateInfoMessageInserterImpl: GroupUpdateInfoMessageInserter { let wasLocalUserInGroup = oldGroupModel?.groupMembership.isLocalUserMemberOfAnyKind ?? false let isLocalUserInGroup = newGroupModel.groupMembership.isLocalUserMemberOfAnyKind - if let groupUpdateSource, localIdentifiers.contains(serviceId: groupUpdateSource) { + let isLocalUserUpdate: Bool + switch groupUpdateSource { + case .unknown: + isLocalUserUpdate = false + case .legacyE164(let e164): + isLocalUserUpdate = localIdentifiers.contains(phoneNumber: e164) + case .aci(let aci): + isLocalUserUpdate = localIdentifiers.contains(serviceId: aci) + case .rejectedInviteToPni(let pni): + isLocalUserUpdate = localIdentifiers.contains(serviceId: pni) + } + if isLocalUserUpdate { infoMessage.markAsRead( atTimestamp: NSDate.ows_millisecondTimeStamp(), thread: groupThread, @@ -150,4 +253,103 @@ class GroupUpdateInfoMessageInserterImpl: GroupUpdateInfoMessageInserter { ) } } + + private static func persistableUpdateForRemovedInvite( + inviteeServiceId: ServiceId, + groupUpdateSource: GroupUpdateSource, + oldGroupMembership: GroupMembership, + localIdentifiers: LocalIdentifiers, + unnamedInvitesRemoved: inout [Aci?: Int], + inviteesRemovedByLocalUser: inout [ServiceId] + ) -> TSInfoMessage.PersistableGroupUpdateItem? { + let inviteeIsLocalUser = localIdentifiers.contains( + serviceId: inviteeServiceId + ) + let inviterAci = oldGroupMembership.addedByAci( + forInvitedMember: .init(inviteeServiceId) + ) + let inviterIsLocalUser = inviterAci == localIdentifiers.aci + switch groupUpdateSource { + case .unknown: + if inviteeIsLocalUser { + return .localUserInviteRevokedByUnknownUser + } else { + unnamedInvitesRemoved[nil] = (unnamedInvitesRemoved[nil] ?? 0) + 1 + return nil + } + case .legacyE164: + owsFailDebug("Cannot remove invites from an e164") + return nil + case .aci(let revokerAci): + let revokerIsLocalUser = revokerAci == localIdentifiers.aci + if inviteeIsLocalUser { + if revokerIsLocalUser { + if let inviterAci { + return .localUserDeclinedInviteFromInviter( + inviterAci: inviterAci.codableUuid + ) + } else { + return .localUserDeclinedInviteFromUnknownUser + } + } else { + return .localUserInviteRevoked( + revokerAci: revokerAci.codableUuid + ) + } + } else if revokerAci == inviteeServiceId { + if inviterIsLocalUser { + return .otherUserDeclinedInviteFromLocalUser( + invitee: inviteeServiceId.codableUppercaseString + ) + } else if let inviterAci { + return .otherUserDeclinedInviteFromInviter( + invitee: inviteeServiceId.codableUppercaseString, + inviterAci: inviterAci.codableUuid + ) + } else { + return .otherUserDeclinedInviteFromUnknownUser( + invitee: inviteeServiceId.codableUppercaseString + ) + } + } else { + if revokerIsLocalUser { + inviteesRemovedByLocalUser.append(inviteeServiceId) + return nil + } else { + unnamedInvitesRemoved[revokerAci] = + (unnamedInvitesRemoved[revokerAci] ?? 0) + 1 + return nil + } + } + case .rejectedInviteToPni(let revokerPni): + // Only two options: we rejected our own pni invite, + // or another user rejected their own pni invite. + if revokerPni == localIdentifiers.pni { + if let inviterAci { + return .localUserDeclinedInviteFromInviter( + inviterAci: inviterAci.codableUuid + ) + } else { + return.localUserDeclinedInviteFromUnknownUser + } + } else { + if let inviterAci { + if inviterAci == localIdentifiers.aci { + return .otherUserDeclinedInviteFromLocalUser( + invitee: inviteeServiceId.codableUppercaseString + ) + } else { + return.otherUserDeclinedInviteFromInviter( + invitee: inviteeServiceId.codableUppercaseString, + inviterAci: inviterAci.codableUuid + ) + } + } else { + return .otherUserDeclinedInviteFromUnknownUser( + invitee: inviteeServiceId.codableUppercaseString + ) + } + } + } + } } diff --git a/SignalServiceKit/tests/Messages/Interactions/TSInfoMessage+LegacyPersistablegroupUpdateItemTest.swift b/SignalServiceKit/tests/Messages/Interactions/TSInfoMessage+LegacyPersistablegroupUpdateItemTest.swift new file mode 100644 index 0000000000..e4250b329f --- /dev/null +++ b/SignalServiceKit/tests/Messages/Interactions/TSInfoMessage+LegacyPersistablegroupUpdateItemTest.swift @@ -0,0 +1,169 @@ +// +// Copyright 2023 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only +// + +import LibSignalClient +import XCTest + +@testable import SignalServiceKit + +class TSInfoMessageLegacyPersistableGroupUpdateItemTest: XCTestCase { + private typealias UpdateItem = TSInfoMessage.LegacyPersistableGroupUpdateItem + + // MARK: - Hardcoded constant data + + enum HardcodedDataTestMode { + case runTest + case printStrings + + /// Toggle this to use ``testHardcodedJsonDataDecodes()`` to print + /// hardcoded strings, for example when adding new constants. + static let mode: Self = .runTest + } + + func testHardcodedJsonDataDecodes() { + switch HardcodedDataTestMode.mode { + case .printStrings: + UpdateItem.printHardcodedJsonDataForConstants() + case .runTest: + for (idx, (constant, jsonData)) in UpdateItem.constants.enumerated() { + do { + let decoded = try JSONDecoder().decode(UpdateItem.self, from: jsonData) + try constant.validate(against: decoded) + } catch let error where error is DecodingError { + XCTFail("Failed to decode JSON model for constant \(idx): \(error)") + } catch ValidatableModelError.failedToValidate { + XCTFail("Failed to validate JSON-decoded model for constant \(idx)") + } catch { + XCTFail("Unexpected error for constant \(idx)") + } + } + } + } + + func testConvertToNewUpdateItem() throws { + let updaterAci = Aci.randomForTesting() + + for (constant, _) in UpdateItem.constants { + let expectedNewValue: TSInfoMessage.PersistableGroupUpdateItem + switch constant { + case let .sequenceOfInviteLinkRequestAndCancels(count, isTail): + expectedNewValue = .sequenceOfInviteLinkRequestAndCancels( + requester: updaterAci.codableUuid, + count: count, + isTail: isTail + ) + case let .inviteRemoved(_, wasLocalUser): + if wasLocalUser { + expectedNewValue = .localUserInviteRevoked( + revokerAci: updaterAci.codableUuid + ) + } else { + expectedNewValue = .unnamedUserInvitesWereRevokedByOtherUser( + updaterAci: updaterAci.codableUuid, + count: 1 + ) + } + case let .invitedPniPromotedToFullMemberAci(_, aci): + expectedNewValue = .invitedPniPromotedToFullMemberAci( + newMember: aci, + inviter: nil + ) + } + guard let newValue = constant.toNewItem( + updater: .aci(updaterAci), + oldGroupModel: nil, + localIdentifiers: .init( + aci: .randomForTesting(), + pni: Pni.constantForTesting("PNI:7CE80DE3-6243-4AD5-AE60-0D1F205391DA"), + e164: .init("+15555555555")! + ) + ) else { + XCTFail("Should always be able to convert!") + return + } + + try newValue.validate(against: expectedNewValue) + } + } +} + +extension TSInfoMessage.LegacyPersistableGroupUpdateItem: ValidatableModel { + static var constants: [(TSInfoMessage.LegacyPersistableGroupUpdateItem, base64JsonData: Data)] { + [ + ( + .sequenceOfInviteLinkRequestAndCancels(count: 12, isTail: true), + Data(base64Encoded: "eyJzZXF1ZW5jZU9mSW52aXRlTGlua1JlcXVlc3RBbmRDYW5jZWxzIjp7ImNvdW50IjoxMiwiaXNUYWlsIjp0cnVlfX0=")! + ), + ( + .sequenceOfInviteLinkRequestAndCancels(count: 0, isTail: false), + Data(base64Encoded: "eyJzZXF1ZW5jZU9mSW52aXRlTGlua1JlcXVlc3RBbmRDYW5jZWxzIjp7ImNvdW50IjowLCJpc1RhaWwiOmZhbHNlfX0=")! + ), + ( + .invitedPniPromotedToFullMemberAci( + pni: Pni.constantForTesting("PNI:7CE80DE3-6243-4AD5-AE60-0D1F205391DA").codableUuid, + aci: Aci.constantForTesting("56EE0EF4-A7DF-4B52-BFAF-C637F15B4FEC").codableUuid + ), + Data(base64Encoded: "eyJpbnZpdGVkUG5pUHJvbW90ZWRUb0Z1bGxNZW1iZXJBY2kiOnsicG5pIjoiN0NFODBERTMtNjI0My00QUQ1LUFFNjAtMEQxRjIwNTM5MURBIiwiYWNpIjoiNTZFRTBFRjQtQTdERi00QjUyLUJGQUYtQzYzN0YxNUI0RkVDIn19")! + ), + ( + .inviteRemoved( + invitee: Aci.constantForTesting("56EE0EF4-A7DF-4B52-BFAF-C637F15B4FEC").codableUppercaseString, + wasLocalUser: false + ), + Data(base64Encoded: "eyJpbnZpdGVSZW1vdmVkIjp7Imludml0ZWUiOiI1NkVFMEVGNC1BN0RGLTRCNTItQkZBRi1DNjM3RjE1QjRGRUMiLCJ3YXNMb2NhbFVzZXIiOmZhbHNlfX0=")! + ), + ( + .inviteRemoved( + invitee: Pni.constantForTesting("PNI:7CE80DE3-6243-4AD5-AE60-0D1F205391DA").codableUppercaseString, + wasLocalUser: true + ), + Data(base64Encoded: "eyJpbnZpdGVSZW1vdmVkIjp7Indhc0xvY2FsVXNlciI6dHJ1ZSwiaW52aXRlZSI6IlBOSTo3Q0U4MERFMy02MjQzLTRBRDUtQUU2MC0wRDFGMjA1MzkxREEifX0=")! + ), + ] + } + + func validate(against: TSInfoMessage.LegacyPersistableGroupUpdateItem) throws { + var validated: Bool = false + + switch (self, against) { + case let ( + .sequenceOfInviteLinkRequestAndCancels(selfCount, selfIsTail), + .sequenceOfInviteLinkRequestAndCancels(againstCount, againstIsTail) + ): + if + selfCount == againstCount, + selfIsTail == againstIsTail + { + validated = true + } + case let ( + .invitedPniPromotedToFullMemberAci(selfPni, selfAci), + .invitedPniPromotedToFullMemberAci(againstPni, againstAci) + ): + if + selfPni == againstPni, + selfAci == againstAci + { + validated = true + } + case let ( + .inviteRemoved(selfInvitee, selfWasLocalUser), + .inviteRemoved(againstInvitee, againstWasLocalUser) + ): + if + selfInvitee == againstInvitee, + selfWasLocalUser == againstWasLocalUser + { + validated = true + } + default: + break + } + + guard validated else { + throw ValidatableModelError.failedToValidate + } + } +} diff --git a/SignalServiceKit/tests/Messages/Interactions/TSInfoMessage+PersistableGroupUpdateItemTest.swift b/SignalServiceKit/tests/Messages/Interactions/TSInfoMessage+PersistableGroupUpdateItemTest.swift index b9bbab0565..068cb9e5ef 100644 --- a/SignalServiceKit/tests/Messages/Interactions/TSInfoMessage+PersistableGroupUpdateItemTest.swift +++ b/SignalServiceKit/tests/Messages/Interactions/TSInfoMessage+PersistableGroupUpdateItemTest.swift @@ -47,19 +47,27 @@ extension TSInfoMessage.PersistableGroupUpdateItem: ValidatableModel { static var constants: [(TSInfoMessage.PersistableGroupUpdateItem, base64JsonData: Data)] { [ ( - .sequenceOfInviteLinkRequestAndCancels(count: 12, isTail: true), - Data(base64Encoded: "eyJzZXF1ZW5jZU9mSW52aXRlTGlua1JlcXVlc3RBbmRDYW5jZWxzIjp7ImNvdW50IjoxMiwiaXNUYWlsIjp0cnVlfX0=")! + .sequenceOfInviteLinkRequestAndCancels( + requester: Aci.constantForTesting("56EE0EF4-A7DF-4B52-BFAF-C637F15B4FEC").codableUuid, + count: 12, + isTail: true + ), + Data(base64Encoded: "eyJzZXF1ZW5jZU9mSW52aXRlTGlua1JlcXVlc3RBbmRDYW5jZWxzIjp7ImNvdW50IjoxMiwiaXNUYWlsIjp0cnVlLCJyZXF1ZXN0ZXIiOiI1NkVFMEVGNC1BN0RGLTRCNTItQkZBRi1DNjM3RjE1QjRGRUMifX0=")! ), ( - .sequenceOfInviteLinkRequestAndCancels(count: 0, isTail: false), - Data(base64Encoded: "eyJzZXF1ZW5jZU9mSW52aXRlTGlua1JlcXVlc3RBbmRDYW5jZWxzIjp7ImNvdW50IjowLCJpc1RhaWwiOmZhbHNlfX0=")! + .sequenceOfInviteLinkRequestAndCancels( + requester: Aci.constantForTesting("56EE0EF4-A7DF-4B52-BFAF-C637F15B4FEC").codableUuid, + count: 0, + isTail: false), + Data(base64Encoded: "eyJzZXF1ZW5jZU9mSW52aXRlTGlua1JlcXVlc3RBbmRDYW5jZWxzIjp7ImlzVGFpbCI6ZmFsc2UsImNvdW50IjowLCJyZXF1ZXN0ZXIiOiI1NkVFMEVGNC1BN0RGLTRCNTItQkZBRi1DNjM3RjE1QjRGRUMifX0=")! ), ( .invitedPniPromotedToFullMemberAci( - pni: Pni.constantForTesting("PNI:7CE80DE3-6243-4AD5-AE60-0D1F205391DA").codableUuid, - aci: Aci.constantForTesting("56EE0EF4-A7DF-4B52-BFAF-C637F15B4FEC").codableUuid + newMember: Aci.constantForTesting("56EE0EF4-A7DF-4B52-BFAF-C637F15B4FEC").codableUuid, + inviter: Aci.constantForTesting("56EE0EF4-A7DF-4B52-BFAF-C637F15B5FEE").codableUuid + ), - Data(base64Encoded: "eyJpbnZpdGVkUG5pUHJvbW90ZWRUb0Z1bGxNZW1iZXJBY2kiOnsicG5pIjoiN0NFODBERTMtNjI0My00QUQ1LUFFNjAtMEQxRjIwNTM5MURBIiwiYWNpIjoiNTZFRTBFRjQtQTdERi00QjUyLUJGQUYtQzYzN0YxNUI0RkVDIn19")! + Data(base64Encoded: "eyJpbnZpdGVkUG5pUHJvbW90ZWRUb0Z1bGxNZW1iZXJBY2kiOnsibmV3TWVtYmVyIjoiNTZFRTBFRjQtQTdERi00QjUyLUJGQUYtQzYzN0YxNUI0RkVDIiwiaW52aXRlciI6IjU2RUUwRUY0LUE3REYtNEI1Mi1CRkFGLUM2MzdGMTVCNUZFRSJ9fQ==")! ) ] } @@ -69,22 +77,40 @@ extension TSInfoMessage.PersistableGroupUpdateItem: ValidatableModel { switch (self, against) { case let ( - .sequenceOfInviteLinkRequestAndCancels(selfCount, selfIsTail), - .sequenceOfInviteLinkRequestAndCancels(againstCount, againstIsTail) + .sequenceOfInviteLinkRequestAndCancels(selfRequester, selfCount, selfIsTail), + .sequenceOfInviteLinkRequestAndCancels(againstRequester, againstCount, againstIsTail) ): if + selfRequester == againstRequester, selfCount == againstCount, selfIsTail == againstIsTail { validated = true } case let ( - .invitedPniPromotedToFullMemberAci(selfPni, selfAci), - .invitedPniPromotedToFullMemberAci(againstPni, againstAci) + .invitedPniPromotedToFullMemberAci(selfNewMemberAci, selfInviter), + .invitedPniPromotedToFullMemberAci(againstNewMemberAci, againstInviter) ): if - selfPni == againstPni, - selfAci == againstAci + selfNewMemberAci == againstNewMemberAci, + selfInviter == againstInviter + { + validated = true + } + case let ( + .localUserInviteRevoked(selfRevokerAci), + .localUserInviteRevoked(againstRevokerAci) + ): + if selfRevokerAci == againstRevokerAci { + validated = true + } + case let ( + .unnamedUserInvitesWereRevokedByOtherUser(selfUpdaterAci, selfCount), + .unnamedUserInvitesWereRevokedByOtherUser(againstUpdaterAci, againstCount) + ): + if + selfUpdaterAci == againstUpdaterAci, + selfCount == againstCount { validated = true }