mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 11:05:19 +00:00
* 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>
261 lines
10 KiB
Swift
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")
|
|
)
|
|
}
|
|
}
|