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

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", "v5_full_schema_superset_parity",
"v6_bidirectional_alias_sync", "v6_bidirectional_alias_sync",
"v7_sync_cursor_reconcile_and_perf_indexes", "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 migrationV7SyncCursorReconcile = "v7_sync_cursor_reconcile_and_perf_indexes"
nonisolated static let migrationV8LastMessageSenderKey = "v8_last_message_sender_key"
private var dbPool: DatabasePool? private var dbPool: DatabasePool?
private var currentAccount: String = "" 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) try migrator.migrate(pool)
dbPool = pool dbPool = pool

View File

@@ -23,6 +23,7 @@ struct DialogRecord: Codable, FetchableRecord, MutablePersistableRecord, Sendabl
var lastMessageFromMe: Int var lastMessageFromMe: Int
var lastMessageDelivered: Int var lastMessageDelivered: Int
var lastMessageRead: Int var lastMessageRead: Int
var lastMessageSenderKey: String
// MARK: - Column mapping // MARK: - Column mapping
@@ -43,6 +44,7 @@ struct DialogRecord: Codable, FetchableRecord, MutablePersistableRecord, Sendabl
case lastMessageFromMe = "last_message_from_me" case lastMessageFromMe = "last_message_from_me"
case lastMessageDelivered = "last_message_delivered" case lastMessageDelivered = "last_message_delivered"
case lastMessageRead = "last_message_read" case lastMessageRead = "last_message_read"
case lastMessageSenderKey = "last_message_sender_key"
} }
enum CodingKeys: String, CodingKey { enum CodingKeys: String, CodingKey {
@@ -62,6 +64,7 @@ struct DialogRecord: Codable, FetchableRecord, MutablePersistableRecord, Sendabl
case lastMessageFromMe = "last_message_from_me" case lastMessageFromMe = "last_message_from_me"
case lastMessageDelivered = "last_message_delivered" case lastMessageDelivered = "last_message_delivered"
case lastMessageRead = "last_message_read" case lastMessageRead = "last_message_read"
case lastMessageSenderKey = "last_message_sender_key"
} }
// MARK: - Auto-increment // MARK: - Auto-increment
@@ -73,7 +76,7 @@ struct DialogRecord: Codable, FetchableRecord, MutablePersistableRecord, Sendabl
// MARK: - Conversions // MARK: - Conversions
func toDialog() -> Dialog { func toDialog() -> Dialog {
Dialog( var d = Dialog(
id: opponentKey, id: opponentKey,
account: account, account: account,
opponentKey: opponentKey, opponentKey: opponentKey,
@@ -92,6 +95,8 @@ struct DialogRecord: Codable, FetchableRecord, MutablePersistableRecord, Sendabl
lastMessageDelivered: DeliveryStatus(rawValue: lastMessageDelivered) ?? .waiting, lastMessageDelivered: DeliveryStatus(rawValue: lastMessageDelivered) ?? .waiting,
lastMessageRead: lastMessageRead != 0 lastMessageRead: lastMessageRead != 0
) )
d.lastMessageSenderKey = lastMessageSenderKey
return d
} }
static func from(_ dialog: Dialog) -> DialogRecord { static func from(_ dialog: Dialog) -> DialogRecord {
@@ -112,7 +117,8 @@ struct DialogRecord: Codable, FetchableRecord, MutablePersistableRecord, Sendabl
isMuted: dialog.isMuted ? 1 : 0, isMuted: dialog.isMuted ? 1 : 0,
lastMessageFromMe: dialog.lastMessageFromMe ? 1 : 0, lastMessageFromMe: dialog.lastMessageFromMe ? 1 : 0,
lastMessageDelivered: dialog.lastMessageDelivered.rawValue, 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. /// Desktop parity: true when an unread group message mentions the current user.
var hasMention: Bool = false var hasMention: Bool = false
/// Sender public key of the last message (for "Alice: hey" group chat preview).
var lastMessageSenderKey: String = ""
// MARK: - Computed // MARK: - Computed
var isSavedMessages: Bool { opponentKey == account } var isSavedMessages: Bool { opponentKey == account }

View File

@@ -166,6 +166,7 @@ final class DialogRepository {
case .avatar: lastMessageText = "Avatar" case .avatar: lastMessageText = "Avatar"
case .messages: lastMessageText = "Forwarded message" case .messages: lastMessageText = "Forwarded message"
case .call: lastMessageText = "Call" case .call: lastMessageText = "Call"
@unknown default: lastMessageText = "Attachment"
} }
} else if textIsEmpty { } else if textIsEmpty {
lastMessageText = "" lastMessageText = ""
@@ -196,6 +197,8 @@ final class DialogRepository {
dialog.lastMessageDelivered = lastFromMe ? lastMsg.deliveryStatus : .delivered dialog.lastMessageDelivered = lastFromMe ? lastMsg.deliveryStatus : .delivered
// Android parity: separate read flag from last outgoing message's is_read column. // Android parity: separate read flag from last outgoing message's is_read column.
dialog.lastMessageRead = lastFromMe ? lastMsg.isRead : false dialog.lastMessageRead = lastFromMe ? lastMsg.isRead : false
// Group sender key for "Alice: hey" chat list preview
dialog.lastMessageSenderKey = lastMsg.fromPublicKey
dialogs[opponentKey] = dialog dialogs[opponentKey] = dialog
_sortedKeysCache = nil _sortedKeysCache = nil
@@ -382,8 +385,8 @@ final class DialogRepository {
INSERT INTO dialogs (account, opponent_key, opponent_title, opponent_username, INSERT INTO dialogs (account, opponent_key, opponent_title, opponent_username,
last_message, last_message_timestamp, unread_count, is_online, last_seen, last_message, last_message_timestamp, unread_count, is_online, last_seen,
verified, i_have_sent, is_pinned, is_muted, last_message_from_me, verified, i_have_sent, is_pinned, is_muted, last_message_from_me,
last_message_delivered, last_message_read) last_message_delivered, last_message_read, last_message_sender_key)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(account, opponent_key) DO UPDATE SET ON CONFLICT(account, opponent_key) DO UPDATE SET
opponent_title = excluded.opponent_title, opponent_title = excluded.opponent_title,
opponent_username = excluded.opponent_username, opponent_username = excluded.opponent_username,
@@ -398,7 +401,8 @@ final class DialogRepository {
is_muted = excluded.is_muted, is_muted = excluded.is_muted,
last_message_from_me = excluded.last_message_from_me, last_message_from_me = excluded.last_message_from_me,
last_message_delivered = excluded.last_message_delivered, 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: [ arguments: [
dialog.account, dialog.opponentKey, dialog.opponentTitle, dialog.opponentUsername, dialog.account, dialog.opponentKey, dialog.opponentTitle, dialog.opponentUsername,
@@ -406,7 +410,7 @@ final class DialogRepository {
dialog.isOnline ? 1 : 0, dialog.lastSeen, dialog.verified, dialog.isOnline ? 1 : 0, dialog.lastSeen, dialog.verified,
dialog.iHaveSent ? 1 : 0, dialog.isPinned ? 1 : 0, dialog.isMuted ? 1 : 0, dialog.iHaveSent ? 1 : 0, dialog.isPinned ? 1 : 0, dialog.isMuted ? 1 : 0,
dialog.lastMessageFromMe ? 1 : 0, dialog.lastMessageDelivered.rawValue, 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 file = 2
case avatar = 3 case avatar = 3
case call = 4 case call = 4
case voice = 5
/// Android parity: `fromInt() ?: UNKNOWN`. Fallback to `.image` for unknown values /// Android parity: `fromInt() ?: UNKNOWN`. Fallback to `.image` for unknown values
/// so a single unknown type doesn't crash the entire [MessageAttachment] array decode. /// 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 backgroundSecondary = Color(hex: 0xF2F2F7) // iOS system grouped bg
static let surface = Color(hex: 0xF5F5F5) static let surface = Color(hex: 0xF5F5F5)
static let text = Color.black 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 textTertiary = Color(hex: 0x3C3C43).opacity(0.3) // Figma hint gray
static let border = Color(hex: 0xE0E0E0) 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 messageBubble = Color(hex: 0xF5F5F5)
static let messageBubbleOwn = Color(hex: 0xDCF8C6) static let messageBubbleOwn = Color(hex: 0xDCF8C6)
static let inputBackground = Color(hex: 0xF2F3F5) static let inputBackground = Color(hex: 0xF2F3F5)
@@ -66,10 +66,10 @@ enum RosettaColors {
static let pinnedSectionBackground = Color(hex: 0x1C1C1D) static let pinnedSectionBackground = Color(hex: 0x1C1C1D)
static let surface = Color(hex: 0x242424) static let surface = Color(hex: 0x242424)
static let text = Color.white 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 textTertiary = Color(hex: 0x666666)
static let border = Color(hex: 0x2E2E2E) 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 messageBubble = Color(hex: 0x2A2A2A)
static let messageBubbleOwn = Color(hex: 0x263341) static let messageBubbleOwn = Color(hex: 0x263341)
static let inputBackground = Color(hex: 0x2A2A2A) 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 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 inputBackground = RosettaColors.adaptive(light: RosettaColors.Light.inputBackground, dark: RosettaColors.Dark.inputBackground)
static let pinnedSectionBackground = RosettaColors.adaptive( static let pinnedSectionBackground = RosettaColors.adaptive(
light: Color(hex: 0xF2F2F7), light: Color(hex: 0xF7F7F7), // Telegram: pinnedItemBackgroundColor
dark: RosettaColors.Dark.pinnedSectionBackground 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( static let searchBarFill = RosettaColors.adaptive(
light: Color.black.opacity(0.08), light: Color.black.opacity(0.08),
dark: Color.white.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 .avatar: return "Avatar"
case .messages: return "Forwarded message" case .messages: return "Forwarded message"
case .call: return "Call" case .call: return "Call"
@unknown default: return "Attachment"
} }
} }
return nil return nil

View File

@@ -12,6 +12,12 @@ protocol ComposerViewDelegate: AnyObject {
func composerDidCancelReply(_ composer: ComposerView) func composerDidCancelReply(_ composer: ComposerView)
func composerUserDidType(_ composer: ComposerView) func composerUserDidType(_ composer: ComposerView)
func composerKeyboardHeightDidChange(_ composer: ComposerView, height: CGFloat) 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 // MARK: - ComposerView
@@ -63,8 +69,8 @@ final class ComposerView: UIView, UITextViewDelegate {
private let sendButton = UIButton(type: .system) private let sendButton = UIButton(type: .system)
private let sendCapsule = UIView() private let sendCapsule = UIView()
// Mic button (glass circle, 42×42) // Mic button (glass circle, 42×42) custom control for recording gestures
private let micButton = UIButton(type: .system) private let micButton = RecordingMicButton(frame: .zero)
private let micGlass = TelegramGlassUIView(frame: .zero) private let micGlass = TelegramGlassUIView(frame: .zero)
private var attachIconLayer: CAShapeLayer? private var attachIconLayer: CAShapeLayer?
private var emojiIconLayer: CAShapeLayer? private var emojiIconLayer: CAShapeLayer?
@@ -92,6 +98,13 @@ final class ComposerView: UIView, UITextViewDelegate {
private var isSendVisible = false private var isSendVisible = false
private var isUpdatingText = 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 // MARK: - Init
override init(frame: CGRect) { override init(frame: CGRect) {
@@ -230,7 +243,7 @@ final class ComposerView: UIView, UITextViewDelegate {
micButton.layer.addSublayer(micIcon) micButton.layer.addSublayer(micIcon)
micIconLayer = micIcon micIconLayer = micIcon
micButton.tag = 4 micButton.tag = 4
micButton.addTarget(self, action: #selector(micTapped), for: .touchUpInside) micButton.recordingDelegate = self
addSubview(micButton) addSubview(micButton)
updateThemeColors() 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() { @objc private func replyCancelTapped() {
delegate?.composerDidCancelReply(self) 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] 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 // 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 // MARK: - Helpers
/// Generates a random 8-character ID (desktop: `generateRandomKey(8)`). /// 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 showNewGroupSheet = false
@State private var showJoinGroupSheet = false @State private var showJoinGroupSheet = false
@State private var showNewChatActionSheet = false @State private var showNewChatActionSheet = false
@State private var searchBarExpansion: CGFloat = 1.0
@FocusState private var isSearchFocused: Bool @FocusState private var isSearchFocused: Bool
var body: some View { var body: some View {
NavigationStack(path: $navigationState.path) { NavigationStack(path: $navigationState.path) {
VStack(spacing: 0) { VStack(spacing: 0) {
// Custom search bar // Custom search bar collapses on scroll (Telegram: 54pt distance)
customSearchBar customSearchBar
.padding(.horizontal, 16) .padding(.horizontal, 16)
.padding(.top, 12) .padding(.top, isSearchActive ? 8 : 8 * searchBarExpansion)
.padding(.bottom, 8) .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( .background(
(hasPinnedChats && !isSearchActive (hasPinnedChats && !isSearchActive
? RosettaColors.Adaptive.pinnedSectionBackground ? RosettaColors.Adaptive.pinnedSectionBackground
@@ -78,6 +83,9 @@ struct ChatListView: View {
.toolbar(isSearchActive ? .hidden : .visible, for: .navigationBar) .toolbar(isSearchActive ? .hidden : .visible, for: .navigationBar)
.toolbar { toolbarContent } .toolbar { toolbarContent }
.modifier(ChatListToolbarBackgroundModifier()) .modifier(ChatListToolbarBackgroundModifier())
.onChange(of: isSearchActive) { _, _ in
searchBarExpansion = 1.0
}
.onChange(of: searchText) { _, newValue in .onChange(of: searchText) { _, newValue in
viewModel.setSearchQuery(newValue) viewModel.setSearchQuery(newValue)
} }
@@ -166,7 +174,9 @@ struct ChatListView: View {
// MARK: - Cancel Search // MARK: - Cancel Search
private func cancelSearch() { private func cancelSearch() {
isSearchActive = false withAnimation(.easeInOut(duration: 0.3)) {
isSearchActive = false
}
isSearchFocused = false isSearchFocused = false
searchText = "" searchText = ""
viewModel.setSearchQuery("") viewModel.setSearchQuery("")
@@ -229,12 +239,12 @@ private extension ChatListView {
.padding(.horizontal, 12) .padding(.horizontal, 12)
} }
} }
.frame(height: 42) .frame(height: 44)
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
.contentShape(Rectangle()) .contentShape(Rectangle())
.onTapGesture { .onTapGesture {
if !isSearchActive { if !isSearchActive {
withAnimation(.easeInOut(duration: 0.25)) { withAnimation(.easeInOut(duration: 0.14)) {
isSearchActive = true isSearchActive = true
} }
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
@@ -244,20 +254,20 @@ private extension ChatListView {
} }
.background { .background {
if isSearchActive { if isSearchActive {
RoundedRectangle(cornerRadius: 24, style: .continuous) RoundedRectangle(cornerRadius: 22, style: .continuous)
.fill(RosettaColors.Adaptive.searchBarFill) .fill(RosettaColors.Adaptive.searchBarFill)
.overlay { .overlay {
RoundedRectangle(cornerRadius: 24, style: .continuous) RoundedRectangle(cornerRadius: 22, style: .continuous)
.strokeBorder(RosettaColors.Adaptive.searchBarBorder, lineWidth: 0.5) .strokeBorder(RosettaColors.Adaptive.searchBarBorder, lineWidth: 0.5)
} }
} else { } else {
RoundedRectangle(cornerRadius: 24, style: .continuous) RoundedRectangle(cornerRadius: 22, style: .continuous)
.fill(RosettaColors.Adaptive.searchBarFill) .fill(RosettaColors.Adaptive.searchBarFill)
} }
} }
.onChange(of: isSearchFocused) { _, focused in .onChange(of: isSearchFocused) { _, focused in
if focused && !isSearchActive { if focused && !isSearchActive {
withAnimation(.easeInOut(duration: 0.25)) { withAnimation(.easeInOut(duration: 0.14)) {
isSearchActive = true isSearchActive = true
} }
} }
@@ -310,6 +320,9 @@ private extension ChatListView {
hasPinnedChats = pinned hasPinnedChats = pinned
} }
} }
},
onScrollOffsetChange: { expansion in
searchBarExpansion = expansion
} }
) )
} }
@@ -593,6 +606,7 @@ private struct DeviceVerificationContentRouter: View {
@ObservedObject var navigationState: ChatListNavigationState @ObservedObject var navigationState: ChatListNavigationState
var onShowRequests: () -> Void = {} var onShowRequests: () -> Void = {}
var onPinnedStateChange: (Bool) -> Void = { _ in } var onPinnedStateChange: (Bool) -> Void = { _ in }
var onScrollOffsetChange: (CGFloat) -> Void = { _ in }
var body: some View { var body: some View {
let proto = ProtocolManager.shared let proto = ProtocolManager.shared
@@ -611,7 +625,8 @@ private struct DeviceVerificationContentRouter: View {
viewModel: viewModel, viewModel: viewModel,
navigationState: navigationState, navigationState: navigationState,
onShowRequests: onShowRequests, onShowRequests: onShowRequests,
onPinnedStateChange: onPinnedStateChange onPinnedStateChange: onPinnedStateChange,
onScrollOffsetChange: onScrollOffsetChange
) )
} }
} }
@@ -626,6 +641,7 @@ private struct ChatListDialogContent: View {
@ObservedObject var navigationState: ChatListNavigationState @ObservedObject var navigationState: ChatListNavigationState
var onShowRequests: () -> Void = {} var onShowRequests: () -> Void = {}
var onPinnedStateChange: (Bool) -> Void = { _ in } var onPinnedStateChange: (Bool) -> Void = { _ in }
var onScrollOffsetChange: (CGFloat) -> Void = { _ in }
/// Desktop parity: track typing dialogs from MessageRepository (@Published). /// Desktop parity: track typing dialogs from MessageRepository (@Published).
@State private var typingDialogs: [String: Set<String>] = [:] @State private var typingDialogs: [String: Set<String>] = [:]
@@ -659,85 +675,53 @@ private struct ChatListDialogContent: View {
} }
} }
// MARK: - Dialog List // MARK: - Dialog List (UIKit UICollectionView)
private static let topAnchorId = "chatlist_top"
private func dialogList(pinned: [Dialog], unpinned: [Dialog], requestsCount: Int) -> some View { private func dialogList(pinned: [Dialog], unpinned: [Dialog], requestsCount: Int) -> some View {
ScrollViewReader { scrollProxy in Group {
List {
if viewModel.isLoading { if viewModel.isLoading {
ForEach(0..<8, id: \.self) { _ in // Shimmer skeleton during initial load (SwiftUI simple, not perf-critical)
ChatRowShimmerView() List {
.listRowInsets(EdgeInsets()) ForEach(0..<8, id: \.self) { _ in
.listRowBackground(Color.clear) ChatRowShimmerView()
.listRowSeparator(.hidden) .listRowInsets(EdgeInsets())
} .listRowBackground(Color.clear)
} else { .listRowSeparator(.hidden)
// 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 .listStyle(.plain)
chatRow(dialog, isFirst: dialog.id == unpinned.first?.id && pinned.isEmpty && requestsCount == 0) .scrollContentBackground(.hidden)
} } else {
} // UIKit UICollectionView Telegram-level scroll performance
let isSyncing = SessionManager.shared.syncBatchInProgress
Color.clear.frame(height: 80) ChatListCollectionView(
.listRowInsets(EdgeInsets()) pinnedDialogs: pinned,
.listRowBackground(Color.clear) unpinnedDialogs: unpinned,
.listRowSeparator(.hidden) requestsCount: requestsCount,
} typingDialogs: typingDialogs,
.listStyle(.plain) isSyncing: isSyncing,
.scrollContentBackground(.hidden) isLoading: viewModel.isLoading,
.scrollDismissesKeyboard(.immediately) onSelectDialog: { dialog in
.scrollIndicators(.hidden) navigationState.path.append(ChatRoute(dialog: dialog))
.modifier(ClassicSwipeActionsModifier()) },
// Scroll-to-top: tap "Chats" in toolbar onDeleteDialog: { dialog in
.onReceive(NotificationCenter.default.publisher(for: .chatListScrollToTop)) { _ in viewModel.deleteDialog(dialog)
// Scroll to first dialog ID (pinned or unpinned) },
let firstId = pinned.first?.id ?? unpinned.first?.id onTogglePin: { dialog in
if let firstId { viewModel.togglePin(dialog)
withAnimation(.easeOut(duration: 0.3)) { },
scrollProxy.scrollTo(firstId, anchor: .top) 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
)
} }
} }

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)
}
}