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

import SignalServiceKit
import SignalUI

class PaymentsDetailViewController: OWSTableViewController2 {

    private var paymentItem: PaymentsHistoryItem

    init(paymentItem: PaymentsHistoryItem) {
        self.paymentItem = paymentItem

        super.init()
    }

    override func viewDidLoad() {
        super.viewDidLoad()

        title = OWSLocalizedString(
            "SETTINGS_PAYMENTS_DETAIL_VIEW_TITLE",
            comment: "Label for the 'payments details' view of the app settings.",
        )

        updateTableContents()

        DependenciesBridge.shared.databaseChangeObserver.appendDatabaseChangeDelegate(self)
    }

    override func viewWillAppear(_ animated: Bool) {
        super.viewWillAppear(animated)

        updateTableContents()
    }

    override func viewDidAppear(_ animated: Bool) {
        super.viewDidAppear(animated)

        SSKEnvironment.shared.paymentsCurrenciesRef.updateConversionRates()

        if paymentItem.isUnread {
            SSKEnvironment.shared.databaseStorageRef.asyncWrite { [weak self] tx in
                self?.paymentItem.markAsRead(tx: tx)
            }
        }
    }

    override func themeDidChange() {
        super.themeDidChange()

        updateTableContents()
    }

    private func updateTableContents() {
        AssertIsOnMainThread()

        let contents = OWSTableContents()

        let headerSection = OWSTableSection()
        headerSection.shouldDisableCellSelection = true
        headerSection.add(OWSTableItem(
            customCellBlock: { [weak self] in
                let cell = OWSTableItem.newCell()
                self?.configureHeader(cell: cell)
                return cell
            },
            actionBlock: nil,
        ))
        contents.add(headerSection)

        contents.add(buildStatusSection())

        if
            DebugFlags.internalSettings,
            let payment = paymentItem as? PaymentsHistoryModelItem
        {
            contents.add(buildInternalSection(paymentModel: payment.paymentModel))
        }

        self.contents = contents
    }

