Telegram-iOS/submodules/TelegramUI/Components/Chat/ChatMessagePaymentAlertController/Sources/ChatMessagePaymentAlertController.swift
isaac 86d1456552 Postbox -> TelegramEngine waves 107-137 (squashed)
31 waves of consumer-side migration from `import Postbox` to TelegramEngine
typealiases. Net: 173 import drops + 39 BUILD-dep drops + 1 new typealias
(`EngineStoryId = StoryId`, wave 113).

Wave shapes used:
- Orphan-import sweeps (107, 108, 128): drop `import Postbox` from files
  whose only Postbox-symbol reference was the import line itself, then
  resolve build failures. Methodology requires token-level (`grep -oE`)
  filtering, not line-level, to avoid masking real Postbox usage on lines
  that also contain `Namespaces.X` references.
- Identifier-swap mini-waves (109-127, 129-134, 136-137): rename
  Postbox-typealiased identifiers to engine equivalents
  (PeerId -> EnginePeer.Id, MessageId -> EngineMessage.Id,
  MediaId -> EngineMedia.Id, MessageIndex -> EngineMessage.Index,
  StoryId -> EngineStoryId, ItemCollectionId -> EngineItemCollectionId,
  PreferencesEntry -> EnginePreferencesEntry,
  FetchResourceSourceType/Error -> EngineFetchResourceSourceType/Error,
  MemoryBuffer -> EngineMemoryBuffer, MessageTags -> EngineMessage.Tags,
  MessageAttribute -> EngineMessage.Attribute,
  TempBox -> EngineTempBox).
- Asset-string FP-only orphans (124).
- Typealias addition + drain (113): added `EngineStoryId` typealias to
  TelegramCore, then drained 3+11 consumer sites.

