Фикс клавиатуры, скругления input, iOS 26 layout, доставка сообщений и синхронизация

This commit is contained in:
2026-03-24 20:31:30 +05:00
parent 1cdd392cf3
commit d482cdf62b
20 changed files with 565 additions and 235 deletions

1
.gitignore vendored
View File

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

View File

@@ -14,5 +14,9 @@
<array>
<string>remote-notification</string>
</array>
<key>NSUserActivityTypes</key>
<array>
<string>INSendMessageIntent</string>
</array>
</dict>
</plist>

View File

@@ -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

View File

@@ -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
)
}
}

View File

@@ -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

View File

@@ -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
]
)
}

View File

@@ -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,

View File

@@ -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,8 +220,14 @@ final class ProtocolManager: @unchecked Sendable {
handshakeComplete = false
heartbeatTask?.cancel()
Task { @MainActor in
// 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,8 +326,12 @@ final class ProtocolManager: @unchecked Sendable {
self.handshakeComplete = false
self.heartbeatTask?.cancel()
Task { @MainActor in
// 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,8 +378,12 @@ final class ProtocolManager: @unchecked Sendable {
self.handshakeComplete = false
self.heartbeatTask?.cancel()
Task { @MainActor in
// 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()
}
}

View File

@@ -12,6 +12,7 @@ final class WebSocketClient: NSObject, @unchecked Sendable, URLSessionWebSocketD
private var webSocketTask: URLSessionWebSocketTask?
private var isManuallyClosed = false
private var reconnectTask: Task<Void, Never>?
private var connectTimeoutTask: Task<Void, Never>?
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)
}
}

View File

@@ -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<String> = []
/// 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<String> = []
private var requestedUserInfoKeys: Set<String> = []
private var onlineSubscribedKeys: Set<String> = []
private var pendingOutgoingRetryTasks: [String: Task<Void, Never>] = [:]
@@ -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).

View File

@@ -11,10 +11,17 @@ enum ReleaseNotes {
Entry(
version: appVersion,
body: """
**Чат**
Нативная прокрутка без инверсии — плавная работа клавиатуры, корректное позиционирование при открытии чата. Интерактивное скрытие клавиатуры свайпом вниз.
**Клавиатура и поле ввода**
Сообщения поднимаются синхронно с клавиатурой. Исправлено перекрытие сообщений при закрытии. Скругление поля ввода корректно возвращается к капсуле при переходе из многострочного режима. Исправлено ложное срабатывание многострочного режима.
Поле ввода теперь двигается вместе с клавиатурой без задержки — анимация происходит в одной транзакции с клавиатурой через UIKit-контейнер.
**Интерфейс чата**
Тёмные градиенты по краям экрана — контент плавно уходит под навбар и home indicator. Аватарки в списке чатов обновляются мгновенно без перезахода. На iOS 26 поле ввода корректно внизу экрана, клавиатура работает нативно.
**Доставка сообщений**
Сообщения больше не помечаются ошибкой при кратковременном обрыве — показываются часики и автодоставка при реконнекте. Свежий timestamp при повторной отправке — сервер больше не отклоняет. Быстрый реконнект на foreground без 3-секундной задержки.
**Синхронизация**
Прочтения от оппонента больше не теряются при синке — переприменяются после вставки сообщений. Схема БД приведена к Android/Desktop паритету (delivered + read). Прочтение больше не перезаписывает ошибочные сообщения.
"""
)
]

View File

@@ -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

View File

@@ -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)
}
}

View File

@@ -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?()
}

View File

@@ -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)

View File

@@ -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,35 +1868,38 @@ 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)
} else {
switch status {
case .delivered:
SingleCheckmarkShape()
.fill(deliveryTint(status))
.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))
.foregroundStyle(deliveryTint(status, read: read))
case .error:
Image(systemName: "exclamationmark.circle.fill")
.font(.system(size: 10, weight: .semibold))
.foregroundStyle(deliveryTint(status))
.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)
} else {
switch status {
case .delivered:
SingleCheckmarkShape()
.fill(Color.white.opacity(0.8))
@@ -1868,6 +1914,7 @@ private extension ChatDetailView {
.foregroundStyle(RosettaColors.error)
}
}
}
@ViewBuilder
func errorMenu(for message: ChatMessage) -> some View {
@@ -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,13 +2835,19 @@ 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.
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 + ComposerOverlay handle
// content in the wrong direction. KeyboardSpacer + ComposerHostView handle
// keyboard offset manually via KeyboardTracker.
content.ignoresSafeArea(.keyboard)
}
}
}
/// iOS < 26: prevent ScrollView from adjusting for keyboard
/// parent .safeAreaInset already handles it. Without this,
@@ -2846,6 +2899,7 @@ private struct ComposerOverlay<C: View>: View {
}
)
.padding(.bottom, pad)
.frame(maxHeight: .infinity, alignment: .bottom)
}
}

View File

@@ -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,6 +222,11 @@ private extension ChatRowView {
@ViewBuilder
var deliveryIcon: some View {
if dialog.lastMessageDelivered == .delivered && dialog.lastMessageRead {
DoubleCheckmarkShape()
.fill(RosettaColors.figmaBlue)
.frame(width: 17, height: 9.3)
} else {
switch dialog.lastMessageDelivered {
case .waiting:
// Timer isolated to sub-view only .waiting rows create a timer.
@@ -217,16 +235,13 @@ private extension ChatRowView {
SingleCheckmarkShape()
.fill(RosettaColors.Adaptive.textSecondary)
.frame(width: 14, height: 10.3)
case .read:
DoubleCheckmarkShape()
.fill(RosettaColors.figmaBlue)
.frame(width: 17, height: 9.3)
case .error:
Image(systemName: "exclamationmark.circle.fill")
.font(.system(size: 14))
.foregroundStyle(RosettaColors.error)
}
}
}
var unreadBadge: some View {
let count = dialog.unreadCount
@@ -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) {

View File

@@ -4,6 +4,8 @@
<dict>
<key>aps-environment</key>
<string>development</string>
<key>com.apple.developer.usernotifications.communication</key>
<true/>
<key>com.apple.security.application-groups</key>
<array>
<string>group.com.rosetta.dev</string>

View File

@@ -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 {

View File

@@ -26,6 +26,13 @@
<string>com.apple.usernotifications.service</string>
<key>NSExtensionPrincipalClass</key>
<string>$(PRODUCT_MODULE_NAME).NotificationService</string>
<key>NSExtensionAttributes</key>
<dict>
<key>IntentsSupported</key>
<array>
<string>INSendMessageIntent</string>
</array>
</dict>
</dict>
</dict>
</plist>