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