TM-SGNL-iOS/SignalServiceKit/Messages/Interactions/Quotes/QuotedReplyManagerImpl.swift
TeleMessage developers dde0620daf initial commit
2025-05-03 12:28:28 -07:00

881 lines
35 KiB
Swift

//
// Copyright 2024 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
import Foundation
import LibSignalClient
public class QuotedReplyManagerImpl: QuotedReplyManager {
private let attachmentManager: AttachmentManager
private let attachmentStore: AttachmentStore
private let attachmentValidator: AttachmentContentValidator
private let db: any DB
private let tsAccountManager: TSAccountManager
public init(
attachmentManager: AttachmentManager,
attachmentStore: AttachmentStore,
attachmentValidator: AttachmentContentValidator,
db: any DB,
tsAccountManager: TSAccountManager
) {
self.attachmentManager = attachmentManager
self.attachmentStore = attachmentStore
self.attachmentValidator = attachmentValidator
self.db = db
self.tsAccountManager = tsAccountManager
}
public func quotedMessage(
for dataMessage: SSKProtoDataMessage,
thread: TSThread,
tx: DBWriteTransaction
) -> OwnedAttachmentBuilder<TSQuotedMessage>? {
guard let quote = dataMessage.quote else {
return nil
}
let timestamp = quote.id
guard timestamp != 0 else {
owsFailDebug("quoted message missing id")
return nil
}
guard SDS.fitsInInt64(timestamp) else {
owsFailDebug("Invalid timestamp")
return nil
}
guard let quoteAuthor = Aci.parseFrom(aciString: quote.authorAci) else {
owsFailDebug("quoted message missing author")
return nil
}
let originalMessage = InteractionFinder.findMessage(
withTimestamp: timestamp,
threadId: thread.uniqueId,
author: .init(quoteAuthor),
transaction: SDSDB.shimOnlyBridge(tx)
)
switch originalMessage {
case .some(let originalMessage):
// Prefer to generate the quoted content locally if available.
if
let localQuotedMessage = self.quotedMessage(
originalMessage: originalMessage,
quoteProto: quote,
author: quoteAuthor,
tx: tx
)
{
return localQuotedMessage
} else {
fallthrough
}
case .none:
// If we couldn't generate the quoted content from locally available info, we can generate it from the proto.
return remoteQuotedMessage(
quoteProto: quote,
quoteAuthor: quoteAuthor,
quoteTimestamp: timestamp,
tx: tx
)
}
}
/// Builds a remote message from the proto payload
/// NOTE: Quoted messages constructed from proto material may not be representative of the original source content. This
/// should be flagged to the user. (See: ``QuotedReplyModel.isRemotelySourced``)
private func remoteQuotedMessage(
quoteProto: SSKProtoDataMessageQuote,
quoteAuthor: Aci,
quoteTimestamp: UInt64,
tx: DBWriteTransaction
) -> OwnedAttachmentBuilder<TSQuotedMessage>? {
let quoteAuthorAddress = SignalServiceAddress(quoteAuthor)
// This is untrusted content from other users that may not be well-formed.
// The GiftBadge type has no content/attachments, so don't read those
// fields if the type is GiftBadge.
if
quoteProto.hasType,
quoteProto.unwrappedType == .giftBadge
{
return .withoutFinalizer(TSQuotedMessage(
timestamp: quoteTimestamp,
authorAddress: quoteAuthorAddress,
body: nil,
bodyRanges: nil,
bodySource: .remote,
receivedQuotedAttachmentInfo: nil,
isGiftBadge: true,
isTargetMessageViewOnce: false
))
}
let body = quoteProto.text?.nilIfEmpty
let bodyRanges = quoteProto.bodyRanges.isEmpty ? nil : MessageBodyRanges(protos: quoteProto.bodyRanges)
let attachmentBuilder: OwnedAttachmentBuilder<QuotedAttachmentInfo>?
if
// We're only interested in the first attachment
let quotedAttachment = quoteProto.attachments.first,
let thumbnailProto = quotedAttachment.thumbnail
{
let mimeType: String = quotedAttachment.contentType?.nilIfEmpty
?? MimeType.applicationOctetStream.rawValue
let sourceFilename = quotedAttachment.fileName
do {
let thumbnailAttachmentBuilder = try attachmentManager.createAttachmentPointerBuilder(
from: thumbnailProto,
tx: tx
)
attachmentBuilder = thumbnailAttachmentBuilder.wrap {
return QuotedAttachmentInfo(
info: .forThumbnailReference(
withOriginalAttachmentMimeType: mimeType,
originalAttachmentSourceFilename: sourceFilename
),
renderingFlag: .fromProto(thumbnailProto)
)
}
} catch {
// Invalid proto!
return nil
}
} else if let attachmentProto = quoteProto.attachments.first, let mimeType = attachmentProto.contentType {
attachmentBuilder = .withoutFinalizer(QuotedAttachmentInfo(
info: .stub(
withOriginalAttachmentMimeType: mimeType,
originalAttachmentSourceFilename: attachmentProto.fileName
),
renderingFlag: .default
))
} else {
attachmentBuilder = nil
}
if body?.nilIfEmpty == nil, attachmentBuilder == nil {
owsFailDebug("Failed to construct a valid quoted message from remote proto content")
return nil
}
func quotedMessage(attachmentInfo: QuotedAttachmentInfo?) -> TSQuotedMessage {
return TSQuotedMessage(
timestamp: quoteTimestamp,
authorAddress: quoteAuthorAddress,
body: body,
bodyRanges: bodyRanges,
bodySource: .remote,
receivedQuotedAttachmentInfo: attachmentInfo?.info,
isGiftBadge: false,
isTargetMessageViewOnce: false
)
}
if let attachmentBuilder {
return attachmentBuilder.wrap(quotedMessage(attachmentInfo:))
} else {
return .withoutFinalizer(quotedMessage(attachmentInfo: nil))
}
}
/// Builds a quoted message from the original source message
private func quotedMessage(
originalMessage: TSMessage,
quoteProto: SSKProtoDataMessageQuote,
author: Aci,
tx: DBWriteTransaction
) -> OwnedAttachmentBuilder<TSQuotedMessage>? {
let authorAddress: SignalServiceAddress
if let incomingOriginal = originalMessage as? TSIncomingMessage {
authorAddress = incomingOriginal.authorAddress
} else if originalMessage is TSOutgoingMessage {
guard
let localAddress = DependenciesBridge.shared.tsAccountManager.localIdentifiers(
tx: tx
)?.aciAddress
else {
owsFailDebug("Not registered!")
return nil
}
authorAddress = localAddress
} else {
owsFailDebug("Received message of type: \(type(of: originalMessage))")
return nil
}
if originalMessage.isViewOnceMessage {
// We construct a quote that does not include any of the quoted message's renderable content.
return .withoutFinalizer(TSQuotedMessage(
timestamp: originalMessage.timestamp,
authorAddress: authorAddress,
body: nil,
bodyRanges: nil,
bodySource: .local,
receivedQuotedAttachmentInfo: nil,
isGiftBadge: false,
isTargetMessageViewOnce: true
))
}
let body: String?
let bodyRanges: MessageBodyRanges?
var isGiftBadge: Bool
if originalMessage is OWSPaymentMessage {
// This really should recalculate the string from payment metadata.
// But it does not.
body = quoteProto.text
bodyRanges = nil
isGiftBadge = false
} else if let messageBody = originalMessage.body?.nilIfEmpty {
body = messageBody
bodyRanges = originalMessage.bodyRanges
isGiftBadge = false
} else if let contactName = originalMessage.contactShare?.name.displayName.nilIfEmpty {
// Contact share bodies are special-cased in OWSQuotedReplyModel
// We need to account for that here.
body = "👤 " + contactName
bodyRanges = nil
isGiftBadge = false
} else if let storyReactionEmoji = originalMessage.storyReactionEmoji?.nilIfEmpty {
let formatString: String = {
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}}"
)
} 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}}"
)
}
}()
body = String(format: formatString, storyReactionEmoji)
bodyRanges = nil
isGiftBadge = false
} else {
isGiftBadge = originalMessage.giftBadge != nil
body = nil
bodyRanges = nil
}
let attachmentBuilder = self.attachmentBuilder(
originalMessage: originalMessage,
quoteProto: quoteProto,
tx: tx
)
if
body?.nilIfEmpty == nil,
attachmentBuilder == nil,
!isGiftBadge
{
owsFailDebug("quoted message has no content")
return nil
}
func quotedMessage(attachmentInfo: QuotedAttachmentInfo?) -> TSQuotedMessage {
return TSQuotedMessage(
timestamp: originalMessage.timestamp,
authorAddress: authorAddress,
body: body,
bodyRanges: bodyRanges,
bodySource: .local,
receivedQuotedAttachmentInfo: attachmentInfo?.info,
isGiftBadge: isGiftBadge,
isTargetMessageViewOnce: false
)
}
if let attachmentBuilder {
return attachmentBuilder.wrap(quotedMessage(attachmentInfo:))
} else {
return .withoutFinalizer(quotedMessage(attachmentInfo: nil))
}
}
private func attachmentBuilder(
originalMessage: TSMessage,
quoteProto: SSKProtoDataMessageQuote,
tx: DBWriteTransaction
) -> OwnedAttachmentBuilder<QuotedAttachmentInfo>? {
if quoteProto.attachments.isEmpty {
// If the quote we got has no attachments, ignore any attachments
// on the original message.
return nil
}
if
let originalMessageRowId = originalMessage.sqliteRowId,
let originalReference = attachmentStore.attachmentToUseInQuote(
originalMessageRowId: originalMessageRowId,
tx: tx
),
let originalAttachment = attachmentStore.fetch(
id: originalReference.attachmentRowId,
tx: tx
)
{
return attachmentManager.createQuotedReplyMessageThumbnailBuilder(
from: .fromOriginalAttachment(
originalAttachment,
originalReference: originalReference,
thumbnailPointerFromSender: quoteProto.attachments.first?.thumbnail
),
tx: tx
)
} else {
// This could happen if a sender spoofs their quoted message proto.
// Our quoted message will include no thumbnails.
owsFailDebug("Sender sent \(quoteProto.attachments.count) quoted attachments. Local copy has none.")
return nil
}
}
// MARK: - Creating draft
public func buildDraftQuotedReply(
originalMessage: TSMessage,
tx: DBReadTransaction
) -> DraftQuotedReplyModel? {
if originalMessage is OWSPaymentMessage {
owsFailDebug("Use dedicated DraftQuotedReplyModel initializer for payment messages")
}
let timestamp = originalMessage.timestamp
let authorAddress: SignalServiceAddress? = {
if originalMessage is TSOutgoingMessage {
return tsAccountManager.localIdentifiers(tx: tx)?.aciAddress
}
if let incomingMessage = originalMessage as? TSIncomingMessage {
return incomingMessage.authorAddress
}
owsFailDebug("Unexpected message type: \(originalMessage.self)")
return nil
}()
guard let authorAddress, authorAddress.isValid else {
owsFailDebug("No authorAddress or address is not valid.")
return nil
}
let originalMessageBody: () -> MessageBody? = {
guard let body = originalMessage.body else {
return nil
}
return MessageBody(text: body, ranges: originalMessage.bodyRanges ?? .empty)
}
func createDraftReply(content: DraftQuotedReplyModel.Content) -> DraftQuotedReplyModel {
return DraftQuotedReplyModel(
originalMessageTimestamp: timestamp,
originalMessageAuthorAddress: authorAddress,
isOriginalMessageAuthorLocalUser: originalMessage is TSOutgoingMessage,
threadUniqueId: originalMessage.uniqueThreadId,
content: content
)
}
func createTextDraftReplyOrNil() -> DraftQuotedReplyModel? {
if let originalMessageBody = originalMessageBody() {
return createDraftReply(content: .text(originalMessageBody))
} else {
return nil
}
}
if originalMessage.isViewOnceMessage {
return createDraftReply(content: .viewOnce)
}
if let contactShare = originalMessage.contactShare {
return createDraftReply(content: .contactShare(contactShare))
}
if originalMessage.giftBadge != nil {
return createDraftReply(content: .giftBadge)
}
if originalMessage.messageSticker != nil {
guard
let originalMessageRowId = originalMessage.sqliteRowId,
let attachment = attachmentStore.fetchFirstReferencedAttachment(
for: .messageSticker(messageRowId: originalMessageRowId),
tx: tx
),
let stickerData = try? attachment.attachment.asStream()?.decryptedRawData()
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 imageMetadata = stickerData.imageMetadata(withPath: nil, mimeType: nil)
switch imageMetadata.imageFormat {
case .png:
stickerType = .apng
case .gif:
stickerType = .gif
case .webp:
stickerType = .webp
case .unknown:
owsFailDebug("Unknown sticker data format")
return nil
default:
owsFailDebug("Invalid sticker data format: \(imageMetadata.imageFormat)")
return nil
}
let maxThumbnailSizePixels: CGFloat = 512
let thumbnailImage: UIImage? = { () -> UIImage? in
switch stickerType {
case .webp:
let image: UIImage? = stickerData.stillForWebpData()
return image
case .apng:
return UIImage(data: stickerData)
case .gif:
do {
let image = try OWSMediaUtils.thumbnail(
forImageData: stickerData,
maxDimensionPixels: maxThumbnailSizePixels
)
return image
} catch {
owsFailDebug("Error: \(error)")
return nil
}
}
}()
guard let resizedThumbnailImage = thumbnailImage?.resized(maxDimensionPixels: maxThumbnailSizePixels) else {
owsFailDebug("Couldn't generate thumbnail for sticker.")
return nil
}
return createDraftReply(content: .attachment(
nil,
attachmentRef: attachment.reference,
attachment: attachment.attachment,
thumbnailImage: resizedThumbnailImage
))
}
if
let originalMessageRowId = originalMessage.sqliteRowId,
let attachmentRef = attachmentStore.attachmentToUseInQuote(originalMessageRowId: originalMessageRowId, tx: tx)
{
let attachment = attachmentStore.fetch(id: attachmentRef.attachmentRowId, tx: tx)
if
let stream = attachment?.asStream(),
stream.contentType.isVisualMedia,
let thumbnailImage = stream.thumbnailImageSync(quality: .small)
{
guard
let resizedThumbnailImage = thumbnailImage.resized(
maxDimensionPoints: AttachmentThumbnailQuality.thumbnailDimensionPointsForQuotedReply
)
else {
owsFailDebug("Couldn't generate thumbnail.")
return nil
}
return createDraftReply(content: .attachment(
originalMessageBody(),
attachmentRef: attachmentRef,
attachment: stream.attachment,
thumbnailImage: resizedThumbnailImage
))
} else if attachment?.mimeType == MimeType.textXSignalPlain.rawValue {
// If the attachment is "oversize text", try the quote as a reply to text, not as
// a reply to an attachment.
if
let oversizeTextData = try? attachment?.asStream()?.decryptedRawData(),
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.
let truncatedText = oversizeText.trimToUtf8ByteCount(Int(kOversizeTextMessageSizeThreshold))
return createDraftReply(content: .text(
MessageBody(text: truncatedText, ranges: originalMessage.bodyRanges ?? .empty)
))
} else {
return createTextDraftReplyOrNil()
}
} else if let attachment, MimeTypeUtil.isSupportedVisualMediaMimeType(attachment.mimeType) {
return createDraftReply(content: .attachment(
originalMessageBody(),
attachmentRef: attachmentRef,
attachment: attachment,
thumbnailImage: attachment.blurHash.flatMap(BlurHash.image(for:))
))
} else if
let stub = QuotedMessageAttachmentReference.Stub(
mimeType: attachment?.mimeType,
sourceFilename: attachmentRef.sourceFilename)
{
return createDraftReply(content: .attachmentStub(
originalMessageBody(),
stub
))
} else {
return createTextDraftReplyOrNil()
}
}
if let storyReactionEmoji = originalMessage.storyReactionEmoji?.nilIfEmpty {
return createDraftReply(content: .storyReactionEmoji(storyReactionEmoji))
}
return createTextDraftReplyOrNil()
}
public func buildDraftQuotedReplyForEditing(
quotedReplyMessage: TSMessage,
quotedReply: TSQuotedMessage,
originalMessage: TSMessage?,
tx: DBReadTransaction
) -> DraftQuotedReplyModel {
if
let originalMessage,
let innerContent = self.buildDraftQuotedReply(
originalMessage: originalMessage,
tx: tx
)
{
return DraftQuotedReplyModel(
originalMessageTimestamp: innerContent.originalMessageTimestamp,
originalMessageAuthorAddress: innerContent.originalMessageAuthorAddress,
isOriginalMessageAuthorLocalUser: innerContent.isOriginalMessageAuthorLocalUser,
threadUniqueId: quotedReplyMessage.uniqueThreadId,
content: .edit(
quotedReplyMessage,
quotedReply,
content: innerContent.content
)
)
} else {
// Couldn't find the message or build contents.
// If we can't find the original, use the body we have.
let isOriginalMessageAuthorLocalUser = tsAccountManager.localIdentifiers(tx: tx)?.aciAddress
.isEqualToAddress(quotedReply.authorAddress) ?? false
let innerContent: DraftQuotedReplyModel.Content = {
let messageBody = quotedReply.body.map { MessageBody(text: $0, ranges: quotedReply.bodyRanges ?? .empty) }
if
let attachmentInfo = quotedReply.attachmentInfo(),
let attachmentReference = attachmentStore.quotedAttachmentReference(
from: attachmentInfo,
parentMessage: quotedReplyMessage,
tx: tx
)
{
switch attachmentReference {
case .thumbnail(let attachmentRef):
if let attachment = attachmentStore.fetch(id: attachmentRef.attachmentRowId, tx: tx) {
return .attachment(
messageBody,
attachmentRef: attachmentRef,
attachment: attachment,
thumbnailImage: attachment.asStream()?.thumbnailImageSync(quality: .small)
)
} else if let messageBody {
return .text(messageBody)
} else {
return lastResortQuotedReplyDraftContent()
}
case .stub(let stub):
return .attachmentStub(messageBody, stub)
}
} else if let messageBody {
return .text(messageBody)
} else {
return lastResortQuotedReplyDraftContent()
}
}()
return DraftQuotedReplyModel(
originalMessageTimestamp: quotedReply.timestampValue?.uint64Value,
originalMessageAuthorAddress: quotedReply.authorAddress,
isOriginalMessageAuthorLocalUser: isOriginalMessageAuthorLocalUser,
threadUniqueId: quotedReplyMessage.uniqueThreadId,
content: .edit(
quotedReplyMessage,
quotedReply,
content: innerContent
)
)
}
}
public func prepareDraftForSending(
_ draft: DraftQuotedReplyModel
) throws -> DraftQuotedReplyModel.ForSending {
switch draft.content {
case .edit(_, let tsQuotedMessage, _):
return .init(
originalMessageTimestamp: draft.originalMessageTimestamp,
originalMessageAuthorAddress: draft.originalMessageAuthorAddress,
originalMessageIsGiftBadge: draft.content.isGiftBadge,
originalMessageIsViewOnce: draft.content.isViewOnce,
threadUniqueId: draft.threadUniqueId,
quoteBody: draft.bodyForSending,
attachment: nil,
quotedMessageFromEdit: tsQuotedMessage
)
default:
break
}
// Find the original message and any attachment
let (originalAttachmentReference, originalAttachment): (
AttachmentReference?,
Attachment?
) = db.read { tx in
guard
let originalMessageTimestamp = draft.originalMessageTimestamp,
let originalMessage = InteractionFinder.findMessage(
withTimestamp: originalMessageTimestamp,
threadId: draft.threadUniqueId,
author: draft.originalMessageAuthorAddress,
transaction: SDSDB.shimOnlyBridge(tx)
)
else {
return (nil, nil)
}
let attachmentReference = attachmentStore.attachmentToUseInQuote(
originalMessageRowId: originalMessage.sqliteRowId!,
tx: tx
)
let attachment = attachmentStore.fetch(ids: [attachmentReference?.attachmentRowId].compacted(), tx: tx).first
return (attachmentReference, attachment)
}
let quoteAttachment = { () -> DraftQuotedReplyModel.ForSending.Attachment? in
guard let originalAttachmentReference, let originalAttachment else {
return nil
}
let isVisualMedia: Bool = {
if let contentType = originalAttachment.asStream()?.contentType {
return contentType.isVisualMedia
} else {
return MimeTypeUtil.isSupportedVisualMediaMimeType(originalAttachment.mimeType)
}
}()
guard isVisualMedia, let originalAttachmentStream = originalAttachment.asStream() else {
// Just return a stub for non-visual or undownloaded media.
return .stub(.init(mimeType: originalAttachment.mimeType, sourceFilename: originalAttachmentReference.sourceFilename))
}
do {
let dataSource = try attachmentValidator.prepareQuotedReplyThumbnail(
fromOriginalAttachment: originalAttachmentStream,
originalReference: originalAttachmentReference
)
return .thumbnail(dataSource: dataSource)
} catch {
// If we experience errors, just fall back to a stub.
return .stub(.init(mimeType: originalAttachment.mimeType, sourceFilename: originalAttachmentReference.sourceFilename))
}
}()
return .init(
originalMessageTimestamp: draft.originalMessageTimestamp,
originalMessageAuthorAddress: draft.originalMessageAuthorAddress,
originalMessageIsGiftBadge: draft.content.isGiftBadge,
originalMessageIsViewOnce: draft.content.isViewOnce,
threadUniqueId: draft.threadUniqueId,
quoteBody: draft.bodyForSending,
attachment: quoteAttachment,
quotedMessageFromEdit: nil
)
}
public func buildQuotedReplyForSending(
draft: DraftQuotedReplyModel.ForSending,
tx: DBWriteTransaction
) -> OwnedAttachmentBuilder<TSQuotedMessage> {
if let tsQuotedMessage = draft.quotedMessageFromEdit {
return .withoutFinalizer(tsQuotedMessage)
}
// Find the original message.
guard
let originalMessageTimestamp = draft.originalMessageTimestamp,
let originalMessage = InteractionFinder.findMessage(
withTimestamp: originalMessageTimestamp,
threadId: draft.threadUniqueId,
author: draft.originalMessageAuthorAddress,
transaction: SDSDB.shimOnlyBridge(tx)
)
else {
return .withoutFinalizer(TSQuotedMessage(
timestamp: draft.originalMessageTimestamp ?? 0,
authorAddress: draft.originalMessageAuthorAddress,
body: OWSLocalizedString(
"QUOTED_REPLY_CONTENT_FROM_REMOTE_SOURCE",
comment: "Footer label that appears below quoted messages when the quoted content was not derived locally. When the local user doesn't have a copy of the message being quoted, e.g. if it had since been deleted, we instead show the content specified by the sender."
),
bodyRanges: nil,
bodySource: .remote,
receivedQuotedAttachmentInfo: nil,
isGiftBadge: false,
isTargetMessageViewOnce: false
))
}
let body = draft.quoteBody
func buildQuotedMessage(_ attachmentInfo: QuotedAttachmentInfo?) -> TSQuotedMessage {
return TSQuotedMessage(
timestamp: draft.originalMessageTimestamp.map(NSNumber.init(value:)),
authorAddress: draft.originalMessageAuthorAddress,
body: body?.text,
bodyRanges: body?.ranges,
quotedAttachmentForSending: attachmentInfo?.info,
isGiftBadge: draft.originalMessageIsGiftBadge,
isTargetMessageViewOnce: draft.originalMessageIsViewOnce
)
}
guard let quotedAttachment = draft.attachment, originalMessage.isViewOnceMessage.negated else {
return .withoutFinalizer(buildQuotedMessage(nil))
}
switch quotedAttachment {
case .stub(let stub):
return .withoutFinalizer(buildQuotedMessage(QuotedAttachmentInfo(
info: .stub(
withOriginalAttachmentMimeType: stub.mimeType ?? MimeType.applicationOctetStream.rawValue,
originalAttachmentSourceFilename: stub.sourceFilename
),
renderingFlag: .default
)))
case .thumbnail(let dataSource):
let attachmentBuilder = attachmentManager.createQuotedReplyMessageThumbnailBuilder(
from: dataSource,
tx: tx
)
return attachmentBuilder.wrap(buildQuotedMessage(_:))
}
}
private func lastResortQuotedReplyDraftContent() -> DraftQuotedReplyModel.Content {
return .text(MessageBody(
text: OWSLocalizedString(
"QUOTED_REPLY_CONTENT_FROM_REMOTE_SOURCE",
comment: "Footer label that appears below quoted messages when the quoted content was not derived locally. When the local user doesn't have a copy of the message being quoted, e.g. if it had since been deleted, we instead show the content specified by the sender."
),
ranges: .empty
))
}
// MARK: - Outgoing proto
public func buildProtoForSending(
_ quote: TSQuotedMessage,
parentMessage: TSMessage,
tx: DBReadTransaction
) throws -> SSKProtoDataMessageQuote {
guard let timestamp = quote.timestampValue?.uint64Value else {
throw OWSAssertionError("Missing timestamp")
}
let quoteBuilder = SSKProtoDataMessageQuote.builder(id: timestamp)
guard let authorAci = quote.authorAddress.aci else {
throw OWSAssertionError("It should be impossible to quote a message without a UUID")
}
quoteBuilder.setAuthorAci(authorAci.serviceIdString)
var hasQuotedText = false
var hasQuotedAttachment = false
var hasQuotedGiftBadge = false
if let body = quote.body?.nilIfEmpty {
hasQuotedText = true
quoteBuilder.setText(body)
if let bodyRanges = quote.bodyRanges {
quoteBuilder.setBodyRanges(bodyRanges.toProtoBodyRanges(bodyLength: (body as NSString).length))
}
}
if let attachmentProto = buildAttachmentProtoForSending(for: parentMessage, tx: tx) {
hasQuotedAttachment = true
quoteBuilder.setAttachments([attachmentProto])
}
if quote.isGiftBadge {
hasQuotedGiftBadge = true
quoteBuilder.setType(.giftBadge)
}
if quote.isTargetMessageViewOnce {
if !hasQuotedText {
quoteBuilder.setText(OWSLocalizedString(
"PER_MESSAGE_EXPIRATION_NOT_VIEWABLE",
comment: "inbox cell and notification text for an already viewed view-once media message."
))
}
}
guard hasQuotedText || hasQuotedAttachment || hasQuotedGiftBadge else {
throw OWSAssertionError("Invalid quoted message data.")
}
return try quoteBuilder.build()
}
private func buildAttachmentProtoForSending(
for parentMessage: TSMessage,
tx: DBReadTransaction
) -> SSKProtoDataMessageQuoteQuotedAttachment? {
guard
let reference = attachmentStore.quotedAttachmentReference(for: parentMessage, tx: tx)
else {
return nil
}
let builder = SSKProtoDataMessageQuoteQuotedAttachment.builder()
let mimeType: String?
let sourceFilename: String?
switch reference {
case .thumbnail(let attachmentRef):
sourceFilename = attachmentRef.sourceFilename
if
let attachment = attachmentStore.fetch(
id: attachmentRef.attachmentRowId,
tx: tx
)
{
mimeType = attachment.mimeType
if
let pointer = attachment.asTransitTierPointer()
{
let attachmentProto = attachmentManager.buildProtoForSending(
from: attachmentRef,
pointer: pointer
)
builder.setThumbnail(attachmentProto)
}
} else {
mimeType = nil
}
case .stub(let stub):
mimeType = stub.mimeType
sourceFilename = stub.sourceFilename
}
mimeType.map(builder.setContentType(_:))
sourceFilename.map(builder.setFileName(_:))
return builder.buildInfallibly()
}
}