Исправление аватарки на экране разблокировки, плавная анимация инпута, онлайн-статус по входящим сообщениям, push-навигация в чат, оптимизация debug-логов

This commit is contained in:
2026-03-13 00:12:30 +05:00
parent 70deaaf7f7
commit c7bea82c3a
30 changed files with 1245 additions and 270 deletions

1
.gitignore vendored
View File

@@ -6,6 +6,7 @@ sprints/
CLAUDE.md
.claude.local.md
desktop
AGENTS.md
# Xcode
build/

View File

@@ -272,7 +272,7 @@
CODE_SIGN_ENTITLEMENTS = Rosetta/Rosetta.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 10;
CURRENT_PROJECT_VERSION = 11;
DEVELOPMENT_TEAM = QN8Z263QGX;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
@@ -288,7 +288,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0.9;
MARKETING_VERSION = 1.0.10;
PRODUCT_BUNDLE_IDENTIFIER = com.rosetta.dev;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
@@ -311,7 +311,7 @@
CODE_SIGN_ENTITLEMENTS = Rosetta/Rosetta.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 10;
CURRENT_PROJECT_VERSION = 11;
DEVELOPMENT_TEAM = QN8Z263QGX;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
@@ -327,7 +327,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0.9;
MARKETING_VERSION = 1.0.10;
PRODUCT_BUNDLE_IDENTIFIER = com.rosetta.dev;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";

View File

@@ -31,7 +31,7 @@
shouldAutocreateTestPlan = "YES">
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
buildConfiguration = "Release"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"

View File

