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

public enum InteractionDelete {
    /// Specifies the desired side effects of deleting interactions.
    public struct SideEffects {
        /// Specifies what should happen with the ``CallRecord`` associated with
        /// a ``TSInteraction`` being deleted, if one exists.
        public enum AssociatedCallDeleteBehavior {
            /// Delete any ``CallRecord`` associated with the interaction, and
            /// send a `CallEvent` sync message about that deletion.
            case localDeleteAndSendCallEventSyncMessage

            /// Delete any ``CallRecord`` associated with the interaction.
            case localDeleteOnly
        }

        /// Specifies behavior for updating the thread associated with an
        /// interaction when that interaction is deleted.
        public enum UpdateThreadOnInteractionDeleteBehavior {
            /// Update the thread after the interaction is deleted.
            case updateOnEachDeletedInteraction

            /// Skip updating the thread. This value should be used to suppress
            /// intermediate thread updates during a bulk interaction delete.
            case doNotUpdate
        }

        /// Specifies behavior for sending a `DeleteForMe` sync message for any
        /// deleted interactions.
        public enum DeleteForMeSyncMessageBehavior {
            /// Send a sync message.
            /// - Important
            /// Any interactions this case is applied to must match the given
            /// thread.
            case sendSyncMessage(interactionsThread: TSThread)

            /// Do not send a sync message.
            case doNotSend
        }

        let associatedCallDelete: AssociatedCallDeleteBehavior
        let updateThreadOnInteractionDelete: UpdateThreadOnInteractionDeleteBehavior
        let deleteForMeSyncMessage: DeleteForMeSyncMessageBehavior
        let deleteAssociatedEdits: Bool

        private init(
            associatedCallDelete: AssociatedCallDeleteBehavior,
            updateThreadOnInteractionDelete: UpdateThreadOnInteractionDeleteBehavior,
            deleteForMeSyncMessage: DeleteForMeSyncMessageBehavior,
            deleteAssociatedEdits: Bool,
        ) {
            self.associatedCallDelete = associatedCallDelete
            self.updateThreadOnInteractionDelete = updateThreadOnInteractionDelete
            self.deleteForMeSyncMessage = deleteForMeSyncMessage
            self.deleteAssociatedEdits = deleteAssociatedEdits
        }

        public static func `default`() -> SideEffects {
            return .custom()
        }

        public static func custom(
            associatedCallDelete: AssociatedCallDeleteBehavior = .localDeleteAndSendCallEventSyncMessage,
            updateThreadOnInteractionDelete: UpdateThreadOnInteractionDeleteBehavior = .updateOnEachDeletedInteraction,
            deleteForMeSyncMessage: DeleteForMeSyncMessageBehavior = .doNotSend,
            deleteAssociatedEdits: Bool = true,
        ) -> SideEffects {
            return SideEffects(
                associatedCallDelete: associatedCallDelete,
                updateThreadOnInteractionDelete: updateThreadOnInteractionDelete,
                deleteForMeSyncMessage: deleteForMeSyncMessage,
                deleteAssociatedEdits: deleteAssociatedEdits,
            )
        }
    }
}

/// Responsible for deleting ``TSInteraction``s, and initiating ``CallRecord``
/// deletion.
///
/// - Note
/// Every ``CallRecord`` is associated with a ``TSInteraction``, and when
/// one is deleted the other should be as well.
///
/// Correspondingly, this manager also provides an entrypoint for callers to
/// delete call records alongside their associated interactions. This may seem
/// counterintuitive, but avoids a circular dependency between interaction and
/// call record deletion.
///
/// - SeeAlso
/// If you're calling this type for a user-initiated deletion, consider using
/// ``DeleteForMeInfoSheetCoordinator`` in the Signal target instead, which
/// handles some one-time informational UX.
public protocol InteractionDeleteManager {
    typealias SideEffects = InteractionDelete.SideEffects

    /// Remove the given interactions.
    func delete(
        interactions: [TSInteraction],
        sideEffects: SideEffects,
        tx: DBWriteTransaction,
    )

