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 на основе данных сообщения
|
* Генерация уникального 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) }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 =
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user