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

import Foundation
import SignalServiceKit
public import SignalUI

@objc
public class CVComponentArchivedPayment: CVComponentBase, CVComponent {

    public var componentKey: CVComponentKey { .archivedPaymentAttachment }

    private let archivedPaymentAttachment: CVComponentState.ArchivedPaymentAttachment
    private let messageStatus: MessageReceiptStatus

    init(
        itemModel: CVItemModel,
        archivedPaymentAttachment: CVComponentState.ArchivedPaymentAttachment,
        messageStatus: MessageReceiptStatus?,
    ) {
        self.archivedPaymentAttachment = archivedPaymentAttachment

        // If no messageStatus have different defaults for incoming vs outgoing
        switch (messageStatus, itemModel.interaction.interactionType) {
        case (nil, .incomingMessage):
            // Use .sent as default for "incoming" so debug UI shows up correct
            self.messageStatus = .sent
        case (.some(let messageStatus), _):
            self.messageStatus = messageStatus
        default:
            // Default to .failed for all other cases where `messageStatus == nil`
            self.messageStatus = .failed
        }

        super.init(itemModel: itemModel)
    }

    public func buildComponentView(componentDelegate: CVComponentDelegate) -> CVComponentView {
        CVComponentViewArchivedPayment()
    }

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

        let bigAmountLabel = componentView.bigAmountLabel
        bigAmountLabelConfig.applyForRendering(label: bigAmountLabel)
        bigAmountLabel.alpha = messageStatus.bigAmountLabelAlpha
        bigAmountLabel.numberOfLines = messageStatus.bigAmountLabelNumberOfLines

        let topLabel = componentView.topLabel
        topLabelConfig.applyForRendering(label: topLabel)

        let hStackView = componentView.hStackView
        hStackView.addBlurBackgroundExactlyOnce(isIncoming: isIncoming)

        // Reset left space for status
        componentView.leftSpace.removeAllSubviews()

        let hInnerSubviews: [UIView]
        switch messageStatus {
        case .sending:
            componentView.leftSpace.addSubview(self.createLoadingSpinner())
            hInnerSubviews = [
                componentView.leftSpace,
                componentView.bigAmountLabel,
                componentView.rightSpace,
            ]
        case .failed:
            componentView.leftSpace.addSubview(self.createFailureIcon())
            hInnerSubviews = [
                componentView.leftSpace,
                componentView.bigAmountLabel,
            ]
        default:
            hInnerSubviews = [
                componentView.leftSpace,
                componentView.bigAmountLabel,
                componentView.rightSpace,
            ]
        }

        hStackView.configure(
            config: hStackConfig,
            cellMeasurement: cellMeasurement,
            measurementKey: .measurementKey_hStack,
            subviews: hInnerSubviews,
        )

        let vStackView = componentView.vStackView

        let vInnerSubviews: [UIView]
        if archivedPaymentAttachment.note != nil {
            noteLabelConfig.applyForRendering(label: componentView.noteLabel)
            vInnerSubviews = [topLabel, hStackView, componentView.noteLabel]
        } else {
            vInnerSubviews = [topLabel, hStackView]
        }

