412 lines
16 KiB
Kotlin
412 lines
16 KiB
Kotlin
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<Pair<String, String>?>(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<String, Int>,
|
|
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<String>, 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))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|