From 1872459dbf57120965b7f1e3f9560d9fce3acf2e Mon Sep 17 00:00:00 2001 From: notwafel_ <334551429+notwafel@users.noreply.github.com> Date: Sat, 10 Oct 2026 00:04:33 +0200 Subject: [PATCH 1/2] Add configurable double-tap actions. **Changes:** 1. Chats settings: double-tap for others' messages (React / Reply / Off, default React) and own messages (Edit / Reply / Off, default Edit) 2. Double-tap resolves via CVItemViewModelImpl.doubleTapAction (react / reply / edit) 3. Quick react uses the first emoji from the user's reaction bar 4. Optional toggles (default on): double-tap React on stickers and on photos/videos 5. When a double-tap action applies, single-tap on media/stickers is deferred so a fast double-tap reacts instead of opening the viewer or sticker pack details 6. Local reaction adds (picker / context menu / double-tap) go through one shared entrance path; removes skip the animation Tested: * iPhone 18 Pro Simulator (iOS 27.0) * iPhone 17 Simulator (iOS 27.0) * iPhone 13 mini Simulator (iOS 27.0) --- .../CVItemViewModelImpl.swift | 54 ++++++ Signal/ConversationView/CVViewState.swift | 2 + .../CellViews/CVReactionCountsView.swift | 98 ++++++++++- .../Components/CVComponentDelegate.swift | 2 + .../Components/CVComponentMessage.swift | 40 +++-- .../Components/CVComponentReactions.swift | 1 + ...onViewController+CVComponentDelegate.swift | 45 +++++ ...ionViewController+GestureRecognizers.swift | 24 +++ .../ChatsSettingsViewController.swift | 162 ++++++++++++++++++ .../EditHistoryTableSheetViewController.swift | 2 + .../Cells/MediaGalleryFileCell.swift | 2 + .../MemberLabelViewController.swift | 2 + .../MessageDetailViewController.swift | 2 + .../PinnedMessagesDetailsViewController.swift | 2 + Signal/src/views/MockConversationView.swift | 2 + .../translations/en.lproj/Localizable.strings | 30 ++++ .../Messages/Reactions/ReactionManager.swift | 20 ++- SignalServiceKit/Util/SSKPreferences.swift | 74 ++++++++ 18 files changed, 550 insertions(+), 14 deletions(-) diff --git a/Signal/ConversationView/CVItemViewModelImpl.swift b/Signal/ConversationView/CVItemViewModelImpl.swift index 75b3d80ad49..9444e30e54e 100644 --- a/Signal/ConversationView/CVItemViewModelImpl.swift +++ b/Signal/ConversationView/CVItemViewModelImpl.swift @@ -185,6 +185,60 @@ extension CVItemViewModelImpl { return DependenciesBridge.shared.editManager.canShowEditMenu(interaction: interaction, thread: thread) } + enum DoubleTapAction { + case edit + case react + case reply + } + + /// Resolved double-tap action for this message, if any. + var doubleTapAction: DoubleTapAction? { + guard let message = interaction as? TSMessage, !message.wasRemotelyDeleted else { + return nil + } + + return SSKEnvironment.shared.databaseStorageRef.read { tx -> DoubleTapAction? in + if message is TSIncomingMessage { + switch SSKPreferences.doubleTapOthersMessagesAction(transaction: tx) { + case .off: return nil + case .react: return canDoubleTapReact(tx: tx) ? .react : nil + case .reply: return canDoubleTapReply ? .reply : nil + } + } + if message is TSOutgoingMessage { + switch SSKPreferences.doubleTapOwnMessagesAction(transaction: tx) { + case .off: return nil + case .edit: return canEditMessage ? .edit : nil + case .reply: return canDoubleTapReply ? .reply : nil + } + } + return nil + } + } + + private func canDoubleTapReact(tx: DBReadTransaction) -> Bool { + guard thread.canSendReactionToThread, !thread.isTerminatedGroup else { + return false + } + if let groupThread = thread as? TSGroupThread, + !groupThread.groupModel.groupMembership.isLocalUserFullMember + { + return false + } + switch messageCellType { + case .stickerMessage: + return SSKPreferences.isDoubleTapReactEnabledForStickers(transaction: tx) + case .bodyMedia: + return SSKPreferences.isDoubleTapReactEnabledForPhotos(transaction: tx) + default: + return true + } + } + + private var canDoubleTapReply: Bool { + !thread.isReleaseNotesThread && !thread.isTerminatedGroup && !thread.isBlockedByAnnouncementOnly + } + var canCopyOrShareOrSpeakText: Bool { guard !isViewOnce else { return false diff --git a/Signal/ConversationView/CVViewState.swift b/Signal/ConversationView/CVViewState.swift index 71ab1945450..5b7ef172471 100644 --- a/Signal/ConversationView/CVViewState.swift +++ b/Signal/ConversationView/CVViewState.swift @@ -124,6 +124,8 @@ public class CVViewState: NSObject { public var collectionViewActiveContextMenuInteraction: ChatHistoryContextMenuInteraction? public var longPressHandler: CVLongPressHandler? public var panHandler: CVPanHandler? + /// When a double-tap action exists, single-tap (e.g. open media) waits until the double-tap window closes. + public var deferredSingleTap: (() -> Bool)? // MARK: - diff --git a/Signal/ConversationView/CellViews/CVReactionCountsView.swift b/Signal/ConversationView/CellViews/CVReactionCountsView.swift index bec537f189f..b788d1b8d03 100644 --- a/Signal/ConversationView/CellViews/CVReactionCountsView.swift +++ b/Signal/ConversationView/CellViews/CVReactionCountsView.swift @@ -3,6 +3,7 @@ // SPDX-License-Identifier: AGPL-3.0-only // +import SignalServiceKit import SignalUI import UIKit @@ -93,7 +94,11 @@ class CVReactionCountsView: ManualStackView { private static let measurementKey = "CVReactionCountsView" - func configure(state: State, cellMeasurement: CVCellMeasurement) { + /// Bound message for reuse-safe entrance playback. + private var messageId: String? + + func configure(state: State, cellMeasurement: CVCellMeasurement, messageId: String) { + self.messageId = messageId layer.borderColor = Theme.backgroundColor.cgColor @@ -115,6 +120,11 @@ class CVReactionCountsView: ManualStackView { measurementKey: Self.measurementKey, subviews: subviews, ) + + // One flow: local add marks pending in ReactionManager; pill plays when rendered. + if ReactionManager.pendingReactionEntrances.remove(messageId) { + playEntranceWhenReady(messageId: messageId, triesLeft: 60) + } } static func measure( @@ -147,6 +157,7 @@ class CVReactionCountsView: ManualStackView { } override func reset() { + messageId = nil super.reset() pill1.reset() @@ -154,14 +165,30 @@ class CVReactionCountsView: ManualStackView { pill3.reset() } + private func playEntranceWhenReady(messageId: String, triesLeft: Int) { + guard self.messageId == messageId else { return } + if bounds.width > 0 { + pill1.playEntrancePop() + return + } + guard triesLeft > 0 else { return } + DispatchQueue.main.async { [weak self] in + self?.playEntranceWhenReady(messageId: messageId, triesLeft: triesLeft - 1) + } + } + // MARK: - private class PillView: ManualStackViewWithLayer { private let pillKey: String + /// Stack lays out the wrap (sets frame). Transform animates on the label inside. + private let emojiWrap = ManualLayoutView(name: "reaction emoji wrap") private let emojiLabel = CVLabel() private let countLabel = CVLabel() + /// Blocks configure/reset from killing in-flight pop (was the subtle snap). + private var isPlayingEntrance = false private static let pillBorderWidth: CGFloat = 1 @@ -171,6 +198,10 @@ class CVReactionCountsView: ManualStackView { super.init(name: pillKey) emojiLabel.clipsToBounds = true + emojiWrap.addSubview(emojiLabel) + emojiWrap.addLayoutBlock { [weak emojiLabel] view in + emojiLabel?.frame = view.bounds + } clipsToBounds = true } @@ -228,6 +259,14 @@ class CVReactionCountsView: ManualStackView { pillState: PillState, cellMeasurement: CVCellMeasurement, ) { + if !isPlayingEntrance { + layer.removeAllAnimations() + transform = .identity + alpha = 1 + emojiLabel.transform = .identity + emojiLabel.alpha = 1 + clipsToBounds = true + } addLayoutBlock { view in view.layer.borderWidth = Self.pillBorderWidth @@ -246,7 +285,7 @@ class CVReactionCountsView: ManualStackView { if let emojiLabelConfig = Self.emojiLabelConfig(pillState: pillState) { emojiLabelConfig.applyForRendering(label: emojiLabel) - subviews.append(emojiLabel) + subviews.append(emojiWrap) } if let countLabelConfig = Self.countLabelConfig(pillState: pillState) { @@ -262,6 +301,61 @@ class CVReactionCountsView: ManualStackView { ) } + override func reset() { + stopEntrancePop() + super.reset() + } + + func playEntrancePop() { + guard !isPlayingEntrance else { return } + isPlayingEntrance = true + + // Subtle fast chip appear (scale/alpha only — layout frame stays put). + alpha = 0 + transform = CGAffineTransform(scaleX: 0.88, y: 0.88) + UIView.animate( + withDuration: 0.14, + delay: 0, + options: [.curveEaseOut, .allowUserInteraction], + ) { + self.alpha = 1 + self.transform = .identity + } + + // Emoji pop into the chip. + clipsToBounds = false + emojiLabel.alpha = 0 + emojiLabel.transform = CGAffineTransform(translationX: 0, y: -40).scaledBy(x: 0.4, y: 0.4) + + // Peak ~14pt emoji +5px ≈ 1.36 scale; land back to identity in the pill. + UIView.animateKeyframes(withDuration: 0.42, delay: 0, options: []) { + UIView.addKeyframe(withRelativeStartTime: 0, relativeDuration: 0.38) { + self.emojiLabel.alpha = 1 + self.emojiLabel.transform = CGAffineTransform(translationX: 0, y: -40).scaledBy(x: 1.36, y: 1.36) + } + UIView.addKeyframe(withRelativeStartTime: 0.38, relativeDuration: 0.62) { + self.emojiLabel.transform = .identity + } + } completion: { [weak self] _ in + self?.finishEntrancePop() + } + } + + private func stopEntrancePop() { + layer.removeAllAnimations() + emojiLabel.layer.removeAllAnimations() + finishEntrancePop() + } + + private func finishEntrancePop() { + isPlayingEntrance = false + transform = .identity + alpha = 1 + emojiLabel.transform = .identity + emojiLabel.alpha = 1 + clipsToBounds = true + } + static func measure( pillState: PillState, pillKey: String, diff --git a/Signal/ConversationView/Components/CVComponentDelegate.swift b/Signal/ConversationView/Components/CVComponentDelegate.swift index a863a1675c5..3abcafd54ad 100644 --- a/Signal/ConversationView/Components/CVComponentDelegate.swift +++ b/Signal/ConversationView/Components/CVComponentDelegate.swift @@ -42,6 +42,8 @@ public protocol CVComponentDelegate: AnyObject, AudioMessageViewDelegate, CVPoll func didDoubleTapTextViewItem(_ itemViewModel: CVItemViewModelImpl) + func didDoubleTapToReact(_ itemViewModel: CVItemViewModelImpl) + // MARK: - Long Press func didLongPressTextViewItem( diff --git a/Signal/ConversationView/Components/CVComponentMessage.swift b/Signal/ConversationView/Components/CVComponentMessage.swift index c6fd36275f2..edd20fddcea 100644 --- a/Signal/ConversationView/Components/CVComponentMessage.swift +++ b/Signal/ConversationView/Components/CVComponentMessage.swift @@ -2013,22 +2013,40 @@ public class CVComponentMessage: CVComponentBase, CVRootComponent { componentDelegate: any CVComponentDelegate, renderItem: CVRenderItem, ) -> Bool { - if isShowingSelectionUI { - return false - } - - let viewModel = CVItemViewModelImpl(renderItem: renderItem) - return viewModel.canEditMessage + doubleTapAction(componentDelegate: componentDelegate, renderItem: renderItem) != nil } override public func handleDoubleTap(sender: UIGestureRecognizer, componentDelegate: any CVComponentDelegate, renderItem: CVRenderItem) -> Bool { - guard canHandleDoubleTap(sender: sender, componentDelegate: componentDelegate, renderItem: renderItem) else { + guard let (viewModel, action) = doubleTapAction(componentDelegate: componentDelegate, renderItem: renderItem) else { return false } + switch action { + case .edit: + componentDelegate.didDoubleTapTextViewItem(viewModel) + case .react: + componentDelegate.didDoubleTapToReact(viewModel) + case .reply: + componentDelegate.didTapReplyToItem(viewModel) + } + return true + } + private func doubleTapAction( + componentDelegate: any CVComponentDelegate, + renderItem: CVRenderItem, + ) -> (CVItemViewModelImpl, CVItemViewModelImpl.DoubleTapAction)? { + if isShowingSelectionUI { + return nil + } let viewModel = CVItemViewModelImpl(renderItem: renderItem) - componentDelegate.didDoubleTapTextViewItem(viewModel) - return true + guard let action = viewModel.doubleTapAction else { + return nil + } + // Match reaction-picker eligibility so we don't consume the gesture as a no-op. + if action == .react, componentDelegate.hasPendingMessageRequest { + return nil + } + return (viewModel, action) } override public func findLongPressHandler( @@ -2531,8 +2549,8 @@ public class CVComponentMessage: CVComponentBase, CVRootComponent { } public func canHandleDoubleTapGesture(_ sender: UIGestureRecognizer) -> Bool { - // If we have a body text view, allow taps anywhere (incl. adjacent whitespace). - return bodyTextView != nil + // Text / media / stickers can take a double-tap action; component logic decides which. + return bodyTextView != nil || bodyMediaView != nil || stickerView != nil } public func contextMenuContentView() -> UIView? { diff --git a/Signal/ConversationView/Components/CVComponentReactions.swift b/Signal/ConversationView/Components/CVComponentReactions.swift index f3dca110273..4b107c7e294 100644 --- a/Signal/ConversationView/Components/CVComponentReactions.swift +++ b/Signal/ConversationView/Components/CVComponentReactions.swift @@ -47,6 +47,7 @@ public class CVComponentReactions: CVComponentBase, CVComponent, CVAccessibility reactionCountsView.configure( state: viewState, cellMeasurement: cellMeasurement, + messageId: interaction.uniqueId, ) } diff --git a/Signal/ConversationView/ConversationViewController+CVComponentDelegate.swift b/Signal/ConversationView/ConversationViewController+CVComponentDelegate.swift index 77672c82825..08d04fe8e25 100644 --- a/Signal/ConversationView/ConversationViewController+CVComponentDelegate.swift +++ b/Signal/ConversationView/ConversationViewController+CVComponentDelegate.swift @@ -51,6 +51,51 @@ extension ConversationViewController: CVComponentDelegate { controller.beginEditing(animated: true) } + public func didDoubleTapToReact(_ viewModel: CVItemViewModelImpl) { + AssertIsOnMainThread() + + guard + let message = viewModel.interaction as? TSMessage, + shouldShowReactionPickerForInteraction(message) + else { + return + } + + let messageId = message.uniqueId + let quickEmoji = SSKEnvironment.shared.databaseStorageRef.read { + ReactionManager.preferredQuickReactionEmoji(transaction: $0) + } + + // Optimistic UI from the render snapshot; the write re-checks under the lock. + let existing = viewModel.reactionState?.localUserEmojiVariant + if let existing, existing != quickEmoji { + return + } + + ImpactHapticFeedback.impactOccurred(style: .light) + + // Write only. Entrance: ReactionManager.pendingReactionEntrances → CVReactionCountsView.configure. + SSKEnvironment.shared.databaseStorageRef.asyncWrite { tx in + guard + let message = TSMessage.fetchMessageViaCache(uniqueId: messageId, transaction: tx), + !message.wasRemotelyDeleted, + let localAci = DependenciesBridge.shared.tsAccountManager.localIdentifiers(tx: tx)?.aci + else { + return + } + let latest = message.reaction(for: localAci, tx: tx)?.emoji + if let latest, latest != quickEmoji { + return + } + ReactionManager.localUserReacted( + to: messageId, + emoji: quickEmoji, + isRemoving: latest == quickEmoji, + tx: tx, + ) + } + } + // MARK: - Long Press public func didLongPressTextViewItem( diff --git a/Signal/ConversationView/ConversationViewController+GestureRecognizers.swift b/Signal/ConversationView/ConversationViewController+GestureRecognizers.swift index e7ca39d2905..8a03a63d7ef 100644 --- a/Signal/ConversationView/ConversationViewController+GestureRecognizers.swift +++ b/Signal/ConversationView/ConversationViewController+GestureRecognizers.swift @@ -144,17 +144,32 @@ extension ConversationViewController: SingleOrDoubleTapGestureDelegate { finishRecordingVoiceMessage(sendImmediately: false) guard let cell = findCell(forGesture: sender) else { + viewState.deferredSingleTap = nil return false } if let interaction = collectionViewActiveContextMenuInteraction, interaction.contextMenuVisible { + viewState.deferredSingleTap = nil return false } + // Fast double-tap should react/reply/edit instead of opening media or sticker details. + // Defer single-tap until the double-tap window times out. + if cell.canHandleDoubleTap(sender: sender, componentDelegate: self) { + viewState.deferredSingleTap = { [weak self, weak cell] in + guard let self, let cell else { return false } + return cell.handleTap(sender: sender, componentDelegate: self) + } + return false + } + + viewState.deferredSingleTap = nil return cell.handleTap(sender: sender, componentDelegate: self) } public func handleDoubleTap(_ sender: SingleOrDoubleTapGestureRecognizer) -> Bool { + viewState.deferredSingleTap = nil + guard let cell = findCell(forGesture: sender) else { return false } @@ -166,6 +181,15 @@ extension ConversationViewController: SingleOrDoubleTapGestureDelegate { } public func didEndGesture(_ sender: SingleOrDoubleTapGestureRecognizer, wasHandled: Bool) { + if !wasHandled, let deferredSingleTap = viewState.deferredSingleTap { + viewState.deferredSingleTap = nil + if deferredSingleTap() { + return + } + } else { + viewState.deferredSingleTap = nil + } + if !wasHandled { dismissKeyboard() } diff --git a/Signal/src/ViewControllers/AppSettings/ChatsSettingsViewController.swift b/Signal/src/ViewControllers/AppSettings/ChatsSettingsViewController.swift index ea6e8518477..365277bafdf 100644 --- a/Signal/src/ViewControllers/AppSettings/ChatsSettingsViewController.swift +++ b/Signal/src/ViewControllers/AppSettings/ChatsSettingsViewController.swift @@ -24,6 +24,11 @@ class ChatsSettingsViewController: OWSTableViewController2 { ) } + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + updateTableContents() + } + @objc private func updateTableContents() { let contents = OWSTableContents() @@ -102,6 +107,102 @@ class ChatsSettingsViewController: OWSTableViewController2 { ) contents.add(keepMutedChatsArchived) + let (othersAction, ownAction) = SSKEnvironment.shared.databaseStorageRef.read { tx in + ( + SSKPreferences.doubleTapOthersMessagesAction(transaction: tx), + SSKPreferences.doubleTapOwnMessagesAction(transaction: tx), + ) + } + + let doubleTapSection = OWSTableSection() + doubleTapSection.headerTitle = OWSLocalizedString( + "SETTINGS_DOUBLE_TAP_SECTION_TITLE", + comment: "Section title for double-tap message action settings.", + ) + doubleTapSection.footerTitle = OWSLocalizedString( + "SETTINGS_DOUBLE_TAP_SECTION_FOOTER", + comment: "Footer explaining configurable double-tap actions on messages.", + ) + doubleTapSection.add(.disclosureItem( + withText: OWSLocalizedString( + "SETTINGS_DOUBLE_TAP_OTHERS_MESSAGES", + comment: "Setting for double-tap action on messages from other people.", + ), + accessoryText: othersAction.displayName, + actionBlock: { [weak self] in + guard let self else { return } + let vc = DoubleTapActionSettingsViewController( + title: OWSLocalizedString( + "SETTINGS_DOUBLE_TAP_OTHERS_MESSAGES", + comment: "Setting for double-tap action on messages from other people.", + ), + options: SSKPreferences.DoubleTapOthersMessagesAction.allCases.map { ($0.displayName, $0 == othersAction) }, + ) { index in + let action = SSKPreferences.DoubleTapOthersMessagesAction.allCases[index] + SSKEnvironment.shared.databaseStorageRef.write { + SSKPreferences.setDoubleTapOthersMessagesAction(action, transaction: $0) + } + } + self.navigationController?.pushViewController(vc, animated: true) + }, + )) + doubleTapSection.add(.disclosureItem( + withText: OWSLocalizedString( + "SETTINGS_DOUBLE_TAP_OWN_MESSAGES", + comment: "Setting for double-tap action on your own messages.", + ), + accessoryText: ownAction.displayName, + actionBlock: { [weak self] in + guard let self else { return } + let vc = DoubleTapActionSettingsViewController( + title: OWSLocalizedString( + "SETTINGS_DOUBLE_TAP_OWN_MESSAGES", + comment: "Setting for double-tap action on your own messages.", + ), + options: SSKPreferences.DoubleTapOwnMessagesAction.allCases.map { ($0.displayName, $0 == ownAction) }, + ) { index in + let action = SSKPreferences.DoubleTapOwnMessagesAction.allCases[index] + SSKEnvironment.shared.databaseStorageRef.write { + SSKPreferences.setDoubleTapOwnMessagesAction(action, transaction: $0) + } + } + self.navigationController?.pushViewController(vc, animated: true) + }, + )) + doubleTapSection.add(.switch( + withText: OWSLocalizedString( + "SETTINGS_DOUBLE_TAP_REACT_STICKERS", + comment: "Toggle for allowing double-tap quick react on sticker messages.", + ), + isOn: { + SSKEnvironment.shared.databaseStorageRef.read { + SSKPreferences.isDoubleTapReactEnabledForStickers(transaction: $0) + } + }, + actionBlock: { sender in + SSKEnvironment.shared.databaseStorageRef.write { + SSKPreferences.setDoubleTapReactEnabledForStickers(sender.isOn, transaction: $0) + } + }, + )) + doubleTapSection.add(.switch( + withText: OWSLocalizedString( + "SETTINGS_DOUBLE_TAP_REACT_PHOTOS", + comment: "Toggle for allowing double-tap quick react on photo and video messages.", + ), + isOn: { + SSKEnvironment.shared.databaseStorageRef.read { + SSKPreferences.isDoubleTapReactEnabledForPhotos(transaction: $0) + } + }, + actionBlock: { sender in + SSKEnvironment.shared.databaseStorageRef.write { + SSKPreferences.setDoubleTapReactEnabledForPhotos(sender.isOn, transaction: $0) + } + }, + )) + contents.add(doubleTapSection) + let clearHistorySection = OWSTableSection() clearHistorySection.add(.item( name: OWSLocalizedString("SETTINGS_CLEAR_HISTORY", comment: ""), @@ -255,3 +356,64 @@ class ChatsSettingsViewController: OWSTableViewController2 { AttachmentStream.deleteAllAttachmentFiles() } } + +// MARK: - + +private final class DoubleTapActionSettingsViewController: OWSTableViewController2 { + private let options: [(title: String, isSelected: Bool)] + private let onSelect: (Int) -> Void + + init(title: String, options: [(String, Bool)], onSelect: @escaping (Int) -> Void) { + self.options = options + self.onSelect = onSelect + super.init() + self.title = title + } + + override func viewDidLoad() { + super.viewDidLoad() + + let section = OWSTableSection() + for (index, option) in options.enumerated() { + section.add(.init( + text: option.title, + actionBlock: { [weak self] in + guard let self else { return } + self.onSelect(index) + self.navigationController?.popViewController(animated: true) + }, + accessoryType: option.isSelected ? .checkmark : .none, + )) + } + contents = OWSTableContents(sections: [section]) + } +} + +private extension SSKPreferences.DoubleTapOthersMessagesAction { + var displayName: String { + switch self { + case .off: + return OWSLocalizedString("SETTINGS_DOUBLE_TAP_ACTION_OFF", comment: "Double-tap action: disabled.") + case .react: + return OWSLocalizedString( + "SETTINGS_DOUBLE_TAP_ACTION_REACT", + comment: "Double-tap action: react with the first quick-reaction emoji.", + ) + case .reply: + return OWSLocalizedString("SETTINGS_DOUBLE_TAP_ACTION_REPLY", comment: "Double-tap action: reply to the message.") + } + } +} + +private extension SSKPreferences.DoubleTapOwnMessagesAction { + var displayName: String { + switch self { + case .off: + return OWSLocalizedString("SETTINGS_DOUBLE_TAP_ACTION_OFF", comment: "Double-tap action: disabled.") + case .edit: + return OWSLocalizedString("SETTINGS_DOUBLE_TAP_ACTION_EDIT", comment: "Double-tap action: edit your message.") + case .reply: + return OWSLocalizedString("SETTINGS_DOUBLE_TAP_ACTION_REPLY", comment: "Double-tap action: reply to the message.") + } + } +} diff --git a/Signal/src/ViewControllers/EditHistoryTableSheetViewController.swift b/Signal/src/ViewControllers/EditHistoryTableSheetViewController.swift index 3247808c9b2..b21eb458457 100644 --- a/Signal/src/ViewControllers/EditHistoryTableSheetViewController.swift +++ b/Signal/src/ViewControllers/EditHistoryTableSheetViewController.swift @@ -300,6 +300,8 @@ extension EditHistoryTableSheetViewController: CVComponentDelegate { func didDoubleTapTextViewItem(_ itemViewModel: CVItemViewModelImpl) {} + func didDoubleTapToReact(_ itemViewModel: CVItemViewModelImpl) {} + func didLongPressTextViewItem( _ cell: CVCell, itemViewModel: CVItemViewModelImpl, diff --git a/Signal/src/ViewControllers/MediaGallery/Cells/MediaGalleryFileCell.swift b/Signal/src/ViewControllers/MediaGallery/Cells/MediaGalleryFileCell.swift index 53e4783dd88..6ef7b8ea26a 100644 --- a/Signal/src/ViewControllers/MediaGallery/Cells/MediaGalleryFileCell.swift +++ b/Signal/src/ViewControllers/MediaGallery/Cells/MediaGalleryFileCell.swift @@ -345,6 +345,8 @@ extension MediaGalleryFileCell: CVComponentDelegate { func didDoubleTapTextViewItem(_ itemViewModel: CVItemViewModelImpl) {} + func didDoubleTapToReact(_ itemViewModel: CVItemViewModelImpl) {} + func didLongPressTextViewItem( _ cell: CVCell, itemViewModel: CVItemViewModelImpl, diff --git a/Signal/src/ViewControllers/MemberLabels/MemberLabelViewController.swift b/Signal/src/ViewControllers/MemberLabels/MemberLabelViewController.swift index 2ceaa7387c7..35e809701dd 100644 --- a/Signal/src/ViewControllers/MemberLabels/MemberLabelViewController.swift +++ b/Signal/src/ViewControllers/MemberLabels/MemberLabelViewController.swift @@ -678,6 +678,8 @@ class MemberLabelViewController: OWSViewController, UITextFieldDelegate, CVCompo func didDoubleTapTextViewItem(_ itemViewModel: CVItemViewModelImpl) {} + func didDoubleTapToReact(_ itemViewModel: CVItemViewModelImpl) {} + func didLongPressTextViewItem( _ cell: CVCell, itemViewModel: CVItemViewModelImpl, diff --git a/Signal/src/ViewControllers/MessageDetailViewController.swift b/Signal/src/ViewControllers/MessageDetailViewController.swift index 5d3641ab606..46e19d78ede 100644 --- a/Signal/src/ViewControllers/MessageDetailViewController.swift +++ b/Signal/src/ViewControllers/MessageDetailViewController.swift @@ -1054,6 +1054,8 @@ extension MessageDetailViewController: CVComponentDelegate { func didDoubleTapTextViewItem(_ itemViewModel: CVItemViewModelImpl) {} + func didDoubleTapToReact(_ itemViewModel: CVItemViewModelImpl) {} + // MARK: - Long Press // TODO: diff --git a/Signal/src/ViewControllers/PinnedMessages/PinnedMessagesDetailsViewController.swift b/Signal/src/ViewControllers/PinnedMessages/PinnedMessagesDetailsViewController.swift index df471fb469e..ef848532cf6 100644 --- a/Signal/src/ViewControllers/PinnedMessages/PinnedMessagesDetailsViewController.swift +++ b/Signal/src/ViewControllers/PinnedMessages/PinnedMessagesDetailsViewController.swift @@ -435,6 +435,8 @@ extension PinnedMessagesDetailsViewController: CVComponentDelegate { func didDoubleTapTextViewItem(_ itemViewModel: CVItemViewModelImpl) {} + func didDoubleTapToReact(_ itemViewModel: CVItemViewModelImpl) {} + func didLongPressTextViewItem( _ cell: CVCell, itemViewModel: CVItemViewModelImpl, diff --git a/Signal/src/views/MockConversationView.swift b/Signal/src/views/MockConversationView.swift index 373fcad9145..14402cdc98c 100644 --- a/Signal/src/views/MockConversationView.swift +++ b/Signal/src/views/MockConversationView.swift @@ -335,6 +335,8 @@ extension MockConversationView: CVComponentDelegate { func didDoubleTapTextViewItem(_ itemViewModel: CVItemViewModelImpl) {} + func didDoubleTapToReact(_ itemViewModel: CVItemViewModelImpl) {} + func didLongPressTextViewItem( _ cell: CVCell, itemViewModel: CVItemViewModelImpl, diff --git a/Signal/translations/en.lproj/Localizable.strings b/Signal/translations/en.lproj/Localizable.strings index af4b3c3abb6..ea103a31bb9 100644 --- a/Signal/translations/en.lproj/Localizable.strings +++ b/Signal/translations/en.lproj/Localizable.strings @@ -9079,6 +9079,36 @@ /* Title for the 'chats' link in settings. */ "SETTINGS_CHATS" = "Chats"; +/* Section title for double-tap message action settings. */ +"SETTINGS_DOUBLE_TAP_SECTION_TITLE" = "Double-tap"; + +/* Footer explaining configurable double-tap actions on messages. */ +"SETTINGS_DOUBLE_TAP_SECTION_FOOTER" = "Choose what double-tapping a message does. Long-press still opens the full action menu and reaction bar. Stickers and photos toggles apply when React is selected."; + +/* Setting for double-tap action on messages from other people. */ +"SETTINGS_DOUBLE_TAP_OTHERS_MESSAGES" = "Other Messages"; + +/* Setting for double-tap action on your own messages. */ +"SETTINGS_DOUBLE_TAP_OWN_MESSAGES" = "Your Messages"; + +/* Double-tap action: disabled. */ +"SETTINGS_DOUBLE_TAP_ACTION_OFF" = "Off"; + +/* Double-tap action: react with the first quick-reaction emoji. */ +"SETTINGS_DOUBLE_TAP_ACTION_REACT" = "React"; + +/* Double-tap action: reply to the message. */ +"SETTINGS_DOUBLE_TAP_ACTION_REPLY" = "Reply"; + +/* Double-tap action: edit your message. */ +"SETTINGS_DOUBLE_TAP_ACTION_EDIT" = "Edit"; + +/* Toggle for allowing double-tap quick react on sticker messages. */ +"SETTINGS_DOUBLE_TAP_REACT_STICKERS" = "React to Stickers"; + +/* Toggle for allowing double-tap quick react on photo and video messages. */ +"SETTINGS_DOUBLE_TAP_REACT_PHOTOS" = "React to Photos"; + /* No comment provided by engineer. */ "SETTINGS_CLEAR_HISTORY" = "Clear Chat History"; diff --git a/SignalServiceKit/Messages/Reactions/ReactionManager.swift b/SignalServiceKit/Messages/Reactions/ReactionManager.swift index cc2893a1b21..0713ce77277 100644 --- a/SignalServiceKit/Messages/Reactions/ReactionManager.swift +++ b/SignalServiceKit/Messages/Reactions/ReactionManager.swift @@ -11,6 +11,9 @@ public class ReactionManager: NSObject { public static let localUserReacted = Notification.Name("localUserReacted") public static let defaultEmojiSet = ["❤️", "👍", "👎", "😂", "😮", "😢"] + /// Message ids waiting for reaction-pill entrance after a local add. + public static let pendingReactionEntrances = AtomicSet(lock: .init()) + private static let emojiSetKVS = KeyValueStore(collection: "EmojiSetKVS") private static let emojiSetKey = "EmojiSetKey" @@ -22,6 +25,14 @@ public class ReactionManager: NSObject { return emojiSetKVS.getStringArray(emojiSetKey, transaction: transaction) } + /// First emoji from the user's quick-reaction list (custom if set, otherwise default ❤️). + public class func preferredQuickReactionEmoji(transaction: DBReadTransaction) -> String { + if let customFirst = customEmojiSet(transaction: transaction)?.first?.strippedOrNil { + return customFirst + } + return defaultEmojiSet[0] + } + public class func setCustomEmojiSet(_ emojis: [String]?, transaction: DBWriteTransaction) { emojiSetKVS.setStringArray(emojis, key: emojiSetKey, transaction: transaction) } @@ -41,7 +52,14 @@ public class ReactionManager: NSObject { owsFailDebug("Error: \(error)") return Promise(error: error) } - NotificationCenter.default.post(name: ReactionManager.localUserReacted, object: nil) + // Mark before commit so render can consume even if UI reloads in the same turn. + if !isRemoving { + pendingReactionEntrances.insert(messageUniqueId) + } + tx.addSyncCompletion { + // ChatList clears search on this name; entrance uses pendingReactionEntrances. + NotificationCenter.default.post(name: ReactionManager.localUserReacted, object: nil) + } let preparedMessage = PreparedOutgoingMessage.preprepared(transientMessageWithoutAttachments: outgoingMessage) return SSKEnvironment.shared.messageSenderJobQueueRef.add( .promise, diff --git a/SignalServiceKit/Util/SSKPreferences.swift b/SignalServiceKit/Util/SSKPreferences.swift index 36acbcabbd2..776cab43354 100644 --- a/SignalServiceKit/Util/SSKPreferences.swift +++ b/SignalServiceKit/Util/SSKPreferences.swift @@ -181,4 +181,78 @@ public class SSKPreferences: NSObject { public static func setShouldKeepMutedChatsArchived(_ newValue: Bool, transaction: DBWriteTransaction) { store.setBool(newValue, key: shouldKeepMutedChatsArchivedKey, transaction: transaction) } + + // MARK: - Double Tap + + public enum DoubleTapOthersMessagesAction: String, CaseIterable { + case react + case reply + case off + } + + public enum DoubleTapOwnMessagesAction: String, CaseIterable { + case edit + case reply + case off + } + + private static var doubleTapOthersMessagesActionKey: String { "doubleTapOthersMessagesAction" } + private static var doubleTapOwnMessagesActionKey: String { "doubleTapOwnMessagesAction" } + + /// Default: react. + public static func doubleTapOthersMessagesAction(transaction: DBReadTransaction) -> DoubleTapOthersMessagesAction { + stringPref(DoubleTapOthersMessagesAction.self, key: doubleTapOthersMessagesActionKey, default: .react, transaction: transaction) + } + + public static func setDoubleTapOthersMessagesAction( + _ action: DoubleTapOthersMessagesAction, + transaction: DBWriteTransaction, + ) { + store.setString(action.rawValue, key: doubleTapOthersMessagesActionKey, transaction: transaction) + } + + /// Default: edit (existing double-tap-to-edit). + public static func doubleTapOwnMessagesAction(transaction: DBReadTransaction) -> DoubleTapOwnMessagesAction { + stringPref(DoubleTapOwnMessagesAction.self, key: doubleTapOwnMessagesActionKey, default: .edit, transaction: transaction) + } + + public static func setDoubleTapOwnMessagesAction( + _ action: DoubleTapOwnMessagesAction, + transaction: DBWriteTransaction, + ) { + store.setString(action.rawValue, key: doubleTapOwnMessagesActionKey, transaction: transaction) + } + + private static var doubleTapReactStickersEnabledKey: String { "doubleTapReactStickersEnabled" } + private static var doubleTapReactPhotosEnabledKey: String { "doubleTapReactPhotosEnabled" } + + /// Default: true. When false, double-tap React skips sticker messages. + public static func isDoubleTapReactEnabledForStickers(transaction: DBReadTransaction) -> Bool { + store.getBool(doubleTapReactStickersEnabledKey, defaultValue: true, transaction: transaction) + } + + public static func setDoubleTapReactEnabledForStickers(_ value: Bool, transaction: DBWriteTransaction) { + store.setBool(value, key: doubleTapReactStickersEnabledKey, transaction: transaction) + } + + /// Default: true. When false, double-tap React skips photo/video (body media) messages. + public static func isDoubleTapReactEnabledForPhotos(transaction: DBReadTransaction) -> Bool { + store.getBool(doubleTapReactPhotosEnabledKey, defaultValue: true, transaction: transaction) + } + + public static func setDoubleTapReactEnabledForPhotos(_ value: Bool, transaction: DBWriteTransaction) { + store.setBool(value, key: doubleTapReactPhotosEnabledKey, transaction: transaction) + } + + private static func stringPref( + _ type: T.Type, + key: String, + default defaultValue: T, + transaction: DBReadTransaction, + ) -> T where T.RawValue == String { + if let raw = store.getString(key, transaction: transaction), let value = T(rawValue: raw) { + return value + } + return defaultValue + } } From cad52010253ef32b6bfe6e6a24ebb4440cd55663 Mon Sep 17 00:00:00 2001 From: notwafel_ <334551429+notwafel@users.noreply.github.com> Date: Sun, 11 Oct 2026 00:48:54 +0200 Subject: [PATCH 2/2] Add reaction height reflow and entrance animation. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **Changes:** 1. Reaction add/remove reloads the cell with old→new frame animation so height expands or collapses 2. Local add: pill stays hidden until its size is stable, then shows and animates the emoji in 3. Remote add: short scale/fade entrance 4. Scroll continuity during reaction layout uses targetContentOffset so nearby messages don't jump 5. Suppresses reload crossfade on reaction reloads so ManualLayout does not ghost text --- Signal.xcodeproj/project.pbxproj | 4 + Signal/ConversationView/CVCell.swift | 6 + .../CVItemViewModelImpl.swift | 1 - Signal/ConversationView/CVViewState.swift | 3 +- .../CellViews/CVReactionCountsView.swift | 250 +++++++++++---- .../Components/CVComponentMessage.swift | 2 - .../ConversationViewController+CVC.swift | 90 +++++- ...onViewController+CVComponentDelegate.swift | 12 +- ...ionViewController+GestureRecognizers.swift | 3 +- ...rsationViewController+MessageActions.swift | 1 + .../ConversationViewLayout.swift | 58 ++++ .../ConversationView/Loading/CVUpdate.swift | 31 ++ .../Reactions/ReactionLayoutPending.swift | 31 ++ .../NotificationActionHandler.swift | 3 +- .../ChatsSettingsViewController.swift | 289 +++++++++++------- .../translations/en.lproj/Localizable.strings | 27 +- .../Messages/Reactions/ReactionManager.swift | 9 - .../UserNotificationsPresenter.swift | 9 +- SignalServiceKit/Util/SSKPreferences.swift | 4 - 19 files changed, 616 insertions(+), 217 deletions(-) create mode 100644 Signal/ConversationView/Reactions/ReactionLayoutPending.swift diff --git a/Signal.xcodeproj/project.pbxproj b/Signal.xcodeproj/project.pbxproj index a6aabfaaac5..489f881976f 100644 --- a/Signal.xcodeproj/project.pbxproj +++ b/Signal.xcodeproj/project.pbxproj @@ -1751,6 +1751,7 @@ 88B2234A283F290400A25048 /* StoryPrivacySettingsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 88B22349283F290400A25048 /* StoryPrivacySettingsViewController.swift */; }; 88B2234C284FABE600A25048 /* StoryThumbnailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 88B2234B284FABE600A25048 /* StoryThumbnailView.swift */; }; 88B688B0238F0D1000286F82 /* ReactionsDetailSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 88B688AF238F0D1000286F82 /* ReactionsDetailSheet.swift */; }; + E8F4A2022A3B4C5D6E7F8002 /* ReactionLayoutPending.swift in Sources */ = {isa = PBXBuildFile; fileRef = E8F4A2012A3B4C5D6E7F8001 /* ReactionLayoutPending.swift */; }; 88B6D673280770C4005D86EC /* TextAttachmentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 885275BF27E26775003F2F9B /* TextAttachmentView.swift */; }; 88B6D674280770C4005D86EC /* StoryMessage+SignalUI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 88B6D67128076F37005D86EC /* StoryMessage+SignalUI.swift */; }; 88B986F4287F5B6500F8C74D /* NewGroupStoryViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 88B986F3287F5B6500F8C74D /* NewGroupStoryViewController.swift */; }; @@ -6138,6 +6139,7 @@ 88B22349283F290400A25048 /* StoryPrivacySettingsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoryPrivacySettingsViewController.swift; sourceTree = ""; }; 88B2234B284FABE600A25048 /* StoryThumbnailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoryThumbnailView.swift; sourceTree = ""; }; 88B688AF238F0D1000286F82 /* ReactionsDetailSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReactionsDetailSheet.swift; sourceTree = ""; }; + E8F4A2012A3B4C5D6E7F8001 /* ReactionLayoutPending.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReactionLayoutPending.swift; sourceTree = ""; }; 88B6D67128076F37005D86EC /* StoryMessage+SignalUI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "StoryMessage+SignalUI.swift"; sourceTree = ""; }; 88B986F3287F5B6500F8C74D /* NewGroupStoryViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewGroupStoryViewController.swift; sourceTree = ""; }; 88BCCC8023837B7D00CE5FE6 /* InteractionReactionState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InteractionReactionState.swift; sourceTree = ""; }; @@ -10347,6 +10349,7 @@ 8855DF88238F2E690066D96F /* EmojiCountsCollectionView.swift */, 8855DF86238F1E0C0066D96F /* EmojiReactorsTableView.swift */, 88BCCC8023837B7D00CE5FE6 /* InteractionReactionState.swift */, + E8F4A2012A3B4C5D6E7F8001 /* ReactionLayoutPending.swift */, 88B688AF238F0D1000286F82 /* ReactionsDetailSheet.swift */, ); path = Reactions; @@ -19254,6 +19257,7 @@ 88863A56280CD04B00977F69 /* ReactionFlybyAnimation.swift in Sources */, D9E43C0B2CC194140001536E /* ReactionsBurstView.swift in Sources */, 88B688B0238F0D1000286F82 /* ReactionsDetailSheet.swift in Sources */, + E8F4A2022A3B4C5D6E7F8002 /* ReactionLayoutPending.swift in Sources */, D9E43C0C2CC194140001536E /* ReactionsSink.swift in Sources */, 8809CE8A22F93C2200D38867 /* RecentPhotoCollectionView.swift in Sources */, 04E66D402DFC825B0059DBAC /* RecoveryKeyReminderMegaphone.swift in Sources */, diff --git a/Signal/ConversationView/CVCell.swift b/Signal/ConversationView/CVCell.swift index 3e4cf92b324..560eab17db2 100644 --- a/Signal/ConversationView/CVCell.swift +++ b/Signal/ConversationView/CVCell.swift @@ -124,6 +124,12 @@ public class CVCell: UICollectionViewCell, CVRootComponentHost { if !isReusingDedicatedCell { layoutMargins = .zero contentView.layoutMargins = .zero + // reconfigureItems skips prepareForReuse — without this, configureForRendering + // hits addSubviewToFillSuperviewEdges on views that still have a superview. + if componentView != nil { + contentView.removeAllSubviews() + componentView?.reset() + } } configureForHosting( diff --git a/Signal/ConversationView/CVItemViewModelImpl.swift b/Signal/ConversationView/CVItemViewModelImpl.swift index 9444e30e54e..c79c693d86c 100644 --- a/Signal/ConversationView/CVItemViewModelImpl.swift +++ b/Signal/ConversationView/CVItemViewModelImpl.swift @@ -191,7 +191,6 @@ extension CVItemViewModelImpl { case reply } - /// Resolved double-tap action for this message, if any. var doubleTapAction: DoubleTapAction? { guard let message = interaction as? TSMessage, !message.wasRemotelyDeleted else { return nil diff --git a/Signal/ConversationView/CVViewState.swift b/Signal/ConversationView/CVViewState.swift index 5b7ef172471..58081417fc2 100644 --- a/Signal/ConversationView/CVViewState.swift +++ b/Signal/ConversationView/CVViewState.swift @@ -52,6 +52,8 @@ public class CVViewState: NSObject { public var activeCellAnimations = Set() + public var lastDoubleTapReactAtByMessageId = [String: CFAbsoluteTime]() + public func beginCellAnimation(identifier: UUID) { activeCellAnimations.insert(identifier) } @@ -124,7 +126,6 @@ public class CVViewState: NSObject { public var collectionViewActiveContextMenuInteraction: ChatHistoryContextMenuInteraction? public var longPressHandler: CVLongPressHandler? public var panHandler: CVPanHandler? - /// When a double-tap action exists, single-tap (e.g. open media) waits until the double-tap window closes. public var deferredSingleTap: (() -> Bool)? // MARK: - diff --git a/Signal/ConversationView/CellViews/CVReactionCountsView.swift b/Signal/ConversationView/CellViews/CVReactionCountsView.swift index b788d1b8d03..86fccb1a31e 100644 --- a/Signal/ConversationView/CellViews/CVReactionCountsView.swift +++ b/Signal/ConversationView/CellViews/CVReactionCountsView.swift @@ -40,6 +40,22 @@ class CVReactionCountsView: ManualStackView { private let pill2 = PillView(pillKey: CVReactionCountsView.pillKey2) private let pill3 = PillView(pillKey: CVReactionCountsView.pillKey3) + private static let visibleByMessageId = NSMapTable.strongToWeakObjects() + + private enum EntranceKind { + case local + case remote + } + + private var messageId: String? + private var pendingEntrance: EntranceKind? + private var entranceScheduled = false + private var lastSaneSize: CGSize? + + private var localPill: PillView { + [pill1, pill2, pill3].first(where: \.fromLocalUser) ?? pill1 + } + init() { super.init(name: "reaction counts") } @@ -94,11 +110,12 @@ class CVReactionCountsView: ManualStackView { private static let measurementKey = "CVReactionCountsView" - /// Bound message for reuse-safe entrance playback. - private var messageId: String? - func configure(state: State, cellMeasurement: CVCellMeasurement, messageId: String) { + if let previous = self.messageId, previous != messageId { + Self.visibleByMessageId.removeObject(forKey: previous as NSString) + } self.messageId = messageId + Self.visibleByMessageId.setObject(self, forKey: messageId as NSString) layer.borderColor = Theme.backgroundColor.cgColor @@ -121,12 +138,117 @@ class CVReactionCountsView: ManualStackView { subviews: subviews, ) - // One flow: local add marks pending in ReactionManager; pill plays when rendered. - if ReactionManager.pendingReactionEntrances.remove(messageId) { - playEntranceWhenReady(messageId: messageId, triesLeft: 60) + if ReactionLayoutPending.localEntrances.remove(messageId) { + pendingEntrance = .local + entranceScheduled = false + lastSaneSize = nil + } else if ReactionLayoutPending.remoteEntrances.remove(messageId) { + pendingEntrance = .remote + entranceScheduled = false + lastSaneSize = nil + } + layer.removeAllAnimations() + transform = .identity + switch pendingEntrance { + case .local: + // Hidden until stable pill size; then dymek+heart overlap the expand. + alpha = 0 + localPill.prepareForLocalEntranceFlight() + // Wait for flush after expand starts — avoids first-frame stretch glitch. + case .remote: + alpha = 0 + case .none: + alpha = 1 + } + } + + static func flushPendingPops() { + AssertIsOnMainThread() + guard let views = visibleByMessageId.objectEnumerator()?.allObjects as? [CVReactionCountsView] else { + return + } + for view in views { + view.scheduleEntranceIfNeeded() + } + } + + private func scheduleEntranceIfNeeded() { + guard pendingEntrance != nil, !entranceScheduled else { return } + entranceScheduled = true + lastSaneSize = nil + whenPillSized(triesLeft: 12) { [weak self] in + guard let self, let kind = self.pendingEntrance else { return } + guard self.hasSanePillBounds else { + self.entranceScheduled = false + return + } + self.pendingEntrance = nil + self.entranceScheduled = false + self.lastSaneSize = nil + switch kind { + case .local: + self.playLocalEntrance() + case .remote: + self.playRemoteEntrance() + } + } + } + + private var hasSanePillBounds: Bool { + // Tight gate — stretched mid-layout frames must not pass (giant box). + abs(bounds.height - Self.height) < 1.5 + && bounds.width > 18 + && bounds.width < 72 + } + + private func whenPillSized(triesLeft: Int, play: @escaping () -> Void) { + layoutIfNeeded() + if hasSanePillBounds { + let size = bounds.size + // Two stable frames — kills one-frame teleport / giant flash on ProMotion. + if let last = lastSaneSize, + abs(last.width - size.width) < 0.5, + abs(last.height - size.height) < 0.5 { + play() + return + } + lastSaneSize = size + } else { + lastSaneSize = nil + } + guard triesLeft > 0 else { + entranceScheduled = false + lastSaneSize = nil + // Never show a stretched pill. + return + } + DispatchQueue.main.async { [weak self] in + self?.whenPillSized(triesLeft: triesLeft - 1, play: play) } } + private func playLocalEntrance() { + guard hasSanePillBounds else { return } + layer.removeAllAnimations() + // Dymek + heart together with expand (not after it). + alpha = 1 + transform = .identity + localPill.playLocalEntrancePop() + } + + private func playRemoteEntrance() { + guard hasSanePillBounds else { return } + layer.removeAllAnimations() + alpha = 0 + transform = CGAffineTransform(scaleX: 0.86, y: 0.86) + let animator = UIViewPropertyAnimator(duration: 0.15, springDamping: 0.78, springResponse: 0.15) + animator.addAnimations { + self.alpha = 1 + self.transform = .identity + } + animator.startAnimation() + } + static func measure( state: State, measurementBuilder: CVCellMeasurement.Builder, @@ -157,7 +279,16 @@ class CVReactionCountsView: ManualStackView { } override func reset() { + if let messageId, Self.visibleByMessageId.object(forKey: messageId as NSString) === self { + Self.visibleByMessageId.removeObject(forKey: messageId as NSString) + } messageId = nil + pendingEntrance = nil + entranceScheduled = false + lastSaneSize = nil + layer.removeAllAnimations() + alpha = 1 + transform = .identity super.reset() pill1.reset() @@ -165,30 +296,18 @@ class CVReactionCountsView: ManualStackView { pill3.reset() } - private func playEntranceWhenReady(messageId: String, triesLeft: Int) { - guard self.messageId == messageId else { return } - if bounds.width > 0 { - pill1.playEntrancePop() - return - } - guard triesLeft > 0 else { return } - DispatchQueue.main.async { [weak self] in - self?.playEntranceWhenReady(messageId: messageId, triesLeft: triesLeft - 1) - } - } - // MARK: - private class PillView: ManualStackViewWithLayer { private let pillKey: String - /// Stack lays out the wrap (sets frame). Transform animates on the label inside. - private let emojiWrap = ManualLayoutView(name: "reaction emoji wrap") + // WithLayer wrap so flying emoji isn't sliced by pill border. + private let emojiWrap = ManualLayoutViewWithLayer(name: "reaction emoji wrap") private let emojiLabel = CVLabel() private let countLabel = CVLabel() - /// Blocks configure/reset from killing in-flight pop (was the subtle snap). - private var isPlayingEntrance = false + private(set) var fromLocalUser = false + private var isPlayingPop = false private static let pillBorderWidth: CGFloat = 1 @@ -197,10 +316,15 @@ class CVReactionCountsView: ManualStackView { super.init(name: pillKey) - emojiLabel.clipsToBounds = true + emojiWrap.clipsToBounds = false + emojiWrap.backgroundColor = .clear + emojiWrap.isOpaque = false + emojiLabel.clipsToBounds = false + emojiLabel.backgroundColor = .clear emojiWrap.addSubview(emojiLabel) - emojiWrap.addLayoutBlock { [weak emojiLabel] view in - emojiLabel?.frame = view.bounds + emojiWrap.addLayoutBlock { [weak self, weak emojiLabel] view in + guard let self, let emojiLabel, emojiLabel.superview === self.emojiWrap else { return } + emojiLabel.frame = view.bounds } clipsToBounds = true } @@ -259,7 +383,9 @@ class CVReactionCountsView: ManualStackView { pillState: PillState, cellMeasurement: CVCellMeasurement, ) { - if !isPlayingEntrance { + fromLocalUser = pillState.fromLocalUser + + if !isPlayingPop { layer.removeAllAnimations() transform = .identity alpha = 1 @@ -301,59 +427,65 @@ class CVReactionCountsView: ManualStackView { ) } - override func reset() { - stopEntrancePop() - super.reset() + func prepareForLocalEntranceFlight() { + alpha = 1 + transform = .identity + emojiLabel.alpha = 0 + emojiLabel.transform = CGAffineTransform(translationX: 0, y: -36).scaledBy(x: 0.45, y: 0.45) } - func playEntrancePop() { - guard !isPlayingEntrance else { return } - isPlayingEntrance = true - - // Subtle fast chip appear (scale/alpha only — layout frame stays put). - alpha = 0 - transform = CGAffineTransform(scaleX: 0.88, y: 0.88) - UIView.animate( - withDuration: 0.14, - delay: 0, - options: [.curveEaseOut, .allowUserInteraction], - ) { - self.alpha = 1 - self.transform = .identity + func playLocalEntrancePop() { + guard beginPop() else { + alpha = 1 + emojiLabel.alpha = 1 + emojiLabel.transform = .identity + return } - // Emoji pop into the chip. - clipsToBounds = false + // Pill already on-screen; only emoji flies in. + alpha = 1 + transform = .identity emojiLabel.alpha = 0 - emojiLabel.transform = CGAffineTransform(translationX: 0, y: -40).scaledBy(x: 0.4, y: 0.4) + emojiLabel.transform = CGAffineTransform(translationX: 0, y: -36).scaledBy(x: 0.45, y: 0.45) - // Peak ~14pt emoji +5px ≈ 1.36 scale; land back to identity in the pill. - UIView.animateKeyframes(withDuration: 0.42, delay: 0, options: []) { - UIView.addKeyframe(withRelativeStartTime: 0, relativeDuration: 0.38) { + UIView.animateKeyframes(withDuration: 0.28, delay: 0, options: [.allowUserInteraction]) { + UIView.addKeyframe(withRelativeStartTime: 0, relativeDuration: 0.35) { self.emojiLabel.alpha = 1 - self.emojiLabel.transform = CGAffineTransform(translationX: 0, y: -40).scaledBy(x: 1.36, y: 1.36) + self.emojiLabel.transform = CGAffineTransform(translationX: 0, y: -36).scaledBy(x: 1.32, y: 1.32) } - UIView.addKeyframe(withRelativeStartTime: 0.38, relativeDuration: 0.62) { + UIView.addKeyframe(withRelativeStartTime: 0.35, relativeDuration: 0.65) { self.emojiLabel.transform = .identity } } completion: { [weak self] _ in - self?.finishEntrancePop() + self?.finishPop() } } - private func stopEntrancePop() { - layer.removeAllAnimations() - emojiLabel.layer.removeAllAnimations() - finishEntrancePop() + private func beginPop() -> Bool { + guard !isPlayingPop, let counts = superview else { return false } + // Only unclip the pill — never the message wrapper (giant layer flash). + isPlayingPop = true + clipsToBounds = false + counts.clipsToBounds = false + return true } - private func finishEntrancePop() { - isPlayingEntrance = false + func finishPop() { + isPlayingPop = false + emojiLabel.transform = .identity + emojiLabel.alpha = 1 transform = .identity alpha = 1 + clipsToBounds = true + superview?.clipsToBounds = false + } + + override func reset() { + isPlayingPop = false + fromLocalUser = false emojiLabel.transform = .identity emojiLabel.alpha = 1 - clipsToBounds = true + super.reset() } static func measure( diff --git a/Signal/ConversationView/Components/CVComponentMessage.swift b/Signal/ConversationView/Components/CVComponentMessage.swift index edd20fddcea..f0c8729b3e2 100644 --- a/Signal/ConversationView/Components/CVComponentMessage.swift +++ b/Signal/ConversationView/Components/CVComponentMessage.swift @@ -2042,7 +2042,6 @@ public class CVComponentMessage: CVComponentBase, CVRootComponent { guard let action = viewModel.doubleTapAction else { return nil } - // Match reaction-picker eligibility so we don't consume the gesture as a no-op. if action == .react, componentDelegate.hasPendingMessageRequest { return nil } @@ -2549,7 +2548,6 @@ public class CVComponentMessage: CVComponentBase, CVRootComponent { } public func canHandleDoubleTapGesture(_ sender: UIGestureRecognizer) -> Bool { - // Text / media / stickers can take a double-tap action; component logic decides which. return bodyTextView != nil || bodyMediaView != nil || stickerView != nil } diff --git a/Signal/ConversationView/ConversationViewController+CVC.swift b/Signal/ConversationView/ConversationViewController+CVC.swift index 29df0c7c674..d8aa2c785dd 100644 --- a/Signal/ConversationView/ConversationViewController+CVC.swift +++ b/Signal/ConversationView/ConversationViewController+CVC.swift @@ -171,8 +171,11 @@ extension ConversationViewController: CVLoadCoordinatorDelegate { // Snapshot CVC layout state before we land the load; // we use this to ensure scroll continuity when landing the load. + // Reaction reflow: prefer anchoring on the bubble below so height change grows upward. + let preferredAnchor = update.loadRequest.preferredScrollContinuityAnchorInteractionId + ?? Self.preferredScrollAnchorBelowReaction(prevItems: update.prevRenderState.items) let scrollContinuityToken = layout.buildScrollContinuityToken( - preferredAnchorInteractionId: update.loadRequest.preferredScrollContinuityAnchorInteractionId, + preferredAnchorInteractionId: preferredAnchor, ) // CVC will often use this state to ensure scroll continuity @@ -569,6 +572,13 @@ extension ConversationViewController: CVLoadCoordinatorDelegate { // // We prefer the second mechanism and only use the first mechanism to // handle special cases (ie. when shouldUseDelegateScrollContinuity is true). + // Stock path + thin reaction overlay: reload reacted cell, skip neighbors, + // suppress reload crossfade (ManualLayout), then pop the pill. + let isReactionLayout = items.contains { item in + guard case .update = item.updateType else { return false } + return ReactionLayoutPending.hasPending(for: item.value.interactionUniqueId) + } + let scrollContinuity: ScrollContinuity = { guard let loadType = renderState.loadType else { owsFailDebug("Missing loadType.") @@ -581,6 +591,13 @@ extension ConversationViewController: CVLoadCoordinatorDelegate { return .delegateScrollContinuity } + // Reaction height change: never pre-bake contentOffsetAdjustment + // (instant offset + animated frames = micro-teleports). Keep offset + // in sync via targetContentOffset during the batch animation. + if isReactionLayout { + return .delegateScrollContinuity + } + let scrollContinuityToken = updateToken.scrollContinuityToken switch loadType { @@ -627,7 +644,14 @@ extension ConversationViewController: CVLoadCoordinatorDelegate { self.collectionView.moveItem(at: oldIndexPath, to: newIndexPath) case .update(let oldIndex, _): let indexPath = IndexPath(row: oldIndex, section: section) - self.collectionView.reloadItems(at: [indexPath]) + let itemId = item.value.interactionUniqueId + if ReactionLayoutPending.hasPending(for: itemId) { + // Consume exit flag after old frame was captured (collapse anim). + _ = ReactionLayoutPending.exits.remove(itemId) + self.collectionView.reloadItems(at: [indexPath]) + } else if !isReactionLayout { + self.collectionView.reloadItems(at: [indexPath]) + } } } } @@ -647,7 +671,7 @@ extension ConversationViewController: CVLoadCoordinatorDelegate { self.updateViewToReflectLoad(loadedRenderState: renderState) - if shouldAnimateUpdate { + if shouldAnimateUpdate || isReactionLayout { self.loadDidLand() } @@ -665,21 +689,73 @@ extension ConversationViewController: CVLoadCoordinatorDelegate { } } - // We use an obj-c free function so that we can handle NSException. + // Reaction height reflow: expand/collapse old→new. Local dymek+heart start as soon as + // the pill has a real size (overlaps expand) — not only after completion. + self.layout.suppressReloadCrossfade = isReactionLayout + self.layout.reactionReloadOldFrames = [:] + self.layout.reactionExitIndexPaths = [] + if isReactionLayout { + let section = Self.messageSection + for item in items { + guard case .update(let oldIndex, _) = item.updateType else { continue } + let itemId = item.value.interactionUniqueId + guard ReactionLayoutPending.hasPending(for: itemId) else { continue } + let indexPath = IndexPath(row: oldIndex, section: section) + if let frame = self.layout.layoutAttributesForItem(at: indexPath)?.frame { + self.layout.reactionReloadOldFrames[indexPath] = frame + } + if ReactionLayoutPending.exits.contains(itemId) { + self.layout.reactionExitIndexPaths.insert(indexPath) + } + } + CATransaction.begin() + CATransaction.setAnimationDuration(0.2) + CATransaction.setAnimationTimingFunction( + CAMediaTimingFunction(name: .easeInEaseOut), + ) + } + self.collectionView.cvc_performBatchUpdates( batchUpdatesBlock, - completion: completion, - animated: shouldAnimateUpdate, + completion: { finished in + self.layout.suppressReloadCrossfade = false + self.layout.reactionReloadOldFrames = [:] + self.layout.reactionExitIndexPaths = [] + if isReactionLayout { + CVReactionCountsView.flushPendingPops() + } + completion(finished) + }, + animated: shouldAnimateUpdate || isReactionLayout, scrollContinuity: scrollContinuity, lastKnownDistanceFromBottom: updateToken.lastKnownDistanceFromBottom, cvc: self, ) - if !shouldAnimateUpdate { + if isReactionLayout { + CATransaction.commit() + // Let the first ease-in frames land, then overlap dymek+heart with expand. + DispatchQueue.main.asyncAfter(deadline: .now() + 0.03) { + CVReactionCountsView.flushPendingPops() + } + } + + if !shouldAnimateUpdate, !isReactionLayout { self.loadDidLand() } } + private static func preferredScrollAnchorBelowReaction(prevItems: [CVRenderItem]) -> String? { + for (idx, item) in prevItems.enumerated() { + guard ReactionLayoutPending.hasPending(for: item.interactionUniqueId) else { continue } + for next in prevItems.suffix(from: idx + 1) where next.canBeUsedForContinuity { + return next.interactionUniqueId + } + return nil + } + return nil + } + private var scrolledToEdgeTolerancePoints: CGFloat { let deviceFrame = CurrentAppContext().frame // Within 1 screenful of the edge of the load window. diff --git a/Signal/ConversationView/ConversationViewController+CVComponentDelegate.swift b/Signal/ConversationView/ConversationViewController+CVComponentDelegate.swift index 08d04fe8e25..9143373a946 100644 --- a/Signal/ConversationView/ConversationViewController+CVComponentDelegate.swift +++ b/Signal/ConversationView/ConversationViewController+CVComponentDelegate.swift @@ -62,11 +62,16 @@ extension ConversationViewController: CVComponentDelegate { } let messageId = message.uniqueId + let now = CFAbsoluteTimeGetCurrent() + if let last = viewState.lastDoubleTapReactAtByMessageId[messageId], now - last < 0.3 { + return + } + viewState.lastDoubleTapReactAtByMessageId[messageId] = now + let quickEmoji = SSKEnvironment.shared.databaseStorageRef.read { ReactionManager.preferredQuickReactionEmoji(transaction: $0) } - // Optimistic UI from the render snapshot; the write re-checks under the lock. let existing = viewModel.reactionState?.localUserEmojiVariant if let existing, existing != quickEmoji { return @@ -74,7 +79,6 @@ extension ConversationViewController: CVComponentDelegate { ImpactHapticFeedback.impactOccurred(style: .light) - // Write only. Entrance: ReactionManager.pendingReactionEntrances → CVReactionCountsView.configure. SSKEnvironment.shared.databaseStorageRef.asyncWrite { tx in guard let message = TSMessage.fetchMessageViaCache(uniqueId: messageId, transaction: tx), @@ -87,10 +91,12 @@ extension ConversationViewController: CVComponentDelegate { if let latest, latest != quickEmoji { return } + let isRemoving = latest == quickEmoji + ReactionLayoutPending.markLocal(messageId: messageId, isRemoving: isRemoving) ReactionManager.localUserReacted( to: messageId, emoji: quickEmoji, - isRemoving: latest == quickEmoji, + isRemoving: isRemoving, tx: tx, ) } diff --git a/Signal/ConversationView/ConversationViewController+GestureRecognizers.swift b/Signal/ConversationView/ConversationViewController+GestureRecognizers.swift index 8a03a63d7ef..1105440ec6c 100644 --- a/Signal/ConversationView/ConversationViewController+GestureRecognizers.swift +++ b/Signal/ConversationView/ConversationViewController+GestureRecognizers.swift @@ -153,8 +153,7 @@ extension ConversationViewController: SingleOrDoubleTapGestureDelegate { return false } - // Fast double-tap should react/reply/edit instead of opening media or sticker details. - // Defer single-tap until the double-tap window times out. + // Defer single-tap until double-tap window times out. if cell.canHandleDoubleTap(sender: sender, componentDelegate: self) { viewState.deferredSingleTap = { [weak self, weak cell] in guard let self, let cell else { return false } diff --git a/Signal/ConversationView/ConversationViewController+MessageActions.swift b/Signal/ConversationView/ConversationViewController+MessageActions.swift index c7f5d92f7d5..bbe8dd1e902 100644 --- a/Signal/ConversationView/ConversationViewController+MessageActions.swift +++ b/Signal/ConversationView/ConversationViewController+MessageActions.swift @@ -171,6 +171,7 @@ extension ConversationViewController: ContextMenuInteractionDelegate { } SSKEnvironment.shared.databaseStorageRef.asyncWrite { transaction in + ReactionLayoutPending.markLocal(messageId: message.uniqueId, isRemoving: isRemoving) ReactionManager.localUserReacted( to: message.uniqueId, emoji: reaction, diff --git a/Signal/ConversationView/ConversationViewLayout.swift b/Signal/ConversationView/ConversationViewLayout.swift index 7716abbc7a4..80829506920 100644 --- a/Signal/ConversationView/ConversationViewLayout.swift +++ b/Signal/ConversationView/ConversationViewLayout.swift @@ -781,6 +781,64 @@ public class ConversationViewLayout: UICollectionViewLayout { // Flag set before performBatchUpdates() and cleared after it _returns_. private var isPerformingBatchUpdates = false + /// Reaction reloadItems: hide old cell, grow new cell from captured pre-reload frame. + public var suppressReloadCrossfade = false + public var reactionReloadOldFrames = [IndexPath: CGRect]() + /// Index paths whose reaction is being removed — fade/scale out instead of hard hide. + public var reactionExitIndexPaths = Set() + + override public func initialLayoutAttributesForAppearingItem( + at itemIndexPath: IndexPath, + ) -> UICollectionViewLayoutAttributes? { + if suppressReloadCrossfade { + let source = layoutAttributesForItem(at: itemIndexPath) + ?? super.initialLayoutAttributesForAppearingItem(at: itemIndexPath) + guard let attributes = source?.copy() as? UICollectionViewLayoutAttributes else { + return nil + } + attributes.alpha = 1 + attributes.isHidden = false + attributes.zIndex = 1 + attributes.transform = .identity + attributes.transform3D = CATransform3DIdentity + // Start at pre-reload frame so height animates (final-frame snap killed rozsuwanie). + if let oldFrame = reactionReloadOldFrames[itemIndexPath] { + attributes.frame = oldFrame + } + // Avoid presentation-layer scale interpolation (looks like teleport on ProMotion). + attributes.center.x = attributes.frame.midX + attributes.center.y = attributes.frame.midY + return attributes + } + return super.initialLayoutAttributesForAppearingItem(at: itemIndexPath) + } + + override public func finalLayoutAttributesForDisappearingItem( + at itemIndexPath: IndexPath, + ) -> UICollectionViewLayoutAttributes? { + guard let attributes = super.finalLayoutAttributesForDisappearingItem(at: itemIndexPath)? + .copy() as? UICollectionViewLayoutAttributes + else { + return nil + } + if suppressReloadCrossfade { + attributes.alpha = 0 + attributes.zIndex = -1 + attributes.transform = .identity + attributes.transform3D = CATransform3DIdentity + // Always hard-hide the outgoing reload cell — fade/scale left a ghost double. + attributes.isHidden = true + if reactionExitIndexPaths.contains(itemIndexPath), + let oldFrame = reactionReloadOldFrames[itemIndexPath] + { + // Collapse: keep outgoing cell sized to pre-reload frame so neighbors + // interpolate cleanly against a stable disappearing rect. + attributes.frame = oldFrame + } + } + return attributes + } + private enum DelegateScrollContinuityMode: Equatable { case disabled case enabled(lastKnownDistanceFromBottom: CGFloat?) diff --git a/Signal/ConversationView/Loading/CVUpdate.swift b/Signal/ConversationView/Loading/CVUpdate.swift index c892199dcc8..0875e59a6be 100644 --- a/Signal/ConversationView/Loading/CVUpdate.swift +++ b/Signal/ConversationView/Loading/CVUpdate.swift @@ -105,6 +105,18 @@ extension CVUpdate { case .stateChanged: // The item changed, so we need to update it. changedRenderItems.append(newRenderItem) + let oldReactions = oldRenderItem.componentState.reactions?.viewState + let newReactions = newRenderItem.componentState.reactions?.viewState + if oldReactions != newReactions, + !ReactionLayoutPending.localEntrances.contains(itemId), + !ReactionLayoutPending.exits.contains(itemId) + { + if newReactions != nil, oldReactions == nil { + ReactionLayoutPending.remoteEntrances.insert(itemId) + } else if newReactions == nil, oldReactions != nil { + ReactionLayoutPending.exits.insert(itemId) + } + } case .appearanceChanged: // Take note of the fact that only the _appearance_ of the // item changed, not its state. @@ -162,6 +174,21 @@ extension CVUpdate { var shouldAnimateUpdate = true var onlyAppearanceUpdateChanges = true var previousItemCount = oldItemCount + let batchHasStructuralChange = updateItems.contains { + switch $0.updateType { + case .insert, .delete, .move: return true + case .update: return false + } + } + // Pure reaction height reflow: always animate (bottom-message heuristic would kill it). + if !batchHasStructuralChange, + updateItems.contains(where: { + guard case .update = $0.updateType else { return false } + return ReactionLayoutPending.hasPending(for: $0.value.interactionUniqueId) + }) + { + return true + } for updateItem in updateItems { guard shouldAnimateUpdate else { // Exit early if we already know that we're not just @@ -198,6 +225,10 @@ extension CVUpdate { } onlyAppearanceUpdateChanges = false + // Reaction height reflow - skip only when typing/insert/delete would teleport. + if !batchHasStructuralChange, ReactionLayoutPending.hasPending(for: itemId) { + continue + } switch renderItem.interactionType { case .incomingMessage, .outgoingMessage, .typingIndicator: // We skip animations for the last _two_ diff --git a/Signal/ConversationView/Reactions/ReactionLayoutPending.swift b/Signal/ConversationView/Reactions/ReactionLayoutPending.swift new file mode 100644 index 00000000000..39b6c507174 --- /dev/null +++ b/Signal/ConversationView/Reactions/ReactionLayoutPending.swift @@ -0,0 +1,31 @@ +// +// Copyright 2026 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only +// + +import SignalServiceKit + +/// UI-only flags for reaction height reflow + entrance/exit. Not protocol/DB state. +enum ReactionLayoutPending { + static let localEntrances = AtomicSet(lock: .init()) + static let exits = AtomicSet(lock: .init()) + static let remoteEntrances = AtomicSet(lock: .init()) + + static func hasPending(for messageId: String) -> Bool { + localEntrances.contains(messageId) + || exits.contains(messageId) + || remoteEntrances.contains(messageId) + } + + static func markLocal(messageId: String, isRemoving: Bool) { + if isRemoving { + exits.insert(messageId) + localEntrances.remove(messageId) + remoteEntrances.remove(messageId) + } else { + localEntrances.insert(messageId) + exits.remove(messageId) + remoteEntrances.remove(messageId) + } + } +} diff --git a/Signal/Notifications/NotificationActionHandler.swift b/Signal/Notifications/NotificationActionHandler.swift index 4f77375fc16..92e6b655393 100644 --- a/Signal/Notifications/NotificationActionHandler.swift +++ b/Signal/Notifications/NotificationActionHandler.swift @@ -285,7 +285,8 @@ public class NotificationActionHandler { do { try await SSKEnvironment.shared.databaseStorageRef.awaitableWrite { transaction in - ReactionManager.localUserReacted( + ReactionLayoutPending.markLocal(messageId: incomingMessage.uniqueId, isRemoving: false) + return ReactionManager.localUserReacted( to: incomingMessage.uniqueId, emoji: "👍", isRemoving: false, diff --git a/Signal/src/ViewControllers/AppSettings/ChatsSettingsViewController.swift b/Signal/src/ViewControllers/AppSettings/ChatsSettingsViewController.swift index 365277bafdf..55292daa005 100644 --- a/Signal/src/ViewControllers/AppSettings/ChatsSettingsViewController.swift +++ b/Signal/src/ViewControllers/AppSettings/ChatsSettingsViewController.swift @@ -107,98 +107,22 @@ class ChatsSettingsViewController: OWSTableViewController2 { ) contents.add(keepMutedChatsArchived) - let (othersAction, ownAction) = SSKEnvironment.shared.databaseStorageRef.read { tx in - ( - SSKPreferences.doubleTapOthersMessagesAction(transaction: tx), - SSKPreferences.doubleTapOwnMessagesAction(transaction: tx), - ) - } - let doubleTapSection = OWSTableSection() - doubleTapSection.headerTitle = OWSLocalizedString( - "SETTINGS_DOUBLE_TAP_SECTION_TITLE", - comment: "Section title for double-tap message action settings.", - ) doubleTapSection.footerTitle = OWSLocalizedString( "SETTINGS_DOUBLE_TAP_SECTION_FOOTER", - comment: "Footer explaining configurable double-tap actions on messages.", + comment: "Footer under the Chats settings row that opens double-tap options.", ) doubleTapSection.add(.disclosureItem( withText: OWSLocalizedString( - "SETTINGS_DOUBLE_TAP_OTHERS_MESSAGES", - comment: "Setting for double-tap action on messages from other people.", + "SETTINGS_DOUBLE_TAP_SECTION_TITLE", + comment: "Title for double-tap message action settings.", ), - accessoryText: othersAction.displayName, + accessoryText: DoubleTapSettingsViewController.currentSummaryAccessoryText(), actionBlock: { [weak self] in - guard let self else { return } - let vc = DoubleTapActionSettingsViewController( - title: OWSLocalizedString( - "SETTINGS_DOUBLE_TAP_OTHERS_MESSAGES", - comment: "Setting for double-tap action on messages from other people.", - ), - options: SSKPreferences.DoubleTapOthersMessagesAction.allCases.map { ($0.displayName, $0 == othersAction) }, - ) { index in - let action = SSKPreferences.DoubleTapOthersMessagesAction.allCases[index] - SSKEnvironment.shared.databaseStorageRef.write { - SSKPreferences.setDoubleTapOthersMessagesAction(action, transaction: $0) - } - } - self.navigationController?.pushViewController(vc, animated: true) - }, - )) - doubleTapSection.add(.disclosureItem( - withText: OWSLocalizedString( - "SETTINGS_DOUBLE_TAP_OWN_MESSAGES", - comment: "Setting for double-tap action on your own messages.", - ), - accessoryText: ownAction.displayName, - actionBlock: { [weak self] in - guard let self else { return } - let vc = DoubleTapActionSettingsViewController( - title: OWSLocalizedString( - "SETTINGS_DOUBLE_TAP_OWN_MESSAGES", - comment: "Setting for double-tap action on your own messages.", - ), - options: SSKPreferences.DoubleTapOwnMessagesAction.allCases.map { ($0.displayName, $0 == ownAction) }, - ) { index in - let action = SSKPreferences.DoubleTapOwnMessagesAction.allCases[index] - SSKEnvironment.shared.databaseStorageRef.write { - SSKPreferences.setDoubleTapOwnMessagesAction(action, transaction: $0) - } - } - self.navigationController?.pushViewController(vc, animated: true) - }, - )) - doubleTapSection.add(.switch( - withText: OWSLocalizedString( - "SETTINGS_DOUBLE_TAP_REACT_STICKERS", - comment: "Toggle for allowing double-tap quick react on sticker messages.", - ), - isOn: { - SSKEnvironment.shared.databaseStorageRef.read { - SSKPreferences.isDoubleTapReactEnabledForStickers(transaction: $0) - } - }, - actionBlock: { sender in - SSKEnvironment.shared.databaseStorageRef.write { - SSKPreferences.setDoubleTapReactEnabledForStickers(sender.isOn, transaction: $0) - } - }, - )) - doubleTapSection.add(.switch( - withText: OWSLocalizedString( - "SETTINGS_DOUBLE_TAP_REACT_PHOTOS", - comment: "Toggle for allowing double-tap quick react on photo and video messages.", - ), - isOn: { - SSKEnvironment.shared.databaseStorageRef.read { - SSKPreferences.isDoubleTapReactEnabledForPhotos(transaction: $0) - } - }, - actionBlock: { sender in - SSKEnvironment.shared.databaseStorageRef.write { - SSKPreferences.setDoubleTapReactEnabledForPhotos(sender.isOn, transaction: $0) - } + self?.navigationController?.pushViewController( + DoubleTapSettingsViewController(), + animated: true, + ) }, )) contents.add(doubleTapSection) @@ -359,48 +283,180 @@ class ChatsSettingsViewController: OWSTableViewController2 { // MARK: - -private final class DoubleTapActionSettingsViewController: OWSTableViewController2 { - private let options: [(title: String, isSelected: Bool)] - private let onSelect: (Int) -> Void - - init(title: String, options: [(String, Bool)], onSelect: @escaping (Int) -> Void) { - self.options = options - self.onSelect = onSelect - super.init() - self.title = title - } +/// Checkmark picker for double-tap actions — same pattern as Theme / Sent Media Quality. +final class DoubleTapSettingsViewController: OWSTableViewController2 { override func viewDidLoad() { super.viewDidLoad() + title = OWSLocalizedString( + "SETTINGS_DOUBLE_TAP_SECTION_TITLE", + comment: "Title for double-tap message action settings.", + ) + updateTableContents() + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + updateTableContents() + } + + static func currentSummaryAccessoryText() -> String { + let (others, own) = SSKEnvironment.shared.databaseStorageRef.read { tx in + ( + SSKPreferences.doubleTapOthersMessagesAction(transaction: tx).displayName, + SSKPreferences.doubleTapOwnMessagesAction(transaction: tx).displayName, + ) + } + // Short accessory on the Chats row, e.g. "React, Edit". + return "\(others), \(own)" + } - let section = OWSTableSection() - for (index, option) in options.enumerated() { - section.add(.init( - text: option.title, + private func updateTableContents() { + let (othersAction, ownAction) = SSKEnvironment.shared.databaseStorageRef.read { tx in + ( + SSKPreferences.doubleTapOthersMessagesAction(transaction: tx), + SSKPreferences.doubleTapOwnMessagesAction(transaction: tx), + ) + } + + let contents = OWSTableContents() + + let othersSection = OWSTableSection() + othersSection.headerTitle = OWSLocalizedString( + "SETTINGS_DOUBLE_TAP_OTHERS_MESSAGES", + comment: "Section title for double-tap action on messages from other people.", + ) + othersSection.footerTitle = OWSLocalizedString( + "SETTINGS_DOUBLE_TAP_OTHERS_FOOTER", + comment: "Footer for double-tap action on messages from other people.", + ) + for action in SSKPreferences.DoubleTapOthersMessagesAction.allCases { + othersSection.add(OWSTableItem( + text: action.displayName, + actionBlock: { [weak self] in + self?.setOthersAction(action) + }, + accessoryType: action == othersAction ? .checkmark : .none, + )) + } + contents.add(othersSection) + + let ownSection = OWSTableSection() + ownSection.headerTitle = OWSLocalizedString( + "SETTINGS_DOUBLE_TAP_OWN_MESSAGES", + comment: "Section title for double-tap action on your own messages.", + ) + ownSection.footerTitle = OWSLocalizedString( + "SETTINGS_DOUBLE_TAP_OWN_FOOTER", + comment: "Footer for double-tap action on your own messages.", + ) + for action in SSKPreferences.DoubleTapOwnMessagesAction.allCases { + ownSection.add(OWSTableItem( + text: action.displayName, actionBlock: { [weak self] in - guard let self else { return } - self.onSelect(index) - self.navigationController?.popViewController(animated: true) + self?.setOwnAction(action) + }, + accessoryType: action == ownAction ? .checkmark : .none, + )) + } + contents.add(ownSection) + + if othersAction == .react { + let mediaSection = OWSTableSection() + mediaSection.footerTitle = OWSLocalizedString( + "SETTINGS_DOUBLE_TAP_REACT_MEDIA_FOOTER", + comment: "Footer for toggles that allow double-tap react on stickers and photos.", + ) + mediaSection.add(.switch( + withText: OWSLocalizedString( + "SETTINGS_DOUBLE_TAP_REACT_STICKERS", + comment: "Toggle for allowing double-tap quick react on sticker messages.", + ), + isOn: { + SSKEnvironment.shared.databaseStorageRef.read { + SSKPreferences.isDoubleTapReactEnabledForStickers(transaction: $0) + } + }, + actionBlock: { sender in + SSKEnvironment.shared.databaseStorageRef.write { + SSKPreferences.setDoubleTapReactEnabledForStickers(sender.isOn, transaction: $0) + } + }, + )) + mediaSection.add(.switch( + withText: OWSLocalizedString( + "SETTINGS_DOUBLE_TAP_REACT_PHOTOS", + comment: "Toggle for allowing double-tap quick react on photo and video messages.", + ), + isOn: { + SSKEnvironment.shared.databaseStorageRef.read { + SSKPreferences.isDoubleTapReactEnabledForPhotos(transaction: $0) + } + }, + actionBlock: { sender in + SSKEnvironment.shared.databaseStorageRef.write { + SSKPreferences.setDoubleTapReactEnabledForPhotos(sender.isOn, transaction: $0) + } }, - accessoryType: option.isSelected ? .checkmark : .none, )) + contents.add(mediaSection) + } + + self.contents = contents + } + + private func setOthersAction(_ action: SSKPreferences.DoubleTapOthersMessagesAction) { + let current = SSKEnvironment.shared.databaseStorageRef.read { + SSKPreferences.doubleTapOthersMessagesAction(transaction: $0) + } + guard action != current else { return } + SSKEnvironment.shared.databaseStorageRef.write { + SSKPreferences.setDoubleTapOthersMessagesAction(action, transaction: $0) + } + updateTableContents() + } + + private func setOwnAction(_ action: SSKPreferences.DoubleTapOwnMessagesAction) { + let current = SSKEnvironment.shared.databaseStorageRef.read { + SSKPreferences.doubleTapOwnMessagesAction(transaction: $0) + } + guard action != current else { return } + SSKEnvironment.shared.databaseStorageRef.write { + SSKPreferences.setDoubleTapOwnMessagesAction(action, transaction: $0) } - contents = OWSTableContents(sections: [section]) + updateTableContents() + } +} + +// MARK: - + +private enum DoubleTapActionStrings { + static var off: String { + OWSLocalizedString("SETTINGS_DOUBLE_TAP_ACTION_OFF", comment: "Double-tap action: disabled.") + } + + static var react: String { + OWSLocalizedString( + "SETTINGS_DOUBLE_TAP_ACTION_REACT", + comment: "Double-tap action: react with the first quick-reaction emoji.", + ) + } + + static var edit: String { + OWSLocalizedString("SETTINGS_DOUBLE_TAP_ACTION_EDIT", comment: "Double-tap action: edit your message.") + } + + static var reply: String { + OWSLocalizedString("SETTINGS_DOUBLE_TAP_ACTION_REPLY", comment: "Double-tap action: reply to the message.") } } private extension SSKPreferences.DoubleTapOthersMessagesAction { var displayName: String { switch self { - case .off: - return OWSLocalizedString("SETTINGS_DOUBLE_TAP_ACTION_OFF", comment: "Double-tap action: disabled.") - case .react: - return OWSLocalizedString( - "SETTINGS_DOUBLE_TAP_ACTION_REACT", - comment: "Double-tap action: react with the first quick-reaction emoji.", - ) - case .reply: - return OWSLocalizedString("SETTINGS_DOUBLE_TAP_ACTION_REPLY", comment: "Double-tap action: reply to the message.") + case .off: return DoubleTapActionStrings.off + case .react: return DoubleTapActionStrings.react + case .reply: return DoubleTapActionStrings.reply } } } @@ -408,12 +464,9 @@ private extension SSKPreferences.DoubleTapOthersMessagesAction { private extension SSKPreferences.DoubleTapOwnMessagesAction { var displayName: String { switch self { - case .off: - return OWSLocalizedString("SETTINGS_DOUBLE_TAP_ACTION_OFF", comment: "Double-tap action: disabled.") - case .edit: - return OWSLocalizedString("SETTINGS_DOUBLE_TAP_ACTION_EDIT", comment: "Double-tap action: edit your message.") - case .reply: - return OWSLocalizedString("SETTINGS_DOUBLE_TAP_ACTION_REPLY", comment: "Double-tap action: reply to the message.") + case .off: return DoubleTapActionStrings.off + case .edit: return DoubleTapActionStrings.edit + case .reply: return DoubleTapActionStrings.reply } } } diff --git a/Signal/translations/en.lproj/Localizable.strings b/Signal/translations/en.lproj/Localizable.strings index ea103a31bb9..7cea81fc899 100644 --- a/Signal/translations/en.lproj/Localizable.strings +++ b/Signal/translations/en.lproj/Localizable.strings @@ -9079,17 +9079,23 @@ /* Title for the 'chats' link in settings. */ "SETTINGS_CHATS" = "Chats"; -/* Section title for double-tap message action settings. */ +/* Title for double-tap message action settings. */ "SETTINGS_DOUBLE_TAP_SECTION_TITLE" = "Double-tap"; -/* Footer explaining configurable double-tap actions on messages. */ -"SETTINGS_DOUBLE_TAP_SECTION_FOOTER" = "Choose what double-tapping a message does. Long-press still opens the full action menu and reaction bar. Stickers and photos toggles apply when React is selected."; +/* Footer under the Chats settings row that opens double-tap options. */ +"SETTINGS_DOUBLE_TAP_SECTION_FOOTER" = "Choose what happens when you double-tap a message."; -/* Setting for double-tap action on messages from other people. */ -"SETTINGS_DOUBLE_TAP_OTHERS_MESSAGES" = "Other Messages"; +/* Section title for double-tap action on messages from other people. */ +"SETTINGS_DOUBLE_TAP_OTHERS_MESSAGES" = "Received Messages"; -/* Setting for double-tap action on your own messages. */ -"SETTINGS_DOUBLE_TAP_OWN_MESSAGES" = "Your Messages"; +/* Footer for double-tap action on messages from other people. */ +"SETTINGS_DOUBLE_TAP_OTHERS_FOOTER" = "Double-tap a received message to perform this action. Long press still opens the full menu."; + +/* Section title for double-tap action on your own messages. */ +"SETTINGS_DOUBLE_TAP_OWN_MESSAGES" = "Sent Messages"; + +/* Footer for double-tap action on your own messages. */ +"SETTINGS_DOUBLE_TAP_OWN_FOOTER" = "Double-tap one of your sent messages to perform this action."; /* Double-tap action: disabled. */ "SETTINGS_DOUBLE_TAP_ACTION_OFF" = "Off"; @@ -9104,10 +9110,13 @@ "SETTINGS_DOUBLE_TAP_ACTION_EDIT" = "Edit"; /* Toggle for allowing double-tap quick react on sticker messages. */ -"SETTINGS_DOUBLE_TAP_REACT_STICKERS" = "React to Stickers"; +"SETTINGS_DOUBLE_TAP_REACT_STICKERS" = "Stickers"; /* Toggle for allowing double-tap quick react on photo and video messages. */ -"SETTINGS_DOUBLE_TAP_REACT_PHOTOS" = "React to Photos"; +"SETTINGS_DOUBLE_TAP_REACT_PHOTOS" = "Photos & Videos"; + +/* Footer for toggles that allow double-tap react on stickers and photos. */ +"SETTINGS_DOUBLE_TAP_REACT_MEDIA_FOOTER" = "Allow double-tap to react on these message types. Opening the media still requires a single tap."; /* No comment provided by engineer. */ "SETTINGS_CLEAR_HISTORY" = "Clear Chat History"; diff --git a/SignalServiceKit/Messages/Reactions/ReactionManager.swift b/SignalServiceKit/Messages/Reactions/ReactionManager.swift index 0713ce77277..c70dc4f23e3 100644 --- a/SignalServiceKit/Messages/Reactions/ReactionManager.swift +++ b/SignalServiceKit/Messages/Reactions/ReactionManager.swift @@ -11,9 +11,6 @@ public class ReactionManager: NSObject { public static let localUserReacted = Notification.Name("localUserReacted") public static let defaultEmojiSet = ["❤️", "👍", "👎", "😂", "😮", "😢"] - /// Message ids waiting for reaction-pill entrance after a local add. - public static let pendingReactionEntrances = AtomicSet(lock: .init()) - private static let emojiSetKVS = KeyValueStore(collection: "EmojiSetKVS") private static let emojiSetKey = "EmojiSetKey" @@ -25,7 +22,6 @@ public class ReactionManager: NSObject { return emojiSetKVS.getStringArray(emojiSetKey, transaction: transaction) } - /// First emoji from the user's quick-reaction list (custom if set, otherwise default ❤️). public class func preferredQuickReactionEmoji(transaction: DBReadTransaction) -> String { if let customFirst = customEmojiSet(transaction: transaction)?.first?.strippedOrNil { return customFirst @@ -52,12 +48,7 @@ public class ReactionManager: NSObject { owsFailDebug("Error: \(error)") return Promise(error: error) } - // Mark before commit so render can consume even if UI reloads in the same turn. - if !isRemoving { - pendingReactionEntrances.insert(messageUniqueId) - } tx.addSyncCompletion { - // ChatList clears search on this name; entrance uses pendingReactionEntrances. NotificationCenter.default.post(name: ReactionManager.localUserReacted, object: nil) } let preparedMessage = PreparedOutgoingMessage.preprepared(transientMessageWithoutAttachments: outgoingMessage) diff --git a/SignalServiceKit/Notifications/UserNotificationsPresenter.swift b/SignalServiceKit/Notifications/UserNotificationsPresenter.swift index 067d9b94b66..032c9f5fe9e 100644 --- a/SignalServiceKit/Notifications/UserNotificationsPresenter.swift +++ b/SignalServiceKit/Notifications/UserNotificationsPresenter.swift @@ -208,7 +208,14 @@ public class UserNotificationPresenter { do { try await Self.notificationCenter.add(request) } catch { - owsFailDebug("Error presenting notification with identifier \(notificationIdentifier): \(error)") + let nsError = error as NSError + // UN errors on simulator / denied perms / empty content — not worth TrapDebugger. + // 2003 = not authorized, 1401 = no user-visible content (empty title/body after intent). + if nsError.domain == UNErrorDomain { + Logger.warn("Skipping notification \(notificationIdentifier): \(error)") + } else { + owsFailDebug("Error presenting notification with identifier \(notificationIdentifier): \(error)") + } } } diff --git a/SignalServiceKit/Util/SSKPreferences.swift b/SignalServiceKit/Util/SSKPreferences.swift index 776cab43354..c3fb6a284ef 100644 --- a/SignalServiceKit/Util/SSKPreferences.swift +++ b/SignalServiceKit/Util/SSKPreferences.swift @@ -199,7 +199,6 @@ public class SSKPreferences: NSObject { private static var doubleTapOthersMessagesActionKey: String { "doubleTapOthersMessagesAction" } private static var doubleTapOwnMessagesActionKey: String { "doubleTapOwnMessagesAction" } - /// Default: react. public static func doubleTapOthersMessagesAction(transaction: DBReadTransaction) -> DoubleTapOthersMessagesAction { stringPref(DoubleTapOthersMessagesAction.self, key: doubleTapOthersMessagesActionKey, default: .react, transaction: transaction) } @@ -211,7 +210,6 @@ public class SSKPreferences: NSObject { store.setString(action.rawValue, key: doubleTapOthersMessagesActionKey, transaction: transaction) } - /// Default: edit (existing double-tap-to-edit). public static func doubleTapOwnMessagesAction(transaction: DBReadTransaction) -> DoubleTapOwnMessagesAction { stringPref(DoubleTapOwnMessagesAction.self, key: doubleTapOwnMessagesActionKey, default: .edit, transaction: transaction) } @@ -226,7 +224,6 @@ public class SSKPreferences: NSObject { private static var doubleTapReactStickersEnabledKey: String { "doubleTapReactStickersEnabled" } private static var doubleTapReactPhotosEnabledKey: String { "doubleTapReactPhotosEnabled" } - /// Default: true. When false, double-tap React skips sticker messages. public static func isDoubleTapReactEnabledForStickers(transaction: DBReadTransaction) -> Bool { store.getBool(doubleTapReactStickersEnabledKey, defaultValue: true, transaction: transaction) } @@ -235,7 +232,6 @@ public class SSKPreferences: NSObject { store.setBool(value, key: doubleTapReactStickersEnabledKey, transaction: transaction) } - /// Default: true. When false, double-tap React skips photo/video (body media) messages. public static func isDoubleTapReactEnabledForPhotos(transaction: DBReadTransaction) -> Bool { store.getBool(doubleTapReactPhotosEnabledKey, defaultValue: true, transaction: transaction) }