        vStackView.configure(
            config: vStackConfig,
            cellMeasurement: cellMeasurement,
            measurementKey: .measurementKey_vStack,
            subviews: vInnerSubviews,
        )
    }

    private func createLoadingSpinner() -> CustomView {
        // Recreate each time in-case theme changes
        let animationName = (
            isIncoming && !isDarkThemeEnabled
                ? "indeterminate_spinner_blue"
                : "indeterminate_spinner_white",
        )

        let animationView = mediaCache.buildLottieAnimationView(name: animationName)
        owsAssertDebug(animationView.animation != nil)
        animationView.backgroundBehavior = .pauseAndRestore
        animationView.loopMode = .loop
        animationView.contentMode = .scaleAspectFit
        animationView.play()

        return CustomView.wrapperFor(view: animationView, dimension: .spinnerSquareDimension)
    }

    private func createFailureIcon() -> CustomView {
        let tintColor = conversationStyle.bubbleTextColor(isIncoming: isIncoming)
        return CustomView.wrapperFor(
            view: UIImageView.createFailureIcon(tintColor: tintColor),
            dimension: .failureIconDimension,
        )
    }

    private func formatPaymentAmount(status: MessageReceiptStatus) -> NSAttributedString {
        guard let amount = archivedPaymentAttachment.amount else {
            let text = OWSLocalizedString(
                "PAYMENTS_INFO_UNAVAILABLE_MESSAGE",
                comment: "Status indicator for invalid payments which could not be processed.",
            )
            return NSAttributedString(string: text)
        }

        switch status {
        case .failed:
            return PaymentsFormat.inChatFailureAmountBuilder(amount)
        default:
            return PaymentsFormat.inChatSuccessAmountBuilder(amount)
        }
    }

    private var hStackConfig: CVStackViewConfig {
        CVStackViewConfig(
            axis: .horizontal,
            alignment: .center,
            spacing: .innerHStackSpacing,
            layoutMargins: UIEdgeInsets(top: 25, leading: 8, bottom: 25, trailing: 16),
        )
    }

    private var vStackConfig: CVStackViewConfig {
        CVStackViewConfig(
            axis: .vertical,
            alignment: .leading,
            spacing: 8,
            layoutMargins: UIEdgeInsets(top: 5, leading: 0, bottom: 0, trailing: 0),
        )
    }

    private var bigAmountLabelConfig: CVLabelConfig {
        let font = UIFont.dynamicTypeLargeTitle1Clamped.withSize(28)
        return CVLabelConfig(
            text: .attributedText(formatPaymentAmount(status: messageStatus)),
            displayConfig: .forUnstyledText(
                font: font,
                textColor: conversationStyle.bubbleTextColor(isIncoming: isIncoming),
            ),
            font: font,
            textColor: conversationStyle.bubbleTextColor(isIncoming: isIncoming),
            numberOfLines: messageStatus.bigAmountLabelNumberOfLines,
            lineBreakMode: .byWordWrapping,
            textAlignment: messageStatus.bigAmountLabelTextAlignment,
        )
    }

    private var topLabelConfig: CVLabelConfig {
        let text: String
        let interactionType = itemModel.interaction.interactionType
        switch (interactionType, messageStatus) {
        case (.incomingMessage, _):
            let format = OWSLocalizedString(
                "PAYMENTS_PAYMENT_STATUS_IN_CHAT_SENT_YOU",
                comment: "Payment status context with contact name, incoming. Embeds {{ Name of sending contact }}",
            )
            text = String.nonPluralLocalizedStringWithFormat(format, archivedPaymentAttachment.otherUserShortName)
        case (.outgoingMessage, .failed):
            let format = OWSLocalizedString(
                "PAYMENTS_PAYMENT_STATUS_IN_CHAT_PAYMENT_TO",
                comment: "Payment status context with contact name, failed. Embeds {{ Name of receiving contact }}",
            )
            text = String.nonPluralLocalizedStringWithFormat(format, archivedPaymentAttachment.otherUserShortName)
        case (.outgoingMessage, _):
            let format = OWSLocalizedString(
                "PAYMENTS_PAYMENT_STATUS_IN_CHAT_YOU_SENT",
                comment: "Payment status context with contact name, sent. Embeds {{ Name of receiving contact }}",
            )
            text = String.nonPluralLocalizedStringWithFormat(format, archivedPaymentAttachment.otherUserShortName)
        default:
            // default to failed text because it doesn't imply success
            let format = OWSLocalizedString(
                "PAYMENTS_PAYMENT_STATUS_IN_CHAT_PAYMENT_TO",
                comment: "Payment status context with contact name, failed. Embeds {{ Name of receiving contact }}",
            )
            text = String.nonPluralLocalizedStringWithFormat(format, archivedPaymentAttachment.otherUserShortName)
        }

        return CVLabelConfig(
            text: .text(text),
            displayConfig: .forUnstyledText(
                font: .dynamicTypeBody,
                textColor: conversationStyle.bubbleTextColor(isIncoming: isIncoming),
            ),
            font: UIFont.dynamicTypeBody,
            textColor: conversationStyle.bubbleTextColor(isIncoming: isIncoming),
            lineBreakMode: .byTruncatingMiddle,
        )
    }

    private var noteLabelConfig: CVLabelConfig {
        CVLabelConfig(
            text: .text(archivedPaymentAttachment.note ?? ""),
            displayConfig: .forUnstyledText(
                font: .dynamicTypeBody,
                textColor: conversationStyle.bubbleTextColor(isIncoming: isIncoming),
            ),
            font: UIFont.dynamicTypeBody,
            textColor: conversationStyle.bubbleTextColor(isIncoming: isIncoming),
            numberOfLines: 0,
            lineBreakMode: .byTruncatingMiddle,
        )
    }

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

        let maxLabelWidth = max(0, maxWidth - vStackConfig.layoutMargins.totalWidth)

        let maxBigLabelWidth: CGFloat = {
            let nonLabelWidth =
                (
                    hStackConfig.layoutMargins.totalWidth
                        + messageStatus.hStackCumulativeSpacing
                        + vStackConfig.layoutMargins.totalWidth
                        + messageStatus.spacersTotalWidth,
                )

            return max(0, maxWidth - nonLabelWidth)
        }()

        let bigAmountLabelSize = CVText.measureLabel(
            config: bigAmountLabelConfig,
            maxWidth: maxBigLabelWidth,
        )
        let statusIconSize = CGSize(square: messageStatus.statusIconDimension)

        var hSubviewInfos = [ManualStackSubviewInfo]()
        hSubviewInfos.append(statusIconSize.asManualSubviewInfo())
        hSubviewInfos.append(bigAmountLabelSize.asManualSubviewInfo())
        if messageStatus != .failed {
            hSubviewInfos.append(statusIconSize.asManualSubviewInfo())
        }
        let hStackMeasurement = ManualStackView.measure(
            config: hStackConfig,
            measurementBuilder: measurementBuilder,
            measurementKey: .measurementKey_hStack,
            subviewInfos: hSubviewInfos,
            maxWidth: maxWidth,
        )

        let maxTopLabelWidth = min(maxLabelWidth, hStackMeasurement.measuredSize.width)
        let maxNoteLabelWidth = maxTopLabelWidth // Same for now
        let topLabelSize = CVText.measureLabel(config: topLabelConfig, maxWidth: maxTopLabelWidth)
        let noteLabelSize = CVText.measureLabel(
            config: noteLabelConfig,
            maxWidth: maxNoteLabelWidth,
        )

        var vSubviewInfos = [ManualStackSubviewInfo]()
        vSubviewInfos.append(topLabelSize.asManualSubviewInfo())
        vSubviewInfos.append(hStackMeasurement.measuredSize.asManualSubviewInfo)

        if archivedPaymentAttachment.note != nil {
            vSubviewInfos.append(noteLabelSize.asManualSubviewInfo())
        }

        let vStackMeasurement = ManualStackView.measure(
            config: vStackConfig,
            measurementBuilder: measurementBuilder,
            measurementKey: .measurementKey_vStack,
            subviewInfos: vSubviewInfos,
        )

        return vStackMeasurement.measuredSize
    }

    // MARK: - CVComponentView

    // Used for rendering some portion of an Conversation View item.
    // It could be the entire item or some part thereof.
    public class CVComponentViewArchivedPayment: NSObject, CVComponentView {

        fileprivate let hStackView = ManualStackView(name: "ArchivedPayment.hStackView")
        fileprivate let vStackView = ManualStackView(name: "ArchivedPayment.vStackView")

        fileprivate var leftSpace = UIView()
        fileprivate var rightSpace = UIView()

        fileprivate let bigAmountLabel = CVLabel()
        fileprivate let topLabel = CVLabel()
        fileprivate let noteLabel = CVLabel()

        public var isDedicatedCellView = true

        public var rootView: UIView {
            vStackView
        }

        public func setIsCellVisible(_ isCellVisible: Bool) {}

        public func reset() {
            hStackView.reset()
            vStackView.reset()

            bigAmountLabel.text = nil
            topLabel.text = nil
            noteLabel.text = nil

            leftSpace.removeAllSubviews()
            rightSpace.removeAllSubviews()
        }
    }

    override public func handleTap(
        sender: UIGestureRecognizer,
        componentDelegate: CVComponentDelegate,
        componentView: CVComponentView,
        renderItem: CVRenderItem,
    ) -> Bool {
        guard let contactAddress = (thread as? TSContactThread)?.contactAddress else {
            owsFailDebug("Should be contact thread")
            return false
        }
        guard let archivedPayment = archivedPaymentAttachment.archivedPayment else { return false }
        guard
            let item = ArchivedPaymentHistoryItem(
                archivedPayment: archivedPayment,
                address: contactAddress,
                displayName: archivedPaymentAttachment.otherUserShortName,
                interaction: interaction,
            )
        else {
            return false
        }
        componentDelegate.didTapPayment(item)
        return true
    }
}

