504 lines
18 KiB
Swift
504 lines
18 KiB
Swift
//
|
|
// 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(format: 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(format: 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(format: 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(format: 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()
|
|
}
|
|
}
|
|
|
|
public override 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
|
|
|
|
fileprivate 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
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|
|
}
|