Files
forkmessager/client-mobile/chats/presentation/components/MessageBubble.kt
Халимов Рустам 3310a3c4a4 Правка чата
2026-04-14 11:31:20 +03:00

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))
}
}
}
}
}