Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
signalapp
GitHub Repository: signalapp/Signal-iOS
Path: blob/main/Signal/src/ViewControllers/Donations/DonationViewsUtil.swift
1 views
//
// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//

import BonMot
import Foundation
import Lottie
import SafariServices
public import SignalServiceKit
public import SignalUI
import UIKit

// MARK: - Profile badge lookup

public class ProfileBadgeLookup {
    let boostBadge: ProfileBadge?
    let giftBadge: ProfileBadge?
    let badgesBySubscriptionLevel: [UInt: ProfileBadge]

    public convenience init() {
        self.init(boostBadge: nil, giftBadge: nil, subscriptionLevels: [])
    }

    public init(boostBadge: ProfileBadge?, giftBadge: ProfileBadge?, subscriptionLevels: [DonationSubscriptionLevel]) {
        self.boostBadge = boostBadge
        self.giftBadge = giftBadge

        var badgesBySubscriptionLevel = [UInt: ProfileBadge]()
        for subscriptionLevel in subscriptionLevels {
            badgesBySubscriptionLevel[subscriptionLevel.level] = subscriptionLevel.badge
        }
        self.badgesBySubscriptionLevel = badgesBySubscriptionLevel
    }

    private func get(donationReceipt: DonationReceipt) -> ProfileBadge? {
        switch donationReceipt.receiptType {
        case .boost: return boostBadge
        case .subscription(let subscriptionLevel): return badgesBySubscriptionLevel[subscriptionLevel]
        case .gift: return giftBadge
        }
    }

    public func getImage(donationReceipt: DonationReceipt, preferDarkTheme: Bool) -> UIImage? {
        guard let assets = get(donationReceipt: donationReceipt)?.assets else { return nil }
        return preferDarkTheme ? assets.dark16 : assets.light16
    }

    public func attemptToPopulateBadgeAssets(populateAssetsOnBadge: @escaping (ProfileBadge) async throws -> Void) async -> Void {
        var badgesToLoad = Array(badgesBySubscriptionLevel.values)
        if let boostBadge { badgesToLoad.append(boostBadge) }
        if let giftBadge { badgesToLoad.append(giftBadge) }

        await withTaskGroup(of: Void.self) { group in
            for badge in badgesToLoad {
                group.addTask {
                    do {
                        try await populateAssetsOnBadge(badge)
                    } catch {}
                }
            }

            await group.waitForAll()
        }
    }
}

// MARK: - Currency picker view

public class DonationCurrencyPickerButton: UIView {
    init(
        currentCurrencyCode: Currency.Code,
        hasLabel: Bool,
        block: @escaping () -> Void,
    ) {
        super.init(frame: .zero)

        let leadingAnchorForButton: NSLayoutXAxisAnchor
        if hasLabel {
            let label = UILabel()
            label.font = .dynamicTypeBodyClamped
            label.textColor = .Signal.label
            label.text = OWSLocalizedString(
                "DONATIONS_CURRENCY_PICKER_LABEL",
                comment: "Label for the currency picker button in donation views",
            )
            label.setContentHuggingHorizontalHigh()
            label.setContentHuggingVerticalLow()
            label.setCompressionResistanceHigh()
            label.translatesAutoresizingMaskIntoConstraints = false
            addSubview(label)
            addConstraints([
                label.leadingAnchor.constraint(equalTo: leadingAnchor),
                label.topAnchor.constraint(equalTo: topAnchor),
                label.bottomAnchor.constraint(equalTo: bottomAnchor),
            ])
            leadingAnchorForButton = label.trailingAnchor
        } else {
            leadingAnchorForButton = leadingAnchor
        }

        let picker = UIButton(
            configuration: .bordered(),
            primaryAction: UIAction { _ in
                block()
            },
        )
        picker.configuration?.attributedTitle = AttributedString(
            NSAttributedString.composed(of: [
                currentCurrencyCode,
                Special.noBreakSpace,
                NSAttributedString.with(
                    image: UIImage(imageLiteralResourceName: "chevron-down-extra-small"),
                    font: .regularFont(ofSize: 17),
                ).styled(
                    with: .color(.Signal.tertiaryLabel),
                ),
            ]).styled(
                with: .font(.regularFont(ofSize: 17)),
                .color(.Signal.label),
            ),
        )
        picker.configuration?.contentInsets = .init(hMargin: 12, vMargin: 8)
        picker.configuration?.baseBackgroundColor = DonationViewsUtil.bubbleBackgroundColor
        picker.configuration?.background.strokeColor = .Signal.tertiaryLabel
        picker.configuration?.cornerStyle = .capsule
        picker.setContentHuggingHigh()
        picker.setCompressionResistanceHigh()
        picker.translatesAutoresizingMaskIntoConstraints = false
        addSubview(picker)
        addConstraints([
            picker.leadingAnchor.constraint(equalTo: leadingAnchorForButton, constant: hasLabel ? 8 : 0),
            picker.topAnchor.constraint(equalTo: topAnchor),
            picker.bottomAnchor.constraint(equalTo: bottomAnchor),
            picker.trailingAnchor.constraint(equalTo: trailingAnchor),
        ])
    }

