Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
signalapp
GitHub Repository: signalapp/Signal-iOS
Path: blob/main/SignalServiceKit/Groups/GroupUpdateInfoMessageInserter+FoldIntoExistingMessage.swift
1 views
//
// Copyright 2023 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//

import Foundation
public import LibSignalClient

extension GroupUpdateInfoMessageInserterImpl {
    /// Represents updates to a group's membership that may be possible to
    /// collapse into existing info messages.
    enum PossiblyCollapsibleMembershipChange {
        case newJoinRequestFromSingleUser(requestingAci: Aci)
        case canceledJoinRequestFromSingleUser(cancelingAci: Aci)
    }

    /// Represents the result of collapsing updates into existing messages.
    enum CollapsibleMembershipChangeResult {
        /// Membership changes were collapsed into an existing, now-updated,
        /// info message.
        case updatesCollapsedIntoExistingMessage
        /// Update messages pertaining to the membership change are available
        /// and a new info message should be inserted containing them. Existing
        /// messages may have been updated while computing these updates.
        case updateItemForNewMessage(TSInfoMessage.PersistableGroupUpdateItem)
    }

    func handlePossiblyCollapsibleMembershipChange(
        possiblyCollapsibleMembershipChange: PossiblyCollapsibleMembershipChange,
        localIdentifiers: LocalIdentifiers,
        groupThread: TSGroupThread,
        newGroupModel: TSGroupModel,
        transaction: DBWriteTransaction,
    ) -> CollapsibleMembershipChangeResult? {
        guard
            let (mostRecentInfoMsg, secondMostRecentInfoMsgMaybe) =
            Self.mostRecentVisibleInteractionsAsInfoMessages(
                forGroupThread: groupThread,
                withTransaction: transaction,
            )
        else {
            return nil
        }

        switch possiblyCollapsibleMembershipChange {
        case .newJoinRequestFromSingleUser(let requestingAci):
            /// By requesting and canceling over and over, a user who is not in
            /// a group would be able to fill the group's chat history with info
            /// messages detailing their actions. To address that, we'll
            /// "collapse" a request/cancel event into a preexisting info
            /// message, if appropriate, rather than letting the events pile up.

            guard localIdentifiers.aci != requestingAci else {
                return nil
            }

            return maybeUpdate(
                mostRecentInfoMsg: mostRecentInfoMsg,
                withNewJoinRequestFrom: requestingAci,
                localIdentifiers: localIdentifiers,
                transaction: transaction,
            )
        case .canceledJoinRequestFromSingleUser(let cancelingAci):
            /// See the comment above for why we care about this case.

            guard localIdentifiers.aci != cancelingAci else {
                return nil
            }

            return maybeUpdate(
                mostRecentInfoMsg: mostRecentInfoMsg,
                andSecondMostRecentInfoMsg: secondMostRecentInfoMsgMaybe,
                withCanceledJoinRequestFrom: cancelingAci,
                newGroupModel: newGroupModel,
                localIdentifiers: localIdentifiers,
                transaction: transaction,
            )
        }
    }

    private func maybeUpdate(
        mostRecentInfoMsg: TSInfoMessage,
        withNewJoinRequestFrom requestingAci: Aci,
        localIdentifiers: LocalIdentifiers,
        transaction: DBWriteTransaction,
    ) -> CollapsibleMembershipChangeResult? {

        // For a new join request we always want a new info message. However,
        // if the new request matches collapsed request/cancel events on the
        // most recent message we should make a note on the new message that
        // it is no longer the tail of the sequence.
        //
        // Note that the new message might get collapsed further (into the
        // most recent message) in the future.

        let mostRecentUpdateItem: TSInfoMessage.PersistableGroupUpdateItem?
        switch mostRecentInfoMsg.groupUpdateMetadata(localIdentifiers: localIdentifiers) {
        case .precomputed(let precomputedItems):
            mostRecentUpdateItem = precomputedItems.asSingleUpdateItem
        case .modelDiff, .legacyRawString, .newGroup, .nonGroupUpdate:
            return nil
        }

        guard
            let mostRecentUpdateItem,
            case let .sequenceOfInviteLinkRequestAndCancels(requester, count, isTail) = mostRecentUpdateItem,
            requestingAci == requester.wrappedValue
        else {
            return nil
        }

        owsAssertDebug(isTail)

        mostRecentInfoMsg.setSingleUpdateItem(
            singleUpdateItem: .sequenceOfInviteLinkRequestAndCancels(
                requester: requestingAci.codableUuid,
                count: count,
                isTail: false,
            ),
        )
        mostRecentInfoMsg.anyUpsert(transaction: transaction)

        return .updateItemForNewMessage(
            .sequenceOfInviteLinkRequestAndCancels(
                requester: requestingAci.codableUuid,
                count: 0,
                isTail: true,
            ),
        )
    }