    /// Deletes the given call records and their associated interactions.
    func delete(
        alongsideAssociatedCallRecords callRecords: [CallRecord],
        sideEffects: SideEffects,
        tx: DBWriteTransaction,
    )
}

public extension InteractionDeleteManager {
    /// Remove the given interaction.
    func delete(
        _ interaction: TSInteraction,
        sideEffects: SideEffects,
        tx: DBWriteTransaction,
    ) {
        delete(interactions: [interaction], sideEffects: sideEffects, tx: tx)
    }
}

final class InteractionDeleteManagerImpl: InteractionDeleteManager {
    private let callRecordStore: CallRecordStore
    private let callRecordDeleteManager: CallRecordDeleteManager
    private let databaseStorage: SDSDatabaseStorage
    private let deleteForMeOutgoingSyncMessageManager: DeleteForMeOutgoingSyncMessageManager
    private let interactionReadCache: InteractionReadCache
    private let interactionStore: InteractionStore
    private let messageSendLog: MessageSendLog
    private let tsAccountManager: TSAccountManager

    init(
        callRecordStore: CallRecordStore,
        callRecordDeleteManager: CallRecordDeleteManager,
        databaseStorage: SDSDatabaseStorage,
        deleteForMeOutgoingSyncMessageManager: DeleteForMeOutgoingSyncMessageManager,
        interactionReadCache: InteractionReadCache,
        interactionStore: InteractionStore,
        messageSendLog: MessageSendLog,
        tsAccountManager: TSAccountManager,
    ) {
        self.callRecordStore = callRecordStore
        self.callRecordDeleteManager = callRecordDeleteManager
        self.databaseStorage = databaseStorage
        self.deleteForMeOutgoingSyncMessageManager = deleteForMeOutgoingSyncMessageManager
        self.interactionReadCache = interactionReadCache
        self.interactionStore = interactionStore
        self.messageSendLog = messageSendLog
        self.tsAccountManager = tsAccountManager
    }

    func delete(
        interactions: [TSInteraction],
        sideEffects: SideEffects,
        tx: DBWriteTransaction,
    ) {
        for interaction in interactions {
            guard interaction.shouldBeSaved else {
                return
            }

            _deleteInternal(
                interaction: interaction,
                knownAssociatedCallRecord: nil,
                sideEffects: sideEffects,
                tx: tx,
            )
        }

        sendDeleteForMeSyncMessageIfNecessary(
            interactions: interactions,
            sideEffects: sideEffects,
            tx: tx,
        )
    }

    func delete(
        alongsideAssociatedCallRecords callRecords: [CallRecord],
        sideEffects: SideEffects,
        tx: DBWriteTransaction,
    ) {
        var deletedInteractions = [TSInteraction]()
        for callRecord in callRecords {
            guard
                let associatedInteraction: TSInteraction = interactionStore
                    .fetchAssociatedInteraction(callRecord: callRecord, tx: tx)
            else { continue }

            deletedInteractions.append(associatedInteraction)
            CallRecord.assertDebugIsCallRecordInteraction(associatedInteraction)

            _deleteInternal(
                interaction: associatedInteraction,
                knownAssociatedCallRecord: callRecord,
                sideEffects: sideEffects,
                tx: tx,
            )
        }

        sendDeleteForMeSyncMessageIfNecessary(
            interactions: deletedInteractions,
            sideEffects: sideEffects,
            tx: tx,
        )
    }

    private func sendDeleteForMeSyncMessageIfNecessary(
        interactions: [TSInteraction],
        sideEffects: SideEffects,
        tx: DBWriteTransaction,
    ) {
        switch sideEffects.deleteForMeSyncMessage {
        case .sendSyncMessage(let interactionsThread):
            owsPrecondition(
                interactions.allSatisfy { $0.uniqueThreadId == interactionsThread.uniqueId },
                "Thread did not match interaction!",
            )

            if let localIdentifiers = tsAccountManager.localIdentifiers(tx: tx) {
                deleteForMeOutgoingSyncMessageManager.send(
                    deletedMessages: interactions.compactMap { $0 as? TSMessage },
                    thread: interactionsThread,
                    localIdentifiers: localIdentifiers,
                    tx: tx,
                )
            }
        case .doNotSend:
            break
        }
    }