    private func buildInternalSection(paymentModel: TSPaymentModel) -> OWSTableSection {
        let section = OWSTableSection()
        section.headerTitle = "Internal"

        section.add(OWSTableItem.item(
            name: "paymentType",
            accessoryText: paymentModel.paymentType.formatted,
            accessibilityIdentifier: "paymentType",
            actionBlock: nil,
        ))
        section.add(OWSTableItem.item(
            name: "paymentState",
            accessoryText: paymentModel.paymentState.formatted,
            accessibilityIdentifier: "paymentState",
            actionBlock: nil,
        ))
        section.add(OWSTableItem.item(
            name: "paymentFailure",
            accessoryText: paymentModel.paymentFailure.formatted,
            accessibilityIdentifier: "paymentFailure",
            actionBlock: nil,
        ))

        if let paymentAmount = paymentModel.paymentAmount {
            section.add(OWSTableItem.item(
                name: "paymentAmount",
                accessoryText: paymentAmount.formatted,
                accessibilityIdentifier: "paymentAmount",
                actionBlock: nil,
            ))
        }

        let dateFormatter = DateFormatter()
        dateFormatter.dateStyle = .medium
        dateFormatter.timeStyle = .medium

        section.add(OWSTableItem.item(
            name: "createdDate",
            accessoryText: dateFormatter.string(from: paymentModel.createdDate),
            accessibilityIdentifier: "createdDate",
            actionBlock: nil,
        ))

        guard let mobileCoin = paymentModel.mobileCoin else {
            return section
        }

        func hexFormatData(_ data: Data) -> String {
            return "0x\(data.hexadecimalString.prefix(8))…"
        }

        if let recipientPublicAddressData = mobileCoin.recipientPublicAddressData {
            section.add(.copyableItem(
                label: "recipientPublicAddressData",
                value: hexFormatData(recipientPublicAddressData),
                pasteboardValue: recipientPublicAddressData.base64EncodedString(),
            ))
        }

        if let transactionData = mobileCoin.transactionData {
            section.add(.copyableItem(
                label: "transactionData",
                value: hexFormatData(transactionData),
                pasteboardValue: transactionData.base64EncodedString(),
            ))
        }

        if let receiptData = mobileCoin.receiptData {
            section.add(.copyableItem(
                label: "receiptData",
                value: hexFormatData(receiptData),
                pasteboardValue: receiptData.base64EncodedString(),
            ))
        }

        for (index, publicKey) in (mobileCoin.incomingTransactionPublicKeys ?? []).enumerated() {
            section.add(.copyableItem(
                label: "incomingTxoPublicKey.\(index)",
                value: hexFormatData(publicKey),
                pasteboardValue: publicKey.base64EncodedString(),
            ))
        }

        for (index, keyImage) in (mobileCoin.spentKeyImages ?? []).enumerated() {
            section.add(.copyableItem(
                label: "spentKeyImage.\(index)",
                value: hexFormatData(keyImage),
                pasteboardValue: keyImage.base64EncodedString(),
            ))
        }

        for (index, publicKey) in (mobileCoin.outputPublicKeys ?? []).enumerated() {
            section.add(.copyableItem(
                label: "outputPublicKey.\(index)",
                value: hexFormatData(publicKey),
                pasteboardValue: publicKey.base64EncodedString(),
            ))
        }

        if let ledgerBlockDate = mobileCoin.ledgerBlockDate {
            section.add(OWSTableItem.item(
                name: "ledgerBlockDate",
                accessoryText: dateFormatter.string(from: ledgerBlockDate),
                accessibilityIdentifier: "ledgerBlockDate",
                actionBlock: nil,
            ))
        }

        if mobileCoin.ledgerBlockIndex > 0 {
            section.add(OWSTableItem.item(
                name: "ledgerBlockIndex",
                accessoryText: "\(mobileCoin.ledgerBlockIndex)",
                accessibilityIdentifier: "ledgerBlockIndex",
                actionBlock: nil,
            ))
        }

        if let feeAmount = mobileCoin.feeAmount {
            section.add(OWSTableItem.item(
                name: "feeAmount",
                accessoryText: feeAmount.formatted,
                accessibilityIdentifier: "feeAmount",
                actionBlock: nil,
            ))
        }

        return section
    }

