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

import LibSignalClient
import SafariServices
import SignalServiceKit
import SignalUI

extension ConversationViewController: MessageRequestDelegate {
    func messageRequestViewDidTapBlock() {
        AssertIsOnMainThread()

        let blockSheet = createBlockThreadActionSheet()
        presentActionSheet(blockSheet)
    }

    func messageRequestViewDidTapReport() {
        AssertIsOnMainThread()

        let reportSheet = createReportThreadActionSheet()
        presentActionSheet(reportSheet)
    }

    func messageRequestViewDidTapAccept(mode: MessageRequestMode, unblockThread: Bool, unhideRecipient: Bool) {
        AssertIsOnMainThread()

        let messageFormat = OWSLocalizedString(
            "MESSAGE_REQUEST_CONFIRM_ACCEPT_MESSAGE",
            comment: "Message for an action sheet asking the user to confirm if they want to accept a message request. {{ Embeds 'Signal will never' in bolded text }}",
        )

        let embeddedMessage = OWSLocalizedString(
            "MESSAGE_REQUEST_CONFIRM_ACCEPT_MESSAGE_EMBEDDED_BOLD_TEXT",
            comment: "Embedded text in the message for an action sheet asking the user to confirm if they want to accept a message request.",
        )

        let message = NSAttributedString.make(
            fromFormat: messageFormat,
            attributedFormatArgs: [
                .string(
                    embeddedMessage,
                    attributes: [
                        .foregroundColor: UIColor.Signal.label,
                        .font: UIFont.dynamicTypeBody.semibold(),
                    ],
                ),
            ],
            defaultAttributes: [
                .foregroundColor: UIColor.Signal.label,
                .font: UIFont.dynamicTypeBody,
            ],
        )

        OWSActionSheets.showConfirmationAlert(
            title: OWSLocalizedString("MESSAGE_REQUEST_CONFIRM_ACCEPT_TITLE", comment: "Title for an action sheet asking the user to confirm if they want to accept a message request"),
            message: message,
            proceedTitle: OWSLocalizedString(
                "MESSAGE_REQUEST_VIEW_ACCEPT_BUTTON",
                comment: "A button used to accept a user on an incoming message request.",
            ),
            proceedAction: { _ in
                let thread = self.thread
                Task {
                    await self.acceptMessageRequest(in: thread, mode: mode, unblockThread: unblockThread, unhideRecipient: unhideRecipient)
                }
            },
        )
    }

    func messageRequestViewDidTapDelete() {
        AssertIsOnMainThread()

        let deleteSheet = createDeleteThreadActionSheet()
        presentActionSheet(deleteSheet)
    }

    func messageRequestViewDidTapUnblock(mode: MessageRequestMode) {
        AssertIsOnMainThread()

        let threadName: String
        let message: String
        if let groupThread = thread as? TSGroupThread {
            threadName = groupThread.groupNameOrDefault
            message = OWSLocalizedString(
                "BLOCK_LIST_UNBLOCK_GROUP_MESSAGE",
                comment: "An explanation of what unblocking a group means.",
            )
        } else if let contactThread = thread as? TSContactThread {
            threadName = SSKEnvironment.shared.databaseStorageRef.read { tx in
                return SSKEnvironment.shared.contactManagerRef.displayName(for: contactThread.contactAddress, tx: tx).resolvedValue()
            }
            message = OWSLocalizedString(
                "BLOCK_LIST_UNBLOCK_CONTACT_MESSAGE",
                comment: "An explanation of what unblocking a contact means.",
            )
        } else {
            owsFailDebug("Invalid thread.")
            return
        }

        let title = String.nonPluralLocalizedStringWithFormat(
            OWSLocalizedString(
                "BLOCK_LIST_UNBLOCK_TITLE_FORMAT",
                comment: "A format for the 'unblock conversation' action sheet title. Embeds the {{conversation title}}.",
            ),
            threadName,
        )

        OWSActionSheets.showConfirmationAlert(
            title: title,
            message: message,
            proceedTitle: OWSLocalizedString(
                "BLOCK_LIST_UNBLOCK_BUTTON",
                comment: "Button label for the 'unblock' button",
            ),
        ) { _ in
            self.messageRequestViewDidTapAccept(mode: mode, unblockThread: true, unhideRecipient: true)
        }
    }

    func messageRequestViewDidTapLearnMore() {
        AssertIsOnMainThread()

        let safariVC = SFSafariViewController(url: URL.Support.profilesAndMessageRequests)
        present(safariVC, animated: true)
    }
}

