Swiftgram/submodules/PremiumUI/Sources/PremiumBoostLevelsScreen.swift
Ilya Laktyushin a8cff5c5e0 Group boosts
2024-02-07 17:03:02 +04:00

2610 lines
120 KiB
Swift

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 BalancedTextComponent
import BundleIconComponent
import Markdown
import TextFormat
import SolidRoundedButtonComponent
import BlurredBackgroundComponent
import UndoUI
import ConfettiEffect
func requiredBoostSubjectLevel(subject: BoostSubject, group: Bool, context: AccountContext, configuration: PremiumConfiguration) -> Int32 {
switch subject {
case .stories:
return 1
case let .channelReactions(reactionCount):
return reactionCount
case let .nameColors(colors):
if let value = context.peerNameColors.nameColorsChannelMinRequiredBoostLevel[colors.rawValue] {
return value
}
return 1
case .nameIcon:
return configuration.minChannelNameIconLevel
case let .profileColors(colors):
if group {
if let value = context.peerNameColors.profileColorsGroupMinRequiredBoostLevel[colors.rawValue] {
return value
}
} else {
return configuration.minChannelProfileColorLevel
}
return 1
case .profileIcon:
return group ? configuration.minGroupProfileIconLevel : configuration.minChannelProfileIconLevel
case .emojiStatus:
return group ? configuration.minGroupEmojiStatusLevel : configuration.minChannelEmojiStatusLevel
case .wallpaper:
return group ? configuration.minGroupWallpaperLevel : configuration.minChannelWallpaperLevel
case .customWallpaper:
return group ? configuration.minGroupCustomWallpaperLevel : configuration.minChannelCustomWallpaperLevel
case .audioTranscription:
return configuration.minGroupAudioTranscriptionLevel
case .emojiPack:
return configuration.minGroupEmojiPackLevel
}
}
public enum BoostSubject: Equatable {
case stories
case channelReactions(reactionCount: Int32)
case nameColors(colors: PeerNameColor)
case nameIcon
case profileColors(colors: PeerNameColor)
case profileIcon
case emojiStatus
case wallpaper
case customWallpaper
case audioTranscription
case emojiPack
public func requiredLevel(group: Bool, context: AccountContext, configuration: PremiumConfiguration) -> Int32 {
return requiredBoostSubjectLevel(subject: self, group: group, context: context, configuration: configuration)
}
}
private final class LevelHeaderComponent: CombinedComponent {
let theme: PresentationTheme
let text: String
init(
theme: PresentationTheme,
text: String
) {
self.theme = theme
self.text = text
}
static func ==(lhs: LevelHeaderComponent, rhs: LevelHeaderComponent) -> Bool {
if lhs.theme !== rhs.theme {
return false
}
if lhs.text != rhs.text {
return false
}
return true
}
static var body: Body {
let background = Child(RoundedRectangle.self)
let text = Child(MultilineTextComponent.self)
let leftLine = Child(Rectangle.self)
let rightLine = Child(Rectangle.self)
return { context in
let component = context.component
let outerInset: CGFloat = 28.0
let innerInset: CGFloat = 9.0
let height: CGFloat = 50.0
let backgroundHeight: CGFloat = 34.0
let text = text.update(
component: MultilineTextComponent(
text: .plain(NSAttributedString(string: component.text, font: Font.semibold(15.0), textColor: .white)),
horizontalAlignment: .center
),
availableSize: context.availableSize,
transition: .immediate
)
let backgroundWidth: CGFloat = floor(text.size.width + 21.0)
let background = background.update(
component: RoundedRectangle(colors: [UIColor(rgb: 0x9076ff), UIColor(rgb: 0xbc6de8)], cornerRadius: backgroundHeight / 2.0, gradientDirection: .horizontal),
availableSize: CGSize(width: backgroundWidth, height: backgroundHeight),
transition: .immediate
)
context.add(background
.position(CGPoint(x: context.availableSize.width / 2.0, y: height / 2.0))
)
context.add(text
.position(CGPoint(x: context.availableSize.width / 2.0, y: height / 2.0))
)
let remainingWidth = (context.availableSize.width - background.size.width) / 2.0
let lineSize = remainingWidth - outerInset - innerInset
let lineWidth = 1.0 - UIScreenPixel
let leftLine = leftLine.update(
component: Rectangle(
color: component.theme.actionSheet.secondaryTextColor.withMultipliedAlpha(0.5)
),
availableSize: CGSize(width: lineSize, height: lineWidth),
transition: .immediate
)
context.add(leftLine
.position(CGPoint(x: outerInset + lineSize / 2.0, y: height / 2.0))
)
let rightLine = rightLine.update(
component: Rectangle(
color: component.theme.actionSheet.secondaryTextColor.withMultipliedAlpha(0.5)
),
availableSize: CGSize(width: lineSize, height: lineWidth),
transition: .immediate
)
context.add(rightLine
.position(CGPoint(x: context.availableSize.width - outerInset - lineSize / 2.0, y: height / 2.0))
)
return CGSize(width: context.availableSize.width, height: height)
}
}
}
private final class LevelPerkComponent: CombinedComponent {
let theme: PresentationTheme
let iconName: String
let text: String
init(
theme: PresentationTheme,
iconName: String,
text: String
) {
self.theme = theme
self.iconName = iconName
self.text = text
}
static func ==(lhs: LevelPerkComponent, rhs: LevelPerkComponent) -> Bool {
if lhs.theme !== rhs.theme {
return false
}
if lhs.iconName != rhs.iconName {
return false
}
if lhs.text != rhs.text {
return false
}
return true
}
static var body: Body {
let icon = Child(BundleIconComponent.self)
let text = Child(MultilineTextComponent.self)
return { context in
let component = context.component
let outerInset: CGFloat = 28.0
let height: CGFloat = 44.0
let icon = icon.update(
component: BundleIconComponent(
name: component.iconName,
tintColor: component.theme.actionSheet.controlAccentColor
),
availableSize: context.availableSize,
transition: .immediate
)
context.add(icon
.position(CGPoint(x: outerInset + icon.size.width / 2.0, y: height / 2.0))
)
let text = text.update(
component: MultilineTextComponent(
text: .plain(NSAttributedString(string: component.text, font: Font.semibold(15.0), textColor: component.theme.actionSheet.primaryTextColor)),
horizontalAlignment: .center
),
availableSize: CGSize(width: context.availableSize.width, height: context.availableSize.height),
transition: .immediate
)
context.add(text
.position(CGPoint(x: outerInset * 2.0 + 18.0 + text.size.width / 2.0, y: height / 2.0))
)
return CGSize(width: context.availableSize.width, height: height)
}
}
}
private final class LevelSectionComponent: CombinedComponent {
enum Perk: Equatable {
case story(Int32)
case reaction(Int32)
case nameColor(Int32)
case profileColor(Int32)
case profileIcon
case linkColor(Int32)
case linkIcon
case emojiStatus
case wallpaper(Int32)
case customWallpaper
case audioTranscription
case emojiPack
func title(strings: PresentationStrings, isGroup: Bool) -> String {
switch self {
case let .story(value):
return strings.ChannelBoost_Table_StoriesPerDay(value)
case let .reaction(value):
return strings.ChannelBoost_Table_CustomReactions(value)
case let .nameColor(value):
return strings.ChannelBoost_Table_NameColor(value)
case let .profileColor(value):
return isGroup ? strings.ChannelBoost_Table_Group_ProfileColor(value) : strings.ChannelBoost_Table_ProfileColor(value)
case .profileIcon:
return isGroup ? strings.ChannelBoost_Table_Group_ProfileLogo : strings.ChannelBoost_Table_ProfileLogo
case let .linkColor(value):
return strings.ChannelBoost_Table_StyleForHeaders(value)
case .linkIcon:
return strings.ChannelBoost_Table_HeadersLogo
case .emojiStatus:
return strings.ChannelBoost_Table_EmojiStatus
case let .wallpaper(value):
return isGroup ? strings.ChannelBoost_Table_Group_Wallpaper(value) : strings.ChannelBoost_Table_Wallpaper(value)
case .customWallpaper:
return isGroup ? strings.ChannelBoost_Table_Group_CustomWallpaper : strings.ChannelBoost_Table_CustomWallpaper
case .audioTranscription:
return "Voice-to-Text Conversion"
case .emojiPack:
return "Custom Emojipack"
}
}
var iconName: String {
switch self {
case .story:
return "Premium/BoostPerk/Story"
case .reaction:
return "Premium/BoostPerk/Reaction"
case .nameColor:
return "Premium/BoostPerk/NameColor"
case .profileColor:
return "Premium/BoostPerk/CoverColor"
case .profileIcon:
return "Premium/BoostPerk/CoverLogo"
case .linkColor:
return "Premium/BoostPerk/LinkColor"
case .linkIcon:
return "Premium/BoostPerk/LinkLogo"
case .emojiStatus:
return "Premium/BoostPerk/EmojiStatus"
case .wallpaper:
return "Premium/BoostPerk/Wallpaper"
case .customWallpaper:
return "Premium/BoostPerk/CustomWallpaper"
case .audioTranscription:
return "Premium/BoostPerk/AudioTranscription"
case .emojiPack:
return "Premium/BoostPerk/EmojiPack"
}
}
}
let theme: PresentationTheme
let strings: PresentationStrings
let level: Int32
let isFirst: Bool
let perks: [Perk]
let isGroup: Bool
init(
theme: PresentationTheme,
strings: PresentationStrings,
level: Int32,
isFirst: Bool,
perks: [Perk],
isGroup: Bool
) {
self.theme = theme
self.strings = strings
self.level = level
self.isFirst = isFirst
self.perks = perks
self.isGroup = isGroup
}
static func ==(lhs: LevelSectionComponent, rhs: LevelSectionComponent) -> Bool {
if lhs.theme !== rhs.theme {
return false
}
if lhs.level != rhs.level {
return false
}
if lhs.isFirst != rhs.isFirst {
return false
}
if lhs.perks != rhs.perks {
return false
}
if lhs.isGroup != rhs.isGroup {
return false
}
return true
}
static var body: Body {
let header = Child(LevelHeaderComponent.self)
let list = Child(List<Empty>.self)
return { context in
let component = context.component
let header = header.update(
component: LevelHeaderComponent(theme: component.theme, text: component.isFirst ? component.strings.ChannelBoost_Table_LevelUnlocks(component.level) : component.strings.ChannelBoost_Table_Level(component.level)),
availableSize: context.availableSize,
transition: .immediate
)
context.add(header
.position(CGPoint(x: context.availableSize.width / 2.0, y: header.size.height / 2.0)))
let items: [AnyComponentWithIdentity<Empty>] = component.perks.enumerated().map { index, value in
AnyComponentWithIdentity(
id: index, component: AnyComponent(
LevelPerkComponent(
theme: component.theme,
iconName: value.iconName,
text: value.title(strings: component.strings, isGroup: component.isGroup)
)
)
)
}
let list = list.update(
component: List(items),
availableSize: CGSize(width: context.availableSize.width, height: 10000.0),
transition: context.transition
)
context.add(list
.position(CGPoint(x: context.availableSize.width / 2.0, y: header.size.height + list.size.height / 2.0)))
return CGSize(width: context.availableSize.width, height: header.size.height + list.size.height)
}
}
}
private final class SheetContent: CombinedComponent {
typealias EnvironmentType = (Empty, ScrollChildEnvironment)
let context: AccountContext
let theme: PresentationTheme
let strings: PresentationStrings
let insets: UIEdgeInsets
let peerId: EnginePeer.Id
let isGroup: Bool
let mode: PremiumBoostLevelsScreen.Mode
let status: ChannelBoostStatus?
let boostState: InternalBoostState.DisplayData?
let boost: () -> Void
let copyLink: (String) -> Void
let dismiss: () -> Void
let openStats: (() -> Void)?
let openGift: (() -> Void)?
let openPeer: ((EnginePeer) -> Void)?
init(context: AccountContext,
theme: PresentationTheme,
strings: PresentationStrings,
insets: UIEdgeInsets,
peerId: EnginePeer.Id,
isGroup: Bool,
mode: PremiumBoostLevelsScreen.Mode,
status: ChannelBoostStatus?,
boostState: InternalBoostState.DisplayData?,
boost: @escaping () -> Void,
copyLink: @escaping (String) -> Void,
dismiss: @escaping () -> Void,
openStats: (() -> Void)?,
openGift: (() -> Void)?,
openPeer: ((EnginePeer) -> Void)?
) {
self.context = context
self.theme = theme
self.strings = strings
self.insets = insets
self.peerId = peerId
self.isGroup = isGroup
self.mode = mode
self.status = status
self.boostState = boostState
self.boost = boost
self.copyLink = copyLink
self.dismiss = dismiss
self.openStats = openStats
self.openGift = openGift
self.openPeer = openPeer
}
static func ==(lhs: SheetContent, rhs: SheetContent) -> Bool {
if lhs.context !== rhs.context {
return false
}
if lhs.theme !== rhs.theme {
return false
}
if lhs.insets != rhs.insets {
return false
}
if lhs.peerId != rhs.peerId {
return false
}
if lhs.isGroup != rhs.isGroup {
return false
}
if lhs.mode != rhs.mode {
return false
}
if lhs.status != rhs.status {
return false
}
if lhs.boostState != rhs.boostState {
return false
}
return true
}
final class State: ComponentState {
var cachedChevronImage: (UIImage, PresentationTheme)?
var cachedIconImage: UIImage?
private(set) var peer: EnginePeer?
private(set) var memberPeer: EnginePeer?
private var disposable: Disposable?
private var memberDisposable: Disposable?
init(context: AccountContext, peerId: EnginePeer.Id, userId: EnginePeer.Id?) {
super.init()
self.disposable = (context.engine.data.get(TelegramEngine.EngineData.Item.Peer.Peer(id: peerId))
|> deliverOnMainQueue).startStrict(next: { [weak self] peer in
guard let self else {
return
}
self.peer = peer
self.updated()
})
if let userId {
self.memberDisposable = (context.engine.data.get(TelegramEngine.EngineData.Item.Peer.Peer(id: userId))
|> deliverOnMainQueue).startStrict(next: { [weak self] peer in
guard let self else {
return
}
self.memberPeer = peer
self.updated()
})
}
}
deinit {
self.disposable?.dispose()
self.memberDisposable?.dispose()
}
}
func makeState() -> State {
var userId: EnginePeer.Id?
if case let .user(mode) = mode, case let .groupPeer(peerId, _) = mode {
userId = peerId
}
return State(context: self.context, peerId: self.peerId, userId: userId)
}
static var body: Body {
let iconBackground = Child(Image.self)
let icon = Child(BundleIconComponent.self)
//let icon = Child(LottieComponent.self)
let peerShortcut = Child(Button.self)
let text = Child(BalancedTextComponent.self)
let alternateText = Child(List<Empty>.self)
let limit = Child(PremiumLimitDisplayComponent.self)
let linkButton = Child(SolidRoundedButtonComponent.self)
let boostButton = Child(SolidRoundedButtonComponent.self)
let copyButton = Child(SolidRoundedButtonComponent.self)
let orLeftLine = Child(Rectangle.self)
let orRightLine = Child(Rectangle.self)
let orText = Child(MultilineTextComponent.self)
let giftText = Child(BalancedTextComponent.self)
let levels = Child(List<Empty>.self)
return { context in
let component = context.component
let theme = component.theme
let strings = component.strings
let state = context.state
let premiumConfiguration = PremiumConfiguration.with(appConfiguration: component.context.currentAppConfiguration.with { $0 })
let sideInset: CGFloat = 16.0 // + environment.safeInsets.left
let textSideInset: CGFloat = 32.0 // + environment.safeInsets.left
let iconName = "Premium/Boost"
let peerName = state.peer?.compactDisplayTitle ?? ""
let isGroup = component.isGroup
let level: Int
let boosts: Int
let remaining: Int?
let progress: CGFloat
let myBoostCount: Int
if let boostState = component.boostState {
level = Int(boostState.level)
boosts = Int(boostState.boosts)
if let nextLevelBoosts = boostState.nextLevelBoosts {
remaining = Int(nextLevelBoosts - boostState.boosts)
progress = CGFloat(boostState.boosts - boostState.currentLevelBoosts) / CGFloat(nextLevelBoosts - boostState.currentLevelBoosts)
} else {
remaining = nil
progress = 1.0
}
myBoostCount = Int(boostState.myBoostCount)
} else if let status = component.status {
level = status.level
boosts = status.boosts
if let nextLevelBoosts = status.nextLevelBoosts {
remaining = nextLevelBoosts - status.boosts
progress = CGFloat(status.boosts - status.currentLevelBoosts) / CGFloat(nextLevelBoosts - status.currentLevelBoosts)
} else {
remaining = nil
progress = 1.0
}
myBoostCount = 0
} else {
level = 0
boosts = 0
remaining = nil
progress = 0.0
myBoostCount = 0
}
let badgeText = "\(boosts)"
var textString = ""
var isCurrent = false
switch component.mode {
case let .owner(subject):
if let remaining {
var needsSecondParagraph = true
if let subject {
let storiesString = strings.ChannelBoost_StoriesPerDay(Int32(level) + 1)
let valueString = strings.ChannelBoost_MoreBoosts(Int32(remaining))
switch subject {
case .stories:
if level == 0 {
textString = strings.ChannelBoost_EnableStoriesText(valueString).string
} else {
textString = strings.ChannelBoost_IncreaseLimitText(valueString, storiesString).string
}
needsSecondParagraph = false
case let .channelReactions(reactionCount):
textString = strings.ChannelBoost_CustomReactionsText("\(reactionCount)", "\(reactionCount)").string
needsSecondParagraph = false
case .nameColors:
let colorLevel = subject.requiredLevel(group: isGroup, context: context.component.context, configuration: premiumConfiguration)
textString = strings.ChannelBoost_EnableNameColorLevelText("\(colorLevel)").string
case .nameIcon:
textString = strings.ChannelBoost_EnableNameIconLevelText("\(premiumConfiguration.minChannelNameIconLevel)").string
case .profileColors:
textString = isGroup ? strings.GroupBoost_EnableProfileColorLevelText("\(premiumConfiguration.minChannelProfileColorLevel)").string : strings.ChannelBoost_EnableProfileColorLevelText("\(premiumConfiguration.minChannelProfileColorLevel)").string
case .profileIcon:
textString = isGroup ? strings.GroupBoost_EnableProfileIconLevelText("\(premiumConfiguration.minChannelProfileIconLevel)").string : strings.ChannelBoost_EnableProfileIconLevelText("\(premiumConfiguration.minChannelProfileIconLevel)").string
case .emojiStatus:
textString = isGroup ? strings.GroupBoost_EnableEmojiStatusLevelText("\(premiumConfiguration.minChannelEmojiStatusLevel)").string : strings.ChannelBoost_EnableEmojiStatusLevelText("\(premiumConfiguration.minChannelEmojiStatusLevel)").string
case .wallpaper:
textString = isGroup ? strings.GroupBoost_EnableWallpaperLevelText("\(premiumConfiguration.minChannelWallpaperLevel)").string : strings.ChannelBoost_EnableWallpaperLevelText("\(premiumConfiguration.minChannelWallpaperLevel)").string
case .customWallpaper:
textString = isGroup ? strings.GroupBoost_EnableCustomWallpaperLevelText("\(premiumConfiguration.minChannelCustomWallpaperLevel)").string : strings.ChannelBoost_EnableCustomWallpaperLevelText("\(premiumConfiguration.minChannelCustomWallpaperLevel)").string
case .audioTranscription:
textString = ""
case .emojiPack:
textString = strings.GroupBoost_EnableEmojiPackLevelText("\(premiumConfiguration.minGroupEmojiPackLevel)").string
}
} else {
let boostsString = strings.ChannelBoost_MoreBoostsNeeded_Boosts(Int32(remaining))
if myBoostCount > 0 {
textString = strings.ChannelBoost_MoreBoostsNeeded_Boosted_Text(boostsString).string
} else {
textString = strings.ChannelBoost_MoreBoostsNeeded_Text(peerName, boostsString).string
}
}
if needsSecondParagraph {
textString += "\n\n\(strings.ChannelBoost_AskToBoost)"
}
} else {
textString = strings.ChannelBoost_MaxLevelReached_Text(peerName, "\(level)").string
// let storiesString = strings.ChannelBoost_StoriesPerDay(Int32(level))
// textString = strings.ChannelBoost_MaxLevelReachedTextAuthor("\(level)", storiesString).string
}
case let .user(mode):
switch mode {
case let .groupPeer(_, peerBoostCount):
let memberName = state.memberPeer?.compactDisplayTitle ?? ""
//TODO:localize
if myBoostCount > 0 {
if let remaining {
let boostsString = strings.ChannelBoost_MoreBoostsNeeded_Boosts(Int32(remaining))
textString = "**\(memberName)** boosted the group **\(peerBoostCount)** times. \(strings.ChannelBoost_MoreBoostsNeeded_Boosted_Text(boostsString).string)"
} else {
textString = "**\(memberName)** boosted the group **\(peerBoostCount)** times."
}
} else {
textString = "**\(memberName)** boosted the group **\(peerBoostCount)** times. Boost **\(peerName)** to help it unlock new features and get a booster **badge** for your messages."
}
isCurrent = true
case let .unrestrict(unrestrictCount):
textString = "Boost the group **\(unrestrictCount)** times to remove messaging restrictions. Your boosts will help **\(peerName)** to unlock new features."
isCurrent = true
default:
if let remaining {
let boostsString = strings.ChannelBoost_MoreBoostsNeeded_Boosts(Int32(remaining))
if myBoostCount > 0 {
textString = strings.ChannelBoost_MoreBoostsNeeded_Boosted_Text(boostsString).string
} else {
textString = strings.ChannelBoost_MoreBoostsNeeded_Text(peerName, boostsString).string
}
} else {
textString = strings.ChannelBoost_MaxLevelReached_Text(peerName, "\(level)").string
}
isCurrent = mode == .current
}
case .features:
textString = strings.GroupBoost_AdditionalFeaturesText
}
let defaultTitle = strings.ChannelBoost_Level("\(level)").string
let defaultValue = ""
let premiumValue = strings.ChannelBoost_Level("\(level + 1)").string
let premiumTitle = ""
var contentSize: CGSize = CGSize(width: context.availableSize.width, height: 44.0)
let textFont = Font.regular(15.0)
let boldTextFont = Font.semibold(15.0)
let textColor = theme.actionSheet.primaryTextColor
let linkColor = theme.actionSheet.controlAccentColor
let markdownAttributes = MarkdownAttributes(body: MarkdownAttributeSet(font: textFont, textColor: textColor), bold: MarkdownAttributeSet(font: boldTextFont, textColor: textColor), link: MarkdownAttributeSet(font: textFont, textColor: linkColor), linkAttribute: { contents in
return (TelegramTextAttributes.URL, contents)
})
let gradientColors = [
UIColor(rgb: 0x0077ff),
UIColor(rgb: 0x6b93ff),
UIColor(rgb: 0x8878ff),
UIColor(rgb: 0xe46ace)
]
let buttonGradientColors = [
UIColor(rgb: 0x007afe),
UIColor(rgb: 0x5494ff)
]
if case let .user(mode) = component.mode, case .external = mode, let peer = state.peer {
contentSize.height += 10.0
let peerShortcut = peerShortcut.update(
component: Button(
content: AnyComponent(
PeerShortcutComponent(
context: component.context,
theme: component.theme,
peer: peer
)
),
action: {
component.dismiss()
Queue.mainQueue().after(0.35) {
component.openPeer?(peer)
}
}
),
availableSize: CGSize(width: context.availableSize.width - 32.0, height: context.availableSize.height),
transition: .immediate
)
context.add(peerShortcut
.position(CGPoint(x: context.availableSize.width / 2.0, y: contentSize.height + peerShortcut.size.height / 2.0))
)
contentSize.height += peerShortcut.size.height + 2.0
}
if case .features = component.mode {
contentSize.height -= 14.0
let iconSize = CGSize(width: 90.0, height: 90.0)
let gradientImage: UIImage
if let current = state.cachedIconImage {
gradientImage = current
} else {
gradientImage = generateFilledCircleImage(diameter: iconSize.width, color: theme.actionSheet.controlAccentColor)!
context.state.cachedIconImage = gradientImage
}
let iconBackground = iconBackground.update(
component: Image(image: gradientImage),
availableSize: iconSize,
transition: .immediate
)
context.add(iconBackground
.position(CGPoint(x: context.availableSize.width / 2.0, y: contentSize.height + iconBackground.size.height / 2.0))
)
let icon = icon.update(
component: BundleIconComponent(
name: "Premium/BoostLarge",
tintColor: .white
),
availableSize: CGSize(width: 90.0, height: 90.0),
transition: .immediate
)
// let icon = icon.update(
// component: LottieComponent(
// content: LottieComponent.AppBundleContent(name: iconName),
// playOnce: state.playOnce
// ),
// availableSize: CGSize(width: 70, height: 70),
// transition: .immediate
// )
context.add(icon
.position(CGPoint(x: context.availableSize.width / 2.0, y: contentSize.height + iconBackground.size.height / 2.0))
)
contentSize.height += iconSize.height
contentSize.height += 52.0
} else {
let limit = limit.update(
component: PremiumLimitDisplayComponent(
inactiveColor: theme.list.itemBlocksSeparatorColor.withAlphaComponent(0.3),
activeColors: gradientColors,
inactiveTitle: defaultTitle,
inactiveValue: defaultValue,
inactiveTitleColor: theme.list.itemPrimaryTextColor,
activeTitle: premiumTitle,
activeValue: premiumValue,
activeTitleColor: .white,
badgeIconName: iconName,
badgeText: badgeText,
badgePosition: progress,
badgeGraphPosition: progress,
invertProgress: true,
isPremiumDisabled: false
),
availableSize: CGSize(width: context.availableSize.width - sideInset * 2.0, height: context.availableSize.height),
transition: context.transition
)
context.add(limit
.position(CGPoint(x: context.availableSize.width / 2.0, y: contentSize.height + limit.size.height / 2.0))
)
contentSize.height += limit.size.height + 23.0
}
if myBoostCount > 0 {
let alternateTitle = isCurrent ? strings.ChannelBoost_YouBoostedChannelText(peerName).string : strings.ChannelBoost_YouBoostedOtherChannelText
var alternateBadge: String?
if myBoostCount > 1 {
alternateBadge = "X\(myBoostCount)"
}
let alternateText = alternateText.update(
component: List(
[
AnyComponentWithIdentity(
id: "title",
component: AnyComponent(
BoostedTitleContent(text: NSAttributedString(string: alternateTitle, font: Font.semibold(15.0), textColor: textColor), badge: alternateBadge)
)
),
AnyComponentWithIdentity(
id: "text",
component: AnyComponent(
BalancedTextComponent(
text: .markdown(text: textString, attributes: markdownAttributes),
horizontalAlignment: .center,
maximumNumberOfLines: 0,
lineSpacing: 0.1
)
)
)
],
centerAlignment: true
),
availableSize: CGSize(width: context.availableSize.width - textSideInset * 2.0, height: context.availableSize.height),
transition: .immediate
)
context.add(alternateText
.position(CGPoint(x: context.availableSize.width / 2.0, y: contentSize.height + alternateText.size.height / 2.0))
.appear(Transition.Appear({ _, view, transition in
transition.animatePosition(view: view, from: CGPoint(x: 0.0, y: 64.0), to: .zero, additive: true)
transition.animateAlpha(view: view, from: 0.0, to: 1.0)
}))
.disappear(Transition.Disappear({ view, transition, completion in
view.superview?.sendSubviewToBack(view)
transition.animatePosition(view: view, from: .zero, to: CGPoint(x: 0.0, y: -64.0), additive: true)
transition.setAlpha(view: view, alpha: 0.0, completion: { _ in
completion()
})
}))
)
contentSize.height += alternateText.size.height + 20.0
} else {
let text = text.update(
component: BalancedTextComponent(
text: .markdown(text: textString, attributes: markdownAttributes),
horizontalAlignment: .center,
maximumNumberOfLines: 0,
lineSpacing: 0.2
),
availableSize: CGSize(width: context.availableSize.width - textSideInset * 2.0, height: context.availableSize.height),
transition: .immediate
)
context.add(text
.position(CGPoint(x: context.availableSize.width / 2.0, y: contentSize.height + text.size.height / 2.0))
.appear(Transition.Appear({ _, view, transition in
transition.animatePosition(view: view, from: CGPoint(x: 0.0, y: 64.0), to: .zero, additive: true)
transition.animateAlpha(view: view, from: 0.0, to: 1.0)
}))
.disappear(Transition.Disappear({ view, transition, completion in
view.superview?.sendSubviewToBack(view)
transition.animatePosition(view: view, from: .zero, to: CGPoint(x: 0.0, y: -64.0), additive: true)
transition.setAlpha(view: view, alpha: 0.0, completion: { _ in
completion()
})
}))
)
contentSize.height += text.size.height + 20.0
}
if case .owner = component.mode, let status = component.status {
contentSize.height += 7.0
let linkButton = linkButton.update(
component: SolidRoundedButtonComponent(
title: status.url.replacingOccurrences(of: "https://", with: ""),
theme: SolidRoundedButtonComponent.Theme(
backgroundColor: theme.list.itemBlocksSeparatorColor.withAlphaComponent(0.3),
backgroundColors: [],
foregroundColor: theme.list.itemPrimaryTextColor
),
font: .regular,
fontSize: 17.0,
height: 50.0,
cornerRadius: 10.0,
action: {
component.copyLink(status.url)
component.dismiss()
}
),
availableSize: CGSize(width: context.availableSize.width - sideInset * 2.0, height: 50.0),
transition: context.transition
)
context.add(linkButton
.position(CGPoint(x: context.availableSize.width / 2.0, y: contentSize.height + linkButton.size.height / 2.0))
)
contentSize.height += linkButton.size.height + 16.0
//TODO:localize
let boostButton = boostButton.update(
component: SolidRoundedButtonComponent(
title: "Boost",
theme: SolidRoundedButtonComponent.Theme(
backgroundColor: .black,
backgroundColors: buttonGradientColors,
foregroundColor: .white
),
font: .bold,
fontSize: 17.0,
height: 50.0,
cornerRadius: 10.0,
gloss: false,
iconName: nil,
animationName: nil,
iconPosition: .left,
action: {
component.boost()
}
),
availableSize: CGSize(width: (context.availableSize.width - 8.0 - sideInset * 2.0) / 2.0, height: 50.0),
transition: context.transition
)
let copyButton = copyButton.update(
component: SolidRoundedButtonComponent(
title: "Copy",
theme: SolidRoundedButtonComponent.Theme(
backgroundColor: .black,
backgroundColors: buttonGradientColors,
foregroundColor: .white
),
font: .bold,
fontSize: 17.0,
height: 50.0,
cornerRadius: 10.0,
gloss: false,
iconName: nil,
animationName: nil,
iconPosition: .left,
action: {
component.copyLink(status.url)
component.dismiss()
}
),
availableSize: CGSize(width: (context.availableSize.width - 8.0 - sideInset * 2.0) / 2.0, height: 50.0),
transition: context.transition
)
let boostButtonFrame = CGRect(origin: CGPoint(x: sideInset, y: contentSize.height), size: boostButton.size)
context.add(boostButton
.position(boostButtonFrame.center)
)
let copyButtonFrame = CGRect(origin: CGPoint(x: context.availableSize.width - sideInset - copyButton.size.width, y: contentSize.height), size: copyButton.size)
context.add(copyButton
.position(copyButtonFrame.center)
)
contentSize.height += boostButton.size.height
if premiumConfiguration.giveawayGiftsPurchaseAvailable {
let orText = orText.update(
component: MultilineTextComponent(text: .plain(NSAttributedString(string: strings.ChannelBoost_Or, font: Font.regular(15.0), textColor: textColor.withAlphaComponent(0.8), paragraphAlignment: .center))),
availableSize: CGSize(width: context.availableSize.width - sideInset * 2.0, height: context.availableSize.height),
transition: .immediate
)
context.add(orText
.position(CGPoint(x: context.availableSize.width / 2.0, y: contentSize.height + 27.0))
)
let orLeftLine = orLeftLine.update(
component: Rectangle(color: theme.list.itemBlocksSeparatorColor.withAlphaComponent(0.3)),
availableSize: CGSize(width: 90.0, height: 1.0 - UIScreenPixel),
transition: .immediate
)
context.add(orLeftLine
.position(CGPoint(x: context.availableSize.width / 2.0 - orText.size.width / 2.0 - 11.0 - 45.0, y: contentSize.height + 27.0))
)
let orRightLine = orRightLine.update(
component: Rectangle(color: theme.list.itemBlocksSeparatorColor.withAlphaComponent(0.3)),
availableSize: CGSize(width: 90.0, height: 1.0 - UIScreenPixel),
transition: .immediate
)
context.add(orRightLine
.position(CGPoint(x: context.availableSize.width / 2.0 + orText.size.width / 2.0 + 11.0 + 45.0, y: contentSize.height + 27.0))
)
if state.cachedChevronImage == nil || state.cachedChevronImage?.1 !== theme {
state.cachedChevronImage = (generateTintedImage(image: UIImage(bundleImageName: "Settings/TextArrowRight"), color: linkColor)!, theme)
}
let giftString = isGroup ? strings.Premium_Group_BoostByGiftDescription : strings.Premium_BoostByGiftDescription2
let giftAttributedString = parseMarkdownIntoAttributedString(giftString, attributes: markdownAttributes).mutableCopy() as! NSMutableAttributedString
if let range = giftAttributedString.string.range(of: ">"), let chevronImage = state.cachedChevronImage?.0 {
giftAttributedString.addAttribute(.attachment, value: chevronImage, range: NSRange(range, in: giftAttributedString.string))
}
let giftText = giftText.update(
component: BalancedTextComponent(
text: .plain(giftAttributedString),
horizontalAlignment: .center,
maximumNumberOfLines: 0,
lineSpacing: 0.1,
highlightColor: linkColor.withAlphaComponent(0.2),
highlightAction: { _ in
return nil
},
tapAction: { _, _ in
component.openGift?()
}
),
availableSize: CGSize(width: context.availableSize.width - textSideInset * 2.0, height: context.availableSize.height),
transition: .immediate
)
context.add(giftText
.position(CGPoint(x: context.availableSize.width / 2.0, y: contentSize.height + 50.0 + giftText.size.height / 2.0))
)
contentSize.height += giftText.size.height + 50.0 + 23.0
}
}
var nextLevels: ClosedRange<Int32>?
if level < 10 {
nextLevels = Int32(level) + 1 ... 10
}
var levelItems: [AnyComponentWithIdentity<Empty>] = []
var nameColorsAtLevel: [(Int32, Int32)] = []
var nameColorsCountMap: [Int32: Int32] = [:]
for color in context.component.context.peerNameColors.displayOrder {
if let level = context.component.context.peerNameColors.nameColorsChannelMinRequiredBoostLevel[color] {
if let current = nameColorsCountMap[level] {
nameColorsCountMap[level] = current + 1
} else {
nameColorsCountMap[level] = 1
}
}
}
for (key, value) in nameColorsCountMap {
nameColorsAtLevel.append((key, value))
}
var profileColorsAtLevel: [(Int32, Int32)] = []
var profileColorsCountMap: [Int32: Int32] = [:]
for color in context.component.context.peerNameColors.profileDisplayOrder {
if let level = isGroup ? context.component.context.peerNameColors.profileColorsGroupMinRequiredBoostLevel[color] : context.component.context.peerNameColors.profileColorsChannelMinRequiredBoostLevel[color] {
if let current = profileColorsCountMap[level] {
profileColorsCountMap[level] = current + 1
} else {
profileColorsCountMap[level] = 1
}
}
}
for (key, value) in profileColorsCountMap {
profileColorsAtLevel.append((key, value))
}
var isFeatures = false
if case .features = component.mode {
isFeatures = true
}
if let nextLevels {
for level in nextLevels {
var perks: [LevelSectionComponent.Perk] = []
perks.append(.story(level))
if !isGroup {
perks.append(.reaction(level))
}
var nameColorsCount: Int32 = 0
for (colorLevel, count) in nameColorsAtLevel {
if level >= colorLevel && colorLevel == 1 {
nameColorsCount = count
}
}
if !isGroup && nameColorsCount > 0 {
perks.append(.nameColor(nameColorsCount))
}
if isGroup && level >= requiredBoostSubjectLevel(subject: .audioTranscription, group: isGroup, context: component.context, configuration: premiumConfiguration) {
perks.append(.audioTranscription)
}
var profileColorsCount: Int32 = 0
for (colorLevel, count) in profileColorsAtLevel {
if level >= colorLevel {
profileColorsCount += count
}
}
if profileColorsCount > 0 {
perks.append(.profileColor(profileColorsCount))
}
if level >= requiredBoostSubjectLevel(subject: .profileIcon, group: isGroup, context: component.context, configuration: premiumConfiguration) {
perks.append(.profileIcon)
}
if isGroup && level >= requiredBoostSubjectLevel(subject: .audioTranscription, group: isGroup, context: component.context, configuration: premiumConfiguration) {
perks.append(.emojiPack)
}
var linkColorsCount: Int32 = 0
for (colorLevel, count) in nameColorsAtLevel {
if level >= colorLevel {
linkColorsCount += count
}
}
if !isGroup && linkColorsCount > 0 {
perks.append(.linkColor(linkColorsCount))
}
if !isGroup && level >= requiredBoostSubjectLevel(subject: .nameIcon, group: isGroup, context: component.context, configuration: premiumConfiguration) {
perks.append(.linkIcon)
}
if level >= requiredBoostSubjectLevel(subject: .emojiStatus, group: isGroup, context: component.context, configuration: premiumConfiguration) {
perks.append(.emojiStatus)
}
if level >= requiredBoostSubjectLevel(subject: .wallpaper, group: isGroup, context: component.context, configuration: premiumConfiguration) {
perks.append(.wallpaper(8))
}
if level >= requiredBoostSubjectLevel(subject: .customWallpaper, group: isGroup, context: component.context, configuration: premiumConfiguration) {
perks.append(.customWallpaper)
}
levelItems.append(
AnyComponentWithIdentity(
id: level, component: AnyComponent(
LevelSectionComponent(
theme: component.theme,
strings: component.strings,
level: level,
isFirst: !isFeatures && levelItems.isEmpty,
perks: perks.reversed(),
isGroup: isGroup
)
)
)
)
}
}
if !levelItems.isEmpty {
let levels = levels.update(
component: List(levelItems),
availableSize: CGSize(width: context.availableSize.width, height: 100000.0),
transition: context.transition
)
context.add(levels
.position(CGPoint(x: context.availableSize.width / 2.0, y: contentSize.height + levels.size.height / 2.0 ))
)
contentSize.height += levels.size.height + 80.0
contentSize.height += 60.0
}
return contentSize
}
}
}
private final class BoostLevelsContainerComponent: CombinedComponent {
let context: AccountContext
let theme: PresentationTheme
let strings: PresentationStrings
let peerId: EnginePeer.Id
let mode: PremiumBoostLevelsScreen.Mode
let status: ChannelBoostStatus?
let boostState: InternalBoostState.DisplayData?
let boost: () -> Void
let copyLink: (String) -> Void
let dismiss: () -> Void
let openStats: (() -> Void)?
let openGift: (() -> Void)?
let openPeer: ((EnginePeer) -> Void)?
init(
context: AccountContext,
theme: PresentationTheme,
strings: PresentationStrings,
peerId: EnginePeer.Id,
mode: PremiumBoostLevelsScreen.Mode,
status: ChannelBoostStatus?,
boostState: InternalBoostState.DisplayData?,
boost: @escaping () -> Void,
copyLink: @escaping (String) -> Void,
dismiss: @escaping () -> Void,
openStats: (() -> Void)?,
openGift: (() -> Void)?,
openPeer: ((EnginePeer) -> Void)?
) {
self.context = context
self.theme = theme
self.strings = strings
self.peerId = peerId
self.mode = mode
self.status = status
self.boostState = boostState
self.boost = boost
self.copyLink = copyLink
self.dismiss = dismiss
self.openStats = openStats
self.openGift = openGift
self.openPeer = openPeer
}
static func ==(lhs: BoostLevelsContainerComponent, rhs: BoostLevelsContainerComponent) -> Bool {
if lhs.context !== rhs.context {
return false
}
if lhs.theme !== rhs.theme {
return false
}
if lhs.peerId != rhs.peerId {
return false
}
if lhs.mode != rhs.mode {
return false
}
if lhs.status != rhs.status {
return false
}
if lhs.boostState != rhs.boostState {
return false
}
return true
}
final class State: ComponentState {
var topContentOffset: CGFloat = 0.0
var cachedStatsImage: (UIImage, PresentationTheme)?
var cachedCloseImage: (UIImage, PresentationTheme)?
private var disposable: Disposable?
private(set) var peer: EnginePeer?
init(context: AccountContext, peerId: EnginePeer.Id) {
super.init()
self.disposable = (context.engine.data.get(TelegramEngine.EngineData.Item.Peer.Peer(id: peerId))
|> deliverOnMainQueue).startStrict(next: { [weak self] peer in
guard let self else {
return
}
self.peer = peer
self.updated()
})
}
deinit {
self.disposable?.dispose()
}
}
func makeState() -> State {
return State(context: self.context, peerId: self.peerId)
}
static var body: Body {
let background = Child(Rectangle.self)
let scroll = Child(ScrollComponent<Empty>.self)
let topPanel = Child(BlurredBackgroundComponent.self)
let topSeparator = Child(Rectangle.self)
let title = Child(MultilineTextComponent.self)
let statsButton = Child(Button.self)
let closeButton = Child(Button.self)
return { context in
let state = context.state
let theme = context.component.theme
let strings = context.component.context.sharedContext.currentPresentationData.with { $0 }.strings
let topInset: CGFloat = 56.0
let component = context.component
var isGroup: Bool?
if let peer = state.peer {
if case let .channel(channel) = peer, case .group = channel.info {
isGroup = true
} else {
isGroup = false
}
}
if let isGroup {
let scroll = scroll.update(
component: ScrollComponent<Empty>(
content: AnyComponent(
SheetContent(
context: component.context,
theme: component.theme,
strings: component.strings,
insets: .zero,
peerId: component.peerId,
isGroup: isGroup,
mode: component.mode,
status: component.status,
boostState: component.boostState,
boost: component.boost,
copyLink: component.copyLink,
dismiss: component.dismiss,
openStats: component.openStats,
openGift: component.openGift,
openPeer: component.openPeer
)
),
contentInsets: UIEdgeInsets(top: topInset, left: 0.0, bottom: 0.0, right: 0.0),
contentOffsetUpdated: { [weak state] topContentOffset, _ in
state?.topContentOffset = topContentOffset
Queue.mainQueue().justDispatch {
state?.updated(transition: .immediate)
}
},
contentOffsetWillCommit: { _ in }
),
availableSize: context.availableSize,
transition: context.transition
)
let background = background.update(
component: Rectangle(color: theme.overallDarkAppearance ? theme.list.blocksBackgroundColor : theme.list.plainBackgroundColor),
availableSize: scroll.size,
transition: context.transition
)
context.add(background
.position(CGPoint(x: context.availableSize.width / 2.0, y: background.size.height / 2.0))
)
context.add(scroll
.position(CGPoint(x: context.availableSize.width / 2.0, y: scroll.size.height / 2.0))
)
}
let topPanel = topPanel.update(
component: BlurredBackgroundComponent(
color: theme.rootController.navigationBar.blurredBackgroundColor
),
availableSize: CGSize(width: context.availableSize.width, height: topInset),
transition: context.transition
)
let topSeparator = topSeparator.update(
component: Rectangle(
color: theme.rootController.navigationBar.separatorColor
),
availableSize: CGSize(width: context.availableSize.width, height: UIScreenPixel),
transition: context.transition
)
let titleString: String
var titleFont = Font.semibold(17.0)
switch component.mode {
case let .owner(subject):
if let status = component.status, let _ = status.nextLevelBoosts {
if let subject {
switch subject {
case .stories:
if status.level == 0 {
titleString = strings.ChannelBoost_EnableStories
} else {
titleString = strings.ChannelBoost_IncreaseLimit
}
case .nameColors:
titleString = strings.ChannelBoost_NameColor
case .nameIcon:
titleString = strings.ChannelBoost_NameIcon
case .profileColors:
titleString = strings.ChannelBoost_ProfileColor
case .profileIcon:
titleString = strings.ChannelBoost_ProfileIcon
case .channelReactions:
titleString = strings.ChannelBoost_CustomReactions
case .emojiStatus:
titleString = strings.ChannelBoost_EmojiStatus
case .wallpaper:
titleString = strings.ChannelBoost_Wallpaper
case .customWallpaper:
titleString = strings.ChannelBoost_CustomWallpaper
case .audioTranscription:
titleString = strings.GroupBoost_AudioTranscription
case .emojiPack:
titleString = strings.GroupBoost_EmojiPack
}
} else {
titleString = isGroup == true ? strings.GroupBoost_Title_Current : strings.ChannelBoost_Title_Current
}
} else {
titleString = strings.ChannelBoost_MaxLevelReached
}
case let .user(mode):
var remaining: Int?
if let status = component.status, let nextLevelBoosts = status.nextLevelBoosts {
remaining = nextLevelBoosts - status.boosts
}
if let _ = remaining {
if case .current = mode {
titleString = isGroup == true ? strings.GroupBoost_Title_Current : strings.ChannelBoost_Title_Current
} else {
titleString = isGroup == true ? strings.GroupBoost_Title_Other : strings.ChannelBoost_Title_Other
}
} else {
titleString = strings.ChannelBoost_MaxLevelReached
}
case .features:
titleString = strings.GroupBoost_AdditionalFeatures
titleFont = Font.semibold(20.0)
}
let title = title.update(
component: MultilineTextComponent(
text: .plain(NSAttributedString(string: titleString, font: titleFont, textColor: theme.rootController.navigationBar.primaryTextColor)),
horizontalAlignment: .center,
truncationType: .end,
maximumNumberOfLines: 1
),
availableSize: context.availableSize,
transition: context.transition
)
let topPanelAlpha: CGFloat
let titleOriginY: CGFloat
let titleScale: CGFloat
if case .features = component.mode {
if state.topContentOffset > 78.0 {
topPanelAlpha = min(30.0, state.topContentOffset - 78.0) / 30.0
} else {
topPanelAlpha = 0.0
}
let titleTopOriginY = topPanel.size.height / 2.0
let titleBottomOriginY: CGFloat = 146.0
let titleOriginDelta = titleTopOriginY - titleBottomOriginY
let fraction = min(1.0, state.topContentOffset / abs(titleOriginDelta))
titleOriginY = titleBottomOriginY + fraction * titleOriginDelta
titleScale = 1.0 - max(0.0, fraction * 0.2)
} else {
topPanelAlpha = min(30.0, state.topContentOffset) / 30.0
titleOriginY = topPanel.size.height / 2.0
titleScale = 1.0
}
context.add(topPanel
.position(CGPoint(x: context.availableSize.width / 2.0, y: topPanel.size.height / 2.0))
.opacity(topPanelAlpha)
)
context.add(topSeparator
.position(CGPoint(x: context.availableSize.width / 2.0, y: topPanel.size.height))
.opacity(topPanelAlpha)
)
context.add(title
.position(CGPoint(x: context.availableSize.width / 2.0, y: titleOriginY))
.scale(titleScale)
)
if let openStats = component.openStats {
let statsButton = statsButton.update(
component: Button(
content: AnyComponent(
BundleIconComponent(
name: "Premium/Stats",
tintColor: component.theme.list.itemAccentColor
)
),
action: {
component.dismiss()
Queue.mainQueue().after(0.35) {
openStats()
}
}
).minSize(CGSize(width: 44.0, height: 44.0)),
availableSize: context.availableSize,
transition: .immediate
)
context.add(statsButton
.position(CGPoint(x: 31.0, y: 28.0))
)
}
let closeImage: UIImage
if let (image, theme) = state.cachedCloseImage, theme === component.theme {
closeImage = image
} else {
closeImage = generateCloseButtonImage(backgroundColor: UIColor(rgb: 0x808084, alpha: 0.1), foregroundColor: theme.actionSheet.inputClearButtonColor)!
state.cachedCloseImage = (closeImage, theme)
}
let closeButton = closeButton.update(
component: Button(
content: AnyComponent(Image(image: closeImage)),
action: {
component.dismiss()
}
),
availableSize: CGSize(width: 30.0, height: 30.0),
transition: .immediate
)
context.add(closeButton
.position(CGPoint(x: context.availableSize.width - closeButton.size.width, y: 28.0))
)
return context.availableSize
}
}
}
public class PremiumBoostLevelsScreen: ViewController {
public enum Mode: Equatable {
public enum UserMode: Equatable {
case external
case current
case groupPeer(EnginePeer.Id, Int)
case unrestrict(Int)
}
case user(mode: UserMode)
case owner(subject: BoostSubject?)
case features
}
final class Node: ViewControllerTracingNode, UIScrollViewDelegate, UIGestureRecognizerDelegate {
private var presentationData: PresentationData
private weak var controller: PremiumBoostLevelsScreen?
let dim: ASDisplayNode
let wrappingView: UIView
let containerView: UIView
let contentView: ComponentHostView<Empty>
let footerContainerView: UIView
let footerView: ComponentHostView<Empty>
private(set) var isExpanded = false
private var panGestureRecognizer: UIPanGestureRecognizer?
private var panGestureArguments: (topInset: CGFloat, offset: CGFloat, scrollView: UIScrollView?, listNode: ListView?)?
private let hapticFeedback = HapticFeedback()
private var currentIsVisible: Bool = false
private var currentLayout: ContainerViewLayout?
init(context: AccountContext, controller: PremiumBoostLevelsScreen) {
self.presentationData = context.sharedContext.currentPresentationData.with { $0 }
if controller.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.contentView = ComponentHostView()
self.footerContainerView = UIView()
self.footerView = ComponentHostView()
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.contentView)
if case .user = controller.mode, let status = controller.status {
self.containerView.addSubview(self.footerContainerView)
self.footerContainerView.addSubview(self.footerView)
var myBoostCount: Int32 = 0
var currentMyBoostCount: Int32 = 0
var availableBoosts: [MyBoostStatus.Boost] = []
var occupiedBoosts: [MyBoostStatus.Boost] = []
if let myBoostStatus = controller.myBoostStatus {
for boost in myBoostStatus.boosts {
if let boostPeer = boost.peer {
if boostPeer.id == controller.peerId {
myBoostCount += 1
} else {
occupiedBoosts.append(boost)
}
} else {
availableBoosts.append(boost)
}
}
}
let boosts = max(Int32(status.boosts), myBoostCount)
let initialState = InternalBoostState(level: Int32(status.level), currentLevelBoosts: Int32(status.currentLevelBoosts), nextLevelBoosts: status.nextLevelBoosts.flatMap(Int32.init), boosts: boosts)
self.boostState = initialState.displayData(myBoostCount: myBoostCount, currentMyBoostCount: 0, replacedBoosts: controller.replacedBoosts?.0)
self.updatedState.set(.single(InternalBoostState(level: Int32(status.level), currentLevelBoosts: Int32(status.currentLevelBoosts), nextLevelBoosts: status.nextLevelBoosts.flatMap(Int32.init), boosts: boosts + 1)))
if let (replacedBoosts, inChannels) = controller.replacedBoosts {
currentMyBoostCount += 1
self.boostState = initialState.displayData(myBoostCount: myBoostCount, currentMyBoostCount: 1)
Queue.mainQueue().justDispatch {
self.updated(transition: .easeInOut(duration: 0.2))
}
Queue.mainQueue().after(0.3) {
let presentationData = context.sharedContext.currentPresentationData.with { $0 }
let undoController = UndoOverlayController(presentationData: presentationData, content: .image(image: generateTintedImage(image: UIImage(bundleImageName: "Premium/BoostReplaceIcon"), color: .white)!, title: nil, text: presentationData.strings.ReassignBoost_Success(presentationData.strings.ReassignBoost_Boosts(replacedBoosts), presentationData.strings.ReassignBoost_OtherChannels(inChannels)).string, round: false, undoText: nil), elevatedLayout: false, position: .top, action: { _ in return true })
controller.present(undoController, in: .current)
}
}
self.availableBoosts = availableBoosts
self.occupiedBoosts = occupiedBoosts
self.myBoostCount = myBoostCount
self.currentMyBoostCount = currentMyBoostCount
}
}
override func didLoad() {
super.didLoad()
let panRecognizer = UIPanGestureRecognizer(target: self, action: #selector(self.panGesture(_:)))
panRecognizer.delegate = self
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.controller?.navigationBar?.updateBackgroundAlpha(0.0, transition: .immediate)
}
@objc func dimTapGesture(_ recognizer: UITapGestureRecognizer) {
if case .ended = recognizer.state {
self.controller?.dismiss(animated: true)
}
}
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 {
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) {
guard !self.isDismissing else {
return
}
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 footerHeight: CGFloat = 8.0 + 50.0
footerHeight += layout.intrinsicInsets.bottom > 0.0 ? layout.intrinsicInsets.bottom + 5.0 : 8.0
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.footerContainerView, frame: convertedFooterFrame)
self.updated(transition: transition)
}
private var boostState: InternalBoostState.DisplayData?
func updated(transition: Transition) {
guard let controller = self.controller, let layout = self.currentLayout else {
return
}
let contentSize = self.contentView.update(
transition: transition,
component: AnyComponent(
BoostLevelsContainerComponent(
context: controller.context,
theme: self.presentationData.theme,
strings: self.presentationData.strings,
peerId: controller.peerId,
mode: controller.mode,
status: controller.status,
boostState: self.boostState,
boost: { [weak controller] in
guard let controller, let navigationController = controller.navigationController else {
return
}
controller.dismiss(animated: true)
Queue.mainQueue().justDispatch {
let boostController = PremiumBoostLevelsScreen(
context: controller.context,
peerId: controller.peerId,
mode: .user(mode: .current),
status: controller.status,
myBoostStatus: nil,
forceDark: controller.forceDark
)
navigationController.pushViewController(boostController, animated: true)
}
},
copyLink: { [weak self, weak controller] link in
guard let self else {
return
}
UIPasteboard.general.string = link
if let previousController = controller?.navigationController?.viewControllers.reversed().first(where: { $0 !== controller }) as? ViewController {
previousController.present(UndoOverlayController(presentationData: self.presentationData, content: .linkCopied(text: self.presentationData.strings.ChannelBoost_BoostLinkCopied), elevatedLayout: true, position: .top, animateInAsReplacement: false, action: { _ in return false }), in: .current)
}
},
dismiss: { [weak controller] in
controller?.dismiss(animated: true)
},
openStats: controller.openStats,
openGift: controller.openGift,
openPeer: controller.openPeer
)
),
environment: {},
containerSize: self.containerView.bounds.size
)
self.contentView.frame = CGRect(origin: .zero, size: contentSize)
var footerHeight: CGFloat = 8.0 + 50.0
footerHeight += layout.intrinsicInsets.bottom > 0.0 ? layout.intrinsicInsets.bottom + 5.0 : 8.0
let footerSize = self.footerView.update(
transition: .immediate,
component: AnyComponent(
FooterComponent(
context: controller.context,
theme: self.presentationData.theme,
title: self.currentMyBoostCount > 0 ? "Boost Again" : "Boost Group",
action: { [weak self] in
guard let self else {
return
}
self.buttonPressed()
}
)
),
environment: {},
containerSize: CGSize(width: self.containerView.bounds.width, height: footerHeight)
)
self.footerView.frame = CGRect(origin: .zero, size: footerSize)
}
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
return layout.size.height - layout.size.width - 128.0 - panelHeight
} 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 {
return (view, nil)
}
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))
}
private var currentMyBoostCount: Int32 = 0
private var myBoostCount: Int32 = 0
private var availableBoosts: [MyBoostStatus.Boost] = []
private var occupiedBoosts: [MyBoostStatus.Boost] = []
private let updatedState = Promise<InternalBoostState?>()
private func updateBoostState() {
guard let controller = self.controller else {
return
}
let context = controller.context
let peerId = controller.peerId
let mode = controller.mode
let status = controller.status
let isPremium = controller.context.isPremium
let premiumConfiguration = PremiumConfiguration.with(appConfiguration: context.currentAppConfiguration.with({ $0 }))
let canBoostAgain = premiumConfiguration.boostsPerGiftCount > 0
let presentationData = self.presentationData
let forceDark = controller.forceDark
if let _ = status?.nextLevelBoosts {
if let availableBoost = self.availableBoosts.first {
self.currentMyBoostCount += 1
self.myBoostCount += 1
let _ = (context.engine.peers.applyChannelBoost(peerId: peerId, slots: [availableBoost.slot])
|> deliverOnMainQueue).startStandalone(completed: { [weak self] in
self?.updatedState.set(context.engine.peers.getChannelBoostStatus(peerId: peerId)
|> map { status in
if let status {
return InternalBoostState(level: Int32(status.level), currentLevelBoosts: Int32(status.currentLevelBoosts), nextLevelBoosts: status.nextLevelBoosts.flatMap(Int32.init), boosts: Int32(status.boosts + 1))
} else {
return nil
}
})
})
let _ = (self.updatedState.get()
|> take(1)
|> deliverOnMainQueue).startStandalone(next: { [weak self] state in
guard let self, let state else {
return
}
self.boostState = state.displayData(myBoostCount: self.myBoostCount, currentMyBoostCount: self.currentMyBoostCount)
self.updated(transition: .easeInOut(duration: 0.2))
self.animateSuccess()
})
self.availableBoosts.removeFirst()
} else if !self.occupiedBoosts.isEmpty, let myBoostStatus = controller.myBoostStatus {
if canBoostAgain {
let navigationController = controller.navigationController
let openPeer = controller.openPeer
var dismissReplaceImpl: (() -> Void)?
let replaceController = ReplaceBoostScreen(context: context, peerId: peerId, myBoostStatus: myBoostStatus, replaceBoosts: { slots in
var channelIds = Set<EnginePeer.Id>()
for boost in myBoostStatus.boosts {
if slots.contains(boost.slot) {
if let peer = boost.peer {
channelIds.insert(peer.id)
}
}
}
let _ = context.engine.peers.applyChannelBoost(peerId: peerId, slots: slots).startStandalone(completed: {
let _ = combineLatest(
queue: Queue.mainQueue(),
context.engine.peers.getChannelBoostStatus(peerId: peerId),
context.engine.peers.getMyBoostStatus()
).startStandalone(next: { boostStatus, myBoostStatus in
dismissReplaceImpl?()
let levelsController = PremiumBoostLevelsScreen(
context: context,
peerId: peerId,
mode: mode,
status: status,
myBoostStatus: myBoostStatus,
replacedBoosts: (Int32(slots.count), Int32(channelIds.count)),
openStats: nil, openGift: nil, openPeer: openPeer, forceDark: forceDark
)
if let navigationController {
navigationController.pushViewController(levelsController, animated: true)
}
})
})
})
if let navigationController = controller.navigationController {
controller.dismiss(animated: true)
navigationController.pushViewController(replaceController, animated: true)
}
dismissReplaceImpl = { [weak replaceController] in
replaceController?.dismiss(animated: true)
}
} else if let boost = self.occupiedBoosts.first, let occupiedPeer = boost.peer {
if let cooldown = boost.cooldownUntil {
let currentTime = Int32(CFAbsoluteTimeGetCurrent() + kCFAbsoluteTimeIntervalSince1970)
let timeout = cooldown - currentTime
let valueText = timeIntervalString(strings: presentationData.strings, value: timeout, usage: .afterTime, preferLowerValue: false)
let alertController = textAlertController(
sharedContext: context.sharedContext,
updatedPresentationData: nil,
title: presentationData.strings.ChannelBoost_Error_BoostTooOftenTitle,
text: presentationData.strings.ChannelBoost_Error_BoostTooOftenText(valueText).string,
actions: [
TextAlertAction(type: .defaultAction, title: presentationData.strings.Common_OK, action: {})
],
parseMarkdown: true
)
controller.present(alertController, in: .window(.root))
} else {
let _ = (context.engine.data.get(TelegramEngine.EngineData.Item.Peer.Peer(id: peerId))
|> deliverOnMainQueue).start(next: { [weak controller] peer in
guard let peer, let controller else {
return
}
let replaceController = replaceBoostConfirmationController(context: context, fromPeers: [occupiedPeer], toPeer: peer, commit: { [weak self] in
self?.currentMyBoostCount += 1
self?.myBoostCount += 1
let _ = (context.engine.peers.applyChannelBoost(peerId: peerId, slots: [boost.slot])
|> deliverOnMainQueue).startStandalone(completed: { [weak self] in
guard let self else {
return
}
let _ = (self.updatedState.get()
|> take(1)
|> deliverOnMainQueue).startStandalone(next: { [weak self] state in
guard let self, let state else {
return
}
self.boostState = state.displayData(myBoostCount: self.myBoostCount, currentMyBoostCount: self.currentMyBoostCount)
self.updated(transition: .easeInOut(duration: 0.2))
self.animateSuccess()
})
})
})
controller.present(replaceController, in: .window(.root))
})
}
} else {
controller.dismiss(animated: true, completion: nil)
}
} else {
if isPremium {
if !canBoostAgain {
controller.dismiss(animated: true, completion: nil)
} else {
let _ = (context.engine.data.get(TelegramEngine.EngineData.Item.Peer.Peer(id: peerId))
|> deliverOnMainQueue).start(next: { [weak controller] peer in
guard let peer, let controller else {
return
}
let alertController = textAlertController(
sharedContext: context.sharedContext,
updatedPresentationData: nil,
title: presentationData.strings.ChannelBoost_MoreBoosts_Title,
text: presentationData.strings.ChannelBoost_MoreBoosts_Text(peer.compactDisplayTitle, "\(premiumConfiguration.boostsPerGiftCount)").string,
actions: [
TextAlertAction(type: .defaultAction, title: presentationData.strings.ChannelBoost_MoreBoosts_Gift, action: { [weak controller] in
if let navigationController = controller?.navigationController {
controller?.dismiss(animated: true, completion: nil)
Queue.mainQueue().after(0.4) {
let giftController = context.sharedContext.makePremiumGiftController(context: context, source: .channelBoost)
navigationController.pushViewController(giftController, animated: true)
}
}
}),
TextAlertAction(type: .genericAction, title: presentationData.strings.Common_Close, action: {})
],
actionLayout: .vertical,
parseMarkdown: true
)
controller.present(alertController, in: .window(.root))
})
}
} else {
let alertController = textAlertController(
sharedContext: context.sharedContext,
updatedPresentationData: nil,
title: presentationData.strings.ChannelBoost_Error_PremiumNeededTitle,
text: presentationData.strings.ChannelBoost_Error_PremiumNeededText,
actions: [
TextAlertAction(type: .genericAction, title: presentationData.strings.Common_Cancel, action: {}),
TextAlertAction(type: .defaultAction, title: presentationData.strings.Common_Yes, action: { [weak controller] in
if let navigationController = controller?.navigationController {
controller?.dismiss(animated: true)
let premiumController = context.sharedContext.makePremiumIntroController(context: context, source: .channelBoost(peerId), forceDark: forceDark, dismissed: nil)
navigationController.pushViewController(premiumController, animated: true)
}
})
],
parseMarkdown: true
)
controller.present(alertController, in: .window(.root))
}
}
} else {
controller.dismiss(animated: true)
}
}
func buttonPressed() {
self.updateBoostState()
}
private func animateSuccess() {
self.hapticFeedback.impact()
self.view.addSubview(ConfettiView(frame: self.view.bounds))
if self.isExpanded {
self.update(isExpanded: false, transition: .animated(duration: 0.4, curve: .spring))
}
}
}
var node: Node {
return self.displayNode as! Node
}
private let context: AccountContext
private let peerId: EnginePeer.Id
private let mode: Mode
private let status: ChannelBoostStatus?
private let myBoostStatus: MyBoostStatus?
private let replacedBoosts: (Int32, Int32)?
private let openStats: (() -> Void)?
private let openGift: (() -> Void)?
private let openPeer: ((EnginePeer) -> Void)?
private let forceDark: Bool
private var currentLayout: ContainerViewLayout?
public var disposed: () -> Void = {}
public init(
context: AccountContext,
peerId: EnginePeer.Id,
mode: Mode,
status: ChannelBoostStatus?,
myBoostStatus: MyBoostStatus? = nil,
replacedBoosts: (Int32, Int32)? = nil,
openStats: (() -> Void)? = nil,
openGift: (() -> Void)? = nil,
openPeer: ((EnginePeer) -> Void)? = nil,
forceDark: Bool = false
) {
self.context = context
self.peerId = peerId
self.mode = mode
self.status = status
self.myBoostStatus = myBoostStatus
self.replacedBoosts = replacedBoosts
self.openStats = openStats
self.openGift = openGift
self.openPeer = openPeer
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()
}
override open func loadDisplayNode() {
self.displayNode = Node(context: self.context, controller: self)
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 final class FooterComponent: Component {
let context: AccountContext
let theme: PresentationTheme
let title: String
let action: () -> Void
init(context: AccountContext, theme: PresentationTheme, title: String, action: @escaping () -> Void) {
self.context = context
self.theme = theme
self.title = title
self.action = action
}
static func ==(lhs: FooterComponent, rhs: FooterComponent) -> Bool {
if lhs.context !== rhs.context {
return false
}
if lhs.theme !== rhs.theme {
return false
}
if lhs.title != rhs.title {
return false
}
return true
}
final class View: UIView {
private let backgroundView: BlurredBackgroundView
private let separator = SimpleLayer()
private let button = ComponentView<Empty>()
private var component: FooterComponent?
private weak var state: EmptyComponentState?
override init(frame: CGRect) {
self.backgroundView = BlurredBackgroundView(color: nil)
super.init(frame: frame)
self.backgroundView.clipsToBounds = true
self.addSubview(self.backgroundView)
self.layer.addSublayer(self.separator)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func update(component: FooterComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment<Empty>, transition: Transition) -> CGSize {
self.component = component
self.state = state
let bounds = CGRect(origin: .zero, size: availableSize)
self.backgroundView.updateColor(color: component.theme.rootController.tabBar.backgroundColor, transition: transition.containedViewLayoutTransition)
self.backgroundView.update(size: bounds.size, transition: transition.containedViewLayoutTransition)
transition.setFrame(view: self.backgroundView, frame: bounds)
self.separator.backgroundColor = component.theme.rootController.tabBar.separatorColor.cgColor
transition.setFrame(layer: self.separator, frame: CGRect(origin: .zero, size: CGSize(width: availableSize.width, height: UIScreenPixel)))
let gradientColors = [
UIColor(rgb: 0x0077ff),
UIColor(rgb: 0x6b93ff),
UIColor(rgb: 0x8878ff),
UIColor(rgb: 0xe46ace)
]
let buttonSize = self.button.update(
transition: .immediate,
component: AnyComponent(
SolidRoundedButtonComponent(
title: component.title,
theme: SolidRoundedButtonComponent.Theme(
backgroundColor: .black,
backgroundColors: gradientColors,
foregroundColor: .white
),
font: .bold,
fontSize: 17.0,
height: 50.0,
cornerRadius: 10.0,
gloss: true,
iconName: "Premium/BoostChannel",
animationName: nil,
iconPosition: .left,
action: {
component.action()
}
)
),
environment: {},
containerSize: CGSize(width: availableSize.width - 32.0, height: availableSize.height)
)
if let view = self.button.view {
if view.superview == nil {
self.addSubview(view)
}
let buttonFrame = CGRect(origin: CGPoint(x: 16.0, y: 8.0), size: buttonSize)
view.frame = buttonFrame
}
return availableSize
}
}
func makeView() -> View {
return View(frame: CGRect())
}
func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment<Empty>, transition: Transition) -> CGSize {
return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition)
}
}
private struct InternalBoostState: Equatable {
let level: Int32
let currentLevelBoosts: Int32
let nextLevelBoosts: Int32?
let boosts: Int32
struct DisplayData: Equatable {
let level: Int32
let boosts: Int32
let currentLevelBoosts: Int32
let nextLevelBoosts: Int32?
let myBoostCount: Int32
}
func displayData(myBoostCount: Int32, currentMyBoostCount: Int32, replacedBoosts: Int32? = nil) -> DisplayData {
var currentLevel = self.level
var nextLevelBoosts = self.nextLevelBoosts
var currentLevelBoosts = self.currentLevelBoosts
var boosts = self.boosts
if let replacedBoosts {
boosts = max(currentLevelBoosts, boosts - replacedBoosts)
}
if currentMyBoostCount > 0 && self.boosts == currentLevelBoosts {
currentLevel = max(0, currentLevel - 1)
nextLevelBoosts = currentLevelBoosts
currentLevelBoosts = max(0, currentLevelBoosts - 1)
}
return DisplayData(
level: currentLevel,
boosts: boosts,
currentLevelBoosts: currentLevelBoosts,
nextLevelBoosts: nextLevelBoosts,
myBoostCount: myBoostCount
)
}
}