package com.bitchat.android.ui import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.ui.draw.clip import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.text.TextLayoutResult import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.platform.LocalHapticFeedback import android.content.Intent import android.net.Uri import com.bitchat.android.model.BitchatMessage import com.bitchat.android.model.DeliveryStatus import com.bitchat.android.mesh.BluetoothMeshService import java.text.SimpleDateFormat import java.util.* import com.bitchat.android.ui.media.VoiceNotePlayer import androidx.compose.material3.Icon import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Close import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.shape.CircleShape import com.bitchat.android.ui.media.FileMessageItem import com.bitchat.android.model.BitchatMessageType import com.bitchat.android.R import androidx.compose.ui.res.stringResource // VoiceNotePlayer moved to com.bitchat.android.ui.media.VoiceNotePlayer /** * Message display components for ChatScreen * Extracted from ChatScreen.kt for better organization */ @Composable fun MessagesList( messages: List, currentUserNickname: String, meshService: BluetoothMeshService, modifier: Modifier = Modifier, forceScrollToBottom: Boolean = false, onScrolledUpChanged: ((Boolean) -> Unit)? = null, onNicknameClick: ((String) -> Unit)? = null, onMessageLongPress: ((BitchatMessage) -> Unit)? = null, onCancelTransfer: ((BitchatMessage) -> Unit)? = null, onImageClick: ((String, List, Int) -> Unit)? = null ) { val listState = rememberLazyListState() // Track if this is the first time messages are being loaded var hasScrolledToInitialPosition by remember { mutableStateOf(false) } // Smart scroll: auto-scroll to bottom for initial load, then only when user is at or near the bottom LaunchedEffect(messages.size) { if (messages.isNotEmpty()) { val layoutInfo = listState.layoutInfo val firstVisibleIndex = layoutInfo.visibleItemsInfo.firstOrNull()?.index ?: -1 // With reverseLayout=true and reversed data, index 0 is the latest message at the bottom val isFirstLoad = !hasScrolledToInitialPosition val isNearLatest = firstVisibleIndex <= 2 if (isFirstLoad || isNearLatest) { listState.animateScrollToItem(0) if (isFirstLoad) { hasScrolledToInitialPosition = true } } } } // Track whether user has scrolled away from the latest messages val isAtLatest by remember { derivedStateOf { val firstVisibleIndex = listState.layoutInfo.visibleItemsInfo.firstOrNull()?.index ?: -1 firstVisibleIndex <= 2 } } LaunchedEffect(isAtLatest) { onScrolledUpChanged?.invoke(!isAtLatest) } // Force scroll to bottom when requested (e.g., when user sends a message) LaunchedEffect(forceScrollToBottom) { if (messages.isNotEmpty()) { // With reverseLayout=true and reversed data, latest is at index 0 listState.animateScrollToItem(0) } } LazyColumn( state = listState, contentPadding = PaddingValues(horizontal = 12.dp, vertical = 8.dp), verticalArrangement = Arrangement.spacedBy(4.dp), modifier = modifier, reverseLayout = true ) { items( items = messages.asReversed(), key = { it.id } ) { message -> MessageItem( message = message, messages = messages, currentUserNickname = currentUserNickname, meshService = meshService, onNicknameClick = onNicknameClick, onMessageLongPress = onMessageLongPress, onCancelTransfer = onCancelTransfer, onImageClick = onImageClick ) } } } @OptIn(ExperimentalFoundationApi::class) @Composable fun MessageItem( message: BitchatMessage, currentUserNickname: String, meshService: BluetoothMeshService, messages: List = emptyList(), onNicknameClick: ((String) -> Unit)? = null, onMessageLongPress: ((BitchatMessage) -> Unit)? = null, onCancelTransfer: ((BitchatMessage) -> Unit)? = null, onImageClick: ((String, List, Int) -> Unit)? = null ) { val colorScheme = MaterialTheme.colorScheme val timeFormatter = remember { SimpleDateFormat("HH:mm:ss", Locale.getDefault()) } Column( modifier = Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(0.dp) ) { Box(modifier = Modifier.fillMaxWidth()) { Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Start, verticalAlignment = Alignment.Top ) { // Provide a small end padding for own private messages so overlay doesn't cover text val endPad = if (message.isPrivate && message.sender == currentUserNickname) 16.dp else 0.dp // Create a custom layout that combines selectable text with clickable nickname areas MessageTextWithClickableNicknames( message = message, messages = messages, currentUserNickname = currentUserNickname, meshService = meshService, colorScheme = colorScheme, timeFormatter = timeFormatter, onNicknameClick = onNicknameClick, onMessageLongPress = onMessageLongPress, onCancelTransfer = onCancelTransfer, onImageClick = onImageClick, modifier = Modifier .weight(1f) .padding(end = endPad) ) } // Delivery status for private messages (overlay, non-displacing) if (message.isPrivate && message.sender == currentUserNickname) { message.deliveryStatus?.let { status -> Box( modifier = Modifier .align(Alignment.TopEnd) .padding(top = 2.dp) ) { DeliveryStatusIcon(status = status) } } } } // Link previews removed; links are now highlighted inline and clickable within the message text } } @OptIn(ExperimentalFoundationApi::class) @Composable private fun MessageTextWithClickableNicknames( message: BitchatMessage, messages: List, currentUserNickname: String, meshService: BluetoothMeshService, colorScheme: ColorScheme, timeFormatter: SimpleDateFormat, onNicknameClick: ((String) -> Unit)?, onMessageLongPress: ((BitchatMessage) -> Unit)?, onCancelTransfer: ((BitchatMessage) -> Unit)?, onImageClick: ((String, List, Int) -> Unit)?, modifier: Modifier = Modifier ) { // Image special rendering if (message.type == BitchatMessageType.Image) { com.bitchat.android.ui.media.ImageMessageItem( message = message, messages = messages, currentUserNickname = currentUserNickname, meshService = meshService, colorScheme = colorScheme, timeFormatter = timeFormatter, onNicknameClick = onNicknameClick, onMessageLongPress = onMessageLongPress, onCancelTransfer = onCancelTransfer, onImageClick = onImageClick, modifier = modifier ) return } // Voice note special rendering if (message.type == BitchatMessageType.Audio) { com.bitchat.android.ui.media.AudioMessageItem( message = message, currentUserNickname = currentUserNickname, meshService = meshService, colorScheme = colorScheme, timeFormatter = timeFormatter, onNicknameClick = onNicknameClick, onMessageLongPress = onMessageLongPress, onCancelTransfer = onCancelTransfer, modifier = modifier ) return } // File special rendering if (message.type == BitchatMessageType.File) { val path = message.content.trim() // Derive sending progress if applicable val (overrideProgress, _) = when (val st = message.deliveryStatus) { is com.bitchat.android.model.DeliveryStatus.PartiallyDelivered -> { if (st.total > 0 && st.reached < st.total) { (st.reached.toFloat() / st.total.toFloat()) to Color(0xFF1E88E5) // blue while sending } else null to null } else -> null to null } Column(modifier = modifier.fillMaxWidth()) { // Header: nickname + timestamp line above the file, identical styling to text messages val headerText = formatMessageHeaderAnnotatedString( message = message, currentUserNickname = currentUserNickname, meshService = meshService, colorScheme = colorScheme, timeFormatter = timeFormatter ) val haptic = LocalHapticFeedback.current var headerLayout by remember { mutableStateOf(null) } Text( text = headerText, fontFamily = FontFamily.Monospace, color = colorScheme.onSurface, modifier = Modifier.pointerInput(message.id) { detectTapGestures(onTap = { pos -> val layout = headerLayout ?: return@detectTapGestures val offset = layout.getOffsetForPosition(pos) val ann = headerText.getStringAnnotations("nickname_click", offset, offset) if (ann.isNotEmpty() && onNicknameClick != null) { haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove) onNicknameClick.invoke(ann.first().item) } }, onLongPress = { onMessageLongPress?.invoke(message) }) }, onTextLayout = { headerLayout = it } ) // Try to load the file packet from the path val packet = try { val file = java.io.File(path) if (file.exists()) { // Create a temporary BitchatFilePacket for display // In a real implementation, this would be stored with the packet metadata com.bitchat.android.model.BitchatFilePacket( fileName = file.name, fileSize = file.length(), mimeType = com.bitchat.android.features.file.FileUtils.getMimeTypeFromExtension(file.name), content = file.readBytes() ) } else null } catch (e: Exception) { null } Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Start) { Box { if (packet != null) { if (overrideProgress != null) { // Show sending animation while in-flight com.bitchat.android.ui.media.FileSendingAnimation( fileName = packet.fileName, progress = overrideProgress, modifier = Modifier.fillMaxWidth() ) } else { // Static file display with open/save dialog FileMessageItem( packet = packet, onFileClick = { // handled inside FileMessageItem via dialog } ) } // Cancel button overlay during sending val showCancel = message.sender == currentUserNickname && (message.deliveryStatus is DeliveryStatus.PartiallyDelivered) if (showCancel) { Box( modifier = Modifier .align(Alignment.TopEnd) .padding(4.dp) .size(22.dp) .background(Color.Gray.copy(alpha = 0.6f), CircleShape) .clickable { onCancelTransfer?.invoke(message) }, contentAlignment = Alignment.Center ) { Icon(imageVector = Icons.Filled.Close, contentDescription = stringResource(R.string.cd_cancel), tint = Color.White, modifier = Modifier.size(14.dp)) } } } else { Text(text = stringResource(R.string.file_unavailable), fontFamily = FontFamily.Monospace, color = Color.Gray) } } } } return } // Check if this message should be animated during PoW mining val shouldAnimate = shouldAnimateMessage(message.id) // If animation is needed, use the matrix animation component for content only if (shouldAnimate) { // Display message with matrix animation for content MessageWithMatrixAnimation( message = message, messages = messages, currentUserNickname = currentUserNickname, meshService = meshService, colorScheme = colorScheme, timeFormatter = timeFormatter, onNicknameClick = onNicknameClick, onMessageLongPress = onMessageLongPress, onImageClick = onImageClick, modifier = modifier ) } else { // Normal message display val annotatedText = formatMessageAsAnnotatedString( message = message, currentUserNickname = currentUserNickname, meshService = meshService, colorScheme = colorScheme, timeFormatter = timeFormatter ) // Check if this message was sent by self to avoid click interactions on own nickname val isSelf = message.senderPeerID == meshService.myPeerID || message.sender == currentUserNickname || message.sender.startsWith("$currentUserNickname#") val haptic = LocalHapticFeedback.current val context = LocalContext.current var textLayoutResult by remember { mutableStateOf(null) } Text( text = annotatedText, modifier = modifier.pointerInput(message) { detectTapGestures( onTap = { position -> val layout = textLayoutResult ?: return@detectTapGestures val offset = layout.getOffsetForPosition(position) // Nickname click only when not self if (!isSelf && onNicknameClick != null) { val nicknameAnnotations = annotatedText.getStringAnnotations( tag = "nickname_click", start = offset, end = offset ) if (nicknameAnnotations.isNotEmpty()) { val nickname = nicknameAnnotations.first().item haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove) onNicknameClick.invoke(nickname) return@detectTapGestures } } // Geohash teleport (all messages) val geohashAnnotations = annotatedText.getStringAnnotations( tag = "geohash_click", start = offset, end = offset ) if (geohashAnnotations.isNotEmpty()) { val geohash = geohashAnnotations.first().item try { val locationManager = com.bitchat.android.geohash.LocationChannelManager.getInstance( context ) val level = when (geohash.length) { in 0..2 -> com.bitchat.android.geohash.GeohashChannelLevel.REGION in 3..4 -> com.bitchat.android.geohash.GeohashChannelLevel.PROVINCE 5 -> com.bitchat.android.geohash.GeohashChannelLevel.CITY 6 -> com.bitchat.android.geohash.GeohashChannelLevel.NEIGHBORHOOD else -> com.bitchat.android.geohash.GeohashChannelLevel.BLOCK } val channel = com.bitchat.android.geohash.GeohashChannel(level, geohash.lowercase()) locationManager.setTeleported(true) locationManager.select(com.bitchat.android.geohash.ChannelID.Location(channel)) } catch (_: Exception) { } haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove) return@detectTapGestures } // URL open (all messages) val urlAnnotations = annotatedText.getStringAnnotations( tag = "url_click", start = offset, end = offset ) if (urlAnnotations.isNotEmpty()) { val raw = urlAnnotations.first().item val resolved = if (raw.startsWith("http://", ignoreCase = true) || raw.startsWith("https://", ignoreCase = true)) raw else "https://$raw" try { val intent = Intent(Intent.ACTION_VIEW, Uri.parse(resolved)) intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) context.startActivity(intent) } catch (_: Exception) { } haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove) return@detectTapGestures } }, onLongPress = { haptic.performHapticFeedback(HapticFeedbackType.LongPress) onMessageLongPress?.invoke(message) } ) }, fontFamily = FontFamily.Monospace, softWrap = true, overflow = TextOverflow.Visible, style = androidx.compose.ui.text.TextStyle( color = colorScheme.onSurface ), onTextLayout = { result -> textLayoutResult = result } ) } } @Composable fun DeliveryStatusIcon(status: DeliveryStatus) { val colorScheme = MaterialTheme.colorScheme when (status) { is DeliveryStatus.Sending -> { Text( text = stringResource(R.string.status_sending), fontSize = 10.sp, color = colorScheme.primary.copy(alpha = 0.6f) ) } is DeliveryStatus.Sent -> { // Use a subtle hollow marker for Sent; single check is reserved for Delivered (iOS parity) Text( text = stringResource(R.string.status_pending), fontSize = 10.sp, color = colorScheme.primary.copy(alpha = 0.6f) ) } is DeliveryStatus.Delivered -> { // Single check for Delivered (matches iOS expectations) Text( text = stringResource(R.string.status_sent), fontSize = 10.sp, color = colorScheme.primary.copy(alpha = 0.8f) ) } is DeliveryStatus.Read -> { Text( text = stringResource(R.string.status_delivered), fontSize = 10.sp, color = Color(0xFF007AFF), // Blue fontWeight = FontWeight.Bold ) } is DeliveryStatus.Failed -> { Text( text = stringResource(R.string.status_failed), fontSize = 10.sp, color = Color.Red.copy(alpha = 0.8f) ) } is DeliveryStatus.PartiallyDelivered -> { // Show a single subdued check without numeric label Text( text = stringResource(R.string.status_sent), fontSize = 10.sp, color = colorScheme.primary.copy(alpha = 0.6f) ) } } }