From 660d1f046d9e9c4b45930b84cdbf0a77f242b57c Mon Sep 17 00:00:00 2001 From: senseiGai Date: Fri, 17 Apr 2026 01:07:46 +0500 Subject: [PATCH] =?UTF-8?q?=D0=93=D1=80=D1=83=D0=BF=D0=BF=D0=BE=D0=B2?= =?UTF-8?q?=D1=8B=D0=B5=20=D0=B0=D0=B2=D0=B0=D1=82=D0=B0=D1=80=D0=BA=D0=B8?= =?UTF-8?q?:=20=D0=BE=D1=82=D0=BF=D1=80=D0=B0=D0=B2=D0=BA=D0=B0=20+=20?= =?UTF-8?q?=D1=88=D0=B8=D1=84=D1=80=D0=BE=D0=B2=D0=B0=D0=BD=D0=B8=D0=B5=20?= =?UTF-8?q?+=20Desktop=20parity?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Rosetta/Core/Network/TransportManager.swift | 37 ++++ .../Services/InAppNotificationManager.swift | 5 +- Rosetta/Core/Services/SessionManager.swift | 27 ++- .../ChatDetail/ChatDetailViewController.swift | 73 +++----- .../Chats/ChatDetail/MessageAvatarView.swift | 6 +- .../Chats/ChatDetail/NativeMessageCell.swift | 15 +- .../Chats/ChatDetail/NativeMessageList.swift | 4 +- .../OpponentProfileViewController.swift | 161 ++++++++++++++++-- .../ChatDetail/VoiceRecordingPanel.swift | 4 +- .../Chats/ChatList/UIKit/ChatListCell.swift | 17 +- 10 files changed, 254 insertions(+), 95 deletions(-) diff --git a/Rosetta/Core/Network/TransportManager.swift b/Rosetta/Core/Network/TransportManager.swift index 0ab3ee5..2e812dd 100644 --- a/Rosetta/Core/Network/TransportManager.swift +++ b/Rosetta/Core/Network/TransportManager.swift @@ -22,6 +22,37 @@ enum TransportError: LocalizedError { } } +// MARK: - TransportLimiter + +/// Limits concurrent CDN upload/download operations to prevent I/O storms +/// when user scrolls through media-heavy chats. Without this, unbounded +/// Task.detached can spawn 50+ concurrent transfers → CPU spike → phone overheating. +private actor TransportLimiter { + static let shared = TransportLimiter() + private let maxConcurrent = 4 + private var running = 0 + private var waiters: [CheckedContinuation] = [] + + func acquire() async { + if running < maxConcurrent { + running += 1 + return + } + await withCheckedContinuation { continuation in + waiters.append(continuation) + } + } + + func release() { + if let next = waiters.first { + waiters.removeFirst() + next.resume() + } else { + running -= 1 + } + } +} + // MARK: - TransportManager /// Manages file upload/download to the transport server. @@ -81,6 +112,9 @@ final class TransportManager: @unchecked Sendable { private static let maxUploadRetries = 3 func uploadFile(id: String, content: Data) async throws -> (tag: String, server: String) { + await TransportLimiter.shared.acquire() + defer { Task { await TransportLimiter.shared.release() } } + guard let serverUrl = await MainActor.run(body: { transportServer }) else { throw TransportError.noTransportServer } @@ -162,6 +196,9 @@ final class TransportManager: @unchecked Sendable { server: String? = nil, onProgress: (@MainActor (Double) -> Void)? ) async throws -> Data { + await TransportLimiter.shared.acquire() + defer { Task { await TransportLimiter.shared.release() } } + let serverUrl: String if let explicit = server, !explicit.isEmpty { serverUrl = explicit diff --git a/Rosetta/Core/Services/InAppNotificationManager.swift b/Rosetta/Core/Services/InAppNotificationManager.swift index a0779e0..22ae3e0 100644 --- a/Rosetta/Core/Services/InAppNotificationManager.swift +++ b/Rosetta/Core/Services/InAppNotificationManager.swift @@ -8,9 +8,8 @@ enum InAppNotificationManager { static func shouldSuppress(senderKey: String) -> Bool { if senderKey.isEmpty { return true } if MessageRepository.shared.isDialogActive(senderKey) { return true } - let mutedKeys = UserDefaults(suiteName: "group.com.rosetta.dev")? - .stringArray(forKey: "muted_chats_keys") ?? [] - if mutedKeys.contains(senderKey) { return true } + // PERF: use in-memory dialog state instead of UserDefaults I/O per notification check. + if DialogRepository.shared.dialogs[senderKey]?.isMuted == true { return true } return false } } diff --git a/Rosetta/Core/Services/SessionManager.swift b/Rosetta/Core/Services/SessionManager.swift index e6cef1a..775f494 100644 --- a/Rosetta/Core/Services/SessionManager.swift +++ b/Rosetta/Core/Services/SessionManager.swift @@ -425,11 +425,7 @@ final class SessionManager { let messageId = UUID().uuidString.replacingOccurrences(of: "-", with: "").lowercased() let timestamp = Int64(Date().timeIntervalSince1970 * 1000) - let randomPart = String((0..<8).map { _ in "abcdefghijklmnopqrstuvwxyz0123456789".randomElement()! }) - // Mark group avatar attachments with "ga_" prefix so UI can distinguish - // "Shared group photo" vs "Shared profile photo" in the same group chat. - let isGroupAvatarSource = avatarSourceKey != nil && DatabaseManager.isGroupDialogKey(avatarSourceKey!) - let attachmentId = isGroupAvatarSource ? "ga_\(randomPart)" : randomPart + let attachmentId = String((0..<8).map { _ in "abcdefghijklmnopqrstuvwxyz0123456789".randomElement()! }) let isGroup = DatabaseManager.isGroupDialogKey(toPublicKey) // Android/Desktop parity: avatar messages have empty text. @@ -453,7 +449,9 @@ final class SessionManager { encryptedContent = try CryptoManager.shared.encryptWithPasswordDesktopCompat( Data("".utf8), password: groupKey ) - attachmentPassword = groupKey + // Desktop parity: Buffer.from(groupKey).toString('hex') — hex of UTF-8 bytes. + // Desktop stores chacha_key = hex(groupKey) and uses it for attachment decryption. + attachmentPassword = Data(groupKey.utf8).hexString outChachaKey = "" outAesChachaKey = "" } else { @@ -1951,11 +1949,8 @@ final class SessionManager { // Telegram parity: show in-app banner for foreground messages in non-active chats. if !fromMe && !effectiveFromSync && isAppInForeground { - let isMuted: Bool = { - let mutedKeys = UserDefaults(suiteName: "group.com.rosetta.dev")? - .stringArray(forKey: "muted_chats_keys") ?? [] - return mutedKeys.contains(opponentKey) - }() + // PERF: use in-memory dialog state instead of UserDefaults I/O per message. + let isMuted = dialog?.isMuted == true // Desktop-active suppression: skip banner if dialog was read on another device < 30s ago. let isDesktopActive: Bool = { if let readDate = self.desktopActiveDialogs[opponentKey] { @@ -2684,7 +2679,9 @@ final class SessionManager { } } - // Priority: fetch missing names first (generous 200ms stagger) + // Priority: fetch missing names first (generous 250ms stagger). + // PERF: increased stagger from 200→250ms to reduce CPU/network overhead + // that contributes to phone overheating with many chats. var count = 0 for key in missingName { guard ProtocolManager.shared.connectionState == .authenticated else { break } @@ -2692,11 +2689,11 @@ final class SessionManager { requestUserInfoIfNeeded(opponentKey: key, privateKeyHash: privateKeyHash) count += 1 if count > 1 { - try? await Task.sleep(for: .milliseconds(200)) + try? await Task.sleep(for: .milliseconds(250)) } } - // Then refresh online status for recently active dialogs only (300ms stagger). + // Then refresh online status for recently active dialogs only (400ms stagger). // Sort by lastMessageTimestamp descending — most recent chats first. let recentKeys = hasName .compactMap { key -> (String, Int64)? in @@ -2712,7 +2709,7 @@ final class SessionManager { requestUserInfoIfNeeded(opponentKey: key, privateKeyHash: privateKeyHash) count += 1 if count > 1 { - try? await Task.sleep(for: .milliseconds(300)) + try? await Task.sleep(for: .milliseconds(400)) } } Self.logger.info("Refreshed user info: \(missingName.count) missing names + \(recentKeys.count) online status = \(count) total (capped at 30)") diff --git a/Rosetta/Features/Chats/ChatDetail/ChatDetailViewController.swift b/Rosetta/Features/Chats/ChatDetail/ChatDetailViewController.swift index 6eb6de2..45cfd5d 100644 --- a/Rosetta/Features/Chats/ChatDetail/ChatDetailViewController.swift +++ b/Rosetta/Features/Chats/ChatDetail/ChatDetailViewController.swift @@ -835,9 +835,14 @@ final class ChatDetailViewController: UIViewController { onSendAvatar: { [weak self] in self?.sendAvatarToChat() }, - hasAvatar: AvatarRepository.shared.loadAvatar(publicKey: currentPublicKey) != nil, + hasAvatar: avatarAvailableForSending(), onSetAvatar: { [weak self] in - self?.showAlert(title: "No Avatar", message: "Set a profile photo in Settings to share it with contacts.") + guard let self else { return } + if self.route.isGroup { + self.showAlert(title: "No Group Avatar", message: "Set a group photo first to share it with members.") + } else { + self.showAlert(title: "No Avatar", message: "Set a profile photo in Settings to share it with contacts.") + } } ) let hosting = UIHostingController(rootView: panel) @@ -1318,59 +1323,17 @@ final class ChatDetailViewController: UIViewController { sendCurrentMessage() } + /// Desktop parity: in groups only admin can send, and only group avatar. + /// In 1:1 chats — sends personal avatar. private func sendAvatarToChat() { - if route.isGroup { - let cached = GroupRepository.shared.cachedMembers( - account: currentPublicKey, - groupDialogKey: route.publicKey - ) - if cached?.adminKey == currentPublicKey { - showAvatarActionSheet() - return - } - } - performSendAvatar() - } - - private func performSendAvatar() { - Task { @MainActor in - do { - try await SessionManager.shared.sendAvatar( - toPublicKey: route.publicKey, - opponentTitle: route.title, - opponentUsername: route.username - ) - } catch { - showAlert(title: "Send Error", message: error.localizedDescription) - } - } - } - - private func showAvatarActionSheet() { - let hasGroupAvatar = AvatarRepository.shared.loadAvatar(publicKey: route.publicKey) != nil - let sheet = UIAlertController(title: nil, message: nil, preferredStyle: .actionSheet) - sheet.addAction(UIAlertAction(title: "Send My Avatar", style: .default) { [weak self] _ in - self?.performSendAvatar() - }) - if hasGroupAvatar { - sheet.addAction(UIAlertAction(title: "Share Group Avatar", style: .default) { [weak self] _ in - self?.shareGroupAvatar() - }) - } - sheet.addAction(UIAlertAction(title: "Cancel", style: .cancel)) - DispatchQueue.main.async { [weak self] in - self?.present(sheet, animated: true) - } - } - - private func shareGroupAvatar() { + let sourceKey = route.isGroup ? route.publicKey : nil Task { @MainActor in do { try await SessionManager.shared.sendAvatar( toPublicKey: route.publicKey, opponentTitle: route.title, opponentUsername: route.username, - avatarSourceKey: route.publicKey + avatarSourceKey: sourceKey ) } catch { showAlert(title: "Send Error", message: error.localizedDescription) @@ -1378,6 +1341,20 @@ final class ChatDetailViewController: UIViewController { } } + /// Returns true if avatar tab should show "Send" (not "Set"). + /// Groups: only admin + group has avatar. Personal: user has avatar. + private func avatarAvailableForSending() -> Bool { + if route.isGroup { + let cached = GroupRepository.shared.cachedMembers( + account: currentPublicKey, + groupDialogKey: route.publicKey + ) + guard cached?.adminKey == currentPublicKey else { return false } + return AvatarRepository.shared.loadAvatar(publicKey: route.publicKey) != nil + } + return AvatarRepository.shared.loadAvatar(publicKey: currentPublicKey) != nil + } + private func handleComposerUserTyping() { guard !route.isSavedMessages, !route.isSystemAccount else { return } SessionManager.shared.sendTypingIndicator(toPublicKey: route.publicKey) diff --git a/Rosetta/Features/Chats/ChatDetail/MessageAvatarView.swift b/Rosetta/Features/Chats/ChatDetail/MessageAvatarView.swift index 31ba4bd..3ea7968 100644 --- a/Rosetta/Features/Chats/ChatDetail/MessageAvatarView.swift +++ b/Rosetta/Features/Chats/ChatDetail/MessageAvatarView.swift @@ -66,7 +66,7 @@ struct MessageAvatarView: View { .font(.system(size: 12)) .foregroundStyle(RosettaColors.error) } else if avatarImage != nil { - Text(attachment.id.hasPrefix("ga_") + Text(DatabaseManager.isGroupDialogKey(message.toPublicKey) ? "Shared group photo." : "Shared profile photo.") .font(.system(size: 12)) @@ -264,8 +264,8 @@ struct MessageAvatarView: View { if let downloadedImage { avatarImage = downloadedImage AttachmentCache.shared.saveImage(downloadedImage, forAttachmentId: attachment.id) - // Desktop parity: in group chats save as group avatar, - // in personal chats save as sender's avatar + // Desktop parity: in groups, avatar saves as group avatar. + // In personal chats, saves as sender's avatar. let avatarKey = DatabaseManager.isGroupDialogKey(message.toPublicKey) ? message.toPublicKey : message.fromPublicKey diff --git a/Rosetta/Features/Chats/ChatDetail/NativeMessageCell.swift b/Rosetta/Features/Chats/ChatDetail/NativeMessageCell.swift index ca4312c..097dc5e 100644 --- a/Rosetta/Features/Chats/ChatDetail/NativeMessageCell.swift +++ b/Rosetta/Features/Chats/ChatDetail/NativeMessageCell.swift @@ -1160,12 +1160,12 @@ final class NativeMessageCell: UICollectionViewCell { avatarImageView.image = cached avatarImageView.isHidden = false fileIconView.isHidden = true - fileSizeLabel.text = avatarAtt.id.hasPrefix("ga_") ? "Shared group photo" : "Shared profile photo" + fileSizeLabel.text = DatabaseManager.isGroupDialogKey(message.toPublicKey) ? "Shared group photo" : "Shared profile photo" fileSizeLabel.textColor = isOutgoing ? UIColor.white.withAlphaComponent(0.5) : .secondaryLabel } else { if isOutgoing { // Own avatar — already uploaded, just loading from disk - fileSizeLabel.text = avatarAtt.id.hasPrefix("ga_") ? "Shared group photo" : "Shared profile photo" + fileSizeLabel.text = DatabaseManager.isGroupDialogKey(message.toPublicKey) ? "Shared group photo" : "Shared profile photo" } else { // Incoming avatar — needs download on tap (Android parity) fileSizeLabel.text = "Tap to download" @@ -1208,7 +1208,8 @@ final class NativeMessageCell: UICollectionViewCell { self.avatarImageView.image = diskImage self.avatarImageView.isHidden = false self.fileIconView.isHidden = true - self.fileSizeLabel.text = attId.hasPrefix("ga_") ? "Shared group photo" : "Shared profile photo" + let isGroupMsg = DatabaseManager.isGroupDialogKey(self.message?.toPublicKey ?? "") + self.fileSizeLabel.text = isGroupMsg ? "Shared group photo" : "Shared profile photo" } } // CDN download is triggered by user tap via .triggerAttachmentDownload @@ -2275,6 +2276,7 @@ final class NativeMessageCell: UICollectionViewCell { fileSizeLabel.text = "Downloading..." let messageId = message.id // Desktop parity: group avatar saves to group dialog key, not sender key + // Desktop parity: in groups, avatar saves as group avatar. let avatarTargetKey = DatabaseManager.isGroupDialogKey(message.toPublicKey) ? message.toPublicKey : message.fromPublicKey let server = avatarAtt.transportServer @@ -2290,7 +2292,8 @@ final class NativeMessageCell: UICollectionViewCell { self.avatarImageView.image = downloaded self.avatarImageView.isHidden = false self.fileIconView.isHidden = true - self.fileSizeLabel.text = id.hasPrefix("ga_") ? "Shared group photo" : "Shared profile photo" + let isGroupMsg = DatabaseManager.isGroupDialogKey(self.message?.toPublicKey ?? "") + self.fileSizeLabel.text = isGroupMsg ? "Shared group photo" : "Shared profile photo" // Trigger refresh of sender avatar circles in visible cells NotificationCenter.default.post( name: Notification.Name("avatarDidUpdate"), object: nil @@ -3193,6 +3196,10 @@ final class NativeMessageCell: UICollectionViewCell { let server = attachment.transportServer photoDownloadTasks[attachmentId] = Task { [weak self] in + // PERF: concurrent CDN downloads are capped by TransportLimiter (max 4) inside + // TransportManager.downloadFile(). Do NOT use ImageLoadLimiter here — it holds + // a slot (max 3) for the entire download duration (seconds), starving fast + // disk loads in startPhotoLoadTask() and causing cached photos to lag. do { let encryptedData = try await TransportManager.shared.downloadFile(tag: tag, server: server) let encryptedString = String(decoding: encryptedData, as: UTF8.self) diff --git a/Rosetta/Features/Chats/ChatDetail/NativeMessageList.swift b/Rosetta/Features/Chats/ChatDetail/NativeMessageList.swift index 5bb7bb4..67e4018 100644 --- a/Rosetta/Features/Chats/ChatDetail/NativeMessageList.swift +++ b/Rosetta/Features/Chats/ChatDetail/NativeMessageList.swift @@ -1743,7 +1743,7 @@ final class NativeMessageListController: UIViewController { let composerBottom = max(currentKeyboardHeight, view.safeAreaInsets.bottom) let composerHeight = lastComposerHeight let newInsetTop = composerHeight + composerBottom + UIConstants.messageToComposerGap - let topInset = view.safeAreaInsets.top + 6 + let topInset = view.safeAreaInsets.top + topStickyOffset + 6 let oldInsetTop = collectionView.contentInset.top let delta = newInsetTop - oldInsetTop @@ -1889,7 +1889,7 @@ final class NativeMessageListController: UIViewController { // Explicit composerHeightConstraint prevents the 372pt inflation bug. let composerH = lastComposerHeight let newInsetTop = composerH + composerBottom + UIConstants.messageToComposerGap - let topInset = view.safeAreaInsets.top + 6 + let topInset = view.safeAreaInsets.top + topStickyOffset + 6 let oldInsetTop = collectionView.contentInset.top let delta = newInsetTop - oldInsetTop diff --git a/Rosetta/Features/Chats/ChatDetail/OpponentProfileViewController.swift b/Rosetta/Features/Chats/ChatDetail/OpponentProfileViewController.swift index 0227c5c..3d55d62 100644 --- a/Rosetta/Features/Chats/ChatDetail/OpponentProfileViewController.swift +++ b/Rosetta/Features/Chats/ChatDetail/OpponentProfileViewController.swift @@ -2,9 +2,10 @@ import UIKit import SwiftUI import Combine -/// Pure UIKit peer profile screen. Phase 2: data + interactivity. +/// Pure UIKit peer profile screen. Phase 3: expand/collapse header + haptic. final class OpponentProfileViewController: UIViewController, UIGestureRecognizerDelegate, - ProfileTabBarDelegate, UICollectionViewDataSource, UICollectionViewDelegate { + ProfileTabBarDelegate, UICollectionViewDataSource, UICollectionViewDelegate, + UIScrollViewDelegate { // MARK: - Properties @@ -50,6 +51,15 @@ final class OpponentProfileViewController: UIViewController, UIGestureRecognizer private let emptyIcon = UIImageView() private let emptyLabel = UILabel() + // Expand/collapse header + private let headerImageView = UIImageView() + private let scrimView = UIView() + private let scrimGradient = CAGradientLayer() + private var isLargeHeader = false + private var canExpand = false + private var profileImage: UIImage? + private let expandFeedback = UIImpactFeedbackGenerator(style: .light) + // MARK: - Constants private let hPad: CGFloat = 16 @@ -102,7 +112,9 @@ final class OpponentProfileViewController: UIViewController, UIGestureRecognizer setupMediaGrid() setupListContainers() setupEmptyState() + setupHeaderImage() + scrollView.delegate = self backButton.addTarget(self, action: #selector(backTapped), for: .touchUpInside) view.addSubview(backButton) @@ -139,7 +151,13 @@ final class OpponentProfileViewController: UIViewController, UIGestureRecognizer viewModel.$isOnline .receive(on: DispatchQueue.main) .sink { [weak self] online in - self?.subtitleLabel.text = online ? "online" : "offline" + guard let self else { return } + self.subtitleLabel.text = online ? "online" : "offline" + if self.isLargeHeader { + self.subtitleLabel.textColor = online ? self.onlineColor : .white.withAlphaComponent(0.7) + } else { + self.subtitleLabel.textColor = online ? self.onlineColor : self.textSecondary + } } .store(in: &cancellables) @@ -294,6 +312,41 @@ final class OpponentProfileViewController: UIViewController, UIGestureRecognizer contentView.addSubview(emptyLabel) } + private func setupHeaderImage() { + headerImageView.contentMode = .scaleAspectFill + headerImageView.clipsToBounds = true + headerImageView.alpha = 0 + contentView.insertSubview(headerImageView, at: 0) + + scrimView.isUserInteractionEnabled = false + scrimView.alpha = 0 + contentView.insertSubview(scrimView, aboveSubview: headerImageView) + + // Bottom gradient — for name readability + scrimGradient.colors = [ + UIColor.clear.cgColor, + UIColor.black.withAlphaComponent(0.05).cgColor, + UIColor.black.withAlphaComponent(0.6).cgColor + ] + scrimGradient.locations = [0.4, 0.65, 1.0] + scrimView.layer.addSublayer(scrimGradient) + + // Top gradient — for status bar readability + let topGradient = CAGradientLayer() + topGradient.colors = [ + UIColor.black.withAlphaComponent(0.4).cgColor, + UIColor.clear.cgColor + ] + topGradient.locations = [0.0, 0.3] + topGradient.accessibilityHint = "topScrim" + scrimView.layer.addSublayer(topGradient) + + profileImage = AvatarRepository.shared.loadAvatar(publicKey: route.publicKey) + canExpand = profileImage != nil + if canExpand { headerImageView.image = profileImage } + expandFeedback.prepare() + } + // MARK: - Layout private func layoutAll() { @@ -301,25 +354,64 @@ final class OpponentProfileViewController: UIViewController, UIGestureRecognizer let safeTop = view.safeAreaInsets.top scrollView.frame = view.bounds contentView.frame.size.width = w - backButton.frame = CGRect(x: 7, y: safeTop - 10, width: 44, height: 44) - var y: CGFloat = safeTop - 8 + // Back button — same position as ChatDetailViewController (centered in 44pt header bar) + let headerCenterY = safeTop + 22 + backButton.frame = CGRect(x: 8, y: headerCenterY - 22, width: 44, height: 44) - // Avatar - avatarContainer.frame = CGRect(x: (w - avatarSize) / 2, y: y, width: avatarSize, height: avatarSize) - avatarHosting?.view.frame = avatarContainer.bounds - y += avatarSize + 12 + var y: CGFloat + let expandedH = w // square photo, like Telegram - // Name - let nameH = nameLabel.sizeThatFits(CGSize(width: w - 60, height: 30)).height - nameLabel.frame = CGRect(x: 30, y: y, width: w - 60, height: nameH) - y += nameH + 1 + if isLargeHeader { + // ── Expanded: full-width photo from y=0 (behind Dynamic Island) ── + headerImageView.frame = CGRect(x: 0, y: 0, width: w, height: expandedH) + headerImageView.alpha = 1 + scrimView.frame = headerImageView.frame + scrimView.alpha = 1 + CATransaction.begin() + CATransaction.setDisableActions(true) + scrimGradient.frame = scrimView.bounds + CATransaction.commit() - // Subtitle - subtitleLabel.frame = CGRect(x: 30, y: y, width: w - 60, height: 20) - y += 20 + 20 + avatarContainer.alpha = 0 - // Buttons + // Name + subtitle overlaid at bottom of photo (inside gradient) + let nameH = nameLabel.sizeThatFits(CGSize(width: w - 40, height: 30)).height + nameLabel.frame = CGRect(x: 16, y: expandedH - 50, width: w - 32, height: nameH) + nameLabel.textAlignment = .left + nameLabel.textColor = .white + nameLabel.font = .systemFont(ofSize: 24, weight: .bold) + + subtitleLabel.frame = CGRect(x: 16, y: expandedH - 26, width: w - 32, height: 20) + subtitleLabel.textAlignment = .left + subtitleLabel.textColor = viewModel.isOnline ? onlineColor : .white.withAlphaComponent(0.7) + + y = expandedH + 16 + } else { + // ── Collapsed: small avatar ── + headerImageView.alpha = 0 + scrimView.alpha = 0 + avatarContainer.alpha = 1 + + y = safeTop + 8 + avatarContainer.frame = CGRect(x: (w - avatarSize) / 2, y: y, width: avatarSize, height: avatarSize) + avatarHosting?.view.frame = avatarContainer.bounds + y += avatarSize + 12 + + let nameH = nameLabel.sizeThatFits(CGSize(width: w - 60, height: 30)).height + nameLabel.frame = CGRect(x: 30, y: y, width: w - 60, height: nameH) + nameLabel.textAlignment = .center + nameLabel.textColor = textPrimary + nameLabel.font = .systemFont(ofSize: 17, weight: .semibold) + y += nameH + 1 + + subtitleLabel.frame = CGRect(x: 30, y: y, width: w - 60, height: 20) + subtitleLabel.textAlignment = .center + subtitleLabel.textColor = viewModel.isOnline ? onlineColor : textSecondary + y += 20 + 20 + } + + // ── Buttons (both states) ── let btnCount = CGFloat(actionButtonViews.count) let btnW = (w - hPad * 2 - buttonSpacing * (btnCount - 1)) / btnCount for (i, (c, iv, lbl)) in actionButtonViews.enumerated() { @@ -345,6 +437,41 @@ final class OpponentProfileViewController: UIViewController, UIGestureRecognizer scrollView.contentSize = CGSize(width: w, height: y) } + // MARK: - Expand / Collapse + + private func expandHeader() { + guard canExpand, !isLargeHeader else { return } + isLargeHeader = true + expandFeedback.impactOccurred() + scrollView.setContentOffset(.zero, animated: false) + UIView.animate(withDuration: 0.35, delay: 0, usingSpringWithDamping: 0.86, + initialSpringVelocity: 0.5, options: []) { + self.layoutAll() + } + } + + private func collapseHeader() { + guard isLargeHeader else { return } + isLargeHeader = false + scrollView.setContentOffset(.zero, animated: false) + UIView.animate(withDuration: 0.35, delay: 0, usingSpringWithDamping: 0.86, + initialSpringVelocity: 0.5, options: []) { + self.layoutAll() + } + } + + // MARK: - UIScrollViewDelegate + + func scrollViewDidScroll(_ scrollView: UIScrollView) { + let offsetY = scrollView.contentOffset.y + if offsetY <= -32 && scrollView.isDragging && scrollView.isTracking + && canExpand && !isLargeHeader { + expandHeader() + } else if offsetY >= 1 && isLargeHeader { + collapseHeader() + } + } + private func layoutInfoCard(width: CGFloat) -> CGFloat { infoCard.subviews.forEach { $0.removeFromSuperview() } let dialog = DialogRepository.shared.dialogs[route.publicKey] diff --git a/Rosetta/Features/Chats/ChatDetail/VoiceRecordingPanel.swift b/Rosetta/Features/Chats/ChatDetail/VoiceRecordingPanel.swift index 882531e..da085df 100644 --- a/Rosetta/Features/Chats/ChatDetail/VoiceRecordingPanel.swift +++ b/Rosetta/Features/Chats/ChatDetail/VoiceRecordingPanel.swift @@ -159,7 +159,7 @@ final class VoiceRecordingPanel: UIView { // Red dot: 10×10, centered vertically with timer let timerSize = timerLabel.sizeThatFits(CGSize(width: 100, height: h)) - let timerY = floor((h - timerSize.height) / 2) + 1 // +1pt baseline offset (Telegram) + let timerY = floor((h - timerSize.height) / 2) redDot.frame = CGRect( x: dotX, @@ -226,7 +226,7 @@ final class VoiceRecordingPanel: UIView { return } let timerSize = timerLabel.sizeThatFits(CGSize(width: .greatestFiniteMagnitude, height: h)) - let timerY = floor((h - timerSize.height) / 2) + 1 + let timerY = floor((h - timerSize.height) / 2) let timerWidth = max(timerMinWidth, ceil(timerSize.width + 4)) CATransaction.begin() CATransaction.setDisableActions(true) diff --git a/Rosetta/Features/Chats/ChatList/UIKit/ChatListCell.swift b/Rosetta/Features/Chats/ChatList/UIKit/ChatListCell.swift index 70a4202..4a9b6ce 100644 --- a/Rosetta/Features/Chats/ChatList/UIKit/ChatListCell.swift +++ b/Rosetta/Features/Chats/ChatList/UIKit/ChatListCell.swift @@ -572,7 +572,22 @@ final class ChatListCell: UICollectionViewCell { // MARK: - Delivery Status /// Cached checkmark images (rendered once from SwiftUI Shapes). - private static let singleCheckImage: UIImage = StatusIconRenderer.renderShape(SingleCheckmarkShape(), size: CGSize(width: 14, height: 10.3)) + private static let singleCheckImage: UIImage = { + // Match double checkmark canvas (17×9.3) so both scale + // identically in the 16×16 statusImageView (scaleAspectFit). + let canvas = CGSize(width: 17, height: 9.3) + let checkW = 16.4 * (9.3 / 12.0) + let renderer = UIGraphicsImageRenderer(size: canvas) + return renderer.image { ctx in + ctx.cgContext.translateBy(x: canvas.width - checkW, y: 0) + let path = SingleCheckmarkShape().path( + in: CGRect(x: 0, y: 0, width: checkW, height: canvas.height) + ) + ctx.cgContext.addPath(path.cgPath) + ctx.cgContext.setFillColor(UIColor.black.cgColor) + ctx.cgContext.fillPath() + } + }() private static let doubleCheckImage: UIImage = StatusIconRenderer.renderShape(DoubleCheckmarkShape(), size: CGSize(width: 17, height: 9.3)) /// Cached error indicator (Telegram: red circle with white exclamation).