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

import LibSignalClient
public import SignalServiceKit
public import SignalUI

public struct CVSelectionType: OptionSet {
    public let rawValue: UInt

    public init(rawValue: UInt) {
        self.rawValue = rawValue
    }

    public static var none: CVSelectionType { CVSelectionType(rawValue: 0) }
    public static let primaryContent = CVSelectionType(rawValue: 1 << 0)
    public static let secondaryContent = CVSelectionType(rawValue: 1 << 1)
    public static var allContent: CVSelectionType { primaryContent.union(secondaryContent) }
}

// MARK: -

public struct CVSelectionItem {
    public let interactionId: String
    public let interactionType: OWSInteractionType
    public let isForwardable: Bool

    public let selectionType: CVSelectionType

    init(
        interactionId: String,
        interactionType: OWSInteractionType,
        isForwardable: Bool,
        selectionType: CVSelectionType,
    ) {

        self.interactionId = interactionId
        self.interactionType = interactionType
        self.isForwardable = isForwardable
        self.selectionType = selectionType
    }

    init(
        interaction: TSInteraction,
        hasRenderableContent: Bool,
        selectionType: CVSelectionType,
    ) {

        self.interactionId = interaction.uniqueId
        self.interactionType = interaction.interactionType
        if let message = interaction as? TSMessage {
            self.isForwardable = (
                hasRenderableContent &&
                    !message.isViewOnceMessage &&
                    !message.wasRemotelyDeleted &&
                    !message.isPoll,
            )
        } else {
            self.isForwardable = false
        }
        self.selectionType = selectionType
    }
}

// MARK: -

public protocol CVSelectionStateDelegate: AnyObject {
    func selectionStateDidChange()
}

// MARK: -

// In selection mode, represents which interactions (or portions thereof) are currently selected.
public class CVSelectionState: NSObject {
    public weak var delegate: CVSelectionStateDelegate?

    // A map of interaction uniqueId-to-CVSelectionItem.
    //
    // For items in this map, selectionType should never be .none.
    private var itemMap = [String: CVSelectionItem]()

    public var interactionCount: Int { itemMap.count }

    public func add(interaction: TSInteraction, hasRenderableContent: Bool, selectionType: CVSelectionType) {
        AssertIsOnMainThread()

        guard !selectionType.isEmpty else {
            owsFailDebug("Cannot add or remote empty selection type.")
            return
        }

        let interactionId = interaction.uniqueId
        owsAssertDebug(!isSelected(interactionId, selectionType: selectionType))

        if let oldItem = itemMap[interactionId] {
            let newItem = CVSelectionItem(
                interaction: interaction,
                hasRenderableContent: hasRenderableContent,
                selectionType: oldItem.selectionType.union(selectionType),
            )
            owsAssertDebug(!newItem.selectionType.isEmpty)
            owsAssertDebug(oldItem.interactionId == newItem.interactionId)
            owsAssertDebug(oldItem.interactionType == newItem.interactionType)
            guard oldItem.selectionType != newItem.selectionType else {
                owsFailDebug("Did not change state.")
                return
            }
            itemMap[interactionId] = newItem
        } else {
            let newItem = CVSelectionItem(
                interaction: interaction,
                hasRenderableContent: hasRenderableContent,
                selectionType: selectionType,
            )
            itemMap[interactionId] = newItem
        }
        delegate?.selectionStateDidChange()
    }

    public func add(itemViewModel: CVItemViewModel, selectionType: CVSelectionType) {
        add(
            interaction: itemViewModel.interaction,
            hasRenderableContent: itemViewModel.hasRenderableContent,
            selectionType: selectionType,
        )
    }

