Фикс: Request Chats навигация + reply cancel (X) + emoji краш в группах + баннер аватарка

This commit is contained in:
2026-04-16 21:38:33 +05:00
parent 459ac4e4da
commit bb7be99f44
15 changed files with 2111 additions and 70 deletions

View File

@@ -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 "".

View File

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

View File

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

View 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() }
}

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

View File

@@ -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 hiddenvisible 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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 {

View File

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

File diff suppressed because it is too large Load Diff

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