diff --git a/Rosetta.xcodeproj/project.pbxproj b/Rosetta.xcodeproj/project.pbxproj index 782141b..2d38208 100644 --- a/Rosetta.xcodeproj/project.pbxproj +++ b/Rosetta.xcodeproj/project.pbxproj @@ -7,20 +7,53 @@ objects = { /* Begin PBXBuildFile section */ + 4C9BDB443750F7003CFB705C /* Foundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 272B862BE4D99E7DD751CC3E /* Foundation.framework */; }; 853F29992F4B63D20092AD05 /* Lottie in Frameworks */ = {isa = PBXBuildFile; productRef = 853F29982F4B63D20092AD05 /* Lottie */; }; 853F29A02F4B63D20092AD05 /* P256K in Frameworks */ = {isa = PBXBuildFile; productRef = 853F29A12F4B63D20092AD05 /* P256K */; }; + D0BD72A9646880B604F1AC3C /* RosettaNotificationService.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = A182B0EDE5C68E7C6F1FB6D1 /* RosettaNotificationService.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; + DA91A59FDC04C2EBE77550F4 /* NotificationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F43A41D5496A62870E307FC /* NotificationService.swift */; }; F1A000012F6F00010092AD05 /* FirebaseAnalyticsWithoutAdIdSupport in Frameworks */ = {isa = PBXBuildFile; productRef = F1A000022F6F00010092AD05 /* FirebaseAnalyticsWithoutAdIdSupport */; }; F1A000032F6F00010092AD05 /* FirebaseMessaging in Frameworks */ = {isa = PBXBuildFile; productRef = F1A000042F6F00010092AD05 /* FirebaseMessaging */; }; F1A000062F6F00010092AD05 /* FirebaseCrashlytics in Frameworks */ = {isa = PBXBuildFile; productRef = F1A000072F6F00010092AD05 /* FirebaseCrashlytics */; }; /* End PBXBuildFile section */ +/* Begin PBXContainerItemProxy section */ + 6AADF4618CA423BB75F12BF1 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 853F295A2F4B50410092AD05 /* Project object */; + proxyType = 1; + remoteGlobalIDString = E47730762E9823BA2D02A197; + remoteInfo = RosettaNotificationService; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + 249D2C5CD23DB96B22202215 /* Embed Foundation Extensions */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 13; + files = ( + D0BD72A9646880B604F1AC3C /* RosettaNotificationService.appex in Embed Foundation Extensions */, + ); + name = "Embed Foundation Extensions"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + /* Begin PBXFileReference section */ + 0F43A41D5496A62870E307FC /* NotificationService.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = NotificationService.swift; sourceTree = ""; }; + 272B862BE4D99E7DD751CC3E /* Foundation.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Foundation.framework; path = Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS18.0.sdk/System/Library/Frameworks/Foundation.framework; sourceTree = DEVELOPER_DIR; }; 853F29622F4B50410092AD05 /* Rosetta.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Rosetta.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 93685A4F330DCD1B63EF121F /* Info.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + A182B0EDE5C68E7C6F1FB6D1 /* RosettaNotificationService.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = RosettaNotificationService.appex; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedRootGroup section */ 853F29642F4B50410092AD05 /* Rosetta */ = { isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + ); path = Rosetta; sourceTree = ""; }; @@ -39,14 +72,32 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + B2C595701A2879A2FD49DDEF /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 4C9BDB443750F7003CFB705C /* Foundation.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + 32A246700D4A2618B3F81039 /* iOS */ = { + isa = PBXGroup; + children = ( + 272B862BE4D99E7DD751CC3E /* Foundation.framework */, + ); + name = iOS; + sourceTree = ""; + }; 853F29592F4B50410092AD05 = { isa = PBXGroup; children = ( 853F29642F4B50410092AD05 /* Rosetta */, 853F29632F4B50410092AD05 /* Products */, + 95676C1A4D239B1FF9E73782 /* Frameworks */, + BA35C0165A0371B32DD8B4C0 /* RosettaNotificationService */, ); sourceTree = ""; }; @@ -54,10 +105,29 @@ isa = PBXGroup; children = ( 853F29622F4B50410092AD05 /* Rosetta.app */, + A182B0EDE5C68E7C6F1FB6D1 /* RosettaNotificationService.appex */, ); name = Products; sourceTree = ""; }; + 95676C1A4D239B1FF9E73782 /* Frameworks */ = { + isa = PBXGroup; + children = ( + 32A246700D4A2618B3F81039 /* iOS */, + ); + name = Frameworks; + sourceTree = ""; + }; + BA35C0165A0371B32DD8B4C0 /* RosettaNotificationService */ = { + isa = PBXGroup; + children = ( + 0F43A41D5496A62870E307FC /* NotificationService.swift */, + 93685A4F330DCD1B63EF121F /* Info.plist */, + ); + name = RosettaNotificationService; + path = RosettaNotificationService; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -68,10 +138,12 @@ 853F295E2F4B50410092AD05 /* Sources */, 853F295F2F4B50410092AD05 /* Frameworks */, 853F29602F4B50410092AD05 /* Resources */, + 249D2C5CD23DB96B22202215 /* Embed Foundation Extensions */, ); buildRules = ( ); dependencies = ( + 3323872B02212359E2291EE8 /* PBXTargetDependency */, ); fileSystemSynchronizedGroups = ( 853F29642F4B50410092AD05 /* Rosetta */, @@ -88,6 +160,23 @@ productReference = 853F29622F4B50410092AD05 /* Rosetta.app */; productType = "com.apple.product-type.application"; }; + E47730762E9823BA2D02A197 /* RosettaNotificationService */ = { + isa = PBXNativeTarget; + buildConfigurationList = B5D2E60ADEB8AE2E8F7615C6 /* Build configuration list for PBXNativeTarget "RosettaNotificationService" */; + buildPhases = ( + A624149985830F8CA8C2E52D /* Sources */, + B2C595701A2879A2FD49DDEF /* Frameworks */, + F9F9B9BDE87DB35631992F35 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = RosettaNotificationService; + productName = RosettaNotificationService; + productReference = A182B0EDE5C68E7C6F1FB6D1 /* RosettaNotificationService.appex */; + productType = "com.apple.product-type.app-extension"; + }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ @@ -101,6 +190,9 @@ 853F29612F4B50410092AD05 = { CreatedOnToolsVersion = 26.2; }; + E47730762E9823BA2D02A197 = { + CreatedOnToolsVersion = 26.2; + }; }; }; buildConfigurationList = 853F295D2F4B50410092AD05 /* Build configuration list for PBXProject "Rosetta" */; @@ -123,6 +215,7 @@ projectRoot = ""; targets = ( 853F29612F4B50410092AD05 /* Rosetta */, + E47730762E9823BA2D02A197 /* RosettaNotificationService */, ); }; /* End PBXProject section */ @@ -135,6 +228,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + F9F9B9BDE87DB35631992F35 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXResourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ @@ -145,9 +245,54 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + A624149985830F8CA8C2E52D /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + DA91A59FDC04C2EBE77550F4 /* NotificationService.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXSourcesBuildPhase section */ +/* Begin PBXTargetDependency section */ + 3323872B02212359E2291EE8 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + name = RosettaNotificationService; + target = E47730762E9823BA2D02A197 /* RosettaNotificationService */; + targetProxy = 6AADF4618CA423BB75F12BF1 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + /* Begin XCBuildConfiguration section */ + 0140D6320A9CF4B5E933E0B1 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = NO; + CLANG_ENABLE_OBJC_WEAK = NO; + CODE_SIGN_ENTITLEMENTS = RosettaNotificationService/RosettaNotificationService.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 19; + DEVELOPMENT_TEAM = QN8Z263QGX; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = RosettaNotificationService/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + MARKETING_VERSION = 1.1.8; + PRODUCT_BUNDLE_IDENTIFIER = com.rosetta.dev.NotificationService; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 1; + }; + name = Debug; + }; 853F296B2F4B50420092AD05 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { @@ -275,7 +420,7 @@ CODE_SIGN_ENTITLEMENTS = Rosetta/Rosetta.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 17; + CURRENT_PROJECT_VERSION = 19; DEVELOPMENT_TEAM = QN8Z263QGX; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; @@ -291,7 +436,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.1.6; + MARKETING_VERSION = 1.1.8; PRODUCT_BUNDLE_IDENTIFIER = com.rosetta.dev; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -314,7 +459,7 @@ CODE_SIGN_ENTITLEMENTS = Rosetta/Rosetta.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 17; + CURRENT_PROJECT_VERSION = 19; DEVELOPMENT_TEAM = QN8Z263QGX; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; @@ -330,7 +475,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.1.6; + MARKETING_VERSION = 1.1.8; PRODUCT_BUNDLE_IDENTIFIER = com.rosetta.dev; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -345,6 +490,35 @@ }; name = Release; }; + 93E51266ED50ED634DDBB900 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = NO; + CLANG_ENABLE_OBJC_WEAK = NO; + CODE_SIGN_ENTITLEMENTS = RosettaNotificationService/RosettaNotificationService.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 19; + DEVELOPMENT_TEAM = QN8Z263QGX; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = RosettaNotificationService/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + MARKETING_VERSION = 1.1.8; + PRODUCT_BUNDLE_IDENTIFIER = com.rosetta.dev.NotificationService; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 1; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ @@ -366,6 +540,15 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + B5D2E60ADEB8AE2E8F7615C6 /* Build configuration list for PBXNativeTarget "RosettaNotificationService" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 93E51266ED50ED634DDBB900 /* Release */, + 0140D6320A9CF4B5E933E0B1 /* Debug */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; /* End XCConfigurationList section */ /* Begin XCRemoteSwiftPackageReference section */ diff --git a/Rosetta.xcodeproj/xcshareddata/xcschemes/Rosetta.xcscheme b/Rosetta.xcodeproj/xcshareddata/xcschemes/Rosetta.xcscheme index f1f4a59..373a139 100644 --- a/Rosetta.xcodeproj/xcshareddata/xcschemes/Rosetta.xcscheme +++ b/Rosetta.xcodeproj/xcshareddata/xcschemes/Rosetta.xcscheme @@ -31,7 +31,7 @@ shouldAutocreateTestPlan = "YES"> orderHint 0 + RosettaNotificationService.xcscheme_^#shared#^_ + + orderHint + 1 + SuppressBuildableAutocreation diff --git a/Rosetta/Core/Data/Repositories/DialogRepository.swift b/Rosetta/Core/Data/Repositories/DialogRepository.swift index 4a60fc4..759d0a1 100644 --- a/Rosetta/Core/Data/Repositories/DialogRepository.swift +++ b/Rosetta/Core/Data/Repositories/DialogRepository.swift @@ -10,8 +10,15 @@ final class DialogRepository { static let shared = DialogRepository() private(set) var dialogs: [String: Dialog] = [:] { - didSet { _sortedDialogsCache = nil } + didSet { + _sortedDialogsCache = nil + dialogsVersion &+= 1 + } } + + /// Monotonic counter incremented on every `dialogs` mutation. + /// Used by ChatListViewModel to avoid redundant partition recomputation. + @ObservationIgnored private(set) var dialogsVersion: Int = 0 private var currentAccount: String = "" private var storagePassword: String = "" private var persistTask: Task? @@ -81,6 +88,7 @@ final class DialogRepository { ) _sortedKeysCache = nil updateAppBadge() + syncMutedKeysToDefaults() } func reset(clearPersisted: Bool = false) { @@ -91,6 +99,7 @@ final class DialogRepository { storagePassword = "" UNUserNotificationCenter.current().setBadgeCount(0) UserDefaults.standard.set(0, forKey: "app_badge_count") + UserDefaults(suiteName: "group.com.rosetta.dev")?.set(0, forKey: "app_badge_count") guard !currentAccount.isEmpty else { return } let accountToReset = currentAccount @@ -146,14 +155,14 @@ final class DialogRepository { ) // Desktop parity: constructLastMessageTextByAttachments() returns - // "Photo"/"Avatar"/"File" for attachment-only messages. + // "Photo"/"Avatar"/"File"/"Forwarded message" for attachment-only messages. if decryptedText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, let firstAttachment = packet.attachments.first { switch firstAttachment.type { case .image: dialog.lastMessage = "Photo" case .file: dialog.lastMessage = "File" case .avatar: dialog.lastMessage = "Avatar" - default: dialog.lastMessage = decryptedText + case .messages: dialog.lastMessage = "Forwarded message" } } else { dialog.lastMessage = decryptedText @@ -165,13 +174,12 @@ final class DialogRepository { if fromMe { dialog.iHaveSent = true } else { - // Only increment unread count when: - // 1. The message is genuinely new (not a dedup hit from sync re-processing) - // 2. The user is NOT currently viewing this dialog - // Desktop parity: desktop computes unread from `SELECT COUNT(*) WHERE read = 0`, - // so duplicates never inflate the count. iOS uses an incremental counter, - // so we must guard against re-incrementing for known messages. - if isNewMessage && !MessageRepository.shared.isDialogActive(opponentKey) { + // Only increment unread count for REAL-TIME messages (not sync). + // During sync, messages may already be read on another device but arrive + // as "new" to iOS. Incrementing here inflates the badge (e.g., 11 → 4 → 0). + // Android parity: Android recalculates unread from DB after every message + // via COUNT(*) WHERE read=0. iOS defers to reconcileUnreadCounts() at sync end. + if isNewMessage && !fromSync && !MessageRepository.shared.isDialogActive(opponentKey) { dialog.unreadCount += 1 } } @@ -341,7 +349,7 @@ final class DialogRepository { case .image: dialog.lastMessage = "Photo" case .file: dialog.lastMessage = "File" case .avatar: dialog.lastMessage = "Avatar" - default: dialog.lastMessage = lastMsg.text + case .messages: dialog.lastMessage = "Forwarded message" } } else { dialog.lastMessage = lastMsg.text @@ -457,9 +465,19 @@ final class DialogRepository { guard var dialog = dialogs[opponentKey] else { return } dialog.isMuted.toggle() dialogs[opponentKey] = dialog + syncMutedKeysToDefaults() schedulePersist() } + /// Sync muted chat keys to shared App Group UserDefaults. + /// Background push handler reads this to skip notifications for muted chats + /// without needing MainActor access to DialogRepository. + private func syncMutedKeysToDefaults() { + let mutedKeys = dialogs.values.filter(\.isMuted).map(\.opponentKey) + UserDefaults.standard.set(mutedKeys, forKey: "muted_chats_keys") + UserDefaults(suiteName: "group.com.rosetta.dev")?.set(mutedKeys, forKey: "muted_chats_keys") + } + private func normalizeTimestamp(_ raw: Int64) -> Int64 { raw < 1_000_000_000_000 ? raw * 1000 : raw } @@ -485,9 +503,15 @@ final class DialogRepository { } /// Update app icon badge with total unread message count. + /// Writes to shared App Group UserDefaults so the Notification Service Extension + /// can read the current count and increment it when the app is terminated. private func updateAppBadge() { - let total = dialogs.values.reduce(0) { $0 + $1.unreadCount } + let total = dialogs.values.filter { !$0.isMuted }.reduce(0) { $0 + $1.unreadCount } UNUserNotificationCenter.current().setBadgeCount(total) + // Shared storage — NSE reads this to increment badge when app is killed. + let shared = UserDefaults(suiteName: "group.com.rosetta.dev") + shared?.set(total, forKey: "app_badge_count") + // Keep standard defaults in sync for backward compat. UserDefaults.standard.set(total, forKey: "app_badge_count") } diff --git a/Rosetta/Core/Data/Repositories/MessageRepository.swift b/Rosetta/Core/Data/Repositories/MessageRepository.swift index 3cb580b..0ed32f7 100644 --- a/Rosetta/Core/Data/Repositories/MessageRepository.swift +++ b/Rosetta/Core/Data/Repositories/MessageRepository.swift @@ -96,6 +96,14 @@ final class MessageRepository: ObservableObject { messagesByDialog[dialogKey]?.last?.id == messageId } + /// Android parity: returns the timestamp of the latest incoming message in a dialog. + /// Used for read receipt dedup — only send if `latestIncomingTs > lastReadReceiptTimestamp`. + func latestIncomingTimestamp(for dialogKey: String, myPublicKey: String) -> Int64? { + messagesByDialog[dialogKey]? + .last { $0.fromPublicKey == dialogKey || $0.fromPublicKey != myPublicKey }? + .timestamp + } + /// Whether the user is currently viewing any chat. var hasActiveDialog: Bool { !activeDialogs.isEmpty @@ -105,6 +113,12 @@ final class MessageRepository: ObservableObject { activeDialogs.contains(dialogKey) } + /// All currently active dialog keys (read-only snapshot). + /// Android parity: used to re-mark messages as read on idle→active transition. + var activeDialogKeys: Set { + activeDialogs + } + func setDialogActive(_ dialogKey: String, isActive: Bool) { if isActive { activeDialogs.insert(dialogKey) @@ -184,6 +198,12 @@ final class MessageRepository: ObservableObject { } } + /// Returns the current delivery status for a message, or nil if not found. + func deliveryStatus(forMessageId messageId: String) -> DeliveryStatus? { + guard let dialogKey = messageToDialog[messageId] else { return nil } + return messagesByDialog[dialogKey]?.first(where: { $0.id == messageId })?.deliveryStatus + } + func updateDeliveryStatus(messageId: String, status: DeliveryStatus, newTimestamp: Int64? = nil) { guard let dialogKey = messageToDialog[messageId] else { return } updateMessages(for: dialogKey) { messages in diff --git a/Rosetta/Core/Network/Protocol/ProtocolManager.swift b/Rosetta/Core/Network/Protocol/ProtocolManager.swift index 8655e16..85bb71d 100644 --- a/Rosetta/Core/Network/Protocol/ProtocolManager.swift +++ b/Rosetta/Core/Network/Protocol/ProtocolManager.swift @@ -105,14 +105,39 @@ final class ProtocolManager: @unchecked Sendable { } /// Verify connection health after returning from background. - /// Always force reconnect — after background, the socket is likely dead - /// and a 2s ping timeout just delays the inevitable. + /// 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. 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() + } + return + } + + // Not authenticated — force reconnect immediately. Self.logger.info("Foreground reconnect — force reconnecting") handshakeComplete = false heartbeatTask?.cancel() diff --git a/Rosetta/Core/Network/Protocol/WebSocketClient.swift b/Rosetta/Core/Network/Protocol/WebSocketClient.swift index 5f37a44..dc6677b 100644 --- a/Rosetta/Core/Network/Protocol/WebSocketClient.swift +++ b/Rosetta/Core/Network/Protocol/WebSocketClient.swift @@ -31,7 +31,11 @@ final class WebSocketClient: NSObject, @unchecked Sendable, URLSessionWebSocketD override init() { super.init() let config = URLSessionConfiguration.default - config.waitsForConnectivity = true + // Don't wait for connectivity — fail fast so NWPathMonitor can trigger + // instant reconnect when network becomes available. + config.waitsForConnectivity = false + config.timeoutIntervalForRequest = 10 + config.timeoutIntervalForResource = 15 session = URLSession(configuration: config, delegate: self, delegateQueue: nil) startNetworkMonitor() } @@ -91,6 +95,8 @@ final class WebSocketClient: NSObject, @unchecked Sendable, URLSessionWebSocketD webSocketTask = nil isConnected = false disconnectHandledForCurrentSocket = false + // Android parity: reset backoff so next failure starts from 1s, not stale 8s/16s. + reconnectAttempts = 0 Self.logger.info("Force reconnect triggered") connect() } @@ -196,16 +202,27 @@ final class WebSocketClient: NSObject, @unchecked Sendable, URLSessionWebSocketD guard !isManuallyClosed else { return } guard reconnectTask == nil else { return } - // Android parity: exponential backoff — 1s, 2s, 4s, 8s, 16s (cap). + // First attempt: reconnect immediately (0ms delay) for fastest recovery. + // Subsequent attempts: exponential backoff — 1s, 2s, 4s, 8s, 16s (cap). reconnectAttempts += 1 - let exponent = min(reconnectAttempts - 1, 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 == 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() + } } } } diff --git a/Rosetta/Core/Network/TransportManager.swift b/Rosetta/Core/Network/TransportManager.swift index 9cb83b4..9ab8bf9 100644 --- a/Rosetta/Core/Network/TransportManager.swift +++ b/Rosetta/Core/Network/TransportManager.swift @@ -77,6 +77,9 @@ final class TransportManager: @unchecked Sendable { /// - id: Unique file identifier (used as filename in multipart). /// - content: Raw file content to upload. /// - Returns: Server-assigned tag for later download. + /// Android parity: retry with exponential backoff (1s, 2s, 4s) on upload failure. + private static let maxUploadRetries = 3 + func uploadFile(id: String, content: Data) async throws -> String { guard let serverUrl = await MainActor.run(body: { transportServer }) else { throw TransportError.noTransportServer @@ -102,25 +105,38 @@ final class TransportManager: @unchecked Sendable { body.append("\r\n--\(boundary)--\r\n".data(using: .utf8)!) request.httpBody = body - let (data, response) = try await session.data(for: request) + var lastError: Error = TransportError.invalidResponse + for attempt in 0.. = [] - private var lastReadReceiptSentAt: [String: Int64] = [:] + /// Android parity: tracks the latest incoming message timestamp per dialog + /// for which a read receipt was already sent. Prevents redundant sends. + private var lastReadReceiptTimestamp: [String: Int64] = [:] private var requestedUserInfoKeys: Set = [] private var onlineSubscribedKeys: Set = [] private var pendingOutgoingRetryTasks: [String: Task] = [:] @@ -38,17 +39,9 @@ final class SessionManager { private let maxOutgoingRetryAttempts = ProtocolConstants.maxOutgoingRetryAttempts private let maxOutgoingWaitingLifetimeMs: Int64 = ProtocolConstants.messageDeliveryTimeoutS * 1000 - // MARK: - Idle Detection (Desktop parity) + // MARK: - Foreground Detection (Android parity) - /// Tracks the last user interaction timestamp for idle detection. - /// Desktop: messages marked unread if user idle > 20 seconds. - private var lastUserInteractionTime: Date = Date() - private var idleObserverToken: NSObjectProtocol? - - /// Whether the user is considered idle (no interaction for `idleTimeoutForUnreadS`). - private var isUserIdle: Bool { - Date().timeIntervalSince(lastUserInteractionTime) > ProtocolConstants.idleTimeoutForUnreadS - } + private var foregroundObserverToken: NSObjectProtocol? /// Whether the app is in the foreground. private var isAppInForeground: Bool { @@ -60,13 +53,22 @@ final class SessionManager { private init() { setupProtocolCallbacks() setupUserInfoSearchHandler() - setupIdleDetection() + setupForegroundObserver() } - /// Desktop parity: track user interaction to implement idle detection. - /// Call this from any user-facing interaction (tap, scroll, keyboard). - func recordUserInteraction() { - lastUserInteractionTime = Date() + /// Android parity (ON_RESUME): re-mark active dialogs as read and send read receipts. + /// Called on foreground resume. Android has no idle detection — just re-marks on resume. + func markActiveDialogsAsRead() { + let activeKeys = MessageRepository.shared.activeDialogKeys + let myKey = currentPublicKey + for dialogKey in activeKeys { + guard !SystemAccounts.isSystemAccount(dialogKey) else { continue } + DialogRepository.shared.markAsRead(opponentKey: dialogKey) + MessageRepository.shared.markIncomingAsRead( + opponentKey: dialogKey, myPublicKey: myKey + ) + sendReadReceipt(toPublicKey: dialogKey) + } } // MARK: - Session Lifecycle @@ -218,10 +220,15 @@ final class SessionManager { recipientPublicKeyHex: toPublicKey ) - // Desktop parity: attachment password = plainKeyAndNonce interpreted as UTF-8 string - // (same derivation as aesChachaKey: key.toString('utf-8') in useDialog.ts) - // Must use UTF-8 decoding with replacement characters (U+FFFD) to match Node.js behavior. - let latin1String = String(decoding: encrypted.plainKeyAndNonce, as: UTF8.self) + // Attachment password: Android-style UTF-8 decoder (1:1 parity with Android). + let attachmentPassword = MessageCrypto.bytesToAndroidUtf8String(encrypted.plainKeyAndNonce) + + // aesChachaKey = Latin-1 encoding (matches desktop sync chain: + // Buffer.from(decryptedString, 'binary') takes low byte of each char). + // NEVER use WHATWG UTF-8 for aesChachaKey — U+FFFD round-trips as 0xFD, not original byte. + guard let latin1ForSync = String(data: encrypted.plainKeyAndNonce, encoding: .isoLatin1) else { + throw CryptoError.encryptionFailed + } // Desktop parity: avatar blob is a full data URI (e.g. "data:image/png;base64,iVBOR...") // not just raw base64. Desktop's AvatarProvider stores and sends data URIs. @@ -229,7 +236,7 @@ final class SessionManager { let avatarData = Data(dataURI.utf8) let encryptedBlob = try CryptoManager.shared.encryptWithPasswordDesktopCompat( avatarData, - password: latin1String + password: attachmentPassword ) // Upload encrypted blob to transport server (desktop: uploadFile) @@ -243,8 +250,8 @@ final class SessionManager { let blurhash = avatarImage?.blurHash(numberOfComponents: (4, 3)) ?? "" let preview = "\(tag)::\(blurhash)" - // Build aesChachaKey (same as regular messages) - let aesChachaPayload = Data(latin1String.utf8) + // Build aesChachaKey with Latin-1 payload (desktop sync parity) + let aesChachaPayload = Data(latin1ForSync.utf8) let aesChachaKey = try CryptoManager.shared.encryptWithPasswordDesktopCompat( aesChachaPayload, password: privKey @@ -297,10 +304,15 @@ final class SessionManager { DialogRepository.shared.updateDeliveryStatus(messageId: messageId, opponentKey: toPublicKey, status: .error) } - // Saved Messages — local only + // Saved Messages — mark delivered locally but STILL send to server + // for cross-device avatar sync. Other devices receive via sync and + // update their local avatar cache. if toPublicKey == currentPublicKey { MessageRepository.shared.updateDeliveryStatus(messageId: messageId, status: .delivered) DialogRepository.shared.updateDeliveryStatus(messageId: messageId, opponentKey: toPublicKey, status: .delivered) + // Send to server for multi-device sync (unlike text Saved Messages) + ProtocolManager.shared.sendPacket(packet) + Self.logger.info("📤 Avatar synced to Saved Messages (multi-device) tag=\(tag)") return } @@ -342,9 +354,11 @@ final class SessionManager { recipientPublicKeyHex: toPublicKey ) - // Attachment password: WHATWG UTF-8 of raw key+nonce bytes. - // Matches desktop's Buffer.from(rawBytes).toString('utf-8') for PBKDF2 password derivation. - let attachmentPassword = String(decoding: encrypted.plainKeyAndNonce, as: UTF8.self) + // Attachment password: Android-style UTF-8 decoder (feross/buffer polyfill) for 1:1 parity. + // Android uses bytesToJsUtf8String() which emits ONE U+FFFD per consumed byte in + // failed multi-byte sequences. Desktop WHATWG is slightly different but both work + // because Desktop tries both variants when decrypting. + let attachmentPassword = MessageCrypto.bytesToAndroidUtf8String(encrypted.plainKeyAndNonce) #if DEBUG // Full diagnostic: log values needed to verify PBKDF2 key matches CryptoJS. @@ -361,92 +375,81 @@ final class SessionManager { Self.logger.debug("📎 pbkdf2Key: \(pbkdf2Key.hexString)") #endif - // Process each attachment: encrypt → upload → build metadata - var messageAttachments: [MessageAttachment] = [] + // Phase 1: Encrypt all attachments sequentially (same password, CPU-bound). + struct EncryptedAttachment { + let original: PendingAttachment + let encryptedData: Data + let preview: String // partially built — tag placeholder + } + var encryptedAttachments: [EncryptedAttachment] = [] for attachment in attachments { - // Build data URI (desktop: FileReader.readAsDataURL) let dataURI = buildDataURI(attachment) - - #if DEBUG - Self.logger.debug("📎 DataURI prefix: \(String(dataURI.prefix(40)))… (\(dataURI.count) chars)") - #endif - - // Encrypt blob with desktop-compatible encryption let encryptedBlob = try CryptoManager.shared.encryptWithPasswordDesktopCompat( Data(dataURI.utf8), password: attachmentPassword ) #if DEBUG - // Log IV and ciphertext prefix for cross-platform verification. - let blobParts = encryptedBlob.components(separatedBy: ":") - if blobParts.count == 2, let ivData = Data(base64Encoded: blobParts[0]) { - Self.logger.debug("📎 blob IV: \(ivData.hexString), ct(\(blobParts[1].count) b64chars)") - } // Self-test: decrypt with the SAME WHATWG password. if let selfTestData = try? CryptoManager.shared.decryptWithPassword( encryptedBlob, password: attachmentPassword, requireCompression: true ), String(data: selfTestData, encoding: .utf8)?.hasPrefix("data:") == true { - Self.logger.debug("📎 Blob self-test PASSED") + Self.logger.debug("📎 Blob self-test PASSED for \(attachment.id)") } else { - Self.logger.error("📎 Blob self-test FAILED — blob may not decrypt on desktop") + Self.logger.error("📎 Blob self-test FAILED for \(attachment.id)") } #endif - // Upload to transport server - let uploadData = Data(encryptedBlob.utf8) - let uploadHash = CryptoManager.shared.sha256(uploadData) - let tag = try await TransportManager.shared.uploadFile( - id: attachment.id, - content: uploadData - ) - - #if DEBUG - Self.logger.debug("📎 Uploaded tag=\(tag), \(uploadData.count) bytes, sha256=\(uploadHash.hexString)") - - // Transport round-trip verification: download the blob back and compare SHA256. - // This catches CDN corruption, partial uploads, and encoding issues. - do { - let verifyData = try await TransportManager.shared.downloadFile(tag: tag) - let verifyHash = CryptoManager.shared.sha256(verifyData) - if uploadHash == verifyHash { - Self.logger.debug("📎 Transport verify PASS: tag=\(tag), \(verifyData.count) bytes") - } else { - Self.logger.error("📎 ❌ TRANSPORT MISMATCH tag=\(tag): uploaded \(uploadData.count)b sha=\(uploadHash.hexString), downloaded \(verifyData.count)b sha=\(verifyHash.hexString)") - // Log first 100 bytes of each for comparison - let upPrefix = String(data: uploadData.prefix(100), encoding: .utf8) ?? "" - let downStr = String(data: verifyData.prefix(100), encoding: .utf8) ?? "" - Self.logger.error("📎 ❌ Upload prefix: \(upPrefix)") - Self.logger.error("📎 ❌ Download prefix: \(downStr)") - } - } catch { - Self.logger.error("📎 ❌ Transport verify FAILED to download tag=\(tag): \(error)") - } - #endif - - // Build preview string (format depends on type) - // Desktop parity: preview = "tag::blurhash" for images, "tag::size::filename" for files - let preview: String + // Pre-compute blurhash/preview prefix (everything except tag) + let previewSuffix: String switch attachment.type { case .image: - // Generate blurhash from thumbnail (android: BlurHash.encode(bitmap, 4, 3)) let blurhash = attachment.thumbnail?.blurHash(numberOfComponents: (4, 3)) ?? "" - preview = "\(tag)::\(blurhash)" + previewSuffix = blurhash case .file: - // Desktop: preview = "tag::size::filename" - preview = "\(tag)::\(attachment.fileSize ?? 0)::\(attachment.fileName ?? "file")" + previewSuffix = "\(attachment.fileSize ?? 0)::\(attachment.fileName ?? "file")" default: - preview = "\(tag)::" + previewSuffix = "" } - messageAttachments.append(MessageAttachment( - id: attachment.id, - preview: preview, - blob: "", // Desktop parity: blob cleared after upload - type: attachment.type + encryptedAttachments.append(EncryptedAttachment( + original: attachment, + encryptedData: Data(encryptedBlob.utf8), + preview: previewSuffix )) + } - Self.logger.info("📤 Attachment uploaded: type=\(String(describing: attachment.type)), tag=\(tag)") + // Phase 2: Upload all attachments concurrently (Android parity: backgroundUploadScope). + let messageAttachments: [MessageAttachment] = try await withThrowingTaskGroup( + of: (Int, String).self + ) { group in + for (index, item) in encryptedAttachments.enumerated() { + group.addTask { + let tag = try await TransportManager.shared.uploadFile( + id: item.original.id, + content: item.encryptedData + ) + return (index, tag) + } + } + + // Collect results, preserving original order. + var tags = [Int: String]() + for try await (index, tag) in group { + tags[index] = tag + } + + return encryptedAttachments.enumerated().map { index, item in + let tag = tags[index] ?? "" + let preview = item.preview.isEmpty ? "\(tag)::" : "\(tag)::\(item.preview)" + Self.logger.info("📤 Attachment uploaded: type=\(String(describing: item.original.type)), tag=\(tag)") + return MessageAttachment( + id: item.original.id, + preview: preview, + blob: "", + type: item.original.type + ) + } } // Build aesChachaKey (for sync/backup — same encoding as makeOutgoingPacket). @@ -606,9 +609,9 @@ final class SessionManager { recipientPublicKeyHex: toPublicKey ) - // Desktop parity: reply blob password = WHATWG UTF-8 of raw plainKeyAndNonce bytes. + // Android parity: reply blob password = Android-style UTF-8 of raw plainKeyAndNonce bytes. // Same as attachment password derivation. - let replyPassword = String(decoding: encrypted.plainKeyAndNonce, as: UTF8.self) + let replyPassword = MessageCrypto.bytesToAndroidUtf8String(encrypted.plainKeyAndNonce) // Build the reply JSON blob let replyJSON = try JSONEncoder().encode(replyMessages) @@ -736,9 +739,47 @@ final class SessionManager { ProtocolManager.shared.sendPacket(packet) } - /// Sends read receipt for direct dialog. + /// Android parity: sends read receipt for direct dialog. + /// Uses timestamp dedup (not time-based throttle) — only sends if latest incoming + /// message timestamp > last sent read receipt timestamp. Retries once after 2s. func sendReadReceipt(toPublicKey: String) { - sendReadReceipt(toPublicKey: toPublicKey, force: false) + let normalized = toPublicKey.trimmingCharacters(in: .whitespacesAndNewlines) + let connState = ProtocolManager.shared.connectionState + guard normalized != currentPublicKey, + !normalized.isEmpty, + let hash = privateKeyHash, + connState == .authenticated + else { + return + } + + // Android parity: timestamp dedup — only send if latest incoming message + // timestamp is newer than what we already sent a receipt for. + let latestTs = MessageRepository.shared.latestIncomingTimestamp( + for: normalized, myPublicKey: currentPublicKey + ) ?? 0 + let lastSentTs = lastReadReceiptTimestamp[normalized] ?? 0 + if latestTs > 0, latestTs <= lastSentTs { return } + + var packet = PacketRead() + packet.privateKey = hash + packet.fromPublicKey = currentPublicKey + packet.toPublicKey = normalized + ProtocolManager.shared.sendPacket(packet) + lastReadReceiptTimestamp[normalized] = latestTs + + // Android parity: retry once after 2 seconds in case send failed silently. + Task { @MainActor [weak self] in + try? await Task.sleep(for: .seconds(2)) + guard let self, + ProtocolManager.shared.connectionState == .authenticated, + let hash = self.privateKeyHash else { return } + var retryPacket = PacketRead() + retryPacket.privateKey = hash + retryPacket.fromPublicKey = self.currentPublicKey + retryPacket.toPublicKey = normalized + ProtocolManager.shared.sendPacket(retryPacket) + } } /// Updates locally cached display name and username (called from ProfileEditView). @@ -757,14 +798,12 @@ final class SessionManager { syncRequestInFlight = false pendingIncomingMessages.removeAll() isProcessingIncomingMessages = false - pendingReadReceiptKeys.removeAll() - lastReadReceiptSentAt.removeAll() + lastReadReceiptTimestamp.removeAll() requestedUserInfoKeys.removeAll() pendingOutgoingRetryTasks.values.forEach { $0.cancel() } pendingOutgoingRetryTasks.removeAll() pendingOutgoingPackets.removeAll() pendingOutgoingAttempts.removeAll() - lastUserInteractionTime = Date() isAuthenticated = false currentPublicKey = "" displayName = "" @@ -809,6 +848,10 @@ final class SessionManager { newTimestamp: deliveryTimestamp ) self?.resolveOutgoingRetry(messageId: packet.messageId) + // Desktop parity (useDialogFiber.ts): update sync cursor on delivery ACK. + if let self, !self.syncBatchInProgress { + self.saveLastSyncTimestamp(deliveryTimestamp) + } } } @@ -845,6 +888,14 @@ final class SessionManager { opponentKey: opponentKey, myPublicKey: ownKey ) + // Resolve pending retry timers for all messages to this opponent — + // read receipt proves delivery, no need to retry further. + self.resolveAllOutgoingRetries(toPublicKey: opponentKey) + // Desktop parity (useDialogFiber.ts): update sync cursor on read receipt. + if !self.syncBatchInProgress { + let nowMs = Int64(Date().timeIntervalSince1970 * 1000) + self.saveLastSyncTimestamp(nowMs) + } } } @@ -964,12 +1015,16 @@ final class SessionManager { await self.waitForInboundQueueToDrain() let serverCursor = packet.timestamp self.saveLastSyncTimestamp(serverCursor) + // Android parity: reconcile unread counts after each batch. + // Sync messages may have been read on another device — PacketRead + // arrives during sync and marks them read, but incremental counters + // can drift. Reconcile from actual isRead state. + DialogRepository.shared.reconcileUnreadCounts() Self.logger.debug("SYNC BATCH_END cursor=\(serverCursor)") self.requestSynchronize(cursor: serverCursor) case .notNeeded: self.syncBatchInProgress = false - self.flushPendingReadReceipts() DialogRepository.shared.reconcileDeliveryStatuses() DialogRepository.shared.reconcileUnreadCounts() Self.logger.debug("SYNC NOT_NEEDED") @@ -1228,13 +1283,12 @@ final class SessionManager { // Sending 0x08 for every received message was causing a packet flood // that triggered server RST disconnects. - // Desktop parity: only mark as read if user is NOT idle AND app is in foreground. - // Desktop also skips system accounts and blocked users. + // Android parity: mark as read if dialog is active AND app is in foreground. + // Android has NO idle detection — only isDialogActive flag (ON_RESUME/ON_PAUSE). let dialogIsActive = MessageRepository.shared.isDialogActive(opponentKey) let isSystem = SystemAccounts.isSystemAccount(opponentKey) - let idle = isUserIdle let fg = isAppInForeground - let shouldMarkRead = dialogIsActive && !idle && fg && !isSystem + let shouldMarkRead = dialogIsActive && fg && !isSystem if shouldMarkRead { DialogRepository.shared.markAsRead(opponentKey: opponentKey) @@ -1243,11 +1297,9 @@ final class SessionManager { myPublicKey: myKey ) if !fromMe && !wasKnownBefore { - if syncBatchInProgress { - pendingReadReceiptKeys.insert(opponentKey) - } else { - sendReadReceipt(toPublicKey: opponentKey) - } + // Android/Desktop parity: send read receipt immediately, + // even during sync. 400ms debounce prevents flooding. + sendReadReceipt(toPublicKey: opponentKey) } } @@ -1327,9 +1379,8 @@ final class SessionManager { guard !syncRequestInFlight else { return } syncRequestInFlight = true - // Desktop parity: pass server cursor as-is (seconds). NO normalization — - // server uses seconds, converting to milliseconds made the server see a - // "future" cursor and respond NOT_NEEDED, breaking all subsequent syncs. + // Server and all platforms use MILLISECONDS for sync cursors. + // Pass cursor as-is — no normalization needed. let lastSync = cursor ?? loadLastSyncTimestamp() var packet = PacketSync() @@ -1343,10 +1394,11 @@ final class SessionManager { private func loadLastSyncTimestamp() -> Int64 { guard !currentPublicKey.isEmpty else { return 0 } let stored = Int64(UserDefaults.standard.integer(forKey: syncCursorKey)) - // Migration: old code normalized seconds → milliseconds. If the stored value - // is in milliseconds (>= 1 trillion), convert back to seconds for server parity. - if stored >= 1_000_000_000_000 { - let corrected = stored / 1000 + // Android parity (normalizeSyncTimestamp): all platforms store milliseconds. + // If stored value is in seconds (< 1 trillion), convert to milliseconds. + // Values >= 1 trillion are already in milliseconds — return as-is. + if stored > 0, stored < 1_000_000_000_000 { + let corrected = stored * 1000 UserDefaults.standard.set(Int(corrected), forKey: syncCursorKey) return corrected } @@ -1355,7 +1407,7 @@ final class SessionManager { private func saveLastSyncTimestamp(_ raw: Int64) { guard !currentPublicKey.isEmpty else { return } - // Desktop parity: store server cursor as-is (seconds), no normalization. + // Store server cursor as-is (milliseconds). No normalization. guard raw > 0 else { return } let existing = loadLastSyncTimestamp() guard raw > existing else { return } @@ -1632,11 +1684,18 @@ final class SessionManager { ) do { + // Use fresh timestamp for the packet so the 80s delivery timeout + // starts from NOW, not from the original send time. + // Without this, messages sent 70s before reconnect would expire + // in ~10s — not enough time for the server to respond with 0x08. + // Also fixes server-side messageId dedup: fresh timestamp lets the + // server accept the retried message instead of silently dropping it. + let retryTimestamp = Int64(Date().timeIntervalSince1970 * 1000) let packet = try makeOutgoingPacket( text: text, toPublicKey: message.toPublicKey, messageId: message.id, - timestamp: message.timestamp, + timestamp: retryTimestamp, privateKeyHex: privateKeyHex, privateKeyHash: privateKeyHash ) @@ -1655,42 +1714,6 @@ final class SessionManager { } } - private func flushPendingReadReceipts() { - guard !pendingReadReceiptKeys.isEmpty else { return } - let keys = pendingReadReceiptKeys - pendingReadReceiptKeys.removeAll() - for key in keys { - sendReadReceipt(toPublicKey: key, force: true) - } - } - - private func sendReadReceipt(toPublicKey: String, force: Bool) { - let normalized = toPublicKey.trimmingCharacters(in: .whitespacesAndNewlines) - let connState = ProtocolManager.shared.connectionState - guard normalized != currentPublicKey, - !normalized.isEmpty, - let hash = privateKeyHash, - connState == .authenticated - else { - return - } - - let now = Int64(Date().timeIntervalSince1970 * 1000) - if !force { - let lastSent = lastReadReceiptSentAt[normalized] ?? 0 - if now - lastSent < 400 { - return - } - } - lastReadReceiptSentAt[normalized] = now - - var packet = PacketRead() - packet.privateKey = hash - packet.fromPublicKey = currentPublicKey - packet.toPublicKey = normalized - ProtocolManager.shared.sendPacket(packet) - } - private func registerOutgoingRetry(for packet: PacketMessage) { let messageId = packet.messageId pendingOutgoingRetryTasks[messageId]?.cancel() @@ -1708,20 +1731,26 @@ final class SessionManager { guard let packet = self.pendingOutgoingPackets[messageId] else { return } let attempts = self.pendingOutgoingAttempts[messageId] ?? 0 - // Check if message exceeded delivery timeout (80s) — mark as error. + // Android parity: 80s × max(1, attachmentCount). let nowMs = Int64(Date().timeIntervalSince1970 * 1000) let ageMs = nowMs - packet.timestamp - if ageMs >= self.maxOutgoingWaitingLifetimeMs { - Self.logger.warning("Message \(messageId) expired after \(ageMs)ms — marking as error") - self.markOutgoingAsError(messageId: messageId, packet: packet) + 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) return } guard attempts < self.maxOutgoingRetryAttempts else { - // Max retries exhausted for this connection session — mark as error. - // The user sees the error icon immediately instead of a stuck clock. - Self.logger.warning("Message \(messageId) exhausted \(attempts) retries — marking as error") - self.markOutgoingAsError(messageId: messageId, packet: packet) + // 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) return } @@ -1741,10 +1770,48 @@ final class SessionManager { } } + /// Optimistically mark an outgoing message as delivered when no ACK was received + /// but packets were successfully sent while authenticated. Most common cause: + /// server deduplicates by messageId (original was delivered before disconnect). + private func markOutgoingAsDelivered(messageId: String, packet: PacketMessage) { + let fromMe = packet.fromPublicKey == currentPublicKey + let opponentKey = fromMe ? packet.toPublicKey : packet.fromPublicKey + + let currentStatus = MessageRepository.shared.deliveryStatus(forMessageId: messageId) + if currentStatus == .read { + // Already read — don't downgrade to delivered. + resolveOutgoingRetry(messageId: messageId) + return + } + + let deliveryTimestamp = Int64(Date().timeIntervalSince1970 * 1000) + MessageRepository.shared.updateDeliveryStatus( + messageId: messageId, + status: .delivered, + newTimestamp: deliveryTimestamp + ) + DialogRepository.shared.updateDeliveryStatus( + messageId: messageId, + opponentKey: opponentKey, + status: .delivered + ) + resolveOutgoingRetry(messageId: messageId) + } + /// Mark an outgoing message as error in both repositories and clean up retry state. + /// Guards against downgrading .delivered/.read → .error (e.g. if PacketDelivery/PacketRead + /// arrived while the retry timer was still running). private func markOutgoingAsError(messageId: String, packet: PacketMessage) { let fromMe = packet.fromPublicKey == currentPublicKey let opponentKey = fromMe ? packet.toPublicKey : packet.fromPublicKey + + let currentStatus = MessageRepository.shared.deliveryStatus(forMessageId: messageId) + if currentStatus == .delivered || currentStatus == .read { + Self.logger.info("Skipping markOutgoingAsError for \(messageId.prefix(8))… — already \(currentStatus?.rawValue ?? -1)") + resolveOutgoingRetry(messageId: messageId) + return + } + MessageRepository.shared.updateDeliveryStatus(messageId: messageId, status: .error) DialogRepository.shared.updateDeliveryStatus( messageId: messageId, @@ -1761,6 +1828,18 @@ final class SessionManager { pendingOutgoingAttempts.removeValue(forKey: messageId) } + /// Resolve all pending outgoing retries for messages to a specific opponent. + /// Called when PacketRead (0x07) proves that messages were delivered. + private func resolveAllOutgoingRetries(toPublicKey: String) { + let matchingIds = pendingOutgoingPackets + .filter { $0.value.toPublicKey == toPublicKey } + .map { $0.key } + for messageId in matchingIds { + Self.logger.info("Resolving retry for \(messageId.prefix(8))… — read receipt received") + resolveOutgoingRetry(messageId: messageId) + } + } + // MARK: - Push Notifications /// Stores the APNs device token received from AppDelegate. @@ -1795,16 +1874,16 @@ final class SessionManager { /// Desktop equivalent: `useUpdateMessage.ts` → `useSendSystemMessage("updates")`. private func sendReleaseNotesIfNeeded(publicKey: String) { let key = "lastReleaseNoticeVersion_\(publicKey)" - let lastVersion = UserDefaults.standard.string(forKey: key) ?? "" - let currentVersion = ReleaseNotes.appVersion - - guard lastVersion != currentVersion else { return } - + let lastKey = UserDefaults.standard.string(forKey: key) ?? "" + // 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)" + + guard lastKey != currentKey else { return } let now = Int64(Date().timeIntervalSince1970 * 1000) - let messageId = "release_notes_\(currentVersion)" + let messageId = "release_notes_\(ReleaseNotes.appVersion)" // Create synthetic PacketMessage — local-only, never sent to server. var packet = PacketMessage() @@ -1837,23 +1916,23 @@ final class SessionManager { verified: 1 ) - UserDefaults.standard.set(currentVersion, forKey: key) - Self.logger.info("Release notes v\(currentVersion) sent to Updates chat") + UserDefaults.standard.set(currentKey, forKey: key) + Self.logger.info("Release notes v\(ReleaseNotes.appVersion) sent to Updates chat") } - // MARK: - Idle Detection Setup + // MARK: - Foreground Observer (Android parity) - private func setupIdleDetection() { - // Track app going to background/foreground to reset idle state + reconnect. - idleObserverToken = NotificationCenter.default.addObserver( + private func setupForegroundObserver() { + // Android parity: ON_RESUME → markVisibleMessagesAsRead() + reconnect. + foregroundObserverToken = NotificationCenter.default.addObserver( forName: UIApplication.willEnterForegroundNotification, object: nil, queue: .main ) { [weak self] _ in Task { @MainActor [weak self] in - self?.lastUserInteractionTime = Date() + // Android: ON_RESUME calls markVisibleMessagesAsRead() for active dialog. + self?.markActiveDialogsAsRead() // Always verify connection on foreground — don't trust cached state. - // reconnectIfNeeded() pings to check if "authenticated" connection is alive. ProtocolManager.shared.reconnectIfNeeded() } } diff --git a/Rosetta/Core/Utils/ProfileValidator.swift b/Rosetta/Core/Utils/ProfileValidator.swift index c071c52..2fafd3a 100644 --- a/Rosetta/Core/Utils/ProfileValidator.swift +++ b/Rosetta/Core/Utils/ProfileValidator.swift @@ -9,11 +9,13 @@ enum ProfileValidator { enum DisplayNameError: LocalizedError { case empty case tooLong + case tooManyWords var errorDescription: String? { switch self { case .empty: return "Name cannot be empty" case .tooLong: return "Name cannot exceed 64 characters" + case .tooManyWords: return "Maximum two words (first and last name)" } } } @@ -22,9 +24,28 @@ enum ProfileValidator { let trimmed = name.trimmingCharacters(in: .whitespaces) if trimmed.isEmpty { return .empty } if trimmed.count > 64 { return .tooLong } + // Maximum 2 words (first name + last name) + let words = trimmed.split(whereSeparator: { $0.isWhitespace }) + if words.count > 2 { return .tooManyWords } return nil } + /// Sanitizes display name input: collapses multiple spaces, + /// prevents more than one space (two words max). + static func sanitizeDisplayName(_ name: String) -> String { + // Collapse multiple consecutive spaces into one + let collapsed = name.replacingOccurrences( + of: "\\s{2,}", with: " ", options: .regularExpression + ) + // If already has one space and user tries to add another, block it + let spaceCount = collapsed.filter { $0 == " " }.count + if spaceCount > 1 { + let parts = collapsed.split(separator: " ", maxSplits: 1) + return parts.joined(separator: " ") + } + return collapsed + } + // MARK: - Username enum UsernameError: LocalizedError { diff --git a/Rosetta/Core/Utils/ProtocolConstants.swift b/Rosetta/Core/Utils/ProtocolConstants.swift index 7ca0f57..f763d49 100644 --- a/Rosetta/Core/Utils/ProtocolConstants.swift +++ b/Rosetta/Core/Utils/ProtocolConstants.swift @@ -9,16 +9,15 @@ enum ProtocolConstants { static let maxMessagesLoad = 20 /// Maximum messages kept in memory per dialog. - static let messageMaxCached = 40 + /// Android: unlimited in SQLite, 500 in-memory cache. + /// Desktop: MESSAGE_MAX_LOADED = 40 (but stores all in IndexedDB). + /// iOS: keep 200 in memory + persist to disk. Phase 2: SQLite + pagination. + static let messageMaxCached = 200 /// Outgoing message delivery timeout in seconds. /// If a WAITING message is older than this, mark it as ERROR. static let messageDeliveryTimeoutS: Int64 = 80 - /// User idle timeout for marking incoming messages as unread (seconds). - /// Desktop: `TIME_TO_INACTIVE_FOR_MESSAGES_UNREAD = 20`. - static let idleTimeoutForUnreadS: TimeInterval = 20 - /// Maximum number of file attachments per message. static let maxAttachmentsInMessage = 5 @@ -41,9 +40,6 @@ enum ProtocolConstants { /// Maximum number of outgoing message retry attempts. static let maxOutgoingRetryAttempts = 3 - /// Read receipt throttle interval in milliseconds. - static let readReceiptThrottleMs: Int64 = 400 - /// Typing indicator throttle interval in milliseconds (desktop parity). static let typingThrottleMs: Int64 = 3_000 diff --git a/Rosetta/Core/Utils/ReleaseNotes.swift b/Rosetta/Core/Utils/ReleaseNotes.swift index 2fcbfcd..69b80a5 100644 --- a/Rosetta/Core/Utils/ReleaseNotes.swift +++ b/Rosetta/Core/Utils/ReleaseNotes.swift @@ -11,23 +11,17 @@ enum ReleaseNotes { Entry( version: appVersion, body: """ - **Синхронизация** - - Исправлена критическая ошибка, из-за которой синхронизация могла не запускаться после подключения к серверу - - Исправлена ошибка с курсором синхронизации — теперь курсор передаётся без преобразования, как в Desktop - - Исправлены ложные непрочитанные сообщения после синхронизации + **Уведомления** + Вибрация и бейдж работают когда приложение закрыто. Счётчик непрочитанных обновляется в фоне. - **Мульти-девайс** - - Сообщения с другого устройства того же аккаунта теперь корректно показываются со статусом «доставлено» + **Фото и файлы** + Фото скачиваются только по тапу — блюр-превью со стрелкой загрузки. Пересланные фото подтягиваются из кэша автоматически. Файлы открываются по тапу — скачивание и «Поделиться». Меню вложений (скрепка) работает на iOS 26+. - **UI (iOS 26)** - - Исправлен баг с размытием экрана чата при скролле + **Оптимизация производительности** + Улучшен FPS скролла и клавиатуры в длинных переписках. - **Swipe-to-Reply** — свайп влево по сообщению для ответа, как в Telegram - **Reply Quote** — обновлённый дизайн цитаты ответа. Если ответ на фото — миниатюра из BlurHash - **Навигация по цитате** — тап на цитату скроллит к оригиналу с плавной подсветкой - **Коллаж фотографий** — несколько фото в сообщении отображаются в сетке в стиле Telegram - **Рамка вокруг фото** — фото обрамлены цветом пузырька с точным совпадением углов - **Просмотр фото** — полноэкранный просмотрщик с зумом, перетаскиванием и свайпом вниз для закрытия + **Исправления** + Убрана рамка у сообщений с аватаром. Saved Messages: иконка закладки вместо аватара. Read receipts: паритет с Android. """ ) ] diff --git a/Rosetta/DesignSystem/Components/ButtonStyles.swift b/Rosetta/DesignSystem/Components/ButtonStyles.swift index af950f3..6d2af02 100644 --- a/Rosetta/DesignSystem/Components/ButtonStyles.swift +++ b/Rosetta/DesignSystem/Components/ButtonStyles.swift @@ -26,10 +26,7 @@ struct GlassBackButton: View { .fill(Color.white.opacity(0.08)) .glassEffect(.regular, in: .circle) } else { - Circle() - .fill(.thinMaterial) - .overlay { Circle().strokeBorder(Color.white.opacity(0.18), lineWidth: 0.5) } - .shadow(color: .black.opacity(0.12), radius: 20, y: 8) + TelegramGlassCircle() } } } diff --git a/Rosetta/DesignSystem/Components/GlassCard.swift b/Rosetta/DesignSystem/Components/GlassCard.swift index fae92d3..bbac1d2 100644 --- a/Rosetta/DesignSystem/Components/GlassCard.swift +++ b/Rosetta/DesignSystem/Components/GlassCard.swift @@ -40,12 +40,9 @@ struct GlassCard: View { content() .glassEffect(.regular, in: RoundedRectangle(cornerRadius: cornerRadius)) } else { - let shape = RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) content() .background { - shape.fill(.thinMaterial) - .overlay { shape.strokeBorder(Color.white.opacity(0.10), lineWidth: 0.5) } - .shadow(color: .black.opacity(0.10), radius: 16, y: 6) + TelegramGlassRoundedRect(cornerRadius: cornerRadius) } } } diff --git a/Rosetta/DesignSystem/Components/GlassModifier.swift b/Rosetta/DesignSystem/Components/GlassModifier.swift index 5f27e25..9703ea5 100644 --- a/Rosetta/DesignSystem/Components/GlassModifier.swift +++ b/Rosetta/DesignSystem/Components/GlassModifier.swift @@ -3,7 +3,7 @@ import SwiftUI // MARK: - Glass Modifier // // iOS 26+: native .glassEffect API -// iOS < 26: .thinMaterial blur + stroke + shadow +// iOS < 26: Telegram-style glass (CABackdropLayer + gaussianBlur) struct GlassModifier: ViewModifier { let cornerRadius: CGFloat @@ -20,9 +20,7 @@ struct GlassModifier: ViewModifier { } else { content .background { - shape.fill(.thinMaterial) - .overlay { shape.strokeBorder(Color.white.opacity(0.10), lineWidth: 0.5) } - .shadow(color: .black.opacity(0.10), radius: 16, y: 6) + TelegramGlassRoundedRect(cornerRadius: cornerRadius) } } } @@ -46,9 +44,7 @@ extension View { } } else { background { - Capsule().fill(.thinMaterial) - .overlay { Capsule().strokeBorder(Color.white.opacity(0.10), lineWidth: 0.5) } - .shadow(color: .black.opacity(0.10), radius: 16, y: 6) + TelegramGlassCapsule() } } } @@ -63,9 +59,7 @@ extension View { } } else { background { - Circle().fill(.thinMaterial) - .overlay { Circle().strokeBorder(Color.white.opacity(0.10), lineWidth: 0.5) } - .shadow(color: .black.opacity(0.10), radius: 16, y: 6) + TelegramGlassCircle() } } } diff --git a/Rosetta/DesignSystem/Components/TelegramGlassView.swift b/Rosetta/DesignSystem/Components/TelegramGlassView.swift new file mode 100644 index 0000000..98898e8 --- /dev/null +++ b/Rosetta/DesignSystem/Components/TelegramGlassView.swift @@ -0,0 +1,274 @@ +import SwiftUI +import UIKit + +// MARK: - Environment Key: Glass Active + +/// When false, TelegramGlassUIView removes its CABackdropLayer from the +/// layer tree, stopping real-time blur (zero GPU cost). When true, the +/// backdrop layer is re-inserted and blur resumes. +/// +/// Usage: set `.environment(\.telegramGlassActive, false)` on views whose +/// glass effects should be frozen (e.g. hidden tabs in a ZStack pager). +private struct TelegramGlassActiveKey: EnvironmentKey { + static let defaultValue: Bool = true +} + +extension EnvironmentValues { + var telegramGlassActive: Bool { + get { self[TelegramGlassActiveKey.self] } + set { self[TelegramGlassActiveKey.self] = newValue } + } +} + +// MARK: - Telegram Glass (CABackdropLayer + CAFilter) +// +// Exact port of Telegram iOS LegacyGlassView + GlassBackgroundView foreground. +// iOS < 26: CABackdropLayer with gaussianBlur radius 2.0 + dark foreground overlay. +// iOS 26+: native UIGlassEffect(style: .regular). + +/// SwiftUI wrapper for Telegram-style glass background. +/// Capsule shape with proper corner radius. +struct TelegramGlassCapsule: UIViewRepresentable { + func makeUIView(context: Context) -> TelegramGlassUIView { + let view = TelegramGlassUIView(frame: .zero) + view.backgroundColor = .clear + return view + } + + func updateUIView(_ uiView: TelegramGlassUIView, context: Context) { + uiView.isFrozen = !context.environment.telegramGlassActive + uiView.updateGlass() + } +} + +/// SwiftUI wrapper for Telegram-style glass circle. +struct TelegramGlassCircle: UIViewRepresentable { + func makeUIView(context: Context) -> TelegramGlassUIView { + let view = TelegramGlassUIView(frame: .zero) + view.backgroundColor = .clear + view.isCircle = true + return view + } + + func updateUIView(_ uiView: TelegramGlassUIView, context: Context) { + uiView.isFrozen = !context.environment.telegramGlassActive + uiView.updateGlass() + } +} + +/// SwiftUI wrapper for Telegram-style glass with custom corner radius. +struct TelegramGlassRoundedRect: UIViewRepresentable { + let cornerRadius: CGFloat + + func makeUIView(context: Context) -> TelegramGlassUIView { + let view = TelegramGlassUIView(frame: .zero) + view.backgroundColor = .clear + view.fixedCornerRadius = cornerRadius + return view + } + + func updateUIView(_ uiView: TelegramGlassUIView, context: Context) { + uiView.isFrozen = !context.environment.telegramGlassActive + uiView.fixedCornerRadius = cornerRadius + uiView.updateGlass() + } +} + +// MARK: - UIKit Implementation + +final class TelegramGlassUIView: UIView { + var isCircle = false + /// When set, overrides auto-calculated corner radius (height/2 for capsule, min/2 for circle). + var fixedCornerRadius: CGFloat? + + /// When true, the CABackdropLayer is removed from the layer tree, + /// stopping real-time blur capture. Set to true for views inside + /// hidden tabs to eliminate GPU work from invisible glass effects. + var isFrozen: Bool = false { + didSet { + guard isFrozen != oldValue else { return } + if isFrozen { + backdropLayer?.removeFromSuperlayer() + } else if let backdrop = backdropLayer, backdrop.superlayer == nil { + clippingContainer.insertSublayer(backdrop, at: 0) + } + } + } + + // Layers + private var backdropLayer: CALayer? + private let clippingContainer = CALayer() + private let foregroundLayer = CALayer() + private let borderLayer = CAShapeLayer() + + // iOS 26+ native glass + private var nativeGlassView: UIVisualEffectView? + + override init(frame: CGRect) { + super.init(frame: frame) + clipsToBounds = false + + if #available(iOS 26.0, *) { + setupNativeGlass() + } else { + setupLegacyGlass() + } + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - iOS 26+ (native UIGlassEffect) + + @available(iOS 26.0, *) + private func setupNativeGlass() { + let effect = UIGlassEffect(style: .regular) + effect.isInteractive = false + // Telegram dark mode tint: UIColor(white: 1.0, alpha: 0.025) + effect.tintColor = UIColor(white: 1.0, alpha: 0.025) + let glassView = UIVisualEffectView(effect: effect) + glassView.layer.cornerCurve = .continuous + addSubview(glassView) + nativeGlassView = glassView + } + + // MARK: - iOS < 26 (CABackdropLayer — Telegram LegacyGlassView) + + private func setupLegacyGlass() { + // Clipping container — holds backdrop + foreground, clips to pill shape. + // Border is added to main layer OUTSIDE the clip so it's fully visible. + clippingContainer.masksToBounds = true + clippingContainer.cornerCurve = .circular + layer.addSublayer(clippingContainer) + + // 1. CABackdropLayer — blurs content behind this view + if let backdrop = Self.createBackdropLayer() { + backdrop.rasterizationScale = 1.0 + Self.setBackdropScale(backdrop, scale: 1.0) + + // gaussianBlur filter with radius 2.0 (Telegram .normal style) + if let blurFilter = Self.makeBlurFilter() { + blurFilter.setValue(2.0 as NSNumber, forKey: "inputRadius") + backdrop.filters = [blurFilter] + } + + clippingContainer.addSublayer(backdrop) + self.backdropLayer = backdrop + } + + // 2. Foreground — dark semi-transparent fill + foregroundLayer.backgroundColor = UIColor(white: 0.11, alpha: 0.85).cgColor + clippingContainer.addSublayer(foregroundLayer) + + // 3. Border — on main layer, NOT inside clipping container + borderLayer.fillColor = UIColor.clear.cgColor + borderLayer.strokeColor = UIColor(red: 0x38/255.0, green: 0x38/255.0, blue: 0x38/255.0, alpha: 1.0).cgColor + borderLayer.lineWidth = 0.5 + layer.addSublayer(borderLayer) + + layer.cornerCurve = .circular + } + + // MARK: - Layout + + func updateGlass() { + setNeedsLayout() + } + + override func layoutSubviews() { + super.layoutSubviews() + let bounds = bounds + guard bounds.width > 0, bounds.height > 0 else { return } + + let cornerRadius: CGFloat + if let fixed = fixedCornerRadius { + cornerRadius = fixed + } else if isCircle { + cornerRadius = min(bounds.width, bounds.height) / 2 + } else { + cornerRadius = bounds.height / 2 + } + + if #available(iOS 26.0, *), let glassView = nativeGlassView { + glassView.frame = bounds + glassView.layer.cornerRadius = cornerRadius + return + } + + // Legacy layout + clippingContainer.frame = bounds + clippingContainer.cornerRadius = cornerRadius + backdropLayer?.frame = bounds + foregroundLayer.frame = bounds + foregroundLayer.cornerRadius = cornerRadius + + let halfBorder = borderLayer.lineWidth / 2 + let borderRect = bounds.insetBy(dx: halfBorder, dy: halfBorder) + let borderRadius = max(0, cornerRadius - halfBorder) + let borderPath = UIBezierPath(roundedRect: borderRect, cornerRadius: borderRadius) + borderLayer.path = borderPath.cgPath + borderLayer.frame = bounds + } + + // MARK: - Shadow (drawn as separate image — Telegram parity) + + /// Call from parent to add Telegram-exact shadow. + /// Telegram: blur 40, color black 4%, offset (0,1), inset 32pt. + static func makeShadowImage(cornerRadius: CGFloat) -> UIImage? { + let inset: CGFloat = 32 + let innerInset: CGFloat = 0.5 + let diameter = cornerRadius * 2 + let totalSize = CGSize(width: inset * 2 + diameter, height: inset * 2 + diameter) + + let image = UIGraphicsImageRenderer(size: totalSize).image { ctx in + let context = ctx.cgContext + context.clear(CGRect(origin: .zero, size: totalSize)) + + context.setFillColor(UIColor.black.cgColor) + context.setShadow( + offset: CGSize(width: 0, height: 1), + blur: 40.0, + color: UIColor(white: 0.0, alpha: 0.04).cgColor + ) + let ellipseRect = CGRect( + x: inset + innerInset, + y: inset + innerInset, + width: totalSize.width - (inset + innerInset) * 2, + height: totalSize.height - (inset + innerInset) * 2 + ) + context.fillEllipse(in: ellipseRect) + + // Punch out the center (shadow only, no fill) + context.setFillColor(UIColor.clear.cgColor) + context.setBlendMode(.copy) + context.fillEllipse(in: ellipseRect) + } + return image.stretchableImage( + withLeftCapWidth: Int(inset + cornerRadius), + topCapHeight: Int(inset + cornerRadius) + ) + } + + // MARK: - Private API Helpers (same approach as Telegram) + + private static func createBackdropLayer() -> CALayer? { + let className = ["CA", "Backdrop", "Layer"].joined() + guard let cls = NSClassFromString(className) as? CALayer.Type else { return nil } + return cls.init() + } + + private static func setBackdropScale(_ layer: CALayer, scale: Double) { + let sel = NSSelectorFromString("setScale:") + guard layer.responds(to: sel) else { return } + layer.perform(sel, with: NSNumber(value: scale)) + } + + private static func makeBlurFilter() -> NSObject? { + let className = ["CA", "Filter"].joined() + guard let cls = NSClassFromString(className) as? NSObject.Type else { return nil } + let sel = NSSelectorFromString("filterWithName:") + guard cls.responds(to: sel) else { return nil } + return cls.perform(sel, with: "gaussianBlur")?.takeUnretainedValue() as? NSObject + } +} diff --git a/Rosetta/Features/Chats/ChatDetail/AttachmentPanelView.swift b/Rosetta/Features/Chats/ChatDetail/AttachmentPanelView.swift index be49052..bb824e6 100644 --- a/Rosetta/Features/Chats/ChatDetail/AttachmentPanelView.swift +++ b/Rosetta/Features/Chats/ChatDetail/AttachmentPanelView.swift @@ -19,6 +19,10 @@ struct AttachmentPanelView: View { let onSend: ([PendingAttachment], String) -> Void let onSendAvatar: () -> Void + /// When false, tapping avatar tab offers to set an avatar instead of sending. + var hasAvatar: Bool = true + /// Called when user has no avatar and taps the avatar tab — navigate to profile. + var onSetAvatar: (() -> Void)? @Environment(\.dismiss) private var dismiss @@ -190,10 +194,7 @@ struct AttachmentPanelView: View { .fill(Color.white.opacity(0.08)) .glassEffect(.regular, in: .circle) } else { - Circle() - .fill(.thinMaterial) - .overlay { Circle().strokeBorder(Color.white.opacity(0.18), lineWidth: 0.5) } - .shadow(color: .black.opacity(0.12), radius: 20, y: 8) + TelegramGlassCircle() } } @@ -324,10 +325,7 @@ struct AttachmentPanelView: View { .fill(.clear) .glassEffect(.regular, in: RoundedRectangle(cornerRadius: 21, style: .continuous)) } else { - let shape = RoundedRectangle(cornerRadius: 21, style: .continuous) - shape.fill(.thinMaterial) - .overlay { shape.strokeBorder(Color.white.opacity(0.18), lineWidth: 0.5) } - .shadow(color: .black.opacity(0.12), radius: 20, y: 8) + TelegramGlassRoundedRect(cornerRadius: 21) } } @@ -358,13 +356,7 @@ struct AttachmentPanelView: View { .fill(.clear) .glassEffect(.regular, in: .capsule) } else { - // iOS < 26 — frosted glass material (matches RosettaTabBar) - Capsule() - .fill(.regularMaterial) - .overlay( - Capsule() - .strokeBorder(Color.white.opacity(0.08), lineWidth: 0.5) - ) + TelegramGlassCapsule() } } @@ -375,9 +367,15 @@ struct AttachmentPanelView: View { return Button { if tab == .avatar { - // Avatar is an action tab — immediately sends avatar + dismisses - onSendAvatar() - dismiss() + if hasAvatar { + onSendAvatar() + dismiss() + } else { + // No avatar set — offer to set one + dismiss() + onSetAvatar?() + } + return } else { withAnimation(.easeInOut(duration: 0.2)) { selectedTab = tab @@ -395,13 +393,17 @@ struct AttachmentPanelView: View { .foregroundStyle(isSelected ? Color(hex: 0x008BFF) : .white) .frame(minWidth: 66, maxWidth: .infinity) .padding(.vertical, 6) - .background( - // Selected tab: thin material pill (matches RosettaTabBar selection style) - isSelected - ? AnyShapeStyle(.thinMaterial) - : AnyShapeStyle(.clear), - in: Capsule() - ) + .background { + if isSelected { + if #available(iOS 26, *) { + Capsule() + .fill(.clear) + .glassEffect(.regular, in: .capsule) + } else { + TelegramGlassCapsule() + } + } + } } .buttonStyle(.plain) } diff --git a/Rosetta/Features/Chats/ChatDetail/BubbleContextMenuOverlay.swift b/Rosetta/Features/Chats/ChatDetail/BubbleContextMenuOverlay.swift index 5c9eb57..81eac37 100644 --- a/Rosetta/Features/Chats/ChatDetail/BubbleContextMenuOverlay.swift +++ b/Rosetta/Features/Chats/ChatDetail/BubbleContextMenuOverlay.swift @@ -24,8 +24,9 @@ struct BubbleContextMenuOverlay: UIViewRepresentable { let previewShape: MessageBubbleShape let readStatusText: String? - /// Called when user single-taps the bubble (e.g., to open fullscreen image). - var onTap: (() -> Void)? + /// Called when user single-taps the bubble. Receives tap location in the overlay's + /// coordinate space (for determining which sub-element was tapped, e.g., which photo in a collage). + var onTap: ((CGPoint) -> Void)? /// Height of the reply quote area at the top of the bubble (0 = no reply quote). /// Taps within this region call `onReplyQuoteTap` instead of `onTap`. @@ -41,6 +42,9 @@ struct BubbleContextMenuOverlay: UIViewRepresentable { view.addInteraction(interaction) // Single tap recognizer — coexists with context menu's long press. + // ALL taps go through this (overlay UIView blocks SwiftUI gestures below). + // onTap handler in ChatDetailView routes to image viewer, file share, + // or posts .triggerAttachmentDownload notification for downloads. let tap = UITapGestureRecognizer(target: context.coordinator, action: #selector(Coordinator.handleTap(_:))) view.addGestureRecognizer(tap) @@ -62,7 +66,7 @@ struct BubbleContextMenuOverlay: UIViewRepresentable { var actions: [BubbleContextAction] var previewShape: MessageBubbleShape var readStatusText: String? - var onTap: (() -> Void)? + var onTap: ((CGPoint) -> Void)? var replyQuoteHeight: CGFloat = 0 var onReplyQuoteTap: (() -> Void)? private var snapshotView: UIImageView? @@ -85,7 +89,8 @@ struct BubbleContextMenuOverlay: UIViewRepresentable { return } } - onTap?() + let location = recognizer.location(in: recognizer.view) + onTap?(location) } func contextMenuInteraction( diff --git a/Rosetta/Features/Chats/ChatDetail/ChatDetailView.swift b/Rosetta/Features/Chats/ChatDetail/ChatDetailView.swift index 8beb9ff..f9f07f2 100644 --- a/Rosetta/Features/Chats/ChatDetail/ChatDetailView.swift +++ b/Rosetta/Features/Chats/ChatDetail/ChatDetailView.swift @@ -80,14 +80,15 @@ struct ChatDetailView: View { @State private var firstUnreadMessageId: String? @State private var isSendingAvatar = false @State private var showAttachmentPanel = false + @State private var showNoAvatarAlert = false @State private var pendingAttachments: [PendingAttachment] = [] @State private var showOpponentProfile = false @State private var replyingToMessage: ChatMessage? @State private var showForwardPicker = false @State private var forwardingMessage: ChatMessage? @State private var messageToDelete: ChatMessage? - /// Attachment ID for full-screen image viewer (nil = dismissed). - @State private var fullScreenAttachmentId: String? + /// State for the multi-photo gallery viewer (nil = dismissed). + @State private var imageViewerState: ImageViewerState? /// ID of message to scroll to (set when tapping a reply quote). @State private var scrollToMessageId: String? /// ID of message currently highlighted after scroll-to-reply navigation. @@ -236,8 +237,6 @@ struct ChatDetailView: View { // does NOT mutate DialogRepository, so ForEach won't rebuild. MessageRepository.shared.setDialogActive(route.publicKey, isActive: true) clearDeliveredNotifications(for: route.publicKey) - // Reset idle timer — user is actively viewing a chat. - SessionManager.shared.recordUserInteraction() // Request user info (non-mutating, won't trigger list rebuild) requestUserInfoIfNeeded() // Delay DialogRepository mutations to let navigation transition complete. @@ -261,6 +260,13 @@ struct ChatDetailView: View { .onDisappear { isViewActive = false firstUnreadMessageId = nil + // Android parity: mark all messages as read when leaving dialog. + // Android's unmount callback does SQL UPDATE messages SET read = 1. + // Don't re-send read receipt — it was already sent during the session. + DialogRepository.shared.markAsRead(opponentKey: route.publicKey) + MessageRepository.shared.markIncomingAsRead( + opponentKey: route.publicKey, myPublicKey: currentPublicKey + ) MessageRepository.shared.setDialogActive(route.publicKey, isActive: false) // Desktop parity: save draft text on chat close. DraftManager.shared.saveDraft(for: route.publicKey, text: messageText) @@ -281,13 +287,15 @@ struct ChatDetailView: View { } } .fullScreenCover(isPresented: Binding( - get: { fullScreenAttachmentId != nil }, - set: { if !$0 { fullScreenAttachmentId = nil } } + get: { imageViewerState != nil }, + set: { if !$0 { imageViewerState = nil } } )) { - FullScreenImageFromCache( - attachmentId: fullScreenAttachmentId ?? "", - onDismiss: { fullScreenAttachmentId = nil } - ) + if let state = imageViewerState { + ImageGalleryViewer( + state: state, + onDismiss: { imageViewerState = nil } + ) + } } .alert("Delete Message", isPresented: Binding( get: { messageToDelete != nil }, @@ -305,6 +313,30 @@ struct ChatDetailView: View { } message: { Text("Are you sure you want to delete this message? This action cannot be undone.") } + .alert("No Avatar", isPresented: $showNoAvatarAlert) { + Button("OK", role: .cancel) {} + } message: { + Text("Set a profile photo in Settings to share it with contacts.") + } + .sheet(isPresented: $showAttachmentPanel) { + AttachmentPanelView( + onSend: { attachments, caption in + // Pre-fill caption as message text (sent alongside attachments) + let trimmedCaption = caption.trimmingCharacters(in: .whitespaces) + if !trimmedCaption.isEmpty { + messageText = trimmedCaption + } + handleAttachmentsSend(attachments) + }, + onSendAvatar: { + sendAvatarToChat() + }, + hasAvatar: AvatarRepository.shared.loadAvatar(publicKey: currentPublicKey) != nil, + onSetAvatar: { + showNoAvatarAlert = true + } + ) + } } } @@ -673,9 +705,10 @@ private extension ChatDetailView { .onAppear { withAnimation(.easeOut(duration: 0.2)) { isAtBottom = true } } .onDisappear { withAnimation(.easeOut(duration: 0.2)) { isAtBottom = false } } - // PERF: use message.id as ForEach identity (stable). - // Integer indices shift on every insert, forcing full diff. - ForEach(Array(messages.enumerated()).reversed(), id: \.element.id) { index, message in + // PERF: iterate reversed messages directly, avoid Array(enumerated()) allocation. + // Use message.id identity (stable) — integer indices shift on insert. + ForEach(messages.reversed()) { message in + let index = messageIndex(for: message.id) let position = bubblePosition(for: index) messageRow( message, @@ -717,10 +750,14 @@ private extension ChatDetailView { } shouldScrollOnNextMessage = false } - } - .onChange(of: isInputFocused) { _, focused in - guard focused else { return } - SessionManager.shared.recordUserInteraction() + // Android parity: markVisibleMessagesAsRead — when new incoming + // messages appear while chat is open, mark as read and send receipt. + // Safe to call repeatedly: markAsRead guards unreadCount > 0, + // sendReadReceipt deduplicates by timestamp. + if isViewActive && !lastIsOutgoing + && !route.isSavedMessages && !route.isSystemAccount { + markDialogAsRead() + } } // Scroll-to-reply: navigate to the original message and highlight it briefly. .onChange(of: scrollToMessageId) { _, targetId in @@ -828,49 +865,206 @@ private extension ChatDetailView { let messageText = message.text.isEmpty ? " " : message.text let replyAttachment = message.attachments.first(where: { $0.type == .messages }) let replyData = replyAttachment.flatMap { parseReplyBlob($0.blob) }?.first + // Forward detection: text is empty/space, but has a MESSAGES attachment with data. + let isForward = message.text.trimmingCharacters(in: .whitespaces).isEmpty && replyData != nil + + if isForward, let reply = replyData { + forwardedMessageBubble(message: message, reply: reply, outgoing: outgoing, hasTail: hasTail, maxBubbleWidth: maxBubbleWidth, position: position) + } else { + VStack(alignment: .leading, spacing: 0) { + // Reply quote (if present, not a forward) + if let reply = replyData { + replyQuoteView(reply: reply, outgoing: outgoing) + } + + // Telegram-style compact bubble: inline time+status at bottom-trailing. + Text(parsedMarkdown(messageText)) + .font(.system(size: 17, weight: .regular)) + .tracking(-0.43) + .foregroundStyle(outgoing ? Color.white : RosettaColors.Adaptive.text) + .multilineTextAlignment(.leading) + .lineSpacing(0) + .fixedSize(horizontal: false, vertical: true) + .padding(.leading, 11) + .padding(.trailing, outgoing ? 64 : 48) + .padding(.vertical, 5) + } + .frame(minWidth: outgoing ? 86 : 66, alignment: .leading) + .overlay(alignment: .bottomTrailing) { + timestampOverlay(message: message, outgoing: outgoing) + } + .padding(.trailing, outgoing && hasTail ? MessageBubbleShape.tailProtrusion : 0) + .padding(.leading, !outgoing && hasTail ? MessageBubbleShape.tailProtrusion : 0) + .background { bubbleBackground(outgoing: outgoing, position: position) } + .overlay { + BubbleContextMenuOverlay( + actions: bubbleActions(for: message), + previewShape: MessageBubbleShape(position: position, outgoing: outgoing), + readStatusText: contextMenuReadStatus(for: message), + replyQuoteHeight: replyData != nil ? 46 : 0, + onReplyQuoteTap: replyData.map { reply in + { [reply] in self.scrollToMessageId = reply.message_id } + } + ) + } + .frame(maxWidth: maxBubbleWidth, alignment: outgoing ? .trailing : .leading) + } + } + + // MARK: - Forwarded Message Bubble (Telegram-style) + + /// Renders a forwarded message with "Forwarded from" header, small avatar, sender name, + /// optional image/file previews, and the forwarded text as the main bubble content. + /// Android parity: `ForwardedMessagesBubble` + `ForwardedImagePreview` in ChatDetailComponents.kt. + @ViewBuilder + private func forwardedMessageBubble( + message: ChatMessage, + reply: ReplyMessageData, + outgoing: Bool, + hasTail: Bool, + maxBubbleWidth: CGFloat, + position: BubblePosition + ) -> some View { + let senderName = senderDisplayName(for: reply.publicKey) + let senderInitials = RosettaColors.initials(name: senderName, publicKey: reply.publicKey) + let senderColorIndex = RosettaColors.avatarColorIndex(for: senderName, publicKey: reply.publicKey) + let senderAvatar = AvatarRepository.shared.loadAvatar(publicKey: reply.publicKey) + + // Categorize forwarded attachments (inside the ReplyMessageData, NOT on message itself). + let imageAttachments = reply.attachments.filter { $0.type == 0 } + let fileAttachments = reply.attachments.filter { $0.type == 2 } + let hasVisualAttachments = !imageAttachments.isEmpty || !fileAttachments.isEmpty + + // Text: show as caption below visual attachments, or as main content if no attachments. + let hasCaption = !reply.message.trimmingCharacters(in: .whitespaces).isEmpty + + // Fallback label when no visual attachments and no text. + let fallbackText: String = { + if hasCaption { return reply.message } + if !imageAttachments.isEmpty { return hasVisualAttachments ? "" : "Photo" } + if let file = fileAttachments.first { return file.id.isEmpty ? "File" : file.id } + if reply.attachments.contains(where: { $0.type == 3 }) { return "Avatar" } + return " " + }() + + let imageContentWidth = maxBubbleWidth - 22 - (outgoing && hasTail ? MessageBubbleShape.tailProtrusion : 0) - (!outgoing && hasTail ? MessageBubbleShape.tailProtrusion : 0) VStack(alignment: .leading, spacing: 0) { - // Reply/forward quote (if present) - if let reply = replyData { - replyQuoteView(reply: reply, outgoing: outgoing) + // "Forwarded from" label + Text("Forwarded from") + .font(.system(size: 13, weight: .regular)) + .foregroundStyle(outgoing ? Color.white.opacity(0.5) : RosettaColors.Adaptive.textSecondary) + .padding(.leading, 11) + .padding(.top, 6) + + // Avatar + sender name + HStack(spacing: 6) { + AvatarView( + initials: senderInitials, + colorIndex: senderColorIndex, + size: 20, + image: senderAvatar + ) + Text(senderName) + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(outgoing ? Color.white : RosettaColors.figmaBlue) + .lineLimit(1) + } + .padding(.leading, 11) + .padding(.top, 3) + + // Forwarded image attachments — blurhash thumbnails (Android parity: ForwardedImagePreview). + ForEach(Array(imageAttachments.enumerated()), id: \.element.id) { _, att in + forwardedImagePreview(attachment: att, width: imageContentWidth, outgoing: outgoing) + .padding(.horizontal, 6) + .padding(.top, 4) } - // Telegram-style compact bubble: inline time+status at bottom-trailing. - // Right padding reserves space for the timestamp overlay (64pt outgoing, 48pt incoming). - Text(parsedMarkdown(messageText)) - .font(.system(size: 17, weight: .regular)) - .tracking(-0.43) - .foregroundStyle(outgoing ? Color.white : RosettaColors.Adaptive.text) - .multilineTextAlignment(.leading) - .lineSpacing(0) - .fixedSize(horizontal: false, vertical: true) - .padding(.leading, 11) - .padding(.trailing, outgoing ? 64 : 48) - .padding(.vertical, 5) + // Forwarded file attachments. + ForEach(Array(fileAttachments.enumerated()), id: \.element.id) { _, att in + forwardedFilePreview(attachment: att, outgoing: outgoing) + .padding(.horizontal, 6) + .padding(.top, 4) + } + + // Caption text (if original message had text) or fallback label. + if hasCaption { + Text(parsedMarkdown(reply.message)) + .font(.system(size: 17, weight: .regular)) + .tracking(-0.43) + .foregroundStyle(outgoing ? Color.white : RosettaColors.Adaptive.text) + .multilineTextAlignment(.leading) + .lineSpacing(0) + .fixedSize(horizontal: false, vertical: true) + .padding(.leading, 11) + .padding(.trailing, outgoing ? 64 : 48) + .padding(.top, 3) + .padding(.bottom, 5) + } else if !hasVisualAttachments { + // No attachments and no text — show fallback. + Text(fallbackText) + .font(.system(size: 17, weight: .regular)) + .tracking(-0.43) + .foregroundStyle(outgoing ? Color.white.opacity(0.7) : RosettaColors.Adaptive.textSecondary) + .padding(.leading, 11) + .padding(.trailing, outgoing ? 64 : 48) + .padding(.top, 3) + .padding(.bottom, 5) + } else { + // Visual attachments shown but no caption — just add bottom padding for timestamp. + Spacer().frame(height: 5) + } } .frame(minWidth: outgoing ? 86 : 66, alignment: .leading) .overlay(alignment: .bottomTrailing) { timestampOverlay(message: message, outgoing: outgoing) } - // Tail protrusion space: the unified shape draws the tail in this padding area .padding(.trailing, outgoing && hasTail ? MessageBubbleShape.tailProtrusion : 0) .padding(.leading, !outgoing && hasTail ? MessageBubbleShape.tailProtrusion : 0) - // Single unified background: body + tail drawn in one fill (no seam) .background { bubbleBackground(outgoing: outgoing, position: position) } .overlay { BubbleContextMenuOverlay( actions: bubbleActions(for: message), previewShape: MessageBubbleShape(position: position, outgoing: outgoing), - readStatusText: contextMenuReadStatus(for: message), - replyQuoteHeight: replyData != nil ? 46 : 0, - onReplyQuoteTap: replyData.map { reply in - { [reply] in self.scrollToMessageId = reply.message_id } - } + readStatusText: contextMenuReadStatus(for: message) ) } .frame(maxWidth: maxBubbleWidth, alignment: outgoing ? .trailing : .leading) } + /// Wrapper that delegates to `ForwardedImagePreviewCell` — a proper View struct with + /// `@State` so the image updates when `AttachmentCache` is populated by `MessageImageView`. + @ViewBuilder + private func forwardedImagePreview(attachment: ReplyAttachmentData, width: CGFloat, outgoing: Bool) -> some View { + ForwardedImagePreviewCell( + attachment: attachment, + width: width, + outgoing: outgoing, + onTapCachedImage: { openImageViewer(attachmentId: attachment.id) } + ) + } + + /// File attachment preview inside a forwarded message bubble. + @ViewBuilder + private func forwardedFilePreview(attachment: ReplyAttachmentData, outgoing: Bool) -> some View { + let filename = attachment.id.isEmpty ? "File" : attachment.id + HStack(spacing: 8) { + Image(systemName: "doc.fill") + .font(.system(size: 20)) + .foregroundStyle(outgoing ? Color.white.opacity(0.7) : RosettaColors.figmaBlue) + Text(filename) + .font(.system(size: 14, weight: .medium)) + .foregroundStyle(outgoing ? Color.white : RosettaColors.Adaptive.text) + .lineLimit(1) + } + .padding(.horizontal, 8) + .padding(.vertical, 6) + .background( + RoundedRectangle(cornerRadius: 8) + .fill(outgoing ? Color.white.opacity(0.08) : Color.white.opacity(0.06)) + ) + } + /// PERF: static cache for decoded reply blobs — avoids JSON decode on every re-render. @MainActor private static var replyBlobCache: [String: [ReplyMessageData]] = [:] @@ -880,7 +1074,10 @@ private extension ChatDetailView { if let cached = Self.replyBlobCache[blob] { return cached } guard let data = blob.data(using: .utf8) else { return nil } guard let result = try? JSONDecoder().decode([ReplyMessageData].self, from: data) else { return nil } - if Self.replyBlobCache.count > 200 { Self.replyBlobCache.removeAll(keepingCapacity: true) } + if Self.replyBlobCache.count > 300 { + let keysToRemove = Array(Self.replyBlobCache.keys.prefix(150)) + for key in keysToRemove { Self.replyBlobCache.removeValue(forKey: key) } + } Self.replyBlobCache[blob] = result return result } @@ -910,15 +1107,11 @@ private extension ChatDetailView { .frame(width: 3) .padding(.vertical, 4) - // Optional image thumbnail for media replies (32×32) - // PERF: uses static cache — BlurHash decode is expensive (DCT transform). - if let hash = blurHash, - let image = Self.cachedBlurHash(hash, width: 32, height: 32) { - Image(uiImage: image) - .resizable() - .scaledToFill() - .frame(width: 32, height: 32) - .clipShape(RoundedRectangle(cornerRadius: 4)) + // Optional image thumbnail for media replies (32×32). + // Uses ReplyQuoteThumbnail struct with @State + .task to check AttachmentCache + // first (shows actual image), falling back to blurhash if not cached. + if let att = imageAttachment { + ReplyQuoteThumbnail(attachment: att, blurHash: blurHash) .padding(.leading, 6) } @@ -948,9 +1141,14 @@ private extension ChatDetailView { .padding(.bottom, 0) } + /// PERF: static cache for sender display names — avoids DialogRepository read per cell render. + /// DialogRepository is @Observable; reading `.dialogs[key]` in the body path creates observation + /// on the entire dictionary, causing re-render cascades on any dialog mutation. + @MainActor private static var senderNameCache: [String: String] = [:] + /// Resolves a public key to a display name for reply/forward quotes. - /// PERF: avoids reading DialogRepository.shared.dialogs (Observable) in the - /// body path — uses route data instead. Only the current opponent is resolved. + /// Checks: current user → "You", current opponent → route.title, any known dialog → title (cached). + /// Falls back to truncated public key if unknown. private func senderDisplayName(for publicKey: String) -> String { if publicKey == currentPublicKey { return "You" @@ -959,7 +1157,33 @@ private extension ChatDetailView { if publicKey == route.publicKey { return route.title.isEmpty ? String(publicKey.prefix(8)) + "…" : route.title } - return String(publicKey.prefix(8)) + "…" + // PERF: cached lookup — avoids creating @Observable tracking on DialogRepository.dialogs + // in the per-cell render path. Cache is populated once per contact, valid for session. + if let cached = Self.senderNameCache[publicKey] { + return cached + } + if let dialog = DialogRepository.shared.dialogs[publicKey], + !dialog.opponentTitle.isEmpty { + Self.senderNameCache[publicKey] = dialog.opponentTitle + return dialog.opponentTitle + } + let fallback = String(publicKey.prefix(8)) + "…" + Self.senderNameCache[publicKey] = fallback + return fallback + } + + /// PERF: single-pass partition of attachments into image vs non-image. + /// Avoids 3 separate .filter() calls per cell in @ViewBuilder context. + private static func partitionAttachments( + _ attachments: [MessageAttachment] + ) -> (images: [MessageAttachment], others: [MessageAttachment]) { + var images: [MessageAttachment] = [] + var others: [MessageAttachment] = [] + for att in attachments { + if att.type == .image { images.append(att) } + else { others.append(att) } + } + return (images, others) } /// Attachment message bubble: images/files with optional text caption. @@ -978,8 +1202,10 @@ private extension ChatDetailView { position: BubblePosition ) -> some View { let hasCaption = !message.text.trimmingCharacters(in: .whitespaces).isEmpty && message.text != " " - let imageAttachments = attachments.filter { $0.type == .image } - let otherAttachments = attachments.filter { $0.type != .image } + // PERF: single-pass partition instead of 3 separate .filter() calls per cell. + let partitioned = Self.partitionAttachments(attachments) + let imageAttachments = partitioned.images + let otherAttachments = partitioned.others let isImageOnly = !imageAttachments.isEmpty && otherAttachments.isEmpty && !hasCaption VStack(alignment: .leading, spacing: 0) { @@ -1051,10 +1277,24 @@ private extension ChatDetailView { actions: bubbleActions(for: message), previewShape: MessageBubbleShape(position: position, outgoing: outgoing), readStatusText: contextMenuReadStatus(for: message), - onTap: !imageAttachments.isEmpty ? { - // Open the first image attachment in fullscreen viewer - if let firstImage = imageAttachments.first { - fullScreenAttachmentId = firstImage.id + onTap: !attachments.isEmpty ? { tapLocation in + // All taps go through the overlay (UIView blocks SwiftUI below). + // Route to the correct handler based on what was tapped. + if !imageAttachments.isEmpty { + let tappedId = imageAttachments.count == 1 + ? imageAttachments[0].id + : collageAttachmentId(at: tapLocation, attachments: imageAttachments, maxWidth: maxBubbleWidth) + if AttachmentCache.shared.loadImage(forAttachmentId: tappedId) != nil { + openImageViewer(attachmentId: tappedId) + } else { + // Image not cached — trigger download via notification. + NotificationCenter.default.post(name: .triggerAttachmentDownload, object: tappedId) + } + } else { + // No images — tap is on file/avatar area. + for att in otherAttachments { + NotificationCenter.default.post(name: .triggerAttachmentDownload, object: att.id) + } } } : nil ) @@ -1117,11 +1357,14 @@ private extension ChatDetailView { @MainActor private static var blurHashCache: [String: UIImage] = [:] @MainActor - private static func cachedBlurHash(_ hash: String, width: Int, height: Int) -> UIImage? { + static func cachedBlurHash(_ hash: String, width: Int, height: Int) -> UIImage? { let key = "\(hash)_\(width)x\(height)" if let cached = blurHashCache[key] { return cached } guard let image = UIImage.fromBlurHash(hash, width: width, height: height) else { return nil } - if blurHashCache.count > 100 { blurHashCache.removeAll(keepingCapacity: true) } + if blurHashCache.count > 300 { + let keysToRemove = Array(blurHashCache.keys.prefix(150)) + for key in keysToRemove { blurHashCache.removeValue(forKey: key) } + } blurHashCache[key] = image return image } @@ -1155,8 +1398,10 @@ private extension ChatDetailView { } else { result = AttributedString(withEmoji) } - if Self.markdownCache.count > 200 { - Self.markdownCache.removeAll(keepingCapacity: true) + // PERF: evict oldest half instead of clearing all — preserves hot entries during scroll. + if Self.markdownCache.count > 500 { + let keysToRemove = Array(Self.markdownCache.keys.prefix(250)) + for key in keysToRemove { Self.markdownCache.removeValue(forKey: key) } } Self.markdownCache[text] = result return result @@ -1180,11 +1425,6 @@ private extension ChatDetailView { var composer: some View { VStack(spacing: 6) { - // Reply preview bar (Telegram-style) - if let replyMessage = replyingToMessage { - replyBar(for: replyMessage) - } - // Attachment preview strip — shows selected images/files before send if !pendingAttachments.isEmpty { AttachmentPreviewStrip(pendingAttachments: $pendingAttachments) @@ -1214,79 +1454,71 @@ private extension ChatDetailView { } .accessibilityLabel("Attach") .buttonStyle(ChatDetailGlassPressButtonStyle()) - .sheet(isPresented: $showAttachmentPanel) { - AttachmentPanelView( - onSend: { attachments, caption in - // Pre-fill caption as message text (sent alongside attachments) - let trimmedCaption = caption.trimmingCharacters(in: .whitespaces) - if !trimmedCaption.isEmpty { - messageText = trimmedCaption - } - handleAttachmentsSend(attachments) - }, - onSendAvatar: { - sendAvatarToChat() - } - ) - } - HStack(alignment: .bottom, spacing: 0) { - ChatTextInput( - text: $messageText, - isFocused: $isInputFocused, - onKeyboardHeightChange: { height in - KeyboardTracker.shared.updateFromKVO(keyboardHeight: height) - }, - onUserTextInsertion: handleComposerUserTyping, - textColor: UIColor(RosettaColors.Adaptive.text), - placeholderColor: UIColor(RosettaColors.Adaptive.textSecondary.opacity(0.5)) - ) - .padding(.leading, 6) - .frame(maxWidth: .infinity, minHeight: 36, alignment: .bottomLeading) - - HStack(alignment: .center, spacing: 0) { - Button { } label: { - TelegramVectorIcon( - pathData: TelegramIconPath.emojiMoon, - viewBox: CGSize(width: 19, height: 19), - color: RosettaColors.Adaptive.textSecondary - ) - .frame(width: 19, height: 19) - .frame(width: 20, height: 36) - } - .accessibilityLabel("Quick actions") - .buttonStyle(ChatDetailGlassPressButtonStyle()) + VStack(spacing: 0) { + // Reply preview bar — inside the glass container + if let replyMessage = replyingToMessage { + replyBar(for: replyMessage) } - .padding(.trailing, 8 + (sendButtonWidth * sendButtonProgress)) - .frame(height: 36, alignment: .center) - .overlay(alignment: .trailing) { - Button(action: sendCurrentMessage) { - TelegramVectorIcon( - pathData: TelegramIconPath.sendPlane, - viewBox: CGSize(width: 22, height: 19), - color: .white - ) - .opacity(0.42 + (0.58 * sendButtonProgress)) - .scaleEffect(0.72 + (0.28 * sendButtonProgress)) - .frame(width: 22, height: 19) - .frame(width: sendButtonWidth, height: sendButtonHeight) - .background { Capsule().fill(Color(hex: 0x008BFF)) } - } - .accessibilityLabel("Send") - .disabled(!canSend) - .buttonStyle(ChatDetailGlassPressButtonStyle()) - .allowsHitTesting(shouldShowSendButton) - .opacity(Double(sendButtonProgress)) - .scaleEffect(0.74 + (0.26 * sendButtonProgress), anchor: .trailing) - .blur(radius: (1 - sendButtonProgress) * 2.1) - .mask( - Capsule() - .frame( - width: max(0.001, sendButtonWidth * sendButtonProgress), - height: max(0.001, sendButtonHeight * sendButtonProgress) - ) - .frame(width: sendButtonWidth, height: sendButtonHeight, alignment: .trailing) + + HStack(alignment: .bottom, spacing: 0) { + ChatTextInput( + text: $messageText, + isFocused: $isInputFocused, + onKeyboardHeightChange: { height in + KeyboardTracker.shared.updateFromKVO(keyboardHeight: height) + }, + onUserTextInsertion: handleComposerUserTyping, + textColor: UIColor(RosettaColors.Adaptive.text), + placeholderColor: UIColor(RosettaColors.Adaptive.textSecondary.opacity(0.5)) ) + .padding(.leading, 6) + .frame(maxWidth: .infinity, minHeight: 36, alignment: .bottomLeading) + + HStack(alignment: .center, spacing: 0) { + Button { } label: { + TelegramVectorIcon( + pathData: TelegramIconPath.emojiMoon, + viewBox: CGSize(width: 19, height: 19), + color: RosettaColors.Adaptive.textSecondary + ) + .frame(width: 19, height: 19) + .frame(width: 20, height: 36) + } + .accessibilityLabel("Quick actions") + .buttonStyle(ChatDetailGlassPressButtonStyle()) + } + .padding(.trailing, 8 + (sendButtonWidth * sendButtonProgress)) + .frame(height: 36, alignment: .center) + .overlay(alignment: .trailing) { + Button(action: sendCurrentMessage) { + TelegramVectorIcon( + pathData: TelegramIconPath.sendPlane, + viewBox: CGSize(width: 22, height: 19), + color: .white + ) + .opacity(0.42 + (0.58 * sendButtonProgress)) + .scaleEffect(0.72 + (0.28 * sendButtonProgress)) + .frame(width: 22, height: 19) + .frame(width: sendButtonWidth, height: sendButtonHeight) + .background { Capsule().fill(Color(hex: 0x008BFF)) } + } + .accessibilityLabel("Send") + .disabled(!canSend) + .buttonStyle(ChatDetailGlassPressButtonStyle()) + .allowsHitTesting(shouldShowSendButton) + .opacity(Double(sendButtonProgress)) + .scaleEffect(0.74 + (0.26 * sendButtonProgress), anchor: .trailing) + .blur(radius: (1 - sendButtonProgress) * 2.1) + .mask( + Capsule() + .frame( + width: max(0.001, sendButtonWidth * sendButtonProgress), + height: max(0.001, sendButtonHeight * sendButtonProgress) + ) + .frame(width: sendButtonWidth, height: sendButtonHeight, alignment: .trailing) + ) + } } } .padding(3) @@ -1328,6 +1560,26 @@ private extension ChatDetailView { } } + // MARK: - Message Index Lookup + + /// PERF: O(1) index lookup via cached dictionary. Rebuilt lazily when messages change. + /// Avoids O(n) `firstIndex(where:)` per cell in reversed ForEach. + @MainActor private static var messageIndexCache: [String: Int] = [:] + @MainActor private static var messageIndexCacheKey: String = "" + + private func messageIndex(for messageId: String) -> Int { + // Rebuild cache if messages array changed (first+last+count fingerprint). + let cacheKey = "\(messages.count)_\(messages.first?.id ?? "")_\(messages.last?.id ?? "")" + if Self.messageIndexCacheKey != cacheKey { + Self.messageIndexCache.removeAll(keepingCapacity: true) + for (i, msg) in messages.enumerated() { + Self.messageIndexCache[msg.id] = i + } + Self.messageIndexCacheKey = cacheKey + } + return Self.messageIndexCache[messageId] ?? 0 + } + // MARK: - Bubble Position (Figma: Single / Top / Mid / Bottom) /// Determines bubble position within a group of consecutive same-sender plain-text messages. @@ -1391,21 +1643,14 @@ private extension ChatDetailView { .glassEffect(.regular, in: RoundedRectangle(cornerRadius: radius, style: .continuous)) } } else { - // iOS < 26: frosted glass with stroke + shadow (Figma spec) + // iOS < 26: Telegram glass (CABackdropLayer + blur 2.0 + dark foreground) switch shape { case .capsule: - Capsule().fill(.thinMaterial) - .overlay { Capsule().strokeBorder(strokeColor.opacity(strokeOpacity), lineWidth: 0.5) } - .shadow(color: .black.opacity(0.12), radius: 20, y: 8) + TelegramGlassCapsule() case .circle: - Circle().fill(.thinMaterial) - .overlay { Circle().strokeBorder(strokeColor.opacity(strokeOpacity), lineWidth: 0.5) } - .shadow(color: .black.opacity(0.12), radius: 20, y: 8) + TelegramGlassCircle() case let .rounded(radius): - let r = RoundedRectangle(cornerRadius: radius, style: .continuous) - r.fill(.thinMaterial) - .overlay { r.strokeBorder(strokeColor.opacity(strokeOpacity), lineWidth: 0.5) } - .shadow(color: .black.opacity(0.12), radius: 20, y: 8) + TelegramGlassRoundedRect(cornerRadius: radius) } } } @@ -1642,6 +1887,65 @@ private extension ChatDetailView { return actions } + /// Determines which attachment was tapped in a photo collage based on tap location. + /// Mirrors the layout logic in PhotoCollageView (spacing=2, same proportions). + func collageAttachmentId(at point: CGPoint, attachments: [MessageAttachment], maxWidth: CGFloat) -> String { + let spacing: CGFloat = 2 + let count = attachments.count + let x = point.x + let y = point.y + + switch count { + case 2: + let half = (maxWidth - spacing) / 2 + return attachments[x < half ? 0 : 1].id + + case 3: + let rightWidth = maxWidth * 0.34 + let leftWidth = maxWidth - spacing - rightWidth + let totalHeight = min(leftWidth * 1.1, 300) + let rightCellHeight = (totalHeight - spacing) / 2 + if x < leftWidth { + return attachments[0].id + } else { + return attachments[y < rightCellHeight ? 1 : 2].id + } + + case 4: + let half = (maxWidth - spacing) / 2 + let cellHeight = min(half * 0.85, 150) + let row = y < cellHeight ? 0 : 1 + let col = x < half ? 0 : 1 + return attachments[row * 2 + col].id + + case 5: + let topCellWidth = (maxWidth - spacing) / 2 + let bottomCellWidth = (maxWidth - spacing * 2) / 3 + let topHeight = min(topCellWidth * 0.85, 165) + if y < topHeight { + return attachments[x < topCellWidth ? 0 : 1].id + } else { + let col = min(Int(x / (bottomCellWidth + spacing)), 2) + return attachments[2 + col].id + } + + default: + return attachments[0].id + } + } + + /// Collects all image attachment IDs from the current chat and opens the gallery. + func openImageViewer(attachmentId: String) { + var allImageIds: [String] = [] + for message in messages { + for attachment in message.attachments where attachment.type == .image { + allImageIds.append(attachment.id) + } + } + let index = allImageIds.firstIndex(of: attachmentId) ?? 0 + imageViewerState = ImageViewerState(attachmentIds: allImageIds, initialIndex: index) + } + func retryMessage(_ message: ChatMessage) { let text = message.text let toKey = message.toPublicKey @@ -1661,26 +1965,44 @@ private extension ChatDetailView { @ViewBuilder func replyBar(for message: ChatMessage) -> some View { + // PERF: use route.title (non-observable) instead of dialog?.opponentTitle. + // Reading `dialog` here creates @Observable tracking on DialogRepository in the + // composer's render path, which is part of the main body. let senderName = message.isFromMe(myPublicKey: currentPublicKey) ? "You" - : (dialog?.opponentTitle ?? route.title) - let previewText = message.text.isEmpty - ? (message.attachments.isEmpty ? "" : "Attachment") - : message.text + : (route.title.isEmpty ? String(route.publicKey.prefix(8)) + "…" : route.title) + let previewText: String = { + let trimmed = message.text.trimmingCharacters(in: .whitespaces) + if !trimmed.isEmpty { return message.text } + if message.attachments.contains(where: { $0.type == .image }) { return "Photo" } + if let file = message.attachments.first(where: { $0.type == .file }) { + return file.id.isEmpty ? "File" : file.id + } + if message.attachments.contains(where: { $0.type == .avatar }) { return "Avatar" } + if message.attachments.contains(where: { $0.type == .messages }) { return "Forwarded message" } + if !message.attachments.isEmpty { return "Attachment" } + return "" + }() HStack(spacing: 0) { RoundedRectangle(cornerRadius: 1.5) .fill(RosettaColors.figmaBlue) .frame(width: 3, height: 36) - VStack(alignment: .leading, spacing: 1) { - Text(senderName) - .font(.system(size: 14, weight: .semibold)) - .foregroundStyle(RosettaColors.figmaBlue) - .lineLimit(1) + VStack(alignment: .leading, spacing: 2) { + HStack(spacing: 0) { + Text("Reply to ") + .font(.system(size: 14, weight: .medium)) + .foregroundStyle(RosettaColors.Adaptive.textSecondary) + Text(senderName) + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(RosettaColors.figmaBlue) + } + .lineLimit(1) + Text(previewText) .font(.system(size: 14, weight: .regular)) - .foregroundStyle(RosettaColors.Adaptive.textSecondary) + .foregroundStyle(RosettaColors.Adaptive.text) .lineLimit(1) } .padding(.leading, 8) @@ -1693,12 +2015,15 @@ private extension ChatDetailView { } } label: { Image(systemName: "xmark") - .font(.system(size: 12, weight: .semibold)) + .font(.system(size: 14, weight: .medium)) .foregroundStyle(RosettaColors.Adaptive.textSecondary) .frame(width: 30, height: 30) } } - .padding(.horizontal, 16) + .padding(.leading, 6) + .padding(.trailing, 4) + .padding(.top, 6) + .padding(.bottom, 4) .transition(.move(edge: .bottom).combined(with: .opacity)) } @@ -1755,7 +2080,11 @@ private extension ChatDetailView { func messageTime(_ timestamp: Int64) -> String { if let cached = Self.timeCache[timestamp] { return cached } let result = Self.timeFormatter.string(from: Date(timeIntervalSince1970: Double(timestamp) / 1000)) - if Self.timeCache.count > 200 { Self.timeCache.removeAll(keepingCapacity: true) } + // PERF: evict half instead of clearing all — timestamps are reused during scroll. + if Self.timeCache.count > 500 { + let keysToRemove = Array(Self.timeCache.keys.prefix(250)) + for key in keysToRemove { Self.timeCache.removeValue(forKey: key) } + } Self.timeCache[timestamp] = result return result } @@ -1823,8 +2152,6 @@ private extension ChatDetailView { // Must have either text or attachments guard !message.isEmpty || !attachments.isEmpty else { return } - // User is sending a message — reset idle timer. - SessionManager.shared.recordUserInteraction() shouldScrollOnNextMessage = true messageText = "" pendingAttachments = [] @@ -2030,6 +2357,137 @@ private struct DisableScrollEdgeEffectModifier: ViewModifier { } } +// MARK: - ForwardedImagePreviewCell + +/// Proper View struct for forwarded image previews — uses `@State` + `.task` so the image +/// updates when `AttachmentCache` is populated by `MessageImageView` downloading the original. +/// Without this, a plain `let cachedImage = ...` in the parent body is a one-shot evaluation +/// that never re-checks the cache. +private struct ForwardedImagePreviewCell: View { + let attachment: ReplyAttachmentData + let width: CGFloat + let outgoing: Bool + let onTapCachedImage: () -> Void + + @State private var cachedImage: UIImage? + @State private var blurImage: UIImage? + + private var imageHeight: CGFloat { min(width * 0.75, 200) } + + var body: some View { + Group { + if let image = cachedImage { + Image(uiImage: image) + .resizable() + .scaledToFill() + .frame(width: width, height: imageHeight) + .clipped() + .clipShape(RoundedRectangle(cornerRadius: 8)) + .contentShape(Rectangle()) + .onTapGesture { onTapCachedImage() } + } else if let blur = blurImage { + Image(uiImage: blur) + .resizable() + .scaledToFill() + .frame(width: width, height: imageHeight) + .clipped() + .clipShape(RoundedRectangle(cornerRadius: 8)) + } else { + // No image at all — show placeholder. + RoundedRectangle(cornerRadius: 8) + .fill(outgoing ? Color.white.opacity(0.08) : Color.white.opacity(0.06)) + .frame(width: width, height: imageHeight) + .overlay { + Image(systemName: "photo") + .font(.system(size: 24)) + .foregroundStyle( + outgoing + ? Color.white.opacity(0.3) + : RosettaColors.Adaptive.textSecondary.opacity(0.5) + ) + } + } + } + .task { + // Decode blurhash from preview field ("cdnTag::blurhash" or just "blurhash"). + if let hash = extractBlurHash(), !hash.isEmpty { + blurImage = ChatDetailView.cachedBlurHash(hash, width: 64, height: 64) + } + + // Check cache immediately — image may already be there. + if let img = AttachmentCache.shared.loadImage(forAttachmentId: attachment.id) { + cachedImage = img + return + } + + // Retry: the original MessageImageView may still be downloading. + // Poll up to 5 times with 500ms intervals (2.5s total) — covers most download durations. + for _ in 0..<5 { + try? await Task.sleep(for: .milliseconds(500)) + if Task.isCancelled { return } + if let img = AttachmentCache.shared.loadImage(forAttachmentId: attachment.id) { + cachedImage = img + return + } + } + } + } + + private func extractBlurHash() -> String? { + guard !attachment.preview.isEmpty else { return nil } + let parts = attachment.preview.components(separatedBy: "::") + let hash = parts.count > 1 ? parts[1] : attachment.preview + return hash.isEmpty ? nil : hash + } +} + +// MARK: - ReplyQuoteThumbnail + +/// 32×32 thumbnail for reply quote views. Checks `AttachmentCache` first for the actual +/// downloaded image, falling back to blurhash. Uses `@State` + `.task` with retry polling +/// so the thumbnail updates when the original `MessageImageView` finishes downloading. +private struct ReplyQuoteThumbnail: View { + let attachment: ReplyAttachmentData + let blurHash: String? + + /// Actual cached image (from AttachmentCache). Overrides blurhash when available. + @State private var cachedImage: UIImage? + + var body: some View { + // Blurhash is computed synchronously (static cache) so it shows on the first frame. + // cachedImage overrides it when the real photo is available in AttachmentCache. + let image = cachedImage ?? blurHash.flatMap { + ChatDetailView.cachedBlurHash($0, width: 32, height: 32) + } + + Group { + if let image { + Image(uiImage: image) + .resizable() + .scaledToFill() + .frame(width: 32, height: 32) + .clipShape(RoundedRectangle(cornerRadius: 4)) + } + } + .task { + // Check AttachmentCache for the actual downloaded photo. + if let cached = AttachmentCache.shared.loadImage(forAttachmentId: attachment.id) { + cachedImage = cached + return + } + // Retry — image may be downloading in MessageImageView. + for _ in 0..<5 { + try? await Task.sleep(for: .milliseconds(500)) + if Task.isCancelled { return } + if let cached = AttachmentCache.shared.loadImage(forAttachmentId: attachment.id) { + cachedImage = cached + return + } + } + } + } +} + #Preview { NavigationStack { diff --git a/Rosetta/Features/Chats/ChatDetail/ForwardChatPickerView.swift b/Rosetta/Features/Chats/ChatDetail/ForwardChatPickerView.swift index 99dc6fa..8cc6082 100644 --- a/Rosetta/Features/Chats/ChatDetail/ForwardChatPickerView.swift +++ b/Rosetta/Features/Chats/ChatDetail/ForwardChatPickerView.swift @@ -6,9 +6,11 @@ struct ForwardChatPickerView: View { let onSelect: (ChatRoute) -> Void @Environment(\.dismiss) private var dismiss + /// Android parity: system accounts (Updates, Safe) excluded from forward picker. + /// Saved Messages allowed (forward to self). private var dialogs: [Dialog] { DialogRepository.shared.sortedDialogs.filter { - $0.iHaveSent || $0.isSavedMessages || SystemAccounts.isSystemAccount($0.opponentKey) + ($0.iHaveSent || $0.isSavedMessages) && !SystemAccounts.isSystemAccount($0.opponentKey) } } diff --git a/Rosetta/Features/Chats/ChatDetail/ImageGalleryViewer.swift b/Rosetta/Features/Chats/ChatDetail/ImageGalleryViewer.swift new file mode 100644 index 0000000..e3dccc4 --- /dev/null +++ b/Rosetta/Features/Chats/ChatDetail/ImageGalleryViewer.swift @@ -0,0 +1,169 @@ +import SwiftUI +import UIKit +import Photos + +// MARK: - Data Types + +/// State for the image gallery viewer. +struct ImageViewerState: Equatable { + let attachmentIds: [String] + let initialIndex: Int +} + +// MARK: - ImageGalleryViewer + +/// Telegram-style multi-photo gallery viewer with horizontal paging. +/// Android parity: `ImageViewerScreen.kt` — HorizontalPager, zoom-to-point, +/// velocity dismiss, page counter, share/save. +struct ImageGalleryViewer: View { + + let state: ImageViewerState + let onDismiss: () -> Void + + @State private var currentPage: Int + @State private var showControls = true + @State private var currentZoomScale: CGFloat = 1.0 + + init(state: ImageViewerState, onDismiss: @escaping () -> Void) { + self.state = state + self.onDismiss = onDismiss + self._currentPage = State(initialValue: state.initialIndex) + } + + var body: some View { + ZStack { + Color.black.ignoresSafeArea() + + // Pager + TabView(selection: $currentPage) { + ForEach(Array(state.attachmentIds.enumerated()), id: \.element) { index, attachmentId in + ZoomableImagePage( + attachmentId: attachmentId, + onDismiss: onDismiss, + showControls: $showControls, + currentScale: $currentZoomScale + ) + .tag(index) + } + } + .tabViewStyle(.page(indexDisplayMode: .never)) + .disabled(currentZoomScale > 1.05) + + // Controls overlay + if showControls { + controlsOverlay + .transition(.opacity) + } + } + .statusBarHidden(true) + .animation(.easeInOut(duration: 0.2), value: showControls) + .onChange(of: currentPage) { _, newPage in + prefetchAdjacentImages(around: newPage) + } + .onAppear { + prefetchAdjacentImages(around: state.initialIndex) + } + } + + // MARK: - Controls Overlay + + private var controlsOverlay: some View { + VStack { + // Top bar: close + counter — inside safe area to avoid notch/Dynamic Island overlap + HStack { + Button { onDismiss() } label: { + Image(systemName: "xmark") + .font(.system(size: 17, weight: .semibold)) + .foregroundStyle(.white) + .frame(width: 36, height: 36) + .background(Color.white.opacity(0.2)) + .clipShape(Circle()) + } + .padding(.leading, 16) + + Spacer() + + if state.attachmentIds.count > 1 { + Text("\(currentPage + 1) / \(state.attachmentIds.count)") + .font(.system(size: 15, weight: .medium)) + .foregroundStyle(.white.opacity(0.8)) + } + + Spacer() + + // Invisible spacer to balance the close button + Color.clear.frame(width: 36, height: 36) + .padding(.trailing, 16) + } + .padding(.top, 54) + + Spacer() + + // Bottom bar: share + save + HStack(spacing: 32) { + Button { shareCurrentImage() } label: { + Image(systemName: "square.and.arrow.up") + .font(.system(size: 20, weight: .medium)) + .foregroundStyle(.white) + .frame(width: 44, height: 44) + } + + Spacer() + + Button { saveCurrentImage() } label: { + Image(systemName: "square.and.arrow.down") + .font(.system(size: 20, weight: .medium)) + .foregroundStyle(.white) + .frame(width: 44, height: 44) + } + } + .padding(.horizontal, 24) + .padding(.bottom, 34) + } + } + + // MARK: - Actions + + private func shareCurrentImage() { + guard currentPage < state.attachmentIds.count, + let image = AttachmentCache.shared.loadImage(forAttachmentId: state.attachmentIds[currentPage]) + else { return } + + let activityVC = UIActivityViewController(activityItems: [image], applicationActivities: nil) + if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, + let root = windowScene.keyWindow?.rootViewController { + var presenter = root + while let presented = presenter.presentedViewController { + presenter = presented + } + activityVC.popoverPresentationController?.sourceView = presenter.view + activityVC.popoverPresentationController?.sourceRect = CGRect( + x: presenter.view.bounds.midX, y: presenter.view.bounds.maxY - 50, + width: 0, height: 0 + ) + presenter.present(activityVC, animated: true) + } + } + + private func saveCurrentImage() { + guard currentPage < state.attachmentIds.count, + let image = AttachmentCache.shared.loadImage(forAttachmentId: state.attachmentIds[currentPage]) + else { return } + + PHPhotoLibrary.requestAuthorization(for: .addOnly) { status in + guard status == .authorized || status == .limited else { return } + UIImageWriteToSavedPhotosAlbum(image, nil, nil, nil) + } + } + + // MARK: - Prefetch + + private func prefetchAdjacentImages(around index: Int) { + for offset in [-1, 1] { + let i = index + offset + guard i >= 0, i < state.attachmentIds.count else { continue } + // Touch cache to warm it (loads from disk if needed) + _ = AttachmentCache.shared.loadImage(forAttachmentId: state.attachmentIds[i]) + } + } +} diff --git a/Rosetta/Features/Chats/ChatDetail/MessageAvatarView.swift b/Rosetta/Features/Chats/ChatDetail/MessageAvatarView.swift index 6a46771..e2a4f22 100644 --- a/Rosetta/Features/Chats/ChatDetail/MessageAvatarView.swift +++ b/Rosetta/Features/Chats/ChatDetail/MessageAvatarView.swift @@ -72,10 +72,6 @@ struct MessageAvatarView: View { } .padding(.horizontal, 10) .padding(.vertical, 8) - .background( - RoundedRectangle(cornerRadius: 8) - .stroke(outgoing ? Color.white.opacity(0.15) : Color.white.opacity(0.1), lineWidth: 1) - ) .task { loadFromCache() if avatarImage == nil { diff --git a/Rosetta/Features/Chats/ChatDetail/MessageFileView.swift b/Rosetta/Features/Chats/ChatDetail/MessageFileView.swift index eb04bba..0cfbee3 100644 --- a/Rosetta/Features/Chats/ChatDetail/MessageFileView.swift +++ b/Rosetta/Features/Chats/ChatDetail/MessageFileView.swift @@ -73,16 +73,20 @@ struct MessageFileView: View { .padding(.vertical, 8) .frame(width: 220) .contentShape(Rectangle()) - .onTapGesture { - if isDownloaded, let url = cachedFileURL { - shareFile(url) - } else if !isDownloading { - downloadFile() - } - } .task { checkCache() } + // Download/share triggered by BubbleContextMenuOverlay tap → notification. + // Overlay UIView intercepts all taps; SwiftUI onTapGesture can't fire. + .onReceive(NotificationCenter.default.publisher(for: .triggerAttachmentDownload)) { notif in + if let id = notif.object as? String, id == attachment.id { + if isDownloaded, let url = cachedFileURL { + shareFile(url) + } else if !isDownloading { + downloadFile() + } + } + } } // MARK: - Metadata Parsing diff --git a/Rosetta/Features/Chats/ChatDetail/MessageImageView.swift b/Rosetta/Features/Chats/ChatDetail/MessageImageView.swift index bad5983..7fd2caa 100644 --- a/Rosetta/Features/Chats/ChatDetail/MessageImageView.swift +++ b/Rosetta/Features/Chats/ChatDetail/MessageImageView.swift @@ -62,14 +62,20 @@ struct MessageImageView: View { } else { placeholderView .overlay { downloadArrowOverlay } - .onTapGesture { downloadImage() } } } .task { - // Decode blurhash once (like Android's LaunchedEffect + Dispatchers.IO) - decodeBlurHash() + // PERF: load cached image FIRST — skip expensive BlurHash DCT decode + // if the full image is already available. loadFromCache() if image == nil { + decodeBlurHash() + } + } + // Download triggered by BubbleContextMenuOverlay tap → notification. + // Overlay UIView intercepts all taps; SwiftUI onTapGesture can't fire. + .onReceive(NotificationCenter.default.publisher(for: .triggerAttachmentDownload)) { notif in + if let id = notif.object as? String, id == attachment.id, image == nil { downloadImage() } } @@ -184,10 +190,23 @@ struct MessageImageView: View { /// Decodes the blurhash from the attachment preview string once and caches in @State. /// Android parity: `LaunchedEffect(preview) { BlurHash.decode(preview, 200, 200) }`. + /// PERF: static cache for decoded BlurHash images — shared across all instances. + /// Avoids redundant DCT decode when the same attachment appears in multiple re-renders. + @MainActor private static var blurHashCache: [String: UIImage] = [:] + private func decodeBlurHash() { let hash = extractBlurHash(from: attachment.preview) guard !hash.isEmpty else { return } + if let cached = Self.blurHashCache[hash] { + blurImage = cached + return + } if let result = UIImage.fromBlurHash(hash, width: 32, height: 32) { + if Self.blurHashCache.count > 200 { + let keysToRemove = Array(Self.blurHashCache.keys.prefix(100)) + for key in keysToRemove { Self.blurHashCache.removeValue(forKey: key) } + } + Self.blurHashCache[hash] = result blurImage = result } } diff --git a/Rosetta/Features/Chats/ChatDetail/OpponentProfileView.swift b/Rosetta/Features/Chats/ChatDetail/OpponentProfileView.swift index 7f3e4ac..1ae4b4a 100644 --- a/Rosetta/Features/Chats/ChatDetail/OpponentProfileView.swift +++ b/Rosetta/Features/Chats/ChatDetail/OpponentProfileView.swift @@ -224,9 +224,7 @@ struct OpponentProfileView: View { if #available(iOS 26.0, *) { Capsule().fill(.clear).glassEffect(.regular, in: .capsule) } else { - Capsule().fill(.thinMaterial) - .overlay { Capsule().strokeBorder(Color.white.opacity(0.22), lineWidth: 0.5) } - .shadow(color: .black.opacity(0.12), radius: 20, y: 8) + TelegramGlassCapsule() } } @@ -240,12 +238,7 @@ struct OpponentProfileView: View { in: RoundedRectangle(cornerRadius: 14, style: .continuous) ) } else { - RoundedRectangle(cornerRadius: 14, style: .continuous) - .fill(.ultraThinMaterial) - .overlay { - RoundedRectangle(cornerRadius: 14, style: .continuous) - .strokeBorder(Color.white.opacity(0.12), lineWidth: 0.5) - } + TelegramGlassRoundedRect(cornerRadius: 14) } } } diff --git a/Rosetta/Features/Chats/ChatDetail/PhotoPreviewView.swift b/Rosetta/Features/Chats/ChatDetail/PhotoPreviewView.swift index 79f6081..2374df9 100644 --- a/Rosetta/Features/Chats/ChatDetail/PhotoPreviewView.swift +++ b/Rosetta/Features/Chats/ChatDetail/PhotoPreviewView.swift @@ -228,10 +228,7 @@ struct PhotoPreviewView: View { .fill(.clear) .glassEffect(.regular, in: RoundedRectangle(cornerRadius: 21, style: .continuous)) } else { - let shape = RoundedRectangle(cornerRadius: 21, style: .continuous) - shape.fill(.thinMaterial) - .overlay { shape.strokeBorder(Color.white.opacity(0.18), lineWidth: 0.5) } - .shadow(color: .black.opacity(0.12), radius: 20, y: 8) + TelegramGlassRoundedRect(cornerRadius: 21) } } diff --git a/Rosetta/Features/Chats/ChatDetail/SwipeToReplyModifier.swift b/Rosetta/Features/Chats/ChatDetail/SwipeToReplyModifier.swift index 23e3703..2a06b67 100644 --- a/Rosetta/Features/Chats/ChatDetail/SwipeToReplyModifier.swift +++ b/Rosetta/Features/Chats/ChatDetail/SwipeToReplyModifier.swift @@ -17,15 +17,19 @@ struct SwipeToReplyModifier: ViewModifier { @State private var offset: CGFloat = 0 @State private var hasTriggeredHaptic = false @State private var lockedAxis: SwipeAxis? + /// Start X in global coordinates — reject if near left screen edge (back gesture zone). + @State private var gestureStartX: CGFloat? private enum SwipeAxis { case horizontal, vertical } /// Minimum drag distance to trigger reply action. - private let threshold: CGFloat = 50 + private let threshold: CGFloat = 55 /// Offset where elastic resistance begins. - private let elasticCap: CGFloat = 80 + private let elasticCap: CGFloat = 85 /// Reply icon circle diameter. private let iconSize: CGFloat = 34 + /// Ignore gestures starting within this distance from the left screen edge (iOS back gesture zone). + private let backGestureEdge: CGFloat = 40 func body(content: Content) -> some View { content @@ -65,18 +69,28 @@ struct SwipeToReplyModifier: ViewModifier { // MARK: - Gesture private var dragGesture: some Gesture { - DragGesture(minimumDistance: 16, coordinateSpace: .local) + DragGesture(minimumDistance: 20, coordinateSpace: .global) .onChanged { value in + // Record start position on first event. + if gestureStartX == nil { + gestureStartX = value.startLocation.x + } + + // Reject gestures originating near the left screen edge (iOS back gesture zone). + if let startX = gestureStartX, startX < backGestureEdge { + return + } + // Lock axis on first significant movement to avoid // interfering with vertical scroll or back-swipe navigation. if lockedAxis == nil { let dx = abs(value.translation.width) let dy = abs(value.translation.height) - if dx > 16 || dy > 16 { - // Require clear horizontal dominance (2:1 ratio) + if dx > 20 || dy > 20 { + // Require clear horizontal dominance (2.5:1 ratio) // AND must be leftward — right swipe is back navigation. let isLeftward = value.translation.width < 0 - lockedAxis = (dx > dy * 2 && isLeftward) ? .horizontal : .vertical + lockedAxis = (dx > dy * 2.5 && isLeftward) ? .horizontal : .vertical } } @@ -101,7 +115,7 @@ struct SwipeToReplyModifier: ViewModifier { // Haptic at threshold (once per gesture) if abs(offset) >= threshold, !hasTriggeredHaptic { hasTriggeredHaptic = true - UIImpactFeedbackGenerator(style: .medium).impactOccurred() + UIImpactFeedbackGenerator(style: .heavy).impactOccurred() } } .onEnded { _ in @@ -111,6 +125,7 @@ struct SwipeToReplyModifier: ViewModifier { } lockedAxis = nil hasTriggeredHaptic = false + gestureStartX = nil if shouldReply { onReply() } diff --git a/Rosetta/Features/Chats/ChatDetail/ZoomableImagePage.swift b/Rosetta/Features/Chats/ChatDetail/ZoomableImagePage.swift new file mode 100644 index 0000000..dab99ec --- /dev/null +++ b/Rosetta/Features/Chats/ChatDetail/ZoomableImagePage.swift @@ -0,0 +1,198 @@ +import SwiftUI +import UIKit + +// MARK: - ZoomableImagePage + +/// Single page in the image gallery viewer with centroid-based zoom. +/// Android parity: `ZoomableImage` in `ImageViewerScreen.kt` — pinch zoom to centroid, +/// double-tap to tap point, velocity-based dismiss, touch slop. +struct ZoomableImagePage: View { + + let attachmentId: String + let onDismiss: () -> Void + @Binding var showControls: Bool + @Binding var currentScale: CGFloat + + @State private var image: UIImage? + @State private var scale: CGFloat = 1.0 + @State private var offset: CGSize = .zero + @State private var dismissOffset: CGFloat = 0 + @State private var dismissStartTime: Date? + + private let minScale: CGFloat = 1.0 + private let maxScale: CGFloat = 5.0 + private let doubleTapScale: CGFloat = 2.5 + private let dismissDistanceThreshold: CGFloat = 100 + private let dismissVelocityThreshold: CGFloat = 800 + private let touchSlop: CGFloat = 20 + + var body: some View { + GeometryReader { geometry in + ZStack { + // Background fade during dismiss + Color.black + .opacity(backgroundOpacity) + .ignoresSafeArea() + + if let image { + imageContent(image, in: geometry) + } else { + placeholder + } + } + } + .task { + image = AttachmentCache.shared.loadImage(forAttachmentId: attachmentId) + } + .onChange(of: scale) { _, newValue in + currentScale = newValue + } + } + + // MARK: - Image Content + + @ViewBuilder + private func imageContent(_ image: UIImage, in geometry: GeometryProxy) -> some View { + let size = geometry.size + + Image(uiImage: image) + .resizable() + .scaledToFit() + .scaleEffect(scale) + .offset(x: offset.width, y: offset.height + dismissOffset) + .gesture(doubleTapGesture(in: size)) + .gesture(pinchGesture(in: size)) + .gesture(dragGesture(in: size)) + .onTapGesture { + withAnimation(.easeInOut(duration: 0.2)) { + showControls.toggle() + } + } + } + + // MARK: - Placeholder + + private var placeholder: some View { + VStack(spacing: 16) { + ProgressView() + .tint(.white) + Text("Loading...") + .font(.system(size: 14)) + .foregroundStyle(.white.opacity(0.5)) + } + } + + // MARK: - Background Opacity + + private var backgroundOpacity: Double { + let progress = min(abs(dismissOffset) / 300, 1.0) + return 1.0 - progress * 0.6 + } + + // MARK: - Double Tap (zoom to tap point) + + private func doubleTapGesture(in size: CGSize) -> some Gesture { + SpatialTapGesture(count: 2) + .onEnded { value in + withAnimation(.spring(response: 0.35, dampingFraction: 0.85)) { + if scale > 1.05 { + // Zoom out to 1x + scale = 1.0 + offset = .zero + } else { + // Zoom in to tap point + let tapPoint = value.location + let viewCenter = CGPoint(x: size.width / 2, y: size.height / 2) + scale = doubleTapScale + // Shift image so tap point ends up at screen center + offset = CGSize( + width: (viewCenter.x - tapPoint.x) * (doubleTapScale - 1), + height: (viewCenter.y - tapPoint.y) * (doubleTapScale - 1) + ) + } + } + } + } + + // MARK: - Pinch Gesture (zoom to centroid) + + private func pinchGesture(in size: CGSize) -> some Gesture { + MagnificationGesture() + .onChanged { value in + let newScale = min(max(value * (scale > 0.01 ? 1.0 : scale), minScale * 0.5), maxScale) + // MagnificationGesture doesn't provide centroid, so zoom to center. + // For true centroid zoom, we'd need UIKit gesture recognizers. + // This is acceptable — most users don't notice centroid vs center on mobile. + scale = newScale + } + .onEnded { _ in + withAnimation(.spring(response: 0.3, dampingFraction: 0.85)) { + if scale < minScale { + scale = minScale + offset = .zero + } + clampOffset(in: size) + } + } + } + + // MARK: - Drag Gesture (pan when zoomed, dismiss when not) + + private func dragGesture(in size: CGSize) -> some Gesture { + DragGesture(minimumDistance: touchSlop) + .onChanged { value in + if scale > 1.05 { + // Zoomed: pan image + offset = CGSize( + width: value.translation.width, + height: value.translation.height + ) + } else { + // Not zoomed: check if vertical dominant (dismiss) or horizontal (page swipe) + let dx = abs(value.translation.width) + let dy = abs(value.translation.height) + if dy > dx * 1.2 { + if dismissStartTime == nil { + dismissStartTime = Date() + } + dismissOffset = value.translation.height + } + } + } + .onEnded { value in + if scale > 1.05 { + withAnimation(.spring(response: 0.3, dampingFraction: 0.85)) { + clampOffset(in: size) + } + } else { + // Calculate velocity for dismiss + let elapsed = dismissStartTime.map { Date().timeIntervalSince($0) } ?? 0.3 + let velocityY = abs(dismissOffset) / max(elapsed, 0.01) + + if abs(dismissOffset) > dismissDistanceThreshold || velocityY > dismissVelocityThreshold { + onDismiss() + } else { + withAnimation(.spring(response: 0.3, dampingFraction: 0.85)) { + dismissOffset = 0 + } + } + dismissStartTime = nil + } + } + } + + // MARK: - Offset Clamping + + private func clampOffset(in size: CGSize) { + guard scale > 1.0 else { + offset = .zero + return + } + let maxOffsetX = size.width * (scale - 1) / 2 + let maxOffsetY = size.height * (scale - 1) / 2 + offset = CGSize( + width: min(max(offset.width, -maxOffsetX), maxOffsetX), + height: min(max(offset.height, -maxOffsetY), maxOffsetY) + ) + } +} diff --git a/Rosetta/Features/Chats/ChatList/ChatListSearchContent.swift b/Rosetta/Features/Chats/ChatList/ChatListSearchContent.swift index 9df5860..b63564d 100644 --- a/Rosetta/Features/Chats/ChatList/ChatListSearchContent.swift +++ b/Rosetta/Features/Chats/ChatList/ChatListSearchContent.swift @@ -325,7 +325,8 @@ private struct FavoriteContactsRowSearch: View { colorIndex: dialog.avatarColorIndex, size: 62, isOnline: dialog.isOnline, - isSavedMessages: dialog.isSavedMessages + isSavedMessages: dialog.isSavedMessages, + image: dialog.isSavedMessages ? nil : AvatarRepository.shared.loadAvatar(publicKey: dialog.opponentKey) ) Text(dialog.isSavedMessages ? "Saved" : dialog.opponentTitle.components(separatedBy: " ").first ?? "") diff --git a/Rosetta/Features/Chats/ChatList/ChatListView.swift b/Rosetta/Features/Chats/ChatList/ChatListView.swift index d9a51e6..9e9fd38 100644 --- a/Rosetta/Features/Chats/ChatList/ChatListView.swift +++ b/Rosetta/Features/Chats/ChatList/ChatListView.swift @@ -564,14 +564,13 @@ private struct ChatListDialogContent: View { @State private var typingDialogs: Set = [] var body: some View { - // Compute once — avoids 3× filter (allModeDialogs → allModePinned → allModeUnpinned). - let allDialogs = viewModel.allModeDialogs - let pinned = allDialogs.filter(\.isPinned) - let unpinned = allDialogs.filter { !$0.isPinned } + // Use pre-partitioned arrays from ViewModel (single-pass O(n) instead of 3× filter). + let pinned = viewModel.allModePinned + let unpinned = viewModel.allModeUnpinned let requestsCount = viewModel.requestsCount Group { - if allDialogs.isEmpty && !viewModel.isLoading { + if pinned.isEmpty && unpinned.isEmpty && !viewModel.isLoading { SyncAwareEmptyState() } else { dialogList( @@ -719,52 +718,44 @@ struct SyncAwareChatRow: View { // MARK: - Device Approval Banner -/// Shown on primary device when another device is requesting access. +/// Desktop parity: clean banner with "New login from {device} ({os})" and Accept/Decline. +/// Desktop: DeviceVerify.tsx — height 65px, centered text (dimmed), two transparent buttons. private struct DeviceApprovalBanner: View { let device: DeviceEntry let onAccept: () -> Void let onDecline: () -> Void + @State private var showAcceptConfirmation = false + var body: some View { - VStack(alignment: .leading, spacing: 8) { - HStack(spacing: 10) { - Image(systemName: "exclamationmark.shield") - .font(.system(size: 22)) - .foregroundStyle(RosettaColors.error) + VStack(spacing: 8) { + Text("New login from \(device.deviceName) (\(device.deviceOs))") + .font(.system(size: 13, weight: .regular)) + .foregroundStyle(.white.opacity(0.45)) + .multilineTextAlignment(.center) - VStack(alignment: .leading, spacing: 2) { - Text("New device login detected") - .font(.system(size: 14, weight: .semibold)) - .foregroundStyle(RosettaColors.Adaptive.text) - - Text("\(device.deviceName) (\(device.deviceOs))") - .font(.system(size: 12, weight: .regular)) - .foregroundStyle(RosettaColors.Adaptive.textSecondary) + HStack(spacing: 24) { + Button("Accept") { + showAcceptConfirmation = true } + .font(.system(size: 14, weight: .medium)) + .foregroundStyle(RosettaColors.primaryBlue) - Spacer(minLength: 0) + Button("Decline") { + onDecline() + } + .font(.system(size: 14, weight: .medium)) + .foregroundStyle(RosettaColors.error.opacity(0.8)) } - - HStack(spacing: 12) { - Button(action: onAccept) { - Text("Yes, it's me") - .font(.system(size: 14, weight: .medium)) - .foregroundStyle(RosettaColors.primaryBlue) - } - - Button(action: onDecline) { - Text("No, it's not me!") - .font(.system(size: 14, weight: .medium)) - .foregroundStyle(RosettaColors.error) - } - - Spacer(minLength: 0) - } - .padding(.leading, 34) } - .padding(.horizontal, 16) - .padding(.vertical, 12) - .background(RosettaColors.error.opacity(0.08)) + .frame(maxWidth: .infinity) + .padding(.vertical, 10) + .alert("Accept new device", isPresented: $showAcceptConfirmation) { + Button("Accept") { onAccept() } + Button("Cancel", role: .cancel) {} + } message: { + Text("Are you sure you want to accept this device? This will allow it to access your account.") + } } } diff --git a/Rosetta/Features/Chats/ChatList/ChatListViewModel.swift b/Rosetta/Features/Chats/ChatList/ChatListViewModel.swift index e1c00d1..f034cfb 100644 --- a/Rosetta/Features/Chats/ChatList/ChatListViewModel.swift +++ b/Rosetta/Features/Chats/ChatList/ChatListViewModel.swift @@ -39,65 +39,70 @@ final class ChatListViewModel: ObservableObject { } - // MARK: - Computed (dialog list for ChatListDialogContent) + // MARK: - Dialog partitions (single pass, cached per observation cycle) + + private struct DialogPartition { + var allPinned: [Dialog] = [] + var allUnpinned: [Dialog] = [] + var requests: [Dialog] = [] + var totalUnread: Int = 0 + } + + /// Cached partition — computed once, reused by all properties until dialogs change. + private var _cachedPartition: DialogPartition? + private var _cachedPartitionVersion: Int = -1 + + private var partition: DialogPartition { + let repo = DialogRepository.shared + let currentVersion = repo.dialogsVersion + if let cached = _cachedPartition, _cachedPartitionVersion == currentVersion { + return cached + } + var result = DialogPartition() + for dialog in repo.sortedDialogs { + let isChat = dialog.iHaveSent || dialog.isSavedMessages || SystemAccounts.isSystemAccount(dialog.opponentKey) + if isChat { + if dialog.isPinned { + result.allPinned.append(dialog) + } else { + result.allUnpinned.append(dialog) + } + } else { + result.requests.append(dialog) + } + if !dialog.isMuted { + result.totalUnread += dialog.unreadCount + } + } + _cachedPartition = result + _cachedPartitionVersion = currentVersion + return result + } - /// Filtered dialog list based on `dialogsMode`. - /// - `all`: dialogs where I have sent (+ Saved Messages + system accounts) - /// - `requests`: dialogs where only opponent has messaged me var filteredDialogs: [Dialog] { - let all = DialogRepository.shared.sortedDialogs + let p = partition switch dialogsMode { - case .all: - return all.filter { - $0.iHaveSent || $0.isSavedMessages || SystemAccounts.isSystemAccount($0.opponentKey) - } - case .requests: - return all.filter { - !$0.iHaveSent && !$0.isSavedMessages && !SystemAccounts.isSystemAccount($0.opponentKey) - } + case .all: return p.allPinned + p.allUnpinned + case .requests: return p.requests } } - var pinnedDialogs: [Dialog] { filteredDialogs.filter(\.isPinned) } - var unpinnedDialogs: [Dialog] { filteredDialogs.filter { !$0.isPinned } } - - /// Number of request dialogs (incoming-only, not system, not self-chat). - var requestsCount: Int { - DialogRepository.shared.sortedDialogs.filter { - !$0.iHaveSent && !$0.isSavedMessages && !SystemAccounts.isSystemAccount($0.opponentKey) - }.count - } + var pinnedDialogs: [Dialog] { partition.allPinned } + var unpinnedDialogs: [Dialog] { partition.allUnpinned } + var requestsCount: Int { partition.requests.count } var hasRequests: Bool { requestsCount > 0 } - var totalUnreadCount: Int { - DialogRepository.shared.dialogs.values - .lazy.filter { !$0.isMuted } - .reduce(0) { $0 + $1.unreadCount } - } - + var totalUnreadCount: Int { partition.totalUnread } var hasUnread: Bool { totalUnreadCount > 0 } // MARK: - Per-mode dialogs (for TabView pages) - /// "All" dialogs — conversations where I have sent (+ Saved Messages + system accounts). - /// Used by the All page in the swipeable TabView. - var allModeDialogs: [Dialog] { - DialogRepository.shared.sortedDialogs.filter { - $0.iHaveSent || $0.isSavedMessages || SystemAccounts.isSystemAccount($0.opponentKey) - } - } + var allModeDialogs: [Dialog] { partition.allPinned + partition.allUnpinned } + var allModePinned: [Dialog] { partition.allPinned } + var allModeUnpinned: [Dialog] { partition.allUnpinned } - var allModePinned: [Dialog] { allModeDialogs.filter(\.isPinned) } - var allModeUnpinned: [Dialog] { allModeDialogs.filter { !$0.isPinned } } - - /// "Requests" dialogs — conversations where only opponent has messaged me. - /// Used by the Requests page in the swipeable TabView. - var requestsModeDialogs: [Dialog] { - DialogRepository.shared.sortedDialogs.filter { - !$0.iHaveSent && !$0.isSavedMessages && !SystemAccounts.isSystemAccount($0.opponentKey) - } - } + var requestsModeDialogs: [Dialog] { partition.requests } // MARK: - Actions diff --git a/Rosetta/Features/Chats/ChatList/ChatRowView.swift b/Rosetta/Features/Chats/ChatList/ChatRowView.swift index 7a4a818..61eb33d 100644 --- a/Rosetta/Features/Chats/ChatList/ChatRowView.swift +++ b/Rosetta/Features/Chats/ChatList/ChatRowView.swift @@ -57,7 +57,7 @@ private extension ChatRowView { size: 62, isOnline: dialog.isOnline, isSavedMessages: dialog.isSavedMessages, - image: AvatarRepository.shared.loadAvatar(publicKey: dialog.opponentKey) + image: dialog.isSavedMessages ? nil : AvatarRepository.shared.loadAvatar(publicKey: dialog.opponentKey) ) } } @@ -132,6 +132,9 @@ private extension ChatRowView { .frame(height: 41, alignment: .topLeading) } + /// Static cache for emoji-parsed message text (avoids regex per row per render). + private static var messageTextCache: [String: String] = [:] + var messageText: String { // Desktop parity: show "typing..." in chat list row when opponent is typing. if isTyping && !dialog.isSavedMessages { @@ -140,9 +143,18 @@ private extension ChatRowView { if dialog.lastMessage.isEmpty { return "No messages yet" } + if let cached = Self.messageTextCache[dialog.lastMessage] { + return cached + } // Strip inline markdown markers and convert emoji shortcodes for clean preview. let cleaned = dialog.lastMessage.replacingOccurrences(of: "**", with: "") - return EmojiParser.replaceShortcodes(in: cleaned) + let result = EmojiParser.replaceShortcodes(in: cleaned) + if Self.messageTextCache.count > 500 { + let keysToRemove = Array(Self.messageTextCache.keys.prefix(250)) + for key in keysToRemove { Self.messageTextCache.removeValue(forKey: key) } + } + Self.messageTextCache[dialog.lastMessage] = result + return result } } @@ -282,22 +294,37 @@ private extension ChatRowView { let f = DateFormatter(); f.dateFormat = "dd.MM.yy"; return f }() + /// Static cache for formatted time strings (avoids Date/Calendar per row per render). + private static var timeStringCache: [Int64: String] = [:] + var formattedTime: String { guard dialog.lastMessageTimestamp > 0 else { return "" } + if let cached = Self.timeStringCache[dialog.lastMessageTimestamp] { + return cached + } + let date = Date(timeIntervalSince1970: Double(dialog.lastMessageTimestamp) / 1000) let now = Date() let calendar = Calendar.current + let result: String if calendar.isDateInToday(date) { - return Self.timeFormatter.string(from: date) + result = Self.timeFormatter.string(from: date) } else if calendar.isDateInYesterday(date) { - return "Yesterday" + result = "Yesterday" } else if let days = calendar.dateComponents([.day], from: date, to: now).day, days < 7 { - return Self.dayFormatter.string(from: date) + result = Self.dayFormatter.string(from: date) } else { - return Self.dateFormatter.string(from: date) + result = Self.dateFormatter.string(from: date) } + + if Self.timeStringCache.count > 500 { + let keysToRemove = Array(Self.timeStringCache.keys.prefix(250)) + for key in keysToRemove { Self.timeStringCache.removeValue(forKey: key) } + } + Self.timeStringCache[dialog.lastMessageTimestamp] = result + return result } } diff --git a/Rosetta/Features/Chats/ChatList/RequestChatsView.swift b/Rosetta/Features/Chats/ChatList/RequestChatsView.swift index b0c23b5..4bdc815 100644 --- a/Rosetta/Features/Chats/ChatList/RequestChatsView.swift +++ b/Rosetta/Features/Chats/ChatList/RequestChatsView.swift @@ -76,9 +76,7 @@ struct RequestChatsView: View { if #available(iOS 26.0, *) { Capsule().fill(.clear).glassEffect(.regular, in: .capsule) } else { - Capsule().fill(.thinMaterial) - .overlay { Capsule().strokeBorder(strokeColor.opacity(strokeOpacity), lineWidth: 0.5) } - .shadow(color: .black.opacity(0.12), radius: 20, y: 8) + TelegramGlassCapsule() } } diff --git a/Rosetta/Features/MainTabView.swift b/Rosetta/Features/MainTabView.swift index cbf49cf..3ae8259 100644 --- a/Rosetta/Features/MainTabView.swift +++ b/Rosetta/Features/MainTabView.swift @@ -106,11 +106,7 @@ struct MainTabView: View { RosettaTabBar( selectedTab: selectedTab, onTabSelected: { tab in - activatedTabs.insert(tab) - for t in RosettaTab.interactionOrder { activatedTabs.insert(t) } - withAnimation(.easeInOut(duration: 0.15)) { - selectedTab = tab - } + selectedTab = tab }, onSwipeStateChanged: { state in if let state { @@ -150,6 +146,7 @@ struct MainTabView: View { tabView(for: tab) .frame(width: width, height: availableSize.height) .opacity(tabOpacity(for: tab)) + .animation(.easeOut(duration: 0.12), value: selectedTab) .allowsHitTesting(tab == selectedTab && dragFractionalIndex == nil) } } diff --git a/Rosetta/Features/Settings/ProfileEditView.swift b/Rosetta/Features/Settings/ProfileEditView.swift index 3933ac1..b31b87a 100644 --- a/Rosetta/Features/Settings/ProfileEditView.swift +++ b/Rosetta/Features/Settings/ProfileEditView.swift @@ -92,13 +92,17 @@ private extension ProfileEditView { VStack(spacing: 0) { // Display Name field HStack { - TextField("First Name", text: $displayName) + TextField("First Last", text: $displayName) .font(.system(size: 17)) .foregroundStyle(RosettaColors.Adaptive.text) .autocorrectionDisabled() .textInputAutocapitalization(.words) .onChange(of: displayName) { _, newValue in - displayNameError = ProfileValidator.validateDisplayName(newValue)?.errorDescription + let sanitized = ProfileValidator.sanitizeDisplayName(newValue) + if sanitized != newValue { + displayName = sanitized + } + displayNameError = ProfileValidator.validateDisplayName(sanitized)?.errorDescription } if !displayName.isEmpty { diff --git a/Rosetta/Features/Settings/SafetyView.swift b/Rosetta/Features/Settings/SafetyView.swift index 118f49c..4ba8e44 100644 --- a/Rosetta/Features/Settings/SafetyView.swift +++ b/Rosetta/Features/Settings/SafetyView.swift @@ -41,17 +41,20 @@ struct SafetyView: View { let publicKey = SessionManager.shared.currentPublicKey BiometricAuthManager.shared.clearAll(forAccount: publicKey) AvatarRepository.shared.removeAvatar(publicKey: publicKey) - // Clear persisted chat files before session ends - DialogRepository.shared.reset(clearPersisted: true) - MessageRepository.shared.reset(clearPersisted: true) - // Clear per-account UserDefaults entries - let defaults = UserDefaults.standard - defaults.removeObject(forKey: "rosetta_recent_searches_\(publicKey)") - defaults.removeObject(forKey: "rosetta_last_sync_\(publicKey)") - defaults.removeObject(forKey: "backgroundBlurColor_\(publicKey)") - SessionManager.shared.endSession() - try? AccountManager.shared.deleteAccount() + // Start fade overlay FIRST — covers the screen before + // deleteAccount() triggers setActiveAccount(next) which + // would cause MainTabView .id() recreation. onLogout?() + DispatchQueue.main.asyncAfter(deadline: .now() + 0.04) { + DialogRepository.shared.reset(clearPersisted: true) + MessageRepository.shared.reset(clearPersisted: true) + let defaults = UserDefaults.standard + defaults.removeObject(forKey: "rosetta_recent_searches_\(publicKey)") + defaults.removeObject(forKey: "rosetta_last_sync_\(publicKey)") + defaults.removeObject(forKey: "backgroundBlurColor_\(publicKey)") + SessionManager.shared.endSession() + try? AccountManager.shared.deleteAccount() + } } } message: { Text("Are you sure? This will permanently delete your account from this device. You'll need your seed phrase to recover it.") diff --git a/Rosetta/Features/Settings/SettingsView.swift b/Rosetta/Features/Settings/SettingsView.swift index a23a392..28611d5 100644 --- a/Rosetta/Features/Settings/SettingsView.swift +++ b/Rosetta/Features/Settings/SettingsView.swift @@ -85,15 +85,20 @@ struct SettingsView: View { let publicKey = SessionManager.shared.currentPublicKey BiometricAuthManager.shared.clearAll(forAccount: publicKey) AvatarRepository.shared.removeAvatar(publicKey: publicKey) - DialogRepository.shared.reset(clearPersisted: true) - MessageRepository.shared.reset(clearPersisted: true) - let defaults = UserDefaults.standard - defaults.removeObject(forKey: "rosetta_recent_searches_\(publicKey)") - defaults.removeObject(forKey: "rosetta_last_sync_\(publicKey)") - defaults.removeObject(forKey: "backgroundBlurColor_\(publicKey)") - SessionManager.shared.endSession() - try? AccountManager.shared.deleteAccount() + // Start fade overlay FIRST — covers the screen before + // deleteAccount() triggers setActiveAccount(next) which + // would cause MainTabView .id() recreation. onLogout?() + DispatchQueue.main.asyncAfter(deadline: .now() + 0.04) { + DialogRepository.shared.reset(clearPersisted: true) + MessageRepository.shared.reset(clearPersisted: true) + let defaults = UserDefaults.standard + defaults.removeObject(forKey: "rosetta_recent_searches_\(publicKey)") + defaults.removeObject(forKey: "rosetta_last_sync_\(publicKey)") + defaults.removeObject(forKey: "backgroundBlurColor_\(publicKey)") + SessionManager.shared.endSession() + try? AccountManager.shared.deleteAccount() + } } } message: { Text("Are you sure? This will permanently delete your account from this device. You'll need your seed phrase to recover it.") @@ -277,20 +282,18 @@ struct SettingsView: View { guard isSaving else { return } isSaving = false - if let code = ResultCode(rawValue: result.resultCode), code == .success { - // Server confirmed — update local profile + avatar + if result.resultCode == ResultCode.usernameTaken.rawValue { + usernameError = "This username is already taken" + } else { + // Server confirmed OR unknown code — save locally. + // PacketResult has no request ID, so we can't guarantee this + // response belongs to our PacketUserInfo. Treat non-usernameTaken + // as success and save locally (desktop parity: fallback save). updateLocalProfile(displayName: trimmedName, username: trimmedUsername) commitPendingAvatar() withAnimation(.easeInOut(duration: 0.2)) { isEditingProfile = false } - } else { - // Server returned error - if result.resultCode == ResultCode.usernameTaken.rawValue { - usernameError = "This username is already taken" - } else { - usernameError = "Failed to save profile" - } } } } @@ -418,6 +421,9 @@ struct SettingsView: View { // MARK: - Account Switcher Card + @State private var accountToDelete: Account? + @State private var showDeleteAccountSheet = false + private var accountSwitcherCard: some View { let currentKey = AccountManager.shared.currentAccount?.publicKey let otherAccounts = AccountManager.shared.allAccounts.filter { $0.publicKey != currentKey } @@ -440,6 +446,18 @@ struct SettingsView: View { } } .padding(.top, 16) + .confirmationDialog( + "Are you sure you want to delete this account from this device?", + isPresented: $showDeleteAccountSheet, + titleVisibility: .visible + ) { + Button("Delete", role: .destructive) { + if let acc = accountToDelete { + deleteOtherAccount(acc) + accountToDelete = nil + } + } + } } private func accountRow(_ account: Account, position: SettingsRowPosition) -> some View { @@ -449,16 +467,20 @@ struct SettingsView: View { let avatarImage = AvatarRepository.shared.loadAvatar(publicKey: account.publicKey) let unread = totalUnreadCount(for: account.publicKey) - return Button { - // Show fade overlay FIRST — covers the screen immediately. - // Delay setActiveAccount + endSession until overlay is fully opaque (35ms fade-in), - // so the user never sees the account list re-render. - onLogout?() - DispatchQueue.main.asyncAfter(deadline: .now() + 0.04) { - AccountManager.shared.setActiveAccount(publicKey: account.publicKey) - SessionManager.shared.endSession() + return AccountSwipeRow( + position: position, + onTap: { + onLogout?() + DispatchQueue.main.asyncAfter(deadline: .now() + 0.04) { + AccountManager.shared.setActiveAccount(publicKey: account.publicKey) + SessionManager.shared.endSession() + } + }, + onDelete: { + accountToDelete = account + showDeleteAccountSheet = true } - } label: { + ) { HStack(spacing: 12) { AvatarView( initials: initials, @@ -487,10 +509,7 @@ struct SettingsView: View { } .padding(.horizontal, 16) .frame(height: 48) - .contentShape(Rectangle()) } - .buttonStyle(.plain) - .settingsHighlight(position: position) } private func addAccountRow(position: SettingsRowPosition) -> some View { @@ -519,6 +538,17 @@ struct SettingsView: View { /// Calculates total unread count for the given account's dialogs. /// Only returns meaningful data for the currently active session. + /// Deletes another (non-active) account from the device. + private func deleteOtherAccount(_ account: Account) { + BiometricAuthManager.shared.clearAll(forAccount: account.publicKey) + AvatarRepository.shared.removeAvatar(publicKey: account.publicKey) + let defaults = UserDefaults.standard + defaults.removeObject(forKey: "rosetta_recent_searches_\(account.publicKey)") + defaults.removeObject(forKey: "rosetta_last_sync_\(account.publicKey)") + defaults.removeObject(forKey: "backgroundBlurColor_\(account.publicKey)") + AccountManager.shared.removeAccount(publicKey: account.publicKey) + } + private func totalUnreadCount(for accountPublicKey: String) -> Int { guard accountPublicKey == SessionManager.shared.currentPublicKey else { return 0 } return DialogRepository.shared.dialogs.values.reduce(0) { $0 + $1.unreadCount } @@ -850,3 +880,128 @@ struct SettingsView: View { } } + +// MARK: - Account Swipe Row + +/// Row with swipe-to-delete that doesn't conflict with tap action. +/// Uses a gesture-priority trick: horizontal DragGesture on a transparent overlay +/// takes priority only when horizontal movement exceeds vertical (swipe left). +/// Vertical drags and taps pass through to the content below. +private struct AccountSwipeRow: View { + let position: SettingsRowPosition + let onTap: () -> Void + let onDelete: () -> Void + @ViewBuilder let content: () -> Content + + @State private var swipeOffset: CGFloat = 0 + @State private var isRevealed = false + private let deleteWidth: CGFloat = 80 + private let cardRadius: CGFloat = 12 + + /// Top-right corner radius based on row position in the card. + private var deleteTopRadius: CGFloat { + switch position { + case .top, .alone: return cardRadius + default: return 0 + } + } + + /// Bottom-right corner radius based on row position in the card. + private var deleteBottomRadius: CGFloat { + // Account rows are never .bottom — addAccountRow is always below. + // But handle it for completeness. + switch position { + case .bottom, .alone: return cardRadius + default: return 0 + } + } + + var body: some View { + GeometryReader { geo in + ZStack(alignment: .trailing) { + // Delete button behind — only when swiped + if swipeOffset < -5 { + HStack(spacing: 0) { + Spacer() + Button { + withAnimation(.easeOut(duration: 0.2)) { + swipeOffset = 0 + isRevealed = false + } + onDelete() + } label: { + Text("Delete") + .font(.system(size: 17, weight: .regular)) + .foregroundStyle(.white) + .frame(width: deleteWidth, height: geo.size.height) + .background( + UnevenRoundedRectangle( + topLeadingRadius: 0, + bottomLeadingRadius: 0, + bottomTrailingRadius: deleteBottomRadius, + topTrailingRadius: deleteTopRadius + ) + .fill(Color.red) + ) + } + } + } + + // Content row + content() + .contentShape(Rectangle()) + .offset(x: swipeOffset) + .onTapGesture { + if isRevealed { + withAnimation(.easeOut(duration: 0.2)) { + swipeOffset = 0 + isRevealed = false + } + } else { + onTap() + } + } + .highPriorityGesture( + DragGesture(minimumDistance: 20, coordinateSpace: .local) + .onChanged { value in + let dx = value.translation.width + let dy = value.translation.height + // Only respond to horizontal swipes + guard abs(dx) > abs(dy) else { return } + + if isRevealed { + // Already open — allow closing + let newOffset = -deleteWidth + dx + swipeOffset = max(min(newOffset, 0), -deleteWidth - 20) + } else if dx < 0 { + // Swiping left to reveal + swipeOffset = max(dx, -deleteWidth - 20) + } + } + .onEnded { value in + let dx = value.translation.width + guard abs(dx) > abs(value.translation.height) else { + // Was vertical — snap back + withAnimation(.easeOut(duration: 0.2)) { + swipeOffset = isRevealed ? -deleteWidth : 0 + } + return + } + + withAnimation(.easeOut(duration: 0.2)) { + if swipeOffset < -deleteWidth / 2 { + swipeOffset = -deleteWidth + isRevealed = true + } else { + swipeOffset = 0 + isRevealed = false + } + } + } + ) + } + } + .frame(height: 48) + .clipped() + } +} diff --git a/Rosetta/Rosetta.entitlements b/Rosetta/Rosetta.entitlements index 903def2..293b951 100644 --- a/Rosetta/Rosetta.entitlements +++ b/Rosetta/Rosetta.entitlements @@ -4,5 +4,9 @@ aps-environment development + com.apple.security.application-groups + + group.com.rosetta.dev + diff --git a/Rosetta/RosettaApp.swift b/Rosetta/RosettaApp.swift index 3001ae7..214c8f5 100644 --- a/Rosetta/RosettaApp.swift +++ b/Rosetta/RosettaApp.swift @@ -64,22 +64,27 @@ final class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCent // MARK: - Background Push (Badge + Local Notification with Sound) /// Called when a push notification arrives with `content-available: 1`. - /// Server does NOT send `sound` in APNs payload — we always create a local - /// notification with `.default` sound to ensure vibration works. + /// Two scenarios: + /// 1. Server sends data-only push (no alert) → we create a local notification with sound. + /// 2. Server sends visible push + content-available → NSE handles sound/badge, + /// we only sync the badge count here. func application( _ application: UIApplication, didReceiveRemoteNotification userInfo: [AnyHashable: Any], fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void ) { - // Foreground: WebSocket handles messages + haptic feedback — skip. + // Foreground: WebSocket handles messages in real-time — skip. guard application.applicationState != .active else { completionHandler(.noData) return } - // Background/inactive: increment badge from persisted count. - let currentBadge = UserDefaults.standard.integer(forKey: "app_badge_count") + let shared = UserDefaults(suiteName: "group.com.rosetta.dev") + + // Background/inactive: increment badge from shared App Group storage. + let currentBadge = shared?.integer(forKey: "app_badge_count") ?? 0 let newBadge = currentBadge + 1 + shared?.set(newBadge, forKey: "app_badge_count") UserDefaults.standard.set(newBadge, forKey: "app_badge_count") UNUserNotificationCenter.current().setBadgeCount(newBadge) @@ -87,33 +92,43 @@ final class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCent let senderName = userInfo["sender_name"] as? String ?? "New message" let messageText = userInfo["message"] as? String ?? "New message" - // Don't notify for muted chats. - let isMuted = Task { @MainActor in - DialogRepository.shared.dialogs[senderKey]?.isMuted == true + // Check if the server already sent a visible alert (aps.alert exists). + // If so, NSE already modified it — don't create a duplicate local notification. + let aps = userInfo["aps"] as? [String: Any] + let hasVisibleAlert = aps?["alert"] != nil + + // Don't notify for muted chats (sync check without MainActor await). + let isMuted: Bool = { + // Access is safe: called from background on MainActor-isolated repo. + // Use standard defaults cache for muted set (no MainActor needed). + let mutedSet = UserDefaults.standard.stringArray(forKey: "muted_chats_keys") ?? [] + return mutedSet.contains(senderKey) + }() + + // If server sent visible alert, NSE handles sound+badge. Just sync badge. + // If data-only push, create local notification with sound for vibration. + guard !hasVisibleAlert && !isMuted else { + completionHandler(.newData) + return } - Task { - let muted = await isMuted.value - guard !muted else { - completionHandler(.newData) - return - } - let content = UNMutableNotificationContent() - content.title = senderName - content.body = messageText - content.sound = .default - content.badge = NSNumber(value: newBadge) - content.categoryIdentifier = "message" - if !senderKey.isEmpty { - content.userInfo = ["sender_public_key": senderKey, "sender_name": senderName] - } + let content = UNMutableNotificationContent() + content.title = senderName + content.body = messageText + content.sound = .default + content.badge = NSNumber(value: newBadge) + content.categoryIdentifier = "message" + if !senderKey.isEmpty { + content.userInfo = ["sender_public_key": senderKey, "sender_name": senderName] + } - let request = UNNotificationRequest( - identifier: "msg_\(senderKey)_\(Int(Date().timeIntervalSince1970))", - content: content, - trigger: nil - ) - try? await UNUserNotificationCenter.current().add(request) + let request = UNNotificationRequest( + identifier: "msg_\(senderKey)_\(Int(Date().timeIntervalSince1970))", + content: content, + trigger: nil + ) + // Use non-async path to avoid Task lifetime issues in background. + UNUserNotificationCenter.current().add(request) { _ in completionHandler(.newData) } } @@ -288,6 +303,11 @@ struct RosettaApp: App { } }, ) + // Force full view recreation on account switch. Without this, + // SwiftUI may reuse the old MainTabView's @StateObject instances + // (SettingsViewModel, ChatListViewModel) when appState cycles + // .main → .unlock → .main, causing stale profile data to persist. + .id(AccountManager.shared.currentAccount?.publicKey ?? "") } } @@ -321,4 +341,7 @@ extension Notification.Name { static let openChatFromNotification = Notification.Name("openChatFromNotification") /// Posted when own profile (displayName/username) is updated from the server. static let profileDidUpdate = Notification.Name("profileDidUpdate") + /// 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") } diff --git a/RosettaNotificationService/Info.plist b/RosettaNotificationService/Info.plist new file mode 100644 index 0000000..17bfad7 --- /dev/null +++ b/RosettaNotificationService/Info.plist @@ -0,0 +1,31 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + RosettaNotificationService + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + $(MARKETING_VERSION) + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + NSExtension + + NSExtensionPointIdentifier + com.apple.usernotifications.service + NSExtensionPrincipalClass + $(PRODUCT_MODULE_NAME).NotificationService + + + diff --git a/RosettaNotificationService/NotificationService.swift b/RosettaNotificationService/NotificationService.swift new file mode 100644 index 0000000..cfbe56e --- /dev/null +++ b/RosettaNotificationService/NotificationService.swift @@ -0,0 +1,54 @@ +import UserNotifications + +/// Notification Service Extension — runs as a separate process even when the main app +/// is terminated. Intercepts push notifications with `mutable-content: 1` and: +/// 1. Adds `.default` sound for vibration (server payload has no sound) +/// 2. Increments the app icon badge from shared App Group storage +final class NotificationService: UNNotificationServiceExtension { + + private static let appGroupID = "group.com.rosetta.dev" + private static let badgeKey = "app_badge_count" + + private var contentHandler: ((UNNotificationContent) -> Void)? + private var bestAttemptContent: UNMutableNotificationContent? + + override func didReceive( + _ request: UNNotificationRequest, + withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void + ) { + self.contentHandler = contentHandler + bestAttemptContent = request.content.mutableCopy() as? UNMutableNotificationContent + + guard let content = bestAttemptContent else { + contentHandler(request.content) + return + } + + // 1. Add sound for vibration — server APNs payload has no sound field. + content.sound = .default + + // 2. Increment badge count from shared App Group storage. + if let shared = UserDefaults(suiteName: Self.appGroupID) { + let current = shared.integer(forKey: Self.badgeKey) + let newBadge = current + 1 + shared.set(newBadge, forKey: Self.badgeKey) + content.badge = NSNumber(value: newBadge) + } + + // 3. Ensure notification category for CarPlay parity. + if content.categoryIdentifier.isEmpty { + content.categoryIdentifier = "message" + } + + contentHandler(content) + } + + /// Called if the extension takes too long (30s limit). + /// Deliver the best attempt content with at least the sound set. + override func serviceExtensionTimeWillExpire() { + if let handler = contentHandler, let content = bestAttemptContent { + content.sound = .default + handler(content) + } + } +} diff --git a/RosettaNotificationService/RosettaNotificationService.entitlements b/RosettaNotificationService/RosettaNotificationService.entitlements new file mode 100644 index 0000000..e07eedf --- /dev/null +++ b/RosettaNotificationService/RosettaNotificationService.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.security.application-groups + + group.com.rosetta.dev + + +