    public func remove(interaction: TSInteraction, hasRenderableContent: Bool, selectionType: CVSelectionType) {
        AssertIsOnMainThread()

        guard !selectionType.isEmpty else {
            owsFailDebug("Cannot add or remote empty selection type.")
            return
        }

        let interactionId = interaction.uniqueId
        owsAssertDebug(isSelected(interactionId, selectionType: selectionType))

        if let oldItem = itemMap[interactionId] {
            let newItem = CVSelectionItem(
                interaction: interaction,
                hasRenderableContent: hasRenderableContent,
                selectionType: oldItem.selectionType.subtracting(selectionType),
            )
            owsAssertDebug(oldItem.interactionId == newItem.interactionId)
            owsAssertDebug(oldItem.interactionType == newItem.interactionType)
            guard oldItem.selectionType != newItem.selectionType else {
                owsFailDebug("Did not change state.")
                return
            }
            if newItem.selectionType.isEmpty {
                itemMap.removeValue(forKey: interactionId)
            } else {
                itemMap[interactionId] = newItem
            }
        } else {
            owsFailDebug("Did not change state.")
            return
        }
        delegate?.selectionStateDidChange()
    }

    public func remove(itemViewModel: CVItemViewModel, selectionType: CVSelectionType) {
        remove(
            interaction: itemViewModel.interaction,
            hasRenderableContent: itemViewModel.hasRenderableContent,
            selectionType: selectionType,
        )
    }

    public func isSelected(_ interactionId: String, selectionType: CVSelectionType) -> Bool {
        AssertIsOnMainThread()

        guard let oldItem = itemMap[interactionId] else {
            return false
        }
        owsAssertDebug(oldItem.selectionType != .none)
        return oldItem.selectionType.contains(selectionType)
    }

    public func hasAnySelection(_ interactionId: String) -> Bool {
        AssertIsOnMainThread()

        guard let oldItem = itemMap[interactionId] else {
            return false
        }
        owsAssertDebug(oldItem.selectionType != .none)
        return true
    }

    public func hasAnySelection(interaction: TSInteraction) -> Bool {
        hasAnySelection(interaction.uniqueId)
    }

    public func reset() {
        AssertIsOnMainThread()

        guard !itemMap.isEmpty else {
            return
        }

        itemMap.removeAll()

        delegate?.selectionStateDidChange()
    }

    public var selectionItems: [CVSelectionItem] {
        AssertIsOnMainThread()

        return Array(itemMap.values)
    }
}

// MARK: -

extension CVSelectionState {

    public var selectionCanBeDeleted: Bool {
        guard !itemMap.isEmpty else {
            return false
        }
        for item in itemMap.values {
            switch item.interactionType {
            case .threadDetails, .unknownThreadWarning, .defaultDisappearingMessageTimer, .typingIndicator, .unreadIndicator, .dateHeader, .collapseSet:
                return false
            case .outgoingMessage where item.selectionType != .allContent:
                return false
            case .info, .error, .call:
                break
            case .incomingMessage, .outgoingMessage:
                break
            case .unknown:
                owsFailDebug("Unknown interaction type.")
                return false
            }
        }
        return true
    }

    public var selectionCanBeForwarded: Bool {
        guard !itemMap.isEmpty else {
            return false
        }
        let maxForwardCount: Int = 32
        guard itemMap.count <= maxForwardCount else {
            return false
        }
        for item in itemMap.values {
            guard item.isForwardable else {
                return false
            }

            switch item.interactionType {
            case .threadDetails, .unknownThreadWarning, .defaultDisappearingMessageTimer, .typingIndicator, .unreadIndicator, .dateHeader, .collapseSet:
                return false
            case .info, .error, .call:
                return false
            case .incomingMessage, .outgoingMessage:
                break
            case .unknown:
                owsFailDebug("Unknown interaction type.")
                return false
            }
        }
        return true
    }
}

// MARK: -

extension ConversationViewController {

