From f08fef3991d9e9e85a40097e40f9ddf1957e1e22 Mon Sep 17 00:00:00 2001 From: Ilya Laktyushin Date: Wed, 2 Oct 2024 17:57:59 +0400 Subject: [PATCH] Gift improvements --- .../Sources/AccountContext.swift | 2 +- .../Sources/ChatListSearchListPaneNode.swift | 3 +- .../Sources/GiftSetupScreen.swift | 53 +- .../Sources/RemainingCountComponent.swift | 827 ++++++++++++++++++ .../Sources/PeerInfoScreen.swift | 3 +- .../Sources/ApplicationContext.swift | 4 +- .../Chat/ChatControllerOpenWebApp.swift | 8 +- .../TelegramUI/Sources/ChatController.swift | 2 +- .../Sources/SharedAccountContext.swift | 4 +- 9 files changed, 885 insertions(+), 21 deletions(-) create mode 100644 submodules/TelegramUI/Components/Gifts/GiftSetupScreen/Sources/RemainingCountComponent.swift diff --git a/submodules/AccountContext/Sources/AccountContext.swift b/submodules/AccountContext/Sources/AccountContext.swift index 5e32fda584..95b279d4b1 100644 --- a/submodules/AccountContext/Sources/AccountContext.swift +++ b/submodules/AccountContext/Sources/AccountContext.swift @@ -1041,7 +1041,7 @@ public protocol SharedAccountContext: AnyObject { func makeMiniAppListScreenInitialData(context: AccountContext) -> Signal func makeMiniAppListScreen(context: AccountContext, initialData: MiniAppListScreenInitialData) -> ViewController - func openWebApp(context: AccountContext, parentController: ViewController, updatedPresentationData: (initial: PresentationData, signal: Signal)?, peer: EnginePeer, threadId: Int64?, buttonText: String, url: String, simple: Bool, source: ChatOpenWebViewSource, skipTermsOfService: Bool) + func openWebApp(context: AccountContext, parentController: ViewController, updatedPresentationData: (initial: PresentationData, signal: Signal)?, peer: EnginePeer, threadId: Int64?, buttonText: String, url: String, simple: Bool, source: ChatOpenWebViewSource, skipTermsOfService: Bool, payload: String?) func makeDebugSettingsController(context: AccountContext?) -> ViewController? diff --git a/submodules/ChatListUI/Sources/ChatListSearchListPaneNode.swift b/submodules/ChatListUI/Sources/ChatListSearchListPaneNode.swift index 3620d86c21..a522784efb 100644 --- a/submodules/ChatListUI/Sources/ChatListSearchListPaneNode.swift +++ b/submodules/ChatListUI/Sources/ChatListSearchListPaneNode.swift @@ -3672,7 +3672,8 @@ final class ChatListSearchListPaneNode: ASDisplayNode, ChatListSearchPaneNode { url: "", simple: true, source: .generic, - skipTermsOfService: true + skipTermsOfService: true, + payload: nil ) } else { self.context.sharedContext.navigateToChatController(NavigateToChatControllerParams( diff --git a/submodules/TelegramUI/Components/Gifts/GiftSetupScreen/Sources/GiftSetupScreen.swift b/submodules/TelegramUI/Components/Gifts/GiftSetupScreen/Sources/GiftSetupScreen.swift index 62e6bc47b6..538c7ca8eb 100644 --- a/submodules/TelegramUI/Components/Gifts/GiftSetupScreen/Sources/GiftSetupScreen.swift +++ b/submodules/TelegramUI/Components/Gifts/GiftSetupScreen/Sources/GiftSetupScreen.swift @@ -74,6 +74,7 @@ final class GiftSetupScreenComponent: Component { private let scrollView: ScrollView private let navigationTitle = ComponentView() + private let remainingCount = ComponentView() private let introContent = ComponentView() private let introSection = ComponentView() private let hideSection = ComponentView() @@ -296,11 +297,15 @@ final class GiftSetupScreenComponent: Component { guard let component = self.component, case let .starGift(starGift) = component.subject, let starsContext = component.context.starsContext, let starsState = starsContext.currentState else { return } - + let proceed = { [weak self] in guard let self else { return } + + self.inProgress = true + self.state?.updated() + let entities = generateChatInputTextEntities(self.textInputState.text) let source: BotPaymentInvoiceSource = .starGift(hideName: self.hideName, peerId: component.peerId, giftId: starGift.id, text: self.textInputState.text.string, entities: entities) @@ -558,6 +563,43 @@ final class GiftSetupScreenComponent: Component { contentHeight += environment.navigationHeight contentHeight += 26.0 + if case let .starGift(starGift) = component.subject, let availability = starGift.availability { + //TODO:localize + let remains: Int32 = Int32(CGFloat(availability.remains) * 0.66) + let position = CGFloat(remains) / CGFloat(availability.total) + let remainsString = "\(remains)" //presentationStringsFormattedNumber(remains, environment.dateTimeFormat.groupingSeparator) + let totalString = presentationStringsFormattedNumber(availability.total, environment.dateTimeFormat.groupingSeparator) + let remainingCountSize = self.remainingCount.update( + transition: transition, + component: AnyComponent(RemainingCountComponent( + inactiveColor: environment.theme.list.itemBlocksSeparatorColor.withAlphaComponent(0.3), + activeColors: [UIColor(rgb: 0x5bc2ff), UIColor(rgb: 0x2d9eff)], + inactiveTitle: "Limited", + inactiveValue: "", + inactiveTitleColor: environment.theme.list.itemSecondaryTextColor, + activeTitle: "", + activeValue: totalString, + activeTitleColor: .white, + badgeText: "\(remainsString)", + badgePosition: position, + badgeGraphPosition: position, + invertProgress: true + )), + environment: {}, + containerSize: CGSize(width: availableSize.width - sideInset * 2.0, height: 10000.0) + ) + let remainingCountFrame = CGRect(origin: CGPoint(x: sideInset, y: contentHeight - 36.0), size: remainingCountSize) + if let remainingCountView = self.remainingCount.view { + if remainingCountView.superview == nil { + self.scrollView.addSubview(remainingCountView) + } + transition.setFrame(view: remainingCountView, frame: remainingCountFrame) + } + contentHeight += remainingCountSize.height + contentHeight -= 36.0 + contentHeight += sectionSpacing + } + let giftConfiguration = GiftConfiguration.with(appConfiguration: component.context.currentAppConfiguration.with { $0 }) var introSectionItems: [AnyComponentWithIdentity] = [] @@ -648,14 +690,7 @@ final class GiftSetupScreenComponent: Component { transition: transition, component: AnyComponent(ListSectionComponent( theme: environment.theme, - header: AnyComponent(MultilineTextComponent( - text: .plain(NSAttributedString( - string: environment.strings.Gift_Send_Customize_Title, - font: Font.regular(presentationData.listsFontSize.itemListBaseHeaderFontSize), - textColor: environment.theme.list.freeTextColor - )), - maximumNumberOfLines: 0 - )), + header: nil, footer: introFooter, items: introSectionItems )), diff --git a/submodules/TelegramUI/Components/Gifts/GiftSetupScreen/Sources/RemainingCountComponent.swift b/submodules/TelegramUI/Components/Gifts/GiftSetupScreen/Sources/RemainingCountComponent.swift new file mode 100644 index 0000000000..267382cf3e --- /dev/null +++ b/submodules/TelegramUI/Components/Gifts/GiftSetupScreen/Sources/RemainingCountComponent.swift @@ -0,0 +1,827 @@ +import Foundation +import UIKit +import Display +import AsyncDisplayKit +import TelegramCore +import SwiftSignalKit +import AccountContext +import TelegramPresentationData +import PresentationDataUtils +import ComponentFlow +import MultilineTextComponent +import Markdown +import TextFormat +import RoundedRectWithTailPath + +public class RemainingCountComponent: Component { + private let inactiveColor: UIColor + private let activeColors: [UIColor] + private let inactiveTitle: String + private let inactiveValue: String + private let inactiveTitleColor: UIColor + private let activeTitle: String + private let activeValue: String + private let activeTitleColor: UIColor + private let badgeText: String? + private let badgePosition: CGFloat + private let badgeGraphPosition: CGFloat + private let invertProgress: Bool + + public init( + inactiveColor: UIColor, + activeColors: [UIColor], + inactiveTitle: String, + inactiveValue: String, + inactiveTitleColor: UIColor, + activeTitle: String, + activeValue: String, + activeTitleColor: UIColor, + badgeText: String?, + badgePosition: CGFloat, + badgeGraphPosition: CGFloat, + invertProgress: Bool = false + ) { + self.inactiveColor = inactiveColor + self.activeColors = activeColors + self.inactiveTitle = inactiveTitle + self.inactiveValue = inactiveValue + self.inactiveTitleColor = inactiveTitleColor + self.activeTitle = activeTitle + self.activeValue = activeValue + self.activeTitleColor = activeTitleColor + self.badgeText = badgeText + self.badgePosition = badgePosition + self.badgeGraphPosition = badgeGraphPosition + self.invertProgress = invertProgress + } + + public static func ==(lhs: RemainingCountComponent, rhs: RemainingCountComponent) -> Bool { + if lhs.inactiveColor != rhs.inactiveColor { + return false + } + if lhs.activeColors != rhs.activeColors { + return false + } + if lhs.inactiveTitle != rhs.inactiveTitle { + return false + } + if lhs.inactiveValue != rhs.inactiveValue { + return false + } + if lhs.inactiveTitleColor != rhs.inactiveTitleColor { + return false + } + if lhs.activeTitle != rhs.activeTitle { + return false + } + if lhs.activeValue != rhs.activeValue { + return false + } + if lhs.activeTitleColor != rhs.activeTitleColor { + return false + } + if lhs.badgeText != rhs.badgeText { + return false + } + if lhs.badgePosition != rhs.badgePosition { + return false + } + if lhs.badgeGraphPosition != rhs.badgeGraphPosition { + return false + } + if lhs.invertProgress != rhs.invertProgress { + return false + } + return true + } + + public final class View: UIView { + private var component: RemainingCountComponent? + + private let container: UIView + private let inactiveBackground: SimpleLayer + + private let inactiveTitleLabel = ComponentView() + private let inactiveValueLabel = ComponentView() + + private let innerLeftTitleLabel = ComponentView() + private let innerRightTitleLabel = ComponentView() + + private let activeContainer: UIView + private let activeBackground: SimpleLayer + + private let activeTitleLabel = ComponentView() + private let activeValueLabel = ComponentView() + + private let badgeView: UIView + private let badgeMaskView: UIView + private let badgeShapeLayer = CAShapeLayer() + + private let badgeForeground: SimpleLayer + private let badgeLabel: BadgeLabelView + private let badgeLabelMaskView = UIImageView() + + private var badgeTailPosition: CGFloat = 0.0 + private var badgeShapeArguments: (Double, Double, CGSize, CGFloat, CGFloat)? + + override init(frame: CGRect) { + self.container = UIView() + self.container.clipsToBounds = true + self.container.layer.cornerRadius = 9.0 + + self.inactiveBackground = SimpleLayer() + + self.activeContainer = UIView() + self.activeContainer.clipsToBounds = true + + self.activeBackground = SimpleLayer() + self.activeBackground.anchorPoint = CGPoint() + + self.badgeView = UIView() + self.badgeView.alpha = 0.0 + + self.badgeShapeLayer.fillColor = UIColor.white.cgColor + self.badgeShapeLayer.transform = CATransform3DMakeScale(1.0, -1.0, 1.0) + + self.badgeMaskView = UIView() + self.badgeMaskView.layer.addSublayer(self.badgeShapeLayer) + self.badgeView.mask = self.badgeMaskView + + self.badgeForeground = SimpleLayer() + + self.badgeLabel = BadgeLabelView() + let _ = self.badgeLabel.update(value: "0", transition: .immediate) + self.badgeLabel.mask = self.badgeLabelMaskView + + super.init(frame: frame) + + self.addSubview(self.container) + self.container.layer.addSublayer(self.inactiveBackground) + self.container.addSubview(self.activeContainer) + self.activeContainer.layer.addSublayer(self.activeBackground) + + self.addSubview(self.badgeView) + self.badgeView.layer.addSublayer(self.badgeForeground) + self.badgeView.addSubview(self.badgeLabel) + + self.badgeLabelMaskView.contentMode = .scaleToFill + self.badgeLabelMaskView.image = generateImage(CGSize(width: 2.0, height: 30.0), rotatedContext: { size, context in + let bounds = CGRect(origin: .zero, size: size) + context.clear(bounds) + + let colorsArray: [CGColor] = [ + UIColor(rgb: 0xffffff, alpha: 0.0).cgColor, + UIColor(rgb: 0xffffff).cgColor, + UIColor(rgb: 0xffffff).cgColor, + UIColor(rgb: 0xffffff, alpha: 0.0).cgColor, + ] + var locations: [CGFloat] = [0.0, 0.24, 0.76, 1.0] + let gradient = CGGradient(colorsSpace: deviceColorSpace, colors: colorsArray as CFArray, locations: &locations)! + + context.drawLinearGradient(gradient, start: CGPoint(x: 0.0, y: 0.0), end: CGPoint(x: 0.0, y: size.height), options: CGGradientDrawingOptions()) + }) + + self.isUserInteractionEnabled = false + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + deinit { + self.badgeShapeAnimator?.invalidate() + } + + private var didPlayAppearanceAnimation = false + func playAppearanceAnimation(component: RemainingCountComponent, badgeFullSize: CGSize, from: CGFloat? = nil) { + if from == nil { + self.badgeView.layer.animateScale(from: 0.1, to: 1.0, duration: 0.4, timingFunction: CAMediaTimingFunctionName.easeInEaseOut.rawValue) + } + + let rotationAngle: CGFloat + if badgeFullSize.width > 100.0 { + rotationAngle = 0.2 + } else { + rotationAngle = 0.26 + } + + let to: CGFloat = self.badgeView.center.x + + let positionAnimation = CABasicAnimation(keyPath: "position.x") + positionAnimation.fromValue = NSValue(cgPoint: CGPoint(x: from ?? self.container.frame.width, y: 0.0)) + positionAnimation.toValue = NSValue(cgPoint: CGPoint(x: to, y: 0.0)) + positionAnimation.duration = 0.5 + positionAnimation.fillMode = .forwards + positionAnimation.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut) + self.badgeView.layer.add(positionAnimation, forKey: "appearance1") + + if from != to { + let rotateAnimation = CABasicAnimation(keyPath: "transform.rotation.z") + rotateAnimation.fromValue = 0.0 as NSNumber + rotateAnimation.toValue = rotationAngle as NSNumber + rotateAnimation.duration = 0.15 + rotateAnimation.fillMode = .forwards + rotateAnimation.timingFunction = CAMediaTimingFunction(name: .easeOut) + rotateAnimation.isRemovedOnCompletion = false + self.badgeView.layer.add(rotateAnimation, forKey: "appearance2") + + Queue.mainQueue().after(0.5, { + let bounceAnimation = CABasicAnimation(keyPath: "transform.rotation.z") + bounceAnimation.fromValue = rotationAngle as NSNumber + bounceAnimation.toValue = -0.04 as NSNumber + bounceAnimation.duration = 0.2 + bounceAnimation.fillMode = .forwards + bounceAnimation.timingFunction = CAMediaTimingFunction(name: .easeOut) + bounceAnimation.isRemovedOnCompletion = false + self.badgeView.layer.add(bounceAnimation, forKey: "appearance3") + self.badgeView.layer.removeAnimation(forKey: "appearance2") + + Queue.mainQueue().after(0.2) { + let returnAnimation = CABasicAnimation(keyPath: "transform.rotation.z") + returnAnimation.fromValue = -0.04 as NSNumber + returnAnimation.toValue = 0.0 as NSNumber + returnAnimation.duration = 0.15 + returnAnimation.fillMode = .forwards + returnAnimation.timingFunction = CAMediaTimingFunction(name: .easeIn) + self.badgeView.layer.add(returnAnimation, forKey: "appearance4") + self.badgeView.layer.removeAnimation(forKey: "appearance3") + } + }) + } + + if from == nil { + self.badgeView.alpha = 1.0 + self.badgeView.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.1) + } + + if let badgeText = component.badgeText { + let transition: ComponentTransition = .easeInOut(duration: from != nil ? 0.3 : 0.5) + var frameTransition = transition + if from == nil { + frameTransition = frameTransition.withAnimation(.none) + } + let badgeLabelSize = self.badgeLabel.update(value: badgeText, transition: transition) + frameTransition.setFrame(view: self.badgeLabel, frame: CGRect(origin: CGPoint(x: floorToScreenPixels((badgeFullSize.width - badgeLabelSize.width) / 2.0), y: -2.0), size: badgeLabelSize)) + } + } + + var previousAvailableSize: CGSize? + func update(component: RemainingCountComponent, availableSize: CGSize, transition: ComponentTransition) -> CGSize { + self.component = component + self.inactiveBackground.backgroundColor = component.inactiveColor.cgColor + self.activeBackground.backgroundColor = component.activeColors.last?.cgColor + + let size = CGSize(width: availableSize.width, height: 90.0) + + self.badgeLabel.color = component.activeTitleColor + + let lineHeight: CGFloat = 30.0 + let containerFrame = CGRect(origin: CGPoint(x: 0.0, y: size.height - lineHeight), size: CGSize(width: size.width, height: lineHeight)) + self.container.frame = containerFrame + + let activityPosition: CGFloat = floor(containerFrame.width * component.badgeGraphPosition) + let activeWidth: CGFloat = containerFrame.width - activityPosition + + let leftTextColor: UIColor + let rightTextColor: UIColor + if component.invertProgress { + leftTextColor = component.inactiveTitleColor + rightTextColor = component.inactiveTitleColor + } else { + leftTextColor = component.inactiveTitleColor + rightTextColor = component.activeTitleColor + } + + if "".isEmpty { + if component.invertProgress { + let innerLeftTitleSize = self.innerLeftTitleLabel.update( + transition: .immediate, + component: AnyComponent( + MultilineTextComponent( + text: .plain( + NSAttributedString( + string: component.inactiveTitle, + font: Font.semibold(15.0), + textColor: component.activeTitleColor + ) + ) + ) + ), + environment: {}, + containerSize: availableSize + ) + if let view = self.innerLeftTitleLabel.view { + if view.superview == nil { + self.activeContainer.addSubview(view) + } + view.frame = CGRect(origin: CGPoint(x: 12.0, y: floorToScreenPixels((lineHeight - innerLeftTitleSize.height) / 2.0)), size: innerLeftTitleSize) + } + + let innerRightTitleSize = self.innerRightTitleLabel.update( + transition: .immediate, + component: AnyComponent( + MultilineTextComponent( + text: .plain( + NSAttributedString( + string: component.activeValue, + font: Font.semibold(15.0), + textColor: component.activeTitleColor + ) + ) + ) + ), + environment: {}, + containerSize: availableSize + ) + if let view = self.innerRightTitleLabel.view { + if view.superview == nil { + self.activeContainer.addSubview(view) + } + view.frame = CGRect(origin: CGPoint(x: containerFrame.width - 12.0 - innerRightTitleSize.width, y: floorToScreenPixels((lineHeight - innerRightTitleSize.height) / 2.0)), size: innerRightTitleSize) + } + } + + let inactiveTitleSize = self.inactiveTitleLabel.update( + transition: .immediate, + component: AnyComponent( + MultilineTextComponent( + text: .plain( + NSAttributedString( + string: component.inactiveTitle, + font: Font.semibold(15.0), + textColor: leftTextColor + ) + ) + ) + ), + environment: {}, + containerSize: availableSize + ) + if let view = self.inactiveTitleLabel.view { + if view.superview == nil { + self.container.addSubview(view) + } + view.frame = CGRect(origin: CGPoint(x: 12.0, y: floorToScreenPixels((lineHeight - inactiveTitleSize.height) / 2.0)), size: inactiveTitleSize) + } + + let inactiveValueSize = self.inactiveValueLabel.update( + transition: .immediate, + component: AnyComponent( + MultilineTextComponent( + text: .plain( + NSAttributedString( + string: component.inactiveValue, + font: Font.semibold(15.0), + textColor: leftTextColor + ) + ) + ) + ), + environment: {}, + containerSize: availableSize + ) + if let view = self.inactiveValueLabel.view { + if view.superview == nil { + self.container.addSubview(view) + } + view.frame = CGRect(origin: CGPoint(x: activityPosition - 12.0 - inactiveValueSize.width, y: floorToScreenPixels((lineHeight - inactiveValueSize.height) / 2.0)), size: inactiveValueSize) + } + + let activeTitleSize = self.activeTitleLabel.update( + transition: .immediate, + component: AnyComponent( + MultilineTextComponent( + text: .plain( + NSAttributedString( + string: component.activeTitle, + font: Font.semibold(15.0), + textColor: rightTextColor + ) + ) + ) + ), + environment: {}, + containerSize: availableSize + ) + if let view = self.activeTitleLabel.view { + if view.superview == nil { + self.container.addSubview(view) + } + view.frame = CGRect(origin: CGPoint(x: activityPosition + 12.0, y: floorToScreenPixels((lineHeight - activeTitleSize.height) / 2.0)), size: activeTitleSize) + } + + let activeValueSize = self.activeValueLabel.update( + transition: .immediate, + component: AnyComponent( + MultilineTextComponent( + text: .plain( + NSAttributedString( + string: component.activeValue, + font: Font.semibold(15.0), + textColor: rightTextColor + ) + ) + ) + ), + environment: {}, + containerSize: availableSize + ) + if let view = self.activeValueLabel.view { + if view.superview == nil { + self.container.addSubview(view) + + if component.invertProgress { + self.container.bringSubviewToFront(self.activeContainer) + } + } + view.frame = CGRect(origin: CGPoint(x: containerFrame.width - 12.0 - activeValueSize.width, y: floorToScreenPixels((lineHeight - activeValueSize.height) / 2.0)), size: activeValueSize) + } + } + + var progressTransition: ComponentTransition = .immediate + if !transition.animation.isImmediate { + progressTransition = .easeInOut(duration: 0.5) + } + if "".isEmpty { + if component.invertProgress { + progressTransition.setFrame(layer: self.inactiveBackground, frame: CGRect(origin: CGPoint(x: activityPosition, y: 0.0), size: CGSize(width: size.width - activityPosition, height: lineHeight))) + progressTransition.setFrame(view: self.activeContainer, frame: CGRect(origin: .zero, size: CGSize(width: activityPosition, height: lineHeight))) + progressTransition.setBounds(layer: self.activeBackground, bounds: CGRect(origin: .zero, size: CGSize(width: containerFrame.width * 1.35, height: lineHeight))) + } else { + progressTransition.setFrame(layer: self.inactiveBackground, frame: CGRect(origin: .zero, size: CGSize(width: activityPosition, height: lineHeight))) + progressTransition.setFrame(view: self.activeContainer, frame: CGRect(origin: CGPoint(x: activityPosition, y: 0.0), size: CGSize(width: activeWidth, height: lineHeight))) + progressTransition.setFrame(layer: self.activeBackground, frame: CGRect(origin: CGPoint(x: -activityPosition, y: 0.0), size: CGSize(width: containerFrame.width * 1.35, height: lineHeight))) + } + if self.activeBackground.animation(forKey: "movement") == nil { + self.activeBackground.position = CGPoint(x: -self.activeContainer.frame.width * 0.35, y: lineHeight / 2.0) + } + } + + let countWidth: CGFloat + if let badgeText = component.badgeText { + countWidth = CGFloat(badgeText.count) * 10.0 + } else { + countWidth = 51.0 + } + let badgeWidth: CGFloat = countWidth + 20.0 + + let badgeSize = CGSize(width: badgeWidth, height: 30.0) + let badgeFullSize = CGSize(width: badgeWidth, height: 30.0 + 8.0) + let tailSize = CGSize(width: 15.0, height: 6.0) + let tailRadius: CGFloat = 3.0 + self.badgeMaskView.frame = CGRect(origin: .zero, size: badgeFullSize) + self.badgeShapeLayer.frame = CGRect(origin: CGPoint(x: 0.0, y: -4.0), size: badgeFullSize) + + self.badgeView.bounds = CGRect(origin: .zero, size: badgeFullSize) + + let currentBadgeX = self.badgeView.center.x + + let badgePosition = component.badgePosition + + if badgePosition > 1.0 - 0.15 { + progressTransition.setAnchorPoint(layer: self.badgeView.layer, anchorPoint: CGPoint(x: 1.0, y: 1.0)) + + if progressTransition.animation.isImmediate { + self.badgeShapeLayer.path = generateRoundedRectWithTailPath(rectSize: badgeSize, tailSize: tailSize, tailRadius: tailRadius, tailPosition: 1.0).cgPath + } else { + self.badgeShapeArguments = (CACurrentMediaTime(), 0.5, badgeSize, self.badgeTailPosition, 1.0) + self.animateBadgeTailPositionChange() + } + self.badgeTailPosition = 1.0 + + if let _ = self.badgeView.layer.animation(forKey: "appearance1") { + } else { + self.badgeView.center = CGPoint(x: 3.0 + (size.width - 6.0) * badgePosition + 3.0, y: 56.0) + } + } else if badgePosition < 0.15 { + progressTransition.setAnchorPoint(layer: self.badgeView.layer, anchorPoint: CGPoint(x: 0.0, y: 1.0)) + + if progressTransition.animation.isImmediate { + self.badgeShapeLayer.path = generateRoundedRectWithTailPath(rectSize: badgeSize, tailSize: tailSize, tailRadius: tailRadius, tailPosition: 0.0).cgPath + } else { + self.badgeShapeArguments = (CACurrentMediaTime(), 0.5, badgeSize, self.badgeTailPosition, 0.0) + self.animateBadgeTailPositionChange() + } + self.badgeTailPosition = 0.0 + + if let _ = self.badgeView.layer.animation(forKey: "appearance1") { + + } else { + self.badgeView.center = CGPoint(x: (size.width - 6.0) * badgePosition, y: 56.0) + } + } else { + progressTransition.setAnchorPoint(layer: self.badgeView.layer, anchorPoint: CGPoint(x: 0.5, y: 1.0)) + + if progressTransition.animation.isImmediate { + self.badgeShapeLayer.path = generateRoundedRectWithTailPath(rectSize: badgeSize, tailSize: tailSize, tailRadius: tailRadius, tailPosition: 0.5).cgPath + } else { + self.badgeShapeArguments = (CACurrentMediaTime(), 0.5, badgeSize, self.badgeTailPosition, 0.5) + self.animateBadgeTailPositionChange() + } + self.badgeTailPosition = 0.5 + + if let _ = self.badgeView.layer.animation(forKey: "appearance1") { + + } else { + self.badgeView.center = CGPoint(x: size.width * badgePosition, y: 56.0) + } + } + self.badgeForeground.bounds = CGRect(origin: CGPoint(), size: CGSize(width: badgeFullSize.width * 3.0, height: badgeFullSize.height)) + if self.badgeForeground.animation(forKey: "movement") == nil { + self.badgeForeground.position = CGPoint(x: badgeSize.width * 3.0 / 2.0 - self.badgeForeground.frame.width * 0.35, y: badgeFullSize.height / 2.0) + } + + self.badgeLabelMaskView.frame = CGRect(x: 0.0, y: 0.0, width: 100.0, height: 36.0) + + if !self.didPlayAppearanceAnimation || !transition.animation.isImmediate { + self.didPlayAppearanceAnimation = true + if transition.animation.isImmediate { + if component.badgePosition < 0.1 { + self.badgeView.alpha = 1.0 + if let badgeText = component.badgeText { + let badgeLabelSize = self.badgeLabel.update(value: badgeText, transition: .immediate) + transition.setFrame(view: self.badgeLabel, frame: CGRect(origin: CGPoint(x: floorToScreenPixels((badgeFullSize.width - badgeLabelSize.width) / 2.0), y: -2.0), size: badgeLabelSize)) + } + } else { + self.playAppearanceAnimation(component: component, badgeFullSize: badgeFullSize) + } + } else { + self.playAppearanceAnimation(component: component, badgeFullSize: badgeFullSize, from: currentBadgeX) + } + } + + if self.previousAvailableSize != availableSize { + self.previousAvailableSize = availableSize + + var locations: [CGFloat] = [] + let delta = 1.0 / CGFloat(component.activeColors.count - 1) + for i in 0 ..< component.activeColors.count { + locations.append(delta * CGFloat(i)) + } + + let gradient = generateGradientImage(size: CGSize(width: 200.0, height: 60.0), colors: component.activeColors, locations: locations, direction: .horizontal) + self.badgeForeground.contentsGravity = .resizeAspectFill + self.badgeForeground.contents = gradient?.cgImage + + self.activeBackground.contentsGravity = .resizeAspectFill + self.activeBackground.contents = gradient?.cgImage + + self.setupGradientAnimations() + } + + return size + } + + private var badgeShapeAnimator: ConstantDisplayLinkAnimator? + private func animateBadgeTailPositionChange() { + if self.badgeShapeAnimator == nil { + self.badgeShapeAnimator = ConstantDisplayLinkAnimator(update: { [weak self] in + self?.animateBadgeTailPositionChange() + }) + self.badgeShapeAnimator?.isPaused = true + } + + if let (startTime, duration, badgeSize, initial, target) = self.badgeShapeArguments { + self.badgeShapeAnimator?.isPaused = false + + let t = CGFloat(max(0.0, min(1.0, (CACurrentMediaTime() - startTime) / duration))) + let value = initial + (target - initial) * t + + let tailSize = CGSize(width: 15.0, height: 6.0) + let tailRadius: CGFloat = 3.0 + self.badgeShapeLayer.path = generateRoundedRectWithTailPath(rectSize: badgeSize, tailSize: tailSize, tailRadius: tailRadius, tailPosition: value).cgPath + + if t >= 1.0 { + self.badgeShapeArguments = nil + self.badgeShapeAnimator?.isPaused = true + self.badgeShapeAnimator?.invalidate() + self.badgeShapeAnimator = nil + } + } else { + self.badgeShapeAnimator?.isPaused = true + self.badgeShapeAnimator?.invalidate() + self.badgeShapeAnimator = nil + } + } + + private func setupGradientAnimations() { + guard let _ = self.component else { + return + } + if let _ = self.badgeForeground.animation(forKey: "movement") { + } else { + CATransaction.begin() + + let badgeOffset = (self.badgeForeground.frame.width - self.badgeView.bounds.width) / 2.0 + let badgePreviousValue = self.badgeForeground.position.x + var badgeNewValue: CGFloat = badgeOffset + if badgeOffset - badgePreviousValue < self.badgeForeground.frame.width * 0.25 { + badgeNewValue -= self.badgeForeground.frame.width * 0.35 + } + self.badgeForeground.position = CGPoint(x: badgeNewValue, y: self.badgeForeground.bounds.size.height / 2.0) + + let lineOffset = 0.0 + let linePreviousValue = self.activeBackground.position.x + var lineNewValue: CGFloat = lineOffset + if linePreviousValue < 0.0 { + lineNewValue = 0.0 + } else { + lineNewValue = -self.activeContainer.bounds.width * 0.35 + } + lineNewValue -= self.activeContainer.frame.minX + self.activeBackground.position = CGPoint(x: lineNewValue, y: 0.0) + + let badgeAnimation = CABasicAnimation(keyPath: "position.x") + badgeAnimation.duration = 4.5 + badgeAnimation.fromValue = badgePreviousValue + badgeAnimation.toValue = badgeNewValue + badgeAnimation.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut) + + CATransaction.setCompletionBlock { [weak self] in + self?.setupGradientAnimations() + } + self.badgeForeground.add(badgeAnimation, forKey: "movement") + + let lineAnimation = CABasicAnimation(keyPath: "position.x") + lineAnimation.duration = 4.5 + lineAnimation.fromValue = linePreviousValue + lineAnimation.toValue = lineNewValue + lineAnimation.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut) + self.activeBackground.add(lineAnimation, forKey: "movement") + + CATransaction.commit() + } + } + } + + 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, transition: transition) + } +} + + +private let labelWidth: CGFloat = 10.0 +private let labelHeight: CGFloat = 30.0 +private let labelSize = CGSize(width: labelWidth, height: labelHeight) +private let font = Font.with(size: 15.0, design: .regular, weight: .semibold, traits: []) + +final class BadgeLabelView: UIView { + private class StackView: UIView { + var labels: [UILabel] = [] + + var currentValue: Int32 = 0 + + var color: UIColor = .white { + didSet { + for view in self.labels { + view.textColor = self.color + } + } + } + + init() { + super.init(frame: CGRect(origin: .zero, size: labelSize)) + + var height: CGFloat = -labelHeight + for i in -1 ..< 10 { + let label = UILabel() + if i == -1 { + label.text = "9" + } else { + label.text = "\(i)" + } + label.textColor = self.color + label.font = font + label.textAlignment = .center + label.frame = CGRect(x: 0, y: height, width: labelWidth, height: labelHeight) + self.addSubview(label) + self.labels.append(label) + + height += labelHeight + } + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func update(value: Int32, isFirst: Bool, isLast: Bool, transition: ComponentTransition) { + let previousValue = self.currentValue + self.currentValue = value + + self.labels[1].alpha = isFirst && !isLast ? 0.0 : 1.0 + + if previousValue == 9 && value < 9 { + self.bounds = CGRect( + origin: CGPoint( + x: 0.0, + y: -1.0 * labelSize.height + ), + size: labelSize + ) + } + + let bounds = CGRect( + origin: CGPoint( + x: 0.0, + y: CGFloat(value) * labelSize.height + ), + size: labelSize + ) + transition.setBounds(view: self, bounds: bounds) + } + } + + private var itemViews: [Int: StackView] = [:] + private var staticLabel = UILabel() + + init() { + super.init(frame: .zero) + + self.clipsToBounds = true + self.isUserInteractionEnabled = false + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + var color: UIColor = .white { + didSet { + self.staticLabel.textColor = self.color + for (_, view) in self.itemViews { + view.color = self.color + } + } + } + + func update(value: String, transition: ComponentTransition) -> CGSize { + if value.contains(" ") { + for (_, view) in self.itemViews { + view.isHidden = true + } + + if self.staticLabel.superview == nil { + self.staticLabel.textColor = self.color + self.staticLabel.font = font + + self.addSubview(self.staticLabel) + } + + self.staticLabel.text = value + let size = self.staticLabel.sizeThatFits(CGSize(width: 100.0, height: 100.0)) + self.staticLabel.frame = CGRect(origin: .zero, size: CGSize(width: size.width, height: labelHeight)) + + return CGSize(width: ceil(self.staticLabel.bounds.width), height: ceil(self.staticLabel.bounds.height)) + } + + let string = value + let stringArray = Array(string.map { String($0) }.reversed()) + + let totalWidth = CGFloat(stringArray.count) * labelWidth + + var validIds: [Int] = [] + for i in 0 ..< stringArray.count { + validIds.append(i) + + let itemView: StackView + var itemTransition = transition + if let current = self.itemViews[i] { + itemView = current + } else { + itemTransition = transition.withAnimation(.none) + itemView = StackView() + itemView.color = self.color + self.itemViews[i] = itemView + self.addSubview(itemView) + } + + let digit = Int32(stringArray[i]) ?? 0 + itemView.update(value: digit, isFirst: i == stringArray.count - 1, isLast: i == 0, transition: transition) + + itemTransition.setFrame( + view: itemView, + frame: CGRect(x: totalWidth - labelWidth * CGFloat(i + 1), y: 0.0, width: labelWidth, height: labelHeight) + ) + } + + var removeIds: [Int] = [] + for (id, itemView) in self.itemViews { + if !validIds.contains(id) { + removeIds.append(id) + + transition.setAlpha(view: itemView, alpha: 0.0, completion: { _ in + itemView.removeFromSuperview() + }) + } + } + for id in removeIds { + self.itemViews.removeValue(forKey: id) + } + return CGSize(width: totalWidth, height: labelHeight) + } +} diff --git a/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoScreen.swift b/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoScreen.swift index 1caa1447d7..f499db410e 100644 --- a/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoScreen.swift +++ b/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoScreen.swift @@ -1382,7 +1382,8 @@ private func infoItems(data: PeerInfoScreenData?, context: AccountContext, prese url: "", simple: true, source: .generic, - skipTermsOfService: true + skipTermsOfService: true, + payload: nil ) }) } diff --git a/submodules/TelegramUI/Sources/ApplicationContext.swift b/submodules/TelegramUI/Sources/ApplicationContext.swift index 89264f0f67..00ef0e1e1e 100644 --- a/submodules/TelegramUI/Sources/ApplicationContext.swift +++ b/submodules/TelegramUI/Sources/ApplicationContext.swift @@ -945,8 +945,8 @@ final class AuthorizedApplicationContext { chatLocation = .peer(peer) } - if openAppIfAny, let parentController = self.rootController.viewControllers.last as? ViewController { - self.context.sharedContext.openWebApp(context: self.context, parentController: parentController, updatedPresentationData: nil, peer: peer, threadId: nil, buttonText: "", url: "", simple: true, source: .generic, skipTermsOfService: true) + if openAppIfAny, case let .user(user) = peer, let botInfo = user.botInfo, botInfo.flags.contains(.hasWebApp), let parentController = self.rootController.viewControllers.last as? ViewController { + self.context.sharedContext.openWebApp(context: self.context, parentController: parentController, updatedPresentationData: nil, peer: peer, threadId: nil, buttonText: "", url: "", simple: true, source: .generic, skipTermsOfService: true, payload: nil) } else { self.context.sharedContext.navigateToChatController(NavigateToChatControllerParams(navigationController: self.rootController, context: self.context, chatLocation: chatLocation, subject: isOutgoingMessage ? messageId.flatMap { .message(id: .id($0), highlight: ChatControllerSubject.MessageHighlight(quote: nil), timecode: nil, setupReply: false) } : nil, activateInput: activateInput ? .text : nil)) } diff --git a/submodules/TelegramUI/Sources/Chat/ChatControllerOpenWebApp.swift b/submodules/TelegramUI/Sources/Chat/ChatControllerOpenWebApp.swift index b9a165de76..e2d930eb90 100644 --- a/submodules/TelegramUI/Sources/Chat/ChatControllerOpenWebApp.swift +++ b/submodules/TelegramUI/Sources/Chat/ChatControllerOpenWebApp.swift @@ -14,7 +14,7 @@ import UndoUI import UrlHandling import TelegramPresentationData -func openWebAppImpl(context: AccountContext, parentController: ViewController, updatedPresentationData: (initial: PresentationData, signal: Signal)?, peer: EnginePeer, threadId: Int64?, buttonText: String, url: String, simple: Bool, source: ChatOpenWebViewSource, skipTermsOfService: Bool) { +func openWebAppImpl(context: AccountContext, parentController: ViewController, updatedPresentationData: (initial: PresentationData, signal: Signal)?, peer: EnginePeer, threadId: Int64?, buttonText: String, url: String, simple: Bool, source: ChatOpenWebViewSource, skipTermsOfService: Bool, payload: String?) { let presentationData: PresentationData if let parentController = parentController as? ChatControllerImpl { presentationData = parentController.presentationData @@ -195,7 +195,7 @@ func openWebAppImpl(context: AccountContext, parentController: ViewController, u } else { source = url.isEmpty ? .generic : .simple } - let params = WebAppParameters(source: source, peerId: peer.id, botId: botId, botName: botName, botVerified: botVerified, url: result.url, queryId: nil, payload: nil, buttonText: buttonText, keepAliveSignal: nil, forceHasSettings: false, fullSize: result.flags.contains(.fullSize)) + let params = WebAppParameters(source: source, peerId: peer.id, botId: botId, botName: botName, botVerified: botVerified, url: result.url, queryId: nil, payload: payload, buttonText: buttonText, keepAliveSignal: nil, forceHasSettings: false, fullSize: result.flags.contains(.fullSize)) let controller = standaloneWebAppController(context: context, updatedPresentationData: updatedPresentationData, params: params, threadId: threadId, openUrl: { [weak parentController] url, concealed, commit in ChatControllerImpl.botOpenUrl(context: context, peerId: peer.id, controller: parentController as? ChatControllerImpl, url: url, concealed: concealed, present: { c, a in presentImpl?(c, a) @@ -310,7 +310,7 @@ public extension ChatControllerImpl { } self.chatDisplayNode.dismissInput() - self.context.sharedContext.openWebApp(context: self.context, parentController: self, updatedPresentationData: self.updatedPresentationData, peer: EnginePeer(peer), threadId: self.chatLocation.threadId, buttonText: buttonText, url: url, simple: simple, source: source, skipTermsOfService: false) + self.context.sharedContext.openWebApp(context: self.context, parentController: self, updatedPresentationData: self.updatedPresentationData, peer: EnginePeer(peer), threadId: self.chatLocation.threadId, buttonText: buttonText, url: url, simple: simple, source: source, skipTermsOfService: false, payload: nil) } static func botRequestSwitchInline(context: AccountContext, controller: ChatControllerImpl?, peerId: EnginePeer.Id, botAddress: String, query: String, chatTypes: [ReplyMarkupButtonRequestPeerType]?, completion: @escaping () -> Void) -> Void { @@ -561,7 +561,7 @@ public extension ChatControllerImpl { } }) } else { - self.context.sharedContext.openWebApp(context: self.context, parentController: self, updatedPresentationData: self.updatedPresentationData, peer: botPeer, threadId: nil, buttonText: "", url: "", simple: true, source: .generic, skipTermsOfService: false) + self.context.sharedContext.openWebApp(context: self.context, parentController: self, updatedPresentationData: self.updatedPresentationData, peer: botPeer, threadId: nil, buttonText: "", url: "", simple: true, source: .generic, skipTermsOfService: false, payload: payload) } } } diff --git a/submodules/TelegramUI/Sources/ChatController.swift b/submodules/TelegramUI/Sources/ChatController.swift index 4805717a80..a4a0dff681 100644 --- a/submodules/TelegramUI/Sources/ChatController.swift +++ b/submodules/TelegramUI/Sources/ChatController.swift @@ -9721,7 +9721,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G commit() }) } else { - strongSelf.context.sharedContext.openWebApp(context: strongSelf.context, parentController: strongSelf, updatedPresentationData: strongSelf.updatedPresentationData, peer: peer, threadId: nil, buttonText: "", url: "", simple: true, source: .generic, skipTermsOfService: false) + strongSelf.context.sharedContext.openWebApp(context: strongSelf.context, parentController: strongSelf, updatedPresentationData: strongSelf.updatedPresentationData, peer: peer, threadId: nil, buttonText: "", url: "", simple: true, source: .generic, skipTermsOfService: false, payload: botAppStart.payload) commit() } } diff --git a/submodules/TelegramUI/Sources/SharedAccountContext.swift b/submodules/TelegramUI/Sources/SharedAccountContext.swift index 60de3e50ed..8213dafa0c 100644 --- a/submodules/TelegramUI/Sources/SharedAccountContext.swift +++ b/submodules/TelegramUI/Sources/SharedAccountContext.swift @@ -2827,8 +2827,8 @@ public final class SharedAccountContextImpl: SharedAccountContext { return MiniAppListScreen(context: context, initialData: initialData as! MiniAppListScreen.InitialData) } - public func openWebApp(context: AccountContext, parentController: ViewController, updatedPresentationData: (initial: PresentationData, signal: Signal)?, peer: EnginePeer, threadId: Int64?, buttonText: String, url: String, simple: Bool, source: ChatOpenWebViewSource, skipTermsOfService: Bool) { - openWebAppImpl(context: context, parentController: parentController, updatedPresentationData: updatedPresentationData, peer: peer, threadId: threadId, buttonText: buttonText, url: url, simple: simple, source: source, skipTermsOfService: skipTermsOfService) + public func openWebApp(context: AccountContext, parentController: ViewController, updatedPresentationData: (initial: PresentationData, signal: Signal)?, peer: EnginePeer, threadId: Int64?, buttonText: String, url: String, simple: Bool, source: ChatOpenWebViewSource, skipTermsOfService: Bool, payload: String?) { + openWebAppImpl(context: context, parentController: parentController, updatedPresentationData: updatedPresentationData, peer: peer, threadId: threadId, buttonText: buttonText, url: url, simple: simple, source: source, skipTermsOfService: skipTermsOfService, payload: payload) } }