    required init(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
}

// MARK: - Gift badge cell view

public class GiftBadgeCellView: UIStackView {
    init(badge: ProfileBadge, price: FiatMoney) {
        super.init(frame: .zero)

        self.axis = .horizontal
        self.spacing = 12
        self.alignment = .center

        let badgeImageView: UIView = {
            let badgeImage = badge.assets?.universal160
            return UIImageView(image: badgeImage)
        }()
        self.addArrangedSubview(badgeImageView)
        badgeImageView.autoSetDimensions(to: CGSize(square: 64))

        let titleLabel = UILabel()
        titleLabel.text = badge.localizedName
        titleLabel.textColor = .Signal.label
        titleLabel.font = .dynamicTypeHeadline
        titleLabel.numberOfLines = 0

        let secondLineLabel = UILabel()
        secondLineLabel.text = {
            let formattedPrice = CurrencyFormatter.format(money: price)

            let formattedDuration: String = {
                guard let duration = badge.duration else {
                    owsFailDebug("Gift badge had no duration but one was expected")
                    return ""
                }

                let durationFormatter = DateComponentsFormatter()
                durationFormatter.unitsStyle = .short
                durationFormatter.allowedUnits = [.day]
                guard let formattedDuration = durationFormatter.string(from: duration) else {
                    owsFailDebug("Could not format gift badge duration")
                    return ""
                }

                return formattedDuration
            }()
            let formattedDurationText = String.nonPluralLocalizedStringWithFormat(
                OWSLocalizedString(
                    "DONATION_FOR_A_FRIEND_ROW_DURATION",
                    comment: "When donating on behalf of a friend, a badge will be sent. This shows how long the badge lasts. Embeds {{formatted duration}}.",
                ),
                formattedDuration,
            )

            return String.nonPluralLocalizedStringWithFormat(
                OWSLocalizedString(
                    "JOINED_WITH_DOT",
                    comment: "Two strings, joined by a dot. Embeds {first} and {second}, which are on opposite sides of the dot",
                ),
                formattedPrice,
                formattedDurationText,
            )
        }()
        secondLineLabel.textColor = .Signal.label
        secondLineLabel.font = .dynamicTypeSubheadline
        secondLineLabel.numberOfLines = 0

        let vStackView = UIStackView(arrangedSubviews: [titleLabel, secondLineLabel])
        vStackView.axis = .vertical
        vStackView.distribution = .equalCentering
        vStackView.spacing = 4
        self.addArrangedSubview(vStackView)
    }

    required init(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
}

// MARK: - Misc. utilities

public enum DonationViewsUtil {
    public static let bubbleBorderWidth: CGFloat = 1.5
    public static var bubbleBackgroundColor: UIColor { .Signal.secondaryGroupedBackground }
    public static var amountFieldMinHeight: CGFloat = if #available(iOS 26, *) { 52 } else { 48 }