    public func buildSelectionToolbar() -> MessageActionsToolbar {
        let deleteMessagesAction = MessageAction(
            .delete,
            accessibilityLabel: OWSLocalizedString(
                "MESSAGE_ACTION_DELETE_SELECTED_MESSAGES",
                comment: "accessibility label",
            ),
            accessibilityIdentifier: UIView.accessibilityIdentifier(
                containerName: "message_action",
                name: "delete_selected_messages",
            ),
            contextMenuTitle: "Delete Selected",
            contextMenuAttributes: [],
            block: { [weak self] _ in self?.didTapDeleteSelectedItems() },
        )
        let forwardMessagesAction = MessageAction(
            .forward,
            accessibilityLabel: OWSLocalizedString(
                "MESSAGE_ACTION_FORWARD_SELECTED_MESSAGES",
                comment: "Action sheet button title",
            ),
            accessibilityIdentifier: UIView.accessibilityIdentifier(
                containerName: "message_action",
                name: "forward_selected_messages",
            ),
            contextMenuTitle: "Forward Selected",
            contextMenuAttributes: [],
            block: { [weak self] _ in self?.didTapForwardSelectedItems() },
        )

        let toolbarMode = MessageActionsToolbar.Mode.selection(
            deleteMessagesAction: deleteMessagesAction,
            forwardMessagesAction: forwardMessagesAction,
        )
        let toolbar = MessageActionsToolbar(mode: toolbarMode)
        toolbar.actionDelegate = self
        return toolbar
    }

    func didTapDeleteSelectedItems() {
        let db = DependenciesBridge.shared.db
        let adminDeleteManager = DependenciesBridge.shared.adminDeleteManager

        let selectionItems = self.selectionState.selectionItems
        guard !selectionItems.isEmpty else {
            owsFailDebug("Invalid selection.")
            return
        }

        let alert = ActionSheetController(
            title: nil,
            message: String.localizedStringWithFormat(
                OWSLocalizedString(
                    "DELETE_SELECTED_MESSAGES_IN_CONVERSATION_ALERT_%d",
                    tableName: "PluralAware",
                    comment: "action sheet body. Embeds {{number of selected messages}} which will be deleted.",
                ),
                selectionItems.count,
            ),
        )
        alert.addAction(OWSActionSheets.cancelAction)

        let deleteForMeAction = ActionSheetAction(
            title: CommonStrings.deleteForMeButton,
            style: .destructive,
        ) { [weak self] _ in
            guard let self else { return }

            ModalActivityIndicatorViewController.present(
                fromViewController: self,
                title: CommonStrings.deletingModal,
                canCancel: false,
            ) { [weak self] modalActivityIndicator in
                guard let self else { return }
                let thread = self.thread

                await db.awaitableWrite { tx in
                    self.deleteSelectedItemsForMe(
                        selectionItems: selectionItems,
                        thread: thread,
                        tx: tx,
                    )
                }

                modalActivityIndicator.dismiss {
                    self.uiMode = .normal
                }
            }
        }
        alert.addAction(deleteForMeAction)

        var deleteType: AdminDeleteManager.DeleteType = []
        let canDeleteForEveryone: Bool = db.read { tx in
            selectionItems.allSatisfy { selectionItem in
                guard
                    let message = TSMessage.fetchMessageViaCache(
                        uniqueId: selectionItem.interactionId,
                        transaction: tx,
                    )
                else {
                    return false
                }

                if message.isIncoming {
                    deleteType.update(with: .admin)
                } else {
                    deleteType.update(with: .regular)
                }

                let canAdminDelete = adminDeleteManager.canAdminDeleteMessage(message: message, thread: thread, tx: tx)
                return message.canBeRemotelyDeletedByNonAdmin || canAdminDelete
            }
        }

        if canDeleteForEveryone, !thread.isTerminatedGroup {
            let deleteForEveryoneAction = ActionSheetAction(
                title: CommonStrings.deleteForEveryoneButton,
                style: .destructive,
            ) { [weak self] _ in
                guard let self else { return }
                TSInteraction.showDeleteForEveryoneConfirmationIfNecessary(
                    deleteType: deleteType,
                    completion: {
                        ModalActivityIndicatorViewController.present(
                            fromViewController: self,
                            title: CommonStrings.deletingModal,
                            canCancel: false,
                        ) { @MainActor [weak self] modalActivityIndicator in
                            guard let self else { return }
                            let thread = self.thread
                            await db.awaitableWrite { tx in
                                self.deleteSelectedItemsForEveryone(
                                    selectionItems: selectionItems,
                                    thread: thread,
                                    tx: tx,
                                )
                            }

                            modalActivityIndicator.dismiss {
                                self.uiMode = .normal
                            }
                        }
                    },
                )
            }
            alert.addAction(deleteForEveryoneAction)
        }

        present(alert, animated: true)
    }