private extension ConversationViewController {
    func blockThread() {
        // Leave the group while blocking the thread.
        SSKEnvironment.shared.databaseStorageRef.write { transaction in
            SSKEnvironment.shared.blockingManagerRef.addBlockedThread(
                thread,
                blockMode: .local,
                shouldLeaveIfGroup: true,
                transaction: transaction,
            )
            SSKEnvironment.shared.syncManagerRef.sendMessageRequestResponseSyncMessage(
                thread: thread,
                responseType: .block,
                transaction: transaction,
            )
        }
        NotificationCenter.default.post(name: ChatListViewController.clearSearch, object: nil)
    }

    func blockThreadAndDelete() {
        // Do not leave the group while blocking the thread; we'll
        // that below so that we can surface an error to the user
        // if leaving the group fails.
        SSKEnvironment.shared.databaseStorageRef.write { transaction in
            SSKEnvironment.shared.blockingManagerRef.addBlockedThread(
                thread,
                blockMode: .local,
                shouldLeaveIfGroup: false,
                transaction: transaction,
            )
        }
        leaveAndSoftDeleteThread(messageRequestResponseType: .blockAndDelete)
    }

    func blockThreadAndReportSpam(in thread: TSThread) {
        let spamReport = SSKEnvironment.shared.databaseStorageRef.write { tx in
            return ReportSpamUIUtils.blockAndBuildSpamReport(in: thread, tx: tx)
        }
        Task {
            try? await spamReport?.submit(using: SSKEnvironment.shared.networkManagerRef)
        }

        presentToastCVC(ReportSpamUIUtils.successfulReportText(didBlock: true))
        NotificationCenter.default.post(name: ChatListViewController.clearSearch, object: nil)
    }

    func leaveAndSoftDeleteThread(
        messageRequestResponseType: OutgoingMessageRequestResponseSyncMessage.ResponseType,
    ) {
        AssertIsOnMainThread()

        let databaseStorage = SSKEnvironment.shared.databaseStorageRef
        let syncManager = SSKEnvironment.shared.syncManagerRef

        databaseStorage.write { tx in
            syncManager.sendMessageRequestResponseSyncMessage(
                thread: self.thread,
                responseType: messageRequestResponseType,
                transaction: tx,
            )
        }

        let completion = {
            databaseStorage.write { transaction in
                DependenciesBridge.shared.threadSoftDeleteManager.softDelete(
                    threads: [self.thread],
                    // We're already sending a sync message about this above!
                    sendDeleteForMeSyncMessage: false,
                    tx: transaction,
                )
            }
            self.conversationSplitViewController?.closeSelectedConversation(animated: true)
            NotificationCenter.default.post(name: ChatListViewController.clearSearch, object: nil)
        }

        guard let groupThread = thread as? TSGroupThread, groupThread.groupModel.groupMembership.isLocalUserFullOrInvitedMember else {
            // If we don't need to leave the group, finish up immediately.
            return completion()
        }

        // Leave the group if we're a member.
        GroupManager.leaveGroupOrDeclineInviteAsyncWithUI(groupThread: groupThread, fromViewController: self, success: completion)
    }

