diff --git a/bitchat/Localizable.xcstrings b/bitchat/Localizable.xcstrings index 64dec94c..4b1a4a16 100644 --- a/bitchat/Localizable.xcstrings +++ b/bitchat/Localizable.xcstrings @@ -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" } } } diff --git a/bitchat/Views/Components/TextMessageView.swift b/bitchat/Views/Components/TextMessageView.swift index ae99619c..ff310a7e 100644 --- a/bitchat/Views/Components/TextMessageView.swift +++ b/bitchat/Views/Components/TextMessageView.swift @@ -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) diff --git a/bitchat/Views/ContentComposerView.swift b/bitchat/Views/ContentComposerView.swift index a643487a..36f88098 100644 --- a/bitchat/Views/ContentComposerView.swift +++ b/bitchat/Views/ContentComposerView.swift @@ -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") diff --git a/bitchat/Views/ContentSheetViews.swift b/bitchat/Views/ContentSheetViews.swift index 9e4aaa48..55010e37 100644 --- a/bitchat/Views/ContentSheetViews.swift +++ b/bitchat/Views/ContentSheetViews.swift @@ -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 { diff --git a/bitchat/Views/Media/MediaMessageView.swift b/bitchat/Views/Media/MediaMessageView.swift index 0c34ed79..cb05e065 100644 --- a/bitchat/Views/Media/MediaMessageView.swift +++ b/bitchat/Views/Media/MediaMessageView.swift @@ -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) + } } } }