From 667ba06967da64d3fd516a258a207991387f7403 Mon Sep 17 00:00:00 2001 From: senseiGai Date: Sat, 11 Apr 2026 01:46:09 +0500 Subject: [PATCH] =?UTF-8?q?=D0=93=D0=BE=D0=BB=D0=BE=D1=81=D0=BE=D0=B2?= =?UTF-8?q?=D1=8B=D0=B5=20=D1=81=D0=BE=D0=BE=D0=B1=D1=89=D0=B5=D0=BD=D0=B8?= =?UTF-8?q?=D1=8F=20-=20=D0=B0=D0=BD=D0=B8=D0=BC=D0=B0=D1=86=D0=B8=D1=8F?= =?UTF-8?q?=20=D0=BA=D0=BD=D0=BE=D0=BF=D0=BA=D0=B8=20=D0=BC=D0=B8=D0=BA?= =?UTF-8?q?=D1=80=D0=BE=D1=84=D0=BE=D0=BD=D0=B0=20+=20=D0=BF=D0=B0=D0=BD?= =?UTF-8?q?=D0=B5=D0=BB=D1=8C=20=D0=B7=D0=B0=D0=BF=D0=B8=D1=81=D0=B8=20?= =?UTF-8?q?=D1=81=20=D1=82=D0=B0=D0=B9=D0=BC=D0=B5=D1=80=D0=BE=D0=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Core/Data/Database/DatabaseManager.swift | 11 + Rosetta/Core/Data/Database/DialogRecord.swift | 10 +- Rosetta/Core/Data/Models/Dialog.swift | 3 + .../Data/Repositories/DialogRepository.swift | 12 +- .../Network/Protocol/Packets/Packet.swift | 1 + Rosetta/Core/Services/AudioRecorder.swift | 187 ++++ Rosetta/DesignSystem/Colors.swift | 15 +- .../Components/VoiceBlobView.swift | 398 ++++++++ .../Chats/ChatDetail/ChatDetailView.swift | 1 + .../Chats/ChatDetail/ComposerView.swift | 131 ++- .../Chats/ChatDetail/NativeMessageList.swift | 18 + .../Chats/ChatDetail/PendingAttachment.swift | 13 + .../Chats/ChatDetail/RecordingMicButton.swift | 290 ++++++ .../ChatDetail/VoiceRecordingOverlay.swift | 212 +++++ .../ChatDetail/VoiceRecordingPanel.swift | 294 ++++++ .../Chats/ChatList/ChatListView.swift | 150 ++- .../Chats/ChatList/UIKit/ChatListCell.swift | 886 ++++++++++++++++++ .../UIKit/ChatListCollectionController.swift | 530 +++++++++++ .../UIKit/ChatListCollectionView.swift | 70 ++ .../UIKit/PinnedSectionBackgroundView.swift | 34 + 20 files changed, 3157 insertions(+), 109 deletions(-) create mode 100644 Rosetta/Core/Services/AudioRecorder.swift create mode 100644 Rosetta/DesignSystem/Components/VoiceBlobView.swift create mode 100644 Rosetta/Features/Chats/ChatDetail/RecordingMicButton.swift create mode 100644 Rosetta/Features/Chats/ChatDetail/VoiceRecordingOverlay.swift create mode 100644 Rosetta/Features/Chats/ChatDetail/VoiceRecordingPanel.swift create mode 100644 Rosetta/Features/Chats/ChatList/UIKit/ChatListCell.swift create mode 100644 Rosetta/Features/Chats/ChatList/UIKit/ChatListCollectionController.swift create mode 100644 Rosetta/Features/Chats/ChatList/UIKit/ChatListCollectionView.swift create mode 100644 Rosetta/Features/Chats/ChatList/UIKit/PinnedSectionBackgroundView.swift diff --git a/Rosetta/Core/Data/Database/DatabaseManager.swift b/Rosetta/Core/Data/Database/DatabaseManager.swift index 0ce6029..99dc846 100644 --- a/Rosetta/Core/Data/Database/DatabaseManager.swift +++ b/Rosetta/Core/Data/Database/DatabaseManager.swift @@ -16,8 +16,10 @@ final class DatabaseManager { "v5_full_schema_superset_parity", "v6_bidirectional_alias_sync", "v7_sync_cursor_reconcile_and_perf_indexes", + "v8_last_message_sender_key", ] nonisolated static let migrationV7SyncCursorReconcile = "v7_sync_cursor_reconcile_and_perf_indexes" + nonisolated static let migrationV8LastMessageSenderKey = "v8_last_message_sender_key" private var dbPool: DatabasePool? private var currentAccount: String = "" @@ -783,6 +785,15 @@ final class DatabaseManager { ) } + // MARK: v8 — last_message_sender_key (group chat preview: "Alice: hey") + + migrator.registerMigration("v8_last_message_sender_key") { db in + let cols = Set(try db.columns(in: "dialogs").map(\.name)) + if !cols.contains("last_message_sender_key") { + try db.execute(sql: "ALTER TABLE dialogs ADD COLUMN last_message_sender_key TEXT NOT NULL DEFAULT ''") + } + } + try migrator.migrate(pool) dbPool = pool diff --git a/Rosetta/Core/Data/Database/DialogRecord.swift b/Rosetta/Core/Data/Database/DialogRecord.swift index 4b0d1f5..ef9e3b4 100644 --- a/Rosetta/Core/Data/Database/DialogRecord.swift +++ b/Rosetta/Core/Data/Database/DialogRecord.swift @@ -23,6 +23,7 @@ struct DialogRecord: Codable, FetchableRecord, MutablePersistableRecord, Sendabl var lastMessageFromMe: Int var lastMessageDelivered: Int var lastMessageRead: Int + var lastMessageSenderKey: String // MARK: - Column mapping @@ -43,6 +44,7 @@ struct DialogRecord: Codable, FetchableRecord, MutablePersistableRecord, Sendabl case lastMessageFromMe = "last_message_from_me" case lastMessageDelivered = "last_message_delivered" case lastMessageRead = "last_message_read" + case lastMessageSenderKey = "last_message_sender_key" } enum CodingKeys: String, CodingKey { @@ -62,6 +64,7 @@ struct DialogRecord: Codable, FetchableRecord, MutablePersistableRecord, Sendabl case lastMessageFromMe = "last_message_from_me" case lastMessageDelivered = "last_message_delivered" case lastMessageRead = "last_message_read" + case lastMessageSenderKey = "last_message_sender_key" } // MARK: - Auto-increment @@ -73,7 +76,7 @@ struct DialogRecord: Codable, FetchableRecord, MutablePersistableRecord, Sendabl // MARK: - Conversions func toDialog() -> Dialog { - Dialog( + var d = Dialog( id: opponentKey, account: account, opponentKey: opponentKey, @@ -92,6 +95,8 @@ struct DialogRecord: Codable, FetchableRecord, MutablePersistableRecord, Sendabl lastMessageDelivered: DeliveryStatus(rawValue: lastMessageDelivered) ?? .waiting, lastMessageRead: lastMessageRead != 0 ) + d.lastMessageSenderKey = lastMessageSenderKey + return d } static func from(_ dialog: Dialog) -> DialogRecord { @@ -112,7 +117,8 @@ struct DialogRecord: Codable, FetchableRecord, MutablePersistableRecord, Sendabl isMuted: dialog.isMuted ? 1 : 0, lastMessageFromMe: dialog.lastMessageFromMe ? 1 : 0, lastMessageDelivered: dialog.lastMessageDelivered.rawValue, - lastMessageRead: dialog.lastMessageRead ? 1 : 0 + lastMessageRead: dialog.lastMessageRead ? 1 : 0, + lastMessageSenderKey: dialog.lastMessageSenderKey ) } } diff --git a/Rosetta/Core/Data/Models/Dialog.swift b/Rosetta/Core/Data/Models/Dialog.swift index 1930f76..6950be9 100644 --- a/Rosetta/Core/Data/Models/Dialog.swift +++ b/Rosetta/Core/Data/Models/Dialog.swift @@ -56,6 +56,9 @@ struct Dialog: Identifiable, Codable, Equatable { /// Desktop parity: true when an unread group message mentions the current user. var hasMention: Bool = false + /// Sender public key of the last message (for "Alice: hey" group chat preview). + var lastMessageSenderKey: String = "" + // MARK: - Computed var isSavedMessages: Bool { opponentKey == account } diff --git a/Rosetta/Core/Data/Repositories/DialogRepository.swift b/Rosetta/Core/Data/Repositories/DialogRepository.swift index dd81a28..b388c2d 100644 --- a/Rosetta/Core/Data/Repositories/DialogRepository.swift +++ b/Rosetta/Core/Data/Repositories/DialogRepository.swift @@ -166,6 +166,7 @@ final class DialogRepository { case .avatar: lastMessageText = "Avatar" case .messages: lastMessageText = "Forwarded message" case .call: lastMessageText = "Call" + @unknown default: lastMessageText = "Attachment" } } else if textIsEmpty { lastMessageText = "" @@ -196,6 +197,8 @@ final class DialogRepository { dialog.lastMessageDelivered = lastFromMe ? lastMsg.deliveryStatus : .delivered // Android parity: separate read flag from last outgoing message's is_read column. dialog.lastMessageRead = lastFromMe ? lastMsg.isRead : false + // Group sender key for "Alice: hey" chat list preview + dialog.lastMessageSenderKey = lastMsg.fromPublicKey dialogs[opponentKey] = dialog _sortedKeysCache = nil @@ -382,8 +385,8 @@ final class DialogRepository { INSERT INTO dialogs (account, opponent_key, opponent_title, opponent_username, last_message, last_message_timestamp, unread_count, is_online, last_seen, verified, i_have_sent, is_pinned, is_muted, last_message_from_me, - last_message_delivered, last_message_read) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + last_message_delivered, last_message_read, last_message_sender_key) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(account, opponent_key) DO UPDATE SET opponent_title = excluded.opponent_title, opponent_username = excluded.opponent_username, @@ -398,7 +401,8 @@ final class DialogRepository { is_muted = excluded.is_muted, last_message_from_me = excluded.last_message_from_me, last_message_delivered = excluded.last_message_delivered, - last_message_read = excluded.last_message_read + last_message_read = excluded.last_message_read, + last_message_sender_key = excluded.last_message_sender_key """, arguments: [ dialog.account, dialog.opponentKey, dialog.opponentTitle, dialog.opponentUsername, @@ -406,7 +410,7 @@ final class DialogRepository { dialog.isOnline ? 1 : 0, dialog.lastSeen, dialog.verified, dialog.iHaveSent ? 1 : 0, dialog.isPinned ? 1 : 0, dialog.isMuted ? 1 : 0, dialog.lastMessageFromMe ? 1 : 0, dialog.lastMessageDelivered.rawValue, - dialog.lastMessageRead ? 1 : 0 + dialog.lastMessageRead ? 1 : 0, dialog.lastMessageSenderKey ] ) } diff --git a/Rosetta/Core/Network/Protocol/Packets/Packet.swift b/Rosetta/Core/Network/Protocol/Packets/Packet.swift index 2ecae78..e7a4f0a 100644 --- a/Rosetta/Core/Network/Protocol/Packets/Packet.swift +++ b/Rosetta/Core/Network/Protocol/Packets/Packet.swift @@ -88,6 +88,7 @@ enum AttachmentType: Int, Codable, Sendable { case file = 2 case avatar = 3 case call = 4 + case voice = 5 /// Android parity: `fromInt() ?: UNKNOWN`. Fallback to `.image` for unknown values /// so a single unknown type doesn't crash the entire [MessageAttachment] array decode. diff --git a/Rosetta/Core/Services/AudioRecorder.swift b/Rosetta/Core/Services/AudioRecorder.swift new file mode 100644 index 0000000..25e67d4 --- /dev/null +++ b/Rosetta/Core/Services/AudioRecorder.swift @@ -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() + } + } +} diff --git a/Rosetta/DesignSystem/Colors.swift b/Rosetta/DesignSystem/Colors.swift index 69872af..0d2248b 100644 --- a/Rosetta/DesignSystem/Colors.swift +++ b/Rosetta/DesignSystem/Colors.swift @@ -48,10 +48,10 @@ enum RosettaColors { static let backgroundSecondary = Color(hex: 0xF2F2F7) // iOS system grouped bg static let surface = Color(hex: 0xF5F5F5) static let text = Color.black - static let textSecondary = Color(hex: 0x3C3C43).opacity(0.6) // Figma subtitle gray + static let textSecondary = Color(hex: 0x8E8E93) // Telegram: dateTextColor/messageTextColor static let textTertiary = Color(hex: 0x3C3C43).opacity(0.3) // Figma hint gray static let border = Color(hex: 0xE0E0E0) - static let divider = Color(hex: 0xEEEEEE) + static let divider = Color(hex: 0xC8C7CC) // Telegram: itemSeparatorColor static let messageBubble = Color(hex: 0xF5F5F5) static let messageBubbleOwn = Color(hex: 0xDCF8C6) static let inputBackground = Color(hex: 0xF2F3F5) @@ -66,10 +66,10 @@ enum RosettaColors { static let pinnedSectionBackground = Color(hex: 0x1C1C1D) static let surface = Color(hex: 0x242424) static let text = Color.white - static let textSecondary = Color(hex: 0x8E8E93) + static let textSecondary = Color(hex: 0x8D8E93) // Telegram: dateTextColor/messageTextColor static let textTertiary = Color(hex: 0x666666) static let border = Color(hex: 0x2E2E2E) - static let divider = Color(hex: 0x333333) + static let divider = Color(UIColor(red: 0x54/255, green: 0x54/255, blue: 0x58/255, alpha: 0.55)) // Telegram: 0x545458 @ 55% static let messageBubble = Color(hex: 0x2A2A2A) static let messageBubbleOwn = Color(hex: 0x263341) static let inputBackground = Color(hex: 0x2A2A2A) @@ -98,9 +98,14 @@ enum RosettaColors { static let messageBubbleOwn = RosettaColors.adaptive(light: RosettaColors.Light.messageBubbleOwn, dark: RosettaColors.Dark.messageBubbleOwn) static let inputBackground = RosettaColors.adaptive(light: RosettaColors.Light.inputBackground, dark: RosettaColors.Dark.inputBackground) static let pinnedSectionBackground = RosettaColors.adaptive( - light: Color(hex: 0xF2F2F7), + light: Color(hex: 0xF7F7F7), // Telegram: pinnedItemBackgroundColor dark: RosettaColors.Dark.pinnedSectionBackground ) + /// Muted badge background (Telegram: unreadBadgeInactiveBackgroundColor) + static let badgeInactive = RosettaColors.adaptive( + light: Color(hex: 0xB6B6BB), + dark: Color(hex: 0x666666) + ) static let searchBarFill = RosettaColors.adaptive( light: Color.black.opacity(0.08), dark: Color.white.opacity(0.08) diff --git a/Rosetta/DesignSystem/Components/VoiceBlobView.swift b/Rosetta/DesignSystem/Components/VoiceBlobView.swift new file mode 100644 index 0000000..1743c97 --- /dev/null +++ b/Rosetta/DesignSystem/Components/VoiceBlobView.swift @@ -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.. 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.. 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)) + } + } +} diff --git a/Rosetta/Features/Chats/ChatDetail/ChatDetailView.swift b/Rosetta/Features/Chats/ChatDetail/ChatDetailView.swift index 8889a27..ab3c047 100644 --- a/Rosetta/Features/Chats/ChatDetail/ChatDetailView.swift +++ b/Rosetta/Features/Chats/ChatDetail/ChatDetailView.swift @@ -1502,6 +1502,7 @@ private extension ChatDetailView { case .avatar: return "Avatar" case .messages: return "Forwarded message" case .call: return "Call" + @unknown default: return "Attachment" } } return nil diff --git a/Rosetta/Features/Chats/ChatDetail/ComposerView.swift b/Rosetta/Features/Chats/ChatDetail/ComposerView.swift index f71ae14..efb8d55 100644 --- a/Rosetta/Features/Chats/ChatDetail/ComposerView.swift +++ b/Rosetta/Features/Chats/ChatDetail/ComposerView.swift @@ -12,6 +12,12 @@ protocol ComposerViewDelegate: AnyObject { func composerDidCancelReply(_ composer: ComposerView) func composerUserDidType(_ composer: ComposerView) func composerKeyboardHeightDidChange(_ composer: ComposerView, height: CGFloat) + + // Voice recording + func composerDidStartRecording(_ composer: ComposerView) + func composerDidFinishRecording(_ composer: ComposerView, sendImmediately: Bool) + func composerDidCancelRecording(_ composer: ComposerView) + func composerDidLockRecording(_ composer: ComposerView) } // MARK: - ComposerView @@ -63,8 +69,8 @@ final class ComposerView: UIView, UITextViewDelegate { private let sendButton = UIButton(type: .system) private let sendCapsule = UIView() - // Mic button (glass circle, 42×42) - private let micButton = UIButton(type: .system) + // Mic button (glass circle, 42×42) — custom control for recording gestures + private let micButton = RecordingMicButton(frame: .zero) private let micGlass = TelegramGlassUIView(frame: .zero) private var attachIconLayer: CAShapeLayer? private var emojiIconLayer: CAShapeLayer? @@ -92,6 +98,13 @@ final class ComposerView: UIView, UITextViewDelegate { private var isSendVisible = false private var isUpdatingText = false + // MARK: - Voice Recording + + private let audioRecorder = AudioRecorder() + private var recordingOverlay: VoiceRecordingOverlay? + private var recordingPanel: VoiceRecordingPanel? + private(set) var isRecording = false + // MARK: - Init override init(frame: CGRect) { @@ -230,7 +243,7 @@ final class ComposerView: UIView, UITextViewDelegate { micButton.layer.addSublayer(micIcon) micIconLayer = micIcon micButton.tag = 4 - micButton.addTarget(self, action: #selector(micTapped), for: .touchUpInside) + micButton.recordingDelegate = self addSubview(micButton) updateThemeColors() @@ -583,19 +596,107 @@ final class ComposerView: UIView, UITextViewDelegate { } } - @objc private func micTapped() { - // Mic = placeholder for voice messages, acts as send when there's content - let text = (textView.text ?? "").trimmingCharacters(in: .whitespacesAndNewlines) - if !text.isEmpty { - delegate?.composerDidTapSend(self) - } else { - if !textView.isFirstResponder { - textView.becomeFirstResponder() - } - } - } - @objc private func replyCancelTapped() { delegate?.composerDidCancelReply(self) } } + +// MARK: - RecordingMicButtonDelegate + +extension ComposerView: RecordingMicButtonDelegate { + + func micButtonRecordingBegan(_ button: RecordingMicButton) { + guard audioRecorder.startRecording() else { return } + isRecording = true + guard let window else { return } + + // 1. Overlay circles on mic button + let overlay = VoiceRecordingOverlay() + overlay.present(anchorView: micButton, in: window) + recordingOverlay = overlay + + // 2. Recording panel (spans full width: attach area to mic button) + let panelX = horizontalPadding + let panelW = micButton.frame.minX - innerSpacing - horizontalPadding + let panel = VoiceRecordingPanel(frame: CGRect( + x: panelX, + y: inputContainer.frame.origin.y, + width: panelW, + height: inputContainer.frame.height + )) + panel.delegate = self + addSubview(panel) + panel.animateIn(panelWidth: panelW) + recordingPanel = panel + + // 3. Feed audio level → overlay + timer + audioRecorder.onLevelUpdate = { [weak self] duration, level in + self?.recordingOverlay?.addMicLevel(CGFloat(level)) + self?.recordingPanel?.updateDuration(duration) + } + + // 4. Hide composer content (Telegram: textInput 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() + } +} diff --git a/Rosetta/Features/Chats/ChatDetail/NativeMessageList.swift b/Rosetta/Features/Chats/ChatDetail/NativeMessageList.swift index 9c12a3d..1b5a222 100644 --- a/Rosetta/Features/Chats/ChatDetail/NativeMessageList.swift +++ b/Rosetta/Features/Chats/ChatDetail/NativeMessageList.swift @@ -1425,6 +1425,24 @@ extension NativeMessageListController: ComposerViewDelegate { userInfo: ["height": height] ) } + + // MARK: - Voice Recording + + func composerDidStartRecording(_ composer: ComposerView) { + // Recording started — handled by ComposerView internally + } + + func composerDidFinishRecording(_ composer: ComposerView, sendImmediately: Bool) { + // Recording finished — will be wired to send pipeline later + } + + func composerDidCancelRecording(_ composer: ComposerView) { + // Recording cancelled — no action needed + } + + func composerDidLockRecording(_ composer: ComposerView) { + // Recording locked — UI handled by ComposerView + } } // MARK: - PreSizedCell diff --git a/Rosetta/Features/Chats/ChatDetail/PendingAttachment.swift b/Rosetta/Features/Chats/ChatDetail/PendingAttachment.swift index 4f9c60b..8d0d246 100644 --- a/Rosetta/Features/Chats/ChatDetail/PendingAttachment.swift +++ b/Rosetta/Features/Chats/ChatDetail/PendingAttachment.swift @@ -59,6 +59,19 @@ struct PendingAttachment: Identifiable, Sendable { ) } + /// Creates a PendingAttachment from a voice recording. + /// Duration in seconds, waveform is normalized [Float] array (0..1). + static func fromVoice(data: Data, duration: TimeInterval, waveform: [Float]) -> PendingAttachment { + return PendingAttachment( + id: generateRandomId(), + type: .voice, + data: data, + thumbnail: nil, + fileName: "voice_\(Int(duration))s.m4a", + fileSize: data.count + ) + } + // MARK: - Helpers /// Generates a random 8-character ID (desktop: `generateRandomKey(8)`). diff --git a/Rosetta/Features/Chats/ChatDetail/RecordingMicButton.swift b/Rosetta/Features/Chats/ChatDetail/RecordingMicButton.swift new file mode 100644 index 0000000..e0c38d6 --- /dev/null +++ b/Rosetta/Features/Chats/ChatDetail/RecordingMicButton.swift @@ -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 + } +} diff --git a/Rosetta/Features/Chats/ChatDetail/VoiceRecordingOverlay.swift b/Rosetta/Features/Chats/ChatDetail/VoiceRecordingOverlay.swift new file mode 100644 index 0000000..7b143ed --- /dev/null +++ b/Rosetta/Features/Chats/ChatDetail/VoiceRecordingOverlay.swift @@ -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() } +} diff --git a/Rosetta/Features/Chats/ChatDetail/VoiceRecordingPanel.swift b/Rosetta/Features/Chats/ChatDetail/VoiceRecordingPanel.swift new file mode 100644 index 0000000..cff9900 --- /dev/null +++ b/Rosetta/Features/Chats/ChatDetail/VoiceRecordingPanel.swift @@ -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) + } +} diff --git a/Rosetta/Features/Chats/ChatList/ChatListView.swift b/Rosetta/Features/Chats/ChatList/ChatListView.swift index 696d80f..70f8b8a 100644 --- a/Rosetta/Features/Chats/ChatList/ChatListView.swift +++ b/Rosetta/Features/Chats/ChatList/ChatListView.swift @@ -35,16 +35,21 @@ struct ChatListView: View { @State private var showNewGroupSheet = false @State private var showJoinGroupSheet = false @State private var showNewChatActionSheet = false + @State private var searchBarExpansion: CGFloat = 1.0 @FocusState private var isSearchFocused: Bool var body: some View { NavigationStack(path: $navigationState.path) { VStack(spacing: 0) { - // Custom search bar + // Custom search bar — collapses on scroll (Telegram: 54pt distance) customSearchBar .padding(.horizontal, 16) - .padding(.top, 12) - .padding(.bottom, 8) + .padding(.top, isSearchActive ? 8 : 8 * searchBarExpansion) + .padding(.bottom, isSearchActive ? 8 : 8 * searchBarExpansion) + .frame(height: isSearchActive ? 60 : max(0, 60 * searchBarExpansion), alignment: .top) + .clipped() + .opacity(isSearchActive ? 1 : Double(searchBarExpansion)) + .allowsHitTesting(isSearchActive || searchBarExpansion > 0.5) .background( (hasPinnedChats && !isSearchActive ? RosettaColors.Adaptive.pinnedSectionBackground @@ -78,6 +83,9 @@ struct ChatListView: View { .toolbar(isSearchActive ? .hidden : .visible, for: .navigationBar) .toolbar { toolbarContent } .modifier(ChatListToolbarBackgroundModifier()) + .onChange(of: isSearchActive) { _, _ in + searchBarExpansion = 1.0 + } .onChange(of: searchText) { _, newValue in viewModel.setSearchQuery(newValue) } @@ -166,7 +174,9 @@ struct ChatListView: View { // MARK: - Cancel Search private func cancelSearch() { - isSearchActive = false + withAnimation(.easeInOut(duration: 0.3)) { + isSearchActive = false + } isSearchFocused = false searchText = "" viewModel.setSearchQuery("") @@ -229,12 +239,12 @@ private extension ChatListView { .padding(.horizontal, 12) } } - .frame(height: 42) + .frame(height: 44) .frame(maxWidth: .infinity) .contentShape(Rectangle()) .onTapGesture { if !isSearchActive { - withAnimation(.easeInOut(duration: 0.25)) { + withAnimation(.easeInOut(duration: 0.14)) { isSearchActive = true } DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { @@ -244,20 +254,20 @@ private extension ChatListView { } .background { if isSearchActive { - RoundedRectangle(cornerRadius: 24, style: .continuous) + RoundedRectangle(cornerRadius: 22, style: .continuous) .fill(RosettaColors.Adaptive.searchBarFill) .overlay { - RoundedRectangle(cornerRadius: 24, style: .continuous) + RoundedRectangle(cornerRadius: 22, style: .continuous) .strokeBorder(RosettaColors.Adaptive.searchBarBorder, lineWidth: 0.5) } } else { - RoundedRectangle(cornerRadius: 24, style: .continuous) + RoundedRectangle(cornerRadius: 22, style: .continuous) .fill(RosettaColors.Adaptive.searchBarFill) } } .onChange(of: isSearchFocused) { _, focused in if focused && !isSearchActive { - withAnimation(.easeInOut(duration: 0.25)) { + withAnimation(.easeInOut(duration: 0.14)) { isSearchActive = true } } @@ -310,6 +320,9 @@ private extension ChatListView { hasPinnedChats = pinned } } + }, + onScrollOffsetChange: { expansion in + searchBarExpansion = expansion } ) } @@ -593,6 +606,7 @@ private struct DeviceVerificationContentRouter: View { @ObservedObject var navigationState: ChatListNavigationState var onShowRequests: () -> Void = {} var onPinnedStateChange: (Bool) -> Void = { _ in } + var onScrollOffsetChange: (CGFloat) -> Void = { _ in } var body: some View { let proto = ProtocolManager.shared @@ -611,7 +625,8 @@ private struct DeviceVerificationContentRouter: View { viewModel: viewModel, navigationState: navigationState, onShowRequests: onShowRequests, - onPinnedStateChange: onPinnedStateChange + onPinnedStateChange: onPinnedStateChange, + onScrollOffsetChange: onScrollOffsetChange ) } } @@ -626,6 +641,7 @@ private struct ChatListDialogContent: View { @ObservedObject var navigationState: ChatListNavigationState var onShowRequests: () -> Void = {} var onPinnedStateChange: (Bool) -> Void = { _ in } + var onScrollOffsetChange: (CGFloat) -> Void = { _ in } /// Desktop parity: track typing dialogs from MessageRepository (@Published). @State private var typingDialogs: [String: Set] = [:] @@ -659,85 +675,53 @@ private struct ChatListDialogContent: View { } } - // MARK: - Dialog List - - private static let topAnchorId = "chatlist_top" + // MARK: - Dialog List (UIKit UICollectionView) private func dialogList(pinned: [Dialog], unpinned: [Dialog], requestsCount: Int) -> some View { - ScrollViewReader { scrollProxy in - List { + Group { if viewModel.isLoading { - ForEach(0..<8, id: \.self) { _ in - ChatRowShimmerView() - .listRowInsets(EdgeInsets()) - .listRowBackground(Color.clear) - .listRowSeparator(.hidden) - } - } else { - // Telegram-style "Request Chats" row at top (like Archived Chats) - if requestsCount > 0 { - RequestChatsRow(count: requestsCount, onTap: onShowRequests) - .listRowInsets(EdgeInsets()) - .listRowBackground(Color.clear) - .listRowSeparator(.visible, edges: .bottom) - .listRowSeparatorTint(RosettaColors.Adaptive.divider) - .alignmentGuide(.listRowSeparatorLeading) { _ in 82 } - } - - if !pinned.isEmpty { - ForEach(pinned, id: \.id) { dialog in - chatRow(dialog, isFirst: dialog.id == pinned.first?.id && requestsCount == 0) - .environment(\.rowBackgroundColor, RosettaColors.Adaptive.pinnedSectionBackground) - .listRowBackground(RosettaColors.Adaptive.pinnedSectionBackground) + // Shimmer skeleton during initial load (SwiftUI — simple, not perf-critical) + List { + ForEach(0..<8, id: \.self) { _ in + ChatRowShimmerView() + .listRowInsets(EdgeInsets()) + .listRowBackground(Color.clear) + .listRowSeparator(.hidden) } } - ForEach(unpinned, id: \.id) { dialog in - chatRow(dialog, isFirst: dialog.id == unpinned.first?.id && pinned.isEmpty && requestsCount == 0) - } - } - - Color.clear.frame(height: 80) - .listRowInsets(EdgeInsets()) - .listRowBackground(Color.clear) - .listRowSeparator(.hidden) - } - .listStyle(.plain) - .scrollContentBackground(.hidden) - .scrollDismissesKeyboard(.immediately) - .scrollIndicators(.hidden) - .modifier(ClassicSwipeActionsModifier()) - // Scroll-to-top: tap "Chats" in toolbar - .onReceive(NotificationCenter.default.publisher(for: .chatListScrollToTop)) { _ in - // Scroll to first dialog ID (pinned or unpinned) - let firstId = pinned.first?.id ?? unpinned.first?.id - if let firstId { - withAnimation(.easeOut(duration: 0.3)) { - scrollProxy.scrollTo(firstId, anchor: .top) - } + .listStyle(.plain) + .scrollContentBackground(.hidden) + } else { + // UIKit UICollectionView — Telegram-level scroll performance + let isSyncing = SessionManager.shared.syncBatchInProgress + ChatListCollectionView( + pinnedDialogs: pinned, + unpinnedDialogs: unpinned, + requestsCount: requestsCount, + typingDialogs: typingDialogs, + isSyncing: isSyncing, + isLoading: viewModel.isLoading, + onSelectDialog: { dialog in + navigationState.path.append(ChatRoute(dialog: dialog)) + }, + onDeleteDialog: { dialog in + viewModel.deleteDialog(dialog) + }, + onTogglePin: { dialog in + viewModel.togglePin(dialog) + }, + onToggleMute: { dialog in + viewModel.toggleMute(dialog) + }, + onPinnedStateChange: onPinnedStateChange, + onShowRequests: onShowRequests, + onScrollOffsetChange: onScrollOffsetChange, + onMarkAsRead: { dialog in + viewModel.markAsRead(dialog) + } + ) } } - } // ScrollViewReader - } - - private func chatRow(_ dialog: Dialog, isFirst: Bool = false) -> some View { - /// Desktop parity: wrap in SyncAwareChatRow to isolate @Observable read - /// of SessionManager.syncBatchInProgress from this view's observation scope. - /// viewModel + navigationState passed as plain `let` (not @ObservedObject) — - /// stable class references don't trigger row re-evaluation on parent re-render. - SyncAwareChatRow( - dialog: dialog, - isTyping: !(typingDialogs[dialog.opponentKey]?.isEmpty ?? true), - typingSenderNames: { - guard let senderKeys = typingDialogs[dialog.opponentKey] else { return [] } - return senderKeys.map { sk in - DialogRepository.shared.dialogs[sk]?.opponentTitle - ?? String(sk.prefix(8)) - } - }(), - isFirst: isFirst, - viewModel: viewModel, - navigationState: navigationState - ) } } diff --git a/Rosetta/Features/Chats/ChatList/UIKit/ChatListCell.swift b/Rosetta/Features/Chats/ChatList/UIKit/ChatListCell.swift new file mode 100644 index 0000000..c7eb3ff --- /dev/null +++ b/Rosetta/Features/Chats/ChatList/UIKit/ChatListCell.swift @@ -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(_ 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() + } +} diff --git a/Rosetta/Features/Chats/ChatList/UIKit/ChatListCollectionController.swift b/Rosetta/Features/Chats/ChatList/UIKit/ChatListCollectionController.swift new file mode 100644 index 0000000..2d8e6da --- /dev/null +++ b/Rosetta/Features/Chats/ChatList/UIKit/ChatListCollectionController.swift @@ -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] = [:] + private(set) var isSyncing: Bool = false + private var lastReportedExpansion: CGFloat = 1.0 + + // MARK: - UI + + private var collectionView: UICollectionView! + private var dataSource: UICollectionViewDiffableDataSource! + private var cellRegistration: UICollectionView.CellRegistration! + private var requestsCellRegistration: UICollectionView.CellRegistration! + + // 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 { + [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 { + cell, indexPath, count in + cell.configure(count: count) + } + } + + // MARK: - Data Source + + private func setupDataSource() { + dataSource = UICollectionViewDiffableDataSource( + 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], 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() + + 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 + } +} diff --git a/Rosetta/Features/Chats/ChatList/UIKit/ChatListCollectionView.swift b/Rosetta/Features/Chats/ChatList/UIKit/ChatListCollectionView.swift new file mode 100644 index 0000000..ec26b42 --- /dev/null +++ b/Rosetta/Features/Chats/ChatList/UIKit/ChatListCollectionView.swift @@ -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] + 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 + ) + } +} diff --git a/Rosetta/Features/Chats/ChatList/UIKit/PinnedSectionBackgroundView.swift b/Rosetta/Features/Chats/ChatList/UIKit/PinnedSectionBackgroundView.swift new file mode 100644 index 0000000..59be20c --- /dev/null +++ b/Rosetta/Features/Chats/ChatList/UIKit/PinnedSectionBackgroundView.swift @@ -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) + } +}