Review fixes: gate nostr availability on a stored recipient key

- Codex P2 / review: a mutual favorite with a nil peerNostrPublicKey
  still reported .nostrAvailable, so the DM caption could claim
  "end-to-end encrypted" with neither a Noise session nor a usable
  Nostr key. BitchatPeer.connectionState (and the header fallback in
  PrivateConversationModel) now require a stored recipient key —
  mirroring NostrTransport's reachability rule. Regression test:
  mutual + nil key → .offline; key present → .nostrAvailable.
- Copy: "local-only identity" → "device-local identity" (all 30
  locales) — once a fingerprint or QR is shared it isn't "only" local;
  device-local says where the keys live without over-claiming.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
jack 2026-08-09 11:48:24 +02:00
parent d723e5152a
commit e3b22f3598
4 changed files with 69 additions and 33 deletions

View File

@ -357,7 +357,10 @@ final class PrivateConversationModel: ObservableObject {
}
if let noiseKey = Data(hexString: headerPeerID.id),
let favoriteStatus = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey),
favoriteStatus.isMutual {
favoriteStatus.isMutual,
// No stored recipient key means no Nostr delivery — and the
// "end-to-end encrypted" caption keys off .nostrAvailable.
favoriteStatus.peerNostrPublicKey != nil {
return .nostrAvailable
}
if chatViewModel.meshService.isPeerConnected(headerPeerID) || chatViewModel.connectedPeers.contains(headerPeerID) {

View File

@ -11560,181 +11560,181 @@
"ar" : {
"stringUnit" : {
"state" : "translated",
"value" : "هوية محلية فقط"
"value" : "هوية محفوظة على الجهاز"
}
},
"bn" : {
"stringUnit" : {
"state" : "translated",
"value" : "শুধুই স্থানীয় পরিচয়"
"value" : "ডিভাইসে থাকা পরিচয়"
}
},
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "nur lokale identität"
"value" : "gerätelokale identität"
}
},
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "local-only identity"
"value" : "device-local identity"
}
},
"es" : {
"stringUnit" : {
"state" : "translated",
"value" : "identidad solo local"
"value" : "identidad local del dispositivo"
}
},
"fa" : {
"stringUnit" : {
"state" : "translated",
"value" : "هویت فقط محلی"
"value" : "هویت محلی دستگاه"
}
},
"fil" : {
"stringUnit" : {
"state" : "translated",
"value" : "pagkakakilanlang lokal lamang"
"value" : "pagkakakilanlang nasa device"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "identité locale uniquement"
"value" : "identité locale à l'appareil"
}
},
"he" : {
"stringUnit" : {
"state" : "translated",
"value" : "זהות מקומית בלבד"
"value" : "זהות מקומית במכשיר"
}
},
"hi" : {
"stringUnit" : {
"state" : "translated",
"value" : "केवल-स्थानीय पहचान"
"value" : "डिवाइस-स्थानीय पहचान"
}
},
"id" : {
"stringUnit" : {
"state" : "translated",
"value" : "identitas hanya lokal"
"value" : "identitas lokal perangkat"
}
},
"it" : {
"stringUnit" : {
"state" : "translated",
"value" : "identità solo locale"
"value" : "identità locale al dispositivo"
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "ローカル限定のアイデンティティ"
"value" : "端末ローカルのアイデンティティ"
}
},
"ko" : {
"stringUnit" : {
"state" : "translated",
"value" : "이 기기에만 있는 신원"
"value" : "기기 로컬 신원"
}
},
"ms" : {
"stringUnit" : {
"state" : "translated",
"value" : "identiti setempat sahaja"
"value" : "identiti setempat peranti"
}
},
"ne" : {
"stringUnit" : {
"state" : "translated",
"value" : "स्थानीय-मात्र परिचय"
"value" : "यन्त्र-स्थानीय परिचय"
}
},
"nl" : {
"stringUnit" : {
"state" : "translated",
"value" : "alleen-lokale identiteit"
"value" : "apparaat-lokale identiteit"
}
},
"pl" : {
"stringUnit" : {
"state" : "translated",
"value" : "tożsamość tylko lokalna"
"value" : "tożsamość lokalna urządzenia"
}
},
"pt" : {
"stringUnit" : {
"state" : "translated",
"value" : "identidade apenas local"
"value" : "identidade local do dispositivo"
}
},
"pt-BR" : {
"stringUnit" : {
"state" : "translated",
"value" : "identidade apenas local"
"value" : "identidade local do dispositivo"
}
},
"ru" : {
"stringUnit" : {
"state" : "translated",
"value" : "идентичность только на устройстве"
"value" : "идентичность на устройстве"
}
},
"sv" : {
"stringUnit" : {
"state" : "translated",
"value" : "endast lokal identitet"
"value" : "enhetslokal identitet"
}
},
"ta" : {
"stringUnit" : {
"state" : "translated",
"value" : "உள்ளூர்-மட்டும் அடையாளம்"
"value" : "சாதன-உள்ளூர் அடையாளம்"
}
},
"th" : {
"stringUnit" : {
"state" : "translated",
"value" : "ตัวตนเฉพาะในเครื่อง"
"value" : "ตัวตนในเครื่อง"
}
},
"tr" : {
"stringUnit" : {
"state" : "translated",
"value" : "yalnızca yerel kimlik"
"value" : "cihaza yerel kimlik"
}
},
"uk" : {
"stringUnit" : {
"state" : "translated",
"value" : "ідентичність лише на пристрої"
"value" : "ідентичність на пристрої"
}
},
"ur" : {
"stringUnit" : {
"state" : "translated",
"value" : "صرف مقامی شناخت"
"value" : "ڈیوائس مقامی شناخت"
}
},
"vi" : {
"stringUnit" : {
"state" : "translated",
"value" : "danh tính chỉ trên thiết bị"
"value" : "danh tính cục bộ trên thiết bị"
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "仅限本机的身份"
"value" : "设备本地身份"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "僅限本機的身分"
"value" : "裝置本機身分"
}
}
}

