// // Copyright 2024 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only // import SignalServiceKit import LibSignalClient public struct ReportSpamUIUtils { public typealias Completion = (Bool) -> Void public static func showReportSpamActionSheet( _ thread: TSThread, isBlocked: Bool, from viewController: UIViewController, completion: Completion? ) { let actionSheet = createReportSpamActionSheet(for: thread, isBlocked: isBlocked) viewController.presentActionSheet(actionSheet) } public static func createReportSpamActionSheet(for thread: TSThread, isBlocked: Bool) -> ActionSheetController { let actionSheetTitle = OWSLocalizedString( "MESSAGE_REQUEST_REPORT_CONVERSATION_TITLE", comment: "Action sheet title to confirm reporting a conversation as spam via a message request." ) let actionSheetMessage = OWSLocalizedString( "MESSAGE_REQUEST_REPORT_CONVERSATION_MESSAGE", comment: "Action sheet message to confirm reporting a conversation as spam via a message request." ) let actionSheet = ActionSheetController(title: actionSheetTitle, message: actionSheetMessage) actionSheet.addAction(ActionSheetAction( title: OWSLocalizedString( "MESSAGE_REQUEST_REPORT_SPAM_ACTION", comment: "Action sheet action to confirm reporting a conversation as spam via a message request." ), handler: { _ in NSObject.databaseStorage.write { tx in Self.reportSpam(in: thread, tx: tx) } }) ) if !isBlocked { actionSheet.addAction(ActionSheetAction( title: 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." ), handler: { _ in NSObject.databaseStorage.write { tx in Self.blockAndReport(in: thread, tx: tx) } }) ) } actionSheet.addAction(ActionSheetAction(title: CommonStrings.cancelButton, style: .cancel)) return actionSheet } public static func blockAndReport(in thread: TSThread, tx: SDSAnyWriteTransaction) { NSObject.blockingManager.addBlockedThread( thread, blockMode: .localShouldNotLeaveGroups, transaction: tx ) Self.reportSpam(in: thread, tx: tx) NSObject.syncManager.sendMessageRequestResponseSyncMessage( thread: thread, responseType: .blockAndSpam ) } public static func report(in thread: TSThread, tx: SDSAnyWriteTransaction) { Self.reportSpam(in: thread, tx: tx) NSObject.syncManager.sendMessageRequestResponseSyncMessage( thread: thread, responseType: .blockAndSpam ) } private static func reportSpam(in thread: TSThread, tx: SDSAnyWriteTransaction) { var aci: Aci? var isGroup = false if let contactThread = thread as? TSContactThread { aci = contactThread.contactAddress.serviceId as? Aci } else if let groupThread = thread as? TSGroupThread { isGroup = true let accountManager = DependenciesBridge.shared.tsAccountManager guard let localIdentifiers = accountManager.localIdentifiers(tx: tx.asV2Read) else { return owsFailDebug("Missing local identifiers") } let groupMembership = groupThread.groupModel.groupMembership if let invitedAtServiceId = groupMembership.localUserInvitedAtServiceId(localIdentifiers: localIdentifiers) { aci = groupMembership.addedByAci(forInvitedMember: invitedAtServiceId) } } else { return owsFailDebug("Unexpected thread type for reporting spam \(type(of: thread))") } guard let aci else { return owsFailDebug("Missing ACI for reporting spam") } let infoMessage = TSInfoMessage(thread: thread, messageType: .reportedSpam) infoMessage.anyInsert(transaction: tx) // We only report a selection of the N most recent messages // in the conversation. let maxMessagesToReport = 3 var guidsToReport = Set() do { if isGroup { guard let localIdentifiers: LocalIdentifiers = DependenciesBridge.shared.tsAccountManager.localIdentifiers(tx: tx.asV2Read) else { owsFailDebug("Unable to find local identifiers") return } try InteractionFinder( threadUniqueId: thread.uniqueId ).enumerateRecentGroupUpdateMessages( transaction: tx ) { infoMessage, stop in guard let groupUpdateItems = infoMessage.computedGroupUpdateItems(localIdentifiers: localIdentifiers, tx: tx) else { return } for item in groupUpdateItems { if let serverGuid = infoMessage.serverGuid, let updaterAci = item.aciForSpamReporting, updaterAci.wrappedValue == aci { guidsToReport.insert(serverGuid) } } guard guidsToReport.count < maxMessagesToReport else { stop.pointee = true return } } } else { try InteractionFinder( threadUniqueId: thread.uniqueId ).enumerateInteractionsForConversationView( rowIdFilter: .newest, tx: tx ) { interaction -> Bool in guard let incomingMessage = interaction as? TSIncomingMessage else { return true } if let serverGuid = incomingMessage.serverGuid { guidsToReport.insert(serverGuid) } if guidsToReport.count < maxMessagesToReport { return true } return false } } } catch { owsFailDebug("Failed to lookup guids to report \(error)") } var reportingToken: SpamReportingToken? do { reportingToken = try SpamReportingTokenRecord.reportingToken( for: aci, database: tx.unwrapGrdbRead.database ) } catch { owsFailBeta("Failed to look up spam reporting token. Continuing on, as the parameter is optional. Error: \(error)") } guard !guidsToReport.isEmpty else { Logger.warn("No messages with serverGuids to report.") return } Logger.info( "Reporting \(guidsToReport.count) message(s) from \(aci) as spam. We \(reportingToken == nil ? "do not have" : "have") a reporting token" ) var promises = [Promise]() for guid in guidsToReport { let request = OWSRequestFactory.reportSpam( from: aci, withServerGuid: guid, reportingToken: reportingToken ) promises.append(NSObject.networkManager.makePromise(request: request).asVoid()) } Promise.when(fulfilled: promises).done { Logger.info("Successfully reported \(guidsToReport.count) message(s) from \(aci) as spam.") }.catch { error in owsFailDebug("Failed to report message(s) from \(aci) as spam with error: \(error)") } } }