Голосовые сообщения — фиксы lock view, cancel анимация, recording panel UI
This commit is contained in:
@@ -53,6 +53,10 @@ final class ChatListCell: UICollectionViewCell {
|
||||
// Message row
|
||||
let messageLabel = UILabel()
|
||||
|
||||
// Typing indicator
|
||||
let typingDotsView = TypingDotsView()
|
||||
let typingLabel = UILabel()
|
||||
|
||||
// Trailing column
|
||||
let dateLabel = UILabel()
|
||||
let statusImageView = UIImageView()
|
||||
@@ -151,6 +155,14 @@ final class ChatListCell: UICollectionViewCell {
|
||||
messageLabel.lineBreakMode = .byTruncatingTail
|
||||
contentView.addSubview(messageLabel)
|
||||
|
||||
// Typing indicator (hidden by default)
|
||||
typingDotsView.isHidden = true
|
||||
contentView.addSubview(typingDotsView)
|
||||
|
||||
typingLabel.font = .systemFont(ofSize: 15, weight: .regular)
|
||||
typingLabel.isHidden = true
|
||||
contentView.addSubview(typingLabel)
|
||||
|
||||
// Date
|
||||
dateLabel.font = .systemFont(ofSize: 14, weight: .regular)
|
||||
dateLabel.textAlignment = .right
|
||||
@@ -348,6 +360,17 @@ final class ChatListCell: UICollectionViewCell {
|
||||
)
|
||||
}
|
||||
|
||||
// ── Typing indicator ──
|
||||
// Y=30 so visual center matches 2-line message visual center (~40pt).
|
||||
// Dots (16pt) centered within text height (20pt) → Y+2.
|
||||
if !typingDotsView.isHidden {
|
||||
let dotsW: CGFloat = 24
|
||||
let dotsH: CGFloat = 16
|
||||
let typingY: CGFloat = 30
|
||||
typingLabel.frame = CGRect(x: textLeft + dotsW - 2, y: typingY, width: max(0, messageMaxW - dotsW + 2), height: 20)
|
||||
typingDotsView.frame = CGRect(x: textLeft, y: typingY + 2, width: dotsW, height: dotsH)
|
||||
}
|
||||
|
||||
// ── Separator ──
|
||||
let separatorHeight = 1.0 / scale
|
||||
separatorView.frame = CGRect(
|
||||
@@ -363,7 +386,7 @@ final class ChatListCell: UICollectionViewCell {
|
||||
/// Message text cache (shared across cells, avoids regex per configure).
|
||||
private static var messageTextCache: [String: String] = [:]
|
||||
|
||||
func configure(with dialog: Dialog, isSyncing: Bool) {
|
||||
func configure(with dialog: Dialog, isSyncing: Bool, typingUsers: Set<String>? = nil) {
|
||||
let isDark = traitCollection.userInterfaceStyle == .dark
|
||||
isPinned = dialog.isPinned
|
||||
|
||||
@@ -408,8 +431,14 @@ final class ChatListCell: UICollectionViewCell {
|
||||
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)
|
||||
// Message text or typing indicator
|
||||
let activeTyping = typingUsers.flatMap { $0.isEmpty ? nil : $0 }
|
||||
if let typers = activeTyping {
|
||||
configureTypingIndicator(dialog: dialog, typingUsers: typers, color: secondaryColor)
|
||||
} else {
|
||||
hideTypingIndicator()
|
||||
configureMessageText(dialog: dialog, secondaryColor: secondaryColor, titleColor: titleColor)
|
||||
}
|
||||
|
||||
// Date
|
||||
dateLabel.text = formatTime(dialog.lastMessageTimestamp)
|
||||
@@ -667,6 +696,49 @@ final class ChatListCell: UICollectionViewCell {
|
||||
messageLabel.textColor = secondaryColor
|
||||
}
|
||||
|
||||
// MARK: - Typing Indicator
|
||||
|
||||
private func configureTypingIndicator(dialog: Dialog, typingUsers: Set<String>, color: UIColor) {
|
||||
// Hide normal message content
|
||||
messageLabel.isHidden = true
|
||||
authorLabel.isHidden = true
|
||||
|
||||
// Show typing
|
||||
typingDotsView.isHidden = false
|
||||
typingDotsView.dotColor = color
|
||||
typingDotsView.startAnimating()
|
||||
|
||||
typingLabel.isHidden = false
|
||||
typingLabel.textColor = color
|
||||
|
||||
if dialog.isGroup {
|
||||
let names = typingUsers.prefix(2).map { key -> String in
|
||||
if let d = DialogRepository.shared.dialogs[key], !d.opponentTitle.isEmpty {
|
||||
return d.opponentTitle
|
||||
}
|
||||
return String(key.prefix(8))
|
||||
}
|
||||
if typingUsers.count == 1 {
|
||||
typingLabel.text = "\(names[0]) typing"
|
||||
} else if typingUsers.count == 2 {
|
||||
typingLabel.text = "\(names[0]), \(names[1]) typing"
|
||||
} else {
|
||||
typingLabel.text = "\(names[0]) and \(typingUsers.count - 1) others typing"
|
||||
}
|
||||
} else {
|
||||
typingLabel.text = "typing"
|
||||
}
|
||||
}
|
||||
|
||||
private func hideTypingIndicator() {
|
||||
typingDotsView.stopAnimating()
|
||||
typingDotsView.isHidden = true
|
||||
typingLabel.isHidden = true
|
||||
messageLabel.isHidden = false
|
||||
}
|
||||
|
||||
// MARK: - Message Text Resolve
|
||||
|
||||
private func resolveMessageText(dialog: Dialog) -> String {
|
||||
let raw = dialog.lastMessage.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if raw.isEmpty { return "No messages yet" }
|
||||
@@ -778,6 +850,10 @@ final class ChatListCell: UICollectionViewCell {
|
||||
messageLabel.attributedText = nil
|
||||
messageLabel.numberOfLines = 2
|
||||
authorLabel.isHidden = true
|
||||
// Typing indicator
|
||||
typingDotsView.stopAnimating()
|
||||
typingDotsView.isHidden = true
|
||||
typingLabel.isHidden = true
|
||||
// Badge animation state
|
||||
wasBadgeVisible = false
|
||||
wasMentionBadgeVisible = false
|
||||
|
||||
@@ -129,11 +129,13 @@ final class ChatListCollectionController: UIViewController {
|
||||
cellRegistration = UICollectionView.CellRegistration<ChatListCell, Dialog> {
|
||||
[weak self] cell, indexPath, dialog in
|
||||
guard let self else { return }
|
||||
cell.configure(with: dialog, isSyncing: self.isSyncing)
|
||||
// Hide separator for first cell in first dialog section
|
||||
let isFirstDialogSection = (self.sectionForIndexPath(indexPath) == .pinned && self.requestsCount == 0)
|
||||
|| (self.sectionForIndexPath(indexPath) == .unpinned && self.pinnedDialogs.isEmpty && self.requestsCount == 0)
|
||||
cell.setSeparatorHidden(indexPath.item == 0 && isFirstDialogSection)
|
||||
let typingUsers = self.typingDialogs[dialog.opponentKey]
|
||||
cell.configure(with: dialog, isSyncing: self.isSyncing, typingUsers: typingUsers)
|
||||
// Hide separator for last cell in pinned/unpinned section
|
||||
let section = self.sectionForIndexPath(indexPath)
|
||||
let isLastInPinned = section == .pinned && indexPath.item == self.pinnedDialogs.count - 1
|
||||
let isLastInUnpinned = section == .unpinned && indexPath.item == self.unpinnedDialogs.count - 1
|
||||
cell.setSeparatorHidden(isLastInPinned || isLastInUnpinned)
|
||||
}
|
||||
|
||||
requestsCellRegistration = UICollectionView.CellRegistration<ChatListRequestsCell, Int> {
|
||||
@@ -235,7 +237,8 @@ final class ChatListCollectionController: UIViewController {
|
||||
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)
|
||||
let typingUsers = typingDialogs[dialog.opponentKey]
|
||||
chatCell.configure(with: dialog, isSyncing: isSyncing, typingUsers: typingUsers)
|
||||
} else if let reqCell = cell as? ChatListRequestsCell {
|
||||
reqCell.configure(count: requestsCount)
|
||||
}
|
||||
|
||||
115
Rosetta/Features/Chats/ChatList/UIKit/TypingDotsView.swift
Normal file
115
Rosetta/Features/Chats/ChatList/UIKit/TypingDotsView.swift
Normal file
@@ -0,0 +1,115 @@
|
||||
import UIKit
|
||||
import SwiftUI
|
||||
|
||||
/// Animated typing dots indicator matching Telegram iOS exactly.
|
||||
/// Uses CADisplayLink for smooth animation of 3 pulsing dots.
|
||||
///
|
||||
/// Reference: Telegram-iOS `ChatTypingActivityContentNode.swift`
|
||||
/// - minDiameter: 3.0, maxDiameter: 4.5
|
||||
/// - duration: 0.7s, timeOffsets: [0.4, 0.2, 0.0]
|
||||
/// - alpha range: 0.75–1.0
|
||||
/// - total size: 24×16
|
||||
final class TypingDotsView: UIView {
|
||||
|
||||
var dotColor: UIColor = UIColor(red: 0x8E/255, green: 0x8E/255, blue: 0x93/255, alpha: 1) {
|
||||
didSet { setNeedsDisplay() }
|
||||
}
|
||||
|
||||
private var displayLink: CADisplayLink?
|
||||
private var startTime: CFTimeInterval = 0
|
||||
|
||||
// Telegram-exact constants
|
||||
private let animDuration: CFTimeInterval = 0.7
|
||||
private let minD: CGFloat = 3.0
|
||||
private let maxD: CGFloat = 4.5
|
||||
private let dotDistance: CGFloat = 5.5 // 11.0 / 2.0
|
||||
private let leftPad: CGFloat = 6.0
|
||||
private let minAlpha: CGFloat = 0.75
|
||||
private let deltaAlpha: CGFloat = 0.25 // 1.0 - 0.75
|
||||
private let timeOffsets: [CGFloat] = [0.4, 0.2, 0.0]
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
isOpaque = false
|
||||
backgroundColor = .clear
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
deinit {
|
||||
displayLink?.invalidate()
|
||||
}
|
||||
|
||||
func startAnimating() {
|
||||
guard displayLink == nil else { return }
|
||||
startTime = CACurrentMediaTime()
|
||||
let link = CADisplayLink(target: self, selector: #selector(tick))
|
||||
link.add(to: .main, forMode: .common)
|
||||
displayLink = link
|
||||
}
|
||||
|
||||
func stopAnimating() {
|
||||
displayLink?.invalidate()
|
||||
displayLink = nil
|
||||
}
|
||||
|
||||
@objc private func tick() {
|
||||
setNeedsDisplay()
|
||||
}
|
||||
|
||||
override func draw(_ rect: CGRect) {
|
||||
guard let ctx = UIGraphicsGetCurrentContext() else { return }
|
||||
|
||||
let progress = CGFloat(fmod(CACurrentMediaTime() - startTime, animDuration) / animDuration)
|
||||
let centerY = rect.height / 2
|
||||
|
||||
for (i, offset) in timeOffsets.enumerated() {
|
||||
var r = radiusFunc(progress, timeOffset: offset)
|
||||
r = (max(minD, r) - minD) / (maxD - minD) * 1.5
|
||||
|
||||
let alpha = (r * deltaAlpha + minAlpha)
|
||||
ctx.setFillColor(dotColor.withAlphaComponent(alpha).cgColor)
|
||||
|
||||
let x = leftPad + CGFloat(i) * dotDistance
|
||||
let size = minD + r
|
||||
ctx.fillEllipse(in: CGRect(
|
||||
x: x - size / 2,
|
||||
y: centerY - size / 2,
|
||||
width: size,
|
||||
height: size
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
// Telegram-exact radius function (ChatTypingActivityContentNode.swift)
|
||||
private func radiusFunc(_ value: CGFloat, timeOffset: CGFloat) -> CGFloat {
|
||||
var v = value + timeOffset
|
||||
if v > 1.0 { v -= floor(v) }
|
||||
if v < 0.4 {
|
||||
return (1.0 - v / 0.4) * minD + (v / 0.4) * maxD
|
||||
} else if v < 0.8 {
|
||||
return (1.0 - (v - 0.4) / 0.4) * maxD + ((v - 0.4) / 0.4) * minD
|
||||
}
|
||||
return minD
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - SwiftUI Bridge
|
||||
|
||||
/// SwiftUI wrapper for TypingDotsView — used in ChatDetail toolbar capsule.
|
||||
struct TypingDotsRepresentable: UIViewRepresentable {
|
||||
let color: UIColor
|
||||
|
||||
func makeUIView(context: Context) -> TypingDotsView {
|
||||
let view = TypingDotsView()
|
||||
view.dotColor = color
|
||||
view.startAnimating()
|
||||
return view
|
||||
}
|
||||
|
||||
func updateUIView(_ view: TypingDotsView, context: Context) {
|
||||
view.dotColor = color
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user