feat: Add message highlighting and scrolling functionality for replies

This commit is contained in:
k1ngsterr1
2026-01-15 17:02:35 +05:00
parent 327b12a462
commit a939054c54
2 changed files with 82 additions and 21 deletions

View File

@@ -249,6 +249,9 @@ fun ChatDetailScreen(
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val density = LocalDensity.current val density = LocalDensity.current
// 🔥 State для подсветки сообщения при клике на reply
var highlightedMessageId by remember { mutableStateOf<String?>(null) }
// 🔥 Emoji picker state (поднят из MessageInputBar для KeyboardAvoidingView) // 🔥 Emoji picker state (поднят из MessageInputBar для KeyboardAvoidingView)
var showEmojiPicker by remember { mutableStateOf(false) } var showEmojiPicker by remember { mutableStateOf(false) }
@@ -286,6 +289,11 @@ fun ChatDetailScreen(
val replyMessages by viewModel.replyMessages.collectAsState() val replyMessages by viewModel.replyMessages.collectAsState()
val hasReply = replyMessages.isNotEmpty() val hasReply = replyMessages.isNotEmpty()
// 🔥 Snapshot последнего непустого состояния для отображения во время анимации закрытия
val displayReplyMessages = remember(replyMessages) {
if (replyMessages.isNotEmpty()) replyMessages else emptyList()
}.let { if (hasReply) replyMessages else it }
// 🔥 FocusRequester для автофокуса на инпут при reply // 🔥 FocusRequester для автофокуса на инпут при reply
val inputFocusRequester = remember { FocusRequester() } val inputFocusRequester = remember { FocusRequester() }
@@ -406,6 +414,22 @@ fun ChatDetailScreen(
result 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 // Динамический subtitle: typing > online > offline
val chatSubtitle = val chatSubtitle =
when { when {
@@ -1013,7 +1037,10 @@ fun ChatDetailScreen(
// Focus requester для автофокуса при reply // Focus requester для автофокуса при reply
focusRequester = inputFocusRequester, focusRequester = inputFocusRequester,
// Coordinator для плавных переходов // Coordinator для плавных переходов
coordinator = coordinator coordinator = coordinator,
// 🔥 Для отображения reply preview и скролла
displayReplyMessages = displayReplyMessages,
onReplyClick = scrollToMessage
) )
} }
} }
@@ -1259,6 +1286,7 @@ fun ChatDetailScreen(
isDarkTheme = isDarkTheme, isDarkTheme = isDarkTheme,
showTail = showTail, showTail = showTail,
isSelected = selectedMessages.contains(selectionKey), isSelected = selectedMessages.contains(selectionKey),
isHighlighted = highlightedMessageId == message.id,
onLongClick = { onLongClick = {
// Toggle selection on long press // Toggle selection on long press
selectedMessages = if (selectedMessages.contains(selectionKey)) { selectedMessages = if (selectedMessages.contains(selectionKey)) {
@@ -1281,6 +1309,10 @@ fun ChatDetailScreen(
// 🔥 Swipe-to-reply: добавляем это сообщение в reply // 🔥 Swipe-to-reply: добавляем это сообщение в reply
viewModel.setReplyMessages(listOf(message)) viewModel.setReplyMessages(listOf(message))
}, },
onReplyClick = { messageId ->
// 🔥 Клик на цитату - скроллим к сообщению
scrollToMessage(messageId)
},
onRetry = { onRetry = {
// 🔥 Retry: удаляем старое и отправляем заново // 🔥 Retry: удаляем старое и отправляем заново
viewModel.retryMessage(message) viewModel.retryMessage(message)
@@ -1543,9 +1575,11 @@ private fun MessageBubble(
isDarkTheme: Boolean, isDarkTheme: Boolean,
showTail: Boolean = true, showTail: Boolean = true,
isSelected: Boolean = false, isSelected: Boolean = false,
isHighlighted: Boolean = false, // 🔥 Подсветка при клике на reply
onLongClick: () -> Unit = {}, onLongClick: () -> Unit = {},
onClick: () -> Unit = {}, onClick: () -> Unit = {},
onSwipeToReply: () -> Unit = {}, onSwipeToReply: () -> Unit = {},
onReplyClick: (String) -> Unit = {}, // 🔥 Клик на цитату - скролл к сообщению
onRetry: () -> Unit = {}, // 🔥 Retry для ошибки onRetry: () -> Unit = {}, // 🔥 Retry для ошибки
onDelete: () -> Unit = {} // 🔥 Delete для ошибки onDelete: () -> Unit = {} // 🔥 Delete для ошибки
) { ) {
@@ -1674,11 +1708,21 @@ private fun MessageBubble(
label = "selectionBg" 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( Row(
modifier = modifier =
Modifier.fillMaxWidth() Modifier.fillMaxWidth()
// 🔥 TELEGRAM: Фоновая подсветка при выделении - на всю ширину экрана! // 🔥 TELEGRAM: Фоновая подсветка при выделении - на всю ширину экрана!
.background(selectionBackgroundColor) .background(combinedBackgroundColor)
// 🔥 Только vertical padding, horizontal убран чтобы выделение было edge-to-edge // 🔥 Только vertical padding, horizontal убран чтобы выделение было edge-to-edge
.padding(vertical = 2.dp) .padding(vertical = 2.dp)
.offset { IntOffset(animatedOffset.toInt(), 0) }, .offset { IntOffset(animatedOffset.toInt(), 0) },
@@ -1751,7 +1795,8 @@ private fun MessageBubble(
ReplyBubble( ReplyBubble(
replyData = reply, replyData = reply,
isOutgoing = message.isOutgoing, isOutgoing = message.isOutgoing,
isDarkTheme = isDarkTheme isDarkTheme = isDarkTheme,
onClick = { onReplyClick(reply.messageId) }
) )
Spacer(modifier = Modifier.height(4.dp)) // Меньше отступ Spacer(modifier = Modifier.height(4.dp)) // Меньше отступ
} }
@@ -1916,7 +1961,8 @@ private fun AnimatedMessageStatus(
private fun ReplyBubble( private fun ReplyBubble(
replyData: ReplyData, replyData: ReplyData,
isOutgoing: Boolean, isOutgoing: Boolean,
isDarkTheme: Boolean isDarkTheme: Boolean,
onClick: () -> Unit = {} // 🔥 Клик на цитату
) { ) {
// НАШИ ОРИГИНАЛЬНЫЕ ЦВЕТА // НАШИ ОРИГИНАЛЬНЫЕ ЦВЕТА
val backgroundColor = if (isOutgoing) { val backgroundColor = if (isOutgoing) {
@@ -1948,6 +1994,7 @@ private fun ReplyBubble(
.wrapContentWidth() .wrapContentWidth()
.height(IntrinsicSize.Min) .height(IntrinsicSize.Min)
.clip(RoundedCornerShape(4.dp)) .clip(RoundedCornerShape(4.dp))
.clickable(onClick = onClick) // 🔥 Клик на цитату
.background(backgroundColor) .background(backgroundColor)
) { ) {
// 🔥 TELEGRAM: Вертикальная линия слева 3dp // 🔥 TELEGRAM: Вертикальная линия слева 3dp
@@ -2056,7 +2103,10 @@ private fun MessageInputBar(
// Focus requester для автофокуса при reply // Focus requester для автофокуса при reply
focusRequester: FocusRequester? = null, focusRequester: FocusRequester? = null,
// Coordinator для плавных переходов клавиатуры // Coordinator для плавных переходов клавиатуры
coordinator: KeyboardTransitionCoordinator coordinator: KeyboardTransitionCoordinator,
// 🔥 Для отображения reply preview и скролла к сообщению
displayReplyMessages: List<ChatViewModel.ReplyMessage> = emptyList(),
onReplyClick: (String) -> Unit = {}
) { ) {
// 🔥 ОПТИМИЗАЦИЯ: Убрано логирование чтобы не засорять logcat // 🔥 ОПТИМИЗАЦИЯ: Убрано логирование чтобы не засорять logcat
@@ -2344,6 +2394,12 @@ private fun MessageInputBar(
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.clickable {
// 🔥 При клике на reply preview - скроллим к первому сообщению
if (displayReplyMessages.isNotEmpty()) {
onReplyClick(displayReplyMessages.first().messageId)
}
}
.background(backgroundColor) // Тот же цвет что и фон чата .background(backgroundColor) // Тот же цвет что и фон чата
.padding(horizontal = 12.dp, vertical = 8.dp), .padding(horizontal = 12.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
@@ -2357,8 +2413,8 @@ private fun MessageInputBar(
Spacer(modifier = Modifier.width(10.dp)) Spacer(modifier = Modifier.width(10.dp))
Column(modifier = Modifier.weight(1f)) { Column(modifier = Modifier.weight(1f)) {
Text( Text(
text = if (isForwardMode) "Forward message${if (replyMessages.size > 1) "s" else ""}" text = if (isForwardMode) "Forward message${if (displayReplyMessages.size > 1) "s" else ""}"
else "Reply to ${if (replyMessages.size == 1 && !replyMessages.first().isOutgoing) chatTitle else "You"}", else "Reply to ${if (displayReplyMessages.size == 1 && !displayReplyMessages.first().isOutgoing) chatTitle else "You"}",
fontSize = 13.sp, fontSize = 13.sp,
fontWeight = FontWeight.SemiBold, fontWeight = FontWeight.SemiBold,
color = PrimaryBlue, color = PrimaryBlue,
@@ -2366,12 +2422,13 @@ private fun MessageInputBar(
) )
Spacer(modifier = Modifier.height(2.dp)) Spacer(modifier = Modifier.height(2.dp))
// Превью ответа // Превью ответа
if (displayReplyMessages.isNotEmpty()) {
Text( Text(
text = if (replyMessages.size == 1) { text = if (displayReplyMessages.size == 1) {
val msg = replyMessages.first() val msg = displayReplyMessages.first()
val shortText = msg.text.take(40) val shortText = msg.text.take(40)
if (shortText.length < msg.text.length) "$shortText..." else shortText if (shortText.length < msg.text.length) "$shortText..." else shortText
} else "${replyMessages.size} messages", } else "${displayReplyMessages.size} messages",
fontSize = 13.sp, fontSize = 13.sp,
color = if (isDarkTheme) Color.White.copy(alpha = 0.6f) color = if (isDarkTheme) Color.White.copy(alpha = 0.6f)
else Color.Black.copy(alpha = 0.5f), else Color.Black.copy(alpha = 0.5f),
@@ -2379,6 +2436,7 @@ private fun MessageInputBar(
overflow = TextOverflow.Ellipsis overflow = TextOverflow.Ellipsis
) )
} }
}
IconButton( IconButton(
onClick = onCloseReply, onClick = onCloseReply,
modifier = Modifier.size(32.dp) modifier = Modifier.size(32.dp)

View File

@@ -864,8 +864,11 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
_messages.value = _messages.value + optimisticMessage _messages.value = _messages.value + optimisticMessage
_inputText.value = "" _inputText.value = ""
// Очищаем reply ПОСЛЕ добавления сообщения в список // Очищаем reply ПОСЛЕ добавления сообщения в список с небольшой задержкой
viewModelScope.launch {
kotlinx.coroutines.delay(100)
clearReplyMessages() clearReplyMessages()
}
// Кэшируем текст // Кэшируем текст
decryptionCache[messageId] = text decryptionCache[messageId] = text