From c41c27e6d91cdcb09783b4f4568999d32b81e5d6 Mon Sep 17 00:00:00 2001 From: k1ngsterr1 Date: Mon, 2 Feb 2026 15:04:22 +0500 Subject: [PATCH] fix: improve message ID generation and enhance pagination handling in chat screen --- .../messenger/data/MessageRepository.kt | 12 ++- .../messenger/ui/chats/ChatDetailScreen.kt | 76 ++++++++++++++----- .../messenger/ui/chats/ChatViewModel.kt | 6 +- .../chats/components/ChatDetailComponents.kt | 51 ++++++++++--- 4 files changed, 107 insertions(+), 38 deletions(-) diff --git a/app/src/main/java/com/rosetta/messenger/data/MessageRepository.kt b/app/src/main/java/com/rosetta/messenger/data/MessageRepository.kt index 52402e9..3f85cae 100644 --- a/app/src/main/java/com/rosetta/messenger/data/MessageRepository.kt +++ b/app/src/main/java/com/rosetta/messenger/data/MessageRepository.kt @@ -92,15 +92,13 @@ class MessageRepository private constructor(private val context: Context) { } /** - * Генерация детерминированного messageId на основе данных сообщения - * Аналог generateRandomKeyFormSeed из Архива + * Генерация уникального messageId + * 🔥 ИСПРАВЛЕНО: Используем UUID вместо детерминированного хэша, + * чтобы избежать коллизий при одинаковом timestamp (при спаме сообщениями) */ fun generateMessageId(fromPublicKey: String, toPublicKey: String, timestamp: Long): String { - val seed = fromPublicKey + toPublicKey + timestamp.toString() - val hash = java.security.MessageDigest.getInstance("SHA-256") - .digest(seed.toByteArray()) - // Берём первые 16 символов hex-представления - return hash.take(8).joinToString("") { String.format("%02x", it) } + // Генерируем UUID для гарантии уникальности + return UUID.randomUUID().toString().replace("-", "").take(32) } } 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 5d9cf98..31e86c3 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 @@ -84,6 +84,9 @@ import android.provider.MediaStore import androidx.core.content.FileProvider import java.io.File import kotlinx.coroutines.delay +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.launch import java.text.SimpleDateFormat import java.util.Date @@ -92,7 +95,8 @@ import java.util.Locale @OptIn( ExperimentalMaterial3Api::class, androidx.compose.foundation.ExperimentalFoundationApi::class, - androidx.compose.ui.ExperimentalComposeUiApi::class + androidx.compose.ui.ExperimentalComposeUiApi::class, + FlowPreview::class ) @Composable fun ChatDetailScreen( @@ -257,6 +261,7 @@ fun ChatDetailScreen( val messages by viewModel.messages.collectAsState() val inputText by viewModel.inputText.collectAsState() val isTyping by viewModel.opponentTyping.collectAsState() + val isLoadingMore by viewModel.isLoadingMore.collectAsState() val isOnline by viewModel.opponentOnline.collectAsState() val isLoading by viewModel.isLoading.collectAsState() // 🔥 Для скелетона @@ -265,6 +270,45 @@ fun ChatDetailScreen( val hasReply = replyMessages.isNotEmpty() val isForwardMode by viewModel.isForwardMode.collectAsState() + // 🔥 Количество сообщений для отслеживания пагинации + val messagesCount = messages.size + var previousMessagesCount by remember { mutableStateOf(messagesCount) } + + // 🔥 ПАГИНАЦИЯ: Сохраняем позицию скролла при подгрузке старых сообщений + LaunchedEffect(messagesCount) { + if (messagesCount > previousMessagesCount && previousMessagesCount > 0) { + // Загрузились новые (старые) сообщения - корректируем позицию + val addedCount = messagesCount - previousMessagesCount + val currentIndex = listState.firstVisibleItemIndex + val currentOffset = listState.firstVisibleItemScrollOffset + + // Прокручиваем на количество добавленных элементов, чтобы остаться на месте + listState.scrollToItem(currentIndex + addedCount, currentOffset) + } + previousMessagesCount = messagesCount + } + + // 🔥 ПАГИНАЦИЯ: Загружаем старые сообщения при прокрутке вверх + // reverseLayout=true означает что index 0 - это новое сообщение внизу, + // а большие индексы - старые сообщения вверху + // Используем snapshotFlow с debounce для плавной пагинации без прерывания скролла + LaunchedEffect(listState) { + snapshotFlow { + val layoutInfo = listState.layoutInfo + val totalItems = layoutInfo.totalItemsCount + val lastVisibleItemIndex = layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0 + Pair(lastVisibleItemIndex, totalItems) + } + .distinctUntilChanged() + .debounce(100) // 🔥 Debounce чтобы не триггерить на каждый фрейм скролла + .collect { (lastVisible, total) -> + // Загружаем когда осталось 5 элементов до конца и не идёт загрузка + if (total > 0 && lastVisible >= total - 5 && !viewModel.isLoadingMore.value) { + viewModel.loadMoreMessages() + } + } + } + // 🔥 Display reply messages - получаем полную информацию о сообщениях для reply val displayReplyMessages = remember(replyMessages, messages) { @@ -400,16 +444,21 @@ fun ChatDetailScreen( } } - // Telegram-style: Прокрутка при новых сообщениях - // 🔥 Добавлен debounce для защиты от спама - ждём 50ms перед скроллом - // Это предотвращает создание множества параллельных анимаций при быстром добавлении сообщений - LaunchedEffect(messages.size) { - if (messages.isNotEmpty()) { + // 🔥 Отслеживаем ID самого нового сообщения для умного скролла + val newestMessageId = messages.firstOrNull()?.id + var lastNewestMessageId by remember { mutableStateOf(null) } + + // Telegram-style: Прокрутка ТОЛЬКО при новых сообщениях (не при пагинации) + // 🔥 Скроллим только если изменился ID самого нового сообщения + // При пагинации добавляются старые сообщения - ID нового не меняется + LaunchedEffect(newestMessageId) { + if (newestMessageId != null && lastNewestMessageId != null && newestMessageId != lastNewestMessageId) { + // Новое сообщение пришло - скроллим вниз delay(50) // Debounce - ждём стабилизации - // Всегда скроллим вниз при новом сообщении listState.animateScrollToItem(0) wasManualScroll = false } + lastNewestMessageId = newestMessageId } // Аватар - используем publicKey для консистентности цвета везде @@ -1640,18 +1689,7 @@ fun ChatDetailScreen( .time) > 60_000) - Column( - modifier = - Modifier.animateItemPlacement( - animationSpec = - spring( - dampingRatio = - Spring.DampingRatioMediumBouncy, - stiffness = - Spring.StiffnessMedium - ) - ) - ) { + Column { if (showDate) { DateHeader( dateText = 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 99136de..2d9870b 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 @@ -237,11 +237,11 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { val opponent = opponentKey ?: return@withContext val isSavedMessages = (opponent == account) - // Получаем последние N сообщений из БД (больше чем 1, чтобы поймать весь спам) + // 🔥 Получаем последние N сообщений из БД (увеличено до 50 чтобы поймать весь спам) val latestEntities = if (isSavedMessages) { - messageDao.getMessagesForSavedDialog(account, limit = 20, offset = 0) + messageDao.getMessagesForSavedDialog(account, limit = 50, offset = 0) } else { - messageDao.getMessages(account, dialogKey, limit = 20, offset = 0) + messageDao.getMessages(account, dialogKey, limit = 50, offset = 0) } if (latestEntities.isEmpty()) return@withContext 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 963760c..49d5458 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 @@ -11,7 +11,10 @@ import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.gestures.awaitEachGesture +import androidx.compose.foundation.gestures.awaitFirstDown import androidx.compose.foundation.gestures.detectHorizontalDragGestures +import kotlin.math.abs import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.CircleShape @@ -27,6 +30,7 @@ import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.input.pointer.positionChange import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.Layout import androidx.compose.ui.layout.Measurable @@ -304,19 +308,48 @@ fun MessageBubble( Box( modifier = Modifier.fillMaxWidth().pointerInput(Unit) { - detectHorizontalDragGestures( - onDragEnd = { - if (swipeOffset <= -swipeThreshold) { - onSwipeToReply() + // 🔥 Кастомная обработка жестов с определением направления + // Это предотвращает конфликт между swipe-to-reply и вертикальным скроллом + awaitEachGesture { + val down = awaitFirstDown(requireUnconsumed = false) + var isDragStarted = false + var isHorizontalDrag = false + val touchSlop = 20f // Минимальное смещение для определения направления + + var totalX = 0f + var totalY = 0f + + do { + val event = awaitPointerEvent() + val change = event.changes.firstOrNull() ?: break + + if (!isDragStarted) { + // Определяем направление по первым движениям + totalX += change.positionChange().x + totalY += change.positionChange().y + + if (abs(totalX) > touchSlop || abs(totalY) > touchSlop) { + isDragStarted = true + // Горизонтальный свайп только если X > Y и свайп влево + isHorizontalDrag = abs(totalX) > abs(totalY) * 1.5f && totalX < 0 } - swipeOffset = 0f - }, - onDragCancel = { swipeOffset = 0f }, - onHorizontalDrag = { _, dragAmount -> + } + + if (isDragStarted && isHorizontalDrag) { + // Перехватываем только горизонтальные жесты влево + change.consume() + val dragAmount = change.positionChange().x val newOffset = swipeOffset + dragAmount swipeOffset = newOffset.coerceIn(-maxSwipe, 0f) } - ) + } while (event.changes.any { it.pressed }) + + // onDragEnd + if (isHorizontalDrag && swipeOffset <= -swipeThreshold) { + onSwipeToReply() + } + swipeOffset = 0f + } } ) { // Reply icon