mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-24 22:45:19 +00:00
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>
294 lines
13 KiB
Swift
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")
|
|
)
|
|
}
|
|
}
|