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