    // MARK: -

    private func _deleteInternal(
        interaction: TSInteraction,
        knownAssociatedCallRecord: CallRecord?,
        sideEffects: SideEffects,
        tx: DBWriteTransaction,
    ) {
        willRemove(
            interaction: interaction,
            knownAssociatedCallRecord: knownAssociatedCallRecord,
            sideEffects: sideEffects,
            tx: tx,
        )

        // Worth using a cached statement here, since we may be deleting a large
        // number of interactions at once here.
        tx.database.executeWithCachedStatement(
            sql: "DELETE FROM model_TSInteraction WHERE uniqueId = ?",
            arguments: [interaction.uniqueId],
        )

        didRemove(
            interaction: interaction,
            sideEffects: sideEffects,
            tx: tx,
        )
    }

    private func willRemove(
        interaction: TSInteraction,
        knownAssociatedCallRecord: CallRecord?,
        sideEffects: SideEffects,
        tx: DBWriteTransaction,
    ) {
        databaseStorage.updateIdMapping(interaction: interaction, transaction: tx)

        if
            let callInteraction = interaction as? CallRecordAssociatedInteraction,
            let interactionRowId = callInteraction.sqliteRowId,
            let associatedCallRecord = knownAssociatedCallRecord ?? callRecordStore.fetch(
                interactionRowId: interactionRowId,
                tx: tx,
            )
        {
            let sendSyncMessage = switch sideEffects.associatedCallDelete {
            case .localDeleteOnly: false
            case .localDeleteAndSendCallEventSyncMessage: true
            }

            callRecordDeleteManager.deleteCallRecords(
                [associatedCallRecord],
                sendSyncMessageOnDelete: sendSyncMessage,
                tx: tx,
            )
        }

        if sideEffects.deleteAssociatedEdits, let message = interaction as? TSMessage {
            // Ensure any associated edits are removed before removing.
            message.removeEdits(transaction: tx)
        }
    }

    private func didRemove(
        interaction: TSInteraction,
        sideEffects: SideEffects,
        tx: DBWriteTransaction,
    ) {
        switch sideEffects.updateThreadOnInteractionDelete {
        case .updateOnEachDeletedInteraction:
            if let associatedThread = interaction.thread(tx: tx) {
                associatedThread.updateWithRemovedInteraction(interaction, tx: tx)
            }
        case .doNotUpdate:
            break
        }

        messageSendLog.deleteAllPayloadsForInteraction(interaction, tx: tx)
        interactionReadCache.didRemove(interaction: interaction, transaction: tx)

        if let message = interaction as? TSMessage {
            FullTextSearchIndexer.delete(message, tx: tx)

            message.removeAllAttachments(tx: tx)
            message.removeAllReactions(transaction: tx)
            message.removeAllMentions(transaction: tx)
            message.touchStoryMessageIfNecessary(replyCountIncrement: .replyDeleted, transaction: tx)
        }
    }
}

// MARK: - Mock

#if TESTABLE_BUILD

open class MockInteractionDeleteManager: InteractionDeleteManager {
    var deleteInteractionsMock: ((
        _ interactions: [TSInteraction],
        _ sideEffects: SideEffects,
    ) -> Void)?
    open func delete(interactions: [TSInteraction], sideEffects: SideEffects, tx: DBWriteTransaction) {
        deleteInteractionsMock!(interactions, sideEffects)
    }

    var deleteAlongsideCallRecordsMock: ((
        _ callRecords: [CallRecord],
        _ sideEffects: SideEffects,
    ) -> Void)?
    open func delete(alongsideAssociatedCallRecords callRecords: [CallRecord], sideEffects: SideEffects, tx: DBWriteTransaction) {
        deleteAlongsideCallRecordsMock!(callRecords, sideEffects)
    }
}

#endif