feat: Implement message caching for instant loading and improved performance in chat screen

This commit is contained in:
k1ngsterr1
2026-01-13 18:57:08 +05:00
parent 4881024a9c
commit b60738ce55
3 changed files with 209 additions and 14 deletions

View File

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

View File

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

View File

@@ -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}")
}
}
}
/** /**
* Создать или обновить диалог после отправки/получения сообщения * Создать или обновить диалог после отправки/получения сообщения
*/ */