Голосовые сообщения — фиксы lock view, cancel анимация, recording panel UI
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 0→41)
|
||||
playPauseAnimationView.play(fromFrame: 0, toFrame: 41, loopMode: .playOnce)
|
||||
} else {
|
||||
// pause → play (Telegram: frames 41→83)
|
||||
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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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