From b60738ce5550ce5031cdbefba329dc9b5b85396f Mon Sep 17 00:00:00 2001 From: k1ngsterr1 Date: Tue, 13 Jan 2026 18:57:08 +0500 Subject: [PATCH] feat: Implement message caching for instant loading and improved performance in chat screen --- .../messenger/ui/chats/ChatDetailScreen.kt | 102 +++++++++++++++++- .../messenger/ui/chats/ChatViewModel.kt | 91 ++++++++++++++-- .../messenger/ui/chats/ChatsListViewModel.kt | 30 ++++++ 3 files changed, 209 insertions(+), 14 deletions(-) diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt index 421b516..161c8d1 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt @@ -327,6 +327,7 @@ fun ChatDetailScreen( val inputText by viewModel.inputText.collectAsState() val isTyping by viewModel.opponentTyping.collectAsState() val isOnline by viewModel.opponentOnline.collectAsState() + val isLoading by viewModel.isLoading.collectAsState() // 🔥 Для скелетона // 🔥 Reply/Forward state (replyMessages и hasReply определены выше для listBottomPadding) val isForwardMode by viewModel.isForwardMode.collectAsState() @@ -762,8 +763,16 @@ fun ChatDetailScreen( ) { // Список сообщений - динамический padding для клавиатуры/эмодзи Box(modifier = Modifier.fillMaxSize().padding(bottom = listBottomPadding)) { - if (messages.isEmpty()) { - // Пустое состояние + when { + // 🔥 СКЕЛЕТОН - показываем пока загружаются сообщения + isLoading -> { + MessageSkeletonList( + isDarkTheme = isDarkTheme, + modifier = Modifier.fillMaxSize() + ) + } + // Пустое состояние (нет сообщений) + messages.isEmpty() -> { Column( modifier = Modifier.fillMaxSize().padding(32.dp), horizontalAlignment = Alignment.CenterHorizontally, @@ -808,8 +817,9 @@ fun ChatDetailScreen( color = secondaryTextColor.copy(alpha = 0.7f) ) } - } else { - LazyColumn( + } + // Есть сообщения + else -> LazyColumn( state = listState, modifier = 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) + ) + } +} diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatViewModel.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatViewModel.kt index 5af7c15..51b5a49 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatViewModel.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatViewModel.kt @@ -43,6 +43,10 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { // 🔥 Кэш расшифрованных сообщений (messageId -> plainText) private val decryptionCache = ConcurrentHashMap() + // 🔥 Кэш сообщений на уровне диалогов (dialogKey -> List) + // При повторном входе в тот же чат - мгновенная загрузка из кэша! + private val dialogMessagesCache = ConcurrentHashMap>() + // Информация о собеседнике private var opponentTitle: String = "" private var opponentUsername: String = "" @@ -412,29 +416,42 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { /** * 🚀 СУПЕР-оптимизированная загрузка сообщений - * 🔥 ОПТИМИЗАЦИЯ: Задержка для завершения анимации + чанковая расшифровка + * 🔥 ОПТИМИЗАЦИЯ: Кэш на уровне диалога + задержка для анимации + чанковая расшифровка */ private fun loadMessagesFromDatabase(delayMs: Long = 0L) { val account = myPublicKey ?: return val opponent = opponentKey ?: return + val dialogKey = getDialogKey(account, opponent) if (isLoadingMessages) return isLoadingMessages = true loadingJob = viewModelScope.launch(Dispatchers.IO) { 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) { + withContext(Dispatchers.Main.immediate) { + _isLoading.value = true // Показываем скелетон + } delay(delayMs) } - // 🔥 Сначала показываем loading НА ГЛАВНОМ потоке - мгновенно - withContext(Dispatchers.Main.immediate) { - _isLoading.value = true - } - - val dialogKey = getDialogKey(account, opponent) 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") + // 🔥 Сохраняем в кэш для мгновенной повторной загрузки! + dialogMessagesCache[dialogKey] = messages.toList() + ProtocolManager.addLog("💾 Cached ${messages.size} messages for dialog $dialogKey") + // 🔥 СРАЗУ обновляем UI - пользователь видит сообщения мгновенно // НО сохраняем оптимистичные сообщения (SENDING), которые ещё не в БД withContext(Dispatchers.Main.immediate) { @@ -505,7 +526,57 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { } /** - * 🚀 Загрузка следующей страницы (для бесконечной прокрутки) + * � Фоновое обновление сообщений из БД (проверка новых) + * Вызывается когда кэш уже отображён, но нужно проверить есть ли новые сообщения + */ + private suspend fun refreshMessagesFromDb( + account: String, + opponent: String, + dialogKey: String, + cachedMessages: List + ) { + try { + val entities = messageDao.getMessages(account, dialogKey, limit = PAGE_SIZE, offset = 0) + + // Если в БД есть новые сообщения + if (entities.size != cachedMessages.size) { + val messages = ArrayList(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 + } + + /** + * �🚀 Загрузка следующей страницы (для бесконечной прокрутки) */ fun loadMoreMessages() { val account = myPublicKey ?: return diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListViewModel.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListViewModel.kt index e894b42..1f41abd 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListViewModel.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListViewModel.kt @@ -6,8 +6,10 @@ import androidx.lifecycle.viewModelScope import com.rosetta.messenger.crypto.CryptoManager import com.rosetta.messenger.database.DialogEntity import com.rosetta.messenger.database.RosettaDatabase +import com.rosetta.messenger.network.PacketOnlineSubscribe import com.rosetta.messenger.network.ProtocolManager import com.rosetta.messenger.network.SearchUser +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch @@ -89,10 +91,38 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio _dialogs.value = decryptedDialogs ProtocolManager.addLog("📋 Dialogs loaded: ${decryptedDialogs.size} (lastMessages decrypted)") + + // 🟢 Подписываемся на онлайн-статусы всех собеседников + subscribeToOnlineStatuses(dialogsList.map { it.opponentKey }, privateKey) } } } + /** + * 🟢 Подписаться на онлайн-статусы всех собеседников + */ + private fun subscribeToOnlineStatuses(opponentKeys: List, 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}") + } + } + } + /** * Создать или обновить диалог после отправки/получения сообщения */