    private func buildStatusSection() -> OWSTableSection {
        let section = OWSTableSection()
        section.customHeaderHeight = 16

        let paymentItem = self.paymentItem

        // Block
        if
            paymentItem.isUnidentified,
            let index = paymentItem.ledgerBlockIndex,
            index > 0
        {
            section.add(buildStatusItem(
                topText: OWSLocalizedString(
                    "SETTINGS_PAYMENTS_PAYMENT_DETAILS_BLOCK_INDEX",
                    comment: "Label for the 'MobileCoin block index' in the payment details view in the app settings.",
                ),
                bottomText: OWSFormat.formatUInt64(index),
            ))
        }

        // Type/Amount
        if
            let value = paymentItem.formattedPaymentAmount,
            !paymentItem.isFailed
        {
            let title: String
            if let senderOrRecipientAddress = paymentItem.address {
                let username = SSKEnvironment.shared.databaseStorageRef.read { tx in
                    return SSKEnvironment.shared.contactManagerRef.displayName(for: senderOrRecipientAddress, tx: tx).resolvedValue()
                }
                let titleFormat: String = {
                    switch paymentItem {
                    case let item where item.isIncoming:
                        return OWSLocalizedString(
                            "SETTINGS_PAYMENTS_PAYMENT_DETAILS_RECEIVED_FORMAT",
                            comment: "Format for indicator that you received a payment in the payment details view in the app settings. Embeds: {{ the user who sent you the payment }}.",
                        )
                    case let item where item.paymentState.messageReceiptStatus == .sending:
                        return OWSLocalizedString(
                            "SETTINGS_PAYMENTS_PAYMENT_DETAILS_SENDING_FORMAT",
                            comment: "Format for indicator that you sent a payment in the payment details view in the app settings. Embeds: {{ the user who you sent the payment to }}.",
                        )
                    default:
                        return OWSLocalizedString(
                            "SETTINGS_PAYMENTS_PAYMENT_DETAILS_SENT_FORMAT",
                            comment: "Format for indicator that you sent a payment in the payment details view in the app settings. Embeds: {{ the user who you sent the payment to }}.",
                        )
                    }
                }()
                title = String.nonPluralLocalizedStringWithFormat(titleFormat, username)
            } else {
                if paymentItem.isIncoming {
                    title = OWSLocalizedString(
                        "SETTINGS_PAYMENTS_PAYMENT_DETAILS_RECEIVED",
                        comment: "Indicates that you received a payment in the payment details view in the app settings.",
                    )
                } else {
                    title = OWSLocalizedString(
                        "SETTINGS_PAYMENTS_PAYMENT_DETAILS_SENT",
                        comment: "Indicates that you sent a payment in the payment details view in the app settings.",
                    )
                }
            }

            section.add(buildStatusItem(topText: title, bottomText: value))
        }

        // Fee
        if
            paymentItem.isOutgoing,
            let feeAmount = paymentItem.formattedFeeAmount, !paymentItem.isFailed
        {
            section.add(buildStatusItem(
                topText: OWSLocalizedString(
                    "SETTINGS_PAYMENTS_PAYMENT_DETAILS_FEE",
                    comment: "Label for the 'MobileCoin network fee' in the payment details view in the app settings.",
                ),
                bottomText: feeAmount,
            ))
        }

        // TODO: We might not want to include dates if an incoming
        //       transaction has not yet been verified.
        if let statusMessage = paymentItem.statusDescription(isLongForm: true) {
            section.add(buildStatusItem(
                topText: OWSLocalizedString(
                    "SETTINGS_PAYMENTS_PAYMENT_DETAILS_STATUS",
                    comment: "Label for the transaction status in the payment details view in the app settings.",
                ),
                bottomText: statusMessage,
                useFailedColor: paymentItem.isFailed,
            ))
        }

        // Sender
        do {
            let sender = { () -> String in
                if paymentItem.isOutgoing {
                    return CommonStrings.you
                }
                let displayName: DisplayName
                if let senderAci = paymentItem.address {
                    displayName = SSKEnvironment.shared.databaseStorageRef.read { tx in
                        return SSKEnvironment.shared.contactManagerRef.displayName(for: senderAci, tx: tx)
                    }
                } else {
                    displayName = .unknown
                }
                return displayName.resolvedValue()
            }()
            let value: String
            if let mcLedgerBlockDate = paymentItem.ledgerBlockDate {
                let senderFormat = OWSLocalizedString(
                    "SETTINGS_PAYMENTS_PAYMENT_DETAILS_SENDER_FORMAT",
                    comment: "Format for the sender info in the payment details view in the app settings. Embeds {{ %1$@ the name of the sender of the payment, %2$@ the date the transaction was sent }}.",
                )
                value = String.nonPluralLocalizedStringWithFormat(
                    senderFormat,
                    sender,
                    TSPaymentModel.formatDate(mcLedgerBlockDate, isLongForm: true),
                )
            } else {
                value = sender
            }
            let topTextLocalization: String = {
                switch paymentItem {
                case let item where item.isFailed:
                    return OWSLocalizedString(
                        "SETTINGS_PAYMENTS_PAYMENT_DETAILS_SENDER_ATTEMPTED",
                        comment: "Label for the sender in the payment details view in the app settings when the status is 'Failed'. Followed by sender name.",
                    )
                default:
                    return OWSLocalizedString(
                        "SETTINGS_PAYMENTS_PAYMENT_DETAILS_SENDER",
                        comment: "Label for the sender in the payment details view in the app settings.",
                    )
                }
            }()
            section.add(
                buildStatusItem(
                    topText: topTextLocalization,
                    bottomText: value,
                ),
            )
        }

        let footerText = (
            paymentItem.isDefragmentation
                ? OWSLocalizedString(
                    "SETTINGS_PAYMENTS_PAYMENT_DETAILS_STATUS_FOOTER_DEFRAGMENTATION",
                    comment: "Footer string for the status section of the payment details view in the app settings for defragmentation transactions.",
                )
                : OWSLocalizedString(
                    "SETTINGS_PAYMENTS_PAYMENT_DETAILS_STATUS_FOOTER",
                    comment: "Footer string for the status section of the payment details view in the app settings.",
                ),
        )
        let footerLabel = PaymentsViewUtils.buildTextWithLearnMoreLinkTextView(
            text: footerText,
            font: .dynamicTypeCaption1Clamped,
            learnMoreUrl: URL.Support.Payments.details,
        )
        let footerStack = UIStackView(arrangedSubviews: [footerLabel])
        footerStack.axis = .vertical
        footerStack.alignment = .fill
        footerStack.layoutMargins = .init(hMargin: Self.cellHInnerMargin, vMargin: 12)
        footerStack.isLayoutMarginsRelativeArrangement = true
        section.customFooterView = footerStack

        return section
    }

