import Foundation import UIKit import Display import AsyncDisplayKit import Postbox import TelegramCore import SwiftSignalKit import AccountContext import TelegramPresentationData import PresentationDataUtils import ComponentFlow import ViewControllerComponent import SheetComponent import MultilineTextComponent import MultilineTextWithEntitiesComponent import BundleIconComponent import ButtonComponent import Markdown import BalancedTextComponent import AvatarNode import TextFormat import TelegramStringFormatting import StarsAvatarComponent import EmojiTextAttachmentView import EmojiStatusComponent import UndoUI import ConfettiEffect import PlainButtonComponent import CheckComponent import TooltipUI import GiftAnimationComponent import LottieComponent import ContextUI import TelegramNotices import PremiumLockButtonSubtitleComponent import StarsBalanceOverlayComponent private final class GiftViewSheetContent: CombinedComponent { typealias EnvironmentType = ViewControllerComponentContainer.Environment let context: AccountContext let subject: GiftViewScreen.Subject let animateOut: ActionSlot> let getController: () -> ViewController? init( context: AccountContext, subject: GiftViewScreen.Subject, animateOut: ActionSlot>, getController: @escaping () -> ViewController? ) { self.context = context self.subject = subject self.animateOut = animateOut self.getController = getController } static func ==(lhs: GiftViewSheetContent, rhs: GiftViewSheetContent) -> Bool { if lhs.context !== rhs.context { return false } if lhs.subject != rhs.subject { return false } return true } final class State: ComponentState { let modelButtonTag = GenericComponentViewTag() let backdropButtonTag = GenericComponentViewTag() let symbolButtonTag = GenericComponentViewTag() let statusTag = GenericComponentViewTag() private let context: AccountContext private(set) var subject: GiftViewScreen.Subject private let getController: () -> ViewController? private var disposable: Disposable? var initialized = false var recipientPeerIdPromise = ValuePromise(nil) var recipientPeerId: EnginePeer.Id? { didSet { self.recipientPeerIdPromise.set(self.recipientPeerId) } } var peerMap: [EnginePeer.Id: EnginePeer] = [:] var starGiftsMap: [Int64: StarGift.Gift] = [:] var cachedCircleImage: UIImage? var cachedStarImage: (UIImage, PresentationTheme)? var cachedSmallStarImage: (UIImage, PresentationTheme)? var cachedChevronImage: (UIImage, PresentationTheme)? var cachedSmallChevronImage: (UIImage, PresentationTheme)? var inProgress = false var inUpgradePreview = false var upgradeForm: BotPaymentForm? var upgradeFormDisposable: Disposable? var upgradeDisposable: Disposable? let levelsDisposable = MetaDisposable() var buyForm: BotPaymentForm? var buyFormDisposable: Disposable? var buyDisposable: Disposable? var inWearPreview = false var pendingWear = false var pendingTakeOff = false var sampleGiftAttributes: [StarGift.UniqueGift.Attribute]? let sampleDisposable = DisposableSet() var keepOriginalInfo = false private var optionsDisposable: Disposable? private(set) var options: [StarsTopUpOption] = [] { didSet { self.optionsPromise.set(self.options) } } private let optionsPromise = ValuePromise<[StarsTopUpOption]?>(nil) private let animateOut: ActionSlot> init( context: AccountContext, subject: GiftViewScreen.Subject, animateOut: ActionSlot>, getController: @escaping () -> ViewController? ) { self.context = context self.subject = subject self.animateOut = animateOut self.getController = getController super.init() if let arguments = subject.arguments { if let upgradeStars = arguments.upgradeStars, upgradeStars > 0, !arguments.nameHidden { self.keepOriginalInfo = true } var peerIds: [EnginePeer.Id] = [context.account.peerId] if let peerId = arguments.peerId { peerIds.append(peerId) } if let fromPeerId = arguments.fromPeerId, !peerIds.contains(fromPeerId) { peerIds.append(fromPeerId) } if case let .message(message) = subject { for media in message.media { peerIds.append(contentsOf: media.peerIds) } } if case let .unique(gift) = arguments.gift { if case let .peerId(peerId) = gift.owner { peerIds.append(peerId) } for attribute in gift.attributes { if case let .originalInfo(senderPeerId, recipientPeerId, _, _, _) = attribute { if let senderPeerId { peerIds.append(senderPeerId) } peerIds.append(recipientPeerId) break } } if let _ = arguments.resellStars { self.buyFormDisposable = (context.engine.payments.fetchBotPaymentForm(source: .starGiftResale(slug: gift.slug, toPeerId: context.account.peerId), themeParams: nil) |> deliverOnMainQueue).start(next: { [weak self] paymentForm in guard let self else { return } self.buyForm = paymentForm self.updated() }) } } else if case let .generic(gift) = arguments.gift { if arguments.canUpgrade || arguments.upgradeStars != nil { self.sampleDisposable.add((context.engine.payments.starGiftUpgradePreview(giftId: gift.id) |> deliverOnMainQueue).start(next: { [weak self] attributes in guard let self else { return } self.sampleGiftAttributes = attributes for attribute in attributes { switch attribute { case let .model(_, file, _): self.sampleDisposable.add(freeMediaFileResourceInteractiveFetched(account: self.context.account, userLocation: .other, fileReference: .standalone(media: file), resource: file.resource).start()) case let .pattern(_, file, _): self.sampleDisposable.add(freeMediaFileResourceInteractiveFetched(account: self.context.account, userLocation: .other, fileReference: .standalone(media: file), resource: file.resource).start()) default: break } } self.updated() })) if arguments.upgradeStars == nil, let reference = arguments.reference { self.upgradeFormDisposable = (context.engine.payments.fetchBotPaymentForm(source: .starGiftUpgrade(keepOriginalInfo: false, reference: reference), themeParams: nil) |> deliverOnMainQueue).start(next: { [weak self] paymentForm in guard let self else { return } self.upgradeForm = paymentForm self.updated() }) } } } let peerIdsSignal: Signal<[EnginePeer.Id], NoError> if case let .uniqueGift(_, recipientPeerIdValue) = subject, let recipientPeerIdValue { self.recipientPeerId = recipientPeerIdValue self.recipientPeerIdPromise.set(recipientPeerIdValue) peerIdsSignal = self.recipientPeerIdPromise.get() |> map { recipientPeerId in var peerIds = peerIds if let recipientPeerId { peerIds.append(recipientPeerId) } return peerIds } } else { peerIdsSignal = .single(peerIds) } self.disposable = combineLatest(queue: Queue.mainQueue(), peerIdsSignal |> distinctUntilChanged |> mapToSignal { peerIds in return context.engine.data.get(EngineDataMap( peerIds.map { peerId -> TelegramEngine.EngineData.Item.Peer.Peer in return TelegramEngine.EngineData.Item.Peer.Peer(id: peerId) } )) }, .single(nil) |> then(context.engine.payments.cachedStarGifts()) ).startStrict(next: { [weak self] peers, starGifts in if let strongSelf = self { var peersMap: [EnginePeer.Id: EnginePeer] = [:] for (peerId, maybePeer) in peers { if let peer = maybePeer { peersMap[peerId] = peer } } strongSelf.peerMap = peersMap var starGiftsMap: [Int64: StarGift.Gift] = [:] if let starGifts { for gift in starGifts { if case let .generic(gift) = gift { starGiftsMap[gift.id] = gift } } } strongSelf.starGiftsMap = starGiftsMap strongSelf.initialized = true strongSelf.updated(transition: .immediate) } }) } var minRequiredAmount = StarsAmount(value: 100, nanos: 0) if let resellStars = self.subject.arguments?.resellStars { minRequiredAmount = StarsAmount(value: resellStars, nanos: 0) } if let starsContext = context.starsContext, let state = starsContext.currentState, state.balance < minRequiredAmount { self.optionsDisposable = (context.engine.payments.starsTopUpOptions() |> deliverOnMainQueue).start(next: { [weak self] options in guard let self else { return } self.options = options }) } } deinit { self.disposable?.dispose() self.sampleDisposable.dispose() self.upgradeFormDisposable?.dispose() self.upgradeDisposable?.dispose() self.buyFormDisposable?.dispose() self.buyDisposable?.dispose() self.levelsDisposable.dispose() self.optionsDisposable?.dispose() } func openPeer(_ peer: EnginePeer, gifts: Bool = false, dismiss: Bool = true) { guard let controller = self.getController() as? GiftViewScreen, let navigationController = controller.navigationController as? NavigationController else { return } controller.dismissAllTooltips() let context = self.context let action = { if gifts { let profileGifts = ProfileGiftsContext(account: context.account, peerId: peer.id) let _ = (profileGifts.state |> filter { state in if case .ready = state.dataState { return true } return false } |> take(1) |> deliverOnMainQueue).start(next: { [weak navigationController] _ in if let profileController = context.sharedContext.makePeerInfoController( context: context, updatedPresentationData: nil, peer: peer._asPeer(), mode: peer.id == context.account.peerId ? .myProfileGifts : .gifts, avatarInitiallyExpanded: false, fromChat: false, requestsContext: nil ) { navigationController?.pushViewController(profileController) } let _ = profileGifts }) } else { context.sharedContext.navigateToChatController(NavigateToChatControllerParams( navigationController: navigationController, chatController: nil, context: context, chatLocation: .peer(peer), subject: nil, botStart: nil, updateTextInputState: nil, keepStack: .always, useExisting: true, purposefulAction: nil, scrollToEndIfExists: false, activateMessageSearch: nil, animated: true )) } } if dismiss { self.dismiss(animated: true) Queue.mainQueue().after(0.4, { action() }) } else { action() } } func openAddress(_ address: String) { guard let controller = self.getController() as? GiftViewScreen, let navigationController = controller.navigationController as? NavigationController else { return } let presentationData = self.context.sharedContext.currentPresentationData.with { $0 } let configuration = GiftViewConfiguration.with(appConfiguration: context.currentAppConfiguration.with { $0 }) let url = configuration.explorerUrl + address Queue.mainQueue().after(0.3) { self.context.sharedContext.openExternalUrl( context: self.context, urlContext: .generic, url: url, forceExternal: false, presentationData: presentationData, navigationController: navigationController, dismissInput: {} ) } self.dismiss(animated: true) } func copyAddress(_ address: String) { guard let controller = self.getController() as? GiftViewScreen else { return } UIPasteboard.general.string = address controller.dismissAllTooltips() let presentationData = self.context.sharedContext.currentPresentationData.with { $0 } controller.present( UndoOverlayController( presentationData: presentationData, content: .copy(text: presentationData.strings.Gift_View_CopiedAddress), elevatedLayout: false, position: .bottom, action: { _ in return true } ), in: .current ) HapticFeedback().tap() } func updateSavedToProfile(_ added: Bool) { guard let controller = self.getController() as? GiftViewScreen, let arguments = self.subject.arguments, let reference = arguments.reference else { return } var animationFile: TelegramMediaFile? switch arguments.gift { case let .generic(gift): animationFile = gift.file case let .unique(gift): for attribute in gift.attributes { if case let .model(_, file, _) = attribute { animationFile = file break } } } if let updateSavedToProfile = controller.updateSavedToProfile { updateSavedToProfile(reference, added) } else { let _ = (self.context.engine.payments.updateStarGiftAddedToProfile(reference: reference, added: added) |> deliverOnMainQueue).startStandalone() } controller.dismissAnimated() let giftsPeerId: EnginePeer.Id? let presentationData = self.context.sharedContext.currentPresentationData.with { $0 } let text: String if case let .peer(peerId, _) = arguments.reference, peerId.namespace == Namespaces.Peer.CloudChannel { giftsPeerId = peerId text = added ? presentationData.strings.Gift_Displayed_ChannelText : presentationData.strings.Gift_Hidden_ChannelText } else { giftsPeerId = context.account.peerId text = added ? presentationData.strings.Gift_Displayed_NewText : presentationData.strings.Gift_Hidden_NewText } if let navigationController = controller.navigationController as? NavigationController { Queue.mainQueue().after(0.5) { if let lastController = navigationController.viewControllers.last as? ViewController, let animationFile { let resultController = UndoOverlayController( presentationData: presentationData, content: .sticker( context: self.context, file: animationFile, loop: false, title: nil, text: text, undoText: presentationData.strings.Gift_Displayed_View, customAction: nil ), elevatedLayout: lastController is ChatController, action: { [weak navigationController] action in if case .undo = action, let navigationController, let giftsPeerId { let _ = (self.context.engine.data.get(TelegramEngine.EngineData.Item.Peer.Peer(id: giftsPeerId)) |> deliverOnMainQueue).start(next: { [weak navigationController] peer in guard let peer, let navigationController else { return } if let controller = self.context.sharedContext.makePeerInfoController( context: self.context, updatedPresentationData: nil, peer: peer._asPeer(), mode: giftsPeerId == self.context.account.peerId ? .myProfileGifts : .gifts, avatarInitiallyExpanded: false, fromChat: false, requestsContext: nil ) { navigationController.pushViewController(controller, animated: true) } }) } return true } ) lastController.present(resultController, in: .window(.root)) } } } } func convertToStars() { guard let controller = self.getController() as? GiftViewScreen, let starsContext = context.starsContext, let arguments = self.subject.arguments, let reference = arguments.reference, let fromPeerName = arguments.fromPeerName, let convertStars = arguments.convertStars, let navigationController = controller.navigationController as? NavigationController else { return } let configuration = GiftConfiguration.with(appConfiguration: context.currentAppConfiguration.with { $0 }) let starsConvertMaxDate = arguments.date + configuration.convertToStarsPeriod var isChannelGift = false if case let .peer(peerId, _) = reference, peerId.namespace == Namespaces.Peer.CloudChannel { isChannelGift = true } let presentationData = self.context.sharedContext.currentPresentationData.with { $0 } let currentTime = Int32(CFAbsoluteTimeGetCurrent() + kCFAbsoluteTimeIntervalSince1970) if currentTime > starsConvertMaxDate { let days: Int32 = Int32(ceil(Float(configuration.convertToStarsPeriod) / 86400.0)) let alertController = textAlertController( context: self.context, title: presentationData.strings.Gift_Convert_Title, text: presentationData.strings.Gift_Convert_Period_Unavailable_Text(presentationData.strings.Gift_Convert_Period_Unavailable_Days(days)).string, actions: [ TextAlertAction(type: .defaultAction, title: presentationData.strings.Common_OK, action: {}) ], parseMarkdown: true ) controller.present(alertController, in: .window(.root)) } else { let delta = starsConvertMaxDate - currentTime let days: Int32 = Int32(ceil(Float(delta) / 86400.0)) let text = presentationData.strings.Gift_Convert_Period_Text( fromPeerName, presentationData.strings.Gift_Convert_Period_Stars(Int32(convertStars)), presentationData.strings.Gift_Convert_Period_Days(days) ).string let alertController = textAlertController( context: self.context, title: presentationData.strings.Gift_Convert_Title, text: text, actions: [ TextAlertAction(type: .defaultAction, title: presentationData.strings.Common_Cancel, action: {}), TextAlertAction(type: .defaultAction, title: presentationData.strings.Gift_Convert_Convert, action: { [weak self, weak controller, weak navigationController] in guard let self else { return } if let convertToStars = controller?.convertToStars { convertToStars() } else { let _ = (self.context.engine.payments.convertStarGift(reference: reference) |> deliverOnMainQueue).startStandalone() } controller?.dismissAnimated() if let navigationController { Queue.mainQueue().after(0.5) { starsContext.load(force: true) let text: String if isChannelGift { text = presentationData.strings.Gift_Convert_Success_ChannelText( presentationData.strings.Gift_Convert_Success_ChannelText_Stars(Int32(convertStars)) ).string } else { text = presentationData.strings.Gift_Convert_Success_Text( presentationData.strings.Gift_Convert_Success_Text_Stars(Int32(convertStars)) ).string if let starsContext = self.context.starsContext { navigationController.pushViewController( self.context.sharedContext.makeStarsTransactionsScreen( context: self.context, starsContext: starsContext ), animated: true ) } } if let lastController = navigationController.viewControllers.last as? ViewController { let resultController = UndoOverlayController( presentationData: presentationData, content: .universal( animation: "StarsBuy", scale: 0.066, colors: [:], title: presentationData.strings.Gift_Convert_Success_Title, text: text, customUndoText: nil, timeout: nil ), elevatedLayout: lastController is ChatController, action: { _ in return true } ) lastController.present(resultController, in: .window(.root)) } } } }) ], parseMarkdown: true ) controller.present(alertController, in: .window(.root)) } } func openStarsIntro() { guard let controller = self.getController() else { return } let introController = self.context.sharedContext.makeStarsIntroScreen(context: self.context) controller.push(introController) } func sendGift(peerId: EnginePeer.Id) { guard let controller = self.getController() else { return } let _ = (self.context.engine.payments.premiumGiftCodeOptions(peerId: nil, onlyCached: true) |> filter { !$0.isEmpty } |> deliverOnMainQueue).start(next: { [weak self, weak controller] giftOptions in guard let self, let controller else { return } let premiumOptions = giftOptions.filter { $0.users == 1 }.map { CachedPremiumGiftOption(months: $0.months, currency: $0.currency, amount: $0.amount, botUrl: "", storeProductId: $0.storeProductId) } let giftController = self.context.sharedContext.makeGiftOptionsController(context: self.context, peerId: peerId, premiumOptions: premiumOptions, hasBirthday: false, completion: nil) controller.push(giftController) }) Queue.mainQueue().after(0.6, { self.dismiss(animated: false) }) } func shareGift() { guard let arguments = self.subject.arguments, case let .unique(gift) = arguments.gift, let controller = self.getController() as? GiftViewScreen else { return } let presentationData = self.context.sharedContext.currentPresentationData.with { $0 } var shareStoryImpl: (() -> Void)? if let shareStory = controller.shareStory { shareStoryImpl = { shareStory(gift) } } let link = "https://t.me/nft/\(gift.slug)" let shareController = self.context.sharedContext.makeShareController( context: self.context, subject: .url(link), forceExternal: false, shareStory: shareStoryImpl, enqueued: { [weak self, weak controller] peerIds, _ in guard let self else { return } let _ = (self.context.engine.data.get( EngineDataList( peerIds.map(TelegramEngine.EngineData.Item.Peer.Peer.init) ) ) |> deliverOnMainQueue).startStandalone(next: { [weak self, weak controller] peerList in guard let self else { return } let peers = peerList.compactMap { $0 } let presentationData = self.context.sharedContext.currentPresentationData.with { $0 } let text: String var savedMessages = false if peerIds.count == 1, let peerId = peerIds.first, peerId == context.account.peerId { text = presentationData.strings.Conversation_ForwardTooltip_SavedMessages_One savedMessages = true } else { if peers.count == 1, let peer = peers.first { var peerName = peer.id == context.account.peerId ? presentationData.strings.DialogList_SavedMessages : peer.displayTitle(strings: presentationData.strings, displayOrder: presentationData.nameDisplayOrder) peerName = peerName.replacingOccurrences(of: "**", with: "") text = presentationData.strings.Conversation_ForwardTooltip_Chat_One(peerName).string } else if peers.count == 2, let firstPeer = peers.first, let secondPeer = peers.last { var firstPeerName = firstPeer.id == context.account.peerId ? presentationData.strings.DialogList_SavedMessages : firstPeer.displayTitle(strings: presentationData.strings, displayOrder: presentationData.nameDisplayOrder) firstPeerName = firstPeerName.replacingOccurrences(of: "**", with: "") var secondPeerName = secondPeer.id == context.account.peerId ? presentationData.strings.DialogList_SavedMessages : secondPeer.displayTitle(strings: presentationData.strings, displayOrder: presentationData.nameDisplayOrder) secondPeerName = secondPeerName.replacingOccurrences(of: "**", with: "") text = presentationData.strings.Conversation_ForwardTooltip_TwoChats_One(firstPeerName, secondPeerName).string } else if let peer = peers.first { var peerName = peer.displayTitle(strings: presentationData.strings, displayOrder: presentationData.nameDisplayOrder) peerName = peerName.replacingOccurrences(of: "**", with: "") text = presentationData.strings.Conversation_ForwardTooltip_ManyChats_One(peerName, "\(peers.count - 1)").string } else { text = "" } } controller?.present(UndoOverlayController(presentationData: presentationData, content: .forward(savedMessages: savedMessages, text: text), elevatedLayout: false, animateInAsReplacement: false, action: { [weak self, weak controller] action in if let self, savedMessages, action == .info { let _ = (self.context.engine.data.get(TelegramEngine.EngineData.Item.Peer.Peer(id: self.context.account.peerId)) |> deliverOnMainQueue).start(next: { [weak self, weak controller] peer in guard let peer else { return } self?.openPeer(peer) Queue.mainQueue().after(0.6) { controller?.dismiss(animated: false, completion: nil) } }) } return false }, additionalView: nil), in: .current) }) }, actionCompleted: { [weak controller] in controller?.present(UndoOverlayController(presentationData: presentationData, content: .linkCopied(title: nil, text: presentationData.strings.Conversation_LinkCopied), elevatedLayout: false, animateInAsReplacement: false, action: { _ in return false }), in: .current) } ) controller.present(shareController, in: .window(.root)) } func transferGift() { guard let arguments = self.subject.arguments, let controller = self.getController() as? GiftViewScreen, case let .unique(gift) = arguments.gift, let reference = arguments.reference, let transferStars = arguments.transferStars else { return } controller.dismissAllTooltips() let presentationData = self.context.sharedContext.currentPresentationData.with { $0 } let currentTime = Int32(CFAbsoluteTimeGetCurrent() + kCFAbsoluteTimeIntervalSince1970) if let canTransferDate = arguments.canTransferDate, currentTime < canTransferDate { let dateString = stringForFullDate(timestamp: canTransferDate, strings: presentationData.strings, dateTimeFormat: presentationData.dateTimeFormat) let alertController = textAlertController( context: self.context, title: presentationData.strings.Gift_Transfer_Unavailable_Title, text: presentationData.strings.Gift_Transfer_Unavailable_Text(dateString).string, actions: [ TextAlertAction(type: .defaultAction, title: presentationData.strings.Common_OK, action: {}) ], parseMarkdown: true ) controller.present(alertController, in: .window(.root)) return } let context = self.context let _ = (self.context.account.stateManager.contactBirthdays |> take(1) |> deliverOnMainQueue).start(next: { [weak self, weak controller] birthdays in guard let self, let controller else { return } var showSelf = false if arguments.peerId?.namespace == Namespaces.Peer.CloudChannel { showSelf = true } let tranfserGiftImpl = controller.transferGift let transferController = self.context.sharedContext.makePremiumGiftController(context: context, source: .starGiftTransfer(birthdays, reference, gift, transferStars, arguments.canExportDate, showSelf), completion: { peerIds in guard let peerId = peerIds.first else { return .complete() } Queue.mainQueue().after(1.5, { if transferStars > 0 { context.starsContext?.load(force: true) } }) if let tranfserGiftImpl { return tranfserGiftImpl(transferStars == 0, peerId) } else { return (context.engine.payments.transferStarGift(prepaid: transferStars == 0, reference: reference, peerId: peerId) |> deliverOnMainQueue) } }) controller.push(transferController) }) } func resellGift(update: Bool = false) { guard let arguments = self.subject.arguments, case let .unique(gift) = arguments.gift, let controller = self.getController() as? GiftViewScreen else { return } controller.dismissAllTooltips() let presentationData = self.context.sharedContext.currentPresentationData.with { $0 } let currentTime = Int32(CFAbsoluteTimeGetCurrent() + kCFAbsoluteTimeIntervalSince1970) if let canResaleDate = arguments.canResaleDate, currentTime < canResaleDate { let dateString = stringForFullDate(timestamp: canResaleDate, strings: presentationData.strings, dateTimeFormat: presentationData.dateTimeFormat) let alertController = textAlertController( context: self.context, title: presentationData.strings.Gift_Resale_Unavailable_Title, text: presentationData.strings.Gift_Resale_Unavailable_Text(dateString).string, actions: [ TextAlertAction(type: .defaultAction, title: presentationData.strings.Common_OK, action: {}) ], parseMarkdown: true ) controller.present(alertController, in: .window(.root)) return } let giftTitle = "\(gift.title) #\(presentationStringsFormattedNumber(gift.number, presentationData.dateTimeFormat.groupingSeparator))" let reference = arguments.reference ?? .slug(slug: gift.slug) if let resellStars = gift.resellStars, resellStars > 0, !update { let alertController = textAlertController( context: context, title: presentationData.strings.Gift_View_Resale_Unlist_Title, text: presentationData.strings.Gift_View_Resale_Unlist_Text, actions: [ TextAlertAction(type: .defaultAction, title: presentationData.strings.Gift_View_Resale_Unlist_Unlist, action: { [weak self, weak controller] in guard let self, let controller else { return } let _ = ((controller.updateResellStars?(nil) ?? context.engine.payments.updateStarGiftResalePrice(reference: reference, price: nil)) |> deliverOnMainQueue).startStandalone(error: { error in }, completed: { [weak self, weak controller] in guard let self, let controller else { return } switch self.subject { case let .profileGift(peerId, currentSubject): self.subject = .profileGift(peerId, currentSubject.withGift(.unique(gift.withResellStars(nil)))) case let .uniqueGift(_, recipientPeerId): self.subject = .uniqueGift(gift.withResellStars(nil), recipientPeerId) default: break } self.updated(transition: .easeInOut(duration: 0.2)) let text = presentationData.strings.Gift_View_Resale_Unlist_Success(giftTitle).string let tooltipController = UndoOverlayController( presentationData: presentationData, content: .universalImage( image: generateTintedImage(image: UIImage(bundleImageName: "Premium/Collectible/Unlist"), color: .white)!, size: nil, title: nil, text: text, customUndoText: nil, timeout: 3.0 ), position: .bottom, animateInAsReplacement: false, appearance: UndoOverlayController.Appearance(sideInset: 16.0, bottomInset: 62.0), action: { action in return false } ) controller.present(tooltipController, in: .window(.root)) }) }), TextAlertAction(type: .genericAction, title: presentationData.strings.Common_Cancel, action: { }) ], actionLayout: .vertical ) controller.present(alertController, in: .window(.root)) } else { let resellController = self.context.sharedContext.makeStarGiftResellScreen(context: self.context, update: update, completion: { [weak self, weak controller] price in guard let self, let controller else { return } let _ = ((controller.updateResellStars?(price) ?? context.engine.payments.updateStarGiftResalePrice(reference: reference, price: price)) |> deliverOnMainQueue).startStandalone(error: { [weak self, weak controller] error in guard let self else { return } let title: String? let text: String switch error { case .generic: title = nil text = presentationData.strings.Gift_Send_ErrorUnknown case let .starGiftResellTooEarly(canResaleDate): let dateString = stringForFullDate(timestamp: canResaleDate, strings: presentationData.strings, dateTimeFormat: presentationData.dateTimeFormat) title = presentationData.strings.Gift_Resale_Unavailable_Title text = presentationData.strings.Gift_Resale_Unavailable_Text(dateString).string } let alertController = textAlertController( context: self.context, title: title, text: text, actions: [ TextAlertAction(type: .defaultAction, title: presentationData.strings.Common_OK, action: {}) ], parseMarkdown: true ) controller?.present(alertController, in: .window(.root)) }, completed: { [weak self, weak controller] in guard let self, let controller else { return } switch self.subject { case let .profileGift(peerId, currentSubject): self.subject = .profileGift(peerId, currentSubject.withGift(.unique(gift.withResellStars(price)))) case let .uniqueGift(_, recipientPeerId): self.subject = .uniqueGift(gift.withResellStars(price), recipientPeerId) default: break } self.updated(transition: .easeInOut(duration: 0.2)) var text = presentationData.strings.Gift_View_Resale_List_Success(giftTitle).string if update { let starsString = presentationData.strings.Gift_View_Resale_Relist_Success_Stars(Int32(price)) text = presentationData.strings.Gift_View_Resale_Relist_Success(giftTitle, starsString).string } let tooltipController = UndoOverlayController( presentationData: presentationData, content: .universalImage( image: generateTintedImage(image: UIImage(bundleImageName: "Premium/Collectible/Sell"), color: .white)!, size: nil, title: nil, text: text, customUndoText: nil, timeout: 3.0 ), position: .bottom, animateInAsReplacement: false, appearance: UndoOverlayController.Appearance(sideInset: 16.0, bottomInset: 62.0), action: { action in return false } ) controller.present(tooltipController, in: .window(.root)) }) }) controller.push(resellController) } } func viewUpgradedGift(messageId: EngineMessage.Id) { guard let controller = self.getController(), let navigationController = controller.navigationController as? NavigationController else { return } let _ = (self.context.engine.data.get( TelegramEngine.EngineData.Item.Peer.Peer(id: messageId.peerId) ) |> deliverOnMainQueue).start(next: { [weak self, weak navigationController] peer in guard let self, let navigationController, let peer else { return } self.context.sharedContext.navigateToChatController(NavigateToChatControllerParams(navigationController: navigationController, context: self.context, chatLocation: .peer(peer), subject: .message(id: .id(messageId), highlight: ChatControllerSubject.MessageHighlight(quote: nil), timecode: nil, setupReply: false), keepStack: .always, useExisting: true, purposefulAction: {}, peekData: nil, forceAnimatedScroll: true)) }) } func showAttributeInfo(tag: Any, text: String) { guard let controller = self.getController() as? GiftViewScreen else { return } controller.dismissAllTooltips() guard let sourceView = controller.node.hostView.findTaggedView(tag: tag), let absoluteLocation = sourceView.superview?.convert(sourceView.center, to: controller.view) else { return } let location = CGRect(origin: CGPoint(x: absoluteLocation.x, y: absoluteLocation.y - 12.0), size: CGSize()) let tooltipController = TooltipScreen(account: self.context.account, sharedContext: self.context.sharedContext, text: .plain(text: text), style: .wide, location: .point(location, .bottom), displayDuration: .default, inset: 16.0, shouldDismissOnTouch: { _, _ in return .dismiss(consume: false) }) controller.present(tooltipController, in: .current) } func openMore(node: ASDisplayNode, gesture: ContextGesture?) { guard let arguments = self.subject.arguments, case let .unique(gift) = arguments.gift else { return } let presentationData = self.context.sharedContext.currentPresentationData.with { $0 } let link = "https://t.me/nft/\(gift.slug)" let _ = (self.context.engine.data.get( TelegramEngine.EngineData.Item.Peer.Peer(id: arguments.peerId ?? context.account.peerId) ) |> deliverOnMainQueue).start(next: { [weak self] peer in guard let self, let controller = self.getController() as? GiftViewScreen else { return } var items: [ContextMenuItem] = [] let strings = presentationData.strings if let _ = arguments.reference, case .unique = arguments.gift, let togglePinnedToTop = controller.togglePinnedToTop, let pinnedToTop = arguments.pinnedToTop { items.append(.action(ContextMenuActionItem(text: pinnedToTop ? strings.PeerInfo_Gifts_Context_Unpin : strings.PeerInfo_Gifts_Context_Pin , icon: { theme in generateTintedImage(image: UIImage(bundleImageName: pinnedToTop ? "Chat/Context Menu/Unpin" : "Chat/Context Menu/Pin"), color: theme.contextMenu.primaryColor) }, action: { [weak self] c, f in c?.dismiss(completion: { [weak self, weak controller] in guard let self, let controller else { return } let pinnedToTop = !pinnedToTop if togglePinnedToTop(pinnedToTop) { if pinnedToTop { controller.dismissAnimated() } else { let toastText = strings.PeerInfo_Gifts_ToastUnpinned_Text controller.present(UndoOverlayController(presentationData: presentationData, content: .universal(animation: "anim_toastunpin", scale: 0.06, colors: [:], title: nil, text: toastText, customUndoText: nil, timeout: 5), elevatedLayout: false, animateInAsReplacement: false, action: { _ in return false }), in: .current) if case let .profileGift(peerId, gift) = self.subject { self.subject = .profileGift(peerId, gift.withPinnedToTop(false)) } } } }) }))) } if case let .unique(gift) = arguments.gift, let resellStars = gift.resellStars, resellStars > 0 { if arguments.reference != nil || gift.owner.peerId == context.account.peerId { items.append(.action(ContextMenuActionItem(text: presentationData.strings.Gift_View_Context_ChangePrice, icon: { theme in return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/PriceTag"), color: theme.contextMenu.primaryColor) }, action: { [weak self] c, _ in c?.dismiss(completion: nil) self?.resellGift(update: true) }))) } } items.append(.action(ContextMenuActionItem(text: presentationData.strings.Gift_View_Context_CopyLink, icon: { theme in return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Link"), color: theme.contextMenu.primaryColor) }, action: { [weak controller] c, _ in c?.dismiss(completion: nil) UIPasteboard.general.string = link controller?.present(UndoOverlayController(presentationData: presentationData, content: .linkCopied(title: nil, text: presentationData.strings.Conversation_LinkCopied), elevatedLayout: false, animateInAsReplacement: false, action: { _ in return false }), in: .current) }))) items.append(.action(ContextMenuActionItem(text: presentationData.strings.Gift_View_Context_Share, icon: { theme in return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Forward"), color: theme.contextMenu.primaryColor) }, action: { [weak self] c, _ in c?.dismiss(completion: nil) self?.shareGift() }))) if let _ = arguments.transferStars { if case let .channel(channel) = peer, !channel.flags.contains(.isCreator) { } else { items.append(.action(ContextMenuActionItem(text: presentationData.strings.Gift_View_Context_Transfer, icon: { theme in return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Replace"), color: theme.contextMenu.primaryColor) }, action: { [weak self] c, _ in c?.dismiss(completion: nil) self?.transferGift() }))) } } if let _ = arguments.resellStars, case let .uniqueGift(uniqueGift, recipientPeerId) = subject, let _ = recipientPeerId { items.append(.action(ContextMenuActionItem(text: presentationData.strings.Gift_View_Context_ViewInProfile, icon: { theme in return generateTintedImage(image: UIImage(bundleImageName: "Peer Info/ShowIcon"), color: theme.contextMenu.primaryColor) }, action: { [weak self] c, _ in c?.dismiss(completion: nil) guard let self, case let .peerId(peerId) = uniqueGift.owner else { return } let _ = (self.context.engine.data.get(TelegramEngine.EngineData.Item.Peer.Peer(id: peerId)) |> deliverOnMainQueue).start(next: { [weak self] peer in guard let self, let peer else { return } self.openPeer(peer, gifts: true) Queue.mainQueue().after(0.6) { controller.dismiss(animated: false, completion: nil) } }) }))) } let contextController = ContextController(presentationData: presentationData, source: .reference(GiftViewContextReferenceContentSource(controller: controller, sourceNode: node)), items: .single(ContextController.Items(content: .list(items))), gesture: gesture) controller.presentInGlobalOverlay(contextController) }) } func dismiss(animated: Bool) { guard let controller = self.getController() as? GiftViewScreen else { return } if animated { controller.dismissAllTooltips() controller.dismissBalanceOverlay() self.animateOut.invoke(Action { [weak controller] _ in controller?.dismiss(completion: nil) }) } else { controller.dismiss(animated: false) } } func requestWearPreview() { self.inWearPreview = true self.updated(transition: .spring(duration: 0.4)) } func commitWear(_ uniqueGift: StarGift.UniqueGift) { self.pendingWear = true self.pendingTakeOff = false self.inWearPreview = false self.updated(transition: .spring(duration: 0.4)) if let arguments = self.subject.arguments, let peerId = arguments.peerId, peerId.namespace == Namespaces.Peer.CloudChannel { let _ = self.context.engine.peers.updatePeerStarGiftStatus(peerId: peerId, starGift: uniqueGift, expirationDate: nil).startStandalone() } else { let _ = self.context.engine.accountData.setStarGiftStatus(starGift: uniqueGift, expirationDate: nil).startStandalone() } let _ = ApplicationSpecificNotice.incrementStarGiftWearTips(accountManager: self.context.sharedContext.accountManager).startStandalone() } func commitTakeOff() { self.pendingTakeOff = true self.pendingWear = false self.updated(transition: .spring(duration: 0.4)) if let arguments = self.subject.arguments, let peerId = arguments.peerId, peerId.namespace == Namespaces.Peer.CloudChannel { let _ = self.context.engine.peers.updatePeerEmojiStatus(peerId: peerId, fileId: nil, expirationDate: nil).startStandalone() } else { let _ = self.context.engine.accountData.setEmojiStatus(file: nil, expirationDate: nil).startStandalone() } } func requestUpgradePreview() { guard let arguments = self.subject.arguments, arguments.canUpgrade || arguments.upgradeStars != nil else { return } self.context.starsContext?.load(force: false) self.inUpgradePreview = true self.updated(transition: .spring(duration: 0.4)) if let controller = self.getController() as? GiftViewScreen { controller.showBalance = true } } func cancelUpgradePreview() { self.inUpgradePreview = false self.updated(transition: .spring(duration: 0.4)) if let controller = self.getController() as? GiftViewScreen { controller.showBalance = false } } func commitBuy(acceptedPrice: Int64? = nil, skipConfirmation: Bool = false) { guard let resellStars = self.subject.arguments?.resellStars, let starsContext = self.context.starsContext, let starsState = starsContext.currentState, case let .unique(uniqueGift) = self.subject.arguments?.gift else { return } let giftTitle = "\(uniqueGift.title) #\(uniqueGift.number)" let context = self.context let presentationData = context.sharedContext.currentPresentationData.with { $0 } let recipientPeerId = self.recipientPeerId ?? self.context.account.peerId let action = { let proceed: () -> Void = { guard let controller = self.getController() as? GiftViewScreen else { return } self.inProgress = true self.updated() let buyGiftImpl: ((String, EnginePeer.Id, Int64?) -> Signal) if let buyGift = controller.buyGift { buyGiftImpl = { slug, peerId, price in return buyGift(slug, peerId, price) |> afterCompleted { context.starsContext?.load(force: true) } } } else { buyGiftImpl = { slug, peerId, price in return self.context.engine.payments.buyStarGift(slug: slug, peerId: peerId, price: price) |> afterCompleted { context.starsContext?.load(force: true) } } } self.buyDisposable = (buyGiftImpl(uniqueGift.slug, recipientPeerId, acceptedPrice ?? resellStars) |> deliverOnMainQueue).start( error: { [weak self] error in guard let self, let controller = self.getController() else { return } self.inProgress = false self.updated() switch error { case let .priceChanged(newPrice): let errorTitle = presentationData.strings.Gift_Buy_ErrorPriceChanged_Title let originalPriceString = presentationData.strings.Gift_Buy_ErrorPriceChanged_Text_Stars(Int32(resellStars)) let newPriceString = presentationData.strings.Gift_Buy_ErrorPriceChanged_Text_Stars(Int32(newPrice)) let errorText = presentationData.strings.Gift_Buy_ErrorPriceChanged_Text(originalPriceString, newPriceString).string let alertController = textAlertController( context: context, title: errorTitle, text: errorText, actions: [ TextAlertAction(type: .defaultAction, title: presentationData.strings.Gift_Buy_Confirm_BuyFor(Int32(newPrice)), action: { [weak self] in guard let self else { return } self.commitBuy(acceptedPrice: newPrice, skipConfirmation: true) }), TextAlertAction(type: .genericAction, title: presentationData.strings.Common_Cancel, action: { }) ], actionLayout: .vertical, parseMarkdown: true ) controller.present(alertController, in: .window(.root)) default: let alertController = textAlertController(context: context, title: nil, text: presentationData.strings.Gift_Buy_ErrorUnknown, actions: [TextAlertAction(type: .defaultAction, title: presentationData.strings.Common_OK, action: {})], parseMarkdown: true) controller.present(alertController, in: .window(.root)) } }, completed: { [weak self, weak starsContext] in guard let self, let controller = self.getController() as? GiftViewScreen else { return } self.inProgress = false var animationFile: TelegramMediaFile? for attribute in uniqueGift.attributes { if case let .model(_, file, _) = attribute { animationFile = file break } } if let navigationController = controller.navigationController as? NavigationController { if recipientPeerId == self.context.account.peerId { controller.dismissAnimated() navigationController.view.addSubview(ConfettiView(frame: navigationController.view.bounds)) Queue.mainQueue().after(0.5, { if let lastController = navigationController.viewControllers.last as? ViewController, let animationFile { let resultController = UndoOverlayController( presentationData: presentationData, content: .sticker(context: context, file: animationFile, loop: false, title: presentationData.strings.Gift_View_Resale_SuccessYou_Title, text: presentationData.strings.Gift_View_Resale_SuccessYou_Text(giftTitle).string, undoText: nil, customAction: nil), elevatedLayout: lastController is ChatController, action: { _ in return true } ) lastController.present(resultController, in: .window(.root)) } }) } else { var controllers = Array(navigationController.viewControllers.prefix(1)) let chatController = self.context.sharedContext.makeChatController(context: context, chatLocation: .peer(id: recipientPeerId), subject: nil, botStart: nil, mode: .standard(.default), params: nil) chatController.hintPlayNextOutgoingGift() controllers.append(chatController) navigationController.setViewControllers(controllers, animated: true) Queue.mainQueue().after(0.5, { let _ = (self.context.engine.data.get(TelegramEngine.EngineData.Item.Peer.Peer(id: recipientPeerId)) |> deliverOnMainQueue).start(next: { [weak navigationController] peer in if let peer, let lastController = navigationController?.viewControllers.last as? ViewController, let animationFile { let resultController = UndoOverlayController( presentationData: presentationData, content: .sticker(context: context, file: animationFile, loop: false, title: presentationData.strings.Gift_View_Resale_Success_Title, text: presentationData.strings.Gift_View_Resale_Success_Text(peer.compactDisplayTitle).string, undoText: nil, customAction: nil), elevatedLayout: lastController is ChatController, action: { _ in return true } ) lastController.present(resultController, in: .window(.root)) } }) }) } } self.updated(transition: .spring(duration: 0.4)) Queue.mainQueue().after(0.5) { starsContext?.load(force: true) } }) } if let buyForm = self.buyForm, let price = buyForm.invoice.prices.first?.amount { if starsState.balance < StarsAmount(value: price, nanos: 0) { if self.options.isEmpty { self.inProgress = true self.updated() } let _ = (self.optionsPromise.get() |> filter { $0 != nil } |> take(1) |> deliverOnMainQueue).startStandalone(next: { [weak self] options in guard let self, let controller = self.getController() else { return } let purchaseController = self.context.sharedContext.makeStarsPurchaseScreen( context: self.context, starsContext: starsContext, options: options ?? [], purpose: .buyStarGift(requiredStars: price), completion: { [weak self, weak starsContext] stars in guard let self, let starsContext else { return } self.inProgress = true self.updated() starsContext.add(balance: StarsAmount(value: stars, nanos: 0)) let _ = (starsContext.onUpdate |> deliverOnMainQueue).start(next: { [weak self] in guard let self else { return } Queue.mainQueue().after(0.1, { [weak self] in guard let self, let starsContext = self.context.starsContext, let starsState = starsContext.currentState else { return } if starsState.balance < StarsAmount(value: price, nanos: 0) { self.inProgress = false self.updated() self.commitBuy(skipConfirmation: true) } else { proceed() } }); }) } ) controller.push(purchaseController) }) } else { proceed() } } } if skipConfirmation { action() } else { let _ = (self.context.engine.data.get(TelegramEngine.EngineData.Item.Peer.Peer(id: recipientPeerId)) |> deliverOnMainQueue).start(next: { [weak self] peer in guard let self, let peer else { return } let text: String let starsString = presentationData.strings.Gift_Buy_Confirm_Text_Stars(Int32(resellStars)) if recipientPeerId == self.context.account.peerId { text = presentationData.strings.Gift_Buy_Confirm_Text(giftTitle, starsString).string } else { text = presentationData.strings.Gift_Buy_Confirm_GiftText(giftTitle, starsString, peer.displayTitle(strings: presentationData.strings, displayOrder: presentationData.nameDisplayOrder)).string } let alertController = textAlertController( context: self.context, title: presentationData.strings.Gift_Buy_Confirm_Title, text: text, actions: [ TextAlertAction(type: .defaultAction, title: presentationData.strings.Gift_Buy_Confirm_BuyFor(Int32(resellStars)), action: { action() }), TextAlertAction(type: .genericAction, title: presentationData.strings.Common_Cancel, action: { }) ], actionLayout: .vertical, parseMarkdown: true ) if let controller = self.getController() as? GiftViewScreen { controller.present(alertController, in: .window(.root)) } }) } } func commitUpgrade() { guard let arguments = self.subject.arguments, let peerId = arguments.peerId, let starsContext = self.context.starsContext, let starsState = starsContext.currentState else { return } let proceed: (Int64?) -> Void = { formId in guard let controller = self.getController() as? GiftViewScreen else { return } self.inProgress = true self.updated() controller.showBalance = false let context = self.context let upgradeGiftImpl: ((Int64?, Bool) -> Signal) if let upgradeGift = controller.upgradeGift { upgradeGiftImpl = { formId, keepOriginalInfo in return upgradeGift(formId, keepOriginalInfo) |> afterCompleted { if formId != nil { context.starsContext?.load(force: true) } } } } else { guard let reference = arguments.reference else { return } upgradeGiftImpl = { formId, keepOriginalInfo in return self.context.engine.payments.upgradeStarGift(formId: formId, reference: reference, keepOriginalInfo: keepOriginalInfo) |> afterCompleted { if formId != nil { context.starsContext?.load(force: true) } } } } self.upgradeDisposable = (upgradeGiftImpl(formId, self.keepOriginalInfo) |> deliverOnMainQueue).start(next: { [weak self, weak starsContext] result in guard let self, let controller = self.getController() as? GiftViewScreen else { return } self.inProgress = false self.inUpgradePreview = false self.subject = .profileGift(peerId, result) controller.animateSuccess() self.updated(transition: .spring(duration: 0.4)) Queue.mainQueue().after(0.5) { starsContext?.load(force: true) } }) } if let upgradeStars = arguments.upgradeStars, upgradeStars > 0 { proceed(nil) } else if let upgradeForm = self.upgradeForm, let price = upgradeForm.invoice.prices.first?.amount { if starsState.balance < StarsAmount(value: price, nanos: 0) { let _ = (self.optionsPromise.get() |> filter { $0 != nil } |> take(1) |> deliverOnMainQueue).startStandalone(next: { [weak self] options in guard let self, let controller = self.getController() else { return } let purchaseController = self.context.sharedContext.makeStarsPurchaseScreen( context: self.context, starsContext: starsContext, options: options ?? [], purpose: .upgradeStarGift(requiredStars: price), completion: { [weak self, weak starsContext] stars in guard let self, let starsContext else { return } self.inProgress = true self.updated() starsContext.add(balance: StarsAmount(value: stars, nanos: 0)) let _ = (starsContext.onUpdate |> deliverOnMainQueue).start(next: { proceed(upgradeForm.id) }) } ) controller.push(purchaseController) }) } else { proceed(upgradeForm.id) } } if let controller = self.getController() as? GiftViewScreen { controller.showBalance = true } } } func makeState() -> State { return State(context: self.context, subject: self.subject, animateOut: self.animateOut, getController: self.getController) } static var body: Body { let priceButton = Child(PlainButtonComponent.self) let buttons = Child(ButtonsComponent.self) let animation = Child(GiftCompositionComponent.self) let title = Child(MultilineTextComponent.self) let description = Child(MultilineTextComponent.self) let transferButton = Child(PlainButtonComponent.self) let wearButton = Child(PlainButtonComponent.self) let resellButton = Child(PlainButtonComponent.self) let wearAvatar = Child(AvatarComponent.self) let wearPeerName = Child(MultilineTextComponent.self) let wearPeerStatus = Child(MultilineTextComponent.self) let wearTitle = Child(MultilineTextComponent.self) let wearDescription = Child(MultilineTextComponent.self) let wearPerks = Child(List.self) let hiddenText = Child(MultilineTextComponent.self) let table = Child(TableComponent.self) let additionalText = Child(MultilineTextComponent.self) let button = Child(ButtonComponent.self) let upgradeTitle = Child(MultilineTextComponent.self) let upgradeDescription = Child(BalancedTextComponent.self) let upgradePerks = Child(List.self) let upgradeKeepName = Child(PlainButtonComponent.self) let spaceRegex = try? NSRegularExpression(pattern: "\\[(.*?)\\]", options: []) let giftCompositionExternalState = GiftCompositionComponent.ExternalState() return { context in let environment = context.environment[ViewControllerComponentContainer.Environment.self].value let component = context.component let theme = environment.theme let strings = environment.strings let dateTimeFormat = environment.dateTimeFormat let nameDisplayOrder = component.context.sharedContext.currentPresentationData.with { $0 }.nameDisplayOrder let controller = environment.controller let state = context.state let subject = state.subject let sideInset: CGFloat = 16.0 + environment.safeInsets.left var titleString: String var animationFile: TelegramMediaFile? let stars: Int64 let convertStars: Int64? let text: String? let entities: [MessageTextEntity]? var limitRemains: Int32? let limitTotal: Int32? var incoming = false var savedToProfile = false var converted = false var giftId: Int64 = 0 var date: Int32? var soldOut = false var nameHidden = false var upgraded = false var exported = false var canUpgrade = false var upgradeStars: Int64? var uniqueGift: StarGift.UniqueGift? var isSelfGift = false var isChannelGift = false var isMyUniqueGift = false if case let .soldOutGift(gift) = subject { animationFile = gift.file stars = gift.price text = nil entities = nil limitRemains = nil limitTotal = gift.availability?.total convertStars = nil soldOut = true titleString = strings.Gift_View_UnavailableTitle } else if let arguments = subject.arguments { switch arguments.gift { case let .generic(gift): animationFile = gift.file stars = gift.price text = arguments.text entities = arguments.entities limitRemains = gift.availability?.remains limitTotal = gift.availability?.total convertStars = arguments.convertStars converted = arguments.converted giftId = gift.id date = arguments.date upgraded = arguments.upgraded canUpgrade = arguments.canUpgrade upgradeStars = arguments.upgradeStars case let .unique(gift): stars = 0 text = nil entities = nil limitRemains = nil limitTotal = nil convertStars = nil uniqueGift = gift } savedToProfile = arguments.savedToProfile if let reference = arguments.reference, case .peer = reference { isChannelGift = true incoming = true } else { incoming = arguments.incoming || arguments.peerId == component.context.account.peerId } nameHidden = arguments.nameHidden isSelfGift = arguments.messageId?.peerId == component.context.account.peerId if case let .peerId(peerId) = uniqueGift?.owner, peerId == component.context.account.peerId || isChannelGift { isMyUniqueGift = true } if isSelfGift { titleString = strings.Gift_View_Self_Title } else { titleString = incoming ? strings.Gift_View_ReceivedTitle : strings.Gift_View_Title } } else { animationFile = nil stars = 0 text = nil entities = nil limitTotal = nil convertStars = nil titleString = "" } var showUpgradePreview = false if state.inUpgradePreview, let _ = state.sampleGiftAttributes { showUpgradePreview = true } else if case .upgradePreview = component.subject { showUpgradePreview = true } var showWearPreview = false if state.inWearPreview { showWearPreview = true } else if case .wearPreview = component.subject { showWearPreview = true } let buttons = buttons.update( component: ButtonsComponent( theme: theme, isOverlay: showUpgradePreview || uniqueGift != nil, showMoreButton: uniqueGift != nil && !showWearPreview, closePressed: { [weak state] in guard let state else { return } if state.inWearPreview { if let controller = controller() as? GiftViewScreen { controller.dismissAllTooltips() } state.inWearPreview = false state.updated(transition: .spring(duration: 0.4)) } else if state.inUpgradePreview { state.cancelUpgradePreview() } else { state.dismiss(animated: true) } }, morePressed: { [weak state] node, gesture in state?.openMore(node: node, gesture: gesture) } ), availableSize: CGSize(width: 30.0, height: 30.0), transition: context.transition ) var originY: CGFloat = 0.0 let headerHeight: CGFloat let headerSubject: GiftCompositionComponent.Subject? if let uniqueGift { if showWearPreview { headerHeight = 200.0 } else if case let .peerId(peerId) = uniqueGift.owner, peerId == component.context.account.peerId || isChannelGift { headerHeight = 314.0 } else { headerHeight = 240.0 } headerSubject = .unique(uniqueGift) } else if state.inUpgradePreview, let attributes = state.sampleGiftAttributes { headerHeight = 258.0 headerSubject = .preview(attributes) } else if case let .upgradePreview(attributes, _) = component.subject { headerHeight = 258.0 headerSubject = .preview(attributes) } else if let animationFile { headerHeight = 210.0 headerSubject = .generic(animationFile) } else { headerHeight = 210.0 headerSubject = nil } var ownerPeerId: EnginePeer.Id? if let uniqueGift, case let .peerId(peerId) = uniqueGift.owner { ownerPeerId = peerId } let wearOwnerPeerId = ownerPeerId ?? component.context.account.peerId var wearPeerNameChild: _UpdatedChildComponent? if showWearPreview, let uniqueGift { var peerName = "" if let ownerPeer = state.peerMap[wearOwnerPeerId] { peerName = ownerPeer.displayTitle(strings: strings, displayOrder: nameDisplayOrder) } wearPeerNameChild = wearPeerName.update( component: MultilineTextComponent( text: .plain(NSAttributedString( string: peerName, font: Font.bold(28.0), textColor: .white, paragraphAlignment: .center )), horizontalAlignment: .center, maximumNumberOfLines: 1 ), availableSize: CGSize(width: context.availableSize.width - sideInset * 2.0 - 60.0, height: CGFloat.greatestFiniteMagnitude), transition: .immediate ) let giftTitle: String if case .wearPreview = component.subject { giftTitle = uniqueGift.title } else { giftTitle = "\(uniqueGift.title) #\(presentationStringsFormattedNumber(uniqueGift.number, environment.dateTimeFormat.groupingSeparator))" } let wearTitle = wearTitle.update( component: MultilineTextComponent( text: .plain(NSAttributedString( string: strings.Gift_Wear_Wear(giftTitle).string, font: Font.bold(24.0), textColor: theme.actionSheet.primaryTextColor, paragraphAlignment: .center )), horizontalAlignment: .center, maximumNumberOfLines: 1 ), availableSize: CGSize(width: context.availableSize.width - sideInset * 2.0, height: CGFloat.greatestFiniteMagnitude), transition: .immediate ) let wearDescription = wearDescription.update( component: MultilineTextComponent( text: .plain(NSAttributedString( string: strings.Gift_Wear_GetBenefits, font: Font.regular(15.0), textColor: theme.actionSheet.primaryTextColor, paragraphAlignment: .center )), horizontalAlignment: .center, maximumNumberOfLines: 1, lineSpacing: 0.2 ), availableSize: CGSize(width: context.availableSize.width - sideInset * 2.0 - 50.0, height: CGFloat.greatestFiniteMagnitude), transition: .immediate ) var titleOriginY = headerHeight + 18.0 context.add(wearTitle .position(CGPoint(x: context.availableSize.width / 2.0, y: titleOriginY + wearTitle.size.height)) .appear(.default(alpha: true)) .disappear(.default(alpha: true)) ) titleOriginY += wearTitle.size.height titleOriginY += 18.0 context.add(wearDescription .position(CGPoint(x: context.availableSize.width / 2.0, y: titleOriginY + wearDescription.size.height)) .appear(.default(alpha: true)) .disappear(.default(alpha: true)) ) } var animationOffset: CGPoint? var animationScale: CGFloat? if let wearPeerNameChild { animationOffset = CGPoint(x: wearPeerNameChild.size.width / 2.0 + 20.0 - 12.0, y: 56.0) animationScale = 0.19 } if let headerSubject { let animation = animation.update( component: GiftCompositionComponent( context: component.context, theme: environment.theme, subject: headerSubject, animationOffset: animationOffset, animationScale: animationScale, displayAnimationStars: showWearPreview, externalState: giftCompositionExternalState, requestUpdate: { [weak state] in state?.updated() } ), availableSize: CGSize(width: context.availableSize.width, height: headerHeight), transition: context.transition ) context.add(animation .position(CGPoint(x: context.availableSize.width / 2.0, y: headerHeight / 2.0)) ) } originY += headerHeight let vibrantColor: UIColor if let previewPatternColor = giftCompositionExternalState.previewPatternColor { vibrantColor = previewPatternColor.withMultiplied(hue: 1.0, saturation: 1.02, brightness: 1.25).mixedWith(UIColor.white, alpha: 0.3) } else { vibrantColor = UIColor.white.withAlphaComponent(0.6) } if let wearPeerNameChild { if let ownerPeer = state.peerMap[wearOwnerPeerId] { let wearAvatar = wearAvatar.update( component: AvatarComponent( context: component.context, theme: theme, peer: ownerPeer ), environment: {}, availableSize: CGSize(width: 100.0, height: 100.0), transition: context.transition ) context.add(wearAvatar .position(CGPoint(x: context.availableSize.width / 2.0, y: 67.0)) .appear(.default(scale: true, alpha: true)) .disappear(.default(scale: true, alpha: true)) ) } let wearPeerStatus = wearPeerStatus.update( component: MultilineTextComponent( text: .plain(NSAttributedString( string: isChannelGift ? strings.Channel_Status : strings.Presence_online, font: Font.regular(17.0), textColor: vibrantColor, paragraphAlignment: .center )), horizontalAlignment: .center, maximumNumberOfLines: 5, lineSpacing: 0.2 ), availableSize: CGSize(width: context.availableSize.width - sideInset * 2.0 - 50.0, height: CGFloat.greatestFiniteMagnitude), transition: .immediate ) context.add(wearPeerNameChild .position(CGPoint(x: context.availableSize.width / 2.0 - 12.0, y: 144.0)) .appear(.default(alpha: true)) .disappear(.default(alpha: true)) ) context.add(wearPeerStatus .position(CGPoint(x: context.availableSize.width / 2.0, y: 174.0)) .appear(.default(alpha: true)) .disappear(.default(alpha: true)) ) originY += 18.0 originY += 28.0 originY += 18.0 originY += 20.0 originY += 24.0 let textColor = theme.actionSheet.primaryTextColor let secondaryTextColor = theme.actionSheet.secondaryTextColor let linkColor = theme.actionSheet.controlAccentColor var items: [AnyComponentWithIdentity] = [] items.append( AnyComponentWithIdentity( id: "badge", component: AnyComponent(ParagraphComponent( title: strings.Gift_Wear_Badge_Title, titleColor: textColor, text: isChannelGift ? strings.Gift_Wear_Badge_ChannelText : strings.Gift_Wear_Badge_Text, textColor: secondaryTextColor, accentColor: linkColor, iconName: "Premium/Collectible/Badge", iconColor: linkColor )) ) ) items.append( AnyComponentWithIdentity( id: "design", component: AnyComponent(ParagraphComponent( title: strings.Gift_Wear_Design_Title, titleColor: textColor, text: isChannelGift ? strings.Gift_Wear_Design_ChannelText : strings.Gift_Wear_Design_Text, textColor: secondaryTextColor, accentColor: linkColor, iconName: "Premium/BoostPerk/CoverColor", iconColor: linkColor )) ) ) items.append( AnyComponentWithIdentity( id: "proof", component: AnyComponent(ParagraphComponent( title: strings.Gift_Wear_Proof_Title, titleColor: textColor, text: isChannelGift ? strings.Gift_Wear_Proof_ChannelText : strings.Gift_Wear_Proof_Text, textColor: secondaryTextColor, accentColor: linkColor, iconName: "Premium/Collectible/Proof", iconColor: linkColor )) ) ) let perksSideInset = sideInset + 16.0 let wearPerks = wearPerks.update( component: List(items), availableSize: CGSize(width: context.availableSize.width - perksSideInset * 2.0, height: 10000.0), transition: context.transition ) context.add(wearPerks .position(CGPoint(x: context.availableSize.width / 2.0, y: originY + wearPerks.size.height / 2.0)) .appear(.default(alpha: true)) .disappear(.default(alpha: true)) ) originY += wearPerks.size.height originY += 16.0 } else if showUpgradePreview { let title: String let description: String let uniqueText: String let transferableText: String let tradableText: String if case let .upgradePreview(_, name) = component.subject { title = environment.strings.Gift_Upgrade_IncludeTitle description = environment.strings.Gift_Upgrade_IncludeDescription(name).string uniqueText = strings.Gift_Upgrade_Unique_IncludeDescription transferableText = strings.Gift_Upgrade_Transferable_IncludeDescription tradableText = strings.Gift_Upgrade_Tradable_IncludeDescription } else { title = environment.strings.Gift_Upgrade_Title description = environment.strings.Gift_Upgrade_Description uniqueText = strings.Gift_Upgrade_Unique_Description transferableText = strings.Gift_Upgrade_Transferable_Description tradableText = strings.Gift_Upgrade_Tradable_Description } let upgradeTitle = upgradeTitle.update( component: MultilineTextComponent( text: .plain(NSAttributedString( string: title, font: Font.bold(20.0), textColor: .white, paragraphAlignment: .center )), horizontalAlignment: .center, maximumNumberOfLines: 1 ), availableSize: CGSize(width: context.availableSize.width - sideInset * 2.0 - 60.0, height: CGFloat.greatestFiniteMagnitude), transition: .immediate ) let upgradeDescription = upgradeDescription.update( component: BalancedTextComponent( text: .plain(NSAttributedString( string: description, font: Font.regular(13.0), textColor: vibrantColor, paragraphAlignment: .center )), horizontalAlignment: .center, maximumNumberOfLines: 5, lineSpacing: 0.2 ), availableSize: CGSize(width: context.availableSize.width - sideInset * 2.0 - 50.0, height: CGFloat.greatestFiniteMagnitude), transition: .immediate ) let spacing: CGFloat = 6.0 let totalHeight: CGFloat = upgradeTitle.size.height + spacing + upgradeDescription.size.height context.add(upgradeTitle .position(CGPoint(x: context.availableSize.width / 2.0, y: floor(212.0 - totalHeight / 2.0 + upgradeTitle.size.height / 2.0))) .appear(.default(alpha: true)) .disappear(.default(alpha: true)) ) context.add(upgradeDescription .position(CGPoint(x: context.availableSize.width / 2.0, y: floor(212.0 + totalHeight / 2.0 - upgradeDescription.size.height / 2.0))) .appear(.default(alpha: true)) .disappear(.default(alpha: true)) ) originY += 24.0 let textColor = theme.actionSheet.primaryTextColor let secondaryTextColor = theme.actionSheet.secondaryTextColor let linkColor = theme.actionSheet.controlAccentColor var items: [AnyComponentWithIdentity] = [] items.append( AnyComponentWithIdentity( id: "unique", component: AnyComponent(ParagraphComponent( title: strings.Gift_Upgrade_Unique_Title, titleColor: textColor, text: uniqueText, textColor: secondaryTextColor, accentColor: linkColor, iconName: "Premium/Collectible/Unique", iconColor: linkColor )) ) ) items.append( AnyComponentWithIdentity( id: "transferable", component: AnyComponent(ParagraphComponent( title: strings.Gift_Upgrade_Transferable_Title, titleColor: textColor, text: transferableText, textColor: secondaryTextColor, accentColor: linkColor, iconName: "Premium/Collectible/Transferable", iconColor: linkColor )) ) ) items.append( AnyComponentWithIdentity( id: "tradable", component: AnyComponent(ParagraphComponent( title: strings.Gift_Upgrade_Tradable_Title, titleColor: textColor, text: tradableText, textColor: secondaryTextColor, accentColor: linkColor, iconName: "Premium/Collectible/Tradable", iconColor: linkColor )) ) ) let perksSideInset = sideInset + 16.0 let upgradePerks = upgradePerks.update( component: List(items), availableSize: CGSize(width: context.availableSize.width - perksSideInset * 2.0, height: 10000.0), transition: context.transition ) context.add(upgradePerks .position(CGPoint(x: context.availableSize.width / 2.0, y: originY + upgradePerks.size.height / 2.0)) .appear(.default(alpha: true)) .disappear(.default(alpha: true)) ) originY += upgradePerks.size.height originY += 16.0 if case .upgradePreview = component.subject { } else { let checkTheme = CheckComponent.Theme( backgroundColor: theme.list.itemCheckColors.fillColor, strokeColor: theme.list.itemCheckColors.foregroundColor, borderColor: theme.list.itemCheckColors.strokeColor, overlayBorder: false, hasInset: false, hasShadow: false ) let keepInfoText: String if let nameHidden = subject.arguments?.nameHidden, nameHidden { keepInfoText = isChannelGift ? strings.Gift_Upgrade_AddChannelName : strings.Gift_Upgrade_AddMyName } else { keepInfoText = text != nil ? strings.Gift_Upgrade_AddNameAndComment : strings.Gift_Upgrade_AddName } let upgradeKeepName = upgradeKeepName.update( component: PlainButtonComponent( content: AnyComponent(HStack([ AnyComponentWithIdentity(id: AnyHashable(0), component: AnyComponent(CheckComponent( theme: checkTheme, size: CGSize(width: 18.0, height: 18.0), selected: state.keepOriginalInfo ))), AnyComponentWithIdentity(id: AnyHashable(1), component: AnyComponent(MultilineTextComponent( text: .plain(NSAttributedString(string: keepInfoText, font: Font.regular(13.0), textColor: theme.list.itemSecondaryTextColor)) ))) ], spacing: 10.0 )), effectAlignment: .center, action: { [weak state] in guard let state else { return } state.keepOriginalInfo = !state.keepOriginalInfo state.updated(transition: .easeInOut(duration: 0.2)) }, animateAlpha: false, animateScale: false ), availableSize: CGSize(width: context.availableSize.width - sideInset * 2.0, height: 1000.0), transition: context.transition ) context.add(upgradeKeepName .position(CGPoint(x: context.availableSize.width / 2.0, y: originY + upgradeKeepName.size.height / 2.0)) .appear(.default(alpha: true)) .disappear(.default(alpha: true)) ) originY += upgradeKeepName.size.height originY += 18.0 } } else { var descriptionText: String if let uniqueGift { titleString = uniqueGift.title descriptionText = "\(strings.Gift_Unique_Collectible) #\(presentationStringsFormattedNumber(uniqueGift.number, environment.dateTimeFormat.groupingSeparator))" } else if soldOut { descriptionText = strings.Gift_View_UnavailableDescription } else if upgraded { descriptionText = strings.Gift_View_UpgradedDescription } else if incoming { if let _ = upgradeStars { descriptionText = strings.Gift_View_FreeUpgradeDescription } else if let convertStars, !upgraded { if !converted { if canUpgrade || upgradeStars != nil { descriptionText = isChannelGift ? strings.Gift_View_KeepUpgradeOrConvertDescription_Channel(strings.Gift_View_KeepOrConvertDescription_Stars(Int32(convertStars))).string : strings.Gift_View_KeepUpgradeOrConvertDescription(strings.Gift_View_KeepOrConvertDescription_Stars(Int32(convertStars))).string } else { descriptionText = isChannelGift ? strings.Gift_View_KeepOrConvertDescription_Channel(strings.Gift_View_KeepOrConvertDescription_Stars(Int32(convertStars))).string : strings.Gift_View_KeepOrConvertDescription(strings.Gift_View_KeepOrConvertDescription_Stars(Int32(convertStars))).string } } else { descriptionText = strings.Gift_View_ConvertedDescription(strings.Gift_View_ConvertedDescription_Stars(Int32(convertStars))).string } } else { descriptionText = strings.Gift_View_BotDescription } } else if let peerId = subject.arguments?.peerId, let peer = state.peerMap[peerId] { if let _ = upgradeStars { descriptionText = strings.Gift_View_FreeUpgradeOtherDescription(peer.compactDisplayTitle).string } else if case .message = subject, let convertStars { descriptionText = strings.Gift_View_OtherDescription(peer.compactDisplayTitle, strings.Gift_View_OtherDescription_Stars(Int32(convertStars))).string } else { descriptionText = "" } } else { descriptionText = "" } if let spaceRegex { let nsRange = NSRange(descriptionText.startIndex..., in: descriptionText) let matches = spaceRegex.matches(in: descriptionText, options: [], range: nsRange) var modifiedString = descriptionText for match in matches.reversed() { let matchRange = Range(match.range, in: descriptionText)! let matchedSubstring = String(descriptionText[matchRange]) let replacedSubstring = matchedSubstring.replacingOccurrences(of: " ", with: "\u{00A0}") modifiedString.replaceSubrange(matchRange, with: replacedSubstring) } descriptionText = modifiedString } let title = title.update( component: MultilineTextComponent( text: .plain(NSAttributedString( string: titleString, font: uniqueGift != nil ? Font.bold(20.0) : Font.bold(25.0), textColor: uniqueGift != nil ? .white : theme.actionSheet.primaryTextColor, paragraphAlignment: .center )), horizontalAlignment: .center, maximumNumberOfLines: 1 ), availableSize: CGSize(width: context.availableSize.width - sideInset * 2.0 - 60.0, height: CGFloat.greatestFiniteMagnitude), transition: .immediate ) context.add(title .position(CGPoint(x: context.availableSize.width / 2.0, y: uniqueGift != nil ? 190.0 : 177.0)) .appear(.default(alpha: true)) .disappear(.default(alpha: true)) ) if !descriptionText.isEmpty { let linkColor = theme.actionSheet.controlAccentColor if state.cachedSmallStarImage == nil || state.cachedSmallStarImage?.1 !== environment.theme { state.cachedSmallStarImage = (generateTintedImage(image: UIImage(bundleImageName: "Premium/Stars/ButtonStar"), color: .white)!, theme) } if state.cachedChevronImage == nil || state.cachedChevronImage?.1 !== environment.theme { state.cachedChevronImage = (generateTintedImage(image: UIImage(bundleImageName: "Settings/TextArrowRight"), color: linkColor)!, theme) } let textFont: UIFont let textColor: UIColor if let _ = uniqueGift { textFont = Font.regular(13.0) textColor = vibrantColor } else { textFont = soldOut ? Font.medium(15.0) : Font.regular(15.0) textColor = soldOut ? theme.list.itemDestructiveColor : theme.list.itemPrimaryTextColor } let markdownAttributes = MarkdownAttributes(body: MarkdownAttributeSet(font: textFont, textColor: textColor), bold: MarkdownAttributeSet(font: textFont, textColor: textColor), link: MarkdownAttributeSet(font: textFont, textColor: linkColor), linkAttribute: { contents in return (TelegramTextAttributes.URL, contents) }) descriptionText = descriptionText.replacingOccurrences(of: " >]", with: "\u{00A0}>]") let attributedString = parseMarkdownIntoAttributedString(descriptionText, attributes: markdownAttributes, textAlignment: .center).mutableCopy() as! NSMutableAttributedString if let range = attributedString.string.range(of: "*"), let starImage = state.cachedSmallStarImage?.0 { attributedString.addAttribute(.font, value: Font.regular(13.0), range: NSRange(range, in: attributedString.string)) attributedString.addAttribute(.attachment, value: starImage, range: NSRange(range, in: attributedString.string)) attributedString.addAttribute(.baselineOffset, value: 1.0, range: NSRange(range, in: attributedString.string)) } if let range = attributedString.string.range(of: ">"), let chevronImage = state.cachedChevronImage?.0 { attributedString.addAttribute(.attachment, value: chevronImage, range: NSRange(range, in: attributedString.string)) } let description = description.update( component: MultilineTextComponent( text: .plain(attributedString), horizontalAlignment: .center, maximumNumberOfLines: 5, lineSpacing: 0.2, highlightColor: linkColor.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 state] attributes, _ in if let _ = attributes[NSAttributedString.Key(rawValue: TelegramTextAttributes.URL)] as? String { state?.openStarsIntro() } } ), availableSize: CGSize(width: context.availableSize.width - sideInset * 2.0 - 50.0, height: CGFloat.greatestFiniteMagnitude), transition: .immediate ) context.add(description .position(CGPoint(x: context.availableSize.width / 2.0, y: 207.0 + description.size.height / 2.0)) .appear(.default(alpha: true)) .disappear(.default(alpha: true)) ) if uniqueGift != nil { originY += 16.0 } else { originY += description.size.height + 21.0 if soldOut { originY -= 7.0 } } } else { originY += 9.0 } if nameHidden && uniqueGift == nil { let textFont = Font.regular(13.0) let textColor = theme.list.itemSecondaryTextColor let hiddenDescription: String if incoming { hiddenDescription = text != nil ? strings.Gift_View_NameAndMessageHidden : strings.Gift_View_NameHidden } else if let peerId = subject.arguments?.peerId, let peer = state.peerMap[peerId], subject.arguments?.fromPeerId != nil { hiddenDescription = text != nil ? strings.Gift_View_Outgoing_NameAndMessageHidden(peer.compactDisplayTitle).string : strings.Gift_View_Outgoing_NameHidden(peer.compactDisplayTitle).string } else { hiddenDescription = "" } if !hiddenDescription.isEmpty { let hiddenText = hiddenText.update( component: MultilineTextComponent( text: .plain(NSAttributedString(string: hiddenDescription, font: textFont, textColor: textColor)), horizontalAlignment: .center, maximumNumberOfLines: 2, lineSpacing: 0.2 ), availableSize: CGSize(width: context.availableSize.width - sideInset * 2.0 - 60.0, height: CGFloat.greatestFiniteMagnitude), transition: .immediate ) context.add(hiddenText .position(CGPoint(x: context.availableSize.width / 2.0, y: originY)) ) originY += hiddenText.size.height originY += 11.0 } } let tableFont = Font.regular(15.0) let tableBoldFont = Font.semibold(15.0) let tableItalicFont = Font.italic(15.0) let tableBoldItalicFont = Font.semiboldItalic(15.0) let tableMonospaceFont = Font.monospace(15.0) let tableLargeMonospaceFont = Font.monospace(16.0) let tableTextColor = theme.list.itemPrimaryTextColor let tableLinkColor = theme.list.itemAccentColor var tableItems: [TableComponent.Item] = [] var isWearing = state.pendingWear if !soldOut { if let uniqueGift { switch uniqueGift.owner { case let .peerId(peerId): if let peer = state.peerMap[peerId] { let ownerComponent: AnyComponent if peer.id == component.context.account.peerId, peer.isPremium { let animationContent: EmojiStatusComponent.Content var color: UIColor? var statusId: Int64 = 1 if state.pendingWear { var fileId: Int64? for attribute in uniqueGift.attributes { if case let .model(_, file, _) = attribute { fileId = file.fileId.id } if case let .backdrop(_, _, innerColor, _, _, _, _) = attribute { color = UIColor(rgb: UInt32(bitPattern: innerColor)) } } if let fileId { statusId = fileId animationContent = .animation(content: .customEmoji(fileId: fileId), size: CGSize(width: 18.0, height: 18.0), placeholderColor: theme.list.mediaPlaceholderColor, themeColor: tableLinkColor, loopMode: .count(2)) } else { animationContent = .premium(color: tableLinkColor) } } else if let emojiStatus = peer.emojiStatus, !state.pendingTakeOff { animationContent = .animation(content: .customEmoji(fileId: emojiStatus.fileId), size: CGSize(width: 18.0, height: 18.0), placeholderColor: theme.list.mediaPlaceholderColor, themeColor: tableLinkColor, loopMode: .count(2)) if case let .starGift(id, _, _, _, _, innerColor, _, _, _) = emojiStatus.content { color = UIColor(rgb: UInt32(bitPattern: innerColor)) if id == uniqueGift.id { isWearing = true state.pendingWear = false } } } else { animationContent = .premium(color: tableLinkColor) state.pendingTakeOff = false } ownerComponent = AnyComponent( HStack([ AnyComponentWithIdentity( id: AnyHashable(0), component: AnyComponent(Button( content: AnyComponent( PeerCellComponent( context: component.context, theme: theme, strings: strings, peer: peer ) ), action: { [weak state] in state?.openPeer(peer) } )) ), AnyComponentWithIdentity( id: AnyHashable(statusId), component: AnyComponent(EmojiStatusComponent( context: component.context, animationCache: component.context.animationCache, animationRenderer: component.context.animationRenderer, content: animationContent, particleColor: color, size: CGSize(width: 18.0, height: 18.0), isVisibleForAnimations: true, action: { }, tag: state.statusTag )) ) ], spacing: 2.0) ) } else { ownerComponent = AnyComponent(Button( content: AnyComponent( PeerCellComponent( context: component.context, theme: theme, strings: strings, peer: peer ) ), action: { [weak state] in state?.openPeer(peer) } )) } tableItems.append(.init( id: "owner", title: strings.Gift_Unique_Owner, component: ownerComponent )) } case let .name(name): tableItems.append(.init( id: "name_owner", title: strings.Gift_Unique_Owner, component: AnyComponent( MultilineTextComponent(text: .plain(NSAttributedString(string: name, font: tableFont, textColor: tableTextColor))) ) )) case let .address(address): exported = true func formatAddress(_ str: String) -> String { guard str.count == 48 && !str.hasSuffix(".ton") else { return str } var result = str let middleIndex = result.index(result.startIndex, offsetBy: str.count / 2) result.insert("\n", at: middleIndex) return result } tableItems.append(.init( id: "address_owner", title: strings.Gift_Unique_Owner, component: AnyComponent( Button( content: AnyComponent( MultilineTextComponent(text: .plain(NSAttributedString(string: formatAddress(address), font: tableLargeMonospaceFont, textColor: tableLinkColor)), maximumNumberOfLines: 2, lineSpacing: 0.2) ), action: { [weak state] in state?.copyAddress(address) } ) ) )) } } else if let peerId = subject.arguments?.fromPeerId, let peer = state.peerMap[peerId] { var isBot = false if case let .user(user) = peer, user.botInfo != nil { isBot = true } let fromComponent: AnyComponent if incoming && !peer.isDeleted && !isBot && !isChannelGift { fromComponent = AnyComponent( HStack([ AnyComponentWithIdentity( id: AnyHashable(0), component: AnyComponent(Button( content: AnyComponent( PeerCellComponent( context: component.context, theme: theme, strings: strings, peer: peer ) ), action: { [weak state] in state?.openPeer(peer) } )) ), AnyComponentWithIdentity( id: AnyHashable(1), component: AnyComponent(Button( content: AnyComponent(ButtonContentComponent( context: component.context, text: strings.Gift_View_Send, color: theme.list.itemAccentColor )), action: { [weak state] in state?.sendGift(peerId: peerId) } )) ) ], spacing: 4.0) ) } else { fromComponent = AnyComponent(Button( content: AnyComponent( PeerCellComponent( context: component.context, theme: theme, strings: strings, peer: peer ) ), action: { [weak state] in state?.openPeer(peer) } )) } if !isSelfGift { tableItems.append(.init( id: "from", title: strings.Gift_View_From, component: fromComponent )) } } else { if !isSelfGift { tableItems.append(.init( id: "from_anon", title: strings.Gift_View_From, component: AnyComponent( PeerCellComponent( context: component.context, theme: theme, strings: strings, peer: nil ) ) )) } } } if let uniqueGift { if isMyUniqueGift, case let .peerId(peerId) = uniqueGift.owner { var canTransfer = true var canResell = true if let peer = state.peerMap[peerId], case let .channel(channel) = peer { if !channel.flags.contains(.isCreator) { canTransfer = false } canResell = false } else if subject.arguments?.transferStars == nil { canTransfer = false } var buttonsCount = 1 if canTransfer { buttonsCount += 1 } if canResell { buttonsCount += 1 } let buttonSpacing: CGFloat = 10.0 let buttonWidth = floor(context.availableSize.width - sideInset * 2.0 - buttonSpacing * CGFloat(buttonsCount - 1)) / CGFloat(buttonsCount) let buttonHeight: CGFloat = 58.0 var buttonOriginX = sideInset if canTransfer { let transferButton = transferButton.update( component: PlainButtonComponent( content: AnyComponent( HeaderButtonComponent( title: strings.Gift_View_Header_Transfer, iconName: "Premium/Collectible/Transfer" ) ), effectAlignment: .center, action: { [weak state] in state?.transferGift() } ), environment: {}, availableSize: CGSize(width: buttonWidth, height: buttonHeight), transition: context.transition ) context.add(transferButton .position(CGPoint(x: buttonOriginX + buttonWidth / 2.0, y: headerHeight - buttonHeight / 2.0 - 16.0)) .appear(.default(scale: true, alpha: true)) .disappear(.default(scale: true, alpha: true)) ) buttonOriginX += buttonWidth + buttonSpacing } let wearButton = wearButton.update( component: PlainButtonComponent( content: AnyComponent( HeaderButtonComponent( title: isWearing ? strings.Gift_View_Header_TakeOff : strings.Gift_View_Header_Wear, iconName: isWearing ? "Premium/Collectible/Unwear" : "Premium/Collectible/Wear" ) ), effectAlignment: .center, action: { [weak state] in if let state { if isWearing { state.commitTakeOff() state.showAttributeInfo(tag: state.statusTag, text: strings.Gift_View_TookOff("\(uniqueGift.title) #\(presentationStringsFormattedNumber(uniqueGift.number, environment.dateTimeFormat.groupingSeparator))").string) } else { if let controller = controller() as? GiftViewScreen { controller.dismissAllTooltips() } let canWear: Bool if isChannelGift, case let .channel(channel) = state.peerMap[wearOwnerPeerId] { let premiumConfiguration = PremiumConfiguration.with(appConfiguration: component.context.currentAppConfiguration.with { $0 }) let requiredLevel = Int(BoostSubject.wearGift.requiredLevel(group: false, context: component.context, configuration: premiumConfiguration)) if let boostLevel = channel.approximateBoostLevel { canWear = boostLevel >= requiredLevel } else { canWear = false } } else { canWear = component.context.isPremium } let _ = (ApplicationSpecificNotice.getStarGiftWearTips(accountManager: component.context.sharedContext.accountManager) |> deliverOnMainQueue).start(next: { [weak state] count in guard let state else { return } if !canWear || count < 3 { state.requestWearPreview() } else { state.commitWear(uniqueGift) state.showAttributeInfo(tag: state.statusTag, text: strings.Gift_View_PutOn("\(uniqueGift.title) #\(presentationStringsFormattedNumber(uniqueGift.number, environment.dateTimeFormat.groupingSeparator))").string) } }) } } } ), environment: {}, availableSize: CGSize(width: buttonWidth, height: buttonHeight), transition: context.transition ) context.add(wearButton .position(CGPoint(x: buttonOriginX + buttonWidth / 2.0, y: headerHeight - buttonHeight / 2.0 - 16.0)) .appear(.default(scale: true, alpha: true)) .disappear(.default(scale: true, alpha: true)) ) buttonOriginX += buttonWidth + buttonSpacing if canResell { let resellButton = resellButton.update( component: PlainButtonComponent( content: AnyComponent( HeaderButtonComponent( title: uniqueGift.resellStars == nil ? strings.Gift_View_Sell : strings.Gift_View_Unlist, iconName: uniqueGift.resellStars == nil ? "Premium/Collectible/Sell" : "Premium/Collectible/Unlist" ) ), effectAlignment: .center, action: { [weak state] in state?.resellGift() } ), environment: {}, availableSize: CGSize(width: buttonWidth, height: buttonHeight), transition: context.transition ) context.add(resellButton .position(CGPoint(x: buttonOriginX + buttonWidth / 2.0, y: headerHeight - buttonHeight / 2.0 - 16.0)) .appear(.default(scale: true, alpha: true)) .disappear(.default(scale: true, alpha: true)) ) } } let order: [StarGift.UniqueGift.Attribute.AttributeType] = [ .model, .backdrop, .pattern, .originalInfo ] var attributeMap: [StarGift.UniqueGift.Attribute.AttributeType: StarGift.UniqueGift.Attribute] = [:] for attribute in uniqueGift.attributes { attributeMap[attribute.attributeType] = attribute } var hasOriginalInfo = false for type in order { if let attribute = attributeMap[type] { let id: String let title: String? let value: NSAttributedString let percentage: Float? let tag: AnyObject? var hasBackground = false switch attribute { case let .model(name, _, rarity): id = "model" title = strings.Gift_Unique_Model value = NSAttributedString(string: name, font: tableFont, textColor: tableTextColor) percentage = Float(rarity) * 0.1 tag = state.modelButtonTag case let .backdrop(name, _, _, _, _, _, rarity): id = "backdrop" title = strings.Gift_Unique_Backdrop value = NSAttributedString(string: name, font: tableFont, textColor: tableTextColor) percentage = Float(rarity) * 0.1 tag = state.backdropButtonTag case let .pattern(name, _, rarity): id = "pattern" title = strings.Gift_Unique_Symbol value = NSAttributedString(string: name, font: tableFont, textColor: tableTextColor) percentage = Float(rarity) * 0.1 tag = state.symbolButtonTag case let .originalInfo(senderPeerId, recipientPeerId, date, text, entities): id = "originalInfo" title = nil hasBackground = true let tableFont = Font.regular(13.0) let tableBoldFont = Font.semibold(13.0) let tableItalicFont = Font.italic(13.0) let tableBoldItalicFont = Font.semiboldItalic(13.0) let tableMonospaceFont = Font.monospace(13.0) let senderName = (senderPeerId.flatMap { state.peerMap[$0]?.displayTitle(strings: strings, displayOrder: nameDisplayOrder) }) let recipientName = state.peerMap[recipientPeerId]?.displayTitle(strings: strings, displayOrder: nameDisplayOrder) ?? "" let dateString = stringForMediumDate(timestamp: date, strings: strings, dateTimeFormat: dateTimeFormat, withTime: false) if let text { let attributedText = stringWithAppliedEntities(text, entities: entities ?? [], baseColor: tableTextColor, linkColor: tableLinkColor, baseFont: tableFont, linkFont: tableFont, boldFont: tableBoldFont, italicFont: tableItalicFont, boldItalicFont: tableBoldItalicFont, fixedFont: tableMonospaceFont, blockQuoteFont: tableFont, message: nil) let format = senderName != nil ? strings.Gift_Unique_OriginalInfoSenderWithText(senderName!, recipientName, dateString, "") : strings.Gift_Unique_OriginalInfoWithText(recipientName, dateString, "") let string = NSMutableAttributedString(string: format.string, font: tableFont, textColor: tableTextColor) string.replaceCharacters(in: format.ranges[format.ranges.count - 1].range, with: attributedText) if let senderPeerId { string.addAttribute(.foregroundColor, value: tableLinkColor, range: format.ranges[0].range) string.addAttribute(NSAttributedString.Key(rawValue: TelegramTextAttributes.PeerMention), value: TelegramPeerMention(peerId: senderPeerId, mention: ""), range: format.ranges[0].range) string.addAttribute(.foregroundColor, value: tableLinkColor, range: format.ranges[1].range) string.addAttribute(NSAttributedString.Key(rawValue: TelegramTextAttributes.PeerMention), value: TelegramPeerMention(peerId: recipientPeerId, mention: ""), range: format.ranges[1].range) } else { string.addAttribute(.foregroundColor, value: tableLinkColor, range: format.ranges[0].range) string.addAttribute(NSAttributedString.Key(rawValue: TelegramTextAttributes.PeerMention), value: TelegramPeerMention(peerId: recipientPeerId, mention: ""), range: format.ranges[0].range) } value = string } else { let format = senderName != nil ? strings.Gift_Unique_OriginalInfoSender(senderName!, recipientName, dateString) : strings.Gift_Unique_OriginalInfo(recipientName, dateString) let string = NSMutableAttributedString(string: format.string, font: tableFont, textColor: tableTextColor) if let senderPeerId { string.addAttribute(.foregroundColor, value: tableLinkColor, range: format.ranges[0].range) string.addAttribute(NSAttributedString.Key(rawValue: TelegramTextAttributes.PeerMention), value: TelegramPeerMention(peerId: senderPeerId, mention: ""), range: format.ranges[0].range) string.addAttribute(.foregroundColor, value: tableLinkColor, range: format.ranges[1].range) string.addAttribute(NSAttributedString.Key(rawValue: TelegramTextAttributes.PeerMention), value: TelegramPeerMention(peerId: recipientPeerId, mention: ""), range: format.ranges[1].range) } else { string.addAttribute(.foregroundColor, value: tableLinkColor, range: format.ranges[0].range) string.addAttribute(NSAttributedString.Key(rawValue: TelegramTextAttributes.PeerMention), value: TelegramPeerMention(peerId: recipientPeerId, mention: ""), range: format.ranges[0].range) } value = string } percentage = nil tag = nil hasOriginalInfo = true } var items: [AnyComponentWithIdentity] = [] items.append( AnyComponentWithIdentity( id: AnyHashable(0), component: AnyComponent( MultilineTextWithEntitiesComponent( context: component.context, animationCache: component.context.animationCache, animationRenderer: component.context.animationRenderer, placeholderColor: theme.list.mediaPlaceholderColor, text: .plain(value), horizontalAlignment: .center, maximumNumberOfLines: 0, insets: id == "originalInfo" ? UIEdgeInsets(top: 2.0, left: 0.0, bottom: 2.0, right: 0.0) : .zero, highlightColor: tableLinkColor.withAlphaComponent(0.1), handleSpoilers: true, highlightAction: { attributes in if let _ = attributes[NSAttributedString.Key(rawValue: TelegramTextAttributes.PeerMention)] { return NSAttributedString.Key(rawValue: TelegramTextAttributes.PeerMention) } else { return nil } }, tapAction: { [weak state] attributes, _ in guard let state else { return } if let mention = attributes[NSAttributedString.Key(rawValue: TelegramTextAttributes.PeerMention)] as? TelegramPeerMention, let peer = state.peerMap[mention.peerId] { state.openPeer(peer) } } ) ) ) ) if let percentage, let tag { items.append(AnyComponentWithIdentity( id: AnyHashable(1), component: AnyComponent(Button( content: AnyComponent(ButtonContentComponent( context: component.context, text: formatPercentage(percentage), color: theme.list.itemAccentColor )), action: { [weak state] in state?.showAttributeInfo(tag: tag, text: strings.Gift_Unique_AttributeDescription(formatPercentage(percentage)).string) } ).tagged(tag)) )) } let itemComponent = AnyComponent( HStack(items, spacing: 4.0) ) tableItems.append(.init( id: id, title: title, hasBackground: hasBackground, component: itemComponent )) } } let issuedString = presentationStringsFormattedNumber(uniqueGift.availability.issued, environment.dateTimeFormat.groupingSeparator) let totalString = presentationStringsFormattedNumber(uniqueGift.availability.total, environment.dateTimeFormat.groupingSeparator) tableItems.insert(.init( id: "availability", title: strings.Gift_Unique_Availability, component: AnyComponent( MultilineTextComponent(text: .plain(NSAttributedString(string: strings.Gift_Unique_Issued("\(issuedString)/\(totalString)").string, font: tableFont, textColor: tableTextColor))) ) ), at: hasOriginalInfo ? tableItems.count - 1 : tableItems.count) } else { if case let .soldOutGift(gift) = subject, let soldOut = gift.soldOut { tableItems.append(.init( id: "firstDate", title: strings.Gift_View_FirstSale, component: AnyComponent( MultilineTextComponent(text: .plain(NSAttributedString(string: stringForMediumDate(timestamp: soldOut.firstSale, strings: strings, dateTimeFormat: dateTimeFormat), font: tableFont, textColor: tableTextColor))) ) )) tableItems.append(.init( id: "lastDate", title: strings.Gift_View_LastSale, component: AnyComponent( MultilineTextComponent(text: .plain(NSAttributedString(string: stringForMediumDate(timestamp: soldOut.lastSale, strings: strings, dateTimeFormat: dateTimeFormat), font: tableFont, textColor: tableTextColor))) ) )) } else if let date { tableItems.append(.init( id: "date", title: strings.Gift_View_Date, component: AnyComponent( MultilineTextComponent(text: .plain(NSAttributedString(string: stringForMediumDate(timestamp: date, strings: strings, dateTimeFormat: dateTimeFormat), font: tableFont, textColor: tableTextColor))) ) )) } var finalStars = stars if let upgradeStars, upgradeStars > 0 { finalStars += upgradeStars } let valueString = "\(presentationStringsFormattedNumber(abs(Int32(finalStars)), dateTimeFormat.groupingSeparator))⭐️" let valueAttributedString = NSMutableAttributedString(string: valueString, font: tableFont, textColor: tableTextColor) let range = (valueAttributedString.string as NSString).range(of: "⭐️") if range.location != NSNotFound { valueAttributedString.addAttribute(ChatTextInputAttributes.customEmoji, value: ChatTextInputTextCustomEmojiAttribute(interactivelySelectedFromPackId: nil, fileId: 0, file: nil, custom: .stars(tinted: false)), range: range) valueAttributedString.addAttribute(.baselineOffset, value: 1.0, range: range) } var canConvert = true if let reference = subject.arguments?.reference, case let .peer(peerId, _) = reference { if let peer = state.peerMap[peerId], case let .channel(channel) = peer, !channel.flags.contains(.isCreator) { canConvert = false } } if let convertStars, incoming && !converted && canConvert { tableItems.append(.init( id: "value_convert", title: strings.Gift_View_Value, component: AnyComponent( HStack([ AnyComponentWithIdentity( id: AnyHashable(0), component: AnyComponent(MultilineTextWithEntitiesComponent( context: component.context, animationCache: component.context.animationCache, animationRenderer: component.context.animationRenderer, placeholderColor: theme.list.mediaPlaceholderColor, text: .plain(valueAttributedString), maximumNumberOfLines: 0 )) ), AnyComponentWithIdentity( id: AnyHashable(1), component: AnyComponent(Button( content: AnyComponent(ButtonContentComponent( context: component.context, text: strings.Gift_View_Sale(strings.Gift_View_Sale_Stars(Int32(convertStars))).string, color: theme.list.itemAccentColor )), action: { [weak state] in state?.convertToStars() } )) ) ], spacing: 4.0) ), insets: UIEdgeInsets(top: 0.0, left: 10.0, bottom: 0.0, right: 12.0) )) } else { tableItems.append(.init( id: "value", title: strings.Gift_View_Value, component: AnyComponent(MultilineTextWithEntitiesComponent( context: component.context, animationCache: component.context.animationCache, animationRenderer: component.context.animationRenderer, placeholderColor: theme.list.mediaPlaceholderColor, text: .plain(valueAttributedString), maximumNumberOfLines: 0 )), insets: UIEdgeInsets(top: 0.0, left: 10.0, bottom: 0.0, right: 12.0) )) } if let limitTotal { var remains: Int32 = limitRemains ?? 0 if let gift = state.starGiftsMap[giftId], let availability = gift.availability { remains = availability.remains } let remainsString = presentationStringsFormattedNumber(remains, environment.dateTimeFormat.groupingSeparator) let totalString = presentationStringsFormattedNumber(limitTotal, environment.dateTimeFormat.groupingSeparator) tableItems.append(.init( id: "availability", title: strings.Gift_View_Availability, component: AnyComponent( MultilineTextComponent(text: .plain(NSAttributedString(string: strings.Gift_View_Availability_NewOf("\(remainsString)", "\(totalString)").string, font: tableFont, textColor: tableTextColor))) ) )) } if !soldOut && canUpgrade { var items: [AnyComponentWithIdentity] = [] items.append( AnyComponentWithIdentity( id: AnyHashable(0), component: AnyComponent(MultilineTextComponent(text: .plain(NSAttributedString(string: strings.Gift_View_Status_NonUnique, font: tableFont, textColor: tableTextColor)))) ) ) if incoming { items.append( AnyComponentWithIdentity( id: AnyHashable(1), component: AnyComponent(Button( content: AnyComponent(ButtonContentComponent( context: component.context, text: strings.Gift_View_Status_Upgrade, color: theme.list.itemAccentColor )), action: { [weak state] in state?.requestUpgradePreview() } )) ) ) } tableItems.append(.init( id: "status", title: strings.Gift_View_Status, component: AnyComponent( HStack(items, spacing: 4.0) ), insets: UIEdgeInsets(top: 0.0, left: 10.0, bottom: 0.0, right: 12.0) )) } if let text { let attributedText = stringWithAppliedEntities(text, entities: entities ?? [], baseColor: tableTextColor, linkColor: tableLinkColor, baseFont: tableFont, linkFont: tableFont, boldFont: tableBoldFont, italicFont: tableItalicFont, boldItalicFont: tableBoldItalicFont, fixedFont: tableMonospaceFont, blockQuoteFont: tableFont, message: nil) tableItems.append(.init( id: "text", title: nil, component: AnyComponent( MultilineTextWithEntitiesComponent( context: component.context, animationCache: component.context.animationCache, animationRenderer: component.context.animationRenderer, placeholderColor: theme.list.mediaPlaceholderColor, text: .plain(attributedText), maximumNumberOfLines: 0, insets: UIEdgeInsets(top: 1.0, left: 0.0, bottom: 1.0, right: 0.0), handleSpoilers: true ) ) )) } } let table = table.update( component: TableComponent( theme: environment.theme, items: tableItems ), availableSize: CGSize(width: context.availableSize.width - sideInset * 2.0, height: .greatestFiniteMagnitude), transition: context.transition ) context.add(table .position(CGPoint(x: context.availableSize.width / 2.0, y: originY + table.size.height / 2.0)) .appear(.default(alpha: true)) .disappear(.default(alpha: true)) ) originY += table.size.height + 23.0 } var resellStars: Int64? var selling = false if let uniqueGift { resellStars = uniqueGift.resellStars if let resellStars { if incoming || ownerPeerId == component.context.account.peerId { let priceButton = priceButton.update( component: PlainButtonComponent( content: AnyComponent( PriceButtonComponent(price: presentationStringsFormattedNumber(Int32(resellStars), environment.dateTimeFormat.groupingSeparator)) ), effectAlignment: .center, action: { [weak state] in state?.resellGift(update: true) }, animateScale: false ), availableSize: CGSize(width: 150.0, height: 30.0), transition: context.transition ) context.add(priceButton .position(CGPoint(x: environment.safeInsets.left + 16.0 + priceButton.size.width / 2.0, y: 28.0)) .appear(.default(scale: true, alpha: true)) .disappear(.default(scale: true, alpha: true)) ) } if case let .uniqueGift(_, recipientPeerId) = component.subject, recipientPeerId != nil { } else if ownerPeerId != component.context.account.peerId { selling = true } } } if ((incoming && !converted && !upgraded) || exported || selling) && (!showUpgradePreview && !showWearPreview) { let linkColor = theme.actionSheet.controlAccentColor if state.cachedSmallChevronImage == nil || state.cachedSmallChevronImage?.1 !== environment.theme { state.cachedSmallChevronImage = (generateTintedImage(image: UIImage(bundleImageName: "Item List/InlineTextRightArrow"), color: linkColor)!, theme) } var addressToOpen: String? var descriptionText: String if let uniqueGift, selling { let ownerName: String if case let .peerId(peerId) = uniqueGift.owner { ownerName = state.peerMap[peerId]?.compactDisplayTitle ?? "" } else { ownerName = "" } descriptionText = strings.Gift_View_SellingGiftInfo(ownerName).string } else if let uniqueGift, let address = uniqueGift.giftAddress, case .address = uniqueGift.owner { addressToOpen = address descriptionText = strings.Gift_View_TonGiftAddressInfo } else if savedToProfile { descriptionText = isChannelGift ? strings.Gift_View_DisplayedInfoHide_Channel : strings.Gift_View_DisplayedInfoHide } else if let upgradeStars, upgradeStars > 0 && !upgraded { descriptionText = isChannelGift ? strings.Gift_View_HiddenInfoShow_Channel : strings.Gift_View_HiddenInfoShow } else { if let _ = uniqueGift { descriptionText = isChannelGift ? strings.Gift_View_UniqueHiddenInfo_Channel : strings.Gift_View_UniqueHiddenInfo } else { descriptionText = isChannelGift ? strings.Gift_View_HiddenInfo_Channel : strings.Gift_View_HiddenInfo } } let textFont = Font.regular(13.0) let textColor = theme.list.itemSecondaryTextColor let markdownAttributes = MarkdownAttributes(body: MarkdownAttributeSet(font: textFont, textColor: textColor), bold: MarkdownAttributeSet(font: textFont, textColor: textColor), link: MarkdownAttributeSet(font: textFont, textColor: linkColor), linkAttribute: { contents in return (TelegramTextAttributes.URL, contents) }) descriptionText = descriptionText.replacingOccurrences(of: " >]", with: "\u{00A0}>]") let attributedString = parseMarkdownIntoAttributedString(descriptionText, attributes: markdownAttributes, textAlignment: .center).mutableCopy() as! NSMutableAttributedString if let range = attributedString.string.range(of: ">"), let chevronImage = state.cachedSmallChevronImage?.0 { attributedString.addAttribute(.attachment, value: chevronImage, range: NSRange(range, in: attributedString.string)) } originY -= 5.0 let additionalText = additionalText.update( component: MultilineTextComponent( text: .plain(attributedString), horizontalAlignment: .center, maximumNumberOfLines: 5, lineSpacing: 0.2, highlightColor: linkColor.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 state] attributes, _ in if let _ = attributes[NSAttributedString.Key(rawValue: TelegramTextAttributes.URL)] as? String { if let addressToOpen { state?.openAddress(addressToOpen) } else { state?.updateSavedToProfile(!savedToProfile) Queue.mainQueue().after(0.6, { state?.dismiss(animated: false) }) } } } ), availableSize: CGSize(width: context.availableSize.width - sideInset * 2.0 - 60.0, height: CGFloat.greatestFiniteMagnitude), transition: .immediate ) context.add(additionalText .position(CGPoint(x: context.availableSize.width / 2.0, y: originY + additionalText.size.height / 2.0)) .appear(.default(alpha: true)) .disappear(.default(alpha: true)) ) originY += additionalText.size.height originY += 16.0 } let buttonSize = CGSize(width: context.availableSize.width - sideInset * 2.0, height: 50.0) let buttonBackground = ButtonComponent.Background( color: theme.list.itemCheckColors.fillColor, foreground: theme.list.itemCheckColors.foregroundColor, pressedColor: theme.list.itemCheckColors.fillColor.withMultipliedAlpha(0.9) ) let buttonChild: _UpdatedChildComponent if showWearPreview, let uniqueGift { let buttonContent: AnyComponentWithIdentity let premiumConfiguration = PremiumConfiguration.with(appConfiguration: component.context.currentAppConfiguration.with { $0 }) let requiredLevel = Int(BoostSubject.wearGift.requiredLevel(group: false, context: component.context, configuration: premiumConfiguration)) var canWear = true if isChannelGift, case let .channel(channel) = state.peerMap[wearOwnerPeerId], (channel.approximateBoostLevel ?? 0) < requiredLevel { canWear = false buttonContent = AnyComponentWithIdentity( id: AnyHashable("wear_channel"), component: AnyComponent( VStack([ AnyComponentWithIdentity( id: AnyHashable("label"), component: AnyComponent(MultilineTextComponent(text: .plain(NSAttributedString(string: strings.Gift_Wear_Start, font: Font.semibold(17.0), textColor: theme.list.itemCheckColors.foregroundColor, paragraphAlignment: .center)))) ), AnyComponentWithIdentity( id: AnyHashable("level"), component: AnyComponent(PremiumLockButtonSubtitleComponent( count: requiredLevel, theme: theme, strings: strings )) ) ], spacing: 3.0) ) ) } else if !isChannelGift && !component.context.isPremium { canWear = false buttonContent = AnyComponentWithIdentity( id: AnyHashable("wear_premium"), component: AnyComponent( HStack([ AnyComponentWithIdentity( id: AnyHashable("icon"), component: AnyComponent(BundleIconComponent(name: "Chat/Stickers/Lock", tintColor: theme.list.itemCheckColors.foregroundColor)) ), AnyComponentWithIdentity( id: AnyHashable("label"), component: AnyComponent(MultilineTextComponent(text: .plain(NSAttributedString(string: strings.Gift_Wear_Start, font: Font.semibold(17.0), textColor: theme.list.itemCheckColors.foregroundColor, paragraphAlignment: .center)))) ) ], spacing: 3.0) ) ) } else { buttonContent = AnyComponentWithIdentity( id: AnyHashable("wear"), component: AnyComponent(MultilineTextComponent(text: .plain(NSAttributedString(string: strings.Gift_Wear_Start, font: Font.semibold(17.0), textColor: theme.list.itemCheckColors.foregroundColor, paragraphAlignment: .center)))) ) } buttonChild = button.update( component: ButtonComponent( background: buttonBackground, content: buttonContent, isEnabled: true, displaysProgress: false, action: { [weak state] in if let state { let context = component.context if !canWear, let controller = controller() as? GiftViewScreen { controller.dismissAllTooltips() if isChannelGift { state.levelsDisposable.set(combineLatest( queue: Queue.mainQueue(), context.engine.peers.getChannelBoostStatus(peerId: wearOwnerPeerId), context.engine.peers.getMyBoostStatus() ).startStandalone(next: { [weak controller, weak state] boostStatus, myBoostStatus in guard let controller, let state, let boostStatus, let myBoostStatus else { return } state.dismiss(animated: true) let levelsController = context.sharedContext.makePremiumBoostLevelsController(context: context, peerId: wearOwnerPeerId, subject: .wearGift, boostStatus: boostStatus, myBoostStatus: myBoostStatus, forceDark: false, openStats: nil) controller.push(levelsController) HapticFeedback().impact(.light) })) } else { let presentationData = context.sharedContext.currentPresentationData.with { $0 } let text = strings.Gift_View_TooltipPremiumWearing let tooltipController = UndoOverlayController( presentationData: presentationData, content: .premiumPaywall(title: nil, text: text, customUndoText: nil, timeout: nil, linkAction: nil), position: .bottom, animateInAsReplacement: false, appearance: UndoOverlayController.Appearance(sideInset: 16.0, bottomInset: 62.0), action: { [weak controller, weak state] action in if case .info = action { controller?.dismissAllTooltips() let premiumController = context.sharedContext.makePremiumIntroController(context: context, source: .messageEffects, forceDark: false, dismissed: nil) controller?.push(premiumController) Queue.mainQueue().after(0.6, { state?.dismiss(animated: false) }) } return false } ) controller.present(tooltipController, in: .window(.root)) } } else { state.commitWear(uniqueGift) if case .wearPreview = component.subject { state.dismiss(animated: true) } else { Queue.mainQueue().after(0.2) { state.showAttributeInfo(tag: state.statusTag, text: strings.Gift_View_PutOn("\(uniqueGift.title) #\(presentationStringsFormattedNumber(uniqueGift.number, environment.dateTimeFormat.groupingSeparator))").string) } } } } }), availableSize: buttonSize, transition: context.transition ) } else if state.inUpgradePreview { if state.cachedStarImage == nil || state.cachedStarImage?.1 !== theme { state.cachedStarImage = (generateTintedImage(image: UIImage(bundleImageName: "Item List/PremiumIcon"), color: theme.list.itemCheckColors.foregroundColor)!, theme) } var upgradeString = strings.Gift_Upgrade_Upgrade if let upgradeForm = state.upgradeForm, let price = upgradeForm.invoice.prices.first?.amount { upgradeString += " # \(presentationStringsFormattedNumber(Int32(price), environment.dateTimeFormat.groupingSeparator))" } let buttonTitle = subject.arguments?.upgradeStars != nil ? strings.Gift_Upgrade_Confirm : upgradeString let buttonAttributedString = NSMutableAttributedString(string: buttonTitle, 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: theme.list.itemCheckColors.foregroundColor, range: NSRange(range, in: buttonAttributedString.string)) buttonAttributedString.addAttribute(.baselineOffset, value: 1.5, range: NSRange(range, in: buttonAttributedString.string)) buttonAttributedString.addAttribute(.kern, value: 2.0, range: NSRange(range, in: buttonAttributedString.string)) } buttonChild = button.update( component: ButtonComponent( background: buttonBackground, content: AnyComponentWithIdentity( id: AnyHashable("upgrade"), component: AnyComponent(MultilineTextComponent(text: .plain(buttonAttributedString))) ), isEnabled: true, displaysProgress: state.inProgress, action: { [weak state] in state?.commitUpgrade() }), availableSize: buttonSize, transition: context.transition ) } else if upgraded, let upgradeMessageIdId = subject.arguments?.upgradeMessageId, let originalMessageId = subject.arguments?.messageId { let upgradeMessageId = MessageId(peerId: originalMessageId.peerId, namespace: originalMessageId.namespace, id: upgradeMessageIdId) let buttonTitle = strings.Gift_View_ViewUpgraded buttonChild = button.update( component: ButtonComponent( background: buttonBackground, content: AnyComponentWithIdentity( id: AnyHashable("button"), component: AnyComponent(MultilineTextComponent(text: .plain(NSAttributedString(string: buttonTitle, font: Font.semibold(17.0), textColor: theme.list.itemCheckColors.foregroundColor, paragraphAlignment: .center)))) ), isEnabled: true, displaysProgress: false, action: { [weak state] in state?.dismiss(animated: true) state?.viewUpgradedGift(messageId: upgradeMessageId) }), availableSize: buttonSize, transition: context.transition ) } else if incoming && !converted && !upgraded, let upgradeStars, upgradeStars > 0 { let buttonTitle = strings.Gift_View_UpgradeForFree buttonChild = button.update( component: ButtonComponent( background: buttonBackground.withIsShimmering(true), content: AnyComponentWithIdentity( id: AnyHashable("freeUpgrade"), component: AnyComponent(HStack([ AnyComponentWithIdentity(id: 0, component: AnyComponent( MultilineTextComponent(text: .plain(NSAttributedString(string: buttonTitle, font: Font.semibold(17.0), textColor: theme.list.itemCheckColors.foregroundColor, paragraphAlignment: .center)) ))), AnyComponentWithIdentity(id: 1, component: AnyComponent( LottieComponent( content: LottieComponent.AppBundleContent( name: "GiftUpgrade" ), size: CGSize(width: 30.0, height: 30.0), loop: true ) )) ], spacing: 5.0)) ), isEnabled: true, displaysProgress: state.inProgress, action: { [weak state] in state?.requestUpgradePreview() } ), availableSize: buttonSize, transition: context.transition ) } else if incoming && !converted && !savedToProfile { let buttonTitle = isChannelGift ? strings.Gift_View_Display_Channel : strings.Gift_View_Display buttonChild = button.update( component: ButtonComponent( background: buttonBackground, content: AnyComponentWithIdentity( id: AnyHashable("button"), component: AnyComponent(MultilineTextComponent(text: .plain(NSAttributedString(string: buttonTitle, font: Font.semibold(17.0), textColor: theme.list.itemCheckColors.foregroundColor, paragraphAlignment: .center)))) ), isEnabled: true, displaysProgress: state.inProgress, action: { [weak state] in state?.updateSavedToProfile(!savedToProfile) }), availableSize: buttonSize, transition: context.transition ) } else if !incoming, let resellStars, !isMyUniqueGift { if state.cachedStarImage == nil || state.cachedStarImage?.1 !== theme { state.cachedStarImage = (generateTintedImage(image: UIImage(bundleImageName: "Item List/PremiumIcon"), color: theme.list.itemCheckColors.foregroundColor)!, theme) } var upgradeString = strings.Gift_View_BuyFor upgradeString += " # \(presentationStringsFormattedNumber(Int32(resellStars), environment.dateTimeFormat.groupingSeparator))" let buttonTitle = subject.arguments?.upgradeStars != nil ? strings.Gift_Upgrade_Confirm : upgradeString let buttonAttributedString = NSMutableAttributedString(string: buttonTitle, 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: theme.list.itemCheckColors.foregroundColor, range: NSRange(range, in: buttonAttributedString.string)) buttonAttributedString.addAttribute(.baselineOffset, value: 1.5, range: NSRange(range, in: buttonAttributedString.string)) buttonAttributedString.addAttribute(.kern, value: 2.0, range: NSRange(range, in: buttonAttributedString.string)) } buttonChild = button.update( component: ButtonComponent( background: buttonBackground, content: AnyComponentWithIdentity( id: AnyHashable("buy"), component: AnyComponent(MultilineTextComponent(text: .plain(buttonAttributedString))) ), isEnabled: true, displaysProgress: state.inProgress, action: { [weak state] in state?.commitBuy() }), availableSize: buttonSize, transition: context.transition ) } else { buttonChild = button.update( component: ButtonComponent( background: buttonBackground, content: AnyComponentWithIdentity( id: AnyHashable("ok"), component: AnyComponent(MultilineTextComponent(text: .plain(NSAttributedString(string: strings.Common_OK, font: Font.semibold(17.0), textColor: theme.list.itemCheckColors.foregroundColor, paragraphAlignment: .center)))) ), isEnabled: true, displaysProgress: state.inProgress, action: { [weak state] in state?.dismiss(animated: true) }), availableSize: buttonSize, transition: context.transition ) } let buttonFrame = CGRect(origin: CGPoint(x: sideInset, y: originY), size: buttonChild.size) context.add(buttonChild .position(CGPoint(x: buttonFrame.midX, y: buttonFrame.midY)) .cornerRadius(10.0) ) originY += buttonChild.size.height originY += 7.0 context.add(buttons .position(CGPoint(x: context.availableSize.width - environment.safeInsets.left - 16.0 - buttons.size.width / 2.0, y: 28.0)) ) let effectiveBottomInset: CGFloat = environment.metrics.isTablet ? 0.0 : environment.safeInsets.bottom return CGSize(width: context.availableSize.width, height: originY + 5.0 + effectiveBottomInset) } } } final class GiftViewSheetComponent: CombinedComponent { typealias EnvironmentType = ViewControllerComponentContainer.Environment let context: AccountContext let subject: GiftViewScreen.Subject init( context: AccountContext, subject: GiftViewScreen.Subject ) { self.context = context self.subject = subject } static func ==(lhs: GiftViewSheetComponent, rhs: GiftViewSheetComponent) -> Bool { if lhs.context !== rhs.context { return false } if lhs.subject != rhs.subject { 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(GiftViewSheetContent( context: context.component.context, subject: context.component.subject, animateOut: animateOut, getController: controller )), backgroundColor: .color(environment.theme.actionSheet.opaqueItemBackgroundColor), followContentSizeChanges: true, clipsContent: true, externalState: sheetExternalState, animateOut: animateOut, onPan: { if let controller = controller() as? GiftViewScreen { controller.dismissAllTooltips() } }, willDismiss: { if let controller = controller() as? GiftViewScreen { controller.dismissBalanceOverlay() } } ), 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 { if let controller = controller() as? GiftViewScreen { controller.dismissAllTooltips() controller.dismissBalanceOverlay() animateOut.invoke(Action { _ in controller.dismiss(completion: nil) }) } } else { if let controller = controller() as? GiftViewScreen { controller.dismissAllTooltips() controller.dismissBalanceOverlay() 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)) ) if let controller = controller(), !controller.automaticallyControlPresentationContextLayout { let layout = ContainerViewLayout( size: context.availableSize, metrics: environment.metrics, deviceMetrics: environment.deviceMetrics, intrinsicInsets: UIEdgeInsets(top: 0.0, left: 0.0, bottom: max(environment.safeInsets.bottom, sheetExternalState.contentHeight), right: 0.0), safeInsets: UIEdgeInsets(top: 0.0, left: environment.safeInsets.left, bottom: 0.0, right: environment.safeInsets.right), additionalInsets: .zero, statusBarHeight: environment.statusBarHeight, inputHeight: nil, inputHeightIsInteractivellyChanging: false, inVoiceOver: false ) controller.presentationContext.containerLayoutUpdated(layout, transition: context.transition.containedViewLayoutTransition) } return context.availableSize } } } public class GiftViewScreen: ViewControllerComponentContainer { public enum Subject: Equatable { case message(EngineMessage) case uniqueGift(StarGift.UniqueGift, EnginePeer.Id?) case profileGift(EnginePeer.Id, ProfileGiftsContext.State.StarGift) case soldOutGift(StarGift.Gift) case upgradePreview([StarGift.UniqueGift.Attribute], String) case wearPreview(StarGift.UniqueGift) var arguments: (peerId: EnginePeer.Id?, fromPeerId: EnginePeer.Id?, fromPeerName: String?, messageId: EngineMessage.Id?, reference: StarGiftReference?, incoming: Bool, gift: StarGift, date: Int32, convertStars: Int64?, text: String?, entities: [MessageTextEntity]?, nameHidden: Bool, savedToProfile: Bool, pinnedToTop: Bool?, converted: Bool, upgraded: Bool, refunded: Bool, canUpgrade: Bool, upgradeStars: Int64?, transferStars: Int64?, resellStars: Int64?, canExportDate: Int32?, upgradeMessageId: Int32?, canTransferDate: Int32?, canResaleDate: Int32?)? { switch self { case let .message(message): if let action = message.media.first(where: { $0 is TelegramMediaAction }) as? TelegramMediaAction { switch action.action { case let .starGift(gift, convertStars, text, entities, nameHidden, savedToProfile, converted, upgraded, canUpgrade, upgradeStars, isRefunded, upgradeMessageId, peerId, senderId, savedId): var reference: StarGiftReference if let peerId, let savedId { reference = .peer(peerId: peerId, id: savedId) } else { reference = .message(messageId: message.id) } return (message.id.peerId, senderId ?? message.author?.id, message.author?.compactDisplayTitle, message.id, reference, message.flags.contains(.Incoming), gift, message.timestamp, convertStars, text, entities, nameHidden, savedToProfile, nil, converted, upgraded, isRefunded, canUpgrade, upgradeStars, nil, nil, nil, upgradeMessageId, nil, nil) case let .starGiftUnique(gift, isUpgrade, isTransferred, savedToProfile, canExportDate, transferStars, _, peerId, senderId, savedId, _, canTransferDate, canResaleDate): var reference: StarGiftReference if let peerId, let savedId { reference = .peer(peerId: peerId, id: savedId) } else { reference = .message(messageId: message.id) } var incoming = false if isUpgrade { if message.author?.id != message.id.peerId { incoming = true } } else if isTransferred { if message.author?.id != message.id.peerId { incoming = true } } else { incoming = message.flags.contains(.Incoming) } var resellStars: Int64? if case let .unique(uniqueGift) = gift { resellStars = uniqueGift.resellStars } return (message.id.peerId, senderId ?? message.author?.id, message.author?.compactDisplayTitle, message.id, reference, incoming, gift, message.timestamp, nil, nil, nil, false, savedToProfile, nil, false, false, false, false, nil, transferStars, resellStars, canExportDate, nil, canTransferDate, canResaleDate) default: return nil } } case let .uniqueGift(gift, _), let .wearPreview(gift): return (nil, nil, nil, nil, nil, false, .unique(gift), 0, nil, nil, nil, false, false, nil, false, false, false, false, nil, nil, gift.resellStars, nil, nil, nil, nil) case let .profileGift(peerId, gift): var messageId: EngineMessage.Id? if case let .message(messageIdValue) = gift.reference { messageId = messageIdValue } var resellStars: Int64? if case let .unique(uniqueGift) = gift.gift { resellStars = uniqueGift.resellStars } return (peerId, gift.fromPeer?.id, gift.fromPeer?.compactDisplayTitle, messageId, gift.reference, false, gift.gift, gift.date, gift.convertStars, gift.text, gift.entities, gift.nameHidden, gift.savedToProfile, gift.pinnedToTop, false, false, false, gift.canUpgrade, gift.upgradeStars, gift.transferStars, resellStars, gift.canExportDate, nil, gift.canTransferDate, gift.canResaleDate) case .soldOutGift: return nil case .upgradePreview: return nil } return nil } } private let context: AccountContext private let subject: GiftViewScreen.Subject fileprivate var showBalance = false { didSet { self.requestLayout(transition: .immediate) } } private let balanceOverlay = ComponentView() fileprivate let updateSavedToProfile: ((StarGiftReference, Bool) -> Void)? fileprivate let convertToStars: (() -> Void)? fileprivate let transferGift: ((Bool, EnginePeer.Id) -> Signal)? fileprivate let upgradeGift: ((Int64?, Bool) -> Signal)? fileprivate let buyGift: ((String, EnginePeer.Id, Int64?) -> Signal)? fileprivate let updateResellStars: ((Int64?) -> Signal)? fileprivate let togglePinnedToTop: ((Bool) -> Bool)? fileprivate let shareStory: ((StarGift.UniqueGift) -> Void)? public var disposed: () -> Void = {} public init( context: AccountContext, subject: GiftViewScreen.Subject, allSubjects: [GiftViewScreen.Subject]? = nil, index: Int? = nil, forceDark: Bool = false, updateSavedToProfile: ((StarGiftReference, Bool) -> Void)? = nil, convertToStars: (() -> Void)? = nil, transferGift: ((Bool, EnginePeer.Id) -> Signal)? = nil, upgradeGift: ((Int64?, Bool) -> Signal)? = nil, buyGift: ((String, EnginePeer.Id, Int64?) -> Signal)? = nil, updateResellStars: ((Int64?) -> Signal)? = nil, togglePinnedToTop: ((Bool) -> Bool)? = nil, shareStory: ((StarGift.UniqueGift) -> Void)? = nil ) { self.context = context self.subject = subject self.updateSavedToProfile = updateSavedToProfile self.convertToStars = convertToStars self.transferGift = transferGift self.upgradeGift = upgradeGift self.buyGift = buyGift self.updateResellStars = updateResellStars self.togglePinnedToTop = togglePinnedToTop self.shareStory = shareStory var items: [GiftPagerComponent.Item] = [GiftPagerComponent.Item(id: 0, subject: subject)] if let allSubjects, !allSubjects.isEmpty { items.removeAll() for i in 0 ..< allSubjects.count { items.append(GiftPagerComponent.Item(id: i, subject: allSubjects[i])) } } var dismissTooltipsImpl: (() -> Void)? super.init( context: context, component: GiftPagerComponent( context: context, items: items, index: index ?? 0, updated: { _, _ in dismissTooltipsImpl?() } ), navigationBarAppearance: .none, statusBarStyle: .ignore, theme: forceDark ? .dark : .default ) dismissTooltipsImpl = { [weak self] in self?.dismissAllTooltips() } self.navigationPresentation = .flatModal self.automaticallyControlPresentationContextLayout = false } required public init(coder aDecoder: NSCoder) { fatalError("init(coder:) has not been implemented") } deinit { self.disposed() } public override func viewDidLoad() { super.viewDidLoad() self.view.disablesInteractiveModalDismiss = true if let arguments = self.subject.arguments, let _ = self.subject.arguments?.resellStars { if case let .unique(uniqueGift) = arguments.gift, case .peerId(self.context.account.peerId) = uniqueGift.owner { } else { self.showBalance = true } } } public override func viewWillDisappear(_ animated: Bool) { super.viewWillDisappear(animated) self.dismissAllTooltips() } fileprivate func animateSuccess() { self.navigationController?.view.addSubview(ConfettiView(frame: self.view.bounds)) let presentationData = self.context.sharedContext.currentPresentationData.with { $0 } self.present(UndoOverlayController(presentationData: presentationData, content: .universal( animation: "GiftUpgraded", scale: 0.066, colors: [:], title: presentationData.strings.Gift_Upgrade_Succeed_Title, text: presentationData.strings.Gift_Upgrade_Succeed_Text, customUndoText: nil, timeout: 4.0 ), elevatedLayout: false, position: .bottom, action: { _ in return true }), in: .current) } public func dismissAnimated() { self.dismissAllTooltips() if let view = self.node.hostView.findTaggedView(tag: SheetComponent.View.Tag()) as? SheetComponent.View { view.dismissAnimated() } self.dismissBalanceOverlay() } fileprivate func dismissBalanceOverlay() { if let view = self.balanceOverlay.view, view.superview != nil { view.layer.animateScale(from: 1.0, to: 0.8, duration: 0.4, removeOnCompletion: false) view.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.3, removeOnCompletion: false) } } fileprivate func dismissAllTooltips() { self.window?.forEachController({ controller in if let controller = controller as? TooltipScreen { controller.dismiss(inPlace: false) } if let controller = controller as? UndoOverlayController { controller.dismiss() } }) self.forEachController({ controller in if let controller = controller as? TooltipScreen { controller.dismiss(inPlace: false) } if let controller = controller as? UndoOverlayController { controller.dismiss() } return true }) } public override func containerLayoutUpdated(_ layout: ContainerViewLayout, transition: ContainedViewLayoutTransition) { super.containerLayoutUpdated(layout, transition: transition) if self.showBalance { let context = self.context let insets = layout.insets(options: .statusBar) let balanceSize = self.balanceOverlay.update( transition: .immediate, component: AnyComponent( StarsBalanceOverlayComponent( context: context, theme: context.sharedContext.currentPresentationData.with { $0 }.theme, action: { [weak self] in guard let self, let starsContext = context.starsContext, let navigationController = self.navigationController as? NavigationController else { return } self.dismissAnimated() let _ = (context.engine.payments.starsTopUpOptions() |> take(1) |> deliverOnMainQueue).startStandalone(next: { options in let controller = context.sharedContext.makeStarsPurchaseScreen( context: context, starsContext: starsContext, options: options, purpose: .generic, completion: { _ in } ) navigationController.pushViewController(controller) }) } ) ), environment: {}, containerSize: layout.size ) if let view = self.balanceOverlay.view { if view.superview == nil { self.view.addSubview(view) view.layer.animatePosition(from: CGPoint(x: 0.0, y: -64.0), to: .zero, duration: 0.4, timingFunction: kCAMediaTimingFunctionSpring, additive: true) view.layer.animateSpring(from: 0.8 as NSNumber, to: 1.0 as NSNumber, keyPath: "transform.scale", duration: 0.5, initialVelocity: 0.0, removeOnCompletion: true, additive: false, completion: nil) view.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.25) } view.frame = CGRect(origin: CGPoint(x: floorToScreenPixels((layout.size.width - balanceSize.width) / 2.0), y: insets.top + 5.0), size: balanceSize) } } else if let view = self.balanceOverlay.view, view.superview != nil { view.alpha = 0.0 view.layer.animateScale(from: 1.0, to: 0.8, duration: 0.4) view.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.3, completion: { _ in view.removeFromSuperview() view.alpha = 1.0 }) } } } private func formatPercentage(_ value: Float) -> String { return String(format: "%0.1f%%", value).replacingOccurrences(of: ".0%", with: "%").replacingOccurrences(of: ",0%", with: "%") } private final class PeerCellComponent: Component { let context: AccountContext let theme: PresentationTheme let strings: PresentationStrings let peer: EnginePeer? init(context: AccountContext, theme: PresentationTheme, strings: PresentationStrings, peer: EnginePeer?) { self.context = context self.theme = theme self.strings = strings self.peer = peer } static func ==(lhs: PeerCellComponent, rhs: PeerCellComponent) -> Bool { if lhs.context !== rhs.context { return false } if lhs.theme !== rhs.theme { return false } if lhs.strings !== rhs.strings { return false } if lhs.peer != rhs.peer { return false } return true } final class View: UIView { private let avatarNode: AvatarNode private let text = ComponentView() private var component: PeerCellComponent? private weak var state: EmptyComponentState? override init(frame: CGRect) { self.avatarNode = AvatarNode(font: avatarPlaceholderFont(size: 8.0)) super.init(frame: frame) self.addSubnode(self.avatarNode) } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } func update(component: PeerCellComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: ComponentTransition) -> CGSize { self.component = component self.state = state let avatarSize = CGSize(width: 22.0, height: 22.0) let spacing: CGFloat = 6.0 let peerName: String let avatarOverride: AvatarNodeImageOverride? if let peerValue = component.peer { peerName = peerValue.compactDisplayTitle avatarOverride = nil } else { peerName = component.strings.Gift_View_HiddenName avatarOverride = .anonymousSavedMessagesIcon(isColored: true) } let avatarNaturalSize = CGSize(width: 40.0, height: 40.0) self.avatarNode.setPeer(context: component.context, theme: component.theme, peer: component.peer, overrideImage: avatarOverride) self.avatarNode.bounds = CGRect(origin: .zero, size: avatarNaturalSize) let textSize = self.text.update( transition: .immediate, component: AnyComponent( MultilineTextComponent( text: .plain(NSAttributedString(string: peerName, font: Font.regular(15.0), textColor: component.peer != nil ? component.theme.list.itemAccentColor : component.theme.list.itemPrimaryTextColor, paragraphAlignment: .left)) ) ), environment: {}, containerSize: CGSize(width: availableSize.width - avatarSize.width - spacing, height: availableSize.height) ) let size = CGSize(width: avatarSize.width + textSize.width + spacing, height: textSize.height) let avatarFrame = CGRect(origin: CGPoint(x: 0.0, y: floorToScreenPixels((size.height - avatarSize.height) / 2.0)), size: avatarSize) self.avatarNode.frame = avatarFrame if let view = self.text.view { if view.superview == nil { self.addSubview(view) } let textFrame = CGRect(origin: CGPoint(x: avatarSize.width + spacing, y: floorToScreenPixels((size.height - textSize.height) / 2.0)), size: textSize) transition.setFrame(view: view, frame: textFrame) } return size } } func makeView() -> View { return View(frame: CGRect()) } func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: ComponentTransition) -> CGSize { return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition) } } private final class ButtonContentComponent: Component { let context: AccountContext let text: String let color: UIColor public init( context: AccountContext, text: String, color: UIColor ) { self.context = context self.text = text self.color = color } public static func ==(lhs: ButtonContentComponent, rhs: ButtonContentComponent) -> Bool { if lhs.context !== rhs.context { return false } if lhs.text != rhs.text { return false } if lhs.color != rhs.color { return false } return true } public final class View: UIView { private var component: ButtonContentComponent? private weak var componentState: EmptyComponentState? private let backgroundLayer = SimpleLayer() private let title = ComponentView() override init(frame: CGRect) { super.init(frame: frame) self.layer.addSublayer(self.backgroundLayer) self.backgroundLayer.masksToBounds = true } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } func update(component: ButtonContentComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: ComponentTransition) -> CGSize { self.component = component self.componentState = state let attributedText = NSAttributedString(string: component.text, font: Font.regular(11.0), textColor: component.color) let titleSize = self.title.update( transition: transition, component: AnyComponent( MultilineTextWithEntitiesComponent( context: component.context, animationCache: component.context.animationCache, animationRenderer: component.context.animationRenderer, placeholderColor: .white, text: .plain(attributedText) ) ), environment: {}, containerSize: availableSize ) let padding: CGFloat = 6.0 let size = CGSize(width: titleSize.width + padding * 2.0, height: 18.0) let titleFrame = CGRect(origin: CGPoint(x: floorToScreenPixels((size.width - titleSize.width) / 2.0), y: floorToScreenPixels((size.height - titleSize.height) / 2.0)), size: titleSize) if let titleView = self.title.view { if titleView.superview == nil { self.addSubview(titleView) } transition.setFrame(view: titleView, frame: titleFrame) } let backgroundColor = component.color.withAlphaComponent(0.1) self.backgroundLayer.backgroundColor = backgroundColor.cgColor transition.setFrame(layer: self.backgroundLayer, frame: CGRect(origin: .zero, size: size)) self.backgroundLayer.cornerRadius = size.height / 2.0 return size } } public func makeView() -> View { return View(frame: CGRect()) } public func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: ComponentTransition) -> CGSize { return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition) } } private struct GiftConfiguration { static var defaultValue: GiftConfiguration { return GiftConfiguration(convertToStarsPeriod: 90 * 86400) } let convertToStarsPeriod: Int32 fileprivate init(convertToStarsPeriod: Int32) { self.convertToStarsPeriod = convertToStarsPeriod } static func with(appConfiguration: AppConfiguration) -> GiftConfiguration { if let data = appConfiguration.data { var convertToStarsPeriod: Int32? if let value = data["stargifts_convert_period_max"] as? Double { convertToStarsPeriod = Int32(value) } return GiftConfiguration(convertToStarsPeriod: convertToStarsPeriod ?? GiftConfiguration.defaultValue.convertToStarsPeriod) } else { return .defaultValue } } } private final class ParagraphComponent: CombinedComponent { let title: String let titleColor: UIColor let text: String let textColor: UIColor let accentColor: UIColor let iconName: String let iconColor: UIColor let badge: String? let action: () -> Void public init( title: String, titleColor: UIColor, text: String, textColor: UIColor, accentColor: UIColor, iconName: String, iconColor: UIColor, badge: String? = nil, action: @escaping () -> Void = {} ) { self.title = title self.titleColor = titleColor self.text = text self.textColor = textColor self.accentColor = accentColor self.iconName = iconName self.iconColor = iconColor self.badge = badge self.action = action } static func ==(lhs: ParagraphComponent, rhs: ParagraphComponent) -> Bool { if lhs.title != rhs.title { return false } if lhs.titleColor != rhs.titleColor { return false } if lhs.text != rhs.text { return false } if lhs.textColor != rhs.textColor { return false } if lhs.accentColor != rhs.accentColor { return false } if lhs.iconName != rhs.iconName { return false } if lhs.iconColor != rhs.iconColor { return false } if lhs.badge != rhs.badge { return false } return true } static var body: Body { let title = Child(MultilineTextComponent.self) let text = Child(MultilineTextComponent.self) let icon = Child(BundleIconComponent.self) let badgeBackground = Child(RoundedRectangle.self) let badgeText = Child(MultilineTextComponent.self) return { context in let component = context.component let leftInset: CGFloat = 32.0 let rightInset: CGFloat = 24.0 let textSideInset: CGFloat = leftInset + 8.0 let spacing: CGFloat = 5.0 let textTopInset: CGFloat = 9.0 let title = title.update( component: MultilineTextComponent( text: .plain(NSAttributedString( string: component.title, font: Font.semibold(15.0), textColor: component.titleColor, paragraphAlignment: .natural )), horizontalAlignment: .center, maximumNumberOfLines: 1 ), availableSize: CGSize(width: context.availableSize.width - leftInset - rightInset, height: CGFloat.greatestFiniteMagnitude), transition: .immediate ) let textFont = Font.regular(15.0) let boldTextFont = Font.semibold(15.0) let textColor = component.textColor let accentColor = component.accentColor 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 text = text.update( component: MultilineTextComponent( text: .markdown(text: component.text, attributes: markdownAttributes), horizontalAlignment: .natural, maximumNumberOfLines: 0, lineSpacing: 0.2, highlightAction: { attributes in if let _ = attributes[NSAttributedString.Key(rawValue: TelegramTextAttributes.URL)] { return NSAttributedString.Key(rawValue: TelegramTextAttributes.URL) } else { return nil } }, tapAction: { _, _ in component.action() } ), availableSize: CGSize(width: context.availableSize.width - leftInset - rightInset, height: context.availableSize.height), transition: .immediate ) let icon = icon.update( component: BundleIconComponent( name: component.iconName, tintColor: component.iconColor ), availableSize: CGSize(width: context.availableSize.width, height: context.availableSize.height), transition: .immediate ) context.add(title .position(CGPoint(x: textSideInset + title.size.width / 2.0, y: textTopInset + title.size.height / 2.0)) ) if let badge = component.badge { let badgeText = badgeText.update( component: MultilineTextComponent(text: .plain(NSAttributedString(string: badge, font: Font.semibold(11.0), textColor: .white))), availableSize: context.availableSize, transition: context.transition ) let badgeWidth = badgeText.size.width + 7.0 let badgeBackground = badgeBackground.update( component: RoundedRectangle( color: component.accentColor, cornerRadius: 5.0), availableSize: CGSize(width: badgeWidth, height: 16.0), transition: context.transition ) context.add(badgeBackground .position(CGPoint(x: textSideInset + title.size.width + badgeWidth / 2.0 + 5.0, y: textTopInset + title.size.height / 2.0)) ) context.add(badgeText .position(CGPoint(x: textSideInset + title.size.width + badgeWidth / 2.0 + 5.0, y: textTopInset + title.size.height / 2.0)) ) } context.add(text .position(CGPoint(x: textSideInset + text.size.width / 2.0, y: textTopInset + title.size.height + spacing + text.size.height / 2.0)) ) context.add(icon .position(CGPoint(x: 15.0, y: textTopInset + 18.0)) ) return CGSize(width: context.availableSize.width, height: textTopInset + title.size.height + text.size.height + 20.0) } } } private final class GiftViewContextReferenceContentSource: ContextReferenceContentSource { private let controller: ViewController private let sourceNode: ASDisplayNode init(controller: ViewController, sourceNode: ASDisplayNode) { self.controller = controller self.sourceNode = sourceNode } func transitionInfo() -> ContextControllerReferenceViewInfo? { return ContextControllerReferenceViewInfo(referenceView: self.sourceNode.view, contentAreaInScreenSpace: UIScreen.main.bounds) } } private final class HeaderButtonComponent: CombinedComponent { let title: String let iconName: String let isLocked: Bool public init( title: String, iconName: String, isLocked: Bool = false ) { self.title = title self.iconName = iconName self.isLocked = isLocked } static func ==(lhs: HeaderButtonComponent, rhs: HeaderButtonComponent) -> Bool { if lhs.title != rhs.title { return false } if lhs.iconName != rhs.iconName { return false } if lhs.isLocked != rhs.isLocked { return false } return true } static var body: Body { let background = Child(RoundedRectangle.self) let title = Child(MultilineTextComponent.self) let icon = Child(BundleIconComponent.self) let lockIcon = Child(BundleIconComponent.self) return { context in let component = context.component let background = background.update( component: RoundedRectangle( color: UIColor.white.withAlphaComponent(0.16), cornerRadius: 10.0 ), availableSize: context.availableSize, transition: .immediate ) context.add(background .position(CGPoint(x: context.availableSize.width / 2.0, y: context.availableSize.height / 2.0)) ) let icon = icon.update( component: BundleIconComponent( name: component.iconName, tintColor: UIColor.white ), availableSize: context.availableSize, transition: .immediate ) context.add(icon .position(CGPoint(x: context.availableSize.width / 2.0, y: 22.0)) ) let title = title.update( component: MultilineTextComponent( text: .plain(NSAttributedString( string: component.title, font: Font.regular(11.0), textColor: UIColor.white, paragraphAlignment: .natural )), horizontalAlignment: .center, maximumNumberOfLines: 1 ), availableSize: CGSize(width: context.availableSize.width - 16.0, height: context.availableSize.height), transition: .immediate ) var totalTitleWidth = title.size.width var titleOriginX = context.availableSize.width / 2.0 - totalTitleWidth / 2.0 if component.isLocked { let titleSpacing: CGFloat = 2.0 let lockIcon = lockIcon.update( component: BundleIconComponent( name: "Chat List/StatusLockIcon", tintColor: UIColor.white ), availableSize: context.availableSize, transition: .immediate ) totalTitleWidth += lockIcon.size.width + titleSpacing titleOriginX = context.availableSize.width / 2.0 - totalTitleWidth / 2.0 context.add(lockIcon .position(CGPoint(x: titleOriginX + lockIcon.size.width / 2.0, y: 42.0)) ) titleOriginX += lockIcon.size.width + titleSpacing } context.add(title .position(CGPoint(x: titleOriginX + title.size.width / 2.0, y: 42.0)) ) return context.availableSize } } } private final class AvatarComponent: Component { let context: AccountContext let theme: PresentationTheme let peer: EnginePeer init(context: AccountContext, theme: PresentationTheme, peer: EnginePeer) { self.context = context self.theme = theme self.peer = peer } static func ==(lhs: AvatarComponent, rhs: AvatarComponent) -> Bool { if lhs.context !== rhs.context { return false } if lhs.theme !== rhs.theme { return false } if lhs.peer != rhs.peer { return false } return true } final class View: UIView { private let avatarNode: AvatarNode private var component: AvatarComponent? private weak var state: EmptyComponentState? override init(frame: CGRect) { self.avatarNode = AvatarNode(font: avatarPlaceholderFont(size: 42.0)) super.init(frame: frame) self.addSubnode(self.avatarNode) } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } func update(component: AvatarComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: ComponentTransition) -> CGSize { self.component = component self.state = state self.avatarNode.frame = CGRect(origin: .zero, size: availableSize) self.avatarNode.setPeer( context: component.context, theme: component.theme, peer: component.peer, synchronousLoad: true ) return availableSize } } func makeView() -> View { return View(frame: CGRect()) } func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: ComponentTransition) -> CGSize { return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition) } } private struct GiftViewConfiguration { public static var defaultValue: GiftViewConfiguration { return GiftViewConfiguration(explorerUrl: "https://tonviewer.com") } public let explorerUrl: String fileprivate init(explorerUrl: String) { self.explorerUrl = explorerUrl } public static func with(appConfiguration: AppConfiguration) -> GiftViewConfiguration { if let data = appConfiguration.data, let value = data["ton_blockchain_explorer_url"] as? String { return GiftViewConfiguration(explorerUrl: value) } else { return .defaultValue } } }