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 75b3d80ad49..c79c693d86c 100644 --- a/Signal/ConversationView/CVItemViewModelImpl.swift +++ b/Signal/ConversationView/CVItemViewModelImpl.swift @@ -185,6 +185,59 @@ extension CVItemViewModelImpl { return DependenciesBridge.shared.editManager.canShowEditMenu(interaction: interaction, thread: thread) } + enum DoubleTapAction { + case edit + case react + case reply + } + + 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..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,6 +126,7 @@ public class CVViewState: NSObject { public var collectionViewActiveContextMenuInteraction: ChatHistoryContextMenuInteraction? public var longPressHandler: CVLongPressHandler? public var panHandler: CVPanHandler? + public var deferredSingleTap: (() -> Bool)? // MARK: - diff --git a/Signal/ConversationView/CellViews/CVReactionCountsView.swift b/Signal/ConversationView/CellViews/CVReactionCountsView.swift index bec537f189f..86fccb1a31e 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 @@ -39,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") } @@ -93,7 +110,12 @@ class CVReactionCountsView: ManualStackView { private static let measurementKey = "CVReactionCountsView" - func configure(state: State, cellMeasurement: CVCellMeasurement) { + 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 @@ -115,6 +137,116 @@ class CVReactionCountsView: ManualStackView { measurementKey: Self.measurementKey, subviews: subviews, ) + + 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( @@ -147,6 +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() @@ -160,8 +302,12 @@ class CVReactionCountsView: ManualStackView { private let pillKey: String + // 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() + private(set) var fromLocalUser = false + private var isPlayingPop = false private static let pillBorderWidth: CGFloat = 1 @@ -170,7 +316,16 @@ 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 self, weak emojiLabel] view in + guard let self, let emojiLabel, emojiLabel.superview === self.emojiWrap else { return } + emojiLabel.frame = view.bounds + } clipsToBounds = true } @@ -228,6 +383,16 @@ class CVReactionCountsView: ManualStackView { pillState: PillState, cellMeasurement: CVCellMeasurement, ) { + fromLocalUser = pillState.fromLocalUser + + if !isPlayingPop { + layer.removeAllAnimations() + transform = .identity + alpha = 1 + emojiLabel.transform = .identity + emojiLabel.alpha = 1 + clipsToBounds = true + } addLayoutBlock { view in view.layer.borderWidth = Self.pillBorderWidth @@ -246,7 +411,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 +427,67 @@ class CVReactionCountsView: ManualStackView { ) } + func prepareForLocalEntranceFlight() { + alpha = 1 + transform = .identity + emojiLabel.alpha = 0 + emojiLabel.transform = CGAffineTransform(translationX: 0, y: -36).scaledBy(x: 0.45, y: 0.45) + } + + func playLocalEntrancePop() { + guard beginPop() else { + alpha = 1 + emojiLabel.alpha = 1 + emojiLabel.transform = .identity + return + } + + // Pill already on-screen; only emoji flies in. + alpha = 1 + transform = .identity + emojiLabel.alpha = 0 + emojiLabel.transform = CGAffineTransform(translationX: 0, y: -36).scaledBy(x: 0.45, y: 0.45) + + 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: -36).scaledBy(x: 1.32, y: 1.32) + } + UIView.addKeyframe(withRelativeStartTime: 0.35, relativeDuration: 0.65) { + self.emojiLabel.transform = .identity + } + } completion: { [weak self] _ in + self?.finishPop() + } + } + + 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 + } + + 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 + super.reset() + } + 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..f0c8729b3e2 100644 --- a/Signal/ConversationView/Components/CVComponentMessage.swift +++ b/Signal/ConversationView/Components/CVComponentMessage.swift @@ -2013,22 +2013,39 @@ 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 + } + if action == .react, componentDelegate.hasPendingMessageRequest { + return nil + } + return (viewModel, action) } override public func findLongPressHandler( @@ -2531,8 +2548,7 @@ 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 + 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+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 77672c82825..9143373a946 100644 --- a/Signal/ConversationView/ConversationViewController+CVComponentDelegate.swift +++ b/Signal/ConversationView/ConversationViewController+CVComponentDelegate.swift @@ -51,6 +51,57 @@ 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 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) + } + + let existing = viewModel.reactionState?.localUserEmojiVariant + if let existing, existing != quickEmoji { + return + } + + ImpactHapticFeedback.impactOccurred(style: .light) + + 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 + } + let isRemoving = latest == quickEmoji + ReactionLayoutPending.markLocal(messageId: messageId, isRemoving: isRemoving) + ReactionManager.localUserReacted( + to: messageId, + emoji: quickEmoji, + isRemoving: isRemoving, + tx: tx, + ) + } + } + // MARK: - Long Press public func didLongPressTextViewItem( diff --git a/Signal/ConversationView/ConversationViewController+GestureRecognizers.swift b/Signal/ConversationView/ConversationViewController+GestureRecognizers.swift index e7ca39d2905..1105440ec6c 100644 --- a/Signal/ConversationView/ConversationViewController+GestureRecognizers.swift +++ b/Signal/ConversationView/ConversationViewController+GestureRecognizers.swift @@ -144,17 +144,31 @@ 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 } + // 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 } + 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 +180,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/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 ea6e8518477..55292daa005 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,26 @@ class ChatsSettingsViewController: OWSTableViewController2 { ) contents.add(keepMutedChatsArchived) + let doubleTapSection = OWSTableSection() + doubleTapSection.footerTitle = OWSLocalizedString( + "SETTINGS_DOUBLE_TAP_SECTION_FOOTER", + comment: "Footer under the Chats settings row that opens double-tap options.", + ) + doubleTapSection.add(.disclosureItem( + withText: OWSLocalizedString( + "SETTINGS_DOUBLE_TAP_SECTION_TITLE", + comment: "Title for double-tap message action settings.", + ), + accessoryText: DoubleTapSettingsViewController.currentSummaryAccessoryText(), + actionBlock: { [weak self] in + self?.navigationController?.pushViewController( + DoubleTapSettingsViewController(), + animated: true, + ) + }, + )) + contents.add(doubleTapSection) + let clearHistorySection = OWSTableSection() clearHistorySection.add(.item( name: OWSLocalizedString("SETTINGS_CLEAR_HISTORY", comment: ""), @@ -255,3 +280,193 @@ class ChatsSettingsViewController: OWSTableViewController2 { AttachmentStream.deleteAllAttachmentFiles() } } + +// MARK: - + +/// 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)" + } + + 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 + 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) + } + }, + )) + 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) + } + 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 DoubleTapActionStrings.off + case .react: return DoubleTapActionStrings.react + case .reply: return DoubleTapActionStrings.reply + } + } +} + +private extension SSKPreferences.DoubleTapOwnMessagesAction { + var displayName: String { + switch self { + case .off: return DoubleTapActionStrings.off + case .edit: return DoubleTapActionStrings.edit + case .reply: return DoubleTapActionStrings.reply + } + } +} 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..7cea81fc899 100644 --- a/Signal/translations/en.lproj/Localizable.strings +++ b/Signal/translations/en.lproj/Localizable.strings @@ -9079,6 +9079,45 @@ /* Title for the 'chats' link in settings. */ "SETTINGS_CHATS" = "Chats"; +/* Title for double-tap message action settings. */ +"SETTINGS_DOUBLE_TAP_SECTION_TITLE" = "Double-tap"; + +/* 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."; + +/* Section title for double-tap action on messages from other people. */ +"SETTINGS_DOUBLE_TAP_OTHERS_MESSAGES" = "Received 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"; + +/* 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" = "Stickers"; + +/* Toggle for allowing double-tap quick react on photo and video messages. */ +"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 cc2893a1b21..c70dc4f23e3 100644 --- a/SignalServiceKit/Messages/Reactions/ReactionManager.swift +++ b/SignalServiceKit/Messages/Reactions/ReactionManager.swift @@ -22,6 +22,13 @@ public class ReactionManager: NSObject { return emojiSetKVS.getStringArray(emojiSetKey, transaction: transaction) } + 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 +48,9 @@ public class ReactionManager: NSObject { owsFailDebug("Error: \(error)") return Promise(error: error) } - NotificationCenter.default.post(name: ReactionManager.localUserReacted, object: nil) + tx.addSyncCompletion { + NotificationCenter.default.post(name: ReactionManager.localUserReacted, object: nil) + } let preparedMessage = PreparedOutgoingMessage.preprepared(transientMessageWithoutAttachments: outgoingMessage) return SSKEnvironment.shared.messageSenderJobQueueRef.add( .promise, 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 36acbcabbd2..c3fb6a284ef 100644 --- a/SignalServiceKit/Util/SSKPreferences.swift +++ b/SignalServiceKit/Util/SSKPreferences.swift @@ -181,4 +181,74 @@ 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" } + + 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) + } + + 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" } + + 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) + } + + 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 + } }