import Foundation import UIKit import Display import AccountContext import SwiftSignalKit import TelegramCore import TelegramPresentationData import PresentationDataUtils import ComponentFlow import ViewControllerComponent import SheetComponent import ResizableSheetComponent import BundleIconComponent import MultilineTextComponent import ButtonComponent import PlainButtonComponent import LottieComponent import PremiumDiamondComponent import GlassBarButtonComponent import GlassControls import TableComponent import ContextUI import TelegramStringFormatting import TextFormat import TextFieldComponent import UndoUI import TooltipUI import AvatarComponent import ShimmeringMask import WalletContext import PasscodeCore import WalletPagerComponent import WalletCollectibleHeaderComponent import WalletSendScreen import TextSelectionNode import Pasteboard import Speak import TranslateUI import TelegramUIPreferences import TelegramNotices import InvisibleInkDustNode import WalletAuthorizationUI private struct WalletTransactionPreviewSource: Equatable { let id: String let address: String let recipientPeer: EnginePeer? let amount: Int64 let requestedAmount: Int64 let isSendAll: Bool let comment: String? let commentEncrypted: Bool let collectible: WalletContext.Collectible? let initialFee: Int64? let preparedTransfer: WalletContext.PreparedTransfer? init(preparedTransfer: WalletContext.PreparedTransfer) { self.id = preparedTransfer.id self.address = preparedTransfer.recipient self.recipientPeer = nil self.amount = preparedTransfer.amount self.requestedAmount = preparedTransfer.requestedAmount self.isSendAll = preparedTransfer.isSendAll self.comment = preparedTransfer.comment self.commentEncrypted = preparedTransfer.commentEncrypted self.collectible = preparedTransfer.collectible self.initialFee = preparedTransfer.fee self.preparedTransfer = preparedTransfer } init(address: String, recipientPeer: EnginePeer?, amount: Int64, sendAll: Bool, comment: String?, collectible: WalletContext.Collectible?, initialFee: Int64?) { self.id = UUID().uuidString self.address = address self.recipientPeer = recipientPeer self.amount = amount self.requestedAmount = amount self.isSendAll = sendAll self.comment = comment self.commentEncrypted = false self.collectible = collectible self.initialFee = initialFee self.preparedTransfer = nil } } private enum WalletTransactionContentMode { case transaction(WalletContext.Transaction) case preview( walletContext: WalletContext, source: WalletTransactionPreviewSource, dismissSendScreen: () -> Void ) } private final class TransactionCommentComponent: Component { let id: String let theme: PresentationTheme let strings: PresentationStrings let text: NSAttributedString let controller: () -> ViewController? let performAction: (NSAttributedString, TextSelectionAction) -> Void init( id: String, theme: PresentationTheme, strings: PresentationStrings, text: NSAttributedString, controller: @escaping () -> ViewController?, performAction: @escaping (NSAttributedString, TextSelectionAction) -> Void ) { self.id = id self.theme = theme self.strings = strings self.text = text self.controller = controller self.performAction = performAction } static func ==(lhs: TransactionCommentComponent, rhs: TransactionCommentComponent) -> Bool { if lhs.id != rhs.id { return false } if lhs.theme !== rhs.theme { return false } if lhs.strings !== rhs.strings { return false } if lhs.text != rhs.text { return false } return true } final class View: UIView { private let text = ComponentView() private var textSelectionNode: TextSelectionNode? private weak var selectionTheme: PresentationTheme? private weak var selectionStrings: PresentationStrings? private var component: TransactionCommentComponent? override init(frame: CGRect) { super.init(frame: frame) self.clipsToBounds = false } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } override func point(inside point: CGPoint, with event: UIEvent?) -> Bool { if self.bounds.contains(point) { return true } if let textSelectionNode { let localPoint = self.convert(point, to: textSelectionNode.view) return textSelectionNode.view.hitTest(localPoint, with: event) != nil } return false } override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { guard !self.isHidden, self.alpha > 0.0, self.isUserInteractionEnabled, self.point(inside: point, with: event) else { return nil } if let textSelectionNode { let localPoint = self.convert(point, to: textSelectionNode.view) if let result = textSelectionNode.view.hitTest(localPoint, with: event) { return result } } return super.hitTest(point, with: event) } override func didMoveToWindow() { super.didMoveToWindow() if self.window == nil { self.removeTextSelectionNode() } } func cancelSelection() { // Cancel the pending long press as well as any visible selection/menu. if let recognizer = self.textSelectionNode?.recognizer, recognizer.isEnabled { recognizer.isEnabled = false recognizer.isEnabled = true } self.textSelectionNode?.cancelSelection() } private func removeTextSelectionNode() { guard let textSelectionNode = self.textSelectionNode else { return } self.textSelectionNode = nil textSelectionNode.cancelSelection() textSelectionNode.highlightAreaNode.view.removeFromSuperview() textSelectionNode.view.removeFromSuperview() } private func ensureTextSelectionNode(textView: MultilineTextComponent.View) { guard let component = self.component else { return } if self.selectionTheme !== component.theme || self.selectionStrings !== component.strings { self.removeTextSelectionNode() } self.selectionTheme = component.theme self.selectionStrings = component.strings let textSelectionNode: TextSelectionNode if let current = self.textSelectionNode { textSelectionNode = current } else { let accentColor = component.theme.actionSheet.controlAccentColor textSelectionNode = TextSelectionNode( theme: TextSelectionTheme( selection: accentColor.withMultipliedAlpha(0.5), knob: accentColor, isDark: component.theme.overallDarkAppearance ), strings: component.strings, textNodeOrView: .view(textView), updateIsActive: { _ in }, present: { [weak self] controller, arguments in self?.component?.controller()?.presentInGlobalOverlay(controller, with: arguments) }, rootView: { [weak self] in return self?.component?.controller()?.displayNode.view }, performAction: { [weak self] text, action in self?.component?.performAction(text, action) } ) textSelectionNode.enableQuote = false textSelectionNode.enableSpeak = isSpeakSelectionEnabled() textSelectionNode.cancelSelectionOnOutsideTap = true self.textSelectionNode = textSelectionNode self.insertSubview(textSelectionNode.highlightAreaNode.view, belowSubview: textView) self.addSubview(textSelectionNode.view) } textSelectionNode.enableAutomaticScrolling = true textSelectionNode.enableCopy = true textSelectionNode.enableShare = true } func update( component: TransactionCommentComponent, availableSize: CGSize, transition: ComponentTransition ) -> CGSize { if let previousComponent = self.component, previousComponent.id != component.id || previousComponent.text != component.text || previousComponent.theme !== component.theme { self.removeTextSelectionNode() } self.component = component let textSize = self.text.update( transition: transition, component: AnyComponent(MultilineTextComponent( text: .plain(component.text), maximumNumberOfLines: 0, insets: UIEdgeInsets(top: 2.0, left: 0.0, bottom: 2.0, right: 0.0) )), environment: {}, containerSize: CGSize(width: availableSize.width, height: .greatestFiniteMagnitude) ) if let textView = self.text.view as? MultilineTextComponent.View { if textView.superview == nil { self.addSubview(textView) } textView.frame = CGRect(origin: .zero, size: textSize) self.ensureTextSelectionNode(textView: textView) if let textSelectionNode = self.textSelectionNode { let shouldUpdateLayout = textSelectionNode.frame.size != textSize textSelectionNode.frame = CGRect(origin: .zero, size: textSize) textSelectionNode.highlightAreaNode.frame = textSelectionNode.frame if shouldUpdateLayout { textSelectionNode.updateLayout() } } } return textSize } } func makeView() -> View { return View(frame: .zero) } func update( view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: ComponentTransition ) -> CGSize { return view.update(component: self, availableSize: availableSize, transition: transition) } } private final class TransactionCommentContainerView: UIView, UIScrollViewDelegate { private final class ScrollView: UIScrollView { override func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool { if gestureRecognizer === self.panGestureRecognizer { let velocity = self.panGestureRecognizer.velocity(in: self) if abs(velocity.x) > abs(velocity.y) { return false } } return super.gestureRecognizerShouldBegin(gestureRecognizer) } } private let scrollView = ScrollView() private let fadeMask = CAGradientLayer() private let selectionInset: CGFloat = 12.0 private var contentId: String? private var text: String? private var isUpdating = false weak var textSelectionView: TransactionCommentComponent.View? var contentView: UIView { return self.scrollView } override init(frame: CGRect) { super.init(frame: frame) // The stationary mask clips both the bubble background and its text. self.scrollView.clipsToBounds = false self.scrollView.contentInsetAdjustmentBehavior = .never self.scrollView.showsVerticalScrollIndicator = false self.scrollView.showsHorizontalScrollIndicator = false self.scrollView.bounces = false self.scrollView.isDirectionalLockEnabled = true self.scrollView.scrollsToTop = false self.scrollView.delaysContentTouches = false self.scrollView.delegate = self self.addSubview(self.scrollView) self.fadeMask.startPoint = CGPoint(x: 0.5, y: 0.0) self.fadeMask.endPoint = CGPoint(x: 0.5, y: 1.0) } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } override func point(inside point: CGPoint, with event: UIEvent?) -> Bool { if self.layer.mask != nil && !self.fadeMask.frame.contains(point) { return false } if self.bounds.contains(point) { return true } if let textSelectionView = self.textSelectionView { return textSelectionView.hitTest(self.convert(point, to: textSelectionView), with: event) != nil } return false } override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { guard !self.isHidden, self.alpha > 0.0, self.isUserInteractionEnabled, self.point(inside: point, with: event) else { return nil } if let textSelectionView = self.textSelectionView, let result = textSelectionView.hitTest(self.convert(point, to: textSelectionView), with: event) { return result } return super.hitTest(point, with: event) } func scrollViewWillBeginDragging(_ scrollView: UIScrollView) { self.textSelectionView?.cancelSelection() } func scrollViewDidScroll(_ scrollView: UIScrollView) { if !self.isUpdating { self.updateScrollFade() } } private func updateScrollFade() { CATransaction.begin() CATransaction.setDisableActions(true) defer { CATransaction.commit() } let size = self.scrollView.bounds.size let maxOffset = max(0.0, self.scrollView.contentSize.height - size.height) guard maxOffset > 0.0, size.height > 0.0 else { self.layer.mask = nil return } let fadeHeight = min(12.0, size.height / 2.0) let offset = max(0.0, min(maxOffset, self.scrollView.contentOffset.y)) let topFade = min(1.0, offset / fadeHeight) let bottomFade = min(1.0, (maxOffset - offset) / fadeHeight) var maskFrame = CGRect(origin: .zero, size: size).insetBy(dx: -self.selectionInset, dy: 0.0) // At either end there is no hidden text, but selection knobs can extend past the viewport. if offset == 0.0 { maskFrame.origin.y -= self.selectionInset maskFrame.size.height += self.selectionInset } if offset == maxOffset { maskFrame.size.height += self.selectionInset } self.fadeMask.frame = maskFrame self.fadeMask.locations = [ 0.0, NSNumber(value: Double(fadeHeight / maskFrame.height)), NSNumber(value: Double(1.0 - fadeHeight / maskFrame.height)), 1.0 ] self.fadeMask.colors = [ UIColor.white.withAlphaComponent(1.0 - topFade).cgColor, UIColor.white.cgColor, UIColor.white.cgColor, UIColor.white.withAlphaComponent(1.0 - bottomFade).cgColor ] self.layer.mask = self.fadeMask } func update(contentSize: CGSize, maxHeight: CGFloat, id: String, text: String?) -> CGSize { self.isUpdating = true defer { self.isUpdating = false } let resetOffset = self.contentId != id || self.text != text self.contentId = id self.text = text let previousOffset = self.scrollView.contentOffset.y let size = CGSize(width: contentSize.width, height: min(contentSize.height, maxHeight)) let maxOffset = max(0.0, contentSize.height - size.height) self.scrollView.isScrollEnabled = maxOffset > 0.0 let scrollFrame = CGRect(origin: .zero, size: size) if self.scrollView.frame != scrollFrame { self.scrollView.frame = scrollFrame } if self.scrollView.contentSize != contentSize { self.scrollView.contentSize = contentSize } let contentOffset = CGPoint(x: 0.0, y: resetOffset ? 0.0 : max(0.0, min(maxOffset, previousOffset))) if self.scrollView.contentOffset != contentOffset { self.scrollView.contentOffset = contentOffset } self.updateScrollFade() return size } } private protocol WalletTransactionContentController: AnyObject { var walletPresentationData: (initial: PresentationData, signal: Signal)? { get } func setCloseAction(id: String, action: @escaping (Bool) -> Void) func requestClose(animated: Bool) func dismissAllTooltips() } private func walletTransactionModeId(_ mode: WalletTransactionContentMode) -> String { switch mode { case let .transaction(transaction): return "transaction:\(transaction.presentationId)" case let .preview(_, source, _): return "preview:\(source.id)" } } private final class WalletTransactionFeePlaceholderComponent: Component { let color: UIColor init(color: UIColor) { self.color = color } static func ==(lhs: WalletTransactionFeePlaceholderComponent, rhs: WalletTransactionFeePlaceholderComponent) -> Bool { return lhs.color.isEqual(rhs.color) } final class View: UIView { private let shimmerView = ShimmeringMaskView(peakAlpha: 0.3, duration: 1.6) private let shape = ComponentView() override init(frame: CGRect) { super.init(frame: frame) self.isUserInteractionEnabled = false self.shimmerView.isUserInteractionEnabled = false self.addSubview(self.shimmerView) } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } func update( component: WalletTransactionFeePlaceholderComponent, state: EmptyComponentState, transition: ComponentTransition ) -> CGSize { let size = CGSize(width: 128.0, height: 16.0) self.shape.parentState = state let shapeSize = self.shape.update( transition: transition, component: AnyComponent(RoundedRectangle( color: component.color, cornerRadius: 8.0, size: size )), environment: {}, containerSize: size ) if let shapeView = self.shape.view { if shapeView.superview !== self.shimmerView.contentView { self.shimmerView.contentView.addSubview(shapeView) } transition.setFrame(view: shapeView, frame: CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: shapeSize)) } transition.setFrame(view: self.shimmerView, frame: CGRect(origin: CGPoint(x: 0.0, y: 1.0), size: size)) self.shimmerView.update( size: size, containerWidth: size.width, offsetX: 0.0, gradientWidth: 60.0, transition: transition ) return CGSize(width: size.width, height: size.height + 4.0) } } func makeView() -> View { return View(frame: .zero) } func update( view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: ComponentTransition ) -> CGSize { return view.update(component: self, state: state, transition: transition) } } private final class SendButtonContentComponent: Component { let text: String let color: UIColor init(text: String, color: UIColor) { self.text = text self.color = color } static func ==(lhs: SendButtonContentComponent, rhs: SendButtonContentComponent) -> Bool { return lhs.text == rhs.text && lhs.color == rhs.color } final class View: UIView { private let backgroundLayer = SimpleLayer() private let title = ComponentView() override init(frame: CGRect) { super.init(frame: frame) self.layer.addSublayer(self.backgroundLayer) self.backgroundLayer.masksToBounds = true } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } func update( component: SendButtonContentComponent, availableSize: CGSize, transition: ComponentTransition ) -> CGSize { let titleSize = self.title.update( transition: transition, component: AnyComponent(MultilineTextComponent( text: .plain(NSAttributedString( string: component.text, font: Font.regular(11.0), textColor: component.color )), maximumNumberOfLines: 1 )), environment: {}, containerSize: availableSize ) let size = CGSize(width: titleSize.width + 12.0, height: 18.0) if let titleView = self.title.view { if titleView.superview == nil { self.addSubview(titleView) } transition.setFrame( view: titleView, frame: CGRect( x: floorToScreenPixels((size.width - titleSize.width) / 2.0), y: floorToScreenPixels((size.height - titleSize.height) / 2.0), width: titleSize.width, height: titleSize.height ) ) } self.backgroundLayer.backgroundColor = component.color.withAlphaComponent(0.1).cgColor self.backgroundLayer.cornerRadius = size.height / 2.0 transition.setFrame(layer: self.backgroundLayer, frame: CGRect(origin: .zero, size: size)) return size } } func makeView() -> View { return View(frame: .zero) } func update( view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: ComponentTransition ) -> CGSize { return view.update(component: self, availableSize: availableSize, transition: transition) } } private enum CounterpartyContentId: Hashable { case peer(EnginePeer.Id) case name(String) case address(String) case unknown } private enum CounterpartyRowId: Hashable { case withSendButton case content(CounterpartyContentId) } private final class CounterpartyRowComponent: CombinedComponent { typealias EnvironmentType = Empty let counterparty: AnyComponentWithIdentity let addressText: NSAttributedString? let sendButton: AnyComponent let spacing: CGFloat let alignSendButtonToTop: Bool init( counterparty: AnyComponentWithIdentity, addressText: NSAttributedString? = nil, sendButton: AnyComponent, spacing: CGFloat, alignSendButtonToTop: Bool ) { self.counterparty = counterparty self.addressText = addressText self.sendButton = sendButton self.spacing = spacing self.alignSendButtonToTop = alignSendButtonToTop } static func ==(lhs: CounterpartyRowComponent, rhs: CounterpartyRowComponent) -> Bool { return lhs.counterparty == rhs.counterparty && lhs.addressText == rhs.addressText && lhs.sendButton == rhs.sendButton && lhs.spacing == rhs.spacing && lhs.alignSendButtonToTop == rhs.alignSendButtonToTop } static var body: Body { let counterparties = ChildMap(environment: Empty.self, keyedBy: AnyHashable.self) let sendButton = Child(environment: Empty.self) return { context in let sendButton = sendButton.update( component: context.component.sendButton, availableSize: context.availableSize, transition: context.transition ) let counterpartyWidth = max(0.0, context.availableSize.width - sendButton.size.width - context.component.spacing) let displaysSendButton: Bool if let addressText = context.component.addressText { let (addressLayout, _) = TextView.asyncLayout(nil)(TextNodeLayoutArguments( attributedString: addressText, maximumNumberOfLines: 0, truncationType: .end, constrainedSize: CGSize(width: counterpartyWidth, height: context.availableSize.height), lineSpacing: 0.12 )) displaysSendButton = counterpartyWidth > 0.0 && addressLayout.numberOfLines <= 3 } else { displaysSendButton = true } let counterparty = counterparties[context.component.counterparty.id].update( component: context.component.counterparty.component, availableSize: CGSize( width: displaysSendButton ? counterpartyWidth : context.availableSize.width, height: context.availableSize.height ), transition: context.transition ) let size = CGSize( width: counterparty.size.width + (displaysSendButton ? context.component.spacing + sendButton.size.width : 0.0), height: displaysSendButton ? max(counterparty.size.height, sendButton.size.height) : counterparty.size.height ) context.add(counterparty.position(CGPoint( x: counterparty.size.width / 2.0, y: size.height / 2.0 ))) context.add(sendButton.opacity(displaysSendButton ? 1.0 : 0.0).position(CGPoint( x: counterparty.size.width + context.component.spacing + sendButton.size.width / 2.0, y: context.component.alignSendButtonToTop ? sendButton.size.height / 2.0 : size.height / 2.0 ))) return size } } } private final class WalletTransactionKeyUpdateHeaderComponent: Component { let theme: PresentationTheme let strings: PresentationStrings init(theme: PresentationTheme, strings: PresentationStrings) { self.theme = theme self.strings = strings } static func ==(lhs: WalletTransactionKeyUpdateHeaderComponent, rhs: WalletTransactionKeyUpdateHeaderComponent) -> Bool { return lhs.theme === rhs.theme && lhs.strings === rhs.strings } final class View: UIView { private let backgroundView = UIImageView() private let iconView = UIImageView() private let title = ComponentView() override init(frame: CGRect) { super.init(frame: frame) self.isUserInteractionEnabled = false self.backgroundView.image = generateGradientFilledCircleImage( diameter: 90.0, colors: [ UIColor(rgb: 0x9aa0ac).cgColor, UIColor(rgb: 0xb8bdc7).cgColor ] as NSArray, direction: .vertical ) self.iconView.image = generateTintedImage( image: UIImage(bundleImageName: "Wallet/TransactionKeyLarge"), color: .white ) self.iconView.contentMode = .scaleAspectFit self.addSubview(self.backgroundView) self.addSubview(self.iconView) } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } func update(component: WalletTransactionKeyUpdateHeaderComponent, availableSize: CGSize, transition: ComponentTransition) -> CGSize { let iconBackgroundFrame = CGRect( x: floorToScreenPixels((availableSize.width - 90.0) / 2.0), y: 76.0 - 90.0 / 2.0, width: 90.0, height: 90.0 ) transition.setFrame(view: self.backgroundView, frame: iconBackgroundFrame) transition.setFrame(view: self.iconView, frame: CGRect( x: iconBackgroundFrame.midX - 26.0, y: iconBackgroundFrame.midY - 26.0, width: 52.0, height: 52.0 )) let titleSize = self.title.update( transition: transition, component: AnyComponent(MultilineTextComponent( text: .plain(NSAttributedString( string: component.strings.Wallet_Transaction_KeyUpdate, font: Font.semibold(22.0), textColor: component.theme.actionSheet.primaryTextColor, paragraphAlignment: .center )), horizontalAlignment: .center, maximumNumberOfLines: 1 )), environment: {}, containerSize: CGSize(width: availableSize.width - 100.0, height: .greatestFiniteMagnitude) ) let titleFrame = CGRect( x: floorToScreenPixels((availableSize.width - titleSize.width) / 2.0), y: floorToScreenPixels(151.0 - titleSize.height / 2.0), width: titleSize.width, height: titleSize.height ) if let titleView = self.title.view { if titleView.superview == nil { self.addSubview(titleView) } transition.setFrame(view: titleView, frame: titleFrame) } return CGSize(width: availableSize.width, height: titleFrame.maxY) } } func makeView() -> View { return View(frame: .zero) } func update( view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: ComponentTransition ) -> CGSize { return view.update(component: self, availableSize: availableSize, transition: transition) } } private final class WalletTransactionContentComponent: Component { typealias EnvironmentType = ViewControllerComponentContainer.Environment let context: AccountContext let mode: WalletTransactionContentMode let walletContext: WalletContext? let fromChat: Bool let maxCommentHeight: CGFloat let openExplorer: (String) -> Void let animateOut: ActionSlot> let pagerPosition: WalletPagerView.ItemPosition? init( context: AccountContext, mode: WalletTransactionContentMode, walletContext: WalletContext?, fromChat: Bool, maxCommentHeight: CGFloat, openExplorer: @escaping (String) -> Void, animateOut: ActionSlot>, pagerPosition: WalletPagerView.ItemPosition? = nil ) { self.context = context self.mode = mode self.walletContext = walletContext self.fromChat = fromChat self.maxCommentHeight = maxCommentHeight self.openExplorer = openExplorer self.animateOut = animateOut self.pagerPosition = pagerPosition } static func ==(lhs: WalletTransactionContentComponent, rhs: WalletTransactionContentComponent) -> Bool { if lhs.context !== rhs.context || lhs.walletContext !== rhs.walletContext || lhs.fromChat != rhs.fromChat || lhs.maxCommentHeight != rhs.maxCommentHeight || lhs.pagerPosition !== rhs.pagerPosition { return false } switch (lhs.mode, rhs.mode) { case let (.transaction(lhsTransaction), .transaction(rhsTransaction)): return lhsTransaction == rhsTransaction case let (.preview(lhsContext, lhsSource, _), .preview(rhsContext, rhsSource, _)): return lhsContext === rhsContext && lhsSource == rhsSource default: return false } } final class View: UIView { private struct Roll { private static let reach: CGFloat = 18.0 private var x: CGFloat = 0.0 private var v: CGFloat = 0.0 private var last: CFTimeInterval? private var sheetX: CGFloat = 0.0 private var sheetV: CGFloat = 0.0 private(set) var isAnimating = false private static func target(_ sheet: CGFloat) -> CGFloat { return min(Self.reach, max(-Self.reach, sheet * 0.06)) } mutating func rebase(sheet: CGFloat, at now: CFTimeInterval) { guard self.last != nil else { return } self.sheetX = sheet self.sheetV = 0.0 self.last = now self.isAnimating = abs(self.x - Self.target(sheet)) >= 0.01 || abs(self.v) >= 0.01 } mutating func step(sheet: CGFloat, at now: CFTimeInterval) -> CGFloat { let target = Self.target(sheet) guard let last = self.last else { self.x = target self.sheetX = sheet self.last = now return self.x } self.isAnimating = self.isAnimating || self.sheetX != sheet let dt = CGFloat(min(max(now - last, 0.0), 0.05)) self.last = now guard dt > 0.0005 else { return self.x } let raw = (sheet - self.sheetX) / dt self.sheetX = sheet let previousVelocity = self.sheetV self.sheetV += (raw - self.sheetV) * min(1.0, dt * 12.0) self.v -= (self.sheetV - previousVelocity) * 0.06 let w: CGFloat = 2.0 * .pi * 1.7 var remaining = dt while remaining > 0.0 { let h = min(remaining, 1.0 / 240.0) self.v += (w * w * (target - self.x) - 2.0 * 0.45 * w * self.v) * h self.x += self.v * h remaining -= h } let edge = Self.reach * 1.2 if abs(self.x) > edge { self.x = self.x > 0.0 ? edge : -edge self.v = 0.0 } // Let the filtered sheet velocity decay too, so a pending braking impulse is not lost. self.isAnimating = abs(self.x - target) >= 0.01 || abs(self.v) >= 0.01 || abs(self.sheetV) >= 0.01 if !self.isAnimating { self.x = target self.v = 0.0 self.sheetV = 0.0 } return self.x } } private enum PreviewOperation: Equatable { case ready case preparing case authorizing case submitting case submissionUnknown case confirmed var displaysProgress: Bool { switch self { case .authorizing, .submitting: return true case .ready, .preparing, .submissionUnknown, .confirmed: return false } } } private let controlButtons = ComponentView() private let keyUpdateHeader = ComponentView() private let collectibleHeader = ComponentView() private var gramAnimation = ComponentView() private var pagerPosition: WalletPagerView.ItemPosition? private var roll = Roll() private var isReturningToWallet = false private var hasTransferredDiamond = false private let amount = ComponentView() private let usdValue = ComponentView() private let processingDot = ComponentView() private let processingText = ComponentView() private let commentContainerView = TransactionCommentContainerView(frame: .zero) private let commentBackgroundView = WalletSendCommentBackgroundView(frame: .zero) private var commentText = ComponentView() private let commentButton = ComponentView() private var commentDustNode: InvisibleInkDustNode? private var decryptedComment: String? private var commentDecryptionInProgress = false private var commentDecryptionRevision = 0 private var commentWalletIdentity: String? private var isImportingCommentKey = false private weak var commentRecoveryController: ViewController? private let commentDecryptionDisposable = MetaDisposable() private let commentAuthorizationDisposable = MetaDisposable() private var restorationSession: PasscodeSession? private var isRestoringCommentKey = false private var commentIsVisible = true private let table = ComponentView() private let gramInfoButton = ComponentView() private let inputBackground = ComponentView() private let inputField = ComponentView() private let commentEncryptionButton = ComponentView() private var displayedCommentEncrypted: Bool? private let actionButton = ComponentView() private var isClosing = false private var component: WalletTransactionContentComponent? private var environment: EnvironmentType? private weak var componentState: EmptyComponentState? private var modeId: String? private var transaction: WalletContext.Transaction? private var walletContext: WalletContext? private var previewSource: WalletTransactionPreviewSource? private var previewComment: String? private var previewCommentEncrypted = false private var commentSession: PasscodeSession? private var commentSessionGeneration: UInt64 = 0 private var isAuthorizingComment = false private var commentSessionAvailable = true private let commentSessionDisposable = MetaDisposable() private let commentEnvironmentDisposable = MetaDisposable() private let commentCredentialChangesDisposable = MetaDisposable() private var preparedTransfer: WalletContext.PreparedTransfer? private var submittedTransfer: WalletContext.PendingTransfer? private weak var transferResultNavigationController: NavigationController? private var displayedFee: Int64? private var preparedTransferNeedsRefresh = false private var dismissSendScreen: (() -> Void)? private var didDismissSendScreen = false private var previewOperation: PreviewOperation = .ready private var submissionStage: WalletContext.TransferSubmissionStage? private var preparingForSend = false private var previewTimestamp = Int32(Date().timeIntervalSince1970) private var latestWalletState: WalletContext.State? private var didShowSuccess = false private let inputExternalState = TextFieldComponent.ExternalState() private var commentRevision = 0 private var isApplyingInput = false private let walletDisposable = MetaDisposable() private let gaslessInfoDisposable = MetaDisposable() private let transferDisposable = MetaDisposable() private let discardTransferDisposables = DisposableSet() private let hapticFeedback = HapticFeedback() private var currentSpeechHolder: SpeechSynthesizerHolder? private var isUpdating = false private func currentPresentationData(for component: WalletTransactionContentComponent) -> (initial: PresentationData, signal: Signal) { let presentationData = component.context.sharedContext.currentPresentationData.with { $0 } if let updatedPresentationData = (self.environment?.controller() as? WalletTransactionContentController)?.walletPresentationData { return ( initial: presentationData.withUpdated(theme: self.environment?.theme ?? updatedPresentationData.initial.theme), signal: updatedPresentationData.signal ) } return (initial: presentationData, signal: component.context.sharedContext.presentationData) } override init(frame: CGRect) { super.init(frame: frame) self.addSubview(self.commentContainerView) self.commentContainerView.contentView.addSubview(self.commentBackgroundView) self.inputExternalState.updated = { [weak self] in self?.inputTextUpdated() } } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } deinit { self.pagerPosition?.updated = nil self.restorationSession?.invalidate() self.commentSession?.invalidate() self.commentSessionDisposable.dispose() self.commentEnvironmentDisposable.dispose() self.commentCredentialChangesDisposable.dispose() self.discardCurrentPreparedTransfer() self.walletDisposable.dispose() self.gaslessInfoDisposable.dispose() self.transferDisposable.dispose() self.discardTransferDisposables.dispose() self.commentDecryptionDisposable.dispose() self.commentAuthorizationDisposable.dispose() } override func didMoveToWindow() { super.didMoveToWindow() self.pagerPositionChanged(reset: true) } private func setPagerPosition(_ position: WalletPagerView.ItemPosition?) { if self.pagerPosition !== position { self.pagerPosition?.updated = nil (self.gramAnimation.view as? InteractiveDiamondComponent.View)?.externalMotion = nil self.pagerPosition = position self.roll = Roll() position?.updated = { [weak self] reset in self?.pagerPositionChanged(reset: reset) } } if position != nil, let diamond = self.gramAnimation.view as? InteractiveDiamondComponent.View, diamond.externalMotion == nil { diamond.externalMotion = { [weak self, weak diamond] time in guard let self, let diamond else { return (0.0, false) } var offset: CGFloat = 0.0 if let time, let position = self.pagerPosition, position.isVisible, self.window != nil, self.environment?.isVisible == true, !self.isReturningToWallet, !self.hasTransferredDiamond, !UIAccessibility.isReduceMotionEnabled { offset = self.roll.step(sheet: position.offset, at: time) } else { self.roll = Roll() } UIView.performWithoutAnimation { diamond.transform = CGAffineTransform(translationX: offset, y: 0.0) } return (Float(offset / (78.0 / 2.0 * 0.949) * 0.8), self.roll.isAnimating) } } self.pagerPositionChanged(reset: false) } private func pagerPositionChanged(reset: Bool) { guard let position = self.pagerPosition, let diamond = self.gramAnimation.view as? InteractiveDiamondComponent.View else { return } if reset { self.roll.rebase(sheet: position.offset, at: CACurrentMediaTime()) } diamond.isRenderingEnabled = position.isVisible && self.window != nil && self.environment?.isVisible == true && !self.isReturningToWallet && !self.hasTransferredDiamond diamond.requestExternalMotionUpdate() } @objc private func gramAnimationTapped() { guard !self.isReturningToWallet, let animationView = self.gramAnimation.view as? InteractiveDiamondComponent.View, !animationView.isPlaying else { return } animationView.playOnce() } private var isPreview: Bool { return self.walletContext != nil } private var isFinishedPreview: Bool { guard self.isPreview else { return false } return self.previewOperation == .confirmed || self.previewOperation == .submissionUnknown } private var canEditPreviewComment: Bool { return self.isPreview && !self.preparingForSend && (self.previewOperation == .ready || self.previewOperation == .preparing) } fileprivate var isCommentInputActive: Bool { return self.isPreview && !self.isFinishedPreview && (self.inputField.view as? TextFieldComponent.View)?.isActive == true } private var effectivePreviewCommentEncrypted: Bool { return self.previewCommentEncrypted && self.previewComment != nil && self.previewSource?.collectible == nil } private func commentPrivacyDescription(strings: PresentationStrings) -> String { return self.previewCommentEncrypted ? strings.Wallet_Transaction_PrivateComment : strings.Wallet_Transaction_PublicComment } private func configureMode(_ mode: WalletTransactionContentMode, walletContext: WalletContext?) { self.invalidateCommentSession() self.commentEnvironmentDisposable.set(nil) self.commentCredentialChangesDisposable.set(nil) self.resetCommentDecryption() self.commentWalletIdentity = nil self.discardCurrentPreparedTransfer() self.walletDisposable.set(nil) self.gaslessInfoDisposable.set(nil) self.transferDisposable.set(nil) self.transaction = nil self.walletContext = nil self.previewSource = nil self.previewComment = nil self.previewCommentEncrypted = false self.displayedCommentEncrypted = nil self.preparedTransfer = nil self.submittedTransfer = nil self.displayedFee = nil self.preparedTransferNeedsRefresh = false self.dismissSendScreen = nil self.didDismissSendScreen = false self.previewOperation = .ready self.submissionStage = nil self.preparingForSend = false self.latestWalletState = nil self.didShowSuccess = false self.isReturningToWallet = false self.hasTransferredDiamond = false self.commentRevision += 1 switch mode { case let .transaction(transaction): self.modeId = walletTransactionModeId(.transaction(transaction)) self.transaction = transaction case let .preview(walletContext, source, dismissSendScreen): self.modeId = "preview:\(source.id)" self.walletContext = walletContext self.previewSource = source self.previewComment = walletTransactionComment(source.comment) self.previewCommentEncrypted = source.collectible == nil && source.commentEncrypted self.preparedTransfer = source.preparedTransfer self.displayedFee = source.initialFee if source.collectible == nil { self.gaslessInfoDisposable.set(walletContext.beginGaslessInfoUpdates()) } self.dismissSendScreen = dismissSendScreen self.previewTimestamp = Int32(Date().timeIntervalSince1970) self.isApplyingInput = true self.inputExternalState.initialText = NSAttributedString(string: source.comment ?? "") self.isApplyingInput = false if let component = self.component { let context = component.context let accountId = context.account.id self.commentEnvironmentDisposable.set((combineLatest( context.sharedContext.applicationBindings.applicationInForeground, context.sharedContext.appLockContext.isPasscodeLocked, context.sharedContext.activeAccountContexts |> map { primary, _, _ in primary?.account.id == accountId } ) |> deliverOnMainQueue).start(next: { [weak self] foreground, locked, current in guard let self else { return } self.commentSessionAvailable = foreground && !locked && current if !self.commentSessionAvailable { self.invalidateCommentSession() if !self.isUpdating { self.componentState?.updated(transition: .immediate) } } })) self.commentCredentialChangesDisposable.set(PasscodeCredentialStore.shared.changes.start(next: { [weak self] _ in self?.invalidateCommentSession() self?.componentState?.updated(transition: .immediate) })) } if source.commentEncrypted, let prepared = source.preparedTransfer, source.collectible == nil { let generation = self.commentSessionGeneration self.isAuthorizingComment = true self.commentSessionDisposable.set(walletContext.adoptCommentEncryptionSession(prepared).start(next: { [weak self] session in guard let self, self.commentSessionGeneration == generation, self.commentSessionAvailable else { session?.invalidate() return } self.isAuthorizingComment = false if let session { self.installCommentSession(session) } else { self.invalidateCommentSession() } self.previewCommentUpdated() }, error: { [weak self] _ in guard let self, self.commentSessionGeneration == generation else { return } self.invalidateCommentSession() self.componentState?.updated(transition: .immediate) })) } if source.preparedTransfer == nil { let revision = self.commentRevision Queue.mainQueue().justDispatch { [weak self] in guard let self, self.commentRevision == revision else { return } self.prepareCurrentComment(revision: revision, authorizeAfterPreparation: false) } } } let observedContext = self.walletContext ?? walletContext if let observedContext { self.walletDisposable.set((observedContext.state |> deliverOnMainQueue).start(next: { [weak self] state in guard let self, self.walletContext === observedContext || (self.walletContext == nil && self.component?.walletContext === observedContext) else { return } let walletIdentity: String? if case let .wallet(info) = state.phase { walletIdentity = info.address + ":" + info.publicKey } else { walletIdentity = nil } if self.commentWalletIdentity != walletIdentity { if self.commentWalletIdentity != nil, self.isPreview { self.invalidateCommentSession() } self.resetCommentDecryption() self.commentWalletIdentity = walletIdentity } self.latestWalletState = state if self.previewOperation == .submitting, let preparedTransfer = self.preparedTransfer, state.pendingTransfers.contains(where: { $0.id == preparedTransfer.id }) { self.returnToWalletWithPendingTransfer(id: preparedTransfer.id) } if !self.isUpdating && !self.isClosing { self.componentState?.updated(transition: .easeInOut(duration: 0.2)) } })) } } private func currentTransaction() -> WalletContext.Transaction { if let transaction = self.transaction { return transaction } guard let previewSource = self.previewSource else { preconditionFailure() } let preparedTransfer = self.preparedTransfer let recipient = preparedTransfer?.recipient ?? previewSource.address let transactionPeer: WalletContext.Transaction.Peer if let recipientPeer = previewSource.recipientPeer { transactionPeer = .user(recipientPeer, address: recipient, domain: nil) } else { transactionPeer = .address(recipient, domain: nil) } let amount = preparedTransfer?.amount ?? (previewSource.isSendAll ? max(0, previewSource.amount - (self.displayedFee ?? 0)) : previewSource.amount) let collectible = preparedTransfer?.collectible ?? previewSource.collectible let gasless: Bool if let submittedTransfer = self.submittedTransfer { gasless = self.latestWalletState?.transactions.items.first(where: { $0.presentationId == "pending:\(submittedTransfer.id)" })?.gasless ?? submittedTransfer.gasless } else if collectible == nil, let component = self.component, let walletState = self.latestWalletState ?? self.walletContext?.stateValue, case let .wallet(info) = walletState.phase, !WalletContext.isSelfTransfer(recipient: recipient, walletAddress: info.address), !previewSource.isSendAll || preparedTransfer != nil || self.displayedFee != nil { let configuration = WalletConfiguration.with(appConfiguration: component.context.currentAppConfiguration.with { $0 }) gasless = WalletContext.isGaslessEligible( amount: amount, gaslessInfo: walletState.gaslessInfo.currentValue, minimumAmount: configuration.transferGaslessMinAmount ) } else { gasless = false } return WalletContext.Transaction( id: "preview-\(previewSource.id)", logicalTime: previewSource.id, timestamp: self.previewTimestamp, direction: .outgoing, amount: amount, fee: self.displayedFee ?? 0, gasless: gasless, peer: transactionPeer, comment: self.previewComment, collectible: collectible.map(WalletContext.Transaction.CollectibleTransfer.init(collectible:)) ) } private func dismissSendScreenIfNeeded() { guard !self.didDismissSendScreen else { return } self.didDismissSendScreen = true self.dismissSendScreen?() } private func close(animated: Bool = true, completion: (() -> Void)? = nil) { guard !self.isClosing, let component = self.component, let controller = self.environment?.controller() else { return } self.isClosing = true self.gaslessInfoDisposable.set(nil) (controller as? WalletTransactionScreen)?.cancelFirstGramsSuggestion() (controller as? WalletTransactionScreen)?.cancelCommentDecryptionOnOpen() if !self.isReturningToWallet { self.invalidateCommentSession() } self.resetCommentDecryption() switch self.previewOperation { case .submitting: if self.submissionStage == .waitingForPreviousTransfer { self.transferDisposable.set(nil) self.discardCurrentPreparedTransfer() self.previewOperation = .ready self.submissionStage = nil } else { self.dismissSendScreenIfNeeded() } case .submissionUnknown, .confirmed: self.dismissSendScreenIfNeeded() case .ready, .preparing: self.commentRevision += 1 self.transferDisposable.set(nil) self.discardCurrentPreparedTransfer() self.previewOperation = .ready self.preparingForSend = false case .authorizing: self.discardCurrentPreparedTransfer() self.previewOperation = .ready self.preparingForSend = false } (controller as? WalletTransactionContentController)?.dismissAllTooltips() if animated { (controller as? ViewControllerComponentContainer)?.requestLayout( forceUpdate: true, transition: .easeInOut(duration: 0.3).withUserData(ViewControllerComponentContainer.AnimateOutTransition()) ) component.animateOut.invoke(Action { [weak controller] _ in controller?.dismiss(completion: completion) }) } else { controller.dismiss(completion: completion) } } private func openMyWallet() { guard !self.isClosing, let component = self.component, component.fromChat, component.context.walletContext != nil, let navigationController = self.environment?.controller()?.navigationController as? NavigationController else { return } let context = component.context self.close(completion: { [weak navigationController] in guard let navigationController else { return } navigationController.pushViewController(context.sharedContext.makeWalletScreen(context: context)) }) } private func presentFeesAlert(transaction: WalletContext.Transaction) { guard let component = self.component, let environment = self.environment, let controller = environment.controller() else { return } let walletConfiguration = WalletConfiguration.with(appConfiguration: component.context.currentAppConfiguration.with { $0 }) let feeText: String if let fiatState = self.latestWalletState?.fiat, let fiatRate = fiatState.selectedRate { let fiatFee = formatTonFiatValue( transaction.fee, rate: fiatRate.unitsPerGram, currencySymbol: fiatState.selectedCurrency.symbol, maxDecimalPositions: 4, dateTimeFormat: environment.dateTimeFormat ) feeText = " (\(fiatFee))" } else { feeText = "" } let alertController = textAlertController( context: component.context, updatedPresentationData: self.currentPresentationData(for: component), title: environment.strings.Wallet_Transaction_NetworkFeesTitle, text: environment.strings.Wallet_Transaction_NetworkFeesText(feeText, environment.strings.Wallet_Transaction_Transfers(Int32(clamping: walletConfiguration.transferGaslessDailyLimit))).string, actions: [ TextAlertAction( type: .defaultAction, title: environment.strings.Wallet_GotIt, action: {} ) ] ) controller.present(alertController, in: .window(.root)) } private func resetCommentDecryption() { self.commentDecryptionRevision += 1 self.restorationSession?.invalidate() self.restorationSession = nil self.isRestoringCommentKey = false self.commentDecryptionDisposable.set(nil) self.commentAuthorizationDisposable.set(nil) self.commentDecryptionInProgress = false self.isImportingCommentKey = false self.commentRecoveryController = nil if self.decryptedComment != nil { (self.commentText.view as? TransactionCommentComponent.View)?.cancelSelection() self.commentText.view?.removeFromSuperview() self.commentText = ComponentView() self.decryptedComment = nil self.currentSpeechHolder = nil } } fileprivate func commentVisibilityUpdated(_ visible: Bool, leavingTransaction: Bool) { self.commentIsVisible = visible if leavingTransaction { self.resetCommentDecryption() } if visible, self.isImportingCommentKey { self.isImportingCommentKey = false self.commentRecoveryController = nil if let walletContext = self.component?.walletContext, case let .wallet(info) = walletContext.stateValue.phase, info.canSign, info.address + ":" + info.publicKey == self.commentWalletIdentity { self.startCommentDecryption(revision: self.commentDecryptionRevision) } else { self.resetCommentDecryption() } } else if !visible, !self.isImportingCommentKey, !self.isRestoringCommentKey { self.resetCommentDecryption() } if !self.isUpdating { self.componentState?.updated(transition: .immediate) } } private func encryptedCommentPressed() { guard !self.commentDecryptionInProgress, self.decryptedComment == nil, let transaction = self.transaction, transaction.commentEncrypted, let component = self.component, let walletContext = component.walletContext, let controller = self.environment?.controller() else { return } (controller as? WalletTransactionScreen)?.cancelCommentDecryptionOnOpen() guard case let .wallet(info) = walletContext.stateValue.phase else { self.presentCommentDecryptionError(.unavailable) return } self.commentDecryptionRevision += 1 let revision = self.commentDecryptionRevision self.commentWalletIdentity = info.address + ":" + info.publicKey self.commentDecryptionInProgress = true self.componentState?.updated(transition: .easeInOut(duration: 0.2)) if info.canSign { self.restorationSession?.invalidate() self.restorationSession = nil self.isRestoringCommentKey = false self.startCommentDecryption(revision: revision) } else if info.canExportPhrase { self.isRestoringCommentKey = true self.commentAuthorizationDisposable.set(performWalletAuthorizedOperation( context: component.context, updatedPresentationData: self.currentPresentationData(for: component), present: { [weak controller] alert in controller?.present(alert, in: .window(.root)) }, operation: { [weak self] password -> Signal<[String], WalletContext.WalletError> in guard let self, self.commentDecryptionRevision == revision else { return .fail(.authorizationCancelled) } return self.restorationAuthorization(revision: revision) |> mapToSignal { session in walletContext.recoveryPhrase(password: password, session: session) } }, next: { [weak self] _ in guard let self, self.commentDecryptionRevision == revision else { return } self.restorationSession?.invalidate() self.restorationSession = nil self.isRestoringCommentKey = false if self.commentIsVisible { self.startCommentDecryption(revision: revision) } else { self.resetCommentDecryption() } }, failed: { [weak self] error in guard let self, self.commentDecryptionRevision == revision else { return } self.finishCommentRestoration(error: error, revision: revision) } )) } else { self.importCommentKey(revision: revision) } } private func restorationAuthorization(revision: Int) -> Signal { guard let walletContext = self.component?.walletContext else { return .fail(.authorizationCancelled) } if let session = self.restorationSession, session.isValid { return .single(session) } return walletContext.beginWalletFlow(reason: "Restore wallet") |> deliverOnMainQueue |> mapToSignal { [weak self] session -> Signal in guard let self, self.commentDecryptionRevision == revision else { session.invalidate() return .fail(.authorizationCancelled) } self.restorationSession?.invalidate() self.restorationSession = session return .single(session) } } private func finishCommentRestoration(error: WalletContext.WalletError, revision: Int) { if error == .authorizationCancelled { self.finishCommentDecryption(error: error) return } self.commentDecryptionInProgress = false self.componentState?.updated(transition: .easeInOut(duration: 0.2)) guard let component = self.component, let controller = self.environment?.controller() else { self.resetCommentDecryption() return } let strings = self.currentPresentationData(for: component).initial.strings let message = walletAuthorizationErrorMessage(error, strings: strings) controller.present(textAlertController( context: component.context, updatedPresentationData: self.currentPresentationData(for: component), title: message?.title ?? strings.Wallet_RestoreErrorTitle, text: message?.text ?? strings.Wallet_NetworkError, actions: [ TextAlertAction(type: .genericAction, title: strings.Common_Cancel, action: { [weak self] in guard let self, self.commentDecryptionRevision == revision else { return } self.resetCommentDecryption() }), TextAlertAction(type: .defaultAction, title: strings.Wallet_Retry, action: { [weak self] in guard let self, self.commentDecryptionRevision == revision else { return } self.encryptedCommentPressed() }) ], dismissOnOutsideTap: false ), in: .window(.root)) } private func importCommentKey(revision: Int) { guard self.commentDecryptionRevision == revision else { return } guard let component = self.component, let controller = self.environment?.controller(), controller.navigationController != nil else { self.finishCommentDecryption(error: .authorizationCancelled) return } let importController = component.context.sharedContext.makeWalletImportScreen( context: component.context, mode: .enterRecoveryPhrase, completion: { [weak self] in self?.commentRecoveryController?.dismiss(animated: true) } ) self.commentRecoveryController = importController self.isImportingCommentKey = true controller.push(importController) } private func startCommentDecryption(revision: Int) { guard self.commentDecryptionRevision == revision, let component = self.component, let walletContext = component.walletContext, let transaction = self.transaction else { return } let walletIdentity = self.commentWalletIdentity self.commentDecryptionDisposable.set((walletContext.state |> filter { state in guard case let .wallet(info) = state.phase else { return false } return info.canSign && state.activeOperation == nil && info.address + ":" + info.publicKey == walletIdentity } |> take(1) |> castError(WalletContext.WalletError.self) |> mapToSignal { _ in walletContext.decryptTransactionComment(transaction) } |> deliverOnMainQueue).start(next: { [weak self] comment in guard let self, self.commentDecryptionRevision == revision, self.component?.walletContext === walletContext, case let .wallet(info) = walletContext.stateValue.phase, info.address + ":" + info.publicKey == walletIdentity else { return } self.commentDecryptionInProgress = false self.decryptedComment = comment self.componentState?.updated(transition: .spring(duration: 0.35)) }, error: { [weak self] error in guard let self, self.commentDecryptionRevision == revision else { return } self.finishCommentDecryption(error: error) })) } private func finishCommentDecryption(error: WalletContext.WalletError) { self.resetCommentDecryption() self.componentState?.updated(transition: .easeInOut(duration: 0.2)) if error != .authorizationCancelled { self.presentCommentDecryptionError(error) } } private func presentCommentDecryptionError(_ error: WalletContext.WalletError) { guard let component = self.component, let controller = self.environment?.controller() else { return } let strings = self.currentPresentationData(for: component).initial.strings let authorizationMessage = walletAuthorizationErrorMessage(error, strings: strings) controller.present(textAlertController( context: component.context, updatedPresentationData: self.currentPresentationData(for: component), title: authorizationMessage?.title ?? strings.Wallet_Transaction_CommentDecryptionErrorTitle, text: authorizationMessage?.text ?? strings.Wallet_Transaction_CommentDecryptionErrorText, actions: [TextAlertAction(type: .defaultAction, title: strings.Common_OK, action: {})] ), in: .window(.root)) } private func inputTextUpdated() { guard !self.isApplyingInput, !self.isUpdating, self.canEditPreviewComment else { return } self.previewComment = walletTransactionComment(self.inputExternalState.text.string) self.previewCommentUpdated() } fileprivate func invalidateCommentSession() { if self.previewOperation == .submitting && self.submissionStage == .waitingForPreviousTransfer { self.transferDisposable.set(nil) self.discardCurrentPreparedTransfer() self.previewOperation = .ready self.submissionStage = nil } self.commentSessionGeneration &+= 1 self.commentSession?.invalidate() self.commentSession = nil self.isAuthorizingComment = false self.commentSessionDisposable.set(nil) if self.previewOperation != .submitting && !self.isFinishedPreview { self.preparingForSend = false if self.previewCommentEncrypted { self.commentRevision += 1 self.transferDisposable.set(nil) self.discardCurrentPreparedTransfer() self.previewOperation = .ready } } } private func installCommentSession(_ session: PasscodeSession) { if self.commentSession !== session { self.commentSession?.invalidate() } self.commentSession = session guard let expiresAt = session.expiresAt else { return } let delay = max(0.0, expiresAt - ProcessInfo.processInfo.systemUptime) DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self, weak session] in guard let self, let session, self.commentSession === session else { return } self.invalidateCommentSession() self.componentState?.updated(transition: .immediate) } } private func requestCommentSession(forSend: Bool) { guard !self.isAuthorizingComment, self.commentSessionAvailable, let walletContext = self.walletContext else { return } self.invalidateCommentSession() self.commentRevision += 1 self.transferDisposable.set(nil) self.discardCurrentPreparedTransfer() self.previewOperation = .ready let generation = self.commentSessionGeneration self.isAuthorizingComment = true self.preparingForSend = forSend self.componentState?.updated(transition: .easeInOut(duration: 0.2)) self.commentSessionDisposable.set(walletContext.beginCommentEncryptionSession().start(next: { [weak self] session in guard let self, self.commentSessionGeneration == generation, self.commentSessionAvailable, self.walletContext === walletContext else { session.invalidate() return } self.isAuthorizingComment = false self.installCommentSession(session) self.previewCommentEncrypted = true if forSend { self.commentRevision += 1 self.transferDisposable.set(nil) self.prepareCurrentComment(revision: self.commentRevision, authorizeAfterPreparation: true) } else { self.previewCommentUpdated() self.showCommentPrivacyTooltip() } }, error: { [weak self] error in guard let self, self.commentSessionGeneration == generation else { return } self.isAuthorizingComment = false self.preparingForSend = false self.componentState?.updated(transition: .easeInOut(duration: 0.2)) self.presentTransferError(error) })) } private func commentEncryptionPressed() { guard self.canEditPreviewComment, self.previewSource?.collectible == nil else { return } if self.previewCommentEncrypted || self.isAuthorizingComment { self.invalidateCommentSession() self.previewCommentEncrypted = false self.previewCommentUpdated() self.showCommentPrivacyTooltip() } else { self.requestCommentSession(forSend: false) } } private func showCommentPrivacyTooltip() { guard self.canEditPreviewComment, let component = self.component, let controller = self.environment?.controller(), let buttonView = self.commentEncryptionButton.view else { return } (controller as? WalletTransactionContentController)?.dismissAllTooltips() let sourceFrame = buttonView.convert(buttonView.bounds, to: nil).offsetBy(dx: 0.0, dy: 3.0) controller.present(TooltipScreen( account: component.context.account, sharedContext: component.context.sharedContext, text: .plain(text: self.commentPrivacyDescription(strings: self.currentPresentationData(for: component).initial.strings)), location: .point(sourceFrame, .bottom), displayDuration: .default, shouldDismissOnTouch: { _, _ in return .dismiss(consume: false) } ), in: .current) } private func previewCommentUpdated() { self.commentRevision += 1 let revision = self.commentRevision self.transferDisposable.set(nil) self.previewOperation = .ready self.preparingForSend = false if self.isAuthorizingComment { self.componentState?.updated(transition: .easeInOut(duration: 0.2)) return } if self.previewCommentEncrypted && self.commentSession?.isValid != true { self.discardCurrentPreparedTransfer() self.componentState?.updated(transition: .easeInOut(duration: 0.2)) return } if let preparedTransfer = self.preparedTransfer, !self.preparedTransferNeedsRefresh, preparedTransfer.comment == self.previewComment, preparedTransfer.commentEncrypted == self.effectivePreviewCommentEncrypted, TimeInterval(preparedTransfer.expiresAt) > Date().timeIntervalSince1970 { self.displayedFee = preparedTransfer.fee self.componentState?.updated(transition: .easeInOut(duration: 0.2)) return } self.componentState?.updated(transition: .easeInOut(duration: 0.2)) Queue.mainQueue().after(0.4) { [weak self] in guard let self, self.commentRevision == revision else { return } self.prepareCurrentComment(revision: revision, authorizeAfterPreparation: false) } } private func prepareCurrentComment(revision: Int, authorizeAfterPreparation: Bool) { guard let walletContext = self.walletContext, let previewSource = self.previewSource, self.commentRevision == revision, !self.isAuthorizingComment else { return } if self.previewCommentEncrypted && self.commentSession?.isValid != true { self.invalidateCommentSession() if authorizeAfterPreparation { self.requestCommentSession(forSend: true) } return } let comment = self.previewComment self.previewOperation = .preparing self.preparingForSend = authorizeAfterPreparation self.componentState?.updated(transition: .easeInOut(duration: 0.2)) let collectible = self.preparedTransfer?.collectible ?? previewSource.collectible let requestedAmount = self.preparedTransfer?.requestedAmount ?? previewSource.requestedAmount let sendAll = self.preparedTransfer?.isSendAll ?? previewSource.isSendAll let preparation: Signal if let collectible { preparation = walletContext.prepareCollectibleTransfer( address: self.preparedTransfer?.recipient ?? previewSource.address, collectible: collectible, comment: comment ) } else { let estimatedFee: Int64? let previewAge = Date().timeIntervalSince1970 - TimeInterval(self.previewTimestamp) if !sendAll, self.preparedTransfer == nil, previewSource.preparedTransfer == nil, !self.preparedTransferNeedsRefresh, previewAge >= 0, previewAge < 300, comment == walletTransactionComment(previewSource.comment), self.previewCommentEncrypted == previewSource.commentEncrypted { estimatedFee = previewSource.initialFee } else { estimatedFee = nil } preparation = walletContext.prepareTransfer( address: self.preparedTransfer?.recipient ?? previewSource.address, amount: requestedAmount, sendAll: sendAll, comment: comment, commentEncrypted: self.previewCommentEncrypted, session: self.previewCommentEncrypted ? self.commentSession : nil, estimatedFee: estimatedFee ) } self.transferDisposable.set((walletContext.state |> filter { $0.activeOperation == nil } |> take(1) |> castError(WalletContext.WalletError.self) |> mapToSignal { state -> Signal in if collectible == nil { guard let balance = state.balance.currentValue, requestedAmount <= balance else { return .fail(.insufficientBalance(required: requestedAmount)) } if sendAll && balance != requestedAmount { return .fail(.previewFailed) } } return preparation } |> deliverOnMainQueue).start(next: { [weak self] updatedTransfer in guard let self else { _ = walletContext.discardPreparedTransfer(updatedTransfer).start() return } if revision != self.commentRevision { self.discardTransferDisposables.add( walletContext.discardPreparedTransfer(updatedTransfer).start() ) return } if self.preparedTransfer?.id != updatedTransfer.id { self.discardCurrentPreparedTransfer() } self.preparedTransfer = updatedTransfer self.displayedFee = updatedTransfer.fee self.preparedTransferNeedsRefresh = false let shouldAuthorize = self.preparingForSend self.preparingForSend = false if shouldAuthorize { self.authorizeAndSubmit(updatedTransfer) } else { self.previewOperation = .ready self.componentState?.updated(transition: .easeInOut(duration: 0.2)) } }, error: { [weak self] error in guard let self else { return } if revision != self.commentRevision { return } self.previewOperation = .ready self.preparingForSend = false self.componentState?.updated(transition: .easeInOut(duration: 0.2)) self.presentTransferError(error) })) } private func send() { guard self.isPreview, !self.preparingForSend, self.previewOperation == .ready || self.previewOperation == .preparing else { return } if self.isAuthorizingComment { return } self.endEditing(true) if self.previewCommentEncrypted && self.commentSession?.isValid != true { self.requestCommentSession(forSend: true) return } if self.previewOperation == .preparing { self.preparingForSend = true self.componentState?.updated(transition: .easeInOut(duration: 0.2)) return } guard self.previewOperation == .ready else { return } let comment = self.previewComment if let preparedTransfer = self.preparedTransfer, !self.preparedTransferNeedsRefresh, preparedTransfer.comment == comment, preparedTransfer.commentEncrypted == self.effectivePreviewCommentEncrypted, TimeInterval(preparedTransfer.expiresAt) > Date().timeIntervalSince1970 { self.authorizeAndSubmit(preparedTransfer) } else { self.commentRevision += 1 let revision = self.commentRevision self.transferDisposable.set(nil) self.prepareCurrentComment(revision: revision, authorizeAfterPreparation: true) } } private func authorizeAndSubmit(_ preparedTransfer: WalletContext.PreparedTransfer) { self.previewOperation = .authorizing self.componentState?.updated(transition: .easeInOut(duration: 0.2)) self.submit(preparedTransfer) } private func submit(_ preparedTransfer: WalletContext.PreparedTransfer) { guard let walletContext = self.walletContext else { return } self.submittedTransfer = nil self.transferResultNavigationController = self.environment?.controller()?.navigationController as? NavigationController self.previewOperation = .submitting self.submissionStage = .waitingForPreviousTransfer self.componentState?.updated(transition: .easeInOut(duration: 0.2)) self.transferDisposable.set((walletContext.submitTransfer(preparedTransfer, session: self.previewCommentEncrypted ? self.commentSession : nil, stageUpdated: { [weak self] stage in guard let self else { return } self.submissionStage = stage if stage == .submitted { self.commentSession?.invalidate() self.commentSession = nil } }) // Keep observing the submission after the preview closes so failures still reach the wallet. |> deliverOnMainQueue).start(next: { [self] pendingTransfer in self.transferDisposable.set(nil) self.submittedTransfer = pendingTransfer self.submissionStage = nil self.preparedTransferNeedsRefresh = false switch pendingTransfer.status { case .submissionUnknown: self.dismissSendScreenIfNeeded() self.previewOperation = .submissionUnknown self.invalidateCommentSession() self.componentState?.updated(transition: .easeInOut(duration: 0.2)) self.presentSubmissionUnknown() case .broadcasting, .pending, .confirmed: self.invalidateCommentSession() self.returnToWalletWithPendingTransfer(id: pendingTransfer.id) if self.isReturningToWallet { return } self.dismissSendScreenIfNeeded() self.previewOperation = .confirmed self.componentState?.updated(transition: .easeInOut(duration: 0.25)) if pendingTransfer.collectibleAddress != nil { self.showCollectibleSuccessIfNeeded(address: pendingTransfer.recipient) } } }, error: { [self] error in self.transferDisposable.set(nil) switch error { case .preparedTransferExpired, .preparedTransferNotFound: self.preparedTransferNeedsRefresh = true default: self.preparedTransferNeedsRefresh = false } self.previewOperation = .ready self.submissionStage = nil self.componentState?.updated(transition: .easeInOut(duration: 0.2)) self.presentTransferError(error) })) } private func returnToWalletWithPendingTransfer(id: String) { guard !self.isClosing, !self.isReturningToWallet, self.previewSource?.collectible == nil, let walletContext = self.walletContext, let controller = self.environment?.controller() as? WalletTransactionPreviewScreen, let transferAnimation = controller.transferAnimation else { return } let presentationId = "pending:\(id)" guard walletContext.stateValue.transactions.items.contains(where: { $0.presentationId == presentationId && $0.status != .failed }) else { return } self.isReturningToWallet = true CATransaction.begin() CATransaction.setDisableActions(true) let source: WalletSendTransferAnimationSource? if !UIAccessibility.isReduceMotionEnabled, self.commentSessionAvailable, UIApplication.shared.applicationState != .background, self.environment?.isVisible == true, let diamond = self.gramAnimation.view as? InteractiveDiamondComponent.View { source = WalletSendTransferAnimationSource.capture(diamond: diamond, width: 78.0, spinStartedAt: CACurrentMediaTime()) } else { source = nil } if let source { self.hasTransferredDiamond = true self.gramAnimation = ComponentView() source.diamond.prepareForWalletTransfer() Haptics.hit(0.95) source.diamond.spin(-10.0, decay: 0.9) } if !transferAnimation(presentationId, source) { source?.diamond.isRenderingEnabled = false source?.diamond.removeFromSuperview() } CATransaction.commit() // Remove the input form and recipient selection while the preview still covers them. self.dismissSendScreenIfNeeded() self.close() } private func discardCurrentPreparedTransfer() { guard let walletContext = self.walletContext, let preparedTransfer = self.preparedTransfer else { return } self.preparedTransfer = nil self.preparedTransferNeedsRefresh = false self.discardTransferDisposables.add( walletContext.discardPreparedTransfer(preparedTransfer).start() ) } private func showCollectibleSuccessIfNeeded(address: String) { guard !self.didShowSuccess, let component = self.component, let controller = self.environment?.controller() else { return } self.didShowSuccess = true let presentationData = self.currentPresentationData(for: component).initial let recipientName = self.previewSource?.recipientPeer?.displayTitle(strings: presentationData.strings, displayOrder: presentationData.nameDisplayOrder) ?? walletTransactionShortAddress(address) controller.present( UndoOverlayController( presentationData: presentationData, content: .emoji(name: "Celebrate", text: presentationData.strings.Wallet_Transfer_CollectibleSuccess(recipientName).string), position: .bottom, action: { _ in return false } ), in: .current ) } private func presentSubmissionUnknown() { guard let component = self.component, let controller = self.transferResultController else { return } let strings = self.currentPresentationData(for: component).initial.strings controller.present(textAlertController( context: component.context, updatedPresentationData: self.currentPresentationData(for: component), title: strings.Wallet_Transfer_PendingTitle, text: strings.Wallet_Transfer_PendingText, actions: [TextAlertAction(type: .defaultAction, title: strings.Common_OK, action: { })] ), in: .window(.root)) } private func presentTransferError(_ error: WalletContext.WalletError) { guard error != .authorizationCancelled else { return } guard let component = self.component, let controller = self.transferResultController else { return } let strings = self.currentPresentationData(for: component).initial.strings let title: String let text: String var actions = [TextAlertAction(type: .defaultAction, title: strings.Common_OK, action: {})] switch error { case .insufficientBalance where self.previewSource?.collectible != nil: title = strings.Wallet_Backup_InsufficientFundsTitle text = strings.Wallet_Transfer_CollectibleTopUpText actions = [ TextAlertAction(type: .genericAction, title: strings.Common_Cancel, action: {}), TextAlertAction(type: .defaultAction, title: strings.Wallet_Backup_TopUp, action: { [weak self, weak controller] in guard let self, let controller, let walletContext = self.walletContext, case let .wallet(info) = walletContext.stateValue.phase else { return } controller.push(component.context.sharedContext.makeWalletReceiveScreen( context: component.context, address: info.address )) }) ] case .commentTooLong: title = strings.Wallet_Transfer_CommentTooLongTitle text = strings.Wallet_Transfer_CommentTooLongText case .commentEncryptionRecipientUnavailable: title = strings.Wallet_Transfer_CommentEncryptionErrorTitle text = strings.Wallet_Transfer_CommentEncryptionUnavailable case .commentEncryptionFailed: title = strings.Wallet_Transfer_CommentEncryptionErrorTitle text = strings.Wallet_Transfer_CommentEncryptionErrorText default: title = strings.Wallet_Transfer_ErrorTitle text = strings.Wallet_Transfer_ErrorText } controller.present(textAlertController( context: component.context, updatedPresentationData: self.currentPresentationData(for: component), title: title, text: text, actions: actions ), in: .window(.root)) } private var transferResultController: ViewController? { if self.isReturningToWallet { let navigationController = self.transferResultNavigationController ?? self.component?.context.sharedContext.mainWindow?.viewController as? NavigationController return navigationController?.viewControllers.reversed().first(where: { $0 !== self.environment?.controller() }) as? ViewController } return self.environment?.controller() } private func copyAddress(_ address: String) { UIPasteboard.general.string = address self.hapticFeedback.tap() guard let component = self.component, let controller = self.environment?.controller() else { return } (controller as? WalletTransactionContentController)?.dismissAllTooltips() let presentationData = self.currentPresentationData(for: component).initial controller.present( UndoOverlayController( presentationData: presentationData, content: .copy(text: presentationData.strings.Wallet_TonAddressCopied), position: .bottom, action: { _ in return false } ), in: .current ) } private func openSend(peer transactionPeer: WalletContext.Transaction.Peer) { guard !self.isPreview, let component = self.component, let walletContext = component.walletContext, let controller = self.environment?.controller() else { return } let transactionController = controller as? WalletTransactionScreen let refreshBalanceOnOpen = transactionController?.refreshBalanceOnSend ?? true let sendScreen: WalletSendScreen switch transactionPeer { case let .user(peer, counterpartyAddress, _): let address = WalletContext.transferAddress(from: counterpartyAddress, preserveBounce: true) sendScreen = WalletSendScreen( context: component.context, peer: peer, walletContext: walletContext, initialAddress: address ?? "", refreshBalanceOnOpen: refreshBalanceOnOpen, transferAnimation: transactionController?.transferAnimation, completed: transactionController?.returnedToWallet ) case .address: guard let counterpartyAddress = transactionPeer.address else { return } let address = WalletContext.transferAddress(from: counterpartyAddress, preserveBounce: true) ?? counterpartyAddress sendScreen = WalletSendScreen( context: component.context, walletContext: walletContext, address: address, refreshBalanceOnOpen: refreshBalanceOnOpen, transferAnimation: transactionController?.transferAnimation, completed: transactionController?.returnedToWallet ) case .onramp, .unsupported: return } sendScreen.navigationPresentation = .modal if let controller = controller as? WalletTransactionScreen { if let view = controller.node.hostView.findTaggedView(tag: WalletPagerView.Tag()) as? WalletPagerView { view.setDimHidden(true, animated: true) } else if let view = controller.node.hostView.findTaggedView( tag: SheetComponent.View.Tag() ) as? SheetComponent.View { view.setDimHidden(true, animated: true) } } controller.push(sendScreen) Queue.mainQueue().after(0.6) { [weak self] in self?.close(animated: false) } } private func performCommentTextSelectionAction(text: NSAttributedString, action: TextSelectionAction) { guard let component = self.component, let controller = self.environment?.controller() else { return } switch action { case .copy: storeAttributedTextInPasteboard(text) let presentationData = self.currentPresentationData(for: component).initial controller.present( UndoOverlayController( presentationData: presentationData, content: .copy(text: presentationData.strings.Conversation_TextCopied), position: .bottom, action: { _ in return true } ), in: .current ) case .share: let shareController = component.context.sharedContext.makeShareController( context: component.context, params: ShareControllerParams( subject: .text(text.string), externalShare: true, immediateExternalShare: false ) ) controller.present(shareController, in: .window(.root)) case .lookup: let lookupController = UIReferenceLibraryViewController(term: text.string) if let window = controller.view.window { lookupController.popoverPresentationController?.sourceView = window lookupController.popoverPresentationController?.sourceRect = CGRect( origin: CGPoint(x: window.bounds.width / 2.0, y: window.bounds.height - 1.0), size: CGSize(width: 1.0, height: 1.0) ) window.rootViewController?.present(lookupController, animated: true) } case .speak: if let speechHolder = speakText(text: text.string) { speechHolder.completion = { [weak self, weak speechHolder] in guard let self else { return } if self.currentSpeechHolder === speechHolder { self.currentSpeechHolder = nil } } self.currentSpeechHolder = speechHolder } case .translate: let _ = (component.context.sharedContext.accountManager.sharedData(keys: [ApplicationSpecificSharedDataKeys.translationSettings]) |> take(1) |> deliverOnMainQueue).startStandalone(next: { [weak self] sharedData in guard let self, let component = self.component else { return } let translationSettings: TranslationSettings if let current = sharedData.entries[ApplicationSpecificSharedDataKeys.translationSettings]?.get(TranslationSettings.self) { translationSettings = current } else { translationSettings = TranslationSettings.defaultSettings } let (_, language) = canTranslateText( context: component.context, text: text.string, showTranslate: translationSettings.showTranslate, showTranslateIfTopical: false, ignoredLanguages: translationSettings.ignoredLanguages ) let _ = ApplicationSpecificNotice.incrementTranslationSuggestion( accountManager: component.context.sharedContext.accountManager, timestamp: Int32(Date().timeIntervalSince1970) ).startStandalone() Task { @MainActor [weak self] in guard let self, let component = self.component, let controller = self.environment?.controller() else { return } let presentationData = self.currentPresentationData(for: component).initial let translationController = await component.context.sharedContext.makeTextProcessingScreen( context: component.context, theme: nil, mode: .translate(fromLanguage: language, applyResult: nil), inputText: .plain(text: text.string, entities: []), copyResult: { [weak controller] result in guard let controller else { return } switch result { case let .plain(text, entities): storeMessageTextInPasteboard(text, entities: entities) case .rich(_), .empty: return } controller.present( UndoOverlayController( presentationData: presentationData, content: .copy(text: presentationData.strings.Conversation_TextCopied), elevatedLayout: true, animateInAsReplacement: false, action: { _ in return false } ), in: .window(.root) ) }, translateChat: nil ) controller.present(translationController, in: .window(.root)) } }) case .quote: break } } private func openPeer(_ peer: EnginePeer) { guard let component = self.component, let controller = self.environment?.controller(), let navigationController = controller.navigationController as? NavigationController else { return } (controller as? WalletTransactionContentController)?.dismissAllTooltips() component.context.sharedContext.navigateToChatController(NavigateToChatControllerParams( navigationController: navigationController, chatController: nil, context: component.context, chatLocation: .peer(peer), subject: nil, botStart: nil, updateTextInputState: nil, keepStack: .always, useExisting: true, purposefulAction: nil, scrollToEndIfExists: false, activateMessageSearch: nil, animated: true )) } private func openExplorer(sourceView: UIView) { guard let component = self.component, let controller = self.environment?.controller(), let transaction = self.transaction else { return } let strings = self.currentPresentationData(for: component).initial.strings let configuration = WalletConfiguration.with(appConfiguration: component.context.currentAppConfiguration.with { $0 }) let explorerUrl = walletTransactionExplorerUrl(explorerUrl: configuration.explorerUrl, id: transaction.transactionHash ?? transaction.id) let item = ContextMenuActionItem( text: strings.Wallet_ViewInExplorer, icon: { theme in return generateTintedImage( image: UIImage(bundleImageName: "Chat/Context Menu/Search"), color: theme.contextMenu.primaryColor ) }, action: { _, dismiss in dismiss(.default) if let explorerUrl { component.openExplorer(explorerUrl) } } ) var items: [ContextMenuItem] = [.action(item)] if transaction.currency == .ton && transaction.collectible == nil { items.append(.action(ContextMenuActionItem( text: strings.Wallet_Transaction_WhatIsGram, icon: { theme in return generateTintedImage( image: UIImage(bundleImageName: "Chat/Context Menu/Help"), color: theme.contextMenu.primaryColor ) }, action: { [weak self, weak controller] _, dismiss in dismiss(.default) guard let self else { return } controller?.push(component.context.sharedContext.makeWalletInfoScreen( context: component.context, updatedPresentationData: self.currentPresentationData(for: component), mode: .wallet, completion: nil )) } ))) } let contextController = makeContextController( presentationData: self.currentPresentationData(for: component).initial, source: .reference(WalletTransactionContextReferenceContentSource(sourceView: sourceView)), items: .single(ContextController.Items(content: .list(items))), gesture: nil ) controller.presentInGlobalOverlay(contextController) } func update( component: WalletTransactionContentComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: ComponentTransition ) -> CGSize { self.isUpdating = true defer { self.isUpdating = false } let environment = environment[EnvironmentType.self].value let previousWalletContext = self.component?.walletContext self.component = component self.environment = environment self.componentState = state let incomingModeId = walletTransactionModeId(component.mode) if self.modeId != incomingModeId || previousWalletContext !== component.walletContext { self.setPagerPosition(nil) self.configureMode(component.mode, walletContext: component.walletContext) } else if case let .transaction(transaction) = component.mode { if self.transaction?.comment != transaction.comment || self.transaction?.commentEncrypted != transaction.commentEncrypted || self.transaction?.direction != transaction.direction || self.transaction?.peer.address != transaction.peer.address { self.resetCommentDecryption() } self.transaction = transaction } (environment.controller() as? WalletTransactionContentController)?.setCloseAction(id: incomingModeId, action: { [weak self] animated in self?.close(animated: animated) }) (environment.controller() as? WalletTransactionPreviewScreen)?.invalidateCommentSession = { [weak self] in guard let self, !self.isReturningToWallet else { return } self.invalidateCommentSession() } (environment.controller() as? WalletTransactionScreen)?.setCommentVisibilityAction(id: incomingModeId, action: { [weak self] visible, leavingTransaction in self?.commentVisibilityUpdated(visible, leavingTransaction: leavingTransaction) }) (environment.controller() as? WalletTransactionScreen)?.setCommentDecryptionAction(id: incomingModeId, action: { [weak self] in guard let self, self.modeId == incomingModeId, self.commentIsVisible, !self.isClosing, self.transaction?.commentEncrypted == true, self.transaction?.comment?.isEmpty == false else { return } self.encryptedCommentPressed() }) let theme = environment.theme let transaction = self.currentTransaction() let walletState = self.latestWalletState ?? (self.walletContext ?? component.walletContext)?.stateValue let walletAddress: String? if case let .wallet(info) = walletState?.phase { walletAddress = info.address } else { walletAddress = nil } let isSelfTransfer = transaction.isSelfTransfer(walletAddress: walletAddress) let displaysIncomingSelfTransfer = isSelfTransfer && (!self.isPreview || self.isFinishedPreview) let displayedDirection: WalletContext.Transaction.Direction = displaysIncomingSelfTransfer ? .incoming : transaction.direction let displayedAmount = displaysIncomingSelfTransfer ? abs(transaction.amount) : transaction.amount let showsMore = !self.isPreview || (self.isFinishedPreview && self.transaction != nil) let controlsSize = self.controlButtons.update( transition: transition, component: AnyComponent(GlassControlPanelComponent( theme: theme, leftItem: GlassControlPanelComponent.Item( items: [GlassControlGroupComponent.Item( id: AnyHashable("close"), content: .icon("Navigation/Close"), action: { [weak self] in self?.close() } )], background: .panel ), centralItem: nil, rightItem: showsMore ? GlassControlPanelComponent.Item( items: [GlassControlGroupComponent.Item( id: AnyHashable("more"), content: .animation("anim_morewide"), action: { [weak self] in guard let self, let controlsView = self.controlButtons.view as? GlassControlPanelComponent.View, let sourceView = controlsView.rightItemView?.itemView(id: AnyHashable("more")) else { return } self.openExplorer(sourceView: sourceView) } )], background: .panel ) : nil, centerAlignmentIfPossible: true, isDark: theme.overallDarkAppearance )), environment: {}, containerSize: CGSize(width: availableSize.width - 32.0, height: 44.0) ) if let controlsView = self.controlButtons.view { if controlsView.superview == nil { self.addSubview(controlsView) } controlsView.isUserInteractionEnabled = !self.isPreview transition.setFrame( view: controlsView, frame: CGRect(x: 16.0, y: 16.0, width: controlsSize.width, height: controlsSize.height) ) transition.setAlpha(view: controlsView, alpha: self.isPreview ? 0.0 : 1.0) } let fiatCurrency = self.latestWalletState?.fiat.selectedCurrency ?? .usd let fiatRate = self.latestWalletState?.fiat.selectedRate let isKeyChange = transaction.kind == .keyChange let displaysGramHeader = transaction.currency == .ton && transaction.collectible == nil && !isKeyChange if !displaysGramHeader { self.setPagerPosition(nil) } if !displaysGramHeader, let animationView = self.gramAnimation.view as? InteractiveDiamondComponent.View { transition.setAlpha(view: animationView, alpha: 0.0) animationView.isRenderingEnabled = false } if !isKeyChange, let headerView = self.keyUpdateHeader.view { transition.setAlpha(view: headerView, alpha: 0.0) } if isKeyChange || transaction.collectible == nil, let headerView = self.collectibleHeader.view { transition.setAlpha(view: headerView, alpha: 0.0) (headerView as? WalletCollectibleHeaderComponent.View)?.setAnimationVisible(false) } if isKeyChange || transaction.collectible != nil { if let amountView = self.amount.view { amountView.isUserInteractionEnabled = false transition.setAlpha(view: amountView, alpha: 0.0) } if let usdView = self.usdValue.view { transition.setAlpha(view: usdView, alpha: 0.0) } if let dotView = self.processingDot.view { transition.setAlpha(view: dotView, alpha: 0.0) } if let processingView = self.processingText.view { transition.setAlpha(view: processingView, alpha: 0.0) } } var contentHeight: CGFloat = transaction.collectible == nil ? 71.0 : 44.0 if isKeyChange { let headerSize = self.keyUpdateHeader.update( transition: transition, component: AnyComponent(WalletTransactionKeyUpdateHeaderComponent(theme: theme, strings: environment.strings)), environment: {}, containerSize: CGSize(width: availableSize.width, height: .greatestFiniteMagnitude) ) if let headerView = self.keyUpdateHeader.view { if headerView.superview == nil { self.addSubview(headerView) } transition.setFrame(view: headerView, frame: CGRect(origin: .zero, size: headerSize)) transition.setAlpha(view: headerView, alpha: 1.0) } contentHeight = headerSize.height } else if let collectible = transaction.collectible { let headerSize = self.collectibleHeader.update( transition: transition, component: AnyComponent(WalletCollectibleHeaderComponent( context: component.context, theme: theme, item: WalletCollectibleHeaderComponent.Item( name: collectible.name, image: collectible.image, lottie: collectible.lottie, collectionName: collectible.collectionName, collectionUrl: collectible.collectionUrl ), openCollection: component.openExplorer )), environment: {}, containerSize: CGSize(width: availableSize.width, height: 1000.0) ) if let headerView = self.collectibleHeader.view { if headerView.superview == nil { self.addSubview(headerView) } transition.setFrame(view: headerView, frame: CGRect( x: 0.0, y: contentHeight, width: headerSize.width, height: headerSize.height )) transition.setAlpha(view: headerView, alpha: 1.0) (headerView as? WalletCollectibleHeaderComponent.View)?.setAnimationVisible(true) } contentHeight += headerSize.height } else { if displaysGramHeader { let animationSize = CGSize(width: 118.0, height: 118.0) let rollsWithPager = !self.isPreview && component.pagerPosition != nil if !self.isReturningToWallet { let _ = self.gramAnimation.update( transition: transition, component: AnyComponent(InteractiveDiamondComponent( size: animationSize, diamondWidth: 78.0, isVisible: environment.isVisible, theme: theme, animationMode: rollsWithPager ? .idle : .lottie(loop: false), animateOnAppear: !rollsWithPager )), environment: {}, containerSize: animationSize ) } contentHeight = 10.0 if !self.hasTransferredDiamond, let animationView = self.gramAnimation.view as? InteractiveDiamondComponent.View { animationView.isRenderingEnabled = environment.isVisible animationView.isUserInteractionEnabled = !rollsWithPager if animationView.superview == nil { animationView.addGestureRecognizer(UITapGestureRecognizer( target: self, action: #selector(self.gramAnimationTapped) )) self.addSubview(animationView) } transition.setBounds(view: animationView, bounds: CGRect(origin: .zero, size: animationSize)) transition.setPosition(view: animationView, position: CGPoint( x: floorToScreenPixels((availableSize.width - animationSize.width) / 2.0) + animationSize.width / 2.0, y: contentHeight + animationSize.height / 2.0 )) transition.setAlpha(view: animationView, alpha: 1.0) } self.setPagerPosition(self.isPreview ? nil : component.pagerPosition) contentHeight += animationSize.height - 16.0 } let amountSize = self.amount.update( transition: transition, component: AnyComponent(WalletTransactionAmountComponent( theme: theme, dateTimeFormat: environment.dateTimeFormat, amount: displayedAmount, direction: displayedDirection, currency: transaction.currency, pending: !self.isPreview && transaction.status == .pending )), environment: {}, containerSize: CGSize(width: availableSize.width, height: 100.0) ) if let amountView = self.amount.view { if amountView.superview == nil { self.addSubview(amountView) } amountView.isUserInteractionEnabled = true transition.setFrame( view: amountView, frame: CGRect( x: floorToScreenPixels((availableSize.width - amountSize.width) / 2.0), y: contentHeight, width: amountSize.width, height: amountSize.height ) ) transition.setAlpha(view: amountView, alpha: 1.0) } contentHeight += amountSize.height + 7.0 let usdText: String switch transaction.currency { case .ton: if let fiatRate { usdText = formatTonFiatValue( abs(transaction.amount), rate: fiatRate.unitsPerGram, currencySymbol: fiatCurrency.symbol, dateTimeFormat: environment.dateTimeFormat ) } else { usdText = "—" } case .usdt: if let fiatRate { usdText = formatFiatValue( abs(Double(transaction.amount)) / 1_000_000.0 * fiatRate.unitsPerUsd, currencySymbol: fiatCurrency.symbol, dateTimeFormat: environment.dateTimeFormat ) } else { usdText = "—" } } let usdSize = self.usdValue.update( transition: transition, component: AnyComponent(MultilineTextComponent( text: .plain(NSAttributedString( string: usdText, font: Font.regular(15.0), textColor: theme.actionSheet.secondaryTextColor )), maximumNumberOfLines: 1 )), environment: {}, containerSize: CGSize(width: availableSize.width - 64.0, height: 24.0) ) let displaysTransactionStatus = !self.isPreview && (transaction.status == .pending || transaction.status == .failed) let dotSize = self.processingDot.update( transition: transition, component: AnyComponent(MultilineTextComponent( text: .plain(NSAttributedString( string: "•", font: Font.regular(15.0), textColor: theme.actionSheet.secondaryTextColor )), maximumNumberOfLines: 1 )), environment: {}, containerSize: CGSize(width: 20.0, height: 24.0) ) let processingSize = self.processingText.update( transition: transition, component: AnyComponent(MultilineTextComponent( text: .plain(NSAttributedString( string: transaction.status == .failed ? environment.strings.Wallet_Transaction_Failed : environment.strings.Wallet_Transaction_Processing, font: Font.regular(15.0), textColor: transaction.status == .failed ? theme.list.itemDestructiveColor : theme.actionSheet.controlAccentColor )), maximumNumberOfLines: 1 )), environment: {}, containerSize: CGSize(width: availableSize.width / 2.0, height: 24.0) ) let usdToDotSpacing: CGFloat = 6.0 let dotToProcessingSpacing: CGFloat = 4.0 let processingWidth = usdToDotSpacing + dotSize.width + dotToProcessingSpacing + processingSize.width let combinedWidth = usdSize.width + (displaysTransactionStatus ? processingWidth : 0.0) let combinedX = floorToScreenPixels((availableSize.width - combinedWidth) / 2.0) if let usdView = self.usdValue.view { if usdView.superview == nil { self.addSubview(usdView) } transition.setFrame(view: usdView, frame: CGRect(x: combinedX, y: contentHeight, width: usdSize.width, height: usdSize.height)) transition.setAlpha(view: usdView, alpha: 1.0) } if let dotView = self.processingDot.view { if dotView.superview == nil { self.addSubview(dotView) } transition.setFrame(view: dotView, frame: CGRect(x: combinedX + usdSize.width + usdToDotSpacing, y: contentHeight, width: dotSize.width, height: dotSize.height)) transition.setAlpha(view: dotView, alpha: displaysTransactionStatus ? 1.0 : 0.0) } if let processingView = self.processingText.view { if processingView.superview == nil { self.addSubview(processingView) } transition.setFrame(view: processingView, frame: CGRect( x: combinedX + usdSize.width + usdToDotSpacing + dotSize.width + dotToProcessingSpacing, y: contentHeight, width: processingSize.width, height: processingSize.height )) transition.setAlpha(view: processingView, alpha: displaysTransactionStatus ? 1.0 : 0.0) } contentHeight += usdSize.height } let displaysCommentBubble = !self.isPreview || self.isFinishedPreview let displayedComment = transaction.commentEncrypted ? self.decryptedComment : walletTransactionComment(transaction.comment) let isCommentConcealed = transaction.commentEncrypted && transaction.comment?.isEmpty == false && self.decryptedComment == nil self.commentButton.view?.isHidden = !displaysCommentBubble || !isCommentConcealed if !displaysCommentBubble || !isCommentConcealed, let dustNode = self.commentDustNode { self.commentDustNode = nil transition.setAlpha(view: dustNode.view, alpha: 0.0, completion: { _ in dustNode.view.removeFromSuperview() }) } if displaysCommentBubble, isCommentConcealed || displayedComment != nil { contentHeight += 22.0 let presentationData = self.currentPresentationData(for: component).initial let commentSize: CGSize if isCommentConcealed { commentSize = CGSize(width: 120.0, height: ceil(Font.regular(15.0).lineHeight)) self.commentText.view?.isHidden = true (self.commentText.view as? TransactionCommentComponent.View)?.cancelSelection() self.commentContainerView.textSelectionView = nil } else { commentSize = self.commentText.update( transition: transition, component: AnyComponent(TransactionCommentComponent( id: incomingModeId, theme: theme, strings: environment.strings, text: NSAttributedString( string: displayedComment ?? "", font: Font.regular(15.0), textColor: theme.actionSheet.primaryTextColor ), controller: environment.controller, performAction: { [weak self] text, action in self?.performCommentTextSelectionAction(text: text, action: action) } )), environment: {}, containerSize: CGSize(width: availableSize.width - 122.0, height: .greatestFiniteMagnitude) ) } var commentTransition = transition if self.commentContainerView.bounds.isEmpty { self.commentContainerView.alpha = 0.0 commentTransition = .immediate } let bubbleSize = CGSize(width: commentSize.width + 34.0, height: max(34.0, commentSize.height + 14.0)) // Include the directional tail offset while keeping the text centered on screen. let containerSize = self.commentContainerView.update( contentSize: CGSize(width: bubbleSize.width + 6.0, height: bubbleSize.height), maxHeight: component.maxCommentHeight, id: incomingModeId, text: displayedComment ) commentTransition.setFrame(view: self.commentContainerView, frame: CGRect( x: floorToScreenPixels((availableSize.width - containerSize.width) / 2.0), y: contentHeight, width: containerSize.width, height: containerSize.height )) self.commentContainerView.isUserInteractionEnabled = true transition.setAlpha(view: self.commentContainerView, alpha: 1.0) self.commentBackgroundView.update( size: bubbleSize, maxCornerRadius: presentationData.chatBubbleCorners.mainRadius, minCornerRadius: presentationData.chatBubbleCorners.auxiliaryRadius, theme: theme, incoming: displayedDirection == .incoming ) let bubbleFrame = CGRect( x: displayedDirection == .incoming ? 0.0 : 6.0, y: 0.0, width: bubbleSize.width, height: bubbleSize.height ) commentTransition.setFrame(view: self.commentBackgroundView, frame: bubbleFrame) let commentFrame = CGRect( x: floorToScreenPixels((containerSize.width - commentSize.width) / 2.0), y: floorToScreenPixels((bubbleSize.height - commentSize.height) / 2.0), width: commentSize.width, height: commentSize.height ) if isCommentConcealed { let dustNode: InvisibleInkDustNode if let current = self.commentDustNode { dustNode = current } else { dustNode = InvisibleInkDustNode(textNode: nil, enableAnimations: component.context.sharedContext.energyUsageSettings.fullTranslucency) dustNode.isUserInteractionEnabled = false dustNode.isAccessibilityElement = false self.commentDustNode = dustNode self.commentContainerView.contentView.addSubview(dustNode.view) } dustNode.frame = commentFrame.insetBy(dx: -3.0, dy: -3.0) let rect = CGRect(origin: CGPoint(x: 3.0, y: 3.0), size: commentSize).insetBy(dx: 0.0, dy: 2.0) dustNode.update(size: dustNode.frame.size, color: theme.actionSheet.primaryTextColor, textColor: theme.actionSheet.primaryTextColor, rects: [rect], wordRects: [rect]) transition.setAlpha(view: dustNode.view, alpha: self.commentDecryptionInProgress ? 0.25 : 1.0) let _ = self.commentButton.update( transition: .immediate, component: AnyComponent(PlainButtonComponent( content: AnyComponent(Rectangle(color: .clear)), minSize: bubbleSize, action: { [weak self] in self?.encryptedCommentPressed() }, isEnabled: !self.commentDecryptionInProgress, animateAlpha: false, animateScale: false )), environment: {}, containerSize: bubbleSize ) if let commentButtonView = self.commentButton.view { if commentButtonView.superview == nil { self.commentContainerView.contentView.addSubview(commentButtonView) } commentButtonView.frame = bubbleFrame self.commentContainerView.contentView.bringSubviewToFront(commentButtonView) } } else if let commentView = self.commentText.view { if commentView.superview == nil { commentTransition = .immediate commentView.alpha = 0.0 self.commentContainerView.contentView.addSubview(commentView) } self.commentContainerView.textSelectionView = commentView as? TransactionCommentComponent.View commentView.isHidden = false commentView.isUserInteractionEnabled = true commentTransition.setFrame(view: commentView, frame: commentFrame) transition.setAlpha(view: commentView, alpha: 1.0) } contentHeight += containerSize.height + 32.0 } else { self.commentContainerView.isUserInteractionEnabled = false transition.setAlpha(view: self.commentContainerView, alpha: 0.0) if let commentView = self.commentText.view { commentView.isUserInteractionEnabled = false (commentView as? TransactionCommentComponent.View)?.cancelSelection() transition.setAlpha(view: commentView, alpha: 0.0) } contentHeight += displaysGramHeader ? 32.0 : (!isKeyChange && transaction.collectible == nil ? 44.0 : 22.0) } let valueFont = Font.regular(15.0) let valueColor = theme.list.itemPrimaryTextColor let secondaryValueColor = theme.list.itemSecondaryTextColor let counterpartyTitle: String if self.isPreview && !self.isFinishedPreview, case .address = transaction.peer { counterpartyTitle = environment.strings.Wallet_Transaction_Address } else { switch displayedDirection { case .incoming: counterpartyTitle = environment.strings.Wallet_Transaction_Sender case .outgoing: counterpartyTitle = environment.strings.Wallet_Transaction_Recipient case .unknown: counterpartyTitle = environment.strings.Wallet_Transaction_Address } } let peerDisplayName = transaction.peer.displayName.flatMap { value -> String? in let value = value.trimmingCharacters(in: .whitespacesAndNewlines) return value.isEmpty ? nil : value } let counterpartyName = peerDisplayName ?? transaction.peer.domain let addressText: NSAttributedString? let addressComponent: AnyComponent? if let counterparty = transaction.peer.address { let address = WalletContext.transferAddress(from: counterparty, preserveBounce: true) ?? counterparty let formattedAddress = walletTransactionFormattedAddress( address, font: Font.monospace(15.0), primaryTextColor: valueColor, secondaryTextColor: theme.actionSheet.secondaryTextColor ) addressText = formattedAddress addressComponent = AnyComponent(Button( content: AnyComponent(MultilineTextComponent( text: .plain(formattedAddress), maximumNumberOfLines: 0, lineSpacing: 0.12 )), action: { [weak self] in self?.copyAddress(address) } )) } else { addressText = nil addressComponent = nil } let counterpartyContentId: CounterpartyContentId let counterpartyContent: AnyComponent if case let .user(peer, _, _) = transaction.peer { counterpartyContentId = .peer(peer.id) let peerItems: [AnyComponentWithIdentity] = [ AnyComponentWithIdentity( id: "avatar", component: AnyComponent(AvatarComponent( context: component.context, theme: theme, peer: peer, size: CGSize(width: 20.0, height: 20.0) )) ), AnyComponentWithIdentity( id: "title", component: AnyComponent(MultilineTextComponent( text: .plain(NSAttributedString( string: peer.debugDisplayTitle, font: valueFont, textColor: theme.list.itemAccentColor )), maximumNumberOfLines: 1 )) ) ] counterpartyContent = AnyComponent(Button( content: AnyComponent(HStack(peerItems, spacing: 6.0)), action: { [weak self] in self?.openPeer(peer) } )) } else if let counterpartyName { counterpartyContentId = .name(transaction.peer.address ?? counterpartyName) counterpartyContent = AnyComponent(MultilineTextComponent( text: .plain(NSAttributedString(string: counterpartyName, font: valueFont, textColor: valueColor)), maximumNumberOfLines: 0 )) } else if let addressComponent, let address = transaction.peer.address { counterpartyContentId = .address(address) counterpartyContent = addressComponent } else { counterpartyContentId = .unknown counterpartyContent = AnyComponent(MultilineTextComponent( text: .plain(NSAttributedString(string: environment.strings.Wallet_Transaction_UnknownAddress, font: valueFont, textColor: valueColor)), maximumNumberOfLines: 0 )) } let counterpartyContentComponent = AnyComponentWithIdentity( id: counterpartyContentId, component: counterpartyContent ) let canSendToPeer: Bool switch transaction.peer { case .user: canSendToPeer = true case .address: canSendToPeer = transaction.peer.address != nil case .onramp, .unsupported: canSendToPeer = false } let displaysSendButton: Bool if !self.isPreview, transaction.kind != .keyChange, component.walletContext != nil, canSendToPeer { switch displayedDirection { case .incoming, .outgoing: displaysSendButton = true case .unknown: displaysSendButton = false } } else { displaysSendButton = false } let alignSendButtonToTop: Bool switch transaction.peer { case .address: alignSendButtonToTop = true case .user, .onramp, .unsupported: alignSendButtonToTop = false } let counterpartyComponent: AnyComponent if displaysSendButton { let counterpartyAddressText: NSAttributedString? if case .address = counterpartyContentId { counterpartyAddressText = addressText } else { counterpartyAddressText = nil } counterpartyComponent = AnyComponent(CounterpartyRowComponent( counterparty: counterpartyContentComponent, addressText: counterpartyAddressText, sendButton: AnyComponent(Button( content: AnyComponent(SendButtonContentComponent( text: environment.strings.Wallet_Transaction_Send, color: theme.list.itemAccentColor )), action: { [weak self] in self?.openSend(peer: transaction.peer) } )), spacing: 6.0, alignSendButtonToTop: alignSendButtonToTop )) } else { counterpartyComponent = counterpartyContentComponent.component } let displayedFee: Int64? = self.isPreview ? self.displayedFee : transaction.fee let feeComponent: AnyComponent? if let displayedFee { if displayedFee > 0 { var feeItems: [AnyComponentWithIdentity] = [ AnyComponentWithIdentity(id: "icon", component: AnyComponent(BundleIconComponent( name: "Wallet/TransactionGram", tintColor: nil, maxSize: CGSize(width: 20.0, height: 20.0) ))), AnyComponentWithIdentity(id: "amount", component: AnyComponent(MultilineTextComponent( text: .plain(NSAttributedString( string: formatTonAmountText(displayedFee, dateTimeFormat: environment.dateTimeFormat, maxDecimalPositions: 5), font: valueFont, textColor: valueColor )), maximumNumberOfLines: 1 ))) ] if let fiatRate { let usdFee = formatTonFiatValue( displayedFee, rate: fiatRate.unitsPerGram, currencySymbol: fiatCurrency.symbol, maxDecimalPositions: 4, dateTimeFormat: environment.dateTimeFormat ) feeItems.append(AnyComponentWithIdentity(id: "usd", component: AnyComponent(MultilineTextComponent( text: .plain(NSAttributedString(string: "~ \(usdFee)", font: valueFont, textColor: secondaryValueColor)), maximumNumberOfLines: 1 )))) } feeComponent = AnyComponent(HStack(feeItems, spacing: 3.0)) } else { feeComponent = nil } } else { feeComponent = AnyComponent(HStack([ AnyComponentWithIdentity( id: "placeholder", component: AnyComponent(WalletTransactionFeePlaceholderComponent( color: theme.overallDarkAppearance ? theme.list.itemModalBlocksBackgroundColor : theme.list.itemInputField.backgroundColor )) ) ], spacing: 0.0)) } let feeTitle = environment.strings.Wallet_Transaction_Fee var tableItems: [TableComponent.Item] = [TableComponent.Item( id: displaysSendButton ? CounterpartyRowId.withSendButton : .content(counterpartyContentId), title: counterpartyTitle, component: counterpartyComponent )] let displaysSeparateAddress: Bool switch transaction.peer { case .user, .onramp: displaysSeparateAddress = true case .address: displaysSeparateAddress = transaction.peer.domain != nil case .unsupported: displaysSeparateAddress = false } if displaysSeparateAddress, let addressComponent { tableItems.append(TableComponent.Item( id: "address", title: environment.strings.Wallet_Transaction_Address, component: addressComponent )) } if isSelfTransfer || (transaction.direction == .outgoing && !transaction.gasless), let feeComponent { tableItems.append(TableComponent.Item( id: "fee", title: feeTitle, component: feeComponent )) } if !self.isPreview, !isSelfTransfer, transaction.gasless && transaction.direction == .outgoing { tableItems.append(TableComponent.Item( id: "gaslessFee", title: feeTitle, component: AnyComponent(CounterpartyRowComponent( counterparty: AnyComponentWithIdentity( id: "gaslessFee", component: AnyComponent(HStack([ AnyComponentWithIdentity(id: "icon", component: AnyComponent(BundleIconComponent( name: "Wallet/TransactionGram", tintColor: nil, maxSize: CGSize(width: 20.0, height: 20.0) ))), AnyComponentWithIdentity(id: "text", component: AnyComponent(MultilineTextComponent( text: .plain(NSAttributedString( string: environment.strings.Wallet_Transaction_FreeFee, font: valueFont, textColor: valueColor )), maximumNumberOfLines: 0 ))) ], spacing: 3.0)) ), sendButton: AnyComponent(Button( content: AnyComponent(SendButtonContentComponent( text: "?", color: theme.list.itemAccentColor )), action: { [weak self] in self?.presentFeesAlert(transaction: transaction) } )), spacing: 6.0, alignSendButtonToTop: false )) )) } tableItems.append(TableComponent.Item( id: "date", title: environment.strings.Wallet_Transaction_Date, component: AnyComponent(MultilineTextComponent( text: .plain(NSAttributedString( string: walletTransactionDateText( timestamp: transaction.timestamp, strings: environment.strings, dateTimeFormat: environment.dateTimeFormat ), font: valueFont, textColor: valueColor )), maximumNumberOfLines: 1 )) )) let tableWidth = availableSize.width - (20.0 + environment.safeInsets.left) * 2.0 let tableSize = self.table.update( transition: transition, component: AnyComponent(TableComponent(theme: theme, items: tableItems)), environment: {}, containerSize: CGSize(width: tableWidth, height: .greatestFiniteMagnitude) ) if let tableView = self.table.view { if tableView.superview == nil { self.addSubview(tableView) } transition.setFrame(view: tableView, frame: CGRect( x: floorToScreenPixels((availableSize.width - tableSize.width) / 2.0), y: contentHeight, width: tableSize.width, height: tableSize.height )) } contentHeight += tableSize.height let displaysGramInfo = displaysGramHeader && !self.isPreview if displaysGramInfo { contentHeight += 4.0 let gramInfoTitle = environment.strings.Wallet_Transaction_GramInfo let gramInfoSize = self.gramInfoButton.update( transition: transition, component: AnyComponent(PlainButtonComponent( content: AnyComponent(Text( text: gramInfoTitle, font: Font.regular(14.0), color: theme.actionSheet.controlAccentColor )), minSize: CGSize(width: 44.0, height: 44.0), action: { [weak self] in guard let self, let component = self.component else { return } self.environment?.controller()?.push(component.context.sharedContext.makeWalletInfoScreen( context: component.context, updatedPresentationData: self.currentPresentationData(for: component), mode: .wallet, completion: nil )) } )), environment: {}, containerSize: CGSize(width: tableWidth, height: 44.0) ) if let gramInfoView = self.gramInfoButton.view { if gramInfoView.superview == nil { self.addSubview(gramInfoView) } gramInfoView.isUserInteractionEnabled = true gramInfoView.isAccessibilityElement = true gramInfoView.accessibilityLabel = gramInfoTitle gramInfoView.accessibilityTraits = .button transition.setFrame(view: gramInfoView, frame: CGRect( x: floorToScreenPixels((availableSize.width - gramInfoSize.width) / 2.0), y: contentHeight, width: gramInfoSize.width, height: gramInfoSize.height )) transition.setAlpha(view: gramInfoView, alpha: 1.0) } contentHeight += gramInfoSize.height } else if let gramInfoView = self.gramInfoButton.view { gramInfoView.isUserInteractionEnabled = false transition.setAlpha(view: gramInfoView, alpha: 0.0) } let displaysInput = self.isPreview && !self.isFinishedPreview let displaysCommentEncryption = displaysInput && transaction.collectible == nil if displaysInput { contentHeight += 12.0 let inputWidth = tableSize.width let commentEncryptionButtonSize = CGSize(width: 44.0, height: 40.0) let inputFieldWidth = displaysCommentEncryption ? max(0.0, inputWidth - commentEncryptionButtonSize.width) : inputWidth self.inputField.parentState = state let fieldSize = self.inputField.update( transition: transition, component: AnyComponent(TextFieldComponent( context: component.context, theme: theme, strings: environment.strings, externalState: self.inputExternalState, fontSize: 17.0, textColor: theme.actionSheet.inputTextColor, accentColor: theme.actionSheet.controlAccentColor, insets: UIEdgeInsets(top: 10.0, left: 8.0, bottom: 10.0, right: 16.0), hideKeyboard: false, customInputView: nil, placeholder: NSAttributedString( string: environment.strings.Wallet_OptionalMessage, font: Font.regular(17.0), textColor: theme.actionSheet.inputPlaceholderColor ), resetText: nil, isOneLineWhenUnfocused: false, characterLimit: nil, emptyLineHandling: .notAllowed, formatMenuAvailability: .none, returnKeyType: .done, keyboardType: .default, autocapitalizationType: .sentences, autocorrectionType: .default, lockedFormatAction: { }, present: { [weak self] controller in self?.environment?.controller()?.present(controller, in: .window(.root)) }, paste: { _ in }, returnKeyAction: { [weak self] in self?.endEditing(true) } )), environment: {}, containerSize: CGSize(width: inputFieldWidth, height: .greatestFiniteMagnitude) ) let inputSize = CGSize(width: inputWidth, height: max(40.0, fieldSize.height)) let inputBackgroundSize = self.inputBackground.update( transition: transition, component: AnyComponent(RoundedRectangle( color: theme.overallDarkAppearance ? theme.list.itemModalBlocksBackgroundColor : theme.list.itemInputField.backgroundColor, cornerRadius: 20.0 )), environment: {}, containerSize: inputSize ) if let backgroundView = self.inputBackground.view { if backgroundView.superview == nil { self.addSubview(backgroundView) } transition.setFrame(view: backgroundView, frame: CGRect( x: floorToScreenPixels((availableSize.width - tableSize.width) / 2.0), y: contentHeight, width: tableSize.width, height: inputBackgroundSize.height )) transition.setAlpha(view: backgroundView, alpha: 1.0) } if let fieldView = self.inputField.view { if fieldView.superview == nil { self.addSubview(fieldView) } transition.setFrame(view: fieldView, frame: CGRect( x: floorToScreenPixels((availableSize.width - inputWidth) / 2.0), y: contentHeight + floorToScreenPixels((inputSize.height - fieldSize.height) / 2.0) + 1.0 - UIScreenPixel, width: fieldSize.width, height: fieldSize.height )) transition.setAlpha(view: fieldView, alpha: 1.0) fieldView.isUserInteractionEnabled = self.canEditPreviewComment } if displaysCommentEncryption { let commentEncrypted = self.previewCommentEncrypted let buttonSize = self.commentEncryptionButton.update( transition: transition, component: AnyComponent(PlainButtonComponent( content: AnyComponent(LottieComponent( content: LottieComponent.AppBundleContent( name: "WalletCommentLock", frameRange: commentEncrypted ? (10.0 / 180.0 ..< 30.0 / 180.0) : (0.0 ..< 11.0 / 180.0) ), color: commentEncrypted ? theme.actionSheet.controlAccentColor : theme.actionSheet.inputPlaceholderColor, startingPosition: .end, size: CGSize(width: 24.0, height: 24.0), lottieSettings: component.context.lottieRenderingSettings )), minSize: commentEncryptionButtonSize, action: { [weak self] in self?.commentEncryptionPressed() }, isEnabled: self.canEditPreviewComment, animateContents: false )), environment: {}, containerSize: commentEncryptionButtonSize ) if let buttonView = self.commentEncryptionButton.view { if buttonView.superview == nil { self.addSubview(buttonView) } transition.setFrame(view: buttonView, frame: CGRect( x: floorToScreenPixels((availableSize.width - tableSize.width) / 2.0) + tableSize.width - buttonSize.width, y: contentHeight + floorToScreenPixels((inputSize.height - buttonSize.height) / 2.0), width: buttonSize.width, height: buttonSize.height )) transition.setAlpha(view: buttonView, alpha: 1.0) buttonView.isUserInteractionEnabled = self.canEditPreviewComment buttonView.accessibilityLabel = environment.strings.Wallet_Transaction_CommentPrivacy buttonView.accessibilityValue = self.commentPrivacyDescription(strings: environment.strings) if let displayedCommentEncrypted = self.displayedCommentEncrypted, displayedCommentEncrypted != commentEncrypted, let animationView = (buttonView as? PlainButtonComponent.View)?.contentView as? LottieComponent.View { animationView.playOnce() } self.displayedCommentEncrypted = commentEncrypted } } contentHeight += inputSize.height contentHeight += 24.0 } else { contentHeight += displaysGramInfo ? 2.0 : 30.0 if let backgroundView = self.inputBackground.view { transition.setAlpha(view: backgroundView, alpha: 0.0) } if let fieldView = self.inputField.view { transition.setAlpha(view: fieldView, alpha: 0.0) } } if !displaysCommentEncryption { if let buttonView = self.commentEncryptionButton.view { transition.setAlpha(view: buttonView, alpha: 0.0) buttonView.isUserInteractionEnabled = false } self.displayedCommentEncrypted = nil } let actionTitle: String if self.isPreview && !self.isFinishedPreview { if transaction.collectible != nil { actionTitle = environment.strings.Wallet_Transaction_SendCollectible } else { actionTitle = environment.strings.Wallet_Send_Amount(formatTonAmountText( transaction.amount, dateTimeFormat: environment.dateTimeFormat, maxDecimalPositions: 9, formatString: environment.strings.Currency_Grams )).string } } else if !self.isPreview && component.fromChat { actionTitle = environment.strings.Wallet_Transaction_OpenWallet } else { actionTitle = environment.strings.Common_OK } let canSign: Bool if let latestWalletState = self.latestWalletState, case let .wallet(walletInfo) = latestWalletState.phase { canSign = walletInfo.canSign } else { canSign = false } let actionIsEnabled: Bool if !self.isPreview || self.isFinishedPreview { actionIsEnabled = true } else { actionIsEnabled = canSign && !self.preparingForSend && (self.previewOperation == .ready || self.previewOperation == .preparing) } let actionSize = self.actionButton.update( transition: transition, component: AnyComponent(ButtonComponent( background: ButtonComponent.Background( style: .glass, color: theme.list.itemCheckColors.fillColor, foreground: theme.list.itemCheckColors.foregroundColor, pressedColor: theme.list.itemCheckColors.fillColor.withMultipliedAlpha(0.9) ), content: AnyComponentWithIdentity(id: actionTitle, component: AnyComponent(Text( text: actionTitle, font: Font.semibold(17.0), color: theme.list.itemCheckColors.foregroundColor ))), isEnabled: actionIsEnabled, displaysProgress: self.isPreview && (self.previewOperation.displaysProgress || self.preparingForSend), action: { [weak self] in guard let self else { return } if self.isPreview && !self.isFinishedPreview { self.send() } else if !self.isPreview && self.component?.fromChat == true { self.openMyWallet() } else { self.close() } } )), environment: {}, containerSize: CGSize(width: availableSize.width - 60.0, height: 52.0) ) if let actionView = self.actionButton.view { if actionView.superview == nil { self.addSubview(actionView) } transition.setFrame(view: actionView, frame: CGRect( x: floorToScreenPixels((availableSize.width - actionSize.width) / 2.0), y: contentHeight, width: actionSize.width, height: actionSize.height )) } contentHeight += actionSize.height + 30.0 return CGSize(width: availableSize.width, height: contentHeight) } } func makeView() -> View { return View(frame: .zero) } func update( view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: ComponentTransition ) -> CGSize { return view.update( component: self, availableSize: availableSize, state: state, environment: environment, transition: transition ) } } private final class WalletTransactionPagerComponent: Component { typealias EnvironmentType = ViewControllerComponentContainer.Environment let context: AccountContext let walletContext: WalletContext let fromChat: Bool let transactions: [WalletContext.Transaction] let initialIndex: Int let itemSpacing: CGFloat let openExplorer: (String) -> Void let indexUpdated: (Int) -> Void let draggingBegan: (Int) -> Void init( context: AccountContext, walletContext: WalletContext, fromChat: Bool, transactions: [WalletContext.Transaction], initialIndex: Int, itemSpacing: CGFloat, openExplorer: @escaping (String) -> Void, indexUpdated: @escaping (Int) -> Void, draggingBegan: @escaping (Int) -> Void ) { self.context = context self.walletContext = walletContext self.fromChat = fromChat self.transactions = transactions self.initialIndex = initialIndex self.itemSpacing = itemSpacing self.openExplorer = openExplorer self.indexUpdated = indexUpdated self.draggingBegan = draggingBegan } static func ==(lhs: WalletTransactionPagerComponent, rhs: WalletTransactionPagerComponent) -> Bool { return lhs.context === rhs.context && lhs.walletContext === rhs.walletContext && lhs.fromChat == rhs.fromChat && lhs.transactions == rhs.transactions && lhs.initialIndex == rhs.initialIndex && lhs.itemSpacing == rhs.itemSpacing } typealias View = WalletPagerView func makeView() -> View { return View(frame: .zero) } func update( view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: ComponentTransition ) -> CGSize { return view.update( itemIds: self.transactions.map(\.presentationId), initialIndex: self.initialIndex, itemSpacing: self.itemSpacing, availableSize: availableSize, environment: environment, transition: transition, makeContent: { [weak view] index, isCurrent in return AnyComponent(WalletTransactionSheetComponent( context: self.context, transaction: self.transactions[index], walletContext: self.walletContext, fromChat: self.fromChat, hasDimView: false, updatesPresentationContextLayout: isCurrent, openExplorer: self.openExplorer, pagerPosition: view?.itemPosition(for: self.transactions[index].presentationId) )) }, indexUpdated: self.indexUpdated, draggingBegan: self.draggingBegan ) } } private final class WalletTransactionSheetComponent: CombinedComponent { typealias EnvironmentType = ViewControllerComponentContainer.Environment let context: AccountContext let transaction: WalletContext.Transaction let walletContext: WalletContext? let fromChat: Bool let hasDimView: Bool let updatesPresentationContextLayout: Bool let openExplorer: (String) -> Void let pagerPosition: WalletPagerView.ItemPosition? init( context: AccountContext, transaction: WalletContext.Transaction, walletContext: WalletContext?, fromChat: Bool, hasDimView: Bool, updatesPresentationContextLayout: Bool, openExplorer: @escaping (String) -> Void, pagerPosition: WalletPagerView.ItemPosition? = nil ) { self.context = context self.transaction = transaction self.walletContext = walletContext self.fromChat = fromChat self.hasDimView = hasDimView self.updatesPresentationContextLayout = updatesPresentationContextLayout self.openExplorer = openExplorer self.pagerPosition = pagerPosition } static func ==(lhs: WalletTransactionSheetComponent, rhs: WalletTransactionSheetComponent) -> Bool { if lhs.context !== rhs.context || lhs.walletContext !== rhs.walletContext || lhs.fromChat != rhs.fromChat || lhs.hasDimView != rhs.hasDimView || lhs.updatesPresentationContextLayout != rhs.updatesPresentationContextLayout || lhs.pagerPosition !== rhs.pagerPosition { return false } return lhs.transaction == rhs.transaction } static var body: Body { let sheet = Child(SheetComponent.self) let animateOut = StoredActionSlot(Action.self) let sheetExternalState = SheetComponent.ExternalState() return { context in let environment = context.environment[EnvironmentType.self] let controller = environment.controller let availableCommentHeight = context.availableSize.height - environment.safeInsets.top - max(environment.safeInsets.bottom, environment.inputHeight) let sheetComponent = sheet.update( component: SheetComponent( content: AnyComponent(WalletTransactionContentComponent( context: context.component.context, mode: .transaction(context.component.transaction), walletContext: context.component.walletContext, fromChat: context.component.fromChat, maxCommentHeight: floorToScreenPixels(min(150.0, max(80.0, availableCommentHeight * 0.25))), openExplorer: context.component.openExplorer, animateOut: animateOut, pagerPosition: context.component.pagerPosition )), style: .glass, backgroundColor: .color(environment.theme.actionSheet.opaqueItemBackgroundColor), followContentSizeChanges: true, clipsContent: true, hasDimView: context.component.hasDimView, autoAnimateOut: false, externalState: sheetExternalState, animateOut: animateOut, onPan: { (controller() as? WalletTransactionContentController)?.dismissAllTooltips() }, willDismiss: { (controller() as? ViewControllerComponentContainer)?.requestLayout( forceUpdate: true, transition: .easeInOut(duration: 0.3).withUserData(ViewControllerComponentContainer.AnimateOutTransition()) ) } ), environment: { environment SheetComponentEnvironment( metrics: environment.metrics, deviceMetrics: environment.deviceMetrics, isDisplaying: environment.value.isVisible, isCentered: environment.metrics.widthClass == .regular, hasInputHeight: !environment.inputHeight.isZero, regularMetricsSize: CGSize(width: 430.0, height: 900.0), dismiss: { animated in (controller() as? WalletTransactionContentController)?.requestClose(animated: animated) } ) }, availableSize: context.availableSize, transition: context.transition ) context.add(sheetComponent.position(CGPoint(x: context.availableSize.width / 2.0, y: context.availableSize.height / 2.0))) if context.component.updatesPresentationContextLayout, let controller = controller(), !controller.automaticallyControlPresentationContextLayout { var sideInset: CGFloat = 0.0 var bottomInset: CGFloat = max(environment.safeInsets.bottom, sheetExternalState.contentHeight) if case .regular = environment.metrics.widthClass { sideInset = floor((context.availableSize.width - 430.0) / 2.0) - 12.0 bottomInset = (context.availableSize.height - sheetExternalState.contentHeight) / 2.0 + sheetExternalState.contentHeight } controller.presentationContext.containerLayoutUpdated( ContainerViewLayout( size: context.availableSize, metrics: environment.metrics, deviceMetrics: environment.deviceMetrics, intrinsicInsets: UIEdgeInsets(top: 0.0, left: 0.0, bottom: bottomInset, right: 0.0), safeInsets: UIEdgeInsets( top: 0.0, left: max(sideInset, environment.safeInsets.left), bottom: 0.0, right: max(sideInset, environment.safeInsets.right) ), additionalInsets: .zero, statusBarHeight: environment.statusBarHeight, inputHeight: nil, inputHeightIsInteractivellyChanging: false, inVoiceOver: false, presentedInFormSheet: false ), transition: context.transition.containedViewLayoutTransition ) } return context.availableSize } } } private final class WalletTransactionPreviewSheetComponent: CombinedComponent { typealias EnvironmentType = ViewControllerComponentContainer.Environment let context: AccountContext let walletContext: WalletContext let source: WalletTransactionPreviewSource let dismissSendScreen: () -> Void let openExplorer: (String) -> Void init( context: AccountContext, walletContext: WalletContext, source: WalletTransactionPreviewSource, dismissSendScreen: @escaping () -> Void, openExplorer: @escaping (String) -> Void ) { self.context = context self.walletContext = walletContext self.source = source self.dismissSendScreen = dismissSendScreen self.openExplorer = openExplorer } static func ==(lhs: WalletTransactionPreviewSheetComponent, rhs: WalletTransactionPreviewSheetComponent) -> Bool { return lhs.context === rhs.context && lhs.walletContext === rhs.walletContext && lhs.source == rhs.source } static var body: Body { let sheet = Child(ResizableSheetComponent.self) let animateOut = StoredActionSlot(Action.self) let sheetExternalState = ResizableSheetComponent.ExternalState() return { context in let environment = context.environment[EnvironmentType.self] let controller = environment.controller let availableCommentHeight = context.availableSize.height - environment.safeInsets.top - max(environment.safeInsets.bottom, environment.inputHeight) let sheetComponent = sheet.update( component: ResizableSheetComponent( content: AnyComponent(WalletTransactionContentComponent( context: context.component.context, mode: .preview( walletContext: context.component.walletContext, source: context.component.source, dismissSendScreen: context.component.dismissSendScreen ), walletContext: context.component.walletContext, fromChat: false, maxCommentHeight: floorToScreenPixels(min(150.0, max(80.0, availableCommentHeight * 0.25))), openExplorer: context.component.openExplorer, animateOut: animateOut )), leftItem: AnyComponent(GlassBarButtonComponent( size: CGSize(width: 44.0, height: 44.0), backgroundColor: nil, isDark: environment.theme.overallDarkAppearance, state: .glass, component: AnyComponentWithIdentity( id: "close", component: AnyComponent(BundleIconComponent( name: "Navigation/Close", tintColor: environment.theme.chat.inputPanel.panelControlColor )) ), action: { _ in (controller() as? WalletTransactionContentController)?.requestClose(animated: true) } )), hasTopEdgeEffect: false, backgroundColor: .color(environment.theme.actionSheet.opaqueItemBackgroundColor), clipsContent: true, externalState: sheetExternalState, animateOut: animateOut ), environment: { environment ResizableSheetComponentEnvironment( theme: environment.theme, statusBarHeight: environment.statusBarHeight, safeInsets: environment.safeInsets, inputHeight: environment.inputHeight, metrics: environment.metrics, deviceMetrics: environment.deviceMetrics, isDisplaying: environment.value.isVisible, isCentered: environment.metrics.widthClass == .regular, screenSize: context.availableSize, regularMetricsSize: CGSize(width: 430.0, height: 900.0), dismiss: { animated in (controller() as? WalletTransactionContentController)?.requestClose(animated: animated) } ) }, availableSize: context.availableSize, transition: context.transition ) context.add(sheetComponent.position(CGPoint(x: context.availableSize.width / 2.0, y: context.availableSize.height / 2.0))) if let sheetView = findTaggedComponentViewImpl( view: context.view, tag: ResizableSheetComponent.View.Tag() ) as? ResizableSheetComponent.View, let contentView = sheetView.contentViewValue as? WalletTransactionContentComponent.View, contentView.isCommentInputActive { sheetView.scrollToBottom(transition: context.transition) } if let controller = controller(), !controller.automaticallyControlPresentationContextLayout { let contentHeight = sheetExternalState.contentHeight + environment.inputHeight var sideInset: CGFloat = 0.0 var bottomInset: CGFloat = max(environment.safeInsets.bottom, contentHeight) if case .regular = environment.metrics.widthClass { sideInset = floor((context.availableSize.width - 430.0) / 2.0) - 12.0 bottomInset = (context.availableSize.height - contentHeight) / 2.0 + contentHeight } controller.presentationContext.containerLayoutUpdated( ContainerViewLayout( size: context.availableSize, metrics: environment.metrics, deviceMetrics: environment.deviceMetrics, intrinsicInsets: UIEdgeInsets(top: 0.0, left: 0.0, bottom: bottomInset, right: 0.0), safeInsets: UIEdgeInsets( top: 0.0, left: max(sideInset, environment.safeInsets.left), bottom: 0.0, right: max(sideInset, environment.safeInsets.right) ), additionalInsets: .zero, statusBarHeight: environment.statusBarHeight, inputHeight: nil, inputHeightIsInteractivellyChanging: false, inVoiceOver: false, presentedInFormSheet: false ), transition: context.transition.containedViewLayoutTransition ) } return context.availableSize } } } private final class WalletTransactionRootComponent: Component { typealias EnvironmentType = ViewControllerComponentContainer.Environment let content: AnyComponent init(content: AnyComponent) { self.content = content } static func ==(lhs: WalletTransactionRootComponent, rhs: WalletTransactionRootComponent) -> Bool { return lhs.content == rhs.content } func makeView() -> ComponentHostView { return ComponentHostView() } func update( view: ComponentHostView, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: ComponentTransition ) -> CGSize { return view.update( transition: transition, component: self.content, environment: { environment[EnvironmentType.self] }, forceUpdate: true, containerSize: availableSize ) } } private func walletTransactionOpenExplorer(context: AccountContext) -> (String) -> Void { return { url in context.sharedContext.openExternalUrl( context: context, urlContext: .generic, url: url, forceExternal: true, presentationData: context.sharedContext.currentPresentationData.with { $0 }, navigationController: nil, dismissInput: { } ) } } public final class WalletTransactionScreen: ViewControllerComponentContainer, WalletTransactionContentController { private let accountContext: AccountContext fileprivate let walletPresentationData: (initial: PresentationData, signal: Signal)? private let navigationWalletContext: WalletContext? fileprivate let transferAnimation: WalletSendTransferAnimation? fileprivate let returnedToWallet: (() -> Void)? private let fromChat: Bool private let openExplorer: (String) -> Void private let stateDisposable = MetaDisposable() private let loadMoreDisposable = MetaDisposable() private let firstGramsSuggestionDisposable = MetaDisposable() private var checkFirstGramsOnAppear: Bool #if DEBUG private var gaslessInfoDisposable: Disposable? #endif fileprivate var refreshBalanceOnSend: Bool { self.navigationWalletContext == nil } private var transactionsState: WalletContext.TransactionsState? private var transactions: [WalletContext.Transaction] private var currentTransactionPresentationId: String? private var currentCloseId: String private var closeActions: [String: (Bool) -> Void] = [:] private var commentVisibilityActions: [String: (Bool, Bool) -> Void] = [:] private var commentDecryptionActions: [String: () -> Void] = [:] private var hasPendingCommentDecryption: Bool private var isVisibleForCommentDecryption = false private var requestedOffset: Int? private var failedOffset: Int? public init( context: AccountContext, updatedPresentationData: (initial: PresentationData, signal: Signal)? = nil, walletContext: WalletContext? = nil, transaction: WalletContext.Transaction, fromChat: Bool, decryptCommentOnOpen: Bool = false, transferAnimation: WalletSendTransferAnimation? = nil, returnedToWallet: (() -> Void)? = nil ) { let initialState = walletContext?.stateValue.transactions var initialTransactions = initialState?.items.filter(\.isVisibleInWalletHistory) ?? [] let initialIndex: Int if let index = initialTransactions.firstIndex(where: { $0.presentationId == transaction.presentationId }) ?? initialTransactions.firstIndex(where: { $0.matchesHistoryTransaction(transaction) }) { initialIndex = index } else { initialTransactions.insert(transaction, at: 0) initialIndex = 0 } let initialTransaction = initialTransactions[initialIndex] let openExplorer = walletTransactionOpenExplorer(context: context) self.accountContext = context self.walletPresentationData = updatedPresentationData self.navigationWalletContext = walletContext self.transferAnimation = transferAnimation self.returnedToWallet = returnedToWallet self.fromChat = fromChat self.openExplorer = openExplorer self.checkFirstGramsOnAppear = !decryptCommentOnOpen && transaction.direction == .incoming && transaction.currency == .ton && transaction.collectible == nil self.hasPendingCommentDecryption = decryptCommentOnOpen && initialTransaction.commentEncrypted && initialTransaction.comment?.isEmpty == false self.transactionsState = initialState self.transactions = initialTransactions self.currentTransactionPresentationId = initialTransaction.presentationId self.currentCloseId = walletTransactionModeId(.transaction(initialTransaction)) var indexUpdatedImpl: ((Int) -> Void)? var draggingBeganImpl: ((Int) -> Void)? let initialComponent: AnyComponent if let walletContext { initialComponent = AnyComponent(WalletTransactionPagerComponent( context: context, walletContext: walletContext, fromChat: fromChat, transactions: initialTransactions, initialIndex: initialIndex, itemSpacing: 10.0, openExplorer: openExplorer, indexUpdated: { index in indexUpdatedImpl?(index) }, draggingBegan: { index in draggingBeganImpl?(index) } )) } else { initialComponent = AnyComponent(WalletTransactionSheetComponent( context: context, transaction: transaction, walletContext: context.walletContext, fromChat: fromChat, hasDimView: true, updatesPresentationContextLayout: true, openExplorer: openExplorer )) } super.init( context: context, component: WalletTransactionRootComponent(content: initialComponent), navigationBarAppearance: .none, statusBarStyle: .ignore, theme: .default, updatedPresentationData: updatedPresentationData ) indexUpdatedImpl = { [weak self] index in self?.currentIndexUpdated(index) } draggingBeganImpl = { [weak self] index in self?.draggingBegan(index) } self.navigationPresentation = .flatModal self.automaticallyControlPresentationContextLayout = false if let walletContext { self.stateDisposable.set((walletContext.state |> map { $0.transactions } |> distinctUntilChanged |> deliverOnMainQueue).start(next: { [weak self] transactions in Queue.mainQueue().justDispatch { [weak self] in self?.transactionsStateUpdated(transactions) } })) self.requestLoadMoreIfNeeded(index: initialIndex) } } required public init(coder aDecoder: NSCoder) { fatalError("init(coder:) has not been implemented") } deinit { self.stateDisposable.dispose() self.loadMoreDisposable.dispose() self.firstGramsSuggestionDisposable.dispose() #if DEBUG self.gaslessInfoDisposable?.dispose() #endif } public override func viewDidLoad() { super.viewDidLoad() self.view.disablesInteractiveModalDismiss = true } public override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) #if DEBUG if self.gaslessInfoDisposable == nil { self.gaslessInfoDisposable = (self.navigationWalletContext ?? self.accountContext.walletContext)?.beginGaslessInfoUpdates() } #endif self.commentVisibilityActions[self.currentCloseId]?(true, false) } public override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) self.isVisibleForCommentDecryption = true self.activatePendingCommentDecryption() guard self.checkFirstGramsOnAppear else { return } self.checkFirstGramsOnAppear = false self.firstGramsSuggestionDisposable.set((self.accountContext.engine.notices.getServerProvidedSuggestions() |> take(1) |> deliverOnMainQueue).start(next: { [weak self] suggestions in guard let self, suggestions.contains(.walletFirstIncomingTransfer), let navigationController = self.navigationController as? NavigationController, navigationController.topViewController === self else { return } navigationController.pushViewController(self.accountContext.sharedContext.makeWalletInfoScreen( context: self.accountContext, updatedPresentationData: self.walletPresentationData ?? ( initial: self.accountContext.sharedContext.currentPresentationData.with { $0 }, signal: self.accountContext.sharedContext.presentationData ), mode: .firstGrams, completion: nil )) let _ = self.accountContext.engine.notices.dismissServerProvidedSuggestion(suggestion: ServerProvidedSuggestion.walletFirstIncomingTransfer.id).startStandalone() })) } public override func viewWillDisappear(_ animated: Bool) { self.isVisibleForCommentDecryption = false self.cancelCommentDecryptionOnOpen() self.cancelFirstGramsSuggestion() super.viewWillDisappear(animated) for action in self.commentVisibilityActions.values { action(false, false) } self.dismissAllTooltips() } fileprivate func cancelFirstGramsSuggestion() { self.checkFirstGramsOnAppear = false self.firstGramsSuggestionDisposable.dispose() } fileprivate func setCloseAction(id: String, action: @escaping (Bool) -> Void) { self.closeActions[id] = action } public override func viewDidDisappear(_ animated: Bool) { super.viewDidDisappear(animated) #if DEBUG self.gaslessInfoDisposable?.dispose() self.gaslessInfoDisposable = nil #endif if self.navigationController?.viewControllers.contains(where: { $0 === self }) != true { for action in self.commentVisibilityActions.values { action(false, true) } } } fileprivate func setCommentVisibilityAction(id: String, action: @escaping (Bool, Bool) -> Void) { self.commentVisibilityActions[id] = action } fileprivate func setCommentDecryptionAction(id: String, action: @escaping () -> Void) { guard self.hasPendingCommentDecryption else { return } self.commentDecryptionActions[id] = action if self.isVisibleForCommentDecryption, id == self.currentCloseId { Queue.mainQueue().justDispatch { [weak self] in self?.activatePendingCommentDecryption() } } } private func activatePendingCommentDecryption() { guard self.hasPendingCommentDecryption, self.isVisibleForCommentDecryption, let action = self.commentDecryptionActions[self.currentCloseId] else { return } self.cancelCommentDecryptionOnOpen() action() } fileprivate func cancelCommentDecryptionOnOpen() { self.hasPendingCommentDecryption = false self.commentDecryptionActions.removeAll() } fileprivate func requestClose(animated: Bool) { self.cancelCommentDecryptionOnOpen() self.dismissAllTooltips() if let closeAction = self.closeActions[self.currentCloseId] { closeAction(animated) } else { self.dismiss(completion: nil) } } public func dismissAnimated() { self.requestClose(animated: true) } fileprivate func dismissAllTooltips() { self.window?.forEachController({ controller in if let controller = controller as? TooltipScreen { controller.dismiss(inPlace: false) } if let controller = controller as? UndoOverlayController { controller.dismiss() } }) self.forEachController({ controller in if let controller = controller as? TooltipScreen { controller.dismiss(inPlace: false) } if let controller = controller as? UndoOverlayController { controller.dismiss() } return true }) } private func transactionsStateUpdated(_ state: WalletContext.TransactionsState) { if let requestedOffset = self.requestedOffset, state.offset != requestedOffset || !state.canLoadMore { self.requestedOffset = nil self.failedOffset = nil } var transactions = state.items.filter(\.isVisibleInWalletHistory) if let currentTransactionPresentationId = self.currentTransactionPresentationId, !transactions.contains(where: { $0.presentationId == currentTransactionPresentationId }), let currentTransaction = self.transactions.first(where: { $0.presentationId == currentTransactionPresentationId }) { if let replacement = transactions.first(where: { $0.matchesHistoryTransaction(currentTransaction) }) { self.currentTransactionPresentationId = replacement.presentationId } else { let previousIndex = self.transactions.firstIndex(where: { $0.presentationId == currentTransactionPresentationId }) ?? 0 transactions.insert(currentTransaction, at: min(previousIndex, transactions.count)) } } if transactions.isEmpty, let currentTransaction = self.transactions.first { transactions = [currentTransaction] } self.transactionsState = state self.transactions = transactions let currentIndex: Int if let currentTransactionPresentationId = self.currentTransactionPresentationId { currentIndex = transactions.firstIndex(where: { $0.presentationId == currentTransactionPresentationId }) ?? 0 } else { currentIndex = 0 } if transactions.indices.contains(currentIndex) { self.currentCloseId = walletTransactionModeId(.transaction(transactions[currentIndex])) } self.updatePager(initialIndex: currentIndex) self.requestLoadMoreIfNeeded(index: currentIndex) } private func updatePager(initialIndex: Int) { guard let navigationWalletContext = self.navigationWalletContext else { return } self.updateComponent( component: AnyComponent(WalletTransactionRootComponent( content: AnyComponent(WalletTransactionPagerComponent( context: self.accountContext, walletContext: navigationWalletContext, fromChat: self.fromChat, transactions: self.transactions, initialIndex: initialIndex, itemSpacing: 10.0, openExplorer: self.openExplorer, indexUpdated: { [weak self] index in self?.currentIndexUpdated(index) }, draggingBegan: { [weak self] index in self?.draggingBegan(index) } )) )), transition: .easeInOut(duration: 0.2) ) } private func currentIndexUpdated(_ index: Int) { guard self.transactions.indices.contains(index) else { return } let transaction = self.transactions[index] if self.currentTransactionPresentationId != transaction.presentationId { self.cancelCommentDecryptionOnOpen() self.commentVisibilityActions[self.currentCloseId]?(false, true) } self.currentTransactionPresentationId = transaction.presentationId self.currentCloseId = walletTransactionModeId(.transaction(transaction)) self.requestLoadMoreIfNeeded(index: index) } private func draggingBegan(_ index: Int) { self.cancelCommentDecryptionOnOpen() self.dismissAllTooltips() if self.failedOffset == self.transactionsState?.offset { self.requestedOffset = nil self.failedOffset = nil } self.requestLoadMoreIfNeeded(index: index) } private func requestLoadMoreIfNeeded(index: Int) { guard let navigationWalletContext = self.navigationWalletContext, let transactionsState = self.transactionsState, !self.transactions.isEmpty, index >= max(0, self.transactions.count - 2), transactionsState.canLoadMore, !transactionsState.isLoadingMore, transactionsState.error == nil || self.failedOffset == nil, navigationWalletContext.stateValue.activeOperation == nil else { return } let offset = transactionsState.offset guard self.requestedOffset != offset else { return } self.requestedOffset = offset self.loadMoreDisposable.set((navigationWalletContext.loadMoreTransactions() |> deliverOnMainQueue).start(error: { [weak self] _ in guard let self, self.requestedOffset == offset else { return } self.failedOffset = offset })) } } public final class WalletTransactionPreviewScreen: ViewControllerComponentContainer, WalletTransactionContentController, WalletSendTransferAnimationController { public var transferAnimation: WalletSendTransferAnimation? fileprivate let walletPresentationData: (initial: PresentationData, signal: Signal)? private let currentCloseId: String private var closeActions: [String: (Bool) -> Void] = [:] fileprivate var invalidateCommentSession: (() -> Void)? public init( context: AccountContext, updatedPresentationData: (initial: PresentationData, signal: Signal)? = nil, walletContext: WalletContext, preparedTransfer: WalletContext.PreparedTransfer, dismissSendScreen: @escaping () -> Void ) { let source = WalletTransactionPreviewSource(preparedTransfer: preparedTransfer) self.walletPresentationData = updatedPresentationData self.currentCloseId = walletTransactionModeId(.preview( walletContext: walletContext, source: source, dismissSendScreen: dismissSendScreen )) super.init( context: context, component: WalletTransactionPreviewSheetComponent( context: context, walletContext: walletContext, source: source, dismissSendScreen: dismissSendScreen, openExplorer: walletTransactionOpenExplorer(context: context) ), navigationBarAppearance: .none, statusBarStyle: .ignore, theme: .default, updatedPresentationData: updatedPresentationData ) self.navigationPresentation = .flatModal self.automaticallyControlPresentationContextLayout = false } public init( context: AccountContext, updatedPresentationData: (initial: PresentationData, signal: Signal)? = nil, walletContext: WalletContext, address: String, recipientPeer: EnginePeer? = nil, amount: Int64, sendAll: Bool, comment: String?, collectible: WalletContext.Collectible? = nil, initialFee: Int64? = nil, dismissSendScreen: @escaping () -> Void ) { let source = WalletTransactionPreviewSource(address: address, recipientPeer: recipientPeer, amount: amount, sendAll: sendAll, comment: comment, collectible: collectible, initialFee: initialFee) self.walletPresentationData = updatedPresentationData self.currentCloseId = walletTransactionModeId(.preview( walletContext: walletContext, source: source, dismissSendScreen: dismissSendScreen )) super.init( context: context, component: WalletTransactionPreviewSheetComponent( context: context, walletContext: walletContext, source: source, dismissSendScreen: dismissSendScreen, openExplorer: walletTransactionOpenExplorer(context: context) ), navigationBarAppearance: .none, statusBarStyle: .ignore, theme: .default, updatedPresentationData: updatedPresentationData ) self.navigationPresentation = .flatModal self.automaticallyControlPresentationContextLayout = false } required public init(coder aDecoder: NSCoder) { fatalError("init(coder:) has not been implemented") } public override func viewDidLoad() { super.viewDidLoad() self.view.disablesInteractiveModalDismiss = true } public override func viewWillDisappear(_ animated: Bool) { super.viewWillDisappear(animated) if self.isBeingDismissed || self.isMovingFromParent || self.navigationController?.isBeingDismissed == true { self.invalidateCommentSession?() } self.dismissAllTooltips() } public override func dismiss(completion: (() -> Void)? = nil) { self.invalidateCommentSession?() super.dismiss(completion: completion) } public override func dismiss(animated flag: Bool, completion: (() -> Void)? = nil) { self.invalidateCommentSession?() super.dismiss(animated: flag, completion: completion) } public override func viewWillLeaveNavigation() { self.invalidateCommentSession?() super.viewWillLeaveNavigation() } fileprivate func setCloseAction(id: String, action: @escaping (Bool) -> Void) { self.closeActions[id] = action } fileprivate func requestClose(animated: Bool) { self.dismissAllTooltips() if let closeAction = self.closeActions[self.currentCloseId] { closeAction(animated) } else { self.dismiss(completion: nil) } } public func dismissAnimated() { self.requestClose(animated: true) } fileprivate func dismissAllTooltips() { self.window?.forEachController({ controller in if let controller = controller as? TooltipScreen { controller.dismiss(inPlace: false) } if let controller = controller as? UndoOverlayController { controller.dismiss() } }) self.forEachController({ controller in if let controller = controller as? TooltipScreen { controller.dismiss(inPlace: false) } if let controller = controller as? UndoOverlayController { controller.dismiss() } return true }) } } private func walletTransactionComment(_ value: String?) -> String? { guard var value else { return nil } value = value.trimmingCharacters(in: .whitespacesAndNewlines) return value.isEmpty ? nil : value } private func walletTransactionShortAddress(_ address: String) -> String { let address = WalletContext.transferAddress(from: address, preserveBounce: true) ?? address guard address.count > 8 else { return address } return "\(address.prefix(4))…\(address.suffix(4))" } private func walletTransactionFormattedAddress( _ address: String, font: UIFont, primaryTextColor: UIColor, secondaryTextColor: UIColor ) -> NSAttributedString { let result = NSMutableAttributedString() var index = address.startIndex var groupIndex = 0 while index < address.endIndex { let endIndex = address.index(index, offsetBy: 4, limitedBy: address.endIndex) ?? address.endIndex if groupIndex != 0 { let separator = groupIndex.isMultiple(of: 4) ? "\n" : " " result.append(NSAttributedString(string: separator, font: font, textColor: primaryTextColor)) } let rowIndex = groupIndex / 4 let columnIndex = groupIndex % 4 result.append(NSAttributedString( string: String(address[index ..< endIndex]), font: font, textColor: (rowIndex + columnIndex).isMultiple(of: 2) ? primaryTextColor : secondaryTextColor )) index = endIndex groupIndex += 1 } return result } private func walletTransactionDateText( timestamp: Int32, strings: PresentationStrings, dateTimeFormat: PresentationDateTimeFormat ) -> String { let dateComponents = getDateTimeComponents(timestamp: timestamp) let date = stringForMediumCompactDate( timestamp: timestamp, strings: strings, dateTimeFormat: dateTimeFormat, withTime: false ) let time = stringForShortTimestamp( hours: dateComponents.hour, minutes: dateComponents.minutes, dateTimeFormat: dateTimeFormat ) return strings.Time_MediumDate(date, time).string } private func tonHashHex(fromBase64 hash: String) -> String? { guard let data = Data(base64Encoded: hash), data.count == 32 else { return nil } return data.map { String(format: "%02x", $0) }.joined() } private func walletTransactionExplorerUrl(explorerUrl: String, id: String) -> String? { guard let encodedId = tonHashHex(fromBase64: id) else { return nil } let baseUrl = explorerUrl.hasSuffix("/") ? explorerUrl : explorerUrl + "/" return "\(baseUrl)transaction/\(encodedId)" } private final class WalletTransactionContextReferenceContentSource: ContextReferenceContentSource { private let sourceView: UIView init(sourceView: UIView) { self.sourceView = sourceView } func transitionInfo() -> ContextControllerReferenceViewInfo? { return ContextControllerReferenceViewInfo( referenceView: self.sourceView, contentAreaInScreenSpace: UIScreen.main.bounds, actionsPosition: .bottom ) } }