Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
signalapp
GitHub Repository: signalapp/Signal-iOS
Path: blob/main/SignalServiceKit/Messages/Interactions/TSInfoMessage+GroupUpdates+GroupUpdateItemBuilder.swift
1 views
//
// Copyright 2019 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//

import Foundation
import LibSignalClient

public protocol GroupUpdateItemBuilder {
    /// Build a list of group updates using the given precomputed, persisted
    /// update items.
    ///
    /// - Important
    /// If there are precomputed update items available, this method should be
    /// preferred over all others.
    func displayableUpdateItemsForPrecomputed(
        precomputedUpdateItems: [TSInfoMessage.PersistableGroupUpdateItem],
        localIdentifiers: LocalIdentifiers,
        tx: DBReadTransaction,
    ) -> [DisplayableGroupUpdateItem]

    /// Build group update items for a just-inserted group.
    ///
    /// - Note
    /// You should use this method if there are neither precomputed update items
    /// nor an "old group model" available.
    func precomputedUpdateItemsForNewGroup(
        newGroupModel: TSGroupModel,
        newDisappearingMessageToken: DisappearingMessageToken?,
        localIdentifiers: LocalIdentifiers,
        groupUpdateSource: GroupUpdateSource,
        tx: DBReadTransaction,
    ) -> [TSInfoMessage.PersistableGroupUpdateItem]

    /// Build a list of group updates by "diffing" the old and new group states.
    ///
    /// - Note
    /// You should use this method if there are not precomputed update items,
    /// but we do have both an "old/new group model" from before and after a
    /// group update.
    func precomputedUpdateItemsByDiffingModels(
        oldGroupModel: TSGroupModel,
        newGroupModel: TSGroupModel,
        oldDisappearingMessageToken: DisappearingMessageToken?,
        newDisappearingMessageToken: DisappearingMessageToken?,
        localIdentifiers: LocalIdentifiers,
        groupUpdateSource: GroupUpdateSource,
        tx: DBReadTransaction,
    ) -> [TSInfoMessage.PersistableGroupUpdateItem]
}

extension GroupUpdateItemBuilder {
    /// Build group update items for a just-inserted group.
    ///
    /// - Note
    /// You should use this method if there are neither precomputed update items
    /// nor an "old group model" available.
    func displayableUpdateItemsForNewGroup(
        newGroupModel: TSGroupModel,
        newDisappearingMessageToken: DisappearingMessageToken?,
        localIdentifiers: LocalIdentifiers,
        groupUpdateSource: GroupUpdateSource,
        tx: DBReadTransaction,
    ) -> [DisplayableGroupUpdateItem] {
        let precomputedItems = precomputedUpdateItemsForNewGroup(
            newGroupModel: newGroupModel,
            newDisappearingMessageToken: newDisappearingMessageToken,
            localIdentifiers: localIdentifiers,
            groupUpdateSource: groupUpdateSource,
            tx: tx,
        )
        return displayableUpdateItemsForPrecomputed(
            precomputedUpdateItems: precomputedItems,
            localIdentifiers: localIdentifiers,
            tx: tx,
        )
    }

    /// Build a list of group updates by "diffing" the old and new group states.
    ///
    /// - Note
    /// You should use this method if there are not precomputed update items,
    /// but we do have both an "old/new group model" from before and after a
    /// group update.
    func displayableUpdateItemsByDiffingModels(
        oldGroupModel: TSGroupModel,
        newGroupModel: TSGroupModel,
        oldDisappearingMessageToken: DisappearingMessageToken?,
        newDisappearingMessageToken: DisappearingMessageToken?,
        localIdentifiers: LocalIdentifiers,
        groupUpdateSource: GroupUpdateSource,
        tx: DBReadTransaction,
    ) -> [DisplayableGroupUpdateItem] {
        let precomputedItems = precomputedUpdateItemsByDiffingModels(
            oldGroupModel: oldGroupModel,
            newGroupModel: newGroupModel,
            oldDisappearingMessageToken: oldDisappearingMessageToken,
            newDisappearingMessageToken: newDisappearingMessageToken,
            localIdentifiers: localIdentifiers,
            groupUpdateSource: groupUpdateSource,
            tx: tx,
        )
        return displayableUpdateItemsForPrecomputed(
            precomputedUpdateItems: precomputedItems,
            localIdentifiers: localIdentifiers,
            tx: tx,
        )
    }
}

public struct GroupUpdateItemBuilderImpl: GroupUpdateItemBuilder {
    private let contactsManager: ContactManager
    private let recipientDatabaseTable: RecipientDatabaseTable

    init(
        contactsManager: ContactManager,
        recipientDatabaseTable: RecipientDatabaseTable,
    ) {
        self.contactsManager = contactsManager
        self.recipientDatabaseTable = recipientDatabaseTable
    }

    public func precomputedUpdateItemsForNewGroup(
        newGroupModel: TSGroupModel,
        newDisappearingMessageToken: DisappearingMessageToken?,
        localIdentifiers: LocalIdentifiers,
        groupUpdateSource: GroupUpdateSource,
        tx: DBReadTransaction,
    ) -> [TSInfoMessage.PersistableGroupUpdateItem] {
        let groupUpdateSource = groupUpdateSource.sanitize(recipientDatabaseTable: recipientDatabaseTable, tx: tx)

        let precomputedItems = NewGroupUpdateItemBuilder(
            contactsManager: contactsManager,
        ).buildGroupUpdateItems(
            newGroupModel: newGroupModel,
            newDisappearingMessageToken: newDisappearingMessageToken,
            groupUpdateSource: groupUpdateSource,
            localIdentifiers: localIdentifiers,
        )

        return validateUpdateItemsNotEmpty(
            tentativeUpdateItems: precomputedItems,
            groupUpdateSource: groupUpdateSource,
            localIdentifiers: localIdentifiers,
        )
    }

    public func displayableUpdateItemsForPrecomputed(
        precomputedUpdateItems: [TSInfoMessage.PersistableGroupUpdateItem],
        localIdentifiers: LocalIdentifiers,
        tx: DBReadTransaction,
    ) -> [DisplayableGroupUpdateItem] {
        let precomputedUpdateItems = validateUpdateItemsNotEmpty(
            tentativeUpdateItems: precomputedUpdateItems,
            groupUpdateSource: .unknown,
            localIdentifiers: localIdentifiers,
        )

        let builder = PrecomputedGroupUpdateItemBuilder(
            contactsManager: contactsManager,
        )
        let items = precomputedUpdateItems.map {
            builder.buildGroupUpdateItem(
                precomputedUpdateItem: $0,
                localIdentifiers: localIdentifiers,
                tx: tx,
            )
        }

        return items
    }

    public func precomputedUpdateItemsByDiffingModels(
        oldGroupModel: TSGroupModel,
        newGroupModel: TSGroupModel,
        oldDisappearingMessageToken: DisappearingMessageToken?,
        newDisappearingMessageToken: DisappearingMessageToken?,
        localIdentifiers: LocalIdentifiers,
        groupUpdateSource: GroupUpdateSource,
        tx: DBReadTransaction,
    ) -> [TSInfoMessage.PersistableGroupUpdateItem] {
        // Sanitize first so we map e164s to known acis.
        let groupUpdateSource = groupUpdateSource.sanitize(recipientDatabaseTable: recipientDatabaseTable, tx: tx)

        let precomputedItems = DiffingGroupUpdateItemBuilder(
            oldGroupModel: oldGroupModel,
            newGroupModel: newGroupModel,
            oldDisappearingMessageToken: oldDisappearingMessageToken,
            newDisappearingMessageToken: newDisappearingMessageToken,
            groupUpdateSource: groupUpdateSource,
            localIdentifiers: localIdentifiers,
        ).itemList

        return validateUpdateItemsNotEmpty(
            tentativeUpdateItems: precomputedItems,
            groupUpdateSource: groupUpdateSource,
            localIdentifiers: localIdentifiers,
        )
    }

    private func validateUpdateItemsNotEmpty(
        tentativeUpdateItems: [TSInfoMessage.PersistableGroupUpdateItem],
        groupUpdateSource: GroupUpdateSource,
        localIdentifiers: LocalIdentifiers,
        file: String = #file,
        function: String = #function,
        line: Int = #line,
    ) -> [TSInfoMessage.PersistableGroupUpdateItem] {
        guard tentativeUpdateItems.isEmpty else {
            return tentativeUpdateItems
        }

        owsFailDebug("Empty group update!", file: file, function: function, line: line)

        switch groupUpdateSource {
        case .localUser:
            return [.genericUpdateByLocalUser]
        case let .aci(aci):
            return [.genericUpdateByOtherUser(updaterAci: aci.codableUuid)]
        case .rejectedInviteToPni, .legacyE164, .unknown:
            return [.genericUpdateByUnknownUser]
        }
    }
}

// MARK: -

public extension GroupUpdateSource {

    func sanitize(recipientDatabaseTable: RecipientDatabaseTable, tx: DBReadTransaction) -> Self {
        switch self {
        case .legacyE164(let e164):
            // If we can map an e164 to an aci, do that. If we can't,
            // most of the time this becomes "unknown" (which only affects
            // gv1 updates)
            if
                let recipient = recipientDatabaseTable.fetchRecipient(phoneNumber: e164.stringValue, transaction: tx),
                let aci = recipient.aci
            {
                return .aci(aci)
            }
            return .legacyE164(e164)
        default:
            return self
        }
    }
}

// MARK: -

private enum MembershipStatus: Equatable {
    case normalMember(Aci, role: TSGroupMemberRole)
    case invited(ServiceId, role: TSGroupMemberRole, invitedBy: Aci?)
    case requesting(Aci)

    static func of(
        address: SignalServiceAddress,
        groupMembership: GroupMembership,
    ) -> MembershipStatus? {
        guard let serviceId = address.serviceId else {
            return nil
        }

        if
            let aci = serviceId as? Aci,
            groupMembership.isFullMember(address),
            let role = groupMembership.role(for: address)
        {
            return .normalMember(aci, role: role)
        } else if
            groupMembership.isInvitedMember(address),
            let role = groupMembership.role(for: address)
        {
            return .invited(
                serviceId,
                role: role,
                invitedBy: groupMembership.addedByAci(
                    forInvitedMember: serviceId,
                ),
            )
        } else if
            let aci = serviceId as? Aci,
            groupMembership.isRequestingMember(serviceId)
        {
            return .requesting(aci)
        } else {
            return nil
        }
    }
}

// MARK: -

/// Aggregates invite-related changes in which the invitee is unnamed, so we can
/// display one update rather than individual updates for each unnamed user.
private struct UnnamedInviteCounts {
    var newInviteCount: UInt = 0
    var revokedInviteCount: UInt = 0
}

// MARK: -

/// Translates "precomputed" persisted group update items to displayable update
/// items.
///
/// Historically, when a group was updated we persisted a "before" and "after"
/// group model. Then, at display-time, we would "diff" those models to find out
/// what changed.
///
/// All new group updates are now "precomputed" when we learn about an update
/// and persisted. Consequently, all new group updates should go through this
/// struct.
private struct PrecomputedGroupUpdateItemBuilder {
    private let contactsManager: ContactManager

    init(contactsManager: ContactManager) {
        self.contactsManager = contactsManager
    }

