import Foundation import UIKit import Display import AccountContext import TelegramCore import SwiftSignalKit import TelegramPresentationData import PresentationDataUtils import ComponentFlow import ViewControllerComponent import SheetComponent import BalancedTextComponent import BundleIconComponent import GlassBarButtonComponent import ButtonComponent import WalletContext import WalletCardComponent import AlertUI import UndoUI fileprivate enum WalletConnectFinishResult { case cancelled case connected } public final class WalletConnectAppIconComponent: Component { let applicationName: String let context: AccountContext let icon: WalletTonConnectIcon? let size: CGFloat let cornerRadius: CGFloat? public init(context: AccountContext, applicationName: String, icon: WalletTonConnectIcon?, size: CGFloat = 88.0, cornerRadius: CGFloat? = nil) { self.applicationName = applicationName self.context = context self.icon = icon self.size = size self.cornerRadius = cornerRadius } public static func ==(lhs: WalletConnectAppIconComponent, rhs: WalletConnectAppIconComponent) -> Bool { return lhs.applicationName == rhs.applicationName && lhs.context === rhs.context && lhs.icon == rhs.icon && lhs.size == rhs.size && lhs.cornerRadius == rhs.cornerRadius } public final class View: UIView { private let imageView = UIImageView() private let fallbackLabel = UILabel() private var currentIcon: WalletTonConnectIcon? private weak var accountContext: AccountContext? private let fetchDisposable = MetaDisposable() private let dataDisposable = MetaDisposable() public override init(frame: CGRect) { super.init(frame: frame) self.clipsToBounds = true self.backgroundColor = UIColor(rgb: 0x2aabee) self.imageView.contentMode = .scaleAspectFill self.fallbackLabel.textAlignment = .center self.fallbackLabel.font = Font.bold(40.0) self.fallbackLabel.textColor = .white self.addSubview(self.fallbackLabel) self.addSubview(self.imageView) } required public init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } deinit { self.fetchDisposable.dispose() self.dataDisposable.dispose() } func update(component: WalletConnectAppIconComponent, availableSize: CGSize) -> CGSize { let size = CGSize(width: min(availableSize.width, component.size), height: min(availableSize.height, component.size)) self.layer.cornerRadius = component.cornerRadius ?? size.height / 2.0 self.fallbackLabel.font = Font.bold(min(40.0, size.height * 0.5)) self.imageView.frame = CGRect(origin: .zero, size: size) self.fallbackLabel.frame = CGRect(origin: .zero, size: size) self.fallbackLabel.text = component.applicationName.first.map { String($0).uppercased() } if self.currentIcon != component.icon || self.accountContext !== component.context { self.currentIcon = component.icon self.accountContext = component.context self.fetchDisposable.set(nil) self.dataDisposable.set(nil) self.imageView.image = nil self.fallbackLabel.isHidden = false if let icon = component.icon, icon.size > 0, icon.size <= 2 * 1024 * 1024, icon.mimeType.lowercased().hasPrefix("image/") { let resources = component.context.engine.resources let resource = WebFileReferenceMediaResource(url: icon.url, size: Int64(icon.size), accessHash: icon.accessHash) self.dataDisposable.set((resources.data(resource: EngineMediaResource(resource)) |> map { data -> UIImage? in guard data.isComplete, data.availableSize <= 2 * 1024 * 1024 else { return nil } return UIImage(contentsOfFile: data.path) } |> deliverOnMainQueue).start(next: { [weak self, weak context = component.context] image in guard let self, self.currentIcon == icon, self.accountContext === context, let image else { return } self.imageView.image = image self.fallbackLabel.isHidden = true })) self.fetchDisposable.set(resources.fetch(reference: .standalone(resource: resource), userLocation: .other, userContentType: .image, statsCategory: .image).start()) } } return size } } public func makeView() -> View { return View(frame: .zero) } public func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: ComponentTransition) -> CGSize { return view.update(component: self, availableSize: availableSize) } } private final class WalletConnectSheetContent: CombinedComponent { typealias EnvironmentType = ViewControllerComponentContainer.Environment let context: AccountContext let walletContext: WalletContext let request: WalletContext.TonConnectRequest let connect: (@escaping (Result) -> Void) -> Void let animateOut: ActionSlot> let getController: () -> ViewController? init( context: AccountContext, walletContext: WalletContext, request: WalletContext.TonConnectRequest, connect: @escaping (@escaping (Result) -> Void) -> Void, animateOut: ActionSlot>, getController: @escaping () -> ViewController? ) { self.context = context self.walletContext = walletContext self.request = request self.connect = connect self.animateOut = animateOut self.getController = getController } static func ==(lhs: WalletConnectSheetContent, rhs: WalletConnectSheetContent) -> Bool { if lhs.context !== rhs.context { return false } if lhs.walletContext !== rhs.walletContext { return false } if lhs.request != rhs.request { return false } return true } final class State: ComponentState { private let getController: () -> ViewController? private let disposables = DisposableSet() fileprivate var walletState: WalletContext.State? fileprivate var accountName = "" fileprivate var isConnecting = false init( context: AccountContext, walletContext: WalletContext, getController: @escaping () -> ViewController? ) { self.getController = getController super.init() self.disposables.add((walletContext.state |> deliverOnMainQueue).start(next: { [weak self] walletState in guard let self else { return } self.walletState = walletState self.updated(transition: .easeInOut(duration: 0.25)) })) self.disposables.add((context.engine.data.subscribe( TelegramEngine.EngineData.Item.Peer.Peer(id: context.account.peerId) ) |> deliverOnMainQueue).start(next: { [weak self] peer in guard let self else { return } let accountName = peer?.debugDisplayTitle.uppercased() ?? "" if self.accountName != accountName { self.accountName = accountName self.updated(transition: .immediate) } })) } deinit { self.disposables.dispose() } func finish(_ result: WalletConnectFinishResult, animated: Bool, animateOut: ActionSlot>) { guard let controller = self.getController() as? WalletConnectScreen else { return } controller.finish(result, animated: animated, animateOut: animateOut) } func connect(component: WalletConnectSheetContent) { guard !self.isConnecting else { return } self.isConnecting = true // Keep the button spinner visible while preventing the sheet's pan or dim-tap dismissal. self.getController()?.view.isUserInteractionEnabled = false self.updated(transition: .easeInOut(duration: 0.2)) component.connect({ [weak self] result in guard let self else { return } self.getController()?.view.isUserInteractionEnabled = true switch result { case .success: self.finish(.connected, animated: true, animateOut: component.animateOut) case let .failure(error): self.isConnecting = false self.updated(transition: .easeInOut(duration: 0.2)) if error == .authorizationCancelled { return } guard let controller = self.getController() else { return } let presentationData = component.context.sharedContext.currentPresentationData.with { $0 } controller.present(textAlertController( context: component.context, title: nil, text: presentationData.strings.Wallet_Connect_Error, actions: [ TextAlertAction(type: .defaultAction, title: presentationData.strings.Common_OK, action: {}) ] ), in: .window(.root)) } }) } func openReceive(context: AccountContext, address: String) { guard let controller = self.getController() else { return } let receiveController = context.sharedContext.makeWalletReceiveScreen( context: context, address: address ) if controller.navigationController != nil { controller.push(receiveController) } else { controller.window?.present( receiveController, on: .root, blockInteraction: false, completion: { } ) } } } func makeState() -> State { return State( context: self.context, walletContext: self.walletContext, getController: self.getController ) } static var body: Body { let appIcon = Child(WalletConnectAppIconComponent.self) let title = Child(BalancedTextComponent.self) let domain = Child(HStack.self) let permission = Child(BalancedTextComponent.self) let card = Child(WalletCardComponent.self) let disclaimer = Child(BalancedTextComponent.self) let cancelButton = Child(ButtonComponent.self) let connectButton = Child(ButtonComponent.self) let closeButton = Child(GlassBarButtonComponent.self) return { context in let component = context.component let state = context.state let environment = context.environment[EnvironmentType.self].value let theme = environment.theme let safeContentWidth = max( 0.0, context.availableSize.width - environment.safeInsets.left - environment.safeInsets.right ) let contentCenterX = environment.safeInsets.left + safeContentWidth / 2.0 let textWidth = max(1.0, safeContentWidth - 48.0) let primaryTextColor = theme.actionSheet.primaryTextColor let secondaryTextColor = theme.actionSheet.secondaryTextColor let accentColor = theme.actionSheet.controlAccentColor var contentHeight: CGFloat = 32.0 let appIconSize = CGSize(width: 88.0, height: 88.0) let appIconCenter = CGPoint( x: contentCenterX, y: contentHeight + appIconSize.height / 2.0 ) let appIcon = appIcon.update( component: WalletConnectAppIconComponent( context: component.context, applicationName: component.request.applicationName, icon: component.request.icon ), availableSize: appIconSize, transition: context.transition ) context.add(appIcon .position(appIconCenter) .cornerRadius(appIconSize.width * 0.5) .clipsToBounds(true) ) contentHeight += appIconSize.height contentHeight += 18.0 let title = title.update( component: BalancedTextComponent( text: .plain(NSAttributedString( string: environment.strings.Wallet_Connect_Title(component.request.applicationName).string, font: Font.bold(22.0), textColor: primaryTextColor )), horizontalAlignment: .center, maximumNumberOfLines: 0, lineSpacing: 0.1 ), availableSize: CGSize(width: textWidth, height: context.availableSize.height), transition: .immediate ) context.add(title.position(CGPoint( x: contentCenterX, y: contentHeight + title.size.height / 2.0 ))) contentHeight += title.size.height contentHeight += 5.0 let domainItems: [AnyComponentWithIdentity] = [AnyComponentWithIdentity( id: "domain", component: AnyComponent(Text( text: component.request.domain, font: Font.semibold(15.0), color: accentColor )) )] let domain = domain.update( component: HStack(domainItems, spacing: 4.0), availableSize: CGSize(width: textWidth, height: 30.0), transition: .immediate ) context.add(domain.position(CGPoint( x: contentCenterX, y: contentHeight + domain.size.height / 2.0 ))) contentHeight += domain.size.height contentHeight += 19.0 var permissionTexts: [String] = [] permissionTexts.append(environment.strings.Wallet_Connect_Permissions) for permission in component.request.permissions { if case let .proof(domain) = permission { permissionTexts.append(environment.strings.Wallet_Connect_Proof(domain).string) } } let permissionText = permissionTexts.joined(separator: "\n\n") let permission = permission.update( component: BalancedTextComponent( text: .plain(NSAttributedString( string: permissionText, font: Font.regular(15.0), textColor: primaryTextColor )), horizontalAlignment: .center, maximumNumberOfLines: 0, lineSpacing: 0.2 ), availableSize: CGSize(width: textWidth, height: context.availableSize.height), transition: .immediate ) context.add(permission.position(CGPoint( x: contentCenterX, y: contentHeight + permission.size.height / 2.0 ))) contentHeight += permission.size.height contentHeight += 18.0 let cardWidth = min(361.0, max(1.0, safeContentWidth - 42.0)) let walletInfo: WalletContext.WalletInfo? if let walletState = state.walletState, case let .wallet(value) = walletState.phase { walletInfo = value } else { walletInfo = nil } let fiatCurrency = state.walletState?.fiat.selectedCurrency ?? .usd let fiatRate = state.walletState?.fiat.selectedRate let card = card.update( component: WalletCardComponent( theme: environment.theme, balance: state.walletState?.balance.currentValue, fiatCurrency: fiatCurrency, fiatRate: fiatRate, dateTimeFormat: environment.dateTimeFormat, name: state.accountName, address: walletInfo?.address ?? "", isVisible: environment.isVisible, qrPressed: { [weak state] in guard let walletInfo else { return } state?.openReceive( context: component.context, address: walletInfo.address ) } ), availableSize: CGSize(width: cardWidth, height: context.availableSize.height), transition: context.transition ) context.add(card.position( CGPoint( x: contentCenterX, y: contentHeight + card.size.height / 2.0 )) .clipsToBounds(false) ) contentHeight += card.size.height contentHeight += 20.0 let disclaimer = disclaimer.update( component: BalancedTextComponent( text: .plain(NSAttributedString( string: environment.strings.Wallet_Connect_Disclaimer(component.request.applicationName).string, font: Font.regular(13.0), textColor: secondaryTextColor )), horizontalAlignment: .center, maximumNumberOfLines: 0, lineSpacing: 0.2 ), availableSize: CGSize(width: textWidth, height: context.availableSize.height), transition: .immediate ) context.add(disclaimer.position(CGPoint( x: contentCenterX, y: contentHeight + disclaimer.size.height / 2.0 ))) contentHeight += disclaimer.size.height contentHeight += 19.0 let buttonSpacing: CGFloat = 10.0 let buttonInsets = ContainerViewLayout.concentricInsets( bottomInset: environment.safeInsets.bottom, innerDiameter: 52.0, sideInset: 30.0 ) // The bottom buttons span iPhone Duo's side strip; they keep clear only of a corner block at the bottom. let buttonsSideInsets = environment.bottomEdgeSideInsets() let buttonsBandWidth = max(0.0, context.availableSize.width - buttonsSideInsets.left - buttonsSideInsets.right) let buttonsCenterX = buttonsSideInsets.left + buttonsBandWidth / 2.0 let buttonsWidth = max(2.0, buttonsBandWidth - buttonInsets.left - buttonInsets.right) let cancelButtonWidth = floorToScreenPixels((buttonsWidth - buttonSpacing) / 2.0) let connectButtonWidth = buttonsWidth - buttonSpacing - cancelButtonWidth let cancelButton = cancelButton.update( component: ButtonComponent( background: ButtonComponent.Background( style: .glass, color: theme.list.itemPrimaryTextColor.withMultipliedAlpha(0.1), foreground: theme.list.itemPrimaryTextColor, pressedColor: theme.list.itemPrimaryTextColor.withMultipliedAlpha(0.16), cornerRadius: 26.0 ), content: AnyComponentWithIdentity( id: "cancel", component: AnyComponent(Text( text: environment.strings.Common_Cancel, font: Font.semibold(17.0), color: theme.list.itemPrimaryTextColor )) ), isEnabled: !state.isConnecting, action: { [weak state] in state?.finish(.cancelled, animated: true, animateOut: component.animateOut) } ), availableSize: CGSize(width: cancelButtonWidth, height: 52.0), transition: context.transition ) context.add(cancelButton.position(CGPoint( x: buttonsCenterX - buttonSpacing / 2.0 - cancelButton.size.width / 2.0, y: contentHeight + cancelButton.size.height / 2.0 ))) let connectButton = connectButton.update( component: ButtonComponent( background: ButtonComponent.Background( style: .glass, color: theme.list.itemCheckColors.fillColor, foreground: theme.list.itemCheckColors.foregroundColor, pressedColor: theme.list.itemCheckColors.fillColor.withMultipliedAlpha(0.9), cornerRadius: 26.0 ), content: AnyComponentWithIdentity( id: "connect", component: AnyComponent(Text( text: environment.strings.Wallet_Connect_Action, font: Font.semibold(17.0), color: theme.list.itemCheckColors.foregroundColor )) ), isEnabled: !state.isConnecting, displaysProgress: state.isConnecting, action: { [weak state] in state?.connect(component: component) } ), availableSize: CGSize(width: connectButtonWidth, height: 52.0), transition: context.transition ) context.add(connectButton.position(CGPoint( x: buttonsCenterX + buttonSpacing / 2.0 + connectButton.size.width / 2.0, y: contentHeight + connectButton.size.height / 2.0 ))) contentHeight += max(cancelButton.size.height, connectButton.size.height) contentHeight += buttonInsets.bottom let closeButton = closeButton.update( component: GlassBarButtonComponent( size: CGSize(width: 44.0, height: 44.0), backgroundColor: nil, isDark: theme.overallDarkAppearance, state: .glass, component: AnyComponentWithIdentity( id: "close", component: AnyComponent(BundleIconComponent( name: "Navigation/Close", tintColor: theme.chat.inputPanel.panelControlColor )) ), action: { [weak state] _ in guard state?.isConnecting == false else { return } state?.finish(.cancelled, animated: true, animateOut: component.animateOut) } ), availableSize: CGSize(width: 44.0, height: 44.0), transition: .immediate ) context.add(closeButton.position(CGPoint( x: environment.safeInsets.left + 16.0 + closeButton.size.width / 2.0, y: 16.0 + closeButton.size.height / 2.0 ))) return CGSize(width: context.availableSize.width, height: contentHeight) } } } private final class WalletConnectSheetComponent: CombinedComponent { typealias EnvironmentType = ViewControllerComponentContainer.Environment let context: AccountContext let walletContext: WalletContext let request: WalletContext.TonConnectRequest let connect: (@escaping (Result) -> Void) -> Void init( context: AccountContext, walletContext: WalletContext, request: WalletContext.TonConnectRequest, connect: @escaping (@escaping (Result) -> Void) -> Void ) { self.context = context self.walletContext = walletContext self.request = request self.connect = connect } static func ==(lhs: WalletConnectSheetComponent, rhs: WalletConnectSheetComponent) -> Bool { if lhs.context !== rhs.context { return false } if lhs.walletContext !== rhs.walletContext { return false } if lhs.request != rhs.request { return false } return true } 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 sheet = sheet.update( component: SheetComponent( content: AnyComponent(WalletConnectSheetContent( context: context.component.context, walletContext: context.component.walletContext, request: context.component.request, connect: context.component.connect, animateOut: animateOut, getController: controller )), style: .glass, backgroundColor: .color(environment.theme.actionSheet.opaqueItemBackgroundColor), followContentSizeChanges: true, clipsContent: true, autoAnimateOut: false, externalState: sheetExternalState, animateOut: animateOut, onPan: { }, willDismiss: { } ), environment: { environment SheetComponentEnvironment( metrics: environment.metrics, deviceMetrics: environment.deviceMetrics, isDisplaying: environment.value.isVisible, isCentered: environment.presentsFloatingPanels, hasInputHeight: !environment.inputHeight.isZero, regularMetricsSize: CGSize(width: 430.0, height: 900.0), dismiss: { animated in if let controller = controller() as? WalletConnectScreen { controller.finish( .cancelled, animated: animated, animateOut: animateOut ) } } ) }, availableSize: context.availableSize, transition: context.transition ) context.add(sheet.position(CGPoint( x: context.availableSize.width / 2.0, y: context.availableSize.height / 2.0 ))) if 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 } let layout = 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) ), reservedCorners: environment.reservedCorners, division: nil, additionalInsets: .zero, statusBarHeight: environment.fullStatusBarHeight, inputHeight: nil, inputHeightIsInteractivellyChanging: false, inVoiceOver: false, presentedInFormSheet: false ) controller.presentationContext.containerLayoutUpdated( layout, transition: context.transition.containedViewLayoutTransition ) } return context.availableSize } } } public final class WalletConnectScreen: ViewControllerComponentContainer { private let context: AccountContext private var applicationName: String private let walletContext: WalletContext private let connectAction: (@escaping (Result) -> Void) -> Void private let cancelled: () -> Void public var tonConnectClosed: (() -> Void)? private var finishResult: WalletConnectFinishResult? public init( context: AccountContext, walletContext: WalletContext, request: WalletContext.TonConnectRequest, cancelled: @escaping () -> Void, connect: @escaping (@escaping (Result) -> Void) -> Void ) { self.context = context self.applicationName = request.applicationName self.walletContext = walletContext self.connectAction = connect self.cancelled = cancelled super.init( context: context, component: WalletConnectSheetComponent( context: context, walletContext: walletContext, request: request, connect: connect ), navigationBarAppearance: .none, statusBarStyle: .ignore, theme: .default ) self.navigationPresentation = .flatModal self.automaticallyControlPresentationContextLayout = false } public func updateRequest(_ request: WalletContext.TonConnectRequest) { self.applicationName = request.applicationName self.updateComponent(component: AnyComponent(WalletConnectSheetComponent(context: self.context, walletContext: self.walletContext, request: request, connect: self.connectAction)), transition: .immediate) } required public init(coder aDecoder: NSCoder) { fatalError("init(coder:) has not been implemented") } public override func viewDidLoad() { super.viewDidLoad() self.view.disablesInteractiveModalDismiss = true } fileprivate func finish( _ result: WalletConnectFinishResult, animated: Bool, animateOut: ActionSlot>? ) { guard self.finishResult == nil else { return } self.finishResult = result let callback: () -> Void switch result { case .cancelled: callback = self.cancelled case .connected: let context = self.context let applicationName = self.applicationName callback = { guard let navigationController = context.sharedContext.mainWindow?.viewController as? NavigationController, let controller = navigationController.viewControllers.reversed().first(where: { !($0 is WalletConnectScreen) }) as? ViewController else { return } let presentationData = context.sharedContext.currentPresentationData.with { $0 } controller.present( UndoOverlayController( presentationData: presentationData, content: .actionSucceeded( title: presentationData.strings.Wallet_Connect_SuccessTitle, text: presentationData.strings.Wallet_Connect_SuccessText(applicationName).string, cancel: nil, destructive: false ), elevatedLayout: false, animateInAsReplacement: false, action: { _ in return false } ), in: .current ) } } let dismissController: () -> Void = { [weak self] in guard let self else { callback() return } self.dismiss(completion: { callback() self.tonConnectClosed?() }) } if animated, let animateOut { animateOut.invoke(Action { _ in dismissController() }) } else if animated { dismissController() } else { self.dismiss(animated: false, completion: { callback() self.tonConnectClosed?() }) } } public func dismissAnimated() { if let view = self.node.hostView.findTaggedView( tag: SheetComponent.View.Tag() ) as? SheetComponent.View { view.dismissAnimated() } else { self.finish(.cancelled, animated: false, animateOut: nil) } } }