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 ec7e0a9..ae583d5 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 @@ -194,22 +194,24 @@ fun ChatDetailScreen( var imageViewerInitialIndex by remember { mutableStateOf(0) } var imageViewerSourceBounds by remember { mutableStateOf(null) } - // 🎨 Управление статус баром - чёрный при просмотре фото + // 🎨 Управление статус баром DisposableEffect(isDarkTheme, showImageViewer) { val insetsController = window?.let { WindowCompat.getInsetsController(it, view) } if (showImageViewer) { // 📸 При просмотре фото - чёрный статус бар - window?.statusBarColor = 0xFF000000.toInt() + window?.statusBarColor = android.graphics.Color.BLACK insetsController?.isAppearanceLightStatusBars = false } else { - // Обычный режим - цвет хедера - val headerColor = if (isDarkTheme) 0xFF212121.toInt() else 0xFFFFFFFF.toInt() - window?.statusBarColor = headerColor + // Обычный режим - прозрачный статус бар, иконки по теме + window?.statusBarColor = android.graphics.Color.TRANSPARENT insetsController?.isAppearanceLightStatusBars = !isDarkTheme } - onDispose { } + onDispose { + // Восстанавливаем прозрачный статус бар при выходе + window?.statusBarColor = android.graphics.Color.TRANSPARENT + } } // 📷 Camera: URI для сохранения фото @@ -342,8 +344,14 @@ fun ChatDetailScreen( Pair>() // message, showDateHeader var lastDateString = "" + // 🔥 КРИТИЧНО: Дедупликация по ID перед сортировкой! + val uniqueMessages = messages.distinctBy { it.id } + if (uniqueMessages.size != messages.size) { + android.util.Log.e("ChatDetailScreen", "🚨 DEDUPLICATED ${messages.size - uniqueMessages.size} messages in UI! Original: ${messages.map { it.id }}") + } + // Сортируем по времени (новые -> старые) для reversed layout - val sortedMessages = messages.sortedByDescending { it.timestamp.time } + val sortedMessages = uniqueMessages.sortedByDescending { it.timestamp.time } for (i in sortedMessages.indices) { val message = sortedMessages[i] @@ -892,7 +900,8 @@ fun ChatDetailScreen( publicKey = user.publicKey, avatarRepository = avatarRepository, size = 40.dp, - isDarkTheme = isDarkTheme + isDarkTheme = isDarkTheme, + displayName = user.title.ifEmpty { user.username } // 🔥 Для инициалов ) } } 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 15e42a0..85e2011 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 @@ -258,7 +258,18 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { // Добавляем все сразу kotlinx.coroutines.withContext(Dispatchers.Main.immediate) { val currentList = _messages.value - _messages.value = (currentList + newMessages).sortedBy { it.timestamp } + val newList = (currentList + newMessages).sortedBy { it.timestamp } + + // 🔍 DEBUG: Проверка на дублирующиеся ID + val allIds = newList.map { it.id } + val duplicates = allIds.groupBy { it }.filter { it.value.size > 1 }.keys + if (duplicates.isNotEmpty()) { + android.util.Log.e("ChatViewModel", "🚨 DUPLICATE IDS FOUND in pollLatestMessages: $duplicates") + android.util.Log.e("ChatViewModel", " currentList ids: ${currentList.map { it.id }}") + android.util.Log.e("ChatViewModel", " newMessages ids: ${newMessages.map { it.id }}") + } + + _messages.value = newList } // Обновляем кэш @@ -359,6 +370,23 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { // ✅ handleIncomingMessage удалён - обработка входящих сообщений теперь ТОЛЬКО в MessageRepository // Это предотвращает дублирование сообщений + /** + * 🔥 Безопасное добавление сообщения - предотвращает дубликаты + * Возвращает true если сообщение было добавлено + */ + private fun addMessageSafely(message: ChatMessage): Boolean { + val currentMessages = _messages.value + val currentIds = currentMessages.map { it.id }.toSet() + android.util.Log.d("ChatViewModel", "🔍 addMessageSafely: id=${message.id}, currentCount=${currentMessages.size}, ids=${currentIds.take(5)}...") + if (message.id in currentIds) { + android.util.Log.e("ChatViewModel", "🚨 BLOCKED DUPLICATE: id=${message.id} already exists in ${currentIds.size} messages!") + return false + } + _messages.value = currentMessages + message + android.util.Log.d("ChatViewModel", "✅ Added message: id=${message.id}, newCount=${_messages.value.size}") + return true + } + private fun updateMessageStatus(messageId: String, status: MessageStatus) { _messages.value = _messages.value.map { msg -> if (msg.id == messageId) msg.copy(status = status) else msg @@ -565,10 +593,27 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { // 🔥 СРАЗУ обновляем UI - пользователь видит сообщения мгновенно // НО сохраняем оптимистичные сообщения (SENDING), которые ещё не в БД withContext(Dispatchers.Main.immediate) { - val optimisticMessages = _messages.value.filter { msg -> - msg.status == MessageStatus.SENDING && messages.none { it.id == msg.id } + val dbIds = messages.map { it.id }.toSet() + val currentMsgs = _messages.value + android.util.Log.d("ChatViewModel", "📥 loadMessages: dbCount=${messages.size}, currentCount=${currentMsgs.size}") + android.util.Log.d("ChatViewModel", " DB ids: ${dbIds.take(5)}...") + android.util.Log.d("ChatViewModel", " Current ids: ${currentMsgs.map { it.id }.take(5)}...") + + val optimisticMessages = currentMsgs.filter { msg -> + msg.status == MessageStatus.SENDING && msg.id !in dbIds } - _messages.value = messages + optimisticMessages + android.util.Log.d("ChatViewModel", " Optimistic (SENDING, not in DB): ${optimisticMessages.size} - ${optimisticMessages.map { it.id }}") + + val newList = messages + optimisticMessages + + // 🔍 Финальная дедупликация по ID (на всякий случай) + val deduplicatedList = newList.distinctBy { it.id } + + if (deduplicatedList.size != newList.size) { + android.util.Log.e("ChatViewModel", "🚨 DEDUPLICATED ${newList.size - deduplicatedList.size} messages!") + } + + _messages.value = deduplicatedList _isLoading.value = false } @@ -1287,7 +1332,9 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { status = MessageStatus.SENDING, replyData = replyData // Данные для reply bubble ) - _messages.value = _messages.value + optimisticMessage + + // � Безопасное добавление с проверкой дубликатов + addMessageSafely(optimisticMessage) _inputText.value = "" // 🔥 Очищаем reply после отправки - данные сохраняются в displayReplyMessages для анимации @@ -1475,7 +1522,8 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { ) ) ) - _messages.value = _messages.value + optimisticMessage + // 🔥 Безопасное добавление с проверкой дубликатов + addMessageSafely(optimisticMessage) _inputText.value = "" @@ -1531,10 +1579,6 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { ProtocolManager.send(packet) } - withContext(Dispatchers.Main) { - updateMessageStatus(messageId, MessageStatus.SENT) - } - // 💾 Сохраняем изображение в файл локально (как в desktop) AttachmentFileManager.saveAttachment( context = getApplication(), @@ -1564,10 +1608,19 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { encryptedKey = encryptedKey, timestamp = timestamp, isFromMe = true, - delivered = if (isSavedMessages) 2 else 0, + delivered = if (isSavedMessages) 2 else 0, // SENDING для обычных attachmentsJson = attachmentsJson ) + // 🔥 После успешной отправки обновляем статус на SENT (2) в БД и UI + if (!isSavedMessages) { + updateMessageStatusInDb(messageId, 2) // SENT + } + + withContext(Dispatchers.Main) { + updateMessageStatus(messageId, MessageStatus.SENT) + } + saveDialog(if (text.isNotEmpty()) text else "photo", timestamp) } catch (e: Exception) { @@ -1634,7 +1687,8 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { status = MessageStatus.SENDING, attachments = attachmentsList ) - _messages.value = _messages.value + optimisticMessage + // 🔥 Безопасное добавление с проверкой дубликатов + addMessageSafely(optimisticMessage) _inputText.value = "" @@ -1715,10 +1769,6 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { ProtocolManager.send(packet) } - withContext(Dispatchers.Main) { - updateMessageStatus(messageId, MessageStatus.SENT) - } - // Сохраняем в БД saveMessageToDatabase( messageId = messageId, @@ -1731,6 +1781,16 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { attachmentsJson = attachmentsJsonArray.toString() ) + // 🔥 Обновляем статус в БД после отправки + if (!isSavedMessages) { + updateMessageStatusInDb(messageId, 2) // SENT + } + + // Обновляем UI + withContext(Dispatchers.Main) { + updateMessageStatus(messageId, MessageStatus.SENT) + } + saveDialog(if (text.isNotEmpty()) text else "📷 ${images.size} photos", timestamp) } catch (e: Exception) { @@ -1785,7 +1845,8 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { ) ) ) - _messages.value = _messages.value + optimisticMessage + // 🔥 Безопасное добавление с проверкой дубликатов + addMessageSafely(optimisticMessage) _inputText.value = "" @@ -1838,10 +1899,6 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { ProtocolManager.send(packet) } - withContext(Dispatchers.Main) { - updateMessageStatus(messageId, MessageStatus.SENT) - } - // ⚠️ НЕ сохраняем файл локально - они слишком большие // Файлы загружаются с Transport Server при необходимости val attachmentsJson = JSONArray().apply { @@ -1853,6 +1910,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { }) }.toString() + // 🔥 Сохраняем сначала с SENDING, потом обновляем на SENT saveMessageToDatabase( messageId = messageId, text = text, @@ -1860,10 +1918,19 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { encryptedKey = encryptedKey, timestamp = timestamp, isFromMe = true, - delivered = if (isSavedMessages) 2 else 0, + delivered = if (isSavedMessages) 2 else 0, // SENDING для обычных, SENT для saved attachmentsJson = attachmentsJson ) + // 🔥 После успешной отправки обновляем статус на SENT (2) в БД и UI + if (!isSavedMessages) { + updateMessageStatusInDb(messageId, 2) // SENT + } + + withContext(Dispatchers.Main) { + updateMessageStatus(messageId, MessageStatus.SENT) + } + saveDialog(if (text.isNotEmpty()) text else "file", timestamp) } catch (e: Exception) { @@ -1973,7 +2040,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { ) ) withContext(Dispatchers.Main) { - _messages.value = _messages.value + optimisticMessage + addMessageSafely(optimisticMessage) } // 2. Шифрование текста (пустой текст для аватарки) @@ -2029,10 +2096,6 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { ProtocolManager.send(packet) } - withContext(Dispatchers.Main) { - updateMessageStatus(messageId, MessageStatus.SENT) - } - // 💾 Сохраняем аватар в файл локально (как IMAGE - с приватным ключом) AttachmentFileManager.saveAttachment( context = getApplication(), @@ -2063,6 +2126,16 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { attachmentsJson = attachmentsJson ) + // 🔥 Обновляем статус в БД после отправки + if (!isSavedMessages) { + updateMessageStatusInDb(messageId, 2) // SENT + } + + // Обновляем UI + withContext(Dispatchers.Main) { + updateMessageStatus(messageId, MessageStatus.SENT) + } + saveDialog("\$a=Avatar", timestamp) 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 8b23ecf..608f6e1 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 @@ -448,7 +448,8 @@ fun ChatsListScreen( publicKey = accountPublicKey, avatarRepository = avatarRepository, size = 66.dp, - isDarkTheme = isDarkTheme + isDarkTheme = isDarkTheme, + displayName = accountName.ifEmpty { accountUsername } // 🔥 Для инициалов ) } @@ -1247,7 +1248,8 @@ fun ChatItem( size = 56.dp, isDarkTheme = isDarkTheme, showOnlineIndicator = true, - isOnline = chat.isOnline + isOnline = chat.isOnline, + displayName = chat.name // 🔥 Для инициалов ) Spacer(modifier = Modifier.width(12.dp)) @@ -1724,11 +1726,20 @@ fun DialogItemContent( ) } } else { + // 🔥 Формируем displayName для инициалов в placeholder + val avatarDisplayName = when { + dialog.opponentTitle.isNotEmpty() && + dialog.opponentTitle != dialog.opponentKey && + !dialog.opponentTitle.startsWith(dialog.opponentKey.take(7)) -> dialog.opponentTitle + dialog.opponentUsername.isNotEmpty() -> dialog.opponentUsername + else -> null + } com.rosetta.messenger.ui.components.AvatarImage( publicKey = dialog.opponentKey, avatarRepository = avatarRepository, size = 56.dp, - isDarkTheme = isDarkTheme + isDarkTheme = isDarkTheme, + displayName = avatarDisplayName ) } diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/SearchResultsList.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/SearchResultsList.kt index 452cc76..5adb666 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/SearchResultsList.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/SearchResultsList.kt @@ -193,12 +193,22 @@ private fun SearchResultItem( modifier = Modifier.size(20.dp) ) } else { + // Приоритет: title -> username -> publicKey + val initials = when { + user.title.isNotEmpty() && + user.title != user.publicKey && + !user.title.startsWith(user.publicKey.take(7)) -> { + getInitials(user.title) + } + user.username.isNotEmpty() -> { + user.username.take(2).uppercase() + } + else -> { + user.publicKey.take(2).uppercase() + } + } Text( - text = if (user.title.isNotEmpty()) { - getInitials(user.title) - } else { - user.publicKey.take(2).uppercase() - }, + text = initials, fontSize = 15.sp, fontWeight = FontWeight.SemiBold, color = avatarColors.textColor diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/SearchScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/SearchScreen.kt index 248372a..8034081 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/SearchScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/SearchScreen.kt @@ -369,7 +369,8 @@ private fun RecentUserItem( size = 48.dp, isDarkTheme = isDarkTheme, showOnlineIndicator = false, - isOnline = false + isOnline = false, + displayName = user.title.ifEmpty { user.username } // 🔥 Для инициалов ) Spacer(modifier = Modifier.width(12.dp)) diff --git a/app/src/main/java/com/rosetta/messenger/ui/components/AppleEmojiEditText.kt b/app/src/main/java/com/rosetta/messenger/ui/components/AppleEmojiEditText.kt index 9679c0d..3778cb6 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/components/AppleEmojiEditText.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/components/AppleEmojiEditText.kt @@ -197,8 +197,19 @@ class AppleEmojiEditTextView @JvmOverloads constructor( } // Восстанавливаем курсор, убедившись что он в допустимых пределах - if (cursorPosition >= 0 && cursorPosition <= editable.length) { - post { setSelection(cursorPosition.coerceIn(0, editable.length)) } + // 🔥 Захватываем длину ДО post, т.к. text может измениться + val safePosition = cursorPosition.coerceIn(0, editable.length) + if (safePosition >= 0) { + post { + try { + val currentLength = text?.length ?: 0 + if (safePosition <= currentLength) { + setSelection(safePosition.coerceIn(0, currentLength)) + } + } catch (e: Exception) { + // Игнорируем - текст мог измениться + } + } } } finally { isUpdating = false diff --git a/app/src/main/java/com/rosetta/messenger/ui/components/AvatarImage.kt b/app/src/main/java/com/rosetta/messenger/ui/components/AvatarImage.kt index e2c0762..09d0e2d 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/components/AvatarImage.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/components/AvatarImage.kt @@ -27,6 +27,7 @@ import com.rosetta.messenger.repository.AvatarRepository import com.rosetta.messenger.ui.chats.AvatarColors import com.rosetta.messenger.ui.chats.getAvatarColor import com.rosetta.messenger.ui.chats.getAvatarText +import com.rosetta.messenger.ui.chats.getInitials import com.rosetta.messenger.utils.AvatarFileManager import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext @@ -57,7 +58,8 @@ fun AvatarImage( onClick: (() -> Unit)? = null, showOnlineIndicator: Boolean = false, isOnline: Boolean = false, - shape: Shape = CircleShape + shape: Shape = CircleShape, + displayName: String? = null // 🔥 Имя для инициалов (title/username) ) { // Получаем аватары из репозитория val avatars by avatarRepository?.getAvatars(publicKey, allDecode = false)?.collectAsState() @@ -113,7 +115,8 @@ fun AvatarImage( publicKey = publicKey, size = size, isDarkTheme = isDarkTheme, - shape = shape + shape = shape, + displayName = displayName ) } @@ -138,10 +141,16 @@ fun AvatarPlaceholder( size: Dp = 40.dp, isDarkTheme: Boolean, fontSize: TextUnit? = null, - shape: Shape = CircleShape + shape: Shape = CircleShape, + displayName: String? = null // 🔥 Имя для инициалов ) { val avatarColors = getAvatarColor(publicKey, isDarkTheme) - val avatarText = getAvatarText(publicKey) + // 🔥 Используем displayName для инициалов, если оно есть + val avatarText = if (!displayName.isNullOrEmpty() && displayName != publicKey && !displayName.startsWith(publicKey.take(7))) { + getInitials(displayName) + } else { + getAvatarText(publicKey) + } Box( modifier = Modifier diff --git a/app/src/main/java/com/rosetta/messenger/ui/settings/OtherProfileScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/settings/OtherProfileScreen.kt index cf39bd7..8bc6f10 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/settings/OtherProfileScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/settings/OtherProfileScreen.kt @@ -1,7 +1,9 @@ package com.rosetta.messenger.ui.settings +import android.app.Activity import android.util.Log import androidx.activity.compose.BackHandler +import androidx.core.view.WindowCompat import androidx.compose.animation.core.LinearOutSlowInEasing import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.tween @@ -36,6 +38,7 @@ import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.platform.LocalView import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow @@ -82,6 +85,26 @@ fun OtherProfileScreen( val secondaryTextColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666) val avatarColors = getAvatarColor(user.publicKey, isDarkTheme) val context = LocalContext.current + val view = LocalView.current + val window = remember { (view.context as? Activity)?.window } + + // 🎨 Статус бар - прозрачный с белыми иконками (поверх аватара) + DisposableEffect(Unit) { + val insetsController = window?.let { WindowCompat.getInsetsController(it, view) } + // Сохраняем оригинальные значения + val originalStatusBarColor = window?.statusBarColor ?: 0 + val originalLightStatusBars = insetsController?.isAppearanceLightStatusBars ?: false + + // Прозрачный статус бар с белыми иконками + window?.statusBarColor = android.graphics.Color.TRANSPARENT + insetsController?.isAppearanceLightStatusBars = false + + onDispose { + // Восстанавливаем при выходе + window?.statusBarColor = originalStatusBarColor + insetsController?.isAppearanceLightStatusBars = originalLightStatusBars + } + } // 🔥 Получаем тот же ChatViewModel что и в ChatDetailScreen для очистки истории val viewModel: ChatViewModel = viewModel(key = "chat_${user.publicKey}")