import Foundation import UIKit import Display import AsyncDisplayKit import SwiftSignalKit import Postbox import TelegramCore import TelegramPresentationData import TelegramUIPreferences import PresentationDataUtils import AccountContext import ComponentFlow import ViewControllerComponent import MultilineTextComponent import BalancedTextComponent import BundleIconComponent import Markdown import TelegramStringFormatting import PlainButtonComponent import BlurredBackgroundComponent import PremiumStarComponent import TextFormat import GiftItemComponent import InAppPurchaseManager import TabSelectorComponent import GiftSetupScreen import GiftViewScreen import UndoUI final class GiftOptionsScreenComponent: Component { typealias EnvironmentType = ViewControllerComponentContainer.Environment let context: AccountContext let starsContext: StarsContext let peerId: EnginePeer.Id let premiumOptions: [CachedPremiumGiftOption] let hasBirthday: Bool let completion: (() -> Void)? init( context: AccountContext, starsContext: StarsContext, peerId: EnginePeer.Id, premiumOptions: [CachedPremiumGiftOption], hasBirthday: Bool, completion: (() -> Void)? ) { self.context = context self.starsContext = starsContext self.peerId = peerId self.premiumOptions = premiumOptions self.hasBirthday = hasBirthday self.completion = completion } static func ==(lhs: GiftOptionsScreenComponent, rhs: GiftOptionsScreenComponent) -> Bool { if lhs.context !== rhs.context { return false } if lhs.peerId != rhs.peerId { return false } if lhs.premiumOptions != rhs.premiumOptions { return false } if lhs.hasBirthday != rhs.hasBirthday { return false } return true } private final class ScrollView: UIScrollView { override func touchesShouldCancel(in view: UIView) -> Bool { return true } } public enum StarsFilter: Equatable { case all case limited case inStock case stars(Int64) case transfer init(rawValue: Int64) { switch rawValue { case 0: self = .all case -1: self = .limited case -2: self = .inStock case -3: self = .transfer default: self = .stars(rawValue) } } public var rawValue: Int64 { switch self { case .all: return 0 case .limited: return -1 case .inStock: return -2 case .transfer: return -3 case let .stars(stars): return stars } } } final class View: UIView, UIScrollViewDelegate { private let topOverscrollLayer = SimpleLayer() private let scrollView: ScrollView private let topPanel = ComponentView() private let topSeparator = ComponentView() private let cancelButton = ComponentView() private let header = ComponentView() private let balanceTitle = ComponentView() private let balanceValue = ComponentView() private let balanceIcon = ComponentView() private let premiumTitle = ComponentView() private let premiumDescription = ComponentView() private var premiumItems: [AnyHashable: ComponentView] = [:] private let starsTitle = ComponentView() private let starsDescription = ComponentView() private var starsItems: [AnyHashable: ComponentView] = [:] private let tabSelector = ComponentView() private var starsFilter: StarsFilter = .all private var _effectiveStarGifts: ([StarGift], StarsFilter)? private var effectiveStarGifts: [StarGift]? { get { if let (currentGifts, currentFilter) = self._effectiveStarGifts, currentFilter == self.starsFilter && currentFilter != .transfer { return currentGifts } else if let allGifts = self.state?.starGifts { if case .transfer = self.starsFilter { let filteredGifts: [StarGift] = self.state?.transferStarGifts?.map { gift in return gift.gift } ?? [] self._effectiveStarGifts = (filteredGifts, self.starsFilter) return filteredGifts } else { var sortedGifts = allGifts if self.component?.hasBirthday == true { var updatedGifts: [StarGift] = [] for starGift in allGifts { if case let .generic(gift) = starGift { if gift.flags.contains(.isBirthdayGift) { updatedGifts.append(starGift) } } } for starGift in allGifts { if case let .generic(gift) = starGift { if !gift.flags.contains(.isBirthdayGift) { updatedGifts.append(starGift) } } } sortedGifts = updatedGifts } let filteredGifts: [StarGift] = sortedGifts.filter { switch self.starsFilter { case .all: return true case .limited: if case let .generic(gift) = $0 { if gift.availability != nil { return true } } case .inStock: if case let .generic(gift) = $0 { if gift.availability == nil || gift.availability!.remains > 0 { return true } } case let .stars(stars): if case let .generic(gift) = $0 { if gift.price == stars { return true } } case .transfer: break } return false } self._effectiveStarGifts = (filteredGifts, self.starsFilter) return filteredGifts } } else { return nil } } } private var isUpdating: Bool = false private var starsStateDisposable: Disposable? private var starsState: StarsContext.State? private let optionsPromise = Promise<[StarsTopUpOption]?>(nil) private var component: GiftOptionsScreenComponent? private(set) weak var state: State? private var environment: EnvironmentType? private var starsItemsOrigin: CGFloat = 0.0 private var dismissed = false private var chevronImage: (UIImage, PresentationTheme)? override init(frame: CGRect) { self.scrollView = ScrollView() self.scrollView.showsVerticalScrollIndicator = true self.scrollView.showsHorizontalScrollIndicator = false self.scrollView.scrollsToTop = false self.scrollView.delaysContentTouches = false self.scrollView.canCancelContentTouches = true self.scrollView.contentInsetAdjustmentBehavior = .never if #available(iOS 13.0, *) { self.scrollView.automaticallyAdjustsScrollIndicatorInsets = false } self.scrollView.alwaysBounceVertical = true super.init(frame: frame) self.scrollView.delegate = self self.addSubview(self.scrollView) self.scrollView.layer.addSublayer(self.topOverscrollLayer) } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } deinit { self.starsStateDisposable?.dispose() } func scrollToTop() { self.scrollView.setContentOffset(CGPoint(), animated: true) } var nextScrollTransition: ComponentTransition? func scrollViewDidScroll(_ scrollView: UIScrollView) { self.updateScrolling(interactive: true, transition: self.nextScrollTransition ?? .immediate) } private func updateScrolling(interactive: Bool = false, transition: ComponentTransition) { guard let environment = self.environment, let component = self.component else { return } let availableWidth = self.scrollView.bounds.width let contentOffset = self.scrollView.contentOffset.y let topPanelAlpha = min(20.0, max(0.0, contentOffset - 95.0)) / 20.0 if let topPanelView = self.topPanel.view, let topSeparator = self.topSeparator.view { transition.setAlpha(view: topPanelView, alpha: topPanelAlpha) transition.setAlpha(view: topSeparator, alpha: topPanelAlpha) } let topInset: CGFloat = 0.0 let headerTopInset: CGFloat = environment.navigationHeight - 56.0 let premiumTitleInitialPosition = (topInset + 160.0) let premiumTitleOffsetDelta = premiumTitleInitialPosition - (environment.statusBarHeight + (environment.navigationHeight - environment.statusBarHeight) / 2.0) let premiumTitleOffset = contentOffset + max(0.0, min(1.0, contentOffset / premiumTitleOffsetDelta)) * 10.0 let premiumTitleFraction = max(0.0, min(1.0, premiumTitleOffset / premiumTitleOffsetDelta)) let premiumTitleScale = 1.0 - premiumTitleFraction * 0.36 var premiumTitleAdditionalOffset: CGFloat = 0.0 let starsTitleOffsetDelta = (topInset + 100.0) - (environment.statusBarHeight + (environment.navigationHeight - environment.statusBarHeight) / 2.0) let starsTitleOffset: CGFloat let starsTitleFraction: CGFloat if contentOffset > 350, self.starsTitle.view != nil { starsTitleOffset = contentOffset + max(0.0, min(1.0, (contentOffset - 350.0) / starsTitleOffsetDelta)) * 10.0 starsTitleFraction = max(0.0, min(1.0, (starsTitleOffset - 350.0) / starsTitleOffsetDelta)) if contentOffset > 380.0 { premiumTitleAdditionalOffset = contentOffset - 380.0 } } else { starsTitleOffset = contentOffset starsTitleFraction = 0.0 } let starsTitleScale = 1.0 - starsTitleFraction * 0.36 if let starsTitleView = self.starsTitle.view { var starsTitlePosition: CGFloat = 455.0 if let descriptionPosition = self.starsDescription.view?.frame.minY { starsTitlePosition = descriptionPosition - 28.0 } transition.setPosition(view: starsTitleView, position: CGPoint(x: availableWidth / 2.0, y: max(topInset + starsTitlePosition - starsTitleOffset, environment.statusBarHeight + (environment.navigationHeight - environment.statusBarHeight) / 2.0))) transition.setScale(view: starsTitleView, scale: starsTitleScale) } if let premiumTitleView = self.premiumTitle.view { transition.setPosition(view: premiumTitleView, position: CGPoint(x: availableWidth / 2.0, y: max(premiumTitleInitialPosition - premiumTitleOffset, environment.statusBarHeight + (environment.navigationHeight - environment.statusBarHeight) / 2.0) - premiumTitleAdditionalOffset)) transition.setScale(view: premiumTitleView, scale: premiumTitleScale) } if let headerView = self.header.view { transition.setPosition(view: headerView, position: CGPoint(x: availableWidth / 2.0, y: headerTopInset + headerView.bounds.height / 2.0 - 30.0 - premiumTitleOffset * premiumTitleScale)) transition.setScale(view: headerView, scale: premiumTitleScale) } let visibleBounds = self.scrollView.bounds.insetBy(dx: 0.0, dy: -10.0) if let starGifts = self.effectiveStarGifts { let sideInset: CGFloat = 16.0 + environment.safeInsets.left let optionSpacing: CGFloat = 10.0 let optionWidth = (availableWidth - sideInset * 2.0 - optionSpacing * 2.0) / 3.0 let starsOptionSize = CGSize(width: optionWidth, height: 154.0) var validIds: [AnyHashable] = [] var itemFrame = CGRect(origin: CGPoint(x: sideInset, y: self.starsItemsOrigin), size: starsOptionSize) let controller = environment.controller for gift in starGifts { var isVisible = false if visibleBounds.intersects(itemFrame) { isVisible = true } if isVisible { let itemId = AnyHashable(gift.id) validIds.append(itemId) var itemTransition = transition let visibleItem: ComponentView if let current = self.starsItems[itemId] { visibleItem = current } else { visibleItem = ComponentView() if !transition.animation.isImmediate { itemTransition = .immediate } self.starsItems[itemId] = visibleItem } var ribbon: GiftItemComponent.Ribbon? var isSoldOut = false if case let .generic(gift) = gift { if let _ = gift.soldOut { ribbon = GiftItemComponent.Ribbon( text: environment.strings.Gift_Options_Gift_SoldOut, color: .red ) isSoldOut = true } else if let _ = gift.availability { ribbon = GiftItemComponent.Ribbon( text: environment.strings.Gift_Options_Gift_Limited, color: .blue ) } } let subject: GiftItemComponent.Subject switch gift { case let .generic(gift): subject = .starGift(gift: gift, price: "⭐️ \(gift.price)") case let .unique(gift): subject = .uniqueGift(gift: gift) } let _ = visibleItem.update( transition: itemTransition, component: AnyComponent( PlainButtonComponent( content: AnyComponent( GiftItemComponent( context: component.context, theme: environment.theme, strings: environment.strings, peer: nil, subject: subject, ribbon: ribbon, isSoldOut: isSoldOut ) ), effectAlignment: .center, action: { [weak self] in if let self, let component = self.component { if let controller = controller() as? GiftOptionsScreen { let mainController: ViewController if let parentController = controller.parentController() { mainController = parentController } else { mainController = controller } if case let .generic(gift) = gift { if gift.availability?.remains == 0 { let giftController = GiftViewScreen( context: component.context, subject: .soldOutGift(gift) ) mainController.push(giftController) } else { var forceUnique = false if let disallowedGifts = self.state?.disallowedGifts, disallowedGifts.contains(.limited) && !disallowedGifts.contains(.unique) { forceUnique = true } let giftController = GiftSetupScreen( context: component.context, peerId: component.peerId, subject: .starGift(gift, forceUnique), completion: component.completion ) mainController.push(giftController) } } else if case let .unique(gift) = gift { self.transferGift(gift) } } } }, animateAlpha: false ) ), environment: {}, containerSize: starsOptionSize ) if let itemView = visibleItem.view { if itemView.superview == nil { self.scrollView.addSubview(itemView) if !transition.animation.isImmediate { transition.animateAlpha(view: itemView, from: 0.0, to: 1.0) transition.animateScale(view: itemView, from: 0.01, to: 1.0) } } itemTransition.setFrame(view: itemView, frame: itemFrame) } } itemFrame.origin.x += itemFrame.width + optionSpacing if itemFrame.maxX > availableWidth { itemFrame.origin.x = sideInset itemFrame.origin.y += starsOptionSize.height + optionSpacing } } var removeIds: [AnyHashable] = [] for (id, item) in self.starsItems { if !validIds.contains(id) { removeIds.append(id) if let itemView = item.view { if !transition.animation.isImmediate { itemView.layer.animateScale(from: 1.0, to: 0.01, duration: 0.25, removeOnCompletion: false) itemView.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.25, removeOnCompletion: false, completion: { _ in itemView.removeFromSuperview() }) } else { itemView.removeFromSuperview() } } } } for id in removeIds { self.starsItems.removeValue(forKey: id) } } let bottomContentOffset = max(0.0, self.scrollView.contentSize.height - self.scrollView.contentOffset.y - self.scrollView.frame.height) if interactive, bottomContentOffset < 320.0, case .transfer = self.starsFilter { self.state?.starGiftsContext.loadMore() } } func transferGift(_ transferGift: StarGift.UniqueGift) { guard let component = self.component, let environment = self.environment, let peer = self.state?.peer, let controller = environment.controller() as? GiftOptionsScreen else { return } guard let gift = self.state?.transferStarGifts?.first(where: { gift in if case let .unique(gift) = gift.gift, gift.slug == transferGift.slug { return true } else { return false } }), let reference = gift.reference else { return } let mainController: ViewController if let parentController = controller.parentController() { mainController = parentController } else { mainController = controller } let context = component.context let presentationData = context.sharedContext.currentPresentationData.with { $0 } var dismissAlertImpl: (() -> Void)? let alertController = giftTransferAlertController( context: context, gift: transferGift, peer: peer, transferStars: gift.transferStars ?? 0, navigationController: mainController.navigationController as? NavigationController, commit: { [weak self, weak mainController] in let proceed: (Bool) -> Void = { waitForTopUp in var errorImpl: ((TransferStarGiftError) -> Void)? var completedImpl: (() -> Void)? if waitForTopUp, let starsContext = context.starsContext { let _ = (starsContext.onUpdate |> deliverOnMainQueue).start(next: { let _ = (context.engine.payments.transferStarGift(prepaid: gift.transferStars == 0, reference: reference, peerId: peer.id) |> deliverOnMainQueue).start(error: { error in errorImpl?(error) }, completed: { completedImpl?() }) }) } else { let _ = (context.engine.payments.transferStarGift(prepaid: gift.transferStars == 0, reference: reference, peerId: peer.id) |> deliverOnMainQueue).start(error: { error in errorImpl?(error) }, completed: { completedImpl?() }) } guard let controller = mainController, let navigationController = controller.navigationController as? NavigationController else { return } errorImpl = { [weak navigationController] error in guard let navigationController else { return } dismissAlertImpl?() var errorText: String? switch error { case .disallowedStarGift: errorText = presentationData.strings.Gift_Send_ErrorDisallowed(peer.compactDisplayTitle).string default: errorText = presentationData.strings.Gift_Send_ErrorUnknown } if let errorText = errorText { let alertController = textAlertController(context: context, title: nil, text: errorText, actions: [TextAlertAction(type: .defaultAction, title: presentationData.strings.Common_OK, action: {})], parseMarkdown: true) if let lastController = navigationController.viewControllers.last as? ViewController { lastController.present(alertController, in: .window(.root)) } } } completedImpl = { dismissAlertImpl?() if peer.id.namespace == Namespaces.Peer.CloudChannel { var controllers = navigationController.viewControllers controllers = controllers.filter { !($0 is GiftSetupScreen) && !($0 is GiftOptionsScreenProtocol) } var foundController = false for controller in controllers.reversed() { if let controller = controller as? PeerInfoScreen, controller.peerId == component.peerId { foundController = true break } } if !foundController { if let controller = context.sharedContext.makePeerInfoController( context: context, updatedPresentationData: nil, peer: peer._asPeer(), mode: .gifts, avatarInitiallyExpanded: false, fromChat: false, requestsContext: nil ) { controllers.append(controller) } } navigationController.setViewControllers(controllers, animated: true) } else { var controllers = navigationController.viewControllers controllers = controllers.filter { !($0 is GiftSetupScreen) && !($0 is GiftOptionsScreenProtocol) && !($0 is PeerInfoScreen) && !($0 is ContactSelectionController) } var foundController = false for controller in controllers.reversed() { if let chatController = controller as? ChatController, case .peer(id: component.peerId) = chatController.chatLocation { chatController.hintPlayNextOutgoingGift() foundController = true break } } if !foundController { let chatController = component.context.sharedContext.makeChatController(context: component.context, chatLocation: .peer(id: component.peerId), subject: nil, botStart: nil, mode: .standard(.default), params: nil) chatController.hintPlayNextOutgoingGift() controllers.append(chatController) } navigationController.setViewControllers(controllers, animated: true) } if let completion = component.completion { completion() } } } if let self, let transferStars = gift.transferStars, transferStars > 0, let starsContext = context.starsContext, let starsState = self.starsState { if starsState.balance < StarsAmount(value: transferStars, nanos: 0) { let _ = (self.optionsPromise.get() |> filter { $0 != nil } |> take(1) |> deliverOnMainQueue).startStandalone(next: { [weak mainController] options in let purchaseController = context.sharedContext.makeStarsPurchaseScreen( context: context, starsContext: starsContext, options: options ?? [], purpose: .transferStarGift(requiredStars: transferStars), completion: { stars in starsContext.add(balance: StarsAmount(value: stars, nanos: 0)) proceed(true) } ) mainController?.push(purchaseController) }) } else { proceed(false) } } else { proceed(false) } } ) controller.present(alertController, in: .current) dismissAlertImpl = { [weak alertController] in alertController?.dismissAnimated() } } func update(component: GiftOptionsScreenComponent, availableSize: CGSize, state: State, environment: Environment, transition: ComponentTransition) -> CGSize { self.isUpdating = true defer { self.isUpdating = false } let environment = environment[EnvironmentType.self].value let controller = environment.controller let themeUpdated = self.environment?.theme !== environment.theme self.environment = environment self.state = state if self.component == nil { self.starsStateDisposable = (component.starsContext.state |> deliverOnMainQueue).start(next: { [weak self] state in guard let self else { return } self.starsState = state if !self.isUpdating { self.state?.updated() } }) if let state = component.starsContext.currentState, state.balance < StarsAmount(value: 100, nanos: 0) { self.optionsPromise.set(component.context.engine.payments.starsTopUpOptions() |> map(Optional.init)) } } self.component = component let theme = environment.theme let strings = environment.strings if let disallowedGifts = self.state?.disallowedGifts, disallowedGifts == .All, let controller = controller(), !self.dismissed { if let navigationController = controller.navigationController as? NavigationController, let peer = state.peer { Queue.mainQueue().after(0.3) { let alertController = textAlertController(context: component.context, title: nil, text: strings.Gift_Send_GiftsDisallowed(peer.compactDisplayTitle).string, actions: [TextAlertAction(type: .defaultAction, title: strings.Common_OK, action: {})]) (navigationController.viewControllers.last as? ViewController)?.present(alertController, in: .window(.root)) } } controller.dismiss() self.dismissed = true } if (state.starGifts ?? []).isEmpty && !(state.transferStarGifts ?? []).isEmpty { self.starsFilter = .transfer } if themeUpdated { self.backgroundColor = environment.theme.list.blocksBackgroundColor } let textColor = theme.list.itemPrimaryTextColor let accentColor = theme.list.itemAccentColor let textFont = Font.regular(15.0) let boldTextFont = Font.semibold(15.0) let bottomContentInset: CGFloat = 24.0 let sideInset: CGFloat = 16.0 + environment.safeInsets.left let headerSideInset: CGFloat = 24.0 + environment.safeInsets.left let premiumConfiguration = PremiumConfiguration.with(appConfiguration: component.context.currentAppConfiguration.with { $0 }) let isPremiumDisabled = premiumConfiguration.isPremiumDisabled || state.disallowedGifts?.contains(.premium) == true let isSelfGift = component.peerId == component.context.account.peerId let isChannelGift = component.peerId.namespace == Namespaces.Peer.CloudChannel var contentHeight: CGFloat = 0.0 contentHeight += environment.navigationHeight - 56.0 + 188.0 let headerSize = self.header.update( transition: .immediate, component: AnyComponent( GiftAvatarComponent( context: component.context, theme: theme, peers: state.peer.flatMap { [$0] } ?? [], isVisible: true, hasIdleAnimations: true, color: UIColor(rgb: 0xf9b004), hasLargeParticles: true, action: { [weak self] in guard let self, let component = self.component, let controller = controller(), let navigationController = controller.navigationController as? NavigationController else { return } let _ = (component.context.engine.data.get( TelegramEngine.EngineData.Item.Peer.Peer(id: component.peerId) ) |> deliverOnMainQueue).start(next: { peer in guard let peer else { return } 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)) }) } ) ), environment: {}, containerSize: CGSize(width: min(414.0, availableSize.width), height: 220.0) ) if let headerView = self.header.view { if headerView.superview == nil { self.addSubview(headerView) } transition.setBounds(view: headerView, bounds: CGRect(origin: .zero, size: headerSize)) } let topPanelSize = self.topPanel.update( transition: transition, component: AnyComponent(BlurredBackgroundComponent( color: theme.rootController.navigationBar.blurredBackgroundColor )), environment: {}, containerSize: CGSize(width: availableSize.width, height: environment.navigationHeight) ) let topSeparatorSize = self.topSeparator.update( transition: transition, component: AnyComponent(Rectangle( color: theme.rootController.navigationBar.separatorColor )), environment: {}, containerSize: CGSize(width: availableSize.width, height: UIScreenPixel) ) let topPanelFrame = CGRect(origin: .zero, size: CGSize(width: availableSize.width, height: topPanelSize.height)) let topSeparatorFrame = CGRect(origin: CGPoint(x: 0.0, y: topPanelSize.height), size: CGSize(width: topSeparatorSize.width, height: topSeparatorSize.height)) if let topPanelView = self.topPanel.view, let topSeparatorView = self.topSeparator.view { if topPanelView.superview == nil { self.addSubview(topPanelView) self.addSubview(topSeparatorView) } transition.setFrame(view: topPanelView, frame: topPanelFrame) transition.setFrame(view: topSeparatorView, frame: topSeparatorFrame) } let cancelButtonSize = self.cancelButton.update( transition: transition, component: AnyComponent( PlainButtonComponent( content: AnyComponent( MultilineTextComponent( text: .plain(NSAttributedString(string: strings.Common_Cancel, font: Font.regular(17.0), textColor: theme.rootController.navigationBar.accentTextColor)), horizontalAlignment: .center ) ), effectAlignment: .center, action: { controller()?.dismiss() }, animateScale: false ) ), environment: {}, containerSize: CGSize(width: availableSize.width, height: 100.0) ) let cancelButtonFrame = CGRect(origin: CGPoint(x: environment.safeInsets.left + 16.0, y: environment.statusBarHeight + (environment.navigationHeight - environment.statusBarHeight) / 2.0 - cancelButtonSize.height / 2.0), size: cancelButtonSize) if let cancelButtonView = self.cancelButton.view { if cancelButtonView.superview == nil { self.addSubview(cancelButtonView) } transition.setFrame(view: cancelButtonView, frame: cancelButtonFrame) } let balanceTitleSize = self.balanceTitle.update( transition: .immediate, component: AnyComponent(MultilineTextComponent( text: .plain(NSAttributedString( string: strings.Stars_Purchase_Balance, font: Font.regular(14.0), textColor: environment.theme.actionSheet.primaryTextColor )), maximumNumberOfLines: 1 )), environment: {}, containerSize: availableSize ) let formattedBalance = formatStarsAmountText(self.starsState?.balance ?? StarsAmount.zero, dateTimeFormat: environment.dateTimeFormat) let smallLabelFont = Font.regular(11.0) let labelFont = Font.semibold(14.0) let balanceText = tonAmountAttributedString(formattedBalance, integralFont: labelFont, fractionalFont: smallLabelFont, color: environment.theme.actionSheet.primaryTextColor, decimalSeparator: environment.dateTimeFormat.decimalSeparator) let balanceValueSize = self.balanceValue.update( transition: .immediate, component: AnyComponent(MultilineTextComponent( text: .plain(balanceText), maximumNumberOfLines: 1 )), environment: {}, containerSize: availableSize ) let balanceIconSize = self.balanceIcon.update( transition: .immediate, component: AnyComponent(BundleIconComponent(name: "Premium/Stars/StarSmall", tintColor: nil)), environment: {}, containerSize: availableSize ) if let balanceTitleView = self.balanceTitle.view, let balanceValueView = self.balanceValue.view, let balanceIconView = self.balanceIcon.view { if balanceTitleView.superview == nil { self.addSubview(balanceTitleView) self.addSubview(balanceValueView) self.addSubview(balanceIconView) } let navigationHeight = environment.navigationHeight - environment.statusBarHeight let topBalanceOriginY = environment.statusBarHeight + (navigationHeight - balanceTitleSize.height - balanceValueSize.height) / 2.0 balanceTitleView.center = CGPoint(x: availableSize.width - 16.0 - environment.safeInsets.right - balanceTitleSize.width / 2.0, y: topBalanceOriginY + balanceTitleSize.height / 2.0) balanceTitleView.bounds = CGRect(origin: .zero, size: balanceTitleSize) balanceValueView.center = CGPoint(x: availableSize.width - 16.0 - environment.safeInsets.right - balanceValueSize.width / 2.0, y: topBalanceOriginY + balanceTitleSize.height + balanceValueSize.height / 2.0) balanceValueView.bounds = CGRect(origin: .zero, size: balanceValueSize) balanceIconView.center = CGPoint(x: availableSize.width - 16.0 - environment.safeInsets.right - balanceValueSize.width - balanceIconSize.width / 2.0 - 2.0, y: topBalanceOriginY + balanceTitleSize.height + balanceValueSize.height / 2.0 - UIScreenPixel) balanceIconView.bounds = CGRect(origin: .zero, size: balanceIconSize) } let premiumTitleString: String if isSelfGift { premiumTitleString = strings.Gift_Options_GiftSelf_Title } else if isChannelGift { premiumTitleString = strings.Gift_Options_GiftChannel_Title } else if isPremiumDisabled { premiumTitleString = strings.Gift_Options_Gift_Title } else { premiumTitleString = strings.Gift_Options_Premium_Title } let premiumTitleSize = self.premiumTitle.update( transition: transition, component: AnyComponent(MultilineTextComponent( text: .plain(NSAttributedString(string: premiumTitleString, font: Font.bold(28.0), textColor: theme.rootController.navigationBar.primaryTextColor)), horizontalAlignment: .center )), environment: {}, containerSize: CGSize(width: availableSize.width - headerSideInset * 2.0, height: 100.0) ) if let premiumTitleView = self.premiumTitle.view { if premiumTitleView.superview == nil { self.addSubview(premiumTitleView) } transition.setBounds(view: premiumTitleView, bounds: CGRect(origin: .zero, size: premiumTitleSize)) } if self.chevronImage == nil || self.chevronImage?.1 !== theme { self.chevronImage = (generateTintedImage(image: UIImage(bundleImageName: "Settings/TextArrowRight"), color: accentColor)!, theme) } let markdownAttributes = MarkdownAttributes(body: MarkdownAttributeSet(font: textFont, textColor: textColor), bold: MarkdownAttributeSet(font: boldTextFont, textColor: textColor), link: MarkdownAttributeSet(font: textFont, textColor: accentColor), linkAttribute: { contents in return (TelegramTextAttributes.URL, contents) }) let peerName = state.peer?.compactDisplayTitle ?? "" let premiumDescriptionRawString: String if isSelfGift { premiumDescriptionRawString = strings.Gift_Options_GiftSelf_Text } else if isChannelGift { premiumDescriptionRawString = strings.Gift_Options_GiftChannel_Text(peerName).string } else if isPremiumDisabled { premiumDescriptionRawString = strings.Gift_Options_Gift_Text(peerName).string } else { premiumDescriptionRawString = strings.Gift_Options_Premium_Text(peerName).string } let premiumDescriptionString = parseMarkdownIntoAttributedString(premiumDescriptionRawString, attributes: markdownAttributes).mutableCopy() as! NSMutableAttributedString if let range = premiumDescriptionString.string.range(of: ">"), let chevronImage = self.chevronImage?.0 { premiumDescriptionString.addAttribute(.attachment, value: chevronImage, range: NSRange(range, in: premiumDescriptionString.string)) } let premiumDescriptionSize = self.premiumDescription.update( transition: transition, component: AnyComponent(BalancedTextComponent( text: .plain(premiumDescriptionString), horizontalAlignment: .center, maximumNumberOfLines: 0, lineSpacing: 0.2, highlightColor: accentColor.withAlphaComponent(0.1), highlightInset: UIEdgeInsets(top: 0.0, left: 0.0, bottom: 0.0, right: -8.0), highlightAction: { attributes in if let _ = attributes[NSAttributedString.Key(rawValue: TelegramTextAttributes.URL)] { return NSAttributedString.Key(rawValue: TelegramTextAttributes.URL) } else { return nil } }, tapAction: { [weak self] _, _ in guard let self, let component = self.component, let environment = self.environment else { return } let introController: ViewController if isPremiumDisabled { introController = component.context.sharedContext.makeStarsIntroScreen(context: component.context) } else { introController = component.context.sharedContext.makePremiumIntroController(context: component.context, source: .settings, forceDark: false, dismissed: nil) introController.navigationPresentation = .modal } if let controller = environment.controller() as? GiftOptionsScreen { let mainController: ViewController if let parentController = controller.parentController() { mainController = parentController } else { mainController = controller } mainController.push(introController) } } )), environment: {}, containerSize: CGSize(width: availableSize.width - headerSideInset * 2.0, height: 1000.0) ) let premiumDescriptionFrame = CGRect(origin: CGPoint(x: floor((availableSize.width - premiumDescriptionSize.width) / 2.0), y: contentHeight), size: premiumDescriptionSize) if let premiumDescriptionView = self.premiumDescription.view { if premiumDescriptionView.superview == nil { self.scrollView.addSubview(premiumDescriptionView) } transition.setFrame(view: premiumDescriptionView, frame: premiumDescriptionFrame) } contentHeight += premiumDescriptionSize.height contentHeight += 11.0 let optionSpacing: CGFloat = 10.0 let optionWidth = (availableSize.width - sideInset * 2.0 - optionSpacing * 2.0) / 3.0 let showStarPrice = (self.starsState?.balance.value ?? 0) > 10 var hasGenericGifts = false var hasTransferGifts = false if !(self.state?.starGifts ?? []).isEmpty { hasGenericGifts = true } if !(self.state?.transferStarGifts ?? []).isEmpty { hasTransferGifts = true } let hasAnyGifts = hasGenericGifts || hasTransferGifts if isSelfGift || isChannelGift || isPremiumDisabled { contentHeight += 6.0 } else { if let premiumProducts = state.premiumProducts { var premiumOptionSize = CGSize(width: optionWidth, height: 178.0) var validIds: [AnyHashable] = [] var itemFrame = CGRect(origin: CGPoint(x: sideInset, y: contentHeight), size: premiumOptionSize) for product in premiumProducts { if let _ = product.starsPrice { premiumOptionSize.height = 178.0 + 23.0 } let itemId = AnyHashable(product.id) validIds.append(itemId) var itemTransition = transition let visibleItem: ComponentView if let current = self.premiumItems[itemId] { visibleItem = current } else { visibleItem = ComponentView() if !transition.animation.isImmediate { itemTransition = .immediate } self.premiumItems[itemId] = visibleItem } let title: String switch product.months { case 6: title = strings.Gift_Options_Premium_Months(6) case 12: title = strings.Gift_Options_Premium_Years(1) default: title = strings.Gift_Options_Premium_Months(3) } let _ = visibleItem.update( transition: itemTransition, component: AnyComponent( PlainButtonComponent( content: AnyComponent( GiftItemComponent( context: component.context, theme: theme, strings: environment.strings, peer: nil, subject: .premium(months: product.months, price: product.price), title: title, subtitle: strings.Gift_Options_Premium_Premium, label: showStarPrice ? product.starsPrice.flatMap { strings.Gift_Options_Premium_OrStars("**#\(presentationStringsFormattedNumber(Int32($0), environment.dateTimeFormat.groupingSeparator))**").string } : nil, ribbon: product.discount.flatMap { GiftItemComponent.Ribbon( text: "-\($0)%", color: .purple ) }, isLoading: false ) ), effectAlignment: .center, action: { [weak self] in if let self, let component = self.component { if let controller = controller() as? GiftOptionsScreen { let mainController: ViewController if let parentController = controller.parentController() { mainController = parentController } else { mainController = controller } let giftController = GiftSetupScreen( context: component.context, peerId: component.peerId, subject: .premium(product), completion: component.completion ) mainController.push(giftController) } } }, animateAlpha: false ) ), environment: {}, containerSize: premiumOptionSize ) if let itemView = visibleItem.view { if itemView.superview == nil { self.scrollView.addSubview(itemView) if !transition.animation.isImmediate { transition.animateAlpha(view: itemView, from: 0.0, to: 1.0) } } itemTransition.setFrame(view: itemView, frame: itemFrame) } itemFrame.origin.x += itemFrame.width + optionSpacing if itemFrame.maxX > availableSize.width { itemFrame.origin.x = sideInset itemFrame.origin.y += premiumOptionSize.height + optionSpacing } } var removeIds: [AnyHashable] = [] for (id, item) in self.premiumItems { if !validIds.contains(id) { removeIds.append(id) if let itemView = item.view { if !transition.animation.isImmediate { itemView.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.25, removeOnCompletion: false, completion: { _ in itemView.removeFromSuperview() }) } else { itemView.removeFromSuperview() } } } } for id in removeIds { self.premiumItems.removeValue(forKey: id) } contentHeight += ceil(CGFloat(premiumProducts.count) / 3.0) * premiumOptionSize.height contentHeight += 66.0 } if hasAnyGifts { let starsTitleSize = self.starsTitle.update( transition: transition, component: AnyComponent(MultilineTextComponent( text: .plain(NSAttributedString(string: strings.Gift_Options_Gift_Title, font: Font.bold(28.0), textColor: theme.rootController.navigationBar.primaryTextColor)), horizontalAlignment: .center )), environment: {}, containerSize: CGSize(width: availableSize.width - headerSideInset * 2.0, height: 100.0) ) if let starsTitleView = self.starsTitle.view { if starsTitleView.superview == nil { self.addSubview(starsTitleView) } transition.setBounds(view: starsTitleView, bounds: CGRect(origin: .zero, size: starsTitleSize)) } let starsDescriptionString = parseMarkdownIntoAttributedString(strings.Gift_Options_Gift_Text(peerName).string, attributes: markdownAttributes).mutableCopy() as! NSMutableAttributedString if let range = starsDescriptionString.string.range(of: ">"), let chevronImage = self.chevronImage?.0 { starsDescriptionString.addAttribute(.attachment, value: chevronImage, range: NSRange(range, in: starsDescriptionString.string)) } let starsDescriptionSize = self.starsDescription.update( transition: transition, component: AnyComponent(BalancedTextComponent( text: .plain(starsDescriptionString), horizontalAlignment: .center, maximumNumberOfLines: 0, lineSpacing: 0.2, highlightColor: accentColor.withAlphaComponent(0.1), highlightInset: UIEdgeInsets(top: 0.0, left: 0.0, bottom: 0.0, right: -8.0), highlightAction: { attributes in if let _ = attributes[NSAttributedString.Key(rawValue: TelegramTextAttributes.URL)] { return NSAttributedString.Key(rawValue: TelegramTextAttributes.URL) } else { return nil } }, tapAction: { [weak self] _, _ in guard let self, let component = self.component, let environment = self.environment else { return } let introController = component.context.sharedContext.makeStarsIntroScreen(context: component.context) if let controller = environment.controller() as? GiftOptionsScreen { let mainController: ViewController if let parentController = controller.parentController() { mainController = parentController } else { mainController = controller } mainController.push(introController) } } )), environment: {}, containerSize: CGSize(width: availableSize.width - headerSideInset * 2.0, height: 1000.0) ) let starsDescriptionFrame = CGRect(origin: CGPoint(x: floor((availableSize.width - starsDescriptionSize.width) / 2.0), y: contentHeight), size: starsDescriptionSize) if let starsDescriptionView = self.starsDescription.view { if starsDescriptionView.superview == nil { self.scrollView.addSubview(starsDescriptionView) } transition.setFrame(view: starsDescriptionView, frame: starsDescriptionFrame) } contentHeight += starsDescriptionSize.height contentHeight += 16.0 } } if hasGenericGifts { var tabSelectorItems: [TabSelectorComponent.Item] = [] tabSelectorItems.append(TabSelectorComponent.Item( id: AnyHashable(StarsFilter.all.rawValue), title: strings.Gift_Options_Gift_Filter_AllGifts )) if hasTransferGifts { tabSelectorItems.append(TabSelectorComponent.Item( id: AnyHashable(StarsFilter.transfer.rawValue), title: strings.Gift_Options_Gift_Filter_MyGifts )) } var hasLimited = false var starsAmountsSet = Set() if let starGifts = self.state?.starGifts { for gift in starGifts { if case let .generic(gift) = gift { starsAmountsSet.insert(gift.price) if gift.availability != nil { hasLimited = true } } } } if hasLimited { tabSelectorItems.append(TabSelectorComponent.Item( id: AnyHashable(StarsFilter.limited.rawValue), title: strings.Gift_Options_Gift_Filter_Limited )) } tabSelectorItems.append(TabSelectorComponent.Item( id: AnyHashable(StarsFilter.inStock.rawValue), title: strings.Gift_Options_Gift_Filter_InStock )) let starsAmounts = Array(starsAmountsSet).sorted() for amount in starsAmounts { tabSelectorItems.append(TabSelectorComponent.Item( id: AnyHashable(StarsFilter.stars(amount).rawValue), title: "⭐️\(amount)" )) } let tabSelectorSize = self.tabSelector.update( transition: transition, component: AnyComponent(TabSelectorComponent( context: component.context, colors: TabSelectorComponent.Colors( foreground: theme.list.itemSecondaryTextColor, selection: theme.list.itemSecondaryTextColor.withMultipliedAlpha(0.15), simple: true ), items: tabSelectorItems, selectedId: AnyHashable(self.starsFilter.rawValue), setSelectedId: { [weak self] id in guard let self, let idValue = id.base as? Int64 else { return } let starsFilter = StarsFilter(rawValue: idValue) if self.starsFilter != starsFilter { self.starsFilter = starsFilter self.state?.updated(transition: .easeInOut(duration: 0.25)) } } )), environment: {}, containerSize: CGSize(width: availableSize.width - 10.0 * 2.0, height: 50.0) ) if let tabSelectorView = self.tabSelector.view { if tabSelectorView.superview == nil { self.scrollView.addSubview(tabSelectorView) } transition.setFrame(view: tabSelectorView, frame: CGRect(origin: CGPoint(x: floor((availableSize.width - tabSelectorSize.width) / 2.0), y: contentHeight), size: tabSelectorSize)) } contentHeight += tabSelectorSize.height contentHeight += 19.0 } if let starGifts = self.effectiveStarGifts { self.starsItemsOrigin = contentHeight let starsOptionSize = CGSize(width: optionWidth, height: 154.0) let optionSpacing: CGFloat = 10.0 contentHeight += ceil(CGFloat(starGifts.count) / 3.0) * (starsOptionSize.height + optionSpacing) contentHeight += -optionSpacing + 66.0 } contentHeight += bottomContentInset contentHeight += environment.safeInsets.bottom let previousBounds = self.scrollView.bounds let contentSize = CGSize(width: availableSize.width, height: contentHeight) if self.scrollView.frame != CGRect(origin: CGPoint(), size: availableSize) { self.scrollView.frame = CGRect(origin: CGPoint(), size: availableSize) } if self.scrollView.contentSize != contentSize { if contentSize.height < self.scrollView.contentSize.height, !transition.animation.isImmediate { self.nextScrollTransition = transition } self.scrollView.contentSize = contentSize self.nextScrollTransition = nil } let scrollInsets = UIEdgeInsets(top: environment.navigationHeight, left: 0.0, bottom: 0.0, right: 0.0) if self.scrollView.scrollIndicatorInsets != scrollInsets { self.scrollView.scrollIndicatorInsets = scrollInsets } if !previousBounds.isEmpty, !transition.animation.isImmediate { let bounds = self.scrollView.bounds if bounds.maxY != previousBounds.maxY { let offsetY = previousBounds.maxY - bounds.maxY transition.animateBoundsOrigin(view: self.scrollView, from: CGPoint(x: 0.0, y: offsetY), to: CGPoint(), additive: true) } } self.topOverscrollLayer.frame = CGRect(origin: CGPoint(x: 0.0, y: -3000.0), size: CGSize(width: availableSize.width, height: 3000.0)) self.updateScrolling(transition: transition) return availableSize } } func makeView() -> View { return View() } final class State: ComponentState { private let context: AccountContext private var disposable: Disposable? private var updateDisposable: Disposable? fileprivate var peer: EnginePeer? fileprivate var disallowedGifts: TelegramDisallowedGifts? fileprivate var premiumProducts: [PremiumGiftProduct]? fileprivate var starGifts: [StarGift]? fileprivate let starGiftsContext: ProfileGiftsContext fileprivate var transferStarGifts: [ProfileGiftsContext.State.StarGift]? init( context: AccountContext, peerId: EnginePeer.Id, premiumOptions: [CachedPremiumGiftOption] ) { self.context = context self.starGiftsContext = ProfileGiftsContext(account: context.account, peerId: context.account.peerId, filter: [.unique, .displayed, .hidden]) super.init() let availableProducts: Signal<[InAppPurchaseManager.Product], NoError> if let inAppPurchaseManager = context.inAppPurchaseManager { availableProducts = inAppPurchaseManager.availableProducts } else { availableProducts = .single([]) } self.disposable = combineLatest( queue: Queue.mainQueue(), context.engine.data.get( TelegramEngine.EngineData.Item.Peer.Peer.init(id: peerId) ), context.engine.data.subscribe( TelegramEngine.EngineData.Item.Peer.DisallowedGifts(id: peerId) ), availableProducts, context.engine.payments.cachedStarGifts(), self.starGiftsContext.state ).start(next: { [weak self] peer, disallowedGifts, availableProducts, starGifts, profileGiftsState in guard let self else { return } if disallowedGifts == nil && self.peer == nil, case .user = peer { let _ = context.engine.peers.fetchAndUpdateCachedPeerData(peerId: peerId).startStandalone() } self.peer = peer if peerId == context.account.peerId { self.disallowedGifts = [] } else { self.disallowedGifts = disallowedGifts ?? [] } if peerId != context.account.peerId { if availableProducts.isEmpty { var premiumProducts: [PremiumGiftProduct] = [] for option in premiumOptions { if option.currency == "XTR" { continue } let starsGiftOption = premiumOptions.first(where: { $0.currency == "XTR" && $0.months == option.months }) premiumProducts.append( PremiumGiftProduct( giftOption: CachedPremiumGiftOption( months: option.months, currency: option.currency, amount: option.amount, botUrl: "", storeProductId: option.storeProductId ), starsGiftOption: starsGiftOption, storeProduct: nil, discount: nil ) ) } self.premiumProducts = premiumProducts.sorted(by: { $0.months < $1.months }) } else { let shortestOptionPrice: (Int64, NSDecimalNumber) if let product = availableProducts.first(where: { $0.id.hasSuffix(".monthly") }) { shortestOptionPrice = (Int64(Float(product.priceCurrencyAndAmount.amount)), product.priceValue) } else { shortestOptionPrice = (1, NSDecimalNumber(decimal: 1)) } var premiumProducts: [PremiumGiftProduct] = [] for option in premiumOptions { if let product = availableProducts.first(where: { $0.id == option.storeProductId }), !product.isSubscription { let fraction = Float(product.priceCurrencyAndAmount.amount) / Float(option.months) / Float(shortestOptionPrice.0) let discountValue = Int(round((1.0 - fraction) * 20.0) * 5.0) let starsGiftOption = premiumOptions.first(where: { $0.currency == "XTR" && $0.months == option.months }) premiumProducts.append(PremiumGiftProduct( giftOption: option, starsGiftOption: starsGiftOption, storeProduct: product, discount: discountValue > 0 ? discountValue : nil )) } } self.premiumProducts = premiumProducts.sorted(by: { $0.months < $1.months }) } if let disallowedGifts, disallowedGifts.contains(.unique) { } else { self.transferStarGifts = profileGiftsState.filteredGifts.compactMap { gift in if case .unique = gift.gift { return gift } else { return nil } } } } var filteredStarGifts = starGifts if let disallowedGifts = self.disallowedGifts, !disallowedGifts.isEmpty { filteredStarGifts = filteredStarGifts?.filter { gift in if case let .generic(gift) = gift { if disallowedGifts.contains(.unlimited) { if gift.availability == nil { return false } } if disallowedGifts.contains(.limited) { if gift.availability != nil { if !disallowedGifts.contains(.unique) && gift.upgradeStars != nil { } else { return false } } } } return true } } self.starGifts = filteredStarGifts self.updated() }) self.updateDisposable = self.context.engine.payments.keepStarGiftsUpdated().start() } deinit { self.disposable?.dispose() self.updateDisposable?.dispose() } } func makeState() -> State { return State(context: self.context, peerId: self.peerId, premiumOptions: self.premiumOptions) } func update(view: View, availableSize: CGSize, state: State, environment: Environment, transition: ComponentTransition) -> CGSize { return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition) } } open class GiftOptionsScreen: ViewControllerComponentContainer, GiftOptionsScreenProtocol { private let context: AccountContext public var parentController: () -> ViewController? = { return nil } public init( context: AccountContext, starsContext: StarsContext, peerId: EnginePeer.Id, premiumOptions: [CachedPremiumGiftOption], hasBirthday: Bool, completion: (() -> Void)? = nil ) { self.context = context super.init(context: context, component: GiftOptionsScreenComponent( context: context, starsContext: starsContext, peerId: peerId, premiumOptions: premiumOptions, hasBirthday: hasBirthday, completion: completion ), navigationBarAppearance: .none, theme: .default, updatedPresentationData: nil) self.navigationItem.backBarButtonItem = UIBarButtonItem(title: self.context.sharedContext.currentPresentationData.with { $0 }.strings.Common_Back, style: .plain, target: nil, action: nil) self.scrollToTop = { [weak self] in guard let self, let componentView = self.node.hostView.componentView as? GiftOptionsScreenComponent.View else { return } componentView.scrollToTop() } } required public init(coder aDecoder: NSCoder) { fatalError("init(coder:) has not been implemented") } deinit { } override public func containerLayoutUpdated(_ layout: ContainerViewLayout, transition: ContainedViewLayoutTransition) { super.containerLayoutUpdated(layout, transition: transition) } } private extension StarGift { var id: String { switch self { case let .generic(gift): return "\(gift.id)" case let .unique(gift): return gift.slug } } }