Hard blockers identified during these waves (must restore `import Postbox`
when present): MediaResource[A-Za-z]* (any suffix -- the literal
`MediaResource` matches don't catch MediaResourceData/MediaResourceId/etc.),
Postbox/MediaBox/MediaResource raw types, PostboxCoding/PostboxEncoder/
PostboxDecoder, TempBoxFile, ValueBoxKey, PostboxView, combinedView,
HashFunctions, postboxLog, openPostbox, declareEncodable, PeerView,
MessageHistoryView, MessageHistoryThreadData, CachedPeerData, RenderedPeer,
SelectivePrivacyPeer, SimpleDictionary, ItemCollectionInfosView,
ItemCollectionItem, ItemCollectionItemIndex, ItemCollectionViewEntryIndex,
ChatListIndex, ChatListEntrySummaryComponents, CodableEntry,
MessageHistoryThread, MessageHistoryAnchorIndex,
MessageHistoryEntryLocation, PeerStoryStats, PeerNameIndex,
PeerSummaryCounterTags, ChatListTotalUnreadStateCategory/Stats,
arePeersEqual. Protocol-shape blockers: bare `Peer`/`Message`/`Media`
in function signatures, generic args, enum-case payloads, or dict value
types (e.g., `[PeerId: Peer]`, `case messages([Message])`,
`Signal<(Peer?, ...), NoError>`).

`replace_all PeerId -> EnginePeer.Id` is dangerous: mangles compound
names like `failedPeerId`, `ContactListPeerId`, `nextRemoteMediaId`,
`replyToMessageId`. Pre-flight grep `\b[a-z][a-zA-Z]*PeerId\b` and only
replace_all if 0 matches.

Also removes unneeded design/plan docs from a separate (link-highlighting)
feature branch:
- docs/superpowers/plans/2026-05-02-link-highlighting-modern-path-fixes.md
- docs/superpowers/specs/2026-05-02-link-highlighting-modern-path-fixes-design.md

Squashed commits: 6d82c2980d..e6de5d53a3 (59 commits, including
per-wave content commits and per-wave CLAUDE.md bumps).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 10:28:50 +02:00

353 lines
15 KiB
Swift

import Foundation
import UIKit
import SwiftSignalKit
import AsyncDisplayKit
import Display
import ComponentFlow
import ComponentDisplayAdapters
import TelegramCore
import TelegramPresentationData
import TelegramUIPreferences
import AccountContext
import AppBundle
import AvatarNode
import CheckNode
import Markdown
import TextFormat
import StarsBalanceOverlayComponent
import AlertComponent
import AlertCheckComponent
public class ChatMessagePaymentAlertController: AlertScreen {
private let context: AccountContext?
private let presentationData: PresentationData
private weak var parentNavigationController: NavigationController?
private let chatPeerId: EnginePeer.Id
private let showBalance: Bool
private let animateBalanceOverlay: Bool
private var didUpdateCurrency = false
private var initialCurrency: CurrencyAmount.Currency?
public var currency: CurrencyAmount.Currency?
private var currencyDisposable: Disposable?
private let balance = ComponentView<Empty>()
private var didAppear = false
public init(
context: AccountContext?,
presentationData: PresentationData,
updatedPresentationData: (initial: PresentationData, signal: Signal<PresentationData, NoError>)? = nil,
configuration: Configuration = AlertScreen.Configuration(),
contentSignal: Signal<[AnyComponentWithIdentity<AlertComponentEnvironment>], NoError>,
actionsSignal: Signal<[AlertScreen.Action], NoError>,
navigationController: NavigationController?,
chatPeerId: EnginePeer.Id,
showBalance: Bool = true,
currencySignal: Signal<CurrencyAmount.Currency, NoError> = .single(.stars),
animateBalanceOverlay: Bool = true
) {
self.context = context
self.presentationData = presentationData
self.parentNavigationController = navigationController
self.chatPeerId = chatPeerId
self.showBalance = showBalance
self.animateBalanceOverlay = animateBalanceOverlay
var effectiveUpdatedPresentationData: (initial: PresentationData, signal: Signal<PresentationData, NoError>)
if let updatedPresentationData {
effectiveUpdatedPresentationData = updatedPresentationData
} else {
effectiveUpdatedPresentationData = (initial: presentationData, signal: .single(presentationData))
}
super.init(
configuration: configuration,
contentSignal: contentSignal,
actionsSignal: actionsSignal,
updatedPresentationData: effectiveUpdatedPresentationData
)
self.currencyDisposable = (currencySignal
|> distinctUntilChanged
|> deliverOnMainQueue).start(next: { [weak self] currency in
guard let self else {
return
}
if self.currency == nil {
self.initialCurrency = currency
}
self.currency = currency
if let layout = self.validLayout {
self.containerLayoutUpdated(layout, transition: .animated(duration: 0.25, curve: .easeInOut))
}
})
}
public convenience init(
context: AccountContext?,
presentationData: PresentationData,
updatedPresentationData: (initial: PresentationData, signal: Signal<PresentationData, NoError>)? = nil,
configuration: Configuration = AlertScreen.Configuration(),
content: [AnyComponentWithIdentity<AlertComponentEnvironment>],
actions: [AlertScreen.Action],
navigationController: NavigationController?,
chatPeerId: EnginePeer.Id,
showBalance: Bool = true,
currency: CurrencyAmount.Currency = .stars,
animateBalanceOverlay: Bool = true
) {
self.init(
context: context,
presentationData: presentationData,
updatedPresentationData: updatedPresentationData,
configuration: configuration,
contentSignal: .single(content),
actionsSignal: .single(actions),
navigationController: navigationController,
chatPeerId: chatPeerId,
showBalance: showBalance,
currencySignal: .single(currency),
animateBalanceOverlay: animateBalanceOverlay
)
}
required public init(coder aDecoder: NSCoder) {
preconditionFailure()
}
override public func dismiss(completion: (() -> Void)? = nil) {
super.dismiss(completion: completion)
self.animateOut()
}
private func animateOut() {
if !self.animateBalanceOverlay {
if case .ton = self.currency, let initialCurrency, initialCurrency != self.currency {
self.currency = .stars
if let layout = self.validLayout {
self.containerLayoutUpdated(layout, transition: .animated(duration: 0.25, curve: .easeInOut))
}
}
} else {
if let view = self.balance.view {
view.layer.animateScale(from: 1.0, to: 0.8, duration: 0.4, removeOnCompletion: false)
view.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.3, removeOnCompletion: false)
}
}
}
public override func containerLayoutUpdated(_ layout: ContainerViewLayout, transition: ContainedViewLayoutTransition) {
super.containerLayoutUpdated(layout, transition: transition)
if !self.didAppear {
self.didAppear = true
if !layout.metrics.isTablet && layout.size.width > layout.size.height {
Queue.mainQueue().after(0.1) {
self.view.window?.endEditing(true)
}
}
}
if let context = self.context, let _ = self.parentNavigationController, self.showBalance, let currency = self.currency {
let insets = layout.insets(options: .statusBar)
var balanceTransition = ComponentTransition(transition)
if self.balance.view == nil {
balanceTransition = .immediate
}
let balanceSize = self.balance.update(
transition: balanceTransition,
component: AnyComponent(
StarsBalanceOverlayComponent(
context: context,
peerId: self.chatPeerId.namespace == Namespaces.Peer.CloudChannel ? self.chatPeerId : context.account.peerId,
theme: self.presentationData.theme,
currency: currency,
action: { [weak self] in
guard let self, let starsContext = context.starsContext, let navigationController = self.parentNavigationController, let currency = self.currency else {
return
}
switch currency {
case .stars:
let _ = (context.engine.payments.starsTopUpOptions()
|> take(1)
|> deliverOnMainQueue).startStandalone(next: { options in
let controller = context.sharedContext.makeStarsPurchaseScreen(
context: context,
starsContext: starsContext,
options: options,
purpose: .generic,
targetPeerId: nil,
customTheme: nil,
completion: { _ in }
)
navigationController.pushViewController(controller)
})
case .ton:
var fragmentUrl = "https://fragment.com/ads/topup"
if let data = context.currentAppConfiguration.with({ $0 }).data, let value = data["ton_topup_url"] as? String {
fragmentUrl = value
}
context.sharedContext.applicationBindings.openUrl(fragmentUrl)
}
self.dismiss(completion: nil)
}
)
),
environment: {},
containerSize: layout.size
)
if let view = self.balance.view {
if view.superview == nil {
self.view.addSubview(view)
if self.animateBalanceOverlay {
view.layer.animatePosition(from: CGPoint(x: 0.0, y: -64.0), to: .zero, duration: 0.4, timingFunction: kCAMediaTimingFunctionSpring, additive: true)
view.layer.animateSpring(from: 0.8 as NSNumber, to: 1.0 as NSNumber, keyPath: "transform.scale", duration: 0.5, initialVelocity: 0.0, removeOnCompletion: true, additive: false, completion: nil)
view.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.25)
}
}
balanceTransition.setFrame(view: view, frame: CGRect(origin: CGPoint(x: floorToScreenPixels((layout.size.width - balanceSize.width) / 2.0), y: insets.top + 5.0), size: balanceSize))
}
}
}
}
public func chatMessagePaymentAlertController(
context: AccountContext?,
presentationData: PresentationData,
updatedPresentationData: (initial: PresentationData, signal: Signal<PresentationData, NoError>)?,
peers: [EngineRenderedPeer],
count: Int32,
amount: StarsAmount,
totalAmount: StarsAmount?,
hasCheck: Bool = true,
navigationController: NavigationController?,
completion: @escaping (Bool) -> Void
) -> ViewController {
let strings = presentationData.strings
let messagesString = strings.Chat_PaidMessage_Confirm_Text_Messages(count)
let text: String
if peers.count == 1, let peer = peers.first {
let amountString = strings.Chat_PaidMessage_Confirm_Text_Stars(Int32(clamping: amount.value))
let totalString = strings.Chat_PaidMessage_Confirm_Text_Stars(Int32(clamping: amount.value * Int64(count)))
if case let .channel(channel) = peer.chatOrMonoforumMainPeer, case .broadcast = channel.info {
text = strings.Chat_PaidMessage_Confirm_SingleComment_Text(EnginePeer(channel).compactDisplayTitle, amountString, totalString, messagesString).string
} else {
text = strings.Chat_PaidMessage_Confirm_Single_Text(peer.chatOrMonoforumMainPeer?.compactDisplayTitle ?? " ", amountString, totalString, messagesString).string
}
} else {
let amount = totalAmount ?? amount
let usersString = strings.Chat_PaidMessage_Confirm_Text_Users(Int32(peers.count))
let totalString = strings.Chat_PaidMessage_Confirm_Text_Stars(Int32(clamping: amount.value * Int64(count)))
text = strings.Chat_PaidMessage_Confirm_Multiple_Text(usersString, totalString, messagesString).string
}
let checkState = AlertCheckComponent.ExternalState()
var content: [AnyComponentWithIdentity<AlertComponentEnvironment>] = []
content.append(AnyComponentWithIdentity(
id: "title",
component: AnyComponent(
AlertTitleComponent(title: strings.Chat_PaidMessage_Confirm_Title)
)
))
content.append(AnyComponentWithIdentity(
id: "text",
component: AnyComponent(
AlertTextComponent(content: .plain(text))
)
))
if hasCheck {
content.append(AnyComponentWithIdentity(
id: "check",
component: AnyComponent(
AlertCheckComponent(title: strings.Chat_PaidMessage_Confirm_DontAskAgain, initialValue: false, externalState: checkState)
)
))
}
let alertController = ChatMessagePaymentAlertController(
context: context,
presentationData: presentationData,
updatedPresentationData: updatedPresentationData,
configuration: AlertScreen.Configuration(actionAlignment: .vertical, allowInputInset: true),
content: content,
actions: [
.init(title: strings.Chat_PaidMessage_Confirm_PayForMessage(count), type: .default, action: {
completion(checkState.value)
}),
.init(title: strings.Common_Cancel)
],
navigationController: navigationController,
chatPeerId: context?.account.peerId ?? peers[0].peerId
)
return alertController
}
public func chatMessageRemovePaymentAlertController(
context: AccountContext? = nil,
presentationData: PresentationData,
updatedPresentationData: (initial: PresentationData, signal: Signal<PresentationData, NoError>)?,
peer: EnginePeer,
chatPeer: EnginePeer,
amount: StarsAmount?,
navigationController: NavigationController?,
completion: @escaping (Bool) -> Void
) -> ViewController {
let strings = presentationData.strings
let text: String
if case .user = chatPeer {
text = strings.Chat_PaidMessage_RemoveFee_Text(peer.compactDisplayTitle).string
} else if let context, chatPeer.id != context.account.peerId {
text = strings.Channel_RemoveFeeAlert_Text(peer.compactDisplayTitle).string
} else {
text = strings.Chat_PaidMessage_RemoveFee_Text(peer.compactDisplayTitle).string
}
let checkState = AlertCheckComponent.ExternalState()
var content: [AnyComponentWithIdentity<AlertComponentEnvironment>] = []
content.append(AnyComponentWithIdentity(
id: "title",
component: AnyComponent(
AlertTitleComponent(title: strings.Chat_PaidMessage_RemoveFee_Title)
)
))
content.append(AnyComponentWithIdentity(
id: "text",
component: AnyComponent(
AlertTextComponent(content: .plain(text))
)
))
if let amount {
content.append(AnyComponentWithIdentity(
id: "check",
component: AnyComponent(
AlertCheckComponent(title: strings.Chat_PaidMessage_RemoveFee_Refund(strings.Chat_PaidMessage_RemoveFee_Refund_Stars(Int32(clamping: amount.value))).string, initialValue: false, externalState: checkState)
)
))
}
let alertController = ChatMessagePaymentAlertController(
context: context,
presentationData: presentationData,
updatedPresentationData: updatedPresentationData,
content: content,
actions: [
.init(title: strings.Common_Cancel),
.init(title: strings.Chat_PaidMessage_RemoveFee_Yes, type: .default, action: {
completion(checkState.value)
})
],
navigationController: navigationController,
chatPeerId: chatPeer.id
)
return alertController
}