Голосовые сообщения — фиксы lock view, cancel анимация, recording panel UI

This commit is contained in:
2026-04-12 23:30:00 +05:00
parent 30f333ef90
commit 08a1da64a8
13 changed files with 751 additions and 155 deletions

View File

@@ -226,6 +226,59 @@ enum RosettaColors {
return (first + second).uppercased()
}
}
// MARK: - Voice Message Bubble Colors (Telegram exact, UIKit)
// Source: DefaultDarkPresentationTheme.swift + DefaultDayPresentationTheme.swift
enum Voice {
/// Returns the full color set for a voice message bubble.
static func colors(isOutgoing: Bool, isDark: Bool) -> VoiceColors {
if isDark {
return isOutgoing ? darkOutgoing : darkIncoming
} else {
return isOutgoing ? lightOutgoing : lightIncoming
}
}
private static let darkIncoming = VoiceColors(
playButtonBg: UIColor(white: 1, alpha: 1), // #FFFFFF
playButtonFg: UIColor(red: 0x26/255, green: 0x26/255, blue: 0x28/255, alpha: 1), // #262628
waveformPlayed: UIColor(white: 1, alpha: 1), // #FFFFFF
waveformUnplayed: UIColor(white: 1, alpha: 0.4), // #FFF 40%
durationText: UIColor(white: 1, alpha: 0.5) // #FFF 50%
)
private static let darkOutgoing = VoiceColors(
playButtonBg: UIColor(white: 1, alpha: 1), // #FFFFFF
// Telegram uses .clear foreground (bubble color shows through cutout).
// With Lottie we tint explicitly to bubble blue (#3390EC).
playButtonFg: UIColor(red: 0x33/255, green: 0x90/255, blue: 0xEC/255, alpha: 1), // #3390EC
waveformPlayed: UIColor(white: 1, alpha: 1), // #FFFFFF
waveformUnplayed: UIColor(white: 1, alpha: 0.5), // #FFF 50%
durationText: UIColor(white: 1, alpha: 0.5) // #FFF 50%
)
private static let lightIncoming = VoiceColors(
playButtonBg: UIColor(red: 0, green: 0x88/255, blue: 1, alpha: 1), // #0088FF
playButtonFg: UIColor.white,
waveformPlayed: UIColor(red: 0, green: 0x88/255, blue: 1, alpha: 1), // #0088FF
waveformUnplayed: UIColor(red: 0xCA/255, green: 0xCA/255, blue: 0xCA/255, alpha: 1), // #CACACA
durationText: UIColor(red: 0x52/255, green: 0x52/255, blue: 0x52/255, alpha: 0.6) // #525252 60%
)
private static let lightOutgoing = VoiceColors(
playButtonBg: UIColor(red: 0x3F/255, green: 0xC3/255, blue: 0x3B/255, alpha: 1), // #3FC33B
playButtonFg: UIColor.white,
waveformPlayed: UIColor(red: 0x3F/255, green: 0xC3/255, blue: 0x3B/255, alpha: 1), // #3FC33B
waveformUnplayed: UIColor(red: 0x93/255, green: 0xD9/255, blue: 0x87/255, alpha: 1), // #93D987
durationText: UIColor(red: 0, green: 0x8C/255, blue: 0x09/255, alpha: 0.8) // #008C09 80%
)
}
}
struct VoiceColors {
let playButtonBg: UIColor
let playButtonFg: UIColor
let waveformPlayed: UIColor
let waveformUnplayed: UIColor
let durationText: UIColor
}
// MARK: - Color Hex Initializer

View File

