feat: Add message highlighting and scrolling functionality for replies
This commit is contained in:
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user