Фикс клавиатуры, скругления input, iOS 26 layout, доставка сообщений и синхронизация
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -6,6 +6,7 @@ sprints/
|
|||||||
CLAUDE.md
|
CLAUDE.md
|
||||||
.claude.local.md
|
.claude.local.md
|
||||||
desktop
|
desktop
|
||||||
|
server
|
||||||
AGENTS.md
|
AGENTS.md
|
||||||
|
|
||||||
# Xcode
|
# Xcode
|
||||||
|
|||||||
@@ -14,5 +14,9 @@
|
|||||||
<array>
|
<array>
|
||||||
<string>remote-notification</string>
|
<string>remote-notification</string>
|
||||||
</array>
|
</array>
|
||||||
|
<key>NSUserActivityTypes</key>
|
||||||
|
<array>
|
||||||
|
<string>INSendMessageIntent</string>
|
||||||
|
</array>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</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)
|
try migrator.migrate(pool)
|
||||||
dbPool = pool
|
dbPool = pool
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ struct DialogRecord: Codable, FetchableRecord, MutablePersistableRecord, Sendabl
|
|||||||
var isMuted: Int
|
var isMuted: Int
|
||||||
var lastMessageFromMe: Int
|
var lastMessageFromMe: Int
|
||||||
var lastMessageDelivered: Int
|
var lastMessageDelivered: Int
|
||||||
|
var lastMessageRead: Int
|
||||||
|
|
||||||
// MARK: - Column mapping
|
// MARK: - Column mapping
|
||||||
|
|
||||||
@@ -41,6 +42,7 @@ struct DialogRecord: Codable, FetchableRecord, MutablePersistableRecord, Sendabl
|
|||||||
case isMuted = "is_muted"
|
case isMuted = "is_muted"
|
||||||
case lastMessageFromMe = "last_message_from_me"
|
case lastMessageFromMe = "last_message_from_me"
|
||||||
case lastMessageDelivered = "last_message_delivered"
|
case lastMessageDelivered = "last_message_delivered"
|
||||||
|
case lastMessageRead = "last_message_read"
|
||||||
}
|
}
|
||||||
|
|
||||||
enum CodingKeys: String, CodingKey {
|
enum CodingKeys: String, CodingKey {
|
||||||
@@ -59,6 +61,7 @@ struct DialogRecord: Codable, FetchableRecord, MutablePersistableRecord, Sendabl
|
|||||||
case isMuted = "is_muted"
|
case isMuted = "is_muted"
|
||||||
case lastMessageFromMe = "last_message_from_me"
|
case lastMessageFromMe = "last_message_from_me"
|
||||||
case lastMessageDelivered = "last_message_delivered"
|
case lastMessageDelivered = "last_message_delivered"
|
||||||
|
case lastMessageRead = "last_message_read"
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Auto-increment
|
// MARK: - Auto-increment
|
||||||
@@ -86,7 +89,8 @@ struct DialogRecord: Codable, FetchableRecord, MutablePersistableRecord, Sendabl
|
|||||||
isPinned: isPinned != 0,
|
isPinned: isPinned != 0,
|
||||||
isMuted: isMuted != 0,
|
isMuted: isMuted != 0,
|
||||||
lastMessageFromMe: lastMessageFromMe != 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,
|
isPinned: dialog.isPinned ? 1 : 0,
|
||||||
isMuted: dialog.isMuted ? 1 : 0,
|
isMuted: dialog.isMuted ? 1 : 0,
|
||||||
lastMessageFromMe: dialog.lastMessageFromMe ? 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
|
// 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 {
|
enum DeliveryStatus: Int, Codable {
|
||||||
case waiting = 0
|
case waiting = 0
|
||||||
case delivered = 1
|
case delivered = 1
|
||||||
case error = 2
|
case error = 2
|
||||||
case read = 3
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Dialog
|
// MARK: - Dialog
|
||||||
@@ -49,6 +50,8 @@ struct Dialog: Identifiable, Codable, Equatable {
|
|||||||
|
|
||||||
var lastMessageFromMe: Bool
|
var lastMessageFromMe: Bool
|
||||||
var lastMessageDelivered: DeliveryStatus
|
var lastMessageDelivered: DeliveryStatus
|
||||||
|
/// Android parity: separate read flag for last outgoing message.
|
||||||
|
var lastMessageRead: Bool
|
||||||
|
|
||||||
// MARK: - Computed
|
// MARK: - Computed
|
||||||
|
|
||||||
|
|||||||
@@ -179,7 +179,8 @@ final class DialogRepository {
|
|||||||
lastMessage: "", lastMessageTimestamp: 0, unreadCount: 0,
|
lastMessage: "", lastMessageTimestamp: 0, unreadCount: 0,
|
||||||
isOnline: false, lastSeen: 0, verified: 0,
|
isOnline: false, lastSeen: 0, verified: 0,
|
||||||
iHaveSent: false, isPinned: false, isMuted: false,
|
iHaveSent: false, isPinned: false, isMuted: false,
|
||||||
lastMessageFromMe: false, lastMessageDelivered: .waiting
|
lastMessageFromMe: false, lastMessageDelivered: .waiting,
|
||||||
|
lastMessageRead: false
|
||||||
)
|
)
|
||||||
|
|
||||||
// Update computed fields
|
// Update computed fields
|
||||||
@@ -189,6 +190,8 @@ final class DialogRepository {
|
|||||||
dialog.iHaveSent = hasSent || isSystem
|
dialog.iHaveSent = hasSent || isSystem
|
||||||
dialog.lastMessageFromMe = lastFromMe
|
dialog.lastMessageFromMe = lastFromMe
|
||||||
dialog.lastMessageDelivered = lastFromMe ? lastMsg.deliveryStatus : .delivered
|
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
|
dialogs[opponentKey] = dialog
|
||||||
_sortedKeysCache = nil
|
_sortedKeysCache = nil
|
||||||
@@ -223,7 +226,8 @@ final class DialogRepository {
|
|||||||
lastMessage: "", lastMessageTimestamp: 0, unreadCount: 0,
|
lastMessage: "", lastMessageTimestamp: 0, unreadCount: 0,
|
||||||
isOnline: false, lastSeen: 0, verified: verified,
|
isOnline: false, lastSeen: 0, verified: verified,
|
||||||
iHaveSent: false, isPinned: false, isMuted: false,
|
iHaveSent: false, isPinned: false, isMuted: false,
|
||||||
lastMessageFromMe: false, lastMessageDelivered: .waiting
|
lastMessageFromMe: false, lastMessageDelivered: .waiting,
|
||||||
|
lastMessageRead: false
|
||||||
)
|
)
|
||||||
dialogs[opponentKey] = dialog
|
dialogs[opponentKey] = dialog
|
||||||
_sortedKeysCache = nil
|
_sortedKeysCache = nil
|
||||||
@@ -354,8 +358,9 @@ final class DialogRepository {
|
|||||||
sql: """
|
sql: """
|
||||||
INSERT INTO dialogs (account, opponent_key, opponent_title, opponent_username,
|
INSERT INTO dialogs (account, opponent_key, opponent_title, opponent_username,
|
||||||
last_message, last_message_timestamp, unread_count, is_online, last_seen,
|
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)
|
verified, i_have_sent, is_pinned, is_muted, last_message_from_me,
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
last_message_delivered, last_message_read)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
ON CONFLICT(account, opponent_key) DO UPDATE SET
|
ON CONFLICT(account, opponent_key) DO UPDATE SET
|
||||||
opponent_title = excluded.opponent_title,
|
opponent_title = excluded.opponent_title,
|
||||||
opponent_username = excluded.opponent_username,
|
opponent_username = excluded.opponent_username,
|
||||||
@@ -369,14 +374,16 @@ final class DialogRepository {
|
|||||||
is_pinned = excluded.is_pinned,
|
is_pinned = excluded.is_pinned,
|
||||||
is_muted = excluded.is_muted,
|
is_muted = excluded.is_muted,
|
||||||
last_message_from_me = excluded.last_message_from_me,
|
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: [
|
arguments: [
|
||||||
dialog.account, dialog.opponentKey, dialog.opponentTitle, dialog.opponentUsername,
|
dialog.account, dialog.opponentKey, dialog.opponentTitle, dialog.opponentUsername,
|
||||||
dialog.lastMessage, dialog.lastMessageTimestamp, dialog.unreadCount,
|
dialog.lastMessage, dialog.lastMessageTimestamp, dialog.unreadCount,
|
||||||
dialog.isOnline ? 1 : 0, dialog.lastSeen, dialog.verified,
|
dialog.isOnline ? 1 : 0, dialog.lastSeen, dialog.verified,
|
||||||
dialog.iHaveSent ? 1 : 0, dialog.isPinned ? 1 : 0, dialog.isMuted ? 1 : 0,
|
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 {
|
} else {
|
||||||
// Insert new message — Android parity: read = 0 for incoming by default
|
// Android/Desktop parity: is_read means different things per direction:
|
||||||
let isRead = incomingRead || fromMe
|
// - 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(
|
var record = MessageRecord(
|
||||||
id: nil,
|
id: nil,
|
||||||
account: myPublicKey,
|
account: myPublicKey,
|
||||||
@@ -328,8 +330,8 @@ final class MessageRepository: ObservableObject {
|
|||||||
).fetchOne(db) else { return nil }
|
).fetchOne(db) else { return nil }
|
||||||
|
|
||||||
let current = DeliveryStatus(rawValue: existing.deliveryStatus) ?? .waiting
|
let current = DeliveryStatus(rawValue: existing.deliveryStatus) ?? .waiting
|
||||||
if current == .read && (status == .delivered || status == .waiting) { return nil }
|
// Android parity: don't downgrade delivery status.
|
||||||
if current == .delivered && status == .waiting { return nil }
|
if current == .delivered && (status == .waiting || status == .error) { return nil }
|
||||||
|
|
||||||
var sql = "UPDATE messages SET delivery_status = ?"
|
var sql = "UPDATE messages SET delivery_status = ?"
|
||||||
var args: [any DatabaseValueConvertible] = [status.rawValue]
|
var args: [any DatabaseValueConvertible] = [status.rawValue]
|
||||||
@@ -375,9 +377,12 @@ final class MessageRepository: ObservableObject {
|
|||||||
let dialogKey = DatabaseManager.dialogKey(account: myPublicKey, opponentKey: opponentKey)
|
let dialogKey = DatabaseManager.dialogKey(account: myPublicKey, opponentKey: opponentKey)
|
||||||
do {
|
do {
|
||||||
try db.writeSync { db in
|
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(
|
try db.execute(
|
||||||
sql: "UPDATE messages SET delivery_status = ? WHERE account = ? AND dialog_key = ? AND from_me = 1",
|
sql: "UPDATE messages SET is_read = 1 WHERE account = ? AND dialog_key = ? AND from_me = 1 AND is_read = 0",
|
||||||
arguments: [DeliveryStatus.read.rawValue, myPublicKey, dialogKey]
|
arguments: [myPublicKey, dialogKey]
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
} catch {
|
} 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(
|
func resolveRetryableOutgoingMessages(
|
||||||
myPublicKey: String,
|
myPublicKey: String,
|
||||||
nowMs: Int64,
|
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,
|
/// Android parity: `reconnectNowIfNeeded()` — if already in an active state,
|
||||||
/// skip reconnect. Otherwise reset backoff and connect immediately.
|
/// skip reconnect. Otherwise reset backoff and connect immediately.
|
||||||
func reconnectIfNeeded() {
|
func reconnectIfNeeded() {
|
||||||
@@ -191,7 +220,13 @@ final class ProtocolManager: @unchecked Sendable {
|
|||||||
handshakeComplete = false
|
handshakeComplete = false
|
||||||
heartbeatTask?.cancel()
|
heartbeatTask?.cancel()
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
self.connectionState = .connecting
|
// Guard: only downgrade to .connecting if reconnect hasn't already progressed.
|
||||||
|
// forceReconnect() is called synchronously below — if it completes fast,
|
||||||
|
// this async Task could overwrite .authenticated/.handshaking with .connecting.
|
||||||
|
let s = self.connectionState
|
||||||
|
if s != .authenticated && s != .handshaking && s != .connected {
|
||||||
|
self.connectionState = .connecting
|
||||||
|
}
|
||||||
}
|
}
|
||||||
client.forceReconnect()
|
client.forceReconnect()
|
||||||
}
|
}
|
||||||
@@ -291,7 +326,11 @@ final class ProtocolManager: @unchecked Sendable {
|
|||||||
self.handshakeComplete = false
|
self.handshakeComplete = false
|
||||||
self.heartbeatTask?.cancel()
|
self.heartbeatTask?.cancel()
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
self.connectionState = .connecting
|
// Guard: only downgrade to .connecting if reconnect hasn't already progressed.
|
||||||
|
let s = self.connectionState
|
||||||
|
if s != .authenticated && s != .handshaking && s != .connected {
|
||||||
|
self.connectionState = .connecting
|
||||||
|
}
|
||||||
}
|
}
|
||||||
self.client.forceReconnect()
|
self.client.forceReconnect()
|
||||||
}
|
}
|
||||||
@@ -339,7 +378,11 @@ final class ProtocolManager: @unchecked Sendable {
|
|||||||
self.handshakeComplete = false
|
self.handshakeComplete = false
|
||||||
self.heartbeatTask?.cancel()
|
self.heartbeatTask?.cancel()
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
self.connectionState = .connecting
|
// Guard: only downgrade to .connecting if reconnect hasn't already progressed.
|
||||||
|
let s = self.connectionState
|
||||||
|
if s != .authenticated && s != .handshaking && s != .connected {
|
||||||
|
self.connectionState = .connecting
|
||||||
|
}
|
||||||
}
|
}
|
||||||
self.client.forceReconnect()
|
self.client.forceReconnect()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ final class WebSocketClient: NSObject, @unchecked Sendable, URLSessionWebSocketD
|
|||||||
private var webSocketTask: URLSessionWebSocketTask?
|
private var webSocketTask: URLSessionWebSocketTask?
|
||||||
private var isManuallyClosed = false
|
private var isManuallyClosed = false
|
||||||
private var reconnectTask: Task<Void, Never>?
|
private var reconnectTask: Task<Void, Never>?
|
||||||
|
private var connectTimeoutTask: Task<Void, Never>?
|
||||||
private var hasNotifiedConnected = false
|
private var hasNotifiedConnected = false
|
||||||
private(set) var isConnected = false
|
private(set) var isConnected = false
|
||||||
/// Android parity: prevents concurrent connect() calls and suppresses handleDisconnect
|
/// Android parity: prevents concurrent connect() calls and suppresses handleDisconnect
|
||||||
@@ -82,6 +83,24 @@ final class WebSocketClient: NSObject, @unchecked Sendable, URLSessionWebSocketD
|
|||||||
task.resume()
|
task.resume()
|
||||||
|
|
||||||
receiveLoop()
|
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() {
|
func disconnect() {
|
||||||
@@ -90,6 +109,8 @@ final class WebSocketClient: NSObject, @unchecked Sendable, URLSessionWebSocketD
|
|||||||
isConnecting = false
|
isConnecting = false
|
||||||
reconnectTask?.cancel()
|
reconnectTask?.cancel()
|
||||||
reconnectTask = nil
|
reconnectTask = nil
|
||||||
|
connectTimeoutTask?.cancel()
|
||||||
|
connectTimeoutTask = nil
|
||||||
webSocketTask?.cancel(with: .goingAway, reason: nil)
|
webSocketTask?.cancel(with: .goingAway, reason: nil)
|
||||||
webSocketTask = nil
|
webSocketTask = nil
|
||||||
isConnected = false
|
isConnected = false
|
||||||
@@ -101,6 +122,8 @@ final class WebSocketClient: NSObject, @unchecked Sendable, URLSessionWebSocketD
|
|||||||
guard !isManuallyClosed else { return }
|
guard !isManuallyClosed else { return }
|
||||||
reconnectTask?.cancel()
|
reconnectTask?.cancel()
|
||||||
reconnectTask = nil
|
reconnectTask = nil
|
||||||
|
connectTimeoutTask?.cancel()
|
||||||
|
connectTimeoutTask = nil
|
||||||
// Always tear down and reconnect — connection may be zombie after background
|
// Always tear down and reconnect — connection may be zombie after background
|
||||||
webSocketTask?.cancel(with: .goingAway, reason: nil)
|
webSocketTask?.cancel(with: .goingAway, reason: nil)
|
||||||
webSocketTask = nil
|
webSocketTask = nil
|
||||||
@@ -175,6 +198,8 @@ final class WebSocketClient: NSObject, @unchecked Sendable, URLSessionWebSocketD
|
|||||||
guard !isManuallyClosed else { return }
|
guard !isManuallyClosed else { return }
|
||||||
// Android parity: reset isConnecting on successful open (Protocol.kt onOpen).
|
// Android parity: reset isConnecting on successful open (Protocol.kt onOpen).
|
||||||
isConnecting = false
|
isConnecting = false
|
||||||
|
connectTimeoutTask?.cancel()
|
||||||
|
connectTimeoutTask = nil
|
||||||
hasNotifiedConnected = true
|
hasNotifiedConnected = true
|
||||||
isConnected = true
|
isConnected = true
|
||||||
disconnectHandledForCurrentSocket = false
|
disconnectHandledForCurrentSocket = false
|
||||||
@@ -197,6 +222,18 @@ final class WebSocketClient: NSObject, @unchecked Sendable, URLSessionWebSocketD
|
|||||||
handleDisconnect(error: nil)
|
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
|
// MARK: - Receive Loop
|
||||||
|
|
||||||
private func receiveLoop() {
|
private func receiveLoop() {
|
||||||
@@ -221,6 +258,10 @@ final class WebSocketClient: NSObject, @unchecked Sendable, URLSessionWebSocketD
|
|||||||
|
|
||||||
case .failure(let error):
|
case .failure(let error):
|
||||||
Self.logger.error("Receive error: \(error.localizedDescription)")
|
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)
|
self.handleDisconnect(error: error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,6 +36,12 @@ final class SessionManager {
|
|||||||
/// PacketRead can arrive BEFORE the sync messages, so markIncomingAsRead
|
/// PacketRead can arrive BEFORE the sync messages, so markIncomingAsRead
|
||||||
/// updates 0 rows. Re-applying after sync ensures the read state sticks.
|
/// updates 0 rows. Re-applying after sync ensures the read state sticks.
|
||||||
private var pendingSyncReads: Set<String> = []
|
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 requestedUserInfoKeys: Set<String> = []
|
||||||
private var onlineSubscribedKeys: Set<String> = []
|
private var onlineSubscribedKeys: Set<String> = []
|
||||||
private var pendingOutgoingRetryTasks: [String: Task<Void, Never>] = [:]
|
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.
|
/// 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.
|
/// Called on foreground resume. Android has no idle detection — just re-marks on resume.
|
||||||
func markActiveDialogsAsRead() {
|
func markActiveDialogsAsRead() {
|
||||||
@@ -199,27 +222,19 @@ final class SessionManager {
|
|||||||
myPublicKey: currentPublicKey
|
myPublicKey: currentPublicKey
|
||||||
)
|
)
|
||||||
|
|
||||||
// Desktop parity: if not connected, set initial status to ERROR (not WAITING).
|
// Android parity: always insert as WAITING (status=0) regardless of connection state.
|
||||||
// The packet is still queued and will be sent on reconnect;
|
// Packet is queued in ProtocolManager and sent on reconnect; retry mechanism
|
||||||
// delivery ACK will update status to DELIVERED.
|
// handles timeout (80s → ERROR). Previously iOS marked offline sends as ERROR
|
||||||
let isConnected = connState == .authenticated
|
// immediately (Desktop parity), but this caused false ❌ on brief WebSocket drops.
|
||||||
let offlineAsSend = !isConnected
|
// Android keeps WAITING (⏳) and delivers automatically — much better UX.
|
||||||
|
|
||||||
// Android parity: insert message FIRST, then recalculate dialog from DB.
|
|
||||||
MessageRepository.shared.upsertFromMessagePacket(
|
MessageRepository.shared.upsertFromMessagePacket(
|
||||||
packet,
|
packet,
|
||||||
myPublicKey: currentPublicKey,
|
myPublicKey: currentPublicKey,
|
||||||
decryptedText: text,
|
decryptedText: text,
|
||||||
fromSync: offlineAsSend
|
fromSync: false
|
||||||
)
|
)
|
||||||
DialogRepository.shared.updateDialogFromMessages(opponentKey: packet.toPublicKey)
|
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.
|
// Android parity: persist IMMEDIATELY after inserting outgoing message.
|
||||||
// Without this, if app is killed within 800ms debounce window,
|
// Without this, if app is killed within 800ms debounce window,
|
||||||
// the message is lost forever (only in memory, not on disk).
|
// 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
|
opponentKey: toPublicKey, title: title, username: username, myPublicKey: currentPublicKey
|
||||||
)
|
)
|
||||||
|
|
||||||
let isConnected = ProtocolManager.shared.connectionState == .authenticated
|
|
||||||
let offlineAsSend = !isConnected
|
|
||||||
let displayText = messageText
|
let displayText = messageText
|
||||||
|
|
||||||
|
// Android parity: always insert as WAITING (fromSync: false).
|
||||||
|
// Retry mechanism handles timeout (80s → ERROR).
|
||||||
MessageRepository.shared.upsertFromMessagePacket(
|
MessageRepository.shared.upsertFromMessagePacket(
|
||||||
optimisticPacket, myPublicKey: currentPublicKey, decryptedText: displayText,
|
optimisticPacket, myPublicKey: currentPublicKey, decryptedText: displayText,
|
||||||
attachmentPassword: "rawkey:" + encrypted.plainKeyAndNonce.hexString, fromSync: offlineAsSend
|
attachmentPassword: "rawkey:" + encrypted.plainKeyAndNonce.hexString, fromSync: false
|
||||||
)
|
)
|
||||||
DialogRepository.shared.updateDialogFromMessages(opponentKey: toPublicKey)
|
DialogRepository.shared.updateDialogFromMessages(opponentKey: toPublicKey)
|
||||||
MessageRepository.shared.persistNow()
|
MessageRepository.shared.persistNow()
|
||||||
|
|
||||||
if offlineAsSend {
|
|
||||||
MessageRepository.shared.updateDeliveryStatus(messageId: messageId, status: .error)
|
|
||||||
DialogRepository.shared.updateDeliveryStatus(messageId: messageId, opponentKey: toPublicKey, status: .error)
|
|
||||||
}
|
|
||||||
if toPublicKey == currentPublicKey {
|
if toPublicKey == currentPublicKey {
|
||||||
MessageRepository.shared.updateDeliveryStatus(messageId: messageId, status: .delivered)
|
MessageRepository.shared.updateDeliveryStatus(messageId: messageId, status: .delivered)
|
||||||
DialogRepository.shared.updateDeliveryStatus(messageId: messageId, opponentKey: toPublicKey, status: .delivered)
|
DialogRepository.shared.updateDeliveryStatus(messageId: messageId, opponentKey: toPublicKey, status: .delivered)
|
||||||
@@ -885,26 +896,18 @@ final class SessionManager {
|
|||||||
myPublicKey: currentPublicKey
|
myPublicKey: currentPublicKey
|
||||||
)
|
)
|
||||||
|
|
||||||
// Optimistic UI update — use localPacket (decrypted blob) for storage
|
// Optimistic UI update — use localPacket (decrypted blob) for storage.
|
||||||
let isConnected = ProtocolManager.shared.connectionState == .authenticated
|
// Android parity: always insert as WAITING (fromSync: false).
|
||||||
let offlineAsSend = !isConnected
|
|
||||||
let displayText = messageText
|
let displayText = messageText
|
||||||
MessageRepository.shared.upsertFromMessagePacket(
|
MessageRepository.shared.upsertFromMessagePacket(
|
||||||
localPacket,
|
localPacket,
|
||||||
myPublicKey: currentPublicKey,
|
myPublicKey: currentPublicKey,
|
||||||
decryptedText: displayText,
|
decryptedText: displayText,
|
||||||
attachmentPassword: "rawkey:" + encrypted.plainKeyAndNonce.hexString,
|
attachmentPassword: "rawkey:" + encrypted.plainKeyAndNonce.hexString,
|
||||||
fromSync: offlineAsSend
|
fromSync: false
|
||||||
)
|
)
|
||||||
DialogRepository.shared.updateDialogFromMessages(opponentKey: localPacket.toPublicKey)
|
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
|
// Saved Messages: local-only
|
||||||
if toPublicKey == currentPublicKey {
|
if toPublicKey == currentPublicKey {
|
||||||
MessageRepository.shared.updateDeliveryStatus(messageId: messageId, status: .delivered)
|
MessageRepository.shared.updateDeliveryStatus(messageId: messageId, status: .delivered)
|
||||||
@@ -998,6 +1001,8 @@ final class SessionManager {
|
|||||||
lastTypingSentAt.removeAll()
|
lastTypingSentAt.removeAll()
|
||||||
syncBatchInProgress = false
|
syncBatchInProgress = false
|
||||||
syncRequestInFlight = false
|
syncRequestInFlight = false
|
||||||
|
pendingSyncReads.removeAll()
|
||||||
|
pendingOpponentReads.removeAll()
|
||||||
pendingIncomingMessages.removeAll()
|
pendingIncomingMessages.removeAll()
|
||||||
isProcessingIncomingMessages = false
|
isProcessingIncomingMessages = false
|
||||||
lastReadReceiptTimestamp.removeAll()
|
lastReadReceiptTimestamp.removeAll()
|
||||||
@@ -1091,6 +1096,13 @@ final class SessionManager {
|
|||||||
opponentKey: opponentKey,
|
opponentKey: opponentKey,
|
||||||
myPublicKey: ownKey
|
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 —
|
// Resolve pending retry timers for all messages to this opponent —
|
||||||
// read receipt proves delivery, no need to retry further.
|
// read receipt proves delivery, no need to retry further.
|
||||||
self.resolveAllOutgoingRetries(toPublicKey: opponentKey)
|
self.resolveAllOutgoingRetries(toPublicKey: opponentKey)
|
||||||
@@ -1223,6 +1235,7 @@ final class SessionManager {
|
|||||||
// PacketRead can arrive BEFORE sync messages — markIncomingAsRead
|
// PacketRead can arrive BEFORE sync messages — markIncomingAsRead
|
||||||
// updates 0 rows because the message isn't in DB yet.
|
// updates 0 rows because the message isn't in DB yet.
|
||||||
self.reapplyPendingSyncReads()
|
self.reapplyPendingSyncReads()
|
||||||
|
self.reapplyPendingOpponentReads()
|
||||||
// Android parity: reconcile unread counts after each batch.
|
// Android parity: reconcile unread counts after each batch.
|
||||||
DialogRepository.shared.reconcileUnreadCounts()
|
DialogRepository.shared.reconcileUnreadCounts()
|
||||||
Self.logger.debug("SYNC BATCH_END cursor=\(serverCursor)")
|
Self.logger.debug("SYNC BATCH_END cursor=\(serverCursor)")
|
||||||
@@ -1233,6 +1246,7 @@ final class SessionManager {
|
|||||||
self.syncBatchInProgress = false
|
self.syncBatchInProgress = false
|
||||||
// Re-apply cross-device reads one final time.
|
// Re-apply cross-device reads one final time.
|
||||||
self.reapplyPendingSyncReads()
|
self.reapplyPendingSyncReads()
|
||||||
|
self.reapplyPendingOpponentReads()
|
||||||
DialogRepository.shared.reconcileDeliveryStatuses()
|
DialogRepository.shared.reconcileDeliveryStatuses()
|
||||||
DialogRepository.shared.reconcileUnreadCounts()
|
DialogRepository.shared.reconcileUnreadCounts()
|
||||||
self.retryWaitingOutgoingMessagesAfterReconnect()
|
self.retryWaitingOutgoingMessagesAfterReconnect()
|
||||||
@@ -1871,9 +1885,19 @@ final class SessionManager {
|
|||||||
else { return }
|
else { return }
|
||||||
|
|
||||||
let now = Int64(Date().timeIntervalSince1970 * 1000)
|
let now = Int64(Date().timeIntervalSince1970 * 1000)
|
||||||
// Android parity: retry messages within 80-second window (MESSAGE_MAX_TIME_TO_DELIVERED_MS).
|
// Android parity: MESSAGE_MAX_TIME_TO_DELIVERED_MS = 80_000.
|
||||||
// Messages older than 80s are marked as error — user can retry manually.
|
|
||||||
let maxRetryAgeMs: Int64 = 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(
|
let retryable = MessageRepository.shared.resolveRetryableOutgoingMessages(
|
||||||
myPublicKey: currentPublicKey,
|
myPublicKey: currentPublicKey,
|
||||||
nowMs: now,
|
nowMs: now,
|
||||||
@@ -1896,18 +1920,15 @@ final class SessionManager {
|
|||||||
)
|
)
|
||||||
|
|
||||||
do {
|
do {
|
||||||
// Use fresh timestamp for the packet so the 80s delivery timeout
|
// Fresh timestamp for retry: server silently rejects messages older than 30s
|
||||||
// starts from NOW, not from the original send time.
|
// (Executor6Message maxPaddingSec=30). Original timestamp would fail validation
|
||||||
// Without this, messages sent 70s before reconnect would expire
|
// if reconnect took >30s. Server overwrites timestamp with System.currentTimeMillis()
|
||||||
// in ~10s — not enough time for the server to respond with 0x08.
|
// anyway (Executor6Message:102), so client timestamp is only for age validation.
|
||||||
// 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)
|
|
||||||
let packet = try makeOutgoingPacket(
|
let packet = try makeOutgoingPacket(
|
||||||
text: text,
|
text: text,
|
||||||
toPublicKey: message.toPublicKey,
|
toPublicKey: message.toPublicKey,
|
||||||
messageId: message.id,
|
messageId: message.id,
|
||||||
timestamp: retryTimestamp,
|
timestamp: Int64(Date().timeIntervalSince1970 * 1000),
|
||||||
privateKeyHex: privateKeyHex,
|
privateKeyHex: privateKeyHex,
|
||||||
privateKeyHash: privateKeyHash
|
privateKeyHash: privateKeyHash
|
||||||
)
|
)
|
||||||
@@ -1943,12 +1964,10 @@ final class SessionManager {
|
|||||||
guard let packet = self.pendingOutgoingPackets[messageId] else { return }
|
guard let packet = self.pendingOutgoingPackets[messageId] else { return }
|
||||||
let attempts = self.pendingOutgoingAttempts[messageId] ?? 0
|
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 nowMs = Int64(Date().timeIntervalSince1970 * 1000)
|
||||||
let ageMs = nowMs - packet.timestamp
|
let ageMs = nowMs - packet.timestamp
|
||||||
let attachCount = max(1, Int64(packet.attachments.count))
|
if ageMs >= self.maxOutgoingWaitingLifetimeMs {
|
||||||
let timeoutMs = self.maxOutgoingWaitingLifetimeMs * attachCount
|
|
||||||
if ageMs >= timeoutMs {
|
|
||||||
// Android parity: mark as ERROR after timeout, not DELIVERED.
|
// Android parity: mark as ERROR after timeout, not DELIVERED.
|
||||||
// If the message was actually delivered, server will send 0x08 on reconnect
|
// If the message was actually delivered, server will send 0x08 on reconnect
|
||||||
// (or sync will restore the message). Marking DELIVERED optimistically
|
// (or sync will restore the message). Marking DELIVERED optimistically
|
||||||
@@ -1967,11 +1986,10 @@ final class SessionManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
guard ProtocolManager.shared.connectionState == .authenticated else {
|
guard ProtocolManager.shared.connectionState == .authenticated else {
|
||||||
// Android parity: don't resolve (cancel) retry — keep message as .waiting.
|
// Android parity: cancel retry when not authenticated — clean up in-memory state.
|
||||||
// retryWaitingOutgoingMessagesAfterReconnect() will pick it up on next handshake.
|
// retryWaitingOutgoingMessagesAfterReconnect() re-reads from DB after sync completes.
|
||||||
// Previously: resolveOutgoingRetry() cancelled everything → message stuck as
|
Self.logger.debug("Message \(messageId) retry deferred — not authenticated")
|
||||||
// .delivered (optimistic) → never retried → lost.
|
self.resolveOutgoingRetry(messageId: messageId)
|
||||||
Self.logger.debug("Message \(messageId) retry deferred — not authenticated, keeping .waiting")
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1990,13 +2008,6 @@ final class SessionManager {
|
|||||||
let fromMe = packet.fromPublicKey == currentPublicKey
|
let fromMe = packet.fromPublicKey == currentPublicKey
|
||||||
let opponentKey = fromMe ? packet.toPublicKey : packet.fromPublicKey
|
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)
|
let deliveryTimestamp = Int64(Date().timeIntervalSince1970 * 1000)
|
||||||
MessageRepository.shared.updateDeliveryStatus(
|
MessageRepository.shared.updateDeliveryStatus(
|
||||||
messageId: messageId,
|
messageId: messageId,
|
||||||
@@ -2019,8 +2030,8 @@ final class SessionManager {
|
|||||||
let opponentKey = fromMe ? packet.toPublicKey : packet.fromPublicKey
|
let opponentKey = fromMe ? packet.toPublicKey : packet.fromPublicKey
|
||||||
|
|
||||||
let currentStatus = MessageRepository.shared.deliveryStatus(forMessageId: messageId)
|
let currentStatus = MessageRepository.shared.deliveryStatus(forMessageId: messageId)
|
||||||
if currentStatus == .delivered || currentStatus == .read {
|
if currentStatus == .delivered {
|
||||||
Self.logger.info("Skipping markOutgoingAsError for \(messageId.prefix(8))… — already \(currentStatus?.rawValue ?? -1)")
|
Self.logger.info("Skipping markOutgoingAsError for \(messageId.prefix(8))… — already delivered")
|
||||||
resolveOutgoingRetry(messageId: messageId)
|
resolveOutgoingRetry(messageId: messageId)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -2149,20 +2160,14 @@ final class SessionManager {
|
|||||||
// Android: ON_RESUME calls markVisibleMessagesAsRead() for active dialog.
|
// Android: ON_RESUME calls markVisibleMessagesAsRead() for active dialog.
|
||||||
self?.markActiveDialogsAsRead()
|
self?.markActiveDialogsAsRead()
|
||||||
|
|
||||||
// iOS-specific: after long background, iOS suspends the process and the
|
// Android parity: on foreground resume, always force reconnect.
|
||||||
// server RSTs the TCP connection. But `didCloseWith` never fires (process
|
// Android's OkHttp fires onFailure in background → state is accurate.
|
||||||
// frozen), so connectionState stays `.authenticated` (stale zombie socket).
|
// iOS URLSession does NOT fire didCloseWith when process is suspended →
|
||||||
// Android doesn't have this problem because OkHttp callbacks fire in background.
|
// connectionState stays .authenticated (zombie socket). Ping-first
|
||||||
//
|
// added 3 seconds of delay waiting for pong that never comes.
|
||||||
// Fix: if state looks "alive" (.authenticated/.connected), ping-first to
|
// Match Android: just tear down + reconnect. If connection was alive,
|
||||||
// verify. If no pong → zombie → forceReconnect. If disconnected, reconnect
|
// small cost (reconnect <1s). If zombie, no wasted 3s.
|
||||||
// immediately (Android parity: reconnectNowIfNeeded).
|
ProtocolManager.shared.forceReconnectOnForeground()
|
||||||
let state = ProtocolManager.shared.connectionState
|
|
||||||
if state == .authenticated || state == .connected {
|
|
||||||
ProtocolManager.shared.verifyConnectionOrReconnect()
|
|
||||||
} else {
|
|
||||||
ProtocolManager.shared.reconnectIfNeeded()
|
|
||||||
}
|
|
||||||
|
|
||||||
// syncOnForeground() has its own `.authenticated` guard — safe to call.
|
// syncOnForeground() has its own `.authenticated` guard — safe to call.
|
||||||
// If ping-first triggers reconnect, sync won't fire (state is .connecting).
|
// If ping-first triggers reconnect, sync won't fire (state is .connecting).
|
||||||
|
|||||||
@@ -11,10 +11,17 @@ enum ReleaseNotes {
|
|||||||
Entry(
|
Entry(
|
||||||
version: appVersion,
|
version: appVersion,
|
||||||
body: """
|
body: """
|
||||||
**Чат**
|
**Клавиатура и поле ввода**
|
||||||
Нативная прокрутка без инверсии — плавная работа клавиатуры, корректное позиционирование при открытии чата. Интерактивное скрытие клавиатуры свайпом вниз.
|
Сообщения поднимаются синхронно с клавиатурой. Исправлено перекрытие сообщений при закрытии. Скругление поля ввода корректно возвращается к капсуле при переходе из многострочного режима. Исправлено ложное срабатывание многострочного режима.
|
||||||
|
|
||||||
Поле ввода теперь двигается вместе с клавиатурой без задержки — анимация происходит в одной транзакции с клавиатурой через UIKit-контейнер.
|
**Интерфейс чата**
|
||||||
|
Тёмные градиенты по краям экрана — контент плавно уходит под навбар и home indicator. Аватарки в списке чатов обновляются мгновенно без перезахода. На iOS 26 поле ввода корректно внизу экрана, клавиатура работает нативно.
|
||||||
|
|
||||||
|
**Доставка сообщений**
|
||||||
|
Сообщения больше не помечаются ошибкой при кратковременном обрыве — показываются часики и автодоставка при реконнекте. Свежий timestamp при повторной отправке — сервер больше не отклоняет. Быстрый реконнект на foreground без 3-секундной задержки.
|
||||||
|
|
||||||
|
**Синхронизация**
|
||||||
|
Прочтения от оппонента больше не теряются при синке — переприменяются после вставки сообщений. Схема БД приведена к Android/Desktop паритету (delivered + read). Прочтение больше не перезаписывает ошибочные сообщения.
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ enum StressTestGenerator {
|
|||||||
toPublicKey: isOutgoing ? dialogKey : myKey,
|
toPublicKey: isOutgoing ? dialogKey : myKey,
|
||||||
text: attachments.isEmpty ? text : (i % 2 == 0 ? text : ""),
|
text: attachments.isEmpty ? text : (i % 2 == 0 ? text : ""),
|
||||||
timestamp: timestamp,
|
timestamp: timestamp,
|
||||||
deliveryStatus: isOutgoing ? [.delivered, .read, .delivered][i % 3] : .delivered,
|
deliveryStatus: .delivered,
|
||||||
isRead: true,
|
isRead: true,
|
||||||
attachments: attachments,
|
attachments: attachments,
|
||||||
attachmentPassword: nil
|
attachmentPassword: nil
|
||||||
|
|||||||
@@ -250,15 +250,32 @@ struct ChatTextInput: UIViewRepresentable {
|
|||||||
|
|
||||||
func invalidateHeight(_ tv: UITextView) {
|
func invalidateHeight(_ tv: UITextView) {
|
||||||
tv.invalidateIntrinsicContentSize()
|
tv.invalidateIntrinsicContentSize()
|
||||||
|
// Force contentSize recalculation before checking multiline.
|
||||||
|
// Without this, contentSize still reflects the OLD text after deletion.
|
||||||
|
tv.setNeedsLayout()
|
||||||
|
tv.layoutIfNeeded()
|
||||||
checkMultiline(tv)
|
checkMultiline(tv)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func checkMultiline(_ tv: UITextView) {
|
private func checkMultiline(_ tv: UITextView) {
|
||||||
let lineHeight = tv.font?.lineHeight ?? 20
|
let lineHeight = tv.font?.lineHeight ?? 20
|
||||||
let singleLineHeight = lineHeight + tv.textContainerInset.top + tv.textContainerInset.bottom
|
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 {
|
if isMultiline != wasMultiline {
|
||||||
wasMultiline = isMultiline
|
wasMultiline = isMultiline
|
||||||
|
#if DEBUG
|
||||||
|
print("📐 MULTILINE CHANGED → \(isMultiline)")
|
||||||
|
#endif
|
||||||
parent.onMultilineChange(isMultiline)
|
parent.onMultilineChange(isMultiline)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,12 +8,11 @@ import UIKit
|
|||||||
/// - `keyboardPadding`: bottom padding to apply when keyboard is visible
|
/// - `keyboardPadding`: bottom padding to apply when keyboard is visible
|
||||||
///
|
///
|
||||||
/// Animation strategy:
|
/// Animation strategy:
|
||||||
/// - Notification (show/hide): A hidden UIView is animated with the keyboard's
|
/// - Notification (show/hide): `spacerPadding` driven by CADisplayLink with curve-predicted
|
||||||
/// exact `UIViewAnimationCurve` (rawValue 7) inside the same Core Animation
|
/// bezier evaluation. SHOW uses 2.5-frame advance to stay ahead of UIKit composer
|
||||||
/// transaction. CADisplayLink samples the presentation layer at 60fps,
|
/// (prevents message overlap). HIDE uses 1-frame advance. `keyboardPadding`
|
||||||
/// giving pixel-perfect curve sync. Cubic bezier fallback if no window.
|
/// driven by sync view reading (for ComposerHostView consumers).
|
||||||
/// - KVO (interactive dismiss): raw assignment at 30fps via coalescing.
|
/// - KVO (interactive dismiss): raw assignment at 30fps via coalescing.
|
||||||
/// - NO `withAnimation` / `.animation()` — these cause LazyVStack cell recycling gaps.
|
|
||||||
@MainActor
|
@MainActor
|
||||||
final class KeyboardTracker: ObservableObject {
|
final class KeyboardTracker: ObservableObject {
|
||||||
|
|
||||||
@@ -173,9 +172,6 @@ final class KeyboardTracker: ObservableObject {
|
|||||||
let current = pendingKVOPadding ?? keyboardPadding
|
let current = pendingKVOPadding ?? keyboardPadding
|
||||||
guard newPadding < current else { return }
|
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)
|
// Move composer immediately (UIKit, no SwiftUI overhead)
|
||||||
composerHostView?.setKeyboardOffset(rawPadding)
|
composerHostView?.setKeyboardOffset(rawPadding)
|
||||||
|
|
||||||
@@ -202,8 +198,10 @@ final class KeyboardTracker: ObservableObject {
|
|||||||
guard pending.isFinite, pending >= 0 else { return }
|
guard pending.isFinite, pending >= 0 else { return }
|
||||||
guard pending != keyboardPadding else { return }
|
guard pending != keyboardPadding else { return }
|
||||||
keyboardPadding = pending
|
keyboardPadding = pending
|
||||||
// spacerPadding NOT updated from KVO — only from handleNotification
|
// Also update spacerPadding so KeyboardSpacer follows during interactive dismiss
|
||||||
// with withAnimation to stay in sync with keyboard CA transaction.
|
if pending != spacerPadding {
|
||||||
|
spacerPadding = pending
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Immediately applies any buffered KVO value (used when KVO stops).
|
/// 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 duration = info[UIResponder.keyboardAnimationDurationUserInfoKey] as? TimeInterval ?? 0.25
|
||||||
let curveRaw = info[UIResponder.keyboardAnimationCurveUserInfoKey] as? Int ?? 0
|
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)
|
configureBezier(curveRaw: curveRaw)
|
||||||
|
|
||||||
// Direction-dependent spacerPadding initialization:
|
// Direction-dependent initialization:
|
||||||
let isShow = targetPadding > keyboardPadding
|
let isShow = targetPadding > keyboardPadding
|
||||||
if isShow {
|
if isShow {
|
||||||
// Curve-aware head start: evaluate bezier at ~1 frame into animation.
|
// Head-start: immediately set spacerPadding to ~1 frame into animation.
|
||||||
// Covers the gap before first CADisplayLink tick fires (~8ms on 120Hz, ~16ms on 60Hz).
|
// Covers the gap before first CADisplayLink tick fires (~8ms on 120Hz).
|
||||||
// Smooth (follows curve shape), not a jarring jump.
|
// Without this, spacerPadding = 0 for the first frame → messages overlap composer.
|
||||||
let initialT = min(0.016 / max(duration, 0.05), 1.0)
|
let initialT = min(0.016 / max(duration, 0.05), 1.0)
|
||||||
let initialEased = cubicBezierEase(initialT)
|
let initialEased = cubicBezierEase(initialT)
|
||||||
let initialValue = keyboardPadding + (targetPadding - keyboardPadding) * initialEased
|
let initialValue = keyboardPadding + (targetPadding - keyboardPadding) * initialEased
|
||||||
spacerPadding = max(keyboardPadding, round(initialValue))
|
spacerPadding = max(keyboardPadding, round(initialValue))
|
||||||
|
#if DEBUG
|
||||||
|
print("⌨️ 🎬 SHOW headstart: spacer=\(Int(spacerPadding)) target=\(Int(targetPadding))")
|
||||||
|
#endif
|
||||||
} else {
|
} else {
|
||||||
// HIDE: instant drop, keyboardPadding drives smooth descent
|
// spacerPadding handled by animationTick via curve prediction
|
||||||
spacerPadding = 0
|
// 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
|
let delta = targetPadding - lastNotificationPadding
|
||||||
@@ -280,7 +288,9 @@ final class KeyboardTracker: ObservableObject {
|
|||||||
return
|
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.
|
// SwiftUI ComposerOverlay (iOS 26+) doesn't use this — composerHostView is nil.
|
||||||
if let hostView = composerHostView {
|
if let hostView = composerHostView {
|
||||||
let options = UIView.AnimationOptions(rawValue: UInt(curveRaw) << 16)
|
let options = UIView.AnimationOptions(rawValue: UInt(curveRaw) << 16)
|
||||||
@@ -441,14 +451,13 @@ final class KeyboardTracker: ObservableObject {
|
|||||||
return
|
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
|
let rawEased = eased
|
||||||
if animTickCount > 1 {
|
let prevMonotonicEased = previousMonotonicEased
|
||||||
let velocity = eased - 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 {
|
if velocity > 0 {
|
||||||
eased = min(eased + velocity * 1.0, 1.0)
|
eased = min(eased + velocity * 1.0, 1.0)
|
||||||
}
|
}
|
||||||
@@ -498,24 +507,30 @@ final class KeyboardTracker: ObservableObject {
|
|||||||
#endif
|
#endif
|
||||||
keyboardPadding = rounded
|
keyboardPadding = rounded
|
||||||
#if DEBUG
|
#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
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
// Time-advanced spacerPadding: leads keyboardPadding by ~1 frame.
|
// Curve-predicted spacerPadding: predicts where the animation will be
|
||||||
// Prevents compositor-message overlap during SHOW — UIKit composer has
|
// when SwiftUI finishes rendering (~8-16ms from now).
|
||||||
// zero lag (same CA transaction), but SwiftUI spacer has 15-25ms render
|
// SHOW: 2.5-frame advance — spacer must stay AHEAD of composer (UIKit)
|
||||||
// delay. Without this advance, messages lag behind the rising composer.
|
// to prevent message overlap. SwiftUI pipeline lag = ~8ms + render.
|
||||||
if animTargetPadding > animStartPadding {
|
// HIDE: 0 advance — SwiftUI lag keeps spacer slightly ABOVE compositor,
|
||||||
let advanceMs: CFTimeInterval = 0.016
|
// creating a small gap instead of overlap. Gap > overlap.
|
||||||
let advancedT = min((elapsed + advanceMs) / animDuration, 1.0)
|
let frameInterval = displayLinkProxy?.currentFrameInterval ?? 0.008
|
||||||
let advancedEased = cubicBezierEase(advancedT)
|
let isShowDirection = animTargetPadding > animStartPadding
|
||||||
let advancedRaw = animStartPadding + (animTargetPadding - animStartPadding) * advancedEased
|
let advanceFrames: CGFloat = isShowDirection ? 2.5 : 0
|
||||||
let advancedRounded = max(0, round(advancedRaw))
|
let predictedElapsed = elapsed + frameInterval * advanceFrames
|
||||||
// Monotonic: only increase during SHOW (no jitter from bezier approximation)
|
let predictedT = min(predictedElapsed / animDuration, 1.0)
|
||||||
if advancedRounded > spacerPadding {
|
let predictedEased = cubicBezierEase(predictedT)
|
||||||
spacerPadding = advancedRounded
|
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 callback: (() -> Void)?
|
||||||
private var displayLink: CADisplayLink?
|
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).
|
/// - Parameter maxFPS: Max frame rate. 0 = device native (120Hz on ProMotion).
|
||||||
/// Non-zero values cap via preferredFrameRateRange.
|
/// Non-zero values cap via preferredFrameRateRange.
|
||||||
init(maxFPS: Int = 0, callback: @escaping () -> Void) {
|
init(maxFPS: Int = 0, callback: @escaping () -> Void) {
|
||||||
@@ -593,6 +612,9 @@ private class DisplayLinkProxy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@objc private func tick() {
|
@objc private func tick() {
|
||||||
|
if let dl = displayLink {
|
||||||
|
currentFrameInterval = dl.targetTimestamp - dl.timestamp
|
||||||
|
}
|
||||||
callback?()
|
callback?()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -68,8 +68,14 @@ struct TelegramGlassRoundedRect: UIViewRepresentable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func updateUIView(_ uiView: TelegramGlassUIView, context: Context) {
|
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.isFrozen = !context.environment.telegramGlassActive
|
||||||
uiView.fixedCornerRadius = cornerRadius
|
uiView.fixedCornerRadius = cornerRadius
|
||||||
|
uiView.applyCornerRadius()
|
||||||
uiView.updateGlass()
|
uiView.updateGlass()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -99,10 +105,10 @@ final class TelegramGlassUIView: UIView {
|
|||||||
private var backdropLayer: CALayer?
|
private var backdropLayer: CALayer?
|
||||||
private let clippingContainer = CALayer()
|
private let clippingContainer = CALayer()
|
||||||
private let foregroundLayer = CALayer()
|
private let foregroundLayer = CALayer()
|
||||||
private let borderLayer = CAShapeLayer()
|
|
||||||
|
|
||||||
// iOS 26+ native glass
|
// iOS 26+ native glass
|
||||||
private var nativeGlassView: UIVisualEffectView?
|
private var nativeGlassView: UIVisualEffectView?
|
||||||
|
private var glassMaskLayer: CAShapeLayer?
|
||||||
|
|
||||||
override init(frame: CGRect) {
|
override init(frame: CGRect) {
|
||||||
super.init(frame: frame)
|
super.init(frame: frame)
|
||||||
@@ -131,6 +137,7 @@ final class TelegramGlassUIView: UIView {
|
|||||||
// Telegram dark mode tint: UIColor(white: 1.0, alpha: 0.025)
|
// Telegram dark mode tint: UIColor(white: 1.0, alpha: 0.025)
|
||||||
effect.tintColor = UIColor(white: 1.0, alpha: 0.025)
|
effect.tintColor = UIColor(white: 1.0, alpha: 0.025)
|
||||||
let glassView = UIVisualEffectView(effect: effect)
|
let glassView = UIVisualEffectView(effect: effect)
|
||||||
|
glassView.clipsToBounds = true
|
||||||
glassView.layer.cornerCurve = .continuous
|
glassView.layer.cornerCurve = .continuous
|
||||||
glassView.isUserInteractionEnabled = false
|
glassView.isUserInteractionEnabled = false
|
||||||
addSubview(glassView)
|
addSubview(glassView)
|
||||||
@@ -143,7 +150,7 @@ final class TelegramGlassUIView: UIView {
|
|||||||
// Clipping container — holds backdrop + foreground, clips to pill shape.
|
// Clipping container — holds backdrop + foreground, clips to pill shape.
|
||||||
// Border is added to main layer OUTSIDE the clip so it's fully visible.
|
// Border is added to main layer OUTSIDE the clip so it's fully visible.
|
||||||
clippingContainer.masksToBounds = true
|
clippingContainer.masksToBounds = true
|
||||||
clippingContainer.cornerCurve = .circular
|
clippingContainer.cornerCurve = .continuous
|
||||||
layer.addSublayer(clippingContainer)
|
layer.addSublayer(clippingContainer)
|
||||||
|
|
||||||
// 1. CABackdropLayer — blurs content behind this view
|
// 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
|
foregroundLayer.backgroundColor = UIColor(white: 0.11, alpha: 0.85).cgColor
|
||||||
clippingContainer.addSublayer(foregroundLayer)
|
clippingContainer.addSublayer(foregroundLayer)
|
||||||
|
|
||||||
// 3. Border — on main layer, NOT inside clipping container
|
// 3. Border — on main layer via CALayer border properties.
|
||||||
borderLayer.fillColor = UIColor.clear.cgColor
|
// Using layer.borderWidth + cornerCurve ensures the border follows
|
||||||
borderLayer.strokeColor = UIColor(red: 0x38/255.0, green: 0x38/255.0, blue: 0x38/255.0, alpha: 1.0).cgColor
|
// the same .continuous curve as the clipping container fill.
|
||||||
borderLayer.lineWidth = 0.5
|
layer.borderWidth = 0.5
|
||||||
layer.addSublayer(borderLayer)
|
layer.borderColor = UIColor(red: 0x38/255.0, green: 0x38/255.0, blue: 0x38/255.0, alpha: 1.0).cgColor
|
||||||
|
layer.cornerCurve = .continuous
|
||||||
layer.cornerCurve = .circular
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Layout
|
// MARK: - Layout
|
||||||
@@ -180,6 +186,38 @@ final class TelegramGlassUIView: UIView {
|
|||||||
setNeedsLayout()
|
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() {
|
override func layoutSubviews() {
|
||||||
super.layoutSubviews()
|
super.layoutSubviews()
|
||||||
let bounds = bounds
|
let bounds = bounds
|
||||||
@@ -187,7 +225,8 @@ final class TelegramGlassUIView: UIView {
|
|||||||
|
|
||||||
let cornerRadius: CGFloat
|
let cornerRadius: CGFloat
|
||||||
if let fixed = fixedCornerRadius {
|
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 {
|
} else if isCircle {
|
||||||
cornerRadius = min(bounds.width, bounds.height) / 2
|
cornerRadius = min(bounds.width, bounds.height) / 2
|
||||||
} else {
|
} else {
|
||||||
@@ -196,23 +235,33 @@ final class TelegramGlassUIView: UIView {
|
|||||||
|
|
||||||
if #available(iOS 26.0, *), let glassView = nativeGlassView {
|
if #available(iOS 26.0, *), let glassView = nativeGlassView {
|
||||||
glassView.frame = bounds
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Legacy layout
|
// Legacy layout — clippingContainer.masksToBounds clips all children,
|
||||||
|
// so foregroundLayer needs no cornerRadius (avoids double-rounding artifacts).
|
||||||
clippingContainer.frame = bounds
|
clippingContainer.frame = bounds
|
||||||
clippingContainer.cornerRadius = cornerRadius
|
clippingContainer.cornerRadius = cornerRadius
|
||||||
backdropLayer?.frame = bounds
|
backdropLayer?.frame = bounds
|
||||||
foregroundLayer.frame = bounds
|
foregroundLayer.frame = bounds
|
||||||
foregroundLayer.cornerRadius = cornerRadius
|
|
||||||
|
|
||||||
let halfBorder = borderLayer.lineWidth / 2
|
// Border follows .continuous curve via main layer's cornerRadius.
|
||||||
let borderRect = bounds.insetBy(dx: halfBorder, dy: halfBorder)
|
// clipsToBounds is false, so this only affects visual border — not child clipping.
|
||||||
let borderRadius = max(0, cornerRadius - halfBorder)
|
layer.cornerRadius = cornerRadius
|
||||||
let borderPath = UIBezierPath(roundedRect: borderRect, cornerRadius: borderRadius)
|
|
||||||
borderLayer.path = borderPath.cgPath
|
|
||||||
borderLayer.frame = bounds
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Shadow (drawn as separate image — Telegram parity)
|
// 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
|
// Inverted scroll: spacer at VStack START. Growing it pushes
|
||||||
// messages away from offset=0 → visually UP. CADisplayLink
|
// messages away from offset=0 → visually UP. CADisplayLink
|
||||||
// animates keyboardPadding in sync with keyboard curve.
|
// animates keyboardPadding in sync with keyboard curve.
|
||||||
return composerHeight + max(keyboard.keyboardPadding, keyboard.spacerPadding) + 8
|
return composerHeight + keyboard.spacerPadding + 8
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
@@ -220,22 +220,9 @@ struct ChatDetailView: View {
|
|||||||
}
|
}
|
||||||
.modifier(IgnoreKeyboardSafeAreaLegacy())
|
.modifier(IgnoreKeyboardSafeAreaLegacy())
|
||||||
.background {
|
.background {
|
||||||
ZStack(alignment: .bottom) {
|
ZStack {
|
||||||
RosettaColors.Adaptive.background
|
RosettaColors.Adaptive.background
|
||||||
tiledChatBackground
|
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()
|
.ignoresSafeArea()
|
||||||
}
|
}
|
||||||
@@ -594,7 +581,39 @@ private extension ChatDetailView {
|
|||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
var chatEdgeGradients: some View {
|
var chatEdgeGradients: some View {
|
||||||
if #available(iOS 26, *) {
|
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 {
|
} else {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
// Telegram-style: dark gradient that smoothly fades content into
|
// Telegram-style: dark gradient that smoothly fades content into
|
||||||
@@ -615,8 +634,20 @@ private extension ChatDetailView {
|
|||||||
.frame(height: 90)
|
.frame(height: 90)
|
||||||
|
|
||||||
Spacer()
|
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)
|
.allowsHitTesting(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1443,7 +1474,7 @@ private extension ChatDetailView {
|
|||||||
if message.deliveryStatus == .error {
|
if message.deliveryStatus == .error {
|
||||||
errorMenu(for: message)
|
errorMenu(for: message)
|
||||||
} else {
|
} else {
|
||||||
deliveryIndicator(message.deliveryStatus)
|
deliveryIndicator(message.deliveryStatus, read: message.isRead)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1463,7 +1494,7 @@ private extension ChatDetailView {
|
|||||||
if message.deliveryStatus == .error {
|
if message.deliveryStatus == .error {
|
||||||
errorMenu(for: message)
|
errorMenu(for: message)
|
||||||
} else {
|
} else {
|
||||||
mediaDeliveryIndicator(message.deliveryStatus)
|
mediaDeliveryIndicator(message.deliveryStatus, read: message.isRead)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1600,6 +1631,9 @@ private extension ChatDetailView {
|
|||||||
},
|
},
|
||||||
onUserTextInsertion: handleComposerUserTyping,
|
onUserTextInsertion: handleComposerUserTyping,
|
||||||
onMultilineChange: { multiline in
|
onMultilineChange: { multiline in
|
||||||
|
#if DEBUG
|
||||||
|
print("📐 onMultilineChange callback: \(multiline) (was \(isMultilineInput)) → radius will be \(multiline ? 16 : 21)")
|
||||||
|
#endif
|
||||||
withAnimation(.easeInOut(duration: 0.2)) {
|
withAnimation(.easeInOut(duration: 0.2)) {
|
||||||
isMultilineInput = multiline
|
isMultilineInput = multiline
|
||||||
}
|
}
|
||||||
@@ -1611,7 +1645,9 @@ private extension ChatDetailView {
|
|||||||
.frame(maxWidth: .infinity, minHeight: 36, alignment: .bottomLeading)
|
.frame(maxWidth: .infinity, minHeight: 36, alignment: .bottomLeading)
|
||||||
|
|
||||||
HStack(alignment: .center, spacing: 0) {
|
HStack(alignment: .center, spacing: 0) {
|
||||||
Button { } label: {
|
Button {
|
||||||
|
switchToEmojiKeyboard()
|
||||||
|
} label: {
|
||||||
TelegramVectorIcon(
|
TelegramVectorIcon(
|
||||||
pathData: TelegramIconPath.emojiMoon,
|
pathData: TelegramIconPath.emojiMoon,
|
||||||
viewBox: CGSize(width: 19, height: 19),
|
viewBox: CGSize(width: 19, height: 19),
|
||||||
@@ -1620,8 +1656,8 @@ private extension ChatDetailView {
|
|||||||
.frame(width: 19, height: 19)
|
.frame(width: 19, height: 19)
|
||||||
.frame(width: 20, height: 36)
|
.frame(width: 20, height: 36)
|
||||||
}
|
}
|
||||||
.accessibilityLabel("Quick actions")
|
.accessibilityLabel("Emoji")
|
||||||
.buttonStyle(ChatDetailGlassPressButtonStyle())
|
.buttonStyle(.plain)
|
||||||
}
|
}
|
||||||
.padding(.trailing, 8 + (sendButtonWidth * sendButtonProgress))
|
.padding(.trailing, 8 + (sendButtonWidth * sendButtonProgress))
|
||||||
.frame(height: 36, alignment: .center)
|
.frame(height: 36, alignment: .center)
|
||||||
@@ -1804,6 +1840,12 @@ private extension ChatDetailView {
|
|||||||
else { isInputFocused = true }
|
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 {
|
var composerDismissGesture: some Gesture {
|
||||||
DragGesture(minimumDistance: 10)
|
DragGesture(minimumDistance: 10)
|
||||||
.onChanged { value in
|
.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 {
|
switch status {
|
||||||
case .read: return Color(hex: 0xA4E2FF)
|
|
||||||
case .delivered: return Color.white.opacity(0.5)
|
case .delivered: return Color.white.opacity(0.5)
|
||||||
case .error: return RosettaColors.error
|
case .error: return RosettaColors.error
|
||||||
default: return Color.white.opacity(0.78)
|
default: return Color.white.opacity(0.78)
|
||||||
@@ -1825,47 +1868,51 @@ private extension ChatDetailView {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
func deliveryIndicator(_ status: DeliveryStatus) -> some View {
|
func deliveryIndicator(_ status: DeliveryStatus, read: Bool) -> some View {
|
||||||
switch status {
|
if status == .delivered && read {
|
||||||
case .read:
|
|
||||||
DoubleCheckmarkShape()
|
DoubleCheckmarkShape()
|
||||||
.fill(deliveryTint(status))
|
.fill(deliveryTint(status, read: read))
|
||||||
.frame(width: 16, height: 8.7)
|
.frame(width: 16, height: 8.7)
|
||||||
case .delivered:
|
} else {
|
||||||
SingleCheckmarkShape()
|
switch status {
|
||||||
.fill(deliveryTint(status))
|
case .delivered:
|
||||||
.frame(width: 12, height: 8.8)
|
SingleCheckmarkShape()
|
||||||
case .waiting:
|
.fill(deliveryTint(status, read: read))
|
||||||
Image(systemName: "clock")
|
.frame(width: 12, height: 8.8)
|
||||||
.font(.system(size: 10, weight: .semibold))
|
case .waiting:
|
||||||
.foregroundStyle(deliveryTint(status))
|
Image(systemName: "clock")
|
||||||
case .error:
|
.font(.system(size: 10, weight: .semibold))
|
||||||
Image(systemName: "exclamationmark.circle.fill")
|
.foregroundStyle(deliveryTint(status, read: read))
|
||||||
.font(.system(size: 10, weight: .semibold))
|
case .error:
|
||||||
.foregroundStyle(deliveryTint(status))
|
Image(systemName: "exclamationmark.circle.fill")
|
||||||
|
.font(.system(size: 10, weight: .semibold))
|
||||||
|
.foregroundStyle(deliveryTint(status, read: read))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Delivery indicator with white tint for on-image media overlay.
|
/// Delivery indicator with white tint for on-image media overlay.
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
func mediaDeliveryIndicator(_ status: DeliveryStatus) -> some View {
|
func mediaDeliveryIndicator(_ status: DeliveryStatus, read: Bool) -> some View {
|
||||||
switch status {
|
if status == .delivered && read {
|
||||||
case .read:
|
|
||||||
DoubleCheckmarkShape()
|
DoubleCheckmarkShape()
|
||||||
.fill(Color.white)
|
.fill(Color.white)
|
||||||
.frame(width: 16, height: 8.7)
|
.frame(width: 16, height: 8.7)
|
||||||
case .delivered:
|
} else {
|
||||||
SingleCheckmarkShape()
|
switch status {
|
||||||
.fill(Color.white.opacity(0.8))
|
case .delivered:
|
||||||
.frame(width: 12, height: 8.8)
|
SingleCheckmarkShape()
|
||||||
case .waiting:
|
.fill(Color.white.opacity(0.8))
|
||||||
Image(systemName: "clock")
|
.frame(width: 12, height: 8.8)
|
||||||
.font(.system(size: 10, weight: .semibold))
|
case .waiting:
|
||||||
.foregroundStyle(.white.opacity(0.8))
|
Image(systemName: "clock")
|
||||||
case .error:
|
.font(.system(size: 10, weight: .semibold))
|
||||||
Image(systemName: "exclamationmark.circle.fill")
|
.foregroundStyle(.white.opacity(0.8))
|
||||||
.font(.system(size: 10, weight: .semibold))
|
case .error:
|
||||||
.foregroundStyle(RosettaColors.error)
|
Image(systemName: "exclamationmark.circle.fill")
|
||||||
|
.font(.system(size: 10, weight: .semibold))
|
||||||
|
.foregroundStyle(RosettaColors.error)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1980,7 +2027,7 @@ private extension ChatDetailView {
|
|||||||
|
|
||||||
private func contextMenuReadStatus(for message: ChatMessage) -> String? {
|
private func contextMenuReadStatus(for message: ChatMessage) -> String? {
|
||||||
let outgoing = message.isFromMe(myPublicKey: currentPublicKey)
|
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"
|
return "Read"
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2788,11 +2835,17 @@ enum TelegramIconPath {
|
|||||||
/// iOS 26+: SwiftUI handles keyboard natively.
|
/// iOS 26+: SwiftUI handles keyboard natively.
|
||||||
private struct IgnoreKeyboardSafeAreaLegacy: ViewModifier {
|
private struct IgnoreKeyboardSafeAreaLegacy: ViewModifier {
|
||||||
func body(content: Content) -> some View {
|
func body(content: Content) -> some View {
|
||||||
// Both iOS versions: disable SwiftUI's native keyboard avoidance.
|
if #available(iOS 26, *) {
|
||||||
// Inverted scroll (scaleEffect y: -1) breaks native avoidance — it pushes
|
// iOS 26+: SwiftUI handles keyboard natively — don't block it.
|
||||||
// content in the wrong direction. KeyboardSpacer + ComposerOverlay handle
|
// KeyboardTracker is inert on iOS 26 (init returns early).
|
||||||
// keyboard offset manually via KeyboardTracker.
|
content
|
||||||
content.ignoresSafeArea(.keyboard)
|
} else {
|
||||||
|
// iOS < 26: disable SwiftUI's native keyboard avoidance.
|
||||||
|
// Inverted scroll (scaleEffect y: -1) breaks native avoidance — it pushes
|
||||||
|
// content in the wrong direction. KeyboardSpacer + ComposerHostView handle
|
||||||
|
// keyboard offset manually via KeyboardTracker.
|
||||||
|
content.ignoresSafeArea(.keyboard)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2846,6 +2899,7 @@ private struct ComposerOverlay<C: View>: View {
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
.padding(.bottom, pad)
|
.padding(.bottom, pad)
|
||||||
|
.frame(maxHeight: .infinity, alignment: .bottom)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -50,8 +50,15 @@ struct ChatRowView: View {
|
|||||||
|
|
||||||
// MARK: - Avatar
|
// MARK: - Avatar
|
||||||
|
|
||||||
private extension ChatRowView {
|
/// Observation-isolated: reads `AvatarRepository.avatarVersion` in its own
|
||||||
var avatarSection: some View {
|
/// 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(
|
AvatarView(
|
||||||
initials: dialog.initials,
|
initials: dialog.initials,
|
||||||
colorIndex: dialog.avatarColorIndex,
|
colorIndex: dialog.avatarColorIndex,
|
||||||
@@ -63,6 +70,12 @@ private extension ChatRowView {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private extension ChatRowView {
|
||||||
|
var avatarSection: some View {
|
||||||
|
ChatRowAvatar(dialog: dialog)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Content Section
|
// MARK: - Content Section
|
||||||
// Figma "Contents": flex-col, h-full, items-start, justify-center, pb-px
|
// Figma "Contents": flex-col, h-full, items-start, justify-center, pb-px
|
||||||
// └─ "Title and Trailing Accessories": flex-1, gap-6, items-center
|
// └─ "Title and Trailing Accessories": flex-1, gap-6, items-center
|
||||||
@@ -209,22 +222,24 @@ private extension ChatRowView {
|
|||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
var deliveryIcon: some View {
|
var deliveryIcon: some View {
|
||||||
switch dialog.lastMessageDelivered {
|
if dialog.lastMessageDelivered == .delivered && dialog.lastMessageRead {
|
||||||
case .waiting:
|
|
||||||
// Timer isolated to sub-view — only .waiting rows create a timer.
|
|
||||||
DeliveryWaitingIcon(sentTimestamp: dialog.lastMessageTimestamp)
|
|
||||||
case .delivered:
|
|
||||||
SingleCheckmarkShape()
|
|
||||||
.fill(RosettaColors.Adaptive.textSecondary)
|
|
||||||
.frame(width: 14, height: 10.3)
|
|
||||||
case .read:
|
|
||||||
DoubleCheckmarkShape()
|
DoubleCheckmarkShape()
|
||||||
.fill(RosettaColors.figmaBlue)
|
.fill(RosettaColors.figmaBlue)
|
||||||
.frame(width: 17, height: 9.3)
|
.frame(width: 17, height: 9.3)
|
||||||
case .error:
|
} else {
|
||||||
Image(systemName: "exclamationmark.circle.fill")
|
switch dialog.lastMessageDelivered {
|
||||||
.font(.system(size: 14))
|
case .waiting:
|
||||||
.foregroundStyle(RosettaColors.error)
|
// Timer isolated to sub-view — only .waiting rows create a timer.
|
||||||
|
DeliveryWaitingIcon(sentTimestamp: dialog.lastMessageTimestamp)
|
||||||
|
case .delivered:
|
||||||
|
SingleCheckmarkShape()
|
||||||
|
.fill(RosettaColors.Adaptive.textSecondary)
|
||||||
|
.frame(width: 14, height: 10.3)
|
||||||
|
case .error:
|
||||||
|
Image(systemName: "exclamationmark.circle.fill")
|
||||||
|
.font(.system(size: 14))
|
||||||
|
.foregroundStyle(RosettaColors.error)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -341,7 +356,8 @@ private extension ChatRowView {
|
|||||||
unreadCount: 3, isOnline: true, lastSeen: 0,
|
unreadCount: 3, isOnline: true, lastSeen: 0,
|
||||||
verified: 1, iHaveSent: true,
|
verified: 1, iHaveSent: true,
|
||||||
isPinned: false, isMuted: false,
|
isPinned: false, isMuted: false,
|
||||||
lastMessageFromMe: true, lastMessageDelivered: .read
|
lastMessageFromMe: true, lastMessageDelivered: .delivered,
|
||||||
|
lastMessageRead: true
|
||||||
)
|
)
|
||||||
|
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
|
|||||||
@@ -4,6 +4,8 @@
|
|||||||
<dict>
|
<dict>
|
||||||
<key>aps-environment</key>
|
<key>aps-environment</key>
|
||||||
<string>development</string>
|
<string>development</string>
|
||||||
|
<key>com.apple.developer.usernotifications.communication</key>
|
||||||
|
<true/>
|
||||||
<key>com.apple.security.application-groups</key>
|
<key>com.apple.security.application-groups</key>
|
||||||
<array>
|
<array>
|
||||||
<string>group.com.rosetta.dev</string>
|
<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(
|
UNUserNotificationCenter.current().requestAuthorization(
|
||||||
options: [.alert, .badge, .sound]
|
options: [.alert, .badge, .sound, .carPlay]
|
||||||
) { granted, _ in
|
) { granted, _ in
|
||||||
if granted {
|
if granted {
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
|
|||||||
@@ -26,6 +26,13 @@
|
|||||||
<string>com.apple.usernotifications.service</string>
|
<string>com.apple.usernotifications.service</string>
|
||||||
<key>NSExtensionPrincipalClass</key>
|
<key>NSExtensionPrincipalClass</key>
|
||||||
<string>$(PRODUCT_MODULE_NAME).NotificationService</string>
|
<string>$(PRODUCT_MODULE_NAME).NotificationService</string>
|
||||||
|
<key>NSExtensionAttributes</key>
|
||||||
|
<dict>
|
||||||
|
<key>IntentsSupported</key>
|
||||||
|
<array>
|
||||||
|
<string>INSendMessageIntent</string>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
</dict>
|
</dict>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|||||||
Reference in New Issue
Block a user