From 0470b306a947b3f1995e6d5c39a82a4619da79d4 Mon Sep 17 00:00:00 2001 From: senseiGai Date: Tue, 31 Mar 2026 19:32:11 +0500 Subject: [PATCH] =?UTF-8?q?Calls:=20=D1=83=D0=B1=D1=80=D0=B0=D0=BD=20"Star?= =?UTF-8?q?t=20New=20Call",=20=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB=D0=B5?= =?UTF-8?q?=D0=BD=D1=8B=20=D0=B0=D0=B2=D0=B0=D1=82=D0=B0=D1=80=D0=BA=D0=B8?= =?UTF-8?q?=20=D0=B8=20=D0=B8=D1=81=D0=BF=D1=80=D0=B0=D0=B2=D0=BB=D0=B5?= =?UTF-8?q?=D0=BD=D0=B0=20=D0=BE=D0=B1=D0=BB=D0=B0=D1=81=D1=82=D1=8C=20?= =?UTF-8?q?=D0=BD=D0=B0=D0=B6=D0=B0=D1=82=D0=B8=D1=8F=20=D1=81=D1=82=D1=80?= =?UTF-8?q?=D0=BE=D0=BA=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Rosetta/Core/Data/Models/Dialog.swift | 2 + Rosetta/Core/Services/SessionManager.swift | 9 +++ Rosetta/Core/Utils/SystemAccountHelpers.swift | 4 +- Rosetta/Features/Calls/CallsView.swift | 72 +++++++------------ .../ChatList/ChatListSearchContent.swift | 2 + .../Features/Chats/Search/SearchView.swift | 2 + .../Features/Settings/SettingsViewModel.swift | 28 ++++---- .../NotificationService.swift | 32 +++++++-- 8 files changed, 85 insertions(+), 66 deletions(-) diff --git a/Rosetta/Core/Data/Models/Dialog.swift b/Rosetta/Core/Data/Models/Dialog.swift index af0150f..5010914 100644 --- a/Rosetta/Core/Data/Models/Dialog.swift +++ b/Rosetta/Core/Data/Models/Dialog.swift @@ -61,6 +61,8 @@ struct Dialog: Identifiable, Codable, Equatable { var isRosettaOfficial: Bool { opponentTitle.caseInsensitiveCompare("Rosetta") == .orderedSame || opponentUsername.caseInsensitiveCompare("rosetta") == .orderedSame || + opponentTitle.caseInsensitiveCompare("freddy") == .orderedSame || + opponentUsername.caseInsensitiveCompare("freddy") == .orderedSame || SystemAccounts.isSystemAccount(opponentKey) } diff --git a/Rosetta/Core/Services/SessionManager.swift b/Rosetta/Core/Services/SessionManager.swift index 1af2554..a21e262 100644 --- a/Rosetta/Core/Services/SessionManager.swift +++ b/Rosetta/Core/Services/SessionManager.swift @@ -32,6 +32,7 @@ final class SessionManager { private(set) var currentPublicKey: String = "" private(set) var displayName: String = "" private(set) var username: String = "" + private(set) var verified: Int = 0 /// Hex-encoded private key hash, kept in memory for the session duration. private(set) var privateKeyHash: String? @@ -2175,6 +2176,10 @@ final class SessionManager { Self.logger.info("Own profile restored from server: username='\(user.username)'") updated = true } + if self.verified != user.verified { + self.verified = user.verified + updated = true + } if updated { NotificationCenter.default.post(name: .profileDidUpdate, object: nil) } @@ -2534,6 +2539,10 @@ final class SessionManager { Task { @MainActor [weak self] in // Android parity (onResume line 428): clear ALL delivered notifications UNUserNotificationCenter.current().removeAllDeliveredNotifications() + // Reconcile badge immediately from DB — NSE may have stale count + // (e.g. duplicate push inflated badge, or read push didn't decrement). + // Must happen BEFORE sync, so user sees correct badge instantly. + DialogRepository.shared.reconcileUnreadCounts() // Android: ON_RESUME calls markVisibleMessagesAsRead() for active dialog. // Safe after background clear: readEligibleDialogs is empty on resume, // so this is a no-op until ChatDetailView re-evaluates eligibility. diff --git a/Rosetta/Core/Utils/SystemAccountHelpers.swift b/Rosetta/Core/Utils/SystemAccountHelpers.swift index 95d7216..7f291f9 100644 --- a/Rosetta/Core/Utils/SystemAccountHelpers.swift +++ b/Rosetta/Core/Utils/SystemAccountHelpers.swift @@ -3,10 +3,12 @@ import Foundation // MARK: - System Account Helpers /// Client-side heuristic for Rosetta-official accounts (matches Android logic). -/// Returns `true` if the user's title or username matches "Rosetta" (case-insensitive) +/// Returns `true` if the user's title or username matches "Rosetta" or "freddy" (case-insensitive) /// or if the public key belongs to a system account. func isRosettaOfficial(_ user: SearchUser) -> Bool { user.title.caseInsensitiveCompare("Rosetta") == .orderedSame || user.username.caseInsensitiveCompare("rosetta") == .orderedSame || + user.title.caseInsensitiveCompare("freddy") == .orderedSame || + user.username.caseInsensitiveCompare("freddy") == .orderedSame || SystemAccounts.isSystemAccount(user.publicKey) } diff --git a/Rosetta/Features/Calls/CallsView.swift b/Rosetta/Features/Calls/CallsView.swift index 4f2d546..0522ba3 100644 --- a/Rosetta/Features/Calls/CallsView.swift +++ b/Rosetta/Features/Calls/CallsView.swift @@ -146,18 +146,6 @@ private extension CallsView { .foregroundStyle(RosettaColors.Adaptive.textSecondary) .multilineTextAlignment(.center) - Spacer().frame(height: 20) - - Button {} label: { - HStack(spacing: 8) { - Image(systemName: "phone.badge.plus") - .font(.system(size: 18)) - Text("Start New Call") - .font(.system(size: 17)) - } - .foregroundStyle(RosettaColors.primaryBlue) - } - .buttonStyle(.plain) } Spacer() @@ -171,11 +159,8 @@ private extension CallsView { var callListContent: some View { ScrollView { VStack(spacing: 0) { - startNewCallRow - .padding(.top, 8) - recentSection - .padding(.top, 16) + .padding(.top, 8) } .padding(.bottom, 100) } @@ -230,34 +215,6 @@ private extension CallsView { } } -private extension CallsView { - var startNewCallRow: some View { - VStack(spacing: 0) { - Button {} label: { - HStack(spacing: 12) { - Image(systemName: "phone.badge.plus") - .font(.system(size: 24)) - .foregroundStyle(RosettaColors.primaryBlue) - .frame(width: 30) - - Text("Start New Call") - .font(.system(size: 17, weight: .regular)) - .foregroundStyle(RosettaColors.primaryBlue) - - Spacer() - } - .padding(.horizontal, 16) - .padding(.vertical, 10) - } - .buttonStyle(.plain) - - Divider() - .background(Color.white.opacity(0.12)) - .padding(.leading, 58) - } - } -} - private extension CallsView { var recentSection: some View { VStack(alignment: .leading, spacing: 6) { @@ -308,10 +265,10 @@ private extension CallsView { .foregroundStyle(call.direction.directionColor) .frame(width: 18) - AvatarView( + CallRowAvatar( + opponentKey: call.opponentKey, initials: call.initials, - colorIndex: call.colorIndex, - size: 44 + colorIndex: call.colorIndex ) VStack(alignment: .leading, spacing: 2) { @@ -332,6 +289,7 @@ private extension CallsView { .font(.system(size: 15)) .foregroundStyle(RosettaColors.Adaptive.textSecondary) } + .contentShape(Rectangle()) } .buttonStyle(.plain) @@ -404,6 +362,26 @@ private struct CallsDataObserver: View { } } +// MARK: - Call Row Avatar (observation-isolated) + +/// Isolated child view so that `AvatarRepository.shared.avatarVersion` observation +/// does NOT propagate to the entire call list. +private struct CallRowAvatar: View { + let opponentKey: String + let initials: String + let colorIndex: Int + + var body: some View { + let _ = AvatarRepository.shared.avatarVersion + AvatarView( + initials: initials, + colorIndex: colorIndex, + size: 44, + image: AvatarRepository.shared.loadAvatar(publicKey: opponentKey) + ) + } +} + #Preview("Empty State") { CallsView() .preferredColorScheme(.dark) diff --git a/Rosetta/Features/Chats/ChatList/ChatListSearchContent.swift b/Rosetta/Features/Chats/ChatList/ChatListSearchContent.swift index d7f831b..d14ab48 100644 --- a/Rosetta/Features/Chats/ChatList/ChatListSearchContent.swift +++ b/Rosetta/Features/Chats/ChatList/ChatListSearchContent.swift @@ -221,6 +221,8 @@ private extension ChatListSearchContent { if verified > 0 { return verified } if title.caseInsensitiveCompare("Rosetta") == .orderedSame { return 1 } if username.caseInsensitiveCompare("rosetta") == .orderedSame { return 1 } + if title.caseInsensitiveCompare("freddy") == .orderedSame { return 1 } + if username.caseInsensitiveCompare("freddy") == .orderedSame { return 1 } if SystemAccounts.isSystemAccount(publicKey) { return 1 } return 0 } diff --git a/Rosetta/Features/Chats/Search/SearchView.swift b/Rosetta/Features/Chats/Search/SearchView.swift index 7a402a7..a8fb874 100644 --- a/Rosetta/Features/Chats/Search/SearchView.swift +++ b/Rosetta/Features/Chats/Search/SearchView.swift @@ -297,6 +297,8 @@ private struct RecentSection: View { if verified > 0 { return verified } if title.caseInsensitiveCompare("Rosetta") == .orderedSame { return 1 } if username.caseInsensitiveCompare("rosetta") == .orderedSame { return 1 } + if title.caseInsensitiveCompare("freddy") == .orderedSame { return 1 } + if username.caseInsensitiveCompare("freddy") == .orderedSame { return 1 } if SystemAccounts.isSystemAccount(publicKey) { return 1 } return 0 } diff --git a/Rosetta/Features/Settings/SettingsViewModel.swift b/Rosetta/Features/Settings/SettingsViewModel.swift index 740769e..c4f977e 100644 --- a/Rosetta/Features/Settings/SettingsViewModel.swift +++ b/Rosetta/Features/Settings/SettingsViewModel.swift @@ -38,19 +38,7 @@ final class SettingsViewModel: ObservableObject { RosettaColors.avatarColorIndex(for: displayName, publicKey: publicKey) } - /// Own account verified level. - /// Shows badge only for Rosetta administration accounts (level 2). - var verified: Int { - let name = displayName - let user = username - let key = publicKey - if name.caseInsensitiveCompare("Rosetta") == .orderedSame - || user.caseInsensitiveCompare("rosetta") == .orderedSame - || SystemAccounts.isSystemAccount(key) { - return 2 - } - return 0 - } + @Published private(set) var verified: Int = 0 /// Snapshot current state from singletons. Call from `.task {}` or `.onAppear`. func refresh() { @@ -67,6 +55,20 @@ final class SettingsViewModel: ObservableObject { publicKey = account?.publicKey ?? "" + // Server-provided verified level + heuristic fallback (Android ProfileScreen parity: else 2) + let serverVerified = session.verified + if serverVerified > 0 { + verified = serverVerified + } else if (!displayName.isEmpty && displayName.caseInsensitiveCompare("Rosetta") == .orderedSame) + || (!username.isEmpty && username.caseInsensitiveCompare("rosetta") == .orderedSame) + || (!displayName.isEmpty && displayName.caseInsensitiveCompare("freddy") == .orderedSame) + || (!username.isEmpty && username.caseInsensitiveCompare("freddy") == .orderedSame) + || SystemAccounts.isSystemAccount(publicKey) { + verified = 2 + } else { + verified = 0 + } + let state = ProtocolManager.shared.connectionState isConnected = state == .authenticated switch state { diff --git a/RosettaNotificationService/NotificationService.swift b/RosettaNotificationService/NotificationService.swift index f4fc977..7cf8f75 100644 --- a/RosettaNotificationService/NotificationService.swift +++ b/RosettaNotificationService/NotificationService.swift @@ -12,6 +12,9 @@ final class NotificationService: UNNotificationServiceExtension { private static let appGroupID = "group.com.rosetta.dev" private static let badgeKey = "app_badge_count" + private static let processedIdsKey = "nse_processed_message_ids" + /// Max dedup entries kept in App Group — NSE has tight memory limits. + private static let maxProcessedIds = 100 /// Android parity: multiple key names for sender public key extraction. /// Server sends `dialog` field (was `from`). Both kept for backward compat. @@ -132,11 +135,30 @@ final class NotificationService: UNNotificationServiceExtension { return } - // 4. Increment badge count — only for non-muted chats. - let current = shared.integer(forKey: Self.badgeKey) - let newBadge = current + 1 - shared.set(newBadge, forKey: Self.badgeKey) - content.badge = NSNumber(value: newBadge) + // 3.5 Dedup: skip badge increment if we already processed this push. + // Protects against duplicate FCM delivery (rare, but server dedup window is ~10s). + let messageId = content.userInfo["message_id"] as? String + ?? content.userInfo["messageId"] as? String + ?? request.identifier + var processedIds = shared.stringArray(forKey: Self.processedIdsKey) ?? [] + if processedIds.contains(messageId) { + // Already counted — show notification but don't inflate badge. + let currentBadge = shared.integer(forKey: Self.badgeKey) + content.badge = NSNumber(value: currentBadge) + } else { + // 4. Increment badge count — only for non-muted, non-duplicate chats. + let current = shared.integer(forKey: Self.badgeKey) + let newBadge = current + 1 + shared.set(newBadge, forKey: Self.badgeKey) + content.badge = NSNumber(value: newBadge) + + // Track this message ID. Evict oldest if over limit. + processedIds.append(messageId) + if processedIds.count > Self.maxProcessedIds { + processedIds = Array(processedIds.suffix(Self.maxProcessedIds)) + } + shared.set(processedIds, forKey: Self.processedIdsKey) + } } // 5. Normalize sender_public_key in userInfo for tap navigation.