From a939054c54cf1b64e691299dab5a2832bbf5efa9 Mon Sep 17 00:00:00 2001 From: k1ngsterr1 Date: Thu, 15 Jan 2026 17:02:35 +0500 Subject: [PATCH] feat: Add message highlighting and scrolling functionality for replies --- .../messenger/ui/chats/ChatDetailScreen.kt | 96 +++++++++++++++---- .../messenger/ui/chats/ChatViewModel.kt | 7 +- 2 files changed, 82 insertions(+), 21 deletions(-) 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 b9d9857..16dc4eb 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 @@ -249,6 +249,9 @@ fun ChatDetailScreen( val scope = rememberCoroutineScope() val density = LocalDensity.current + // 🔥 State для подсветки сообщения при клике на reply + var highlightedMessageId by remember { mutableStateOf(null) } + // 🔥 Emoji picker state (поднят из MessageInputBar для KeyboardAvoidingView) var showEmojiPicker by remember { mutableStateOf(false) } @@ -286,6 +289,11 @@ fun ChatDetailScreen( val replyMessages by viewModel.replyMessages.collectAsState() val hasReply = replyMessages.isNotEmpty() + // 🔥 Snapshot последнего непустого состояния для отображения во время анимации закрытия + val displayReplyMessages = remember(replyMessages) { + if (replyMessages.isNotEmpty()) replyMessages else emptyList() + }.let { if (hasReply) replyMessages else it } + // 🔥 FocusRequester для автофокуса на инпут при reply val inputFocusRequester = remember { FocusRequester() } @@ -405,6 +413,22 @@ fun ChatDetailScreen( result } + + // 🔥 Функция для скролла к сообщению с подсветкой + val scrollToMessage: (String) -> Unit = { messageId -> + scope.launch { + // Находим индекс сообщения в списке + val messageIndex = messagesWithDates.indexOfFirst { it.first.id == messageId } + if (messageIndex != -1) { + // Скроллим к сообщению + listState.animateScrollToItem(messageIndex) + // Подсвечиваем на 2 секунды + highlightedMessageId = messageId + delay(2000) + highlightedMessageId = null + } + } + } // Динамический subtitle: typing > online > offline val chatSubtitle = @@ -1013,7 +1037,10 @@ fun ChatDetailScreen( // Focus requester для автофокуса при reply focusRequester = inputFocusRequester, // Coordinator для плавных переходов - coordinator = coordinator + coordinator = coordinator, + // 🔥 Для отображения reply preview и скролла + displayReplyMessages = displayReplyMessages, + onReplyClick = scrollToMessage ) } } @@ -1259,6 +1286,7 @@ fun ChatDetailScreen( isDarkTheme = isDarkTheme, showTail = showTail, isSelected = selectedMessages.contains(selectionKey), + isHighlighted = highlightedMessageId == message.id, onLongClick = { // Toggle selection on long press selectedMessages = if (selectedMessages.contains(selectionKey)) { @@ -1281,6 +1309,10 @@ fun ChatDetailScreen( // 🔥 Swipe-to-reply: добавляем это сообщение в reply viewModel.setReplyMessages(listOf(message)) }, + onReplyClick = { messageId -> + // 🔥 Клик на цитату - скроллим к сообщению + scrollToMessage(messageId) + }, onRetry = { // 🔥 Retry: удаляем старое и отправляем заново viewModel.retryMessage(message) @@ -1543,9 +1575,11 @@ private fun MessageBubble( isDarkTheme: Boolean, showTail: Boolean = true, isSelected: Boolean = false, + isHighlighted: Boolean = false, // 🔥 Подсветка при клике на reply onLongClick: () -> Unit = {}, onClick: () -> Unit = {}, onSwipeToReply: () -> Unit = {}, + onReplyClick: (String) -> Unit = {}, // 🔥 Клик на цитату - скролл к сообщению onRetry: () -> Unit = {}, // 🔥 Retry для ошибки onDelete: () -> Unit = {} // 🔥 Delete для ошибки ) { @@ -1674,11 +1708,21 @@ private fun MessageBubble( label = "selectionBg" ) + // 🔥 Подсветка для highlighted сообщений (клик на reply) - голубой прозрачный + val highlightBackgroundColor by animateColorAsState( + targetValue = if (isHighlighted) PrimaryBlue.copy(alpha = 0.12f) else Color.Transparent, + animationSpec = tween(300), + label = "highlightBg" + ) + + // 🔥 Комбинируем оба фона (selection имеет приоритет) + val combinedBackgroundColor = if (isSelected) selectionBackgroundColor else highlightBackgroundColor + Row( modifier = Modifier.fillMaxWidth() // 🔥 TELEGRAM: Фоновая подсветка при выделении - на всю ширину экрана! - .background(selectionBackgroundColor) + .background(combinedBackgroundColor) // 🔥 Только vertical padding, horizontal убран чтобы выделение было edge-to-edge .padding(vertical = 2.dp) .offset { IntOffset(animatedOffset.toInt(), 0) }, @@ -1751,7 +1795,8 @@ private fun MessageBubble( ReplyBubble( replyData = reply, isOutgoing = message.isOutgoing, - isDarkTheme = isDarkTheme + isDarkTheme = isDarkTheme, + onClick = { onReplyClick(reply.messageId) } ) Spacer(modifier = Modifier.height(4.dp)) // Меньше отступ } @@ -1916,7 +1961,8 @@ private fun AnimatedMessageStatus( private fun ReplyBubble( replyData: ReplyData, isOutgoing: Boolean, - isDarkTheme: Boolean + isDarkTheme: Boolean, + onClick: () -> Unit = {} // 🔥 Клик на цитату ) { // НАШИ ОРИГИНАЛЬНЫЕ ЦВЕТА val backgroundColor = if (isOutgoing) { @@ -1948,6 +1994,7 @@ private fun ReplyBubble( .wrapContentWidth() .height(IntrinsicSize.Min) .clip(RoundedCornerShape(4.dp)) + .clickable(onClick = onClick) // 🔥 Клик на цитату .background(backgroundColor) ) { // 🔥 TELEGRAM: Вертикальная линия слева 3dp @@ -2056,7 +2103,10 @@ private fun MessageInputBar( // Focus requester для автофокуса при reply focusRequester: FocusRequester? = null, // Coordinator для плавных переходов клавиатуры - coordinator: KeyboardTransitionCoordinator + coordinator: KeyboardTransitionCoordinator, + // 🔥 Для отображения reply preview и скролла к сообщению + displayReplyMessages: List = emptyList(), + onReplyClick: (String) -> Unit = {} ) { // 🔥 ОПТИМИЗАЦИЯ: Убрано логирование чтобы не засорять logcat @@ -2344,6 +2394,12 @@ private fun MessageInputBar( Row( modifier = Modifier .fillMaxWidth() + .clickable { + // 🔥 При клике на reply preview - скроллим к первому сообщению + if (displayReplyMessages.isNotEmpty()) { + onReplyClick(displayReplyMessages.first().messageId) + } + } .background(backgroundColor) // Тот же цвет что и фон чата .padding(horizontal = 12.dp, vertical = 8.dp), verticalAlignment = Alignment.CenterVertically @@ -2357,8 +2413,8 @@ private fun MessageInputBar( Spacer(modifier = Modifier.width(10.dp)) Column(modifier = Modifier.weight(1f)) { Text( - text = if (isForwardMode) "Forward message${if (replyMessages.size > 1) "s" else ""}" - else "Reply to ${if (replyMessages.size == 1 && !replyMessages.first().isOutgoing) chatTitle else "You"}", + text = if (isForwardMode) "Forward message${if (displayReplyMessages.size > 1) "s" else ""}" + else "Reply to ${if (displayReplyMessages.size == 1 && !displayReplyMessages.first().isOutgoing) chatTitle else "You"}", fontSize = 13.sp, fontWeight = FontWeight.SemiBold, color = PrimaryBlue, @@ -2366,18 +2422,20 @@ private fun MessageInputBar( ) Spacer(modifier = Modifier.height(2.dp)) // Превью ответа - Text( - text = if (replyMessages.size == 1) { - val msg = replyMessages.first() - val shortText = msg.text.take(40) - if (shortText.length < msg.text.length) "$shortText..." else shortText - } else "${replyMessages.size} messages", - fontSize = 13.sp, - color = if (isDarkTheme) Color.White.copy(alpha = 0.6f) - else Color.Black.copy(alpha = 0.5f), - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) + if (displayReplyMessages.isNotEmpty()) { + Text( + text = if (displayReplyMessages.size == 1) { + val msg = displayReplyMessages.first() + val shortText = msg.text.take(40) + if (shortText.length < msg.text.length) "$shortText..." else shortText + } else "${displayReplyMessages.size} messages", + fontSize = 13.sp, + color = if (isDarkTheme) Color.White.copy(alpha = 0.6f) + else Color.Black.copy(alpha = 0.5f), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } } IconButton( onClick = onCloseReply, 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 5100f7d..4bfefc1 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 @@ -864,8 +864,11 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { _messages.value = _messages.value + optimisticMessage _inputText.value = "" - // Очищаем reply ПОСЛЕ добавления сообщения в список - clearReplyMessages() + // Очищаем reply ПОСЛЕ добавления сообщения в список с небольшой задержкой + viewModelScope.launch { + kotlinx.coroutines.delay(100) + clearReplyMessages() + } // Кэшируем текст decryptionCache[messageId] = text