Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
signalapp
GitHub Repository: signalapp/Signal-iOS
Path: blob/main/Signal/ConversationView/Components/CVComponentState.swift
1 views
//
// Copyright 2020 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//

import LibSignalClient
import MobileCoin
public import SignalServiceKit
import SignalUI

public enum CVAttachment: Equatable {
    case stream(ReferencedAttachmentStream, isUploading: Bool)
    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(
        referencedAttachment: ReferencedAttachment,
        message: TSMessage,
        tx: DBReadTransaction,
    ) -> CVAttachment {
        if let stream = referencedAttachment.asReferencedStream {
            let attachmentBackfillManager = DependenciesBridge.shared.attachmentBackfillManager
            var isUploading = false

            // We can infer from the properties of an outgoing message whether
            // it's got things being uploaded.
            if
                let outgoingMessage = message as? TSOutgoingMessage,
                referencedAttachment.attachment.latestTransitTierInfo == nil,
                referencedAttachment.attachment.mediaTierInfo == nil,
                !outgoingMessage.wasNotCreatedLocally,
                outgoingMessage.messageState != .failed
            {
                isUploading = true
            }

            // If we have an enqueued backfill for this message, assume this
            // attachment is being reuploaded.
            if
                attachmentBackfillManager.hasEnqueuedInboundRequest(
                    interactionRowId: message.sqliteRowId!,
                    tx: tx,
                )
            {
                isUploading = true
            }

            return .stream(stream, isUploading: isUploading)
        } else if let pointer = referencedAttachment.asReferencedAnyPointer {
            return .pointer(pointer, downloadState: pointer.attachmentPointer.downloadState(tx: tx))
        } else if let thumbnail = referencedAttachment.asReferencedBackupThumbnail {
            return .backupThumbnail(thumbnail)
        } else {
            return .undownloadable(referencedAttachment)
        }
    }

