import Foundation import UIKit import Display import ComponentFlow import SwiftSignalKit import TelegramCore import Markdown import TextFormat import TelegramPresentationData import ViewControllerComponent import SheetComponent import BalancedTextComponent import MultilineTextComponent import BundleIconComponent import ButtonComponent import ItemListUI import UndoUI import AccountContext import PresentationDataUtils import StarsImageComponent import ConfettiEffect private final class SheetContent: CombinedComponent { typealias EnvironmentType = ViewControllerComponentContainer.Environment let context: AccountContext let starsContext: StarsContext let invoice: TelegramMediaInvoice let source: BotPaymentInvoiceSource let extendedMedia: [TelegramExtendedMedia] let inputData: Signal<(StarsContext.State, BotPaymentForm, EnginePeer?)?, NoError> let dismiss: () -> Void init( context: AccountContext, starsContext: StarsContext, invoice: TelegramMediaInvoice, source: BotPaymentInvoiceSource, extendedMedia: [TelegramExtendedMedia], inputData: Signal<(StarsContext.State, BotPaymentForm, EnginePeer?)?, NoError>, dismiss: @escaping () -> Void ) { self.context = context self.starsContext = starsContext self.invoice = invoice self.source = source self.extendedMedia = extendedMedia self.inputData = inputData self.dismiss = dismiss } static func ==(lhs: SheetContent, rhs: SheetContent) -> Bool { if lhs.context !== rhs.context { return false } if lhs.invoice != rhs.invoice { return false } if lhs.extendedMedia != rhs.extendedMedia { return false } return true } final class State: ComponentState { var cachedCloseImage: (UIImage, PresentationTheme)? var cachedStarImage: (UIImage, PresentationTheme)? private let context: AccountContext private let starsContext: StarsContext private let source: BotPaymentInvoiceSource private let extendedMedia: [TelegramExtendedMedia] private let invoice: TelegramMediaInvoice private(set) var botPeer: EnginePeer? private(set) var chatPeer: EnginePeer? private var peerDisposable: Disposable? private(set) var balance: Int64? private(set) var form: BotPaymentForm? private var stateDisposable: Disposable? private var optionsDisposable: Disposable? private(set) var options: [StarsTopUpOption] = [] { didSet { self.optionsPromise.set(self.options) } } private let optionsPromise = ValuePromise<[StarsTopUpOption]?>(nil) var inProgress = false init( context: AccountContext, starsContext: StarsContext, source: BotPaymentInvoiceSource, extendedMedia: [TelegramExtendedMedia], invoice: TelegramMediaInvoice, inputData: Signal<(StarsContext.State, BotPaymentForm, EnginePeer?)?, NoError> ) { self.context = context self.starsContext = starsContext self.source = source self.extendedMedia = extendedMedia self.invoice = invoice super.init() let chatPeer: Signal if case let .message(messageId) = source { chatPeer = context.engine.data.get(TelegramEngine.EngineData.Item.Peer.Peer(id: messageId.peerId)) } else { chatPeer = .single(nil) } self.peerDisposable = (combineLatest( inputData, chatPeer ) |> deliverOnMainQueue).start(next: { [weak self] inputData, chatPeer in guard let self else { return } self.balance = inputData?.0.balance ?? 0 self.form = inputData?.1 self.botPeer = inputData?.2 self.chatPeer = chatPeer self.updated(transition: .immediate) if self.optionsDisposable == nil, let balance = self.balance, balance < self.invoice.totalAmount { self.optionsDisposable = (context.engine.payments.starsTopUpOptions() |> deliverOnMainQueue).start(next: { [weak self] options in guard let self else { return } self.options = options }) } }) self.stateDisposable = (starsContext.state |> deliverOnMainQueue).start(next: { [weak self] state in guard let self else { return } self.balance = state?.balance self.updated(transition: .immediate) }) } deinit { self.peerDisposable?.dispose() self.stateDisposable?.dispose() self.optionsDisposable?.dispose() } func buy(requestTopUp: @escaping (@escaping () -> Void) -> Void, completion: @escaping (Bool) -> Void) { guard let form, let balance else { return } let action = { [weak self] in guard let self else { return } self.inProgress = true self.updated() let _ = (self.context.engine.payments.sendStarsPaymentForm(formId: form.id, source: self.source) |> deliverOnMainQueue).start(next: { _ in completion(true) }, error: { [weak self] error in guard let self else { return } switch error { case .alreadyPaid: if !self.extendedMedia.isEmpty, case let .message(messageId) = self.source { let _ = self.context.engine.messages.updateExtendedMedia(messageIds: [messageId]).startStandalone() } default: break } completion(false) }) } if balance < self.invoice.totalAmount { if self.options.isEmpty { self.inProgress = true self.updated() } let _ = (self.optionsPromise.get() |> filter { $0 != nil } |> take(1) |> deliverOnMainQueue).startStandalone(next: { [weak self] _ in if let self { self.inProgress = false self.updated() requestTopUp({ [weak self] in guard let self else { return } self.inProgress = true self.updated() let _ = (self.starsContext.state |> filter { state in if let state { return !state.flags.contains(.isPendingBalance) } return false } |> take(1) |> deliverOnMainQueue).start(next: { _ in Queue.mainQueue().after(0.1, { [weak self] in if let self, let balance = self.balance, balance < self.invoice.totalAmount { self.inProgress = false self.updated() self.buy(requestTopUp: requestTopUp, completion: completion) } else { action() } }) }) }) } }) } else { action() } } } func makeState() -> State { return State(context: self.context, starsContext: self.starsContext, source: self.source, extendedMedia: self.extendedMedia, invoice: self.invoice, inputData: self.inputData) } static var body: Body { let background = Child(RoundedRectangle.self) let star = Child(StarsImageComponent.self) let closeButton = Child(Button.self) let title = Child(Text.self) let text = Child(BalancedTextComponent.self) let button = Child(ButtonComponent.self) let balanceTitle = Child(MultilineTextComponent.self) let balanceValue = Child(MultilineTextComponent.self) let balanceIcon = Child(BundleIconComponent.self) return { context in let environment = context.environment[EnvironmentType.self] let component = context.component let state = context.state let presentationData = component.context.sharedContext.currentPresentationData.with { $0 } let theme = presentationData.theme let strings = presentationData.strings var contentSize = CGSize(width: context.availableSize.width, height: 18.0) let background = background.update( component: RoundedRectangle(color: theme.list.blocksBackgroundColor, cornerRadius: 8.0), availableSize: CGSize(width: context.availableSize.width, height: 1000.0), transition: .immediate ) context.add(background .position(CGPoint(x: context.availableSize.width / 2.0, y: background.size.height / 2.0)) ) let subject: StarsImageComponent.Subject if !component.extendedMedia.isEmpty { subject = .extendedMedia(component.extendedMedia) } else if let peer = state.botPeer { if let photo = component.invoice.photo { subject = .photo(photo) } else { subject = .transactionPeer(.peer(peer)) } } else { subject = .none } let star = star.update( component: StarsImageComponent( context: component.context, subject: subject, theme: theme, diameter: 90.0, backgroundColor: theme.list.blocksBackgroundColor ), availableSize: CGSize(width: min(414.0, context.availableSize.width), height: 220.0), transition: context.transition ) context.add(star .position(CGPoint(x: context.availableSize.width / 2.0, y: star.size.height / 2.0 - 27.0)) ) let closeImage: UIImage if let (image, cacheTheme) = state.cachedCloseImage, theme === cacheTheme { closeImage = image } else { closeImage = generateCloseButtonImage(backgroundColor: UIColor(rgb: 0x808084, alpha: 0.1), foregroundColor: theme.actionSheet.inputClearButtonColor)! state.cachedCloseImage = (closeImage, theme) } let closeButton = closeButton.update( component: Button( content: AnyComponent(Image(image: closeImage)), action: { component.dismiss() } ), availableSize: CGSize(width: 30.0, height: 30.0), transition: .immediate ) context.add(closeButton .position(CGPoint(x: context.availableSize.width - closeButton.size.width, y: 28.0)) ) let constrainedTitleWidth = context.availableSize.width - 16.0 * 2.0 contentSize.height += 126.0 let title = title.update( component: Text(text: strings.Stars_Transfer_Title, font: Font.bold(24.0), color: theme.list.itemPrimaryTextColor), availableSize: CGSize(width: constrainedTitleWidth, height: context.availableSize.height), transition: .immediate ) context.add(title .position(CGPoint(x: context.availableSize.width / 2.0, y: contentSize.height + title.size.height / 2.0)) ) contentSize.height += title.size.height contentSize.height += 13.0 let textFont = Font.regular(15.0) let boldTextFont = Font.semibold(15.0) let textColor = theme.actionSheet.primaryTextColor let linkColor = theme.actionSheet.controlAccentColor let markdownAttributes = MarkdownAttributes(body: MarkdownAttributeSet(font: textFont, textColor: textColor), bold: MarkdownAttributeSet(font: boldTextFont, textColor: textColor), link: MarkdownAttributeSet(font: textFont, textColor: linkColor), linkAttribute: { contents in return (TelegramTextAttributes.URL, contents) }) let amount = component.invoice.totalAmount let infoText: String if !component.extendedMedia.isEmpty { var description: String = "" var photoCount: Int32 = 0 var videoCount: Int32 = 0 for media in component.extendedMedia { if case let .preview(_, _, videoDuration) = media, videoDuration != nil { videoCount += 1 } else { photoCount += 1 } } if photoCount > 0 && videoCount > 0 { description = strings.Stars_Transfer_MediaAnd("**\(strings.Stars_Transfer_Photos(photoCount))**", "**\(strings.Stars_Transfer_Videos(videoCount))**").string } else if photoCount > 0 { if photoCount > 1 { description += "**\(strings.Stars_Transfer_Photos(photoCount))**" } else { description += "**\(strings.Stars_Transfer_SinglePhoto)**" } } else if videoCount > 0 { if videoCount > 1 { description += "**\(strings.Stars_Transfer_Videos(videoCount))**" } else { description += "**\(strings.Stars_Transfer_SingleVideo)**" } } infoText = strings.Stars_Transfer_UnlockInfo( description, state.chatPeer?.compactDisplayTitle ?? "", strings.Stars_Transfer_Info_Stars(Int32(amount)) ).string } else { infoText = strings.Stars_Transfer_Info( component.invoice.title, state.botPeer?.compactDisplayTitle ?? "", strings.Stars_Transfer_Info_Stars(Int32(amount)) ).string } let text = text.update( component: BalancedTextComponent( text: .markdown( text: infoText, attributes: markdownAttributes ), horizontalAlignment: .center, maximumNumberOfLines: 0, lineSpacing: 0.2 ), availableSize: CGSize(width: constrainedTitleWidth, height: context.availableSize.height), transition: .immediate ) context.add(text .position(CGPoint(x: context.availableSize.width / 2.0, y: contentSize.height + text.size.height / 2.0)) ) contentSize.height += text.size.height contentSize.height += 28.0 let balanceTitle = balanceTitle.update( component: MultilineTextComponent( text: .plain(NSAttributedString( string: environment.strings.Stars_Transfer_Balance, font: Font.regular(14.0), textColor: textColor )), maximumNumberOfLines: 1 ), availableSize: context.availableSize, transition: .immediate ) let balanceValue = balanceValue.update( component: MultilineTextComponent( text: .plain(NSAttributedString( string: presentationStringsFormattedNumber(Int32(state.balance ?? 0), environment.dateTimeFormat.groupingSeparator), font: Font.semibold(16.0), textColor: textColor )), maximumNumberOfLines: 1 ), availableSize: context.availableSize, transition: .immediate ) let balanceIcon = balanceIcon.update( component: BundleIconComponent(name: "Premium/Stars/StarSmall", tintColor: nil), availableSize: context.availableSize, transition: .immediate ) let topBalanceOriginY = 11.0 context.add(balanceTitle .position(CGPoint(x: 16.0 + environment.safeInsets.left + balanceTitle.size.width / 2.0, y: topBalanceOriginY + balanceTitle.size.height / 2.0)) ) context.add(balanceIcon .position(CGPoint(x: 16.0 + environment.safeInsets.left + balanceIcon.size.width / 2.0, y: topBalanceOriginY + balanceTitle.size.height + balanceValue.size.height / 2.0 + 1.0 + UIScreenPixel)) ) context.add(balanceValue .position(CGPoint(x: 16.0 + environment.safeInsets.left + balanceIcon.size.width + 3.0 + balanceValue.size.width / 2.0, y: topBalanceOriginY + balanceTitle.size.height + balanceValue.size.height / 2.0 + 2.0 - UIScreenPixel)) ) if state.cachedStarImage == nil || state.cachedStarImage?.1 !== theme { state.cachedStarImage = (generateTintedImage(image: UIImage(bundleImageName: "Item List/PremiumIcon"), color: theme.list.itemCheckColors.foregroundColor)!, theme) } let amountString = presentationStringsFormattedNumber(Int32(amount), presentationData.dateTimeFormat.groupingSeparator) let buttonAttributedString = NSMutableAttributedString(string: "\(strings.Stars_Transfer_Pay) # \(amountString)", font: Font.semibold(17.0), textColor: theme.list.itemCheckColors.foregroundColor, paragraphAlignment: .center) if let range = buttonAttributedString.string.range(of: "#"), let starImage = state.cachedStarImage?.0 { buttonAttributedString.addAttribute(.attachment, value: starImage, range: NSRange(range, in: buttonAttributedString.string)) buttonAttributedString.addAttribute(.foregroundColor, value: UIColor(rgb: 0xffffff), range: NSRange(range, in: buttonAttributedString.string)) buttonAttributedString.addAttribute(.baselineOffset, value: 1.0, range: NSRange(range, in: buttonAttributedString.string)) } let controller = environment.controller() as? StarsTransferScreen let accountContext = component.context let starsContext = component.starsContext let botTitle = state.botPeer?.compactDisplayTitle ?? "" let invoice = component.invoice let isMedia = !component.extendedMedia.isEmpty let button = button.update( component: ButtonComponent( background: ButtonComponent.Background( color: theme.list.itemCheckColors.fillColor, foreground: theme.list.itemCheckColors.foregroundColor, pressedColor: theme.list.itemCheckColors.fillColor.withMultipliedAlpha(0.9), cornerRadius: 10.0 ), content: AnyComponentWithIdentity( id: AnyHashable(0), component: AnyComponent(MultilineTextComponent(text: .plain(buttonAttributedString))) ), isEnabled: true, displaysProgress: state.inProgress, action: { [weak state, weak controller] in state?.buy(requestTopUp: { [weak controller] completion in let premiumConfiguration = PremiumConfiguration.with(appConfiguration: accountContext.currentAppConfiguration.with { $0 }) if !premiumConfiguration.isPremiumDisabled { let purpose: StarsPurchasePurpose if isMedia { purpose = .unlockMedia(requiredStars: invoice.totalAmount) } else if let peerId = state?.botPeer?.id { purpose = .transfer(peerId: peerId, requiredStars: invoice.totalAmount) } else { purpose = .generic } let purchaseController = accountContext.sharedContext.makeStarsPurchaseScreen( context: accountContext, starsContext: starsContext, options: state?.options ?? [], purpose: purpose, completion: { [weak starsContext] stars in starsContext?.add(balance: stars) Queue.mainQueue().after(0.1) { completion() } } ) controller?.push(purchaseController) } else { let alertController = textAlertController(context: accountContext, title: nil, text: presentationData.strings.Stars_Transfer_Unavailable, actions: [TextAlertAction(type: .defaultAction, title: presentationData.strings.Common_OK, action: {})]) controller?.present(alertController, in: .window(.root)) } }, completion: { [weak controller] success in if success { let presentationData = accountContext.sharedContext.currentPresentationData.with { $0 } let text: String if let _ = component.invoice.extendedMedia { text = presentationData.strings.Stars_Transfer_UnlockedText( presentationData.strings.Stars_Transfer_Purchased_Stars(Int32(invoice.totalAmount))).string } else { text = presentationData.strings.Stars_Transfer_PurchasedText(invoice.title, botTitle, presentationData.strings.Stars_Transfer_Purchased_Stars(Int32(invoice.totalAmount))).string } if let navigationController = controller?.navigationController { Queue.mainQueue().after(0.5) { if let lastController = navigationController.viewControllers.last as? ViewController { let resultController = UndoOverlayController( presentationData: presentationData, content: .image( image: UIImage(bundleImageName: "Premium/Stars/StarLarge")!, title: presentationData.strings.Stars_Transfer_PurchasedTitle, text: text, round: false, undoText: nil ), elevatedLayout: lastController is ChatController, action: { _ in return true} ) lastController.present(resultController, in: .window(.root)) } } } } controller?.complete(paid: success) controller?.dismissAnimated() starsContext.load(force: true) }) } ), availableSize: CGSize(width: 361.0, height: 50), transition: .immediate ) context.add(button .clipsToBounds(true) .cornerRadius(10.0) .position(CGPoint(x: context.availableSize.width / 2.0, y: contentSize.height + button.size.height / 2.0)) ) contentSize.height += button.size.height contentSize.height += 48.0 return contentSize } } } private final class StarsTransferSheetComponent: CombinedComponent { typealias EnvironmentType = ViewControllerComponentContainer.Environment private let context: AccountContext private let starsContext: StarsContext private let invoice: TelegramMediaInvoice private let source: BotPaymentInvoiceSource private let extendedMedia: [TelegramExtendedMedia] private let inputData: Signal<(StarsContext.State, BotPaymentForm, EnginePeer?)?, NoError> init( context: AccountContext, starsContext: StarsContext, invoice: TelegramMediaInvoice, source: BotPaymentInvoiceSource, extendedMedia: [TelegramExtendedMedia], inputData: Signal<(StarsContext.State, BotPaymentForm, EnginePeer?)?, NoError> ) { self.context = context self.starsContext = starsContext self.invoice = invoice self.source = source self.extendedMedia = extendedMedia self.inputData = inputData } static func ==(lhs: StarsTransferSheetComponent, rhs: StarsTransferSheetComponent) -> Bool { if lhs.context !== rhs.context { return false } if lhs.invoice != rhs.invoice { return false } if lhs.extendedMedia != rhs.extendedMedia { return false } return true } static var body: Body { let sheet = Child(SheetComponent<(EnvironmentType)>.self) let animateOut = StoredActionSlot(Action.self) return { context in let environment = context.environment[EnvironmentType.self] let controller = environment.controller let sheet = sheet.update( component: SheetComponent( content: AnyComponent(SheetContent( context: context.component.context, starsContext: context.component.starsContext, invoice: context.component.invoice, source: context.component.source, extendedMedia: context.component.extendedMedia, inputData: context.component.inputData, dismiss: { animateOut.invoke(Action { _ in if let controller = controller() { controller.dismiss(completion: nil) } }) } )), backgroundColor: .blur(.light), followContentSizeChanges: true, clipsContent: true, animateOut: animateOut ), environment: { environment SheetComponentEnvironment( isDisplaying: environment.value.isVisible, isCentered: environment.metrics.widthClass == .regular, hasInputHeight: !environment.inputHeight.isZero, regularMetricsSize: CGSize(width: 430.0, height: 900.0), dismiss: { animated in if animated { animateOut.invoke(Action { _ in if let controller = controller() { controller.dismiss(completion: nil) } }) } else { if let controller = controller() { controller.dismiss(completion: nil) } } } ) }, availableSize: context.availableSize, transition: context.transition ) context.add(sheet .position(CGPoint(x: context.availableSize.width / 2.0, y: context.availableSize.height / 2.0)) ) return context.availableSize } } } public final class StarsTransferScreen: ViewControllerComponentContainer { private let context: AccountContext private let extendedMedia: [TelegramExtendedMedia] private let completion: (Bool) -> Void public init( context: AccountContext, starsContext: StarsContext, invoice: TelegramMediaInvoice, source: BotPaymentInvoiceSource, extendedMedia: [TelegramExtendedMedia], inputData: Signal<(StarsContext.State, BotPaymentForm, EnginePeer?)?, NoError>, completion: @escaping (Bool) -> Void ) { self.context = context self.extendedMedia = extendedMedia self.completion = completion super.init( context: context, component: StarsTransferSheetComponent( context: context, starsContext: starsContext, invoice: invoice, source: source, extendedMedia: extendedMedia, inputData: inputData ), navigationBarAppearance: .none, statusBarStyle: .ignore, theme: .default ) self.navigationPresentation = .flatModal starsContext.load(force: false) } deinit { self.complete(paid: false) } required public init(coder aDecoder: NSCoder) { fatalError("init(coder:) has not been implemented") } private var didComplete = false fileprivate func complete(paid: Bool) { guard !self.didComplete else { return } self.didComplete = true self.completion(paid) if !self.extendedMedia.isEmpty && paid { self.navigationController?.view.addSubview(ConfettiView(frame: self.view.bounds, customImage: UIImage(bundleImageName: "Peer Info/PremiumIcon"))) } } public func dismissAnimated() { if let view = self.node.hostView.findTaggedView(tag: SheetComponent.View.Tag()) as? SheetComponent.View { view.dismissAnimated() } } } private func generateCloseButtonImage(backgroundColor: UIColor, foregroundColor: UIColor) -> UIImage? { return generateImage(CGSize(width: 30.0, height: 30.0), contextGenerator: { size, context in context.clear(CGRect(origin: CGPoint(), size: size)) context.setFillColor(backgroundColor.cgColor) context.fillEllipse(in: CGRect(origin: CGPoint(), size: size)) context.setLineWidth(2.0) context.setLineCap(.round) context.setStrokeColor(foregroundColor.cgColor) context.move(to: CGPoint(x: 10.0, y: 10.0)) context.addLine(to: CGPoint(x: 20.0, y: 20.0)) context.strokePath() context.move(to: CGPoint(x: 20.0, y: 10.0)) context.addLine(to: CGPoint(x: 10.0, y: 20.0)) context.strokePath() }) }