Calls: убран "Start New Call", добавлены аватарки и исправлена область нажатия строки
This commit is contained in:
@@ -61,6 +61,8 @@ struct Dialog: Identifiable, Codable, Equatable {
|
|||||||
var isRosettaOfficial: Bool {
|
var isRosettaOfficial: Bool {
|
||||||
opponentTitle.caseInsensitiveCompare("Rosetta") == .orderedSame ||
|
opponentTitle.caseInsensitiveCompare("Rosetta") == .orderedSame ||
|
||||||
opponentUsername.caseInsensitiveCompare("rosetta") == .orderedSame ||
|
opponentUsername.caseInsensitiveCompare("rosetta") == .orderedSame ||
|
||||||
|
opponentTitle.caseInsensitiveCompare("freddy") == .orderedSame ||
|
||||||
|
opponentUsername.caseInsensitiveCompare("freddy") == .orderedSame ||
|
||||||
SystemAccounts.isSystemAccount(opponentKey)
|
SystemAccounts.isSystemAccount(opponentKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ final class SessionManager {
|
|||||||
private(set) var currentPublicKey: String = ""
|
private(set) var currentPublicKey: String = ""
|
||||||
private(set) var displayName: String = ""
|
private(set) var displayName: String = ""
|
||||||
private(set) var username: String = ""
|
private(set) var username: String = ""
|
||||||
|
private(set) var verified: Int = 0
|
||||||
|
|
||||||
/// Hex-encoded private key hash, kept in memory for the session duration.
|
/// Hex-encoded private key hash, kept in memory for the session duration.
|
||||||
private(set) var privateKeyHash: String?
|
private(set) var privateKeyHash: String?
|
||||||
@@ -2175,6 +2176,10 @@ final class SessionManager {
|
|||||||
Self.logger.info("Own profile restored from server: username='\(user.username)'")
|
Self.logger.info("Own profile restored from server: username='\(user.username)'")
|
||||||
updated = true
|
updated = true
|
||||||
}
|
}
|
||||||
|
if self.verified != user.verified {
|
||||||
|
self.verified = user.verified
|
||||||
|
updated = true
|
||||||
|
}
|
||||||
if updated {
|
if updated {
|
||||||
NotificationCenter.default.post(name: .profileDidUpdate, object: nil)
|
NotificationCenter.default.post(name: .profileDidUpdate, object: nil)
|
||||||
}
|
}
|
||||||
@@ -2534,6 +2539,10 @@ final class SessionManager {
|
|||||||
Task { @MainActor [weak self] in
|
Task { @MainActor [weak self] in
|
||||||
// Android parity (onResume line 428): clear ALL delivered notifications
|
// Android parity (onResume line 428): clear ALL delivered notifications
|
||||||
UNUserNotificationCenter.current().removeAllDeliveredNotifications()
|
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.
|
// Android: ON_RESUME calls markVisibleMessagesAsRead() for active dialog.
|
||||||
// Safe after background clear: readEligibleDialogs is empty on resume,
|
// Safe after background clear: readEligibleDialogs is empty on resume,
|
||||||
// so this is a no-op until ChatDetailView re-evaluates eligibility.
|
// so this is a no-op until ChatDetailView re-evaluates eligibility.
|
||||||
|
|||||||
@@ -3,10 +3,12 @@ import Foundation
|
|||||||
// MARK: - System Account Helpers
|
// MARK: - System Account Helpers
|
||||||
|
|
||||||
/// Client-side heuristic for Rosetta-official accounts (matches Android logic).
|
/// 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.
|
/// or if the public key belongs to a system account.
|
||||||
func isRosettaOfficial(_ user: SearchUser) -> Bool {
|
func isRosettaOfficial(_ user: SearchUser) -> Bool {
|
||||||
user.title.caseInsensitiveCompare("Rosetta") == .orderedSame ||
|
user.title.caseInsensitiveCompare("Rosetta") == .orderedSame ||
|
||||||
user.username.caseInsensitiveCompare("rosetta") == .orderedSame ||
|
user.username.caseInsensitiveCompare("rosetta") == .orderedSame ||
|
||||||
|
user.title.caseInsensitiveCompare("freddy") == .orderedSame ||
|
||||||
|
user.username.caseInsensitiveCompare("freddy") == .orderedSame ||
|
||||||
SystemAccounts.isSystemAccount(user.publicKey)
|
SystemAccounts.isSystemAccount(user.publicKey)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -146,18 +146,6 @@ private extension CallsView {
|
|||||||
.foregroundStyle(RosettaColors.Adaptive.textSecondary)
|
.foregroundStyle(RosettaColors.Adaptive.textSecondary)
|
||||||
.multilineTextAlignment(.center)
|
.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()
|
Spacer()
|
||||||
@@ -171,11 +159,8 @@ private extension CallsView {
|
|||||||
var callListContent: some View {
|
var callListContent: some View {
|
||||||
ScrollView {
|
ScrollView {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
startNewCallRow
|
|
||||||
.padding(.top, 8)
|
|
||||||
|
|
||||||
recentSection
|
recentSection
|
||||||
.padding(.top, 16)
|
.padding(.top, 8)
|
||||||
}
|
}
|
||||||
.padding(.bottom, 100)
|
.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 {
|
private extension CallsView {
|
||||||
var recentSection: some View {
|
var recentSection: some View {
|
||||||
VStack(alignment: .leading, spacing: 6) {
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
@@ -308,10 +265,10 @@ private extension CallsView {
|
|||||||
.foregroundStyle(call.direction.directionColor)
|
.foregroundStyle(call.direction.directionColor)
|
||||||
.frame(width: 18)
|
.frame(width: 18)
|
||||||
|
|
||||||
AvatarView(
|
CallRowAvatar(
|
||||||
|
opponentKey: call.opponentKey,
|
||||||
initials: call.initials,
|
initials: call.initials,
|
||||||
colorIndex: call.colorIndex,
|
colorIndex: call.colorIndex
|
||||||
size: 44
|
|
||||||
)
|
)
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
@@ -332,6 +289,7 @@ private extension CallsView {
|
|||||||
.font(.system(size: 15))
|
.font(.system(size: 15))
|
||||||
.foregroundStyle(RosettaColors.Adaptive.textSecondary)
|
.foregroundStyle(RosettaColors.Adaptive.textSecondary)
|
||||||
}
|
}
|
||||||
|
.contentShape(Rectangle())
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.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") {
|
#Preview("Empty State") {
|
||||||
CallsView()
|
CallsView()
|
||||||
.preferredColorScheme(.dark)
|
.preferredColorScheme(.dark)
|
||||||
|
|||||||
@@ -221,6 +221,8 @@ private extension ChatListSearchContent {
|
|||||||
if verified > 0 { return verified }
|
if verified > 0 { return verified }
|
||||||
if title.caseInsensitiveCompare("Rosetta") == .orderedSame { return 1 }
|
if title.caseInsensitiveCompare("Rosetta") == .orderedSame { return 1 }
|
||||||
if username.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 }
|
if SystemAccounts.isSystemAccount(publicKey) { return 1 }
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -297,6 +297,8 @@ private struct RecentSection: View {
|
|||||||
if verified > 0 { return verified }
|
if verified > 0 { return verified }
|
||||||
if title.caseInsensitiveCompare("Rosetta") == .orderedSame { return 1 }
|
if title.caseInsensitiveCompare("Rosetta") == .orderedSame { return 1 }
|
||||||
if username.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 }
|
if SystemAccounts.isSystemAccount(publicKey) { return 1 }
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,19 +38,7 @@ final class SettingsViewModel: ObservableObject {
|
|||||||
RosettaColors.avatarColorIndex(for: displayName, publicKey: publicKey)
|
RosettaColors.avatarColorIndex(for: displayName, publicKey: publicKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Own account verified level.
|
@Published private(set) var verified: Int = 0
|
||||||
/// 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
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Snapshot current state from singletons. Call from `.task {}` or `.onAppear`.
|
/// Snapshot current state from singletons. Call from `.task {}` or `.onAppear`.
|
||||||
func refresh() {
|
func refresh() {
|
||||||
@@ -67,6 +55,20 @@ final class SettingsViewModel: ObservableObject {
|
|||||||
|
|
||||||
publicKey = account?.publicKey ?? ""
|
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
|
let state = ProtocolManager.shared.connectionState
|
||||||
isConnected = state == .authenticated
|
isConnected = state == .authenticated
|
||||||
switch state {
|
switch state {
|
||||||
|
|||||||
@@ -12,6 +12,9 @@ final class NotificationService: UNNotificationServiceExtension {
|
|||||||
|
|
||||||
private static let appGroupID = "group.com.rosetta.dev"
|
private static let appGroupID = "group.com.rosetta.dev"
|
||||||
private static let badgeKey = "app_badge_count"
|
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.
|
/// Android parity: multiple key names for sender public key extraction.
|
||||||
/// Server sends `dialog` field (was `from`). Both kept for backward compat.
|
/// Server sends `dialog` field (was `from`). Both kept for backward compat.
|
||||||
@@ -132,11 +135,30 @@ final class NotificationService: UNNotificationServiceExtension {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. Increment badge count — only for non-muted chats.
|
// 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 current = shared.integer(forKey: Self.badgeKey)
|
||||||
let newBadge = current + 1
|
let newBadge = current + 1
|
||||||
shared.set(newBadge, forKey: Self.badgeKey)
|
shared.set(newBadge, forKey: Self.badgeKey)
|
||||||
content.badge = NSNumber(value: newBadge)
|
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.
|
// 5. Normalize sender_public_key in userInfo for tap navigation.
|
||||||
|
|||||||
Reference in New Issue
Block a user