    func buildGroupUpdateItem(
        precomputedUpdateItem: TSInfoMessage.PersistableGroupUpdateItem,
        localIdentifiers: LocalIdentifiers,
        tx: DBReadTransaction,
    ) -> DisplayableGroupUpdateItem {
        func expandAci(_ aci: AciUuid) -> (String, SignalServiceAddress) {
            let address = SignalServiceAddress(aci.wrappedValue)

            return (
                contactsManager.displayNameString(for: address, transaction: tx),
                address,
            )
        }

        switch precomputedUpdateItem {
        case let .sequenceOfInviteLinkRequestAndCancels(requesterAci, count, isTail):
            return sequenceOfInviteLinkRequestAndCancelsItem(
                requesterAci: requesterAci.wrappedValue,
                count: count,
                isTail: isTail,
                tx: tx,
            )

        case let .invitedPniPromotedToFullMemberAci(newMember, inviter):
            if newMember.wrappedValue == localIdentifiers.aci {
                // Local user promoted.
                if let inviter {
                    let (inviterName, inviterAddress) = expandAci(inviter)
                    return .localUserAcceptedInviteFromInviter(
                        inviterName: inviterName,
                        inviterAddress: inviterAddress,
                    )
                } else {
                    return .localUserAcceptedInviteFromUnknownUser
                }
            } else {
                // Another user promoted themselves.
                let (userName, userAddress) = expandAci(newMember)
                if let inviter, inviter.wrappedValue == localIdentifiers.aci {
                    return .otherUserAcceptedInviteFromLocalUser(
                        userName: userName,
                        userAddress: userAddress,
                    )
                } else if let inviter {
                    let (inviterName, inviterAddress) = expandAci(inviter)
                    return .otherUserAcceptedInviteFromInviter(
                        userName: userName,
                        userAddress: userAddress,
                        inviterName: inviterName,
                        inviterAddress: inviterAddress,
                    )
                } else {
                    return .otherUserAcceptedInviteFromUnknownUser(
                        userName: userName,
                        userAddress: userAddress,
                    )
                }
            }

        case .genericUpdateByLocalUser:
            return .genericUpdateByLocalUser

        case .genericUpdateByOtherUser(let updaterAci):
            let (updaterName, updaterAddress) = expandAci(updaterAci)
            return .genericUpdateByOtherUser(updaterName: updaterName, updaterAddress: updaterAddress)

        case .genericUpdateByUnknownUser:
            return .genericUpdateByUnknownUser

        case .createdByLocalUser:
            return .createdByLocalUser

        case .createdByOtherUser(let updaterAci):
            let (updaterName, updaterAddress) = expandAci(updaterAci)
            return .createdByOtherUser(updaterName: updaterName, updaterAddress: updaterAddress)

        case .createdByUnknownUser:
            return .createdByUnknownUser

        case .inviteFriendsToNewlyCreatedGroup:
            return .inviteFriendsToNewlyCreatedGroup

        case .wasMigrated:
            return .wasMigrated

        case .localUserInvitedAfterMigration:
            return .localUserInvitedAfterMigration

        case .otherUsersInvitedAfterMigration(let count):
            return .otherUsersInvitedAfterMigration(count: count)

        case .otherUsersDroppedAfterMigration(let count):
            return .otherUsersDroppedAfterMigration(count: count)

        case .nameChangedByLocalUser(let newGroupName):
            return .nameChangedByLocalUser(newGroupName: newGroupName)

        case .nameChangedByOtherUser(let updaterAci, let newGroupName):
            let (updaterName, updaterAddress) = expandAci(updaterAci)
            return .nameChangedByOtherUser(updaterName: updaterName, updaterAddress: updaterAddress, newGroupName: newGroupName)

        case .nameChangedByUnknownUser(let newGroupName):
            return .nameChangedByUnknownUser(newGroupName: newGroupName)

        case .nameRemovedByLocalUser:
            return .nameRemovedByLocalUser

        case .nameRemovedByOtherUser(let updaterAci):
            let (updaterName, updaterAddress) = expandAci(updaterAci)
            return .nameRemovedByOtherUser(updaterName: updaterName, updaterAddress: updaterAddress)

        case .nameRemovedByUnknownUser:
            return .nameRemovedByUnknownUser

        case .avatarChangedByLocalUser:
            return .avatarChangedByLocalUser

        case .avatarChangedByOtherUser(let updaterAci):
            let (updaterName, updaterAddress) = expandAci(updaterAci)
            return .avatarChangedByOtherUser(updaterName: updaterName, updaterAddress: updaterAddress)

        case .avatarChangedByUnknownUser:
            return .avatarChangedByUnknownUser

        case .avatarRemovedByLocalUser:
            return .avatarRemovedByLocalUser

        case .avatarRemovedByOtherUser(let updaterAci):
            let (updaterName, updaterAddress) = expandAci(updaterAci)
            return .avatarRemovedByOtherUser(updaterName: updaterName, updaterAddress: updaterAddress)

        case .avatarRemovedByUnknownUser:
            return .avatarRemovedByUnknownUser

        case .descriptionChangedByLocalUser(let newDescription):
            return .descriptionChangedByLocalUser(newDescription: newDescription)

        case let .descriptionChangedByOtherUser(updaterAci, newDescription):
            let (updaterName, updaterAddress) = expandAci(updaterAci)
            return .descriptionChangedByOtherUser(
                newDescription: newDescription,
                updaterName: updaterName,
                updaterAddress: updaterAddress,
            )

        case .descriptionChangedByUnknownUser(let newDescription):
            return .descriptionChangedByUnknownUser(newDescription: newDescription)

        case .descriptionRemovedByLocalUser:
            return .descriptionRemovedByLocalUser

        case .descriptionRemovedByOtherUser(let updaterAci):
            let (updaterName, updaterAddress) = expandAci(updaterAci)
            return .descriptionRemovedByOtherUser(updaterName: updaterName, updaterAddress: updaterAddress)

        case .descriptionRemovedByUnknownUser:
            return .descriptionRemovedByUnknownUser

        case .membersAccessChangedByLocalUser(let newAccess):
            return .membersAccessChangedByLocalUser(newAccess: newAccess)

        case .membersAccessChangedByOtherUser(let updaterAci, let newAccess):
            let (updaterName, updaterAddress) = expandAci(updaterAci)
            return .membersAccessChangedByOtherUser(updaterName: updaterName, updaterAddress: updaterAddress, newAccess: newAccess)

        case .membersAccessChangedByUnknownUser(let newAccess):
            return .membersAccessChangedByUnknownUser(newAccess: newAccess)

        case .attributesAccessChangedByLocalUser(let newAccess):
            return .attributesAccessChangedByLocalUser(newAccess: newAccess)

        case .attributesAccessChangedByOtherUser(let updaterAci, let newAccess):
            let (updaterName, updaterAddress) = expandAci(updaterAci)
            return .attributesAccessChangedByOtherUser(updaterName: updaterName, updaterAddress: updaterAddress, newAccess: newAccess)

        case .attributesAccessChangedByUnknownUser(let newAccess):
            return .attributesAccessChangedByUnknownUser(newAccess: newAccess)

        case .memberLabelsAccessChangedByLocalUser(let newAccess):
            return .memberLabelsAccessChangedByLocalUser(newAccess: newAccess)

        case .memberLabelsAccessChangedByUnknownUser(let newAccess):
            return .memberLabelsAccessChangedByUnknownUser(newAccess: newAccess)

        case .memberLabelsAccessChangedByOtherUser(let updaterAci, let newAccess):
            let (updaterName, updaterAddress) = expandAci(updaterAci)
            return .memberLabelsAccessChangedByOtherUser(
                updaterName: updaterName,
                updaterAddress: updaterAddress,
                newAccess: newAccess,
            )

        case .announcementOnlyEnabledByLocalUser:
            return .announcementOnlyEnabledByLocalUser

        case .announcementOnlyEnabledByOtherUser(let updaterAci):
            let (updaterName, updaterAddress) = expandAci(updaterAci)
            return .announcementOnlyEnabledByOtherUser(updaterName: updaterName, updaterAddress: updaterAddress)

        case .announcementOnlyEnabledByUnknownUser:
            return .announcementOnlyEnabledByUnknownUser

        case .announcementOnlyDisabledByLocalUser:
            return .announcementOnlyDisabledByLocalUser

        case .announcementOnlyDisabledByOtherUser(let updaterAci):
            let (updaterName, updaterAddress) = expandAci(updaterAci)
            return .announcementOnlyDisabledByOtherUser(updaterName: updaterName, updaterAddress: updaterAddress)

        case .announcementOnlyDisabledByUnknownUser:
            return .announcementOnlyDisabledByUnknownUser

        case .localUserWasGrantedAdministratorByLocalUser:
            return .localUserWasGrantedAdministratorByLocalUser

        case .localUserWasGrantedAdministratorByOtherUser(let updaterAci):
            let (updaterName, updaterAddress) = expandAci(updaterAci)
            return .localUserWasGrantedAdministratorByOtherUser(updaterName: updaterName, updaterAddress: updaterAddress)

        case .localUserWasGrantedAdministratorByUnknownUser:
            return .localUserWasGrantedAdministratorByUnknownUser

        case .otherUserWasGrantedAdministratorByLocalUser(let userAci):
            let (userName, userAddress) = expandAci(userAci)
            return .otherUserWasGrantedAdministratorByLocalUser(userName: userName, userAddress: userAddress)

        case .otherUserWasGrantedAdministratorByOtherUser(let updaterAci, let userAci):
            let (updaterName, updaterAddress) = expandAci(updaterAci)
            let (userName, userAddress) = expandAci(userAci)
            return .otherUserWasGrantedAdministratorByOtherUser(updaterName: updaterName, updaterAddress: updaterAddress, userName: userName, userAddress: userAddress)

        case .otherUserWasGrantedAdministratorByUnknownUser(let userAci):
            let (userName, userAddress) = expandAci(userAci)
            return .otherUserWasGrantedAdministratorByUnknownUser(userName: userName, userAddress: userAddress)

        case .localUserWasRevokedAdministratorByLocalUser:
            return .localUserWasRevokedAdministratorByLocalUser

        case .localUserWasRevokedAdministratorByOtherUser(let updaterAci):
            let (updaterName, updaterAddress) = expandAci(updaterAci)
            return .localUserWasRevokedAdministratorByOtherUser(updaterName: updaterName, updaterAddress: updaterAddress)

        case .localUserWasRevokedAdministratorByUnknownUser:
            return .localUserWasRevokedAdministratorByUnknownUser

        case .otherUserWasRevokedAdministratorByLocalUser(let userAci):
            let (userName, userAddress) = expandAci(userAci)
            return .otherUserWasRevokedAdministratorByLocalUser(userName: userName, userAddress: userAddress)

        case .otherUserWasRevokedAdministratorByOtherUser(let updaterAci, let userAci):
            let (updaterName, updaterAddress) = expandAci(updaterAci)
            let (userName, userAddress) = expandAci(userAci)
            return .otherUserWasRevokedAdministratorByOtherUser(updaterName: updaterName, updaterAddress: updaterAddress, userName: userName, userAddress: userAddress)

        case .otherUserWasRevokedAdministratorByUnknownUser(let userAci):
            let (userName, userAddress) = expandAci(userAci)
            return .otherUserWasRevokedAdministratorByUnknownUser(userName: userName, userAddress: userAddress)

        case .localUserLeft:
            return .localUserLeft

        case .localUserRemoved(let removerAci):
            let (removerName, removerAddress) = expandAci(removerAci)
            return .localUserRemoved(removerName: removerName, removerAddress: removerAddress)

        case .localUserRemovedByUnknownUser:
            return .localUserRemovedByUnknownUser

        case .otherUserLeft(let userAci):
            let (userName, userAddress) = expandAci(userAci)
            return .otherUserLeft(userName: userName, userAddress: userAddress)

        case .otherUserRemovedByLocalUser(let userAci):
            let (userName, userAddress) = expandAci(userAci)
            return .otherUserRemovedByLocalUser(userName: userName, userAddress: userAddress)

        case .otherUserRemoved(let removerAci, let userAci):
            let (removerName, removerAddress) = expandAci(removerAci)
            let (userName, userAddress) = expandAci(userAci)
            return .otherUserRemoved(removerName: removerName, removerAddress: removerAddress, userName: userName, userAddress: userAddress)

        case .otherUserRemovedByUnknownUser(let userAci):
            let (userName, userAddress) = expandAci(userAci)
            return .otherUserRemovedByUnknownUser(
                userName: userName,
                userAddress: userAddress,
            )

        case .localUserWasInvitedByLocalUser:
            return .localUserWasInvitedByLocalUser

        case .localUserWasInvitedByOtherUser(let updaterAci):
            let (updaterName, updaterAddress) = expandAci(updaterAci)
            return .localUserWasInvitedByOtherUser(updaterName: updaterName, updaterAddress: updaterAddress)

        case .localUserWasInvitedByUnknownUser:
            return .localUserWasInvitedByUnknownUser

        case .otherUserWasInvitedByLocalUser(let invitee):
            let inviteeAddress = SignalServiceAddress(invitee.wrappedValue)
            let inviteeName = contactsManager.displayNameString(
                for: inviteeAddress,
                transaction: tx,
            )
            return .otherUserWasInvitedByLocalUser(
                userName: inviteeName,
                userAddress: inviteeAddress,
            )

        case .unnamedUsersWereInvitedByLocalUser(let count):
            return .unnamedUsersWereInvitedByLocalUser(count: count)

        case .unnamedUsersWereInvitedByOtherUser(let updaterAci, let count):
            let (updaterName, updaterAddress) = expandAci(updaterAci)
            return .unnamedUsersWereInvitedByOtherUser(updaterName: updaterName, updaterAddress: updaterAddress, count: count)

        case .unnamedUsersWereInvitedByUnknownUser(let count):
            return .unnamedUsersWereInvitedByUnknownUser(count: count)

        case .localUserAcceptedInviteFromInviter(let inviterAci):
            let (inviterName, inviterAddress) = expandAci(inviterAci)
            return .localUserAcceptedInviteFromInviter(inviterName: inviterName, inviterAddress: inviterAddress)

        case .localUserAcceptedInviteFromUnknownUser:
            return .localUserAcceptedInviteFromUnknownUser

        case .otherUserAcceptedInviteFromLocalUser(let userAci):
            let (userName, userAddress) = expandAci(userAci)
            return .otherUserAcceptedInviteFromLocalUser(userName: userName, userAddress: userAddress)

        case .otherUserAcceptedInviteFromInviter(let userAci, let inviterAci):
            let (userName, userAddress) = expandAci(userAci)
            let (inviterName, inviterAddress) = expandAci(inviterAci)
            return .otherUserAcceptedInviteFromInviter(userName: userName, userAddress: userAddress, inviterName: inviterName, inviterAddress: inviterAddress)

        case .otherUserAcceptedInviteFromUnknownUser(let userAci):
            let (userName, userAddress) = expandAci(userAci)
            return .otherUserAcceptedInviteFromUnknownUser(userName: userName, userAddress: userAddress)

        case .localUserJoined:
            return .localUserJoined

        case .otherUserJoined(let userAci):
            let (userName, userAddress) = expandAci(userAci)
            return .otherUserJoined(userName: userName, userAddress: userAddress)

        case .localUserAddedByLocalUser:
            return .localUserAddedByLocalUser

        case .localUserAddedByOtherUser(let updaterAci):
            let (updaterName, updaterAddress) = expandAci(updaterAci)
            return .localUserAddedByOtherUser(updaterName: updaterName, updaterAddress: updaterAddress)

        case .localUserAddedByUnknownUser:
            return .localUserAddedByUnknownUser

        case .otherUserAddedByLocalUser(let userAci):
            let (userName, userAddress) = expandAci(userAci)
            return .otherUserAddedByLocalUser(userName: userName, userAddress: userAddress)

        case .otherUserAddedByOtherUser(let updaterAci, let userAci):
            let (updaterName, updaterAddress) = expandAci(updaterAci)
            let (userName, userAddress) = expandAci(userAci)
            return .otherUserAddedByOtherUser(updaterName: updaterName, updaterAddress: updaterAddress, userName: userName, userAddress: userAddress)

        case .otherUserAddedByUnknownUser(let userAci):
            let (userName, userAddress) = expandAci(userAci)
            return .otherUserAddedByUnknownUser(userName: userName, userAddress: userAddress)

        case .localUserDeclinedInviteFromInviter(let inviterAci):
            return .localUserDeclinedInviteFromInviter(
                inviterName: contactsManager.displayNameString(
                    for: SignalServiceAddress(inviterAci.wrappedValue),
                    transaction: tx,
                ),
                inviterAddress: .init(inviterAci.wrappedValue),
            )

        case .localUserDeclinedInviteFromUnknownUser:
            return .localUserDeclinedInviteFromUnknownUser

        case .otherUserDeclinedInviteFromLocalUser(let invitee):
            return .otherUserDeclinedInviteFromLocalUser(
                userName: contactsManager.displayNameString(
                    for: SignalServiceAddress(invitee.wrappedValue),
                    transaction: tx,
                ),
                userAddress: SignalServiceAddress(invitee.wrappedValue),
            )

        case let .otherUserDeclinedInviteFromInviter(_, inviterAci),
             let .unnamedUserDeclinedInviteFromInviter(inviterAci):
            return .otherUserDeclinedInviteFromInviter(
                inviterName: contactsManager.displayNameString(
                    for: SignalServiceAddress(inviterAci.wrappedValue),
                    transaction: tx,
                ),
                inviterAddress: .init(inviterAci.wrappedValue),
            )

        case .otherUserDeclinedInviteFromUnknownUser,
             .unnamedUserDeclinedInviteFromUnknownUser:
            return .otherUserDeclinedInviteFromUnknownUser

        case .localUserInviteRevoked(let revokerAci):
            return .localUserInviteRevoked(
                revokerName: contactsManager.displayNameString(
                    for: SignalServiceAddress(revokerAci.wrappedValue),
                    transaction: tx,
                ),
                revokerAddress: .init(revokerAci.wrappedValue),
            )

        case .localUserInviteRevokedByUnknownUser:
            return .localUserInviteRevokedByUnknownUser

        case .otherUserInviteRevokedByLocalUser(let invitee):
            return .otherUserInviteRevokedByLocalUser(
                userName: contactsManager.displayNameString(
                    for: SignalServiceAddress(invitee.wrappedValue),
                    transaction: tx,
                ),
                userAddress: .init(invitee.wrappedValue),
            )

        case .unnamedUserInvitesWereRevokedByLocalUser(let count):
            return .unnamedUserInvitesWereRevokedByLocalUser(count: count)

        case let .unnamedUserInvitesWereRevokedByOtherUser(updaterAci, count):
            return .unnamedUserInvitesWereRevokedByOtherUser(
                updaterName: contactsManager.displayNameString(
                    for: SignalServiceAddress(updaterAci.wrappedValue),
                    transaction: tx,
                ),
                updaterAddress: .init(updaterAci.wrappedValue),
                count: count,
            )

        case .unnamedUserInvitesWereRevokedByUnknownUser(let count):
            return .unnamedUserInvitesWereRevokedByUnknownUser(count: count)

        case .localUserRequestedToJoin:
            return .localUserRequestedToJoin

        case .otherUserRequestedToJoin(let userAci):
            let (userName, userAddress) = expandAci(userAci)
            return .otherUserRequestedToJoin(userName: userName, userAddress: userAddress)

        case .localUserRequestApproved(let approverAci):
            let (approverName, approverAddress) = expandAci(approverAci)
            return .localUserRequestApproved(approverName: approverName, approverAddress: approverAddress)

        case .localUserRequestApprovedByUnknownUser:
            return .localUserRequestApprovedByUnknownUser

        case .otherUserRequestApprovedByLocalUser(let userAci):
            let (userName, userAddress) = expandAci(userAci)
            return .otherUserRequestApprovedByLocalUser(userName: userName, userAddress: userAddress)

        case .otherUserRequestApproved(let userAci, let approverAci):
            let (userName, userAddress) = expandAci(userAci)
            let (approverName, approverAddress) = expandAci(approverAci)
            return .otherUserRequestApproved(userName: userName, userAddress: userAddress, approverName: approverName, approverAddress: approverAddress)

        case .otherUserRequestApprovedByUnknownUser(let userAci):
            let (userName, userAddress) = expandAci(userAci)
            return .otherUserRequestApprovedByUnknownUser(
                userName: userName,
                userAddress: userAddress,
            )

        case .localUserRequestCanceledByLocalUser:
            return .localUserRequestCanceledByLocalUser

        case .localUserRequestRejectedByUnknownUser:
            return .localUserRequestRejectedByUnknownUser

        case .otherUserRequestRejectedByLocalUser(let requesterAci):
            let (requesterName, requesterAddress) = expandAci(requesterAci)
            return .otherUserRequestRejectedByLocalUser(requesterName: requesterName, requesterAddress: requesterAddress)

        case .otherUserRequestRejectedByOtherUser(let updaterAci, let requesterAci):
            let (updaterName, updaterAddress) = expandAci(updaterAci)
            let (requesterName, requesterAddress) = expandAci(requesterAci)
            return .otherUserRequestRejectedByOtherUser(updaterName: updaterName, updaterAddress: updaterAddress, requesterName: requesterName, requesterAddress: requesterAddress)

        case .otherUserRequestCanceledByOtherUser(let requesterAci):
            let (requesterName, requesterAddress) = expandAci(requesterAci)
            return .otherUserRequestCanceledByOtherUser(requesterName: requesterName, requesterAddress: requesterAddress)

        case .otherUserRequestRejectedByUnknownUser(let requesterAci):
            let (requesterName, requesterAddress) = expandAci(requesterAci)
            return .otherUserRequestRejectedByUnknownUser(requesterName: requesterName, requesterAddress: requesterAddress)

        case .disappearingMessagesEnabledByLocalUser(let durationMs):
            return .disappearingMessagesEnabledByLocalUser(durationMs: durationMs)

        case .disappearingMessagesEnabledByOtherUser(let updaterAci, let durationMs):
            let (updaterName, updaterAddress) = expandAci(updaterAci)
            return .disappearingMessagesEnabledByOtherUser(updaterName: updaterName, updaterAddress: updaterAddress, durationMs: durationMs)

        case .disappearingMessagesEnabledByUnknownUser(let durationMs):
            return .disappearingMessagesEnabledByUnknownUser(durationMs: durationMs)

        case .disappearingMessagesDisabledByLocalUser:
            return .disappearingMessagesDisabledByLocalUser

        case .disappearingMessagesDisabledByOtherUser(let updaterAci):
            let (updaterName, updaterAddress) = expandAci(updaterAci)
            return .disappearingMessagesDisabledByOtherUser(updaterName: updaterName, updaterAddress: updaterAddress)

        case .disappearingMessagesDisabledByUnknownUser:
            return .disappearingMessagesDisabledByUnknownUser

        case .inviteLinkResetByLocalUser:
            return .inviteLinkResetByLocalUser

        case .inviteLinkResetByOtherUser(let updaterAci):
            let (updaterName, updaterAddress) = expandAci(updaterAci)
            return .inviteLinkResetByOtherUser(updaterName: updaterName, updaterAddress: updaterAddress)

        case .inviteLinkResetByUnknownUser:
            return .inviteLinkResetByUnknownUser

        case .inviteLinkEnabledWithoutApprovalByLocalUser:
            return .inviteLinkEnabledWithoutApprovalByLocalUser

        case .inviteLinkEnabledWithoutApprovalByOtherUser(let updaterAci):
            let (updaterName, updaterAddress) = expandAci(updaterAci)
            return .inviteLinkEnabledWithoutApprovalByOtherUser(updaterName: updaterName, updaterAddress: updaterAddress)

        case .inviteLinkEnabledWithoutApprovalByUnknownUser:
            return .inviteLinkEnabledWithoutApprovalByUnknownUser

        case .inviteLinkEnabledWithApprovalByLocalUser:
            return .inviteLinkEnabledWithApprovalByLocalUser

        case .inviteLinkEnabledWithApprovalByOtherUser(let updaterAci):
            let (updaterName, updaterAddress) = expandAci(updaterAci)
            return .inviteLinkEnabledWithApprovalByOtherUser(updaterName: updaterName, updaterAddress: updaterAddress)

        case .inviteLinkEnabledWithApprovalByUnknownUser:
            return .inviteLinkEnabledWithApprovalByUnknownUser

        case .inviteLinkDisabledByLocalUser:
            return .inviteLinkDisabledByLocalUser

        case .inviteLinkDisabledByOtherUser(let updaterAci):
            let (updaterName, updaterAddress) = expandAci(updaterAci)
            return .inviteLinkDisabledByOtherUser(updaterName: updaterName, updaterAddress: updaterAddress)

        case .inviteLinkDisabledByUnknownUser:
            return .inviteLinkDisabledByUnknownUser

        case .inviteLinkApprovalDisabledByLocalUser:
            return .inviteLinkApprovalDisabledByLocalUser

        case .inviteLinkApprovalDisabledByOtherUser(let updaterAci):
            let (updaterName, updaterAddress) = expandAci(updaterAci)
            return .inviteLinkApprovalDisabledByOtherUser(updaterName: updaterName, updaterAddress: updaterAddress)

        case .inviteLinkApprovalDisabledByUnknownUser:
            return .inviteLinkApprovalDisabledByUnknownUser

        case .inviteLinkApprovalEnabledByLocalUser:
            return .inviteLinkApprovalEnabledByLocalUser

        case .inviteLinkApprovalEnabledByOtherUser(let updaterAci):
            let (updaterName, updaterAddress) = expandAci(updaterAci)
            return .inviteLinkApprovalEnabledByOtherUser(updaterName: updaterName, updaterAddress: updaterAddress)

        case .inviteLinkApprovalEnabledByUnknownUser:
            return .inviteLinkApprovalEnabledByUnknownUser

        case .localUserJoinedViaInviteLink:
            return .localUserJoinedViaInviteLink

        case .otherUserJoinedViaInviteLink(let userAci):
            let (userName, userAddress) = expandAci(userAci)
            return .otherUserJoinedViaInviteLink(userName: userName, userAddress: userAddress)

        case .groupTerminatedByLocalUser:
            return .groupTerminatedByLocalUser

        case .groupTerminatedByOtherUser(let updaterAci):
            let (updaterName, updaterAddress) = expandAci(updaterAci)
            return .groupTerminatedByOtherUser(updaterName: updaterName, updaterAddress: updaterAddress)

        case .groupTerminatedByUnknownUser:
            return .groupTerminatedByUnknownUser
        }
    }