@@ -81,7 +81,7 @@ final class CryptoManager: @unchecked Sendable {
let compressed = try CryptoPrimitives.rawDeflate(data)
let key = CryptoPrimitives.pbkdf2(
password: password, salt: "rosetta", iterations: 1000,
keyLength: 32, prf: CCPseudoRandomAlgorithm(kCCPRFHmacAlgSHA1)
keyLength: 32, prf: CCPseudoRandomAlgorithm(kCCPRFHmacAlgSHA256)
)
let iv = try CryptoPrimitives.randomBytes(count: 16)
let ciphertext = try CryptoPrimitives.aesCBCEncrypt(compressed, key: key, iv: iv)

View File

@@ -21,17 +21,34 @@ actor ChatPersistenceStore {
rootDirectory = directory
}
func load<T: Decodable>(_ type: T.Type, fileName: String) -> T? {
func load<T: Decodable>(_ type: T.Type, fileName: String, password: String? = nil) -> T? {
let fileURL = rootDirectory.appendingPathComponent(fileName, isDirectory: false)
guard FileManager.default.fileExists(atPath: fileURL.path) else { return nil }
guard let data = try? Data(contentsOf: fileURL) else { return nil }
if let password,
let encryptedSnapshot = String(data: data, encoding: .utf8),
let decrypted = try? CryptoManager.shared.decryptWithPassword(encryptedSnapshot, password: password),
let decoded = try? decoder.decode(type, from: decrypted) {
return decoded
}
return try? decoder.decode(type, from: data)
}
func save<T: Encodable>(_ value: T, fileName: String) {
func save<T: Encodable>(_ value: T, fileName: String, password: String? = nil) {
let fileURL = rootDirectory.appendingPathComponent(fileName, isDirectory: false)
guard let data = try? encoder.encode(value) else { return }
try? data.write(to: fileURL, options: [.atomic])
let payload: Data
if let password,
let encryptedSnapshot = try? CryptoManager.shared.encryptWithPassword(data, password: password),
let encryptedData = encryptedSnapshot.data(using: .utf8) {
payload = encryptedData
} else {
payload = data
}
try? payload.write(
to: fileURL,
options: [.atomic, .completeFileProtectionUntilFirstUserAuthentication]
)
}
func remove(fileName: String) {

View File

@@ -12,6 +12,7 @@ final class DialogRepository {
didSet { _sortedDialogsCache = nil }
}
private var currentAccount: String = ""
private var storagePassword: String = ""
private var persistTask: Task<Void, Never>?
private var _sortedDialogsCache: [Dialog]?
@@ -27,23 +28,30 @@ final class DialogRepository {
private init() {}
func bootstrap(accountPublicKey: String) async {
func bootstrap(accountPublicKey: String, storagePassword: String) async {
let account = accountPublicKey.trimmingCharacters(in: .whitespacesAndNewlines)
guard !account.isEmpty else {
reset()
return
}
if currentAccount == account, !dialogs.isEmpty {
if currentAccount == account,
self.storagePassword == storagePassword,
!dialogs.isEmpty {
return
}
currentAccount = account
self.storagePassword = storagePassword
persistTask?.cancel()
persistTask = nil
let fileName = Self.dialogsFileName(for: account)
let stored = await ChatPersistenceStore.shared.load([Dialog].self, fileName: fileName) ?? []
let stored = await ChatPersistenceStore.shared.load(
[Dialog].self,
fileName: fileName,
password: storagePassword
) ?? []
dialogs = Dictionary(
uniqueKeysWithValues: stored
.filter { $0.account == account }
@@ -55,6 +63,7 @@ final class DialogRepository {
persistTask?.cancel()
persistTask = nil
dialogs.removeAll()
storagePassword = ""
guard !currentAccount.isEmpty else { return }
let accountToReset = currentAccount
@@ -186,15 +195,19 @@ final class DialogRepository {
func updateDeliveryStatus(messageId: String, opponentKey: String, status: DeliveryStatus) {
guard var dialog = dialogs[opponentKey] else { return }
let current = dialog.lastMessageDelivered
if current == .read, status == .delivered {
return
}
if current == .read, status == .waiting {
return
}
if current == .delivered, status == .waiting {
if current == status { return }
// Desktop parity: desktop reads the actual last message from the DB at
// render time (useDialogInfo SELECT * FROM messages WHERE message_id = ?).
// On iOS we cache lastMessageDelivered, so we must only accept updates
// from the latest outgoing message to avoid stale ACKs / error timers
// from older messages overwriting the indicator.
let messages = MessageRepository.shared.messages(for: opponentKey)
if let lastOutgoing = messages.last(where: { $0.fromPublicKey == dialog.account }),
lastOutgoing.id != messageId {
return
}
dialog.lastMessageDelivered = status
dialogs[opponentKey] = dialog
schedulePersist()
@@ -347,11 +360,16 @@ final class DialogRepository {
let snapshot = Array(dialogs.values)
let fileName = Self.dialogsFileName(for: currentAccount)
let storagePassword = self.storagePassword
persistTask?.cancel()
persistTask = Task(priority: .utility) {
try? await Task.sleep(for: .milliseconds(180))
guard !Task.isCancelled else { return }
await ChatPersistenceStore.shared.save(snapshot, fileName: fileName)
await ChatPersistenceStore.shared.save(
snapshot,
fileName: fileName,
password: storagePassword.isEmpty ? nil : storagePassword
)
}
}

View File

@@ -16,21 +16,25 @@ final class MessageRepository: ObservableObject {
private var typingResetTasks: [String: Task<Void, Never>] = [:]
private var persistTask: Task<Void, Never>?
private var currentAccount: String = ""
private var storagePassword: String = ""
private init() {}
func bootstrap(accountPublicKey: String) async {
func bootstrap(accountPublicKey: String, storagePassword: String) async {
let account = accountPublicKey.trimmingCharacters(in: .whitespacesAndNewlines)
guard !account.isEmpty else {
reset()
return
}
if currentAccount == account, !messagesByDialog.isEmpty {
if currentAccount == account,
self.storagePassword == storagePassword,
!messagesByDialog.isEmpty {
return
}
currentAccount = account
self.storagePassword = storagePassword
persistTask?.cancel()
persistTask = nil
activeDialogs.removeAll()
@@ -42,7 +46,11 @@ final class MessageRepository: ObservableObject {
messageToDialog.removeAll()
let fileName = Self.messagesFileName(for: account)
let stored = await ChatPersistenceStore.shared.load([String: [ChatMessage]].self, fileName: fileName) ?? [:]
let stored = await ChatPersistenceStore.shared.load(
[String: [ChatMessage]].self,
fileName: fileName,
password: storagePassword
) ?? [:]
var restored: [String: [ChatMessage]] = [:]
for (dialogKey, list) in stored {
var sorted = list.sorted {
@@ -278,6 +286,7 @@ final class MessageRepository: ObservableObject {
typingDialogs.removeAll()
activeDialogs.removeAll()
messageToDialog.removeAll()
storagePassword = ""
guard !currentAccount.isEmpty else { return }
let accountToReset = currentAccount
@@ -327,11 +336,16 @@ final class MessageRepository: ObservableObject {
let snapshot = messagesByDialog
let fileName = Self.messagesFileName(for: currentAccount)
let storagePassword = self.storagePassword
persistTask?.cancel()
persistTask = Task(priority: .utility) {
try? await Task.sleep(for: .milliseconds(220))
guard !Task.isCancelled else { return }
await ChatPersistenceStore.shared.save(snapshot, fileName: fileName)
await ChatPersistenceStore.shared.save(
snapshot,
fileName: fileName,
password: storagePassword.isEmpty ? nil : storagePassword
)
}
}

View File

@@ -29,7 +29,7 @@ struct PacketHandshake: Packet {
var protocolVersion: Int = 1
var heartbeatInterval: Int = 15
var device = HandshakeDevice()
var handshakeState: HandshakeState = .completed
var handshakeState: HandshakeState = .needDeviceVerification
func write(to stream: Stream) {
stream.writeString(privateKey)

View File

@@ -57,8 +57,10 @@ final class ProtocolManager: @unchecked Sendable {
private var heartbeatTask: Task<Void, Never>?
private var handshakeTimeoutTask: Task<Void, Never>?
private let searchHandlersLock = NSLock()
private let resultHandlersLock = NSLock()
private let packetQueueLock = NSLock()
private var searchResultHandlers: [UUID: (PacketSearch) -> Void] = [:]
private var resultHandlers: [UUID: (PacketResult) -> Void] = [:]
// Saved credentials for auto-reconnect
private var savedPublicKey: String?
@@ -98,6 +100,15 @@ final class ProtocolManager: @unchecked Sendable {
savedPrivateHash = nil
}
/// Immediately reconnect after returning from background, bypassing backoff.
func reconnectIfNeeded() {
guard savedPublicKey != nil, savedPrivateHash != nil else { return }
if connectionState == .authenticated || connectionState == .handshaking { return }
Self.logger.info("Force reconnect from foreground")
connectionState = .connecting
client.forceReconnect()
}
// MARK: - Sending
func sendPacket(_ packet: any Packet) {
@@ -128,6 +139,24 @@ final class ProtocolManager: @unchecked Sendable {
searchHandlersLock.unlock()
}
// MARK: - Result Handlers (Android parity: waitPacket(0x02))
/// Register a one-shot handler for PacketResult (0x02).
@discardableResult
func addResultHandler(_ handler: @escaping (PacketResult) -> Void) -> UUID {
let id = UUID()
resultHandlersLock.lock()
resultHandlers[id] = handler
resultHandlersLock.unlock()
return id
}
func removeResultHandler(_ id: UUID) {
resultHandlersLock.lock()
resultHandlers.removeValue(forKey: id)
resultHandlersLock.unlock()
}
// MARK: - Private Setup
private func setupClientCallbacks() {
@@ -173,7 +202,7 @@ final class ProtocolManager: @unchecked Sendable {
}
let device = HandshakeDevice(
deviceId: UIDevice.current.identifierForVendor?.uuidString ?? "unknown",
deviceId: DeviceIdentityManager.shared.currentDeviceId(),
deviceName: UIDevice.current.name,
deviceOs: "iOS \(UIDevice.current.systemVersion)"
)
@@ -184,7 +213,7 @@ final class ProtocolManager: @unchecked Sendable {
protocolVersion: 1,
heartbeatInterval: 15,
device: device,
handshakeState: .completed
handshakeState: .needDeviceVerification
)
sendPacketDirect(handshake)
@@ -238,7 +267,8 @@ final class ProtocolManager: @unchecked Sendable {
}
case 0x02:
if let p = packet as? PacketResult {
let _ = ResultCode(rawValue: p.resultCode)
Self.logger.info("📥 PacketResult: code=\(p.resultCode)")
notifyResultHandlers(p)
}
case 0x03:
if let p = packet as? PacketSearch {
@@ -293,6 +323,18 @@ final class ProtocolManager: @unchecked Sendable {
}
}
private func notifyResultHandlers(_ packet: PacketResult) {
resultHandlersLock.lock()
let handlers = resultHandlers
// One-shot: clear all handlers after dispatch (Android parity)
resultHandlers.removeAll()
resultHandlersLock.unlock()
for (_, handler) in handlers {
handler(packet)
}
}
private func handleHandshakeResponse(_ packet: PacketHandshake) {
handshakeTimeoutTask?.cancel()
handshakeTimeoutTask = nil

View File

@@ -14,7 +14,6 @@ final class WebSocketClient: NSObject, @unchecked Sendable, URLSessionWebSocketD
private var hasNotifiedConnected = false
private(set) var isConnected = false
private var disconnectHandledForCurrentSocket = false
private var reconnectAttempt = 0
var onConnected: (() -> Void)?
var onDisconnected: ((Error?) -> Void)?
@@ -55,6 +54,20 @@ final class WebSocketClient: NSObject, @unchecked Sendable, URLSessionWebSocketD
isConnected = false
}
/// Immediately reconnect, bypassing scheduled retry.
/// Used when returning from background to establish connection ASAP.
func forceReconnect() {
guard !isManuallyClosed else { return }
reconnectTask?.cancel()
reconnectTask = nil
guard !isConnected else { return }
webSocketTask?.cancel(with: .goingAway, reason: nil)
webSocketTask = nil
disconnectHandledForCurrentSocket = false
Self.logger.info("Force reconnect triggered")
connect()
}
@discardableResult
func send(_ data: Data, onFailure: ((Error?) -> Void)? = nil) -> Bool {
guard isConnected, let task = webSocketTask else {
@@ -89,7 +102,6 @@ final class WebSocketClient: NSObject, @unchecked Sendable, URLSessionWebSocketD
hasNotifiedConnected = true
isConnected = true
disconnectHandledForCurrentSocket = false
reconnectAttempt = 0
reconnectTask?.cancel()
reconnectTask = nil
onConnected?()
@@ -142,12 +154,10 @@ final class WebSocketClient: NSObject, @unchecked Sendable, URLSessionWebSocketD
guard !isManuallyClosed else { return }
guard reconnectTask == nil else { return }
let attempt = reconnectAttempt
reconnectAttempt += 1
// Exponential backoff: 5s, 7.5s, 11.25s, ... capped at 30s
let delaySeconds = min(5.0 * pow(1.5, Double(attempt)), 30.0)
// Fixed 5-second reconnect interval (desktop parity)
let delaySeconds: Double = 5.0
reconnectTask = Task { [weak self] in
Self.logger.info("Reconnecting in \(String(format: "%.1f", delaySeconds))s (attempt \(attempt + 1))...")
Self.logger.info("Reconnecting in 5s...")
try? await Task.sleep(nanoseconds: UInt64(delaySeconds * 1_000_000_000))
guard let self, !isManuallyClosed, !Task.isCancelled else { return }
self.reconnectTask = nil

View File

@@ -0,0 +1,79 @@
import Foundation
import UIKit
/// Manages the random per-device identifier used in handshake/device approval flows.
/// The identifier is stored encrypted in the app container, keyed by local device traits,
/// mirroring the desktop model more closely than sending `identifierForVendor` directly.
final class DeviceIdentityManager: @unchecked Sendable {
static let shared = DeviceIdentityManager()
private enum LegacyKeychainKey {
static let randomDeviceId = "randomDeviceId"
}
private let crypto = CryptoManager.shared
private let keychain = KeychainManager.shared
private let fileManager = FileManager.default
private let alphabet = Array("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789")
private let salt = "rosetta-device-salt"
private lazy var deviceFileURL: URL = {
let baseURL = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first
?? fileManager.urls(for: .documentDirectory, in: .userDomainMask).first
?? URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true)
let directory = baseURL
.appendingPathComponent("Rosetta", isDirectory: true)
.appendingPathComponent("System", isDirectory: true)
try? fileManager.createDirectory(at: directory, withIntermediateDirectories: true)
return directory.appendingPathComponent("device_id", isDirectory: false)
}()
private init() {}
func currentDeviceId() -> String {
if let encrypted = try? String(contentsOf: deviceFileURL, encoding: .utf8),
let decrypted = try? crypto.decryptWithPassword(encrypted, password: localSecret()),
let deviceId = String(data: decrypted, encoding: .utf8),
!deviceId.isEmpty {
return deviceId
}
if let legacy = try? keychain.loadString(forKey: LegacyKeychainKey.randomDeviceId),
!legacy.isEmpty {
persist(deviceId: legacy)
try? keychain.delete(forKey: LegacyKeychainKey.randomDeviceId)
return legacy
}
let generated = generateRandomDeviceId(length: 128)
persist(deviceId: generated)
return generated
}
}
private extension DeviceIdentityManager {
func persist(deviceId: String) {
guard let raw = deviceId.data(using: .utf8),
let encrypted = try? crypto.encryptWithPassword(raw, password: localSecret()),
let data = encrypted.data(using: .utf8) else {
return
}
try? data.write(
to: deviceFileURL,
options: [.atomic, .completeFileProtectionUntilFirstUserAuthentication]
)
}
func localSecret() -> String {
let vendorId = UIDevice.current.identifierForVendor?.uuidString ?? "unknown"
return vendorId + UIDevice.current.name + salt
}
func generateRandomDeviceId(length: Int) -> String {
guard let randomBytes = try? CryptoPrimitives.randomBytes(count: max(length, 1)) else {
return UUID().uuidString.replacingOccurrences(of: "-", with: "")
}
return String(randomBytes.prefix(length).map { alphabet[Int($0) % alphabet.count] })
}
}

View File

@@ -91,8 +91,14 @@ final class SessionManager {
username = account.username ?? ""
// Warm local state immediately, then let network sync reconcile updates.
await DialogRepository.shared.bootstrap(accountPublicKey: account.publicKey)
await MessageRepository.shared.bootstrap(accountPublicKey: account.publicKey)
await DialogRepository.shared.bootstrap(
accountPublicKey: account.publicKey,
storagePassword: privateKeyHex
)
await MessageRepository.shared.bootstrap(
accountPublicKey: account.publicKey,
storagePassword: privateKeyHex
)
RecentSearchesRepository.shared.setAccount(account.publicKey)
// Generate private key hash for handshake
@@ -177,7 +183,7 @@ final class SessionManager {
registerOutgoingRetry(for: packet)
}
/// Sends typing indicator with throttling (Android parity: max once per 2s per dialog).
/// Sends typing indicator with throttling (desktop parity: max once per 3s per dialog).
func sendTypingIndicator(toPublicKey: String) {
guard toPublicKey != currentPublicKey,
let hash = privateKeyHash,
@@ -186,7 +192,7 @@ final class SessionManager {
let now = Int64(Date().timeIntervalSince1970 * 1000)
let lastSent = lastTypingSentAt[toPublicKey] ?? 0
if now - lastSent < 2_000 {
if now - lastSent < ProtocolConstants.typingThrottleMs {
return
}
lastTypingSentAt[toPublicKey] = now
@@ -387,6 +393,9 @@ final class SessionManager {
// Desktop parity: request message synchronization after authentication.
self.requestSynchronize()
self.retryWaitingOutgoingMessagesAfterReconnect()
// Safety net: reconcile dialog delivery indicators with actual
// message statuses, fixing any desync from stale retry timers.
DialogRepository.shared.reconcileDeliveryStatuses()
// Clear dedup sets on reconnect so subscriptions can be re-established lazily.
self.requestedUserInfoKeys.removeAll()
@@ -529,6 +538,13 @@ final class SessionManager {
fromSync: syncBatchInProgress
)
// Desktop parity: if we received a message from the opponent (not our own),
// they are clearly online update their online status immediately.
// This supplements PacketOnlineState (0x05) which may arrive with delay.
if !fromMe && !syncBatchInProgress {
DialogRepository.shared.updateOnlineState(publicKey: opponentKey, isOnline: true)
}
let dialog = DialogRepository.shared.dialogs[opponentKey]
if dialog?.opponentTitle.isEmpty == true {
@@ -630,6 +646,19 @@ final class SessionManager {
ProtocolManager.shared.sendPacket(packet)
}
/// Force-refresh user info (including online status) by sending PacketSearch
/// without dedup check. Desktop parity: `forceUpdateUserInformation()`.
func forceRefreshUserInfo(publicKey: String) {
guard !publicKey.isEmpty,
let hash = privateKeyHash,
ProtocolManager.shared.connectionState == .authenticated
else { return }
var searchPacket = PacketSearch()
searchPacket.privateKey = hash
searchPacket.search = publicKey
ProtocolManager.shared.sendPacket(searchPacket)
}
private func requestSynchronize(cursor: Int64? = nil) {
guard ProtocolManager.shared.connectionState == .authenticated else { return }
guard !syncRequestInFlight else { return }
@@ -1064,7 +1093,7 @@ final class SessionManager {
// MARK: - Idle Detection Setup
private func setupIdleDetection() {
// Track app going to background/foreground to reset idle state.
// Track app going to background/foreground to reset idle state + reconnect.
idleObserverToken = NotificationCenter.default.addObserver(
forName: UIApplication.willEnterForegroundNotification,
object: nil,
@@ -1072,6 +1101,10 @@ final class SessionManager {
) { [weak self] _ in
Task { @MainActor [weak self] in
self?.lastUserInteractionTime = Date()
// Immediate reconnect when returning from background
if ProtocolManager.shared.connectionState != .authenticated {
ProtocolManager.shared.reconnectIfNeeded()
}
}
}
}

View File

@@ -0,0 +1,67 @@
import Foundation
/// Client-side validation for display name and username fields.
/// Used in profile editing (SettingsView / ProfileEditView).
enum ProfileValidator {
// MARK: - Display Name
enum DisplayNameError: LocalizedError {
case empty
case tooLong
var errorDescription: String? {
switch self {
case .empty: return "Name cannot be empty"
case .tooLong: return "Name cannot exceed 64 characters"
}
}
}
static func validateDisplayName(_ name: String) -> DisplayNameError? {
let trimmed = name.trimmingCharacters(in: .whitespaces)
if trimmed.isEmpty { return .empty }
if trimmed.count > 64 { return .tooLong }
return nil
}
// MARK: - Username
enum UsernameError: LocalizedError {
case tooShort
case tooLong
case invalidCharacters
case mustStartWithLetter
case reserved
var errorDescription: String? {
switch self {
case .tooShort: return "Username must be at least 5 characters"
case .tooLong: return "Username cannot exceed 32 characters"
case .invalidCharacters: return "Only lowercase letters, numbers, and underscores"
case .mustStartWithLetter: return "Must start with a letter"
case .reserved: return "This username is not available"
}
}
}
private static let reservedNames: Set<String> = [
"rosetta", "admin", "support", "help", "system",
"moderator", "official", "staff", "bot", "service",
]
/// Validate username. Returns `nil` if valid, or the first error found.
/// Empty username is allowed (user may not have chosen one yet).
static func validateUsername(_ username: String) -> UsernameError? {
let lowered = username.lowercased().trimmingCharacters(in: .whitespaces)
guard !lowered.isEmpty else { return nil } // empty is OK
if lowered.count < 5 { return .tooShort }
if lowered.count > 32 { return .tooLong }
guard let first = lowered.first, first.isLetter else { return .mustStartWithLetter }
if lowered.range(of: "^[a-z][a-z0-9_]{4,31}$", options: .regularExpression) == nil {
return .invalidCharacters
}
if reservedNames.contains(lowered) { return .reserved }
return nil
}
}

View File

@@ -44,8 +44,8 @@ enum ProtocolConstants {
/// Read receipt throttle interval in milliseconds.
static let readReceiptThrottleMs: Int64 = 400
/// Typing indicator throttle interval in milliseconds.
static let typingThrottleMs: Int64 = 2_000
/// Typing indicator throttle interval in milliseconds (desktop parity).
static let typingThrottleMs: Int64 = 3_000
/// Typing indicator display timeout in seconds.
static let typingDisplayTimeoutS: TimeInterval = 3

View File

@@ -0,0 +1,270 @@
import SwiftUI
import UIKit
// MARK: - KeyboardTrackingView
/// A zero-height UIView used as `inputAccessoryView`.
/// KVO on `superview.center` gives pixel-perfect keyboard position
/// during interactive dismiss the most reliable path for composer sync.
final class KeyboardTrackingView: UIView {
var onHeightChange: ((CGFloat) -> Void)?
private var observation: NSKeyValueObservation?
private var superviewHeightObservation: NSKeyValueObservation?
override init(frame: CGRect) {
super.init(frame: .init(x: 0, y: 0, width: frame.width, height: 0))
autoresizingMask = .flexibleWidth
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError() }
override var intrinsicContentSize: CGSize {
.init(width: UIView.noIntrinsicMetric, height: 0)
}
override func didMoveToSuperview() {
super.didMoveToSuperview()
observation?.invalidate()
observation = nil
superviewHeightObservation?.invalidate()
superviewHeightObservation = nil
guard let sv = superview else { return }
observation = sv.observe(\.center, options: [.new]) { [weak self] view, _ in
self?.reportHeight(from: view)
}
superviewHeightObservation = sv.observe(\.bounds, options: [.new]) { [weak self] view, _ in
self?.reportHeight(from: view)
}
}
private func reportHeight(from hostView: UIView) {
guard let window = hostView.window else { return }
let screenHeight = window.screen.bounds.height
let hostFrame = hostView.convert(hostView.bounds, to: nil)
let keyboardHeight = max(0, screenHeight - hostFrame.origin.y)
onHeightChange?(keyboardHeight)
}
deinit {
observation?.invalidate()
superviewHeightObservation?.invalidate()
}
}
// MARK: - ChatInputTextView
/// UITextView subclass with a placeholder label that stays visible while empty.
final class ChatInputTextView: UITextView {
let trackingView = KeyboardTrackingView(
frame: .init(x: 0, y: 0, width: UIScreen.main.bounds.width, height: 0)
)
/// Placeholder label visible when text is empty, even while focused.
let placeholderLabel: UILabel = {
let label = UILabel()
label.translatesAutoresizingMaskIntoConstraints = false
label.isUserInteractionEnabled = false
return label
}()
override init(frame: CGRect, textContainer: NSTextContainer?) {
super.init(frame: frame, textContainer: textContainer)
inputAccessoryView = trackingView
inputAssistantItem.leadingBarButtonGroups = []
inputAssistantItem.trailingBarButtonGroups = []
addSubview(placeholderLabel)
}
override func layoutSubviews() {
super.layoutSubviews()
// Position placeholder to match text position exactly
let insets = textContainerInset
let padding = textContainer.lineFragmentPadding
placeholderLabel.frame.origin = CGPoint(x: insets.left + padding, y: insets.top)
placeholderLabel.frame.size = CGSize(
width: bounds.width - insets.left - insets.right - padding * 2,
height: placeholderLabel.intrinsicContentSize.height
)
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError() }
}
// MARK: - ChatTextInput (UIViewRepresentable)
struct ChatTextInput: UIViewRepresentable {
@Binding var text: String
@Binding var isFocused: Bool
var onKeyboardHeightChange: (CGFloat) -> Void
var onUserTextInsertion: () -> Void = {}
var font: UIFont = .systemFont(ofSize: 17, weight: .regular)
var textColor: UIColor = .white
var placeholderColor: UIColor = UIColor.white.withAlphaComponent(0.35)
var placeholder: String = "Message"
func makeCoordinator() -> Coordinator {
Coordinator(parent: self)
}
func makeUIView(context: Context) -> ChatInputTextView {
let tv = ChatInputTextView()
tv.delegate = context.coordinator
tv.font = font
tv.textColor = textColor
tv.backgroundColor = .clear
tv.tintColor = UIColor(RosettaColors.primaryBlue)
tv.isScrollEnabled = false
tv.textContainerInset = UIEdgeInsets(top: 6, left: 2, bottom: 8, right: 0)
tv.textContainer.lineFragmentPadding = 0
tv.autocapitalizationType = .sentences
tv.autocorrectionType = .default
tv.keyboardAppearance = .dark
tv.returnKeyType = .default
// Placeholder label (stays visible when text is empty, even if focused)
tv.placeholderLabel.text = placeholder
tv.placeholderLabel.font = font
tv.placeholderLabel.textColor = placeholderColor
tv.placeholderLabel.isHidden = !text.isEmpty
tv.trackingView.onHeightChange = { [weak coordinator = context.coordinator] height in
coordinator?.handleKeyboardHeight(height)
}
// Set initial text
if !text.isEmpty {
tv.text = text
}
return tv
}
func updateUIView(_ tv: ChatInputTextView, context: Context) {
let coord = context.coordinator
coord.parent = self
// Sync text from SwiftUI UIKit (avoid loop)
if !coord.isUpdatingText {
if text != tv.text {
tv.text = text
coord.invalidateHeight(tv)
}
tv.placeholderLabel.isHidden = !text.isEmpty
}
// Sync focus without replaying stale async requests during interactive dismiss.
coord.syncFocus(for: tv)
}
func sizeThatFits(_ proposal: ProposedViewSize, uiView tv: ChatInputTextView, context: Context) -> CGSize? {
let maxWidth = proposal.width ?? UIScreen.main.bounds.width
let lineHeight = font.lineHeight
let maxLines: CGFloat = 5
let insets = tv.textContainerInset
let maxTextHeight = lineHeight * maxLines
let maxTotalHeight = maxTextHeight + insets.top + insets.bottom
let fittingSize = tv.sizeThatFits(CGSize(width: maxWidth, height: .greatestFiniteMagnitude))
let clampedHeight = min(fittingSize.height, maxTotalHeight)
// Enable scrolling when content exceeds max height
let shouldScroll = fittingSize.height > maxTotalHeight
if tv.isScrollEnabled != shouldScroll {
tv.isScrollEnabled = shouldScroll
}
return CGSize(width: maxWidth, height: max(36, clampedHeight))
}
// MARK: - Coordinator
final class Coordinator: NSObject, UITextViewDelegate {
var parent: ChatTextInput
var isUpdatingText = false
private var pendingFocusSync: DispatchWorkItem?
init(parent: ChatTextInput) {
self.parent = parent
}
func handleKeyboardHeight(_ height: CGFloat) {
parent.onKeyboardHeightChange(height)
}
// MARK: UITextViewDelegate
func textViewDidBeginEditing(_ tv: UITextView) {
pendingFocusSync?.cancel()
// Placeholder stays visible only hidden when user types
if !parent.isFocused {
parent.isFocused = true
}
}
func textViewDidEndEditing(_ tv: UITextView) {
pendingFocusSync?.cancel()
// Must be synchronous async causes race condition with .ignoresSafeArea(.keyboard):
// padding animation triggers updateUIView before isFocused is updated,
// causing becomeFirstResponder() keyboard reopens.
if parent.isFocused {
parent.isFocused = false
}
}
func textView(
_ tv: UITextView,
shouldChangeTextIn range: NSRange,
replacementText text: String
) -> Bool {
guard !text.isEmpty, text != "\n" else { return true }
parent.onUserTextInsertion()
return true
}
func textViewDidChange(_ tv: UITextView) {
isUpdatingText = true
parent.text = tv.text ?? ""
isUpdatingText = false
// Toggle placeholder based on content
if let chatTV = tv as? ChatInputTextView {
chatTV.placeholderLabel.isHidden = !tv.text.isEmpty
}
invalidateHeight(tv)
}
func invalidateHeight(_ tv: UITextView) {
tv.invalidateIntrinsicContentSize()
}
func syncFocus(for tv: UITextView) {
pendingFocusSync?.cancel()
let wantsFocus = parent.isFocused
guard wantsFocus != tv.isFirstResponder else { return }
let workItem = DispatchWorkItem { [weak self, weak tv] in
guard let self, let tv else { return }
guard self.parent.isFocused == wantsFocus else { return }
if wantsFocus {
guard !tv.isFirstResponder else { return }
tv.becomeFirstResponder()
} else {
guard tv.isFirstResponder else { return }
tv.resignFirstResponder()
}
}
pendingFocusSync = workItem
DispatchQueue.main.async(execute: workItem)
}
}
}

View File

@@ -0,0 +1,94 @@
import SwiftUI
import UIKit
/// Displays text that shows a native iOS callout bubble ("Copy") on tap or long press.
/// Uses `UIEditMenuInteraction` for the system callout style.
struct CopyableText: UIViewRepresentable {
let displayText: String
let fullText: String
let font: UIFont
let textColor: UIColor
func makeUIView(context: Context) -> CopyableLabel {
let label = CopyableLabel()
label.text = displayText
label.font = font
label.textColor = textColor
label.textToCopy = fullText
label.textAlignment = .center
label.setContentHuggingPriority(.required, for: .vertical)
label.setContentCompressionResistancePriority(.required, for: .vertical)
return label
}
func updateUIView(_ uiView: CopyableLabel, context: Context) {
uiView.text = displayText
uiView.font = font
uiView.textColor = textColor
uiView.textToCopy = fullText
}
}
/// UILabel subclass with `UIEditMenuInteraction` for native callout copy menu.
final class CopyableLabel: UILabel, UIEditMenuInteractionDelegate {
var textToCopy: String = ""
private var editMenuInteraction: UIEditMenuInteraction?
override init(frame: CGRect) {
super.init(frame: frame)
setup()
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
private func setup() {
isUserInteractionEnabled = true
let interaction = UIEditMenuInteraction(delegate: self)
addInteraction(interaction)
editMenuInteraction = interaction
let tap = UITapGestureRecognizer(target: self, action: #selector(handleTap(_:)))
addGestureRecognizer(tap)
let longPress = UILongPressGestureRecognizer(target: self, action: #selector(handleLongPress(_:)))
longPress.minimumPressDuration = 0.5
addGestureRecognizer(longPress)
}
@objc private func handleTap(_ gesture: UITapGestureRecognizer) {
showMenu(at: gesture.location(in: self))
}
@objc private func handleLongPress(_ gesture: UILongPressGestureRecognizer) {
guard gesture.state == .began else { return }
showMenu(at: gesture.location(in: self))
}
private func showMenu(at point: CGPoint) {
let config = UIEditMenuConfiguration(identifier: nil, sourcePoint: point)
editMenuInteraction?.presentEditMenu(with: config)
}
// MARK: - UIEditMenuInteractionDelegate
func editMenuInteraction(
_ interaction: UIEditMenuInteraction,
menuFor configuration: UIEditMenuConfiguration,
suggestedActions: [UIMenuElement]
) -> UIMenu? {
let textToCopy = textToCopy
let copy = UIAction(
title: String(localized: "Copy"),
image: UIImage(systemName: "doc.on.doc")
) { _ in
UIPasteboard.general.string = textToCopy
}
return UIMenu(children: [copy])
}
}

View File

@@ -0,0 +1,158 @@
import SwiftUI
import Combine
import UIKit
/// Drives keyboard-related positioning for the chat composer.
///
/// Published properties:
/// - `keyboardPadding`: bottom padding for the composer
/// - `interactiveOffset`: visual offset during interactive drag
///
/// Data sources:
/// 1. `keyboardWillChangeFrameNotification` animated show/hide with system timing
/// 2. KVO on `inputAccessoryView.superview.center` pixel-perfect interactive drag
@MainActor
final class KeyboardTracker: ObservableObject {
@Published private(set) var keyboardPadding: CGFloat = 0
@Published private(set) var interactiveOffset: CGFloat = 0
private var baseKeyboardHeight: CGFloat?
private var isAnimating = false
private let bottomInset: CGFloat
private var pendingResetTask: Task<Void, Never>?
private var cancellables = Set<AnyCancellable>()
init() {
if let scene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
let window = scene.keyWindow ?? scene.windows.first {
let bottom = window.safeAreaInsets.bottom
bottomInset = bottom < 50 ? bottom : 34
} else {
bottomInset = 34
}
NotificationCenter.default
.publisher(for: UIResponder.keyboardWillChangeFrameNotification)
.sink { [weak self] in self?.handleNotification($0) }
.store(in: &cancellables)
}
func updateFromKVO(keyboardHeight: CGFloat) {
guard !isAnimating else { return }
if keyboardHeight <= 0 {
baseKeyboardHeight = nil
if interactiveOffset > 0 {
if pendingResetTask == nil {
pendingResetTask = Task { @MainActor [weak self] in
try? await Task.sleep(for: .milliseconds(300))
guard let self, !Task.isCancelled else { return }
if self.interactiveOffset != 0 || self.keyboardPadding != 0 {
withAnimation(.easeOut(duration: 0.25)) {
self.interactiveOffset = 0
self.keyboardPadding = 0
}
}
}
}
} else if interactiveOffset != 0 {
interactiveOffset = 0
}
return
}
pendingResetTask?.cancel()
pendingResetTask = nil
if baseKeyboardHeight == nil {
baseKeyboardHeight = keyboardHeight
}
guard let base = baseKeyboardHeight else { return }
if keyboardHeight >= base {
if keyboardHeight > base {
baseKeyboardHeight = keyboardHeight
let newPadding = max(0, keyboardHeight - bottomInset)
if newPadding != keyboardPadding {
keyboardPadding = newPadding
}
}
if interactiveOffset > 0 {
withAnimation(.interactiveSpring(duration: 0.35)) {
interactiveOffset = 0
}
} else if interactiveOffset != 0 {
interactiveOffset = 0
}
return
}
let newOffset = base - keyboardHeight
// Ignore small fluctuations (<10pt) from animation settling only respond
// to significant drags. Without this threshold, KVO reports slightly-off
// values after isAnimating expires, causing a brief downward offset (sink).
if newOffset > 10 {
if newOffset != interactiveOffset { interactiveOffset = newOffset }
} else if interactiveOffset != 0 {
interactiveOffset = 0
}
}
private func handleNotification(_ notification: Notification) {
guard let info = notification.userInfo,
let endFrame = info[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect
else { return }
let screenHeight = UIScreen.main.bounds.height
let keyboardTop = endFrame.origin.y
let isVisible = keyboardTop < screenHeight
let duration = info[UIResponder.keyboardAnimationDurationUserInfoKey] as? TimeInterval ?? 0
let endHeight = isVisible ? (screenHeight - keyboardTop) : 0
pendingResetTask?.cancel()
pendingResetTask = nil
if isVisible {
baseKeyboardHeight = endHeight
let targetPadding = max(0, endHeight - bottomInset)
isAnimating = true
if duration > 0 {
withAnimation(.easeInOut(duration: duration)) {
keyboardPadding = targetPadding
interactiveOffset = 0
}
} else {
keyboardPadding = targetPadding
interactiveOffset = 0
}
Task { @MainActor [weak self] in
try? await Task.sleep(for: .seconds(max(duration, 0.05) + 0.15))
self?.isAnimating = false
}
} else {
baseKeyboardHeight = nil
isAnimating = true
if duration > 0 {
withAnimation(.easeInOut(duration: duration)) {
keyboardPadding = 0
interactiveOffset = 0
}
} else {
keyboardPadding = 0
interactiveOffset = 0
}
Task { @MainActor [weak self] in
try? await Task.sleep(for: .seconds(max(duration, 0.05) + 0.15))
self?.isAnimating = false
}
}
}
}

View File

@@ -68,7 +68,7 @@ struct AuthCoordinator: View {
.ignoresSafeArea()
.opacity(fadeOverlay ? 1 : 0)
.allowsHitTesting(fadeOverlay)
.animation(.easeInOut(duration: 0.12), value: fadeOverlay)
.animation(.easeInOut(duration: 0.08), value: fadeOverlay)
}
.overlay(alignment: .leading) {
if canSwipeBack {
@@ -174,9 +174,9 @@ private extension AuthCoordinator {
navigationDirection = .forward
fadeOverlay = true
Task { @MainActor in
try? await Task.sleep(nanoseconds: 140_000_000)
try? await Task.sleep(nanoseconds: 80_000_000)
currentScreen = screen
try? await Task.sleep(nanoseconds: 30_000_000)
try? await Task.sleep(nanoseconds: 20_000_000)
fadeOverlay = false
}
}

View File

@@ -12,6 +12,7 @@ struct SetPasswordView: View {
@State private var isCreating = false
@State private var errorMessage: String?
@State private var focusedField: Field?
@State private var showWeakPasswordAlert = false
fileprivate enum Field {
case password, confirm
@@ -25,6 +26,12 @@ struct SetPasswordView: View {
passwordsMatch && !isCreating
}
private var matchesDesktopPasswordPolicy: Bool {
password.range(of: "[A-Z]+", options: .regularExpression) != nil &&
password.range(of: "[0-9]+", options: .regularExpression) != nil &&
password.range(of: "[$@#&!]+", options: .regularExpression) != nil
}
var body: some View {
VStack(spacing: 0) {
AuthNavigationBar(onBack: onBack)
@@ -72,6 +79,14 @@ struct SetPasswordView: View {
.padding(.bottom, 16)
}
.ignoresSafeArea(.keyboard)
.alert("Insecure password", isPresented: $showWeakPasswordAlert) {
Button("I'll come up with a new one", role: .cancel) {}
Button("Continue") {
performAccountCreation()
}
} message: {
Text("Your password is insecure. Such passwords are easy to guess. Choose a stronger one, or continue anyway.")
}
}
}
@@ -200,6 +215,15 @@ private extension SetPasswordView {
}
func createAccount() {
guard canCreate else { return }
guard matchesDesktopPasswordPolicy else {
showWeakPasswordAlert = true
return
}
performAccountCreation()
}
func performAccountCreation() {
guard canCreate else { return }
isCreating = true

View File

@@ -24,13 +24,13 @@ struct UnlockView: View {
account?.publicKey ?? ""
}
/// First 2 chars of public key matching Android's avatar text.
/// Initials from display name, falling back to first 2 chars of public key.
private var avatarText: String {
RosettaColors.avatarText(publicKey: publicKey)
RosettaColors.initials(name: account?.displayName ?? "", publicKey: publicKey)
}
private var avatarColorIndex: Int {
RosettaColors.avatarColorIndex(for: "", publicKey: publicKey)
RosettaColors.avatarColorIndex(for: account?.displayName ?? "", publicKey: publicKey)
}
/// Short public key 7 characters like Android (e.g. "0325a4d").
@@ -67,8 +67,8 @@ struct UnlockView: View {
Spacer().frame(height: 20)
// Short public key (7 chars like Android)
Text(shortPublicKey)
// Display name (or short public key fallback)
Text(displayTitle)
.font(.system(size: 24, weight: .bold))
.foregroundStyle(.white)
.opacity(showTitle ? 1 : 0)

View File

@@ -1,4 +1,5 @@
import SwiftUI
import UserNotifications
struct ChatDetailView: View {
let route: ChatRoute
@@ -17,7 +18,8 @@ struct ChatDetailView: View {
@State private var sendError: String?
@State private var isViewActive = false
// markReadTask removed read receipts no longer sent from .onChange(of: messages.count)
@FocusState private var isInputFocused: Bool
@State private var isInputFocused = false
@StateObject private var keyboard = KeyboardTracker()
private var currentPublicKey: String {
SessionManager.shared.currentPublicKey
@@ -97,7 +99,11 @@ struct ChatDetailView: View {
messagesList(maxBubbleWidth: max(min(geometry.size.width * 0.72, 380), 140))
}
.overlay { chatEdgeGradients }
.safeAreaInset(edge: .bottom, spacing: 0) { composer }
.safeAreaInset(edge: .bottom, spacing: 0) {
composer
.offset(y: keyboard.interactiveOffset)
.animation(.spring(.smooth(duration: 0.32)), value: keyboard.interactiveOffset)
}
.background {
ZStack {
RosettaColors.Adaptive.background
@@ -124,8 +130,15 @@ struct ChatDetailView: View {
guard isViewActive else { return }
activateDialog()
markDialogAsRead()
// Clear delivered notifications from this sender
clearDeliveredNotifications(for: route.publicKey)
// Subscribe to opponent's online status (Android parity) only after settled
SessionManager.shared.subscribeToOnlineStatus(publicKey: route.publicKey)
// Desktop parity: force-refresh user info (incl. online status) on chat open.
// PacketSearch (0x03) returns current online state, supplementing 0x05 subscription.
if !route.isSavedMessages {
SessionManager.shared.forceRefreshUserInfo(publicKey: route.publicKey)
}
}
.onDisappear {
isViewActive = false
@@ -177,14 +190,17 @@ private extension ChatDetailView {
Text(subtitleText)
.font(.system(size: 12, weight: .medium))
.foregroundStyle(
isTyping || (dialog?.isOnline == true)
? RosettaColors.online
: RosettaColors.Adaptive.textSecondary
isTyping
? RosettaColors.primaryBlue
: (dialog?.isOnline == true)
? RosettaColors.online
: RosettaColors.Adaptive.textSecondary
)
.lineLimit(1)
}
}
.padding(.horizontal, 12)
.frame(minWidth: 120)
.frame(height: 44)
.background {
glass(shape: .capsule, strokeOpacity: 0.22, strokeColor: .white)
@@ -229,14 +245,17 @@ private extension ChatDetailView {
Text(subtitleText)
.font(.system(size: 12, weight: .medium))
.foregroundStyle(
isTyping || (dialog?.isOnline == true)
? RosettaColors.online
: RosettaColors.Adaptive.textSecondary
isTyping
? RosettaColors.primaryBlue
: (dialog?.isOnline == true)
? RosettaColors.online
: RosettaColors.Adaptive.textSecondary
)
.lineLimit(1)
}
}
.padding(.horizontal, 16)
.frame(minWidth: 120)
.frame(height: 44)
.background {
glass(shape: .capsule, strokeOpacity: 0.22, strokeColor: .white)
@@ -416,7 +435,7 @@ private extension ChatDetailView {
.padding(.top, messagesTopInset)
.padding(.bottom, 10)
}
.scrollDismissesKeyboard(.immediately)
.scrollDismissesKeyboard(.interactively)
.onTapGesture { isInputFocused = false }
.onAppear {
DispatchQueue.main.async { scrollToBottom(proxy: proxy, animated: false) }
@@ -440,13 +459,8 @@ private extension ChatDetailView {
guard focused else { return }
// User tapped the input reset idle timer.
SessionManager.shared.recordUserInteraction()
// Delay matches keyboard animation (~250ms) so scroll happens after layout settles.
Task { @MainActor in
try? await Task.sleep(nanoseconds: 300_000_000)
scrollToBottom(proxy: proxy, animated: true)
}
scrollToBottom(proxy: proxy, animated: false)
}
scroll
.defaultScrollAnchor(.bottom)
.scrollIndicators(.hidden)
@@ -535,17 +549,16 @@ private extension ChatDetailView {
.buttonStyle(ChatDetailGlassPressButtonStyle())
HStack(alignment: .bottom, spacing: 0) {
TextField("Message", text: $messageText, axis: .vertical)
.font(.system(size: 17, weight: .regular))
.foregroundStyle(RosettaColors.Adaptive.text)
.lineLimit(1...5)
.focused($isInputFocused)
.textInputAutocapitalization(.sentences)
.autocorrectionDisabled()
.padding(.leading, 6)
.padding(.top, 6)
.padding(.bottom, 8)
.frame(maxWidth: .infinity, minHeight: 36, alignment: .bottomLeading)
ChatTextInput(
text: $messageText,
isFocused: $isInputFocused,
onKeyboardHeightChange: { keyboard.updateFromKVO(keyboardHeight: $0) },
onUserTextInsertion: handleComposerUserTyping,
textColor: UIColor(RosettaColors.Adaptive.text),
placeholderColor: UIColor(RosettaColors.Adaptive.textSecondary.opacity(0.5))
)
.padding(.leading, 6)
.frame(maxWidth: .infinity, minHeight: 36, alignment: .bottomLeading)
HStack(alignment: .center, spacing: 0) {
Button { } label: {
@@ -625,6 +638,7 @@ private extension ChatDetailView {
.padding(.trailing, composerTrailingPadding)
.padding(.top, 4)
.padding(.bottom, 4)
.simultaneousGesture(composerDismissGesture)
.animation(composerAnimation, value: canSend)
.animation(composerAnimation, value: shouldShowSendButton)
}
@@ -739,6 +753,17 @@ private extension ChatDetailView {
else { isInputFocused = true }
}
var composerDismissGesture: some Gesture {
DragGesture(minimumDistance: 10)
.onChanged { value in
guard isInputFocused else { return }
let vertical = value.translation.height
let horizontal = value.translation.width
guard vertical > 12, abs(vertical) > abs(horizontal) else { return }
isInputFocused = false
}
}
func deliveryTint(_ status: DeliveryStatus) -> Color {
switch status {
case .read: return Color(hex: 0xA4E2FF)
@@ -852,6 +877,19 @@ private extension ChatDetailView {
SessionManager.shared.sendReadReceipt(toPublicKey: route.publicKey)
}
/// Remove all delivered push notifications from this specific sender.
func clearDeliveredNotifications(for senderKey: String) {
let center = UNUserNotificationCenter.current()
center.getDeliveredNotifications { delivered in
let idsToRemove = delivered
.filter { $0.request.content.userInfo["sender_public_key"] as? String == senderKey }
.map { $0.request.identifier }
if !idsToRemove.isEmpty {
center.removeDeliveredNotifications(withIdentifiers: idsToRemove)
}
}
}
func sendCurrentMessage() {
let message = trimmedMessage
guard !message.isEmpty else { return }
@@ -877,6 +915,10 @@ private extension ChatDetailView {
}
}
func handleComposerUserTyping() {
SessionManager.shared.sendTypingIndicator(toPublicKey: route.publicKey)
}
static let timeFormatter: DateFormatter = {
let formatter = DateFormatter()
formatter.locale = Locale(identifier: "en_US_POSIX")
@@ -1139,7 +1181,7 @@ private struct SVGPathParser {
while index < tokens.count {
if case .command = tokens[index] { return }
index += 1
}
}
}
}

View File

@@ -135,7 +135,7 @@ private extension ChatListSearchContent {
}
}
.padding(.horizontal, 16)
.padding(.top, 8)
.padding(.top, 20)
.padding(.bottom, 6)
ForEach(viewModel.recentSearches, id: \.publicKey) { recent in

View File

@@ -56,7 +56,7 @@ struct ChatListView: View {
navigationState.path.append(route)
// Delay search dismissal so NavigationStack processes
// the push before the search overlay is removed.
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) {
isSearchActive = false
isSearchFocused = false
searchText = ""
@@ -92,6 +92,11 @@ struct ChatListView: View {
}
}
.tint(RosettaColors.figmaBlue)
.onReceive(NotificationCenter.default.publisher(for: .openChatFromNotification)) { notification in
guard let route = notification.object as? ChatRoute else { return }
// Navigate to the chat from push notification tap
navigationState.path = [route]
}
}
// MARK: - Cancel Search

View File

@@ -172,7 +172,9 @@ private extension ChatRowView {
.rotationEffect(.degrees(45))
}
if dialog.unreadCount > 0 {
// Desktop parity: delivery icon and unread badge are
// mutually exclusive badge hidden when lastMessageFromMe.
if dialog.unreadCount > 0 && !dialog.lastMessageFromMe {
unreadBadge
}
}

View File

@@ -4,35 +4,34 @@ import SwiftUI
/// Telegram-style skeleton loading for search results.
/// Matches the Figma chat row layout: 62px avatar, two-line text, trailing time.
/// Uses TimelineView so the shimmer never restarts on view rebuild.
struct SearchSkeletonView: View {
@State private var phase: CGFloat = 0
var body: some View {
ScrollView {
VStack(spacing: 0) {
ForEach(0..<7, id: \.self) { index in
skeletonRow(index: index)
if index < 6 {
Divider()
.foregroundStyle(RosettaColors.Adaptive.divider)
.padding(.leading, 82)
TimelineView(.animation) { timeline in
let phase = shimmerPhase(from: timeline.date)
ScrollView {
VStack(spacing: 0) {
ForEach(0..<7, id: \.self) { index in
skeletonRow(index: index, phase: phase)
if index < 6 {
Divider()
.foregroundStyle(RosettaColors.Adaptive.divider)
.padding(.leading, 82)
}
}
}
}
}
.scrollDisabled(true)
.task {
withAnimation(.linear(duration: 1.5).repeatForever(autoreverses: false)) {
phase = 1
}
.scrollDisabled(true)
}
}
private func skeletonRow(index: Int) -> some View {
private func skeletonRow(index: Int, phase: CGFloat) -> some View {
HStack(spacing: 0) {
// Avatar 62pt circle matching Figma
Circle()
.fill(shimmerGradient)
.fill(shimmerGradient(phase: phase))
.frame(width: 62, height: 62)
.padding(.leading, 10)
.padding(.trailing, 10)
@@ -41,12 +40,12 @@ struct SearchSkeletonView: View {
VStack(alignment: .leading, spacing: 8) {
// Title line name width varies per row
RoundedRectangle(cornerRadius: 4)
.fill(shimmerGradient)
.fill(shimmerGradient(phase: phase))
.frame(width: titleWidth(for: index), height: 16)
// Subtitle line message preview
RoundedRectangle(cornerRadius: 4)
.fill(shimmerGradient)
.fill(shimmerGradient(phase: phase))
.frame(width: subtitleWidth(for: index), height: 14)
}
@@ -54,7 +53,7 @@ struct SearchSkeletonView: View {
// Trailing time placeholder
RoundedRectangle(cornerRadius: 3)
.fill(shimmerGradient)
.fill(shimmerGradient(phase: phase))
.frame(width: 40, height: 12)
.padding(.trailing, 16)
}
@@ -71,18 +70,6 @@ struct SearchSkeletonView: View {
let widths: [CGFloat] = [200, 170, 220, 150, 190, 180, 210]
return widths[index % widths.count]
}
private var shimmerGradient: LinearGradient {
LinearGradient(
colors: [
Color.gray.opacity(0.08),
Color.gray.opacity(0.15),
Color.gray.opacity(0.08),
],
startPoint: UnitPoint(x: phase - 0.4, y: 0),
endPoint: UnitPoint(x: phase + 0.4, y: 0)
)
}
}
// MARK: - SearchSkeletonRow
@@ -90,44 +77,56 @@ struct SearchSkeletonView: View {
/// Single shimmer row matching `serverUserRow` layout (48px avatar, two text lines).
/// Used inline below existing search results while server is still loading.
struct SearchSkeletonRow: View {
@State private var phase: CGFloat = 0
var body: some View {
HStack(spacing: 12) {
Circle()
.fill(shimmerGradient)
.frame(width: 48, height: 48)
TimelineView(.animation) { timeline in
let phase = shimmerPhase(from: timeline.date)
VStack(alignment: .leading, spacing: 6) {
RoundedRectangle(cornerRadius: 4)
.fill(shimmerGradient)
.frame(width: 120, height: 14)
HStack(spacing: 12) {
Circle()
.fill(shimmerGradient(phase: phase))
.frame(width: 48, height: 48)
RoundedRectangle(cornerRadius: 4)
.fill(shimmerGradient)
.frame(width: 90, height: 12)
VStack(alignment: .leading, spacing: 6) {
RoundedRectangle(cornerRadius: 4)
.fill(shimmerGradient(phase: phase))
.frame(width: 120, height: 14)
RoundedRectangle(cornerRadius: 4)
.fill(shimmerGradient(phase: phase))
.frame(width: 90, height: 12)
}
Spacer()
}
Spacer()
.padding(.horizontal, 16)
.padding(.vertical, 12)
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
.task {
withAnimation(.linear(duration: 1.5).repeatForever(autoreverses: false)) {
phase = 1
}
}
}
private var shimmerGradient: LinearGradient {
LinearGradient(
colors: [
Color.gray.opacity(0.08),
Color.gray.opacity(0.15),
Color.gray.opacity(0.08),
],
startPoint: UnitPoint(x: phase - 0.4, y: 0),
endPoint: UnitPoint(x: phase + 0.4, y: 0)
)
}
}
// MARK: - Shared Shimmer Helpers
/// Derives a 01 phase from the system clock (1.5s cycle).
/// Clock-based never resets on view rebuild.
private func shimmerPhase(from date: Date) -> CGFloat {
let elapsed = date.timeIntervalSinceReferenceDate
let cycle: Double = 1.5
return CGFloat(elapsed.truncatingRemainder(dividingBy: cycle) / cycle)
}
private func shimmerGradient(phase: CGFloat) -> LinearGradient {
// Map phase 01 to position -0.51.5 so the highlight
// enters from off-screen left and exits off-screen right.
// When phase wraps 10, highlight is already invisible no jump.
let position = phase * 2.0 - 0.5
return LinearGradient(
colors: [
Color.gray.opacity(0.08),
Color.gray.opacity(0.15),
Color.gray.opacity(0.08),
],
startPoint: UnitPoint(x: position - 0.3, y: 0),
endPoint: UnitPoint(x: position + 0.3, y: 0)
)
}

View File

@@ -10,8 +10,10 @@ struct SearchView: View {
@MainActor static var _bodyCount = 0
var body: some View {
#if DEBUG
let _ = Self._bodyCount += 1
let _ = print("🔵 SearchView.body #\(Self._bodyCount)")
#endif
NavigationStack(path: $navigationPath) {
ZStack(alignment: .bottom) {
RosettaColors.Adaptive.background
@@ -143,8 +145,10 @@ private struct FavoriteContactsRow: View {
@MainActor static var _bodyCount = 0
var body: some View {
#if DEBUG
let _ = Self._bodyCount += 1
let _ = print("🟠 FavoriteContactsRow.body #\(Self._bodyCount)")
#endif
let dialogs = DialogRepository.shared.sortedDialogs.prefix(10)
if !dialogs.isEmpty {
ScrollView(.horizontal, showsIndicators: false) {
@@ -191,8 +195,10 @@ private struct RecentSection: View {
@MainActor static var _bodyCount = 0
var body: some View {
#if DEBUG
let _ = Self._bodyCount += 1
let _ = print("🟤 RecentSection.body #\(Self._bodyCount)")
#endif
if viewModel.recentSearches.isEmpty {
emptyState
} else {

View File

@@ -2,13 +2,14 @@ import PhotosUI
import SwiftUI
/// Embedded profile editing content (no NavigationStack lives inside SettingsView's).
/// Matches Telegram's edit screen: avatar + photo picker, name fields,
/// helper texts, "Add Another Account", and "Log Out".
/// Avatar + photo picker, name fields with validation.
struct ProfileEditView: View {
@Binding var displayName: String
@Binding var username: String
let publicKey: String
var onLogout: () -> Void = {}
@Binding var displayNameError: String?
@Binding var usernameError: String?
@State private var selectedPhotoItem: PhotosPickerItem?
@State private var selectedPhoto: UIImage?
@@ -33,12 +34,6 @@ struct ProfileEditView: View {
.padding(.bottom, 24)
addAccountSection
helperText("You can connect multiple accounts with different phone numbers.")
.padding(.top, 8)
.padding(.bottom, 24)
logoutSection
}
.padding(.horizontal, 16)
.padding(.top, 24)
@@ -88,55 +83,90 @@ private extension ProfileEditView {
private extension ProfileEditView {
var nameSection: some View {
GlassCard(cornerRadius: 26, fillOpacity: 0.08) {
VStack(spacing: 0) {
HStack {
TextField("First Name", text: $displayName)
.font(.system(size: 17))
.foregroundStyle(RosettaColors.Adaptive.text)
.autocorrectionDisabled()
.textInputAutocapitalization(.words)
VStack(alignment: .leading, spacing: 0) {
GlassCard(cornerRadius: 26, fillOpacity: 0.08) {
VStack(spacing: 0) {
// Display Name field
HStack {
TextField("First Name", text: $displayName)
.font(.system(size: 17))
.foregroundStyle(RosettaColors.Adaptive.text)
.autocorrectionDisabled()
.textInputAutocapitalization(.words)
.onChange(of: displayName) { _, newValue in
displayNameError = ProfileValidator.validateDisplayName(newValue)?.errorDescription
}
if !displayName.isEmpty {
Button { displayName = "" } label: {
Image(systemName: "xmark.circle.fill")
.font(.system(size: 18))
.foregroundStyle(RosettaColors.tertiaryText)
if !displayName.isEmpty {
Button { displayName = "" } label: {
Image(systemName: "xmark.circle.fill")
.font(.system(size: 18))
.foregroundStyle(RosettaColors.tertiaryText)
}
.buttonStyle(.plain)
}
.buttonStyle(.plain)
}
}
.padding(.horizontal, 16)
.frame(height: 52)
.padding(.horizontal, 16)
.frame(height: 52)
Divider()
.background(RosettaColors.Adaptive.divider)
.padding(.leading, 16)
Divider()
.background(RosettaColors.Adaptive.divider)
.padding(.leading, 16)
HStack {
TextField("Username", text: $username)
.font(.system(size: 17))
.foregroundStyle(RosettaColors.Adaptive.text)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
// Username field
HStack {
TextField("Username", text: $username)
.font(.system(size: 17))
.foregroundStyle(RosettaColors.Adaptive.text)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.onChange(of: username) { _, newValue in
let lowered = newValue.lowercased()
if lowered != newValue {
username = lowered
}
usernameError = ProfileValidator.validateUsername(lowered)?.errorDescription
}
if !username.isEmpty {
Button { username = "" } label: {
Image(systemName: "xmark.circle.fill")
.font(.system(size: 18))
if !username.isEmpty {
Text("\(username.count)/32")
.font(.system(size: 13))
.foregroundStyle(RosettaColors.tertiaryText)
.padding(.trailing, 4)
Button { username = "" } label: {
Image(systemName: "xmark.circle.fill")
.font(.system(size: 18))
.foregroundStyle(RosettaColors.tertiaryText)
}
.buttonStyle(.plain)
}
.buttonStyle(.plain)
}
.padding(.horizontal, 16)
.frame(height: 52)
}
.padding(.horizontal, 16)
.frame(height: 52)
}
// Validation errors below the card
if let displayNameError {
Text(displayNameError)
.font(.system(size: 13))
.foregroundStyle(RosettaColors.error)
.padding(.horizontal, 16)
.padding(.top, 8)
}
if let usernameError {
Text(usernameError)
.font(.system(size: 13))
.foregroundStyle(RosettaColors.error)
.padding(.horizontal, 16)
.padding(.top, displayNameError != nil ? 2 : 8)
}
}
}
}
// MARK: - Add Account & Logout
// MARK: - Add Account & Helpers
private extension ProfileEditView {
var addAccountSection: some View {
@@ -155,21 +185,6 @@ private extension ProfileEditView {
}
}
var logoutSection: some View {
GlassCard(cornerRadius: 26, fillOpacity: 0.08) {
Button(action: onLogout) {
HStack {
Spacer()
Text("Delete Account")
.font(.system(size: 17))
.foregroundStyle(RosettaColors.error)
Spacer()
}
.frame(height: 52)
}
}
}
func helperText(_ text: String) -> some View {
Text(text)
.font(.system(size: 13))
@@ -187,7 +202,9 @@ private extension ProfileEditView {
ProfileEditView(
displayName: .constant("Gaidar"),
username: .constant("GaidarTheDev"),
publicKey: "028d1c9d0000000000000000000000000000000000000000000000000000008e03ec"
publicKey: "028d1c9d0000000000000000000000000000000000000000000000000000008e03ec",
displayNameError: .constant(nil),
usernameError: .constant(nil)
)
}
.background(RosettaColors.Adaptive.background)

View File

@@ -8,12 +8,14 @@ struct SettingsView: View {
@Binding var isEditingProfile: Bool
@StateObject private var viewModel = SettingsViewModel()
@State private var showCopiedToast = false
@State private var showDeleteAccountConfirmation = false
// Edit mode field state initialized when entering edit mode
@State private var editDisplayName = ""
@State private var editUsername = ""
@State private var displayNameError: String?
@State private var usernameError: String?
@State private var isSaving = false
var body: some View {
NavigationStack {
@@ -23,7 +25,8 @@ struct SettingsView: View {
displayName: $editDisplayName,
username: $editUsername,
publicKey: viewModel.publicKey,
onLogout: { showDeleteAccountConfirmation = true }
displayNameError: $displayNameError,
usernameError: $usernameError
)
.transition(.opacity)
} else {
@@ -51,12 +54,8 @@ struct SettingsView: View {
if !isEditing { viewModel.refresh() }
}
}
.overlay(alignment: .top) {
if showCopiedToast {
copiedToast
.transition(.move(edge: .top).combined(with: .opacity))
}
}
}
// MARK: - Toolbar
@@ -66,7 +65,7 @@ struct SettingsView: View {
ToolbarItem(placement: .navigationBarLeading) {
if isEditingProfile {
Button {
withAnimation(.easeInOut(duration: 0.3)) {
withAnimation(.easeInOut(duration: 0.2)) {
isEditingProfile = false
}
} label: {
@@ -78,6 +77,7 @@ struct SettingsView: View {
}
.buttonStyle(.plain)
.glassCapsule()
.disabled(isSaving)
} else {
Button {} label: {
Image(systemName: "qrcode")
@@ -111,7 +111,9 @@ struct SettingsView: View {
Button {
editDisplayName = viewModel.displayName
editUsername = viewModel.username
withAnimation(.easeInOut(duration: 0.3)) {
displayNameError = nil
usernameError = nil
withAnimation(.easeInOut(duration: 0.2)) {
isEditingProfile = true
}
} label: {
@@ -135,29 +137,85 @@ struct SettingsView: View {
private func saveProfile() {
let trimmedName = editDisplayName.trimmingCharacters(in: .whitespaces)
let trimmedUsername = editUsername.trimmingCharacters(in: .whitespaces)
let trimmedUsername = editUsername.trimmingCharacters(in: .whitespaces).lowercased()
if hasProfileChanges {
AccountManager.shared.updateProfile(
displayName: trimmedName,
username: trimmedUsername
)
SessionManager.shared.updateDisplayNameAndUsername(
displayName: trimmedName,
username: trimmedUsername
)
if let hash = SessionManager.shared.privateKeyHash {
var packet = PacketUserInfo()
packet.username = trimmedUsername
packet.title = trimmedName
packet.privateKey = hash
ProtocolManager.shared.sendPacket(packet)
// Validate before saving
if let error = ProfileValidator.validateDisplayName(trimmedName) {
displayNameError = error.errorDescription
return
}
if let error = ProfileValidator.validateUsername(trimmedUsername) {
usernameError = error.errorDescription
return
}
displayNameError = nil
usernameError = nil
guard hasProfileChanges else {
withAnimation(.easeInOut(duration: 0.2)) {
isEditingProfile = false
}
return
}
guard !isSaving else { return }
isSaving = true
// Register one-shot result handler (Android parity: waitPacket(0x02))
let handlerId = ProtocolManager.shared.addResultHandler { result in
Task { @MainActor in
guard isSaving else { return }
isSaving = false
if let code = ResultCode(rawValue: result.resultCode), code == .success {
// Server confirmed update local profile
updateLocalProfile(displayName: trimmedName, username: trimmedUsername)
withAnimation(.easeInOut(duration: 0.2)) {
isEditingProfile = false
}
} else {
// Server returned error
if result.resultCode == ResultCode.usernameTaken.rawValue {
usernameError = "This username is already taken"
} else {
usernameError = "Failed to save profile"
}
}
}
}
withAnimation(.easeInOut(duration: 0.3)) {
isEditingProfile = false
// Send PacketUserInfo to server
if let hash = SessionManager.shared.privateKeyHash {
var packet = PacketUserInfo()
packet.username = trimmedUsername
packet.title = trimmedName
packet.privateKey = hash
ProtocolManager.shared.sendPacket(packet)
}
// 10s timeout fallback to local save (Android parity)
Task { @MainActor in
try? await Task.sleep(nanoseconds: 10_000_000_000)
guard isSaving else { return }
// Server didn't respond save locally as fallback
ProtocolManager.shared.removeResultHandler(handlerId)
isSaving = false
updateLocalProfile(displayName: trimmedName, username: trimmedUsername)
withAnimation(.easeInOut(duration: 0.2)) {
isEditingProfile = false
}
}
}
private func updateLocalProfile(displayName: String, username: String) {
AccountManager.shared.updateProfile(
displayName: displayName,
username: username
)
SessionManager.shared.updateDisplayNameAndUsername(
displayName: displayName,
username: username
)
}
// MARK: - Settings Content
@@ -200,23 +258,13 @@ struct SettingsView: View {
}
}
Button {
viewModel.copyPublicKey()
withAnimation(.easeInOut(duration: 0.25)) { showCopiedToast = true }
Task {
try? await Task.sleep(nanoseconds: 2_000_000_000)
withAnimation { showCopiedToast = false }
}
} label: {
HStack(spacing: 6) {
Text(formatPublicKey(viewModel.publicKey))
.font(.system(size: 12, design: .monospaced))
.foregroundStyle(RosettaColors.tertiaryText)
Image(systemName: "doc.on.doc")
.font(.system(size: 11))
.foregroundStyle(RosettaColors.primaryBlue)
}
}
CopyableText(
displayText: formatPublicKey(viewModel.publicKey),
fullText: viewModel.publicKey,
font: .monospacedSystemFont(ofSize: 12, weight: .regular),
textColor: UIColor(RosettaColors.tertiaryText)
)
.frame(height: 16)
}
.padding(.vertical, 16)
}
@@ -330,14 +378,4 @@ struct SettingsView: View {
return String(key.prefix(8)) + "..." + String(key.suffix(6))
}
private var copiedToast: some View {
Text("Public key copied")
.font(.system(size: 14, weight: .medium))
.foregroundStyle(RosettaColors.Adaptive.text)
.padding(.horizontal, 20)
.padding(.vertical, 10)
.background(RosettaColors.Adaptive.backgroundSecondary)
.clipShape(Capsule())
.padding(.top, 60)
}
}

View File

@@ -79,7 +79,4 @@ final class SettingsViewModel: ObservableObject {
}
}
func copyPublicKey() {
UIPasteboard.general.string = publicKey
}
}

View File

@@ -52,7 +52,7 @@ final class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCent
// MARK: - UNUserNotificationCenterDelegate
/// Handle foreground notifications suppress when app is active (Android parity).
/// Handle foreground notifications suppress only when the specific chat is open.
func userNotificationCenter(
_ center: UNUserNotificationCenter,
willPresent notification: UNNotification,
@@ -62,21 +62,54 @@ final class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCent
let userInfo = notification.request.content.userInfo
let type = userInfo["type"] as? String
// Suppress foreground notifications (Android parity: isAppInForeground check)
if type == "new_message" {
completionHandler([])
let senderKey = userInfo["sender_public_key"] as? String ?? ""
// Only suppress if this specific chat is currently open
if !senderKey.isEmpty,
MessageRepository.shared.isDialogActive(senderKey)
{
completionHandler([])
} else {
completionHandler([.banner, .badge, .sound])
}
} else {
completionHandler([.banner, .badge, .sound])
}
}
/// Handle notification tap navigate to chat.
/// Handle notification tap navigate to the sender's chat.
func userNotificationCenter(
_ center: UNUserNotificationCenter,
didReceive response: UNNotificationResponse,
withCompletionHandler completionHandler: @escaping () -> Void
) {
// TODO: Navigate to specific chat using sender_public_key from payload
let userInfo = response.notification.request.content.userInfo
let senderKey = userInfo["sender_public_key"] as? String ?? ""
if !senderKey.isEmpty {
let senderName = userInfo["sender_name"] as? String ?? ""
let route = ChatRoute(
publicKey: senderKey,
title: senderName,
username: "",
verified: 0
)
// Post notification for ChatListView to handle navigation
NotificationCenter.default.post(
name: .openChatFromNotification,
object: route
)
// Clear all delivered notifications from this sender
center.getDeliveredNotifications { delivered in
let idsToRemove = delivered
.filter { $0.request.content.userInfo["sender_public_key"] as? String == senderKey }
.map { $0.request.identifier }
if !idsToRemove.isEmpty {
center.removeDeliveredNotifications(withIdentifiers: idsToRemove)
}
}
}
completionHandler()
}
}
@@ -131,7 +164,7 @@ struct RosettaApp: App {
.ignoresSafeArea()
.opacity(transitionOverlay ? 1 : 0)
.allowsHitTesting(transitionOverlay)
.animation(.easeInOut(duration: 0.12), value: transitionOverlay)
.animation(.easeInOut(duration: 0.08), value: transitionOverlay)
}
.preferredColorScheme(.dark)
.onAppear {
@@ -145,8 +178,10 @@ struct RosettaApp: App {
@MainActor static var _bodyCount = 0
@ViewBuilder
private func rootView(for state: AppState) -> some View {
#if DEBUG
let _ = Self._bodyCount += 1
let _ = print("⭐ RosettaApp.rootView #\(Self._bodyCount) state=\(state)")
#endif
switch state {
case .onboarding:
OnboardingView {
@@ -193,9 +228,9 @@ struct RosettaApp: App {
guard !transitionOverlay else { return }
transitionOverlay = true
Task { @MainActor in
try? await Task.sleep(nanoseconds: 140_000_000) // wait for overlay fade-in
try? await Task.sleep(nanoseconds: 80_000_000) // wait for overlay fade-in
appState = newState
try? await Task.sleep(nanoseconds: 30_000_000) // brief settle
try? await Task.sleep(nanoseconds: 20_000_000) // brief settle
transitionOverlay = false
}
}
@@ -209,3 +244,10 @@ struct RosettaApp: App {
}
}
}
// MARK: - Notification Names
extension Notification.Name {
/// Posted when user taps a push notification carries a `ChatRoute` as `object`.
static let openChatFromNotification = Notification.Name("openChatFromNotification")
}