    public static func avatarView() -> ConversationAvatarView {
        let sizeClass = ConversationAvatarView.Configuration.SizeClass.eightyEight
        return ConversationAvatarView(sizeClass: sizeClass, localUserDisplayMode: .asUser)
    }

    /// Complete the monthly donation and allow for an optional onFinished block to be called with the
    /// result of the subscription.  Regardless of success or failure here, the pending donation is cleared
    /// since there isn't anything actionable for the user to do on failure other than try again with a new
    /// donation.
    public static func completeMonthlyDonations(
        subscriberId: Data,
        paymentType: DonationSubscriptionManager.RecurringSubscriptionPaymentType,
        newSubscriptionLevel: DonationSubscriptionLevel,
        priorSubscriptionLevel: DonationSubscriptionLevel?,
        currencyCode: Currency.Code,
        databaseStorage: SDSDatabaseStorage,
    ) async throws {
        let pendingStore = DependenciesBridge.shared.externalPendingIDEALDonationStore
        var rethrowError: Error?
        do {
            try await DonationViewsUtil.finalizeAndRedeemSubscription(
                subscriberId: subscriberId,
                paymentType: paymentType,
                newSubscriptionLevel: newSubscriptionLevel,
                priorSubscriptionLevel: priorSubscriptionLevel,
                currencyCode: currencyCode,
            )
        } catch {
            rethrowError = error
        }
        await databaseStorage.awaitableWrite { tx in
            pendingStore.clearPendingSubscription(tx: tx)
        }
        if let rethrowError {
            throw rethrowError
        }
    }

    /// Complete the one-time donation and allow for an optional onFinished
    /// block to be called with the result of the transaction.
    public static func completeOneTimeDonation(
        paymentIntentId: String,
        amount: FiatMoney,
        paymentMethod: DonationPaymentMethod,
        databaseStorage: SDSDatabaseStorage,
    ) async throws {
        let pendingStore = DependenciesBridge.shared.externalPendingIDEALDonationStore
        var rethrowError: Error?
        do {
            try await DonationViewsUtil.createAndRedeemOneTimeDonation(
                paymentIntentId: paymentIntentId,
                amount: amount,
                paymentMethod: paymentMethod,
            )
        } catch {
            rethrowError = error
        }
        await databaseStorage.awaitableWrite { tx in
            pendingStore.clearPendingOneTimeDonation(tx: tx)
        }
        if let rethrowError {
            throw rethrowError
        }
    }

    public static func createAndRedeemOneTimeDonation(
        paymentIntentId: String,
        amount: FiatMoney,
        paymentMethod: DonationPaymentMethod,
    ) async throws {
        return try await DonationViewsUtil.waitForRedemption(paymentMethod: paymentMethod) {
            try await DonationSubscriptionManager.requestAndRedeemReceipt(
                boostPaymentIntentId: paymentIntentId,
                amount: amount,
                paymentProcessor: .stripe,
                paymentMethod: paymentMethod,
            )
        }
    }

    public static func finalizeAndRedeemSubscription(
        subscriberId: Data,
        paymentType: DonationSubscriptionManager.RecurringSubscriptionPaymentType,
        newSubscriptionLevel: DonationSubscriptionLevel,
        priorSubscriptionLevel: DonationSubscriptionLevel?,
        currencyCode: Currency.Code,
    ) async throws {
        Logger.info("[Donations] Finalizing new subscription")

        _ = try await DonationSubscriptionManager.finalizeNewSubscription(
            forSubscriberId: subscriberId,
            paymentType: paymentType,
            subscription: newSubscriptionLevel,
            currencyCode: currencyCode,
        )

        Logger.info("[Donations] Redeeming monthly receipts")

        return try await DonationViewsUtil.waitForRedemption(paymentMethod: paymentType.paymentMethod) {
            try await DonationSubscriptionManager.requestAndRedeemReceipt(
                subscriberId: subscriberId,
                subscriptionLevel: newSubscriptionLevel.level,
                priorSubscriptionLevel: priorSubscriptionLevel?.level,
                paymentProcessor: paymentType.paymentProcessor,
                paymentMethod: paymentType.paymentMethod,
                isNewSubscription: true,
            )
        }
    }

