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

import SignalServiceKit
import SignalUI

class CVComponentUndownloadableAttachment: CVComponentBase, CVComponent {
    var componentKey: CVComponentKey { .undownloadableAttachment }

    private var icon: UIImage {
        switch attachmentType {
        case .audio:
            UIImage(named: "audio-square-slash")!
        case .sticker:
            UIImage(named: "sticker-slash")!
        }
    }

    private var message: String {
        switch attachmentType {
        case .audio:
            OWSLocalizedString(
                "AUDIO_UNAVAILABLE_MESSAGE_LABEL",
                value: "Voice message not available",
                comment: "Message when trying to show a voice message that has expired and is unavailable for download",
            )
        case .sticker:
            OWSLocalizedString(
                "STICKER_UNAVAILABLE_MESSAGE_LABEL",
                value: "Sticker not available",
                comment: "Message when trying to show a sticker that has expired and is unavailable for download",
            )
        }
    }

    private let attachmentType: CVComponentState.UndownloadableAttachment
    private let footerOverlay: CVComponent?

    init(
        itemModel: CVItemModel,
        attachmentType: CVComponentState.UndownloadableAttachment,
        footerOverlay: CVComponent?,
    ) {
        self.attachmentType = attachmentType
        self.footerOverlay = footerOverlay
        super.init(itemModel: itemModel)
    }

    private static let measurementKey_stackView = "CVComponentUndownloadableAttachment.measurementKey_stackView"
    private static let measurementKey_footerSize = "CVComponentUndownloadableAttachment.measurementKey_footerSize"

    func buildComponentView(componentDelegate: any CVComponentDelegate) -> any CVComponentView {
        CVComponentViewUndownloadableAttachment()
    }

    func configureForRendering(
        componentView: any CVComponentView,
        cellMeasurement: SignalUI.CVCellMeasurement,
        componentDelegate: any CVComponentDelegate,
    ) {
        guard let componentView = componentView as? CVComponentViewUndownloadableAttachment else {
            owsFailDebug("Unexpected componentView.")
            componentView.reset()
            return
        }

        let stackView = componentView.stackView

        if let footerOverlay {
            let footerView: CVComponentView
            if let footerOverlayView = componentView.footerOverlayView {
                footerView = footerOverlayView
            } else {
                let footerOverlayView = CVComponentFooter.CVComponentViewFooter()
                componentView.footerOverlayView = footerOverlayView
                footerView = footerOverlayView
            }
            footerOverlay.configureForRendering(
                componentView: footerView,
                cellMeasurement: cellMeasurement,
                componentDelegate: componentDelegate,
            )
            let footerRootView = footerView.rootView

            let footerSize = cellMeasurement.size(key: Self.measurementKey_footerSize) ?? .zero
            stackView.addSubview(footerRootView) { view in
                var footerFrame = view.bounds
                footerFrame.height = min(view.bounds.height, footerSize.height)
                footerFrame.y = view.bounds.height - footerSize.height
                footerRootView.frame = footerFrame
            }
        }

        let label = CVTextLabel()
        label.configureForRendering(
            config: labelConfig(
                conversationStyle: conversationStyle,
                isIncoming: itemModel.interaction is TSIncomingMessage,
            ),
            spoilerAnimationManager: .init(),
        )

        stackView.configure(
            config: stackViewConfig,
            cellMeasurement: cellMeasurement,
            measurementKey: Self.measurementKey_stackView,
            subviews: [label.view],
        )
    }

    func measure(maxWidth: CGFloat, measurementBuilder: SignalUI.CVCellMeasurement.Builder) -> CGSize {
        owsAssertDebug(maxWidth > 0)

        let config = labelConfig(
            conversationStyle: conversationStyle,
            isIncoming: false, // Used for color. Doesn't matter for measurement
        )

        let footerSize: CGSize
        if let footerOverlay {
            let maxFooterWidth = max(0, maxWidth - conversationStyle.textInsets.totalWidth)
            footerSize = footerOverlay.measure(
                maxWidth: maxFooterWidth,
                measurementBuilder: measurementBuilder,
            )
            measurementBuilder.setSize(key: Self.measurementKey_footerSize, size: footerSize)
        } else {
            footerSize = .zero
        }

        let info = CVText.measureBodyTextLabelInManualStackView(
            config: config,
            footerSize: footerSize,
            maxWidth: maxWidth,
            measurementBuilder: measurementBuilder,
        )

        let stackMeasurement = ManualStackView.measure(
            config: stackViewConfig,
            measurementBuilder: measurementBuilder,
            measurementKey: Self.measurementKey_stackView,
            subviewInfos: info,
            maxWidth: maxWidth,
        )

        return stackMeasurement.measuredSize
    }

    var stackViewConfig: CVStackViewConfig {
        CVStackViewConfig(
            axis: .vertical,
            alignment: .leading,
            spacing: 0,
            layoutMargins: .zero,
        )
    }

    private func labelConfig(
        conversationStyle: ConversationStyle,
        isIncoming: Bool,
    ) -> CVTextLabel.Config {
        let font = UIFont.dynamicTypeSubheadlineClamped
        let textColor = conversationStyle.bubbleTextColor(isIncoming: isIncoming)

        return CVTextLabel.Config(
            text: .attributedText(
                .composed(of: [
                    NSAttributedString.with(
                        image: icon,
                        font: .dynamicTypeSubheadlineClamped,
                        centerVerticallyRelativeTo: font,
                    ),
                    " ",
                    message,
                    SignalSymbol.LeadingCharacter.nonBreakingSpace.rawValue,
                    SignalSymbol.chevronTrailing.attributedString(
                        dynamicTypeBaseSize: 16,
                        clamped: true,
                        weight: .bold,
                        leadingCharacter: .nonBreakingSpace,
                        attributes: [.foregroundColor: UIColor.Signal.secondaryLabel],
                    ),
                ]).styled(with: .font(font), .color(textColor)),
            ),
            displayConfig: .forUnstyledText(font: font, textColor: textColor),
            font: font,
            textColor: textColor,
            selectionStyling: [:],
            textAlignment: .natural,
            lineBreakMode: .byWordWrapping,
            items: [],
            linkifyStyle: .linkAttribute,
        )
    }

    override func handleTap(
        sender: UIGestureRecognizer,
        componentDelegate: any CVComponentDelegate,
        componentView: any CVComponentView,
        renderItem: CVRenderItem,
    ) -> Bool {
        switch self.attachmentType {
        case .audio:
            componentDelegate.didTapUndownloadableAudio()
        case .sticker:
            componentDelegate.didTapUndownloadableSticker()
        }
        return true
    }

    class CVComponentViewUndownloadableAttachment: NSObject, CVComponentView {
        fileprivate let stackView = ManualStackView(name: "CVComponentViewAudioAttachment.stackView")
        fileprivate var footerOverlayView: CVComponentView?

        var isDedicatedCellView: Bool = false

        var rootView: UIView {
            stackView
        }

        func setIsCellVisible(_ isCellVisible: Bool) {}

        func reset() {
            stackView.reset()
            footerOverlayView?.reset()
            footerOverlayView = nil
        }
    }
}