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>
2423 lines
119 KiB
Swift
2423 lines
119 KiB
Swift
import Foundation
|
||
import LottieSettings
|
||
import UIKit
|
||
import CoreText
|
||
import Metal
|
||
import MetalEngine
|
||
import AsyncDisplayKit
|
||
import Display
|
||
import PremiumDiamondComponent
|
||
import SwiftSignalKit
|
||
import TelegramCore
|
||
import AccountContext
|
||
import TelegramPresentationData
|
||
import TextFormat
|
||
import LocalizedPeerData
|
||
import TelegramStringFormatting
|
||
import WallpaperBackgroundNode
|
||
import ChatMessageBubbleContentNode
|
||
import ChatMessageItemCommon
|
||
import ChatControllerInteraction
|
||
import TextSelectionNode
|
||
import InvisibleInkDustNode
|
||
import WalletContext
|
||
import WalletCardComponent
|
||
|
||
private enum TransferCardStatus: Equatable {
|
||
case waiting
|
||
case pending
|
||
case completed
|
||
case unavailable
|
||
}
|
||
|
||
public enum WalletTransferArrivalAnimation {
|
||
public static let climax = passTime(122.0)
|
||
static let landing = climax - 0.08
|
||
public static let duration = landing + 1.8
|
||
|
||
static func passTime(_ x: CGFloat) -> Double {
|
||
let e = min(1.0, max(0.0, (Double(x) + 30.0) / 320.0))
|
||
return 0.05 + 1.1 * (0.5 - sin(asin(1.0 - 2.0 * e) / 3.0))
|
||
}
|
||
|
||
static func sheenX(_ time: Double) -> CGFloat? {
|
||
let u = (time - 0.05) / 1.1
|
||
guard u >= 0.0, u <= 1.0 else { return nil }
|
||
return CGFloat(-30.0 + 320.0 * u * u * (3.0 - 2.0 * u))
|
||
}
|
||
|
||
static func crossing(_ point: CGPoint) -> CGFloat {
|
||
// Card content is inset four points in the reference's 224 x 156 canvas.
|
||
return point.x + 4.0 + 0.35 * (point.y + 4.0) - 13.7
|
||
}
|
||
|
||
static func lift(_ time: Double, at x: CGFloat) -> CGFloat {
|
||
let t = time - passTime(x)
|
||
guard t > 0.0, t < 0.8 else { return 0.0 }
|
||
return CGFloat(sin(2.0 * .pi * 1.7 * t) * exp(-t / 0.2))
|
||
}
|
||
}
|
||
|
||
private final class TransferArrivalTextView: UIView {
|
||
private struct GlyphKey: Hashable {
|
||
let font: String
|
||
let size: CGFloat
|
||
let glyph: CGGlyph
|
||
}
|
||
private struct Shape {
|
||
let path: CGPath
|
||
let advance: CGFloat
|
||
let image: UIImage?
|
||
}
|
||
private struct Glyph {
|
||
let path: CGPath
|
||
let origin: CGPoint
|
||
let color: CGColor
|
||
let kind: Int
|
||
let landing: CGFloat
|
||
let value: Int?
|
||
let column: Int
|
||
let pitch: CGFloat
|
||
let advance: CGFloat
|
||
let digits: [Shape]
|
||
let image: UIImage?
|
||
}
|
||
private static var shapes: [GlyphKey: Shape] = [:]
|
||
private var glyphs: [Glyph] = []
|
||
private var layouts: [(TextNodeLayout, CGRect)] = []
|
||
private var integralCount = 0
|
||
private var time: Double = 0.0
|
||
private let addressGradient: CGGradient
|
||
private let nameGradient: CGGradient
|
||
|
||
override init(frame: CGRect) {
|
||
func gradient(color: UIColor, peak: CGFloat) -> CGGradient {
|
||
let alphas: [CGFloat] = [0.0, 0.18, 0.6, 1.0, 0.6, 0.18, 0.0]
|
||
let colors = alphas.map { color.withAlphaComponent($0 * peak).cgColor }
|
||
let locations: [CGFloat] = [0.0, 0.22, 0.4, 0.5, 0.6, 0.78, 1.0]
|
||
return CGGradient(colorsSpace: CGColorSpaceCreateDeviceRGB(), colors: colors as CFArray, locations: locations)!
|
||
}
|
||
self.addressGradient = gradient(color: UIColor(red: 0.36, green: 0.86, blue: 1.0, alpha: 1.0), peak: 0.9)
|
||
self.nameGradient = gradient(color: .white, peak: 0.25)
|
||
super.init(frame: frame)
|
||
self.isOpaque = false
|
||
self.isUserInteractionEnabled = false
|
||
self.accessibilityElementsHidden = true
|
||
self.contentScaleFactor = UIScreenScale
|
||
}
|
||
|
||
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
|
||
|
||
private static func shape(font: CTFont, glyph: CGGlyph) -> Shape {
|
||
let key = GlyphKey(font: CTFontCopyPostScriptName(font) as String, size: CTFontGetSize(font), glyph: glyph)
|
||
if let cached = self.shapes[key] { return cached }
|
||
var glyph = glyph
|
||
var advance = CGSize.zero
|
||
CTFontGetAdvancesForGlyphs(font, .horizontal, &glyph, &advance, 1)
|
||
let path: CGPath
|
||
var image: UIImage?
|
||
if let outline = CTFontCreatePathForGlyph(font, glyph, nil) {
|
||
path = outline
|
||
} else {
|
||
let bounds = CTFontGetBoundingRectsForGlyphs(font, .horizontal, &glyph, nil, 1).integral
|
||
if !bounds.isEmpty && !bounds.isInfinite && !bounds.isNull {
|
||
// Keep color-font glyphs (for example an emoji in a peer name).
|
||
let format = UIGraphicsImageRendererFormat()
|
||
format.scale = UIScreenScale
|
||
image = UIGraphicsImageRenderer(size: bounds.size, format: format).image { renderer in
|
||
let context = renderer.cgContext
|
||
context.translateBy(x: -bounds.minX, y: bounds.maxY)
|
||
context.scaleBy(x: 1, y: -1)
|
||
var position = CGPoint.zero
|
||
CTFontDrawGlyphs(font, &glyph, &position, 1, context)
|
||
}
|
||
path = CGPath(rect: bounds, transform: nil)
|
||
} else {
|
||
path = CGMutablePath()
|
||
}
|
||
}
|
||
let result = Shape(path: path, advance: advance.width, image: image)
|
||
self.shapes[key] = result
|
||
return result
|
||
}
|
||
|
||
func update(nodes: [TextNode]) {
|
||
let layouts = nodes.compactMap { node -> (TextNodeLayout, CGRect)? in
|
||
node.cachedLayout.map { ($0, node.frame) }
|
||
}
|
||
if layouts.count == self.layouts.count && zip(layouts, self.layouts).allSatisfy({ $0.0.0 === $0.1.0 && $0.0.1 == $0.1.1 }) { return }
|
||
self.layouts = layouts
|
||
self.glyphs.removeAll(keepingCapacity: true)
|
||
self.integralCount = 0
|
||
var column = 0
|
||
for (kind, entry) in layouts.enumerated() {
|
||
let (layout, frame) = entry
|
||
let string = (layout.attributedString?.string ?? "") as NSString
|
||
layout.enumerateRenderedLines(in: CGRect(origin: .zero, size: frame.size)) { line, baseline in
|
||
for run in CTLineGetGlyphRuns(line) as! [CTRun] {
|
||
let attributes = CTRunGetAttributes(run) as NSDictionary
|
||
let font = attributes[kCTFontAttributeName] as! CTFont
|
||
let count = CTRunGetGlyphCount(run)
|
||
var glyphs = [CGGlyph](repeating: 0, count: count)
|
||
var positions = [CGPoint](repeating: .zero, count: count)
|
||
var indices = [CFIndex](repeating: 0, count: count)
|
||
CTRunGetGlyphs(run, CFRange(), &glyphs)
|
||
CTRunGetPositions(run, CFRange(), &positions)
|
||
CTRunGetStringIndices(run, CFRange(), &indices)
|
||
for i in 0 ..< count {
|
||
let shape = Self.shape(font: font, glyph: glyphs[i])
|
||
guard !shape.path.isEmpty else { continue }
|
||
let origin = CGPoint(x: frame.minX + baseline.x + positions[i].x, y: frame.minY + baseline.y - positions[i].y)
|
||
var transform = CGAffineTransform(a: 1, b: 0, c: 0, d: -1, tx: origin.x, ty: origin.y)
|
||
guard let path = shape.path.copy(using: &transform) else { continue }
|
||
let index = indices[i]
|
||
var value: Int?
|
||
var digits: [Shape] = []
|
||
let color = (attributes[NSAttributedString.Key.foregroundColor.rawValue] as? UIColor)
|
||
?? (index >= 0 && index < string.length ? layout.attributedString?.attribute(.foregroundColor, at: index, effectiveRange: nil) as? UIColor : nil)
|
||
?? .white
|
||
if kind == 0, index >= 0, index < string.length {
|
||
let character = string.substring(with: string.rangeOfComposedCharacterSequence(at: index)).first
|
||
if let number = character?.wholeNumberValue, number < 10 {
|
||
// Verify the shaped glyph: a truncation token may reuse a string index.
|
||
var code = string.character(at: index)
|
||
var expected: CGGlyph = 0
|
||
if CTFontGetGlyphsForCharacters(font, &code, &expected, 1), expected == glyphs[i] {
|
||
value = number
|
||
for digit in 0 ..< 10 {
|
||
var digitCode = UniChar(Int(code) - number + digit)
|
||
var digitGlyph: CGGlyph = 0
|
||
CTFontGetGlyphsForCharacters(font, &digitCode, &digitGlyph, 1)
|
||
digits.append(Self.shape(font: font, glyph: digitGlyph))
|
||
}
|
||
}
|
||
}
|
||
}
|
||
let isIntegral = CTFontGetSize(font) >= 17.0
|
||
if value != nil && isIntegral { self.integralCount += 1 }
|
||
let midpoint = CGPoint(x: path.boundingBoxOfPath.midX, y: path.boundingBoxOfPath.midY)
|
||
self.glyphs.append(Glyph(path: path, origin: origin, color: color.cgColor, kind: kind,
|
||
landing: WalletTransferArrivalAnimation.crossing(midpoint), value: value, column: column,
|
||
pitch: isIntegral ? 17.0 : 13.2, advance: shape.advance, digits: digits, image: shape.image))
|
||
if value != nil { column += 1 }
|
||
}
|
||
}
|
||
}
|
||
}
|
||
self.setNeedsDisplay()
|
||
}
|
||
|
||
func update(time: Double) {
|
||
self.time = time
|
||
self.setNeedsDisplay()
|
||
}
|
||
|
||
override func draw(_ rect: CGRect) {
|
||
guard let context = UIGraphicsGetCurrentContext() else { return }
|
||
let namePath = CGMutablePath()
|
||
let addressPath = CGMutablePath()
|
||
for glyph in self.glyphs {
|
||
let landing = WalletTransferArrivalAnimation.passTime(glyph.landing)
|
||
func progress(_ back: Double) -> Double {
|
||
return min(1.0, max(0.0, (self.time - back - 0.02) / max(landing - 0.02, 0.05)))
|
||
}
|
||
if let value = glyph.value, progress(0) < 0.999 {
|
||
let distance = Double(value + 10 * min(glyph.column, 2))
|
||
func position(_ back: Double) -> Double { distance * (1.0 - pow(1.0 - progress(back), 1.7)) }
|
||
let now = position(0.0)
|
||
let was = position(1.0 / 60.0)
|
||
let travel = CGFloat(abs(now - was)) * glyph.pitch
|
||
let samples = travel > 1.0 ? min(2 + Int(travel), 14) : 1
|
||
let weights = (0 ..< samples).map { index -> CGFloat in
|
||
guard samples > 1 else { return 1.0 }
|
||
let q = CGFloat(index) / CGFloat(samples - 1) * 2.0 - 1.0
|
||
return 1.0 - 0.55 * q * q
|
||
}
|
||
let total = weights.reduce(0, +)
|
||
let low = Int(floor(min(now, was))) - 1
|
||
let high = Int(ceil(max(now, was))) + 1
|
||
for n in low ... high {
|
||
let digit = ((n % 10) + 10) % 10
|
||
if digit == 0 && glyph.column == 0 && self.integralCount > 1 && value != 0 { continue }
|
||
let shape = glyph.digits[digit]
|
||
for sample in 0 ..< samples {
|
||
let q = samples > 1 ? Double(sample) / Double(samples - 1) : 1.0
|
||
let dy = CGFloat(was + (now - was) * q - Double(n)) * glyph.pitch
|
||
let edge = abs(dy) / (glyph.pitch * 0.58)
|
||
let alpha = (edge >= 1 ? 0 : 1 - pow(edge, 2.2)) * weights[sample] / total
|
||
guard alpha > 0.006 else { continue }
|
||
context.saveGState()
|
||
context.translateBy(x: glyph.origin.x + (glyph.advance - shape.advance) * 0.5, y: glyph.origin.y - dy)
|
||
context.scaleBy(x: 1.0, y: -1.0)
|
||
context.setAlpha(alpha)
|
||
context.setFillColor(glyph.color)
|
||
context.addPath(shape.path)
|
||
context.fillPath()
|
||
context.restoreGState()
|
||
}
|
||
}
|
||
continue
|
||
}
|
||
let lift = WalletTransferArrivalAnimation.lift(self.time, at: glyph.landing)
|
||
let bounds = glyph.path.boundingBoxOfPath
|
||
let scale = 1.0 + (glyph.kind == 0 ? 0.1 : 0.08) * lift
|
||
var transform = CGAffineTransform(translationX: bounds.midX, y: bounds.midY - (glyph.kind == 0 ? 1.2 : 0.9) * lift)
|
||
.scaledBy(x: scale, y: scale).translatedBy(x: -bounds.midX, y: -bounds.midY)
|
||
if let image = glyph.image {
|
||
context.saveGState()
|
||
context.concatenate(transform)
|
||
image.draw(in: bounds)
|
||
context.restoreGState()
|
||
continue
|
||
}
|
||
guard let path = glyph.path.copy(using: &transform) else { continue }
|
||
if glyph.kind == 2 {
|
||
context.saveGState()
|
||
context.translateBy(x: 0, y: 1)
|
||
context.setFillColor(UIColor.white.withAlphaComponent(0.06).cgColor)
|
||
context.addPath(path)
|
||
context.fillPath()
|
||
context.restoreGState()
|
||
addressPath.addPath(path)
|
||
} else if glyph.kind == 1 {
|
||
namePath.addPath(path)
|
||
}
|
||
context.setFillColor(glyph.color)
|
||
context.addPath(path)
|
||
context.fillPath()
|
||
}
|
||
if let x = WalletTransferArrivalAnimation.sheenX(self.time) {
|
||
for (path, gradient) in [(addressPath, self.addressGradient), (namePath, self.nameGradient)] {
|
||
context.saveGState()
|
||
context.addPath(path)
|
||
context.clip()
|
||
if path === namePath { context.setBlendMode(.plusLighter) }
|
||
context.drawLinearGradient(gradient, start: CGPoint(x: x - 112 - 4, y: -4), end: CGPoint(x: x + 112 - 4, y: 112 * 0.7 - 4), options: [])
|
||
context.restoreGState()
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
private struct TransferCardWalletState: Equatable {
|
||
let status: TransferCardStatus
|
||
let operationId: String?
|
||
let transactionHash: Data?
|
||
}
|
||
|
||
private enum TransferCardRibbonGeometry {
|
||
private static let imageSize = CGSize(width: 54.800781, height: 54.800766)
|
||
static let size = imageSize
|
||
static let center = CGPoint(x: 33.057275 * size.width / imageSize.width, y: 21.743515 * size.height / imageSize.height)
|
||
|
||
static let path: CGPath = {
|
||
let path = CGMutablePath()
|
||
path.move(to: CGPoint(x: 50.114521, y: 24.658621))
|
||
path.addLine(to: CGPoint(x: 30.142169, y: 4.686269))
|
||
path.addCurve(to: CGPoint(x: 26.538727, y: 1.473573), control1: CGPoint(x: 28.412651, y: 2.956751), control2: CGPoint(x: 27.547891, y: 2.091991))
|
||
path.addCurve(to: CGPoint(x: 23.648195, y: 0.276273), control1: CGPoint(x: 25.643999, y: 0.925288), control2: CGPoint(x: 24.668556, y: 0.521241))
|
||
path.addCurve(to: CGPoint(x: 18.828456, y: 0.0), control1: CGPoint(x: 22.497317, y: 0.0), control2: CGPoint(x: 21.274368, y: 0.0))
|
||
path.addLine(to: CGPoint(x: 4.897057, y: 0.0))
|
||
path.addCurve(to: CGPoint(x: 0.701126, y: 0.479164), control1: CGPoint(x: 2.473808, y: 0.0), control2: CGPoint(x: 1.262181, y: 0.0))
|
||
path.addCurve(to: CGPoint(x: 0.006188, y: 2.156895), control1: CGPoint(x: 0.214309, y: 0.894945), control2: CGPoint(x: -0.044041, y: 1.518656))
|
||
path.addCurve(to: CGPoint(x: 2.634339, y: 5.462719), control1: CGPoint(x: 0.064078, y: 2.892458), control2: CGPoint(x: 0.920832, y: 3.749212))
|
||
path.addLine(to: CGPoint(x: 49.338070, y: 52.166450))
|
||
path.addCurve(to: CGPoint(x: 52.643895, y: 54.794601), control1: CGPoint(x: 51.051577, y: 53.879956), control2: CGPoint(x: 51.908330, y: 54.736710))
|
||
path.addCurve(to: CGPoint(x: 54.321625, y: 54.099662), control1: CGPoint(x: 53.282136, y: 54.844833), control2: CGPoint(x: 53.905845, y: 54.586481))
|
||
path.addCurve(to: CGPoint(x: 54.800818, y: 49.903712), control1: CGPoint(x: 54.800810, y: 53.538617), control2: CGPoint(x: 54.800810, y: 52.326999))
|
||
path.addLine(to: CGPoint(x: 54.800816, y: 35.972328))
|
||
path.addCurve(to: CGPoint(x: 54.524518, y: 31.152596), control1: CGPoint(x: 54.800816, y: 33.526424), control2: CGPoint(x: 54.800813, y: 32.303474))
|
||
path.addCurve(to: CGPoint(x: 53.327218, y: 28.262064), control1: CGPoint(x: 54.279548, y: 30.132233), control2: CGPoint(x: 53.875502, y: 29.156791))
|
||
path.addCurve(to: CGPoint(x: 50.114521, y: 24.658621), control1: CGPoint(x: 52.708799, y: 27.252899), control2: CGPoint(x: 51.844040, y: 26.388140))
|
||
path.closeSubpath()
|
||
var transform = CGAffineTransform(scaleX: size.width / imageSize.width, y: size.height / imageSize.height)
|
||
return path.copy(using: &transform) ?? path
|
||
}()
|
||
|
||
static func compactPath(center: CGPoint) -> CGPath {
|
||
let path = CGMutablePath()
|
||
let radius: CGFloat = 7.0
|
||
func point(_ angle: CGFloat) -> CGPoint {
|
||
return CGPoint(x: center.x + cos(angle) * radius, y: center.y + sin(angle) * radius)
|
||
}
|
||
|
||
let angles: [CGFloat] = [-.pi / 4.0, -.pi / 2.0, -.pi * 5.0 / 4.0, -.pi * 2.0, -.pi * 9.0 / 4.0]
|
||
path.move(to: point(angles[0]))
|
||
for corner in 0 ..< 4 {
|
||
let startAngle = angles[corner]
|
||
let step = (angles[corner + 1] - startAngle) / 3.0
|
||
let controlLength = 4.0 / 3.0 * tan(step / 4.0) * radius
|
||
path.addLine(to: point(startAngle))
|
||
for segment in 0 ..< 3 {
|
||
let angle = startAngle + CGFloat(segment) * step
|
||
let nextAngle = angle + step
|
||
let start = point(angle)
|
||
let end = point(nextAngle)
|
||
path.addCurve(
|
||
to: end,
|
||
control1: CGPoint(x: start.x - sin(angle) * controlLength, y: start.y + cos(angle) * controlLength),
|
||
control2: CGPoint(x: end.x + sin(nextAngle) * controlLength, y: end.y - cos(nextAngle) * controlLength)
|
||
)
|
||
}
|
||
}
|
||
path.closeSubpath()
|
||
return path
|
||
}
|
||
}
|
||
|
||
private func transferCardTransactionHash(_ value: String?) -> Data? {
|
||
guard let value else {
|
||
return nil
|
||
}
|
||
let parts = value.split(separator: ":", maxSplits: 1, omittingEmptySubsequences: false)
|
||
if parts.count == 2 && UInt64(parts[0]) == nil {
|
||
return nil
|
||
}
|
||
guard let hash = parts.last.flatMap({ Data(base64Encoded: String($0)) }), hash.count == 32 else {
|
||
return nil
|
||
}
|
||
return hash
|
||
}
|
||
|
||
private func transferCardWalletState(_ state: WalletContext.State, operationId: String?, transactionHash: Data?) -> TransferCardWalletState? {
|
||
if let transaction = state.transactions.items.first(where: { transaction in
|
||
guard transaction.direction == .outgoing, transaction.collectible == nil, transaction.kind == .transfer else {
|
||
return false
|
||
}
|
||
return operationId.map { transaction.presentationId == "pending:\($0)" } == true
|
||
|| (transactionHash != nil && transferCardTransactionHash(transaction.transactionHash ?? transaction.id) == transactionHash)
|
||
}) {
|
||
let status: TransferCardStatus
|
||
switch transaction.status {
|
||
case .pending:
|
||
status = .pending
|
||
case .completed:
|
||
status = .completed
|
||
case .failed:
|
||
status = .unavailable
|
||
}
|
||
return TransferCardWalletState(
|
||
status: status,
|
||
operationId: operationId ?? (transaction.presentationId.hasPrefix("pending:") ? String(transaction.presentationId.dropFirst("pending:".count)) : nil),
|
||
transactionHash: transferCardTransactionHash(transaction.transactionHash ?? transaction.id) ?? transactionHash
|
||
)
|
||
}
|
||
if let pending = state.pendingTransfers.first(where: { pending in
|
||
pending.collectibleAddress == nil && (pending.id == operationId
|
||
|| (transactionHash != nil && transferCardTransactionHash(pending.transactionHash) == transactionHash))
|
||
}) {
|
||
let status: TransferCardStatus
|
||
switch pending.status {
|
||
case .broadcasting:
|
||
status = .waiting
|
||
case .pending, .submissionUnknown:
|
||
status = .pending
|
||
case .confirmed:
|
||
status = .completed
|
||
}
|
||
return TransferCardWalletState(status: status, operationId: pending.id, transactionHash: transferCardTransactionHash(pending.transactionHash) ?? transactionHash)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
private final class TransferCardShimmerView: UIView {
|
||
let repeatAnimation: Bool
|
||
let followsArrival: Bool
|
||
var completion: (() -> Void)?
|
||
|
||
private let surfaceLayer = SimpleGradientLayer()
|
||
private let borderGlowLayer = SimpleGradientLayer()
|
||
private let borderLayer = SimpleGradientLayer()
|
||
private let borderGlowMask = SimpleShapeLayer()
|
||
private let borderMask = SimpleShapeLayer()
|
||
private let addressView = UIView()
|
||
private let addressLayer = SimpleGradientLayer()
|
||
private var currentLayout: (size: CGSize, addressFrame: CGRect)?
|
||
private var animationStartTime: CFTimeInterval?
|
||
|
||
init(addressMask: UIView, repeatAnimation: Bool, followsArrival: Bool = false) {
|
||
self.repeatAnimation = repeatAnimation
|
||
self.followsArrival = followsArrival
|
||
|
||
super.init(frame: .zero)
|
||
self.isUserInteractionEnabled = false
|
||
self.clipsToBounds = true
|
||
self.layer.cornerRadius = 20.0
|
||
|
||
for (mask, width) in [(self.borderGlowMask, CGFloat(4.5)), (self.borderMask, CGFloat(1.3))] {
|
||
mask.fillColor = UIColor.clear.cgColor
|
||
mask.strokeColor = UIColor.white.cgColor
|
||
mask.lineWidth = width
|
||
mask.contentsScale = UIScreenScale
|
||
}
|
||
self.borderGlowLayer.mask = self.borderGlowMask
|
||
self.borderLayer.mask = self.borderMask
|
||
self.addressView.mask = addressMask
|
||
self.addressView.layer.addSublayer(self.addressLayer)
|
||
|
||
for (layer, color, peak) in [
|
||
(self.surfaceLayer, UIColor.white, CGFloat(0.07)),
|
||
(self.borderGlowLayer, UIColor.white, CGFloat(0.28)),
|
||
(self.borderLayer, UIColor.white, CGFloat(0.55)),
|
||
(self.addressLayer, UIColor(red: 0.36, green: 0.86, blue: 1.0, alpha: 1.0), CGFloat(0.9))
|
||
] {
|
||
layer.colors = [CGFloat(0.0), 0.18, 0.6, 1.0, 0.6, 0.18, 0.0].map {
|
||
color.withAlphaComponent(peak * $0).cgColor
|
||
}
|
||
layer.locations = [0.0, 0.22, 0.4, 0.5, 0.6, 0.78, 1.0]
|
||
layer.opacity = 0.0
|
||
}
|
||
for layer in [self.surfaceLayer, self.borderGlowLayer, self.borderLayer] {
|
||
self.layer.addSublayer(layer)
|
||
}
|
||
self.addSubview(self.addressView)
|
||
self.addressView.isHidden = followsArrival
|
||
self.surfaceLayer.compositingFilter = "screenBlendMode"
|
||
self.borderGlowLayer.compositingFilter = "plusL"
|
||
self.borderLayer.compositingFilter = "plusL"
|
||
}
|
||
|
||
required init?(coder: NSCoder) {
|
||
fatalError("init(coder:) has not been implemented")
|
||
}
|
||
|
||
func update(size: CGSize, addressFrame: CGRect) {
|
||
guard size.width > 0.0, size.height > 0.0 else { return }
|
||
if let currentLayout = self.currentLayout, currentLayout.size == size, currentLayout.addressFrame == addressFrame {
|
||
return
|
||
}
|
||
self.currentLayout = (size, addressFrame)
|
||
if self.animationStartTime == nil {
|
||
self.animationStartTime = CACurrentMediaTime() + 0.15
|
||
}
|
||
|
||
let bounds = CGRect(origin: .zero, size: size)
|
||
self.frame = bounds
|
||
for layer in [self.surfaceLayer, self.borderGlowLayer, self.borderLayer] {
|
||
layer.frame = bounds
|
||
}
|
||
for mask in [self.borderGlowMask, self.borderMask] {
|
||
mask.frame = bounds
|
||
mask.path = UIBezierPath(roundedRect: bounds, cornerRadius: 20.0).cgPath
|
||
}
|
||
self.addressView.frame = bounds
|
||
self.addressView.mask?.frame = addressFrame
|
||
self.addressLayer.frame = bounds
|
||
|
||
if self.followsArrival { return }
|
||
|
||
for (layer, width, frame) in [
|
||
(self.surfaceLayer, CGFloat(160.0), bounds),
|
||
(self.borderGlowLayer, CGFloat(128.0), bounds),
|
||
(self.borderLayer, CGFloat(112.0), bounds),
|
||
(self.addressLayer, CGFloat(112.0), bounds)
|
||
] {
|
||
self.animateBand(layer, width: width, frame: frame, cardWidth: size.width + 8.0)
|
||
}
|
||
}
|
||
|
||
func updateArrival(time: Double) {
|
||
guard self.followsArrival, self.bounds.width > 0, self.bounds.height > 0 else { return }
|
||
let x = WalletTransferArrivalAnimation.sheenX(time)
|
||
for (layer, width) in [(self.surfaceLayer, CGFloat(160)), (self.borderGlowLayer, CGFloat(128)), (self.borderLayer, CGFloat(112))] {
|
||
layer.opacity = x == nil ? 0 : 1
|
||
if let x {
|
||
layer.startPoint = CGPoint(x: (x - width - 4) / self.bounds.width, y: -4 / self.bounds.height)
|
||
layer.endPoint = CGPoint(x: (x + width - 4) / self.bounds.width, y: (width * 0.7 - 4) / self.bounds.height)
|
||
}
|
||
}
|
||
}
|
||
|
||
private func animateBand(_ layer: SimpleGradientLayer, width: CGFloat, frame: CGRect, cardWidth: CGFloat) {
|
||
guard frame.width > 0.0, frame.height > 0.0, let animationStartTime = self.animationStartTime else { return }
|
||
func point(_ x: CGFloat, _ y: CGFloat) -> CGPoint {
|
||
return CGPoint(x: (x - 4.0 - frame.minX) / frame.width, y: (y - 4.0 - frame.minY) / frame.height)
|
||
}
|
||
let fromX = -0.6 * cardWidth
|
||
let toX = 1.6 * cardWidth
|
||
let duration = 1.46
|
||
let passFraction = 0.96 / duration
|
||
let ease = CAMediaTimingFunction(controlPoints: 1.0 / 3.0, 0.0, 2.0 / 3.0, 1.0)
|
||
var animations: [CAAnimation] = []
|
||
for (keyPath, from, to) in [
|
||
("startPoint", point(fromX - width, 0.0), point(toX - width, 0.0)),
|
||
("endPoint", point(fromX + width, width * 0.7), point(toX + width, width * 0.7))
|
||
] {
|
||
let animation = CAKeyframeAnimation(keyPath: keyPath)
|
||
animation.values = [NSValue(cgPoint: from), NSValue(cgPoint: to), NSValue(cgPoint: to)]
|
||
animation.keyTimes = [0.0, NSNumber(value: passFraction), 1.0]
|
||
animation.timingFunctions = [ease, CAMediaTimingFunction(name: .linear)]
|
||
animation.duration = duration
|
||
animations.append(animation)
|
||
}
|
||
let opacity = CAKeyframeAnimation(keyPath: "opacity")
|
||
opacity.values = [1.0, 0.0, 0.0]
|
||
opacity.keyTimes = [0.0, NSNumber(value: passFraction), 1.0]
|
||
opacity.calculationMode = .discrete
|
||
opacity.duration = duration
|
||
animations.append(opacity)
|
||
|
||
let group = CAAnimationGroup()
|
||
group.animations = animations
|
||
group.duration = duration
|
||
group.beginTime = layer.convertTime(animationStartTime, from: nil)
|
||
group.repeatCount = self.repeatAnimation ? .infinity : 0.0
|
||
if !self.repeatAnimation, layer === self.surfaceLayer {
|
||
group.completion = { [weak self] finished in
|
||
if finished {
|
||
self?.completion?()
|
||
}
|
||
}
|
||
}
|
||
layer.add(group, forKey: "shimmer")
|
||
}
|
||
}
|
||
|
||
public final class ChatMessageTransferBubbleContentNode: ChatMessageBubbleContentNode {
|
||
private struct BackgroundRotationAnimation {
|
||
let duration: CFTimeInterval
|
||
let turns: CGFloat
|
||
var start: (time: CFTimeInterval, offset: CGFloat)?
|
||
}
|
||
|
||
private let labelNode: TextNode
|
||
private var labelBackgroundNode: WallpaperBubbleBackgroundNode?
|
||
private let labelBackgroundMaskNode: ASImageNode
|
||
private var linkHighlightingNode: LinkHighlightingNode?
|
||
|
||
private let mediaContainerNode: ASDisplayNode
|
||
private var mediaBackgroundContent: WallpaperBubbleBackgroundNode?
|
||
private let cardNode: ASDisplayNode
|
||
private let cardBackgroundNode: ASImageNode
|
||
private var cardBackgroundRotation: CGFloat = 0.0
|
||
private var cardBackgroundMotion: (rotation: CGFloat, time: CFTimeInterval)?
|
||
private var cardBackgroundDeviceMotion: Disposable?
|
||
private var cardBackgroundRotationAnimation: BackgroundRotationAnimation?
|
||
private var cardIcon: InteractiveDiamondComponent.View?
|
||
private var isAwaitingTransferFlight = false
|
||
private let amountNode: TextNode
|
||
private let nameNode: TextNode
|
||
private let addressNode: TextNode
|
||
private let addressHighlightNode: TextNode
|
||
private let addressShimmerMaskNode: TextNode
|
||
private var shimmerView: TransferCardShimmerView?
|
||
private var isHighlighted = false
|
||
private var isPlayingHighlightShimmer = false
|
||
private let sendingClockNode: ASDisplayNode
|
||
private let clockFrameNode: ASImageNode
|
||
private let clockMinNode: ASImageNode
|
||
private let captionNode: TextNode
|
||
private var captionTextSelectionNode: TextSelectionNode?
|
||
private var captionDustNode: InvisibleInkDustNode?
|
||
private let ribbonBackgroundNode: ASImageNode
|
||
private let ribbonTextNode: TextNode
|
||
private let ribbonTextContainerNode: ASDisplayNode
|
||
private let ribbonTextMaskNode: ASImageNode
|
||
private var ribbonAnimationLayer: SimpleShapeLayer?
|
||
private var ribbonAnimationMaskLayer: SimpleShapeLayer?
|
||
private var ribbonGlintLayer: SimpleGradientLayer?
|
||
private var completionAnimationId = 0
|
||
private var arrivalTextView: TransferArrivalTextView?
|
||
private var arrivalStartTime: CFTimeInterval?
|
||
private var arrivalDidLand = false
|
||
private var arrivalDidGlint = false
|
||
|
||
private weak var walletContext: WalletContext?
|
||
private var walletStateDisposable: MetaDisposable?
|
||
private var renderedFiatState: WalletContext.FiatState?
|
||
private var transferOperationId: String?
|
||
private var transferTransactionHash: Data?
|
||
private var transferStatus: TransferCardStatus?
|
||
private var isIncomingTransfer = false
|
||
|
||
#if DEBUG
|
||
private var debugTransferStatus: TransferCardStatus?
|
||
#endif
|
||
|
||
private var displayedTransferStatus: TransferCardStatus? {
|
||
#if DEBUG
|
||
if let debugTransferStatus = self.debugTransferStatus {
|
||
return debugTransferStatus
|
||
}
|
||
#endif
|
||
return self.transferStatus
|
||
}
|
||
|
||
private var isSendingTransfer: Bool {
|
||
return !self.isIncomingTransfer && self.displayedTransferStatus != nil && self.displayedTransferStatus != .completed
|
||
}
|
||
|
||
private var cachedLabelBackgroundImage: (CGPoint, UIImage, [CGRect])?
|
||
private var absoluteRect: (CGRect, CGSize)?
|
||
|
||
public var scrollTiltProvider: ((CFTimeInterval) -> Float)? {
|
||
didSet {
|
||
self.cardIcon?.scrollTiltProvider = self.scrollTiltProvider
|
||
}
|
||
}
|
||
|
||
override public var disablesClipping: Bool {
|
||
return true
|
||
}
|
||
|
||
override public func didEnterHierarchy() {
|
||
super.didEnterHierarchy()
|
||
self.updateIncomingTransferVisibility()
|
||
// UIKit may attach the backing view to its window after the node enters hierarchy.
|
||
DispatchQueue.main.async { [weak self] in
|
||
self?.updateIncomingTransferVisibility()
|
||
}
|
||
}
|
||
|
||
override public func didExitHierarchy() {
|
||
super.didExitHierarchy()
|
||
self.cancelIncomingTransferAnimation()
|
||
}
|
||
|
||
override public var visibility: ListViewItemNodeVisibility {
|
||
didSet {
|
||
if (oldValue != .none) != (self.visibility != .none) {
|
||
self.cardIcon?.isRenderingEnabled = self.visibility != .none && !self.isAwaitingTransferFlight
|
||
if self.visibility == .none {
|
||
self.cancelIncomingTransferAnimation()
|
||
self.mediaContainerNode.layer.removeAnimation(forKey: "transferFlightLanding")
|
||
self.stopCardBackgroundMotion()
|
||
self.finishCompletionAnimation()
|
||
self.isPlayingHighlightShimmer = false
|
||
}
|
||
self.updateSendingClockAnimation()
|
||
self.updateShimmer(animated: false)
|
||
}
|
||
self.updateIncomingTransferVisibility()
|
||
}
|
||
}
|
||
|
||
required public init(lottieSettings: LottieRenderingSettings) {
|
||
self.labelNode = TextNode()
|
||
self.labelNode.isUserInteractionEnabled = false
|
||
self.labelNode.displaysAsynchronously = false
|
||
|
||
self.labelBackgroundMaskNode = ASImageNode()
|
||
self.labelBackgroundMaskNode.displaysAsynchronously = false
|
||
|
||
self.mediaContainerNode = ASDisplayNode()
|
||
self.mediaContainerNode.clipsToBounds = false
|
||
|
||
self.cardNode = ASDisplayNode()
|
||
self.cardNode.clipsToBounds = true
|
||
self.cardNode.cornerRadius = 20.0
|
||
|
||
self.cardBackgroundNode = ASImageNode()
|
||
self.cardBackgroundNode.isLayerBacked = true
|
||
self.cardBackgroundNode.isUserInteractionEnabled = false
|
||
self.cardBackgroundNode.isOpaque = true
|
||
self.cardBackgroundNode.displaysAsynchronously = false
|
||
self.cardBackgroundNode.displayWithoutProcessing = true
|
||
self.cardBackgroundNode.contentMode = .scaleToFill
|
||
self.cardBackgroundNode.image = UIImage(bundleImageName: "Wallet/CardChatGradient")
|
||
|
||
self.amountNode = TextNode()
|
||
self.amountNode.isUserInteractionEnabled = false
|
||
self.amountNode.displaysAsynchronously = false
|
||
|
||
self.nameNode = TextNode()
|
||
self.nameNode.isUserInteractionEnabled = false
|
||
self.nameNode.displaysAsynchronously = false
|
||
|
||
self.addressNode = TextNode()
|
||
self.addressNode.isUserInteractionEnabled = false
|
||
self.addressNode.displaysAsynchronously = false
|
||
|
||
self.addressHighlightNode = TextNode()
|
||
self.addressHighlightNode.isUserInteractionEnabled = false
|
||
self.addressHighlightNode.displaysAsynchronously = false
|
||
self.addressHighlightNode.alpha = 0.06
|
||
|
||
self.addressShimmerMaskNode = TextNode()
|
||
self.addressShimmerMaskNode.isUserInteractionEnabled = false
|
||
self.addressShimmerMaskNode.displaysAsynchronously = false
|
||
|
||
self.sendingClockNode = ASDisplayNode()
|
||
self.sendingClockNode.isUserInteractionEnabled = false
|
||
self.sendingClockNode.alpha = 0.0
|
||
|
||
self.clockFrameNode = ASImageNode()
|
||
self.clockFrameNode.isLayerBacked = true
|
||
self.clockFrameNode.displaysAsynchronously = false
|
||
self.clockFrameNode.displayWithoutProcessing = true
|
||
|
||
self.clockMinNode = ASImageNode()
|
||
self.clockMinNode.isLayerBacked = true
|
||
self.clockMinNode.displaysAsynchronously = false
|
||
self.clockMinNode.displayWithoutProcessing = true
|
||
|
||
self.captionNode = TextNode()
|
||
self.captionNode.isUserInteractionEnabled = false
|
||
self.captionNode.displaysAsynchronously = false
|
||
|
||
self.ribbonBackgroundNode = ASImageNode()
|
||
self.ribbonBackgroundNode.displaysAsynchronously = false
|
||
self.ribbonBackgroundNode.displayWithoutProcessing = true
|
||
self.ribbonBackgroundNode.image = generateTintedImage(
|
||
image: UIImage(bundleImageName: "Wallet/MessageRibbon"),
|
||
color: .white
|
||
)
|
||
|
||
self.ribbonTextNode = TextNode()
|
||
self.ribbonTextNode.isUserInteractionEnabled = false
|
||
self.ribbonTextNode.displaysAsynchronously = false
|
||
|
||
self.ribbonTextContainerNode = ASDisplayNode()
|
||
self.ribbonTextContainerNode.isUserInteractionEnabled = false
|
||
self.ribbonTextMaskNode = ASImageNode()
|
||
self.ribbonTextMaskNode.displaysAsynchronously = false
|
||
self.ribbonTextMaskNode.displayWithoutProcessing = true
|
||
self.ribbonTextMaskNode.image = UIImage(bundleImageName: "Wallet/MessageRibbon")
|
||
|
||
super.init(lottieSettings: lottieSettings)
|
||
|
||
self.cardNode.addSubnode(self.cardBackgroundNode)
|
||
self.cardNode.addSubnode(self.amountNode)
|
||
self.cardNode.addSubnode(self.nameNode)
|
||
self.cardNode.addSubnode(self.addressHighlightNode)
|
||
self.cardNode.addSubnode(self.addressNode)
|
||
self.cardNode.addSubnode(self.sendingClockNode)
|
||
self.sendingClockNode.addSubnode(self.clockFrameNode)
|
||
self.sendingClockNode.addSubnode(self.clockMinNode)
|
||
|
||
self.addSubnode(self.mediaContainerNode)
|
||
self.mediaContainerNode.addSubnode(self.cardNode)
|
||
self.mediaContainerNode.addSubnode(self.ribbonBackgroundNode)
|
||
self.mediaContainerNode.addSubnode(self.ribbonTextContainerNode)
|
||
self.ribbonTextContainerNode.addSubnode(self.ribbonTextNode)
|
||
self.mediaContainerNode.addSubnode(self.captionNode)
|
||
self.addSubnode(self.labelNode)
|
||
}
|
||
|
||
required public init?(coder aDecoder: NSCoder) {
|
||
fatalError("init(coder:) has not been implemented")
|
||
}
|
||
|
||
deinit {
|
||
self.cardBackgroundDeviceMotion?.dispose()
|
||
self.walletStateDisposable?.dispose()
|
||
}
|
||
|
||
private func stopCardBackgroundMotion() {
|
||
self.cardBackgroundDeviceMotion?.dispose()
|
||
self.cardBackgroundDeviceMotion = nil
|
||
self.cardBackgroundRotationAnimation = nil
|
||
self.cardBackgroundMotion = nil
|
||
}
|
||
|
||
private func updateCardBackgroundRotation(_ state: InteractiveDiamondComponent.MotionState?) {
|
||
guard let state, self.visibility != .none,
|
||
UIApplication.shared.applicationState != .background, !UIAccessibility.isReduceMotionEnabled,
|
||
!self.isAwaitingTransferFlight, let window = self.cardIcon?.window else {
|
||
self.stopCardBackgroundMotion()
|
||
return
|
||
}
|
||
if self.cardBackgroundDeviceMotion == nil {
|
||
self.cardBackgroundDeviceMotion = WalletCardBackgroundMotion.shared.subscribe()
|
||
if self.cardBackgroundRotationAnimation == nil {
|
||
self.cardBackgroundRotationAnimation = BackgroundRotationAnimation(duration: 0.22, turns: 0.0)
|
||
}
|
||
}
|
||
let deviceRotation = WalletCardBackgroundMotion.shared.rotation(
|
||
at: CACurrentMediaTime(),
|
||
orientation: window.windowScene?.interfaceOrientation ?? .portrait
|
||
)
|
||
|
||
let rotation: CGFloat
|
||
if let transferEnergy = state.transferEnergy,
|
||
(!self.isIncomingTransfer && self.displayedTransferStatus == .pending) || (self.arrivalStartTime != nil && !self.arrivalDidLand) {
|
||
self.cardBackgroundRotationAnimation = nil
|
||
defer {
|
||
self.cardBackgroundMotion = (state.rotation, state.time)
|
||
}
|
||
guard let previous = self.cardBackgroundMotion, state.time >= previous.time else { return }
|
||
let delta = state.rotation - previous.rotation
|
||
let energy = min(1.0, max(0.0, transferEnergy))
|
||
let step = atan2(sin(delta), cos(delta)) * 2.0 * (1.0 - 0.5 * energy)
|
||
+ 0.12 * CGFloat(state.time - previous.time)
|
||
rotation = self.cardBackgroundRotation + step
|
||
} else {
|
||
self.cardBackgroundMotion = nil
|
||
if var animation = self.cardBackgroundRotationAnimation {
|
||
let start: (time: CFTimeInterval, offset: CGFloat)
|
||
if let current = animation.start {
|
||
start = current
|
||
} else {
|
||
let delta = self.cardBackgroundRotation - deviceRotation
|
||
start = (state.time, atan2(sin(delta), cos(delta)) - animation.turns * 2.0 * .pi)
|
||
animation.start = start
|
||
}
|
||
let progress = CGFloat(min(1.0, max(0.0, (state.time - start.time) / animation.duration)))
|
||
let remaining = 1.0 - progress
|
||
rotation = deviceRotation + start.offset * remaining * remaining * remaining
|
||
self.cardBackgroundRotationAnimation = progress < 1.0 ? animation : nil
|
||
} else {
|
||
rotation = deviceRotation
|
||
}
|
||
}
|
||
let normalizedRotation = rotation.truncatingRemainder(dividingBy: 2.0 * .pi)
|
||
guard self.cardBackgroundRotation != normalizedRotation else { return }
|
||
self.cardBackgroundRotation = normalizedRotation
|
||
CATransaction.begin()
|
||
CATransaction.setDisableActions(true)
|
||
self.cardBackgroundNode.transform = CATransform3DMakeRotation(self.cardBackgroundRotation, 0.0, 0.0, 1.0)
|
||
CATransaction.commit()
|
||
}
|
||
|
||
private func updateDiamond() {
|
||
guard let item = self.item else { return }
|
||
let size = CGSize(width: 64.0, height: 64.0)
|
||
let component = InteractiveDiamondComponent(
|
||
size: size, diamondWidth: 38.0,
|
||
isVisible: self.visibility != .none && !self.isAwaitingTransferFlight,
|
||
theme: item.presentationData.theme.theme, appearance: .cool,
|
||
expansionStyle: .downward, tapToSpin: true
|
||
)
|
||
let diamond = self.cardIcon ?? component.makeView()
|
||
self.cardIcon = diamond
|
||
diamond.update(component: component)
|
||
diamond.isHidden = self.isAwaitingTransferFlight
|
||
diamond.isUserInteractionEnabled = !self.isAwaitingTransferFlight && !self.isSendingTransfer
|
||
diamond.scrollTiltProvider = self.scrollTiltProvider
|
||
diamond.onExpansionChanged = { [weak self] isExpanded in
|
||
guard let self else { return }
|
||
if isExpanded {
|
||
self.cancelIncomingTransferAnimation()
|
||
if self.cardBackgroundRotationAnimation != nil {
|
||
self.cardBackgroundRotationAnimation = BackgroundRotationAnimation(duration: 0.22, turns: 0.0)
|
||
}
|
||
self.cardBackgroundMotion = nil
|
||
}
|
||
self.updateDiamondRefraction()
|
||
}
|
||
diamond.onMotionUpdated = { [weak self] state in
|
||
guard let self else { return }
|
||
if state == nil {
|
||
self.mediaContainerNode.layer.removeAnimation(forKey: "sublayerTransform.scale")
|
||
}
|
||
if UIAccessibility.isReduceMotionEnabled {
|
||
self.mediaContainerNode.layer.removeAnimation(forKey: "transferFlightLanding")
|
||
}
|
||
self.updateCardBackgroundRotation(state)
|
||
}
|
||
diamond.onLanding = { [weak self] power in
|
||
self?.animateDiamondLandingBump(power: power)
|
||
}
|
||
if diamond.superview !== self.cardNode.view {
|
||
self.cardNode.view.addSubview(diamond)
|
||
}
|
||
diamond.bounds = CGRect(origin: .zero, size: size)
|
||
diamond.center = CGPoint(x: floorToScreenPixels((self.cardNode.bounds.width - 38.0) * 0.5) + 19.0, y: 35.0)
|
||
}
|
||
|
||
public var canPlayIncomingTransferAnimation: Bool {
|
||
return self.isIncomingTransfer && self.visibility != .none && self.cardNode.bounds.width > 0
|
||
&& self.isNodeLoaded && self.view.window != nil
|
||
&& self.cardIcon?.isExpanded != true
|
||
&& self.item?.controllerInteraction.canReadHistory == true
|
||
&& UIApplication.shared.applicationState == .active
|
||
}
|
||
|
||
override public func unreadMessageRangeUpdated() {
|
||
self.updateIncomingTransferVisibility()
|
||
}
|
||
|
||
private func updateIncomingTransferVisibility() {
|
||
guard let item = self.item, self.isIncomingTransfer, self.cardNode.bounds.width > 0 else { return }
|
||
let isUnread = item.controllerInteraction.unreadMessageRange[UnreadMessageRangeKey(peerId: item.message.id.peerId, namespace: item.message.id.namespace)]?.contains(item.message.id.id) == true
|
||
var state = item.controllerInteraction.walletTransferArrivalState?(item.message.id)
|
||
if state == nil, isUnread, self.canPlayIncomingTransferAnimation {
|
||
item.controllerInteraction.requestWalletTransferArrival?(item.message)
|
||
state = item.controllerInteraction.walletTransferArrivalState?(item.message.id)
|
||
}
|
||
if UIAccessibility.isReduceMotionEnabled {
|
||
self.finishIncomingTransferAnimation()
|
||
return
|
||
}
|
||
switch state {
|
||
case let .queued(rise):
|
||
self.prepareIncomingTransferAnimation(rise: rise)
|
||
case let .playing(startTime, rise):
|
||
self.prepareIncomingTransferAnimation(rise: rise)
|
||
self.updateIncomingTransferAnimation(startTime: startTime, rise: rise, at: CACurrentMediaTime())
|
||
case .finished:
|
||
self.finishIncomingTransferAnimation()
|
||
case nil:
|
||
// Prepare before visibility and history-reading readiness allow the scene to start.
|
||
if isUnread, item.controllerInteraction.requestWalletTransferArrival != nil, self.cardIcon?.isExpanded != true {
|
||
self.prepareIncomingTransferAnimation(rise: item.controllerInteraction.freshWalletTransferMessageIds.contains(item.message.id))
|
||
} else {
|
||
self.finishIncomingTransferAnimation()
|
||
}
|
||
}
|
||
}
|
||
|
||
private func prepareIncomingTransferAnimation(rise: Bool) {
|
||
guard self.arrivalTextView == nil else { return }
|
||
CATransaction.begin()
|
||
CATransaction.setDisableActions(true)
|
||
self.finishCompletionAnimation()
|
||
self.isPlayingHighlightShimmer = false
|
||
self.mediaContainerNode.layer.removeAnimation(forKey: "sublayerTransform.scale")
|
||
self.shimmerView?.removeFromSuperview()
|
||
self.shimmerView = nil
|
||
let text = TransferArrivalTextView(frame: self.cardNode.bounds)
|
||
self.arrivalTextView = text
|
||
self.cardNode.view.addSubview(text)
|
||
text.update(nodes: [self.amountNode, self.nameNode, self.addressNode])
|
||
for node in [self.amountNode, self.nameNode, self.addressNode, self.addressHighlightNode] {
|
||
node.alpha = 0.0
|
||
}
|
||
self.sendingClockNode.alpha = 0.0
|
||
self.ribbonBackgroundNode.alpha = 0.0
|
||
self.ribbonTextContainerNode.alpha = 0.0
|
||
self.mediaContainerNode.alpha = rise ? 0.0 : 1.0
|
||
self.updateShimmer(animated: false)
|
||
CATransaction.commit()
|
||
}
|
||
|
||
public func updateIncomingTransferAnimation(startTime: CFTimeInterval, rise: Bool, at timestamp: CFTimeInterval) {
|
||
guard self.canPlayIncomingTransferAnimation else { return }
|
||
if UIAccessibility.isReduceMotionEnabled {
|
||
self.cancelIncomingTransferAnimation()
|
||
return
|
||
}
|
||
CATransaction.begin()
|
||
CATransaction.setDisableActions(true)
|
||
defer { CATransaction.commit() }
|
||
self.prepareIncomingTransferAnimation(rise: rise)
|
||
if self.arrivalStartTime == nil {
|
||
self.arrivalStartTime = startTime
|
||
self.cardBackgroundRotationAnimation = nil
|
||
self.cardBackgroundMotion = nil
|
||
self.cardIcon?.beginReceivingTransfer(at: startTime, completionDelay: WalletTransferArrivalAnimation.landing)
|
||
}
|
||
let t = max(0.0, timestamp - startTime)
|
||
let finish = t - WalletTransferArrivalAnimation.landing
|
||
self.arrivalTextView?.update(time: t)
|
||
self.shimmerView?.updateArrival(time: t)
|
||
|
||
let bounce = finish > 0 && finish < 1.2 ? -sin(2 * .pi * 2 * finish) * exp(-finish / 0.25) : 0
|
||
let scale: CGFloat
|
||
let y: CGFloat
|
||
if rise {
|
||
let spring = t < 0.8 ? 1 - exp(-9 * t) * (cos(13 * t) + 9.0 / 13.0 * sin(13 * t)) : 1
|
||
scale = CGFloat(0.86 + 0.14 * spring) * CGFloat(1 + 0.045 * bounce)
|
||
y = CGFloat(70 * (1 - spring)) + (1 - scale) * self.mediaContainerNode.bounds.height * 0.5
|
||
self.mediaContainerNode.alpha = CGFloat(min(1, t / 0.12))
|
||
} else {
|
||
let pop = t < 1.2 ? 0.05 * sin(2 * .pi * 1.8 * t) * exp(-t / 0.22) : 0
|
||
scale = CGFloat((1 + pop) * (1 + 0.045 * bounce))
|
||
y = 0
|
||
self.mediaContainerNode.alpha = 1
|
||
}
|
||
self.mediaContainerNode.layer.sublayerTransform = CATransform3DScale(CATransform3DMakeTranslation(0, y, 0), scale, scale, 1)
|
||
|
||
if finish >= 0 {
|
||
if !self.arrivalDidLand {
|
||
self.arrivalDidLand = true
|
||
self.cardBackgroundRotationAnimation = BackgroundRotationAnimation(duration: 1.8, turns: 2)
|
||
}
|
||
let tau = 0.08, w = 2 * Double.pi * 1.6
|
||
let length = 1 - exp(-finish / tau) * (cos(w * finish) + sin(w * finish) / (tau * w))
|
||
let width = 1 - pow(1 - min(1, finish / 0.3), 2.2)
|
||
let pivot = CGPoint(x: 200, y: 24)
|
||
let center = self.ribbonBackgroundNode.position
|
||
let transform = CGAffineTransform(translationX: pivot.x - center.x, y: pivot.y - center.y)
|
||
.rotated(by: .pi / 4).scaledBy(x: CGFloat(max(0.001, length)), y: CGFloat(0.2 + 0.8 * width))
|
||
.rotated(by: -.pi / 4).translatedBy(x: center.x - pivot.x, y: center.y - pivot.y)
|
||
self.ribbonBackgroundNode.transform = CATransform3DMakeAffineTransform(transform)
|
||
self.ribbonTextContainerNode.transform = CATransform3DMakeAffineTransform(transform)
|
||
self.ribbonBackgroundNode.alpha = CGFloat(min(1, max(0, length * 4)))
|
||
self.ribbonTextContainerNode.alpha = self.ribbonBackgroundNode.alpha * CGFloat(min(1, max(0, (length - 0.6) / 0.35)))
|
||
if finish >= 0.6 && !self.arrivalDidGlint {
|
||
self.arrivalDidGlint = true
|
||
self.animateRibbonGlint()
|
||
}
|
||
}
|
||
}
|
||
|
||
private func cancelIncomingTransferAnimation() {
|
||
guard self.arrivalTextView != nil, let item = self.item else { return }
|
||
item.controllerInteraction.cancelWalletTransferArrival?(item.message.id)
|
||
self.finishIncomingTransferAnimation()
|
||
}
|
||
|
||
public func finishIncomingTransferAnimation() {
|
||
guard let text = self.arrivalTextView else { return }
|
||
CATransaction.begin()
|
||
CATransaction.setDisableActions(true)
|
||
self.arrivalTextView = nil
|
||
text.removeFromSuperview()
|
||
self.arrivalStartTime = nil
|
||
self.arrivalDidLand = false
|
||
self.arrivalDidGlint = false
|
||
for node in [self.amountNode, self.nameNode, self.addressNode, self.addressHighlightNode] {
|
||
node.alpha = 1.0
|
||
}
|
||
self.addressHighlightNode.alpha = 0.06
|
||
self.mediaContainerNode.alpha = 1.0
|
||
self.mediaContainerNode.layer.sublayerTransform = CATransform3DIdentity
|
||
self.ribbonBackgroundNode.transform = CATransform3DIdentity
|
||
self.ribbonTextContainerNode.transform = CATransform3DIdentity
|
||
self.cardIcon?.endReceivingTransfer()
|
||
self.cardBackgroundMotion = nil
|
||
self.cardBackgroundRotationAnimation = BackgroundRotationAnimation(duration: 0.22, turns: 0)
|
||
self.finishCompletionAnimation()
|
||
self.updateShimmer(animated: false)
|
||
CATransaction.commit()
|
||
}
|
||
|
||
public func transferDiamondTarget(in view: UIView) -> (center: CGPoint, width: CGFloat)? {
|
||
guard self.visibility != .none, self.cardNode.isNodeLoaded,
|
||
let window = self.cardNode.view.window, window === view.window,
|
||
self.cardNode.bounds.width > 0.0 else { return nil }
|
||
let layer = self.cardNode.layer.presentation() ?? self.cardNode.layer
|
||
let targetLayer = view.layer.presentation() ?? view.layer
|
||
let center = CGPoint(x: floorToScreenPixels((self.cardNode.bounds.width - 38.0) * 0.5) + 19.0, y: 35.0)
|
||
let point = layer.convert(center, to: targetLayer)
|
||
let left = layer.convert(CGPoint(x: center.x - 19.0, y: center.y), to: targetLayer)
|
||
let right = layer.convert(CGPoint(x: center.x + 19.0, y: center.y), to: targetLayer)
|
||
return (point, hypot(right.x - left.x, right.y - left.y))
|
||
}
|
||
|
||
public func setAwaitingTransferFlight(_ awaiting: Bool, animated: Bool = false) {
|
||
guard self.isAwaitingTransferFlight != awaiting else { return }
|
||
self.isAwaitingTransferFlight = awaiting
|
||
self.stopCardBackgroundMotion()
|
||
self.cardIcon?.isHidden = awaiting
|
||
self.cardIcon?.isRenderingEnabled = !awaiting && self.visibility != .none
|
||
self.cardIcon?.isUserInteractionEnabled = !awaiting && !self.isSendingTransfer
|
||
if awaiting {
|
||
self.mediaContainerNode.layer.removeAnimation(forKey: "transferFlightLanding")
|
||
self.cardIcon?.updateTransferState(isSending: false, animateCompletion: false)
|
||
}
|
||
self.updateTransferAppearance(previousStatus: nil, animated: false)
|
||
if !awaiting && animated {
|
||
self.cardIcon?.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.15)
|
||
let statusNode = self.isSendingTransfer ? self.sendingClockNode : self.ribbonBackgroundNode
|
||
statusNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.15)
|
||
if !self.isSendingTransfer {
|
||
self.ribbonTextContainerNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.15)
|
||
}
|
||
}
|
||
}
|
||
|
||
public func acceptTransferDiamond(_ diamond: InteractiveDiamondComponent.View) {
|
||
self.cardIcon?.onMotionUpdated = nil
|
||
self.cardIcon?.isRenderingEnabled = false
|
||
self.cardIcon?.removeFromSuperview()
|
||
self.cardIcon = diamond
|
||
self.isAwaitingTransferFlight = false
|
||
diamond.transform = .identity
|
||
diamond.alpha = 1.0
|
||
self.updateDiamond()
|
||
diamond.updateTransferState(isSending: true, animateCompletion: false)
|
||
diamond.spin(4.5, decay: 0.7)
|
||
self.finishCompletionAnimation()
|
||
self.updateTransferAppearance(previousStatus: .pending, animated: true)
|
||
self.animateTransferFlightLanding()
|
||
}
|
||
|
||
private func animateTransferFlightLanding() {
|
||
guard !UIAccessibility.isReduceMotionEnabled else { return }
|
||
let duration = 1.2
|
||
let count = Int(duration * 120.0)
|
||
let impact = (0 ... count).map { index -> Double in
|
||
let time = duration * Double(index) / Double(count)
|
||
return index == count ? 0.0 : sin(2.0 * .pi * 2.2 * time) * exp(-time / 0.2)
|
||
}
|
||
let scale = CAKeyframeAnimation(keyPath: "sublayerTransform.scale")
|
||
scale.values = impact.map { 1.0 - 0.025 * $0 }
|
||
let offset = CAKeyframeAnimation(keyPath: "sublayerTransform.translation.y")
|
||
offset.values = impact.map { 4.0 * $0 }
|
||
for animation in [scale, offset] {
|
||
animation.duration = duration
|
||
animation.calculationMode = .linear
|
||
}
|
||
let group = CAAnimationGroup()
|
||
group.animations = [scale, offset]
|
||
group.duration = duration
|
||
self.mediaContainerNode.layer.add(group, forKey: "transferFlightLanding")
|
||
}
|
||
|
||
private func updateDiamondRefraction() {
|
||
guard let diamond = self.cardIcon else { return }
|
||
guard diamond.isExpanded else {
|
||
diamond.updateRefractionSource(nil)
|
||
return
|
||
}
|
||
|
||
let sourceRect = self.amountNode.frame.insetBy(dx: -2.0, dy: -2.0).integral
|
||
let scale = UIScreen.main.scale
|
||
let width = Int(ceil(sourceRect.width * scale))
|
||
let height = Int(ceil(sourceRect.height * scale))
|
||
guard width > 0, height > 0,
|
||
let context = CGContext(data: nil, width: width, height: height, bitsPerComponent: 8, bytesPerRow: width * 4,
|
||
space: CGColorSpaceCreateDeviceRGB(),
|
||
bitmapInfo: CGBitmapInfo.byteOrder32Little.rawValue | CGImageAlphaInfo.premultipliedFirst.rawValue),
|
||
let bytes = context.data else {
|
||
diamond.updateRefractionSource(nil)
|
||
return
|
||
}
|
||
context.clear(CGRect(x: 0.0, y: 0.0, width: CGFloat(width), height: CGFloat(height)))
|
||
context.translateBy(x: 0.0, y: CGFloat(height))
|
||
context.scaleBy(x: scale, y: -scale)
|
||
context.translateBy(x: self.amountNode.frame.minX - sourceRect.minX, y: self.amountNode.frame.minY - sourceRect.minY)
|
||
UIGraphicsPushContext(context)
|
||
TextNode.draw(self.amountNode.bounds,
|
||
withParameters: TextNode.DrawingParameters(cachedLayout: self.amountNode.cachedLayout, renderContentTypes: .all),
|
||
isCancelled: { false }, isRasterizing: true)
|
||
UIGraphicsPopContext()
|
||
|
||
let descriptor = MTLTextureDescriptor.texture2DDescriptor(pixelFormat: .bgra8Unorm, width: width, height: height, mipmapped: false)
|
||
descriptor.storageMode = .shared
|
||
descriptor.usage = .shaderRead
|
||
guard let texture = MetalEngine.shared.device.makeTexture(descriptor: descriptor) else {
|
||
diamond.updateRefractionSource(nil)
|
||
return
|
||
}
|
||
texture.replace(region: MTLRegionMake2D(0, 0, width, height), mipmapLevel: 0, withBytes: bytes, bytesPerRow: context.bytesPerRow)
|
||
let rect = self.cardNode.view.convert(sourceRect, to: diamond)
|
||
.offsetBy(dx: -diamond.bounds.midX, dy: -diamond.bounds.midY)
|
||
diamond.updateRefractionSource(InteractiveDiamondComponent.RefractionSource(
|
||
texture: texture, uv: SIMD4(0.0, 0.0, 1.0, 1.0), rect: rect, preservesColors: true
|
||
))
|
||
}
|
||
|
||
private func updateWalletSubscription(item: ChatMessageBubbleContentItem, isIncoming: Bool, transactionId: String, fiatState: WalletContext.FiatState?, isSameMessage: Bool) {
|
||
if !isSameMessage {
|
||
self.walletStateDisposable?.dispose()
|
||
self.walletStateDisposable = nil
|
||
self.transferOperationId = nil
|
||
self.transferTransactionHash = nil
|
||
self.transferStatus = nil
|
||
#if DEBUG
|
||
self.debugTransferStatus = nil
|
||
#endif
|
||
self.finishCompletionAnimation()
|
||
self.shimmerView?.removeFromSuperview()
|
||
self.shimmerView = nil
|
||
self.isHighlighted = false
|
||
self.isPlayingHighlightShimmer = false
|
||
}
|
||
|
||
let walletContext = item.context.walletContext
|
||
if self.walletContext !== walletContext {
|
||
self.walletStateDisposable?.dispose()
|
||
self.walletStateDisposable = nil
|
||
self.walletContext = walletContext
|
||
}
|
||
|
||
self.renderedFiatState = fiatState
|
||
self.requestWalletFiatUpdateIfNeeded()
|
||
|
||
if isIncoming {
|
||
self.updateTransferStatus(.completed, animated: false)
|
||
} else {
|
||
let pending = item.message.attributes.compactMap { $0 as? PendingWalletTransferMessageAttribute }.first
|
||
self.transferOperationId = pending?.operationId ?? self.transferOperationId
|
||
self.transferTransactionHash = transferCardTransactionHash(pending?.transactionId)
|
||
?? transferCardTransactionHash(transactionId)
|
||
?? self.transferTransactionHash
|
||
|
||
self.applyWalletState(walletContext.flatMap {
|
||
transferCardWalletState($0.stateValue, operationId: self.transferOperationId, transactionHash: self.transferTransactionHash)
|
||
}, animated: isSameMessage)
|
||
}
|
||
|
||
guard self.walletStateDisposable == nil, let walletContext else {
|
||
return
|
||
}
|
||
let disposable = MetaDisposable()
|
||
self.walletStateDisposable = disposable
|
||
disposable.set((walletContext.state
|
||
|> deliverOnMainQueue).start(next: { [weak self] state in
|
||
guard let self else {
|
||
return
|
||
}
|
||
if self.transferStatus != .completed {
|
||
self.applyWalletState(
|
||
transferCardWalletState(state, operationId: self.transferOperationId, transactionHash: self.transferTransactionHash),
|
||
animated: true
|
||
)
|
||
}
|
||
self.requestWalletFiatUpdateIfNeeded()
|
||
}))
|
||
}
|
||
|
||
private func requestWalletFiatUpdateIfNeeded() {
|
||
Queue.mainQueue().justDispatch { [weak self] in
|
||
guard let self, let item = self.item, let fiatState = self.walletContext?.stateValue.fiat else {
|
||
return
|
||
}
|
||
guard self.renderedFiatState?.selectedCurrency != fiatState.selectedCurrency
|
||
|| self.renderedFiatState?.selectedRate?.unitsPerGram != fiatState.selectedRate?.unitsPerGram else {
|
||
return
|
||
}
|
||
item.controllerInteraction.requestMessageUpdate(item.message.id, false, nil)
|
||
}
|
||
}
|
||
|
||
private func applyWalletState(_ state: TransferCardWalletState?, animated: Bool) {
|
||
if let state {
|
||
self.transferOperationId = state.operationId ?? self.transferOperationId
|
||
self.transferTransactionHash = state.transactionHash ?? self.transferTransactionHash
|
||
self.updateTransferStatus(state.status, animated: animated)
|
||
} else if self.transferStatus == .pending || self.transferStatus == .unavailable {
|
||
self.updateTransferStatus(.unavailable, animated: animated)
|
||
} else {
|
||
self.updateTransferStatus(self.transferOperationId == nil ? .completed : .waiting, animated: animated)
|
||
}
|
||
}
|
||
|
||
private func updateTransferStatus(_ status: TransferCardStatus, animated: Bool) {
|
||
guard self.transferStatus != .completed else {
|
||
return
|
||
}
|
||
let previousStatus = self.displayedTransferStatus
|
||
self.transferStatus = status
|
||
self.updateTransferAppearance(previousStatus: previousStatus, animated: animated)
|
||
}
|
||
|
||
private func updateTransferAppearance(previousStatus: TransferCardStatus?, animated: Bool) {
|
||
let status = self.displayedTransferStatus
|
||
if self.arrivalTextView != nil { return }
|
||
if self.isAwaitingTransferFlight {
|
||
self.finishCompletionAnimation()
|
||
self.updateShimmer(animated: false)
|
||
return
|
||
}
|
||
if self.isSendingTransfer {
|
||
self.cardBackgroundRotationAnimation = nil
|
||
}
|
||
self.cardIcon?.isUserInteractionEnabled = !self.isSendingTransfer
|
||
if previousStatus == status {
|
||
self.cardIcon?.updateTransferState(isSending: self.isSendingTransfer, animateCompletion: false)
|
||
if status == .pending {
|
||
self.updateShimmer(animated: false)
|
||
}
|
||
return
|
||
}
|
||
self.finishCompletionAnimation()
|
||
self.updateShimmer(animated: animated && previousStatus != nil)
|
||
let animateCompletion = !self.isIncomingTransfer && status == .completed && previousStatus != nil && animated && self.visibility != .none
|
||
if status == .completed {
|
||
self.cardBackgroundRotationAnimation = BackgroundRotationAnimation(
|
||
duration: animateCompletion ? 1.8 : 0.22,
|
||
turns: animateCompletion ? 2.0 : 0.0
|
||
)
|
||
}
|
||
self.cardIcon?.updateTransferState(isSending: self.isSendingTransfer, animateCompletion: animateCompletion)
|
||
if animateCompletion {
|
||
self.playCompletionHaptics()
|
||
self.animateCompletion()
|
||
}
|
||
}
|
||
|
||
private func updateSendingClockAnimation() {
|
||
let shouldAnimate = self.isSendingTransfer && !self.isAwaitingTransferFlight && self.visibility != .none
|
||
for (node, duration) in [(self.clockFrameNode, 6.0), (self.clockMinNode, 1.0)] {
|
||
let key = "transferClockRotation"
|
||
if shouldAnimate {
|
||
if node.layer.animation(forKey: key) == nil {
|
||
node.layer.transform = CATransform3DIdentity
|
||
let animation = CABasicAnimation(keyPath: "transform.rotation.z")
|
||
animation.fromValue = 0.0 as NSNumber
|
||
animation.toValue = (Double.pi * 2.0) as NSNumber
|
||
animation.duration = duration
|
||
animation.repeatCount = .infinity
|
||
animation.timingFunction = CAMediaTimingFunction(name: .linear)
|
||
node.layer.add(animation, forKey: key)
|
||
}
|
||
} else if node.layer.animation(forKey: key) != nil {
|
||
let transform = node.layer.presentation()?.transform ?? CATransform3DIdentity
|
||
node.layer.removeAnimation(forKey: key)
|
||
node.layer.transform = transform
|
||
}
|
||
}
|
||
}
|
||
|
||
private func updateShimmer(animated: Bool) {
|
||
if let text = self.arrivalTextView {
|
||
let shimmer: TransferCardShimmerView
|
||
if let current = self.shimmerView, current.followsArrival {
|
||
shimmer = current
|
||
} else {
|
||
self.shimmerView?.removeFromSuperview()
|
||
shimmer = TransferCardShimmerView(addressMask: self.addressShimmerMaskNode.view, repeatAnimation: false, followsArrival: true)
|
||
self.shimmerView = shimmer
|
||
self.cardNode.view.insertSubview(shimmer, belowSubview: text)
|
||
}
|
||
shimmer.update(size: self.cardNode.bounds.size, addressFrame: self.addressNode.frame)
|
||
if let diamond = self.cardIcon { self.cardNode.view.bringSubviewToFront(diamond) }
|
||
return
|
||
}
|
||
let displayShimmer = !self.isAwaitingTransferFlight && (self.displayedTransferStatus == .pending || self.isPlayingHighlightShimmer) && self.visibility != .none
|
||
if displayShimmer {
|
||
let repeatAnimation = !self.isPlayingHighlightShimmer
|
||
let shimmerView: TransferCardShimmerView
|
||
if let current = self.shimmerView, current.repeatAnimation == repeatAnimation {
|
||
shimmerView = current
|
||
} else {
|
||
self.shimmerView?.removeFromSuperview()
|
||
shimmerView = TransferCardShimmerView(addressMask: self.addressShimmerMaskNode.view, repeatAnimation: repeatAnimation)
|
||
self.shimmerView = shimmerView
|
||
self.cardNode.view.addSubview(shimmerView)
|
||
if !repeatAnimation {
|
||
self.animateHighlightBump()
|
||
shimmerView.completion = { [weak self, weak shimmerView] in
|
||
guard let self, let shimmerView, self.shimmerView === shimmerView else {
|
||
return
|
||
}
|
||
self.isPlayingHighlightShimmer = false
|
||
self.shimmerView = nil
|
||
shimmerView.removeFromSuperview()
|
||
self.updateShimmer(animated: false)
|
||
}
|
||
}
|
||
}
|
||
if let iconView = self.cardIcon, iconView.superview === self.cardNode.view {
|
||
self.cardNode.view.bringSubviewToFront(iconView)
|
||
}
|
||
shimmerView.layer.removeAnimation(forKey: "opacity")
|
||
shimmerView.alpha = 1.0
|
||
shimmerView.update(size: self.cardNode.bounds.size, addressFrame: self.addressNode.frame)
|
||
self.addressShimmerMaskNode.recursivelyEnsureDisplaySynchronously(true)
|
||
} else if let shimmerView = self.shimmerView {
|
||
if animated && self.visibility != .none {
|
||
guard shimmerView.alpha != 0.0 else {
|
||
return
|
||
}
|
||
shimmerView.alpha = 0.0
|
||
let fadeValues = (0 ... 36).map { NSNumber(value: pow(1.0 - Double($0) / 36.0, 2.2)) }
|
||
shimmerView.layer.animateKeyframes(values: fadeValues, duration: 0.3, keyPath: "opacity", completion: { [weak self, weak shimmerView] finished in
|
||
guard finished, let self, let shimmerView, self.shimmerView === shimmerView, shimmerView.alpha == 0.0 else {
|
||
return
|
||
}
|
||
shimmerView.removeFromSuperview()
|
||
self.shimmerView = nil
|
||
})
|
||
} else {
|
||
shimmerView.removeFromSuperview()
|
||
self.shimmerView = nil
|
||
}
|
||
}
|
||
}
|
||
|
||
private func animateDiamondLandingBump(power: CGFloat) {
|
||
guard self.visibility != .none, !self.isSendingTransfer,
|
||
UIApplication.shared.applicationState == .active, !UIAccessibility.isReduceMotionEnabled else {
|
||
return
|
||
}
|
||
let duration = 1.0
|
||
let frameCount = Int(duration * 120.0)
|
||
let values = (0 ... frameCount).map { index -> NSNumber in
|
||
let time = duration * Double(index) / Double(frameCount)
|
||
let bounce = index == frameCount ? 0.0 : sin(2.0 * .pi * 2.4 * time) * exp(-time / 0.2)
|
||
return NSNumber(value: 1.0 - 0.05 * Double(power) * bounce)
|
||
}
|
||
self.mediaContainerNode.layer.animateKeyframes(
|
||
values: values,
|
||
duration: duration,
|
||
keyPath: "sublayerTransform.scale"
|
||
)
|
||
}
|
||
|
||
private func animateHighlightBump() {
|
||
guard self.visibility != .none, UIApplication.shared.applicationState == .active,
|
||
!UIAccessibility.isReduceMotionEnabled else {
|
||
return
|
||
}
|
||
let delay = 0.18
|
||
let durationFactor = 1.7
|
||
let duration = 0.9 * durationFactor
|
||
let frameCount = Int(ceil(duration * 120.0))
|
||
var values: [NSNumber] = [1.0]
|
||
var keyTimes: [NSNumber] = [0.0]
|
||
for index in 0 ... frameCount {
|
||
let progress = Double(index) / Double(frameCount)
|
||
let time = duration * progress / durationFactor
|
||
let bump = index == frameCount ? 0.0 : sin(2.0 * .pi * 1.6 * time) * exp(-time / 0.2)
|
||
values.append(NSNumber(value: 1.0 + 0.06 * bump))
|
||
keyTimes.append(NSNumber(value: (delay + duration * progress) / (delay + duration)))
|
||
}
|
||
self.mediaContainerNode.layer.animateKeyframes(
|
||
values: values,
|
||
keyTimes: keyTimes,
|
||
duration: delay + duration,
|
||
keyPath: "transform.scale"
|
||
)
|
||
Queue.mainQueue().after(delay, { [weak self, weak shimmerView = self.shimmerView] in
|
||
guard let self, let shimmerView, self.shimmerView === shimmerView, self.visibility != .none else {
|
||
return
|
||
}
|
||
self.cardIcon?.pushFromBelow(strength: 1.5)
|
||
})
|
||
}
|
||
|
||
private func removeRibbonAnimation() {
|
||
self.ribbonAnimationLayer?.removeAllAnimations()
|
||
self.ribbonAnimationLayer?.removeFromSuperlayer()
|
||
self.ribbonAnimationLayer = nil
|
||
if let ribbonAnimationMaskLayer = self.ribbonAnimationMaskLayer {
|
||
ribbonAnimationMaskLayer.removeAllAnimations()
|
||
self.ribbonAnimationMaskLayer = nil
|
||
self.ribbonTextContainerNode.layer.mask = nil
|
||
}
|
||
self.ribbonTextContainerNode.view.mask = self.ribbonTextMaskNode.view
|
||
}
|
||
|
||
private func finishCompletionAnimation() {
|
||
guard self.arrivalTextView == nil else { return }
|
||
self.completionAnimationId &+= 1
|
||
self.removeRibbonAnimation()
|
||
self.ribbonGlintLayer?.removeAllAnimations()
|
||
self.ribbonGlintLayer?.removeFromSuperlayer()
|
||
self.ribbonGlintLayer = nil
|
||
self.ribbonBackgroundNode.layer.removeAnimation(forKey: "opacity")
|
||
self.ribbonTextNode.layer.removeAnimation(forKey: "opacity")
|
||
self.ribbonTextNode.layer.removeAnimation(forKey: "transform.scale")
|
||
self.sendingClockNode.layer.removeAnimation(forKey: "opacity")
|
||
self.sendingClockNode.layer.removeAnimation(forKey: "transform.scale")
|
||
self.mediaContainerNode.layer.removeAnimation(forKey: "transform.scale")
|
||
let sending = self.isSendingTransfer
|
||
self.sendingClockNode.alpha = sending && !self.isAwaitingTransferFlight ? 1.0 : 0.0
|
||
self.ribbonBackgroundNode.alpha = sending || self.isAwaitingTransferFlight ? 0.0 : 1.0
|
||
self.ribbonTextContainerNode.alpha = sending || self.isAwaitingTransferFlight ? 0.0 : 1.0
|
||
self.ribbonTextNode.alpha = 1.0
|
||
ContainedViewLayoutTransition.immediate.updateTintColor(
|
||
layer: self.ribbonBackgroundNode.layer,
|
||
color: UIColor(rgb: self.isIncomingTransfer ? 0x42b0ff : 0x00cf00)
|
||
)
|
||
self.updateSendingClockAnimation()
|
||
}
|
||
|
||
private func playCompletionHaptics() {
|
||
guard UIApplication.shared.applicationState == .active else { return }
|
||
Haptics.strong()
|
||
let animationId = self.completionAnimationId
|
||
for (delay, intensity) in [(0.13, CGFloat(0.7)), (0.3, CGFloat(0.45))] {
|
||
DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in
|
||
guard let self, self.completionAnimationId == animationId,
|
||
self.visibility != .none, self.displayedTransferStatus == .completed,
|
||
UIApplication.shared.applicationState == .active else { return }
|
||
Haptics.hit(intensity)
|
||
}
|
||
}
|
||
}
|
||
|
||
private func animateRibbonGlint() {
|
||
let size = self.ribbonBackgroundNode.bounds.size
|
||
let center = self.ribbonBackgroundNode.position
|
||
let frame = CGRect(x: center.x - size.width * 0.5, y: center.y - size.height * 0.5, width: size.width, height: size.height)
|
||
guard frame.width > 0.0, frame.height > 0.0 else {
|
||
return
|
||
}
|
||
func point(_ x: CGFloat, _ y: CGFloat) -> CGPoint {
|
||
return CGPoint(x: (x - frame.minX) / frame.width, y: (y - frame.minY) / frame.height)
|
||
}
|
||
|
||
let glintLayer = SimpleGradientLayer()
|
||
glintLayer.frame = frame
|
||
glintLayer.colors = [UIColor(white: 1.0, alpha: 0.0).cgColor, UIColor(white: 1.0, alpha: 0.7).cgColor, UIColor(white: 1.0, alpha: 0.0).cgColor]
|
||
glintLayer.locations = [0.0, 0.5, 1.0]
|
||
glintLayer.opacity = 0.0
|
||
glintLayer.compositingFilter = "plusL"
|
||
|
||
let maskLayer = SimpleShapeLayer()
|
||
maskLayer.frame = CGRect(origin: .zero, size: frame.size)
|
||
maskLayer.contentsScale = UIScreenScale
|
||
maskLayer.fillColor = UIColor.white.cgColor
|
||
maskLayer.path = TransferCardRibbonGeometry.path
|
||
glintLayer.mask = maskLayer
|
||
self.ribbonGlintLayer = glintLayer
|
||
self.mediaContainerNode.layer.insertSublayer(glintLayer, above: self.ribbonBackgroundNode.layer)
|
||
|
||
let delay = 0.6
|
||
let duration = 0.55
|
||
glintLayer.startPoint = point(226.0, 0.0)
|
||
glintLayer.endPoint = point(254.0, 12.0)
|
||
for (keyPath, from, to) in [
|
||
("startPoint", point(136.0, 0.0), glintLayer.startPoint),
|
||
("endPoint", point(164.0, 12.0), glintLayer.endPoint)
|
||
] {
|
||
glintLayer.animate(from: NSValue(cgPoint: from), to: NSValue(cgPoint: to), keyPath: keyPath, timingFunction: CAMediaTimingFunctionName.linear.rawValue, duration: duration, delay: delay)
|
||
}
|
||
|
||
let frameCount = Int(ceil(duration * 120.0))
|
||
var values: [NSNumber] = [0.0]
|
||
var keyTimes: [NSNumber] = [0.0]
|
||
for index in 0 ... frameCount {
|
||
let progress = Double(index) / Double(frameCount)
|
||
values.append(NSNumber(value: index == frameCount ? 0.0 : sin(.pi * progress)))
|
||
keyTimes.append(NSNumber(value: (delay + duration * progress) / (delay + duration)))
|
||
}
|
||
glintLayer.animateKeyframes(values: values, keyTimes: keyTimes, duration: delay + duration, keyPath: "opacity")
|
||
}
|
||
|
||
private func animateCompletion() {
|
||
guard !UIAccessibility.isReduceMotionEnabled else {
|
||
return
|
||
}
|
||
let animationId = self.completionAnimationId
|
||
let ribbonFrame = self.ribbonBackgroundNode.frame
|
||
let clockCenter = CGPoint(
|
||
x: self.cardNode.frame.minX + self.sendingClockNode.position.x - ribbonFrame.minX,
|
||
y: self.cardNode.frame.minY + self.sendingClockNode.position.y - ribbonFrame.minY
|
||
)
|
||
let finalPath = TransferCardRibbonGeometry.path
|
||
let ribbonCenter = TransferCardRibbonGeometry.center
|
||
let overshootOffset = -0.02 * (ribbonCenter.x + ribbonCenter.y)
|
||
var overshootTransform = CGAffineTransform(a: 1.02, b: 0.02, c: 0.02, d: 1.02, tx: overshootOffset, ty: overshootOffset)
|
||
let paths = [
|
||
TransferCardRibbonGeometry.compactPath(center: clockCenter),
|
||
finalPath,
|
||
finalPath.copy(using: &overshootTransform) ?? finalPath,
|
||
finalPath
|
||
]
|
||
let keyTimes = [0.0, 0.28 / 0.42, 0.34 / 0.42, 1.0].map { NSNumber(value: $0) }
|
||
|
||
let ribbonLayer = SimpleShapeLayer()
|
||
ribbonLayer.frame = ribbonFrame
|
||
ribbonLayer.contentsScale = UIScreenScale
|
||
ribbonLayer.fillColor = UIColor(rgb: 0x00cf00).cgColor
|
||
ribbonLayer.path = finalPath
|
||
self.ribbonAnimationLayer = ribbonLayer
|
||
self.mediaContainerNode.layer.insertSublayer(ribbonLayer, below: self.ribbonBackgroundNode.layer)
|
||
self.ribbonBackgroundNode.alpha = 0.0
|
||
|
||
let maskLayer = SimpleShapeLayer()
|
||
maskLayer.frame = CGRect(origin: .zero, size: ribbonFrame.size)
|
||
maskLayer.contentsScale = UIScreenScale
|
||
maskLayer.fillColor = UIColor.white.cgColor
|
||
maskLayer.path = finalPath
|
||
self.ribbonAnimationMaskLayer = maskLayer
|
||
self.ribbonTextContainerNode.view.mask = nil
|
||
self.ribbonTextContainerNode.layer.mask = maskLayer
|
||
|
||
for layer in [ribbonLayer, maskLayer] {
|
||
layer.animateKeyframes(values: paths, keyTimes: keyTimes, duration: 0.42, keyPath: "path", timingFunction: CAMediaTimingFunctionName.easeInEaseOut.rawValue)
|
||
}
|
||
ribbonLayer.animateAlpha(from: 0.0, to: 1.0, duration: 0.12)
|
||
self.sendingClockNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.12)
|
||
self.sendingClockNode.layer.animateScale(from: 1.0, to: 0.4, duration: 0.12)
|
||
self.ribbonTextNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.22, delay: 0.06)
|
||
self.ribbonTextNode.layer.animateScale(from: 0.65, to: 1.0, duration: 0.28)
|
||
|
||
self.ribbonBackgroundNode.alpha = 1.0
|
||
self.ribbonBackgroundNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.06, delay: 0.42, completion: { [weak self] finished in
|
||
guard finished, let self, self.completionAnimationId == animationId else {
|
||
return
|
||
}
|
||
self.removeRibbonAnimation()
|
||
})
|
||
self.animateRibbonGlint()
|
||
|
||
let bounceDuration = 1.2
|
||
let bounceFrameCount = Int(bounceDuration * 120.0)
|
||
let bounceValues = (0 ... bounceFrameCount).map { index -> NSNumber in
|
||
let time = bounceDuration * Double(index) / Double(bounceFrameCount)
|
||
let bounce = index == bounceFrameCount ? 0.0 : -sin(2.0 * Double.pi * 2.0 * time) * exp(-time / 0.25)
|
||
return NSNumber(value: 1.0 + 0.045 * bounce)
|
||
}
|
||
self.mediaContainerNode.layer.animateKeyframes(
|
||
values: bounceValues,
|
||
duration: bounceDuration,
|
||
keyPath: "transform.scale",
|
||
timingFunction: CAMediaTimingFunctionName.linear.rawValue,
|
||
completion: { [weak self] finished in
|
||
guard finished, let self, self.completionAnimationId == animationId else {
|
||
return
|
||
}
|
||
self.finishCompletionAnimation()
|
||
}
|
||
)
|
||
}
|
||
|
||
#if DEBUG
|
||
private func toggleDebugTransferStatus() {
|
||
guard !self.isIncomingTransfer else {
|
||
return
|
||
}
|
||
let previousStatus = self.displayedTransferStatus
|
||
self.debugTransferStatus = previousStatus == .completed ? .pending : .completed
|
||
self.updateTransferAppearance(previousStatus: previousStatus, animated: true)
|
||
}
|
||
#endif
|
||
|
||
private func removeCaptionTextSelection(animated: Bool) {
|
||
guard let textSelectionNode = self.captionTextSelectionNode else {
|
||
return
|
||
}
|
||
self.captionTextSelectionNode = nil
|
||
self.updateIsTextSelectionActive?(false)
|
||
|
||
if animated {
|
||
textSelectionNode.highlightAreaNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2, removeOnCompletion: false)
|
||
textSelectionNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2, removeOnCompletion: false, completion: { [weak textSelectionNode] _ in
|
||
textSelectionNode?.highlightAreaNode.removeFromSupernode()
|
||
textSelectionNode?.removeFromSupernode()
|
||
})
|
||
} else {
|
||
textSelectionNode.highlightAreaNode.removeFromSupernode()
|
||
textSelectionNode.removeFromSupernode()
|
||
}
|
||
}
|
||
|
||
override public func willUpdateIsExtractedToContextPreview(_ value: Bool) {
|
||
if !value {
|
||
self.removeCaptionTextSelection(animated: true)
|
||
}
|
||
}
|
||
|
||
override public func updateIsExtractedToContextPreview(_ value: Bool) {
|
||
if value {
|
||
guard self.captionTextSelectionNode == nil,
|
||
let item = self.item,
|
||
!self.captionNode.isHidden,
|
||
let attributedText = self.captionNode.cachedLayout?.attributedString,
|
||
attributedText.length > 0,
|
||
let rootNode = item.controllerInteraction.chatControllerNode() else {
|
||
return
|
||
}
|
||
|
||
let knobColor: UIColor
|
||
if item.message.effectivelyIncoming(item.context.account.peerId) {
|
||
knobColor = item.presentationData.theme.theme.chat.message.incoming.textSelectionKnobColor
|
||
} else {
|
||
knobColor = item.presentationData.theme.theme.chat.message.outgoing.textSelectionKnobColor
|
||
}
|
||
|
||
let textSelectionNode = TextSelectionNode(
|
||
theme: TextSelectionTheme(
|
||
selection: UIColor.white.withAlphaComponent(0.4),
|
||
knob: knobColor,
|
||
isDark: item.presentationData.theme.theme.overallDarkAppearance
|
||
),
|
||
strings: item.presentationData.strings,
|
||
textNodeOrView: .node(self.captionNode),
|
||
updateIsActive: { [weak self] value in
|
||
self?.updateIsTextSelectionActive?(value)
|
||
},
|
||
present: { [weak self] controller, arguments in
|
||
self?.item?.controllerInteraction.presentGlobalOverlayController(controller, arguments)
|
||
},
|
||
rootView: { [weak rootNode] in
|
||
return rootNode?.view
|
||
},
|
||
performAction: { [weak self] text, action in
|
||
guard let self, let item = self.item else {
|
||
return
|
||
}
|
||
item.controllerInteraction.performTextSelectionAction(item.message, true, text, nil, action)
|
||
}
|
||
)
|
||
textSelectionNode.enableCopy = true
|
||
textSelectionNode.enableQuote = false
|
||
textSelectionNode.enableShare = true
|
||
|
||
self.captionTextSelectionNode = textSelectionNode
|
||
self.mediaContainerNode.addSubnode(textSelectionNode)
|
||
self.mediaContainerNode.insertSubnode(textSelectionNode.highlightAreaNode, belowSubnode: self.captionNode)
|
||
textSelectionNode.frame = self.captionNode.frame
|
||
textSelectionNode.highlightAreaNode.frame = textSelectionNode.frame
|
||
} else {
|
||
self.removeCaptionTextSelection(animated: true)
|
||
}
|
||
}
|
||
|
||
override public func asyncLayoutContent() -> (_ item: ChatMessageBubbleContentItem, _ layoutConstants: ChatMessageItemLayoutConstants, _ preparePosition: ChatMessageBubblePreparePosition, _ messageSelection: Bool?, _ constrainedSize: CGSize, _ avatarInset: CGFloat) -> (ChatMessageBubbleContentProperties, unboundSize: CGSize?, maxWidth: CGFloat, layout: (CGSize, ChatMessageBubbleContentPosition) -> (CGFloat, (CGFloat) -> (CGSize, (ListViewItemUpdateAnimation, Bool, ListViewItemApply?) -> Void))) {
|
||
let makeLabelLayout = TextNode.asyncLayout(self.labelNode)
|
||
let makeAmountLayout = TextNode.asyncLayout(self.amountNode)
|
||
let makeNameLayout = TextNode.asyncLayout(self.nameNode)
|
||
let makeAddressLayout = TextNode.asyncLayout(self.addressNode)
|
||
let makeAddressHighlightLayout = TextNode.asyncLayout(self.addressHighlightNode)
|
||
let makeAddressShimmerMaskLayout = TextNode.asyncLayout(self.addressShimmerMaskNode)
|
||
let makeCaptionLayout = TextNode.asyncLayout(self.captionNode)
|
||
let makeRibbonTextLayout = TextNode.asyncLayout(self.ribbonTextNode)
|
||
let cachedLabelBackgroundImage = self.cachedLabelBackgroundImage
|
||
|
||
return { [weak self] item, _, _, _, _, _ in
|
||
let contentProperties = ChatMessageBubbleContentProperties(
|
||
hidesSimpleAuthorHeader: true,
|
||
headerSpacing: 0.0,
|
||
hidesBackground: .always,
|
||
forceFullCorners: false,
|
||
forceAlignment: .center
|
||
)
|
||
|
||
return (contentProperties, nil, CGFloat.greatestFiniteMagnitude, { constrainedSize, _ in
|
||
let engineMessage = EngineMessage(item.message)
|
||
guard let action = item.message.media.first(where: { media in
|
||
guard let action = media as? TelegramMediaAction else {
|
||
return false
|
||
}
|
||
if case .gramTransfer = action.action {
|
||
return true
|
||
} else {
|
||
return false
|
||
}
|
||
}) as? TelegramMediaAction else {
|
||
return (0.0, { _ in
|
||
return (CGSize(), { _, _, _ in })
|
||
})
|
||
}
|
||
guard case let .gramTransfer(amount, peerAddress, transactionId, comment, commentEncrypted) = action.action else {
|
||
return (0.0, { _ in
|
||
return (CGSize(), { _, _, _ in })
|
||
})
|
||
}
|
||
let isIncoming = engineMessage.effectivelyIncoming(item.context.account.peerId)
|
||
let caption = commentEncrypted ? "" : (comment ?? "")
|
||
let hasEncryptedCaption = commentEncrypted
|
||
|
||
let fiatState = item.context.walletContext?.stateValue.fiat
|
||
let fiatValue: String?
|
||
if let fiatState, let rate = fiatState.selectedRate {
|
||
fiatValue = formatTonFiatValue(
|
||
amount,
|
||
rate: rate.unitsPerGram,
|
||
currencySymbol: fiatState.selectedCurrency.symbol,
|
||
dateTimeFormat: item.presentationData.dateTimeFormat
|
||
)
|
||
} else {
|
||
fiatValue = nil
|
||
}
|
||
let serviceText = walletTransferServiceMessageString(
|
||
presentationData: (item.presentationData.theme.theme, item.presentationData.theme.wallpaper),
|
||
strings: item.presentationData.strings,
|
||
dateTimeFormat: item.presentationData.dateTimeFormat,
|
||
message: engineMessage,
|
||
isIncoming: isIncoming,
|
||
amount: amount,
|
||
fiatValue: fiatValue
|
||
)
|
||
|
||
let (labelLayout, labelApply) = makeLabelLayout(TextNodeLayoutArguments(
|
||
attributedString: serviceText,
|
||
backgroundColor: nil,
|
||
maximumNumberOfLines: 0,
|
||
truncationType: .end,
|
||
constrainedSize: CGSize(width: max(1.0, constrainedSize.width - 32.0), height: CGFloat.greatestFiniteMagnitude),
|
||
alignment: .center,
|
||
cutout: nil,
|
||
insets: UIEdgeInsets()
|
||
))
|
||
|
||
let cardSize = CGSize(width: 216.0, height: 148.0)
|
||
|
||
let amountFont = Font.with(
|
||
size: 18.0,
|
||
design: .round,
|
||
weight: .bold
|
||
)
|
||
let fractionalAmountFont = Font.with(
|
||
size: 14.0,
|
||
design: .round,
|
||
weight: .bold
|
||
)
|
||
let sign: String
|
||
if isIncoming {
|
||
sign = "+"
|
||
} else {
|
||
sign = "−"
|
||
}
|
||
let formattedAmount = formatTonAmountText(
|
||
amount,
|
||
dateTimeFormat: item.presentationData.dateTimeFormat,
|
||
maxDecimalPositions: 3
|
||
)
|
||
let localizedAmount = formatTonAmountText(
|
||
amount,
|
||
dateTimeFormat: item.presentationData.dateTimeFormat,
|
||
maxDecimalPositions: 3,
|
||
formatString: item.presentationData.strings.Currency_Grams
|
||
)
|
||
let amountText = NSMutableAttributedString(
|
||
string: localizedAmount,
|
||
font: amountFont,
|
||
textColor: UIColor(rgb: 0x0fddff)
|
||
)
|
||
let amountRange = (localizedAmount as NSString).range(of: formattedAmount)
|
||
if amountRange.location != NSNotFound {
|
||
amountText.replaceCharacters(in: amountRange, with: tonAmountAttributedString(
|
||
sign + formattedAmount,
|
||
integralFont: amountFont,
|
||
fractionalFont: fractionalAmountFont,
|
||
color: .white,
|
||
decimalSeparator: item.presentationData.dateTimeFormat.decimalSeparator
|
||
))
|
||
}
|
||
let (amountLayout, amountApply) = makeAmountLayout(TextNodeLayoutArguments(
|
||
attributedString: amountText,
|
||
backgroundColor: nil,
|
||
maximumNumberOfLines: 1,
|
||
truncationType: .end,
|
||
constrainedSize: CGSize(width: cardSize.width - 24.0, height: CGFloat.greatestFiniteMagnitude),
|
||
alignment: .center,
|
||
cutout: nil,
|
||
insets: UIEdgeInsets()
|
||
))
|
||
|
||
let peerName = item.message.id.peerId.isTelegramNotifications ? item.presentationData.strings.Notification_GramTransfer_UnknownUser : item.message.peers[item.message.id.peerId].flatMap(EnginePeer.init)?.displayTitle(strings: item.presentationData.strings, displayOrder: item.presentationData.nameDisplayOrder).uppercased() ?? ""
|
||
let (nameLayout, nameApply) = makeNameLayout(TextNodeLayoutArguments(
|
||
attributedString: NSAttributedString(
|
||
string: peerName,
|
||
font: Font.with(size: 12.0, design: .monospace, weight: .semibold),
|
||
textColor: UIColor(rgb: 0x0bdbff),
|
||
paragraphAlignment: .center
|
||
),
|
||
backgroundColor: nil,
|
||
maximumNumberOfLines: 1,
|
||
truncationType: .end,
|
||
constrainedSize: CGSize(width: cardSize.width - 30.0, height: CGFloat.greatestFiniteMagnitude),
|
||
alignment: .center,
|
||
cutout: nil,
|
||
insets: UIEdgeInsets()
|
||
))
|
||
|
||
var addressGroups: [String] = []
|
||
var addressIndex = peerAddress.startIndex
|
||
while addressIndex < peerAddress.endIndex {
|
||
let endIndex = peerAddress.index(addressIndex, offsetBy: 4, limitedBy: peerAddress.endIndex) ?? peerAddress.endIndex
|
||
addressGroups.append(String(peerAddress[addressIndex ..< endIndex]))
|
||
addressIndex = endIndex
|
||
}
|
||
let addressLayoutArguments = TextNodeLayoutArguments(
|
||
attributedString: NSAttributedString(
|
||
string: addressGroups.joined(separator: " "),
|
||
font: Font.with(size: 10.0, design: .monospace, weight: .medium),
|
||
textColor: UIColor(rgb: 0x0036b2),
|
||
paragraphAlignment: .center
|
||
),
|
||
backgroundColor: nil,
|
||
maximumNumberOfLines: 2,
|
||
truncationType: .end,
|
||
constrainedSize: CGSize(width: cardSize.width - 24.0, height: CGFloat.greatestFiniteMagnitude),
|
||
alignment: .center,
|
||
lineSpacing: 0.05,
|
||
cutout: nil,
|
||
insets: UIEdgeInsets()
|
||
)
|
||
let (addressLayout, addressApply) = makeAddressLayout(addressLayoutArguments)
|
||
let whiteAddressLayoutArguments = addressLayoutArguments.withAttributedString(
|
||
NSAttributedString(
|
||
string: addressGroups.joined(separator: " "),
|
||
font: Font.with(size: 10.0, design: .monospace, weight: .medium),
|
||
textColor: .white,
|
||
paragraphAlignment: .center
|
||
)
|
||
)
|
||
let (_, addressHighlightApply) = makeAddressHighlightLayout(whiteAddressLayoutArguments)
|
||
let (_, addressShimmerMaskApply) = makeAddressShimmerMaskLayout(whiteAddressLayoutArguments)
|
||
|
||
let hasCaption = hasEncryptedCaption || !caption.isEmpty
|
||
let (captionLayout, captionApply) = makeCaptionLayout(TextNodeLayoutArguments(
|
||
attributedString: NSAttributedString(
|
||
string: caption,
|
||
font: Font.regular(13.0),
|
||
textColor: .white,
|
||
paragraphAlignment: .center
|
||
),
|
||
backgroundColor: nil,
|
||
maximumNumberOfLines: 0,
|
||
truncationType: .end,
|
||
constrainedSize: CGSize(width: max(1.0, cardSize.width - 24.0), height: CGFloat.greatestFiniteMagnitude),
|
||
alignment: .center,
|
||
cutout: nil,
|
||
insets: UIEdgeInsets()
|
||
))
|
||
let captionSize = hasEncryptedCaption
|
||
? CGSize(width: 120.0, height: ceil(Font.regular(13.0).lineHeight))
|
||
: captionLayout.size
|
||
|
||
let ribbonTitle: String
|
||
if isIncoming {
|
||
ribbonTitle = item.presentationData.strings.Chat_GramTransfer_Received
|
||
} else {
|
||
ribbonTitle = item.presentationData.strings.Chat_GramTransfer_Sent
|
||
}
|
||
let ribbonTextLayoutArguments = TextNodeLayoutArguments(
|
||
attributedString: NSAttributedString(
|
||
string: ribbonTitle,
|
||
font: Font.semibold(11.0),
|
||
textColor: .white,
|
||
paragraphAlignment: .center
|
||
),
|
||
backgroundColor: nil,
|
||
maximumNumberOfLines: 1,
|
||
truncationType: .end,
|
||
constrainedSize: CGSize(width: CGFloat.greatestFiniteMagnitude, height: CGFloat.greatestFiniteMagnitude),
|
||
alignment: .center,
|
||
cutout: nil,
|
||
insets: UIEdgeInsets()
|
||
)
|
||
var (ribbonTextLayout, ribbonTextApply) = makeRibbonTextLayout(ribbonTextLayoutArguments)
|
||
let ribbonTextMaxWidth: CGFloat = 52.0
|
||
if ribbonTextLayout.size.width > ribbonTextMaxWidth {
|
||
(ribbonTextLayout, ribbonTextApply) = makeRibbonTextLayout(ribbonTextLayoutArguments.withAttributedString(
|
||
NSAttributedString(
|
||
string: ribbonTitle,
|
||
font: Font.semibold(11.0 * ribbonTextMaxWidth / ribbonTextLayout.size.width),
|
||
textColor: .white,
|
||
paragraphAlignment: .center
|
||
)
|
||
))
|
||
}
|
||
|
||
var labelRects = labelLayout.linesRects()
|
||
if labelRects.count > 1 {
|
||
let sortedIndices = (0 ..< labelRects.count).sorted(by: { labelRects[$0].width > labelRects[$1].width })
|
||
for index in sortedIndices {
|
||
for offset in -1 ... 1 where offset != 0 {
|
||
let adjacentIndex = index + offset
|
||
if adjacentIndex >= 0 && adjacentIndex < labelRects.count && abs(labelRects[adjacentIndex].width - labelRects[index].width) < 40.0 {
|
||
let width = max(labelRects[adjacentIndex].width, labelRects[index].width)
|
||
labelRects[adjacentIndex].size.width = width
|
||
labelRects[index].size.width = width
|
||
}
|
||
}
|
||
}
|
||
}
|
||
for index in labelRects.indices {
|
||
labelRects[index] = labelRects[index].insetBy(dx: -7.0, dy: floor((labelRects[index].height - 22.0) / 2.0))
|
||
labelRects[index].size.height = 22.0
|
||
labelRects[index].origin.x = floor((labelLayout.size.width - labelRects[index].width) / 2.0)
|
||
}
|
||
|
||
let labelBackgroundImage: (CGPoint, UIImage)?
|
||
var labelBackgroundUpdated = false
|
||
if let (currentOffset, currentImage, currentRects) = cachedLabelBackgroundImage, currentRects == labelRects {
|
||
labelBackgroundImage = (currentOffset, currentImage)
|
||
} else {
|
||
labelBackgroundImage = LinkHighlightingNode.generateImage(
|
||
color: .black,
|
||
inset: 0.0,
|
||
innerRadius: 11.0,
|
||
outerRadius: 11.0,
|
||
rects: labelRects,
|
||
useModernPathCalculation: false
|
||
)
|
||
labelBackgroundUpdated = true
|
||
}
|
||
|
||
let outerInset: CGFloat = 4.0
|
||
let captionSpacing = hasCaption ? 7.0 : 0.0
|
||
let captionBottomInset = hasCaption ? 4.0 : 0.0
|
||
let mediaSize = CGSize(
|
||
width: cardSize.width + outerInset * 2.0,
|
||
height: cardSize.height + outerInset * 2.0 + captionSpacing + (hasCaption ? captionSize.height : 0.0) + captionBottomInset
|
||
)
|
||
let totalSize = CGSize(
|
||
width: max(mediaSize.width, labelLayout.size.width),
|
||
height: labelLayout.size.height + 13.0 + mediaSize.height
|
||
)
|
||
|
||
return (totalSize.width, { boundingWidth in
|
||
return (totalSize, { [weak self] animation, _, _ in
|
||
guard let self else {
|
||
return
|
||
}
|
||
let isSameMessage = self.item?.context.account === item.context.account
|
||
&& self.item?.message.id.peerId == item.message.id.peerId
|
||
&& self.item?.message.stableId == item.message.stableId
|
||
if !isSameMessage {
|
||
self.cancelIncomingTransferAnimation()
|
||
self.mediaContainerNode.layer.removeAnimation(forKey: "transferFlightLanding")
|
||
self.stopCardBackgroundMotion()
|
||
self.cardIcon?.isRenderingEnabled = false
|
||
self.cardIcon?.removeFromSuperview()
|
||
self.cardIcon = nil
|
||
self.cardBackgroundRotation = 0.0
|
||
self.cardBackgroundMotion = nil
|
||
self.cardBackgroundNode.transform = CATransform3DIdentity
|
||
}
|
||
self.item = item
|
||
self.isIncomingTransfer = isIncoming
|
||
let wasAwaitingTransferFlight = self.isAwaitingTransferFlight
|
||
self.isAwaitingTransferFlight = !isIncoming
|
||
&& item.controllerInteraction.isAwaitingWalletTransferFlight?(item.message) == true
|
||
|
||
let refractionContentChanged = self.amountNode.cachedLayout !== amountLayout
|
||
let _ = labelApply()
|
||
let _ = amountApply()
|
||
let _ = nameApply()
|
||
let _ = addressApply()
|
||
let _ = addressHighlightApply()
|
||
let _ = addressShimmerMaskApply()
|
||
let _ = captionApply()
|
||
let _ = ribbonTextApply()
|
||
|
||
let labelFrame = CGRect(
|
||
origin: CGPoint(x: floorToScreenPixels((boundingWidth - labelLayout.size.width) * 0.5), y: 2.0),
|
||
size: labelLayout.size
|
||
)
|
||
self.labelNode.frame = labelFrame
|
||
|
||
let mediaFrame = CGRect(
|
||
origin: CGPoint(x: floorToScreenPixels((boundingWidth - mediaSize.width) * 0.5), y: labelLayout.size.height + 13.0),
|
||
size: mediaSize
|
||
)
|
||
let cardFrame = CGRect(
|
||
origin: CGPoint(x: outerInset, y: outerInset),
|
||
size: cardSize
|
||
)
|
||
if self.mediaContainerNode.frame != mediaFrame {
|
||
self.finishCompletionAnimation()
|
||
}
|
||
animation.animator.updateFrame(layer: self.mediaContainerNode.layer, frame: mediaFrame, completion: nil)
|
||
self.cardNode.frame = cardFrame
|
||
self.cardBackgroundNode.bounds = CGRect(origin: .zero, size: CGSize(width: 380.0, height: 295.0))
|
||
self.cardBackgroundNode.position = CGPoint(x: cardSize.width * 0.5, y: cardSize.height * 0.5)
|
||
|
||
let clockSize = CGSize(width: 14.0, height: 14.0)
|
||
let clockInset = 12.0 + (1.0 - UIScreenPixel)
|
||
self.sendingClockNode.frame = CGRect(origin: CGPoint(x: cardSize.width - clockSize.width - clockInset, y: clockInset), size: clockSize)
|
||
for node in [self.clockFrameNode, self.clockMinNode] {
|
||
node.bounds = CGRect(origin: .zero, size: clockSize)
|
||
node.position = CGPoint(x: clockSize.width * 0.5, y: clockSize.height * 0.5)
|
||
}
|
||
if self.clockFrameNode.image == nil {
|
||
let graphics = PresentationResourcesChat.principalGraphics(
|
||
theme: item.presentationData.theme.theme,
|
||
wallpaper: item.presentationData.theme.wallpaper,
|
||
bubbleCorners: item.presentationData.chatBubbleCorners
|
||
)
|
||
let clockColor = UIColor.white
|
||
self.clockFrameNode.image = generateTintedImage(image: graphics.clockMediaFrameImage, color: clockColor)
|
||
self.clockMinNode.image = generateTintedImage(image: graphics.clockMediaMinImage, color: clockColor)
|
||
}
|
||
|
||
self.updateDiamond()
|
||
self.amountNode.frame = CGRect(
|
||
origin: CGPoint(x: floorToScreenPixels((cardSize.width - amountLayout.size.width) * 0.5), y: 62.0),
|
||
size: amountLayout.size
|
||
)
|
||
self.nameNode.frame = CGRect(
|
||
origin: CGPoint(x: floorToScreenPixels((cardSize.width - nameLayout.size.width) * 0.5), y: 97.0),
|
||
size: nameLayout.size
|
||
)
|
||
if refractionContentChanged, self.cardIcon?.isExpanded == true {
|
||
self.updateDiamondRefraction()
|
||
}
|
||
self.addressNode.frame = CGRect(
|
||
origin: CGPoint(x: floorToScreenPixels((cardSize.width - addressLayout.size.width) * 0.5), y: 114.0),
|
||
size: addressLayout.size
|
||
)
|
||
self.addressHighlightNode.frame = self.addressNode.frame.offsetBy(dx: 0.0, dy: 1.0)
|
||
self.addressShimmerMaskNode.frame = self.addressNode.frame
|
||
|
||
let ribbonSize = TransferCardRibbonGeometry.size
|
||
if self.arrivalTextView != nil {
|
||
// Apply layout in model coordinates, then restore the arrival pose below.
|
||
self.ribbonBackgroundNode.transform = CATransform3DIdentity
|
||
self.ribbonTextContainerNode.transform = CATransform3DIdentity
|
||
}
|
||
let ribbonFrame = CGRect(
|
||
origin: CGPoint(x: cardFrame.maxX - ribbonSize.width + 2.0, y: cardFrame.minY - 2.0),
|
||
size: ribbonSize
|
||
)
|
||
self.ribbonBackgroundNode.frame = ribbonFrame
|
||
self.ribbonTextContainerNode.frame = ribbonFrame
|
||
self.ribbonTextMaskNode.frame = CGRect(origin: .zero, size: ribbonSize)
|
||
if let ribbonAnimationMaskLayer = self.ribbonAnimationMaskLayer {
|
||
ribbonAnimationMaskLayer.frame = CGRect(origin: .zero, size: ribbonSize)
|
||
} else {
|
||
self.ribbonTextContainerNode.view.mask = self.ribbonTextMaskNode.view
|
||
}
|
||
let ribbonCenter = TransferCardRibbonGeometry.center
|
||
let ribbonTextPosition = CGPoint(x: ribbonCenter.x, y: ribbonCenter.y + 1.0)
|
||
self.ribbonTextNode.transform = CATransform3DMakeRotation(.pi / 4.0, 0.0, 0.0, 1.0)
|
||
self.ribbonTextNode.bounds = CGRect(origin: .zero, size: ribbonTextLayout.size)
|
||
self.ribbonTextNode.position = ribbonTextPosition
|
||
|
||
self.captionNode.isHidden = !hasCaption || hasEncryptedCaption
|
||
if hasEncryptedCaption {
|
||
self.removeCaptionTextSelection(animated: false)
|
||
} else if let captionDustNode = self.captionDustNode {
|
||
captionDustNode.removeFromSupernode()
|
||
self.captionDustNode = nil
|
||
}
|
||
if hasCaption {
|
||
let captionFrame = CGRect(
|
||
origin: CGPoint(
|
||
x: floorToScreenPixels((mediaSize.width - captionSize.width) * 0.5),
|
||
y: cardFrame.maxY + captionSpacing
|
||
),
|
||
size: captionSize
|
||
)
|
||
self.captionNode.frame = captionFrame
|
||
if hasEncryptedCaption {
|
||
let dustNode: InvisibleInkDustNode
|
||
if let current = self.captionDustNode {
|
||
dustNode = current
|
||
} else {
|
||
dustNode = InvisibleInkDustNode(textNode: nil, enableAnimations: item.context.sharedContext.energyUsageSettings.fullTranslucency)
|
||
dustNode.isUserInteractionEnabled = false
|
||
self.captionDustNode = dustNode
|
||
self.mediaContainerNode.addSubnode(dustNode)
|
||
}
|
||
dustNode.frame = captionFrame.insetBy(dx: -3.0, dy: -3.0)
|
||
let rect = CGRect(origin: CGPoint(x: 3.0, y: 3.0), size: captionSize).insetBy(dx: 0.0, dy: 2.0)
|
||
dustNode.update(size: dustNode.frame.size, color: .white, textColor: .white, rects: [rect], wordRects: [rect])
|
||
}
|
||
if let textSelectionNode = self.captionTextSelectionNode {
|
||
let shouldUpdateLayout = textSelectionNode.frame.size != captionFrame.size
|
||
textSelectionNode.frame = captionFrame
|
||
textSelectionNode.highlightAreaNode.frame = captionFrame
|
||
if shouldUpdateLayout {
|
||
textSelectionNode.updateLayout()
|
||
}
|
||
}
|
||
} else {
|
||
self.removeCaptionTextSelection(animated: false)
|
||
self.captionNode.frame = CGRect()
|
||
}
|
||
|
||
if self.mediaBackgroundContent == nil, let backgroundContent = item.controllerInteraction.presentationContext.backgroundNode?.makeBubbleBackground(for: .free) {
|
||
backgroundContent.clipsToBounds = true
|
||
backgroundContent.cornerRadius = 24.0
|
||
self.mediaBackgroundContent = backgroundContent
|
||
self.mediaContainerNode.insertSubnode(backgroundContent, at: 0)
|
||
}
|
||
if let mediaBackgroundContent = self.mediaBackgroundContent {
|
||
animation.animator.updateFrame(layer: mediaBackgroundContent.layer, frame: CGRect(origin: .zero, size: mediaSize), completion: nil)
|
||
mediaBackgroundContent.cornerRadius = 24.0
|
||
}
|
||
|
||
let baseLabelBackgroundFrame = labelFrame.offsetBy(dx: 0.0, dy: -11.0)
|
||
if let (offset, image) = labelBackgroundImage {
|
||
if self.labelBackgroundNode == nil, let backgroundNode = item.controllerInteraction.presentationContext.backgroundNode?.makeBubbleBackground(for: .free) {
|
||
self.labelBackgroundNode = backgroundNode
|
||
self.insertSubnode(backgroundNode, at: 0)
|
||
}
|
||
if labelBackgroundUpdated, let labelBackgroundNode = self.labelBackgroundNode {
|
||
if labelRects.count == 1 {
|
||
labelBackgroundNode.clipsToBounds = true
|
||
labelBackgroundNode.cornerRadius = labelRects[0].height * 0.5
|
||
labelBackgroundNode.view.mask = nil
|
||
} else {
|
||
labelBackgroundNode.clipsToBounds = false
|
||
labelBackgroundNode.cornerRadius = 0.0
|
||
labelBackgroundNode.view.mask = self.labelBackgroundMaskNode.view
|
||
}
|
||
}
|
||
if let labelBackgroundNode = self.labelBackgroundNode {
|
||
animation.animator.updateFrame(
|
||
layer: labelBackgroundNode.layer,
|
||
frame: CGRect(
|
||
origin: CGPoint(x: baseLabelBackgroundFrame.minX + offset.x, y: baseLabelBackgroundFrame.minY + offset.y),
|
||
size: image.size
|
||
),
|
||
completion: nil
|
||
)
|
||
}
|
||
self.labelBackgroundMaskNode.image = image
|
||
self.labelBackgroundMaskNode.frame = CGRect(origin: .zero, size: image.size)
|
||
self.cachedLabelBackgroundImage = (offset, image, labelRects)
|
||
}
|
||
|
||
if let (rect, size) = self.absoluteRect {
|
||
self.updateAbsoluteRect(rect, within: size)
|
||
}
|
||
self.updateWalletSubscription(item: item, isIncoming: isIncoming, transactionId: transactionId, fiatState: fiatState, isSameMessage: isSameMessage)
|
||
if wasAwaitingTransferFlight != self.isAwaitingTransferFlight {
|
||
self.updateTransferAppearance(previousStatus: nil, animated: false)
|
||
}
|
||
self.shimmerView?.update(size: cardSize, addressFrame: self.addressNode.frame)
|
||
self.arrivalTextView?.frame = self.cardNode.bounds
|
||
self.arrivalTextView?.update(nodes: [self.amountNode, self.nameNode, self.addressNode])
|
||
self.updateIncomingTransferVisibility()
|
||
})
|
||
})
|
||
})
|
||
}
|
||
}
|
||
|
||
override public func updateAbsoluteRect(_ rect: CGRect, within containerSize: CGSize) {
|
||
self.absoluteRect = (rect, containerSize)
|
||
|
||
}
|
||
|
||
override public func updateHighlightedState(animated: Bool) -> Bool {
|
||
guard let item = self.item else {
|
||
return false
|
||
}
|
||
let highlighted = item.controllerInteraction.highlightedState?.messageStableId == item.message.stableId
|
||
if self.isHighlighted != highlighted {
|
||
self.isHighlighted = highlighted
|
||
if highlighted && self.arrivalTextView == nil {
|
||
self.isPlayingHighlightShimmer = true
|
||
self.shimmerView?.removeFromSuperview()
|
||
self.shimmerView = nil
|
||
self.updateShimmer(animated: false)
|
||
}
|
||
}
|
||
return highlighted
|
||
}
|
||
|
||
override public func updateTouchesAtPoint(_ point: CGPoint?) {
|
||
guard let item = self.item else {
|
||
return
|
||
}
|
||
|
||
var rects: [(CGRect, CGRect)]?
|
||
let textNodeFrame = self.labelNode.frame
|
||
if let point, let (index, attributes) = self.labelNode.attributesAtPoint(CGPoint(
|
||
x: point.x - textNodeFrame.minX,
|
||
y: point.y - textNodeFrame.minY
|
||
)) {
|
||
let possibleNames = [TelegramTextAttributes.URL, TelegramTextAttributes.PeerMention]
|
||
for name in possibleNames where attributes[NSAttributedString.Key(rawValue: name)] != nil {
|
||
rects = self.labelNode.lineAndAttributeRects(name: name, at: index)
|
||
break
|
||
}
|
||
}
|
||
|
||
if let rects {
|
||
let mappedRects = rects.map { lineRect, attributeRect -> CGRect in
|
||
var attributeRect = attributeRect
|
||
attributeRect.origin.x = floor((textNodeFrame.size.width - lineRect.width) * 0.5) + attributeRect.origin.x
|
||
return attributeRect
|
||
}
|
||
let highlightingNode: LinkHighlightingNode
|
||
if let current = self.linkHighlightingNode {
|
||
highlightingNode = current
|
||
} else {
|
||
let serviceColor = serviceMessageColorComponents(
|
||
theme: item.presentationData.theme.theme,
|
||
wallpaper: item.presentationData.theme.wallpaper
|
||
)
|
||
highlightingNode = LinkHighlightingNode(color: serviceColor.linkHighlight)
|
||
highlightingNode.inset = 2.5
|
||
self.linkHighlightingNode = highlightingNode
|
||
self.insertSubnode(highlightingNode, belowSubnode: self.labelNode)
|
||
}
|
||
highlightingNode.frame = self.labelNode.frame.offsetBy(dx: 0.0, dy: 1.5)
|
||
highlightingNode.updateRects(mappedRects)
|
||
} else if let highlightingNode = self.linkHighlightingNode {
|
||
self.linkHighlightingNode = nil
|
||
highlightingNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.18, removeOnCompletion: false, completion: { [weak highlightingNode] _ in
|
||
highlightingNode?.removeFromSupernode()
|
||
})
|
||
}
|
||
}
|
||
|
||
override public func tapActionAtPoint(_ point: CGPoint, gesture: TapLongTapOrDoubleTapGesture, isEstimating: Bool) -> ChatMessageBubbleContentTapAction {
|
||
if let iconView = self.cardIcon,
|
||
iconView.isUserInteractionEnabled,
|
||
iconView.point(inside: iconView.convert(point, from: self.view), with: nil) {
|
||
return ChatMessageBubbleContentTapAction(content: .ignore)
|
||
}
|
||
if gesture == .tap, let (_, attributes) = self.labelNode.attributesAtPoint(CGPoint(
|
||
x: point.x - self.labelNode.frame.minX,
|
||
y: point.y - self.labelNode.frame.minY
|
||
)) {
|
||
if let _ = attributes[NSAttributedString.Key(rawValue: TelegramTextAttributes.URL)] as? String {
|
||
return ChatMessageBubbleContentTapAction(content: .custom({ [weak self] in
|
||
guard let self, let item = self.item else {
|
||
return
|
||
}
|
||
let controller = item.context.sharedContext.makeWalletInfoScreen(
|
||
context: item.context,
|
||
mode: .gram,
|
||
completion: nil
|
||
)
|
||
if let navigationController = item.controllerInteraction.navigationController() {
|
||
navigationController.pushViewController(controller)
|
||
} else {
|
||
item.controllerInteraction.presentControllerInCurrent(controller, nil)
|
||
}
|
||
}))
|
||
} else if let peerMention = attributes[NSAttributedString.Key(rawValue: TelegramTextAttributes.PeerMention)] as? TelegramPeerMention {
|
||
#if DEBUG
|
||
if !self.isIncomingTransfer {
|
||
return ChatMessageBubbleContentTapAction(content: .custom({ [weak self] in
|
||
self?.toggleDebugTransferStatus()
|
||
}))
|
||
}
|
||
#endif
|
||
return ChatMessageBubbleContentTapAction(content: .peerMention(
|
||
peerId: peerMention.peerId,
|
||
mention: peerMention.mention,
|
||
openProfile: false
|
||
))
|
||
}
|
||
}
|
||
|
||
let mediaPoint = self.mediaContainerNode.view.convert(point, from: self.view)
|
||
if gesture == .tap, let captionDustNode = self.captionDustNode, captionDustNode.frame.contains(mediaPoint) {
|
||
return ChatMessageBubbleContentTapAction(content: .custom({ [weak self] in
|
||
guard let self, let item = self.item else {
|
||
return
|
||
}
|
||
let _ = item.controllerInteraction.openMessage(item.message, OpenMessageParams(mode: .default, decryptWalletComment: true))
|
||
}))
|
||
}
|
||
if self.cardNode.frame.contains(mediaPoint) || self.captionNode.frame.contains(mediaPoint) || self.mediaBackgroundContent?.frame.contains(mediaPoint) == true {
|
||
return ChatMessageBubbleContentTapAction(content: .openMessage)
|
||
}
|
||
return ChatMessageBubbleContentTapAction(content: .none)
|
||
}
|
||
}
|
||
|
||
private func walletTransferServiceMessageString(
|
||
presentationData: (PresentationTheme, TelegramWallpaper),
|
||
strings: PresentationStrings,
|
||
dateTimeFormat: PresentationDateTimeFormat,
|
||
message: EngineMessage,
|
||
isIncoming: Bool,
|
||
amount: Int64,
|
||
fiatValue: String?
|
||
) -> NSAttributedString {
|
||
let primaryTextColor = serviceMessageColorComponents(theme: presentationData.0, wallpaper: presentationData.1).primaryText
|
||
let regularFont = Font.regular(13.0)
|
||
let semiboldFont = Font.semibold(13.0)
|
||
let conversationPeer = message.enginePeers[message.id.peerId] ?? message.author
|
||
let peerName = conversationPeer?.compactDisplayTitle ?? ""
|
||
let peerMentionAttributes: [NSAttributedString.Key: Any]
|
||
if let peerId = conversationPeer?.id {
|
||
peerMentionAttributes = [
|
||
NSAttributedString.Key(rawValue: TelegramTextAttributes.PeerMention): TelegramPeerMention(peerId: peerId, mention: "")
|
||
]
|
||
} else {
|
||
peerMentionAttributes = [:]
|
||
}
|
||
|
||
let amountText = formatTonAmountText(
|
||
amount,
|
||
dateTimeFormat: dateTimeFormat,
|
||
maxDecimalPositions: 3,
|
||
formatString: strings.Currency_Grams
|
||
)
|
||
let text: PresentationStrings.FormattedString
|
||
if let fiatValue {
|
||
text = isIncoming
|
||
? (message.id.peerId.isTelegramNotifications ? strings.Notification_GramTransferUnknown_WithFiat(amountText, fiatValue) : strings.Notification_GramTransfer_WithFiat(peerName, amountText, fiatValue))
|
||
: strings.Notification_GramTransfer_WithFiatYou(peerName, amountText, fiatValue)
|
||
} else {
|
||
text = isIncoming
|
||
? (message.id.peerId.isTelegramNotifications ? strings.Notification_GramTransferUnknown(amountText) : strings.Notification_GramTransfer(peerName, amountText))
|
||
: strings.Notification_GramTransferYou(peerName, amountText)
|
||
}
|
||
let result = NSMutableAttributedString(string: text.string, font: regularFont, textColor: primaryTextColor)
|
||
for range in text.ranges {
|
||
if range.index == 0 {
|
||
result.addAttributes(peerMentionAttributes, range: range.range)
|
||
if isIncoming {
|
||
result.addAttribute(.font, value: semiboldFont, range: range.range)
|
||
}
|
||
} else if range.index == 1 {
|
||
result.addAttribute(.font, value: semiboldFont, range: range.range)
|
||
}
|
||
}
|
||
|
||
return result
|
||
}
|