    private func maybeUpdate(
        mostRecentInfoMsg: TSInfoMessage,
        andSecondMostRecentInfoMsg secondMostRecentInfoMsg: TSInfoMessage?,
        withCanceledJoinRequestFrom cancelingAci: Aci,
        newGroupModel: TSGroupModel,
        localIdentifiers: LocalIdentifiers,
        transaction: DBWriteTransaction,
    ) -> CollapsibleMembershipChangeResult? {

        // If the most recent message represents the join request that's being
        // canceled, we want to collapse into it.
        //
        // Further, if the second-most-recent message represents already-
        // collapsed join/cancel events from the same address, we can simply
        // increment that message's collapse counter and delete the most recent
        // message.

        if
            let (mostRecentInfoMsgJoiner, count) = mostRecentInfoMsg.representsSequenceOfRequestsAndCancelsWithAdditionalRequestToJoin(
                localIdentifiers: localIdentifiers,
            )
        {
            guard mostRecentInfoMsgJoiner == cancelingAci else {
                return nil
            }
            // collapse into the single most recent message; it already represents
            // a sequence of requests + cancels and one more request.
            mostRecentInfoMsg.setSingleUpdateItem(
                singleUpdateItem: .sequenceOfInviteLinkRequestAndCancels(
                    requester: cancelingAci.codableUuid,
                    count: count + 1,
                    isTail: true,
                ),
            )
            mostRecentInfoMsg.anyUpsert(transaction: transaction)

            return .updatesCollapsedIntoExistingMessage
        }

        guard
            let mostRecentInfoMsgJoiner = mostRecentInfoMsg.representsCollapsibleSingleRequestToJoin(
                localIdentifiers: localIdentifiers,
                tx: transaction,
            ),
            cancelingAci == mostRecentInfoMsgJoiner
        else {
            return nil
        }

        if
            let secondMostRecentInfoMsg,
            let (requester, count) = secondMostRecentInfoMsg
                .representsSingleSequenceOfRequestsAndCancels(
                    localIdentifiers: localIdentifiers,
                ),
            cancelingAci == requester
        {
            DependenciesBridge.shared.interactionDeleteManager
                .delete(mostRecentInfoMsg, sideEffects: .default(), tx: transaction)

            secondMostRecentInfoMsg.setSingleUpdateItem(
                singleUpdateItem: .sequenceOfInviteLinkRequestAndCancels(
                    requester: cancelingAci.codableUuid,
                    count: count + 1,
                    isTail: true,
                ),
            )
            secondMostRecentInfoMsg.anyUpsert(transaction: transaction)

            return .updatesCollapsedIntoExistingMessage
        } else {
            mostRecentInfoMsg.setSingleUpdateItem(
                singleUpdateItem: .sequenceOfInviteLinkRequestAndCancels(
                    requester: cancelingAci.codableUuid,
                    count: 1,
                    isTail: true,
                ),
            )
            mostRecentInfoMsg.anyUpsert(transaction: transaction)

            return .updatesCollapsedIntoExistingMessage
        }
    }

    private static func mostRecentVisibleInteractionsAsInfoMessages(
        forGroupThread groupThread: TSGroupThread,
        withTransaction transaction: DBReadTransaction,
    ) -> (first: TSInfoMessage, second: TSInfoMessage?)? {
        var mostRecentVisibleInteraction: TSInteraction?
        var secondMostRecentVisibleInteraction: TSInteraction?
        do {
            try InteractionFinder(threadUniqueId: groupThread.uniqueId)
                .enumerateInteractionsForConversationView(
                    rowIdFilter: .newest,
                    tx: transaction,
                ) { interaction -> Bool in
                    if mostRecentVisibleInteraction == nil {
                        mostRecentVisibleInteraction = interaction
                    } else if secondMostRecentVisibleInteraction == nil {
                        secondMostRecentVisibleInteraction = interaction
                        return false
                    }
                    return true
                }
        } catch let error {
            Logger.warn("Failed to get most recent interactions for thread: \(error.localizedDescription)")
            return nil
        }

        guard let mostRecentInfoMessage = mostRecentVisibleInteraction as? TSInfoMessage else {
            return nil
        }

        guard let secondMostRecentInfoMessage = secondMostRecentVisibleInteraction as? TSInfoMessage else {
            return (mostRecentInfoMessage, nil)
        }

        return (mostRecentInfoMessage, secondMostRecentInfoMessage)
    }
}

// MARK: TSInfoMessage extension