    private func sequenceOfInviteLinkRequestAndCancelsItem(
        requesterAci: Aci,
        count: UInt,
        isTail: Bool,
        tx: DBReadTransaction,
    ) -> DisplayableGroupUpdateItem {
        let updaterAddress = SignalServiceAddress(requesterAci)
        let updaterName = contactsManager.displayNameString(for: updaterAddress, transaction: tx)

        guard count > 0 else {
            // We haven't actually collapsed anything, so we should fall back to
            // the regular ol' "user requested to join".
            return .otherUserRequestedToJoin(
                userName: updaterName,
                userAddress: updaterAddress,
            )
        }

        return .sequenceOfInviteLinkRequestAndCancels(
            userName: updaterName,
            userAddress: updaterAddress,
            count: count,
            isTail: isTail,
        )
    }
}

// MARK: -

/// Generates displayable update items about the fact that a group was created.
///
/// Historically, when a group was updated we persisted a "before" and "after"
/// group model. Then, at display-time, we would "diff" those models to find out
/// what changed. When a group was first created it only had an "after" model,
/// which is where this struct comes into play.
///
/// All new group updates are now "precomputed" when we learn about an update
/// and persisted – including for new group creation. Consequently, this struct
/// should only deal with legacy updates, and all new updates should go through
/// ``PrecomputedGroupUpdateItemBuilder``.
private struct NewGroupUpdateItemBuilder {