View File

@ -33,13 +33,22 @@ struct BitchatPeer: Equatable {
return .bluetoothConnected
} else if isReachable {
return .meshReachable
} else if favoriteStatus?.isMutual == true {
// Mutual favorites can communicate via Nostr when offline
} else if favoriteStatus?.isMutual == true, reachableNostrPublicKey != nil {
// Mutual favorites can communicate via Nostr when offline — but
// only with a stored recipient key. NostrTransport applies the
// same rule when computing reachable peers; without a key,
// "available" would be a lie, and the DM header's
// "end-to-end encrypted" caption keys off this state.
return .nostrAvailable
} else {
return .offline
}
}
/// The Nostr key a private envelope would actually be sealed to, if any.
var reachableNostrPublicKey: String? {
nostrPublicKey ?? favoriteStatus?.peerNostrPublicKey
}
var isFavorite: Bool {
favoriteStatus?.isFavorite ?? false

View File

@ -36,6 +36,30 @@ struct BitchatPeerTests {
#expect(peer.statusIcon == "🌙")
}
@Test("Mutual favorite without a stored Nostr key is offline, not nostr-available")
func mutualFavoriteWithoutNostrKeyIsOffline() {
let peerID = PeerID(str: "0123456789abcdef")
let noiseKey = Data((0..<32).map(UInt8.init))
var peer = BitchatPeer(peerID: peerID, noisePublicKey: noiseKey, nickname: "A", isConnected: false, isReachable: false)
peer.favoriteStatus = FavoriteRelationship(
peerNoisePublicKey: noiseKey,
peerNostrPublicKey: nil,
peerNickname: "A",
isFavorite: true,
theyFavoritedUs: true,
favoritedAt: Date(timeIntervalSince1970: 1),
lastUpdated: Date(timeIntervalSince1970: 2)
)
// Nothing to seal a Nostr envelope to: claiming availability here
// would relight the DM header's "end-to-end encrypted" caption with
// neither a Noise session nor a usable recipient key.
#expect(peer.connectionState == .offline)
peer.nostrPublicKey = "abcdef"
#expect(peer.connectionState == .nostrAvailable)
}
@Test("Mutual offline peers show Nostr icon")
func mutualFavoriteOfflinePeerShowsNostrIcon() {
let peerID = PeerID(str: "0011223344556677")