diff --git a/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt b/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt index 72a9e1e44..192950c0f 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt @@ -13,11 +13,21 @@ import androidx.compose.runtime.* import androidx.compose.runtime.livedata.observeAsState import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.Alignment import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ArrowDownward +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Image +import androidx.compose.material.icons.filled.InsertDriveFile +import androidx.compose.material.icons.filled.Mic import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.material3.IconButton import androidx.compose.ui.text.TextRange import androidx.compose.ui.text.input.TextFieldValue @@ -55,6 +65,7 @@ fun ChatScreen(viewModel: ChatViewModel) { val commandSuggestions by viewModel.commandSuggestions.observeAsState(emptyList()) val showMentionSuggestions by viewModel.showMentionSuggestions.observeAsState(false) val mentionSuggestions by viewModel.mentionSuggestions.observeAsState(emptyList()) + val pendingAttachment by viewModel.pendingAttachment.observeAsState() val showAppInfo by viewModel.showAppInfo.observeAsState(false) var messageText by remember { mutableStateOf(TextFieldValue("")) } @@ -188,17 +199,28 @@ fun ChatScreen(viewModel: ChatViewModel) { viewModel.updateMentionSuggestions(newText.text) }, onSend = { - if (messageText.text.trim().isNotEmpty()) { - viewModel.sendMessage(messageText.text.trim()) + val trimmed = messageText.text.trim() + val hasTextToSend = trimmed.isNotEmpty() + val hadAttachment = pendingAttachment != null + if (!hasTextToSend && !hadAttachment) { + return@ChatInputSection + } + if (hadAttachment) { + viewModel.confirmPendingAttachment() + } + if (hasTextToSend) { + viewModel.sendMessage(trimmed) messageText = TextFieldValue("") + } + if (hasTextToSend || hadAttachment) { forceScrollToBottom = !forceScrollToBottom // Toggle to trigger scroll } }, onSendVoiceNote = { peer, onionOrChannel, path -> viewModel.sendVoiceNote(peer, onionOrChannel, path) }, - onSendImageNote = { peer, onionOrChannel, path -> - viewModel.sendImageNote(peer, onionOrChannel, path) + onImageSelected = { peer, onionOrChannel, path -> + viewModel.stageImageAttachment(peer, onionOrChannel, path) }, onSendFileNote = { peer, onionOrChannel, path -> viewModel.sendFileNote(peer, onionOrChannel, path) @@ -222,6 +244,8 @@ fun ChatScreen(viewModel: ChatViewModel) { selection = TextRange(mentionText.length) ) }, + pendingAttachment = pendingAttachment, + onPendingAttachmentRemove = { viewModel.clearPendingAttachment() }, selectedPrivatePeer = selectedPrivatePeer, currentChannel = currentChannel, nickname = nickname, @@ -370,8 +394,10 @@ private fun ChatInputSection( onMessageTextChange: (TextFieldValue) -> Unit, onSend: () -> Unit, onSendVoiceNote: (String?, String?, String) -> Unit, - onSendImageNote: (String?, String?, String) -> Unit, + onImageSelected: (String?, String?, String) -> Unit, onSendFileNote: (String?, String?, String) -> Unit, + pendingAttachment: PendingAttachment?, + onPendingAttachmentRemove: () -> Unit, showCommandSuggestions: Boolean, commandSuggestions: List, showMentionSuggestions: Boolean, @@ -407,13 +433,23 @@ private fun ChatInputSection( ) HorizontalDivider(color = colorScheme.outline.copy(alpha = 0.2f)) } + if (pendingAttachment != null) { + PendingAttachmentPreview( + attachment = pendingAttachment, + onRemove = onPendingAttachmentRemove, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 8.dp) + ) + } MessageInput( value = messageText, onValueChange = onMessageTextChange, onSend = onSend, onSendVoiceNote = onSendVoiceNote, - onSendImageNote = onSendImageNote, + onImageSelected = onImageSelected, onSendFileNote = onSendFileNote, + pendingAttachment = pendingAttachment, selectedPrivatePeer = selectedPrivatePeer, currentChannel = currentChannel, nickname = nickname, @@ -422,6 +458,98 @@ private fun ChatInputSection( } } } + +@Composable +private fun PendingAttachmentPreview( + attachment: PendingAttachment, + onRemove: () -> Unit, + modifier: Modifier = Modifier +) { + val colorScheme = MaterialTheme.colorScheme + val fileName = remember(attachment.path) { runCatching { java.io.File(attachment.path).name }.getOrDefault("attachment") } + val targetLabel = remember(attachment.targetPeerId, attachment.targetChannel) { + when { + !attachment.targetPeerId.isNullOrBlank() -> "Private" + !attachment.targetChannel.isNullOrBlank() -> "#${attachment.targetChannel}" + else -> "Broadcast" + } + } + val previewBitmap = remember(attachment.path) { + runCatching { android.graphics.BitmapFactory.decodeFile(attachment.path) }.getOrNull() + } + + Surface( + modifier = modifier, + color = colorScheme.surfaceVariant.copy(alpha = 0.6f), + shape = RoundedCornerShape(12.dp) + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 8.dp) + ) { + val previewModifier = Modifier + .size(56.dp) + .clip(RoundedCornerShape(10.dp)) + .background(colorScheme.surfaceVariant) + + if (attachment.type == PendingAttachmentType.IMAGE && previewBitmap != null) { + Image( + bitmap = previewBitmap.asImageBitmap(), + contentDescription = null, + modifier = previewModifier, + contentScale = ContentScale.Crop + ) + } else { + Box(modifier = previewModifier, contentAlignment = Alignment.Center) { + val fallbackIcon = when (attachment.type) { + PendingAttachmentType.AUDIO -> Icons.Filled.Mic + PendingAttachmentType.FILE -> Icons.Filled.InsertDriveFile + PendingAttachmentType.IMAGE -> Icons.Filled.Image + } + Icon( + imageVector = fallbackIcon, + contentDescription = null, + tint = colorScheme.onSurfaceVariant.copy(alpha = 0.8f) + ) + } + } + + Spacer(Modifier.width(12.dp)) + + Column(modifier = Modifier.weight(1f)) { + Text( + text = fileName, + style = MaterialTheme.typography.bodyMedium.copy(fontFamily = FontFamily.Monospace), + color = colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Text( + text = targetLabel, + style = MaterialTheme.typography.labelSmall, + color = colorScheme.onSurfaceVariant + ) + Text( + text = attachment.mimeType, + style = MaterialTheme.typography.labelSmall, + color = colorScheme.onSurfaceVariant.copy(alpha = 0.7f) + ) + } + + IconButton(onClick = onRemove) { + Icon( + imageVector = Icons.Filled.Close, + contentDescription = "Remove pending attachment", + tint = colorScheme.onSurface + ) + } + } + } +} + + @OptIn(ExperimentalMaterial3Api::class) @Composable private fun ChatFloatingHeader( @@ -533,3 +661,4 @@ private fun ChatDialogs( ) } } + diff --git a/app/src/main/java/com/bitchat/android/ui/ChatState.kt b/app/src/main/java/com/bitchat/android/ui/ChatState.kt index e17c14943..05f2ee6b2 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatState.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatState.kt @@ -18,6 +18,23 @@ data class CommandSuggestion( val description: String ) + + +enum class PendingAttachmentType { + IMAGE, + AUDIO, + FILE +} + + +data class PendingAttachment( + val path: String, + val type: PendingAttachmentType, + val mimeType: String, + val targetPeerId: String?, + val targetChannel: String? +) + /** * Contains all the observable state for the chat system */ @@ -85,6 +102,9 @@ class ChatState { private val _mentionSuggestions = MutableLiveData>(emptyList()) val mentionSuggestions: LiveData> = _mentionSuggestions + + private val _pendingAttachment = MutableLiveData(null) + val pendingAttachment: LiveData = _pendingAttachment // Favorites private val _favoritePeers = MutableLiveData>(emptySet()) @@ -179,6 +199,8 @@ class ChatState { fun getCommandSuggestionsValue() = _commandSuggestions.value ?: emptyList() fun getShowMentionSuggestionsValue() = _showMentionSuggestions.value ?: false fun getMentionSuggestionsValue() = _mentionSuggestions.value ?: emptyList() + + fun getPendingAttachmentValue() = _pendingAttachment.value fun getFavoritePeersValue() = _favoritePeers.value ?: emptySet() fun getPeerSessionStatesValue() = _peerSessionStates.value ?: emptyMap() fun getPeerFingerprintsValue() = _peerFingerprints.value ?: emptyMap() @@ -268,6 +290,10 @@ class ChatState { _mentionSuggestions.value = suggestions } + fun setPendingAttachment(attachment: PendingAttachment?) { + _pendingAttachment.value = attachment + } + fun setFavoritePeers(favorites: Set) { val currentValue = _favoritePeers.value ?: emptySet() Log.d("ChatState", "setFavoritePeers called with ${favorites.size} favorites: $favorites") diff --git a/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt b/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt index 4224d9c1f..c836e4d71 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt @@ -2,6 +2,7 @@ package com.bitchat.android.ui import android.app.Application import android.util.Log +import android.webkit.MimeTypeMap import androidx.core.app.NotificationManagerCompat import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.LiveData @@ -46,6 +47,50 @@ class ChatViewModel( mediaSendingManager.sendImageNote(toPeerIDOrNull, channelOrNull, filePath) } + + + fun stageImageAttachment(toPeerIDOrNull: String?, channelOrNull: String?, filePath: String) { + // Defer payload encryption until confirmation so NoiseEncryptionService reads the file once. + val pending = PendingAttachment( + path = filePath, + type = PendingAttachmentType.IMAGE, + mimeType = resolveMimeType(filePath, "image/jpeg"), + targetPeerId = toPeerIDOrNull, + targetChannel = channelOrNull + ) + state.setPendingAttachment(pending) + } + + fun clearPendingAttachment() { + state.setPendingAttachment(null) + } + + fun confirmPendingAttachment(): Boolean { + val pending = state.getPendingAttachmentValue() ?: return false + when (pending.type) { + PendingAttachmentType.IMAGE -> sendImageNote(pending.targetPeerId, pending.targetChannel, pending.path) + PendingAttachmentType.AUDIO -> sendVoiceNote(pending.targetPeerId, pending.targetChannel, pending.path) + PendingAttachmentType.FILE -> sendFileNote(pending.targetPeerId, pending.targetChannel, pending.path) + } + state.setPendingAttachment(null) + return true + } + + private fun resolveMimeType(path: String, fallback: String): String { + return try { + val extension = path.substringAfterLast('.', "").lowercase() + if (extension.isNotEmpty()) { + MimeTypeMap.getSingleton()?.getMimeTypeFromExtension(extension) ?: fallback + } else { + fallback + } + } catch (e: Exception) { + Log.w(TAG, "Failed to resolve mime type for $path: ${e.message}") + fallback + } + + } + // MARK: - State management private val state = ChatState() @@ -125,6 +170,7 @@ class ChatViewModel( val commandSuggestions: LiveData> = state.commandSuggestions val showMentionSuggestions: LiveData = state.showMentionSuggestions val mentionSuggestions: LiveData> = state.mentionSuggestions + val pendingAttachment: LiveData = state.pendingAttachment val favoritePeers: LiveData> = state.favoritePeers val peerSessionStates: LiveData> = state.peerSessionStates val peerFingerprints: LiveData> = state.peerFingerprints diff --git a/app/src/main/java/com/bitchat/android/ui/InputComponents.kt b/app/src/main/java/com/bitchat/android/ui/InputComponents.kt index eb1e744b4..f7e772d53 100644 --- a/app/src/main/java/com/bitchat/android/ui/InputComponents.kt +++ b/app/src/main/java/com/bitchat/android/ui/InputComponents.kt @@ -165,8 +165,9 @@ fun MessageInput( onValueChange: (TextFieldValue) -> Unit, onSend: () -> Unit, onSendVoiceNote: (String?, String?, String) -> Unit, - onSendImageNote: (String?, String?, String) -> Unit, + onImageSelected: (String?, String?, String) -> Unit, onSendFileNote: (String?, String?, String) -> Unit, + pendingAttachment: PendingAttachment?, selectedPrivatePeer: String?, currentChannel: String?, nickname: String, @@ -175,6 +176,8 @@ fun MessageInput( val colorScheme = MaterialTheme.colorScheme val isFocused = remember { mutableStateOf(false) } val hasText = value.text.isNotBlank() // Check if there's text for send button state + val hasAttachment = pendingAttachment != null + val canSend = hasText || hasAttachment val keyboard = LocalSoftwareKeyboardController.current val focusRequester = remember { FocusRequester() } var isRecording by remember { mutableStateOf(false) } @@ -201,7 +204,7 @@ fun MessageInput( cursorBrush = SolidColor(if (isRecording) Color.Transparent else colorScheme.primary), keyboardOptions = KeyboardOptions(imeAction = ImeAction.Send), keyboardActions = KeyboardActions(onSend = { - if (hasText) onSend() // Only send if there's text + if (canSend) onSend() // Only send when there is content }), visualTransformation = CombinedVisualTransformation( listOf(SlashCommandVisualTransformation(), MentionVisualTransformation()) @@ -274,7 +277,7 @@ fun MessageInput( //) ImagePickerButton( onImageReady = { outPath -> - onSendImageNote(latestSelectedPeer.value, latestChannel.value, outPath) + onImageSelected(latestSelectedPeer.value, latestChannel.value, outPath) } ) } @@ -313,28 +316,27 @@ fun MessageInput( } ) - } else { + } + + if (canSend) { // Send button with enabled/disabled state IconButton( - onClick = { if (hasText) onSend() }, // Only execute if there's text - enabled = hasText, // Enable only when there's text + onClick = { if (canSend) onSend() }, + enabled = canSend, modifier = Modifier.size(32.dp) ) { - // Update send button to match input field colors Box( modifier = Modifier .size(30.dp) .background( - color = if (!hasText) { - // Disabled state - muted grey + color = if (!canSend) { colorScheme.onSurface.copy(alpha = 0.3f) } else if (selectedPrivatePeer != null || currentChannel != null) { - // Orange for both private messages and channels when enabled Color(0xFFFF9500).copy(alpha = 0.75f) } else if (colorScheme.background == Color.Black) { - Color(0xFF00FF00).copy(alpha = 0.75f) // Bright green for dark theme + Color(0xFF00FF00).copy(alpha = 0.75f) } else { - Color(0xFF008000).copy(alpha = 0.75f) // Dark green for light theme + Color(0xFF008000).copy(alpha = 0.75f) }, shape = CircleShape ), @@ -344,16 +346,14 @@ fun MessageInput( imageVector = Icons.Filled.ArrowUpward, contentDescription = stringResource(id = R.string.send_message), modifier = Modifier.size(20.dp), - tint = if (!hasText) { - // Disabled state - muted grey icon + tint = if (!canSend) { colorScheme.onSurface.copy(alpha = 0.5f) } else if (selectedPrivatePeer != null || currentChannel != null) { - // Black arrow on orange for both private and channel modes Color.Black } else if (colorScheme.background == Color.Black) { - Color.Black // Black arrow on bright green in dark theme + Color.Black } else { - Color.White // White arrow on dark green in light theme + Color.White } ) } @@ -507,3 +507,4 @@ fun MentionSuggestionItem( ) } } +