Голосовые сообщения - анимация кнопки микрофона + панель записи с таймером
This commit is contained in:
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
|||||||
@@ -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
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
187
Rosetta/Core/Services/AudioRecorder.swift
Normal file
187
Rosetta/Core/Services/AudioRecorder.swift
Normal 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
|||||||
398
Rosetta/DesignSystem/Components/VoiceBlobView.swift
Normal file
398
Rosetta/DesignSystem/Components/VoiceBlobView.swift
Normal 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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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 alpha→0, accessories alpha→0)
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)`).
|
||||||
|
|||||||
290
Rosetta/Features/Chats/ChatDetail/RecordingMicButton.swift
Normal file
290
Rosetta/Features/Chats/ChatDetail/RecordingMicButton.swift
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
212
Rosetta/Features/Chats/ChatDetail/VoiceRecordingOverlay.swift
Normal file
212
Rosetta/Features/Chats/ChatDetail/VoiceRecordingOverlay.swift
Normal 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 (back→front): 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, scale→0.2, alpha→0)
|
||||||
|
|
||||||
|
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() }
|
||||||
|
}
|
||||||
294
Rosetta/Features/Chats/ChatDetail/VoiceRecordingPanel.swift
Normal file
294
Rosetta/Features/Chats/ChatDetail/VoiceRecordingPanel.swift
Normal 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.4–0.5s, dot/timer slide from left, cancel from right.
|
||||||
|
func animateIn(panelWidth: CGFloat) {
|
||||||
|
// Red dot: appear with scale 0.3→1.0, alpha 0→1, 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
886
Rosetta/Features/Chats/ChatList/UIKit/ChatListCell.swift
Normal file
886
Rosetta/Features/Chats/ChatList/UIKit/ChatListCell.swift
Normal 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.0001→1.2 (0.2s) → 1.0 (0.12s settle);
|
||||||
|
/// disappear = scale 1.0→0.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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user