feat: Implement message caching for instant loading and improved performance in chat screen
This commit is contained in:
@@ -327,6 +327,7 @@ fun ChatDetailScreen(
|
|||||||
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 isOnline by viewModel.opponentOnline.collectAsState()
|
val isOnline by viewModel.opponentOnline.collectAsState()
|
||||||
|
val isLoading by viewModel.isLoading.collectAsState() // 🔥 Для скелетона
|
||||||
|
|
||||||
// 🔥 Reply/Forward state (replyMessages и hasReply определены выше для listBottomPadding)
|
// 🔥 Reply/Forward state (replyMessages и hasReply определены выше для listBottomPadding)
|
||||||
val isForwardMode by viewModel.isForwardMode.collectAsState()
|
val isForwardMode by viewModel.isForwardMode.collectAsState()
|
||||||
@@ -762,8 +763,16 @@ fun ChatDetailScreen(
|
|||||||
) {
|
) {
|
||||||
// Список сообщений - динамический padding для клавиатуры/эмодзи
|
// Список сообщений - динамический padding для клавиатуры/эмодзи
|
||||||
Box(modifier = Modifier.fillMaxSize().padding(bottom = listBottomPadding)) {
|
Box(modifier = Modifier.fillMaxSize().padding(bottom = listBottomPadding)) {
|
||||||
if (messages.isEmpty()) {
|
when {
|
||||||
// Пустое состояние
|
// 🔥 СКЕЛЕТОН - показываем пока загружаются сообщения
|
||||||
|
isLoading -> {
|
||||||
|
MessageSkeletonList(
|
||||||
|
isDarkTheme = isDarkTheme,
|
||||||
|
modifier = Modifier.fillMaxSize()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// Пустое состояние (нет сообщений)
|
||||||
|
messages.isEmpty() -> {
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier.fillMaxSize().padding(32.dp),
|
modifier = Modifier.fillMaxSize().padding(32.dp),
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
@@ -808,8 +817,9 @@ fun ChatDetailScreen(
|
|||||||
color = secondaryTextColor.copy(alpha = 0.7f)
|
color = secondaryTextColor.copy(alpha = 0.7f)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
} else {
|
}
|
||||||
LazyColumn(
|
// Есть сообщения
|
||||||
|
else -> LazyColumn(
|
||||||
state = listState,
|
state = listState,
|
||||||
modifier =
|
modifier =
|
||||||
Modifier.fillMaxSize()
|
Modifier.fillMaxSize()
|
||||||
@@ -2253,3 +2263,87 @@ fun TypingIndicator(isDarkTheme: Boolean) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 🔥 СКЕЛЕТОН для загрузки сообщений
|
||||||
|
* Показывает анимированные плейсхолдеры пока загружаются сообщения
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun MessageSkeletonList(
|
||||||
|
isDarkTheme: Boolean,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
val shimmerColor = if (isDarkTheme) Color(0xFF2A2A2A) else Color(0xFFE8E8E8)
|
||||||
|
val shimmerHighlight = if (isDarkTheme) Color(0xFF3A3A3A) else Color(0xFFF5F5F5)
|
||||||
|
|
||||||
|
// Анимация shimmer
|
||||||
|
val infiniteTransition = rememberInfiniteTransition(label = "shimmer")
|
||||||
|
val shimmerProgress by infiniteTransition.animateFloat(
|
||||||
|
initialValue = 0f,
|
||||||
|
targetValue = 1f,
|
||||||
|
animationSpec = infiniteRepeatable(
|
||||||
|
animation = tween(1200, easing = LinearEasing),
|
||||||
|
repeatMode = RepeatMode.Restart
|
||||||
|
),
|
||||||
|
label = "shimmer"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Градиент для shimmer эффекта
|
||||||
|
val shimmerBrush = Brush.horizontalGradient(
|
||||||
|
colors = listOf(
|
||||||
|
shimmerColor,
|
||||||
|
shimmerHighlight,
|
||||||
|
shimmerColor
|
||||||
|
),
|
||||||
|
startX = shimmerProgress * 1000f - 500f,
|
||||||
|
endX = shimmerProgress * 1000f + 500f
|
||||||
|
)
|
||||||
|
|
||||||
|
// 🔥 Box с выравниванием внизу - как настоящий чат
|
||||||
|
Box(modifier = modifier) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.BottomCenter)
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 8.dp, vertical = 8.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(4.dp)
|
||||||
|
) {
|
||||||
|
// Паттерн сообщений снизу вверх (как в реальном чате) - короткие пузырьки
|
||||||
|
SkeletonBubble(isOutgoing = true, widthFraction = 0.25f, brush = shimmerBrush, isDarkTheme = isDarkTheme)
|
||||||
|
SkeletonBubble(isOutgoing = false, widthFraction = 0.35f, brush = shimmerBrush, isDarkTheme = isDarkTheme)
|
||||||
|
SkeletonBubble(isOutgoing = true, widthFraction = 0.30f, brush = shimmerBrush, isDarkTheme = isDarkTheme)
|
||||||
|
SkeletonBubble(isOutgoing = false, widthFraction = 0.28f, brush = shimmerBrush, isDarkTheme = isDarkTheme)
|
||||||
|
SkeletonBubble(isOutgoing = true, widthFraction = 0.40f, brush = shimmerBrush, isDarkTheme = isDarkTheme)
|
||||||
|
SkeletonBubble(isOutgoing = false, widthFraction = 0.32f, brush = shimmerBrush, isDarkTheme = isDarkTheme)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Пузырёк-скелетон сообщения (как настоящий bubble)
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun SkeletonBubble(
|
||||||
|
isOutgoing: Boolean,
|
||||||
|
widthFraction: Float,
|
||||||
|
brush: Brush,
|
||||||
|
isDarkTheme: Boolean
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = if (isOutgoing) Arrangement.End else Arrangement.Start
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth(widthFraction)
|
||||||
|
.height(34.dp) // Фиксированная высота как у реального пузырька
|
||||||
|
.clip(RoundedCornerShape(
|
||||||
|
topStart = 18.dp,
|
||||||
|
topEnd = 18.dp,
|
||||||
|
bottomStart = if (isOutgoing) 18.dp else 4.dp,
|
||||||
|
bottomEnd = if (isOutgoing) 4.dp else 18.dp
|
||||||
|
))
|
||||||
|
.background(brush)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -43,6 +43,10 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
// 🔥 Кэш расшифрованных сообщений (messageId -> plainText)
|
// 🔥 Кэш расшифрованных сообщений (messageId -> plainText)
|
||||||
private val decryptionCache = ConcurrentHashMap<String, String>()
|
private val decryptionCache = ConcurrentHashMap<String, String>()
|
||||||
|
|
||||||
|
// 🔥 Кэш сообщений на уровне диалогов (dialogKey -> List<ChatMessage>)
|
||||||
|
// При повторном входе в тот же чат - мгновенная загрузка из кэша!
|
||||||
|
private val dialogMessagesCache = ConcurrentHashMap<String, List<ChatMessage>>()
|
||||||
|
|
||||||
// Информация о собеседнике
|
// Информация о собеседнике
|
||||||
private var opponentTitle: String = ""
|
private var opponentTitle: String = ""
|
||||||
private var opponentUsername: String = ""
|
private var opponentUsername: String = ""
|
||||||
@@ -412,29 +416,42 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 🚀 СУПЕР-оптимизированная загрузка сообщений
|
* 🚀 СУПЕР-оптимизированная загрузка сообщений
|
||||||
* 🔥 ОПТИМИЗАЦИЯ: Задержка для завершения анимации + чанковая расшифровка
|
* 🔥 ОПТИМИЗАЦИЯ: Кэш на уровне диалога + задержка для анимации + чанковая расшифровка
|
||||||
*/
|
*/
|
||||||
private fun loadMessagesFromDatabase(delayMs: Long = 0L) {
|
private fun loadMessagesFromDatabase(delayMs: Long = 0L) {
|
||||||
val account = myPublicKey ?: return
|
val account = myPublicKey ?: return
|
||||||
val opponent = opponentKey ?: return
|
val opponent = opponentKey ?: return
|
||||||
|
val dialogKey = getDialogKey(account, opponent)
|
||||||
|
|
||||||
if (isLoadingMessages) return
|
if (isLoadingMessages) return
|
||||||
isLoadingMessages = true
|
isLoadingMessages = true
|
||||||
|
|
||||||
loadingJob = viewModelScope.launch(Dispatchers.IO) {
|
loadingJob = viewModelScope.launch(Dispatchers.IO) {
|
||||||
try {
|
try {
|
||||||
// 🔥 Задержка перед загрузкой чтобы анимация перехода успела завершиться!
|
// 🔥 МГНОВЕННАЯ загрузка из кэша если есть!
|
||||||
// Это критично для плавности - иначе расшифровка блокирует UI thread
|
val cachedMessages = dialogMessagesCache[dialogKey]
|
||||||
|
if (cachedMessages != null && cachedMessages.isNotEmpty()) {
|
||||||
|
ProtocolManager.addLog("⚡ Loading ${cachedMessages.size} messages from CACHE (instant!)")
|
||||||
|
withContext(Dispatchers.Main.immediate) {
|
||||||
|
_messages.value = cachedMessages
|
||||||
|
_isLoading.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Фоновое обновление из БД (новые сообщения)
|
||||||
|
delay(100) // Небольшая задержка чтобы UI успел отрисоваться
|
||||||
|
refreshMessagesFromDb(account, opponent, dialogKey, cachedMessages)
|
||||||
|
isLoadingMessages = false
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🔥 Нет кэша - показываем скелетон и загружаем с задержкой для анимации
|
||||||
if (delayMs > 0) {
|
if (delayMs > 0) {
|
||||||
|
withContext(Dispatchers.Main.immediate) {
|
||||||
|
_isLoading.value = true // Показываем скелетон
|
||||||
|
}
|
||||||
delay(delayMs)
|
delay(delayMs)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 🔥 Сначала показываем loading НА ГЛАВНОМ потоке - мгновенно
|
|
||||||
withContext(Dispatchers.Main.immediate) {
|
|
||||||
_isLoading.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
val dialogKey = getDialogKey(account, opponent)
|
|
||||||
ProtocolManager.addLog("📂 Loading messages from DB for dialog: $dialogKey")
|
ProtocolManager.addLog("📂 Loading messages from DB for dialog: $dialogKey")
|
||||||
|
|
||||||
// 🔍 Проверяем общее количество сообщений в диалоге
|
// 🔍 Проверяем общее количество сообщений в диалоге
|
||||||
@@ -465,6 +482,10 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
|
|
||||||
ProtocolManager.addLog("📋 Decrypted and loaded ${messages.size} messages from DB")
|
ProtocolManager.addLog("📋 Decrypted and loaded ${messages.size} messages from DB")
|
||||||
|
|
||||||
|
// 🔥 Сохраняем в кэш для мгновенной повторной загрузки!
|
||||||
|
dialogMessagesCache[dialogKey] = messages.toList()
|
||||||
|
ProtocolManager.addLog("💾 Cached ${messages.size} messages for dialog $dialogKey")
|
||||||
|
|
||||||
// 🔥 СРАЗУ обновляем UI - пользователь видит сообщения мгновенно
|
// 🔥 СРАЗУ обновляем UI - пользователь видит сообщения мгновенно
|
||||||
// НО сохраняем оптимистичные сообщения (SENDING), которые ещё не в БД
|
// НО сохраняем оптимистичные сообщения (SENDING), которые ещё не в БД
|
||||||
withContext(Dispatchers.Main.immediate) {
|
withContext(Dispatchers.Main.immediate) {
|
||||||
@@ -505,7 +526,57 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 🚀 Загрузка следующей страницы (для бесконечной прокрутки)
|
* <EFBFBD> Фоновое обновление сообщений из БД (проверка новых)
|
||||||
|
* Вызывается когда кэш уже отображён, но нужно проверить есть ли новые сообщения
|
||||||
|
*/
|
||||||
|
private suspend fun refreshMessagesFromDb(
|
||||||
|
account: String,
|
||||||
|
opponent: String,
|
||||||
|
dialogKey: String,
|
||||||
|
cachedMessages: List<ChatMessage>
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
val entities = messageDao.getMessages(account, dialogKey, limit = PAGE_SIZE, offset = 0)
|
||||||
|
|
||||||
|
// Если в БД есть новые сообщения
|
||||||
|
if (entities.size != cachedMessages.size) {
|
||||||
|
val messages = ArrayList<ChatMessage>(entities.size)
|
||||||
|
for (entity in entities.asReversed()) {
|
||||||
|
messages.add(entityToChatMessage(entity))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Обновляем кэш и UI
|
||||||
|
dialogMessagesCache[dialogKey] = messages.toList()
|
||||||
|
withContext(Dispatchers.Main.immediate) {
|
||||||
|
_messages.value = messages
|
||||||
|
}
|
||||||
|
ProtocolManager.addLog("🔄 Refreshed: found ${messages.size - cachedMessages.size} new messages")
|
||||||
|
}
|
||||||
|
|
||||||
|
hasMoreMessages = entities.size >= PAGE_SIZE
|
||||||
|
currentOffset = entities.size
|
||||||
|
|
||||||
|
// Фоновые операции
|
||||||
|
messageDao.markDialogAsRead(account, dialogKey)
|
||||||
|
dialogDao.clearUnreadCount(account, opponent)
|
||||||
|
|
||||||
|
} catch (e: Exception) {
|
||||||
|
ProtocolManager.addLog("❌ Error refreshing messages: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 🔥 Обновить кэш после отправки/получения сообщения
|
||||||
|
*/
|
||||||
|
private fun updateDialogCache(dialogKey: String, newMessage: ChatMessage) {
|
||||||
|
val current = dialogMessagesCache[dialogKey]?.toMutableList() ?: mutableListOf()
|
||||||
|
// Добавляем в конец (новые сообщения)
|
||||||
|
current.add(newMessage)
|
||||||
|
dialogMessagesCache[dialogKey] = current
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* <20>🚀 Загрузка следующей страницы (для бесконечной прокрутки)
|
||||||
*/
|
*/
|
||||||
fun loadMoreMessages() {
|
fun loadMoreMessages() {
|
||||||
val account = myPublicKey ?: return
|
val account = myPublicKey ?: return
|
||||||
|
|||||||
@@ -6,8 +6,10 @@ import androidx.lifecycle.viewModelScope
|
|||||||
import com.rosetta.messenger.crypto.CryptoManager
|
import com.rosetta.messenger.crypto.CryptoManager
|
||||||
import com.rosetta.messenger.database.DialogEntity
|
import com.rosetta.messenger.database.DialogEntity
|
||||||
import com.rosetta.messenger.database.RosettaDatabase
|
import com.rosetta.messenger.database.RosettaDatabase
|
||||||
|
import com.rosetta.messenger.network.PacketOnlineSubscribe
|
||||||
import com.rosetta.messenger.network.ProtocolManager
|
import com.rosetta.messenger.network.ProtocolManager
|
||||||
import com.rosetta.messenger.network.SearchUser
|
import com.rosetta.messenger.network.SearchUser
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.flow.*
|
import kotlinx.coroutines.flow.*
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
@@ -89,10 +91,38 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
|
|||||||
|
|
||||||
_dialogs.value = decryptedDialogs
|
_dialogs.value = decryptedDialogs
|
||||||
ProtocolManager.addLog("📋 Dialogs loaded: ${decryptedDialogs.size} (lastMessages decrypted)")
|
ProtocolManager.addLog("📋 Dialogs loaded: ${decryptedDialogs.size} (lastMessages decrypted)")
|
||||||
|
|
||||||
|
// 🟢 Подписываемся на онлайн-статусы всех собеседников
|
||||||
|
subscribeToOnlineStatuses(dialogsList.map { it.opponentKey }, privateKey)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 🟢 Подписаться на онлайн-статусы всех собеседников
|
||||||
|
*/
|
||||||
|
private fun subscribeToOnlineStatuses(opponentKeys: List<String>, privateKey: String) {
|
||||||
|
if (opponentKeys.isEmpty()) return
|
||||||
|
|
||||||
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
|
try {
|
||||||
|
val privateKeyHash = CryptoManager.generatePrivateKeyHash(privateKey)
|
||||||
|
|
||||||
|
val packet = PacketOnlineSubscribe().apply {
|
||||||
|
this.privateKey = privateKeyHash
|
||||||
|
opponentKeys.forEach { key ->
|
||||||
|
addPublicKey(key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ProtocolManager.send(packet)
|
||||||
|
ProtocolManager.addLog("🟢 Subscribed to ${opponentKeys.size} online statuses")
|
||||||
|
} catch (e: Exception) {
|
||||||
|
ProtocolManager.addLog("❌ Online subscribe error: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Создать или обновить диалог после отправки/получения сообщения
|
* Создать или обновить диалог после отправки/получения сообщения
|
||||||
*/
|
*/
|
||||||
|
|||||||
Reference in New Issue
Block a user