Голосовые сообщения - анимация кнопки микрофона + панель записи с таймером

This commit is contained in:
2026-04-11 01:46:09 +05:00
parent 49fc49ffda
commit 667ba06967
20 changed files with 3157 additions and 109 deletions

View File

@@ -16,8 +16,10 @@ final class DatabaseManager {
"v5_full_schema_superset_parity",
"v6_bidirectional_alias_sync",
"v7_sync_cursor_reconcile_and_perf_indexes",
"v8_last_message_sender_key",
]
nonisolated static let migrationV7SyncCursorReconcile = "v7_sync_cursor_reconcile_and_perf_indexes"
nonisolated static let migrationV8LastMessageSenderKey = "v8_last_message_sender_key"
private var dbPool: DatabasePool?
private var currentAccount: String = ""
@@ -783,6 +785,15 @@ final class DatabaseManager {
)
}
// MARK: v8 last_message_sender_key (group chat preview: "Alice: hey")
migrator.registerMigration("v8_last_message_sender_key") { db in
let cols = Set(try db.columns(in: "dialogs").map(\.name))
if !cols.contains("last_message_sender_key") {
try db.execute(sql: "ALTER TABLE dialogs ADD COLUMN last_message_sender_key TEXT NOT NULL DEFAULT ''")
}
}
try migrator.migrate(pool)
dbPool = pool

View File

@@ -23,6 +23,7 @@ struct DialogRecord: Codable, FetchableRecord, MutablePersistableRecord, Sendabl
var lastMessageFromMe: Int
var lastMessageDelivered: Int
var lastMessageRead: Int
var lastMessageSenderKey: String
// MARK: - Column mapping
@@ -43,6 +44,7 @@ struct DialogRecord: Codable, FetchableRecord, MutablePersistableRecord, Sendabl
case lastMessageFromMe = "last_message_from_me"
case lastMessageDelivered = "last_message_delivered"
case lastMessageRead = "last_message_read"
case lastMessageSenderKey = "last_message_sender_key"
}
enum CodingKeys: String, CodingKey {
@@ -62,6 +64,7 @@ struct DialogRecord: Codable, FetchableRecord, MutablePersistableRecord, Sendabl
case lastMessageFromMe = "last_message_from_me"
case lastMessageDelivered = "last_message_delivered"
case lastMessageRead = "last_message_read"
case lastMessageSenderKey = "last_message_sender_key"
}
// MARK: - Auto-increment
@@ -73,7 +76,7 @@ struct DialogRecord: Codable, FetchableRecord, MutablePersistableRecord, Sendabl
// MARK: - Conversions
func toDialog() -> Dialog {
Dialog(
var d = Dialog(
id: opponentKey,
account: account,
opponentKey: opponentKey,
@@ -92,6 +95,8 @@ struct DialogRecord: Codable, FetchableRecord, MutablePersistableRecord, Sendabl
lastMessageDelivered: DeliveryStatus(rawValue: lastMessageDelivered) ?? .waiting,
lastMessageRead: lastMessageRead != 0
)
d.lastMessageSenderKey = lastMessageSenderKey
return d
}
static func from(_ dialog: Dialog) -> DialogRecord {
@@ -112,7 +117,8 @@ struct DialogRecord: Codable, FetchableRecord, MutablePersistableRecord, Sendabl
isMuted: dialog.isMuted ? 1 : 0,
lastMessageFromMe: dialog.lastMessageFromMe ? 1 : 0,
lastMessageDelivered: dialog.lastMessageDelivered.rawValue,
lastMessageRead: dialog.lastMessageRead ? 1 : 0
lastMessageRead: dialog.lastMessageRead ? 1 : 0,
lastMessageSenderKey: dialog.lastMessageSenderKey
)
}
}

View File

