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