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

import LibSignalClient
public import SignalServiceKit

public protocol RecipientContextMenuHelperDelegate: AnyObject {
    func additionalActions(for address: SignalServiceAddress) -> [UIAction]
    func additionalActions(for groupThread: TSGroupThread) -> [UIAction]
}

public extension RecipientContextMenuHelperDelegate {
    func additionalActions(for address: SignalServiceAddress) -> [UIAction] {
        []
    }

    func additionalActions(for groupThread: TSGroupThread) -> [UIAction] { [] }
}

class RecipientContextMenuHelper {

    private let databaseStorage: SDSDatabaseStorage
    private let recipientHidingManager: RecipientHidingManager
    // TODO: When BlockingManager is protocolized, this should be the protocol type.
    private let blockingManager: BlockingManager
    private let accountManager: TSAccountManager
    private let contactsManager: any ContactManager

    /// The view controller from which to present action
    /// sheets and toasts.
    private weak var fromViewController: UIViewController?

    weak var delegate: (any RecipientContextMenuHelperDelegate)?

    /// Initializer. This helper must be retained for as
    /// long as context menu display should be possible.
    init(
        databaseStorage: SDSDatabaseStorage,
        blockingManager: BlockingManager,
        recipientHidingManager: RecipientHidingManager,
        accountManager: TSAccountManager,
        contactsManager: any ContactManager,
        fromViewController: UIViewController,
        delegate: (any RecipientContextMenuHelperDelegate)? = nil,
    ) {
        self.databaseStorage = databaseStorage
        self.blockingManager = blockingManager
        self.recipientHidingManager = recipientHidingManager
        self.accountManager = accountManager
        self.contactsManager = contactsManager
        self.fromViewController = fromViewController
        self.delegate = delegate
    }

    /// Returns the `UIContextMenuActionProvider` used to configure
    /// a system context menu for a recipient with the given `address`.
    func actionProvider(address: SignalServiceAddress) -> UIContextMenuActionProvider {
        return { [weak self] _ in
            guard
                let self,
                let fromViewController = self.fromViewController
            else {
                return nil
            }
            let localAddress: SignalServiceAddress? = self.databaseStorage.read { [weak self] tx in
                guard let self else { return nil }
                return self.accountManager.localIdentifiers(tx: tx)?.aciAddress
            }
            guard
                let localAddress,
                !localAddress.isEqualToAddress(address)
            else {
                /// There may come a day when the recipient context menu has
                /// menu items that should be available for Note to Self, at
                /// which point this should no longer return nil.
                return nil
            }
            let additionalActions = self.delegate?.additionalActions(for: address) ?? []
            return UIMenu(children: additionalActions + [
                self.removeAction(address: address, fromViewController: fromViewController),
                self.blockAction(address: address, fromViewController: fromViewController),
            ])
        }
    }

    func actionProvider(groupThread: TSGroupThread) -> UIContextMenuActionProvider {
        { [weak self] _ in
            guard
                let self,
                let fromViewController = self.fromViewController
            else {
                return nil
            }
            let additionalActions = self.delegate?.additionalActions(for: groupThread) ?? []
            return UIMenu(children: additionalActions + [
                self.blockAction(thread: groupThread, fromViewController: fromViewController),
            ])
        }
    }

    // MARK: Block

    private var blockActionTitle: String {
        OWSLocalizedString(
            "RECIPIENT_CONTEXT_MENU_BLOCK_TITLE",
            comment: "The title for a context menu item that blocks a recipient from your recipient picker list.",
        )
    }

    /// Returns context menu action for blocking a recipient.
    ///
    /// - Parameter address: Address of the recipient.
    /// - Parameter fromViewController: The view controller from which to present the action sheet.
    ///
    /// - Returns: A Block UIAction.
    private func blockAction(
        address: SignalServiceAddress,
        fromViewController: UIViewController,
    ) -> UIAction {
        UIAction(
            title: blockActionTitle,
            image: UIImage(named: "block"),
            attributes: .destructive,
        ) { _ in
            BlockListUIUtils.showBlockAddressActionSheet(
                address,
                from: fromViewController,
                completion: nil,
            )
        }
    }

    private func blockAction(
        thread: TSThread,
        fromViewController: UIViewController,
    ) -> UIAction {
        UIAction(
            title: blockActionTitle,
            image: UIImage(named: "block"),
            attributes: .destructive,
        ) { _ in
            BlockListUIUtils.showBlockThreadActionSheet(
                thread,
                from: fromViewController,
                completion: nil,
            )
        }
    }

    // MARK: Remove contact

    /// Returns context menu action for hiding a recipient.
    ///
    /// - Parameter address: Address of the recipient.
    /// - Parameter fromViewController: The view controller from which to present the action sheet.
    ///
    /// - Returns: A Remove UIAction.
    private func removeAction(
        address: SignalServiceAddress,
        fromViewController: UIViewController,
    ) -> UIAction {
        let title = OWSLocalizedString(
            "RECIPIENT_CONTEXT_MENU_REMOVE_TITLE",
            comment: "The title for a context menu item that removes a recipient from your recipient picker list.",
        )
        return UIAction(
            title: title,
            image: UIImage(named: "minus-circle"),
        ) { [weak self] _ in
            guard let self else { return }
            if let e164 = address.e164, self.isSystemContact(e164: e164) {
                self.displayViewContactActionSheet(
                    address: address,
                    e164: e164,
                    fromViewController: fromViewController,
                )
            } else {
                self.displayHideRecipientActionSheet(
                    address: address,
                    fromViewController: fromViewController,
                )
            }
        }
    }

