Реализован UI файлов, звонков и аватаров в пузырьках сообщений — Telegram iOS parity

This commit is contained in:
2026-03-29 01:57:45 +05:00
parent 16191ef197
commit 6e927f8871
25 changed files with 1354 additions and 296 deletions

View File

@@ -10,6 +10,8 @@
<string>Rosetta needs access to your photo library to send images in chats.</string>
<key>NSCameraUsageDescription</key>
<string>Rosetta needs access to your camera to take and send photos in chats.</string>
<key>NSMicrophoneUsageDescription</key>
<string>Rosetta needs access to your microphone for secure voice calls and audio messages.</string>
<key>UIBackgroundModes</key>
<array>
<string>remote-notification</string>

View File

@@ -0,0 +1,13 @@
{
"images" : [
{
"filename" : "back_5.png",
"idiom" : "universal",
"scale" : "1x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

View File

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

View File

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

View File

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

View File

@@ -36,6 +36,7 @@ final class CallManager: NSObject, ObservableObject {
var attachedReceiverIds: Set<String> = []
var durationTask: Task<Void, Never>?
var ringTimeoutTask: Task<Void, Never>?
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 }

View File

@@ -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<Void, Never>?
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
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

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