diff --git a/Signal.xcodeproj/project.pbxproj b/Signal.xcodeproj/project.pbxproj index 1b165641ab..dbbf21fa0e 100644 --- a/Signal.xcodeproj/project.pbxproj +++ b/Signal.xcodeproj/project.pbxproj @@ -847,6 +847,7 @@ 50D5E2432980B53000899660 /* LinkValidatorTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50D5E2422980B53000899660 /* LinkValidatorTest.swift */; }; 50D6A93F2AA9167400B7F093 /* UniqueObjectRecipientMerger.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50D6A93E2AA9167400B7F093 /* UniqueObjectRecipientMerger.swift */; }; 50D6BDEF2ED6724600CC012E /* DeviceType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50D6BDEE2ED6724600CC012E /* DeviceType.swift */; }; + 50D839512F916A3700EE009A /* MessageRequestDecliner.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50D839502F916A3700EE009A /* MessageRequestDecliner.swift */; }; 50D8796A2A16D2C20031345D /* MessageLoaderBatchTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50D879692A16D2C20031345D /* MessageLoaderBatchTest.swift */; }; 50D9CD8D2C52D78000273D6C /* StoryRecipientManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50D9CD8C2C52D78000273D6C /* StoryRecipientManager.swift */; }; 50DCCBFA2F1817280024D124 /* DisappearingMessagesConfigurationMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50DCCBF92F1817280024D124 /* DisappearingMessagesConfigurationMessage.swift */; }; @@ -5121,6 +5122,7 @@ 50D5E2422980B53000899660 /* LinkValidatorTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkValidatorTest.swift; sourceTree = ""; }; 50D6A93E2AA9167400B7F093 /* UniqueObjectRecipientMerger.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UniqueObjectRecipientMerger.swift; sourceTree = ""; }; 50D6BDEE2ED6724600CC012E /* DeviceType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceType.swift; sourceTree = ""; }; + 50D839502F916A3700EE009A /* MessageRequestDecliner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageRequestDecliner.swift; sourceTree = ""; }; 50D879692A16D2C20031345D /* MessageLoaderBatchTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageLoaderBatchTest.swift; sourceTree = ""; }; 50D9CD8C2C52D78000273D6C /* StoryRecipientManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoryRecipientManager.swift; sourceTree = ""; }; 50DCCBF92F1817280024D124 /* DisappearingMessagesConfigurationMessage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DisappearingMessagesConfigurationMessage.swift; sourceTree = ""; }; @@ -8885,6 +8887,7 @@ 34A95517271B510400B05242 /* LinkPreviewGroupLink.swift */, 346EAA13250199A300E8AB6F /* MemberRequestView.swift */, 4CB5F26820F7D060004D1B42 /* MessageActions.swift */, + 50D839502F916A3700EE009A /* MessageRequestDecliner.swift */, 88D1D40122EBB5A100F472C5 /* MessageRequestView.swift */, 50BD86AE2A3CFF89005B6AC1 /* ResendMessagePromptBuilder.swift */, 88C980D327F3AD2C009750C0 /* TSInteraction+DeleteActionSheet.swift */, @@ -18574,6 +18577,7 @@ 66CDB7522AF9D117009A36EC /* MessageFetchBGRefreshTask.swift in Sources */, 34DE9C02256575300080E4AF /* MessageLoader.swift in Sources */, 881218F0238CA51600E6F271 /* MessageReactionPicker.swift in Sources */, + 50D839512F916A3700EE009A /* MessageRequestDecliner.swift in Sources */, 88D1D40222EBB5A100F472C5 /* MessageRequestView.swift in Sources */, 34EB0E722629DC2B00B62DC3 /* MessageSelectionView.swift in Sources */, 34EB0CEB26289D8800B62DC3 /* MessageTimerView.swift in Sources */, diff --git a/Signal/ConversationView/ConversationViewController+MessageRequest.swift b/Signal/ConversationView/ConversationViewController+MessageRequest.swift index eba03c7fe3..6ee117a094 100644 --- a/Signal/ConversationView/ConversationViewController+MessageRequest.swift +++ b/Signal/ConversationView/ConversationViewController+MessageRequest.swift @@ -129,87 +129,20 @@ extension ConversationViewController: MessageRequestDelegate { } 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, - ) - } + func declineMessageRequest(responseType: OutgoingMessageRequestResponseSyncMessage.ResponseType) { + MessageRequestDecliner.declineMessageRequest( + inThread: self.thread, + responseType: responseType, + ) + if responseType.shouldDeleteThread { 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() + if responseType.shouldReportSpam { + self.presentToastCVC( + ReportSpamUIUtils.successfulReportText(didBlock: responseType.shouldBlockThread), + ) } - - // Leave the group if we're a member. - GroupManager.leaveGroupOrDeclineInviteAsyncWithUI(groupThread: groupThread, fromViewController: self, success: completion) + NotificationCenter.default.post(name: ChatListViewController.clearSearch, object: nil) } /// Accept a message request, or unblock chat. @@ -380,19 +313,18 @@ extension ConversationViewController { ) actionSheet.addAction(ActionSheetAction(title: blockActionTitle) { [weak self] _ in - self?.blockThread() + self?.declineMessageRequest(responseType: .block) sheetCompletion?(true) }) if !hasReportedSpam { actionSheet.addAction(ActionSheetAction(title: blockAndReportSpamActionTitle) { [weak self] _ in - guard let self else { return } - self.blockThreadAndReportSpam(in: self.thread) + self?.declineMessageRequest(responseType: .blockAndSpam) sheetCompletion?(true) }) } else { actionSheet.addAction(ActionSheetAction(title: blockAndDeleteActionTitle) { [weak self] _ in - self?.blockThreadAndDelete() + self?.declineMessageRequest(responseType: .blockAndDelete) sheetCompletion?(true) }) } @@ -442,8 +374,8 @@ extension ConversationViewController { } let actionSheet = ActionSheetController(title: actionSheetTitle, message: actionSheetMessage) - actionSheet.addAction(ActionSheetAction(title: confirmationText, handler: { _ in - self.leaveAndSoftDeleteThread(messageRequestResponseType: .delete) + actionSheet.addAction(ActionSheetAction(title: confirmationText, handler: { [weak self] _ in + self?.declineMessageRequest(responseType: .delete) })) actionSheet.addAction(ActionSheetAction(title: CommonStrings.cancelButton, style: .cancel)) return actionSheet @@ -452,8 +384,9 @@ extension ConversationViewController { // TODO[SPAM]: For groups, fetch the inviter to add to the message func createReportThreadActionSheet() -> ActionSheetController { return ReportSpamUIUtils.createReportSpamActionSheet( - for: thread, + forThread: thread, isBlocked: threadViewModel.isBlocked, + declineMessageRequest: self.declineMessageRequest(responseType:), ) } } diff --git a/Signal/ConversationView/MessageRequestDecliner.swift b/Signal/ConversationView/MessageRequestDecliner.swift new file mode 100644 index 0000000000..e9c1bf7557 --- /dev/null +++ b/Signal/ConversationView/MessageRequestDecliner.swift @@ -0,0 +1,65 @@ +// +// Copyright 2026 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only +// + +import Foundation +import SignalServiceKit +import SignalUI + +enum MessageRequestDecliner { + @MainActor + static func declineMessageRequest( + inThread thread: TSThread, + responseType: OutgoingMessageRequestResponseSyncMessage.ResponseType, + ) { + let blockingManager = SSKEnvironment.shared.blockingManagerRef + let databaseStorage = SSKEnvironment.shared.databaseStorageRef + let deleteManager = DependenciesBridge.shared.threadSoftDeleteManager + let syncManager = SSKEnvironment.shared.syncManagerRef + + // Leave the group if we're going to block it or delete it. (If we're only + // reporting spam without blocking it, we remain a member of the group.) + let shouldLeaveGroup = responseType.shouldBlockThread || responseType.shouldDeleteThread + + databaseStorage.write { tx in + syncManager.sendMessageRequestResponseSyncMessage( + thread: thread, + responseType: responseType, + transaction: tx, + ) + if responseType.shouldBlockThread { + blockingManager.addBlockedThread( + thread, + blockMode: .local, + shouldLeaveIfGroup: false, + transaction: tx, + ) + } + if responseType.shouldReportSpam { + let spamReport = ReportSpamUIUtils.insertSpamReportMessage(in: thread, tx: tx) + // We don't wait for this because it's best effort. + Task { + _ = try? await spamReport?.submit(using: SSKEnvironment.shared.networkManagerRef) + } + } + if shouldLeaveGroup, let thread = thread as? TSGroupThread, thread.groupModel.groupMembership.isLocalUserFullOrInvitedMember { + // We don't wait for this because it's durably enqeueued and may take up to + // 24 hours to complete. + _ = GroupManager.localLeaveGroupOrDeclineInvite( + groupThread: thread, + waitForMessageProcessing: true, + tx: tx, + ) + } + if responseType.shouldDeleteThread { + deleteManager.softDelete( + threads: [thread], + // We're already sending a sync message about this above! + sendDeleteForMeSyncMessage: false, + tx: tx, + ) + } + } + } +} diff --git a/Signal/Spam/SpamReportingUIUtils.swift b/Signal/Spam/SpamReportingUIUtils.swift index 3b1bcd43f3..d1366deaeb 100644 --- a/Signal/Spam/SpamReportingUIUtils.swift +++ b/Signal/Spam/SpamReportingUIUtils.swift @@ -8,21 +8,28 @@ import SignalServiceKit import SignalUI enum ReportSpamUIUtils { - /// Called only if the user reports spam. - /// The `Bool` parameter represents if the thread was also blocked. - typealias Completion = (Bool) -> Void - static func showReportSpamActionSheet( - _ thread: TSThread, - isBlocked: Bool, from viewController: UIViewController, - completion: Completion?, + forThread thread: TSThread, + isBlocked: Bool, + onSuccess: @escaping @MainActor (OutgoingMessageRequestResponseSyncMessage.ResponseType) -> Void, ) { - let actionSheet = createReportSpamActionSheet(for: thread, isBlocked: isBlocked, completion: completion) + let actionSheet = createReportSpamActionSheet( + forThread: thread, + isBlocked: isBlocked, + declineMessageRequest: { responseType in + MessageRequestDecliner.declineMessageRequest(inThread: thread, responseType: responseType) + onSuccess(responseType) + }, + ) viewController.presentActionSheet(actionSheet) } - static func createReportSpamActionSheet(for thread: TSThread, isBlocked: Bool, completion: Completion? = nil) -> ActionSheetController { + static func createReportSpamActionSheet( + forThread thread: TSThread, + isBlocked: Bool, + declineMessageRequest: @escaping @MainActor (OutgoingMessageRequestResponseSyncMessage.ResponseType) -> Void, + ) -> ActionSheetController { let actionSheetTitle = OWSLocalizedString( "MESSAGE_REQUEST_REPORT_CONVERSATION_TITLE", comment: "Action sheet title to confirm reporting a conversation as spam via a message request.", @@ -40,13 +47,7 @@ enum ReportSpamUIUtils { comment: "Action sheet action to confirm reporting a conversation as spam via a message request.", ), handler: { _ in - let spamReport = SSKEnvironment.shared.databaseStorageRef.write { tx in - return Self.buildSpamReport(in: thread, tx: tx) - } - Task { - try? await spamReport?.submit(using: SSKEnvironment.shared.networkManagerRef) - } - completion?(false) + declineMessageRequest(.spam) }, ), ) @@ -58,13 +59,7 @@ enum ReportSpamUIUtils { comment: "Action sheet action to confirm blocking and reporting spam for a thread via a message request.", ), handler: { _ in - let spamReport = SSKEnvironment.shared.databaseStorageRef.write { tx in - return Self.blockAndBuildSpamReport(in: thread, tx: tx) - } - Task { - try? await spamReport?.submit(using: SSKEnvironment.shared.networkManagerRef) - } - completion?(true) + declineMessageRequest(.blockAndSpam) }, ), ) @@ -87,38 +82,7 @@ enum ReportSpamUIUtils { } } - static func blockAndBuildSpamReport(in thread: TSThread, tx: DBWriteTransaction) -> SpamReport? { - SSKEnvironment.shared.blockingManagerRef.addBlockedThread( - thread, - blockMode: .local, - shouldLeaveIfGroup: false, - transaction: tx, - ) - - let result = Self._buildSpamReport(in: thread, tx: tx) - - SSKEnvironment.shared.syncManagerRef.sendMessageRequestResponseSyncMessage( - thread: thread, - responseType: .blockAndSpam, - transaction: tx, - ) - - return result - } - - static func buildSpamReport(in thread: TSThread, tx: DBWriteTransaction) -> SpamReport? { - let result = Self._buildSpamReport(in: thread, tx: tx) - - SSKEnvironment.shared.syncManagerRef.sendMessageRequestResponseSyncMessage( - thread: thread, - responseType: .spam, - transaction: tx, - ) - - return result - } - - private static func _buildSpamReport(in thread: TSThread, tx: DBWriteTransaction) -> SpamReport? { + static func insertSpamReportMessage(in thread: TSThread, tx: DBWriteTransaction) -> SpamReport? { var aci: Aci? var isGroup = false if let contactThread = thread as? TSContactThread { diff --git a/Signal/src/ViewControllers/ThreadSettings/ConversationSettingsViewController.swift b/Signal/src/ViewControllers/ThreadSettings/ConversationSettingsViewController.swift index bed218b5f7..fd3b7bae25 100644 --- a/Signal/src/ViewControllers/ThreadSettings/ConversationSettingsViewController.swift +++ b/Signal/src/ViewControllers/ThreadSettings/ConversationSettingsViewController.swift @@ -694,13 +694,14 @@ class ConversationSettingsViewController: OWSTableViewController2, BadgeCollecti func didTapReportSpam() { ReportSpamUIUtils.showReportSpamActionSheet( - thread, - isBlocked: threadViewModel.isBlocked, from: self, - ) { [weak self] didBlock in - self?.reloadThreadAndUpdateContent() - self?.presentToast(text: ReportSpamUIUtils.successfulReportText(didBlock: didBlock)) - } + forThread: thread, + isBlocked: threadViewModel.isBlocked, + onSuccess: { [weak self] responseType in + self?.reloadThreadAndUpdateContent() + self?.presentToast(text: ReportSpamUIUtils.successfulReportText(didBlock: responseType.shouldBlockThread)) + }, + ) } func didTapInternalSettings() { diff --git a/SignalServiceKit/Messages/DeviceSyncing/OutgoingMessageRequestResponseSyncMessage.swift b/SignalServiceKit/Messages/DeviceSyncing/OutgoingMessageRequestResponseSyncMessage.swift index c0527ae9bf..24fa9629a8 100644 --- a/SignalServiceKit/Messages/DeviceSyncing/OutgoingMessageRequestResponseSyncMessage.swift +++ b/SignalServiceKit/Messages/DeviceSyncing/OutgoingMessageRequestResponseSyncMessage.swift @@ -27,6 +27,33 @@ public final class OutgoingMessageRequestResponseSyncMessage: OutgoingSyncMessag case .blockAndSpam: .blockAndSpam } } + + public var shouldBlockThread: Bool { + switch self { + case .block, .blockAndDelete, .blockAndSpam: + return true + case .accept, .delete, .spam: + return false + } + } + + public var shouldDeleteThread: Bool { + switch self { + case .delete, .blockAndDelete: + return true + case .accept, .block, .spam, .blockAndSpam: + return false + } + } + + public var shouldReportSpam: Bool { + switch self { + case .spam, .blockAndSpam: + return true + case .accept, .block, .delete, .blockAndDelete: + return false + } + } } // v0: The sending thread is also the acted-upon thread.