    /// Whether the given `e164` corresponds with a system contact.
    private func isSystemContact(e164: E164) -> Bool {
        return databaseStorage.read { tx in
            return contactsManager.fetchSignalAccount(forPhoneNumber: e164.stringValue, transaction: tx) != nil
        }
    }

    private enum Constants {
        static let toastInset = 8.0
    }

    /// Displays an action sheet confirming that the user wants to hide
    /// the indicated recipient.
    ///
    /// - Parameter address: Address of the recipient to hide.
    /// - Parameter fromViewController: The view controller from which to present the action sheet.
    private func displayHideRecipientActionSheet(
        address: SignalServiceAddress,
        fromViewController: UIViewController,
    ) {
        guard address.isValid else {
            owsFailDebug("Invalid address: \(address).")
            return
        }
        let (localAddress, recipientDisplayName) = databaseStorage.read { tx in
            let localAddress = accountManager.localIdentifiers(tx: tx)?.aciAddress
            let recipientDisplayName = contactsManager.displayName(for: address, tx: tx)
                .resolvedValue().formattedForActionSheetTitle()
            return (localAddress, recipientDisplayName)
        }
        guard
            let localAddress,
            !localAddress.isEqualToAddress(address)
        else {
            owsFailDebug("Remove recipient option should not have been shown in context menu for Note to Self, so we shouldn't be able to get here.")
            return
        }
        let actionSheetTitle = String.nonPluralLocalizedStringWithFormat(
            OWSLocalizedString(
                "HIDE_RECIPIENT_ACTION_SHEET_TITLE_FORMAT",
                comment: "A format for the 'remove user' action sheet title. Embeds {{the removed user's name or phone number}}.",
            ),
            recipientDisplayName,
        )

        let actionSheet = ActionSheetController(
            title: actionSheetTitle,
            message: OWSLocalizedString(
                "HIDE_RECIPIENT_BEHAVIOR_EXPLANATION",
                comment: "An explanation of the consequences of removing a user.",
            ),
        )

        actionSheet.addAction(ActionSheetAction(
            title: OWSLocalizedString("HIDE_RECIPIENT_BUTTON", comment: "Button label for the 'remove' button"),
            style: .destructive,
            handler: { [weak self] _ in
                guard let self else { return }
                let result: Result<Void, Error> = self.databaseStorage.write { tx in
                    do {
                        try self.recipientHidingManager.addHiddenRecipient(
                            address,
                            inKnownMessageRequestState: false,
                            wasLocallyInitiated: true,
                            tx: tx,
                        )
                        return .success(())
                    } catch {
                        return .failure(error)
                    }
                }

                switch result {
                case .success(()):
                    self.displaySuccessToast(
                        fromViewController: fromViewController,
                        displayName: recipientDisplayName,
                    )
                case .failure(let error):
                    /// This `error` is of the custom type ``RecipientHidingError``.
                    /// We do not currently handle the various errors differently
                    /// in the UI (Design wants to use the generic error message),
                    /// but we do have the capability if we wanted to add per-error
                    /// localized Strings in the future.
                    Logger.warn("[Recipient Hiding] Error: \(error)")
                    self.displayErrorActionSheet(
                        fromViewController: fromViewController,
                        displayName: recipientDisplayName,
                    )
                }
            },
        ))
        actionSheet.addAction(ActionSheetAction(
            title: CommonStrings.cancelButton,
            style: .cancel,
        ))
        fromViewController.presentActionSheet(actionSheet)
    }