// MARK: - Constants & Utils

private extension String {
    static let measurementKey_hStack = "CVComponentArchivedPayment.measurementKey_hStack"
    static let measurementKey_vStack = "CVComponentArchivedPayment.measurementKey_vStack"
}

extension CVComponentArchivedPayment: CVAccessibilityComponent {
    public var accessibilityDescription: String {
        return formatPaymentAmount(status: messageStatus).string
    }
}

private extension UIView {
    @discardableResult
    func addBlur(style: UIBlurEffect.Style = .extraLight) -> UIVisualEffectView {
        let blurEffect = UIBlurEffect(style: style)
        let blurBackground = UIVisualEffectView(effect: blurEffect)
        blurBackground.alpha = 0.3
        blurBackground.layer.cornerRadius = 18
        blurBackground.clipsToBounds = true
        blurBackground.frame = self.frame // your view that have any objects
        blurBackground.autoresizingMask = [.flexibleWidth, .flexibleHeight]
        addSubview(blurBackground)
        return blurBackground
    }
}

private class CustomView: UIView {
    var dimension: CGFloat = .spinnerSquareDimension

    override var intrinsicContentSize: CGSize {
        CGSize(square: dimension)
    }

    static func wrapperFor(view: UIView, dimension: CGFloat) -> CustomView {
        let wrapper = CustomView()

        wrapper.contentMode = .center
        wrapper.dimension = dimension
        wrapper.addSubview(view)
        view.translatesAutoresizingMaskIntoConstraints = false
        view.contentMode = .scaleAspectFit

        view.centerXAnchor.constraint(equalTo: wrapper.centerXAnchor).isActive = true
        view.centerYAnchor.constraint(equalTo: wrapper.centerYAnchor).isActive = true
        view.heightAnchor.constraint(equalToConstant: dimension).isActive = true
        view.widthAnchor.constraint(equalToConstant: dimension).isActive = true

        wrapper.translatesAutoresizingMaskIntoConstraints = false
        NSLayoutConstraint.activate([
            wrapper.heightAnchor.constraint(equalToConstant: dimension),
            wrapper.widthAnchor.constraint(equalTo: wrapper.heightAnchor, multiplier: 1),
        ])

        return wrapper
    }
}

