From d482cdf62bf807b162f13d062427f7c79148f4ec Mon Sep 17 00:00:00 2001 From: senseiGai Date: Tue, 24 Mar 2026 20:31:30 +0500 Subject: [PATCH] =?UTF-8?q?=D0=A4=D0=B8=D0=BA=D1=81=20=D0=BA=D0=BB=D0=B0?= =?UTF-8?q?=D0=B2=D0=B8=D0=B0=D1=82=D1=83=D1=80=D1=8B,=20=D1=81=D0=BA?= =?UTF-8?q?=D1=80=D1=83=D0=B3=D0=BB=D0=B5=D0=BD=D0=B8=D1=8F=20input,=20iOS?= =?UTF-8?q?=2026=20layout,=20=D0=B4=D0=BE=D1=81=D1=82=D0=B0=D0=B2=D0=BA?= =?UTF-8?q?=D0=B0=20=D1=81=D0=BE=D0=BE=D0=B1=D1=89=D0=B5=D0=BD=D0=B8=D0=B9?= =?UTF-8?q?=20=D0=B8=20=D1=81=D0=B8=D0=BD=D1=85=D1=80=D0=BE=D0=BD=D0=B8?= =?UTF-8?q?=D0=B7=D0=B0=D1=86=D0=B8=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 1 + Info.plist | 4 + .../Core/Data/Database/DatabaseManager.swift | 15 ++ Rosetta/Core/Data/Database/DialogRecord.swift | 9 +- Rosetta/Core/Data/Models/Dialog.swift | 5 +- .../Data/Repositories/DialogRepository.swift | 19 +- .../Data/Repositories/MessageRepository.swift | 43 ++++- .../Network/Protocol/ProtocolManager.swift | 49 ++++- .../Network/Protocol/WebSocketClient.swift | 41 ++++ Rosetta/Core/Services/SessionManager.swift | 153 +++++++-------- Rosetta/Core/Utils/ReleaseNotes.swift | 13 +- Rosetta/Core/Utils/StressTestGenerator.swift | 2 +- .../Components/ChatTextInput.swift | 19 +- .../Components/KeyboardTracker.swift | 102 ++++++---- .../Components/TelegramGlassView.swift | 87 +++++++-- .../Chats/ChatDetail/ChatDetailView.swift | 176 ++++++++++++------ .../Features/Chats/ChatList/ChatRowView.swift | 48 +++-- Rosetta/Rosetta.entitlements | 2 + Rosetta/RosettaApp.swift | 5 +- RosettaNotificationService/Info.plist | 7 + 20 files changed, 565 insertions(+), 235 deletions(-) diff --git a/.gitignore b/.gitignore index a723ed0..ed7f004 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,7 @@ sprints/ CLAUDE.md .claude.local.md desktop +server AGENTS.md # Xcode diff --git a/Info.plist b/Info.plist index de5787b..896913a 100644 --- a/Info.plist +++ b/Info.plist @@ -14,5 +14,9 @@ remote-notification + NSUserActivityTypes + + INSendMessageIntent + diff --git a/Rosetta/Core/Data/Database/DatabaseManager.swift b/Rosetta/Core/Data/Database/DatabaseManager.swift index a8eb671..cf84dae 100644 --- a/Rosetta/Core/Data/Database/DatabaseManager.swift +++ b/Rosetta/Core/Data/Database/DatabaseManager.swift @@ -119,6 +119,21 @@ final class DatabaseManager { } } + // v3: Android/Desktop parity — split delivery_status into delivered (0/1/2) + read (0/1). + // Previously iOS combined both into delivery_status (0=WAITING, 1=DELIVERED, 2=ERROR, 3=READ). + // Android and Desktop use separate columns: delivered (0/1/2) + read (0/1). + migrator.registerMigration("v3_split_read_status") { db in + // Step 1: Outgoing non-READ messages had is_read=1 due to `|| fromMe` in insert. + // Reset to 0 — is_read for outgoing now means "opponent has read" (Android parity). + try db.execute(sql: "UPDATE messages SET is_read = 0 WHERE from_me = 1 AND delivery_status != 3") + + // Step 2: Convert READ (3) → DELIVERED (1). is_read stays 1 for these. + try db.execute(sql: "UPDATE messages SET delivery_status = 1 WHERE delivery_status = 3") + + // Step 3: Add last_message_read to dialogs (Android: lastMessageRead column). + try db.execute(sql: "ALTER TABLE dialogs ADD COLUMN last_message_read INTEGER NOT NULL DEFAULT 0") + } + try migrator.migrate(pool) dbPool = pool diff --git a/Rosetta/Core/Data/Database/DialogRecord.swift b/Rosetta/Core/Data/Database/DialogRecord.swift index be453ea..4b0d1f5 100644 --- a/Rosetta/Core/Data/Database/DialogRecord.swift +++ b/Rosetta/Core/Data/Database/DialogRecord.swift @@ -22,6 +22,7 @@ struct DialogRecord: Codable, FetchableRecord, MutablePersistableRecord, Sendabl var isMuted: Int var lastMessageFromMe: Int var lastMessageDelivered: Int + var lastMessageRead: Int // MARK: - Column mapping @@ -41,6 +42,7 @@ struct DialogRecord: Codable, FetchableRecord, MutablePersistableRecord, Sendabl case isMuted = "is_muted" case lastMessageFromMe = "last_message_from_me" case lastMessageDelivered = "last_message_delivered" + case lastMessageRead = "last_message_read" } enum CodingKeys: String, CodingKey { @@ -59,6 +61,7 @@ struct DialogRecord: Codable, FetchableRecord, MutablePersistableRecord, Sendabl case isMuted = "is_muted" case lastMessageFromMe = "last_message_from_me" case lastMessageDelivered = "last_message_delivered" + case lastMessageRead = "last_message_read" } // MARK: - Auto-increment @@ -86,7 +89,8 @@ struct DialogRecord: Codable, FetchableRecord, MutablePersistableRecord, Sendabl isPinned: isPinned != 0, isMuted: isMuted != 0, lastMessageFromMe: lastMessageFromMe != 0, - lastMessageDelivered: DeliveryStatus(rawValue: lastMessageDelivered) ?? .waiting + lastMessageDelivered: DeliveryStatus(rawValue: lastMessageDelivered) ?? .waiting, + lastMessageRead: lastMessageRead != 0 ) } @@ -107,7 +111,8 @@ struct DialogRecord: Codable, FetchableRecord, MutablePersistableRecord, Sendabl isPinned: dialog.isPinned ? 1 : 0, isMuted: dialog.isMuted ? 1 : 0, lastMessageFromMe: dialog.lastMessageFromMe ? 1 : 0, - lastMessageDelivered: dialog.lastMessageDelivered.rawValue + lastMessageDelivered: dialog.lastMessageDelivered.rawValue, + lastMessageRead: dialog.lastMessageRead ? 1 : 0 ) } } diff --git a/Rosetta/Core/Data/Models/Dialog.swift b/Rosetta/Core/Data/Models/Dialog.swift index bf446d2..af0150f 100644 --- a/Rosetta/Core/Data/Models/Dialog.swift +++ b/Rosetta/Core/Data/Models/Dialog.swift @@ -16,11 +16,12 @@ enum SystemAccounts { // MARK: - DeliveryStatus +/// Android/Desktop parity: delivery_status is 0/1/2 only. +/// Read status is stored separately in the `is_read` / `read` column. enum DeliveryStatus: Int, Codable { case waiting = 0 case delivered = 1 case error = 2 - case read = 3 } // MARK: - Dialog @@ -49,6 +50,8 @@ struct Dialog: Identifiable, Codable, Equatable { var lastMessageFromMe: Bool var lastMessageDelivered: DeliveryStatus + /// Android parity: separate read flag for last outgoing message. + var lastMessageRead: Bool // MARK: - Computed diff --git a/Rosetta/Core/Data/Repositories/DialogRepository.swift b/Rosetta/Core/Data/Repositories/DialogRepository.swift index db50733..ba24f4c 100644 --- a/Rosetta/Core/Data/Repositories/DialogRepository.swift +++ b/Rosetta/Core/Data/Repositories/DialogRepository.swift @@ -179,7 +179,8 @@ final class DialogRepository { lastMessage: "", lastMessageTimestamp: 0, unreadCount: 0, isOnline: false, lastSeen: 0, verified: 0, iHaveSent: false, isPinned: false, isMuted: false, - lastMessageFromMe: false, lastMessageDelivered: .waiting + lastMessageFromMe: false, lastMessageDelivered: .waiting, + lastMessageRead: false ) // Update computed fields @@ -189,6 +190,8 @@ final class DialogRepository { dialog.iHaveSent = hasSent || isSystem dialog.lastMessageFromMe = lastFromMe dialog.lastMessageDelivered = lastFromMe ? lastMsg.deliveryStatus : .delivered + // Android parity: separate read flag from last outgoing message's is_read column. + dialog.lastMessageRead = lastFromMe ? lastMsg.isRead : false dialogs[opponentKey] = dialog _sortedKeysCache = nil @@ -223,7 +226,8 @@ final class DialogRepository { lastMessage: "", lastMessageTimestamp: 0, unreadCount: 0, isOnline: false, lastSeen: 0, verified: verified, iHaveSent: false, isPinned: false, isMuted: false, - lastMessageFromMe: false, lastMessageDelivered: .waiting + lastMessageFromMe: false, lastMessageDelivered: .waiting, + lastMessageRead: false ) dialogs[opponentKey] = dialog _sortedKeysCache = nil @@ -354,8 +358,9 @@ final class DialogRepository { sql: """ INSERT INTO dialogs (account, opponent_key, opponent_title, opponent_username, last_message, last_message_timestamp, unread_count, is_online, last_seen, - verified, i_have_sent, is_pinned, is_muted, last_message_from_me, last_message_delivered) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + verified, i_have_sent, is_pinned, is_muted, last_message_from_me, + last_message_delivered, last_message_read) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(account, opponent_key) DO UPDATE SET opponent_title = excluded.opponent_title, opponent_username = excluded.opponent_username, @@ -369,14 +374,16 @@ final class DialogRepository { is_pinned = excluded.is_pinned, is_muted = excluded.is_muted, last_message_from_me = excluded.last_message_from_me, - last_message_delivered = excluded.last_message_delivered + last_message_delivered = excluded.last_message_delivered, + last_message_read = excluded.last_message_read """, arguments: [ dialog.account, dialog.opponentKey, dialog.opponentTitle, dialog.opponentUsername, dialog.lastMessage, dialog.lastMessageTimestamp, dialog.unreadCount, dialog.isOnline ? 1 : 0, dialog.lastSeen, dialog.verified, dialog.iHaveSent ? 1 : 0, dialog.isPinned ? 1 : 0, dialog.isMuted ? 1 : 0, - dialog.lastMessageFromMe ? 1 : 0, dialog.lastMessageDelivered.rawValue + dialog.lastMessageFromMe ? 1 : 0, dialog.lastMessageDelivered.rawValue, + dialog.lastMessageRead ? 1 : 0 ] ) } diff --git a/Rosetta/Core/Data/Repositories/MessageRepository.swift b/Rosetta/Core/Data/Repositories/MessageRepository.swift index 74f7e45..ba64ab1 100644 --- a/Rosetta/Core/Data/Repositories/MessageRepository.swift +++ b/Rosetta/Core/Data/Repositories/MessageRepository.swift @@ -273,8 +273,10 @@ final class MessageRepository: ObservableObject { ] ) } else { - // Insert new message — Android parity: read = 0 for incoming by default - let isRead = incomingRead || fromMe + // Android/Desktop parity: is_read means different things per direction: + // - Incoming (from_me=0): "I have read this message" (true if dialog active) + // - Outgoing (from_me=1): "Opponent has read this message" (always starts false) + let isRead = incomingRead var record = MessageRecord( id: nil, account: myPublicKey, @@ -328,8 +330,8 @@ final class MessageRepository: ObservableObject { ).fetchOne(db) else { return nil } let current = DeliveryStatus(rawValue: existing.deliveryStatus) ?? .waiting - if current == .read && (status == .delivered || status == .waiting) { return nil } - if current == .delivered && status == .waiting { return nil } + // Android parity: don't downgrade delivery status. + if current == .delivered && (status == .waiting || status == .error) { return nil } var sql = "UPDATE messages SET delivery_status = ?" var args: [any DatabaseValueConvertible] = [status.rawValue] @@ -375,9 +377,12 @@ final class MessageRepository: ObservableObject { let dialogKey = DatabaseManager.dialogKey(account: myPublicKey, opponentKey: opponentKey) do { try db.writeSync { db in + // Android/Desktop parity: set is_read = 1 for outgoing messages (separate column). + // delivery_status stays unchanged (WAITING/DELIVERED/ERROR). + // Android: `UPDATE messages SET read = 1 WHERE from_me = 1 AND read != 1` try db.execute( - sql: "UPDATE messages SET delivery_status = ? WHERE account = ? AND dialog_key = ? AND from_me = 1", - arguments: [DeliveryStatus.read.rawValue, myPublicKey, dialogKey] + sql: "UPDATE messages SET is_read = 1 WHERE account = ? AND dialog_key = ? AND from_me = 1 AND is_read = 0", + arguments: [myPublicKey, dialogKey] ) } } catch { @@ -451,6 +456,32 @@ final class MessageRepository: ObservableObject { } } + /// Android parity: `markExpiredWaitingAsError()` — batch SQL UPDATE for old WAITING messages. + /// Returns the number of rows updated. + @discardableResult + func markExpiredWaitingAsError(myPublicKey: String, maxTimestamp: Int64) -> Int { + do { + return try db.writeSync { db in + try db.execute( + sql: """ + UPDATE messages SET delivery_status = ? + WHERE account = ? AND from_me = 1 AND delivery_status = ? AND timestamp < ? + """, + arguments: [ + DeliveryStatus.error.rawValue, + myPublicKey, + DeliveryStatus.waiting.rawValue, + maxTimestamp, + ] + ) + return db.changesCount + } + } catch { + print("[DB] markExpiredWaitingAsError error: \(error)") + return 0 + } + } + func resolveRetryableOutgoingMessages( myPublicKey: String, nowMs: Int64, diff --git a/Rosetta/Core/Network/Protocol/ProtocolManager.swift b/Rosetta/Core/Network/Protocol/ProtocolManager.swift index 2f63b5c..1a14834 100644 --- a/Rosetta/Core/Network/Protocol/ProtocolManager.swift +++ b/Rosetta/Core/Network/Protocol/ProtocolManager.swift @@ -124,6 +124,35 @@ final class ProtocolManager: @unchecked Sendable { } } + /// Android parity: `reconnectNowIfNeeded(reason: "foreground")`. + /// On foreground resume, always force reconnect — iOS suspends the process in + /// background, server RSTs TCP, but `didCloseWith` never fires (zombie socket). + /// Android doesn't have this because OkHttp fires onFailure in background. + /// Previously iOS used ping-first (3s timeout) which was too slow. + func forceReconnectOnForeground() { + guard savedPublicKey != nil, savedPrivateHash != nil else { return } + + // Android parity: skip if handshake or device verification is in progress. + // These are active flows that should not be interrupted. + switch connectionState { + case .handshaking, .deviceVerificationRequired: + return + case .connecting: + if client.isConnecting { return } + case .authenticated, .connected, .disconnected: + break // Always reconnect — .authenticated/.connected may be zombie on iOS + } + + Self.logger.info("⚡ Foreground reconnect — tearing down potential zombie socket") + pingVerificationInProgress = false + pingTimeoutTask?.cancel() + pingTimeoutTask = nil + handshakeComplete = false + heartbeatTask?.cancel() + connectionState = .connecting + client.forceReconnect() + } + /// Android parity: `reconnectNowIfNeeded()` — if already in an active state, /// skip reconnect. Otherwise reset backoff and connect immediately. func reconnectIfNeeded() { @@ -191,7 +220,13 @@ final class ProtocolManager: @unchecked Sendable { handshakeComplete = false heartbeatTask?.cancel() Task { @MainActor in - self.connectionState = .connecting + // Guard: only downgrade to .connecting if reconnect hasn't already progressed. + // forceReconnect() is called synchronously below — if it completes fast, + // this async Task could overwrite .authenticated/.handshaking with .connecting. + let s = self.connectionState + if s != .authenticated && s != .handshaking && s != .connected { + self.connectionState = .connecting + } } client.forceReconnect() } @@ -291,7 +326,11 @@ final class ProtocolManager: @unchecked Sendable { self.handshakeComplete = false self.heartbeatTask?.cancel() Task { @MainActor in - self.connectionState = .connecting + // Guard: only downgrade to .connecting if reconnect hasn't already progressed. + let s = self.connectionState + if s != .authenticated && s != .handshaking && s != .connected { + self.connectionState = .connecting + } } self.client.forceReconnect() } @@ -339,7 +378,11 @@ final class ProtocolManager: @unchecked Sendable { self.handshakeComplete = false self.heartbeatTask?.cancel() Task { @MainActor in - self.connectionState = .connecting + // Guard: only downgrade to .connecting if reconnect hasn't already progressed. + let s = self.connectionState + if s != .authenticated && s != .handshaking && s != .connected { + self.connectionState = .connecting + } } self.client.forceReconnect() } diff --git a/Rosetta/Core/Network/Protocol/WebSocketClient.swift b/Rosetta/Core/Network/Protocol/WebSocketClient.swift index 68eb70b..3d172eb 100644 --- a/Rosetta/Core/Network/Protocol/WebSocketClient.swift +++ b/Rosetta/Core/Network/Protocol/WebSocketClient.swift @@ -12,6 +12,7 @@ final class WebSocketClient: NSObject, @unchecked Sendable, URLSessionWebSocketD private var webSocketTask: URLSessionWebSocketTask? private var isManuallyClosed = false private var reconnectTask: Task? + private var connectTimeoutTask: Task? private var hasNotifiedConnected = false private(set) var isConnected = false /// Android parity: prevents concurrent connect() calls and suppresses handleDisconnect @@ -82,6 +83,24 @@ final class WebSocketClient: NSObject, @unchecked Sendable, URLSessionWebSocketD task.resume() receiveLoop() + + // Safety net: if didOpenWithProtocol never fires within 15s, clean up + // and trigger reconnect. Matches URLSession's timeoutIntervalForResource + // but provides better logging and guaranteed cleanup of isConnecting flag. + connectTimeoutTask?.cancel() + connectTimeoutTask = Task { [weak self] in + try? await Task.sleep(nanoseconds: 15_000_000_000) + guard let self, !Task.isCancelled, self.isConnecting else { return } + Self.logger.warning("Connection establishment timeout (15s)") + self.isConnecting = false + self.webSocketTask?.cancel(with: .goingAway, reason: nil) + self.webSocketTask = nil + self.isConnected = false + self.handleDisconnect(error: NSError( + domain: "WebSocketClient", code: -2, + userInfo: [NSLocalizedDescriptionKey: "Connection establishment timeout"] + )) + } } func disconnect() { @@ -90,6 +109,8 @@ final class WebSocketClient: NSObject, @unchecked Sendable, URLSessionWebSocketD isConnecting = false reconnectTask?.cancel() reconnectTask = nil + connectTimeoutTask?.cancel() + connectTimeoutTask = nil webSocketTask?.cancel(with: .goingAway, reason: nil) webSocketTask = nil isConnected = false @@ -101,6 +122,8 @@ final class WebSocketClient: NSObject, @unchecked Sendable, URLSessionWebSocketD guard !isManuallyClosed else { return } reconnectTask?.cancel() reconnectTask = nil + connectTimeoutTask?.cancel() + connectTimeoutTask = nil // Always tear down and reconnect — connection may be zombie after background webSocketTask?.cancel(with: .goingAway, reason: nil) webSocketTask = nil @@ -175,6 +198,8 @@ final class WebSocketClient: NSObject, @unchecked Sendable, URLSessionWebSocketD guard !isManuallyClosed else { return } // Android parity: reset isConnecting on successful open (Protocol.kt onOpen). isConnecting = false + connectTimeoutTask?.cancel() + connectTimeoutTask = nil hasNotifiedConnected = true isConnected = true disconnectHandledForCurrentSocket = false @@ -197,6 +222,18 @@ final class WebSocketClient: NSObject, @unchecked Sendable, URLSessionWebSocketD handleDisconnect(error: nil) } + /// Safety net for transport-level failures (DNS, TLS, TCP refused) that may + /// bypass didCloseWith when the connection never opened successfully. + nonisolated func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) { + guard let error else { return } + // Ignore callbacks from old (cancelled) sockets after forceReconnect. + guard task === self.webSocketTask else { return } + Self.logger.warning("URLSession task failed: \(error.localizedDescription)") + isConnecting = false + isConnected = false + handleDisconnect(error: error) + } + // MARK: - Receive Loop private func receiveLoop() { @@ -221,6 +258,10 @@ final class WebSocketClient: NSObject, @unchecked Sendable, URLSessionWebSocketD case .failure(let error): Self.logger.error("Receive error: \(error.localizedDescription)") + // Android parity (onFailure): clear isConnecting before handleDisconnect. + // Without this, if connection fails before didOpenWithProtocol (DNS/TLS error), + // isConnecting stays true → handleDisconnect returns early → no reconnect ever scheduled. + self.isConnecting = false self.handleDisconnect(error: error) } } diff --git a/Rosetta/Core/Services/SessionManager.swift b/Rosetta/Core/Services/SessionManager.swift index 920e42c..f9479a8 100644 --- a/Rosetta/Core/Services/SessionManager.swift +++ b/Rosetta/Core/Services/SessionManager.swift @@ -36,6 +36,12 @@ final class SessionManager { /// PacketRead can arrive BEFORE the sync messages, so markIncomingAsRead /// updates 0 rows. Re-applying after sync ensures the read state sticks. private var pendingSyncReads: Set = [] + /// Opponent read receipts received during sync — re-applied at BATCH_END. + /// Mirrors `pendingSyncReads` for own-device reads. Without this, + /// markOutgoingAsRead() races with message insertion (0 rows affected) + /// because iOS processes reads via separate Task, not the inbound queue. + /// Android/Desktop don't need this — all packets go through one FIFO queue. + private var pendingOpponentReads: Set = [] private var requestedUserInfoKeys: Set = [] private var onlineSubscribedKeys: Set = [] private var pendingOutgoingRetryTasks: [String: Task] = [:] @@ -80,6 +86,23 @@ final class SessionManager { } } + /// Re-apply opponent read receipts that raced with message insertion during sync. + /// Android parity: Android doesn't need this because all packets go through one FIFO + /// queue (launchInboundPacketTask). iOS processes reads via separate Task, so they + /// can execute before messages are committed to DB → markOutgoingAsRead updates 0 rows. + private func reapplyPendingOpponentReads() { + guard !pendingOpponentReads.isEmpty else { return } + let keys = pendingOpponentReads + pendingOpponentReads.removeAll() + let myKey = currentPublicKey + for opponentKey in keys { + MessageRepository.shared.markOutgoingAsRead( + opponentKey: opponentKey, myPublicKey: myKey + ) + DialogRepository.shared.markOutgoingAsRead(opponentKey: opponentKey) + } + } + /// Android parity (ON_RESUME): re-mark active dialogs as read and send read receipts. /// Called on foreground resume. Android has no idle detection — just re-marks on resume. func markActiveDialogsAsRead() { @@ -199,27 +222,19 @@ final class SessionManager { myPublicKey: currentPublicKey ) - // Desktop parity: if not connected, set initial status to ERROR (not WAITING). - // The packet is still queued and will be sent on reconnect; - // delivery ACK will update status to DELIVERED. - let isConnected = connState == .authenticated - let offlineAsSend = !isConnected - - // Android parity: insert message FIRST, then recalculate dialog from DB. + // Android parity: always insert as WAITING (status=0) regardless of connection state. + // Packet is queued in ProtocolManager and sent on reconnect; retry mechanism + // handles timeout (80s → ERROR). Previously iOS marked offline sends as ERROR + // immediately (Desktop parity), but this caused false ❌ on brief WebSocket drops. + // Android keeps WAITING (⏳) and delivers automatically — much better UX. MessageRepository.shared.upsertFromMessagePacket( packet, myPublicKey: currentPublicKey, decryptedText: text, - fromSync: offlineAsSend + fromSync: false ) DialogRepository.shared.updateDialogFromMessages(opponentKey: packet.toPublicKey) - // Desktop parity: mark as ERROR when offline (fromSync sets .delivered, override to .error) - if offlineAsSend { - MessageRepository.shared.updateDeliveryStatus(messageId: messageId, status: .error) - DialogRepository.shared.updateDeliveryStatus(messageId: messageId, opponentKey: toPublicKey, status: .error) - } - // Android parity: persist IMMEDIATELY after inserting outgoing message. // Without this, if app is killed within 800ms debounce window, // the message is lost forever (only in memory, not on disk). @@ -558,21 +573,17 @@ final class SessionManager { opponentKey: toPublicKey, title: title, username: username, myPublicKey: currentPublicKey ) - let isConnected = ProtocolManager.shared.connectionState == .authenticated - let offlineAsSend = !isConnected let displayText = messageText + // Android parity: always insert as WAITING (fromSync: false). + // Retry mechanism handles timeout (80s → ERROR). MessageRepository.shared.upsertFromMessagePacket( optimisticPacket, myPublicKey: currentPublicKey, decryptedText: displayText, - attachmentPassword: "rawkey:" + encrypted.plainKeyAndNonce.hexString, fromSync: offlineAsSend + attachmentPassword: "rawkey:" + encrypted.plainKeyAndNonce.hexString, fromSync: false ) DialogRepository.shared.updateDialogFromMessages(opponentKey: toPublicKey) MessageRepository.shared.persistNow() - if offlineAsSend { - MessageRepository.shared.updateDeliveryStatus(messageId: messageId, status: .error) - DialogRepository.shared.updateDeliveryStatus(messageId: messageId, opponentKey: toPublicKey, status: .error) - } if toPublicKey == currentPublicKey { MessageRepository.shared.updateDeliveryStatus(messageId: messageId, status: .delivered) DialogRepository.shared.updateDeliveryStatus(messageId: messageId, opponentKey: toPublicKey, status: .delivered) @@ -885,26 +896,18 @@ final class SessionManager { myPublicKey: currentPublicKey ) - // Optimistic UI update — use localPacket (decrypted blob) for storage - let isConnected = ProtocolManager.shared.connectionState == .authenticated - let offlineAsSend = !isConnected + // Optimistic UI update — use localPacket (decrypted blob) for storage. + // Android parity: always insert as WAITING (fromSync: false). let displayText = messageText MessageRepository.shared.upsertFromMessagePacket( localPacket, myPublicKey: currentPublicKey, decryptedText: displayText, attachmentPassword: "rawkey:" + encrypted.plainKeyAndNonce.hexString, - fromSync: offlineAsSend + fromSync: false ) DialogRepository.shared.updateDialogFromMessages(opponentKey: localPacket.toPublicKey) - if offlineAsSend { - MessageRepository.shared.updateDeliveryStatus(messageId: messageId, status: .error) - DialogRepository.shared.updateDeliveryStatus( - messageId: messageId, opponentKey: toPublicKey, status: .error - ) - } - // Saved Messages: local-only if toPublicKey == currentPublicKey { MessageRepository.shared.updateDeliveryStatus(messageId: messageId, status: .delivered) @@ -998,6 +1001,8 @@ final class SessionManager { lastTypingSentAt.removeAll() syncBatchInProgress = false syncRequestInFlight = false + pendingSyncReads.removeAll() + pendingOpponentReads.removeAll() pendingIncomingMessages.removeAll() isProcessingIncomingMessages = false lastReadReceiptTimestamp.removeAll() @@ -1091,6 +1096,13 @@ final class SessionManager { opponentKey: opponentKey, myPublicKey: ownKey ) + // Race fix: if sync is in progress, messages may not be in DB yet. + // markOutgoingAsRead() may have updated 0 rows. Store for re-application + // at BATCH_END after waitForInboundQueueToDrain() ensures all messages + // are committed. Android doesn't need this — single FIFO queue. + if self.syncBatchInProgress { + self.pendingOpponentReads.insert(opponentKey) + } // Resolve pending retry timers for all messages to this opponent — // read receipt proves delivery, no need to retry further. self.resolveAllOutgoingRetries(toPublicKey: opponentKey) @@ -1223,6 +1235,7 @@ final class SessionManager { // PacketRead can arrive BEFORE sync messages — markIncomingAsRead // updates 0 rows because the message isn't in DB yet. self.reapplyPendingSyncReads() + self.reapplyPendingOpponentReads() // Android parity: reconcile unread counts after each batch. DialogRepository.shared.reconcileUnreadCounts() Self.logger.debug("SYNC BATCH_END cursor=\(serverCursor)") @@ -1233,6 +1246,7 @@ final class SessionManager { self.syncBatchInProgress = false // Re-apply cross-device reads one final time. self.reapplyPendingSyncReads() + self.reapplyPendingOpponentReads() DialogRepository.shared.reconcileDeliveryStatuses() DialogRepository.shared.reconcileUnreadCounts() self.retryWaitingOutgoingMessagesAfterReconnect() @@ -1871,9 +1885,19 @@ final class SessionManager { else { return } let now = Int64(Date().timeIntervalSince1970 * 1000) - // Android parity: retry messages within 80-second window (MESSAGE_MAX_TIME_TO_DELIVERED_MS). - // Messages older than 80s are marked as error — user can retry manually. + // Android parity: MESSAGE_MAX_TIME_TO_DELIVERED_MS = 80_000. let maxRetryAgeMs: Int64 = 80_000 + + // Android parity: batch-mark expired WAITING messages as ERROR first (SQL UPDATE). + let expiredCount = MessageRepository.shared.markExpiredWaitingAsError( + myPublicKey: currentPublicKey, + maxTimestamp: now - maxRetryAgeMs + ) + if expiredCount > 0 { + Self.logger.warning("Marked \(expiredCount) expired WAITING messages as ERROR") + } + + // Android parity: get remaining WAITING messages within the window. let retryable = MessageRepository.shared.resolveRetryableOutgoingMessages( myPublicKey: currentPublicKey, nowMs: now, @@ -1896,18 +1920,15 @@ final class SessionManager { ) do { - // Use fresh timestamp for the packet so the 80s delivery timeout - // starts from NOW, not from the original send time. - // Without this, messages sent 70s before reconnect would expire - // in ~10s — not enough time for the server to respond with 0x08. - // Also fixes server-side messageId dedup: fresh timestamp lets the - // server accept the retried message instead of silently dropping it. - let retryTimestamp = Int64(Date().timeIntervalSince1970 * 1000) + // Fresh timestamp for retry: server silently rejects messages older than 30s + // (Executor6Message maxPaddingSec=30). Original timestamp would fail validation + // if reconnect took >30s. Server overwrites timestamp with System.currentTimeMillis() + // anyway (Executor6Message:102), so client timestamp is only for age validation. let packet = try makeOutgoingPacket( text: text, toPublicKey: message.toPublicKey, messageId: message.id, - timestamp: retryTimestamp, + timestamp: Int64(Date().timeIntervalSince1970 * 1000), privateKeyHex: privateKeyHex, privateKeyHash: privateKeyHash ) @@ -1943,12 +1964,10 @@ final class SessionManager { guard let packet = self.pendingOutgoingPackets[messageId] else { return } let attempts = self.pendingOutgoingAttempts[messageId] ?? 0 - // Android parity: 80s × max(1, attachmentCount). + // Android parity: flat 80s timeout (MESSAGE_MAX_TIME_TO_DELIVERED_MS). let nowMs = Int64(Date().timeIntervalSince1970 * 1000) let ageMs = nowMs - packet.timestamp - let attachCount = max(1, Int64(packet.attachments.count)) - let timeoutMs = self.maxOutgoingWaitingLifetimeMs * attachCount - if ageMs >= timeoutMs { + if ageMs >= self.maxOutgoingWaitingLifetimeMs { // Android parity: mark as ERROR after timeout, not DELIVERED. // If the message was actually delivered, server will send 0x08 on reconnect // (or sync will restore the message). Marking DELIVERED optimistically @@ -1967,11 +1986,10 @@ final class SessionManager { } guard ProtocolManager.shared.connectionState == .authenticated else { - // Android parity: don't resolve (cancel) retry — keep message as .waiting. - // retryWaitingOutgoingMessagesAfterReconnect() will pick it up on next handshake. - // Previously: resolveOutgoingRetry() cancelled everything → message stuck as - // .delivered (optimistic) → never retried → lost. - Self.logger.debug("Message \(messageId) retry deferred — not authenticated, keeping .waiting") + // Android parity: cancel retry when not authenticated — clean up in-memory state. + // retryWaitingOutgoingMessagesAfterReconnect() re-reads from DB after sync completes. + Self.logger.debug("Message \(messageId) retry deferred — not authenticated") + self.resolveOutgoingRetry(messageId: messageId) return } @@ -1990,13 +2008,6 @@ final class SessionManager { let fromMe = packet.fromPublicKey == currentPublicKey let opponentKey = fromMe ? packet.toPublicKey : packet.fromPublicKey - let currentStatus = MessageRepository.shared.deliveryStatus(forMessageId: messageId) - if currentStatus == .read { - // Already read — don't downgrade to delivered. - resolveOutgoingRetry(messageId: messageId) - return - } - let deliveryTimestamp = Int64(Date().timeIntervalSince1970 * 1000) MessageRepository.shared.updateDeliveryStatus( messageId: messageId, @@ -2019,8 +2030,8 @@ final class SessionManager { let opponentKey = fromMe ? packet.toPublicKey : packet.fromPublicKey let currentStatus = MessageRepository.shared.deliveryStatus(forMessageId: messageId) - if currentStatus == .delivered || currentStatus == .read { - Self.logger.info("Skipping markOutgoingAsError for \(messageId.prefix(8))… — already \(currentStatus?.rawValue ?? -1)") + if currentStatus == .delivered { + Self.logger.info("Skipping markOutgoingAsError for \(messageId.prefix(8))… — already delivered") resolveOutgoingRetry(messageId: messageId) return } @@ -2149,20 +2160,14 @@ final class SessionManager { // Android: ON_RESUME calls markVisibleMessagesAsRead() for active dialog. self?.markActiveDialogsAsRead() - // iOS-specific: after long background, iOS suspends the process and the - // server RSTs the TCP connection. But `didCloseWith` never fires (process - // frozen), so connectionState stays `.authenticated` (stale zombie socket). - // Android doesn't have this problem because OkHttp callbacks fire in background. - // - // Fix: if state looks "alive" (.authenticated/.connected), ping-first to - // verify. If no pong → zombie → forceReconnect. If disconnected, reconnect - // immediately (Android parity: reconnectNowIfNeeded). - let state = ProtocolManager.shared.connectionState - if state == .authenticated || state == .connected { - ProtocolManager.shared.verifyConnectionOrReconnect() - } else { - ProtocolManager.shared.reconnectIfNeeded() - } + // Android parity: on foreground resume, always force reconnect. + // Android's OkHttp fires onFailure in background → state is accurate. + // iOS URLSession does NOT fire didCloseWith when process is suspended → + // connectionState stays .authenticated (zombie socket). Ping-first + // added 3 seconds of delay waiting for pong that never comes. + // Match Android: just tear down + reconnect. If connection was alive, + // small cost (reconnect <1s). If zombie, no wasted 3s. + ProtocolManager.shared.forceReconnectOnForeground() // syncOnForeground() has its own `.authenticated` guard — safe to call. // If ping-first triggers reconnect, sync won't fire (state is .connecting). diff --git a/Rosetta/Core/Utils/ReleaseNotes.swift b/Rosetta/Core/Utils/ReleaseNotes.swift index 73da861..f4c5d5d 100644 --- a/Rosetta/Core/Utils/ReleaseNotes.swift +++ b/Rosetta/Core/Utils/ReleaseNotes.swift @@ -11,10 +11,17 @@ enum ReleaseNotes { Entry( version: appVersion, body: """ - **Чат** - Нативная прокрутка без инверсии — плавная работа клавиатуры, корректное позиционирование при открытии чата. Интерактивное скрытие клавиатуры свайпом вниз. + **Клавиатура и поле ввода** + Сообщения поднимаются синхронно с клавиатурой. Исправлено перекрытие сообщений при закрытии. Скругление поля ввода корректно возвращается к капсуле при переходе из многострочного режима. Исправлено ложное срабатывание многострочного режима. - Поле ввода теперь двигается вместе с клавиатурой без задержки — анимация происходит в одной транзакции с клавиатурой через UIKit-контейнер. + **Интерфейс чата** + Тёмные градиенты по краям экрана — контент плавно уходит под навбар и home indicator. Аватарки в списке чатов обновляются мгновенно без перезахода. На iOS 26 поле ввода корректно внизу экрана, клавиатура работает нативно. + + **Доставка сообщений** + Сообщения больше не помечаются ошибкой при кратковременном обрыве — показываются часики и автодоставка при реконнекте. Свежий timestamp при повторной отправке — сервер больше не отклоняет. Быстрый реконнект на foreground без 3-секундной задержки. + + **Синхронизация** + Прочтения от оппонента больше не теряются при синке — переприменяются после вставки сообщений. Схема БД приведена к Android/Desktop паритету (delivered + read). Прочтение больше не перезаписывает ошибочные сообщения. """ ) ] diff --git a/Rosetta/Core/Utils/StressTestGenerator.swift b/Rosetta/Core/Utils/StressTestGenerator.swift index e21b151..cb996f9 100644 --- a/Rosetta/Core/Utils/StressTestGenerator.swift +++ b/Rosetta/Core/Utils/StressTestGenerator.swift @@ -72,7 +72,7 @@ enum StressTestGenerator { toPublicKey: isOutgoing ? dialogKey : myKey, text: attachments.isEmpty ? text : (i % 2 == 0 ? text : ""), timestamp: timestamp, - deliveryStatus: isOutgoing ? [.delivered, .read, .delivered][i % 3] : .delivered, + deliveryStatus: .delivered, isRead: true, attachments: attachments, attachmentPassword: nil diff --git a/Rosetta/DesignSystem/Components/ChatTextInput.swift b/Rosetta/DesignSystem/Components/ChatTextInput.swift index 5c1cb9e..8dcb16a 100644 --- a/Rosetta/DesignSystem/Components/ChatTextInput.swift +++ b/Rosetta/DesignSystem/Components/ChatTextInput.swift @@ -250,15 +250,32 @@ struct ChatTextInput: UIViewRepresentable { func invalidateHeight(_ tv: UITextView) { tv.invalidateIntrinsicContentSize() + // Force contentSize recalculation before checking multiline. + // Without this, contentSize still reflects the OLD text after deletion. + tv.setNeedsLayout() + tv.layoutIfNeeded() checkMultiline(tv) } private func checkMultiline(_ tv: UITextView) { let lineHeight = tv.font?.lineHeight ?? 20 let singleLineHeight = lineHeight + tv.textContainerInset.top + tv.textContainerInset.bottom - let isMultiline = tv.contentSize.height > singleLineHeight + 0.5 + let threshold = singleLineHeight + lineHeight * 0.5 + // Use sizeThatFits instead of contentSize — contentSize reflects the current + // FRAME (still multiline-sized), not the actual text height. sizeThatFits + // calculates the needed height for the text, independent of the current frame. + let fittingHeight = tv.sizeThatFits(CGSize(width: tv.bounds.width, height: .greatestFiniteMagnitude)).height + let isMultiline = fittingHeight > threshold + #if DEBUG + let textLen = tv.text?.count ?? 0 + let textRepr = tv.text?.replacingOccurrences(of: "\n", with: "\\n").prefix(20) ?? "" + print("📐 checkMultiline | fitH=\(String(format: "%.1f", fittingHeight)) contentH=\(String(format: "%.1f", tv.contentSize.height)) singleH=\(String(format: "%.1f", singleLineHeight)) threshold=\(String(format: "%.1f", threshold)) | isMulti=\(isMultiline) was=\(wasMultiline) | len=\(textLen) text=\"\(textRepr)\"") + #endif if isMultiline != wasMultiline { wasMultiline = isMultiline + #if DEBUG + print("📐 MULTILINE CHANGED → \(isMultiline)") + #endif parent.onMultilineChange(isMultiline) } } diff --git a/Rosetta/DesignSystem/Components/KeyboardTracker.swift b/Rosetta/DesignSystem/Components/KeyboardTracker.swift index 37da665..7a621ad 100644 --- a/Rosetta/DesignSystem/Components/KeyboardTracker.swift +++ b/Rosetta/DesignSystem/Components/KeyboardTracker.swift @@ -8,12 +8,11 @@ import UIKit /// - `keyboardPadding`: bottom padding to apply when keyboard is visible /// /// Animation strategy: -/// - Notification (show/hide): A hidden UIView is animated with the keyboard's -/// exact `UIViewAnimationCurve` (rawValue 7) inside the same Core Animation -/// transaction. CADisplayLink samples the presentation layer at 60fps, -/// giving pixel-perfect curve sync. Cubic bezier fallback if no window. +/// - Notification (show/hide): `spacerPadding` driven by CADisplayLink with curve-predicted +/// bezier evaluation. SHOW uses 2.5-frame advance to stay ahead of UIKit composer +/// (prevents message overlap). HIDE uses 1-frame advance. `keyboardPadding` +/// driven by sync view reading (for ComposerHostView consumers). /// - KVO (interactive dismiss): raw assignment at 30fps via coalescing. -/// - NO `withAnimation` / `.animation()` — these cause LazyVStack cell recycling gaps. @MainActor final class KeyboardTracker: ObservableObject { @@ -173,9 +172,6 @@ final class KeyboardTracker: ObservableObject { let current = pendingKVOPadding ?? keyboardPadding guard newPadding < current else { return } - // Drop spacerPadding floor so max() follows keyboardPadding during dismiss - if spacerPadding != 0 { spacerPadding = 0 } - // Move composer immediately (UIKit, no SwiftUI overhead) composerHostView?.setKeyboardOffset(rawPadding) @@ -202,8 +198,10 @@ final class KeyboardTracker: ObservableObject { guard pending.isFinite, pending >= 0 else { return } guard pending != keyboardPadding else { return } keyboardPadding = pending - // spacerPadding NOT updated from KVO — only from handleNotification - // with withAnimation to stay in sync with keyboard CA transaction. + // Also update spacerPadding so KeyboardSpacer follows during interactive dismiss + if pending != spacerPadding { + spacerPadding = pending + } } /// Immediately applies any buffered KVO value (used when KVO stops). @@ -245,22 +243,32 @@ final class KeyboardTracker: ObservableObject { let duration = info[UIResponder.keyboardAnimationDurationUserInfoKey] as? TimeInterval ?? 0.25 let curveRaw = info[UIResponder.keyboardAnimationCurveUserInfoKey] as? Int ?? 0 - // Configure bezier early — needed for curve-aware initial spacerPadding below. + // Configure bezier early — needed for HIDE head-start and animationTick fallback. configureBezier(curveRaw: curveRaw) - // Direction-dependent spacerPadding initialization: + // Direction-dependent initialization: let isShow = targetPadding > keyboardPadding if isShow { - // Curve-aware head start: evaluate bezier at ~1 frame into animation. - // Covers the gap before first CADisplayLink tick fires (~8ms on 120Hz, ~16ms on 60Hz). - // Smooth (follows curve shape), not a jarring jump. + // Head-start: immediately set spacerPadding to ~1 frame into animation. + // Covers the gap before first CADisplayLink tick fires (~8ms on 120Hz). + // Without this, spacerPadding = 0 for the first frame → messages overlap composer. let initialT = min(0.016 / max(duration, 0.05), 1.0) let initialEased = cubicBezierEase(initialT) let initialValue = keyboardPadding + (targetPadding - keyboardPadding) * initialEased spacerPadding = max(keyboardPadding, round(initialValue)) + #if DEBUG + print("⌨️ 🎬 SHOW headstart: spacer=\(Int(spacerPadding)) target=\(Int(targetPadding))") + #endif } else { - // HIDE: instant drop, keyboardPadding drives smooth descent - spacerPadding = 0 + // spacerPadding handled by animationTick via curve prediction + // Keep keyboardPadding headstart for iOS 26+ consumers + let initialT = min(0.016 / max(duration, 0.05), 1.0) + let initialEased = cubicBezierEase(initialT) + let initialValue = keyboardPadding + (targetPadding - keyboardPadding) * initialEased + #if DEBUG + print("⌨️ 🎬 HIDE headstart: kbPad \(Int(keyboardPadding))→\(Int(round(initialValue)))") + #endif + keyboardPadding = round(initialValue) } let delta = targetPadding - lastNotificationPadding @@ -280,7 +288,9 @@ final class KeyboardTracker: ObservableObject { return } - // UIKit: animate composer in same CA transaction as keyboard → zero lag. + // UIKit compositor: animate in the SAME CA transaction as the keyboard. + // This ensures pixel-perfect sync — render server interpolates both + // the keyboard and compositor with identical timing curves. // SwiftUI ComposerOverlay (iOS 26+) doesn't use this — composerHostView is nil. if let hostView = composerHostView { let options = UIView.AnimationOptions(rawValue: UInt(curveRaw) << 16) @@ -441,14 +451,13 @@ final class KeyboardTracker: ObservableObject { return } - // Velocity prediction: compensates for SwiftUI's ~8ms render lag. - // Composer is UIKit (zero lag), but KeyboardSpacer still needs prediction - // to keep messages moving in sync. Any over/under prediction shows as - // slight gap between messages and composer — much less visible than - // the old keyboard-composer gap. let rawEased = eased - if animTickCount > 1 { - let velocity = eased - previousMonotonicEased + let prevMonotonicEased = previousMonotonicEased + + // Velocity prediction: compensates for SwiftUI's ~8ms render lag. + // Only for HIDE — SHOW uses spacerPadding advance instead (no compound overshoot). + if animTickCount > 1 && animTargetPadding < animStartPadding { + let velocity = eased - prevMonotonicEased if velocity > 0 { eased = min(eased + velocity * 1.0, 1.0) } @@ -498,24 +507,30 @@ final class KeyboardTracker: ObservableObject { #endif keyboardPadding = rounded #if DEBUG - print("⌨️ T\(animTickCount) | syncOp=\(syncOpacity.map { String(format: "%.3f", $0) } ?? "nil") eased=\(String(format: "%.4f", eased)) | pad \(Int(prevPad))→\(Int(rounded)) Δ\(Int(rounded - prevPad))pt | \(String(format: "%.1f", elapsed * 1000))ms\(gliding ? " 🛬" : "")") + print("⌨️ T\(animTickCount) | syncOp=\(syncOpacity.map { String(format: "%.3f", $0) } ?? "nil") eased=\(String(format: "%.4f", eased)) | pad \(Int(prevPad))→\(Int(rounded)) Δ\(Int(rounded - prevPad))pt spacer=\(Int(spacerPadding)) max=\(Int(max(rounded, spacerPadding))) | \(String(format: "%.1f", elapsed * 1000))ms\(gliding ? " 🛬" : "")") #endif } - // Time-advanced spacerPadding: leads keyboardPadding by ~1 frame. - // Prevents compositor-message overlap during SHOW — UIKit composer has - // zero lag (same CA transaction), but SwiftUI spacer has 15-25ms render - // delay. Without this advance, messages lag behind the rising composer. - if animTargetPadding > animStartPadding { - let advanceMs: CFTimeInterval = 0.016 - let advancedT = min((elapsed + advanceMs) / animDuration, 1.0) - let advancedEased = cubicBezierEase(advancedT) - let advancedRaw = animStartPadding + (animTargetPadding - animStartPadding) * advancedEased - let advancedRounded = max(0, round(advancedRaw)) - // Monotonic: only increase during SHOW (no jitter from bezier approximation) - if advancedRounded > spacerPadding { - spacerPadding = advancedRounded - } + // Curve-predicted spacerPadding: predicts where the animation will be + // when SwiftUI finishes rendering (~8-16ms from now). + // SHOW: 2.5-frame advance — spacer must stay AHEAD of composer (UIKit) + // to prevent message overlap. SwiftUI pipeline lag = ~8ms + render. + // HIDE: 0 advance — SwiftUI lag keeps spacer slightly ABOVE compositor, + // creating a small gap instead of overlap. Gap > overlap. + let frameInterval = displayLinkProxy?.currentFrameInterval ?? 0.008 + let isShowDirection = animTargetPadding > animStartPadding + let advanceFrames: CGFloat = isShowDirection ? 2.5 : 0 + let predictedElapsed = elapsed + frameInterval * advanceFrames + let predictedT = min(predictedElapsed / animDuration, 1.0) + let predictedEased = cubicBezierEase(predictedT) + let predictedSpacer = animStartPadding + (animTargetPadding - animStartPadding) * predictedEased + var predictedRounded = max(0, round(predictedSpacer)) + // During SHOW: floor at current keyboardPadding — absolute guarantee of no overlap. + if isShowDirection { + predictedRounded = max(predictedRounded, rounded) + } + if predictedRounded != spacerPadding { + spacerPadding = predictedRounded } } @@ -577,6 +592,10 @@ private class DisplayLinkProxy { private var callback: (() -> Void)? private var displayLink: CADisplayLink? + /// Frame interval from CADisplayLink (targetTimestamp - timestamp). + /// Used for curve prediction in animationTick. + private(set) var currentFrameInterval: CFTimeInterval = 0.008 + /// - Parameter maxFPS: Max frame rate. 0 = device native (120Hz on ProMotion). /// Non-zero values cap via preferredFrameRateRange. init(maxFPS: Int = 0, callback: @escaping () -> Void) { @@ -593,6 +612,9 @@ private class DisplayLinkProxy { } @objc private func tick() { + if let dl = displayLink { + currentFrameInterval = dl.targetTimestamp - dl.timestamp + } callback?() } diff --git a/Rosetta/DesignSystem/Components/TelegramGlassView.swift b/Rosetta/DesignSystem/Components/TelegramGlassView.swift index 9f3b566..3e2fcc3 100644 --- a/Rosetta/DesignSystem/Components/TelegramGlassView.swift +++ b/Rosetta/DesignSystem/Components/TelegramGlassView.swift @@ -68,8 +68,14 @@ struct TelegramGlassRoundedRect: UIViewRepresentable { } func updateUIView(_ uiView: TelegramGlassUIView, context: Context) { + #if DEBUG + let oldRadius = uiView.fixedCornerRadius ?? -1 + let boundsH = uiView.bounds.height + print("🔲 GlassRoundedRect.updateUIView | radius \(Int(oldRadius))→\(Int(cornerRadius)) boundsH=\(Int(boundsH))") + #endif uiView.isFrozen = !context.environment.telegramGlassActive uiView.fixedCornerRadius = cornerRadius + uiView.applyCornerRadius() uiView.updateGlass() } } @@ -99,10 +105,10 @@ final class TelegramGlassUIView: UIView { private var backdropLayer: CALayer? private let clippingContainer = CALayer() private let foregroundLayer = CALayer() - private let borderLayer = CAShapeLayer() // iOS 26+ native glass private var nativeGlassView: UIVisualEffectView? + private var glassMaskLayer: CAShapeLayer? override init(frame: CGRect) { super.init(frame: frame) @@ -131,6 +137,7 @@ final class TelegramGlassUIView: UIView { // Telegram dark mode tint: UIColor(white: 1.0, alpha: 0.025) effect.tintColor = UIColor(white: 1.0, alpha: 0.025) let glassView = UIVisualEffectView(effect: effect) + glassView.clipsToBounds = true glassView.layer.cornerCurve = .continuous glassView.isUserInteractionEnabled = false addSubview(glassView) @@ -143,7 +150,7 @@ final class TelegramGlassUIView: UIView { // Clipping container — holds backdrop + foreground, clips to pill shape. // Border is added to main layer OUTSIDE the clip so it's fully visible. clippingContainer.masksToBounds = true - clippingContainer.cornerCurve = .circular + clippingContainer.cornerCurve = .continuous layer.addSublayer(clippingContainer) // 1. CABackdropLayer — blurs content behind this view @@ -165,13 +172,12 @@ final class TelegramGlassUIView: UIView { foregroundLayer.backgroundColor = UIColor(white: 0.11, alpha: 0.85).cgColor clippingContainer.addSublayer(foregroundLayer) - // 3. Border — on main layer, NOT inside clipping container - borderLayer.fillColor = UIColor.clear.cgColor - borderLayer.strokeColor = UIColor(red: 0x38/255.0, green: 0x38/255.0, blue: 0x38/255.0, alpha: 1.0).cgColor - borderLayer.lineWidth = 0.5 - layer.addSublayer(borderLayer) - - layer.cornerCurve = .circular + // 3. Border — on main layer via CALayer border properties. + // Using layer.borderWidth + cornerCurve ensures the border follows + // the same .continuous curve as the clipping container fill. + layer.borderWidth = 0.5 + layer.borderColor = UIColor(red: 0x38/255.0, green: 0x38/255.0, blue: 0x38/255.0, alpha: 1.0).cgColor + layer.cornerCurve = .continuous } // MARK: - Layout @@ -180,6 +186,38 @@ final class TelegramGlassUIView: UIView { setNeedsLayout() } + /// Directly applies current fixedCornerRadius to all layers without waiting for layout. + /// Call when cornerRadius changes but bounds may not — ensures immediate visual update. + func applyCornerRadius() { + let bounds = bounds + guard bounds.width > 0, bounds.height > 0 else { return } + let radius: CGFloat + if let fixed = fixedCornerRadius { + radius = min(fixed, bounds.height / 2) + } else if isCircle { + radius = min(bounds.width, bounds.height) / 2 + } else { + radius = bounds.height / 2 + } + if #available(iOS 26.0, *), let glassView = nativeGlassView { + let mask: CAShapeLayer + if let existing = glassMaskLayer { + mask = existing + } else { + mask = CAShapeLayer() + glassMaskLayer = mask + glassView.layer.mask = mask + } + mask.path = UIBezierPath( + roundedRect: bounds, + cornerRadius: radius + ).cgPath + } else { + clippingContainer.cornerRadius = radius + layer.cornerRadius = radius + } + } + override func layoutSubviews() { super.layoutSubviews() let bounds = bounds @@ -187,7 +225,8 @@ final class TelegramGlassUIView: UIView { let cornerRadius: CGFloat if let fixed = fixedCornerRadius { - cornerRadius = fixed + // Cap at half-height to guarantee capsule shape when radius >= height/2. + cornerRadius = min(fixed, bounds.height / 2) } else if isCircle { cornerRadius = min(bounds.width, bounds.height) / 2 } else { @@ -196,23 +235,33 @@ final class TelegramGlassUIView: UIView { if #available(iOS 26.0, *), let glassView = nativeGlassView { glassView.frame = bounds - glassView.layer.cornerRadius = cornerRadius + // UIGlassEffect ignores layer.cornerRadius changes after initial layout. + // Use CAShapeLayer mask — guaranteed to clip the glass to any shape. + let mask: CAShapeLayer + if let existing = glassMaskLayer { + mask = existing + } else { + mask = CAShapeLayer() + glassMaskLayer = mask + glassView.layer.mask = mask + } + mask.path = UIBezierPath( + roundedRect: bounds, + cornerRadius: cornerRadius + ).cgPath return } - // Legacy layout + // Legacy layout — clippingContainer.masksToBounds clips all children, + // so foregroundLayer needs no cornerRadius (avoids double-rounding artifacts). clippingContainer.frame = bounds clippingContainer.cornerRadius = cornerRadius backdropLayer?.frame = bounds foregroundLayer.frame = bounds - foregroundLayer.cornerRadius = cornerRadius - let halfBorder = borderLayer.lineWidth / 2 - let borderRect = bounds.insetBy(dx: halfBorder, dy: halfBorder) - let borderRadius = max(0, cornerRadius - halfBorder) - let borderPath = UIBezierPath(roundedRect: borderRect, cornerRadius: borderRadius) - borderLayer.path = borderPath.cgPath - borderLayer.frame = bounds + // Border follows .continuous curve via main layer's cornerRadius. + // clipsToBounds is false, so this only affects visual border — not child clipping. + layer.cornerRadius = cornerRadius } // MARK: - Shadow (drawn as separate image — Telegram parity) diff --git a/Rosetta/Features/Chats/ChatDetail/ChatDetailView.swift b/Rosetta/Features/Chats/ChatDetail/ChatDetailView.swift index 03b0067..5908236 100644 --- a/Rosetta/Features/Chats/ChatDetail/ChatDetailView.swift +++ b/Rosetta/Features/Chats/ChatDetail/ChatDetailView.swift @@ -27,7 +27,7 @@ private struct KeyboardSpacer: View { // Inverted scroll: spacer at VStack START. Growing it pushes // messages away from offset=0 → visually UP. CADisplayLink // animates keyboardPadding in sync with keyboard curve. - return composerHeight + max(keyboard.keyboardPadding, keyboard.spacerPadding) + 8 + return composerHeight + keyboard.spacerPadding + 8 } }() #if DEBUG @@ -220,22 +220,9 @@ struct ChatDetailView: View { } .modifier(IgnoreKeyboardSafeAreaLegacy()) .background { - ZStack(alignment: .bottom) { + ZStack { RosettaColors.Adaptive.background tiledChatBackground - // Telegram-style: dark gradient at screen bottom (home indicator area). - // In background (not overlay) so it never moves with keyboard. - if #unavailable(iOS 26) { - LinearGradient( - colors: [ - Color.black.opacity(0.0), - Color.black.opacity(0.55) - ], - startPoint: .top, - endPoint: .bottom - ) - .frame(height: 34) - } } .ignoresSafeArea() } @@ -594,7 +581,39 @@ private extension ChatDetailView { @ViewBuilder var chatEdgeGradients: some View { if #available(iOS 26, *) { - EmptyView() + VStack(spacing: 0) { + // Top: dark gradient behind nav bar (same style as iOS < 26). + LinearGradient( + stops: [ + .init(color: Color.black.opacity(0.85), location: 0.0), + .init(color: Color.black.opacity(0.75), location: 0.2), + .init(color: Color.black.opacity(0.55), location: 0.4), + .init(color: Color.black.opacity(0.3), location: 0.6), + .init(color: Color.black.opacity(0.12), location: 0.78), + .init(color: Color.black.opacity(0.0), location: 1.0), + ], + startPoint: .top, + endPoint: .bottom + ) + .frame(height: 100) + + Spacer() + + // Bottom: dark gradient for home indicator area. + LinearGradient( + stops: [ + .init(color: Color.black.opacity(0.0), location: 0.0), + .init(color: Color.black.opacity(0.3), location: 0.3), + .init(color: Color.black.opacity(0.65), location: 0.6), + .init(color: Color.black.opacity(0.85), location: 1.0), + ], + startPoint: .top, + endPoint: .bottom + ) + .frame(height: 54) + } + .ignoresSafeArea() + .allowsHitTesting(false) } else { VStack(spacing: 0) { // Telegram-style: dark gradient that smoothly fades content into @@ -615,8 +634,20 @@ private extension ChatDetailView { .frame(height: 90) Spacer() + + // Bottom: dark gradient for home indicator area below composer. + LinearGradient( + stops: [ + .init(color: Color.black.opacity(0.0), location: 0.0), + .init(color: Color.black.opacity(0.55), location: 0.35), + .init(color: Color.black.opacity(0.85), location: 1.0), + ], + startPoint: .top, + endPoint: .bottom + ) + .frame(height: 44) } - .ignoresSafeArea(edges: .top) + .ignoresSafeArea() .allowsHitTesting(false) } } @@ -1443,7 +1474,7 @@ private extension ChatDetailView { if message.deliveryStatus == .error { errorMenu(for: message) } else { - deliveryIndicator(message.deliveryStatus) + deliveryIndicator(message.deliveryStatus, read: message.isRead) } } } @@ -1463,7 +1494,7 @@ private extension ChatDetailView { if message.deliveryStatus == .error { errorMenu(for: message) } else { - mediaDeliveryIndicator(message.deliveryStatus) + mediaDeliveryIndicator(message.deliveryStatus, read: message.isRead) } } } @@ -1600,6 +1631,9 @@ private extension ChatDetailView { }, onUserTextInsertion: handleComposerUserTyping, onMultilineChange: { multiline in + #if DEBUG + print("📐 onMultilineChange callback: \(multiline) (was \(isMultilineInput)) → radius will be \(multiline ? 16 : 21)") + #endif withAnimation(.easeInOut(duration: 0.2)) { isMultilineInput = multiline } @@ -1611,7 +1645,9 @@ private extension ChatDetailView { .frame(maxWidth: .infinity, minHeight: 36, alignment: .bottomLeading) HStack(alignment: .center, spacing: 0) { - Button { } label: { + Button { + switchToEmojiKeyboard() + } label: { TelegramVectorIcon( pathData: TelegramIconPath.emojiMoon, viewBox: CGSize(width: 19, height: 19), @@ -1620,8 +1656,8 @@ private extension ChatDetailView { .frame(width: 19, height: 19) .frame(width: 20, height: 36) } - .accessibilityLabel("Quick actions") - .buttonStyle(ChatDetailGlassPressButtonStyle()) + .accessibilityLabel("Emoji") + .buttonStyle(.plain) } .padding(.trailing, 8 + (sendButtonWidth * sendButtonProgress)) .frame(height: 36, alignment: .center) @@ -1804,6 +1840,12 @@ private extension ChatDetailView { else { isInputFocused = true } } + /// Opens the keyboard — emoji button acts as a focus trigger. + /// System emoji keyboard is accessible via the 🌐 key on the keyboard. + func switchToEmojiKeyboard() { + if !isInputFocused { isInputFocused = true } + } + var composerDismissGesture: some Gesture { DragGesture(minimumDistance: 10) .onChanged { value in @@ -1815,9 +1857,10 @@ private extension ChatDetailView { } } - func deliveryTint(_ status: DeliveryStatus) -> Color { + /// Android/Desktop parity: delivery tint depends on both delivery status AND read flag. + func deliveryTint(_ status: DeliveryStatus, read: Bool) -> Color { + if status == .delivered && read { return Color(hex: 0xA4E2FF) } switch status { - case .read: return Color(hex: 0xA4E2FF) case .delivered: return Color.white.opacity(0.5) case .error: return RosettaColors.error default: return Color.white.opacity(0.78) @@ -1825,47 +1868,51 @@ private extension ChatDetailView { } @ViewBuilder - func deliveryIndicator(_ status: DeliveryStatus) -> some View { - switch status { - case .read: + func deliveryIndicator(_ status: DeliveryStatus, read: Bool) -> some View { + if status == .delivered && read { DoubleCheckmarkShape() - .fill(deliveryTint(status)) + .fill(deliveryTint(status, read: read)) .frame(width: 16, height: 8.7) - case .delivered: - SingleCheckmarkShape() - .fill(deliveryTint(status)) - .frame(width: 12, height: 8.8) - case .waiting: - Image(systemName: "clock") - .font(.system(size: 10, weight: .semibold)) - .foregroundStyle(deliveryTint(status)) - case .error: - Image(systemName: "exclamationmark.circle.fill") - .font(.system(size: 10, weight: .semibold)) - .foregroundStyle(deliveryTint(status)) + } else { + switch status { + case .delivered: + SingleCheckmarkShape() + .fill(deliveryTint(status, read: read)) + .frame(width: 12, height: 8.8) + case .waiting: + Image(systemName: "clock") + .font(.system(size: 10, weight: .semibold)) + .foregroundStyle(deliveryTint(status, read: read)) + case .error: + Image(systemName: "exclamationmark.circle.fill") + .font(.system(size: 10, weight: .semibold)) + .foregroundStyle(deliveryTint(status, read: read)) + } } } /// Delivery indicator with white tint for on-image media overlay. @ViewBuilder - func mediaDeliveryIndicator(_ status: DeliveryStatus) -> some View { - switch status { - case .read: + func mediaDeliveryIndicator(_ status: DeliveryStatus, read: Bool) -> some View { + if status == .delivered && read { DoubleCheckmarkShape() .fill(Color.white) .frame(width: 16, height: 8.7) - case .delivered: - SingleCheckmarkShape() - .fill(Color.white.opacity(0.8)) - .frame(width: 12, height: 8.8) - case .waiting: - Image(systemName: "clock") - .font(.system(size: 10, weight: .semibold)) - .foregroundStyle(.white.opacity(0.8)) - case .error: - Image(systemName: "exclamationmark.circle.fill") - .font(.system(size: 10, weight: .semibold)) - .foregroundStyle(RosettaColors.error) + } else { + switch status { + case .delivered: + SingleCheckmarkShape() + .fill(Color.white.opacity(0.8)) + .frame(width: 12, height: 8.8) + case .waiting: + Image(systemName: "clock") + .font(.system(size: 10, weight: .semibold)) + .foregroundStyle(.white.opacity(0.8)) + case .error: + Image(systemName: "exclamationmark.circle.fill") + .font(.system(size: 10, weight: .semibold)) + .foregroundStyle(RosettaColors.error) + } } } @@ -1980,7 +2027,7 @@ private extension ChatDetailView { private func contextMenuReadStatus(for message: ChatMessage) -> String? { let outgoing = message.isFromMe(myPublicKey: currentPublicKey) - guard outgoing, message.deliveryStatus == .read else { return nil } + guard outgoing, message.deliveryStatus == .delivered, message.isRead else { return nil } return "Read" } @@ -2788,11 +2835,17 @@ enum TelegramIconPath { /// iOS 26+: SwiftUI handles keyboard natively. private struct IgnoreKeyboardSafeAreaLegacy: ViewModifier { func body(content: Content) -> some View { - // Both iOS versions: disable SwiftUI's native keyboard avoidance. - // Inverted scroll (scaleEffect y: -1) breaks native avoidance — it pushes - // content in the wrong direction. KeyboardSpacer + ComposerOverlay handle - // keyboard offset manually via KeyboardTracker. - content.ignoresSafeArea(.keyboard) + if #available(iOS 26, *) { + // iOS 26+: SwiftUI handles keyboard natively — don't block it. + // KeyboardTracker is inert on iOS 26 (init returns early). + content + } else { + // iOS < 26: disable SwiftUI's native keyboard avoidance. + // Inverted scroll (scaleEffect y: -1) breaks native avoidance — it pushes + // content in the wrong direction. KeyboardSpacer + ComposerHostView handle + // keyboard offset manually via KeyboardTracker. + content.ignoresSafeArea(.keyboard) + } } } @@ -2846,6 +2899,7 @@ private struct ComposerOverlay: View { } ) .padding(.bottom, pad) + .frame(maxHeight: .infinity, alignment: .bottom) } } diff --git a/Rosetta/Features/Chats/ChatList/ChatRowView.swift b/Rosetta/Features/Chats/ChatList/ChatRowView.swift index e734a1f..15cd6c5 100644 --- a/Rosetta/Features/Chats/ChatList/ChatRowView.swift +++ b/Rosetta/Features/Chats/ChatList/ChatRowView.swift @@ -50,8 +50,15 @@ struct ChatRowView: View { // MARK: - Avatar -private extension ChatRowView { - var avatarSection: some View { +/// Observation-isolated: reads `AvatarRepository.avatarVersion` in its own +/// scope so only the avatar re-renders when opponent avatar changes — not the +/// entire ChatRowView (title, message preview, badge, etc.). +private struct ChatRowAvatar: View { + let dialog: Dialog + + var body: some View { + // Establish @Observable tracking — re-renders this view on avatar save/remove. + let _ = AvatarRepository.shared.avatarVersion AvatarView( initials: dialog.initials, colorIndex: dialog.avatarColorIndex, @@ -63,6 +70,12 @@ private extension ChatRowView { } } +private extension ChatRowView { + var avatarSection: some View { + ChatRowAvatar(dialog: dialog) + } +} + // MARK: - Content Section // Figma "Contents": flex-col, h-full, items-start, justify-center, pb-px // └─ "Title and Trailing Accessories": flex-1, gap-6, items-center @@ -209,22 +222,24 @@ private extension ChatRowView { @ViewBuilder var deliveryIcon: some View { - switch dialog.lastMessageDelivered { - case .waiting: - // Timer isolated to sub-view — only .waiting rows create a timer. - DeliveryWaitingIcon(sentTimestamp: dialog.lastMessageTimestamp) - case .delivered: - SingleCheckmarkShape() - .fill(RosettaColors.Adaptive.textSecondary) - .frame(width: 14, height: 10.3) - case .read: + if dialog.lastMessageDelivered == .delivered && dialog.lastMessageRead { DoubleCheckmarkShape() .fill(RosettaColors.figmaBlue) .frame(width: 17, height: 9.3) - case .error: - Image(systemName: "exclamationmark.circle.fill") - .font(.system(size: 14)) - .foregroundStyle(RosettaColors.error) + } else { + switch dialog.lastMessageDelivered { + case .waiting: + // Timer isolated to sub-view — only .waiting rows create a timer. + DeliveryWaitingIcon(sentTimestamp: dialog.lastMessageTimestamp) + case .delivered: + SingleCheckmarkShape() + .fill(RosettaColors.Adaptive.textSecondary) + .frame(width: 14, height: 10.3) + case .error: + Image(systemName: "exclamationmark.circle.fill") + .font(.system(size: 14)) + .foregroundStyle(RosettaColors.error) + } } } @@ -341,7 +356,8 @@ private extension ChatRowView { unreadCount: 3, isOnline: true, lastSeen: 0, verified: 1, iHaveSent: true, isPinned: false, isMuted: false, - lastMessageFromMe: true, lastMessageDelivered: .read + lastMessageFromMe: true, lastMessageDelivered: .delivered, + lastMessageRead: true ) VStack(spacing: 0) { diff --git a/Rosetta/Rosetta.entitlements b/Rosetta/Rosetta.entitlements index 293b951..84aba9d 100644 --- a/Rosetta/Rosetta.entitlements +++ b/Rosetta/Rosetta.entitlements @@ -4,6 +4,8 @@ aps-environment development + com.apple.developer.usernotifications.communication + com.apple.security.application-groups group.com.rosetta.dev diff --git a/Rosetta/RosettaApp.swift b/Rosetta/RosettaApp.swift index 00ce0d3..c72564e 100644 --- a/Rosetta/RosettaApp.swift +++ b/Rosetta/RosettaApp.swift @@ -45,9 +45,10 @@ final class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCent } } - // Request notification permission + // Request notification permission (including CarPlay display). + // .carPlay enables "Show in CarPlay" toggle in Settings > Notifications > Rosetta. UNUserNotificationCenter.current().requestAuthorization( - options: [.alert, .badge, .sound] + options: [.alert, .badge, .sound, .carPlay] ) { granted, _ in if granted { DispatchQueue.main.async { diff --git a/RosettaNotificationService/Info.plist b/RosettaNotificationService/Info.plist index 17bfad7..cf5199b 100644 --- a/RosettaNotificationService/Info.plist +++ b/RosettaNotificationService/Info.plist @@ -26,6 +26,13 @@ com.apple.usernotifications.service NSExtensionPrincipalClass $(PRODUCT_MODULE_NAME).NotificationService + NSExtensionAttributes + + IntentsSupported + + INSendMessageIntent + +