mirror of
https://github.com/ichmagmaus111/ghostgram.git
synced 2026-11-03 22:27:13 +01:00
Ghostgram for iOS, rebuilt from scratch on Telegram iOS 13.0. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
405 lines
18 KiB
Swift
405 lines
18 KiB
Swift
import Foundation
|
|
import UIKit
|
|
import Display
|
|
import SwiftSignalKit
|
|
import TelegramCore
|
|
import AccountContext
|
|
import WalletContext
|
|
import WalletSendScreen
|
|
import PremiumDiamondComponent
|
|
import ChatMessageBubbleItemNode
|
|
import ChatMessageTransferBubbleContentNode
|
|
import ChatControllerInteraction
|
|
|
|
final class ChatWalletTransferAnimation {
|
|
private final class Arrival {
|
|
let message: EngineRawMessage
|
|
let rise: Bool
|
|
var startTime: CFTimeInterval?
|
|
var didPlayHaptic = false
|
|
weak var target: ChatMessageTransferBubbleContentNode?
|
|
|
|
init(message: EngineRawMessage, rise: Bool) {
|
|
self.message = message
|
|
self.rise = rise
|
|
}
|
|
}
|
|
|
|
private final class Flight {
|
|
let id: String
|
|
let operationId: String
|
|
let peerId: EnginePeer.Id
|
|
var source: WalletSendTransferAnimationSource?
|
|
var overlay: UIView?
|
|
var createdAt: CFTimeInterval
|
|
var startedAt: CFTimeInterval?
|
|
var stableId: UInt32?
|
|
var localId: Int32?
|
|
var transactionHash: Data?
|
|
weak var target: ChatMessageTransferBubbleContentNode?
|
|
|
|
init(id: String, peerId: EnginePeer.Id) {
|
|
self.id = id
|
|
self.operationId = String(id.dropFirst("pending:".count))
|
|
self.peerId = peerId
|
|
self.createdAt = CACurrentMediaTime()
|
|
}
|
|
|
|
func launch(source: WalletSendTransferAnimationSource, window: UIWindow) {
|
|
self.source = source
|
|
self.createdAt = CACurrentMediaTime()
|
|
let overlay = UIView(frame: window.bounds)
|
|
overlay.autoresizingMask = [.flexibleWidth, .flexibleHeight]
|
|
overlay.isUserInteractionEnabled = false
|
|
overlay.accessibilityElementsHidden = true
|
|
window.addSubview(overlay)
|
|
overlay.addSubview(source.diamond)
|
|
source.diamond.center = source.center
|
|
source.diamond.transform = CGAffineTransform(rotationAngle: source.rotation)
|
|
source.diamond.isRenderingEnabled = true
|
|
self.overlay = overlay
|
|
}
|
|
|
|
func matches(_ message: EngineRawMessage) -> Bool {
|
|
guard message.id.peerId == self.peerId else { return false }
|
|
let matches: Bool
|
|
if let pending = message.attributes.compactMap({ $0 as? PendingWalletTransferMessageAttribute }).first {
|
|
matches = pending.operationId == self.operationId
|
|
} else if self.stableId == message.stableId {
|
|
matches = true
|
|
} else if message.id.namespace == Namespaces.Message.Local && self.localId == message.id.id {
|
|
matches = true
|
|
} else {
|
|
matches = self.transactionHash != nil && message.media.contains(where: { media in
|
|
guard let action = media as? TelegramMediaAction,
|
|
case let .gramTransfer(_, _, transactionId, _, _) = action.action else { return false }
|
|
return ChatWalletTransferAnimation.transactionHash(transactionId) == self.transactionHash
|
|
})
|
|
}
|
|
if matches { self.stableId = message.stableId }
|
|
return matches
|
|
}
|
|
}
|
|
|
|
private weak var controller: ChatControllerImpl?
|
|
private var flights: [String: Flight] = [:]
|
|
private var endedFlights = Set<String>()
|
|
private var arrivals: [EngineMessage.Id: Arrival] = [:]
|
|
private var seenArrivals = Set<EngineMessage.Id>()
|
|
private var lastArrivalStartTime: CFTimeInterval?
|
|
private var displayLink: SharedDisplayLinkDriver.Link?
|
|
private var activityDisposable: Disposable?
|
|
private var isApplicationActive = UIApplication.shared.applicationState == .active
|
|
private var isApplicationInForeground = UIApplication.shared.applicationState != .background
|
|
|
|
init(controller: ChatControllerImpl) {
|
|
self.controller = controller
|
|
controller.controllerInteraction?.isAwaitingWalletTransferFlight = { [weak self] message in
|
|
guard let self else { return false }
|
|
for flight in self.flights.values {
|
|
if let state = self.controller?.context.walletContext?.stateValue {
|
|
self.refresh(flight, state: state)
|
|
}
|
|
if flight.matches(message) { return true }
|
|
}
|
|
return false
|
|
}
|
|
self.activityDisposable = (combineLatest(
|
|
controller.context.sharedContext.applicationBindings.applicationIsActive,
|
|
controller.context.sharedContext.applicationBindings.applicationInForeground
|
|
) |> deliverOnMainQueue).start(next: { [weak self] active, foreground in
|
|
guard let self else { return }
|
|
self.isApplicationActive = active
|
|
self.isApplicationInForeground = foreground
|
|
if !foreground {
|
|
self.cancelAll()
|
|
} else if !active {
|
|
for id in Array(self.arrivals.keys) {
|
|
self.cancelArrival(id)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
|
|
deinit {
|
|
self.activityDisposable?.dispose()
|
|
self.displayLink?.invalidate()
|
|
for flight in self.flights.values {
|
|
flight.source?.diamond.isRenderingEnabled = false
|
|
flight.overlay?.removeFromSuperview()
|
|
}
|
|
for arrival in self.arrivals.values {
|
|
arrival.target?.finishIncomingTransferAnimation()
|
|
}
|
|
}
|
|
|
|
func arrivalState(_ id: EngineMessage.Id) -> WalletTransferArrivalState? {
|
|
if let arrival = self.arrivals[id] {
|
|
if let startTime = arrival.startTime {
|
|
return .playing(startTime: startTime, rise: arrival.rise)
|
|
}
|
|
return .queued(rise: arrival.rise)
|
|
}
|
|
return self.seenArrivals.contains(id) ? .finished : nil
|
|
}
|
|
|
|
func requestArrival(_ message: EngineRawMessage) {
|
|
guard self.isApplicationActive, !self.seenArrivals.contains(message.id),
|
|
let interaction = self.controller?.controllerInteraction, interaction.canReadHistory,
|
|
message.flags.contains(.Incoming),
|
|
interaction.unreadMessageRange[UnreadMessageRangeKey(peerId: message.id.peerId, namespace: message.id.namespace)]?.contains(message.id.id) == true else { return }
|
|
self.seenArrivals.insert(message.id)
|
|
let rise = interaction.freshWalletTransferMessageIds.remove(message.id) != nil
|
|
guard !UIAccessibility.isReduceMotionEnabled else { return }
|
|
self.arrivals[message.id] = Arrival(message: message, rise: rise)
|
|
// All requests from the current layout transaction are sorted on the next frame.
|
|
self.ensureDisplayLink()
|
|
}
|
|
|
|
func cancelArrival(_ id: EngineMessage.Id) {
|
|
guard let arrival = self.arrivals.removeValue(forKey: id) else { return }
|
|
arrival.target?.finishIncomingTransferAnimation()
|
|
for node in self.contentNodes() where node.item?.message.id == id && node !== arrival.target {
|
|
node.finishIncomingTransferAnimation()
|
|
}
|
|
if self.arrivals.isEmpty { self.lastArrivalStartTime = nil }
|
|
self.stopDisplayLinkIfIdle()
|
|
}
|
|
|
|
private func updateArrivals(at now: CFTimeInterval, nodes: [ChatMessageTransferBubbleContentNode]) {
|
|
for arrival in self.arrivals.values.sorted(by: { $0.message.index < $1.message.index }) {
|
|
let id = arrival.message.id
|
|
guard self.controller?.controllerInteraction?.canReadHistory == true,
|
|
!UIAccessibility.isReduceMotionEnabled,
|
|
let node = nodes.first(where: { $0.item?.message.id == id && $0.canPlayIncomingTransferAnimation }) else {
|
|
self.cancelArrival(id)
|
|
continue
|
|
}
|
|
if let previous = arrival.target, previous !== node {
|
|
previous.finishIncomingTransferAnimation()
|
|
}
|
|
arrival.target = node
|
|
if arrival.startTime == nil {
|
|
if let previousStart = self.lastArrivalStartTime, now - previousStart < 0.9 { continue }
|
|
arrival.startTime = now
|
|
self.lastArrivalStartTime = now
|
|
}
|
|
guard let startTime = arrival.startTime else { continue }
|
|
if now - startTime >= WalletTransferArrivalAnimation.duration {
|
|
self.cancelArrival(id)
|
|
} else {
|
|
node.updateIncomingTransferAnimation(startTime: startTime, rise: arrival.rise, at: now)
|
|
if self.arrivals[id] === arrival, !arrival.didPlayHaptic,
|
|
now - startTime >= WalletTransferArrivalAnimation.climax {
|
|
arrival.didPlayHaptic = true
|
|
Haptics.strong()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func prepare(id: String) {
|
|
guard self.isApplicationInForeground, !UIAccessibility.isReduceMotionEnabled,
|
|
id.hasPrefix("pending:"), self.flights[id] == nil, !self.endedFlights.contains(id),
|
|
let controller = self.controller, let peerId = controller.chatLocation.peerId,
|
|
let state = controller.context.walletContext?.stateValue,
|
|
let transaction = state.transactions.items.first(where: { $0.presentationId == id }),
|
|
transaction.status != .failed else { return }
|
|
let flight = Flight(id: id, peerId: peerId)
|
|
self.refresh(flight, state: state)
|
|
self.flights[id] = flight
|
|
CATransaction.begin()
|
|
CATransaction.setDisableActions(true)
|
|
for node in self.contentNodes() where node.item.map({ flight.matches($0.message) }) == true {
|
|
node.setAwaitingTransferFlight(true)
|
|
}
|
|
CATransaction.commit()
|
|
self.ensureDisplayLink()
|
|
}
|
|
|
|
func receive(id: String, source: WalletSendTransferAnimationSource?) -> Bool {
|
|
guard self.isApplicationInForeground, !UIAccessibility.isReduceMotionEnabled,
|
|
id.hasPrefix("pending:"), self.flights[id]?.source == nil, !self.endedFlights.contains(id),
|
|
let controller = self.controller, controller.isNodeLoaded,
|
|
let peerId = controller.chatLocation.peerId,
|
|
let wallet = controller.context.walletContext,
|
|
let transaction = wallet.stateValue.transactions.items.first(where: { $0.presentationId == id }),
|
|
transaction.status != .failed, let source, let window = source.window,
|
|
controller.view.window === window else {
|
|
self.cancel(id: id, animated: true)
|
|
self.stopDisplayLinkIfIdle()
|
|
return false
|
|
}
|
|
CATransaction.begin()
|
|
CATransaction.setDisableActions(true)
|
|
let flight = self.flights[id] ?? Flight(id: id, peerId: peerId)
|
|
flight.launch(source: source, window: window)
|
|
self.refresh(flight, state: wallet.stateValue)
|
|
self.flights[id] = flight
|
|
for node in self.contentNodes() where node.item.map({ flight.matches($0.message) }) == true {
|
|
node.setAwaitingTransferFlight(true)
|
|
}
|
|
CATransaction.commit()
|
|
controller.scrollToEndOfHistory()
|
|
self.update()
|
|
self.ensureDisplayLink()
|
|
return true
|
|
}
|
|
|
|
private func ensureDisplayLink() {
|
|
if (!self.flights.isEmpty || !self.arrivals.isEmpty) && self.displayLink == nil {
|
|
self.displayLink = SharedDisplayLinkDriver.shared.add(framesPerSecond: .max, { [weak self] _ in
|
|
self?.update()
|
|
})
|
|
}
|
|
}
|
|
|
|
func cancelAll() {
|
|
for id in Array(self.arrivals.keys) {
|
|
self.cancelArrival(id)
|
|
}
|
|
for id in Array(self.flights.keys) {
|
|
self.cancel(id: id, animated: false)
|
|
}
|
|
self.stopDisplayLinkIfIdle()
|
|
}
|
|
|
|
private static func transactionHash(_ value: String?) -> Data? {
|
|
guard let value else { return nil }
|
|
let parts = value.split(separator: ":", maxSplits: 1, omittingEmptySubsequences: false)
|
|
guard parts.count == 1 || UInt64(parts[0]) != nil,
|
|
let last = parts.last, let hash = Data(base64Encoded: String(last)), hash.count == 32 else { return nil }
|
|
return hash
|
|
}
|
|
|
|
private func refresh(_ flight: Flight, state: WalletContext.State) {
|
|
if let pending = state.pendingTransfers.first(where: { $0.id == flight.operationId }) {
|
|
flight.localId = pending.pendingMessage?.localId ?? flight.localId
|
|
flight.transactionHash = Self.transactionHash(pending.transactionHash) ?? flight.transactionHash
|
|
}
|
|
if let transaction = state.transactions.items.first(where: { $0.presentationId == flight.id }) {
|
|
flight.transactionHash = Self.transactionHash(transaction.transactionHash ?? transaction.id) ?? flight.transactionHash
|
|
}
|
|
}
|
|
|
|
private func contentNodes() -> [ChatMessageTransferBubbleContentNode] {
|
|
guard let controller = self.controller, controller.isNodeLoaded else { return [] }
|
|
var result: [ChatMessageTransferBubbleContentNode] = []
|
|
controller.chatDisplayNode.historyNode.forEachItemNode { node in
|
|
guard let node = node as? ChatMessageBubbleItemNode else { return }
|
|
result.append(contentsOf: node.contentNodes.compactMap { $0 as? ChatMessageTransferBubbleContentNode })
|
|
}
|
|
return result
|
|
}
|
|
|
|
private func update() {
|
|
guard self.isApplicationInForeground, let controller = self.controller, controller.isNodeLoaded,
|
|
controller.view.window != nil else {
|
|
self.cancelAll()
|
|
return
|
|
}
|
|
let now = CACurrentMediaTime()
|
|
let nodes = self.contentNodes()
|
|
CATransaction.begin()
|
|
CATransaction.setDisableActions(true)
|
|
defer { CATransaction.commit() }
|
|
self.updateArrivals(at: now, nodes: nodes)
|
|
for id in Array(self.flights.keys) {
|
|
guard let flight = self.flights[id] else { continue }
|
|
guard let state = controller.context.walletContext?.stateValue else {
|
|
self.cancel(id: id, animated: false)
|
|
continue
|
|
}
|
|
self.refresh(flight, state: state)
|
|
let transaction = state.transactions.items.first(where: { $0.presentationId == id })
|
|
let hasPending = state.pendingTransfers.contains(where: { $0.id == flight.operationId })
|
|
guard !UIAccessibility.isReduceMotionEnabled, flight.peerId == controller.chatLocation.peerId,
|
|
transaction?.status != .failed, transaction != nil || hasPending else {
|
|
self.cancel(id: id, animated: true)
|
|
continue
|
|
}
|
|
guard let source = flight.source, let overlay = flight.overlay else {
|
|
if now - flight.createdAt >= 1.5 { self.cancel(id: id, animated: true) }
|
|
continue
|
|
}
|
|
let target = nodes.first(where: { node in
|
|
node.item.map({ flight.matches($0.message) }) == true
|
|
&& node.transferDiamondTarget(in: overlay) != nil
|
|
})
|
|
if let previous = flight.target, previous !== target,
|
|
previous.item.map({ flight.matches($0.message) }) == true {
|
|
previous.setAwaitingTransferFlight(false)
|
|
}
|
|
flight.target = target
|
|
guard let target, let slot = target.transferDiamondTarget(in: overlay),
|
|
overlay.bounds.contains(slot.center) else {
|
|
if flight.startedAt != nil || now - flight.createdAt >= 0.5 {
|
|
self.cancel(id: id, animated: true)
|
|
} else {
|
|
source.updateFlightHaptics(at: now)
|
|
}
|
|
continue
|
|
}
|
|
source.updateFlightHaptics(at: now)
|
|
target.setAwaitingTransferFlight(true)
|
|
if flight.startedAt == nil { flight.startedAt = now }
|
|
let t = min(1.0, max(0.0, now - (flight.startedAt ?? now)) / 0.62)
|
|
let deltaY = slot.center.y - source.center.y
|
|
let rise = 56.0 + max(0.0, -deltaY)
|
|
let b = -2.0 * rise - 2.0 * sqrt(max(0.0, rise * rise + rise * deltaY))
|
|
let diamond = source.diamond
|
|
diamond.transform = CGAffineTransform(rotationAngle: source.rotation * (1.0 - t))
|
|
diamond.center = CGPoint(
|
|
x: source.center.x + (slot.center.x - source.center.x) * t,
|
|
y: source.center.y + b * t + (deltaY - b) * t * t
|
|
)
|
|
let width = (source.width + (slot.width - source.width) * t)
|
|
* (1.0 + 0.28 * sin(.pi * min(1.0, t / 0.75)))
|
|
let speed = Float(2.0 * .pi / 26.0 + (5.0 - 2.0 * .pi / 26.0) * min(1.0, t / 0.6))
|
|
diamond.updateWalletTransfer(width: width, rotationSpeed: speed, completion: false,
|
|
isDark: controller.presentationData.theme.overallDarkAppearance, appearance: t > 0.5 ? .cool : .blue)
|
|
if t >= 1.0 {
|
|
self.flights.removeValue(forKey: id)
|
|
self.endedFlights.insert(id)
|
|
Haptics.hit(0.8)
|
|
target.acceptTransferDiamond(diamond)
|
|
flight.overlay?.removeFromSuperview()
|
|
}
|
|
}
|
|
self.stopDisplayLinkIfIdle()
|
|
}
|
|
|
|
private func cancel(id: String, animated: Bool) {
|
|
guard let flight = self.flights.removeValue(forKey: id) else { return }
|
|
self.endedFlights.insert(id)
|
|
for node in self.contentNodes() where node.item.map({ flight.matches($0.message) }) == true {
|
|
node.setAwaitingTransferFlight(false, animated: animated)
|
|
}
|
|
flight.source?.diamond.isRenderingEnabled = false
|
|
if animated, let overlay = flight.overlay {
|
|
overlay.alpha = 0.0
|
|
overlay.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.15, completion: { _ in
|
|
overlay.removeFromSuperview()
|
|
})
|
|
} else {
|
|
flight.overlay?.removeFromSuperview()
|
|
}
|
|
}
|
|
|
|
private func stopDisplayLinkIfIdle() {
|
|
if self.flights.isEmpty && self.arrivals.isEmpty {
|
|
self.displayLink?.invalidate()
|
|
self.displayLink = nil
|
|
}
|
|
}
|
|
}
|
|
|
|
extension ChatControllerImpl {
|
|
func walletTransferAnimationCoordinator() -> ChatWalletTransferAnimation {
|
|
if let current = self.walletTransferAnimation { return current }
|
|
let animation = ChatWalletTransferAnimation(controller: self)
|
|
self.walletTransferAnimation = animation
|
|
return animation
|
|
}
|
|
}
|