feat: verification seal on private message sender rows (#1573)

* feat: show verification seal on private message sender rows

DM-only filled seal next to verified peers' names closes the remaining
UI gap from #1439 without dressing public mesh timelines.

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

* fix: skip in-string verified ✓ on private rows with SF Symbol seal

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

* Add verified_sender catalog entry (30 locales) + live seal repaint on verify

The accessibility label existed only in code, so VoiceOver read english
in 29 locales; the coverage test can't see source-only keys. Also forward
peerIdentityStore.$verifiedFingerprints into objectWillChange so toggling
verification repaints rows in an open DM instead of waiting for the next
unrelated invalidation.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Taksh Kothari 2026-08-01 18:21:28 +05:30 committed by GitHub
parent 681c180060
commit 0152344554
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 229 additions and 2 deletions

View File

@ -128,6 +128,18 @@ final class ConversationUIModel: ObservableObject {
message.sender == currentNickname || message.senderPeerID == chatViewModel.meshService.myPeerID
}
/// Whether a private-message row should show the filled verification seal
/// next to the sender name (#1439). Scoped to DMs only public timelines
/// have different trust semantics and stay undressed.
func showsVerifiedSeal(for message: BitchatMessage) -> Bool {
guard message.isPrivate,
message.sender != "system",
!isSentByCurrentUser(message),
let peerID = message.senderPeerID else { return false }
guard let fingerprint = chatViewModel.getFingerprint(for: peerID) else { return false }
return chatViewModel.peerIdentityStore.isVerified(fingerprint)
}
func senderDisplayName(for peerID: PeerID, fallbackMessages: [BitchatMessage]) -> String? {
if peerID.isGeoDM || peerID.isGeoChat {
return chatViewModel.geohashDisplayName(for: peerID)
@ -219,6 +231,15 @@ final class ConversationUIModel: ObservableObject {
self?.refreshComputedState()
}
.store(in: &cancellables)
// Verify/unverify while a DM is open must repaint existing rows
// showsVerifiedSeal is computed per render, so forward the store change.
chatViewModel.peerIdentityStore.$verifiedFingerprints
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
self?.objectWillChange.send()
}
.store(in: &cancellables)
}
private func refreshComputedState() {

View File

@ -26973,6 +26973,191 @@
}
}
},
"content.accessibility.verified_sender" : {
"extractionState" : "manual",
"localizations" : {
"ar" : {
"stringUnit" : {
"state" : "translated",
"value" : "مرسل موثق"
}
},
"bn" : {
"stringUnit" : {
"state" : "translated",
"value" : "যাচাইকৃত প্রেরক"
}
},
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Verifizierter absender"
}
},
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Verified sender"
}
},
"es" : {
"stringUnit" : {
"state" : "translated",
"value" : "Remitente verificado"
}
},
"fa" : {
"stringUnit" : {
"state" : "translated",
"value" : "فرستنده تأییدشده"
}
},
"fil" : {
"stringUnit" : {
"state" : "translated",
"value" : "Beripikadong nagpadala"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Expéditeur vérifié"
}
},
"he" : {
"stringUnit" : {
"state" : "translated",
"value" : "שולח מאומת"
}
},
"hi" : {
"stringUnit" : {
"state" : "translated",
"value" : "सत्यापित प्रेषक"
}
},
"id" : {
"stringUnit" : {
"state" : "translated",
"value" : "Pengirim terverifikasi"
}
},
"it" : {
"stringUnit" : {
"state" : "translated",
"value" : "Mittente verificato"
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "確認済みの送信者"
}
},
"ko" : {
"stringUnit" : {
"state" : "translated",
"value" : "확인된 보낸 사람"
}
},
"ms" : {
"stringUnit" : {
"state" : "translated",
"value" : "Pengirim yang disahkan"
}
},
"ne" : {
"stringUnit" : {
"state" : "translated",
"value" : "प्रमाणित प्रेषक"
}
},
"nl" : {
"stringUnit" : {
"state" : "translated",
"value" : "Geverifieerde afzender"
}
},
"pl" : {
"stringUnit" : {
"state" : "translated",
"value" : "Zweryfikowany nadawca"
}
},
"pt" : {
"stringUnit" : {
"state" : "translated",
"value" : "Remetente verificado"
}
},
"pt-BR" : {
"stringUnit" : {
"state" : "translated",
"value" : "Remetente verificado"
}
},
"ru" : {
"stringUnit" : {
"state" : "translated",
"value" : "Подтверждённый отправитель"
}
},
"sv" : {
"stringUnit" : {
"state" : "translated",
"value" : "Verifierad avsändare"
}
},
"ta" : {
"stringUnit" : {
"state" : "translated",
"value" : "சரிபார்க்கப்பட்ட அனுப்புநர்"
}
},
"th" : {
"stringUnit" : {
"state" : "translated",
"value" : "ผู้ส่งที่ยืนยันแล้ว"
}
},
"tr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Doğrulanmış gönderen"
}
},
"uk" : {
"stringUnit" : {
"state" : "translated",
"value" : "Підтверджений відправник"
}
},
"ur" : {
"stringUnit" : {
"state" : "translated",
"value" : "تصدیق شدہ مرسل"
}
},
"vi" : {
"stringUnit" : {
"state" : "translated",
"value" : "Người gửi đã xác minh"
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "已验证的发送者"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "已驗證的傳送者"
}
}
}
},
"content.accessibility.view_fingerprint_hint" : {
"extractionState" : "manual",
"localizations" : {

View File

@ -68,7 +68,10 @@ final class ChatMessageFormatter {
suffixStyle.foregroundColor = baseColor.opacity(0.6)
result.append(AttributedString(suffix).mergingAttributes(suffixStyle))
}
if isVerifiedSender {
// Private rows render a filled SF Symbol seal beside the lock
// (TextMessageView / MediaMessageView); skip the in-string there
// so verified DMs don't show two markers.
if isVerifiedSender, !message.isPrivate {
appendVerifiedSeal(to: &result, baseColor: baseColor, design: design)
}
result.append(AttributedString("> ").mergingAttributes(senderStyle))
@ -388,7 +391,7 @@ final class ChatMessageFormatter {
suffixStyle.foregroundColor = baseColor.opacity(0.6)
result.append(AttributedString(suffix).mergingAttributes(suffixStyle))
}
if isVerifiedSender {
if isVerifiedSender, !message.isPrivate {
appendVerifiedSeal(to: &result, baseColor: baseColor, design: design)
}
result.append(AttributedString("> ").mergingAttributes(senderStyle))

View File

@ -50,6 +50,15 @@ struct TextMessageView: View {
.padding(.trailing, 4)
.accessibilityHidden(true)
}
if conversationUIModel.showsVerifiedSeal(for: message) {
Image(systemName: "checkmark.seal.fill")
.font(.bitchatSystem(size: 8))
.foregroundColor(Color.green.opacity(0.85))
.padding(.trailing, 4)
.accessibilityLabel(
String(localized: "content.accessibility.verified_sender", defaultValue: "Verified sender", comment: "Accessibility label for the seal next to a verified peer's name on a private message")
)
}
if message.isBridged {
Image(systemName: "network")
.font(.bitchatSystem(size: 8))

View File

@ -48,6 +48,15 @@ struct MediaMessageView: View {
.padding(.trailing, 4)
.accessibilityHidden(true)
}
if conversationUIModel.showsVerifiedSeal(for: message) {
Image(systemName: "checkmark.seal.fill")
.font(.bitchatSystem(size: 8))
.foregroundColor(Color.green.opacity(0.85))
.padding(.trailing, 4)
.accessibilityLabel(
String(localized: "content.accessibility.verified_sender", defaultValue: "Verified sender", comment: "Accessibility label for the seal next to a verified peer's name on a private message")
)
}
VStack(alignment: .leading, spacing: 2) {
HStack(alignment: .center, spacing: 4) {
Text(conversationUIModel.formatMessageHeader(message, colorScheme: colorScheme, theme: theme))