Доставка сообщений при потере сети, кэш фото при отправке, FPS клавиатуры, свайп фото, badge tab bar, release notes, sync unread fix
This commit is contained in:
@@ -417,7 +417,7 @@
|
||||
CODE_SIGN_ENTITLEMENTS = Rosetta/Rosetta.entitlements;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 20;
|
||||
CURRENT_PROJECT_VERSION = 21;
|
||||
DEVELOPMENT_TEAM = QN8Z263QGX;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
@@ -433,7 +433,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.1.9;
|
||||
MARKETING_VERSION = 1.2.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.rosetta.dev;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
@@ -456,7 +456,7 @@
|
||||
CODE_SIGN_ENTITLEMENTS = Rosetta/Rosetta.entitlements;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 20;
|
||||
CURRENT_PROJECT_VERSION = 21;
|
||||
DEVELOPMENT_TEAM = QN8Z263QGX;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
@@ -472,7 +472,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.1.9;
|
||||
MARKETING_VERSION = 1.2.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.rosetta.dev;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
|
||||
@@ -53,14 +53,14 @@
|
||||
<CommandLineArguments>
|
||||
<CommandLineArgument
|
||||
argument = "-LogForEachSlowPath YES"
|
||||
isEnabled = "YES">
|
||||
isEnabled = "NO">
|
||||
</CommandLineArgument>
|
||||
</CommandLineArguments>
|
||||
<EnvironmentVariables>
|
||||
<EnvironmentVariable
|
||||
key = "CA_DEBUG_TRANSACTIONS"
|
||||
value = "1"
|
||||
isEnabled = "YES">
|
||||
isEnabled = "NO">
|
||||
</EnvironmentVariable>
|
||||
</EnvironmentVariables>
|
||||
</LaunchAction>
|
||||
|
||||
@@ -172,7 +172,9 @@ final class MessageRepository: ObservableObject {
|
||||
if fromMe, messages[existingIndex].deliveryStatus == .error {
|
||||
messages[existingIndex].deliveryStatus = fromSync ? .delivered : .waiting
|
||||
}
|
||||
if incomingRead {
|
||||
// Mark as read if dialog is active OR if this is a sync-restored
|
||||
// known message (was read on another device).
|
||||
if incomingRead || (fromSync && wasKnownBefore && !fromMe) {
|
||||
messages[existingIndex].isRead = true
|
||||
}
|
||||
return
|
||||
@@ -404,6 +406,23 @@ final class MessageRepository: ObservableObject {
|
||||
schedulePersist()
|
||||
}
|
||||
|
||||
/// Android parity: persist immediately (no debounce) after sending a message.
|
||||
/// Ensures the outgoing message with .waiting status survives app termination.
|
||||
/// Android saves to Room DB synchronously in sendMessage(); iOS debounces at 800ms.
|
||||
func persistNow() {
|
||||
guard !currentAccount.isEmpty else { return }
|
||||
persistTask?.cancel()
|
||||
let snapshot = messagesByDialog
|
||||
let idsSnapshot = allKnownMessageIds
|
||||
let messagesFile = Self.messagesFileName(for: currentAccount)
|
||||
let knownIdsFile = Self.knownIdsFileName(for: currentAccount)
|
||||
let password = storagePassword.isEmpty ? nil : storagePassword
|
||||
persistTask = Task(priority: .userInitiated) {
|
||||
await ChatPersistenceStore.shared.save(snapshot, fileName: messagesFile, password: password)
|
||||
await ChatPersistenceStore.shared.save(idsSnapshot, fileName: knownIdsFile, password: password)
|
||||
}
|
||||
}
|
||||
|
||||
private func normalizeTimestamp(_ raw: Int64) -> Int64 {
|
||||
// Some peers still send seconds, while Android path uses milliseconds.
|
||||
raw < 1_000_000_000_000 ? raw * 1000 : raw
|
||||
|
||||
@@ -104,41 +104,23 @@ final class ProtocolManager: @unchecked Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
/// Verify connection health after returning from background.
|
||||
/// Fast path: if already authenticated, send a WebSocket ping first (< 100ms).
|
||||
/// If pong arrives, connection is alive — no reconnect needed.
|
||||
/// If ping fails or times out (500ms), force full reconnect.
|
||||
/// Android parity: `reconnectNowIfNeeded()` — if already in an active state,
|
||||
/// skip reconnect. Otherwise reset backoff and connect immediately.
|
||||
func reconnectIfNeeded() {
|
||||
guard savedPublicKey != nil, savedPrivateHash != nil else { return }
|
||||
|
||||
// Don't interrupt active handshake
|
||||
if connectionState == .handshaking { return }
|
||||
|
||||
// Fast path: if authenticated, try ping first before tearing down.
|
||||
if connectionState == .authenticated, client.isConnected {
|
||||
Self.logger.info("Foreground — ping check")
|
||||
client.sendPing { [weak self] error in
|
||||
guard let self else { return }
|
||||
if error == nil {
|
||||
// Pong received — connection alive, send heartbeat to keep it fresh.
|
||||
Self.logger.info("Foreground ping OK — connection alive")
|
||||
self.client.sendText("heartbeat")
|
||||
return
|
||||
}
|
||||
// Ping failed — connection dead, force reconnect.
|
||||
Self.logger.info("Foreground ping failed — force reconnecting")
|
||||
self.handshakeComplete = false
|
||||
self.heartbeatTask?.cancel()
|
||||
Task { @MainActor in
|
||||
self.connectionState = .connecting
|
||||
}
|
||||
self.client.forceReconnect()
|
||||
}
|
||||
// Android parity: skip if already in any active state.
|
||||
switch connectionState {
|
||||
case .authenticated, .handshaking, .deviceVerificationRequired, .connected:
|
||||
return
|
||||
case .connecting:
|
||||
if client.isConnected { return }
|
||||
case .disconnected:
|
||||
break
|
||||
}
|
||||
|
||||
// Not authenticated — force reconnect immediately.
|
||||
Self.logger.info("Foreground reconnect — force reconnecting")
|
||||
// Reset backoff and connect immediately.
|
||||
Self.logger.info("⚡ Fast reconnect — state=\(self.connectionState.rawValue)")
|
||||
handshakeComplete = false
|
||||
heartbeatTask?.cancel()
|
||||
connectionState = .connecting
|
||||
@@ -402,6 +384,8 @@ final class ProtocolManager: @unchecked Sendable {
|
||||
switch packet.handshakeState {
|
||||
case .completed:
|
||||
handshakeComplete = true
|
||||
// Android parity: reset backoff counter on successful authentication.
|
||||
client.resetReconnectAttempts()
|
||||
Self.logger.info("Handshake completed. Protocol v\(packet.protocolVersion), heartbeat \(packet.heartbeatInterval)s")
|
||||
|
||||
flushPacketQueue()
|
||||
@@ -441,18 +425,41 @@ final class ProtocolManager: @unchecked Sendable {
|
||||
// Android parity: heartbeat at 1/3 the server-specified interval (more aggressive keep-alive).
|
||||
let intervalNs = UInt64(interval) * 1_000_000_000 / 3
|
||||
|
||||
heartbeatTask = Task {
|
||||
heartbeatTask = Task { [weak self] in
|
||||
// Send first heartbeat immediately
|
||||
client.sendText("heartbeat")
|
||||
self?.sendHeartbeat()
|
||||
|
||||
while !Task.isCancelled {
|
||||
try? await Task.sleep(nanoseconds: intervalNs)
|
||||
guard !Task.isCancelled else { break }
|
||||
client.sendText("heartbeat")
|
||||
self?.sendHeartbeat()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Android parity: send heartbeat and trigger disconnect on failure.
|
||||
private func sendHeartbeat() {
|
||||
let state = connectionState
|
||||
guard state == .authenticated || state == .deviceVerificationRequired else { return }
|
||||
guard client.isConnected else {
|
||||
Self.logger.warning("💔 Heartbeat failed: socket not connected — triggering reconnect")
|
||||
handleHeartbeatFailure()
|
||||
return
|
||||
}
|
||||
client.sendText("heartbeat")
|
||||
}
|
||||
|
||||
/// Android parity: failed heartbeat → handleDisconnect.
|
||||
private func handleHeartbeatFailure() {
|
||||
heartbeatTask?.cancel()
|
||||
handshakeComplete = false
|
||||
Task { @MainActor in
|
||||
self.connectionState = .disconnected
|
||||
}
|
||||
// Let WebSocketClient's own handleDisconnect schedule reconnect.
|
||||
client.forceReconnect()
|
||||
}
|
||||
|
||||
// MARK: - Packet Queue
|
||||
|
||||
private func sendPacketDirect(_ packet: any Packet) {
|
||||
|
||||
@@ -15,7 +15,7 @@ final class WebSocketClient: NSObject, @unchecked Sendable, URLSessionWebSocketD
|
||||
private var hasNotifiedConnected = false
|
||||
private(set) var isConnected = false
|
||||
private var disconnectHandledForCurrentSocket = false
|
||||
/// Android parity: exponential backoff counter, reset on successful connection.
|
||||
/// Android parity: exponential backoff counter, reset on AUTHENTICATED (not on open).
|
||||
private var reconnectAttempts = 0
|
||||
|
||||
/// NWPathMonitor for instant reconnect on network changes (Wi-Fi ↔ cellular, etc.).
|
||||
@@ -85,7 +85,7 @@ final class WebSocketClient: NSObject, @unchecked Sendable, URLSessionWebSocketD
|
||||
}
|
||||
|
||||
/// Immediately reconnect, bypassing scheduled retry.
|
||||
/// Used when returning from background to establish connection ASAP.
|
||||
/// Android parity: `reconnectNowIfNeeded()` — reset backoff and connect.
|
||||
func forceReconnect() {
|
||||
guard !isManuallyClosed else { return }
|
||||
reconnectTask?.cancel()
|
||||
@@ -101,6 +101,11 @@ final class WebSocketClient: NSObject, @unchecked Sendable, URLSessionWebSocketD
|
||||
connect()
|
||||
}
|
||||
|
||||
/// Android parity: reset backoff counter on successful AUTHENTICATED state.
|
||||
func resetReconnectAttempts() {
|
||||
reconnectAttempts = 0
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func send(_ data: Data, onFailure: ((Error?) -> Void)? = nil) -> Bool {
|
||||
guard isConnected, let task = webSocketTask else {
|
||||
@@ -151,7 +156,8 @@ final class WebSocketClient: NSObject, @unchecked Sendable, URLSessionWebSocketD
|
||||
disconnectHandledForCurrentSocket = false
|
||||
reconnectTask?.cancel()
|
||||
reconnectTask = nil
|
||||
reconnectAttempts = 0
|
||||
// Android parity: backoff reset moved to AUTHENTICATED (ProtocolManager).
|
||||
// Do NOT reset here — handshake may still fail.
|
||||
onConnected?()
|
||||
}
|
||||
|
||||
@@ -202,27 +208,22 @@ final class WebSocketClient: NSObject, @unchecked Sendable, URLSessionWebSocketD
|
||||
guard !isManuallyClosed else { return }
|
||||
|
||||
guard reconnectTask == nil else { return }
|
||||
// First attempt: reconnect immediately (0ms delay) for fastest recovery.
|
||||
// Subsequent attempts: exponential backoff — 1s, 2s, 4s, 8s, 16s (cap).
|
||||
// Android parity: exponential backoff — 1s, 2s, 4s, 8s, 16s, 30s (cap).
|
||||
// No instant first attempt. Formula: min(1000 * 2^(n-1), 30000).
|
||||
reconnectAttempts += 1
|
||||
if reconnectAttempts == 1 {
|
||||
// Immediate retry — no delay on first attempt.
|
||||
Self.logger.info("Reconnecting immediately (attempt #1)...")
|
||||
reconnectTask = Task { [weak self] in
|
||||
guard let self, !isManuallyClosed, !Task.isCancelled else { return }
|
||||
self.reconnectTask = nil
|
||||
self.connect()
|
||||
}
|
||||
} else {
|
||||
let exponent = min(reconnectAttempts - 2, 4)
|
||||
let delayMs = min(1000 * (1 << exponent), 16000)
|
||||
reconnectTask = Task { [weak self] in
|
||||
Self.logger.info("Reconnecting in \(delayMs)ms (attempt #\(self?.reconnectAttempts ?? 0))...")
|
||||
try? await Task.sleep(nanoseconds: UInt64(delayMs) * 1_000_000)
|
||||
guard let self, !isManuallyClosed, !Task.isCancelled else { return }
|
||||
self.reconnectTask = nil
|
||||
self.connect()
|
||||
}
|
||||
|
||||
if reconnectAttempts > 20 {
|
||||
Self.logger.warning("⚠️ Too many reconnect attempts (\(self.reconnectAttempts)), may be stuck in loop")
|
||||
}
|
||||
|
||||
let exponent = min(reconnectAttempts - 1, 4)
|
||||
let delayMs = min(1000 * (1 << exponent), 30000)
|
||||
reconnectTask = Task { [weak self] in
|
||||
Self.logger.info("Reconnecting in \(delayMs)ms (attempt #\(self?.reconnectAttempts ?? 0))...")
|
||||
try? await Task.sleep(nanoseconds: UInt64(delayMs) * 1_000_000)
|
||||
guard let self, !isManuallyClosed, !Task.isCancelled else { return }
|
||||
self.reconnectTask = nil
|
||||
self.connect()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,6 +42,8 @@ final class SessionManager {
|
||||
// MARK: - Foreground Detection (Android parity)
|
||||
|
||||
private var foregroundObserverToken: NSObjectProtocol?
|
||||
/// Android parity: 5s debounce between foreground sync requests.
|
||||
private var lastForegroundSyncTime: TimeInterval = 0
|
||||
|
||||
/// Whether the app is in the foreground.
|
||||
private var isAppInForeground: Bool {
|
||||
@@ -184,6 +186,12 @@ final class SessionManager {
|
||||
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).
|
||||
// Android saves to Room DB synchronously in sendMessage().
|
||||
MessageRepository.shared.persistNow()
|
||||
|
||||
// Saved Messages: local-only, no server send
|
||||
if toPublicKey == currentPublicKey {
|
||||
MessageRepository.shared.updateDeliveryStatus(messageId: messageId, status: .delivered)
|
||||
@@ -319,6 +327,7 @@ final class SessionManager {
|
||||
|
||||
ProtocolManager.shared.sendPacket(packet)
|
||||
registerOutgoingRetry(for: packet)
|
||||
MessageRepository.shared.persistNow()
|
||||
Self.logger.info("📤 Avatar sent to \(toPublicKey.prefix(12))… tag=\(tag)")
|
||||
}
|
||||
|
||||
@@ -348,10 +357,11 @@ final class SessionManager {
|
||||
let messageId = UUID().uuidString.replacingOccurrences(of: "-", with: "").lowercased()
|
||||
let timestamp = Int64(Date().timeIntervalSince1970 * 1000)
|
||||
|
||||
// Encrypt message text (use single space if empty — desktop parity)
|
||||
let messageText = text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? " " : text
|
||||
// Android parity: no caption → encrypt empty string "".
|
||||
// Receivers decrypt "" → show "Photo"/"File" in chat list.
|
||||
let messageText = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let encrypted = try MessageCrypto.encryptOutgoing(
|
||||
plaintext: messageText,
|
||||
plaintext: messageText.isEmpty ? "" : messageText,
|
||||
recipientPublicKeyHex: toPublicKey
|
||||
)
|
||||
|
||||
@@ -420,6 +430,15 @@ final class SessionManager {
|
||||
))
|
||||
}
|
||||
|
||||
// Android parity: cache original images BEFORE upload so they display
|
||||
// instantly in the chat bubble. Without this, photo doesn't appear until
|
||||
// upload completes (can take seconds on slow connection).
|
||||
for item in encryptedAttachments {
|
||||
if item.original.type == .image, let image = UIImage(data: item.original.data) {
|
||||
AttachmentCache.shared.saveImage(image, forAttachmentId: item.original.id)
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 2: Upload all attachments concurrently (Android parity: backgroundUploadScope).
|
||||
let messageAttachments: [MessageAttachment] = try await withThrowingTaskGroup(
|
||||
of: (Int, String).self
|
||||
@@ -515,8 +534,8 @@ final class SessionManager {
|
||||
let isConnected = ProtocolManager.shared.connectionState == .authenticated
|
||||
let offlineAsSend = !isConnected
|
||||
|
||||
// For outgoing messages, store attachment password so we can view our own sent images
|
||||
let displayText = messageText == " " ? " " : messageText
|
||||
// messageText is already trimmed — "" for no caption, triggers "Photo"/"File" in updateFromMessage.
|
||||
let displayText = messageText
|
||||
DialogRepository.shared.updateFromMessage(
|
||||
packet, myPublicKey: currentPublicKey, decryptedText: displayText, fromSync: offlineAsSend
|
||||
)
|
||||
@@ -546,6 +565,7 @@ final class SessionManager {
|
||||
|
||||
ProtocolManager.shared.sendPacket(packet)
|
||||
registerOutgoingRetry(for: packet)
|
||||
MessageRepository.shared.persistNow()
|
||||
Self.logger.info("📤 Message with \(attachments.count) attachment(s) sent to \(toPublicKey.prefix(12))…")
|
||||
}
|
||||
|
||||
@@ -686,7 +706,7 @@ final class SessionManager {
|
||||
// Optimistic UI update — use localPacket (decrypted blob) for storage
|
||||
let isConnected = ProtocolManager.shared.connectionState == .authenticated
|
||||
let offlineAsSend = !isConnected
|
||||
let displayText = messageText == " " ? " " : messageText
|
||||
let displayText = messageText == " " ? "" : messageText
|
||||
DialogRepository.shared.updateFromMessage(
|
||||
localPacket, myPublicKey: currentPublicKey, decryptedText: displayText, fromSync: offlineAsSend
|
||||
)
|
||||
@@ -716,6 +736,7 @@ final class SessionManager {
|
||||
|
||||
ProtocolManager.shared.sendPacket(packet)
|
||||
registerOutgoingRetry(for: packet)
|
||||
MessageRepository.shared.persistNow()
|
||||
Self.logger.info("📤 Reply message sent to \(toPublicKey.prefix(12))… with \(replyMessages.count) quoted message(s)")
|
||||
}
|
||||
|
||||
@@ -1753,28 +1774,29 @@ final class SessionManager {
|
||||
let attachCount = max(1, Int64(packet.attachments.count))
|
||||
let timeoutMs = self.maxOutgoingWaitingLifetimeMs * attachCount
|
||||
if ageMs >= timeoutMs {
|
||||
// Server didn't send 0x08, but we were authenticated and packets
|
||||
// were sent successfully. Most likely cause: server deduplicates
|
||||
// by messageId (original was delivered before disconnect, ACK lost).
|
||||
// Mark as DELIVERED (optimistic) rather than ERROR.
|
||||
Self.logger.info("Message \(messageId) — no ACK after \(ageMs)ms, marking as delivered (optimistic)")
|
||||
self.markOutgoingAsDelivered(messageId: messageId, packet: packet)
|
||||
// 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
|
||||
// hides the problem from the user — they think it was sent but it wasn't.
|
||||
Self.logger.warning("Message \(messageId) — no ACK after \(ageMs)ms, marking as ERROR")
|
||||
self.markOutgoingAsError(messageId: messageId, packet: packet)
|
||||
return
|
||||
}
|
||||
|
||||
guard attempts < self.maxOutgoingRetryAttempts else {
|
||||
// Max retries exhausted while connected — same reasoning:
|
||||
// packets were sent, no error from server, likely delivered.
|
||||
Self.logger.info("Message \(messageId) — no ACK after \(attempts) retries, marking as delivered (optimistic)")
|
||||
self.markOutgoingAsDelivered(messageId: messageId, packet: packet)
|
||||
// Android parity: mark as ERROR after max retries.
|
||||
// User can manually retry via error menu.
|
||||
Self.logger.warning("Message \(messageId) — no ACK after \(attempts) retries, marking as ERROR")
|
||||
self.markOutgoingAsError(messageId: messageId, packet: packet)
|
||||
return
|
||||
}
|
||||
|
||||
guard ProtocolManager.shared.connectionState == .authenticated else {
|
||||
// Not authenticated — don't endlessly loop. The message will be
|
||||
// retried via retryWaitingOutgoingMessagesAfterReconnect() on next handshake.
|
||||
Self.logger.debug("Message \(messageId) retry deferred — not authenticated")
|
||||
self.resolveOutgoingRetry(messageId: messageId)
|
||||
// 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")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1894,7 +1916,10 @@ final class SessionManager {
|
||||
// Android parity: version + text hash — re-sends if text changed within same version.
|
||||
let noticeText = ReleaseNotes.releaseNoticeText
|
||||
guard !noticeText.isEmpty else { return }
|
||||
let currentKey = "\(ReleaseNotes.appVersion)_\(noticeText.hashValue)"
|
||||
// Use stable hash — Swift's hashValue is randomized per launch (ASLR seed).
|
||||
// Android uses Java's hashCode() which is deterministic.
|
||||
let stableHash = noticeText.utf8.reduce(0) { ($0 &* 31) &+ Int($1) }
|
||||
let currentKey = "\(ReleaseNotes.appVersion)_\(stableHash)"
|
||||
|
||||
guard lastKey != currentKey else { return }
|
||||
|
||||
@@ -1908,20 +1933,24 @@ final class SessionManager {
|
||||
packet.timestamp = now
|
||||
packet.messageId = messageId
|
||||
|
||||
// Insert message into MessageRepository (delivered immediately).
|
||||
// Insert message into MessageRepository (delivered, already read).
|
||||
MessageRepository.shared.upsertFromMessagePacket(
|
||||
packet,
|
||||
myPublicKey: publicKey,
|
||||
decryptedText: noticeText
|
||||
decryptedText: noticeText,
|
||||
fromSync: true // fromSync = true → message marked as delivered + read
|
||||
)
|
||||
|
||||
// Create/update dialog in DialogRepository.
|
||||
// isNewMessage: false — don't increment unread count for release notes.
|
||||
DialogRepository.shared.updateFromMessage(
|
||||
packet,
|
||||
myPublicKey: publicKey,
|
||||
decryptedText: noticeText,
|
||||
isNewMessage: true
|
||||
isNewMessage: false
|
||||
)
|
||||
// Force-clear unread for system Updates account — release notes are always "read".
|
||||
DialogRepository.shared.markAsRead(opponentKey: Account.updatesPublicKey)
|
||||
|
||||
// Set system account display info (title, username, verified badge).
|
||||
// Desktop parity: both system accounts use verified=1 (blue rosette badge).
|
||||
@@ -1939,7 +1968,7 @@ final class SessionManager {
|
||||
// MARK: - Foreground Observer (Android parity)
|
||||
|
||||
private func setupForegroundObserver() {
|
||||
// Android parity: ON_RESUME → markVisibleMessagesAsRead() + reconnect.
|
||||
// Android parity: ON_RESUME → markVisibleMessagesAsRead() + reconnect + sync.
|
||||
foregroundObserverToken = NotificationCenter.default.addObserver(
|
||||
forName: UIApplication.willEnterForegroundNotification,
|
||||
object: nil,
|
||||
@@ -1948,9 +1977,24 @@ final class SessionManager {
|
||||
Task { @MainActor [weak self] in
|
||||
// Android: ON_RESUME calls markVisibleMessagesAsRead() for active dialog.
|
||||
self?.markActiveDialogsAsRead()
|
||||
// Always verify connection on foreground — don't trust cached state.
|
||||
// Android parity: reconnectNowIfNeeded() — only reconnects if disconnected.
|
||||
ProtocolManager.shared.reconnectIfNeeded()
|
||||
// Android parity: syncOnForeground() — sync if already authenticated.
|
||||
self?.syncOnForeground()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Android parity: `syncOnForeground()` — request sync on foreground resume
|
||||
/// if already authenticated and 5s have passed since last foreground sync.
|
||||
private func syncOnForeground() {
|
||||
guard ProtocolManager.shared.connectionState == .authenticated else { return }
|
||||
guard !syncBatchInProgress else { return }
|
||||
guard !syncRequestInFlight else { return }
|
||||
let now = Date().timeIntervalSince1970
|
||||
guard now - lastForegroundSyncTime >= 5 else { return }
|
||||
lastForegroundSyncTime = now
|
||||
Self.logger.info("🔄 Sync on foreground resume")
|
||||
requestSynchronize()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,17 +11,20 @@ enum ReleaseNotes {
|
||||
Entry(
|
||||
version: appVersion,
|
||||
body: """
|
||||
**Доставка сообщений**
|
||||
Сообщения больше не теряются при потере сети. Автоматический повтор отправки при восстановлении соединения. Отправленные фото отображаются мгновенно.
|
||||
|
||||
**Уведомления**
|
||||
Вибрация и бейдж работают когда приложение закрыто. Счётчик непрочитанных обновляется в фоне.
|
||||
|
||||
**Фото и файлы**
|
||||
Фото скачиваются только по тапу — блюр-превью со стрелкой загрузки. Пересланные фото подтягиваются из кэша автоматически. Файлы открываются по тапу — скачивание и «Поделиться». Меню вложений (скрепка) работает на iOS 26+.
|
||||
Фото скачиваются только по тапу. Пересланные фото загружаются из кэша. Файлы открываются по тапу. Свайп между фото в полноэкранном просмотре. Плавное закрытие свайпом вниз.
|
||||
|
||||
**Оптимизация производительности**
|
||||
Улучшен FPS скролла и клавиатуры в длинных переписках.
|
||||
**Производительность**
|
||||
Оптимизация FPS клавиатуры и скролла в длинных переписках. Снижен нагрев устройства.
|
||||
|
||||
**Исправления**
|
||||
Убрана рамка у сообщений с аватаром. Saved Messages: иконка закладки вместо аватара. Read receipts: паритет с Android.
|
||||
Исправлены нерабочие кнопки на iOS 26+. Увеличен отступ строки ввода. Исправлен счётчик непрочитанных после синхронизации. Saved Messages: иконка закладки вместо аватара.
|
||||
"""
|
||||
)
|
||||
]
|
||||
|
||||
@@ -19,15 +19,11 @@ struct GlassBackButton: View {
|
||||
.clipShape(Circle())
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var glassCircle: some View {
|
||||
if #available(iOS 26, *) {
|
||||
Circle()
|
||||
.fill(Color.white.opacity(0.08))
|
||||
.glassEffect(.regular, in: .circle)
|
||||
} else {
|
||||
TelegramGlassCircle()
|
||||
}
|
||||
// Use TelegramGlassCircle for all versions — it supports iOS 26 natively
|
||||
// (UIGlassEffect) and has isUserInteractionEnabled = false, guaranteeing
|
||||
// touches pass through. SwiftUI .glassEffect() intercepts taps.
|
||||
TelegramGlassCircle()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,27 +37,15 @@ struct RosettaPrimaryButtonStyle: ButtonStyle {
|
||||
}
|
||||
|
||||
func makeBody(configuration: Configuration) -> some View {
|
||||
Group {
|
||||
if #available(iOS 26, *) {
|
||||
configuration.label
|
||||
.background {
|
||||
Capsule().fill(fillColor.opacity(configuration.isPressed ? 0.7 : 1.0))
|
||||
}
|
||||
.glassEffect(.regular, in: Capsule())
|
||||
} else {
|
||||
configuration.label
|
||||
.background { glassBackground(isPressed: configuration.isPressed) }
|
||||
.clipShape(Capsule())
|
||||
configuration.label
|
||||
.background {
|
||||
Capsule()
|
||||
.fill(fillColor.opacity(configuration.isPressed ? 0.7 : 1.0))
|
||||
}
|
||||
}
|
||||
.scaleEffect(configuration.isPressed && isEnabled ? 0.97 : 1.0)
|
||||
.animation(.easeOut(duration: 0.15), value: configuration.isPressed)
|
||||
.allowsHitTesting(isEnabled)
|
||||
}
|
||||
|
||||
private func glassBackground(isPressed: Bool) -> some View {
|
||||
Capsule()
|
||||
.fill(fillColor.opacity(isPressed ? 0.7 : 1.0))
|
||||
.clipShape(Capsule())
|
||||
.scaleEffect(configuration.isPressed && isEnabled ? 0.97 : 1.0)
|
||||
.animation(.easeOut(duration: 0.15), value: configuration.isPressed)
|
||||
.allowsHitTesting(isEnabled)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -85,9 +69,10 @@ private struct SettingsHighlightModifier: ViewModifier {
|
||||
.background(shape.fill(isPressed ? Color.white.opacity(0.08) : Color.clear))
|
||||
.clipShape(shape)
|
||||
.simultaneousGesture(
|
||||
DragGesture(minimumDistance: 0)
|
||||
DragGesture(minimumDistance: 5)
|
||||
.updating($isPressed) { value, state, _ in
|
||||
state = abs(value.translation.height) < 10
|
||||
&& abs(value.translation.width) < 10
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -36,14 +36,9 @@ struct GlassCard<Content: View>: View {
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
if #available(iOS 26, *) {
|
||||
content()
|
||||
.glassEffect(.regular, in: RoundedRectangle(cornerRadius: cornerRadius))
|
||||
} else {
|
||||
content()
|
||||
.background {
|
||||
TelegramGlassRoundedRect(cornerRadius: cornerRadius)
|
||||
}
|
||||
}
|
||||
content()
|
||||
.background {
|
||||
TelegramGlassRoundedRect(cornerRadius: cornerRadius)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,27 +2,20 @@ import SwiftUI
|
||||
|
||||
// MARK: - Glass Modifier
|
||||
//
|
||||
// iOS 26+: native .glassEffect API
|
||||
// iOS < 26: Telegram-style glass (CABackdropLayer + gaussianBlur)
|
||||
// Uses TelegramGlass* UIViewRepresentable for ALL iOS versions.
|
||||
// TelegramGlassUIView supports iOS 26 natively (UIGlassEffect) and has
|
||||
// isUserInteractionEnabled = false, guaranteeing touches pass through.
|
||||
// SwiftUI .glassEffect() modifier creates UIKit containers that intercept
|
||||
// taps even with .allowsHitTesting(false).
|
||||
|
||||
struct GlassModifier: ViewModifier {
|
||||
let cornerRadius: CGFloat
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
let shape = RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)
|
||||
|
||||
if #available(iOS 26, *) {
|
||||
content
|
||||
.background {
|
||||
shape.fill(.clear)
|
||||
.glassEffect(.regular, in: .rect(cornerRadius: cornerRadius))
|
||||
}
|
||||
} else {
|
||||
content
|
||||
.background {
|
||||
TelegramGlassRoundedRect(cornerRadius: cornerRadius)
|
||||
}
|
||||
}
|
||||
content
|
||||
.background {
|
||||
TelegramGlassRoundedRect(cornerRadius: cornerRadius)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,32 +28,16 @@ extension View {
|
||||
}
|
||||
|
||||
/// Glass capsule — convenience for pill-shaped elements.
|
||||
@ViewBuilder
|
||||
func glassCapsule() -> some View {
|
||||
if #available(iOS 26, *) {
|
||||
background {
|
||||
Capsule().fill(.clear)
|
||||
.glassEffect(.regular, in: .capsule)
|
||||
}
|
||||
} else {
|
||||
background {
|
||||
TelegramGlassCapsule()
|
||||
}
|
||||
background {
|
||||
TelegramGlassCapsule()
|
||||
}
|
||||
}
|
||||
|
||||
/// Glass circle — convenience for circular buttons.
|
||||
@ViewBuilder
|
||||
func glassCircle() -> some View {
|
||||
if #available(iOS 26, *) {
|
||||
background {
|
||||
Circle().fill(.clear)
|
||||
.glassEffect(.regular, in: .circle)
|
||||
}
|
||||
} else {
|
||||
background {
|
||||
TelegramGlassCircle()
|
||||
}
|
||||
background {
|
||||
TelegramGlassCircle()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -133,20 +133,9 @@ struct RosettaTabBar: View {
|
||||
selectionIndicator
|
||||
}
|
||||
.background {
|
||||
if #available(iOS 26.0, *) {
|
||||
// iOS 26+ — native liquid glass
|
||||
Capsule()
|
||||
.fill(.clear)
|
||||
.glassEffect(.regular, in: .capsule)
|
||||
} else {
|
||||
// iOS < 26 — frosted glass material (Telegram-style)
|
||||
Capsule()
|
||||
.fill(.regularMaterial)
|
||||
.overlay(
|
||||
Capsule()
|
||||
.strokeBorder(TabBarColors.pillBorder, lineWidth: 0.5)
|
||||
)
|
||||
}
|
||||
// TelegramGlassCapsule handles both iOS 26+ (UIGlassEffect)
|
||||
// and iOS < 26 (CABackdropLayer), with isUserInteractionEnabled = false.
|
||||
TelegramGlassCapsule()
|
||||
}
|
||||
.contentShape(Capsule())
|
||||
.gesture(dragGesture)
|
||||
@@ -156,6 +145,13 @@ struct RosettaTabBar: View {
|
||||
.padding(.bottom, 12)
|
||||
.onAppear { Task { @MainActor in refreshBadges() } }
|
||||
.onChange(of: selectedTab) { _, _ in Task { @MainActor in refreshBadges() } }
|
||||
// Observation-isolated badge refresh: checks dialogsVersion on every
|
||||
// DialogRepository mutation. Only calls refreshBadges() when version changes.
|
||||
.overlay {
|
||||
BadgeVersionObserver(onVersionChanged: refreshBadges)
|
||||
.frame(width: 0, height: 0)
|
||||
.allowsHitTesting(false)
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads DialogRepository outside the body's observation scope.
|
||||
@@ -185,6 +181,7 @@ struct RosettaTabBar: View {
|
||||
// iOS 26+ — native liquid glass
|
||||
Capsule().fill(.clear)
|
||||
.glassEffect(.regular, in: .capsule)
|
||||
.allowsHitTesting(false)
|
||||
.frame(width: width)
|
||||
.offset(x: xOffset)
|
||||
} else {
|
||||
@@ -353,6 +350,40 @@ private extension Comparable {
|
||||
|
||||
// MARK: - Preview
|
||||
|
||||
// MARK: - Badge Version Observer (observation-isolated)
|
||||
|
||||
/// Observes DialogRepository in its own scope, debounced badge refresh.
|
||||
/// Body re-evaluates on any dialog mutation (observation tracking via `dialogs` read).
|
||||
/// Debounces at 500ms to avoid CPU spikes from rapid mutations.
|
||||
private struct BadgeVersionObserver: View {
|
||||
var onVersionChanged: () -> Void
|
||||
@State private var refreshTask: Task<Void, Never>?
|
||||
@State private var lastUnread: Int = -1
|
||||
|
||||
var body: some View {
|
||||
// O(n) reduce — but only on THIS view's body (isolated scope).
|
||||
// Not called during RosettaTabBar drag/animation.
|
||||
let unread = DialogRepository.shared.dialogs.values
|
||||
.reduce(0) { $0 + ($1.isMuted ? 0 : $1.unreadCount) }
|
||||
Color.clear
|
||||
.onChange(of: unread) { _, newValue in
|
||||
guard newValue != lastUnread else { return }
|
||||
lastUnread = newValue
|
||||
// Debounce to avoid rapid refreshes during sync
|
||||
refreshTask?.cancel()
|
||||
refreshTask = Task { @MainActor in
|
||||
try? await Task.sleep(for: .milliseconds(200))
|
||||
guard !Task.isCancelled else { return }
|
||||
onVersionChanged()
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
lastUnread = unread
|
||||
onVersionChanged()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
ZStack(alignment: .bottom) {
|
||||
Color.black.ignoresSafeArea()
|
||||
|
||||
@@ -107,6 +107,9 @@ final class TelegramGlassUIView: UIView {
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
clipsToBounds = false
|
||||
// CRITICAL: disable user interaction so glass background never intercepts
|
||||
// touches from SwiftUI Buttons that use this view as .background.
|
||||
isUserInteractionEnabled = false
|
||||
|
||||
if #available(iOS 26.0, *) {
|
||||
setupNativeGlass()
|
||||
@@ -129,6 +132,7 @@ final class TelegramGlassUIView: UIView {
|
||||
effect.tintColor = UIColor(white: 1.0, alpha: 0.025)
|
||||
let glassView = UIVisualEffectView(effect: effect)
|
||||
glassView.layer.cornerCurve = .continuous
|
||||
glassView.isUserInteractionEnabled = false
|
||||
addSubview(glassView)
|
||||
nativeGlassView = glassView
|
||||
}
|
||||
|
||||
@@ -187,15 +187,8 @@ struct AttachmentPanelView: View {
|
||||
}
|
||||
|
||||
/// Glass circle background matching GlassBackButton (ButtonStyles.swift lines 22–34).
|
||||
@ViewBuilder
|
||||
private var closeButtonGlass: some View {
|
||||
if #available(iOS 26, *) {
|
||||
Circle()
|
||||
.fill(Color.white.opacity(0.08))
|
||||
.glassEffect(.regular, in: .circle)
|
||||
} else {
|
||||
TelegramGlassCircle()
|
||||
}
|
||||
TelegramGlassCircle()
|
||||
}
|
||||
|
||||
/// Title text changes based on selected tab.
|
||||
@@ -318,15 +311,8 @@ struct AttachmentPanelView: View {
|
||||
}
|
||||
|
||||
/// Glass background matching ChatDetailView's composer (`.thinMaterial` + stroke + shadow).
|
||||
@ViewBuilder
|
||||
private var captionBarBackground: some View {
|
||||
if #available(iOS 26, *) {
|
||||
RoundedRectangle(cornerRadius: 21, style: .continuous)
|
||||
.fill(.clear)
|
||||
.glassEffect(.regular, in: RoundedRectangle(cornerRadius: 21, style: .continuous))
|
||||
} else {
|
||||
TelegramGlassRoundedRect(cornerRadius: 21)
|
||||
}
|
||||
TelegramGlassRoundedRect(cornerRadius: 21)
|
||||
}
|
||||
|
||||
// MARK: - Tab Bar (Figma: glass capsule, 3 tabs)
|
||||
@@ -349,22 +335,8 @@ struct AttachmentPanelView: View {
|
||||
}
|
||||
|
||||
/// Glass background matching RosettaTabBar (lines 136–149).
|
||||
@ViewBuilder
|
||||
private var tabBarBackground: some View {
|
||||
if #available(iOS 26, *) {
|
||||
// iOS 26+ — native liquid glass
|
||||
Capsule()
|
||||
.fill(.clear)
|
||||
.glassEffect(.regular, in: .capsule)
|
||||
} else {
|
||||
// iOS < 26 — matches RosettaTabBar: .regularMaterial + border
|
||||
Capsule()
|
||||
.fill(.regularMaterial)
|
||||
.overlay(
|
||||
Capsule()
|
||||
.strokeBorder(Color.white.opacity(0.08), lineWidth: 0.5)
|
||||
)
|
||||
}
|
||||
TelegramGlassCapsule()
|
||||
}
|
||||
|
||||
/// Individual tab button matching RosettaTabBar dimensions exactly.
|
||||
@@ -402,16 +374,7 @@ struct AttachmentPanelView: View {
|
||||
.padding(.vertical, 6)
|
||||
.background {
|
||||
if isSelected {
|
||||
if #available(iOS 26, *) {
|
||||
Capsule()
|
||||
.fill(.clear)
|
||||
.glassEffect(.regular, in: .capsule)
|
||||
} else {
|
||||
// Matches RosettaTabBar selection indicator: .thinMaterial
|
||||
Capsule()
|
||||
.fill(.thinMaterial)
|
||||
.padding(.vertical, 2)
|
||||
}
|
||||
TelegramGlassCapsule()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -804,6 +804,7 @@ private extension ChatDetailView {
|
||||
)
|
||||
.frame(width: 14, height: 8)
|
||||
.frame(width: 42, height: 42)
|
||||
.contentShape(Circle())
|
||||
.background {
|
||||
glass(shape: .circle, strokeOpacity: 0.18)
|
||||
}
|
||||
@@ -1456,6 +1457,7 @@ private extension ChatDetailView {
|
||||
)
|
||||
.frame(width: 21, height: 24)
|
||||
.frame(width: 42, height: 42)
|
||||
.contentShape(Circle())
|
||||
.background { glass(shape: .circle, strokeOpacity: 0.18) }
|
||||
}
|
||||
.accessibilityLabel("Attach")
|
||||
@@ -1558,8 +1560,8 @@ private extension ChatDetailView {
|
||||
}
|
||||
.padding(.leading, 16)
|
||||
.padding(.trailing, composerTrailingPadding)
|
||||
.padding(.top, 4)
|
||||
.padding(.bottom, 4)
|
||||
.padding(.top, 6)
|
||||
.padding(.bottom, 12)
|
||||
.simultaneousGesture(composerDismissGesture)
|
||||
.animation(composerAnimation, value: canSend)
|
||||
.animation(composerAnimation, value: shouldShowSendButton)
|
||||
@@ -1588,7 +1590,8 @@ private extension ChatDetailView {
|
||||
|
||||
// MARK: - Bubble Position (Figma: Single / Top / Mid / Bottom)
|
||||
|
||||
/// Determines bubble position within a group of consecutive same-sender plain-text messages.
|
||||
/// Determines bubble position within a group of consecutive same-sender messages.
|
||||
/// Telegram parity: photo messages group with text messages from the same sender.
|
||||
func bubblePosition(for index: Int) -> BubblePosition {
|
||||
let hasPrev: Bool = {
|
||||
guard index > 0 else { return false }
|
||||
@@ -1596,7 +1599,7 @@ private extension ChatDetailView {
|
||||
let current = messages[index]
|
||||
let sameSender = current.isFromMe(myPublicKey: currentPublicKey)
|
||||
== prev.isFromMe(myPublicKey: currentPublicKey)
|
||||
return sameSender && prev.attachments.isEmpty && current.attachments.isEmpty
|
||||
return sameSender
|
||||
}()
|
||||
|
||||
let hasNext: Bool = {
|
||||
@@ -1605,7 +1608,7 @@ private extension ChatDetailView {
|
||||
let current = messages[index]
|
||||
let sameSender = current.isFromMe(myPublicKey: currentPublicKey)
|
||||
== next.isFromMe(myPublicKey: currentPublicKey)
|
||||
return sameSender && next.attachments.isEmpty && current.attachments.isEmpty
|
||||
return sameSender
|
||||
}()
|
||||
|
||||
switch (hasPrev, hasNext) {
|
||||
@@ -1637,27 +1640,18 @@ private extension ChatDetailView {
|
||||
strokeOpacity: Double = 0.18,
|
||||
strokeColor: Color = .white
|
||||
) -> some View {
|
||||
if #available(iOS 26.0, *) {
|
||||
switch shape {
|
||||
case .capsule:
|
||||
Capsule().fill(.clear).glassEffect(.regular, in: .capsule)
|
||||
case .circle:
|
||||
Circle().fill(.clear).glassEffect(.regular, in: .circle)
|
||||
case let .rounded(radius):
|
||||
RoundedRectangle(cornerRadius: radius, style: .continuous)
|
||||
.fill(.clear)
|
||||
.glassEffect(.regular, in: RoundedRectangle(cornerRadius: radius, style: .continuous))
|
||||
}
|
||||
} else {
|
||||
// iOS < 26: Telegram glass (CABackdropLayer + blur 2.0 + dark foreground)
|
||||
switch shape {
|
||||
case .capsule:
|
||||
TelegramGlassCapsule()
|
||||
case .circle:
|
||||
TelegramGlassCircle()
|
||||
case let .rounded(radius):
|
||||
TelegramGlassRoundedRect(cornerRadius: radius)
|
||||
}
|
||||
// Use TelegramGlass* UIViewRepresentable for ALL iOS versions.
|
||||
// TelegramGlassUIView already supports iOS 26 natively (UIGlassEffect)
|
||||
// and has isUserInteractionEnabled = false, guaranteeing touches pass
|
||||
// through to parent Buttons. SwiftUI .glassEffect() modifier creates
|
||||
// UIKit containers that intercept taps even with .allowsHitTesting(false).
|
||||
switch shape {
|
||||
case .capsule:
|
||||
TelegramGlassCapsule()
|
||||
case .circle:
|
||||
TelegramGlassCircle()
|
||||
case let .rounded(radius):
|
||||
TelegramGlassRoundedRect(cornerRadius: radius)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -158,15 +158,19 @@ struct ImageGalleryViewer: View {
|
||||
|
||||
@ViewBuilder
|
||||
private var controlsOverlay: some View {
|
||||
if showControls && !isDismissing {
|
||||
VStack(spacing: 0) {
|
||||
VStack(spacing: 0) {
|
||||
// Android parity: slide + fade, 200ms, FastOutSlowInEasing, 24pt slide distance.
|
||||
if showControls && !isDismissing {
|
||||
topBar
|
||||
Spacer()
|
||||
bottomBar
|
||||
.transition(.move(edge: .top).combined(with: .opacity))
|
||||
}
|
||||
Spacer()
|
||||
if showControls && !isDismissing {
|
||||
bottomBar
|
||||
.transition(.move(edge: .bottom).combined(with: .opacity))
|
||||
}
|
||||
.transition(.opacity)
|
||||
.animation(.easeInOut(duration: 0.2), value: showControls)
|
||||
}
|
||||
.animation(.easeOut(duration: 0.2), value: showControls)
|
||||
}
|
||||
|
||||
// MARK: - Top Bar (Android: sender name + date, back arrow)
|
||||
|
||||
@@ -17,19 +17,86 @@ struct ZoomableImagePage: View {
|
||||
let onEdgeTap: ((Int) -> Void)?
|
||||
|
||||
@State private var image: UIImage?
|
||||
/// Vertical drag offset for dismiss gesture (SwiftUI DragGesture).
|
||||
@State private var dismissDragOffset: CGFloat = 0
|
||||
|
||||
@State private var zoomScale: CGFloat = 1.0
|
||||
@State private var zoomOffset: CGSize = .zero
|
||||
@GestureState private var pinchScale: CGFloat = 1.0
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if let image {
|
||||
ZoomableImageUIViewRepresentable(
|
||||
image: image,
|
||||
onDismiss: onDismiss,
|
||||
onDismissProgress: onDismissProgress,
|
||||
onDismissCancel: onDismissCancel,
|
||||
onToggleControls: { showControls.toggle() },
|
||||
onScaleChanged: { scale in currentScale = scale },
|
||||
onEdgeTap: onEdgeTap
|
||||
)
|
||||
let effectiveScale = zoomScale * pinchScale
|
||||
|
||||
Image(uiImage: image)
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.scaleEffect(effectiveScale)
|
||||
.offset(x: effectiveScale > 1.05 ? zoomOffset.width : 0,
|
||||
y: (effectiveScale > 1.05 ? zoomOffset.height : 0) + dismissDragOffset)
|
||||
// Single tap: toggle controls / edge navigation
|
||||
.onTapGesture { location in
|
||||
let width = UIScreen.main.bounds.width
|
||||
let edgeZone = width * 0.20
|
||||
if location.x < edgeZone {
|
||||
onEdgeTap?(-1)
|
||||
} else if location.x > width - edgeZone {
|
||||
onEdgeTap?(1)
|
||||
} else {
|
||||
showControls.toggle()
|
||||
}
|
||||
}
|
||||
// Double tap: zoom to 2.5x or reset
|
||||
.onTapGesture(count: 2) {
|
||||
withAnimation(.spring(response: 0.35, dampingFraction: 0.9)) {
|
||||
if zoomScale > 1.1 {
|
||||
zoomScale = 1.0
|
||||
zoomOffset = .zero
|
||||
} else {
|
||||
zoomScale = 2.5
|
||||
}
|
||||
currentScale = zoomScale
|
||||
}
|
||||
}
|
||||
// Pinch zoom
|
||||
.gesture(
|
||||
MagnifyGesture()
|
||||
.updating($pinchScale) { value, state, _ in
|
||||
state = value.magnification
|
||||
}
|
||||
.onEnded { value in
|
||||
let newScale = zoomScale * value.magnification
|
||||
withAnimation(.spring(response: 0.35, dampingFraction: 0.9)) {
|
||||
zoomScale = min(max(newScale, 1.0), 5.0)
|
||||
if zoomScale <= 1.05 {
|
||||
zoomScale = 1.0
|
||||
zoomOffset = .zero
|
||||
}
|
||||
currentScale = zoomScale
|
||||
}
|
||||
}
|
||||
)
|
||||
// Pan when zoomed
|
||||
.gesture(
|
||||
zoomScale > 1.05 ?
|
||||
DragGesture()
|
||||
.onChanged { value in
|
||||
zoomOffset = value.translation
|
||||
}
|
||||
.onEnded { _ in
|
||||
// Clamp offset
|
||||
}
|
||||
: nil
|
||||
)
|
||||
// Dismiss drag (vertical swipe when not zoomed)
|
||||
// simultaneousGesture so it coexists with TabView's page swipe.
|
||||
// The 2.0× vertical ratio in dismissDragGesture prevents
|
||||
// horizontal swipes from triggering dismiss.
|
||||
.simultaneousGesture(
|
||||
zoomScale <= 1.05 ? dismissDragGesture : nil
|
||||
)
|
||||
.contentShape(Rectangle())
|
||||
} else {
|
||||
placeholder
|
||||
}
|
||||
@@ -39,6 +106,35 @@ struct ZoomableImagePage: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// Vertical drag-to-dismiss gesture.
|
||||
/// Uses minimumDistance:40 to give TabView's page swipe a head start.
|
||||
private var dismissDragGesture: some Gesture {
|
||||
DragGesture(minimumDistance: 40, coordinateSpace: .local)
|
||||
.onChanged { value in
|
||||
let dy = abs(value.translation.height)
|
||||
let dx = abs(value.translation.width)
|
||||
// Only vertical-dominant drags trigger dismiss
|
||||
guard dy > dx * 2.0 else { return }
|
||||
|
||||
dismissDragOffset = value.translation.height
|
||||
let progress = min(abs(dismissDragOffset) / 300, 1.0)
|
||||
onDismissProgress(progress)
|
||||
}
|
||||
.onEnded { value in
|
||||
let velocityY = abs(value.predictedEndTranslation.height - value.translation.height)
|
||||
if abs(dismissDragOffset) > 100 || velocityY > 500 {
|
||||
// Dismiss — keep offset so photo doesn't jump back before fade-out
|
||||
onDismiss()
|
||||
} else {
|
||||
// Snap back
|
||||
withAnimation(.easeOut(duration: 0.25)) {
|
||||
dismissDragOffset = 0
|
||||
}
|
||||
onDismissCancel()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Placeholder
|
||||
|
||||
private var placeholder: some View {
|
||||
@@ -116,6 +212,7 @@ final class ImageGestureContainerView: UIView, UIGestureRecognizerDelegate {
|
||||
// MARK: - Subviews
|
||||
|
||||
private let imageView = UIImageView()
|
||||
private var panGesture: UIPanGestureRecognizer?
|
||||
|
||||
// MARK: - Transform State
|
||||
|
||||
@@ -184,10 +281,10 @@ final class ImageGestureContainerView: UIView, UIGestureRecognizerDelegate {
|
||||
pinch.delegate = self
|
||||
addGestureRecognizer(pinch)
|
||||
|
||||
let pan = UIPanGestureRecognizer(target: self, action: #selector(handlePan))
|
||||
pan.delegate = self
|
||||
pan.maximumNumberOfTouches = 1
|
||||
addGestureRecognizer(pan)
|
||||
// Pan gesture REMOVED — replaced by SwiftUI DragGesture on the wrapper view.
|
||||
// UIKit UIPanGestureRecognizer on UIViewRepresentable intercepts ALL touches
|
||||
// before SwiftUI TabView gets them, preventing page swipe navigation.
|
||||
// SwiftUI DragGesture cooperates with TabView natively.
|
||||
|
||||
let doubleTap = UITapGestureRecognizer(target: self, action: #selector(handleDoubleTap))
|
||||
doubleTap.numberOfTapsRequired = 2
|
||||
@@ -276,62 +373,32 @@ final class ImageGestureContainerView: UIView, UIGestureRecognizerDelegate {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Pan Gesture (Pan when zoomed, Dismiss when not)
|
||||
// MARK: - Pan Gesture (Pan when zoomed ONLY)
|
||||
// Dismiss gesture moved to SwiftUI DragGesture on ZoomableImagePage wrapper
|
||||
// to allow TabView page swipe to work.
|
||||
|
||||
@objc private func handlePan(_ gesture: UIPanGestureRecognizer) {
|
||||
// Only handle pan when zoomed — dismiss is handled by SwiftUI DragGesture
|
||||
guard currentScale > 1.05 else {
|
||||
gesture.state = .cancelled
|
||||
return
|
||||
}
|
||||
|
||||
let translation = gesture.translation(in: self)
|
||||
let velocity = gesture.velocity(in: self)
|
||||
|
||||
switch gesture.state {
|
||||
case .began:
|
||||
panStartOffset = currentOffset
|
||||
gestureAxisLocked = false
|
||||
isDismissGesture = false
|
||||
|
||||
case .changed:
|
||||
if currentScale > 1.05 {
|
||||
// Zoomed: pan the image
|
||||
currentOffset = CGPoint(
|
||||
x: panStartOffset.x + translation.x,
|
||||
y: panStartOffset.y + translation.y
|
||||
)
|
||||
applyTransform()
|
||||
} else {
|
||||
// Not zoomed: detect axis
|
||||
if !gestureAxisLocked {
|
||||
let dx = abs(translation.x)
|
||||
let dy = abs(translation.y)
|
||||
// Android: abs(panChange.y) > abs(panChange.x) * 1.5
|
||||
if dx > touchSlop || dy > touchSlop {
|
||||
gestureAxisLocked = true
|
||||
isDismissGesture = dy > dx * 1.2
|
||||
}
|
||||
}
|
||||
|
||||
if isDismissGesture {
|
||||
dismissOffset = translation.y
|
||||
let progress = min(abs(dismissOffset) / 300, 1.0)
|
||||
onDismissProgress?(progress)
|
||||
applyTransform()
|
||||
}
|
||||
}
|
||||
currentOffset = CGPoint(
|
||||
x: panStartOffset.x + translation.x,
|
||||
y: panStartOffset.y + translation.y
|
||||
)
|
||||
applyTransform()
|
||||
|
||||
case .ended, .cancelled:
|
||||
if currentScale > 1.05 {
|
||||
clampOffset(animated: true)
|
||||
} else if isDismissGesture {
|
||||
let velocityY = abs(velocity.y)
|
||||
if abs(dismissOffset) > dismissDistanceThreshold || velocityY > dismissVelocityThreshold {
|
||||
// Dismiss with fade-out (Android: smoothDismiss 200ms fade)
|
||||
onDismiss?()
|
||||
} else {
|
||||
// Snap back
|
||||
dismissOffset = 0
|
||||
onDismissCancel?()
|
||||
applyTransform(animated: true)
|
||||
}
|
||||
}
|
||||
isDismissGesture = false
|
||||
clampOffset(animated: true)
|
||||
gestureAxisLocked = false
|
||||
|
||||
default: break
|
||||
@@ -429,13 +496,9 @@ final class ImageGestureContainerView: UIView, UIGestureRecognizerDelegate {
|
||||
}
|
||||
|
||||
override func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
|
||||
guard let pan = gestureRecognizer as? UIPanGestureRecognizer else { return true }
|
||||
let velocity = pan.velocity(in: self)
|
||||
|
||||
if currentScale <= 1.05 {
|
||||
// Not zoomed: only begin for vertical-dominant drags.
|
||||
// Let horizontal swipes pass through to TabView for paging.
|
||||
return abs(velocity.y) > abs(velocity.x) * 1.2
|
||||
if gestureRecognizer is UIPanGestureRecognizer {
|
||||
// Pan only when zoomed — dismiss handled by SwiftUI DragGesture
|
||||
return currentScale > 1.05
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -377,6 +377,9 @@ private struct ToolbarTitleView: View {
|
||||
.foregroundStyle(RosettaColors.Adaptive.text)
|
||||
.contentTransition(.numericText())
|
||||
.animation(.easeInOut(duration: 0.25), value: state)
|
||||
.onTapGesture {
|
||||
NotificationCenter.default.post(name: .chatListScrollToTop, object: nil)
|
||||
}
|
||||
} else {
|
||||
ToolbarStatusLabel(title: "Connecting...")
|
||||
}
|
||||
@@ -589,7 +592,10 @@ private struct ChatListDialogContent: View {
|
||||
|
||||
// MARK: - Dialog List
|
||||
|
||||
private static let topAnchorId = "chatlist_top"
|
||||
|
||||
private func dialogList(pinned: [Dialog], unpinned: [Dialog], requestsCount: Int) -> some View {
|
||||
ScrollViewReader { scrollProxy in
|
||||
List {
|
||||
if viewModel.isLoading {
|
||||
ForEach(0..<8, id: \.self) { _ in
|
||||
@@ -630,6 +636,17 @@ private struct ChatListDialogContent: View {
|
||||
.scrollContentBackground(.hidden)
|
||||
.scrollDismissesKeyboard(.immediately)
|
||||
.scrollIndicators(.hidden)
|
||||
// Scroll-to-top: tap "Chats" in toolbar
|
||||
.onReceive(NotificationCenter.default.publisher(for: .chatListScrollToTop)) { _ in
|
||||
// Scroll to first dialog ID (pinned or unpinned)
|
||||
let firstId = pinned.first?.id ?? unpinned.first?.id
|
||||
if let firstId {
|
||||
withAnimation(.easeOut(duration: 0.3)) {
|
||||
scrollProxy.scrollTo(firstId, anchor: .top)
|
||||
}
|
||||
}
|
||||
}
|
||||
} // ScrollViewReader
|
||||
}
|
||||
|
||||
private func chatRow(_ dialog: Dialog, isFirst: Bool = false) -> some View {
|
||||
|
||||
@@ -358,10 +358,6 @@ struct SettingsView: View {
|
||||
themeCard
|
||||
safetyCard
|
||||
|
||||
#if DEBUG
|
||||
debugCard
|
||||
#endif
|
||||
|
||||
rosettaPowerFooter
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
|
||||
@@ -12,7 +12,6 @@ struct UpdatesView: View {
|
||||
versionCard
|
||||
helpText
|
||||
checkButton
|
||||
releaseNotesSection
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.top, 16)
|
||||
|
||||
@@ -344,4 +344,6 @@ extension Notification.Name {
|
||||
/// Posted when user taps an attachment in the bubble overlay — carries attachment ID (String) as `object`.
|
||||
/// MessageImageView / MessageFileView listen and trigger download/share.
|
||||
static let triggerAttachmentDownload = Notification.Name("triggerAttachmentDownload")
|
||||
/// Posted when user taps "Chats" toolbar title — triggers scroll-to-top.
|
||||
static let chatListScrollToTop = Notification.Name("chatListScrollToTop")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user