Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
141 changes: 135 additions & 6 deletions app/src/main/java/com/bitchat/android/ui/ChatScreen.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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("")) }
Expand Down Expand Up @@ -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)
Expand All @@ -222,6 +244,8 @@ fun ChatScreen(viewModel: ChatViewModel) {
selection = TextRange(mentionText.length)
)
},
pendingAttachment = pendingAttachment,
onPendingAttachmentRemove = { viewModel.clearPendingAttachment() },
selectedPrivatePeer = selectedPrivatePeer,
currentChannel = currentChannel,
nickname = nickname,
Expand Down Expand Up @@ -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<CommandSuggestion>,
showMentionSuggestions: Boolean,
Expand Down Expand Up @@ -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,
Expand All @@ -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(
Expand Down Expand Up @@ -533,3 +661,4 @@ private fun ChatDialogs(
)
}
}

26 changes: 26 additions & 0 deletions app/src/main/java/com/bitchat/android/ui/ChatState.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
Expand Down Expand Up @@ -85,6 +102,9 @@ class ChatState {

private val _mentionSuggestions = MutableLiveData<List<String>>(emptyList())
val mentionSuggestions: LiveData<List<String>> = _mentionSuggestions

private val _pendingAttachment = MutableLiveData<PendingAttachment?>(null)
val pendingAttachment: LiveData<PendingAttachment?> = _pendingAttachment

// Favorites
private val _favoritePeers = MutableLiveData<Set<String>>(emptySet())
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -268,6 +290,10 @@ class ChatState {
_mentionSuggestions.value = suggestions
}

fun setPendingAttachment(attachment: PendingAttachment?) {
_pendingAttachment.value = attachment
}

fun setFavoritePeers(favorites: Set<String>) {
val currentValue = _favoritePeers.value ?: emptySet()
Log.d("ChatState", "setFavoritePeers called with ${favorites.size} favorites: $favorites")
Expand Down
46 changes: 46 additions & 0 deletions app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()

Expand Down Expand Up @@ -125,6 +170,7 @@ class ChatViewModel(
val commandSuggestions: LiveData<List<CommandSuggestion>> = state.commandSuggestions
val showMentionSuggestions: LiveData<Boolean> = state.showMentionSuggestions
val mentionSuggestions: LiveData<List<String>> = state.mentionSuggestions
val pendingAttachment: LiveData<PendingAttachment?> = state.pendingAttachment
val favoritePeers: LiveData<Set<String>> = state.favoritePeers
val peerSessionStates: LiveData<Map<String, String>> = state.peerSessionStates
val peerFingerprints: LiveData<Map<String, String>> = state.peerFingerprints
Expand Down
Loading