    private func deleteSelectedItemsForMe(
        selectionItems: [CVSelectionItem],
        thread: TSThread,
        tx: DBWriteTransaction,
    ) {
        let interactionDeleteManager = DependenciesBridge.shared.interactionDeleteManager

        let interactionsToDelete = selectionItems.compactMap { item in
            TSInteraction.fetchViaCache(
                uniqueId: item.interactionId,
                transaction: tx,
            )
        }

        interactionDeleteManager.delete(
            interactions: interactionsToDelete,
            sideEffects: .custom(
                deleteForMeSyncMessage: .sendSyncMessage(interactionsThread: thread),
            ),
            tx: tx,
        )
    }

    private func deleteSelectedItemsForEveryone(
        selectionItems: [CVSelectionItem],
        thread: TSThread,
        tx: DBWriteTransaction,
    ) {
        let adminDeleteManager = DependenciesBridge.shared.adminDeleteManager

        guard !selectionItems.isEmpty else { return }
        guard let latestThread = TSThread.fetchViaCache(uniqueId: thread.uniqueId, transaction: tx) else {
            return owsFailDebug("Trying to delete messages without a thread.")
        }

        guard let localIdentifiers = DependenciesBridge.shared.tsAccountManager.localIdentifiers(tx: tx) else {
            return owsFailDebug("LocalIdentifiers missing during message deletion.")
        }

        selectionItems.forEach {
            guard
                let message = TSMessage.fetchMessageViaCache(
                    uniqueId: $0.interactionId,
                    transaction: tx,
                )
            else {
                return
            }

            let canAdminDelete = adminDeleteManager.canAdminDeleteMessage(message: message, thread: thread, tx: tx)

            guard
                let deleteMessage = TSInteraction.buildDeleteMessage(
                    thread: thread,
                    message: message,
                    localIdentifiers: localIdentifiers,
                    canAdminDelete: canAdminDelete,
                    tx: tx,
                )
            else {
                return owsFailDebug("Failure to build outgoing delete for everyone.")
            }

            // TODO: pending send state for incoming messages
            (message as? TSOutgoingMessage)?.updateWithRecipientAddressStates(
                deleteMessage.recipientAddressStates,
                tx: tx,
            )

            if message.canBeRemotelyDeletedByNonAdmin {
                do {
                    try TSMessage.tryToRemotelyDeleteMessageAsNonAdmin(
                        fromAuthor: localIdentifiers.aci,
                        sentAtTimestamp: message.timestamp,
                        threadUniqueId: latestThread.uniqueId,
                        serverTimestamp: 0, // TSOutgoingMessage won't have server timestamp.
                        transaction: tx,
                    )
                } catch {
                    return owsFailDebug("Unable to remotely delete message")
                }
            } else if
                canAdminDelete,
                let groupThread = thread as? TSGroupThread
            {
                let originalMessageAuthorAci: Aci?
                if let incomingMessage = (message as? TSIncomingMessage) {
                    originalMessageAuthorAci = incomingMessage.authorAddress.aci
                } else {
                    originalMessageAuthorAci = localIdentifiers.aci
                }

                guard let originalMessageAuthorAci else {
                    owsFailDebug("Unable to admin delete without original message author")
                    return
                }
                do {
                    try DependenciesBridge.shared.adminDeleteManager.tryToAdminDeleteMessage(
                        originalMessageAuthorAci: originalMessageAuthorAci,
                        deleteAuthorAci: localIdentifiers.aci,
                        sentAtTimestamp: message.timestamp,
                        groupThread: groupThread,
                        threadUniqueId: latestThread.uniqueId,
                        serverTimestamp: 0, // TSOutgoingMessage won't have server timestamp.
                        transaction: tx,
                    )
                } catch {
                    return owsFailDebug("Unable to remotely delete message")
                }
            } else {
                owsFailDebug("Unable to delete as admin or as non-admin")
                return
            }

            let preparedMessage = PreparedOutgoingMessage.preprepared(
                transientMessageWithoutAttachments: deleteMessage,
            )

            SSKEnvironment.shared.messageSenderJobQueueRef.add(message: preparedMessage, transaction: tx)
        }
    }

