diff --git a/Rosetta/Core/Data/Repositories/DialogRepository.swift b/Rosetta/Core/Data/Repositories/DialogRepository.swift index 32abf68..534e65e 100644 --- a/Rosetta/Core/Data/Repositories/DialogRepository.swift +++ b/Rosetta/Core/Data/Repositories/DialogRepository.swift @@ -412,6 +412,9 @@ final class DialogRepository { var batch = dialogs var sortOrderChanged = false + var dialogsToPersist: [Dialog] = [] + var keysToDelete: [String] = [] + for opponentKey in keysToReconcile { let account = currentAccount let isSavedMessages = opponentKey == account @@ -424,12 +427,7 @@ final class DialogRepository { if batch[opponentKey] != nil { batch.removeValue(forKey: opponentKey) sortOrderChanged = true - try? db.writeSync { db in - try db.execute( - sql: "DELETE FROM dialogs WHERE account = ? AND opponent_key = ?", - arguments: [account, opponentKey] - ) - } + keysToDelete.append(opponentKey) } continue } @@ -490,9 +488,13 @@ final class DialogRepository { sortOrderChanged = true } batch[opponentKey] = dialog - persistDialog(dialog) + dialogsToPersist.append(dialog) } + // PERF: Single DB transaction for all dialog upserts + deletes + // instead of N separate writeSync calls (was: 50 dialogs = 50 transactions). + persistDialogsBatch(dialogsToPersist, keysToDelete: keysToDelete) + // Single assignment — triggers didSet exactly once dialogs = batch if sortOrderChanged { invalidateSortOrder() } @@ -514,6 +516,59 @@ final class DialogRepository { // MARK: - Private + /// PERF: Batch upsert + delete in a single DB transaction. + /// Used by reconcileAllDialogs() — reduces N writeSync calls to 1. + private func persistDialogsBatch(_ dialogs: [Dialog], keysToDelete: [String] = []) { + guard !dialogs.isEmpty || !keysToDelete.isEmpty else { return } + do { + try db.writeSync { db in + for key in keysToDelete { + try db.execute( + sql: "DELETE FROM dialogs WHERE account = ? AND opponent_key = ?", + arguments: [currentAccount, key] + ) + } + for dialog in dialogs { + try db.execute( + sql: """ + INSERT INTO dialogs (account, opponent_key, opponent_title, opponent_username, + last_message, last_message_timestamp, unread_count, is_online, last_seen, + verified, i_have_sent, is_pinned, is_muted, last_message_from_me, + last_message_delivered, last_message_read, last_message_sender_key) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(account, opponent_key) DO UPDATE SET + opponent_title = excluded.opponent_title, + opponent_username = excluded.opponent_username, + last_message = excluded.last_message, + last_message_timestamp = excluded.last_message_timestamp, + unread_count = excluded.unread_count, + is_online = excluded.is_online, + last_seen = excluded.last_seen, + verified = excluded.verified, + i_have_sent = excluded.i_have_sent, + is_pinned = excluded.is_pinned, + is_muted = excluded.is_muted, + last_message_from_me = excluded.last_message_from_me, + last_message_delivered = excluded.last_message_delivered, + last_message_read = excluded.last_message_read, + last_message_sender_key = excluded.last_message_sender_key + """, + arguments: [ + dialog.account, dialog.opponentKey, dialog.opponentTitle, dialog.opponentUsername, + dialog.lastMessage, dialog.lastMessageTimestamp, dialog.unreadCount, + dialog.isOnline ? 1 : 0, dialog.lastSeen, dialog.verified, + dialog.iHaveSent ? 1 : 0, dialog.isPinned ? 1 : 0, dialog.isMuted ? 1 : 0, + dialog.lastMessageFromMe ? 1 : 0, dialog.lastMessageDelivered.rawValue, + dialog.lastMessageRead ? 1 : 0, dialog.lastMessageSenderKey + ] + ) + } + } + } catch { + print("[DB] persistDialogsBatch error: \(error)") + } + } + private func persistDialog(_ dialog: Dialog) { do { try db.writeSync { db in diff --git a/Rosetta/Core/Data/Repositories/MessageRepository.swift b/Rosetta/Core/Data/Repositories/MessageRepository.swift index 5038e78..33b2ef1 100644 --- a/Rosetta/Core/Data/Repositories/MessageRepository.swift +++ b/Rosetta/Core/Data/Repositories/MessageRepository.swift @@ -824,7 +824,32 @@ final class MessageRepository: ObservableObject { } catch { print("[DB] markIncomingAsRead error: \(error)") } - // Performance: patch cache in-memory instead of full reload+decrypt. + patchCacheMarkIncoming(opponentKey: opponentKey, myPublicKey: myPublicKey) + } + + /// PERF: Batch mark incoming as read for multiple dialogs in a single DB transaction. + /// Used by reapplyPendingSyncReads/markActiveDialogsAsRead — reduces N writeSync calls to 1. + func markIncomingAsReadBatch(opponentKeys: [String], myPublicKey: String) { + guard !currentAccount.isEmpty, !opponentKeys.isEmpty else { return } + do { + try db.writeSync { db in + for opponentKey in opponentKeys { + let dialogKey = DatabaseManager.dialogKey(account: myPublicKey, opponentKey: opponentKey) + try db.execute( + sql: "UPDATE messages SET is_read = 1 WHERE account = ? AND dialog_key = ? AND from_me = 0", + arguments: [myPublicKey, dialogKey] + ) + } + } + } catch { + print("[DB] markIncomingAsReadBatch error: \(error)") + } + for opponentKey in opponentKeys { + patchCacheMarkIncoming(opponentKey: opponentKey, myPublicKey: myPublicKey) + } + } + + private func patchCacheMarkIncoming(opponentKey: String, myPublicKey: String) { if var cached = messagesByDialog[opponentKey] { var changed = false for i in cached.indices where !cached[i].isFromMe(myPublicKey: myPublicKey) && !cached[i].isRead { @@ -853,7 +878,32 @@ final class MessageRepository: ObservableObject { } catch { print("[DB] markOutgoingAsRead error: \(error)") } - // Performance: patch cache in-memory instead of full reload+decrypt. + patchCacheMarkOutgoing(opponentKey: opponentKey, myPublicKey: myPublicKey) + } + + /// PERF: Batch mark outgoing as read for multiple dialogs in a single DB transaction. + /// Used by reapplyPendingOpponentReads — reduces N writeSync calls to 1. + func markOutgoingAsReadBatch(opponentKeys: [String], myPublicKey: String) { + guard !currentAccount.isEmpty, !opponentKeys.isEmpty else { return } + do { + try db.writeSync { db in + for opponentKey in opponentKeys { + let dialogKey = DatabaseManager.dialogKey(account: myPublicKey, opponentKey: opponentKey) + try db.execute( + sql: "UPDATE messages SET is_read = 1 WHERE account = ? AND dialog_key = ? AND from_me = 1 AND is_read = 0", + arguments: [myPublicKey, dialogKey] + ) + } + } + } catch { + print("[DB] markOutgoingAsReadBatch error: \(error)") + } + for opponentKey in opponentKeys { + patchCacheMarkOutgoing(opponentKey: opponentKey, myPublicKey: myPublicKey) + } + } + + private func patchCacheMarkOutgoing(opponentKey: String, myPublicKey: String) { if var cached = messagesByDialog[opponentKey] { var changed = false for i in cached.indices where cached[i].isFromMe(myPublicKey: myPublicKey) && !cached[i].isRead { diff --git a/Rosetta/Core/Layout/MessageCellLayout.swift b/Rosetta/Core/Layout/MessageCellLayout.swift index 4d0b42b..bce05ed 100644 --- a/Rosetta/Core/Layout/MessageCellLayout.swift +++ b/Rosetta/Core/Layout/MessageCellLayout.swift @@ -1076,12 +1076,6 @@ extension MessageCellLayout { if allBase64 { return true } } - // Pure hex string (≥40 chars) — XChaCha20 wire format - if trimmed.count >= 40 { - let hexChars = CharacterSet(charactersIn: "0123456789abcdefABCDEF") - if trimmed.unicodeScalars.allSatisfy({ hexChars.contains($0) }) { return true } - } - return false } diff --git a/Rosetta/Core/Network/TransportManager.swift b/Rosetta/Core/Network/TransportManager.swift index 2e812dd..1b3d967 100644 --- a/Rosetta/Core/Network/TransportManager.swift +++ b/Rosetta/Core/Network/TransportManager.swift @@ -53,6 +53,29 @@ private actor TransportLimiter { } } +// MARK: - UploadProgressDelegate + +/// Inline delegate for tracking URLSession upload byte progress. +private final class UploadProgressDelegate: NSObject, URLSessionTaskDelegate, @unchecked Sendable { + let onProgress: @MainActor (Double) -> Void + + init(onProgress: @escaping @MainActor (Double) -> Void) { + self.onProgress = onProgress + } + + func urlSession( + _ session: URLSession, + task: URLSessionTask, + didSendBodyData bytesSent: Int64, + totalBytesSent: Int64, + totalBytesExpectedToSend: Int64 + ) { + guard totalBytesExpectedToSend > 0 else { return } + let fraction = Double(totalBytesSent) / Double(totalBytesExpectedToSend) + Task { @MainActor in self.onProgress(min(fraction, 1.0)) } + } +} + // MARK: - TransportManager /// Manages file upload/download to the transport server. @@ -73,6 +96,13 @@ final class TransportManager: @unchecked Sendable { /// Transport server URL received from server via PacketRequestTransport (0x0F). private(set) var transportServer: String? + /// Upload progress per message ID (0.0–1.0). Cells read this to show real upload progress. + /// Cleared when upload completes or fails. + @MainActor static var uploadProgress: [String: Double] = [:] + + /// Notification posted on MainActor when upload progress changes. Object = messageId (String). + static let uploadProgressNotification = Notification.Name("TransportManager.uploadProgress") + private let session: URLSession private init() { @@ -112,6 +142,16 @@ final class TransportManager: @unchecked Sendable { private static let maxUploadRetries = 3 func uploadFile(id: String, content: Data) async throws -> (tag: String, server: String) { + try await uploadFile(id: id, content: content, onProgress: nil) + } + + /// Upload with optional progress reporting. + /// Progress callback fires on MainActor with values 0.0–1.0. + func uploadFile( + id: String, + content: Data, + onProgress: (@MainActor (Double) -> Void)? + ) async throws -> (tag: String, server: String) { await TransportLimiter.shared.acquire() defer { Task { await TransportLimiter.shared.release() } } @@ -137,12 +177,19 @@ final class TransportManager: @unchecked Sendable { body.append("Content-Type: application/octet-stream\r\n\r\n".data(using: .utf8)!) body.append(content) body.append("\r\n--\(boundary)--\r\n".data(using: .utf8)!) - request.httpBody = body + + let delegate = onProgress.map { UploadProgressDelegate(onProgress: $0) } var lastError: Error = TransportError.invalidResponse for attempt in 0.. (tag: String, server: String) + func uploadFile(id: String, content: Data, onProgress: (@MainActor (Double) -> Void)?) async throws -> (tag: String, server: String) func downloadFile(tag: String, server: String?) async throws -> Data } @@ -23,6 +24,10 @@ struct LiveAttachmentFlowTransport: AttachmentFlowTransporting { try await TransportManager.shared.uploadFile(id: id, content: content) } + func uploadFile(id: String, content: Data, onProgress: (@MainActor (Double) -> Void)?) async throws -> (tag: String, server: String) { + try await TransportManager.shared.uploadFile(id: id, content: content, onProgress: onProgress) + } + func downloadFile(tag: String, server: String?) async throws -> Data { try await TransportManager.shared.downloadFile(tag: tag, server: server) } diff --git a/Rosetta/Core/Services/SessionManager.swift b/Rosetta/Core/Services/SessionManager.swift index 430f19e..995d206 100644 --- a/Rosetta/Core/Services/SessionManager.swift +++ b/Rosetta/Core/Services/SessionManager.swift @@ -124,10 +124,11 @@ final class SessionManager { let keys = pendingSyncReads pendingSyncReads.removeAll() let myKey = currentPublicKey + // PERF: batch DB writes into single transaction instead of N separate writeSync calls + MessageRepository.shared.markIncomingAsReadBatch( + opponentKeys: Array(keys), myPublicKey: myKey + ) for opponentKey in keys { - MessageRepository.shared.markIncomingAsRead( - opponentKey: opponentKey, myPublicKey: myKey - ) DialogRepository.shared.markAsRead(opponentKey: opponentKey) } } @@ -141,10 +142,11 @@ final class SessionManager { let keys = pendingOpponentReads pendingOpponentReads.removeAll() let myKey = currentPublicKey + // PERF: batch DB writes into single transaction instead of N separate writeSync calls + MessageRepository.shared.markOutgoingAsReadBatch( + opponentKeys: Array(keys), myPublicKey: myKey + ) for opponentKey in keys { - MessageRepository.shared.markOutgoingAsRead( - opponentKey: opponentKey, myPublicKey: myKey - ) DialogRepository.shared.markOutgoingAsRead(opponentKey: opponentKey) } } @@ -154,13 +156,20 @@ final class SessionManager { func markActiveDialogsAsRead() { let activeKeys = MessageRepository.shared.activeDialogKeys let myKey = currentPublicKey + // Collect eligible keys first, then batch DB writes + var eligibleKeys: [String] = [] for dialogKey in activeKeys { guard !SystemAccounts.isSystemAccount(dialogKey) else { continue } guard MessageRepository.shared.isDialogReadEligible(dialogKey) else { continue } + eligibleKeys.append(dialogKey) + } + guard !eligibleKeys.isEmpty else { return } + // PERF: batch DB writes into single transaction + MessageRepository.shared.markIncomingAsReadBatch( + opponentKeys: eligibleKeys, myPublicKey: myKey + ) + for dialogKey in eligibleKeys { DialogRepository.shared.markAsRead(opponentKey: dialogKey) - MessageRepository.shared.markIncomingAsRead( - opponentKey: dialogKey, myPublicKey: myKey - ) sendReadReceipt(toPublicKey: dialogKey) } } @@ -810,13 +819,26 @@ final class SessionManager { // and send a text-only packet (which causes empty messages on recipient). do { let flowTransport = attachmentFlowTransport + let totalAttachments = encryptedAttachments.count + let capturedMessageId = messageId let messageAttachments: [MessageAttachment] = try await withThrowingTaskGroup( of: (Int, String, String).self ) { group in for (index, item) in encryptedAttachments.enumerated() { group.addTask { let result = try await flowTransport.uploadFile( - id: item.original.id, content: item.encryptedData + id: item.original.id, content: item.encryptedData, + onProgress: { progress in + // Approximate: each attachment contributes equally to overall progress + let perAttachment = 1.0 / Double(totalAttachments) + let base = Double(index) * perAttachment + let combined = base + progress * perAttachment + TransportManager.uploadProgress[capturedMessageId] = combined + NotificationCenter.default.post( + name: TransportManager.uploadProgressNotification, + object: capturedMessageId + ) + } ) return (index, result.tag, result.server) } @@ -839,6 +861,8 @@ final class SessionManager { } } + TransportManager.uploadProgress.removeValue(forKey: messageId) + // Update message with real attachment tags (preview with CDN tag) MessageRepository.shared.updateAttachments(messageId: messageId, attachments: messageAttachments) @@ -2283,27 +2307,36 @@ final class SessionManager { from: resolvedAttachmentPassword! ) + // PERF: Remember which password candidate succeeded for the first attachment, + // then try it first for subsequent attachments. All candidates are still tried + // as fallback — only the ORDER changes. Avoids redundant PBKDF2 derivations. + var preferredPassword: String? + for i in processedPacket.attachments.indices where processedPacket.attachments[i].type == .messages { let blob = processedPacket.attachments[i].blob guard !blob.isEmpty else { continue } var decrypted = false - for password in passwordCandidates { + + let ordered = Self.orderedCandidates(passwordCandidates, preferred: preferredPassword) + for password in ordered { if let data = try? CryptoManager.shared.decryptWithPassword( blob, password: password, requireCompression: true ), let decryptedString = String(data: data, encoding: .utf8) { processedPacket.attachments[i].blob = decryptedString decrypted = true + if preferredPassword == nil { preferredPassword = password } break } } if !decrypted { - for password in passwordCandidates { + for password in ordered { if let data = try? CryptoManager.shared.decryptWithPassword( blob, password: password ), let decryptedString = String(data: data, encoding: .utf8) { processedPacket.attachments[i].blob = decryptedString + if preferredPassword == nil { preferredPassword = password } break } } @@ -2337,6 +2370,20 @@ final class SessionManager { ) } + /// Reorder password candidates so `preferred` is tried first. + /// All candidates are still present — only the order changes. + nonisolated private static func orderedCandidates( + _ candidates: [String], preferred: String? + ) -> [String] { + guard let preferred, let idx = candidates.firstIndex(of: preferred) else { + return candidates + } + var reordered = candidates + reordered.remove(at: idx) + reordered.insert(preferred, at: 0) + return reordered + } + /// Returns (decryptedText, rawKeyData) where rawKeyData can be used for attachment blob decryption. nonisolated private static func decryptIncomingMessage( packet: PacketMessage, diff --git a/Rosetta/Features/Chats/ChatDetail/ChatDetailViewController.swift b/Rosetta/Features/Chats/ChatDetail/ChatDetailViewController.swift index 8e7cabf..e13fdae 100644 --- a/Rosetta/Features/Chats/ChatDetail/ChatDetailViewController.swift +++ b/Rosetta/Features/Chats/ChatDetail/ChatDetailViewController.swift @@ -135,6 +135,25 @@ final class ChatDetailViewController: UIViewController { override func viewWillDisappear(_ animated: Bool) { super.viewWillDisappear(animated) + + // Cancel voice recording during navigation pop to prevent UI freeze + if let composer = messageListController?.composerView, + composer.recordingFlowState != .idle { + if let coordinator = transitionCoordinator, coordinator.isInteractive { + composer.pauseRecordingForSwipeBack() + coordinator.notifyWhenInteractionChanges { [weak composer] context in + guard let composer else { return } + if context.isCancelled { + composer.resumeRecordingAfterSwipeBack() + } else { + composer.cancelRecordingForNavigation() + } + } + } else { + composer.cancelRecordingForNavigation() + } + } + deactivateChat() } @@ -172,7 +191,7 @@ final class ChatDetailViewController: UIViewController { willShow viewController: UIViewController, animated: Bool ) { - let hide = viewController === self + let hide = viewController === self || viewController is OpponentProfileViewController navigationController.setNavigationBarHidden(hide, animated: animated) } @@ -736,19 +755,9 @@ final class ChatDetailViewController: UIViewController { hosting.navigationItem.leftBarButtonItem = UIBarButtonItem(customView: backView) navigationController?.pushViewController(hosting, animated: true) } else if !route.isSystemAccount { - navigationController?.setNavigationBarHidden(false, animated: false) - let clearAppearance = UINavigationBarAppearance() - clearAppearance.configureWithTransparentBackground() - clearAppearance.shadowColor = .clear - navigationController?.navigationBar.standardAppearance = clearAppearance - navigationController?.navigationBar.scrollEdgeAppearance = clearAppearance - 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) + navigationController?.setNavigationBarHidden(true, animated: false) + let profileVC = OpponentProfileViewController(route: route) + navigationController?.pushViewController(profileVC, animated: true) } } @@ -827,11 +836,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 @@ -1376,8 +1382,8 @@ final class ChatDetailViewController: UIViewController { private func markDialogAsRead() { guard MessageRepository.shared.isDialogReadEligible(route.publicKey) else { return } - DialogRepository.shared.markAsRead(opponentKey: route.publicKey) MessageRepository.shared.markIncomingAsRead(opponentKey: route.publicKey, myPublicKey: currentPublicKey) + DialogRepository.shared.markAsRead(opponentKey: route.publicKey) if !route.isSystemAccount { SessionManager.shared.sendReadReceipt(toPublicKey: route.publicKey) } diff --git a/Rosetta/Features/Chats/ChatDetail/ComposerView.swift b/Rosetta/Features/Chats/ChatDetail/ComposerView.swift index 47c6ebb..6edf4c7 100644 --- a/Rosetta/Features/Chats/ChatDetail/ComposerView.swift +++ b/Rosetta/Features/Chats/ChatDetail/ComposerView.swift @@ -1531,6 +1531,45 @@ extension ComposerView: RecordingMicButtonDelegate { finalizeVoiceSession(cleanup: .discardRecording, dismissStyle: .cancel) } + // MARK: - Swipe-Back Recording Handling + + /// Pauses recording when interactive swipe-back begins. + func pauseRecordingForSwipeBack() { + guard isRecording else { return } + audioRecorder.onLevelUpdate = nil + audioRecorder.pauseRecordingForPreview() + } + + /// Resumes recording when swipe-back is cancelled (user returned to chat). + func resumeRecordingAfterSwipeBack() { + guard recordingFlowState == .recordingUnlocked || recordingFlowState == .recordingLocked else { return } + audioRecorder.resumeRecording() + configureRecorderLevelUpdates() + } + + /// Called when the view controller is being popped (swipe-back completed or programmatic). + /// Immediately stops recording and cleans up UI without animations. + func cancelRecordingForNavigation() { + guard recordingFlowState != .idle else { return } + clearLastRecordedDraftFile() + recordingPreviewPanel?.stopPlayback() + recordingPreviewPanel?.removeFromSuperview() + recordingPreviewPanel = nil + recordingOverlay?.dismiss() + recordingOverlay = nil + recordingPanel?.removeFromSuperview() + recordingPanel = nil + recordingLockView?.dismiss() + recordingLockView = nil + resetVoiceSessionState(cleanup: .discardRecording) + inputContainer.alpha = 1 + attachButton.alpha = 1 + micButton.alpha = isSendVisible ? 0 : 1 + micGlass.alpha = isSendVisible ? 0 : 1 + micIconView?.alpha = isSendVisible ? 0 : 1 + delegate?.composerDidCancelRecording(self) + } + private func dismissOverlayAndRestore(skipAudioCleanup: Bool = false) { let cleanupMode: VoiceSessionCleanupMode = skipAudioCleanup ? .preserveRecordedDraft : .discardRecording if cleanupMode == .discardRecording { diff --git a/Rosetta/Features/Chats/ChatDetail/NativeMessageCell.swift b/Rosetta/Features/Chats/ChatDetail/NativeMessageCell.swift index 097dc5e..c05f6bd 100644 --- a/Rosetta/Features/Chats/ChatDetail/NativeMessageCell.swift +++ b/Rosetta/Features/Chats/ChatDetail/NativeMessageCell.swift @@ -176,12 +176,12 @@ final class NativeMessageCell: UICollectionViewCell { private let photoContainer = UIView() private var photoTileImageViews: [UIImageView] = [] private var photoTilePlaceholderViews: [UIView] = [] - private var photoTileActivityIndicators: [UIActivityIndicatorView] = [] + private var photoTileProgressRings: [VoiceDownloadRingView] = [] private var photoTileErrorViews: [UIImageView] = [] private var photoTileDownloadArrows: [UIView] = [] private var photoTileButtons: [UIButton] = [] private let photoUploadingOverlayView = UIView() - private let photoUploadingIndicator = UIActivityIndicatorView(style: .medium) + private let photoUploadingRing = VoiceDownloadRingView(frame: CGRect(x: 0, y: 0, width: 48, height: 48)) private let photoOverflowOverlayView = UIView() private let photoOverflowLabel = UILabel() @@ -189,8 +189,11 @@ final class NativeMessageCell: UICollectionViewCell { private let fileContainer = UIView() private let fileIconView = UIView() private let fileIconSymbolView = UIImageView() + private let fileProgressRing = VoiceDownloadRingView(frame: CGRect(x: 0, y: 0, width: 44, height: 44)) private let fileNameLabel = UILabel() private let fileSizeLabel = UILabel() + private var fileDownloadTask: Task? + private var fileUploadProgressObserver: NSObjectProtocol? // Call-specific private let callArrowView = UIImageView() @@ -410,11 +413,10 @@ final class NativeMessageCell: UICollectionViewCell { placeholderView.isHidden = true photoContainer.addSubview(placeholderView) - let indicator = UIActivityIndicatorView(style: .medium) - indicator.color = .white - indicator.hidesWhenStopped = true - indicator.isHidden = true - photoContainer.addSubview(indicator) + let ring = VoiceDownloadRingView(frame: CGRect(x: 0, y: 0, width: 48, height: 48)) + ring.setRingColor(.white) + ring.isHidden = true + photoContainer.addSubview(ring) let errorView = UIImageView(image: Self.errorIcon) errorView.contentMode = .center @@ -448,7 +450,7 @@ final class NativeMessageCell: UICollectionViewCell { photoTileImageViews.append(imageView) photoTilePlaceholderViews.append(placeholderView) - photoTileActivityIndicators.append(indicator) + photoTileProgressRings.append(ring) photoTileErrorViews.append(errorView) photoTileDownloadArrows.append(downloadArrow) photoTileButtons.append(button) @@ -459,10 +461,9 @@ final class NativeMessageCell: UICollectionViewCell { photoUploadingOverlayView.isUserInteractionEnabled = false photoContainer.addSubview(photoUploadingOverlayView) - photoUploadingIndicator.color = .white - photoUploadingIndicator.hidesWhenStopped = true - photoUploadingIndicator.isHidden = true - photoContainer.addSubview(photoUploadingIndicator) + photoUploadingRing.setRingColor(.white) + photoUploadingRing.isHidden = true + photoContainer.addSubview(photoUploadingRing) photoOverflowOverlayView.backgroundColor = UIColor.black.withAlphaComponent(0.45) photoOverflowOverlayView.layer.cornerCurve = .continuous @@ -484,6 +485,9 @@ final class NativeMessageCell: UICollectionViewCell { fileIconSymbolView.contentMode = .scaleAspectFit fileIconView.addSubview(fileIconSymbolView) fileContainer.addSubview(fileIconView) + fileProgressRing.setRingColor(.white) + fileProgressRing.isHidden = true + fileContainer.addSubview(fileProgressRing) fileNameLabel.font = Self.fileNameFont fileNameLabel.textColor = .label fileContainer.addSubview(fileNameLabel) @@ -1135,15 +1139,38 @@ final class NativeMessageCell: UICollectionViewCell { let isCached = AttachmentCache.shared.fileURL( forAttachmentId: fileAtt.id, fileName: parsed.fileName ) != nil - let iconName = isCached ? Self.fileIcon(for: parsed.fileName) : "arrow.down" - fileIconSymbolView.image = UIImage(systemName: iconName) + let isFileUploading = isFileOutgoing && message.deliveryStatus == .waiting + + if isFileUploading { + // Upload in progress — show ring with real progress + fileIconSymbolView.isHidden = true + fileProgressRing.show() + fileProgressRing.setRingColor(.white) + subscribeFileUploadProgress() + fileSizeLabel.text = "Uploading..." + } else if isCached { + fileIconSymbolView.image = UIImage(systemName: Self.fileIcon(for: parsed.fileName)) + fileIconSymbolView.isHidden = false + fileProgressRing.hide() + fileSizeLabel.text = Self.formattedFileSize(bytes: parsed.fileSize) + unsubscribeFileUploadProgress() + } else { + fileIconSymbolView.image = UIImage(systemName: "arrow.down") + fileIconSymbolView.isHidden = false + fileProgressRing.hide() + fileSizeLabel.text = Self.formattedFileSize(bytes: parsed.fileSize) + unsubscribeFileUploadProgress() + } fileIconSymbolView.tintColor = .white fileNameLabel.font = Self.fileNameFont fileNameLabel.text = parsed.fileName // Telegram parity: accent blue filename (incoming) or white (outgoing) fileNameLabel.textColor = isFileOutgoing ? .white : UIColor(red: 0, green: 0x8B/255.0, blue: 1.0, alpha: 1) - fileSizeLabel.text = Self.formattedFileSize(bytes: parsed.fileSize) - fileSizeLabel.textColor = isFileOutgoing ? UIColor.white.withAlphaComponent(0.5) : .secondaryLabel + if !isFileUploading { + fileSizeLabel.textColor = isFileOutgoing ? UIColor.white.withAlphaComponent(0.5) : .secondaryLabel + } else { + fileSizeLabel.textColor = isFileOutgoing ? UIColor.white.withAlphaComponent(0.5) : .secondaryLabel + } callArrowView.isHidden = true callBackButton.isHidden = true } else if let avatarAtt = message.attachments.first(where: { $0.type == .avatar }) { @@ -1570,6 +1597,7 @@ final class NativeMessageCell: UICollectionViewCell { let contentH: CGFloat = 44 // icon height dominates let topY: CGFloat = layout.isForward ? 5 : max(0, (centerableH - contentH) / 2) fileIconView.frame = CGRect(x: 9, y: topY, width: 44, height: 44) + fileProgressRing.frame = fileIconView.frame fileIconSymbolView.frame = CGRect(x: 11, y: 11, width: 22, height: 22) let textTopY = topY + 4 fileNameLabel.frame = CGRect(x: 63, y: textTopY, width: fileW - 75, height: 19) @@ -2326,12 +2354,27 @@ final class NativeMessageCell: UICollectionViewCell { } fileSizeLabel.text = "Downloading..." + fileIconSymbolView.isHidden = true + fileProgressRing.show() let messageId = message.id let attId = fileAtt.id let server = fileAtt.transportServer - Task.detached(priority: .userInitiated) { + fileProgressRing.onCancel = { [weak self] in + self?.fileDownloadTask?.cancel() + self?.fileDownloadTask = nil + self?.fileProgressRing.hide() + self?.fileIconSymbolView.image = UIImage(systemName: "arrow.down") + self?.fileIconSymbolView.isHidden = false + self?.fileSizeLabel.text = Self.formattedFileSize(bytes: parsed.fileSize) + } + fileDownloadTask = Task { [weak self] in do { - let encryptedData = try await TransportManager.shared.downloadFile(tag: tag, server: server) + let encryptedData = try await TransportManager.shared.downloadFile( + tag: tag, server: server, + onProgress: { [weak self] progress in + self?.fileProgressRing.setProgress(CGFloat(progress)) + } + ) let encryptedString = String(decoding: encryptedData, as: UTF8.self) let passwords = MessageCrypto.attachmentPasswordCandidates(from: storedPassword) @@ -2364,13 +2407,20 @@ final class NativeMessageCell: UICollectionViewCell { let url = AttachmentCache.shared.saveFile(fileData, forAttachmentId: attId, fileName: fileName) await MainActor.run { [weak self] in guard let self, self.message?.id == messageId else { return } + self.fileDownloadTask = nil + self.fileProgressRing.hide() + self.fileIconSymbolView.isHidden = false self.fileSizeLabel.text = Self.formattedFileSize(bytes: fileData.count) - self.fileIconSymbolView.image = UIImage(systemName: "doc.fill") + self.fileIconSymbolView.image = UIImage(systemName: Self.fileIcon(for: fileName)) self.shareFile(url) } } catch { await MainActor.run { [weak self] in guard let self, self.message?.id == messageId else { return } + self.fileDownloadTask = nil + self.fileProgressRing.hide() + self.fileIconSymbolView.image = UIImage(systemName: "arrow.down") + self.fileIconSymbolView.isHidden = false self.fileSizeLabel.text = "File expired" } } @@ -2665,7 +2715,7 @@ final class NativeMessageCell: UICollectionViewCell { let isActiveTile = index < photoAttachments.count let imageView = photoTileImageViews[index] let placeholderView = photoTilePlaceholderViews[index] - let indicator = photoTileActivityIndicators[index] + let ring = photoTileProgressRings[index] let errorView = photoTileErrorViews[index] let downloadArrow = photoTileDownloadArrows[index] let button = photoTileButtons[index] @@ -2676,20 +2726,31 @@ final class NativeMessageCell: UICollectionViewCell { imageView.image = nil imageView.isHidden = true placeholderView.isHidden = true - indicator.stopAnimating() - indicator.isHidden = true + ring.hide() errorView.isHidden = true downloadArrow.isHidden = true continue } let attachment = photoAttachments[index] + let isUploading = layout.isOutgoing && message.deliveryStatus == .waiting if let cached = AttachmentCache.shared.cachedImage(forAttachmentId: attachment.id) { failedAttachmentIds.remove(attachment.id) - setPhotoTileImage(cached, at: index, animated: false) + // During upload: show blurhash if available, then reveal full image on completion. + // This gives visual feedback that upload is in progress (Telegram UX parity). + if isUploading { + if let blur = Self.cachedBlurHashImage(from: attachment.preview) { + setPhotoTileImage(blur, at: index, animated: false) + } else { + // Blurhash not decoded yet — show full image, start async decode + setPhotoTileImage(cached, at: index, animated: false) + startPhotoBlurHashTask(attachment: attachment) + } + } else { + setPhotoTileImage(cached, at: index, animated: false) + } placeholderView.isHidden = true - indicator.stopAnimating() - indicator.isHidden = true + ring.hide() errorView.isHidden = true downloadArrow.isHidden = true } else { @@ -2702,19 +2763,16 @@ final class NativeMessageCell: UICollectionViewCell { placeholderView.isHidden = imageView.image != nil let hasFailed = failedAttachmentIds.contains(attachment.id) if hasFailed { - indicator.stopAnimating() - indicator.isHidden = true + ring.hide() errorView.isHidden = false downloadArrow.isHidden = true } else if downloadingAttachmentIds.contains(attachment.id) { - indicator.startAnimating() - indicator.isHidden = false + ring.show() errorView.isHidden = true downloadArrow.isHidden = true } else { // Not downloaded, not downloading — show download arrow - indicator.stopAnimating() - indicator.isHidden = true + ring.hide() errorView.isHidden = true downloadArrow.isHidden = false } @@ -2777,7 +2835,7 @@ final class NativeMessageCell: UICollectionViewCell { let isActiveTile = index < photoAttachments.count let imageView = photoTileImageViews[index] let placeholderView = photoTilePlaceholderViews[index] - let indicator = photoTileActivityIndicators[index] + let ring = photoTileProgressRings[index] let errorView = photoTileErrorViews[index] let downloadArrow = photoTileDownloadArrows[index] let button = photoTileButtons[index] @@ -2787,8 +2845,7 @@ final class NativeMessageCell: UICollectionViewCell { imageView.image = nil imageView.isHidden = true placeholderView.isHidden = true - indicator.stopAnimating() - indicator.isHidden = true + ring.hide() errorView.isHidden = true downloadArrow.isHidden = true continue @@ -2798,8 +2855,7 @@ final class NativeMessageCell: UICollectionViewCell { if let cached = AttachmentCache.shared.cachedImage(forAttachmentId: attachment.id) { setPhotoTileImage(cached, at: index, animated: false) placeholderView.isHidden = true - indicator.stopAnimating() - indicator.isHidden = true + ring.hide() errorView.isHidden = true downloadArrow.isHidden = true } else { @@ -2811,8 +2867,7 @@ final class NativeMessageCell: UICollectionViewCell { startPhotoBlurHashTask(attachment: attachment) } placeholderView.isHidden = imageView.image != nil - indicator.startAnimating() - indicator.isHidden = false + ring.show() errorView.isHidden = true downloadArrow.isHidden = true @@ -2861,8 +2916,7 @@ final class NativeMessageCell: UICollectionViewCell { self.failedAttachmentIds.insert(attachmentId) self.photoTileErrorViews[tileIndex].isHidden = false } - self.photoTileActivityIndicators[tileIndex].stopAnimating() - self.photoTileActivityIndicators[tileIndex].isHidden = true + self.photoTileProgressRings[tileIndex].hide() self.photoTileDownloadArrows[tileIndex].isHidden = true } } catch { @@ -2873,8 +2927,7 @@ final class NativeMessageCell: UICollectionViewCell { self.failedAttachmentIds.insert(attachmentId) if let tileIndex = self.tileIndex(for: attachmentId), tileIndex < self.photoTileErrorViews.count { - self.photoTileActivityIndicators[tileIndex].stopAnimating() - self.photoTileActivityIndicators[tileIndex].isHidden = true + self.photoTileProgressRings[tileIndex].hide() self.photoTileErrorViews[tileIndex].isHidden = false } } @@ -2890,7 +2943,7 @@ final class NativeMessageCell: UICollectionViewCell { photoTileImageViews[index].frame = frame photoTilePlaceholderViews[index].frame = frame photoTileButtons[index].frame = frame - photoTileActivityIndicators[index].center = CGPoint(x: frame.midX, y: frame.midY) + photoTileProgressRings[index].center = CGPoint(x: frame.midX, y: frame.midY) photoTileErrorViews[index].frame = CGRect( x: frame.midX - 10, y: frame.midY - 10, width: 20, height: 20 @@ -2903,12 +2956,12 @@ final class NativeMessageCell: UICollectionViewCell { } } photoUploadingOverlayView.frame = photoContainer.bounds - photoUploadingIndicator.center = CGPoint( + photoUploadingRing.center = CGPoint( x: photoContainer.bounds.midX, y: photoContainer.bounds.midY ) photoContainer.bringSubviewToFront(photoUploadingOverlayView) - photoContainer.bringSubviewToFront(photoUploadingIndicator) + photoContainer.bringSubviewToFront(photoUploadingRing) photoContainer.bringSubviewToFront(photoOverflowOverlayView) layoutPhotoOverflowOverlay(frames: frames) applyPhotoLastTileMask(frames: frames, layout: layout) @@ -3086,14 +3139,147 @@ final class NativeMessageCell: UICollectionViewCell { photoOverflowLabel.frame = photoOverflowOverlayView.bounds } + private var uploadProgressObserver: NSObjectProtocol? + private func updatePhotoUploadingOverlay(isVisible: Bool) { photoUploadingOverlayView.isHidden = !isVisible if isVisible { - photoUploadingIndicator.isHidden = false - photoUploadingIndicator.startAnimating() + photoUploadingRing.show() + // Cancel upload: delete the waiting message + photoUploadingRing.onCancel = { [weak self] in + guard let self, let msgId = self.message?.id else { return } + TransportManager.uploadProgress.removeValue(forKey: msgId) + MessageRepository.shared.deleteMessage(id: msgId) + if let opponentKey = self.message?.toPublicKey { + DialogRepository.shared.updateDialogFromMessages(opponentKey: opponentKey) + } + } + // Subscribe to real upload progress + if uploadProgressObserver == nil { + uploadProgressObserver = NotificationCenter.default.addObserver( + forName: TransportManager.uploadProgressNotification, + object: nil, queue: .main + ) { [weak self] notif in + guard let self, + let msgId = notif.object as? String, + msgId == self.message?.id, + let progress = TransportManager.uploadProgress[msgId] else { return } + // Cap visual progress at 85% — ring completes when deliveryStatus + // changes to .delivered and overlay hides. Gives perception of + // longer upload even on fast connections. + let visual = min(CGFloat(progress) * 0.85, 0.85) + self.photoUploadingRing.setProgress(visual) + } + } } else { - photoUploadingIndicator.stopAnimating() - photoUploadingIndicator.isHidden = true + photoUploadingRing.hide() + if let observer = uploadProgressObserver { + NotificationCenter.default.removeObserver(observer) + uploadProgressObserver = nil + } + } + } + + // MARK: - File Upload/Download Progress + + private func subscribeFileUploadProgress() { + guard fileUploadProgressObserver == nil else { return } + fileUploadProgressObserver = NotificationCenter.default.addObserver( + forName: TransportManager.uploadProgressNotification, + object: nil, queue: .main + ) { [weak self] notif in + guard let self, + let msgId = notif.object as? String, + msgId == self.message?.id, + let progress = TransportManager.uploadProgress[msgId] else { return } + let visual = min(CGFloat(progress) * 0.85, 0.85) + self.fileProgressRing.setProgress(visual) + } + fileProgressRing.onCancel = { [weak self] in + guard let self, let msgId = self.message?.id else { return } + TransportManager.uploadProgress.removeValue(forKey: msgId) + MessageRepository.shared.deleteMessage(id: msgId) + if let opponentKey = self.message?.toPublicKey { + DialogRepository.shared.updateDialogFromMessages(opponentKey: opponentKey) + } + } + } + + private func unsubscribeFileUploadProgress() { + if let observer = fileUploadProgressObserver { + NotificationCenter.default.removeObserver(observer) + fileUploadProgressObserver = nil + } + fileProgressRing.onCancel = nil + } + + func startFileDownload(attachment: MessageAttachment, password: String, fileName: String) { + guard fileDownloadTask == nil else { return } + let tag = attachment.effectiveDownloadTag + guard !tag.isEmpty else { return } + + fileIconSymbolView.isHidden = true + fileProgressRing.show() + fileSizeLabel.text = "Downloading..." + + let attachmentId = attachment.id + fileDownloadTask = Task { [weak self] in + do { + let encryptedData = try await TransportManager.shared.downloadFile( + tag: tag, server: attachment.transportServer, + onProgress: { [weak self] progress in + self?.fileProgressRing.setProgress(CGFloat(progress)) + } + ) + let encryptedString = String(decoding: encryptedData, as: UTF8.self) + let passwords = MessageCrypto.attachmentPasswordCandidates(from: password) + var fileData: Data? + for pwd in passwords { + if let d = try? CryptoManager.shared.decryptWithPassword( + encryptedString, password: pwd, requireCompression: true + ) { + // Parse data URI if present + if let str = String(data: d, encoding: .utf8), + str.hasPrefix("data:"), + let comma = str.firstIndex(of: ",") { + fileData = Data(base64Encoded: String(str[str.index(after: comma)...])) ?? d + } else { + fileData = d + } + break + } + } + guard let fileData else { throw TransportError.invalidResponse } + let _ = AttachmentCache.shared.saveFile( + fileData, forAttachmentId: attachmentId, fileName: fileName + ) + await MainActor.run { [weak self] in + guard let self else { return } + self.fileDownloadTask = nil + self.fileProgressRing.hide() + self.fileIconSymbolView.image = UIImage(systemName: Self.fileIcon(for: fileName)) + self.fileIconSymbolView.isHidden = false + self.fileSizeLabel.text = Self.formattedFileSize(bytes: fileData.count) + } + } catch { + await MainActor.run { [weak self] in + guard let self else { return } + self.fileDownloadTask = nil + self.fileProgressRing.hide() + self.fileIconSymbolView.image = UIImage(systemName: "arrow.down") + self.fileIconSymbolView.isHidden = false + self.fileSizeLabel.text = "Download failed" + } + } + } + + fileProgressRing.onCancel = { [weak self] in + self?.fileDownloadTask?.cancel() + self?.fileDownloadTask = nil + self?.fileProgressRing.hide() + self?.fileIconSymbolView.image = UIImage(systemName: "arrow.down") + self?.fileIconSymbolView.isHidden = false + self?.fileSizeLabel.text = Self.formattedFileSize(bytes: 0) } } @@ -3128,8 +3314,11 @@ final class NativeMessageCell: UICollectionViewCell { return } Self.blurHashCache.setObject(decoded, forKey: hash as NSString) - // Do not override already loaded real image. - guard self.photoTileImageViews[tileIndex].image == nil else { return } + // For uploading outgoing: replace full image with blurhash. + // For downloading incoming: only set if no image yet. + let isUploading = self.currentLayout?.isOutgoing == true + && self.message?.deliveryStatus == .waiting + guard isUploading || self.photoTileImageViews[tileIndex].image == nil else { return } self.setPhotoTileImage(decoded, at: tileIndex, animated: false) self.photoTilePlaceholderViews[tileIndex].isHidden = true } @@ -3157,8 +3346,7 @@ final class NativeMessageCell: UICollectionViewCell { self.failedAttachmentIds.remove(attachmentId) self.setPhotoTileImage(loaded, at: tileIndex, animated: true) self.photoTilePlaceholderViews[tileIndex].isHidden = true - self.photoTileActivityIndicators[tileIndex].stopAnimating() - self.photoTileActivityIndicators[tileIndex].isHidden = true + self.photoTileProgressRings[tileIndex].hide() self.photoTileErrorViews[tileIndex].isHidden = true self.photoTileDownloadArrows[tileIndex].isHidden = true } @@ -3176,8 +3364,7 @@ final class NativeMessageCell: UICollectionViewCell { !storedPassword.isEmpty else { failedAttachmentIds.insert(attachment.id) if let tileIndex = tileIndex(for: attachment.id), tileIndex < photoTileErrorViews.count { - photoTileActivityIndicators[tileIndex].stopAnimating() - photoTileActivityIndicators[tileIndex].isHidden = true + photoTileProgressRings[tileIndex].hide() photoTileErrorViews[tileIndex].isHidden = false photoTileDownloadArrows[tileIndex].isHidden = true } @@ -3187,9 +3374,8 @@ final class NativeMessageCell: UICollectionViewCell { let attachmentId = attachment.id failedAttachmentIds.remove(attachmentId) downloadingAttachmentIds.insert(attachmentId) - if let tileIndex = tileIndex(for: attachmentId), tileIndex < photoTileActivityIndicators.count { - photoTileActivityIndicators[tileIndex].startAnimating() - photoTileActivityIndicators[tileIndex].isHidden = false + if let tileIndex = tileIndex(for: attachmentId), tileIndex < photoTileProgressRings.count { + photoTileProgressRings[tileIndex].show() photoTileErrorViews[tileIndex].isHidden = true photoTileDownloadArrows[tileIndex].isHidden = true } @@ -3201,7 +3387,15 @@ final class NativeMessageCell: UICollectionViewCell { // 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 encryptedData = try await TransportManager.shared.downloadFile( + tag: tag, server: server, + onProgress: { [weak self] progress in + guard let self, + let tileIndex = self.tileIndex(for: attachmentId), + tileIndex < self.photoTileProgressRings.count else { return } + self.photoTileProgressRings[tileIndex].setProgress(CGFloat(progress)) + } + ) let encryptedString = String(decoding: encryptedData, as: UTF8.self) let passwords = MessageCrypto.attachmentPasswordCandidates(from: storedPassword) let image = Self.decryptAndParseImage(encryptedString: encryptedString, passwords: passwords) @@ -3223,8 +3417,7 @@ final class NativeMessageCell: UICollectionViewCell { self.failedAttachmentIds.insert(attachmentId) self.photoTileErrorViews[tileIndex].isHidden = false } - self.photoTileActivityIndicators[tileIndex].stopAnimating() - self.photoTileActivityIndicators[tileIndex].isHidden = true + self.photoTileProgressRings[tileIndex].hide() self.photoTileDownloadArrows[tileIndex].isHidden = true } } catch { @@ -3234,11 +3427,10 @@ final class NativeMessageCell: UICollectionViewCell { self.downloadingAttachmentIds.remove(attachmentId) self.failedAttachmentIds.insert(attachmentId) guard let tileIndex = self.tileIndex(for: attachmentId), - tileIndex < self.photoTileActivityIndicators.count else { + tileIndex < self.photoTileProgressRings.count else { return } - self.photoTileActivityIndicators[tileIndex].stopAnimating() - self.photoTileActivityIndicators[tileIndex].isHidden = true + self.photoTileProgressRings[tileIndex].hide() self.photoTileErrorViews[tileIndex].isHidden = false self.photoTileDownloadArrows[tileIndex].isHidden = true } @@ -3275,8 +3467,7 @@ final class NativeMessageCell: UICollectionViewCell { photoTileImageViews[index].layer.mask = nil photoTilePlaceholderViews[index].isHidden = true photoTilePlaceholderViews[index].layer.mask = nil - photoTileActivityIndicators[index].stopAnimating() - photoTileActivityIndicators[index].isHidden = true + photoTileProgressRings[index].hide() photoTileErrorViews[index].isHidden = true photoTileDownloadArrows[index].isHidden = true photoTileButtons[index].isHidden = true @@ -3457,6 +3648,14 @@ final class NativeMessageCell: UICollectionViewCell { override func prepareForReuse() { super.prepareForReuse() + if let observer = uploadProgressObserver { + NotificationCenter.default.removeObserver(observer) + uploadProgressObserver = nil + } + unsubscribeFileUploadProgress() + fileDownloadTask?.cancel() + fileDownloadTask = nil + fileProgressRing.hide() layer.removeAnimation(forKey: "insertionSlide") layer.removeAnimation(forKey: "insertionMove") contentView.layer.removeAnimation(forKey: "insertionAlpha") diff --git a/Rosetta/Features/Chats/ChatDetail/OpponentProfileViewController.swift b/Rosetta/Features/Chats/ChatDetail/OpponentProfileViewController.swift index 05a5204..87c92c6 100644 --- a/Rosetta/Features/Chats/ChatDetail/OpponentProfileViewController.swift +++ b/Rosetta/Features/Chats/ChatDetail/OpponentProfileViewController.swift @@ -336,14 +336,17 @@ final class OpponentProfileViewController: UIViewController, UIGestureRecognizer UIColor.clear.cgColor // 1.0 ] scrimGradient.locations = [0.0, 0.45, 0.55, 0.65, 0.75, 0.85, 0.93, 1.0] - // SwiftUI gradient goes top→bottom (black at top), but we want it bottom→top - scrimGradient.startPoint = CGPoint(x: 0.5, y: 1.0) - scrimGradient.endPoint = CGPoint(x: 0.5, y: 0.0) + // Match SwiftUI: startPoint .top → endPoint .bottom (black at top for status bar, fades down) + scrimGradient.startPoint = CGPoint(x: 0.5, y: 0.0) + scrimGradient.endPoint = CGPoint(x: 0.5, y: 1.0) scrimView.layer.addSublayer(scrimGradient) profileImage = AvatarRepository.shared.loadAvatar(publicKey: route.publicKey) canExpand = profileImage != nil - if canExpand { headerImageView.image = profileImage } + if canExpand { + headerImageView.image = profileImage + isLargeHeader = true + } expandFeedback.prepare() } @@ -445,26 +448,116 @@ final class OpponentProfileViewController: UIViewController, UIGestureRecognizer scrollView.contentSize = CGSize(width: w, height: y) } - // MARK: - Expand / Collapse + // MARK: - Expand / Collapse (Telegram-pattern: per-element CABasicAnimation) + + private let springTiming = CAMediaTimingFunction(controlPoints: 0.38, 0.70, 0.125, 1.0) + private let animDuration: CFTimeInterval = 0.35 private func expandHeader() { guard canExpand, !isLargeHeader else { return } + + // 1. Capture old frames BEFORE layout change + let oldAvatarFrame = avatarContainer.frame + let oldAvatarAlpha = avatarContainer.alpha + let oldNameFrame = nameLabel.frame + let oldSubtitleFrame = subtitleLabel.frame + let oldHeaderAlpha = headerImageView.alpha + let oldScrimAlpha = scrimView.alpha + + // 2. Set new state & compute new layout (instant — no UIView.animate) 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() - } + layoutAll() + + // 3. Animate ONLY header elements (avatar, photo, scrim, name, subtitle) + // Buttons and content below just snap to new position — NO spring bounce + animateLayer(avatarContainer.layer, property: "opacity", + from: oldAvatarAlpha, to: avatarContainer.alpha) + animateLayer(headerImageView.layer, property: "opacity", + from: oldHeaderAlpha, to: headerImageView.alpha) + animateLayer(scrimView.layer, property: "opacity", + from: oldScrimAlpha, to: scrimView.alpha) + animateFrameChange(nameLabel.layer, from: oldNameFrame, to: nameLabel.frame) + animateFrameChange(subtitleLabel.layer, from: oldSubtitleFrame, to: subtitleLabel.frame) + + // Avatar: frame + cornerRadius transition + animateFrameChange(avatarContainer.layer, from: oldAvatarFrame, to: avatarContainer.frame) + + // Header image: frame expansion + let expandedH: CGFloat = 300 + let w = view.bounds.width + let oldImgFrame = CGRect(x: (w - avatarSize) / 2, + y: view.safeAreaInsets.top + 16, + width: avatarSize, height: avatarSize) + animateFrameChange(headerImageView.layer, from: oldImgFrame, + to: CGRect(x: 0, y: 0, width: w, height: expandedH)) + animateFrameChange(scrimView.layer, from: oldImgFrame, + to: CGRect(x: 0, y: 0, width: w, height: expandedH)) } private func collapseHeader() { guard isLargeHeader else { return } + + let oldNameFrame = nameLabel.frame + let oldSubtitleFrame = subtitleLabel.frame + let oldHeaderAlpha = headerImageView.alpha + let oldScrimAlpha = scrimView.alpha + let oldAvatarAlpha = avatarContainer.alpha + isLargeHeader = false scrollView.setContentOffset(.zero, animated: false) - UIView.animate(withDuration: 0.35, delay: 0, usingSpringWithDamping: 0.86, - initialSpringVelocity: 0.5, options: []) { - self.layoutAll() + layoutAll() + + animateLayer(avatarContainer.layer, property: "opacity", + from: oldAvatarAlpha, to: avatarContainer.alpha) + animateLayer(headerImageView.layer, property: "opacity", + from: oldHeaderAlpha, to: headerImageView.alpha) + animateLayer(scrimView.layer, property: "opacity", + from: oldScrimAlpha, to: scrimView.alpha) + animateFrameChange(nameLabel.layer, from: oldNameFrame, to: nameLabel.frame) + animateFrameChange(subtitleLabel.layer, from: oldSubtitleFrame, to: subtitleLabel.frame) + animateFrameChange(avatarContainer.layer, + from: CGRect(x: 0, y: 0, width: view.bounds.width, height: 300), + to: avatarContainer.frame) + } + + // MARK: - CABasicAnimation Helpers (Telegram pattern) + + private func animateLayer(_ layer: CALayer, property: String, from: CGFloat, to: CGFloat) { + guard from != to else { return } + let anim = CABasicAnimation(keyPath: property) + anim.fromValue = from + anim.toValue = to + anim.duration = animDuration + anim.timingFunction = springTiming + anim.isRemovedOnCompletion = true + layer.add(anim, forKey: property) + } + + private func animateFrameChange(_ layer: CALayer, from: CGRect, to: CGRect) { + guard from != to else { return } + // Position animation + let oldCenter = CGPoint(x: from.midX, y: from.midY) + let newCenter = CGPoint(x: to.midX, y: to.midY) + if oldCenter != newCenter { + let posAnim = CABasicAnimation(keyPath: "position") + posAnim.fromValue = oldCenter + posAnim.toValue = newCenter + posAnim.duration = animDuration + posAnim.timingFunction = springTiming + posAnim.isRemovedOnCompletion = true + layer.add(posAnim, forKey: "position") + } + // Bounds animation + if from.size != to.size { + let boundsAnim = CABasicAnimation(keyPath: "bounds.size") + boundsAnim.fromValue = from.size + boundsAnim.toValue = to.size + boundsAnim.duration = animDuration + boundsAnim.timingFunction = springTiming + boundsAnim.isRemovedOnCompletion = true + layer.add(boundsAnim, forKey: "bounds.size") } } @@ -472,7 +565,6 @@ final class OpponentProfileViewController: UIViewController, UIGestureRecognizer func scrollViewDidScroll(_ scrollView: UIScrollView) { let offsetY = scrollView.contentOffset.y - // SwiftUI original: expand at v < -10, collapse at v >= 0 if offsetY <= -10 && scrollView.isDragging && scrollView.isTracking && canExpand && !isLargeHeader { expandHeader() diff --git a/Rosetta/Features/Groups/GroupInfoView.swift b/Rosetta/Features/Groups/GroupInfoView.swift index 9e64ffb..e6d2482 100644 --- a/Rosetta/Features/Groups/GroupInfoView.swift +++ b/Rosetta/Features/Groups/GroupInfoView.swift @@ -91,11 +91,9 @@ struct GroupInfoView: View { .onChange(of: showMemberChat) { show in guard show, let route = selectedMemberRoute else { return } showMemberChat = false - var profile = OpponentProfileView(route: route) - profile.showMessageButton = true // show "Message" button (group member context) - let vc = UIHostingController(rootView: profile) - vc.navigationItem.hidesBackButton = true - navController?.pushViewController(vc, animated: true) + let profileVC = OpponentProfileViewController(route: route, showMessageButton: true) + profileVC.groupContext = (dialogKey: viewModel.groupDialogKey, isAdmin: viewModel.isAdmin) + navController?.pushViewController(profileVC, animated: true) } .onChange(of: showEncryptionKeyPage) { show in guard show else { return } @@ -341,7 +339,6 @@ private extension GroupInfoView { let canKick = viewModel.isAdmin && member.id != myKey && !member.isAdmin Button { - // Tap on member → push their personal chat (skip self) guard member.id != myKey else { return } selectedMemberRoute = ChatRoute( publicKey: member.id, @@ -356,14 +353,38 @@ private extension GroupInfoView { } .buttonStyle(.plain) .contextMenu { + if member.id != myKey { + Button { + let route = ChatRoute( + publicKey: member.id, + title: member.title, + username: member.username, + verified: member.verified + ) + let chatVC = ChatDetailViewController(route: route) + navController?.pushViewController(chatVC, animated: true) + } label: { + Label("Send Message", systemImage: "bubble.left.fill") + } + } + if canKick { Button(role: .destructive) { memberToKick = member } label: { - Label("Remove", systemImage: "person.badge.minus") + Label("Remove Member", systemImage: "person.badge.minus") } } } + .simultaneousGesture( + DragGesture(minimumDistance: 50) + .onEnded { value in + guard canKick else { return } + if value.translation.width < -50 && abs(value.translation.height) < 30 { + memberToKick = member + } + } + ) } // MARK: Tab Bar (Telegram parity — horizontal capsule tabs with liquid selection) diff --git a/Rosetta/Features/Groups/GroupInfoViewController.swift b/Rosetta/Features/Groups/GroupInfoViewController.swift index 9e065ba..8748dab 100644 --- a/Rosetta/Features/Groups/GroupInfoViewController.swift +++ b/Rosetta/Features/Groups/GroupInfoViewController.swift @@ -8,7 +8,7 @@ import Combine /// Expandable header, description, encryption key card, 5 tabs (Members/Media/Files/Voice/Links). final class GroupInfoViewController: UIViewController, UIScrollViewDelegate, PeerProfileHeaderDelegate, ProfileTabBarDelegate, UIGestureRecognizerDelegate, - UITableViewDataSource, UITableViewDelegate { + UIContextMenuInteractionDelegate { // MARK: - Properties @@ -25,21 +25,6 @@ final class GroupInfoViewController: UIViewController, UIScrollViewDelegate, private let encryptionKeyCard = UIControl() private let tabBar: ProfileTabBarView private let membersCard = UIView() - private lazy var membersTableView: UITableView = { - let tv = UITableView(frame: .zero, style: .plain) - tv.register(GroupMemberTableCell.self, forCellReuseIdentifier: GroupMemberTableCell.reuseId) - tv.dataSource = self - tv.delegate = self - tv.isScrollEnabled = false - tv.separatorInset = UIEdgeInsets(top: 0, left: 65, bottom: 0, right: 0) - tv.separatorColor = UIColor { $0.userInterfaceStyle == .dark - ? UIColor(red: 0x54/255, green: 0x54/255, blue: 0x58/255, alpha: 0.55) - : UIColor(red: 0x3C/255, green: 0x3C/255, blue: 0x43/255, alpha: 0.36) - } - tv.backgroundColor = .clear - tv.rowHeight = 60 - return tv - }() private var mediaCollectionView: UICollectionView! private let filesContainer = UIView() private let linksContainer = UIView() @@ -174,9 +159,7 @@ final class GroupInfoViewController: UIViewController, UIScrollViewDelegate, membersCard.backgroundColor = sectionFill membersCard.layer.cornerRadius = 11 membersCard.layer.cornerCurve = .continuous - membersCard.clipsToBounds = true membersCard.isHidden = true - membersCard.addSubview(membersTableView) contentView.addSubview(membersCard) } @@ -455,26 +438,58 @@ final class GroupInfoViewController: UIViewController, UIScrollViewDelegate, // MARK: - Members Layout private func layoutMembersList(width: CGFloat) -> CGFloat { + membersCard.subviews.forEach { $0.removeFromSuperview() } + if viewModel.isLoading && viewModel.members.isEmpty { - membersTableView.isHidden = true - // Show spinner - let spinnerTag = 9999 - if membersCard.viewWithTag(spinnerTag) == nil { - let spinner = UIActivityIndicatorView(style: .medium) - spinner.tag = spinnerTag - spinner.color = textColor - spinner.startAnimating() - membersCard.addSubview(spinner) - } - membersCard.viewWithTag(spinnerTag)?.center = CGPoint(x: width / 2, y: 30) + let spinner = UIActivityIndicatorView(style: .medium) + spinner.color = textColor + spinner.startAnimating() + spinner.center = CGPoint(x: width / 2, y: 30) + membersCard.addSubview(spinner) return 60 } - membersCard.viewWithTag(9999)?.removeFromSuperview() - membersTableView.isHidden = false - let tableH = CGFloat(viewModel.members.count) * 60 - membersTableView.frame = CGRect(x: 0, y: 0, width: width, height: tableH) - return tableH + var currentY: CGFloat = 0 + let rowH: CGFloat = 60 + let myKey = SessionManager.shared.currentPublicKey + + for (index, member) in viewModel.members.enumerated() { + let cell = GroupMemberCell() + cell.configure(member: member) + + let control = UIControl() + control.frame = CGRect(x: 0, y: currentY, width: width, height: rowH) + control.tag = index + control.addTarget(self, action: #selector(memberTapped(_:)), for: .touchUpInside) + control.addSubview(cell) + cell.frame = CGRect(x: 16, y: 0, width: width - 32, height: rowH) + + // Context menu (long-press): "Send Message" + "Remove Member" + if member.id != myKey { + let interaction = UIContextMenuInteraction(delegate: self) + control.addInteraction(interaction) + } + + // Swipe-to-delete (left swipe → kick alert) + let canKick = viewModel.isAdmin && member.id != myKey && !member.isAdmin + if canKick { + let swipe = UISwipeGestureRecognizer(target: self, action: #selector(memberSwiped(_:))) + swipe.direction = .left + control.addGestureRecognizer(swipe) + } + + membersCard.addSubview(control) + currentY += rowH + + if index < viewModel.members.count - 1 { + let sep = UIView() + sep.backgroundColor = separatorColor + sep.frame = CGRect(x: 65, y: currentY, width: width - 65, height: 1 / UIScreen.main.scale) + membersCard.addSubview(sep) + } + } + + return currentY } private func layoutAdminHint(at y: CGFloat, width: CGFloat) { @@ -714,10 +729,7 @@ final class GroupInfoViewController: UIViewController, UIScrollViewDelegate, private func bindViewModel() { viewModel.$members .receive(on: DispatchQueue.main) - .sink { [weak self] _ in - self?.membersTableView.reloadData() - self?.view.setNeedsLayout() - } + .sink { [weak self] _ in self?.view.setNeedsLayout() } .store(in: &cancellables) viewModel.$mediaItems @@ -894,7 +906,10 @@ final class GroupInfoViewController: UIViewController, UIScrollViewDelegate, navigationController?.pushViewController(vc, animated: true) } - private func openMemberProfile(_ member: GroupMember) { + @objc private func memberTapped(_ sender: UIControl) { + let index = sender.tag + guard index < viewModel.members.count else { return } + let member = viewModel.members[index] let myKey = SessionManager.shared.currentPublicKey guard member.id != myKey else { return } @@ -909,6 +924,12 @@ final class GroupInfoViewController: UIViewController, UIScrollViewDelegate, navigationController?.pushViewController(profileVC, animated: true) } + @objc private func memberSwiped(_ gesture: UISwipeGestureRecognizer) { + guard let control = gesture.view as? UIControl, + control.tag < viewModel.members.count else { return } + showKickAlert(member: viewModel.members[control.tag]) + } + // MARK: - ProfileTabBarDelegate func tabBar(_ tabBar: ProfileTabBarView, didSelectTabAt index: Int) { @@ -972,58 +993,21 @@ extension GroupInfoViewController: UICollectionViewDataSource, UICollectionViewD } } -// MARK: - UITableViewDataSource + Delegate (Members) +// MARK: - UIContextMenuInteractionDelegate extension GroupInfoViewController { - private func canKickMember(at indexPath: IndexPath) -> Bool { - guard viewModel.isAdmin, indexPath.row < viewModel.members.count else { return false } - let member = viewModel.members[indexPath.row] - return member.id != SessionManager.shared.currentPublicKey && !member.isAdmin - } - - func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { - viewModel.members.count - } - - func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { - let cell = tableView.dequeueReusableCell(withIdentifier: GroupMemberTableCell.reuseId, for: indexPath) as! GroupMemberTableCell - let member = viewModel.members[indexPath.row] - cell.configure(member: member) - return cell - } - - func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { - tableView.deselectRow(at: indexPath, animated: true) - guard indexPath.row < viewModel.members.count else { return } - openMemberProfile(viewModel.members[indexPath.row]) - } - - func tableView(_ tableView: UITableView, trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? { - guard canKickMember(at: indexPath) else { return nil } - let member = viewModel.members[indexPath.row] - - let remove = UIContextualAction(style: .destructive, title: "Remove") { [weak self] _, _, completion in - self?.showKickAlert(member: member) - completion(false) - } - remove.image = UIImage(systemName: "person.badge.minus") - let config = UISwipeActionsConfiguration(actions: [remove]) - config.performsFirstActionWithFullSwipe = false - return config - } - - // MARK: - Long-press context menu - - func tableView(_ tableView: UITableView, contextMenuConfigurationForRowAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration? { - guard indexPath.row < viewModel.members.count else { return nil } - let member = viewModel.members[indexPath.row] + func contextMenuInteraction(_ interaction: UIContextMenuInteraction, configurationForMenuAtLocation location: CGPoint) -> UIContextMenuConfiguration? { + guard let control = interaction.view as? UIControl else { return nil } + let index = control.tag + guard index < viewModel.members.count else { return nil } + let member = viewModel.members[index] let myKey = SessionManager.shared.currentPublicKey - guard member.id != myKey else { return nil } return UIContextMenuConfiguration(identifier: nil, previewProvider: nil) { [weak self] _ in guard let self else { return UIMenu(children: []) } + // "Send Message" — opens direct chat let sendMessage = UIAction( title: "Send Message", image: UIImage(systemName: "bubble.left.fill") @@ -1040,7 +1024,9 @@ extension GroupInfoViewController { var actions: [UIMenuElement] = [sendMessage] - if self.canKickMember(at: indexPath) { + // "Remove Member" — admin only, not self, not other admins + let canKick = self.viewModel.isAdmin && member.id != myKey && !member.isAdmin + if canKick { let remove = UIAction( title: "Remove Member", image: UIImage(systemName: "person.badge.minus"), @@ -1056,32 +1042,6 @@ extension GroupInfoViewController { } } -// MARK: - GroupMemberTableCell - -private final class GroupMemberTableCell: UITableViewCell { - static let reuseId = "GroupMemberTableCell" - private let memberView = GroupMemberCell() - - override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { - super.init(style: style, reuseIdentifier: reuseIdentifier) - backgroundColor = .clear - contentView.backgroundColor = .clear - selectionStyle = .default - contentView.addSubview(memberView) - } - - @available(*, unavailable) required init?(coder: NSCoder) { fatalError() } - - override func layoutSubviews() { - super.layoutSubviews() - memberView.frame = CGRect(x: 16, y: 0, width: contentView.bounds.width - 32, height: contentView.bounds.height) - } - - func configure(member: GroupMember) { - memberView.configure(member: member) - } -} - // MARK: - Media Tile Cell private final class GroupMediaTileCell: UICollectionViewCell { diff --git a/RosettaTests/DeliveryReliabilityTests.swift b/RosettaTests/DeliveryReliabilityTests.swift index 124baf4..187d4a2 100644 --- a/RosettaTests/DeliveryReliabilityTests.swift +++ b/RosettaTests/DeliveryReliabilityTests.swift @@ -764,9 +764,11 @@ final class DeliveryReliabilityTests: XCTestCase { XCTAssertTrue(MessageCellLayout.isGarbageOrEncrypted("CHNK:chunk1::chunk2::chunk3"), "Must detect chunked format") - // Long hex string (≥40 chars) - XCTAssertTrue(MessageCellLayout.isGarbageOrEncrypted(String(repeating: "a1b2c3d4", count: 6)), - "Must detect long hex strings") + // Long hex string — must NOT be flagged (public keys, tx hashes are valid user content) + XCTAssertFalse(MessageCellLayout.isGarbageOrEncrypted(String(repeating: "a1b2c3d4", count: 6)), + "Long hex strings (public keys, hashes) must not be flagged") + XCTAssertFalse(MessageCellLayout.isGarbageOrEncrypted("02abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890ab"), + "secp256k1 public key must not be flagged") // U+FFFD only (failed decryption) XCTAssertTrue(MessageCellLayout.isGarbageOrEncrypted("\u{FFFD}\u{FFFD}\u{FFFD}"),