feat: Add message highlighting and scrolling functionality for replies
This commit is contained in:
@@ -249,6 +249,9 @@ fun ChatDetailScreen(
|
||||
val scope = rememberCoroutineScope()
|
||||
val density = LocalDensity.current
|
||||
|
||||
// 🔥 State для подсветки сообщения при клике на reply
|
||||
var highlightedMessageId by remember { mutableStateOf<String?>(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() }
|
||||
|
||||
@@ -406,6 +414,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 =
|
||||
when {
|
||||
@@ -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<ChatViewModel.ReplyMessage> = 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,
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user