    func didTapForwardSelectedItems() {
        AssertIsOnMainThread()

        let selectionItems = self.selectionState.selectionItems
        guard !selectionItems.isEmpty else {
            owsFailDebug("Invalid selection.")
            return
        }
        ForwardMessageViewController.present(forSelectionItems: selectionItems, from: self, delegate: self)
    }

    public func updateSelectionButtons() {
        guard let selectionToolbar = self.selectionToolbar else {
            owsFailDebug("Missing selectionToolbar.")
            return
        }

        selectionToolbar.updateContent()

        if let deleteButton = selectionToolbar.buttonItem(for: .delete) {
            deleteButton.isEnabled = (
                uiMode == .selection &&
                    selectionState.selectionCanBeDeleted,
            )
        } else {
            owsFailDebug("deleteButton was unexpectedly nil")
            return
        }

        if let forwardButton = selectionToolbar.buttonItem(for: .forward) {
            forwardButton.isEnabled = (
                uiMode == .selection &&
                    selectionState.selectionCanBeForwarded,
            )
        } else {
            owsFailDebug("forwardButton was unexpectedly nil")
            return
        }
    }
}

// MARK: - Selection

extension ConversationViewController {

    var cancelSelectionBarButtonItem: UIBarButtonItem {
        .cancelButton { [weak self] in
            self?.uiMode = .normal
        }
    }

    var deleteAllBarButtonItem: UIBarButtonItem {
        return .button(
            title: OWSLocalizedString(
                "CONVERSATION_VIEW_DELETE_ALL_MESSAGES",
                comment: "button text to delete all items in the current conversation",
            ),
            style: .plain,
            action: { [weak self] in
                self?.didTapDeleteAll()
            },
        )
    }

    func didTapDeleteAll() {
        let db = DependenciesBridge.shared.db
        let threadSoftDeleteManager = DependenciesBridge.shared.threadSoftDeleteManager

        let thread = self.thread
        let alert = ActionSheetController(title: nil, message: OWSLocalizedString("DELETE_ALL_MESSAGES_IN_CONVERSATION_ALERT_BODY", comment: "action sheet body"))
        alert.addAction(OWSActionSheets.cancelAction)
        let deleteTitle = OWSLocalizedString("DELETE_ALL_MESSAGES_IN_CONVERSATION_BUTTON", comment: "button text")
        let delete = ActionSheetAction(title: deleteTitle, style: .destructive) { [weak self] _ in
            guard let self else { return }
            ModalActivityIndicatorViewController.present(
                fromViewController: self,
                title: CommonStrings.deletingModal,
                canCancel: false,
            ) { [weak self] modal in
                guard let self else { return }
                db.write {
                    threadSoftDeleteManager.removeAllInteractions(
                        thread: thread,
                        sendDeleteForMeSyncMessage: true,
                        tx: $0,
                    )
                }
                DispatchQueue.main.async {
                    modal.dismiss { [weak self] in
                        guard let self else { return }
                        self.uiMode = .normal
                    }
                }
            }
        }
        alert.addAction(delete)
        present(alert, animated: true)
    }
}

// MARK: -

extension ConversationViewController: CVSelectionStateDelegate {
    public func selectionStateDidChange() {
        AssertIsOnMainThread()

        updateSelectionButtons()
    }
}