diff --git a/Signal.xcodeproj/project.pbxproj b/Signal.xcodeproj/project.pbxproj index 3ead85f781..b39c845892 100644 --- a/Signal.xcodeproj/project.pbxproj +++ b/Signal.xcodeproj/project.pbxproj @@ -1862,6 +1862,8 @@ D9E7C8772B9A4A9C005BD3B9 /* CallRecord+Sorting.swift in Sources */ = {isa = PBXBuildFile; fileRef = D9E7C8762B9A4A9C005BD3B9 /* CallRecord+Sorting.swift */; }; D9E7C8792B9B9072005BD3B9 /* IdentifierIndexedArray.swift in Sources */ = {isa = PBXBuildFile; fileRef = D9E7C8782B9B9072005BD3B9 /* IdentifierIndexedArray.swift */; }; D9E8EDED2C0EAFE700923E3C /* DeleteForMeOutgoingSyncMessageManagerTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = D9E8EDEC2C0EAFE700923E3C /* DeleteForMeOutgoingSyncMessageManagerTest.swift */; }; + D9E8EDF12C0FCB3000923E3C /* DeleteForMeSyncMessageInfoSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = D9E8EDF02C0FCB3000923E3C /* DeleteForMeSyncMessageInfoSheet.swift */; }; + D9E8EDF32C0FD8C800923E3C /* DeleteForMeInfoSheetCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = D9E8EDF22C0FD8C800923E3C /* DeleteForMeInfoSheetCoordinator.swift */; }; D9EB221E2A4B636C00C73E1D /* Bitmaps+LineDrawing.swift in Sources */ = {isa = PBXBuildFile; fileRef = D9EB22192A4B636B00C73E1D /* Bitmaps+LineDrawing.swift */; }; D9EB221F2A4B636C00C73E1D /* Bitmaps+Shapes.swift in Sources */ = {isa = PBXBuildFile; fileRef = D9EB221A2A4B636B00C73E1D /* Bitmaps+Shapes.swift */; }; D9EB22202A4B636C00C73E1D /* Bitmaps+Image.swift in Sources */ = {isa = PBXBuildFile; fileRef = D9EB221B2A4B636B00C73E1D /* Bitmaps+Image.swift */; }; @@ -4803,6 +4805,8 @@ D9E7C8762B9A4A9C005BD3B9 /* CallRecord+Sorting.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CallRecord+Sorting.swift"; sourceTree = ""; }; D9E7C8782B9B9072005BD3B9 /* IdentifierIndexedArray.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IdentifierIndexedArray.swift; sourceTree = ""; }; D9E8EDEC2C0EAFE700923E3C /* DeleteForMeOutgoingSyncMessageManagerTest.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DeleteForMeOutgoingSyncMessageManagerTest.swift; sourceTree = ""; }; + D9E8EDF02C0FCB3000923E3C /* DeleteForMeSyncMessageInfoSheet.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DeleteForMeSyncMessageInfoSheet.swift; sourceTree = ""; }; + D9E8EDF22C0FD8C800923E3C /* DeleteForMeInfoSheetCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeleteForMeInfoSheetCoordinator.swift; sourceTree = ""; }; D9EB22192A4B636B00C73E1D /* Bitmaps+LineDrawing.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Bitmaps+LineDrawing.swift"; sourceTree = ""; }; D9EB221A2A4B636B00C73E1D /* Bitmaps+Shapes.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Bitmaps+Shapes.swift"; sourceTree = ""; }; D9EB221B2A4B636B00C73E1D /* Bitmaps+Image.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Bitmaps+Image.swift"; sourceTree = ""; }; @@ -9308,6 +9312,7 @@ 88D23D1323CEC0C700B0E74B /* Calls */, 50B6BCAF2AEC4F3B0010FB3B /* Contacts */, 3448BFC01EDF0EA7005B2D69 /* ConversationView */, + D9E8EDEF2C0FCB0600923E3C /* DeleteForMe */, 88C4E38124671F9D009C9B97 /* DeviceTransfer */, 3428576F26BD8777005A2A96 /* Emoji */, 50E7E1CC2BACBDE000A94861 /* Expiration */, @@ -9812,6 +9817,15 @@ path = Mocks; sourceTree = ""; }; + D9E8EDEF2C0FCB0600923E3C /* DeleteForMe */ = { + isa = PBXGroup; + children = ( + D9E8EDF22C0FD8C800923E3C /* DeleteForMeInfoSheetCoordinator.swift */, + D9E8EDF02C0FCB3000923E3C /* DeleteForMeSyncMessageInfoSheet.swift */, + ); + path = DeleteForMe; + sourceTree = ""; + }; D9F399B72A9EA1EB001599EC /* Mocks */ = { isa = PBXGroup; children = ( @@ -12958,6 +12972,8 @@ 88DBDFB9263731C800C2101C /* DefaultDisappearingMessageTimerInteraction.swift in Sources */, 66B8B28028C94C0F005EAFE0 /* DelegatingContextMenuButton.swift in Sources */, 887B6DC925F6C3E900E677D4 /* DeleteAccountConfirmationViewController.swift in Sources */, + D9E8EDF32C0FD8C800923E3C /* DeleteForMeInfoSheetCoordinator.swift in Sources */, + D9E8EDF12C0FCB3000923E3C /* DeleteForMeSyncMessageInfoSheet.swift in Sources */, 3498AC892513896400B1F315 /* Dependencies+MainApp.swift in Sources */, 5011D1CD29400E7300064098 /* DeviceProvisioningURL.swift in Sources */, 887CD4772472FEA500FDD265 /* DeviceTransferOperation.swift in Sources */, diff --git a/Signal/ConversationView/ConversationViewController+Selection.swift b/Signal/ConversationView/ConversationViewController+Selection.swift index 3bf999ff8b..8ec7d21a65 100644 --- a/Signal/ConversationView/ConversationViewController+Selection.swift +++ b/Signal/ConversationView/ConversationViewController+Selection.swift @@ -300,35 +300,67 @@ extension ConversationViewController { return } - let messageFormat = OWSLocalizedString("DELETE_SELECTED_MESSAGES_IN_CONVERSATION_ALERT_%d", tableName: "PluralAware", - comment: "action sheet body. Embeds {{number of selected messages}} which will be deleted.") - let message = String.localizedStringWithFormat(messageFormat, selectionItems.count) - let alert = ActionSheetController(title: nil, message: message) - alert.addAction(OWSActionSheets.cancelAction) + DeleteForMeInfoSheetCoordinator.fromGlobals().coordinateDelete( + fromViewController: self + ) { interactionDeleteManager, _ in + self.presentDeleteSelectedMessagesActionSheet( + selectionItems: selectionItems, + interactionDeleteManager: interactionDeleteManager + ) + } + } - let delete = ActionSheetAction(title: CommonStrings.deleteForMeButton, style: .destructive) { [weak self] _ in + private func presentDeleteSelectedMessagesActionSheet( + selectionItems: [CVSelectionItem], + interactionDeleteManager: InteractionDeleteManager + ) { + let deleteAction = ActionSheetAction( + title: CommonStrings.deleteForMeButton, + style: .destructive + ) { [weak self] _ in guard let self = self else { return } - ModalActivityIndicatorViewController.present(fromViewController: self, canCancel: false) { [weak self] modalActivityIndicator in + + ModalActivityIndicatorViewController.present( + fromViewController: self, + canCancel: false + ) { [weak self] modalActivityIndicator in guard let self = self else { return } DispatchQueue.main.async { Self.deleteSelectedItems( selectionItems: selectionItems, - thread: self.thread + thread: self.thread, + interactionDeleteManager: interactionDeleteManager ) + modalActivityIndicator.dismiss { self.uiMode = .normal } } } } - alert.addAction(delete) + + let alert = ActionSheetController( + title: nil, + message: String.localizedStringWithFormat( + OWSLocalizedString( + "DELETE_SELECTED_MESSAGES_IN_CONVERSATION_ALERT_%d", + tableName: "PluralAware", + comment: "action sheet body. Embeds {{number of selected messages}} which will be deleted." + ), + selectionItems.count + ) + ) + alert.addAction(OWSActionSheets.cancelAction) + alert.addAction(deleteAction) + present(alert, animated: true) } private static func deleteSelectedItems( selectionItems: [CVSelectionItem], - thread: TSThread + thread: TSThread, + interactionDeleteManager: InteractionDeleteManager ) { databaseStorage.write { tx in var interactionsToDelete = [TSInteraction]() @@ -351,7 +383,7 @@ extension ConversationViewController { } } - DependenciesBridge.shared.interactionDeleteManager.delete( + interactionDeleteManager.delete( interactions: interactionsToDelete, sideEffects: .custom( deleteForMeSyncMessage: .sendSyncMessage(interactionsThread: thread) @@ -462,6 +494,20 @@ extension ConversationViewController { } func didTapDeleteAll() { + DeleteForMeInfoSheetCoordinator.fromGlobals().coordinateDelete( + fromViewController: self + ) { [weak self] _, threadSoftDeleteManager in + guard let self else { return } + + self.presentDeleteAllConfirmationSheet( + threadSoftDeleteManager: threadSoftDeleteManager + ) + } + } + + private func presentDeleteAllConfirmationSheet( + threadSoftDeleteManager: any ThreadSoftDeleteManager + ) { let thread = self.thread let alert = ActionSheetController(title: nil, message: OWSLocalizedString("DELETE_ALL_MESSAGES_IN_CONVERSATION_ALERT_BODY", comment: "action sheet body")) alert.addAction(OWSActionSheets.cancelAction) @@ -471,8 +517,11 @@ extension ConversationViewController { ModalActivityIndicatorViewController.present(fromViewController: self, canCancel: false) { [weak self] modalActivityIndicator in guard let self = self else { return } self.databaseStorage.write { - DependenciesBridge.shared.threadSoftDeleteManager - .removeAllInteractions(thread: thread, sendDeleteForMeSyncMessage: true, tx: $0.asV2Write) + threadSoftDeleteManager.removeAllInteractions( + thread: thread, + sendDeleteForMeSyncMessage: true, + tx: $0.asV2Write + ) } DispatchQueue.main.async { modalActivityIndicator.dismiss { [weak self] in diff --git a/Signal/ConversationView/TSInteraction+DeleteActionSheet.swift b/Signal/ConversationView/TSInteraction+DeleteActionSheet.swift index 2927140e9a..d73e12e1dd 100644 --- a/Signal/ConversationView/TSInteraction+DeleteActionSheet.swift +++ b/Signal/ConversationView/TSInteraction+DeleteActionSheet.swift @@ -10,6 +10,97 @@ import UIKit public extension TSInteraction { func presentDeletionActionSheet(from fromViewController: UIViewController, forceDarkTheme: Bool = false) { + let (associatedThread, hasLinkedDevices): (TSThread?, Bool) = databaseStorage.read { tx in + return ( + thread(tx: tx), + DependenciesBridge.shared.deviceStore.hasLinkedDevices(tx: tx.asV2Read) + ) + } + + guard let associatedThread else { return } + + if + // We only want the new Note to Self delete UX if we're sending + // DeleteForMe sync messages. + DeleteForMeSyncMessage.isSendingEnabled, + associatedThread.isNoteToSelf + { + presentDeletionActionSheetForNoteToSelf( + fromViewController: fromViewController, + thread: associatedThread, + hasLinkedDevices: hasLinkedDevices, + forceDarkTheme: forceDarkTheme, + interactionDeleteManager: DependenciesBridge.shared.interactionDeleteManager + ) + } else { + DeleteForMeInfoSheetCoordinator.fromGlobals().coordinateDelete( + fromViewController: fromViewController + ) { [weak self] interactionDeleteManager, _ in + self?.presentDeletionActionSheetForNotNoteToSelf( + fromViewController: fromViewController, + thread: associatedThread, + forceDarkTheme: forceDarkTheme, + interactionDeleteManager: interactionDeleteManager + ) + } + } + } + + private func presentDeletionActionSheetForNoteToSelf( + fromViewController: UIViewController, + thread: TSThread, + hasLinkedDevices: Bool, + forceDarkTheme: Bool, + interactionDeleteManager: any InteractionDeleteManager + ) { + let deleteMessageHeaderText = OWSLocalizedString( + "DELETE_FOR_ME_NOTE_TO_SELF_ACTION_SHEET_HEADER", + comment: "Header text for an action sheet confirming deleting a message in Note to Self." + ) + let (title, message, deleteActionTitle): (String?, String, String) = if hasLinkedDevices { + ( + deleteMessageHeaderText, + OWSLocalizedString( + "DELETE_FOR_ME_NOTE_TO_SELF_LINKED_DEVICES_PRESENT_ACTION_SHEET_SUBHEADER", + comment: "Subheader for an action sheet explaining that a Note to Self deleted on this device will be deleted on the user's other devices as well." + ), + OWSLocalizedString( + "DELETE_FOR_ME_NOTE_TO_SELF_LINKED_DEVICES_PRESENT_ACTION_SHEET_BUTTON_TITLE", + comment: "Title for an action sheet button explaining that a message will be deleted." + ) + ) + } else { + ( + nil, + deleteMessageHeaderText, + OWSLocalizedString( + "DELETE_FOR_ME_NOTE_TO_SELF_LINKED_DEVICES_NOT_PRESENT_ACTION_SHEET_BUTTON_TITLE", + comment: "Title for an action sheet button explaining that a message will be deleted." + ) + ) + } + + let actionSheet = ActionSheetController( + title: title, + message: message, + theme: forceDarkTheme ? .translucentDark : .default + ) + actionSheet.addAction(deleteForMeAction( + title: deleteActionTitle, + thread: thread, + interactionDeleteManager: interactionDeleteManager + )) + actionSheet.addAction(.cancel) + + fromViewController.presentActionSheet(actionSheet) + } + + private func presentDeletionActionSheetForNotNoteToSelf( + fromViewController: UIViewController, + thread: TSThread, + forceDarkTheme: Bool, + interactionDeleteManager: any InteractionDeleteManager + ) { let actionSheetController = ActionSheetController( message: OWSLocalizedString( "MESSAGE_ACTION_DELETE_FOR_TITLE", @@ -18,33 +109,11 @@ public extension TSInteraction { theme: forceDarkTheme ? .translucentDark : .default ) - let deleteForMeAction = ActionSheetAction( + actionSheetController.addAction(deleteForMeAction( title: CommonStrings.deleteForMeButton, - style: .destructive - ) { _ in - Self.databaseStorage.asyncWrite { tx in - guard let freshSelf = TSInteraction.anyFetch( - uniqueId: self.uniqueId, transaction: tx - ) else { return } - - if let thread = freshSelf.thread(tx: tx) { - DependenciesBridge.shared.interactionDeleteManager.delete( - interactions: [freshSelf], - sideEffects: .custom( - deleteForMeSyncMessage: .sendSyncMessage(interactionsThread: thread) - ), - tx: tx.asV2Write - ) - } else { - DependenciesBridge.shared.interactionDeleteManager.delete( - interactions: [freshSelf], - sideEffects: .default(), - tx: tx.asV2Write - ) - } - } - } - actionSheetController.addAction(deleteForMeAction) + thread: thread, + interactionDeleteManager: interactionDeleteManager + )) if let outgoingMessage = self as? TSOutgoingMessage, @@ -123,4 +192,32 @@ public extension TSInteraction { completion() } } + + private func deleteForMeAction( + title: String, + thread: TSThread, + interactionDeleteManager: any InteractionDeleteManager + ) -> ActionSheetAction { + return ActionSheetAction( + title: CommonStrings.deleteForMeButton, + style: .destructive + ) { [weak self] _ in + guard let self else { return } + + self.databaseStorage.asyncWrite { tx in + guard + let freshSelf = TSInteraction.anyFetch(uniqueId: self.uniqueId, transaction: tx), + let freshThread = TSThread.anyFetch(uniqueId: thread.uniqueId, transaction: tx) + else { return } + + interactionDeleteManager.delete( + interactions: [freshSelf], + sideEffects: .custom( + deleteForMeSyncMessage: .sendSyncMessage(interactionsThread: freshThread) + ), + tx: tx.asV2Write + ) + } + } + } } diff --git a/Signal/DeleteForMe/DeleteForMeInfoSheetCoordinator.swift b/Signal/DeleteForMe/DeleteForMeInfoSheetCoordinator.swift new file mode 100644 index 0000000000..eafb1aef7d --- /dev/null +++ b/Signal/DeleteForMe/DeleteForMeInfoSheetCoordinator.swift @@ -0,0 +1,109 @@ +// +// Copyright 2024 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only +// + +import UIKit +import SignalServiceKit + +/// Responsible for optionally showing informational UX before a deletion occurs +/// that will sync across devices. +/// +/// As part of the introduction of "delete syncs" (powered by `DeleteForMe` sync +/// messages), we want to show users a one-time pop-up explaining that their +/// deletes will now sync before we do the deletion. This type handles checking +/// if the pop-up should be shown, and if so, doing so. +final class DeleteForMeInfoSheetCoordinator { + typealias DeletionBlock = (InteractionDeleteManager, ThreadSoftDeleteManager) -> Void + + private enum StoreKeys { + static let hasShownDeleteForMeInfoSheet = "hasShownDeleteForMeInfoSheet" + } + + private let db: DB + private let deviceStore: OWSDeviceStore + private let interactionDeleteManager: InteractionDeleteManager + private let keyValueStore: KeyValueStore + private let threadSoftDeleteManager: ThreadSoftDeleteManager + + init( + db: DB, + deviceStore: OWSDeviceStore, + interactionDeleteManager: InteractionDeleteManager, + keyValueStoreFactory: KeyValueStoreFactory, + threadSoftDeleteManager: ThreadSoftDeleteManager + ) { + self.db = db + self.deviceStore = deviceStore + self.interactionDeleteManager = interactionDeleteManager + self.keyValueStore = keyValueStoreFactory.keyValueStore(collection: "DeleteForMeInfoSheetCoordinator") + self.threadSoftDeleteManager = threadSoftDeleteManager + } + + static func fromGlobals() -> DeleteForMeInfoSheetCoordinator { + return DeleteForMeInfoSheetCoordinator( + db: DependenciesBridge.shared.db, + deviceStore: DependenciesBridge.shared.deviceStore, + interactionDeleteManager: DependenciesBridge.shared.interactionDeleteManager, + keyValueStoreFactory: DependenciesBridge.shared.keyValueStoreFactory, + threadSoftDeleteManager: DependenciesBridge.shared.threadSoftDeleteManager + ) + } + + func coordinateDelete( + fromViewController: UIViewController, + deletionBlock: @escaping DeletionBlock + ) { + guard shouldShowInfoSheet() else { + deletionBlock(interactionDeleteManager, threadSoftDeleteManager) + return + } + + let infoSheet = DeleteForMeSyncMessage.InfoSheet(onConfirmBlock: { + self.db.write { tx in + self.keyValueStore.setBool( + true, + key: StoreKeys.hasShownDeleteForMeInfoSheet, + transaction: tx + ) + } + + deletionBlock( + self.interactionDeleteManager, + self.threadSoftDeleteManager + ) + }) + + fromViewController.present(infoSheet, animated: true) + } + + #if USE_DEBUG_UI + func forceEnableInfoSheet(tx: any DBWriteTransaction) { + keyValueStore.removeValue( + forKey: StoreKeys.hasShownDeleteForMeInfoSheet, + transaction: tx + ) + } + #endif + + private func shouldShowInfoSheet() -> Bool { + return db.read { tx -> Bool in + guard DeleteForMeSyncMessage.isSendingEnabled else { + // Nothing will actually be synced! + return false + } + + guard deviceStore.hasLinkedDevices(tx: tx) else { + // No devices with which to sync! + return false + } + + guard keyValueStore.getBool(StoreKeys.hasShownDeleteForMeInfoSheet, transaction: tx) != true else { + // Already shown! + return false + } + + return true + } + } +} diff --git a/Signal/DeleteForMe/DeleteForMeSyncMessageInfoSheet.swift b/Signal/DeleteForMe/DeleteForMeSyncMessageInfoSheet.swift new file mode 100644 index 0000000000..07fa72af6c --- /dev/null +++ b/Signal/DeleteForMe/DeleteForMeSyncMessageInfoSheet.swift @@ -0,0 +1,134 @@ +// +// Copyright 2024 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only +// + +import SignalServiceKit +import SignalUI +import UIKit + +extension DeleteForMeSyncMessage { + final class InfoSheet: InteractiveSheetViewController { + private let onConfirmBlock: () -> Void + + init(onConfirmBlock: @escaping () -> Void) { + self.onConfirmBlock = onConfirmBlock + } + + override var interactiveScrollViews: [UIScrollView] { [contentScrollWrapper] } + + private lazy var contentScrollWrapper: UIScrollView = { + let scrollView = UIScrollView(forAutoLayout: ()) + scrollView.addSubview(_contentView) + + // Pin height to scrollable area, but width to the viewport. + _contentView.autoPinHeightToSuperview() + _contentView.autoPinWidth(toWidthOf: scrollView) + + return scrollView + }() + + private lazy var _contentView: UIView = { + let view = UIView() + + let headerImageView = { () -> UIImageView in + let imageName = Theme.isDarkThemeEnabled ? "delete-sync-dark" : "delete-sync-light" + let imageView = UIImageView(image: UIImage(named: imageName)!) + imageView.heightAnchor.constraint(equalToConstant: 88).isActive = true + imageView.contentMode = .scaleAspectFit + + return imageView + }() + + let titleLabel = { () -> UILabel in + let label = UILabel() + label.translatesAutoresizingMaskIntoConstraints = false + label.text = OWSLocalizedString( + "DELETE_FOR_ME_SYNC_MESSAGE_INFO_SHEET_TITLE", + comment: "Title for an info sheet explaining that deletes are now synced across devices." + ) + label.font = .dynamicTypeTitle3.semibold() + label.numberOfLines = 0 + label.lineBreakMode = .byWordWrapping + label.textAlignment = .center + + return label + }() + + let subtitleLabel = { () -> UILabel in + let label = UILabel() + label.translatesAutoresizingMaskIntoConstraints = false + label.text = OWSLocalizedString( + "DELETE_FOR_ME_SYNC_MESSAGE_INFO_SHEET_SUBTITLE", + comment: "Subtitle for an info sheet explaining that deletes are now synced across devices." + ) + label.font = .dynamicTypeBody + label.textColor = Theme.isDarkThemeEnabled ? .ows_gray20 : .ows_gray60 + label.numberOfLines = 0 + label.lineBreakMode = .byWordWrapping + label.textAlignment = .center + + return label + }() + + let spacer = UIView(forAutoLayout: ()) + + let gotItButton = { () -> UIButton in + let button = OWSButton( + title: OWSLocalizedString( + "DELETE_FOR_ME_SYNC_MESSAGE_INFO_SHEET_BUTTON", + comment: "Label for a button in an info sheet confirming that deletes are now synced across devices." + ), + block: { [weak self] in + guard let self else { return } + self.dismiss(animated: true) { + self.onConfirmBlock() + } + } + ) + button.backgroundColor = .ows_accentBlue + button.layer.cornerRadius = 12 + button.configureForMultilineTitle() + button.titleLabel!.font = .dynamicTypeHeadline.semibold() + + return button + }() + + view.addSubview(headerImageView) + view.addSubview(titleLabel) + view.addSubview(subtitleLabel) + view.addSubview(spacer) + view.addSubview(gotItButton) + + headerImageView.autoPinEdges(toSuperviewMarginsExcludingEdge: .bottom) + + titleLabel.autoPinEdge(.top, to: .bottom, of: headerImageView, withOffset: 24) + titleLabel.autoPinWidthToSuperviewMargins(withInset: 24) + + subtitleLabel.autoPinEdge(.top, to: .bottom, of: titleLabel, withOffset: 12) + subtitleLabel.autoPinWidthToSuperviewMargins(withInset: 24) + + spacer.autoPinEdge(.top, to: .bottom, of: subtitleLabel) + spacer.autoPinWidthToSuperviewMargins() + spacer.heightAnchor.constraint(greaterThanOrEqualToConstant: 92).isActive = true + + gotItButton.autoPinEdge(.top, to: .bottom, of: spacer) + gotItButton.autoPinLeadingToSuperviewMargin(withInset: 48) + gotItButton.autoPinTrailingToSuperviewMargin(withInset: 48) + gotItButton.autoPinBottomToSuperviewMargin() + gotItButton.heightAnchor.constraint(greaterThanOrEqualToConstant: 50).isActive = true + + return view + }() + + override public func viewDidLoad() { + super.viewDidLoad() + minimizedHeight = 487 + allowsExpansion = true + + contentView.addSubview(contentScrollWrapper) + contentScrollWrapper.autoPinEdgesToSuperviewMargins() + contentScrollWrapper.alwaysBounceVertical = true + } + } +} diff --git a/Signal/Images.xcassets/delete-sync-dark.imageset/Contents.json b/Signal/Images.xcassets/delete-sync-dark.imageset/Contents.json new file mode 100644 index 0000000000..56a7df0a1d --- /dev/null +++ b/Signal/Images.xcassets/delete-sync-dark.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "delete-sync-dark.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Signal/Images.xcassets/delete-sync-dark.imageset/delete-sync-dark.pdf b/Signal/Images.xcassets/delete-sync-dark.imageset/delete-sync-dark.pdf new file mode 100644 index 0000000000..2992de456b Binary files /dev/null and b/Signal/Images.xcassets/delete-sync-dark.imageset/delete-sync-dark.pdf differ diff --git a/Signal/Images.xcassets/delete-sync-light.imageset/Contents.json b/Signal/Images.xcassets/delete-sync-light.imageset/Contents.json new file mode 100644 index 0000000000..c040e5dbbe --- /dev/null +++ b/Signal/Images.xcassets/delete-sync-light.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "delete-sync-light.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Signal/Images.xcassets/delete-sync-light.imageset/delete-sync-light.pdf b/Signal/Images.xcassets/delete-sync-light.imageset/delete-sync-light.pdf new file mode 100644 index 0000000000..8f1ccb3e87 Binary files /dev/null and b/Signal/Images.xcassets/delete-sync-light.imageset/delete-sync-light.pdf differ diff --git a/Signal/src/ViewControllers/AppSettings/ChatsSettingsViewController.swift b/Signal/src/ViewControllers/AppSettings/ChatsSettingsViewController.swift index 225cda4b53..d68bb11930 100644 --- a/Signal/src/ViewControllers/AppSettings/ChatsSettingsViewController.swift +++ b/Signal/src/ViewControllers/AppSettings/ChatsSettingsViewController.swift @@ -171,34 +171,71 @@ class ChatsSettingsViewController: OWSTableViewController2 { Self.storageServiceManager.recordPendingLocalAccountUpdates() } + // MARK: - + private func didTapClearHistory() { - OWSActionSheets.showConfirmationAlert( - title: OWSLocalizedString( - "SETTINGS_DELETE_HISTORYLOG_CONFIRMATION", - comment: "Alert message before user confirms clearing history" - ), - proceedTitle: OWSLocalizedString( - "SETTINGS_DELETE_HISTORYLOG_CONFIRMATION_BUTTON", - comment: "Confirmation text for button which deletes all message, calling, attachments, etc." - ), - proceedStyle: .destructive, - proceedAction: { [weak self] _ in self?.clearHistory() } - ) + DeleteForMeInfoSheetCoordinator.fromGlobals().coordinateDelete( + fromViewController: self + ) { _, threadSoftDeleteManager in + OWSActionSheets.showConfirmationAlert( + title: OWSLocalizedString( + "SETTINGS_DELETE_HISTORYLOG_CONFIRMATION", + comment: "Alert message before user confirms clearing history" + ), + proceedTitle: OWSLocalizedString( + "SETTINGS_DELETE_HISTORYLOG_CONFIRMATION_BUTTON", + comment: "Confirmation text for button which deletes all message, calling, attachments, etc." + ), + proceedStyle: .destructive, + proceedAction: { [weak self] _ in + self?.clearHistoryBehindSpinner(threadSoftDeleteManager: threadSoftDeleteManager) + } + ) + } } - private func clearHistory() { + private func clearHistoryBehindSpinner( + threadSoftDeleteManager: any ThreadSoftDeleteManager + ) { ModalActivityIndicatorViewController.present( fromViewController: self, canCancel: false, presentationDelay: 0.5, + backgroundBlockQueueQos: .userInitiated, backgroundBlock: { modal in - DispatchQueue.global(qos: .userInitiated).async { - ThreadUtil.deleteAllContentWithSneakyTransaction() - DispatchQueue.main.async { - modal.dismiss() - } + self.databaseStorage.write { tx in + self.clearHistoryWithSneakyTransaction( + threadSoftDeleteManager: threadSoftDeleteManager + ) + } + + DispatchQueue.main.async { + modal.dismiss() } } ) } + + private func clearHistoryWithSneakyTransaction( + threadSoftDeleteManager: any ThreadSoftDeleteManager + ) { + Logger.info("") + + databaseStorage.write { transaction in + threadSoftDeleteManager.softDelete( + threads: TSThread.anyFetchAll(transaction: transaction), + sendDeleteForMeSyncMessage: true, + tx: transaction.asV2Write + ) + + StoryMessage.anyRemoveAllWithInstantiation(transaction: transaction) + TSAttachment.anyRemoveAllWithInstantiation(transaction: transaction) + + // Deleting attachments above should be enough to remove any gallery items, but + // we redunantly clean up *all* gallery items to be safe. + MediaGalleryRecordManager.didRemoveAllContent(transaction: transaction) + } + + TSAttachmentStream.deleteAttachmentsFromDisk() + } } diff --git a/Signal/src/ViewControllers/DebugUI/DebugUIMisc.swift b/Signal/src/ViewControllers/DebugUI/DebugUIMisc.swift index 42fee62db5..e4b53db334 100644 --- a/Signal/src/ViewControllers/DebugUI/DebugUIMisc.swift +++ b/Signal/src/ViewControllers/DebugUI/DebugUIMisc.swift @@ -172,6 +172,13 @@ class DebugUIMisc: NSObject, DebugUIPage, Dependencies { let flipCamTooltipManager = FlipCameraTooltipManager(db: DependenciesBridge.shared.db) flipCamTooltipManager.markTooltipAsUnread() }), + + OWSTableItem(title: "Enable DeleteForMeSyncMessage info sheet", actionBlock: { + SDSDatabaseStorage.shared.write { tx in + DeleteForMeInfoSheetCoordinator.fromGlobals() + .forceEnableInfoSheet(tx: tx.asV2Write) + } + }) ] return OWSTableSection(title: name, items: items) } diff --git a/Signal/src/ViewControllers/HomeView/Chat List/ChatListViewController+Multiselect.swift b/Signal/src/ViewControllers/HomeView/Chat List/ChatListViewController+Multiselect.swift index 3c34842b2b..8ec630570a 100644 --- a/Signal/src/ViewControllers/HomeView/Chat List/ChatListViewController+Multiselect.swift +++ b/Signal/src/ViewControllers/HomeView/Chat List/ChatListViewController+Multiselect.swift @@ -278,6 +278,16 @@ extension ChatListViewController { return } + DeleteForMeInfoSheetCoordinator.fromGlobals().coordinateDelete( + fromViewController: self + ) { [weak self] _, threadSoftDeleteManager in + self?.showDeleteAllActionSheet( + threadSoftDeleteManager: threadSoftDeleteManager + ) + } + } + + private func showDeleteAllActionSheet(threadSoftDeleteManager: any ThreadSoftDeleteManager) { let title: String let message: String let count = tableView.indexPathsForSelectedRows?.count ?? 0 @@ -306,7 +316,7 @@ extension ChatListViewController { // change as we're deleting them. self.databaseStorage.write { transaction in self.performOnAllSelectedEntries { threadViewModels in - DependenciesBridge.shared.threadSoftDeleteManager.softDelete( + threadSoftDeleteManager.softDelete( threads: threadViewModels.map { $0.threadRecord }, sendDeleteForMeSyncMessage: true, tx: transaction.asV2Write diff --git a/Signal/src/ViewControllers/HomeView/Chat List/ThreadSwipeHandler.swift b/Signal/src/ViewControllers/HomeView/Chat List/ThreadSwipeHandler.swift index 6677bde74b..17a1735349 100644 --- a/Signal/src/ViewControllers/HomeView/Chat List/ThreadSwipeHandler.swift +++ b/Signal/src/ViewControllers/HomeView/Chat List/ThreadSwipeHandler.swift @@ -85,8 +85,23 @@ extension ThreadSwipeHandler where Self: UIViewController { muteAction.accessibilityLabel = threadViewModel.isMuted ? CommonStrings.unmuteButton : CommonStrings.muteButton let deleteAction = UIContextualAction(style: .destructive, title: nil) { [weak self] (_, _, completion) in - self?.deleteThreadWithConfirmation(threadViewModel: threadViewModel, closeConversationBlock: closeConversationBlock) - completion(false) + guard let self else { + completion(false) + return + } + + DeleteForMeInfoSheetCoordinator.fromGlobals().coordinateDelete( + fromViewController: self + ) { [weak self] _, threadSoftDeleteManager in + guard let self else { return } + + self.deleteThreadWithConfirmation( + threadViewModel: threadViewModel, + threadSoftDeleteManager: threadSoftDeleteManager, + closeConversationBlock: closeConversationBlock + ) + completion(false) + } } deleteAction.backgroundColor = .ows_accentRed deleteAction.image = actionImage(name: "trash-fill", title: CommonStrings.deleteButton) @@ -140,36 +155,40 @@ extension ThreadSwipeHandler where Self: UIViewController { updateUIAfterSwipeAction() } - fileprivate func deleteThreadWithConfirmation(threadViewModel: ThreadViewModel, closeConversationBlock: (() -> Void)?) { + fileprivate func deleteThreadWithConfirmation( + threadViewModel: ThreadViewModel, + threadSoftDeleteManager: any ThreadSoftDeleteManager, + closeConversationBlock: (() -> Void)? + ) { AssertIsOnMainThread() let alert = ActionSheetController(title: OWSLocalizedString("CONVERSATION_DELETE_CONFIRMATION_ALERT_TITLE", comment: "Title for the 'conversation delete confirmation' alert."), message: OWSLocalizedString("CONVERSATION_DELETE_CONFIRMATION_ALERT_MESSAGE", comment: "Message for the 'conversation delete confirmation' alert.")) - alert.addAction(ActionSheetAction(title: CommonStrings.deleteButton, - style: .destructive) { [weak self] _ in - self?.deleteThread(threadViewModel: threadViewModel, closeConversationBlock: closeConversationBlock) + alert.addAction(ActionSheetAction( + title: CommonStrings.deleteButton, + style: .destructive + ) { [weak self] _ in + guard let self else { return } + + closeConversationBlock?() + + databaseStorage.write { transaction in + threadSoftDeleteManager.softDelete( + threads: [threadViewModel.threadRecord], + sendDeleteForMeSyncMessage: true, + tx: transaction.asV2Write + ) + } + + updateUIAfterSwipeAction() }) alert.addAction(OWSActionSheets.cancelAction) presentActionSheet(alert) } - func deleteThread(threadViewModel: ThreadViewModel, closeConversationBlock: (() -> Void)?) { - AssertIsOnMainThread() - - closeConversationBlock?() - databaseStorage.write { transaction in - DependenciesBridge.shared.threadSoftDeleteManager.softDelete( - threads: [threadViewModel.threadRecord], - sendDeleteForMeSyncMessage: true, - tx: transaction.asV2Write - ) - } - updateUIAfterSwipeAction() - } - func markThreadAsRead(threadViewModel: ThreadViewModel) { AssertIsOnMainThread() diff --git a/Signal/src/ViewControllers/MediaGallery/MediaGallery.swift b/Signal/src/ViewControllers/MediaGallery/MediaGallery.swift index 97895174f5..b5d8fa41c5 100644 --- a/Signal/src/ViewControllers/MediaGallery/MediaGallery.swift +++ b/Signal/src/ViewControllers/MediaGallery/MediaGallery.swift @@ -300,6 +300,8 @@ class MediaGallery: Dependencies { typealias Update = Sections.Update typealias Journal = [JournalingOrderedDictionaryChange] + private let thread: TSThread + // Used for filtering. private(set) var mediaFilter: AllMediaFilter private let mediaCategory: AllMediaCategory @@ -324,6 +326,7 @@ class MediaGallery: Dependencies { } init(thread: TSThread, mediaCategory: AllMediaCategory, spoilerState: SpoilerRenderState) { + self.thread = thread mediaFilter = AllMediaFilter.defaultMediaType(for: mediaCategory) let finder = MediaGalleryResourceFinder(thread: thread, filter: mediaFilter) self.mediaGalleryFinder = finder @@ -837,26 +840,22 @@ class MediaGallery: Dependencies { return false }() if shouldDeleteMessage { - // Refresh attachment list on the model, so deletion doesn't try to remove - // them again. Also, this ensures we've fetched the latest message details - // within this transaction. + // Refresh attachment list on the message, so deletion doesn't try to remove + // them again. We want to ensure we have the latest models within this transaction. message.anyReload(transaction: tx) + self.thread.anyReload(transaction: tx) - if let thread = message.thread(tx: tx) { - DependenciesBridge.shared.interactionDeleteManager.delete( - message, - sideEffects: .custom( - deleteForMeSyncMessage: .sendSyncMessage(interactionsThread: thread) - ), - tx: tx.asV2Write - ) - } else { - DependenciesBridge.shared.interactionDeleteManager.delete( - message, - sideEffects: .default(), - tx: tx.asV2Write - ) - } + // Since we don't know until we're deep in the write + // transaction whether we'll actually end up deleting an + // interaction, we'll skip showing the one-time "delete + // sync info sheet". + DependenciesBridge.shared.interactionDeleteManager.delete( + message, + sideEffects: .custom( + deleteForMeSyncMessage: .sendSyncMessage(interactionsThread: self.thread) + ), + tx: tx.asV2Write + ) } } } catch { diff --git a/Signal/translations/en.lproj/Localizable.strings b/Signal/translations/en.lproj/Localizable.strings index f97430eaeb..f5cb66210a 100644 --- a/Signal/translations/en.lproj/Localizable.strings +++ b/Signal/translations/en.lproj/Localizable.strings @@ -1786,6 +1786,27 @@ /* Toast message confirming the system contact was deleted. Embeds {{The name of the user who was deleted.}}. */ "DELETE_CONTACT_CONFIRMATION_TOAST" = "%@ was deleted from your contacts and removed."; +/* Header text for an action sheet confirming deleting a message in Note to Self. */ +"DELETE_FOR_ME_NOTE_TO_SELF_ACTION_SHEET_HEADER" = "Delete message?"; + +/* Title for an action sheet button explaining that a message will be deleted. */ +"DELETE_FOR_ME_NOTE_TO_SELF_LINKED_DEVICES_NOT_PRESENT_ACTION_SHEET_BUTTON_TITLE" = "Delete"; + +/* Title for an action sheet button explaining that a message will be deleted. */ +"DELETE_FOR_ME_NOTE_TO_SELF_LINKED_DEVICES_PRESENT_ACTION_SHEET_BUTTON_TITLE" = "Delete for Me"; + +/* Subheader for an action sheet explaining that a Note to Self deleted on this device will be deleted on the user's other devices as well. */ +"DELETE_FOR_ME_NOTE_TO_SELF_LINKED_DEVICES_PRESENT_ACTION_SHEET_SUBHEADER" = "This message will be deleted from all your devices."; + +/* Label for a button in an info sheet confirming that deletes are now synced across devices. */ +"DELETE_FOR_ME_SYNC_MESSAGE_INFO_SHEET_BUTTON" = "Got it"; + +/* Subtitle for an info sheet explaining that deletes are now synced across devices. */ +"DELETE_FOR_ME_SYNC_MESSAGE_INFO_SHEET_SUBTITLE" = "When you delete messages or chats, they will be deleted from your phone and all linked devices."; + +/* Title for an info sheet explaining that deletes are now synced across devices. */ +"DELETE_FOR_ME_SYNC_MESSAGE_INFO_SHEET_TITLE" = "Deleting is now synced across all of your devices"; + /* Label indicating a user who deleted their account. */ "DELETED_USER" = "Deleted Account"; diff --git a/SignalServiceKit/Contacts/Threads/ThreadSoftDeleteManager.swift b/SignalServiceKit/Contacts/Threads/ThreadSoftDeleteManager.swift index 81b663161f..e477087c37 100644 --- a/SignalServiceKit/Contacts/Threads/ThreadSoftDeleteManager.swift +++ b/SignalServiceKit/Contacts/Threads/ThreadSoftDeleteManager.swift @@ -7,6 +7,19 @@ import Intents import LibSignalClient import SignalCoreKit +/// Responsible for "soft-deleting" threads, or removing their contents without +/// removing the `TSThread` record itself. The app's architecture is to never\* +/// delete the thread itself, but instead to delete all data associated with the +/// thread, in case the thread is needed again later on. +/// +/// \*Threads can be hard-deleted, but only in niche scenarios. +/// +/// - SeeAlso ``ThreadRemover``. +/// +/// - SeeAlso +/// If you're calling this type for a user-initiated deletion, consider using +/// ``DeleteForMeInfoSheetCoordinator`` in the Signal target instead, which +/// handles some one-time informational UX. public protocol ThreadSoftDeleteManager { func softDelete( threads: [TSThread], @@ -168,7 +181,7 @@ final class ThreadSoftDeleteManagerImpl: ThreadSoftDeleteManager { ) let callDeleteBehavior: InteractionDelete.SideEffects.AssociatedCallDeleteBehavior = { - if deleteForMeOutgoingSyncMessageManager.isSendingEnabled() { + if DeleteForMeSyncMessage.isSendingEnabled { /// If we're able to send a `DeleteForMe` sync /// message, we don't need to send `CallEvent`s... return .localDeleteOnly diff --git a/SignalServiceKit/Devices/OWSDevice.swift b/SignalServiceKit/Devices/OWSDevice.swift index ab1c426708..5356356a3f 100644 --- a/SignalServiceKit/Devices/OWSDevice.swift +++ b/SignalServiceKit/Devices/OWSDevice.swift @@ -118,6 +118,10 @@ public extension OWSDevice { var isPrimaryDevice: Bool { deviceId == Self.primaryDeviceId } + + var isLinkedDevice: Bool { + !isPrimaryDevice + } } // MARK: - Replace all diff --git a/SignalServiceKit/Devices/OWSDeviceStore.swift b/SignalServiceKit/Devices/OWSDeviceStore.swift index 9c1d1314fa..545e89d8a6 100644 --- a/SignalServiceKit/Devices/OWSDeviceStore.swift +++ b/SignalServiceKit/Devices/OWSDeviceStore.swift @@ -7,6 +7,12 @@ public protocol OWSDeviceStore { func fetchAll(tx: DBReadTransaction) -> [OWSDevice] } +public extension OWSDeviceStore { + func hasLinkedDevices(tx: DBReadTransaction) -> Bool { + return fetchAll(tx: tx).contains { $0.isLinkedDevice } + } +} + class OWSDeviceStoreImpl: OWSDeviceStore { func fetchAll(tx: DBReadTransaction) -> [OWSDevice] { return OWSDevice.anyFetchAll(transaction: SDSDB.shimOnlyBridge(tx)) diff --git a/SignalServiceKit/Messages/DeviceSyncing/DeleteForMe/DeleteForMeOutgoingSyncMessageManager.swift b/SignalServiceKit/Messages/DeviceSyncing/DeleteForMe/DeleteForMeOutgoingSyncMessageManager.swift index 97dbcf651f..5551f82e43 100644 --- a/SignalServiceKit/Messages/DeviceSyncing/DeleteForMe/DeleteForMeOutgoingSyncMessageManager.swift +++ b/SignalServiceKit/Messages/DeviceSyncing/DeleteForMe/DeleteForMeOutgoingSyncMessageManager.swift @@ -58,9 +58,9 @@ public protocol DeleteForMeOutgoingSyncMessageManager { ) -> Outgoing.ThreadDeletionContext? } -extension DeleteForMeOutgoingSyncMessageManager { +extension DeleteForMeSyncMessage { /// Is sending a `DeleteForMe` sync message enabled at all? - func isSendingEnabled() -> Bool { + public static var isSendingEnabled: Bool { // [DeleteForMe] TODO: We can remove this 90d after release. return FeatureFlags.shouldSendDeleteForMeSyncMessages || RemoteConfig.shouldSendDeleteForMeSyncMessages @@ -231,7 +231,7 @@ final class DeleteForMeOutgoingSyncMessageManagerImpl: DeleteForMeOutgoingSyncMe contents: DeleteForMeOutgoingSyncMessage.Contents, tx: any DBWriteTransaction ) { - guard isSendingEnabled() else { + guard DeleteForMeSyncMessage.isSendingEnabled else { logger.warn("Skipping delete-for-me sync message, feature not enabled!") return } diff --git a/SignalServiceKit/Messages/Interactions/InteractionDeleteManager.swift b/SignalServiceKit/Messages/Interactions/InteractionDeleteManager.swift index 7a73facbc7..c198e6f98b 100644 --- a/SignalServiceKit/Messages/Interactions/InteractionDeleteManager.swift +++ b/SignalServiceKit/Messages/Interactions/InteractionDeleteManager.swift @@ -86,6 +86,11 @@ public enum InteractionDelete { /// delete call records alongside their associated interactions. This may seem /// counterintuitive, but avoids a circular dependency between interaction and /// call record deletion. +/// +/// - SeeAlso +/// If you're calling this type for a user-initiated deletion, consider using +/// ``DeleteForMeInfoSheetCoordinator`` in the Signal target instead, which +/// handles some one-time informational UX. public protocol InteractionDeleteManager { typealias SideEffects = InteractionDelete.SideEffects diff --git a/SignalServiceKit/Util/ThreadUtil.swift b/SignalServiceKit/Util/ThreadUtil.swift index bac16d49e9..1209d59ca1 100644 --- a/SignalServiceKit/Util/ThreadUtil.swift +++ b/SignalServiceKit/Util/ThreadUtil.swift @@ -252,31 +252,6 @@ extension ThreadUtil { } } -// MARK: - Delete all content - -extension ThreadUtil { - public static func deleteAllContentWithSneakyTransaction() { - Logger.info("") - - databaseStorage.write { transaction in - DependenciesBridge.shared.threadSoftDeleteManager.softDelete( - threads: TSThread.anyFetchAll(transaction: transaction), - sendDeleteForMeSyncMessage: true, - tx: transaction.asV2Write - ) - - StoryMessage.anyRemoveAllWithInstantiation(transaction: transaction) - TSAttachment.anyRemoveAllWithInstantiation(transaction: transaction) - - // Deleting attachments above should be enough to remove any gallery items, but - // we redunantly clean up *all* gallery items to be safe. - MediaGalleryRecordManager.didRemoveAllContent(transaction: transaction) - } - - TSAttachmentStream.deleteAttachmentsFromDisk() - } -} - // MARK: - Sharing Suggestions import Intents diff --git a/SignalUI/UIKitExtensions/UIView+AutoLayout.swift b/SignalUI/UIKitExtensions/UIView+AutoLayout.swift index a8a9d038db..5855ec113d 100644 --- a/SignalUI/UIKitExtensions/UIView+AutoLayout.swift +++ b/SignalUI/UIKitExtensions/UIView+AutoLayout.swift @@ -39,6 +39,14 @@ public extension UIView { return autoPinEdge(toSuperviewMargin: .trailing, withInset: inset) } + @discardableResult + func autoPinWidthToSuperviewMargins(withInset inset: CGFloat) -> [NSLayoutConstraint] { + return [ + autoPinEdge(toSuperviewMargin: .leading, withInset: inset), + autoPinEdge(toSuperviewMargin: .trailing, withInset: inset) + ] + } + @discardableResult func autoPinWidthToSuperviewMargins(relation: NSLayoutConstraint.Relation = .equal) -> [NSLayoutConstraint] { // We invert the relation because of the weird grammar switch when talking about diff --git a/SignalUI/ViewControllers/ModalActivityIndicatorViewController.swift b/SignalUI/ViewControllers/ModalActivityIndicatorViewController.swift index 563b0f09b1..ec49f8a23f 100644 --- a/SignalUI/ViewControllers/ModalActivityIndicatorViewController.swift +++ b/SignalUI/ViewControllers/ModalActivityIndicatorViewController.swift @@ -9,6 +9,9 @@ import SignalServiceKit // A modal view that be used during blocking interactions (e.g. waiting on response from // service or on the completion of a long-running local operation). public class ModalActivityIndicatorViewController: OWSViewController { + public enum Constants { + public static let defaultPresentationDelay: TimeInterval = 0.05 + } let canCancel: Bool @@ -27,7 +30,6 @@ public class ModalActivityIndicatorViewController: OWSViewController { var wasDimissed: Bool = false - private static let kPresentationDelayDefault: TimeInterval = 0.05 private let presentationDelay: TimeInterval // MARK: Initializers @@ -47,21 +49,8 @@ public class ModalActivityIndicatorViewController: OWSViewController { public class func present( fromViewController: UIViewController, canCancel: Bool, - backgroundBlock: @escaping (ModalActivityIndicatorViewController) -> Void - ) { - present( - fromViewController: fromViewController, - canCancel: canCancel, - presentationDelay: kPresentationDelayDefault, - isInvisible: false, - backgroundBlock: backgroundBlock - ) - } - - public class func present( - fromViewController: UIViewController, - canCancel: Bool, - presentationDelay: TimeInterval, + presentationDelay: TimeInterval = Constants.defaultPresentationDelay, + backgroundBlockQueueQos: DispatchQoS = .default, backgroundBlock: @escaping (ModalActivityIndicatorViewController) -> Void ) { present( @@ -69,6 +58,7 @@ public class ModalActivityIndicatorViewController: OWSViewController { canCancel: canCancel, presentationDelay: presentationDelay, isInvisible: false, + backgroundBlockQueueQos: backgroundBlockQueueQos, backgroundBlock: backgroundBlock ) } @@ -80,17 +70,19 @@ public class ModalActivityIndicatorViewController: OWSViewController { present( fromViewController: fromViewController, canCancel: false, - presentationDelay: kPresentationDelayDefault, + presentationDelay: Constants.defaultPresentationDelay, isInvisible: true, + backgroundBlockQueueQos: .default, backgroundBlock: backgroundBlock ) } - public class func present( + private class func present( fromViewController: UIViewController, canCancel: Bool, presentationDelay: TimeInterval, isInvisible: Bool, + backgroundBlockQueueQos: DispatchQoS, backgroundBlock: @escaping (ModalActivityIndicatorViewController) -> Void ) { AssertIsOnMainThread() @@ -103,7 +95,7 @@ public class ModalActivityIndicatorViewController: OWSViewController { // Present this modal _over_ the current view contents. view.modalPresentationStyle = .overFullScreen fromViewController.present(view, animated: false) { - DispatchQueue.global().async { + DispatchQueue.global(qos: backgroundBlockQueueQos.qosClass).async { backgroundBlock(view) } }