mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-08-29 07:27:16 +00:00
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:
parent
68edb34469
commit
c66015d029
@ -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)"
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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" : {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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")
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user