feat: local alias field on the fingerprint sheet (#1507)

* feat: let users set a local alias on the fingerprint sheet

Wire the existing localPetname field to a write path so peers can be
renamed locally; read paths already prefer the alias when present.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix: make local aliases visible and localize the fingerprint field

Give local petnames display precedence over announced nicknames across
peer rows, DM headers, and resolveNickname; rebuild peer state after a
save so lists update immediately. Load the alias draft when the
fingerprint arrives, and add the three local-alias strings across all
30 locales.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix: use macOS 13-compatible onChange for fingerprint alias sync

The two-parameter onChange API requires macOS 14; match the rest of the
views so release and Periphery builds stay green on the 13.0 deployment target.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Taksh Kothari 2026-08-01 14:53:14 +05:30 committed by GitHub
parent 68edb34469
commit c66015d029
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 752 additions and 26 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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