Фикс клавиатуры, скругления input, iOS 26 layout, доставка сообщений и синхронизация
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -6,6 +6,7 @@ sprints/
|
||||
CLAUDE.md
|
||||
.claude.local.md
|
||||
desktop
|
||||
server
|
||||
AGENTS.md
|
||||
|
||||
# Xcode
|
||||
|
||||
@@ -14,5 +14,9 @@
|
||||
<array>
|
||||
<string>remote-notification</string>
|
||||
</array>
|
||||
<key>NSUserActivityTypes</key>
|
||||
<array>
|
||||
<string>INSendMessageIntent</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -11,10 +11,17 @@ enum ReleaseNotes {
|
||||
Entry(
|
||||
version: appVersion,
|
||||
body: """
|
||||
**Чат**
|
||||
Нативная прокрутка без инверсии — плавная работа клавиатуры, корректное позиционирование при открытии чата. Интерактивное скрытие клавиатуры свайпом вниз.
|
||||
**Клавиатура и поле ввода**
|
||||
Сообщения поднимаются синхронно с клавиатурой. Исправлено перекрытие сообщений при закрытии. Скругление поля ввода корректно возвращается к капсуле при переходе из многострочного режима. Исправлено ложное срабатывание многострочного режима.
|
||||
|
||||
Поле ввода теперь двигается вместе с клавиатурой без задержки — анимация происходит в одной транзакции с клавиатурой через UIKit-контейнер.
|
||||
**Интерфейс чата**
|
||||
Тёмные градиенты по краям экрана — контент плавно уходит под навбар и home indicator. Аватарки в списке чатов обновляются мгновенно без перезахода. На iOS 26 поле ввода корректно внизу экрана, клавиатура работает нативно.
|
||||
|
||||
**Доставка сообщений**
|
||||
Сообщения больше не помечаются ошибкой при кратковременном обрыве — показываются часики и автодоставка при реконнекте. Свежий timestamp при повторной отправке — сервер больше не отклоняет. Быстрый реконнект на foreground без 3-секундной задержки.
|
||||
|
||||
**Синхронизация**
|
||||
Прочтения от оппонента больше не теряются при синке — переприменяются после вставки сообщений. Схема БД приведена к Android/Desktop паритету (delivered + read). Прочтение больше не перезаписывает ошибочные сообщения.
|
||||
"""
|
||||
)
|
||||
]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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?()
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user