    typealias PersistableGroupUpdateItem = TSInfoMessage.PersistableGroupUpdateItem

    private let contactsManager: ContactManager

    init(contactsManager: ContactManager) {
        self.contactsManager = contactsManager
    }

    func buildGroupUpdateItems(
        newGroupModel: TSGroupModel,
        newDisappearingMessageToken: DisappearingMessageToken?,
        groupUpdateSource: GroupUpdateSource,
        localIdentifiers: LocalIdentifiers,
    ) -> [PersistableGroupUpdateItem] {
        var items = [PersistableGroupUpdateItem]()

        // We're just learning of the group.
        let groupInsertedUpdateItems = groupInsertedUpdateItems(
            groupUpdateSource: groupUpdateSource,
            localIdentifiers: localIdentifiers,
            newGroupModel: newGroupModel,
            newGroupMembership: newGroupModel.groupMembership,
        )

        items.append(contentsOf: groupInsertedUpdateItems)

        // Skip update items for things like name, avatar, current members. Do
        // add update items for the current disappearing messages state if we have one.
        // We can use unknown attribution here – either we created the group (so it was
        // us who set the time) or someone else did (so we don't know who set
        // the timer), and unknown attribution is always safe.
        if newDisappearingMessageToken?.isEnabled == true {
            DiffingGroupUpdateItemBuilder.disappearingMessageUpdateItem(
                groupUpdateSource: groupUpdateSource,
                oldToken: nil,
                newToken: newDisappearingMessageToken,
                forceUnknownAttribution: true,
            ).map { items.append($0) }
        }

        if items.contains(where: { if case .createdByLocalUser = $0 { return true }; return false }) {
            // If we just created the group, add an update item to let users
            // know about the group link.
            items.append(.inviteFriendsToNewlyCreatedGroup)
        }

        return items
    }

    private func groupInsertedUpdateItems(
        groupUpdateSource: GroupUpdateSource,
        localIdentifiers: LocalIdentifiers,
        newGroupModel: TSGroupModel,
        newGroupMembership: GroupMembership,
    ) -> [PersistableGroupUpdateItem] {
        guard let newGroupModel = newGroupModel as? TSGroupModelV2 else {
            // This is a V1 group. While we may be able to be more specific, we
            // shouldn't stress over V1 group update messages.
            return [.createdByUnknownUser]
        }

        let inviteItem = groupInviteItems(
            groupUpdateSource: groupUpdateSource,
            localIdentifiers: localIdentifiers,
            newGroupModel: newGroupModel,
            newGroupMembership: newGroupMembership,
        )

        let createItem: PersistableGroupUpdateItem? = groupCreationUpdateItems(
            groupUpdateSource: groupUpdateSource,
            newGroupModel: newGroupModel,
        )

        return [createItem, inviteItem].compacted()
    }

    private func groupInviteItems(
        groupUpdateSource: GroupUpdateSource,
        localIdentifiers: LocalIdentifiers,
        newGroupModel: TSGroupModelV2,
        newGroupMembership: GroupMembership,
    ) -> PersistableGroupUpdateItem? {
        let localMembershipStatus: MembershipStatus
        if
            let aciMembership: MembershipStatus = .of(
                address: SignalServiceAddress(localIdentifiers.aci),
                groupMembership: newGroupMembership,
            )
        {
            localMembershipStatus = aciMembership
        } else if
            let localPni = localIdentifiers.pni,
            let pniMembership: MembershipStatus = .of(
                address: SignalServiceAddress(localPni),
                groupMembership: newGroupMembership,
            )
        {
            localMembershipStatus = pniMembership
        } else {
            return nil
        }

        switch localMembershipStatus {
        case .normalMember:
            switch groupUpdateSource {
            case let .aci(updaterAci):
                return .localUserAddedByOtherUser(
                    updaterAci: updaterAci.codableUuid,
                )
            case .localUser:
                if newGroupModel.didJustAddSelfViaGroupLink || newGroupMembership.didJoinFromInviteLink(forFullMember: localIdentifiers.aciAddress) {
                    return .localUserJoinedViaInviteLink
                } else {
                    // Displaying a message like "You added yourself to the group" isn't useful, so skip it.
                    return nil
                }
            default:
                return .localUserAddedByUnknownUser
            }
        case .invited(_, _, let inviterAci):
            if let inviterAci {
                return .localUserWasInvitedByOtherUser(
                    updaterAci: inviterAci.codableUuid,
                )
            } else {
                return .localUserWasInvitedByUnknownUser
            }
        case .requesting:
            return .localUserRequestedToJoin
        }
    }

    private func groupCreationUpdateItems(
        groupUpdateSource: GroupUpdateSource,
        newGroupModel: TSGroupModelV2,
    ) -> PersistableGroupUpdateItem? {
        let wasGroupJustCreated = newGroupModel.revision == 0
        if wasGroupJustCreated {
            switch groupUpdateSource {
            case .localUser:
                return .createdByLocalUser
            case .aci, .rejectedInviteToPni, .legacyE164, .unknown:
                // Don't show when others created the group,
                // just when the local user does.
                return nil
            }
        }
        return nil
    }
}

// MARK: -

/// Generates displayable update items about the fact that a group was created.
///
/// Historically, when a group was updated we persisted a "before" and "after"
/// group model. Then, at display-time, we would "diff" those models to find out
/// what changed. This struct handles that "diffing".
///
/// All new group updates are now "precomputed" when we learn about an update
/// and persisted, and do not need display-time diffing. Consequently, this
/// struct deal with displaying legacy updates, and displaying any new updates
/// should go through ``PrecomputedGroupUpdateItemBuilder``.
///
/// - Note
/// At the time of writing, this struct's "diffing" approach is used during the
/// "precomputation" step referenced above, to maximize compatibility with
/// existing code.
///
/// - Note
/// Historically, group update items were computed using a struct that populated
/// itself with update items during initialization. Rather than refactor many,
/// many call sites to pass through the historically stored-as-properties values
/// used by that computation, we preserve that pattern here.
private struct DiffingGroupUpdateItemBuilder {
    typealias PersistableGroupUpdateItem = TSInfoMessage.PersistableGroupUpdateItem

    /// - Important
    /// The PNI in `localIdentifiers` represents the user's *current* PNI, but
    /// PNIs can change. This can result in inaccurate comparisons when
    /// comparing against a PNI in an old group model; all we can say is whether
    /// that PNI *currently* matches ours, not whether it matched ours at the
    /// time the group model was created/persisted.
    private let localIdentifiers: LocalIdentifiers
    private let groupUpdateSource: GroupUpdateSource
    private let isReplacingJoinRequestPlaceholder: Bool

    /// The update items, in order.
    private(set) var itemList = [PersistableGroupUpdateItem]()

