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 на основе данных сообщения
* Аналог 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)
}
}

View File

@@ -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<String?>(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 =

View File

@@ -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

View File

@@ -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