Files
forkmessager/client-mobile/chats/presentation/components/MessageBubble.kt
Халимов Рустам bef30c2c86 Ответы
2026-04-17 21:59:53 +03:00

522 lines
22 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.material3.CircularProgressIndicator
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.res.painterResource
import ru.knot.messager.R
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 chats.domain.model.Media
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.*
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 core.presentation.components.AppMediaLightbox
import java.time.Instant
import java.time.ZoneId
import java.time.format.DateTimeFormatter
@OptIn(androidx.compose.foundation.ExperimentalFoundationApi::class)
@Composable
fun MessageBubble(
message: Message,
isCurrentUser: Boolean,
onReactionClick: (String) -> Unit = {},
autoPlay: Boolean = false,
initialPlaybackSpeed: Float = 1.0f,
onVoiceFinished: (Float) -> Unit = {},
onReplyClick: (Message) -> Unit = {},
onMediaClick: (chats.domain.model.Media) -> Unit = {}
) {
val context = LocalContext.current
var showReactionPicker by remember { mutableStateOf(false) }
var lightboxMediaIndex by remember { mutableStateOf<Int?>(null) }
val backgroundColor = if (isCurrentUser) {
Color(0xFF3096E5)
} else {
Color(0xFF212121)
}
val contentColor = Color.White
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.media.any { it.type == "voice" } || (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
) {
// No avatars here as per user request (web parity)
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)
.clickable { onReplyClick(reply) }
) {
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 ?: if (reply.media.isNotEmpty()) "\uD83D\uDCCE Media" else "",
style = MaterialTheme.typography.bodySmall,
color = contentColor.copy(alpha = 0.8f),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
}
// GIF Content (Klipy)
if (message.mediaType == MediaType.GIF) {
AsyncImage(
model = message.content,
imageLoader = core.utils.CoilUtils.getGifImageLoader(context),
contentDescription = null,
modifier = Modifier
.fillMaxWidth()
.heightIn(max = 300.dp)
.clip(RoundedCornerShape(8.dp))
.clickable { /* Handle click */ },
contentScale = ContentScale.Crop
)
Spacer(modifier = Modifier.height(4.dp))
}
// Loading state for temp media messages
if (message.id.startsWith("temp_") && message.mediaType != MediaType.TEXT) {
Box(
modifier = Modifier
.fillMaxWidth()
.height(200.dp)
.clip(RoundedCornerShape(8.dp))
.background(contentColor.copy(alpha = 0.1f)),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator(
modifier = Modifier.size(24.dp),
strokeWidth = 2.dp,
color = contentColor.copy(alpha = 0.5f)
)
}
Spacer(modifier = Modifier.height(4.dp))
}
// Media Content (Attachments)
if (message.media.isNotEmpty() && message.mediaType != MediaType.GIF) {
val mediaCount = message.media.size
if (mediaCount == 1) {
val mediaItem = message.media[0]
when {
mediaItem.type.startsWith("image") || message.mediaType == MediaType.IMAGE -> {
AsyncImage(
model = mediaItem.url,
imageLoader = core.utils.CoilUtils.getGifImageLoader(context),
contentDescription = null,
modifier = Modifier
.fillMaxWidth()
.heightIn(max = 300.dp)
.clip(RoundedCornerShape(8.dp))
.clickable { onMediaClick(mediaItem) },
contentScale = ContentScale.Crop
)
}
message.mediaType == MediaType.VIDEO || mediaItem.type.startsWith("video") -> {
Box(
modifier = Modifier
.fillMaxWidth()
.height(240.dp)
.clip(RoundedCornerShape(8.dp))
) {
AppVideoPlayer(
url = mediaItem.url,
modifier = Modifier.fillMaxSize(),
useController = false,
isMuted = true,
autoPlay = true
)
// Прозрачный слой поверх видео для клика
Box(modifier = Modifier
.fillMaxSize()
.background(Color.Transparent)
.clickable { onMediaClick(mediaItem) }
)
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)
)
}
}
mediaItem.type.startsWith("audio") || mediaItem.filename?.endsWith(".mp2", true) == true || mediaItem.filename?.endsWith(".mp3", true) == true || message.mediaType == MediaType.AUDIO -> {
AppAudioPlayer(
url = mediaItem.url,
name = mediaItem.filename,
size = mediaItem.size,
isVoiceMessage = isVoiceMessage,
initialPlaybackSpeed = initialPlaybackSpeed,
autoPlay = autoPlay,
onFinished = onVoiceFinished,
contentColor = contentColor
)
}
else -> {
FileItem(media = mediaItem, isCurrentUser = isCurrentUser, contentColor = contentColor)
}
}
} else {
// Multi-media grid
PhotoGrid(
mediaList = message.media,
isCurrentUser = isCurrentUser,
onMediaClick = { index -> onMediaClick(message.media[index]) },
modifier = Modifier.fillMaxWidth().height(300.dp).clip(RoundedCornerShape(12.dp))
)
}
Spacer(modifier = Modifier.height(4.dp))
}
// Text Content
if (!message.content.isNullOrBlank() && !isVoiceMessage && message.mediaType != MediaType.GIF) {
Column {
val annotatedString = remember(message.content) {
val text = message.content ?: ""
val links = core.utils.LinkParser.findLinks(text)
androidx.compose.ui.text.buildAnnotatedString {
append(text)
links.forEach { link ->
val startIndex = text.indexOf(link)
if (startIndex >= 0) {
val endIndex = startIndex + link.length
addStyle(
style = androidx.compose.ui.text.SpanStyle(
color = Color(0xFF3096E5),
textDecoration = androidx.compose.ui.text.style.TextDecoration.Underline
),
start = startIndex,
end = endIndex
)
addStringAnnotation(
tag = "URL",
annotation = link,
start = startIndex,
end = endIndex
)
}
}
}
}
val context = androidx.compose.ui.platform.LocalContext.current
androidx.compose.foundation.text.ClickableText(
text = annotatedString,
style = MaterialTheme.typography.bodyMedium.copy(color = contentColor),
onClick = { offset ->
annotatedString.getStringAnnotations(tag = "URL", start = offset, end = offset)
.firstOrNull()?.let { annotation ->
try {
val intent = android.content.Intent(android.content.Intent.ACTION_VIEW, android.net.Uri.parse(annotation.item))
context.startActivity(intent)
} catch (e: Exception) {}
}
}
)
val links = remember(message.content) { core.utils.LinkParser.findLinks(message.content) }
if (links.isNotEmpty()) {
Spacer(modifier = Modifier.height(8.dp))
LinkPreview(
url = links[0],
contentColor = 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 {
val instant = Instant.parse(message.createdAt)
val zonedDateTime = instant.atZone(ZoneId.systemDefault())
zonedDateTime.format(DateTimeFormatter.ofPattern("HH:mm"))
} catch (e: Exception) {
message.createdAt.substringAfter('T').take(5)
}
}
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)
)
}
}
}
}
lightboxMediaIndex?.let { index ->
AppMediaLightbox(
mediaList = message.media,
initialIndex = index,
onClose = { lightboxMediaIndex = 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(media: Media, 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 = media.filename ?: "File"
Text(
text = fileName,
style = MaterialTheme.typography.bodySmall,
color = contentColor,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
val sizeStr = media.size?.let {
if (it > 1024 * 1024) "${it / (1024 * 1024)} MB" else "${it / 1024} KB"
} ?: "File"
Text(
text = sizeStr,
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(mediaList: List<Media>, isCurrentUser: Boolean, onMediaClick: (Int) -> Unit, modifier: Modifier = Modifier) {
if (mediaList.isEmpty()) return
val columns = when {
mediaList.size == 1 -> 1
mediaList.size <= 4 -> 2
else -> 3
}
Column(modifier = modifier) {
val rows = (mediaList.size + columns - 1) / columns
for (i in 0 until rows) {
Row(modifier = Modifier.weight(1f).fillMaxWidth()) {
for (j in 0 until columns) {
val index = i * columns + j
if (index < mediaList.size) {
GridItem(
media = mediaList[index],
modifier = Modifier
.weight(1f)
.fillMaxHeight()
.padding(1.dp)
.clickable { onMediaClick(index) }
)
} else {
Spacer(modifier = Modifier.weight(1f))
}
}
}
}
}
}
@Composable
fun GridItem(media: Media, modifier: Modifier = Modifier) {
val context = LocalContext.current
val isImage = media.type.startsWith("image")
val isVideo = media.type.startsWith("video")
Box(modifier = modifier.background(Color.White.copy(alpha = 0.1f))) {
if (isImage || isVideo) {
AsyncImage(
model = media.url,
contentDescription = null,
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop
)
if (isVideo) {
Icon(
imageVector = Icons.Default.PlayCircle,
contentDescription = null,
tint = Color.White.copy(alpha = 0.8f),
modifier = Modifier.align(Alignment.Center).size(32.dp)
)
}
} else {
Column(
modifier = Modifier.fillMaxSize().padding(4.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Icon(
imageVector = Icons.Default.InsertDriveFile,
contentDescription = null,
tint = Color.White.copy(alpha = 0.7f),
modifier = Modifier.size(32.dp)
)
Text(
text = media.filename ?: "File",
style = MaterialTheme.typography.labelSmall,
color = Color.White.copy(alpha = 0.9f),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
textAlign = androidx.compose.ui.text.style.TextAlign.Center
)
Text(
text = media.url.substringAfterLast(".").uppercase(),
style = MaterialTheme.typography.labelSmall,
color = Color.White.copy(alpha = 0.5f),
fontSize = 8.sp
)
}
}
}
}