fix: improve message ID generation and enhance pagination handling in chat screen

This commit is contained in:
k1ngsterr1
2026-02-02 15:04:22 +05:00
parent 311144ff4d
commit c41c27e6d9
4 changed files with 107 additions and 38 deletions

View File

@@ -92,15 +92,13 @@ class MessageRepository private constructor(private val context: Context) {
} }
/** /**
* Генерация детерминированного messageId на основе данных сообщения * Генерация уникального messageId
* Аналог generateRandomKeyFormSeed из Архива * 🔥 ИСПРАВЛЕНО: Используем UUID вместо детерминированного хэша,
* чтобы избежать коллизий при одинаковом timestamp (при спаме сообщениями)
*/ */
fun generateMessageId(fromPublicKey: String, toPublicKey: String, timestamp: Long): String { fun generateMessageId(fromPublicKey: String, toPublicKey: String, timestamp: Long): String {
val seed = fromPublicKey + toPublicKey + timestamp.toString() // Генерируем UUID для гарантии уникальности
val hash = java.security.MessageDigest.getInstance("SHA-256") return UUID.randomUUID().toString().replace("-", "").take(32)
.digest(seed.toByteArray())
// Берём первые 16 символов hex-представления
return hash.take(8).joinToString("") { String.format("%02x", it) }
} }
} }

View File

