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 a313087..3cbb0d5 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 @@ -78,10 +78,16 @@ import java.text.SimpleDateFormat import java.util.* import kotlinx.coroutines.delay import kotlinx.coroutines.launch +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.Stable // Telegram's CubicBezier interpolator (0.199, 0.010, 0.279, 0.910) val TelegramEasing = CubicBezierEasing(0.199f, 0.010f, 0.279f, 0.910f) +// 🚀 Помечаем классы как Stable для оптимизации рекомпозиций +@Stable +class StableSearchUser(val user: SearchUser) + /** Telegram Send Icon (горизонтальный самолетик) - кастомная SVG иконка */ private val TelegramSendIcon: ImageVector get() = @@ -1381,7 +1387,7 @@ fun rememberMessageEnterAnimation(messageId: String): Pair { return Pair(alpha, translationY) } -/** 🚀 Пузырек сообщения Telegram-style */ +/** 🚀 Пузырек сообщения Telegram-style - ОПТИМИЗИРОВАННЫЙ */ @OptIn(ExperimentalFoundationApi::class) @Composable private fun MessageBubble( @@ -1408,10 +1414,7 @@ private fun MessageBubble( // Прогресс свайпа для иконки reply (0..1) - используем абсолютное значение т.к. свайп влево val swipeProgress = (kotlin.math.abs(animatedOffset) / swipeThreshold).coerceIn(0f, 1f) - // ❌ УБРАЛИ: Telegram-style enter animation - она мешает при скролле - // val (alpha, translationY) = rememberMessageEnterAnimation(message.id) - - // Selection animation + // Selection animation - только если нужно val selectionScale by animateFloatAsState( targetValue = if (isSelected) 0.95f else 1f, animationSpec = spring(dampingRatio = 0.6f, stiffness = 400f), @@ -1423,34 +1426,34 @@ private fun MessageBubble( label = "selectionAlpha" ) - // Telegram colors (как в React Native themes.ts и Архив) - val bubbleColor = - if (message.isOutgoing) { - PrimaryBlue - } else { - // Входящие: серый фон чтобы выделялись на белом фоне экрана (как в Архиве) - if (isDarkTheme) Color(0xFF212121) else Color(0xFFF5F5F5) - } - val textColor = - if (message.isOutgoing) Color.White - else { - if (isDarkTheme) Color.White else Color(0xFF000000) - } - val timeColor = - if (message.isOutgoing) Color.White.copy(alpha = 0.7f) - else { - if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666) - } + // 🔥 ОПТИМИЗАЦИЯ: Кешируем цвета - они не меняются для одного сообщения + val bubbleColor = remember(message.isOutgoing, isDarkTheme) { + if (message.isOutgoing) { + PrimaryBlue + } else { + if (isDarkTheme) Color(0xFF212121) else Color(0xFFF5F5F5) + } + } + val textColor = remember(message.isOutgoing, isDarkTheme) { + if (message.isOutgoing) Color.White + else if (isDarkTheme) Color.White else Color(0xFF000000) + } + val timeColor = remember(message.isOutgoing, isDarkTheme) { + if (message.isOutgoing) Color.White.copy(alpha = 0.7f) + else if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666) + } - // Telegram bubble shape - хвостик только у последнего сообщения в группе - val bubbleShape = - RoundedCornerShape( - topStart = 18.dp, - topEnd = 18.dp, - bottomStart = if (message.isOutgoing) 18.dp else (if (showTail) 4.dp else 18.dp), - bottomEnd = if (message.isOutgoing) (if (showTail) 4.dp else 18.dp) else 18.dp - ) + // 🔥 ОПТИМИЗАЦИЯ: Кешируем форму bubble + val bubbleShape = remember(message.isOutgoing, showTail) { + RoundedCornerShape( + topStart = 18.dp, + topEnd = 18.dp, + bottomStart = if (message.isOutgoing) 18.dp else (if (showTail) 4.dp else 18.dp), + bottomEnd = if (message.isOutgoing) (if (showTail) 4.dp else 18.dp) else 18.dp + ) + } + // 🔥 ОПТИМИЗАЦИЯ: SimpleDateFormat создаём один раз val timeFormat = remember { SimpleDateFormat("HH:mm", Locale.getDefault()) } // 🔥 Swipe-to-reply wrapper 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 632d31b..9be8893 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 @@ -397,7 +397,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { } /** - * 🚀 Оптимизированная загрузка сообщений с пагинацией + * 🚀 СУПЕР-оптимизированная загрузка сообщений */ private fun loadMessagesFromDatabase() { val account = myPublicKey ?: return @@ -408,7 +408,8 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { loadingJob = viewModelScope.launch(Dispatchers.IO) { try { - withContext(Dispatchers.Main) { + // 🔥 Сначала показываем loading НА ГЛАВНОМ потоке - мгновенно + withContext(Dispatchers.Main.immediate) { _isLoading.value = true } @@ -419,7 +420,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { val totalCount = messageDao.getMessageCount(account, dialogKey) ProtocolManager.addLog("📂 Total messages in DB: $totalCount") - // Получаем первую страницу сообщений + // 🔥 Получаем первую страницу - БЕЗ suspend задержки val entities = messageDao.getMessages(account, dialogKey, limit = PAGE_SIZE, offset = 0) ProtocolManager.addLog("📂 Loaded ${entities.size} messages from DB (offset: 0, limit: $PAGE_SIZE)") @@ -427,23 +428,26 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { hasMoreMessages = entities.size >= PAGE_SIZE currentOffset = entities.size - // 🔥 Быстрая конвертация без расшифровки (plainMessage уже есть в БД) - val messages = entities.map { entity -> - entityToChatMessage(entity) - }.reversed() + // 🔥 ОПТИМИЗАЦИЯ: Быстрая конвертация в одном проходе + val messages = ArrayList(entities.size) + for (entity in entities.asReversed()) { + messages.add(entityToChatMessage(entity)) + } - // 🔥 Отмечаем все входящие сообщения как прочитанные в БД (как в архиве) - messageDao.markDialogAsRead(account, dialogKey) - // 🔥 Очищаем счетчик непрочитанных в диалоге - dialogDao.clearUnreadCount(account, opponent) - ProtocolManager.addLog("👁️ Marked all incoming messages as read in DB, cleared unread count") - - // Обновляем UI в Main потоке - withContext(Dispatchers.Main) { + // 🔥 СРАЗУ обновляем UI - пользователь видит сообщения мгновенно + withContext(Dispatchers.Main.immediate) { _messages.value = messages _isLoading.value = false + } + + // 🔥 Фоновые операции БЕЗ блокировки UI + launch(Dispatchers.IO) { + // Отмечаем как прочитанные в БД + messageDao.markDialogAsRead(account, dialogKey) + dialogDao.clearUnreadCount(account, opponent) + ProtocolManager.addLog("👁️ Marked all incoming messages as read in DB, cleared unread count") - // 🔥 Отправляем read receipt собеседнику (как в архиве) + // Отправляем read receipt собеседнику if (messages.isNotEmpty()) { val lastIncoming = messages.lastOrNull { !it.isOutgoing } if (lastIncoming != null && lastIncoming.timestamp.time > lastReadMessageTimestamp) { @@ -457,7 +461,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { } catch (e: Exception) { ProtocolManager.addLog("❌ Error loading messages: ${e.message}") Log.e(TAG, "Error loading messages", e) - withContext(Dispatchers.Main) { + withContext(Dispatchers.Main.immediate) { _isLoading.value = false } isLoadingMessages = false diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListScreen.kt index 910a0f5..b635390 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListScreen.kt @@ -781,25 +781,29 @@ fun DrawerMenuItem( } } -/** Элемент диалога из базы данных */ +/** Элемент диалога из базы данных - ОПТИМИЗИРОВАННЫЙ */ @Composable fun DialogItem(dialog: DialogEntity, isDarkTheme: Boolean, onClick: () -> Unit) { - val textColor = if (isDarkTheme) Color.White else Color.Black - val secondaryTextColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666) - val dividerColor = if (isDarkTheme) Color(0xFF3A3A3A) else Color(0xFFE8E8E8) + // 🔥 ОПТИМИЗАЦИЯ: Кешируем цвета и строки + val textColor = remember(isDarkTheme) { if (isDarkTheme) Color.White else Color.Black } + val secondaryTextColor = remember(isDarkTheme) { if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666) } + val dividerColor = remember(isDarkTheme) { if (isDarkTheme) Color(0xFF3A3A3A) else Color(0xFFE8E8E8) } - val avatarColors = getAvatarColor(dialog.opponentKey, isDarkTheme) - val displayName = dialog.opponentTitle.ifEmpty { dialog.opponentKey.take(8) } - val initials = - if (dialog.opponentTitle.isNotEmpty()) { - dialog.opponentTitle - .split(" ") - .take(2) - .mapNotNull { it.firstOrNull()?.uppercase() } - .joinToString("") - } else { - dialog.opponentKey.take(2).uppercase() - } + val avatarColors = remember(dialog.opponentKey, isDarkTheme) { getAvatarColor(dialog.opponentKey, isDarkTheme) } + val displayName = remember(dialog.opponentTitle, dialog.opponentKey) { + dialog.opponentTitle.ifEmpty { dialog.opponentKey.take(8) } + } + val initials = remember(dialog.opponentTitle, dialog.opponentKey) { + if (dialog.opponentTitle.isNotEmpty()) { + dialog.opponentTitle + .split(" ") + .take(2) + .mapNotNull { it.firstOrNull()?.uppercase() } + .joinToString("") + } else { + dialog.opponentKey.take(2).uppercase() + } + } Column { Row(