    public static func loadSubscriptionLevels(donationConfiguration: DonationSubscriptionConfiguration, badgeStore: BadgeStore) async throws -> [DonationSubscriptionLevel] {
        let levels = donationConfiguration.subscription.levels
        try await withThrowingTaskGroup(of: Void.self) { taskGroup in
            for level in levels {
                taskGroup.addTask {
                    try await badgeStore.populateAssetsOnBadge(level.badge)
                }
            }
            try await taskGroup.waitForAll()
        }
        return levels
    }

    public static func loadCurrentSubscription(subscriberID: Data?) async throws -> Subscription? {
        let networkManager = SSKEnvironment.shared.networkManagerRef

        if let subscriberID {
            return try await SubscriptionFetcher(networkManager: networkManager)
                .fetch(subscriberID: subscriberID)
        } else {
            return nil
        }
    }

    public static func subscriptionLevelForSubscription(
        subscriptionLevels: [DonationSubscriptionLevel],
        subscription: Subscription,
    ) -> DonationSubscriptionLevel? {
        subscriptionLevels.first { $0.level == subscription.level }
    }

    public static func openDonateWebsite() {
        UIApplication.shared.open(TSConstants.donateUrl, options: [:], completionHandler: nil)
    }

    public static func nonBankPaymentStillProcessingActionSheet() -> ActionSheetController {
        let actionSheet = ActionSheetController(
            title: OWSLocalizedString(
                "SUSTAINER_STILL_PROCESSING_BADGE_TITLE",
                comment: "Action sheet title for Still Processing Badge sheet",
            ),
            message: OWSLocalizedString(
                "SUSTAINER_VIEW_STILL_PROCESSING_BADGE_MESSAGE",
                comment: "Action sheet message for Still Processing Badge sheet",
            ),
        )
        actionSheet.addAction(OWSActionSheets.okayAction)

        return actionSheet
    }

    static func completeIDEALDonation(
        donationType: Stripe.IDEALCallbackType,
        databaseStorage: SDSDatabaseStorage,
    ) async throws {
        let paymentStore = DependenciesBridge.shared.externalPendingIDEALDonationStore
        switch donationType {
        case let .monthly(success, clientSecret, intentId):
            guard
                let monthlyDonation = databaseStorage.read(block: { tx in
                    paymentStore.getPendingSubscription(tx: tx)
                })
            else {
                throw OWSGenericError("[Donations] Could not find iDEAL subscription to complete")
            }
            guard
                clientSecret == monthlyDonation.clientSecret,
                intentId == monthlyDonation.setupIntentId
            else {
                throw OWSAssertionError("[Donations] Pending iDEAL subscription details do not match")
            }
            guard success else {
                throw OWSGenericError("")
            }

            return try await DonationViewsUtil.completeMonthlyDonations(
                subscriberId: monthlyDonation.subscriberId,
                paymentType: .ideal(setupIntentId: monthlyDonation.setupIntentId),
                newSubscriptionLevel: monthlyDonation.newSubscriptionLevel,
                priorSubscriptionLevel: monthlyDonation.oldSubscriptionLevel,
                currencyCode: monthlyDonation.amount.currencyCode,
                databaseStorage: databaseStorage,
            )
        case let .oneTime(success, intentId):
            guard
                let oneTimePayment = databaseStorage.read(block: { tx in
                    paymentStore.getPendingOneTimeDonation(tx: tx)
                })
            else {
                throw OWSGenericError("[Donations] Could not find iDEAL payment to complete")
            }
            guard intentId == oneTimePayment.paymentIntentId else {
                throw OWSAssertionError("[Donations] Could not find iDEAL subscription to complete")
            }
            guard success else {
                throw OWSGenericError("")
            }

            return try await DonationViewsUtil.completeOneTimeDonation(
                paymentIntentId: oneTimePayment.paymentIntentId,
                amount: oneTimePayment.amount,
                paymentMethod: .ideal,
                databaseStorage: databaseStorage,
            )
        }
    }

