Calls: убран "Start New Call", добавлены аватарки и исправлена область нажатия строки

This commit is contained in:
2026-03-31 19:32:11 +05:00
parent 464fae37a9
commit 0470b306a9
8 changed files with 85 additions and 66 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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