package chats.presentation.components import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.compose.ui.window.Popup import chats.domain.model.Message import chats.domain.model.MediaType import coil.compose.AsyncImage import core.presentation.components.AppVideoPlayer import core.presentation.components.AppAudioPlayer import core.presentation.components.AppAvatar import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Description import androidx.compose.material.icons.filled.Download import androidx.compose.material.icons.filled.Done import androidx.compose.material.icons.filled.DoneAll import chats.presentation.components.LinkPreview import android.content.Intent import android.net.Uri import androidx.compose.ui.platform.LocalContext import androidx.compose.material3.Icon import androidx.compose.ui.text.style.TextOverflow import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material.icons.filled.VolumeOff import core.presentation.components.AppMediaLightbox @OptIn(androidx.compose.foundation.ExperimentalFoundationApi::class) @Composable fun MessageBubble( message: Message, isCurrentUser: Boolean, senderAvatar: String? = null, onReactionClick: (String) -> Unit = {} ) { val context = LocalContext.current var showReactionPicker by remember { mutableStateOf(false) } var lightboxMedia by remember { mutableStateOf?>(null) } val backgroundColor = if (isCurrentUser) { Color(0xFF3096E5) // Updated Blue } else { Color(0xFF212121) // Updated Dark Grey } val contentColor = Color.White val alignment = if (isCurrentUser) Alignment.CenterEnd else Alignment.CenterStart val shape = if (isCurrentUser) { RoundedCornerShape(16.dp, 16.dp, 4.dp, 16.dp) } else { RoundedCornerShape(16.dp, 16.dp, 16.dp, 4.dp) } val isVoiceMessage = message.mediaType == MediaType.AUDIO && (message.content == null || message.content.isEmpty()) Row( modifier = Modifier .fillMaxWidth() .padding(horizontal = 8.dp, vertical = 2.dp), horizontalArrangement = if (isCurrentUser) Arrangement.End else Arrangement.Start, verticalAlignment = Alignment.Bottom ) { if (!isCurrentUser) { AppAvatar( url = senderAvatar, name = message.senderName, size = 32.dp, modifier = Modifier.padding(end = 8.dp, bottom = 4.dp) ) } Column( modifier = Modifier .widthIn(max = 300.dp) .clip(shape) .background(backgroundColor) .combinedClickable( onClick = { /* Handle normal click */ }, onLongClick = { showReactionPicker = true } ) .padding(8.dp) ) { if (showReactionPicker) { Popup( alignment = Alignment.TopCenter, offset = IntOffset(0, -100), onDismissRequest = { showReactionPicker = false } ) { ReactionPicker(onReactionSelected = { onReactionClick(it) showReactionPicker = false }) } } // Reply Info message.replyTo?.let { reply -> Row( modifier = Modifier .fillMaxWidth() .padding(bottom = 6.dp) .clip(RoundedCornerShape(4.dp)) .background(contentColor.copy(alpha = 0.1f)) .height(IntrinsicSize.Min) ) { Box( modifier = Modifier .fillMaxHeight() .width(2.dp) .background(if (isCurrentUser) Color.White else Color(0xFF3096E5)) ) Column(modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp)) { val accentColor = if (isCurrentUser) Color.White else Color(0xFF3096E5) Text( text = reply.senderName, style = MaterialTheme.typography.labelMedium, color = accentColor, maxLines = 1, fontWeight = androidx.compose.ui.text.font.FontWeight.Bold ) Text( text = reply.content ?: "[Media]", style = MaterialTheme.typography.bodySmall, color = contentColor.copy(alpha = 0.8f), maxLines = 1 ) } } } // Media Content if (message.media.isNotEmpty()) { val mediaCount = message.media.size if (mediaCount == 1) { val mediaUrl = message.media[0] when (message.mediaType) { MediaType.IMAGE -> { AsyncImage( model = mediaUrl, contentDescription = null, modifier = Modifier .fillMaxWidth() .clip(RoundedCornerShape(12.dp)) .clickable { lightboxMedia = mediaUrl to "image" }, contentScale = ContentScale.FillWidth ) } MediaType.VIDEO -> { Box( modifier = Modifier .fillMaxWidth() .height(200.dp) .clip(RoundedCornerShape(12.dp)) .background(Color.Black) .clickable { lightboxMedia = mediaUrl to "video" }, contentAlignment = Alignment.Center ) { AppVideoPlayer( url = mediaUrl, modifier = Modifier.fillMaxSize(), useController = false, autoPlay = true, isMuted = true ) // Volume Off icon in top right Icon( imageVector = Icons.Default.VolumeOff, contentDescription = null, tint = Color.White.copy(alpha = 0.7f), modifier = Modifier .align(Alignment.TopEnd) .padding(8.dp) .size(20.dp) ) } } MediaType.AUDIO -> { AppAudioPlayer( url = mediaUrl, isVoiceMessage = isVoiceMessage, contentColor = contentColor ) } MediaType.FILE -> { FileItem(mediaUrl = mediaUrl, isCurrentUser = isCurrentUser, contentColor = contentColor) } else -> {} } } else if (mediaCount > 1) { // Photo Grid for multiple images PhotoGrid( urls = message.media, onMediaClick = { lightboxMedia = it to "image" }, modifier = Modifier .fillMaxWidth() .height(300.dp) .clip(RoundedCornerShape(12.dp)) ) } if (mediaCount > 0) { Spacer(modifier = Modifier.height(4.dp)) } } // Text Content if (!message.content.isNullOrBlank() && !isVoiceMessage) { Text( text = message.content, style = MaterialTheme.typography.bodyMedium, color = contentColor ) } // Time and Status Row( modifier = Modifier.align(Alignment.End), verticalAlignment = Alignment.CenterVertically ) { if (message.reactions.isNotEmpty()) { MessageReactions( reactions = message.reactions, onReactionClick = onReactionClick, modifier = Modifier.padding(end = 4.dp) ) } val formattedTime = remember(message.createdAt) { try { // Handle ISO 8601 strings like 2024-05-14T10:49:02.12Z val timePart = message.createdAt.substringAfter('T').take(5) if (timePart.contains(':')) timePart else "00:00" } catch (e: Exception) { "00:00" } } Text( text = formattedTime, style = MaterialTheme.typography.labelSmall, color = contentColor.copy(alpha = 0.6f), fontSize = 11.sp ) if (isCurrentUser) { Spacer(modifier = Modifier.width(2.dp)) Icon( imageVector = if (message.isRead) Icons.Default.DoneAll else Icons.Default.Done, contentDescription = null, modifier = Modifier.size(12.dp), tint = contentColor.copy(alpha = 0.6f) ) } } } } lightboxMedia?.let { (url, type) -> AppMediaLightbox( url = url, type = type, onClose = { lightboxMedia = null } ) } } @Composable fun MessageReactions( reactions: Map, onReactionClick: (String) -> Unit, modifier: Modifier = Modifier ) { Row( modifier = modifier, horizontalArrangement = Arrangement.spacedBy(4.dp) ) { reactions.forEach { (emoji, count) -> Box( modifier = Modifier .clip(CircleShape) .background(Color.White.copy(alpha = 0.2f)) .clickable { onReactionClick(emoji) } .padding(horizontal = 6.dp, vertical = 2.dp) ) { Row(verticalAlignment = Alignment.CenterVertically) { Text(text = emoji, fontSize = 12.sp) if (count > 1) { Spacer(modifier = Modifier.width(2.dp)) Text( text = count.toString(), fontSize = 10.sp, color = Color.White ) } } } } } } @Composable fun FileItem(mediaUrl: String, isCurrentUser: Boolean, contentColor: Color) { Row( modifier = Modifier .fillMaxWidth() .clip(RoundedCornerShape(8.dp)) .background(contentColor.copy(alpha = 0.1f)) .padding(8.dp), verticalAlignment = Alignment.CenterVertically ) { Box( modifier = Modifier .size(40.dp) .clip(RoundedCornerShape(8.dp)) .background(if (isCurrentUser) Color.White.copy(alpha = 0.2f) else Color(0xFF3390EC).copy(alpha = 0.2f)), contentAlignment = Alignment.Center ) { Icon( Icons.Default.Description, contentDescription = null, tint = contentColor ) } Column( modifier = Modifier .weight(1f) .padding(horizontal = 8.dp) ) { val fileName = remember(mediaUrl) { val decoded = Uri.decode(mediaUrl.substringAfterLast("/")) if (decoded.length > 30) { decoded.take(15) + "..." + decoded.takeLast(10) } else { decoded } } Text( text = fileName, style = MaterialTheme.typography.bodySmall, color = contentColor, maxLines = 1, overflow = TextOverflow.Ellipsis ) Text( text = if (mediaUrl.endsWith(".mp3", ignoreCase = true) || mediaUrl.contains("audio")) "Audio" else "File", style = MaterialTheme.typography.labelSmall, color = contentColor.copy(alpha = 0.6f) ) } Icon( Icons.Default.Download, contentDescription = null, tint = contentColor, modifier = Modifier.size(20.dp) ) } } @Composable fun PhotoGrid(urls: List, onMediaClick: (String) -> Unit, modifier: Modifier = Modifier) { val items = urls.take(4) Column(modifier = modifier) { val rows = (items.size + 1) / 2 for (i in 0 until rows) { Row(modifier = Modifier.weight(1f)) { val firstIndex = i * 2 AsyncImage( model = items[firstIndex], contentDescription = null, modifier = Modifier .weight(1f) .fillMaxHeight() .padding(1.dp) .clickable { onMediaClick(items[firstIndex]) }, contentScale = ContentScale.Crop ) if (firstIndex + 1 < items.size) { AsyncImage( model = items[firstIndex + 1], contentDescription = null, modifier = Modifier .weight(1f) .fillMaxHeight() .padding(1.dp) .clickable { onMediaClick(items[firstIndex + 1]) }, contentScale = ContentScale.Crop ) } else if (rows > 1) { Spacer(modifier = Modifier.weight(1f)) } } } } }