Групповые аватарки: отправка + шифрование + Desktop parity

This commit is contained in:
2026-04-17 01:07:46 +05:00
parent 01399a4571
commit 660d1f046d
10 changed files with 254 additions and 95 deletions

View File

@@ -22,6 +22,37 @@ enum TransportError: LocalizedError {
}
}
// MARK: - TransportLimiter
/// Limits concurrent CDN upload/download operations to prevent I/O storms
/// when user scrolls through media-heavy chats. Without this, unbounded
/// Task.detached can spawn 50+ concurrent transfers CPU spike phone overheating.
private actor TransportLimiter {
static let shared = TransportLimiter()
private let maxConcurrent = 4
private var running = 0
private var waiters: [CheckedContinuation<Void, Never>] = []
func acquire() async {
if running < maxConcurrent {
running += 1
return
}
await withCheckedContinuation { continuation in
waiters.append(continuation)
}
}
func release() {
if let next = waiters.first {
waiters.removeFirst()
next.resume()
} else {
running -= 1
}
}
}
// MARK: - TransportManager
/// Manages file upload/download to the transport server.
@@ -81,6 +112,9 @@ final class TransportManager: @unchecked Sendable {
private static let maxUploadRetries = 3
func uploadFile(id: String, content: Data) async throws -> (tag: String, server: String) {
await TransportLimiter.shared.acquire()
defer { Task { await TransportLimiter.shared.release() } }
guard let serverUrl = await MainActor.run(body: { transportServer }) else {
throw TransportError.noTransportServer
}
@@ -162,6 +196,9 @@ final class TransportManager: @unchecked Sendable {
server: String? = nil,
onProgress: (@MainActor (Double) -> Void)?
) async throws -> Data {
await TransportLimiter.shared.acquire()
defer { Task { await TransportLimiter.shared.release() } }
let serverUrl: String
if let explicit = server, !explicit.isEmpty {
serverUrl = explicit

View File

@@ -8,9 +8,8 @@ enum InAppNotificationManager {
static func shouldSuppress(senderKey: String) -> Bool {
if senderKey.isEmpty { return true }
if MessageRepository.shared.isDialogActive(senderKey) { return true }
let mutedKeys = UserDefaults(suiteName: "group.com.rosetta.dev")?
.stringArray(forKey: "muted_chats_keys") ?? []
if mutedKeys.contains(senderKey) { return true }
// PERF: use in-memory dialog state instead of UserDefaults I/O per notification check.
if DialogRepository.shared.dialogs[senderKey]?.isMuted == true { return true }
return false
}
}

View File

@@ -425,11 +425,7 @@ final class SessionManager {
let messageId = UUID().uuidString.replacingOccurrences(of: "-", with: "").lowercased()
let timestamp = Int64(Date().timeIntervalSince1970 * 1000)
let randomPart = String((0..<8).map { _ in "abcdefghijklmnopqrstuvwxyz0123456789".randomElement()! })
// Mark group avatar attachments with "ga_" prefix so UI can distinguish
// "Shared group photo" vs "Shared profile photo" in the same group chat.
let isGroupAvatarSource = avatarSourceKey != nil && DatabaseManager.isGroupDialogKey(avatarSourceKey!)
let attachmentId = isGroupAvatarSource ? "ga_\(randomPart)" : randomPart
let attachmentId = String((0..<8).map { _ in "abcdefghijklmnopqrstuvwxyz0123456789".randomElement()! })
let isGroup = DatabaseManager.isGroupDialogKey(toPublicKey)
// Android/Desktop parity: avatar messages have empty text.
@@ -453,7 +449,9 @@ final class SessionManager {
encryptedContent = try CryptoManager.shared.encryptWithPasswordDesktopCompat(
Data("".utf8), password: groupKey
)
attachmentPassword = groupKey
// Desktop parity: Buffer.from(groupKey).toString('hex') hex of UTF-8 bytes.
// Desktop stores chacha_key = hex(groupKey) and uses it for attachment decryption.
attachmentPassword = Data(groupKey.utf8).hexString
outChachaKey = ""
outAesChachaKey = ""
} else {
@@ -1951,11 +1949,8 @@ final class SessionManager {
// Telegram parity: show in-app banner for foreground messages in non-active chats.
if !fromMe && !effectiveFromSync && isAppInForeground {
let isMuted: Bool = {
let mutedKeys = UserDefaults(suiteName: "group.com.rosetta.dev")?
.stringArray(forKey: "muted_chats_keys") ?? []
return mutedKeys.contains(opponentKey)
}()
// PERF: use in-memory dialog state instead of UserDefaults I/O per message.
let isMuted = dialog?.isMuted == true
// Desktop-active suppression: skip banner if dialog was read on another device < 30s ago.
let isDesktopActive: Bool = {
if let readDate = self.desktopActiveDialogs[opponentKey] {
@@ -2684,7 +2679,9 @@ final class SessionManager {
}
}
// Priority: fetch missing names first (generous 200ms stagger)
// Priority: fetch missing names first (generous 250ms stagger).
// PERF: increased stagger from 200250ms to reduce CPU/network overhead
// that contributes to phone overheating with many chats.
var count = 0
for key in missingName {
guard ProtocolManager.shared.connectionState == .authenticated else { break }
@@ -2692,11 +2689,11 @@ final class SessionManager {
requestUserInfoIfNeeded(opponentKey: key, privateKeyHash: privateKeyHash)
count += 1
if count > 1 {
try? await Task.sleep(for: .milliseconds(200))
try? await Task.sleep(for: .milliseconds(250))
}
}
// Then refresh online status for recently active dialogs only (300ms stagger).
// Then refresh online status for recently active dialogs only (400ms stagger).
// Sort by lastMessageTimestamp descending most recent chats first.
let recentKeys = hasName
.compactMap { key -> (String, Int64)? in
@@ -2712,7 +2709,7 @@ final class SessionManager {
requestUserInfoIfNeeded(opponentKey: key, privateKeyHash: privateKeyHash)
count += 1
if count > 1 {
try? await Task.sleep(for: .milliseconds(300))
try? await Task.sleep(for: .milliseconds(400))
}
}
Self.logger.info("Refreshed user info: \(missingName.count) missing names + \(recentKeys.count) online status = \(count) total (capped at 30)")

View File

@@ -835,9 +835,14 @@ final class ChatDetailViewController: UIViewController {
onSendAvatar: { [weak self] in
self?.sendAvatarToChat()
},
hasAvatar: AvatarRepository.shared.loadAvatar(publicKey: currentPublicKey) != nil,
hasAvatar: avatarAvailableForSending(),
onSetAvatar: { [weak self] in
self?.showAlert(title: "No Avatar", message: "Set a profile photo in Settings to share it with contacts.")
guard let self else { return }
if self.route.isGroup {
self.showAlert(title: "No Group Avatar", message: "Set a group photo first to share it with members.")
} else {
self.showAlert(title: "No Avatar", message: "Set a profile photo in Settings to share it with contacts.")
}
}
)
let hosting = UIHostingController(rootView: panel)
@@ -1318,59 +1323,17 @@ final class ChatDetailViewController: UIViewController {
sendCurrentMessage()
}
/// Desktop parity: in groups only admin can send, and only group avatar.
/// In 1:1 chats sends personal avatar.
private func sendAvatarToChat() {
if route.isGroup {
let cached = GroupRepository.shared.cachedMembers(
account: currentPublicKey,
groupDialogKey: route.publicKey
)
if cached?.adminKey == currentPublicKey {
showAvatarActionSheet()
return
}
}
performSendAvatar()
}
private func performSendAvatar() {
Task { @MainActor in
do {
try await SessionManager.shared.sendAvatar(
toPublicKey: route.publicKey,
opponentTitle: route.title,
opponentUsername: route.username
)
} catch {
showAlert(title: "Send Error", message: error.localizedDescription)
}
}
}
private func showAvatarActionSheet() {
let hasGroupAvatar = AvatarRepository.shared.loadAvatar(publicKey: route.publicKey) != nil
let sheet = UIAlertController(title: nil, message: nil, preferredStyle: .actionSheet)
sheet.addAction(UIAlertAction(title: "Send My Avatar", style: .default) { [weak self] _ in
self?.performSendAvatar()
})
if hasGroupAvatar {
sheet.addAction(UIAlertAction(title: "Share Group Avatar", style: .default) { [weak self] _ in
self?.shareGroupAvatar()
})
}
sheet.addAction(UIAlertAction(title: "Cancel", style: .cancel))
DispatchQueue.main.async { [weak self] in
self?.present(sheet, animated: true)
}
}
private func shareGroupAvatar() {
let sourceKey = route.isGroup ? route.publicKey : nil
Task { @MainActor in
do {
try await SessionManager.shared.sendAvatar(
toPublicKey: route.publicKey,
opponentTitle: route.title,
opponentUsername: route.username,
avatarSourceKey: route.publicKey
avatarSourceKey: sourceKey
)
} catch {
showAlert(title: "Send Error", message: error.localizedDescription)
@@ -1378,6 +1341,20 @@ final class ChatDetailViewController: UIViewController {
}
}
/// Returns true if avatar tab should show "Send" (not "Set").
/// Groups: only admin + group has avatar. Personal: user has avatar.
private func avatarAvailableForSending() -> Bool {
if route.isGroup {
let cached = GroupRepository.shared.cachedMembers(
account: currentPublicKey,
groupDialogKey: route.publicKey
)
guard cached?.adminKey == currentPublicKey else { return false }
return AvatarRepository.shared.loadAvatar(publicKey: route.publicKey) != nil
}
return AvatarRepository.shared.loadAvatar(publicKey: currentPublicKey) != nil
}
private func handleComposerUserTyping() {
guard !route.isSavedMessages, !route.isSystemAccount else { return }
SessionManager.shared.sendTypingIndicator(toPublicKey: route.publicKey)

View File

@@ -66,7 +66,7 @@ struct MessageAvatarView: View {
.font(.system(size: 12))
.foregroundStyle(RosettaColors.error)
} else if avatarImage != nil {
Text(attachment.id.hasPrefix("ga_")
Text(DatabaseManager.isGroupDialogKey(message.toPublicKey)
? "Shared group photo."
: "Shared profile photo.")
.font(.system(size: 12))
@@ -264,8 +264,8 @@ struct MessageAvatarView: View {
if let downloadedImage {
avatarImage = downloadedImage
AttachmentCache.shared.saveImage(downloadedImage, forAttachmentId: attachment.id)
// Desktop parity: in group chats save as group avatar,
// in personal chats save as sender's avatar
// Desktop parity: in groups, avatar saves as group avatar.
// In personal chats, saves as sender's avatar.
let avatarKey = DatabaseManager.isGroupDialogKey(message.toPublicKey)
? message.toPublicKey
: message.fromPublicKey

View File

@@ -1160,12 +1160,12 @@ final class NativeMessageCell: UICollectionViewCell {
avatarImageView.image = cached
avatarImageView.isHidden = false
fileIconView.isHidden = true
fileSizeLabel.text = avatarAtt.id.hasPrefix("ga_") ? "Shared group photo" : "Shared profile photo"
fileSizeLabel.text = DatabaseManager.isGroupDialogKey(message.toPublicKey) ? "Shared group photo" : "Shared profile photo"
fileSizeLabel.textColor = isOutgoing ? UIColor.white.withAlphaComponent(0.5) : .secondaryLabel
} else {
if isOutgoing {
// Own avatar already uploaded, just loading from disk
fileSizeLabel.text = avatarAtt.id.hasPrefix("ga_") ? "Shared group photo" : "Shared profile photo"
fileSizeLabel.text = DatabaseManager.isGroupDialogKey(message.toPublicKey) ? "Shared group photo" : "Shared profile photo"
} else {
// Incoming avatar needs download on tap (Android parity)
fileSizeLabel.text = "Tap to download"
@@ -1208,7 +1208,8 @@ final class NativeMessageCell: UICollectionViewCell {
self.avatarImageView.image = diskImage
self.avatarImageView.isHidden = false
self.fileIconView.isHidden = true
self.fileSizeLabel.text = attId.hasPrefix("ga_") ? "Shared group photo" : "Shared profile photo"
let isGroupMsg = DatabaseManager.isGroupDialogKey(self.message?.toPublicKey ?? "")
self.fileSizeLabel.text = isGroupMsg ? "Shared group photo" : "Shared profile photo"
}
}
// CDN download is triggered by user tap via .triggerAttachmentDownload
@@ -2275,6 +2276,7 @@ final class NativeMessageCell: UICollectionViewCell {
fileSizeLabel.text = "Downloading..."
let messageId = message.id
// Desktop parity: group avatar saves to group dialog key, not sender key
// Desktop parity: in groups, avatar saves as group avatar.
let avatarTargetKey = DatabaseManager.isGroupDialogKey(message.toPublicKey)
? message.toPublicKey : message.fromPublicKey
let server = avatarAtt.transportServer
@@ -2290,7 +2292,8 @@ final class NativeMessageCell: UICollectionViewCell {
self.avatarImageView.image = downloaded
self.avatarImageView.isHidden = false
self.fileIconView.isHidden = true
self.fileSizeLabel.text = id.hasPrefix("ga_") ? "Shared group photo" : "Shared profile photo"
let isGroupMsg = DatabaseManager.isGroupDialogKey(self.message?.toPublicKey ?? "")
self.fileSizeLabel.text = isGroupMsg ? "Shared group photo" : "Shared profile photo"
// Trigger refresh of sender avatar circles in visible cells
NotificationCenter.default.post(
name: Notification.Name("avatarDidUpdate"), object: nil
@@ -3193,6 +3196,10 @@ final class NativeMessageCell: UICollectionViewCell {
let server = attachment.transportServer
photoDownloadTasks[attachmentId] = Task { [weak self] in
// PERF: concurrent CDN downloads are capped by TransportLimiter (max 4) inside
// TransportManager.downloadFile(). Do NOT use ImageLoadLimiter here it holds
// a slot (max 3) for the entire download duration (seconds), starving fast
// disk loads in startPhotoLoadTask() and causing cached photos to lag.
do {
let encryptedData = try await TransportManager.shared.downloadFile(tag: tag, server: server)
let encryptedString = String(decoding: encryptedData, as: UTF8.self)

View File

@@ -1743,7 +1743,7 @@ final class NativeMessageListController: UIViewController {
let composerBottom = max(currentKeyboardHeight, view.safeAreaInsets.bottom)
let composerHeight = lastComposerHeight
let newInsetTop = composerHeight + composerBottom + UIConstants.messageToComposerGap
let topInset = view.safeAreaInsets.top + 6
let topInset = view.safeAreaInsets.top + topStickyOffset + 6
let oldInsetTop = collectionView.contentInset.top
let delta = newInsetTop - oldInsetTop
@@ -1889,7 +1889,7 @@ final class NativeMessageListController: UIViewController {
// Explicit composerHeightConstraint prevents the 372pt inflation bug.
let composerH = lastComposerHeight
let newInsetTop = composerH + composerBottom + UIConstants.messageToComposerGap
let topInset = view.safeAreaInsets.top + 6
let topInset = view.safeAreaInsets.top + topStickyOffset + 6
let oldInsetTop = collectionView.contentInset.top
let delta = newInsetTop - oldInsetTop

View File

@@ -2,9 +2,10 @@ import UIKit
import SwiftUI
import Combine
/// Pure UIKit peer profile screen. Phase 2: data + interactivity.
/// Pure UIKit peer profile screen. Phase 3: expand/collapse header + haptic.
final class OpponentProfileViewController: UIViewController, UIGestureRecognizerDelegate,
ProfileTabBarDelegate, UICollectionViewDataSource, UICollectionViewDelegate {
ProfileTabBarDelegate, UICollectionViewDataSource, UICollectionViewDelegate,
UIScrollViewDelegate {
// MARK: - Properties
@@ -50,6 +51,15 @@ final class OpponentProfileViewController: UIViewController, UIGestureRecognizer
private let emptyIcon = UIImageView()
private let emptyLabel = UILabel()
// Expand/collapse header
private let headerImageView = UIImageView()
private let scrimView = UIView()
private let scrimGradient = CAGradientLayer()
private var isLargeHeader = false
private var canExpand = false
private var profileImage: UIImage?
private let expandFeedback = UIImpactFeedbackGenerator(style: .light)
// MARK: - Constants
private let hPad: CGFloat = 16
@@ -102,7 +112,9 @@ final class OpponentProfileViewController: UIViewController, UIGestureRecognizer
setupMediaGrid()
setupListContainers()
setupEmptyState()
setupHeaderImage()
scrollView.delegate = self
backButton.addTarget(self, action: #selector(backTapped), for: .touchUpInside)
view.addSubview(backButton)
@@ -139,7 +151,13 @@ final class OpponentProfileViewController: UIViewController, UIGestureRecognizer
viewModel.$isOnline
.receive(on: DispatchQueue.main)
.sink { [weak self] online in
self?.subtitleLabel.text = online ? "online" : "offline"
guard let self else { return }
self.subtitleLabel.text = online ? "online" : "offline"
if self.isLargeHeader {
self.subtitleLabel.textColor = online ? self.onlineColor : .white.withAlphaComponent(0.7)
} else {
self.subtitleLabel.textColor = online ? self.onlineColor : self.textSecondary
}
}
.store(in: &cancellables)
@@ -294,6 +312,41 @@ final class OpponentProfileViewController: UIViewController, UIGestureRecognizer
contentView.addSubview(emptyLabel)
}
private func setupHeaderImage() {
headerImageView.contentMode = .scaleAspectFill
headerImageView.clipsToBounds = true
headerImageView.alpha = 0
contentView.insertSubview(headerImageView, at: 0)
scrimView.isUserInteractionEnabled = false
scrimView.alpha = 0
contentView.insertSubview(scrimView, aboveSubview: headerImageView)
// Bottom gradient for name readability
scrimGradient.colors = [
UIColor.clear.cgColor,
UIColor.black.withAlphaComponent(0.05).cgColor,
UIColor.black.withAlphaComponent(0.6).cgColor
]
scrimGradient.locations = [0.4, 0.65, 1.0]
scrimView.layer.addSublayer(scrimGradient)
// Top gradient for status bar readability
let topGradient = CAGradientLayer()
topGradient.colors = [
UIColor.black.withAlphaComponent(0.4).cgColor,
UIColor.clear.cgColor
]
topGradient.locations = [0.0, 0.3]
topGradient.accessibilityHint = "topScrim"
scrimView.layer.addSublayer(topGradient)
profileImage = AvatarRepository.shared.loadAvatar(publicKey: route.publicKey)
canExpand = profileImage != nil
if canExpand { headerImageView.image = profileImage }
expandFeedback.prepare()
}
// MARK: - Layout
private func layoutAll() {
@@ -301,25 +354,64 @@ final class OpponentProfileViewController: UIViewController, UIGestureRecognizer
let safeTop = view.safeAreaInsets.top
scrollView.frame = view.bounds
contentView.frame.size.width = w
backButton.frame = CGRect(x: 7, y: safeTop - 10, width: 44, height: 44)
var y: CGFloat = safeTop - 8
// Back button same position as ChatDetailViewController (centered in 44pt header bar)
let headerCenterY = safeTop + 22
backButton.frame = CGRect(x: 8, y: headerCenterY - 22, width: 44, height: 44)
// Avatar
avatarContainer.frame = CGRect(x: (w - avatarSize) / 2, y: y, width: avatarSize, height: avatarSize)
avatarHosting?.view.frame = avatarContainer.bounds
y += avatarSize + 12
var y: CGFloat
let expandedH = w // square photo, like Telegram
// Name
let nameH = nameLabel.sizeThatFits(CGSize(width: w - 60, height: 30)).height
nameLabel.frame = CGRect(x: 30, y: y, width: w - 60, height: nameH)
y += nameH + 1
if isLargeHeader {
// Expanded: full-width photo from y=0 (behind Dynamic Island)
headerImageView.frame = CGRect(x: 0, y: 0, width: w, height: expandedH)
headerImageView.alpha = 1
scrimView.frame = headerImageView.frame
scrimView.alpha = 1
CATransaction.begin()
CATransaction.setDisableActions(true)
scrimGradient.frame = scrimView.bounds
CATransaction.commit()
// Subtitle
subtitleLabel.frame = CGRect(x: 30, y: y, width: w - 60, height: 20)
y += 20 + 20
avatarContainer.alpha = 0
// Buttons
// Name + subtitle overlaid at bottom of photo (inside gradient)
let nameH = nameLabel.sizeThatFits(CGSize(width: w - 40, height: 30)).height
nameLabel.frame = CGRect(x: 16, y: expandedH - 50, width: w - 32, height: nameH)
nameLabel.textAlignment = .left
nameLabel.textColor = .white
nameLabel.font = .systemFont(ofSize: 24, weight: .bold)
subtitleLabel.frame = CGRect(x: 16, y: expandedH - 26, width: w - 32, height: 20)
subtitleLabel.textAlignment = .left
subtitleLabel.textColor = viewModel.isOnline ? onlineColor : .white.withAlphaComponent(0.7)
y = expandedH + 16
} else {
// Collapsed: small avatar
headerImageView.alpha = 0
scrimView.alpha = 0
avatarContainer.alpha = 1
y = safeTop + 8
avatarContainer.frame = CGRect(x: (w - avatarSize) / 2, y: y, width: avatarSize, height: avatarSize)
avatarHosting?.view.frame = avatarContainer.bounds
y += avatarSize + 12
let nameH = nameLabel.sizeThatFits(CGSize(width: w - 60, height: 30)).height
nameLabel.frame = CGRect(x: 30, y: y, width: w - 60, height: nameH)
nameLabel.textAlignment = .center
nameLabel.textColor = textPrimary
nameLabel.font = .systemFont(ofSize: 17, weight: .semibold)
y += nameH + 1
subtitleLabel.frame = CGRect(x: 30, y: y, width: w - 60, height: 20)
subtitleLabel.textAlignment = .center
subtitleLabel.textColor = viewModel.isOnline ? onlineColor : textSecondary
y += 20 + 20
}
// Buttons (both states)
let btnCount = CGFloat(actionButtonViews.count)
let btnW = (w - hPad * 2 - buttonSpacing * (btnCount - 1)) / btnCount
for (i, (c, iv, lbl)) in actionButtonViews.enumerated() {
@@ -345,6 +437,41 @@ final class OpponentProfileViewController: UIViewController, UIGestureRecognizer
scrollView.contentSize = CGSize(width: w, height: y)
}
// MARK: - Expand / Collapse
private func expandHeader() {
guard canExpand, !isLargeHeader else { return }
isLargeHeader = true
expandFeedback.impactOccurred()
scrollView.setContentOffset(.zero, animated: false)
UIView.animate(withDuration: 0.35, delay: 0, usingSpringWithDamping: 0.86,
initialSpringVelocity: 0.5, options: []) {
self.layoutAll()
}
}
private func collapseHeader() {
guard isLargeHeader else { return }
isLargeHeader = false
scrollView.setContentOffset(.zero, animated: false)
UIView.animate(withDuration: 0.35, delay: 0, usingSpringWithDamping: 0.86,
initialSpringVelocity: 0.5, options: []) {
self.layoutAll()
}
}
// MARK: - UIScrollViewDelegate
func scrollViewDidScroll(_ scrollView: UIScrollView) {
let offsetY = scrollView.contentOffset.y
if offsetY <= -32 && scrollView.isDragging && scrollView.isTracking
&& canExpand && !isLargeHeader {
expandHeader()
} else if offsetY >= 1 && isLargeHeader {
collapseHeader()
}
}
private func layoutInfoCard(width: CGFloat) -> CGFloat {
infoCard.subviews.forEach { $0.removeFromSuperview() }
let dialog = DialogRepository.shared.dialogs[route.publicKey]

View File

@@ -159,7 +159,7 @@ final class VoiceRecordingPanel: UIView {
// Red dot: 10×10, centered vertically with timer
let timerSize = timerLabel.sizeThatFits(CGSize(width: 100, height: h))
let timerY = floor((h - timerSize.height) / 2) + 1 // +1pt baseline offset (Telegram)
let timerY = floor((h - timerSize.height) / 2)
redDot.frame = CGRect(
x: dotX,
@@ -226,7 +226,7 @@ final class VoiceRecordingPanel: UIView {
return
}
let timerSize = timerLabel.sizeThatFits(CGSize(width: .greatestFiniteMagnitude, height: h))
let timerY = floor((h - timerSize.height) / 2) + 1
let timerY = floor((h - timerSize.height) / 2)
let timerWidth = max(timerMinWidth, ceil(timerSize.width + 4))
CATransaction.begin()
CATransaction.setDisableActions(true)

View File

@@ -572,7 +572,22 @@ final class ChatListCell: UICollectionViewCell {
// MARK: - Delivery Status
/// Cached checkmark images (rendered once from SwiftUI Shapes).
private static let singleCheckImage: UIImage = StatusIconRenderer.renderShape(SingleCheckmarkShape(), size: CGSize(width: 14, height: 10.3))
private static let singleCheckImage: UIImage = {
// Match double checkmark canvas (17×9.3) so both scale
// identically in the 16×16 statusImageView (scaleAspectFit).
let canvas = CGSize(width: 17, height: 9.3)
let checkW = 16.4 * (9.3 / 12.0)
let renderer = UIGraphicsImageRenderer(size: canvas)
return renderer.image { ctx in
ctx.cgContext.translateBy(x: canvas.width - checkW, y: 0)
let path = SingleCheckmarkShape().path(
in: CGRect(x: 0, y: 0, width: checkW, height: canvas.height)
)
ctx.cgContext.addPath(path.cgPath)
ctx.cgContext.setFillColor(UIColor.black.cgColor)
ctx.cgContext.fillPath()
}
}()
private static let doubleCheckImage: UIImage = StatusIconRenderer.renderShape(DoubleCheckmarkShape(), size: CGSize(width: 17, height: 9.3))
/// Cached error indicator (Telegram: red circle with white exclamation).