fix: improve message ID generation and enhance pagination handling in chat screen
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user