    /// Create a ``GroupUpdateCopy``.
    ///
    /// - Parameter groupUpdateSource
    /// The address to whom this update should be attributed, if known.
    init(
        oldGroupModel: TSGroupModel,
        newGroupModel: TSGroupModel,
        oldDisappearingMessageToken: DisappearingMessageToken?,
        newDisappearingMessageToken: DisappearingMessageToken?,
        groupUpdateSource: GroupUpdateSource,
        localIdentifiers: LocalIdentifiers,
    ) {
        self.localIdentifiers = localIdentifiers
        self.groupUpdateSource = groupUpdateSource

        if let oldGroupModelV2 = oldGroupModel as? TSGroupModelV2 {
            self.isReplacingJoinRequestPlaceholder = oldGroupModelV2.isJoinRequestPlaceholder
        } else {
            self.isReplacingJoinRequestPlaceholder = false
        }

        populate(
            oldGroupModel: oldGroupModel,
            newGroupModel: newGroupModel,
            oldDisappearingMessageToken: oldDisappearingMessageToken,
            newDisappearingMessageToken: newDisappearingMessageToken,
            groupUpdateSource: groupUpdateSource,
        )

        switch groupUpdateSource {
        case .unknown:
            Logger.warn("Missing updater info!")
        default:
            break
        }
    }

    private mutating func addItem(_ item: PersistableGroupUpdateItem) {
        itemList.append(item)
    }

    // MARK: Population

    /// Populate this builder's list of update items, by diffing the provided
    /// values.
    mutating func populate(
        oldGroupModel: TSGroupModel,
        newGroupModel: TSGroupModel,
        oldDisappearingMessageToken: DisappearingMessageToken?,
        newDisappearingMessageToken: DisappearingMessageToken?,
        groupUpdateSource: GroupUpdateSource,
    ) {
        if isReplacingJoinRequestPlaceholder {
            addMembershipUpdates(
                oldGroupMembership: oldGroupModel.groupMembership,
                newGroupMembership: newGroupModel.groupMembership,
                newGroupModel: newGroupModel,
                groupUpdateSource: groupUpdateSource,
                forLocalUserOnly: true,
            )

            addDisappearingMessageUpdates(
                oldToken: oldDisappearingMessageToken,
                newToken: newDisappearingMessageToken,
            )
        } else if newGroupModel.wasJustMigratedToV2 {
            addMigrationUpdates(
                oldGroupMembership: oldGroupModel.groupMembership,
                newGroupMembership: newGroupModel.groupMembership,
                newGroupModel: newGroupModel,
            )
        } else {
            addMembershipUpdates(
                oldGroupMembership: oldGroupModel.groupMembership,
                newGroupMembership: newGroupModel.groupMembership,
                newGroupModel: newGroupModel,
                groupUpdateSource: groupUpdateSource,
                forLocalUserOnly: false,
            )

            addAttributesUpdates(
                oldGroupModel: oldGroupModel,
                newGroupModel: newGroupModel,
            )

            addAccessUpdates(
                oldGroupModel: oldGroupModel,
                newGroupModel: newGroupModel,
            )

            addDisappearingMessageUpdates(
                oldToken: oldDisappearingMessageToken,
                newToken: newDisappearingMessageToken,
            )

            addGroupInviteLinkUpdates(
                oldGroupModel: oldGroupModel,
                newGroupModel: newGroupModel,
            )

            addIsAnnouncementOnlyLinkUpdates(
                oldGroupModel: oldGroupModel,
                newGroupModel: newGroupModel,
            )

            addGroupTerminatedUpdate(
                oldGroupModel: oldGroupModel,
                newGroupModel: newGroupModel,
            )
        }
    }

    // MARK: Attributes

    mutating func addAttributesUpdates(
        oldGroupModel: TSGroupModel,
        newGroupModel: TSGroupModel,
    ) {
        let groupName = { (groupModel: TSGroupModel) -> String? in
            groupModel.groupName?.stripped.nilIfEmpty
        }

        let oldGroupName = groupName(oldGroupModel)
        let newGroupName = groupName(newGroupModel)

        if oldGroupName != newGroupName {
            if let name = newGroupName {
                switch groupUpdateSource {
                case .localUser:
                    addItem(.nameChangedByLocalUser(newGroupName: name))
                case let .aci(updaterAci):
                    addItem(.nameChangedByOtherUser(
                        updaterAci: updaterAci.codableUuid,
                        newGroupName: name,
                    ))
                case .rejectedInviteToPni, .legacyE164, .unknown:
                    addItem(.nameChangedByUnknownUser(newGroupName: name))
                }
            } else {
                switch groupUpdateSource {
                case .localUser:
                    addItem(.nameRemovedByLocalUser)
                case let .aci(updaterAci):
                    addItem(.nameRemovedByOtherUser(updaterAci: updaterAci.codableUuid))
                case .rejectedInviteToPni, .legacyE164, .unknown:
                    addItem(.nameRemovedByUnknownUser)
                }
            }
        }

        if oldGroupModel.avatarHash != newGroupModel.avatarHash {
            if !newGroupModel.avatarHash.isEmptyOrNil {
                switch groupUpdateSource {
                case .localUser:
                    addItem(.avatarChangedByLocalUser)
                case let .aci(updaterAci):
                    addItem(.avatarChangedByOtherUser(
                        updaterAci: updaterAci.codableUuid,
                    ))
                case .rejectedInviteToPni, .legacyE164, .unknown:
                    addItem(.avatarChangedByUnknownUser)
                }
            } else {
                switch groupUpdateSource {
                case .localUser:
                    addItem(.avatarRemovedByLocalUser)
                case let .aci(updaterAci):
                    addItem(.avatarRemovedByOtherUser(
                        updaterAci: updaterAci.codableUuid,
                    ))
                case .rejectedInviteToPni, .legacyE164, .unknown:
                    addItem(.avatarRemovedByUnknownUser)
                }
            }
        }

        guard
            let oldGroupModel = oldGroupModel as? TSGroupModelV2,
            let newGroupModel = newGroupModel as? TSGroupModelV2 else { return }

        let groupDescription = { (groupModel: TSGroupModelV2) -> String? in
            return groupModel.descriptionText?.stripped.nilIfEmpty
        }
        let oldGroupDescription = groupDescription(oldGroupModel)
        let newGroupDescription = groupDescription(newGroupModel)
        if oldGroupDescription != newGroupDescription {
            if let newGroupDescription {
                switch groupUpdateSource {
                case .localUser:
                    addItem(.descriptionChangedByLocalUser(newDescription: newGroupDescription))
                case let .aci(updaterAci):
                    addItem(.descriptionChangedByOtherUser(
                        updaterAci: updaterAci.codableUuid,
                        newDescription: newGroupDescription,
                    ))
                case .rejectedInviteToPni, .legacyE164, .unknown:
                    addItem(.descriptionChangedByUnknownUser(newDescription: newGroupDescription))
                }
            } else {
                switch groupUpdateSource {
                case .localUser:
                    addItem(.descriptionRemovedByLocalUser)
                case let .aci(updaterAci):
                    addItem(.descriptionRemovedByOtherUser(
                        updaterAci: updaterAci.codableUuid,
                    ))
                case .rejectedInviteToPni, .legacyE164, .unknown:
                    addItem(.descriptionRemovedByUnknownUser)
                }
            }
        }
    }

    // MARK: Access

    mutating func addAccessUpdates(
        oldGroupModel: TSGroupModel,
        newGroupModel: TSGroupModel,
    ) {
        guard let oldGroupModel = oldGroupModel as? TSGroupModelV2 else {
            return
        }
        guard let newGroupModel = newGroupModel as? TSGroupModelV2 else {
            owsFailDebug("Invalid group model.")
            return
        }

        let oldAccess = oldGroupModel.access
        let newAccess = newGroupModel.access

        if oldAccess.members != newAccess.members {
            switch groupUpdateSource {
            case .localUser:
                addItem(.membersAccessChangedByLocalUser(newAccess: newAccess.members))
            case let .aci(updaterAci):
                addItem(.membersAccessChangedByOtherUser(
                    updaterAci: updaterAci.codableUuid,
                    newAccess: newAccess.members,
                ))
            case .rejectedInviteToPni, .legacyE164, .unknown:
                addItem(.membersAccessChangedByUnknownUser(newAccess: newAccess.members))
            }
        }

        if oldAccess.attributes != newAccess.attributes {
            switch groupUpdateSource {
            case .localUser:
                addItem(.attributesAccessChangedByLocalUser(newAccess: newAccess.attributes))
            case let .aci(updaterAci):
                addItem(.attributesAccessChangedByOtherUser(
                    updaterAci: updaterAci.codableUuid,
                    newAccess: newAccess.attributes,
                ))
            case .rejectedInviteToPni, .legacyE164, .unknown:
                addItem(.attributesAccessChangedByUnknownUser(newAccess: newAccess.attributes))
            }
        }

        if oldAccess.memberLabels != newAccess.memberLabels {
            switch groupUpdateSource {
            case .localUser:
                addItem(.memberLabelsAccessChangedByLocalUser(newAccess: newAccess.memberLabels))
            case let .aci(updaterAci):
                addItem(.memberLabelsAccessChangedByOtherUser(
                    updaterAci: updaterAci.codableUuid,
                    newAccess: newAccess.memberLabels,
                ))
            case .rejectedInviteToPni, .legacyE164, .unknown:
                addItem(.memberLabelsAccessChangedByUnknownUser(newAccess: newAccess.memberLabels))
            }
        }
    }

    // MARK: Membership

