// // 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 declineMessageRequest(responseType: OutgoingMessageRequestResponseSyncMessage.ResponseType) { MessageRequestDecliner.declineMessageRequest( inThread: self.thread, responseType: responseType, ) if responseType.shouldDeleteThread { self.conversationSplitViewController?.closeSelectedConversation(animated: true) } if responseType.shouldReportSpam { self.presentToastCVC( ReportSpamUIUtils.successfulReportText(didBlock: responseType.shouldBlockThread), ) } NotificationCenter.default.post(name: ChatListViewController.clearSearch, object: nil) } /// 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?.declineMessageRequest(responseType: .block) sheetCompletion?(true) }) if !hasReportedSpam { actionSheet.addAction(ActionSheetAction(title: blockAndReportSpamActionTitle) { [weak self] _ in self?.declineMessageRequest(responseType: .blockAndSpam) sheetCompletion?(true) }) } else { actionSheet.addAction(ActionSheetAction(title: blockAndDeleteActionTitle) { [weak self] _ in self?.declineMessageRequest(responseType: .blockAndDelete) 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: { [weak self] _ in self?.declineMessageRequest(responseType: .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( forThread: thread, isBlocked: threadViewModel.isBlocked, declineMessageRequest: self.declineMessageRequest(responseType:), ) } } 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) } }