Informational UX for delete syncs

This commit is contained in:
Sasha Weiss 2024-06-11 16:39:22 -07:00 committed by GitHub
parent 84295351e8
commit 41d2a3d1b2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
23 changed files with 669 additions and 144 deletions

View File

@ -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 = "<group>"; };
D9E7C8782B9B9072005BD3B9 /* IdentifierIndexedArray.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IdentifierIndexedArray.swift; sourceTree = "<group>"; };
D9E8EDEC2C0EAFE700923E3C /* DeleteForMeOutgoingSyncMessageManagerTest.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DeleteForMeOutgoingSyncMessageManagerTest.swift; sourceTree = "<group>"; };
D9E8EDF02C0FCB3000923E3C /* DeleteForMeSyncMessageInfoSheet.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DeleteForMeSyncMessageInfoSheet.swift; sourceTree = "<group>"; };
D9E8EDF22C0FD8C800923E3C /* DeleteForMeInfoSheetCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeleteForMeInfoSheetCoordinator.swift; sourceTree = "<group>"; };
D9EB22192A4B636B00C73E1D /* Bitmaps+LineDrawing.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Bitmaps+LineDrawing.swift"; sourceTree = "<group>"; };
D9EB221A2A4B636B00C73E1D /* Bitmaps+Shapes.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Bitmaps+Shapes.swift"; sourceTree = "<group>"; };
D9EB221B2A4B636B00C73E1D /* Bitmaps+Image.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Bitmaps+Image.swift"; sourceTree = "<group>"; };
@ -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 = "<group>";
};
D9E8EDEF2C0FCB0600923E3C /* DeleteForMe */ = {
isa = PBXGroup;
children = (
D9E8EDF22C0FD8C800923E3C /* DeleteForMeInfoSheetCoordinator.swift */,
D9E8EDF02C0FCB3000923E3C /* DeleteForMeSyncMessageInfoSheet.swift */,
);
path = DeleteForMe;
sourceTree = "<group>";
};
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 */,

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,12 @@
{
"images" : [
{
"filename" : "delete-sync-dark.pdf",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@ -0,0 +1,12 @@
{
"images" : [
{
"filename" : "delete-sync-light.pdf",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

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

View File

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

View File

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

View File

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

View File

@ -300,6 +300,8 @@ class MediaGallery: Dependencies {
typealias Update = Sections.Update
typealias Journal = [JournalingOrderedDictionaryChange<Sections.ItemChange>]
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 {

View File

@ -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";

View File

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

View File

@ -118,6 +118,10 @@ public extension OWSDevice {
var isPrimaryDevice: Bool {
deviceId == Self.primaryDeviceId
}
var isLinkedDevice: Bool {
!isPrimaryDevice
}
}
// MARK: - Replace all

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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