import Foundation import UIKit import SwiftSignalKit import AsyncDisplayKit import Display import ComponentFlow import Postbox import TelegramCore import TelegramPresentationData import TelegramUIPreferences import AccountContext import AppBundle import AvatarNode import CheckNode import Markdown import TextFormat import StarsBalanceOverlayComponent private let textFont = Font.regular(13.0) private let boldTextFont = Font.semibold(13.0) private func formattedText(_ text: String, fontSize: CGFloat, color: UIColor, linkColor: UIColor, textAlignment: NSTextAlignment = .natural) -> NSAttributedString { return parseMarkdownIntoAttributedString(text, attributes: MarkdownAttributes(body: MarkdownAttributeSet(font: Font.regular(fontSize), textColor: color), bold: MarkdownAttributeSet(font: Font.semibold(fontSize), textColor: color), link: MarkdownAttributeSet(font: Font.regular(fontSize), textColor: linkColor), linkAttribute: { _ in return (TelegramTextAttributes.URL, "") }), textAlignment: textAlignment) } private final class ChatMessagePaymentAlertContentNode: AlertContentNode, ASGestureRecognizerDelegate { private let strings: PresentationStrings private let title: String private let text: String private let optionText: String? private let alignment: TextAlertContentActionLayout private let titleNode: ImmediateTextNode private let textNode: ImmediateTextNode private let checkNode: InteractiveCheckNode private let checkLabelNode: ImmediateTextNode private let actionNodesSeparator: ASDisplayNode private let actionNodes: [TextAlertContentActionNode] private let actionVerticalSeparators: [ASDisplayNode] private var validLayout: CGSize? override var dismissOnOutsideTap: Bool { return self.isUserInteractionEnabled } var dontAskAgain: Bool = false { didSet { self.checkNode.setSelected(self.dontAskAgain, animated: true) } } var openTerms: () -> Void = {} init(theme: AlertControllerTheme, ptheme: PresentationTheme, strings: PresentationStrings, title: String, text: String, optionText: String?, actions: [TextAlertAction], alignment: TextAlertContentActionLayout) { self.strings = strings self.title = title self.text = text self.optionText = optionText self.alignment = alignment self.titleNode = ImmediateTextNode() self.titleNode.displaysAsynchronously = false self.titleNode.maximumNumberOfLines = 1 self.titleNode.textAlignment = .center self.textNode = ImmediateTextNode() self.textNode.maximumNumberOfLines = 0 self.textNode.displaysAsynchronously = false self.textNode.lineSpacing = 0.1 self.textNode.textAlignment = .center self.checkNode = InteractiveCheckNode(theme: CheckNodeTheme(backgroundColor: theme.accentColor, strokeColor: theme.contrastColor, borderColor: theme.controlBorderColor, overlayBorder: false, hasInset: false, hasShadow: false)) self.checkLabelNode = ImmediateTextNode() self.checkLabelNode.maximumNumberOfLines = 4 self.actionNodesSeparator = ASDisplayNode() self.actionNodesSeparator.isLayerBacked = true self.actionNodes = actions.map { action -> TextAlertContentActionNode in return TextAlertContentActionNode(theme: theme, action: action) } var actionVerticalSeparators: [ASDisplayNode] = [] if actions.count > 1 { for _ in 0 ..< actions.count - 1 { let separatorNode = ASDisplayNode() separatorNode.isLayerBacked = true actionVerticalSeparators.append(separatorNode) } } self.actionVerticalSeparators = actionVerticalSeparators super.init() self.addSubnode(self.titleNode) self.addSubnode(self.textNode) if let _ = optionText { self.addSubnode(self.checkNode) self.addSubnode(self.checkLabelNode) } self.addSubnode(self.actionNodesSeparator) for actionNode in self.actionNodes { self.addSubnode(actionNode) } for separatorNode in self.actionVerticalSeparators { self.addSubnode(separatorNode) } self.checkNode.valueChanged = { [weak self] value in if let strongSelf = self { strongSelf.dontAskAgain = !strongSelf.dontAskAgain } } self.checkLabelNode.highlightAttributeAction = { attributes in if let _ = attributes[NSAttributedString.Key(rawValue: TelegramTextAttributes.URL)] { return NSAttributedString.Key(rawValue: TelegramTextAttributes.URL) } else { return nil } } self.checkLabelNode.tapAttributeAction = { [weak self] attributes, _ in if let _ = attributes[NSAttributedString.Key(rawValue: TelegramTextAttributes.URL)] { self?.openTerms() } } self.updateTheme(theme) } override func didLoad() { super.didLoad() let tapGesture = UITapGestureRecognizer(target: self, action: #selector(self.acceptTap(_:))) tapGesture.delegate = self.wrappedGestureRecognizerDelegate self.view.addGestureRecognizer(tapGesture) } override func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool { let location = gestureRecognizer.location(in: self.checkLabelNode.view) if self.checkLabelNode.bounds.contains(location) { return true } return false } override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { if !self.bounds.contains(point) { return nil } if let (_, attributes) = self.checkLabelNode.attributesAtPoint(self.view.convert(point, to: self.checkLabelNode.view)) { if attributes[NSAttributedString.Key(rawValue: TelegramTextAttributes.URL)] == nil { return self.view } } return super.hitTest(point, with: event) } @objc private func acceptTap(_ gestureRecognizer: UITapGestureRecognizer) { self.dontAskAgain = !self.dontAskAgain } override func updateTheme(_ theme: AlertControllerTheme) { self.titleNode.attributedText = NSAttributedString(string: self.title, font: Font.semibold(17.0), textColor: theme.primaryColor, paragraphAlignment: .center) self.textNode.attributedText = formattedText(self.text, fontSize: 13.0, color: theme.primaryColor, linkColor: theme.accentColor, textAlignment: .center) self.checkLabelNode.attributedText = parseMarkdownIntoAttributedString( self.optionText ?? "", attributes: MarkdownAttributes( body: MarkdownAttributeSet(font: textFont, textColor: theme.primaryColor), bold: MarkdownAttributeSet(font: boldTextFont, textColor: theme.primaryColor), link: MarkdownAttributeSet(font: textFont, textColor: theme.primaryColor), linkAttribute: { _ in return nil } ) ) self.actionNodesSeparator.backgroundColor = theme.separatorColor for actionNode in self.actionNodes { actionNode.updateTheme(theme) } for separatorNode in self.actionVerticalSeparators { separatorNode.backgroundColor = theme.separatorColor } if let size = self.validLayout { _ = self.updateLayout(size: size, transition: .immediate) } } override func updateLayout(size: CGSize, transition: ContainedViewLayoutTransition) -> CGSize { var size = size size.width = min(size.width, 270.0) self.validLayout = size var origin: CGPoint = CGPoint(x: 0.0, y: 17.0) let titleSize = self.titleNode.updateLayout(CGSize(width: size.width - 32.0, height: size.height)) transition.updateFrame(node: self.titleNode, frame: CGRect(origin: CGPoint(x: floorToScreenPixels((size.width - titleSize.width) / 2.0), y: origin.y), size: titleSize)) origin.y += titleSize.height + 4.0 var entriesHeight: CGFloat = 0.0 let textSize = self.textNode.updateLayout(CGSize(width: size.width - 32.0, height: size.height)) transition.updateFrame(node: self.textNode, frame: CGRect(origin: CGPoint(x: floorToScreenPixels((size.width - textSize.width) / 2.0), y: origin.y), size: textSize)) origin.y += textSize.height if self.checkLabelNode.supernode != nil { origin.y += 21.0 entriesHeight += 21.0 let checkSize = CGSize(width: 22.0, height: 22.0) let condensedSize = CGSize(width: size.width - 76.0, height: size.height) let spacing: CGFloat = 12.0 let acceptTermsSize = self.checkLabelNode.updateLayout(condensedSize) let acceptTermsTotalWidth = checkSize.width + spacing + acceptTermsSize.width let acceptTermsOriginX = floorToScreenPixels((size.width - acceptTermsTotalWidth) / 2.0) transition.updateFrame(node: self.checkNode, frame: CGRect(origin: CGPoint(x: acceptTermsOriginX, y: origin.y - 3.0), size: checkSize)) transition.updateFrame(node: self.checkLabelNode, frame: CGRect(origin: CGPoint(x: acceptTermsOriginX + checkSize.width + spacing, y: origin.y), size: acceptTermsSize)) origin.y += acceptTermsSize.height entriesHeight += acceptTermsSize.height origin.y += 21.0 } let actionButtonHeight: CGFloat = 44.0 var minActionsWidth: CGFloat = 0.0 let maxActionWidth: CGFloat = floor(size.width / CGFloat(self.actionNodes.count)) let actionTitleInsets: CGFloat = 8.0 var effectiveActionLayout = self.alignment for actionNode in self.actionNodes { let actionTitleSize = actionNode.titleNode.updateLayout(CGSize(width: maxActionWidth, height: actionButtonHeight)) if case .horizontal = effectiveActionLayout, actionTitleSize.height > actionButtonHeight * 0.6667 { effectiveActionLayout = .vertical } switch effectiveActionLayout { case .horizontal: minActionsWidth += actionTitleSize.width + actionTitleInsets case .vertical: minActionsWidth = max(minActionsWidth, actionTitleSize.width + actionTitleInsets) } } let insets = UIEdgeInsets(top: 18.0, left: 18.0, bottom: 18.0, right: 18.0) let contentWidth = max(size.width, minActionsWidth) var actionsHeight: CGFloat = 0.0 switch effectiveActionLayout { case .horizontal: actionsHeight = actionButtonHeight case .vertical: actionsHeight = actionButtonHeight * CGFloat(self.actionNodes.count) } let resultSize = CGSize(width: contentWidth, height: titleSize.height + textSize.height + entriesHeight + actionsHeight + 3.0 + insets.top + insets.bottom) transition.updateFrame(node: self.actionNodesSeparator, frame: CGRect(origin: CGPoint(x: 0.0, y: resultSize.height - actionsHeight - UIScreenPixel), size: CGSize(width: resultSize.width, height: UIScreenPixel))) var actionOffset: CGFloat = 0.0 let actionWidth: CGFloat = floor(resultSize.width / CGFloat(self.actionNodes.count)) var separatorIndex = -1 var nodeIndex = 0 for actionNode in self.actionNodes { if separatorIndex >= 0 { let separatorNode = self.actionVerticalSeparators[separatorIndex] switch effectiveActionLayout { case .horizontal: transition.updateFrame(node: separatorNode, frame: CGRect(origin: CGPoint(x: actionOffset - UIScreenPixel, y: resultSize.height - actionsHeight), size: CGSize(width: UIScreenPixel, height: actionsHeight - UIScreenPixel))) case .vertical: transition.updateFrame(node: separatorNode, frame: CGRect(origin: CGPoint(x: 0.0, y: resultSize.height - actionsHeight + actionOffset - UIScreenPixel), size: CGSize(width: resultSize.width, height: UIScreenPixel))) } } separatorIndex += 1 let currentActionWidth: CGFloat switch effectiveActionLayout { case .horizontal: if nodeIndex == self.actionNodes.count - 1 { currentActionWidth = resultSize.width - actionOffset } else { currentActionWidth = actionWidth } case .vertical: currentActionWidth = resultSize.width } let actionNodeFrame: CGRect switch effectiveActionLayout { case .horizontal: actionNodeFrame = CGRect(origin: CGPoint(x: actionOffset, y: resultSize.height - actionsHeight), size: CGSize(width: currentActionWidth, height: actionButtonHeight)) actionOffset += currentActionWidth case .vertical: actionNodeFrame = CGRect(origin: CGPoint(x: 0.0, y: resultSize.height - actionsHeight + actionOffset), size: CGSize(width: currentActionWidth, height: actionButtonHeight)) actionOffset += actionButtonHeight } transition.updateFrame(node: actionNode, frame: actionNodeFrame) nodeIndex += 1 } return resultSize } } public class ChatMessagePaymentAlertController: AlertController { private let context: AccountContext? private let presentationData: PresentationData private weak var parentNavigationController: NavigationController? private let showBalance: Bool private let balance = ComponentView() private var didAppear = false public init(context: AccountContext?, presentationData: PresentationData, contentNode: AlertContentNode, navigationController: NavigationController?, showBalance: Bool = true) { self.context = context self.presentationData = presentationData self.parentNavigationController = navigationController self.showBalance = showBalance super.init(theme: AlertControllerTheme(presentationData: presentationData), contentNode: contentNode) self.willDismiss = { [weak self] in guard let self else { return } self.animateOut() } } required public init(coder aDecoder: NSCoder) { preconditionFailure() } private func animateOut() { if let view = self.balance.view { 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) } } public override func dismissAnimated() { super.dismissAnimated() self.animateOut() } public override func containerLayoutUpdated(_ layout: ContainerViewLayout, transition: ContainedViewLayoutTransition) { super.containerLayoutUpdated(layout, transition: transition) if !self.didAppear { self.didAppear = true if !layout.metrics.isTablet && layout.size.width > layout.size.height { Queue.mainQueue().after(0.1) { self.view.window?.endEditing(true) } } } if let context = self.context, let _ = self.parentNavigationController, self.showBalance { let insets = layout.insets(options: .statusBar) let balanceSize = self.balance.update( transition: .immediate, component: AnyComponent( StarsBalanceOverlayComponent( context: context, theme: self.presentationData.theme, action: { [weak self] in guard let self, let starsContext = context.starsContext, let navigationController = self.parentNavigationController 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.balance.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) } } } } public func chatMessagePaymentAlertController( context: AccountContext?, presentationData: PresentationData, updatedPresentationData: (initial: PresentationData, signal: Signal)?, peers: [EnginePeer], count: Int32, amount: StarsAmount, totalAmount: StarsAmount?, hasCheck: Bool = true, navigationController: NavigationController?, completion: @escaping (Bool) -> Void ) -> AlertController { let theme = defaultDarkColorPresentationTheme let presentationData = updatedPresentationData?.initial ?? presentationData let strings = presentationData.strings var completionImpl: (() -> Void)? var dismissImpl: (() -> Void)? let title = presentationData.strings.Chat_PaidMessage_Confirm_Title let actionTitle = presentationData.strings.Chat_PaidMessage_Confirm_PayForMessage(count) let messagesString = presentationData.strings.Chat_PaidMessage_Confirm_Text_Messages(count) let actions: [TextAlertAction] = [TextAlertAction(type: .defaultAction, title: actionTitle, action: { completionImpl?() dismissImpl?() }), TextAlertAction(type: .genericAction, title: presentationData.strings.Common_Cancel, action: { dismissImpl?() })] let text: String if peers.count == 1, let peer = peers.first { let amountString = presentationData.strings.Chat_PaidMessage_Confirm_Text_Stars(Int32(amount.value)) let totalString = presentationData.strings.Chat_PaidMessage_Confirm_Text_Stars(Int32(amount.value * Int64(count))) if case let .channel(channel) = peer, case .broadcast = channel.info { text = presentationData.strings.Chat_PaidMessage_Confirm_SingleComment_Text(peer.compactDisplayTitle, amountString, totalString, messagesString).string } else { text = presentationData.strings.Chat_PaidMessage_Confirm_Single_Text(peer.compactDisplayTitle, amountString, totalString, messagesString).string } } else { let amount = totalAmount ?? amount let usersString = presentationData.strings.Chat_PaidMessage_Confirm_Text_Users(Int32(peers.count)) let totalString = presentationData.strings.Chat_PaidMessage_Confirm_Text_Stars(Int32(amount.value * Int64(count))) text = presentationData.strings.Chat_PaidMessage_Confirm_Multiple_Text(usersString, totalString, messagesString).string } let optionText = hasCheck ? presentationData.strings.Chat_PaidMessage_Confirm_DontAskAgain : nil let contentNode = ChatMessagePaymentAlertContentNode(theme: AlertControllerTheme(presentationData: presentationData), ptheme: theme, strings: strings, title: title, text: text, optionText: optionText, actions: actions, alignment: .vertical) completionImpl = { [weak contentNode] in guard let contentNode else { return } completion(contentNode.dontAskAgain) } let controller = ChatMessagePaymentAlertController(context: context, presentationData: presentationData, contentNode: contentNode, navigationController: navigationController) dismissImpl = { [weak controller] in controller?.dismissAnimated() } return controller } public func chatMessageRemovePaymentAlertController( context: AccountContext? = nil, presentationData: PresentationData, updatedPresentationData: (initial: PresentationData, signal: Signal)?, peer: EnginePeer, amount: StarsAmount?, navigationController: NavigationController?, completion: @escaping (Bool) -> Void ) -> AlertController { let theme = defaultDarkColorPresentationTheme let presentationData = updatedPresentationData?.initial ?? presentationData let strings = presentationData.strings var completionImpl: (() -> Void)? var dismissImpl: (() -> Void)? let actions: [TextAlertAction] = [ TextAlertAction(type: .genericAction, title: strings.Common_Cancel, action: { dismissImpl?() }), TextAlertAction(type: .defaultAction, title: strings.Chat_PaidMessage_RemoveFee_Yes, action: { completionImpl?() dismissImpl?() }) ] let title = strings.Chat_PaidMessage_RemoveFee_Title let text = strings.Chat_PaidMessage_RemoveFee_Text(peer.compactDisplayTitle).string let optionText = amount.flatMap { strings.Chat_PaidMessage_RemoveFee_Refund(strings.Chat_PaidMessage_RemoveFee_Refund_Stars(Int32($0.value))).string } let contentNode = ChatMessagePaymentAlertContentNode(theme: AlertControllerTheme(presentationData: presentationData), ptheme: theme, strings: strings, title: title, text: text, optionText: optionText, actions: actions, alignment: .horizontal) completionImpl = { [weak contentNode] in guard let contentNode else { return } completion(contentNode.dontAskAgain) } let controller = ChatMessagePaymentAlertController(context: context, presentationData: presentationData, contentNode: contentNode, navigationController: navigationController) dismissImpl = { [weak controller] in controller?.dismissAnimated() } return controller }