From 0d0e1e2c228a34f247958296ed69340926c0a90f Mon Sep 17 00:00:00 2001 From: k1ngsterr1 Date: Sun, 8 Feb 2026 05:47:24 +0500 Subject: [PATCH] Refactor code structure for improved readability and maintainability --- .../messenger/ui/chats/ChatsListScreen.kt | 99 +- .../chats/components/ChatDetailComponents.kt | 2689 +++++++++-------- 2 files changed, 1527 insertions(+), 1261 deletions(-) diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListScreen.kt index df493b0..51dcef5 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListScreen.kt @@ -4,10 +4,8 @@ import android.content.Context import androidx.compose.animation.* import androidx.compose.animation.core.* import androidx.compose.foundation.* -import androidx.compose.foundation.gestures.detectHorizontalDragGestures import androidx.compose.foundation.gestures.awaitEachGesture import androidx.compose.foundation.gestures.awaitFirstDown -import androidx.compose.foundation.gestures.horizontalDrag import androidx.activity.compose.BackHandler import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn @@ -28,6 +26,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clipToBounds import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.pointer.changedToUpIgnoreConsumed import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.positionChange import androidx.compose.ui.input.pointer.util.VelocityTracker @@ -1694,6 +1693,8 @@ fun SwipeableDialogItem( .background(backgroundColor) .pointerInput(Unit) { val velocityTracker = VelocityTracker() + val touchSlop = viewConfiguration.touchSlop + awaitEachGesture { val down = awaitFirstDown(requireUnconsumed = false) @@ -1701,22 +1702,58 @@ fun SwipeableDialogItem( if (isDrawerOpen) return@awaitEachGesture velocityTracker.resetTracking() - var started = false - try { - horizontalDrag(down.id) { change -> - val dragAmount = change.positionChange().x + var totalDragX = 0f + var totalDragY = 0f + var passedSlop = false + var claimed = false - // First movement determines direction - if (!started) { - // Swipe right with actions closed — let drawer handle it - if (dragAmount > 0 && offsetX == 0f) { - return@horizontalDrag + while (true) { + val event = awaitPointerEvent() + val change = event.changes.firstOrNull { it.id == down.id } + ?: break + if (change.changedToUpIgnoreConsumed()) break + + val delta = change.positionChange() + totalDragX += delta.x + totalDragY += delta.y + + if (!passedSlop) { + val dist = kotlin.math.sqrt( + totalDragX * totalDragX + totalDragY * totalDragY + ) + if (dist < touchSlop) continue + + val dominated = kotlin.math.abs(totalDragX) > + kotlin.math.abs(totalDragY) * 2.0f + + when { + // Horizontal left swipe — reveal action buttons + dominated && totalDragX < 0 -> { + passedSlop = true + claimed = true + onSwipeStarted() + change.consume() + } + // Horizontal right swipe with buttons open — close them + dominated && totalDragX > 0 && offsetX != 0f -> { + passedSlop = true + claimed = true + change.consume() + } + // Right swipe with buttons closed — let drawer handle + totalDragX > 0 && offsetX == 0f -> break + // Vertical/diagonal — close buttons if open, let LazyColumn scroll + else -> { + if (offsetX != 0f) { + offsetX = 0f + onSwipeClosed() + } + break } - started = true - onSwipeStarted() } - - val newOffset = offsetX + dragAmount + } else { + // Gesture is ours — update offset + val newOffset = offsetX + delta.x offsetX = newOffset.coerceIn(-swipeWidthPx, 0f) velocityTracker.addPosition( change.uptimeMillis, @@ -1724,20 +1761,30 @@ fun SwipeableDialogItem( ) change.consume() } - } catch (_: Exception) { - offsetX = 0f } - if (started) { + // Snap animation + if (claimed) { val velocity = velocityTracker.calculateVelocity().x - // Telegram-like: fling left (-velocity) OR dragged past 1/3 - val shouldOpen = velocity < -300f || - kotlin.math.abs(offsetX) > swipeWidthPx / 3 - if (shouldOpen) { - offsetX = -swipeWidthPx - } else { - offsetX = 0f - onSwipeClosed() + when { + // Rightward fling — always close + velocity > 150f -> { + offsetX = 0f + onSwipeClosed() + } + // Strong leftward fling — always open + velocity < -300f -> { + offsetX = -swipeWidthPx + } + // Past halfway — stay open + kotlin.math.abs(offsetX) > swipeWidthPx / 2 -> { + offsetX = -swipeWidthPx + } + // Less than halfway — close + else -> { + offsetX = 0f + onSwipeClosed() + } } } } 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 d2d9205..fa0f390 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 @@ -11,10 +11,7 @@ import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.combinedClickable -import androidx.compose.foundation.gestures.awaitEachGesture -import androidx.compose.foundation.gestures.awaitFirstDown import androidx.compose.foundation.gestures.detectHorizontalDragGestures -import kotlin.math.abs import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.CircleShape @@ -30,13 +27,10 @@ import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.input.pointer.pointerInput -import androidx.compose.ui.input.pointer.positionChange import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.Layout -import androidx.compose.ui.layout.Measurable -import androidx.compose.ui.layout.Placeable -import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.IntOffset @@ -56,6 +50,7 @@ import compose.icons.TablerIcons import compose.icons.tablericons.* import java.text.SimpleDateFormat import java.util.* +import kotlin.math.abs import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext @@ -65,10 +60,9 @@ import kotlinx.coroutines.withContext */ /** - * Telegram-style layout для текста сообщения с временем. - * Если текст + время помещаются в одну строку - располагает их рядом. - * Если текст длинный и переносится - время встаёт в правый нижний угол, - * под последней строкой текста (как в Telegram). + * Telegram-style layout для текста сообщения с временем. Если текст + время помещаются в одну + * строку - располагает их рядом. Если текст длинный и переносится - время встаёт в правый нижний + * угол, под последней строкой текста (как в Telegram). * * @param textContent Composable с текстом сообщения * @param timeContent Composable с временем и статусом @@ -83,79 +77,84 @@ fun TelegramStyleMessageContent( spacing: Int = 10, fillWidth: Boolean = false // 🔥 Если true - время всегда справа на всю ширину контейнера ) { - val density = LocalDensity.current - val spacingPx = with(density) { spacing.dp.roundToPx() } - val newLineHeightPx = with(density) { 14.dp.roundToPx() } + val density = LocalDensity.current + val spacingPx = with(density) { spacing.dp.roundToPx() } + val newLineHeightPx = with(density) { 14.dp.roundToPx() } - Layout( - content = { - textContent() - timeContent() - }, - modifier = modifier - ) { measurables, constraints -> - require(measurables.size == 2) { "TelegramStyleMessageContent requires exactly 2 children" } - - val textMeasurable = measurables[0] - val timeMeasurable = measurables[1] - - // Измеряем время с минимальными constraints - val timePlaceable = timeMeasurable.measure(constraints.copy(minWidth = 0, minHeight = 0)) - - // Измеряем текст с полной доступной шириной - val textConstraints = constraints.copy(minWidth = 0, minHeight = 0) - val textPlaceable = textMeasurable.measure(textConstraints) - - // Проверяем, помещается ли текст + время в одну строку - val textWidth = textPlaceable.width - val timeWidth = timePlaceable.width - val totalSingleLineWidth = textWidth + spacingPx + timeWidth - - // Если текст занимает всю ширину (с учётом constraints), значит он переносится - val textWraps = textWidth >= constraints.maxWidth - timeWidth - spacingPx - - // Определяем layout - val (width, height, timeX, timeY) = - if (fillWidth) { - // 🔥 Для caption - занимаем всю доступную ширину - val w = constraints.maxWidth - - // 🔥 ИСПРАВЛЕНИЕ: Проверяем помещается ли текст + время в одну строку - val availableForTime = w - textWidth - spacingPx - if (availableForTime >= timeWidth) { - // Текст и время помещаются - время справа на одной линии - val h = maxOf(textPlaceable.height, timePlaceable.height) - val tX = w - timeWidth // Время справа - val tY = h - timePlaceable.height // Время внизу строки (выровнено по baseline) - LayoutResult(w, h, tX, tY) - } else { - // Текст длинный - время на новой строке справа внизу - val h = textPlaceable.height + newLineHeightPx - val tX = w - timeWidth // Время справа - val tY = h - timePlaceable.height // Время внизу - LayoutResult(w, h, tX, tY) - } - } else if (!textWraps && totalSingleLineWidth <= constraints.maxWidth) { - // Текст и время на одной строке - val w = totalSingleLineWidth - val h = maxOf(textPlaceable.height, timePlaceable.height) - val tX = textWidth + spacingPx - val tY = h - timePlaceable.height // Время внизу строки - LayoutResult(w, h, tX, tY) - } else { - // Текст переносится - время справа внизу на новой строке - val w = maxOf(textWidth, timeWidth) - val h = textPlaceable.height + newLineHeightPx - val tX = w - timeWidth // Время справа - val tY = h - timePlaceable.height // Время внизу - LayoutResult(w, h, tX, tY) + Layout( + content = { + textContent() + timeContent() + }, + modifier = modifier + ) { measurables, constraints -> + require(measurables.size == 2) { + "TelegramStyleMessageContent requires exactly 2 children" } - layout(width, height) { - textPlaceable.placeRelative(0, 0) - timePlaceable.placeRelative(timeX, timeY) + val textMeasurable = measurables[0] + val timeMeasurable = measurables[1] + + // Измеряем время с минимальными constraints + val timePlaceable = + timeMeasurable.measure(constraints.copy(minWidth = 0, minHeight = 0)) + + // Измеряем текст с полной доступной шириной + val textConstraints = constraints.copy(minWidth = 0, minHeight = 0) + val textPlaceable = textMeasurable.measure(textConstraints) + + // Проверяем, помещается ли текст + время в одну строку + val textWidth = textPlaceable.width + val timeWidth = timePlaceable.width + val totalSingleLineWidth = textWidth + spacingPx + timeWidth + + // Если текст занимает всю ширину (с учётом constraints), значит он переносится + val textWraps = textWidth >= constraints.maxWidth - timeWidth - spacingPx + + // Определяем layout + val (width, height, timeX, timeY) = + if (fillWidth) { + // 🔥 Для caption - занимаем всю доступную ширину + val w = constraints.maxWidth + + // 🔥 ИСПРАВЛЕНИЕ: Проверяем помещается ли текст + время в одну + // строку + val availableForTime = w - textWidth - spacingPx + if (availableForTime >= timeWidth) { + // Текст и время помещаются - время справа на одной линии + val h = maxOf(textPlaceable.height, timePlaceable.height) + val tX = w - timeWidth // Время справа + val tY = h - timePlaceable.height // Время внизу строки + // (выровнено по baseline) + LayoutResult(w, h, tX, tY) + } else { + // Текст длинный - время на новой строке справа внизу + val h = textPlaceable.height + newLineHeightPx + val tX = w - timeWidth // Время справа + val tY = h - timePlaceable.height // Время внизу + LayoutResult(w, h, tX, tY) + } + } else if (!textWraps && totalSingleLineWidth <= constraints.maxWidth) { + // Текст и время на одной строке + val w = totalSingleLineWidth + val h = maxOf(textPlaceable.height, timePlaceable.height) + val tX = textWidth + spacingPx + val tY = h - timePlaceable.height // Время внизу строки + LayoutResult(w, h, tX, tY) + } else { + // Текст переносится - время справа внизу на новой строке + val w = maxOf(textWidth, timeWidth) + val h = textPlaceable.height + newLineHeightPx + val tX = w - timeWidth // Время справа + val tY = h - timePlaceable.height // Время внизу + LayoutResult(w, h, tX, tY) + } + + layout(width, height) { + textPlaceable.placeRelative(0, 0) + timePlaceable.placeRelative(timeX, timeY) + } } - } } private data class LayoutResult(val width: Int, val height: Int, val timeX: Int, val timeY: Int) @@ -163,76 +162,76 @@ private data class LayoutResult(val width: Int, val height: Int, val timeX: Int, /** 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" - ) + var isVisible by remember { mutableStateOf(false) } + val alpha by + animateFloatAsState( + targetValue = if (isVisible) 1f else 0f, + animationSpec = tween(durationMillis = 250, easing = TelegramEasing), + label = "dateAlpha" + ) - LaunchedEffect(dateText) { isVisible = true } + LaunchedEffect(dateText) { isVisible = true } - Row( - 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, + Row( modifier = - Modifier.background( - color = secondaryTextColor.copy(alpha = 0.1f), - shape = RoundedCornerShape(12.dp) - ) - .padding(horizontal = 12.dp, vertical = 4.dp) - ) - } + 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) + ) + } } /** Typing indicator with animated dots (Telegram style) */ @Composable fun TypingIndicator(isDarkTheme: Boolean) { - val infiniteTransition = rememberInfiniteTransition(label = "typing") - val typingColor = Color(0xFF54A9EB) + val infiniteTransition = rememberInfiniteTransition(label = "typing") + val typingColor = Color(0xFF54A9EB) - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(2.dp) - ) { - Text(text = "typing", fontSize = 13.sp, color = typingColor) + Row( + 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" - ) + 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" + ) - Text( - text = ".", - fontSize = 13.sp, - color = typingColor, - modifier = Modifier.offset(y = offsetY.dp) - ) + Text( + text = ".", + fontSize = 13.sp, + color = typingColor, + modifier = Modifier.offset(y = offsetY.dp) + ) + } } - } } /** Message bubble with Telegram-style design and animations */ @@ -258,498 +257,660 @@ fun MessageBubble( onDelete: () -> Unit = {}, onImageClick: (attachmentId: String, bounds: ImageSourceBounds?) -> Unit = { _, _ -> } ) { - // Swipe-to-reply state - var swipeOffset by remember { mutableStateOf(0f) } - val swipeThreshold = 80f - val maxSwipe = 120f + // 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 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" - ) - - // 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 linkColor = - remember(message.isOutgoing, isDarkTheme) { - if (message.isOutgoing) Color(0xFFB3E5FC) // Светло-голубой на синем фоне - else Color(0xFF2196F3) // Стандартный Material Blue для входящих - } - - val timeColor = - remember(message.isOutgoing, isDarkTheme) { - if (message.isOutgoing) Color.White.copy(alpha = 0.7f) - else if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666) - } - - // Telegram: bubbleRadius = 17dp, smallRad (хвостик) = 6dp - val bubbleShape = - remember(message.isOutgoing, showTail) { - RoundedCornerShape( - topStart = TelegramBubbleSpec.bubbleRadius, - topEnd = TelegramBubbleSpec.bubbleRadius, - bottomStart = - if (message.isOutgoing) TelegramBubbleSpec.bubbleRadius - else (if (showTail) TelegramBubbleSpec.nearRadius else TelegramBubbleSpec.bubbleRadius), - bottomEnd = - if (message.isOutgoing) (if (showTail) TelegramBubbleSpec.nearRadius else TelegramBubbleSpec.bubbleRadius) - else TelegramBubbleSpec.bubbleRadius + val animatedOffset by + animateFloatAsState( + targetValue = swipeOffset, + animationSpec = spring(dampingRatio = 0.6f, stiffness = 400f), + label = "swipeOffset" ) - } - val timeFormat = remember { SimpleDateFormat("HH:mm", Locale.getDefault()) } + val swipeProgress = (kotlin.math.abs(animatedOffset) / swipeThreshold).coerceIn(0f, 1f) - Box( - modifier = - Modifier.fillMaxWidth().pointerInput(Unit) { - // 🔥 Простой горизонтальный свайп для reply - // Используем detectHorizontalDragGestures который лучше работает со скроллом - detectHorizontalDragGestures( - onDragStart = { }, - onDragEnd = { - // Если свайпнули достаточно влево - reply - if (swipeOffset <= -swipeThreshold) { - onSwipeToReply() - } - swipeOffset = 0f - }, - onDragCancel = { - swipeOffset = 0f - }, - onHorizontalDrag = { change, dragAmount -> - // Только свайп влево (отрицательное значение) - if (dragAmount < 0 || swipeOffset < 0) { - change.consume() - val newOffset = swipeOffset + dragAmount - swipeOffset = newOffset.coerceIn(-maxSwipe, 0f) - } - } + // 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" + ) + + // 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 linkColor = + remember(message.isOutgoing, isDarkTheme) { + if (message.isOutgoing) Color(0xFFB3E5FC) // Светло-голубой на синем фоне + else Color(0xFF2196F3) // Стандартный Material Blue для входящих + } + + val timeColor = + remember(message.isOutgoing, isDarkTheme) { + if (message.isOutgoing) Color.White.copy(alpha = 0.7f) + else if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666) + } + + // Telegram: bubbleRadius = 17dp, smallRad (хвостик) = 6dp + val bubbleShape = + remember(message.isOutgoing, showTail) { + RoundedCornerShape( + topStart = TelegramBubbleSpec.bubbleRadius, + topEnd = TelegramBubbleSpec.bubbleRadius, + bottomStart = + if (message.isOutgoing) TelegramBubbleSpec.bubbleRadius + else + (if (showTail) TelegramBubbleSpec.nearRadius + else TelegramBubbleSpec.bubbleRadius), + bottomEnd = + if (message.isOutgoing) + (if (showTail) TelegramBubbleSpec.nearRadius + else TelegramBubbleSpec.bubbleRadius) + else TelegramBubbleSpec.bubbleRadius ) - } - ) { - // 🔥 Reply icon - справа, появляется при свайпе влево + } + + val timeFormat = remember { SimpleDateFormat("HH:mm", Locale.getDefault()) } + Box( modifier = - Modifier.align(Alignment.CenterEnd).padding(end = 16.dp).graphicsLayer { - alpha = swipeProgress - scaleX = swipeProgress - scaleY = swipeProgress + Modifier.fillMaxWidth().pointerInput(Unit) { + // 🔥 Простой горизонтальный свайп для reply + // Используем detectHorizontalDragGestures который лучше работает со + // скроллом + detectHorizontalDragGestures( + onDragStart = {}, + onDragEnd = { + // Если свайпнули достаточно влево - reply + if (swipeOffset <= -swipeThreshold) { + onSwipeToReply() + } + swipeOffset = 0f + }, + onDragCancel = { swipeOffset = 0f }, + onHorizontalDrag = { change, dragAmount -> + // Только свайп влево (отрицательное значение) + if (dragAmount < 0 || swipeOffset < 0) { + change.consume() + val newOffset = swipeOffset + dragAmount + swipeOffset = + newOffset.coerceIn(-maxSwipe, 0f) + } + } + ) } ) { - Box( - 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) - ) - } - } - - 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 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 - ) { - // 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) - ) { + // 🔥 Reply icon - справа, появляется при свайпе влево Box( modifier = - Modifier.padding(start = 12.dp, end = 4.dp) - .size(24.dp) - .clip(CircleShape) - .background(Color(0xFF4CD964)), - contentAlignment = Alignment.Center + Modifier.align(Alignment.CenterEnd) + .padding(end = 16.dp) + .graphicsLayer { + alpha = swipeProgress + scaleX = swipeProgress + scaleY = swipeProgress + } ) { - Icon( - 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)) } - - 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 - } - - // Фото + caption (как в Telegram) - 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 bubbleBorderWidth = if (hasOnlyMedia) 1.dp else 0.dp - - // Telegram-style: ширина пузырька = ширина фото - // Caption переносится на новые строки, не расширяя пузырёк - val configuration = LocalConfiguration.current - val screenWidthDp = configuration.screenWidthDp - val maxPhotoWidth = TelegramBubbleSpec.maxPhotoWidth(screenWidthDp).dp - - // Вычисляем ширину фото для ограничения пузырька - val photoWidth = if (hasImageWithCaption || hasOnlyMedia) { - val firstImage = message.attachments.firstOrNull { - it.type == com.rosetta.messenger.network.AttachmentType.IMAGE - } - if (firstImage != null && firstImage.width > 0 && firstImage.height > 0) { - val ar = firstImage.width.toFloat() / firstImage.height.toFloat() - val maxW = TelegramBubbleSpec.maxPhotoWidth(screenWidthDp).toFloat() - var w = if (ar >= 1f) maxW else maxW * 0.75f - var h = w / ar - - if (h > TelegramBubbleSpec.maxPhotoHeight) { - h = TelegramBubbleSpec.maxPhotoHeight.toFloat() - w = h * ar - } - if (h < TelegramBubbleSpec.minPhotoHeight) { - h = TelegramBubbleSpec.minPhotoHeight.toFloat() - w = h * ar - } - w.coerceIn(TelegramBubbleSpec.minPhotoWidth.toFloat(), maxW).dp - } else { - maxPhotoWidth - } - } else { - 280.dp - } - - val bubbleWidthModifier = if (hasImageWithCaption || hasOnlyMedia) { - Modifier.width(photoWidth) // 🔥 Фиксированная ширина = размер фото (убирает лишний отступ) - } else { - Modifier.widthIn(min = 60.dp, max = 280.dp) - } - - Box( - modifier = - Modifier.padding(end = 12.dp) - .then(bubbleWidthModifier) - .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) } - ) - 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 = attachmentDisplayStatus, - avatarRepository = avatarRepository, - currentUserPublicKey = currentUserPublicKey, - hasCaption = hasImageWithCaption, - showTail = showTail, // Передаём для формы пузырька - onImageClick = onImageClick - ) - } - - // 🖼️ Caption под фото (Telegram-style) - // Отступ от фото = 9dp, слева = 11dp (исходящие) / 13dp (входящие) - if (hasImageWithCaption) { - val captionPaddingStart = if (message.isOutgoing) - TelegramBubbleSpec.captionPaddingStartOutgoing - else - TelegramBubbleSpec.captionPaddingStartIncoming Box( modifier = - Modifier - .fillMaxWidth() // 🔥 Растягиваем на ширину фото для правильного позиционирования времени - .background(bubbleColor) - .padding( - start = captionPaddingStart, - end = TelegramBubbleSpec.captionPaddingEnd, - top = TelegramBubbleSpec.captionPaddingTop, - bottom = TelegramBubbleSpec.captionPaddingBottom - ) + Modifier.size(36.dp) + .clip(CircleShape) + .background( + if (swipeProgress >= 1f) PrimaryBlue + else if (isDarkTheme) Color(0xFF3A3A3A) + else Color(0xFFE0E0E0) + ), + contentAlignment = Alignment.Center ) { - TelegramStyleMessageContent( - textContent = { - AppleEmojiText( - text = message.text, - color = textColor, - fontSize = 16.sp, - linkColor = linkColor, - onLongClick = onLongClick // 🔥 Long press для selection - ) - }, - timeContent = { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(2.dp) - ) { - Text( - 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 - AnimatedMessageStatus( - status = displayStatus, - timeColor = timeColor, - timestamp = message.timestamp.time, - onRetry = onRetry, - onDelete = onDelete - ) - } - } - }, - spacing = 8, - fillWidth = true // 🔥 Время справа на всю ширину фото - ) + 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) + ) } - } else if (message.attachments.isNotEmpty() && message.text.isNotEmpty()) { - // Обычное фото + текст (не только изображения) - Spacer(modifier = Modifier.height(8.dp)) - } + } - // Если есть reply - Telegram-style layout - if (message.replyData != null && message.text.isNotEmpty()) { - TelegramStyleMessageContent( - textContent = { - AppleEmojiText( - text = message.text, - color = textColor, - fontSize = 17.sp, - linkColor = linkColor, - onLongClick = onLongClick // 🔥 Long press для selection - ) - }, - timeContent = { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(2.dp) - ) { - Text( - 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 - AnimatedMessageStatus( - status = displayStatus, - timeColor = timeColor, - timestamp = message.timestamp.time, - onRetry = onRetry, - onDelete = onDelete - ) - } - } - }, - fillWidth = true // 🔥 Время справа с учётом ширины ReplyBubble + val selectionBackgroundColor by + animateColorAsState( + targetValue = + if (isSelected) PrimaryBlue.copy(alpha = 0.15f) + else Color.Transparent, + animationSpec = tween(200), + label = "selectionBg" ) - } else if (!hasOnlyMedia && !hasImageWithCaption && message.text.isNotEmpty()) { - // Telegram-style: текст + время с автоматическим переносом - TelegramStyleMessageContent( - textContent = { - AppleEmojiText( - text = message.text, - color = textColor, - fontSize = 17.sp, - linkColor = linkColor, - onLongClick = onLongClick // 🔥 Long press для selection + + 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 + + Row( + 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) + ) { + Box( + 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) ) - }, - timeContent = { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(2.dp) + } + } + + AnimatedVisibility( + 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 + } + + // Фото + caption (как в Telegram) + 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 bubbleBorderWidth = if (hasOnlyMedia) 1.dp else 0.dp + + // Telegram-style: ширина пузырька = ширина фото + // Caption переносится на новые строки, не расширяя пузырёк + val configuration = LocalConfiguration.current + val screenWidthDp = configuration.screenWidthDp + val maxPhotoWidth = TelegramBubbleSpec.maxPhotoWidth(screenWidthDp).dp + + // Вычисляем ширину фото для ограничения пузырька + val photoWidth = + if (hasImageWithCaption || hasOnlyMedia) { + val firstImage = + message.attachments.firstOrNull { + it.type == + com.rosetta.messenger.network + .AttachmentType.IMAGE + } + if (firstImage != null && + firstImage.width > 0 && + firstImage.height > 0 ) { - Text( - text = timeFormat.format(message.timestamp), - color = timeColor, - fontSize = 11.sp, - fontStyle = - androidx.compose.ui.text.font.FontStyle.Italic - ) - if (message.isOutgoing) { - val displayStatus = + val ar = + firstImage.width.toFloat() / + firstImage.height.toFloat() + val maxW = + TelegramBubbleSpec.maxPhotoWidth( + screenWidthDp + ) + .toFloat() + var w = if (ar >= 1f) maxW else maxW * 0.75f + var h = w / ar + + if (h > TelegramBubbleSpec.maxPhotoHeight) { + h = + TelegramBubbleSpec.maxPhotoHeight + .toFloat() + w = h * ar + } + if (h < TelegramBubbleSpec.minPhotoHeight) { + h = + TelegramBubbleSpec.minPhotoHeight + .toFloat() + w = h * ar + } + w.coerceIn( + TelegramBubbleSpec.minPhotoWidth + .toFloat(), + maxW + ) + .dp + } else { + maxPhotoWidth + } + } else { + 280.dp + } + + val bubbleWidthModifier = + if (hasImageWithCaption || hasOnlyMedia) { + Modifier.width( + photoWidth + ) // 🔥 Фиксированная ширина = размер фото (убирает лишний + // отступ) + } else { + Modifier.widthIn(min = 60.dp, max = 280.dp) + } + + Box( + modifier = + Modifier.padding(end = 12.dp) + .then(bubbleWidthModifier) + .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) } + ) + Spacer(modifier = Modifier.height(4.dp)) + } + + // 📎 Attachments (IMAGE, FILE, AVATAR) + if (message.attachments.isNotEmpty()) { + val attachmentDisplayStatus = if (isSavedMessages) MessageStatus.READ else message.status - AnimatedMessageStatus( - status = displayStatus, - timeColor = timeColor, - timestamp = message.timestamp.time, - onRetry = onRetry, - onDelete = onDelete + MessageAttachments( + 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, + showTail = showTail, // Передаём для формы + // пузырька + onImageClick = onImageClick ) - } } - } - ) - } + + // 🖼️ Caption под фото (Telegram-style) + // Отступ от фото = 9dp, слева = 11dp (исходящие) / 13dp + // (входящие) + if (hasImageWithCaption) { + val captionPaddingStart = + if (message.isOutgoing) + TelegramBubbleSpec + .captionPaddingStartOutgoing + else + TelegramBubbleSpec + .captionPaddingStartIncoming + Box( + modifier = + Modifier.fillMaxWidth() // 🔥 + // Растягиваем на ширину + // фото для правильного + // позиционирования времени + .background(bubbleColor) + .padding( + start = + captionPaddingStart, + end = + TelegramBubbleSpec + .captionPaddingEnd, + top = + TelegramBubbleSpec + .captionPaddingTop, + bottom = + TelegramBubbleSpec + .captionPaddingBottom + ) + ) { + TelegramStyleMessageContent( + textContent = { + AppleEmojiText( + text = message.text, + color = textColor, + fontSize = 16.sp, + linkColor = + linkColor, + onLongClick = + onLongClick // 🔥 Long press для selection + ) + }, + timeContent = { + Row( + verticalAlignment = + Alignment + .CenterVertically, + horizontalArrangement = + Arrangement + .spacedBy( + 2.dp + ) + ) { + Text( + 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 + AnimatedMessageStatus( + status = + displayStatus, + timeColor = + timeColor, + timestamp = + message.timestamp + .time, + onRetry = + onRetry, + onDelete = + onDelete + ) + } + } + }, + spacing = 8, + fillWidth = + true // 🔥 Время справа на + // всю ширину фото + ) + } + } else if (message.attachments.isNotEmpty() && + message.text.isNotEmpty() + ) { + // Обычное фото + текст (не только изображения) + Spacer(modifier = Modifier.height(8.dp)) + } + + // Если есть reply - Telegram-style layout + if (message.replyData != null && message.text.isNotEmpty() + ) { + TelegramStyleMessageContent( + textContent = { + AppleEmojiText( + text = message.text, + color = textColor, + fontSize = 17.sp, + linkColor = linkColor, + onLongClick = + onLongClick // 🔥 + // Long + // press + // для + // selection + ) + }, + timeContent = { + Row( + verticalAlignment = + Alignment + .CenterVertically, + horizontalArrangement = + Arrangement + .spacedBy( + 2.dp + ) + ) { + Text( + 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 + AnimatedMessageStatus( + status = + displayStatus, + timeColor = + timeColor, + timestamp = + message.timestamp + .time, + onRetry = + onRetry, + onDelete = + onDelete + ) + } + } + }, + fillWidth = true // 🔥 Время справа с учётом + // ширины ReplyBubble + ) + } else if (!hasOnlyMedia && + !hasImageWithCaption && + message.text.isNotEmpty() + ) { + // Telegram-style: текст + время с автоматическим + // переносом + TelegramStyleMessageContent( + textContent = { + AppleEmojiText( + text = message.text, + color = textColor, + fontSize = 17.sp, + linkColor = linkColor, + onLongClick = + onLongClick // 🔥 + // Long + // press + // для + // selection + ) + }, + timeContent = { + Row( + verticalAlignment = + Alignment + .CenterVertically, + horizontalArrangement = + Arrangement + .spacedBy( + 2.dp + ) + ) { + Text( + 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 + AnimatedMessageStatus( + status = + displayStatus, + timeColor = + timeColor, + timestamp = + message.timestamp + .time, + onRetry = + onRetry, + onDelete = + onDelete + ) + } + } + } + ) + } + } + } } - } } - } } /** Animated message status indicator */ @@ -761,110 +922,117 @@ fun AnimatedMessageStatus( onRetry: () -> Unit = {}, onDelete: () -> Unit = {} ) { - val isTimedOut = - status == MessageStatus.SENDING && timestamp > 0 && !isMessageDeliveredByTime(timestamp) - val effectiveStatus = if (isTimedOut) MessageStatus.ERROR else status + 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 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 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) } + var previousStatus by remember { mutableStateOf(effectiveStatus) } + var shouldAnimate by remember { mutableStateOf(false) } - LaunchedEffect(effectiveStatus) { - if (previousStatus != effectiveStatus) { - shouldAnimate = true - previousStatus = effectiveStatus - } - } - - 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" - ) { 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 - ) - ) + LaunchedEffect(effectiveStatus) { + if (previousStatus != effectiveStatus) { + shouldAnimate = true + previousStatus = effectiveStatus + } } - DropdownMenu(expanded = showErrorMenu, onDismissRequest = { showErrorMenu = false }) { - DropdownMenuItem( - text = { Text("Retry") }, - onClick = { - showErrorMenu = false - onRetry() - }, - leadingIcon = { + 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" + ) { currentStatus -> + val iconSize = with(LocalDensity.current) { 14.sp.toDp() } + Icon( - TablerIcons.Refresh, + 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, - modifier = Modifier.size(18.dp) + tint = animatedColor, + modifier = + Modifier.size(iconSize) + .scale(scale) + .then( + if (currentStatus == MessageStatus.ERROR) { + Modifier.clickable { + showErrorMenu = true + } + } else Modifier + ) ) - } - ) - 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) + } + + 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) + ) + } ) - } - ) + 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) + ) + } + ) + } } - } } /** Reply bubble inside message - Telegram style with image preview */ @@ -875,257 +1043,284 @@ fun ReplyBubble( 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 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 - } - - // 🖼️ Проверяем есть ли изображение в 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 { - "" - } - if (blurhash.isNotEmpty()) { - blurPreviewBitmap = BlurHash.decode(blurhash, 36, 36) - } - } catch (e: Exception) { - // Ignore blurhash decode errors + 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) } - } - } - } - // Потом пробуем загрузить полноценную картинку - LaunchedEffect(imageAttachment?.id) { - if (imageAttachment != null) { - withContext(Dispatchers.IO) { - try { - // Пробуем сначала из blob - if (imageAttachment.blob.isNotEmpty()) { - val decoded = + 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 + } + + // 🖼️ Проверяем есть ли изображение в 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 { - 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 - ) + // Получаем 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 { + "" + } + if (blurhash.isNotEmpty()) { + blurPreviewBitmap = + BlurHash.decode(blurhash, 36, 36) + } } catch (e: Exception) { - null + // Ignore blurhash decode errors } - if (decoded != null) { - imageBitmap = decoded - return@withContext } - } - - // Если blob нет - загружаем из локального файла - 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 - } - imageBitmap = decoded - } - } catch (e: Exception) { } - } } - } - Row( - modifier = - Modifier.fillMaxWidth() - .height(IntrinsicSize.Min) - .clip(RoundedCornerShape(4.dp)) - .clickable(onClick = onClick) - .background(backgroundColor) - ) { - // Вертикальная полоска слева - Box(modifier = Modifier.width(3.dp).fillMaxHeight().background(borderColor)) + // Потом пробуем загрузить полноценную картинку + LaunchedEffect(imageAttachment?.id) { + if (imageAttachment != null) { + withContext(Dispatchers.IO) { + 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 + } + if (decoded != null) { + imageBitmap = decoded + return@withContext + } + } + + // Если blob нет - загружаем из локального файла + 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 + } + imageBitmap = decoded + } + } catch (e: Exception) {} + } + } + } - // Контент 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.fillMaxWidth() + .height(IntrinsicSize.Min) + .clip(RoundedCornerShape(4.dp)) + .clickable(onClick = onClick) + .background(backgroundColor) ) { - // Текстовая часть - 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 - ) + // Вертикальная полоска слева + Box(modifier = Modifier.width(3.dp).fillMaxHeight().background(borderColor)) - // Текст или "Photo" - val displayText = - when { - replyData.text.isNotEmpty() -> replyData.text - hasImage -> "Photo" - replyData.attachments.any { it.type == AttachmentType.FILE } -> "File" - else -> "..." - } - - AppleEmojiText( - text = displayText, - color = replyTextColor, - fontSize = 14.sp, - maxLines = 1, - overflow = android.text.TextUtils.TruncateAt.END, - enableLinks = false - ) - } - - // 🖼️ Превью изображения справа (как в Telegram) - if (hasImage) { - Spacer(modifier = Modifier.width(8.dp)) - Box( + // Контент reply + Row( modifier = - Modifier.size(36.dp) - .clip(RoundedCornerShape(4.dp)) - .background(Color.Gray.copy(alpha = 0.3f)) + Modifier.weight(1f) + .padding( + start = 8.dp, + end = if (hasImage) 4.dp else 10.dp, + top = 4.dp, + bottom = 4.dp + ), + verticalAlignment = Alignment.CenterVertically ) { - if (imageBitmap != null) { - Image( - 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 - ) - } else { - // Placeholder с иконкой только если нет blurhash - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center - ) { - Icon( - TablerIcons.Photo, - contentDescription = null, - tint = Color.White.copy(alpha = 0.7f), - modifier = Modifier.size(20.dp) - ) + // Текстовая часть + 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 + ) + + // Текст или "Photo" + val displayText = + when { + replyData.text.isNotEmpty() -> replyData.text + hasImage -> "Photo" + replyData.attachments.any { + it.type == AttachmentType.FILE + } -> "File" + else -> "..." + } + + AppleEmojiText( + text = displayText, + color = replyTextColor, + fontSize = 14.sp, + maxLines = 1, + overflow = android.text.TextUtils.TruncateAt.END, + enableLinks = false + ) + } + + // 🖼️ Превью изображения справа (как в 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)) + ) { + if (imageBitmap != null) { + Image( + 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 + ) + } else { + // Placeholder с иконкой только если нет blurhash + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Icon( + TablerIcons.Photo, + contentDescription = null, + tint = + Color.White.copy( + alpha = 0.7f + ), + modifier = Modifier.size(20.dp) + ) + } + } + } } - } } - } } - } } /** Message skeleton loader with shimmer animation */ @Composable fun MessageSkeletonList(isDarkTheme: Boolean, modifier: Modifier = Modifier) { - val skeletonColor = if (isDarkTheme) Color(0xFF3A3A3C) else Color(0xFFE0E0E0) + 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 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" + ) - Box(modifier = modifier) { - Column( - 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) - SkeletonBubble(true, 0.35f, skeletonColor, shimmerAlpha) - SkeletonBubble(false, 0.50f, skeletonColor, shimmerAlpha) - SkeletonBubble(true, 0.60f, skeletonColor, shimmerAlpha) - SkeletonBubble(false, 0.40f, skeletonColor, shimmerAlpha) + Box(modifier = modifier) { + Column( + 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) + SkeletonBubble(true, 0.35f, skeletonColor, shimmerAlpha) + SkeletonBubble(false, 0.50f, skeletonColor, shimmerAlpha) + SkeletonBubble(true, 0.60f, skeletonColor, shimmerAlpha) + SkeletonBubble(false, 0.40f, skeletonColor, shimmerAlpha) + } } - } } @Composable @@ -1135,26 +1330,27 @@ private fun SkeletonBubble( bubbleColor: Color, alpha: Float ) { - Row( - 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 + Row( + 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) - ) - } + .background(bubbleColor.copy(alpha = alpha)) + .padding(horizontal = 14.dp, vertical = 10.dp) + ) + } } /** Telegram-style kebab menu */ @@ -1170,63 +1366,63 @@ fun KebabMenu( onDeleteClick: () -> Unit, onLogsClick: () -> 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 - ) - ) { - 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 - ) + DropdownMenu( + 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 + ) - Box( - modifier = - Modifier.fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 4.dp) - .height(0.5.dp) - .background(dividerColor) - ) + Box( + modifier = + Modifier.fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 4.dp) + .height(0.5.dp) + .background(dividerColor) + ) + } + + // Debug Logs + KebabMenuItem( + icon = TablerIcons.Bug, + text = "Debug Logs", + onClick = onLogsClick, + 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) + ) + + KebabMenuItem( + icon = TablerIcons.Trash, + text = "Delete Chat", + onClick = onDeleteClick, + tintColor = Color(0xFFFF3B30), + textColor = Color(0xFFFF3B30) + ) } - - // Debug Logs - KebabMenuItem( - icon = TablerIcons.Bug, - text = "Debug Logs", - onClick = onLogsClick, - 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) - ) - - KebabMenuItem( - icon = TablerIcons.Trash, - text = "Delete Chat", - onClick = onDeleteClick, - tintColor = Color(0xFFFF3B30), - textColor = Color(0xFFFF3B30) - ) - } } @Composable @@ -1237,34 +1433,34 @@ private fun KebabMenuItem( tintColor: Color, textColor: Color ) { - val interactionSource = remember { MutableInteractionSource() } + 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) - ) + 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) + ) } /** 🖼️ Превью изображения для reply в input bar (как в Telegram) */ @@ -1275,130 +1471,153 @@ fun ReplyImagePreview( senderPublicKey: String = "", recipientPrivateKey: String = "" ) { - val context = androidx.compose.ui.platform.LocalContext.current - var previewBitmap by remember { mutableStateOf(null) } - var fullImageBitmap by remember { mutableStateOf(null) } + 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 - } - if (blurhash.isNotEmpty() && !blurhash.startsWith("http")) { - previewBitmap = BlurHash.decode(blurhash, 40, 40) - } - } catch (e: Exception) { - // Ignore blurhash decode errors - } - } - } - } - - // Потом пробуем загрузить полноценную картинку - LaunchedEffect(attachment.id) { - if (senderPublicKey.isNotEmpty() && recipientPrivateKey.isNotEmpty()) { - withContext(Dispatchers.IO) { - try { - // Пробуем сначала из blob - if (attachment.blob.isNotEmpty()) { - val decoded = + // Сначала загружаем blurhash preview + LaunchedEffect(attachment.preview) { + if (attachment.preview.isNotEmpty()) { + withContext(Dispatchers.IO) { 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 - ) + // Получаем blurhash из preview (может быть в формате + // "tag::blurhash") + 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) + } } catch (e: Exception) { - null + // Ignore blurhash decode errors } - if (decoded != null) { - fullImageBitmap = decoded - return@withContext } - } - - // Если blob нет - загружаем из локального файла - 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 - } - fullImageBitmap = decoded - } - } catch (e: Exception) { } - } } - } - Box( - 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 - ) - } else if (previewBitmap != null) { - // Blurhash preview если полная не загружена - Image( - bitmap = previewBitmap!!.asImageBitmap(), - contentDescription = "Photo preview", - modifier = Modifier.fillMaxSize(), - contentScale = ContentScale.Crop - ) - } else { - // Placeholder с иконкой только если нет ничего - Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - Icon( - TablerIcons.Photo, - contentDescription = null, - tint = Color.White.copy(alpha = 0.7f), - modifier = Modifier.size(20.dp) - ) - } + // Потом пробуем загрузить полноценную картинку + LaunchedEffect(attachment.id) { + if (senderPublicKey.isNotEmpty() && recipientPrivateKey.isNotEmpty()) { + withContext(Dispatchers.IO) { + 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 + } + if (decoded != null) { + fullImageBitmap = decoded + return@withContext + } + } + + // Если blob нет - загружаем из локального файла + 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 + } + fullImageBitmap = decoded + } + } catch (e: Exception) {} + } + } + } + + Box( + 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 + ) + } else if (previewBitmap != null) { + // Blurhash preview если полная не загружена + Image( + bitmap = previewBitmap!!.asImageBitmap(), + contentDescription = "Photo preview", + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Crop + ) + } else { + // Placeholder с иконкой только если нет ничего + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Icon( + TablerIcons.Photo, + contentDescription = null, + tint = Color.White.copy(alpha = 0.7f), + modifier = Modifier.size(20.dp) + ) + } + } } - } } /** Profile photo menu for avatar */ @Composable @@ -1410,47 +1629,47 @@ fun ProfilePhotoMenu( 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 - ) - ) { - 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 - ) + DropdownMenu( + 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 + ) - // Показываем Delete только если есть аватар - if (hasAvatar && onDeletePhotoClick != null) { - Box( - modifier = - Modifier.fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 4.dp) - .height(0.5.dp) - .background(dividerColor) - ) + // Показываем Delete только если есть аватар + if (hasAvatar && onDeletePhotoClick != null) { + Box( + 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) - ) + ProfilePhotoMenuItem( + icon = TablerIcons.Trash, + text = "Delete Photo", + onClick = onDeletePhotoClick, + tintColor = Color(0xFFFF3B30), + textColor = Color(0xFFFF3B30) + ) + } } - } } /** Other user profile menu with block option */ @@ -1463,44 +1682,44 @@ fun OtherProfileMenu( 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 - ) - ) { - 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 - ) + DropdownMenu( + 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 + ) - Box( - modifier = - Modifier.fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 4.dp) - .height(0.5.dp) - .background(dividerColor) - ) + Box( + 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) - ) - } + ProfilePhotoMenuItem( + icon = TablerIcons.Trash, + text = "Clear Chat History", + onClick = onClearChatClick, + tintColor = Color(0xFFFF3B30), + textColor = Color(0xFFFF3B30) + ) + } } @Composable @@ -1511,32 +1730,32 @@ private fun ProfilePhotoMenuItem( tintColor: Color, textColor: Color ) { - val interactionSource = remember { MutableInteractionSource() } + 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) - ) + 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) + ) }