This commit is contained in:
Халимов Рустам
2026-04-14 10:48:07 +03:00
parent dc051fa9ae
commit d8b0d86534
19 changed files with 504 additions and 184 deletions
@@ -22,19 +22,29 @@ 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
@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) }
val backgroundColor = if (isCurrentUser) {
@@ -47,22 +57,33 @@ fun MessageBubble(
val alignment = if (isCurrentUser) Alignment.CenterEnd else Alignment.CenterStart
val shape = if (isCurrentUser) {
RoundedCornerShape(12.dp, 12.dp, 4.dp, 12.dp)
RoundedCornerShape(16.dp, 16.dp, 4.dp, 16.dp)
} else {
RoundedCornerShape(12.dp, 12.dp, 12.dp, 4.dp)
RoundedCornerShape(16.dp, 16.dp, 16.dp, 4.dp)
}
val isVoiceMessage = message.mediaType == MediaType.AUDIO &&
(message.content == null || message.content.isEmpty())
Box(
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 8.dp, vertical = 4.dp),
contentAlignment = alignment
.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(
@@ -70,7 +91,6 @@ fun MessageBubble(
onLongClick = { showReactionPicker = true }
)
.padding(8.dp)
.widthIn(max = 300.dp)
) {
if (showReactionPicker) {
Popup(
@@ -84,37 +104,35 @@ fun MessageBubble(
})
}
}
// Sender Name (only for others)
if (!isCurrentUser) {
Text(
text = message.senderName,
style = MaterialTheme.typography.labelSmall,
color = contentColor.copy(alpha = 0.7f),
modifier = Modifier.padding(bottom = 2.dp)
)
}
// Reply Info
message.replyTo?.let { reply ->
Box(
Row(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 4.dp)
.padding(bottom = 6.dp)
.clip(RoundedCornerShape(4.dp))
.background(contentColor.copy(alpha = 0.1f))
.padding(8.dp)
.height(IntrinsicSize.Min)
) {
Column {
Box(
modifier = Modifier
.fillMaxHeight()
.width(2.dp)
.background(if (isCurrentUser) Color.White else Color(0xFF3390EC))
)
Column(modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp)) {
Text(
text = reply.senderName,
style = MaterialTheme.typography.labelSmall,
color = contentColor,
maxLines = 1
style = MaterialTheme.typography.labelMedium,
color = if (isCurrentUser) Color.White else Color(0xFF3390EC),
maxLines = 1,
fontWeight = androidx.compose.ui.text.font.FontWeight.Bold
)
Text(
text = reply.content ?: "[Media]",
style = MaterialTheme.typography.bodySmall,
color = contentColor.copy(alpha = 0.7f),
color = contentColor.copy(alpha = 0.8f),
maxLines = 1
)
}
@@ -122,57 +140,65 @@ fun MessageBubble(
}
// Media Content
if (message.mediaUrl != null) {
when (message.mediaType) {
MediaType.IMAGE -> {
AsyncImage(
model = message.mediaUrl,
contentDescription = null,
modifier = Modifier
.fillMaxWidth()
.heightIn(max = 200.dp)
.clip(RoundedCornerShape(12.dp)),
contentScale = ContentScale.Crop
)
Spacer(modifier = Modifier.height(4.dp))
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 {
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(mediaUrl))
context.startActivity(intent)
},
contentScale = ContentScale.FillWidth
)
}
MediaType.VIDEO -> {
AppVideoPlayer(
url = mediaUrl,
modifier = Modifier
.fillMaxWidth()
.height(200.dp)
.clip(RoundedCornerShape(12.dp)),
useController = true,
autoPlay = false
)
}
MediaType.AUDIO -> {
AppAudioPlayer(
url = mediaUrl,
isVoiceMessage = isVoiceMessage,
contentColor = contentColor
)
}
MediaType.FILE -> {
FileItem(mediaUrl = mediaUrl, isCurrentUser = isCurrentUser, contentColor = contentColor)
}
else -> {}
}
MediaType.VIDEO -> {
AppVideoPlayer(
url = message.mediaUrl,
modifier = Modifier
.fillMaxWidth()
.height(200.dp)
.clip(RoundedCornerShape(12.dp)),
useController = true,
autoPlay = false
)
Spacer(modifier = Modifier.height(4.dp))
}
MediaType.AUDIO -> {
AppAudioPlayer(
url = message.mediaUrl,
isVoiceMessage = isVoiceMessage,
contentColor = contentColor
)
Spacer(modifier = Modifier.height(4.dp))
}
else -> {}
} else if (mediaCount > 1) {
// Photo Grid for multiple images
PhotoGrid(
urls = message.media,
modifier = Modifier
.fillMaxWidth()
.height(300.dp)
.clip(RoundedCornerShape(12.dp))
)
}
if (mediaCount > 0) {
Spacer(modifier = Modifier.height(4.dp))
}
}
// Text Content (Story reply or simple text)
if (message.mediaType == MediaType.STORY_REPLY) {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 4.dp)
.clip(RoundedCornerShape(8.dp))
.background(contentColor.copy(alpha = 0.1f))
.padding(8.dp)
) {
Text("Story Reply: ${message.content}", color = contentColor, style = MaterialTheme.typography.bodyMedium)
}
} else if (!message.content.isNullOrBlank() && !isVoiceMessage) {
// Text Content
if (!message.content.isNullOrBlank() && !isVoiceMessage) {
Text(
text = message.content,
style = MaterialTheme.typography.bodyMedium,
@@ -185,20 +211,157 @@ fun MessageBubble(
modifier = Modifier.align(Alignment.End),
verticalAlignment = Alignment.CenterVertically
) {
if (message.reactions.isNotEmpty()) {
MessageReactions(
reactions = message.reactions,
onReactionClick = onReactionClick,
modifier = Modifier.padding(end = 4.dp)
)
}
Text(
text = message.createdAt.takeLast(5),
style = MaterialTheme.typography.labelSmall,
color = contentColor.copy(alpha = 0.6f)
)
}
// Reactions
if (message.reactions.isNotEmpty()) {
MessageReactions(
reactions = message.reactions,
onReactionClick = onReactionClick
color = contentColor.copy(alpha = 0.6f),
fontSize = 10.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)
)
}
}
}
}
}
@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>, 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),
contentScale = ContentScale.Crop
)
if (firstIndex + 1 < items.size) {
AsyncImage(
model = items[firstIndex + 1],
contentDescription = null,
modifier = Modifier
.weight(1f)
.fillMaxHeight()
.padding(1.dp),
contentScale = ContentScale.Crop
)
} else if (rows > 1) {
Spacer(modifier = Modifier.weight(1f))
}
}
}
}
}