From 03bdb55e21176aa8fa0e071a2dbebfb8661f7d4e Mon Sep 17 00:00:00 2001 From: Nora Trapp Date: Tue, 29 Mar 2022 15:31:02 -0700 Subject: [PATCH] Add context menu to group replies --- Signal.xcodeproj/project.pbxproj | 4 + .../ContextMenuActionsAccessory.swift | 29 +++++--- .../ContextMenuConfiguration.swift | 4 + .../Context Menus/ContextMenuController.swift | 3 +- .../ContextMenuInteraction.swift | 12 +-- .../CV/CVItemViewModelImpl.swift | 9 +-- ...iewController+MessageActionsDelegate.swift | 48 +----------- .../ConversationViewController+OWS.swift | 36 --------- .../StoryGroupReplySheet.swift | 72 ++++++++++++++++++ .../StoryGroupReplyViewItem.swift | 3 + .../translations/en.lproj/Localizable.strings | 9 +++ .../src/Messages/Interactions/TSMessage.swift | 12 +++ SignalUI/Appearance/Theme+OWS.swift | 10 ++- SignalUI/Utils/TSMessage+SignalUI.swift | 74 +++++++++++++++++++ 14 files changed, 217 insertions(+), 108 deletions(-) create mode 100644 SignalUI/Utils/TSMessage+SignalUI.swift diff --git a/Signal.xcodeproj/project.pbxproj b/Signal.xcodeproj/project.pbxproj index a91bb9d161..465a6ec316 100644 --- a/Signal.xcodeproj/project.pbxproj +++ b/Signal.xcodeproj/project.pbxproj @@ -941,6 +941,7 @@ 88C4E38024635337009C9B97 /* DeviceTransferService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 88C4E37F24635337009C9B97 /* DeviceTransferService.swift */; }; 88C659B024688335002AC115 /* SelfSignedIdentity.swift in Sources */ = {isa = PBXBuildFile; fileRef = 88C659AF24688335002AC115 /* SelfSignedIdentity.swift */; }; 88C7597324B7EAA600DB03EA /* AdvancedPinSettingsTableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 88C7597224B7EAA600DB03EA /* AdvancedPinSettingsTableViewController.swift */; }; + 88C980D427F3AD2C009750C0 /* TSMessage+SignalUI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 88C980D327F3AD2C009750C0 /* TSMessage+SignalUI.swift */; }; 88CB462225843758001900F2 /* GroupCallTooltip.swift in Sources */ = {isa = PBXBuildFile; fileRef = 88CB462125843758001900F2 /* GroupCallTooltip.swift */; }; 88D1BCB924F73C05009A1738 /* PhoneNumberSharingSettingsTableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 88D1BCB824F73C04009A1738 /* PhoneNumberSharingSettingsTableViewController.swift */; }; 88D1BCBB24F73C15009A1738 /* PhoneNumberDiscoverabilitySettingsTableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 88D1BCBA24F73C15009A1738 /* PhoneNumberDiscoverabilitySettingsTableViewController.swift */; }; @@ -2247,6 +2248,7 @@ 88C4E37F24635337009C9B97 /* DeviceTransferService.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DeviceTransferService.swift; sourceTree = ""; }; 88C659AF24688335002AC115 /* SelfSignedIdentity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelfSignedIdentity.swift; sourceTree = ""; }; 88C7597224B7EAA600DB03EA /* AdvancedPinSettingsTableViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AdvancedPinSettingsTableViewController.swift; sourceTree = ""; }; + 88C980D327F3AD2C009750C0 /* TSMessage+SignalUI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TSMessage+SignalUI.swift"; sourceTree = ""; }; 88CB462125843758001900F2 /* GroupCallTooltip.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroupCallTooltip.swift; sourceTree = ""; }; 88D1BCB824F73C04009A1738 /* PhoneNumberSharingSettingsTableViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhoneNumberSharingSettingsTableViewController.swift; sourceTree = ""; }; 88D1BCBA24F73C15009A1738 /* PhoneNumberDiscoverabilitySettingsTableViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhoneNumberDiscoverabilitySettingsTableViewController.swift; sourceTree = ""; }; @@ -3189,6 +3191,7 @@ 342FFE63271DB66E000AC89F /* OWSProfileManager+SignalUI.h */, 342FFE64271DB66E000AC89F /* OWSProfileManager+SignalUI.m */, 342FFE56271DA8C9000AC89F /* OWSSounds+SignalUI.swift */, + 88C980D327F3AD2C009750C0 /* TSMessage+SignalUI.swift */, ); path = Utils; sourceTree = ""; @@ -5827,6 +5830,7 @@ 34A955AE271B533000B05242 /* FullTextSearcher.swift in Sources */, 3402AA58271D9DCD0084CBAE /* OWSNavigationController.m in Sources */, 3402A9FF271D9D7B0084CBAE /* OWSAnyTouchGestureRecognizer.m in Sources */, + 88C980D427F3AD2C009750C0 /* TSMessage+SignalUI.swift in Sources */, 342FFE66271DB66E000AC89F /* OWSProfileManager+SignalUI.m in Sources */, 3402AAB2271D9E180084CBAE /* ContactTableViewCell.swift in Sources */, 3402AA74271D9E180084CBAE /* OWSBubbleShapeView.swift in Sources */, diff --git a/Signal/src/ViewControllers/Context Menus/ContextMenuActionsAccessory.swift b/Signal/src/ViewControllers/Context Menus/ContextMenuActionsAccessory.swift index 83ac763682..471ae143e1 100644 --- a/Signal/src/ViewControllers/Context Menus/ContextMenuActionsAccessory.swift +++ b/Signal/src/ViewControllers/Context Menus/ContextMenuActionsAccessory.swift @@ -17,11 +17,12 @@ public class ContextMenuActionsAccessory: ContextMenuTargetedPreviewAccessory, C public init( menu: ContextMenu, - accessoryAlignment: AccessoryAlignment + accessoryAlignment: AccessoryAlignment, + forceDarkTheme: Bool = false ) { self.menu = menu - menuView = ContextMenuActionsView(menu: menu) + menuView = ContextMenuActionsView(menu: menu, forceDarkTheme: forceDarkTheme) menuView.isHidden = true super.init(accessoryView: menuView, accessoryAlignment: accessoryAlignment) menuView.delegate = self @@ -134,6 +135,7 @@ public class ContextMenuActionsView: UIView, UIGestureRecognizerDelegate, UIScro private class ContextMenuActionRow: UIView { let attributes: ContextMenuAction.Attributes let hostEffect: UIBlurEffect + let forceDarkTheme: Bool let titleLabel: UILabel let iconView: UIImageView let separatorView: UIVisualEffectView @@ -147,8 +149,8 @@ public class ContextMenuActionsView: UIView, UIGestureRecognizerDelegate, UIScro vibrancyView.frame = bounds let view = UIView(frame: bounds) view.autoresizingMask = [.flexibleWidth, .flexibleHeight] - view.backgroundColor = Theme.isDarkThemeEnabled ? .ows_gray80 : .ows_gray12 - if Theme.isDarkThemeEnabled { + view.backgroundColor = forceDarkTheme || Theme.isDarkThemeEnabled ? .ows_gray80 : .ows_gray12 + if forceDarkTheme || Theme.isDarkThemeEnabled { vibrancyView.backgroundColor = UIColor.ows_whiteAlpha20 } view.alpha = 0.3 @@ -175,7 +177,8 @@ public class ContextMenuActionsView: UIView, UIGestureRecognizerDelegate, UIScro title: String, icon: UIImage?, attributes: ContextMenuAction.Attributes, - hostBlurEffect: UIBlurEffect + hostBlurEffect: UIBlurEffect, + forceDarkTheme: Bool ) { titleLabel = UILabel(frame: CGRect.zero) titleLabel.text = title @@ -184,13 +187,14 @@ public class ContextMenuActionsView: UIView, UIGestureRecognizerDelegate, UIScro self.attributes = attributes hostEffect = hostBlurEffect + self.forceDarkTheme = forceDarkTheme if attributes.contains(.destructive) { titleLabel.textColor = Theme.ActionSheet.default.destructiveButtonTextColor } else if attributes.contains(.disabled) { - titleLabel.textColor = Theme.secondaryTextAndIconColor + titleLabel.textColor = forceDarkTheme ? Theme.darkThemeSecondaryTextAndIconColor : Theme.secondaryTextAndIconColor } else { - titleLabel.textColor = Theme.primaryTextColor + titleLabel.textColor = forceDarkTheme ? Theme.darkThemePrimaryColor : Theme.primaryTextColor } iconView = UIImageView(image: icon) @@ -198,12 +202,12 @@ public class ContextMenuActionsView: UIView, UIGestureRecognizerDelegate, UIScro iconView.tintColor = titleLabel.textColor separatorView = UIVisualEffectView(effect: UIVibrancyEffect(blurEffect: hostBlurEffect)) - if Theme.isDarkThemeEnabled { + if forceDarkTheme || Theme.isDarkThemeEnabled { separatorView.backgroundColor = UIColor.ows_whiteAlpha20 } let separator = UIView(frame: separatorView.bounds) - separator.backgroundColor = Theme.isDarkThemeEnabled ? .ows_gray75 : .ows_gray22 + separator.backgroundColor = forceDarkTheme || Theme.isDarkThemeEnabled ? .ows_gray75 : .ows_gray22 separator.autoresizingMask = [.flexibleWidth, .flexibleHeight] separatorView.contentView.addSubview(separator) isHighlighted = false @@ -263,6 +267,7 @@ public class ContextMenuActionsView: UIView, UIGestureRecognizerDelegate, UIScro weak var delegate: ContextMenuActionsViewDelegate? public let menu: ContextMenu + public let forceDarkTheme: Bool private let actionViews: [ContextMenuActionRow] private let scrollView: UIScrollView @@ -284,9 +289,11 @@ public class ContextMenuActionsView: UIView, UIGestureRecognizerDelegate, UIScro let cornerRadius: CGFloat = 12 public init( - menu: ContextMenu + menu: ContextMenu, + forceDarkTheme: Bool = false ) { self.menu = menu + self.forceDarkTheme = forceDarkTheme scrollView = UIScrollView(frame: CGRect.zero) let effect = UIBlurEffect(style: UIBlurEffect.Style.prominent) @@ -294,7 +301,7 @@ public class ContextMenuActionsView: UIView, UIGestureRecognizerDelegate, UIScro var actionViews: [ContextMenuActionRow] = [] for action in menu.children { - let actionView = ContextMenuActionRow(title: action.title, icon: action.image, attributes: action.attributes, hostBlurEffect: effect) + let actionView = ContextMenuActionRow(title: action.title, icon: action.image, attributes: action.attributes, hostBlurEffect: effect, forceDarkTheme: forceDarkTheme) actionViews.append(actionView) } diff --git a/Signal/src/ViewControllers/Context Menus/ContextMenuConfiguration.swift b/Signal/src/ViewControllers/Context Menus/ContextMenuConfiguration.swift index 2c4d39917f..773143a464 100644 --- a/Signal/src/ViewControllers/Context Menus/ContextMenuConfiguration.swift +++ b/Signal/src/ViewControllers/Context Menus/ContextMenuConfiguration.swift @@ -160,6 +160,7 @@ public class ContextMenuTargetedPreview { public let snapshot: UIView? public var auxiliarySnapshot: UIView? public let alignment: Alignment + public var alignmentOffset: CGPoint? public let accessoryViews: [ContextMenuTargetedPreviewAccessory] /// Default targeted preview initializer @@ -196,9 +197,11 @@ public typealias ContextMenuActionProvider = ([ContextMenuAction]) -> ContextMen public class ContextMenuConfiguration { public let identifier: NSCopying public let actionProvider: ContextMenuActionProvider? + public let forceDarkTheme: Bool public init ( identifier: NSCopying?, + forceDarkTheme: Bool = false, actionProvider: ContextMenuActionProvider? ) { if let ident = identifier { @@ -207,6 +210,7 @@ public class ContextMenuConfiguration { self.identifier = UUID() as NSCopying } + self.forceDarkTheme = forceDarkTheme self.actionProvider = actionProvider } } diff --git a/Signal/src/ViewControllers/Context Menus/ContextMenuController.swift b/Signal/src/ViewControllers/Context Menus/ContextMenuController.swift index 81f81fb27f..1510563773 100644 --- a/Signal/src/ViewControllers/Context Menus/ContextMenuController.swift +++ b/Signal/src/ViewControllers/Context Menus/ContextMenuController.swift @@ -386,6 +386,7 @@ class ContextMenuController: UIViewController, ContextMenuViewDelegate, UIGestur self.menuAccessory = menuAccessory self.presentImmediately = presentImmediately super.init(nibName: nil, bundle: nil) + if #available(iOS 13, *), configuration.forceDarkTheme { overrideUserInterfaceStyle = .dark } } required init?(coder: NSCoder) { @@ -448,7 +449,7 @@ class ContextMenuController: UIViewController, ContextMenuViewDelegate, UIGestur UIView.animate(withDuration: animationDuration / 2.0) { if !UIDevice.current.isIPad { self.blurView.effect = UIBlurEffect(style: UIBlurEffect.Style.regular) - self.blurView.backgroundColor = Theme.isDarkThemeEnabled ? UIColor.ows_whiteAlpha20 : UIColor.ows_blackAlpha20 + self.blurView.backgroundColor = self.contextMenuConfiguration.forceDarkTheme || Theme.isDarkThemeEnabled ? UIColor.ows_whiteAlpha20 : UIColor.ows_blackAlpha20 } else { self.blurView.backgroundColor = UIColor.ows_blackAlpha40 } diff --git a/Signal/src/ViewControllers/Context Menus/ContextMenuInteraction.swift b/Signal/src/ViewControllers/Context Menus/ContextMenuInteraction.swift index 87479d6f86..cec5b24417 100644 --- a/Signal/src/ViewControllers/Context Menus/ContextMenuInteraction.swift +++ b/Signal/src/ViewControllers/Context Menus/ContextMenuInteraction.swift @@ -155,7 +155,7 @@ public class ContextMenuInteraction: NSObject, UIInteraction { public func presentMenu(window: UIWindow, contextMenuConfiguration: ContextMenuConfiguration, targetedPreview: ContextMenuTargetedPreview, presentImmediately: Bool) { - let menuAccessory = menuAccessory(configuration: contextMenuConfiguration, previewAlignment: targetedPreview.alignment) + let menuAccessory = menuAccessory(configuration: contextMenuConfiguration, targetedPreview: targetedPreview) let contextMenuController = ContextMenuController(configuration: contextMenuConfiguration, preview: targetedPreview, initiatingGestureRecognizer: initiatingGestureRecognizer(), menuAccessory: menuAccessory, presentImmediately: presentImmediately) contextMenuController.delegate = self self.contextMenuController = contextMenuController @@ -171,11 +171,11 @@ public class ContextMenuInteraction: NSObject, UIInteraction { return longPressGestureRecognizer } - public func menuAccessory(configuration: ContextMenuConfiguration, previewAlignment: ContextMenuTargetedPreview.Alignment) -> ContextMenuActionsAccessory { + public func menuAccessory(configuration: ContextMenuConfiguration, targetedPreview: ContextMenuTargetedPreview) -> ContextMenuActionsAccessory { var alignments: [(ContextMenuTargetedPreviewAccessory.AccessoryAlignment.Edge, ContextMenuTargetedPreviewAccessory.AccessoryAlignment.Origin)] = [(.bottom, .exterior)] - switch previewAlignment { + switch targetedPreview.alignment { case .left: alignments.append((CurrentAppContext().isRTL ? .trailing : .leading, .interior)) case .right: @@ -185,8 +185,8 @@ public class ContextMenuInteraction: NSObject, UIInteraction { } let menu = configuration.actionProvider?([]) ?? ContextMenu([]) - let alignment = ContextMenuTargetedPreviewAccessory.AccessoryAlignment(alignments: alignments, alignmentOffset: CGPoint(x: 0, y: 12)) - let accessory = ContextMenuActionsAccessory(menu: menu, accessoryAlignment: alignment) + let alignment = ContextMenuTargetedPreviewAccessory.AccessoryAlignment(alignments: alignments, alignmentOffset: targetedPreview.alignmentOffset ?? CGPoint(x: 0, y: 12)) + let accessory = ContextMenuActionsAccessory(menu: menu, accessoryAlignment: alignment, forceDarkTheme: configuration.forceDarkTheme) accessory.delegate = self return accessory } @@ -327,7 +327,7 @@ public class ChatHistoryContextMenuInteraction: ContextMenuInteraction { return chatHistoryLongPressGesture } - public override func menuAccessory(configuration: ContextMenuConfiguration, previewAlignment: ContextMenuTargetedPreview.Alignment) -> ContextMenuActionsAccessory { + public override func menuAccessory(configuration: ContextMenuConfiguration, targetedPreview: ContextMenuTargetedPreview) -> ContextMenuActionsAccessory { let isRTL = CurrentAppContext().isRTL let menu = configuration.actionProvider?([]) ?? ContextMenu([]) let isIncomingMessage = itemViewModel.interaction.interactionType == .incomingMessage diff --git a/Signal/src/ViewControllers/ConversationView/CV/CVItemViewModelImpl.swift b/Signal/src/ViewControllers/ConversationView/CV/CVItemViewModelImpl.swift index 97d774b0c2..be015ae305 100644 --- a/Signal/src/ViewControllers/ConversationView/CV/CVItemViewModelImpl.swift +++ b/Signal/src/ViewControllers/ConversationView/CV/CVItemViewModelImpl.swift @@ -1,5 +1,5 @@ // -// Copyright (c) 2021 Open Whisper Systems. All rights reserved. +// Copyright (c) 2022 Open Whisper Systems. All rights reserved. // import Foundation @@ -227,13 +227,6 @@ extension CVItemViewModelImpl { return !hasUnloadedAttachments } } - - func deleteAction() { - let interaction = self.interaction - databaseStorage.asyncWrite { transaction in - interaction.anyRemove(transaction: transaction) - } - } } // MARK: - diff --git a/Signal/src/ViewControllers/ConversationView/ConversationViewController+MessageActionsDelegate.swift b/Signal/src/ViewControllers/ConversationView/ConversationViewController+MessageActionsDelegate.swift index bbd89b749c..18ab904482 100644 --- a/Signal/src/ViewControllers/ConversationView/ConversationViewController+MessageActionsDelegate.swift +++ b/Signal/src/ViewControllers/ConversationView/ConversationViewController+MessageActionsDelegate.swift @@ -1,5 +1,5 @@ // -// Copyright (c) 2021 Open Whisper Systems. All rights reserved. +// Copyright (c) 2022 Open Whisper Systems. All rights reserved. // extension ConversationViewController: MessageActionsDelegate { @@ -95,49 +95,7 @@ extension ConversationViewController: MessageActionsDelegate { } func messageActionsDeleteItem(_ itemViewModel: CVItemViewModelImpl) { - let actionSheetController = ActionSheetController(message: NSLocalizedString( - "MESSAGE_ACTION_DELETE_FOR_TITLE", - comment: "The title for the action sheet asking who the user wants to delete the message for." - )) - - let deleteForMeAction = ActionSheetAction( - title: CommonStrings.deleteForMeButton, - style: .destructive - ) { _ in - itemViewModel.deleteAction() - } - actionSheetController.addAction(deleteForMeAction) - - if canBeRemotelyDeleted(item: itemViewModel), - let message = itemViewModel.interaction as? TSOutgoingMessage { - - let deleteForEveryoneAction = ActionSheetAction( - title: NSLocalizedString( - "MESSAGE_ACTION_DELETE_FOR_EVERYONE", - comment: "The title for the action that deletes a message for all users in the conversation." - ), - style: .destructive - ) { [weak self] _ in - self?.showDeleteForEveryoneConfirmationIfNecessary { - guard let self = self else { return } - - let deleteMessage = TSOutgoingDeleteMessage(thread: self.thread, message: message) - - self.databaseStorage.write { transaction in - // Reset the sending states, so we can render the sending state of the deleted message. - // TSOutgoingDeleteMessage will automatically pass through it's send state to the message - // record that it is deleting. - message.updateWith(recipientAddressStates: deleteMessage.recipientAddressStates, transaction: transaction) - message.updateWithRemotelyDeletedAndRemoveRenderableContent(with: transaction) - Self.messageSenderJobQueue.add(message: deleteMessage.asPreparer, transaction: transaction) - } - } - } - actionSheetController.addAction(deleteForEveryoneAction) - } - - actionSheetController.addAction(OWSActionSheets.cancelAction) - - presentActionSheet(actionSheetController) + guard let message = itemViewModel.interaction as? TSMessage else { return } + message.presentDeletionActionSheet(from: self) } } diff --git a/Signal/src/ViewControllers/ConversationView/ConversationViewController+OWS.swift b/Signal/src/ViewControllers/ConversationView/ConversationViewController+OWS.swift index f50bbccb96..9fbb5da7da 100644 --- a/Signal/src/ViewControllers/ConversationView/ConversationViewController+OWS.swift +++ b/Signal/src/ViewControllers/ConversationView/ConversationViewController+OWS.swift @@ -236,42 +236,6 @@ extension ConversationViewController: ForwardMessageDelegate { } } -// MARK: - - -extension ConversationViewController { - - // A message can be remotely deleted iff: - // * the feature flag is enabled - // * you sent this message - // * you haven't already remotely deleted this message - // * it has been less than 3 hours since you sent the message - func canBeRemotelyDeleted(item: CVItemViewModel) -> Bool { - guard let outgoingMessage = item.interaction as? TSOutgoingMessage else { return false } - guard !outgoingMessage.wasRemotelyDeleted else { return false } - guard Date.ows_millisecondTimestamp() - outgoingMessage.timestamp <= (kHourInMs * 3) else { return false } - - return true - } - - func showDeleteForEveryoneConfirmationIfNecessary(completion: @escaping () -> Void) { - guard !Self.preferences.wasDeleteForEveryoneConfirmationShown() else { return completion() } - - OWSActionSheets.showConfirmationAlert( - title: NSLocalizedString( - "MESSAGE_ACTION_DELETE_FOR_EVERYONE_CONFIRMATION", - comment: "A one-time confirmation that you want to delete for everyone" - ), - proceedTitle: NSLocalizedString( - "MESSAGE_ACTION_DELETE_FOR_EVERYONE", - comment: "The title for the action that deletes a message for all users in the conversation." - ), - proceedStyle: .destructive) { _ in - Self.preferences.setWasDeleteForEveryoneConfirmationShown() - completion() - } - } -} - // MARK: - MessageActionsToolbarDelegate extension ConversationViewController: MessageActionsToolbarDelegate { diff --git a/Signal/src/ViewControllers/HomeView/Stories/Group Reply Sheet/StoryGroupReplySheet.swift b/Signal/src/ViewControllers/HomeView/Stories/Group Reply Sheet/StoryGroupReplySheet.swift index e36b04408f..5d6314d9de 100644 --- a/Signal/src/ViewControllers/HomeView/Stories/Group Reply Sheet/StoryGroupReplySheet.swift +++ b/Signal/src/ViewControllers/HomeView/Stories/Group Reply Sheet/StoryGroupReplySheet.swift @@ -12,6 +12,7 @@ class StoryGroupReplySheet: InteractiveSheetViewController { private lazy var tableView = UITableView() private lazy var inputToolbar = StoryReplyInputToolbar() private lazy var inputToolbarBottomConstraint = inputToolbar.autoPinEdge(toSuperviewEdge: .bottom) + private lazy var contextMenu = ContextMenuInteraction(delegate: self) private lazy var inputAccessoryPlaceholder: InputAccessoryViewPlaceholder = { let placeholder = InputAccessoryViewPlaceholder() @@ -54,6 +55,7 @@ class StoryGroupReplySheet: InteractiveSheetViewController { tableView.rowHeight = UITableView.automaticDimension tableView.keyboardDismissMode = .interactive tableView.contentInset = UIEdgeInsets(top: 30, left: 0, bottom: 0, right: 0) + tableView.addInteraction(contextMenu) contentView.addSubview(tableView) tableView.autoPinEdgesToSuperviewEdges() @@ -358,3 +360,73 @@ extension StoryGroupReplySheet: MessageReactionPickerDelegate { } } } + +extension StoryGroupReplySheet: ContextMenuInteractionDelegate { + func contextMenuInteraction(_ interaction: ContextMenuInteraction, configurationForMenuAtLocation location: CGPoint) -> ContextMenuConfiguration? { + guard let indexPath = tableView.indexPathForRow(at: location), + let item = replyLoader?.replyItem(for: indexPath) else { return nil } + + return .init(identifier: indexPath as NSCopying, forceDarkTheme: true) { _ in + + var actions = [ContextMenuAction]() + + actions.append(.init( + title: NSLocalizedString( + "STORIES_PRIVATE_REPLY_ACTION", + comment: "Context menu action to privately reply to the selected story reply"), + image: Theme.iconImage(.messageActionReply, isDarkThemeEnabled: true), + handler: { _ in + OWSActionSheets.showActionSheet(title: LocalizationNotNeeded("Private replies are not yet implemented.")) + })) + + if item.cellType != .reaction { + actions.append(.init( + title: NSLocalizedString( + "STORIES_COPY_REPLY_ACTION", + comment: "Context menu action to copy the selected story reply"), + image: Theme.iconImage(.messageActionCopy, isDarkThemeEnabled: true), + handler: { _ in + guard let displayableText = item.displayableText else { return } + MentionTextView.copyAttributedStringToPasteboard(displayableText.fullAttributedText) + })) + } + + actions.append(.init( + title: NSLocalizedString( + "STORIES_DELETE_REPLY_ACTION", + comment: "Context menu action to delete the selected story reply"), + image: Theme.iconImage(.messageActionDelete, isDarkThemeEnabled: true), + attributes: .destructive, + handler: { [weak self] _ in + guard let self = self else { return } + guard let message = Self.databaseStorage.read( + block: { TSMessage.anyFetchMessage(uniqueId: item.interactionUniqueId, transaction: $0) } + ) else { return } + message.presentDeletionActionSheet(from: self) + })) + + return .init(actions) + } + } + + func contextMenuInteraction(_ interaction: ContextMenuInteraction, previewForHighlightingMenuWithConfiguration configuration: ContextMenuConfiguration) -> ContextMenuTargetedPreview? { + guard let indexPath = configuration.identifier as? IndexPath else { return nil } + + guard let cell = tableView.cellForRow(at: indexPath) else { return nil } + + let targetedPreview = ContextMenuTargetedPreview( + view: cell, + alignment: .leading, + accessoryViews: nil + ) + targetedPreview.alignmentOffset = CGPoint(x: 52, y: 12) + + return targetedPreview + } + + func contextMenuInteraction(_ interaction: ContextMenuInteraction, willDisplayMenuForConfiguration: ContextMenuConfiguration) {} + + func contextMenuInteraction(_ interaction: ContextMenuInteraction, willEndForConfiguration: ContextMenuConfiguration) {} + + func contextMenuInteraction(_ interaction: ContextMenuInteraction, didEndForConfiguration configuration: ContextMenuConfiguration) {} +} diff --git a/Signal/src/ViewControllers/HomeView/Stories/Group Reply Sheet/StoryGroupReplyViewItem.swift b/Signal/src/ViewControllers/HomeView/Stories/Group Reply Sheet/StoryGroupReplyViewItem.swift index 835ed144e8..866c211102 100644 --- a/Signal/src/ViewControllers/HomeView/Stories/Group Reply Sheet/StoryGroupReplyViewItem.swift +++ b/Signal/src/ViewControllers/HomeView/Stories/Group Reply Sheet/StoryGroupReplyViewItem.swift @@ -7,6 +7,7 @@ import SignalServiceKit import UIKit class StoryGroupReplyViewItem: Dependencies { + let interactionUniqueId: String let displayableText: DisplayableText? let reactionEmoji: String? let wasRemotelyDeleted: Bool @@ -26,6 +27,8 @@ class StoryGroupReplyViewItem: Dependencies { authorColor: UIColor, transaction: SDSAnyReadTransaction ) { + self.interactionUniqueId = message.uniqueId + if !message.wasRemotelyDeleted { self.displayableText = DisplayableText.displayableText( withMessageBody: .init(text: message.body ?? "", ranges: message.bodyRanges ?? .empty), diff --git a/Signal/translations/en.lproj/Localizable.strings b/Signal/translations/en.lproj/Localizable.strings index ea3b388178..e10a3461a3 100644 --- a/Signal/translations/en.lproj/Localizable.strings +++ b/Signal/translations/en.lproj/Localizable.strings @@ -6091,6 +6091,12 @@ /* Label for the 'uninstall sticker pack' button. */ "STICKERS_UNINSTALL_BUTTON" = "Uninstall"; +/* Context menu action to copy the selected story reply */ +"STORIES_COPY_REPLY_ACTION" = "Copy"; + +/* Context menu action to delete the selected story reply */ +"STORIES_DELETE_REPLY_ACTION" = "Delete"; + /* Context menu action to forward the selected story */ "STORIES_FORWARD_STORY_ACTION" = "Forward"; @@ -6100,6 +6106,9 @@ /* Context menu action to hide the selected story */ "STORIES_HIDE_STORY_ACTION" = "Hide Story"; +/* Context menu action to privately reply to the selected story reply */ +"STORIES_PRIVATE_REPLY_ACTION" = "Private Reply"; + /* Context menu action to share the selected story */ "STORIES_SHARE_STORY_ACTION" = "Share"; diff --git a/SignalServiceKit/src/Messages/Interactions/TSMessage.swift b/SignalServiceKit/src/Messages/Interactions/TSMessage.swift index 74a9d39491..9202070e4a 100644 --- a/SignalServiceKit/src/Messages/Interactions/TSMessage.swift +++ b/SignalServiceKit/src/Messages/Interactions/TSMessage.swift @@ -134,6 +134,18 @@ public extension TSMessage { // MARK: - Remote Delete + // A message can be remotely deleted iff: + // * you sent this message + // * you haven't already remotely deleted this message + // * it has been less than 3 hours since you sent the message + var canBeRemotelyDeleted: Bool { + guard let outgoingMessage = self as? TSOutgoingMessage else { return false } + guard !outgoingMessage.wasRemotelyDeleted else { return false } + guard Date.ows_millisecondTimestamp() - outgoingMessage.timestamp <= (kHourInMs * 3) else { return false } + + return true + } + @objc(OWSRemoteDeleteProcessingResult) enum RemoteDeleteProcessingResult: Int, Error { case deletedMessageMissing diff --git a/SignalUI/Appearance/Theme+OWS.swift b/SignalUI/Appearance/Theme+OWS.swift index ec8448de74..14408195a2 100644 --- a/SignalUI/Appearance/Theme+OWS.swift +++ b/SignalUI/Appearance/Theme+OWS.swift @@ -168,7 +168,11 @@ public extension Theme { @objc public extension Theme { class func iconImage(_ icon: ThemeIcon) -> UIImage { - let name = iconName(icon) + iconImage(icon, isDarkThemeEnabled: isDarkThemeEnabled) + } + + class func iconImage(_ icon: ThemeIcon, isDarkThemeEnabled: Bool) -> UIImage { + let name = iconName(icon, isDarkThemeEnabled: isDarkThemeEnabled) guard let image = UIImage(named: name) else { owsFailDebug("image was unexpectedly nil: \(name)") return UIImage() @@ -178,6 +182,10 @@ public extension Theme { } class func iconName(_ icon: ThemeIcon) -> String { + iconName(icon, isDarkThemeEnabled: isDarkThemeEnabled) + } + + class func iconName(_ icon: ThemeIcon, isDarkThemeEnabled: Bool) -> String { switch icon { case .settingsUserInContacts: return isDarkThemeEnabled ? "profile-circle-solid-24" : "profile-circle-outline-24" diff --git a/SignalUI/Utils/TSMessage+SignalUI.swift b/SignalUI/Utils/TSMessage+SignalUI.swift new file mode 100644 index 0000000000..656169d849 --- /dev/null +++ b/SignalUI/Utils/TSMessage+SignalUI.swift @@ -0,0 +1,74 @@ +// +// Copyright (c) 2022 Open Whisper Systems. All rights reserved. +// + +import Foundation +import UIKit + +public extension TSMessage { + func presentDeletionActionSheet(from fromViewController: UIViewController) { + let actionSheetController = ActionSheetController(message: OWSLocalizedString( + "MESSAGE_ACTION_DELETE_FOR_TITLE", + comment: "The title for the action sheet asking who the user wants to delete the message for." + )) + + let deleteForMeAction = ActionSheetAction( + title: CommonStrings.deleteForMeButton, + style: .destructive + ) { _ in + Self.databaseStorage.asyncWrite { self.anyRemove(transaction: $0) } + } + actionSheetController.addAction(deleteForMeAction) + + if canBeRemotelyDeleted, let outgoingMessage = self as? TSOutgoingMessage { + let deleteForEveryoneAction = ActionSheetAction( + title: NSLocalizedString( + "MESSAGE_ACTION_DELETE_FOR_EVERYONE", + comment: "The title for the action that deletes a message for all users in the conversation." + ), + style: .destructive + ) { [weak self] _ in + self?.showDeleteForEveryoneConfirmationIfNecessary { + guard let self = self else { return } + + self.databaseStorage.write { transaction in + let deleteMessage = TSOutgoingDeleteMessage( + thread: outgoingMessage.thread(transaction: transaction), + message: outgoingMessage + ) + + // Reset the sending states, so we can render the sending state of the deleted message. + // TSOutgoingDeleteMessage will automatically pass through it's send state to the message + // record that it is deleting. + outgoingMessage.updateWith(recipientAddressStates: deleteMessage.recipientAddressStates, transaction: transaction) + outgoingMessage.updateWithRemotelyDeletedAndRemoveRenderableContent(with: transaction) + Self.messageSenderJobQueue.add(message: deleteMessage.asPreparer, transaction: transaction) + } + } + } + actionSheetController.addAction(deleteForEveryoneAction) + } + + actionSheetController.addAction(OWSActionSheets.cancelAction) + + fromViewController.presentActionSheet(actionSheetController) + } + + private func showDeleteForEveryoneConfirmationIfNecessary(completion: @escaping () -> Void) { + guard !Self.preferences.wasDeleteForEveryoneConfirmationShown() else { return completion() } + + OWSActionSheets.showConfirmationAlert( + title: NSLocalizedString( + "MESSAGE_ACTION_DELETE_FOR_EVERYONE_CONFIRMATION", + comment: "A one-time confirmation that you want to delete for everyone" + ), + proceedTitle: NSLocalizedString( + "MESSAGE_ACTION_DELETE_FOR_EVERYONE", + comment: "The title for the action that deletes a message for all users in the conversation." + ), + proceedStyle: .destructive) { _ in + Self.preferences.setWasDeleteForEveryoneConfirmationShown() + completion() + } + } +}