Consolidate code to decline message requests

This commit is contained in:
Max Radermacher 2026-04-20 11:51:09 -05:00 committed by GitHub
parent d4fa2a3ec6
commit d474e2a505
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 140 additions and 146 deletions

View File

@ -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 = "<group>"; };
50D6A93E2AA9167400B7F093 /* UniqueObjectRecipientMerger.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UniqueObjectRecipientMerger.swift; sourceTree = "<group>"; };
50D6BDEE2ED6724600CC012E /* DeviceType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceType.swift; sourceTree = "<group>"; };
50D839502F916A3700EE009A /* MessageRequestDecliner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageRequestDecliner.swift; sourceTree = "<group>"; };
50D879692A16D2C20031345D /* MessageLoaderBatchTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageLoaderBatchTest.swift; sourceTree = "<group>"; };
50D9CD8C2C52D78000273D6C /* StoryRecipientManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoryRecipientManager.swift; sourceTree = "<group>"; };
50DCCBF92F1817280024D124 /* DisappearingMessagesConfigurationMessage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DisappearingMessagesConfigurationMessage.swift; sourceTree = "<group>"; };
@ -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 */,

View File

@ -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:),
)
}
}

View File

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

View File

@ -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 {

View File

@ -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() {

View File

@ -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.