Голосовые сообщения — фиксы lock view, cancel анимация, recording panel UI
This commit is contained in:
@@ -226,6 +226,59 @@ enum RosettaColors {
|
|||||||
return (first + second).uppercased()
|
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
|
// MARK: - Color Hex Initializer
|
||||||
|
|||||||
@@ -12,17 +12,32 @@ final class WaveformView: UIView {
|
|||||||
|
|
||||||
enum Gravity { case center, bottom }
|
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 sampleWidth: CGFloat = 2.0
|
||||||
private let halfSampleWidth: CGFloat = 1.0
|
private let halfSampleWidth: CGFloat = 1.0
|
||||||
private let distance: CGFloat = 1.0
|
var distance: CGFloat = 1.0
|
||||||
|
|
||||||
var peakHeight: CGFloat = 12.0
|
var peakHeight: CGFloat = 12.0
|
||||||
var gravity: Gravity = .center
|
var gravity: Gravity = .center
|
||||||
var backgroundColor_: UIColor = UIColor.white.withAlphaComponent(0.3)
|
var backgroundColor_: UIColor = UIColor.white.withAlphaComponent(0.3)
|
||||||
var foregroundColor_: UIColor = UIColor(red: 0, green: 136/255.0, blue: 1, alpha: 1)
|
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
|
// MARK: - State
|
||||||
|
|
||||||
private var samples: [Float] = []
|
private var samples: [Float] = []
|
||||||
@@ -40,6 +55,11 @@ final class WaveformView: UIView {
|
|||||||
super.init(frame: frame)
|
super.init(frame: frame)
|
||||||
backgroundColor = .clear
|
backgroundColor = .clear
|
||||||
isOpaque = false
|
isOpaque = false
|
||||||
|
|
||||||
|
let pan = UIPanGestureRecognizer(target: self, action: #selector(handlePan(_:)))
|
||||||
|
pan.isEnabled = false // enabled only when enableScrubbing = true
|
||||||
|
addGestureRecognizer(pan)
|
||||||
|
panGesture = pan
|
||||||
}
|
}
|
||||||
|
|
||||||
convenience init(
|
convenience init(
|
||||||
@@ -63,7 +83,7 @@ final class WaveformView: UIView {
|
|||||||
setNeedsDisplay()
|
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) {
|
override func draw(_ rect: CGRect) {
|
||||||
guard !samples.isEmpty else { return }
|
guard !samples.isEmpty else { return }
|
||||||
@@ -85,19 +105,31 @@ final class WaveformView: UIView {
|
|||||||
|
|
||||||
let gravityMultiplierY: CGFloat = gravity == .bottom ? 1.0 : 0.5
|
let gravityMultiplierY: CGFloat = gravity == .bottom ? 1.0 : 0.5
|
||||||
|
|
||||||
// Draw background bars, then foreground bars on top
|
// Pre-extract RGBA components for fast blending
|
||||||
for pass in 0..<2 {
|
var bgR: CGFloat = 0, bgG: CGFloat = 0, bgB: CGFloat = 0, bgA: CGFloat = 0
|
||||||
let color = pass == 0 ? backgroundColor_ : foregroundColor_
|
var fgR: CGFloat = 0, fgG: CGFloat = 0, fgB: CGFloat = 0, fgA: CGFloat = 0
|
||||||
ctx.setFillColor(color.cgColor)
|
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 {
|
for i in 0..<numSamples {
|
||||||
let offset = CGFloat(i) * (sampleWidth + distance)
|
let offset = CGFloat(i) * (sampleWidth + distance)
|
||||||
|
|
||||||
// For foreground pass, only draw bars within progress
|
// Per-bar color blend at progress boundary
|
||||||
if pass == 1 {
|
let startFraction = CGFloat(i) / CGFloat(numSamples)
|
||||||
let samplePosition = CGFloat(i) / CGFloat(numSamples)
|
let nextStartFraction = CGFloat(i + 1) / CGFloat(numSamples)
|
||||||
guard samplePosition < progress else { continue }
|
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
|
var sampleHeight = CGFloat(resampled[i]) * peakHeight
|
||||||
if sampleHeight > peakHeight { sampleHeight = peakHeight }
|
if sampleHeight > peakHeight { sampleHeight = peakHeight }
|
||||||
@@ -105,7 +137,7 @@ final class WaveformView: UIView {
|
|||||||
let adjustedSampleHeight = sampleHeight - diff
|
let adjustedSampleHeight = sampleHeight - diff
|
||||||
|
|
||||||
if adjustedSampleHeight <= sampleWidth {
|
if adjustedSampleHeight <= sampleWidth {
|
||||||
// Tiny bar: single dot + small rect (Telegram lines 212-214)
|
// Tiny bar: single dot + small rect
|
||||||
ctx.fillEllipse(in: CGRect(
|
ctx.fillEllipse(in: CGRect(
|
||||||
x: offset,
|
x: offset,
|
||||||
y: (size.height - sampleWidth) * gravityMultiplierY,
|
y: (size.height - sampleWidth) * gravityMultiplierY,
|
||||||
@@ -119,7 +151,7 @@ final class WaveformView: UIView {
|
|||||||
height: halfSampleWidth
|
height: halfSampleWidth
|
||||||
))
|
))
|
||||||
} else {
|
} else {
|
||||||
// Normal bar: rect + top cap + bottom cap (Telegram lines 216-224)
|
// Normal bar: rect + top cap + bottom cap
|
||||||
let barRect = CGRect(
|
let barRect = CGRect(
|
||||||
x: offset,
|
x: offset,
|
||||||
y: (size.height - adjustedSampleHeight) * gravityMultiplierY,
|
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)
|
// MARK: - Resampling (Telegram: max extraction per bin)
|
||||||
|
|||||||
@@ -561,14 +561,14 @@ private struct ChatDetailPrincipal: View {
|
|||||||
let names = viewModel.typingSenderNames
|
let names = viewModel.typingSenderNames
|
||||||
if !names.isEmpty {
|
if !names.isEmpty {
|
||||||
if names.count == 1 {
|
if names.count == 1 {
|
||||||
return "\(names[0]) typing..."
|
return "\(names[0]) typing"
|
||||||
} else {
|
} else {
|
||||||
return "\(names[0]) and \(names.count - 1) typing..."
|
return "\(names[0]) and \(names.count - 1) typing"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return "group"
|
return "group"
|
||||||
}
|
}
|
||||||
if viewModel.isTyping { return "typing..." }
|
if viewModel.isTyping { return "typing" }
|
||||||
if let dialog, dialog.isOnline { return "online" }
|
if let dialog, dialog.isOnline { return "online" }
|
||||||
return "offline"
|
return "offline"
|
||||||
}
|
}
|
||||||
@@ -593,11 +593,22 @@ private struct ChatDetailPrincipal: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if !subtitleText.isEmpty {
|
if !subtitleText.isEmpty {
|
||||||
|
if viewModel.isTyping || !viewModel.typingSenderNames.isEmpty {
|
||||||
|
HStack(spacing: 0) {
|
||||||
|
TypingDotsRepresentable(color: UIColor(subtitleColor))
|
||||||
|
.frame(width: 24, height: 14)
|
||||||
Text(subtitleText)
|
Text(subtitleText)
|
||||||
.font(.system(size: 12, weight: .medium))
|
.font(.system(size: 12, weight: .medium))
|
||||||
.foregroundStyle(subtitleColor)
|
.foregroundStyle(subtitleColor)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
Text(subtitleText)
|
||||||
|
.font(.system(size: 12, weight: .medium))
|
||||||
|
.foregroundStyle(subtitleColor)
|
||||||
|
.lineLimit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import AVFAudio
|
import AVFAudio
|
||||||
@preconcurrency import AVFoundation
|
@preconcurrency import AVFoundation
|
||||||
|
import Lottie
|
||||||
import UIKit
|
import UIKit
|
||||||
|
|
||||||
// MARK: - ComposerViewDelegate
|
// MARK: - ComposerViewDelegate
|
||||||
@@ -1124,7 +1125,7 @@ extension ComposerView: RecordingMicButtonDelegate {
|
|||||||
recordingOverlay?.applyDragTransform(distanceX: distanceX, distanceY: distanceY)
|
recordingOverlay?.applyDragTransform(distanceX: distanceX, distanceY: distanceY)
|
||||||
recordingPanel?.updateCancelTranslation(distanceX)
|
recordingPanel?.updateCancelTranslation(distanceX)
|
||||||
let lockness = VoiceRecordingParityMath.lockness(distanceY: distanceY)
|
let lockness = VoiceRecordingParityMath.lockness(distanceY: distanceY)
|
||||||
recordingLockView?.updateLockness(lockness)
|
recordingLockView?.updateLockness(lockness, dragOffsetY: distanceY)
|
||||||
}
|
}
|
||||||
|
|
||||||
func showRecordingPreview() {
|
func showRecordingPreview() {
|
||||||
@@ -1368,15 +1369,10 @@ extension ComposerView: RecordingMicButtonDelegate {
|
|||||||
recordingPanel?.animateOut { [weak self] in
|
recordingPanel?.animateOut { [weak self] in
|
||||||
self?.recordingPanel = nil
|
self?.recordingPanel = nil
|
||||||
}
|
}
|
||||||
restoreComposerChrome()
|
|
||||||
case .cancel:
|
case .cancel:
|
||||||
recordingOverlay?.dismissCancel()
|
recordingOverlay?.dismissCancel()
|
||||||
recordingPanel?.animateOutCancel { [weak self] in
|
recordingPanel?.animateOutCancel { [weak self] in
|
||||||
self?.recordingPanel = nil
|
self?.recordingPanel = nil
|
||||||
self?.restoreComposerChrome()
|
|
||||||
}
|
|
||||||
if recordingPanel == nil {
|
|
||||||
restoreComposerChrome()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
recordingOverlay = nil
|
recordingOverlay = nil
|
||||||
@@ -1387,6 +1383,45 @@ extension ComposerView: RecordingMicButtonDelegate {
|
|||||||
recordingPreviewPanel?.animateOut { [weak self] in
|
recordingPreviewPanel?.animateOut { [weak self] in
|
||||||
self?.recordingPreviewPanel = nil
|
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() {
|
private func clearLastRecordedDraftFile() {
|
||||||
|
|||||||
@@ -205,8 +205,7 @@ final class MentionCell: UITableViewCell {
|
|||||||
avatarImageView.image = nil
|
avatarImageView.image = nil
|
||||||
avatarImageView.isHidden = true
|
avatarImageView.isHidden = true
|
||||||
avatarInitialLabel.isHidden = false
|
avatarInitialLabel.isHidden = false
|
||||||
let initial = String(candidate.title.prefix(1)).uppercased()
|
avatarInitialLabel.text = RosettaColors.initials(name: candidate.title, publicKey: candidate.publicKey)
|
||||||
avatarInitialLabel.text = initial
|
|
||||||
let colorIndex = RosettaColors.avatarColorIndex(for: 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)
|
// Mantine "light" variant: dark base + tint at 15% opacity (dark mode)
|
||||||
let isDark = traitCollection.userInterfaceStyle == .dark
|
let isDark = traitCollection.userInterfaceStyle == .dark
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import Lottie
|
||||||
import UIKit
|
import UIKit
|
||||||
|
|
||||||
// MARK: - MessageVoiceView
|
// MARK: - MessageVoiceView
|
||||||
@@ -9,6 +10,7 @@ final class MessageVoiceView: UIView {
|
|||||||
// MARK: - Subviews
|
// MARK: - Subviews
|
||||||
|
|
||||||
private let playButton = UIButton(type: .system)
|
private let playButton = UIButton(type: .system)
|
||||||
|
private let playPauseAnimationView = LottieAnimationView()
|
||||||
private let waveformView = WaveformView()
|
private let waveformView = WaveformView()
|
||||||
private let durationLabel = UILabel()
|
private let durationLabel = UILabel()
|
||||||
|
|
||||||
@@ -17,6 +19,9 @@ final class MessageVoiceView: UIView {
|
|||||||
private var messageId: String = ""
|
private var messageId: String = ""
|
||||||
private var attachmentId: String = ""
|
private var attachmentId: String = ""
|
||||||
private var isOutgoing = false
|
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)
|
// MARK: - Layout Constants (Telegram exact: ChatMessageInteractiveFileNode)
|
||||||
|
|
||||||
@@ -44,21 +49,35 @@ final class MessageVoiceView: UIView {
|
|||||||
// MARK: - Setup
|
// MARK: - Setup
|
||||||
|
|
||||||
private func setupSubviews() {
|
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.layer.cornerRadius = playButtonSize / 2
|
||||||
playButton.clipsToBounds = true
|
playButton.clipsToBounds = true
|
||||||
playButton.addTarget(self, action: #selector(playTapped), for: .touchUpInside)
|
playButton.addTarget(self, action: #selector(playTapped), for: .touchUpInside)
|
||||||
addSubview(playButton)
|
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.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)
|
addSubview(waveformView)
|
||||||
|
|
||||||
durationLabel.font = .monospacedDigitSystemFont(ofSize: 11, weight: .regular)
|
durationLabel.font = .monospacedDigitSystemFont(ofSize: 11, weight: .regular)
|
||||||
durationLabel.textColor = .white.withAlphaComponent(0.6)
|
|
||||||
addSubview(durationLabel)
|
addSubview(durationLabel)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -75,6 +94,13 @@ final class MessageVoiceView: UIView {
|
|||||||
width: playButtonSize,
|
width: playButtonSize,
|
||||||
height: 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
|
// Waveform: from x=57 to near right edge, height=18, y=1
|
||||||
let waveW = bounds.width - waveformX - 4
|
let waveW = bounds.width - waveformX - 4
|
||||||
@@ -101,6 +127,21 @@ final class MessageVoiceView: UIView {
|
|||||||
self.messageId = messageId
|
self.messageId = messageId
|
||||||
self.attachmentId = attachmentId
|
self.attachmentId = attachmentId
|
||||||
self.isOutgoing = isOutgoing
|
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
|
// Decode waveform from preview
|
||||||
let samples = Self.decodeWaveform(from: preview)
|
let samples = Self.decodeWaveform(from: preview)
|
||||||
@@ -108,20 +149,42 @@ final class MessageVoiceView: UIView {
|
|||||||
waveformView.progress = 0
|
waveformView.progress = 0
|
||||||
|
|
||||||
// Duration label
|
// Duration label
|
||||||
let totalSeconds = Int(duration)
|
durationLabel.text = Self.formatDuration(duration)
|
||||||
let minutes = totalSeconds / 60
|
|
||||||
let seconds = totalSeconds % 60
|
|
||||||
durationLabel.text = String(format: "%d:%02d", minutes, seconds)
|
|
||||||
|
|
||||||
// Style based on incoming/outgoing
|
// Telegram-exact theme-aware colors
|
||||||
if isOutgoing {
|
applyColors()
|
||||||
playButton.backgroundColor = .white
|
}
|
||||||
playButton.tintColor = UIColor(red: 0, green: 136/255.0, blue: 1, alpha: 1)
|
|
||||||
durationLabel.textColor = .white.withAlphaComponent(0.6)
|
// MARK: - Colors (Telegram exact: DefaultDarkPresentationTheme + DefaultDayPresentationTheme)
|
||||||
} else {
|
|
||||||
playButton.backgroundColor = UIColor(red: 0, green: 136/255.0, blue: 1, alpha: 1)
|
private func applyColors() {
|
||||||
playButton.tintColor = .white
|
let isDark = traitCollection.userInterfaceStyle == .dark
|
||||||
durationLabel.textColor = UIColor.white.withAlphaComponent(0.5)
|
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.
|
/// Update play button icon and waveform progress from VoiceMessagePlayer state.
|
||||||
func updatePlaybackState(isPlaying: Bool, progress: CGFloat) {
|
func updatePlaybackState(isPlaying: Bool, progress: CGFloat) {
|
||||||
let config = UIImage.SymbolConfiguration(pointSize: 16, weight: .bold)
|
// Don't override progress while user is scrubbing
|
||||||
let name = isPlaying ? "pause.fill" : "play.fill"
|
if !waveformView.isScrubbing {
|
||||||
playButton.setImage(UIImage(systemName: name, withConfiguration: config), for: .normal)
|
|
||||||
waveformView.progress = progress
|
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
|
// MARK: - Waveform Decoding
|
||||||
|
|
||||||
|
|||||||
@@ -248,6 +248,9 @@ final class NativeMessageCell: UICollectionViewCell {
|
|||||||
private var message: ChatMessage?
|
private var message: ChatMessage?
|
||||||
private var actions: MessageCellActions?
|
private var actions: MessageCellActions?
|
||||||
private(set) var currentLayout: MessageCellLayout?
|
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 isSavedMessages = false
|
||||||
var isSystemAccount = false
|
var isSystemAccount = false
|
||||||
/// When true, the inline date header pill is hidden (floating sticky one covers it).
|
/// 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
|
let textTopY = topY + 4
|
||||||
fileNameLabel.frame = CGRect(x: 63, y: textTopY, width: fileW - 75, height: 19)
|
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)
|
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 {
|
} else {
|
||||||
// File layout: vertically centered icon + title + size
|
// File layout: vertically centered icon + title + size
|
||||||
let contentH: CGFloat = 44 // icon height dominates
|
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? {
|
func voiceTransitionTargetFrame(in window: UIWindow) -> CGRect? {
|
||||||
guard !voiceView.isHidden else { return nil }
|
guard !voiceView.isHidden else { return nil }
|
||||||
return voiceView.convert(voiceView.bounds, to: window)
|
return voiceView.convert(voiceView.bounds, to: window)
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import Combine
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import UIKit
|
import UIKit
|
||||||
|
|
||||||
@@ -124,6 +125,9 @@ final class NativeMessageListController: UIViewController {
|
|||||||
/// scrollViewDidEndScrollingAnimation prematurely.
|
/// scrollViewDidEndScrollingAnimation prematurely.
|
||||||
private var scrollToBottomTimestamp: CFAbsoluteTime = 0
|
private var scrollToBottomTimestamp: CFAbsoluteTime = 0
|
||||||
|
|
||||||
|
// MARK: - Voice Playback Live Updates
|
||||||
|
private var voicePlayerCancellables = Set<AnyCancellable>()
|
||||||
|
|
||||||
// MARK: - Scroll-to-Bottom Button
|
// MARK: - Scroll-to-Bottom Button
|
||||||
private var scrollToBottomButton: UIButton?
|
private var scrollToBottomButton: UIButton?
|
||||||
private var scrollToBottomButtonContainer: UIView?
|
private var scrollToBottomButtonContainer: UIView?
|
||||||
@@ -253,6 +257,9 @@ final class NativeMessageListController: UIViewController {
|
|||||||
self.refreshAllMessageCells()
|
self.refreshAllMessageCells()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Voice playback: live waveform progress updates
|
||||||
|
setupVoicePlayerSubscription()
|
||||||
|
|
||||||
// Show skeleton placeholder while messages load from DB
|
// Show skeleton placeholder while messages load from DB
|
||||||
if messages.isEmpty {
|
if messages.isEmpty {
|
||||||
showSkeleton()
|
showSkeleton()
|
||||||
@@ -636,14 +643,20 @@ final class NativeMessageListController: UIViewController {
|
|||||||
/// Auto Layout ↔ transform race condition during interactive dismiss.
|
/// Auto Layout ↔ transform race condition during interactive dismiss.
|
||||||
func setScrollToBottomVisible(_ visible: Bool) {
|
func setScrollToBottomVisible(_ visible: Bool) {
|
||||||
guard let button = scrollToBottomButton else { return }
|
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
|
let isCurrentlyVisible = button.alpha > 0.5
|
||||||
guard visible != isCurrentlyVisible else { return }
|
guard effectiveVisible != isCurrentlyVisible else { return }
|
||||||
|
|
||||||
UIView.animate(withDuration: 0.3, delay: 0,
|
UIView.animate(withDuration: 0.3, delay: 0,
|
||||||
usingSpringWithDamping: 0.82, initialSpringVelocity: 0,
|
usingSpringWithDamping: 0.82, initialSpringVelocity: 0,
|
||||||
options: .beginFromCurrentState) {
|
options: .beginFromCurrentState) {
|
||||||
button.alpha = visible ? 1 : 0
|
button.alpha = effectiveVisible ? 1 : 0
|
||||||
button.layer.transform = visible
|
button.layer.transform = effectiveVisible
|
||||||
? CATransform3DIdentity
|
? CATransform3DIdentity
|
||||||
: CATransform3DMakeScale(0.2, 0.2, 1.0)
|
: 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
|
// MARK: - Voice Recording
|
||||||
|
|
||||||
func composerDidStartRecording(_ composer: ComposerView) {
|
func composerDidStartRecording(_ composer: ComposerView) {
|
||||||
// Recording started — handled by ComposerView internally
|
setScrollToBottomVisible(false)
|
||||||
updateScrollToBottomButtonConstraints()
|
updateScrollToBottomButtonConstraints()
|
||||||
view.layoutIfNeeded()
|
view.layoutIfNeeded()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -75,7 +75,7 @@ final class RecordingLockView: UIView {
|
|||||||
|
|
||||||
private func setupPanel() {
|
private func setupPanel() {
|
||||||
panelGlassView.isUserInteractionEnabled = false
|
panelGlassView.isUserInteractionEnabled = false
|
||||||
panelGlassView.fixedCornerRadius = panelFullHeight / 2.0
|
panelGlassView.fixedCornerRadius = panelWidth / 2.0
|
||||||
addSubview(panelGlassView)
|
addSubview(panelGlassView)
|
||||||
|
|
||||||
panelBorderView.isUserInteractionEnabled = false
|
panelBorderView.isUserInteractionEnabled = false
|
||||||
@@ -89,8 +89,12 @@ final class RecordingLockView: UIView {
|
|||||||
lockAnimationContainer.isUserInteractionEnabled = false
|
lockAnimationContainer.isUserInteractionEnabled = false
|
||||||
addSubview(lockAnimationContainer)
|
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) {
|
if let animation = LottieAnimation.named(VoiceRecordingLottieAsset.lockWait.rawValue) {
|
||||||
idleLockView.animation = animation
|
idleLockView.animation = animation
|
||||||
|
idleLockView.configuration = mainThreadConfig
|
||||||
}
|
}
|
||||||
idleLockView.backgroundBehavior = .pauseAndRestore
|
idleLockView.backgroundBehavior = .pauseAndRestore
|
||||||
idleLockView.loopMode = .autoReverse
|
idleLockView.loopMode = .autoReverse
|
||||||
@@ -100,6 +104,7 @@ final class RecordingLockView: UIView {
|
|||||||
|
|
||||||
if let animation = LottieAnimation.named(VoiceRecordingLottieAsset.lock.rawValue) {
|
if let animation = LottieAnimation.named(VoiceRecordingLottieAsset.lock.rawValue) {
|
||||||
lockingView.animation = animation
|
lockingView.animation = animation
|
||||||
|
lockingView.configuration = mainThreadConfig
|
||||||
}
|
}
|
||||||
lockingView.backgroundBehavior = .pauseAndRestore
|
lockingView.backgroundBehavior = .pauseAndRestore
|
||||||
lockingView.loopMode = .playOnce
|
lockingView.loopMode = .playOnce
|
||||||
@@ -118,10 +123,8 @@ final class RecordingLockView: UIView {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func setupArrow() {
|
private func setupArrow() {
|
||||||
lockArrowView.image = VoiceRecordingAssets.image(.videoRecordArrow, templated: true)
|
// Arrow is part of the Lottie animation — no separate UIImageView needed.
|
||||||
lockArrowView.contentMode = .center
|
lockArrowView.isHidden = true
|
||||||
lockArrowView.isUserInteractionEnabled = false
|
|
||||||
addSubview(lockArrowView)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func setupStopButton() {
|
private func setupStopButton() {
|
||||||
@@ -182,11 +185,11 @@ final class RecordingLockView: UIView {
|
|||||||
|
|
||||||
let panelFrame = CGRect(x: 0, y: panelY, width: panelWidth, height: panelHeight)
|
let panelFrame = CGRect(x: 0, y: panelY, width: panelWidth, height: panelHeight)
|
||||||
panelGlassView.frame = panelFrame
|
panelGlassView.frame = panelFrame
|
||||||
panelGlassView.fixedCornerRadius = panelHeight / 2.0
|
panelGlassView.fixedCornerRadius = min(panelWidth, panelHeight) / 2.0
|
||||||
panelGlassView.applyCornerRadius()
|
panelGlassView.applyCornerRadius()
|
||||||
|
|
||||||
panelBorderView.frame = panelFrame
|
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)
|
lockAnimationContainer.frame = CGRect(x: 0, y: 6.0, width: 40.0, height: 60.0)
|
||||||
idleLockView.frame = lockAnimationContainer.bounds
|
idleLockView.frame = lockAnimationContainer.bounds
|
||||||
@@ -275,10 +278,13 @@ final class RecordingLockView: UIView {
|
|||||||
// MARK: - Lockness Update
|
// MARK: - Lockness Update
|
||||||
|
|
||||||
/// Update lock progress (0 = idle, 1 = locked).
|
/// 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 }
|
guard visualState == .lock else { return }
|
||||||
|
|
||||||
currentLockness = max(0, min(1, lockness))
|
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()
|
updatePanelGeometry()
|
||||||
|
|
||||||
if currentLockness > 0 {
|
if currentLockness > 0 {
|
||||||
@@ -357,6 +363,12 @@ final class RecordingLockView: UIView {
|
|||||||
stopButton.layer.zPosition = 100
|
stopButton.layer.zPosition = 100
|
||||||
bringSubviewToFront(stopButton)
|
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]) {
|
UIView.animate(withDuration: 0.25, delay: 0.56, options: [.curveEaseOut]) {
|
||||||
self.stopButton.alpha = 1
|
self.stopButton.alpha = 1
|
||||||
self.stopButton.transform = .identity
|
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)
|
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)
|
borderColor = isDark ? UIColor(white: 1.0, alpha: 0.22) : UIColor(white: 0.0, alpha: 0.14)
|
||||||
} else {
|
} else {
|
||||||
iconColor = UIColor(white: 0.58, alpha: 1.0)
|
iconColor = isDark ? UIColor.white : UIColor(white: 0.20, alpha: 1.0)
|
||||||
borderColor = UIColor(white: 0.7, alpha: 0.55)
|
borderColor = isDark ? UIColor(white: 1.0, alpha: 0.18) : UIColor(white: 0.0, alpha: 0.14)
|
||||||
}
|
}
|
||||||
|
|
||||||
panelBorderView.layer.borderColor = borderColor.cgColor
|
panelBorderView.layer.borderColor = borderColor.cgColor
|
||||||
@@ -417,8 +429,19 @@ final class RecordingLockView: UIView {
|
|||||||
|
|
||||||
stopBorderView.layer.borderColor = borderColor.cgColor
|
stopBorderView.layer.borderColor = borderColor.cgColor
|
||||||
stopGlyphView.tintColor = iconColor
|
stopGlyphView.tintColor = iconColor
|
||||||
lockArrowView.tintColor = iconColor
|
|
||||||
lockFallbackGlyphView.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
|
// MARK: - Stop Action
|
||||||
|
|||||||
@@ -172,14 +172,13 @@ final class VoiceRecordingPanel: UIView {
|
|||||||
let timerWidth = max(timerMinWidth, timerSize.width + 4)
|
let timerWidth = max(timerMinWidth, timerSize.width + 4)
|
||||||
timerLabel.frame = CGRect(x: timerX, y: timerY, width: timerWidth, height: timerSize.height)
|
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 labelSize = slideLabel.sizeThatFits(CGSize(width: 200, height: h))
|
||||||
let arrowW: CGFloat = 9 // Telegram SVG: 9pt wide
|
let arrowW: CGFloat = 9 // Telegram SVG: 9pt wide
|
||||||
let arrowH: CGFloat = 18 // Telegram SVG: 18pt tall
|
let arrowH: CGFloat = 18 // Telegram SVG: 18pt tall
|
||||||
let totalCancelW = arrowW + 12 + labelSize.width
|
let totalCancelW = arrowW + 12 + labelSize.width
|
||||||
let timerTrailingX = timerX + timerWidth
|
let cancelRightShift: CGFloat = 16
|
||||||
let availableWidth = w - timerTrailingX
|
let cancelX = floor((w - totalCancelW) / 2) + cancelRightShift
|
||||||
let cancelX = timerTrailingX + floor((availableWidth - totalCancelW) / 2)
|
|
||||||
|
|
||||||
cancelContainer.frame = CGRect(x: cancelX, y: 0, width: totalCancelW, height: h)
|
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)
|
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
|
height: labelSize.height
|
||||||
)
|
)
|
||||||
|
|
||||||
// Cancel button: centered in available space after timer
|
// Cancel button: centered with same right offset
|
||||||
cancelButton.sizeToFit()
|
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?) {
|
override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
|
||||||
@@ -334,36 +333,10 @@ final class VoiceRecordingPanel: UIView {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Telegram parity: on cancel, panel content disappears quickly while
|
// Bin Lottie now plays inside attachButton (ComposerView.playBinAnimationInAttachButton).
|
||||||
// bin animation keeps playing near the leading edge.
|
// Panel just fades its elements out.
|
||||||
let indicatorFrame = CGRect(x: 0, y: floor((bounds.height - 40) / 2.0), width: 40, height: 40)
|
didFinishBin = true
|
||||||
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)
|
|
||||||
redDot.alpha = 0
|
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.
|
// Timer: scale to 0, slide left.
|
||||||
UIView.animate(withDuration: 0.2) {
|
UIView.animate(withDuration: 0.2) {
|
||||||
|
|||||||
@@ -53,6 +53,10 @@ final class ChatListCell: UICollectionViewCell {
|
|||||||
// Message row
|
// Message row
|
||||||
let messageLabel = UILabel()
|
let messageLabel = UILabel()
|
||||||
|
|
||||||
|
// Typing indicator
|
||||||
|
let typingDotsView = TypingDotsView()
|
||||||
|
let typingLabel = UILabel()
|
||||||
|
|
||||||
// Trailing column
|
// Trailing column
|
||||||
let dateLabel = UILabel()
|
let dateLabel = UILabel()
|
||||||
let statusImageView = UIImageView()
|
let statusImageView = UIImageView()
|
||||||
@@ -151,6 +155,14 @@ final class ChatListCell: UICollectionViewCell {
|
|||||||
messageLabel.lineBreakMode = .byTruncatingTail
|
messageLabel.lineBreakMode = .byTruncatingTail
|
||||||
contentView.addSubview(messageLabel)
|
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
|
// Date
|
||||||
dateLabel.font = .systemFont(ofSize: 14, weight: .regular)
|
dateLabel.font = .systemFont(ofSize: 14, weight: .regular)
|
||||||
dateLabel.textAlignment = .right
|
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 ──
|
// ── Separator ──
|
||||||
let separatorHeight = 1.0 / scale
|
let separatorHeight = 1.0 / scale
|
||||||
separatorView.frame = CGRect(
|
separatorView.frame = CGRect(
|
||||||
@@ -363,7 +386,7 @@ final class ChatListCell: UICollectionViewCell {
|
|||||||
/// Message text cache (shared across cells, avoids regex per configure).
|
/// Message text cache (shared across cells, avoids regex per configure).
|
||||||
private static var messageTextCache: [String: String] = [:]
|
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
|
let isDark = traitCollection.userInterfaceStyle == .dark
|
||||||
isPinned = dialog.isPinned
|
isPinned = dialog.isPinned
|
||||||
|
|
||||||
@@ -408,8 +431,14 @@ final class ChatListCell: UICollectionViewCell {
|
|||||||
mutedIconView.isHidden = !dialog.isMuted
|
mutedIconView.isHidden = !dialog.isMuted
|
||||||
mutedIconView.tintColor = secondaryColor
|
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)
|
configureMessageText(dialog: dialog, secondaryColor: secondaryColor, titleColor: titleColor)
|
||||||
|
}
|
||||||
|
|
||||||
// Date
|
// Date
|
||||||
dateLabel.text = formatTime(dialog.lastMessageTimestamp)
|
dateLabel.text = formatTime(dialog.lastMessageTimestamp)
|
||||||
@@ -667,6 +696,49 @@ final class ChatListCell: UICollectionViewCell {
|
|||||||
messageLabel.textColor = secondaryColor
|
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 {
|
private func resolveMessageText(dialog: Dialog) -> String {
|
||||||
let raw = dialog.lastMessage.trimmingCharacters(in: .whitespacesAndNewlines)
|
let raw = dialog.lastMessage.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
if raw.isEmpty { return "No messages yet" }
|
if raw.isEmpty { return "No messages yet" }
|
||||||
@@ -778,6 +850,10 @@ final class ChatListCell: UICollectionViewCell {
|
|||||||
messageLabel.attributedText = nil
|
messageLabel.attributedText = nil
|
||||||
messageLabel.numberOfLines = 2
|
messageLabel.numberOfLines = 2
|
||||||
authorLabel.isHidden = true
|
authorLabel.isHidden = true
|
||||||
|
// Typing indicator
|
||||||
|
typingDotsView.stopAnimating()
|
||||||
|
typingDotsView.isHidden = true
|
||||||
|
typingLabel.isHidden = true
|
||||||
// Badge animation state
|
// Badge animation state
|
||||||
wasBadgeVisible = false
|
wasBadgeVisible = false
|
||||||
wasMentionBadgeVisible = false
|
wasMentionBadgeVisible = false
|
||||||
|
|||||||
@@ -129,11 +129,13 @@ final class ChatListCollectionController: UIViewController {
|
|||||||
cellRegistration = UICollectionView.CellRegistration<ChatListCell, Dialog> {
|
cellRegistration = UICollectionView.CellRegistration<ChatListCell, Dialog> {
|
||||||
[weak self] cell, indexPath, dialog in
|
[weak self] cell, indexPath, dialog in
|
||||||
guard let self else { return }
|
guard let self else { return }
|
||||||
cell.configure(with: dialog, isSyncing: self.isSyncing)
|
let typingUsers = self.typingDialogs[dialog.opponentKey]
|
||||||
// Hide separator for first cell in first dialog section
|
cell.configure(with: dialog, isSyncing: self.isSyncing, typingUsers: typingUsers)
|
||||||
let isFirstDialogSection = (self.sectionForIndexPath(indexPath) == .pinned && self.requestsCount == 0)
|
// Hide separator for last cell in pinned/unpinned section
|
||||||
|| (self.sectionForIndexPath(indexPath) == .unpinned && self.pinnedDialogs.isEmpty && self.requestsCount == 0)
|
let section = self.sectionForIndexPath(indexPath)
|
||||||
cell.setSeparatorHidden(indexPath.item == 0 && isFirstDialogSection)
|
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> {
|
requestsCellRegistration = UICollectionView.CellRegistration<ChatListRequestsCell, Int> {
|
||||||
@@ -235,7 +237,8 @@ final class ChatListCollectionController: UIViewController {
|
|||||||
guard let itemId = dataSource.itemIdentifier(for: indexPath) else { continue }
|
guard let itemId = dataSource.itemIdentifier(for: indexPath) else { continue }
|
||||||
|
|
||||||
if let chatCell = cell as? ChatListCell, let dialog = dialogMap[itemId] {
|
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 {
|
} else if let reqCell = cell as? ChatListRequestsCell {
|
||||||
reqCell.configure(count: requestsCount)
|
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