Give private DMs an unmistakable visual signature (#1357)

* Give private DMs an unmistakable visual signature

An open DM renders identically to the public room — same view, same
green-on-black surface, with a small header name and two orange icons
as the only cues. For this audience the cost of misreading "am I in the
encrypted DM or the public channel?" is severe: sensitive text typed
into the wrong composer.

Four presentation-layer cues; no formatter or cache changes:

- The composer placeholder states the destination instead of a generic
  prompt: "message @jack — private" in a DM, "message #mesh — public,
  nearby" on mesh, "message #9q8yy — public" in a geohash channel.
- A persistent lock caption sits above the DM composer. It reads
  "private · end-to-end encrypted" only once the Noise session is
  actually secured or verified, and "private conversation" before that
  — the caption must not overstate encryption mid-handshake.
- The DM sheet header carries a faint orange wash (6%), extending the
  existing orange self-accent to the chrome.
- Each private message row is prefixed with a small orange lock glyph
  (view-layer, hidden from VoiceOver — the caption carries the
  semantic; the cached AttributedString formatter is untouched).

New strings are added source-language (en) only.

* Fix geohash-DM caption and placeholder

Two carve/review follow-ups:

- The privacy caption showed "private conversation" for geohash DMs,
  implying they are not encrypted — but geohash DMs are NIP-17
  gift-wrapped (always end-to-end encrypted), they just carry no Noise
  session status. Show the encrypted caption for geohash DMs and for
  secured Noise sessions; the pre-secured wording now applies only while
  a mesh handshake is still in progress.
- The private-chat placeholder prepended "@" to the partner name, which
  for a geohash DM (whose display name is already "#geohash/@name")
  produced a doubled "@". The "@" is now added only for mesh nicknames.

* Make the DM header orange wash visible in the matrix theme

The 6% orange background was chained after .themedSurface(), so in the
default matrix theme (whose themedSurface paints an opaque background)
the wash sat behind the surface and never rendered — it was only
visible in liquid glass. Apply the orange tint before .themedSurface()
so it layers in front of the themed background.

* Align DM lock glyph across text and media rows; keep header wash visible under glass

Media rows in a private conversation now get the same leading lock
glyph as text rows, so left edges line up instead of misaligning by
the glyph's width. The DM header's orange wash gets a higher opacity
under the liquid-glass theme, where themedSurface() adds no opaque
backing and 6% orange disappears into the backdrop gradient. Also
drops the dead sender != "system" guard in TextMessageView — system
messages are routed to systemMessageRow before this view is built.

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

* Remove orphaned content.input.message_placeholder from the string catalog

The destination-stating placeholders replaced its last code reference;
nothing on the branch resolves this key anymore.

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

---------

Co-authored-by: jack <212554440+jackjackbits@users.noreply.github.com>
Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Hot Pixel Group
2026-07-06 10:06:18 +02:00
committed by GitHub
co-authored by jack jack Claude Fable 5
parent 9ccff9cce4
commit c8ceac1968
5 changed files with 190 additions and 222 deletions
+39 -158
View File
@@ -17846,181 +17846,62 @@
}
}
},
"content.input.message_placeholder" : {
"content.input.placeholder.location" : {
"comment" : "Composer placeholder for a public geohash channel, naming it",
"extractionState" : "manual",
"localizations" : {
"ar" : {
"stringUnit" : {
"state" : "translated",
"value" : "اكتب رسالة..."
}
},
"bn" : {
"stringUnit" : {
"state" : "translated",
"value" : "একটি বার্তা লিখুন..."
}
},
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "nachricht eingeben..."
}
},
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "type a message..."
"value" : "message #%@ — public"
}
},
"es" : {
}
}
},
"content.input.placeholder.mesh" : {
"comment" : "Composer placeholder for the public mesh channel",
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "escribe un mensaje..."
"value" : "message #mesh — public, nearby"
}
},
"fil" : {
}
}
},
"content.input.placeholder.private" : {
"comment" : "Composer placeholder inside a private chat, naming the conversation partner",
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "mag-type ng mensahe..."
"value" : "message %@ — private"
}
},
"fr" : {
}
}
},
"content.private.caption" : {
"comment" : "Caption above the private chat composer before encryption is established",
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "écris un message..."
"value" : "private conversation"
}
},
"he" : {
}
}
},
"content.private.caption_encrypted" : {
"comment" : "Caption above the private chat composer once the session is end-to-end encrypted",
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "כתוב הודעה..."
}
},
"hi" : {
"stringUnit" : {
"state" : "translated",
"value" : "संदेश लिखें..."
}
},
"id" : {
"stringUnit" : {
"state" : "translated",
"value" : "ketik pesan..."
}
},
"it" : {
"stringUnit" : {
"state" : "translated",
"value" : "scrivi un messaggio..."
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "メッセージを入力..."
}
},
"ko" : {
"stringUnit" : {
"state" : "translated",
"value" : "메시지를 입력하세요..."
}
},
"ms" : {
"stringUnit" : {
"state" : "translated",
"value" : "ketik pesan..."
}
},
"ne" : {
"stringUnit" : {
"state" : "translated",
"value" : "सन्देश टाइप गर..."
}
},
"nl" : {
"stringUnit" : {
"state" : "translated",
"value" : "typ een bericht..."
}
},
"pl" : {
"stringUnit" : {
"state" : "translated",
"value" : "wpisz wiadomość..."
}
},
"pt" : {
"stringUnit" : {
"state" : "translated",
"value" : "escreve uma mensagem..."
}
},
"pt-BR" : {
"stringUnit" : {
"state" : "translated",
"value" : "digite uma mensagem..."
}
},
"ru" : {
"stringUnit" : {
"state" : "translated",
"value" : "напиши сообщение..."
}
},
"sv" : {
"stringUnit" : {
"state" : "translated",
"value" : "skriv ett meddelande..."
}
},
"ta" : {
"stringUnit" : {
"state" : "translated",
"value" : "ஒரு செய்தியைத் தட்டச்சு செய்க..."
}
},
"th" : {
"stringUnit" : {
"state" : "translated",
"value" : "พิมพ์ข้อความ..."
}
},
"tr" : {
"stringUnit" : {
"state" : "translated",
"value" : "bir mesaj yazın..."
}
},
"uk" : {
"stringUnit" : {
"state" : "translated",
"value" : "напиши повідомлення..."
}
},
"ur" : {
"stringUnit" : {
"state" : "translated",
"value" : "پیغام ٹائپ کریں..."
}
},
"vi" : {
"stringUnit" : {
"state" : "translated",
"value" : "nhập tin nhắn..."
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "输入消息..."
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "輸入訊息..."
"value" : "private · end-to-end encrypted"
}
}
}
@@ -39,6 +39,14 @@ struct TextMessageView: View {
HStack(alignment: .top, spacing: 0) {
let isLong = (message.content.count > TransportConfig.uiLongMessageLengthThreshold || message.content.hasVeryLongToken(threshold: TransportConfig.uiVeryLongTokenThreshold)) && cashuLinks.isEmpty
let isExpanded = expandedMessageIDs.contains(message.id)
if message.isPrivate {
Image(systemName: "lock.fill")
.font(.bitchatSystem(size: 8))
.foregroundColor(Color.orange.opacity(0.75))
.padding(.top, 5)
.padding(.trailing, 4)
.accessibilityHidden(true)
}
Text(conversationUIModel.formatMessage(message, colorScheme: colorScheme, theme: theme))
.fixedSize(horizontal: false, vertical: true)
.lineLimit(isLong && !isExpanded ? TransportConfig.uiLongMessageLineLimit : nil)
+30 -4
View File
@@ -6,6 +6,7 @@ import UIKit
struct ContentComposerView: View {
@EnvironmentObject private var conversationUIModel: ConversationUIModel
@EnvironmentObject private var privateConversationModel: PrivateConversationModel
@EnvironmentObject private var locationChannelsModel: LocationChannelsModel
@Environment(\.appTheme) private var theme
@ThemedPalette private var palette
@@ -60,10 +61,8 @@ struct ContentComposerView: View {
TextField(
"",
text: $messageText,
prompt: Text(
String(localized: "content.input.message_placeholder", comment: "Placeholder shown in the chat composer")
)
.foregroundColor(palette.secondary.opacity(0.6))
prompt: Text(placeholderText)
.foregroundColor(palette.secondary.opacity(0.6))
)
.textFieldStyle(.plain)
.bitchatFont(size: 15)
@@ -110,6 +109,33 @@ struct ContentComposerView: View {
}
private extension ContentComposerView {
/// States where a message will land: the DM partner's name for private
/// chats, the channel (and its public nature) otherwise so a stressed
/// user never has to guess who can read what they're typing.
var placeholderText: String {
if let header = privateConversationModel.selectedHeaderState {
// A geohash-DM display name already carries its own "#geohash/@name"
// form, so it must not get another "@" prefix; a mesh nickname does.
let isGeoDM = privateConversationModel.selectedPeerID?.isGeoDM == true
let target = isGeoDM ? header.displayName : "@\(header.displayName)"
return String(
format: String(localized: "content.input.placeholder.private", comment: "Composer placeholder inside a private chat, naming the conversation partner"),
locale: .current,
target
)
}
switch locationChannelsModel.selectedChannel {
case .mesh:
return String(localized: "content.input.placeholder.mesh", comment: "Composer placeholder for the public mesh channel")
case .location(let channel):
return String(
format: String(localized: "content.input.placeholder.location", comment: "Composer placeholder for a public geohash channel, naming it"),
locale: .current,
channel.geohash
)
}
}
var recordingIndicator: some View {
HStack(spacing: 12) {
Image(systemName: "waveform.circle.fill")
+43
View File
@@ -363,6 +363,12 @@ private struct ContentPrivateChatSheetView: View {
.padding(.horizontal, 16)
.padding(.top, 10)
.padding(.bottom, 12)
// Orange tint before themedSurface so it layers in front of the
// (opaque, in matrix) themed background rather than behind it.
// Glass has no opaque backing themedSurface is a no-op there,
// so the wash needs more opacity to read over the backdrop
// gradient's blue/purple glows.
.background(Color.orange.opacity(theme.usesGlassChrome ? 0.14 : 0.06))
.themedSurface()
}
@@ -385,6 +391,8 @@ private struct ContentPrivateChatSheetView: View {
Divider()
}
privacyCaption
#if os(iOS)
ContentComposerView(
messageText: $messageText,
@@ -421,6 +429,41 @@ private struct ContentPrivateChatSheetView: View {
}
)
}
/// Persistent one-line reminder that this composer feeds a private
/// conversation the DM sheet otherwise renders identically to the
/// public timeline. Claims end-to-end encryption only once the session
/// is actually secured.
private var privacyCaption: some View {
HStack(spacing: 5) {
Image(systemName: "lock.fill")
.font(.bitchatSystem(size: 9))
Text(verbatim: privacyCaptionText)
.bitchatFont(size: 11, weight: .medium)
}
.foregroundColor(Color.orange)
.frame(maxWidth: .infinity)
.padding(.vertical, 4)
.background(Color.orange.opacity(0.08))
.accessibilityElement(children: .combine)
}
private var privacyCaptionText: String {
// Geohash DMs are NIP-17 gift-wrapped always end-to-end encrypted,
// even though they carry no Noise session status. Mesh DMs earn the
// "encrypted" claim only once the Noise handshake has secured.
let isGeoDM = privateConversationModel.selectedPeerID?.isGeoDM == true
let noiseSecured: Bool = {
switch privateConversationModel.selectedHeaderState?.encryptionStatus {
case .noiseSecured, .noiseVerified: return true
default: return false
}
}()
if isGeoDM || noiseSecured {
return String(localized: "content.private.caption_encrypted", comment: "Caption above the private chat composer once the session is end-to-end encrypted")
}
return String(localized: "content.private.caption", comment: "Caption above the private chat composer before encryption is established")
}
}
private struct ContentPrivateHeaderInfoButton: View {
+70 -60
View File
@@ -36,71 +36,81 @@ struct MediaMessageView: View {
let isFromMe = conversationUIModel.isMediaMessageFromCurrentUser(message)
let cancelAction: (() -> Void)? = state.canCancel ? { conversationUIModel.cancelMediaSend(messageID: message.id) } : nil
VStack(alignment: .leading, spacing: 2) {
HStack(alignment: .center, spacing: 4) {
Text(conversationUIModel.formatMessageHeader(message, colorScheme: colorScheme, theme: theme))
.fixedSize(horizontal: false, vertical: true)
.frame(maxWidth: .infinity, alignment: .leading)
// Delivery status indicator for private messages. Tappable:
// .help() tooltips only exist on macOS, so iOS users get the
// explanation as a caption under the row instead.
HStack(alignment: .top, spacing: 0) {
if message.isPrivate {
Image(systemName: "lock.fill")
.font(.bitchatSystem(size: 8))
.foregroundColor(Color.orange.opacity(0.75))
.padding(.top, 5)
.padding(.trailing, 4)
.accessibilityHidden(true)
}
VStack(alignment: .leading, spacing: 2) {
HStack(alignment: .center, spacing: 4) {
Text(conversationUIModel.formatMessageHeader(message, colorScheme: colorScheme, theme: theme))
.fixedSize(horizontal: false, vertical: true)
.frame(maxWidth: .infinity, alignment: .leading)
// Delivery status indicator for private messages. Tappable:
// .help() tooltips only exist on macOS, so iOS users get the
// explanation as a caption under the row instead.
if message.isPrivate && conversationUIModel.isSentByCurrentUser(message),
let status = deliveryStatus {
Button {
showDeliveryDetail.toggle()
} label: {
DeliveryStatusView(status: status)
.padding(.leading, 4)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.accessibilityHint(
String(localized: "content.accessibility.delivery_detail_hint", comment: "Accessibility hint for the delivery status glyph explaining a tap reveals details")
)
}
}
// Failure reasons stay visible without a tap; other statuses
// reveal on demand.
if message.isPrivate && conversationUIModel.isSentByCurrentUser(message),
let status = deliveryStatus {
Button {
showDeliveryDetail.toggle()
} label: {
DeliveryStatusView(status: status)
.padding(.leading, 4)
.contentShape(Rectangle())
if case .failed = status {
Text(verbatim: status.bitchatDescription)
.bitchatFont(size: 11)
.foregroundColor(Color.red.opacity(0.9))
.fixedSize(horizontal: false, vertical: true)
} else if showDeliveryDetail {
Text(verbatim: status.bitchatDescription)
.bitchatFont(size: 11)
.foregroundColor(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
.buttonStyle(.plain)
.accessibilityHint(
String(localized: "content.accessibility.delivery_detail_hint", comment: "Accessibility hint for the delivery status glyph explaining a tap reveals details")
)
}
}
// Failure reasons stay visible without a tap; other statuses
// reveal on demand.
if message.isPrivate && conversationUIModel.isSentByCurrentUser(message),
let status = deliveryStatus {
if case .failed = status {
Text(verbatim: status.bitchatDescription)
.bitchatFont(size: 11)
.foregroundColor(Color.red.opacity(0.9))
.fixedSize(horizontal: false, vertical: true)
} else if showDeliveryDetail {
Text(verbatim: status.bitchatDescription)
.bitchatFont(size: 11)
.foregroundColor(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
}
Group {
switch media {
case .voice(let url):
VoiceNoteView(
url: url,
isSending: state.isSending,
sendProgress: state.progress,
onCancel: cancelAction
)
case .image(let url):
BlockRevealImageView(
url: url,
revealProgress: state.progress,
isSending: state.isSending,
onCancel: cancelAction,
initiallyBlurred: !isFromMe,
onOpen: {
if !state.isSending {
imagePreviewURL = url
}
},
onDelete: !isFromMe ? { conversationUIModel.deleteMediaMessage(messageID: message.id) } : nil
)
.frame(maxWidth: 280)
Group {
switch media {
case .voice(let url):
VoiceNoteView(
url: url,
isSending: state.isSending,
sendProgress: state.progress,
onCancel: cancelAction
)
case .image(let url):
BlockRevealImageView(
url: url,
revealProgress: state.progress,
isSending: state.isSending,
onCancel: cancelAction,
initiallyBlurred: !isFromMe,
onOpen: {
if !state.isSending {
imagePreviewURL = url
}
},
onDelete: !isFromMe ? { conversationUIModel.deleteMediaMessage(messageID: message.id) } : nil
)
.frame(maxWidth: 280)
}
}
}
}