    private func buildStatusItem(
        topText: String,
        bottomText: String,
        useFailedColor: Bool = false,
    ) -> OWSTableItem {
        OWSTableItem(
            customCellBlock: {
                let cell = OWSTableItem.newCell()

                let topLabel = UILabel()
                topLabel.text = topText
                topLabel.textColor = useFailedColor ? .ows_accentRed : Theme.primaryTextColor
                topLabel.font = UIFont.dynamicTypeBodyClamped

                let bottomLabel = UILabel()
                bottomLabel.text = bottomText
                bottomLabel.textColor = Theme.secondaryTextAndIconColor
                bottomLabel.font = UIFont.dynamicTypeFootnoteClamped
                bottomLabel.numberOfLines = 0
                bottomLabel.lineBreakMode = .byWordWrapping

                let stack = UIStackView(arrangedSubviews: [topLabel, bottomLabel])
                stack.axis = .vertical
                stack.alignment = .fill

                cell.contentView.addSubview(stack)
                stack.autoPinEdgesToSuperviewMargins()

                return cell
            },
            actionBlock: nil,
        )
    }

    private func configureHeader(cell: UITableViewCell) {
        if let senderOrRecipientAddress = paymentItem.address {
            configureHeaderContact(cell: cell, address: senderOrRecipientAddress)
        } else {
            configureHeaderUnidentified(cell: cell)
        }
    }

    private func configureHeaderContact(
        cell: UITableViewCell,
        address: SignalServiceAddress,
    ) {

        var stackViews = [UIView]()

        let avatarView = ConversationAvatarView(sizeClass: .customDiameter(52), localUserDisplayMode: .asUser)
        stackViews.append(avatarView)
        stackViews.append(UIView.spacer(withHeight: 12))

        let usernameLabel = UILabel()
        usernameLabel.textColor = Theme.primaryTextColor
        usernameLabel.font = UIFont.dynamicTypeSubheadlineClamped
        usernameLabel.textAlignment = .center
        usernameLabel.numberOfLines = 0
        usernameLabel.lineBreakMode = .byWordWrapping
        stackViews.append(usernameLabel)
        stackViews.append(UIView.spacer(withHeight: 8))

        stackViews.append(buildAmountView())

        if let memoLabel = PaymentsViewUtils.buildMemoLabel(memoMessage: paymentItem.memoMessage) {
            stackViews.append(UIView.spacer(withHeight: 12))
            stackViews.append(memoLabel)
        }

        SSKEnvironment.shared.databaseStorageRef.read { transaction in
            avatarView.update(transaction) { config in
                config.dataSource = .address(address)
            }

            let displayName = SSKEnvironment.shared.contactManagerRef.displayName(for: address, tx: transaction).resolvedValue()
            let displayNameFormat = (
                self.paymentItem.isIncoming
                    ? OWSLocalizedString(
                        "SETTINGS_PAYMENTS_PAYMENT_USER_INCOMING_FORMAT",
                        comment: "Format string for the sender of an incoming payment. Embeds: {{ the name of the sender of the payment}}.",
                    )
                    : OWSLocalizedString(
                        "SETTINGS_PAYMENTS_PAYMENT_USER_OUTGOING_FORMAT",
                        comment: "Format string for the recipient of an outgoing payment. Embeds: {{ the name of the recipient of the payment}}.",
                    ),
            )
            usernameLabel.text = String.nonPluralLocalizedStringWithFormat(displayNameFormat, displayName)
        }

        let headerStack = UIStackView(arrangedSubviews: stackViews)
        headerStack.axis = .vertical
        headerStack.alignment = .center
        headerStack.layoutMargins = UIEdgeInsets(top: 24, leading: 0, bottom: 36, trailing: 0)
        headerStack.isLayoutMarginsRelativeArrangement = true
        cell.contentView.addSubview(headerStack)
        headerStack.autoPinEdgesToSuperviewMargins()
    }

