Реализован UI файлов, звонков и аватаров в пузырьках сообщений — Telegram iOS parity
This commit is contained in:
@@ -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>
|
||||
|
||||
13
Rosetta/Assets.xcassets/ChatWallpaper.imageset/Contents.json
vendored
Normal file
13
Rosetta/Assets.xcassets/ChatWallpaper.imageset/Contents.json
vendored
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "back_5.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
BIN
Rosetta/Assets.xcassets/ChatWallpaper.imageset/back_5.png
vendored
Normal file
BIN
Rosetta/Assets.xcassets/ChatWallpaper.imageset/back_5.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.4 MiB |
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
|
||||
121
Rosetta/Core/Services/CallSoundManager.swift
Normal file
121
Rosetta/Core/Services/CallSoundManager.swift
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
209
Rosetta/Features/Calls/CallsViewModel.swift
Normal file
209
Rosetta/Features/Calls/CallsViewModel.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
129
Rosetta/Features/Chats/ChatDetail/MessageCallView.swift
Normal file
129
Rosetta/Features/Chats/ChatDetail/MessageCallView.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
BIN
Rosetta/Resources/Lottie/Images/back_5.png
Normal file
BIN
Rosetta/Resources/Lottie/Images/back_5.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.4 MiB |
BIN
Rosetta/Resources/Sounds/calling.mp3
Normal file
BIN
Rosetta/Resources/Sounds/calling.mp3
Normal file
Binary file not shown.
BIN
Rosetta/Resources/Sounds/connected.mp3
Normal file
BIN
Rosetta/Resources/Sounds/connected.mp3
Normal file
Binary file not shown.
BIN
Rosetta/Resources/Sounds/end_call.mp3
Normal file
BIN
Rosetta/Resources/Sounds/end_call.mp3
Normal file
Binary file not shown.
BIN
Rosetta/Resources/Sounds/ringtone.mp3
Normal file
BIN
Rosetta/Resources/Sounds/ringtone.mp3
Normal file
Binary file not shown.
240
RosettaTests/CallSoundAndTimeoutTests.swift
Normal file
240
RosettaTests/CallSoundAndTimeoutTests.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user