TM-SGNL-iOS/Signal/ConversationView/Components/CVComponentPaymentAttachment.swift
TeleMessage developers dde0620daf initial commit
2025-05-03 12:28:28 -07:00

518 lines
18 KiB
Swift

//
// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
//
// Copyright (c) 2022 Open Whisper Systems. All rights reserved.
//
import Foundation
import SignalServiceKit
public 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 }
let paymentHistoryItem = PaymentsHistoryModelItem(
paymentModel: paymentModel,
displayName: contactName
)
componentDelegate.didTapPayment(paymentHistoryItem)
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
}
}