    private func configureHeaderUnidentified(cell: UITableViewCell) {

        var stackViews = [UIView]()

        let avatarSize: UInt = 52
        let avatarView = PaymentsViewUtils.buildUnidentifiedTransactionAvatar(avatarSize: avatarSize)
        avatarView.autoSetDimensions(to: .square(CGFloat(avatarSize)))
        stackViews.append(avatarView)
        stackViews.append(UIView.spacer(withHeight: 12))

        let usernameLabel = UILabel()
        usernameLabel.text = paymentItem.displayName
        usernameLabel.textColor = Theme.primaryTextColor
        usernameLabel.font = UIFont.dynamicTypeBodyClamped
        usernameLabel.textAlignment = .center
        usernameLabel.numberOfLines = 0
        usernameLabel.lineBreakMode = .byWordWrapping
        stackViews.append(usernameLabel)
        stackViews.append(UIView.spacer(withHeight: 8))

        let amountLabel = UILabel()
        amountLabel.textColor = Theme.primaryTextColor
        amountLabel.font = UIFont.regularFont(ofSize: 54)
        amountLabel.textAlignment = .center
        amountLabel.adjustsFontSizeToFitWidth = true

        stackViews.append(buildAmountView())

        let headerStack = UIStackView(arrangedSubviews: stackViews)
        headerStack.axis = .vertical
        headerStack.alignment = .center
        headerStack.layoutMargins = UIEdgeInsets(top: 24, leading: 0, bottom: 36, trailing: 0)
        headerStack.isLayoutMarginsRelativeArrangement = true
        cell.contentView.addSubview(headerStack)
        headerStack.autoPinEdgesToSuperviewMargins()
    }

    private func buildAmountView() -> UIView {

        let amountLabel = UILabel()
        amountLabel.textColor = Theme.primaryTextColor
        amountLabel.font = UIFont.regularFont(ofSize: 54)
        amountLabel.textAlignment = .center
        amountLabel.adjustsFontSizeToFitWidth = true

        let amountWrapper = UIView.container()
        amountWrapper.addSubview(amountLabel)
        amountLabel.autoPinEdgesToSuperviewEdges()

        if paymentItem.isFailed {
            amountLabel.alpha = 0.3
        } else if !paymentItem.paymentState.isVerified {
            amountLabel.alpha = 0.5
        }

        if let amount = paymentItem.attributedPaymentAmount {
            amountLabel.attributedText = amount
        } else {
            amountLabel.text = " "

            let activityIndicator = UIActivityIndicatorView(style: .medium)
            amountWrapper.addSubview(activityIndicator)
            activityIndicator.autoCenterInSuperview()
            activityIndicator.startAnimating()
        }

        return amountWrapper
    }

    // MARK: -

    private func updateItem() {
        guard
            let _ = SSKEnvironment.shared.databaseStorageRef.read(block: { [weak self] tx in
                self?.paymentItem.reload(tx: tx)
            })
        else {
            navigationController?.popViewController(animated: true)
            return
        }

        updateTableContents()
    }

}

// MARK: -

extension PaymentsDetailViewController: DatabaseChangeDelegate {

    func databaseChangesDidUpdate(databaseChanges: DatabaseChanges) {
        guard databaseChanges.didUpdate(tableName: TSPaymentModel.databaseTableName) else {
            return
        }

        updateItem()
    }

    func databaseChangesDidUpdateExternally() {
        updateItem()
    }

    func databaseChangesDidReset() {
        updateItem()
    }
}