Signal-iOS/Signal/ConversationView/Components/CVComponentState.swift
2025-09-23 17:20:15 -04:00

1904 lines
73 KiB
Swift
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//
// Copyright 2020 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
import MobileCoin
public import SignalServiceKit
import SignalUI
public enum CVAttachment: Equatable {
case stream(ReferencedAttachmentStream)
case pointer(ReferencedAttachmentPointer, downloadState: AttachmentDownloadState)
case backupThumbnail(ReferencedAttachmentBackupThumbnail)
/// The attachment has no stream and cannot be downloaded because there is no cdn info.
/// Typically happens if we restore from a free-tier backup with old media expired from transit tier.
case undownloadable(ReferencedAttachment)
public var attachment: ReferencedAttachment {
switch self {
case .stream(let stream):
return stream
case .pointer(let pointer, _):
return pointer
case .backupThumbnail(let thumbnail):
return thumbnail
case .undownloadable(let attachment):
return attachment
}
}
public var attachmentStream: AttachmentStream? {
switch self {
case .stream(let stream):
return stream.attachmentStream
case .pointer, .backupThumbnail:
return nil
case .undownloadable:
return nil
}
}
public var attachmentPointer: AttachmentPointer? {
switch self {
case .stream, .backupThumbnail:
return nil
case .pointer(let pointer, _):
return pointer.attachmentPointer
case .undownloadable:
return nil
}
}
public var attachmentBackupThumbnail: AttachmentBackupThumbnail? {
switch self {
case .stream, .pointer:
return nil
case .backupThumbnail(let thumbnail):
return thumbnail.attachmentBackupThumbnail
case .undownloadable:
return nil
}
}
public static func from(_ attachment: ReferencedAttachment, tx: DBReadTransaction) -> CVAttachment {
if let stream = attachment.asReferencedStream {
return .stream(stream)
} else if let pointer = attachment.asReferencedAnyPointer {
return .pointer(pointer, downloadState: pointer.attachmentPointer.downloadState(tx: tx))
} else if let thumbnail = attachment.asReferencedBackupThumbnail {
return .backupThumbnail(thumbnail)
} else {
return .undownloadable(attachment)
}
}
public static func == (lhs: CVAttachment, rhs: CVAttachment) -> Bool {
switch (lhs, rhs) {
case (.stream(let lhsStream), .stream(let rhsStream)):
return lhsStream.attachment.id == rhsStream.attachment.id
&& lhsStream.reference.hasSameOwner(as: rhsStream.reference)
case (.pointer(let lhsPointer, let lhsState), .pointer(let rhsPointer, let rhsState)):
return lhsPointer.attachment.id == rhsPointer.attachment.id
&& lhsPointer.reference.hasSameOwner(as: rhsPointer.reference)
&& lhsState == rhsState
case (.backupThumbnail(let lhsThumbnail), .backupThumbnail(let rhsThumbnail)):
return lhsThumbnail.attachment.id == rhsThumbnail.attachment.id
&& lhsThumbnail.reference.hasSameOwner(as: rhsThumbnail.reference)
case (.undownloadable(let lhsAttachment), .undownloadable(let rhsAttachment)):
return lhsAttachment.attachment.id == rhsAttachment.attachment.id
&& lhsAttachment.reference.hasSameOwner(as: rhsAttachment.reference)
case
(.stream, .pointer),
(.stream, .backupThumbnail),
(.stream, .undownloadable),
(.pointer, .stream),
(.pointer, .backupThumbnail),
(.pointer, .undownloadable),
(.backupThumbnail, .pointer),
(.backupThumbnail, .stream),
(.backupThumbnail, .undownloadable),
(.undownloadable, .stream),
(.undownloadable, .pointer),
(.undownloadable, .backupThumbnail):
return false
}
}
}
public class CVComponentState: Equatable {
let messageCellType: CVMessageCellType
struct SenderName: Equatable {
let senderName: NSAttributedString
let senderNameColor: UIColor
}
let senderName: SenderName?
struct SenderAvatar: Equatable {
let avatarDataSource: ConversationAvatarDataSource
}
let senderAvatar: SenderAvatar?
enum BodyText: Equatable {
case bodyText(displayableText: DisplayableText, hasTapForMore: Bool)
// TODO: Should we have oversizeTextFailed?
case oversizeTextDownloading
case oversizeTextUndownloadable(truncatedBody: DisplayableText)
// We use the "body text" component to
// render the "remotely deleted" indicator.
case remotelyDeleted
var displayableText: DisplayableText? {
switch self {
case .bodyText(let text, _), .oversizeTextUndownloadable(let text):
return text
default:
return nil
}
}
func textValue(isTextExpanded: Bool) -> CVTextValue? {
switch self {
case .bodyText(let text, _), .oversizeTextUndownloadable(let text):
return text.textValue(isTextExpanded: isTextExpanded)
default:
return nil
}
}
var jumbomojiCount: UInt? {
switch self {
case .bodyText(let text, _), .oversizeTextUndownloadable(let text):
return text.jumbomojiCount
default:
return nil
}
}
var isJumbomojiMessage: Bool {
guard let jumbomojiCount = jumbomojiCount else {
return false
}
return jumbomojiCount > 0
}
}
let bodyText: BodyText?
struct BodyMedia: Equatable {
let items: [CVMediaAlbumItem]
let mediaAlbumHasFailedAttachment: Bool
let mediaAlbumHasPendingAttachment: Bool
}
let bodyMedia: BodyMedia?
struct GenericAttachment: Equatable {
let attachment: CVAttachment
var attachmentStream: AttachmentStream? {
attachment.attachmentStream
}
var attachmentPointer: AttachmentPointer? {
attachment.attachmentPointer
}
var attachmentBackupThumbnail: AttachmentBackupThumbnail? {
attachment.attachmentBackupThumbnail
}
}
let genericAttachment: GenericAttachment?
public struct PaymentAttachment: Equatable {
let notification: TSPaymentNotification
let model: TSPaymentModel?
let otherUserShortName: String
var status: TSPaymentState? { model?.paymentState }
}
var paymentAttachment: PaymentAttachment?
public struct ArchivedPaymentAttachment: Equatable {
let amount: String?
let fee: String?
let note: String?
let otherUserShortName: String
let archivedPayment: ArchivedPayment?
public static func == (lhs: Self, rhs: Self) -> Bool {
lhs.amount == rhs.amount
&& lhs.fee == rhs.fee
&& lhs.note == rhs.note
&& lhs.otherUserShortName == rhs.otherUserShortName
}
}
var archivedPaymentAttachment: ArchivedPaymentAttachment?
// It's not practical to reload the audio cell every time
// playback state or progress changes. Therefore we only
// capture the stable state related to the audio. Dynamic
// state is obtained from CVAudioPlayer.
//
// NOTE: We use the existing AudioAttachment entity,
// not a custom struct.
let audioAttachment: AudioAttachment?
struct ViewOnce: Equatable {
let viewOnceState: ViewOnceState
}
let viewOnce: ViewOnce?
let hasRenderableContent: Bool
struct QuotedReply: Equatable {
let viewState: QuotedMessageView.State
var quotedReplyModel: QuotedReplyModel { viewState.quotedReplyModel }
}
let quotedReply: QuotedReply?
enum Sticker: Equatable {
case available(
stickerMetadata: any StickerMetadata,
attachmentStream: ReferencedAttachmentStream
)
case downloading(attachmentPointer: ReferencedAttachmentPointer)
case failedOrPending(
attachmentPointer: ReferencedAttachmentPointer,
downloadState: AttachmentDownloadState
)
public var stickerMetadata: (any StickerMetadata)? {
switch self {
case .available(let stickerMetadata, _):
return stickerMetadata
case .downloading, .failedOrPending:
return nil
}
}
public var attachmentStream: ReferencedAttachmentStream? {
switch self {
case .available(_, let attachmentStream):
return attachmentStream
case .downloading:
return nil
case .failedOrPending:
return nil
}
}
public var attachmentPointer: ReferencedAttachmentPointer? {
switch self {
case .available:
return nil
case .downloading(let attachmentPointer):
return attachmentPointer
case .failedOrPending(let attachmentPointer, _):
return attachmentPointer
}
}
static func == (lhs: CVComponentState.Sticker, rhs: CVComponentState.Sticker) -> Bool {
switch (lhs, rhs) {
case let (.available(lhsData, lhsStream), .available(rhsData, rhsStream)):
return lhsData.stickerInfo.asKey() == rhsData.stickerInfo.asKey()
&& lhsStream.attachment.id == rhsStream.attachment.id
&& lhsStream.reference.hasSameOwner(as: rhsStream.reference)
case let (.downloading(lhsPointer), .downloading(rhsPointer)):
return lhsPointer.attachment.id == rhsPointer.attachment.id
&& lhsPointer.reference.hasSameOwner(as: rhsPointer.reference)
case let (.failedOrPending(lhsPointer, lhsState), .failedOrPending(rhsPointer, rhsState)):
return lhsPointer.attachment.id == rhsPointer.attachment.id
&& lhsPointer.reference.hasSameOwner(as: rhsPointer.reference)
&& lhsState == rhsState
case (.available, _), (.downloading, _), (.failedOrPending, _):
return false
}
}
}
let sticker: Sticker?
/// The attachment has no stream and cannot be downloaded because there is no cdn info.
/// Typically happens if we restore from a free-tier backup with old media expired from transit tier.
enum UndownloadableAttachment: Equatable {
case audio
case sticker
}
let undownloadableAttachment: UndownloadableAttachment?
struct ContactShare: Equatable {
let state: CVContactShareView.State
}
let contactShare: ContactShare?
struct LinkPreview: Equatable {
// TODO: convert OWSLinkPreview to Swift?
let linkPreview: OWSLinkPreview
let state: LinkPreviewState
// MARK: - Equatable
public static func == (lhs: LinkPreview, rhs: LinkPreview) -> Bool {
return lhs.state === rhs.state
}
}
let linkPreview: LinkPreview?
struct GiftBadge: Equatable {
let messageUniqueId: String
let otherUserShortName: String
let cachedBadge: CachedBadge
let expirationDate: Date
let redemptionState: OWSGiftBadgeRedemptionState
}
let giftBadge: GiftBadge?
struct Poll: Equatable {
let state: CVPollView.State
}
let poll: Poll?
struct SystemMessage: Equatable {
typealias ReferencedUser = CVTextLabel.ReferencedUserItem
let title: NSAttributedString
let titleColor: UIColor
let titleSelectionBackgroundColor: UIColor
let action: CVMessageAction?
/// Represents users whose names appear in the title. Only applies to
/// system messages in group threads.
let namesInTitle: [ReferencedUser]
init(
title: NSAttributedString,
titleColor: UIColor,
titleSelectionBackgroundColor: UIColor,
action: CVMessageAction?
) {
let mutableTitle = NSMutableAttributedString(attributedString: title)
mutableTitle.removeAttribute(
.addressOfName,
range: NSRange(location: 0, length: mutableTitle.length)
)
self.title = NSAttributedString(attributedString: mutableTitle)
self.titleColor = titleColor
self.titleSelectionBackgroundColor = titleSelectionBackgroundColor
self.action = action
self.namesInTitle = {
// Extract the addresses for names in the string. These are only
// stored for system messages in group threads.
var referencedUsers = [ReferencedUser]()
title.enumerateAddressesOfNames { address, range, _ in
if let address = address {
referencedUsers.append(ReferencedUser(
address: address,
range: range
))
}
}
return referencedUsers
}()
}
}
let systemMessage: SystemMessage?
struct DateHeader: Equatable {
}
let dateHeader: DateHeader?
struct UnreadIndicator: Equatable {
}
let unreadIndicator: UnreadIndicator?
struct Reactions: Equatable {
let reactionState: InteractionReactionState
let viewState: CVReactionCountsView.State
}
let reactions: Reactions?
struct TypingIndicator: Equatable {
let address: SignalServiceAddress
let avatarDataSource: ConversationAvatarDataSource?
}
let typingIndicator: TypingIndicator?
struct ThreadDetails: Equatable {
struct SafetySection: Equatable {
/// For " Review Carefully"
let shouldShowLowTrustWarning: Bool
/// For "Profile names are not verified"
let shouldShowProfileNamesEducation: Bool
/// For phone numbers or group member count
let detailsText: NSAttributedString?
/// For mutual groups, lack thereof and note-to-self description.
let mutualGroupsText: NSAttributedString?
let threadType: SafetyTipsType
let shouldShowSafetyTipsButton: Bool
}
let avatarDataSource: ConversationAvatarDataSource?
let isAvatarBlurred: Bool
let isAvatarBeingDownloaded: Bool
let titleText: String
let shouldShowVerifiedBadge: Bool
let bioText: String?
let safetySection: SafetySection?
let groupDescriptionText: String?
}
let threadDetails: ThreadDetails?
typealias UnknownThreadWarning = CVComponentState.SystemMessage
let unknownThreadWarning: UnknownThreadWarning?
typealias DefaultDisappearingMessageTimer = CVComponentState.SystemMessage
let defaultDisappearingMessageTimer: DefaultDisappearingMessageTimer?
struct BottomButtons: Equatable {
let actions: [CVMessageAction]
}
let bottomButtons: BottomButtons?
let bottomLabel: String?
struct FailedOrPendingDownloads: Equatable {
let attachmentPointers: [AttachmentPointer]
static func == (lhs: CVComponentState.FailedOrPendingDownloads, rhs: CVComponentState.FailedOrPendingDownloads) -> Bool {
return lhs.attachmentPointers.map(\.id) == rhs.attachmentPointers.map(\.id)
}
}
let failedOrPendingDownloads: FailedOrPendingDownloads?
struct SendFailureBadge: Equatable {
let color: UIColor
}
let sendFailureBadge: SendFailureBadge?
let messageHasBodyAttachments: Bool
fileprivate init(
messageCellType: CVMessageCellType,
senderName: SenderName?,
senderAvatar: SenderAvatar?,
bodyText: BodyText?,
bodyMedia: BodyMedia?,
genericAttachment: GenericAttachment?,
paymentAttachment: PaymentAttachment?,
archivedPaymentAttachment: ArchivedPaymentAttachment?,
audioAttachment: AudioAttachment?,
viewOnce: ViewOnce?,
quotedReply: QuotedReply?,
sticker: Sticker?,
undownloadableAttachment: UndownloadableAttachment?,
contactShare: ContactShare?,
linkPreview: LinkPreview?,
giftBadge: GiftBadge?,
systemMessage: SystemMessage?,
dateHeader: DateHeader?,
unreadIndicator: UnreadIndicator?,
reactions: Reactions?,
typingIndicator: TypingIndicator?,
threadDetails: ThreadDetails?,
unknownThreadWarning: UnknownThreadWarning?,
defaultDisappearingMessageTimer: DefaultDisappearingMessageTimer?,
bottomButtons: BottomButtons?,
bottomLabel: String?,
failedOrPendingDownloads: FailedOrPendingDownloads?,
sendFailureBadge: SendFailureBadge?,
messageHasBodyAttachments: Bool,
hasRenderableContent: Bool,
poll: Poll?
) {
self.messageCellType = messageCellType
self.senderName = senderName
self.senderAvatar = senderAvatar
self.bodyText = bodyText
self.bodyMedia = bodyMedia
self.genericAttachment = genericAttachment
self.paymentAttachment = paymentAttachment
self.archivedPaymentAttachment = archivedPaymentAttachment
self.audioAttachment = audioAttachment
self.viewOnce = viewOnce
self.quotedReply = quotedReply
self.sticker = sticker
self.undownloadableAttachment = undownloadableAttachment
self.contactShare = contactShare
self.linkPreview = linkPreview
self.giftBadge = giftBadge
self.systemMessage = systemMessage
self.dateHeader = dateHeader
self.unreadIndicator = unreadIndicator
self.reactions = reactions
self.typingIndicator = typingIndicator
self.threadDetails = threadDetails
self.unknownThreadWarning = unknownThreadWarning
self.defaultDisappearingMessageTimer = defaultDisappearingMessageTimer
self.bottomButtons = bottomButtons
self.bottomLabel = bottomLabel
self.failedOrPendingDownloads = failedOrPendingDownloads
self.sendFailureBadge = sendFailureBadge
self.messageHasBodyAttachments = messageHasBodyAttachments
self.hasRenderableContent = hasRenderableContent
self.poll = poll
}
// MARK: - Equatable
public static func == (lhs: CVComponentState, rhs: CVComponentState) -> Bool {
return (lhs.messageCellType == rhs.messageCellType &&
lhs.senderName == rhs.senderName &&
lhs.senderAvatar == rhs.senderAvatar &&
lhs.bodyText == rhs.bodyText &&
lhs.bodyMedia == rhs.bodyMedia &&
lhs.genericAttachment == rhs.genericAttachment &&
lhs.paymentAttachment == rhs.paymentAttachment &&
lhs.archivedPaymentAttachment == rhs.archivedPaymentAttachment &&
lhs.audioAttachment == rhs.audioAttachment &&
lhs.viewOnce == rhs.viewOnce &&
lhs.quotedReply == rhs.quotedReply &&
lhs.sticker == rhs.sticker &&
lhs.undownloadableAttachment == rhs.undownloadableAttachment &&
lhs.contactShare == rhs.contactShare &&
lhs.linkPreview == rhs.linkPreview &&
lhs.giftBadge == rhs.giftBadge &&
lhs.systemMessage == rhs.systemMessage &&
lhs.dateHeader == rhs.dateHeader &&
lhs.unreadIndicator == rhs.unreadIndicator &&
lhs.reactions == rhs.reactions &&
lhs.typingIndicator == rhs.typingIndicator &&
lhs.threadDetails == rhs.threadDetails &&
lhs.unknownThreadWarning == rhs.unknownThreadWarning &&
lhs.defaultDisappearingMessageTimer == rhs.defaultDisappearingMessageTimer &&
lhs.bottomButtons == rhs.bottomButtons &&
lhs.bottomLabel == rhs.bottomLabel &&
lhs.failedOrPendingDownloads == rhs.failedOrPendingDownloads &&
lhs.sendFailureBadge == rhs.sendFailureBadge &&
lhs.poll == rhs.poll)
}
// MARK: - Building
fileprivate struct Builder: CVItemBuilding {
typealias SenderName = CVComponentState.SenderName
typealias SenderAvatar = CVComponentState.SenderAvatar
typealias BodyText = CVComponentState.BodyText
typealias BodyMedia = CVComponentState.BodyMedia
typealias GenericAttachment = CVComponentState.GenericAttachment
typealias PaymentAttachment = CVComponentState.PaymentAttachment
typealias ArchivedPaymentAttachment = CVComponentState.ArchivedPaymentAttachment
typealias ViewOnce = CVComponentState.ViewOnce
typealias QuotedReply = CVComponentState.QuotedReply
typealias Sticker = CVComponentState.Sticker
typealias UndownloadableAttachment = CVComponentState.UndownloadableAttachment
typealias SystemMessage = CVComponentState.SystemMessage
typealias ContactShare = CVComponentState.ContactShare
typealias Reactions = CVComponentState.Reactions
typealias LinkPreview = CVComponentState.LinkPreview
typealias GiftBadge = CVComponentState.GiftBadge
typealias DateHeader = CVComponentState.DateHeader
typealias UnreadIndicator = CVComponentState.UnreadIndicator
typealias TypingIndicator = CVComponentState.TypingIndicator
typealias ThreadDetails = CVComponentState.ThreadDetails
typealias UnknownThreadWarning = CVComponentState.UnknownThreadWarning
typealias DefaultDisappearingMessageTimer = CVComponentState.DefaultDisappearingMessageTimer
typealias FailedOrPendingDownloads = CVComponentState.FailedOrPendingDownloads
typealias BottomButtons = CVComponentState.BottomButtons
typealias SendFailureBadge = CVComponentState.SendFailureBadge
typealias Poll = CVComponentState.Poll
let interaction: TSInteraction
let itemBuildingContext: CVItemBuildingContext
var revealedSpoilerIdsSnapshot: Set<StyleIdType> {
return itemBuildingContext.viewStateSnapshot.spoilerReveal[.fromInteraction(interaction)] ?? Set()
}
var senderName: SenderName?
var senderAvatar: SenderAvatar?
var bodyText: BodyText?
var bodyMedia: BodyMedia?
var genericAttachment: GenericAttachment?
var paymentAttachment: PaymentAttachment?
var archivedPaymentAttachment: ArchivedPaymentAttachment?
var audioAttachment: AudioAttachment?
var viewOnce: ViewOnce?
var quotedReply: QuotedReply?
var sticker: Sticker?
var undownloadableAttachment: UndownloadableAttachment?
var systemMessage: SystemMessage?
var contactShare: ContactShare?
var linkPreview: LinkPreview?
var giftBadge: GiftBadge?
var dateHeader: DateHeader?
var unreadIndicator: UnreadIndicator?
var typingIndicator: TypingIndicator?
var threadDetails: ThreadDetails?
var unknownThreadWarning: UnknownThreadWarning?
var defaultDisappearingMessageTimer: DefaultDisappearingMessageTimer?
var reactions: Reactions?
var failedOrPendingDownloads: FailedOrPendingDownloads?
var sendFailureBadge: SendFailureBadge?
var messageHasBodyAttachments: Bool
var hasRenderableContent: Bool
var poll: Poll?
var bottomButtonsActions = [CVMessageAction]()
var bottomLabel: String?
init(interaction: TSInteraction, itemBuildingContext: CVItemBuildingContext) {
self.interaction = interaction
self.messageHasBodyAttachments = (interaction as? TSMessage)?.hasBodyAttachments(transaction: itemBuildingContext.transaction) ?? false
self.hasRenderableContent = (interaction as? TSMessage)?.hasRenderableContent(tx: itemBuildingContext.transaction) ?? false
self.itemBuildingContext = itemBuildingContext
}
mutating func build() -> CVComponentState {
var bottomButtons: BottomButtons?
if !bottomButtonsActions.isEmpty {
bottomButtons = BottomButtons(actions: bottomButtonsActions)
}
return CVComponentState(
messageCellType: messageCellType,
senderName: senderName,
senderAvatar: senderAvatar,
bodyText: bodyText,
bodyMedia: bodyMedia,
genericAttachment: genericAttachment,
paymentAttachment: paymentAttachment,
archivedPaymentAttachment: archivedPaymentAttachment,
audioAttachment: audioAttachment,
viewOnce: viewOnce,
quotedReply: quotedReply,
sticker: sticker,
undownloadableAttachment: undownloadableAttachment,
contactShare: contactShare,
linkPreview: linkPreview,
giftBadge: giftBadge,
systemMessage: systemMessage,
dateHeader: dateHeader,
unreadIndicator: unreadIndicator,
reactions: reactions,
typingIndicator: typingIndicator,
threadDetails: threadDetails,
unknownThreadWarning: unknownThreadWarning,
defaultDisappearingMessageTimer: defaultDisappearingMessageTimer,
bottomButtons: bottomButtons,
bottomLabel: bottomLabel,
failedOrPendingDownloads: failedOrPendingDownloads,
sendFailureBadge: sendFailureBadge,
messageHasBodyAttachments: messageHasBodyAttachments,
hasRenderableContent: hasRenderableContent,
poll: poll
)
}
// MARK: -
lazy var isIncoming: Bool = {
interaction is TSIncomingMessage
}()
lazy var isOutgoing: Bool = {
interaction is TSOutgoingMessage
}()
lazy var messageCellType: CVMessageCellType = {
if dateHeader != nil {
return .dateHeader
}
if unreadIndicator != nil {
return .unreadIndicator
}
if typingIndicator != nil {
return .typingIndicator
}
if threadDetails != nil {
return .threadDetails
}
if unknownThreadWarning != nil {
return .unknownThreadWarning
}
if defaultDisappearingMessageTimer != nil {
return .defaultDisappearingMessageTimer
}
if systemMessage != nil {
return .systemMessage
}
if self.sticker != nil {
return .stickerMessage
}
if self.undownloadableAttachment != nil {
return .undownloadableAttachment
}
if viewOnce != nil {
return .viewOnce
}
if contactShare != nil {
return .contactShare
}
if audioAttachment != nil {
return .audio
}
if genericAttachment != nil {
return .genericAttachment
}
if paymentAttachment != nil {
return .paymentAttachment
}
if archivedPaymentAttachment != nil {
return .archivedPaymentAttachment
}
if giftBadge != nil {
return .giftBadge
}
if bodyMedia != nil {
return .bodyMedia
}
if bodyText != nil {
return .textOnlyMessage
}
if quotedReply != nil {
return .quoteOnlyMessage
}
if poll != nil {
return .poll
}
owsFailDebug("Unknown state.")
return .unknown
}()
}
// MARK: - Convenience
lazy var shouldRenderAsSticker: Bool = {
sticker != nil
}()
lazy var activeComponentStateKeys: Set<CVComponentKey> = {
var result = Set<CVComponentKey>()
if senderName != nil {
result.insert(.senderName)
}
if senderAvatar != nil {
result.insert(.senderAvatar)
}
if bodyText != nil {
result.insert(.bodyText)
}
if bodyMedia != nil {
result.insert(.bodyMedia)
}
if genericAttachment != nil {
result.insert(.genericAttachment)
}
if paymentAttachment != nil {
result.insert(.paymentAttachment)
}
if archivedPaymentAttachment != nil {
result.insert(.archivedPaymentAttachment)
}
if audioAttachment != nil {
result.insert(.audioAttachment)
}
if viewOnce != nil {
result.insert(.viewOnce)
}
if quotedReply != nil {
result.insert(.quotedReply)
}
if sticker != nil {
result.insert(.sticker)
}
if contactShare != nil {
result.insert(.contactShare)
}
if linkPreview != nil {
result.insert(.linkPreview)
}
if giftBadge != nil {
result.insert(.giftBadge)
}
if systemMessage != nil {
result.insert(.systemMessage)
}
if dateHeader != nil {
result.insert(.dateHeader)
}
if unreadIndicator != nil {
result.insert(.unreadIndicator)
}
if reactions != nil {
result.insert(.reactions)
}
if typingIndicator != nil {
result.insert(.typingIndicator)
}
if threadDetails != nil {
result.insert(.threadDetails)
}
if unknownThreadWarning != nil {
result.insert(.unknownThreadWarning)
}
if defaultDisappearingMessageTimer != nil {
result.insert(.defaultDisappearingMessageTimer)
}
if bottomButtons != nil {
result.insert(.bottomButtons)
}
if bottomLabel != nil {
result.insert(.bottomLabel)
}
if failedOrPendingDownloads != nil {
result.insert(.failedOrPendingDownloads)
}
if sendFailureBadge != nil {
result.insert(.sendFailureBadge)
}
if poll != nil {
result.insert(.poll)
}
return result
}()
lazy var isTextOnlyMessage: Bool = {
let validKeys: [CVComponentKey] = [
.senderName,
.senderAvatar,
.bodyText,
.footer,
.reactions,
.sendFailureBadge
]
return activeComponentStateKeys.isSubset(of: Set(validKeys))
}()
lazy var isBorderlessJumbomojiMessage: Bool = {
isTextOnlyMessage && (bodyText?.isJumbomojiMessage == true)
}()
lazy var isBodyMediaOnlyMessage: Bool = {
let validKeys: [CVComponentKey] = [
.senderName,
.senderAvatar,
.bodyMedia,
.footer,
.reactions,
.sendFailureBadge
]
return activeComponentStateKeys.isSubset(of: Set(validKeys))
}()
lazy var isBorderlessBodyMediaMessage: Bool = {
if isBodyMediaOnlyMessage,
let bodyMedia = bodyMedia,
bodyMedia.items.count == 1,
let firstItem = bodyMedia.items.first,
firstItem.renderingFlag == .borderless
{
return true
}
return false
}()
}
// MARK: -
extension CVComponentState {
static func buildDateHeader(interaction: TSInteraction,
itemBuildingContext: CVItemBuildingContext) -> CVComponentState {
var builder = CVComponentState.Builder(interaction: interaction,
itemBuildingContext: itemBuildingContext)
builder.dateHeader = DateHeader()
return builder.build()
}
static func buildUnreadIndicator(interaction: TSInteraction,
itemBuildingContext: CVItemBuildingContext) -> CVComponentState {
var builder = CVComponentState.Builder(interaction: interaction,
itemBuildingContext: itemBuildingContext)
builder.unreadIndicator = UnreadIndicator()
return builder.build()
}
static func build(interaction: TSInteraction,
itemBuildingContext: CVItemBuildingContext) throws -> CVComponentState {
var builder = CVComponentState.Builder(interaction: interaction,
itemBuildingContext: itemBuildingContext)
return try builder.populateAndBuild()
}
}
// MARK: -
fileprivate extension CVComponentState.Builder {
mutating func populateAndBuild() throws -> CVComponentState {
if let reactionState = InteractionReactionState(interaction: interaction,
transaction: transaction),
reactionState.hasReactions {
self.reactions = Reactions(reactionState: reactionState,
viewState: CVReactionCountsView.buildState(with: reactionState))
}
self.senderAvatar = tryToBuildSenderAvatar()
self.failedOrPendingDownloads = tryToBuildFailedOrPendingDownloads()
switch interaction.interactionType {
case .threadDetails:
self.threadDetails = buildThreadDetails()
return build()
case .unknownThreadWarning:
// TODO: Remove this case entirely?
return build()
case .defaultDisappearingMessageTimer:
self.defaultDisappearingMessageTimer = CVComponentSystemMessage.buildDefaultDisappearingMessageTimerState(
interaction: interaction,
threadViewModel: threadViewModel,
transaction: transaction
)
return build()
case .typingIndicator:
guard let typingIndicatorInteraction = interaction as? TypingIndicatorInteraction else {
owsFailDebug("Invalid typingIndicator.")
return build()
}
let avatarDataSource = { () -> ConversationAvatarDataSource? in
guard thread.isGroupThread else {
return nil
}
return self.avatarBuilder.buildAvatarDataSource(
forAddress: typingIndicatorInteraction.address,
includingBadge: true,
localUserDisplayMode: .asUser,
diameterPoints: UInt(ConversationStyle.groupMessageAvatarSizeClass.diameter))
}()
self.typingIndicator = TypingIndicator(address: typingIndicatorInteraction.address,
avatarDataSource: avatarDataSource)
return build()
case .info, .error, .call:
let currentGroupThreadCallGroupId = viewStateSnapshot.currentGroupThreadCallGroupId
self.systemMessage = CVComponentSystemMessage.buildComponentState(interaction: interaction,
threadViewModel: threadViewModel,
currentGroupThreadCallGroupId: currentGroupThreadCallGroupId,
transaction: transaction)
return build()
case .unreadIndicator:
unreadIndicator = CVComponentState.UnreadIndicator()
return build()
case .dateHeader:
dateHeader = CVComponentState.DateHeader()
return build()
case .incomingMessage, .outgoingMessage:
guard let message = interaction as? TSMessage else {
owsFailDebug("Invalid message.")
return build()
}
return try populateAndBuild(message: message, revealedSpoilerIdsSnapshot: revealedSpoilerIdsSnapshot)
case .unknown:
owsFailDebug("Unknown interaction type.")
return build()
default:
owsFailDebug("Invalid interaction type: \(NSStringFromOWSInteractionType(interaction.interactionType))")
return build()
}
}
private func tryToBuildSenderAvatar() -> SenderAvatar? {
guard thread.isGroupThread,
let incomingMessage = interaction as? TSIncomingMessage else {
return nil
}
guard let avatarDataSource = self.avatarBuilder.buildAvatarDataSource(
forAddress: incomingMessage.authorAddress,
includingBadge: true,
localUserDisplayMode: .asUser,
diameterPoints: UInt(ConversationStyle.groupMessageAvatarSizeClass.diameter)
) else {
owsFailDebug("Could build avatar image")
return nil
}
return SenderAvatar(avatarDataSource: avatarDataSource)
}
private func tryToBuildFailedOrPendingDownloads() -> FailedOrPendingDownloads? {
guard let message = interaction as? TSMessage else {
return nil
}
let attachmentPointers = message.failedOrPendingAttachments(transaction: transaction)
guard !attachmentPointers.isEmpty else {
return nil
}
return FailedOrPendingDownloads(attachmentPointers: attachmentPointers)
}
mutating func populateAndBuild(
message: TSMessage,
revealedSpoilerIdsSnapshot: Set<StyleIdType>
) throws -> CVComponentState {
if message.wasRemotelyDeleted {
// If the message has been remotely deleted, suppress everything else.
self.bodyText = .remotelyDeleted
return build()
}
if message.isViewOnceMessage {
return try buildViewOnceMessage(message: message)
}
if let contact = message.contactShare {
return try buildContact(message: message, contact: contact)
}
if let messageSticker = message.messageSticker {
return try buildSticker(message: message, messageSticker: messageSticker)
}
// Check for quoted replies _before_ media album handling,
// since that logic may exit early.
buildQuotedReply(message: message, revealedSpoilerIdsSnapshot: revealedSpoilerIdsSnapshot)
try buildBodyText(message: message)
if let outgoingMessage = message as? TSOutgoingMessage {
let messageStatus: MessageReceiptStatus
if
let paymentMessage = message as? OWSPaymentMessage,
let receipt = paymentMessage.paymentNotification?.mcReceiptData,
let model = PaymentFinder.paymentModels(
forMcReceiptData: receipt,
transaction: transaction
).first
{
messageStatus = MessageRecipientStatusUtils.recipientStatus(
outgoingMessage: outgoingMessage,
paymentModel: model
)
} else {
messageStatus = MessageRecipientStatusUtils.recipientStatus(
outgoingMessage: outgoingMessage,
transaction: transaction
)
}
switch messageStatus {
case .failed:
sendFailureBadge = SendFailureBadge(color: .ows_accentRed)
case .pending:
sendFailureBadge = SendFailureBadge(color: .ows_gray60)
default:
break
}
}
// Could be incoming or outgoing; protocol covers both
if let paymentMessage = message as? OWSPaymentMessage,
let paymentNotification = paymentMessage.paymentNotification {
return buildPaymentAttachment(paymentNotification: paymentNotification)
}
if let archivedPaymentMessage = message as? OWSArchivedPaymentMessage {
do {
let archivedPayment = try DependenciesBridge.shared.archivedPaymentStore.fetch(
for: archivedPaymentMessage,
interactionUniqueId: message.uniqueId,
tx: transaction
)
return buildArchivedPaymentAttachment(
archivedPaymentMessage: archivedPaymentMessage,
archivedPayment: archivedPayment
)
} catch {
owsFail("\(error.grdbErrorForLogging)")
}
}
if let giftBadge = message.giftBadge {
return try buildGiftBadge(messageUniqueId: message.uniqueId, giftBadge: giftBadge)
}
if message.isPoll {
return try buildPoll(message: message, transaction: transaction)
}
do {
let bodyAttachments = message.sqliteRowId.map {
DependenciesBridge.shared.attachmentStore.fetchReferencedAttachments(
for: .messageBodyAttachment(messageRowId: $0),
tx: transaction
)
} ?? []
let mediaAlbumItems = buildMediaAlbumItems(for: bodyAttachments, message: message)
if mediaAlbumItems.count > 0 {
var mediaAlbumHasFailedAttachment = false
var mediaAlbumHasPendingAttachment = false
// TODO
for attachment in bodyAttachments {
guard
attachment.attachment.asStream() == nil,
let pointer = attachment.attachment.asAnyPointer()
else {
continue
}
switch pointer.downloadState(tx: transaction) {
case .enqueuedOrDownloading:
continue
case .failed:
mediaAlbumHasFailedAttachment = true
case .none:
mediaAlbumHasPendingAttachment = true
}
}
self.bodyMedia = BodyMedia(items: mediaAlbumItems,
mediaAlbumHasFailedAttachment: mediaAlbumHasFailedAttachment,
mediaAlbumHasPendingAttachment: mediaAlbumHasPendingAttachment)
return build()
}
// Only media galleries should have more than one attachment.
owsAssertDebug(bodyAttachments.count <= 1)
if let bodyAttachment = bodyAttachments.first {
try buildNonMediaAttachment(bodyAttachment: bodyAttachment)
}
}
if !threadViewModel.hasPendingMessageRequest,
let linkPreview = message.linkPreview {
try buildLinkPreview(message: message, linkPreview: linkPreview)
}
let result = build()
if result.messageCellType == .unknown {
owsFailDebug("Unknown message cell type.")
}
return result
}
}
// MARK: -
fileprivate extension CVComponentState.Builder {
mutating func buildThreadDetails() -> ThreadDetails {
owsAssertDebug(interaction is ThreadDetailsInteraction)
return CVComponentThreadDetails.buildComponentState(thread: thread,
transaction: transaction,
avatarBuilder: avatarBuilder)
}
// TODO: Should we throw more?
mutating func buildViewOnceMessage(message: TSMessage) throws -> CVComponentState {
owsAssertDebug(message.isViewOnceMessage)
func buildViewOnce(viewOnceState: ViewOnceState) -> CVComponentState {
self.viewOnce = ViewOnce(viewOnceState: viewOnceState)
return self.build()
}
if let outgoingMessage = message as? TSOutgoingMessage {
let viewOnceState: ViewOnceState
if message.isViewOnceComplete {
viewOnceState = .outgoingSentExpired
} else {
switch outgoingMessage.messageState {
case .sending, .pending:
viewOnceState = .outgoingSending
case .failed:
viewOnceState = .outgoingFailed
default:
viewOnceState = .outgoingSentExpired
}
}
return buildViewOnce(viewOnceState: viewOnceState)
} else if message is TSIncomingMessage {
if message.isViewOnceComplete {
return buildViewOnce(viewOnceState: .incomingExpired)
}
let attachmentRefs = message.sqliteRowId.map {
DependenciesBridge.shared.attachmentStore.fetchReferences(
owners: [
.messageOversizeText(messageRowId: $0),
.messageBodyAttachment(messageRowId: $0)
],
tx: transaction
)
} ?? []
let hasMoreThanOneAttachment: Bool = attachmentRefs.count > 1
let hasBodyText: Bool = !(message.body?.isEmpty ?? true)
if hasMoreThanOneAttachment || hasBodyText {
// Refuse to render incoming "view once" messages if they
// have more than one attachment or any body text.
owsFailDebug("Invalid content.")
return buildViewOnce(viewOnceState: .incomingInvalidContent)
}
let mediaAttachments: [ReferencedAttachment] = message.sqliteRowId.map {
DependenciesBridge.shared.attachmentStore
.fetchReferencedAttachments(
for: .messageBodyAttachment(messageRowId: $0),
tx: transaction
)
} ?? []
// We currently only support single attachments for view-once messages.
guard let mediaAttachment = mediaAttachments.first else {
owsFailDebug("Missing attachment.")
return buildViewOnce(viewOnceState: .incomingInvalidContent)
}
let renderingFlag = mediaAttachment.reference.renderingFlag
if let attachmentStream = mediaAttachment.attachment.asStream() {
if attachmentStream.contentType.isVisualMedia
&& (
MimeTypeUtil.isSupportedImageMimeType(attachmentStream.mimeType)
|| MimeTypeUtil.isSupportedMaybeAnimatedMimeType(attachmentStream.mimeType)
|| MimeTypeUtil.isSupportedVideoMimeType(attachmentStream.mimeType)
)
{
return buildViewOnce(viewOnceState: .incomingAvailable(
attachmentStream: attachmentStream,
renderingFlag: renderingFlag
))
}
} else if let attachmentPointer = mediaAttachment.attachment.asAnyPointer() {
switch attachmentPointer.downloadState(tx: transaction) {
case .enqueuedOrDownloading:
return buildViewOnce(viewOnceState: .incomingDownloading(
attachmentPointer: attachmentPointer,
renderingFlag: renderingFlag
))
case .failed:
return buildViewOnce(viewOnceState: .incomingFailed)
case .none:
return buildViewOnce(viewOnceState: .incomingPending)
}
} else {
return buildViewOnce(viewOnceState: .incomingUndownloadable)
}
owsFailDebug("Invalid content.")
return buildViewOnce(viewOnceState: .incomingInvalidContent)
} else {
throw OWSAssertionError("Invalid message.")
}
}
// TODO: Should we throw more?
mutating func buildContact(message: TSMessage, contact: OWSContact) throws -> CVComponentState {
let contactShare = ContactShareViewModel(
contactShareRecord: contact,
parentMessage: message,
transaction: transaction
)
let state = CVContactShareView.buildState(
contactShare: contactShare,
isIncoming: isIncoming,
conversationStyle: conversationStyle,
transaction: transaction
)
self.contactShare = ContactShare(state: state)
let phoneNumberPartition = contactShare.dbRecord.phoneNumberPartition(tx: transaction)
let phoneNumberAction: CVMessageAction? = phoneNumberPartition.map(
ifSendablePhoneNumbers: {
// If system contacts are known/linkable, show a "Send" button.
return CVMessageAction(
title: CommonStrings.sendMessage,
accessibilityIdentifier: "send_message_to_contact_share",
action: .didTapSendMessage(phoneNumbers: $0)
)
},
elseIfInvitablePhoneNumbers: { _ in
// If system contacts aren't registered, show an "Invite" button.
return CVMessageAction(
title: OWSLocalizedString("ACTION_INVITE", comment: "Label for 'invite' button in contact view."),
accessibilityIdentifier: "invite_contact_share",
action: .didTapSendInvite(contactShare: contactShare)
)
},
elseIfAddablePhoneNumbers: { _ in
// Otherwise, offer to add the number(s) to your address book.
return CVMessageAction(
title: OWSLocalizedString("CONVERSATION_VIEW_ADD_TO_CONTACTS_OFFER", comment: "Message shown in conversation view that offers to add an unknown user to your phone's contacts."),
accessibilityIdentifier: "add_to_contacts",
action: .didTapAddToContacts(contactShare: contactShare)
)
},
elseIfNoPhoneNumbers: {
return nil
}
)
if let phoneNumberAction {
bottomButtonsActions.append(phoneNumberAction)
}
return build()
}
// TODO: Should we throw more?
mutating func buildSticker(message: TSMessage, messageSticker: MessageSticker) throws -> CVComponentState {
guard
let rowId = message.sqliteRowId,
let attachment = DependenciesBridge.shared.attachmentStore.fetchFirstReferencedAttachment(
for: .messageSticker(messageRowId: rowId),
tx: transaction
)
else {
throw OWSAssertionError("Missing sticker attachment.")
}
if let attachmentStream = attachment.asReferencedStream {
let stickerMetadata = attachmentStream.attachmentStream.asStickerMetadata(
stickerInfo: messageSticker.info,
stickerType: StickerManager.stickerType(forContentType: attachmentStream.attachment.mimeType),
emojiString: messageSticker.emoji
)
self.sticker = .available(
stickerMetadata: stickerMetadata,
attachmentStream: attachmentStream
)
return build()
} else if let attachmentPointer = attachment.asReferencedAnyPointer {
let downloadState = attachmentPointer.attachmentPointer.downloadState(tx: transaction)
switch downloadState {
case .enqueuedOrDownloading:
self.sticker = .downloading(attachmentPointer: attachmentPointer)
case .failed, .none:
self.sticker = .failedOrPending(
attachmentPointer: attachmentPointer,
downloadState: downloadState
)
}
return build()
} else {
self.undownloadableAttachment = .sticker
return build()
}
}
// TODO: Should we validate and throw errors?
mutating func buildQuotedReply(
message: TSMessage,
revealedSpoilerIdsSnapshot: Set<StyleIdType>
) {
let quotedReplyModel: QuotedReplyModel? = {
if
message.isStoryReply,
let storyAuthorAci = message.storyAuthorAci?.wrappedAciValue
{
return QuotedReplyModel.build(
storyReplyMessage: message,
storyTimestamp: message.storyTimestamp?.uint64Value,
storyAuthorAci: storyAuthorAci,
transaction: transaction
)
} else if let quotedMessage = message.quotedMessage {
return QuotedReplyModel.build(replyMessage: message, quotedMessage: quotedMessage, transaction: transaction)
} else {
return nil
}
}()
guard let quotedReplyModel else {
return
}
var displayableQuotedText: DisplayableText?
if let quotedBody = quotedReplyModel.originalMessageBody, !quotedBody.text.isEmpty {
displayableQuotedText = CVComponentState.displayableQuotedText(
text: quotedBody.text,
ranges: quotedBody.ranges,
interaction: message,
revealedSpoilerIdsSnapshot: revealedSpoilerIdsSnapshot,
transaction: transaction
)
}
let viewState = QuotedMessageView.stateForConversation(
quotedReplyModel: quotedReplyModel,
displayableQuotedText: displayableQuotedText,
conversationStyle: conversationStyle,
isOutgoing: isOutgoing,
transaction: transaction
)
self.quotedReply = QuotedReply(viewState: viewState)
}
mutating func buildBodyText(message: TSMessage) throws {
if message.isPoll {
return
}
bodyText = try CVComponentBodyText.buildComponentState(
message: message,
viewStateSnapshot: viewStateSnapshot,
transaction: transaction
)
}
// MARK: -
func buildMediaAlbumItems(for mediaAttachments: [ReferencedAttachment],
message: TSMessage) -> [CVMediaAlbumItem] {
let threadHasPendingMessageRequest = message.thread(tx: transaction)?
.hasPendingMessageRequest(transaction: transaction)
?? false
var mediaAlbumItems = [CVMediaAlbumItem]()
for attachment in mediaAttachments {
guard
// Use the validated content type and only fall back to the mime type.
attachment.attachment.asStream()?.contentType.isVisualMedia
?? MimeTypeUtil.isSupportedVisualMediaMimeType(attachment.attachment.mimeType)
else {
// Well behaving clients should not send a mix of visual media (like JPG) and non-visual media (like PDF's)
// Since we're not coped to handle a mix of media, return @[]
owsAssertDebug(mediaAlbumItems.count == 0)
return []
}
let cvAttachment = CVAttachment.from(attachment, tx: transaction)
let caption = attachment.reference.legacyMessageCaption
let hasCaption = caption.map {
return CVComponentState.displayableCaption(
text: $0,
transaction: transaction
).fullTextValue.isEmpty.negated
} ?? false
switch cvAttachment {
case .pointer:
var mediaSize: CGSize = .zero
if let sourceMediaSizePixels = attachment.reference.sourceMediaSizePixels {
mediaSize = sourceMediaSizePixels
} else {
owsFailDebug("Invalid attachment.")
}
mediaAlbumItems.append(CVMediaAlbumItem(
attachment: cvAttachment,
attachmentStream: nil,
hasCaption: hasCaption,
mediaSize: mediaSize,
isBroken: false,
threadHasPendingMessageRequest: threadHasPendingMessageRequest
))
continue
case .stream(let attachmentStream):
let attachmentStream = attachmentStream.attachmentStream
guard attachmentStream.contentType.isVisualMedia else {
Logger.warn("Filtering invalid media.")
mediaAlbumItems.append(CVMediaAlbumItem(
attachment: cvAttachment,
attachmentStream: nil,
hasCaption: hasCaption,
mediaSize: .zero,
isBroken: true,
threadHasPendingMessageRequest: threadHasPendingMessageRequest
))
continue
}
let mediaSizePixels: CGSize
switch attachmentStream.contentType {
case let .image(pixelSize), let .video(_, pixelSize, _), let .animatedImage(pixelSize):
guard pixelSize.isNonEmpty else {
Logger.warn("Filtering media with invalid size.")
fallthrough
}
mediaSizePixels = pixelSize
case .audio, .file, .invalid:
Logger.warn("Filtering media with invalid size.")
mediaAlbumItems.append(CVMediaAlbumItem(
attachment: cvAttachment,
attachmentStream: nil,
hasCaption: hasCaption,
mediaSize: .zero,
isBroken: true,
threadHasPendingMessageRequest: threadHasPendingMessageRequest
))
continue
}
mediaAlbumItems.append(CVMediaAlbumItem(
attachment: cvAttachment,
attachmentStream: attachmentStream,
hasCaption: hasCaption,
mediaSize: mediaSizePixels,
isBroken: false,
threadHasPendingMessageRequest: threadHasPendingMessageRequest
))
case .backupThumbnail(let thumbnail):
// TODO: Need to make CVMediaAlbumItem take a thumbnail
var mediaSize: CGSize = .zero
// need to determine if a size is possible
// TODO Cache this
if let sourceMediaSizePixels = thumbnail.attachmentBackupThumbnail.image?.size {
mediaSize = sourceMediaSizePixels
} else {
owsFailDebug("Invalid attachment.")
}
mediaAlbumItems.append(CVMediaAlbumItem(
attachment: cvAttachment,
attachmentStream: nil,
hasCaption: hasCaption,
mediaSize: mediaSize,
isBroken: false,
threadHasPendingMessageRequest: threadHasPendingMessageRequest
))
continue
case .undownloadable(let attachment):
var mediaSize: CGSize = .zero
if let sourceMediaSizePixels = attachment.reference.sourceMediaSizePixels {
mediaSize = sourceMediaSizePixels
} else {
owsFailDebug("Invalid attachment.")
}
mediaAlbumItems.append(CVMediaAlbumItem(
attachment: cvAttachment,
attachmentStream: nil,
hasCaption: hasCaption,
mediaSize: mediaSize,
isBroken: true,
threadHasPendingMessageRequest: threadHasPendingMessageRequest
))
continue
}
}
return mediaAlbumItems
}
mutating func buildNonMediaAttachment(bodyAttachment: ReferencedAttachment?) throws {
guard let attachment = bodyAttachment else {
throw OWSAssertionError("Missing attachment.")
}
func buildGenericAttachment() {
let cvAttachment = CVAttachment.from(attachment, tx: transaction)
self.genericAttachment = .init(attachment: cvAttachment)
}
guard
// Use the validated content type and only fall back to the mime type.
attachment.attachment.asStream()?.contentType.isAudio
?? MimeTypeUtil.isSupportedAudioMimeType(attachment.attachment.mimeType)
else {
buildGenericAttachment()
return
}
if
let attachmentStream = attachment.asReferencedStream,
let audioAttachment = AudioAttachment(
attachmentStream: attachmentStream,
owningMessage: interaction as? TSMessage,
metadata: nil,
receivedAtDate: interaction.receivedAtDate
)
{
self.audioAttachment = audioAttachment
} else if let attachmentPointer = attachment.asReferencedAnyPointer {
self.audioAttachment = AudioAttachment(
attachmentPointer: attachmentPointer,
owningMessage: interaction as? TSMessage,
metadata: nil,
receivedAtDate: interaction.receivedAtDate,
downloadState: attachmentPointer.attachmentPointer.downloadState(tx: transaction)
)
} else {
self.undownloadableAttachment = .audio
}
}
mutating func buildPaymentAttachment(
paymentNotification: TSPaymentNotification
) -> CVComponentState {
// Note: there should only ever be one payment model per receipt,
// but this is unenforced.
let paymentModel: TSPaymentModel? = PaymentFinder.paymentModels(
forMcReceiptData: paymentNotification.mcReceiptData,
transaction: itemBuildingContext.transaction
).first
self.paymentAttachment = PaymentAttachment(
notification: paymentNotification,
model: paymentModel,
// Only used for 1:1 threads, but not enforced.
otherUserShortName: threadViewModel.shortName ?? threadViewModel.name
)
return build()
}
mutating func buildArchivedPaymentAttachment(
archivedPaymentMessage: OWSArchivedPaymentMessage,
archivedPayment: ArchivedPayment?
) -> CVComponentState {
self.archivedPaymentAttachment = ArchivedPaymentAttachment(
amount: archivedPaymentMessage.archivedPaymentInfo.amount,
fee: archivedPaymentMessage.archivedPaymentInfo.fee,
note: archivedPaymentMessage.archivedPaymentInfo.note,
// Only used for 1:1 threads, but not enforced.
otherUserShortName: threadViewModel.shortName ?? threadViewModel.name,
archivedPayment: archivedPayment
)
return build()
}
private mutating func buildLinkPreview(message: TSMessage, linkPreview: OWSLinkPreview) throws {
guard bodyText != nil else {
owsFailDebug("Missing body text.")
return
}
guard let urlString = linkPreview.urlString else {
owsFailDebug("Missing urlString.")
return
}
guard let url = URL(string: urlString) else {
owsFailDebug("Invalid urlString.")
return
}
if let groupInviteLinkInfo = GroupInviteLinkInfo.parseFrom(url) {
let groupInviteLinkViewModel = CVComponentState.configureGroupInviteLink(
url,
message: message,
groupInviteLinkInfo: groupInviteLinkInfo
)
if !groupInviteLinkViewModel.isExpired {
let state = LinkPreviewGroupLink(
linkType: isIncoming ? .incomingMessageGroupInviteLink : .outgoingMessageGroupInviteLink,
linkPreview: linkPreview,
groupInviteLinkViewModel: groupInviteLinkViewModel,
conversationStyle: conversationStyle
)
self.linkPreview = LinkPreview(
linkPreview: linkPreview,
state: state
)
}
} else if let callLink = CallLink(url: url) {
let bottomButtonAction = CVMessageAction(
title: OWSLocalizedString(
"CONVERSATION_VIEW_JOIN_CALL",
comment: "Message shown in conversation view that offers to join a Call Link call."
),
accessibilityIdentifier: "join_call_link_call",
action: .didTapJoinCallLinkCall(callLink: callLink)
)
bottomButtonsActions.append(bottomButtonAction)
let state = LinkPreviewCallLink(previewType: .sent(linkPreview, conversationStyle), callLink: callLink)
self.linkPreview = LinkPreview(
linkPreview: linkPreview,
state: state
)
} else {
let linkPreviewAttachment = { () -> ReferencedAttachment? in
guard
let rowId = message.sqliteRowId,
let linkPreviewAttachment = DependenciesBridge.shared.attachmentStore.fetchFirstReferencedAttachment(
for: .messageLinkPreview(messageRowId: rowId),
tx: transaction
)
else {
return nil
}
guard MimeTypeUtil.isSupportedImageMimeType(linkPreviewAttachment.attachment.mimeType) else {
owsFailDebug("Link preview attachment isn't an image.")
return nil
}
if let attachmentStream = linkPreviewAttachment.asReferencedStream {
guard attachmentStream.attachmentStream.contentType.isImage else {
owsFailDebug("Link preview image attachment isn't valid.")
return nil
}
return attachmentStream
} else if
let blurHash = linkPreviewAttachment.attachment.blurHash,
BlurHash.isValidBlurHash(blurHash)
{
return linkPreviewAttachment
} else {
return nil
}
}()
let isFailedImageAttachmentDownload: Bool
if linkPreviewAttachment?.attachment.asStream() == nil {
switch linkPreviewAttachment?.attachment.asAnyPointer()?.downloadState(tx: transaction) ?? .none {
case .none, .enqueuedOrDownloading:
isFailedImageAttachmentDownload = false
case .failed:
isFailedImageAttachmentDownload = true
}
} else {
isFailedImageAttachmentDownload = false
}
let state = LinkPreviewSent(
linkPreview: linkPreview,
imageAttachment: linkPreviewAttachment,
isFailedImageAttachmentDownload: isFailedImageAttachmentDownload,
conversationStyle: conversationStyle
)
self.linkPreview = LinkPreview(
linkPreview: linkPreview,
state: state
)
}
}
private mutating func buildGiftBadge(messageUniqueId: String, giftBadge: OWSGiftBadge) throws -> CVComponentState {
let (level, expirationDate) = try giftBadge.getReceiptDetails()
self.giftBadge = GiftBadge(
messageUniqueId: messageUniqueId,
otherUserShortName: threadViewModel.shortName ?? threadViewModel.name,
cachedBadge: DonationSubscriptionManager.getCachedBadge(level: .giftBadge(level)),
expirationDate: expirationDate,
redemptionState: giftBadge.redemptionState
)
return build()
}
mutating func buildPoll(message: TSMessage, transaction: DBReadTransaction) throws -> CVComponentState {
guard let poll = try DependenciesBridge.shared.pollMessageManager.buildPoll(message: message, transaction: transaction) else {
Logger.error("Failed to build poll")
return build()
}
let state = CVPollView.buildState(
poll: poll,
isIncoming: isIncoming,
conversationStyle: conversationStyle)
self.poll = Poll(state: state)
if poll.totalVotes() > 0 {
let title = poll.isEnded ? OWSLocalizedString(
"POLL_BUTTON_VIEW_RESULTS",
comment: "Button to view poll results"
) : OWSLocalizedString(
"POLL_BUTTON_VIEW_VOTES",
comment: "Button to view poll votes"
)
let viewVotesAction = CVMessageAction(
title: title,
accessibilityIdentifier: "view_votes",
action: .didTapViewVotes(poll: poll)
)
bottomButtonsActions.append(viewVotesAction)
} else {
bottomLabel = OWSLocalizedString(
"POLL_NO_VOTES",
comment: "String to display when a poll has no votes"
)
}
return build()
}
}
// MARK: - DisplayableText
public extension CVComponentState {
static func displayableBodyText(text: String,
ranges: MessageBodyRanges?,
interaction: TSInteraction,
transaction: DBReadTransaction) -> DisplayableText {
return DisplayableText.displayableText(
withMessageBody: MessageBody(text: text, ranges: ranges ?? .empty),
transaction: transaction)
}
static func displayableBodyText(
oversizeTextAttachment attachmentStream: AttachmentStream,
ranges: MessageBodyRanges?,
interaction: TSInteraction,
transaction: DBReadTransaction
) -> DisplayableText {
let text = { () -> String in
do {
return try attachmentStream.decryptedLongText()
} catch {
owsFailDebug("Couldn't load oversize text: \(error).")
return ""
}
}()
return DisplayableText.displayableText(
withMessageBody: MessageBody(text: text, ranges: ranges ?? .empty),
transaction: transaction)
}
}
// MARK: -
fileprivate extension CVComponentState {
static func displayableQuotedText(
text: String,
ranges: MessageBodyRanges?,
interaction: TSInteraction,
revealedSpoilerIdsSnapshot: Set<StyleIdType>,
transaction: DBReadTransaction
) -> DisplayableText {
return DisplayableText.displayableText(
withMessageBody: MessageBody(text: text, ranges: ranges ?? .empty),
transaction: transaction
)
}
static func displayableCaption(
text: String,
transaction: DBReadTransaction
) -> DisplayableText {
return DisplayableText.displayableText(
withMessageBody: MessageBody(text: text, ranges: .empty),
transaction: transaction
)
}
}
// MARK: -
public extension CVComponentState {
var hasPrimaryAndSecondaryContentForSelection: Bool {
var hasPrimaryContent = false
// Search for a component that qualifies as "non-body text primary".
for key in activeComponentStateKeys {
switch key {
case .bodyText, .linkPreview:
// "Primary" content is not body text.
// A link preview is associated with the body text.
break
case .bodyMedia, .sticker, .audioAttachment, .genericAttachment, .contactShare:
hasPrimaryContent = true
case .senderName, .senderAvatar, .footer, .reactions, .bottomButtons, .bottomLabel, .sendFailureBadge, .dateHeader, .unreadIndicator, .typingIndicator, .threadDetails, .failedOrPendingDownloads, .unknownThreadWarning, .defaultDisappearingMessageTimer, .messageRoot:
// "Primary" content is not just metadata / UI.
break
case .giftBadge:
// Gift badges can't be forwarded.
break
case .viewOnce:
// We should never forward view-once messages.
break
case .systemMessage:
// We should never forward system messages.
break
case .quotedReply:
// Quoted replies are never forwarded.
break
case .paymentAttachment, .archivedPaymentAttachment:
// Payments can't be forwarded.
break
case .poll:
break
case .undownloadableAttachment:
break
}
}
let hasSecondaryContent = bodyText != nil
return hasPrimaryContent && hasSecondaryContent
}
}