extension CGFloat {
    fileprivate static let spinnerSquareDimension: CGFloat = 20
    fileprivate static let failureIconDimension: CGFloat = 22
    fileprivate static let innerHStackSpacing: CGFloat = 9
}

private extension MessageReceiptStatus {
    var bigAmountLabelAlpha: CGFloat {
        self == .sending ? 0.5 : 1
    }

    var bigAmountLabelNumberOfLines: Int {
        self == .failed ? 2 : 1
    }

    var bigAmountLabelTextAlignment: NSTextAlignment {
        self == .failed ? .left : .center
    }

    var statusIconDimension: CGFloat {
        self == .failed ? .failureIconDimension : .spinnerSquareDimension
    }

    var spacersTotalWidth: CGFloat {
        self == .failed ? .failureIconDimension : .spinnerSquareDimension * 2
    }

    var hStackCumulativeSpacing: CGFloat {
        self == .failed ? .innerHStackSpacing : .innerHStackSpacing * 2
    }
}

private extension ManualStackView {
    func addBlurBackgroundExactlyOnce(isIncoming: Bool) {
        var subviewsToCheck = self.subviews
        while let subviewToCheck = subviewsToCheck.popLast() {
            if subviewToCheck is UIVisualEffectView {
                // already exists
                return
            }
            subviewsToCheck = subviewToCheck.subviews + subviewsToCheck
        }

        let effect: UIBlurEffect.Style = {
            (Theme.isDarkThemeEnabled && isIncoming) ? .regular : .extraLight
        }()

        let blurBackground = self.addBlur(style: effect)
        blurBackground.alpha = {
            switch (Theme.isDarkThemeEnabled, isIncoming) {
            case (_, false):
                return 0.4
            case (true, true):
                return 1
            case (false, true):
                return 1
            }
        }()
    }
}

private extension UIImageView {
    static func createFailureIcon(tintColor: UIColor) -> UIImageView {
        let sendFailureBadge = UIImageView(frame: .zero)
        sendFailureBadge.contentMode = .center
        sendFailureBadge.setTemplateImageName("error-outline-24", tintColor: tintColor)
        sendFailureBadge.backgroundColor = UIColor.clear
        sendFailureBadge.layer.cornerRadius = .failureIconDimension / 2
        sendFailureBadge.clipsToBounds = true

        return sendFailureBadge
    }
}