    mutating func addMembershipUpdates(
        oldGroupMembership: GroupMembership,
        newGroupMembership: GroupMembership,
        newGroupModel: TSGroupModel,
        groupUpdateSource: GroupUpdateSource,
        forLocalUserOnly: Bool,
    ) {
        var unnamedInviteCounts = UnnamedInviteCounts()

        struct MembershipChange {
            let serviceId: ServiceId
            let oldMembership: MembershipStatus?
            let newMembership: MembershipStatus?

            init?(
                address: SignalServiceAddress,
                oldGroupMembership: GroupMembership,
                newGroupMembership: GroupMembership,
            ) {
                guard let serviceId = address.serviceId else {
                    // No membership change if no serviceId.
                    return nil
                }

                let before: MembershipStatus? = .of(address: address, groupMembership: oldGroupMembership)
                let after: MembershipStatus? = .of(address: address, groupMembership: newGroupMembership)

                guard before != after else {
                    // Nothing changed!
                    return nil
                }

                self.serviceId = serviceId
                self.oldMembership = before
                self.newMembership = after
            }
        }
        var membershipChanges: [MembershipChange] = []

        if forLocalUserOnly {
            if
                let aciMembershipChange = MembershipChange(
                    address: SignalServiceAddress(localIdentifiers.aci),
                    oldGroupMembership: oldGroupMembership,
                    newGroupMembership: newGroupMembership,
                )
            {
                membershipChanges.append(aciMembershipChange)
            }

            if
                let localPni = localIdentifiers.pni,
                let pniMembershipChange = MembershipChange(
                    address: SignalServiceAddress(localPni),
                    oldGroupMembership: oldGroupMembership,
                    newGroupMembership: newGroupMembership,
                )
            {
                membershipChanges.append(pniMembershipChange)
            }
        } else {
            let allMembers = oldGroupMembership.allMembersOfAnyKind.union(newGroupMembership.allMembersOfAnyKind)

            membershipChanges = allMembers.compactMap { address in
                return MembershipChange(
                    address: address,
                    oldGroupMembership: oldGroupMembership,
                    newGroupMembership: newGroupMembership,
                )
            }
        }

        // We want to sort updates about the updater to the end of the updates
        // list, so get their service ID.
        let updaterServiceId: ServiceId?
        switch groupUpdateSource {
        case .unknown:
            updaterServiceId = nil
        case .legacyE164:
            updaterServiceId = nil
        case .aci(let aci):
            updaterServiceId = aci
        case .rejectedInviteToPni(let pni):
            updaterServiceId = pni
        case .localUser:
            // We're going to sort local-user updates to the front, so we can
            // ignore them for the purposes of the updater.
            updaterServiceId = nil
        }

        // Sort such that the eventual updates are always added to this builder
        // in a stable order.
        membershipChanges.sort { lhs, rhs in
            // If equal to the updater, sort to the back.
            if let updaterServiceId, lhs.serviceId == updaterServiceId {
                return false
            } else if let updaterServiceId, rhs.serviceId == updaterServiceId {
                return true
            }

            // If we find our own ServiceId, sort it to the front, preferring
            // our ACI over our PNI.
            if lhs.serviceId == localIdentifiers.aci {
                return true
            } else if rhs.serviceId == localIdentifiers.aci {
                return false
            } else if lhs.serviceId == localIdentifiers.pni {
                return true
            } else if rhs.serviceId == localIdentifiers.pni {
                return false
            }

            // Otherwise, arbitrary stable sort.
            return lhs.serviceId < rhs.serviceId
        }

        for membershipChange in membershipChanges {

            switch membershipChange.oldMembership {
            case .normalMember(let memberAci, let roleBefore):
                switch membershipChange.newMembership {
                case .normalMember(_, let roleAfter):
                    // Membership status didn't change.
                    // Check for role changes.
                    addMemberRoleUpdates(
                        userAci: memberAci,
                        oldRole: roleBefore,
                        newRole: roleAfter,
                        newGroupModel: newGroupModel,
                    )
                case .invited:
                    addUserLeftOrWasKickedOutOfGroupThenWasInvitedToTheGroup(
                        userAci: memberAci,
                    )
                case .requesting:
                    // This could happen if a user leaves a group, the requests to rejoin
                    // and we do not have access to the intervening revisions.
                    addUserRequestedToJoinGroup(requesterAci: memberAci)
                case nil:
                    addUserLeftOrWasKickedOutOfGroup(userAci: memberAci)
                }

            case .invited(let inviteeServiceId, _, let inviterAci):
                switch membershipChange.newMembership {
                case .normalMember(let inviteeAci, _):
                    // Note that invited-by-PNI, accepted-by-ACI is handled
                    // specially elsewhere.
                    addUserInvitedByAciAcceptedOrWasAdded(
                        inviteeAci: inviteeAci,
                        inviterAci: inviterAci,
                    )
                case .invited:
                    // Membership status didn't change.
                    break
                case .requesting(let inviteeAci):
                    addUserRequestedToJoinGroup(requesterAci: inviteeAci)
                case .none:
                    addUserInviteWasDeclinedOrRevoked(
                        inviteeServiceId: inviteeServiceId,
                        inviterAci: inviterAci,
                        unnamedInviteCounts: &unnamedInviteCounts,
                    )
                }

            case .requesting(let requesterAci):
                switch membershipChange.newMembership {
                case .normalMember:
                    if newGroupMembership.didJoinFromAcceptedJoinRequest(forFullMember: SignalServiceAddress(requesterAci)) {
                        addUserRequestWasApproved(
                            requesterAci: requesterAci,
                        )
                    } else {
                        addUserWasAddedToTheGroup(
                            newMember: requesterAci,
                            newGroupModel: newGroupModel,
                        )
                    }
                case .invited:
                    addUserWasInvitedToTheGroup(
                        invitee: requesterAci,
                        unnamedInviteCounts: &unnamedInviteCounts,
                    )
                case .requesting:
                    // Membership status didn't change.
                    break
                case nil:
                    addUserRequestWasRejected(requesterAci: requesterAci)
                }

            case nil:
                switch membershipChange.newMembership {
                case .normalMember(let memberAci, _):
                    if newGroupMembership.didJoinFromInviteLink(forFullMember: SignalServiceAddress(memberAci)) {
                        addUserJoinedFromInviteLink(newMember: memberAci)
                    } else if newGroupMembership.didJoinFromAcceptedJoinRequest(forFullMember: SignalServiceAddress(memberAci)) {
                        addUserRequestWasApproved(
                            requesterAci: memberAci,
                        )
                    } else {
                        addUserWasAddedToTheGroup(
                            newMember: memberAci,
                            newGroupModel: newGroupModel,
                        )
                    }
                case .invited(let inviteeServiceId, _, _):
                    addUserWasInvitedToTheGroup(
                        invitee: inviteeServiceId,
                        unnamedInviteCounts: &unnamedInviteCounts,
                    )
                case .requesting(let requesterAci):
                    addUserRequestedToJoinGroup(requesterAci: requesterAci)
                case .none:
                    // Membership status didn't change.
                    break
                }
            }
        }

        addUnnamedUsersWereInvited(count: unnamedInviteCounts.newInviteCount)
        addUnnamedUserInvitesWereRevoked(count: unnamedInviteCounts.revokedInviteCount)

        addInvalidInviteUpdates(
            oldGroupMembership: oldGroupMembership,
            newGroupMembership: newGroupMembership,
        )
    }

    /// "Invalid invites" become unnamed invites; we don't distinguish the two beyond this point.
    mutating func addInvalidInviteUpdates(
        oldGroupMembership: GroupMembership,
        newGroupMembership: GroupMembership,
    ) {
        let oldInvalidInviteUserIds = Set(oldGroupMembership.invalidInviteUserIds)
        let newInvalidInviteUserIds = Set(newGroupMembership.invalidInviteUserIds)
        let addedInvalidInviteCount = newInvalidInviteUserIds.subtracting(oldInvalidInviteUserIds).count
        let removedInvalidInviteCount = oldInvalidInviteUserIds.subtracting(newInvalidInviteUserIds).count

        if addedInvalidInviteCount > 0 {
            switch groupUpdateSource {
            case .localUser:
                addItem(.unnamedUsersWereInvitedByLocalUser(count: UInt(addedInvalidInviteCount)))
            case let .aci(updaterAci):
                addItem(.unnamedUsersWereInvitedByOtherUser(
                    updaterAci: updaterAci.codableUuid,
                    count: UInt(addedInvalidInviteCount),
                ))
            case .rejectedInviteToPni, .legacyE164, .unknown:
                addItem(.unnamedUsersWereInvitedByUnknownUser(count: UInt(addedInvalidInviteCount)))
            }
        }

        if removedInvalidInviteCount > 0 {
            switch groupUpdateSource {
            case .localUser:
                addItem(.unnamedUserInvitesWereRevokedByLocalUser(count: UInt(removedInvalidInviteCount)))
            case let .aci(updaterAci):
                addItem(.unnamedUserInvitesWereRevokedByOtherUser(
                    updaterAci: updaterAci.codableUuid,
                    count: UInt(removedInvalidInviteCount),
                ))
            case .rejectedInviteToPni, .legacyE164, .unknown:
                addItem(.unnamedUserInvitesWereRevokedByUnknownUser(count: UInt(removedInvalidInviteCount)))
            }
        }
    }

    mutating func addMemberRoleUpdates(
        userAci: Aci,
        oldRole: TSGroupMemberRole,
        newRole: TSGroupMemberRole,
        newGroupModel: TSGroupModel,
    ) {
        switch (oldRole, newRole) {
        case (.normal, .normal), (.administrator, .administrator):
            break
        case (.normal, .administrator):
            addUserWasGrantedAdministrator(
                userAci: userAci,
                newGroupModel: newGroupModel,
            )
        case (.administrator, .normal):
            addUserWasRevokedAdministrator(userAci: userAci)
        }
    }

    mutating func addUserWasGrantedAdministrator(
        userAci: Aci,
        newGroupModel: TSGroupModel,
    ) {

        if
            let newGroupModelV2 = newGroupModel as? TSGroupModelV2,
            newGroupModelV2.wasJustMigrated
        {
            // All v1 group members become admins when the
            // group is migrated to v2. We don't need to
            // surface this to the user.
            return
        }

        if localIdentifiers.aci == userAci {
            switch groupUpdateSource {
            case .localUser:
                owsFailDebug("Local user made themselves administrator!")
                addItem(.localUserWasGrantedAdministratorByLocalUser)
            case let .aci(updaterAci):
                addItem(.localUserWasGrantedAdministratorByOtherUser(
                    updaterAci: updaterAci.codableUuid,
                ))
            case .rejectedInviteToPni, .legacyE164, .unknown:
                addItem(.localUserWasGrantedAdministratorByUnknownUser)
            }
        } else {
            switch groupUpdateSource {
            case .localUser:
                addItem(.otherUserWasGrantedAdministratorByLocalUser(
                    userAci: userAci.codableUuid,
                ))
            case let .aci(updaterAci):
                if updaterAci == userAci {
                    owsFailDebug("Remote user made themselves administrator!")
                    addItem(.otherUserWasGrantedAdministratorByUnknownUser(
                        userAci: userAci.codableUuid,
                    ))
                } else {
                    addItem(.otherUserWasGrantedAdministratorByOtherUser(
                        updaterAci: updaterAci.codableUuid,
                        userAci: userAci.codableUuid,
                    ))
                }
            case .rejectedInviteToPni, .legacyE164, .unknown:
                addItem(.otherUserWasGrantedAdministratorByUnknownUser(
                    userAci: userAci.codableUuid,
                ))
            }
        }
    }

    mutating func addUserWasRevokedAdministrator(
        userAci: Aci,
    ) {
        if localIdentifiers.aci == userAci {
            switch groupUpdateSource {
            case .localUser:
                addItem(.localUserWasRevokedAdministratorByLocalUser)
            case let .aci(updaterAci):
                addItem(.localUserWasRevokedAdministratorByOtherUser(
                    updaterAci: updaterAci.codableUuid,
                ))
            case .rejectedInviteToPni, .legacyE164, .unknown:
                addItem(.localUserWasRevokedAdministratorByUnknownUser)
            }
        } else {
            switch groupUpdateSource {
            case .localUser:
                addItem(.otherUserWasRevokedAdministratorByLocalUser(
                    userAci: userAci.codableUuid,
                ))
            case let .aci(updaterAci):
                if updaterAci == userAci {
                    addItem(.otherUserWasRevokedAdministratorByUnknownUser(
                        userAci: userAci.codableUuid,
                    ))
                } else {
                    addItem(.otherUserWasRevokedAdministratorByOtherUser(
                        updaterAci: updaterAci.codableUuid,
                        userAci: userAci.codableUuid,
                    ))
                }
            case .rejectedInviteToPni, .legacyE164, .unknown:
                addItem(.otherUserWasRevokedAdministratorByUnknownUser(
                    userAci: userAci.codableUuid,
                ))
            }
        }
    }

    mutating func addUserLeftOrWasKickedOutOfGroup(
        userAci: Aci,
    ) {
        if localIdentifiers.aci == userAci {
            switch groupUpdateSource {
            case .localUser:
                addItem(.localUserLeft)
            case let .aci(updaterAci):
                addItem(.localUserRemoved(
                    removerAci: updaterAci.codableUuid,
                ))
            case .rejectedInviteToPni, .legacyE164, .unknown:
                addItem(.localUserRemovedByUnknownUser)
            }
        } else {
            switch groupUpdateSource {
            case .localUser:
                addItem(.otherUserRemovedByLocalUser(
                    userAci: userAci.codableUuid,
                ))
            case let .aci(updaterAci):
                if updaterAci == userAci {
                    addItem(.otherUserLeft(userAci: userAci.codableUuid))
                } else {
                    addItem(.otherUserRemoved(
                        removerAci: updaterAci.codableUuid,
                        userAci: userAci.codableUuid,
                    ))
                }
            case .rejectedInviteToPni, .legacyE164, .unknown:
                addItem(.otherUserRemovedByUnknownUser(
                    userAci: userAci.codableUuid,
                ))
            }
        }
    }

