diff --git a/Info.plist b/Info.plist index 896913a..5d790e9 100644 --- a/Info.plist +++ b/Info.plist @@ -10,6 +10,8 @@ Rosetta needs access to your photo library to send images in chats. NSCameraUsageDescription Rosetta needs access to your camera to take and send photos in chats. + NSMicrophoneUsageDescription + Rosetta needs access to your microphone for secure voice calls and audio messages. UIBackgroundModes remote-notification diff --git a/Rosetta/Assets.xcassets/ChatWallpaper.imageset/Contents.json b/Rosetta/Assets.xcassets/ChatWallpaper.imageset/Contents.json new file mode 100644 index 0000000..1f5febd --- /dev/null +++ b/Rosetta/Assets.xcassets/ChatWallpaper.imageset/Contents.json @@ -0,0 +1,13 @@ +{ + "images" : [ + { + "filename" : "back_5.png", + "idiom" : "universal", + "scale" : "1x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Rosetta/Assets.xcassets/ChatWallpaper.imageset/back_5.png b/Rosetta/Assets.xcassets/ChatWallpaper.imageset/back_5.png new file mode 100644 index 0000000..3ec7b78 Binary files /dev/null and b/Rosetta/Assets.xcassets/ChatWallpaper.imageset/back_5.png differ diff --git a/Rosetta/Core/Layout/MessageCellLayout.swift b/Rosetta/Core/Layout/MessageCellLayout.swift index 6ae46b7..1e1cac4 100644 --- a/Rosetta/Core/Layout/MessageCellLayout.swift +++ b/Rosetta/Core/Layout/MessageCellLayout.swift @@ -202,14 +202,15 @@ extension MessageCellLayout { let textMeasurement: TextMeasurement var cachedTextLayout: CoreTextTextLayout? - if !config.text.isEmpty && isTextMessage { + let needsDetailedTextLayout = isTextMessage || messageType == .photoWithCaption + if !config.text.isEmpty && needsDetailedTextLayout { // CoreText (CTTypesetter) — returns per-line widths including lastLineWidth. // Also captures CoreTextTextLayout for cell rendering (avoids double computation). let (measurement, layout) = measureTextDetailedWithLayout(config.text, maxWidth: max(maxTextWidth, 50), font: font) textMeasurement = measurement cachedTextLayout = layout } else if !config.text.isEmpty { - // Captions, forwards, files + // Forwards, files (no CoreTextLabel rendering needed) let size = measureText(config.text, maxWidth: max(maxTextWidth, 50), font: font) textMeasurement = TextMeasurement(size: size, trailingLineWidth: size.width) } else { @@ -220,7 +221,6 @@ extension MessageCellLayout { let timestampText = config.timestampText.isEmpty ? "00:00" : config.timestampText let tsSize = measureText(timestampText, maxWidth: 60, font: tsFont) let hasStatusIcon = config.isOutgoing && !isOutgoingFailed - let isMediaMessage = config.imageCount > 0 let statusWidth: CGFloat = hasStatusIcon ? textStatusLaneMetrics.statusWidth : 0 @@ -282,7 +282,9 @@ extension MessageCellLayout { let replyH: CGFloat = config.hasReplyQuote ? 46 : 0 var photoH: CGFloat = 0 let forwardHeaderH: CGFloat = config.isForward ? 40 : 0 - let fileH: CGFloat = CGFloat(config.fileCount + config.callCount) * 56 + let fileH: CGFloat = CGFloat(config.fileCount) * 56 + + CGFloat(config.callCount) * 60 + + CGFloat(config.avatarCount) * 72 // Tiny floor just to prevent zero-width collapse. // Telegram does NOT force a large minW — short messages get tight bubbles. @@ -330,6 +332,11 @@ extension MessageCellLayout { minHeight: mediaDimensions.minHeight ) } + // Photo+caption: ensure bubble is wide enough for text + if !config.text.isEmpty { + let textNeedW = leftPad + textMeasurement.size.width + rightPad + bubbleW = max(bubbleW, min(textNeedW, effectiveMaxBubbleWidth)) + } // Telegram: 2pt inset on all 4 sides → bubble is photoH + 4pt taller bubbleH += photoH + photoInset * 2 if !config.text.isEmpty { @@ -363,9 +370,18 @@ extension MessageCellLayout { let finalContentW = max(textMeasurement.size.width, metadataWidth) bubbleW = leftPad + finalContentW + rightPad bubbleW = max(minW, min(bubbleW, effectiveMaxBubbleWidth)) + // File/call/avatar with text: ensure min width for icon+text layout + if fileH > 0 { bubbleW = max(bubbleW, min(240, effectiveMaxBubbleWidth)) } bubbleH += topPad + textMeasurement.size.height + bottomPad + } else if fileH > 0 { + // File / Call / Avatar — needs wide bubble for icon + text + metadata + // Telegram file min: ~240pt (icon 44 + spacing + filename + insets) + bubbleW = min(240, effectiveMaxBubbleWidth) + bubbleW = max(bubbleW, leftPad + metadataWidth + rightPad) + // Add space for timestamp row below file content + bubbleH += bottomPad + tsSize.height + 2 } else { - // No text (forward header only, empty) + // No text, no file (forward header only, empty) bubbleW = leftPad + metadataWidth + rightPad bubbleW = max(minW, min(bubbleW, effectiveMaxBubbleWidth)) } @@ -389,21 +405,23 @@ extension MessageCellLayout { // tsFrame.maxX = checkFrame.minX - timeToCheckGap // checkFrame.minX = bubbleW - inset - checkW let metadataRightInset: CGFloat - if isMediaMessage { - // Telegram: statusInsets are 6pt from MEDIA edge, not bubble edge. - // Photo has 2pt inset from bubble → 6 + 2 = 8pt from bubble edge. - metadataRightInset = 8 - } else if isTextMessage { - // Outgoing: 5pt (checkmarks fill the gap to rightPad) - // Incoming: rightPad (11pt, same as text — no checkmarks to fill the gap) + if messageType == .photo { + // Telegram: statusInsets = (top:0, left:0, bottom:6, right:6) from PHOTO edges. + // Pill right = statusEndX + mediaStatusInsets.right(7) = bubbleW - X + 7 + // Photo right = bubbleW - 2. Gap = 6pt → pill right = bubbleW - 8. + // → statusEndX = bubbleW - 15 → metadataRightInset = 15. + metadataRightInset = 15 + } else if isTextMessage || messageType == .photoWithCaption { metadataRightInset = config.isOutgoing ? textStatusLaneMetrics.textStatusRightInset : rightPad } else { metadataRightInset = rightPad } - // Telegram: statusInsets bottom 6pt from MEDIA edge → 6 + 2 = 8pt from bubble edge - let metadataBottomInset: CGFloat = isMediaMessage ? 8 : bottomPad + // Telegram: pill bottom = statusEndY + mediaStatusInsets.bottom(2). + // Photo bottom = bubbleH - 2. Gap = 6pt → pill bottom = bubbleH - 8. + // → statusEndY = bubbleH - 10 → metadataBottomInset = 10. + let metadataBottomInset: CGFloat = (messageType == .photo) ? 10 : bottomPad let statusEndX = bubbleW - metadataRightInset let statusEndY = bubbleH - metadataBottomInset let statusVerticalOffset: CGFloat = isTextMessage diff --git a/Rosetta/Core/Network/Protocol/WebSocketClient.swift b/Rosetta/Core/Network/Protocol/WebSocketClient.swift index b299588..dbdfd09 100644 --- a/Rosetta/Core/Network/Protocol/WebSocketClient.swift +++ b/Rosetta/Core/Network/Protocol/WebSocketClient.swift @@ -38,8 +38,8 @@ final class WebSocketClient: NSObject, @unchecked Sendable, URLSessionWebSocketD // Don't wait for connectivity — fail fast so NWPathMonitor can trigger // instant reconnect when network becomes available. config.waitsForConnectivity = false - config.timeoutIntervalForRequest = 10 - config.timeoutIntervalForResource = 15 + // Keep default request/resource timeouts for long-lived WebSocket tasks. + // Connection establishment timeout is enforced separately by connectTimeoutTask. session = URLSession(configuration: config, delegate: self, delegateQueue: nil) startNetworkMonitor() } diff --git a/Rosetta/Core/Services/CallManager+Runtime.swift b/Rosetta/Core/Services/CallManager+Runtime.swift index f8c9f83..827b589 100644 --- a/Rosetta/Core/Services/CallManager+Runtime.swift +++ b/Rosetta/Core/Services/CallManager+Runtime.swift @@ -1,6 +1,7 @@ import AVFAudio import CryptoKit import Foundation +import UIKit import WebRTC @MainActor @@ -81,6 +82,14 @@ extension CallManager { } func finishCall(reason: String?, notifyPeer: Bool) { + cancelRingTimeout() + let wasActive = uiState.phase == .active + if wasActive { + CallSoundManager.shared.playEndCall() + } else { + CallSoundManager.shared.stopAll() + } + let snapshot = uiState if notifyPeer, ownPublicKey.isEmpty == false, @@ -230,9 +239,11 @@ extension CallManager { ) try session.setActive(true) applyAudioOutputRouting() + UIDevice.current.isProximityMonitoringEnabled = true } func deactivateAudioSession() { + UIDevice.current.isProximityMonitoringEnabled = false try? AVAudioSession.sharedInstance().setActive(false, options: [.notifyOthersOnDeactivation]) } @@ -386,7 +397,8 @@ extension CallManager: RTCPeerConnectionDelegate { nonisolated func peerConnection(_ peerConnection: RTCPeerConnection, didAdd stream: RTCMediaStream) { Task { @MainActor in for audioTrack in stream.audioTracks { - audioTrack.isEnabled = !self.uiState.isSpeakerOn + // Speaker routing must not mute the remote track. + audioTrack.isEnabled = true } } } @@ -419,6 +431,7 @@ extension CallManager: RTCPeerConnectionDelegate { nonisolated func peerConnection(_ peerConnection: RTCPeerConnection, didStartReceivingOn transceiver: RTCRtpTransceiver) { guard let receiver = transceiver.receiver as RTCRtpReceiver? else { return } Task { @MainActor in + receiver.track?.isEnabled = true self.attachReceiverCryptor(receiver) } } diff --git a/Rosetta/Core/Services/CallManager.swift b/Rosetta/Core/Services/CallManager.swift index 92cbfe0..cd20baf 100644 --- a/Rosetta/Core/Services/CallManager.swift +++ b/Rosetta/Core/Services/CallManager.swift @@ -36,6 +36,7 @@ final class CallManager: NSObject, ObservableObject { var attachedReceiverIds: Set = [] var durationTask: Task? + var ringTimeoutTask: Task? private override init() { super.init() @@ -79,6 +80,8 @@ final class CallManager: NSObject, ObservableObject { src: ownPublicKey, dst: target ) + CallSoundManager.shared.playCalling() + startRingTimeout() return .started } @@ -87,6 +90,8 @@ final class CallManager: NSObject, ObservableObject { guard ownPublicKey.isEmpty == false else { return .accountNotBound } guard uiState.peerPublicKey.isEmpty == false else { return .invalidTarget } + cancelRingTimeout() + CallSoundManager.shared.stopAll() role = .callee ensureLocalSessionKeys() guard localPublicKeyHex.isEmpty == false else { return .invalidTarget } @@ -189,6 +194,8 @@ final class CallManager: NSObject, ObservableObject { uiState.phase = .incoming uiState.statusText = "Incoming call..." hydratePeerIdentity(for: incomingPeer) + CallSoundManager.shared.playRingtone() + startRingTimeout() case .keyExchange: handleKeyExchange(packet) case .createRoom: @@ -229,6 +236,9 @@ final class CallManager: NSObject, ObservableObject { uiState.keyCast = derivedSharedKey.hexString applySenderCryptorIfPossible() + cancelRingTimeout() + CallSoundManager.shared.stopAll() + switch role { case .caller: ProtocolManager.shared.sendCallSignal( @@ -276,9 +286,33 @@ final class CallManager: NSObject, ObservableObject { guard uiState.phase != .active else { return } uiState.phase = .active uiState.statusText = "Call active" + cancelRingTimeout() + CallSoundManager.shared.playConnected() startDurationTimerIfNeeded() } + func startRingTimeout() { + cancelRingTimeout() + let isIncoming = uiState.phase == .incoming + let timeout: Duration = isIncoming ? .seconds(45) : .seconds(60) + ringTimeoutTask = Task { [weak self] in + try? await Task.sleep(for: timeout) + guard !Task.isCancelled else { return } + guard let self else { return } + // Verify phase hasn't changed during sleep + if isIncoming, self.uiState.phase == .incoming { + self.finishCall(reason: "No answer", notifyPeer: true) + } else if !isIncoming, self.uiState.phase == .outgoing { + self.endCall() + } + } + } + + func cancelRingTimeout() { + ringTimeoutTask?.cancel() + ringTimeoutTask = nil + } + func attachReceiverCryptor(_ receiver: RTCRtpReceiver) { guard let sharedKey, sharedKey.count >= CallMediaCrypto.keyLength else { return } guard attachedReceiverIds.contains(receiver.receiverId) == false else { return } diff --git a/Rosetta/Core/Services/CallSoundManager.swift b/Rosetta/Core/Services/CallSoundManager.swift new file mode 100644 index 0000000..3d1efa9 --- /dev/null +++ b/Rosetta/Core/Services/CallSoundManager.swift @@ -0,0 +1,121 @@ +import AudioToolbox +import AVFAudio +import Foundation + +@MainActor +final class CallSoundManager { + + static let shared = CallSoundManager() + + private var loopingPlayer: AVAudioPlayer? + private var oneShotPlayer: AVAudioPlayer? + private var vibrationTask: Task? + + private init() {} + + // MARK: - Public API + + func playRingtone() { + stopAll() + configureForRingtone() + playLoop("ringtone") + startVibration() + } + + func playCalling() { + stopAll() + configureForRingtone() + playLoop("calling") + } + + func playConnected() { + stopLooping() + stopVibration() + playOneShot("connected") + } + + func playEndCall() { + stopLooping() + stopVibration() + playOneShot("end_call") + } + + func stopAll() { + stopLooping() + stopVibration() + oneShotPlayer?.stop() + oneShotPlayer = nil + } + + // MARK: - Audio Session + + private func configureForRingtone() { + let session = AVAudioSession.sharedInstance() + // Use .playback so ringtone/calling sounds play through speaker + // even if phone is on silent mode. WebRTC will reconfigure later. + try? session.setCategory(.playback, mode: .default, options: []) + try? session.setActive(true) + } + + // MARK: - Playback + + private func playLoop(_ name: String) { + guard let url = Bundle.main.url(forResource: name, withExtension: "mp3") else { + print("[CallSound] Sound file not found: \(name).mp3") + return + } + do { + let player = try AVAudioPlayer(contentsOf: url) + player.numberOfLoops = -1 + player.volume = 1.0 + player.prepareToPlay() + player.play() + loopingPlayer = player + } catch { + print("[CallSound] Failed to play \(name): \(error)") + } + } + + private func playOneShot(_ name: String) { + guard let url = Bundle.main.url(forResource: name, withExtension: "mp3") else { + print("[CallSound] Sound file not found: \(name).mp3") + return + } + do { + let player = try AVAudioPlayer(contentsOf: url) + player.numberOfLoops = 0 + player.volume = 1.0 + player.prepareToPlay() + player.play() + oneShotPlayer = player + } catch { + print("[CallSound] Failed to play \(name): \(error)") + } + } + + private func stopLooping() { + loopingPlayer?.stop() + loopingPlayer = nil + } + + // MARK: - Vibration + + private func startVibration() { + stopVibration() + vibrationTask = Task { + while !Task.isCancelled { + AudioServicesPlaySystemSound(kSystemSoundID_Vibrate) + // Short sleep for responsive cancellation + for _ in 0..<9 { + guard !Task.isCancelled else { return } + try? await Task.sleep(for: .milliseconds(200)) + } + } + } + } + + private func stopVibration() { + vibrationTask?.cancel() + vibrationTask = nil + } +} diff --git a/Rosetta/Core/Services/SessionManager.swift b/Rosetta/Core/Services/SessionManager.swift index 74137fb..eda8e00 100644 --- a/Rosetta/Core/Services/SessionManager.swift +++ b/Rosetta/Core/Services/SessionManager.swift @@ -536,20 +536,11 @@ final class SessionManager { let previewSuffix: String switch attachment.type { case .image: - let blurhash = attachment.thumbnail?.blurHash(numberOfComponents: (4, 3)) ?? "" - // Encode image dimensions for Telegram-style aspect-ratio sizing. - // Format: "blurhash::WxH" — backward compatible (old parsers ignore suffix). - if let img = UIImage(data: attachment.data) { - // Encode pixel dimensions after blurhash using "|" separator. - // "|" is NOT in blurhash Base83 alphabet and NOT in "::" protocol separator, - // so desktop's getPreview() passes "blurhash|WxH" to blurhashToBase64Image() - // which silently ignores the suffix (blurhash decoder stops at unknown chars). - let w = Int(img.size.width * img.scale) - let h = Int(img.size.height * img.scale) - previewSuffix = "\(blurhash)|\(w)x\(h)" - } else { - previewSuffix = blurhash - } + // Plain blurhash only — NO dimension suffix. + // Desktop's blurhash decoder (woltapp/blurhash) does strict length validation; + // appending "|WxH" causes it to throw → no preview on desktop. + // Android also sends plain blurhash without dimensions. + previewSuffix = attachment.thumbnail?.blurHash(numberOfComponents: (4, 3)) ?? "" case .file: previewSuffix = "\(attachment.fileSize ?? 0)::\(attachment.fileName ?? "file")" default: diff --git a/Rosetta/Features/Calls/ActiveCallOverlayView.swift b/Rosetta/Features/Calls/ActiveCallOverlayView.swift index feb634b..addf5ae 100644 --- a/Rosetta/Features/Calls/ActiveCallOverlayView.swift +++ b/Rosetta/Features/Calls/ActiveCallOverlayView.swift @@ -14,17 +14,28 @@ struct ActiveCallOverlayView: View { return String(format: "%02d:%02d", minutes, seconds) } + private var peerInitials: String { + let name = state.peerTitle.isEmpty ? state.peerUsername : state.peerTitle + guard !name.isEmpty else { return "?" } + let parts = name.split(separator: " ") + if parts.count >= 2 { + return "\(parts[0].prefix(1))\(parts[1].prefix(1))".uppercased() + } + return String(name.prefix(2)).uppercased() + } + + private var peerColorIndex: Int { + guard !state.peerPublicKey.isEmpty else { return 0 } + return abs(state.peerPublicKey.hashValue) % 7 + } + var body: some View { ZStack { Color.black.opacity(0.7) .ignoresSafeArea() VStack(spacing: 20) { - Image(systemName: "phone.fill") - .font(.system(size: 30, weight: .semibold)) - .foregroundStyle(.white) - .padding(20) - .background(Circle().fill(Color.white.opacity(0.14))) + avatarSection Text(state.displayName) .font(.system(size: 22, weight: .semibold)) @@ -59,6 +70,22 @@ struct ActiveCallOverlayView: View { .transition(.opacity.combined(with: .scale(scale: 0.95))) } + @ViewBuilder + private var avatarSection: some View { + ZStack { + if state.phase != .active { + PulsingRings() + } + + AvatarView( + initials: peerInitials, + colorIndex: peerColorIndex, + size: 90 + ) + } + .frame(width: 130, height: 130) + } + @ViewBuilder private var controls: some View { if state.phase == .incoming { @@ -139,3 +166,23 @@ struct ActiveCallOverlayView: View { } } } + +private struct PulsingRings: View { + @State private var animate = false + + var body: some View { + ZStack { + ForEach(0..<3, id: \.self) { index in + Circle() + .stroke(Color.white.opacity(0.08 - Double(index) * 0.02), lineWidth: 1.5) + .scaleEffect(animate ? 1.0 + CGFloat(index + 1) * 0.12 : 1.0) + .opacity(animate ? 0.0 : 0.6) + } + } + .onAppear { + withAnimation(.easeInOut(duration: 3.0).repeatForever(autoreverses: false)) { + animate = true + } + } + } +} diff --git a/Rosetta/Features/Calls/CallsView.swift b/Rosetta/Features/Calls/CallsView.swift index 5da2f61..4f2d546 100644 --- a/Rosetta/Features/Calls/CallsView.swift +++ b/Rosetta/Features/Calls/CallsView.swift @@ -1,101 +1,31 @@ import Lottie import SwiftUI -// MARK: - Call Type - -private enum CallType { - case outgoing - case incoming - case missed - - var label: String { - switch self { - case .outgoing: return "Outgoing" - case .incoming: return "Incoming" - case .missed: return "Missed" - } - } - - /// Small direction icon shown to the left of the avatar. - var directionIcon: String { - switch self { - case .outgoing: return "phone.arrow.up.right" - case .incoming: return "phone.arrow.down.left" - case .missed: return "phone.arrow.down.left" - } - } - - var directionColor: Color { - switch self { - case .outgoing, .incoming: return RosettaColors.success - case .missed: return RosettaColors.error - } - } - - var isMissed: Bool { self == .missed } -} - -// MARK: - Call Entry - -private struct CallEntry: Identifiable { - let id = UUID() - let name: String - let initials: String - let colorIndex: Int - let types: [CallType] - let duration: String? - let date: String - - init( - name: String, - initials: String, - colorIndex: Int, - types: [CallType], - duration: String? = nil, - date: String - ) { - self.name = name - self.initials = initials - self.colorIndex = colorIndex - self.types = types - self.duration = duration - self.date = date - } - - var isMissed: Bool { types.contains { $0.isMissed } } - var primaryType: CallType { types.first ?? .outgoing } - - var subtitleText: String { - let labels = types.map(\.label) - let joined = labels.joined(separator: ", ") - if let duration { return "\(joined) (\(duration))" } - return joined - } -} - -// MARK: - Filter - private enum CallFilter: String, CaseIterable { case all = "All" case missed = "Missed" } -// MARK: - CallsView - struct CallsView: View { + @StateObject private var viewModel = CallsViewModel() @State private var selectedFilter: CallFilter = .all + @State private var showCallError = false + @State private var callErrorMessage = "" - /// Empty by default — real calls will come from backend later. - /// Mock data is only in #Preview. - fileprivate var recentCalls: [CallEntry] = [] - - private var filteredCalls: [CallEntry] { + private var filteredCalls: [CallLogEntry] { + let calls = viewModel.recentCalls switch selectedFilter { - case .all: return recentCalls - case .missed: return recentCalls.filter { $0.isMissed } + case .all: + return calls + case .missed: + return calls.filter(\.isMissed) } } + private var hasAnyCalls: Bool { + !viewModel.recentCalls.isEmpty + } + var body: some View { NavigationStack { Group { @@ -109,8 +39,12 @@ struct CallsView: View { .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .navigationBarLeading) { - Button {} label: { - Text("Edit") + Button { + withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) { + viewModel.isEditing.toggle() + } + } label: { + Text(viewModel.isEditing ? "Done" : "Edit") .font(.system(size: 17, weight: .medium)) .foregroundStyle(RosettaColors.Adaptive.text) .fixedSize(horizontal: true, vertical: false) @@ -126,53 +60,113 @@ struct CallsView: View { } } .toolbarBackground(.hidden, for: .navigationBar) + .task { + viewModel.reload() + } + .overlay(alignment: .topLeading) { + CallsDataObserver(viewModel: viewModel) + } + .alert("Call Error", isPresented: $showCallError) { + Button("OK", role: .cancel) {} + } message: { + Text(callErrorMessage) + } } } -} -// MARK: - Empty State + private func startCall(to call: CallLogEntry) { + guard !call.opponentKey.isEmpty else { return } + let result = CallManager.shared.startOutgoingCall( + toPublicKey: call.opponentKey, + title: call.name, + username: "" + ) + if result == .alreadyInCall { + callErrorMessage = "You're already in a call" + showCallError = true + } + } + + private func openChat(for call: CallLogEntry) { + guard !call.opponentKey.isEmpty else { return } + let route: ChatRoute + if let dialog = DialogRepository.shared.dialogs[call.opponentKey] { + route = ChatRoute(dialog: dialog) + } else { + route = ChatRoute( + publicKey: call.opponentKey, + title: call.name, + username: "", + verified: 0 + ) + } + NotificationCenter.default.post( + name: .openChatFromNotification, + object: route + ) + } + + private func deleteCall(_ call: CallLogEntry) { + viewModel.deleteCallEntry(call) + } + + private func deleteFilteredCalls() { + let toDelete = filteredCalls + viewModel.deleteCalls(toDelete) + } +} private extension CallsView { var emptyStateContent: some View { VStack(spacing: 0) { Spacer() - LottieView( - animationName: "phone_duck", - loopMode: .playOnce, - animationSpeed: 1.0 - ) - .frame(width: 200, height: 200) + if hasAnyCalls { + Image(systemName: "phone.badge.exclamationmark") + .font(.system(size: 48, weight: .medium)) + .foregroundStyle(RosettaColors.Adaptive.textSecondary.opacity(0.7)) + .padding(.bottom, 18) - Spacer().frame(height: 24) + Text("No \(selectedFilter.rawValue.lowercased()) calls yet.") + .font(.system(size: 16)) + .foregroundStyle(RosettaColors.Adaptive.textSecondary) + .multilineTextAlignment(.center) + } else { + LottieView( + animationName: "phone_duck", + loopMode: .playOnce, + animationSpeed: 1.0 + ) + .frame(width: 200, height: 200) - Text("Your recent voice and video calls will\nappear here.") - .font(.system(size: 15)) - .foregroundStyle(RosettaColors.Adaptive.textSecondary) - .multilineTextAlignment(.center) + Spacer().frame(height: 24) - Spacer().frame(height: 20) + Text("Your recent voice and video calls will\nappear here.") + .font(.system(size: 15)) + .foregroundStyle(RosettaColors.Adaptive.textSecondary) + .multilineTextAlignment(.center) - Button {} label: { - HStack(spacing: 8) { - Image(systemName: "phone.badge.plus") - .font(.system(size: 18)) - Text("Start New Call") - .font(.system(size: 17)) + Spacer().frame(height: 20) + + Button {} label: { + HStack(spacing: 8) { + Image(systemName: "phone.badge.plus") + .font(.system(size: 18)) + Text("Start New Call") + .font(.system(size: 17)) + } + .foregroundStyle(RosettaColors.primaryBlue) } - .foregroundStyle(RosettaColors.primaryBlue) + .buttonStyle(.plain) } - .buttonStyle(.plain) Spacer() } .frame(maxWidth: .infinity, maxHeight: .infinity) - .offset(y: -40) + .offset(y: hasAnyCalls ? -10 : -40) } } -// MARK: - Call List Content - private extension CallsView { var callListContent: some View { ScrollView { @@ -187,9 +181,20 @@ private extension CallsView { } .scrollContentBackground(.hidden) } -} -// MARK: - Filter Picker + var deleteAllButton: some View { + Button(role: .destructive) { + deleteFilteredCalls() + } label: { + Text("Delete All") + .font(.system(size: 17)) + .foregroundStyle(RosettaColors.error) + } + .buttonStyle(.plain) + .padding(.horizontal, 16) + .padding(.top, 16) + } +} private extension CallsView { var filterPicker: some View { @@ -201,13 +206,13 @@ private extension CallsView { } } label: { Text(filter.rawValue) - .font(.system(size: 15, weight: selectedFilter == filter ? .semibold : .regular)) + .font(.system(size: 14, weight: selectedFilter == filter ? .semibold : .regular)) .foregroundStyle( selectedFilter == filter ? Color.white : RosettaColors.Adaptive.textSecondary ) - .frame(width: 74) + .padding(.horizontal, 12) .frame(height: 32) .background { if selectedFilter == filter { @@ -225,8 +230,6 @@ private extension CallsView { } } -// MARK: - Start New Call - private extension CallsView { var startNewCallRow: some View { VStack(spacing: 0) { @@ -255,8 +258,6 @@ private extension CallsView { } } -// MARK: - Recent Calls Section - private extension CallsView { var recentSection: some View { VStack(alignment: .leading, spacing: 6) { @@ -277,78 +278,129 @@ private extension CallsView { } } } + + if viewModel.isEditing { + deleteAllButton + } } } - func callRow(_ call: CallEntry) -> some View { + func callRow(_ call: CallLogEntry) -> some View { HStack(spacing: 10) { - // Call direction icon (far left, Telegram-style) - Image(systemName: call.primaryType.directionIcon) - .font(.system(size: 13)) - .foregroundStyle(call.primaryType.directionColor) - .frame(width: 18) - - // Avatar — reuse existing AvatarView component - AvatarView( - initials: call.initials, - colorIndex: call.colorIndex, - size: 44 - ) - - // Name + call type subtitle - VStack(alignment: .leading, spacing: 2) { - Text(call.name) - .font(.system(size: 17, weight: .semibold)) - .foregroundStyle(call.isMissed ? RosettaColors.error : .white) - .lineLimit(1) - - Text(call.subtitleText) - .font(.system(size: 14)) - .foregroundStyle(RosettaColors.Adaptive.textSecondary) - .lineLimit(1) - } - - Spacer() - - // Date + info button - HStack(spacing: 10) { - Text(call.date) - .font(.system(size: 15)) - .foregroundStyle(RosettaColors.Adaptive.textSecondary) - - Button {} label: { - Image(systemName: "info.circle") + if viewModel.isEditing { + Button { + deleteCall(call) + } label: { + Image(systemName: "minus.circle.fill") .font(.system(size: 22)) - .foregroundStyle(RosettaColors.primaryBlue) + .foregroundStyle(RosettaColors.error) } .buttonStyle(.plain) + .transition(.move(edge: .leading).combined(with: .opacity)) } + + Button { + startCall(to: call) + } label: { + HStack(spacing: 10) { + Image(systemName: call.direction.directionIcon) + .font(.system(size: 13)) + .foregroundStyle(call.direction.directionColor) + .frame(width: 18) + + AvatarView( + initials: call.initials, + colorIndex: call.colorIndex, + size: 44 + ) + + VStack(alignment: .leading, spacing: 2) { + Text(call.name) + .font(.system(size: 17, weight: .semibold)) + .foregroundStyle(call.isMissed ? RosettaColors.error : .white) + .lineLimit(1) + + Text(call.subtitleText) + .font(.system(size: 14)) + .foregroundStyle(RosettaColors.Adaptive.textSecondary) + .lineLimit(1) + } + + Spacer() + + Text(call.dateText) + .font(.system(size: 15)) + .foregroundStyle(RosettaColors.Adaptive.textSecondary) + } + } + .buttonStyle(.plain) + + Button { + openChat(for: call) + } label: { + Image(systemName: "info.circle") + .font(.system(size: 22)) + .foregroundStyle(RosettaColors.primaryBlue) + } + .buttonStyle(.plain) } .padding(.horizontal, 16) .padding(.vertical, 10) + .contentShape(Rectangle()) .accessibilityElement(children: .combine) - .accessibilityLabel("\(call.name), \(call.subtitleText), \(call.date)") + .accessibilityLabel("\(call.name), \(call.subtitleText), \(call.dateText)") } } -// MARK: - Preview (with mock data) +private extension CallLogDirection { + var directionIcon: String { + switch self { + case .outgoing, .rejected: + return "phone.arrow.up.right" + case .incoming, .missed: + return "phone.arrow.down.left" + } + } + + var directionColor: Color { + switch self { + case .outgoing, .incoming: + return RosettaColors.success + case .missed, .rejected: + return RosettaColors.error + } + } +} + +private struct CallsDataObserver: View { + @ObservedObject var viewModel: CallsViewModel + @ObservedObject private var callManager = CallManager.shared + + private var reloadToken: String { + let dialogs = DialogRepository.shared.sortedDialogs + let phaseTag = callManager.uiState.phase.rawValue + + guard !dialogs.isEmpty else { return "empty|\(phaseTag)" } + + let dialogPart = dialogs + .map { + "\($0.opponentKey):\($0.lastMessageTimestamp):\($0.lastMessageFromMe ? 1 : 0):\($0.lastMessageDelivered.rawValue):\($0.lastMessageRead ? 1 : 0)" + } + .joined(separator: "|") + return "\(dialogPart)|\(phaseTag)" + } -private struct CallsViewWithMockData: View { var body: some View { - var view = CallsView() - view.recentCalls = [ - CallEntry(name: "Alice Johnson", initials: "AJ", colorIndex: 0, types: [.outgoing], date: "01:50"), - CallEntry(name: "Bob Smith", initials: "BS", colorIndex: 1, types: [.incoming], date: "Sat"), - CallEntry(name: "Alice Johnson", initials: "AJ", colorIndex: 0, types: [.outgoing], duration: "12 sec", date: "28.02"), - CallEntry(name: "Carol White", initials: "CW", colorIndex: 2, types: [.outgoing], date: "27.02"), - CallEntry(name: "David Brown", initials: "DB", colorIndex: 3, types: [.outgoing, .incoming], date: "26.02"), - CallEntry(name: "Alice Johnson", initials: "AJ", colorIndex: 0, types: [.outgoing], duration: "1 min", date: "25.02"), - CallEntry(name: "Eve Davis", initials: "ED", colorIndex: 4, types: [.outgoing], duration: "2 sec", date: "24.02"), - CallEntry(name: "Frank Miller", initials: "FM", colorIndex: 5, types: [.missed], date: "24.02"), - CallEntry(name: "Carol White", initials: "CW", colorIndex: 2, types: [.incoming], date: "22.02"), - CallEntry(name: "Alice Johnson", initials: "AJ", colorIndex: 0, types: [.outgoing, .incoming], date: "21.02"), - ] - return view + Color.clear + .frame(width: 0, height: 0) + .allowsHitTesting(false) + .task(id: reloadToken) { + // Small delay when call just ended — allows sendCallAttachment Task to complete + if callManager.uiState.phase == .idle { + try? await Task.sleep(for: .milliseconds(300)) + } + viewModel.reload() + } } } @@ -356,8 +408,3 @@ private struct CallsViewWithMockData: View { CallsView() .preferredColorScheme(.dark) } - -#Preview("With Calls") { - CallsViewWithMockData() - .preferredColorScheme(.dark) -} diff --git a/Rosetta/Features/Calls/CallsViewModel.swift b/Rosetta/Features/Calls/CallsViewModel.swift new file mode 100644 index 0000000..ea93abd --- /dev/null +++ b/Rosetta/Features/Calls/CallsViewModel.swift @@ -0,0 +1,209 @@ +import Combine +import Foundation + +enum CallLogDirection: String, Sendable { + case outgoing + case incoming + case missed + case rejected + + var label: String { + switch self { + case .outgoing: + return "Outgoing" + case .incoming: + return "Incoming" + case .missed: + return "Missed" + case .rejected: + return "Rejected" + } + } + + var isMissed: Bool { + self == .missed + } + + var isIncoming: Bool { + self == .incoming || self == .missed + } + + var isOutgoing: Bool { + self == .outgoing || self == .rejected + } +} + +struct CallLogEntry: Identifiable, Equatable, Sendable { + let id: String + let opponentKey: String + let name: String + let initials: String + let colorIndex: Int + let direction: CallLogDirection + let durationSec: Int + let timestamp: Int64 + let dateText: String + + var isMissed: Bool { + direction.isMissed + } + + var subtitleText: String { + let base = direction.label + guard durationSec > 0 else { return base } + return "\(base) (\(Self.formattedDuration(seconds: durationSec)))" + } + + private static func formattedDuration(seconds: Int) -> String { + let safe = max(seconds, 0) + let minutes = safe / 60 + let secs = safe % 60 + return String(format: "%d:%02d", minutes, secs) + } +} + +@MainActor +final class CallsViewModel: ObservableObject { + @Published private(set) var recentCalls: [CallLogEntry] = [] + @Published var isEditing = false + + private let maxEntries: Int + + private static let timeFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.dateFormat = "HH:mm" + return formatter + }() + + private static let dayFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.dateFormat = "EEE" + return formatter + }() + + private static let dateFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.dateFormat = "dd.MM.yy" + return formatter + }() + + init(maxEntries: Int = 300) { + self.maxEntries = max(1, maxEntries) + } + + func reload() { + let dialogs = DialogRepository.shared.sortedDialogs + guard !dialogs.isEmpty else { + if !recentCalls.isEmpty { + recentCalls = [] + } + return + } + + let ownKey = SessionManager.shared.currentPublicKey + var entries: [CallLogEntry] = [] + entries.reserveCapacity(64) + + for dialog in dialogs where !dialog.isSavedMessages { + let messages = MessageRepository.shared.messages(for: dialog.opponentKey) + guard !messages.isEmpty else { continue } + + for message in messages { + guard let callAttachment = message.attachments.first(where: { $0.type == .call }) else { + continue + } + + let durationSec = AttachmentPreviewCodec.parseCallDurationSeconds(callAttachment.preview) + let isOutgoing = ownKey.isEmpty + ? (message.fromPublicKey == dialog.account) + : (message.fromPublicKey == ownKey) + let direction = callDirection(isOutgoing: isOutgoing, durationSec: durationSec) + + let title = dialog.opponentTitle.trimmingCharacters(in: .whitespacesAndNewlines) + let displayName: String + if title.isEmpty { + displayName = String(dialog.opponentKey.prefix(12)) + } else { + displayName = title + } + + let messageId = message.id.isEmpty + ? "\(dialog.opponentKey)-\(message.timestamp)-\(entries.count)" + : message.id + + entries.append( + CallLogEntry( + id: messageId, + opponentKey: dialog.opponentKey, + name: displayName, + initials: dialog.initials, + colorIndex: dialog.avatarColorIndex, + direction: direction, + durationSec: durationSec, + timestamp: message.timestamp, + dateText: Self.formattedTimestamp(message.timestamp) + ) + ) + } + } + + entries.sort { + if $0.timestamp == $1.timestamp { + return $0.id > $1.id + } + return $0.timestamp > $1.timestamp + } + + if entries.count > maxEntries { + entries = Array(entries.prefix(maxEntries)) + } + + if recentCalls != entries { + recentCalls = entries + } + } + + func deleteCallEntry(_ entry: CallLogEntry) { + guard !entry.id.contains("-") || !entry.id.contains(":") else { + // Synthetic ID — can't delete from DB, just remove from UI + recentCalls.removeAll { $0.id == entry.id } + return + } + MessageRepository.shared.deleteMessage(id: entry.id) + recentCalls.removeAll { $0.id == entry.id } + } + + func deleteCalls(_ entries: [CallLogEntry]) { + for entry in entries { + deleteCallEntry(entry) + } + if recentCalls.isEmpty { + isEditing = false + } + } + + private func callDirection(isOutgoing: Bool, durationSec: Int) -> CallLogDirection { + if durationSec <= 0 { + return isOutgoing ? .rejected : .missed + } + return isOutgoing ? .outgoing : .incoming + } + + private static func formattedTimestamp(_ timestampMs: Int64) -> String { + guard timestampMs > 0 else { return "" } + let date = Date(timeIntervalSince1970: Double(timestampMs) / 1000) + let now = Date() + let calendar = Calendar.current + + if calendar.isDateInToday(date) { + return timeFormatter.string(from: date) + } + if calendar.isDateInYesterday(date) { + return "Yesterday" + } + if let days = calendar.dateComponents([.day], from: date, to: now).day, days < 7 { + return dayFormatter.string(from: date) + } + return dateFormatter.string(from: date) + } +} diff --git a/Rosetta/Features/Chats/ChatDetail/ChatDetailView.swift b/Rosetta/Features/Chats/ChatDetail/ChatDetailView.swift index e594acd..39a2bbd 100644 --- a/Rosetta/Features/Chats/ChatDetail/ChatDetailView.swift +++ b/Rosetta/Features/Chats/ChatDetail/ChatDetailView.swift @@ -219,6 +219,14 @@ struct ChatDetailView: View { } cellActions.onRetry = { [self] msg in retryMessage(msg) } cellActions.onRemove = { [self] msg in removeMessage(msg) } + cellActions.onCall = { [self] peerKey in + let peerTitle = dialog?.opponentTitle ?? route.title + let peerUsername = dialog?.opponentUsername ?? route.username + let result = CallManager.shared.startOutgoingCall( + toPublicKey: peerKey, title: peerTitle, username: peerUsername + ) + if case .alreadyInCall = result { callErrorMessage = "You are already in another call." } + } // Capture first unread incoming message BEFORE marking as read. if firstUnreadMessageId == nil { firstUnreadMessageId = messages.first(where: { @@ -708,28 +716,11 @@ private extension ChatDetailView { } /// Cached tiled pattern color — computed once, reused across renders - private static let cachedTiledColor: Color? = { - guard let uiImage = UIImage(named: "ChatBackground"), - let cgImage = uiImage.cgImage else { return nil } - let tileWidth: CGFloat = 200 - let scaleFactor = uiImage.size.width / tileWidth - let scaledImage = UIImage( - cgImage: cgImage, - scale: uiImage.scale * scaleFactor, - orientation: .up - ) - return Color(uiColor: UIColor(patternImage: scaledImage)) - }() - - /// Tiled chat background with properly scaled tiles (200pt wide) + /// Default chat wallpaper — full-screen scaled image. private var tiledChatBackground: some View { - Group { - if let color = Self.cachedTiledColor { - color.opacity(0.18) - } else { - Color.clear - } - } + Image("ChatWallpaper") + .resizable() + .aspectRatio(contentMode: .fill) } // MARK: - Messages diff --git a/Rosetta/Features/Chats/ChatDetail/MediaBubbleCornerMaskFactory.swift b/Rosetta/Features/Chats/ChatDetail/MediaBubbleCornerMaskFactory.swift index c949f1c..66d7b43 100644 --- a/Rosetta/Features/Chats/ChatDetail/MediaBubbleCornerMaskFactory.swift +++ b/Rosetta/Features/Chats/ChatDetail/MediaBubbleCornerMaskFactory.swift @@ -3,7 +3,11 @@ import UIKit enum MediaBubbleCornerMaskFactory { private static let mainRadius: CGFloat = 16 private static let mergedRadius: CGFloat = 8 - private static let inset: CGFloat = 2 + // Telegram: photo corners use the SAME radius as bubble (16pt). + // The 2pt gap is purely spatial (photoFrame offset), NOT radius reduction. + // Non-concentric circles with same radius + 2pt offset create a natural + // slightly wider gap at corners (~2.83pt at 45°) which matches Telegram. + private static let inset: CGFloat = 0 /// Full bubble mask INCLUDING tail shape — used for photo-only messages /// where the photo fills the entire bubble area. @@ -86,20 +90,14 @@ enum MediaBubbleCornerMaskFactory { metrics: metrics ) - var adjusted = base - if BubbleGeometryEngine.hasTail(for: mergeType) { - if outgoing { - adjusted.bottomRight = min(adjusted.bottomRight, mergedRadius) - } else { - adjusted.bottomLeft = min(adjusted.bottomLeft, mergedRadius) - } - } - + // Telegram: photo corners use the SAME radii as the bubble body. + // No reduction at tail corner — the bubble's raster image behind + // the 2pt gap handles the tail shape visually. return ( - topLeft: max(adjusted.topLeft - inset, 0), - topRight: max(adjusted.topRight - inset, 0), - bottomLeft: max(adjusted.bottomLeft - inset, 0), - bottomRight: max(adjusted.bottomRight - inset, 0) + topLeft: base.topLeft, + topRight: base.topRight, + bottomLeft: base.bottomLeft, + bottomRight: base.bottomRight ) } diff --git a/Rosetta/Features/Chats/ChatDetail/MessageCallView.swift b/Rosetta/Features/Chats/ChatDetail/MessageCallView.swift new file mode 100644 index 0000000..11f6912 --- /dev/null +++ b/Rosetta/Features/Chats/ChatDetail/MessageCallView.swift @@ -0,0 +1,129 @@ +import SwiftUI + +// MARK: - MessageCallView + +/// Displays a call attachment inside a message bubble. +/// +/// Telegram iOS parity: `ChatMessageCallBubbleContentNode.swift` +/// — title (16pt medium), directional arrow, duration, call-back button. +/// +/// Desktop parity: `MessageCall.tsx` — phone icon, direction label, duration "M:SS". +/// +/// Preview format: duration seconds as plain int (0 = missed/rejected). +struct MessageCallView: View { + + let attachment: MessageAttachment + let message: ChatMessage + let outgoing: Bool + let currentPublicKey: String + let actions: MessageCellActions + + var body: some View { + HStack(spacing: 0) { + // Left: icon + info + HStack(spacing: 10) { + // Call icon circle (44×44 — Telegram parity) + ZStack { + Circle() + .fill(iconBackgroundColor) + .frame(width: 44, height: 44) + + Image(systemName: iconName) + .font(.system(size: 20, weight: .medium)) + .foregroundStyle(.white) + } + + // Call metadata + VStack(alignment: .leading, spacing: 2) { + Text(titleText) + .font(.system(size: 16, weight: .medium)) + .foregroundStyle(.white) + + // Status line with directional arrow + HStack(spacing: 4) { + Image(systemName: arrowIconName) + .font(.system(size: 10, weight: .bold)) + .foregroundStyle(arrowColor) + + Text(subtitleText) + .font(.system(size: 13).monospacedDigit()) + .foregroundStyle(.white.opacity(0.6)) + } + } + } + + Spacer(minLength: 8) + + // Right: call-back button + Button { + let peerKey = outgoing ? message.toPublicKey : message.fromPublicKey + actions.onCall(peerKey) + } label: { + ZStack { + Circle() + .fill(Color(hex: 0x248AE6).opacity(0.3)) + .frame(width: 44, height: 44) + + Image(systemName: "phone.fill") + .font(.system(size: 18)) + .foregroundStyle(Color(hex: 0x248AE6)) + } + } + .buttonStyle(.plain) + } + .padding(.horizontal, 10) + .padding(.vertical, 8) + } + + // MARK: - Computed Properties + + private var durationSeconds: Int { + AttachmentPreviewCodec.parseCallDurationSeconds(attachment.preview) + } + + private var isMissedOrRejected: Bool { + durationSeconds == 0 + } + + private var isIncoming: Bool { + !outgoing + } + + private var titleText: String { + if isMissedOrRejected { + return isIncoming ? "Missed Call" : "Cancelled Call" + } + return isIncoming ? "Incoming Call" : "Outgoing Call" + } + + private var subtitleText: String { + if isMissedOrRejected { + return "Call was not answered" + } + let minutes = durationSeconds / 60 + let seconds = durationSeconds % 60 + return String(format: "%d:%02d", minutes, seconds) + } + + private var iconName: String { + if isMissedOrRejected { + return "phone.down.fill" + } + return isIncoming ? "phone.arrow.down.left.fill" : "phone.arrow.up.right.fill" + } + + private var iconBackgroundColor: Color { + if isMissedOrRejected { + return Color(hex: 0xFF4747).opacity(0.85) + } + return Color.white.opacity(0.2) + } + + private var arrowIconName: String { + isIncoming ? "arrow.down.left" : "arrow.up.right" + } + + private var arrowColor: Color { + isMissedOrRejected ? Color(hex: 0xFF4747) : Color(hex: 0x36C033) + } +} diff --git a/Rosetta/Features/Chats/ChatDetail/MessageCellActions.swift b/Rosetta/Features/Chats/ChatDetail/MessageCellActions.swift index 4623df0..1dd76dc 100644 --- a/Rosetta/Features/Chats/ChatDetail/MessageCellActions.swift +++ b/Rosetta/Features/Chats/ChatDetail/MessageCellActions.swift @@ -13,4 +13,5 @@ final class MessageCellActions { var onScrollToMessage: (String) -> Void = { _ in } var onRetry: (ChatMessage) -> Void = { _ in } var onRemove: (ChatMessage) -> Void = { _ in } + var onCall: (String) -> Void = { _ in } // peer public key } diff --git a/Rosetta/Features/Chats/ChatDetail/MessageCellView.swift b/Rosetta/Features/Chats/ChatDetail/MessageCellView.swift index 8104a35..ee3fbfd 100644 --- a/Rosetta/Features/Chats/ChatDetail/MessageCellView.swift +++ b/Rosetta/Features/Chats/ChatDetail/MessageCellView.swift @@ -31,7 +31,7 @@ struct MessageCellView: View, Equatable { let hasTail = position == .single || position == .bottom let visibleAttachments = message.attachments.filter { - $0.type == .image || $0.type == .file || $0.type == .avatar + $0.type == .image || $0.type == .file || $0.type == .avatar || $0.type == .call } Group { @@ -317,6 +317,16 @@ struct MessageCellView: View, Equatable { ) .padding(.horizontal, 6) .padding(.top, 4) + case .call: + MessageCallView( + attachment: attachment, + message: message, + outgoing: outgoing, + currentPublicKey: currentPublicKey, + actions: actions + ) + .padding(.horizontal, 4) + .padding(.top, 4) default: EmptyView() } diff --git a/Rosetta/Features/Chats/ChatDetail/MessageFileView.swift b/Rosetta/Features/Chats/ChatDetail/MessageFileView.swift index 29fb880..7077885 100644 --- a/Rosetta/Features/Chats/ChatDetail/MessageFileView.swift +++ b/Rosetta/Features/Chats/ChatDetail/MessageFileView.swift @@ -26,7 +26,7 @@ struct MessageFileView: View { ZStack { Circle() .fill(outgoing ? Color.white.opacity(0.2) : Color(hex: 0x008BFF).opacity(0.2)) - .frame(width: 40, height: 40) + .frame(width: 44, height: 44) if isDownloading { ProgressView() @@ -34,11 +34,11 @@ struct MessageFileView: View { .scaleEffect(0.8) } else if isDownloaded { Image(systemName: fileIcon) - .font(.system(size: 18)) + .font(.system(size: 20)) .foregroundStyle(outgoing ? .white : Color(hex: 0x008BFF)) } else { Image(systemName: "arrow.down.circle.fill") - .font(.system(size: 18)) + .font(.system(size: 20)) .foregroundStyle(outgoing ? .white.opacity(0.7) : Color(hex: 0x008BFF).opacity(0.7)) } } @@ -46,21 +46,21 @@ struct MessageFileView: View { // File metadata VStack(alignment: .leading, spacing: 2) { Text(fileName) - .font(.system(size: 14, weight: .medium)) + .font(.system(size: 16, weight: .regular)) .foregroundStyle(outgoing ? .white : RosettaColors.Adaptive.text) .lineLimit(1) if isDownloading { Text("Downloading...") - .font(.system(size: 12)) + .font(.system(size: 13).monospacedDigit()) .foregroundStyle(outgoing ? .white.opacity(0.5) : RosettaColors.Adaptive.textSecondary) } else if downloadError { Text("File expired") - .font(.system(size: 12)) + .font(.system(size: 13)) .foregroundStyle(RosettaColors.error) } else { Text(formattedFileSize) - .font(.system(size: 12)) + .font(.system(size: 13).monospacedDigit()) .foregroundStyle( outgoing ? .white.opacity(0.5) : RosettaColors.Adaptive.textSecondary ) diff --git a/Rosetta/Features/Chats/ChatDetail/NativeMessageCell.swift b/Rosetta/Features/Chats/ChatDetail/NativeMessageCell.swift index 58d8862..b6bde7f 100644 --- a/Rosetta/Features/Chats/ChatDetail/NativeMessageCell.swift +++ b/Rosetta/Features/Chats/ChatDetail/NativeMessageCell.swift @@ -21,8 +21,8 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel private static let replyTextFont = UIFont.systemFont(ofSize: 14, weight: .regular) private static let forwardLabelFont = UIFont.systemFont(ofSize: 13, weight: .regular) private static let forwardNameFont = UIFont.systemFont(ofSize: 14, weight: .semibold) - private static let fileNameFont = UIFont.systemFont(ofSize: 14, weight: .medium) - private static let fileSizeFont = UIFont.systemFont(ofSize: 12, weight: .regular) + private static let fileNameFont = UIFont.systemFont(ofSize: 16, weight: .regular) + private static let fileSizeFont = UIFont.monospacedDigitSystemFont(ofSize: 13, weight: .regular) private static let bubbleMetrics = BubbleMetrics.telegram() private static let statusBubbleInsets = bubbleMetrics.mediaStatusInsets private static let sendingClockAnimationKey = "clockFrameAnimation" @@ -66,7 +66,6 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel // Timestamp + delivery private let statusBackgroundView = UIView() - private let statusGradientLayer = CAGradientLayer() private let timestampLabel = UILabel() private let checkSentView = UIImageView() private let checkReadView = UIImageView() @@ -85,19 +84,27 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel private var photoTilePlaceholderViews: [UIView] = [] private var photoTileActivityIndicators: [UIActivityIndicatorView] = [] private var photoTileErrorViews: [UIImageView] = [] + private var photoTileDownloadArrows: [UIView] = [] private var photoTileButtons: [UIButton] = [] private let photoUploadingOverlayView = UIView() private let photoUploadingIndicator = UIActivityIndicatorView(style: .medium) private let photoOverflowOverlayView = UIView() private let photoOverflowLabel = UILabel() - // File + // File / Call / Avatar (shared container) private let fileContainer = UIView() private let fileIconView = UIView() private let fileIconSymbolView = UIImageView() private let fileNameLabel = UILabel() private let fileSizeLabel = UILabel() + // Call-specific + private let callArrowView = UIImageView() + private let callBackButton = UIButton(type: .custom) + + // Avatar-specific + private let avatarImageView = UIImageView() + // Forward header private let forwardLabel = UILabel() private let forwardAvatarView = UIView() @@ -160,18 +167,12 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel bubbleView.addSubview(textLabel) // Timestamp - statusBackgroundView.backgroundColor = .clear - statusBackgroundView.layer.cornerRadius = 7 + // Telegram: solid pill UIColor(white:0, alpha:0.3), diameter 18 → radius 9 + statusBackgroundView.backgroundColor = UIColor(white: 0.0, alpha: 0.3) + statusBackgroundView.layer.cornerRadius = 9 statusBackgroundView.layer.cornerCurve = .continuous statusBackgroundView.clipsToBounds = true statusBackgroundView.isHidden = true - statusGradientLayer.colors = [ - UIColor.black.withAlphaComponent(0.0).cgColor, - UIColor.black.withAlphaComponent(0.5).cgColor - ] - statusGradientLayer.startPoint = CGPoint(x: 0, y: 0) - statusGradientLayer.endPoint = CGPoint(x: 1, y: 1) - statusBackgroundView.layer.insertSublayer(statusGradientLayer, at: 0) bubbleView.addSubview(statusBackgroundView) timestampLabel.font = Self.timestampFont @@ -225,6 +226,25 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel errorView.isHidden = true photoContainer.addSubview(errorView) + // Download arrow overlay — shown when photo not yet downloaded + let downloadArrow = UIView() + downloadArrow.isHidden = true + downloadArrow.isUserInteractionEnabled = false + let arrowCircle = UIView(frame: CGRect(x: 0, y: 0, width: 48, height: 48)) + arrowCircle.backgroundColor = UIColor.black.withAlphaComponent(0.5) + arrowCircle.layer.cornerRadius = 24 + arrowCircle.tag = 1001 + downloadArrow.addSubview(arrowCircle) + let arrowImage = UIImageView( + image: UIImage(systemName: "arrow.down", + withConfiguration: UIImage.SymbolConfiguration(pointSize: 20, weight: .medium)) + ) + arrowImage.tintColor = .white + arrowImage.contentMode = .center + arrowImage.frame = arrowCircle.bounds + arrowCircle.addSubview(arrowImage) + photoContainer.addSubview(downloadArrow) + let button = UIButton(type: .custom) button.tag = index button.isHidden = true @@ -235,6 +255,7 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel photoTilePlaceholderViews.append(placeholderView) photoTileActivityIndicators.append(indicator) photoTileErrorViews.append(errorView) + photoTileDownloadArrows.append(downloadArrow) photoTileButtons.append(button) } @@ -263,7 +284,7 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel // File fileIconView.backgroundColor = UIColor.white.withAlphaComponent(0.2) - fileIconView.layer.cornerRadius = 20 + fileIconView.layer.cornerRadius = 22 fileIconSymbolView.tintColor = .white fileIconSymbolView.contentMode = .scaleAspectFit fileIconView.addSubview(fileIconSymbolView) @@ -274,6 +295,38 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel fileSizeLabel.font = Self.fileSizeFont fileSizeLabel.textColor = UIColor.white.withAlphaComponent(0.6) fileContainer.addSubview(fileSizeLabel) + + // Call arrow (small directional arrow left of duration) + callArrowView.contentMode = .scaleAspectFit + callArrowView.isHidden = true + fileContainer.addSubview(callArrowView) + + // Call-back button (phone icon on right side) + let callCircle = UIView() + callCircle.backgroundColor = UIColor(red: 0.14, green: 0.54, blue: 0.90, alpha: 0.3) // #248AE6 @ 0.3 + callCircle.layer.cornerRadius = 22 + callCircle.isUserInteractionEnabled = false + callCircle.tag = 2001 + callBackButton.addSubview(callCircle) + let callPhoneIcon = UIImageView( + image: UIImage(systemName: "phone.fill", + withConfiguration: UIImage.SymbolConfiguration(pointSize: 18, weight: .medium)) + ) + callPhoneIcon.tintColor = UIColor(red: 0.14, green: 0.54, blue: 0.90, alpha: 1) // #248AE6 + callPhoneIcon.contentMode = .center + callPhoneIcon.tag = 2002 + callBackButton.addSubview(callPhoneIcon) + callBackButton.addTarget(self, action: #selector(callBackTapped), for: .touchUpInside) + callBackButton.isHidden = true + fileContainer.addSubview(callBackButton) + + // Avatar image (circular, replaces icon for avatar type) + avatarImageView.contentMode = .scaleAspectFill + avatarImageView.clipsToBounds = true + avatarImageView.layer.cornerRadius = 22 + avatarImageView.isHidden = true + fileContainer.addSubview(avatarImageView) + bubbleView.addSubview(fileContainer) // Forward header @@ -423,40 +476,120 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel if let callAtt = message.attachments.first(where: { $0.type == .call }) { let durationSec = AttachmentPreviewCodec.parseCallDurationSeconds(callAtt.preview) let isOutgoing = currentLayout?.isOutgoing ?? false - let isError = durationSec == 0 + let isMissed = durationSec == 0 + let isIncoming = !isOutgoing + avatarImageView.isHidden = true + fileIconView.isHidden = false - if isError { - fileIconView.backgroundColor = UIColor.systemRed.withAlphaComponent(0.85) - fileIconSymbolView.image = UIImage(systemName: "xmark") - fileNameLabel.text = isOutgoing ? "Rejected call" : "Missed call" - fileSizeLabel.text = "Call was not answered or was rejected" - fileSizeLabel.textColor = UIColor.systemRed.withAlphaComponent(0.95) + // Icon + if isMissed { + fileIconView.backgroundColor = UIColor(red: 1.0, green: 0.28, blue: 0.28, alpha: 0.85) + fileIconSymbolView.image = UIImage( + systemName: "phone.down.fill", + withConfiguration: UIImage.SymbolConfiguration(pointSize: 20, weight: .medium) + ) } else { fileIconView.backgroundColor = UIColor.white.withAlphaComponent(0.2) fileIconSymbolView.image = UIImage( - systemName: isOutgoing ? "phone.arrow.up.right.fill" : "phone.arrow.down.left.fill" + systemName: isOutgoing ? "phone.arrow.up.right.fill" : "phone.arrow.down.left.fill", + withConfiguration: UIImage.SymbolConfiguration(pointSize: 20, weight: .medium) ) - fileNameLabel.text = isOutgoing ? "Outgoing call" : "Incoming call" - fileSizeLabel.text = Self.formattedDuration(seconds: durationSec) + } + + // Title (16pt medium — Telegram parity) + fileNameLabel.font = UIFont.systemFont(ofSize: 16, weight: .medium) + if isMissed { + fileNameLabel.text = isIncoming ? "Missed Call" : "Cancelled Call" + } else { + fileNameLabel.text = isIncoming ? "Incoming Call" : "Outgoing Call" + } + + // Duration with arrow + if isMissed { + fileSizeLabel.text = "Call was not answered" + fileSizeLabel.textColor = UIColor(red: 1.0, green: 0.28, blue: 0.28, alpha: 0.95) + } else { + fileSizeLabel.text = " " + Self.formattedDuration(seconds: durationSec) fileSizeLabel.textColor = UIColor.white.withAlphaComponent(0.6) } + + // Directional arrow (green/red) + let arrowName = isIncoming ? "arrow.down.left" : "arrow.up.right" + callArrowView.image = UIImage( + systemName: arrowName, + withConfiguration: UIImage.SymbolConfiguration(pointSize: 10, weight: .bold) + ) + callArrowView.tintColor = isMissed + ? UIColor(red: 1.0, green: 0.28, blue: 0.28, alpha: 1) + : UIColor(red: 0.21, green: 0.75, blue: 0.20, alpha: 1) // #36C033 + callArrowView.isHidden = false + callBackButton.isHidden = false } else if let fileAtt = message.attachments.first(where: { $0.type == .file }) { + let parsed = AttachmentPreviewCodec.parseFilePreview(fileAtt.preview) + avatarImageView.isHidden = true + fileIconView.isHidden = false fileIconView.backgroundColor = UIColor.white.withAlphaComponent(0.2) fileIconSymbolView.image = UIImage(systemName: "doc.fill") - fileNameLabel.text = AttachmentPreviewCodec.parseFilePreview(fileAtt.preview).fileName - fileSizeLabel.text = "" + fileNameLabel.font = Self.fileNameFont + fileNameLabel.text = parsed.fileName + fileSizeLabel.text = Self.formattedFileSize(bytes: parsed.fileSize) fileSizeLabel.textColor = UIColor.white.withAlphaComponent(0.6) - } else if message.attachments.first(where: { $0.type == .avatar }) != nil { - fileIconView.backgroundColor = UIColor.white.withAlphaComponent(0.2) - fileIconSymbolView.image = UIImage(systemName: "person.crop.circle.fill") + callArrowView.isHidden = true + callBackButton.isHidden = true + } else if let avatarAtt = message.attachments.first(where: { $0.type == .avatar }) { + fileNameLabel.font = Self.fileNameFont fileNameLabel.text = "Avatar" - fileSizeLabel.text = "" + fileSizeLabel.text = "Shared profile photo" fileSizeLabel.textColor = UIColor.white.withAlphaComponent(0.6) + callArrowView.isHidden = true + callBackButton.isHidden = true + + // Try to load cached avatar image or blurhash + if let cached = AttachmentCache.shared.cachedImage(forAttachmentId: avatarAtt.id) { + avatarImageView.image = cached + avatarImageView.isHidden = false + fileIconView.isHidden = true + } else { + // Try blurhash placeholder + let hash = AttachmentPreviewCodec.blurHash(from: avatarAtt.preview) + if !hash.isEmpty, let blurImg = Self.blurHashCache.object(forKey: hash as NSString) { + avatarImageView.image = blurImg + avatarImageView.isHidden = false + fileIconView.isHidden = true + } else if !hash.isEmpty { + // Decode blurhash on background + avatarImageView.isHidden = true + fileIconView.isHidden = false + fileIconView.backgroundColor = UIColor.white.withAlphaComponent(0.2) + fileIconSymbolView.image = UIImage(systemName: "person.crop.circle.fill") + let messageId = message.id + Task.detached(priority: .userInitiated) { + guard let decoded = UIImage.fromBlurHash(hash, width: 32, height: 32) else { return } + await MainActor.run { [weak self] in + guard let self, self.message?.id == messageId else { return } + Self.blurHashCache.setObject(decoded, forKey: hash as NSString) + self.avatarImageView.image = decoded + self.avatarImageView.isHidden = false + self.fileIconView.isHidden = true + } + } + } else { + avatarImageView.isHidden = true + fileIconView.isHidden = false + fileIconView.backgroundColor = UIColor.white.withAlphaComponent(0.2) + fileIconSymbolView.image = UIImage(systemName: "person.crop.circle.fill") + } + } } else { + avatarImageView.isHidden = true + fileIconView.isHidden = false fileIconView.backgroundColor = UIColor.white.withAlphaComponent(0.2) fileIconSymbolView.image = UIImage(systemName: "doc.fill") + fileNameLabel.font = Self.fileNameFont fileNameLabel.text = "File" fileSizeLabel.textColor = UIColor.white.withAlphaComponent(0.6) + callArrowView.isHidden = true + callBackButton.isHidden = true } } else { fileContainer.isHidden = true @@ -581,14 +714,41 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel } bringStatusOverlayToFront() - // File + // File / Call / Avatar fileContainer.isHidden = !layout.hasFile if layout.hasFile { fileContainer.frame = layout.fileFrame - fileIconView.frame = CGRect(x: 10, y: 8, width: 40, height: 40) - fileIconSymbolView.frame = CGRect(x: 9, y: 9, width: 22, height: 22) - fileNameLabel.frame = CGRect(x: 60, y: 10, width: layout.fileFrame.width - 70, height: 17) - fileSizeLabel.frame = CGRect(x: 60, y: 30, width: layout.fileFrame.width - 70, height: 15) + let isCallType = message?.attachments.contains(where: { $0.type == .call }) ?? false + let fileW = layout.fileFrame.width + + fileIconView.frame = CGRect(x: 9, y: 6, width: 44, height: 44) + fileIconSymbolView.frame = CGRect(x: 11, y: 11, width: 22, height: 22) + + let isAvatarType = message?.attachments.contains(where: { $0.type == .avatar }) ?? false + + if isCallType { + // Call layout: icon + title/status + call-back button on right + let callBtnSize: CGFloat = 44 + let callBtnRight: CGFloat = 8 + let textRight = callBtnRight + callBtnSize + 4 + fileNameLabel.frame = CGRect(x: 63, y: 8, width: fileW - 63 - textRight, height: 20) + fileSizeLabel.frame = CGRect(x: 78, y: 30, width: fileW - 78 - textRight, height: 16) + callArrowView.frame = CGRect(x: 63, y: 33, width: 12, height: 12) + callBackButton.frame = CGRect(x: fileW - callBtnSize - callBtnRight, y: 8, width: callBtnSize, height: callBtnSize) + callBackButton.viewWithTag(2001)?.frame = CGRect(x: 0, y: 0, width: callBtnSize, height: callBtnSize) + callBackButton.viewWithTag(2002)?.frame = CGRect(x: 0, y: 0, width: callBtnSize, height: callBtnSize) + avatarImageView.isHidden = true + } else if isAvatarType { + // Avatar layout: circular image (44pt) + title + description + avatarImageView.frame = CGRect(x: 9, y: 14, width: 44, height: 44) + fileNameLabel.frame = CGRect(x: 63, y: 18, width: fileW - 75, height: 19) + fileSizeLabel.frame = CGRect(x: 63, y: 39, width: fileW - 75, height: 16) + } else { + // File layout: icon + title + size + fileNameLabel.frame = CGRect(x: 63, y: 9, width: fileW - 75, height: 19) + fileSizeLabel.frame = CGRect(x: 63, y: 30, width: fileW - 75, height: 16) + avatarImageView.isHidden = true + } } // Forward @@ -652,6 +812,13 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel return String(format: "%d:%02d", minutes, secs) } + private static func formattedFileSize(bytes: Int) -> String { + if bytes < 1024 { return "\(bytes) B" } + if bytes < 1024 * 1024 { return String(format: "%.1f KB", Double(bytes) / 1024) } + if bytes < 1024 * 1024 * 1024 { return String(format: "%.1f MB", Double(bytes) / (1024 * 1024)) } + return String(format: "%.1f GB", Double(bytes) / (1024 * 1024 * 1024)) + } + // MARK: - Self-sizing (from pre-calculated layout) override func preferredLayoutAttributesFitting( @@ -725,6 +892,13 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel actions.onRetry(message) } + @objc private func callBackTapped() { + guard let message, let actions else { return } + let isOutgoing = currentLayout?.isOutgoing ?? false + let peerKey = isOutgoing ? message.toPublicKey : message.fromPublicKey + actions.onCall(peerKey) + } + @objc private func handlePhotoTileTap(_ sender: UIButton) { guard sender.tag >= 0, sender.tag < photoAttachments.count, let message, @@ -795,6 +969,7 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel let placeholderView = photoTilePlaceholderViews[index] let indicator = photoTileActivityIndicators[index] let errorView = photoTileErrorViews[index] + let downloadArrow = photoTileDownloadArrows[index] let button = photoTileButtons[index] button.isHidden = !isActiveTile @@ -806,6 +981,7 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel indicator.stopAnimating() indicator.isHidden = true errorView.isHidden = true + downloadArrow.isHidden = true continue } @@ -817,6 +993,7 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel indicator.stopAnimating() indicator.isHidden = true errorView.isHidden = true + downloadArrow.isHidden = true } else { if let blur = Self.cachedBlurHashImage(from: attachment.preview) { setPhotoTileImage(blur, at: index, animated: false) @@ -830,14 +1007,18 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel indicator.stopAnimating() indicator.isHidden = true errorView.isHidden = false + downloadArrow.isHidden = true } else if downloadingAttachmentIds.contains(attachment.id) { indicator.startAnimating() indicator.isHidden = false errorView.isHidden = true + downloadArrow.isHidden = true } else { + // Not downloaded, not downloading — show download arrow indicator.stopAnimating() indicator.isHidden = true errorView.isHidden = true + downloadArrow.isHidden = false } startPhotoLoadTask(attachment: attachment) } @@ -861,6 +1042,12 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel x: frame.midX - 10, y: frame.midY - 10, width: 20, height: 20 ) + // Download arrow: full tile frame, circle centered + let arrow = photoTileDownloadArrows[index] + arrow.frame = frame + if let circle = arrow.viewWithTag(1001) { + circle.center = CGPoint(x: frame.width / 2, y: frame.height / 2) + } } photoUploadingOverlayView.frame = photoContainer.bounds photoUploadingIndicator.center = CGPoint( @@ -1120,6 +1307,7 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel self.photoTileActivityIndicators[tileIndex].stopAnimating() self.photoTileActivityIndicators[tileIndex].isHidden = true self.photoTileErrorViews[tileIndex].isHidden = true + self.photoTileDownloadArrows[tileIndex].isHidden = true } } } @@ -1135,6 +1323,7 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel photoTileActivityIndicators[tileIndex].stopAnimating() photoTileActivityIndicators[tileIndex].isHidden = true photoTileErrorViews[tileIndex].isHidden = false + photoTileDownloadArrows[tileIndex].isHidden = true } return } @@ -1146,6 +1335,7 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel photoTileActivityIndicators[tileIndex].startAnimating() photoTileActivityIndicators[tileIndex].isHidden = false photoTileErrorViews[tileIndex].isHidden = true + photoTileDownloadArrows[tileIndex].isHidden = true } photoDownloadTasks[attachmentId] = Task { [weak self] in @@ -1174,6 +1364,7 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel } self.photoTileActivityIndicators[tileIndex].stopAnimating() self.photoTileActivityIndicators[tileIndex].isHidden = true + self.photoTileDownloadArrows[tileIndex].isHidden = true } } catch { await MainActor.run { @@ -1188,6 +1379,7 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel self.photoTileActivityIndicators[tileIndex].stopAnimating() self.photoTileActivityIndicators[tileIndex].isHidden = true self.photoTileErrorViews[tileIndex].isHidden = false + self.photoTileDownloadArrows[tileIndex].isHidden = true } } } @@ -1225,6 +1417,7 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel photoTileActivityIndicators[index].stopAnimating() photoTileActivityIndicators[index].isHidden = true photoTileErrorViews[index].isHidden = true + photoTileDownloadArrows[index].isHidden = true photoTileButtons[index].isHidden = true photoTileButtons[index].layer.mask = nil } @@ -1362,10 +1555,6 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel width: contentRect.width + insets.left + insets.right, height: contentRect.height + insets.top + insets.bottom ) - CATransaction.begin() - CATransaction.setDisableActions(true) - statusGradientLayer.frame = statusBackgroundView.bounds - CATransaction.commit() } private func bringStatusOverlayToFront() { @@ -1429,6 +1618,11 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel resetPhotoTiles() replyContainer.isHidden = true fileContainer.isHidden = true + callArrowView.isHidden = true + callBackButton.isHidden = true + avatarImageView.image = nil + avatarImageView.isHidden = true + fileIconView.isHidden = false forwardLabel.isHidden = true forwardAvatarView.isHidden = true forwardNameLabel.isHidden = true diff --git a/Rosetta/Resources/Lottie/Images/back_5.png b/Rosetta/Resources/Lottie/Images/back_5.png new file mode 100644 index 0000000..3ec7b78 Binary files /dev/null and b/Rosetta/Resources/Lottie/Images/back_5.png differ diff --git a/Rosetta/Resources/Sounds/calling.mp3 b/Rosetta/Resources/Sounds/calling.mp3 new file mode 100644 index 0000000..5a00cd0 Binary files /dev/null and b/Rosetta/Resources/Sounds/calling.mp3 differ diff --git a/Rosetta/Resources/Sounds/connected.mp3 b/Rosetta/Resources/Sounds/connected.mp3 new file mode 100644 index 0000000..3e030fa Binary files /dev/null and b/Rosetta/Resources/Sounds/connected.mp3 differ diff --git a/Rosetta/Resources/Sounds/end_call.mp3 b/Rosetta/Resources/Sounds/end_call.mp3 new file mode 100644 index 0000000..c58e9ed Binary files /dev/null and b/Rosetta/Resources/Sounds/end_call.mp3 differ diff --git a/Rosetta/Resources/Sounds/ringtone.mp3 b/Rosetta/Resources/Sounds/ringtone.mp3 new file mode 100644 index 0000000..6c576c2 Binary files /dev/null and b/Rosetta/Resources/Sounds/ringtone.mp3 differ diff --git a/RosettaTests/CallSoundAndTimeoutTests.swift b/RosettaTests/CallSoundAndTimeoutTests.swift new file mode 100644 index 0000000..7caf0ae --- /dev/null +++ b/RosettaTests/CallSoundAndTimeoutTests.swift @@ -0,0 +1,240 @@ +import XCTest +@testable import Rosetta + +@MainActor +final class CallSoundAndTimeoutTests: XCTestCase { + private let ownKey = "02-own-sound-test" + private let peerKey = "02-peer-sound-test" + + override func setUp() { + super.setUp() + CallManager.shared.resetForSessionEnd() + CallManager.shared.bindAccount(publicKey: ownKey) + } + + override func tearDown() { + CallManager.shared.resetForSessionEnd() + super.tearDown() + } + + // MARK: - Ring Timeout Tests + + func testRingTimeoutTaskCreatedOnOutgoingCall() { + let result = CallManager.shared.startOutgoingCall( + toPublicKey: peerKey, + title: "Peer", + username: "peer" + ) + + XCTAssertEqual(result, .started) + XCTAssertNotNil(CallManager.shared.ringTimeoutTask, "Ring timeout should be set for outgoing call") + XCTAssertEqual(CallManager.shared.uiState.phase, .outgoing) + } + + func testRingTimeoutTaskCreatedOnIncomingCall() { + let packet = PacketSignalPeer( + src: peerKey, + dst: ownKey, + sharedPublic: "", + signalType: .call, + roomId: "" + ) + + CallManager.shared.testHandleSignalPacket(packet) + + XCTAssertNotNil(CallManager.shared.ringTimeoutTask, "Ring timeout should be set for incoming call") + XCTAssertEqual(CallManager.shared.uiState.phase, .incoming) + } + + func testRingTimeoutCancelledOnAcceptIncoming() { + // Set up incoming call + let packet = PacketSignalPeer( + src: peerKey, + dst: ownKey, + sharedPublic: "", + signalType: .call, + roomId: "" + ) + CallManager.shared.testHandleSignalPacket(packet) + XCTAssertNotNil(CallManager.shared.ringTimeoutTask) + + // Accept + _ = CallManager.shared.acceptIncomingCall() + + XCTAssertNil(CallManager.shared.ringTimeoutTask, "Ring timeout should be cancelled on accept") + } + + func testRingTimeoutCancelledOnDeclineIncoming() { + let packet = PacketSignalPeer( + src: peerKey, + dst: ownKey, + sharedPublic: "", + signalType: .call, + roomId: "" + ) + CallManager.shared.testHandleSignalPacket(packet) + XCTAssertNotNil(CallManager.shared.ringTimeoutTask) + + CallManager.shared.declineIncomingCall() + + XCTAssertNil(CallManager.shared.ringTimeoutTask, "Ring timeout should be cancelled on decline") + XCTAssertEqual(CallManager.shared.uiState.phase, .idle) + } + + func testRingTimeoutCancelledOnEndCall() { + _ = CallManager.shared.startOutgoingCall( + toPublicKey: peerKey, + title: "Peer", + username: "peer" + ) + XCTAssertNotNil(CallManager.shared.ringTimeoutTask) + + CallManager.shared.endCall() + + XCTAssertNil(CallManager.shared.ringTimeoutTask, "Ring timeout should be cancelled on endCall") + } + + func testRingTimeoutCancelledOnBusySignal() { + _ = CallManager.shared.startOutgoingCall( + toPublicKey: peerKey, + title: "Peer", + username: "peer" + ) + XCTAssertNotNil(CallManager.shared.ringTimeoutTask) + + let busyPacket = PacketSignalPeer( + src: "", + dst: "", + sharedPublic: "", + signalType: .endCallBecauseBusy, + roomId: "" + ) + CallManager.shared.testHandleSignalPacket(busyPacket) + + XCTAssertNil(CallManager.shared.ringTimeoutTask, "Ring timeout should be cancelled on busy signal") + XCTAssertEqual(CallManager.shared.uiState.phase, .idle) + } + + func testRingTimeoutCancelledOnPeerEndCall() { + let incomingPacket = PacketSignalPeer( + src: peerKey, + dst: ownKey, + sharedPublic: "", + signalType: .call, + roomId: "" + ) + CallManager.shared.testHandleSignalPacket(incomingPacket) + XCTAssertNotNil(CallManager.shared.ringTimeoutTask) + + let endPacket = PacketSignalPeer( + src: "", + dst: "", + sharedPublic: "", + signalType: .endCall, + roomId: "" + ) + CallManager.shared.testHandleSignalPacket(endPacket) + + XCTAssertNil(CallManager.shared.ringTimeoutTask, "Ring timeout should be cancelled when peer ends call") + XCTAssertEqual(CallManager.shared.uiState.phase, .idle) + } + + // MARK: - Sound Flow Tests (verify phase transitions trigger correct states) + + func testOutgoingCallSetsCorrectPhaseForSounds() { + let result = CallManager.shared.startOutgoingCall( + toPublicKey: peerKey, + title: "Peer", + username: "peer" + ) + + XCTAssertEqual(result, .started) + XCTAssertEqual(CallManager.shared.uiState.phase, .outgoing) + // playCalling() should have been called (verified by phase) + } + + func testIncomingCallSetsCorrectPhaseForSounds() { + let packet = PacketSignalPeer( + src: peerKey, + dst: ownKey, + sharedPublic: "", + signalType: .call, + roomId: "" + ) + CallManager.shared.testHandleSignalPacket(packet) + + XCTAssertEqual(CallManager.shared.uiState.phase, .incoming) + // playRingtone() should have been called (verified by phase) + } + + func testCallActiveTransitionClearsTimeout() { + CallManager.shared.testSetUiState( + CallUiState( + phase: .webRtcExchange, + peerPublicKey: peerKey, + statusText: "Connecting..." + ) + ) + // Manually set timeout to verify it gets cleared + CallManager.shared.startRingTimeout() + XCTAssertNotNil(CallManager.shared.ringTimeoutTask) + + CallManager.shared.setCallActiveIfNeeded() + + XCTAssertEqual(CallManager.shared.uiState.phase, .active) + XCTAssertNil(CallManager.shared.ringTimeoutTask, "Ring timeout should be cancelled when call becomes active") + } + + // MARK: - Duplicate Call Prevention + + func testCannotStartSecondCallWhileInCall() { + _ = CallManager.shared.startOutgoingCall( + toPublicKey: peerKey, + title: "Peer", + username: "peer" + ) + + let result = CallManager.shared.startOutgoingCall( + toPublicKey: "02-another-peer", + title: "Another", + username: "another" + ) + + XCTAssertEqual(result, .alreadyInCall) + XCTAssertEqual(CallManager.shared.uiState.peerPublicKey, peerKey, "Original call should not be replaced") + } + + func testCannotAcceptWhenNotIncoming() { + _ = CallManager.shared.startOutgoingCall( + toPublicKey: peerKey, + title: "Peer", + username: "peer" + ) + + let result = CallManager.shared.acceptIncomingCall() + + XCTAssertEqual(result, .notIncoming) + } + + // MARK: - CallsViewModel Filter Tests + + func testCallLogDirectionMapping() { + // Outgoing with duration > 0 = outgoing + XCTAssertFalse(CallLogDirection.outgoing.isMissed) + XCTAssertTrue(CallLogDirection.outgoing.isOutgoing) + XCTAssertFalse(CallLogDirection.outgoing.isIncoming) + + // Incoming with duration > 0 = incoming + XCTAssertFalse(CallLogDirection.incoming.isMissed) + XCTAssertFalse(CallLogDirection.incoming.isOutgoing) + XCTAssertTrue(CallLogDirection.incoming.isIncoming) + + // Duration 0 + incoming = missed + XCTAssertTrue(CallLogDirection.missed.isMissed) + XCTAssertTrue(CallLogDirection.missed.isIncoming) + + // Duration 0 + outgoing = rejected + XCTAssertFalse(CallLogDirection.rejected.isMissed) + XCTAssertTrue(CallLogDirection.rejected.isOutgoing) + } +}