From 5fdd30b0ae72eb77b659dabd49c6c6fc0bfefe8f Mon Sep 17 00:00:00 2001 From: k1ngsterr1 Date: Sat, 31 Jan 2026 04:37:23 +0500 Subject: [PATCH] Refactor code structure for improved readability and maintainability --- .../chats/components/AttachmentComponents.kt | 12 +- .../chats/components/ChatDetailComponents.kt | 1601 +++++++++-------- 2 files changed, 851 insertions(+), 762 deletions(-) diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/components/AttachmentComponents.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/components/AttachmentComponents.kt index c5bf3b8..bfc73c7 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/components/AttachmentComponents.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/components/AttachmentComponents.kt @@ -461,6 +461,10 @@ fun ImageAttachment( fillMaxSize: Boolean = false, onImageClick: (attachmentId: String) -> Unit = {} ) { + Log.d( + TAG, + "🖼️ ImageAttachment: id=${attachment.id}, isOutgoing=$isOutgoing, messageStatus=$messageStatus, showTimeOverlay=$showTimeOverlay" + ) val context = LocalContext.current val scope = rememberCoroutineScope() @@ -845,7 +849,7 @@ fun ImageAttachment( } MessageStatus.DELIVERED -> { Icon( - compose.icons.TablerIcons.Checks, + compose.icons.TablerIcons.Check, contentDescription = null, tint = Color.White.copy(alpha = 0.7f), modifier = Modifier.size(14.dp) @@ -855,7 +859,7 @@ fun ImageAttachment( Icon( compose.icons.TablerIcons.Checks, contentDescription = null, - tint = Color(0xFF4FC3F7), + tint = Color.White, modifier = Modifier.size(14.dp) ) } @@ -1578,7 +1582,7 @@ fun AvatarAttachment( } MessageStatus.DELIVERED -> { Icon( - compose.icons.TablerIcons.Checks, + compose.icons.TablerIcons.Check, contentDescription = "Delivered", tint = Color.White.copy(alpha = 0.6f), modifier = Modifier.size(14.dp) @@ -1588,7 +1592,7 @@ fun AvatarAttachment( Icon( compose.icons.TablerIcons.Checks, contentDescription = "Read", - tint = Color(0xFF4FC3F7), + tint = Color.White, modifier = Modifier.size(14.dp) ) } diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/components/ChatDetailComponents.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/components/ChatDetailComponents.kt index 7bbb6fa..81ce524 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/components/ChatDetailComponents.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/components/ChatDetailComponents.kt @@ -1,6 +1,9 @@ package com.rosetta.messenger.ui.chats.components import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.util.Base64 +import android.util.Log import androidx.compose.animation.* import androidx.compose.animation.core.* import androidx.compose.foundation.ExperimentalFoundationApi @@ -11,21 +14,16 @@ import androidx.compose.foundation.clickable import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.gestures.detectHorizontalDragGestures import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.interaction.collectIsPressedAsState import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.* -import compose.icons.TablerIcons -import compose.icons.tablericons.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.scale -import androidx.compose.ui.draw.shadow import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.TransformOrigin import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.vector.ImageVector @@ -37,63 +35,59 @@ import androidx.compose.ui.text.style.TextOverflow 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 androidx.compose.ui.window.PopupProperties -import android.graphics.BitmapFactory -import android.util.Base64 -import android.util.Log -import com.rosetta.messenger.crypto.MessageCrypto import com.rosetta.messenger.network.AttachmentType import com.rosetta.messenger.network.MessageAttachment -import com.rosetta.messenger.network.TransportManager import com.rosetta.messenger.repository.AvatarRepository -import com.rosetta.messenger.ui.components.AppleEmojiText -import com.rosetta.messenger.ui.onboarding.PrimaryBlue import com.rosetta.messenger.ui.chats.models.* import com.rosetta.messenger.ui.chats.utils.* +import com.rosetta.messenger.ui.components.AppleEmojiText +import com.rosetta.messenger.ui.onboarding.PrimaryBlue import com.rosetta.messenger.utils.AttachmentFileManager import com.vanniktech.blurhash.BlurHash -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.delay -import kotlinx.coroutines.withContext +import compose.icons.TablerIcons +import compose.icons.tablericons.* import java.text.SimpleDateFormat import java.util.* +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext /** - * Reusable UI components for Chat Detail Screen - * Extracted from ChatDetailScreen.kt for better organization + * Reusable UI components for Chat Detail Screen Extracted from ChatDetailScreen.kt for better + * organization */ /** Date header with fade-in animation */ @Composable fun DateHeader(dateText: String, secondaryTextColor: Color) { var isVisible by remember { mutableStateOf(false) } - val alpha by animateFloatAsState( - targetValue = if (isVisible) 1f else 0f, - animationSpec = tween(durationMillis = 250, easing = TelegramEasing), - label = "dateAlpha" - ) + val alpha by + animateFloatAsState( + targetValue = if (isVisible) 1f else 0f, + animationSpec = tween(durationMillis = 250, easing = TelegramEasing), + label = "dateAlpha" + ) LaunchedEffect(dateText) { isVisible = true } Row( - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 12.dp) - .graphicsLayer { this.alpha = alpha }, - horizontalArrangement = Arrangement.Center + modifier = + Modifier.fillMaxWidth().padding(vertical = 12.dp).graphicsLayer { + this.alpha = alpha + }, + horizontalArrangement = Arrangement.Center ) { Text( - text = dateText, - fontSize = 13.sp, - fontWeight = FontWeight.Medium, - color = secondaryTextColor, - modifier = Modifier - .background( - color = secondaryTextColor.copy(alpha = 0.1f), - shape = RoundedCornerShape(12.dp) - ) - .padding(horizontal = 12.dp, vertical = 4.dp) + text = dateText, + fontSize = 13.sp, + fontWeight = FontWeight.Medium, + color = secondaryTextColor, + modifier = + Modifier.background( + color = secondaryTextColor.copy(alpha = 0.1f), + shape = RoundedCornerShape(12.dp) + ) + .padding(horizontal = 12.dp, vertical = 4.dp) ) } } @@ -105,31 +99,34 @@ fun TypingIndicator(isDarkTheme: Boolean) { val typingColor = Color(0xFF54A9EB) Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(2.dp) + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(2.dp) ) { Text(text = "typing", fontSize = 13.sp, color = typingColor) repeat(3) { index -> - val offsetY by infiniteTransition.animateFloat( - initialValue = 0f, - targetValue = -4f, - animationSpec = infiniteRepeatable( - animation = tween( - durationMillis = 600, - delayMillis = index * 100, - easing = FastOutSlowInEasing - ), - repeatMode = RepeatMode.Reverse - ), - label = "dot$index" - ) + val offsetY by + infiniteTransition.animateFloat( + initialValue = 0f, + targetValue = -4f, + animationSpec = + infiniteRepeatable( + animation = + tween( + durationMillis = 600, + delayMillis = index * 100, + easing = FastOutSlowInEasing + ), + repeatMode = RepeatMode.Reverse + ), + label = "dot$index" + ) Text( - text = ".", - fontSize = 13.sp, - color = typingColor, - modifier = Modifier.offset(y = offsetY.dp) + text = ".", + fontSize = 13.sp, + color = typingColor, + modifier = Modifier.offset(y = offsetY.dp) ) } } @@ -139,316 +136,355 @@ fun TypingIndicator(isDarkTheme: Boolean) { @OptIn(ExperimentalFoundationApi::class) @Composable fun MessageBubble( - message: ChatMessage, - isDarkTheme: Boolean, - showTail: Boolean = true, - isSelected: Boolean = false, - isHighlighted: Boolean = false, - isSavedMessages: Boolean = false, - privateKey: String = "", - senderPublicKey: String = "", - currentUserPublicKey: String = "", - avatarRepository: AvatarRepository? = null, - onLongClick: () -> Unit = {}, - onClick: () -> Unit = {}, - onSwipeToReply: () -> Unit = {}, - onReplyClick: (String) -> Unit = {}, - onRetry: () -> Unit = {}, - onDelete: () -> Unit = {}, - onImageClick: (attachmentId: String) -> Unit = {} + message: ChatMessage, + isDarkTheme: Boolean, + showTail: Boolean = true, + isSelected: Boolean = false, + isHighlighted: Boolean = false, + isSavedMessages: Boolean = false, + privateKey: String = "", + senderPublicKey: String = "", + currentUserPublicKey: String = "", + avatarRepository: AvatarRepository? = null, + onLongClick: () -> Unit = {}, + onClick: () -> Unit = {}, + onSwipeToReply: () -> Unit = {}, + onReplyClick: (String) -> Unit = {}, + onRetry: () -> Unit = {}, + onDelete: () -> Unit = {}, + onImageClick: (attachmentId: String) -> Unit = {} ) { // Swipe-to-reply state var swipeOffset by remember { mutableStateOf(0f) } val swipeThreshold = 80f val maxSwipe = 120f - val animatedOffset by animateFloatAsState( - targetValue = swipeOffset, - animationSpec = spring(dampingRatio = 0.6f, stiffness = 400f), - label = "swipeOffset" - ) + val animatedOffset by + animateFloatAsState( + targetValue = swipeOffset, + animationSpec = spring(dampingRatio = 0.6f, stiffness = 400f), + label = "swipeOffset" + ) val swipeProgress = (kotlin.math.abs(animatedOffset) / swipeThreshold).coerceIn(0f, 1f) // Selection animations - val selectionScale by animateFloatAsState( - targetValue = if (isSelected) 0.95f else 1f, - animationSpec = spring(dampingRatio = 0.6f, stiffness = 400f), - label = "selectionScale" - ) - val selectionAlpha by animateFloatAsState( - targetValue = if (isSelected) 0.85f else 1f, - animationSpec = tween(150), - label = "selectionAlpha" - ) + val selectionScale by + animateFloatAsState( + targetValue = if (isSelected) 0.95f else 1f, + animationSpec = spring(dampingRatio = 0.6f, stiffness = 400f), + label = "selectionScale" + ) + val selectionAlpha by + animateFloatAsState( + targetValue = if (isSelected) 0.85f else 1f, + animationSpec = tween(150), + label = "selectionAlpha" + ) // Colors - val bubbleColor = remember(message.isOutgoing, isDarkTheme) { - if (message.isOutgoing) { - PrimaryBlue - } else { - if (isDarkTheme) Color(0xFF212121) else Color(0xFFF5F5F5) - } - } - - val textColor = remember(message.isOutgoing, isDarkTheme) { - if (message.isOutgoing) Color.White - else if (isDarkTheme) Color.White else Color(0xFF000000) - } - - val timeColor = remember(message.isOutgoing, isDarkTheme) { - if (message.isOutgoing) Color.White.copy(alpha = 0.7f) - else if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666) - } + val bubbleColor = + remember(message.isOutgoing, isDarkTheme) { + if (message.isOutgoing) { + PrimaryBlue + } else { + if (isDarkTheme) Color(0xFF212121) else Color(0xFFF5F5F5) + } + } - val bubbleShape = remember(message.isOutgoing, showTail) { - RoundedCornerShape( - topStart = 18.dp, - topEnd = 18.dp, - bottomStart = if (message.isOutgoing) 18.dp else (if (showTail) 4.dp else 18.dp), - bottomEnd = if (message.isOutgoing) (if (showTail) 4.dp else 18.dp) else 18.dp - ) - } + val textColor = + remember(message.isOutgoing, isDarkTheme) { + if (message.isOutgoing) Color.White + else if (isDarkTheme) Color.White else Color(0xFF000000) + } + + val timeColor = + remember(message.isOutgoing, isDarkTheme) { + if (message.isOutgoing) Color.White.copy(alpha = 0.7f) + else if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666) + } + + val bubbleShape = + remember(message.isOutgoing, showTail) { + RoundedCornerShape( + topStart = 18.dp, + topEnd = 18.dp, + bottomStart = + if (message.isOutgoing) 18.dp else (if (showTail) 4.dp else 18.dp), + bottomEnd = + if (message.isOutgoing) (if (showTail) 4.dp else 18.dp) else 18.dp + ) + } val timeFormat = remember { SimpleDateFormat("HH:mm", Locale.getDefault()) } Box( - modifier = Modifier - .fillMaxWidth() - .pointerInput(Unit) { - detectHorizontalDragGestures( - onDragEnd = { - if (swipeOffset <= -swipeThreshold) { - onSwipeToReply() - } - swipeOffset = 0f - }, - onDragCancel = { swipeOffset = 0f }, - onHorizontalDrag = { _, dragAmount -> - val newOffset = swipeOffset + dragAmount - swipeOffset = newOffset.coerceIn(-maxSwipe, 0f) + modifier = + Modifier.fillMaxWidth().pointerInput(Unit) { + detectHorizontalDragGestures( + onDragEnd = { + if (swipeOffset <= -swipeThreshold) { + onSwipeToReply() + } + swipeOffset = 0f + }, + onDragCancel = { swipeOffset = 0f }, + onHorizontalDrag = { _, dragAmount -> + val newOffset = swipeOffset + dragAmount + swipeOffset = newOffset.coerceIn(-maxSwipe, 0f) + } + ) } - ) - } ) { // Reply icon Box( - modifier = Modifier - .align(Alignment.CenterEnd) - .padding(end = 16.dp) - .graphicsLayer { - alpha = swipeProgress - scaleX = swipeProgress - scaleY = swipeProgress - } + modifier = + Modifier.align(Alignment.CenterEnd).padding(end = 16.dp).graphicsLayer { + alpha = swipeProgress + scaleX = swipeProgress + scaleY = swipeProgress + } ) { Box( - modifier = Modifier - .size(36.dp) - .clip(CircleShape) - .background( - if (swipeProgress >= 1f) PrimaryBlue - else if (isDarkTheme) Color(0xFF3A3A3A) - else Color(0xFFE0E0E0) - ), - contentAlignment = Alignment.Center + modifier = + Modifier.size(36.dp) + .clip(CircleShape) + .background( + if (swipeProgress >= 1f) PrimaryBlue + else if (isDarkTheme) Color(0xFF3A3A3A) + else Color(0xFFE0E0E0) + ), + contentAlignment = Alignment.Center ) { Icon( - TablerIcons.CornerUpLeft, - contentDescription = "Reply", - tint = if (swipeProgress >= 1f) Color.White - else if (isDarkTheme) Color.White.copy(alpha = 0.7f) - else Color(0xFF666666), - modifier = Modifier.size(20.dp) + TablerIcons.CornerUpLeft, + contentDescription = "Reply", + tint = + if (swipeProgress >= 1f) Color.White + else if (isDarkTheme) Color.White.copy(alpha = 0.7f) + else Color(0xFF666666), + modifier = Modifier.size(20.dp) ) } } - val selectionBackgroundColor by animateColorAsState( - targetValue = if (isSelected) PrimaryBlue.copy(alpha = 0.15f) else Color.Transparent, - animationSpec = tween(200), - label = "selectionBg" - ) + val selectionBackgroundColor by + animateColorAsState( + targetValue = + if (isSelected) PrimaryBlue.copy(alpha = 0.15f) + else Color.Transparent, + animationSpec = tween(200), + label = "selectionBg" + ) - val highlightBackgroundColor by animateColorAsState( - targetValue = if (isHighlighted) PrimaryBlue.copy(alpha = 0.12f) else Color.Transparent, - animationSpec = tween(300), - label = "highlightBg" - ) + val highlightBackgroundColor by + animateColorAsState( + targetValue = + if (isHighlighted) PrimaryBlue.copy(alpha = 0.12f) + else Color.Transparent, + animationSpec = tween(300), + label = "highlightBg" + ) - val combinedBackgroundColor = if (isSelected) selectionBackgroundColor else highlightBackgroundColor + val combinedBackgroundColor = + if (isSelected) selectionBackgroundColor else highlightBackgroundColor Row( - modifier = Modifier - .fillMaxWidth() - .background(combinedBackgroundColor) - .padding(vertical = 2.dp) - .offset { IntOffset(animatedOffset.toInt(), 0) }, - horizontalArrangement = Arrangement.Start, - verticalAlignment = Alignment.CenterVertically + modifier = + Modifier.fillMaxWidth() + .background(combinedBackgroundColor) + .padding(vertical = 2.dp) + .offset { IntOffset(animatedOffset.toInt(), 0) }, + horizontalArrangement = Arrangement.Start, + verticalAlignment = Alignment.CenterVertically ) { // Selection checkmark AnimatedVisibility( - visible = isSelected, - enter = fadeIn(tween(150)) + scaleIn( - initialScale = 0.3f, - animationSpec = spring(dampingRatio = 0.6f) - ), - exit = fadeOut(tween(100)) + scaleOut(targetScale = 0.3f) + visible = isSelected, + enter = + fadeIn(tween(150)) + + scaleIn( + initialScale = 0.3f, + animationSpec = spring(dampingRatio = 0.6f) + ), + exit = fadeOut(tween(100)) + scaleOut(targetScale = 0.3f) ) { Box( - modifier = Modifier - .padding(start = 12.dp, end = 4.dp) - .size(24.dp) - .clip(CircleShape) - .background(Color(0xFF4CD964)), - contentAlignment = Alignment.Center + modifier = + Modifier.padding(start = 12.dp, end = 4.dp) + .size(24.dp) + .clip(CircleShape) + .background(Color(0xFF4CD964)), + contentAlignment = Alignment.Center ) { Icon( - TablerIcons.Check, - contentDescription = "Selected", - tint = Color.White, - modifier = Modifier.size(16.dp) + TablerIcons.Check, + contentDescription = "Selected", + tint = Color.White, + modifier = Modifier.size(16.dp) ) } } AnimatedVisibility( - visible = !isSelected, - enter = fadeIn(tween(100)), - exit = fadeOut(tween(100)) - ) { - Spacer(modifier = Modifier.width(12.dp)) - } + visible = !isSelected, + enter = fadeIn(tween(100)), + exit = fadeOut(tween(100)) + ) { Spacer(modifier = Modifier.width(12.dp)) } if (message.isOutgoing) { Spacer(modifier = Modifier.weight(1f)) } // Проверяем - есть ли только фотки без текста - val hasOnlyMedia = message.attachments.isNotEmpty() && - message.text.isEmpty() && - message.replyData == null && - message.attachments.all { it.type == com.rosetta.messenger.network.AttachmentType.IMAGE } - + val hasOnlyMedia = + message.attachments.isNotEmpty() && + message.text.isEmpty() && + message.replyData == null && + message.attachments.all { + it.type == com.rosetta.messenger.network.AttachmentType.IMAGE + } + // Фото + caption (как в Telegram) - val hasImageWithCaption = message.attachments.isNotEmpty() && - message.text.isNotEmpty() && - message.replyData == null && - message.attachments.all { it.type == com.rosetta.messenger.network.AttachmentType.IMAGE } - + val hasImageWithCaption = + message.attachments.isNotEmpty() && + message.text.isNotEmpty() && + message.replyData == null && + message.attachments.all { + it.type == com.rosetta.messenger.network.AttachmentType.IMAGE + } + // Для сообщений только с фото - минимальный padding и тонкий border // Для фото + caption - padding только внизу для текста - val bubblePadding = when { - hasOnlyMedia -> PaddingValues(0.dp) - hasImageWithCaption -> PaddingValues(0.dp) - else -> PaddingValues(horizontal = 10.dp, vertical = 8.dp) - } + val bubblePadding = + when { + hasOnlyMedia -> PaddingValues(0.dp) + hasImageWithCaption -> PaddingValues(0.dp) + else -> PaddingValues(horizontal = 10.dp, vertical = 8.dp) + } val bubbleBorderWidth = if (hasOnlyMedia) 1.dp else 0.dp Box( - modifier = Modifier - .padding(end = 12.dp) - .widthIn(min = 60.dp, max = 280.dp) - .wrapContentWidth(unbounded = false) - .graphicsLayer { - this.alpha = selectionAlpha - this.scaleX = selectionScale - this.scaleY = selectionScale - } - .combinedClickable( - indication = null, - interactionSource = remember { MutableInteractionSource() }, - onClick = onClick, - onLongClick = onLongClick - ) - .clip(bubbleShape) - .then( - if (hasOnlyMedia) { - Modifier.border( - width = bubbleBorderWidth, - color = if (message.isOutgoing) { - Color.White.copy(alpha = 0.15f) - } else { - if (isDarkTheme) Color.White.copy(alpha = 0.1f) else Color.Black.copy(alpha = 0.08f) - }, - shape = bubbleShape - ) - } else { - Modifier.background(bubbleColor) - } - ) - .padding(bubblePadding) + modifier = + Modifier.padding(end = 12.dp) + .widthIn(min = 60.dp, max = 280.dp) + .wrapContentWidth(unbounded = false) + .graphicsLayer { + this.alpha = selectionAlpha + this.scaleX = selectionScale + this.scaleY = selectionScale + } + .combinedClickable( + indication = null, + interactionSource = + remember { MutableInteractionSource() }, + onClick = onClick, + onLongClick = onLongClick + ) + .clip(bubbleShape) + .then( + if (hasOnlyMedia) { + Modifier.border( + width = bubbleBorderWidth, + color = + if (message.isOutgoing) { + Color.White.copy(alpha = 0.15f) + } else { + if (isDarkTheme) + Color.White.copy( + alpha = 0.1f + ) + else + Color.Black.copy( + alpha = 0.08f + ) + }, + shape = bubbleShape + ) + } else { + Modifier.background(bubbleColor) + } + ) + .padding(bubblePadding) ) { Column { message.replyData?.let { reply -> ReplyBubble( - replyData = reply, - isOutgoing = message.isOutgoing, - isDarkTheme = isDarkTheme, - onClick = { onReplyClick(reply.messageId) } + replyData = reply, + isOutgoing = message.isOutgoing, + isDarkTheme = isDarkTheme, + onClick = { onReplyClick(reply.messageId) } ) Spacer(modifier = Modifier.height(4.dp)) } - + // 📎 Attachments (IMAGE, FILE, AVATAR) if (message.attachments.isNotEmpty()) { + val attachmentDisplayStatus = + if (isSavedMessages) MessageStatus.READ else message.status MessageAttachments( - attachments = message.attachments, - chachaKey = message.chachaKey, - privateKey = privateKey, - isOutgoing = message.isOutgoing, - isDarkTheme = isDarkTheme, - senderPublicKey = senderPublicKey, - timestamp = message.timestamp, - messageStatus = message.status, - avatarRepository = avatarRepository, - currentUserPublicKey = currentUserPublicKey, - hasCaption = hasImageWithCaption, // Если есть caption - время на пузырьке, не на фото - onImageClick = onImageClick + attachments = message.attachments, + chachaKey = message.chachaKey, + privateKey = privateKey, + isOutgoing = message.isOutgoing, + isDarkTheme = isDarkTheme, + senderPublicKey = senderPublicKey, + timestamp = message.timestamp, + messageStatus = attachmentDisplayStatus, + avatarRepository = avatarRepository, + currentUserPublicKey = currentUserPublicKey, + hasCaption = hasImageWithCaption, // Если есть caption - время на + // пузырьке, не на фото + onImageClick = onImageClick ) } - + // 🖼️ Caption под фото (как в Telegram) if (hasImageWithCaption) { Column( - modifier = Modifier - .fillMaxWidth() - .background(bubbleColor) - .padding(horizontal = 10.dp, vertical = 6.dp) // Уменьшил padding + modifier = + Modifier.fillMaxWidth() + .background(bubbleColor) + .padding( + horizontal = 10.dp, + vertical = 6.dp + ) // Уменьшил padding ) { Row( - verticalAlignment = Alignment.Bottom, - modifier = Modifier.fillMaxWidth() + verticalAlignment = Alignment.Bottom, + modifier = Modifier.fillMaxWidth() ) { AppleEmojiText( - text = message.text, - color = textColor, - fontSize = 16.sp, - modifier = Modifier.weight(1f, fill = false) + text = message.text, + color = textColor, + fontSize = 16.sp, + modifier = Modifier.weight(1f, fill = false) ) - + Spacer(modifier = Modifier.width(8.dp)) - + Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(2.dp), - modifier = Modifier.padding(bottom = 2.dp) + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(2.dp), + modifier = Modifier.padding(bottom = 2.dp) ) { Text( - text = timeFormat.format(message.timestamp), - color = timeColor, - fontSize = 11.sp, - fontStyle = androidx.compose.ui.text.font.FontStyle.Italic + text = timeFormat.format(message.timestamp), + color = timeColor, + fontSize = 11.sp, + fontStyle = + androidx.compose.ui.text.font.FontStyle.Italic ) if (message.isOutgoing) { - val displayStatus = if (isSavedMessages) MessageStatus.READ else message.status + val displayStatus = + if (isSavedMessages) MessageStatus.READ + else message.status AnimatedMessageStatus( - status = displayStatus, - timeColor = timeColor, - timestamp = message.timestamp.time, - onRetry = onRetry, - onDelete = onDelete + status = displayStatus, + timeColor = timeColor, + timestamp = message.timestamp.time, + onRetry = onRetry, + onDelete = onDelete ) } } @@ -462,37 +498,39 @@ fun MessageBubble( // Если есть reply - текст слева, время справа на одной строке if (message.replyData != null && message.text.isNotEmpty()) { Row( - verticalAlignment = Alignment.Bottom, - modifier = Modifier.fillMaxWidth() + verticalAlignment = Alignment.Bottom, + modifier = Modifier.fillMaxWidth() ) { AppleEmojiText( - text = message.text, - color = textColor, - fontSize = 17.sp, - modifier = Modifier.weight(1f, fill = false) + text = message.text, + color = textColor, + fontSize = 17.sp, + modifier = Modifier.weight(1f, fill = false) ) - + Spacer(modifier = Modifier.width(10.dp)) - + Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(2.dp), - modifier = Modifier.padding(bottom = 2.dp) + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(2.dp), + modifier = Modifier.padding(bottom = 2.dp) ) { Text( - text = timeFormat.format(message.timestamp), - color = timeColor, - fontSize = 11.sp, - fontStyle = androidx.compose.ui.text.font.FontStyle.Italic + text = timeFormat.format(message.timestamp), + color = timeColor, + fontSize = 11.sp, + fontStyle = androidx.compose.ui.text.font.FontStyle.Italic ) if (message.isOutgoing) { - val displayStatus = if (isSavedMessages) MessageStatus.READ else message.status + val displayStatus = + if (isSavedMessages) MessageStatus.READ + else message.status AnimatedMessageStatus( - status = displayStatus, - timeColor = timeColor, - timestamp = message.timestamp.time, - onRetry = onRetry, - onDelete = onDelete + status = displayStatus, + timeColor = timeColor, + timestamp = message.timestamp.time, + onRetry = onRetry, + onDelete = onDelete ) } } @@ -500,36 +538,38 @@ fun MessageBubble( } else if (!hasOnlyMedia && !hasImageWithCaption && message.text.isNotEmpty()) { // Без reply, не только фото, и не фото с caption - компактно в одну строку Row( - verticalAlignment = Alignment.Bottom, - horizontalArrangement = Arrangement.spacedBy(10.dp), - modifier = Modifier.wrapContentWidth() + verticalAlignment = Alignment.Bottom, + horizontalArrangement = Arrangement.spacedBy(10.dp), + modifier = Modifier.wrapContentWidth() ) { AppleEmojiText( - text = message.text, - color = textColor, - fontSize = 17.sp, - modifier = Modifier.wrapContentWidth() + text = message.text, + color = textColor, + fontSize = 17.sp, + modifier = Modifier.wrapContentWidth() ) Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(2.dp), - modifier = Modifier.padding(bottom = 2.dp) + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(2.dp), + modifier = Modifier.padding(bottom = 2.dp) ) { Text( - text = timeFormat.format(message.timestamp), - color = timeColor, - fontSize = 11.sp, - fontStyle = androidx.compose.ui.text.font.FontStyle.Italic + text = timeFormat.format(message.timestamp), + color = timeColor, + fontSize = 11.sp, + fontStyle = androidx.compose.ui.text.font.FontStyle.Italic ) if (message.isOutgoing) { - val displayStatus = if (isSavedMessages) MessageStatus.READ else message.status + val displayStatus = + if (isSavedMessages) MessageStatus.READ + else message.status AnimatedMessageStatus( - status = displayStatus, - timeColor = timeColor, - timestamp = message.timestamp.time, - onRetry = onRetry, - onDelete = onDelete + status = displayStatus, + timeColor = timeColor, + timestamp = message.timestamp.time, + onRetry = onRetry, + onDelete = onDelete ) } } @@ -544,28 +584,29 @@ fun MessageBubble( /** Animated message status indicator */ @Composable fun AnimatedMessageStatus( - status: MessageStatus, - timeColor: Color, - timestamp: Long = 0L, - onRetry: () -> Unit = {}, - onDelete: () -> Unit = {} + status: MessageStatus, + timeColor: Color, + timestamp: Long = 0L, + onRetry: () -> Unit = {}, + onDelete: () -> Unit = {} ) { - val isTimedOut = status == MessageStatus.SENDING && - timestamp > 0 && - !isMessageDeliveredByTime(timestamp) + val isTimedOut = + status == MessageStatus.SENDING && timestamp > 0 && !isMessageDeliveredByTime(timestamp) val effectiveStatus = if (isTimedOut) MessageStatus.ERROR else status - val targetColor = when (effectiveStatus) { - MessageStatus.READ -> Color(0xFF4FC3F7) - MessageStatus.ERROR -> Color(0xFFE53935) - else -> timeColor - } - - val animatedColor by animateColorAsState( - targetValue = targetColor, - animationSpec = tween(durationMillis = 300, easing = FastOutSlowInEasing), - label = "statusColor" - ) + val targetColor = + when (effectiveStatus) { + MessageStatus.READ -> Color(0xFF4FC3F7) + MessageStatus.ERROR -> Color(0xFFE53935) + else -> timeColor + } + + val animatedColor by + animateColorAsState( + targetValue = targetColor, + animationSpec = tween(durationMillis = 300, easing = FastOutSlowInEasing), + label = "statusColor" + ) var previousStatus by remember { mutableStateOf(effectiveStatus) } var shouldAnimate by remember { mutableStateOf(false) } @@ -577,79 +618,79 @@ fun AnimatedMessageStatus( } } - val scale by animateFloatAsState( - targetValue = if (shouldAnimate) 1.2f else 1f, - animationSpec = spring( - dampingRatio = Spring.DampingRatioMediumBouncy, - stiffness = Spring.StiffnessLow - ), - finishedListener = { shouldAnimate = false }, - label = "statusScale" - ) + val scale by + animateFloatAsState( + targetValue = if (shouldAnimate) 1.2f else 1f, + animationSpec = + spring( + dampingRatio = Spring.DampingRatioMediumBouncy, + stiffness = Spring.StiffnessLow + ), + finishedListener = { shouldAnimate = false }, + label = "statusScale" + ) var showErrorMenu by remember { mutableStateOf(false) } Box { Crossfade( - targetState = effectiveStatus, - animationSpec = tween(durationMillis = 200), - label = "statusIcon" + targetState = effectiveStatus, + animationSpec = tween(durationMillis = 200), + label = "statusIcon" ) { currentStatus -> val iconSize = with(LocalDensity.current) { 14.sp.toDp() } Icon( - imageVector = when (currentStatus) { - MessageStatus.SENDING -> TablerIcons.Clock - MessageStatus.SENT -> TablerIcons.Check - MessageStatus.DELIVERED -> TablerIcons.Check - MessageStatus.READ -> TablerIcons.Checks - MessageStatus.ERROR -> TablerIcons.AlertCircle - }, - contentDescription = null, - tint = animatedColor, - modifier = Modifier - .size(iconSize) - .scale(scale) - .then( - if (currentStatus == MessageStatus.ERROR) { - Modifier.clickable { showErrorMenu = true } - } else Modifier - ) + imageVector = + when (currentStatus) { + MessageStatus.SENDING -> TablerIcons.Clock + MessageStatus.SENT -> TablerIcons.Check + MessageStatus.DELIVERED -> TablerIcons.Check + MessageStatus.READ -> TablerIcons.Checks + MessageStatus.ERROR -> TablerIcons.AlertCircle + }, + contentDescription = null, + tint = animatedColor, + modifier = + Modifier.size(iconSize) + .scale(scale) + .then( + if (currentStatus == MessageStatus.ERROR) { + Modifier.clickable { showErrorMenu = true } + } else Modifier + ) ) } - DropdownMenu( - expanded = showErrorMenu, - onDismissRequest = { showErrorMenu = false } - ) { + DropdownMenu(expanded = showErrorMenu, onDismissRequest = { showErrorMenu = false }) { DropdownMenuItem( - text = { Text("Retry") }, - onClick = { - showErrorMenu = false - onRetry() - }, - leadingIcon = { - Icon( - TablerIcons.Refresh, - contentDescription = null, - modifier = Modifier.size(18.dp) - ) - } + text = { Text("Retry") }, + onClick = { + showErrorMenu = false + onRetry() + }, + leadingIcon = { + Icon( + TablerIcons.Refresh, + contentDescription = null, + modifier = Modifier.size(18.dp) + ) + } ) DropdownMenuItem( - text = { Text("Delete", color = Color(0xFFE53935)) }, - onClick = { - showErrorMenu = false - onDelete() - }, - leadingIcon = { - Icon( - TablerIcons.Trash, - contentDescription = null, - tint = Color(0xFFE53935), - modifier = Modifier.size(18.dp) - ) - } + text = { Text("Delete", color = Color(0xFFE53935)) }, + onClick = { + showErrorMenu = false + onDelete() + }, + leadingIcon = { + Icon( + TablerIcons.Trash, + contentDescription = null, + tint = Color(0xFFE53935), + modifier = Modifier.size(18.dp) + ) + } ) } } @@ -658,48 +699,53 @@ fun AnimatedMessageStatus( /** Reply bubble inside message - Telegram style with image preview */ @Composable fun ReplyBubble( - replyData: ReplyData, - isOutgoing: Boolean, - isDarkTheme: Boolean, - onClick: () -> Unit = {} + replyData: ReplyData, + isOutgoing: Boolean, + isDarkTheme: Boolean, + onClick: () -> Unit = {} ) { val context = androidx.compose.ui.platform.LocalContext.current - val backgroundColor = if (isOutgoing) { - Color.Black.copy(alpha = 0.15f) - } else { - Color.Black.copy(alpha = 0.08f) - } + val backgroundColor = + if (isOutgoing) { + Color.Black.copy(alpha = 0.15f) + } else { + Color.Black.copy(alpha = 0.08f) + } val borderColor = if (isOutgoing) Color.White else PrimaryBlue val nameColor = if (isOutgoing) Color.White else PrimaryBlue - val replyTextColor = if (isOutgoing) { - Color.White.copy(alpha = 0.85f) - } else { - if (isDarkTheme) Color.White else Color.Black - } - + val replyTextColor = + if (isOutgoing) { + Color.White.copy(alpha = 0.85f) + } else { + if (isDarkTheme) Color.White else Color.Black + } + // 🖼️ Проверяем есть ли изображение в attachments val imageAttachment = replyData.attachments.firstOrNull { it.type == AttachmentType.IMAGE } val hasImage = imageAttachment != null - + // Загружаем полноценную картинку вместо blur preview var imageBitmap by remember { mutableStateOf(null) } // Blurhash preview для fallback var blurPreviewBitmap by remember { mutableStateOf(null) } - + // Сначала загружаем blurhash preview LaunchedEffect(imageAttachment?.preview) { if (imageAttachment != null && imageAttachment.preview.isNotEmpty()) { withContext(Dispatchers.IO) { try { // Получаем blurhash из preview (может быть в формате "tag::blurhash") - val blurhash = if (imageAttachment.preview.contains("::")) { - imageAttachment.preview.substringAfter("::") - } else if (!imageAttachment.preview.startsWith("http") && imageAttachment.preview.length < 50) { - imageAttachment.preview - } else { - "" - } + val blurhash = + if (imageAttachment.preview.contains("::")) { + imageAttachment.preview.substringAfter("::") + } else if (!imageAttachment.preview.startsWith("http") && + imageAttachment.preview.length < 50 + ) { + imageAttachment.preview + } else { + "" + } if (blurhash.isNotEmpty()) { blurPreviewBitmap = BlurHash.decode(blurhash, 36, 36) } @@ -709,7 +755,7 @@ fun ReplyBubble( } } } - + // Потом пробуем загрузить полноценную картинку LaunchedEffect(imageAttachment?.id) { if (imageAttachment != null) { @@ -717,43 +763,56 @@ fun ReplyBubble( try { // Пробуем сначала из blob if (imageAttachment.blob.isNotEmpty()) { - val decoded = try { - val cleanBase64 = if (imageAttachment.blob.contains(",")) { - imageAttachment.blob.substringAfter(",") - } else { - imageAttachment.blob - } - val decodedBytes = Base64.decode(cleanBase64, Base64.DEFAULT) - BitmapFactory.decodeByteArray(decodedBytes, 0, decodedBytes.size) - } catch (e: Exception) { - null - } + val decoded = + try { + val cleanBase64 = + if (imageAttachment.blob.contains(",")) { + imageAttachment.blob.substringAfter(",") + } else { + imageAttachment.blob + } + val decodedBytes = Base64.decode(cleanBase64, Base64.DEFAULT) + BitmapFactory.decodeByteArray( + decodedBytes, + 0, + decodedBytes.size + ) + } catch (e: Exception) { + null + } if (decoded != null) { imageBitmap = decoded return@withContext } } - + // Если blob нет - загружаем из локального файла - val localBlob = AttachmentFileManager.readAttachment( - context, - imageAttachment.id, - replyData.senderPublicKey, - replyData.recipientPrivateKey - ) - + val localBlob = + AttachmentFileManager.readAttachment( + context, + imageAttachment.id, + replyData.senderPublicKey, + replyData.recipientPrivateKey + ) + if (localBlob != null) { - val decoded = try { - val cleanBase64 = if (localBlob.contains(",")) { - localBlob.substringAfter(",") - } else { - localBlob - } - val decodedBytes = Base64.decode(cleanBase64, Base64.DEFAULT) - BitmapFactory.decodeByteArray(decodedBytes, 0, decodedBytes.size) - } catch (e: Exception) { - null - } + val decoded = + try { + val cleanBase64 = + if (localBlob.contains(",")) { + localBlob.substringAfter(",") + } else { + localBlob + } + val decodedBytes = Base64.decode(cleanBase64, Base64.DEFAULT) + BitmapFactory.decodeByteArray( + decodedBytes, + 0, + decodedBytes.size + ) + } catch (e: Exception) { + null + } imageBitmap = decoded } } catch (e: Exception) { @@ -764,88 +823,94 @@ fun ReplyBubble( } Row( - modifier = Modifier - .fillMaxWidth() - .height(IntrinsicSize.Min) - .clip(RoundedCornerShape(4.dp)) - .clickable(onClick = onClick) - .background(backgroundColor) + modifier = + Modifier.fillMaxWidth() + .height(IntrinsicSize.Min) + .clip(RoundedCornerShape(4.dp)) + .clickable(onClick = onClick) + .background(backgroundColor) ) { // Вертикальная полоска слева Box(modifier = Modifier.width(3.dp).fillMaxHeight().background(borderColor)) // Контент reply Row( - modifier = Modifier - .weight(1f) - .padding(start = 8.dp, end = if (hasImage) 4.dp else 10.dp, top = 4.dp, bottom = 4.dp), - verticalAlignment = Alignment.CenterVertically + modifier = + Modifier.weight(1f) + .padding( + start = 8.dp, + end = if (hasImage) 4.dp else 10.dp, + top = 4.dp, + bottom = 4.dp + ), + verticalAlignment = Alignment.CenterVertically ) { // Текстовая часть - Column( - modifier = Modifier.weight(1f) - ) { + Column(modifier = Modifier.weight(1f)) { Text( - text = if (replyData.isForwarded) "Forwarded message" else replyData.senderName, - color = nameColor, - fontSize = 14.sp, - fontWeight = FontWeight.Medium, - maxLines = 1, - overflow = TextOverflow.Ellipsis + text = + if (replyData.isForwarded) "Forwarded message" + else replyData.senderName, + color = nameColor, + fontSize = 14.sp, + fontWeight = FontWeight.Medium, + maxLines = 1, + overflow = TextOverflow.Ellipsis ) // Текст или "Photo" - val displayText = when { - replyData.text.isNotEmpty() -> replyData.text - hasImage -> "Photo" - replyData.attachments.any { it.type == AttachmentType.FILE } -> "File" - else -> "..." - } - + val displayText = + when { + replyData.text.isNotEmpty() -> replyData.text + hasImage -> "Photo" + replyData.attachments.any { it.type == AttachmentType.FILE } -> "File" + else -> "..." + } + Text( - text = displayText, - color = replyTextColor, - fontSize = 14.sp, - maxLines = 1, - overflow = TextOverflow.Ellipsis + text = displayText, + color = replyTextColor, + fontSize = 14.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis ) } - + // 🖼️ Превью изображения справа (как в Telegram) if (hasImage) { Spacer(modifier = Modifier.width(8.dp)) Box( - modifier = Modifier - .size(36.dp) - .clip(RoundedCornerShape(4.dp)) - .background(Color.Gray.copy(alpha = 0.3f)) + modifier = + Modifier.size(36.dp) + .clip(RoundedCornerShape(4.dp)) + .background(Color.Gray.copy(alpha = 0.3f)) ) { if (imageBitmap != null) { Image( - bitmap = imageBitmap!!.asImageBitmap(), - contentDescription = "Photo preview", - modifier = Modifier.fillMaxSize(), - contentScale = ContentScale.Crop + bitmap = imageBitmap!!.asImageBitmap(), + contentDescription = "Photo preview", + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Crop ) } else if (blurPreviewBitmap != null) { // Blurhash preview если картинка не загружена Image( - bitmap = blurPreviewBitmap!!.asImageBitmap(), - contentDescription = "Photo preview", - modifier = Modifier.fillMaxSize(), - contentScale = ContentScale.Crop + bitmap = blurPreviewBitmap!!.asImageBitmap(), + contentDescription = "Photo preview", + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Crop ) } else { // Placeholder с иконкой только если нет blurhash Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center ) { Icon( - TablerIcons.Photo, - contentDescription = null, - tint = Color.White.copy(alpha = 0.7f), - modifier = Modifier.size(20.dp) + TablerIcons.Photo, + contentDescription = null, + tint = Color.White.copy(alpha = 0.7f), + modifier = Modifier.size(20.dp) ) } } @@ -861,24 +926,26 @@ fun MessageSkeletonList(isDarkTheme: Boolean, modifier: Modifier = Modifier) { val skeletonColor = if (isDarkTheme) Color(0xFF3A3A3C) else Color(0xFFE0E0E0) val infiniteTransition = rememberInfiniteTransition(label = "shimmer") - val shimmerAlpha by infiniteTransition.animateFloat( - initialValue = 0.4f, - targetValue = 0.8f, - animationSpec = infiniteRepeatable( - animation = tween(800, easing = FastOutSlowInEasing), - repeatMode = RepeatMode.Reverse - ), - label = "shimmerAlpha" - ) + val shimmerAlpha by + infiniteTransition.animateFloat( + initialValue = 0.4f, + targetValue = 0.8f, + animationSpec = + infiniteRepeatable( + animation = tween(800, easing = FastOutSlowInEasing), + repeatMode = RepeatMode.Reverse + ), + label = "shimmerAlpha" + ) Box(modifier = modifier) { Column( - modifier = Modifier - .align(Alignment.BottomCenter) - .fillMaxWidth() - .padding(horizontal = 8.dp) - .padding(bottom = 80.dp), - verticalArrangement = Arrangement.spacedBy(6.dp) + modifier = + Modifier.align(Alignment.BottomCenter) + .fillMaxWidth() + .padding(horizontal = 8.dp) + .padding(bottom = 80.dp), + verticalArrangement = Arrangement.spacedBy(6.dp) ) { SkeletonBubble(true, 0.45f, skeletonColor, shimmerAlpha) SkeletonBubble(false, 0.55f, skeletonColor, shimmerAlpha) @@ -892,29 +959,29 @@ fun MessageSkeletonList(isDarkTheme: Boolean, modifier: Modifier = Modifier) { @Composable private fun SkeletonBubble( - isOutgoing: Boolean, - widthFraction: Float, - bubbleColor: Color, - alpha: Float + isOutgoing: Boolean, + widthFraction: Float, + bubbleColor: Color, + alpha: Float ) { Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = if (isOutgoing) Arrangement.End else Arrangement.Start + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = if (isOutgoing) Arrangement.End else Arrangement.Start ) { Box( - modifier = Modifier - .fillMaxWidth(widthFraction) - .defaultMinSize(minHeight = 44.dp) - .clip( - RoundedCornerShape( - topStart = 18.dp, - topEnd = 18.dp, - bottomStart = if (isOutgoing) 18.dp else 6.dp, - bottomEnd = if (isOutgoing) 6.dp else 18.dp - ) - ) - .background(bubbleColor.copy(alpha = alpha)) - .padding(horizontal = 14.dp, vertical = 10.dp) + modifier = + Modifier.fillMaxWidth(widthFraction) + .defaultMinSize(minHeight = 44.dp) + .clip( + RoundedCornerShape( + topStart = 18.dp, + topEnd = 18.dp, + bottomStart = if (isOutgoing) 18.dp else 6.dp, + bottomEnd = if (isOutgoing) 6.dp else 18.dp + ) + ) + .background(bubbleColor.copy(alpha = alpha)) + .padding(horizontal = 14.dp, vertical = 10.dp) ) } } @@ -922,116 +989,119 @@ private fun SkeletonBubble( /** Telegram-style kebab menu */ @Composable fun KebabMenu( - expanded: Boolean, - onDismiss: () -> Unit, - isDarkTheme: Boolean, - isSavedMessages: Boolean, - isBlocked: Boolean, - onBlockClick: () -> Unit, - onUnblockClick: () -> Unit, - onDeleteClick: () -> Unit + expanded: Boolean, + onDismiss: () -> Unit, + isDarkTheme: Boolean, + isSavedMessages: Boolean, + isBlocked: Boolean, + onBlockClick: () -> Unit, + onUnblockClick: () -> Unit, + onDeleteClick: () -> Unit ) { - val dividerColor = if (isDarkTheme) Color.White.copy(alpha = 0.1f) else Color.Black.copy(alpha = 0.08f) + val dividerColor = + if (isDarkTheme) Color.White.copy(alpha = 0.1f) else Color.Black.copy(alpha = 0.08f) DropdownMenu( - expanded = expanded, - onDismissRequest = onDismiss, - modifier = Modifier.width(220.dp), - properties = PopupProperties( - focusable = true, - dismissOnBackPress = true, - dismissOnClickOutside = true - ) + expanded = expanded, + onDismissRequest = onDismiss, + modifier = Modifier.width(220.dp), + properties = + PopupProperties( + focusable = true, + dismissOnBackPress = true, + dismissOnClickOutside = true + ) ) { if (!isSavedMessages) { KebabMenuItem( - icon = if (isBlocked) TablerIcons.CircleCheck else TablerIcons.Ban, - text = if (isBlocked) "Unblock User" else "Block User", - onClick = { if (isBlocked) onUnblockClick() else onBlockClick() }, - tintColor = PrimaryBlue, - textColor = if (isDarkTheme) Color.White else Color.Black + icon = if (isBlocked) TablerIcons.CircleCheck else TablerIcons.Ban, + text = if (isBlocked) "Unblock User" else "Block User", + onClick = { if (isBlocked) onUnblockClick() else onBlockClick() }, + tintColor = PrimaryBlue, + textColor = if (isDarkTheme) Color.White else Color.Black ) Box( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 4.dp) - .height(0.5.dp) - .background(dividerColor) + modifier = + Modifier.fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 4.dp) + .height(0.5.dp) + .background(dividerColor) ) } KebabMenuItem( - icon = TablerIcons.Trash, - text = "Delete Chat", - onClick = onDeleteClick, - tintColor = Color(0xFFFF3B30), - textColor = Color(0xFFFF3B30) + icon = TablerIcons.Trash, + text = "Delete Chat", + onClick = onDeleteClick, + tintColor = Color(0xFFFF3B30), + textColor = Color(0xFFFF3B30) ) } } @Composable private fun KebabMenuItem( - icon: ImageVector, - text: String, - onClick: () -> Unit, - tintColor: Color, - textColor: Color + icon: ImageVector, + text: String, + onClick: () -> Unit, + tintColor: Color, + textColor: Color ) { val interactionSource = remember { MutableInteractionSource() } DropdownMenuItem( - text = { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.fillMaxWidth() - ) { - Icon( - imageVector = icon, - contentDescription = null, - tint = tintColor, - modifier = Modifier.size(24.dp) - ) - Spacer(modifier = Modifier.width(14.dp)) - Text( - text = text, - color = textColor, - fontSize = 16.sp, - fontWeight = FontWeight.Medium - ) - } - }, - onClick = onClick, - modifier = Modifier.fillMaxWidth().padding(horizontal = 4.dp), - interactionSource = interactionSource, - contentPadding = PaddingValues(horizontal = 12.dp, vertical = 12.dp) + text = { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth() + ) { + Icon( + imageVector = icon, + contentDescription = null, + tint = tintColor, + modifier = Modifier.size(24.dp) + ) + Spacer(modifier = Modifier.width(14.dp)) + Text( + text = text, + color = textColor, + fontSize = 16.sp, + fontWeight = FontWeight.Medium + ) + } + }, + onClick = onClick, + modifier = Modifier.fillMaxWidth().padding(horizontal = 4.dp), + interactionSource = interactionSource, + contentPadding = PaddingValues(horizontal = 12.dp, vertical = 12.dp) ) } /** 🖼️ Превью изображения для reply в input bar (как в Telegram) */ @Composable fun ReplyImagePreview( - attachment: MessageAttachment, - modifier: Modifier = Modifier, - senderPublicKey: String = "", - recipientPrivateKey: String = "" + attachment: MessageAttachment, + modifier: Modifier = Modifier, + senderPublicKey: String = "", + recipientPrivateKey: String = "" ) { val context = androidx.compose.ui.platform.LocalContext.current var previewBitmap by remember { mutableStateOf(null) } var fullImageBitmap by remember { mutableStateOf(null) } - + // Сначала загружаем blurhash preview LaunchedEffect(attachment.preview) { if (attachment.preview.isNotEmpty()) { withContext(Dispatchers.IO) { try { // Получаем blurhash из preview (может быть в формате "tag::blurhash") - val blurhash = if (attachment.preview.contains("::")) { - attachment.preview.split("::").lastOrNull() ?: "" - } else { - attachment.preview - } + val blurhash = + if (attachment.preview.contains("::")) { + attachment.preview.split("::").lastOrNull() ?: "" + } else { + attachment.preview + } if (blurhash.isNotEmpty() && !blurhash.startsWith("http")) { previewBitmap = BlurHash.decode(blurhash, 40, 40) } @@ -1041,7 +1111,7 @@ fun ReplyImagePreview( } } } - + // Потом пробуем загрузить полноценную картинку LaunchedEffect(attachment.id) { if (senderPublicKey.isNotEmpty() && recipientPrivateKey.isNotEmpty()) { @@ -1049,43 +1119,56 @@ fun ReplyImagePreview( try { // Пробуем сначала из blob if (attachment.blob.isNotEmpty()) { - val decoded = try { - val cleanBase64 = if (attachment.blob.contains(",")) { - attachment.blob.substringAfter(",") - } else { - attachment.blob - } - val decodedBytes = Base64.decode(cleanBase64, Base64.DEFAULT) - BitmapFactory.decodeByteArray(decodedBytes, 0, decodedBytes.size) - } catch (e: Exception) { - null - } + val decoded = + try { + val cleanBase64 = + if (attachment.blob.contains(",")) { + attachment.blob.substringAfter(",") + } else { + attachment.blob + } + val decodedBytes = Base64.decode(cleanBase64, Base64.DEFAULT) + BitmapFactory.decodeByteArray( + decodedBytes, + 0, + decodedBytes.size + ) + } catch (e: Exception) { + null + } if (decoded != null) { fullImageBitmap = decoded return@withContext } } - + // Если blob нет - загружаем из локального файла - val localBlob = AttachmentFileManager.readAttachment( - context, - attachment.id, - senderPublicKey, - recipientPrivateKey - ) - + val localBlob = + AttachmentFileManager.readAttachment( + context, + attachment.id, + senderPublicKey, + recipientPrivateKey + ) + if (localBlob != null) { - val decoded = try { - val cleanBase64 = if (localBlob.contains(",")) { - localBlob.substringAfter(",") - } else { - localBlob - } - val decodedBytes = Base64.decode(cleanBase64, Base64.DEFAULT) - BitmapFactory.decodeByteArray(decodedBytes, 0, decodedBytes.size) - } catch (e: Exception) { - null - } + val decoded = + try { + val cleanBase64 = + if (localBlob.contains(",")) { + localBlob.substringAfter(",") + } else { + localBlob + } + val decodedBytes = Base64.decode(cleanBase64, Base64.DEFAULT) + BitmapFactory.decodeByteArray( + decodedBytes, + 0, + decodedBytes.size + ) + } catch (e: Exception) { + null + } fullImageBitmap = decoded } } catch (e: Exception) { @@ -1094,89 +1177,89 @@ fun ReplyImagePreview( } } } - + Box( - modifier = modifier - .clip(RoundedCornerShape(4.dp)) - .background(Color.Gray.copy(alpha = 0.3f)) + modifier = + modifier.clip(RoundedCornerShape(4.dp)) + .background(Color.Gray.copy(alpha = 0.3f)) ) { if (fullImageBitmap != null) { // Полноценная картинка если загружена Image( - bitmap = fullImageBitmap!!.asImageBitmap(), - contentDescription = "Photo preview", - modifier = Modifier.fillMaxSize(), - contentScale = ContentScale.Crop + bitmap = fullImageBitmap!!.asImageBitmap(), + contentDescription = "Photo preview", + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Crop ) } else if (previewBitmap != null) { // Blurhash preview если полная не загружена Image( - bitmap = previewBitmap!!.asImageBitmap(), - contentDescription = "Photo preview", - modifier = Modifier.fillMaxSize(), - contentScale = ContentScale.Crop + bitmap = previewBitmap!!.asImageBitmap(), + contentDescription = "Photo preview", + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Crop ) } else { // Placeholder с иконкой только если нет ничего - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center - ) { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { Icon( - TablerIcons.Photo, - contentDescription = null, - tint = Color.White.copy(alpha = 0.7f), - modifier = Modifier.size(20.dp) + TablerIcons.Photo, + contentDescription = null, + tint = Color.White.copy(alpha = 0.7f), + modifier = Modifier.size(20.dp) ) } } } -}/** Profile photo menu for avatar */ +} +/** Profile photo menu for avatar */ @Composable fun ProfilePhotoMenu( - expanded: Boolean, - onDismiss: () -> Unit, - isDarkTheme: Boolean, - onSetPhotoClick: () -> Unit, - onDeletePhotoClick: (() -> Unit)? = null, - hasAvatar: Boolean = false + expanded: Boolean, + onDismiss: () -> Unit, + isDarkTheme: Boolean, + onSetPhotoClick: () -> Unit, + onDeletePhotoClick: (() -> Unit)? = null, + hasAvatar: Boolean = false ) { - val dividerColor = if (isDarkTheme) Color.White.copy(alpha = 0.1f) else Color.Black.copy(alpha = 0.08f) - + val dividerColor = + if (isDarkTheme) Color.White.copy(alpha = 0.1f) else Color.Black.copy(alpha = 0.08f) + DropdownMenu( - expanded = expanded, - onDismissRequest = onDismiss, - modifier = Modifier.width(220.dp), - properties = PopupProperties( - focusable = true, - dismissOnBackPress = true, - dismissOnClickOutside = true - ) + expanded = expanded, + onDismissRequest = onDismiss, + modifier = Modifier.width(220.dp), + properties = + PopupProperties( + focusable = true, + dismissOnBackPress = true, + dismissOnClickOutside = true + ) ) { ProfilePhotoMenuItem( - icon = TablerIcons.CameraPlus, - text = "Set Profile Photo", - onClick = onSetPhotoClick, - tintColor = if (isDarkTheme) Color.White else Color.Black, - textColor = if (isDarkTheme) Color.White else Color.Black + icon = TablerIcons.CameraPlus, + text = "Set Profile Photo", + onClick = onSetPhotoClick, + tintColor = if (isDarkTheme) Color.White else Color.Black, + textColor = if (isDarkTheme) Color.White else Color.Black ) - + // Показываем Delete только если есть аватар if (hasAvatar && onDeletePhotoClick != null) { Box( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 4.dp) - .height(0.5.dp) - .background(dividerColor) + modifier = + Modifier.fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 4.dp) + .height(0.5.dp) + .background(dividerColor) ) - + ProfilePhotoMenuItem( - icon = TablerIcons.Trash, - text = "Delete Photo", - onClick = onDeletePhotoClick, - tintColor = Color(0xFFFF3B30), - textColor = Color(0xFFFF3B30) + icon = TablerIcons.Trash, + text = "Delete Photo", + onClick = onDeletePhotoClick, + tintColor = Color(0xFFFF3B30), + textColor = Color(0xFFFF3B30) ) } } @@ -1185,85 +1268,87 @@ fun ProfilePhotoMenu( /** Other user profile menu with block option */ @Composable fun OtherProfileMenu( - expanded: Boolean, - onDismiss: () -> Unit, - isDarkTheme: Boolean, - isBlocked: Boolean, - onBlockClick: () -> Unit, - onClearChatClick: () -> Unit + expanded: Boolean, + onDismiss: () -> Unit, + isDarkTheme: Boolean, + isBlocked: Boolean, + onBlockClick: () -> Unit, + onClearChatClick: () -> Unit ) { - val dividerColor = if (isDarkTheme) Color.White.copy(alpha = 0.1f) else Color.Black.copy(alpha = 0.08f) - + val dividerColor = + if (isDarkTheme) Color.White.copy(alpha = 0.1f) else Color.Black.copy(alpha = 0.08f) + DropdownMenu( - expanded = expanded, - onDismissRequest = onDismiss, - modifier = Modifier.width(220.dp), - properties = PopupProperties( - focusable = true, - dismissOnBackPress = true, - dismissOnClickOutside = true - ) + expanded = expanded, + onDismissRequest = onDismiss, + modifier = Modifier.width(220.dp), + properties = + PopupProperties( + focusable = true, + dismissOnBackPress = true, + dismissOnClickOutside = true + ) ) { ProfilePhotoMenuItem( - icon = if (isBlocked) TablerIcons.CircleCheck else TablerIcons.Ban, - text = if (isBlocked) "Unblock User" else "Block User", - onClick = onBlockClick, - tintColor = PrimaryBlue, - textColor = if (isDarkTheme) Color.White else Color.Black + icon = if (isBlocked) TablerIcons.CircleCheck else TablerIcons.Ban, + text = if (isBlocked) "Unblock User" else "Block User", + onClick = onBlockClick, + tintColor = PrimaryBlue, + textColor = if (isDarkTheme) Color.White else Color.Black ) - + Box( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 4.dp) - .height(0.5.dp) - .background(dividerColor) + modifier = + Modifier.fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 4.dp) + .height(0.5.dp) + .background(dividerColor) ) - + ProfilePhotoMenuItem( - icon = TablerIcons.Trash, - text = "Clear Chat History", - onClick = onClearChatClick, - tintColor = Color(0xFFFF3B30), - textColor = Color(0xFFFF3B30) + icon = TablerIcons.Trash, + text = "Clear Chat History", + onClick = onClearChatClick, + tintColor = Color(0xFFFF3B30), + textColor = Color(0xFFFF3B30) ) } } @Composable private fun ProfilePhotoMenuItem( - icon: ImageVector, - text: String, - onClick: () -> Unit, - tintColor: Color, - textColor: Color + icon: ImageVector, + text: String, + onClick: () -> Unit, + tintColor: Color, + textColor: Color ) { val interactionSource = remember { MutableInteractionSource() } DropdownMenuItem( - text = { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.fillMaxWidth() - ) { - Icon( - imageVector = icon, - contentDescription = null, - tint = tintColor, - modifier = Modifier.size(24.dp) - ) - Spacer(modifier = Modifier.width(14.dp)) - Text( - text = text, - color = textColor, - fontSize = 16.sp, - fontWeight = FontWeight.Medium - ) - } - }, - onClick = onClick, - modifier = Modifier.fillMaxWidth().padding(horizontal = 4.dp), - interactionSource = interactionSource, - contentPadding = PaddingValues(horizontal = 12.dp, vertical = 12.dp) + text = { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth() + ) { + Icon( + imageVector = icon, + contentDescription = null, + tint = tintColor, + modifier = Modifier.size(24.dp) + ) + Spacer(modifier = Modifier.width(14.dp)) + Text( + text = text, + color = textColor, + fontSize = 16.sp, + fontWeight = FontWeight.Medium + ) + } + }, + onClick = onClick, + modifier = Modifier.fillMaxWidth().padding(horizontal = 4.dp), + interactionSource = interactionSource, + contentPadding = PaddingValues(horizontal = 12.dp, vertical = 12.dp) ) }