diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt index cca22d1..ad32dd7 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt @@ -1612,6 +1612,27 @@ fun ChatDetailScreen( .time) > 60_000 + // Определяем начало новой + // группы (для отступов) + val prevMessage = + messagesWithDates + .getOrNull( + index - + 1 + ) + ?.first + val isGroupStart = + prevMessage != null && + (prevMessage + .isOutgoing != + message.isOutgoing || + (prevMessage + .timestamp + .time - + message.timestamp + .time) > + 60_000) + Column( modifier = Modifier.animateItemPlacement( @@ -1644,6 +1665,8 @@ fun ChatDetailScreen( isDarkTheme, showTail = showTail, + isGroupStart = + isGroupStart, isSelected = selectedMessages .contains( diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatViewModel.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatViewModel.kt index 6495928..10b393a 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatViewModel.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatViewModel.kt @@ -96,6 +96,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { private val _opponentTyping = MutableStateFlow(false) val opponentTyping: StateFlow = _opponentTyping.asStateFlow() + private var typingTimeoutJob: kotlinx.coroutines.Job? = null // 🟢 Онлайн статус собеседника private val _opponentOnline = MutableStateFlow(false) @@ -357,6 +358,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { _messages.value = emptyList() _opponentOnline.value = false _opponentTyping.value = false + typingTimeoutJob?.cancel() currentOffset = 0 hasMoreMessages = true isLoadingMessages = false @@ -401,6 +403,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { */ fun closeDialog() { isDialogActive = false + typingTimeoutJob?.cancel() } /** @@ -2143,7 +2146,9 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { private fun showTypingIndicator() { _opponentTyping.value = true - viewModelScope.launch(Dispatchers.Default) { + // Отменяем предыдущий таймер, чтобы избежать race condition + typingTimeoutJob?.cancel() + typingTimeoutJob = viewModelScope.launch(Dispatchers.Default) { kotlinx.coroutines.delay(3000) _opponentTyping.value = false } 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 dc0fe5e..ec02b49 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 @@ -28,6 +28,9 @@ import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.input.pointer.pointerInput 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.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow @@ -56,6 +59,82 @@ import kotlinx.coroutines.withContext * organization */ +/** + * Telegram-style layout для текста сообщения с временем. + * Если текст + время помещаются в одну строку - располагает их рядом. + * Если текст длинный и переносится - время встаёт в правый нижний угол, + * под последней строкой текста (как в Telegram). + * + * @param textContent Composable с текстом сообщения + * @param timeContent Composable с временем и статусом + * @param modifier Модификатор для Layout + * @param spacing Минимальный отступ между текстом и временем (dp) + */ +@Composable +fun TelegramStyleMessageContent( + textContent: @Composable () -> Unit, + timeContent: @Composable () -> Unit, + modifier: Modifier = Modifier, + spacing: Int = 10 +) { + 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 (!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) + /** Date header with fade-in animation */ @Composable fun DateHeader(dateText: String, secondaryTextColor: Color) { @@ -138,6 +217,7 @@ fun MessageBubble( message: ChatMessage, isDarkTheme: Boolean, showTail: Boolean = true, + isGroupStart: Boolean = false, isSelected: Boolean = false, isHighlighted: Boolean = false, isSavedMessages: Boolean = false, @@ -288,11 +368,15 @@ fun MessageBubble( val combinedBackgroundColor = if (isSelected) selectionBackgroundColor else highlightBackgroundColor + // Динамические отступы: больше между группами, меньше внутри группы + val topPadding = if (isGroupStart) 8.dp else 2.dp + val bottomPadding = 2.dp + Row( modifier = Modifier.fillMaxWidth() .background(combinedBackgroundColor) - .padding(vertical = 2.dp) + .padding(top = topPadding, bottom = bottomPadding) .offset { IntOffset(animatedOffset.toInt(), 0) }, horizontalArrangement = Arrangement.Start, verticalAlignment = Alignment.CenterVertically @@ -438,142 +522,131 @@ fun MessageBubble( ) } - // 🖼️ Caption под фото (как в Telegram) + // 🖼️ Caption под фото (как в Telegram) - Telegram-style layout if (hasImageWithCaption) { - Column( + Box( modifier = Modifier.fillMaxWidth() .background(bubbleColor) - .padding( - horizontal = 10.dp, - vertical = 6.dp - ) // Уменьшил padding + .padding(horizontal = 10.dp, vertical = 6.dp) ) { - Row( - verticalAlignment = Alignment.Bottom, - horizontalArrangement = Arrangement.SpaceBetween, - modifier = Modifier.fillMaxWidth() - ) { - AppleEmojiText( - 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) - ) { - 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 + TelegramStyleMessageContent( + textContent = { + AppleEmojiText( + text = message.text, + color = textColor, + fontSize = 16.sp ) - } - } - } + }, + 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 + ) } } else if (message.attachments.isNotEmpty() && message.text.isNotEmpty()) { // Обычное фото + текст (не только изображения) Spacer(modifier = Modifier.height(8.dp)) } - // Если есть reply - текст слева, время справа на одной строке + // Если есть reply - Telegram-style layout if (message.replyData != null && message.text.isNotEmpty()) { - Row( - verticalAlignment = Alignment.Bottom, - modifier = Modifier.fillMaxWidth() - ) { - AppleEmojiText( - 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) - ) { - 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 + TelegramStyleMessageContent( + textContent = { + AppleEmojiText( + text = message.text, + color = textColor, + fontSize = 17.sp ) + }, + 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 + ) + } + } } - } - } + ) } else if (!hasOnlyMedia && !hasImageWithCaption && message.text.isNotEmpty()) { - // Без reply, не только фото, и не фото с caption - компактно в одну строку - Row( - verticalAlignment = Alignment.Bottom, - horizontalArrangement = Arrangement.spacedBy(10.dp), - modifier = Modifier.wrapContentWidth() - ) { - AppleEmojiText( - 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) - ) { - 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 + // Telegram-style: текст + время с автоматическим переносом + TelegramStyleMessageContent( + textContent = { + AppleEmojiText( + text = message.text, + color = textColor, + fontSize = 17.sp ) + }, + 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 + ) + } + } } - } - } + ) } } }