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)
}
}