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 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)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,6 +43,10 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
// 🔥 Кэш расшифрованных сообщений (messageId -> plainText)
|
||||
private val decryptionCache = ConcurrentHashMap<String, String>()
|
||||
|
||||
// 🔥 Кэш сообщений на уровне диалогов (dialogKey -> List<ChatMessage>)
|
||||
// При повторном входе в тот же чат - мгновенная загрузка из кэша!
|
||||
private val dialogMessagesCache = ConcurrentHashMap<String, List<ChatMessage>>()
|
||||
|
||||
// Информация о собеседнике
|
||||
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) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 🚀 Загрузка следующей страницы (для бесконечной прокрутки)
|
||||
* <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() {
|
||||
val account = myPublicKey ?: return
|
||||
|
||||
@@ -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,6 +91,34 @@ 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<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