From 08a1da64a8f4ee020fd22f88c112ac9c95dd4009 Mon Sep 17 00:00:00 2001 From: senseiGai Date: Sun, 12 Apr 2026 23:30:00 +0500 Subject: [PATCH] =?UTF-8?q?=D0=93=D0=BE=D0=BB=D0=BE=D1=81=D0=BE=D0=B2?= =?UTF-8?q?=D1=8B=D0=B5=20=D1=81=D0=BE=D0=BE=D0=B1=D1=89=D0=B5=D0=BD=D0=B8?= =?UTF-8?q?=D1=8F=20=E2=80=94=20=D1=84=D0=B8=D0=BA=D1=81=D1=8B=20lock=20vi?= =?UTF-8?q?ew,=20cancel=20=D0=B0=D0=BD=D0=B8=D0=BC=D0=B0=D1=86=D0=B8=D1=8F?= =?UTF-8?q?,=20recording=20panel=20UI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Rosetta/DesignSystem/Colors.swift | 53 +++++ .../Components/WaveformView.swift | 177 +++++++++++----- .../Chats/ChatDetail/ChatDetailView.swift | 25 ++- .../Chats/ChatDetail/ComposerView.swift | 47 ++++- .../ChatDetail/MentionAutocompleteView.swift | 3 +- .../Chats/ChatDetail/MessageVoiceView.swift | 196 +++++++++++++++--- .../Chats/ChatDetail/NativeMessageCell.swift | 19 ++ .../Chats/ChatDetail/NativeMessageList.swift | 84 +++++++- .../Chats/ChatDetail/RecordingLockView.swift | 45 +++- .../ChatDetail/VoiceRecordingPanel.swift | 45 +--- .../Chats/ChatList/UIKit/ChatListCell.swift | 82 +++++++- .../UIKit/ChatListCollectionController.swift | 15 +- .../Chats/ChatList/UIKit/TypingDotsView.swift | 115 ++++++++++ 13 files changed, 751 insertions(+), 155 deletions(-) create mode 100644 Rosetta/Features/Chats/ChatList/UIKit/TypingDotsView.swift diff --git a/Rosetta/DesignSystem/Colors.swift b/Rosetta/DesignSystem/Colors.swift index 0d2248b..8451cc0 100644 --- a/Rosetta/DesignSystem/Colors.swift +++ b/Rosetta/DesignSystem/Colors.swift @@ -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 diff --git a/Rosetta/DesignSystem/Components/WaveformView.swift b/Rosetta/DesignSystem/Components/WaveformView.swift index d0d4b41..43137c3 100644 --- a/Rosetta/DesignSystem/Components/WaveformView.swift +++ b/Rosetta/DesignSystem/Components/WaveformView.swift @@ -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,62 +105,107 @@ 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) - for i in 0.. peakHeight { sampleHeight = peakHeight } - - let adjustedSampleHeight = sampleHeight - diff - - if adjustedSampleHeight <= sampleWidth { - // Tiny bar: single dot + small rect (Telegram lines 212-214) - ctx.fillEllipse(in: CGRect( - x: offset, - y: (size.height - sampleWidth) * gravityMultiplierY, - width: sampleWidth, - height: sampleWidth - )) - ctx.fill(CGRect( - x: offset, - y: (size.height - halfSampleWidth) * gravityMultiplierY, - width: sampleWidth, - height: halfSampleWidth - )) - } else { - // Normal bar: rect + top cap + bottom cap (Telegram lines 216-224) - let barRect = CGRect( - x: offset, - y: (size.height - adjustedSampleHeight) * gravityMultiplierY, - width: sampleWidth, - height: adjustedSampleHeight - ) - ctx.fill(barRect) - ctx.fillEllipse(in: CGRect( - x: barRect.minX, - y: barRect.minY - halfSampleWidth, - width: sampleWidth, - height: sampleWidth - )) - ctx.fillEllipse(in: CGRect( - x: barRect.minX, - y: barRect.maxY - halfSampleWidth, - width: sampleWidth, - height: sampleWidth - )) - } + // 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 } + + let adjustedSampleHeight = sampleHeight - diff + + if adjustedSampleHeight <= sampleWidth { + // Tiny bar: single dot + small rect + ctx.fillEllipse(in: CGRect( + x: offset, + y: (size.height - sampleWidth) * gravityMultiplierY, + width: sampleWidth, + height: sampleWidth + )) + ctx.fill(CGRect( + x: offset, + y: (size.height - halfSampleWidth) * gravityMultiplierY, + width: sampleWidth, + height: halfSampleWidth + )) + } else { + // Normal bar: rect + top cap + bottom cap + let barRect = CGRect( + x: offset, + y: (size.height - adjustedSampleHeight) * gravityMultiplierY, + width: sampleWidth, + height: adjustedSampleHeight + ) + ctx.fill(barRect) + ctx.fillEllipse(in: CGRect( + x: barRect.minX, + y: barRect.minY - halfSampleWidth, + width: sampleWidth, + height: sampleWidth + )) + ctx.fillEllipse(in: CGRect( + x: barRect.minX, + y: barRect.maxY - halfSampleWidth, + width: sampleWidth, + height: sampleWidth + )) + } + } + } + + // 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 } } diff --git a/Rosetta/Features/Chats/ChatDetail/ChatDetailView.swift b/Rosetta/Features/Chats/ChatDetail/ChatDetailView.swift index c07f04a..cb30998 100644 --- a/Rosetta/Features/Chats/ChatDetail/ChatDetailView.swift +++ b/Rosetta/Features/Chats/ChatDetail/ChatDetailView.swift @@ -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,10 +593,21 @@ private struct ChatDetailPrincipal: View { } if !subtitleText.isEmpty { - Text(subtitleText) - .font(.system(size: 12, weight: .medium)) - .foregroundStyle(subtitleColor) - .lineLimit(1) + 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) + } } } } diff --git a/Rosetta/Features/Chats/ChatDetail/ComposerView.swift b/Rosetta/Features/Chats/ChatDetail/ComposerView.swift index 9a7d315..946b61a 100644 --- a/Rosetta/Features/Chats/ChatDetail/ComposerView.swift +++ b/Rosetta/Features/Chats/ChatDetail/ComposerView.swift @@ -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() { diff --git a/Rosetta/Features/Chats/ChatDetail/MentionAutocompleteView.swift b/Rosetta/Features/Chats/ChatDetail/MentionAutocompleteView.swift index 010db97..59e98bd 100644 --- a/Rosetta/Features/Chats/ChatDetail/MentionAutocompleteView.swift +++ b/Rosetta/Features/Chats/ChatDetail/MentionAutocompleteView.swift @@ -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 diff --git a/Rosetta/Features/Chats/ChatDetail/MessageVoiceView.swift b/Rosetta/Features/Chats/ChatDetail/MessageVoiceView.swift index 829a9d9..e0abb6c 100644 --- a/Rosetta/Features/Chats/ChatDetail/MessageVoiceView.swift +++ b/Rosetta/Features/Chats/ChatDetail/MessageVoiceView.swift @@ -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) - waveformView.peakHeight = 18 // Telegram AudioWaveformComponent peak - waveformView.gravity = .center + // 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.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,10 +196,95 @@ 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) - waveformView.progress = progress + // 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 diff --git a/Rosetta/Features/Chats/ChatDetail/NativeMessageCell.swift b/Rosetta/Features/Chats/ChatDetail/NativeMessageCell.swift index 14867b5..71d44ee 100644 --- a/Rosetta/Features/Chats/ChatDetail/NativeMessageCell.swift +++ b/Rosetta/Features/Chats/ChatDetail/NativeMessageCell.swift @@ -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) diff --git a/Rosetta/Features/Chats/ChatDetail/NativeMessageList.swift b/Rosetta/Features/Chats/ChatDetail/NativeMessageList.swift index 3772ac6..646ab88 100644 --- a/Rosetta/Features/Chats/ChatDetail/NativeMessageList.swift +++ b/Rosetta/Features/Chats/ChatDetail/NativeMessageList.swift @@ -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() + // 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() } diff --git a/Rosetta/Features/Chats/ChatDetail/RecordingLockView.swift b/Rosetta/Features/Chats/ChatDetail/RecordingLockView.swift index 8fabd84..a0edaf4 100644 --- a/Rosetta/Features/Chats/ChatDetail/RecordingLockView.swift +++ b/Rosetta/Features/Chats/ChatDetail/RecordingLockView.swift @@ -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 diff --git a/Rosetta/Features/Chats/ChatDetail/VoiceRecordingPanel.swift b/Rosetta/Features/Chats/ChatDetail/VoiceRecordingPanel.swift index 75535e5..1190d7e 100644 --- a/Rosetta/Features/Chats/ChatDetail/VoiceRecordingPanel.swift +++ b/Rosetta/Features/Chats/ChatDetail/VoiceRecordingPanel.swift @@ -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) - 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 - }) - }) - } + // Bin Lottie now plays inside attachButton (ComposerView.playBinAnimationInAttachButton). + // Panel just fades its elements out. + didFinishBin = true + redDot.alpha = 0 // Timer: scale to 0, slide left. UIView.animate(withDuration: 0.2) { diff --git a/Rosetta/Features/Chats/ChatList/UIKit/ChatListCell.swift b/Rosetta/Features/Chats/ChatList/UIKit/ChatListCell.swift index ad2d1e0..0555d16 100644 --- a/Rosetta/Features/Chats/ChatList/UIKit/ChatListCell.swift +++ b/Rosetta/Features/Chats/ChatList/UIKit/ChatListCell.swift @@ -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? = nil) { let isDark = traitCollection.userInterfaceStyle == .dark isPinned = dialog.isPinned @@ -408,8 +431,14 @@ final class ChatListCell: UICollectionViewCell { mutedIconView.isHidden = !dialog.isMuted mutedIconView.tintColor = secondaryColor - // Message text (typing is NOT shown in chat list — only inside chat detail) - configureMessageText(dialog: dialog, secondaryColor: secondaryColor, titleColor: titleColor) + // Message text or typing indicator + let activeTyping = typingUsers.flatMap { $0.isEmpty ? nil : $0 } + if let typers = activeTyping { + configureTypingIndicator(dialog: dialog, typingUsers: typers, color: secondaryColor) + } else { + hideTypingIndicator() + configureMessageText(dialog: dialog, secondaryColor: secondaryColor, titleColor: titleColor) + } // Date dateLabel.text = formatTime(dialog.lastMessageTimestamp) @@ -667,6 +696,49 @@ final class ChatListCell: UICollectionViewCell { messageLabel.textColor = secondaryColor } + // MARK: - Typing Indicator + + private func configureTypingIndicator(dialog: Dialog, typingUsers: Set, 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 diff --git a/Rosetta/Features/Chats/ChatList/UIKit/ChatListCollectionController.swift b/Rosetta/Features/Chats/ChatList/UIKit/ChatListCollectionController.swift index 2d8e6da..cf2702f 100644 --- a/Rosetta/Features/Chats/ChatList/UIKit/ChatListCollectionController.swift +++ b/Rosetta/Features/Chats/ChatList/UIKit/ChatListCollectionController.swift @@ -129,11 +129,13 @@ final class ChatListCollectionController: UIViewController { cellRegistration = UICollectionView.CellRegistration { [weak self] cell, indexPath, dialog in guard let self else { return } - cell.configure(with: dialog, isSyncing: self.isSyncing) - // Hide separator for first cell in first dialog section - let isFirstDialogSection = (self.sectionForIndexPath(indexPath) == .pinned && self.requestsCount == 0) - || (self.sectionForIndexPath(indexPath) == .unpinned && self.pinnedDialogs.isEmpty && self.requestsCount == 0) - cell.setSeparatorHidden(indexPath.item == 0 && isFirstDialogSection) + 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 { @@ -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) } diff --git a/Rosetta/Features/Chats/ChatList/UIKit/TypingDotsView.swift b/Rosetta/Features/Chats/ChatList/UIKit/TypingDotsView.swift new file mode 100644 index 0000000..6319ab1 --- /dev/null +++ b/Rosetta/Features/Chats/ChatList/UIKit/TypingDotsView.swift @@ -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 + } +}