@@ -56,6 +56,9 @@ struct Dialog: Identifiable, Codable, Equatable {
/// Desktop parity: true when an unread group message mentions the current user.
var hasMention: Bool = false
/// Sender public key of the last message (for "Alice: hey" group chat preview).
var lastMessageSenderKey: String = ""
// MARK: - Computed
var isSavedMessages: Bool { opponentKey == account }

View File

@@ -166,6 +166,7 @@ final class DialogRepository {
case .avatar: lastMessageText = "Avatar"
case .messages: lastMessageText = "Forwarded message"
case .call: lastMessageText = "Call"
@unknown default: lastMessageText = "Attachment"
}
} else if textIsEmpty {
lastMessageText = ""
@@ -196,6 +197,8 @@ final class DialogRepository {
dialog.lastMessageDelivered = lastFromMe ? lastMsg.deliveryStatus : .delivered
// Android parity: separate read flag from last outgoing message's is_read column.
dialog.lastMessageRead = lastFromMe ? lastMsg.isRead : false
// Group sender key for "Alice: hey" chat list preview
dialog.lastMessageSenderKey = lastMsg.fromPublicKey
dialogs[opponentKey] = dialog
_sortedKeysCache = nil
@@ -382,8 +385,8 @@ final class DialogRepository {
INSERT INTO dialogs (account, opponent_key, opponent_title, opponent_username,
last_message, last_message_timestamp, unread_count, is_online, last_seen,
verified, i_have_sent, is_pinned, is_muted, last_message_from_me,
last_message_delivered, last_message_read)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
last_message_delivered, last_message_read, last_message_sender_key)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(account, opponent_key) DO UPDATE SET
opponent_title = excluded.opponent_title,
opponent_username = excluded.opponent_username,
@@ -398,7 +401,8 @@ final class DialogRepository {
is_muted = excluded.is_muted,
last_message_from_me = excluded.last_message_from_me,
last_message_delivered = excluded.last_message_delivered,
last_message_read = excluded.last_message_read
last_message_read = excluded.last_message_read,
last_message_sender_key = excluded.last_message_sender_key
""",
arguments: [
dialog.account, dialog.opponentKey, dialog.opponentTitle, dialog.opponentUsername,
@@ -406,7 +410,7 @@ final class DialogRepository {
dialog.isOnline ? 1 : 0, dialog.lastSeen, dialog.verified,
dialog.iHaveSent ? 1 : 0, dialog.isPinned ? 1 : 0, dialog.isMuted ? 1 : 0,
dialog.lastMessageFromMe ? 1 : 0, dialog.lastMessageDelivered.rawValue,
dialog.lastMessageRead ? 1 : 0
dialog.lastMessageRead ? 1 : 0, dialog.lastMessageSenderKey
]
)
}

View File

@@ -88,6 +88,7 @@ enum AttachmentType: Int, Codable, Sendable {
case file = 2
case avatar = 3
case call = 4
case voice = 5
/// Android parity: `fromInt() ?: UNKNOWN`. Fallback to `.image` for unknown values
/// so a single unknown type doesn't crash the entire [MessageAttachment] array decode.

View File

@@ -0,0 +1,187 @@
import AVFAudio
import Foundation
import QuartzCore
import os
// MARK: - Recording State
enum AudioRecordingState: Sendable {
case idle
case recording(duration: TimeInterval, micLevel: Float)
case finished(url: URL, duration: TimeInterval, waveform: [Float])
}
// MARK: - AudioRecorder
/// Records voice messages using AVAudioRecorder.
/// Exposes mic level for animation and collects waveform samples.
@MainActor
final class AudioRecorder: NSObject {
private let logger = Logger(subsystem: "com.rosetta.messenger", category: "AudioRecorder")
private(set) var state: AudioRecordingState = .idle
private(set) var micLevel: Float = 0
var onLevelUpdate: ((TimeInterval, Float) -> Void)?
var onFinished: ((URL, TimeInterval, [Float]) -> Void)?
private var recorder: AVAudioRecorder?
private var displayLink: CADisplayLink?
private var waveformSamples: [Float] = []
private var lastSampleTime: TimeInterval = 0
private let sampleInterval: TimeInterval = 1.0 / 30.0
private var fileURL: URL {
let tmp = FileManager.default.temporaryDirectory
return tmp.appendingPathComponent("rosetta_voice_\(UUID().uuidString).m4a")
}
@discardableResult
func startRecording() -> Bool {
guard case .idle = state else {
logger.warning("[AudioRecorder] startRecording called while not idle")
return false
}
let session = AVAudioSession.sharedInstance()
do {
try session.setCategory(.playAndRecord, mode: .default, options: [.defaultToSpeaker])
try session.setActive(true)
} catch {
logger.error("[AudioRecorder] Audio session failed: \(error.localizedDescription)")
return false
}
let url = fileURL
let settings: [String: Any] = [
AVFormatIDKey: Int(kAudioFormatMPEG4AAC),
AVSampleRateKey: 48000,
AVNumberOfChannelsKey: 1,
AVEncoderAudioQualityKey: AVAudioQuality.high.rawValue,
AVEncoderBitRateKey: 64000
]
do {
let rec = try AVAudioRecorder(url: url, settings: settings)
rec.isMeteringEnabled = true
rec.delegate = self
rec.prepareToRecord()
guard rec.record() else {
logger.error("[AudioRecorder] record() returned false")
return false
}
recorder = rec
waveformSamples = []
lastSampleTime = 0
micLevel = 0
state = .recording(duration: 0, micLevel: 0)
startDisplayLink()
logger.info("[AudioRecorder] Started: \(url.lastPathComponent)")
return true
} catch {
logger.error("[AudioRecorder] Init failed: \(error.localizedDescription)")
return false
}
}
func stopRecording() {
guard let rec = recorder, rec.isRecording else { return }
let duration = rec.currentTime
rec.stop()
stopDisplayLink()
let url = rec.url
state = .finished(url: url, duration: duration, waveform: waveformSamples)
onFinished?(url, duration, waveformSamples)
logger.info("[AudioRecorder] Stopped: \(String(format: "%.1f", duration))s")
recorder = nil
}
func cancelRecording() {
guard let rec = recorder else { reset(); return }
let url = rec.url
rec.stop()
stopDisplayLink()
try? FileManager.default.removeItem(at: url)
logger.info("[AudioRecorder] Cancelled")
recorder = nil
reset()
}
func reset() {
stopDisplayLink()
recorder = nil
micLevel = 0
waveformSamples = []
state = .idle
}
func recordedData() -> Data? {
guard case .finished(let url, _, _) = state else { return nil }
return try? Data(contentsOf: url)
}
// MARK: - Display Link
private func startDisplayLink() {
let link = CADisplayLink(target: self, selector: #selector(displayLinkTick))
link.add(to: .main, forMode: .common)
displayLink = link
}
private func stopDisplayLink() {
displayLink?.invalidate()
displayLink = nil
}
@objc private func displayLinkTick() {
guard let rec = recorder, rec.isRecording else { return }
rec.updateMeters()
let power = rec.averagePower(forChannel: 0)
let normalized = Self.normalizeMicLevel(power)
micLevel = normalized
let duration = rec.currentTime
state = .recording(duration: duration, micLevel: normalized)
if duration - lastSampleTime >= sampleInterval {
waveformSamples.append(normalized)
lastSampleTime = duration
}
onLevelUpdate?(duration, normalized)
}
static func normalizeMicLevel(_ power: Float) -> Float {
let minDb: Float = -60
let clamped = max(minDb, min(power, 0))
let normalized = (clamped - minDb) / (-minDb)
return normalized * normalized
}
static func requestMicrophonePermission() async -> Bool {
let status = AVAudioSession.sharedInstance().recordPermission
switch status {
case .granted: return true
case .undetermined:
return await withCheckedContinuation { continuation in
AVAudioSession.sharedInstance().requestRecordPermission { granted in
continuation.resume(returning: granted)
}
}
case .denied: return false
@unknown default: return false
}
}
}
extension AudioRecorder: AVAudioRecorderDelegate {
nonisolated func audioRecorderDidFinishRecording(_ recorder: AVAudioRecorder, successfully flag: Bool) {
Task { @MainActor in
if !flag { logger.warning("[AudioRecorder] Finished unsuccessfully"); reset() }
}
}
nonisolated func audioRecorderEncodeErrorDidOccur(_ recorder: AVAudioRecorder, error: Error?) {
Task { @MainActor in
logger.error("[AudioRecorder] Encode error: \(error?.localizedDescription ?? "unknown")")
cancelRecording()
}
}
}

View File

@@ -48,10 +48,10 @@ enum RosettaColors {
static let backgroundSecondary = Color(hex: 0xF2F2F7) // iOS system grouped bg
static let surface = Color(hex: 0xF5F5F5)
static let text = Color.black
static let textSecondary = Color(hex: 0x3C3C43).opacity(0.6) // Figma subtitle gray
static let textSecondary = Color(hex: 0x8E8E93) // Telegram: dateTextColor/messageTextColor
static let textTertiary = Color(hex: 0x3C3C43).opacity(0.3) // Figma hint gray
static let border = Color(hex: 0xE0E0E0)
static let divider = Color(hex: 0xEEEEEE)
static let divider = Color(hex: 0xC8C7CC) // Telegram: itemSeparatorColor
static let messageBubble = Color(hex: 0xF5F5F5)
static let messageBubbleOwn = Color(hex: 0xDCF8C6)
static let inputBackground = Color(hex: 0xF2F3F5)
@@ -66,10 +66,10 @@ enum RosettaColors {
static let pinnedSectionBackground = Color(hex: 0x1C1C1D)
static let surface = Color(hex: 0x242424)
static let text = Color.white
static let textSecondary = Color(hex: 0x8E8E93)
static let textSecondary = Color(hex: 0x8D8E93) // Telegram: dateTextColor/messageTextColor
static let textTertiary = Color(hex: 0x666666)
static let border = Color(hex: 0x2E2E2E)
static let divider = Color(hex: 0x333333)
static let divider = Color(UIColor(red: 0x54/255, green: 0x54/255, blue: 0x58/255, alpha: 0.55)) // Telegram: 0x545458 @ 55%
static let messageBubble = Color(hex: 0x2A2A2A)
static let messageBubbleOwn = Color(hex: 0x263341)
static let inputBackground = Color(hex: 0x2A2A2A)
@@ -98,9 +98,14 @@ enum RosettaColors {
static let messageBubbleOwn = RosettaColors.adaptive(light: RosettaColors.Light.messageBubbleOwn, dark: RosettaColors.Dark.messageBubbleOwn)
static let inputBackground = RosettaColors.adaptive(light: RosettaColors.Light.inputBackground, dark: RosettaColors.Dark.inputBackground)
static let pinnedSectionBackground = RosettaColors.adaptive(
light: Color(hex: 0xF2F2F7),
light: Color(hex: 0xF7F7F7), // Telegram: pinnedItemBackgroundColor
dark: RosettaColors.Dark.pinnedSectionBackground
)
/// Muted badge background (Telegram: unreadBadgeInactiveBackgroundColor)
static let badgeInactive = RosettaColors.adaptive(
light: Color(hex: 0xB6B6BB),
dark: Color(hex: 0x666666)
)
static let searchBarFill = RosettaColors.adaptive(
light: Color.black.opacity(0.08),
dark: Color.white.opacity(0.08)

View File

@@ -0,0 +1,398 @@
import QuartzCore
import UIKit
// MARK: - VoiceBlobView
/// Three-layer animated blob visualization for voice recording.
/// Ported from Telegram-iOS `AudioBlob/Sources/BlobView.swift`.
///
/// Architecture:
/// - Small blob (innermost): circle, solid fill, subtle scale pulsing
/// - Medium blob: organic shape morphing, 0.3 alpha
/// - Big blob: organic shape morphing, 0.15 alpha
///
/// Audio level drives both blob scale and morph speed.
final class VoiceBlobView: UIView {
typealias BlobRange = (min: CGFloat, max: CGFloat)
private let smallBlob: BlobLayer
private let mediumBlob: BlobLayer
private let bigBlob: BlobLayer
private let maxLevel: CGFloat
private var displayLink: CADisplayLink?
private var audioLevel: CGFloat = 0
private(set) var presentationAudioLevel: CGFloat = 0
private(set) var isAnimating = false
// MARK: - Init
init(
frame: CGRect = .zero,
maxLevel: CGFloat = 1.0,
smallBlobRange: BlobRange = (min: 0.45, max: 0.55),
mediumBlobRange: BlobRange = (min: 0.52, max: 0.87),
bigBlobRange: BlobRange = (min: 0.57, max: 1.0)
) {
self.maxLevel = maxLevel
self.smallBlob = BlobLayer(
pointsCount: 8,
minRandomness: 0.1, maxRandomness: 0.5,
minSpeed: 0.2, maxSpeed: 0.6,
minScale: smallBlobRange.min, maxScale: smallBlobRange.max,
isCircle: true
)
self.mediumBlob = BlobLayer(
pointsCount: 8,
minRandomness: 1.0, maxRandomness: 1.0,
minSpeed: 0.9, maxSpeed: 4.0,
minScale: mediumBlobRange.min, maxScale: mediumBlobRange.max,
isCircle: false
)
self.bigBlob = BlobLayer(
pointsCount: 8,
minRandomness: 1.0, maxRandomness: 1.0,
minSpeed: 0.9, maxSpeed: 4.0,
minScale: bigBlobRange.min, maxScale: bigBlobRange.max,
isCircle: false
)
super.init(frame: frame)
layer.addSublayer(bigBlob.shapeLayer)
layer.addSublayer(mediumBlob.shapeLayer)
layer.addSublayer(smallBlob.shapeLayer)
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError() }
deinit {
displayLink?.invalidate()
}
// MARK: - Public API
func setColor(_ color: UIColor) {
smallBlob.setColor(color)
mediumBlob.setColor(color.withAlphaComponent(0.3))
bigBlob.setColor(color.withAlphaComponent(0.15))
}
func updateLevel(_ level: CGFloat, immediately: Bool = false) {
let normalized = min(1, max(level / maxLevel, 0))
smallBlob.updateSpeedLevel(to: normalized)
mediumBlob.updateSpeedLevel(to: normalized)
bigBlob.updateSpeedLevel(to: normalized)
audioLevel = normalized
if immediately {
presentationAudioLevel = normalized
}
}
func startAnimating(immediately: Bool = false) {
guard !isAnimating else { return }
isAnimating = true
if !immediately {
animateScale(of: mediumBlob.shapeLayer, from: 0.75, to: 1.0, duration: 0.35)
animateScale(of: bigBlob.shapeLayer, from: 0.75, to: 1.0, duration: 0.35)
}
updateBlobsState()
startDisplayLink()
}
func stopAnimating(duration: Double = 0.15) {
guard isAnimating else { return }
isAnimating = false
animateScale(of: mediumBlob.shapeLayer, from: 1.0, to: 0.75, duration: duration)
animateScale(of: bigBlob.shapeLayer, from: 1.0, to: 0.75, duration: duration)
updateBlobsState()
stopDisplayLink()
}
// MARK: - Layout
override func layoutSubviews() {
super.layoutSubviews()
let size = bounds.size
smallBlob.updateBounds(size)
mediumBlob.updateBounds(size)
bigBlob.updateBounds(size)
updateBlobsState()
}
// MARK: - Display Link
private func startDisplayLink() {
guard displayLink == nil else { return }
let link = CADisplayLink(target: self, selector: #selector(displayLinkTick))
link.add(to: .main, forMode: .common)
displayLink = link
}
private func stopDisplayLink() {
displayLink?.invalidate()
displayLink = nil
}
@objc private func displayLinkTick() {
presentationAudioLevel = presentationAudioLevel * 0.9 + audioLevel * 0.1
smallBlob.level = presentationAudioLevel
mediumBlob.level = presentationAudioLevel
bigBlob.level = presentationAudioLevel
}
// MARK: - Helpers
private func updateBlobsState() {
if isAnimating, bounds.size != .zero {
smallBlob.startAnimating()
mediumBlob.startAnimating()
bigBlob.startAnimating()
} else {
smallBlob.stopAnimating()
mediumBlob.stopAnimating()
bigBlob.stopAnimating()
}
}
private func animateScale(of layer: CAShapeLayer, from: CGFloat, to: CGFloat, duration: Double) {
let anim = CABasicAnimation(keyPath: "transform.scale")
anim.fromValue = from
anim.toValue = to
anim.duration = duration
anim.fillMode = .forwards
anim.isRemovedOnCompletion = false
anim.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
layer.add(anim, forKey: "blobScale")
}
}
// MARK: - BlobLayer
/// Single animated blob shape using CAShapeLayer + Bezier morphing.
/// Ported from Telegram's `BlobNode` (AsyncDisplayKit pure CALayer).
private final class BlobLayer {
let shapeLayer = CAShapeLayer()
let pointsCount: Int
let smoothness: CGFloat
let minRandomness: CGFloat
let maxRandomness: CGFloat
let minSpeed: CGFloat
let maxSpeed: CGFloat
let minScale: CGFloat
let maxScale: CGFloat
let isCircle: Bool
var level: CGFloat = 0 {
didSet {
guard abs(level - oldValue) > 0.01 else { return }
let lv = minScale + (maxScale - minScale) * level
CATransaction.begin()
CATransaction.setDisableActions(true)
shapeLayer.transform = CATransform3DMakeScale(lv, lv, 1)
CATransaction.commit()
}
}
private var speedLevel: CGFloat = 0
private var boundsSize: CGSize = .zero
init(
pointsCount: Int,
minRandomness: CGFloat,
maxRandomness: CGFloat,
minSpeed: CGFloat,
maxSpeed: CGFloat,
minScale: CGFloat,
maxScale: CGFloat,
isCircle: Bool
) {
self.pointsCount = pointsCount
self.minRandomness = minRandomness
self.maxRandomness = maxRandomness
self.minSpeed = minSpeed
self.maxSpeed = maxSpeed
self.minScale = minScale
self.maxScale = maxScale
self.isCircle = isCircle
let angle = (CGFloat.pi * 2) / CGFloat(pointsCount)
self.smoothness = ((4.0 / 3.0) * tan(angle / 4.0)) / sin(angle / 2.0) / 2.0
shapeLayer.strokeColor = nil
shapeLayer.transform = CATransform3DMakeScale(minScale, minScale, 1)
}
func setColor(_ color: UIColor) {
shapeLayer.fillColor = color.cgColor
}
func updateSpeedLevel(to newLevel: CGFloat) {
speedLevel = max(speedLevel, newLevel)
}
func updateBounds(_ size: CGSize) {
boundsSize = size
shapeLayer.bounds = CGRect(origin: .zero, size: size)
shapeLayer.position = CGPoint(x: size.width / 2, y: size.height / 2)
if isCircle {
let hw = size.width / 2
shapeLayer.path = UIBezierPath(
roundedRect: CGRect(x: -hw, y: -hw, width: size.width, height: size.height),
cornerRadius: hw
).cgPath
}
}
func startAnimating() {
guard !isCircle else { return }
animateToNewShape()
}
func stopAnimating() {
shapeLayer.removeAnimation(forKey: "path")
}
// MARK: - Shape Animation
private func animateToNewShape() {
guard !isCircle, boundsSize != .zero else { return }
if shapeLayer.path == nil {
let points = generateBlob(for: boundsSize)
shapeLayer.path = BezierSmooth.smoothCurve(through: points, length: boundsSize.width, smoothness: smoothness).cgPath
}
let nextPoints = generateBlob(for: boundsSize)
let nextPath = BezierSmooth.smoothCurve(through: nextPoints, length: boundsSize.width, smoothness: smoothness).cgPath
let anim = CABasicAnimation(keyPath: "path")
let previous = shapeLayer.path
shapeLayer.path = nextPath
anim.fromValue = previous
anim.toValue = nextPath
anim.duration = CFTimeInterval(1.0 / (minSpeed + (maxSpeed - minSpeed) * speedLevel))
anim.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
anim.isRemovedOnCompletion = false
anim.fillMode = .forwards
anim.delegate = AnimationDelegate { [weak self] finished in
if finished {
self?.animateToNewShape()
}
}
shapeLayer.add(anim, forKey: "path")
speedLevel = 0
}
private func generateBlob(for size: CGSize) -> [CGPoint] {
let randomness = minRandomness + (maxRandomness - minRandomness) * speedLevel
return blobPoints(count: pointsCount, randomness: randomness).map {
CGPoint(x: $0.x * size.width, y: $0.y * size.height)
}
}
private func blobPoints(count: Int, randomness: CGFloat) -> [CGPoint] {
let angle = (CGFloat.pi * 2) / CGFloat(count)
let rangeStart: CGFloat = 1.0 / (1.0 + randomness / 10.0)
let startAngle = angle * CGFloat(arc4random_uniform(100)) / 100.0
return (0..<count).map { i in
let rng = CGFloat(arc4random_uniform(1000)) / 1000.0
let randOffset = (rangeStart + rng * (1.0 - rangeStart)) / 2.0
let angleRandomness = angle * 0.1
let randAngle = angle + angle * ((angleRandomness * CGFloat(arc4random_uniform(100)) / 100.0) - angleRandomness * 0.5)
let px = sin(startAngle + CGFloat(i) * randAngle)
let py = cos(startAngle + CGFloat(i) * randAngle)
return CGPoint(x: px * randOffset, y: py * randOffset)
}
}
}
// MARK: - Animation Delegate Helper
private final class AnimationDelegate: NSObject, CAAnimationDelegate {
let completion: (Bool) -> Void
init(completion: @escaping (Bool) -> Void) {
self.completion = completion
}
func animationDidStop(_ anim: CAAnimation, finished flag: Bool) {
completion(flag)
}
}
// MARK: - Bezier Smooth Curve
/// Generates smooth closed Bezier curves through a set of points.
/// Ported from Telegram's `UIBezierPath.smoothCurve` extension.
private enum BezierSmooth {
static func smoothCurve(through points: [CGPoint], length: CGFloat, smoothness: CGFloat) -> UIBezierPath {
let smoothPoints = points.enumerated().map { index, curr -> SmoothPoint in
let prevIdx = index - 1
let prev = points[prevIdx >= 0 ? prevIdx : points.count + prevIdx]
let next = points[(index + 1) % points.count]
let angle: CGFloat = {
let dx = next.x - prev.x
let dy = -next.y + prev.y
let a = atan2(dy, dx)
return a < 0 ? abs(a) : 2 * .pi - a
}()
return SmoothPoint(
point: curr,
inAngle: angle + .pi,
inLength: smoothness * distance(prev, curr),
outAngle: angle,
outLength: smoothness * distance(curr, next)
)
}
let path = UIBezierPath()
path.move(to: smoothPoints[0].point)
for i in 0..<smoothPoints.count {
let curr = smoothPoints[i]
let next = smoothPoints[(i + 1) % smoothPoints.count]
path.addCurve(to: next.point, controlPoint1: curr.smoothOut(), controlPoint2: next.smoothIn())
}
path.close()
return path
}
private static func distance(_ a: CGPoint, _ b: CGPoint) -> CGFloat {
sqrt((a.x - b.x) * (a.x - b.x) + (a.y - b.y) * (a.y - b.y))
}
private struct SmoothPoint {
let point: CGPoint
let inAngle: CGFloat
let inLength: CGFloat
let outAngle: CGFloat
let outLength: CGFloat
func smoothIn() -> CGPoint {
CGPoint(x: point.x + inLength * cos(inAngle), y: point.y + inLength * sin(inAngle))
}
func smoothOut() -> CGPoint {
CGPoint(x: point.x + outLength * cos(outAngle), y: point.y + outLength * sin(outAngle))
}
}
}

View File

@@ -1502,6 +1502,7 @@ private extension ChatDetailView {
case .avatar: return "Avatar"
case .messages: return "Forwarded message"
case .call: return "Call"
@unknown default: return "Attachment"
}
}
return nil

View File

@@ -12,6 +12,12 @@ protocol ComposerViewDelegate: AnyObject {
func composerDidCancelReply(_ composer: ComposerView)
func composerUserDidType(_ composer: ComposerView)
func composerKeyboardHeightDidChange(_ composer: ComposerView, height: CGFloat)
// Voice recording
func composerDidStartRecording(_ composer: ComposerView)
func composerDidFinishRecording(_ composer: ComposerView, sendImmediately: Bool)
func composerDidCancelRecording(_ composer: ComposerView)
func composerDidLockRecording(_ composer: ComposerView)
}
// MARK: - ComposerView
@@ -63,8 +69,8 @@ final class ComposerView: UIView, UITextViewDelegate {
private let sendButton = UIButton(type: .system)
private let sendCapsule = UIView()
// Mic button (glass circle, 42×42)
private let micButton = UIButton(type: .system)
// Mic button (glass circle, 42×42) custom control for recording gestures
private let micButton = RecordingMicButton(frame: .zero)
private let micGlass = TelegramGlassUIView(frame: .zero)
private var attachIconLayer: CAShapeLayer?
private var emojiIconLayer: CAShapeLayer?
@@ -92,6 +98,13 @@ final class ComposerView: UIView, UITextViewDelegate {
private var isSendVisible = false
private var isUpdatingText = false
// MARK: - Voice Recording
private let audioRecorder = AudioRecorder()
private var recordingOverlay: VoiceRecordingOverlay?
private var recordingPanel: VoiceRecordingPanel?
private(set) var isRecording = false
// MARK: - Init
override init(frame: CGRect) {
@@ -230,7 +243,7 @@ final class ComposerView: UIView, UITextViewDelegate {
micButton.layer.addSublayer(micIcon)
micIconLayer = micIcon
micButton.tag = 4
micButton.addTarget(self, action: #selector(micTapped), for: .touchUpInside)
micButton.recordingDelegate = self
addSubview(micButton)
updateThemeColors()
@@ -583,19 +596,107 @@ final class ComposerView: UIView, UITextViewDelegate {
}
}
@objc private func micTapped() {
// Mic = placeholder for voice messages, acts as send when there's content
let text = (textView.text ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
if !text.isEmpty {
delegate?.composerDidTapSend(self)
} else {
if !textView.isFirstResponder {
textView.becomeFirstResponder()
}
}
}
@objc private func replyCancelTapped() {
delegate?.composerDidCancelReply(self)
}
}
// MARK: - RecordingMicButtonDelegate
extension ComposerView: RecordingMicButtonDelegate {
func micButtonRecordingBegan(_ button: RecordingMicButton) {
guard audioRecorder.startRecording() else { return }
isRecording = true
guard let window else { return }
// 1. Overlay circles on mic button
let overlay = VoiceRecordingOverlay()
overlay.present(anchorView: micButton, in: window)
recordingOverlay = overlay
// 2. Recording panel (spans full width: attach area to mic button)
let panelX = horizontalPadding
let panelW = micButton.frame.minX - innerSpacing - horizontalPadding
let panel = VoiceRecordingPanel(frame: CGRect(
x: panelX,
y: inputContainer.frame.origin.y,
width: panelW,
height: inputContainer.frame.height
))
panel.delegate = self
addSubview(panel)
panel.animateIn(panelWidth: panelW)
recordingPanel = panel
// 3. Feed audio level overlay + timer
audioRecorder.onLevelUpdate = { [weak self] duration, level in
self?.recordingOverlay?.addMicLevel(CGFloat(level))
self?.recordingPanel?.updateDuration(duration)
}
// 4. Hide composer content (Telegram: textInput alpha0, accessories alpha0)
UIView.animate(withDuration: 0.15) {
self.inputContainer.alpha = 0
self.attachButton.alpha = 0
self.micGlass.alpha = 0
self.micIconLayer?.opacity = 0
}
}
func micButtonRecordingFinished(_ button: RecordingMicButton) {
dismissOverlayAndRestore()
button.resetState()
}
func micButtonRecordingCancelled(_ button: RecordingMicButton) {
dismissOverlayAndRestore()
button.resetState()
}
func micButtonRecordingLocked(_ button: RecordingMicButton) {
dismissOverlayAndRestore()
button.resetState()
}
func micButtonCancelTranslationChanged(_ button: RecordingMicButton, translation: CGFloat) {
let progress = min(1, abs(translation) / 150)
recordingOverlay?.dismissFactor = 1.0 - progress * 0.5
recordingPanel?.updateCancelTranslation(translation)
}
func micButtonLockProgressChanged(_ button: RecordingMicButton, progress: CGFloat) {
// Future: lock indicator
}
private func dismissOverlayAndRestore() {
isRecording = false
audioRecorder.onLevelUpdate = nil
audioRecorder.cancelRecording()
recordingOverlay?.dismiss()
recordingOverlay = nil
recordingPanel?.animateOut { [weak self] in
self?.recordingPanel = nil
}
// Restore composer content
UIView.animate(withDuration: 0.15) {
self.inputContainer.alpha = 1
self.attachButton.alpha = 1
self.micGlass.alpha = 1
self.micIconLayer?.opacity = 1
}
updateSendMicVisibility(animated: false)
}
}
// MARK: - VoiceRecordingPanelDelegate
extension ComposerView: VoiceRecordingPanelDelegate {
func recordingPanelDidTapCancel(_ panel: VoiceRecordingPanel) {
dismissOverlayAndRestore()
micButton.resetState()
}
}

View File

@@ -1425,6 +1425,24 @@ extension NativeMessageListController: ComposerViewDelegate {
userInfo: ["height": height]
)
}
// MARK: - Voice Recording
func composerDidStartRecording(_ composer: ComposerView) {
// Recording started handled by ComposerView internally
}
func composerDidFinishRecording(_ composer: ComposerView, sendImmediately: Bool) {
// Recording finished will be wired to send pipeline later
}
func composerDidCancelRecording(_ composer: ComposerView) {
// Recording cancelled no action needed
}
func composerDidLockRecording(_ composer: ComposerView) {
// Recording locked UI handled by ComposerView
}
}
// MARK: - PreSizedCell

View File

@@ -59,6 +59,19 @@ struct PendingAttachment: Identifiable, Sendable {
)
}
/// Creates a PendingAttachment from a voice recording.
/// Duration in seconds, waveform is normalized [Float] array (0..1).
static func fromVoice(data: Data, duration: TimeInterval, waveform: [Float]) -> PendingAttachment {
return PendingAttachment(
id: generateRandomId(),
type: .voice,
data: data,
thumbnail: nil,
fileName: "voice_\(Int(duration))s.m4a",
fileSize: data.count
)
}
// MARK: - Helpers
/// Generates a random 8-character ID (desktop: `generateRandomKey(8)`).

View File

@@ -0,0 +1,290 @@
import QuartzCore
import UIKit
// MARK: - Recording State
enum VoiceRecordingState {
case idle
case waiting // finger down, waiting for threshold (0.15s)
case recording // actively recording, finger held
case locked // slid up past lock threshold, finger released
case cancelled // slid left past cancel threshold
case finished // finger released normally send
}
// MARK: - RecordingMicButtonDelegate
@MainActor
protocol RecordingMicButtonDelegate: AnyObject {
/// Recording threshold reached (0.15s hold). Start actual recording.
func micButtonRecordingBegan(_ button: RecordingMicButton)
/// Finger released normally send the recording.
func micButtonRecordingFinished(_ button: RecordingMicButton)
/// Slid left past cancel threshold discard recording.
func micButtonRecordingCancelled(_ button: RecordingMicButton)
/// Slid up past lock threshold lock into hands-free recording.
func micButtonRecordingLocked(_ button: RecordingMicButton)
/// Horizontal slide translation update for cancel indicator.
/// Value is negative (slide left), range roughly -150..0.
func micButtonCancelTranslationChanged(_ button: RecordingMicButton, translation: CGFloat)
/// Vertical lock progress update (0..1).
func micButtonLockProgressChanged(_ button: RecordingMicButton, progress: CGFloat)
}
// MARK: - RecordingMicButton
/// Custom UIControl that handles voice recording gestures.
/// Ported from Telegram's `TGModernConversationInputMicButton`.
///
/// Gesture mechanics:
/// - Long press (0.15s) begin recording
/// - Slide left cancel (threshold: -150px, haptic at -100px)
/// - Slide up lock (threshold: -110px, haptic at -60px)
/// - Release finish (send)
final class RecordingMicButton: UIControl {
weak var recordingDelegate: RecordingMicButtonDelegate?
private(set) var recordingState: VoiceRecordingState = .idle
// MARK: - Gesture Thresholds (Telegram parity)
private let holdThreshold: TimeInterval = 0.15
private let cancelDistanceThreshold: CGFloat = -150
private let cancelHapticThreshold: CGFloat = -100
private let lockDistanceThreshold: CGFloat = -110
private let lockHapticThreshold: CGFloat = -60
// MARK: - Tracking State
private var touchStartLocation: CGPoint = .zero
private var holdTimer: Timer?
private var displayLink: CADisplayLink?
// Raw target values (set by touch events)
private var targetCancelTranslation: CGFloat = 0
private var targetLockTranslation: CGFloat = 0
// Smoothed values (updated by display link)
private var currentCancelTranslation: CGFloat = 0
private var currentLockTranslation: CGFloat = 0
// Haptic tracking
private var didCancelHaptic = false
private var didLockHaptic = false
// Haptic generators
private let impactFeedback = UIImpactFeedbackGenerator(style: .medium)
// MARK: - Init
override init(frame: CGRect) {
super.init(frame: frame)
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError() }
// MARK: - Touch Tracking
override func beginTracking(_ touch: UITouch, with event: UIEvent?) -> Bool {
guard recordingState == .idle else { return false }
touchStartLocation = touch.location(in: window)
recordingState = .waiting
targetCancelTranslation = 0
targetLockTranslation = 0
currentCancelTranslation = 0
currentLockTranslation = 0
didCancelHaptic = false
didLockHaptic = false
impactFeedback.prepare()
// Start hold timer after 0.15s we begin recording
holdTimer = Timer.scheduledTimer(withTimeInterval: holdThreshold, repeats: false) { [weak self] _ in
Task { @MainActor in
self?.beginRecording()
}
}
return true
}
override func continueTracking(_ touch: UITouch, with event: UIEvent?) -> Bool {
guard recordingState == .waiting || recordingState == .recording else { return false }
let location = touch.location(in: window)
let distanceX = min(0, location.x - touchStartLocation.x)
let distanceY = min(0, location.y - touchStartLocation.y)
// Check if we moved enough to cancel the hold timer (before recording started)
if recordingState == .waiting {
let totalDistance = sqrt(distanceX * distanceX + distanceY * distanceY)
if totalDistance > 10 {
// Movement before threshold cancel the timer, don't start recording
cancelHoldTimer()
recordingState = .idle
return false
}
return true
}
// Recording state track slide gestures
targetCancelTranslation = distanceX
targetLockTranslation = distanceY
// Cancel haptic
if distanceX < cancelHapticThreshold, !didCancelHaptic {
didCancelHaptic = true
impactFeedback.impactOccurred()
}
// Lock haptic
if distanceY < lockHapticThreshold, !didLockHaptic {
didLockHaptic = true
impactFeedback.impactOccurred()
}
// Check cancel threshold
if distanceX < cancelDistanceThreshold {
commitCancel()
return false
}
// Check lock threshold
if distanceY < lockDistanceThreshold {
commitLock()
return false
}
return true
}
override func endTracking(_ touch: UITouch?, with event: UIEvent?) {
if recordingState == .waiting {
// Released before hold threshold just a tap
cancelHoldTimer()
recordingState = .idle
return
}
if recordingState == .recording {
// Check velocity for quick flick gestures
if let touch {
let location = touch.location(in: window)
let distanceX = location.x - touchStartLocation.x
let distanceY = location.y - touchStartLocation.y
if distanceX < cancelDistanceThreshold / 2 {
commitCancel()
return
}
if distanceY < lockDistanceThreshold / 2 {
commitLock()
return
}
}
// Normal release finish recording (send)
commitFinish()
}
}
override func cancelTracking(with event: UIEvent?) {
if recordingState == .recording {
// Touch cancelled (e.g. system gesture) lock instead of cancel
commitLock()
} else {
cancelHoldTimer()
recordingState = .idle
}
stopDisplayLink()
}
// MARK: - State Transitions
private func beginRecording() {
guard recordingState == .waiting else { return }
recordingState = .recording
holdTimer = nil
impactFeedback.impactOccurred()
startDisplayLink()
recordingDelegate?.micButtonRecordingBegan(self)
}
private func commitCancel() {
guard recordingState == .recording else { return }
recordingState = .cancelled
stopDisplayLink()
recordingDelegate?.micButtonRecordingCancelled(self)
}
private func commitLock() {
guard recordingState == .recording else { return }
recordingState = .locked
stopDisplayLink()
impactFeedback.impactOccurred()
recordingDelegate?.micButtonRecordingLocked(self)
}
private func commitFinish() {
guard recordingState == .recording else { return }
recordingState = .finished
stopDisplayLink()
recordingDelegate?.micButtonRecordingFinished(self)
}
// MARK: - Public API
/// Reset to idle state (call after processing send/cancel/lock).
func resetState() {
cancelHoldTimer()
stopDisplayLink()
recordingState = .idle
targetCancelTranslation = 0
targetLockTranslation = 0
currentCancelTranslation = 0
currentLockTranslation = 0
}
// MARK: - Display Link
private func startDisplayLink() {
guard displayLink == nil else { return }
let link = CADisplayLink(target: self, selector: #selector(displayLinkUpdate))
link.add(to: .main, forMode: .common)
displayLink = link
}
private func stopDisplayLink() {
displayLink?.invalidate()
displayLink = nil
}
@objc private func displayLinkUpdate() {
// Smooth interpolation (Telegram: 0.7/0.3 blend)
currentCancelTranslation = currentCancelTranslation * 0.7 + targetCancelTranslation * 0.3
currentLockTranslation = currentLockTranslation * 0.7 + targetLockTranslation * 0.3
// Report cancel translation
recordingDelegate?.micButtonCancelTranslationChanged(self, translation: currentCancelTranslation)
// Report lock progress (0..1)
let lockProgress = min(1.0, abs(currentLockTranslation) / abs(lockDistanceThreshold))
recordingDelegate?.micButtonLockProgressChanged(self, progress: lockProgress)
}
// MARK: - Helpers
private func cancelHoldTimer() {
holdTimer?.invalidate()
holdTimer = nil
}
}

View File

@@ -0,0 +1,212 @@
import QuartzCore
import UIKit
// MARK: - VoiceRecordingOverlay
/// Telegram-exact recording overlay. Values from audit of:
/// - TGModernConversationInputMicButton.m (lines 11-13, 909-938)
/// - ChatTextInputAudioRecordingOverlayButton.swift (lines 8-173)
///
/// Z-order (backfront): outerCircle innerCircle micIcon
/// Inner circle: 110pt, #0088FF, opaque
/// Outer circle: 160pt, #0088FF alpha 0.2, scales with audio
/// Mic icon: white SVG, 25x34pt, top-most layer
final class VoiceRecordingOverlay {
// Telegram exact (lines 11-13 of TGModernConversationInputMicButton.m)
private let innerDiameter: CGFloat = 110
private let outerDiameter: CGFloat = 160
private var outerMinScale: CGFloat { innerDiameter / outerDiameter } // 0.6875
// Telegram exact: UIColor(rgb: 0x0088ff)
private let telegramBlue = UIColor(red: 0, green: 136/255.0, blue: 1.0, alpha: 1)
// MARK: - Views
private let containerView = UIView()
private let outerCircle = UIView()
private let innerCircle = UIView()
private let micIconLayer = CAShapeLayer()
// MARK: - Display Link
private var displayLink: CADisplayLink?
private var displayLinkTarget: DisplayLinkTarget?
private var animationStartTime: Double = 0
private var currentLevel: CGFloat = 0
private var inputLevel: CGFloat = 0
var dismissFactor: CGFloat = 1.0 {
didSet {
let s = max(0.3, min(dismissFactor, 1.0))
containerView.transform = CGAffineTransform(scaleX: s, y: s)
}
}
// MARK: - Init
init() {
containerView.isUserInteractionEnabled = false
// Outer circle: 160pt, #0088FF alpha 0.2
outerCircle.backgroundColor = telegramBlue.withAlphaComponent(0.2)
outerCircle.bounds = CGRect(origin: .zero, size: CGSize(width: outerDiameter, height: outerDiameter))
outerCircle.layer.cornerRadius = outerDiameter / 2
// Inner circle: 110pt, #0088FF opaque
innerCircle.backgroundColor = telegramBlue
innerCircle.bounds = CGRect(origin: .zero, size: CGSize(width: innerDiameter, height: innerDiameter))
innerCircle.layer.cornerRadius = innerDiameter / 2
// Mic icon SVG
configureMicIcon()
// Z-order: outer (back) inner icon (front)
containerView.addSubview(outerCircle)
containerView.addSubview(innerCircle)
containerView.layer.addSublayer(micIconLayer)
}
private func configureMicIcon() {
let viewBox = CGSize(width: 17.168, height: 23.555)
let targetSize = CGSize(width: 25, height: 34)
var parser = SVGPathParser(pathData: TelegramIconPath.microphone)
let cgPath = parser.parse()
let sx = targetSize.width / viewBox.width
let sy = targetSize.height / viewBox.height
micIconLayer.path = cgPath.copy(using: [CGAffineTransform(scaleX: sx, y: sy)])
micIconLayer.fillColor = UIColor.white.cgColor
micIconLayer.bounds = CGRect(origin: .zero, size: targetSize)
}
// MARK: - Present (Telegram exact: spring damping 0.55, duration 0.5s)
func present(anchorView: UIView, in window: UIWindow) {
guard let superview = anchorView.superview else { return }
// Telegram: centerOffset = (0, -1 + screenPixel)
var center = superview.convert(anchorView.center, to: window)
center.y -= 1.0
containerView.bounds = CGRect(origin: .zero, size: CGSize(width: outerDiameter, height: outerDiameter))
containerView.center = center
containerView.transform = .identity
containerView.alpha = 1
let mid = CGPoint(x: outerDiameter / 2, y: outerDiameter / 2)
outerCircle.center = mid
innerCircle.center = mid
CATransaction.begin()
CATransaction.setDisableActions(true)
micIconLayer.position = mid
CATransaction.commit()
window.addSubview(containerView)
// Start state: scale 0.2, alpha 0.2 (Telegram exact)
innerCircle.transform = CGAffineTransform(scaleX: 0.2, y: 0.2)
innerCircle.alpha = 0.2
outerCircle.transform = CGAffineTransform(scaleX: 0.2, y: 0.2)
outerCircle.alpha = 0.2
micIconLayer.opacity = 0.2
// Alpha fade: 0.15s (Telegram exact)
UIView.animate(withDuration: 0.15) {
self.innerCircle.alpha = 1
self.outerCircle.alpha = 1
}
let iconFade = CABasicAnimation(keyPath: "opacity")
iconFade.fromValue = 0.2
iconFade.toValue = 1.0
iconFade.duration = 0.15
micIconLayer.opacity = 1.0
micIconLayer.add(iconFade, forKey: "fadeIn")
// Spring scale: damping 0.55, duration 0.5s (Telegram exact)
// Inner 1.0
UIView.animate(withDuration: 0.5, delay: 0, usingSpringWithDamping: 0.55, initialSpringVelocity: 0, options: .beginFromCurrentState) {
self.innerCircle.transform = .identity
}
// Outer outerMinScale (0.6875)
UIView.animate(withDuration: 0.5, delay: 0, usingSpringWithDamping: 0.55, initialSpringVelocity: 0, options: .beginFromCurrentState) {
self.outerCircle.transform = CGAffineTransform(scaleX: self.outerMinScale, y: self.outerMinScale)
}
animationStartTime = CACurrentMediaTime()
startDisplayLink()
}
// MARK: - Dismiss (Telegram exact: 0.18s, scale0.2, alpha0)
func dismiss() {
stopDisplayLink()
UIView.animate(withDuration: 0.18, animations: {
self.innerCircle.transform = CGAffineTransform(scaleX: 0.2, y: 0.2)
self.innerCircle.alpha = 0
self.outerCircle.transform = CGAffineTransform(scaleX: 0.2, y: 0.2)
self.outerCircle.alpha = 0
})
let iconFade = CABasicAnimation(keyPath: "opacity")
iconFade.fromValue = 1.0
iconFade.toValue = 0.0
iconFade.duration = 0.18
iconFade.fillMode = .forwards
iconFade.isRemovedOnCompletion = false
micIconLayer.add(iconFade, forKey: "fadeOut")
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { [weak self] in
self?.containerView.removeFromSuperview()
self?.micIconLayer.removeAllAnimations()
self?.micIconLayer.opacity = 1
self?.currentLevel = 0
self?.inputLevel = 0
}
}
// MARK: - Audio Level
func addMicLevel(_ level: CGFloat) {
inputLevel = level
}
// MARK: - Display Link (Telegram: displayLinkEvent, 0.8/0.2 smoothing)
private func startDisplayLink() {
guard displayLink == nil else { return }
let target = DisplayLinkTarget { [weak self] in self?.tick() }
let link = CADisplayLink(target: target, selector: #selector(DisplayLinkTarget.tick))
link.add(to: .main, forMode: .common)
displayLink = link
displayLinkTarget = target
}
private func stopDisplayLink() {
displayLink?.invalidate()
displayLink = nil
displayLinkTarget = nil
}
private func tick() {
// Telegram: wait 0.5s for spring to settle before reacting to audio
guard CACurrentMediaTime() > animationStartTime + 0.5 else { return }
// Telegram exact smoothing (ChatTextInputAudioRecordingOverlay line 162)
currentLevel = currentLevel * 0.8 + inputLevel * 0.2
// Telegram exact: outerCircleMinScale + currentLevel * (1.0 - outerCircleMinScale)
let scale = outerMinScale + currentLevel * (1.0 - outerMinScale)
outerCircle.transform = CGAffineTransform(scaleX: scale, y: scale)
}
}
// MARK: - DisplayLinkTarget
private final class DisplayLinkTarget: NSObject {
let callback: () -> Void
init(_ callback: @escaping () -> Void) { self.callback = callback }
@objc func tick() { callback() }
}

View File

@@ -0,0 +1,294 @@
import QuartzCore
import UIKit
// MARK: - VoiceRecordingPanelDelegate
@MainActor
protocol VoiceRecordingPanelDelegate: AnyObject {
func recordingPanelDidTapCancel(_ panel: VoiceRecordingPanel)
}
// MARK: - VoiceRecordingPanel
/// Recording bar shown inside the composer during voice recording.
/// Telegram parity from ChatTextInputPanelNode.swift audit:
/// - Red dot: 10×10, #FF2D55, keyframe pulsing 0.5s
/// - Timer: 15pt mono, X=34pt from left
/// - "< Slide to cancel": centered, 14pt, panelControlColor
/// - Jiggle animation: 6pt, 1.0s easeInOut, infinite
final class VoiceRecordingPanel: UIView {
weak var delegate: VoiceRecordingPanelDelegate?
// MARK: - Subviews
// Glass background
private let glassBackground = TelegramGlassUIView(frame: .zero)
// Red dot (10×10, #FF2D55)
private let redDot = UIView()
// Timer (15pt monospaced)
private let timerLabel = UILabel()
// Cancel indicator container (arrow + "Slide to cancel")
private let cancelContainer = UIView()
private let arrowIcon = UIImageView()
private let slideLabel = UILabel()
// Cancel button (shown in locked state, 17pt)
private let cancelButton = UIButton(type: .system)
// MARK: - State
private(set) var isDisplayingCancel = false
// MARK: - Telegram-exact layout constants
private let dotX: CGFloat = 16
private let timerX: CGFloat = 34
private let dotSize: CGFloat = 10
private let arrowLabelGap: CGFloat = 6
// MARK: - Init
override init(frame: CGRect) {
super.init(frame: frame)
clipsToBounds = true
layer.cornerRadius = 21
layer.cornerCurve = .continuous
setupSubviews()
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError() }
// MARK: - Setup
private func setupSubviews() {
// Glass background (matches input container style)
glassBackground.fixedCornerRadius = 21
glassBackground.isUserInteractionEnabled = false
addSubview(glassBackground)
// Red dot: 10×10, Telegram #FF2D55
redDot.backgroundColor = UIColor(red: 1.0, green: 45/255.0, blue: 85/255.0, alpha: 1)
redDot.layer.cornerRadius = dotSize / 2
addSubview(redDot)
// Timer: 15pt monospaced
timerLabel.font = .monospacedDigitSystemFont(ofSize: 15, weight: .regular)
timerLabel.textColor = .white
timerLabel.text = "0:00"
addSubview(timerLabel)
// Arrow icon (template, white 30% alpha like panelControlColor on dark)
let arrowConfig = UIImage.SymbolConfiguration(pointSize: 11, weight: .semibold)
arrowIcon.image = UIImage(systemName: "chevron.left", withConfiguration: arrowConfig)
arrowIcon.tintColor = UIColor.white.withAlphaComponent(0.4)
cancelContainer.addSubview(arrowIcon)
// "Slide to cancel" label: 14pt regular
slideLabel.font = .systemFont(ofSize: 14, weight: .regular)
slideLabel.textColor = UIColor.white.withAlphaComponent(0.4)
slideLabel.text = "Slide to cancel"
cancelContainer.addSubview(slideLabel)
addSubview(cancelContainer)
// Cancel button (for locked state): 17pt
cancelButton.setTitle("Cancel", for: .normal)
cancelButton.setTitleColor(.white, for: .normal)
cancelButton.titleLabel?.font = .systemFont(ofSize: 17, weight: .regular)
cancelButton.addTarget(self, action: #selector(cancelTapped), for: .touchUpInside)
cancelButton.alpha = 0
addSubview(cancelButton)
}
// MARK: - Layout
override func layoutSubviews() {
super.layoutSubviews()
let h = bounds.height
let w = bounds.width
// Glass background
glassBackground.frame = bounds
glassBackground.applyCornerRadius()
// 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)
redDot.frame = CGRect(
x: dotX,
y: timerY + floor((timerSize.height - dotSize) / 2),
width: dotSize,
height: dotSize
)
// Timer: at X=34
timerLabel.frame = CGRect(x: timerX, y: timerY, width: timerSize.width + 4, height: timerSize.height)
// Cancel indicator: centered in available width
let labelSize = slideLabel.sizeThatFits(CGSize(width: 200, height: h))
let arrowW: CGFloat = 12
let totalCancelW = arrowW + arrowLabelGap + labelSize.width
let cancelX = floor((w - totalCancelW) / 2)
cancelContainer.frame = CGRect(x: cancelX, y: 0, width: totalCancelW, height: h)
arrowIcon.frame = CGRect(x: 0, y: floor((h - 12) / 2), width: arrowW, height: 12)
slideLabel.frame = CGRect(
x: arrowW + arrowLabelGap,
y: 1 + floor((h - labelSize.height) / 2),
width: labelSize.width,
height: labelSize.height
)
// Cancel button: centered
cancelButton.sizeToFit()
cancelButton.center = CGPoint(x: w / 2, y: h / 2)
}
// MARK: - Public API
/// Updates timer text. Called from AudioRecorder.onLevelUpdate.
func updateDuration(_ duration: TimeInterval) {
let totalSeconds = Int(duration)
let minutes = totalSeconds / 60
let seconds = totalSeconds % 60
let centiseconds = Int(duration * 100) % 100
timerLabel.text = String(format: "%d:%02d,%02d", minutes, seconds, centiseconds)
}
/// Updates cancel indicator position based on horizontal drag.
/// translation is negative (finger sliding left).
func updateCancelTranslation(_ translation: CGFloat) {
guard !isDisplayingCancel else { return }
// Telegram: indicatorTranslation = max(0, cancelTranslation - 8)
let offset = max(0, abs(translation) - 8)
cancelContainer.transform = CGAffineTransform(translationX: -offset * 0.5, y: 0)
// Telegram: alpha = max(0, min(1, (frameMinX - 100) / 10))
let minX = cancelContainer.frame.minX - offset * 0.5
let alpha = max(0, min(1, (minX - 100) / 10))
cancelContainer.alpha = alpha
}
/// Animate panel in. Called when recording begins.
/// Telegram: spring 0.40.5s, dot/timer slide from left, cancel from right.
func animateIn(panelWidth: CGFloat) {
// Red dot: appear with scale 0.31.0, alpha 01, 0.15s (Telegram exact)
redDot.transform = CGAffineTransform(scaleX: 0.3, y: 0.3)
redDot.alpha = 0
UIView.animate(withDuration: 0.15) {
self.redDot.transform = .identity
self.redDot.alpha = 1
}
// Start pulsing after dot appears (Telegram: keyframe, 0.5s cycle)
DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) { [weak self] in
self?.startDotPulsing()
}
// Timer: slide in from left, spring 0.5s
timerLabel.alpha = 0
let timerStartX = timerLabel.frame.origin.x - 30
timerLabel.transform = CGAffineTransform(translationX: -30, y: 0)
UIView.animate(withDuration: 0.5, delay: 0, usingSpringWithDamping: 0.65, initialSpringVelocity: 0, options: []) {
self.timerLabel.alpha = 1
self.timerLabel.transform = .identity
}
// Cancel indicator: slide in from right, spring 0.4s (Telegram exact)
cancelContainer.alpha = 1
cancelContainer.transform = CGAffineTransform(translationX: panelWidth * 0.3, y: 0)
UIView.animate(withDuration: 0.4, delay: 0, usingSpringWithDamping: 0.65, initialSpringVelocity: 0, options: []) {
self.cancelContainer.transform = .identity
}
// Start jiggle after cancel slides in (Telegram: 6pt, 1.0s, easeInOut, infinite)
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in
self?.startCancelJiggle()
}
}
/// Animate panel out. Called when recording ends.
func animateOut(completion: (() -> Void)? = nil) {
stopDotPulsing()
stopCancelJiggle()
// Telegram: 0.15s fade + scale for dot/timer
UIView.animate(withDuration: 0.15, animations: {
self.redDot.alpha = 0
self.timerLabel.alpha = 0
self.timerLabel.transform = CGAffineTransform(scaleX: 0.5, y: 0.5)
self.cancelContainer.alpha = 0
self.cancelButton.alpha = 0
}, completion: { _ in
self.removeFromSuperview()
completion?()
})
}
/// Transition cancel indicator to "Cancel" button (locked state).
/// Telegram: arrow+label shrink up (-22pt, scale 0.25), button grows down.
func showCancelButton() {
guard !isDisplayingCancel else { return }
isDisplayingCancel = true
stopCancelJiggle()
// Cancel button starts small and offset
cancelButton.transform = CGAffineTransform(translationX: 0, y: 22).scaledBy(x: 0.25, y: 0.25)
UIView.animate(withDuration: 0.25) {
// Arrow + label shrink up
self.cancelContainer.alpha = 0
self.cancelContainer.transform = CGAffineTransform(translationX: 0, y: -22).scaledBy(x: 0.25, y: 0.25)
}
UIView.animate(withDuration: 0.25) {
self.cancelButton.alpha = 1
self.cancelButton.transform = .identity
}
}
// MARK: - Red Dot Pulsing (Telegram exact: keyframe, 0.5s, [1,1,0], autoReverse)
private func startDotPulsing() {
let anim = CAKeyframeAnimation(keyPath: "opacity")
anim.values = [1.0, 1.0, 0.0]
anim.keyTimes = [0.0, 0.4546, 0.9091]
anim.duration = 0.5
anim.autoreverses = true
anim.repeatCount = .infinity
redDot.layer.add(anim, forKey: "pulse")
}
private func stopDotPulsing() {
redDot.layer.removeAnimation(forKey: "pulse")
}
// MARK: - Cancel Jiggle (Telegram exact: 6pt, 1.0s, easeInOut, autoReverse, infinite)
private func startCancelJiggle() {
guard bounds.width > 320 else { return } // Telegram: only on wider screens
let anim = CABasicAnimation(keyPath: "transform")
anim.toValue = CATransform3DMakeTranslation(6, 0, 0)
anim.duration = 1.0
anim.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
anim.autoreverses = true
anim.repeatCount = .infinity
cancelContainer.layer.add(anim, forKey: "jiggle")
}
private func stopCancelJiggle() {
cancelContainer.layer.removeAnimation(forKey: "jiggle")
}
// MARK: - Actions
@objc private func cancelTapped() {
delegate?.recordingPanelDidTapCancel(self)
}
}

View File

@@ -35,16 +35,21 @@ struct ChatListView: View {
@State private var showNewGroupSheet = false
@State private var showJoinGroupSheet = false
@State private var showNewChatActionSheet = false
@State private var searchBarExpansion: CGFloat = 1.0
@FocusState private var isSearchFocused: Bool
var body: some View {
NavigationStack(path: $navigationState.path) {
VStack(spacing: 0) {
// Custom search bar
// Custom search bar collapses on scroll (Telegram: 54pt distance)
customSearchBar
.padding(.horizontal, 16)
.padding(.top, 12)
.padding(.bottom, 8)
.padding(.top, isSearchActive ? 8 : 8 * searchBarExpansion)
.padding(.bottom, isSearchActive ? 8 : 8 * searchBarExpansion)
.frame(height: isSearchActive ? 60 : max(0, 60 * searchBarExpansion), alignment: .top)
.clipped()
.opacity(isSearchActive ? 1 : Double(searchBarExpansion))
.allowsHitTesting(isSearchActive || searchBarExpansion > 0.5)
.background(
(hasPinnedChats && !isSearchActive
? RosettaColors.Adaptive.pinnedSectionBackground
@@ -78,6 +83,9 @@ struct ChatListView: View {
.toolbar(isSearchActive ? .hidden : .visible, for: .navigationBar)
.toolbar { toolbarContent }
.modifier(ChatListToolbarBackgroundModifier())
.onChange(of: isSearchActive) { _, _ in
searchBarExpansion = 1.0
}
.onChange(of: searchText) { _, newValue in
viewModel.setSearchQuery(newValue)
}
@@ -166,7 +174,9 @@ struct ChatListView: View {
// MARK: - Cancel Search
private func cancelSearch() {
withAnimation(.easeInOut(duration: 0.3)) {
isSearchActive = false
}
isSearchFocused = false
searchText = ""
viewModel.setSearchQuery("")
@@ -229,12 +239,12 @@ private extension ChatListView {
.padding(.horizontal, 12)
}
}
.frame(height: 42)
.frame(height: 44)
.frame(maxWidth: .infinity)
.contentShape(Rectangle())
.onTapGesture {
if !isSearchActive {
withAnimation(.easeInOut(duration: 0.25)) {
withAnimation(.easeInOut(duration: 0.14)) {
isSearchActive = true
}
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
@@ -244,20 +254,20 @@ private extension ChatListView {
}
.background {
if isSearchActive {
RoundedRectangle(cornerRadius: 24, style: .continuous)
RoundedRectangle(cornerRadius: 22, style: .continuous)
.fill(RosettaColors.Adaptive.searchBarFill)
.overlay {
RoundedRectangle(cornerRadius: 24, style: .continuous)
RoundedRectangle(cornerRadius: 22, style: .continuous)
.strokeBorder(RosettaColors.Adaptive.searchBarBorder, lineWidth: 0.5)
}
} else {
RoundedRectangle(cornerRadius: 24, style: .continuous)
RoundedRectangle(cornerRadius: 22, style: .continuous)
.fill(RosettaColors.Adaptive.searchBarFill)
}
}
.onChange(of: isSearchFocused) { _, focused in
if focused && !isSearchActive {
withAnimation(.easeInOut(duration: 0.25)) {
withAnimation(.easeInOut(duration: 0.14)) {
isSearchActive = true
}
}
@@ -310,6 +320,9 @@ private extension ChatListView {
hasPinnedChats = pinned
}
}
},
onScrollOffsetChange: { expansion in
searchBarExpansion = expansion
}
)
}
@@ -593,6 +606,7 @@ private struct DeviceVerificationContentRouter: View {
@ObservedObject var navigationState: ChatListNavigationState
var onShowRequests: () -> Void = {}
var onPinnedStateChange: (Bool) -> Void = { _ in }
var onScrollOffsetChange: (CGFloat) -> Void = { _ in }
var body: some View {
let proto = ProtocolManager.shared
@@ -611,7 +625,8 @@ private struct DeviceVerificationContentRouter: View {
viewModel: viewModel,
navigationState: navigationState,
onShowRequests: onShowRequests,
onPinnedStateChange: onPinnedStateChange
onPinnedStateChange: onPinnedStateChange,
onScrollOffsetChange: onScrollOffsetChange
)
}
}
@@ -626,6 +641,7 @@ private struct ChatListDialogContent: View {
@ObservedObject var navigationState: ChatListNavigationState
var onShowRequests: () -> Void = {}
var onPinnedStateChange: (Bool) -> Void = { _ in }
var onScrollOffsetChange: (CGFloat) -> Void = { _ in }
/// Desktop parity: track typing dialogs from MessageRepository (@Published).
@State private var typingDialogs: [String: Set<String>] = [:]
@@ -659,86 +675,54 @@ private struct ChatListDialogContent: View {
}
}
// MARK: - Dialog List
private static let topAnchorId = "chatlist_top"
// MARK: - Dialog List (UIKit UICollectionView)
private func dialogList(pinned: [Dialog], unpinned: [Dialog], requestsCount: Int) -> some View {
ScrollViewReader { scrollProxy in
List {
Group {
if viewModel.isLoading {
// Shimmer skeleton during initial load (SwiftUI simple, not perf-critical)
List {
ForEach(0..<8, id: \.self) { _ in
ChatRowShimmerView()
.listRowInsets(EdgeInsets())
.listRowBackground(Color.clear)
.listRowSeparator(.hidden)
}
} else {
// Telegram-style "Request Chats" row at top (like Archived Chats)
if requestsCount > 0 {
RequestChatsRow(count: requestsCount, onTap: onShowRequests)
.listRowInsets(EdgeInsets())
.listRowBackground(Color.clear)
.listRowSeparator(.visible, edges: .bottom)
.listRowSeparatorTint(RosettaColors.Adaptive.divider)
.alignmentGuide(.listRowSeparatorLeading) { _ in 82 }
}
if !pinned.isEmpty {
ForEach(pinned, id: \.id) { dialog in
chatRow(dialog, isFirst: dialog.id == pinned.first?.id && requestsCount == 0)
.environment(\.rowBackgroundColor, RosettaColors.Adaptive.pinnedSectionBackground)
.listRowBackground(RosettaColors.Adaptive.pinnedSectionBackground)
}
}
ForEach(unpinned, id: \.id) { dialog in
chatRow(dialog, isFirst: dialog.id == unpinned.first?.id && pinned.isEmpty && requestsCount == 0)
}
}
Color.clear.frame(height: 80)
.listRowInsets(EdgeInsets())
.listRowBackground(Color.clear)
.listRowSeparator(.hidden)
}
.listStyle(.plain)
.scrollContentBackground(.hidden)
.scrollDismissesKeyboard(.immediately)
.scrollIndicators(.hidden)
.modifier(ClassicSwipeActionsModifier())
// Scroll-to-top: tap "Chats" in toolbar
.onReceive(NotificationCenter.default.publisher(for: .chatListScrollToTop)) { _ in
// Scroll to first dialog ID (pinned or unpinned)
let firstId = pinned.first?.id ?? unpinned.first?.id
if let firstId {
withAnimation(.easeOut(duration: 0.3)) {
scrollProxy.scrollTo(firstId, anchor: .top)
} else {
// UIKit UICollectionView Telegram-level scroll performance
let isSyncing = SessionManager.shared.syncBatchInProgress
ChatListCollectionView(
pinnedDialogs: pinned,
unpinnedDialogs: unpinned,
requestsCount: requestsCount,
typingDialogs: typingDialogs,
isSyncing: isSyncing,
isLoading: viewModel.isLoading,
onSelectDialog: { dialog in
navigationState.path.append(ChatRoute(dialog: dialog))
},
onDeleteDialog: { dialog in
viewModel.deleteDialog(dialog)
},
onTogglePin: { dialog in
viewModel.togglePin(dialog)
},
onToggleMute: { dialog in
viewModel.toggleMute(dialog)
},
onPinnedStateChange: onPinnedStateChange,
onShowRequests: onShowRequests,
onScrollOffsetChange: onScrollOffsetChange,
onMarkAsRead: { dialog in
viewModel.markAsRead(dialog)
}
}
}
} // ScrollViewReader
}
private func chatRow(_ dialog: Dialog, isFirst: Bool = false) -> some View {
/// Desktop parity: wrap in SyncAwareChatRow to isolate @Observable read
/// of SessionManager.syncBatchInProgress from this view's observation scope.
/// viewModel + navigationState passed as plain `let` (not @ObservedObject)
/// stable class references don't trigger row re-evaluation on parent re-render.
SyncAwareChatRow(
dialog: dialog,
isTyping: !(typingDialogs[dialog.opponentKey]?.isEmpty ?? true),
typingSenderNames: {
guard let senderKeys = typingDialogs[dialog.opponentKey] else { return [] }
return senderKeys.map { sk in
DialogRepository.shared.dialogs[sk]?.opponentTitle
?? String(sk.prefix(8))
}
}(),
isFirst: isFirst,
viewModel: viewModel,
navigationState: navigationState
)
}
}
}
}
// MARK: - Sync-Aware Chat Row (observation-isolated)

View File

@@ -0,0 +1,886 @@
import UIKit
import SwiftUI
// MARK: - ChatListCell
/// UICollectionViewCell with manual frame layout matching Telegram iOS ChatListItemNode.
/// All measurements taken from Telegram source: `ChatListItem.swift` asyncLayout().
///
/// No Auto Layout all frames computed in `layoutSubviews()` for maximum scroll performance.
final class ChatListCell: UICollectionViewCell {
// MARK: - Layout Constants (Telegram-exact)
enum CellLayout {
static let avatarDiameter: CGFloat = 60
static let avatarLeftPadding: CGFloat = 10
static let avatarToTextGap: CGFloat = 8 // visual gap after avatar
static let contentLeftInset: CGFloat = 80 // avatarLeft(10) + avatar(60) + gap(10)
static let contentRightInset: CGFloat = 10
static let contentTopOffset: CGFloat = 8
static let titleSpacing: CGFloat = -1 // negative, Telegram's titleSpacing
static let dateYOffset: CGFloat = 2 // relative to contentTop
static let badgeDiameter: CGFloat = 20
static let badgeSpacing: CGFloat = 6
static let badgeBottomInset: CGFloat = 2 // from content bottom
static let separatorInset: CGFloat = 80
static let onlineDotSize: CGFloat = 14 // 24% of 60
static let onlineBorderWidth: CGFloat = 2.5
static let statusIconSize: CGFloat = 16
static let itemHeight: CGFloat = 76
}
// MARK: - Subviews
// Avatar
let avatarBackgroundView = UIView()
let avatarInitialsLabel = UILabel()
let avatarImageView = UIImageView()
let onlineIndicator = UIView()
private let onlineDotInner = UIView()
// Group avatar fallback
let groupIconView = UIImageView()
// Title row
let titleLabel = UILabel()
let verifiedBadge = UIImageView()
let mutedIconView = UIImageView()
// Author (group sender name separate line between title and message)
let authorLabel = UILabel()
// Message row
let messageLabel = UILabel()
// Trailing column
let dateLabel = UILabel()
let statusImageView = UIImageView()
let badgeContainer = UIView()
let badgeLabel = UILabel()
let mentionBadgeContainer = UIView()
let mentionLabel = UILabel()
let pinnedIconView = UIImageView()
// Separator
let separatorView = UIView()
// MARK: - State
private var isPinned = false
private var wasBadgeVisible = false
private var wasMentionBadgeVisible = false
// MARK: - Init
override init(frame: CGRect) {
super.init(frame: frame)
setupSubviews()
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
// MARK: - Setup
private func setupSubviews() {
backgroundColor = .clear
contentView.backgroundColor = .clear
// Avatar background (colored circle for initials)
avatarBackgroundView.clipsToBounds = true
avatarBackgroundView.layer.cornerRadius = CellLayout.avatarDiameter / 2
contentView.addSubview(avatarBackgroundView)
// Initials label
avatarInitialsLabel.textAlignment = .center
avatarInitialsLabel.font = .systemFont(ofSize: CellLayout.avatarDiameter * 0.38, weight: .bold)
contentView.addSubview(avatarInitialsLabel)
// Group icon
groupIconView.contentMode = .center
groupIconView.tintColor = .white.withAlphaComponent(0.9)
groupIconView.image = UIImage(systemName: "person.2.fill")?.withConfiguration(
UIImage.SymbolConfiguration(pointSize: 24, weight: .medium)
)
groupIconView.isHidden = true
contentView.addSubview(groupIconView)
// Avatar image (photo, on top of background)
avatarImageView.contentMode = .scaleAspectFill
avatarImageView.clipsToBounds = true
avatarImageView.layer.cornerRadius = CellLayout.avatarDiameter / 2
contentView.addSubview(avatarImageView)
// Online indicator
onlineIndicator.isHidden = true
onlineIndicator.layer.cornerRadius = CellLayout.onlineDotSize / 2
contentView.addSubview(onlineIndicator)
onlineDotInner.layer.cornerRadius = (CellLayout.onlineDotSize - CellLayout.onlineBorderWidth * 2) / 2
onlineDotInner.backgroundColor = UIColor(RosettaColors.primaryBlue)
onlineIndicator.addSubview(onlineDotInner)
// Title
titleLabel.font = .systemFont(ofSize: 16, weight: .medium)
titleLabel.lineBreakMode = .byTruncatingTail
contentView.addSubview(titleLabel)
// Verified badge
verifiedBadge.contentMode = .scaleAspectFit
verifiedBadge.isHidden = true
contentView.addSubview(verifiedBadge)
// Muted icon
mutedIconView.contentMode = .scaleAspectFit
mutedIconView.image = UIImage(systemName: "speaker.slash.fill")?.withConfiguration(
UIImage.SymbolConfiguration(pointSize: 12, weight: .regular)
)
mutedIconView.isHidden = true
contentView.addSubview(mutedIconView)
// Author (group sender name on own line)
authorLabel.font = .systemFont(ofSize: 15, weight: .regular)
authorLabel.lineBreakMode = .byTruncatingTail
authorLabel.isHidden = true
contentView.addSubview(authorLabel)
// Message
messageLabel.font = .systemFont(ofSize: 15, weight: .regular)
messageLabel.numberOfLines = 2
messageLabel.lineBreakMode = .byTruncatingTail
contentView.addSubview(messageLabel)
// Date
dateLabel.font = .systemFont(ofSize: 14, weight: .regular)
dateLabel.textAlignment = .right
contentView.addSubview(dateLabel)
// Status icon (checkmarks)
statusImageView.contentMode = .scaleAspectFit
statusImageView.isHidden = true
contentView.addSubview(statusImageView)
// Badge container (capsule)
badgeContainer.isHidden = true
badgeContainer.layer.cornerRadius = CellLayout.badgeDiameter / 2
contentView.addSubview(badgeContainer)
// Badge label
badgeLabel.font = .monospacedDigitSystemFont(ofSize: 12, weight: .semibold)
badgeLabel.textColor = .white
badgeLabel.textAlignment = .center
badgeContainer.addSubview(badgeLabel)
// Mention badge
mentionBadgeContainer.isHidden = true
mentionBadgeContainer.layer.cornerRadius = CellLayout.badgeDiameter / 2
contentView.addSubview(mentionBadgeContainer)
mentionLabel.font = .systemFont(ofSize: 14, weight: .medium)
mentionLabel.textColor = .white
mentionLabel.text = "@"
mentionLabel.textAlignment = .center
mentionBadgeContainer.addSubview(mentionLabel)
// Pin icon
pinnedIconView.contentMode = .scaleAspectFit
pinnedIconView.image = UIImage(systemName: "pin.fill")?.withConfiguration(
UIImage.SymbolConfiguration(pointSize: 13, weight: .regular)
)
pinnedIconView.isHidden = true
pinnedIconView.transform = CGAffineTransform(rotationAngle: .pi / 4)
contentView.addSubview(pinnedIconView)
// Separator
separatorView.isUserInteractionEnabled = false
contentView.addSubview(separatorView)
}
// MARK: - Layout (manual frame calculation)
override func layoutSubviews() {
super.layoutSubviews()
let w = contentView.bounds.width
let h = contentView.bounds.height
let scale = UIScreen.main.scale
// Avatar
let avatarY = floor((h - CellLayout.avatarDiameter) / 2)
let avatarFrame = CGRect(
x: CellLayout.avatarLeftPadding,
y: avatarY,
width: CellLayout.avatarDiameter,
height: CellLayout.avatarDiameter
)
avatarBackgroundView.frame = avatarFrame
avatarInitialsLabel.frame = avatarFrame
avatarImageView.frame = avatarFrame
groupIconView.frame = avatarFrame
// Online indicator (bottom-right of avatar)
let onlineX = avatarFrame.maxX - CellLayout.onlineDotSize + 1
let onlineY = avatarFrame.maxY - CellLayout.onlineDotSize + 1
onlineIndicator.frame = CGRect(
x: onlineX, y: onlineY,
width: CellLayout.onlineDotSize, height: CellLayout.onlineDotSize
)
onlineDotInner.frame = onlineIndicator.bounds.insetBy(
dx: CellLayout.onlineBorderWidth, dy: CellLayout.onlineBorderWidth
)
// Content area
let contentLeft = CellLayout.contentLeftInset
let contentRight = w - CellLayout.contentRightInset
let contentTop = CellLayout.contentTopOffset
// Top row: [title + icons ...] [status] [date]
// Date measure first (determines title max width)
let dateSize = dateLabel.sizeThatFits(CGSize(width: 120, height: 20))
let dateX = contentRight - dateSize.width
let dateY = contentTop + CellLayout.dateYOffset
dateLabel.frame = CGRect(x: dateX, y: dateY, width: ceil(dateSize.width), height: ceil(dateSize.height))
// Status icon left of date
var titleRightBound = dateX - 6 // gap between title area and date
if !statusImageView.isHidden {
let statusW: CGFloat = CellLayout.statusIconSize
let statusH: CGFloat = CellLayout.statusIconSize
let statusX = dateX - statusW - 2
let statusY = dateY + floor((dateSize.height - statusH) / 2)
statusImageView.frame = CGRect(x: statusX, y: statusY, width: statusW, height: statusH)
titleRightBound = statusX - 4
}
// Title measure within available width
let titleAvailableWidth = titleRightBound - contentLeft
// Account for verified (16+3) and muted (14+3) if visible
var titleIconsWidth: CGFloat = 0
if !verifiedBadge.isHidden { titleIconsWidth += 16 + 3 }
if !mutedIconView.isHidden { titleIconsWidth += 14 + 3 }
let titleMaxWidth = max(0, titleAvailableWidth - titleIconsWidth)
let titleSize = titleLabel.sizeThatFits(CGSize(width: titleMaxWidth, height: 22))
let screenPixelTitle = 1.0 / scale
titleLabel.frame = CGRect(
x: contentLeft, y: contentTop + screenPixelTitle,
width: min(ceil(titleSize.width), titleMaxWidth), height: ceil(titleSize.height)
)
// Verified badge right of title text
var iconX = titleLabel.frame.maxX + 3
if !verifiedBadge.isHidden {
let s: CGFloat = 16
verifiedBadge.frame = CGRect(
x: iconX,
y: contentTop + floor((titleSize.height - s) / 2),
width: s, height: s
)
iconX = verifiedBadge.frame.maxX + 3
}
// Muted icon
if !mutedIconView.isHidden {
let s: CGFloat = 14
mutedIconView.frame = CGRect(
x: iconX,
y: contentTop + floor((titleSize.height - s) / 2),
width: s, height: s
)
}
// Bottom row: [message ...] [badge/pin]
// Badges positioned at bottom of content area
var badgeRightEdge = contentRight
// Telegram: badges aligned with content bottom - 2pt inset
let badgeY = h - CellLayout.badgeDiameter - 10
if !badgeContainer.isHidden {
let textSize = badgeLabel.sizeThatFits(CGSize(width: 100, height: CellLayout.badgeDiameter))
let badgeW = max(CellLayout.badgeDiameter, ceil(textSize.width) + 10)
badgeContainer.frame = CGRect(
x: badgeRightEdge - badgeW, y: badgeY,
width: badgeW, height: CellLayout.badgeDiameter
)
badgeContainer.layer.cornerRadius = CellLayout.badgeDiameter / 2
badgeLabel.frame = badgeContainer.bounds
badgeRightEdge = badgeContainer.frame.minX - CellLayout.badgeSpacing
}
if !mentionBadgeContainer.isHidden {
mentionBadgeContainer.frame = CGRect(
x: badgeRightEdge - CellLayout.badgeDiameter, y: badgeY,
width: CellLayout.badgeDiameter, height: CellLayout.badgeDiameter
)
mentionLabel.frame = mentionBadgeContainer.bounds
badgeRightEdge = mentionBadgeContainer.frame.minX - CellLayout.badgeSpacing
}
if !pinnedIconView.isHidden {
let pinS: CGFloat = 16
pinnedIconView.frame = CGRect(
x: badgeRightEdge - pinS,
y: badgeY + floor((CellLayout.badgeDiameter - pinS) / 2),
width: pinS, height: pinS
)
badgeRightEdge = pinnedIconView.frame.minX - CellLayout.badgeSpacing
}
// Author + Message fixed Y positions from Telegram screenshot
// Measured from Telegram iOS at 76pt cell height, 16pt title, 15pt text:
// 1:1: title ~8pt, message ~27pt
// Group: title ~8pt, author ~27pt, message ~45pt
let textLeft = contentLeft - 1
let messageMaxW = badgeRightEdge - contentLeft
if !authorLabel.isHidden {
let authorSize = authorLabel.sizeThatFits(CGSize(width: messageMaxW, height: 22))
authorLabel.frame = CGRect(
x: textLeft, y: 30,
width: min(ceil(authorSize.width), messageMaxW),
height: 20
)
messageLabel.frame = CGRect(
x: textLeft, y: 50,
width: max(0, messageMaxW), height: 20
)
} else {
authorLabel.frame = .zero
messageLabel.frame = CGRect(
x: textLeft, y: 21,
width: max(0, messageMaxW), height: 38
)
}
// Separator
let separatorHeight = 1.0 / scale
separatorView.frame = CGRect(
x: CellLayout.separatorInset,
y: h - separatorHeight,
width: w - CellLayout.separatorInset,
height: separatorHeight
)
}
// MARK: - Configuration
/// Message text cache (shared across cells, avoids regex per configure).
private static var messageTextCache: [String: String] = [:]
func configure(with dialog: Dialog, isSyncing: Bool) {
let isDark = traitCollection.userInterfaceStyle == .dark
isPinned = dialog.isPinned
// Colors
let titleColor = isDark ? UIColor.white : UIColor.black
let secondaryColor = UIColor(red: 0x8E/255, green: 0x8E/255, blue: 0x93/255, alpha: 1)
let accentBlue = UIColor(RosettaColors.figmaBlue)
let mutedBadgeBg = isDark
? UIColor(red: 0x66/255, green: 0x66/255, blue: 0x66/255, alpha: 1)
: UIColor(red: 0xB6/255, green: 0xB6/255, blue: 0xBB/255, alpha: 1)
let separatorColor = isDark
? UIColor(red: 0x54/255, green: 0x54/255, blue: 0x58/255, alpha: 0.55)
: UIColor(red: 0xC8/255, green: 0xC7/255, blue: 0xCC/255, alpha: 1)
let pinnedBg = isDark
? UIColor(red: 0x1C/255, green: 0x1C/255, blue: 0x1D/255, alpha: 1)
: UIColor(red: 0xF7/255, green: 0xF7/255, blue: 0xF7/255, alpha: 1)
// Background pinned section uses decoration view, cells always clear
contentView.backgroundColor = .clear
// Online indicator background matches section bg
let cellBg = dialog.isPinned ? pinnedBg : (isDark ? UIColor.black : UIColor.white)
onlineIndicator.backgroundColor = cellBg
// Separator
separatorView.backgroundColor = separatorColor
// Avatar
configureAvatar(dialog: dialog, isDark: isDark)
// Online
onlineIndicator.isHidden = !dialog.isOnline || dialog.isSavedMessages
// Title
titleLabel.text = displayTitle(for: dialog)
titleLabel.textColor = titleColor
// Verified
configureVerified(dialog: dialog)
// Muted
mutedIconView.isHidden = !dialog.isMuted
mutedIconView.tintColor = secondaryColor
// Message text (typing is NOT shown in chat list only inside chat detail)
configureMessageText(dialog: dialog, secondaryColor: secondaryColor, titleColor: titleColor)
// Date
dateLabel.text = formatTime(dialog.lastMessageTimestamp)
dateLabel.textColor = (dialog.unreadCount > 0 && !dialog.isMuted) ? accentBlue : secondaryColor
// Delivery status
configureDeliveryStatus(dialog: dialog, secondaryColor: secondaryColor, accentBlue: accentBlue)
// Badge
configureBadge(dialog: dialog, isSyncing: isSyncing, accentBlue: accentBlue, mutedBadgeBg: mutedBadgeBg)
// Pin
pinnedIconView.isHidden = !(dialog.isPinned && dialog.unreadCount == 0)
pinnedIconView.tintColor = secondaryColor
setNeedsLayout()
}
// MARK: - Avatar Configuration
private func configureAvatar(dialog: Dialog, isDark: Bool) {
let colorPair = RosettaColors.avatarColors[dialog.avatarColorIndex % RosettaColors.avatarColors.count]
let image = dialog.isSavedMessages ? nil : AvatarRepository.shared.loadAvatar(publicKey: dialog.opponentKey)
// Reset visibility
avatarBackgroundView.isHidden = false
avatarImageView.isHidden = true
avatarInitialsLabel.isHidden = true
groupIconView.isHidden = true
if dialog.isSavedMessages {
avatarBackgroundView.backgroundColor = UIColor(RosettaColors.primaryBlue)
groupIconView.isHidden = false
groupIconView.image = UIImage(systemName: "bookmark.fill")?.withConfiguration(
UIImage.SymbolConfiguration(pointSize: CellLayout.avatarDiameter * 0.38, weight: .semibold)
)
groupIconView.tintColor = .white
} else if let image {
avatarImageView.image = image
avatarImageView.isHidden = false
avatarBackgroundView.isHidden = true
} else if dialog.isGroup {
avatarBackgroundView.backgroundColor = UIColor(colorPair.tint)
groupIconView.isHidden = false
groupIconView.image = UIImage(systemName: "person.2.fill")?.withConfiguration(
UIImage.SymbolConfiguration(pointSize: 24, weight: .medium)
)
groupIconView.tintColor = .white.withAlphaComponent(0.9)
} else {
// Initials Mantine "light" variant (matches AvatarView.swift)
let mantineDarkBody = UIColor(red: 0x1A/255, green: 0x1B/255, blue: 0x1E/255, alpha: 1)
let baseColor = isDark ? mantineDarkBody : .white
let tintUIColor = UIColor(colorPair.tint)
let tintAlpha: CGFloat = isDark ? 0.15 : 0.10
avatarBackgroundView.backgroundColor = baseColor.blended(with: tintUIColor, alpha: tintAlpha)
avatarInitialsLabel.isHidden = false
avatarInitialsLabel.text = dialog.initials
avatarInitialsLabel.font = .systemFont(
ofSize: CellLayout.avatarDiameter * 0.38, weight: .bold
).rounded()
avatarInitialsLabel.textColor = isDark
? UIColor(colorPair.text)
: tintUIColor
}
}
// MARK: - Verified Badge
/// Cached verified badge images (rendered once from SVG paths).
private static var verifiedImageCache: [Int: UIImage] = [:]
private static func verifiedImage(level: Int, size: CGFloat) -> UIImage {
if let cached = verifiedImageCache[level] { return cached }
let pathData: String
switch level {
case 2: pathData = TablerIconPath.shieldCheckFilled
case 3...: pathData = TablerIconPath.arrowBadgeDownFilled
default: pathData = TablerIconPath.rosetteDiscountCheckFilled
}
let image = renderSVGPath(pathData, viewBox: CGSize(width: 24, height: 24), size: CGSize(width: size, height: size))
verifiedImageCache[level] = image
return image
}
private func configureVerified(dialog: Dialog) {
let level = dialog.effectiveVerified
guard level > 0 && !dialog.isSavedMessages else {
verifiedBadge.isHidden = true
return
}
verifiedBadge.isHidden = false
verifiedBadge.image = Self.verifiedImage(level: level, size: 16)
.withRenderingMode(.alwaysTemplate)
switch level {
case 1:
verifiedBadge.tintColor = UIColor(RosettaColors.primaryBlue)
case 2:
verifiedBadge.tintColor = UIColor(RosettaColors.success)
default:
verifiedBadge.tintColor = UIColor(red: 1, green: 215/255, blue: 0, alpha: 1)
}
}
// MARK: - Delivery Status
/// Cached checkmark images (rendered once from SwiftUI Shapes).
private static let singleCheckImage: UIImage = renderShape(SingleCheckmarkShape(), size: CGSize(width: 14, height: 10.3))
private static let doubleCheckImage: UIImage = renderShape(DoubleCheckmarkShape(), size: CGSize(width: 17, height: 9.3))
/// Cached error indicator (Telegram: red circle with white exclamation).
private static let errorImage: UIImage = {
let size = CGSize(width: 16, height: 16)
let renderer = UIGraphicsImageRenderer(size: size)
return renderer.image { ctx in
// Red circle
let circlePath = UIBezierPath(ovalIn: CGRect(origin: .zero, size: size))
UIColor(red: 0xFF/255, green: 0x3B/255, blue: 0x30/255, alpha: 1).setFill()
circlePath.fill()
// White exclamation mark
let lineWidth: CGFloat = 1.8
let topY: CGFloat = 3.5
let bottomY: CGFloat = 10
let dotY: CGFloat = 12.5
let centerX = size.width / 2
// Stem
let stem = UIBezierPath()
stem.move(to: CGPoint(x: centerX, y: topY))
stem.addLine(to: CGPoint(x: centerX, y: bottomY))
stem.lineWidth = lineWidth
stem.lineCapStyle = .round
UIColor.white.setStroke()
stem.stroke()
// Dot
let dotSize: CGFloat = 2.0
let dotRect = CGRect(x: centerX - dotSize/2, y: dotY, width: dotSize, height: dotSize)
let dot = UIBezierPath(ovalIn: dotRect)
UIColor.white.setFill()
dot.fill()
}
}()
private func configureDeliveryStatus(dialog: Dialog, secondaryColor: UIColor, accentBlue: UIColor) {
guard dialog.lastMessageFromMe && !dialog.isSavedMessages else {
statusImageView.isHidden = true
return
}
if dialog.lastMessageDelivered == .delivered && dialog.lastMessageRead {
// Read blue double checkmarks
statusImageView.isHidden = false
statusImageView.image = Self.doubleCheckImage.withRenderingMode(.alwaysTemplate)
statusImageView.tintColor = accentBlue
} else if dialog.lastMessageDelivered == .error {
// Error red indicator
statusImageView.isHidden = false
statusImageView.image = Self.errorImage
statusImageView.tintColor = nil
} else {
// Waiting / delivered but not read hide (Telegram doesn't show in chat list)
statusImageView.isHidden = true
}
}
// MARK: - Badge
private func configureBadge(dialog: Dialog, isSyncing: Bool, accentBlue: UIColor, mutedBadgeBg: UIColor) {
let count = dialog.unreadCount
let showBadge = count > 0 && !isSyncing
if showBadge {
let text: String
if count > 999 { text = "\(count / 1000)K" }
else if count > 99 { text = "99+" }
else { text = "\(count)" }
badgeLabel.text = text
badgeContainer.backgroundColor = dialog.isMuted ? mutedBadgeBg : accentBlue
}
// Animate badge appear/disappear (Telegram: scale spring)
animateBadgeTransition(view: badgeContainer, shouldShow: showBadge, wasVisible: &wasBadgeVisible)
// Mention badge
let showMention = dialog.hasMention && count > 0 && !isSyncing
if showMention {
mentionBadgeContainer.backgroundColor = dialog.isMuted ? mutedBadgeBg : accentBlue
}
animateBadgeTransition(view: mentionBadgeContainer, shouldShow: showMention, wasVisible: &wasMentionBadgeVisible)
}
/// Telegram badge animation: appear = scale 0.00011.2 (0.2s) 1.0 (0.12s settle);
/// disappear = scale 1.00.0001 (0.12s). Uses transform (not frame) + .allowUserInteraction.
private func animateBadgeTransition(view: UIView, shouldShow: Bool, wasVisible: inout Bool) {
let wasShowing = wasVisible
wasVisible = shouldShow
if shouldShow && !wasShowing {
// Appear: pop in with bounce
view.isHidden = false
view.transform = CGAffineTransform(scaleX: 0.0001, y: 0.0001)
UIView.animate(withDuration: 0.2, delay: 0, options: [.curveEaseOut, .allowUserInteraction]) {
view.transform = CGAffineTransform(scaleX: 1.15, y: 1.15)
} completion: { _ in
UIView.animate(withDuration: 0.12, delay: 0, options: [.curveEaseInOut, .allowUserInteraction]) {
view.transform = .identity
}
}
} else if !shouldShow && wasShowing {
// Disappear: scale down
UIView.animate(withDuration: 0.12, delay: 0, options: [.curveEaseIn, .allowUserInteraction]) {
view.transform = CGAffineTransform(scaleX: 0.0001, y: 0.0001)
} completion: { finished in
if finished {
view.isHidden = true
view.transform = .identity
}
}
} else {
// No transition just set visibility
view.isHidden = !shouldShow
view.transform = .identity
}
}
// MARK: - Message Text
private func configureMessageText(dialog: Dialog, secondaryColor: UIColor, titleColor: UIColor) {
let text = resolveMessageText(dialog: dialog)
// Group chats: sender name on SEPARATE LINE (Telegram layout)
// authorNameColor = white (dark) / black (light)
let showAuthor = dialog.isGroup && !dialog.isSavedMessages
&& !dialog.lastMessageSenderKey.isEmpty && !text.isEmpty
if showAuthor {
let senderName: String
if dialog.lastMessageFromMe {
senderName = "You"
} else {
let lookup = DialogRepository.shared.dialogs[dialog.lastMessageSenderKey]
senderName = lookup?.opponentTitle.isEmpty == false
? lookup!.opponentTitle
: String(dialog.lastMessageSenderKey.prefix(8))
}
authorLabel.isHidden = false
authorLabel.text = senderName
authorLabel.textColor = titleColor
messageLabel.numberOfLines = 1 // 1 line when author shown
} else {
authorLabel.isHidden = true
messageLabel.numberOfLines = 2
}
messageLabel.attributedText = nil
messageLabel.text = text
messageLabel.textColor = secondaryColor
}
private func resolveMessageText(dialog: Dialog) -> String {
let raw = dialog.lastMessage.trimmingCharacters(in: .whitespacesAndNewlines)
if raw.isEmpty { return "No messages yet" }
if raw.hasPrefix("#group:") { return "Group invite" }
if Self.looksLikeCiphertext(raw) { return "No messages yet" }
if let cached = Self.messageTextCache[dialog.lastMessage] { return cached }
let cleaned = dialog.lastMessage.replacingOccurrences(of: "**", with: "")
let result = EmojiParser.replaceShortcodes(in: cleaned)
if Self.messageTextCache.count > 500 {
let keys = Array(Self.messageTextCache.keys.prefix(250))
for key in keys { Self.messageTextCache.removeValue(forKey: key) }
}
Self.messageTextCache[dialog.lastMessage] = result
return result
}
private static func looksLikeCiphertext(_ text: String) -> Bool {
if text.hasPrefix("CHNK:") { return true }
let parts = text.components(separatedBy: ":")
if parts.count == 2 {
let base64Chars = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "+/="))
let bothBase64 = parts.allSatisfy { part in
part.count >= 16 && part.unicodeScalars.allSatisfy { base64Chars.contains($0) }
}
if bothBase64 { return true }
}
if text.count >= 40 {
let hex = CharacterSet(charactersIn: "0123456789abcdefABCDEF")
if text.unicodeScalars.allSatisfy({ hex.contains($0) }) { return true }
}
return false
}
// MARK: - Display Title
private func displayTitle(for dialog: Dialog) -> String {
if dialog.isSavedMessages { return "Saved Messages" }
if dialog.isGroup {
let meta = GroupRepository.shared.groupMetadata(
account: dialog.account,
groupDialogKey: dialog.opponentKey
)
if let title = meta?.title, !title.isEmpty { return title }
}
if !dialog.opponentTitle.isEmpty { return dialog.opponentTitle }
if !dialog.opponentUsername.isEmpty { return "@\(dialog.opponentUsername)" }
return String(dialog.opponentKey.prefix(12))
}
// MARK: - Time Formatting
private static let timeFormatter: DateFormatter = {
let f = DateFormatter(); f.dateFormat = "h:mm a"; return f
}()
private static let dayFormatter: DateFormatter = {
let f = DateFormatter(); f.dateFormat = "EEE"; return f
}()
private static let dateFormatter: DateFormatter = {
let f = DateFormatter(); f.dateFormat = "dd.MM.yy"; return f
}()
private static var timeStringCache: [Int64: String] = [:]
private func formatTime(_ timestamp: Int64) -> String {
guard timestamp > 0 else { return "" }
if let cached = Self.timeStringCache[timestamp] { return cached }
let date = Date(timeIntervalSince1970: Double(timestamp) / 1000)
let now = Date()
let cal = Calendar.current
let result: String
if cal.isDateInToday(date) {
result = Self.timeFormatter.string(from: date)
} else if cal.isDateInYesterday(date) {
result = "Yesterday"
} else if let days = cal.dateComponents([.day], from: date, to: now).day, days < 7 {
result = Self.dayFormatter.string(from: date)
} else {
result = Self.dateFormatter.string(from: date)
}
if Self.timeStringCache.count > 500 {
let keys = Array(Self.timeStringCache.keys.prefix(250))
for key in keys { Self.timeStringCache.removeValue(forKey: key) }
}
Self.timeStringCache[timestamp] = result
return result
}
// MARK: - Reuse
override func prepareForReuse() {
super.prepareForReuse()
avatarImageView.image = nil
avatarImageView.isHidden = true
avatarBackgroundView.isHidden = false
avatarInitialsLabel.isHidden = false
groupIconView.isHidden = true
verifiedBadge.isHidden = true
mutedIconView.isHidden = true
statusImageView.isHidden = true
badgeContainer.isHidden = true
mentionBadgeContainer.isHidden = true
pinnedIconView.isHidden = true
onlineIndicator.isHidden = true
contentView.backgroundColor = .clear
messageLabel.attributedText = nil
messageLabel.numberOfLines = 2
authorLabel.isHidden = true
// Badge animation state
wasBadgeVisible = false
wasMentionBadgeVisible = false
badgeContainer.transform = .identity
mentionBadgeContainer.transform = .identity
}
// MARK: - Highlight
override var isHighlighted: Bool {
didSet {
let isDark = traitCollection.userInterfaceStyle == .dark
let pinnedBg = isDark
? UIColor(red: 0x1C/255, green: 0x1C/255, blue: 0x1D/255, alpha: 1)
: UIColor(red: 0xF7/255, green: 0xF7/255, blue: 0xF7/255, alpha: 1)
// Telegram-exact: #E5E5EA light, #121212 dark (normal); #E5E5EA/#2B2B2C (pinned)
let highlightBg: UIColor
if isPinned {
highlightBg = isDark
? UIColor(red: 0x2B/255, green: 0x2B/255, blue: 0x2C/255, alpha: 1)
: UIColor(red: 0xE5/255, green: 0xE5/255, blue: 0xEA/255, alpha: 1)
} else {
highlightBg = isDark
? UIColor(red: 0x12/255, green: 0x12/255, blue: 0x12/255, alpha: 1)
: UIColor(red: 0xE5/255, green: 0xE5/255, blue: 0xEA/255, alpha: 1)
}
if isHighlighted {
contentView.backgroundColor = highlightBg
} else {
// Telegram: 0.3s delay + 0.7s ease-out fade
// Pinned section background is handled by decoration view, so always fade to clear
UIView.animate(withDuration: 0.7, delay: 0.3, options: .curveEaseOut) {
self.contentView.backgroundColor = .clear
}
}
}
}
// MARK: - Separator Visibility
func setSeparatorHidden(_ hidden: Bool) {
separatorView.isHidden = hidden
}
}
// MARK: - UIFont Rounded Helper
private extension UIFont {
func rounded() -> UIFont {
guard let descriptor = fontDescriptor.withDesign(.rounded) else { return self }
return UIFont(descriptor: descriptor, size: 0)
}
}
// MARK: - UIColor Blending Helper
private extension UIColor {
func blended(with color: UIColor, alpha: CGFloat) -> UIColor {
var r1: CGFloat = 0, g1: CGFloat = 0, b1: CGFloat = 0, a1: CGFloat = 0
var r2: CGFloat = 0, g2: CGFloat = 0, b2: CGFloat = 0, a2: CGFloat = 0
getRed(&r1, green: &g1, blue: &b1, alpha: &a1)
color.getRed(&r2, green: &g2, blue: &b2, alpha: &a2)
return UIColor(
red: r1 * (1 - alpha) + r2 * alpha,
green: g1 * (1 - alpha) + g2 * alpha,
blue: b1 * (1 - alpha) + b2 * alpha,
alpha: 1
)
}
}
// MARK: - Shape UIImage Rendering
/// Renders a SwiftUI Shape into a UIImage (used for checkmarks).
private func renderShape<S: Shape>(_ shape: S, size: CGSize) -> UIImage {
let renderer = UIGraphicsImageRenderer(size: size)
return renderer.image { ctx in
let path = shape.path(in: CGRect(origin: .zero, size: size))
let cgPath = path.cgPath
ctx.cgContext.addPath(cgPath)
ctx.cgContext.setFillColor(UIColor.black.cgColor)
ctx.cgContext.fillPath()
}
}
/// Renders an SVG path string into a UIImage (used for verified badges).
private func renderSVGPath(_ pathData: String, viewBox: CGSize, size: CGSize) -> UIImage {
let renderer = UIGraphicsImageRenderer(size: size)
return renderer.image { ctx in
let scale = CGAffineTransform(
scaleX: size.width / viewBox.width,
y: size.height / viewBox.height
)
let svgPath = SVGPathShape(pathData: pathData, viewBox: viewBox)
let swiftUIPath = svgPath.path(in: CGRect(origin: .zero, size: size))
ctx.cgContext.addPath(swiftUIPath.cgPath)
ctx.cgContext.setFillColor(UIColor.black.cgColor)
ctx.cgContext.fillPath()
}
}

View File

@@ -0,0 +1,530 @@
import UIKit
import SwiftUI
// MARK: - ChatListCollectionController
/// UIViewController hosting a UICollectionView for the chat list.
/// Uses DiffableDataSource for smooth animated updates and manual-frame ChatListCell
/// for Telegram-level scroll performance.
///
/// Integrates into SwiftUI via `ChatListCollectionView` (UIViewControllerRepresentable).
final class ChatListCollectionController: UIViewController {
// MARK: - Sections
enum Section: Int, CaseIterable {
case requests
case pinned
case unpinned
}
// MARK: - Callbacks (to SwiftUI)
var onSelectDialog: ((Dialog) -> Void)?
var onDeleteDialog: ((Dialog) -> Void)?
var onTogglePin: ((Dialog) -> Void)?
var onToggleMute: ((Dialog) -> Void)?
var onPinnedStateChange: ((Bool) -> Void)?
var onShowRequests: (() -> Void)?
var onScrollToTopRequested: (() -> Void)?
var onScrollOffsetChange: ((CGFloat) -> Void)?
var onMarkAsRead: ((Dialog) -> Void)?
// MARK: - Data
private(set) var pinnedDialogs: [Dialog] = []
private(set) var unpinnedDialogs: [Dialog] = []
private(set) var requestsCount: Int = 0
private(set) var typingDialogs: [String: Set<String>] = [:]
private(set) var isSyncing: Bool = false
private var lastReportedExpansion: CGFloat = 1.0
// MARK: - UI
private var collectionView: UICollectionView!
private var dataSource: UICollectionViewDiffableDataSource<Section, String>!
private var cellRegistration: UICollectionView.CellRegistration<ChatListCell, Dialog>!
private var requestsCellRegistration: UICollectionView.CellRegistration<ChatListRequestsCell, Int>!
// Dialog lookup by ID for cell configuration
private var dialogMap: [String: Dialog] = [:]
// MARK: - Lifecycle
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .clear
setupCollectionView()
setupCellRegistrations()
setupDataSource()
setupScrollToTop()
}
// MARK: - Collection View Setup
private func setupCollectionView() {
let layout = createLayout()
collectionView = UICollectionView(frame: .zero, collectionViewLayout: layout)
collectionView.translatesAutoresizingMaskIntoConstraints = false
collectionView.backgroundColor = .clear
collectionView.delegate = self
collectionView.prefetchDataSource = self
collectionView.keyboardDismissMode = .onDrag
collectionView.showsVerticalScrollIndicator = false
collectionView.alwaysBounceVertical = true
collectionView.contentInsetAdjustmentBehavior = .automatic
// Bottom inset so last cells aren't hidden behind tab bar
collectionView.contentInset.bottom = 80
view.addSubview(collectionView)
NSLayoutConstraint.activate([
collectionView.topAnchor.constraint(equalTo: view.topAnchor),
collectionView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
collectionView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
collectionView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
])
}
private func createLayout() -> UICollectionViewCompositionalLayout {
var listConfig = UICollectionLayoutListConfiguration(appearance: .plain)
listConfig.showsSeparators = false
listConfig.backgroundColor = .clear
// Swipe actions
listConfig.trailingSwipeActionsConfigurationProvider = { [weak self] indexPath in
self?.trailingSwipeActions(for: indexPath)
}
listConfig.leadingSwipeActionsConfigurationProvider = { [weak self] indexPath in
self?.leadingSwipeActions(for: indexPath)
}
let layout = UICollectionViewCompositionalLayout { [weak self] sectionIndex, environment in
let section = NSCollectionLayoutSection.list(using: listConfig, layoutEnvironment: environment)
section.interGroupSpacing = 0
// Add pinned section background decoration
if let self,
sectionIndex < self.dataSource?.snapshot().sectionIdentifiers.count ?? 0,
self.dataSource?.snapshot().sectionIdentifiers[sectionIndex] == .pinned {
let bgItem = NSCollectionLayoutDecorationItem.background(
elementKind: PinnedSectionBackgroundView.elementKind
)
section.decorationItems = [bgItem]
}
return section
}
layout.register(
PinnedSectionBackgroundView.self,
forDecorationViewOfKind: PinnedSectionBackgroundView.elementKind
)
return layout
}
// MARK: - Cell Registrations
private func setupCellRegistrations() {
cellRegistration = UICollectionView.CellRegistration<ChatListCell, Dialog> {
[weak self] cell, indexPath, dialog in
guard let self else { return }
cell.configure(with: dialog, isSyncing: self.isSyncing)
// Hide separator for first cell in first dialog section
let isFirstDialogSection = (self.sectionForIndexPath(indexPath) == .pinned && self.requestsCount == 0)
|| (self.sectionForIndexPath(indexPath) == .unpinned && self.pinnedDialogs.isEmpty && self.requestsCount == 0)
cell.setSeparatorHidden(indexPath.item == 0 && isFirstDialogSection)
}
requestsCellRegistration = UICollectionView.CellRegistration<ChatListRequestsCell, Int> {
cell, indexPath, count in
cell.configure(count: count)
}
}
// MARK: - Data Source
private func setupDataSource() {
dataSource = UICollectionViewDiffableDataSource<Section, String>(
collectionView: collectionView
) { [weak self] collectionView, indexPath, itemId in
guard let self else { return UICollectionViewCell() }
// CRITICAL: use sectionIdentifier, NOT rawValue mapping.
// When sections are skipped (e.g. no requests), indexPath.section=0
// could be .pinned, not .requests. rawValue mapping would be wrong.
let section = self.dataSource.snapshot().sectionIdentifiers[indexPath.section]
if section == .requests {
return collectionView.dequeueConfiguredReusableCell(
using: self.requestsCellRegistration,
for: indexPath,
item: self.requestsCount
)
}
guard let dialog = self.dialogMap[itemId] else {
return UICollectionViewCell()
}
return collectionView.dequeueConfiguredReusableCell(
using: self.cellRegistration,
for: indexPath,
item: dialog
)
}
}
// MARK: - Update Data
func updateDialogs(pinned: [Dialog], unpinned: [Dialog], requestsCount: Int,
typingDialogs: [String: Set<String>], isSyncing: Bool) {
self.typingDialogs = typingDialogs
self.isSyncing = isSyncing
// Check if structure changed (IDs or order)
let oldPinnedIds = self.pinnedDialogs.map(\.id)
let oldUnpinnedIds = self.unpinnedDialogs.map(\.id)
let newPinnedIds = pinned.map(\.id)
let newUnpinnedIds = unpinned.map(\.id)
let structureChanged = oldPinnedIds != newPinnedIds
|| oldUnpinnedIds != newUnpinnedIds
|| self.requestsCount != requestsCount
self.pinnedDialogs = pinned
self.unpinnedDialogs = unpinned
self.requestsCount = requestsCount
// Build lookup map
dialogMap.removeAll(keepingCapacity: true)
for d in pinned { dialogMap[d.id] = d }
for d in unpinned { dialogMap[d.id] = d }
if structureChanged {
// Structure changed rebuild snapshot (animate inserts/deletes/moves)
var snapshot = NSDiffableDataSourceSnapshot<Section, String>()
if requestsCount > 0 {
snapshot.appendSections([.requests])
snapshot.appendItems(["__requests__"], toSection: .requests)
}
if !pinned.isEmpty {
snapshot.appendSections([.pinned])
snapshot.appendItems(newPinnedIds, toSection: .pinned)
}
snapshot.appendSections([.unpinned])
snapshot.appendItems(newUnpinnedIds, toSection: .unpinned)
dataSource.apply(snapshot, animatingDifferences: true)
}
// Always reconfigure ONLY visible cells (cheap just updates content, no layout rebuild)
reconfigureVisibleCells()
// Notify SwiftUI about pinned state
DispatchQueue.main.async { [weak self] in
self?.onPinnedStateChange?(!pinned.isEmpty)
}
}
/// Directly reconfigure only visible cells no snapshot rebuild, no animation.
/// This is the cheapest way to update cell content (online, read status, badges).
private func reconfigureVisibleCells() {
for cell in collectionView.visibleCells {
guard let indexPath = collectionView.indexPath(for: cell) else { continue }
guard let itemId = dataSource.itemIdentifier(for: indexPath) else { continue }
if let chatCell = cell as? ChatListCell, let dialog = dialogMap[itemId] {
chatCell.configure(with: dialog, isSyncing: isSyncing)
} else if let reqCell = cell as? ChatListRequestsCell {
reqCell.configure(count: requestsCount)
}
}
}
// MARK: - Scroll to Top
private func setupScrollToTop() {
NotificationCenter.default.addObserver(
self, selector: #selector(handleScrollToTop),
name: .chatListScrollToTop, object: nil
)
}
@objc private func handleScrollToTop() {
guard collectionView.numberOfSections > 0,
collectionView.numberOfItems(inSection: 0) > 0 else { return }
collectionView.scrollToItem(
at: IndexPath(item: 0, section: 0),
at: .top,
animated: true
)
// Reset search bar expansion
lastReportedExpansion = 1.0
onScrollOffsetChange?(1.0)
}
// MARK: - Swipe Actions
private func sectionForIndexPath(_ indexPath: IndexPath) -> Section? {
let identifiers = dataSource.snapshot().sectionIdentifiers
guard indexPath.section < identifiers.count else { return nil }
return identifiers[indexPath.section]
}
private func trailingSwipeActions(for indexPath: IndexPath) -> UISwipeActionsConfiguration? {
guard let section = sectionForIndexPath(indexPath),
section != .requests else { return nil }
let dialog = dialogForIndexPath(indexPath)
guard let dialog else { return nil }
// Delete
let delete = UIContextualAction(style: .destructive, title: nil) { [weak self] _, _, completion in
DispatchQueue.main.async { self?.onDeleteDialog?(dialog) }
completion(true)
}
delete.image = UIImage(systemName: "trash.fill")
delete.backgroundColor = UIColor(red: 1, green: 0.23, blue: 0.19, alpha: 1)
// Mute/Unmute (skip for Saved Messages)
guard !dialog.isSavedMessages else {
return UISwipeActionsConfiguration(actions: [delete])
}
let mute = UIContextualAction(style: .normal, title: nil) { [weak self] _, _, completion in
DispatchQueue.main.async { self?.onToggleMute?(dialog) }
completion(true)
}
mute.image = UIImage(systemName: dialog.isMuted ? "bell.fill" : "bell.slash.fill")
mute.backgroundColor = dialog.isMuted
? UIColor.systemGreen
: UIColor(red: 1, green: 0.58, blue: 0, alpha: 1) // orange
return UISwipeActionsConfiguration(actions: [delete, mute])
}
private func leadingSwipeActions(for indexPath: IndexPath) -> UISwipeActionsConfiguration? {
guard let section = sectionForIndexPath(indexPath),
section != .requests else { return nil }
let dialog = dialogForIndexPath(indexPath)
guard let dialog else { return nil }
let pin = UIContextualAction(style: .normal, title: nil) { [weak self] _, _, completion in
DispatchQueue.main.async { self?.onTogglePin?(dialog) }
completion(true)
}
pin.image = UIImage(systemName: dialog.isPinned ? "pin.slash.fill" : "pin.fill")
pin.backgroundColor = UIColor(red: 1, green: 0.58, blue: 0, alpha: 1) // orange
let config = UISwipeActionsConfiguration(actions: [pin])
config.performsFirstActionWithFullSwipe = true
return config
}
private func dialogForIndexPath(_ indexPath: IndexPath) -> Dialog? {
guard let itemId = dataSource.itemIdentifier(for: indexPath) else { return nil }
return dialogMap[itemId]
}
}
// MARK: - UICollectionViewDelegate
extension ChatListCollectionController: UICollectionViewDelegate {
// MARK: - Scroll-Linked Search Bar (Telegram: 54pt collapse distance)
func scrollViewDidScroll(_ scrollView: UIScrollView) {
// Only react to user-driven scroll, not programmatic/layout changes
guard scrollView.isDragging || scrollView.isDecelerating else { return }
let offset = scrollView.contentOffset.y + scrollView.adjustedContentInset.top
let expansion = max(0.0, min(1.0, 1.0 - offset / 54.0))
guard abs(expansion - lastReportedExpansion) > 0.005 else { return }
lastReportedExpansion = expansion
onScrollOffsetChange?(expansion)
}
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
collectionView.deselectItem(at: indexPath, animated: true)
guard let section = sectionForIndexPath(indexPath) else { return }
if section == .requests {
DispatchQueue.main.async { [weak self] in
self?.onShowRequests?()
}
return
}
guard let dialog = dialogForIndexPath(indexPath) else { return }
DispatchQueue.main.async { [weak self] in
self?.onSelectDialog?(dialog)
}
}
// MARK: - Context Menu (Long Press)
func collectionView(
_ collectionView: UICollectionView,
contextMenuConfigurationForItemAt indexPath: IndexPath,
point: CGPoint
) -> UIContextMenuConfiguration? {
guard let section = sectionForIndexPath(indexPath),
section != .requests,
let dialog = dialogForIndexPath(indexPath) else { return nil }
return UIContextMenuConfiguration(identifier: nil, previewProvider: nil) { [weak self] _ in
guard let self else { return nil }
let pinTitle = dialog.isPinned ? "Unpin" : "Pin"
let pinImage = UIImage(systemName: dialog.isPinned ? "pin.slash" : "pin")
let pinAction = UIAction(title: pinTitle, image: pinImage) { [weak self] _ in
DispatchQueue.main.async { self?.onTogglePin?(dialog) }
}
var actions: [UIAction] = [pinAction]
if !dialog.isSavedMessages {
let muteTitle = dialog.isMuted ? "Unmute" : "Mute"
let muteImage = UIImage(systemName: dialog.isMuted ? "bell" : "bell.slash")
let muteAction = UIAction(title: muteTitle, image: muteImage) { [weak self] _ in
DispatchQueue.main.async { self?.onToggleMute?(dialog) }
}
actions.append(muteAction)
}
if dialog.unreadCount > 0 {
let readAction = UIAction(
title: "Mark as Read",
image: UIImage(systemName: "checkmark.message")
) { [weak self] _ in
DispatchQueue.main.async { self?.onMarkAsRead?(dialog) }
}
actions.append(readAction)
}
let deleteAction = UIAction(
title: "Delete",
image: UIImage(systemName: "trash"),
attributes: .destructive
) { [weak self] _ in
DispatchQueue.main.async { self?.onDeleteDialog?(dialog) }
}
actions.append(deleteAction)
return UIMenu(children: actions)
}
}
}
// MARK: - UICollectionViewDataSourcePrefetching
extension ChatListCollectionController: UICollectionViewDataSourcePrefetching {
func collectionView(_ collectionView: UICollectionView, prefetchItemsAt indexPaths: [IndexPath]) {
for indexPath in indexPaths {
guard let itemId = dataSource.itemIdentifier(for: indexPath),
let dialog = dialogMap[itemId],
!dialog.isSavedMessages else { continue }
// Warm avatar cache on background queue
let key = dialog.opponentKey
DispatchQueue.global(qos: .userInitiated).async {
_ = AvatarRepository.shared.loadAvatar(publicKey: key)
}
}
}
}
// MARK: - Request Chats Cell
/// Simple cell for "Request Chats" row at the top (like Telegram's Archived Chats).
final class ChatListRequestsCell: UICollectionViewCell {
private let avatarCircle = UIView()
private let iconView = UIImageView()
private let titleLabel = UILabel()
private let subtitleLabel = UILabel()
private let separatorView = UIView()
override init(frame: CGRect) {
super.init(frame: frame)
setupSubviews()
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
private func setupSubviews() {
backgroundColor = .clear
contentView.backgroundColor = .clear
avatarCircle.backgroundColor = UIColor(RosettaColors.primaryBlue)
avatarCircle.layer.cornerRadius = 30
avatarCircle.clipsToBounds = true
contentView.addSubview(avatarCircle)
iconView.image = UIImage(systemName: "tray.and.arrow.down")?.withConfiguration(
UIImage.SymbolConfiguration(pointSize: 24, weight: .medium)
)
iconView.tintColor = .white
iconView.contentMode = .center
contentView.addSubview(iconView)
titleLabel.font = .systemFont(ofSize: 16, weight: .medium)
titleLabel.text = "Request Chats"
contentView.addSubview(titleLabel)
subtitleLabel.font = .systemFont(ofSize: 15, weight: .regular)
contentView.addSubview(subtitleLabel)
separatorView.isUserInteractionEnabled = false
contentView.addSubview(separatorView)
}
override func layoutSubviews() {
super.layoutSubviews()
let h = contentView.bounds.height
let w = contentView.bounds.width
let avatarY = floor((h - 60) / 2)
avatarCircle.frame = CGRect(x: 10, y: avatarY, width: 60, height: 60)
iconView.frame = avatarCircle.frame
titleLabel.frame = CGRect(x: 80, y: 14, width: w - 96, height: 22)
subtitleLabel.frame = CGRect(x: 80, y: 36, width: w - 96, height: 20)
let sepH = 1.0 / UIScreen.main.scale
separatorView.frame = CGRect(x: 80, y: h - sepH, width: w - 80, height: sepH)
}
func configure(count: Int) {
let isDark = traitCollection.userInterfaceStyle == .dark
titleLabel.textColor = isDark ? .white : .black
subtitleLabel.text = count == 1 ? "1 request" : "\(count) requests"
subtitleLabel.textColor = UIColor(red: 0x8E/255, green: 0x8E/255, blue: 0x93/255, alpha: 1)
separatorView.backgroundColor = isDark
? UIColor(red: 0x54/255, green: 0x54/255, blue: 0x58/255, alpha: 0.55)
: UIColor(red: 0xC8/255, green: 0xC7/255, blue: 0xCC/255, alpha: 1)
}
override func preferredLayoutAttributesFitting(
_ layoutAttributes: UICollectionViewLayoutAttributes
) -> UICollectionViewLayoutAttributes {
let attrs = layoutAttributes.copy() as! UICollectionViewLayoutAttributes
attrs.size.height = 76
return attrs
}
}
// MARK: - ChatListCell Self-Sizing Override
extension ChatListCell {
override func preferredLayoutAttributesFitting(
_ layoutAttributes: UICollectionViewLayoutAttributes
) -> UICollectionViewLayoutAttributes {
let attrs = layoutAttributes.copy() as! UICollectionViewLayoutAttributes
attrs.size.height = ChatListCell.CellLayout.itemHeight
return attrs
}
}

View File

@@ -0,0 +1,70 @@
import SwiftUI
// MARK: - ChatListCollectionView
/// SwiftUI bridge wrapping `ChatListCollectionController` (UIKit UICollectionView).
///
/// Follows the same bridge pattern as `RosettaTabBarContainer` and `NativeMessageList`:
/// - Callbacks deferred via `DispatchQueue.main.async` to avoid SwiftUI layout-pass blocking.
/// - Data updates via `updateUIViewController` trigger DiffableDataSource snapshot apply.
struct ChatListCollectionView: UIViewControllerRepresentable {
// MARK: - Data
let pinnedDialogs: [Dialog]
let unpinnedDialogs: [Dialog]
let requestsCount: Int
let typingDialogs: [String: Set<String>]
let isSyncing: Bool
let isLoading: Bool
// MARK: - Callbacks
var onSelectDialog: ((Dialog) -> Void)?
var onDeleteDialog: ((Dialog) -> Void)?
var onTogglePin: ((Dialog) -> Void)?
var onToggleMute: ((Dialog) -> Void)?
var onPinnedStateChange: ((Bool) -> Void)?
var onShowRequests: (() -> Void)?
var onScrollOffsetChange: ((CGFloat) -> Void)?
var onMarkAsRead: ((Dialog) -> Void)?
// MARK: - UIViewControllerRepresentable
func makeUIViewController(context: Context) -> ChatListCollectionController {
let controller = ChatListCollectionController()
controller.onSelectDialog = onSelectDialog
controller.onDeleteDialog = onDeleteDialog
controller.onTogglePin = onTogglePin
controller.onToggleMute = onToggleMute
controller.onPinnedStateChange = onPinnedStateChange
controller.onShowRequests = onShowRequests
controller.onScrollOffsetChange = onScrollOffsetChange
controller.onMarkAsRead = onMarkAsRead
return controller
}
func updateUIViewController(_ controller: ChatListCollectionController, context: Context) {
// Update callbacks (closures may capture new state)
controller.onSelectDialog = onSelectDialog
controller.onDeleteDialog = onDeleteDialog
controller.onTogglePin = onTogglePin
controller.onToggleMute = onToggleMute
controller.onPinnedStateChange = onPinnedStateChange
controller.onShowRequests = onShowRequests
controller.onScrollOffsetChange = onScrollOffsetChange
controller.onMarkAsRead = onMarkAsRead
// Skip data update if loading (shimmer is shown by SwiftUI)
guard !isLoading else { return }
// Update data and apply snapshot
controller.updateDialogs(
pinned: pinnedDialogs,
unpinned: unpinnedDialogs,
requestsCount: requestsCount,
typingDialogs: typingDialogs,
isSyncing: isSyncing
)
}
}

View File

@@ -0,0 +1,34 @@
import UIKit
// MARK: - PinnedSectionBackgroundView
/// Section-level decoration view for the pinned section.
/// Displays Telegram-exact pinned background color edge-to-edge behind all pinned rows.
/// Registered as a decoration item in UICollectionViewCompositionalLayout.
final class PinnedSectionBackgroundView: UICollectionReusableView {
static let elementKind = "pinned-section-background"
override init(frame: CGRect) {
super.init(frame: frame)
updateColor()
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
super.traitCollectionDidChange(previousTraitCollection)
if traitCollection.userInterfaceStyle != previousTraitCollection?.userInterfaceStyle {
updateColor()
}
}
private func updateColor() {
let isDark = traitCollection.userInterfaceStyle == .dark
backgroundColor = isDark
? UIColor(red: 0x1C/255, green: 0x1C/255, blue: 0x1D/255, alpha: 1) // Telegram: pinnedItemBackgroundColor
: UIColor(red: 0xF7/255, green: 0xF7/255, blue: 0xF7/255, alpha: 1)
}
}