public extension TSInfoMessage.PersistableGroupUpdateItemsWrapper {
    var asSingleUpdateItem: TSInfoMessage.PersistableGroupUpdateItem? {
        guard updateItems.count == 1 else {
            return nil
        }

        return updateItems.first
    }
}

private extension TSInfoMessage {
    func setSingleUpdateItem(singleUpdateItem: PersistableGroupUpdateItem) {
        setGroupUpdateItemsWrapper(PersistableGroupUpdateItemsWrapper([singleUpdateItem]))
    }

    func representsCollapsibleSingleRequestToJoin(
        localIdentifiers: LocalIdentifiers,
        tx: DBReadTransaction,
    ) -> Aci? {
        switch groupUpdateMetadata(localIdentifiers: localIdentifiers) {
        case .newGroup, .nonGroupUpdate, .legacyRawString:
            return nil
        case .precomputed(let precomputedItems):
            return precomputedItems
                .asSingleUpdateItem?
                .representsCollapsibleSingleRequestToJoin()
        case .modelDiff:
            // In the case of a model diff, convert to a persistable item first, and see if
            // that persistable item is a single request to join. The persistable item
            // generation logic already has logic to determine this case; no need to
            // replicate it here.
            guard
                let groupUpdateItems = computedGroupUpdateItems(
                    localIdentifiers: localIdentifiers,
                    tx: tx,
                ),
                groupUpdateItems.count == 1
            else {
                return nil
            }

            return groupUpdateItems.first?.representsCollapsibleSingleRequestToJoin()
        }
    }

    func representsSingleSequenceOfRequestsAndCancels(
        localIdentifiers: LocalIdentifiers,
    ) -> (Aci, count: UInt)? {
        switch self.groupUpdateMetadata(localIdentifiers: localIdentifiers) {
        case .modelDiff, .legacyRawString, .newGroup, .nonGroupUpdate:
            // This is a phenomenon exclusive to precomputed cases.
            return nil
        case .precomputed(let precomputedItems):
            return precomputedItems
                .asSingleUpdateItem?
                .representsSequenceOfRequestsAndCancels()
        }
    }

    /// It is possible (e.g. when restoring from a desktop-generated backup) to have a single info message
    /// containing both a ``sequenceOfInviteLinkRequestAndCancels`` and a single request to
    /// join that happens right after.
    /// If this is the latest message, and we get a new cancel, we want to collapse everything down to
    /// a single ``sequenceOfInviteLinkRequestAndCancels`` with an incremented count.
    func representsSequenceOfRequestsAndCancelsWithAdditionalRequestToJoin(
        localIdentifiers: LocalIdentifiers,
    ) -> (Aci, count: UInt)? {
        switch groupUpdateMetadata(localIdentifiers: localIdentifiers) {
        case .newGroup, .nonGroupUpdate, .legacyRawString, .modelDiff:
            // This is a phenomenon exclusive to precomputed cases.
            return nil
        case .precomputed(let precomputedItems):
            let precomputedItems = precomputedItems.updateItems
            guard precomputedItems.count == 2 else {
                return nil
            }
            let firstItemRequester: Aci
            let firstItemCount: UInt
            let firstMessageIsTail: Bool
            switch precomputedItems[0] {
            case let .sequenceOfInviteLinkRequestAndCancels(requester, count, isTail):
                firstItemRequester = requester.wrappedValue
                firstItemCount = count
                firstMessageIsTail = isTail
            default:
                return nil
            }

            guard
                let secondItemRequester = precomputedItems[1]
                    .representsCollapsibleSingleRequestToJoin()
            else {
                return nil
            }

            guard firstItemRequester == secondItemRequester else {
                return nil
            }
            owsAssertDebug(
                !firstMessageIsTail,
                "Should not be tail when there is a subsequent request!",
            )
            return (firstItemRequester, firstItemCount)
        }
    }
}

public extension TSInfoMessage.PersistableGroupUpdateItem {

    func representsSequenceOfRequestsAndCancels() -> (Aci, count: UInt)? {
        guard
            case let .sequenceOfInviteLinkRequestAndCancels(requester, count, _) = self
        else {
            return nil
        }
        return (requester.wrappedValue, count)
    }

    func representsCollapsibleSingleRequestToJoin() -> Aci? {
        switch self {
        case let .sequenceOfInviteLinkRequestAndCancels(requester, count, isTail):
            guard isTail, count == 0 else {
                return nil
            }
            return requester.wrappedValue
        case .localUserRequestedToJoin:
            // Just calling out that we don't collapse the local user's requests.
            return nil
        case let .otherUserRequestedToJoin(requester):
            return requester.wrappedValue
        default:
            return nil
        }
    }
}