mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-24 23:45:18 +00:00
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:
co-authored by
jack
jack
Claude Fable 5
parent
9ccff9cce4
commit
c8ceac1968
+39
-158
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user