// // Copyright 2023 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only // import SignalServiceKit import LibSignalClient /// View model for an existing quoted reply which has already fetched any attachments. /// NOT used for draft quoted replies; this is for TSMessages with quoted replies (or story replies) /// that have already been created, for use rendering in a conversation. public class QuotedReplyModel { /// Timestamp of the original message, be it StoryMessage or TSMessage. public let originalMessageTimestamp: UInt64? /// Address of the original message's author, be it StoryMessage or TSMessage. public let originalMessageAuthorAddress: SignalServiceAddress public let isOriginalMessageAuthorLocalUser: Bool /// IFF the original's content was a story message, the emoji used /// _on the reply body_ to that story message. /// Ignored for other original content types. public let storyReactionEmoji: String? /// The content on the _original_ message being replied to. public enum OriginalContent { /// The original message had text with no attachment case text(MessageBody?) // MARK: - "Special" types /// 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) // MARK: - Attachment types /// The original message had an attachment, but it could not /// be thumbnail-ed case attachmentStub( MessageBody?, QuotedMessageAttachmentReference.Stub ) /// The original message had an attachment that can be thumbnail-ed, /// though it may not actually be thumbnail-ed *yet*. case attachment( MessageBody?, attachment: ReferencedTSResource, thumbnailImage: UIImage? ) // MARK: - Story types case mediaStory( body: StyleOnlyMessageBody?, attachment: ReferencedTSResource, thumbnailImage: UIImage? ) public typealias TextStoryThumbnailRenderer = (SpoilerRenderState) -> UIView case textStory(TextStoryThumbnailRenderer) /// Used if the story has expired; we do not retain a copy. case expiredStory // MARK: - Convenience public var isGiftBadge: Bool { switch self { case .giftBadge: return true default: return false } } public var isStory: Bool { switch self { case .mediaStory, .textStory, .expiredStory: return true default: return false } } public var attachmentMimeType: String? { switch self { case .text(_): return nil case .giftBadge: return nil case .storyReactionEmoji(_): return nil case .attachmentStub(_, let stub): return stub.mimeType case .attachment(_, let attachment, _): return attachment.attachment.mimeType case .mediaStory(_, let attachment, _): return attachment.attachment.mimeType case .textStory(_): return nil case .expiredStory: return nil } } public var attachmentCachedContentType: TSResourceContentType? { switch self { case .text(_): return nil case .giftBadge: return nil case .storyReactionEmoji(_): return nil case .attachmentStub(_, _): return nil case .attachment(_, let attachment, _): return attachment.attachment.asResourceStream()?.cachedContentType case .mediaStory(_, let attachment, _): return attachment.attachment.asResourceStream()?.cachedContentType case .textStory(_): return nil case .expiredStory: return nil } } } public let originalContent: OriginalContent /// Where we got the origina's content from. /// In plain english: did we have the original message (NOT its attachment, /// the TSMessage itself) locally when we created the TSQuotedMessage? public let sourceOfOriginal: TSQuotedMessageContentSource // MARK: Convenience public var originalMessageBody: MessageBody? { switch originalContent { case .text(let messageBody): return messageBody case .giftBadge: return nil case .storyReactionEmoji(let string): return MessageBody(text: string, ranges: .empty) case .attachmentStub(let messageBody, _): return messageBody case .attachment(let messageBody, _, _): return messageBody case .mediaStory(let body, _, _): return body?.asMessageBody() case .textStory(_): return nil case .expiredStory: return MessageBody( text: OWSLocalizedString( "STORY_NO_LONGER_AVAILABLE", comment: "Text indicating a story that was replied to is no longer available." ), ranges: .empty ) } } public var originalAttachmentSourceFilename: String? { switch originalContent { case .text(_): return nil case .giftBadge: return nil case .storyReactionEmoji(_): return nil case .attachmentStub(_, let stub): return stub.sourceFilename case .attachment(_, let attachment, _): return attachment.reference.sourceFilename case .mediaStory(_, _, _): return nil case .textStory(_): return nil case .expiredStory: return nil } } public var hasQuotedThumbnail: Bool { switch originalContent { case .text(_): return false case .giftBadge: // This pretends to be a thumbnail return true case .storyReactionEmoji(_): return false case .attachmentStub(_, _): return false case .attachment(_, _, let thumbnailImage): return thumbnailImage != nil case .mediaStory(_, _, _): return true case .textStory(_): return true case .expiredStory: return false } } public static func build( replyingTo storyMessage: StoryMessage, reactionEmoji: String? = nil, transaction: SDSAnyReadTransaction ) -> QuotedReplyModel { let isOriginalAuthorLocalUser = DependenciesBridge.shared.tsAccountManager .localIdentifiers(tx: transaction.asV2Read)? .aciAddress .isEqualToAddress(storyMessage.authorAddress) ?? false func buildQuotedReplyModel( originalContent: OriginalContent ) -> QuotedReplyModel { return QuotedReplyModel( originalMessageTimestamp: storyMessage.timestamp, originalMessageAuthorAddress: storyMessage.authorAddress, isOriginalMessageAuthorLocalUser: isOriginalAuthorLocalUser, storyReactionEmoji: reactionEmoji, originalContent: originalContent, sourceOfOriginal: .story ) } switch storyMessage.attachment { case .file, .foreignReferenceAttachment: let attachmentReference = DependenciesBridge.shared.tsResourceStore.mediaAttachment(for: storyMessage, tx: transaction.asV2Read) let attachment = attachmentReference?.fetch(tx: transaction) let referencedAttachment: ReferencedTSResource let thumbnailImage: UIImage? if let attachmentReference, let attachment { referencedAttachment = .init(reference: attachmentReference, attachment: attachment) if let stream = attachment.asResourceStream() { thumbnailImage = stream.thumbnailImageSync(quality: .small) } else if let blurHash = attachment.resourceBlurHash { thumbnailImage = BlurHash.image(for: blurHash) } else { thumbnailImage = nil } } else { return buildQuotedReplyModel(originalContent: .expiredStory) } return buildQuotedReplyModel(originalContent: .mediaStory( body: attachmentReference?.storyMediaCaption, attachment: referencedAttachment, thumbnailImage: thumbnailImage )) case .text(let textAttachment): let preloadedTextAttachment = PreloadedTextAttachment.from( textAttachment, storyMessage: storyMessage, tx: transaction ) return buildQuotedReplyModel(originalContent: .textStory({ spoilerState in return TextAttachmentView( attachment: preloadedTextAttachment, interactionIdentifier: .fromStoryMessage(storyMessage), spoilerState: spoilerState ).asThumbnailView() })) } } public static func build( storyReplyMessage message: TSMessage, storyTimestamp: UInt64, storyAuthorAci: Aci, transaction: SDSAnyReadTransaction ) -> QuotedReplyModel { guard let storyMessage = StoryFinder.story( timestamp: storyTimestamp, author: storyAuthorAci, transaction: transaction ) else { let isOriginalMessageAuthorLocalUser = DependenciesBridge.shared.tsAccountManager .localIdentifiers(tx: transaction.asV2Read)? .aci == storyAuthorAci return QuotedReplyModel( originalMessageTimestamp: storyTimestamp, originalMessageAuthorAddress: SignalServiceAddress(storyAuthorAci), isOriginalMessageAuthorLocalUser: isOriginalMessageAuthorLocalUser, storyReactionEmoji: message.storyReactionEmoji, originalContent: .expiredStory, sourceOfOriginal: .story ) } return QuotedReplyModel.build( replyingTo: storyMessage, reactionEmoji: message.storyReactionEmoji, transaction: transaction ) } // Used for persisted quoted replies, both incoming and outgoing. public static func build( replyMessage message: TSMessage, quotedMessage: TSQuotedMessage, transaction: SDSAnyReadTransaction ) -> QuotedReplyModel { func buildQuotedReplyModel( originalContent: OriginalContent ) -> QuotedReplyModel { return QuotedReplyModel( originalMessageTimestamp: quotedMessage.timestampValue?.uint64Value, originalMessageAuthorAddress: quotedMessage.authorAddress, isOriginalMessageAuthorLocalUser: message.isOutgoing, storyReactionEmoji: nil, originalContent: originalContent, sourceOfOriginal: quotedMessage.bodySource ) } let originalMessageBody: MessageBody? = quotedMessage.body.map { MessageBody(text: $0, ranges: quotedMessage.bodyRanges ?? .empty) } if quotedMessage.isGiftBadge { return buildQuotedReplyModel(originalContent: .giftBadge) } let attachmentReference = DependenciesBridge.shared.tsResourceStore.quotedAttachmentReference( for: message, tx: transaction.asV2Read ) switch attachmentReference { case nil: break case .stub(let stub): return buildQuotedReplyModel(originalContent: .attachmentStub(originalMessageBody, stub)) case .thumbnail(let attachmentRef): // Fetch the full attachment. let thumbnailAttachment = DependenciesBridge.shared.tsResourceStore.fetch( attachmentRef.resourceId, tx: transaction.asV2Read ) let image: UIImage? = { if let thumbnailAttachment, let image = DependenciesBridge.shared.tsResourceManager.thumbnailImage( attachment: thumbnailAttachment, parentMessage: message, tx: transaction.asV2Read ) { return image } else if let blurHash = thumbnailAttachment?.resourceBlurHash, let image = BlurHash.image(for: blurHash) { return image } else { return nil } }() if let thumbnailAttachment { return buildQuotedReplyModel(originalContent: .attachment( originalMessageBody, attachment: .init(reference: attachmentRef, attachment: thumbnailAttachment), thumbnailImage: image )) } else { break } } return buildQuotedReplyModel(originalContent: .text(originalMessageBody)) } private init( originalMessageTimestamp: UInt64?, originalMessageAuthorAddress: SignalServiceAddress, isOriginalMessageAuthorLocalUser: Bool, storyReactionEmoji: String?, originalContent: OriginalContent, sourceOfOriginal: TSQuotedMessageContentSource ) { self.originalMessageTimestamp = originalMessageTimestamp self.originalMessageAuthorAddress = originalMessageAuthorAddress self.isOriginalMessageAuthorLocalUser = isOriginalMessageAuthorLocalUser self.storyReactionEmoji = storyReactionEmoji self.originalContent = originalContent self.sourceOfOriginal = sourceOfOriginal } } // MARK: - Equatable extension QuotedReplyModel: Equatable { public static func == (lhs: QuotedReplyModel, rhs: QuotedReplyModel) -> Bool { return lhs.originalMessageTimestamp == rhs.originalMessageTimestamp && lhs.originalMessageAuthorAddress == rhs.originalMessageAuthorAddress && lhs.isOriginalMessageAuthorLocalUser == rhs.isOriginalMessageAuthorLocalUser && lhs.storyReactionEmoji == rhs.storyReactionEmoji && lhs.originalContent == rhs.originalContent && lhs.sourceOfOriginal == rhs.sourceOfOriginal } } extension QuotedReplyModel.OriginalContent: Equatable { public static func == (lhs: QuotedReplyModel.OriginalContent, rhs: QuotedReplyModel.OriginalContent) -> Bool { switch (lhs, rhs) { case let (.text(lhsBody), .text(rhsBody)): return lhsBody == rhsBody case (.giftBadge, .giftBadge): return true case let (.storyReactionEmoji(lhsString), .storyReactionEmoji(rhsString)): return lhsString == rhsString case let (.attachmentStub(lhsBody, lhsStub), .attachmentStub(rhsBody, rhsStub)): return lhsBody == rhsBody && lhsStub == rhsStub case let (.attachment(lhsBody, lhsAttachment, lhsImage), .attachment(rhsBody, rhsAttachment, rhsImage)): return lhsBody == rhsBody && lhsAttachment.attachment.resourceId == rhsAttachment.attachment.resourceId && lhsImage == rhsImage case let (.mediaStory(lhsBody, lhsAttachment, lhsImage), .mediaStory(rhsBody, rhsAttachment, rhsImage)): return lhsBody == rhsBody && lhsAttachment.attachment.resourceId == rhsAttachment.attachment.resourceId && lhsImage == rhsImage case (.textStory(_), .textStory(_)): /// Defensively re-render every time. return false case (.expiredStory, .expiredStory): return true case (.text, _), (.giftBadge, _), (.storyReactionEmoji, _), (.attachmentStub, _), (.attachment, _), (.mediaStory, _), (.textStory, _), (.expiredStory, _): return false } } }