    /// Accept a message request, or unblock chat.
    ///
    /// It's not obvious, but the "message request" UI is shown when a chat is
    /// blocked. However, the "blocked chat" UI only has the option to delete a
    /// chat or unblock. If the user selects "unblock", we end up here with
    /// `unblockThread: true`.
    func acceptMessageRequest(
        in thread: TSThread,
        mode: MessageRequestMode,
        unblockThread: Bool,
        unhideRecipient: Bool,
    ) async {
        switch mode {
        case .none:
            owsFailDebug("Invalid mode.")
            return
        case .contactOrGroupRequest:
            break
        case .groupInviteRequest:
            guard let groupThread = thread as? TSGroupThread else {
                owsFailDebug("Invalid thread.")
                return
            }
            do {
                try await GroupManager.acceptGroupInviteWithModal(groupThread, fromViewController: self)
            } catch {
                owsFailDebug("Couldn't accept group invite: \(error)")
                return
            }
        }

        let blockingManager = SSKEnvironment.shared.blockingManagerRef
        let hidingManager = DependenciesBridge.shared.recipientHidingManager
        let profileManager = SSKEnvironment.shared.profileManagerRef
        let recipientFetcher = DependenciesBridge.shared.recipientFetcher

        func unblockThreadIfNeeded(transaction: DBWriteTransaction) {
            if unblockThread {
                blockingManager.removeBlockedThread(
                    thread,
                    wasLocallyInitiated: true,
                    transaction: transaction,
                )
            }
        }

        func acceptMessageRequestIfNeeded(transaction: DBWriteTransaction) {
            /// If we're not in "unblock" mode, we should take "accept message
            /// request" actions. (Bleh.)
            if !unblockThread {
                /// Insert an info message indicating that we accepted.
                DependenciesBridge.shared.interactionStore.insertInteraction(
                    TSInfoMessage(
                        thread: thread,
                        messageType: .acceptedMessageRequest,
                    ),
                    tx: transaction,
                )

                /// Send a sync message telling our other devices that we
                /// accepted.
                SSKEnvironment.shared.syncManagerRef.sendMessageRequestResponseSyncMessage(
                    thread: thread,
                    responseType: .accept,
                    transaction: transaction,
                )
            }
        }

        await SSKEnvironment.shared.databaseStorageRef.awaitableWrite { transaction in
            switch thread {
            case let thread as TSGroupThread:
                unblockThreadIfNeeded(transaction: transaction)
                acceptMessageRequestIfNeeded(transaction: transaction)
                profileManager.addGroupId(
                    toProfileWhitelist: thread.groupModel.groupId,
                    userProfileWriter: .localUser,
                    transaction: transaction,
                )

            case let thread as TSContactThread:
                unblockThreadIfNeeded(transaction: transaction)
                // Might be nil if thread.contactAddress isn't valid.
                var recipient = recipientFetcher.fetchOrCreate(address: thread.contactAddress, tx: transaction)
                if var innerRecipient = recipient {
                    if unhideRecipient, !thread.contactAddress.isLocalAddress {
                        hidingManager.removeHiddenRecipient(&innerRecipient, wasLocallyInitiated: true, tx: transaction)
                    }
                    recipient = innerRecipient
                }
                acceptMessageRequestIfNeeded(transaction: transaction)
                if var innerRecipient = recipient {
                    profileManager.addRecipientToProfileWhitelist(&innerRecipient, userProfileWriter: .localUser, tx: transaction)
                    recipient = innerRecipient
                }
                // If this is a contact thread, we should give the
                // now-unblocked contact our profile key.
                let profileKeyMessage = ProfileKeyMessage(
                    thread: thread,
                    profileKey: profileManager.localProfileKey(tx: transaction)!,
                    tx: transaction,
                )
                let preparedMessage = PreparedOutgoingMessage.preprepared(
                    transientMessageWithoutAttachments: profileKeyMessage,
                )
                SSKEnvironment.shared.messageSenderJobQueueRef.add(message: preparedMessage, transaction: transaction)

            default:
                owsFailDebug("can't accept message request for \(type(of: thread))")
            }

            NotificationCenter.default.post(name: ChatListViewController.clearSearch, object: nil)
        }
    }
}

// MARK: - Action Sheets

extension ConversationViewController {

    func createBlockThreadActionSheet(sheetCompletion: ((Bool) -> Void)? = nil) -> ActionSheetController {
        Logger.info("")

        let actionSheetTitleFormat: String
        let actionSheetMessage: String
        if thread.isGroupThread {
            actionSheetTitleFormat = OWSLocalizedString(
                "MESSAGE_REQUEST_BLOCK_GROUP_TITLE_FORMAT",
                comment: "Action sheet title to confirm blocking a group via a message request. Embeds {{group name}}",
            )
            actionSheetMessage = OWSLocalizedString(
                "MESSAGE_REQUEST_BLOCK_GROUP_MESSAGE",
                comment: "Action sheet message to confirm blocking a group via a message request.",
            )
        } else {
            actionSheetTitleFormat = OWSLocalizedString(
                "MESSAGE_REQUEST_BLOCK_CONVERSATION_TITLE_FORMAT",
                comment: "Action sheet title to confirm blocking a contact via a message request. Embeds {{contact name or phone number}}",
            )
            actionSheetMessage = OWSLocalizedString(
                "MESSAGE_REQUEST_BLOCK_CONVERSATION_MESSAGE",
                comment: "Action sheet message to confirm blocking a conversation via a message request.",
            )
        }

        let (threadName, hasReportedSpam) = SSKEnvironment.shared.databaseStorageRef.read { tx in
            let threadName = SSKEnvironment.shared.contactManagerRef.displayName(for: thread, transaction: tx)
            let finder = InteractionFinder(threadUniqueId: thread.uniqueId)
            let hasReportedSpam = finder.hasUserReportedSpam(transaction: tx)
            return (threadName, hasReportedSpam)
        }
        let actionSheetTitle = String.nonPluralLocalizedStringWithFormat(actionSheetTitleFormat, threadName)
        let actionSheet = ActionSheetController(title: actionSheetTitle, message: actionSheetMessage)

        let blockActionTitle = OWSLocalizedString(
            "MESSAGE_REQUEST_BLOCK_ACTION",
            comment: "Action sheet action to confirm blocking a thread via a message request.",
        )
        let blockAndDeleteActionTitle = OWSLocalizedString(
            "MESSAGE_REQUEST_BLOCK_AND_DELETE_ACTION",
            comment: "Action sheet action to confirm blocking and deleting a thread via a message request.",
        )
        let blockAndReportSpamActionTitle = OWSLocalizedString(
            "MESSAGE_REQUEST_BLOCK_AND_REPORT_SPAM_ACTION",
            comment: "Action sheet action to confirm blocking and reporting spam for a thread via a message request.",
        )

        actionSheet.addAction(ActionSheetAction(title: blockActionTitle) { [weak self] _ in
            self?.blockThread()
            sheetCompletion?(true)
        })

        if !hasReportedSpam {
            actionSheet.addAction(ActionSheetAction(title: blockAndReportSpamActionTitle) { [weak self] _ in
                guard let self else { return }
                self.blockThreadAndReportSpam(in: self.thread)
                sheetCompletion?(true)
            })
        } else {
            actionSheet.addAction(ActionSheetAction(title: blockAndDeleteActionTitle) { [weak self] _ in
                self?.blockThreadAndDelete()
                sheetCompletion?(true)
            })
        }

        actionSheet.addAction(ActionSheetAction(title: CommonStrings.cancelButton, style: .cancel, handler: { _ in
            sheetCompletion?(false)
        }))
        return actionSheet
    }

