diff --git a/bitchat/App/PeerListModel.swift b/bitchat/App/PeerListModel.swift index b96c2e72..b365d09d 100644 --- a/bitchat/App/PeerListModel.swift +++ b/bitchat/App/PeerListModel.swift @@ -213,7 +213,7 @@ final class PeerListModel: ObservableObject { return MeshPeerRow( peerID: peer.peerID, - displayName: isMe ? chatViewModel.nickname : peer.nickname, + displayName: isMe ? chatViewModel.nickname : peer.displayName, isMe: isMe, hasUnread: chatViewModel.hasUnreadMessages(for: peer.peerID), isBlocked: !isMe && chatViewModel.isPeerBlocked(peer.peerID), @@ -248,7 +248,7 @@ final class PeerListModel: ObservableObject { self.groupRows = groupRows renderID = ( meshRows.map { - "\($0.id)-\($0.isConnected)-\($0.isReachable)-\($0.hasUnread)-\($0.isFavorite)-\($0.isBlocked)" + "\($0.id)-\($0.displayName)-\($0.isConnected)-\($0.isReachable)-\($0.hasUnread)-\($0.isFavorite)-\($0.isBlocked)" } + geohashPeople.map { "geo:\($0.id)-\($0.isTeleported)-\($0.isBlocked)-\($0.displayName)" diff --git a/bitchat/App/PrivateConversationModels.swift b/bitchat/App/PrivateConversationModels.swift index 57c93afd..a4e89149 100644 --- a/bitchat/App/PrivateConversationModels.swift +++ b/bitchat/App/PrivateConversationModels.swift @@ -294,6 +294,21 @@ final class PrivateConversationModel: ObservableObject { if conversationPeerID.isGeoDM, case .location(let channel) = locationChannelsModel.selectedChannel { return "#\(channel.geohash)/@\(chatViewModel.geohashDisplayName(for: conversationPeerID))" } + // Local alias wins over a live peer row's announced nickname. + if headerPeerID.id.count == 16 { + let candidates = chatViewModel.identityManager.getCryptoIdentitiesByPeerIDPrefix(headerPeerID) + if let identity = candidates.first, + let social = chatViewModel.identityManager.getSocialIdentity(for: identity.fingerprint), + let pet = social.localPetname, !pet.isEmpty { + return pet + } + } else if let noiseKey = headerPeerID.noiseKey { + let fingerprint = noiseKey.sha256Fingerprint() + if let social = chatViewModel.identityManager.getSocialIdentity(for: fingerprint), + let pet = social.localPetname, !pet.isEmpty { + return pet + } + } if let displayName = peer?.displayName { return displayName } @@ -308,23 +323,15 @@ final class PrivateConversationModel: ObservableObject { if headerPeerID.id.count == 16 { let candidates = chatViewModel.identityManager.getCryptoIdentitiesByPeerIDPrefix(headerPeerID) if let identity = candidates.first, - let social = chatViewModel.identityManager.getSocialIdentity(for: identity.fingerprint) { - if let pet = social.localPetname, !pet.isEmpty { - return pet - } - if !social.claimedNickname.isEmpty { - return social.claimedNickname - } + let social = chatViewModel.identityManager.getSocialIdentity(for: identity.fingerprint), + !social.claimedNickname.isEmpty { + return social.claimedNickname } } else if let noiseKey = headerPeerID.noiseKey { let fingerprint = noiseKey.sha256Fingerprint() - if let social = chatViewModel.identityManager.getSocialIdentity(for: fingerprint) { - if let pet = social.localPetname, !pet.isEmpty { - return pet - } - if !social.claimedNickname.isEmpty { - return social.claimedNickname - } + if let social = chatViewModel.identityManager.getSocialIdentity(for: fingerprint), + !social.claimedNickname.isEmpty { + return social.claimedNickname } } diff --git a/bitchat/App/VerificationModel.swift b/bitchat/App/VerificationModel.swift index d9c2ce08..4e1de457 100644 --- a/bitchat/App/VerificationModel.swift +++ b/bitchat/App/VerificationModel.swift @@ -8,6 +8,9 @@ struct FingerprintPresentationState: Equatable { let theirFingerprint: String? let myFingerprint: String let isVerified: Bool + /// User-assigned local alias (petname), if any — distinct from the + /// peer-claimed nickname. + let localPetname: String? /// Number of currently-valid vouches from peers the user verified /// (0 when the peer is explicitly verified — the stronger badge wins). let voucherCount: Int @@ -20,6 +23,11 @@ struct FingerprintPresentationState: Equatable { var canToggleVerification: Bool { encryptionStatus == .noiseSecured || encryptionStatus == .noiseVerified } + + /// Alias field is editable once we know who we're looking at. + var canEditLocalAlias: Bool { + theirFingerprint != nil + } } enum VerificationScanOutcome: Equatable { @@ -75,6 +83,46 @@ final class VerificationModel: ObservableObject { chatViewModel.unverifyFingerprint(for: peerID) } + /// Persist a local alias for this peer. Empty/whitespace clears it so the + /// claimed nickname shows again. Display paths prefer `localPetname` + /// when set (#1439). + func setLocalPetname(_ petname: String?, for peerID: PeerID) { + let statusPeerID = chatViewModel.getShortIDForNoiseKey(peerID) + guard let fingerprint = chatViewModel.getFingerprint(for: statusPeerID) else { return } + + let trimmed = petname?.trimmingCharacters(in: .whitespacesAndNewlines) + let normalized: String? = (trimmed?.isEmpty == false) ? trimmed : nil + + let existing = chatViewModel.identityManager.getSocialIdentity(for: fingerprint) + let claimed = existing?.claimedNickname + ?? chatViewModel.meshService.peerNickname(peerID: statusPeerID) + ?? chatViewModel.resolveNickname(for: statusPeerID) + var identity = existing ?? SocialIdentity( + fingerprint: fingerprint, + localPetname: nil, + claimedNickname: claimed, + trustLevel: .unknown, + isFavorite: false, + isBlocked: false, + notes: nil + ) + identity.localPetname = normalized + // Prefer the mesh-announced name for claimedNickname so we don't + // persist a previous alias as the "claimed" identity. + if let announced = chatViewModel.meshService.peerNickname(peerID: statusPeerID), + !announced.isEmpty { + identity.claimedNickname = announced + } else if identity.claimedNickname.isEmpty { + identity.claimedNickname = claimed + } + chatViewModel.identityManager.updateSocialIdentity(identity) + // Rebuild peer rows so PeerList / DM header pick up the new display name + // without waiting for an unrelated mesh event. + chatViewModel.unifiedPeerService.refreshPeers() + NotificationCenter.default.post(name: Notification.Name("peerStatusUpdated"), object: nil) + objectWillChange.send() + } + func isVerified(peerID: PeerID) -> Bool { guard let fingerprint = chatViewModel.getFingerprint(for: peerID) else { return false } return peerIdentityStore.isVerified(fingerprint) @@ -86,6 +134,8 @@ final class VerificationModel: ObservableObject { let theirFingerprint = chatViewModel.getFingerprint(for: statusPeerID) let peerNickname = resolveDisplayName(for: peerID, statusPeerID: statusPeerID) let isVerified = theirFingerprint.map { peerIdentityStore.isVerified($0) } ?? false + let localPetname = theirFingerprint + .flatMap { chatViewModel.identityManager.getSocialIdentity(for: $0)?.localPetname } // Vouch state is recomputed on read: only vouchers still in the // verified set count, so removing a verification silently retires the @@ -110,6 +160,7 @@ final class VerificationModel: ObservableObject { theirFingerprint: theirFingerprint, myFingerprint: chatViewModel.getMyFingerprint(), isVerified: isVerified, + localPetname: localPetname, voucherCount: vouchers.count, voucherNames: voucherNames ) @@ -158,6 +209,15 @@ final class VerificationModel: ObservableObject { } private func resolveDisplayName(for peerID: PeerID, statusPeerID: PeerID) -> String { + // Prefer an explicit local alias even when a live peer row exists — + // peer.displayName already does this once UnifiedPeerService rebuilds, + // but read social identity directly so the fingerprint sheet header + // updates before that rebuild lands. + if let fingerprint = chatViewModel.getFingerprint(for: statusPeerID), + let pet = chatViewModel.identityManager.getSocialIdentity(for: fingerprint)?.localPetname, + !pet.isEmpty { + return pet + } if let peer = chatViewModel.getPeer(byID: statusPeerID) { return peer.displayName } @@ -171,9 +231,6 @@ final class VerificationModel: ObservableObject { } let fingerprint = data.sha256Fingerprint() if let social = chatViewModel.identityManager.getSocialIdentity(for: fingerprint) { - if let pet = social.localPetname, !pet.isEmpty { - return pet - } if !social.claimedNickname.isEmpty { return social.claimedNickname } diff --git a/bitchat/Localizable.xcstrings b/bitchat/Localizable.xcstrings index efda8dc1..8ceebf02 100644 --- a/bitchat/Localizable.xcstrings +++ b/bitchat/Localizable.xcstrings @@ -44954,6 +44954,564 @@ } } }, + "fingerprint.local_alias.hint" : { + "comment" : "Explanation under the local alias field", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "على هذا الجهاز فقط. اتركه فارغًا لاستخدام اللقب الذي يدّعونه." + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "শুধু এই ডিভাইসে। তাদের দাবিকৃত ডাকনাম ব্যবহার করতে খালি রাখুন।" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "nur auf diesem gerät. leer lassen, um den beanspruchten nickname zu verwenden." + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "only on this device. leave blank to use their claimed nickname." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "solo en este dispositivo. déjalo vacío para usar su apodo reclamado." + } + }, + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "فقط روی این دستگاه. برای استفاده از نام مستعار اعلام‌شده خالی بگذارید." + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "sa device na ito lang. iwanang blangko para gamitin ang kanilang claimed nickname." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "uniquement sur cet appareil. laisse vide pour utiliser leur surnom annoncé." + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "רק במכשיר הזה. השאר ריק כדי להשתמש בכינוי שהם מצהירים עליו." + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "केवल इस डिवाइस पर। उनका दावा किया उपनाम इस्तेमाल करने के लिए खाली छोड़ें।" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "hanya di perangkat ini. biarkan kosong untuk memakai nama panggilan yang mereka klaim." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "solo su questo dispositivo. lascia vuoto per usare il nickname dichiarato." + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "この端末のみ。空欄なら相手の名乗りニックネームを使います。" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "이 기기에만 저장됩니다. 비워 두면 상대가 주장한 닉네임을 씁니다." + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "hanya pada peranti ini. biarkan kosong untuk guna nama samaran yang mereka tuntut." + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "यस यन्त्रमा मात्र। उनीहरूको दाबी गरिएको उपनाम प्रयोग गर्न खाली छोड्नुहोस्।" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "alleen op dit apparaat. laat leeg om hun geclaimde bijnaam te gebruiken." + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "tylko na tym urządzeniu. zostaw puste, by użyć ich deklarowanego nicku." + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "apenas neste dispositivo. deixa em branco para usar a alcunha reclamada." + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "somente neste dispositivo. deixe em branco para usar o apelido declarado." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "только на этом устройстве. оставь пустым, чтобы использовать заявленный ник." + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "bara på den här enheten. lämna tomt för att använda deras angivna smeknamn." + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "இந்த சாதனத்தில் மட்டும். அவர்கள் கூறும் புனைபெயரைப் பயன்படுத்த காலியாக விடவும்." + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "เฉพาะบนอุปกรณ์นี้ เว้นว่างเพื่อใช้ชื่อเล่นที่พวกเขาอ้าง" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "yalnızca bu cihazda. iddia edilen takma adı kullanmak için boş bırak." + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "лише на цьому пристрої. залиш порожнім, щоб використати їх заявлене ім’я." + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "صرف اس ڈیوائس پر۔ ان کا دعویٰ کردہ عرفی نام استعمال کرنے کے لیے خالی چھوڑیں۔" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "chỉ trên thiết bị này. để trống để dùng biệt danh họ tuyên bố." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "仅保存在此设备。留空则使用对方声称的昵称。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "僅保存在此裝置。留空則使用對方聲稱的暱稱。" + } + } + } + }, + "fingerprint.local_alias.label" : { + "comment" : "Label for the local-only alias field on the fingerprint sheet", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "اسم مستعار محلي" + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "স্থানীয় উপনাম" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "lokaler alias" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "local alias" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "alias local" + } + }, + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "نام مستعار محلی" + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "local na alias" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "alias local" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "כינוי מקומי" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "स्थानीय उपनाम" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "alias lokal" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "alias locale" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "ローカル別名" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "로컬 별칭" + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "alias tempatan" + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "स्थानीय उपनाम" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "lokale alias" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "lokalny alias" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "alcunha local" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "apelido local" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "локальный псевдоним" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "lokalt alias" + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "உள்ளூர் மாற்றுப்பெயர்" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "ชื่อเล่นในเครื่อง" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "yerel takma ad" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "локальний псевдонім" + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "مقامی عرفی نام" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "bí danh cục bộ" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "本地别名" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "本地別名" + } + } + } + }, + "fingerprint.local_alias.placeholder" : { + "comment" : "Placeholder for the local alias field on the fingerprint sheet", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "اسم لهذا الشخص" + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "এই ব্যক্তির জন্য নাম" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "name für diese person" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "name for this person" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "nombre para esta persona" + } + }, + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "نام برای این فرد" + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "pangalan para sa taong ito" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "nom pour cette personne" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "שם לאדם הזה" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "इस व्यक्ति के लिए नाम" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "nama untuk orang ini" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "nome per questa persona" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "この人の名前" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "이 사람의 이름" + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "nama untuk orang ini" + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "यस व्यक्तिको लागि नाम" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "naam voor deze persoon" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "nazwa dla tej osoby" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "nome para esta pessoa" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "nome para esta pessoa" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "имя для этого человека" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "namn för den här personen" + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "இந்த நபருக்கான பெயர்" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "ชื่อสำหรับคนนี้" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "bu kişi için ad" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "ім’я для цієї людини" + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "اس شخص کا نام" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "tên cho người này" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "给此人的名称" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "給此人的名稱" + } + } + } + }, "fingerprint.message.verified" : { "extractionState" : "manual", "localizations" : { diff --git a/bitchat/Models/BitchatPeer.swift b/bitchat/Models/BitchatPeer.swift index fcccda49..61c0a5f8 100644 --- a/bitchat/Models/BitchatPeer.swift +++ b/bitchat/Models/BitchatPeer.swift @@ -15,6 +15,10 @@ struct BitchatPeer: Equatable { // Nostr identity (if known) var nostrPublicKey: String? + + /// Device-local alias (petname). Never sent over the wire; when set it + /// outranks the peer-claimed `nickname` for display only. + var localPetname: String? // Connection state enum ConnectionState { @@ -51,7 +55,10 @@ struct BitchatPeer: Equatable { // Display helpers var displayName: String { - nickname.isEmpty ? String(peerID.id.prefix(8)) : nickname + if let localPetname, !localPetname.isEmpty { + return localPetname + } + return nickname.isEmpty ? String(peerID.id.prefix(8)) : nickname } var statusIcon: String { @@ -78,13 +85,15 @@ struct BitchatPeer: Equatable { nickname: String, lastSeen _: Date = Date(), isConnected: Bool = false, - isReachable: Bool = false + isReachable: Bool = false, + localPetname: String? = nil ) { self.peerID = peerID self.noisePublicKey = noisePublicKey self.nickname = nickname self.isConnected = isConnected self.isReachable = isReachable + self.localPetname = localPetname // Load favorite status - will be set later by the manager self.favoriteStatus = nil diff --git a/bitchat/Services/UnifiedPeerService.swift b/bitchat/Services/UnifiedPeerService.swift index c22c7e43..f1e4f8fa 100644 --- a/bitchat/Services/UnifiedPeerService.swift +++ b/bitchat/Services/UnifiedPeerService.swift @@ -195,7 +195,8 @@ final class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate { nickname: peerInfo.nickname, lastSeen: peerInfo.lastSeen, isConnected: peerInfo.isConnected, - isReachable: isReachable + isReachable: isReachable, + localPetname: localPetname(forFingerprint: fingerprint) ) // Check for favorite status @@ -218,7 +219,8 @@ final class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate { nickname: favorite.peerNickname, lastSeen: favorite.lastUpdated, isConnected: false, - isReachable: false + isReachable: false, + localPetname: localPetname(forFingerprint: favorite.peerNoisePublicKey.sha256Fingerprint()) ) peer.favoriteStatus = favorite @@ -227,6 +229,21 @@ final class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate { return peer } + /// Rebuild peer rows after a social-identity write (local alias, etc.) so + /// display names update without waiting for a mesh event. + func refreshPeers() { + updatePeers() + } + + private func localPetname(forFingerprint fingerprint: String?) -> String? { + guard let fingerprint, + let petname = identityManager.getSocialIdentity(for: fingerprint)?.localPetname, + !petname.isEmpty else { + return nil + } + return petname + } + // MARK: - Public Methods /// Get peer by ID diff --git a/bitchat/ViewModels/ChatPeerIdentityCoordinator.swift b/bitchat/ViewModels/ChatPeerIdentityCoordinator.swift index 65b30df9..a2bd6297 100644 --- a/bitchat/ViewModels/ChatPeerIdentityCoordinator.swift +++ b/bitchat/ViewModels/ChatPeerIdentityCoordinator.swift @@ -477,15 +477,21 @@ final class ChatPeerIdentityCoordinator { return peerID.id } + // Local aliases outrank announced nicknames so a saved petname is + // actually visible after the fingerprint sheet dismisses. + if let fingerprint = getFingerprint(for: peerID), + let identity = context.socialIdentity(forFingerprint: fingerprint), + let petname = identity.localPetname, + !petname.isEmpty { + return petname + } + if let nickname = context.meshPeerNicknames()[peerID] { return nickname } if let fingerprint = getFingerprint(for: peerID), let identity = context.socialIdentity(forFingerprint: fingerprint) { - if let petname = identity.localPetname { - return petname - } return identity.claimedNickname } diff --git a/bitchat/Views/FingerprintView.swift b/bitchat/Views/FingerprintView.swift index a6c371ae..bce7e89c 100644 --- a/bitchat/Views/FingerprintView.swift +++ b/bitchat/Views/FingerprintView.swift @@ -14,6 +14,8 @@ struct FingerprintView: View { let peerID: PeerID @Environment(\.dismiss) var dismiss @ThemedPalette private var palette + @State private var aliasDraft: String = "" + @State private var didLoadAlias = false private var textColor: Color { palette.primary } @@ -26,6 +28,21 @@ struct FingerprintView: View { static let verifiedBadge: LocalizedStringKey = "fingerprint.badge.verified" static let notVerifiedBadge: LocalizedStringKey = "fingerprint.badge.not_verified" static let verifiedMessage: LocalizedStringKey = "fingerprint.message.verified" + static let localAlias = String( + localized: "fingerprint.local_alias.label", + defaultValue: "local alias", + comment: "Label for the local-only alias field on the fingerprint sheet" + ) + static let localAliasPlaceholder = String( + localized: "fingerprint.local_alias.placeholder", + defaultValue: "name for this person", + comment: "Placeholder for the local alias field on the fingerprint sheet" + ) + static let localAliasHint = String( + localized: "fingerprint.local_alias.hint", + defaultValue: "only on this device. leave blank to use their claimed nickname.", + comment: "Explanation under the local alias field" + ) static func verifyHint(_ nickname: String) -> String { String( format: String(localized: "fingerprint.message.verify_hint", comment: "Instruction to compare fingerprints with a named peer"), @@ -85,6 +102,26 @@ struct FingerprintView: View { .padding() .background(palette.secondary.opacity(0.1)) .cornerRadius(8) + + if fingerprintState.canEditLocalAlias { + VStack(alignment: .leading, spacing: 8) { + Text(verbatim: Strings.localAlias) + .bitchatFont(size: 12, weight: .bold) + .foregroundColor(textColor.opacity(0.7)) + + TextField(Strings.localAliasPlaceholder, text: $aliasDraft) + .bitchatFont(size: 14) + .foregroundColor(textColor) + .padding(10) + .background(palette.secondary.opacity(0.1)) + .cornerRadius(8) + .onSubmit { commitAlias() } + + Text(verbatim: Strings.localAliasHint) + .bitchatFont(size: 11) + .foregroundColor(textColor.opacity(0.6)) + } + } // Their fingerprint VStack(alignment: .leading, spacing: 8) { @@ -248,6 +285,37 @@ struct FingerprintView: View { .padding() .frame(maxWidth: .infinity, maxHeight: .infinity) .themedSheetBackground() + .onAppear { + syncAliasDraft(from: fingerprintState, force: true) + } + .onChange(of: fingerprintState.theirFingerprint) { _ in + // Fingerprint can arrive after the sheet opens; load (or reload) + // the saved alias then, otherwise an empty draft looks like a clear. + syncAliasDraft(from: fingerprintState, force: false) + } + .onDisappear { + commitAlias() + } + } + + /// Populate `aliasDraft` from the persisted petname once we know the + /// fingerprint. `force` reloads even if we already loaded (onAppear). + private func syncAliasDraft(from state: FingerprintPresentationState, force: Bool) { + guard state.canEditLocalAlias else { return } + if didLoadAlias && !force { return } + aliasDraft = state.localPetname ?? "" + didLoadAlias = true + } + + private func commitAlias() { + let fingerprintState = verificationModel.fingerprintPresentation(for: peerID) + guard fingerprintState.canEditLocalAlias else { return } + // Don't treat "never loaded a draft" as an intentional clear. + guard didLoadAlias else { return } + let current = fingerprintState.localPetname ?? "" + let draft = aliasDraft.trimmingCharacters(in: .whitespacesAndNewlines) + guard draft != current else { return } + verificationModel.setLocalPetname(draft.isEmpty ? nil : draft, for: peerID) } private func formatFingerprint(_ fingerprint: String) -> String { diff --git a/bitchatTests/ChatPeerIdentityCoordinatorContextTests.swift b/bitchatTests/ChatPeerIdentityCoordinatorContextTests.swift index bbdda609..982a6de3 100644 --- a/bitchatTests/ChatPeerIdentityCoordinatorContextTests.swift +++ b/bitchatTests/ChatPeerIdentityCoordinatorContextTests.swift @@ -383,6 +383,10 @@ struct ChatPeerIdentityCoordinatorContextTests { ) #expect(coordinator.resolveNickname(for: identityPeer) == "bob!") + // Local alias outranks a live mesh announce for the same peer. + context.nicknamesByPeerID[identityPeer] = "bob" + #expect(coordinator.resolveNickname(for: identityPeer) == "bob!") + #expect(coordinator.resolveNickname(for: unknownPeer) == "anonfeed") #expect(coordinator.getMyFingerprint() == "my-fingerprint") }