Доставка сообщений при потере сети, кэш фото при отправке, FPS клавиатуры, свайп фото, badge tab bar, release notes, sync unread fix

This commit is contained in:
2026-03-20 16:51:57 +05:00
parent 44652e0d97
commit e75c6bac12
20 changed files with 427 additions and 323 deletions

View File

@@ -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 = "";

View File

@@ -53,14 +53,14 @@
<CommandLineArguments>
<CommandLineArgument
argument = "-LogForEachSlowPath YES"
isEnabled = "YES">
isEnabled = "NO">
</CommandLineArgument>
</CommandLineArguments>
<EnvironmentVariables>
<EnvironmentVariable
key = "CA_DEBUG_TRANSACTIONS"
value = "1"
isEnabled = "YES">
isEnabled = "NO">
</EnvironmentVariable>
</EnvironmentVariables>
</LaunchAction>

View File

@@ -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

View File

@@ -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) {

View File

@@ -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()
}
}
}

View File

@@ -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()
}
}

View File

@@ -11,17 +11,20 @@ enum ReleaseNotes {
Entry(
version: appVersion,
body: """
**Доставка сообщений**
Сообщения больше не теряются при потере сети. Автоматический повтор отправки при восстановлении соединения. Отправленные фото отображаются мгновенно.
**Уведомления**
Вибрация и бейдж работают когда приложение закрыто. Счётчик непрочитанных обновляется в фоне.
**Фото и файлы**
Фото скачиваются только по тапу — блюр-превью со стрелкой загрузки. Пересланные фото подтягиваются из кэша автоматически. Файлы открываются по тапу — скачивание и «Поделиться». Меню вложений (скрепка) работает на iOS 26+.
Фото скачиваются только по тапу. Пересланные фото загружаются из кэша. Файлы открываются по тапу. Свайп между фото в полноэкранном просмотре. Плавное закрытие свайпом вниз.
**Оптимизация производительности**
Улучшен FPS скролла и клавиатуры в длинных переписках.
**Производительность**
Оптимизация FPS клавиатуры и скролла в длинных переписках. Снижен нагрев устройства.
**Исправления**
Убрана рамка у сообщений с аватаром. Saved Messages: иконка закладки вместо аватара. Read receipts: паритет с Android.
Исправлены нерабочие кнопки на iOS 26+. Увеличен отступ строки ввода. Исправлен счётчик непрочитанных после синхронизации. Saved Messages: иконка закладки вместо аватара.
"""
)
]

View File

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

View File

@@ -36,14 +36,9 @@ struct GlassCard<Content: View>: View {
}
var body: some View {
if #available(iOS 26, *) {
content()
.glassEffect(.regular, in: RoundedRectangle(cornerRadius: cornerRadius))
} else {
content()
.background {
TelegramGlassRoundedRect(cornerRadius: cornerRadius)
}
}
content()
.background {
TelegramGlassRoundedRect(cornerRadius: cornerRadius)
}
}
}

View File

@@ -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()
}
}
}

View File

@@ -133,20 +133,9 @@ struct RosettaTabBar: View {
selectionIndicator
}
.background {
if #available(iOS 26.0, *) {
// iOS 26+ native liquid glass
Capsule()
.fill(.clear)
.glassEffect(.regular, in: .capsule)
} else {
// iOS < 26 frosted glass material (Telegram-style)
Capsule()
.fill(.regularMaterial)
.overlay(
Capsule()
.strokeBorder(TabBarColors.pillBorder, lineWidth: 0.5)
)
}
// TelegramGlassCapsule handles both iOS 26+ (UIGlassEffect)
// and iOS < 26 (CABackdropLayer), with isUserInteractionEnabled = false.
TelegramGlassCapsule()
}
.contentShape(Capsule())
.gesture(dragGesture)
@@ -156,6 +145,13 @@ struct RosettaTabBar: View {
.padding(.bottom, 12)
.onAppear { Task { @MainActor in refreshBadges() } }
.onChange(of: selectedTab) { _, _ in Task { @MainActor in refreshBadges() } }
// Observation-isolated badge refresh: checks dialogsVersion on every
// DialogRepository mutation. Only calls refreshBadges() when version changes.
.overlay {
BadgeVersionObserver(onVersionChanged: refreshBadges)
.frame(width: 0, height: 0)
.allowsHitTesting(false)
}
}
/// Reads DialogRepository outside the body's observation scope.
@@ -185,6 +181,7 @@ struct RosettaTabBar: View {
// iOS 26+ native liquid glass
Capsule().fill(.clear)
.glassEffect(.regular, in: .capsule)
.allowsHitTesting(false)
.frame(width: width)
.offset(x: xOffset)
} else {
@@ -353,6 +350,40 @@ private extension Comparable {
// MARK: - Preview
// MARK: - Badge Version Observer (observation-isolated)
/// Observes DialogRepository in its own scope, debounced badge refresh.
/// Body re-evaluates on any dialog mutation (observation tracking via `dialogs` read).
/// Debounces at 500ms to avoid CPU spikes from rapid mutations.
private struct BadgeVersionObserver: View {
var onVersionChanged: () -> Void
@State private var refreshTask: Task<Void, Never>?
@State private var lastUnread: Int = -1
var body: some View {
// O(n) reduce but only on THIS view's body (isolated scope).
// Not called during RosettaTabBar drag/animation.
let unread = DialogRepository.shared.dialogs.values
.reduce(0) { $0 + ($1.isMuted ? 0 : $1.unreadCount) }
Color.clear
.onChange(of: unread) { _, newValue in
guard newValue != lastUnread else { return }
lastUnread = newValue
// Debounce to avoid rapid refreshes during sync
refreshTask?.cancel()
refreshTask = Task { @MainActor in
try? await Task.sleep(for: .milliseconds(200))
guard !Task.isCancelled else { return }
onVersionChanged()
}
}
.onAppear {
lastUnread = unread
onVersionChanged()
}
}
}
#Preview {
ZStack(alignment: .bottom) {
Color.black.ignoresSafeArea()

View File

@@ -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
}

View File

@@ -187,15 +187,8 @@ struct AttachmentPanelView: View {
}
/// Glass circle background matching GlassBackButton (ButtonStyles.swift lines 2234).
@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 136149).
@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()
}
}
}

View File

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

View File

@@ -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)

View File

@@ -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
}

View File

@@ -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 {

View File

@@ -358,10 +358,6 @@ struct SettingsView: View {
themeCard
safetyCard
#if DEBUG
debugCard
#endif
rosettaPowerFooter
}
.padding(.horizontal, 16)

View File

@@ -12,7 +12,6 @@ struct UpdatesView: View {
versionCard
helpText
checkButton
releaseNotesSection
}
.padding(.horizontal, 16)
.padding(.top, 16)

View File

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