    mutating func addUserLeftOrWasKickedOutOfGroupThenWasInvitedToTheGroup(
        userAci: Aci,
    ) {
        if localIdentifiers.aci == userAci {
            addItem(.localUserRemovedByUnknownUser)

            switch groupUpdateSource {
            case .localUser:
                owsFailDebug("User invited themselves to the group!")
                addItem(.localUserWasInvitedByLocalUser)
            case let .aci(updaterAci):
                addItem(.localUserWasInvitedByOtherUser(
                    updaterAci: updaterAci.codableUuid,
                ))
            case .rejectedInviteToPni, .legacyE164, .unknown:
                addItem(.localUserWasInvitedByUnknownUser)
            }
        } else {
            addItem(.otherUserLeft(
                userAci: userAci.codableUuid,
            ))

            switch groupUpdateSource {
            case .localUser:
                addItem(.unnamedUsersWereInvitedByLocalUser(count: 1))
            case let .aci(updaterAci):
                addItem(.unnamedUsersWereInvitedByOtherUser(
                    updaterAci: updaterAci.codableUuid,
                    count: 1,
                ))
            case .rejectedInviteToPni, .legacyE164, .unknown:
                addItem(.unnamedUsersWereInvitedByUnknownUser(count: 1))
            }
        }
    }

    /// This code path does NOT handle invited Pnis accepting the invite as an aci.
    ///
    /// When a pni invite is accepted, it is necessarily the only update in the group; in those
    /// cases we short circuit in ``GroupUpdateInfoMessageInserterImpl``
    /// (see its usage of``InvitedPnisPromotionToFullMemberAcis``).
    /// We never even reach this method; we just store a ``PersistableGroupUpdateItem``
    /// (or, historically, a ``LegacyPersistableGroupUpdateItem``).
    mutating func addUserInvitedByAciAcceptedOrWasAdded(
        inviteeAci: Aci,
        inviterAci: Aci?,
    ) {
        if localIdentifiers.aci == inviteeAci {
            switch groupUpdateSource {
            case .localUser:
                if let inviterAci {
                    addItem(.localUserAcceptedInviteFromInviter(
                        inviterAci: inviterAci.codableUuid,
                    ))
                } else {
                    owsFailDebug("Missing inviter name!")
                    addItem(.localUserAcceptedInviteFromUnknownUser)
                }
            case let .aci(updaterAci):
                addItem(.localUserAddedByOtherUser(
                    updaterAci: updaterAci.codableUuid,
                ))
            case .rejectedInviteToPni, .legacyE164, .unknown:
                addItem(.localUserJoined)
            }
        } else {
            switch groupUpdateSource {
            case .localUser:
                addItem(.otherUserAddedByLocalUser(
                    userAci: inviteeAci.codableUuid,
                ))
            case let .aci(updaterAci):
                if inviteeAci == updaterAci {
                    // The update came from the person who was invited.

                    if let inviterAci, localIdentifiers.aci == inviterAci {
                        addItem(.otherUserAcceptedInviteFromLocalUser(
                            userAci: inviteeAci.codableUuid,
                        ))
                    } else if let inviterAci {
                        addItem(.otherUserAcceptedInviteFromInviter(
                            userAci: inviteeAci.codableUuid,
                            inviterAci: inviterAci.codableUuid,
                        ))
                    } else {
                        owsFailDebug("Missing inviter name.")
                        addItem(.otherUserAcceptedInviteFromUnknownUser(
                            userAci: inviteeAci.codableUuid,
                        ))
                    }
                } else {
                    addItem(.otherUserAddedByOtherUser(
                        updaterAci: updaterAci.codableUuid,
                        userAci: inviteeAci.codableUuid,
                    ))
                }
            case .rejectedInviteToPni, .legacyE164, .unknown:
                addItem(.otherUserJoined(
                    userAci: inviteeAci.codableUuid,
                ))
            }
        }
    }

    /// An update item for the fact that the given invited address declined or
    /// had their invite revoked.
    /// - Returns
    /// An update item, if one could be created. If `nil` is returned, inspect
    /// `unnamedInviteCounts` to see if an unnamed invite was affected.
    mutating func addUserInviteWasDeclinedOrRevoked(
        inviteeServiceId: ServiceId,
        inviterAci: Aci?,
        unnamedInviteCounts: inout UnnamedInviteCounts,
    ) {
        if localIdentifiers.contains(serviceId: inviteeServiceId) {
            switch groupUpdateSource {
            case .localUser:
                if let inviterAci {
                    addItem(.localUserDeclinedInviteFromInviter(
                        inviterAci: inviterAci.codableUuid,
                    ))
                } else {
                    owsFailDebug("Missing inviter name!")
                    addItem(.localUserDeclinedInviteFromUnknownUser)
                }
            case let .aci(updaterAci):
                addItem(.localUserInviteRevoked(
                    revokerAci: updaterAci.codableUuid,
                ))
            case .rejectedInviteToPni, .legacyE164, .unknown:
                addItem(.localUserInviteRevokedByUnknownUser)
            }
        } else {

            func addItemForOtherUser(_ updaterServiceId: ServiceId) {
                if inviteeServiceId == updaterServiceId {
                    if let inviterAci, localIdentifiers.aci == inviterAci {
                        addItem(.otherUserDeclinedInviteFromLocalUser(
                            invitee: inviteeServiceId.codableUppercaseString,
                        ))
                    } else if let inviterAci {
                        addItem(.otherUserDeclinedInviteFromInviter(
                            invitee: inviteeServiceId.codableUppercaseString,
                            inviterAci: inviterAci.codableUuid,
                        ))
                    } else {
                        addItem(.otherUserDeclinedInviteFromUnknownUser(
                            invitee: inviteeServiceId.codableUppercaseString,
                        ))
                    }
                } else {
                    unnamedInviteCounts.revokedInviteCount += 1
                }
            }

            switch groupUpdateSource {
            case .localUser:
                addItem(.otherUserInviteRevokedByLocalUser(
                    invitee: inviteeServiceId.codableUppercaseString,
                ))
            case .aci(let updaterAci):
                addItemForOtherUser(updaterAci)
            case .rejectedInviteToPni(let updaterPni):
                addItemForOtherUser(updaterPni)
            case .legacyE164, .unknown:
                unnamedInviteCounts.revokedInviteCount += 1
            }
        }
    }

    mutating func addUserWasAddedToTheGroup(
        newMember: Aci,
        newGroupModel: TSGroupModel,
    ) {
        if newGroupModel.didJustAddSelfViaGroupLinkV2 {
            addItem(.localUserJoined)
        } else if newMember == localIdentifiers.aci {
            switch groupUpdateSource {
            case .localUser:
                owsFailDebug("User added themselves to the group and was updater - should not be possible.")
                addItem(.localUserAddedByLocalUser)
            case let .aci(updaterAci):
                addItem(.localUserAddedByOtherUser(
                    updaterAci: updaterAci.codableUuid,
                ))
            case .rejectedInviteToPni, .legacyE164, .unknown:
                addItem(.localUserAddedByUnknownUser)
            }
        } else {
            switch groupUpdateSource {
            case .localUser:
                addItem(.otherUserAddedByLocalUser(
                    userAci: newMember.codableUuid,
                ))
            case let .aci(updaterAci):
                if updaterAci == newMember {
                    owsFailDebug("Remote user added themselves to the group!")

                    addItem(.otherUserAddedByUnknownUser(
                        userAci: newMember.codableUuid,
                    ))
                } else {
                    addItem(.otherUserAddedByOtherUser(
                        updaterAci: updaterAci.codableUuid,
                        userAci: newMember.codableUuid,
                    ))
                }
            case .rejectedInviteToPni, .legacyE164, .unknown:
                addItem(.otherUserAddedByUnknownUser(
                    userAci: newMember.codableUuid,
                ))
            }
        }
    }

    mutating func addUserJoinedFromInviteLink(
        newMember: Aci,
    ) {
        if localIdentifiers.aci == newMember {
            switch groupUpdateSource {
            case .localUser:
                addItem(.localUserJoinedViaInviteLink)
            case .aci, .rejectedInviteToPni, .legacyE164:
                owsFailDebug("A user should never join the group via invite link unless they are the updater.")
                addItem(.localUserJoined)
            case .unknown:
                addItem(.localUserJoined)
            }
        } else {
            switch groupUpdateSource {
            case .aci(let updaterAci) where updaterAci == newMember:
                addItem(.otherUserJoinedViaInviteLink(
                    userAci: newMember.codableUuid,
                ))
            default:
                owsFailDebug("If user joined via group link, they should be the updater!")
                addItem(.otherUserAddedByUnknownUser(
                    userAci: newMember.codableUuid,
                ))
            }
        }
    }

    mutating func addUserWasInvitedToTheGroup(
        invitee: ServiceId,
        unnamedInviteCounts: inout UnnamedInviteCounts,
    ) {
        if localIdentifiers.contains(serviceId: invitee) {
            switch groupUpdateSource {
            case .localUser:
                owsFailDebug("User invited themselves to the group!")

                addItem(.localUserWasInvitedByLocalUser)
            case let .aci(updaterAci):
                addItem(.localUserWasInvitedByOtherUser(
                    updaterAci: updaterAci.codableUuid,
                ))
            case .rejectedInviteToPni, .legacyE164, .unknown:
                addItem(.localUserWasInvitedByUnknownUser)
            }
        } else {
            switch groupUpdateSource {
            case .localUser:
                addItem(.otherUserWasInvitedByLocalUser(
                    inviteeServiceId: invitee.codableUppercaseString,
                ))
            default:
                unnamedInviteCounts.newInviteCount += 1
            }
        }
    }

    mutating func addUnnamedUsersWereInvited(count: UInt) {
        guard count > 0 else {
            return
        }

        switch groupUpdateSource {
        case .localUser:
            owsFailDebug("Unexpected updater - if local user is inviter, should not be unnamed.")
            addItem(.unnamedUsersWereInvitedByLocalUser(count: count))
        case let .aci(updaterAci):
            addItem(.unnamedUsersWereInvitedByOtherUser(
                updaterAci: updaterAci.codableUuid,
                count: count,
            ))
        case .rejectedInviteToPni, .legacyE164, .unknown:
            addItem(.unnamedUsersWereInvitedByUnknownUser(count: count))
        }
    }

    mutating func addUnnamedUserInvitesWereRevoked(count: UInt) {
        guard count > 0 else {
            return
        }

        switch groupUpdateSource {
        case .localUser:
            owsFailDebug("When local user is updater, should have named invites!")
            addItem(.unnamedUserInvitesWereRevokedByLocalUser(count: count))
        case let .aci(updaterAci):
            addItem(.unnamedUserInvitesWereRevokedByOtherUser(
                updaterAci: updaterAci.codableUuid,
                count: count,
            ))
        case .rejectedInviteToPni, .legacyE164, .unknown:
            addItem(.unnamedUserInvitesWereRevokedByUnknownUser(count: count))
        }
    }

    // MARK: Requesting Members

    mutating func addUserRequestedToJoinGroup(
        requesterAci: Aci,
    ) {
        if localIdentifiers.aci == requesterAci {
            addItem(.localUserRequestedToJoin)
        } else {
            addItem(.otherUserRequestedToJoin(
                userAci: requesterAci.codableUuid,
            ))
        }
    }