    static func presentErrorSheet(
        from viewController: UIViewController,
        error: Error,
        mode donateMode: DonateViewController.DonateMode,
        badge: ProfileBadge,
        paymentMethod: DonationPaymentMethod?,
    ) {
        if let donationJobError = error as? DonationJobError {
            switch donationJobError {
            case .timeout:
                DonationViewsUtil.presentStillProcessingSheet(
                    from: viewController,
                    badge: badge,
                    paymentMethod: paymentMethod,
                    donateMode: donateMode,
                )
            case .assertion:
                DonationViewsUtil.presentBadgeCantBeAddedSheet(
                    from: viewController,
                    donateMode: donateMode,
                )
            }
        } else if let stripeError = error as? Stripe.StripeError {
            DonationViewsUtil.presentDonationErrorSheet(
                from: viewController,
                forDonationChargeErrorCode: stripeError.code,
                using: paymentMethod,
            )
        } else if let redirectError = error as? Stripe.RedirectAuthorizationError {
            DonationViewsUtil.presentRedirectAuthErrorSheet(
                from: viewController,
                donateMode: donateMode,
                paymentMethod: paymentMethod,
                error: redirectError,
            )
        } else {
            presentBadgeCantBeAddedSheet(
                from: viewController,
                donateMode: donateMode,
            )
        }
    }

    private static func presentRedirectAuthErrorSheet(
        from viewController: UIViewController,
        donateMode: DonateViewController.DonateMode,
        paymentMethod: DonationPaymentMethod?,
        error: Stripe.RedirectAuthorizationError,
    ) {
        let actionSheet = ActionSheetController(
            title: OWSLocalizedString(
                "SUSTAINER_VIEW_ERROR_AUTHORIZING_PAYMENT_TITLE",
                comment: "Action sheet title for Error Authorizing Payment sheet",
            ),
            message: DonationViewsUtil.localizedDonationFailureForPaymentAuthorizationRedirect(error: error),
        )

        actionSheet.addAction(.init(title: CommonStrings.okButton, style: .cancel, handler: { _ in
            switch paymentMethod {
            case .ideal:
                let idealDonationStore = DependenciesBridge.shared.externalPendingIDEALDonationStore
                SSKEnvironment.shared.databaseStorageRef.write { tx in
                    switch donateMode {
                    case .oneTime:
                        idealDonationStore.clearPendingOneTimeDonation(tx: tx)
                    case .monthly:
                        idealDonationStore.clearPendingSubscription(tx: tx)
                    }
                }
            case .applePay, .creditOrDebitCard, .paypal, .sepa, .none:
                break
            }
        }))
        viewController.presentActionSheet(actionSheet)
    }

    private static func presentDonationErrorSheet(
        from viewController: UIViewController,
        forDonationChargeErrorCode chargeErrorCode: String,
        using paymentMethod: DonationPaymentMethod?,
    ) {
        let errorSheetDetails = DonationViewsUtil.localizedDonationFailure(
            chargeErrorCode: chargeErrorCode,
            paymentMethod: paymentMethod,
        )
        let actionSheet = ActionSheetController(
            title: OWSLocalizedString(
                "SUSTAINER_VIEW_ERROR_PROCESSING_PAYMENT_TITLE",
                comment: "Action sheet title for Error Processing Payment sheet",
            ),
            message: errorSheetDetails.message,
        )

        actionSheet.addAction(.init(title: CommonStrings.okButton, style: .cancel, handler: nil))

        switch errorSheetDetails.actions {
        case .dismiss:
            break // No other actions needed
        case .learnMore(let learnMoreUrl):
            actionSheet.addAction(.init(title: CommonStrings.learnMore, style: .default) { _ in
                let vc = SFSafariViewController(url: learnMoreUrl)
                viewController.present(vc, animated: true, completion: nil)
            })
        }

        viewController.presentActionSheet(actionSheet)
    }