    func createDeleteThreadActionSheet() -> ActionSheetController {
        let actionSheetTitle: String
        let actionSheetMessage: String
        let confirmationText: String

        var isMemberOfGroup = false
        if let groupThread = thread as? TSGroupThread {
            isMemberOfGroup = groupThread.groupModel.groupMembership.isLocalUserMemberOfAnyKind
        }

        if isMemberOfGroup {
            actionSheetTitle = OWSLocalizedString(
                "MESSAGE_REQUEST_LEAVE_AND_DELETE_GROUP_TITLE",
                comment: "Action sheet title to confirm deleting a group via a message request.",
            )
            actionSheetMessage = OWSLocalizedString(
                "MESSAGE_REQUEST_LEAVE_AND_DELETE_GROUP_MESSAGE",
                comment: "Action sheet message to confirm deleting a group via a message request.",
            )
            confirmationText = OWSLocalizedString(
                "MESSAGE_REQUEST_LEAVE_AND_DELETE_GROUP_ACTION",
                comment: "Action sheet action to confirm deleting a group via a message request.",
            )
        } else { // either 1:1 thread, or a group of which I'm not a member
            actionSheetTitle = OWSLocalizedString(
                "MESSAGE_REQUEST_DELETE_CONVERSATION_TITLE",
                comment: "Action sheet title to confirm deleting a conversation via a message request.",
            )
            actionSheetMessage = OWSLocalizedString(
                "MESSAGE_REQUEST_DELETE_CONVERSATION_MESSAGE",
                comment: "Action sheet message to confirm deleting a conversation via a message request.",
            )
            confirmationText = OWSLocalizedString(
                "MESSAGE_REQUEST_DELETE_CONVERSATION_ACTION",
                comment: "Action sheet action to confirm deleting a conversation via a message request.",
            )
        }

        let actionSheet = ActionSheetController(title: actionSheetTitle, message: actionSheetMessage)
        actionSheet.addAction(ActionSheetAction(title: confirmationText, handler: { _ in
            self.leaveAndSoftDeleteThread(messageRequestResponseType: .delete)
        }))
        actionSheet.addAction(ActionSheetAction(title: CommonStrings.cancelButton, style: .cancel))
        return actionSheet
    }

    // TODO[SPAM]: For groups, fetch the inviter to add to the message
    func createReportThreadActionSheet() -> ActionSheetController {
        return ReportSpamUIUtils.createReportSpamActionSheet(
            for: thread,
            isBlocked: threadViewModel.isBlocked,
        )
    }
}

extension ConversationViewController: NameCollisionResolutionDelegate {

    func nameCollisionControllerDidComplete(_ controller: NameCollisionResolutionViewController, dismissConversationView: Bool) {
        if dismissConversationView {
            // This may have already been closed (e.g. if the user requested deletion), but
            // it's not guaranteed (e.g. the user blocked the request). Let's close it just
            // to be safe.
            self.conversationSplitViewController?.closeSelectedConversation(animated: false)
        } else {
            // Conversation view is being kept around. Update the banner state to account for any changes
            ensureBannerState()
        }
        controller.dismiss(animated: true, completion: nil)
    }
}