    mutating func addUserRequestWasApproved(
        requesterAci: Aci,
    ) {
        if localIdentifiers.aci == requesterAci {
            switch groupUpdateSource {
            case .localUser:
                // This could happen if the user requested to join a group
                // and became a requesting member, then tried to join the
                // group again and was added because the group stopped
                // requiring approval in the interim.
                owsFailDebug("User added themselves to the group and was updater - should not be possible.")
                addItem(.localUserAddedByLocalUser)
            case .aci(let updaterAci):
                addItem(.localUserRequestApproved(
                    approverAci: updaterAci.codableUuid,
                ))
            case .rejectedInviteToPni, .legacyE164, .unknown:
                addItem(.localUserRequestApprovedByUnknownUser)
            }
        } else {
            switch groupUpdateSource {
            case .localUser:
                addItem(.otherUserRequestApprovedByLocalUser(
                    userAci: requesterAci.codableUuid,
                ))
            case .aci(let updaterAci):
                addItem(.otherUserRequestApproved(
                    userAci: requesterAci.codableUuid,
                    approverAci: updaterAci.codableUuid,
                ))
            case .rejectedInviteToPni, .legacyE164, .unknown:
                addItem(.otherUserRequestApprovedByUnknownUser(
                    userAci: requesterAci.codableUuid,
                ))
            }
        }
    }

    mutating func addUserRequestWasRejected(
        requesterAci: Aci,
    ) {
        if localIdentifiers.aci == requesterAci {
            switch groupUpdateSource {
            case .localUser:
                addItem(.localUserRequestCanceledByLocalUser)
            case .aci, .rejectedInviteToPni, .legacyE164, .unknown:
                addItem(.localUserRequestRejectedByUnknownUser)
            }
        } else {
            switch groupUpdateSource {
            case .localUser:
                addItem(.otherUserRequestRejectedByLocalUser(
                    requesterAci: requesterAci.codableUuid,
                ))
            case let .aci(updaterAci):
                if updaterAci == requesterAci {
                    addItem(.otherUserRequestCanceledByOtherUser(
                        requesterAci: requesterAci.codableUuid,
                    ))
                } else {
                    addItem(.otherUserRequestRejectedByOtherUser(
                        updaterAci: updaterAci.codableUuid,
                        requesterAci: requesterAci.codableUuid,
                    ))
                }
            case .rejectedInviteToPni, .legacyE164, .unknown:
                addItem(.otherUserRequestRejectedByUnknownUser(
                    requesterAci: requesterAci.codableUuid,
                ))
            }
        }
    }

    // MARK: Disappearing Messages

    /// Add disappearing message timer updates to the item list.
    ///
    /// - Important
    /// This method checks for other updates that have already been added. Use
    /// caution when reorganizing any calls to this method.
    mutating func addDisappearingMessageUpdates(
        oldToken: DisappearingMessageToken?,
        newToken: DisappearingMessageToken?,
    ) {
        // If this update represents us joining the group, we want to make
        // sure we use "unknown" attribution for whatever the disappearing
        // message timer is set to. Since we just joined, we can't know who
        // set the timer.
        let localUserJustJoined = itemList.contains { updateItem in
            switch updateItem {
            case
                .localUserJoined,
                .localUserJoinedViaInviteLink,
                .localUserRequestApproved,
                .localUserRequestApprovedByUnknownUser:
                return true
            default:
                return false
            }
        }

        Self.disappearingMessageUpdateItem(
            groupUpdateSource: groupUpdateSource,
            oldToken: oldToken,
            newToken: newToken,
            forceUnknownAttribution: localUserJustJoined,
        ).map {
            addItem($0)
        }
    }

    static func disappearingMessageUpdateItem(
        groupUpdateSource: GroupUpdateSource,
        oldToken: DisappearingMessageToken?,
        newToken: DisappearingMessageToken?,
        forceUnknownAttribution: Bool,
    ) -> PersistableGroupUpdateItem? {
        guard let newToken else {
            // This info message was created before we embedded DM state.
            return nil
        }

        // This might be zero if DMs are not enabled.
        let durationMs = UInt64(newToken.durationSeconds) * 1000

        if forceUnknownAttribution, newToken.isEnabled {
            return .disappearingMessagesEnabledByUnknownUser(durationMs: durationMs)
        }

        if let oldToken, newToken == oldToken {
            // No change to disappearing message configuration occurred.
            return nil
        }

        if newToken.isEnabled, durationMs > 0 {
            switch groupUpdateSource {
            case .localUser:
                return .disappearingMessagesEnabledByLocalUser(durationMs: durationMs)
            case let .aci(updaterAci):
                return .disappearingMessagesEnabledByOtherUser(
                    updaterAci: updaterAci.codableUuid,
                    durationMs: durationMs,
                )
            case .rejectedInviteToPni, .legacyE164, .unknown:
                return .disappearingMessagesEnabledByUnknownUser(durationMs: durationMs)
            }
        } else {
            switch groupUpdateSource {
            case .localUser:
                return .disappearingMessagesDisabledByLocalUser
            case let .aci(updaterAci):
                return .disappearingMessagesDisabledByOtherUser(
                    updaterAci: updaterAci.codableUuid,
                )
            case .rejectedInviteToPni, .legacyE164, .unknown:
                return .disappearingMessagesDisabledByUnknownUser
            }
        }
    }

    // MARK: Group Invite Links

    mutating func addGroupInviteLinkUpdates(
        oldGroupModel: TSGroupModel,
        newGroupModel: TSGroupModel,
    ) {
        guard let oldGroupModel = oldGroupModel as? TSGroupModelV2 else {
            return
        }
        guard let newGroupModel = newGroupModel as? TSGroupModelV2 else {
            owsFailDebug("Invalid group model.")
            return
        }
        let oldGroupInviteLinkMode = oldGroupModel.groupInviteLinkMode
        let newGroupInviteLinkMode = newGroupModel.groupInviteLinkMode

        guard oldGroupInviteLinkMode != newGroupInviteLinkMode else {
            if
                let oldInviteLinkPassword = oldGroupModel.inviteLinkPassword,
                let newInviteLinkPassword = newGroupModel.inviteLinkPassword,
                oldInviteLinkPassword != newInviteLinkPassword
            {
                switch groupUpdateSource {
                case .localUser:
                    addItem(.inviteLinkResetByLocalUser)
                case let .aci(updaterAci):
                    addItem(.inviteLinkResetByOtherUser(
                        updaterAci: updaterAci.codableUuid,
                    ))
                case .rejectedInviteToPni, .legacyE164, .unknown:
                    addItem(.inviteLinkResetByUnknownUser)
                }
            }

            return
        }

        switch oldGroupInviteLinkMode {
        case .disabled:
            switch newGroupInviteLinkMode {
            case .disabled:
                owsFailDebug("State did not change.")
            case .enabledWithoutApproval:
                switch groupUpdateSource {
                case .localUser:
                    addItem(.inviteLinkEnabledWithoutApprovalByLocalUser)
                case let .aci(updaterAci):
                    addItem(.inviteLinkEnabledWithoutApprovalByOtherUser(
                        updaterAci: updaterAci.codableUuid,
                    ))
                case .rejectedInviteToPni, .legacyE164, .unknown:
                    addItem(.inviteLinkEnabledWithoutApprovalByUnknownUser)
                }
            case .enabledWithApproval:
                switch groupUpdateSource {
                case .localUser:
                    addItem(.inviteLinkEnabledWithApprovalByLocalUser)
                case let .aci(updaterAci):
                    addItem(.inviteLinkEnabledWithApprovalByOtherUser(
                        updaterAci: updaterAci.codableUuid,
                    ))
                case .rejectedInviteToPni, .legacyE164, .unknown:
                    addItem(.inviteLinkEnabledWithApprovalByUnknownUser)
                }
            }
        case .enabledWithoutApproval, .enabledWithApproval:
            switch newGroupInviteLinkMode {
            case .disabled:
                switch groupUpdateSource {
                case .localUser:
                    addItem(.inviteLinkDisabledByLocalUser)
                case let .aci(updaterAci):
                    addItem(.inviteLinkDisabledByOtherUser(
                        updaterAci: updaterAci.codableUuid,
                    ))
                case .rejectedInviteToPni, .legacyE164, .unknown:
                    addItem(.inviteLinkDisabledByUnknownUser)
                }
            case .enabledWithoutApproval:
                switch groupUpdateSource {
                case .localUser:
                    addItem(.inviteLinkApprovalDisabledByLocalUser)
                case let .aci(updaterAci):
                    addItem(.inviteLinkApprovalDisabledByOtherUser(
                        updaterAci: updaterAci.codableUuid,
                    ))
                case .rejectedInviteToPni, .legacyE164, .unknown:
                    addItem(.inviteLinkApprovalDisabledByUnknownUser)
                }
            case .enabledWithApproval:
                switch groupUpdateSource {
                case .localUser:
                    addItem(.inviteLinkApprovalEnabledByLocalUser)
                case let .aci(updaterAci):
                    addItem(.inviteLinkApprovalEnabledByOtherUser(
                        updaterAci: updaterAci.codableUuid,
                    ))
                case .rejectedInviteToPni, .legacyE164, .unknown:
                    addItem(.inviteLinkApprovalEnabledByUnknownUser)
                }
            }
        }
    }

    // MARK: Announcement-Only Groups

    mutating func addIsAnnouncementOnlyLinkUpdates(
        oldGroupModel: TSGroupModel,
        newGroupModel: TSGroupModel,
    ) {
        guard let oldGroupModel = oldGroupModel as? TSGroupModelV2 else {
            return
        }
        guard let newGroupModel = newGroupModel as? TSGroupModelV2 else {
            owsFailDebug("Invalid group model.")
            return
        }
        let oldIsAnnouncementsOnly = oldGroupModel.isAnnouncementsOnly
        let newIsAnnouncementsOnly = newGroupModel.isAnnouncementsOnly

        guard oldIsAnnouncementsOnly != newIsAnnouncementsOnly else {
            return
        }

        if newIsAnnouncementsOnly {
            switch groupUpdateSource {
            case .localUser:
                addItem(.announcementOnlyEnabledByLocalUser)
            case let .aci(aci):
                addItem(.announcementOnlyEnabledByOtherUser(
                    updaterAci: aci.codableUuid,
                ))
            case .rejectedInviteToPni, .legacyE164, .unknown:
                addItem(.announcementOnlyEnabledByUnknownUser)
            }
        } else {
            switch groupUpdateSource {
            case .localUser:
                addItem(.announcementOnlyDisabledByLocalUser)
            case let .aci(aci):
                addItem(.announcementOnlyDisabledByOtherUser(updaterAci: aci.codableUuid))
            case .rejectedInviteToPni, .legacyE164, .unknown:
                addItem(.announcementOnlyDisabledByUnknownUser)
            }
        }
    }

    // MARK: Group Terminated

    mutating func addGroupTerminatedUpdate(
        oldGroupModel: TSGroupModel,
        newGroupModel: TSGroupModel,
    ) {
        guard let oldGroupModel = oldGroupModel as? TSGroupModelV2 else {
            return
        }
        guard let newGroupModel = newGroupModel as? TSGroupModelV2 else {
            owsFailDebug("Invalid group model.")
            return
        }
        let oldIsTerminated = oldGroupModel.isTerminated
        let newIsTerminated = newGroupModel.isTerminated

        guard oldIsTerminated != newIsTerminated else {
            return
        }

        if newIsTerminated {
            switch groupUpdateSource {
            case .localUser:
                addItem(.groupTerminatedByLocalUser)
            case let .aci(aci):
                addItem(.groupTerminatedByOtherUser(
                    updaterAci: aci.codableUuid,
                ))
            case .rejectedInviteToPni, .legacyE164, .unknown:
                addItem(.groupTerminatedByUnknownUser)
            }
        } else {
            owsFailDebug("Cannot unterminate a group")
        }
    }

    // MARK: Migration

    mutating func addMigrationUpdates(
        oldGroupMembership: GroupMembership,
        newGroupMembership: GroupMembership,
        newGroupModel: TSGroupModel,
    ) {
        owsAssertDebug(newGroupModel.wasJustMigratedToV2)
        addItem(.wasMigrated)
    }
}