    private static func presentStillProcessingSheet(
        from viewController: UIViewController,
        badge: ProfileBadge,
        paymentMethod: DonationPaymentMethod?,
        donateMode: DonateViewController.DonateMode,
    ) {
        switch paymentMethod {
        case nil, .applePay, .creditOrDebitCard, .paypal:
            viewController.presentActionSheet(
                DonationViewsUtil.nonBankPaymentStillProcessingActionSheet(),
            )
        case .sepa, .ideal:
            let badgeIssueSheetMode: BadgeIssueSheetState.Mode = {
                switch donateMode {
                case .oneTime:
                    return .boostBankPaymentProcessing
                case .monthly:
                    return .subscriptionBankPaymentProcessing
                }
            }()

            viewController.present(
                BadgeIssueSheet(
                    badge: badge,
                    mode: badgeIssueSheetMode,
                ),
                animated: true,
            )
        }
    }

    private static func presentBadgeCantBeAddedSheet(
        from viewController: UIViewController,
        donateMode: DonateViewController.DonateMode,
    ) {
        let receiptCredentialRequestError = SSKEnvironment.shared.databaseStorageRef.read { tx -> DonationReceiptCredentialRequestError? in
            let resultStore = DependenciesBridge.shared.donationReceiptCredentialResultStore

            switch donateMode {
            case .oneTime:
                return resultStore.getRequestError(errorMode: .oneTimeBoost, tx: tx)
            case .monthly:
                // All subscriptions from this controller are being initiated.
                return resultStore.getRequestError(errorMode: .recurringSubscriptionInitiation, tx: tx)
            }
        }

        let title: String = {
            switch receiptCredentialRequestError?.errorCode {
            case .paymentFailed:
                return OWSLocalizedString("SUSTAINER_VIEW_ERROR_PROCESSING_PAYMENT_TITLE", comment: "Action sheet title for Error Processing Payment sheet")
            default:
                return OWSLocalizedString("SUSTAINER_VIEW_CANT_ADD_BADGE_TITLE", comment: "Action sheet title for Couldn't Add Badge sheet")
            }
        }()
        let message = OWSLocalizedString("SUSTAINER_VIEW_CANT_ADD_BADGE_MESSAGE", comment: "Action sheet message for Couldn't Add Badge sheet")

        let actionSheet = ActionSheetController(title: title, message: message)
        actionSheet.addAction(ActionSheetAction(
            title: OWSLocalizedString("CONTACT_SUPPORT", comment: "Button text to initiate an email to signal support staff"),
            style: .default,
            handler: { _ in
                let localizedSheetTitle = OWSLocalizedString(
                    "EMAIL_SIGNAL_TITLE",
                    comment: "Title for the fallback support sheet if user cannot send email",
                )
                let localizedSheetMessage = OWSLocalizedString(
                    "EMAIL_SIGNAL_MESSAGE",
                    comment: "Description for the fallback support sheet if user cannot send email",
                )
                guard ComposeSupportEmailOperation.canSendEmails else {
                    let fallbackSheet = ActionSheetController(
                        title: localizedSheetTitle,
                        message: localizedSheetMessage,
                    )
                    let buttonTitle = OWSLocalizedString("BUTTON_OKAY", comment: "Label for the 'okay' button.")
                    fallbackSheet.addAction(ActionSheetAction(title: buttonTitle, style: .default))
                    viewController.presentActionSheet(fallbackSheet)
                    return
                }
                let supportVC = ContactSupportViewController()
                supportVC.selectedFilter = .donationsAndBadges
                let navVC = OWSNavigationController(rootViewController: supportVC)
                viewController.presentFormSheet(navVC, animated: true)
            },
        ))

        actionSheet.addAction(ActionSheetAction(
            title: OWSLocalizedString("SUSTAINER_VIEW_SUBSCRIPTION_CONFIRMATION_NOT_NOW", comment: "Sustainer view Not Now Action sheet button"),
            style: .cancel,
            handler: nil,
        ))
        viewController.presentActionSheet(actionSheet)
    }
}