import Foundation import UIKit import Display import AsyncDisplayKit import TelegramCore import Postbox import SwiftSignalKit import AccountContext import TelegramPresentationData import TelegramUIPreferences import PresentationDataUtils import ComponentFlow import ViewControllerComponent import MultilineTextComponent import BundleIconComponent import Markdown import SolidRoundedButtonNode import BlurredBackgroundComponent public class PremiumLimitsListScreen: ViewController { final class Node: ViewControllerTracingNode, ASScrollViewDelegate, ASGestureRecognizerDelegate { private var presentationData: PresentationData private weak var controller: PremiumLimitsListScreen? let dim: ASDisplayNode let wrappingView: UIView let containerView: UIView let backgroundView: ComponentHostView let pagerView: ComponentHostView let closeView: ComponentHostView let closeDarkIconView = UIImageView() fileprivate let footerNode: FooterNode private(set) var isExpanded = false private var panGestureRecognizer: UIPanGestureRecognizer? private var panGestureArguments: (topInset: CGFloat, offset: CGFloat, scrollView: UIScrollView?, listNode: ListView?)? private var currentIsVisible: Bool = false private var currentLayout: ContainerViewLayout? var isPremium: Bool? var reactions: [AvailableReactions.Reaction]? var stickers: [TelegramMediaFile]? var appIcons: [PresentationAppIcon]? var disposable: Disposable? var promoConfiguration: PremiumPromoConfiguration? let nextAction = ActionSlot() init(context: AccountContext, controller: PremiumLimitsListScreen, buttonTitle: String, gloss: Bool, forceDark: Bool) { self.presentationData = context.sharedContext.currentPresentationData.with { $0 } if forceDark { self.presentationData = self.presentationData.withUpdated(theme: defaultDarkPresentationTheme) } self.presentationData = self.presentationData.withUpdated(theme: self.presentationData.theme.withModalBlocksBackground()) self.controller = controller self.dim = ASDisplayNode() self.dim.alpha = 0.0 self.dim.backgroundColor = UIColor(white: 0.0, alpha: 0.25) self.wrappingView = UIView() self.containerView = UIView() self.backgroundView = ComponentHostView() self.pagerView = ComponentHostView() self.closeView = ComponentHostView() self.footerNode = FooterNode(theme: self.presentationData.theme, title: buttonTitle, gloss: gloss, order: controller.order) super.init() self.containerView.clipsToBounds = true self.containerView.backgroundColor = self.presentationData.theme.overallDarkAppearance ? self.presentationData.theme.list.blocksBackgroundColor : self.presentationData.theme.list.plainBackgroundColor self.addSubnode(self.dim) self.view.addSubview(self.wrappingView) self.wrappingView.addSubview(self.containerView) self.containerView.addSubview(self.backgroundView) self.containerView.addSubview(self.pagerView) self.containerView.addSubnode(self.footerNode) self.containerView.addSubview(self.closeView) self.footerNode.action = { [weak self] in self?.controller?.action() } let context = controller.context let accountSpecificStickerOverrides: [ExperimentalUISettings.AccountReactionOverrides.Item] if context.sharedContext.immediateExperimentalUISettings.enableReactionOverrides, let value = context.sharedContext.immediateExperimentalUISettings.accountStickerEffectOverrides.first(where: { $0.accountId == context.account.id.int64 }) { accountSpecificStickerOverrides = value.items } else { accountSpecificStickerOverrides = [] } let stickerOverrideMessages = context.engine.data.get( EngineDataMap(accountSpecificStickerOverrides.map(\.messageId).map(TelegramEngine.EngineData.Item.Messages.Message.init)) ) self.appIcons = controller.context.sharedContext.applicationBindings.getAvailableAlternateIcons() let stickersKey: PostboxViewKey = .orderedItemList(id: Namespaces.OrderedItemList.CloudPremiumStickers) self.disposable = (combineLatest( queue: Queue.mainQueue(), context.account.postbox.combinedView(keys: [stickersKey]) |> map { views -> [OrderedItemListEntry]? in if let view = views.views[stickersKey] as? OrderedItemListView { return view.items } else { return nil } } |> filter { items in return items != nil } |> take(1), context.engine.data.get( TelegramEngine.EngineData.Item.Peer.Peer(id: context.account.peerId), TelegramEngine.EngineData.Item.Configuration.PremiumPromo() ), stickerOverrideMessages ) |> map { items, data, stickerOverrideMessages -> ([TelegramMediaFile], Bool?, PremiumPromoConfiguration?) in var stickerOverrides: [MessageReaction.Reaction: TelegramMediaFile] = [:] for item in accountSpecificStickerOverrides { if let maybeMessage = stickerOverrideMessages[item.messageId], let message = maybeMessage { for media in message.media { if let file = media as? TelegramMediaFile, file.fileId == item.mediaId { stickerOverrides[item.key] = file } } } } var result: [TelegramMediaFile] = [] if let items = items { for item in items { if let mediaItem = item.contents.get(RecentMediaItem.self) { result.append(mediaItem.media) } } } return (result.map { file -> TelegramMediaFile in for attribute in file.attributes { switch attribute { case let .Sticker(displayText, _, _): if let replacementFile = stickerOverrides[.builtin(displayText)], let dimensions = replacementFile.dimensions { let _ = dimensions return TelegramMediaFile( fileId: file.fileId, partialReference: file.partialReference, resource: file.resource, previewRepresentations: file.previewRepresentations, videoThumbnails: [TelegramMediaFile.VideoThumbnail(dimensions: dimensions, resource: replacementFile.resource)], immediateThumbnailData: file.immediateThumbnailData, mimeType: file.mimeType, size: file.size, attributes: file.attributes ) } default: break } } return file }, data.0?.isPremium ?? false, data.1) }).start(next: { [weak self] stickers, isPremium, promoConfiguration in guard let strongSelf = self else { return } strongSelf.stickers = stickers strongSelf.isPremium = isPremium strongSelf.promoConfiguration = promoConfiguration if !stickers.isEmpty { strongSelf.updated(transition: Transition(.immediate).withUserData(DemoAnimateInTransition())) } }) } deinit { self.disposable?.dispose() } override func didLoad() { super.didLoad() let panRecognizer = UIPanGestureRecognizer(target: self, action: #selector(self.panGesture(_:))) panRecognizer.delegate = self.wrappedGestureRecognizerDelegate panRecognizer.delaysTouchesBegan = false panRecognizer.cancelsTouchesInView = true self.panGestureRecognizer = panRecognizer self.wrappingView.addGestureRecognizer(panRecognizer) self.dim.view.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(self.dimTapGesture(_:)))) self.pagerView.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(self.pagerTapGesture(_:)))) self.controller?.navigationBar?.updateBackgroundAlpha(0.0, transition: .immediate) } @objc func dimTapGesture(_ recognizer: UITapGestureRecognizer) { if case .ended = recognizer.state { self.controller?.dismiss(animated: true) } } @objc func pagerTapGesture(_ recognizer: UITapGestureRecognizer) { if case .ended = recognizer.state { self.nextAction.invoke(Void()) } } override func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool { if let layout = self.currentLayout { if case .regular = layout.metrics.widthClass { return false } } return true } func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool { if gestureRecognizer is UIPanGestureRecognizer && otherGestureRecognizer is UIPanGestureRecognizer { if let scrollView = otherGestureRecognizer.view as? UIScrollView { if scrollView.contentSize.width > scrollView.contentSize.height || scrollView.contentSize.height > 1500.0 { return false } } else if otherGestureRecognizer.view is PremiumCoinComponent.View { return false } return true } return false } private var isDismissing = false func animateIn() { ContainedViewLayoutTransition.animated(duration: 0.3, curve: .linear).updateAlpha(node: self.dim, alpha: 1.0) let targetPosition = self.containerView.center let startPosition = targetPosition.offsetBy(dx: 0.0, dy: self.bounds.height) self.containerView.center = startPosition let transition = ContainedViewLayoutTransition.animated(duration: 0.4, curve: .spring) transition.animateView(allowUserInteraction: true, { self.containerView.center = targetPosition }, completion: { _ in }) } func animateOut(completion: @escaping () -> Void = {}) { self.isDismissing = true let positionTransition: ContainedViewLayoutTransition = .animated(duration: 0.25, curve: .easeInOut) positionTransition.updatePosition(layer: self.containerView.layer, position: CGPoint(x: self.containerView.center.x, y: self.bounds.height + self.containerView.bounds.height / 2.0), completion: { [weak self] _ in self?.controller?.dismiss(animated: false, completion: completion) }) let alphaTransition: ContainedViewLayoutTransition = .animated(duration: 0.25, curve: .easeInOut) alphaTransition.updateAlpha(node: self.dim, alpha: 0.0) self.controller?.updateModalStyleOverlayTransitionFactor(0.0, transition: positionTransition) } private var dismissOffset: CGFloat? func containerLayoutUpdated(layout: ContainerViewLayout, transition: Transition) { self.currentLayout = layout self.dim.frame = CGRect(origin: CGPoint(x: 0.0, y: -layout.size.height), size: CGSize(width: layout.size.width, height: layout.size.height * 3.0)) var effectiveExpanded = self.isExpanded if case .regular = layout.metrics.widthClass { effectiveExpanded = true } let isLandscape = layout.orientation == .landscape let edgeTopInset = isLandscape ? 0.0 : self.defaultTopInset let topInset: CGFloat if let (panInitialTopInset, panOffset, _, _) = self.panGestureArguments { if effectiveExpanded { topInset = min(edgeTopInset, panInitialTopInset + max(0.0, panOffset)) } else { topInset = max(0.0, panInitialTopInset + min(0.0, panOffset)) } } else if let dismissOffset = self.dismissOffset, !dismissOffset.isZero { topInset = edgeTopInset * dismissOffset } else { topInset = effectiveExpanded ? 0.0 : edgeTopInset } transition.setFrame(view: self.wrappingView, frame: CGRect(origin: CGPoint(x: 0.0, y: topInset), size: layout.size), completion: nil) let modalProgress = isLandscape ? 0.0 : (1.0 - topInset / self.defaultTopInset) self.controller?.updateModalStyleOverlayTransitionFactor(modalProgress, transition: transition.containedViewLayoutTransition) let clipFrame: CGRect if layout.metrics.widthClass == .compact { self.dim.backgroundColor = UIColor(rgb: 0x000000, alpha: 0.25) if isLandscape { self.containerView.layer.cornerRadius = 0.0 } else { self.containerView.layer.cornerRadius = 10.0 } if #available(iOS 11.0, *) { if layout.safeInsets.bottom.isZero { self.containerView.layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner] } else { self.containerView.layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner, .layerMinXMaxYCorner, .layerMaxXMaxYCorner] } } if isLandscape { clipFrame = CGRect(origin: CGPoint(), size: layout.size) } else { let coveredByModalTransition: CGFloat = 0.0 var containerTopInset: CGFloat = 10.0 if let statusBarHeight = layout.statusBarHeight { containerTopInset += statusBarHeight } let unscaledFrame = CGRect(origin: CGPoint(x: 0.0, y: containerTopInset - coveredByModalTransition * 10.0), size: CGSize(width: layout.size.width, height: layout.size.height - containerTopInset)) let maxScale: CGFloat = (layout.size.width - 16.0 * 2.0) / layout.size.width let containerScale = 1.0 * (1.0 - coveredByModalTransition) + maxScale * coveredByModalTransition let maxScaledTopInset: CGFloat = containerTopInset - 10.0 let scaledTopInset: CGFloat = containerTopInset * (1.0 - coveredByModalTransition) + maxScaledTopInset * coveredByModalTransition let containerFrame = unscaledFrame.offsetBy(dx: 0.0, dy: scaledTopInset - (unscaledFrame.midY - containerScale * unscaledFrame.height / 2.0)) clipFrame = CGRect(x: containerFrame.minX, y: containerFrame.minY, width: containerFrame.width, height: containerFrame.height) } } else { self.dim.backgroundColor = UIColor(rgb: 0x000000, alpha: 0.4) self.containerView.layer.cornerRadius = 10.0 let verticalInset: CGFloat = 44.0 let maxSide = max(layout.size.width, layout.size.height) let minSide = min(layout.size.width, layout.size.height) let containerSize = CGSize(width: min(layout.size.width - 20.0, floor(maxSide / 2.0)), height: min(layout.size.height, minSide) - verticalInset * 2.0) clipFrame = CGRect(origin: CGPoint(x: floor((layout.size.width - containerSize.width) / 2.0), y: floor((layout.size.height - containerSize.height) / 2.0)), size: containerSize) } transition.setFrame(view: self.containerView, frame: clipFrame) var clipLayout = layout.withUpdatedSize(clipFrame.size) if case .regular = layout.metrics.widthClass { clipLayout = clipLayout.withUpdatedIntrinsicInsets(.zero) } let footerHeight = self.footerNode.updateLayout(layout: clipLayout, transition: .immediate) let convertedFooterFrame = self.view.convert(CGRect(origin: CGPoint(x: clipFrame.minX, y: clipFrame.maxY - footerHeight), size: CGSize(width: clipFrame.width, height: footerHeight)), to: self.containerView) transition.setFrame(view: self.footerNode.view, frame: convertedFooterFrame) self.updated(transition: transition) } private var indexPosition: CGFloat? func updated(transition: Transition) { guard let controller = self.controller else { return } let contentSize = self.containerView.bounds.size let backgroundSize = self.backgroundView.update( transition: .immediate, component: AnyComponent( PremiumGradientBackgroundComponent(colors: [ UIColor(rgb: 0x0077ff), UIColor(rgb: 0x6b93ff), UIColor(rgb: 0x8878ff), UIColor(rgb: 0xe46ace) ]) ), environment: {}, containerSize: CGSize(width: contentSize.width, height: contentSize.width) ) self.backgroundView.frame = CGRect(origin: CGPoint(x: floorToScreenPixels((contentSize.width - backgroundSize.width) / 2.0), y: 0.0), size: backgroundSize) var isStandalone = false if case .other = controller.source { isStandalone = true } let theme = self.presentationData.theme let strings = self.presentationData.strings let videos: [String: TelegramMediaFile] = self.promoConfiguration?.videos ?? [:] let stickers = self.stickers ?? [] let appIcons = self.appIcons ?? [] let isReady: Bool switch controller.subject { case .premiumStickers: isReady = !stickers.isEmpty case .appIcons: isReady = !appIcons.isEmpty case .stories: isReady = true case .doubleLimits: isReady = true case .business: isReady = true default: isReady = !videos.isEmpty } if isReady { let context = controller.context let textColor = theme.actionSheet.primaryTextColor var availableItems: [PremiumPerk: DemoPagerComponent.Item] = [:] var storiesIndex: Int? var limitsIndex: Int? var businessIndex: Int? var storiesNeighbors = PageNeighbors(leftIsList: false, rightIsList: false) var limitsNeighbors = PageNeighbors(leftIsList: false, rightIsList: false) let businessNeighbors = PageNeighbors(leftIsList: false, rightIsList: false) if let order = controller.order { storiesIndex = order.firstIndex(where: { $0 == .stories }) limitsIndex = order.firstIndex(where: { $0 == .doubleLimits }) businessIndex = order.firstIndex(where: { $0 == .business }) if let limitsIndex, let storiesIndex { if limitsIndex == storiesIndex + 1 { storiesNeighbors.rightIsList = true limitsNeighbors.leftIsList = true } else if limitsIndex == storiesIndex - 1 { limitsNeighbors.rightIsList = true storiesNeighbors.leftIsList = true } } } availableItems[.doubleLimits] = DemoPagerComponent.Item( AnyComponentWithIdentity( id: PremiumDemoScreen.Subject.doubleLimits, component: AnyComponent( LimitsPageComponent( context: context, theme: self.presentationData.theme, neighbors: limitsNeighbors, bottomInset: self.footerNode.frame.height, updatedBottomAlpha: { [weak self] alpha in if let strongSelf = self { strongSelf.footerNode.updateCoverAlpha(alpha, transition: .immediate) } }, updatedDismissOffset: { [weak self] offset in if let strongSelf = self { strongSelf.updateDismissOffset(offset) } }, updatedIsDisplaying: { [weak self] isDisplaying in if let self, self.isExpanded && !isDisplaying { if let storiesIndex, let indexPosition = self.indexPosition, abs(CGFloat(storiesIndex) - indexPosition) < 0.1 { } else { self.update(isExpanded: false, transition: .animated(duration: 0.2, curve: .easeInOut)) } } } ) ) ) ) availableItems[.stories] = DemoPagerComponent.Item( AnyComponentWithIdentity( id: PremiumDemoScreen.Subject.stories, component: AnyComponent( StoriesPageComponent( context: context, theme: self.presentationData.theme, neighbors: storiesNeighbors, bottomInset: self.footerNode.frame.height, updatedBottomAlpha: { [weak self] alpha in if let strongSelf = self { strongSelf.footerNode.updateCoverAlpha(alpha, transition: .immediate) } }, updatedDismissOffset: { [weak self] offset in if let strongSelf = self { strongSelf.updateDismissOffset(offset) } }, updatedIsDisplaying: { [weak self] isDisplaying in if let self, self.isExpanded && !isDisplaying { if let limitsIndex, let indexPosition = self.indexPosition, abs(CGFloat(limitsIndex) - indexPosition) < 0.1 { } else { self.update(isExpanded: false, transition: .animated(duration: 0.2, curve: .easeInOut)) } } } ) ) ) ) availableItems[.moreUpload] = DemoPagerComponent.Item( AnyComponentWithIdentity( id: PremiumDemoScreen.Subject.moreUpload, component: AnyComponent( PageComponent( content: AnyComponent(PhoneDemoComponent( context: context, position: .bottom, videoFile: videos["more_upload"], decoration: .dataRain )), title: strings.Premium_UploadSize, text: strings.Premium_UploadSizeInfo, textColor: textColor ) ) ) ) availableItems[.fasterDownload] = DemoPagerComponent.Item( AnyComponentWithIdentity( id: PremiumDemoScreen.Subject.fasterDownload, component: AnyComponent( PageComponent( content: AnyComponent(PhoneDemoComponent( context: context, position: .top, videoFile: videos["faster_download"], decoration: .fasterStars )), title: strings.Premium_FasterSpeed, text: strings.Premium_FasterSpeedInfo, textColor: textColor ) ) ) ) availableItems[.voiceToText] = DemoPagerComponent.Item( AnyComponentWithIdentity( id: PremiumDemoScreen.Subject.voiceToText, component: AnyComponent( PageComponent( content: AnyComponent(PhoneDemoComponent( context: context, position: .top, videoFile: videos["voice_to_text"], decoration: .badgeStars )), title: strings.Premium_VoiceToText, text: strings.Premium_VoiceToTextInfo, textColor: textColor ) ) ) ) availableItems[.noAds] = DemoPagerComponent.Item( AnyComponentWithIdentity( id: PremiumDemoScreen.Subject.noAds, component: AnyComponent( PageComponent( content: AnyComponent(PhoneDemoComponent( context: context, position: .bottom, videoFile: videos["no_ads"], decoration: .swirlStars )), title: strings.Premium_NoAds, text: isStandalone ? strings.Premium_NoAdsStandaloneInfo : strings.Premium_NoAdsInfo, textColor: textColor ) ) ) ) availableItems[.uniqueReactions] = DemoPagerComponent.Item( AnyComponentWithIdentity( id: PremiumDemoScreen.Subject.uniqueReactions, component: AnyComponent( PageComponent( content: AnyComponent(PhoneDemoComponent( context: context, position: .top, videoFile: videos["infinite_reactions"], decoration: .swirlStars )), title: strings.Premium_InfiniteReactions, text: strings.Premium_InfiniteReactionsInfo, textColor: textColor ) ) ) ) availableItems[.premiumStickers] = DemoPagerComponent.Item( AnyComponentWithIdentity( id: PremiumDemoScreen.Subject.premiumStickers, component: AnyComponent( PageComponent( content: AnyComponent( StickersCarouselComponent( context: context, stickers: stickers, tapAction: { [weak self] in self?.nextAction.invoke(Void()) } ) ), title: strings.Premium_Stickers, text: strings.Premium_StickersInfo, textColor: textColor ) ) ) ) availableItems[.emojiStatus] = DemoPagerComponent.Item( AnyComponentWithIdentity( id: PremiumDemoScreen.Subject.emojiStatus, component: AnyComponent( PageComponent( content: AnyComponent(PhoneDemoComponent( context: context, position: .top, videoFile: videos["emoji_status"], decoration: .badgeStars )), title: strings.Premium_EmojiStatus, text: strings.Premium_EmojiStatusInfo, textColor: textColor ) ) ) ) availableItems[.advancedChatManagement] = DemoPagerComponent.Item( AnyComponentWithIdentity( id: PremiumDemoScreen.Subject.advancedChatManagement, component: AnyComponent( PageComponent( content: AnyComponent(PhoneDemoComponent( context: context, position: .top, videoFile: videos["advanced_chat_management"], decoration: .swirlStars )), title: strings.Premium_ChatManagement, text: strings.Premium_ChatManagementInfo, textColor: textColor ) ) ) ) availableItems[.profileBadge] = DemoPagerComponent.Item( AnyComponentWithIdentity( id: PremiumDemoScreen.Subject.profileBadge, component: AnyComponent( PageComponent( content: AnyComponent(PhoneDemoComponent( context: context, position: .top, videoFile: videos["profile_badge"], decoration: .badgeStars )), title: strings.Premium_Badge, text: strings.Premium_BadgeInfo, textColor: textColor ) ) ) ) availableItems[.animatedUserpics] = DemoPagerComponent.Item( AnyComponentWithIdentity( id: PremiumDemoScreen.Subject.animatedUserpics, component: AnyComponent( PageComponent( content: AnyComponent(PhoneDemoComponent( context: context, position: .top, videoFile: videos["animated_userpics"], decoration: .swirlStars )), title: strings.Premium_Avatar, text: strings.Premium_AvatarInfo, textColor: textColor ) ) ) ) availableItems[.appIcons] = DemoPagerComponent.Item( AnyComponentWithIdentity( id: PremiumDemoScreen.Subject.appIcons, component: AnyComponent( PageComponent( content: AnyComponent(AppIconsDemoComponent( context: context, appIcons: appIcons )), title: isStandalone ? strings.Premium_AppIconStandalone : strings.Premium_AppIcon, text: isStandalone ? strings.Premium_AppIconStandaloneInfo :strings.Premium_AppIconInfo, textColor: textColor ) ) ) ) availableItems[.animatedEmoji] = DemoPagerComponent.Item( AnyComponentWithIdentity( id: PremiumDemoScreen.Subject.animatedEmoji, component: AnyComponent( PageComponent( content: AnyComponent(PhoneDemoComponent( context: context, position: .bottom, videoFile: videos["animated_emoji"], decoration: .emoji )), title: strings.Premium_AnimatedEmoji, text: isStandalone ? strings.Premium_AnimatedEmojiStandaloneInfo : strings.Premium_AnimatedEmojiInfo, textColor: textColor ) ) ) ) availableItems[.translation] = DemoPagerComponent.Item( AnyComponentWithIdentity( id: PremiumDemoScreen.Subject.translation, component: AnyComponent( PageComponent( content: AnyComponent(PhoneDemoComponent( context: context, position: .top, model: .island, videoFile: videos["translations"], decoration: .hello )), title: strings.Premium_Translation, text: isStandalone ? strings.Premium_TranslationStandaloneInfo : strings.Premium_TranslationInfo, textColor: textColor ) ) ) ) availableItems[.colors] = DemoPagerComponent.Item( AnyComponentWithIdentity( id: PremiumDemoScreen.Subject.colors, component: AnyComponent( PageComponent( content: AnyComponent(PhoneDemoComponent( context: context, position: .top, videoFile: videos["peer_colors"], decoration: .badgeStars )), title: strings.Premium_Colors, text: strings.Premium_ColorsInfo, textColor: textColor ) ) ) ) availableItems[.wallpapers] = DemoPagerComponent.Item( AnyComponentWithIdentity( id: PremiumDemoScreen.Subject.wallpapers, component: AnyComponent( PageComponent( content: AnyComponent(PhoneDemoComponent( context: context, position: .top, model: .island, videoFile: videos["wallpapers"], decoration: .swirlStars )), title: strings.Premium_Wallpapers, text: strings.Premium_WallpapersInfo, textColor: textColor ) ) ) ) availableItems[.messageTags] = DemoPagerComponent.Item( AnyComponentWithIdentity( id: PremiumDemoScreen.Subject.messageTags, component: AnyComponent( PageComponent( content: AnyComponent(PhoneDemoComponent( context: context, position: .top, model: .island, videoFile: videos["saved_tags"], decoration: .tag )), title: strings.Premium_MessageTags, text: strings.Premium_MessageTagsInfo, textColor: textColor ) ) ) ) availableItems[.lastSeen] = DemoPagerComponent.Item( AnyComponentWithIdentity( id: PremiumDemoScreen.Subject.lastSeen, component: AnyComponent( PageComponent( content: AnyComponent(PhoneDemoComponent( context: context, position: .top, model: .island, videoFile: videos["last_seen"], decoration: .badgeStars )), title: strings.Premium_LastSeen, text: strings.Premium_LastSeenInfo, textColor: textColor ) ) ) ) availableItems[.messagePrivacy] = DemoPagerComponent.Item( AnyComponentWithIdentity( id: PremiumDemoScreen.Subject.messagePrivacy, component: AnyComponent( PageComponent( content: AnyComponent(PhoneDemoComponent( context: context, position: .top, model: .island, videoFile: videos["message_privacy"], decoration: .swirlStars )), title: strings.Premium_MessagePrivacy, text: strings.Premium_MessagePrivacyInfo, textColor: textColor ) ) ) ) availableItems[.business] = DemoPagerComponent.Item( AnyComponentWithIdentity( id: PremiumDemoScreen.Subject.business, component: AnyComponent( BusinessPageComponent( context: context, theme: self.presentationData.theme, neighbors: businessNeighbors, bottomInset: self.footerNode.frame.height, updatedBottomAlpha: { [weak self] alpha in if let strongSelf = self { strongSelf.footerNode.updateCoverAlpha(alpha, transition: .immediate) } }, updatedDismissOffset: { [weak self] offset in if let strongSelf = self { strongSelf.updateDismissOffset(offset) } }, updatedIsDisplaying: { [weak self] isDisplaying in if let self, self.isExpanded && !isDisplaying { if let businessIndex, let indexPosition = self.indexPosition, abs(CGFloat(businessIndex) - indexPosition) < 0.1 { } else { self.update(isExpanded: false, transition: .animated(duration: 0.2, curve: .easeInOut)) } } } ) ) ) ) availableItems[.businessLocation] = DemoPagerComponent.Item( AnyComponentWithIdentity( id: PremiumDemoScreen.Subject.businessLocation, component: AnyComponent( PageComponent( content: AnyComponent(PhoneDemoComponent( context: context, position: .top, model: .island, videoFile: videos["business_location"], decoration: .business )), title: strings.Business_Location, text: strings.Business_LocationInfo, textColor: textColor ) ) ) ) availableItems[.businessHours] = DemoPagerComponent.Item( AnyComponentWithIdentity( id: PremiumDemoScreen.Subject.businessHours, component: AnyComponent( PageComponent( content: AnyComponent(PhoneDemoComponent( context: context, position: .top, model: .island, videoFile: videos["business_hours"], decoration: .business )), title: strings.Business_OpeningHours, text: strings.Business_OpeningHoursInfo, textColor: textColor ) ) ) ) availableItems[.businessQuickReplies] = DemoPagerComponent.Item( AnyComponentWithIdentity( id: PremiumDemoScreen.Subject.businessQuickReplies, component: AnyComponent( PageComponent( content: AnyComponent(PhoneDemoComponent( context: context, position: .top, model: .island, videoFile: videos["quick_replies"], decoration: .business )), title: strings.Business_QuickReplies, text: strings.Business_QuickRepliesInfo, textColor: textColor ) ) ) ) availableItems[.businessGreetingMessage] = DemoPagerComponent.Item( AnyComponentWithIdentity( id: PremiumDemoScreen.Subject.businessGreetingMessage, component: AnyComponent( PageComponent( content: AnyComponent(PhoneDemoComponent( context: context, position: .top, model: .island, videoFile: videos["greeting_message"], decoration: .business )), title: strings.Business_GreetingMessages, text: strings.Business_GreetingMessagesInfo, textColor: textColor ) ) ) ) availableItems[.businessAwayMessage] = DemoPagerComponent.Item( AnyComponentWithIdentity( id: PremiumDemoScreen.Subject.businessAwayMessage, component: AnyComponent( PageComponent( content: AnyComponent(PhoneDemoComponent( context: context, position: .top, model: .island, videoFile: videos["away_message"], decoration: .business )), title: strings.Business_AwayMessages, text: strings.Business_AwayMessagesInfo, textColor: textColor ) ) ) ) availableItems[.businessChatBots] = DemoPagerComponent.Item( AnyComponentWithIdentity( id: PremiumDemoScreen.Subject.businessChatBots, component: AnyComponent( PageComponent( content: AnyComponent(PhoneDemoComponent( context: context, position: .top, model: .island, videoFile: videos["business_bots"], decoration: .business )), title: strings.Business_ChatbotsItem, text: strings.Business_ChatbotsInfo, textColor: textColor ) ) ) ) availableItems[.businessIntro] = DemoPagerComponent.Item( AnyComponentWithIdentity( id: PremiumDemoScreen.Subject.businessIntro, component: AnyComponent( PageComponent( content: AnyComponent(PhoneDemoComponent( context: context, position: .top, model: .island, videoFile: videos["business_intro"], decoration: .business )), title: strings.Business_Intro, text: strings.Business_IntroInfo, textColor: textColor ) ) ) ) availableItems[.businessLinks] = DemoPagerComponent.Item( AnyComponentWithIdentity( id: PremiumDemoScreen.Subject.businessLinks, component: AnyComponent( PageComponent( content: AnyComponent(PhoneDemoComponent( context: context, position: .top, model: .island, videoFile: videos["business_links"], decoration: .business )), title: strings.Business_Links, text: strings.Business_LinksInfo, textColor: textColor ) ) ) ) if let order = controller.order { var items: [DemoPagerComponent.Item] = order.compactMap { availableItems[$0] } let initialIndex: Int switch controller.source { case .intro, .gift: initialIndex = items.firstIndex(where: { (controller.subject as AnyHashable) == $0.content.id }) ?? 0 case .other: items = items.filter { item in return item.content.id == (controller.subject as AnyHashable) } initialIndex = 0 } let pagerSize = self.pagerView.update( transition: .immediate, component: AnyComponent( DemoPagerComponent( items: items, index: initialIndex, nextAction: nextAction, updated: { [weak self] position, count in if let self { let indexPosition = position * CGFloat(count - 1) self.indexPosition = indexPosition self.footerNode.updatePosition(position, count: count) var distance: CGFloat? if let storiesIndex { let value = indexPosition - CGFloat(storiesIndex) if abs(value) < 1.0 { distance = value } } if let limitsIndex { let value = indexPosition - CGFloat(limitsIndex) if abs(value) < 1.0 { distance = value } } if let businessIndex { let value = indexPosition - CGFloat(businessIndex) if abs(value) < 1.0 { distance = value } } var distanceToPage: CGFloat = 1.0 if let distance { if distance >= 0.0 && distance < 0.1 { distanceToPage = distance / 0.1 } else if distance < 0.0 { if distance >= -1.0 && distance < -0.9 { distanceToPage = ((distance * -1.0) - 0.9) / 0.1 } else { distanceToPage = 0.0 } } } self.closeDarkIconView.alpha = 1.0 - max(0.0, min(1.0, distanceToPage)) } } ) ), environment: {}, containerSize: contentSize ) self.pagerView.frame = CGRect(origin: CGPoint(x: floorToScreenPixels((contentSize.width - pagerSize.width) / 2.0), y: 0.0), size: pagerSize) } } let closeImage: UIImage if let image = self.cachedCloseImage { closeImage = image } else { closeImage = generateCloseButtonImage(backgroundColor: .clear, foregroundColor: UIColor(rgb: 0xffffff))! self.cachedCloseImage = closeImage } let closeSize = self.closeView.update( transition: .immediate, component: AnyComponent( Button( content: AnyComponent(ZStack([ AnyComponentWithIdentity( id: "background", component: AnyComponent( BlurredBackgroundComponent( color: UIColor(rgb: 0xbbbbbb, alpha: 0.22) ) ) ), AnyComponentWithIdentity( id: "icon", component: AnyComponent( Image(image: closeImage) ) ), ])), action: { [weak self] in self?.controller?.dismiss(animated: true, completion: nil) } ) ), environment: {}, containerSize: CGSize(width: 30.0, height: 30.0) ) self.closeView.clipsToBounds = true self.closeView.layer.cornerRadius = 15.0 self.closeView.frame = CGRect(origin: CGPoint(x: contentSize.width - closeSize.width * 1.5, y: 28.0 - closeSize.height / 2.0), size: closeSize) if self.closeDarkIconView.image == nil { self.closeDarkIconView.image = generateCloseButtonImage(backgroundColor: .clear, foregroundColor: theme.list.itemSecondaryTextColor)! self.closeDarkIconView.frame = CGRect(origin: .zero, size: closeSize) self.closeView.addSubview(self.closeDarkIconView) } } private var cachedCloseImage: UIImage? private var didPlayAppearAnimation = false func updateIsVisible(isVisible: Bool) { if self.currentIsVisible == isVisible { return } self.currentIsVisible = isVisible guard let layout = self.currentLayout else { return } self.containerLayoutUpdated(layout: layout, transition: .immediate) if !self.didPlayAppearAnimation { self.didPlayAppearAnimation = true self.animateIn() } } private var defaultTopInset: CGFloat { guard let layout = self.currentLayout else { return 210.0 } if case .compact = layout.metrics.widthClass { let bottomPanelPadding: CGFloat = 12.0 let bottomInset: CGFloat = layout.intrinsicInsets.bottom > 0.0 ? layout.intrinsicInsets.bottom + 5.0 : bottomPanelPadding let panelHeight: CGFloat = bottomPanelPadding + 50.0 + bottomInset + 28.0 var additionalInset: CGFloat = 0.0 if let order = self.controller?.order, order.count == 1 { additionalInset = 20.0 } return layout.size.height - layout.size.width - 181.0 - panelHeight + additionalInset } else { return 210.0 } } private func findVerticalScrollView(view: UIView?) -> (UIScrollView, ListView?)? { if let view = view { if let view = view as? UIScrollView, view.contentSize.height > view.contentSize.width && view.contentSize.height < 1500.0 { return (view, nil) } if let node = view.asyncdisplaykit_node as? ListView { return (node.scroller, node) } return findVerticalScrollView(view: view.superview) } else { return nil } } @objc func panGesture(_ recognizer: UIPanGestureRecognizer) { guard let layout = self.currentLayout else { return } let isLandscape = layout.orientation == .landscape let edgeTopInset = isLandscape ? 0.0 : defaultTopInset switch recognizer.state { case .began: let point = recognizer.location(in: self.view) let currentHitView = self.hitTest(point, with: nil) var scrollViewAndListNode = self.findVerticalScrollView(view: currentHitView) if scrollViewAndListNode?.0.frame.height == self.frame.width { scrollViewAndListNode = nil } let scrollView = scrollViewAndListNode?.0 let listNode = scrollViewAndListNode?.1 let topInset: CGFloat if self.isExpanded { topInset = 0.0 } else { topInset = edgeTopInset } self.panGestureArguments = (topInset, 0.0, scrollView, listNode) case .changed: guard let (topInset, panOffset, scrollView, listNode) = self.panGestureArguments else { return } let visibleContentOffset = listNode?.visibleContentOffset() let contentOffset = scrollView?.contentOffset.y ?? 0.0 var translation = recognizer.translation(in: self.view).y var currentOffset = topInset + translation let epsilon = 1.0 if case let .known(value) = visibleContentOffset, value <= epsilon { if let scrollView = scrollView { scrollView.bounces = false scrollView.setContentOffset(CGPoint(x: 0.0, y: 0.0), animated: false) } } else if let scrollView = scrollView, contentOffset <= -scrollView.contentInset.top + epsilon { scrollView.bounces = false scrollView.setContentOffset(CGPoint(x: 0.0, y: -scrollView.contentInset.top), animated: false) } else if let scrollView = scrollView { translation = panOffset currentOffset = topInset + translation if self.isExpanded { recognizer.setTranslation(CGPoint(), in: self.view) } else if currentOffset > 0.0 { scrollView.setContentOffset(CGPoint(x: 0.0, y: -scrollView.contentInset.top), animated: false) } } if scrollView == nil { translation = max(0.0, translation) } self.panGestureArguments = (topInset, translation, scrollView, listNode) if !self.isExpanded { if currentOffset > 0.0, let scrollView = scrollView { scrollView.panGestureRecognizer.setTranslation(CGPoint(), in: scrollView) } } var bounds = self.bounds if self.isExpanded { bounds.origin.y = -max(0.0, translation - edgeTopInset) } else { bounds.origin.y = -translation } bounds.origin.y = min(0.0, bounds.origin.y) self.bounds = bounds self.containerLayoutUpdated(layout: layout, transition: .immediate) case .ended: guard let (currentTopInset, panOffset, scrollView, listNode) = self.panGestureArguments else { return } self.panGestureArguments = nil let visibleContentOffset = listNode?.visibleContentOffset() let contentOffset = scrollView?.contentOffset.y ?? 0.0 let translation = recognizer.translation(in: self.view).y var velocity = recognizer.velocity(in: self.view) if self.isExpanded { if case let .known(value) = visibleContentOffset, value > 0.1 { velocity = CGPoint() } else if case .unknown = visibleContentOffset { velocity = CGPoint() } else if contentOffset > 0.1 { velocity = CGPoint() } } var bounds = self.bounds if self.isExpanded { bounds.origin.y = -max(0.0, translation - edgeTopInset) } else { bounds.origin.y = -translation } bounds.origin.y = min(0.0, bounds.origin.y) scrollView?.bounces = true let offset = currentTopInset + panOffset let topInset: CGFloat = edgeTopInset var dismissing = false if bounds.minY < -60 || (bounds.minY < 0.0 && velocity.y > 300.0) || (self.isExpanded && bounds.minY.isZero && velocity.y > 1800.0) { self.controller?.dismiss(animated: true, completion: nil) dismissing = true } else if self.isExpanded { if velocity.y > 300.0 || offset > topInset / 2.0 { self.isExpanded = false if let listNode = listNode { listNode.scroller.setContentOffset(CGPoint(), animated: false) } else if let scrollView = scrollView { scrollView.setContentOffset(CGPoint(x: 0.0, y: -scrollView.contentInset.top), animated: false) } let distance = topInset - offset let initialVelocity: CGFloat = distance.isZero ? 0.0 : abs(velocity.y / distance) let transition = ContainedViewLayoutTransition.animated(duration: 0.45, curve: .customSpring(damping: 124.0, initialVelocity: initialVelocity)) self.containerLayoutUpdated(layout: layout, transition: Transition(transition)) } else { self.isExpanded = true self.containerLayoutUpdated(layout: layout, transition: Transition(.animated(duration: 0.3, curve: .easeInOut))) } } else if scrollView != nil, (velocity.y < -300.0 || offset < topInset / 2.0) { if velocity.y > -2200.0 && velocity.y < -300.0, let listNode = listNode { DispatchQueue.main.async { listNode.transaction(deleteIndices: [], insertIndicesAndItems: [], updateIndicesAndItems: [], options: [.Synchronous, .LowLatency], scrollToItem: ListViewScrollToItem(index: 0, position: .top(0.0), animated: true, curve: .Default(duration: nil), directionHint: .Up), updateSizeAndInsets: nil, stationaryItemRange: nil, updateOpaqueState: nil, completion: { _ in }) } } let initialVelocity: CGFloat = offset.isZero ? 0.0 : abs(velocity.y / offset) let transition = ContainedViewLayoutTransition.animated(duration: 0.45, curve: .customSpring(damping: 124.0, initialVelocity: initialVelocity)) self.isExpanded = true self.containerLayoutUpdated(layout: layout, transition: Transition(transition)) } else { if let listNode = listNode { listNode.scroller.setContentOffset(CGPoint(), animated: false) } else if let scrollView = scrollView { scrollView.setContentOffset(CGPoint(x: 0.0, y: -scrollView.contentInset.top), animated: false) } self.containerLayoutUpdated(layout: layout, transition: Transition(.animated(duration: 0.3, curve: .easeInOut))) } if !dismissing { var bounds = self.bounds let previousBounds = bounds bounds.origin.y = 0.0 self.bounds = bounds self.layer.animateBounds(from: previousBounds, to: self.bounds, duration: 0.3, timingFunction: CAMediaTimingFunctionName.easeInEaseOut.rawValue) } case .cancelled: self.panGestureArguments = nil self.containerLayoutUpdated(layout: layout, transition: Transition(.animated(duration: 0.3, curve: .easeInOut))) default: break } } func updateDismissOffset(_ offset: CGFloat) { guard self.isExpanded, let layout = self.currentLayout else { return } self.dismissOffset = offset self.containerLayoutUpdated(layout: layout, transition: .immediate) } func update(isExpanded: Bool, transition: ContainedViewLayoutTransition) { guard isExpanded != self.isExpanded else { return } self.dismissOffset = nil self.isExpanded = isExpanded guard let layout = self.currentLayout else { return } self.containerLayoutUpdated(layout: layout, transition: Transition(transition)) } } var node: Node { return self.displayNode as! Node } private let context: AccountContext let subject: PremiumDemoScreen.Subject let source: PremiumDemoScreen.Source let order: [PremiumPerk]? private var currentLayout: ContainerViewLayout? private let buttonText: String private let buttonGloss: Bool private let forceDark: Bool public var action: () -> Void = {} public var disposed: () -> Void = {} public init(context: AccountContext, subject: PremiumDemoScreen.Subject, source: PremiumDemoScreen.Source, order: [PremiumPerk]?, buttonText: String, isPremium: Bool, forceDark: Bool = false) { self.context = context self.subject = subject self.source = source self.order = order self.buttonText = buttonText self.buttonGloss = !isPremium self.forceDark = forceDark super.init(navigationBarPresentationData: nil) self.navigationPresentation = .flatModal self.statusBar.statusBarStyle = .Ignore self.supportedOrientations = ViewControllerSupportedOrientations(regularSize: .all, compactSize: .portrait) } required public init(coder aDecoder: NSCoder) { fatalError("init(coder:) has not been implemented") } deinit { self.disposed() } @objc private func cancelPressed() { self.dismiss(animated: true, completion: nil) } override open func loadDisplayNode() { self.displayNode = Node(context: self.context, controller: self, buttonTitle: self.buttonText, gloss: self.buttonGloss, forceDark: self.forceDark) self.displayNodeDidLoad() self.view.disablesInteractiveModalDismiss = true } public override func dismiss(animated flag: Bool, completion: (() -> Void)? = nil) { self.view.endEditing(true) if flag { self.node.animateOut(completion: { super.dismiss(animated: false, completion: {}) completion?() }) } else { super.dismiss(animated: false, completion: {}) completion?() } } override open func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) self.node.updateIsVisible(isVisible: true) } override open func viewDidDisappear(_ animated: Bool) { super.viewDidDisappear(animated) self.node.updateIsVisible(isVisible: false) } override open func containerLayoutUpdated(_ layout: ContainerViewLayout, transition: ContainedViewLayoutTransition) { self.currentLayout = layout super.containerLayoutUpdated(layout, transition: transition) self.node.containerLayoutUpdated(layout: layout, transition: Transition(transition)) } } private class FooterNode: ASDisplayNode { private let order: [PremiumPerk]? private let backgroundNode: NavigationBackgroundNode private let separatorNode: ASDisplayNode private let coverNode: ASDisplayNode private let buttonNode: SolidRoundedButtonNode private let pageIndicatorView: ComponentHostView private var theme: PresentationTheme private var validLayout: ContainerViewLayout? private var currentParams: (CGFloat, Int)? var action: () -> Void = {} init(theme: PresentationTheme, title: String, gloss: Bool, order: [PremiumPerk]?) { self.order = order self.theme = theme self.backgroundNode = NavigationBackgroundNode(color: theme.rootController.tabBar.backgroundColor) self.separatorNode = ASDisplayNode() self.buttonNode = SolidRoundedButtonNode(theme: SolidRoundedButtonTheme(backgroundColor: .black, foregroundColor: .white), height: 50.0, cornerRadius: 11.0, gloss: gloss) self.buttonNode.title = title self.coverNode = ASDisplayNode() self.coverNode.backgroundColor = self.theme.overallDarkAppearance ? self.theme.list.blocksBackgroundColor : self.theme.list.plainBackgroundColor self.pageIndicatorView = ComponentHostView() self.pageIndicatorView.isUserInteractionEnabled = false super.init() self.addSubnode(self.backgroundNode) self.addSubnode(self.separatorNode) self.addSubnode(self.coverNode) self.addSubnode(self.buttonNode) self.updateTheme(theme) self.buttonNode.pressed = { [weak self] in self?.action() } } override func didLoad() { super.didLoad() self.view.addSubview(self.pageIndicatorView) } private func updateTheme(_ theme: PresentationTheme) { self.theme = theme self.backgroundNode.updateColor(color: self.theme.rootController.tabBar.backgroundColor, transition: .immediate) self.separatorNode.backgroundColor = self.theme.rootController.tabBar.separatorColor let backgroundColors = [ UIColor(rgb: 0x0077ff), UIColor(rgb: 0x6b93ff), UIColor(rgb: 0x8878ff), UIColor(rgb: 0xe46ace) ] self.buttonNode.updateTheme(SolidRoundedButtonTheme(backgroundColor: UIColor(rgb: 0x0077ff), backgroundColors: backgroundColors, foregroundColor: .white), animated: true) } func updateCoverAlpha(_ alpha: CGFloat, transition: ContainedViewLayoutTransition) { transition.updateAlpha(node: self.coverNode, alpha: alpha) } func updatePosition(_ position: CGFloat, count: Int) { self.currentParams = (position, count) if let layout = self.validLayout { let _ = self.updateLayout(layout: layout, transition: .immediate) } } func updateLayout(layout: ContainerViewLayout, transition: ContainedViewLayoutTransition) -> CGFloat { self.validLayout = layout let buttonInset: CGFloat = 16.0 let buttonWidth = layout.size.width - layout.safeInsets.left - layout.safeInsets.right - buttonInset * 2.0 let buttonHeight = self.buttonNode.updateLayout(width: buttonWidth, transition: transition) let bottomPanelPadding: CGFloat = 12.0 let bottomInset: CGFloat = layout.intrinsicInsets.bottom > 0.0 ? layout.intrinsicInsets.bottom + 5.0 : bottomPanelPadding var panelHeight: CGFloat = bottomPanelPadding + 50.0 + bottomInset + 8.0 var buttonOffset: CGFloat = 20.0 if let order, order.count > 1 { panelHeight += 20.0 buttonOffset += 20.0 } let panelFrame = CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: CGSize(width: layout.size.width, height: panelHeight)) transition.updateFrame(node: self.buttonNode, frame: CGRect(origin: CGPoint(x: layout.safeInsets.left + buttonInset, y: buttonOffset), size: CGSize(width: buttonWidth, height: buttonHeight))) transition.updateFrame(node: self.backgroundNode, frame: panelFrame) self.backgroundNode.update(size: panelFrame.size, transition: transition) transition.updateFrame(node: self.separatorNode, frame: CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: CGSize(width: panelFrame.width, height: UIScreenPixel))) if let (position, count) = self.currentParams { let indicatorSize = self.pageIndicatorView.update( transition: .immediate, component: AnyComponent( PageIndicatorComponent( pageCount: count, position: position, inactiveColor: self.theme.list.disclosureArrowColor, activeColor: UIColor(rgb: 0x7169ff) ) ), environment: {}, containerSize: layout.size ) self.pageIndicatorView.frame = CGRect(origin: CGPoint(x: floorToScreenPixels((layout.size.width - indicatorSize.width) / 2.0), y: 10.0), size: indicatorSize) transition.updateAlpha(layer: self.pageIndicatorView.layer, alpha: count <= 1 ? 0.0 : 1.0) } transition.updateFrame(node: self.coverNode, frame: panelFrame) return panelHeight } override func point(inside point: CGPoint, with event: UIEvent?) -> Bool { if self.backgroundNode.frame.contains(point) { return true } else { return false } } }