    public static func ==(lhs: CVAttachment, rhs: CVAttachment) -> Bool {
        switch (lhs, rhs) {
        case (.stream(let lhsStream, let lhsIsUploading), .stream(let rhsStream, let rhsIsUploading)):
            return lhsStream.attachment.id == rhsStream.attachment.id
                && lhsStream.reference.hasSameOwner(as: rhsStream.reference)
                && lhsIsUploading == rhsIsUploading
        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 struct CVComponentState: Equatable {
    let messageCellType: CVMessageCellType

    struct SenderName: Equatable {
        let senderName: NSAttributedString
        let senderNameColor: UIColor
        let memberLabel: String?
    }

    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(deleteAuthor: RemoteDeleteAuthor)

        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 else {
                return false
            }
            return jumbomojiCount > 0
        }
    }

    let bodyText: BodyText?

    struct BodyMedia: Equatable {
        let items: [CVMediaAlbumItem]
        let mediaAlbumHasFailedAttachment: Bool
        let mediaAlbumHasSkippedAttachment: 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: CVQuotedMessageView.State

        var quotedReplyModel: QuotedReplyModel { viewState.quotedReplyModel }
    }

    let quotedReply: QuotedReply?

    enum Sticker: Equatable {
        case available(
            stickerMetadata: any StickerMetadata,
            attachmentStream: ReferencedAttachmentStream,
            isUploading: Bool,
        )
        case downloading(attachmentPointer: ReferencedAttachmentPointer)
        case skipped(
            attachmentPointer: ReferencedAttachmentPointer,
            downloadState: AttachmentDownloadState,
        )

        var stickerMetadata: (any StickerMetadata)? {
            switch self {
            case .available(let stickerMetadata, attachmentStream: _, isUploading: _):
                return stickerMetadata
            case .downloading, .skipped:
                return nil
            }
        }

        var attachmentStream: ReferencedAttachmentStream? {
            switch self {
            case .available(stickerMetadata: _, let attachmentStream, isUploading: _):
                return attachmentStream
            case .downloading:
                return nil
            case .skipped:
                return nil
            }
        }

        var attachmentPointer: ReferencedAttachmentPointer? {
            switch self {
            case .available:
                return nil
            case .downloading(let attachmentPointer):
                return attachmentPointer
            case .skipped(let attachmentPointer, _):
                return attachmentPointer
            }
        }

        static func ==(lhs: CVComponentState.Sticker, rhs: CVComponentState.Sticker) -> Bool {
            switch (lhs, rhs) {
            case let (.available(lhsData, lhsStream, lhsIsUploading), .available(rhsData, rhsStream, rhsIsUploading)):
                return lhsData.stickerInfo.asKey() == rhsData.stickerInfo.asKey()
                    && lhsStream.attachment.id == rhsStream.attachment.id
                    && lhsStream.reference.hasSameOwner(as: rhsStream.reference)
                    && lhsIsUploading == rhsIsUploading
            case let (.downloading(lhsPointer), .downloading(rhsPointer)):
                return lhsPointer.attachment.id == rhsPointer.attachment.id
                    && lhsPointer.reference.hasSameOwner(as: rhsPointer.reference)
            case let (.skipped(lhsPointer, lhsState), .skipped(rhsPointer, rhsState)):
                return lhsPointer.attachment.id == rhsPointer.attachment.id
                    && lhsPointer.reference.hasSameOwner(as: rhsPointer.reference)
                    && lhsState == rhsState
            case (.available, _), (.downloading, _), (.skipped, _):
                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 {
        let linkPreview: OWSLinkPreview
        let state: LinkPreviewState

        // MARK: - Equatable

        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 prevPollState: CVPollView.State?
    }

    let poll: Poll?

    struct SystemMessage: Equatable {
        typealias ReferencedUser = CVTextLabel.ReferencedUserItem

        let title: NSAttributedString
        let titleColorOverride: UIColor?
        let action: CVMessageAction?

        struct Expiration: Equatable {
            let expirationTimestamp: UInt64
            let expiresInSeconds: UInt32
        }

        let expiration: Expiration?

        /// Represents users whose names appear in the title. Only applies to
        /// system messages in group threads.
        let namesInTitle: [ReferencedUser]

        init(
            title: NSAttributedString,
            titleColorOverride: UIColor?,
            action: CVMessageAction?,
            expiration: Expiration?,
        ) {
            let mutableTitle = NSMutableAttributedString(attributedString: title)
            mutableTitle.removeAttribute(
                .addressOfName,
                range: NSRange(location: 0, length: mutableTitle.length),
            )
            self.title = NSAttributedString(attributedString: mutableTitle)
            self.titleColorOverride = titleColorOverride
            self.action = action
            self.expiration = expiration

            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 {
                        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 "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 safetySection: SafetySection?
        let groupDescriptionText: String?
    }

    let threadDetails: ThreadDetails?

    typealias UnknownThreadWarning = CVComponentState.SystemMessage
    let unknownThreadWarning: UnknownThreadWarning?

    typealias DefaultDisappearingMessageTimer = CVComponentState.SystemMessage
    let defaultDisappearingMessageTimer: DefaultDisappearingMessageTimer?

    struct CollapseSet: Equatable {
        let collapsedInteractions: [TSInteraction]
        let collapseSetType: CollapseSetInteraction.MessagesType
        let isExpanded: Bool
        let finalTimerDescription: String?

        static func ==(lhs: CollapseSet, rhs: CollapseSet) -> Bool {
            return lhs.collapsedInteractions.map(\.uniqueId) == rhs.collapsedInteractions.map(\.uniqueId)
                && lhs.collapseSetType == rhs.collapseSetType
                && lhs.isExpanded == rhs.isExpanded
                && lhs.finalTimerDescription == rhs.finalTimerDescription
        }
    }

    let collapseSet: CollapseSet?

    struct BottomButtons: Equatable {
        let actions: [CVMessageAction]
    }

    let bottomButtons: BottomButtons?

    let bottomLabel: String?

    struct SkippedDownloads: Equatable {
        let attachmentPointers: [AttachmentPointer]

        static func ==(lhs: CVComponentState.SkippedDownloads, rhs: CVComponentState.SkippedDownloads) -> Bool {
            return lhs.attachmentPointers.map(\.id) == rhs.attachmentPointers.map(\.id)
        }
    }

    let skippedDownloads: SkippedDownloads?

    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?,
        collapseSet: CollapseSet?,
        bottomButtons: BottomButtons?,
        bottomLabel: String?,
        skippedDownloads: SkippedDownloads?,
        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.collapseSet = collapseSet
        self.bottomButtons = bottomButtons
        self.bottomLabel = bottomLabel
        self.skippedDownloads = skippedDownloads
        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.collapseSet == rhs.collapseSet &&
            lhs.bottomButtons == rhs.bottomButtons &&
            lhs.bottomLabel == rhs.bottomLabel &&
            lhs.skippedDownloads == rhs.skippedDownloads &&
            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 SkippedDownloads = CVComponentState.SkippedDownloads
        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 collapseSet: CollapseSet?
        var reactions: Reactions?
        var skippedDownloads: SkippedDownloads?
        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,
                collapseSet: collapseSet,
                bottomButtons: bottomButtons,
                bottomLabel: bottomLabel,
                skippedDownloads: skippedDownloads,
                sendFailureBadge: sendFailureBadge,
                messageHasBodyAttachments: messageHasBodyAttachments,
                hasRenderableContent: hasRenderableContent,
                poll: poll,
            )
        }

        // MARK: -

        var isIncoming: Bool {
            interaction is TSIncomingMessage
        }

        var isOutgoing: Bool {
            interaction is TSOutgoingMessage
        }

        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 collapseSet != nil {
                return .collapseSet
            }
            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

    var shouldRenderAsSticker: Bool {
        sticker != nil
    }

    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 collapseSet != nil {
            result.insert(.collapseSet)
        }
        if bottomButtons != nil {
            result.insert(.bottomButtons)
        }
        if bottomLabel != nil {
            result.insert(.bottomLabel)
        }
        if skippedDownloads != nil {
            result.insert(.skippedDownloads)
        }
        if sendFailureBadge != nil {
            result.insert(.sendFailureBadge)
        }
        if poll != nil {
            result.insert(.poll)
        }
        return result
    }

    var isTextOnlyMessage: Bool {
        let validKeys: [CVComponentKey] = [
            .senderName,
            .senderAvatar,
            .bodyText,
            .footer,
            .reactions,
            .sendFailureBadge,
        ]
        return activeComponentStateKeys.isSubset(of: Set(validKeys))
    }

    var isBorderlessJumbomojiMessage: Bool {
        isTextOnlyMessage && (bodyText?.isJumbomojiMessage == true)
    }

    var isBodyMediaOnlyMessage: Bool {
        let validKeys: [CVComponentKey] = [
            .senderName,
            .senderAvatar,
            .bodyMedia,
            .footer,
            .reactions,
            .sendFailureBadge,
        ]
        return activeComponentStateKeys.isSubset(of: Set(validKeys))
    }

    var isBorderlessBodyMediaMessage: Bool {
        if
            isBodyMediaOnlyMessage,
            let bodyMedia,
            bodyMedia.items.count == 1,
            let firstItem = bodyMedia.items.first,
            firstItem.renderingFlag == .borderless
        {
            return true
        }
        return false
    }

    // MARK: -

    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 buildCollapseSet(
        interaction: CollapseSetInteraction,
        itemBuildingContext: CVItemBuildingContext,
    ) -> CVComponentState {
        var builder = CVComponentState.Builder(
            interaction: interaction,
            itemBuildingContext: itemBuildingContext,
        )
        builder.collapseSet = CollapseSet(
            collapsedInteractions: interaction.collapsedInteractions,
            collapseSetType: interaction.collapseSetType,
            isExpanded: interaction.isExpanded,
            finalTimerDescription: interaction.finalTimerDescription,
        )
        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: -

    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, .skippedDownloads, .unknownThreadWarning, .defaultDisappearingMessageTimer, .collapseSet, .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
    }

    // MARK: -

    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: - DisplayableText

    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: -

private 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.skippedDownloads = tryToBuildSkippedDownloads()

        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 .collapseSet:
            guard let collapseSetInteraction = interaction as? CollapseSetInteraction else {
                owsFailDebug("Invalid collapseSet interaction.")
                return build()
            }
            self.collapseSet = CVComponentState.CollapseSet(
                collapsedInteractions: collapseSetInteraction.collapsedInteractions,
                collapseSetType: collapseSetInteraction.collapseSetType,
                isExpanded: collapseSetInteraction.isExpanded,
                finalTimerDescription: collapseSetInteraction.finalTimerDescription,
            )
            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 tryToBuildSkippedDownloads() -> SkippedDownloads? {
        guard let message = interaction as? TSMessage else {
            return nil
        }
        let attachmentPointers = message.skippedAttachments(transaction: transaction)
        guard !attachmentPointers.isEmpty else {
            return nil
        }
        return SkippedDownloads(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.
            let remoteDeleteAuthor = message.displayNameForDeleteMessage(localAci: localAci, transaction: transaction)
            self.bodyText = .remotelyDeleted(deleteAuthor: remoteDeleteAuthor)
            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 {
            let archivedPayment = DependenciesBridge.shared.archivedPaymentStore.fetch(
                for: archivedPaymentMessage,
                interactionUniqueId: message.uniqueId,
                tx: transaction,
            )
            return buildArchivedPaymentAttachment(
                archivedPaymentMessage: archivedPaymentMessage,
                archivedPayment: archivedPayment,
            )
        }

        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 mediaAlbumHasSkippedAttachment = 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:
                        mediaAlbumHasSkippedAttachment = true
                    }
                }

                self.bodyMedia = BodyMedia(
                    items: mediaAlbumItems,
                    mediaAlbumHasFailedAttachment: mediaAlbumHasFailedAttachment,
                    mediaAlbumHasSkippedAttachment: mediaAlbumHasSkippedAttachment,
                )
                return build()
            }

            // Only media galleries should have more than one attachment.
            owsAssertDebug(bodyAttachments.count <= 1)
            if let bodyAttachment = bodyAttachments.first {
                buildNonMediaAttachment(
                    referencedAttachment: bodyAttachment,
                    message: message,
                )
            }
        }

        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
    }

    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 referencedAttachment = DependenciesBridge.shared.attachmentStore.fetchAnyReferencedAttachment(
                for: .messageSticker(messageRowId: rowId),
                tx: transaction,
            )
        else {
            throw OWSAssertionError("Missing sticker attachment.")
        }

