diff --git a/Rosetta.xcodeproj/project.pbxproj b/Rosetta.xcodeproj/project.pbxproj
index a8e4ce7..0898773 100644
--- a/Rosetta.xcodeproj/project.pbxproj
+++ b/Rosetta.xcodeproj/project.pbxproj
@@ -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 = "";
diff --git a/Rosetta.xcodeproj/xcshareddata/xcschemes/Rosetta.xcscheme b/Rosetta.xcodeproj/xcshareddata/xcschemes/Rosetta.xcscheme
index 7e5501d..104d1d6 100644
--- a/Rosetta.xcodeproj/xcshareddata/xcschemes/Rosetta.xcscheme
+++ b/Rosetta.xcodeproj/xcshareddata/xcschemes/Rosetta.xcscheme
@@ -53,14 +53,14 @@
+ isEnabled = "NO">
+ isEnabled = "NO">
diff --git a/Rosetta/Core/Data/Repositories/MessageRepository.swift b/Rosetta/Core/Data/Repositories/MessageRepository.swift
index 34b1ccc..38f6de0 100644
--- a/Rosetta/Core/Data/Repositories/MessageRepository.swift
+++ b/Rosetta/Core/Data/Repositories/MessageRepository.swift
@@ -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
diff --git a/Rosetta/Core/Network/Protocol/ProtocolManager.swift b/Rosetta/Core/Network/Protocol/ProtocolManager.swift
index c5f7b8a..9958d95 100644
--- a/Rosetta/Core/Network/Protocol/ProtocolManager.swift
+++ b/Rosetta/Core/Network/Protocol/ProtocolManager.swift
@@ -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) {
diff --git a/Rosetta/Core/Network/Protocol/WebSocketClient.swift b/Rosetta/Core/Network/Protocol/WebSocketClient.swift
index dc6677b..5e2ee9c 100644
--- a/Rosetta/Core/Network/Protocol/WebSocketClient.swift
+++ b/Rosetta/Core/Network/Protocol/WebSocketClient.swift
@@ -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()
}
}
}
diff --git a/Rosetta/Core/Services/SessionManager.swift b/Rosetta/Core/Services/SessionManager.swift
index 44a91cb..86644bf 100644
--- a/Rosetta/Core/Services/SessionManager.swift
+++ b/Rosetta/Core/Services/SessionManager.swift
@@ -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()
+ }
}
diff --git a/Rosetta/Core/Utils/ReleaseNotes.swift b/Rosetta/Core/Utils/ReleaseNotes.swift
index 69b80a5..245dcfd 100644
--- a/Rosetta/Core/Utils/ReleaseNotes.swift
+++ b/Rosetta/Core/Utils/ReleaseNotes.swift
@@ -11,17 +11,20 @@ enum ReleaseNotes {
Entry(
version: appVersion,
body: """
+ **Доставка сообщений**
+ Сообщения больше не теряются при потере сети. Автоматический повтор отправки при восстановлении соединения. Отправленные фото отображаются мгновенно.
+
**Уведомления**
Вибрация и бейдж работают когда приложение закрыто. Счётчик непрочитанных обновляется в фоне.
**Фото и файлы**
- Фото скачиваются только по тапу — блюр-превью со стрелкой загрузки. Пересланные фото подтягиваются из кэша автоматически. Файлы открываются по тапу — скачивание и «Поделиться». Меню вложений (скрепка) работает на iOS 26+.
+ Фото скачиваются только по тапу. Пересланные фото загружаются из кэша. Файлы открываются по тапу. Свайп между фото в полноэкранном просмотре. Плавное закрытие свайпом вниз.
- **Оптимизация производительности**
- Улучшен FPS скролла и клавиатуры в длинных переписках.
+ **Производительность**
+ Оптимизация FPS клавиатуры и скролла в длинных переписках. Снижен нагрев устройства.
**Исправления**
- Убрана рамка у сообщений с аватаром. Saved Messages: иконка закладки вместо аватара. Read receipts: паритет с Android.
+ Исправлены нерабочие кнопки на iOS 26+. Увеличен отступ строки ввода. Исправлен счётчик непрочитанных после синхронизации. Saved Messages: иконка закладки вместо аватара.
"""
)
]
diff --git a/Rosetta/DesignSystem/Components/ButtonStyles.swift b/Rosetta/DesignSystem/Components/ButtonStyles.swift
index 6d2af02..002b38a 100644
--- a/Rosetta/DesignSystem/Components/ButtonStyles.swift
+++ b/Rosetta/DesignSystem/Components/ButtonStyles.swift
@@ -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
}
)
}
diff --git a/Rosetta/DesignSystem/Components/GlassCard.swift b/Rosetta/DesignSystem/Components/GlassCard.swift
index bbac1d2..8239c11 100644
--- a/Rosetta/DesignSystem/Components/GlassCard.swift
+++ b/Rosetta/DesignSystem/Components/GlassCard.swift
@@ -36,14 +36,9 @@ struct GlassCard: 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)
+ }
}
}
diff --git a/Rosetta/DesignSystem/Components/GlassModifier.swift b/Rosetta/DesignSystem/Components/GlassModifier.swift
index 9703ea5..c918927 100644
--- a/Rosetta/DesignSystem/Components/GlassModifier.swift
+++ b/Rosetta/DesignSystem/Components/GlassModifier.swift
@@ -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()
}
}
}
diff --git a/Rosetta/DesignSystem/Components/RosettaTabBar.swift b/Rosetta/DesignSystem/Components/RosettaTabBar.swift
index a4d9331..f2963d5 100644
--- a/Rosetta/DesignSystem/Components/RosettaTabBar.swift
+++ b/Rosetta/DesignSystem/Components/RosettaTabBar.swift
@@ -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?
+ @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()
diff --git a/Rosetta/DesignSystem/Components/TelegramGlassView.swift b/Rosetta/DesignSystem/Components/TelegramGlassView.swift
index 98898e8..9f3b566 100644
--- a/Rosetta/DesignSystem/Components/TelegramGlassView.swift
+++ b/Rosetta/DesignSystem/Components/TelegramGlassView.swift
@@ -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
}
diff --git a/Rosetta/Features/Chats/ChatDetail/AttachmentPanelView.swift b/Rosetta/Features/Chats/ChatDetail/AttachmentPanelView.swift
index cb0f868..2d9f901 100644
--- a/Rosetta/Features/Chats/ChatDetail/AttachmentPanelView.swift
+++ b/Rosetta/Features/Chats/ChatDetail/AttachmentPanelView.swift
@@ -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()
}
}
}
diff --git a/Rosetta/Features/Chats/ChatDetail/ChatDetailView.swift b/Rosetta/Features/Chats/ChatDetail/ChatDetailView.swift
index 6b6bfce..731efae 100644
--- a/Rosetta/Features/Chats/ChatDetail/ChatDetailView.swift
+++ b/Rosetta/Features/Chats/ChatDetail/ChatDetailView.swift
@@ -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)
}
}
diff --git a/Rosetta/Features/Chats/ChatDetail/ImageGalleryViewer.swift b/Rosetta/Features/Chats/ChatDetail/ImageGalleryViewer.swift
index efeeaef..25b53af 100644
--- a/Rosetta/Features/Chats/ChatDetail/ImageGalleryViewer.swift
+++ b/Rosetta/Features/Chats/ChatDetail/ImageGalleryViewer.swift
@@ -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)
diff --git a/Rosetta/Features/Chats/ChatDetail/ZoomableImagePage.swift b/Rosetta/Features/Chats/ChatDetail/ZoomableImagePage.swift
index c9d5666..1e251df 100644
--- a/Rosetta/Features/Chats/ChatDetail/ZoomableImagePage.swift
+++ b/Rosetta/Features/Chats/ChatDetail/ZoomableImagePage.swift
@@ -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
}
diff --git a/Rosetta/Features/Chats/ChatList/ChatListView.swift b/Rosetta/Features/Chats/ChatList/ChatListView.swift
index fe61647..1cfd2f9 100644
--- a/Rosetta/Features/Chats/ChatList/ChatListView.swift
+++ b/Rosetta/Features/Chats/ChatList/ChatListView.swift
@@ -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 {
diff --git a/Rosetta/Features/Settings/SettingsView.swift b/Rosetta/Features/Settings/SettingsView.swift
index d1c9c7d..f990410 100644
--- a/Rosetta/Features/Settings/SettingsView.swift
+++ b/Rosetta/Features/Settings/SettingsView.swift
@@ -358,10 +358,6 @@ struct SettingsView: View {
themeCard
safetyCard
- #if DEBUG
- debugCard
- #endif
-
rosettaPowerFooter
}
.padding(.horizontal, 16)
diff --git a/Rosetta/Features/Settings/UpdatesView.swift b/Rosetta/Features/Settings/UpdatesView.swift
index 6347f3e..8126819 100644
--- a/Rosetta/Features/Settings/UpdatesView.swift
+++ b/Rosetta/Features/Settings/UpdatesView.swift
@@ -12,7 +12,6 @@ struct UpdatesView: View {
versionCard
helpText
checkButton
- releaseNotesSection
}
.padding(.horizontal, 16)
.padding(.top, 16)
diff --git a/Rosetta/RosettaApp.swift b/Rosetta/RosettaApp.swift
index 214c8f5..ac6eb5b 100644
--- a/Rosetta/RosettaApp.swift
+++ b/Rosetta/RosettaApp.swift
@@ -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")
}