@@ -84,6 +84,9 @@ import android.provider.MediaStore
import androidx.core.content.FileProvider import androidx.core.content.FileProvider
import java.io.File import java.io.File
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.Date import java.util.Date
@@ -92,7 +95,8 @@ import java.util.Locale
@OptIn( @OptIn(
ExperimentalMaterial3Api::class, ExperimentalMaterial3Api::class,
androidx.compose.foundation.ExperimentalFoundationApi::class, androidx.compose.foundation.ExperimentalFoundationApi::class,
androidx.compose.ui.ExperimentalComposeUiApi::class androidx.compose.ui.ExperimentalComposeUiApi::class,
FlowPreview::class
) )
@Composable @Composable
fun ChatDetailScreen( fun ChatDetailScreen(
@@ -257,6 +261,7 @@ fun ChatDetailScreen(
val messages by viewModel.messages.collectAsState() val messages by viewModel.messages.collectAsState()
val inputText by viewModel.inputText.collectAsState() val inputText by viewModel.inputText.collectAsState()
val isTyping by viewModel.opponentTyping.collectAsState() val isTyping by viewModel.opponentTyping.collectAsState()
val isLoadingMore by viewModel.isLoadingMore.collectAsState()
val isOnline by viewModel.opponentOnline.collectAsState() val isOnline by viewModel.opponentOnline.collectAsState()
val isLoading by viewModel.isLoading.collectAsState() // 🔥 Для скелетона val isLoading by viewModel.isLoading.collectAsState() // 🔥 Для скелетона
@@ -265,6 +270,45 @@ fun ChatDetailScreen(
val hasReply = replyMessages.isNotEmpty() val hasReply = replyMessages.isNotEmpty()
val isForwardMode by viewModel.isForwardMode.collectAsState() 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 // 🔥 Display reply messages - получаем полную информацию о сообщениях для reply
val displayReplyMessages = val displayReplyMessages =
remember(replyMessages, messages) { remember(replyMessages, messages) {
@@ -400,16 +444,21 @@ fun ChatDetailScreen(
} }
} }
// Telegram-style: Прокрутка при новых сообщениях // 🔥 Отслеживаем ID самого нового сообщения для умного скролла
// 🔥 Добавлен debounce для защиты от спама - ждём 50ms перед скроллом val newestMessageId = messages.firstOrNull()?.id
// Это предотвращает создание множества параллельных анимаций при быстром добавлении сообщений var lastNewestMessageId by remember { mutableStateOf<String?>(null) }
LaunchedEffect(messages.size) {
if (messages.isNotEmpty()) { // Telegram-style: Прокрутка ТОЛЬКО при новых сообщениях (не при пагинации)
// 🔥 Скроллим только если изменился ID самого нового сообщения
// При пагинации добавляются старые сообщения - ID нового не меняется
LaunchedEffect(newestMessageId) {
if (newestMessageId != null && lastNewestMessageId != null && newestMessageId != lastNewestMessageId) {
// Новое сообщение пришло - скроллим вниз
delay(50) // Debounce - ждём стабилизации delay(50) // Debounce - ждём стабилизации
// Всегда скроллим вниз при новом сообщении
listState.animateScrollToItem(0) listState.animateScrollToItem(0)
wasManualScroll = false wasManualScroll = false
} }
lastNewestMessageId = newestMessageId
} }
// Аватар - используем publicKey для консистентности цвета везде // Аватар - используем publicKey для консистентности цвета везде
@@ -1640,18 +1689,7 @@ fun ChatDetailScreen(
.time) > .time) >
60_000) 60_000)
Column( Column {
modifier =
Modifier.animateItemPlacement(
animationSpec =
spring(
dampingRatio =
Spring.DampingRatioMediumBouncy,
stiffness =
Spring.StiffnessMedium
)
)
) {
if (showDate) { if (showDate) {
DateHeader( DateHeader(
dateText = dateText =

View File

@@ -237,11 +237,11 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
val opponent = opponentKey ?: return@withContext val opponent = opponentKey ?: return@withContext
val isSavedMessages = (opponent == account) val isSavedMessages = (opponent == account)
// Получаем последние N сообщений из БД (больше чем 1, чтобы поймать весь спам) // 🔥 Получаем последние N сообщений из БД (увеличено до 50 чтобы поймать весь спам)
val latestEntities = if (isSavedMessages) { val latestEntities = if (isSavedMessages) {
messageDao.getMessagesForSavedDialog(account, limit = 20, offset = 0) messageDao.getMessagesForSavedDialog(account, limit = 50, offset = 0)
} else { } else {
messageDao.getMessages(account, dialogKey, limit = 20, offset = 0) messageDao.getMessages(account, dialogKey, limit = 50, offset = 0)
} }
if (latestEntities.isEmpty()) return@withContext if (latestEntities.isEmpty()) return@withContext

View File

@@ -11,7 +11,10 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.border import androidx.compose.foundation.border
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.gestures.detectHorizontalDragGestures import androidx.compose.foundation.gestures.detectHorizontalDragGestures
import kotlin.math.abs
import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape 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.graphicsLayer
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.input.pointer.pointerInput 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.ContentScale
import androidx.compose.ui.layout.Layout import androidx.compose.ui.layout.Layout
import androidx.compose.ui.layout.Measurable import androidx.compose.ui.layout.Measurable
@@ -304,19 +308,48 @@ fun MessageBubble(
Box( Box(
modifier = modifier =
Modifier.fillMaxWidth().pointerInput(Unit) { Modifier.fillMaxWidth().pointerInput(Unit) {
detectHorizontalDragGestures( // 🔥 Кастомная обработка жестов с определением направления
onDragEnd = { // Это предотвращает конфликт между swipe-to-reply и вертикальным скроллом
if (swipeOffset <= -swipeThreshold) { awaitEachGesture {
onSwipeToReply() 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 }, if (isDragStarted && isHorizontalDrag) {
onHorizontalDrag = { _, dragAmount -> // Перехватываем только горизонтальные жесты влево
change.consume()
val dragAmount = change.positionChange().x
val newOffset = swipeOffset + dragAmount val newOffset = swipeOffset + dragAmount
swipeOffset = newOffset.coerceIn(-maxSwipe, 0f) swipeOffset = newOffset.coerceIn(-maxSwipe, 0f)
} }
) } while (event.changes.any { it.pressed })
// onDragEnd
if (isHorizontalDrag && swipeOffset <= -swipeThreshold) {
onSwipeToReply()
}
swipeOffset = 0f
}
} }
) { ) {
// Reply icon // Reply icon