Files
bitchat/bitchat/Views/ContentComposerView.swift
T
jackandClaude Fable 5 9cbfd131da i18n: add English defaultValue fallback to key-only localized strings so no language shows raw keys
Non-en languages are each missing ~138 of 336 catalog keys, so a key
absent from that language's table renders as the raw dot-key on device
(no English fallback). This adds English defaultValues so every miss
resolves to English instead of a raw key.

- Class (a): 217 String(localized: "dot.key") calls missing defaultValue
  now carry defaultValue: "<en value>" pulled verbatim from the catalog
  (format specifiers preserved).
- Class (b): 41 SwiftUI LocalizedStringKey literals (Text/Button/alert/
  confirmationDialog/TextField) wrapped as
  Text(String(localized: "key", defaultValue: "en")) so they resolve to
  English too. Existing comments folded into the resolved String.

No catalog or en values changed; Swift source only. Both schemes build.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 23:58:29 +02:00

294 lines
13 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)
}
}
.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", defaultValue: "message %@ — 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", defaultValue: "message #mesh — public, nearby", comment: "Composer placeholder for the public mesh channel")
case .location(let channel):
return String(
format: String(localized: "content.input.placeholder.location", defaultValue: "message #%@ — public", 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(
String(localized: "content.accessibility.attach_photo", defaultValue: "attach photo", comment: "Accessibility label for the photo attachment button")
)
.accessibilityHint(
String(localized: "content.accessibility.attach_photo_hint", defaultValue: "opens the photo library; use the take photo action for the camera", comment: "Accessibility hint explaining the attachment button opens the photo library")
)
.accessibilityAddTraits(.isButton)
// The long-press → camera path is unreachable for VoiceOver users;
// mirror it as a named action.
.accessibilityAction(named: Text(String(localized: "content.accessibility.take_photo", defaultValue: "take photo with camera", comment: "Accessibility action name for taking a photo with the camera"))) {
imagePickerSourceType = .camera
showImagePicker = true
}
#else
Button(action: { showMacImagePicker = true }) {
Image(systemName: "photo.circle.fill")
.font(.bitchatSystem(size: 24))
.foregroundColor(composerAccentColor)
.frame(width: 36, height: 36)
}
.buttonStyle(.plain)
.accessibilityLabel(
String(localized: "content.accessibility.choose_photo", defaultValue: "choose photo", comment: "Accessibility label for the macOS photo picker button")
)
#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(
String(localized: "content.accessibility.record_voice_note", defaultValue: "record voice note", comment: "Accessibility label for the voice note button")
)
.accessibilityValue(
voiceRecordingVM.state.isActive
? String(localized: "content.accessibility.recording", defaultValue: "recording", comment: "Accessibility value announced while a voice note is recording")
: ""
)
.accessibilityHint(
String(localized: "content.accessibility.record_voice_hint", defaultValue: "double-tap to start recording, double-tap again to send", comment: "Accessibility hint explaining double-tap toggles voice recording")
)
.accessibilityAddTraits(.isButton)
// Press-and-hold drag gestures can't be activated by VoiceOver;
// give it a start/stop toggle as the default action.
.accessibilityAction {
if voiceRecordingVM.state.isActive {
voiceRecordingVM.finish(completion: conversationUIModel.sendVoiceNote)
} else {
voiceRecordingVM.start(shouldShow: conversationUIModel.canSendMediaInCurrentContext)
}
}
}
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", defaultValue: "send message", comment: "Accessibility label for the send message button")
)
.accessibilityHint(
enabled
? String(localized: "content.accessibility.send_hint_ready", defaultValue: "double tap to send", comment: "Hint prompting the user to send the message")
: String(localized: "content.accessibility.send_hint_empty", defaultValue: "enter a message to send", comment: "Hint prompting the user to enter a message")
)
}
}