diff --git a/Signal.xcodeproj/project.pbxproj b/Signal.xcodeproj/project.pbxproj index c94b8372377..c5fdc3f242e 100644 --- a/Signal.xcodeproj/project.pbxproj +++ b/Signal.xcodeproj/project.pbxproj @@ -1537,6 +1537,10 @@ 76F4B581293ACCD200A7CF2F /* UIKit+Animations.swift in Sources */ = {isa = PBXBuildFile; fileRef = 76F4B580293ACCD200A7CF2F /* UIKit+Animations.swift */; }; 76F958632A09A5AE00B43E63 /* DebugUIDiskUsage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 76F958622A09A5AE00B43E63 /* DebugUIDiskUsage.swift */; }; 76FCCDBC27AB8FBE00BAA7F0 /* MediaControls.swift in Sources */ = {isa = PBXBuildFile; fileRef = 76FCCDBB27AB8FBE00BAA7F0 /* MediaControls.swift */; }; + 79246E552F6C9CC20085AD50 /* ReactionPickerSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 79246E542F6C9CC20085AD50 /* ReactionPickerSheet.swift */; }; + 79246E592F71E0470085AD50 /* StickerReactionImageCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 79246E582F71E0470085AD50 /* StickerReactionImageCache.swift */; }; + 797B94E52F677561001C9E24 /* CustomReactionItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 797B94E42F67755A001C9E24 /* CustomReactionItem.swift */; }; + 79F764502F656A1F001399AF /* StoryReaction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 79F7644F2F656A1C001399AF /* StoryReaction.swift */; }; 83B9573927C9A1FA00A678FD /* CaptchaView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 83B9573827C9A1FA00A678FD /* CaptchaView.swift */; }; 8803FF6628EF89B50023574A /* StorySharingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 88F5FA9528EF7E02007AA1BF /* StorySharingTests.swift */; }; 8806EF19248DBD7200E764C7 /* NotificationPermissionReminderMegaphone.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8806EF18248DBD7200E764C7 /* NotificationPermissionReminderMegaphone.swift */; }; @@ -3491,7 +3495,7 @@ EC7A9D369AF9724FEEE5B653 /* Pods_SignalUITests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = B3F39202F831935AAE1C5F54 /* Pods_SignalUITests.framework */; }; F02564D8274EDF4600D7B48A /* BadgeIssueSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = F02564D7274EDF4600D7B48A /* BadgeIssueSheet.swift */; }; F05F51C926A90D6B00861034 /* ContextMenuActionsAccessory.swift in Sources */ = {isa = PBXBuildFile; fileRef = F05F51C826A90D6B00861034 /* ContextMenuActionsAccessory.swift */; }; - F090C8202762F2C5005C20FC /* EmojiReactionPickerConfigViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = F090C81F2762F2C5005C20FC /* EmojiReactionPickerConfigViewController.swift */; }; + F090C8202762F2C5005C20FC /* CustomReactionPickerConfigViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = F090C81F2762F2C5005C20FC /* CustomReactionPickerConfigViewController.swift */; }; F0B872B6269CF6D900D26481 /* ContextMenuInteraction.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0B872B5269CF6D900D26481 /* ContextMenuInteraction.swift */; }; F0B872B8269D079B00D26481 /* ContextMenuConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0B872B7269D079B00D26481 /* ContextMenuConfiguration.swift */; }; F0EE4DB626A7AC18001DE4ED /* ContextMenuReactionBarAccessory.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0EE4DB526A7AC18001DE4ED /* ContextMenuReactionBarAccessory.swift */; }; @@ -5727,6 +5731,10 @@ 76F4B580293ACCD200A7CF2F /* UIKit+Animations.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIKit+Animations.swift"; sourceTree = ""; }; 76F958622A09A5AE00B43E63 /* DebugUIDiskUsage.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DebugUIDiskUsage.swift; sourceTree = ""; }; 76FCCDBB27AB8FBE00BAA7F0 /* MediaControls.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaControls.swift; sourceTree = ""; }; + 79246E542F6C9CC20085AD50 /* ReactionPickerSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReactionPickerSheet.swift; sourceTree = ""; }; + 79246E582F71E0470085AD50 /* StickerReactionImageCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StickerReactionImageCache.swift; sourceTree = ""; }; + 797B94E42F67755A001C9E24 /* CustomReactionItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomReactionItem.swift; sourceTree = ""; }; + 79F7644F2F656A1C001399AF /* StoryReaction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoryReaction.swift; sourceTree = ""; }; 7F965533D71CA51BE6704CC4 /* Pods_SignalNSE.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_SignalNSE.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 7FF88FB580BC19B240EEB86A /* Pods_Signal.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Signal.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 83B9573827C9A1FA00A678FD /* CaptchaView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CaptchaView.swift; sourceTree = ""; }; @@ -7785,7 +7793,7 @@ F00385FE273F6388000B5ABD /* Stripe.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Stripe.swift; sourceTree = ""; }; F02564D7274EDF4600D7B48A /* BadgeIssueSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BadgeIssueSheet.swift; sourceTree = ""; }; F05F51C826A90D6B00861034 /* ContextMenuActionsAccessory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContextMenuActionsAccessory.swift; sourceTree = ""; }; - F090C81F2762F2C5005C20FC /* EmojiReactionPickerConfigViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiReactionPickerConfigViewController.swift; sourceTree = ""; }; + F090C81F2762F2C5005C20FC /* CustomReactionPickerConfigViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomReactionPickerConfigViewController.swift; sourceTree = ""; }; F0B872B5269CF6D900D26481 /* ContextMenuInteraction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContextMenuInteraction.swift; sourceTree = ""; }; F0B872B7269D079B00D26481 /* ContextMenuConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContextMenuConfiguration.swift; sourceTree = ""; }; F0C124B626D4788A0031C96F /* NSE-Images.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "NSE-Images.xcassets"; sourceTree = ""; }; @@ -8732,7 +8740,7 @@ 880D902B2480889B003D2B14 /* EmojiPickerCollectionView.swift */, 880D902D2480A23E003D2B14 /* EmojiPickerSectionToolbar.swift */, 880D90292480887F003D2B14 /* EmojiPickerSheet.swift */, - F090C81F2762F2C5005C20FC /* EmojiReactionPickerConfigViewController.swift */, + F090C81F2762F2C5005C20FC /* CustomReactionPickerConfigViewController.swift */, 88238EBB24F21EE400F28079 /* EmojiSkinTonePicker.swift */, 3428577326BD8777005A2A96 /* EmojiWithSkinTones.swift */, ); @@ -9342,6 +9350,7 @@ 34B3F8331E8DF1700035BE1A /* ViewControllers */ = { isa = PBXGroup; children = ( + 79246E542F6C9CC20085AD50 /* ReactionPickerSheet.swift */, 340FC87A204DAC8C007AEB0F /* AppSettings */, 8809CE8822F93C0D00D38867 /* Attachment Keyboard */, 883A7FC1269F4BE700841DF9 /* Avatars */, @@ -9956,6 +9965,7 @@ 50E7E1D12BACC3DB00A94861 /* Reactions */ = { isa = PBXGroup; children = ( + 79246E582F71E0470085AD50 /* StickerReactionImageCache.swift */, 8855DF88238F2E690066D96F /* EmojiCountsCollectionView.swift */, 8855DF86238F1E0C0066D96F /* EmojiReactorsTableView.swift */, 88BCCC8023837B7D00CE5FE6 /* InteractionReactionState.swift */, @@ -14935,6 +14945,8 @@ F9C5C948289453B100548EEE /* Reactions */ = { isa = PBXGroup; children = ( + 797B94E42F67755A001C9E24 /* CustomReactionItem.swift */, + 79F7644F2F656A1C001399AF /* StoryReaction.swift */, 50A5AA9C2A7475A900CF2ECC /* OutgoingReactionMessage.swift */, F9C5C94A289453B100548EEE /* OWSReaction.swift */, F9C5C94C289453B100548EEE /* ReactionFinder.swift */, @@ -18030,6 +18042,7 @@ 882BDAAE249050F000C14587 /* AddToGroupViewController.swift in Sources */, D9E43C112CC194140001536E /* AdHocCallStateObserver.swift in Sources */, 88C7597324B7EAA600DB03EA /* AdvancedPinSettingsTableViewController.swift in Sources */, + 79246E592F71E0470085AD50 /* StickerReactionImageCache.swift in Sources */, 887B381325F0681400685845 /* AdvancedPrivacySettingsViewController.swift in Sources */, 45D49115296F69AA00B92BB1 /* AllMediaViewController.swift in Sources */, 32E958AA25C12B3800BF12AD /* AnimatedProgressView.swift in Sources */, @@ -18330,6 +18343,7 @@ F9B652C128D8CB75006914CA /* DatabaseRecoveryViewController.swift in Sources */, 346C19DF25ACDF0B00061D3A /* DataSettingsTableViewController.swift in Sources */, 88535064240829950011D318 /* DateHeaderInteraction.swift in Sources */, + 79246E552F6C9CC20085AD50 /* ReactionPickerSheet.swift in Sources */, 50A1CE3A2A00931900730C40 /* DebugLogger+MainApp.swift in Sources */, 04A573762E75B00B0019651F /* DebugLogPreviewViewController.swift in Sources */, 34067EAB2710D61A000407C3 /* DebugLogs.swift in Sources */, @@ -18406,7 +18420,7 @@ 880D902C2480889B003D2B14 /* EmojiPickerCollectionView.swift in Sources */, 880D902E2480A23E003D2B14 /* EmojiPickerSectionToolbar.swift in Sources */, 880D902A2480887F003D2B14 /* EmojiPickerSheet.swift in Sources */, - F090C8202762F2C5005C20FC /* EmojiReactionPickerConfigViewController.swift in Sources */, + F090C8202762F2C5005C20FC /* CustomReactionPickerConfigViewController.swift in Sources */, 8855DF87238F1E0C0066D96F /* EmojiReactorsTableView.swift in Sources */, 88238EBC24F21EE400F28079 /* EmojiSkinTonePicker.swift in Sources */, D94B71782EF5FD7A000C4C98 /* EmojiWithSkinTones+String.swift in Sources */, @@ -19043,6 +19057,7 @@ 667BB2082C580C1400E79B57 /* BackupAttachmentDownloadStore.swift in Sources */, D9C4C0852E0A75F200B74696 /* BackupAttachmentUploadEraStore.swift in Sources */, 668345502DCA9BEE00566AB3 /* BackupAttachmentUploadProgress.swift in Sources */, + 79F764502F656A1F001399AF /* StoryReaction.swift in Sources */, 663AAF222E01EDDD00B9C4B8 /* BackupAttachmentUploadQueueRunner.swift in Sources */, D98ACAA72E0CA11F00FA497F /* BackupAttachmentUploadQueueStatusManager.swift in Sources */, 66734F012CA1ED3F00558494 /* BackupAttachmentUploadScheduler.swift in Sources */, @@ -19246,6 +19261,7 @@ 503B47232AF0569B00978266 /* ECKeyPair.swift in Sources */, 66F2CE1F2A3A37CB00519342 /* EditableMessageBody.swift in Sources */, C1DB22C329C9F95500757380 /* EditManager.swift in Sources */, + 797B94E52F677561001C9E24 /* CustomReactionItem.swift in Sources */, 66076B5F2BC06CA70043D547 /* EditManagerAttachments.swift in Sources */, 66076B5E2BC06CA70043D547 /* EditManagerAttachmentsImpl.swift in Sources */, 668B30092BBDD9A20001FD25 /* EditManagerImpl.swift in Sources */, diff --git a/Signal/Calls/UserInterface/CallControlsOverflowView.swift b/Signal/Calls/UserInterface/CallControlsOverflowView.swift index 6a996a4adb5..84ba7c4ee39 100644 --- a/Signal/Calls/UserInterface/CallControlsOverflowView.swift +++ b/Signal/Calls/UserInterface/CallControlsOverflowView.swift @@ -10,9 +10,11 @@ import UIKit class CallControlsOverflowView: UIView { private lazy var reactionPicker: MessageReactionPicker = { let picker = MessageReactionPicker( - selectedEmoji: nil, + selectedReaction: nil, delegate: self, style: .contextMenu(allowGlass: false), + // Calls only support emoji reactions, not stickers. + allowStickers: false, ) picker.overrideUserInterfaceStyle = .dark picker.translatesAutoresizingMaskIntoConstraints = false @@ -236,11 +238,16 @@ class CallControlsOverflowView: UIView { // MARK: - MessageReactionPickerDelegate extension CallControlsOverflowView: MessageReactionPickerDelegate { - func didSelectReaction(reaction: String, isRemoving: Bool, inPosition position: Int) { - self.react(with: reaction) + func didSelectReaction( + _ reaction: CustomReactionItem, + isRemoving: Bool, + inPosition position: Int + ) { + // Calls only support emoji reactions, not stickers. + self.react(with: reaction.emoji) } - func didSelectAnyEmoji() { + func didSelectMore() { let sheet = EmojiPickerSheet( message: nil, reactionPickerConfigurationListener: self, @@ -283,7 +290,7 @@ extension CallControlsOverflowView: MessageReactionPickerDelegate { extension CallControlsOverflowView: ReactionPickerConfigurationListener { func didCompleteReactionPickerConfiguration() { - self.reactionPicker.updateReactionPickerEmojis() + self.reactionPicker.updateReactionPickerItems() } } diff --git a/Signal/ConversationView/CellViews/CVQuotedMessageView.swift b/Signal/ConversationView/CellViews/CVQuotedMessageView.swift index 38c6ffaf2af..b037aad100f 100644 --- a/Signal/ConversationView/CellViews/CVQuotedMessageView.swift +++ b/Signal/ConversationView/CellViews/CVQuotedMessageView.swift @@ -3,7 +3,8 @@ // SPDX-License-Identifier: AGPL-3.0-only // -import SignalServiceKit +import SDWebImage +public import SignalServiceKit public import SignalUI public protocol CVQuotedMessageViewDelegate: AnyObject { @@ -17,6 +18,7 @@ public class CVQuotedMessageView: ManualStackViewWithLayer { public struct State: Equatable { let quotedReplyModel: QuotedReplyModel + let storyReactionSticker: CVComponentState.Sticker? let displayableQuotedText: DisplayableText? let conversationStyle: ConversationStyle let isOutgoing: Bool @@ -49,6 +51,7 @@ public class CVQuotedMessageView: ManualStackViewWithLayer { private let quoteContentSourceLabel = CVLabel() private let quoteReactionHeaderLabel = CVLabel() private let quoteReactionLabel = CVLabel() + private var quoteReactionStickerView: ReusableMediaView? private let quotedImageView = CVImageView() private let remotelySourcedContentIconView = CVImageView() @@ -58,6 +61,7 @@ public class CVQuotedMessageView: ManualStackViewWithLayer { static func stateForConversation( quotedReplyModel: QuotedReplyModel, + storyReactionSticker: CVComponentState.Sticker?, displayableQuotedText: DisplayableText?, conversationStyle: ConversationStyle, isOutgoing: Bool, @@ -65,6 +69,7 @@ public class CVQuotedMessageView: ManualStackViewWithLayer { ) -> State { return State( quotedReplyModel: quotedReplyModel, + storyReactionSticker: storyReactionSticker, displayableQuotedText: displayableQuotedText, conversationStyle: conversationStyle, isOutgoing: isOutgoing, @@ -145,7 +150,12 @@ public class CVQuotedMessageView: ManualStackViewWithLayer { } var quotedReactionRect: CGRect { - CGRect(x: 0, y: quotedAttachmentSize.height - 32, width: hasQuotedThumbnail ? 32 : 40, height: 32) + CGRect( + x: 0, + y: quotedAttachmentSize.height - 46, + width: hasQuotedThumbnail ? 46 : 52, + height: 46, + ) } let remotelySourcedContentIconSize: CGFloat = 16 @@ -200,7 +210,7 @@ public class CVQuotedMessageView: ManualStackViewWithLayer { } var hasReaction: Bool { - quotedReplyModel.storyReactionEmoji != nil + quotedReplyModel.storyReaction != nil } var mimeType: String? { @@ -429,7 +439,7 @@ public class CVQuotedMessageView: ManualStackViewWithLayer { var quoteReactionLabelConfig: CVLabelConfig { let font = UIFont.systemFont(ofSize: 28) return CVLabelConfig( - text: .attributedText((quotedReplyModel.storyReactionEmoji ?? "").styled(with: .lineHeightMultiple(0.6))), + text: .attributedText((quotedReplyModel.storyReaction?.emoji ?? "").styled(with: .lineHeightMultiple(0.6))), displayConfig: .forUnstyledText(font: font, textColor: quotedTextColor), font: font, textColor: quotedTextColor, @@ -526,6 +536,7 @@ public class CVQuotedMessageView: ManualStackViewWithLayer { componentDelegate: CVComponentDelegate, sharpCorners: OWSDirectionalRectCorner, cellMeasurement: CVCellMeasurement, + mediaCache: CVMediaCache, ) { self.state = state self.delegate = delegate @@ -702,11 +713,11 @@ public class CVQuotedMessageView: ManualStackViewWithLayer { thumbnailView.frame = CGRect(origin: CGPoint(x: 16, y: 0), size: configurator.quotedAttachmentSize) } - let reactionLabelConfig = configurator.quoteReactionLabelConfig - reactionLabelConfig.applyForRendering(label: quoteReactionLabel) - - quoteReactionLabel.frame = configurator.quotedReactionRect - wrapper.addSubview(quoteReactionLabel) + addReactionView( + to: wrapper, + configurator: configurator, + mediaCache: mediaCache + ) trailingView = wrapper } else { @@ -715,11 +726,11 @@ public class CVQuotedMessageView: ManualStackViewWithLayer { } else if configurator.hasReaction { let wrapper = ManualLayoutView(name: "reactionWrapper") - let reactionLabelConfig = configurator.quoteReactionLabelConfig - reactionLabelConfig.applyForRendering(label: quoteReactionLabel) - - quoteReactionLabel.frame = configurator.quotedReactionRect - wrapper.addSubview(quoteReactionLabel) + addReactionView( + to: wrapper, + configurator: configurator, + mediaCache: mediaCache + ) trailingView = wrapper } else { @@ -796,6 +807,22 @@ public class CVQuotedMessageView: ManualStackViewWithLayer { public func setIsCellVisible(_ isCellVisible: Bool) { quotedTextSpoilerConfigBuilder.isViewVisible = isCellVisible + + if isCellVisible { + if + let quoteReactionStickerView, + quoteReactionStickerView.owner == self + { + quoteReactionStickerView.load() + } + } else { + if + let quoteReactionStickerView, + quoteReactionStickerView.owner == self + { + quoteReactionStickerView.unload() + } + } } // MARK: - Measurement @@ -969,6 +996,118 @@ public class CVQuotedMessageView: ManualStackViewWithLayer { return animator }() + // MARK: - Story Reaction + + /// Adds either a sticker image view or emoji label for the reaction, at `quotedReactionRect`. + private func addReactionView( + to wrapper: ManualLayoutView, + configurator: Configurator, + mediaCache: CVMediaCache + ) { + switch configurator.state.storyReactionSticker { + case .available(_, let attachment): + let attachmentStream = attachment.attachmentStream + let cacheKey = CVMediaCache.CacheKey.attachment(attachmentStream.id) + let reusableMediaView: ReusableMediaView + if let cachedView = mediaCache.getMediaView( + cacheKey, + isAnimated: attachmentStream.contentType.isAnimatedImage + ) { + reusableMediaView = cachedView + } else { + let mediaViewAdapter = MediaViewAdapterSticker( + attachmentStream: attachmentStream + ) + reusableMediaView = ReusableMediaView( + mediaViewAdapter: mediaViewAdapter, + mediaCache: mediaCache + ) + mediaCache.setMediaView( + reusableMediaView, + forKey: cacheKey, + isAnimated: attachmentStream.contentType.isAnimatedImage + ) + } + + reusableMediaView.owner = self + self.quoteReactionStickerView = reusableMediaView + let mediaView = reusableMediaView.mediaView + + mediaView.frame = configurator.quotedReactionRect + wrapper.addSubview(mediaView) + case .downloading(let attachmentPointer): + addUndownloadedStickerView( + to: wrapper, + attachmentPointer: attachmentPointer, + downloadState: .enqueuedOrDownloading, + configurator: configurator, + mediaCache: mediaCache + ) + case .failedOrPending(let attachmentPointer, let downloadState): + addUndownloadedStickerView( + to: wrapper, + attachmentPointer: attachmentPointer, + downloadState: downloadState, + configurator: configurator, + mediaCache: mediaCache + ) + case nil: + let reactionLabelConfig = configurator.quoteReactionLabelConfig + reactionLabelConfig.applyForRendering(label: quoteReactionLabel) + quoteReactionLabel.frame = configurator.quotedReactionRect + wrapper.addSubview(quoteReactionLabel) + } + } + + private func addUndownloadedStickerView( + to wrapper: ManualLayoutView, + attachmentPointer: ReferencedAttachmentPointer, + downloadState: AttachmentDownloadState, + configurator: Configurator, + mediaCache: CVMediaCache, + ) { + let placeholderView = UIView() + placeholderView.backgroundColor = Theme.secondaryBackgroundColor + placeholderView.layer.cornerRadius = 18 + + let progressView = CVAttachmentProgressView( + direction: .download( + attachmentPointer: attachmentPointer.attachmentPointer, + downloadState: downloadState, + ), + colorConfiguration: .init( + conversationStyle: configurator.conversationStyle, + isIncoming: configurator.isIncoming + ), + mediaCache: mediaCache, + ) + progressView.frame = configurator.quotedReactionRect + wrapper.addSubview(progressView) + } + + /// If the point (in receiver's coordinate space) hits the sticker reaction view, returns + /// the `StickerPackInfo` needed to open the sticker pack sheet. Returns `nil` otherwise. + public func stickerPackInfoForReactionTap(at point: CGPoint) -> StickerPackInfo? { + guard let quoteReactionStickerView else { return nil } + guard quoteReactionStickerView.mediaView.superview != nil else { return nil } + let localPoint = convert(point, to: quoteReactionStickerView.mediaView) + guard quoteReactionStickerView.mediaView.bounds.contains(localPoint) else { return nil } + return state?.quotedReplyModel.storyReaction?.stickerInfo?.packInfo + } + + /// If the tap hits an undownloaded sticker reaction, triggers a download + /// via the delegate and returns `true`. Returns `false` otherwise. + public func handleUndownloadedStickerReactionTapIfNeeded(at point: CGPoint) -> Bool { + guard let state else { return false } + switch state.storyReactionSticker { + case .failedOrPending(_, let downloadState) where downloadState == .none || downloadState == .failed: + delegate?.didTapDownloadQuotedReplyAttachment(state.quotedReplyModel) + return true + default: + return false + } + } + // MARK: - @objc @@ -1008,5 +1147,12 @@ public class CVQuotedMessageView: ManualStackViewWithLayer { tintView.reset() tintView.removeFromSuperview() + + if + let quoteReactionStickerView, + quoteReactionStickerView.owner == self + { + quoteReactionStickerView.unload() + } } } diff --git a/Signal/ConversationView/CellViews/CVReactionCountsView.swift b/Signal/ConversationView/CellViews/CVReactionCountsView.swift index 1d54c342c02..3cd49f54dd5 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 @@ -10,12 +11,15 @@ class CVReactionCountsView: ManualStackView { enum PillState: Equatable { case emoji(emoji: String, count: Int, fromLocalUser: Bool) + case sticker(CVAttachment, emoji: String, count: Int, fromLocalUser: Bool) case moreCount(count: Int, fromLocalUser: Bool) var fromLocalUser: Bool { switch self { case .emoji(_, _, let fromLocalUser): return fromLocalUser + case .sticker(_, _, _, let fromLocalUser): + return fromLocalUser case .moreCount(_, let fromLocalUser): return fromLocalUser } @@ -28,7 +32,7 @@ class CVReactionCountsView: ManualStackView { let pill3: PillState? } - static let height: CGFloat = 24 + static let height: CGFloat = 26 static let inset: CGFloat = 7 private static let pillKey1 = "pill1" @@ -49,10 +53,18 @@ class CVReactionCountsView: ManualStackView { static func buildState(with reactionState: InteractionReactionState) -> State { func buildPillState(emojiCount: InteractionReactionState.EmojiCount) -> PillState { - .emoji( + if let sticker = emojiCount.stickerAttachment { + return .sticker( + sticker, + emoji: emojiCount.emoji, + count: emojiCount.count, + fromLocalUser: emojiCount.groupKey == reactionState.localUserReactionGroupKey + ) + } + return .emoji( emoji: emojiCount.emoji, count: emojiCount.count, - fromLocalUser: emojiCount.emoji == reactionState.localUserEmoji, + fromLocalUser: emojiCount.groupKey == reactionState.localUserReactionGroupKey ) } @@ -87,15 +99,19 @@ class CVReactionCountsView: ManualStackView { // will represent the count of remaining unique reactors *not* // the count of remaining unique emoji. if reactionState.emojiCounts.count > 3 { - let renderedEmoji = reactionState.emojiCounts[0...1].map { $0.emoji } + let renderedGroupKeys = reactionState.emojiCounts[0...1].map { $0.groupKey } let remainingReactorCount = reactionState.emojiCounts .lazy - .filter { !renderedEmoji.contains($0.emoji) } + .filter { !renderedGroupKeys.contains($0.groupKey) } .map { $0.count } .reduce(0, +) let remainingReactionsIncludesLocalUserReaction: Bool = { - guard let localEmoji = reactionState.localUserEmoji else { return false } - return !renderedEmoji.contains(localEmoji) + guard + let localUserReactionGroupKey = reactionState.localUserReactionGroupKey + else { + return false + } + return !renderedGroupKeys.contains(localUserReactionGroupKey) }() pill3 = .moreCount( count: remainingReactorCount, @@ -110,21 +126,32 @@ class CVReactionCountsView: ManualStackView { private static let measurementKey = "CVReactionCountsView" - func configure(state: State, cellMeasurement: CVCellMeasurement) { + func configure( + state: State, + cellMeasurement: CVCellMeasurement, + componentView: CVComponentReactions.CVComponentViewReactions, + mediaCache: CVMediaCache + ) { layer.borderColor = Theme.backgroundColor.cgColor var subviews = [UIView]() - func configure(pillView: PillView, pillState: PillState?) { + func configure(pillView: PillView, pillState: PillState?, index: Int) { guard let pillState else { return } - pillView.configure(pillState: pillState, cellMeasurement: cellMeasurement) + pillView.configure( + pillState: pillState, + index: index, + cellMeasurement: cellMeasurement, + componentView: componentView, + mediaCache: mediaCache, + ) subviews.append(pillView) } - configure(pillView: pill1, pillState: state.pill1) - configure(pillView: pill2, pillState: state.pill2) - configure(pillView: pill3, pillState: state.pill3) + configure(pillView: pill1, pillState: state.pill1, index: 0) + configure(pillView: pill2, pillState: state.pill2, index: 1) + configure(pillView: pill3, pillState: state.pill3, index: 2) self.configure( config: Self.stackConfig, @@ -179,8 +206,16 @@ class CVReactionCountsView: ManualStackView { private let emojiLabel = CVLabel() private let countLabel = CVLabel() + private let stickerSpinner = UIActivityIndicatorView(style: .medium) private static let pillBorderWidth: CGFloat = 1 + private static var emojiFont: UIFont { + .boldSystemFont(ofSize: 14) + } + private static var stickerSize: CGFloat { + // Slightly bigger than the emoji + emojiFont.lineHeight * 1.75 + } init(pillKey: String) { self.pillKey = pillKey @@ -189,6 +224,13 @@ class CVReactionCountsView: ManualStackView { emojiLabel.clipsToBounds = true clipsToBounds = true + + let spinnerSize = Self.stickerSize * 0.75 + stickerSpinner.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + stickerSpinner.widthAnchor.constraint(equalToConstant: spinnerSize), + stickerSpinner.heightAnchor.constraint(equalToConstant: spinnerSize), + ]) } static var stackConfig: CVStackViewConfig { @@ -198,13 +240,30 @@ class CVReactionCountsView: ManualStackView { static func emojiLabelConfig(pillState: PillState) -> CVLabelConfig? { switch pillState { + case let .sticker(sticker, emoji, _, _): + switch sticker { + case .stream, .backupThumbnail: + return nil + case .pointer(_, let downloadState): + switch downloadState { + case .none, .enqueuedOrDownloading: + return nil + case .failed: + // Fall back to emoji display. + break + } + case .undownloadable: + // Fall back to emoji display. + break + } + fallthrough case .emoji(let emoji, _, _): assert(emoji.isSingleEmoji) // textColor doesn't matter for emoji. return CVLabelConfig.unstyledText( emoji, - font: .boldSystemFont(ofSize: 14), + font: Self.emojiFont, textColor: .black, textAlignment: .center, ) @@ -224,7 +283,7 @@ class CVReactionCountsView: ManualStackView { let text: String switch pillState { - case .emoji(_, let count, _): + case .emoji(_, let count, _), .sticker(_, _, let count, _): guard count > 1 else { return nil } @@ -241,10 +300,37 @@ class CVReactionCountsView: ManualStackView { ) } + private func prepareReusableMediaView( + attachment: Attachment, + isAnimated: Bool, + index: Int, + componentView: CVComponentReactions.CVComponentViewReactions, + mediaCache: CVMediaCache, + mediaViewAdapter: () -> MediaViewAdapter, + ) -> UIView { + let cacheKey = CVMediaCache.CacheKey.attachment(attachment.id) + let reusableMediaView: ReusableMediaView + if let cached = mediaCache.getMediaView(cacheKey, isAnimated: isAnimated) { + reusableMediaView = cached + } else { + reusableMediaView = ReusableMediaView(mediaViewAdapter: mediaViewAdapter(), mediaCache: mediaCache) + mediaCache.setMediaView(reusableMediaView, forKey: cacheKey, isAnimated: isAnimated) + } + reusableMediaView.owner = componentView + componentView.reusableMediaViews[index] = reusableMediaView + reusableMediaView.mediaView.contentMode = .scaleAspectFit + reusableMediaView.mediaView.clipsToBounds = true + return reusableMediaView.mediaView + } + func configure( pillState: PillState, + index: Int, cellMeasurement: CVCellMeasurement, + componentView: CVComponentReactions.CVComponentViewReactions, + mediaCache: CVMediaCache, ) { + stickerSpinner.stopAnimating() addLayoutBlock { view in view.layer.borderWidth = Self.pillBorderWidth @@ -261,6 +347,47 @@ class CVReactionCountsView: ManualStackView { var subviews = [UIView]() + switch pillState { + case + .emoji, + .moreCount, + .sticker(.pointer(_, .failed), _, _, _), + .sticker(.undownloadable, _, _, _): + componentView.reusableMediaViews[index] = nil + case + .sticker(.pointer(_, .none), _, _, _), + .sticker(.pointer(_, .enqueuedOrDownloading), _, _, _): + stickerSpinner.startAnimating() + subviews.append(stickerSpinner) + componentView.reusableMediaViews[index] = nil + case .sticker(.stream(let stream), _, _, _): + subviews.append(prepareReusableMediaView( + attachment: stream.attachment, + isAnimated: stream.attachmentStream.contentType.isAnimatedImage, + index: index, + componentView: componentView, + mediaCache: mediaCache, + mediaViewAdapter: { + MediaViewAdapterSticker( + attachmentStream: stream.attachmentStream + ) + } + )) + case .sticker(.backupThumbnail(let thumbnail), _, _, _): + subviews.append(prepareReusableMediaView( + attachment: thumbnail.attachment, + isAnimated: false, + index: index, + componentView: componentView, + mediaCache: mediaCache, + mediaViewAdapter: { + MediaViewAdapterBackupThumbnail( + attachmentBackupThumbnail: thumbnail.attachmentBackupThumbnail + ) + } + )) + } + if let emojiLabelConfig = Self.emojiLabelConfig(pillState: pillState) { emojiLabelConfig.applyForRendering(label: emojiLabel) subviews.append(emojiLabel) @@ -286,6 +413,21 @@ class CVReactionCountsView: ManualStackView { ) -> CGSize { var subviewInfos = [ManualStackSubviewInfo]() + + if case .sticker(let attachment, _, _, _) = pillState { + switch attachment { + case + .stream, + .backupThumbnail, + .pointer(_, .enqueuedOrDownloading), + .pointer(_, .none): + let size = CGSize(width: stickerSize, height: stickerSize) + subviewInfos.append(size.asManualSubviewInfo) + case .undownloadable, .pointer(_, .failed): + break + } + } + if let emojiLabelConfig = Self.emojiLabelConfig(pillState: pillState) { let labelSize = CVText.measureLabel( config: emojiLabelConfig, diff --git a/Signal/ConversationView/Components/CVComponentMessage.swift b/Signal/ConversationView/Components/CVComponentMessage.swift index e02268a17c6..4da517b6904 100644 --- a/Signal/ConversationView/Components/CVComponentMessage.swift +++ b/Signal/ConversationView/Components/CVComponentMessage.swift @@ -99,7 +99,7 @@ public class CVComponentMessage: CVComponentBase, CVRootComponent { private var sharpCornersForQuotedMessage: OWSDirectionalRectCorner { var sharpCorners = sharpCorners - if itemViewState.senderNameState != nil || componentState.quotedReply?.quotedReplyModel.storyReactionEmoji != nil { + if itemViewState.senderNameState != nil || componentState.quotedReply?.quotedReplyModel.storyReaction != nil { sharpCorners.insert(.topLeading) sharpCorners.insert(.topTrailing) } diff --git a/Signal/ConversationView/Components/CVComponentQuotedReply.swift b/Signal/ConversationView/Components/CVComponentQuotedReply.swift index c3a432a78ba..9970b58fea5 100644 --- a/Signal/ConversationView/Components/CVComponentQuotedReply.swift +++ b/Signal/ConversationView/Components/CVComponentQuotedReply.swift @@ -51,6 +51,7 @@ public class CVComponentQuotedReply: CVComponentBase, CVComponent { componentDelegate: componentDelegate, sharpCorners: sharpCornersForQuotedMessage, cellMeasurement: cellMeasurement, + mediaCache: mediaCache ) } @@ -81,8 +82,20 @@ public class CVComponentQuotedReply: CVComponentBase, CVComponent { componentView: CVComponentView, renderItem: CVRenderItem, ) -> Bool { + guard let componentView = componentView as? CVComponentViewQuotedReply else { + owsFailDebug("Unexpected componentView.") + return false + } - componentDelegate.didTapQuotedReply(quotedReplyModel) + let quotedMessageView = componentView.quotedMessageView + let tapPoint = sender.location(in: quotedMessageView) + if let packInfo = quotedMessageView.stickerPackInfoForReactionTap(at: tapPoint) { + componentDelegate.didTapStickerPack(packInfo) + } else if quotedMessageView.handleUndownloadedStickerReactionTapIfNeeded(at: tapPoint) { + // Download was triggered via the CVQuotedMessageViewDelegate. + } else { + componentDelegate.didTapQuotedReply(quotedReplyModel) + } return true } diff --git a/Signal/ConversationView/Components/CVComponentReactions.swift b/Signal/ConversationView/Components/CVComponentReactions.swift index f3dca110273..bdcf964757f 100644 --- a/Signal/ConversationView/Components/CVComponentReactions.swift +++ b/Signal/ConversationView/Components/CVComponentReactions.swift @@ -47,6 +47,8 @@ public class CVComponentReactions: CVComponentBase, CVComponent, CVAccessibility reactionCountsView.configure( state: viewState, cellMeasurement: cellMeasurement, + componentView: componentView, + mediaCache: mediaCache ) } @@ -95,6 +97,15 @@ public class CVComponentReactions: CVComponentBase, CVComponent, CVAccessibility count, emoji, ) + case .sticker(_, _, let count, _): + string = String.localizedStringWithFormat( + OWSLocalizedString( + "MESSAGE_REACTIONS_STICKER_ACCESSIBILITY_LABEL_%d", + tableName: "PluralAware", + comment: "Accessibility label reading out a sticker reaction to a message and its count. Embeds {{ count }}.", + ), + count, + ) case .moreCount(let count, _): string = String.localizedStringWithFormat( OWSLocalizedString( @@ -120,16 +131,34 @@ public class CVComponentReactions: CVComponentBase, CVComponent, CVAccessibility fileprivate let reactionCountsView = CVReactionCountsView() + public var reusableMediaViews: [ReusableMediaView?] = [nil, nil, nil] + public var isDedicatedCellView = false public var rootView: UIView { reactionCountsView } - public func setIsCellVisible(_ isCellVisible: Bool) {} + public func setIsCellVisible(_ isCellVisible: Bool) { + for rmv in reusableMediaViews.compactMap({ $0 }) { + guard rmv.owner === self else { continue } + if isCellVisible { + rmv.load() + } else { + rmv.unload() + } + } + } public func reset() { reactionCountsView.reset() + + for rmv in reusableMediaViews.compactMap({ $0 }) { + if rmv.owner === self { + rmv.unload() + } + } + reusableMediaViews = [nil, nil, nil] } } diff --git a/Signal/ConversationView/Components/CVComponentState.swift b/Signal/ConversationView/Components/CVComponentState.swift index 90d44317759..55007ce3c29 100644 --- a/Signal/ConversationView/Components/CVComponentState.swift +++ b/Signal/ConversationView/Components/CVComponentState.swift @@ -1207,7 +1207,10 @@ private extension CVComponentState.Builder { return try buildContact(message: message, contact: contact) } - if let messageSticker = message.messageSticker { + if + let messageSticker = message.messageSticker, + !(message.isStoryReply && !message.isGroupStoryReply) + { return try buildSticker(message: message, messageSticker: messageSticker) } @@ -1582,8 +1585,55 @@ private extension CVComponentState.Builder { transaction: transaction, ) } + + let storyReactionSticker: CVComponentState.Sticker? + if + message.isStoryReply && !message.isGroupStoryReply, + let messageSticker = message.messageSticker, + let rowId = message.sqliteRowId, + let attachment = DependenciesBridge.shared.attachmentStore.fetchAnyReferencedAttachment( + for: .messageSticker(messageRowId: rowId), + tx: transaction, + ) + { + if let referencedAttachmentStream = attachment.asReferencedStream { + let stickerMetadata = EncryptedStickerMetadata( + stickerInfo: messageSticker.info, + stickerType: StickerManager.stickerType( + forContentType: referencedAttachmentStream.attachment.mimeType + ), + emojiString: messageSticker.emoji, + encryptedStickerDataUrl: referencedAttachmentStream.attachmentStream.fileURL, + encryptionKey: referencedAttachmentStream.attachmentStream.attachment.encryptionKey, + plaintextLength: referencedAttachmentStream.attachmentStream.info.unencryptedByteCount, + ) + + storyReactionSticker = .available( + stickerMetadata: stickerMetadata, + attachmentStream: referencedAttachmentStream, + ) + } else if let attachmentPointer = attachment.asReferencedAnyPointer { + let downloadState = attachmentPointer.attachmentPointer.downloadState(tx: transaction) + switch downloadState { + case .enqueuedOrDownloading: + storyReactionSticker = .downloading(attachmentPointer: attachmentPointer) + case .failed, .none: + storyReactionSticker = .failedOrPending( + attachmentPointer: attachmentPointer, + downloadState: downloadState, + ) + } + } else { + // If undownloadable, we drop the sticker and use reaction emoji. + storyReactionSticker = nil + } + } else { + storyReactionSticker = nil + } + let viewState = CVQuotedMessageView.stateForConversation( quotedReplyModel: quotedReplyModel, + storyReactionSticker: storyReactionSticker, displayableQuotedText: displayableQuotedText, conversationStyle: conversationStyle, isOutgoing: isOutgoing, diff --git a/Signal/ConversationView/ConversationInputToolbar+QuotedReplyPreview.swift b/Signal/ConversationView/ConversationInputToolbar+QuotedReplyPreview.swift index 7756ac24d65..080b837eb01 100644 --- a/Signal/ConversationView/ConversationInputToolbar+QuotedReplyPreview.swift +++ b/Signal/ConversationView/ConversationInputToolbar+QuotedReplyPreview.swift @@ -520,7 +520,7 @@ private class QuotedMessageSnippetView: UIView { imageView.contentMode = .scaleAspectFit thumbnailView = imageView - case .payment, .text, .viewOnce, .contactShare, .storyReactionEmoji, .poll: + case .payment, .text, .viewOnce, .contactShare, .storyReaction, .poll: break } @@ -627,7 +627,7 @@ private class QuotedMessageSnippetView: UIView { return (attachment.mimeType, reference.renderingFlag) case .edit(_, _, let innerContent): return mimeTypeAndRenderingFlag(innerContent) - case .giftBadge, .text, .payment, .viewOnce, .contactShare, .storyReactionEmoji, .poll: + case .giftBadge, .text, .payment, .viewOnce, .contactShare, .storyReaction, .poll: return nil } } @@ -683,7 +683,7 @@ private class QuotedMessageSnippetView: UIView { return reference.sourceFilename case .edit(_, _, let innerContent): return sourceFilenameForSnippet(innerContent) - case .giftBadge, .text, .payment, .contactShare, .viewOnce, .storyReactionEmoji, .poll: + case .giftBadge, .text, .payment, .contactShare, .viewOnce, .storyReaction, .poll: return nil } } diff --git a/Signal/ConversationView/ConversationViewController+MessageActions.swift b/Signal/ConversationView/ConversationViewController+MessageActions.swift index 3fb27f335b9..6533c7d3392 100644 --- a/Signal/ConversationView/ConversationViewController+MessageActions.swift +++ b/Signal/ConversationView/ConversationViewController+MessageActions.swift @@ -167,19 +167,16 @@ extension ConversationViewController: ContextMenuInteractionDelegate { // Add reaction bar if necessary if thread.canSendReactionToThread, shouldShowReactionPickerForInteraction(contextInteraction.itemViewModel.interaction) { let reactionBarAccessory = ContextMenuReactionBarAccessory(thread: self.thread, itemViewModel: contextInteraction.itemViewModel) - reactionBarAccessory.didSelectReactionHandler = { [weak self] (message: TSMessage, reaction: String, isRemoving: Bool) in - - guard self != nil else { - owsFailDebug("conversationViewController was unexpectedly nil") - return - } - - SSKEnvironment.shared.databaseStorageRef.asyncWrite { transaction in - ReactionManager.localUserReacted( - to: message.uniqueId, - emoji: reaction, - isRemoving: isRemoving, - tx: transaction, + reactionBarAccessory.didSelectReactionHandler = { [weak self] (message: TSMessage, reaction: CustomReactionItem, isRemoving: Bool) in + Task { [weak self] in + guard let self else { + owsFailDebug("conversationViewController was unexpectedly nil") + return + } + await self.sendReaction( + message: message, + reaction: reaction, + isRemoving: isRemoving ) } } @@ -273,4 +270,54 @@ extension ConversationViewController: ContextMenuInteractionDelegate { } } + private func sendReaction( + message: TSMessage, + reaction: CustomReactionItem, + isRemoving: Bool, + ) async { + let stickerDataSource: MessageStickerDataSource? + if !isRemoving, let stickerInfo = reaction.sticker { + let stickerMetadata = SSKEnvironment.shared.databaseStorageRef.read { tx in + StickerManager.installedStickerMetadata(stickerInfo: stickerInfo, transaction: tx) + } + + guard let stickerMetadata else { + owsFailDebug("Could not find installed sticker metadata for reaction") + return + } + + guard let stickerData = try? stickerMetadata.readStickerData() else { + owsFailDebug("Could not read sticker data for reaction") + return + } + + let draft = MessageStickerDraft( + info: stickerInfo, + stickerData: stickerData, + stickerType: stickerMetadata.stickerType, + emoji: reaction.emoji, + ) + + do { + stickerDataSource = try await DependenciesBridge.shared.messageStickerManager + .buildDataSource(fromDraft: draft) + } catch { + owsFailDebug("Failed to build sticker data source for reaction: \(error)") + return + } + } else { + stickerDataSource = nil + } + + await SSKEnvironment.shared.databaseStorageRef.awaitableWrite { tx in + _ = ReactionManager.localUserReacted( + to: message, + emoji: reaction.emoji, + sticker: stickerDataSource, + isRemoving: isRemoving, + tx: tx, + ) + } + } + } diff --git a/Signal/ConversationView/Reactions/EmojiCountsCollectionView.swift b/Signal/ConversationView/Reactions/EmojiCountsCollectionView.swift index d4ed5a53cd9..2b3d3afcd9c 100644 --- a/Signal/ConversationView/Reactions/EmojiCountsCollectionView.swift +++ b/Signal/ConversationView/Reactions/EmojiCountsCollectionView.swift @@ -3,6 +3,7 @@ // SPDX-License-Identifier: AGPL-3.0-only // +import SDWebImage import SignalServiceKit public import SignalUI @@ -12,22 +13,35 @@ public struct EmojiItem { // If a specific emoji is not specified, this item represents "all" emoji let emoji: String? let count: Int + let sticker: CVAttachment? let didSelect: () -> Void + + init(emoji: String?, count: Int, sticker: CVAttachment? = nil, didSelect: @escaping () -> Void) { + self.emoji = emoji + self.count = count + self.sticker = sticker + self.didSelect = didSelect + } } public class EmojiCountsCollectionView: UICollectionView { let itemHeight: CGFloat = 36 + let stickerImageCache: StickerReactionImageCache + + private var pendingDownloadAttachmentIds = Set() public var items = [EmojiItem]() { didSet { AssertIsOnMainThread() + updatePendingDownloadAttachmentIds() reloadData() } } - public init() { + init(stickerImageCache: StickerReactionImageCache) { + self.stickerImageCache = stickerImageCache let layout = UICollectionViewFlowLayout() layout.minimumInteritemSpacing = 0 layout.minimumLineSpacing = 0 @@ -44,6 +58,13 @@ public class EmojiCountsCollectionView: UICollectionView { contentInset = UIEdgeInsets(top: 8, leading: 8, bottom: 8, trailing: 8) autoSetDimension(.height, toSize: itemHeight + contentInset.top + contentInset.bottom) + + NotificationCenter.default.addObserver( + self, + selector: #selector(attachmentDownloadProgress(_:)), + name: AttachmentDownloads.attachmentDownloadProgressNotification, + object: nil, + ) } func setSelectedIndex(_ index: Int) { @@ -53,6 +74,34 @@ public class EmojiCountsCollectionView: UICollectionView { public required init(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + + private func updatePendingDownloadAttachmentIds() { + pendingDownloadAttachmentIds.removeAll() + for item in items { + guard let sticker = item.sticker else { continue } + if sticker.attachmentStream == nil { + pendingDownloadAttachmentIds.insert(sticker.attachment.attachment.id) + } + } + } + + @objc + private func attachmentDownloadProgress(_ notification: Notification) { + guard + let attachmentId = notification + .userInfo?[AttachmentDownloads.attachmentDownloadAttachmentIDKey] + as? Attachment.IDType, + pendingDownloadAttachmentIds.contains(attachmentId), + let progress = notification + .userInfo?[AttachmentDownloads.attachmentDownloadProgressKey] + as? NSNumber, + progress.floatValue >= 1.0 + else { + return + } + pendingDownloadAttachmentIds.remove(attachmentId) + reloadData() + } } // MARK: - UICollectionViewDelegateFlowLayout @@ -95,51 +144,86 @@ extension EmojiCountsCollectionView: UICollectionViewDataSource { return cell } - emojiCell.configure(with: item) + emojiCell.configure(with: item, imageCache: stickerImageCache) return emojiCell } } class EmojiCountCell: UICollectionViewCell { - let emoji = UILabel() - let count = UILabel() + let emojiLabel = UILabel() + let countLabel = UILabel() + let stackView: UIStackView + let stickerImageView = SDAnimatedImageView() + private static let stickerSize: CGFloat = 28 + + private var stickerAttachmentId: Attachment.IDType? static let reuseIdentifier = "EmojiCountCell" override init(frame: CGRect) { + stackView = UIStackView(arrangedSubviews: [emojiLabel, stickerImageView, countLabel]) super.init(frame: .zero) let selectedBackground = UIView() selectedBackground.backgroundColor = UIColor.Signal.secondaryFill selectedBackgroundView = selectedBackground - let stackView = UIStackView(arrangedSubviews: [emoji, count]) + stickerImageView.contentMode = .scaleAspectFit + stickerImageView.clipsToBounds = true + stickerImageView.autoSetDimensions(to: CGSize(square: Self.stickerSize)) + stickerImageView.isHidden = true + stackView.isLayoutMarginsRelativeArrangement = true - stackView.layoutMargins = UIEdgeInsets(top: 8, leading: 8, bottom: 8, trailing: 8) stackView.spacing = 4 contentView.addSubview(stackView) stackView.autoPinEdgesToSuperviewEdges() stackView.autoSetDimension(.height, toSize: 32) - emoji.font = .systemFont(ofSize: 22) + emojiLabel.font = .systemFont(ofSize: 22) - count.font = UIFont.dynamicTypeSubheadlineClamped.monospaced().semibold() - count.textColor = Theme.primaryTextColor + countLabel.font = UIFont.dynamicTypeSubheadlineClamped.monospaced().semibold() + countLabel.textColor = Theme.primaryTextColor } required init?(coder aDecoder: NSCoder) { fatalError("init(coder:) has not been implemented") } - func configure(with item: EmojiItem) { - emoji.text = item.emoji - emoji.isHidden = item.emoji == nil + override func prepareForReuse() { + super.prepareForReuse() + stickerAttachmentId = nil + stickerImageView.image = nil + stickerImageView.isHidden = true + emojiLabel.isHidden = false + emojiLabel.text = nil + } + + func configure(with item: EmojiItem, imageCache: StickerReactionImageCache?) { + if let sticker = item.sticker, let stream = sticker.attachmentStream, let imageCache { + stackView.layoutMargins = UIEdgeInsets(hMargin: 8, vMargin: 4) + let attachmentId = sticker.attachment.attachment.id + self.stickerAttachmentId = attachmentId + + stickerImageView.isHidden = false + emojiLabel.isHidden = true + + Task { [weak self] in + let image = await imageCache.image(for: stream) + guard let self, self.stickerAttachmentId == attachmentId else { return } + self.stickerImageView.image = image + } + } else { + stackView.layoutMargins = UIEdgeInsets(hMargin: 8, vMargin: 8) + emojiLabel.text = item.emoji + emojiLabel.isHidden = item.emoji == nil + stickerImageView.isHidden = true + } - if item.emoji != nil { - count.text = item.count.abbreviatedString + if item.emoji != nil || item.sticker != nil { + countLabel.text = item.count.abbreviatedString } else { - count.text = String( + countLabel.text = String( format: OWSLocalizedString( "REACTION_DETAIL_ALL_FORMAT", comment: "The header used to indicate All reactions to a given message. Embeds {{number of reactions}}", diff --git a/Signal/ConversationView/Reactions/EmojiReactorsTableView.swift b/Signal/ConversationView/Reactions/EmojiReactorsTableView.swift index 517bb6262a3..dc114d8a8e7 100644 --- a/Signal/ConversationView/Reactions/EmojiReactorsTableView.swift +++ b/Signal/ConversationView/Reactions/EmojiReactorsTableView.swift @@ -3,21 +3,37 @@ // SPDX-License-Identifier: AGPL-3.0-only // +import SDWebImage import SignalServiceKit import SignalUI +protocol EmojiReactorsTableViewDelegate: AnyObject { + func emojiReactorsTableView( + _ tableView: EmojiReactorsTableView, + didTapSticker stickerInfo: StickerInfo + ) +} + class EmojiReactorsTableView: UITableView { struct ReactorItem { let address: SignalServiceAddress let displayName: String let emoji: String + let sticker: CVAttachment? + let stickerInfo: StickerInfo? } + weak var reactorDelegate: EmojiReactorsTableViewDelegate? + let stickerImageCache: StickerReactionImageCache + + private var pendingDownloadAttachmentIds = Set() + private var reactorItems = [ReactorItem]() { didSet { reloadData() } } - init() { + init(stickerImageCache: StickerReactionImageCache) { + self.stickerImageCache = stickerImageCache super.init(frame: .zero, style: .plain) dataSource = self @@ -25,23 +41,62 @@ class EmojiReactorsTableView: UITableView { separatorStyle = .none register(EmojiReactorCell.self, forCellReuseIdentifier: EmojiReactorCell.reuseIdentifier) + + NotificationCenter.default.addObserver( + self, + selector: #selector(attachmentDownloadProgress(_:)), + name: AttachmentDownloads.attachmentDownloadProgressNotification, + object: nil, + ) } required init?(coder aDecoder: NSCoder) { fatalError("init(coder:) has not been implemented") } - func configure(for reactions: [OWSReaction], transaction: DBReadTransaction) { + func configure( + for reactions: [OWSReaction], + stickerAttachmentByReactionId: [Int64: CVAttachment], + transaction: DBReadTransaction, + ) { + pendingDownloadAttachmentIds.removeAll() + reactorItems = reactions.compactMap { reaction in let displayName = SSKEnvironment.shared.contactManagerRef.displayName(for: reaction.reactor, tx: transaction).resolvedValue() + let sticker: CVAttachment? = reaction.id.flatMap { stickerAttachmentByReactionId[$0] } + + if let sticker, sticker.attachmentStream == nil { + pendingDownloadAttachmentIds.insert(sticker.attachment.attachment.id) + } + return ReactorItem( address: reaction.reactor, displayName: displayName, emoji: reaction.emoji, + sticker: sticker, + stickerInfo: reaction.sticker, ) } } + + @objc + private func attachmentDownloadProgress(_ notification: Notification) { + guard + let attachmentId = notification + .userInfo?[AttachmentDownloads.attachmentDownloadAttachmentIDKey] + as? Attachment.IDType, + pendingDownloadAttachmentIds.contains(attachmentId), + let progress = notification + .userInfo?[AttachmentDownloads.attachmentDownloadProgressKey] + as? NSNumber, + progress.floatValue >= 1.0 + else { + return + } + pendingDownloadAttachmentIds.remove(attachmentId) + reloadData() + } } extension EmojiReactorsTableView: UITableViewDataSource { @@ -62,18 +117,41 @@ extension EmojiReactorsTableView: UITableViewDataSource { } contactCell.backgroundColor = .clear - contactCell.configure(item: item) + contactCell.configure(item: item, imageCache: stickerImageCache, delegate: self) return contactCell } } +extension EmojiReactorsTableView: EmojiReactorCellDelegate { + fileprivate func emojiReactorCellDidTapSticker(_ cell: EmojiReactorCell) { + guard + let indexPath = indexPath(for: cell), + let item = reactorItems[safe: indexPath.row], + let stickerInfo = item.stickerInfo + else { return } + reactorDelegate?.emojiReactorsTableView(self, didTapSticker: stickerInfo) + } +} + +// MARK: - EmojiReactorCell + +private protocol EmojiReactorCellDelegate: AnyObject { + func emojiReactorCellDidTapSticker(_ cell: EmojiReactorCell) +} + private class EmojiReactorCell: UITableViewCell { static let reuseIdentifier = "EmojiReactorCell" let avatarView = ConversationAvatarView(sizeClass: .thirtySix, localUserDisplayMode: .asUser) let nameLabel = UILabel() let emojiLabel = UILabel() + let stickerImageView = SDAnimatedImageView() + private static let stickerSize: CGFloat = 36 + + weak var delegate: EmojiReactorCellDelegate? + + private var stickerAttachmentId: Attachment.IDType? override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { super.init(style: style, reuseIdentifier: reuseIdentifier) @@ -92,21 +170,68 @@ private class EmojiReactorCell: UITableViewCell { nameLabel.autoPinHeightToSuperviewMargins() emojiLabel.font = .boldSystemFont(ofSize: 24) - contentView.addSubview(emojiLabel) - emojiLabel.autoPinLeading(toTrailingEdgeOf: nameLabel, offset: 8) - emojiLabel.setContentHuggingHorizontalHigh() - emojiLabel.autoPinHeightToSuperviewMargins() - emojiLabel.autoPinTrailingToSuperviewMargin() + + stickerImageView.contentMode = .scaleAspectFit + stickerImageView.clipsToBounds = true + stickerImageView.autoSetDimensions(to: CGSize(square: Self.stickerSize)) + stickerImageView.isHidden = true + + let trailingStack = UIStackView(arrangedSubviews: [emojiLabel, stickerImageView]) + trailingStack.axis = .horizontal + trailingStack.spacing = 0 + contentView.addSubview(trailingStack) + trailingStack.autoPinLeading(toTrailingEdgeOf: nameLabel, offset: 8) + trailingStack.setContentHuggingHorizontalHigh() + trailingStack.autoVCenterInSuperview() + trailingStack.autoPinTrailingToSuperviewMargin() + + let tapGesture = UITapGestureRecognizer(target: self, action: #selector(didTapStickerImageView)) + stickerImageView.isUserInteractionEnabled = true + stickerImageView.addGestureRecognizer(tapGesture) } required init?(coder aDecoder: NSCoder) { fatalError("init(coder:) has not been implemented") } - func configure(item: EmojiReactorsTableView.ReactorItem) { + override func prepareForReuse() { + super.prepareForReuse() + stickerAttachmentId = nil + stickerImageView.image = nil + stickerImageView.isHidden = true + emojiLabel.isHidden = false + emojiLabel.text = nil + } + + func configure( + item: EmojiReactorsTableView.ReactorItem, + imageCache: StickerReactionImageCache?, + delegate: EmojiReactorCellDelegate, + ) { + self.delegate = delegate nameLabel.textColor = UIColor.Signal.label - emojiLabel.text = item.emoji + if let sticker = item.sticker, let stream = sticker.attachmentStream, let imageCache { + let attachmentId = sticker.attachment.attachment.id + stickerAttachmentId = attachmentId + + Task { [weak self] in + let image = await imageCache.image(for: stream) + guard let self, self.stickerAttachmentId == attachmentId else { return } + if let image { + self.applyStickerImage(image) + } + } + + // Show emoji as fallback until the async load completes. + emojiLabel.text = item.emoji + emojiLabel.isHidden = false + stickerImageView.isHidden = true + } else { + emojiLabel.text = item.emoji + emojiLabel.isHidden = false + stickerImageView.isHidden = true + } if item.address.isLocalAddress { nameLabel.text = CommonStrings.you @@ -118,4 +243,15 @@ private class EmojiReactorCell: UITableViewCell { config.dataSource = .address(item.address) } } + + private func applyStickerImage(_ image: UIImage) { + stickerImageView.image = image + stickerImageView.isHidden = false + emojiLabel.isHidden = true + } + + @objc + private func didTapStickerImageView() { + delegate?.emojiReactorCellDidTapSticker(self) + } } diff --git a/Signal/ConversationView/Reactions/InteractionReactionState.swift b/Signal/ConversationView/Reactions/InteractionReactionState.swift index a9b87b14878..7a06d8f8042 100644 --- a/Signal/ConversationView/Reactions/InteractionReactionState.swift +++ b/Signal/ConversationView/Reactions/InteractionReactionState.swift @@ -5,21 +5,44 @@ public import SignalServiceKit +/// Key for grouping reactions on a message. Sticker reactions are grouped by +/// (packId + stickerId), while emoji reactions are grouped by canonical base emoji. +public enum ReactionGroupKey: Hashable { + case emoji(String) + case sticker(packId: Data, stickerId: UInt32) + + init?(reaction: OWSReaction) { + if let sticker = reaction.sticker { + self = .sticker(packId: sticker.packId, stickerId: sticker.stickerId) + } else if let emoji = EmojiWithSkinTones(rawValue: reaction.emoji) { + self = .emoji(emoji.baseEmoji.rawValue) + } else { + return nil + } + } +} + public class InteractionReactionState: NSObject { var hasReactions: Bool { return !emojiCounts.isEmpty } struct EmojiCount { let emoji: String + let groupKey: ReactionGroupKey let count: Int let highestSortOrder: UInt64 + let stickerAttachment: CVAttachment? } - let reactionsByEmoji: [Emoji: [OWSReaction]] + let reactionsByGroupKey: [ReactionGroupKey: [OWSReaction]] let emojiCounts: [EmojiCount] - let localUserEmoji: String? + let localUserReaction: OWSReaction? + let stickerAttachmentByReactionId: [Int64: CVAttachment] + + var localUserReactionGroupKey: ReactionGroupKey? { + localUserReaction.flatMap { ReactionGroupKey(reaction: $0) } + } init?(interaction: TSInteraction, transaction: DBReadTransaction) { - // No reactions on non-message interactions guard let message = interaction as? TSMessage else { return nil } guard let localAddress = DependenciesBridge.shared.tsAccountManager.localIdentifiers(tx: transaction)?.aciAddress else { @@ -31,27 +54,51 @@ public class InteractionReactionState: NSObject { let allReactions = finder.allReactions(transaction: transaction) let localUserReaction = allReactions.first(where: { $0.reactor == localAddress }) - reactionsByEmoji = allReactions.reduce( - into: [Emoji: [OWSReaction]](), + var stickerAttachmentByReactionIdLocal = [Int64: CVAttachment]() + if let messageRowId = message.sqliteRowId { + let attachmentStore = DependenciesBridge.shared.attachmentStore + let allRefs = attachmentStore.fetchReferencedAttachmentsOwnedByMessage( + messageRowId: messageRowId, + tx: transaction, + ) + for referencedAttachment in allRefs { + if case .message(.reactionSticker(let metadata)) = referencedAttachment.reference.owner { + if let stream = referencedAttachment.asReferencedStream { + stickerAttachmentByReactionIdLocal[metadata.reactionRowId] = .stream(stream) + } else if let pointer = referencedAttachment.asReferencedAnyPointer { + stickerAttachmentByReactionIdLocal[metadata.reactionRowId] = .pointer( + pointer, + downloadState: pointer.attachmentPointer.downloadState(tx: transaction) + ) + } else { + // If we can't download, fall back to displaying emoji (no sticker). + stickerAttachmentByReactionIdLocal[metadata.reactionRowId] = nil + } + } + } + } + + // Group reactions by ReactionGroupKey so that sticker reactions are never + // merged with pure emoji reactions that share the same associated emoji. + reactionsByGroupKey = allReactions.reduce( + into: [ReactionGroupKey: [OWSReaction]](), ) { result, reaction in - guard let emoji = Emoji(reaction.emoji) else { + guard let key = ReactionGroupKey(reaction: reaction) else { return owsFailDebug("Skipping reaction with [unknown emoji]") } - var reactions = result[emoji] ?? [] + var reactions = result[key] ?? [] reactions.append(reaction) - result[emoji] = reactions + result[key] = reactions } - emojiCounts = reactionsByEmoji.values.compactMap { reactions in + emojiCounts = reactionsByGroupKey.compactMap { (groupKey, reactions) in guard let mostRecentReaction = reactions.first else { owsFailDebug("unexpectedly missing reactions") return nil } let mostRecentEmoji = mostRecentReaction.emoji - // We show your own skintone (if you’ve reacted), or the most - // recent skintone (if you haven’t reacted). let emojiToRender: String if let localUserReaction, reactions.contains(localUserReaction) { emojiToRender = localUserReaction.emoji @@ -62,10 +109,22 @@ public class InteractionReactionState: NSObject { let highestSortOrder = (reactions.map { $0.sortOrder }.max() ?? mostRecentReaction.sortOrder) + let stickerAttachment: CVAttachment? = { + if + let reactionId = mostRecentReaction.id, + let state = stickerAttachmentByReactionIdLocal[reactionId] + { + return state + } + return nil + }() + return EmojiCount( emoji: emojiToRender, + groupKey: groupKey, count: reactions.count, highestSortOrder: highestSortOrder, + stickerAttachment: stickerAttachment, ) }.sorted { (lhs: EmojiCount, rhs: EmojiCount) in if lhs.count != rhs.count { @@ -80,6 +139,7 @@ public class InteractionReactionState: NSObject { } } - localUserEmoji = localUserReaction?.emoji + self.localUserReaction = localUserReaction + stickerAttachmentByReactionId = stickerAttachmentByReactionIdLocal } } diff --git a/Signal/ConversationView/Reactions/ReactionsDetailSheet.swift b/Signal/ConversationView/Reactions/ReactionsDetailSheet.swift index 51eb03556a9..e8473dcafa4 100644 --- a/Signal/ConversationView/Reactions/ReactionsDetailSheet.swift +++ b/Signal/ConversationView/Reactions/ReactionsDetailSheet.swift @@ -12,8 +12,9 @@ class ReactionsDetailSheet: InteractiveSheetViewController { private var reactionState: InteractionReactionState private let reactionFinder: ReactionFinder + let stickerImageCache = StickerReactionImageCache() let stackView = UIStackView() - let emojiCountsCollectionView = EmojiCountsCollectionView() + lazy var emojiCountsCollectionView = EmojiCountsCollectionView(stickerImageCache: stickerImageCache) override var interactiveScrollViews: [UIScrollView] { emojiReactorsViews } @@ -23,8 +24,8 @@ class ReactionsDetailSheet: InteractiveSheetViewController { reactionState.emojiCounts } - private var allEmoji: [Emoji] { - return emojiCounts.compactMap { Emoji($0.emoji) } + private var allGroupKeys: [ReactionGroupKey] { + return emojiCounts.map { $0.groupKey } } init(reactionState: InteractionReactionState, message: TSMessage) { @@ -52,8 +53,8 @@ class ReactionsDetailSheet: InteractiveSheetViewController { // Prepare paging between emoji reactors setupPaging() - // Select the "all" reaction page by setting selected emoji to nil - setSelectedEmoji(nil) + // Select the "all" reaction page by setting selected group key to nil + setSelectedGroupKey(nil) } override func viewIsAppearing(_ animated: Bool) { @@ -83,45 +84,46 @@ class ReactionsDetailSheet: InteractiveSheetViewController { buildEmojiCountItems() - // If the currently selected emoji still exists, keep it selected. - // Otherwise, select the "all" page by setting selected emoji to nil. - let newSelectedEmoji: Emoji? - if let selectedEmoji, allEmoji.contains(selectedEmoji) { - newSelectedEmoji = selectedEmoji + // If the currently selected group key still exists, keep it selected. + // Otherwise, select the "all" page by setting selected group key to nil. + let newSelectedGroupKey: ReactionGroupKey? + if let selectedGroupKey, allGroupKeys.contains(selectedGroupKey) { + newSelectedGroupKey = selectedGroupKey } else { - newSelectedEmoji = nil + newSelectedGroupKey = nil } - setSelectedEmoji(newSelectedEmoji, transaction: transaction) + setSelectedGroupKey(newSelectedGroupKey, transaction: transaction) } func buildEmojiCountItems() { let allReactionsItem = EmojiItem(emoji: nil, count: emojiCounts.lazy.map { $0.count }.reduce(0, +)) { [weak self] in - self?.setSelectedEmoji(nil) + self?.setSelectedGroupKey(nil) } emojiCountsCollectionView.items = [allReactionsItem] + emojiCounts.map { emojiCount in - EmojiItem( - emoji: emojiCount.emoji, + return EmojiItem( + emoji: emojiCount.stickerAttachment != nil ? nil : emojiCount.emoji, count: emojiCount.count, + sticker: emojiCount.stickerAttachment, ) { [weak self] in - self?.setSelectedEmoji(Emoji(emojiCount.emoji)) + self?.setSelectedGroupKey(emojiCount.groupKey) } } } - // MARK: - Emoji Selection + // MARK: - Group Key Selection - private var selectedEmoji: Emoji? + private var selectedGroupKey: ReactionGroupKey? - func setSelectedEmoji(_ emoji: Emoji?) { - SSKEnvironment.shared.databaseStorageRef.read { self.setSelectedEmoji(emoji, transaction: $0) } + func setSelectedGroupKey(_ groupKey: ReactionGroupKey?) { + SSKEnvironment.shared.databaseStorageRef.read { self.setSelectedGroupKey(groupKey, transaction: $0) } } - func setSelectedEmoji(_ emoji: Emoji?, transaction: DBReadTransaction) { - let oldValue = selectedEmoji - selectedEmoji = emoji - selectedEmojiChanged(oldSelectedEmoji: oldValue, transaction: transaction) + func setSelectedGroupKey(_ groupKey: ReactionGroupKey?, transaction: DBReadTransaction) { + let oldValue = selectedGroupKey + selectedGroupKey = groupKey + selectedGroupKeyChanged(oldSelectedGroupKey: oldValue, transaction: transaction) } // MARK: - Paging @@ -130,11 +132,15 @@ class ReactionsDetailSheet: InteractiveSheetViewController { /// 0 - Previous Page /// 1 - Current Page /// 2 - Next Page - private lazy var emojiReactorsViews = [ - EmojiReactorsTableView(), - EmojiReactorsTableView(), - EmojiReactorsTableView(), - ] + private lazy var emojiReactorsViews: [EmojiReactorsTableView] = { + let views = [ + EmojiReactorsTableView(stickerImageCache: stickerImageCache), + EmojiReactorsTableView(stickerImageCache: stickerImageCache), + EmojiReactorsTableView(stickerImageCache: stickerImageCache) + ] + views.forEach { $0.reactorDelegate = self } + return views + }() private var emojiReactorsViewConstraints = [NSLayoutConstraint]() private var currentPageReactorsView: EmojiReactorsTableView { @@ -151,26 +157,26 @@ class ReactionsDetailSheet: InteractiveSheetViewController { private let emojiPagingScrollView = UIScrollView() - private var nextPageEmoji: Emoji? { - // If we don't have an emoji defined, the first emoji is always up next - guard let emoji = selectedEmoji else { return allEmoji.first } + private var nextPageGroupKey: ReactionGroupKey? { + // If we don't have a group key defined, the first group is always up next + guard let key = selectedGroupKey else { return allGroupKeys.first } // If we don't have an index, or we're at the end of the array, "all" is up next - guard let index = allEmoji.firstIndex(of: emoji), index < (allEmoji.count - 1) else { return nil } + guard let index = allGroupKeys.firstIndex(of: key), index < (allGroupKeys.count - 1) else { return nil } - // Otherwise, use the next emoji in the array - return allEmoji[index + 1] + // Otherwise, use the next group key in the array + return allGroupKeys[index + 1] } - private var previousPageEmoji: Emoji? { - // If we don't have an emoji defined, the last emoji is always previous - guard let emoji = selectedEmoji else { return allEmoji.last } + private var previousPageGroupKey: ReactionGroupKey? { + // If we don't have a group key defined, the last group is always previous + guard let key = selectedGroupKey else { return allGroupKeys.last } // If we don't have an index, or we're at the start of the array, "all" is previous - guard let index = allEmoji.firstIndex(of: emoji), index > 0 else { return nil } + guard let index = allGroupKeys.firstIndex(of: key), index > 0 else { return nil } - // Otherwise, use the previous emoji in the array - return allEmoji[index - 1] + // Otherwise, use the previous group key in the array + return allGroupKeys[index - 1] } private var pageWidth: CGFloat { return min(contentView.frame.width, maxWidth) } @@ -220,60 +226,60 @@ class ReactionsDetailSheet: InteractiveSheetViewController { } } - private func reactions(for emoji: Emoji?, transaction: DBReadTransaction) -> [OWSReaction] { - guard let emoji else { + private func reactions(for groupKey: ReactionGroupKey?, transaction: DBReadTransaction) -> [OWSReaction] { + guard let groupKey else { return reactionFinder.allReactions(transaction: transaction) } - guard let reactions = reactionState.reactionsByEmoji[emoji] else { - owsFailDebug("missing reactions for emoji \(emoji)") + guard let reactions = reactionState.reactionsByGroupKey[groupKey] else { + owsFailDebug("missing reactions for group key") return [] } return reactions } - private func selectedEmojiChanged(oldSelectedEmoji: Emoji?, transaction: DBReadTransaction) { + private func selectedGroupKeyChanged(oldSelectedGroupKey: ReactionGroupKey?, transaction: DBReadTransaction) { AssertIsOnMainThread() // We're paging backwards! - if oldSelectedEmoji == nextPageEmoji, oldSelectedEmoji != selectedEmoji { + if oldSelectedGroupKey == nextPageGroupKey, oldSelectedGroupKey != selectedGroupKey { // The previous page becomes the current page and the current page becomes // the next page. We have to load the new previous. emojiReactorsViews.insert(emojiReactorsViews.removeLast(), at: 0) emojiReactorsViewConstraints.insert(emojiReactorsViewConstraints.removeLast(), at: 0) - let previousPageReactions = reactions(for: previousPageEmoji, transaction: transaction) - previousPageReactorsView.configure(for: previousPageReactions, transaction: transaction) + let previousPageReactions = reactions(for: previousPageGroupKey, transaction: transaction) + previousPageReactorsView.configure(for: previousPageReactions, stickerAttachmentByReactionId: reactionState.stickerAttachmentByReactionId, transaction: transaction) // We're paging forwards! - } else if oldSelectedEmoji == previousPageEmoji, oldSelectedEmoji != selectedEmoji { + } else if oldSelectedGroupKey == previousPageGroupKey, oldSelectedGroupKey != selectedGroupKey { // The next page becomes the current page and the current page becomes // the previous page. We have to load the new next. emojiReactorsViews.append(emojiReactorsViews.removeFirst()) emojiReactorsViewConstraints.append(emojiReactorsViewConstraints.removeFirst()) - let nextPageReactions = reactions(for: nextPageEmoji, transaction: transaction) - nextPageReactorsView.configure(for: nextPageReactions, transaction: transaction) + let nextPageReactions = reactions(for: nextPageGroupKey, transaction: transaction) + nextPageReactorsView.configure(for: nextPageReactions, stickerAttachmentByReactionId: reactionState.stickerAttachmentByReactionId, transaction: transaction) // We didn't get here through paging, stuff probably changed. Reload all the things. } else { - let currentPageReactions = reactions(for: selectedEmoji, transaction: transaction) - currentPageReactorsView.configure(for: currentPageReactions, transaction: transaction) + let currentPageReactions = reactions(for: selectedGroupKey, transaction: transaction) + currentPageReactorsView.configure(for: currentPageReactions, stickerAttachmentByReactionId: reactionState.stickerAttachmentByReactionId, transaction: transaction) - let previousPageReactions = reactions(for: previousPageEmoji, transaction: transaction) - previousPageReactorsView.configure(for: previousPageReactions, transaction: transaction) + let previousPageReactions = reactions(for: previousPageGroupKey, transaction: transaction) + previousPageReactorsView.configure(for: previousPageReactions, stickerAttachmentByReactionId: reactionState.stickerAttachmentByReactionId, transaction: transaction) - let nextPageReactions = reactions(for: nextPageEmoji, transaction: transaction) - nextPageReactorsView.configure(for: nextPageReactions, transaction: transaction) + let nextPageReactions = reactions(for: nextPageGroupKey, transaction: transaction) + nextPageReactorsView.configure(for: nextPageReactions, stickerAttachmentByReactionId: reactionState.stickerAttachmentByReactionId, transaction: transaction) } updatePageConstraints() - // Update selection on the counts view to reflect our new selected emoji - if let selectedEmoji, let index = allEmoji.firstIndex(of: selectedEmoji) { + // Update selection on the counts view to reflect our new selected group key + if let selectedGroupKey, let index = allGroupKeys.firstIndex(of: selectedGroupKey) { emojiCountsCollectionView.setSelectedIndex(index + 1) } else { emojiCountsCollectionView.setSelectedIndex(0) @@ -340,11 +346,11 @@ class ReactionsDetailSheet: InteractiveSheetViewController { // Scrolled left a page if offsetX <= previousPageThreshold { - setSelectedEmoji(previousPageEmoji) + setSelectedGroupKey(previousPageGroupKey) // Scrolled right a page } else if offsetX >= nextPageThreshold { - setSelectedEmoji(nextPageEmoji) + setSelectedGroupKey(nextPageGroupKey) } @@ -352,7 +358,16 @@ class ReactionsDetailSheet: InteractiveSheetViewController { } } -// MARK: - +// MARK: - EmojiReactorsTableViewDelegate + +extension ReactionsDetailSheet: EmojiReactorsTableViewDelegate { + func emojiReactorsTableView(_ tableView: EmojiReactorsTableView, didTapSticker stickerInfo: StickerInfo) { + let packView = StickerPackViewController(stickerPackInfo: stickerInfo.packInfo) + packView.present(from: self, animated: true) + } +} + +// MARK: - UIScrollViewDelegate extension ReactionsDetailSheet: UIScrollViewDelegate { func scrollViewDidScroll(_ scrollView: UIScrollView) { diff --git a/Signal/ConversationView/Reactions/StickerReactionImageCache.swift b/Signal/ConversationView/Reactions/StickerReactionImageCache.swift new file mode 100644 index 00000000000..bad7eaf6ed1 --- /dev/null +++ b/Signal/ConversationView/Reactions/StickerReactionImageCache.swift @@ -0,0 +1,51 @@ +// +// Copyright 2026 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only +// + +import SDWebImage +import SignalServiceKit + +/// LRU cache of in-memory sticker images decoded from attachment streams. +actor StickerReactionImageCache { + private let cache = LRUCache(maxSize: 24) + + private var inFlightLoads = [Attachment.IDType: Task]() + + /// Reads from cache if available, else loads from disk and puts in the cache. + func image(for stream: AttachmentStream) async -> UIImage? { + if let cached = cache.get(key: stream.id) { + return cached + } + + if let existingTask = inFlightLoads[stream.id] { + return await existingTask.value + } + + // Not cancellable but that's ok; these aren't that expensive + // and callsites (as of writing) don't cancel loads anyway. + let task = Task { + let image: UIImage? + if stream.contentType.isAnimatedImage { + image = try? stream.decryptedSDAnimatedImage() + } else { + image = stream.thumbnailImageSync(quality: .small) + } + return image + } + + inFlightLoads[stream.id] = task + let image = await task.value + inFlightLoads[stream.id] = nil + + if let image { + cache.set(key: stream.id, value: image) + } + return image + } + + func clear() { + cache.removeAllObjects() + inFlightLoads.removeAll() + } +} diff --git a/Signal/Emoji/EmojiReactionPickerConfigViewController.swift b/Signal/Emoji/CustomReactionPickerConfigViewController.swift similarity index 71% rename from Signal/Emoji/EmojiReactionPickerConfigViewController.swift rename to Signal/Emoji/CustomReactionPickerConfigViewController.swift index 979bf07d904..fb9a0098780 100644 --- a/Signal/Emoji/EmojiReactionPickerConfigViewController.swift +++ b/Signal/Emoji/CustomReactionPickerConfigViewController.swift @@ -6,10 +6,14 @@ import SignalServiceKit public import SignalUI -public class EmojiReactionPickerConfigViewController: UIViewController { +protocol ReactionPickerConfigurationListener { + func didCompleteReactionPickerConfiguration() +} + +public class CustomReactionPickerConfigViewController: UIViewController { private lazy var reactionPicker = MessageReactionPicker( - selectedEmoji: nil, + selectedReaction: nil, delegate: nil, style: .configure, ) @@ -67,19 +71,23 @@ public class EmojiReactionPickerConfigViewController: UIViewController { } private func resetButtonTapped() { - let emojiSet: [EmojiWithSkinTones] = ReactionManager.defaultEmojiSet.map { EmojiWithSkinTones(rawValue: $0)! } - - for (index, emoji) in reactionPicker.currentEmojiSet().enumerated() { - if let newEmoji = emojiSet[safe: index]?.rawValue { - reactionPicker.replaceEmojiReaction(emoji, newEmoji: newEmoji, inPosition: index) + let defaultReactions = ReactionManager.defaultCustomReactionSet + + for (index, item) in reactionPicker.currentReactionItems().enumerated() { + if let newReaction = defaultReactions[safe: index] { + reactionPicker.replaceReaction( + item, + new: newReaction, + inPosition: index + ) } } } private func doneButtonTapped() { - let currentEmojiSet = reactionPicker.currentEmojiSet() + let items = reactionPicker.currentReactionItems() SSKEnvironment.shared.databaseStorageRef.write { transaction in - ReactionManager.setCustomEmojiSet(currentEmojiSet, transaction: transaction) + ReactionManager.setCustomReactionSet(items, tx: transaction) } self.reactionPickerConfigurationListener?.didCompleteReactionPickerConfiguration() SSKEnvironment.shared.storageServiceManagerRef.recordPendingLocalAccountUpdates() @@ -88,36 +96,39 @@ public class EmojiReactionPickerConfigViewController: UIViewController { } -extension EmojiReactionPickerConfigViewController: MessageReactionPickerDelegate { - func didSelectReaction(reaction: String, isRemoving: Bool, inPosition position: Int) { - +extension CustomReactionPickerConfigViewController: MessageReactionPickerDelegate { + func didSelectReaction( + _ reaction: CustomReactionItem, + isRemoving: Bool, + inPosition position: Int + ) { if presentedViewController != nil { self.reactionPicker.endReplaceAnimation() presentedViewController?.dismiss(animated: true, completion: nil) return } - let picker = EmojiPickerSheet(message: nil, allowReactionConfiguration: false) { [weak self] emoji in + let picker = ReactionPickerSheet(message: nil, allowReactionConfiguration: false) { [weak self] newReaction in guard let self else { return } - guard let emojiString = emoji?.rawValue else { + guard let newReaction else { self.reactionPicker.endReplaceAnimation() return } - self.reactionPicker.replaceEmojiReaction(reaction, newEmoji: emojiString, inPosition: position) + self.reactionPicker.replaceReaction( + reaction, + new: newReaction, + inPosition: position, + ) self.reactionPicker.endReplaceAnimation() } - reactionPicker.startReplaceAnimation(focusedEmoji: reaction, inPosition: position) + reactionPicker.startReplaceAnimation(focusedReaction: reaction, inPosition: position) present(picker, animated: true) } - func didSelectAnyEmoji() { + func didSelectMore() { // No-op for configuration } } - -protocol ReactionPickerConfigurationListener { - func didCompleteReactionPickerConfiguration() -} diff --git a/Signal/Emoji/EmojiPickerCollectionView.swift b/Signal/Emoji/EmojiPickerCollectionView.swift index 9a91ec5d8cf..3cf7071c0d1 100644 --- a/Signal/Emoji/EmojiPickerCollectionView.swift +++ b/Signal/Emoji/EmojiPickerCollectionView.swift @@ -99,6 +99,10 @@ class EmojiPickerCollectionView: UICollectionView { var messageEmojiSet = Set() var dedupedEmoji = [EmojiWithSkinTones]() for react in messageReacts { + // Ignore sticker reactions; those are shown in the sticker tab. + guard react.sticker == nil else { + continue + } guard let emoji = EmojiWithSkinTones(rawValue: react.emoji) else { continue } @@ -256,6 +260,12 @@ class EmojiPickerCollectionView: UICollectionView { return } + // Mirror to recent reactions. + StickerManager.recordRecentReaction( + CustomReactionItem(emoji: emoji.rawValue, sticker: nil), + tx: transaction + ) + var newRecentEmoji = recentEmoji // Remove any existing entries for this emoji diff --git a/Signal/Emoji/EmojiPickerSheet.swift b/Signal/Emoji/EmojiPickerSheet.swift index c0543680b66..bad05ce0ae7 100644 --- a/Signal/Emoji/EmojiPickerSheet.swift +++ b/Signal/Emoji/EmojiPickerSheet.swift @@ -8,6 +8,7 @@ import SignalUI // MARK: - EmojiPickerSheet +/// A picker for _just_ emoji. If you want emoji + stickers, use ``ReactionPickerSheet``. class EmojiPickerSheet: OWSViewController { let completionHandler: (EmojiWithSkinTones?) -> Void @@ -133,7 +134,7 @@ class EmojiPickerSheet: OWSViewController { @objc private func didSelectConfigureButton(sender: UIButton) { - let configVC = EmojiReactionPickerConfigViewController( + let configVC = CustomReactionPickerConfigViewController( reactionPickerConfigurationListener: self.reactionPickerConfigurationListener, ) let navController = UINavigationController(rootViewController: configVC) diff --git a/Signal/Notifications/NotificationActionHandler.swift b/Signal/Notifications/NotificationActionHandler.swift index d3e753f0ac6..909d2d4f9be 100644 --- a/Signal/Notifications/NotificationActionHandler.swift +++ b/Signal/Notifications/NotificationActionHandler.swift @@ -269,8 +269,9 @@ public class NotificationActionHandler { do { try await SSKEnvironment.shared.databaseStorageRef.awaitableWrite { transaction in ReactionManager.localUserReacted( - to: incomingMessage.uniqueId, + to: incomingMessage, emoji: "👍", + sticker: nil, isRemoving: false, isHighPriority: false, tx: transaction, diff --git a/Signal/src/ViewControllers/ContextMenus/CustomContextMenus/ContextMenuConfiguration.swift b/Signal/src/ViewControllers/ContextMenus/CustomContextMenus/ContextMenuConfiguration.swift index 4763fb191e0..5bd4569bf2b 100644 --- a/Signal/src/ViewControllers/ContextMenus/CustomContextMenus/ContextMenuConfiguration.swift +++ b/Signal/src/ViewControllers/ContextMenus/CustomContextMenus/ContextMenuConfiguration.swift @@ -56,10 +56,10 @@ public class ContextMenu { protocol ContextMenuTargetedPreviewAccessoryInteractionDelegate: AnyObject { func contextMenuTargetedPreviewAccessoryRequestsDismissal(_ accessory: ContextMenuTargetedPreviewAccessory, completion: @escaping () -> Void) func contextMenuTargetedPreviewAccessoryPreviewAlignment(_ accessory: ContextMenuTargetedPreviewAccessory) -> ContextMenuTargetedPreview.Alignment - func contextMenuTargetedPreviewAccessoryRequestsEmojiPicker( + func contextMenuTargetedPreviewAccessoryRequestsReactionPicker( for message: TSMessage, accessory: ContextMenuTargetedPreviewAccessory, - completion: @escaping (String) -> Void, + completion: @escaping (CustomReactionItem) -> Void, ) } diff --git a/Signal/src/ViewControllers/ContextMenus/CustomContextMenus/ContextMenuController.swift b/Signal/src/ViewControllers/ContextMenus/CustomContextMenus/ContextMenuController.swift index 37ae19f9a19..c50bd06e40d 100644 --- a/Signal/src/ViewControllers/ContextMenus/CustomContextMenus/ContextMenuController.swift +++ b/Signal/src/ViewControllers/ContextMenus/CustomContextMenus/ContextMenuController.swift @@ -388,7 +388,7 @@ class ContextMenuController: OWSViewController, ContextMenuViewDelegate, UIGestu return UIVisualEffectView(effect: nil) }() - private var emojiPickerSheet: EmojiPickerSheet? + private var reactionPickerSheet: ReactionPickerSheet? init( configuration: ContextMenuConfiguration, @@ -460,7 +460,7 @@ class ContextMenuController: OWSViewController, ContextMenuViewDelegate, UIGestu guard let superview = view.superview, presentedSize != superview.bounds.size else { return } - emojiPickerSheet?.dismiss(animated: true) + reactionPickerSheet?.dismiss(animated: true) delegate?.contextMenuControllerRequestsDismissal(self) // TODO: Support orientation changes. @@ -729,25 +729,25 @@ class ContextMenuController: OWSViewController, ContextMenuViewDelegate, UIGestu } } - // MARK: Emoji Sheet + // MARK: Reaction Picker Sheet - func showEmojiSheet(message: TSMessage, completion: @escaping (String) -> Void) { - let picker = EmojiPickerSheet(message: message) { [weak self] emoji in + func showReactionPickerSheet(message: TSMessage, completion: @escaping (CustomReactionItem) -> Void) { + let picker = ReactionPickerSheet(message: message) { [weak self] reactionItem in guard let self else { return } - guard let emojiString = emoji?.rawValue else { + guard let reactionItem else { self.delegate?.contextMenuControllerRequestsDismissal(self) return } - completion(emojiString) + completion(reactionItem) } - emojiPickerSheet = picker + reactionPickerSheet = picker present(picker, animated: true) } - func dismissEmojiSheet(animated: Bool, completion: @escaping () -> Void) { - emojiPickerSheet?.dismiss(animated: true, completion: completion) + func dismissReactionPickerSheet(animated: Bool, completion: @escaping () -> Void) { + reactionPickerSheet?.dismiss(animated: true, completion: completion) } // MARK: ContextMenuViewDelegate diff --git a/Signal/src/ViewControllers/ContextMenus/CustomContextMenus/ContextMenuInteraction.swift b/Signal/src/ViewControllers/ContextMenus/CustomContextMenus/ContextMenuInteraction.swift index a512f39d75e..e9d0d97442b 100644 --- a/Signal/src/ViewControllers/ContextMenus/CustomContextMenus/ContextMenuInteraction.swift +++ b/Signal/src/ViewControllers/ContextMenus/CustomContextMenus/ContextMenuInteraction.swift @@ -260,14 +260,14 @@ extension ContextMenuInteraction: ContextMenuControllerDelegate, ContextMenuTarg dismissMenu(animated: true, completion: { }) } - func contextMenuTargetedPreviewAccessoryRequestsEmojiPicker( + func contextMenuTargetedPreviewAccessoryRequestsReactionPicker( for message: TSMessage, accessory: ContextMenuTargetedPreviewAccessory, - completion: @escaping (String) -> Void, + completion: @escaping (CustomReactionItem) -> Void, ) { - contextMenuController?.showEmojiSheet(message: message, completion: { emojiString in - self.contextMenuController?.dismissEmojiSheet(animated: true, completion: { - completion(emojiString) + contextMenuController?.showReactionPickerSheet(message: message, completion: { reactionItem in + self.contextMenuController?.dismissReactionPickerSheet(animated: true, completion: { + completion(reactionItem) }) }) } diff --git a/Signal/src/ViewControllers/ContextMenus/CustomContextMenus/ContextMenuReactionBarAccessory.swift b/Signal/src/ViewControllers/ContextMenus/CustomContextMenus/ContextMenuReactionBarAccessory.swift index 7ef246912c3..92e6e3096be 100644 --- a/Signal/src/ViewControllers/ContextMenus/CustomContextMenus/ContextMenuReactionBarAccessory.swift +++ b/Signal/src/ViewControllers/ContextMenus/CustomContextMenus/ContextMenuReactionBarAccessory.swift @@ -9,7 +9,7 @@ import UIKit public class ContextMenuReactionBarAccessory: ContextMenuTargetedPreviewAccessory, MessageReactionPickerDelegate { public let thread: TSThread public let itemViewModel: CVItemViewModelImpl? - public var didSelectReactionHandler: ((TSMessage, String, Bool) -> Void)? // = {(message: TSMessage, reaction: String, isRemoving: Bool) -> Void in } + public var didSelectReactionHandler: ((TSMessage, CustomReactionItem, Bool) -> Void)? private var reactionPicker: MessageReactionPicker private var highlightHoverGestureRecognizer: UIGestureRecognizer? @@ -22,8 +22,12 @@ public class ContextMenuReactionBarAccessory: ContextMenuTargetedPreviewAccessor self.thread = thread self.itemViewModel = itemViewModel + let selectedReaction: CustomReactionItem? = { + guard let reaction = itemViewModel?.reactionState?.localUserReaction else { return nil } + return CustomReactionItem(emoji: reaction.emoji, sticker: reaction.sticker) + }() reactionPicker = MessageReactionPicker( - selectedEmoji: itemViewModel?.reactionState?.localUserEmoji, + selectedReaction: selectedReaction, delegate: nil, style: .contextMenu(allowGlass: true), ) @@ -89,15 +93,16 @@ public class ContextMenuReactionBarAccessory: ContextMenuTargetedPreviewAccessor @discardableResult override func touchLocationInViewDidEnd(locationInView: CGPoint) -> Bool { - // Send focused emoji if needed - if let focusedEmoji = reactionPicker.focusedEmoji { - switch focusedEmoji { + // Send focused reaction if needed + if let focusedReaction = reactionPicker.focusedReaction { + switch focusedReaction { case .more: - didSelectAnyEmoji() - case .emoji(let emoji): - let isRemoving = emoji == self.itemViewModel?.reactionState?.localUserEmoji - if let index = reactionPicker.currentEmojiSet().firstIndex(of: emoji) { - didSelectReaction(reaction: emoji, isRemoving: isRemoving, inPosition: index) + didSelectMore() + case .reaction(let reaction): + let localUserReaction = self.itemViewModel?.reactionState?.localUserReaction + let isRemoving = localUserReaction.map { CustomReactionItem(emoji: $0.emoji, sticker: $0.sticker) } == reaction + if let index = reactionPicker.currentReactionItems().firstIndex(of: reaction) { + didSelectReaction(reaction, isRemoving: isRemoving, inPosition: index) } } return true @@ -109,7 +114,7 @@ public class ContextMenuReactionBarAccessory: ContextMenuTargetedPreviewAccessor // MARK: MessageReactionPickerDelegate func didSelectReaction( - reaction: String, + _ reaction: CustomReactionItem, isRemoving: Bool, inPosition position: Int, ) { @@ -125,7 +130,7 @@ public class ContextMenuReactionBarAccessory: ContextMenuTargetedPreviewAccessor } } - func didSelectAnyEmoji() { + func didSelectMore() { guard let message = itemViewModel?.interaction as? TSMessage else { owsFailDebug("Not sending reaction for unexpected interaction type") return @@ -133,9 +138,10 @@ public class ContextMenuReactionBarAccessory: ContextMenuTargetedPreviewAccessor reactionPicker.playDismissalAnimation(duration: 0.2) { } - self.delegate?.contextMenuTargetedPreviewAccessoryRequestsEmojiPicker(for: message, accessory: self) { emojiString in - let isRemoving = emojiString == self.itemViewModel?.reactionState?.localUserEmoji - self.didSelectReactionHandler?(message, emojiString, isRemoving) + self.delegate?.contextMenuTargetedPreviewAccessoryRequestsReactionPicker(for: message, accessory: self) { reaction in + let localUserReaction = self.itemViewModel?.reactionState?.localUserReaction + let isRemoving = localUserReaction.map { CustomReactionItem(emoji: $0.emoji, sticker: $0.sticker) } == reaction + self.didSelectReactionHandler?(message, reaction, isRemoving) self.delegate?.contextMenuTargetedPreviewAccessoryRequestsDismissal(self, completion: { }) } } diff --git a/Signal/src/ViewControllers/HomeView/Stories/Replies & Views Sheets/Group Reply Sheet/StoryGroupReplyCell.swift b/Signal/src/ViewControllers/HomeView/Stories/Replies & Views Sheets/Group Reply Sheet/StoryGroupReplyCell.swift index 5f31490674b..e6fa7fc38d4 100644 --- a/Signal/src/ViewControllers/HomeView/Stories/Replies & Views Sheets/Group Reply Sheet/StoryGroupReplyCell.swift +++ b/Signal/src/ViewControllers/HomeView/Stories/Replies & Views Sheets/Group Reply Sheet/StoryGroupReplyCell.swift @@ -4,10 +4,17 @@ // import BonMot +import SDWebImage import SignalServiceKit import SignalUI +protocol StoryGroupReplyCellDelegate: AnyObject { + func storyGroupReplyCellDidTapStickerPack(_ cell: StoryGroupReplyCell, stickerPackInfo: StickerPackInfo) + func storyGroupReplyCellDidTapDownloadSticker(_ cell: StoryGroupReplyCell) +} + class StoryGroupReplyCell: UITableViewCell { + weak var cellDelegate: StoryGroupReplyCellDelegate? lazy var avatarView = ConversationAvatarView(sizeClass: .twentyEight, localUserDisplayMode: .asUser, useAutolayout: true) lazy var messageLabel: UILabel = { let label = UILabel() @@ -28,6 +35,32 @@ class StoryGroupReplyCell: UITableViewCell { return label }() + lazy var reactionStickerImageView: SDAnimatedImageView = { + let imageView = SDAnimatedImageView() + imageView.contentMode = .scaleAspectFit + imageView.clipsToBounds = true + imageView.autoSetDimensions(to: CGSize(square: 28)) + return imageView + }() + + lazy var reactionStickerSpinner: UIActivityIndicatorView = { + let spinner = UIActivityIndicatorView(style: .medium) + spinner.tintColor = .ows_gray25 + spinner.autoSetDimensions(to: CGSize(square: 28)) + return spinner + }() + + lazy var reactionStickerDownloadIcon: UIImageView = { + let imageView = UIImageView() + imageView.contentMode = .scaleAspectFit + imageView.tintColor = .ows_gray25 + imageView.autoSetDimensions(to: CGSize(square: 28)) + return imageView + }() + + private var stickerAttachmentId: Attachment.IDType? + private var stickerInfo: StickerInfo? + lazy var bubbleView: UIView = { let view = UIView() view.backgroundColor = .ows_gray80 @@ -236,6 +269,12 @@ class StoryGroupReplyCell: UITableViewCell { hStack.addArrangedSubview(.hStretchingSpacer()) if cellType.isReaction { + hStack.addArrangedSubview(reactionStickerImageView) + reactionStickerImageView.isHiddenInStackView = true + hStack.addArrangedSubview(reactionStickerSpinner) + reactionStickerSpinner.isHiddenInStackView = true + hStack.addArrangedSubview(reactionStickerDownloadIcon) + reactionStickerDownloadIcon.isHiddenInStackView = true hStack.addArrangedSubview(reactionLabel) } @@ -270,10 +309,16 @@ class StoryGroupReplyCell: UITableViewCell { private var item: StoryGroupReplyViewItem? private var spoilerState: SpoilerRenderState? + private var stickerImageCache: StickerReactionImageCache? - func configure(with item: StoryGroupReplyViewItem, spoilerState: SpoilerRenderState) { + func configure( + with item: StoryGroupReplyViewItem, + spoilerState: SpoilerRenderState, + stickerImageCache: StickerReactionImageCache?, + ) { self.item = item self.spoilerState = spoilerState + self.stickerImageCache = stickerImageCache if cellType.hasAuthor { authorNameLabel.textColor = item.authorColor authorNameLabel.text = item.authorDisplayName @@ -286,7 +331,7 @@ class StoryGroupReplyCell: UITableViewCell { } if cellType.isReaction { - reactionLabel.text = item.reactionEmoji + configureStickerReaction(item: item, stickerImageCache: stickerImageCache) } configureBodyAndFooter(for: item, spoilerState: spoilerState) @@ -545,52 +590,139 @@ class StoryGroupReplyCell: UITableViewCell { override func prepareForReuse() { super.prepareForReuse() + cellDelegate = nil + stickerImageCache = nil spoilerState = nil item = nil messageSpoilerConfigBuilder.text = nil messageSpoilerConfigBuilder.displayConfig = nil + if cellType.isReaction { + stickerAttachmentId = nil + stickerInfo = nil + reactionStickerImageView.image = nil + reactionStickerSpinner.stopAnimating() + reactionStickerDownloadIcon.image = nil + reactionLabel.isHiddenInStackView = false + reactionStickerImageView.isHiddenInStackView = true + reactionStickerSpinner.isHiddenInStackView = true + reactionStickerDownloadIcon.isHiddenInStackView = true + } + } + + private func configureStickerReaction( + item: StoryGroupReplyViewItem, + stickerImageCache: StickerReactionImageCache?, + ) { + self.stickerInfo = item.reactionStickerInfo + if let stream = item.reactionSticker?.asStream(), let stickerImageCache { + let attachmentId = stream.id + stickerAttachmentId = attachmentId + + Task { [weak self] in + let image = await stickerImageCache.image(for: stream) + guard let self, self.stickerAttachmentId == attachmentId else { return } + self.reactionStickerImageView.image = image + self.reactionStickerImageView.isHiddenInStackView = false + self.reactionLabel.isHiddenInStackView = true + self.reactionStickerSpinner.stopAnimating() + self.reactionStickerSpinner.isHiddenInStackView = true + } + + reactionLabel.text = nil + reactionLabel.isHiddenInStackView = true + reactionStickerImageView.isHiddenInStackView = true + reactionStickerSpinner.isHiddenInStackView = true + } else { + switch item.reactionStickerDownloadState { + case .enqueuedOrDownloading: + reactionStickerSpinner.startAnimating() + reactionStickerSpinner.isHiddenInStackView = false + reactionLabel.isHiddenInStackView = true + reactionStickerImageView.isHiddenInStackView = true + reactionStickerDownloadIcon.isHiddenInStackView = true + case .some(.none): + reactionStickerDownloadIcon.image = Theme.iconImage(.arrowDown, isDarkThemeEnabled: true) + reactionStickerDownloadIcon.isHiddenInStackView = false + reactionLabel.isHiddenInStackView = true + reactionStickerImageView.isHiddenInStackView = true + reactionStickerSpinner.isHiddenInStackView = true + case .failed: + reactionStickerDownloadIcon.image = Theme.iconImage(.refresh, isDarkThemeEnabled: true) + reactionStickerDownloadIcon.isHiddenInStackView = false + reactionLabel.isHiddenInStackView = true + reactionStickerImageView.isHiddenInStackView = true + reactionStickerSpinner.isHiddenInStackView = true + case nil: + reactionLabel.text = item.reactionEmoji + reactionLabel.isHiddenInStackView = false + reactionStickerImageView.isHiddenInStackView = true + reactionStickerSpinner.isHiddenInStackView = true + reactionStickerDownloadIcon.isHiddenInStackView = true + } + } } @objc func handleTap(_ recognizer: UITapGestureRecognizer) { let labelLocation = recognizer.location(in: messageLabel) - guard + if let item, let spoilerState, messageLabel.bounds.contains(labelLocation), let tapIndex = messageLabel.characterIndex(of: labelLocation) - else { - return - } - guard let messageText: CVTextValue = item.displayableText?.displayTextValue else { - return - } + { + guard let messageText: CVTextValue = item.displayableText?.displayTextValue else { + return + } - switch messageText { - case .text, .attributedText: - return - case .messageBody(let body): - let revealedSpoilerIds = spoilerState.revealState.revealedSpoilerIds( - interactionIdentifier: item.interactionIdentifier, - ) - for tappableItem in body.tappableItems(revealedSpoilerIds: revealedSpoilerIds, dataDetector: nil) { - switch tappableItem { - case .data, .mention: - continue - case .unrevealedSpoiler(let unrevealedSpoiler): - if unrevealedSpoiler.range.contains(tapIndex) { - spoilerState.revealState.setSpoilerRevealed( - withID: unrevealedSpoiler.id, - interactionIdentifier: item.interactionIdentifier, - ) - // Re-configure. This is ok because revealing the spoiler - // doesn't change the sizing. - configure(with: item, spoilerState: spoilerState) - return + switch messageText { + case .text, .attributedText: + return + case .messageBody(let body): + let revealedSpoilerIds = spoilerState.revealState.revealedSpoilerIds( + interactionIdentifier: item.interactionIdentifier, + ) + for tappableItem in body.tappableItems(revealedSpoilerIds: revealedSpoilerIds, dataDetector: nil) { + switch tappableItem { + case .data, .mention: + continue + case .unrevealedSpoiler(let unrevealedSpoiler): + if unrevealedSpoiler.range.contains(tapIndex) { + spoilerState.revealState.setSpoilerRevealed( + withID: unrevealedSpoiler.id, + interactionIdentifier: item.interactionIdentifier, + ) + // Re-configure. This is ok because revealing the spoiler + // doesn't change the sizing. + configure( + with: item, + spoilerState: spoilerState, + stickerImageCache: stickerImageCache, + ) + return + } } } } } + + let stickerLocation = recognizer.location(in: reactionStickerImageView) + if + let stickerInfo, + !reactionStickerImageView.isHiddenInStackView, + reactionStickerImageView.bounds.contains(stickerLocation) + { + cellDelegate?.storyGroupReplyCellDidTapStickerPack(self, stickerPackInfo: stickerInfo.packInfo) + return + } + + let downloadIconLocation = recognizer.location(in: reactionStickerDownloadIcon) + if + !reactionStickerDownloadIcon.isHiddenInStackView, + reactionStickerDownloadIcon.bounds.contains(downloadIconLocation) + { + cellDelegate?.storyGroupReplyCellDidTapDownloadSticker(self) + } } // MARK: - Spoiler Animation diff --git a/Signal/src/ViewControllers/HomeView/Stories/Replies & Views Sheets/Group Reply Sheet/StoryGroupReplyLoader.swift b/Signal/src/ViewControllers/HomeView/Stories/Replies & Views Sheets/Group Reply Sheet/StoryGroupReplyLoader.swift index 51ca2964298..0c76ae82aa3 100644 --- a/Signal/src/ViewControllers/HomeView/Stories/Replies & Views Sheets/Group Reply Sheet/StoryGroupReplyLoader.swift +++ b/Signal/src/ViewControllers/HomeView/Stories/Replies & Views Sheets/Group Reply Sheet/StoryGroupReplyLoader.swift @@ -21,6 +21,8 @@ class StoryGroupReplyLoader { didSet { AssertIsOnMainThread() } } + public var attachmentIds = Set() + var numberOfRows: Int { replyUniqueIds.count } private(set) var oldestLoadedRow: Int? @@ -197,6 +199,10 @@ class StoryGroupReplyLoader { let replyUniqueIds = messageBatchFetcher.uniqueIdsAndRowIds.map { $0.uniqueId } let oldestLoadedRow = messageLoader.loadedInteractions.first.flatMap { replyUniqueIds.firstIndex(of: $0.uniqueId) } let newestLoadedRow = messageLoader.loadedInteractions.last.flatMap { replyUniqueIds.firstIndex(of: $0.uniqueId) } + var newAttachmentIds = Set() + newReplyItems.lazy + .compactMap(\.value.reactionSticker?.id) + .forEach({ newAttachmentIds.insert($0) }) DispatchQueue.main.async { let wasScrolledToBottom = self.isScrolledToBottom @@ -205,6 +211,7 @@ class StoryGroupReplyLoader { self.newestLoadedRow = newestLoadedRow self.replyUniqueIds = replyUniqueIds self.replyItems = newReplyItems + self.attachmentIds = newAttachmentIds self.tableView?.reloadData() if wasScrolledToBottom { self.scrollToBottomOfLoadWindow(animated: true) } diff --git a/Signal/src/ViewControllers/HomeView/Stories/Replies & Views Sheets/Group Reply Sheet/StoryGroupReplyViewController.swift b/Signal/src/ViewControllers/HomeView/Stories/Replies & Views Sheets/Group Reply Sheet/StoryGroupReplyViewController.swift index 4752379083c..ec20e981e04 100644 --- a/Signal/src/ViewControllers/HomeView/Stories/Replies & Views Sheets/Group Reply Sheet/StoryGroupReplyViewController.swift +++ b/Signal/src/ViewControllers/HomeView/Stories/Replies & Views Sheets/Group Reply Sheet/StoryGroupReplyViewController.swift @@ -16,6 +16,7 @@ class StoryGroupReplyViewController: OWSViewController, StoryReplySheet { private(set) lazy var tableView = UITableView() private let spoilerState: SpoilerRenderState + let stickerImageCache = StickerReactionImageCache() let bottomBar = UIView() private(set) lazy var inputToolbar = StoryReplyInputToolbar(isGroupStory: true, spoilerState: spoilerState) @@ -58,6 +59,28 @@ class StoryGroupReplyViewController: OWSViewController, StoryReplySheet { super.init() DependenciesBridge.shared.databaseChangeObserver.appendDatabaseChangeDelegate(self) + + NotificationCenter.default.addObserver( + self, + selector: #selector(attachmentDownloadProgress(_:)), + name: AttachmentDownloads.attachmentDownloadProgressNotification, + object: nil, + ) + } + + @objc + private func attachmentDownloadProgress(_ notification: Notification) { + guard + let attachmentId = notification + .userInfo?[AttachmentDownloads.attachmentDownloadAttachmentIDKey] + as? Attachment.IDType, + replyLoader?.attachmentIds.contains(attachmentId) == true, + let progress = notification + .userInfo?[AttachmentDownloads.attachmentDownloadProgressKey] + as? NSNumber, + progress.floatValue >= 1.0 + else { return } + replyLoader?.reload() } fileprivate var replyLoader: StoryGroupReplyLoader? @@ -256,7 +279,8 @@ extension StoryGroupReplyViewController: UITableViewDataSource { } let cell = tableView.dequeueReusableCell(withIdentifier: item.cellType.rawValue, for: indexPath) as! StoryGroupReplyCell - cell.configure(with: item, spoilerState: spoilerState) + cell.cellDelegate = self + cell.configure(with: item, spoilerState: spoilerState, stickerImageCache: stickerImageCache) return cell } @@ -272,6 +296,31 @@ extension StoryGroupReplyViewController: UITableViewDataSource { } } +extension StoryGroupReplyViewController: StoryGroupReplyCellDelegate { + func storyGroupReplyCellDidTapStickerPack(_ cell: StoryGroupReplyCell, stickerPackInfo: StickerPackInfo) { + let packView = StickerPackViewController(stickerPackInfo: stickerPackInfo) + packView.present(from: self, animated: true) + } + + func storyGroupReplyCellDidTapDownloadSticker(_ cell: StoryGroupReplyCell) { + guard + let indexPath = tableView.indexPath(for: cell), + let item = replyLoader?.replyItem(for: indexPath) + else { return } + + SSKEnvironment.shared.databaseStorageRef.write { tx in + guard let message = TSMessage.fetchMessageViaCache(uniqueId: item.interactionUniqueId, transaction: tx) else { + return + } + DependenciesBridge.shared.attachmentDownloadManager.enqueueDownloadOfAttachmentsForMessage( + message, + priority: .userInitiated, + tx: tx, + ) + } + } +} + extension StoryGroupReplyViewController: StoryReplyInputToolbarDelegate { func storyReplyInputToolbarDidBeginEditing(_ storyReplyInputToolbar: StoryReplyInputToolbar) { delegate?.storyGroupReplyViewControllerDidBeginEditing(self) diff --git a/Signal/src/ViewControllers/HomeView/Stories/Replies & Views Sheets/Group Reply Sheet/StoryGroupReplyViewItem.swift b/Signal/src/ViewControllers/HomeView/Stories/Replies & Views Sheets/Group Reply Sheet/StoryGroupReplyViewItem.swift index 8c7cdf6a645..711d8eb5266 100644 --- a/Signal/src/ViewControllers/HomeView/Stories/Replies & Views Sheets/Group Reply Sheet/StoryGroupReplyViewItem.swift +++ b/Signal/src/ViewControllers/HomeView/Stories/Replies & Views Sheets/Group Reply Sheet/StoryGroupReplyViewItem.swift @@ -13,6 +13,9 @@ class StoryGroupReplyViewItem { let interactionUniqueId: String let displayableText: DisplayableText? let reactionEmoji: String? + let reactionSticker: Attachment? + let reactionStickerInfo: StickerInfo? + let reactionStickerDownloadState: AttachmentDownloadState? let wasRemotelyDeleted: Bool let receivedAtTimestamp: UInt64 let authorDisplayName: String? @@ -54,9 +57,35 @@ class StoryGroupReplyViewItem { if let reactionEmoji = message.storyReactionEmoji { self.cellType = .init(kind: .reaction) self.reactionEmoji = reactionEmoji + + let attachmentStore = DependenciesBridge.shared.attachmentStore + if + let messageRowId = message.sqliteRowId, + let attachment = attachmentStore.fetchAnyReferencedAttachment( + for: .messageSticker(messageRowId: messageRowId), + tx: transaction, + ), + let stickerInfo = message.messageSticker?.info + { + self.reactionSticker = attachment.attachment + self.reactionStickerInfo = stickerInfo + if attachment.attachment.asStream() == nil { + self.reactionStickerDownloadState = attachment.attachment + .asAnyPointer()?.downloadState(tx: transaction) + } else { + self.reactionStickerDownloadState = nil + } + } else { + self.reactionSticker = nil + self.reactionStickerInfo = nil + self.reactionStickerDownloadState = nil + } } else { self.cellType = .init(kind: .text) self.reactionEmoji = nil + self.reactionSticker = nil + self.reactionStickerInfo = nil + self.reactionStickerDownloadState = nil } } } diff --git a/Signal/src/ViewControllers/HomeView/Stories/Replies & Views Sheets/StoryReplyInputToolbar.swift b/Signal/src/ViewControllers/HomeView/Stories/Replies & Views Sheets/StoryReplyInputToolbar.swift index a6dfa65b6dd..db2b9cd8c0c 100644 --- a/Signal/src/ViewControllers/HomeView/Stories/Replies & Views Sheets/StoryReplyInputToolbar.swift +++ b/Signal/src/ViewControllers/HomeView/Stories/Replies & Views Sheets/StoryReplyInputToolbar.swift @@ -212,7 +212,7 @@ class StoryReplyInputToolbar: UIView { textView.resignFirstResponder() } - private lazy var reactionPicker: MessageReactionPicker = MessageReactionPicker(selectedEmoji: nil, delegate: delegate, style: .inline) + private lazy var reactionPicker: MessageReactionPicker = MessageReactionPicker(selectedReaction: nil, delegate: delegate, style: .inline) private lazy var placeholderTextView: UITextView = { let placeholderTextView = buildTextView() diff --git a/Signal/src/ViewControllers/HomeView/Stories/Replies & Views Sheets/StoryReplySheet.swift b/Signal/src/ViewControllers/HomeView/Stories/Replies & Views Sheets/StoryReplySheet.swift index 2796ca8233c..41a602c9425 100644 --- a/Signal/src/ViewControllers/HomeView/Stories/Replies & Views Sheets/StoryReplySheet.swift +++ b/Signal/src/ViewControllers/HomeView/Stories/Replies & Views Sheets/StoryReplySheet.swift @@ -5,6 +5,7 @@ import Foundation import LibSignalClient +import SDWebImage import SignalServiceKit import SignalUI import UIKit @@ -24,6 +25,7 @@ extension StoryReplySheet { func tryToSendMessage( _ builder: TSOutgoingMessageBuilder, messageBody: ValidatedMessageBody?, + messageStickerDraft: MessageStickerDataSource? ) { guard let thread else { return owsFailDebug("Unexpectedly missing thread") @@ -33,7 +35,11 @@ extension StoryReplySheet { guard !isThreadBlocked else { BlockListUIUtils.showUnblockThreadActionSheet(thread, from: self) { [weak self] isBlocked in guard !isBlocked else { return } - self?.tryToSendMessage(builder, messageBody: messageBody) + self?.tryToSendMessage( + builder, + messageBody: messageBody, + messageStickerDraft: messageStickerDraft + ) } return } @@ -51,7 +57,11 @@ extension StoryReplySheet { forceDarkTheme: true, completion: { [weak self] didConfirmIdentity in guard didConfirmIdentity else { return } - self?.tryToSendMessage(builder, messageBody: messageBody) + self?.tryToSendMessage( + builder, + messageBody: messageBody, + messageStickerDraft: messageStickerDraft + ) }, ) else { return } @@ -76,6 +86,7 @@ extension StoryReplySheet { let unpreparedMessage = UnpreparedOutgoingMessage.forMessage( builder.build(transaction: transaction), body: messageBody, + messageStickerDraft: messageStickerDraft ) guard let preparedMessage = try? unpreparedMessage.prepare(tx: transaction) else { owsFailDebug("Failed to prepare message") @@ -95,13 +106,56 @@ extension StoryReplySheet { } } - func tryToSendReaction(_ reaction: String) { - owsAssertDebug(reaction.isSingleEmoji) + func tryToSendReaction(_ reaction: CustomReactionItem) async { + owsAssertDebug(reaction.emoji.isSingleEmoji) guard let thread else { return owsFailDebug("Unexpectedly missing thread") } + let stickerDataSource: MessageStickerDataSource? + let stickerImage: UIImage? + if let stickerInfo = reaction.sticker { + let stickerMetadata = SSKEnvironment.shared.databaseStorageRef.read { tx in + StickerManager.installedStickerMetadata(stickerInfo: stickerInfo, transaction: tx) + } + + guard let stickerMetadata else { + owsFailDebug("Could not find installed sticker metadata for story reaction") + return + } + + guard let stickerData = try? stickerMetadata.readStickerData() else { + owsFailDebug("Could not read sticker data for story reaction") + return + } + + stickerImage = SDAnimatedImage(data: stickerData) + + let draft = MessageStickerDraft( + info: stickerInfo, + stickerData: stickerData, + stickerType: stickerMetadata.stickerType, + emoji: reaction.emoji, + ) + + do { + stickerDataSource = try await DependenciesBridge.shared.messageStickerManager + .buildDataSource(fromDraft: draft) + } catch { + owsFailDebug("Failed to validate sticker for story reaction: \(error)") + return + } + } else { + stickerDataSource = nil + stickerImage = nil + } + + owsAssertDebug( + !storyMessage.authorAddress.isSystemStoryAddress, + "Should be impossible to reply to system stories" + ) + owsAssertDebug( !storyMessage.authorAddress.isSystemStoryAddress, "Should be impossible to reply to system stories", @@ -111,28 +165,37 @@ extension StoryReplySheet { thread: thread, storyAuthorAci: storyMessage.authorAci, storyTimestamp: storyMessage.timestamp, - storyReactionEmoji: reaction, + storyReactionEmoji: reaction.emoji, ) - tryToSendMessage(builder, messageBody: nil) + tryToSendMessage(builder, messageBody: nil, messageStickerDraft: stickerDataSource) - ReactionFlybyAnimation(reaction: reaction).present(from: self) + Task { + await ReactionFlybyAnimation( + reaction: reaction.emoji, + stickerImage: stickerImage, + ).present(from: self) + } } } // MARK: - MessageReactionPickerDelegate extension StoryReplySheet { - func didSelectReaction(reaction: String, isRemoving: Bool, inPosition position: Int) { - tryToSendReaction(reaction) + func didSelectReaction(_ reaction: CustomReactionItem, isRemoving: Bool, inPosition position: Int) { + Task { + await tryToSendReaction(reaction) + } } - func didSelectAnyEmoji() { + func didSelectMore() { // nil is intentional, the message is for showing other reactions already // on the message, which we don't wanna do for stories. - let sheet = EmojiPickerSheet(message: nil) { [weak self] selectedEmoji in - guard let selectedEmoji else { return } - self?.tryToSendReaction(selectedEmoji.rawValue) + let sheet = ReactionPickerSheet(message: nil) { [weak self] reaction in + guard let self, let reaction else { return } + Task { + await self.tryToSendReaction(reaction) + } } sheet.overrideUserInterfaceStyle = .dark present(sheet, animated: true) @@ -169,7 +232,7 @@ extension StoryReplySheet { storyTimestamp: storyMessage.timestamp, ) - tryToSendMessage(builder, messageBody: messageBody) + tryToSendMessage(builder, messageBody: messageBody, messageStickerDraft: nil) } func storyReplyInputToolbarDidBeginEditing(_ storyReplyInputToolbar: StoryReplyInputToolbar) {} diff --git a/Signal/src/ViewControllers/MessageReactionPicker.swift b/Signal/src/ViewControllers/MessageReactionPicker.swift index fb105164e4a..d2601ca591c 100644 --- a/Signal/src/ViewControllers/MessageReactionPicker.swift +++ b/Signal/src/ViewControllers/MessageReactionPicker.swift @@ -4,12 +4,13 @@ // import Foundation +import SDWebImage import SignalServiceKit import SignalUI protocol MessageReactionPickerDelegate: AnyObject { - func didSelectReaction(reaction: String, isRemoving: Bool, inPosition position: Int) - func didSelectAnyEmoji() + func didSelectReaction(_ reaction: CustomReactionItem, isRemoving: Bool, inPosition position: Int) + func didSelectMore() } class MessageReactionPicker: UIStackView { @@ -34,62 +35,99 @@ class MessageReactionPicker: UIStackView { var reactionHeight: CGFloat { return pickerDiameter - (pickerPadding * 2) } var selectedBackgroundHeight: CGFloat { return pickerDiameter - 4 } - enum Emoji: Equatable { - case emoji(String) + enum Reaction: Equatable { + case reaction(CustomReactionItem) case more } private enum Button: Equatable { - case emoji(emoji: String, button: OWSFlatButton) + case reaction(item: CustomReactionItem, button: OWSFlatButton) + case stickerReaction(item: CustomReactionItem, button: OWSButton, imageView: SDAnimatedImageView) case more(UIView) - var emoji: Emoji { + var focusedReaction: Reaction { switch self { - case .emoji(let emoji, _): .emoji(emoji) + case .reaction(let item, _): .reaction(item) + case .stickerReaction(let item, _, _): .reaction(item) case .more: .more } } - var emojiButton: OWSFlatButton? { + var reactionItem: CustomReactionItem? { switch self { - case .emoji(_, let button): button + case .reaction(let item, _): item + case .stickerReaction(let item, _, _): item case .more: nil } } + var emojiButton: OWSFlatButton? { + switch self { + case .reaction(_, let button): button + default: nil + } + } + + var stickerImageView: SDAnimatedImageView? { + switch self { + case .stickerReaction(_, _, let imageView): imageView + default: nil + } + } + var view: UIView { switch self { - case let .emoji(_, button): button + case let .reaction(_, button): button + case let .stickerReaction(_, button, _): button case let .more(button): button } } + + static func == (lhs: Button, rhs: Button) -> Bool { + switch (lhs, rhs) { + case (.reaction(let l, _), .reaction(let r, _)): return l == r + case (.stickerReaction(let l, _, _), .stickerReaction(let r, _, _)): return l == r + case (.more, .more): return true + default: return false + } + } } private let emojiStackView: UIStackView = UIStackView() - private var buttonForEmoji = [Button]() - private var selectedEmoji: EmojiWithSkinTones? + private var buttonForReaction = [Button]() + private(set) var selectedReaction: CustomReactionItem? private var backgroundView: UIView? private let style: Style + private let allowStickers: Bool - /// The individual emoji buttons and the Any button from `buttonForEmoji` + /// The individual reaction buttons and the Any button from `buttonForReaction` private var buttonViews: [UIView] { - return buttonForEmoji.map(\.view) + return buttonForReaction.map(\.view) } + /// If allowStickers is false, and a sticker is set as one of the default displayed + /// "custom reaction set" items, will fall back to that sticker's emoji. (And will not + /// show the sticker picker tab). init( - selectedEmoji: String?, + selectedReaction: CustomReactionItem?, delegate: MessageReactionPickerDelegate?, style: Style, + allowStickers: Bool = true, ) { - if let selectedEmoji { - self.selectedEmoji = EmojiWithSkinTones(rawValue: selectedEmoji) - owsAssertDebug(self.selectedEmoji != nil) + if let selectedReaction { + if EmojiWithSkinTones(rawValue: selectedReaction.emoji) == nil { + owsFailDebug("Invalid (unknown) preselected emoji") + self.selectedReaction = nil + } else { + self.selectedReaction = selectedReaction + } } else { - self.selectedEmoji = nil + self.selectedReaction = nil } self.delegate = delegate self.style = style + self.allowStickers = allowStickers super.init(frame: .zero) @@ -146,16 +184,16 @@ class MessageReactionPicker: UIStackView { trailing: style.isInline ? 4 : pickerPadding, ) - let emojiSet = currentEmojiSetOnDisk(style: style) + let reactionSet = currentReactionSetOnDisk(style: style) - var addAnyButton = !style.isConfigure + var addMoreButton = !style.isConfigure if !style.isConfigure, - let selectedEmoji = self.selectedEmoji, - nil == emojiSet.firstIndex(of: selectedEmoji) + let selected = self.selectedReaction, + nil == reactionSet.firstIndex(of: selected) { - addAnyButton = false + addMoreButton = false } switch style { @@ -170,40 +208,36 @@ class MessageReactionPicker: UIStackView { self.addArrangedSubview(scrollView) } - for (index, emoji) in emojiSet.enumerated() { - let button = OWSFlatButton() - button.autoSetDimensions(to: CGSize(square: reactionHeight)) - button.setTitle( - title: emoji.rawValue, - font: .systemFont(ofSize: reactionFontSize), - titleColor: .Signal.label, - ) - button.setPressedBlock { [weak self] in - // current title of button may have changed in the meantime - if let currentEmoji = button.button.title(for: .normal) { - ImpactHapticFeedback.impactOccurred(style: .light) - self?.delegate?.didSelectReaction(reaction: currentEmoji, isRemoving: currentEmoji == self?.selectedEmoji?.rawValue, inPosition: index) - } + for (index, item) in reactionSet.enumerated() { + let buttonView: UIView + if allowStickers, item.isStickerReaction, let stickerInfo = item.sticker { + let (button, imageView) = buildStickerButton(item: item, stickerInfo: stickerInfo, index: index) + buttonView = button + buttonForReaction.append(.stickerReaction(item: item, button: button, imageView: imageView)) + emojiStackView.addArrangedSubview(button) + } else { + let button = buildEmojiButton(item: item, index: index) + buttonForReaction.append(.reaction(item: item, button: button)) + emojiStackView.addArrangedSubview(button) + buttonView = button } - buttonForEmoji.append(.emoji(emoji: emoji.rawValue, button: button)) - emojiStackView.addArrangedSubview(button) - // Add a circle behind the currently selected emoji - if self.selectedEmoji == emoji { + // Add a circle behind the currently selected reaction + if self.selectedReaction == item { let selectedBackgroundView = UIView() selectedBackgroundView.backgroundColor = .Signal.secondaryFill selectedBackgroundView.clipsToBounds = true selectedBackgroundView.layer.cornerRadius = selectedBackgroundHeight / 2 backgroundContentView?.addSubview(selectedBackgroundView) selectedBackgroundView.autoSetDimensions(to: CGSize(square: selectedBackgroundHeight)) - selectedBackgroundView.autoAlignAxis(.horizontal, toSameAxisOf: button) - selectedBackgroundView.autoAlignAxis(.vertical, toSameAxisOf: button) + selectedBackgroundView.autoAlignAxis(.horizontal, toSameAxisOf: buttonView) + selectedBackgroundView.autoAlignAxis(.vertical, toSameAxisOf: buttonView) } } - if addAnyButton { + if addMoreButton { let button = OWSButton { [weak self] in - self?.delegate?.didSelectAnyEmoji() + self?.delegate?.didSelectMore() } button.autoSetDimensions(to: CGSize(square: reactionHeight)) button.dimsWhenHighlighted = true @@ -234,87 +268,225 @@ class MessageReactionPicker: UIStackView { backgroundBackground.autoCenterInSuperview() backgroundBackground.isUserInteractionEnabled = false - buttonForEmoji.append(.more(button)) + buttonForReaction.append(.more(button)) self.addArrangedSubview(button) } } - private func currentEmojiSetOnDisk(style: Style) -> [EmojiWithSkinTones] { - var emojiSet = SSKEnvironment.shared.databaseStorageRef.read { transaction in - let customSetStrings = ReactionManager.customEmojiSet(transaction: transaction) ?? [] - let customSet = customSetStrings.lazy.map { EmojiWithSkinTones(rawValue: $0) } + private func buildEmojiButton( + item: CustomReactionItem, + index: Int + ) -> OWSFlatButton { + let button = OWSFlatButton() + button.autoSetDimensions(to: CGSize(square: reactionHeight)) + button.setTitle( + title: item.emoji, + font: .systemFont(ofSize: reactionFontSize), + titleColor: .Signal.label, + ) + button.setPressedBlock { [weak self, weak button] in + guard let self, let currentEmoji = button?.button.title(for: .normal) else { return } + ImpactHapticFeedback.impactOccurred(style: .light) + let reaction = CustomReactionItem(emoji: currentEmoji, sticker: nil) + let isRemoving = self.selectedReaction == reaction + if self.allowStickers { + self.delegate?.didSelectReaction( + reaction, + isRemoving: isRemoving, + inPosition: index) + } else { + self.delegate?.didSelectReaction( + CustomReactionItem(emoji: reaction.emoji, sticker: nil), + isRemoving: isRemoving, + inPosition: index + ) + } + } + return button + } + + private func buildStickerButton( + item: CustomReactionItem, + stickerInfo: StickerInfo, + index: Int, + ) -> (OWSButton, SDAnimatedImageView) { + let button = OWSButton { [weak self] in + guard let self else { return } + ImpactHapticFeedback.impactOccurred(style: .light) + let isRemoving = self.selectedReaction == item + self.delegate?.didSelectReaction(item, isRemoving: isRemoving, inPosition: index) + } + button.autoSetDimensions(to: CGSize(square: reactionHeight)) + button.dimsWhenHighlighted = true + + let imageView = SDAnimatedImageView() + imageView.contentMode = .scaleAspectFit + imageView.clipsToBounds = true + button.addSubview(imageView) + imageView.autoCenterInSuperview() + let imageSize: CGFloat = reactionHeight - 4 + imageView.autoSetDimensions(to: CGSize(square: imageSize)) + + loadStickerImage(stickerInfo: stickerInfo, into: imageView, index: index) + + return (button, imageView) + } + + private func loadStickerImage( + stickerInfo: StickerInfo, + into imageView: SDAnimatedImageView, + index: Int + ) { + Task { [weak self, weak imageView] in + let image: UIImage? = SSKEnvironment.shared.databaseStorageRef.read { tx in + guard + let metadata = StickerManager.installedStickerMetadata(stickerInfo: stickerInfo, transaction: tx), + let data = try? metadata.readStickerData() + else { + return nil + } + return SDAnimatedImage(data: data) + } + await MainActor.run { + guard + let self, + let imageView, + let currentSticker = self.buttonForReaction[safe: index]?.reactionItem?.sticker, + currentSticker.packId == stickerInfo.packId, + currentSticker.stickerId == stickerInfo.stickerId + else { + return + } + imageView.image = image + } + } + } + + private func currentReactionSetOnDisk(style: Style) -> [CustomReactionItem] { + var reactionSet = SSKEnvironment.shared.databaseStorageRef.read { transaction in + let customSet = ReactionManager.customReactionSet(tx: transaction) + ?? ReactionManager.defaultCustomReactionSet // Any holes or invalid choices are filled in with the default reactions. // This could happen if another platform supports an emoji that we don't yet (say, because there's a newer // version of Unicode), or if a bug results in a string that's not valid at all, or fewer entries than the // default. - let savedReactions = ReactionManager.defaultEmojiSet.enumerated().map { i, defaultEmoji -> EmojiWithSkinTones in + let savedReactions = ReactionManager.defaultCustomReactionSet.enumerated().map { i, defaultReaction -> CustomReactionItem in // Treat "out-of-bounds index" and "in-bounds but not valid" the same way. if let customReaction = customSet[safe: i] ?? nil { return customReaction } else { - return EmojiWithSkinTones(rawValue: defaultEmoji)! + return defaultReaction } } - var recentReactions = [EmojiWithSkinTones]() + var recentReactions = [CustomReactionItem]() // Add recent emoji to inline picker if style.isInline { - let savedReactionSet = Set(savedReactions) - - recentReactions = EmojiPickerCollectionView - .getRecentEmoji(tx: transaction) - .filter { !savedReactionSet.contains($0) } + var savedReactionSet = Set(savedReactions) + StickerManager + .getRecentReactions(tx: transaction) + .forEach { + if savedReactionSet.insert($0).inserted { + recentReactions.append($0) + } + } + + // For backwards compatibility, fill in with recent emoji and stickers. + var remainingCount = StickerManager.maxRecentReactionCount - recentReactions.count + if remainingCount > 0 { + let recentEmoji = EmojiPickerCollectionView + .getRecentEmoji(tx: transaction) + .lazy + .map { CustomReactionItem(emoji: $0.rawValue, sticker: nil) } + .filter { !savedReactionSet.contains($0) } + .prefix(remainingCount) + recentReactions.append(contentsOf: recentEmoji) + } + remainingCount = StickerManager.maxRecentReactionCount - recentReactions.count + if remainingCount > 0 { + let recentStickers = StickerManager + .recentStickers(transaction: transaction) + .lazy + .map { + CustomReactionItem( + emoji: $0.emojiString?.nilIfEmpty ?? StickerManager.fallbackStickerEmoji, + sticker: $0.info + ) + } + .filter { !savedReactionSet.contains($0) } + .prefix(remainingCount) + recentReactions.append(contentsOf: recentStickers) + } } return savedReactions + recentReactions } - if !style.isConfigure, let selectedEmoji = self.selectedEmoji { - // If the local user reacted with any of the default emoji set, - // we should show it in the normal place in the picker bar. - // NOTE: This used to match independent of skin tone, but we decided to drop that behavior. - if let index = emojiSet.firstIndex(of: selectedEmoji) { - emojiSet[index] = selectedEmoji + if !style.isConfigure, let selected = self.selectedReaction { + if let index = reactionSet.firstIndex(of: selected) { + reactionSet[index] = selected } else { - emojiSet.append(selectedEmoji) + reactionSet.append(selected) } } - return emojiSet + return reactionSet } - func updateReactionPickerEmojis() { - let currentEmojis = currentEmojiSetOnDisk(style: self.style) - for (index, emoji) in self.currentEmojiSet().enumerated() { - if let newEmoji = currentEmojis[safe: index]?.rawValue { - self.replaceEmojiReaction(emoji, newEmoji: newEmoji, inPosition: index) + func updateReactionPickerItems() { + let currentItems = currentReactionSetOnDisk(style: self.style) + for (index, item) in currentReactionItems().enumerated() { + if let newItem = currentItems[safe: index] { + self.replaceReaction(item, new: newItem, inPosition: index) } } } - func replaceEmojiReaction(_ oldEmoji: String, newEmoji: String, inPosition position: Int) { - guard let button = buttonForEmoji[position].emojiButton else { return } - button.setTitle(title: newEmoji, font: .systemFont(ofSize: reactionFontSize), titleColor: .Signal.label) - buttonForEmoji.replaceSubrange( - position...position, - with: [.emoji(emoji: newEmoji, button: button)], - ) - } - - func currentEmojiSet() -> [String] { - buttonForEmoji.compactMap { button in - switch button { - case .emoji(let emoji, _): - emoji - case .more: - nil + func replaceReaction( + _ old: CustomReactionItem, + new: CustomReactionItem, + inPosition position: Int + ) { + guard let existingButton = buttonForReaction[safe: position] else { + return + } + if allowStickers, let sticker = new.sticker { + if let imageView = existingButton.stickerImageView, case .stickerReaction(_, let existingBtn, _) = existingButton { + loadStickerImage(stickerInfo: sticker, into: imageView, index: position) + buttonForReaction.replaceSubrange( + position...position, + with: [.stickerReaction(item: new, button: existingBtn, imageView: imageView)], + ) + } else { + let (button, imageView) = buildStickerButton(item: new, stickerInfo: sticker, index: position) + buttonForReaction[position] = .stickerReaction(item: new, button: button, imageView: imageView) + emojiStackView.arrangedSubviews[position].removeFromSuperview() + emojiStackView.insertArrangedSubview(button, at: position) + } + } else { + if let button = existingButton.emojiButton { + button.setTitle(title: new.emoji, font: .systemFont(ofSize: reactionFontSize), titleColor: .Signal.label) + buttonForReaction.replaceSubrange( + position...position, + with: [.reaction(item: new, button: button)], + ) + } else { + let button = buildEmojiButton(item: new, index: position) + buttonForReaction[position] = .reaction(item: new, button: button) + emojiStackView.arrangedSubviews[position].removeFromSuperview() + emojiStackView.insertArrangedSubview(button, at: position) } } } - func startReplaceAnimation(focusedEmoji: String, inPosition position: Int) { + /// Returns all reaction items (emoji + sticker) for non-more buttons. + func currentReactionItems() -> [CustomReactionItem] { + buttonForReaction.compactMap(\.reactionItem) + } + + func startReplaceAnimation(focusedReaction: CustomReactionItem, inPosition position: Int) { var buttonToWiggle: UIView? UIView.animate(withDuration: 0.3, delay: 0, options: .curveEaseInOut) { for (index, button) in self.buttonViews.enumerated() { @@ -384,23 +556,25 @@ class MessageReactionPicker: UIStackView { } } - var focusedEmoji: Emoji? + var focusedReaction: Reaction? func updateFocusPosition(_ position: CGPoint, animated: Bool) { var previouslyFocusedButton: UIView? var focusedButton: UIView? if - let focusedEmoji, - let focusedButton = buttonForEmoji.first(where: { $0.emoji == focusedEmoji })?.view + let focusedReaction, + let focusedButton = buttonForReaction + .first(where: { $0.focusedReaction == focusedReaction })? + .view { previouslyFocusedButton = focusedButton } - focusedEmoji = nil + focusedReaction = nil - for button in buttonForEmoji { + for button in buttonForReaction { guard focusArea(for: button.view).contains(position) else { continue } - focusedEmoji = button.emoji + focusedReaction = button.focusedReaction focusedButton = button.view break } diff --git a/Signal/src/ViewControllers/ReactionPickerSheet.swift b/Signal/src/ViewControllers/ReactionPickerSheet.swift new file mode 100644 index 00000000000..1e8cb8b64b3 --- /dev/null +++ b/Signal/src/ViewControllers/ReactionPickerSheet.swift @@ -0,0 +1,356 @@ +// +// Copyright 2026 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only +// + +import SignalServiceKit +import SignalUI + +/// A picker for emoji and sticker reactions. If you want just emoji, use ``EmojiPickerSheet``. +class ReactionPickerSheet: OWSViewController, StickerPickerViewDelegate { + + private let message: TSMessage? + private let completionHandler: (CustomReactionItem?) -> Void + private let allowReactionConfiguration: Bool + private let reactionPickerConfigurationListener: ReactionPickerConfigurationListener? + + private lazy var stickerPickerView = StickerPickerView(delegate: self) + private lazy var emojiCollectionView = EmojiPickerCollectionView(message: message) + private lazy var sectionToolbar = EmojiPickerSectionToolbar(delegate: self) + + private lazy var emojiSearchBar: UISearchBar = { + let searchBar = UISearchBar() + searchBar.placeholder = OWSLocalizedString( + "SEARCH_FIELD_PLACE_HOLDER_TEXT", + comment: "placeholder text in an empty search field" + ) + searchBar.delegate = self + searchBar.searchBarStyle = .minimal + return searchBar + }() + + private lazy var configureButton: UIButton = { + let button = UIButton() + button.setImage(Theme.iconImage(.emojiSettings), for: .normal) + button.tintColor = .Signal.label + button.addTarget(self, action: #selector(didSelectConfigureButton), for: .touchUpInside) + return button + }() + + private lazy var segmentedControl: UISegmentedControl = { + let control = UISegmentedControl(items: [ + OWSLocalizedString( + "REACTION_PICKER_EMOJI_TAB", + comment: "Title for the emoji tab in the reaction picker." + ), + OWSLocalizedString( + "REACTION_PICKER_STICKERS_TAB", + comment: "Title for the stickers tab in the reaction picker." + ), + ]) + control.selectedSegmentIndex = 0 + control.addTarget(self, action: #selector(segmentChanged), for: .valueChanged) + return control + }() + + private var emojiContainerView = UIView() + private var stickerContainerView = UIView() + + init( + message: TSMessage?, + allowReactionConfiguration: Bool = true, + reactionPickerConfigurationListener: ReactionPickerConfigurationListener? = nil, + completionHandler: @escaping (CustomReactionItem?) -> Void, + ) { + self.message = message + self.allowReactionConfiguration = allowReactionConfiguration + self.reactionPickerConfigurationListener = reactionPickerConfigurationListener + self.completionHandler = completionHandler + super.init() + + sheetPresentationController?.detents = [.medium(), .large()] + sheetPresentationController?.prefersGrabberVisible = true + sheetPresentationController?.delegate = self + } + + override func viewDidLoad() { + super.viewDidLoad() + + if #available(iOS 17.0, *), self.overrideUserInterfaceStyle == .dark { + sheetPresentationController?.traitOverrides.userInterfaceStyle = .dark + } + + if #available(iOS 26, *) { + view.backgroundColor = nil + } else { + view.backgroundColor = .tertiarySystemBackground + } + + segmentedControl.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(segmentedControl) + NSLayoutConstraint.activate([ + segmentedControl.topAnchor.constraint( + equalTo: view.topAnchor, + constant: 20 + ), + segmentedControl.leadingAnchor.constraint( + equalTo: view.leadingAnchor, + constant: 16 + ), + segmentedControl.trailingAnchor.constraint( + equalTo: view.trailingAnchor, + constant: -16 + ), + ]) + + emojiContainerView.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(emojiContainerView) + NSLayoutConstraint.activate([ + emojiContainerView.topAnchor.constraint( + equalTo: segmentedControl.bottomAnchor, + constant: 8 + ), + emojiContainerView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + emojiContainerView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + emojiContainerView.bottomAnchor.constraint(equalTo: view.bottomAnchor), + ]) + + let topBarView = UIStackView() + topBarView.axis = .horizontal + topBarView.isLayoutMarginsRelativeArrangement = true + topBarView.spacing = 8 + if allowReactionConfiguration { + topBarView.layoutMargins = UIEdgeInsets( + top: 0, + leading: 0, + bottom: 0, + trailing: 16 + ) + topBarView.addArrangedSubviews([emojiSearchBar, configureButton]) + } else { + topBarView.addArrangedSubview(emojiSearchBar) + } + topBarView.translatesAutoresizingMaskIntoConstraints = false + emojiContainerView.addSubview(topBarView) + NSLayoutConstraint.activate([ + topBarView.topAnchor.constraint(equalTo: emojiContainerView.topAnchor), + topBarView.leadingAnchor.constraint( + equalTo: emojiContainerView.leadingAnchor, + constant: 8 + ), + topBarView.trailingAnchor.constraint( + equalTo: emojiContainerView.trailingAnchor, + constant: -8 + ), + ]) + + emojiCollectionView.pickerDelegate = self + emojiCollectionView.alwaysBounceVertical = true + emojiCollectionView.translatesAutoresizingMaskIntoConstraints = false + emojiContainerView.addSubview(emojiCollectionView) + NSLayoutConstraint.activate([ + emojiCollectionView.topAnchor.constraint(equalTo: topBarView.bottomAnchor), + emojiCollectionView.leadingAnchor.constraint( + equalTo: emojiContainerView.leadingAnchor + ), + emojiCollectionView.trailingAnchor.constraint( + equalTo: emojiContainerView.trailingAnchor + ), + emojiCollectionView.bottomAnchor.constraint( + equalTo: emojiContainerView.bottomAnchor + ), + ]) + + emojiContainerView.addSubview(sectionToolbar) + sectionToolbar.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + sectionToolbar.leadingAnchor.constraint(equalTo: emojiContainerView.leadingAnchor), + sectionToolbar.trailingAnchor.constraint(equalTo: emojiContainerView.trailingAnchor), + sectionToolbar.bottomAnchor.constraint( + equalTo: keyboardLayoutGuide.topAnchor, + constant: -8 + ), + ]) + + // Obscures content underneath the emoji section toolbar to improve legibility. + if #available(iOS 26, *) { + let scrollInteraction = UIScrollEdgeElementContainerInteraction() + scrollInteraction.scrollView = emojiCollectionView + scrollInteraction.edge = .bottom + sectionToolbar.addInteraction(scrollInteraction) + } + + stickerContainerView.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(stickerContainerView) + NSLayoutConstraint.activate([ + stickerContainerView.topAnchor.constraint( + equalTo: segmentedControl.bottomAnchor, + constant: 8 + ), + stickerContainerView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + stickerContainerView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + stickerContainerView.bottomAnchor.constraint(equalTo: view.bottomAnchor), + ]) + + let hMargin = OWSTableViewController2.cellHInnerMargin + stickerPickerView.directionalLayoutMargins = .init(margin: hMargin) + stickerPickerView.translatesAutoresizingMaskIntoConstraints = false + stickerContainerView.addSubview(stickerPickerView) + NSLayoutConstraint.activate([ + stickerPickerView.topAnchor.constraint(equalTo: stickerContainerView.topAnchor), + stickerPickerView.leadingAnchor.constraint( + equalTo: stickerContainerView.leadingAnchor + ), + stickerPickerView.trailingAnchor.constraint( + equalTo: stickerContainerView.trailingAnchor + ), + stickerPickerView.bottomAnchor.constraint( + equalTo: stickerContainerView.bottomAnchor + ), + ]) + + // Set initial state. + segmentChanged() + } + + override func viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() + view.layoutIfNeeded() + + let bottomInset = sectionToolbar.height - sectionToolbar.safeAreaInsets.bottom + let contentInset = UIEdgeInsets(top: 0, leading: 0, bottom: bottomInset, trailing: 0) + emojiCollectionView.contentInset = contentInset + emojiCollectionView.scrollIndicatorInsets = contentInset + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + stickerPickerView.willBePresented() + } + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + stickerPickerView.wasPresented() + } + + @objc + private func didSelectConfigureButton(sender: UIButton) { + let configVC = CustomReactionPickerConfigViewController( + reactionPickerConfigurationListener: self.reactionPickerConfigurationListener, + ) + let navController = UINavigationController(rootViewController: configVC) + if overrideUserInterfaceStyle == .dark { + navController.overrideUserInterfaceStyle = .dark + } + self.present(navController, animated: true) + } + + @objc + private func segmentChanged() { + let showStickers = segmentedControl.selectedSegmentIndex == 1 + emojiContainerView.isHidden = showStickers + stickerContainerView.isHidden = !showStickers + if showStickers { + emojiSearchBar.resignFirstResponder() + } + } + + private func maximizeHeight() { + sheetPresentationController?.animateChanges { + sheetPresentationController?.selectedDetentIdentifier = .large + } + } + + // MARK: StickerPickerViewDelegate + + func didSelectSticker(_ stickerInfo: StickerInfo) { + ImpactHapticFeedback.impactOccurred(style: .light) + // Look up the sticker's associated emoji so we can build a complete CustomReactionItem. + let emoji: String = SSKEnvironment.shared.databaseStorageRef.read { tx in + StickerManager.installedStickerMetadata( + stickerInfo: stickerInfo, + transaction: tx + )?.firstEmoji?.nilIfEmpty + } ?? StickerManager.fallbackStickerEmoji + let item = CustomReactionItem(emoji: emoji, sticker: stickerInfo) + completionHandler(item) + dismiss(animated: true) + } + + func presentManageStickersView(for stickerPickerView: StickerPickerView) { + let manageStickersView = ManageStickersViewController() + let navigationController = OWSNavigationController(rootViewController: manageStickersView) + present(navigationController, animated: true) + } +} + +// MARK: - EmojiPickerCollectionViewDelegate + +extension ReactionPickerSheet: EmojiPickerCollectionViewDelegate { + func emojiPicker(_ emojiPicker: EmojiPickerCollectionView, didSelectEmoji emoji: EmojiWithSkinTones) { + completionHandler(CustomReactionItem(emoji: emoji.rawValue, sticker: nil)) + dismiss(animated: true) + } + + func emojiPicker(_ emojiPicker: EmojiPickerCollectionView, didScrollToSection section: EmojiPickerSection) { + switch section { + case .messageEmoji: + sectionToolbar.setSelectedSection(0) + case .recentEmoji: + sectionToolbar.setSelectedSection(0) + case .emojiCategory(let categoryIndex): + sectionToolbar.setSelectedSection(categoryIndex + (emojiPicker.hasRecentEmoji ? 1 : 0)) + } + } +} + +// MARK: - EmojiPickerSectionToolbarDelegate + +extension ReactionPickerSheet: EmojiPickerSectionToolbarDelegate { + func emojiPickerSectionToolbar(_ sectionToolbar: EmojiPickerSectionToolbar, didSelectSection section: Int) { + let finalSection: EmojiPickerSection + if section == 0, emojiCollectionView.hasRecentEmoji { + finalSection = .recentEmoji + } else { + finalSection = .emojiCategory(categoryIndex: section - (emojiCollectionView.hasRecentEmoji ? 1 : 0)) + } + if let searchText = emojiCollectionView.searchText, !searchText.isEmpty { + emojiSearchBar.text = nil + emojiCollectionView.searchText = nil + emojiCollectionView.performBatchUpdates(nil) { _ in + self.emojiCollectionView.scrollToSectionHeader(finalSection, animated: false) + } + } else { + emojiCollectionView.scrollToSectionHeader(finalSection, animated: false) + } + maximizeHeight() + } + + func emojiPickerSectionToolbarShouldShowRecentsSection(_ sectionToolbar: EmojiPickerSectionToolbar) -> Bool { + return emojiCollectionView.hasRecentEmoji + } + + func emojiPickerWillBeginDragging(_ emojiPicker: EmojiPickerCollectionView) { + emojiSearchBar.resignFirstResponder() + } +} + +// MARK: - UISheetPresentationControllerDelegate + +extension ReactionPickerSheet: UISheetPresentationControllerDelegate { + func presentationControllerDidDismiss(_ presentationController: UIPresentationController) { + completionHandler(nil) + } +} + +// MARK: - UISearchBarDelegate + +extension ReactionPickerSheet: UISearchBarDelegate { + func searchBarTextDidBeginEditing(_ searchBar: UISearchBar) { + maximizeHeight() + } + + func searchBar(_ searchBar: UISearchBar, textDidChange searchText: String) { + emojiCollectionView.searchText = searchText + } +} diff --git a/Signal/src/views/ReactionFlybyAnimation.swift b/Signal/src/views/ReactionFlybyAnimation.swift index 0680923ca81..47528947d07 100644 --- a/Signal/src/views/ReactionFlybyAnimation.swift +++ b/Signal/src/views/ReactionFlybyAnimation.swift @@ -3,6 +3,7 @@ // SPDX-License-Identifier: AGPL-3.0-only // +import SDWebImage import SignalServiceKit import SignalUI public import UIKit @@ -10,9 +11,12 @@ public import UIKit public class ReactionFlybyAnimation: UIView { private static let maxWidth: CGFloat = 500 let reaction: String - init(reaction: String) { - owsAssertDebug(reaction.isSingleEmoji) + private let stickerImage: UIImage? + + init(reaction: String, stickerImage: UIImage?) { + owsAssertDebug(stickerImage != nil || reaction.isSingleEmoji) self.reaction = reaction + self.stickerImage = stickerImage super.init(frame: .zero) isUserInteractionEnabled = false } @@ -147,24 +151,42 @@ public class ReactionFlybyAnimation: UIView { size: CGFloat, rotation: ClosedRange? = nil, ) -> () -> Void { - let font = UIFont.systemFont(ofSize: size) - - let label = UILabel() - label.text = reaction - label.textAlignment = .center - label.font = font - - let reactionSize = reaction.boundingRect( - with: CGSize(square: .greatestFiniteMagnitude), - options: .init(rawValue: 0), - attributes: [.font: font], - context: nil, - ).size - - let container = OWSLayerView(frame: CGRect(origin: .zero, size: reactionSize * 4)) { view in - label.frame = view.bounds + let containerSize: CGSize + let contentView: UIView + + if let stickerImage { + let imageView = SDAnimatedImageView() + imageView.contentMode = .scaleAspectFit + imageView.clipsToBounds = true + if let animated = stickerImage as? SDAnimatedImage { + imageView.image = animated + } else { + imageView.image = stickerImage + } + containerSize = CGSize(square: size * 2) + imageView.frame = CGRect(origin: .zero, size: containerSize) + contentView = imageView + } else { + let font = UIFont.systemFont(ofSize: size) + let label = UILabel() + label.text = reaction + label.textAlignment = .center + label.font = font + let reactionSize = reaction.boundingRect( + with: CGSize(square: .greatestFiniteMagnitude), + options: .init(rawValue: 0), + attributes: [.font: font], + context: nil, + ).size + containerSize = reactionSize * 4 + label.frame = CGRect(origin: .zero, size: containerSize) + contentView = label + } + + let container = OWSLayerView(frame: CGRect(origin: .zero, size: containerSize)) { view in + contentView.frame = view.bounds } - container.addSubview(label) + container.addSubview(contentView) if let rotation { container.transform = .init(rotationAngle: rotation.lowerBound.toRadians) } @@ -172,9 +194,9 @@ public class ReactionFlybyAnimation: UIView { let xPosition: CGFloat switch relativeXPosition { case .left(let offset): - xPosition = offset - (container.width / 2) + (reactionSize.width / 2) + xPosition = offset - (container.width / 2) + (containerSize.width / 2) case .right(let offset): - xPosition = bounds.maxX - container.width - offset + ((container.width - reactionSize.width) / 2) + xPosition = bounds.maxX - container.width - offset + ((container.width - containerSize.width) / 2) case .center(let offset): xPosition = bounds.midX - (container.width / 2) + offset } @@ -183,7 +205,7 @@ public class ReactionFlybyAnimation: UIView { return { UIView.addKeyframe(withRelativeStartTime: relativeStartTime, relativeDuration: relativeDuration) { - container.frame.origin.y = -container.height + container.frame.origin.y = -(container.height + contentView.height) if let rotation { container.transform = .init(rotationAngle: rotation.upperBound.toRadians) } diff --git a/Signal/translations/en.lproj/Localizable.strings b/Signal/translations/en.lproj/Localizable.strings index e77d09001dc..e94f9550f9e 100644 --- a/Signal/translations/en.lproj/Localizable.strings +++ b/Signal/translations/en.lproj/Localizable.strings @@ -7339,6 +7339,48 @@ /* notification body. Embeds {{reaction emoji}} */ "REACTION_INCOMING_NOTIFICATION_TO_VOICE_MESSAGE_BODY_FORMAT" = "Reacted %@ to your voice message"; +/* notification body. */ +"REACTION_STICKER_INCOMING_NOTIFICATION_BODY_FORMAT" = "Reacted with a sticker to your message"; + +/* notification body. */ +"REACTION_STICKER_INCOMING_NOTIFICATION_TO_ALBUM_BODY_FORMAT" = "Reacted with a sticker to your album"; + +/* notification body. */ +"REACTION_STICKER_INCOMING_NOTIFICATION_TO_AUDIO_BODY_FORMAT" = "Reacted with a sticker to your audio"; + +/* notification body. */ +"REACTION_STICKER_INCOMING_NOTIFICATION_TO_CONTACT_SHARE_BODY_FORMAT" = "Reacted with a sticker to your contact share"; + +/* notification body. */ +"REACTION_STICKER_INCOMING_NOTIFICATION_TO_FILE_BODY_FORMAT" = "Reacted with a sticker to your file"; + +/* notification body. */ +"REACTION_STICKER_INCOMING_NOTIFICATION_TO_GIF_BODY_FORMAT" = "Reacted with a sticker to your GIF"; + +/* notification body. */ +"REACTION_STICKER_INCOMING_NOTIFICATION_TO_PHOTO_BODY_FORMAT" = "Reacted with a sticker to your photo"; + +/* notification body. */ +"REACTION_STICKER_INCOMING_NOTIFICATION_TO_STICKER_MESSAGE_BODY_FORMAT" = "Reacted with a sticker to your sticker"; + +/* notification body. and {{body text}} */ +"REACTION_STICKER_INCOMING_NOTIFICATION_TO_TEXT_MESSAGE_BODY_FORMAT" = "Reacted with a sticker to: \"%@\""; + +/* notification body. */ +"REACTION_STICKER_INCOMING_NOTIFICATION_TO_VIDEO_BODY_FORMAT" = "Reacted with a sticker to your video"; + +/* notification body. */ +"REACTION_STICKER_INCOMING_NOTIFICATION_TO_VIEW_ONCE_MESSAGE_BODY_FORMAT" = "Reacted with a sticker to your view-once media"; + +/* notification body. */ +"REACTION_STICKER_INCOMING_NOTIFICATION_TO_VOICE_MESSAGE_BODY_FORMAT" = "Reacted with a sticker to your voice message"; + +/* Title for the emoji tab in the reaction picker. */ +"REACTION_PICKER_EMOJI_TAB" = "Emoji"; + +/* Title for the stickers tab in the reaction picker. */ +"REACTION_PICKER_STICKERS_TAB" = "Stickers"; + /* Pressing this button marks a thread as read */ "READ_ACTION" = "Read"; @@ -9391,15 +9433,27 @@ /* Text explaining that you reacted to someone else's story. Embeds {{ %1$@ reaction emoji, %2$@ story author name }}. */ "STORY_REACTION_PREVIEW_FORMAT_SECOND_PERSON" = "You reacted %1$@ to %2$@’s story"; +/* Text explaining that you reacted to someone else’s story with a sticker. Embeds {{ %1$@ story author name }}. */ +"STORY_REACTION_STICKER_PREVIEW_FORMAT_SECOND_PERSON" = "You reacted with a sticker to %1$@’s story"; + /* Text explaining that someone reacted to your story. Embeds {{ %1$@ reaction emoji }}. */ "STORY_REACTION_PREVIEW_FORMAT_THIRD_PERSON" = "Reacted %1$@ to your story"; +/* Text explaining that someone reacted to your story with a sticker. */ +"STORY_REACTION_STICKER_PREVIEW_THIRD_PERSON" = "Reacted with a sticker to your story"; + /* quote text for a reaction to a story by the user (the header on the bubble says \"You\"). Embeds {{reaction emoji}} */ "STORY_REACTION_QUOTE_FORMAT_SECOND_PERSON" = "Reacted %@ to a story"; /* quote text for a reaction to a story by some other user (the header on the bubble says their name, e.g. \"Bob\"). Embeds {{reaction emoji}} */ "STORY_REACTION_QUOTE_FORMAT_THIRD_PERSON" = "Reacted %@ to a story"; +/* quote text for a reaction to a story by the user with a sticker (the header on the bubble says \"You\"). */ +"STORY_REACTION_STICKER_QUOTE_SECOND_PERSON" = "Reacted with a sticker to a story"; + +/* quote text for a reaction to a story by some other user with a sticker (the header on the bubble says their name, e.g. \"Bob\"). */ +"STORY_REACTION_STICKER_QUOTE_THIRD_PERSON" = "Reacted with a sticker to a story"; + /* Button for replying to a story with no existing replies. */ "STORY_REPLY_BUTTON" = "Reply"; diff --git a/Signal/translations/en.lproj/PluralAware.stringsdict b/Signal/translations/en.lproj/PluralAware.stringsdict index 4eb01c24c5c..7fffc001099 100644 --- a/Signal/translations/en.lproj/PluralAware.stringsdict +++ b/Signal/translations/en.lproj/PluralAware.stringsdict @@ -2,6 +2,22 @@ + MESSAGE_REACTIONS_STICKER_ACCESSIBILITY_LABEL_%d + + NSStringLocalizedFormatKey + %#@text@ + text + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + d + one + %d reaction with a sticker. + other + %d reactions with a sticker. + + ACCESSIBILITY_LABEL_LONG_VOICE_MEMO_%d_%d NSStringLocalizedFormatKey @@ -995,9 +1011,9 @@ NSStringFormatValueTypeKey d one - %d member couldn't be added to the New Group and has been removed. + %d member couldn't be added to the New Group and has been removed. other - %d members couldn't be added to the New Group and have been removed. + %d members couldn't be added to the New Group and have been removed. GROUP_WAS_MIGRATED_USERS_INVITED_%d @@ -1011,9 +1027,9 @@ NSStringFormatValueTypeKey d one - %d member couldn't be added to the New Group and has been invited to join. + %d member couldn't be added to the New Group and has been invited to join. other - %d members couldn't be added to the New Group and have been invited to join. + %d members couldn't be added to the New Group and have been invited to join. IMAGE_PICKER_CAN_SELECT_NO_MORE_TOAST_%d @@ -1043,9 +1059,9 @@ NSStringFormatValueTypeKey d one - To keep "%2$@" linked, open Signal on that device within one day. + To keep "%2$@" linked, open Signal on that device within one day. other - To keep "%2$@" linked, open Signal on that device within %d days. + To keep "%2$@" linked, open Signal on that device within %d days. LINK_DEVICE_CONFIRMATION_ALERT_TRANSFER_SUBTITLE_%d diff --git a/SignalServiceKit/Backups/Archiving/Archivers/AccountData/BackupArchiveAccountDataArchiver.swift b/SignalServiceKit/Backups/Archiving/Archivers/AccountData/BackupArchiveAccountDataArchiver.swift index 0160b164ea6..1efdb95ddb1 100644 --- a/SignalServiceKit/Backups/Archiving/Archivers/AccountData/BackupArchiveAccountDataArchiver.swift +++ b/SignalServiceKit/Backups/Archiving/Archivers/AccountData/BackupArchiveAccountDataArchiver.swift @@ -297,7 +297,20 @@ public class BackupArchiveAccountDataArchiver: BackupArchiveProtoStreamWriter { accountSettings.hasSeenGroupStoryEducationSheet_p = hasSeenGroupStoryEducationSheet accountSettings.hasCompletedUsernameOnboarding_p = hasCompletedUsernameOnboarding accountSettings.phoneNumberSharingMode = phoneNumberSharingMode - accountSettings.preferredReactionEmoji = reactionManager.customEmojiSet(tx: context.tx) ?? [] + if let customReactionSet = reactionManager.customReactionSet(tx: context.tx) { + // Set the legacy field for backwards compatibility. + accountSettings.preferredReactionEmoji = customReactionSet.map(\.emoji) + accountSettings.preferredReactionItems = customReactionSet.map { item in + var protoItem = BackupProto_AccountData.PreferredReactionItem() + protoItem.emoji = item.emoji + if let sticker = item.sticker { + protoItem.stickerPackID = sticker.packId + protoItem.stickerPackKey = sticker.packKey + protoItem.stickerID = sticker.stickerId + } + return protoItem + } + } accountSettings.storyViewReceiptsEnabled = storyManager.areViewReceiptsEnabled(tx: context.tx) accountSettings.pinReminders = hasPinReminders switch backupSettingsStore.backupPlan(tx: context.tx) { @@ -517,8 +530,49 @@ public class BackupArchiveAccountDataArchiver: BackupArchiveProtoStreamWriter { ), tx: context.tx, ) - if settings.preferredReactionEmoji.count > 0 { - reactionManager.setCustomEmojiSet(emojis: settings.preferredReactionEmoji, tx: context.tx) + let customReactionSet: [CustomReactionItem] = settings.preferredReactionItems.compactMap { + guard let emoji = $0.emoji.nilIfEmpty else { return nil } + var sticker: StickerInfo? + if + let stickerPackId = $0.stickerPackID.nilIfEmpty, + let stickerPackKey = $0.stickerPackKey.nilIfEmpty + { + sticker = StickerInfo( + packId: stickerPackId, + packKey: stickerPackKey, + stickerId: $0.stickerID + ) + } + return CustomReactionItem(emoji: emoji, sticker: sticker) + } + + if + !settings.preferredReactionEmoji.isEmpty, + !customReactionSet.isEmpty, + settings.preferredReactionEmoji != customReactionSet.map(\.emoji) + { + // We have both legacy (emoji only) and new custom reaction items, + // and they aren't the same. If a new client had updated them, + // they'd be the same. Therefore an old client must've updated + // custom reactions, and we should take the legacy field, which will + // eventually overwrite the new field next time we write. + reactionManager.setCustomReactionSet( + items: settings.preferredReactionEmoji.map { CustomReactionItem(emoji: $0, sticker: nil) }, + tx: context.tx + ) + } else if + !settings.preferredReactionEmoji.isEmpty, + customReactionSet.isEmpty + { + // We only have legacy. Use those. + reactionManager.setCustomReactionSet( + items: settings.preferredReactionEmoji.map { CustomReactionItem(emoji: $0, sticker: nil) }, + tx: context.tx + ) + } else if !customReactionSet.isEmpty { + // We either only have new custom reaction items, or we have both + // and they're the same underlying emoji; use the new type. + reactionManager.setCustomReactionSet(items: customReactionSet, tx: context.tx) } donationSubscriptionManager.setDisplayBadgesOnProfile(value: settings.displayBadgesOnProfile, tx: context.tx) sskPreferences.setShouldKeepMutedChatsArchived(value: settings.keepMutedChatsArchived, tx: context.tx) diff --git a/SignalServiceKit/Backups/Archiving/Archivers/ChatItem/BackupArchiveMessageAttachmentArchiver.swift b/SignalServiceKit/Backups/Archiving/Archivers/ChatItem/BackupArchiveMessageAttachmentArchiver.swift index df678bb9f6a..6376edb3487 100644 --- a/SignalServiceKit/Backups/Archiving/Archivers/ChatItem/BackupArchiveMessageAttachmentArchiver.swift +++ b/SignalServiceKit/Backups/Archiving/Archivers/ChatItem/BackupArchiveMessageAttachmentArchiver.swift @@ -311,6 +311,41 @@ class BackupArchiveMessageAttachmentArchiver: BackupArchiveProtoStreamWriter { ) } + func restoreReactionStickerAttachment( + _ attachment: BackupProto_FilePointer, + stickerPackId: Data, + stickerId: UInt32, + reactionRowId: Int64, + chatItemId: BackupArchive.ChatItemId, + messageRowId: Int64, + message: TSMessage, + thread: BackupArchive.ChatThread, + context: BackupArchive.ChatItemRestoringContext, + ) -> BackupArchive.RestoreInteractionResult { + let ownedAttachment = OwnedAttachmentBackupPointerProto( + proto: attachment, + // Sticker reactions have no flags + renderingFlag: .default, + // ClientUUID is only for body and quoted reply attachments. + clientUUID: nil, + owner: .messageReactionSticker(.init( + messageRowId: messageRowId, + receivedAtTimestamp: message.receivedAtTimestamp, + threadRowId: thread.threadRowId, + isPastEditRevision: message.isPastEditRevision(), + stickerPackId: stickerPackId, + stickerId: stickerId, + reactionRowId: reactionRowId, + )), + ) + + return restoreAttachments( + [ownedAttachment], + chatItemId: chatItemId, + context: context, + ) + } + private func restoreAttachments( _ attachments: [OwnedAttachmentBackupPointerProto], chatItemId: BackupArchive.ChatItemId, diff --git a/SignalServiceKit/Backups/Archiving/Archivers/ChatItem/BackupArchivePollArchiver.swift b/SignalServiceKit/Backups/Archiving/Archivers/ChatItem/BackupArchivePollArchiver.swift index dcaa3c2050f..bf40b90693a 100644 --- a/SignalServiceKit/Backups/Archiving/Archivers/ChatItem/BackupArchivePollArchiver.swift +++ b/SignalServiceKit/Backups/Archiving/Archivers/ChatItem/BackupArchivePollArchiver.swift @@ -32,6 +32,7 @@ class BackupArchivePollArchiver: BackupArchiveProtoStreamWriter { _ message: TSMessage, messageRowId: Int64, interactionUniqueId: BackupArchive.InteractionUniqueId, + reactionStickerAttachments: BackupArchive.ReactionStickerAttachments, context: BackupArchive.ChatArchivingContext, ) -> BackupArchive.ArchiveInteractionResult { let pollResult = pollManager.buildPollForBackup(message: message, messageRowId: messageRowId, tx: context.tx) @@ -71,6 +72,7 @@ class BackupArchivePollArchiver: BackupArchiveProtoStreamWriter { var reactions: [BackupProto_Reaction] = [] let reactionsResult = reactionArchiver.archiveReactions( message, + reactionStickerAttachments: reactionStickerAttachments, context: context.recipientContext, ) diff --git a/SignalServiceKit/Backups/Archiving/Archivers/ChatItem/BackupArchiveReactionArchiver.swift b/SignalServiceKit/Backups/Archiving/Archivers/ChatItem/BackupArchiveReactionArchiver.swift index cb2604ea809..382e9ac8c28 100644 --- a/SignalServiceKit/Backups/Archiving/Archivers/ChatItem/BackupArchiveReactionArchiver.swift +++ b/SignalServiceKit/Backups/Archiving/Archivers/ChatItem/BackupArchiveReactionArchiver.swift @@ -9,9 +9,14 @@ import LibSignalClient class BackupArchiveReactionArchiver: BackupArchiveProtoStreamWriter { private typealias ArchiveFrameError = BackupArchive.ArchiveFrameError + private let attachmentsArchiver: BackupArchiveMessageAttachmentArchiver private let reactionStore: BackupArchiveReactionStore - init(reactionStore: BackupArchiveReactionStore) { + init( + attachmentsArchiver: BackupArchiveMessageAttachmentArchiver, + reactionStore: BackupArchiveReactionStore, + ) { + self.attachmentsArchiver = attachmentsArchiver self.reactionStore = reactionStore } @@ -19,6 +24,7 @@ class BackupArchiveReactionArchiver: BackupArchiveProtoStreamWriter { func archiveReactions( _ message: TSMessage, + reactionStickerAttachments: BackupArchive.ReactionStickerAttachments, context: BackupArchive.RecipientArchivingContext, ) -> BackupArchive.ArchiveInteractionResult<[BackupProto_Reaction]> { let reactions: [OWSReaction] @@ -66,6 +72,21 @@ class BackupArchiveReactionArchiver: BackupArchiveProtoStreamWriter { reactionProto.sentTimestamp = sentAtTimestamp reactionProto.sortOrder = reaction.sortOrder + if + let sticker = reaction.sticker, + let stickerReferencedAttachment = + reactionStickerAttachments.sticker(for: reaction) + { + var stickerProto = BackupProto_Sticker() + stickerProto.emoji = reaction.emoji + stickerProto.packID = sticker.packId + stickerProto.packKey = sticker.packKey + stickerProto.stickerID = sticker.stickerId + stickerProto.data = stickerReferencedAttachment.asBackupFilePointer( + context: context + ) + reactionProto.sticker = stickerProto + } reactionProtos.append(reactionProto) } @@ -82,23 +103,36 @@ class BackupArchiveReactionArchiver: BackupArchiveProtoStreamWriter { _ reactions: [BackupProto_Reaction], chatItemId: BackupArchive.ChatItemId, message: TSMessage, - context: BackupArchive.RecipientRestoringContext, + messageRowId: Int64, + thread: BackupArchive.ChatThread, + context: BackupArchive.ChatItemRestoringContext, ) -> BackupArchive.RestoreInteractionResult { var reactionErrors = [BackupArchive.RestoreFrameError]() for reaction in reactions { - let reactorAddress = context[reaction.authorRecipientId] + let reactorAddress = context.recipientContext[reaction.authorRecipientId] + + var sticker: StickerInfo? + if reaction.hasSticker, !reaction.sticker.packID.isEmpty { + sticker = StickerInfo( + packId: reaction.sticker.packID, + packKey: reaction.sticker.packKey, + stickerId: reaction.sticker.stickerID + ) + } - let insertResult: Result + // OWSReaction row id + let insertResult: Result switch reactorAddress { case .localAddress: insertResult = Result { try reactionStore.createReaction( uniqueMessageId: message.uniqueId, emoji: reaction.emoji, + sticker: sticker, reactorAci: context.localIdentifiers.aci, sentAtTimestamp: reaction.sentTimestamp, sortOrder: reaction.sortOrder, - context: context, + context: context.recipientContext, ) } case .contact(let address): @@ -107,10 +141,11 @@ class BackupArchiveReactionArchiver: BackupArchiveProtoStreamWriter { try reactionStore.createReaction( uniqueMessageId: message.uniqueId, emoji: reaction.emoji, + sticker: sticker, reactorAci: aci, sentAtTimestamp: reaction.sentTimestamp, sortOrder: reaction.sortOrder, - context: context, + context: context.recipientContext, ) } } else if let e164 = address.e164 { @@ -121,7 +156,7 @@ class BackupArchiveReactionArchiver: BackupArchiveProtoStreamWriter { reactorE164: e164, sentAtTimestamp: reaction.sentTimestamp, sortOrder: reaction.sortOrder, - context: context, + context: context.recipientContext, ) } } else { @@ -147,8 +182,38 @@ class BackupArchiveReactionArchiver: BackupArchiveProtoStreamWriter { } switch insertResult { - case .success: - break + case .success(let reactionRowId): + if let reactionRowId { + if let sticker { + let attachmentResult = attachmentsArchiver.restoreReactionStickerAttachment( + reaction.sticker.data, + stickerPackId: sticker.packId, + stickerId: sticker.stickerId, + reactionRowId: reactionRowId, + chatItemId: chatItemId, + messageRowId: messageRowId, + message: message, + thread: thread, + context: context + ) + innerSwitch: switch attachmentResult.bubbleUp( + Void.self, + partialErrors: &reactionErrors + ) { + case .continue: + break innerSwitch + case .bubbleUpError(let error): + return error + } + } + } else { + reactionErrors.append( + .restoreFrameError( + .databaseModelMissingRowId(modelClass: OWSReaction.self), + chatItemId + ), + ) + } case .failure(let insertError): reactionErrors.append( .restoreFrameError(.databaseInsertionFailed(insertError), chatItemId), diff --git a/SignalServiceKit/Backups/Archiving/Archivers/ChatItem/BackupArchiveReactionStore.swift b/SignalServiceKit/Backups/Archiving/Archivers/ChatItem/BackupArchiveReactionStore.swift index a88117ee67f..fde6974c964 100644 --- a/SignalServiceKit/Backups/Archiving/Archivers/ChatItem/BackupArchiveReactionStore.swift +++ b/SignalServiceKit/Backups/Archiving/Archivers/ChatItem/BackupArchiveReactionStore.swift @@ -31,19 +31,22 @@ public class BackupArchiveReactionStore { func createReaction( uniqueMessageId: String, emoji: String, + sticker: StickerInfo?, reactorAci: Aci, sentAtTimestamp: UInt64, sortOrder: UInt64, context: BackupArchive.RecipientRestoringContext, - ) throws { + ) throws -> Int64? { let reaction = OWSReaction.fromRestoredBackup( uniqueMessageId: uniqueMessageId, emoji: emoji, + sticker: sticker, reactorAci: reactorAci, sentAtTimestamp: sentAtTimestamp, sortOrder: sortOrder, ) try reaction.insert(context.tx.database) + return reaction.id } /// In the olden days before the introduction of Acis, reactions were sent by e164s. @@ -54,14 +57,17 @@ public class BackupArchiveReactionStore { sentAtTimestamp: UInt64, sortOrder: UInt64, context: BackupArchive.RecipientRestoringContext, - ) throws { + ) throws -> Int64? { let reaction = OWSReaction.fromRestoredBackup( uniqueMessageId: uniqueMessageId, emoji: emoji, + // Legacy reactions can't have stickers. + sticker: nil, reactorE164: reactorE164, sentAtTimestamp: sentAtTimestamp, sortOrder: sortOrder, ) try reaction.insert(context.tx.database) + return reaction.id } } diff --git a/SignalServiceKit/Backups/Archiving/Archivers/ChatItem/BackupArchiveTSIncomingMessageArchiver.swift b/SignalServiceKit/Backups/Archiving/Archivers/ChatItem/BackupArchiveTSIncomingMessageArchiver.swift index 02919a3c860..a8f794f9562 100644 --- a/SignalServiceKit/Backups/Archiving/Archivers/ChatItem/BackupArchiveTSIncomingMessageArchiver.swift +++ b/SignalServiceKit/Backups/Archiving/Archivers/ChatItem/BackupArchiveTSIncomingMessageArchiver.swift @@ -444,6 +444,8 @@ extension BackupArchiveTSIncomingMessageArchiver: BackupArchive.TSMessageEditHis messageBuilder.setMessageBody(textReply.body) case .emoji(let emoji): messageBuilder.storyReactionEmoji = emoji + case .sticker(let sticker): + messageBuilder.storyReactionEmoji = sticker.emoji } // Peers can't reply to their own stories; if a 1:1 story reply is incoming // that means the author of the story being replied to was the local user. diff --git a/SignalServiceKit/Backups/Archiving/Archivers/ChatItem/BackupArchiveTSMessageContentsArchiver.swift b/SignalServiceKit/Backups/Archiving/Archivers/ChatItem/BackupArchiveTSMessageContentsArchiver.swift index 23b39f29ef1..b7686095913 100644 --- a/SignalServiceKit/Backups/Archiving/Archivers/ChatItem/BackupArchiveTSMessageContentsArchiver.swift +++ b/SignalServiceKit/Backups/Archiving/Archivers/ChatItem/BackupArchiveTSMessageContentsArchiver.swift @@ -103,8 +103,16 @@ extension BackupArchive { let body: Text.RestoredMessageBody } + struct Sticker { + let emoji: String + let sticker: MessageSticker + + fileprivate let attachment: BackupProto_FilePointer + } + case textReply(TextReply) case emoji(String) + case sticker(Sticker) } let replyType: ReplyType @@ -130,6 +138,22 @@ extension BackupArchive { case poll(Poll) case adminDeleteTombstone(RecipientId) } + + struct ReactionStickerAttachments { + // Maps from OWSReaction.id (row id) to sticker attachment, if any. + private let reactionStickers: [Int64: ReferencedAttachment] + + fileprivate init(_ reactionStickers: [Int64 : ReferencedAttachment]) { + self.reactionStickers = reactionStickers + } + + func sticker(for reaction: OWSReaction) -> ReferencedAttachment? { + guard let id = reaction.id else { + return nil + } + return reactionStickers[id] + } + } } class BackupArchiveTSMessageContentsArchiver: BackupArchiveProtoStreamWriter { @@ -185,6 +209,7 @@ class BackupArchiveTSMessageContentsArchiver: BackupArchiveProtoStreamWriter { let linkPreview: ReferencedAttachment? let contactAvatar: ReferencedAttachment? let sticker: ReferencedAttachment? + let reactionStickers: BackupArchive.ReactionStickerAttachments } func archiveMessageContents( @@ -201,27 +226,56 @@ class BackupArchiveTSMessageContentsArchiver: BackupArchiveProtoStreamWriter { let referencedAttachments = attachmentStore.fetchReferencedAttachmentsOwnedByMessage( messageRowId: messageRowId, tx: context.tx, - ).filter { + ) + var body: [ReferencedAttachment] = [] + var oversizeText: ReferencedAttachment? + var quotedReply: ReferencedAttachment? + var linkPreview: ReferencedAttachment? + var contactAvatar: ReferencedAttachment? + var sticker: ReferencedAttachment? + // OWSReaction row id -> sticker (if any for that reaction) + var reactionStickers: [Int64: ReferencedAttachment] = [:] + for referencedAttachment in referencedAttachments { // There was a bug that resulted in invalid quoted-reply attachments being created // with the voiceMessage rendering flag. Filter them out. if - case .quotedReplyAttachment = $0.reference.owner.id, - $0.reference.renderingFlag == .voiceMessage + case .quotedReplyAttachment = referencedAttachment.reference.owner.id, + referencedAttachment.reference.renderingFlag == .voiceMessage { - return false + continue } - return true - } - let grouped = Dictionary(grouping: referencedAttachments, by: \.reference.owner.id) + switch referencedAttachment.reference.owner { + case .message(let messageSource): + switch messageSource { + case .bodyAttachment: + body.append(referencedAttachment) + case .oversizeText: + oversizeText = referencedAttachment + case .quotedReply: + quotedReply = referencedAttachment + case .linkPreview: + linkPreview = referencedAttachment + case .contactAvatar: + contactAvatar = referencedAttachment + case .sticker: + sticker = referencedAttachment + case .reactionSticker(let metadata): + reactionStickers[metadata.reactionRowId] = referencedAttachment + } + case .storyMessage, .thread: + continue + } + } return MessageOwnedReferencedAttachments( - body: grouped[.messageBodyAttachment(messageRowId: messageRowId)] ?? [], - oversizeText: grouped[.messageOversizeText(messageRowId: messageRowId)]?.first, - quotedReply: grouped[.quotedReplyAttachment(messageRowId: messageRowId)]?.first, - linkPreview: grouped[.messageLinkPreview(messageRowId: messageRowId)]?.first, - contactAvatar: grouped[.messageContactAvatar(messageRowId: messageRowId)]?.first, - sticker: grouped[.messageSticker(messageRowId: messageRowId)]?.first, + body: body, + oversizeText: oversizeText, + quotedReply: quotedReply, + linkPreview: linkPreview, + contactAvatar: contactAvatar, + sticker: sticker, + reactionStickers: BackupArchive.ReactionStickerAttachments(reactionStickers), ) }() @@ -259,13 +313,18 @@ class BackupArchiveTSMessageContentsArchiver: BackupArchiveProtoStreamWriter { message, contactShare: contactShare, contactAvatarReferencedAttachment: messageOwnedReferencedAttachments.contactAvatar, + reactionStickerAttachments: messageOwnedReferencedAttachments.reactionStickers, context: context.recipientContext, ) - } else if let messageSticker = message.messageSticker { + } else if + let messageSticker = message.messageSticker, + (!message.isStoryReply || message.isGroupStoryReply) + { return archiveStickerMessageContents( message, messageSticker: messageSticker, stickerReferencedAttachment: messageOwnedReferencedAttachments.sticker, + reactionStickerAttachments: messageOwnedReferencedAttachments.reactionStickers, context: context.recipientContext, ) } else if let giftBadge = message.giftBadge { @@ -277,12 +336,15 @@ class BackupArchiveTSMessageContentsArchiver: BackupArchiveProtoStreamWriter { return archiveViewOnceMessage( message, bodyReferencedAttachments: messageOwnedReferencedAttachments.body, + reactionStickerAttachments: messageOwnedReferencedAttachments.reactionStickers, context: context, ) } else if message.isStoryReply, !message.isGroupStoryReply { return archiveDirectStoryReplyMessage( message, oversizeTextReferencedAttachment: messageOwnedReferencedAttachments.oversizeText, + stickerReferencedAttachment: messageOwnedReferencedAttachments.sticker, + reactionStickerAttachments: messageOwnedReferencedAttachments.reactionStickers, interactionUniqueId: message.uniqueInteractionId, context: context, ) @@ -291,6 +353,7 @@ class BackupArchiveTSMessageContentsArchiver: BackupArchiveProtoStreamWriter { message, messageRowId: messageRowId, interactionUniqueId: message.uniqueInteractionId, + reactionStickerAttachments: messageOwnedReferencedAttachments.reactionStickers, context: context, ) } else { @@ -500,6 +563,7 @@ class BackupArchiveTSMessageContentsArchiver: BackupArchiveProtoStreamWriter { let reactions: [BackupProto_Reaction] let reactionsResult = reactionArchiver.archiveReactions( message, + reactionStickerAttachments: messageOwnedReferencedAttachments.reactionStickers, context: context, ) switch reactionsResult.bubbleUp(ChatItemType.self, partialErrors: &partialErrors) { @@ -769,6 +833,7 @@ class BackupArchiveTSMessageContentsArchiver: BackupArchiveProtoStreamWriter { _ message: TSMessage, contactShare: OWSContact, contactAvatarReferencedAttachment: ReferencedAttachment?, + reactionStickerAttachments: BackupArchive.ReactionStickerAttachments, context: BackupArchive.RecipientArchivingContext, ) -> ArchiveInteractionResult { var partialErrors = [ArchiveFrameError]() @@ -791,6 +856,7 @@ class BackupArchiveTSMessageContentsArchiver: BackupArchiveProtoStreamWriter { let reactions: [BackupProto_Reaction] let reactionsResult = reactionArchiver.archiveReactions( message, + reactionStickerAttachments: reactionStickerAttachments, context: context, ) switch reactionsResult.bubbleUp(ChatItemType.self, partialErrors: &partialErrors) { @@ -812,6 +878,7 @@ class BackupArchiveTSMessageContentsArchiver: BackupArchiveProtoStreamWriter { _ message: TSMessage, messageSticker: MessageSticker, stickerReferencedAttachment: ReferencedAttachment?, + reactionStickerAttachments: BackupArchive.ReactionStickerAttachments, context: BackupArchive.RecipientArchivingContext, ) -> ArchiveInteractionResult { guard let stickerReferencedAttachment else { @@ -842,6 +909,7 @@ class BackupArchiveTSMessageContentsArchiver: BackupArchiveProtoStreamWriter { let reactions: [BackupProto_Reaction] let reactionsResult = reactionArchiver.archiveReactions( message, + reactionStickerAttachments: reactionStickerAttachments, context: context, ) switch reactionsResult.bubbleUp(ChatItemType.self, partialErrors: &partialErrors) { @@ -889,6 +957,7 @@ class BackupArchiveTSMessageContentsArchiver: BackupArchiveProtoStreamWriter { private func archiveViewOnceMessage( _ message: TSMessage, bodyReferencedAttachments: [ReferencedAttachment], + reactionStickerAttachments: BackupArchive.ReactionStickerAttachments, context: BackupArchive.ChatArchivingContext, ) -> ArchiveInteractionResult { var partialErrors = [ArchiveFrameError]() @@ -920,6 +989,7 @@ class BackupArchiveTSMessageContentsArchiver: BackupArchiveProtoStreamWriter { let reactions: [BackupProto_Reaction] let reactionsResult = reactionArchiver.archiveReactions( message, + reactionStickerAttachments: reactionStickerAttachments, context: context.recipientContext, ) switch reactionsResult.bubbleUp(ChatItemType.self, partialErrors: &partialErrors) { @@ -945,6 +1015,8 @@ class BackupArchiveTSMessageContentsArchiver: BackupArchiveProtoStreamWriter { private func archiveDirectStoryReplyMessage( _ message: TSMessage, oversizeTextReferencedAttachment: ReferencedAttachment?, + stickerReferencedAttachment: ReferencedAttachment?, + reactionStickerAttachments: BackupArchive.ReactionStickerAttachments, interactionUniqueId: BackupArchive.InteractionUniqueId, context: BackupArchive.ChatArchivingContext, ) -> ArchiveInteractionResult { @@ -995,7 +1067,23 @@ class BackupArchiveTSMessageContentsArchiver: BackupArchiveProtoStreamWriter { interactionUniqueId, )]) } - proto.reply = .emoji(emoji) + if let stickerReferencedAttachment, let sticker = message.messageSticker { + var stickerProto = BackupProto_Sticker() + + stickerProto.packID = sticker.packId + stickerProto.packKey = sticker.packKey + stickerProto.stickerID = sticker.stickerId + sticker.emoji.map { stickerProto.emoji = $0 } + + stickerProto.data = attachmentsArchiver.archiveStickerAttachment( + referencedAttachment: stickerReferencedAttachment, + context: context, + ) + + proto.sticker = stickerProto + } else { + proto.reply = .emoji(emoji) + } } else if let body = message.body { guard !body.isEmpty else { return .messageFailure([.archiveFrameError( @@ -1042,6 +1130,7 @@ class BackupArchiveTSMessageContentsArchiver: BackupArchiveProtoStreamWriter { let reactions: [BackupProto_Reaction] let reactionsResult = reactionArchiver.archiveReactions( message, + reactionStickerAttachments: reactionStickerAttachments, context: context.recipientContext, ) switch reactionsResult.bubbleUp(ChatItemType.self, partialErrors: &partialErrors) { @@ -1183,7 +1272,9 @@ class BackupArchiveTSMessageContentsArchiver: BackupArchiveProtoStreamWriter { text.reactions, chatItemId: chatItemId, message: message, - context: context.recipientContext, + messageRowId: messageRowId, + thread: thread, + context: context, )) if let oversizeText = text.body?.oversizeText { downstreamObjectResults.append(oversizeTextArchiver.restoreOversizeText( @@ -1230,7 +1321,9 @@ class BackupArchiveTSMessageContentsArchiver: BackupArchiveProtoStreamWriter { contactShare.reactions, chatItemId: chatItemId, message: message, - context: context.recipientContext, + messageRowId: messageRowId, + thread: thread, + context: context, )) if let avatarAttachment = contactShare.avatarAttachment { downstreamObjectResults.append(attachmentsArchiver.restoreContactAvatarAttachment( @@ -1247,7 +1340,9 @@ class BackupArchiveTSMessageContentsArchiver: BackupArchiveProtoStreamWriter { stickerMessage.reactions, chatItemId: chatItemId, message: message, - context: context.recipientContext, + messageRowId: messageRowId, + thread: thread, + context: context, )) downstreamObjectResults.append(attachmentsArchiver.restoreStickerAttachment( stickerMessage.attachment, @@ -1264,7 +1359,9 @@ class BackupArchiveTSMessageContentsArchiver: BackupArchiveProtoStreamWriter { viewOnceMessage.reactions, chatItemId: chatItemId, message: message, - context: context.recipientContext, + messageRowId: messageRowId, + thread: thread, + context: context, )) switch viewOnceMessage.state { case .unviewed(let attachment): @@ -1284,7 +1381,9 @@ class BackupArchiveTSMessageContentsArchiver: BackupArchiveProtoStreamWriter { storyReply.reactions, chatItemId: chatItemId, message: message, - context: context.recipientContext, + messageRowId: messageRowId, + thread: thread, + context: context, )) switch storyReply.replyType { @@ -1299,6 +1398,17 @@ class BackupArchiveTSMessageContentsArchiver: BackupArchiveProtoStreamWriter { context: context, )) } + case .sticker(let stickerReply): + downstreamObjectResults.append(attachmentsArchiver.restoreStickerAttachment( + stickerReply.attachment, + stickerPackId: stickerReply.sticker.packId, + stickerId: stickerReply.sticker.stickerId, + chatItemId: chatItemId, + messageRowId: messageRowId, + message: message, + thread: thread, + context: context, + )) case .emoji: break } @@ -1307,7 +1417,9 @@ class BackupArchiveTSMessageContentsArchiver: BackupArchiveProtoStreamWriter { poll.reactions, chatItemId: chatItemId, message: message, - context: context.recipientContext, + messageRowId: messageRowId, + thread: thread, + context: context, )) downstreamObjectResults.append( @@ -2109,6 +2221,20 @@ class BackupArchiveTSMessageContentsArchiver: BackupArchiveProtoStreamWriter { } case .emoji(let string): replyType = .emoji(string) + case .sticker(let stickerProto): + let messageSticker = MessageSticker( + info: .init( + packId: stickerProto.packID, + packKey: stickerProto.packKey, + stickerId: stickerProto.stickerID, + ), + emoji: stickerProto.emoji, + ) + replyType = .sticker(.init( + emoji: stickerProto.emoji, + sticker: messageSticker, + attachment: stickerProto.data + )) case .none: return .unrecognizedEnum(BackupArchive.UnrecognizedEnumError( enumType: BackupProto_DirectStoryReplyMessage.OneOf_Reply.self, diff --git a/SignalServiceKit/Backups/Archiving/Archivers/ChatItem/BackupArchiveTSOutgoingMessageArchiver.swift b/SignalServiceKit/Backups/Archiving/Archivers/ChatItem/BackupArchiveTSOutgoingMessageArchiver.swift index a057fe67103..fc785fd9f2a 100644 --- a/SignalServiceKit/Backups/Archiving/Archivers/ChatItem/BackupArchiveTSOutgoingMessageArchiver.swift +++ b/SignalServiceKit/Backups/Archiving/Archivers/ChatItem/BackupArchiveTSOutgoingMessageArchiver.swift @@ -625,6 +625,8 @@ extension BackupArchiveTSOutgoingMessageArchiver: BackupArchive.TSMessageEditHis outgoingMessageBuilder.setMessageBody(textReply.body) case .emoji(let emoji): outgoingMessageBuilder.storyReactionEmoji = emoji + case .sticker(let sticker): + outgoingMessageBuilder.storyReactionEmoji = sticker.emoji } // We can't reply to our own stories; if a 1:1 story reply is outgoing // that means the author of the story being replied to was the peer. diff --git a/SignalServiceKit/Backups/Archiving/BackupArchive+Shims.swift b/SignalServiceKit/Backups/Archiving/BackupArchive+Shims.swift index 3dbb94756d6..ea51bdbf05d 100644 --- a/SignalServiceKit/Backups/Archiving/BackupArchive+Shims.swift +++ b/SignalServiceKit/Backups/Archiving/BackupArchive+Shims.swift @@ -391,17 +391,17 @@ public class _MessageBackup_ProfileManagerWrapper: _MessageBackup_ProfileManager // MARK: - ReactionManager public protocol _MessageBackup_ReactionManagerShim { - func customEmojiSet(tx: DBReadTransaction) -> [String]? - func setCustomEmojiSet(emojis: [String]?, tx: DBWriteTransaction) + func customReactionSet(tx: DBReadTransaction) -> [CustomReactionItem]? + func setCustomReactionSet(items: [CustomReactionItem]?, tx: DBWriteTransaction) } public class _MessageBackup_ReactionManagerWrapper: _MessageBackup_ReactionManagerShim { - public func customEmojiSet(tx: DBReadTransaction) -> [String]? { - ReactionManager.customEmojiSet(transaction: tx) + public func customReactionSet(tx: DBReadTransaction) -> [CustomReactionItem]? { + ReactionManager.customReactionSet(tx: tx) } - public func setCustomEmojiSet(emojis: [String]?, tx: DBWriteTransaction) { - ReactionManager.setCustomEmojiSet(emojis, transaction: tx) + public func setCustomReactionSet(items: [CustomReactionItem]?, tx: DBWriteTransaction) { + ReactionManager.setCustomReactionSet(items, tx: tx) } } diff --git a/SignalServiceKit/Backups/Attachments/BackupAttachmentUploadScheduler.swift b/SignalServiceKit/Backups/Attachments/BackupAttachmentUploadScheduler.swift index ccfe981521f..4f4d9f0a5d9 100644 --- a/SignalServiceKit/Backups/Attachments/BackupAttachmentUploadScheduler.swift +++ b/SignalServiceKit/Backups/Attachments/BackupAttachmentUploadScheduler.swift @@ -277,7 +277,8 @@ public class BackupAttachmentUploadScheduler { .contactAvatar, .linkPreview, .quotedReply, - .sticker: + .sticker, + .reactionSticker: break } diff --git a/SignalServiceKit/Environment/AppSetup.swift b/SignalServiceKit/Environment/AppSetup.swift index e037f2e4341..010950c2920 100644 --- a/SignalServiceKit/Environment/AppSetup.swift +++ b/SignalServiceKit/Environment/AppSetup.swift @@ -1413,6 +1413,7 @@ extension AppSetup.GlobalsContinuation { orphanedAttachmentStore: orphanedAttachmentStore, ) let backupReactionArchiver = BackupArchiveReactionArchiver( + attachmentsArchiver: backupAttachmentsArchiver, reactionStore: BackupArchiveReactionStore(), ) let pollArchiver = BackupArchivePollArchiver( diff --git a/SignalServiceKit/Jobs/MessageSenderJobQueue.swift b/SignalServiceKit/Jobs/MessageSenderJobQueue.swift index 793bbd425ce..a85c0cc1d97 100644 --- a/SignalServiceKit/Jobs/MessageSenderJobQueue.swift +++ b/SignalServiceKit/Jobs/MessageSenderJobQueue.swift @@ -313,7 +313,7 @@ public class MessageSenderJobQueue { let sendPriority: JobPriority if job.record.isHighPriority { sendPriority = .high - } else if message.hasRenderableContent(tx: transaction) { + } else if message.isRenderableContentSendPriority(tx: transaction) { sendPriority = .renderableContent } else { sendPriority = .low diff --git a/SignalServiceKit/Messages/Attachments/V2/AttachmentManager/OwnedAttachmentPointerProto.swift b/SignalServiceKit/Messages/Attachments/V2/AttachmentManager/OwnedAttachmentPointerProto.swift index 64791c2daa4..5eedd720a63 100644 --- a/SignalServiceKit/Messages/Attachments/V2/AttachmentManager/OwnedAttachmentPointerProto.swift +++ b/SignalServiceKit/Messages/Attachments/V2/AttachmentManager/OwnedAttachmentPointerProto.swift @@ -47,6 +47,8 @@ public struct OwnedAttachmentBackupPointerProto { return messageAttachmentBuilder.receivedAtTimestamp case .messageSticker(let messageStickerBuilder): return messageStickerBuilder.receivedAtTimestamp + case .messageReactionSticker(let reactionStickerBuilder): + return reactionStickerBuilder.receivedAtTimestamp case .messageContactAvatar(let messageAttachmentBuilder): return messageAttachmentBuilder.receivedAtTimestamp case .threadWallpaperImage, .globalThreadWallpaperImage: diff --git a/SignalServiceKit/Messages/Attachments/V2/AttachmentReference/AttachmentReference+Owner.swift b/SignalServiceKit/Messages/Attachments/V2/AttachmentReference/AttachmentReference+Owner.swift index 8ddeca8dfc2..deeac57610b 100644 --- a/SignalServiceKit/Messages/Attachments/V2/AttachmentReference/AttachmentReference+Owner.swift +++ b/SignalServiceKit/Messages/Attachments/V2/AttachmentReference/AttachmentReference+Owner.swift @@ -29,6 +29,7 @@ extension AttachmentReference { case quotedReplyAttachment(messageRowId: Int64) case messageSticker(messageRowId: Int64) case messageContactAvatar(messageRowId: Int64) + case messageReactionSticker(messageRowId: Int64, reactionRowId: Int64) case storyMessageMedia(storyMessageRowId: Int64) case storyMessageLinkPreview(storyMessageRowId: Int64) case threadWallpaperImage(threadRowId: Int64) @@ -45,6 +46,7 @@ extension AttachmentReference { case .message(.quotedReply(let metadata)): .quotedReplyAttachment(messageRowId: metadata.messageRowId) case .message(.sticker(let metadata)): .messageSticker(messageRowId: metadata.messageRowId) case .message(.contactAvatar(let metadata)): .messageContactAvatar(messageRowId: metadata.messageRowId) + case .message(.reactionSticker(let metadata)): .messageReactionSticker(messageRowId: metadata.messageRowId, reactionRowId: metadata.reactionRowId) case .storyMessage(.media(let metadata)): .storyMessageMedia(storyMessageRowId: metadata.storyMessageRowId) case .storyMessage(.textStoryLinkPreview(let metadata)): .storyMessageLinkPreview(storyMessageRowId: metadata.storyMessageRowId) case .thread(.threadWallpaperImage(let metadata)): .threadWallpaperImage(threadRowId: metadata.threadRowId) @@ -66,6 +68,9 @@ extension AttachmentReference { case sticker(StickerMetadata) /// Always assumed to have an image content type. case contactAvatar(Metadata) + /// The sticker attachment on a reaction on a message. + /// The parent message is the owner of all its reactions' stickers. + case reactionSticker(ReactionStickerMetadata) // MARK: - @@ -77,6 +82,7 @@ extension AttachmentReference { case .quotedReply: .quotedReplyAttachment case .sticker: .sticker case .contactAvatar: .contactAvatar + case .reactionSticker: .reactionSticker } } @@ -221,6 +227,32 @@ extension AttachmentReference { } } + public class ReactionStickerMetadata: StickerMetadata { + public let reactionRowId: Int64 + + init( + messageRowId: Int64, + receivedAtTimestamp: UInt64, + threadRowId: Int64, + contentType: ContentType?, + isPastEditRevision: Bool, + stickerPackId: Data, + stickerId: UInt32, + reactionRowId: Int64, + ) { + self.reactionRowId = reactionRowId + super.init( + messageRowId: messageRowId, + receivedAtTimestamp: receivedAtTimestamp, + threadRowId: threadRowId, + contentType: contentType, + isPastEditRevision: isPastEditRevision, + stickerPackId: stickerPackId, + stickerId: stickerId, + ) + } + } + public var messageRowId: Int64 { switch self { case .bodyAttachment(let metadata): metadata.messageRowId @@ -229,6 +261,7 @@ extension AttachmentReference { case .quotedReply(let metadata): metadata.messageRowId case .sticker(let metadata): metadata.messageRowId case .contactAvatar(let metadata): metadata.messageRowId + case .reactionSticker(let metadata): metadata.messageRowId } } @@ -240,6 +273,10 @@ extension AttachmentReference { case .quotedReply: nil case .sticker: nil case .contactAvatar: nil + // Note: while reactions do have their own id and are + // "in" their owning message, that is not the namespace + // this var refers to (which applies only to body attachments) + case .reactionSticker: nil } } @@ -251,6 +288,7 @@ extension AttachmentReference { case .quotedReply(let metadata): metadata.receivedAtTimestamp case .sticker(let metadata): metadata.receivedAtTimestamp case .contactAvatar(let metadata): metadata.receivedAtTimestamp + case .reactionSticker(let metadata): metadata.receivedAtTimestamp } } } @@ -418,6 +456,24 @@ extension AttachmentReference.Owner { contentType: try record.contentType.map { try .init(rawValue: $0) }, isPastEditRevision: record.ownerIsPastEditRevision, ))) + case .reactionSticker: + guard + let stickerId = record.stickerId, + let stickerPackId = record.stickerPackId, + let reactionRowId = record.reactionRowId + else { + throw OWSAssertionError("Sticker and reaction metadata required for reaction sticker attachment") + } + return .message(.reactionSticker(.init( + messageRowId: record.ownerRowId, + receivedAtTimestamp: record.receivedAtTimestamp, + threadRowId: record.threadRowId, + contentType: try record.contentType.map { try .init(rawValue: $0) }, + isPastEditRevision: record.ownerIsPastEditRevision, + stickerPackId: stickerPackId, + stickerId: stickerId, + reactionRowId: reactionRowId, + ))) } } @@ -524,6 +580,17 @@ extension AttachmentReference.Owner { contentType: contentType, isPastEditRevision: metadata.isPastEditRevision, )) + case .reactionSticker(let metadata): + return .reactionSticker(.init( + messageRowId: metadata.messageRowId, + receivedAtTimestamp: metadata.receivedAtTimestamp, + threadRowId: metadata.threadRowId, + contentType: contentType, + isPastEditRevision: metadata.isPastEditRevision, + stickerPackId: metadata.stickerPackId, + stickerId: metadata.stickerId, + reactionRowId: metadata.reactionRowId, + )) } }()) case .storyMessage(let storyMessageSource): diff --git a/SignalServiceKit/Messages/Attachments/V2/AttachmentStore/AttachmentStore.swift b/SignalServiceKit/Messages/Attachments/V2/AttachmentStore/AttachmentStore.swift index b45a103a6f4..2424214c941 100644 --- a/SignalServiceKit/Messages/Attachments/V2/AttachmentStore/AttachmentStore.swift +++ b/SignalServiceKit/Messages/Attachments/V2/AttachmentStore/AttachmentStore.swift @@ -81,6 +81,18 @@ public struct AttachmentStore { fetchMessageAttachmentReferences(ownerType: .sticker, messageRowId: messageRowId, tx: tx) case .messageContactAvatar(let messageRowId): fetchMessageAttachmentReferences(ownerType: .contactAvatar, messageRowId: messageRowId, tx: tx) + case .messageReactionSticker(let messageRowId, let reactionRowId): + fetchMessageAttachmentReferences(ownerType: .reactionSticker, messageRowId: messageRowId, tx: tx) + // The number of reactions on a message is + // constrained enough to filter in memory + .filter { + switch $0.owner { + case .message(.reactionSticker(let metadata)): + return metadata.reactionRowId == reactionRowId + default: + return false + } + } case .storyMessageMedia(let storyMessageRowId): fetchStoryAttachmentReferences(ownerType: .media, storyMessageRowId: storyMessageRowId, tx: tx) case .storyMessageLinkPreview(let storyMessageRowId): @@ -490,6 +502,8 @@ public struct AttachmentStore { switch try? AttachmentReference(record: record).owner { case .message(.sticker(let stickerMetadata)): return stickerMetadata + case .message(.reactionSticker(let stickerMetadata)): + return stickerMetadata default: return nil } diff --git a/SignalServiceKit/Messages/Attachments/V2/AttachmentStore/AttachmentStoreTests.swift b/SignalServiceKit/Messages/Attachments/V2/AttachmentStore/AttachmentStoreTests.swift index 475ceea3ff1..4281a420265 100644 --- a/SignalServiceKit/Messages/Attachments/V2/AttachmentStore/AttachmentStoreTests.swift +++ b/SignalServiceKit/Messages/Attachments/V2/AttachmentStore/AttachmentStoreTests.swift @@ -918,6 +918,8 @@ class AttachmentStoreTests: XCTestCase { XCTAssertEqual(metadata.threadRowId, threadId) case .sticker(let metadata): XCTAssertEqual(metadata.threadRowId, threadId) + case .reactionSticker(let metadata): + XCTAssertEqual(metadata.threadRowId, threadId) case .contactAvatar(let metadata): XCTAssertEqual(metadata.threadRowId, threadId) } diff --git a/SignalServiceKit/Messages/Attachments/V2/Downloads/AttachmentDownloadManagerImpl.swift b/SignalServiceKit/Messages/Attachments/V2/Downloads/AttachmentDownloadManagerImpl.swift index 1fc1329e14e..5e958e57be4 100644 --- a/SignalServiceKit/Messages/Attachments/V2/Downloads/AttachmentDownloadManagerImpl.swift +++ b/SignalServiceKit/Messages/Attachments/V2/Downloads/AttachmentDownloadManagerImpl.swift @@ -951,10 +951,16 @@ public class AttachmentDownloadManagerImpl: AttachmentDownloadManager { toAttachmentId: record.attachmentId, tx: tx, block: { reference, stop in + // Doesn't matter which attachment reference we pull + // the sticker info off of, we just need to map + // back to an installed sticker. take the first we see. switch reference.owner { case .message(.sticker(let metadata)): stop = true stickerMetadata = metadata + case .message(.reactionSticker(let metadata)): + stop = true + stickerMetadata = metadata default: break } @@ -1222,7 +1228,7 @@ public class AttachmentDownloadManagerImpl: AttachmentDownloadManager { break case .message(.oversizeText): return false - case .message(.sticker): + case .message(.sticker), .message(.reactionSticker): break case .message(.quotedReply), .message(.linkPreview), .storyMessage(.textStoryLinkPreview), .message(.contactAvatar): return false @@ -1257,7 +1263,7 @@ public class AttachmentDownloadManagerImpl: AttachmentDownloadManager { let threadRowId: Int64 switch owner { - case .message(.oversizeText), .message(.sticker): + case .message(.oversizeText), .message(.sticker), .message(.reactionSticker): return false case .message(.bodyAttachment(let metadata)): threadRowId = metadata.threadRowId @@ -1268,7 +1274,6 @@ public class AttachmentDownloadManagerImpl: AttachmentDownloadManager { case .message(.contactAvatar(let metadata)): threadRowId = metadata.threadRowId case .storyMessage, .thread: - // Ignore non-message cases for purposes of pending message request. return false } @@ -1328,6 +1333,12 @@ public class AttachmentDownloadManagerImpl: AttachmentDownloadManager { return false case .message(.sticker): return !autoDownloadableMediaTypes.contains(.photo) + case .message(.reactionSticker): + // Always download sticker reactions; if we wanted to + // apply the auto-download setting we'd need a way to + // indicate that in UI and not silently fall back to + // the sticker's emoji. + return false case .message(.quotedReply): return false case .message(.linkPreview): diff --git a/SignalServiceKit/Messages/Attachments/V2/Records/Attachment+ConstructionParams.swift b/SignalServiceKit/Messages/Attachments/V2/Records/Attachment+ConstructionParams.swift index fa903cbec17..7c3ffb63f60 100644 --- a/SignalServiceKit/Messages/Attachments/V2/Records/Attachment+ConstructionParams.swift +++ b/SignalServiceKit/Messages/Attachments/V2/Records/Attachment+ConstructionParams.swift @@ -173,6 +173,28 @@ extension Attachment { ) } + public static func forRevertedStickerReactionAttachment( + mimeType: String, + ) -> ConstructionParams { + return .init( + blurHash: nil, + mimeType: mimeType, + // We don't have any cdn info from which to download, so what + // encryption key we use is irrelevant. Just generate a new one. + encryptionKey: AttachmentKey.generate().combinedKey, + streamInfo: nil, + latestTransitTierInfo: nil, + originalTransitTierInfo: nil, + sha256ContentHash: nil, + mediaName: nil, + mediaTierInfo: nil, + thumbnailMediaTierInfo: nil, + localRelativeFilePathThumbnail: nil, + originalAttachmentIdForQuotedReply: nil, + lastFullscreenViewTimestamp: nil, + ) + } + public static func forQuotedReplyThumbnailPointer( originalAttachment: Attachment, thumbnailBlurHash: String?, diff --git a/SignalServiceKit/Messages/Attachments/V2/Records/AttachmentReference+ConstructionParams.swift b/SignalServiceKit/Messages/Attachments/V2/Records/AttachmentReference+ConstructionParams.swift index dcf4df462dd..6a7f4d26146 100644 --- a/SignalServiceKit/Messages/Attachments/V2/Records/AttachmentReference+ConstructionParams.swift +++ b/SignalServiceKit/Messages/Attachments/V2/Records/AttachmentReference+ConstructionParams.swift @@ -27,6 +27,7 @@ extension AttachmentReference { case quotedReplyAttachment(MessageAttachmentBuilder) case messageSticker(MessageStickerBuilder) case messageContactAvatar(MessageAttachmentBuilder) + case messageReactionSticker(MessageReactionStickerBuilder) case storyMessageMedia(StoryMediaBuilder) case storyMessageLinkPreview(storyMessageRowId: Int64) case threadWallpaperImage(threadRowId: Int64) @@ -127,6 +128,17 @@ extension AttachmentReference { contentType: contentType, isPastEditRevision: metadata.isPastEditRevision, ))) + case .messageReactionSticker(let metadata): + return .message(.reactionSticker(.init( + messageRowId: metadata.messageRowId, + receivedAtTimestamp: metadata.receivedAtTimestamp, + threadRowId: metadata.threadRowId, + contentType: contentType, + isPastEditRevision: metadata.isPastEditRevision, + stickerPackId: metadata.stickerPackId, + stickerId: metadata.stickerId, + reactionRowId: metadata.reactionRowId, + ))) case .storyMessageMedia(let metadata): return .storyMessage(.media(.init( storyMessageRowId: metadata.storyMessageRowId, @@ -219,6 +231,34 @@ extension AttachmentReference { } } + public struct MessageReactionStickerBuilder: Equatable { + public let messageRowId: Int64 + public let receivedAtTimestamp: UInt64 + public let threadRowId: Int64 + public let isPastEditRevision: Bool + public let stickerPackId: Data + public let stickerId: UInt32 + public let reactionRowId: Int64 + + public init( + messageRowId: Int64, + receivedAtTimestamp: UInt64, + threadRowId: Int64, + isPastEditRevision: Bool, + stickerPackId: Data, + stickerId: UInt32, + reactionRowId: Int64, + ) { + self.messageRowId = messageRowId + self.receivedAtTimestamp = receivedAtTimestamp + self.threadRowId = threadRowId + self.isPastEditRevision = isPastEditRevision + self.stickerPackId = stickerPackId + self.stickerId = stickerId + self.reactionRowId = reactionRowId + } + } + public struct StoryMediaBuilder: Equatable { public let storyMessageRowId: Int64 public let caption: StyleOnlyMessageBody? @@ -278,6 +318,8 @@ extension AttachmentReference.OwnerBuilder { return .messageSticker(messageRowId: stickerOwnerBuilder.messageRowId) case .messageContactAvatar(let builder): return .messageContactAvatar(messageRowId: builder.messageRowId) + case .messageReactionSticker(let builder): + return .messageReactionSticker(messageRowId: builder.messageRowId, reactionRowId: builder.reactionRowId) case .storyMessageMedia(let mediaOwnerBuilder): return .storyMessageMedia(storyMessageRowId: mediaOwnerBuilder.storyMessageRowId) case .storyMessageLinkPreview(let storyMessageRowId): diff --git a/SignalServiceKit/Messages/Attachments/V2/Records/AttachmentReference+Records.swift b/SignalServiceKit/Messages/Attachments/V2/Records/AttachmentReference+Records.swift index 1b09a6f1b70..e39a0d9655e 100644 --- a/SignalServiceKit/Messages/Attachments/V2/Records/AttachmentReference+Records.swift +++ b/SignalServiceKit/Messages/Attachments/V2/Records/AttachmentReference+Records.swift @@ -15,6 +15,7 @@ extension AttachmentReference { case quotedReplyAttachment = 3 case sticker = 4 case contactAvatar = 5 + case reactionSticker = 6 } let ownerTypeRaw: UInt32 @@ -36,6 +37,7 @@ extension AttachmentReference { let stickerId: UInt32? let isViewOnce: Bool var ownerIsPastEditRevision: Bool + let reactionRowId: Int64? // MARK: - Coding Keys @@ -58,6 +60,7 @@ extension AttachmentReference { case stickerId case isViewOnce case ownerIsPastEditRevision + case reactionRowId } // MARK: - Columns @@ -68,6 +71,7 @@ extension AttachmentReference { static let orderInMessage = Column(CodingKeys.orderInMessage) static let attachmentRowId = Column(CodingKeys.attachmentRowId) static let idInMessage = Column(CodingKeys.idInMessage) + static let reactionRowId = Column(CodingKeys.reactionRowId) } // MARK: - PersistableRecord @@ -122,6 +126,7 @@ extension AttachmentReference { self.stickerId = nil self.isViewOnce = metadata.isViewOnce self.ownerIsPastEditRevision = metadata.isPastEditRevision + self.reactionRowId = nil case .oversizeText(let metadata): self.ownerRowId = metadata.messageRowId self._receivedAtTimestamp = DBUInt64(wrappedValue: metadata.receivedAtTimestamp) @@ -136,6 +141,7 @@ extension AttachmentReference { // Oversize text cannot be view once self.isViewOnce = false self.ownerIsPastEditRevision = metadata.isPastEditRevision + self.reactionRowId = nil case .linkPreview(let metadata): self.ownerRowId = metadata.messageRowId self._receivedAtTimestamp = DBUInt64(wrappedValue: metadata.receivedAtTimestamp) @@ -150,6 +156,7 @@ extension AttachmentReference { // Link previews cannot be view once self.isViewOnce = false self.ownerIsPastEditRevision = metadata.isPastEditRevision + self.reactionRowId = nil case .quotedReply(let metadata): self.ownerRowId = metadata.messageRowId self._receivedAtTimestamp = DBUInt64(wrappedValue: metadata.receivedAtTimestamp) @@ -164,6 +171,7 @@ extension AttachmentReference { // Quoted reply thumbnails cannot be view once self.isViewOnce = false self.ownerIsPastEditRevision = metadata.isPastEditRevision + self.reactionRowId = nil case .sticker(let metadata): self.ownerRowId = metadata.messageRowId self._receivedAtTimestamp = DBUInt64(wrappedValue: metadata.receivedAtTimestamp) @@ -178,6 +186,7 @@ extension AttachmentReference { // Stickers cannot be view once self.isViewOnce = false self.ownerIsPastEditRevision = metadata.isPastEditRevision + self.reactionRowId = nil case .contactAvatar(let metadata): self.ownerRowId = metadata.messageRowId self._receivedAtTimestamp = DBUInt64(wrappedValue: metadata.receivedAtTimestamp) @@ -192,6 +201,22 @@ extension AttachmentReference { // Contact avatars cannot be view once self.isViewOnce = false self.ownerIsPastEditRevision = metadata.isPastEditRevision + self.reactionRowId = nil + case .reactionSticker(let metadata): + self.ownerRowId = metadata.messageRowId + self._receivedAtTimestamp = DBUInt64(wrappedValue: metadata.receivedAtTimestamp) + self.contentType = metadata.contentType.map { UInt32($0.rawValue) } + self.renderingFlag = UInt32(AttachmentReference.RenderingFlag.default.rawValue) + self.idInMessage = nil + self.orderInMessage = nil + self.threadRowId = metadata.threadRowId + self.caption = nil + self.stickerPackId = metadata.stickerPackId + self.stickerId = metadata.stickerId + // Reactions cannot be view once + self.isViewOnce = false + self.ownerIsPastEditRevision = metadata.isPastEditRevision + self.reactionRowId = metadata.reactionRowId } } } diff --git a/SignalServiceKit/Messages/Interactions/Quotes/DraftQuotedReplyModel.swift b/SignalServiceKit/Messages/Interactions/Quotes/DraftQuotedReplyModel.swift index 4a402124e12..917414aa008 100644 --- a/SignalServiceKit/Messages/Interactions/Quotes/DraftQuotedReplyModel.swift +++ b/SignalServiceKit/Messages/Interactions/Quotes/DraftQuotedReplyModel.swift @@ -33,8 +33,8 @@ public class DraftQuotedReplyModel { case viewOnce /// The original message was a contact share case contactShare(OWSContact) - /// The original message is a story reaction emoji - case storyReactionEmoji(String) + /// The original message is a story reaction emoji/sticker + case storyReaction(StoryReaction, stickerThumbnail: UIImage?) /// The original message was a poll. case poll(String) @@ -215,23 +215,39 @@ public class DraftQuotedReplyModel { return body case .giftBadge: return nil - case .storyReactionEmoji(let emoji): - let formatString: String + case .storyReaction(let reaction, _): + let text: String if isOriginalMessageAuthorLocalUser { - formatString = OWSLocalizedString( - "STORY_REACTION_QUOTE_FORMAT_SECOND_PERSON", - comment: "quote text for a reaction to a story by the user (the header on the bubble says \"You\"). Embeds {{reaction emoji}}", - ) + if reaction.sticker != nil { + text = OWSLocalizedString( + "STORY_REACTION_STICKER_QUOTE_SECOND_PERSON", + comment: "quote text for a reaction to a story by the user with a sticker (the header on the bubble says \"You\").", + ) + } else { + text = String( + format: OWSLocalizedString( + "STORY_REACTION_QUOTE_FORMAT_SECOND_PERSON", + comment: "quote text for a reaction to a story by the user (the header on the bubble says \"You\"). Embeds {{reaction emoji}}", + ), + reaction.emoji + ) + } } else { - formatString = OWSLocalizedString( - "STORY_REACTION_QUOTE_FORMAT_THIRD_PERSON", - comment: "quote text for a reaction to a story by some other user (the header on the bubble says their name, e.g. \"Bob\"). Embeds {{reaction emoji}}", - ) + if reaction.sticker != nil { + text = OWSLocalizedString( + "STORY_REACTION_STICKER_QUOTE_THIRD_PERSON", + comment: "quote text for a reaction to a story by some other user with a sticker (the header on the bubble says their name, e.g. \"Bob\").", + ) + } else { + text = String( + format: OWSLocalizedString( + "STORY_REACTION_QUOTE_FORMAT_THIRD_PERSON", + comment: "quote text for a reaction to a story by some other user (the header on the bubble says their name, e.g. \"Bob\"). Embeds {{reaction emoji}}", + ), + reaction.emoji + ) + } } - let text = String( - format: formatString, - emoji, - ) return MessageBody(text: text, ranges: .empty) case .poll(let pollQuestion): // Poll question should be the message body of the draft reply. @@ -261,8 +277,8 @@ extension DraftQuotedReplyModel.Content: Equatable { return lhsBody == rhsBody case let (.contactShare(lhsContact), .contactShare(rhsContact)): return lhsContact == rhsContact - case let (.storyReactionEmoji(lhsEmoji), .storyReactionEmoji(rhsEmoji)): - return lhsEmoji == rhsEmoji + case let (.storyReaction(lhsReaction, lhsImage), .storyReaction(rhsReaction, rhsImage)): + return lhsReaction == rhsReaction && lhsImage == rhsImage case let (.edit(lhsMessage, lhsQuotedReply, lhsContent), .edit(rhsMessage, rhsQuotedReply, rhsContent)): return lhsMessage == rhsMessage && lhsQuotedReply == rhsQuotedReply @@ -285,7 +301,7 @@ extension DraftQuotedReplyModel.Content: Equatable { (.attachmentStub, _), (.attachment, _), (.edit, _), - (.storyReactionEmoji, _), + (.storyReaction, _), (.poll, _), (_, .giftBadge), (_, .payment), @@ -295,7 +311,7 @@ extension DraftQuotedReplyModel.Content: Equatable { (_, .attachmentStub), (_, .attachment), (_, .edit), - (_, .storyReactionEmoji), + (_, .storyReaction), (_, .poll): return false } diff --git a/SignalServiceKit/Messages/Interactions/Quotes/QuotedReplyManager.swift b/SignalServiceKit/Messages/Interactions/Quotes/QuotedReplyManager.swift index bd17be49713..e8453cf2b9b 100644 --- a/SignalServiceKit/Messages/Interactions/Quotes/QuotedReplyManager.swift +++ b/SignalServiceKit/Messages/Interactions/Quotes/QuotedReplyManager.swift @@ -255,7 +255,7 @@ class QuotedReplyManagerImpl: QuotedReplyManager { ) } - let body: String? + var body: String? let bodyRanges: MessageBodyRanges? var isGiftBadge: Bool var isPoll: Bool @@ -280,20 +280,39 @@ class QuotedReplyManagerImpl: QuotedReplyManager { isGiftBadge = false isPoll = false } else if let storyReactionEmoji = originalMessage.storyReactionEmoji?.nilIfEmpty { - let formatString: String = { + body = { if authorAddress.isLocalAddress { - return OWSLocalizedString( - "STORY_REACTION_QUOTE_FORMAT_SECOND_PERSON", - comment: "quote text for a reaction to a story by the user (the header on the bubble says \"You\"). Embeds {{reaction emoji}}", - ) + if originalMessage.messageSticker != nil { + return OWSLocalizedString( + "STORY_REACTION_STICKER_QUOTE_SECOND_PERSON", + comment: "quote text for a reaction to a story by the user with a sticker (the header on the bubble says \"You\").", + ) + } else { + return String( + format: OWSLocalizedString( + "STORY_REACTION_QUOTE_FORMAT_SECOND_PERSON", + comment: "quote text for a reaction to a story by the user (the header on the bubble says \"You\"). Embeds {{reaction emoji}}", + ), + storyReactionEmoji + ) + } } else { - return OWSLocalizedString( - "STORY_REACTION_QUOTE_FORMAT_THIRD_PERSON", - comment: "quote text for a reaction to a story by some other user (the header on the bubble says their name, e.g. \"Bob\"). Embeds {{reaction emoji}}", - ) + if originalMessage.messageSticker != nil { + return OWSLocalizedString( + "STORY_REACTION_STICKER_QUOTE_THIRD_PERSON", + comment: "quote text for a reaction to a story by some other user with a sticker (the header on the bubble says their name, e.g. \"Bob\").", + ) + } else { + return String( + format: OWSLocalizedString( + "STORY_REACTION_QUOTE_FORMAT_THIRD_PERSON", + comment: "quote text for a reaction to a story by some other user (the header on the bubble says their name, e.g. \"Bob\"). Embeds {{reaction emoji}}", + ), + storyReactionEmoji + ) + } } }() - body = String(format: formatString, storyReactionEmoji) bodyRanges = nil isGiftBadge = false isPoll = false @@ -328,6 +347,15 @@ class QuotedReplyManagerImpl: QuotedReplyManager { throw OWSAssertionError("Quoted message has no content!") } + if + originalMessage.storyReactionEmoji?.nilIfEmpty != nil, + thumbnailAttachmentInfo != nil + { + // For story reactions with a sticker, don't put the + // fallback emoji into the body. + body = nil + } + return ValidatedQuotedReply( quotedReply: TSQuotedMessage( timestamp: originalMessage.timestamp, @@ -457,7 +485,7 @@ class QuotedReplyManagerImpl: QuotedReplyManager { return createDraftReply(content: .giftBadge) } - if originalMessage.messageSticker != nil { + if originalMessage.messageSticker != nil && originalMessage.storyReactionEmoji?.nilIfEmpty == nil { guard let originalMessageRowId = originalMessage.sqliteRowId, let attachment = attachmentStore.fetchAnyReferencedAttachment( @@ -496,6 +524,17 @@ class QuotedReplyManagerImpl: QuotedReplyManager { return nil } + if let storyReactionEmoji = originalMessage.storyReactionEmoji?.nilIfEmpty { + return createDraftReply(content: .storyReaction( + StoryReaction( + emoji: storyReactionEmoji, + sticker: attachment, + stickerInfo: originalMessage.messageSticker?.info + ), + stickerThumbnail: UIImage(cgImage: thumbnailImage) + )) + } + return createDraftReply(content: .attachment( nil, attachmentRef: attachment.reference, @@ -570,7 +609,10 @@ class QuotedReplyManagerImpl: QuotedReplyManager { } if let storyReactionEmoji = originalMessage.storyReactionEmoji?.nilIfEmpty { - return createDraftReply(content: .storyReactionEmoji(storyReactionEmoji)) + return createDraftReply(content: .storyReaction( + StoryReaction(emoji: storyReactionEmoji, sticker: nil, stickerInfo: nil), + stickerThumbnail: nil + )) } if originalMessage.isPoll { diff --git a/SignalServiceKit/Messages/Interactions/TSMessage.h b/SignalServiceKit/Messages/Interactions/TSMessage.h index e6cbb773c56..d6220110ad6 100644 --- a/SignalServiceKit/Messages/Interactions/TSMessage.h +++ b/SignalServiceKit/Messages/Interactions/TSMessage.h @@ -109,6 +109,7 @@ typedef NS_CLOSED_ENUM(NSInteger, TSEditState) { @property (nonatomic, readonly, nullable) TSQuotedMessage *quotedMessage; @property (nonatomic, readonly, nullable) OWSContact *contactShare; @property (nonatomic, readonly, nullable) OWSLinkPreview *linkPreview; +/// Used for both sticker messages, and story reaction messages that use a sticker reaction. @property (nonatomic, readonly, nullable) MessageSticker *messageSticker; @property (nonatomic, readonly, nullable) OWSGiftBadge *giftBadge; diff --git a/SignalServiceKit/Messages/Interactions/TSMessage.swift b/SignalServiceKit/Messages/Interactions/TSMessage.swift index fc6da7b9cb0..d402e17a856 100644 --- a/SignalServiceKit/Messages/Interactions/TSMessage.swift +++ b/SignalServiceKit/Messages/Interactions/TSMessage.swift @@ -138,6 +138,24 @@ public extension TSMessage { func removeAllReactions(transaction: DBWriteTransaction) { guard !CurrentAppContext().isRunningTests else { return } reactionFinder.deleteAllReactions(transaction: transaction) + + if let messageRowId = self.sqliteRowId { + let attachmentStore = DependenciesBridge.shared.attachmentStore + attachmentStore.fetchReferencedAttachmentsOwnedByMessage( + messageRowId: messageRowId, + tx: transaction + ).filter({ + switch $0.reference.owner { + case .message(.reactionSticker): + return true + default: + return false + } + }) + .forEach { + attachmentStore.removeReference(reference: $0.reference, tx: transaction) + } + } } @objc @@ -164,6 +182,7 @@ public extension TSMessage { func recordReaction( for reactor: Aci, emoji: String, + sticker: StickerInfo?, sentAtTimestamp: UInt64, receivedAtTimestamp: UInt64, tx: DBWriteTransaction, @@ -171,6 +190,7 @@ public extension TSMessage { return self.recordReaction( for: reactor, emoji: emoji, + sticker: sticker, sentAtTimestamp: sentAtTimestamp, sortOrder: receivedAtTimestamp, tx: tx, @@ -181,6 +201,7 @@ public extension TSMessage { func recordReaction( for reactor: Aci, emoji: String, + sticker: StickerInfo?, sentAtTimestamp: UInt64, sortOrder: UInt64, tx: DBWriteTransaction, @@ -198,6 +219,7 @@ public extension TSMessage { let newReaction = OWSReaction( uniqueMessageId: uniqueId, emoji: emoji, + sticker: sticker, reactor: reactor, sentAtTimestamp: sentAtTimestamp, receivedAtTimestamp: receivedAtTimestamp, @@ -221,6 +243,19 @@ public extension TSMessage { return nil } + if let reactionRowId = reaction.id, let messageRowId = self.sqliteRowId { + let attachmentStore = DependenciesBridge.shared.attachmentStore + let refs = attachmentStore.fetchReferences( + owner: .messageReactionSticker(messageRowId: messageRowId, reactionRowId: reactionRowId), + tx: tx, + ) + for ref in refs { + attachmentStore.removeReference(reference: ref, tx: tx) + } + } else { + owsFailDebug("Missing row id for just-fetched reaction") + } + reaction.anyRemove(transaction: tx) SSKEnvironment.shared.databaseStorageRef.touch(interaction: self, shouldReindex: false, tx: tx) @@ -657,28 +692,50 @@ public extension TSMessage { { let tsAccountManager = DependenciesBridge.shared.tsAccountManager let contactManager = SSKEnvironment.shared.contactManagerRef + let isStickerReaction = messageSticker != nil if let localIdentifiers = tsAccountManager.localIdentifiers(tx: tx), localIdentifiers.contains(serviceId: storyAuthorAci) { - return .storyReactionEmoji(String( - format: OWSLocalizedString( - "STORY_REACTION_PREVIEW_FORMAT_THIRD_PERSON", - comment: "Text explaining that someone reacted to your story. Embeds {{ %1$@ reaction emoji }}.", - ), - storyReactionEmoji, - )) + let text: String + if isStickerReaction { + text = OWSLocalizedString( + "STORY_REACTION_STICKER_PREVIEW_THIRD_PERSON", + comment: "Text explaining that someone reacted to your story with a sticker.", + ) + } else { + text = String( + format: OWSLocalizedString( + "STORY_REACTION_PREVIEW_FORMAT_THIRD_PERSON", + comment: "Text explaining that someone reacted to your story. Embeds {{ %1$@ reaction emoji }}.", + ), + storyReactionEmoji, + ) + } + return .storyReactionEmoji(text) } else { let storyAuthorName = contactManager.displayName(for: SignalServiceAddress(storyAuthorAci), tx: tx) - return .storyReactionEmoji(String( - format: OWSLocalizedString( - "STORY_REACTION_PREVIEW_FORMAT_SECOND_PERSON", - comment: "Text explaining that you reacted to someone else's story. Embeds {{ %1$@ reaction emoji, %2$@ story author name }}.", - ), - storyReactionEmoji, - storyAuthorName.resolvedValue(useShortNameIfAvailable: true), - )) + let text: String + if isStickerReaction { + text = String( + format: OWSLocalizedString( + "STORY_REACTION_STICKER_PREVIEW_FORMAT_SECOND_PERSON", + comment: "Text explaining that you reacted to someone else's story with a sticker. Embeds {{ %1$@ story author name }}.", + ), + storyAuthorName.resolvedValue(useShortNameIfAvailable: true), + ) + } else { + text = String( + format: OWSLocalizedString( + "STORY_REACTION_PREVIEW_FORMAT_SECOND_PERSON", + comment: "Text explaining that you reacted to someone else's story. Embeds {{ %1$@ reaction emoji, %2$@ story author name }}.", + ), + storyReactionEmoji, + storyAuthorName.resolvedValue(useShortNameIfAvailable: true), + ) + } + return .storyReactionEmoji(text) } } @@ -946,7 +1003,9 @@ extension TSMessageBuilder { // Story replies currently only support a subset of message features, so may not // be renderable in some circumstances where a normal message would be. if isStoryReply { - return hasNonemptyBody || (storyReactionEmoji?.isSingleEmoji ?? false) + return hasNonemptyBody + || (storyReactionEmoji?.isSingleEmoji ?? false) + || hasSticker } // We DO NOT consider a message with just a linkPreview diff --git a/SignalServiceKit/Messages/Interactions/TSOutgoingMessage.swift b/SignalServiceKit/Messages/Interactions/TSOutgoingMessage.swift index f05a2898fc9..4161239d6ad 100644 --- a/SignalServiceKit/Messages/Interactions/TSOutgoingMessage.swift +++ b/SignalServiceKit/Messages/Interactions/TSOutgoingMessage.swift @@ -391,6 +391,15 @@ extension TSOutgoingMessage { let reactionBuilder = SSKProtoDataMessageReaction.builder(emoji: storyReactionEmoji, timestamp: storyTimestamp.uint64Value) reactionBuilder.setTargetAuthorAciBinary(storyAuthorAci.serviceIdBinary) + if let messageSticker { + do { + let stickerProto = try self.buildStickerProto(sticker: messageSticker, tx: tx) + reactionBuilder.setSticker(stickerProto) + } catch { + owsFailDebug("Could not build sticker protobuf for story reaction: \(error)") + } + } + do { builder.setReaction(try reactionBuilder.build()) requiredProtocolVersion = max(requiredProtocolVersion, SSKProtoDataMessageProtocolVersion.reactions.rawValue) @@ -476,8 +485,8 @@ extension TSOutgoingMessage { } } - // Sticker - if let messageSticker { + // Sticker message (not a sticker story reaction, which is handled separately above) + if let messageSticker, storyReactionEmoji == nil { do { let stickerProto = try self.buildStickerProto(sticker: messageSticker, tx: tx) builder.setSticker(stickerProto) diff --git a/SignalServiceKit/Messages/OutgoingMessagePreparer/PreparedOutgoingMessage.swift b/SignalServiceKit/Messages/OutgoingMessagePreparer/PreparedOutgoingMessage.swift index dcf3604fffb..95c0e187806 100644 --- a/SignalServiceKit/Messages/OutgoingMessagePreparer/PreparedOutgoingMessage.swift +++ b/SignalServiceKit/Messages/OutgoingMessagePreparer/PreparedOutgoingMessage.swift @@ -39,6 +39,19 @@ public class PreparedOutgoingMessage { return PreparedOutgoingMessage(messageType: messageType) } + /// Use this _only_ to "prepare" outgoing reaction messages with no attachments (just emoji). + /// Instantly prepares because...these messages don't need any preparing. + static func preprepared( + outgoingReactionMessage: OutgoingReactionMessage, + ) -> PreparedOutgoingMessage { + owsAssertDebug(outgoingReactionMessage.createdReaction?.sticker == nil) + let messageType = MessageType.reactionMessage(MessageType.ReactionMessage( + message: outgoingReactionMessage, + hasSticker: false + )) + return PreparedOutgoingMessage(messageType: messageType) + } + /// Use this _only_ to "prepare" outgoing contact sync that, by definition, already uploaded their attachment. /// Instantly prepares because...these messages don't need any preparing. public static func preprepared( @@ -103,6 +116,13 @@ public class PreparedOutgoingMessage { if let storyMessage = message as? OutgoingStoryMessage { return .init(messageType: .story(.init(message: storyMessage))) } + if let reactionMessage = message as? OutgoingReactionMessage { + return .init(messageType: .reactionMessage(.init( + message: reactionMessage, + hasSticker: reactionMessage.createdReaction?.sticker + != nil, + ))) + } return .init(messageType: .transient(message)) case .none: return nil @@ -124,6 +144,11 @@ public class PreparedOutgoingMessage { /// the OutgoingStoryMessage is _not_ persisted to the Interactions table. case story(Story) + /// An OutgoingReactionMessage: a TSMessage subclass we use for sending a reaction. + /// The message being reacted to is a persisted TSMessage and is the owner for any reaction sticker attachments; + /// the OutgoingReactionMessage is _not_ persisted to the Interactions table. + case reactionMessage(ReactionMessage) + /// Catch-all for messages not persisted to the Interactions table. The /// MessageSender will not upload any attachments contained within these /// messages; callers are responsible for uploading them. @@ -147,6 +172,16 @@ public class PreparedOutgoingMessage { message.storyMessageRowId } } + + public struct ReactionMessage { + let message: OutgoingReactionMessage + // If true, there _might_ be a sticker attachment, + // which should be fetched by way of the target + // message (which owns any sticker reaction attachments). + // If false, definitively has no sticker attachment and + // the check can be skipped. + let hasSticker: Bool + } } // MARK: - Public getters @@ -191,16 +226,46 @@ public class PreparedOutgoingMessage { )?.attachmentRowId, ].compacted() } + case .reactionMessage(let reactionMessage): + guard + let createdReaction = reactionMessage.message.createdReaction, + // Legacy reactions can use e164, not aci, but they wouldn't + // have stickers. + let reactorAci = createdReaction.reactorAci, + let targetMessage = DependenciesBridge.shared.interactionStore + .fetchInteraction( + uniqueId: reactionMessage.message.messageUniqueId, + tx: tx + ) + as? TSMessage, + let targetMessageRowId = targetMessage.sqliteRowId, + let reaction = DependenciesBridge.shared.reactionStore + .reaction( + for: reactorAci, + messageId: targetMessage.uniqueId, + tx: tx + ), + let reactionRowId = reaction.id + else { + return [] + } + return attachmentStore.fetchReferences( + owner: .messageReactionSticker( + messageRowId: targetMessageRowId, + reactionRowId: reactionRowId + ), + tx: tx + ).map(\.attachmentRowId) case .transient: return [] } } - public func hasRenderableContent(tx: DBReadTransaction) -> Bool { + public func isRenderableContentSendPriority(tx: DBReadTransaction) -> Bool { switch messageType { case .persisted(let message): return message.message.insertedMessageHasRenderableContent(rowId: message.rowId, tx: tx) - case .editMessage, .story: + case .editMessage, .story, .reactionMessage: // Always have renderable content; send at normal priority. return true case .transient: @@ -225,12 +290,12 @@ public class PreparedOutgoingMessage { case .story: // We don't donate story message intents. return nil - case .transient(let message): - if message is OutgoingReactionMessage { - return message - } else { - return nil - } + case .reactionMessage(let message): + return message.message + case .transient: + // We don't donate transient message intents, except + // reactions, which are handled above. + return nil } } @@ -284,6 +349,8 @@ public class PreparedOutgoingMessage { return try .init(editMessage: edit, isHighPriority: isHighPriority, transaction: tx) case .story(let story): return .init(storyMessage: story, isHighPriority: isHighPriority) + case .reactionMessage(let message): + return .init(transientMessage: message.message, isHighPriority: isHighPriority) case .transient(let message): return .init(transientMessage: message, isHighPriority: isHighPriority) } @@ -310,6 +377,8 @@ public class PreparedOutgoingMessage { return message.editedMessage.body case .story: return nil + case .reactionMessage(let message): + return message.message.body case .transient(let message): return message.body } @@ -330,6 +399,8 @@ public class PreparedOutgoingMessage { return message.messageForSending case .story(let storyMessage): return storyMessage.message + case .reactionMessage(let message): + return message.message case .transient(let message): return message } @@ -346,16 +417,19 @@ public class PreparedOutgoingMessage { return message.editedMessage case .story(let storyMessage): return storyMessage.message + case .reactionMessage(let message): + return message.message case .transient(let message): // Do send states even matter for transient messages? // Yes. + // Thanks. return message } } public var isPinChange: Bool { switch messageType { - case .persisted, .editMessage, .story: + case .persisted, .editMessage, .story, .reactionMessage: return false case .transient(let message): return message is OutgoingPinMessage || message is OutgoingUnpinMessage @@ -376,7 +450,7 @@ extension Array where Element == PreparedOutgoingMessage { var storyMessages = [PreparedOutgoingMessage]() for preparedMessage in self { switch preparedMessage.messageType { - case .persisted, .editMessage, .transient: + case .persisted, .editMessage, .transient, .reactionMessage: return preparedMessage.attachmentIdsForUpload(tx: tx) case .story: storyMessages.append(preparedMessage) diff --git a/SignalServiceKit/Messages/OutgoingMessagePreparer/UnpreparedOutgoingMessage.swift b/SignalServiceKit/Messages/OutgoingMessagePreparer/UnpreparedOutgoingMessage.swift index bac9e218c61..9adfc0bb900 100644 --- a/SignalServiceKit/Messages/OutgoingMessagePreparer/UnpreparedOutgoingMessage.swift +++ b/SignalServiceKit/Messages/OutgoingMessagePreparer/UnpreparedOutgoingMessage.swift @@ -80,6 +80,22 @@ public class UnpreparedOutgoingMessage { ))) } + static func forOutgoingReactionMessage( + _ message: OutgoingReactionMessage, + targetMessage: TSMessage, + targetMessageRowId: Int64, + reactionRowId: Int64?, /* nil if un-reacting */ + stickerDataSource: MessageStickerDataSource?, + ) -> UnpreparedOutgoingMessage { + return .init(messageType: .reactionMessage(.init( + message: message, + targetMessage: targetMessage, + targetMessageRowId: targetMessageRowId, + reactionRowId: reactionRowId, + stickerDataSource: stickerDataSource, + ))) + } + // MARK: - Preparation /// "Prepares" the outgoing message, inserting it into the database if needed and @@ -100,6 +116,8 @@ public class UnpreparedOutgoingMessage { ) case .story(let story): return story.message.timestamp + case .reactionMessage(let reactionMessage): + return reactionMessage.message.timestamp case .transient(let message): return message.timestamp } @@ -120,6 +138,11 @@ public class UnpreparedOutgoingMessage { /// the OutgoingStoryMessage is _not_ persisted to the Interactions table. case story(Story) + /// An OutgoingReactionMessage: a TSMessage subclass we use for sending a reaction. + /// The message being reacted to is a persisted TSMessage and is the owner for any reaction sticker attachments; + /// the OutgoingReactionMessage is _not_ persisted to the Interactions table. + case reactionMessage(ReactionMessage) + /// Catch-all for messages not persisted to the Interactions table. The /// MessageSender will not upload any attachments contained within these /// messages; callers are responsible for uploading them. @@ -148,6 +171,17 @@ public class UnpreparedOutgoingMessage { let message: OutgoingStoryMessage let storyMessageRowId: Int64 } + + struct ReactionMessage { + let message: OutgoingReactionMessage + // The message being reacted to. + let targetMessage: TSMessage + let targetMessageRowId: Int64 + // The OWSReaction's row id, if this is a reaction + // (nil if the message removes a reaction). + let reactionRowId: Int64? + let stickerDataSource: MessageStickerDataSource? + } } private let messageType: MessageType @@ -167,6 +201,9 @@ public class UnpreparedOutgoingMessage { preparedMessageType = try prepareEditMessage(message, tx: tx) case .story(let story): preparedMessageType = prepareStoryMessage(story) + case .reactionMessage(let reactionMessage): + preparedMessageType = try prepareReactionMessage( + reactionMessage, tx: tx) case .transient(let message): preparedMessageType = prepareTransientMessage(message) } @@ -405,6 +442,56 @@ public class UnpreparedOutgoingMessage { )) } + private func prepareReactionMessage( + _ reactionMessage: MessageType.ReactionMessage, + tx: DBWriteTransaction, + ) throws -> PreparedOutgoingMessage.MessageType { + guard + let thread = reactionMessage.message.thread(tx: tx), + let threadRowId = thread.sqliteRowId + else { + throw OWSAssertionError("Outgoing message missing thread.") + } + + let attachmentManager = DependenciesBridge.shared.attachmentManager + let messageStickerManager = DependenciesBridge.shared.messageStickerManager + let validatedMessageSticker = try reactionMessage.stickerDataSource.map { + return try messageStickerManager.validateMessageSticker(dataSource: $0) + } + + if let validatedMessageSticker { + guard let reactionRowId = reactionMessage.reactionRowId else { + throw OWSAssertionError("Cannot apply a sticker without an OWSReaction") + } + let attachmentID = try attachmentManager.createAttachmentStream( + from: OwnedAttachmentDataSource( + dataSource: validatedMessageSticker.attachmentDataSource, + owner: .messageReactionSticker(.init( + messageRowId: reactionMessage.targetMessageRowId, + receivedAtTimestamp: reactionMessage.targetMessage.receivedAtTimestamp, + threadRowId: threadRowId, + isPastEditRevision: reactionMessage.targetMessage.isPastEditRevision(), + stickerPackId: validatedMessageSticker.sticker.packId, + stickerId: validatedMessageSticker.sticker.stickerId, + reactionRowId: reactionRowId, + )), + ), + tx: tx, + ) + Logger.info("Created sticker attachment \(attachmentID) for outgoing reaction message \(reactionMessage.message.timestamp)") + + StickerManager.stickerWasSent( + validatedMessageSticker.sticker.info, + transaction: tx, + ) + } + + return .reactionMessage(PreparedOutgoingMessage.MessageType.ReactionMessage( + message: reactionMessage.message, + hasSticker: validatedMessageSticker != nil + )) + } + private func prepareTransientMessage( _ message: TransientOutgoingMessage, ) -> PreparedOutgoingMessage.MessageType { diff --git a/SignalServiceKit/Messages/Reactions/CustomReactionItem.swift b/SignalServiceKit/Messages/Reactions/CustomReactionItem.swift new file mode 100644 index 00000000000..d1405374ac1 --- /dev/null +++ b/SignalServiceKit/Messages/Reactions/CustomReactionItem.swift @@ -0,0 +1,70 @@ +// +// Copyright 2026 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only +// + +/// Users can select the set of emoji/stickers that appear in the pop up reaction +/// menu (with all others available in the full menu). These are used to represent +/// each item in that selection. +public struct CustomReactionItem: Codable, Equatable, Hashable { + public let emoji: String + public let sticker: StickerInfo? + + public init(emoji: String, sticker: StickerInfo?) { + self.emoji = emoji + self.sticker = sticker + } + + public var isStickerReaction: Bool { + sticker != nil + } + + public enum CodingKeys: CodingKey { + case emoji + case stickerPackId + case stickerPackKey + case stickerId + } + + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.emoji = try container.decode(String.self, forKey: .emoji) + if + let stickerPackId = try container.decodeIfPresent(Data.self, forKey: .stickerPackId), + let stickerPackKey = try container.decodeIfPresent(Data.self, forKey: .stickerPackKey), + let stickerId = try container.decodeIfPresent(UInt32.self, forKey: .stickerId) + { + self.sticker = StickerInfo( + packId: stickerPackId, + packKey: stickerPackKey, + stickerId: stickerId + ) + } else { + self.sticker = nil + } + } + + public func encode(to encoder: any Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(emoji, forKey: .emoji) + if let sticker { + try container.encode(sticker.packId, forKey: .stickerPackId) + try container.encode(sticker.packKey, forKey: .stickerPackKey) + try container.encode(sticker.stickerId, forKey: .stickerId) + } + } + + public static func ==(_ lhs: CustomReactionItem, _ rhs: CustomReactionItem) -> Bool { + return lhs.emoji == rhs.emoji + && lhs.sticker?.packId == rhs.sticker?.packId + && lhs.sticker?.packKey == rhs.sticker?.packKey + && lhs.sticker?.stickerId == rhs.sticker?.stickerId + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(emoji) + hasher.combine(sticker?.packId) + hasher.combine(sticker?.packKey) + hasher.combine(sticker?.stickerId) + } +} diff --git a/SignalServiceKit/Messages/Reactions/OWSReaction.swift b/SignalServiceKit/Messages/Reactions/OWSReaction.swift index 98d888cc512..97471488d62 100644 --- a/SignalServiceKit/Messages/Reactions/OWSReaction.swift +++ b/SignalServiceKit/Messages/Reactions/OWSReaction.swift @@ -24,6 +24,9 @@ public final class OWSReaction: NSObject, SDSCodableModel, Decodable, NSSecureCo case sentAtTimestamp case uniqueMessageId case read + case stickerPackId + case stickerPackKey + case stickerId } public var id: Int64? @@ -51,6 +54,20 @@ public final class OWSReaction: NSObject, SDSCodableModel, Decodable, NSSecureCo public let sortOrder: UInt64 public private(set) var read: Bool + private let stickerPackId: Data? + private let stickerPackKey: Data? + private let stickerId: UInt32? + public var sticker: StickerInfo? { + guard let stickerPackId, let stickerPackKey, let stickerId else { + return nil + } + return StickerInfo( + packId: stickerPackId, + packKey: stickerPackKey, + stickerId: stickerId + ) + } + public var reactor: SignalServiceAddress { SignalServiceAddress.legacyAddress(serviceId: reactorAci, phoneNumber: reactorPhoneNumber) } @@ -61,6 +78,7 @@ public final class OWSReaction: NSObject, SDSCodableModel, Decodable, NSSecureCo public convenience init( uniqueMessageId: String, emoji: String, + sticker: StickerInfo?, reactor: Aci, sentAtTimestamp: UInt64, receivedAtTimestamp: UInt64, @@ -68,6 +86,7 @@ public final class OWSReaction: NSObject, SDSCodableModel, Decodable, NSSecureCo self.init( uniqueMessageId: uniqueMessageId, emoji: emoji, + sticker: sticker, reactorAci: reactor, reactorPhoneNumber: nil, sentAtTimestamp: sentAtTimestamp, @@ -78,6 +97,7 @@ public final class OWSReaction: NSObject, SDSCodableModel, Decodable, NSSecureCo private init( uniqueMessageId: String, emoji: String, + sticker: StickerInfo?, reactorAci: Aci?, reactorPhoneNumber: String?, sentAtTimestamp: UInt64, @@ -91,11 +111,15 @@ public final class OWSReaction: NSObject, SDSCodableModel, Decodable, NSSecureCo self.sentAtTimestamp = sentAtTimestamp self.sortOrder = sortOrder self.read = false + self.stickerPackId = sticker?.packId + self.stickerPackKey = sticker?.packKey + self.stickerId = sticker?.stickerId } public static func fromRestoredBackup( uniqueMessageId: String, emoji: String, + sticker: StickerInfo?, reactorAci: Aci, sentAtTimestamp: UInt64, sortOrder: UInt64, @@ -103,6 +127,7 @@ public final class OWSReaction: NSObject, SDSCodableModel, Decodable, NSSecureCo return Self( uniqueMessageId: uniqueMessageId, emoji: emoji, + sticker: sticker, reactorAci: reactorAci, reactorPhoneNumber: nil, sentAtTimestamp: sentAtTimestamp, @@ -113,6 +138,7 @@ public final class OWSReaction: NSObject, SDSCodableModel, Decodable, NSSecureCo public static func fromRestoredBackup( uniqueMessageId: String, emoji: String, + sticker: StickerInfo?, reactorE164: E164, sentAtTimestamp: UInt64, sortOrder: UInt64, @@ -120,6 +146,7 @@ public final class OWSReaction: NSObject, SDSCodableModel, Decodable, NSSecureCo return .init( uniqueMessageId: uniqueMessageId, emoji: emoji, + sticker: sticker, reactorAci: nil, reactorPhoneNumber: reactorE164.stringValue, sentAtTimestamp: sentAtTimestamp, @@ -155,6 +182,10 @@ public final class OWSReaction: NSObject, SDSCodableModel, Decodable, NSSecureCo sentAtTimestamp = try container.decode(UInt64.self, forKey: .sentAtTimestamp) sortOrder = try container.decode(UInt64.self, forKey: .sortOrder) read = try container.decode(Bool.self, forKey: .read) + + stickerPackId = try container.decodeIfPresent(Data.self, forKey: .stickerPackId) + stickerPackKey = try container.decodeIfPresent(Data.self, forKey: .stickerPackKey) + stickerId = try container.decodeIfPresent(UInt32.self, forKey: .stickerId) } public func encode(to encoder: Encoder) throws { @@ -177,6 +208,10 @@ public final class OWSReaction: NSObject, SDSCodableModel, Decodable, NSSecureCo try container.encode(sentAtTimestamp, forKey: .sentAtTimestamp) try container.encode(sortOrder, forKey: .sortOrder) try container.encode(read, forKey: .read) + + try stickerPackId.map { try container.encode($0, forKey: .stickerPackId) } + try stickerPackKey.map { try container.encode($0, forKey: .stickerPackKey) } + try stickerId.map { try container.encode($0, forKey: .stickerId) } } // MARK: - NSSecureCoding @@ -200,6 +235,16 @@ public final class OWSReaction: NSObject, SDSCodableModel, Decodable, NSSecureCo coder.encode(NSNumber(value: sentAtTimestamp), forKey: CodingKeys.sentAtTimestamp.rawValue) coder.encode(NSNumber(value: sortOrder), forKey: CodingKeys.sortOrder.rawValue) coder.encode(NSNumber(value: read), forKey: CodingKeys.read.rawValue) + + if let stickerPackId { + coder.encode(stickerPackId, forKey: CodingKeys.stickerPackId.rawValue) + } + if let stickerPackKey { + coder.encode(stickerPackKey, forKey: CodingKeys.stickerPackKey.rawValue) + } + if let stickerId { + coder.encode(NSNumber(value: stickerId), forKey: CodingKeys.stickerId.rawValue) + } } public required init?(coder: NSCoder) { @@ -247,5 +292,18 @@ public final class OWSReaction: NSObject, SDSCodableModel, Decodable, NSSecureCo return nil } self.read = read + + self.stickerPackId = coder.decodeObject( + of: NSData.self, + forKey: CodingKeys.stickerPackId.rawValue + ) as? Data + self.stickerPackKey = coder.decodeObject( + of: NSData.self, + forKey: CodingKeys.stickerPackKey.rawValue + ) as? Data + self.stickerId = coder.decodeObject( + of: NSNumber.self, + forKey: CodingKeys.stickerId.rawValue + )?.uint32Value } } diff --git a/SignalServiceKit/Messages/Reactions/OutgoingReactionMessage.swift b/SignalServiceKit/Messages/Reactions/OutgoingReactionMessage.swift index e75e4512262..9f1bdcc4f12 100644 --- a/SignalServiceKit/Messages/Reactions/OutgoingReactionMessage.swift +++ b/SignalServiceKit/Messages/Reactions/OutgoingReactionMessage.swift @@ -144,6 +144,32 @@ final class OutgoingReactionMessage: TransientOutgoingMessage { } reactionBuilder.setTargetAuthorAciBinary(messageAuthor.serviceIdBinary) + if let sticker = createdReaction?.sticker { + if + let reactionRowId = createdReaction?.id, + let messageRowId = message.sqliteRowId, + let referencedAttachment = DependenciesBridge.shared.attachmentStore.fetchAnyReferencedAttachment( + for: .messageReactionSticker(messageRowId: messageRowId, reactionRowId: reactionRowId), + tx: tx, + ), + let attachmentProto = referencedAttachment.asProtoForSending() + { + let stickerBuilder = SSKProtoDataMessageSticker.builder( + packID: sticker.packId, + packKey: sticker.packKey, + stickerID: sticker.stickerId, + data: attachmentProto, + ) + stickerBuilder.setEmoji(emoji) + do { + let stickerProto = try stickerBuilder.build() + reactionBuilder.setSticker(stickerProto) + } catch { + owsFailDebug("Couldn't build protobuf: \(error)") + } + } + } + do { return try reactionBuilder.build() } catch { @@ -191,10 +217,65 @@ final class OutgoingReactionMessage: TransientOutgoingMessage { message.recordReaction( for: localAci, emoji: previousReaction.emoji, + sticker: previousReaction.sticker, sentAtTimestamp: previousReaction.sentAtTimestamp, sortOrder: previousReaction.sortOrder, tx: tx, ) + if + let reappliedReactionRowId = message.reaction(for: localAci, tx: tx)?.id, + let messageRowId = message.sqliteRowId, + let previousReactionSticker = previousReaction.sticker, + let threadRowId = message.thread(tx: tx)?.sqliteRowId + { + // Reapply the previous sticker attachment. + // This is tricky because when we applied the reaction that + // now failed to send, we threw away the previous sticker + // attachment and its cdn info. + // However, we can only send sticker reactions for installed + // sticker packs, so we should be able to recreate the attachment + // from the installed sticker pack. If the user uninstalled the + // sticker pack between then and now, this will fail and we just + // fall back to emoji. + let installedSticker = StickerManager.fetchInstalledSticker( + packId: previousReactionSticker.packId, + stickerId: previousReactionSticker.stickerId, + transaction: tx + ) + if let installedSticker { + let contentType = StickerManager.stickerType(forContentType: installedSticker.contentType) + let attachment = try? DependenciesBridge.shared.attachmentStore.insert( + Attachment.ConstructionParams.forRevertedStickerReactionAttachment( + mimeType: contentType.mimeType + ), + reference: AttachmentReference.ConstructionParams( + owner: .message(.reactionSticker(.init( + messageRowId: messageRowId, + receivedAtTimestamp: message.receivedAtTimestamp, + threadRowId: threadRowId, + contentType: nil, + isPastEditRevision: message.isPastEditRevision(), + stickerPackId: previousReactionSticker.packId, + stickerId: previousReactionSticker.stickerId, + reactionRowId: reappliedReactionRowId + ))), + sourceFilename: contentType.fileExtension, + sourceUnencryptedByteCount: nil, + sourceMediaSizePixels: nil + ), + tx: tx + ) + if let attachment { + // Kick off a local clone download from the installed sticker. + DependenciesBridge.shared.attachmentDownloadManager.enqueueDownloadOfAttachment( + id: attachment.id, + priority: .localClone, + source: .transitTier, + tx: tx + ) + } + } + } } else { message.removeReaction(for: localAci, tx: tx) } diff --git a/SignalServiceKit/Messages/Reactions/ReactionManager.swift b/SignalServiceKit/Messages/Reactions/ReactionManager.swift index 70b229bef23..f17032eb9c9 100644 --- a/SignalServiceKit/Messages/Reactions/ReactionManager.swift +++ b/SignalServiceKit/Messages/Reactions/ReactionManager.swift @@ -9,40 +9,96 @@ public import LibSignalClient @objc(OWSReactionManager) public class ReactionManager: NSObject { public static let localUserReacted = Notification.Name("localUserReacted") - public static let defaultEmojiSet = ["❤️", "👍", "👎", "😂", "😮", "😢"] + public static let defaultCustomReactionSet = ["❤️", "👍", "👎", "😂", "😮", "😢"].map { + CustomReactionItem(emoji: $0, sticker: nil) + } - private static let emojiSetKVS = KeyValueStore(collection: "EmojiSetKVS") - private static let emojiSetKey = "EmojiSetKey" + private static let customReactionKVStore = KeyValueStore(collection: "EmojiSetKVS") + private static let customReactionSetKey = "ReactionItemsKey" + /// Legacy preferred custom reactions that are just emoji strings; read-only for backwards compatibility + private static let customEmojiSetKey = "EmojiSetKey" - /// Returns custom emoji set by the user, or `nil` if the user has never customized their emoji + /// Returns custom emoji/stickers set by the user, or `nil` if the user has never customized their reactions /// (including on linked devices). /// /// This is important because we shouldn't ever send the default set of reactions over storage service. - public class func customEmojiSet(transaction: DBReadTransaction) -> [String]? { - return emojiSetKVS.getStringArray(emojiSetKey, transaction: transaction) + public class func customReactionSet(tx: DBReadTransaction) -> [CustomReactionItem]? { + if + let items: [CustomReactionItem] = try? customReactionKVStore.getCodableValue( + forKey: customReactionSetKey, + transaction: tx + ) + { + return items + } else if + let legacyEmoji = customReactionKVStore.getStringArray( + customEmojiSetKey, + transaction: tx + ) + { + return legacyEmoji.map { + CustomReactionItem(emoji: $0, sticker: nil) + } + } + return nil } - public class func setCustomEmojiSet(_ emojis: [String]?, transaction: DBWriteTransaction) { - emojiSetKVS.setStringArray(emojis, key: emojiSetKey, transaction: transaction) + public class func setCustomReactionSet( + _ items: [CustomReactionItem]?, + tx: DBWriteTransaction + ) { + guard let items else { + customReactionKVStore.removeValue(forKey: customReactionSetKey, transaction: tx) + return + } + try? customReactionKVStore.setCodable( + items, + key: customReactionSetKey, + transaction: tx + ) } @discardableResult public class func localUserReacted( - to messageUniqueId: String, + to targetMessage: TSMessage, emoji: String, + sticker: MessageStickerDataSource?, isRemoving: Bool, isHighPriority: Bool = false, tx: DBWriteTransaction, ) -> Promise { + guard let targetMessageRowId = targetMessage.sqliteRowId else { + return Promise(error: OWSAssertionError("Can't react to uninserted message")) + } let outgoingMessage: OutgoingReactionMessage do { - outgoingMessage = try _localUserReacted(to: messageUniqueId, emoji: emoji, isRemoving: isRemoving, tx: tx) + outgoingMessage = try _localUserReacted( + to: targetMessage.uniqueId, + emoji: emoji, + isRemoving: isRemoving, + sticker: sticker?.info, + tx: tx + ) } catch { owsFailDebug("Error: \(error)") return Promise(error: error) } + NotificationCenter.default.post(name: ReactionManager.localUserReacted, object: nil) - let preparedMessage = PreparedOutgoingMessage.preprepared(transientMessageWithoutAttachments: outgoingMessage) + let unpreparedMessage = UnpreparedOutgoingMessage.forOutgoingReactionMessage( + outgoingMessage, + targetMessage: targetMessage, + targetMessageRowId: targetMessageRowId, + reactionRowId: outgoingMessage.createdReaction?.id, + stickerDataSource: sticker + ) + let preparedMessage: PreparedOutgoingMessage + do { + preparedMessage = try unpreparedMessage.prepare(tx: tx) + } catch { + owsFailDebug("Error preparing reaction: \(error)") + return Promise(error: error) + } return SSKEnvironment.shared.messageSenderJobQueueRef.add( .promise, message: preparedMessage, @@ -56,6 +112,7 @@ public class ReactionManager: NSObject { to messageUniqueId: String, emoji: String, isRemoving: Bool, + sticker: StickerInfo?, tx: DBWriteTransaction, ) throws -> OutgoingReactionMessage { assert(emoji.isSingleEmoji) @@ -76,7 +133,7 @@ public class ReactionManager: NSObject { let previousReaction = message.reaction(for: localAci, tx: tx) - let createdReaction: OWSReaction? + var createdReaction: OWSReaction? if isRemoving { message.removeReaction(for: localAci, tx: tx) createdReaction = nil @@ -84,6 +141,7 @@ public class ReactionManager: NSObject { createdReaction = message.recordReaction( for: localAci, emoji: emoji, + sticker: sticker, sentAtTimestamp: timestamp, receivedAtTimestamp: timestamp, tx: tx, @@ -91,6 +149,9 @@ public class ReactionManager: NSObject { // Always immediately mark outgoing reactions as read. createdReaction?.markAsRead(transaction: tx) + + // Refetch to ensure up to date + createdReaction = message.reaction(for: localAci, tx: tx) } let dmConfigurationStore = DependenciesBridge.shared.disappearingMessagesConfigurationStore @@ -184,24 +245,83 @@ public class ReactionManager: NSObject { if reaction.hasRemove, reaction.remove { message.removeReaction(for: reactor, tx: transaction) } else { - let reaction = message.recordReaction( + let sticker: StickerInfo? + let stickerPointer: SSKProtoAttachmentPointer? + if + let stickerProto = reaction.sticker, + stickerProto.packID.isEmpty.negated, + stickerProto.packKey.isEmpty.negated, + stickerProto.data.cdnKey?.nilIfEmpty != nil + { + sticker = StickerInfo( + packId: stickerProto.packID, + packKey: stickerProto.packKey, + stickerId: stickerProto.stickerID + ) + stickerPointer = stickerProto.data + } else { + sticker = nil + stickerPointer = nil + } + + let recordedReactions = message.recordReaction( for: reactor, emoji: emoji, + sticker: sticker, sentAtTimestamp: timestamp, receivedAtTimestamp: NSDate.ows_millisecondTimeStamp(), tx: transaction, ) + // Refetch to get the sqlite id + let recordedReaction = message.reaction(for: reactor, tx: transaction) + + if + let recordedReaction, + let sticker, + let stickerPointer, + let reactionRowId = recordedReaction.id, + let messageRowId = message.sqliteRowId, + let threadRowId = thread.sqliteRowId + { + let attachmentManager = DependenciesBridge.shared.attachmentManager + let attachmentId = try? attachmentManager.createAttachmentPointer( + from: OwnedAttachmentPointerProto( + proto: stickerPointer, + owner: .messageReactionSticker(.init( + messageRowId: messageRowId, + receivedAtTimestamp: message.receivedAtTimestamp, + threadRowId: threadRowId, + isPastEditRevision: message.isPastEditRevision(), + stickerPackId: sticker.packId, + stickerId: sticker.stickerId, + reactionRowId: reactionRowId, + )), + ), + tx: transaction, + ) + + if let attachmentId { + DependenciesBridge.shared.attachmentDownloadManager + .enqueueDownloadOfAttachment( + id: attachmentId, + priority: .default, + source: .transitTier, + tx: transaction + ) + } + } // If this is a reaction to a message we sent, notify the user. let localAci = DependenciesBridge.shared.tsAccountManager.localIdentifiers(tx: transaction)?.aci if - let reaction, - reaction.oldValue?.sentAtTimestamp != reaction.newValue.sentAtTimestamp, + let recordedReaction, + recordedReactions?.oldValue?.sentAtTimestamp + != recordedReaction.sentAtTimestamp, let message = message as? TSOutgoingMessage, reactor != localAci { SSKEnvironment.shared.notificationPresenterRef.notifyUser( - forReaction: reaction.newValue, + forReaction: recordedReaction, onOutgoingMessage: message, thread: thread, transaction: transaction, @@ -245,9 +365,25 @@ public class ReactionManager: NSObject { let message: TSMessage let localAci = DependenciesBridge.shared.tsAccountManager.localIdentifiers(tx: transaction)?.aci + let reactionStickerInfo: MessageSticker? = { + guard + let stickerProto = reaction.sticker, + !stickerProto.packID.isEmpty, + !stickerProto.packKey.isEmpty + else { return nil } + return MessageSticker( + info: StickerInfo( + packId: stickerProto.packID, + packKey: stickerProto.packKey, + stickerId: stickerProto.stickerID + ), + emoji: nil + ) + }() if reactor == localAci { let builder: TSOutgoingMessageBuilder = .withDefaultValues(thread: thread) populateStoryContext(on: builder) + builder.messageSticker = reactionStickerInfo message = builder.build(transaction: transaction) } else { let builder: TSIncomingMessageBuilder = .withDefaultValues( @@ -256,11 +392,41 @@ public class ReactionManager: NSObject { serverTimestamp: serverTimestamp, ) populateStoryContext(on: builder) + builder.messageSticker = reactionStickerInfo message = builder.build() } message.anyInsert(transaction: transaction) + if + let stickerProto = reaction.sticker, + let messageRowId = message.sqliteRowId, + let threadRowId = thread.sqliteRowId + { + let attachmentManager = DependenciesBridge.shared.attachmentManager + _ = try? attachmentManager.createAttachmentPointer( + from: OwnedAttachmentPointerProto( + proto: stickerProto.data, + owner: .messageSticker(.init( + messageRowId: messageRowId, + receivedAtTimestamp: message.receivedAtTimestamp, + threadRowId: threadRowId, + isPastEditRevision: message.isPastEditRevision(), + stickerPackId: stickerProto.packID, + stickerId: stickerProto.stickerID + )) + ), + tx: transaction + ) + + DependenciesBridge.shared.attachmentDownloadManager + .enqueueDownloadOfAttachmentsForMessage( + message, + priority: .default, + tx: transaction + ) + } + if let incomingMessage = message as? TSIncomingMessage { SSKEnvironment.shared.notificationPresenterRef.notifyUser(forIncomingMessage: incomingMessage, thread: thread, transaction: transaction) } else if let outgoingMessage = message as? TSOutgoingMessage { diff --git a/SignalServiceKit/Messages/Reactions/ReactionStore.swift b/SignalServiceKit/Messages/Reactions/ReactionStore.swift index 648f96e0afc..d58f2419260 100644 --- a/SignalServiceKit/Messages/Reactions/ReactionStore.swift +++ b/SignalServiceKit/Messages/Reactions/ReactionStore.swift @@ -34,12 +34,6 @@ public protocol ReactionStore { messageId: MessageId, tx: DBReadTransaction, ) -> [String] - - /// Delete all reaction records associated with this message - func deleteAllReactions( - messageId: MessageId, - tx: DBWriteTransaction, - ) } public class ReactionStoreImpl: ReactionStore { @@ -66,9 +60,4 @@ public class ReactionStoreImpl: ReactionStore { ReactionFinder(uniqueMessageId: messageId) .allUniqueIds(transaction: tx) } - - public func deleteAllReactions(messageId: MessageId, tx: DBWriteTransaction) { - ReactionFinder(uniqueMessageId: messageId) - .deleteAllReactions(transaction: tx) - } } diff --git a/SignalServiceKit/Messages/Reactions/StoryReaction.swift b/SignalServiceKit/Messages/Reactions/StoryReaction.swift new file mode 100644 index 00000000000..03f5c1c31be --- /dev/null +++ b/SignalServiceKit/Messages/Reactions/StoryReaction.swift @@ -0,0 +1,30 @@ +// +// Copyright 2026 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only +// + +// Every story reaction has an emoji; it may also have a sticker +// (if it does, the sticker's associated emoji will be set). +public struct StoryReaction { + public let emoji: String + public let sticker: ReferencedAttachment? + public let stickerInfo: StickerInfo? + + public init(emoji: String, sticker: ReferencedAttachment?, stickerInfo: StickerInfo?) { + self.emoji = emoji + self.sticker = sticker + self.stickerInfo = stickerInfo + } +} + +extension StoryReaction: Equatable { + public static func ==( + lhs: StoryReaction, + rhs: StoryReaction + ) -> Bool { + return lhs.emoji == rhs.emoji + && lhs.sticker?.attachment.id == rhs.sticker?.attachment.id + && lhs.sticker?.reference.owner.id == rhs.sticker?.reference.owner.id + && lhs.stickerInfo == rhs.stickerInfo + } +} diff --git a/SignalServiceKit/Messages/Stickers/StickerInfo.swift b/SignalServiceKit/Messages/Stickers/StickerInfo.swift index 2a571467b5c..89d89740db0 100644 --- a/SignalServiceKit/Messages/Stickers/StickerInfo.swift +++ b/SignalServiceKit/Messages/Stickers/StickerInfo.swift @@ -66,7 +66,7 @@ public final class StickerInfo: NSObject, NSSecureCoding { ) } - var packInfo: StickerPackInfo { + public var packInfo: StickerPackInfo { return StickerPackInfo(packId: self.packId, packKey: self.packKey) } diff --git a/SignalServiceKit/Messages/Stickers/StickerManager.swift b/SignalServiceKit/Messages/Stickers/StickerManager.swift index fda7d47ebf0..632475ba226 100644 --- a/SignalServiceKit/Messages/Stickers/StickerManager.swift +++ b/SignalServiceKit/Messages/Stickers/StickerManager.swift @@ -35,6 +35,9 @@ public class StickerManager: NSObject { @objc public static let packKeyLength: UInt = 32 + // If a sticker has no emoji, fall back to this. + public static let fallbackStickerEmoji = "💌" + // MARK: - Notifications public static let packsDidChange = Notification.Name("packsDidChange") @@ -607,9 +610,13 @@ public class StickerManager: NSObject { } public class func isStickerInstalled(stickerInfo: StickerInfo, transaction: DBReadTransaction) -> Bool { + return installedSticker(stickerInfo: stickerInfo, transaction: transaction) != nil + } + + public class func installedSticker(stickerInfo: StickerInfo, transaction: DBReadTransaction) -> InstalledStickerRecord? { let installedStickerCache = SSKEnvironment.shared.modelReadCachesRef.installedStickerCache let uniqueId = InstalledStickerRecord.uniqueId(for: stickerInfo) - return installedStickerCache.getInstalledSticker(uniqueId: uniqueId, transaction: transaction) != nil + return installedStickerCache.getInstalledSticker(uniqueId: uniqueId, transaction: transaction) } typealias CleanupCompletion = () -> Void @@ -944,7 +951,7 @@ public class StickerManager: NSObject { private static let kRecentStickersMaxCount: Int = 25 public class func stickerWasSent(_ stickerInfo: StickerInfo, transaction: DBWriteTransaction) { - guard isStickerInstalled(stickerInfo: stickerInfo, transaction: transaction) else { + guard let installedSticker = installedSticker(stickerInfo: stickerInfo, transaction: transaction) else { return } store.prependToOrderedUniqueArray( @@ -953,6 +960,16 @@ public class StickerManager: NSObject { maxCount: kRecentStickersMaxCount, tx: transaction, ) + + // Mirror to recent reactions. + Self.recordRecentReaction( + CustomReactionItem( + emoji: installedSticker.emojiString?.nilIfEmpty ?? fallbackStickerEmoji, + sticker: stickerInfo + ), + tx: transaction + ) + NotificationCenter.default.postOnMainThread(name: recentStickersDidChange, object: nil) } @@ -961,6 +978,7 @@ public class StickerManager: NSObject { transaction: DBWriteTransaction, ) { store.removeFromOrderedUniqueArray(key: kRecentStickersKey, value: stickerInfo.asKey(), tx: transaction) + removeRecentReaction(withSticker: stickerInfo, tx: transaction) NotificationCenter.default.postOnMainThread(name: recentStickersDidChange, object: nil) } @@ -970,7 +988,7 @@ public class StickerManager: NSObject { public class func recentStickers() -> [StickerInfo] { var result = [StickerInfo]() SSKEnvironment.shared.databaseStorageRef.read { transaction in - result = self.recentStickers(transaction: transaction) + result = self.recentStickers(transaction: transaction).map(\.info) } return result } @@ -978,9 +996,9 @@ public class StickerManager: NSObject { // Returned in descending order of recency. // // Only returns installed stickers. - private class func recentStickers(transaction: DBReadTransaction) -> [StickerInfo] { + public class func recentStickers(transaction: DBReadTransaction) -> [InstalledStickerRecord] { let keys = store.orderedUniqueArray(forKey: kRecentStickersKey, tx: transaction) - var result = [StickerInfo]() + var result = [InstalledStickerRecord]() for key in keys { guard let installedSticker = InstalledStickerRecord.anyFetch(uniqueId: key, transaction: transaction) else { owsFailDebug("Couldn't fetch sticker") @@ -990,11 +1008,59 @@ public class StickerManager: NSObject { owsFailDebug("Missing sticker data for installed sticker.") continue } - result.append(installedSticker.info) + result.append(installedSticker) } return result } + private static let recentReactionsKey = "recentReactions" + public static let maxRecentReactionCount = 50 + + public static func getRecentReactions(tx: DBReadTransaction) -> [CustomReactionItem] { + let recentReactions: [CustomReactionItem] = (try? store.getCodableValue( + forKey: Self.recentReactionsKey, + transaction: tx + )) ?? [] + var result = [CustomReactionItem]() + // Filter out uninstalled stickers + for reaction in recentReactions { + guard let sticker = reaction.sticker else { + result.append(reaction) + continue + } + guard + let installedSticker = Self.installedSticker(stickerInfo: sticker, transaction: tx) + else { + owsFailDebug("Couldn't fetch sticker") + continue + } + guard nil != self.stickerDataUrl(forInstalledSticker: installedSticker, verifyExists: true) else { + owsFailDebug("Missing sticker data for installed sticker.") + continue + } + result.append(reaction) + } + return recentReactions + } + + public static func recordRecentReaction(_ reaction: CustomReactionItem, tx: DBWriteTransaction) { + var array = getRecentReactions(tx: tx) + array.insert(reaction, at: 0) + if array.count > Self.maxRecentReactionCount { + _ = array.popLast() + } + try? store.setCodable(array, key: Self.recentReactionsKey, transaction: tx) + } + + private static func removeRecentReaction( + withSticker sticker: StickerInfo, + tx: DBWriteTransaction + ) { + var array = getRecentReactions(tx: tx) + array.removeAll(where: { $0.sticker == sticker }) + try? store.setCodable(array, key: Self.recentReactionsKey, transaction: tx) + } + // MARK: - Misc. // URL might be a sticker or a sticker pack manifest. diff --git a/SignalServiceKit/Notifications/NotificationPresenterImpl.swift b/SignalServiceKit/Notifications/NotificationPresenterImpl.swift index 7a9affce9d2..a14d9454033 100644 --- a/SignalServiceKit/Notifications/NotificationPresenterImpl.swift +++ b/SignalServiceKit/Notifications/NotificationPresenterImpl.swift @@ -969,13 +969,29 @@ public class NotificationPresenterImpl: NotificationPresenter { return nil } }() { - notificationBody = String(format: NotificationStrings.incomingReactionTextMessageFormat, reaction.emoji, bodyDescription) + if reaction.sticker != nil { + notificationBody = String(format: NotificationStrings.incomingReactionStickerTextMessage, bodyDescription) + } else { + notificationBody = String(format: NotificationStrings.incomingReactionTextMessageFormat, reaction.emoji, bodyDescription) + } } else if message.isViewOnceMessage { - notificationBody = String(format: NotificationStrings.incomingReactionViewOnceMessageFormat, reaction.emoji) + if reaction.sticker != nil { + notificationBody = NotificationStrings.incomingReactionStickerViewOnceMessage + } else { + notificationBody = String(format: NotificationStrings.incomingReactionViewOnceMessageFormat, reaction.emoji) + } } else if message.messageSticker != nil { - notificationBody = String(format: NotificationStrings.incomingReactionStickerMessageFormat, reaction.emoji) + if reaction.sticker != nil { + notificationBody = NotificationStrings.incomingReactionStickerStickerMessage + } else { + notificationBody = String(format: NotificationStrings.incomingReactionStickerMessageFormat, reaction.emoji) + } } else if message.contactShare != nil { - notificationBody = String(format: NotificationStrings.incomingReactionContactShareMessageFormat, reaction.emoji) + if reaction.sticker != nil { + notificationBody = NotificationStrings.incomingReactionStickerContactShareMessage + } else { + notificationBody = String(format: NotificationStrings.incomingReactionContactShareMessageFormat, reaction.emoji) + } } else if let messageRowId = message.sqliteRowId, let mediaAttachments = DependenciesBridge.shared.attachmentStore @@ -992,27 +1008,63 @@ public class NotificationPresenterImpl: NotificationPresenter { let firstMimeType = firstAttachment.attachment.mimeType if mediaAttachments.count > 1 { - notificationBody = String(format: NotificationStrings.incomingReactionAlbumMessageFormat, reaction.emoji) + if reaction.sticker != nil { + notificationBody = NotificationStrings.incomingReactionStickerAlbumMessage + } else { + notificationBody = String(format: NotificationStrings.incomingReactionAlbumMessageFormat, reaction.emoji) + } } else if MimeTypeUtil.isSupportedDefinitelyAnimatedMimeType(firstMimeType) { - notificationBody = String(format: NotificationStrings.incomingReactionGifMessageFormat, reaction.emoji) + if reaction.sticker != nil { + notificationBody = NotificationStrings.incomingReactionStickerGifMessage + } else { + notificationBody = String(format: NotificationStrings.incomingReactionGifMessageFormat, reaction.emoji) + } } else if MimeTypeUtil.isSupportedImageMimeType(firstMimeType) { - notificationBody = String(format: NotificationStrings.incomingReactionPhotoMessageFormat, reaction.emoji) + if reaction.sticker != nil { + notificationBody = NotificationStrings.incomingReactionStickerPhotoMessage + } else { + notificationBody = String(format: NotificationStrings.incomingReactionPhotoMessageFormat, reaction.emoji) + } } else if MimeTypeUtil.isSupportedVideoMimeType(firstMimeType), firstRenderingFlag == .shouldLoop { - notificationBody = String(format: NotificationStrings.incomingReactionGifMessageFormat, reaction.emoji) + if reaction.sticker != nil { + notificationBody = NotificationStrings.incomingReactionStickerGifMessage + } else { + notificationBody = String(format: NotificationStrings.incomingReactionGifMessageFormat, reaction.emoji) + } } else if MimeTypeUtil.isSupportedVideoMimeType(firstMimeType) { - notificationBody = String(format: NotificationStrings.incomingReactionVideoMessageFormat, reaction.emoji) + if reaction.sticker != nil { + notificationBody = NotificationStrings.incomingReactionStickerVideoMessage + } else { + notificationBody = String(format: NotificationStrings.incomingReactionVideoMessageFormat, reaction.emoji) + } } else if firstRenderingFlag == .voiceMessage { - notificationBody = String(format: NotificationStrings.incomingReactionVoiceMessageFormat, reaction.emoji) + if reaction.sticker != nil { + notificationBody = NotificationStrings.incomingReactionStickerVoiceMessage + } else { + notificationBody = String(format: NotificationStrings.incomingReactionVoiceMessageFormat, reaction.emoji) + } } else if MimeTypeUtil.isSupportedAudioMimeType(firstMimeType) { - notificationBody = String(format: NotificationStrings.incomingReactionAudioMessageFormat, reaction.emoji) + if reaction.sticker != nil { + notificationBody = NotificationStrings.incomingReactionStickerAudioMessage + } else { + notificationBody = String(format: NotificationStrings.incomingReactionAudioMessageFormat, reaction.emoji) + } } else { - notificationBody = String(format: NotificationStrings.incomingReactionFileMessageFormat, reaction.emoji) + if reaction.sticker != nil { + notificationBody = NotificationStrings.incomingReactionStickerFileMessage + } else { + notificationBody = String(format: NotificationStrings.incomingReactionFileMessageFormat, reaction.emoji) + } } } else { - notificationBody = String(format: NotificationStrings.incomingReactionFormat, reaction.emoji) + if reaction.sticker != nil { + notificationBody = NotificationStrings.incomingReactionSticker + } else { + notificationBody = String(format: NotificationStrings.incomingReactionFormat, reaction.emoji) + } } // Don't reply from lockscreen if anyone in this conversation is diff --git a/SignalServiceKit/Protos/Backups/Backup.pb.swift b/SignalServiceKit/Protos/Backups/Backup.pb.swift index fb5651fb85b..026225f231f 100644 --- a/SignalServiceKit/Protos/Backups/Backup.pb.swift +++ b/SignalServiceKit/Protos/Backups/Backup.pb.swift @@ -722,6 +722,52 @@ public struct BackupProto_AccountData: @unchecked Sendable { public init() {} } + public struct PreferredReactionItem: Sendable { + // SwiftProtobuf.Message conformance is added in an extension below. See the + // `Message` and `Message+*Additions` files in the SwiftProtobuf library for + // methods supported on all messages. + + /// @required + public var emoji: String = String() + + /// If all below fields are present, uses an + /// installed sticker. Otherwise falls back to emoji. + public var stickerPackID: Data { + get {_stickerPackID ?? Data()} + set {_stickerPackID = newValue} + } + /// Returns true if `stickerPackID` has been explicitly set. + public var hasStickerPackID: Bool {self._stickerPackID != nil} + /// Clears the value of `stickerPackID`. Subsequent reads from it will return its default value. + public mutating func clearStickerPackID() {self._stickerPackID = nil} + + public var stickerPackKey: Data { + get {_stickerPackKey ?? Data()} + set {_stickerPackKey = newValue} + } + /// Returns true if `stickerPackKey` has been explicitly set. + public var hasStickerPackKey: Bool {self._stickerPackKey != nil} + /// Clears the value of `stickerPackKey`. Subsequent reads from it will return its default value. + public mutating func clearStickerPackKey() {self._stickerPackKey = nil} + + public var stickerID: UInt32 { + get {_stickerID ?? 0} + set {_stickerID = newValue} + } + /// Returns true if `stickerID` has been explicitly set. + public var hasStickerID: Bool {self._stickerID != nil} + /// Clears the value of `stickerID`. Subsequent reads from it will return its default value. + public mutating func clearStickerID() {self._stickerID = nil} + + public var unknownFields = SwiftProtobuf.UnknownStorage() + + public init() {} + + fileprivate var _stickerPackID: Data? = nil + fileprivate var _stickerPackKey: Data? = nil + fileprivate var _stickerID: UInt32? = nil + } + public struct AccountSettings: @unchecked Sendable { // SwiftProtobuf.Message conformance is added in an extension below. See the // `Message` and `Message+*Additions` files in the SwiftProtobuf library for @@ -907,6 +953,12 @@ public struct BackupProto_AccountData: @unchecked Sendable { set {_uniqueStorage()._seenAdminDeleteEducationDialog = newValue} } + /// Replaces preferredReactionEmoji; older one kept for backwards compatibility + public var preferredReactionItems: [BackupProto_AccountData.PreferredReactionItem] { + get {_storage._preferredReactionItems} + set {_uniqueStorage()._preferredReactionItems = newValue} + } + public var unknownFields = SwiftProtobuf.UnknownStorage() public init() {} @@ -2794,6 +2846,14 @@ public struct BackupProto_DirectStoryReplyMessage: Sendable { set {reply = .emoji(newValue)} } + public var sticker: BackupProto_Sticker { + get { + if case .sticker(let v)? = reply {return v} + return BackupProto_Sticker() + } + set {reply = .sticker(newValue)} + } + public var reactions: [BackupProto_Reaction] = [] public var unknownFields = SwiftProtobuf.UnknownStorage() @@ -2802,6 +2862,7 @@ public struct BackupProto_DirectStoryReplyMessage: Sendable { public enum OneOf_Reply: Equatable, Sendable { case textReply(BackupProto_DirectStoryReplyMessage.TextReply) case emoji(String) + case sticker(BackupProto_Sticker) } @@ -4144,9 +4205,21 @@ public struct BackupProto_Reaction: Sendable { /// incrementing numbers (e.g. 1, 2, 3), others as timestamps. public var sortOrder: UInt64 = 0 + /// If present, Sticker.emoji must match Reaction.emoji + public var sticker: BackupProto_Sticker { + get {_sticker ?? BackupProto_Sticker()} + set {_sticker = newValue} + } + /// Returns true if `sticker` has been explicitly set. + public var hasSticker: Bool {self._sticker != nil} + /// Clears the value of `sticker`. Subsequent reads from it will return its default value. + public mutating func clearSticker() {self._sticker = nil} + public var unknownFields = SwiftProtobuf.UnknownStorage() public init() {} + + fileprivate var _sticker: BackupProto_Sticker? = nil } public struct BackupProto_Poll: Sendable { @@ -7135,9 +7208,58 @@ extension BackupProto_AccountData.AutoDownloadSettings.AutoDownloadOption: Swift public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{2}\0UNKNOWN\0\u{1}NEVER\0\u{1}WIFI\0\u{1}WIFI_AND_CELLULAR\0") } +extension BackupProto_AccountData.PreferredReactionItem: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { + public static let protoMessageName: String = BackupProto_AccountData.protoMessageName + ".PreferredReactionItem" + public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{1}emoji\0\u{1}stickerPackId\0\u{1}stickerPackKey\0\u{1}stickerId\0") + + public mutating func decodeMessage(decoder: inout D) throws { + while let fieldNumber = try decoder.nextFieldNumber() { + // The use of inline closures is to circumvent an issue where the compiler + // allocates stack space for every case branch when no optimizations are + // enabled. https://github.com/apple/swift-protobuf/issues/1034 + switch fieldNumber { + case 1: try { try decoder.decodeSingularStringField(value: &self.emoji) }() + case 2: try { try decoder.decodeSingularBytesField(value: &self._stickerPackID) }() + case 3: try { try decoder.decodeSingularBytesField(value: &self._stickerPackKey) }() + case 4: try { try decoder.decodeSingularUInt32Field(value: &self._stickerID) }() + default: break + } + } + } + + public func traverse(visitor: inout V) throws { + // The use of inline closures is to circumvent an issue where the compiler + // allocates stack space for every if/case branch local when no optimizations + // are enabled. https://github.com/apple/swift-protobuf/issues/1034 and + // https://github.com/apple/swift-protobuf/issues/1182 + if !self.emoji.isEmpty { + try visitor.visitSingularStringField(value: self.emoji, fieldNumber: 1) + } + try { if let v = self._stickerPackID { + try visitor.visitSingularBytesField(value: v, fieldNumber: 2) + } }() + try { if let v = self._stickerPackKey { + try visitor.visitSingularBytesField(value: v, fieldNumber: 3) + } }() + try { if let v = self._stickerID { + try visitor.visitSingularUInt32Field(value: v, fieldNumber: 4) + } }() + try unknownFields.traverse(visitor: &visitor) + } + + public static func ==(lhs: BackupProto_AccountData.PreferredReactionItem, rhs: BackupProto_AccountData.PreferredReactionItem) -> Bool { + if lhs.emoji != rhs.emoji {return false} + if lhs._stickerPackID != rhs._stickerPackID {return false} + if lhs._stickerPackKey != rhs._stickerPackKey {return false} + if lhs._stickerID != rhs._stickerID {return false} + if lhs.unknownFields != rhs.unknownFields {return false} + return true + } +} + extension BackupProto_AccountData.AccountSettings: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { public static let protoMessageName: String = BackupProto_AccountData.protoMessageName + ".AccountSettings" - public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{1}readReceipts\0\u{1}sealedSenderIndicators\0\u{1}typingIndicators\0\u{1}linkPreviews\0\u{1}notDiscoverableByPhoneNumber\0\u{1}preferContactAvatars\0\u{1}universalExpireTimerSeconds\0\u{1}preferredReactionEmoji\0\u{1}displayBadgesOnProfile\0\u{1}keepMutedChatsArchived\0\u{1}hasSetMyStoriesPrivacy\0\u{1}hasViewedOnboardingStory\0\u{1}storiesDisabled\0\u{1}storyViewReceiptsEnabled\0\u{1}hasSeenGroupStoryEducationSheet\0\u{1}hasCompletedUsernameOnboarding\0\u{1}phoneNumberSharingMode\0\u{1}defaultChatStyle\0\u{1}customChatColors\0\u{1}optimizeOnDeviceStorage\0\u{1}backupTier\0\u{2}\u{2}defaultSentMediaQuality\0\u{1}autoDownloadSettings\0\u{2}\u{2}screenLockTimeoutMinutes\0\u{1}pinReminders\0\u{1}appTheme\0\u{1}callsUseLessDataSetting\0\u{1}allowSealedSenderFromAnyone\0\u{1}allowAutomaticKeyVerification\0\u{1}seenAdminDeleteEducationDialog\0\u{c}\u{16}\u{1}\u{c}\u{19}\u{1}") + public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{1}readReceipts\0\u{1}sealedSenderIndicators\0\u{1}typingIndicators\0\u{1}linkPreviews\0\u{1}notDiscoverableByPhoneNumber\0\u{1}preferContactAvatars\0\u{1}universalExpireTimerSeconds\0\u{1}preferredReactionEmoji\0\u{1}displayBadgesOnProfile\0\u{1}keepMutedChatsArchived\0\u{1}hasSetMyStoriesPrivacy\0\u{1}hasViewedOnboardingStory\0\u{1}storiesDisabled\0\u{1}storyViewReceiptsEnabled\0\u{1}hasSeenGroupStoryEducationSheet\0\u{1}hasCompletedUsernameOnboarding\0\u{1}phoneNumberSharingMode\0\u{1}defaultChatStyle\0\u{1}customChatColors\0\u{1}optimizeOnDeviceStorage\0\u{1}backupTier\0\u{2}\u{2}defaultSentMediaQuality\0\u{1}autoDownloadSettings\0\u{2}\u{2}screenLockTimeoutMinutes\0\u{1}pinReminders\0\u{1}appTheme\0\u{1}callsUseLessDataSetting\0\u{1}allowSealedSenderFromAnyone\0\u{1}allowAutomaticKeyVerification\0\u{1}seenAdminDeleteEducationDialog\0\u{1}preferredReactionItems\0\u{c}\u{16}\u{1}\u{c}\u{19}\u{1}") fileprivate class _StorageClass { var _readReceipts: Bool = false @@ -7170,6 +7292,7 @@ extension BackupProto_AccountData.AccountSettings: SwiftProtobuf.Message, SwiftP var _allowSealedSenderFromAnyone: Bool = false var _allowAutomaticKeyVerification: Bool = false var _seenAdminDeleteEducationDialog: Bool = false + var _preferredReactionItems: [BackupProto_AccountData.PreferredReactionItem] = [] // This property is used as the initial default value for new instances of the type. // The type itself is protecting the reference to its storage via CoW semantics. @@ -7210,6 +7333,7 @@ extension BackupProto_AccountData.AccountSettings: SwiftProtobuf.Message, SwiftP _allowSealedSenderFromAnyone = source._allowSealedSenderFromAnyone _allowAutomaticKeyVerification = source._allowAutomaticKeyVerification _seenAdminDeleteEducationDialog = source._seenAdminDeleteEducationDialog + _preferredReactionItems = source._preferredReactionItems } } @@ -7258,6 +7382,7 @@ extension BackupProto_AccountData.AccountSettings: SwiftProtobuf.Message, SwiftP case 30: try { try decoder.decodeSingularBoolField(value: &_storage._allowSealedSenderFromAnyone) }() case 31: try { try decoder.decodeSingularBoolField(value: &_storage._allowAutomaticKeyVerification) }() case 32: try { try decoder.decodeSingularBoolField(value: &_storage._seenAdminDeleteEducationDialog) }() + case 33: try { try decoder.decodeRepeatedMessageField(value: &_storage._preferredReactionItems) }() default: break } } @@ -7360,6 +7485,9 @@ extension BackupProto_AccountData.AccountSettings: SwiftProtobuf.Message, SwiftP if _storage._seenAdminDeleteEducationDialog != false { try visitor.visitSingularBoolField(value: _storage._seenAdminDeleteEducationDialog, fieldNumber: 32) } + if !_storage._preferredReactionItems.isEmpty { + try visitor.visitRepeatedMessageField(value: _storage._preferredReactionItems, fieldNumber: 33) + } } try unknownFields.traverse(visitor: &visitor) } @@ -7399,6 +7527,7 @@ extension BackupProto_AccountData.AccountSettings: SwiftProtobuf.Message, SwiftP if _storage._allowSealedSenderFromAnyone != rhs_storage._allowSealedSenderFromAnyone {return false} if _storage._allowAutomaticKeyVerification != rhs_storage._allowAutomaticKeyVerification {return false} if _storage._seenAdminDeleteEducationDialog != rhs_storage._seenAdminDeleteEducationDialog {return false} + if _storage._preferredReactionItems != rhs_storage._preferredReactionItems {return false} return true } if !storagesAreEqual {return false} @@ -9990,7 +10119,7 @@ extension BackupProto_ContactMessage: SwiftProtobuf.Message, SwiftProtobuf._Mess extension BackupProto_DirectStoryReplyMessage: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { public static let protoMessageName: String = _protobuf_package + ".DirectStoryReplyMessage" - public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{1}textReply\0\u{1}emoji\0\u{1}reactions\0\u{c}\u{4}\u{1}") + public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{1}textReply\0\u{1}emoji\0\u{1}reactions\0\u{2}\u{2}sticker\0\u{c}\u{4}\u{1}") public mutating func decodeMessage(decoder: inout D) throws { while let fieldNumber = try decoder.nextFieldNumber() { @@ -10020,6 +10149,19 @@ extension BackupProto_DirectStoryReplyMessage: SwiftProtobuf.Message, SwiftProto } }() case 3: try { try decoder.decodeRepeatedMessageField(value: &self.reactions) }() + case 5: try { + var v: BackupProto_Sticker? + var hadOneofValue = false + if let current = self.reply { + hadOneofValue = true + if case .sticker(let m) = current {v = m} + } + try decoder.decodeSingularMessageField(value: &v) + if let v = v { + if hadOneofValue {try decoder.handleConflictingOneOf()} + self.reply = .sticker(v) + } + }() default: break } } @@ -10039,11 +10181,14 @@ extension BackupProto_DirectStoryReplyMessage: SwiftProtobuf.Message, SwiftProto guard case .emoji(let v)? = self.reply else { preconditionFailure() } try visitor.visitSingularStringField(value: v, fieldNumber: 2) }() - case nil: break + default: break } if !self.reactions.isEmpty { try visitor.visitRepeatedMessageField(value: self.reactions, fieldNumber: 3) } + try { if case .sticker(let v)? = self.reply { + try visitor.visitSingularMessageField(value: v, fieldNumber: 5) + } }() try unknownFields.traverse(visitor: &visitor) } @@ -11475,7 +11620,7 @@ extension BackupProto_BodyRange.Style: SwiftProtobuf._ProtoNameProviding { extension BackupProto_Reaction: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { public static let protoMessageName: String = _protobuf_package + ".Reaction" - public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{1}emoji\0\u{1}authorId\0\u{1}sentTimestamp\0\u{1}sortOrder\0") + public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{1}emoji\0\u{1}authorId\0\u{1}sentTimestamp\0\u{1}sortOrder\0\u{1}sticker\0") public mutating func decodeMessage(decoder: inout D) throws { while let fieldNumber = try decoder.nextFieldNumber() { @@ -11487,12 +11632,17 @@ extension BackupProto_Reaction: SwiftProtobuf.Message, SwiftProtobuf._MessageImp case 2: try { try decoder.decodeSingularUInt64Field(value: &self.authorID) }() case 3: try { try decoder.decodeSingularUInt64Field(value: &self.sentTimestamp) }() case 4: try { try decoder.decodeSingularUInt64Field(value: &self.sortOrder) }() + case 5: try { try decoder.decodeSingularMessageField(value: &self._sticker) }() default: break } } } public func traverse(visitor: inout V) throws { + // The use of inline closures is to circumvent an issue where the compiler + // allocates stack space for every if/case branch local when no optimizations + // are enabled. https://github.com/apple/swift-protobuf/issues/1034 and + // https://github.com/apple/swift-protobuf/issues/1182 if !self.emoji.isEmpty { try visitor.visitSingularStringField(value: self.emoji, fieldNumber: 1) } @@ -11505,6 +11655,9 @@ extension BackupProto_Reaction: SwiftProtobuf.Message, SwiftProtobuf._MessageImp if self.sortOrder != 0 { try visitor.visitSingularUInt64Field(value: self.sortOrder, fieldNumber: 4) } + try { if let v = self._sticker { + try visitor.visitSingularMessageField(value: v, fieldNumber: 5) + } }() try unknownFields.traverse(visitor: &visitor) } @@ -11513,6 +11666,7 @@ extension BackupProto_Reaction: SwiftProtobuf.Message, SwiftProtobuf._MessageImp if lhs.authorID != rhs.authorID {return false} if lhs.sentTimestamp != rhs.sentTimestamp {return false} if lhs.sortOrder != rhs.sortOrder {return false} + if lhs._sticker != rhs._sticker {return false} if lhs.unknownFields != rhs.unknownFields {return false} return true } diff --git a/SignalServiceKit/Protos/Backups/Backup.proto b/SignalServiceKit/Protos/Backups/Backup.proto index a6ea1cf3944..5508fa1eb2a 100644 --- a/SignalServiceKit/Protos/Backups/Backup.proto +++ b/SignalServiceKit/Protos/Backups/Backup.proto @@ -107,6 +107,16 @@ message AccountData { WIFI_AND_MOBILE_DATA = 3; } + message PreferredReactionItem { + // @required + string emoji = 1; + // If all below fields are present, uses an + // installed sticker. Otherwise falls back to emoji. + optional bytes stickerPackId = 2; + optional bytes stickerPackKey = 3; + optional uint32 stickerId = 4; + } + message AccountSettings { bool readReceipts = 1; bool sealedSenderIndicators = 2; @@ -141,6 +151,8 @@ message AccountData { bool allowSealedSenderFromAnyone = 30; bool allowAutomaticKeyVerification = 31; bool seenAdminDeleteEducationDialog = 32; + // Replaces preferredReactionEmoji; older one kept for backwards compatibility + repeated PreferredReactionItem preferredReactionItems = 33; } message SubscriberData { @@ -597,6 +609,7 @@ message DirectStoryReplyMessage { oneof reply { TextReply textReply = 1; string emoji = 2; + Sticker sticker = 5; } repeated Reaction reactions = 3; @@ -894,6 +907,8 @@ message Reaction { // A higher sort order means that a reaction is more recent. Some clients may export this as // incrementing numbers (e.g. 1, 2, 3), others as timestamps. uint64 sortOrder = 4; + // If present, Sticker.emoji must match Reaction.emoji + optional Sticker sticker = 5; } message Poll { diff --git a/SignalServiceKit/Protos/Generated/SSKProto.swift b/SignalServiceKit/Protos/Generated/SSKProto.swift index 0c9f606d5b1..ce935669b69 100644 --- a/SignalServiceKit/Protos/Generated/SSKProto.swift +++ b/SignalServiceKit/Protos/Generated/SSKProto.swift @@ -6045,6 +6045,9 @@ public class SSKProtoDataMessageReaction: NSObject, Codable, NSSecureCoding { @objc public let timestamp: UInt64 + @objc + public let sticker: SSKProtoDataMessageSticker? + @objc public var remove: Bool { return proto.remove @@ -6088,10 +6091,12 @@ public class SSKProtoDataMessageReaction: NSObject, Codable, NSSecureCoding { private init(proto: SignalServiceProtos_DataMessage.Reaction, emoji: String, - timestamp: UInt64) { + timestamp: UInt64, + sticker: SSKProtoDataMessageSticker?) { self.proto = proto self.emoji = emoji self.timestamp = timestamp + self.sticker = sticker } @objc @@ -6116,9 +6121,15 @@ public class SSKProtoDataMessageReaction: NSObject, Codable, NSSecureCoding { } let timestamp = proto.timestamp + var sticker: SSKProtoDataMessageSticker? + if proto.hasSticker { + sticker = try SSKProtoDataMessageSticker(proto.sticker) + } + self.init(proto: proto, emoji: emoji, - timestamp: timestamp) + timestamp: timestamp, + sticker: sticker) } public required convenience init(from decoder: Swift.Decoder) throws { @@ -6176,6 +6187,9 @@ extension SSKProtoDataMessageReaction { if let _value = targetAuthorAciBinary { builder.setTargetAuthorAciBinary(_value) } + if let _value = sticker { + builder.setSticker(_value) + } if let _value = unknownFields { builder.setUnknownFields(_value) } @@ -6242,6 +6256,17 @@ public class SSKProtoDataMessageReactionBuilder: NSObject { proto.targetAuthorAciBinary = valueParam } + @objc + @available(swift, obsoleted: 1.0) + public func setSticker(_ valueParam: SSKProtoDataMessageSticker?) { + guard let valueParam = valueParam else { return } + proto.sticker = valueParam.proto + } + + public func setSticker(_ valueParam: SSKProtoDataMessageSticker) { + proto.sticker = valueParam.proto + } + public func setUnknownFields(_ unknownFields: SwiftProtobuf.UnknownStorage) { proto.unknownFields = unknownFields } diff --git a/SignalServiceKit/Protos/Generated/SignalService.pb.swift b/SignalServiceKit/Protos/Generated/SignalService.pb.swift index 743bd53b8d3..132efb03957 100644 --- a/SignalServiceKit/Protos/Generated/SignalService.pb.swift +++ b/SignalServiceKit/Protos/Generated/SignalService.pb.swift @@ -1836,6 +1836,16 @@ struct SignalServiceProtos_DataMessage: @unchecked Sendable { /// Clears the value of `targetAuthorAciBinary`. Subsequent reads from it will return its default value. mutating func clearTargetAuthorAciBinary() {self._targetAuthorAciBinary = nil} + /// If present, Sticker.emoji must match Reaction.emoji + var sticker: SignalServiceProtos_DataMessage.Sticker { + get {_sticker ?? SignalServiceProtos_DataMessage.Sticker()} + set {_sticker = newValue} + } + /// Returns true if `sticker` has been explicitly set. + var hasSticker: Bool {self._sticker != nil} + /// Clears the value of `sticker`. Subsequent reads from it will return its default value. + mutating func clearSticker() {self._sticker = nil} + var unknownFields = SwiftProtobuf.UnknownStorage() init() {} @@ -1845,6 +1855,7 @@ struct SignalServiceProtos_DataMessage: @unchecked Sendable { fileprivate var _targetAuthorAci: String? = nil fileprivate var _timestamp: UInt64? = nil fileprivate var _targetAuthorAciBinary: Data? = nil + fileprivate var _sticker: SignalServiceProtos_DataMessage.Sticker? = nil } struct Delete: Sendable { @@ -6526,7 +6537,7 @@ extension SignalServiceProtos_DataMessage.Sticker: SwiftProtobuf.Message, SwiftP extension SignalServiceProtos_DataMessage.Reaction: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { static let protoMessageName: String = SignalServiceProtos_DataMessage.protoMessageName + ".Reaction" - static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{1}emoji\0\u{1}remove\0\u{2}\u{2}targetAuthorAci\0\u{1}timestamp\0\u{1}targetAuthorAciBinary\0\u{c}\u{3}\u{1}") + static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{1}emoji\0\u{1}remove\0\u{2}\u{2}targetAuthorAci\0\u{1}timestamp\0\u{1}targetAuthorAciBinary\0\u{1}sticker\0\u{c}\u{3}\u{1}") mutating func decodeMessage(decoder: inout D) throws { while let fieldNumber = try decoder.nextFieldNumber() { @@ -6539,6 +6550,7 @@ extension SignalServiceProtos_DataMessage.Reaction: SwiftProtobuf.Message, Swift case 4: try { try decoder.decodeSingularStringField(value: &self._targetAuthorAci) }() case 5: try { try decoder.decodeSingularUInt64Field(value: &self._timestamp) }() case 6: try { try decoder.decodeSingularBytesField(value: &self._targetAuthorAciBinary) }() + case 7: try { try decoder.decodeSingularMessageField(value: &self._sticker) }() default: break } } @@ -6564,6 +6576,9 @@ extension SignalServiceProtos_DataMessage.Reaction: SwiftProtobuf.Message, Swift try { if let v = self._targetAuthorAciBinary { try visitor.visitSingularBytesField(value: v, fieldNumber: 6) } }() + try { if let v = self._sticker { + try visitor.visitSingularMessageField(value: v, fieldNumber: 7) + } }() try unknownFields.traverse(visitor: &visitor) } @@ -6573,6 +6588,7 @@ extension SignalServiceProtos_DataMessage.Reaction: SwiftProtobuf.Message, Swift if lhs._targetAuthorAci != rhs._targetAuthorAci {return false} if lhs._timestamp != rhs._timestamp {return false} if lhs._targetAuthorAciBinary != rhs._targetAuthorAciBinary {return false} + if lhs._sticker != rhs._sticker {return false} if lhs.unknownFields != rhs.unknownFields {return false} return true } diff --git a/SignalServiceKit/Protos/Generated/StorageService.pb.swift b/SignalServiceKit/Protos/Generated/StorageService.pb.swift index 3e2b0ed07cf..f87d328d9b6 100644 --- a/SignalServiceKit/Protos/Generated/StorageService.pb.swift +++ b/SignalServiceKit/Protos/Generated/StorageService.pb.swift @@ -903,6 +903,12 @@ struct StorageServiceProtos_AccountRecord: @unchecked Sendable { set {_uniqueStorage()._seenAdminDeleteEducationDialog = newValue} } + /// Replaces preferredReactionEmoji; older one kept for backwards compatibility + var preferredReactionItems: [StorageServiceProtos_AccountRecord.PreferredReactionItem] { + get {_storage._preferredReactionItems} + set {_uniqueStorage()._preferredReactionItems = newValue} + } + var unknownFields = SwiftProtobuf.UnknownStorage() enum PhoneNumberSharingMode: SwiftProtobuf.Enum, Swift.CaseIterable { @@ -1138,6 +1144,52 @@ struct StorageServiceProtos_AccountRecord: @unchecked Sendable { init() {} } + struct PreferredReactionItem: Sendable { + // SwiftProtobuf.Message conformance is added in an extension below. See the + // `Message` and `Message+*Additions` files in the SwiftProtobuf library for + // methods supported on all messages. + + /// @required + var emoji: String = String() + + /// If all below fields are present, uses an + /// installed sticker. Otherwise falls back to emoji. + var stickerPackID: Data { + get {_stickerPackID ?? Data()} + set {_stickerPackID = newValue} + } + /// Returns true if `stickerPackID` has been explicitly set. + var hasStickerPackID: Bool {self._stickerPackID != nil} + /// Clears the value of `stickerPackID`. Subsequent reads from it will return its default value. + mutating func clearStickerPackID() {self._stickerPackID = nil} + + var stickerPackKey: Data { + get {_stickerPackKey ?? Data()} + set {_stickerPackKey = newValue} + } + /// Returns true if `stickerPackKey` has been explicitly set. + var hasStickerPackKey: Bool {self._stickerPackKey != nil} + /// Clears the value of `stickerPackKey`. Subsequent reads from it will return its default value. + mutating func clearStickerPackKey() {self._stickerPackKey = nil} + + var stickerID: UInt32 { + get {_stickerID ?? 0} + set {_stickerID = newValue} + } + /// Returns true if `stickerID` has been explicitly set. + var hasStickerID: Bool {self._stickerID != nil} + /// Clears the value of `stickerID`. Subsequent reads from it will return its default value. + mutating func clearStickerID() {self._stickerID = nil} + + var unknownFields = SwiftProtobuf.UnknownStorage() + + init() {} + + fileprivate var _stickerPackID: Data? = nil + fileprivate var _stickerPackKey: Data? = nil + fileprivate var _stickerID: UInt32? = nil + } + init() {} fileprivate var _storage = _StorageClass.defaultInstance @@ -1989,7 +2041,7 @@ extension StorageServiceProtos_GroupV2Record.StorySendMode: SwiftProtobuf._Proto extension StorageServiceProtos_AccountRecord: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { static let protoMessageName: String = _protobuf_package + ".AccountRecord" - static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{1}profileKey\0\u{1}givenName\0\u{1}familyName\0\u{1}avatarUrl\0\u{1}noteToSelfArchived\0\u{1}readReceipts\0\u{1}sealedSenderIndicators\0\u{1}typingIndicators\0\u{1}proxiedLinkPreviews\0\u{1}noteToSelfMarkedUnread\0\u{1}linkPreviews\0\u{1}phoneNumberSharingMode\0\u{1}notDiscoverableByPhoneNumber\0\u{1}pinnedConversations\0\u{1}preferContactAvatars\0\u{1}payments\0\u{1}universalExpireTimer\0\u{2}\u{2}e164\0\u{1}preferredReactionEmoji\0\u{1}donorSubscriberID\0\u{1}donorSubscriberCurrencyCode\0\u{1}displayBadgesOnProfile\0\u{1}donorSubscriptionManuallyCancelled\0\u{1}keepMutedChatsArchived\0\u{1}myStoryPrivacyHasBeenSet\0\u{1}viewedOnboardingStory\0\u{2}\u{2}storiesDisabled\0\u{1}storyViewReceiptsEnabled\0\u{1}readOnboardingStory\0\u{2}\u{2}username\0\u{1}completedUsernameOnboarding\0\u{1}usernameLink\0\u{2}\u{5}backupTier\0\u{1}backupSubscriberData\0\u{1}avatarColor\0\u{2}\u{4}automaticKeyVerificationDisabled\0\u{1}seenAdminDeleteEducationDialog\0\u{c}\u{12}\u{1}\u{c}\u{1c}\u{1}\u{c} \u{1}\u{c}$\u{1}\u{c}%\u{1}\u{c}&\u{1}\u{c}'\u{1}\u{c}+\u{1}") + static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{1}profileKey\0\u{1}givenName\0\u{1}familyName\0\u{1}avatarUrl\0\u{1}noteToSelfArchived\0\u{1}readReceipts\0\u{1}sealedSenderIndicators\0\u{1}typingIndicators\0\u{1}proxiedLinkPreviews\0\u{1}noteToSelfMarkedUnread\0\u{1}linkPreviews\0\u{1}phoneNumberSharingMode\0\u{1}notDiscoverableByPhoneNumber\0\u{1}pinnedConversations\0\u{1}preferContactAvatars\0\u{1}payments\0\u{1}universalExpireTimer\0\u{2}\u{2}e164\0\u{1}preferredReactionEmoji\0\u{1}donorSubscriberID\0\u{1}donorSubscriberCurrencyCode\0\u{1}displayBadgesOnProfile\0\u{1}donorSubscriptionManuallyCancelled\0\u{1}keepMutedChatsArchived\0\u{1}myStoryPrivacyHasBeenSet\0\u{1}viewedOnboardingStory\0\u{2}\u{2}storiesDisabled\0\u{1}storyViewReceiptsEnabled\0\u{1}readOnboardingStory\0\u{2}\u{2}username\0\u{1}completedUsernameOnboarding\0\u{1}usernameLink\0\u{2}\u{5}backupTier\0\u{1}backupSubscriberData\0\u{1}avatarColor\0\u{2}\u{4}automaticKeyVerificationDisabled\0\u{1}seenAdminDeleteEducationDialog\0\u{1}preferredReactionItems\0\u{c}\u{12}\u{1}\u{c}\u{1c}\u{1}\u{c} \u{1}\u{c}$\u{1}\u{c}%\u{1}\u{c}&\u{1}\u{c}'\u{1}\u{c}+\u{1}") fileprivate class _StorageClass { var _profileKey: Data = Data() @@ -2029,6 +2081,7 @@ extension StorageServiceProtos_AccountRecord: SwiftProtobuf.Message, SwiftProtob var _avatarColor: StorageServiceProtos_AvatarColor? = nil var _automaticKeyVerificationDisabled: Bool = false var _seenAdminDeleteEducationDialog: Bool = false + var _preferredReactionItems: [StorageServiceProtos_AccountRecord.PreferredReactionItem] = [] // This property is used as the initial default value for new instances of the type. // The type itself is protecting the reference to its storage via CoW semantics. @@ -2076,6 +2129,7 @@ extension StorageServiceProtos_AccountRecord: SwiftProtobuf.Message, SwiftProtob _avatarColor = source._avatarColor _automaticKeyVerificationDisabled = source._automaticKeyVerificationDisabled _seenAdminDeleteEducationDialog = source._seenAdminDeleteEducationDialog + _preferredReactionItems = source._preferredReactionItems } } @@ -2131,6 +2185,7 @@ extension StorageServiceProtos_AccountRecord: SwiftProtobuf.Message, SwiftProtob case 42: try { try decoder.decodeSingularEnumField(value: &_storage._avatarColor) }() case 46: try { try decoder.decodeSingularBoolField(value: &_storage._automaticKeyVerificationDisabled) }() case 47: try { try decoder.decodeSingularBoolField(value: &_storage._seenAdminDeleteEducationDialog) }() + case 48: try { try decoder.decodeRepeatedMessageField(value: &_storage._preferredReactionItems) }() default: break } } @@ -2254,6 +2309,9 @@ extension StorageServiceProtos_AccountRecord: SwiftProtobuf.Message, SwiftProtob if _storage._seenAdminDeleteEducationDialog != false { try visitor.visitSingularBoolField(value: _storage._seenAdminDeleteEducationDialog, fieldNumber: 47) } + if !_storage._preferredReactionItems.isEmpty { + try visitor.visitRepeatedMessageField(value: _storage._preferredReactionItems, fieldNumber: 48) + } } try unknownFields.traverse(visitor: &visitor) } @@ -2300,6 +2358,7 @@ extension StorageServiceProtos_AccountRecord: SwiftProtobuf.Message, SwiftProtob if _storage._avatarColor != rhs_storage._avatarColor {return false} if _storage._automaticKeyVerificationDisabled != rhs_storage._automaticKeyVerificationDisabled {return false} if _storage._seenAdminDeleteEducationDialog != rhs_storage._seenAdminDeleteEducationDialog {return false} + if _storage._preferredReactionItems != rhs_storage._preferredReactionItems {return false} return true } if !storagesAreEqual {return false} @@ -2568,6 +2627,55 @@ extension StorageServiceProtos_AccountRecord.IAPSubscriberData: SwiftProtobuf.Me } } +extension StorageServiceProtos_AccountRecord.PreferredReactionItem: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { + static let protoMessageName: String = StorageServiceProtos_AccountRecord.protoMessageName + ".PreferredReactionItem" + static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{1}emoji\0\u{1}stickerPackId\0\u{1}stickerPackKey\0\u{1}stickerId\0") + + mutating func decodeMessage(decoder: inout D) throws { + while let fieldNumber = try decoder.nextFieldNumber() { + // The use of inline closures is to circumvent an issue where the compiler + // allocates stack space for every case branch when no optimizations are + // enabled. https://github.com/apple/swift-protobuf/issues/1034 + switch fieldNumber { + case 1: try { try decoder.decodeSingularStringField(value: &self.emoji) }() + case 2: try { try decoder.decodeSingularBytesField(value: &self._stickerPackID) }() + case 3: try { try decoder.decodeSingularBytesField(value: &self._stickerPackKey) }() + case 4: try { try decoder.decodeSingularUInt32Field(value: &self._stickerID) }() + default: break + } + } + } + + func traverse(visitor: inout V) throws { + // The use of inline closures is to circumvent an issue where the compiler + // allocates stack space for every if/case branch local when no optimizations + // are enabled. https://github.com/apple/swift-protobuf/issues/1034 and + // https://github.com/apple/swift-protobuf/issues/1182 + if !self.emoji.isEmpty { + try visitor.visitSingularStringField(value: self.emoji, fieldNumber: 1) + } + try { if let v = self._stickerPackID { + try visitor.visitSingularBytesField(value: v, fieldNumber: 2) + } }() + try { if let v = self._stickerPackKey { + try visitor.visitSingularBytesField(value: v, fieldNumber: 3) + } }() + try { if let v = self._stickerID { + try visitor.visitSingularUInt32Field(value: v, fieldNumber: 4) + } }() + try unknownFields.traverse(visitor: &visitor) + } + + static func ==(lhs: StorageServiceProtos_AccountRecord.PreferredReactionItem, rhs: StorageServiceProtos_AccountRecord.PreferredReactionItem) -> Bool { + if lhs.emoji != rhs.emoji {return false} + if lhs._stickerPackID != rhs._stickerPackID {return false} + if lhs._stickerPackKey != rhs._stickerPackKey {return false} + if lhs._stickerID != rhs._stickerID {return false} + if lhs.unknownFields != rhs.unknownFields {return false} + return true + } +} + extension StorageServiceProtos_StoryDistributionListRecord: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { static let protoMessageName: String = _protobuf_package + ".StoryDistributionListRecord" static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{1}identifier\0\u{1}name\0\u{1}recipientServiceIds\0\u{1}deletedAtTimestamp\0\u{1}allowsReplies\0\u{1}isBlockList\0\u{1}recipientServiceIdsBinary\0") diff --git a/SignalServiceKit/Protos/Generated/StorageServiceProto.swift b/SignalServiceKit/Protos/Generated/StorageServiceProto.swift index 48ea20862ad..92037a6be6d 100644 --- a/SignalServiceKit/Protos/Generated/StorageServiceProto.swift +++ b/SignalServiceKit/Protos/Generated/StorageServiceProto.swift @@ -3235,6 +3235,187 @@ extension StorageServiceProtoAccountRecordIAPSubscriberDataBuilder { #endif +// MARK: - StorageServiceProtoAccountRecordPreferredReactionItem + +public struct StorageServiceProtoAccountRecordPreferredReactionItem: Codable, CustomDebugStringConvertible { + + fileprivate let proto: StorageServiceProtos_AccountRecord.PreferredReactionItem + + public let emoji: String + + public var stickerPackID: Data? { + guard hasStickerPackID else { + return nil + } + return proto.stickerPackID + } + public var hasStickerPackID: Bool { + return proto.hasStickerPackID + } + + public var stickerPackKey: Data? { + guard hasStickerPackKey else { + return nil + } + return proto.stickerPackKey + } + public var hasStickerPackKey: Bool { + return proto.hasStickerPackKey + } + + public var stickerID: UInt32? { + guard hasStickerID else { + return nil + } + return proto.stickerID + } + public var hasStickerID: Bool { + return proto.hasStickerID + } + + public var hasUnknownFields: Bool { + return !proto.unknownFields.data.isEmpty + } + public var unknownFields: SwiftProtobuf.UnknownStorage? { + guard hasUnknownFields else { return nil } + return proto.unknownFields + } + + private init(proto: StorageServiceProtos_AccountRecord.PreferredReactionItem, + emoji: String) { + self.proto = proto + self.emoji = emoji + } + + public func serializedData() throws -> Data { + return try self.proto.serializedData() + } + + public init(serializedData: Data) throws { + let proto = try StorageServiceProtos_AccountRecord.PreferredReactionItem(serializedBytes: serializedData) + self.init(proto) + } + + fileprivate init(_ proto: StorageServiceProtos_AccountRecord.PreferredReactionItem) { + let emoji = proto.emoji + + self.init(proto: proto, + emoji: emoji) + } + + public init(from decoder: Swift.Decoder) throws { + let singleValueContainer = try decoder.singleValueContainer() + let serializedData = try singleValueContainer.decode(Data.self) + try self.init(serializedData: serializedData) + } + public func encode(to encoder: Swift.Encoder) throws { + var singleValueContainer = encoder.singleValueContainer() + try singleValueContainer.encode(try serializedData()) + } + + public var debugDescription: String { + return "\(proto)" + } +} + +extension StorageServiceProtoAccountRecordPreferredReactionItem { + public static func builder(emoji: String) -> StorageServiceProtoAccountRecordPreferredReactionItemBuilder { + return StorageServiceProtoAccountRecordPreferredReactionItemBuilder(emoji: emoji) + } + + // asBuilder() constructs a builder that reflects the proto's contents. + public func asBuilder() -> StorageServiceProtoAccountRecordPreferredReactionItemBuilder { + var builder = StorageServiceProtoAccountRecordPreferredReactionItemBuilder(emoji: emoji) + if let _value = stickerPackID { + builder.setStickerPackID(_value) + } + if let _value = stickerPackKey { + builder.setStickerPackKey(_value) + } + if let _value = stickerID { + builder.setStickerID(_value) + } + if let _value = unknownFields { + builder.setUnknownFields(_value) + } + return builder + } +} + +public struct StorageServiceProtoAccountRecordPreferredReactionItemBuilder { + + private var proto = StorageServiceProtos_AccountRecord.PreferredReactionItem() + + fileprivate init() {} + + fileprivate init(emoji: String) { + + setEmoji(emoji) + } + + @available(swift, obsoleted: 1.0) + public mutating func setEmoji(_ valueParam: String?) { + guard let valueParam = valueParam else { return } + proto.emoji = valueParam + } + + public mutating func setEmoji(_ valueParam: String) { + proto.emoji = valueParam + } + + @available(swift, obsoleted: 1.0) + public mutating func setStickerPackID(_ valueParam: Data?) { + guard let valueParam = valueParam else { return } + proto.stickerPackID = valueParam + } + + public mutating func setStickerPackID(_ valueParam: Data) { + proto.stickerPackID = valueParam + } + + @available(swift, obsoleted: 1.0) + public mutating func setStickerPackKey(_ valueParam: Data?) { + guard let valueParam = valueParam else { return } + proto.stickerPackKey = valueParam + } + + public mutating func setStickerPackKey(_ valueParam: Data) { + proto.stickerPackKey = valueParam + } + + public mutating func setStickerID(_ valueParam: UInt32) { + proto.stickerID = valueParam + } + + public mutating func setUnknownFields(_ unknownFields: SwiftProtobuf.UnknownStorage) { + proto.unknownFields = unknownFields + } + + public func buildInfallibly() -> StorageServiceProtoAccountRecordPreferredReactionItem { + return StorageServiceProtoAccountRecordPreferredReactionItem(proto) + } + + public func buildSerializedData() throws -> Data { + return try StorageServiceProtoAccountRecordPreferredReactionItem(proto).serializedData() + } +} + +#if TESTABLE_BUILD + +extension StorageServiceProtoAccountRecordPreferredReactionItem { + public func serializedDataIgnoringErrors() -> Data? { + return try! self.serializedData() + } +} + +extension StorageServiceProtoAccountRecordPreferredReactionItemBuilder { + public func buildIgnoringErrors() -> StorageServiceProtoAccountRecordPreferredReactionItem? { + return self.buildInfallibly() + } +} + +#endif + // MARK: - StorageServiceProtoAccountRecordPhoneNumberSharingMode public enum StorageServiceProtoAccountRecordPhoneNumberSharingMode: SwiftProtobuf.Enum { @@ -3299,6 +3480,8 @@ public struct StorageServiceProtoAccountRecord: Codable, CustomDebugStringConver public let backupSubscriberData: StorageServiceProtoAccountRecordIAPSubscriberData? + public let preferredReactionItems: [StorageServiceProtoAccountRecordPreferredReactionItem] + public var profileKey: Data? { guard hasProfileKey else { return nil @@ -3489,12 +3672,14 @@ public struct StorageServiceProtoAccountRecord: Codable, CustomDebugStringConver pinnedConversations: [StorageServiceProtoAccountRecordPinnedConversation], payments: StorageServiceProtoAccountRecordPayments?, usernameLink: StorageServiceProtoAccountRecordUsernameLink?, - backupSubscriberData: StorageServiceProtoAccountRecordIAPSubscriberData?) { + backupSubscriberData: StorageServiceProtoAccountRecordIAPSubscriberData?, + preferredReactionItems: [StorageServiceProtoAccountRecordPreferredReactionItem]) { self.proto = proto self.pinnedConversations = pinnedConversations self.payments = payments self.usernameLink = usernameLink self.backupSubscriberData = backupSubscriberData + self.preferredReactionItems = preferredReactionItems } public func serializedData() throws -> Data { @@ -3525,11 +3710,15 @@ public struct StorageServiceProtoAccountRecord: Codable, CustomDebugStringConver backupSubscriberData = StorageServiceProtoAccountRecordIAPSubscriberData(proto.backupSubscriberData) } + var preferredReactionItems: [StorageServiceProtoAccountRecordPreferredReactionItem] = [] + preferredReactionItems = proto.preferredReactionItems.map { StorageServiceProtoAccountRecordPreferredReactionItem($0) } + self.init(proto: proto, pinnedConversations: pinnedConversations, payments: payments, usernameLink: usernameLink, - backupSubscriberData: backupSubscriberData) + backupSubscriberData: backupSubscriberData, + preferredReactionItems: preferredReactionItems) } public init(from decoder: Swift.Decoder) throws { @@ -3618,6 +3807,7 @@ extension StorageServiceProtoAccountRecord { } builder.setAutomaticKeyVerificationDisabled(automaticKeyVerificationDisabled) builder.setSeenAdminDeleteEducationDialog(seenAdminDeleteEducationDialog) + builder.setPreferredReactionItems(preferredReactionItems) if let _value = unknownFields { builder.setUnknownFields(_value) } @@ -3853,6 +4043,14 @@ public struct StorageServiceProtoAccountRecordBuilder { proto.seenAdminDeleteEducationDialog = valueParam } + public mutating func addPreferredReactionItems(_ valueParam: StorageServiceProtoAccountRecordPreferredReactionItem) { + proto.preferredReactionItems.append(valueParam.proto) + } + + public mutating func setPreferredReactionItems(_ wrappedItems: [StorageServiceProtoAccountRecordPreferredReactionItem]) { + proto.preferredReactionItems = wrappedItems.map { $0.proto } + } + public mutating func setUnknownFields(_ unknownFields: SwiftProtobuf.UnknownStorage) { proto.unknownFields = unknownFields } diff --git a/SignalServiceKit/Protos/Specifications/SignalService.proto b/SignalServiceKit/Protos/Specifications/SignalService.proto index 52a06307c5f..697092e27e1 100644 --- a/SignalServiceKit/Protos/Specifications/SignalService.proto +++ b/SignalServiceKit/Protos/Specifications/SignalService.proto @@ -317,6 +317,8 @@ message DataMessage { // @required optional uint64 timestamp = 5; optional bytes targetAuthorAciBinary = 6; // 16-byte UUID + // If present, Sticker.emoji must match Reaction.emoji + optional Sticker sticker = 7; } message Delete { diff --git a/SignalServiceKit/Protos/Specifications/StorageService.proto b/SignalServiceKit/Protos/Specifications/StorageService.proto index dec0d458fb0..c0e72c28a50 100644 --- a/SignalServiceKit/Protos/Specifications/StorageService.proto +++ b/SignalServiceKit/Protos/Specifications/StorageService.proto @@ -228,6 +228,16 @@ message AccountRecord { } } + message PreferredReactionItem { + // @required + string emoji = 1; + // If all below fields are present, uses an + // installed sticker. Otherwise falls back to emoji. + optional bytes stickerPackId = 2; + optional bytes stickerPackKey = 3; + optional uint32 stickerId = 4; + } + bytes profileKey = 1; string givenName = 2; string familyName = 3; @@ -274,6 +284,8 @@ message AccountRecord { // 44 and 45 related to notification profiles bool automaticKeyVerificationDisabled = 46; bool seenAdminDeleteEducationDialog = 47; + // Replaces preferredReactionEmoji; older one kept for backwards compatibility + repeated PreferredReactionItem preferredReactionItems = 48; } message StoryDistributionListRecord { diff --git a/SignalServiceKit/Storage/Database/GRDBSchemaMigrator.swift b/SignalServiceKit/Storage/Database/GRDBSchemaMigrator.swift index a1e84c98dd4..cf53dfd6ec8 100644 --- a/SignalServiceKit/Storage/Database/GRDBSchemaMigrator.swift +++ b/SignalServiceKit/Storage/Database/GRDBSchemaMigrator.swift @@ -322,6 +322,7 @@ public class GRDBSchemaMigrator { case addRecipientStatesToAdminDelete case modifyCallLinkRootKeyConstraint case addDevice + case stickerReactions // NOTE: Every time we add a migration id, consider // incrementing grdbSchemaVersionLatest. @@ -445,7 +446,7 @@ public class GRDBSchemaMigrator { } public static let grdbSchemaVersionDefault: UInt = 0 - public static let grdbSchemaVersionLatest: UInt = 141 + public static let grdbSchemaVersionLatest: UInt = 142 private class DatabaseMigratorWrapper { // Run with immediate (or disabled) foreign key checks so that pre-existing @@ -5047,6 +5048,25 @@ public class GRDBSchemaMigrator { return .success(()) } + migrator.registerMigration(.stickerReactions) { tx in + try tx.database.alter(table: "model_OWSReaction") { (table: TableAlteration) -> Void in + // All three columns non-null for sticker reactions, null for emoji reactions. + table.add(column: "stickerPackId", .blob) + table.add(column: "stickerPackKey", .blob) + table.add(column: "stickerId", .integer) + } + + // Note: no index because lookup always starts with + // message row id then to reaction; the number of + // reactions per message is low enough for O(n) lookup. + try tx.database.alter(table: "MessageAttachmentReference") { table in + table.add(column: "reactionRowId", .integer) + } + + // TODO: apply schema change to schema.sqlite + return .success(()) + } + // MARK: - Schema Migration Insertion Point } diff --git a/SignalServiceKit/Storage/MediaGallery/MediaGalleryAttachmentFinder.swift b/SignalServiceKit/Storage/MediaGallery/MediaGalleryAttachmentFinder.swift index 64734b971d6..2c0347a6c2f 100644 --- a/SignalServiceKit/Storage/MediaGallery/MediaGalleryAttachmentFinder.swift +++ b/SignalServiceKit/Storage/MediaGallery/MediaGalleryAttachmentFinder.swift @@ -378,8 +378,7 @@ public struct MediaGalleryAttachmentFinder { switch attachmentId.ownerId { case .messageBodyAttachment(let messageRowId): ownerId = messageRowId - case .messageOversizeText, .messageLinkPreview, .quotedReplyAttachment, .messageSticker, .messageContactAvatar: - // These message owner types are already filtered out. + case .messageOversizeText, .messageLinkPreview, .quotedReplyAttachment, .messageSticker, .messageContactAvatar, .messageReactionSticker: continue case .storyMessageMedia, .storyMessageLinkPreview, .threadWallpaperImage, .globalThreadWallpaperImage: owsFailDebug("Invalid owner type for media gallery") diff --git a/SignalServiceKit/StorageService/StorageServiceProto+Sync.swift b/SignalServiceKit/StorageService/StorageServiceProto+Sync.swift index ee18dbeca38..3faca1b12fe 100644 --- a/SignalServiceKit/StorageService/StorageServiceProto+Sync.swift +++ b/SignalServiceKit/StorageService/StorageServiceProto+Sync.swift @@ -1358,8 +1358,21 @@ class StorageServiceAccountRecordUpdater: StorageServiceRecordUpdater { let dmConfiguration = dmConfigurationStore.fetchOrBuildDefault(for: .universal, tx: transaction) builder.setUniversalExpireTimer(dmConfiguration.isEnabled ? dmConfiguration.durationSeconds : 0) - if let customEmojiSet = ReactionManager.customEmojiSet(transaction: transaction) { - builder.setPreferredReactionEmoji(customEmojiSet) + if let customReactionSet = ReactionManager.customReactionSet(tx: transaction) { + // Set raw emoji for backwards compatibility. + let rawEmoji = customReactionSet.map(\.emoji) + builder.setPreferredReactionEmoji(rawEmoji) + builder.setPreferredReactionItems(customReactionSet.map { item in + var protoItem = StorageServiceProtoAccountRecordPreferredReactionItem.builder( + emoji: item.emoji + ) + if let sticker = item.sticker { + protoItem.setStickerPackID(sticker.packId) + protoItem.setStickerPackKey(sticker.packKey) + protoItem.setStickerID(sticker.stickerId) + } + return protoItem.buildInfallibly() + }) } if @@ -1660,12 +1673,53 @@ class StorageServiceAccountRecordUpdater: StorageServiceRecordUpdater { let remoteExpireToken: DisappearingMessageToken = .token(forProtoExpireTimerSeconds: record.universalExpireTimer) dmConfigurationStore.setUniversalTimer(token: remoteExpireToken, tx: transaction) - if !record.preferredReactionEmoji.isEmpty { - // Treat new preferred emoji as a full source of truth (if not empty). Note - // that we aren't doing any validation up front, which may be important if - // another platform supports an emoji we don't (say, because a new version - // of Unicode has come out). We deal with this when the custom set is read. - ReactionManager.setCustomEmojiSet(record.preferredReactionEmoji, transaction: transaction) + let customReactionItems = record.preferredReactionItems.compactMap { protoItem -> CustomReactionItem? in + guard let emoji = protoItem.emoji.nilIfEmpty else { return nil } + var sticker: StickerInfo? + if + let stickerPackID = protoItem.stickerPackID, + let stickerPackKey = protoItem.stickerPackKey, + let stickerID = protoItem.stickerID + { + sticker = StickerInfo( + packId: stickerPackID, + packKey: stickerPackKey, + stickerId: stickerID + ) + } + return CustomReactionItem( + emoji: emoji, + sticker: sticker + ) + } + + if + !record.preferredReactionEmoji.isEmpty, + !customReactionItems.isEmpty, + record.preferredReactionEmoji != customReactionItems.map(\.emoji) + { + // We have both legacy (emoji only) and new custom reaction items, + // and they aren't the same. If a new client had updated them, + // they'd be the same. Therefore an old client must've updated + // custom reactions, and we should take the legacy field, which will + // eventually overwrite the new field next time we write. + ReactionManager.setCustomReactionSet( + record.preferredReactionEmoji.map { CustomReactionItem(emoji: $0, sticker: nil) }, + tx: transaction + ) + } else if + !record.preferredReactionEmoji.isEmpty, + customReactionItems.isEmpty + { + // We only have legacy. Use those. + ReactionManager.setCustomReactionSet( + record.preferredReactionEmoji.map { CustomReactionItem(emoji: $0, sticker: nil) }, + tx: transaction + ) + } else { + // We either only have new custom reaction items, or we have both + // and they're the same underlying emoji; use the new type. + ReactionManager.setCustomReactionSet(customReactionItems, tx: transaction) } if diff --git a/SignalServiceKit/Util/CommonStrings.swift b/SignalServiceKit/Util/CommonStrings.swift index e9160ba959c..3fcc61d9df0 100644 --- a/SignalServiceKit/Util/CommonStrings.swift +++ b/SignalServiceKit/Util/CommonStrings.swift @@ -562,6 +562,90 @@ public enum NotificationStrings { comment: "notification body. Embeds {{reaction emoji}}", ) } + + public static var incomingReactionSticker: String { + OWSLocalizedString( + "REACTION_STICKER_INCOMING_NOTIFICATION_BODY_FORMAT", + comment: "notification body.", + ) + } + + public static var incomingReactionStickerTextMessage: String { + OWSLocalizedString( + "REACTION_STICKER_INCOMING_NOTIFICATION_TO_TEXT_MESSAGE_BODY_FORMAT", + comment: "notification body. Embeds {{body text}}", + ) + } + + public static var incomingReactionStickerViewOnceMessage: String { + OWSLocalizedString( + "REACTION_STICKER_INCOMING_NOTIFICATION_TO_VIEW_ONCE_MESSAGE_BODY_FORMAT", + comment: "notification body.", + ) + } + + public static var incomingReactionStickerStickerMessage: String { + OWSLocalizedString( + "REACTION_STICKER_INCOMING_NOTIFICATION_TO_STICKER_MESSAGE_BODY_FORMAT", + comment: "notification body.", + ) + } + + public static var incomingReactionStickerContactShareMessage: String { + OWSLocalizedString( + "REACTION_STICKER_INCOMING_NOTIFICATION_TO_CONTACT_SHARE_BODY_FORMAT", + comment: "notification body.", + ) + } + + public static var incomingReactionStickerAlbumMessage: String { + OWSLocalizedString( + "REACTION_STICKER_INCOMING_NOTIFICATION_TO_ALBUM_BODY_FORMAT", + comment: "notification body.", + ) + } + + public static var incomingReactionStickerPhotoMessage: String { + OWSLocalizedString( + "REACTION_STICKER_INCOMING_NOTIFICATION_TO_PHOTO_BODY_FORMAT", + comment: "notification body.", + ) + } + + public static var incomingReactionStickerVideoMessage: String { + OWSLocalizedString( + "REACTION_STICKER_INCOMING_NOTIFICATION_TO_VIDEO_BODY_FORMAT", + comment: "notification body.", + ) + } + + public static var incomingReactionStickerVoiceMessage: String { + OWSLocalizedString( + "REACTION_STICKER_INCOMING_NOTIFICATION_TO_VOICE_MESSAGE_BODY_FORMAT", + comment: "notification body.", + ) + } + + public static var incomingReactionStickerAudioMessage: String { + OWSLocalizedString( + "REACTION_STICKER_INCOMING_NOTIFICATION_TO_AUDIO_BODY_FORMAT", + comment: "notification body.", + ) + } + + public static var incomingReactionStickerGifMessage: String { + OWSLocalizedString( + "REACTION_STICKER_INCOMING_NOTIFICATION_TO_GIF_BODY_FORMAT", + comment: "notification body.", + ) + } + + public static var incomingReactionStickerFileMessage: String { + OWSLocalizedString( + "REACTION_STICKER_INCOMING_NOTIFICATION_TO_FILE_BODY_FORMAT", + comment: "notification body.", + ) + } } // MARK: - diff --git a/SignalServiceKit/tests/Storage/Database/DatabaseRecoveryTest.swift b/SignalServiceKit/tests/Storage/Database/DatabaseRecoveryTest.swift index f4aa8fb55b9..7e5b363f519 100644 --- a/SignalServiceKit/tests/Storage/Database/DatabaseRecoveryTest.swift +++ b/SignalServiceKit/tests/Storage/Database/DatabaseRecoveryTest.swift @@ -147,15 +147,25 @@ final class DatabaseRecoveryTest: SSKBaseTest { let message = messageBuilder.build() message.anyInsert(transaction: transaction) - // Reaction - let reaction = OWSReaction( + // Reactions + var reaction = OWSReaction( uniqueMessageId: message.uniqueId, emoji: "💽", + sticker: nil, reactor: localAci, sentAtTimestamp: 1234, receivedAtTimestamp: 1234, ) reaction.anyInsert(transaction: transaction) + reaction = OWSReaction( + uniqueMessageId: message.uniqueId, + emoji: "💽", + sticker: StickerInfo.defaultValue, + reactor: contactAci, + sentAtTimestamp: 1235, + receivedAtTimestamp: 1235, + ) + reaction.anyInsert(transaction: transaction) // Pending read receipts (not copied) var pendingReadReceipt = PendingReadReceiptRecord( diff --git a/SignalUI/Sending/QuotedReplyModel.swift b/SignalUI/Sending/QuotedReplyModel.swift index 1b143f4ad0f..b992bea4d96 100644 --- a/SignalUI/Sending/QuotedReplyModel.swift +++ b/SignalUI/Sending/QuotedReplyModel.swift @@ -21,10 +21,10 @@ public class QuotedReplyModel { public let isOriginalMessageAuthorLocalUser: Bool - /// IFF the original's content was a story message, the emoji used - /// _on the reply body_ to that story message. + /// IFF the original's content was a story message, the reaction used + /// _on the reply_ to that story message. /// Ignored for other original content types. - public let storyReactionEmoji: String? + public let storyReaction: StoryReaction? /// The content on the _original_ message being replied to. public enum OriginalContent { @@ -37,8 +37,8 @@ public class QuotedReplyModel { /// The original message was a gift badge case giftBadge /// The original message is itself a reply to a story - /// with an emoji. - case storyReactionEmoji(String) + /// with an emoji or sticker. + case storyReaction(StoryReaction) // MARK: - Attachment types @@ -107,8 +107,8 @@ public class QuotedReplyModel { return nil case .giftBadge: return nil - case .storyReactionEmoji: - return nil + case .storyReaction(let reaction): + return reaction.sticker?.attachment.mimeType case .attachmentStub(_, let stub): return stub.mimeType case .attachment(_, let attachment, _): @@ -130,8 +130,8 @@ public class QuotedReplyModel { return nil case .giftBadge: return nil - case .storyReactionEmoji: - return nil + case .storyReaction(let reaction): + return reaction.sticker?.attachment.asStream()?.contentType case .attachmentStub: return nil case .attachment(_, let attachment, _): @@ -163,8 +163,11 @@ public class QuotedReplyModel { return messageBody case .giftBadge: return nil - case .storyReactionEmoji(let string): - return MessageBody(text: string, ranges: .empty) + case .storyReaction(let reaction): + if reaction.sticker != nil { + return nil + } + return MessageBody(text: reaction.emoji, ranges: .empty) case .attachmentStub(let messageBody, _): return messageBody case .attachment(let messageBody, _, _): @@ -192,8 +195,8 @@ public class QuotedReplyModel { return nil case .giftBadge: return nil - case .storyReactionEmoji: - return nil + case .storyReaction(let reaction): + return reaction.sticker?.reference.sourceFilename case .attachmentStub(_, let stub): return stub.sourceFilename case .attachment(_, let attachment, _): @@ -220,8 +223,8 @@ public class QuotedReplyModel { return messageBody?.text case .giftBadge: return nil - case .storyReactionEmoji(let string): - return string + case .storyReaction(let reaction): + return reaction.emoji case .attachmentStub(let messageBody, _): var captionString = "" let caption = messageBody?.text.nilIfEmpty @@ -261,8 +264,8 @@ public class QuotedReplyModel { case .giftBadge: // This pretends to be a thumbnail return true - case .storyReactionEmoji: - return false + case .storyReaction: + return true case .attachmentStub: return false case .attachment(_, _, let thumbnailImage): @@ -280,7 +283,7 @@ public class QuotedReplyModel { public static func build( replyingTo storyMessage: StoryMessage, - reactionEmoji: String? = nil, + reaction: StoryReaction? = nil, transaction: DBReadTransaction, ) -> QuotedReplyModel { let isOriginalAuthorLocalUser = DependenciesBridge.shared.tsAccountManager @@ -297,7 +300,7 @@ public class QuotedReplyModel { originalMessageAuthorAddress: storyMessage.authorAddress, originalMessageMemberLabel: nil, isOriginalMessageAuthorLocalUser: isOriginalAuthorLocalUser, - storyReactionEmoji: reactionEmoji, + storyReaction: reaction, originalContent: originalContent, sourceOfOriginal: .story, ) @@ -353,6 +356,29 @@ public class QuotedReplyModel { storyAuthorAci: Aci, transaction: DBReadTransaction, ) -> QuotedReplyModel { + let storyReaction: StoryReaction? + if let emoji = message.storyReactionEmoji { + let stickerAttachment: ReferencedAttachment? + if let messageRowId = message.sqliteRowId { + stickerAttachment = DependenciesBridge.shared.attachmentStore + .fetchAnyReferencedAttachment( + for: .messageSticker(messageRowId: messageRowId), + tx: transaction + ) + } else { + stickerAttachment = nil + } + storyReaction = StoryReaction( + emoji: emoji, + sticker: stickerAttachment, + stickerInfo: stickerAttachment == nil + ? nil + : message.messageSticker?.info + ) + } else { + storyReaction = nil + } + guard let storyTimestamp, let storyMessage = StoryFinder.story( @@ -364,19 +390,20 @@ public class QuotedReplyModel { let isOriginalMessageAuthorLocalUser = DependenciesBridge.shared.tsAccountManager .localIdentifiers(tx: transaction)? .aci == storyAuthorAci + return QuotedReplyModel( originalMessageTimestamp: storyTimestamp, originalMessageAuthorAddress: SignalServiceAddress(storyAuthorAci), originalMessageMemberLabel: nil, isOriginalMessageAuthorLocalUser: isOriginalMessageAuthorLocalUser, - storyReactionEmoji: message.storyReactionEmoji, + storyReaction: storyReaction, originalContent: .expiredStory, sourceOfOriginal: .story, ) } return QuotedReplyModel.build( replyingTo: storyMessage, - reactionEmoji: message.storyReactionEmoji, + reaction: storyReaction, transaction: transaction, ) } @@ -402,7 +429,7 @@ public class QuotedReplyModel { originalMessageAuthorAddress: quotedMessage.authorAddress, originalMessageMemberLabel: memberLabel, isOriginalMessageAuthorLocalUser: isOriginalAuthorLocalUser, - storyReactionEmoji: nil, + storyReaction: nil, originalContent: originalContent, sourceOfOriginal: quotedMessage.bodySource, ) @@ -503,7 +530,7 @@ public class QuotedReplyModel { originalMessageAuthorAddress: SignalServiceAddress, originalMessageMemberLabel: String?, isOriginalMessageAuthorLocalUser: Bool, - storyReactionEmoji: String?, + storyReaction: StoryReaction?, originalContent: OriginalContent, sourceOfOriginal: TSQuotedMessageContentSource, ) { @@ -511,7 +538,7 @@ public class QuotedReplyModel { self.originalMessageAuthorAddress = originalMessageAuthorAddress self.originalMessageMemberLabel = originalMessageMemberLabel self.isOriginalMessageAuthorLocalUser = isOriginalMessageAuthorLocalUser - self.storyReactionEmoji = storyReactionEmoji + self.storyReaction = storyReaction self.originalContent = originalContent self.sourceOfOriginal = sourceOfOriginal } @@ -524,7 +551,7 @@ extension QuotedReplyModel: Equatable { return lhs.originalMessageTimestamp == rhs.originalMessageTimestamp && lhs.originalMessageAuthorAddress == rhs.originalMessageAuthorAddress && lhs.isOriginalMessageAuthorLocalUser == rhs.isOriginalMessageAuthorLocalUser - && lhs.storyReactionEmoji == rhs.storyReactionEmoji + && lhs.storyReaction == rhs.storyReaction && lhs.originalContent == rhs.originalContent && lhs.sourceOfOriginal == rhs.sourceOfOriginal } @@ -537,8 +564,8 @@ extension QuotedReplyModel.OriginalContent: Equatable { return lhsBody == rhsBody case (.giftBadge, .giftBadge): return true - case let (.storyReactionEmoji(lhsString), .storyReactionEmoji(rhsString)): - return lhsString == rhsString + case let (.storyReaction(lhsReaction), .storyReaction(rhsReaction)): + return lhsReaction == rhsReaction case let (.attachmentStub(lhsBody, lhsStub), .attachmentStub(rhsBody, rhsStub)): return lhsBody == rhsBody && lhsStub == rhsStub case let (.attachment(lhsBody, lhsAttachment, lhsImage), .attachment(rhsBody, rhsAttachment, rhsImage)): @@ -559,7 +586,7 @@ extension QuotedReplyModel.OriginalContent: Equatable { case (.text, _), (.giftBadge, _), - (.storyReactionEmoji, _), + (.storyReaction, _), (.attachmentStub, _), (.attachment, _), (.mediaStory, _), diff --git a/SignalUI/Stickers/StickerPickerKeyboard.swift b/SignalUI/Stickers/StickerPickerKeyboard.swift index 582d6bb32c3..9f7287fd3bb 100644 --- a/SignalUI/Stickers/StickerPickerKeyboard.swift +++ b/SignalUI/Stickers/StickerPickerKeyboard.swift @@ -65,7 +65,7 @@ public class StickerKeyboard: CustomKeyboard { extension StickerKeyboard: StickerPickerViewDelegate { - func presentManageStickersView(for stickerPickerView: StickerPickerView) { + public func presentManageStickersView(for stickerPickerView: StickerPickerView) { delegate?.stickerKeyboardDidRequestPresentManageStickersView(self) } diff --git a/SignalUI/Stickers/StickerPickerSheet.swift b/SignalUI/Stickers/StickerPickerSheet.swift index a7b017a19dc..3e929528198 100644 --- a/SignalUI/Stickers/StickerPickerSheet.swift +++ b/SignalUI/Stickers/StickerPickerSheet.swift @@ -90,7 +90,7 @@ public class StickerPickerSheet: InteractiveSheetViewController { extension StickerPickerSheet: StickerPickerViewDelegate { - func presentManageStickersView(for stickerPickerView: StickerPickerView) { + public func presentManageStickersView(for stickerPickerView: StickerPickerView) { guard let sheetDelegate else { return } let manageStickersViewController = sheetDelegate.makeManageStickersViewController(for: self) presentFormSheet(manageStickersViewController, animated: true) diff --git a/SignalUI/Stickers/StickerPickerView.swift b/SignalUI/Stickers/StickerPickerView.swift index 697b2a29ef5..9741801635f 100644 --- a/SignalUI/Stickers/StickerPickerView.swift +++ b/SignalUI/Stickers/StickerPickerView.swift @@ -3,23 +3,23 @@ // SPDX-License-Identifier: AGPL-3.0-only // -import SignalServiceKit +public import SignalServiceKit -protocol StickerPickerViewDelegate: StickerPickerDelegate { +public protocol StickerPickerViewDelegate: StickerPickerDelegate { func presentManageStickersView(for: StickerPickerView) } -class StickerPickerView: UIView { +public class StickerPickerView: UIView { weak var delegate: StickerPickerViewDelegate? private let storyStickerConfigation: StoryStickerConfiguration - var stickerPackCollectionViewPages: [UICollectionView] { + public var stickerPackCollectionViewPages: [UICollectionView] { stickerPageView.stickerPackCollectionViews } - init( + public init( delegate: StickerPickerViewDelegate, storyStickerConfiguration: StoryStickerConfiguration = .hide, ) { @@ -56,17 +56,17 @@ class StickerPickerView: UIView { updateStickerPageViewContentInsets() } - required init?(coder: NSCoder) { + public required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } // MARK: Presentation - func willBePresented() { + public func willBePresented() { stickerPageView.willBePresented() } - func wasPresented() { + public func wasPresented() { stickerPageView.wasPresented() } @@ -78,12 +78,12 @@ class StickerPickerView: UIView { storyStickerConfiguration: storyStickerConfigation, ) - override func layoutMarginsDidChange() { + override public func layoutMarginsDidChange() { super.layoutMarginsDidChange() updateStickerPageViewContentInsets() } - override func layoutSubviews() { + override public func layoutSubviews() { super.layoutSubviews() // Necessary to update bottom inset after footer has its final position and size. @@ -126,7 +126,7 @@ extension StickerPickerView: StickerPickerPageViewDelegate { toolbar.packsCollectionView.updateSelections(scrollToSelectedItem: scrollToSelectedItem) } - func didSelectSticker(_ stickerInfo: StickerInfo) { + public func didSelectSticker(_ stickerInfo: StickerInfo) { delegate?.didSelectSticker(stickerInfo) } }