Исправление аватарки на экране разблокировки, плавная анимация инпута, онлайн-статус по входящим сообщениям, push-навигация в чат, оптимизация debug-логов
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -6,6 +6,7 @@ sprints/
|
|||||||
CLAUDE.md
|
CLAUDE.md
|
||||||
.claude.local.md
|
.claude.local.md
|
||||||
desktop
|
desktop
|
||||||
|
AGENTS.md
|
||||||
|
|
||||||
# Xcode
|
# Xcode
|
||||||
build/
|
build/
|
||||||
|
|||||||
@@ -272,7 +272,7 @@
|
|||||||
CODE_SIGN_ENTITLEMENTS = Rosetta/Rosetta.entitlements;
|
CODE_SIGN_ENTITLEMENTS = Rosetta/Rosetta.entitlements;
|
||||||
CODE_SIGN_IDENTITY = "Apple Development";
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 10;
|
CURRENT_PROJECT_VERSION = 11;
|
||||||
DEVELOPMENT_TEAM = QN8Z263QGX;
|
DEVELOPMENT_TEAM = QN8Z263QGX;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
@@ -288,7 +288,7 @@
|
|||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.0.9;
|
MARKETING_VERSION = 1.0.10;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.rosetta.dev;
|
PRODUCT_BUNDLE_IDENTIFIER = com.rosetta.dev;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||||
@@ -311,7 +311,7 @@
|
|||||||
CODE_SIGN_ENTITLEMENTS = Rosetta/Rosetta.entitlements;
|
CODE_SIGN_ENTITLEMENTS = Rosetta/Rosetta.entitlements;
|
||||||
CODE_SIGN_IDENTITY = "Apple Development";
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 10;
|
CURRENT_PROJECT_VERSION = 11;
|
||||||
DEVELOPMENT_TEAM = QN8Z263QGX;
|
DEVELOPMENT_TEAM = QN8Z263QGX;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
@@ -327,7 +327,7 @@
|
|||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.0.9;
|
MARKETING_VERSION = 1.0.10;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.rosetta.dev;
|
PRODUCT_BUNDLE_IDENTIFIER = com.rosetta.dev;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||||
|
|||||||
@@ -31,7 +31,7 @@
|
|||||||
shouldAutocreateTestPlan = "YES">
|
shouldAutocreateTestPlan = "YES">
|
||||||
</TestAction>
|
</TestAction>
|
||||||
<LaunchAction
|
<LaunchAction
|
||||||
buildConfiguration = "Debug"
|
buildConfiguration = "Release"
|
||||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||||
launchStyle = "0"
|
launchStyle = "0"
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ final class CryptoManager: @unchecked Sendable {
|
|||||||
let compressed = try CryptoPrimitives.rawDeflate(data)
|
let compressed = try CryptoPrimitives.rawDeflate(data)
|
||||||
let key = CryptoPrimitives.pbkdf2(
|
let key = CryptoPrimitives.pbkdf2(
|
||||||
password: password, salt: "rosetta", iterations: 1000,
|
password: password, salt: "rosetta", iterations: 1000,
|
||||||
keyLength: 32, prf: CCPseudoRandomAlgorithm(kCCPRFHmacAlgSHA1)
|
keyLength: 32, prf: CCPseudoRandomAlgorithm(kCCPRFHmacAlgSHA256)
|
||||||
)
|
)
|
||||||
let iv = try CryptoPrimitives.randomBytes(count: 16)
|
let iv = try CryptoPrimitives.randomBytes(count: 16)
|
||||||
let ciphertext = try CryptoPrimitives.aesCBCEncrypt(compressed, key: key, iv: iv)
|
let ciphertext = try CryptoPrimitives.aesCBCEncrypt(compressed, key: key, iv: iv)
|
||||||
|
|||||||
@@ -21,17 +21,34 @@ actor ChatPersistenceStore {
|
|||||||
rootDirectory = directory
|
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)
|
let fileURL = rootDirectory.appendingPathComponent(fileName, isDirectory: false)
|
||||||
guard FileManager.default.fileExists(atPath: fileURL.path) else { return nil }
|
guard FileManager.default.fileExists(atPath: fileURL.path) else { return nil }
|
||||||
guard let data = try? Data(contentsOf: fileURL) 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)
|
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)
|
let fileURL = rootDirectory.appendingPathComponent(fileName, isDirectory: false)
|
||||||
guard let data = try? encoder.encode(value) else { return }
|
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) {
|
func remove(fileName: String) {
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ final class DialogRepository {
|
|||||||
didSet { _sortedDialogsCache = nil }
|
didSet { _sortedDialogsCache = nil }
|
||||||
}
|
}
|
||||||
private var currentAccount: String = ""
|
private var currentAccount: String = ""
|
||||||
|
private var storagePassword: String = ""
|
||||||
private var persistTask: Task<Void, Never>?
|
private var persistTask: Task<Void, Never>?
|
||||||
private var _sortedDialogsCache: [Dialog]?
|
private var _sortedDialogsCache: [Dialog]?
|
||||||
|
|
||||||
@@ -27,23 +28,30 @@ final class DialogRepository {
|
|||||||
|
|
||||||
private init() {}
|
private init() {}
|
||||||
|
|
||||||
func bootstrap(accountPublicKey: String) async {
|
func bootstrap(accountPublicKey: String, storagePassword: String) async {
|
||||||
let account = accountPublicKey.trimmingCharacters(in: .whitespacesAndNewlines)
|
let account = accountPublicKey.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
guard !account.isEmpty else {
|
guard !account.isEmpty else {
|
||||||
reset()
|
reset()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if currentAccount == account, !dialogs.isEmpty {
|
if currentAccount == account,
|
||||||
|
self.storagePassword == storagePassword,
|
||||||
|
!dialogs.isEmpty {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
currentAccount = account
|
currentAccount = account
|
||||||
|
self.storagePassword = storagePassword
|
||||||
persistTask?.cancel()
|
persistTask?.cancel()
|
||||||
persistTask = nil
|
persistTask = nil
|
||||||
|
|
||||||
let fileName = Self.dialogsFileName(for: account)
|
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(
|
dialogs = Dictionary(
|
||||||
uniqueKeysWithValues: stored
|
uniqueKeysWithValues: stored
|
||||||
.filter { $0.account == account }
|
.filter { $0.account == account }
|
||||||
@@ -55,6 +63,7 @@ final class DialogRepository {
|
|||||||
persistTask?.cancel()
|
persistTask?.cancel()
|
||||||
persistTask = nil
|
persistTask = nil
|
||||||
dialogs.removeAll()
|
dialogs.removeAll()
|
||||||
|
storagePassword = ""
|
||||||
|
|
||||||
guard !currentAccount.isEmpty else { return }
|
guard !currentAccount.isEmpty else { return }
|
||||||
let accountToReset = currentAccount
|
let accountToReset = currentAccount
|
||||||
@@ -186,15 +195,19 @@ final class DialogRepository {
|
|||||||
func updateDeliveryStatus(messageId: String, opponentKey: String, status: DeliveryStatus) {
|
func updateDeliveryStatus(messageId: String, opponentKey: String, status: DeliveryStatus) {
|
||||||
guard var dialog = dialogs[opponentKey] else { return }
|
guard var dialog = dialogs[opponentKey] else { return }
|
||||||
let current = dialog.lastMessageDelivered
|
let current = dialog.lastMessageDelivered
|
||||||
if current == .read, status == .delivered {
|
if current == status { return }
|
||||||
return
|
|
||||||
}
|
// Desktop parity: desktop reads the actual last message from the DB at
|
||||||
if current == .read, status == .waiting {
|
// render time (useDialogInfo → SELECT * FROM messages WHERE message_id = ?).
|
||||||
return
|
// On iOS we cache lastMessageDelivered, so we must only accept updates
|
||||||
}
|
// from the latest outgoing message to avoid stale ACKs / error timers
|
||||||
if current == .delivered, status == .waiting {
|
// 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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
dialog.lastMessageDelivered = status
|
dialog.lastMessageDelivered = status
|
||||||
dialogs[opponentKey] = dialog
|
dialogs[opponentKey] = dialog
|
||||||
schedulePersist()
|
schedulePersist()
|
||||||
@@ -347,11 +360,16 @@ final class DialogRepository {
|
|||||||
|
|
||||||
let snapshot = Array(dialogs.values)
|
let snapshot = Array(dialogs.values)
|
||||||
let fileName = Self.dialogsFileName(for: currentAccount)
|
let fileName = Self.dialogsFileName(for: currentAccount)
|
||||||
|
let storagePassword = self.storagePassword
|
||||||
persistTask?.cancel()
|
persistTask?.cancel()
|
||||||
persistTask = Task(priority: .utility) {
|
persistTask = Task(priority: .utility) {
|
||||||
try? await Task.sleep(for: .milliseconds(180))
|
try? await Task.sleep(for: .milliseconds(180))
|
||||||
guard !Task.isCancelled else { return }
|
guard !Task.isCancelled else { return }
|
||||||
await ChatPersistenceStore.shared.save(snapshot, fileName: fileName)
|
await ChatPersistenceStore.shared.save(
|
||||||
|
snapshot,
|
||||||
|
fileName: fileName,
|
||||||
|
password: storagePassword.isEmpty ? nil : storagePassword
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,21 +16,25 @@ final class MessageRepository: ObservableObject {
|
|||||||
private var typingResetTasks: [String: Task<Void, Never>] = [:]
|
private var typingResetTasks: [String: Task<Void, Never>] = [:]
|
||||||
private var persistTask: Task<Void, Never>?
|
private var persistTask: Task<Void, Never>?
|
||||||
private var currentAccount: String = ""
|
private var currentAccount: String = ""
|
||||||
|
private var storagePassword: String = ""
|
||||||
|
|
||||||
private init() {}
|
private init() {}
|
||||||
|
|
||||||
func bootstrap(accountPublicKey: String) async {
|
func bootstrap(accountPublicKey: String, storagePassword: String) async {
|
||||||
let account = accountPublicKey.trimmingCharacters(in: .whitespacesAndNewlines)
|
let account = accountPublicKey.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
guard !account.isEmpty else {
|
guard !account.isEmpty else {
|
||||||
reset()
|
reset()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if currentAccount == account, !messagesByDialog.isEmpty {
|
if currentAccount == account,
|
||||||
|
self.storagePassword == storagePassword,
|
||||||
|
!messagesByDialog.isEmpty {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
currentAccount = account
|
currentAccount = account
|
||||||
|
self.storagePassword = storagePassword
|
||||||
persistTask?.cancel()
|
persistTask?.cancel()
|
||||||
persistTask = nil
|
persistTask = nil
|
||||||
activeDialogs.removeAll()
|
activeDialogs.removeAll()
|
||||||
@@ -42,7 +46,11 @@ final class MessageRepository: ObservableObject {
|
|||||||
messageToDialog.removeAll()
|
messageToDialog.removeAll()
|
||||||
|
|
||||||
let fileName = Self.messagesFileName(for: account)
|
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]] = [:]
|
var restored: [String: [ChatMessage]] = [:]
|
||||||
for (dialogKey, list) in stored {
|
for (dialogKey, list) in stored {
|
||||||
var sorted = list.sorted {
|
var sorted = list.sorted {
|
||||||
@@ -278,6 +286,7 @@ final class MessageRepository: ObservableObject {
|
|||||||
typingDialogs.removeAll()
|
typingDialogs.removeAll()
|
||||||
activeDialogs.removeAll()
|
activeDialogs.removeAll()
|
||||||
messageToDialog.removeAll()
|
messageToDialog.removeAll()
|
||||||
|
storagePassword = ""
|
||||||
|
|
||||||
guard !currentAccount.isEmpty else { return }
|
guard !currentAccount.isEmpty else { return }
|
||||||
let accountToReset = currentAccount
|
let accountToReset = currentAccount
|
||||||
@@ -327,11 +336,16 @@ final class MessageRepository: ObservableObject {
|
|||||||
|
|
||||||
let snapshot = messagesByDialog
|
let snapshot = messagesByDialog
|
||||||
let fileName = Self.messagesFileName(for: currentAccount)
|
let fileName = Self.messagesFileName(for: currentAccount)
|
||||||
|
let storagePassword = self.storagePassword
|
||||||
persistTask?.cancel()
|
persistTask?.cancel()
|
||||||
persistTask = Task(priority: .utility) {
|
persistTask = Task(priority: .utility) {
|
||||||
try? await Task.sleep(for: .milliseconds(220))
|
try? await Task.sleep(for: .milliseconds(220))
|
||||||
guard !Task.isCancelled else { return }
|
guard !Task.isCancelled else { return }
|
||||||
await ChatPersistenceStore.shared.save(snapshot, fileName: fileName)
|
await ChatPersistenceStore.shared.save(
|
||||||
|
snapshot,
|
||||||
|
fileName: fileName,
|
||||||
|
password: storagePassword.isEmpty ? nil : storagePassword
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ struct PacketHandshake: Packet {
|
|||||||
var protocolVersion: Int = 1
|
var protocolVersion: Int = 1
|
||||||
var heartbeatInterval: Int = 15
|
var heartbeatInterval: Int = 15
|
||||||
var device = HandshakeDevice()
|
var device = HandshakeDevice()
|
||||||
var handshakeState: HandshakeState = .completed
|
var handshakeState: HandshakeState = .needDeviceVerification
|
||||||
|
|
||||||
func write(to stream: Stream) {
|
func write(to stream: Stream) {
|
||||||
stream.writeString(privateKey)
|
stream.writeString(privateKey)
|
||||||
|
|||||||
@@ -57,8 +57,10 @@ final class ProtocolManager: @unchecked Sendable {
|
|||||||
private var heartbeatTask: Task<Void, Never>?
|
private var heartbeatTask: Task<Void, Never>?
|
||||||
private var handshakeTimeoutTask: Task<Void, Never>?
|
private var handshakeTimeoutTask: Task<Void, Never>?
|
||||||
private let searchHandlersLock = NSLock()
|
private let searchHandlersLock = NSLock()
|
||||||
|
private let resultHandlersLock = NSLock()
|
||||||
private let packetQueueLock = NSLock()
|
private let packetQueueLock = NSLock()
|
||||||
private var searchResultHandlers: [UUID: (PacketSearch) -> Void] = [:]
|
private var searchResultHandlers: [UUID: (PacketSearch) -> Void] = [:]
|
||||||
|
private var resultHandlers: [UUID: (PacketResult) -> Void] = [:]
|
||||||
|
|
||||||
// Saved credentials for auto-reconnect
|
// Saved credentials for auto-reconnect
|
||||||
private var savedPublicKey: String?
|
private var savedPublicKey: String?
|
||||||
@@ -98,6 +100,15 @@ final class ProtocolManager: @unchecked Sendable {
|
|||||||
savedPrivateHash = nil
|
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
|
// MARK: - Sending
|
||||||
|
|
||||||
func sendPacket(_ packet: any Packet) {
|
func sendPacket(_ packet: any Packet) {
|
||||||
@@ -128,6 +139,24 @@ final class ProtocolManager: @unchecked Sendable {
|
|||||||
searchHandlersLock.unlock()
|
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
|
// MARK: - Private Setup
|
||||||
|
|
||||||
private func setupClientCallbacks() {
|
private func setupClientCallbacks() {
|
||||||
@@ -173,7 +202,7 @@ final class ProtocolManager: @unchecked Sendable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let device = HandshakeDevice(
|
let device = HandshakeDevice(
|
||||||
deviceId: UIDevice.current.identifierForVendor?.uuidString ?? "unknown",
|
deviceId: DeviceIdentityManager.shared.currentDeviceId(),
|
||||||
deviceName: UIDevice.current.name,
|
deviceName: UIDevice.current.name,
|
||||||
deviceOs: "iOS \(UIDevice.current.systemVersion)"
|
deviceOs: "iOS \(UIDevice.current.systemVersion)"
|
||||||
)
|
)
|
||||||
@@ -184,7 +213,7 @@ final class ProtocolManager: @unchecked Sendable {
|
|||||||
protocolVersion: 1,
|
protocolVersion: 1,
|
||||||
heartbeatInterval: 15,
|
heartbeatInterval: 15,
|
||||||
device: device,
|
device: device,
|
||||||
handshakeState: .completed
|
handshakeState: .needDeviceVerification
|
||||||
)
|
)
|
||||||
|
|
||||||
sendPacketDirect(handshake)
|
sendPacketDirect(handshake)
|
||||||
@@ -238,7 +267,8 @@ final class ProtocolManager: @unchecked Sendable {
|
|||||||
}
|
}
|
||||||
case 0x02:
|
case 0x02:
|
||||||
if let p = packet as? PacketResult {
|
if let p = packet as? PacketResult {
|
||||||
let _ = ResultCode(rawValue: p.resultCode)
|
Self.logger.info("📥 PacketResult: code=\(p.resultCode)")
|
||||||
|
notifyResultHandlers(p)
|
||||||
}
|
}
|
||||||
case 0x03:
|
case 0x03:
|
||||||
if let p = packet as? PacketSearch {
|
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) {
|
private func handleHandshakeResponse(_ packet: PacketHandshake) {
|
||||||
handshakeTimeoutTask?.cancel()
|
handshakeTimeoutTask?.cancel()
|
||||||
handshakeTimeoutTask = nil
|
handshakeTimeoutTask = nil
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ final class WebSocketClient: NSObject, @unchecked Sendable, URLSessionWebSocketD
|
|||||||
private var hasNotifiedConnected = false
|
private var hasNotifiedConnected = false
|
||||||
private(set) var isConnected = false
|
private(set) var isConnected = false
|
||||||
private var disconnectHandledForCurrentSocket = false
|
private var disconnectHandledForCurrentSocket = false
|
||||||
private var reconnectAttempt = 0
|
|
||||||
|
|
||||||
var onConnected: (() -> Void)?
|
var onConnected: (() -> Void)?
|
||||||
var onDisconnected: ((Error?) -> Void)?
|
var onDisconnected: ((Error?) -> Void)?
|
||||||
@@ -55,6 +54,20 @@ final class WebSocketClient: NSObject, @unchecked Sendable, URLSessionWebSocketD
|
|||||||
isConnected = false
|
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
|
@discardableResult
|
||||||
func send(_ data: Data, onFailure: ((Error?) -> Void)? = nil) -> Bool {
|
func send(_ data: Data, onFailure: ((Error?) -> Void)? = nil) -> Bool {
|
||||||
guard isConnected, let task = webSocketTask else {
|
guard isConnected, let task = webSocketTask else {
|
||||||
@@ -89,7 +102,6 @@ final class WebSocketClient: NSObject, @unchecked Sendable, URLSessionWebSocketD
|
|||||||
hasNotifiedConnected = true
|
hasNotifiedConnected = true
|
||||||
isConnected = true
|
isConnected = true
|
||||||
disconnectHandledForCurrentSocket = false
|
disconnectHandledForCurrentSocket = false
|
||||||
reconnectAttempt = 0
|
|
||||||
reconnectTask?.cancel()
|
reconnectTask?.cancel()
|
||||||
reconnectTask = nil
|
reconnectTask = nil
|
||||||
onConnected?()
|
onConnected?()
|
||||||
@@ -142,12 +154,10 @@ final class WebSocketClient: NSObject, @unchecked Sendable, URLSessionWebSocketD
|
|||||||
guard !isManuallyClosed else { return }
|
guard !isManuallyClosed else { return }
|
||||||
|
|
||||||
guard reconnectTask == nil else { return }
|
guard reconnectTask == nil else { return }
|
||||||
let attempt = reconnectAttempt
|
// Fixed 5-second reconnect interval (desktop parity)
|
||||||
reconnectAttempt += 1
|
let delaySeconds: Double = 5.0
|
||||||
// Exponential backoff: 5s, 7.5s, 11.25s, ... capped at 30s
|
|
||||||
let delaySeconds = min(5.0 * pow(1.5, Double(attempt)), 30.0)
|
|
||||||
reconnectTask = Task { [weak self] in
|
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))
|
try? await Task.sleep(nanoseconds: UInt64(delaySeconds * 1_000_000_000))
|
||||||
guard let self, !isManuallyClosed, !Task.isCancelled else { return }
|
guard let self, !isManuallyClosed, !Task.isCancelled else { return }
|
||||||
self.reconnectTask = nil
|
self.reconnectTask = nil
|
||||||
|
|||||||
79
Rosetta/Core/Services/DeviceIdentityManager.swift
Normal file
79
Rosetta/Core/Services/DeviceIdentityManager.swift
Normal 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] })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -91,8 +91,14 @@ final class SessionManager {
|
|||||||
username = account.username ?? ""
|
username = account.username ?? ""
|
||||||
|
|
||||||
// Warm local state immediately, then let network sync reconcile updates.
|
// Warm local state immediately, then let network sync reconcile updates.
|
||||||
await DialogRepository.shared.bootstrap(accountPublicKey: account.publicKey)
|
await DialogRepository.shared.bootstrap(
|
||||||
await MessageRepository.shared.bootstrap(accountPublicKey: account.publicKey)
|
accountPublicKey: account.publicKey,
|
||||||
|
storagePassword: privateKeyHex
|
||||||
|
)
|
||||||
|
await MessageRepository.shared.bootstrap(
|
||||||
|
accountPublicKey: account.publicKey,
|
||||||
|
storagePassword: privateKeyHex
|
||||||
|
)
|
||||||
RecentSearchesRepository.shared.setAccount(account.publicKey)
|
RecentSearchesRepository.shared.setAccount(account.publicKey)
|
||||||
|
|
||||||
// Generate private key hash for handshake
|
// Generate private key hash for handshake
|
||||||
@@ -177,7 +183,7 @@ final class SessionManager {
|
|||||||
registerOutgoingRetry(for: packet)
|
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) {
|
func sendTypingIndicator(toPublicKey: String) {
|
||||||
guard toPublicKey != currentPublicKey,
|
guard toPublicKey != currentPublicKey,
|
||||||
let hash = privateKeyHash,
|
let hash = privateKeyHash,
|
||||||
@@ -186,7 +192,7 @@ final class SessionManager {
|
|||||||
|
|
||||||
let now = Int64(Date().timeIntervalSince1970 * 1000)
|
let now = Int64(Date().timeIntervalSince1970 * 1000)
|
||||||
let lastSent = lastTypingSentAt[toPublicKey] ?? 0
|
let lastSent = lastTypingSentAt[toPublicKey] ?? 0
|
||||||
if now - lastSent < 2_000 {
|
if now - lastSent < ProtocolConstants.typingThrottleMs {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
lastTypingSentAt[toPublicKey] = now
|
lastTypingSentAt[toPublicKey] = now
|
||||||
@@ -387,6 +393,9 @@ final class SessionManager {
|
|||||||
// Desktop parity: request message synchronization after authentication.
|
// Desktop parity: request message synchronization after authentication.
|
||||||
self.requestSynchronize()
|
self.requestSynchronize()
|
||||||
self.retryWaitingOutgoingMessagesAfterReconnect()
|
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.
|
// Clear dedup sets on reconnect so subscriptions can be re-established lazily.
|
||||||
self.requestedUserInfoKeys.removeAll()
|
self.requestedUserInfoKeys.removeAll()
|
||||||
@@ -529,6 +538,13 @@ final class SessionManager {
|
|||||||
fromSync: syncBatchInProgress
|
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]
|
let dialog = DialogRepository.shared.dialogs[opponentKey]
|
||||||
|
|
||||||
if dialog?.opponentTitle.isEmpty == true {
|
if dialog?.opponentTitle.isEmpty == true {
|
||||||
@@ -630,6 +646,19 @@ final class SessionManager {
|
|||||||
ProtocolManager.shared.sendPacket(packet)
|
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) {
|
private func requestSynchronize(cursor: Int64? = nil) {
|
||||||
guard ProtocolManager.shared.connectionState == .authenticated else { return }
|
guard ProtocolManager.shared.connectionState == .authenticated else { return }
|
||||||
guard !syncRequestInFlight else { return }
|
guard !syncRequestInFlight else { return }
|
||||||
@@ -1064,7 +1093,7 @@ final class SessionManager {
|
|||||||
// MARK: - Idle Detection Setup
|
// MARK: - Idle Detection Setup
|
||||||
|
|
||||||
private func setupIdleDetection() {
|
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(
|
idleObserverToken = NotificationCenter.default.addObserver(
|
||||||
forName: UIApplication.willEnterForegroundNotification,
|
forName: UIApplication.willEnterForegroundNotification,
|
||||||
object: nil,
|
object: nil,
|
||||||
@@ -1072,6 +1101,10 @@ final class SessionManager {
|
|||||||
) { [weak self] _ in
|
) { [weak self] _ in
|
||||||
Task { @MainActor [weak self] in
|
Task { @MainActor [weak self] in
|
||||||
self?.lastUserInteractionTime = Date()
|
self?.lastUserInteractionTime = Date()
|
||||||
|
// Immediate reconnect when returning from background
|
||||||
|
if ProtocolManager.shared.connectionState != .authenticated {
|
||||||
|
ProtocolManager.shared.reconnectIfNeeded()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
67
Rosetta/Core/Utils/ProfileValidator.swift
Normal file
67
Rosetta/Core/Utils/ProfileValidator.swift
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -44,8 +44,8 @@ enum ProtocolConstants {
|
|||||||
/// Read receipt throttle interval in milliseconds.
|
/// Read receipt throttle interval in milliseconds.
|
||||||
static let readReceiptThrottleMs: Int64 = 400
|
static let readReceiptThrottleMs: Int64 = 400
|
||||||
|
|
||||||
/// Typing indicator throttle interval in milliseconds.
|
/// Typing indicator throttle interval in milliseconds (desktop parity).
|
||||||
static let typingThrottleMs: Int64 = 2_000
|
static let typingThrottleMs: Int64 = 3_000
|
||||||
|
|
||||||
/// Typing indicator display timeout in seconds.
|
/// Typing indicator display timeout in seconds.
|
||||||
static let typingDisplayTimeoutS: TimeInterval = 3
|
static let typingDisplayTimeoutS: TimeInterval = 3
|
||||||
|
|||||||
270
Rosetta/DesignSystem/Components/ChatTextInput.swift
Normal file
270
Rosetta/DesignSystem/Components/ChatTextInput.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
94
Rosetta/DesignSystem/Components/CopyableText.swift
Normal file
94
Rosetta/DesignSystem/Components/CopyableText.swift
Normal 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])
|
||||||
|
}
|
||||||
|
}
|
||||||
158
Rosetta/DesignSystem/Components/KeyboardTracker.swift
Normal file
158
Rosetta/DesignSystem/Components/KeyboardTracker.swift
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -68,7 +68,7 @@ struct AuthCoordinator: View {
|
|||||||
.ignoresSafeArea()
|
.ignoresSafeArea()
|
||||||
.opacity(fadeOverlay ? 1 : 0)
|
.opacity(fadeOverlay ? 1 : 0)
|
||||||
.allowsHitTesting(fadeOverlay)
|
.allowsHitTesting(fadeOverlay)
|
||||||
.animation(.easeInOut(duration: 0.12), value: fadeOverlay)
|
.animation(.easeInOut(duration: 0.08), value: fadeOverlay)
|
||||||
}
|
}
|
||||||
.overlay(alignment: .leading) {
|
.overlay(alignment: .leading) {
|
||||||
if canSwipeBack {
|
if canSwipeBack {
|
||||||
@@ -174,9 +174,9 @@ private extension AuthCoordinator {
|
|||||||
navigationDirection = .forward
|
navigationDirection = .forward
|
||||||
fadeOverlay = true
|
fadeOverlay = true
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
try? await Task.sleep(nanoseconds: 140_000_000)
|
try? await Task.sleep(nanoseconds: 80_000_000)
|
||||||
currentScreen = screen
|
currentScreen = screen
|
||||||
try? await Task.sleep(nanoseconds: 30_000_000)
|
try? await Task.sleep(nanoseconds: 20_000_000)
|
||||||
fadeOverlay = false
|
fadeOverlay = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ struct SetPasswordView: View {
|
|||||||
@State private var isCreating = false
|
@State private var isCreating = false
|
||||||
@State private var errorMessage: String?
|
@State private var errorMessage: String?
|
||||||
@State private var focusedField: Field?
|
@State private var focusedField: Field?
|
||||||
|
@State private var showWeakPasswordAlert = false
|
||||||
|
|
||||||
fileprivate enum Field {
|
fileprivate enum Field {
|
||||||
case password, confirm
|
case password, confirm
|
||||||
@@ -25,6 +26,12 @@ struct SetPasswordView: View {
|
|||||||
passwordsMatch && !isCreating
|
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 {
|
var body: some View {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
AuthNavigationBar(onBack: onBack)
|
AuthNavigationBar(onBack: onBack)
|
||||||
@@ -72,6 +79,14 @@ struct SetPasswordView: View {
|
|||||||
.padding(.bottom, 16)
|
.padding(.bottom, 16)
|
||||||
}
|
}
|
||||||
.ignoresSafeArea(.keyboard)
|
.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() {
|
func createAccount() {
|
||||||
|
guard canCreate else { return }
|
||||||
|
guard matchesDesktopPasswordPolicy else {
|
||||||
|
showWeakPasswordAlert = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
performAccountCreation()
|
||||||
|
}
|
||||||
|
|
||||||
|
func performAccountCreation() {
|
||||||
guard canCreate else { return }
|
guard canCreate else { return }
|
||||||
isCreating = true
|
isCreating = true
|
||||||
|
|
||||||
|
|||||||
@@ -24,13 +24,13 @@ struct UnlockView: View {
|
|||||||
account?.publicKey ?? ""
|
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 {
|
private var avatarText: String {
|
||||||
RosettaColors.avatarText(publicKey: publicKey)
|
RosettaColors.initials(name: account?.displayName ?? "", publicKey: publicKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
private var avatarColorIndex: Int {
|
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").
|
/// Short public key — 7 characters like Android (e.g. "0325a4d").
|
||||||
@@ -67,8 +67,8 @@ struct UnlockView: View {
|
|||||||
|
|
||||||
Spacer().frame(height: 20)
|
Spacer().frame(height: 20)
|
||||||
|
|
||||||
// Short public key (7 chars like Android)
|
// Display name (or short public key fallback)
|
||||||
Text(shortPublicKey)
|
Text(displayTitle)
|
||||||
.font(.system(size: 24, weight: .bold))
|
.font(.system(size: 24, weight: .bold))
|
||||||
.foregroundStyle(.white)
|
.foregroundStyle(.white)
|
||||||
.opacity(showTitle ? 1 : 0)
|
.opacity(showTitle ? 1 : 0)
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import UserNotifications
|
||||||
|
|
||||||
struct ChatDetailView: View {
|
struct ChatDetailView: View {
|
||||||
let route: ChatRoute
|
let route: ChatRoute
|
||||||
@@ -17,7 +18,8 @@ struct ChatDetailView: View {
|
|||||||
@State private var sendError: String?
|
@State private var sendError: String?
|
||||||
@State private var isViewActive = false
|
@State private var isViewActive = false
|
||||||
// markReadTask removed — read receipts no longer sent from .onChange(of: messages.count)
|
// 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 {
|
private var currentPublicKey: String {
|
||||||
SessionManager.shared.currentPublicKey
|
SessionManager.shared.currentPublicKey
|
||||||
@@ -97,7 +99,11 @@ struct ChatDetailView: View {
|
|||||||
messagesList(maxBubbleWidth: max(min(geometry.size.width * 0.72, 380), 140))
|
messagesList(maxBubbleWidth: max(min(geometry.size.width * 0.72, 380), 140))
|
||||||
}
|
}
|
||||||
.overlay { chatEdgeGradients }
|
.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 {
|
.background {
|
||||||
ZStack {
|
ZStack {
|
||||||
RosettaColors.Adaptive.background
|
RosettaColors.Adaptive.background
|
||||||
@@ -124,8 +130,15 @@ struct ChatDetailView: View {
|
|||||||
guard isViewActive else { return }
|
guard isViewActive else { return }
|
||||||
activateDialog()
|
activateDialog()
|
||||||
markDialogAsRead()
|
markDialogAsRead()
|
||||||
|
// Clear delivered notifications from this sender
|
||||||
|
clearDeliveredNotifications(for: route.publicKey)
|
||||||
// Subscribe to opponent's online status (Android parity) — only after settled
|
// Subscribe to opponent's online status (Android parity) — only after settled
|
||||||
SessionManager.shared.subscribeToOnlineStatus(publicKey: route.publicKey)
|
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 {
|
.onDisappear {
|
||||||
isViewActive = false
|
isViewActive = false
|
||||||
@@ -177,7 +190,9 @@ private extension ChatDetailView {
|
|||||||
Text(subtitleText)
|
Text(subtitleText)
|
||||||
.font(.system(size: 12, weight: .medium))
|
.font(.system(size: 12, weight: .medium))
|
||||||
.foregroundStyle(
|
.foregroundStyle(
|
||||||
isTyping || (dialog?.isOnline == true)
|
isTyping
|
||||||
|
? RosettaColors.primaryBlue
|
||||||
|
: (dialog?.isOnline == true)
|
||||||
? RosettaColors.online
|
? RosettaColors.online
|
||||||
: RosettaColors.Adaptive.textSecondary
|
: RosettaColors.Adaptive.textSecondary
|
||||||
)
|
)
|
||||||
@@ -185,6 +200,7 @@ private extension ChatDetailView {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 12)
|
.padding(.horizontal, 12)
|
||||||
|
.frame(minWidth: 120)
|
||||||
.frame(height: 44)
|
.frame(height: 44)
|
||||||
.background {
|
.background {
|
||||||
glass(shape: .capsule, strokeOpacity: 0.22, strokeColor: .white)
|
glass(shape: .capsule, strokeOpacity: 0.22, strokeColor: .white)
|
||||||
@@ -229,7 +245,9 @@ private extension ChatDetailView {
|
|||||||
Text(subtitleText)
|
Text(subtitleText)
|
||||||
.font(.system(size: 12, weight: .medium))
|
.font(.system(size: 12, weight: .medium))
|
||||||
.foregroundStyle(
|
.foregroundStyle(
|
||||||
isTyping || (dialog?.isOnline == true)
|
isTyping
|
||||||
|
? RosettaColors.primaryBlue
|
||||||
|
: (dialog?.isOnline == true)
|
||||||
? RosettaColors.online
|
? RosettaColors.online
|
||||||
: RosettaColors.Adaptive.textSecondary
|
: RosettaColors.Adaptive.textSecondary
|
||||||
)
|
)
|
||||||
@@ -237,6 +255,7 @@ private extension ChatDetailView {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 16)
|
.padding(.horizontal, 16)
|
||||||
|
.frame(minWidth: 120)
|
||||||
.frame(height: 44)
|
.frame(height: 44)
|
||||||
.background {
|
.background {
|
||||||
glass(shape: .capsule, strokeOpacity: 0.22, strokeColor: .white)
|
glass(shape: .capsule, strokeOpacity: 0.22, strokeColor: .white)
|
||||||
@@ -416,7 +435,7 @@ private extension ChatDetailView {
|
|||||||
.padding(.top, messagesTopInset)
|
.padding(.top, messagesTopInset)
|
||||||
.padding(.bottom, 10)
|
.padding(.bottom, 10)
|
||||||
}
|
}
|
||||||
.scrollDismissesKeyboard(.immediately)
|
.scrollDismissesKeyboard(.interactively)
|
||||||
.onTapGesture { isInputFocused = false }
|
.onTapGesture { isInputFocused = false }
|
||||||
.onAppear {
|
.onAppear {
|
||||||
DispatchQueue.main.async { scrollToBottom(proxy: proxy, animated: false) }
|
DispatchQueue.main.async { scrollToBottom(proxy: proxy, animated: false) }
|
||||||
@@ -440,13 +459,8 @@ private extension ChatDetailView {
|
|||||||
guard focused else { return }
|
guard focused else { return }
|
||||||
// User tapped the input — reset idle timer.
|
// User tapped the input — reset idle timer.
|
||||||
SessionManager.shared.recordUserInteraction()
|
SessionManager.shared.recordUserInteraction()
|
||||||
// Delay matches keyboard animation (~250ms) so scroll happens after layout settles.
|
scrollToBottom(proxy: proxy, animated: false)
|
||||||
Task { @MainActor in
|
|
||||||
try? await Task.sleep(nanoseconds: 300_000_000)
|
|
||||||
scrollToBottom(proxy: proxy, animated: true)
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
scroll
|
scroll
|
||||||
.defaultScrollAnchor(.bottom)
|
.defaultScrollAnchor(.bottom)
|
||||||
.scrollIndicators(.hidden)
|
.scrollIndicators(.hidden)
|
||||||
@@ -535,16 +549,15 @@ private extension ChatDetailView {
|
|||||||
.buttonStyle(ChatDetailGlassPressButtonStyle())
|
.buttonStyle(ChatDetailGlassPressButtonStyle())
|
||||||
|
|
||||||
HStack(alignment: .bottom, spacing: 0) {
|
HStack(alignment: .bottom, spacing: 0) {
|
||||||
TextField("Message", text: $messageText, axis: .vertical)
|
ChatTextInput(
|
||||||
.font(.system(size: 17, weight: .regular))
|
text: $messageText,
|
||||||
.foregroundStyle(RosettaColors.Adaptive.text)
|
isFocused: $isInputFocused,
|
||||||
.lineLimit(1...5)
|
onKeyboardHeightChange: { keyboard.updateFromKVO(keyboardHeight: $0) },
|
||||||
.focused($isInputFocused)
|
onUserTextInsertion: handleComposerUserTyping,
|
||||||
.textInputAutocapitalization(.sentences)
|
textColor: UIColor(RosettaColors.Adaptive.text),
|
||||||
.autocorrectionDisabled()
|
placeholderColor: UIColor(RosettaColors.Adaptive.textSecondary.opacity(0.5))
|
||||||
|
)
|
||||||
.padding(.leading, 6)
|
.padding(.leading, 6)
|
||||||
.padding(.top, 6)
|
|
||||||
.padding(.bottom, 8)
|
|
||||||
.frame(maxWidth: .infinity, minHeight: 36, alignment: .bottomLeading)
|
.frame(maxWidth: .infinity, minHeight: 36, alignment: .bottomLeading)
|
||||||
|
|
||||||
HStack(alignment: .center, spacing: 0) {
|
HStack(alignment: .center, spacing: 0) {
|
||||||
@@ -625,6 +638,7 @@ private extension ChatDetailView {
|
|||||||
.padding(.trailing, composerTrailingPadding)
|
.padding(.trailing, composerTrailingPadding)
|
||||||
.padding(.top, 4)
|
.padding(.top, 4)
|
||||||
.padding(.bottom, 4)
|
.padding(.bottom, 4)
|
||||||
|
.simultaneousGesture(composerDismissGesture)
|
||||||
.animation(composerAnimation, value: canSend)
|
.animation(composerAnimation, value: canSend)
|
||||||
.animation(composerAnimation, value: shouldShowSendButton)
|
.animation(composerAnimation, value: shouldShowSendButton)
|
||||||
}
|
}
|
||||||
@@ -739,6 +753,17 @@ private extension ChatDetailView {
|
|||||||
else { isInputFocused = true }
|
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 {
|
func deliveryTint(_ status: DeliveryStatus) -> Color {
|
||||||
switch status {
|
switch status {
|
||||||
case .read: return Color(hex: 0xA4E2FF)
|
case .read: return Color(hex: 0xA4E2FF)
|
||||||
@@ -852,6 +877,19 @@ private extension ChatDetailView {
|
|||||||
SessionManager.shared.sendReadReceipt(toPublicKey: route.publicKey)
|
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() {
|
func sendCurrentMessage() {
|
||||||
let message = trimmedMessage
|
let message = trimmedMessage
|
||||||
guard !message.isEmpty else { return }
|
guard !message.isEmpty else { return }
|
||||||
@@ -877,6 +915,10 @@ private extension ChatDetailView {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func handleComposerUserTyping() {
|
||||||
|
SessionManager.shared.sendTypingIndicator(toPublicKey: route.publicKey)
|
||||||
|
}
|
||||||
|
|
||||||
static let timeFormatter: DateFormatter = {
|
static let timeFormatter: DateFormatter = {
|
||||||
let formatter = DateFormatter()
|
let formatter = DateFormatter()
|
||||||
formatter.locale = Locale(identifier: "en_US_POSIX")
|
formatter.locale = Locale(identifier: "en_US_POSIX")
|
||||||
|
|||||||
@@ -135,7 +135,7 @@ private extension ChatListSearchContent {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 16)
|
.padding(.horizontal, 16)
|
||||||
.padding(.top, 8)
|
.padding(.top, 20)
|
||||||
.padding(.bottom, 6)
|
.padding(.bottom, 6)
|
||||||
|
|
||||||
ForEach(viewModel.recentSearches, id: \.publicKey) { recent in
|
ForEach(viewModel.recentSearches, id: \.publicKey) { recent in
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ struct ChatListView: View {
|
|||||||
navigationState.path.append(route)
|
navigationState.path.append(route)
|
||||||
// Delay search dismissal so NavigationStack processes
|
// Delay search dismissal so NavigationStack processes
|
||||||
// the push before the search overlay is removed.
|
// the push before the search overlay is removed.
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) {
|
||||||
isSearchActive = false
|
isSearchActive = false
|
||||||
isSearchFocused = false
|
isSearchFocused = false
|
||||||
searchText = ""
|
searchText = ""
|
||||||
@@ -92,6 +92,11 @@ struct ChatListView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.tint(RosettaColors.figmaBlue)
|
.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
|
// MARK: - Cancel Search
|
||||||
|
|||||||
@@ -172,7 +172,9 @@ private extension ChatRowView {
|
|||||||
.rotationEffect(.degrees(45))
|
.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
|
unreadBadge
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,14 +4,17 @@ import SwiftUI
|
|||||||
|
|
||||||
/// Telegram-style skeleton loading for search results.
|
/// Telegram-style skeleton loading for search results.
|
||||||
/// Matches the Figma chat row layout: 62px avatar, two-line text, trailing time.
|
/// 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 {
|
struct SearchSkeletonView: View {
|
||||||
@State private var phase: CGFloat = 0
|
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
|
TimelineView(.animation) { timeline in
|
||||||
|
let phase = shimmerPhase(from: timeline.date)
|
||||||
|
|
||||||
ScrollView {
|
ScrollView {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
ForEach(0..<7, id: \.self) { index in
|
ForEach(0..<7, id: \.self) { index in
|
||||||
skeletonRow(index: index)
|
skeletonRow(index: index, phase: phase)
|
||||||
if index < 6 {
|
if index < 6 {
|
||||||
Divider()
|
Divider()
|
||||||
.foregroundStyle(RosettaColors.Adaptive.divider)
|
.foregroundStyle(RosettaColors.Adaptive.divider)
|
||||||
@@ -21,18 +24,14 @@ struct SearchSkeletonView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.scrollDisabled(true)
|
.scrollDisabled(true)
|
||||||
.task {
|
|
||||||
withAnimation(.linear(duration: 1.5).repeatForever(autoreverses: false)) {
|
|
||||||
phase = 1
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func skeletonRow(index: Int) -> some View {
|
private func skeletonRow(index: Int, phase: CGFloat) -> some View {
|
||||||
HStack(spacing: 0) {
|
HStack(spacing: 0) {
|
||||||
// Avatar — 62pt circle matching Figma
|
// Avatar — 62pt circle matching Figma
|
||||||
Circle()
|
Circle()
|
||||||
.fill(shimmerGradient)
|
.fill(shimmerGradient(phase: phase))
|
||||||
.frame(width: 62, height: 62)
|
.frame(width: 62, height: 62)
|
||||||
.padding(.leading, 10)
|
.padding(.leading, 10)
|
||||||
.padding(.trailing, 10)
|
.padding(.trailing, 10)
|
||||||
@@ -41,12 +40,12 @@ struct SearchSkeletonView: View {
|
|||||||
VStack(alignment: .leading, spacing: 8) {
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
// Title line — name width varies per row
|
// Title line — name width varies per row
|
||||||
RoundedRectangle(cornerRadius: 4)
|
RoundedRectangle(cornerRadius: 4)
|
||||||
.fill(shimmerGradient)
|
.fill(shimmerGradient(phase: phase))
|
||||||
.frame(width: titleWidth(for: index), height: 16)
|
.frame(width: titleWidth(for: index), height: 16)
|
||||||
|
|
||||||
// Subtitle line — message preview
|
// Subtitle line — message preview
|
||||||
RoundedRectangle(cornerRadius: 4)
|
RoundedRectangle(cornerRadius: 4)
|
||||||
.fill(shimmerGradient)
|
.fill(shimmerGradient(phase: phase))
|
||||||
.frame(width: subtitleWidth(for: index), height: 14)
|
.frame(width: subtitleWidth(for: index), height: 14)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,7 +53,7 @@ struct SearchSkeletonView: View {
|
|||||||
|
|
||||||
// Trailing time placeholder
|
// Trailing time placeholder
|
||||||
RoundedRectangle(cornerRadius: 3)
|
RoundedRectangle(cornerRadius: 3)
|
||||||
.fill(shimmerGradient)
|
.fill(shimmerGradient(phase: phase))
|
||||||
.frame(width: 40, height: 12)
|
.frame(width: 40, height: 12)
|
||||||
.padding(.trailing, 16)
|
.padding(.trailing, 16)
|
||||||
}
|
}
|
||||||
@@ -71,18 +70,6 @@ struct SearchSkeletonView: View {
|
|||||||
let widths: [CGFloat] = [200, 170, 220, 150, 190, 180, 210]
|
let widths: [CGFloat] = [200, 170, 220, 150, 190, 180, 210]
|
||||||
return widths[index % widths.count]
|
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
|
// MARK: - SearchSkeletonRow
|
||||||
@@ -90,21 +77,23 @@ struct SearchSkeletonView: View {
|
|||||||
/// Single shimmer row matching `serverUserRow` layout (48px avatar, two text lines).
|
/// Single shimmer row matching `serverUserRow` layout (48px avatar, two text lines).
|
||||||
/// Used inline below existing search results while server is still loading.
|
/// Used inline below existing search results while server is still loading.
|
||||||
struct SearchSkeletonRow: View {
|
struct SearchSkeletonRow: View {
|
||||||
@State private var phase: CGFloat = 0
|
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
|
TimelineView(.animation) { timeline in
|
||||||
|
let phase = shimmerPhase(from: timeline.date)
|
||||||
|
|
||||||
HStack(spacing: 12) {
|
HStack(spacing: 12) {
|
||||||
Circle()
|
Circle()
|
||||||
.fill(shimmerGradient)
|
.fill(shimmerGradient(phase: phase))
|
||||||
.frame(width: 48, height: 48)
|
.frame(width: 48, height: 48)
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 6) {
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
RoundedRectangle(cornerRadius: 4)
|
RoundedRectangle(cornerRadius: 4)
|
||||||
.fill(shimmerGradient)
|
.fill(shimmerGradient(phase: phase))
|
||||||
.frame(width: 120, height: 14)
|
.frame(width: 120, height: 14)
|
||||||
|
|
||||||
RoundedRectangle(cornerRadius: 4)
|
RoundedRectangle(cornerRadius: 4)
|
||||||
.fill(shimmerGradient)
|
.fill(shimmerGradient(phase: phase))
|
||||||
.frame(width: 90, height: 12)
|
.frame(width: 90, height: 12)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -112,22 +101,32 @@ struct SearchSkeletonRow: View {
|
|||||||
}
|
}
|
||||||
.padding(.horizontal, 16)
|
.padding(.horizontal, 16)
|
||||||
.padding(.vertical, 12)
|
.padding(.vertical, 12)
|
||||||
.task {
|
|
||||||
withAnimation(.linear(duration: 1.5).repeatForever(autoreverses: false)) {
|
|
||||||
phase = 1
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private var shimmerGradient: LinearGradient {
|
// MARK: - Shared Shimmer Helpers
|
||||||
LinearGradient(
|
|
||||||
|
/// Derives a 0→1 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 0→1 to position -0.5→1.5 so the highlight
|
||||||
|
// enters from off-screen left and exits off-screen right.
|
||||||
|
// When phase wraps 1→0, highlight is already invisible — no jump.
|
||||||
|
let position = phase * 2.0 - 0.5
|
||||||
|
return LinearGradient(
|
||||||
colors: [
|
colors: [
|
||||||
Color.gray.opacity(0.08),
|
Color.gray.opacity(0.08),
|
||||||
Color.gray.opacity(0.15),
|
Color.gray.opacity(0.15),
|
||||||
Color.gray.opacity(0.08),
|
Color.gray.opacity(0.08),
|
||||||
],
|
],
|
||||||
startPoint: UnitPoint(x: phase - 0.4, y: 0),
|
startPoint: UnitPoint(x: position - 0.3, y: 0),
|
||||||
endPoint: UnitPoint(x: phase + 0.4, y: 0)
|
endPoint: UnitPoint(x: position + 0.3, y: 0)
|
||||||
)
|
)
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,8 +10,10 @@ struct SearchView: View {
|
|||||||
|
|
||||||
@MainActor static var _bodyCount = 0
|
@MainActor static var _bodyCount = 0
|
||||||
var body: some View {
|
var body: some View {
|
||||||
|
#if DEBUG
|
||||||
let _ = Self._bodyCount += 1
|
let _ = Self._bodyCount += 1
|
||||||
let _ = print("🔵 SearchView.body #\(Self._bodyCount)")
|
let _ = print("🔵 SearchView.body #\(Self._bodyCount)")
|
||||||
|
#endif
|
||||||
NavigationStack(path: $navigationPath) {
|
NavigationStack(path: $navigationPath) {
|
||||||
ZStack(alignment: .bottom) {
|
ZStack(alignment: .bottom) {
|
||||||
RosettaColors.Adaptive.background
|
RosettaColors.Adaptive.background
|
||||||
@@ -143,8 +145,10 @@ private struct FavoriteContactsRow: View {
|
|||||||
@MainActor static var _bodyCount = 0
|
@MainActor static var _bodyCount = 0
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
|
#if DEBUG
|
||||||
let _ = Self._bodyCount += 1
|
let _ = Self._bodyCount += 1
|
||||||
let _ = print("🟠 FavoriteContactsRow.body #\(Self._bodyCount)")
|
let _ = print("🟠 FavoriteContactsRow.body #\(Self._bodyCount)")
|
||||||
|
#endif
|
||||||
let dialogs = DialogRepository.shared.sortedDialogs.prefix(10)
|
let dialogs = DialogRepository.shared.sortedDialogs.prefix(10)
|
||||||
if !dialogs.isEmpty {
|
if !dialogs.isEmpty {
|
||||||
ScrollView(.horizontal, showsIndicators: false) {
|
ScrollView(.horizontal, showsIndicators: false) {
|
||||||
@@ -191,8 +195,10 @@ private struct RecentSection: View {
|
|||||||
@MainActor static var _bodyCount = 0
|
@MainActor static var _bodyCount = 0
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
|
#if DEBUG
|
||||||
let _ = Self._bodyCount += 1
|
let _ = Self._bodyCount += 1
|
||||||
let _ = print("🟤 RecentSection.body #\(Self._bodyCount)")
|
let _ = print("🟤 RecentSection.body #\(Self._bodyCount)")
|
||||||
|
#endif
|
||||||
if viewModel.recentSearches.isEmpty {
|
if viewModel.recentSearches.isEmpty {
|
||||||
emptyState
|
emptyState
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -2,13 +2,14 @@ import PhotosUI
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
/// Embedded profile editing content (no NavigationStack — lives inside SettingsView's).
|
/// Embedded profile editing content (no NavigationStack — lives inside SettingsView's).
|
||||||
/// Matches Telegram's edit screen: avatar + photo picker, name fields,
|
/// Avatar + photo picker, name fields with validation.
|
||||||
/// helper texts, "Add Another Account", and "Log Out".
|
|
||||||
struct ProfileEditView: View {
|
struct ProfileEditView: View {
|
||||||
@Binding var displayName: String
|
@Binding var displayName: String
|
||||||
@Binding var username: String
|
@Binding var username: String
|
||||||
let publicKey: String
|
let publicKey: String
|
||||||
var onLogout: () -> Void = {}
|
|
||||||
|
@Binding var displayNameError: String?
|
||||||
|
@Binding var usernameError: String?
|
||||||
|
|
||||||
@State private var selectedPhotoItem: PhotosPickerItem?
|
@State private var selectedPhotoItem: PhotosPickerItem?
|
||||||
@State private var selectedPhoto: UIImage?
|
@State private var selectedPhoto: UIImage?
|
||||||
@@ -33,12 +34,6 @@ struct ProfileEditView: View {
|
|||||||
.padding(.bottom, 24)
|
.padding(.bottom, 24)
|
||||||
|
|
||||||
addAccountSection
|
addAccountSection
|
||||||
|
|
||||||
helperText("You can connect multiple accounts with different phone numbers.")
|
|
||||||
.padding(.top, 8)
|
|
||||||
.padding(.bottom, 24)
|
|
||||||
|
|
||||||
logoutSection
|
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 16)
|
.padding(.horizontal, 16)
|
||||||
.padding(.top, 24)
|
.padding(.top, 24)
|
||||||
@@ -88,14 +83,19 @@ private extension ProfileEditView {
|
|||||||
|
|
||||||
private extension ProfileEditView {
|
private extension ProfileEditView {
|
||||||
var nameSection: some View {
|
var nameSection: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
GlassCard(cornerRadius: 26, fillOpacity: 0.08) {
|
GlassCard(cornerRadius: 26, fillOpacity: 0.08) {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
|
// Display Name field
|
||||||
HStack {
|
HStack {
|
||||||
TextField("First Name", text: $displayName)
|
TextField("First Name", text: $displayName)
|
||||||
.font(.system(size: 17))
|
.font(.system(size: 17))
|
||||||
.foregroundStyle(RosettaColors.Adaptive.text)
|
.foregroundStyle(RosettaColors.Adaptive.text)
|
||||||
.autocorrectionDisabled()
|
.autocorrectionDisabled()
|
||||||
.textInputAutocapitalization(.words)
|
.textInputAutocapitalization(.words)
|
||||||
|
.onChange(of: displayName) { _, newValue in
|
||||||
|
displayNameError = ProfileValidator.validateDisplayName(newValue)?.errorDescription
|
||||||
|
}
|
||||||
|
|
||||||
if !displayName.isEmpty {
|
if !displayName.isEmpty {
|
||||||
Button { displayName = "" } label: {
|
Button { displayName = "" } label: {
|
||||||
@@ -113,14 +113,27 @@ private extension ProfileEditView {
|
|||||||
.background(RosettaColors.Adaptive.divider)
|
.background(RosettaColors.Adaptive.divider)
|
||||||
.padding(.leading, 16)
|
.padding(.leading, 16)
|
||||||
|
|
||||||
|
// Username field
|
||||||
HStack {
|
HStack {
|
||||||
TextField("Username", text: $username)
|
TextField("Username", text: $username)
|
||||||
.font(.system(size: 17))
|
.font(.system(size: 17))
|
||||||
.foregroundStyle(RosettaColors.Adaptive.text)
|
.foregroundStyle(RosettaColors.Adaptive.text)
|
||||||
.textInputAutocapitalization(.never)
|
.textInputAutocapitalization(.never)
|
||||||
.autocorrectionDisabled()
|
.autocorrectionDisabled()
|
||||||
|
.onChange(of: username) { _, newValue in
|
||||||
|
let lowered = newValue.lowercased()
|
||||||
|
if lowered != newValue {
|
||||||
|
username = lowered
|
||||||
|
}
|
||||||
|
usernameError = ProfileValidator.validateUsername(lowered)?.errorDescription
|
||||||
|
}
|
||||||
|
|
||||||
if !username.isEmpty {
|
if !username.isEmpty {
|
||||||
|
Text("\(username.count)/32")
|
||||||
|
.font(.system(size: 13))
|
||||||
|
.foregroundStyle(RosettaColors.tertiaryText)
|
||||||
|
.padding(.trailing, 4)
|
||||||
|
|
||||||
Button { username = "" } label: {
|
Button { username = "" } label: {
|
||||||
Image(systemName: "xmark.circle.fill")
|
Image(systemName: "xmark.circle.fill")
|
||||||
.font(.system(size: 18))
|
.font(.system(size: 18))
|
||||||
@@ -133,10 +146,27 @@ private extension ProfileEditView {
|
|||||||
.frame(height: 52)
|
.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 {
|
private extension ProfileEditView {
|
||||||
var addAccountSection: some View {
|
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 {
|
func helperText(_ text: String) -> some View {
|
||||||
Text(text)
|
Text(text)
|
||||||
.font(.system(size: 13))
|
.font(.system(size: 13))
|
||||||
@@ -187,7 +202,9 @@ private extension ProfileEditView {
|
|||||||
ProfileEditView(
|
ProfileEditView(
|
||||||
displayName: .constant("Gaidar"),
|
displayName: .constant("Gaidar"),
|
||||||
username: .constant("GaidarTheDev"),
|
username: .constant("GaidarTheDev"),
|
||||||
publicKey: "028d1c9d0000000000000000000000000000000000000000000000000000008e03ec"
|
publicKey: "028d1c9d0000000000000000000000000000000000000000000000000000008e03ec",
|
||||||
|
displayNameError: .constant(nil),
|
||||||
|
usernameError: .constant(nil)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
.background(RosettaColors.Adaptive.background)
|
.background(RosettaColors.Adaptive.background)
|
||||||
|
|||||||
@@ -8,12 +8,14 @@ struct SettingsView: View {
|
|||||||
@Binding var isEditingProfile: Bool
|
@Binding var isEditingProfile: Bool
|
||||||
|
|
||||||
@StateObject private var viewModel = SettingsViewModel()
|
@StateObject private var viewModel = SettingsViewModel()
|
||||||
@State private var showCopiedToast = false
|
|
||||||
@State private var showDeleteAccountConfirmation = false
|
@State private var showDeleteAccountConfirmation = false
|
||||||
|
|
||||||
// Edit mode field state — initialized when entering edit mode
|
// Edit mode field state — initialized when entering edit mode
|
||||||
@State private var editDisplayName = ""
|
@State private var editDisplayName = ""
|
||||||
@State private var editUsername = ""
|
@State private var editUsername = ""
|
||||||
|
@State private var displayNameError: String?
|
||||||
|
@State private var usernameError: String?
|
||||||
|
@State private var isSaving = false
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
@@ -23,7 +25,8 @@ struct SettingsView: View {
|
|||||||
displayName: $editDisplayName,
|
displayName: $editDisplayName,
|
||||||
username: $editUsername,
|
username: $editUsername,
|
||||||
publicKey: viewModel.publicKey,
|
publicKey: viewModel.publicKey,
|
||||||
onLogout: { showDeleteAccountConfirmation = true }
|
displayNameError: $displayNameError,
|
||||||
|
usernameError: $usernameError
|
||||||
)
|
)
|
||||||
.transition(.opacity)
|
.transition(.opacity)
|
||||||
} else {
|
} else {
|
||||||
@@ -51,12 +54,8 @@ struct SettingsView: View {
|
|||||||
if !isEditing { viewModel.refresh() }
|
if !isEditing { viewModel.refresh() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.overlay(alignment: .top) {
|
|
||||||
if showCopiedToast {
|
|
||||||
copiedToast
|
|
||||||
.transition(.move(edge: .top).combined(with: .opacity))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Toolbar
|
// MARK: - Toolbar
|
||||||
@@ -66,7 +65,7 @@ struct SettingsView: View {
|
|||||||
ToolbarItem(placement: .navigationBarLeading) {
|
ToolbarItem(placement: .navigationBarLeading) {
|
||||||
if isEditingProfile {
|
if isEditingProfile {
|
||||||
Button {
|
Button {
|
||||||
withAnimation(.easeInOut(duration: 0.3)) {
|
withAnimation(.easeInOut(duration: 0.2)) {
|
||||||
isEditingProfile = false
|
isEditingProfile = false
|
||||||
}
|
}
|
||||||
} label: {
|
} label: {
|
||||||
@@ -78,6 +77,7 @@ struct SettingsView: View {
|
|||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
.glassCapsule()
|
.glassCapsule()
|
||||||
|
.disabled(isSaving)
|
||||||
} else {
|
} else {
|
||||||
Button {} label: {
|
Button {} label: {
|
||||||
Image(systemName: "qrcode")
|
Image(systemName: "qrcode")
|
||||||
@@ -111,7 +111,9 @@ struct SettingsView: View {
|
|||||||
Button {
|
Button {
|
||||||
editDisplayName = viewModel.displayName
|
editDisplayName = viewModel.displayName
|
||||||
editUsername = viewModel.username
|
editUsername = viewModel.username
|
||||||
withAnimation(.easeInOut(duration: 0.3)) {
|
displayNameError = nil
|
||||||
|
usernameError = nil
|
||||||
|
withAnimation(.easeInOut(duration: 0.2)) {
|
||||||
isEditingProfile = true
|
isEditingProfile = true
|
||||||
}
|
}
|
||||||
} label: {
|
} label: {
|
||||||
@@ -135,17 +137,54 @@ struct SettingsView: View {
|
|||||||
|
|
||||||
private func saveProfile() {
|
private func saveProfile() {
|
||||||
let trimmedName = editDisplayName.trimmingCharacters(in: .whitespaces)
|
let trimmedName = editDisplayName.trimmingCharacters(in: .whitespaces)
|
||||||
let trimmedUsername = editUsername.trimmingCharacters(in: .whitespaces)
|
let trimmedUsername = editUsername.trimmingCharacters(in: .whitespaces).lowercased()
|
||||||
|
|
||||||
if hasProfileChanges {
|
// Validate before saving
|
||||||
AccountManager.shared.updateProfile(
|
if let error = ProfileValidator.validateDisplayName(trimmedName) {
|
||||||
displayName: trimmedName,
|
displayNameError = error.errorDescription
|
||||||
username: trimmedUsername
|
return
|
||||||
)
|
}
|
||||||
SessionManager.shared.updateDisplayNameAndUsername(
|
if let error = ProfileValidator.validateUsername(trimmedUsername) {
|
||||||
displayName: trimmedName,
|
usernameError = error.errorDescription
|
||||||
username: trimmedUsername
|
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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send PacketUserInfo to server
|
||||||
if let hash = SessionManager.shared.privateKeyHash {
|
if let hash = SessionManager.shared.privateKeyHash {
|
||||||
var packet = PacketUserInfo()
|
var packet = PacketUserInfo()
|
||||||
packet.username = trimmedUsername
|
packet.username = trimmedUsername
|
||||||
@@ -153,12 +192,31 @@ struct SettingsView: View {
|
|||||||
packet.privateKey = hash
|
packet.privateKey = hash
|
||||||
ProtocolManager.shared.sendPacket(packet)
|
ProtocolManager.shared.sendPacket(packet)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
withAnimation(.easeInOut(duration: 0.3)) {
|
// 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
|
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
|
// MARK: - Settings Content
|
||||||
|
|
||||||
@@ -200,23 +258,13 @@ struct SettingsView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Button {
|
CopyableText(
|
||||||
viewModel.copyPublicKey()
|
displayText: formatPublicKey(viewModel.publicKey),
|
||||||
withAnimation(.easeInOut(duration: 0.25)) { showCopiedToast = true }
|
fullText: viewModel.publicKey,
|
||||||
Task {
|
font: .monospacedSystemFont(ofSize: 12, weight: .regular),
|
||||||
try? await Task.sleep(nanoseconds: 2_000_000_000)
|
textColor: UIColor(RosettaColors.tertiaryText)
|
||||||
withAnimation { showCopiedToast = false }
|
)
|
||||||
}
|
.frame(height: 16)
|
||||||
} 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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.padding(.vertical, 16)
|
.padding(.vertical, 16)
|
||||||
}
|
}
|
||||||
@@ -330,14 +378,4 @@ struct SettingsView: View {
|
|||||||
return String(key.prefix(8)) + "..." + String(key.suffix(6))
|
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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -79,7 +79,4 @@ final class SettingsViewModel: ObservableObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func copyPublicKey() {
|
|
||||||
UIPasteboard.general.string = publicKey
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ final class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCent
|
|||||||
|
|
||||||
// MARK: - UNUserNotificationCenterDelegate
|
// 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(
|
func userNotificationCenter(
|
||||||
_ center: UNUserNotificationCenter,
|
_ center: UNUserNotificationCenter,
|
||||||
willPresent notification: UNNotification,
|
willPresent notification: UNNotification,
|
||||||
@@ -62,21 +62,54 @@ final class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCent
|
|||||||
let userInfo = notification.request.content.userInfo
|
let userInfo = notification.request.content.userInfo
|
||||||
let type = userInfo["type"] as? String
|
let type = userInfo["type"] as? String
|
||||||
|
|
||||||
// Suppress foreground notifications (Android parity: isAppInForeground check)
|
|
||||||
if type == "new_message" {
|
if type == "new_message" {
|
||||||
|
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([])
|
completionHandler([])
|
||||||
} else {
|
} else {
|
||||||
completionHandler([.banner, .badge, .sound])
|
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(
|
func userNotificationCenter(
|
||||||
_ center: UNUserNotificationCenter,
|
_ center: UNUserNotificationCenter,
|
||||||
didReceive response: UNNotificationResponse,
|
didReceive response: UNNotificationResponse,
|
||||||
withCompletionHandler completionHandler: @escaping () -> Void
|
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()
|
completionHandler()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -131,7 +164,7 @@ struct RosettaApp: App {
|
|||||||
.ignoresSafeArea()
|
.ignoresSafeArea()
|
||||||
.opacity(transitionOverlay ? 1 : 0)
|
.opacity(transitionOverlay ? 1 : 0)
|
||||||
.allowsHitTesting(transitionOverlay)
|
.allowsHitTesting(transitionOverlay)
|
||||||
.animation(.easeInOut(duration: 0.12), value: transitionOverlay)
|
.animation(.easeInOut(duration: 0.08), value: transitionOverlay)
|
||||||
}
|
}
|
||||||
.preferredColorScheme(.dark)
|
.preferredColorScheme(.dark)
|
||||||
.onAppear {
|
.onAppear {
|
||||||
@@ -145,8 +178,10 @@ struct RosettaApp: App {
|
|||||||
@MainActor static var _bodyCount = 0
|
@MainActor static var _bodyCount = 0
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private func rootView(for state: AppState) -> some View {
|
private func rootView(for state: AppState) -> some View {
|
||||||
|
#if DEBUG
|
||||||
let _ = Self._bodyCount += 1
|
let _ = Self._bodyCount += 1
|
||||||
let _ = print("⭐ RosettaApp.rootView #\(Self._bodyCount) state=\(state)")
|
let _ = print("⭐ RosettaApp.rootView #\(Self._bodyCount) state=\(state)")
|
||||||
|
#endif
|
||||||
switch state {
|
switch state {
|
||||||
case .onboarding:
|
case .onboarding:
|
||||||
OnboardingView {
|
OnboardingView {
|
||||||
@@ -193,9 +228,9 @@ struct RosettaApp: App {
|
|||||||
guard !transitionOverlay else { return }
|
guard !transitionOverlay else { return }
|
||||||
transitionOverlay = true
|
transitionOverlay = true
|
||||||
Task { @MainActor in
|
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
|
appState = newState
|
||||||
try? await Task.sleep(nanoseconds: 30_000_000) // brief settle
|
try? await Task.sleep(nanoseconds: 20_000_000) // brief settle
|
||||||
transitionOverlay = false
|
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")
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user