GLEGram 12.5 — Initial public release

Based on Swiftgram 12.5 (Telegram iOS 12.5).
All GLEGram features ported and organized in GLEGram/ folder.

Features: Ghost Mode, Saved Deleted Messages, Content Protection Bypass,
Font Replacement, Fake Profile, Chat Export, Plugin System, and more.

See CHANGELOG_12.5.md for full details.
This commit is contained in:
Leeksov
2026-04-06 09:48:12 +03:00
commit 4647310322
39685 changed files with 11052678 additions and 0 deletions
@@ -0,0 +1,59 @@
load("@build_bazel_rules_swift//swift:swift.bzl", "swift_library")
swift_library(
name = "StarsTransactionsScreen",
module_name = "StarsTransactionsScreen",
srcs = glob([
"Sources/**/*.swift",
]),
copts = [
"-warnings-as-errors",
],
deps = [
"//submodules/AsyncDisplayKit",
"//submodules/Display",
"//submodules/Postbox",
"//submodules/TelegramCore",
"//submodules/SSignalKit/SwiftSignalKit",
"//submodules/ComponentFlow",
"//submodules/Components/ViewControllerComponent",
"//submodules/Components/ComponentDisplayAdapters",
"//submodules/Components/MultilineTextComponent",
"//submodules/Components/BalancedTextComponent",
"//submodules/TelegramPresentationData",
"//submodules/AccountContext",
"//submodules/AppBundle",
"//submodules/ItemListUI",
"//submodules/TelegramStringFormatting",
"//submodules/PresentationDataUtils",
"//submodules/Components/SheetComponent",
"//submodules/UndoUI",
"//submodules/TextFormat",
"//submodules/TelegramUI/Components/ListSectionComponent",
"//submodules/TelegramUI/Components/ListActionItemComponent",
"//submodules/TelegramUI/Components/ScrollComponent",
"//submodules/TelegramUI/Components/Premium/PremiumStarComponent",
"//submodules/TelegramUI/Components/ButtonComponent",
"//submodules/Components/BlurredBackgroundComponent",
"//submodules/Components/BundleIconComponent",
"//submodules/Components/SolidRoundedButtonComponent",
"//submodules/TelegramUI/Components/AnimatedTextComponent",
"//submodules/AvatarNode",
"//submodules/PhotoResources",
"//submodules/TelegramUI/Components/Stars/StarsImageComponent",
"//submodules/PasswordSetupUI",
"//submodules/TelegramUI/Components/PeerManagement/OwnershipTransferController",
"//submodules/TelegramUI/Components/ListItemComponentAdaptor",
"//submodules/StatisticsUI",
"//submodules/TelegramUI/Components/Stars/StarsWithdrawalScreen",
"//submodules/TelegramUI/Components/Stars/StarsAvatarComponent",
"//submodules/TelegramUI/Components/LottieComponent",
"//submodules/TelegramUI/Components/LottieComponentResourceContent",
"//submodules/TelegramUI/Components/Gifts/GiftAnimationComponent",
"//submodules/TelegramUI/Components/Premium/PremiumCoinComponent",
"//submodules/TelegramUI/Components/Premium/PremiumDiamondComponent",
],
visibility = [
"//visibility:public",
],
)
@@ -0,0 +1,419 @@
import Foundation
import UIKit
import Display
import AsyncDisplayKit
import ComponentFlow
import AccountContext
import MultilineTextComponent
import TelegramPresentationData
import PresentationDataUtils
import ButtonComponent
import BundleIconComponent
import TelegramStringFormatting
import TelegramCore
final class StarsBalanceComponent: Component {
let theme: PresentationTheme
let strings: PresentationStrings
let dateTimeFormat: PresentationDateTimeFormat
let count: StarsAmount
let currency: CurrencyAmount.Currency
let rate: Double?
let actionTitle: String
let actionAvailable: Bool
let actionIsEnabled: Bool
let actionCooldownUntilTimestamp: Int32?
let actionIcon: UIImage?
let action: () -> Void
let secondaryActionTitle: String?
let secondaryActionIcon: UIImage?
let secondaryActionCooldownUntilTimestamp: Int32?
let secondaryAction: (() -> Void)?
let additionalAction: AnyComponent<Empty>?
init(
theme: PresentationTheme,
strings: PresentationStrings,
dateTimeFormat: PresentationDateTimeFormat,
count: StarsAmount,
currency: CurrencyAmount.Currency,
rate: Double?,
actionTitle: String,
actionAvailable: Bool,
actionIsEnabled: Bool,
actionCooldownUntilTimestamp: Int32? = nil,
actionIcon: UIImage? = nil,
action: @escaping () -> Void,
secondaryActionTitle: String? = nil,
secondaryActionIcon: UIImage? = nil,
secondaryActionCooldownUntilTimestamp: Int32? = nil,
secondaryAction: (() -> Void)? = nil,
additionalAction: AnyComponent<Empty>? = nil
) {
self.theme = theme
self.strings = strings
self.dateTimeFormat = dateTimeFormat
self.count = count
self.currency = currency
self.rate = rate
self.actionTitle = actionTitle
self.actionAvailable = actionAvailable
self.actionIsEnabled = actionIsEnabled
self.actionCooldownUntilTimestamp = actionCooldownUntilTimestamp
self.actionIcon = actionIcon
self.action = action
self.secondaryActionTitle = secondaryActionTitle
self.secondaryActionCooldownUntilTimestamp = secondaryActionCooldownUntilTimestamp
self.secondaryActionIcon = secondaryActionIcon
self.secondaryAction = secondaryAction
self.additionalAction = additionalAction
}
static func ==(lhs: StarsBalanceComponent, rhs: StarsBalanceComponent) -> Bool {
if lhs.theme !== rhs.theme {
return false
}
if lhs.strings !== rhs.strings {
return false
}
if lhs.dateTimeFormat != rhs.dateTimeFormat {
return false
}
if lhs.actionTitle != rhs.actionTitle {
return false
}
if lhs.actionAvailable != rhs.actionAvailable {
return false
}
if lhs.actionIsEnabled != rhs.actionIsEnabled {
return false
}
if lhs.actionCooldownUntilTimestamp != rhs.actionCooldownUntilTimestamp {
return false
}
if lhs.secondaryActionTitle != rhs.secondaryActionTitle {
return false
}
if lhs.secondaryActionCooldownUntilTimestamp != rhs.secondaryActionCooldownUntilTimestamp {
return false
}
if lhs.count != rhs.count {
return false
}
if lhs.currency != rhs.currency {
return false
}
if lhs.rate != rhs.rate {
return false
}
return true
}
final class View: UIView {
private let icon = UIImageView()
private let title = ComponentView<Empty>()
private let subtitle = ComponentView<Empty>()
private var button = ComponentView<Empty>()
private var secondaryButton = ComponentView<Empty>()
private var additionalButton = ComponentView<Empty>()
private var component: StarsBalanceComponent?
private weak var state: EmptyComponentState?
private var timer: Timer?
override init(frame: CGRect) {
super.init(frame: frame)
self.addSubview(self.icon)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func update(component: StarsBalanceComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment<Empty>, transition: ComponentTransition) -> CGSize {
if self.component == nil {
switch component.currency {
case .ton:
self.icon.image = generateTintedImage(image: UIImage(bundleImageName: "Ads/TonBig"), color: component.theme.list.itemAccentColor)
case .stars:
self.icon.image = UIImage(bundleImageName: "Premium/Stars/BalanceStar")
}
}
self.component = component
self.state = state
var remainingCooldownSeconds: Int32 = 0
if let cooldownUntilTimestamp = component.actionCooldownUntilTimestamp {
remainingCooldownSeconds = cooldownUntilTimestamp - Int32(Date().timeIntervalSince1970)
remainingCooldownSeconds = max(0, remainingCooldownSeconds)
}
var remainingSecondaryCooldownSeconds: Int32 = 0
if let cooldownUntilTimestamp = component.secondaryActionCooldownUntilTimestamp {
remainingSecondaryCooldownSeconds = cooldownUntilTimestamp - Int32(Date().timeIntervalSince1970)
remainingSecondaryCooldownSeconds = max(0, remainingSecondaryCooldownSeconds)
}
if remainingCooldownSeconds > 0 || remainingSecondaryCooldownSeconds > 0 {
if self.timer == nil {
self.timer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true, block: { [weak self] _ in
guard let self else {
return
}
self.state?.updated(transition: .immediate)
})
}
} else {
if let timer = self.timer {
self.timer = nil
timer.invalidate()
}
}
let sideInset: CGFloat = 16.0
var contentHeight: CGFloat = sideInset
let formattedLabel: String
switch component.currency {
case .ton:
formattedLabel = formatTonAmountText(component.count.value, dateTimeFormat: component.dateTimeFormat, maxDecimalPositions: 3)
case .stars:
formattedLabel = formatStarsAmountText(component.count, dateTimeFormat: component.dateTimeFormat)
}
let labelFont: UIFont
if formattedLabel.contains(component.dateTimeFormat.decimalSeparator) {
labelFont = Font.with(size: 48.0, design: .round, weight: .semibold)
} else {
labelFont = Font.with(size: 48.0, design: .round, weight: .semibold)
}
let smallLabelFont = Font.with(size: 32.0, design: .round, weight: .regular)
let balanceString = tonAmountAttributedString(formattedLabel, integralFont: labelFont, fractionalFont: smallLabelFont, color: component.theme.list.itemPrimaryTextColor, decimalSeparator: component.dateTimeFormat.decimalSeparator)
let titleSize = self.title.update(
transition: .immediate,
component: AnyComponent(
MultilineTextComponent(
text: .plain(balanceString)
)
),
environment: {},
containerSize: CGSize(width: availableSize.width - sideInset * 2.0, height: 50.0)
)
if let titleView = self.title.view {
if titleView.superview == nil {
self.addSubview(titleView)
}
if let icon = self.icon.image {
let spacing: CGFloat = 4.0
let totalWidth = titleSize.width + icon.size.width + spacing
let origin = floorToScreenPixels((availableSize.width - totalWidth) / 2.0)
let titleFrame = CGRect(origin: CGPoint(x: origin + icon.size.width + spacing, y: contentHeight - 3.0), size: titleSize)
titleView.frame = titleFrame
self.icon.frame = CGRect(origin: CGPoint(x: origin, y: floorToScreenPixels(titleFrame.midY - icon.size.height / 2.0)), size: icon.size)
}
}
contentHeight += titleSize.height
let subtitleText: String
if let rate = component.rate {
subtitleText = "~\(formatTonUsdValue(component.count.value, divide: false, rate: rate, dateTimeFormat: component.dateTimeFormat))"
} else {
subtitleText = component.strings.Stars_Intro_YourBalance
}
let subtitleSize = self.subtitle.update(
transition: .immediate,
component: AnyComponent(
MultilineTextComponent(
text: .plain(NSAttributedString(string: subtitleText, font: Font.regular(17.0), textColor: component.theme.list.itemSecondaryTextColor)),
horizontalAlignment: .center
)
),
environment: {},
containerSize: CGSize(width: availableSize.width - sideInset * 2.0, height: 50.0)
)
if let subtitleView = self.subtitle.view {
if subtitleView.superview == nil {
self.addSubview(subtitleView)
}
let subtitleFrame = CGRect(origin: CGPoint(x: floorToScreenPixels((availableSize.width - subtitleSize.width) / 2.0), y: contentHeight - 4.0), size: subtitleSize)
subtitleView.frame = subtitleFrame
}
contentHeight += subtitleSize.height
if component.actionAvailable {
contentHeight += 12.0
var withdrawWidth = availableSize.width - sideInset * 2.0
if let _ = component.secondaryAction {
withdrawWidth = (withdrawWidth - 10.0) / 2.0
}
let content: AnyComponentWithIdentity<Empty>
if remainingCooldownSeconds > 0 {
content = AnyComponentWithIdentity(id: AnyHashable(1 as Int), component: AnyComponent(
VStack([
AnyComponentWithIdentity(id: AnyHashable(1 as Int), component: AnyComponent(Text(text: component.actionTitle, font: Font.semibold(17.0), color: component.theme.list.itemCheckColors.foregroundColor))),
AnyComponentWithIdentity(id: AnyHashable(0 as Int), component: AnyComponent(HStack([
AnyComponentWithIdentity(id: 1, component: AnyComponent(BundleIconComponent(name: "Chat List/StatusLockIcon", tintColor: component.theme.list.itemCheckColors.fillColor.mixedWith(component.theme.list.itemCheckColors.foregroundColor, alpha: 0.7)))),
AnyComponentWithIdentity(id: 0, component: AnyComponent(Text(text: stringForRemainingTime(remainingCooldownSeconds), font: Font.with(size: 11.0, weight: .medium, traits: [.monospacedNumbers]), color: component.theme.list.itemCheckColors.fillColor.mixedWith(component.theme.list.itemCheckColors.foregroundColor, alpha: 0.7))))
], spacing: 3.0)))
], spacing: 1.0)
))
} else {
var items: [AnyComponentWithIdentity<Empty>] = []
if let icon = component.actionIcon {
items.append(AnyComponentWithIdentity(id: "icon", component: AnyComponent(Image(image: icon, tintColor: component.theme.list.itemCheckColors.foregroundColor, size: icon.size))))
}
items.append(AnyComponentWithIdentity(id: "label", component: AnyComponent(Text(text: component.actionTitle, font: Font.semibold(17.0), color: component.theme.list.itemCheckColors.foregroundColor))))
content = AnyComponentWithIdentity(
id: AnyHashable(0 as Int),
component: AnyComponent(
HStack(items, spacing: 7.0)
)
)
}
let buttonSize = self.button.update(
transition: transition,
component: AnyComponent(ButtonComponent(
background: ButtonComponent.Background(
style: .glass,
color: component.theme.list.itemCheckColors.fillColor,
foreground: component.theme.list.itemCheckColors.foregroundColor,
pressedColor: component.theme.list.itemCheckColors.fillColor.withMultipliedAlpha(0.8)
),
content: content,
isEnabled: component.actionIsEnabled,
allowActionWhenDisabled: false,
displaysProgress: false,
action: { [weak self] in
guard let self, let component = self.component else {
return
}
component.action()
}
)),
environment: {},
containerSize: CGSize(width: withdrawWidth, height: 52.0)
)
if let buttonView = self.button.view {
if buttonView.superview == nil {
self.addSubview(buttonView)
}
let buttonFrame = CGRect(origin: CGPoint(x: sideInset, y: contentHeight), size: buttonSize)
buttonView.frame = buttonFrame
}
if let secondaryActionTitle = component.secondaryActionTitle {
let content: AnyComponentWithIdentity<Empty>
var items: [AnyComponentWithIdentity<Empty>] = []
if let icon = component.secondaryActionIcon {
items.append(AnyComponentWithIdentity(id: "icon", component: AnyComponent(Image(image: icon, tintColor: component.theme.list.itemCheckColors.foregroundColor, size: icon.size))))
}
if remainingSecondaryCooldownSeconds > 0 {
items.append(AnyComponentWithIdentity(id: AnyHashable(1 as Int), component: AnyComponent(
VStack([
AnyComponentWithIdentity(id: AnyHashable(1 as Int), component: AnyComponent(Text(text: secondaryActionTitle, font: Font.semibold(17.0), color: component.theme.list.itemCheckColors.foregroundColor))),
AnyComponentWithIdentity(id: AnyHashable(0 as Int), component: AnyComponent(HStack([
AnyComponentWithIdentity(id: 1, component: AnyComponent(BundleIconComponent(name: "Chat List/StatusLockIcon", tintColor: component.theme.list.itemCheckColors.fillColor.mixedWith(component.theme.list.itemCheckColors.foregroundColor, alpha: 0.7)))),
AnyComponentWithIdentity(id: 0, component: AnyComponent(Text(text: stringForRemainingTime(remainingSecondaryCooldownSeconds), font: Font.with(size: 11.0, weight: .medium, traits: [.monospacedNumbers]), color: component.theme.list.itemCheckColors.fillColor.mixedWith(component.theme.list.itemCheckColors.foregroundColor, alpha: 0.7))))
], spacing: 3.0)))
], spacing: 1.0)
)))
} else {
items.append(AnyComponentWithIdentity(id: "label", component: AnyComponent(Text(text: secondaryActionTitle, font: Font.semibold(17.0), color: component.theme.list.itemCheckColors.foregroundColor))))
}
content = AnyComponentWithIdentity(
id: AnyHashable(0 as Int),
component: AnyComponent(
HStack(items, spacing: 7.0)
)
)
let buttonSize = self.secondaryButton.update(
transition: transition,
component: AnyComponent(ButtonComponent(
background: ButtonComponent.Background(
style: .glass,
color: component.theme.list.itemCheckColors.fillColor,
foreground: component.theme.list.itemCheckColors.foregroundColor,
pressedColor: component.theme.list.itemCheckColors.fillColor.withMultipliedAlpha(0.8)
),
content: content,
isEnabled: component.actionIsEnabled,
allowActionWhenDisabled: false,
displaysProgress: false,
action: { [weak self] in
guard let self, let component = self.component else {
return
}
component.secondaryAction?()
}
)),
environment: {},
containerSize: CGSize(width: withdrawWidth, height: 52.0)
)
if let buttonView = self.secondaryButton.view {
if buttonView.superview == nil {
self.addSubview(buttonView)
}
let buttonFrame = CGRect(origin: CGPoint(x: sideInset + withdrawWidth + 10.0, y: contentHeight), size: buttonSize)
buttonView.frame = buttonFrame
}
}
contentHeight += buttonSize.height
}
if let additionalAction = component.additionalAction {
contentHeight += 18.0
let buttonSize = self.additionalButton.update(
transition: transition,
component: additionalAction,
environment: {},
containerSize: CGSize(width: availableSize.width, height: 50.0)
)
if let buttonView = self.additionalButton.view {
if buttonView.superview == nil {
self.addSubview(buttonView)
}
let buttonFrame = CGRect(origin: CGPoint(x: floorToScreenPixels((availableSize.width - buttonSize.width) / 2.0), y: contentHeight), size: buttonSize)
buttonView.frame = buttonFrame
}
contentHeight += buttonSize.height
contentHeight += 2.0
}
contentHeight += sideInset
return CGSize(width: availableSize.width, height: contentHeight)
}
}
func makeView() -> View {
return View(frame: CGRect())
}
func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment<Empty>, transition: ComponentTransition) -> CGSize {
return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition)
}
}
func stringForRemainingTime(_ duration: Int32) -> String {
let hours = duration / 3600
let minutes = duration / 60 % 60
let seconds = duration % 60
let durationString: String
if hours > 0 {
durationString = String(format: "%d:%02d", hours, minutes)
} else {
durationString = String(format: "%02d:%02d", minutes, seconds)
}
return durationString
}
@@ -0,0 +1,160 @@
import Foundation
import UIKit
import Display
import AsyncDisplayKit
import ComponentFlow
import AccountContext
import MultilineTextComponent
import TelegramPresentationData
import PresentationDataUtils
import TelegramStringFormatting
import TelegramCore
final class StarsOverviewItemComponent: Component {
let theme: PresentationTheme
let dateTimeFormat: PresentationDateTimeFormat
let title: String
let value: CurrencyAmount
let rate: Double
init(
theme: PresentationTheme,
dateTimeFormat: PresentationDateTimeFormat,
title: String,
value: CurrencyAmount,
rate: Double
) {
self.theme = theme
self.dateTimeFormat = dateTimeFormat
self.title = title
self.value = value
self.rate = rate
}
static func ==(lhs: StarsOverviewItemComponent, rhs: StarsOverviewItemComponent) -> Bool {
if lhs.theme !== rhs.theme {
return false
}
if lhs.dateTimeFormat != rhs.dateTimeFormat {
return false
}
if lhs.title != rhs.title {
return false
}
if lhs.value != rhs.value {
return false
}
if lhs.rate != rhs.rate {
return false
}
return true
}
final class View: UIView {
private let icon = UIImageView()
private let value = ComponentView<Empty>()
private let usdValue = ComponentView<Empty>()
private let title = ComponentView<Empty>()
private var component: StarsOverviewItemComponent?
override init(frame: CGRect) {
super.init(frame: frame)
self.addSubview(self.icon)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func update(component: StarsOverviewItemComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment<Empty>, transition: ComponentTransition) -> CGSize {
self.component = component
let sideInset: CGFloat = 16.0
let iconY: CGFloat = component.value.currency == .ton ? 13.0 + UIScreenPixel : 10.0
if self.icon.image == nil {
switch component.value.currency {
case .ton:
self.icon.image = generateTintedImage(image: UIImage(bundleImageName: "Ads/TonMedium"), color: component.theme.list.itemAccentColor)
case .stars:
self.icon.image = UIImage(bundleImageName: "Premium/Stars/StarMedium")
}
}
var valueOffset: CGFloat = 0.0
if let icon = self.icon.image {
self.icon.frame = CGRect(origin: CGPoint(x: sideInset - 1.0, y: iconY), size: icon.size)
valueOffset += icon.size.width
}
let valueString = formatCurrencyAmountText(component.value, dateTimeFormat: component.dateTimeFormat, maxDecimalPositions: nil)
let usdValueString = formatTonUsdValue(component.value.amount.value, divide: component.value.currency == .ton, rate: component.rate, dateTimeFormat: component.dateTimeFormat)
let valueSize = self.value.update(
transition: .immediate,
component: AnyComponent(
MultilineTextComponent(
text: .plain(NSAttributedString(string: valueString, font: Font.semibold(17.0), textColor: component.theme.list.itemPrimaryTextColor))
)
),
environment: {},
containerSize: CGSize(width: availableSize.width - sideInset * 2.0, height: 50.0)
)
let valueFrame = CGRect(origin: CGPoint(x: sideInset + valueOffset + 2.0, y: 10.0), size: valueSize)
if let valueView = self.value.view {
if valueView.superview == nil {
self.addSubview(valueView)
}
valueView.frame = valueFrame
}
let usdValueSize = self.usdValue.update(
transition: .immediate,
component: AnyComponent(
MultilineTextComponent(
text: .plain(NSAttributedString(string: "~\(usdValueString)", font: Font.regular(13.0), textColor: component.theme.list.itemSecondaryTextColor))
)
),
environment: {},
containerSize: CGSize(width: availableSize.width - sideInset * 2.0, height: 50.0)
)
let usdValueFrame = CGRect(origin: CGPoint(x: sideInset + valueOffset + valueSize.width + 6.0, y: 14.0), size: usdValueSize)
if let usdValueView = self.usdValue.view {
if usdValueView.superview == nil {
self.addSubview(usdValueView)
}
usdValueView.frame = usdValueFrame
}
let titleSize = self.title.update(
transition: .immediate,
component: AnyComponent(
MultilineTextComponent(
text: .plain(NSAttributedString(string: component.title, font: Font.regular(13.0), textColor: component.theme.list.itemSecondaryTextColor))
)
),
environment: {},
containerSize: CGSize(width: availableSize.width - sideInset * 2.0, height: 50.0)
)
if let titleView = self.title.view {
if titleView.superview == nil {
self.addSubview(titleView)
}
let titleFrame = CGRect(origin: CGPoint(x: sideInset, y: 32.0), size: titleSize)
titleView.frame = titleFrame
}
return CGSize(width: availableSize.width, height: 59.0)
}
}
func makeView() -> View {
return View(frame: CGRect())
}
func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment<Empty>, transition: ComponentTransition) -> CGSize {
return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition)
}
}
@@ -0,0 +1,734 @@
import Foundation
import UIKit
import Display
import AsyncDisplayKit
import ComponentFlow
import SwiftSignalKit
import ViewControllerComponent
import ComponentDisplayAdapters
import TelegramPresentationData
import AccountContext
import TelegramCore
import MultilineTextComponent
import ListActionItemComponent
import TelegramStringFormatting
import AvatarNode
import BundleIconComponent
import PhotoResources
import StarsAvatarComponent
import GiftAnimationComponent
import TelegramStringFormatting
private extension StarsContext.State.Transaction {
var extendedId: String {
if self.count.amount > StarsAmount.zero {
return "\(id)_in"
} else {
return "\(id)_out"
}
}
}
final class StarsTransactionsListPanelComponent: Component {
typealias EnvironmentType = StarsTransactionsPanelEnvironment
let context: AccountContext
let transactionsContext: StarsTransactionsContext
let isAccount: Bool
let action: (StarsContext.State.Transaction) -> Void
init(
context: AccountContext,
transactionsContext: StarsTransactionsContext,
isAccount: Bool,
action: @escaping (StarsContext.State.Transaction) -> Void
) {
self.context = context
self.transactionsContext = transactionsContext
self.isAccount = isAccount
self.action = action
}
static func ==(lhs: StarsTransactionsListPanelComponent, rhs: StarsTransactionsListPanelComponent) -> Bool {
if lhs.context !== rhs.context {
return false
}
if lhs.isAccount != rhs.isAccount {
return false
}
return true
}
private struct ItemLayout: Equatable {
let containerInsets: UIEdgeInsets
let containerWidth: CGFloat
let itemHeight: CGFloat
let itemCount: Int
let contentHeight: CGFloat
init(
containerInsets: UIEdgeInsets,
containerWidth: CGFloat,
itemHeight: CGFloat,
itemCount: Int
) {
self.containerInsets = containerInsets
self.containerWidth = containerWidth
self.itemHeight = itemHeight
self.itemCount = itemCount
self.contentHeight = containerInsets.top + containerInsets.bottom + CGFloat(itemCount) * itemHeight
}
func visibleItems(for rect: CGRect) -> Range<Int>? {
let offsetRect = rect.offsetBy(dx: -self.containerInsets.left, dy: -self.containerInsets.top)
var minVisibleRow = Int(floor((offsetRect.minY) / (self.itemHeight)))
minVisibleRow = max(0, minVisibleRow)
let maxVisibleRow = Int(ceil((offsetRect.maxY) / (self.itemHeight)))
let minVisibleIndex = minVisibleRow
let maxVisibleIndex = maxVisibleRow
if maxVisibleIndex >= minVisibleIndex {
return minVisibleIndex ..< (maxVisibleIndex + 1)
} else {
return nil
}
}
func itemFrame(for index: Int) -> CGRect {
return CGRect(origin: CGPoint(x: 0.0, y: self.containerInsets.top + CGFloat(index) * self.itemHeight), size: CGSize(width: self.containerWidth, height: self.itemHeight))
}
}
private final class ScrollViewImpl: UIScrollView {
var forceDecelerating = false
override var isDecelerating: Bool {
return self.forceDecelerating || super.isDecelerating
}
override func touchesShouldCancel(in view: UIView) -> Bool {
return true
}
}
class View: UIView, UIScrollViewDelegate {
private let scrollView: ScrollViewImpl
private let measureItem = ComponentView<Empty>()
private var visibleItems: [AnyHashable: ComponentView<Empty>] = [:]
private var separatorLayers: [AnyHashable: SimpleLayer] = [:]
private var highlightLayer = SimpleLayer()
private var ignoreScrolling: Bool = false
private var component: StarsTransactionsListPanelComponent?
private var environment: StarsTransactionsPanelEnvironment?
private var itemLayout: ItemLayout?
private var items: [StarsContext.State.Transaction] = []
private var itemsDisposable: Disposable?
private var currentLoadMoreId: String?
override init(frame: CGRect) {
self.scrollView = ScrollViewImpl()
super.init(frame: frame)
self.scrollView.delaysContentTouches = true
self.scrollView.canCancelContentTouches = true
self.scrollView.clipsToBounds = false
if #available(iOSApplicationExtension 11.0, iOS 11.0, *) {
self.scrollView.contentInsetAdjustmentBehavior = .never
}
if #available(iOS 13.0, *) {
self.scrollView.automaticallyAdjustsScrollIndicatorInsets = false
}
self.scrollView.showsVerticalScrollIndicator = false
self.scrollView.showsHorizontalScrollIndicator = false
self.scrollView.alwaysBounceHorizontal = false
self.scrollView.scrollsToTop = false
self.scrollView.delegate = self
self.scrollView.clipsToBounds = true
self.addSubview(self.scrollView)
self.scrollView.layer.addSublayer(self.highlightLayer)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
deinit {
self.itemsDisposable?.dispose()
}
func scrollToTop() -> Bool {
if self.scrollView.contentOffset.y > 0.0 {
self.scrollView.setContentOffset(CGPoint(), animated: true)
return true
} else {
return false
}
}
func scrollViewDidScroll(_ scrollView: UIScrollView) {
if !self.ignoreScrolling {
self.updateScrolling(transition: .immediate)
}
}
func scrollViewWillBeginDragging(_ scrollView: UIScrollView) {
cancelContextGestures(view: scrollView)
if let decelerationAnimator = self.decelerationAnimator {
self.scrollView.forceDecelerating = false
self.decelerationAnimator = nil
decelerationAnimator.invalidate()
}
}
private var decelerationAnimator: ConstantDisplayLinkAnimator?
func transferVelocity(_ velocity: CGFloat) {
if velocity <= 0.0 {
return
}
self.decelerationAnimator?.isPaused = true
let startTime = CACurrentMediaTime()
var currentOffset = self.scrollView.contentOffset
let decelerationRate: CGFloat = 0.998
self.scrollView.forceDecelerating = true
//self.scrollViewDidEndDragging(self.scrollView, willDecelerate: true)
self.decelerationAnimator = ConstantDisplayLinkAnimator(update: { [weak self] in
guard let strongSelf = self else {
return
}
let t = CACurrentMediaTime() - startTime
var currentVelocity = velocity * 15.0 * CGFloat(pow(Double(decelerationRate), 1000.0 * t))
currentOffset.y += currentVelocity
let maxOffset = strongSelf.scrollView.contentSize.height - strongSelf.scrollView.bounds.height
if currentOffset.y >= maxOffset {
currentOffset.y = maxOffset
currentVelocity = 0.0
}
if currentOffset.y < 0.0 {
currentOffset.y = 0.0
currentVelocity = 0.0
}
var didEnd = false
if abs(currentVelocity) < 0.1 {
strongSelf.decelerationAnimator?.isPaused = true
strongSelf.decelerationAnimator = nil
didEnd = true
}
var contentOffset = strongSelf.scrollView.contentOffset
contentOffset.y = floorToScreenPixels(currentOffset.y)
strongSelf.scrollView.setContentOffset(contentOffset, animated: false)
strongSelf.scrollViewDidScroll(strongSelf.scrollView)
if didEnd {
//strongSelf.scrollViewDidEndDecelerating(strongSelf.scrollView)
strongSelf.scrollView.forceDecelerating = false
}
})
self.decelerationAnimator?.isPaused = false
}
private var highlightedItemId: AnyHashable?
private func updateHighlightedItem(itemId: AnyHashable?) {
guard let environment = self.environment else {
return
}
if self.highlightedItemId == itemId {
return
}
let previousHighlightedItemId = self.highlightedItemId
self.highlightedItemId = itemId
if let _ = previousHighlightedItemId, itemId == nil {
ComponentTransition.easeInOut(duration: 0.2).setBackgroundColor(layer: self.highlightLayer, color: .clear)
}
if let itemId, let itemView = self.visibleItems[itemId]?.view {
var highlightFrame = itemView.frame
highlightFrame.size.height += UIScreenPixel
self.highlightLayer.frame = highlightFrame
ComponentTransition.immediate.setBackgroundColor(layer: self.highlightLayer, color: environment.theme.list.itemHighlightedBackgroundColor)
}
}
private func updateScrolling(transition: ComponentTransition) {
guard let component = self.component, let environment = self.environment, let itemLayout = self.itemLayout else {
return
}
var visibleBounds = environment.externalScrollBounds ?? self.scrollView.bounds
visibleBounds = visibleBounds.insetBy(dx: 0.0, dy: -100.0)
var validIds = Set<AnyHashable>()
if let visibleItems = itemLayout.visibleItems(for: visibleBounds) {
for index in visibleItems.lowerBound ..< visibleItems.upperBound {
if index >= self.items.count {
continue
}
let item = self.items[index]
let id = AnyHashable(item.extendedId)
validIds.insert(id)
var itemTransition = transition
let itemView: ComponentView<Empty>
let separatorLayer: SimpleLayer
if let current = self.visibleItems[id], let currentSeparator = self.separatorLayers[id] {
itemView = current
separatorLayer = currentSeparator
} else {
itemTransition = .immediate
itemView = ComponentView()
self.visibleItems[id] = itemView
separatorLayer = SimpleLayer()
self.separatorLayers[id] = separatorLayer
self.scrollView.layer.addSublayer(separatorLayer)
}
separatorLayer.backgroundColor = environment.theme.list.itemBlocksSeparatorColor.cgColor
separatorLayer.isHidden = index == self.items.count - 1
let fontBaseDisplaySize = 17.0
var itemTitle: String
let itemSubtitle: String?
var itemDate: String
var itemPeer: StarsAvatarComponent.Peer = .transactionPeer(item.peer)
var itemFile: TelegramMediaFile?
var itemGift: StarGift?
switch item.peer {
case let .peer(peer):
if let months = item.premiumGiftMonths {
itemTitle = peer.displayTitle(strings: environment.strings, displayOrder: .firstLast)
itemSubtitle = environment.strings.Stars_Intro_Transaction_TelegramPremium(months)
} else if item.flags.contains(.isPostsSearch) {
itemTitle = environment.strings.Stars_Intro_Transaction_SearchFee
itemSubtitle = ""
itemPeer = .search
} else if item.flags.contains(.isLiveStreamPaidMessage) {
itemTitle = peer.displayTitle(strings: environment.strings, displayOrder: .firstLast)
if item.flags.contains(.isReaction) {
itemSubtitle = environment.strings.Stars_Intro_Transaction_LiveStreamReaction
} else {
itemSubtitle = environment.strings.Stars_Intro_Transaction_LiveStreamPaidMessage(item.paidMessageCount ?? 1)
}
} else if item.flags.contains(.isPaidMessage) {
itemTitle = peer.displayTitle(strings: environment.strings, displayOrder: .firstLast)
itemSubtitle = environment.strings.Stars_Intro_Transaction_PaidMessage(item.paidMessageCount ?? 1)
} else if let starGift = item.starGift {
if item.flags.contains(.isStarGiftAuctionBid), case let .generic(gift) = starGift {
itemTitle = gift.title ?? "Gift"
itemSubtitle = environment.strings.Stars_Intro_Transaction_GiftAuctionBid
itemGift = starGift
} else if item.flags.contains(.isStarGiftPrepaidUpgrade) {
itemTitle = peer.displayTitle(strings: environment.strings, displayOrder: .firstLast)
itemSubtitle = environment.strings.Stars_Intro_Transaction_PrepaidGiftUpgrade
} else if item.flags.contains(.isStarGiftDropOriginalDetails), case let .unique(gift) = starGift {
itemTitle = "\(gift.title) #\(presentationStringsFormattedNumber(gift.number, environment.dateTimeFormat.groupingSeparator))"
itemSubtitle = environment.strings.Stars_Intro_Transaction_GiftDropOriginalDetails
itemGift = starGift
} else if item.flags.contains(.isStarGiftUpgrade), case let .unique(gift) = starGift {
itemTitle = "\(gift.title) #\(presentationStringsFormattedNumber(gift.number, environment.dateTimeFormat.groupingSeparator))"
itemSubtitle = environment.strings.Stars_Intro_Transaction_GiftUpgrade
itemGift = starGift
} else {
itemTitle = peer.displayTitle(strings: environment.strings, displayOrder: .firstLast)
switch starGift {
case let .generic(gift):
itemFile = gift.file
itemSubtitle = item.count.amount > StarsAmount.zero ? environment.strings.Stars_Intro_Transaction_ConvertedGift : environment.strings.Stars_Intro_Transaction_Gift
case let .unique(gift):
for attribute in gift.attributes {
if case let .model(_, file, _, _) = attribute {
itemFile = file
break
}
}
if item.count.amount > StarsAmount.zero {
itemSubtitle = environment.strings.Stars_Intro_Transaction_GiftSale
} else {
if item.flags.contains(.isStarGiftResale) {
itemSubtitle = environment.strings.Stars_Intro_Transaction_GiftPurchase
} else {
itemSubtitle = environment.strings.Stars_Intro_Transaction_GiftTransfer
}
}
}
}
} else if let _ = item.giveawayMessageId {
itemTitle = peer.displayTitle(strings: environment.strings, displayOrder: .firstLast)
itemSubtitle = environment.strings.Stars_Intro_Transaction_GiveawayPrize
} else if !item.media.isEmpty {
itemTitle = environment.strings.Stars_Intro_Transaction_MediaPurchase
itemSubtitle = peer.displayTitle(strings: environment.strings, displayOrder: .firstLast)
} else if let title = item.title {
itemTitle = title
itemSubtitle = peer.displayTitle(strings: environment.strings, displayOrder: .firstLast)
} else {
itemTitle = peer.displayTitle(strings: environment.strings, displayOrder: .firstLast)
if item.flags.contains(.isReaction) {
itemSubtitle = environment.strings.Stars_Intro_Transaction_Reaction_Title
} else if item.flags.contains(.isGift) {
itemSubtitle = environment.strings.Stars_Intro_Transaction_Gift_Title
} else if let _ = item.subscriptionPeriod {
itemSubtitle = environment.strings.Stars_Intro_Transaction_SubscriptionFee_Title
} else {
itemSubtitle = nil
}
}
case .appStore:
itemTitle = environment.strings.Stars_Intro_Transaction_AppleTopUp_Title
itemSubtitle = environment.strings.Stars_Intro_Transaction_AppleTopUp_Subtitle
case .playMarket:
itemTitle = environment.strings.Stars_Intro_Transaction_GoogleTopUp_Title
itemSubtitle = environment.strings.Stars_Intro_Transaction_GoogleTopUp_Subtitle
case .fragment:
if component.isAccount {
if item.flags.contains(.isGift) {
itemTitle = environment.strings.Stars_Intro_Transaction_Gift_UnknownUser
itemSubtitle = environment.strings.Stars_Intro_Transaction_Gift_Title
itemPeer = .transactionPeer(.fragment)
} else {
if (item.count.amount.value < 0 && !item.flags.contains(.isRefund)) || (item.count.amount.value > 0 && item.flags.contains(.isRefund)) {
itemTitle = environment.strings.Stars_Intro_Transaction_FragmentWithdrawal_Title
itemSubtitle = environment.strings.Stars_Intro_Transaction_FragmentWithdrawal_Subtitle
} else {
itemTitle = environment.strings.Stars_Intro_Transaction_FragmentTopUp_Title
itemSubtitle = environment.strings.Stars_Intro_Transaction_FragmentTopUp_Subtitle
}
}
} else {
if item.count.amount > StarsAmount.zero && !item.flags.contains(.isRefund) {
itemTitle = environment.strings.Stars_Intro_Transaction_FragmentTopUp_Title
itemSubtitle = environment.strings.Stars_Intro_Transaction_FragmentTopUp_Subtitle
} else {
itemTitle = environment.strings.Stars_Intro_Transaction_FragmentWithdrawal_Title
itemSubtitle = environment.strings.Stars_Intro_Transaction_FragmentWithdrawal_Subtitle
}
}
case .premiumBot:
itemTitle = environment.strings.Stars_Intro_Transaction_PremiumBotTopUp_Title
itemSubtitle = environment.strings.Stars_Intro_Transaction_PremiumBotTopUp_Subtitle
case .ads:
itemTitle = environment.strings.Stars_Intro_Transaction_TelegramAds_Title
itemSubtitle = environment.strings.Stars_Intro_Transaction_TelegramAds_Subtitle
case .apiLimitExtension:
itemTitle = environment.strings.Stars_Intro_Transaction_TelegramBotApi_Title
if let floodskipNumber = item.floodskipNumber {
itemSubtitle = environment.strings.Stars_Intro_Transaction_TelegramBotApi_Messages(floodskipNumber)
} else {
itemSubtitle = nil
}
case .unsupported:
itemTitle = environment.strings.Stars_Intro_Transaction_Unsupported_Title
itemSubtitle = nil
}
let itemLabel: NSAttributedString
let formattedLabel = formatCurrencyAmountText(item.count, dateTimeFormat: environment.dateTimeFormat, showPlus: true)
let smallLabelFont = Font.with(size: floor(fontBaseDisplaySize / 17.0 * 13.0))
let labelFont = Font.medium(fontBaseDisplaySize)
let labelColor = formattedLabel.hasPrefix("-") ? environment.theme.list.itemDestructiveColor : environment.theme.list.itemDisclosureActions.constructive.fillColor
itemLabel = tonAmountAttributedString(formattedLabel, integralFont: labelFont, fractionalFont: smallLabelFont, color: labelColor, decimalSeparator: environment.dateTimeFormat.decimalSeparator)
let itemIconName: String
let itemIconColor: UIColor?
switch item.count.currency {
case .stars:
itemIconName = "Premium/Stars/StarMedium"
itemIconColor = nil
case .ton:
itemIconName = "Ads/TonAbout"
itemIconColor = labelColor
}
var itemDateColor = environment.theme.list.itemSecondaryTextColor
itemDate = stringForMediumCompactDate(timestamp: item.date, strings: environment.strings, dateTimeFormat: environment.dateTimeFormat)
if item.flags.contains(.isRefund) {
itemDate += " \(environment.strings.Stars_Intro_Transaction_Refund)"
} else if item.flags.contains(.isPending) {
itemDate += " \(environment.strings.Monetization_Transaction_Pending)"
} else if item.flags.contains(.isFailed) {
itemDate += " \(environment.strings.Monetization_Transaction_Failed)"
itemDateColor = environment.theme.list.itemDestructiveColor
}
var titleComponents: [AnyComponentWithIdentity<Empty>] = []
titleComponents.append(
AnyComponentWithIdentity(id: AnyHashable(0), component: AnyComponent(MultilineTextComponent(
text: .plain(NSAttributedString(
string: itemTitle,
font: Font.semibold(fontBaseDisplaySize),
textColor: environment.theme.list.itemPrimaryTextColor
)),
maximumNumberOfLines: 1
)))
)
if let itemSubtitle {
var items: [AnyComponentWithIdentity<Empty>] = []
if let itemFile {
items.append(AnyComponentWithIdentity(id: "icon", component: AnyComponent(
GiftAnimationComponent(
context: component.context,
theme: environment.theme,
file: itemFile,
still: true,
size: CGSize(width: 20.0, height: 20.0)
)
)))
}
items.append(AnyComponentWithIdentity(id: "title", component: AnyComponent(
MultilineTextComponent(
text: .plain(NSAttributedString(
string: itemSubtitle,
font: Font.regular(fontBaseDisplaySize * 16.0 / 17.0),
textColor: environment.theme.list.itemPrimaryTextColor
)),
maximumNumberOfLines: 1
)
)))
titleComponents.append(
AnyComponentWithIdentity(id: AnyHashable(1), component: AnyComponent(HStack(items, spacing: 2.0)))
)
}
titleComponents.append(
AnyComponentWithIdentity(id: AnyHashable(2), component: AnyComponent(MultilineTextComponent(
text: .plain(NSAttributedString(
string: itemDate,
font: Font.regular(floor(fontBaseDisplaySize * 14.0 / 17.0)),
textColor: itemDateColor
)),
maximumNumberOfLines: 1
)))
)
let _ = itemView.update(
transition: itemTransition,
component: AnyComponent(ListActionItemComponent(
theme: environment.theme,
title: AnyComponent(VStack(titleComponents, alignment: .left, spacing: 2.0)),
contentInsets: UIEdgeInsets(top: 9.0, left: environment.containerInsets.left, bottom: 8.0, right: environment.containerInsets.right),
leftIcon: .custom(AnyComponentWithIdentity(id: "avatar", component: AnyComponent(StarsAvatarComponent(context: component.context, theme: environment.theme, peer: itemPeer, photo: item.photo, media: item.media, gift: itemGift, backgroundColor: environment.theme.list.plainBackgroundColor))), false),
icon: nil,
accessory: .custom(ListActionItemComponent.CustomAccessory(component: AnyComponentWithIdentity(id: "label", component: AnyComponent(StarsLabelComponent(text: itemLabel, iconName: itemIconName, iconColor: itemIconColor))), insets: UIEdgeInsets(top: 0, left: 0, bottom: 0, right: 16.0))),
action: { [weak self] _ in
guard let self, let component = self.component else {
return
}
if !item.flags.contains(.isLocal) {
component.action(item)
}
},
updateIsHighlighted: { [weak self] _, highlighted in
guard let self else {
return
}
self.updateHighlightedItem(itemId: highlighted ? id : nil)
}
)),
environment: {},
containerSize: CGSize(width: itemLayout.containerWidth - itemLayout.containerInsets.left - itemLayout.containerInsets.right, height: itemLayout.itemHeight)
)
let itemFrame = itemLayout.itemFrame(for: index).offsetBy(dx: itemLayout.containerInsets.left, dy: 0.0)
if let itemComponentView = itemView.view {
if itemComponentView.superview == nil {
if !transition.animation.isImmediate {
transition.animateAlpha(view: itemComponentView, from: 0.0, to: 1.0)
}
self.scrollView.addSubview(itemComponentView)
}
itemTransition.setFrame(view: itemComponentView, frame: itemFrame)
}
let sideInset: CGFloat = 60.0 + environment.containerInsets.left
itemTransition.setFrame(layer: separatorLayer, frame: CGRect(x: sideInset, y: itemFrame.maxY, width: itemFrame.width - sideInset - environment.containerInsets.right, height: UIScreenPixel))
}
}
var removeIds: [AnyHashable] = []
for (id, itemView) in self.visibleItems {
if !validIds.contains(id) {
removeIds.append(id)
if let itemComponentView = itemView.view {
transition.setAlpha(view: itemComponentView, alpha: 0.0, completion: { [weak itemComponentView] _ in
itemComponentView?.removeFromSuperview()
})
}
}
}
for (id, separatorLayer) in self.separatorLayers {
if !validIds.contains(id) {
transition.setAlpha(layer: separatorLayer, alpha: 0.0, completion: { [weak separatorLayer] _ in
separatorLayer?.removeFromSuperlayer()
})
}
}
for id in removeIds {
self.visibleItems.removeValue(forKey: id)
}
let bottomOffset = self.environment?.externalBottomOffset ?? max(0.0, self.scrollView.contentSize.height - self.scrollView.contentOffset.y - self.scrollView.frame.height)
let loadMore = bottomOffset < 100.0
if environment.isCurrent, loadMore {
let lastId = self.items.last?.extendedId
if lastId != self.currentLoadMoreId || lastId == nil {
self.currentLoadMoreId = lastId
component.transactionsContext.loadMore()
}
}
}
private var isUpdating = false
func update(component: StarsTransactionsListPanelComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment<StarsTransactionsPanelEnvironment>, transition: ComponentTransition) -> CGSize {
self.isUpdating = true
defer {
self.isUpdating = false
}
self.component = component
if self.itemsDisposable == nil {
self.itemsDisposable = (component.transactionsContext.state
|> deliverOnMainQueue).start(next: { [weak self, weak state] status in
guard let self else {
return
}
let wasEmpty = self.items.isEmpty
let hadLocalTransactions = self.items.contains(where: { $0.flags.contains(.isLocal) })
var existingIds = Set<String>()
var filteredItems: [StarsContext.State.Transaction] = []
for transaction in status.transactions {
let id = transaction.extendedId
if !existingIds.contains(id) {
existingIds.insert(id)
filteredItems.append(transaction)
}
}
self.items = filteredItems
if !status.isLoading {
self.currentLoadMoreId = nil
}
if !self.isUpdating {
state?.updated(transition: wasEmpty || hadLocalTransactions ? .immediate : .easeInOut(duration: 0.2))
}
})
}
let environment = environment[StarsTransactionsPanelEnvironment.self].value
self.environment = environment
let fontBaseDisplaySize = 17.0
let measureItemSize = self.measureItem.update(
transition: .immediate,
component: AnyComponent(ListActionItemComponent(
theme: environment.theme,
title: AnyComponent(VStack([
AnyComponentWithIdentity(id: AnyHashable(0), component: AnyComponent(MultilineTextComponent(
text: .plain(NSAttributedString(
string: "ABC",
font: Font.regular(fontBaseDisplaySize),
textColor: environment.theme.list.itemPrimaryTextColor
)),
maximumNumberOfLines: 0
))),
AnyComponentWithIdentity(id: AnyHashable(1), component: AnyComponent(MultilineTextComponent(
text: .plain(NSAttributedString(
string: "abc",
font: Font.regular(fontBaseDisplaySize * 16.0 / 17.0),
textColor: environment.theme.list.itemPrimaryTextColor
)),
maximumNumberOfLines: 1
))),
AnyComponentWithIdentity(id: AnyHashable(2), component: AnyComponent(MultilineTextComponent(
text: .plain(NSAttributedString(
string: "abc",
font: Font.regular(floor(fontBaseDisplaySize * 14.0 / 17.0)),
textColor: environment.theme.list.itemSecondaryTextColor
)),
maximumNumberOfLines: 0,
lineSpacing: 0.18
)))
], alignment: .left, spacing: 2.0)),
contentInsets: UIEdgeInsets(top: 9.0, left: 0.0, bottom: 8.0, right: 0.0),
leftIcon: nil,
icon: nil,
accessory: nil,
action: { _ in }
)),
environment: {},
containerSize: CGSize(width: availableSize.width, height: 1000.0)
)
let itemLayout = ItemLayout(
containerInsets: environment.containerInsets,
containerWidth: availableSize.width,
itemHeight: measureItemSize.height,
itemCount: self.items.count
)
self.itemLayout = itemLayout
self.ignoreScrolling = true
let contentOffset = self.scrollView.bounds.minY
var scrollBounds = self.scrollView.bounds
if let _ = environment.externalScrollBounds {
scrollBounds.origin = CGPoint()
scrollBounds.size = CGSize(width: availableSize.width, height: itemLayout.contentHeight)
transition.setPosition(view: self.scrollView, position: scrollBounds.center)
} else {
transition.setPosition(view: self.scrollView, position: CGRect(origin: CGPoint(), size: availableSize).center)
scrollBounds.size = availableSize
if !environment.isScrollable {
scrollBounds.origin = CGPoint()
}
}
transition.setBounds(view: self.scrollView, bounds: scrollBounds)
self.scrollView.isScrollEnabled = environment.isScrollable
let contentSize = CGSize(width: availableSize.width, height: itemLayout.contentHeight)
if self.scrollView.contentSize != contentSize {
self.scrollView.contentSize = contentSize
}
self.scrollView.verticalScrollIndicatorInsets = environment.containerInsets
if !transition.animation.isImmediate && self.scrollView.bounds.minY != contentOffset {
let deltaOffset = self.scrollView.bounds.minY - contentOffset
transition.animateBoundsOrigin(view: self.scrollView, from: CGPoint(x: 0.0, y: -deltaOffset), to: CGPoint(), additive: true)
}
self.ignoreScrolling = false
self.updateScrolling(transition: transition)
if let _ = environment.externalScrollBounds {
return CGSize(width: availableSize.width, height: contentSize.height)
} else {
return availableSize
}
}
}
func makeView() -> View {
return View(frame: CGRect())
}
func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment<StarsTransactionsPanelEnvironment>, transition: ComponentTransition) -> CGSize {
return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition)
}
}
func cancelContextGestures(view: UIView) {
if let gestureRecognizers = view.gestureRecognizers {
for gesture in gestureRecognizers {
if let gesture = gesture as? ContextGesture {
gesture.cancel()
}
}
}
for subview in view.subviews {
cancelContextGestures(view: subview)
}
}
@@ -0,0 +1,850 @@
import Foundation
import UIKit
import Display
import ComponentFlow
import ComponentDisplayAdapters
import TelegramPresentationData
final class StarsTransactionsPanelContainerEnvironment: Equatable {
let isScrollable: Bool
init(
isScrollable: Bool
) {
self.isScrollable = isScrollable
}
static func ==(lhs: StarsTransactionsPanelContainerEnvironment, rhs: StarsTransactionsPanelContainerEnvironment) -> Bool {
if lhs.isScrollable != rhs.isScrollable {
return false
}
return true
}
}
final class StarsTransactionsPanelEnvironment: Equatable {
let theme: PresentationTheme
let strings: PresentationStrings
let dateTimeFormat: PresentationDateTimeFormat
let containerInsets: UIEdgeInsets
let isScrollable: Bool
let isCurrent: Bool
let externalScrollBounds: CGRect?
let externalBottomOffset: CGFloat?
init(
theme: PresentationTheme,
strings: PresentationStrings,
dateTimeFormat: PresentationDateTimeFormat,
containerInsets: UIEdgeInsets,
isScrollable: Bool,
isCurrent: Bool,
externalScrollBounds: CGRect? = nil,
externalBottomOffset: CGFloat? = nil
) {
self.theme = theme
self.strings = strings
self.dateTimeFormat = dateTimeFormat
self.containerInsets = containerInsets
self.isScrollable = isScrollable
self.isCurrent = isCurrent
self.externalScrollBounds = externalScrollBounds
self.externalBottomOffset = externalBottomOffset
}
static func ==(lhs: StarsTransactionsPanelEnvironment, rhs: StarsTransactionsPanelEnvironment) -> Bool {
if lhs.theme !== rhs.theme {
return false
}
if lhs.strings !== rhs.strings {
return false
}
if lhs.dateTimeFormat != rhs.dateTimeFormat {
return false
}
if lhs.containerInsets != rhs.containerInsets {
return false
}
if lhs.isScrollable != rhs.isScrollable {
return false
}
if lhs.isCurrent != rhs.isCurrent {
return false
}
if lhs.externalScrollBounds != rhs.externalScrollBounds {
return false
}
if lhs.externalBottomOffset != rhs.externalBottomOffset {
return false
}
return true
}
}
private final class StarsTransactionsHeaderItemComponent: CombinedComponent {
let theme: PresentationTheme
let title: String
let activityFraction: CGFloat
init(
theme: PresentationTheme,
title: String,
activityFraction: CGFloat
) {
self.theme = theme
self.title = title
self.activityFraction = activityFraction
}
static func ==(lhs: StarsTransactionsHeaderItemComponent, rhs: StarsTransactionsHeaderItemComponent) -> Bool {
if lhs.theme !== rhs.theme {
return false
}
if lhs.title != rhs.title {
return false
}
if lhs.activityFraction != rhs.activityFraction {
return false
}
return true
}
static var body: Body {
let activeText = Child(Text.self)
let inactiveText = Child(Text.self)
return { context in
let activeText = activeText.update(
component: Text(text: context.component.title, font: Font.medium(14.0), color: context.component.theme.list.itemAccentColor),
availableSize: context.availableSize,
transition: .immediate
)
let inactiveText = inactiveText.update(
component: Text(text: context.component.title, font: Font.medium(14.0), color: context.component.theme.list.itemSecondaryTextColor),
availableSize: context.availableSize,
transition: .immediate
)
context.add(activeText
.position(CGPoint(x: activeText.size.width * 0.5, y: activeText.size.height * 0.5))
.opacity(context.component.activityFraction)
)
context.add(inactiveText
.position(CGPoint(x: inactiveText.size.width * 0.5, y: inactiveText.size.height * 0.5))
.opacity(1.0 - context.component.activityFraction)
)
return activeText.size
}
}
}
private extension CGFloat {
func interpolate(with other: CGFloat, fraction: CGFloat) -> CGFloat {
let invT = 1.0 - fraction
let result = other * fraction + self * invT
return result
}
}
private extension CGPoint {
func interpolate(with other: CGPoint, fraction: CGFloat) -> CGPoint {
return CGPoint(x: self.x.interpolate(with: other.x, fraction: fraction), y: self.y.interpolate(with: other.y, fraction: fraction))
}
}
private extension CGSize {
func interpolate(with other: CGSize, fraction: CGFloat) -> CGSize {
return CGSize(width: self.width.interpolate(with: other.width, fraction: fraction), height: self.height.interpolate(with: other.height, fraction: fraction))
}
}
private extension CGRect {
func interpolate(with other: CGRect, fraction: CGFloat) -> CGRect {
return CGRect(origin: self.origin.interpolate(with: other.origin, fraction: fraction), size: self.size.interpolate(with: other.size, fraction: fraction))
}
}
private final class StarsTransactionsHeaderComponent: Component {
struct Item: Equatable {
let id: AnyHashable
let title: String
init(
id: AnyHashable,
title: String
) {
self.id = id
self.title = title
}
}
let theme: PresentationTheme
let items: [Item]
let activeIndex: Int
let transitionFraction: CGFloat
let switchToPanel: (AnyHashable) -> Void
init(
theme: PresentationTheme,
items: [Item],
activeIndex: Int,
transitionFraction: CGFloat,
switchToPanel: @escaping (AnyHashable) -> Void
) {
self.theme = theme
self.items = items
self.activeIndex = activeIndex
self.transitionFraction = transitionFraction
self.switchToPanel = switchToPanel
}
static func ==(lhs: StarsTransactionsHeaderComponent, rhs: StarsTransactionsHeaderComponent) -> Bool {
if lhs.theme !== rhs.theme {
return false
}
if lhs.items != rhs.items {
return false
}
if lhs.activeIndex != rhs.activeIndex {
return false
}
if lhs.transitionFraction != rhs.transitionFraction {
return false
}
return true
}
class View: UIView {
private var component: StarsTransactionsHeaderComponent?
private var visibleItems: [AnyHashable: ComponentView<Empty>] = [:]
private let activeItemLayer: SimpleLayer
override init(frame: CGRect) {
self.activeItemLayer = SimpleLayer()
self.activeItemLayer.cornerRadius = 2.0
self.activeItemLayer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner]
super.init(frame: frame)
self.layer.addSublayer(self.activeItemLayer)
self.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(self.tapGesture(_:))))
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
@objc private func tapGesture(_ recognizer: UITapGestureRecognizer) {
if case .ended = recognizer.state {
let point = recognizer.location(in: self)
var closestId: (CGFloat, AnyHashable)?
if self.bounds.contains(point) {
for (id, item) in self.visibleItems {
if let itemView = item.view {
let distance: CGFloat = min(abs(point.x - itemView.frame.minX), abs(point.x - itemView.frame.maxX))
if let closestIdValue = closestId {
if distance < closestIdValue.0 {
closestId = (distance, id)
}
} else {
closestId = (distance, id)
}
}
}
}
if let closestId = closestId, let component = self.component {
component.switchToPanel(closestId.1)
}
}
}
func update(component: StarsTransactionsHeaderComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment<Empty>, transition: ComponentTransition) -> CGSize {
let themeUpdated = self.component?.theme !== component.theme
self.component = component
var validIds = Set<AnyHashable>()
for i in 0 ..< component.items.count {
let item = component.items[i]
validIds.insert(item.id)
let itemView: ComponentView<Empty>
var itemTransition = transition
if let current = self.visibleItems[item.id] {
itemView = current
} else {
itemTransition = .immediate
itemView = ComponentView()
self.visibleItems[item.id] = itemView
}
let activeIndex: CGFloat = CGFloat(component.activeIndex) - component.transitionFraction
let activityDistance: CGFloat = abs(activeIndex - CGFloat(i))
let activityFraction: CGFloat
if activityDistance < 1.0 {
activityFraction = 1.0 - activityDistance
} else {
activityFraction = 0.0
}
let itemSize = itemView.update(
transition: itemTransition,
component: AnyComponent(StarsTransactionsHeaderItemComponent(
theme: component.theme,
title: item.title,
activityFraction: activityFraction
)),
environment: {},
containerSize: availableSize
)
let itemHorizontalSpace = availableSize.width / CGFloat(component.items.count)
let itemX: CGFloat
if component.items.count == 1 {
itemX = 37.0
} else {
itemX = itemHorizontalSpace * CGFloat(i) + floor((itemHorizontalSpace - itemSize.width) / 2.0)
}
let itemFrame = CGRect(origin: CGPoint(x: itemX, y: floor((availableSize.height - itemSize.height) / 2.0)), size: itemSize)
if let itemComponentView = itemView.view {
if itemComponentView.superview == nil {
self.addSubview(itemComponentView)
itemComponentView.isUserInteractionEnabled = false
}
itemTransition.setFrame(view: itemComponentView, frame: itemFrame)
}
}
if component.activeIndex < component.items.count {
let activeView = self.visibleItems[component.items[component.activeIndex].id]?.view
let nextIndex: Int
if component.transitionFraction > 0.0 {
nextIndex = max(0, component.activeIndex - 1)
} else {
nextIndex = min(component.items.count - 1, component.activeIndex + 1)
}
let nextView = self.visibleItems[component.items[nextIndex].id]?.view
if let activeView = activeView, let nextView = nextView {
let mergedFrame = activeView.frame.interpolate(with: nextView.frame, fraction: abs(component.transitionFraction))
transition.setFrame(layer: self.activeItemLayer, frame: CGRect(origin: CGPoint(x: mergedFrame.minX, y: availableSize.height - 3.0), size: CGSize(width: mergedFrame.width, height: 3.0)))
}
}
if themeUpdated {
self.activeItemLayer.backgroundColor = component.theme.list.itemAccentColor.cgColor
}
var removeIds: [AnyHashable] = []
for (id, itemView) in self.visibleItems {
if !validIds.contains(id) {
removeIds.append(id)
if let itemComponentView = itemView.view {
itemComponentView.removeFromSuperview()
}
}
}
for id in removeIds {
self.visibleItems.removeValue(forKey: id)
}
return availableSize
}
}
func makeView() -> View {
return View(frame: CGRect())
}
func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment<Empty>, transition: ComponentTransition) -> CGSize {
return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition)
}
}
final class StarsTransactionsPanelContainerComponent: Component {
typealias EnvironmentType = StarsTransactionsPanelContainerEnvironment
struct Item: Equatable {
let id: AnyHashable
let title: String
let panel: AnyComponent<StarsTransactionsPanelEnvironment>
init(
id: AnyHashable,
title: String,
panel: AnyComponent<StarsTransactionsPanelEnvironment>
) {
self.id = id
self.title = title
self.panel = panel
}
}
let theme: PresentationTheme
let strings: PresentationStrings
let dateTimeFormat: PresentationDateTimeFormat
let insets: UIEdgeInsets
let items: [Item]
let currentPanelUpdated: (AnyHashable, ComponentTransition) -> Void
init(
theme: PresentationTheme,
strings: PresentationStrings,
dateTimeFormat: PresentationDateTimeFormat,
insets: UIEdgeInsets,
items: [Item],
currentPanelUpdated: @escaping (AnyHashable, ComponentTransition) -> Void
) {
self.theme = theme
self.strings = strings
self.dateTimeFormat = dateTimeFormat
self.insets = insets
self.items = items
self.currentPanelUpdated = currentPanelUpdated
}
static func ==(lhs: StarsTransactionsPanelContainerComponent, rhs: StarsTransactionsPanelContainerComponent) -> Bool {
if lhs.theme !== rhs.theme {
return false
}
if lhs.strings !== rhs.strings {
return false
}
if lhs.dateTimeFormat != rhs.dateTimeFormat {
return false
}
if lhs.insets != rhs.insets {
return false
}
if lhs.items != rhs.items {
return false
}
return true
}
class View: UIView, UIGestureRecognizerDelegate {
private let topPanelClippingView: UIView
private let topPanelBackgroundView: UIView
private let topPanelMergedBackgroundView: UIView
private let topPanelSeparatorLayer: SimpleLayer
private let header = ComponentView<Empty>()
private var component: StarsTransactionsPanelContainerComponent?
private weak var state: EmptyComponentState?
private let panelsBackgroundLayer: SimpleLayer
private let clippingView: UIView
private var visiblePanels: [AnyHashable: ComponentView<StarsTransactionsPanelEnvironment>] = [:]
private var actualVisibleIds = Set<AnyHashable>()
private var currentId: AnyHashable?
private var transitionFraction: CGFloat = 0.0
private var animatingTransition: Bool = false
override init(frame: CGRect) {
self.topPanelClippingView = UIView()
self.topPanelClippingView.clipsToBounds = true
self.topPanelClippingView.layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner]
self.topPanelBackgroundView = UIView()
self.topPanelMergedBackgroundView = UIView()
self.topPanelMergedBackgroundView.alpha = 0.0
self.topPanelSeparatorLayer = SimpleLayer()
self.panelsBackgroundLayer = SimpleLayer()
self.clippingView = UIView()
self.clippingView.clipsToBounds = true
super.init(frame: frame)
self.layer.addSublayer(self.panelsBackgroundLayer)
self.addSubview(self.clippingView)
self.addSubview(self.topPanelClippingView)
self.topPanelClippingView.addSubview(self.topPanelBackgroundView)
self.topPanelClippingView.addSubview(self.topPanelMergedBackgroundView)
self.layer.addSublayer(self.topPanelSeparatorLayer)
let panRecognizer = InteractiveTransitionGestureRecognizer(target: self, action: #selector(self.panGesture(_:)), allowedDirections: { [weak self] point in
guard let self, let component = self.component, let currentId = self.currentId else {
return []
}
guard let index = component.items.firstIndex(where: { $0.id == currentId }) else {
return []
}
/*if strongSelf.tabsContainerNode.bounds.contains(strongSelf.view.convert(point, to: strongSelf.tabsContainerNode.view)) {
return []
}*/
if index == 0 {
return .left
}
return [.left, .right]
})
panRecognizer.delegate = self
panRecognizer.delaysTouchesBegan = false
panRecognizer.cancelsTouchesInView = true
self.addGestureRecognizer(panRecognizer)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
var currentPanelView: UIView? {
guard let currentId = self.currentId, let panel = self.visiblePanels[currentId] else {
return nil
}
return panel.view
}
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
return false
}
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldBeRequiredToFailBy otherGestureRecognizer: UIGestureRecognizer) -> Bool {
if let _ = otherGestureRecognizer as? InteractiveTransitionGestureRecognizer {
return false
}
if let _ = otherGestureRecognizer as? UIPanGestureRecognizer {
return true
}
return false
}
@objc private func panGesture(_ recognizer: UIPanGestureRecognizer) {
switch recognizer.state {
case .began:
func cancelContextGestures(view: UIView) {
if let gestureRecognizers = view.gestureRecognizers {
for gesture in gestureRecognizers {
if let gesture = gesture as? ContextGesture {
gesture.cancel()
}
}
}
for subview in view.subviews {
cancelContextGestures(view: subview)
}
}
cancelContextGestures(view: self)
//self.animatingTransition = true
case .changed:
guard let component = self.component, let currentId = self.currentId else {
return
}
guard let index = component.items.firstIndex(where: { $0.id == currentId }) else {
return
}
let translation = recognizer.translation(in: self)
var transitionFraction = translation.x / self.bounds.width
if index <= 0 {
transitionFraction = min(0.0, transitionFraction)
}
if index >= component.items.count - 1 {
transitionFraction = max(0.0, transitionFraction)
}
self.transitionFraction = transitionFraction
self.state?.updated(transition: .immediate)
case .cancelled, .ended:
guard let component = self.component, let currentId = self.currentId else {
return
}
guard let index = component.items.firstIndex(where: { $0.id == currentId }) else {
return
}
let translation = recognizer.translation(in: self)
let velocity = recognizer.velocity(in: self)
var directionIsToRight: Bool?
if abs(velocity.x) > 10.0 {
directionIsToRight = velocity.x < 0.0
} else {
if abs(translation.x) > self.bounds.width / 2.0 {
directionIsToRight = translation.x > self.bounds.width / 2.0
}
}
if let directionIsToRight = directionIsToRight {
var updatedIndex = index
if directionIsToRight {
updatedIndex = min(updatedIndex + 1, component.items.count - 1)
} else {
updatedIndex = max(updatedIndex - 1, 0)
}
self.currentId = component.items[updatedIndex].id
}
self.transitionFraction = 0.0
let transition = ComponentTransition(animation: .curve(duration: 0.35, curve: .spring))
if let currentId = self.currentId {
self.state?.updated(transition: transition)
component.currentPanelUpdated(currentId, transition)
}
self.animatingTransition = false
//self.currentPaneUpdated?(false)
//self.currentPaneStatusPromise.set(self.currentPane?.node.status ?? .single(nil))
default:
break
}
}
func updateNavigationMergeFactor(value: CGFloat, transition: ComponentTransition) {
transition.setAlpha(view: self.topPanelMergedBackgroundView, alpha: value)
transition.setAlpha(view: self.topPanelBackgroundView, alpha: 1.0 - value)
}
func transferVelocity(_ velocity: CGFloat) {
if let currentPanelView = self.currentPanelView as? StarsTransactionsListPanelComponent.View {
currentPanelView.transferVelocity(velocity)
}
}
func scrollToTop() -> Bool {
if let currentPanelView = self.currentPanelView as? StarsTransactionsListPanelComponent.View {
return currentPanelView.scrollToTop()
}
return false
}
func update(component: StarsTransactionsPanelContainerComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment<StarsTransactionsPanelContainerEnvironment>, transition: ComponentTransition) -> CGSize {
let environment = environment[StarsTransactionsPanelContainerEnvironment.self].value
let themeUpdated = self.component?.theme !== component.theme
self.component = component
self.state = state
if themeUpdated {
self.panelsBackgroundLayer.backgroundColor = component.theme.list.itemBlocksBackgroundColor.cgColor
self.topPanelSeparatorLayer.backgroundColor = component.theme.list.itemBlocksSeparatorColor.cgColor
self.topPanelBackgroundView.backgroundColor = component.theme.list.itemBlocksBackgroundColor
self.topPanelMergedBackgroundView.backgroundColor = component.theme.rootController.navigationBar.blurredBackgroundColor
}
let topPanelCoverHeight: CGFloat = 10.0
let containerWidth = availableSize.width - component.insets.left - component.insets.right
let topPanelFrame = CGRect(origin: CGPoint(x: component.insets.left, y: -topPanelCoverHeight), size: CGSize(width: containerWidth, height: 44.0))
transition.setFrame(view: self.topPanelClippingView, frame: topPanelFrame)
transition.setFrame(view: self.topPanelBackgroundView, frame: CGRect(origin: .zero, size: topPanelFrame.size))
transition.setFrame(view: self.topPanelMergedBackgroundView, frame: CGRect(origin: .zero, size: topPanelFrame.size))
transition.setCornerRadius(layer: self.topPanelClippingView.layer, cornerRadius: component.insets.left > 0.0 ? 26.0 : 0.0)
transition.setFrame(layer: self.panelsBackgroundLayer, frame: CGRect(origin: CGPoint(x: component.insets.left, y: topPanelFrame.maxY), size: CGSize(width: containerWidth, height: availableSize.height - topPanelFrame.maxY)))
transition.setFrame(layer: self.topPanelSeparatorLayer, frame: CGRect(origin: CGPoint(x: component.insets.left, y: topPanelFrame.maxY), size: CGSize(width: containerWidth, height: UIScreenPixel)))
if let currentIdValue = self.currentId, !component.items.contains(where: { $0.id == currentIdValue }) {
self.currentId = nil
}
if self.currentId == nil {
self.currentId = component.items.first?.id
}
var visibleIds = Set<AnyHashable>()
var currentIndex: Int?
if let currentId = self.currentId {
visibleIds.insert(currentId)
if let index = component.items.firstIndex(where: { $0.id == currentId }) {
currentIndex = index
if index != 0 {
visibleIds.insert(component.items[index - 1].id)
}
if index != component.items.count - 1 {
visibleIds.insert(component.items[index + 1].id)
}
}
}
let sideInset: CGFloat = 16.0 + component.insets.left
let condensedPanelWidth: CGFloat = availableSize.width - sideInset * 2.0
let headerSize = self.header.update(
transition: transition,
component: AnyComponent(StarsTransactionsHeaderComponent(
theme: component.theme,
items: component.items.map { item -> StarsTransactionsHeaderComponent.Item in
return StarsTransactionsHeaderComponent.Item(
id: item.id,
title: item.title
)
},
activeIndex: currentIndex ?? 0,
transitionFraction: self.transitionFraction,
switchToPanel: { [weak self] id in
guard let self, let component = self.component else {
return
}
if component.items.contains(where: { $0.id == id }) {
self.currentId = id
let transition = ComponentTransition(animation: .curve(duration: 0.35, curve: .spring))
self.state?.updated(transition: transition)
component.currentPanelUpdated(id, transition)
}
}
)),
environment: {},
containerSize: CGSize(width: condensedPanelWidth, height: topPanelFrame.size.height)
)
if let headerView = self.header.view {
if headerView.superview == nil {
self.addSubview(headerView)
}
transition.setFrame(view: headerView, frame: CGRect(origin: topPanelFrame.origin.offsetBy(dx: 16.0, dy: 0.0), size: headerSize))
}
let centralPanelFrame = CGRect(origin: CGPoint(x: 0.0, y: topPanelFrame.maxY), size: CGSize(width: availableSize.width, height: availableSize.height - topPanelFrame.maxY))
if self.animatingTransition {
visibleIds = visibleIds.filter({ self.visiblePanels[$0] != nil })
}
self.actualVisibleIds = visibleIds
for (id, _) in self.visiblePanels {
visibleIds.insert(id)
}
var validIds = Set<AnyHashable>()
if let currentIndex {
var anyAnchorOffset: CGFloat = 0.0
for (id, panel) in self.visiblePanels {
guard let itemIndex = component.items.firstIndex(where: { $0.id == id }), let panelView = panel.view else {
continue
}
var itemFrame = centralPanelFrame.offsetBy(dx: self.transitionFraction * availableSize.width, dy: 0.0)
if itemIndex < currentIndex {
itemFrame.origin.x -= itemFrame.width
} else if itemIndex > currentIndex {
itemFrame.origin.x += itemFrame.width
}
anyAnchorOffset = itemFrame.minX - panelView.frame.minX
break
}
for id in visibleIds {
guard let itemIndex = component.items.firstIndex(where: { $0.id == id }) else {
continue
}
let panelItem = component.items[itemIndex]
var itemFrame = centralPanelFrame.offsetBy(dx: self.transitionFraction * availableSize.width, dy: 0.0)
if itemIndex < currentIndex {
itemFrame.origin.x -= itemFrame.width
} else if itemIndex > currentIndex {
itemFrame.origin.x += itemFrame.width
}
validIds.insert(panelItem.id)
let panel: ComponentView<StarsTransactionsPanelEnvironment>
var panelTransition = transition
var animateInIfNeeded = false
if let current = self.visiblePanels[panelItem.id] {
panel = current
if let panelView = panel.view, !panelView.bounds.isEmpty {
var wasHidden = false
if abs(panelView.frame.minX - availableSize.width) < .ulpOfOne || abs(panelView.frame.maxX - 0.0) < .ulpOfOne {
wasHidden = true
}
var isHidden = false
if abs(itemFrame.minX - availableSize.width) < .ulpOfOne || abs(itemFrame.maxX - 0.0) < .ulpOfOne {
isHidden = true
}
if wasHidden && isHidden {
panelTransition = .immediate
}
}
} else {
panelTransition = .immediate
animateInIfNeeded = true
panel = ComponentView()
self.visiblePanels[panelItem.id] = panel
}
let childEnvironment = StarsTransactionsPanelEnvironment(
theme: component.theme,
strings: component.strings,
dateTimeFormat: component.dateTimeFormat,
containerInsets: UIEdgeInsets(top: 0.0, left: component.insets.left, bottom: component.insets.bottom, right: component.insets.right),
isScrollable: environment.isScrollable,
isCurrent: self.currentId == panelItem.id
)
let _ = panel.update(
transition: panelTransition,
component: panelItem.panel,
environment: {
childEnvironment
},
containerSize: centralPanelFrame.size
)
if let panelView = panel.view {
if panelView.superview == nil {
self.clippingView.addSubview(panelView)
}
panelTransition.setFrame(view: panelView, frame: itemFrame, completion: { [weak self] _ in
guard let self else {
return
}
if !self.actualVisibleIds.contains(id) {
if let panel = self.visiblePanels[id] {
self.visiblePanels.removeValue(forKey: id)
panel.view?.removeFromSuperview()
}
}
})
if animateInIfNeeded && anyAnchorOffset != 0.0 {
transition.animatePosition(view: panelView, from: CGPoint(x: -anyAnchorOffset, y: 0.0), to: CGPoint(), additive: true)
}
}
}
}
let clippingFrame = CGRect(origin: CGPoint(x: component.insets.left, y: 0.0), size: CGSize(width: availableSize.width - component.insets.left - component.insets.right, height: availableSize.height))
transition.setPosition(view: self.clippingView, position: clippingFrame.center)
transition.setBounds(view: self.clippingView, bounds: CGRect(origin: CGPoint(x: component.insets.left, y: 0.0), size: clippingFrame.size))
var removeIds: [AnyHashable] = []
for (id, panel) in self.visiblePanels {
if !validIds.contains(id) {
removeIds.append(id)
if let panelView = panel.view {
panelView.removeFromSuperview()
}
}
}
for id in removeIds {
self.visiblePanels.removeValue(forKey: id)
}
return availableSize
}
}
func makeView() -> View {
return View(frame: CGRect())
}
func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment<StarsTransactionsPanelContainerEnvironment>, transition: ComponentTransition) -> CGSize {
return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition)
}
}