@@ -12,17 +12,32 @@ final class WaveformView: UIView {
enum Gravity { case center, bottom }
// MARK: - Configuration (Telegram exact: AudioWaveformNode lines 96-98)
// MARK: - Configuration
// Bubble context: distance=2.0, gravity=.bottom (Telegram AudioWaveformComponent)
// Recording preview: distance=1.0, gravity=.center (Telegram AudioWaveformNode)
private let sampleWidth: CGFloat = 2.0
private let halfSampleWidth: CGFloat = 1.0
private let distance: CGFloat = 1.0
var distance: CGFloat = 1.0
var peakHeight: CGFloat = 12.0
var gravity: Gravity = .center
var backgroundColor_: UIColor = UIColor.white.withAlphaComponent(0.3)
var foregroundColor_: UIColor = UIColor(red: 0, green: 136/255.0, blue: 1, alpha: 1)
// MARK: - Scrubbing
/// Enable pan-to-seek gesture (only active during playback in bubble context).
var enableScrubbing: Bool = false {
didSet { panGesture?.isEnabled = enableScrubbing }
}
/// Called on gesture end with fraction 0..1.
var onSeek: ((Double) -> Void)?
private var panGesture: UIPanGestureRecognizer?
private(set) var isScrubbing = false
private var scrubbingStartProgress: CGFloat = 0
// MARK: - State
private var samples: [Float] = []
@@ -40,6 +55,11 @@ final class WaveformView: UIView {
super.init(frame: frame)
backgroundColor = .clear
isOpaque = false
let pan = UIPanGestureRecognizer(target: self, action: #selector(handlePan(_:)))
pan.isEnabled = false // enabled only when enableScrubbing = true
addGestureRecognizer(pan)
panGesture = pan
}
convenience init(
@@ -63,7 +83,7 @@ final class WaveformView: UIView {
setNeedsDisplay()
}
// MARK: - Drawing (Telegram exact: AudioWaveformNode lines 86-232)
// MARK: - Drawing (Telegram AudioWaveformComponent single-pass with per-bar color blend)
override func draw(_ rect: CGRect) {
guard !samples.isEmpty else { return }
@@ -85,19 +105,31 @@ final class WaveformView: UIView {
let gravityMultiplierY: CGFloat = gravity == .bottom ? 1.0 : 0.5
// Draw background bars, then foreground bars on top
for pass in 0..<2 {
let color = pass == 0 ? backgroundColor_ : foregroundColor_
ctx.setFillColor(color.cgColor)
// Pre-extract RGBA components for fast blending
var bgR: CGFloat = 0, bgG: CGFloat = 0, bgB: CGFloat = 0, bgA: CGFloat = 0
var fgR: CGFloat = 0, fgG: CGFloat = 0, fgB: CGFloat = 0, fgA: CGFloat = 0
backgroundColor_.getRed(&bgR, green: &bgG, blue: &bgB, alpha: &bgA)
foregroundColor_.getRed(&fgR, green: &fgG, blue: &fgB, alpha: &fgA)
// Single-pass: each bar individually colored (Telegram AudioWaveformComponent)
for i in 0..<numSamples {
let offset = CGFloat(i) * (sampleWidth + distance)
// For foreground pass, only draw bars within progress
if pass == 1 {
let samplePosition = CGFloat(i) / CGFloat(numSamples)
guard samplePosition < progress else { continue }
// Per-bar color blend at progress boundary
let startFraction = CGFloat(i) / CGFloat(numSamples)
let nextStartFraction = CGFloat(i + 1) / CGFloat(numSamples)
let colorMix: CGFloat
if startFraction < progress {
colorMix = min(1.0, max(0.0, (progress - startFraction) / (nextStartFraction - startFraction)))
} else {
colorMix = 0.0
}
let r = bgR + (fgR - bgR) * colorMix
let g = bgG + (fgG - bgG) * colorMix
let b = bgB + (fgB - bgB) * colorMix
let a = bgA + (fgA - bgA) * colorMix
ctx.setFillColor(red: r, green: g, blue: b, alpha: a)
ctx.setBlendMode(.copy)
var sampleHeight = CGFloat(resampled[i]) * peakHeight
if sampleHeight > peakHeight { sampleHeight = peakHeight }
@@ -105,7 +137,7 @@ final class WaveformView: UIView {
let adjustedSampleHeight = sampleHeight - diff
if adjustedSampleHeight <= sampleWidth {
// Tiny bar: single dot + small rect (Telegram lines 212-214)
// Tiny bar: single dot + small rect
ctx.fillEllipse(in: CGRect(
x: offset,
y: (size.height - sampleWidth) * gravityMultiplierY,
@@ -119,7 +151,7 @@ final class WaveformView: UIView {
height: halfSampleWidth
))
} else {
// Normal bar: rect + top cap + bottom cap (Telegram lines 216-224)
// Normal bar: rect + top cap + bottom cap
let barRect = CGRect(
x: offset,
y: (size.height - adjustedSampleHeight) * gravityMultiplierY,
@@ -142,6 +174,39 @@ final class WaveformView: UIView {
}
}
}
// MARK: - Scrubbing Gesture (Telegram AudioWaveformComponent lines 192-306)
@objc private func handlePan(_ gesture: UIPanGestureRecognizer) {
let location = gesture.location(in: self)
let verticalDistance = abs(gesture.translation(in: self).y)
// Telegram slow-scrub: vertical drag reduces precision
let multiplier: CGFloat
if verticalDistance > 150 {
multiplier = 0.01
} else if verticalDistance > 100 {
multiplier = 0.25
} else if verticalDistance > 50 {
multiplier = 0.5
} else {
multiplier = 1.0
}
switch gesture.state {
case .began:
isScrubbing = true
scrubbingStartProgress = progress
case .changed:
let fraction = location.x / max(1, bounds.width)
let delta = (fraction - scrubbingStartProgress) * multiplier
progress = max(0, min(1, scrubbingStartProgress + delta))
case .ended, .cancelled:
isScrubbing = false
onSeek?(Double(progress))
default:
break
}
}
// MARK: - Resampling (Telegram: max extraction per bin)

View File

@@ -561,14 +561,14 @@ private struct ChatDetailPrincipal: View {
let names = viewModel.typingSenderNames
if !names.isEmpty {
if names.count == 1 {
return "\(names[0]) typing..."
return "\(names[0]) typing"
} else {
return "\(names[0]) and \(names.count - 1) typing..."
return "\(names[0]) and \(names.count - 1) typing"
}
}
return "group"
}
if viewModel.isTyping { return "typing..." }
if viewModel.isTyping { return "typing" }
if let dialog, dialog.isOnline { return "online" }
return "offline"
}
@@ -593,11 +593,22 @@ private struct ChatDetailPrincipal: View {
}
if !subtitleText.isEmpty {
if viewModel.isTyping || !viewModel.typingSenderNames.isEmpty {
HStack(spacing: 0) {
TypingDotsRepresentable(color: UIColor(subtitleColor))
.frame(width: 24, height: 14)
Text(subtitleText)
.font(.system(size: 12, weight: .medium))
.foregroundStyle(subtitleColor)
.lineLimit(1)
}
} else {
Text(subtitleText)
.font(.system(size: 12, weight: .medium))
.foregroundStyle(subtitleColor)
.lineLimit(1)
}
}
}
}
}

View File

@@ -1,5 +1,6 @@
import AVFAudio
@preconcurrency import AVFoundation
import Lottie
import UIKit
// MARK: - ComposerViewDelegate
@@ -1124,7 +1125,7 @@ extension ComposerView: RecordingMicButtonDelegate {
recordingOverlay?.applyDragTransform(distanceX: distanceX, distanceY: distanceY)
recordingPanel?.updateCancelTranslation(distanceX)
let lockness = VoiceRecordingParityMath.lockness(distanceY: distanceY)
recordingLockView?.updateLockness(lockness)
recordingLockView?.updateLockness(lockness, dragOffsetY: distanceY)
}
func showRecordingPreview() {
@@ -1368,15 +1369,10 @@ extension ComposerView: RecordingMicButtonDelegate {
recordingPanel?.animateOut { [weak self] in
self?.recordingPanel = nil
}
restoreComposerChrome()
case .cancel:
recordingOverlay?.dismissCancel()
recordingPanel?.animateOutCancel { [weak self] in
self?.recordingPanel = nil
self?.restoreComposerChrome()
}
if recordingPanel == nil {
restoreComposerChrome()
}
}
recordingOverlay = nil
@@ -1387,6 +1383,45 @@ extension ComposerView: RecordingMicButtonDelegate {
recordingPreviewPanel?.animateOut { [weak self] in
self?.recordingPreviewPanel = nil
}
restoreComposerChrome()
// For cancel: play bin animation inside attach button, then restore icon
if dismissStyle == .cancel {
playBinAnimationInAttachButton()
}
}
private func playBinAnimationInAttachButton() {
// Hide paperclip icon, play bin Lottie inside attach button, then restore
attachIconLayer?.opacity = 0
guard let animation = LottieAnimation.named(VoiceRecordingLottieAsset.binRed.rawValue) else {
// No Lottie asset just fade icon back
CATransaction.begin()
CATransaction.setAnimationDuration(0.25)
attachIconLayer?.opacity = 1
CATransaction.commit()
return
}
let binView = LottieAnimationView(animation: animation)
binView.frame = attachButton.bounds
binView.contentMode = .scaleAspectFit
binView.backgroundBehavior = .pauseAndRestore
binView.loopMode = .playOnce
attachButton.addSubview(binView)
binView.play { [weak self] _ in
binView.removeFromSuperview()
// Fade paperclip icon back in
let fadeIn = CABasicAnimation(keyPath: "opacity")
fadeIn.fromValue = 0
fadeIn.toValue = 1
fadeIn.duration = 0.2
self?.attachIconLayer?.add(fadeIn, forKey: "fadeIn")
self?.attachIconLayer?.opacity = 1
}
}
private func clearLastRecordedDraftFile() {

View File

@@ -205,8 +205,7 @@ final class MentionCell: UITableViewCell {
avatarImageView.image = nil
avatarImageView.isHidden = true
avatarInitialLabel.isHidden = false
let initial = String(candidate.title.prefix(1)).uppercased()
avatarInitialLabel.text = initial
avatarInitialLabel.text = RosettaColors.initials(name: candidate.title, publicKey: candidate.publicKey)
let colorIndex = RosettaColors.avatarColorIndex(for: candidate.title, publicKey: candidate.publicKey)
// Mantine "light" variant: dark base + tint at 15% opacity (dark mode)
let isDark = traitCollection.userInterfaceStyle == .dark

View File

@@ -1,3 +1,4 @@
import Lottie
import UIKit
// MARK: - MessageVoiceView
@@ -9,6 +10,7 @@ final class MessageVoiceView: UIView {
// MARK: - Subviews
private let playButton = UIButton(type: .system)
private let playPauseAnimationView = LottieAnimationView()
private let waveformView = WaveformView()
private let durationLabel = UILabel()
@@ -17,6 +19,9 @@ final class MessageVoiceView: UIView {
private var messageId: String = ""
private var attachmentId: String = ""
private var isOutgoing = false
private var isShowingPause = false // tracks Lottie visual state
private var totalDuration: TimeInterval = 0 // original duration for label reset
private var blobView: VoiceBlobView? // pulsing ring around play button during playback
// MARK: - Layout Constants (Telegram exact: ChatMessageInteractiveFileNode)
@@ -44,21 +49,35 @@ final class MessageVoiceView: UIView {
// MARK: - Setup
private func setupSubviews() {
let config = UIImage.SymbolConfiguration(pointSize: 18, weight: .bold)
playButton.setImage(UIImage(systemName: "play.fill", withConfiguration: config), for: .normal)
playButton.backgroundColor = UIColor(red: 0, green: 136/255.0, blue: 1, alpha: 1)
playButton.tintColor = .white
playButton.layer.cornerRadius = playButtonSize / 2
playButton.clipsToBounds = true
playButton.addTarget(self, action: #selector(playTapped), for: .touchUpInside)
addSubview(playButton)
// Lottie play/pause animation (same asset as RecordingPreviewPanel)
playPauseAnimationView.backgroundBehavior = .pauseAndRestore
playPauseAnimationView.contentMode = .scaleAspectFit
playPauseAnimationView.isUserInteractionEnabled = false
playButton.addSubview(playPauseAnimationView)
if let animation = LottieAnimation.named(VoiceRecordingLottieAsset.playPause.rawValue) {
playPauseAnimationView.animation = animation
playPauseAnimationView.currentFrame = 0 // start at play icon
} else {
// Fallback: SF Symbol if Lottie asset missing
let config = UIImage.SymbolConfiguration(pointSize: 18, weight: .bold)
playButton.setImage(UIImage(systemName: "play.fill", withConfiguration: config), for: .normal)
}
waveformView.peakHeight = 18 // Telegram AudioWaveformComponent peak
waveformView.gravity = .center
waveformView.distance = 2.0 // Telegram AudioWaveformComponent (bubble context)
waveformView.gravity = .bottom // Telegram: bars grow upward from bottom
waveformView.onSeek = { [weak self] fraction in
guard self != nil else { return }
VoiceMessagePlayer.shared.seek(to: fraction)
}
addSubview(waveformView)
durationLabel.font = .monospacedDigitSystemFont(ofSize: 11, weight: .regular)
durationLabel.textColor = .white.withAlphaComponent(0.6)
addSubview(durationLabel)
}
@@ -75,6 +94,13 @@ final class MessageVoiceView: UIView {
width: playButtonSize,
height: playButtonSize
)
// Lottie inset inside 44×44 button (26×26 centered)
let lottieInset: CGFloat = 9
playPauseAnimationView.frame = CGRect(
x: lottieInset, y: lottieInset,
width: playButtonSize - lottieInset * 2,
height: playButtonSize - lottieInset * 2
)
// Waveform: from x=57 to near right edge, height=18, y=1
let waveW = bounds.width - waveformX - 4
@@ -101,6 +127,21 @@ final class MessageVoiceView: UIView {
self.messageId = messageId
self.attachmentId = attachmentId
self.isOutgoing = isOutgoing
self.isShowingPause = false
// Reset Lottie to play icon (frame 0)
if playPauseAnimationView.animation != nil {
playPauseAnimationView.stop()
playPauseAnimationView.currentFrame = 0
playButton.setImage(nil, for: .normal)
}
// Remove blob from previous cell reuse
blobView?.stopAnimating()
blobView?.removeFromSuperview()
blobView = nil
self.totalDuration = duration
// Decode waveform from preview
let samples = Self.decodeWaveform(from: preview)
@@ -108,20 +149,42 @@ final class MessageVoiceView: UIView {
waveformView.progress = 0
// Duration label
let totalSeconds = Int(duration)
let minutes = totalSeconds / 60
let seconds = totalSeconds % 60
durationLabel.text = String(format: "%d:%02d", minutes, seconds)
durationLabel.text = Self.formatDuration(duration)
// Style based on incoming/outgoing
if isOutgoing {
playButton.backgroundColor = .white
playButton.tintColor = UIColor(red: 0, green: 136/255.0, blue: 1, alpha: 1)
durationLabel.textColor = .white.withAlphaComponent(0.6)
} else {
playButton.backgroundColor = UIColor(red: 0, green: 136/255.0, blue: 1, alpha: 1)
playButton.tintColor = .white
durationLabel.textColor = UIColor.white.withAlphaComponent(0.5)
// Telegram-exact theme-aware colors
applyColors()
}
// MARK: - Colors (Telegram exact: DefaultDarkPresentationTheme + DefaultDayPresentationTheme)
private func applyColors() {
let isDark = traitCollection.userInterfaceStyle == .dark
let colors = RosettaColors.Voice.colors(isOutgoing: isOutgoing, isDark: isDark)
playButton.backgroundColor = colors.playButtonBg
playButton.tintColor = colors.playButtonFg
durationLabel.textColor = colors.durationText
waveformView.foregroundColor_ = colors.waveformPlayed
waveformView.backgroundColor_ = colors.waveformUnplayed
waveformView.setNeedsDisplay()
// Tint Lottie animation icon (same pattern as RecordingPreviewPanel)
applyPlayPauseTintColor(colors.playButtonFg)
}
private func applyPlayPauseTintColor(_ color: UIColor) {
var r: CGFloat = 0, g: CGFloat = 0, b: CGFloat = 0, a: CGFloat = 0
color.getRed(&r, green: &g, blue: &b, alpha: &a)
playPauseAnimationView.setValueProvider(
ColorValueProvider(LottieColor(r: Double(r), g: Double(g), b: Double(b), a: Double(a))),
keypath: AnimationKeypath(keypath: "**.Color")
)
}
override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
super.traitCollectionDidChange(previousTraitCollection)
if traitCollection.hasDifferentColorAppearance(comparedTo: previousTraitCollection) {
applyColors()
}
}
@@ -133,11 +196,96 @@ final class MessageVoiceView: UIView {
/// Update play button icon and waveform progress from VoiceMessagePlayer state.
func updatePlaybackState(isPlaying: Bool, progress: CGFloat) {
let config = UIImage.SymbolConfiguration(pointSize: 16, weight: .bold)
let name = isPlaying ? "pause.fill" : "play.fill"
playButton.setImage(UIImage(systemName: name, withConfiguration: config), for: .normal)
// Don't override progress while user is scrubbing
if !waveformView.isScrubbing {
waveformView.progress = progress
}
waveformView.enableScrubbing = isPlaying
// Blob animation (Telegram: VoiceBlobNode around play button during playback)
updateBlobState(isPlaying: isPlaying)
let shouldShowPause = isPlaying
guard shouldShowPause != isShowingPause else { return }
isShowingPause = shouldShowPause
if playPauseAnimationView.animation != nil {
playButton.setImage(nil, for: .normal)
if shouldShowPause {
// play pause (Telegram: frames 041)
playPauseAnimationView.play(fromFrame: 0, toFrame: 41, loopMode: .playOnce)
} else {
// pause play (Telegram: frames 4183)
playPauseAnimationView.play(fromFrame: 41, toFrame: 83, loopMode: .playOnce)
}
} else {
// Fallback: SF Symbols
let config = UIImage.SymbolConfiguration(pointSize: 16, weight: .bold)
let name = shouldShowPause ? "pause.fill" : "play.fill"
playButton.setImage(UIImage(systemName: name, withConfiguration: config), for: .normal)
}
}
// MARK: - Blob Animation (Telegram: ChatMessageInteractiveFileNode lines 1843-1866)
private func updateBlobState(isPlaying: Bool) {
if isPlaying {
if blobView == nil {
// Telegram params: maxLevel=0.3, no small blob, medium 0.7-0.8, big 0.8-0.9
let blob = VoiceBlobView(
frame: .zero,
maxLevel: 0.3,
smallBlobRange: (min: 0, max: 0),
mediumBlobRange: (min: 0.7, max: 0.8),
bigBlobRange: (min: 0.8, max: 0.9)
)
// Frame: 12pt larger on each side than play button (68×68)
let blobInset: CGFloat = -12
blob.frame = playButton.frame.insetBy(dx: blobInset, dy: blobInset)
// Even-odd mask to cut out the inner play button circle
let maskLayer = CAShapeLayer()
let fullRect = CGRect(origin: .zero, size: blob.bounds.size)
let path = UIBezierPath(rect: fullRect)
let innerDiameter = playButtonSize
let innerOrigin = CGPoint(x: (fullRect.width - innerDiameter) / 2,
y: (fullRect.height - innerDiameter) / 2)
let innerRect = CGRect(origin: innerOrigin, size: CGSize(width: innerDiameter, height: innerDiameter))
path.append(UIBezierPath(ovalIn: innerRect))
maskLayer.path = path.cgPath
maskLayer.fillRule = .evenOdd
blob.layer.mask = maskLayer
let isDark = traitCollection.userInterfaceStyle == .dark
let colors = RosettaColors.Voice.colors(isOutgoing: isOutgoing, isDark: isDark)
blob.setColor(colors.playButtonBg)
insertSubview(blob, belowSubview: playButton)
blobView = blob
}
blobView?.startAnimating()
// Gentle fixed pulse (VoiceMessagePlayer doesn't expose audio level metering)
blobView?.updateLevel(0.2)
} else {
blobView?.stopAnimating()
}
}
/// Updates duration label to show elapsed time during playback, total when stopped.
func updateDurationDuringPlayback(currentTime: TimeInterval, totalDuration: TimeInterval, isPlaying: Bool) {
if isPlaying && currentTime > 0 {
durationLabel.text = Self.formatDuration(currentTime)
} else {
durationLabel.text = Self.formatDuration(self.totalDuration)
}
}
private static func formatDuration(_ time: TimeInterval) -> String {
let totalSeconds = Int(time)
let minutes = totalSeconds / 60
let seconds = totalSeconds % 60
return String(format: "%d:%02d", minutes, seconds)
}
// MARK: - Waveform Decoding

View File

@@ -248,6 +248,9 @@ final class NativeMessageCell: UICollectionViewCell {
private var message: ChatMessage?
private var actions: MessageCellActions?
private(set) var currentLayout: MessageCellLayout?
/// Exposed for voice playback live updates (NativeMessageList matches against VoiceMessagePlayer.currentMessageId).
var currentMessageId: String? { message?.id }
var isSavedMessages = false
var isSystemAccount = false
/// When true, the inline date header pill is hidden (floating sticky one covers it).
@@ -1285,6 +1288,15 @@ final class NativeMessageCell: UICollectionViewCell {
let textTopY = topY + 4
fileNameLabel.frame = CGRect(x: 63, y: textTopY, width: fileW - 75, height: 19)
fileSizeLabel.frame = CGRect(x: 63, y: textTopY + 21, width: fileW - 75, height: 16)
} else if !voiceView.isHidden {
// Voice layout: center voiceView vertically, hide file subviews
let contentH: CGFloat = 38
let topY = max(0, (centerableH - contentH) / 2)
voiceView.frame = CGRect(x: 0, y: topY, width: fileW, height: contentH)
fileIconView.isHidden = true
fileNameLabel.isHidden = true
fileSizeLabel.isHidden = true
avatarImageView.isHidden = true
} else {
// File layout: vertically centered icon + title + size
let contentH: CGFloat = 44 // icon height dominates
@@ -2982,6 +2994,13 @@ final class NativeMessageCell: UICollectionViewCell {
}
}
/// Called by NativeMessageList on every VoiceMessagePlayer progress tick for the active cell.
func updateVoicePlayback(isPlaying: Bool, progress: CGFloat, currentTime: TimeInterval, duration: TimeInterval) {
guard !voiceView.isHidden else { return }
voiceView.updatePlaybackState(isPlaying: isPlaying, progress: progress)
voiceView.updateDurationDuringPlayback(currentTime: currentTime, totalDuration: duration, isPlaying: isPlaying)
}
func voiceTransitionTargetFrame(in window: UIWindow) -> CGRect? {
guard !voiceView.isHidden else { return nil }
return voiceView.convert(voiceView.bounds, to: window)

View File

@@ -1,3 +1,4 @@
import Combine
import SwiftUI
import UIKit
@@ -124,6 +125,9 @@ final class NativeMessageListController: UIViewController {
/// scrollViewDidEndScrollingAnimation prematurely.
private var scrollToBottomTimestamp: CFAbsoluteTime = 0
// MARK: - Voice Playback Live Updates
private var voicePlayerCancellables = Set<AnyCancellable>()
// MARK: - Scroll-to-Bottom Button
private var scrollToBottomButton: UIButton?
private var scrollToBottomButtonContainer: UIView?
@@ -253,6 +257,9 @@ final class NativeMessageListController: UIViewController {
self.refreshAllMessageCells()
}
// Voice playback: live waveform progress updates
setupVoicePlayerSubscription()
// Show skeleton placeholder while messages load from DB
if messages.isEmpty {
showSkeleton()
@@ -636,14 +643,20 @@ final class NativeMessageListController: UIViewController {
/// Auto Layout transform race condition during interactive dismiss.
func setScrollToBottomVisible(_ visible: Bool) {
guard let button = scrollToBottomButton else { return }
// Suppress scroll-to-bottom during voice recording
let isRecording = composerView?.recordingFlowState != .idle
&& composerView?.recordingFlowState != nil
let effectiveVisible = visible && !isRecording
let isCurrentlyVisible = button.alpha > 0.5
guard visible != isCurrentlyVisible else { return }
guard effectiveVisible != isCurrentlyVisible else { return }
UIView.animate(withDuration: 0.3, delay: 0,
usingSpringWithDamping: 0.82, initialSpringVelocity: 0,
options: .beginFromCurrentState) {
button.alpha = visible ? 1 : 0
button.layer.transform = visible
button.alpha = effectiveVisible ? 1 : 0
button.layer.transform = effectiveVisible
? CATransform3DIdentity
: CATransform3DMakeScale(0.2, 0.2, 1.0)
}
@@ -1626,10 +1639,73 @@ extension NativeMessageListController: ComposerViewDelegate {
)
}
// MARK: - Voice Playback Live Updates
private func setupVoicePlayerSubscription() {
let player = VoiceMessagePlayer.shared
// Throttle to ~30fps to avoid excessive updates (display link fires at 60-120fps)
player.$progress
.receive(on: RunLoop.main)
.throttle(for: .milliseconds(33), scheduler: RunLoop.main, latest: true)
.sink { [weak self] _ in
self?.updatePlayingVoiceCell()
}
.store(in: &voicePlayerCancellables)
// React immediately to play/stop state changes
player.$isPlaying
.receive(on: RunLoop.main)
.removeDuplicates()
.sink { [weak self] _ in
self?.updatePlayingVoiceCell()
}
.store(in: &voicePlayerCancellables)
// When playback stops (currentMessageId nil), reset the previously playing cell
player.$currentMessageId
.receive(on: RunLoop.main)
.removeDuplicates()
.sink { [weak self] _ in
self?.updateAllVisibleVoiceCells()
}
.store(in: &voicePlayerCancellables)
}
private func updatePlayingVoiceCell() {
let player = VoiceMessagePlayer.shared
guard let messageId = player.currentMessageId else { return }
for cell in collectionView.visibleCells {
guard let messageCell = cell as? NativeMessageCell,
messageCell.currentMessageId == messageId else { continue }
messageCell.updateVoicePlayback(
isPlaying: player.isPlaying,
progress: CGFloat(player.progress),
currentTime: player.currentTime,
duration: player.duration
)
return
}
}
private func updateAllVisibleVoiceCells() {
let player = VoiceMessagePlayer.shared
for cell in collectionView.visibleCells {
guard let messageCell = cell as? NativeMessageCell else { continue }
let isThisMessage = messageCell.currentMessageId == player.currentMessageId
messageCell.updateVoicePlayback(
isPlaying: isThisMessage && player.isPlaying,
progress: isThisMessage ? CGFloat(player.progress) : 0,
currentTime: isThisMessage ? player.currentTime : 0,
duration: isThisMessage ? player.duration : 0
)
}
}
// MARK: - Voice Recording
func composerDidStartRecording(_ composer: ComposerView) {
// Recording started handled by ComposerView internally
setScrollToBottomVisible(false)
updateScrollToBottomButtonConstraints()
view.layoutIfNeeded()
}

View File

@@ -75,7 +75,7 @@ final class RecordingLockView: UIView {
private func setupPanel() {
panelGlassView.isUserInteractionEnabled = false
panelGlassView.fixedCornerRadius = panelFullHeight / 2.0
panelGlassView.fixedCornerRadius = panelWidth / 2.0
addSubview(panelGlassView)
panelBorderView.isUserInteractionEnabled = false
@@ -89,8 +89,12 @@ final class RecordingLockView: UIView {
lockAnimationContainer.isUserInteractionEnabled = false
addSubview(lockAnimationContainer)
// Use Main Thread renderer Core Animation renderer doesn't support Fill Color tinting
let mainThreadConfig = LottieConfiguration(renderingEngine: .mainThread)
if let animation = LottieAnimation.named(VoiceRecordingLottieAsset.lockWait.rawValue) {
idleLockView.animation = animation
idleLockView.configuration = mainThreadConfig
}
idleLockView.backgroundBehavior = .pauseAndRestore
idleLockView.loopMode = .autoReverse
@@ -100,6 +104,7 @@ final class RecordingLockView: UIView {
if let animation = LottieAnimation.named(VoiceRecordingLottieAsset.lock.rawValue) {
lockingView.animation = animation
lockingView.configuration = mainThreadConfig
}
lockingView.backgroundBehavior = .pauseAndRestore
lockingView.loopMode = .playOnce
@@ -118,10 +123,8 @@ final class RecordingLockView: UIView {
}
private func setupArrow() {
lockArrowView.image = VoiceRecordingAssets.image(.videoRecordArrow, templated: true)
lockArrowView.contentMode = .center
lockArrowView.isUserInteractionEnabled = false
addSubview(lockArrowView)
// Arrow is part of the Lottie animation no separate UIImageView needed.
lockArrowView.isHidden = true
}
private func setupStopButton() {
@@ -182,11 +185,11 @@ final class RecordingLockView: UIView {
let panelFrame = CGRect(x: 0, y: panelY, width: panelWidth, height: panelHeight)
panelGlassView.frame = panelFrame
panelGlassView.fixedCornerRadius = panelHeight / 2.0
panelGlassView.fixedCornerRadius = min(panelWidth, panelHeight) / 2.0
panelGlassView.applyCornerRadius()
panelBorderView.frame = panelFrame
panelBorderView.layer.cornerRadius = panelHeight / 2.0
panelBorderView.layer.cornerRadius = min(panelWidth, panelHeight) / 2.0
lockAnimationContainer.frame = CGRect(x: 0, y: 6.0, width: 40.0, height: 60.0)
idleLockView.frame = lockAnimationContainer.bounds
@@ -275,10 +278,13 @@ final class RecordingLockView: UIView {
// MARK: - Lockness Update
/// Update lock progress (0 = idle, 1 = locked).
func updateLockness(_ lockness: CGFloat) {
/// Telegram: lockPanelWrapperView.transform = full drag Y translation
func updateLockness(_ lockness: CGFloat, dragOffsetY: CGFloat = 0) {
guard visualState == .lock else { return }
currentLockness = max(0, min(1, lockness))
// Move entire pill upward with finger (distanceY is negative when swiping up)
transform = CGAffineTransform(translationX: 0, y: dragOffsetY)
updatePanelGeometry()
if currentLockness > 0 {
@@ -357,6 +363,12 @@ final class RecordingLockView: UIView {
stopButton.layer.zPosition = 100
bringSubviewToFront(stopButton)
// Telegram: animate view back to fixed position (reset drag offset)
// Lock panel wrapper slides to final locked Y, then stop button fades in
UIView.animate(withDuration: 0.2, delay: 0, options: [.curveEaseInOut]) {
self.transform = .identity
}
UIView.animate(withDuration: 0.25, delay: 0.56, options: [.curveEaseOut]) {
self.stopButton.alpha = 1
self.stopButton.transform = .identity
@@ -407,8 +419,8 @@ final class RecordingLockView: UIView {
iconColor = isDark ? UIColor(white: 0.95, alpha: 0.92) : UIColor(white: 0.05, alpha: 0.92)
borderColor = isDark ? UIColor(white: 1.0, alpha: 0.22) : UIColor(white: 0.0, alpha: 0.14)
} else {
iconColor = UIColor(white: 0.58, alpha: 1.0)
borderColor = UIColor(white: 0.7, alpha: 0.55)
iconColor = isDark ? UIColor.white : UIColor(white: 0.20, alpha: 1.0)
borderColor = isDark ? UIColor(white: 1.0, alpha: 0.18) : UIColor(white: 0.0, alpha: 0.14)
}
panelBorderView.layer.borderColor = borderColor.cgColor
@@ -417,8 +429,19 @@ final class RecordingLockView: UIView {
stopBorderView.layer.borderColor = borderColor.cgColor
stopGlyphView.tintColor = iconColor
lockArrowView.tintColor = iconColor
lockFallbackGlyphView.tintColor = iconColor
// Tint Lottie animations (Telegram: allKeypaths predicate "Color")
// Note: "Fill Color" is NOT supported by Core Animation renderer only Color, Stroke Color
var r: CGFloat = 0; var g: CGFloat = 0; var b: CGFloat = 0; var a: CGFloat = 0
iconColor.getRed(&r, green: &g, blue: &b, alpha: &a)
let lottieColor = LottieColor(r: Double(r), g: Double(g), b: Double(b), a: Double(a))
let colorProvider = ColorValueProvider(lottieColor)
for keypath in ["**.Color", "**.Stroke Color", "**.Fill Color"] {
let kp = AnimationKeypath(keypath: keypath)
idleLockView.setValueProvider(colorProvider, keypath: kp)
lockingView.setValueProvider(colorProvider, keypath: kp)
}
}
// MARK: - Stop Action

View File

@@ -172,14 +172,13 @@ final class VoiceRecordingPanel: UIView {
let timerWidth = max(timerMinWidth, timerSize.width + 4)
timerLabel.frame = CGRect(x: timerX, y: timerY, width: timerWidth, height: timerSize.height)
// Cancel indicator: centered in available space after timer
// Cancel indicator: centered with slight right offset to balance timer weight
let labelSize = slideLabel.sizeThatFits(CGSize(width: 200, height: h))
let arrowW: CGFloat = 9 // Telegram SVG: 9pt wide
let arrowH: CGFloat = 18 // Telegram SVG: 18pt tall
let totalCancelW = arrowW + 12 + labelSize.width
let timerTrailingX = timerX + timerWidth
let availableWidth = w - timerTrailingX
let cancelX = timerTrailingX + floor((availableWidth - totalCancelW) / 2)
let cancelRightShift: CGFloat = 16
let cancelX = floor((w - totalCancelW) / 2) + cancelRightShift
cancelContainer.frame = CGRect(x: cancelX, y: 0, width: totalCancelW, height: h)
arrowIcon.frame = CGRect(x: 0, y: floor((h - arrowH) / 2), width: arrowW, height: arrowH)
@@ -190,9 +189,9 @@ final class VoiceRecordingPanel: UIView {
height: labelSize.height
)
// Cancel button: centered in available space after timer
// Cancel button: centered with same right offset
cancelButton.sizeToFit()
cancelButton.center = CGPoint(x: timerTrailingX + availableWidth / 2, y: h / 2)
cancelButton.center = CGPoint(x: w / 2 + cancelRightShift, y: h / 2)
}
override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
@@ -334,36 +333,10 @@ final class VoiceRecordingPanel: UIView {
}
}
// Telegram parity: on cancel, panel content disappears quickly while
// bin animation keeps playing near the leading edge.
let indicatorFrame = CGRect(x: 0, y: floor((bounds.height - 40) / 2.0), width: 40, height: 40)
let binHostView = superview ?? self
let binFrameInHost = convert(indicatorFrame, to: binHostView)
if let animation = LottieAnimation.named(VoiceRecordingLottieAsset.binRed.rawValue) {
let binView = LottieAnimationView(animation: animation)
binView.frame = binFrameInHost
binView.backgroundBehavior = .pauseAndRestore
binView.contentMode = .scaleAspectFit
binView.loopMode = .playOnce
binHostView.addSubview(binView)
// Bin Lottie now plays inside attachButton (ComposerView.playBinAnimationInAttachButton).
// Panel just fades its elements out.
didFinishBin = true
redDot.alpha = 0
binView.play { _ in
binView.removeFromSuperview()
didFinishBin = true
completeIfReady()
}
} else {
didFinishBin = true
UIView.animate(withDuration: 0.15, animations: {
self.redDot.transform = CGAffineTransform(scaleX: 1.3, y: 1.3)
self.redDot.backgroundColor = .gray
}, completion: { _ in
UIView.animate(withDuration: 0.15, animations: {
self.redDot.transform = CGAffineTransform(scaleX: 0.01, y: 0.01)
self.redDot.alpha = 0
})
})
}
// Timer: scale to 0, slide left.
UIView.animate(withDuration: 0.2) {

View File

@@ -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)
// 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

View File

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

View 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.751.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
}
}