Files
bitchat/bitchat/Views/ContentComposerView.swift
T
c8ceac1968 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>
2026-07-06 10:06:18 +02:00

261 lines
10 KiB
Swift

import SwiftUI
#if os(iOS)
import UIKit
#endif
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
@Binding var messageText: String
var isTextFieldFocused: FocusState<Bool>.Binding
@ObservedObject var voiceRecordingVM: VoiceRecordingViewModel
@Binding var autocompleteDebounceTimer: Timer?
let onSendMessage: () -> Void
#if os(iOS)
@Binding var showImagePicker: Bool
@Binding var imagePickerSourceType: UIImagePickerController.SourceType
#else
@Binding var showMacImagePicker: Bool
#endif
var body: some View {
VStack(alignment: .leading, spacing: 6) {
if conversationUIModel.showAutocomplete && !conversationUIModel.autocompleteSuggestions.isEmpty {
VStack(alignment: .leading, spacing: 0) {
ForEach(Array(conversationUIModel.autocompleteSuggestions.prefix(4)), id: \.self) { suggestion in
Button(action: {
_ = conversationUIModel.completeNickname(suggestion, in: &messageText)
}) {
HStack {
Text(suggestion)
.bitchatFont(size: 11)
.foregroundColor(palette.primary)
.fontWeight(.medium)
Spacer()
}
.padding(.horizontal, 12)
.padding(.vertical, 3)
.frame(maxWidth: .infinity, alignment: .leading)
}
.buttonStyle(.plain)
.background(Color.gray.opacity(0.1))
}
}
.themedOverlayPanel()
.padding(.horizontal, 12)
}
CommandSuggestionsView(messageText: $messageText)
if voiceRecordingVM.state.isActive {
recordingIndicator
}
HStack(alignment: .center, spacing: 4) {
TextField(
"",
text: $messageText,
prompt: Text(placeholderText)
.foregroundColor(palette.secondary.opacity(0.6))
)
.textFieldStyle(.plain)
.bitchatFont(size: 15)
.foregroundColor(palette.primary)
.focused(isTextFieldFocused)
.autocorrectionDisabled(true)
#if os(iOS)
.textInputAutocapitalization(.sentences)
#endif
.submitLabel(.send)
.onSubmit(onSendMessage)
.padding(.vertical, theme.usesGlassChrome ? 8 : 4)
.padding(.horizontal, 6)
.themedInputBackground()
.modifier(FocusEffectDisabledModifier())
.frame(maxWidth: .infinity, alignment: .leading)
.onChange(of: messageText) { newValue in
autocompleteDebounceTimer?.invalidate()
autocompleteDebounceTimer = Timer.scheduledTimer(withTimeInterval: 0.15, repeats: false) { _ in
let cursorPosition = newValue.count
Task { @MainActor in
conversationUIModel.updateAutocomplete(for: newValue, cursorPosition: cursorPosition)
}
}
}
HStack(alignment: .center, spacing: 4) {
if conversationUIModel.canSendMediaInCurrentContext {
attachmentButton
}
sendOrMicButton
}
}
}
.padding(.horizontal, 6)
.padding(.top, theme.usesGlassChrome ? 8 : 6)
.padding(.bottom, 8)
.themedChromePanel(edge: .bottom)
.onDisappear {
autocompleteDebounceTimer?.invalidate()
}
}
}
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")
.foregroundColor(.red)
.font(.bitchatSystem(size: 20))
TimelineView(.periodic(from: .now, by: 0.05)) { context in
Text(
"recording \(voiceRecordingVM.formattedDuration(for: context.date))",
comment: "Voice note recording duration indicator"
)
.bitchatFont(size: 13)
.foregroundColor(.red)
}
Spacer()
Button(action: voiceRecordingVM.cancel) {
Label("Cancel", systemImage: "xmark.circle")
.labelStyle(.iconOnly)
.font(.bitchatSystem(size: 18))
.foregroundColor(.red)
}
.buttonStyle(.plain)
}
.padding(10)
.background(
RoundedRectangle(cornerRadius: 12)
.fill(Color.red.opacity(0.15))
)
}
var composerAccentColor: Color {
privateConversationModel.selectedPeerID != nil ? Color.orange : palette.accent
}
var attachmentButton: some View {
#if os(iOS)
Image(systemName: "camera.circle.fill")
.font(.bitchatSystem(size: 24))
.foregroundColor(composerAccentColor)
.frame(width: 36, height: 36)
.contentShape(Circle())
.onTapGesture {
imagePickerSourceType = .photoLibrary
showImagePicker = true
}
.onLongPressGesture(minimumDuration: 0.3) {
imagePickerSourceType = .camera
showImagePicker = true
}
.accessibilityLabel("Tap for library, long press for camera")
#else
Button(action: { showMacImagePicker = true }) {
Image(systemName: "photo.circle.fill")
.font(.bitchatSystem(size: 24))
.foregroundColor(composerAccentColor)
.frame(width: 36, height: 36)
}
.buttonStyle(.plain)
.accessibilityLabel("Choose photo")
#endif
}
@ViewBuilder
var sendOrMicButton: some View {
let hasText = !messageText.trimmed.isEmpty
if conversationUIModel.canSendMediaInCurrentContext {
ZStack {
micButtonView
.opacity(hasText ? 0 : 1)
.allowsHitTesting(!hasText)
sendButtonView(enabled: hasText)
.opacity(hasText ? 1 : 0)
.allowsHitTesting(hasText)
}
.frame(width: 36, height: 36)
} else {
sendButtonView(enabled: hasText)
.frame(width: 36, height: 36)
}
}
var micButtonView: some View {
Image(systemName: "mic.circle.fill")
.font(.bitchatSystem(size: 24))
.foregroundColor(voiceRecordingVM.state.isActive ? Color.red : composerAccentColor)
.frame(width: 36, height: 36)
.contentShape(Circle())
.overlay(
Color.clear
.contentShape(Circle())
.gesture(
DragGesture(minimumDistance: 0)
.onChanged { _ in
voiceRecordingVM.start(shouldShow: conversationUIModel.canSendMediaInCurrentContext)
}
.onEnded { _ in
voiceRecordingVM.finish(completion: conversationUIModel.sendVoiceNote)
}
)
)
.accessibilityLabel("Hold to record a voice note")
}
func sendButtonView(enabled: Bool) -> some View {
let activeColor = composerAccentColor
return Button(action: onSendMessage) {
Image(systemName: "arrow.up.circle.fill")
.font(.bitchatSystem(size: 24))
.foregroundColor(enabled ? activeColor : Color.gray)
.frame(width: 36, height: 36)
}
.buttonStyle(.plain)
.disabled(!enabled)
.accessibilityLabel(
String(localized: "content.accessibility.send_message", comment: "Accessibility label for the send message button")
)
.accessibilityHint(
enabled
? String(localized: "content.accessibility.send_hint_ready", comment: "Hint prompting the user to send the message")
: String(localized: "content.accessibility.send_hint_empty", comment: "Hint prompting the user to enter a message")
)
}
}