Files
isaacandClaude Opus 4.7 2ac8a490b0 Postbox -> TelegramEngine waves 94-102 (squashed)
Adds AccountManagerResources facade in TelegramCore mirroring
TelegramEngine.Resources, drains the entire accountManager-side
mediaBox refactor across consumer modules, and validates the
Shape-C/D handle-migration pattern (account: Account ->
engine: TelegramEngine) on three classes.

Wave 94 (AccountManagerResources facade + 8 sites): new
AccountManagerResources class in TelegramCore with
storeResourceData, completedResourcePath (resource + id overloads),
status (with approximateSynchronousValue), moveResourceData,
fetch. Migrate EditThemeController (5 storeResourceData + 2
completedResourcePath) and WebBrowserSettingsController (1
storeResourceData).

Wave 95 (10 sites): WallpaperUtils.swift sweep, 5 distinct
storeResourceData patterns, replace_all on the two repeated ones.

Wave 96 (7 sites): theme controller triple — TPC (1 store + 2
moveResourceData), TPCN (1 status + 1 fetch + closure type
MediaResourceStatus -> EngineMediaResource.FetchStatus + rename
account local to engine), TAC (1 completedResourcePath + 1 store).

Wave 97 (9 sites): WallpaperGalleryController.swift, 4
completedResourcePath + 5 storeResourceData via replace_all.

Wave 98 (2 files): WallpaperGallery status-type cascade
Promise<MediaResourceStatus> -> Promise<EngineMediaResource.FetchStatus>
across WallpaperGalleryItem.swift + WallpaperGalleryController.swift.
Drops cosmetic `_asStatus()` unwrap.

Wave 99 (2 facade extensions + 7 sites): adds
AccountManagerResources.data() and approximateSynchronousValue
to engine.resources.status(). Migrates OpenResolvedUrl (data +
storeResourceData with .complete -> .isComplete cascade),
SettingsThemeWallpaperNode (paired engine + accountManager
status), WallpaperUploadManager, OpenChatMessage,
LiveChatReactionStreamView.

Wave 100 (Shape-C/D, free function): legacyWebSearchItem
account: Account -> engine: TelegramEngine. 4 files.

Wave 101 (Shape-C/D, small class): VerticalListContextResults
ChatInputPanelItem stored field migration. 2 files. Drops
`|> map(EngineMediaResource.FetchStatus.init)` redundancy.

Wave 102 (Shape-C/D, medium class with weak-context retention):
SharedMediaPlayer account: Account -> engine: TelegramEngine.
Engine field is intentionally a strong handle since the class
stores context as `weak var`. 2 files.

After this squash the accountManager-side mediaBox refactor is
essentially complete; only Postbox-protocol-blocked
cachedResourceRepresentation triple remains as the foundational
deferred item.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 02:46:44 +04:00

370 lines
15 KiB
Swift

