// // Copyright 2022 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only // // // Copyright (c) 2022 Open Whisper Systems. All rights reserved. // import Foundation import SignalCoreKit import SignalServiceKit import SignalUI @objc public class CVComponentPaymentAttachment: CVComponentBase, CVComponent { public var componentKey: CVComponentKey { .paymentAttachment } private let paymentAttachment: CVComponentState.PaymentAttachment private let paymentModel: TSPaymentModel? private let paymentAmount: UInt64? private let contactName: String private let messageStatus: MessageReceiptStatus init( itemModel: CVItemModel, paymentAttachment: CVComponentState.PaymentAttachment, paymentModel: TSPaymentModel?, contactName: String, paymentAmount: UInt64?, messageStatus: MessageReceiptStatus? ) { self.paymentAttachment = paymentAttachment self.paymentModel = paymentModel self.contactName = contactName self.paymentAmount = paymentAmount // 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 { CVComponentViewPaymentAttachment() } public func configureForRendering( componentView componentViewParam: CVComponentView, cellMeasurement: CVCellMeasurement, componentDelegate: CVComponentDelegate ) { guard let componentView = componentViewParam as? CVComponentViewPaymentAttachment 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 paymentAttachment.notification.memoMessage != 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 mob = paymentAmount else { let text = OWSLocalizedString( "PAYMENTS_INFO_UNAVAILABLE_MESSAGE", comment: "Status indicator for invalid payments which could not be processed." ) return NSAttributedString(string: text) } let amount = TSPaymentAmount(currency: .mobileCoin, picoMob: mob) switch status { case .failed: return PaymentsFormat.formatInChatFailure(paymentAmount: amount) default: return PaymentsFormat.formatInChatSuccess(paymentAmount: 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 paymentType = paymentModel?.paymentType let interactionType = itemModel.interaction.interactionType switch (paymentType, interactionType, messageStatus) { case (_, _, .sending): text = OWSLocalizedString( "PAYMENTS_PAYMENT_STATUS_IN_CHAT_PROCESSING", comment: "Payment status context while sending" ) case (_, .incomingMessage, _), (.incomingPayment, _, _), (.incomingUnidentified, _, _): 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(format: format, contactName) 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(format: format, contactName) 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(format: format, contactName) 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(format: format, contactName) } 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(paymentAttachment.notification.memoMessage ?? ""), 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 paymentAttachment.notification.memoMessage != 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 CVComponentViewPaymentAttachment: NSObject, CVComponentView { fileprivate let hStackView = ManualStackView(name: "PaymentAttachment.hStackView") fileprivate let vStackView = ManualStackView(name: "PaymentAttachment.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() } } public override func handleTap( sender: UIGestureRecognizer, componentDelegate: CVComponentDelegate, componentView: CVComponentView, renderItem: CVRenderItem ) -> Bool { guard let paymentModel = paymentModel else { return false } componentDelegate.didTapPayment(paymentModel, displayName: contactName) return true } } // MARK: - Constants & Utils fileprivate extension String { static let measurementKey_hStack = "CVComponentPaymentAttachment.measurementKey_hStack" static let measurementKey_vStack = "CVComponentPaymentAttachment.measurementKey_vStack" } extension CVComponentPaymentAttachment: CVAccessibilityComponent { public var accessibilityDescription: String { return formatPaymentAmount(status: messageStatus).string } } fileprivate 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 } fileprivate 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 } } fileprivate 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 } }() } } fileprivate 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 } }