        let cvAttachment = CVAttachment.from(
            referencedAttachment: referencedAttachment,
            message: message,
            tx: transaction,
        )

        switch cvAttachment {
        case .stream(let referencedAttachmentStream, let isUploading):
            let stickerMetadata = EncryptedStickerMetadata(
                stickerInfo: messageSticker.info,
                stickerType: StickerManager.stickerType(forContentType: referencedAttachmentStream.attachment.mimeType),
                emojiString: messageSticker.emoji,
                encryptedStickerDataUrl: referencedAttachmentStream.attachmentStream.fileURL,
                encryptionKey: referencedAttachmentStream.attachmentStream.attachment.encryptionKey,
                plaintextLength: referencedAttachmentStream.attachmentStream.info.unencryptedByteCount,
            )

            self.sticker = .available(
                stickerMetadata: stickerMetadata,
                attachmentStream: referencedAttachmentStream,
                isUploading: isUploading,
            )
        case .pointer(let referencedAttachmentPointer, let downloadState):
            switch downloadState {
            case .enqueuedOrDownloading:
                self.sticker = .downloading(attachmentPointer: referencedAttachmentPointer)
            case .failed, .none:
                self.sticker = .skipped(
                    attachmentPointer: referencedAttachmentPointer,
                    downloadState: downloadState,
                )
            }
        case .backupThumbnail, .undownloadable:
            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 {
                var memberLabel: String?
                if
                    BuildFlags.MemberLabel.display, let groupThread = thread as? TSGroupThread,
                    let originalMessageAuthor = quotedMessage.authorAddress.aci
                {
                    memberLabel = groupThread.groupModel.groupMembership.memberLabel(for: originalMessageAuthor)?.labelForRendering()
                    memberLabel = memberLabel?
                        .components(separatedBy: .whitespaces)
                        .joined(separator: SignalSymbol.LeadingCharacter.nonBreakingSpace.rawValue)
                }

                return QuotedReplyModel.build(replyMessage: message, quotedMessage: quotedMessage, memberLabel: memberLabel, 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 = CVQuotedMessageView.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 referencedAttachment in mediaAttachments {
            guard
                // Use the validated content type and only fall back to the mime type.
                referencedAttachment.attachment.asStream()?.contentType.isVisualMedia
                ?? MimeTypeUtil.isSupportedVisualMediaMimeType(referencedAttachment.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(
                referencedAttachment: referencedAttachment,
                message: message,
                tx: transaction,
            )

            let caption = referencedAttachment.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 = referencedAttachment.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, isUploading: _):
                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(
        referencedAttachment: ReferencedAttachment,
        message: TSMessage,
    ) {
        guard
            // Use the validated content type and only fall back to the mime type.
            referencedAttachment.attachment.asStream()?.contentType.isAudio
            ?? MimeTypeUtil.isSupportedAudioMimeType(referencedAttachment.attachment.mimeType)
        else {
            let cvAttachment = CVAttachment.from(
                referencedAttachment: referencedAttachment,
                message: message,
                tx: transaction,
            )
            self.genericAttachment = .init(attachment: cvAttachment)
            return
        }

        if
            let attachmentStream = referencedAttachment.asReferencedStream,
            let audioAttachment = AudioAttachment(
                attachmentStream: attachmentStream,
                owningMessage: interaction as? TSMessage,
                metadata: nil,
                receivedAtDate: interaction.receivedAtDate,
            )
        {
            self.audioAttachment = audioAttachment
        } else if let referencedAttachmentPointer = referencedAttachment.asReferencedAnyPointer {
            self.audioAttachment = AudioAttachment(
                attachmentPointer: referencedAttachmentPointer,
                owningMessage: interaction as? TSMessage,
                metadata: nil,
                receivedAtDate: interaction.receivedAtDate,
                downloadState: referencedAttachmentPointer.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: PaymentsFormat.formatFromArchive(amount: archivedPaymentMessage.archivedPaymentInfo.amount),
            fee: PaymentsFormat.formatFromArchive(amount: 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.fetchAnyReferencedAttachment(
                        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,
            localAci: self.localAci,
        )

        let prevPollState: CVComponentState.Poll?
        if
            let prevRenderState = itemBuildingContext.prevRenderState,
            let prevPollInteraction = prevRenderState.items.first(
                where: {
                    $0.interactionUniqueId == message.uniqueId
                },
            ),
            let _prevPollState = prevPollInteraction.componentState.poll
        {
            prevPollState = _prevPollState
        } else {
            prevPollState = nil
        }

        // Pass the previously rendered poll so we can animate.
        self.poll = Poll(state: state, prevPollState: prevPollState?.state)

        if poll.totalVoters() > 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()
    }
}