    /// Displays an action sheet letting the user know that
    /// they cannot remove the indicated recipient without
    /// first removing them from their system contacts.
    ///
    /// - Parameter address: Address of the recipient to hide.
    /// - Parameter e164: Phone number of the recipient to hide.
    /// - Parameter fromViewController: The view controller from which to present the action sheet.
    private func displayViewContactActionSheet(
        address: SignalServiceAddress,
        e164: E164,
        fromViewController: UIViewController,
    ) {
        let (
            isPrimaryDevice,
            localAddress,
            recipientDisplayName,
        ) = databaseStorage.read { tx in
            let localAddress = accountManager.localIdentifiers(tx: tx)?.aciAddress
            let recipientDisplayName = contactsManager.displayName(for: address, tx: tx)
                .resolvedValue().formattedForActionSheetTitle()
            return (
                accountManager.registrationState(tx: tx).isPrimaryDevice ?? true,
                localAddress,
                recipientDisplayName,
            )
        }
        guard
            let localAddress,
            !localAddress.isEqualToAddress(address)
        else {
            owsFailDebug("Remove recipient option should not have been shown in context menu for Note to Self, so we shouldn't be able to get here.")
            return
        }
        let actionSheetTitle = String.nonPluralLocalizedStringWithFormat(
            OWSLocalizedString(
                "HIDE_RECIPIENT_IMPASS_BECAUSE_SYSTEM_CONTACT_ACTION_SHEET_TITLE",
                comment: "A format for the 'unable to remove user' action sheet title. Embeds {{the removed user's name or phone number}}.",
            ),
            recipientDisplayName,
        )

        let actionSheetMessage: String
        let removeAction: ActionSheetAction?
        if isPrimaryDevice {
            actionSheetMessage = OWSLocalizedString(
                "HIDE_RECIPIENT_IMPASS_BECAUSE_SYSTEM_CONTACT_ACTION_SHEET_EXPLANATION",
                comment: "An explanation of why the user cannot be removed.",
            )
            removeAction = ActionSheetAction(
                title: OWSLocalizedString("VIEW_CONTACT_BUTTON", comment: "Button label for the 'View Contact' button"),
                handler: { [weak self] _ in
                    guard let self else { return }
                    self.displayDeleteContactViewController(
                        e164: e164,
                        serviceId: address.serviceId,
                        fromViewController: fromViewController,
                    )
                },
            )
        } else {
            actionSheetMessage = OWSLocalizedString(
                "HIDE_RECIPIENT_IMPOSSIBLE_BECAUSE_SYSTEM_CONTACT_ACTION_SHEET_EXPLANATION",
                comment: "An explanation of why the user cannot be removed on a linked device.",
            )
            removeAction = nil
        }

        let actionSheet = ActionSheetController(
            title: actionSheetTitle,
            message: actionSheetMessage,
        )

        if let removeAction {
            actionSheet.addAction(removeAction)
        }
        actionSheet.addAction(ActionSheetAction(
            title: CommonStrings.okayButton,
            style: .cancel,
        ))
        fromViewController.presentActionSheet(actionSheet)
    }

    /// Displays a view controller with a simplified contact
    /// view and the option to delete this contact.
    ///
    /// - Parameter e164: The phone number of the contact to
    ///   potentially be deleted.
    /// - Parameter fromViewController: The view controller
    ///   from which to present this contact deletion view
    ///   controller.
    private func displayDeleteContactViewController(
        e164: E164,
        serviceId: ServiceId?,
        fromViewController: UIViewController,
    ) {
        let deleteContactViewController = DeleteSystemContactViewController(
            e164: e164,
            serviceId: serviceId,
            viewControllerPresentingToast: fromViewController,
            contactsManager: contactsManager,
            databaseStorage: databaseStorage,
            recipientHidingManager: recipientHidingManager,
            tsAccountManager: accountManager,
        )
        let navigationController = OWSNavigationController()
        navigationController.setViewControllers([deleteContactViewController], animated: false)
        fromViewController.presentFormSheet(navigationController, animated: true)
    }

    /// Displays a toast confirming the successful hide of a recipient.
    ///
    /// - Parameter fromViewController: The view controller from which to
    ///   present the toast.
    /// - Parameter displayName: The the display name of the user who has
    ///   been hidden.
    private func displaySuccessToast(
        fromViewController: UIViewController,
        displayName: String,
    ) {
        let toastMessage = String.nonPluralLocalizedStringWithFormat(
            OWSLocalizedString(
                "HIDE_RECIPIENT_CONFIRMATION_TOAST",
                comment: "Toast message confirming the recipient was removed. Embeds {{The name of the user who was removed.}}..",
            ),
            displayName,
        )
        ToastController(
            text: toastMessage,
            image: UIImage(named: "person-minus-compact-light"),
        ).presentToastView(
            from: .bottom,
            of: fromViewController.view,
            inset: fromViewController.view.safeAreaInsets.bottom + Constants.toastInset,
        )
    }

    /// Displays an action sheet notifying the user that their attempt to
    /// hide a recipient failed.
    ///
    /// - Parameter fromViewController: The view controller from which to
    ///   present the action sheet.
    /// - Parameter displayName: The display name of the recipient the user
    ///   attempted to hide.
    private func displayErrorActionSheet(
        fromViewController: UIViewController,
        displayName: String,
    ) {
        let errorActionSheetTitle = String.nonPluralLocalizedStringWithFormat(
            OWSLocalizedString(
                "HIDE_RECIPIENT_ERROR_ACTION_SHEET_TITLE_FORMAT",
                comment: "Title for an action sheet indicating that the user was not successfully removed. Embeds {{name of user we attempted to hide}}.",
            ),
            displayName,
        )
        let errorActionSheet = ActionSheetController(
            title: errorActionSheetTitle,
            message: OWSLocalizedString(
                "HIDE_RECIPIENT_ERROR_ACTION_SHEET_EXPLANATION",
                comment: "An explanation of why a user was not successfully removed and what to do.",
            ),
        )
        errorActionSheet.addAction(ActionSheetAction(
            title: CommonStrings.okayButton,
        ))
        fromViewController.presentActionSheet(errorActionSheet)
    }
}