501 lines
20 KiB
Swift
501 lines
20 KiB
Swift
//
|
|
// Copyright 2023 Signal Messenger, LLC
|
|
// SPDX-License-Identifier: AGPL-3.0-only
|
|
//
|
|
|
|
import SignalServiceKit
|
|
|
|
// View model which has already fetched any attachments.
|
|
|
|
public class QuotedReplyModel: NSObject {
|
|
|
|
public let timestamp: UInt64?
|
|
public let authorAddress: SignalServiceAddress
|
|
public let attachmentStream: TSAttachmentStream?
|
|
public let attachmentType: TSAttachmentType?
|
|
public let failedThumbnailAttachmentPointer: TSAttachmentPointer?
|
|
|
|
// This property should be set IFF we are quoting a text message
|
|
// or attachment with caption.
|
|
public let body: String?
|
|
public let bodyRanges: MessageBodyRanges?
|
|
private let bodySource: TSQuotedMessageContentSource
|
|
public let reactionEmoji: String?
|
|
|
|
public var isRemotelySourced: Bool { bodySource == .remote }
|
|
|
|
public var isStory: Bool { bodySource == .story }
|
|
|
|
public let isGiftBadge: Bool
|
|
|
|
public let isPayment: Bool
|
|
|
|
// MARK: Attachments
|
|
|
|
// This is a MIME type.
|
|
//
|
|
// This property should be set IFF we are quoting an attachment message.
|
|
public let contentType: String?
|
|
|
|
public let sourceFilename: String?
|
|
|
|
public let thumbnailImage: UIImage?
|
|
public let thumbnailViewFactory: ((SpoilerRenderState) -> UIView?)?
|
|
|
|
public convenience init?(storyMessage: StoryMessage, reactionEmoji: String? = nil, transaction: SDSAnyReadTransaction) {
|
|
let thumbnailImage = storyMessage.thumbnailImage(transaction: transaction)
|
|
let thumbnailViewFactory: ((SpoilerRenderState) -> UIView?)?
|
|
if thumbnailImage == nil {
|
|
thumbnailViewFactory = { return storyMessage.thumbnailView(spoilerState: $0) }
|
|
} else {
|
|
thumbnailViewFactory = nil
|
|
}
|
|
|
|
let attachmentStream: TSAttachmentStream?
|
|
let failedAttachmentPointer: TSAttachmentPointer?
|
|
let quotedAttachment = storyMessage.fileAttachment(tx: transaction)
|
|
if let quotedAttachmentStream = quotedAttachment as? TSAttachmentStream {
|
|
attachmentStream = quotedAttachmentStream
|
|
failedAttachmentPointer = nil
|
|
} else if let attachmentPointer = quotedAttachment as? TSAttachmentPointer {
|
|
attachmentStream = nil
|
|
failedAttachmentPointer = attachmentPointer
|
|
} else {
|
|
attachmentStream = nil
|
|
failedAttachmentPointer = nil
|
|
}
|
|
|
|
let attachmentType: TSAttachmentType? = quotedAttachment?
|
|
.isLoopingVideo(inContainingStoryMessage: storyMessage, transaction: transaction) ?? false
|
|
? .GIF : .default
|
|
|
|
let body = storyMessage.quotedBody(transaction: transaction)
|
|
|
|
self.init(
|
|
timestamp: storyMessage.timestamp,
|
|
authorAddress: storyMessage.authorAddress,
|
|
bodySource: .story,
|
|
body: body?.text,
|
|
bodyRanges: body?.ranges,
|
|
thumbnailImage: thumbnailImage,
|
|
thumbnailViewFactory: thumbnailViewFactory,
|
|
contentType: attachmentStream?.contentType,
|
|
attachmentStream: attachmentStream,
|
|
attachmentType: attachmentType,
|
|
failedThumbnailAttachmentPointer: failedAttachmentPointer,
|
|
reactionEmoji: reactionEmoji
|
|
)
|
|
}
|
|
|
|
private convenience init?(storyReplyMessage message: TSMessage, transaction: SDSAnyReadTransaction) {
|
|
guard message.isStoryReply else { return nil }
|
|
|
|
guard let storyTimestamp = message.storyTimestamp?.uint64Value, let storyAuthorAci = message.storyAuthorAci else {
|
|
return nil
|
|
}
|
|
|
|
guard let storyMessage = StoryFinder.story(
|
|
timestamp: storyTimestamp,
|
|
author: storyAuthorAci.wrappedAciValue,
|
|
transaction: transaction
|
|
) else {
|
|
// Story message does not exist, return generic reply.
|
|
self.init(
|
|
timestamp: storyTimestamp,
|
|
authorAddress: SignalServiceAddress(storyAuthorAci.wrappedAciValue),
|
|
bodySource: .story,
|
|
body: OWSLocalizedString(
|
|
"STORY_NO_LONGER_AVAILABLE",
|
|
comment: "Text indicating a story that was replied to is no longer available."
|
|
),
|
|
bodyRanges: .empty,
|
|
reactionEmoji: message.storyReactionEmoji
|
|
)
|
|
return
|
|
}
|
|
|
|
self.init(storyMessage: storyMessage, reactionEmoji: message.storyReactionEmoji, transaction: transaction)
|
|
}
|
|
|
|
// Used for persisted quoted replies, both incoming and outgoing.
|
|
public convenience init?(message: TSMessage, transaction: SDSAnyReadTransaction) {
|
|
if message.isStoryReply {
|
|
self.init(storyReplyMessage: message, transaction: transaction)
|
|
return
|
|
}
|
|
|
|
guard let quotedMessage = message.quotedMessage else {
|
|
return nil
|
|
}
|
|
|
|
let thumbnailImage: UIImage?
|
|
let failedAttachmentPointer: TSAttachmentPointer?
|
|
|
|
let attachment = message.fetchQuotedMessageThumbnail(with: transaction)
|
|
if let attachmentStream = attachment as? TSAttachmentStream {
|
|
thumbnailImage = attachmentStream.thumbnailImageSmallSync()
|
|
failedAttachmentPointer = nil
|
|
} else if !quotedMessage.isThumbnailOwned {
|
|
// If the quoted message isn't owning the thumbnail attachment, it's going to be referencing
|
|
// some other attachment (e.g. undownloaded media). In this case, let's just use the blur hash
|
|
if let blurHash = attachment?.blurHash {
|
|
thumbnailImage = BlurHash.image(for: blurHash)
|
|
} else {
|
|
thumbnailImage = nil
|
|
}
|
|
failedAttachmentPointer = nil
|
|
} else if let attachmentPointer = attachment as? TSAttachmentPointer {
|
|
// If the quoted message has ownership of the thumbnail, but it hasn't been downloaded yet,
|
|
// we should surface this in the view.
|
|
thumbnailImage = nil
|
|
failedAttachmentPointer = attachmentPointer
|
|
} else {
|
|
thumbnailImage = nil
|
|
failedAttachmentPointer = nil
|
|
}
|
|
|
|
let attachmentType: TSAttachmentType? = attachment?
|
|
.attachmentType(forContainingMessage: message, transaction: transaction)
|
|
|
|
var body: String? = quotedMessage.body
|
|
var bodyRanges: MessageBodyRanges? = quotedMessage.bodyRanges
|
|
|
|
let isPayment: Bool
|
|
if let paymentMessage = message as? OWSPaymentMessage {
|
|
isPayment = true
|
|
body = PaymentsFormat.paymentPreviewText(
|
|
paymentMessage: paymentMessage,
|
|
type: message.interactionType,
|
|
transaction: transaction
|
|
)
|
|
bodyRanges = nil
|
|
} else {
|
|
isPayment = false
|
|
}
|
|
|
|
self.init(
|
|
timestamp: quotedMessage.timestampValue?.uint64Value,
|
|
authorAddress: quotedMessage.authorAddress,
|
|
bodySource: quotedMessage.bodySource,
|
|
body: body,
|
|
bodyRanges: bodyRanges,
|
|
thumbnailImage: thumbnailImage,
|
|
contentType: quotedMessage.contentType,
|
|
sourceFilename: quotedMessage.sourceFilename,
|
|
attachmentType: attachmentType,
|
|
failedThumbnailAttachmentPointer: failedAttachmentPointer,
|
|
isGiftBadge: quotedMessage.isGiftBadge,
|
|
isPayment: isPayment
|
|
)
|
|
}
|
|
|
|
// Builds a not-yet-sent QuotedReplyModel
|
|
public static func forSending(item: CVItemViewModel, transaction: SDSAnyReadTransaction) -> QuotedReplyModel? {
|
|
|
|
guard let message = item.interaction as? TSMessage else {
|
|
owsFailDebug("unexpected reply message: \(item.interaction)")
|
|
return nil
|
|
}
|
|
|
|
let timestamp = message.timestamp
|
|
|
|
let authorAddress: SignalServiceAddress? = {
|
|
if message is TSOutgoingMessage {
|
|
return DependenciesBridge.shared.tsAccountManager.localIdentifiers(tx: transaction.asV2Read)?.aciAddress
|
|
}
|
|
if let incomingMessage = message as? TSIncomingMessage {
|
|
return incomingMessage.authorAddress
|
|
}
|
|
owsFailDebug("Unexpected message type: \(message.self)")
|
|
return nil
|
|
}()
|
|
guard let authorAddress, authorAddress.isValid else {
|
|
owsFailDebug("No authorAddress or address is not valid.")
|
|
return nil
|
|
}
|
|
|
|
if message.isViewOnceMessage {
|
|
// We construct a quote that does not include any of the quoted message's renderable content.
|
|
let body = OWSLocalizedString(
|
|
"PER_MESSAGE_EXPIRATION_NOT_VIEWABLE",
|
|
comment: "inbox cell and notification text for an already viewed view-once media message."
|
|
)
|
|
return QuotedReplyModel(
|
|
timestamp: timestamp,
|
|
authorAddress: authorAddress,
|
|
bodySource: .local,
|
|
body: body
|
|
)
|
|
}
|
|
|
|
if let contactShare = item.contactShare {
|
|
// TODO We deliberately always pass `nil` for `thumbnailImage`, even though we might have a
|
|
// contactShare.avatarImage because the QuotedReplyViewModel has some hardcoded assumptions that only quoted
|
|
// attachments have thumbnails. Until we address that we want to be consistent about neither showing nor sending
|
|
// the contactShare avatar in the quoted reply.
|
|
return QuotedReplyModel(
|
|
timestamp: timestamp,
|
|
authorAddress: authorAddress,
|
|
bodySource: .local,
|
|
body: "👤 " + contactShare.displayName
|
|
)
|
|
}
|
|
|
|
if item.isGiftBadge {
|
|
return QuotedReplyModel(
|
|
timestamp: timestamp,
|
|
authorAddress: authorAddress,
|
|
bodySource: .local,
|
|
isGiftBadge: true
|
|
)
|
|
}
|
|
|
|
let isStickerMessage = item.stickerInfo != nil || item.stickerAttachment != nil || item.stickerMetadata != nil
|
|
if isStickerMessage {
|
|
guard
|
|
item.stickerInfo != nil,
|
|
let stickerAttachment = item.stickerAttachment,
|
|
let stickerMetadata = item.stickerMetadata
|
|
else {
|
|
owsFailDebug("Incomplete sticker message.")
|
|
return nil
|
|
}
|
|
|
|
guard let stickerData = try? Data(contentsOf: stickerMetadata.stickerDataUrl) else {
|
|
owsFailDebug("Couldn't load sticker data")
|
|
return nil
|
|
}
|
|
|
|
// Sticker type metadata isn't reliable, so determine the sticker type by examining the actual sticker data.
|
|
let stickerType: StickerType
|
|
let contentType: String?
|
|
if stickerMetadata.stickerType == .webp {
|
|
let imageMetadata = (stickerData as NSData).imageMetadata(withPath: nil, mimeType: nil)
|
|
contentType = imageMetadata.mimeType
|
|
|
|
switch imageMetadata.imageFormat {
|
|
case .png:
|
|
stickerType = .apng
|
|
|
|
case .gif:
|
|
stickerType = .gif
|
|
|
|
case .webp:
|
|
stickerType = .webp
|
|
|
|
case .lottieSticker:
|
|
stickerType = .signalLottie
|
|
|
|
case .unknown:
|
|
owsFailDebug("Unknown sticker data format")
|
|
return nil
|
|
|
|
default:
|
|
owsFailDebug("Invalid sticker data format: \(NSStringForImageFormat(imageMetadata.imageFormat))")
|
|
return nil
|
|
}
|
|
} else {
|
|
stickerType = stickerMetadata.stickerType
|
|
contentType = stickerMetadata.contentType
|
|
}
|
|
|
|
let maxThumbnailSizePixels: CGFloat = 512
|
|
let thumbnailImage: UIImage? = {
|
|
switch stickerType {
|
|
case .webp:
|
|
return (stickerData as NSData).stillForWebpData()
|
|
case .signalLottie:
|
|
return nil
|
|
case .apng:
|
|
return UIImage(data: stickerData)
|
|
case .gif:
|
|
do {
|
|
let image = try OWSMediaUtils.thumbnail(
|
|
forImageAtPath: stickerMetadata.stickerDataUrl.path,
|
|
maxDimensionPixels: maxThumbnailSizePixels
|
|
)
|
|
return image
|
|
} catch {
|
|
owsFailDebug("Error: \(error)")
|
|
return nil
|
|
}
|
|
}
|
|
}()
|
|
guard let resizedThumbnailImage = thumbnailImage?.resized(withMaxDimensionPixels: maxThumbnailSizePixels) else {
|
|
owsFailDebug("Couldn't generate thumbnail for sticker.")
|
|
return nil
|
|
}
|
|
|
|
let attachmentType: TSAttachmentType?
|
|
if let message = item.interaction as? TSMessage {
|
|
attachmentType = stickerAttachment.attachmentType(forContainingMessage: message, transaction: transaction)
|
|
} else {
|
|
attachmentType = nil
|
|
}
|
|
|
|
return QuotedReplyModel(
|
|
timestamp: timestamp,
|
|
authorAddress: authorAddress,
|
|
bodySource: .local,
|
|
thumbnailImage: resizedThumbnailImage,
|
|
contentType: contentType,
|
|
sourceFilename: stickerAttachment.sourceFilename,
|
|
attachmentStream: stickerAttachment,
|
|
attachmentType: attachmentType
|
|
)
|
|
}
|
|
|
|
var quotedText: String?
|
|
if let messageBody = message.body, !messageBody.isEmpty {
|
|
quotedText = messageBody
|
|
} else if let storyReactionEmoji = message.storyReactionEmoji, !storyReactionEmoji.isEmpty {
|
|
let formatString: String
|
|
if authorAddress.isLocalAddress {
|
|
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}}"
|
|
)
|
|
} 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}}"
|
|
)
|
|
}
|
|
quotedText = String(
|
|
format: formatString,
|
|
storyReactionEmoji
|
|
)
|
|
}
|
|
|
|
var hasText = !quotedText.isEmptyOrNil
|
|
|
|
var quotedAttachment: TSAttachmentStream?
|
|
if let attachmentStream = message.bodyAttachments(with: transaction).first as? TSAttachmentStream {
|
|
// If the attachment is "oversize text", try the quote as a reply to text, not as
|
|
// a reply to an attachment.
|
|
if !hasText && attachmentStream.contentType == OWSMimeTypeOversizeTextMessage {
|
|
hasText = true
|
|
quotedText = ""
|
|
|
|
if let originalFilePath = attachmentStream.originalFilePath,
|
|
let oversizeTextData = try? Data(contentsOf: URL(fileURLWithPath: originalFilePath)),
|
|
let oversizeText = String(data: oversizeTextData, encoding: .utf8) {
|
|
// We don't need to include the entire text body of the message, just
|
|
// enough to render a snippet. kOversizeTextMessageSizeThreshold is our
|
|
// limit on how long text should be in protos since they'll be stored in
|
|
// the database. We apply this constant here for the same reasons.
|
|
// First, truncate to the rough max characters.
|
|
var truncatedText = oversizeText.substring(to: Int(kOversizeTextMessageSizeThreshold) - 1)
|
|
// But kOversizeTextMessageSizeThreshold is in _bytes_, not characters,
|
|
// so we need to continue to trim the string until it fits.
|
|
var truncatedTextDataSize = truncatedText.data(using: .utf8)?.count ?? 0
|
|
while truncatedText.count > 0 && truncatedTextDataSize >= kOversizeTextMessageSizeThreshold {
|
|
// A very coarse binary search by halving is acceptable, since
|
|
// kOversizeTextMessageSizeThreshold is much longer than our target
|
|
// length of "three short lines of text on any device we might
|
|
// display this on.
|
|
//
|
|
// The search will always converge since in the worst case (namely
|
|
// a single character which in utf-8 is >= 1024 bytes) the loop will
|
|
// exit when the string is empty.
|
|
truncatedText = truncatedText.substring(to: truncatedText.count / 2)
|
|
truncatedTextDataSize = truncatedText.data(using: .utf8)?.count ?? 0
|
|
}
|
|
if truncatedTextDataSize < kOversizeTextMessageSizeThreshold {
|
|
quotedText = truncatedText
|
|
} else {
|
|
owsFailDebug("Missing valid text snippet.")
|
|
}
|
|
}
|
|
} else {
|
|
quotedAttachment = attachmentStream
|
|
}
|
|
}
|
|
|
|
if quotedAttachment == nil, item.linkPreview != nil,
|
|
let linkPreviewAttachment = item.linkPreviewAttachment as? TSAttachmentStream {
|
|
|
|
quotedAttachment = linkPreviewAttachment
|
|
}
|
|
|
|
let hasAttachment = quotedAttachment != nil
|
|
if !hasText && !hasAttachment {
|
|
owsFailDebug("quoted message has neither text nor attachment")
|
|
}
|
|
|
|
let thumbnailImage: UIImage?
|
|
if let quotedAttachment, quotedAttachment.isValidVisualMedia {
|
|
thumbnailImage = quotedAttachment.thumbnailImageSmallSync()
|
|
} else {
|
|
thumbnailImage = nil
|
|
}
|
|
|
|
let attachmentType: TSAttachmentType?
|
|
if let message = item.interaction as? TSMessage {
|
|
attachmentType = quotedAttachment?.attachmentType(forContainingMessage: message, transaction: transaction)
|
|
} else {
|
|
attachmentType = nil
|
|
}
|
|
|
|
return QuotedReplyModel(
|
|
timestamp: timestamp,
|
|
authorAddress: authorAddress,
|
|
bodySource: .local,
|
|
body: quotedText,
|
|
bodyRanges: message.bodyRanges,
|
|
thumbnailImage: thumbnailImage,
|
|
contentType: quotedAttachment?.contentType,
|
|
sourceFilename: quotedAttachment?.sourceFilename,
|
|
attachmentStream: quotedAttachment,
|
|
attachmentType: attachmentType
|
|
)
|
|
}
|
|
|
|
public func buildQuotedMessageForSending() -> TSQuotedMessage {
|
|
// Legit usage of senderTimestamp to reference existing message
|
|
return TSQuotedMessage(
|
|
timestamp: timestamp.map { NSNumber(value: $0) },
|
|
authorAddress: authorAddress,
|
|
body: body,
|
|
bodyRanges: bodyRanges,
|
|
quotedAttachmentForSending: attachmentStream,
|
|
isGiftBadge: isGiftBadge
|
|
)
|
|
}
|
|
|
|
private init(
|
|
timestamp: UInt64?,
|
|
authorAddress: SignalServiceAddress,
|
|
bodySource: TSQuotedMessageContentSource,
|
|
body: String? = nil,
|
|
bodyRanges: MessageBodyRanges? = nil,
|
|
thumbnailImage: UIImage? = nil,
|
|
thumbnailViewFactory: ((SpoilerRenderState) -> UIView?)? = nil,
|
|
contentType: String? = nil,
|
|
sourceFilename: String? = nil,
|
|
attachmentStream: TSAttachmentStream? = nil,
|
|
attachmentType: TSAttachmentType? = nil,
|
|
failedThumbnailAttachmentPointer: TSAttachmentPointer? = nil,
|
|
reactionEmoji: String? = nil,
|
|
isGiftBadge: Bool = false,
|
|
isPayment: Bool = false
|
|
) {
|
|
self.timestamp = timestamp
|
|
self.authorAddress = authorAddress
|
|
self.bodySource = bodySource
|
|
self.body = body
|
|
self.bodyRanges = bodyRanges
|
|
self.thumbnailImage = thumbnailImage
|
|
self.thumbnailViewFactory = thumbnailViewFactory
|
|
self.contentType = contentType
|
|
self.sourceFilename = sourceFilename
|
|
self.attachmentStream = attachmentStream
|
|
self.attachmentType = attachmentType
|
|
self.failedThumbnailAttachmentPointer = failedThumbnailAttachmentPointer
|
|
self.reactionEmoji = reactionEmoji
|
|
self.isGiftBadge = isGiftBadge
|
|
self.isPayment = isPayment
|
|
super.init()
|
|
}
|
|
}
|