Фикс: Request Chats навигация + reply cancel (X) + emoji краш в группах + баннер аватарка
This commit is contained in:
@@ -571,14 +571,15 @@ final class SessionManager {
|
||||
attachments: [PendingAttachment],
|
||||
toPublicKey: String,
|
||||
opponentTitle: String = "",
|
||||
opponentUsername: String = ""
|
||||
opponentUsername: String = "",
|
||||
messageId: String? = nil
|
||||
) async throws -> String {
|
||||
guard let privKey = privateKeyHex, let hash = privateKeyHash else {
|
||||
Self.logger.error("📤 Cannot send — missing keys")
|
||||
throw CryptoError.decryptionFailed
|
||||
}
|
||||
|
||||
let messageId = UUID().uuidString.replacingOccurrences(of: "-", with: "").lowercased()
|
||||
let messageId = messageId ?? UUID().uuidString.replacingOccurrences(of: "-", with: "").lowercased()
|
||||
let timestamp = Int64(Date().timeIntervalSince1970 * 1000)
|
||||
|
||||
// Android parity: no caption → encrypt empty string "".
|
||||
|
||||
@@ -23,6 +23,10 @@ final class VoiceMessagePlayer: ObservableObject {
|
||||
@Published private(set) var currentTime: TimeInterval = 0
|
||||
@Published private(set) var duration: TimeInterval = 0
|
||||
|
||||
/// Called with the finished messageId right before `stop()` resets state.
|
||||
/// Used by NativeMessageListController for sequential voice autoplay.
|
||||
var onPlaybackCompleted: ((String) -> Void)?
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
private var audioPlayer: AVAudioPlayer?
|
||||
@@ -123,8 +127,12 @@ final class VoiceMessagePlayer: ObservableObject {
|
||||
private func updateProgress() {
|
||||
guard let player = audioPlayer else { return }
|
||||
if !player.isPlaying && isPlaying {
|
||||
// Playback ended
|
||||
// Playback ended naturally — notify before resetting state
|
||||
let finishedId = currentMessageId
|
||||
stop()
|
||||
if let finishedId {
|
||||
onPlaybackCompleted?(finishedId)
|
||||
}
|
||||
return
|
||||
}
|
||||
currentTime = player.currentTime
|
||||
|
||||
@@ -281,20 +281,29 @@ final class InAppBannerView: UIView {
|
||||
let textColor = RosettaColors.avatarTextColor(for: avatarIndex)
|
||||
|
||||
if isGroup {
|
||||
avatarImageView.isHidden = true
|
||||
groupIconView.isHidden = true
|
||||
|
||||
avatarBackgroundView.isHidden = false
|
||||
avatarTintOverlayView.isHidden = false
|
||||
avatarInitialsLabel.isHidden = false
|
||||
// Try loading group photo avatar first (same as 1:1 chats)
|
||||
if let image = AvatarRepository.shared.loadAvatar(publicKey: senderKey) {
|
||||
avatarImageView.image = image
|
||||
avatarImageView.isHidden = false
|
||||
avatarBackgroundView.isHidden = true
|
||||
avatarInitialsLabel.isHidden = true
|
||||
avatarTintOverlayView.isHidden = true
|
||||
} else {
|
||||
avatarImageView.isHidden = true
|
||||
avatarBackgroundView.isHidden = false
|
||||
avatarTintOverlayView.isHidden = false
|
||||
avatarInitialsLabel.isHidden = false
|
||||
|
||||
let isDark = traitCollection.userInterfaceStyle == .dark
|
||||
avatarBackgroundView.backgroundColor = isDark
|
||||
? UIColor(red: 0x1A / 255.0, green: 0x1B / 255.0, blue: 0x1E / 255.0, alpha: 1.0)
|
||||
: UIColor(red: 0xF1 / 255.0, green: 0xF3 / 255.0, blue: 0xF5 / 255.0, alpha: 1.0)
|
||||
avatarTintOverlayView.backgroundColor = tintColor.withAlphaComponent(0.15)
|
||||
avatarInitialsLabel.textColor = textColor
|
||||
avatarInitialsLabel.text = RosettaColors.groupInitial(name: senderName, publicKey: senderKey)
|
||||
let isDark = traitCollection.userInterfaceStyle == .dark
|
||||
avatarBackgroundView.backgroundColor = isDark
|
||||
? UIColor(red: 0x1A / 255.0, green: 0x1B / 255.0, blue: 0x1E / 255.0, alpha: 1.0)
|
||||
: UIColor(red: 0xF1 / 255.0, green: 0xF3 / 255.0, blue: 0xF5 / 255.0, alpha: 1.0)
|
||||
avatarTintOverlayView.backgroundColor = tintColor.withAlphaComponent(0.15)
|
||||
avatarInitialsLabel.textColor = textColor
|
||||
avatarInitialsLabel.text = RosettaColors.groupInitial(name: senderName, publicKey: senderKey)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
508
Rosetta/DesignSystem/Components/PeerProfileHeaderUIView.swift
Normal file
508
Rosetta/DesignSystem/Components/PeerProfileHeaderUIView.swift
Normal file
@@ -0,0 +1,508 @@
|
||||
import UIKit
|
||||
import SwiftUI
|
||||
import Combine
|
||||
|
||||
// MARK: - Delegate
|
||||
|
||||
protocol PeerProfileHeaderDelegate: AnyObject {
|
||||
func headerDidTapBack()
|
||||
func headerDidTapCall()
|
||||
func headerDidTapMuteToggle()
|
||||
func headerDidTapSearch()
|
||||
func headerDidTapMore()
|
||||
func headerDidTapMessage()
|
||||
}
|
||||
|
||||
// MARK: - PeerProfileHeaderUIView
|
||||
|
||||
/// Telegram-parity expandable profile header (UIKit).
|
||||
/// Collapsed: 100pt circular avatar, centered name, solid action buttons.
|
||||
/// Expanded: full-width photo, left-aligned name + scrim gradient, glass buttons.
|
||||
final class PeerProfileHeaderUIView: UIView {
|
||||
|
||||
weak var delegate: PeerProfileHeaderDelegate?
|
||||
|
||||
/// Called when header height changes — parent VC must relayout content below.
|
||||
var onHeightChanged: (() -> Void)?
|
||||
|
||||
// MARK: - State
|
||||
|
||||
private(set) var isLargeHeader = false {
|
||||
didSet { guard oldValue != isLargeHeader else { return }; setNeedsLayout() }
|
||||
}
|
||||
|
||||
// MARK: - Configuration data
|
||||
|
||||
private var displayName = ""
|
||||
private var subtitleText = ""
|
||||
private var effectiveVerified = 0
|
||||
private var avatarImage: UIImage?
|
||||
private var avatarInitials = ""
|
||||
private var avatarColorIndex = 0
|
||||
private var isMuted = false
|
||||
private var isSavedMessages = false
|
||||
private var showCallButton = true
|
||||
private var showMuteButton = true
|
||||
private var showMessageButton = false
|
||||
|
||||
// MARK: - Subviews
|
||||
|
||||
private let avatarClipView = UIView()
|
||||
private let avatarImageView = UIImageView()
|
||||
private let letterBackgroundView = UIView()
|
||||
private let letterOverlayView = UIView()
|
||||
private let letterLabel = UILabel()
|
||||
private let bookmarkIcon = UIImageView()
|
||||
|
||||
private let scrimGradientLayer = CAGradientLayer()
|
||||
private let nameLabel = UILabel()
|
||||
private let subtitleLabel = UILabel()
|
||||
private var verifiedHosting: UIHostingController<VerifiedBadge>?
|
||||
|
||||
private let backButton = UIControl()
|
||||
private let backGlassView = TelegramGlassUIView(frame: .zero)
|
||||
private let backSolidView = UIView()
|
||||
private let backChevronLayer = CAShapeLayer()
|
||||
|
||||
private var actionButtons: [(UIControl, UIImageView, UILabel, TelegramGlassUIView, UIView)] = []
|
||||
|
||||
// Layout constants
|
||||
private let collapsedAvatarSize: CGFloat = 100
|
||||
private let expandedHeight: CGFloat = 300
|
||||
private let buttonSpacing: CGFloat = 6
|
||||
private let horizontalPadding: CGFloat = 15
|
||||
|
||||
// MARK: - Init
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
setupViews()
|
||||
}
|
||||
|
||||
@available(*, unavailable) required init?(coder: NSCoder) { fatalError() }
|
||||
|
||||
// MARK: - Configure
|
||||
|
||||
func configure(
|
||||
displayName: String,
|
||||
subtitle: String,
|
||||
verified: Int,
|
||||
avatar: UIImage?,
|
||||
initials: String,
|
||||
colorIndex: Int,
|
||||
isMuted: Bool,
|
||||
isSavedMessages: Bool,
|
||||
showCall: Bool,
|
||||
showMute: Bool,
|
||||
showMessage: Bool
|
||||
) {
|
||||
self.displayName = displayName
|
||||
self.subtitleText = subtitle
|
||||
self.effectiveVerified = verified
|
||||
self.avatarImage = avatar
|
||||
self.avatarInitials = initials
|
||||
self.avatarColorIndex = colorIndex
|
||||
self.isMuted = isMuted
|
||||
self.isSavedMessages = isSavedMessages
|
||||
self.showCallButton = showCall
|
||||
self.showMuteButton = showMute
|
||||
self.showMessageButton = showMessage
|
||||
|
||||
updateContent()
|
||||
setNeedsLayout()
|
||||
}
|
||||
|
||||
func updateMuted(_ muted: Bool) {
|
||||
isMuted = muted
|
||||
rebuildActionButtons()
|
||||
setNeedsLayout()
|
||||
}
|
||||
|
||||
func updateSubtitle(_ text: String) {
|
||||
subtitleText = text
|
||||
subtitleLabel.text = text
|
||||
}
|
||||
|
||||
func updateAvatar(_ image: UIImage?) {
|
||||
avatarImage = image
|
||||
updateAvatarContent()
|
||||
}
|
||||
|
||||
var canExpand: Bool { avatarImage != nil && !isSavedMessages }
|
||||
|
||||
func setLargeHeader(_ large: Bool, animated: Bool) {
|
||||
guard large != isLargeHeader else { return }
|
||||
if animated {
|
||||
UIView.animate(withDuration: 0.35, delay: 0, usingSpringWithDamping: 0.86, initialSpringVelocity: 0, options: []) {
|
||||
self.isLargeHeader = large
|
||||
self.layoutIfNeeded()
|
||||
self.onHeightChanged?()
|
||||
}
|
||||
} else {
|
||||
isLargeHeader = large
|
||||
onHeightChanged?()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Setup
|
||||
|
||||
private func setupViews() {
|
||||
clipsToBounds = false
|
||||
|
||||
// Avatar clip
|
||||
avatarClipView.clipsToBounds = true
|
||||
addSubview(avatarClipView)
|
||||
|
||||
avatarImageView.contentMode = .scaleAspectFill
|
||||
avatarImageView.clipsToBounds = true
|
||||
avatarClipView.addSubview(avatarImageView)
|
||||
|
||||
// Letter avatar
|
||||
letterBackgroundView.clipsToBounds = true
|
||||
avatarClipView.addSubview(letterBackgroundView)
|
||||
|
||||
letterOverlayView.clipsToBounds = true
|
||||
letterBackgroundView.addSubview(letterOverlayView)
|
||||
|
||||
letterLabel.textAlignment = .center
|
||||
letterLabel.font = .systemFont(ofSize: 38, weight: .bold)
|
||||
letterBackgroundView.addSubview(letterLabel)
|
||||
|
||||
// Saved Messages bookmark
|
||||
bookmarkIcon.image = UIImage(systemName: "bookmark.fill")
|
||||
bookmarkIcon.tintColor = .white
|
||||
bookmarkIcon.contentMode = .scaleAspectFit
|
||||
avatarClipView.addSubview(bookmarkIcon)
|
||||
|
||||
// Scrim gradient
|
||||
scrimGradientLayer.colors = [
|
||||
UIColor.black.withAlphaComponent(1.0).cgColor,
|
||||
UIColor.black.withAlphaComponent(1.0).cgColor,
|
||||
UIColor.black.withAlphaComponent(0.9).cgColor,
|
||||
UIColor.black.withAlphaComponent(0.75).cgColor,
|
||||
UIColor.black.withAlphaComponent(0.55).cgColor,
|
||||
UIColor.black.withAlphaComponent(0.3).cgColor,
|
||||
UIColor.black.withAlphaComponent(0.1).cgColor,
|
||||
UIColor.clear.cgColor
|
||||
]
|
||||
scrimGradientLayer.locations = [0.0, 0.45, 0.55, 0.65, 0.75, 0.85, 0.93, 1.0]
|
||||
scrimGradientLayer.startPoint = CGPoint(x: 0.5, y: 0)
|
||||
scrimGradientLayer.endPoint = CGPoint(x: 0.5, y: 1)
|
||||
avatarClipView.layer.addSublayer(scrimGradientLayer)
|
||||
|
||||
// Name + subtitle
|
||||
nameLabel.font = .systemFont(ofSize: 17, weight: .semibold)
|
||||
nameLabel.textColor = .white
|
||||
addSubview(nameLabel)
|
||||
|
||||
subtitleLabel.font = .systemFont(ofSize: 16)
|
||||
subtitleLabel.textColor = .secondaryLabel
|
||||
addSubview(subtitleLabel)
|
||||
|
||||
// Back button
|
||||
backButton.addTarget(self, action: #selector(backTapped), for: .touchUpInside)
|
||||
addSubview(backButton)
|
||||
|
||||
backGlassView.isUserInteractionEnabled = false
|
||||
backButton.addSubview(backGlassView)
|
||||
|
||||
backSolidView.isUserInteractionEnabled = false
|
||||
backButton.addSubview(backSolidView)
|
||||
|
||||
backChevronLayer.fillColor = UIColor.white.cgColor
|
||||
backButton.layer.addSublayer(backChevronLayer)
|
||||
|
||||
rebuildActionButtons()
|
||||
}
|
||||
|
||||
// MARK: - Content Updates
|
||||
|
||||
private func updateContent() {
|
||||
nameLabel.text = displayName
|
||||
subtitleLabel.text = subtitleText
|
||||
updateAvatarContent()
|
||||
updateVerifiedBadge()
|
||||
rebuildActionButtons()
|
||||
}
|
||||
|
||||
private func updateAvatarContent() {
|
||||
if let image = avatarImage {
|
||||
avatarImageView.image = image
|
||||
avatarImageView.isHidden = false
|
||||
letterBackgroundView.isHidden = true
|
||||
bookmarkIcon.isHidden = true
|
||||
} else if isSavedMessages {
|
||||
avatarImageView.isHidden = true
|
||||
letterBackgroundView.isHidden = true
|
||||
bookmarkIcon.isHidden = false
|
||||
avatarClipView.backgroundColor = UIColor(red: 0x24/255, green: 0x8A/255, blue: 0xE6/255, alpha: 1)
|
||||
} else {
|
||||
avatarImageView.isHidden = true
|
||||
letterBackgroundView.isHidden = false
|
||||
bookmarkIcon.isHidden = true
|
||||
avatarClipView.backgroundColor = .clear
|
||||
|
||||
let colors = RosettaColors.avatarColors
|
||||
let pair = colors[avatarColorIndex % colors.count]
|
||||
let isDark = traitCollection.userInterfaceStyle == .dark
|
||||
let bgColor = isDark ? UIColor(red: 0x1A/255, green: 0x1B/255, blue: 0x1E/255, alpha: 1) : .white
|
||||
let overlayColor = UIColor(pair.tint).withAlphaComponent(isDark ? 0.15 : 0.10)
|
||||
let textColor = isDark ? UIColor(pair.text) : UIColor(pair.tint)
|
||||
|
||||
letterBackgroundView.backgroundColor = bgColor
|
||||
letterOverlayView.backgroundColor = overlayColor
|
||||
letterLabel.text = avatarInitials
|
||||
letterLabel.textColor = textColor
|
||||
}
|
||||
}
|
||||
|
||||
private func updateVerifiedBadge() {
|
||||
verifiedHosting?.view.removeFromSuperview()
|
||||
verifiedHosting?.removeFromParent()
|
||||
verifiedHosting = nil
|
||||
|
||||
guard effectiveVerified > 0 else { return }
|
||||
let badge = VerifiedBadge(verified: effectiveVerified, size: 15, badgeTint: isLargeHeader ? .white : nil)
|
||||
let hosting = UIHostingController(rootView: badge)
|
||||
hosting.view.backgroundColor = .clear
|
||||
hosting.view.translatesAutoresizingMaskIntoConstraints = true
|
||||
addSubview(hosting.view)
|
||||
verifiedHosting = hosting
|
||||
}
|
||||
|
||||
// MARK: - Action Buttons
|
||||
|
||||
private func rebuildActionButtons() {
|
||||
for (control, _, _, _, _) in actionButtons {
|
||||
control.removeFromSuperview()
|
||||
}
|
||||
actionButtons.removeAll()
|
||||
|
||||
var buttons: [(String, String, Selector)] = []
|
||||
|
||||
if showMessageButton {
|
||||
buttons.append(("bubble.left.fill", "Message", #selector(messageTapped)))
|
||||
}
|
||||
if showCallButton {
|
||||
buttons.append(("phone.fill", "Call", #selector(callTapped)))
|
||||
}
|
||||
if showMuteButton {
|
||||
let icon = isMuted ? "bell.slash.fill" : "bell.fill"
|
||||
let title = isMuted ? "Unmute" : "Mute"
|
||||
buttons.append((icon, title, #selector(muteTapped)))
|
||||
}
|
||||
buttons.append(("magnifyingglass", "Search", #selector(searchTapped)))
|
||||
buttons.append(("ellipsis", "More", #selector(moreTapped)))
|
||||
|
||||
for (icon, title, action) in buttons {
|
||||
let control = UIControl()
|
||||
control.addTarget(self, action: action, for: .touchUpInside)
|
||||
|
||||
let imageView = UIImageView(image: UIImage(systemName: icon))
|
||||
imageView.contentMode = .scaleAspectFit
|
||||
imageView.tintColor = .white
|
||||
imageView.isUserInteractionEnabled = false
|
||||
control.addSubview(imageView)
|
||||
|
||||
let label = UILabel()
|
||||
label.text = title
|
||||
label.font = .systemFont(ofSize: 11)
|
||||
label.textAlignment = .center
|
||||
label.textColor = .white
|
||||
label.isUserInteractionEnabled = false
|
||||
control.addSubview(label)
|
||||
|
||||
let glassView = TelegramGlassUIView(frame: .zero)
|
||||
glassView.isUserInteractionEnabled = false
|
||||
control.insertSubview(glassView, at: 0)
|
||||
|
||||
let solidView = UIView()
|
||||
solidView.isUserInteractionEnabled = false
|
||||
solidView.layer.cornerRadius = 15
|
||||
solidView.layer.cornerCurve = .continuous
|
||||
control.insertSubview(solidView, at: 0)
|
||||
|
||||
addSubview(control)
|
||||
actionButtons.append((control, imageView, label, glassView, solidView))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Layout
|
||||
|
||||
override func layoutSubviews() {
|
||||
super.layoutSubviews()
|
||||
|
||||
let w = bounds.width
|
||||
let isDark = traitCollection.userInterfaceStyle == .dark
|
||||
|
||||
// --- Avatar ---
|
||||
let avatarH = isLargeHeader ? expandedHeight : collapsedAvatarSize
|
||||
let avatarW = isLargeHeader ? w : collapsedAvatarSize
|
||||
let avatarX = isLargeHeader ? 0 : (w - collapsedAvatarSize) / 2
|
||||
let avatarY: CGFloat = isLargeHeader ? 0 : 48 // expanded: fill from top; collapsed: below back button
|
||||
|
||||
avatarClipView.frame = CGRect(x: avatarX, y: avatarY, width: avatarW, height: avatarH)
|
||||
avatarClipView.layer.cornerRadius = isLargeHeader ? 0 : collapsedAvatarSize / 2
|
||||
|
||||
avatarImageView.frame = avatarClipView.bounds
|
||||
letterBackgroundView.frame = avatarClipView.bounds
|
||||
letterOverlayView.frame = letterBackgroundView.bounds
|
||||
bookmarkIcon.frame = CGRect(
|
||||
x: (avatarW - (isLargeHeader ? 80 : 38)) / 2,
|
||||
y: (avatarH - (isLargeHeader ? 80 : 38)) / 2,
|
||||
width: isLargeHeader ? 80 : 38,
|
||||
height: isLargeHeader ? 80 : 38
|
||||
)
|
||||
|
||||
let letterSize: CGFloat = isLargeHeader ? 120 : 38
|
||||
letterLabel.font = .systemFont(ofSize: letterSize, weight: .bold)
|
||||
letterLabel.frame = letterBackgroundView.bounds
|
||||
|
||||
// Scrim
|
||||
CATransaction.begin()
|
||||
CATransaction.setDisableActions(true)
|
||||
scrimGradientLayer.frame = CGRect(x: 0, y: 0, width: avatarW, height: avatarH + 40)
|
||||
scrimGradientLayer.isHidden = !isLargeHeader || avatarImage == nil
|
||||
CATransaction.commit()
|
||||
|
||||
// --- Name + Subtitle ---
|
||||
let textColor: UIColor = isLargeHeader ? .white : (isDark ? .white : .black)
|
||||
nameLabel.textColor = textColor
|
||||
nameLabel.font = isLargeHeader
|
||||
? .systemFont(ofSize: 22, weight: .semibold)
|
||||
: .systemFont(ofSize: 17, weight: .semibold)
|
||||
nameLabel.textAlignment = isLargeHeader ? .left : .center
|
||||
nameLabel.numberOfLines = isLargeHeader ? 2 : 1
|
||||
|
||||
subtitleLabel.textColor = isLargeHeader
|
||||
? UIColor.white.withAlphaComponent(0.7)
|
||||
: .secondaryLabel
|
||||
subtitleLabel.font = isLargeHeader ? .systemFont(ofSize: 16) : .systemFont(ofSize: 16)
|
||||
subtitleLabel.textAlignment = isLargeHeader ? .left : .center
|
||||
|
||||
let nameY = avatarClipView.frame.maxY + 12
|
||||
let nameX = horizontalPadding
|
||||
let nameW = w - horizontalPadding * 2
|
||||
|
||||
// Measure name
|
||||
let nameSize = nameLabel.sizeThatFits(CGSize(width: nameW - 24, height: 60))
|
||||
nameLabel.frame = CGRect(x: nameX, y: nameY, width: nameW, height: nameSize.height)
|
||||
|
||||
// Verified badge next to name
|
||||
if let badgeView = verifiedHosting?.view {
|
||||
let badgeSize: CGFloat = isLargeHeader ? 18 : 15
|
||||
let badgeX: CGFloat
|
||||
if isLargeHeader {
|
||||
badgeX = nameX + nameSize.width + 5
|
||||
} else {
|
||||
badgeX = (w + nameSize.width) / 2 + 3
|
||||
}
|
||||
badgeView.frame = CGRect(x: badgeX, y: nameY + (nameSize.height - badgeSize) / 2, width: badgeSize, height: badgeSize)
|
||||
}
|
||||
|
||||
let subtitleY = nameLabel.frame.maxY + (isLargeHeader ? 4 : 1)
|
||||
let subtitleSize = subtitleLabel.sizeThatFits(CGSize(width: nameW, height: 30))
|
||||
subtitleLabel.frame = CGRect(x: nameX, y: subtitleY, width: nameW, height: subtitleSize.height)
|
||||
|
||||
// --- Action Buttons ---
|
||||
let buttonsY = subtitleLabel.frame.maxY + 20
|
||||
let btnCount = CGFloat(actionButtons.count)
|
||||
let totalSpacing = buttonSpacing * (btnCount - 1)
|
||||
let buttonW = (w - horizontalPadding * 2 - totalSpacing) / btnCount
|
||||
let buttonH: CGFloat = 58
|
||||
|
||||
let btnTextColor: UIColor = isLargeHeader ? .white : UIColor(red: 0x24/255, green: 0x8A/255, blue: 0xE6/255, alpha: 1)
|
||||
let solidFill = isDark
|
||||
? UIColor(red: 28/255, green: 28/255, blue: 29/255, alpha: 1)
|
||||
: UIColor(red: 242/255, green: 242/255, blue: 247/255, alpha: 1)
|
||||
|
||||
for (i, (control, imageView, label, glassView, solidView)) in actionButtons.enumerated() {
|
||||
let x = horizontalPadding + CGFloat(i) * (buttonW + buttonSpacing)
|
||||
control.frame = CGRect(x: x, y: buttonsY, width: buttonW, height: buttonH)
|
||||
|
||||
glassView.frame = control.bounds
|
||||
glassView.fixedCornerRadius = 15
|
||||
glassView.updateGlass()
|
||||
glassView.alpha = isLargeHeader ? 0.8 : 0
|
||||
|
||||
solidView.frame = control.bounds
|
||||
solidView.backgroundColor = solidFill
|
||||
solidView.alpha = isLargeHeader ? 0 : 1
|
||||
|
||||
imageView.tintColor = btnTextColor
|
||||
imageView.frame = CGRect(x: (buttonW - 24) / 2, y: 5, width: 24, height: 30)
|
||||
|
||||
label.textColor = btnTextColor
|
||||
label.frame = CGRect(x: 0, y: 37, width: buttonW, height: 16)
|
||||
}
|
||||
|
||||
// --- Back Button (positioned at top, above avatar) ---
|
||||
let backSize: CGFloat = 44
|
||||
backButton.frame = CGRect(x: horizontalPadding - 4, y: 0, width: backSize, height: backSize)
|
||||
|
||||
backGlassView.frame = backButton.bounds
|
||||
backGlassView.fixedCornerRadius = backSize / 2
|
||||
backGlassView.updateGlass()
|
||||
backGlassView.alpha = isLargeHeader ? 1 : 0
|
||||
|
||||
let backSolidFill = isDark
|
||||
? UIColor.white.withAlphaComponent(0.12)
|
||||
: UIColor.black.withAlphaComponent(0.06)
|
||||
backSolidView.frame = backButton.bounds
|
||||
backSolidView.layer.cornerRadius = backSize / 2
|
||||
backSolidView.backgroundColor = backSolidFill
|
||||
backSolidView.alpha = isLargeHeader ? 0 : 1
|
||||
|
||||
let chevronColor: UIColor = isLargeHeader ? .white : (isDark ? .white : .black)
|
||||
backChevronLayer.fillColor = chevronColor.cgColor
|
||||
updateBackChevronPath()
|
||||
|
||||
// Total height
|
||||
let totalH = buttonsY + buttonH + horizontalPadding
|
||||
if abs(bounds.height - totalH) > 1 {
|
||||
frame.size.height = totalH
|
||||
}
|
||||
}
|
||||
|
||||
func preferredHeight() -> CGFloat {
|
||||
let avatarH = isLargeHeader ? expandedHeight : collapsedAvatarSize
|
||||
// backButton(44) + spacing(4) + avatar + spacing(12) + name(~22) + spacing(4) + subtitle(~20) + spacing(20) + buttons(58) + bottomPad(15)
|
||||
return 44 + 4 + avatarH + 12 + 22 + 4 + 20 + 20 + 58 + horizontalPadding
|
||||
}
|
||||
|
||||
private func updateBackChevronPath() {
|
||||
let iconSize = CGSize(width: 11, height: 20)
|
||||
let origin = CGPoint(
|
||||
x: (backButton.bounds.width - iconSize.width) / 2,
|
||||
y: (backButton.bounds.height - iconSize.height) / 2
|
||||
)
|
||||
let vb = CGSize(width: 10.7, height: 19.63)
|
||||
var parser = SVGPathParser(pathData: TelegramIconPath.backChevron)
|
||||
let rawPath = parser.parse()
|
||||
var transform = CGAffineTransform(translationX: origin.x, y: origin.y)
|
||||
.scaledBy(x: iconSize.width / vb.width, y: iconSize.height / vb.height)
|
||||
let scaledPath = rawPath.copy(using: &transform)
|
||||
backChevronLayer.path = scaledPath
|
||||
backChevronLayer.frame = backButton.bounds
|
||||
}
|
||||
|
||||
// MARK: - Trait Changes
|
||||
|
||||
override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
|
||||
super.traitCollectionDidChange(previousTraitCollection)
|
||||
if traitCollection.userInterfaceStyle != previousTraitCollection?.userInterfaceStyle {
|
||||
updateAvatarContent()
|
||||
updateVerifiedBadge()
|
||||
setNeedsLayout()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Actions
|
||||
|
||||
@objc private func backTapped() { delegate?.headerDidTapBack() }
|
||||
@objc private func callTapped() { delegate?.headerDidTapCall() }
|
||||
@objc private func muteTapped() { delegate?.headerDidTapMuteToggle() }
|
||||
@objc private func searchTapped() { delegate?.headerDidTapSearch() }
|
||||
@objc private func moreTapped() { delegate?.headerDidTapMore() }
|
||||
@objc private func messageTapped() { delegate?.headerDidTapMessage() }
|
||||
}
|
||||
133
Rosetta/DesignSystem/Components/ProfileTabBarView.swift
Normal file
133
Rosetta/DesignSystem/Components/ProfileTabBarView.swift
Normal file
@@ -0,0 +1,133 @@
|
||||
import UIKit
|
||||
|
||||
// MARK: - ProfileTabBarDelegate
|
||||
|
||||
protocol ProfileTabBarDelegate: AnyObject {
|
||||
func tabBar(_ tabBar: ProfileTabBarView, didSelectTabAt index: Int)
|
||||
}
|
||||
|
||||
// MARK: - ProfileTabBarView
|
||||
|
||||
/// Telegram-parity capsule tab bar with animated selection indicator.
|
||||
/// Used by both OpponentProfileViewController and GroupInfoViewController.
|
||||
final class ProfileTabBarView: UIView {
|
||||
|
||||
weak var delegate: ProfileTabBarDelegate?
|
||||
|
||||
private let titles: [String]
|
||||
private var tabButtons: [UIButton] = []
|
||||
private let selectionIndicator = UIView()
|
||||
private let containerView = UIView()
|
||||
private(set) var selectedIndex = 0
|
||||
|
||||
// Colors (adaptive)
|
||||
private let activeColor = UIColor { $0.userInterfaceStyle == .dark ? .white : .black }
|
||||
private let inactiveColor = UIColor { $0.userInterfaceStyle == .dark
|
||||
? UIColor(white: 1.0, alpha: 0.6)
|
||||
: UIColor(red: 0, green: 0, blue: 0, alpha: 0.4)
|
||||
}
|
||||
private let indicatorFill = UIColor { $0.userInterfaceStyle == .dark
|
||||
? UIColor(white: 1.0, alpha: 0.18)
|
||||
: UIColor(red: 0, green: 0, blue: 0, alpha: 0.08)
|
||||
}
|
||||
private let containerFill = UIColor { $0.userInterfaceStyle == .dark
|
||||
? UIColor(red: 28/255, green: 28/255, blue: 29/255, alpha: 1)
|
||||
: UIColor(red: 242/255, green: 242/255, blue: 247/255, alpha: 1)
|
||||
}
|
||||
|
||||
// MARK: - Init
|
||||
|
||||
init(titles: [String]) {
|
||||
self.titles = titles
|
||||
super.init(frame: .zero)
|
||||
setupViews()
|
||||
}
|
||||
|
||||
@available(*, unavailable) required init?(coder: NSCoder) { fatalError() }
|
||||
|
||||
// MARK: - Setup
|
||||
|
||||
private func setupViews() {
|
||||
containerView.layer.cornerCurve = .continuous
|
||||
containerView.backgroundColor = containerFill
|
||||
addSubview(containerView)
|
||||
|
||||
selectionIndicator.backgroundColor = indicatorFill
|
||||
containerView.addSubview(selectionIndicator)
|
||||
|
||||
for (index, title) in titles.enumerated() {
|
||||
let button = UIButton(type: .system)
|
||||
button.setTitle(title, for: .normal)
|
||||
button.titleLabel?.font = .systemFont(ofSize: 14, weight: .medium)
|
||||
button.setTitleColor(index == 0 ? activeColor : inactiveColor, for: .normal)
|
||||
button.tag = index
|
||||
button.addTarget(self, action: #selector(tabTapped(_:)), for: .touchUpInside)
|
||||
containerView.addSubview(button)
|
||||
tabButtons.append(button)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Layout
|
||||
|
||||
override func layoutSubviews() {
|
||||
super.layoutSubviews()
|
||||
|
||||
let containerH: CGFloat = 38
|
||||
containerView.frame = CGRect(x: 0, y: 0, width: bounds.width, height: containerH)
|
||||
containerView.layer.cornerRadius = containerH / 2
|
||||
|
||||
let padding: CGFloat = 3
|
||||
let innerW = containerView.bounds.width - padding * 2
|
||||
let tabW = innerW / CGFloat(titles.count)
|
||||
|
||||
for (index, button) in tabButtons.enumerated() {
|
||||
button.frame = CGRect(
|
||||
x: padding + CGFloat(index) * tabW,
|
||||
y: padding,
|
||||
width: tabW,
|
||||
height: containerH - padding * 2
|
||||
)
|
||||
}
|
||||
|
||||
layoutIndicator(animated: false)
|
||||
}
|
||||
|
||||
override var intrinsicContentSize: CGSize {
|
||||
CGSize(width: UIView.noIntrinsicMetric, height: 38)
|
||||
}
|
||||
|
||||
private func layoutIndicator(animated: Bool) {
|
||||
guard selectedIndex < tabButtons.count else { return }
|
||||
let button = tabButtons[selectedIndex]
|
||||
let indicatorFrame = button.frame
|
||||
selectionIndicator.layer.cornerRadius = indicatorFrame.height / 2
|
||||
|
||||
if animated {
|
||||
UIView.animate(withDuration: 0.35, delay: 0, usingSpringWithDamping: 0.8, initialSpringVelocity: 0) {
|
||||
self.selectionIndicator.frame = indicatorFrame
|
||||
}
|
||||
} else {
|
||||
selectionIndicator.frame = indicatorFrame
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Selection
|
||||
|
||||
@objc private func tabTapped(_ sender: UIButton) {
|
||||
let index = sender.tag
|
||||
guard index != selectedIndex else { return }
|
||||
selectTab(at: index, animated: true)
|
||||
delegate?.tabBar(self, didSelectTabAt: index)
|
||||
}
|
||||
|
||||
func selectTab(at index: Int, animated: Bool) {
|
||||
guard index >= 0, index < titles.count else { return }
|
||||
selectedIndex = index
|
||||
|
||||
for (i, button) in tabButtons.enumerated() {
|
||||
button.setTitleColor(i == index ? activeColor : inactiveColor, for: .normal)
|
||||
}
|
||||
|
||||
layoutIndicator(animated: animated)
|
||||
}
|
||||
}
|
||||
@@ -441,6 +441,7 @@ final class ChatDetailViewController: UIViewController {
|
||||
}
|
||||
controller.onComposerReplyCancel = { [weak self] in
|
||||
self?.replyingToMessage = nil
|
||||
self?.syncComposerReplyState()
|
||||
}
|
||||
controller.onComposerTyping = { [weak self] in
|
||||
self?.handleComposerUserTyping()
|
||||
@@ -719,32 +720,25 @@ final class ChatDetailViewController: UIViewController {
|
||||
|
||||
private func openProfile() {
|
||||
view.endEditing(true)
|
||||
// Show nav bar BEFORE push — prevents jump from hidden→visible during animation.
|
||||
// Profile uses .toolbarBackground(.hidden) so it's visually invisible anyway.
|
||||
navigationController?.setNavigationBarHidden(false, animated: false)
|
||||
// Hide nav bar separator (invisible on dark, visible on light — profile has custom header)
|
||||
let clearAppearance = UINavigationBarAppearance()
|
||||
clearAppearance.configureWithTransparentBackground()
|
||||
clearAppearance.shadowColor = .clear
|
||||
navigationController?.navigationBar.standardAppearance = clearAppearance
|
||||
navigationController?.navigationBar.scrollEdgeAppearance = clearAppearance
|
||||
if route.isGroup {
|
||||
// Group info — still raw UIHostingController (Phase 2 will wrap this too)
|
||||
navigationController?.setNavigationBarHidden(false, animated: false)
|
||||
let clearAppearance = UINavigationBarAppearance()
|
||||
clearAppearance.configureWithTransparentBackground()
|
||||
clearAppearance.shadowColor = .clear
|
||||
navigationController?.navigationBar.standardAppearance = clearAppearance
|
||||
navigationController?.navigationBar.scrollEdgeAppearance = clearAppearance
|
||||
let groupInfo = GroupInfoView(groupDialogKey: route.publicKey)
|
||||
let hosting = UIHostingController(rootView: groupInfo)
|
||||
hosting.navigationItem.hidesBackButton = true
|
||||
// Glass back button — matches ChatDetailBackButton style, visible during push transition
|
||||
let backView = ChatDetailBackButton()
|
||||
backView.addTarget(hosting, action: #selector(UIViewController.rosettaPopSelf), for: .touchUpInside)
|
||||
hosting.navigationItem.leftBarButtonItem = UIBarButtonItem(customView: backView)
|
||||
navigationController?.pushViewController(hosting, animated: true)
|
||||
} else if !route.isSystemAccount {
|
||||
let profile = OpponentProfileView(route: route)
|
||||
let hosting = UIHostingController(rootView: profile)
|
||||
hosting.navigationItem.hidesBackButton = true
|
||||
let backView = ChatDetailBackButton()
|
||||
backView.addTarget(hosting, action: #selector(UIViewController.rosettaPopSelf), for: .touchUpInside)
|
||||
hosting.navigationItem.leftBarButtonItem = UIBarButtonItem(customView: backView)
|
||||
navigationController?.pushViewController(hosting, animated: true)
|
||||
// Peer profile — UIKit wrapper (Phase 1), nav bar managed by the VC itself
|
||||
let profileVC = OpponentProfileViewController(route: route)
|
||||
navigationController?.pushViewController(profileVC, animated: true)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -823,11 +817,8 @@ final class ChatDetailViewController: UIViewController {
|
||||
} else {
|
||||
profileRoute = ChatRoute(publicKey: senderKey, title: String(senderKey.prefix(8)), username: "", verified: 0)
|
||||
}
|
||||
navigationController?.setNavigationBarHidden(false, animated: false)
|
||||
let profile = OpponentProfileView(route: profileRoute)
|
||||
let hosting = UIHostingController(rootView: profile)
|
||||
hosting.navigationItem.hidesBackButton = true
|
||||
navigationController?.pushViewController(hosting, animated: true)
|
||||
let profileVC = OpponentProfileViewController(route: profileRoute)
|
||||
navigationController?.pushViewController(profileVC, animated: true)
|
||||
}
|
||||
|
||||
// MARK: - Sheets & Alerts
|
||||
|
||||
@@ -164,6 +164,9 @@ final class ComposerView: UIView, UITextViewDelegate {
|
||||
private(set) var lastRecordedDuration: TimeInterval = 0
|
||||
private(set) var lastRecordedWaveform: [Float] = []
|
||||
private(set) var lastVoiceSendTransitionSource: VoiceSendTransitionSource?
|
||||
/// True when voice send was initiated via mic release (deferred collapse).
|
||||
/// False for preview panel send (immediate collapse).
|
||||
private(set) var voiceSendNeedsDeferred = false
|
||||
|
||||
private let minVoiceDuration = VoiceRecordingParityConstants.minVoiceDuration
|
||||
private let minFreeDiskBytes = VoiceRecordingParityConstants.minFreeDiskBytes
|
||||
@@ -817,14 +820,17 @@ final class ComposerView: UIView, UITextViewDelegate {
|
||||
atIndex = i
|
||||
} else {
|
||||
let prevChar = nsText.character(at: i - 1)
|
||||
if CharacterSet.whitespacesAndNewlines.contains(Unicode.Scalar(prevChar)!) {
|
||||
if let scalar = Unicode.Scalar(prevChar),
|
||||
CharacterSet.whitespacesAndNewlines.contains(scalar) {
|
||||
atIndex = i
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
// Stop if we hit whitespace before finding "@"
|
||||
if CharacterSet.whitespacesAndNewlines.contains(Unicode.Scalar(char)!) {
|
||||
// Unicode.Scalar(char) returns nil for surrogate pairs (emoji) — treat as non-whitespace
|
||||
if let scalar = Unicode.Scalar(char),
|
||||
CharacterSet.whitespacesAndNewlines.contains(scalar) {
|
||||
break
|
||||
}
|
||||
}
|
||||
@@ -1129,7 +1135,7 @@ extension ComposerView: RecordingMicButtonDelegate {
|
||||
self?.micButton.resetState()
|
||||
}
|
||||
recordingOverlay?.transitionToLocked(onTapStop: { [weak self] in
|
||||
self?.showRecordingPreview()
|
||||
self?.finishRecordingAndSend(sourceView: self?.micButton)
|
||||
self?.micButton.resetState()
|
||||
})
|
||||
updateRecordingSendAccessibilityArea(isEnabled: true)
|
||||
@@ -1224,16 +1230,25 @@ extension ComposerView: RecordingMicButtonDelegate {
|
||||
return
|
||||
}
|
||||
|
||||
// Skip transition animation — snapshot appears on window after CDN upload
|
||||
// (1+ seconds later) on top of already-restored composer, causing ghost panel.
|
||||
// Deferred collapse: don't dismiss overlay here — delegate will call
|
||||
// performDeferredVoiceSendCollapse() when the message cell appears in
|
||||
// the collection view. This syncs composer collapse with bubble appearance
|
||||
// (Telegram pattern), eliminating the visual "jerk".
|
||||
voiceSendNeedsDeferred = true
|
||||
lastVoiceSendTransitionSource = nil
|
||||
print("[VOICE_SEND] dismissOverlayAndRestore START")
|
||||
dismissOverlayAndRestore(skipAudioCleanup: true)
|
||||
print("[VOICE_SEND] dismissOverlayAndRestore DONE — calling delegate")
|
||||
print("[VOICE_SEND] deferred collapse — calling delegate WITHOUT dismiss")
|
||||
delegate?.composerDidFinishRecording(self, sendImmediately: true)
|
||||
print("[VOICE_SEND] finishRecordingAndSend DONE")
|
||||
}
|
||||
|
||||
/// Called by NativeMessageListController when the voice message cell appears
|
||||
/// in the collection view. Performs the overlay dismiss + chrome restore that
|
||||
/// was deferred from finishRecordingAndSend.
|
||||
func performDeferredVoiceSendCollapse() {
|
||||
voiceSendNeedsDeferred = false
|
||||
dismissOverlayAndRestore(skipAudioCleanup: true)
|
||||
}
|
||||
|
||||
private func presentRecordingChrome(locked: Bool, animatePanel: Bool) {
|
||||
guard let window else { return }
|
||||
hideComposerChrome()
|
||||
@@ -1258,7 +1273,7 @@ extension ComposerView: RecordingMicButtonDelegate {
|
||||
if locked {
|
||||
panel.showCancelButton()
|
||||
overlay.transitionToLocked(onTapStop: { [weak self] in
|
||||
self?.showRecordingPreview()
|
||||
self?.finishRecordingAndSend(sourceView: self?.micButton)
|
||||
self?.micButton.resetState()
|
||||
})
|
||||
} else {
|
||||
@@ -1343,6 +1358,7 @@ extension ComposerView: RecordingMicButtonDelegate {
|
||||
private func resetVoiceSessionState(cleanup: VoiceSessionCleanupMode) {
|
||||
isRecording = false
|
||||
isRecordingLocked = false
|
||||
voiceSendNeedsDeferred = false
|
||||
setRecordingFlowState(.idle)
|
||||
recordingStartTask?.cancel()
|
||||
recordingStartTask = nil
|
||||
@@ -1399,7 +1415,7 @@ extension ComposerView: RecordingMicButtonDelegate {
|
||||
}
|
||||
|
||||
@objc private func accessibilityStopRecordingTapped() {
|
||||
showRecordingPreview()
|
||||
finishRecordingAndSend(sourceView: micButton)
|
||||
micButton.resetState()
|
||||
}
|
||||
|
||||
|
||||
@@ -941,9 +941,6 @@ final class NativeMessageCell: UICollectionViewCell {
|
||||
)
|
||||
guard !Task.isCancelled else { return }
|
||||
self.voiceView.hideDownloadProgress()
|
||||
if let playableURL, self.message?.id == playbackMessageId {
|
||||
VoiceMessagePlayer.shared.play(messageId: playbackMessageId, fileURL: playableURL)
|
||||
}
|
||||
}
|
||||
self.activeVoiceDownloadTask = downloadTask
|
||||
}
|
||||
@@ -1088,9 +1085,6 @@ final class NativeMessageCell: UICollectionViewCell {
|
||||
)
|
||||
guard !Task.isCancelled else { return }
|
||||
self.voiceView.hideDownloadProgress()
|
||||
if let playableURL, self.message?.id == playbackMessageId {
|
||||
VoiceMessagePlayer.shared.play(messageId: playbackMessageId, fileURL: playableURL)
|
||||
}
|
||||
}
|
||||
self.activeVoiceDownloadTask = downloadTask
|
||||
}
|
||||
@@ -1772,7 +1766,7 @@ final class NativeMessageCell: UICollectionViewCell {
|
||||
|
||||
/// Telegram parity: file-type-specific icon name (same mapping as MessageFileView.swift).
|
||||
/// Parse voice preview: "tag::duration::waveform" or "duration::waveform"
|
||||
private static func parseVoicePreview(_ preview: String) -> (duration: TimeInterval, waveform: String) {
|
||||
static func parseVoicePreview(_ preview: String) -> (duration: TimeInterval, waveform: String) {
|
||||
let parts = preview.components(separatedBy: "::")
|
||||
// Format: "tag::duration::waveform" or "duration::waveform"
|
||||
if parts.count >= 3, let dur = Int(parts[1]) {
|
||||
@@ -1811,7 +1805,7 @@ final class NativeMessageCell: UICollectionViewCell {
|
||||
)
|
||||
}
|
||||
|
||||
private static func playableVoiceURLFromCache(attachmentId: String, fileName: String) -> URL? {
|
||||
static func playableVoiceURLFromCache(attachmentId: String, fileName: String) -> URL? {
|
||||
guard let decrypted = AttachmentCache.shared.loadFileData(
|
||||
forAttachmentId: attachmentId,
|
||||
fileName: fileName
|
||||
|
||||
@@ -137,6 +137,12 @@ final class NativeMessageListController: UIViewController {
|
||||
// MARK: - Voice Playback Live Updates
|
||||
private var voicePlayerCancellables = Set<AnyCancellable>()
|
||||
|
||||
// MARK: - Deferred Voice Send Collapse (Telegram pattern)
|
||||
/// Pending voice send transition: when the cell with `messageId` appears in
|
||||
/// update(messages:), `collapseAction` fires to dismiss recording chrome.
|
||||
private var pendingVoiceCollapse: (messageId: String, collapseAction: () -> Void)?
|
||||
private var voiceCollapseTimer: DispatchWorkItem?
|
||||
|
||||
// MARK: - Scroll-to-Bottom Button
|
||||
private var scrollToBottomButton: UIButton?
|
||||
private var scrollToBottomButtonContainer: UIView?
|
||||
@@ -1460,6 +1466,14 @@ final class NativeMessageListController: UIViewController {
|
||||
&& newIds.count <= 3
|
||||
&& messages.last?.id != oldNewestId
|
||||
|
||||
if !newIds.isEmpty {
|
||||
let hasPending = pendingVoiceCollapse != nil
|
||||
print("[VOICE_ANIM] update() — newIds=\(newIds.count) isInteractive=\(isInteractive) hasCompletedInitialLoad=\(hasCompletedInitialLoad) newestChanged=\(messages.last?.id != oldNewestId) pendingVoice=\(hasPending)")
|
||||
if let pending = pendingVoiceCollapse {
|
||||
print("[VOICE_ANIM] pendingMessageId=\(pending.messageId) matchesNew=\(newIds.contains(pending.messageId))")
|
||||
}
|
||||
}
|
||||
|
||||
// Capture visible cell positions BEFORE applying snapshot (for position animation)
|
||||
var oldPositions: [String: CGFloat] = [:]
|
||||
// Capture pill positions for matching spring animation
|
||||
@@ -1537,6 +1551,17 @@ final class NativeMessageListController: UIViewController {
|
||||
|
||||
dataSource.apply(snapshot, animatingDifferences: false)
|
||||
|
||||
// Voice send correlation: fire deferred collapse when the voice cell appears.
|
||||
// Handled AFTER layout settles (below), regardless of isInteractive.
|
||||
let voiceCorrelationMatch = pendingVoiceCollapse.flatMap { pending in
|
||||
newIds.contains(pending.messageId) ? pending : nil
|
||||
}
|
||||
if voiceCorrelationMatch != nil {
|
||||
pendingVoiceCollapse = nil
|
||||
voiceCollapseTimer?.cancel()
|
||||
voiceCollapseTimer = nil
|
||||
}
|
||||
|
||||
// Dismiss skeleton AFTER snapshot applied — cells now exist for fly-in animation
|
||||
if shouldDismissSkeleton {
|
||||
collectionView.layoutIfNeeded()
|
||||
@@ -1573,6 +1598,54 @@ final class NativeMessageListController: UIViewController {
|
||||
updateFloatingDateHeader()
|
||||
}
|
||||
|
||||
// Voice send: ensure cell is visible and animated, then fire collapse.
|
||||
// Runs AFTER all insertion animations so it doesn't interfere.
|
||||
if let match = voiceCorrelationMatch {
|
||||
print("[VOICE_ANIM] correlation matched! messageId=\(match.messageId)")
|
||||
|
||||
// Scroll to bottom first so the voice cell is in the viewport
|
||||
collectionView.setContentOffset(
|
||||
CGPoint(x: 0, y: -collectionView.contentInset.top),
|
||||
animated: false
|
||||
)
|
||||
collectionView.layoutIfNeeded()
|
||||
|
||||
// Add dedicated animation for the voice cell if applyInsertionAnimations
|
||||
// missed it (e.g., cell was off-screen or isInteractive was false)
|
||||
if let itemIndex = dataSource.snapshot().indexOfItem(match.messageId) {
|
||||
let ip = IndexPath(item: itemIndex, section: 0)
|
||||
let cell = collectionView.cellForItem(at: ip)
|
||||
print("[VOICE_ANIM] itemIndex=\(itemIndex) cell=\(cell != nil) cellHeight=\(cell?.bounds.height ?? -1) hasSlideAnim=\(cell?.layer.animation(forKey: "insertionSlide") != nil)")
|
||||
if let cell = cell {
|
||||
// Only add animation if not already animating (avoid double-animation)
|
||||
if cell.layer.animation(forKey: "insertionSlide") == nil {
|
||||
let slideOffset = -cell.bounds.height * 1.2
|
||||
let slide = CASpringAnimation(keyPath: "position.y")
|
||||
slide.fromValue = slideOffset
|
||||
slide.toValue = 0.0
|
||||
slide.isAdditive = true
|
||||
slide.stiffness = 555.0
|
||||
slide.damping = 47.0
|
||||
slide.mass = 1.0
|
||||
slide.initialVelocity = 0
|
||||
slide.duration = slide.settlingDuration
|
||||
slide.fillMode = .backwards
|
||||
cell.layer.add(slide, forKey: "insertionSlide")
|
||||
|
||||
let alpha = CABasicAnimation(keyPath: "opacity")
|
||||
alpha.fromValue = 0.0
|
||||
alpha.toValue = 1.0
|
||||
alpha.duration = 0.12
|
||||
alpha.fillMode = .backwards
|
||||
cell.contentView.layer.add(alpha, forKey: "insertionAlpha")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
print("[VOICE_SEND] correlation match — collapsing with animation")
|
||||
match.collapseAction()
|
||||
}
|
||||
|
||||
if !hasCompletedInitialLoad && !messages.isEmpty {
|
||||
hasCompletedInitialLoad = true
|
||||
}
|
||||
@@ -1586,6 +1659,12 @@ final class NativeMessageListController: UIViewController {
|
||||
/// All position animations use CASpringAnimation (stiffness=555, damping=47).
|
||||
/// Source: UIKitUtils.m (iOS 26+ branch) + ListView.insertNodeAtIndex.
|
||||
private func applyInsertionAnimations(newIds: Set<String>, oldPositions: [String: CGFloat]) {
|
||||
let visibleIds = Set(collectionView.indexPathsForVisibleItems.compactMap { dataSource.itemIdentifier(for: $0) })
|
||||
let newVisible = newIds.intersection(visibleIds)
|
||||
let newMissing = newIds.subtracting(visibleIds)
|
||||
if !newIds.isEmpty {
|
||||
print("[VOICE_ANIM] applyInsertionAnimations — newIds=\(newIds.count) visible=\(newVisible.count) missing=\(newMissing.count) visibleIPs=\(collectionView.indexPathsForVisibleItems.count)")
|
||||
}
|
||||
for ip in collectionView.indexPathsForVisibleItems {
|
||||
guard let cellId = dataSource.itemIdentifier(for: ip),
|
||||
let cell = collectionView.cellForItem(at: ip) else { continue }
|
||||
@@ -1594,6 +1673,7 @@ final class NativeMessageListController: UIViewController {
|
||||
// NEW cell: slide up from below + alpha fade
|
||||
// In inverted CV: negative offset = below on screen
|
||||
let slideOffset = -cell.bounds.height * 1.2
|
||||
print("[VOICE_ANIM] animating new cell id=\(cellId.prefix(8)) height=\(cell.bounds.height) slideOffset=\(slideOffset)")
|
||||
|
||||
let slide = CASpringAnimation(keyPath: "position.y")
|
||||
slide.fromValue = slideOffset
|
||||
@@ -2082,6 +2162,11 @@ extension NativeMessageListController: ComposerViewDelegate {
|
||||
self?.updateAllVisibleVoiceCells()
|
||||
}
|
||||
.store(in: &voicePlayerCancellables)
|
||||
|
||||
// Sequential voice autoplay: when a voice message finishes, play the next one
|
||||
player.onPlaybackCompleted = { [weak self] finishedMessageId in
|
||||
self?.autoplayNextVoiceMessage(after: finishedMessageId)
|
||||
}
|
||||
}
|
||||
|
||||
private func updatePlayingVoiceCell() {
|
||||
@@ -2114,9 +2199,41 @@ extension NativeMessageListController: ComposerViewDelegate {
|
||||
}
|
||||
}
|
||||
|
||||
/// Sequential voice autoplay: after a voice message finishes, play the next
|
||||
/// consecutive voice message. Stops if the next message is not a voice type
|
||||
/// or if the next voice is not cached locally.
|
||||
private func autoplayNextVoiceMessage(after finishedMessageId: String) {
|
||||
guard let currentIndex = messages.firstIndex(where: { $0.id == finishedMessageId }) else { return }
|
||||
let nextIndex = currentIndex + 1
|
||||
guard nextIndex < messages.count else { return }
|
||||
|
||||
let nextMessage = messages[nextIndex]
|
||||
|
||||
// Stop chain if next message is not a voice message
|
||||
guard let voiceAtt = nextMessage.attachments.first(where: { $0.type == .voice }) else { return }
|
||||
|
||||
// Parse duration from preview to construct cache filename
|
||||
let previewParts = NativeMessageCell.parseVoicePreview(voiceAtt.preview)
|
||||
let fileName = "voice_\(Int(previewParts.duration))s.m4a"
|
||||
|
||||
// Only auto-play if already cached (don't auto-download)
|
||||
guard let cachedURL = NativeMessageCell.playableVoiceURLFromCache(
|
||||
attachmentId: voiceAtt.id, fileName: fileName
|
||||
) else { return }
|
||||
|
||||
VoiceMessagePlayer.shared.play(messageId: nextMessage.id, fileURL: cachedURL)
|
||||
}
|
||||
|
||||
// MARK: - Voice Recording
|
||||
|
||||
func composerDidStartRecording(_ composer: ComposerView) {
|
||||
// Cancel any lingering pending collapse from a previous send
|
||||
if let pending = pendingVoiceCollapse {
|
||||
pendingVoiceCollapse = nil
|
||||
voiceCollapseTimer?.cancel()
|
||||
voiceCollapseTimer = nil
|
||||
pending.collapseAction()
|
||||
}
|
||||
// Block interactive keyboard dismiss while recording voice message.
|
||||
collectionView.keyboardDismissMode = .none
|
||||
setScrollToBottomVisible(false)
|
||||
@@ -2125,18 +2242,42 @@ extension NativeMessageListController: ComposerViewDelegate {
|
||||
}
|
||||
|
||||
func composerDidFinishRecording(_ composer: ComposerView, sendImmediately: Bool) {
|
||||
print("[VOICE_SEND] composerDidFinishRecording — sendImmediately=\(sendImmediately) url=\(composer.lastRecordedURL?.lastPathComponent ?? "nil")")
|
||||
print("[VOICE_SEND] composerDidFinishRecording — sendImmediately=\(sendImmediately) deferred=\(composer.voiceSendNeedsDeferred) url=\(composer.lastRecordedURL?.lastPathComponent ?? "nil")")
|
||||
collectionView.keyboardDismissMode = .interactive
|
||||
updateScrollToBottomButtonConstraints()
|
||||
|
||||
guard sendImmediately,
|
||||
let url = composer.lastRecordedURL,
|
||||
let data = try? Data(contentsOf: url) else {
|
||||
print("[VOICE_SEND] composerDidFinishRecording — GUARD FAILED, returning")
|
||||
print("[VOICE_SEND] composerDidFinishRecording — GUARD FAILED")
|
||||
// Guard fail while overlay may still be showing — force immediate collapse
|
||||
if composer.voiceSendNeedsDeferred {
|
||||
composer.performDeferredVoiceSendCollapse()
|
||||
}
|
||||
return
|
||||
}
|
||||
let transitionSource = composer.consumeVoiceSendTransitionSource()
|
||||
print("[VOICE_SEND] transitionSource=\(transitionSource != nil) dataSize=\(data.count)")
|
||||
|
||||
// Generate messageId HERE so we can register correlation BEFORE send
|
||||
let messageId = UUID().uuidString.replacingOccurrences(of: "-", with: "").lowercased()
|
||||
|
||||
// Deferred collapse: register pending collapse for when the message cell appears
|
||||
if composer.voiceSendNeedsDeferred {
|
||||
let collapseAction: () -> Void = { [weak composer] in
|
||||
composer?.performDeferredVoiceSendCollapse()
|
||||
}
|
||||
pendingVoiceCollapse = (messageId: messageId, collapseAction: collapseAction)
|
||||
|
||||
// Safety timer: if cell doesn't appear within 600ms, force collapse
|
||||
let timer = DispatchWorkItem { [weak self] in
|
||||
guard let self, let pending = self.pendingVoiceCollapse else { return }
|
||||
print("[VOICE_SEND] safety timer fired — forcing collapse")
|
||||
self.pendingVoiceCollapse = nil
|
||||
pending.collapseAction()
|
||||
}
|
||||
voiceCollapseTimer = timer
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.6, execute: timer)
|
||||
}
|
||||
|
||||
let pending = PendingAttachment.fromVoice(
|
||||
data: data,
|
||||
duration: composer.lastRecordedDuration,
|
||||
@@ -2146,19 +2287,16 @@ extension NativeMessageListController: ComposerViewDelegate {
|
||||
let title = config.opponentTitle
|
||||
let username = config.opponentUsername
|
||||
Task { @MainActor in
|
||||
print("[VOICE_SEND] sendMessageWithAttachments START")
|
||||
let messageId = try? await SessionManager.shared.sendMessageWithAttachments(
|
||||
print("[VOICE_SEND] sendMessageWithAttachments START — messageId=\(messageId)")
|
||||
_ = try? await SessionManager.shared.sendMessageWithAttachments(
|
||||
text: "",
|
||||
attachments: [pending],
|
||||
toPublicKey: pubKey,
|
||||
opponentTitle: title,
|
||||
opponentUsername: username
|
||||
opponentUsername: username,
|
||||
messageId: messageId
|
||||
)
|
||||
print("[VOICE_SEND] sendMessageWithAttachments DONE — messageId=\(messageId ?? "nil")")
|
||||
if let source = transitionSource, let messageId {
|
||||
print("[VOICE_SEND] animateVoiceSendTransition START — frame=\(source.sourceFrameInWindow)")
|
||||
animateVoiceSendTransition(source: source, messageId: messageId)
|
||||
}
|
||||
print("[VOICE_SEND] sendMessageWithAttachments DONE")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import UIKit
|
||||
import SwiftUI
|
||||
|
||||
/// Thin UIKit wrapper around SwiftUI OpponentProfileView.
|
||||
/// Phase 1 of incremental migration: handles nav bar + swipe-back natively.
|
||||
/// The SwiftUI content is embedded as a child UIHostingController.
|
||||
final class OpponentProfileViewController: UIViewController, UIGestureRecognizerDelegate {
|
||||
|
||||
private let route: ChatRoute
|
||||
private let showMessageButton: Bool
|
||||
private var addedSwipeBackGesture = false
|
||||
|
||||
init(route: ChatRoute, showMessageButton: Bool = false) {
|
||||
self.route = route
|
||||
self.showMessageButton = showMessageButton
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
}
|
||||
|
||||
@available(*, unavailable) required init?(coder: NSCoder) { fatalError() }
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
view.backgroundColor = UIColor { $0.userInterfaceStyle == .dark ? .black : .white }
|
||||
|
||||
// Embed existing SwiftUI view as child
|
||||
var profileView = OpponentProfileView(route: route)
|
||||
profileView.showMessageButton = showMessageButton
|
||||
let hosting = UIHostingController(rootView: profileView)
|
||||
hosting.view.backgroundColor = .clear
|
||||
|
||||
addChild(hosting)
|
||||
view.addSubview(hosting.view)
|
||||
hosting.view.frame = view.bounds
|
||||
hosting.view.autoresizingMask = [.flexibleWidth, .flexibleHeight]
|
||||
hosting.didMove(toParent: self)
|
||||
|
||||
// Hide system back button — SwiftUI .toolbar handles its own
|
||||
navigationItem.hidesBackButton = true
|
||||
}
|
||||
|
||||
override func viewWillAppear(_ animated: Bool) {
|
||||
super.viewWillAppear(animated)
|
||||
// Show nav bar (SwiftUI .toolbarBackground(.hidden) makes it invisible)
|
||||
navigationController?.setNavigationBarHidden(false, animated: false)
|
||||
let clear = UINavigationBarAppearance()
|
||||
clear.configureWithTransparentBackground()
|
||||
clear.shadowColor = .clear
|
||||
navigationController?.navigationBar.standardAppearance = clear
|
||||
navigationController?.navigationBar.scrollEdgeAppearance = clear
|
||||
}
|
||||
|
||||
override func viewDidAppear(_ animated: Bool) {
|
||||
super.viewDidAppear(animated)
|
||||
setupFullWidthSwipeBack()
|
||||
}
|
||||
|
||||
// MARK: - Full-Width Swipe Back
|
||||
|
||||
private func setupFullWidthSwipeBack() {
|
||||
guard !addedSwipeBackGesture else { return }
|
||||
addedSwipeBackGesture = true
|
||||
|
||||
guard let nav = navigationController,
|
||||
let edgeGesture = nav.interactivePopGestureRecognizer,
|
||||
let targets = edgeGesture.value(forKey: "targets") as? NSArray,
|
||||
targets.count > 0 else { return }
|
||||
|
||||
edgeGesture.isEnabled = true
|
||||
let fullWidthGesture = UIPanGestureRecognizer()
|
||||
fullWidthGesture.setValue(targets, forKey: "targets")
|
||||
fullWidthGesture.delegate = self
|
||||
nav.view.addGestureRecognizer(fullWidthGesture)
|
||||
}
|
||||
|
||||
func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
|
||||
guard let pan = gestureRecognizer as? UIPanGestureRecognizer else { return true }
|
||||
let velocity = pan.velocity(in: pan.view)
|
||||
return velocity.x > 0 && abs(velocity.x) > abs(velocity.y)
|
||||
}
|
||||
}
|
||||
@@ -215,8 +215,7 @@ final class VoiceRecordingPanel: UIView {
|
||||
let totalSeconds = Int(duration)
|
||||
let minutes = totalSeconds / 60
|
||||
let seconds = totalSeconds % 60
|
||||
let centiseconds = Int(duration * 100) % 100
|
||||
let text = String(format: "%d:%02d,%02d", minutes, seconds, centiseconds)
|
||||
let text = String(format: "%d:%02d", minutes, seconds)
|
||||
guard timerLabel.text != text else { return }
|
||||
timerLabel.text = text
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ enum VoiceRecordingParityConstants {
|
||||
static let cancelTransformThreshold: CGFloat = 8
|
||||
|
||||
static let sendAccessibilityHitSize: CGFloat = 120
|
||||
static let minVoiceDuration: TimeInterval = 0.5
|
||||
static let minVoiceDuration: TimeInterval = 1.0
|
||||
static let minFreeDiskBytes: Int64 = 8 * 1024 * 1024
|
||||
|
||||
static func minTrimDuration(duration: TimeInterval, waveformWidth: CGFloat) -> TimeInterval {
|
||||
|
||||
@@ -1092,10 +1092,20 @@ final class RequestChatsUIKitShellController: UIViewController {
|
||||
}
|
||||
|
||||
private func render() {
|
||||
let requests = viewModel.requestsModeDialogs
|
||||
requestsController.updateDialogs(
|
||||
viewModel.requestsModeDialogs,
|
||||
requests,
|
||||
isSyncing: SessionManager.shared.syncBatchInProgress
|
||||
)
|
||||
// When requests become empty (user replied → dialog moved to main list),
|
||||
// silently remove self from nav stack so back goes directly to ChatList.
|
||||
if requests.isEmpty,
|
||||
let nav = navigationController,
|
||||
nav.topViewController !== self {
|
||||
var vcs = nav.viewControllers
|
||||
vcs.removeAll { $0 === self }
|
||||
nav.viewControllers = vcs
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
1024
Rosetta/Features/Groups/GroupInfoViewController.swift
Normal file
1024
Rosetta/Features/Groups/GroupInfoViewController.swift
Normal file
File diff suppressed because it is too large
Load Diff
130
Rosetta/Features/Groups/GroupMemberCell.swift
Normal file
130
Rosetta/Features/Groups/GroupMemberCell.swift
Normal file
@@ -0,0 +1,130 @@
|
||||
import UIKit
|
||||
import SwiftUI
|
||||
|
||||
/// UIKit cell for a group member row (Telegram parity).
|
||||
/// Avatar 40pt, name, status (online/username/last seen), admin badge.
|
||||
final class GroupMemberCell: UIView {
|
||||
|
||||
private let avatarContainer = UIView()
|
||||
private var avatarHosting: UIHostingController<AvatarView>?
|
||||
private let nameLabel = UILabel()
|
||||
private let statusLabel = UILabel()
|
||||
private var verifiedHosting: UIHostingController<VerifiedBadge>?
|
||||
private var adminHosting: UIHostingController<VerifiedBadge>?
|
||||
|
||||
private let accentBlue = UIColor(red: 0x3E/255, green: 0x88/255, blue: 0xF7/255, alpha: 1)
|
||||
private let textColor = UIColor { $0.userInterfaceStyle == .dark ? .white : .black }
|
||||
private let textSecondary = UIColor { $0.userInterfaceStyle == .dark
|
||||
? UIColor(red: 0x8D/255, green: 0x8E/255, blue: 0x93/255, alpha: 1)
|
||||
: UIColor(red: 0x8E/255, green: 0x8E/255, blue: 0x93/255, alpha: 1)
|
||||
}
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
setupViews()
|
||||
}
|
||||
|
||||
@available(*, unavailable) required init?(coder: NSCoder) { fatalError() }
|
||||
|
||||
private func setupViews() {
|
||||
addSubview(avatarContainer)
|
||||
|
||||
nameLabel.font = .systemFont(ofSize: 17)
|
||||
nameLabel.textColor = textColor
|
||||
nameLabel.lineBreakMode = .byTruncatingTail
|
||||
addSubview(nameLabel)
|
||||
|
||||
statusLabel.font = .systemFont(ofSize: 14)
|
||||
statusLabel.textColor = textSecondary
|
||||
statusLabel.lineBreakMode = .byTruncatingTail
|
||||
addSubview(statusLabel)
|
||||
}
|
||||
|
||||
func configure(member: GroupMember) {
|
||||
nameLabel.text = member.title
|
||||
|
||||
if member.isOnline {
|
||||
statusLabel.text = "online"
|
||||
statusLabel.textColor = accentBlue
|
||||
} else if !member.username.isEmpty {
|
||||
statusLabel.text = "@\(member.username)"
|
||||
statusLabel.textColor = textSecondary
|
||||
} else {
|
||||
statusLabel.text = "last seen recently"
|
||||
statusLabel.textColor = textSecondary
|
||||
}
|
||||
|
||||
// Avatar
|
||||
avatarHosting?.view.removeFromSuperview()
|
||||
avatarHosting?.removeFromParent()
|
||||
|
||||
let initials = RosettaColors.initials(name: member.title, publicKey: member.id)
|
||||
let colorIdx = RosettaColors.avatarColorIndex(for: member.title, publicKey: member.id)
|
||||
let avatar = AvatarRepository.shared.loadAvatar(publicKey: member.id)
|
||||
let sectionFill = Color(UIColor { $0.userInterfaceStyle == .dark
|
||||
? UIColor(red: 28/255, green: 28/255, blue: 29/255, alpha: 1)
|
||||
: UIColor(red: 242/255, green: 242/255, blue: 247/255, alpha: 1)
|
||||
})
|
||||
|
||||
let avatarView = AvatarView(
|
||||
initials: initials,
|
||||
colorIndex: colorIdx,
|
||||
size: 40,
|
||||
isOnline: member.isOnline,
|
||||
isSavedMessages: false,
|
||||
image: avatar,
|
||||
onlineBorderColor: sectionFill
|
||||
)
|
||||
let hosting = UIHostingController(rootView: avatarView)
|
||||
hosting.view.backgroundColor = .clear
|
||||
hosting.view.frame = CGRect(x: 0, y: 0, width: 40, height: 40)
|
||||
avatarContainer.addSubview(hosting.view)
|
||||
avatarHosting = hosting
|
||||
|
||||
// Verified badge
|
||||
verifiedHosting?.view.removeFromSuperview()
|
||||
verifiedHosting = nil
|
||||
if member.verified > 0 {
|
||||
let badge = VerifiedBadge(verified: member.verified, size: 14)
|
||||
let vc = UIHostingController(rootView: badge)
|
||||
vc.view.backgroundColor = .clear
|
||||
addSubview(vc.view)
|
||||
verifiedHosting = vc
|
||||
}
|
||||
|
||||
// Admin badge
|
||||
adminHosting?.view.removeFromSuperview()
|
||||
adminHosting = nil
|
||||
if member.isAdmin {
|
||||
let badge = VerifiedBadge(verified: 3, size: 20)
|
||||
let vc = UIHostingController(rootView: badge)
|
||||
vc.view.backgroundColor = .clear
|
||||
addSubview(vc.view)
|
||||
adminHosting = vc
|
||||
}
|
||||
|
||||
setNeedsLayout()
|
||||
}
|
||||
|
||||
override func layoutSubviews() {
|
||||
super.layoutSubviews()
|
||||
let w = bounds.width
|
||||
let h = bounds.height
|
||||
|
||||
avatarContainer.frame = CGRect(x: 0, y: (h - 40) / 2, width: 40, height: 40)
|
||||
|
||||
let nameX: CGFloat = 49
|
||||
let nameSize = nameLabel.sizeThatFits(CGSize(width: w - nameX - 40, height: 22))
|
||||
nameLabel.frame = CGRect(x: nameX, y: 10, width: min(nameSize.width, w - nameX - 40), height: 22)
|
||||
|
||||
if let badgeView = verifiedHosting?.view {
|
||||
badgeView.frame = CGRect(x: nameLabel.frame.maxX + 4, y: 14, width: 14, height: 14)
|
||||
}
|
||||
|
||||
statusLabel.frame = CGRect(x: nameX, y: 34, width: w - nameX - 40, height: 18)
|
||||
|
||||
if let adminView = adminHosting?.view {
|
||||
adminView.frame = CGRect(x: w - 20, y: (h - 20) / 2, width: 20, height: 20)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user