Темизация: adaptive цвета чата, context menu, attachment picker, auth + instant отклик DarkMode кнопки

This commit is contained in:
2026-04-05 12:16:24 +05:00
parent a72a1bd6c9
commit d65624ad35
21 changed files with 803 additions and 159 deletions

View File

@@ -1,9 +1,10 @@
import Foundation
/// Call signaling packet (0x1A / 26).
/// Wire format mirrors desktop/android:
/// `signalType` always first, then short-form for busy/disconnected,
/// otherwise `src`, `dst`, optional `sharedPublic`, optional `roomId`.
/// Wire format mirrors server/desktop legacy signaling:
/// `signalType` always first, then short-form for busy/disconnected/ringingTimeout,
/// otherwise `src`, `dst`, optional `sharedPublic`, optional `callId/joinToken`,
/// optional `roomId` for create-room fallback compatibility.
enum SignalType: Int, Sendable {
case call = 0
case keyExchange = 1
@@ -12,6 +13,8 @@ enum SignalType: Int, Sendable {
case createRoom = 4
case endCallBecausePeerDisconnected = 5
case endCallBecauseBusy = 6
case accept = 7
case ringingTimeout = 8
}
struct PacketSignalPeer: Packet {
@@ -21,11 +24,13 @@ struct PacketSignalPeer: Packet {
var dst: String = ""
var sharedPublic: String = ""
var signalType: SignalType = .call
var callId: String = ""
var joinToken: String = ""
var roomId: String = ""
func write(to stream: Stream) {
stream.writeInt8(signalType.rawValue)
if signalType == .endCallBecauseBusy || signalType == .endCallBecausePeerDisconnected {
if isShortSignal {
return
}
stream.writeString(src)
@@ -33,14 +38,27 @@ struct PacketSignalPeer: Packet {
if signalType == .keyExchange {
stream.writeString(sharedPublic)
}
if signalType == .createRoom {
if hasLegacyCallMetadata {
stream.writeString(callId)
stream.writeString(joinToken)
}
// Signal code 4 is mode-aware:
// - legacy ACTIVE: no roomId on wire
// - create-room fallback: roomId present on wire
if signalType == .createRoom && !roomId.isEmpty {
stream.writeString(roomId)
}
}
mutating func read(from stream: Stream) {
src = ""
dst = ""
sharedPublic = ""
callId = ""
joinToken = ""
roomId = ""
signalType = SignalType(rawValue: stream.readInt8()) ?? .call
if signalType == .endCallBecauseBusy || signalType == .endCallBecausePeerDisconnected {
if isShortSignal {
return
}
src = stream.readString()
@@ -48,8 +66,25 @@ struct PacketSignalPeer: Packet {
if signalType == .keyExchange {
sharedPublic = stream.readString()
}
if hasLegacyCallMetadata {
callId = stream.readString()
joinToken = stream.readString()
}
// Signal code 4 is mode-aware on read:
// - empty roomId => legacy ACTIVE
// - non-empty roomId => create-room fallback
if signalType == .createRoom {
roomId = stream.readString()
}
}
private var isShortSignal: Bool {
signalType == .endCallBecauseBusy
|| signalType == .endCallBecausePeerDisconnected
|| signalType == .ringingTimeout
}
private var hasLegacyCallMetadata: Bool {
signalType == .call || signalType == .accept || signalType == .endCall
}
}

View File

@@ -356,6 +356,8 @@ final class ProtocolManager: @unchecked Sendable {
src: String = "",
dst: String = "",
sharedPublic: String = "",
callId: String = "",
joinToken: String = "",
roomId: String = ""
) {
var packet = PacketSignalPeer()
@@ -363,6 +365,8 @@ final class ProtocolManager: @unchecked Sendable {
packet.src = src
packet.dst = dst
packet.sharedPublic = sharedPublic
packet.callId = callId
packet.joinToken = joinToken
packet.roomId = roomId
sendPacket(packet)
}

View File

@@ -144,6 +144,10 @@ extension CallManager {
func beginCallSession(peerPublicKey: String, title: String, username: String) {
finishCall(reason: nil, notifyPeer: false)
signalingMode = .undecided
callId = ""
joinToken = ""
pendingIncomingAccept = false
uiState = CallUiState(
phase: .idle,
peerPublicKey: peerPublicKey,
@@ -158,11 +162,14 @@ extension CallManager {
guard !isFinishingCall, uiState.phase != .idle else { return }
isFinishingCall = true
pendingCallKitAccept = false
pendingIncomingAccept = false
defer { isFinishingCall = false }
callLogger.notice("[Call] finishCall(reason=\(reason ?? "nil", privacy: .public)) phase=\(self.uiState.phase.rawValue, privacy: .public)")
let snapshot = uiState
let snapshotCallId = callId
let snapshotJoinToken = joinToken
// Step 0: Cancel recovery/rebind tasks and clear packet buffer.
disconnectRecoveryTask?.cancel()
@@ -221,7 +228,9 @@ extension CallManager {
ProtocolManager.shared.sendCallSignal(
signalType: .endCall,
src: ownPublicKey,
dst: snapshot.peerPublicKey
dst: snapshot.peerPublicKey,
callId: snapshotCallId,
joinToken: snapshotJoinToken
)
}
@@ -244,7 +253,10 @@ extension CallManager {
// Step 6: Reset all state.
role = nil
signalingMode = .undecided
roomId = ""
callId = ""
joinToken = ""
localPrivateKey = nil
localPublicKeyHex = ""
sharedKey = nil

View File

@@ -18,7 +18,10 @@ final class CallManager: NSObject, ObservableObject {
var ownPublicKey: String = ""
var role: CallRole?
var signalingMode: CallSignalingMode = .undecided
var roomId: String = ""
var callId: String = ""
var joinToken: String = ""
var localPrivateKey: Curve25519.KeyAgreement.PrivateKey?
var localPublicKeyHex: String = ""
var sharedKey: Data?
@@ -49,6 +52,9 @@ final class CallManager: NSObject, ObservableObject {
/// Pending accept: user tapped Accept on CallKit before WebSocket delivered .call signal.
/// When handleSignalPacket(.call) arrives, auto-accept if this is true.
var pendingCallKitAccept = false
/// Deferred incoming accept: user accepted, but call signaling metadata/mode is
/// not yet sufficient to send the next signal (legacy ACCEPT or create-room KEY_EXCHANGE).
var pendingIncomingAccept = false
/// True after CallKit fires didActivate audio session has the entitlement.
/// WebRTC peer connection MUST NOT be created before this flag is true,
/// otherwise AURemoteIO init fails with "Missing entitlement" (-12988).
@@ -98,22 +104,39 @@ final class CallManager: NSObject, ObservableObject {
/// Sets up incoming call state directly from VoIP push payload.
/// Called when app was killed PushKit wakes it WebSocket not yet connected.
/// The .call signal may never arrive (fire-and-forget), so we set up state from push.
func setupIncomingCallFromPush(callerKey: String, callerName: String) {
guard uiState.phase == .idle else { return }
guard !callerKey.isEmpty else { return }
callLogger.notice("setupIncomingCallFromPush: callerKey=\(callerKey.prefix(12), privacy: .public) name=\(callerName, privacy: .public)")
func setupIncomingCallFromPush(
callerKey: String,
callerName: String,
callId: String? = nil,
joinToken: String? = nil
) {
let peer = callerKey.trimmingCharacters(in: .whitespacesAndNewlines)
guard !peer.isEmpty else { return }
mergeLegacySessionMetadata(callId: callId, joinToken: joinToken, source: "push")
if hasLegacySessionMetadata {
setSignalingMode(.legacy, reason: "push metadata")
}
guard uiState.phase == .idle else {
completePendingIncomingAcceptIfPossible(trigger: "push update")
return
}
callLogger.notice(
"setupIncomingCallFromPush: callerKey=\(peer.prefix(12), privacy: .public) name=\(callerName, privacy: .public) mode=\(self.signalingMode.rawValue, privacy: .public)"
)
// Don't call beginCallSession() it calls finishCall() which kills the
// CallKit call that PushKit just reported. Set state directly instead.
uiState = CallUiState(
phase: .incoming,
peerPublicKey: callerKey,
peerPublicKey: peer,
peerTitle: callerName,
peerUsername: ""
)
role = .callee
uiState.statusText = "Incoming call..."
ProtocolManager.shared.beginCallBackgroundTask()
hydratePeerIdentity(for: callerKey)
hydratePeerIdentity(for: peer)
startRingTimeout()
// Auto-accept if user already tapped Accept on CallKit before this ran.
@@ -125,6 +148,7 @@ final class CallManager: NSObject, ObservableObject {
let result = acceptIncomingCall()
callLogger.info("setupIncomingCallFromPush: auto-accept result=\(String(describing: result), privacy: .public)")
}
completePendingIncomingAcceptIfPossible(trigger: "push setup")
}
func onAuthenticated() {
@@ -143,6 +167,7 @@ final class CallManager: NSObject, ObservableObject {
beginCallSession(peerPublicKey: target, title: title, username: username)
role = .caller
setSignalingMode(.undecided, reason: "outgoing call started")
ensureLocalSessionKeys()
uiState.phase = .outgoing
uiState.statusText = "Calling..."
@@ -172,16 +197,14 @@ final class CallManager: NSObject, ObservableObject {
ensureLocalSessionKeys()
guard localPublicKeyHex.isEmpty == false else { return .invalidTarget }
ProtocolManager.shared.sendCallSignal(
signalType: .keyExchange,
src: ownPublicKey,
dst: uiState.peerPublicKey,
sharedPublic: localPublicKeyHex
)
pendingIncomingAccept = true
uiState.phase = .keyExchange
uiState.isMinimized = false // Show full-screen custom overlay after accept
uiState.statusText = "Exchanging keys..."
uiState.statusText = "Connecting..."
callLogger.notice(
"[Call] acceptIncomingCall: queued mode=\(self.signalingMode.rawValue, privacy: .public) hasMetadata=\(self.hasLegacySessionMetadata.description, privacy: .public)"
)
completePendingIncomingAcceptIfPossible(trigger: "acceptIncomingCall")
return .started
}
@@ -192,7 +215,9 @@ final class CallManager: NSObject, ObservableObject {
ProtocolManager.shared.sendCallSignal(
signalType: .endCall,
src: ownPublicKey,
dst: uiState.peerPublicKey
dst: uiState.peerPublicKey,
callId: callId,
joinToken: joinToken
)
}
finishCall(reason: nil, notifyPeer: false)
@@ -264,8 +289,80 @@ final class CallManager: NSObject, ObservableObject {
}
}
private var hasLegacySessionMetadata: Bool {
!callId.isEmpty && !joinToken.isEmpty
}
private func setSignalingMode(_ mode: CallSignalingMode, reason: String) {
guard signalingMode != mode else { return }
callLogger.notice(
"[Call] signaling mode \(self.signalingMode.rawValue, privacy: .public) -> \(mode.rawValue, privacy: .public) (\(reason, privacy: .public))"
)
signalingMode = mode
}
private func mergeLegacySessionMetadata(callId: String?, joinToken: String?, source: String) {
let normalizedCallId = callId?
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let normalizedJoinToken = joinToken?
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if !normalizedCallId.isEmpty && self.callId != normalizedCallId {
callLogger.info(
"[Call] merge callId from \(source, privacy: .public): \(normalizedCallId.prefix(12), privacy: .public)"
)
self.callId = normalizedCallId
}
if !normalizedJoinToken.isEmpty && self.joinToken != normalizedJoinToken {
callLogger.info("[Call] merge joinToken from \(source, privacy: .public)")
self.joinToken = normalizedJoinToken
}
}
private func completePendingIncomingAcceptIfPossible(trigger: String) {
guard pendingIncomingAccept else { return }
guard ownPublicKey.isEmpty == false else { return }
guard uiState.peerPublicKey.isEmpty == false else { return }
switch signalingMode {
case .legacy:
guard hasLegacySessionMetadata else {
callLogger.info("[Call] pending accept waits for legacy metadata (\(trigger, privacy: .public))")
return
}
ProtocolManager.shared.sendCallSignal(
signalType: .accept,
src: ownPublicKey,
dst: uiState.peerPublicKey,
callId: callId,
joinToken: joinToken
)
pendingIncomingAccept = false
uiState.statusText = "Waiting for key exchange..."
callLogger.notice("[Call] pending accept completed via ACCEPT (\(trigger, privacy: .public))")
case .createRoom:
ensureLocalSessionKeys()
guard !localPublicKeyHex.isEmpty else { return }
ProtocolManager.shared.sendCallSignal(
signalType: .keyExchange,
src: ownPublicKey,
dst: uiState.peerPublicKey,
sharedPublic: localPublicKeyHex
)
pendingIncomingAccept = false
uiState.statusText = "Exchanging keys..."
callLogger.notice("[Call] pending accept completed via KEY_EXCHANGE (\(trigger, privacy: .public))")
case .undecided:
callLogger.info("[Call] pending accept waits for signaling mode decision (\(trigger, privacy: .public))")
}
}
private func handleSignalPacket(_ packet: PacketSignalPeer) {
callLogger.notice("[Call] handleSignalPacket: type=\(String(describing: packet.signalType), privacy: .public) phase=\(self.uiState.phase.rawValue, privacy: .public)")
callLogger.notice(
"[Call] handleSignalPacket: type=\(String(describing: packet.signalType), privacy: .public) phase=\(self.uiState.phase.rawValue, privacy: .public) mode=\(self.signalingMode.rawValue, privacy: .public)"
)
switch packet.signalType {
case .endCallBecauseBusy:
finishCall(reason: "User is busy", notifyPeer: false, skipAttachment: true)
@@ -273,6 +370,9 @@ final class CallManager: NSObject, ObservableObject {
case .endCallBecausePeerDisconnected:
finishCall(reason: "Peer disconnected", notifyPeer: false)
return
case .ringingTimeout:
finishCall(reason: "No answer", notifyPeer: false, skipAttachment: true)
return
case .endCall:
finishCall(reason: "Call ended", notifyPeer: false)
return
@@ -290,12 +390,23 @@ final class CallManager: NSObject, ObservableObject {
case .call:
let incomingPeer = packet.src.trimmingCharacters(in: .whitespacesAndNewlines)
guard incomingPeer.isEmpty == false else { return }
mergeLegacySessionMetadata(
callId: packet.callId,
joinToken: packet.joinToken,
source: "signal.call"
)
if hasLegacySessionMetadata {
setSignalingMode(.legacy, reason: "incoming .call with call metadata")
} else if signalingMode == .undecided {
setSignalingMode(.createRoom, reason: "incoming .call without metadata")
}
guard uiState.phase == .idle else {
// Already in a call with this peer ignore duplicate .call signal.
// Server re-delivers .call after WebSocket reconnect; without this guard,
// the code sends .endCallBecauseBusy which terminates the active call.
if incomingPeer == uiState.peerPublicKey {
callLogger.info("Ignoring duplicate .call signal — already in call with this peer (phase=\(self.uiState.phase.rawValue, privacy: .public))")
completePendingIncomingAcceptIfPossible(trigger: "duplicate .call")
return
}
// Different peer trying to call send busy.
@@ -306,7 +417,26 @@ final class CallManager: NSObject, ObservableObject {
)
return
}
let cachedCallId = callId
let cachedJoinToken = joinToken
beginCallSession(peerPublicKey: incomingPeer, title: "", username: "")
mergeLegacySessionMetadata(
callId: packet.callId,
joinToken: packet.joinToken,
source: "signal.call post-reset"
)
if !hasLegacySessionMetadata {
mergeLegacySessionMetadata(
callId: cachedCallId,
joinToken: cachedJoinToken,
source: "signal.call cached metadata"
)
}
if hasLegacySessionMetadata {
setSignalingMode(.legacy, reason: "incoming .call session start")
} else {
setSignalingMode(.createRoom, reason: "incoming .call session start")
}
role = .callee
uiState.phase = .incoming
ProtocolManager.shared.beginCallBackgroundTask()
@@ -335,12 +465,50 @@ final class CallManager: NSObject, ObservableObject {
let result = acceptIncomingCall()
callLogger.info("Auto-accept: result=\(String(describing: result), privacy: .public) ownKey=\(self.ownPublicKey.isEmpty ? "EMPTY" : String(self.ownPublicKey.prefix(12)), privacy: .public) phase=\(self.uiState.phase.rawValue, privacy: .public) wsState=\(String(describing: ProtocolManager.shared.connectionState), privacy: .public)")
}
completePendingIncomingAcceptIfPossible(trigger: "incoming .call")
case .accept:
mergeLegacySessionMetadata(
callId: packet.callId,
joinToken: packet.joinToken,
source: "signal.accept"
)
guard role == .caller else { return }
if signalingMode == .undecided {
setSignalingMode(.legacy, reason: "caller received ACCEPT")
} else if signalingMode == .createRoom {
callLogger.notice("[Call] ACCEPT ignored — mode already createRoom")
return
}
ensureLocalSessionKeys()
guard !localPublicKeyHex.isEmpty else { return }
ProtocolManager.shared.sendCallSignal(
signalType: .keyExchange,
src: ownPublicKey,
dst: uiState.peerPublicKey,
sharedPublic: localPublicKeyHex
)
uiState.phase = .keyExchange
uiState.statusText = "Exchanging keys..."
callLogger.notice("[Call] legacy path: ACCEPT -> KEY_EXCHANGE")
case .keyExchange:
if role == .caller, signalingMode == .undecided {
setSignalingMode(.createRoom, reason: "caller received KEY_EXCHANGE before ACCEPT")
}
handleKeyExchange(packet)
case .createRoom:
let incomingRoomId = packet.roomId.trimmingCharacters(in: .whitespacesAndNewlines)
guard incomingRoomId.isEmpty == false else { return }
if incomingRoomId.isEmpty {
if signalingMode == .undecided {
setSignalingMode(.legacy, reason: "signal 4 without roomId")
}
callLogger.notice("[Call] signal 4 handled as ACTIVE (legacy)")
} else {
roomId = incomingRoomId
if signalingMode == .undecided {
setSignalingMode(.createRoom, reason: "signal 4 with roomId")
}
callLogger.notice("[Call] signal 4 handled as CREATE_ROOM roomId=\(incomingRoomId.prefix(16), privacy: .public)")
}
uiState.phase = .webRtcExchange
uiState.statusText = "Connecting..."
if audioSessionActivated {
@@ -373,7 +541,7 @@ final class CallManager: NSObject, ObservableObject {
}
case .activeCall:
break
case .endCall, .endCallBecausePeerDisconnected, .endCallBecauseBusy:
case .ringingTimeout, .endCall, .endCallBecausePeerDisconnected, .endCallBecauseBusy:
break
}
}
@@ -406,6 +574,17 @@ final class CallManager: NSObject, ObservableObject {
switch role {
case .caller:
switch signalingMode {
case .legacy:
ProtocolManager.shared.sendCallSignal(
signalType: .createRoom,
src: ownPublicKey,
dst: uiState.peerPublicKey
)
uiState.phase = .keyExchange
uiState.statusText = "Finalizing call..."
callLogger.notice("[Call] legacy path: KEY_EXCHANGE complete -> ACTIVE")
case .createRoom:
ProtocolManager.shared.sendCallSignal(
signalType: .keyExchange,
src: ownPublicKey,
@@ -415,13 +594,46 @@ final class CallManager: NSObject, ObservableObject {
ProtocolManager.shared.sendCallSignal(
signalType: .createRoom,
src: ownPublicKey,
dst: uiState.peerPublicKey
dst: uiState.peerPublicKey,
roomId: roomId
)
uiState.phase = .webRtcExchange
uiState.statusText = "Creating room..."
callLogger.notice("[Call] create-room path: KEY_EXCHANGE complete -> CREATE_ROOM")
case .undecided:
setSignalingMode(.createRoom, reason: "fallback on caller key exchange")
ProtocolManager.shared.sendCallSignal(
signalType: .keyExchange,
src: ownPublicKey,
dst: uiState.peerPublicKey,
sharedPublic: localPublicKeyHex
)
ProtocolManager.shared.sendCallSignal(
signalType: .createRoom,
src: ownPublicKey,
dst: uiState.peerPublicKey,
roomId: roomId
)
uiState.phase = .webRtcExchange
uiState.statusText = "Creating room..."
}
case .callee:
switch signalingMode {
case .legacy:
ProtocolManager.shared.sendCallSignal(
signalType: .keyExchange,
src: ownPublicKey,
dst: uiState.peerPublicKey,
sharedPublic: localPublicKeyHex
)
uiState.phase = .keyExchange
uiState.statusText = "Waiting for active signal..."
callLogger.notice("[Call] legacy path: sent KEY_EXCHANGE, waiting for ACTIVE")
case .createRoom, .undecided:
uiState.phase = .keyExchange
uiState.statusText = "Waiting for room..."
callLogger.notice("[Call] create-room path: waiting for room signal")
}
case .none:
break
}

View File

@@ -15,6 +15,12 @@ enum CallRole: Sendable {
case callee
}
enum CallSignalingMode: String, Sendable {
case undecided
case legacy
case createRoom
}
enum CallActionResult: Sendable {
case started
case alreadyInCall

View File

@@ -17,7 +17,7 @@ struct DarkModeWrapper<Content: View>: View {
content
.onAppear {
if overlayWindow == nil {
if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene {
if let windowScene = activeWindowScene {
let overlayWindow = UIWindow(windowScene: windowScene)
overlayWindow.tag = 0320
overlayWindow.isHidden = false
@@ -27,31 +27,41 @@ struct DarkModeWrapper<Content: View>: View {
}
}
.onChange(of: activateDarkMode, initial: true) { _, newValue in
if let keyWindow = (UIApplication.shared.connectedScenes.first as? UIWindowScene)?.windows.first(where: { $0.isKeyWindow }) {
keyWindow.overrideUserInterfaceStyle = newValue ? .dark : .light
if let windowScene = activeWindowScene {
let style: UIUserInterfaceStyle = newValue ? .dark : .light
for window in windowScene.windows {
window.overrideUserInterfaceStyle = style
}
}
}
}
private var activeWindowScene: UIWindowScene? {
let scenes = UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }
return scenes.first(where: { $0.activationState == .foregroundActive }) ?? scenes.first
}
}
/// Theme toggle button with sun/moon icon and circular reveal animation.
struct DarkModeButton: View {
@State private var buttonRect: CGRect = .zero
@AppStorage("rosetta_dark_mode") private var toggleDarkMode: Bool = true
/// Local icon state changes INSTANTLY on tap (no round-trip through UserDefaults).
@State private var showMoonIcon: Bool = true
@AppStorage("rosetta_dark_mode") private var activateDarkMode: Bool = true
var body: some View {
Button(action: {
toggleDarkMode.toggle()
showMoonIcon.toggle()
animateScheme()
}, label: {
Image(systemName: toggleDarkMode ? "moon.fill" : "sun.max.fill")
Image(systemName: showMoonIcon ? "moon.fill" : "sun.max.fill")
.font(.system(size: 16, weight: .medium))
.foregroundStyle(RosettaColors.Adaptive.text)
.symbolEffect(.bounce, value: toggleDarkMode)
.symbolEffect(.bounce, value: showMoonIcon)
.frame(width: 44, height: 44)
})
.buttonStyle(.plain)
.onAppear { showMoonIcon = activateDarkMode }
.darkModeButtonRect { rect in
buttonRect = rect
}
@@ -59,29 +69,32 @@ struct DarkModeButton: View {
@MainActor
func animateScheme() {
Task {
if let windows = (UIApplication.shared.connectedScenes.first as? UIWindowScene)?.windows,
let window = windows.first(where: { $0.isKeyWindow }),
let overlayWindow = windows.first(where: { $0.tag == 0320 }) {
guard let windowScene = activeWindowScene,
let window = windowScene.windows.first(where: { $0.isKeyWindow }),
let overlayWindow = windowScene.windows.first(where: { $0.tag == 0320 })
else { return }
overlayWindow.isUserInteractionEnabled = true
let imageView = UIImageView()
let targetDark = showMoonIcon
let frameSize = window.frame.size
// 1. Capture old state SYNCHRONOUSLY afterScreenUpdates:false is fast (~5ms).
let previousImage = window.darkModeSnapshotFast(frameSize)
// 2. Show freeze frame IMMEDIATELY user sees instant response.
// Overlay stays non-interactive so button taps always pass through.
let imageView = UIImageView(image: previousImage)
imageView.frame = window.frame
imageView.image = window.darkModeSnapshot(window.frame.size)
imageView.contentMode = .scaleAspectFit
overlayWindow.addSubview(imageView)
let frameSize = window.frame.size
// Capture old state
activateDarkMode = !toggleDarkMode
let previousImage = window.darkModeSnapshot(frameSize)
// Switch to new state
activateDarkMode = toggleDarkMode
// Allow layout to settle
try await Task.sleep(for: .seconds(0.01))
let currentImage = window.darkModeSnapshot(frameSize)
// 3. Switch theme underneath the freeze frame (invisible to user).
activateDarkMode = targetDark
try await Task.sleep(for: .seconds(0.01))
// 4. Capture new state asynchronously after layout settles.
Task {
try? await Task.sleep(for: .seconds(0.06))
window.layoutIfNeeded()
let currentImage = window.darkModeSnapshot(frameSize)
let swiftUIView = DarkModeOverlayView(
buttonRect: buttonRect,
@@ -97,6 +110,10 @@ struct DarkModeButton: View {
imageView.removeFromSuperview()
}
}
private var activeWindowScene: UIWindowScene? {
let scenes = UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }
return scenes.first(where: { $0.activationState == .foregroundActive }) ?? scenes.first
}
}
@@ -175,7 +192,6 @@ private struct DarkModeOverlayView: View {
for view in window.subviews {
view.removeFromSuperview()
}
window.isUserInteractionEnabled = false
}
}
}
@@ -198,10 +214,20 @@ private struct DarkModeOverlayView: View {
// MARK: - UIView Snapshot
private extension UIView {
/// Full-fidelity snapshot waits for pending layout. Use for the NEW state.
func darkModeSnapshot(_ size: CGSize) -> UIImage {
let renderer = UIGraphicsImageRenderer(size: size)
return renderer.image { _ in
drawHierarchy(in: .init(origin: .zero, size: size), afterScreenUpdates: true)
}
}
/// Fast snapshot of CURRENT appearance no layout wait (~5ms vs ~80ms).
/// Use for the OLD state (already on screen, nothing pending).
func darkModeSnapshotFast(_ size: CGSize) -> UIImage {
let renderer = UIGraphicsImageRenderer(size: size)
return renderer.image { _ in
drawHierarchy(in: .init(origin: .zero, size: size), afterScreenUpdates: false)
}
}
}

View File

@@ -54,11 +54,11 @@ private extension ConfirmSeedPhraseView {
VStack(spacing: 12) {
Text("Confirm Backup")
.font(.system(size: 28, weight: .bold))
.foregroundStyle(.white)
.foregroundStyle(RosettaColors.Adaptive.text)
Text("Enter words #2, #5, #9, #12 to confirm\nyou've backed up your phrase.")
.font(.system(size: 15))
.foregroundStyle(Color.white.opacity(0.7))
.foregroundStyle(RosettaColors.Adaptive.textSecondary)
.multilineTextAlignment(.center)
.lineSpacing(3)
}
@@ -130,7 +130,7 @@ private extension ConfirmSeedPhraseView {
TextField("enter", text: $confirmationInputs[inputIndex])
.font(.system(size: 17, weight: .semibold, design: .monospaced))
.foregroundStyle(.white)
.foregroundStyle(RosettaColors.Adaptive.text)
.autocorrectionDisabled()
.textInputAutocapitalization(.never)
.focused($focusedInputIndex, equals: inputIndex)

View File

@@ -53,11 +53,11 @@ private extension ImportSeedPhraseView {
VStack(spacing: 12) {
Text("Import Account")
.font(.system(size: 28, weight: .bold))
.foregroundStyle(.white)
.foregroundStyle(RosettaColors.Adaptive.text)
Text("Enter your 12-word recovery phrase\nto restore your account.")
.font(.system(size: 15))
.foregroundStyle(Color.white.opacity(0.7))
.foregroundStyle(RosettaColors.Adaptive.textSecondary)
.multilineTextAlignment(.center)
.lineSpacing(3)
}
@@ -140,7 +140,7 @@ private extension ImportSeedPhraseView {
TextField("word", text: $importedWords[index])
.font(.system(size: 17, weight: .semibold, design: .monospaced))
.foregroundStyle(hasContent ? color : .white)
.foregroundStyle(hasContent ? color : RosettaColors.Adaptive.text)
.autocorrectionDisabled()
.textInputAutocapitalization(.never)
.focused($focusedWordIndex, equals: index)

View File

@@ -52,7 +52,7 @@ struct PasswordStrengthIndicator: View {
HStack(spacing: 4) {
ForEach(0..<3, id: \.self) { index in
RoundedRectangle(cornerRadius: 2)
.fill(index <= strength.rawValue ? strength.color : Color.white.opacity(0.1))
.fill(index <= strength.rawValue ? strength.color : RosettaColors.Adaptive.text.opacity(0.1))
.frame(height: 4)
.animation(.easeInOut(duration: 0.25), value: strength)
}
@@ -107,7 +107,7 @@ struct WeakPasswordWarning: View {
Text("Your password is too weak. Consider using at least 6 characters for better security.")
.font(.system(size: 13))
.foregroundStyle(Color.white.opacity(0.7))
.foregroundStyle(RosettaColors.Adaptive.textSecondary)
.lineSpacing(2)
}
.padding(14)

View File

@@ -48,13 +48,13 @@ private extension SeedPhraseView {
VStack(spacing: 12) {
Text("Your Recovery Phrase")
.font(.system(size: 28, weight: .bold))
.foregroundStyle(.white)
.foregroundStyle(RosettaColors.Adaptive.text)
.opacity(isContentVisible ? 1.0 : 0.0)
.animation(.easeOut(duration: 0.3), value: isContentVisible)
Text("Write down these 12 words in order.\nYou'll need them to restore your account.")
.font(.system(size: 15))
.foregroundStyle(Color.white.opacity(0.7))
.foregroundStyle(RosettaColors.Adaptive.textSecondary)
.multilineTextAlignment(.center)
.lineSpacing(3)
.opacity(isContentVisible ? 1.0 : 0.0)

View File

@@ -111,13 +111,13 @@ private extension SetPasswordView {
VStack(spacing: 8) {
Text(isImportMode ? "Recover Account" : "Protect Your Account")
.font(.system(size: 24, weight: .bold))
.foregroundStyle(.white)
.foregroundStyle(RosettaColors.Adaptive.text)
Text(isImportMode
? "Set a password to protect your recovered account.\nYou'll need it to unlock Rosetta."
: "This password encrypts your keys locally.\nYou'll need it to unlock Rosetta.")
.font(.system(size: 14))
.foregroundStyle(Color.white.opacity(0.7))
.foregroundStyle(RosettaColors.Adaptive.textSecondary)
.multilineTextAlignment(.center)
.lineSpacing(2)
}
@@ -178,7 +178,7 @@ private extension SetPasswordView {
Text("Your password is never stored or sent anywhere. It's only used to encrypt your keys locally.")
.font(.system(size: 13))
.foregroundStyle(Color.white.opacity(0.7))
.foregroundStyle(RosettaColors.Adaptive.textSecondary)
.lineSpacing(2)
}
.padding(16)
@@ -291,7 +291,7 @@ private struct SecureToggleField: UIViewRepresentable {
tf.isSecureTextEntry = true
tf.font = .systemFont(ofSize: 16)
tf.textColor = .white
tf.textColor = .label
tf.tintColor = UIColor(RosettaColors.primaryBlue)
tf.autocapitalizationType = .none
tf.autocorrectionType = .no
@@ -318,7 +318,7 @@ private struct SecureToggleField: UIViewRepresentable {
let config = UIImage.SymbolConfiguration(pointSize: 14, weight: .thin)
let eyeButton = UIButton(type: .system)
eyeButton.setImage(UIImage(systemName: "eye", withConfiguration: config), for: .normal)
eyeButton.tintColor = UIColor.white.withAlphaComponent(0.5)
eyeButton.tintColor = UIColor.secondaryLabel
eyeButton.addTarget(
context.coordinator,
action: #selector(Coordinator.toggleSecure),

View File

@@ -80,7 +80,7 @@ private extension WelcomeView {
var titleSection: some View {
Text("Your Keys,\nYour Messages")
.font(.system(size: 32, weight: .bold))
.foregroundStyle(.white)
.foregroundStyle(RosettaColors.Adaptive.text)
.multilineTextAlignment(.center)
.opacity(isVisible ? 1.0 : 0.0)
.offset(y: isVisible ? 0 : 16)
@@ -90,7 +90,7 @@ private extension WelcomeView {
var subtitleSection: some View {
Text("Secure messaging with\ncryptographic keys")
.font(.system(size: 16))
.foregroundStyle(Color.white.opacity(0.7))
.foregroundStyle(RosettaColors.Adaptive.textSecondary)
.multilineTextAlignment(.center)
.opacity(isVisible ? 1.0 : 0.0)
.offset(y: isVisible ? 0 : 12)
@@ -122,7 +122,7 @@ private extension WelcomeView {
Text(label)
.font(.system(size: 13, weight: .medium))
.foregroundStyle(Color.white.opacity(0.7))
.foregroundStyle(RosettaColors.Adaptive.textSecondary)
}
.accessibilityElement(children: .combine)
.accessibilityLabel(label)

View File

@@ -132,7 +132,7 @@ struct AttachmentPanelView: View {
VStack(spacing: 0) {
// Custom drag indicator (replaces system .presentationDragIndicator)
Capsule()
.fill(Color.white.opacity(0.3))
.fill(RosettaColors.Adaptive.text.opacity(0.3))
.frame(width: 36, height: 5)
.padding(.top, 5)
.padding(.bottom, 14)
@@ -144,7 +144,7 @@ struct AttachmentPanelView: View {
dismiss()
} label: {
CloseIconShape()
.fill(.white)
.fill(RosettaColors.Adaptive.text)
.frame(width: 14, height: 14)
.frame(width: 44, height: 44)
}
@@ -157,10 +157,10 @@ struct AttachmentPanelView: View {
HStack(spacing: 5) {
Text(tabTitle)
.font(.system(size: 20, weight: .semibold))
.foregroundStyle(.white)
.foregroundStyle(RosettaColors.Adaptive.text)
Image(systemName: "chevron.down")
.font(.system(size: 13, weight: .bold))
.foregroundStyle(.white.opacity(0.45))
.foregroundStyle(RosettaColors.Adaptive.text.opacity(0.45))
}
Spacer()
@@ -195,9 +195,9 @@ struct AttachmentPanelView: View {
Text("\(selectedAssets.count)")
.font(.system(size: 12, weight: .bold))
}
.foregroundStyle(.white)
.foregroundStyle(.white) // White on blue badge is always correct
.frame(width: 44, height: 28)
.background(Color(hex: 0x008BFF), in: Capsule())
.background(RosettaColors.primaryBlue, in: Capsule())
}
/// Glass circle background matching GlassBackButton (ButtonStyles.swift lines 2234).
@@ -233,12 +233,12 @@ struct AttachmentPanelView: View {
// Title
Text("Send Avatar")
.font(.system(size: 20, weight: .semibold))
.foregroundStyle(.white)
.foregroundStyle(RosettaColors.Adaptive.text)
// Subtitle
Text("Share your profile avatar\nwith this contact")
.font(.system(size: 14))
.foregroundStyle(.white.opacity(0.5))
.foregroundStyle(RosettaColors.Adaptive.textSecondary)
.multilineTextAlignment(.center)
// Send button (capsule style matching File tab's "Browse Files" button)
@@ -253,10 +253,10 @@ struct AttachmentPanelView: View {
} label: {
Text(hasAvatar ? "Send Avatar" : "Set Avatar")
.font(.system(size: 15, weight: .medium))
.foregroundStyle(.white)
.foregroundStyle(.white) // White on blue button is always correct
.padding(.horizontal, 24)
.padding(.vertical, 10)
.background(Color(hex: 0x008BFF), in: Capsule())
.background(RosettaColors.primaryBlue, in: Capsule())
}
Spacer()
@@ -284,12 +284,12 @@ struct AttachmentPanelView: View {
// Title
Text("Send File")
.font(.system(size: 20, weight: .semibold))
.foregroundStyle(.white)
.foregroundStyle(RosettaColors.Adaptive.text)
// Subtitle
Text("Select a file to send")
.font(.system(size: 14))
.foregroundStyle(.white.opacity(0.5))
.foregroundStyle(RosettaColors.Adaptive.textSecondary)
.multilineTextAlignment(.center)
// Browse button (capsule style matching avatar tab)
@@ -298,10 +298,10 @@ struct AttachmentPanelView: View {
} label: {
Text("Browse Files")
.font(.system(size: 15, weight: .medium))
.foregroundStyle(.white)
.foregroundStyle(.white) // White on blue button is always correct
.padding(.horizontal, 24)
.padding(.vertical, 10)
.background(Color(hex: 0x008BFF), in: Capsule())
.background(RosettaColors.primaryBlue, in: Capsule())
}
Spacer()
@@ -337,14 +337,14 @@ struct AttachmentPanelView: View {
}
.animation(.easeInOut(duration: 0.25), value: hasSelection)
.background {
// iOS < 26: gradient fade behind tab bar (dark theme).
// iOS < 26: gradient fade behind tab bar.
// iOS 26+: no gradient Liquid Glass pill is self-contained.
if #unavailable(iOS 26) {
LinearGradient(
stops: [
.init(color: .clear, location: 0),
.init(color: .black.opacity(0.6), location: 0.3),
.init(color: .black, location: 0.8),
.init(color: RosettaColors.Adaptive.surface.opacity(0.85), location: 0.3),
.init(color: RosettaColors.Adaptive.surface, location: 0.8),
],
startPoint: .top,
endPoint: .bottom
@@ -364,8 +364,8 @@ struct AttachmentPanelView: View {
// Caption text field
TextField("Add a caption...", text: $captionText)
.font(.system(size: 16))
.foregroundStyle(.white)
.tint(Color(hex: 0x008BFF))
.foregroundStyle(RosettaColors.Adaptive.text)
.tint(RosettaColors.primaryBlue)
.padding(.leading, 6)
// Emoji icon (exact ChatDetail match: TelegramVectorIcon emojiMoon)
@@ -389,7 +389,7 @@ struct AttachmentPanelView: View {
)
.frame(width: 22, height: 19)
.frame(width: 38, height: 36)
.background { Capsule().fill(Color(hex: 0x008BFF)) }
.background { Capsule().fill(RosettaColors.primaryBlue) }
}
}
.padding(3)
@@ -457,7 +457,7 @@ struct AttachmentPanelView: View {
private func legacyTabButton(_ tab: AttachmentTab, icon: String, unselectedIcon: String, label: String) -> some View {
let isSelected = selectedTab == tab
let tint = isSelected ? Color(hex: 0x008BFF) : .white
let tint = isSelected ? RosettaColors.primaryBlue : RosettaColors.Adaptive.text
return Button {
UIImpactFeedbackGenerator(style: .light).impactOccurred()

View File

@@ -66,6 +66,10 @@ final class ComposerView: UIView, UITextViewDelegate {
// Mic button (glass circle, 42×42)
private let micButton = UIButton(type: .system)
private let micGlass = TelegramGlassUIView(frame: .zero)
private var attachIconLayer: CAShapeLayer?
private var emojiIconLayer: CAShapeLayer?
private var sendIconLayer: CAShapeLayer?
private var micIconLayer: CAShapeLayer?
// MARK: - Layout Constants
@@ -113,6 +117,7 @@ final class ComposerView: UIView, UITextViewDelegate {
color: .label
)
attachButton.layer.addSublayer(attachIcon)
attachIconLayer = attachIcon
attachButton.tag = 1 // for icon centering in layoutSubviews
attachButton.addTarget(self, action: #selector(attachTapped), for: .touchUpInside)
addSubview(attachButton)
@@ -188,6 +193,7 @@ final class ComposerView: UIView, UITextViewDelegate {
color: .secondaryLabel
)
emojiButton.layer.addSublayer(emojiIcon)
emojiIconLayer = emojiIcon
emojiButton.tag = 2
emojiButton.addTarget(self, action: #selector(emojiTapped), for: .touchUpInside)
inputContainer.addSubview(emojiButton)
@@ -204,6 +210,7 @@ final class ComposerView: UIView, UITextViewDelegate {
color: .white
)
sendButton.layer.addSublayer(sendIcon)
sendIconLayer = sendIcon
sendButton.tag = 3
sendButton.addTarget(self, action: #selector(sendTapped), for: .touchUpInside)
sendButton.alpha = 0
@@ -221,9 +228,19 @@ final class ComposerView: UIView, UITextViewDelegate {
color: .label
)
micButton.layer.addSublayer(micIcon)
micIconLayer = micIcon
micButton.tag = 4
micButton.addTarget(self, action: #selector(micTapped), for: .touchUpInside)
addSubview(micButton)
updateThemeColors()
if #available(iOS 17.0, *) {
registerForTraitChanges([UITraitUserInterfaceStyle.self]) { (self: ComposerView, previousTraitCollection: UITraitCollection) in
guard self.traitCollection.userInterfaceStyle != previousTraitCollection.userInterfaceStyle else { return }
self.updateThemeColors()
}
}
}
// MARK: - Public API
@@ -426,6 +443,19 @@ final class ComposerView: UIView, UITextViewDelegate {
}
}
private func updateThemeColors() {
attachIconLayer?.fillColor = UIColor.label.cgColor
emojiIconLayer?.fillColor = UIColor.secondaryLabel.cgColor
sendIconLayer?.fillColor = UIColor.white.cgColor
micIconLayer?.fillColor = UIColor.label.cgColor
replyTitleLabel.textColor = .label
replyPreviewLabel.textColor = .label
replyCancelButton.tintColor = .secondaryLabel
textView.textColor = .label
textView.placeholderLabel.textColor = .placeholderText
}
// MARK: - Text Height
private func recalculateTextHeight() {
@@ -498,6 +528,12 @@ final class ComposerView: UIView, UITextViewDelegate {
}
}
override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
if #available(iOS 17.0, *) { return }
guard traitCollection.userInterfaceStyle != previousTraitCollection?.userInterfaceStyle else { return }
updateThemeColors()
}
// MARK: - UITextViewDelegate
func textViewShouldBeginEditing(_ textView: UITextView) -> Bool {

View File

@@ -66,13 +66,46 @@ final class NativeMessageCell: UICollectionViewCell {
ctx.fillPath()
let image = UIGraphicsGetImageFromCurrentImageContext() ?? UIImage()
UIGraphicsEndImageContext()
return image.withRenderingMode(.alwaysOriginal)
return image.withRenderingMode(.alwaysTemplate)
}()
// Telegram-exact stretchable bubble images (raster, not vector only way to get exact tail)
private static let bubbleImages = BubbleImageFactory.generate(
// Telegram-exact stretchable bubble images (raster, not vector only way to get exact tail).
// `var` so they can be regenerated on theme switch (colors baked into raster at generation time).
private static var bubbleImages = BubbleImageFactory.generate(
outgoingColor: outgoingColor,
incomingColor: incomingColor
)
private static var bubbleImagesStyle: UIUserInterfaceStyle = .unspecified
/// Regenerate cached bubble images after theme change.
/// Must be called on main thread. `performAsCurrent` ensures dynamic
/// `incomingColor` resolves with the correct light/dark traits.
static func regenerateBubbleImages(with traitCollection: UITraitCollection) {
traitCollection.performAsCurrent {
bubbleImages = BubbleImageFactory.generate(
outgoingColor: outgoingColor,
incomingColor: incomingColor
)
}
bubbleImagesStyle = normalizedInterfaceStyle(from: traitCollection)
}
/// Ensure bubble image cache matches the current interface style.
/// Covers cases where the theme was changed while chat screen was not mounted.
static func ensureBubbleImages(for traitCollection: UITraitCollection) {
let style = normalizedInterfaceStyle(from: traitCollection)
guard bubbleImagesStyle != style else { return }
regenerateBubbleImages(with: traitCollection)
}
private static func normalizedInterfaceStyle(from traitCollection: UITraitCollection) -> UIUserInterfaceStyle {
switch traitCollection.userInterfaceStyle {
case .dark, .light:
return traitCollection.userInterfaceStyle
default:
let prefersDark = UserDefaults.standard.object(forKey: "rosetta_dark_mode") as? Bool ?? true
return prefersDark ? .dark : .light
}
}
private static let blurHashCache: NSCache<NSString, UIImage> = {
let cache = NSCache<NSString, UIImage>()
cache.countLimit = 200
@@ -456,13 +489,20 @@ final class NativeMessageCell: UICollectionViewCell {
bubbleView.addSubview(highlightOverlay)
// Swipe reply icon circle + Telegram-exact arrow (same vector as SwiftUI SwipeToReplyModifier)
replyCircleView.backgroundColor = UIColor.white.withAlphaComponent(0.12)
replyCircleView.backgroundColor = UIColor { traits in
traits.userInterfaceStyle == .dark
? UIColor.white.withAlphaComponent(0.12)
: UIColor.black.withAlphaComponent(0.12)
}
replyCircleView.layer.cornerRadius = 17 // 34pt / 2
replyCircleView.alpha = 0
contentView.addSubview(replyCircleView)
replyIconView.image = Self.telegramReplyArrowImage
replyIconView.contentMode = .scaleAspectFit
replyIconView.tintColor = UIColor { traits in
traits.userInterfaceStyle == .dark ? .white : .black
}
replyIconView.alpha = 0
contentView.addSubview(replyIconView)
@@ -513,17 +553,19 @@ final class NativeMessageCell: UICollectionViewCell {
// Same CTTypesetter pipeline identical line breaks, zero recomputation.
textLabel.textLayout = textLayout
// Timestamp
// Timestamp dynamic UIColor for incoming so theme changes resolve instantly
timestampLabel.text = timestamp
if isMediaStatus {
timestampLabel.textColor = .white
} else if isOutgoing {
timestampLabel.textColor = UIColor.white.withAlphaComponent(0.55)
} else {
let isDark = traitCollection.userInterfaceStyle == .dark
let tsAlpha: CGFloat = isOutgoing ? 0.55 : 0.6
timestampLabel.textColor = (isOutgoing || isDark)
? UIColor.white.withAlphaComponent(tsAlpha)
timestampLabel.textColor = UIColor { traits in
traits.userInterfaceStyle == .dark
? UIColor.white.withAlphaComponent(0.6)
: UIColor.black.withAlphaComponent(0.45)
}
}
// Delivery checkmarks (Telegram two-node pattern: checkSent + checkRead)
stopSendingClockAnimation()
@@ -566,7 +608,6 @@ final class NativeMessageCell: UICollectionViewCell {
// Reply quote Telegram parity colors
if let replyName {
replyContainer.isHidden = false
let isDark = traitCollection.userInterfaceStyle == .dark
replyContainer.backgroundColor = isOutgoing
? UIColor.white.withAlphaComponent(0.12)
: Self.outgoingColor.withAlphaComponent(0.12)
@@ -574,7 +615,11 @@ final class NativeMessageCell: UICollectionViewCell {
replyNameLabel.text = replyName
replyNameLabel.textColor = isOutgoing ? .white : Self.outgoingColor
replyTextLabel.text = replyText ?? ""
replyTextLabel.textColor = (isOutgoing || isDark) ? .white : .darkGray
replyTextLabel.textColor = isOutgoing
? .white
: UIColor { traits in
traits.userInterfaceStyle == .dark ? .white : .darkGray
}
} else {
replyContainer.isHidden = true
}
@@ -786,6 +831,7 @@ final class NativeMessageCell: UICollectionViewCell {
override func layoutSubviews() {
super.layoutSubviews()
guard let layout = currentLayout else { return }
Self.ensureBubbleImages(for: traitCollection)
let cellW = contentView.bounds.width
let tailProtrusion = Self.bubbleMetrics.tailProtrusion

View File

@@ -183,6 +183,15 @@ final class NativeMessageListController: UIViewController {
self, selector: #selector(handleAvatarDidUpdate),
name: Notification.Name("avatarDidUpdate"), object: nil
)
// Regenerate bubble images + full cell refresh on theme switch.
registerForTraitChanges([UITraitUserInterfaceStyle.self]) { (self: NativeMessageListController, previousTraitCollection: UITraitCollection) in
let oldStyle = previousTraitCollection.userInterfaceStyle
let newStyle = self.traitCollection.userInterfaceStyle
guard oldStyle != newStyle else { return }
NativeMessageCell.regenerateBubbleImages(with: self.traitCollection)
self.calculateLayouts()
self.refreshAllMessageCells()
}
}
@objc private func handleAvatarDidUpdate() {
@@ -483,6 +492,9 @@ final class NativeMessageListController: UIViewController {
let imageView = UIImageView(image: Self.makeTelegramDownButtonImage())
imageView.contentMode = .center
imageView.frame = rect
imageView.tintColor = UIColor { traits in
traits.userInterfaceStyle == .dark ? .white : .black
}
button.addSubview(imageView)
let badgeView = UIView(frame: .zero)
@@ -585,7 +597,7 @@ final class NativeMessageListController: UIViewController {
gc.addLine(to: CGPoint(x: cx, y: cy + 4.5)) // bottom-center
gc.addLine(to: CGPoint(x: cx + 9.0, y: cy - 4.5)) // top-right
gc.strokePath()
}.withRenderingMode(.alwaysOriginal)
}.withRenderingMode(.alwaysTemplate)
}
private static func compactUnreadCountString(_ count: Int) -> String {
@@ -614,7 +626,9 @@ final class NativeMessageListController: UIViewController {
c.addSubview(glass)
let l = UILabel()
l.font = UIFont.systemFont(ofSize: 12, weight: .medium)
l.textColor = .white
l.textColor = UIColor { traits in
traits.userInterfaceStyle == .dark ? .white : .black
}
l.textAlignment = .center
c.addSubview(l)
return (c, l)
@@ -1071,6 +1085,18 @@ final class NativeMessageListController: UIViewController {
dataSource.apply(snapshot, animatingDifferences: false)
}
/// Reconfigure all message cells and force a layout pass.
/// Used for deterministic theme refresh when trait style changes.
private func refreshAllMessageCells() {
var snapshot = dataSource.snapshot()
let allIds = snapshot.itemIdentifiers
guard !allIds.isEmpty else { return }
snapshot.reconfigureItems(allIds)
dataSource.apply(snapshot, animatingDifferences: false)
collectionView.collectionViewLayout.invalidateLayout()
collectionView.layoutIfNeeded()
}
// MARK: - Bubble Position
private func bubblePosition(for message: ChatMessage, at reversedIndex: Int) -> BubblePosition {

View File

@@ -18,13 +18,25 @@ final class TelegramContextMenuCardView: UIView {
private static let iconSize: CGFloat = 24
private static let screenPixel = 1.0 / max(UIScreen.main.scale, 1)
// MARK: - Colors (Telegram dark theme)
// MARK: - Colors (adaptive light/dark)
private static let tintBg = UIColor(red: 0x25/255, green: 0x25/255, blue: 0x25/255, alpha: 0.78)
private static let textColor = UIColor.white
private static let tintBg = UIColor { traits in
traits.userInterfaceStyle == .dark
? UIColor(red: 0x25/255, green: 0x25/255, blue: 0x25/255, alpha: 0.78)
: UIColor(red: 0xF5/255, green: 0xF5/255, blue: 0xF7/255, alpha: 0.78)
}
private static let textColor = UIColor.label
private static let destructiveColor = UIColor(red: 0xEB/255, green: 0x55/255, blue: 0x45/255, alpha: 1)
private static let separatorColor = UIColor(white: 1, alpha: 0.15)
private static let highlightColor = UIColor(white: 1, alpha: 0.15)
private static let separatorColor = UIColor { traits in
traits.userInterfaceStyle == .dark
? UIColor(white: 1, alpha: 0.15)
: UIColor(white: 0, alpha: 0.1)
}
private static let highlightColor = UIColor { traits in
traits.userInterfaceStyle == .dark
? UIColor(white: 1, alpha: 0.15)
: UIColor(white: 0, alpha: 0.08)
}
// MARK: - Public
@@ -33,7 +45,7 @@ final class TelegramContextMenuCardView: UIView {
// MARK: - Views
private let blurView = UIVisualEffectView(effect: UIBlurEffect(style: .systemMaterialDark))
private let blurView = UIVisualEffectView(effect: UIBlurEffect(style: .systemMaterial))
private let tintView = UIView()
private let items: [TelegramContextMenuItem]

View File

@@ -530,12 +530,13 @@ extension AppDelegate: PKPushRegistryDelegate {
}
let data = payload.dictionaryPayload
Logger.voip.info("VoIP push received: \(data.description, privacy: .public)")
// Server sends: { "type": "CALL", "from": "<pubkey>", "callId": "<uuid>" }
// Server sends: { "type": "CALL", "from": "<pubkey>", "callId": "<uuid>", "joinToken": "<token>" }
// Fallback to "dialog" for backward compat with older server versions.
let callerKey = data["from"] as? String
?? data["dialog"] as? String
?? ""
let callId = data["callId"] as? String
let joinToken = data["joinToken"] as? String
// Resolve caller display name from multiple sources.
let callerName: String = {
// 1. Push payload (if server sends title)
@@ -554,7 +555,7 @@ extension AppDelegate: PKPushRegistryDelegate {
}
return "Rosetta"
}()
Logger.voip.info("VoIP resolved: key=\(callerKey.prefix(16), privacy: .public) name=\(callerName, privacy: .public) callId=\(callId ?? "nil", privacy: .public)")
Logger.voip.info("VoIP resolved: key=\(callerKey.prefix(16), privacy: .public) name=\(callerName, privacy: .public) callId=\(callId ?? "nil", privacy: .public) joinTokenPresent=\((joinToken?.isEmpty == false).description, privacy: .public)")
// Apple REQUIREMENT: reportNewIncomingCall MUST be called SYNCHRONOUSLY.
// Using Task { @MainActor } would introduce an async hop that may be
@@ -597,7 +598,9 @@ extension AppDelegate: PKPushRegistryDelegate {
}
CallManager.shared.setupIncomingCallFromPush(
callerKey: callerKey,
callerName: callerName
callerName: callerName,
callId: callId,
joinToken: joinToken
)
}
@@ -671,7 +674,6 @@ struct RosettaApp: App {
@AppStorage("hasCompletedOnboarding") private var hasCompletedOnboarding = false
@AppStorage("isLoggedIn") private var isLoggedIn = false
@AppStorage("hasLaunchedBefore") private var hasLaunchedBefore = false
@AppStorage("rosetta_dark_mode") private var isDarkMode: Bool = true
@State private var appState: AppState?
@State private var transitionOverlay: Bool = false
@@ -695,7 +697,11 @@ struct RosettaApp: App {
.animation(.easeInOut(duration: 0.035), value: transitionOverlay)
}
}
.preferredColorScheme(isDarkMode ? .dark : .light)
// NOTE: preferredColorScheme removed DarkModeWrapper is the single
// source of truth via window.overrideUserInterfaceStyle. Having both
// caused snapshot races where the hosting controller's stale
// preferredColorScheme(.dark) blocked the window's .light override,
// making darklight circular reveal animation invisible.
.onAppear {
if appState == nil {
appState = initialState()

View File

@@ -2,12 +2,23 @@ import XCTest
@testable import Rosetta
final class CallPacketParityTests: XCTestCase {
func testSignalPeerRoundTripForCallKeyExchangeAndCreateRoom() throws {
func testSignalPeerRoundTripForLegacyCallAcceptEndCallAndKeyExchange() throws {
let call = PacketSignalPeer(
src: "02caller",
dst: "02callee",
sharedPublic: "",
signalType: .call,
callId: "call-123",
joinToken: "join-123",
roomId: ""
)
let accept = PacketSignalPeer(
src: "02callee",
dst: "02caller",
sharedPublic: "",
signalType: .accept,
callId: "call-123",
joinToken: "join-123",
roomId: ""
)
let keyExchange = PacketSignalPeer(
@@ -15,14 +26,18 @@ final class CallPacketParityTests: XCTestCase {
dst: "02caller",
sharedPublic: "abcdef012345",
signalType: .keyExchange,
callId: "",
joinToken: "",
roomId: ""
)
let createRoom = PacketSignalPeer(
let endCall = PacketSignalPeer(
src: "02caller",
dst: "02callee",
sharedPublic: "",
signalType: .createRoom,
roomId: "room-42"
signalType: .endCall,
callId: "call-123",
joinToken: "join-123",
roomId: ""
)
let decodedCall = try decodeSignal(call)
@@ -30,29 +45,67 @@ final class CallPacketParityTests: XCTestCase {
XCTAssertEqual(decodedCall.src, "02caller")
XCTAssertEqual(decodedCall.dst, "02callee")
XCTAssertEqual(decodedCall.sharedPublic, "")
XCTAssertEqual(decodedCall.callId, "call-123")
XCTAssertEqual(decodedCall.joinToken, "join-123")
XCTAssertEqual(decodedCall.roomId, "")
let decodedAccept = try decodeSignal(accept)
XCTAssertEqual(decodedAccept.signalType, .accept)
XCTAssertEqual(decodedAccept.callId, "call-123")
XCTAssertEqual(decodedAccept.joinToken, "join-123")
let decodedKeyExchange = try decodeSignal(keyExchange)
XCTAssertEqual(decodedKeyExchange.signalType, .keyExchange)
XCTAssertEqual(decodedKeyExchange.src, "02callee")
XCTAssertEqual(decodedKeyExchange.dst, "02caller")
XCTAssertEqual(decodedKeyExchange.sharedPublic, "abcdef012345")
XCTAssertEqual(decodedKeyExchange.callId, "")
XCTAssertEqual(decodedKeyExchange.joinToken, "")
XCTAssertEqual(decodedKeyExchange.roomId, "")
let decodedEndCall = try decodeSignal(endCall)
XCTAssertEqual(decodedEndCall.signalType, .endCall)
XCTAssertEqual(decodedEndCall.callId, "call-123")
XCTAssertEqual(decodedEndCall.joinToken, "join-123")
}
func testSignalCodeFourRoundTripForLegacyActiveAndCreateRoomFallback() throws {
let legacyActive = PacketSignalPeer(
src: "02caller",
dst: "02callee",
sharedPublic: "",
signalType: .createRoom,
callId: "",
joinToken: "",
roomId: ""
)
let createRoom = PacketSignalPeer(
src: "02caller",
dst: "02callee",
sharedPublic: "",
signalType: .createRoom,
callId: "",
joinToken: "",
roomId: "room-42"
)
let decodedLegacyActive = try decodeSignal(legacyActive)
XCTAssertEqual(decodedLegacyActive.signalType, .createRoom)
XCTAssertEqual(decodedLegacyActive.roomId, "")
let decodedCreateRoom = try decodeSignal(createRoom)
XCTAssertEqual(decodedCreateRoom.signalType, .createRoom)
XCTAssertEqual(decodedCreateRoom.src, "02caller")
XCTAssertEqual(decodedCreateRoom.dst, "02callee")
XCTAssertEqual(decodedCreateRoom.sharedPublic, "")
XCTAssertEqual(decodedCreateRoom.roomId, "room-42")
}
func testSignalPeerRoundTripForBusyAndPeerDisconnectedShortFormat() throws {
func testSignalPeerRoundTripForBusyPeerDisconnectedAndRingingTimeoutShortFormat() throws {
let busy = PacketSignalPeer(
src: "02should-not-be-sent",
dst: "02should-not-be-sent",
sharedPublic: "ignored",
signalType: .endCallBecauseBusy,
callId: "ignored",
joinToken: "ignored",
roomId: "ignored-room"
)
let disconnected = PacketSignalPeer(
@@ -60,6 +113,17 @@ final class CallPacketParityTests: XCTestCase {
dst: "02should-not-be-sent",
sharedPublic: "ignored",
signalType: .endCallBecausePeerDisconnected,
callId: "ignored",
joinToken: "ignored",
roomId: "ignored-room"
)
let ringingTimeout = PacketSignalPeer(
src: "02should-not-be-sent",
dst: "02should-not-be-sent",
sharedPublic: "ignored",
signalType: .ringingTimeout,
callId: "ignored",
joinToken: "ignored",
roomId: "ignored-room"
)
@@ -68,6 +132,8 @@ final class CallPacketParityTests: XCTestCase {
XCTAssertEqual(decodedBusy.src, "")
XCTAssertEqual(decodedBusy.dst, "")
XCTAssertEqual(decodedBusy.sharedPublic, "")
XCTAssertEqual(decodedBusy.callId, "")
XCTAssertEqual(decodedBusy.joinToken, "")
XCTAssertEqual(decodedBusy.roomId, "")
let decodedDisconnected = try decodeSignal(disconnected)
@@ -75,7 +141,18 @@ final class CallPacketParityTests: XCTestCase {
XCTAssertEqual(decodedDisconnected.src, "")
XCTAssertEqual(decodedDisconnected.dst, "")
XCTAssertEqual(decodedDisconnected.sharedPublic, "")
XCTAssertEqual(decodedDisconnected.callId, "")
XCTAssertEqual(decodedDisconnected.joinToken, "")
XCTAssertEqual(decodedDisconnected.roomId, "")
let decodedTimeout = try decodeSignal(ringingTimeout)
XCTAssertEqual(decodedTimeout.signalType, .ringingTimeout)
XCTAssertEqual(decodedTimeout.src, "")
XCTAssertEqual(decodedTimeout.dst, "")
XCTAssertEqual(decodedTimeout.sharedPublic, "")
XCTAssertEqual(decodedTimeout.callId, "")
XCTAssertEqual(decodedTimeout.joinToken, "")
XCTAssertEqual(decodedTimeout.roomId, "")
}
func testWebRtcRoundTripForOfferAnswerAndIceCandidate() throws {

View File

@@ -97,12 +97,14 @@ struct SignalPeerCallFlowTests {
let caller = "02a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2"
let callee = "03f0e1d2c3b4a5968778695a4b3c2d1e0f9e8d7c6b5a49382716051a2b3c4d5e6f"
let packet = PacketSignalPeer(src: caller, dst: callee, sharedPublic: "",
signalType: .call, roomId: "")
signalType: .call, callId: "call-1", joinToken: "join-1", roomId: "")
let decoded = try decode(packet)
#expect(decoded.signalType == .call)
#expect(decoded.src == caller)
#expect(decoded.dst == callee)
#expect(decoded.sharedPublic == "")
#expect(decoded.callId == "call-1")
#expect(decoded.joinToken == "join-1")
#expect(decoded.roomId == "")
}
@@ -153,6 +155,19 @@ struct SignalPeerCallFlowTests {
#expect(decoded.signalType == .endCallBecausePeerDisconnected)
}
@Test("RINGING_TIMEOUT short format — 3 bytes wire size")
func ringingTimeoutShortFormat() throws {
let packet = PacketSignalPeer(src: "ignored", dst: "ignored", sharedPublic: "ignored",
signalType: .ringingTimeout, roomId: "ignored")
let data = PacketRegistry.encode(packet)
#expect(data.count == 3)
let decoded = try decode(packet)
#expect(decoded.signalType == .ringingTimeout)
#expect(decoded.src == "")
#expect(decoded.dst == "")
}
private func decode(_ packet: PacketSignalPeer) throws -> PacketSignalPeer {
let data = PacketRegistry.encode(packet)
guard let result = PacketRegistry.decode(from: data),
@@ -171,7 +186,8 @@ struct CallPushEnumParityTests {
arguments: [
(SignalType.call, 0), (SignalType.keyExchange, 1), (SignalType.activeCall, 2),
(SignalType.endCall, 3), (SignalType.createRoom, 4),
(SignalType.endCallBecausePeerDisconnected, 5), (SignalType.endCallBecauseBusy, 6)
(SignalType.endCallBecausePeerDisconnected, 5), (SignalType.endCallBecauseBusy, 6),
(SignalType.accept, 7), (SignalType.ringingTimeout, 8)
])
func signalTypeEnumValues(pair: (SignalType, Int)) {
#expect(pair.0.rawValue == pair.1)
@@ -222,12 +238,12 @@ struct CallPushWireFormatTests {
#expect(data[14] == 0x00); #expect(data[15] == 0x42)
}
@Test("SignalPeer call byte layout: signalType→src→dst")
@Test("SignalPeer call byte layout: signalType→src→dst→callId→joinToken")
func signalPeerCallByteLayout() {
let packet = PacketSignalPeer(src: "S", dst: "D", sharedPublic: "",
signalType: .call, roomId: "")
let data = PacketRegistry.encode(packet)
#expect(data.count == 15)
#expect(data.count == 23)
// packetId = 0x001A
#expect(data[0] == 0x00); #expect(data[1] == 0x1A)
@@ -241,6 +257,12 @@ struct CallPushWireFormatTests {
#expect(data[9] == 0x00); #expect(data[10] == 0x00)
#expect(data[11] == 0x00); #expect(data[12] == 0x01)
#expect(data[13] == 0x00); #expect(data[14] == 0x44)
// callId "": length=0
#expect(data[15] == 0x00); #expect(data[16] == 0x00)
#expect(data[17] == 0x00); #expect(data[18] == 0x00)
// joinToken "": length=0
#expect(data[19] == 0x00); #expect(data[20] == 0x00)
#expect(data[21] == 0x00); #expect(data[22] == 0x00)
}
}

View File

@@ -1,3 +1,4 @@
import CryptoKit
import XCTest
@testable import Rosetta
@@ -101,4 +102,127 @@ final class CallRoutingTests: XCTestCase {
XCTAssertEqual(CallManager.shared.uiState.peerPublicKey, peerA)
XCTAssertEqual(CallManager.shared.uiState.statusText, "Calling...")
}
func testLegacyOutgoingFlowCallAcceptKeyExchangeActive() {
let start = CallManager.shared.startOutgoingCall(
toPublicKey: peerA,
title: "Peer A",
username: "peer_a"
)
XCTAssertEqual(start, .started)
let accept = PacketSignalPeer(
src: peerA,
dst: ownKey,
sharedPublic: "",
signalType: .accept,
callId: "call-legacy-1",
joinToken: "join-legacy-1",
roomId: ""
)
CallManager.shared.testHandleSignalPacket(accept)
XCTAssertEqual(CallManager.shared.signalingMode, .legacy)
XCTAssertEqual(CallManager.shared.callId, "call-legacy-1")
XCTAssertEqual(CallManager.shared.joinToken, "join-legacy-1")
XCTAssertEqual(CallManager.shared.uiState.phase, .keyExchange)
let keyExchange = PacketSignalPeer(
src: peerA,
dst: ownKey,
sharedPublic: makePeerPublicHex(),
signalType: .keyExchange,
roomId: ""
)
CallManager.shared.testHandleSignalPacket(keyExchange)
XCTAssertEqual(CallManager.shared.uiState.phase, .keyExchange)
// Legacy ACTIVE arrives as signal code 4 with empty roomId.
let active = PacketSignalPeer(
src: peerA,
dst: ownKey,
sharedPublic: "",
signalType: .createRoom,
roomId: ""
)
CallManager.shared.testHandleSignalPacket(active)
XCTAssertEqual(CallManager.shared.uiState.phase, .webRtcExchange)
}
func testCreateRoomFallbackOutgoingFlowKeyExchangeBeforeAccept() {
let start = CallManager.shared.startOutgoingCall(
toPublicKey: peerA,
title: "Peer A",
username: "peer_a"
)
XCTAssertEqual(start, .started)
let keyExchange = PacketSignalPeer(
src: peerA,
dst: ownKey,
sharedPublic: makePeerPublicHex(),
signalType: .keyExchange,
roomId: ""
)
CallManager.shared.testHandleSignalPacket(keyExchange)
XCTAssertEqual(CallManager.shared.signalingMode, .createRoom)
XCTAssertEqual(CallManager.shared.uiState.phase, .webRtcExchange)
}
func testDeferredAcceptCompletesWhenMetadataArrivesLater() {
CallManager.shared.setupIncomingCallFromPush(
callerKey: peerA,
callerName: "Peer A"
)
XCTAssertEqual(CallManager.shared.uiState.phase, .incoming)
let acceptResult = CallManager.shared.acceptIncomingCall()
XCTAssertEqual(acceptResult, .started)
XCTAssertTrue(CallManager.shared.pendingIncomingAccept)
XCTAssertEqual(CallManager.shared.uiState.phase, .keyExchange)
XCTAssertEqual(CallManager.shared.signalingMode, .undecided)
let delayedCall = PacketSignalPeer(
src: peerA,
dst: ownKey,
sharedPublic: "",
signalType: .call,
callId: "call-delayed-1",
joinToken: "join-delayed-1",
roomId: ""
)
CallManager.shared.testHandleSignalPacket(delayedCall)
XCTAssertEqual(CallManager.shared.signalingMode, .legacy)
XCTAssertFalse(CallManager.shared.pendingIncomingAccept)
XCTAssertEqual(CallManager.shared.callId, "call-delayed-1")
XCTAssertEqual(CallManager.shared.joinToken, "join-delayed-1")
XCTAssertEqual(CallManager.shared.uiState.phase, .keyExchange)
}
func testRingingTimeoutSignalTearsDownCall() {
let start = CallManager.shared.startOutgoingCall(
toPublicKey: peerA,
title: "Peer A",
username: "peer_a"
)
XCTAssertEqual(start, .started)
let timeoutPacket = PacketSignalPeer(
src: "",
dst: "",
sharedPublic: "",
signalType: .ringingTimeout,
roomId: ""
)
CallManager.shared.testHandleSignalPacket(timeoutPacket)
XCTAssertEqual(CallManager.shared.uiState.phase, .idle)
XCTAssertEqual(CallManager.shared.uiState.statusText, "No answer")
}
private func makePeerPublicHex() -> String {
Curve25519.KeyAgreement.PrivateKey().publicKey.rawRepresentation.hexString
}
}