import Foundation
import UIKit
import Display
import ComponentFlow
import TelegramCore
import AvatarNode
import AppBundle
import AccountContext
import HierarchyTrackingLayer
import LokiRng
import SwiftSignalKit
import TelegramPresentationData
private let gradientColors: [NSArray] = [
[UIColor(rgb: 0xff516a).cgColor, UIColor(rgb: 0xff885e).cgColor],
[UIColor(rgb: 0xffa85c).cgColor, UIColor(rgb: 0xffcd6a).cgColor],
[UIColor(rgb: 0x665fff).cgColor, UIColor(rgb: 0x82b1ff).cgColor],
[UIColor(rgb: 0x54cb68).cgColor, UIColor(rgb: 0xa0de7e).cgColor],
[UIColor(rgb: 0x4acccd).cgColor, UIColor(rgb: 0x00fcfd).cgColor],
[UIColor(rgb: 0x2a9ef1).cgColor, UIColor(rgb: 0x72d5fd).cgColor],
[UIColor(rgb: 0xd669ed).cgColor, UIColor(rgb: 0xe0a2f3).cgColor],
]
private func avatarViewLettersImage(size: CGSize, peerId: EnginePeer.Id, letters: [String], isStory: Bool) -> UIImage? {
UIGraphicsBeginImageContextWithOptions(size, false, 2.0)
let context = UIGraphicsGetCurrentContext()
context?.beginPath()
if isStory {
context?.addEllipse(in: CGRect(x: 0.0, y: 0.0, width: size.width, height: size.height).insetBy(dx: 4.0, dy: 4.0))
} else {
context?.addEllipse(in: CGRect(x: 0.0, y: 0.0, width: size.width, height: size.height))
}
context?.clip()
let colorIndex: Int
if peerId.namespace == .max {
colorIndex = 0
} else {
colorIndex = abs(Int(clamping: peerId.id._internalGetInt64Value()))
}
let colorsArray = gradientColors[colorIndex % gradientColors.count]
var locations: [CGFloat] = [1.0, 0.0]
let gradient = CGGradient(colorsSpace: deviceColorSpace, colors: colorsArray, locations: &locations)!
context?.drawLinearGradient(gradient, start: CGPoint(), end: CGPoint(x: 0.0, y: size.height), options: CGGradientDrawingOptions())
context?.setBlendMode(.normal)
let string = letters.count == 0 ? "" : (letters[0] + (letters.count == 1 ? "" : letters[1]))
let attributedString = NSAttributedString(string: string, attributes: [NSAttributedString.Key.font: UIFont.systemFont(ofSize: 8.0), NSAttributedString.Key.foregroundColor: UIColor.white])
let line = CTLineCreateWithAttributedString(attributedString)
let lineBounds = CTLineGetBoundsWithOptions(line, .useGlyphPathBounds)
let lineOffset = CGPoint(x: string == "B" ? 1.0 : 0.0, y: 0.0)
let lineOrigin = CGPoint(x: floor(-lineBounds.origin.x + (size.width - lineBounds.size.width) / 2.0) + lineOffset.x, y: floor(-lineBounds.origin.y + (size.height - lineBounds.size.height) / 2.0))
context?.translateBy(x: size.width / 2.0, y: size.height / 2.0)
context?.scaleBy(x: 1.0, y: -1.0)
context?.translateBy(x: -size.width / 2.0, y: -size.height / 2.0)
context?.translateBy(x: lineOrigin.x, y: lineOrigin.y)
if let context = context {
CTLineDraw(line, context)
}
context?.translateBy(x: -lineOrigin.x, y: -lineOrigin.y)
if isStory {
context?.resetClip()
let lineWidth: CGFloat = 2.0
context?.setLineWidth(lineWidth)
context?.addEllipse(in: CGRect(origin: CGPoint(x: size.width * 0.5, y: size.height * 0.5), size: CGSize(width: size.width, height: size.height)).insetBy(dx: lineWidth * 0.5, dy: lineWidth * 0.5))
context?.replacePathWithStrokedPath()
context?.clip()
let colors: [CGColor] = [
UIColor(rgb: 0x34C76F).cgColor,
UIColor(rgb: 0x3DA1FD).cgColor
]
var locations: [CGFloat] = [0.0, 1.0]
let colorSpace = CGColorSpaceCreateDeviceRGB()
let gradient = CGGradient(colorsSpace: colorSpace, colors: colors as CFArray, locations: &locations)!
context?.drawLinearGradient(gradient, start: CGPoint(x: 0.0, y: 0.0), end: CGPoint(x: 0.0, y: size.height), options: CGGradientDrawingOptions())
}
let image = UIGraphicsGetImageFromCurrentImageContext()
UIGraphicsEndImageContext()
return image
}
private func makePeerBadgeImage(engine: TelegramEngine, peer: EnginePeer, count: Int) async -> UIImage {
let avatarSize: CGFloat = 16.0
let avatarInset: CGFloat = 2.0
let avatarIconSpacing: CGFloat = 2.0
let iconTextSpacing: CGFloat = 1.0
let iconSize: CGFloat = 10.0
let rightInset: CGFloat = 5.0
let text = NSAttributedString(string: countString(Int64(count)), font: Font.semibold(10.0), textColor: .white)
var textSize = text.boundingRect(with: CGSize(width: 200.0, height: 100.0), options: .usesLineFragmentOrigin, context: nil).size
textSize.width = ceil(textSize.width)
textSize.height = ceil(textSize.height)
var avatarSourceImage: UIImage?
if let resource = smallestImageRepresentation(peer.profileImageRepresentations)?.resource, let peerReference = PeerReference(peer) {
let disposable = engine.resources.fetch(reference: .avatar(peer: peerReference, resource: resource), userLocation: .peer(peer.id), userContentType: .avatar).startStrict()
let signal = engine.resources.data(resource: EngineMediaResource(resource))
|> filter { $0.isComplete }
|> map { value -> Data? in
if value.isComplete {
return try? Data(contentsOf: URL(fileURLWithPath: value.path))
} else {
return nil
}
}
|> take(1)
|> timeout(0.9, queue: Queue.concurrentDefaultQueue(), alternate: .single(nil))
if let imageData = await signal.get() {
avatarSourceImage = UIImage(data: imageData)
}
disposable.dispose()
}
let size = CGSize(width: avatarInset + avatarSize + avatarIconSpacing + iconSize + iconTextSpacing + textSize.width + rightInset, height: avatarSize + avatarInset * 2.0)
return generateImage(size, rotatedContext: { size, context in
UIGraphicsPushContext(context)
defer {
UIGraphicsPopContext()
}
context.clear(CGRect(origin: CGPoint(), size: size))
context.setFillColor(UIColor(rgb: 0xFFB10D).cgColor)
context.addPath(UIBezierPath(roundedRect: CGRect(origin: CGPoint(), size: size), cornerRadius: size.height * 0.5).cgPath)
context.fillPath()
let avatarRect = CGRect(origin: CGPoint(x: avatarInset, y: avatarInset), size: CGSize(width: avatarSize, height: avatarSize))
if let avatarSourceImage {
context.addEllipse(in: avatarRect)
context.clip()
avatarSourceImage.draw(in: avatarRect)
context.resetClip()
} else if let image = avatarViewLettersImage(size: CGSize(width: avatarSize, height: avatarSize), peerId: peer.id, letters: peer.displayLetters, isStory: false) {
image.draw(in: avatarRect)
}
if let image = generateTintedImage(image: UIImage(bundleImageName: "Premium/Stars/ButtonStar"), color: .white) {
let iconFrame = CGRect(origin: CGPoint(x: avatarInset + avatarSize + avatarIconSpacing, y: floorToScreenPixels((size.height - iconSize) * 0.5) + 1.0), size: CGSize(width: iconSize, height: iconSize))
image.draw(in: iconFrame)
}
text.draw(at: CGPoint(x: avatarInset + avatarSize + avatarIconSpacing + iconSize + iconTextSpacing, y: floorToScreenPixels((size.height - textSize.height) * 0.5)))
})!
}
private actor LiveChatReactionItemTaskQueue {
private final class PeerTask {
let peer: EnginePeer
let count: Int
let completion: (UIImage) -> Void
init(peer: EnginePeer, count: Int, completion: @escaping (UIImage) -> Void) {
self.peer = peer
self.count = count
self.completion = completion
}
}
private let engine: TelegramEngine
private var tasks: [PeerTask] = []
init(engine: TelegramEngine) {
self.engine = engine
}
func add(peer: EnginePeer, count: Int, completion: @escaping (UIImage) -> Void) {
self.tasks.append(PeerTask(peer: peer, count: count, completion: completion))
if self.tasks.count == 1 {
Task {
await processTasks()
}
}
}
private func processTasks() async {
while !self.tasks.isEmpty {
let task = self.tasks.removeFirst()
let image = await makePeerBadgeImage(engine: self.engine, peer: task.peer, count: task.count)
task.completion(image)
}
}
}
final class LiveChatReactionStreamView: UIView {
private final class ItemLayer: SimpleLayer {
let amplitude: CGFloat
let period: CGFloat
let phaseOffset: CGFloat
let baseX: CGFloat
let verticalVelocity: CGFloat
var timeValue: CGFloat = 0.0
init(image: UIImage, amplitude: CGFloat, period: CGFloat, phaseOffset: CGFloat, baseX: CGFloat, verticalVelocity: CGFloat) {
self.amplitude = amplitude
self.period = period
self.phaseOffset = phaseOffset
self.baseX = baseX
self.verticalVelocity = verticalVelocity
super.init()
self.contents = image.cgImage
self.allowsEdgeAntialiasing = true
}
override init(layer: Any) {
self.amplitude = 0.0
self.period = 0.0
self.phaseOffset = 0.0
self.baseX = 0.0
self.verticalVelocity = 0.0
super.init(layer: layer)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
private var nextId: Int = 0
private var itemLayers: [Int: ItemLayer] = [:]
private let itemLayerContainer: SimpleLayer
private let hierarchyTracker: HierarchyTrackingLayer
private var previousTimestamp: Double = 0.0
private var displayLink: SharedDisplayLinkDriver.Link?
private var previousPhysicsTimestamp: Double = 0.0
private let taskQueue: LiveChatReactionItemTaskQueue
init(context: AccountContext) {
self.itemLayerContainer = SimpleLayer()
self.hierarchyTracker = HierarchyTrackingLayer()
self.taskQueue = LiveChatReactionItemTaskQueue(engine: context.engine)
super.init(frame: CGRect())
self.layer.addSublayer(self.itemLayerContainer)
self.layer.addSublayer(self.hierarchyTracker)
self.hierarchyTracker.isInHierarchyUpdated = { [weak self] inHierarchy in
guard let self else {
return
}
if inHierarchy {
if self.displayLink == nil {
self.displayLink = SharedDisplayLinkDriver.shared.add(framesPerSecond: .max, { [weak self] _ in
guard let self else {
return
}
self.updatePhysics()
})
}
} else {
self.displayLink = nil
}
}
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func add(peer: EnginePeer, count: Int) {
if !self.hierarchyTracker.isInHierarchy {
return
}
let timestamp = CFAbsoluteTimeGetCurrent()
if timestamp < self.previousTimestamp + 0.2 {
return
}
self.previousTimestamp = timestamp
Task {
await self.taskQueue.add(peer: peer, count: count, completion: { [weak self] image in
Task { @MainActor in
guard let self else {
return
}
self.addRenderedItem(image: image)
}
})
}
}
private func addRenderedItem(image: UIImage) {
let id = self.nextId
self.nextId += 1
let random = LokiRng(seed0: UInt(id), seed1: 1, seed2: 0)
let itemX: CGFloat = -image.size.width - 8.0 + 20.0 * CGFloat(LokiRng.random(withSeed0: UInt(id), seed1: 0, seed2: 0) - 0.5)
let phaseOffset: CGFloat = CGFloat(random.next())
let itemLayer = ItemLayer(image: image, amplitude: 0.0 + CGFloat(random.next()) * 6.0, period: 1.5 + CGFloat(random.next()) * 2.0, phaseOffset: phaseOffset, baseX: itemX, verticalVelocity: -(1.0 + CGFloat(random.next()) * 0.2) * 90.0)
itemLayer.frame = CGRect(origin: CGPoint(x: itemX, y: -image.size.height * 0.5), size: image.size)
self.itemLayers[id] = itemLayer
self.itemLayerContainer.addSublayer(itemLayer)
let itemDuration: Double = 1.2 + Double(random.next()) * 0.8
itemLayer.animateScale(from: 0.001, to: 1.0, duration: 0.2)
itemLayer.animateAlpha(from: 0.0, to: 1.0, duration: 0.1, completion: { [weak self, weak itemLayer] _ in
guard let itemLayer else {
return
}
let delay: Double = itemDuration - 0.1 - 0.18
let transition: ComponentTransition = .easeInOut(duration: 0.2)
transition.animateBlur(layer: itemLayer, fromRadius: 0.0, toRadius: 8.0, delay: delay)
itemLayer.animateScale(from: 1.0, to: 0.001, duration: 0.2, delay: delay, removeOnCompletion: false)
itemLayer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2, delay: delay, removeOnCompletion: false, completion: { [weak self] _ in
guard let self else {
return
}
if let itemLayer = self.itemLayers[id] {
self.itemLayers.removeValue(forKey: id)
itemLayer.removeFromSuperlayer()
}
})
})
}
private func updatePhysics() {
let timestamp = CACurrentMediaTime()
let dt = max(1.0 / 120.0, min(1.0 / 30.0, timestamp - self.previousPhysicsTimestamp))
self.previousPhysicsTimestamp = timestamp
CATransaction.begin()
CATransaction.setDisableActions(true)
for (_, itemLayer) in self.itemLayers {
itemLayer.timeValue += dt
let itemPhase = (itemLayer.timeValue.truncatingRemainder(dividingBy: itemLayer.period) / itemLayer.period + itemLayer.phaseOffset).truncatingRemainder(dividingBy: 1.0)
let phaseAngle = itemPhase * CGFloat.pi * 2.0
let phaseFraction = sin(phaseAngle)
let newX = itemLayer.baseX + phaseFraction * itemLayer.amplitude
let newY = itemLayer.position.y + itemLayer.verticalVelocity * dt
itemLayer.position = CGPoint(x: newX, y: newY)
let horizontalVelocity = itemLayer.amplitude * cos(phaseAngle) * (CGFloat.pi * 2.0 / itemLayer.period)
let rotationAngle = atan2(itemLayer.verticalVelocity, horizontalVelocity) + CGFloat.pi * 0.5
itemLayer.setValue(rotationAngle, forKeyPath: "transform.rotation.z")
}
CATransaction.commit()
}
func update(size: CGSize, sourcePoint: CGPoint, transition: ComponentTransition) {
transition.setFrame(layer: self.itemLayerContainer, frame: CGRect(origin: sourcePoint, size: CGSize()))
}
}