diff --git a/app/src/main/java/com/rosetta/messenger/network/FileDownloadManager.kt b/app/src/main/java/com/rosetta/messenger/network/FileDownloadManager.kt index 13bb6f7..ca873fd 100644 --- a/app/src/main/java/com/rosetta/messenger/network/FileDownloadManager.kt +++ b/app/src/main/java/com/rosetta/messenger/network/FileDownloadManager.kt @@ -12,7 +12,9 @@ data class FileDownloadState( val fileName: String, val status: FileDownloadStatus, /** 0f..1f */ - val progress: Float = 0f + val progress: Float = 0f, + val accountPublicKey: String = "", + val savedPath: String = "" ) enum class FileDownloadStatus { @@ -84,17 +86,27 @@ object FileDownloadManager { downloadTag: String, chachaKey: String, privateKey: String, + accountPublicKey: String, fileName: String, savedFile: File ) { // Уже в процессе? if (jobs[attachmentId]?.isActive == true) return + val normalizedAccount = accountPublicKey.trim() + val savedPath = savedFile.absolutePath - update(attachmentId, fileName, FileDownloadStatus.QUEUED, 0f) + update(attachmentId, fileName, FileDownloadStatus.QUEUED, 0f, normalizedAccount, savedPath) jobs[attachmentId] = scope.launch { try { - update(attachmentId, fileName, FileDownloadStatus.DOWNLOADING, 0f) + update( + attachmentId, + fileName, + FileDownloadStatus.DOWNLOADING, + 0f, + normalizedAccount, + savedPath + ) // Запускаем polling прогресса из TransportManager val progressJob = launch { @@ -103,34 +115,87 @@ object FileDownloadManager { if (entry != null) { // CDN progress: 0-100 → 0f..0.8f (80% круга — скачивание) val p = (entry.progress / 100f) * 0.8f - update(attachmentId, fileName, FileDownloadStatus.DOWNLOADING, p) + update( + attachmentId, + fileName, + FileDownloadStatus.DOWNLOADING, + p, + normalizedAccount, + savedPath + ) } } } val success = withContext(Dispatchers.IO) { if (isGroupStoredKey(chachaKey)) { - downloadGroupFile(attachmentId, downloadTag, chachaKey, privateKey, fileName, savedFile) + downloadGroupFile( + attachmentId = attachmentId, + downloadTag = downloadTag, + chachaKey = chachaKey, + privateKey = privateKey, + fileName = fileName, + savedFile = savedFile, + accountPublicKey = normalizedAccount, + savedPath = savedPath + ) } else { - downloadDirectFile(attachmentId, downloadTag, chachaKey, privateKey, fileName, savedFile) + downloadDirectFile( + attachmentId = attachmentId, + downloadTag = downloadTag, + chachaKey = chachaKey, + privateKey = privateKey, + fileName = fileName, + savedFile = savedFile, + accountPublicKey = normalizedAccount, + savedPath = savedPath + ) } } progressJob.cancel() if (success) { - update(attachmentId, fileName, FileDownloadStatus.DONE, 1f) + update( + attachmentId, + fileName, + FileDownloadStatus.DONE, + 1f, + normalizedAccount, + savedPath + ) } else { - update(attachmentId, fileName, FileDownloadStatus.ERROR, 0f) + update( + attachmentId, + fileName, + FileDownloadStatus.ERROR, + 0f, + normalizedAccount, + savedPath + ) } } catch (e: CancellationException) { throw e } catch (e: Exception) { e.printStackTrace() - update(attachmentId, fileName, FileDownloadStatus.ERROR, 0f) + update( + attachmentId, + fileName, + FileDownloadStatus.ERROR, + 0f, + normalizedAccount, + savedPath + ) } catch (_: OutOfMemoryError) { System.gc() - update(attachmentId, fileName, FileDownloadStatus.ERROR, 0f) + update( + attachmentId, + fileName, + FileDownloadStatus.ERROR, + 0f, + normalizedAccount, + savedPath + ) } finally { jobs.remove(attachmentId) // Автоочистка через 5 секунд после завершения @@ -159,25 +224,55 @@ object FileDownloadManager { chachaKey: String, privateKey: String, fileName: String, - savedFile: File + savedFile: File, + accountPublicKey: String, + savedPath: String ): Boolean { val encryptedContent = TransportManager.downloadFile(attachmentId, downloadTag) - update(attachmentId, fileName, FileDownloadStatus.DECRYPTING, 0.82f) + update( + attachmentId, + fileName, + FileDownloadStatus.DECRYPTING, + 0.82f, + accountPublicKey, + savedPath + ) val groupPassword = decodeGroupPassword(chachaKey, privateKey) if (groupPassword.isNullOrBlank()) return false val decrypted = CryptoManager.decryptWithPassword(encryptedContent, groupPassword) - update(attachmentId, fileName, FileDownloadStatus.DECRYPTING, 0.88f) + update( + attachmentId, + fileName, + FileDownloadStatus.DECRYPTING, + 0.88f, + accountPublicKey, + savedPath + ) val bytes = decrypted?.let { decodeBase64Payload(it) } ?: return false - update(attachmentId, fileName, FileDownloadStatus.DECRYPTING, 0.93f) + update( + attachmentId, + fileName, + FileDownloadStatus.DECRYPTING, + 0.93f, + accountPublicKey, + savedPath + ) withContext(Dispatchers.IO) { savedFile.parentFile?.mkdirs() savedFile.writeBytes(bytes) } - update(attachmentId, fileName, FileDownloadStatus.DECRYPTING, 0.98f) + update( + attachmentId, + fileName, + FileDownloadStatus.DECRYPTING, + 0.98f, + accountPublicKey, + savedPath + ) return true } @@ -187,14 +282,30 @@ object FileDownloadManager { chachaKey: String, privateKey: String, fileName: String, - savedFile: File + savedFile: File, + accountPublicKey: String, + savedPath: String ): Boolean { // Streaming: скачиваем во temp file val tempFile = TransportManager.downloadFileRaw(attachmentId, downloadTag) - update(attachmentId, fileName, FileDownloadStatus.DECRYPTING, 0.83f) + update( + attachmentId, + fileName, + FileDownloadStatus.DECRYPTING, + 0.83f, + accountPublicKey, + savedPath + ) val decryptedKeyAndNonce = MessageCrypto.decryptKeyFromSender(chachaKey, privateKey) - update(attachmentId, fileName, FileDownloadStatus.DECRYPTING, 0.88f) + update( + attachmentId, + fileName, + FileDownloadStatus.DECRYPTING, + 0.88f, + accountPublicKey, + savedPath + ) // Streaming decrypt: tempFile → AES → inflate → base64 → savedFile withContext(Dispatchers.IO) { @@ -208,13 +319,36 @@ object FileDownloadManager { tempFile.delete() } } - update(attachmentId, fileName, FileDownloadStatus.DECRYPTING, 0.98f) + update( + attachmentId, + fileName, + FileDownloadStatus.DECRYPTING, + 0.98f, + accountPublicKey, + savedPath + ) return true } - private fun update(id: String, fileName: String, status: FileDownloadStatus, progress: Float) { + private fun update( + id: String, + fileName: String, + status: FileDownloadStatus, + progress: Float, + accountPublicKey: String, + savedPath: String + ) { _downloads.update { map -> - map + (id to FileDownloadState(id, fileName, status, progress)) + map + ( + id to FileDownloadState( + attachmentId = id, + fileName = fileName, + status = status, + progress = progress, + accountPublicKey = accountPublicKey, + savedPath = savedPath + ) + ) } } } diff --git a/app/src/main/java/com/rosetta/messenger/network/ProtocolManager.kt b/app/src/main/java/com/rosetta/messenger/network/ProtocolManager.kt index 0f5d0db..46a7e62 100644 --- a/app/src/main/java/com/rosetta/messenger/network/ProtocolManager.kt +++ b/app/src/main/java/com/rosetta/messenger/network/ProtocolManager.kt @@ -29,6 +29,7 @@ object ProtocolManager { private const val MANUAL_SYNC_BACKTRACK_MS = 120_000L private const val MAX_DEBUG_LOGS = 600 private const val DEBUG_LOG_FLUSH_DELAY_MS = 60L + private const val TYPING_INDICATOR_TIMEOUT_MS = 3_000L // Server address - same as React Native version private const val SERVER_ADDRESS = "ws://46.28.71.12:3000" @@ -59,6 +60,9 @@ object ProtocolManager { // Typing status private val _typingUsers = MutableStateFlow>(emptySet()) val typingUsers: StateFlow> = _typingUsers.asStateFlow() + private val typingStateLock = Any() + private val typingUsersByDialog = mutableMapOf>() + private val typingTimeoutJobs = ConcurrentHashMap() // Connected devices and pending verification requests private val _devices = MutableStateFlow>(emptyList()) @@ -200,6 +204,7 @@ object ProtocolManager { */ fun initializeAccount(publicKey: String, privateKey: String) { setSyncInProgress(false) + clearTypingState() messageRepository?.initialize(publicKey, privateKey) if (resyncRequiredAfterAccountInit || protocol?.isAuthenticated() == true) { resyncRequiredAfterAccountInit = false @@ -369,13 +374,26 @@ object ProtocolManager { // Обработчик typing (0x0B) waitPacket(0x0B) { packet -> val typingPacket = packet as PacketTyping - - // Добавляем в set и удаляем через 3 секунды - _typingUsers.value = _typingUsers.value + typingPacket.fromPublicKey - scope.launch { - delay(3000) - _typingUsers.value = _typingUsers.value - typingPacket.fromPublicKey + val fromPublicKey = typingPacket.fromPublicKey.trim() + val toPublicKey = typingPacket.toPublicKey.trim() + if (fromPublicKey.isBlank() || toPublicKey.isBlank()) return@waitPacket + + val ownPublicKey = + getProtocol().getPublicKey()?.trim().orEmpty().ifBlank { + messageRepository?.getCurrentAccountKey()?.trim().orEmpty() + } + if (ownPublicKey.isNotBlank() && fromPublicKey.equals(ownPublicKey, ignoreCase = true)) { + return@waitPacket } + + val dialogKey = + resolveTypingDialogKey( + fromPublicKey = fromPublicKey, + toPublicKey = toPublicKey, + ownPublicKey = ownPublicKey + ) ?: return@waitPacket + + rememberTypingEvent(dialogKey, fromPublicKey) } // 📱 Обработчик списка устройств (0x17) @@ -508,6 +526,71 @@ object ProtocolManager { return normalized.startsWith("#group:") || normalized.startsWith("group:") } + private fun normalizeGroupDialogKey(value: String): String { + val trimmed = value.trim() + val normalized = trimmed.lowercase(Locale.ROOT) + return when { + normalized.startsWith("#group:") -> "#group:${trimmed.substringAfter(':').trim()}" + normalized.startsWith("group:") -> "#group:${trimmed.substringAfter(':').trim()}" + else -> trimmed + } + } + + private fun resolveTypingDialogKey( + fromPublicKey: String, + toPublicKey: String, + ownPublicKey: String + ): String? { + return when { + isGroupDialogKey(toPublicKey) -> normalizeGroupDialogKey(toPublicKey) + ownPublicKey.isNotBlank() && toPublicKey.equals(ownPublicKey, ignoreCase = true) -> + fromPublicKey.trim() + else -> null + } + } + + private fun makeTypingTimeoutKey(dialogKey: String, fromPublicKey: String): String { + return "${dialogKey.lowercase(Locale.ROOT)}|${fromPublicKey.lowercase(Locale.ROOT)}" + } + + private fun rememberTypingEvent(dialogKey: String, fromPublicKey: String) { + val normalizedDialogKey = + if (isGroupDialogKey(dialogKey)) normalizeGroupDialogKey(dialogKey) else dialogKey.trim() + val normalizedFrom = fromPublicKey.trim() + if (normalizedDialogKey.isBlank() || normalizedFrom.isBlank()) return + + synchronized(typingStateLock) { + val users = typingUsersByDialog.getOrPut(normalizedDialogKey) { mutableSetOf() } + users.add(normalizedFrom) + _typingUsers.value = typingUsersByDialog.keys.toSet() + } + + val timeoutKey = makeTypingTimeoutKey(normalizedDialogKey, normalizedFrom) + typingTimeoutJobs.remove(timeoutKey)?.cancel() + typingTimeoutJobs[timeoutKey] = + scope.launch { + delay(TYPING_INDICATOR_TIMEOUT_MS) + synchronized(typingStateLock) { + val users = typingUsersByDialog[normalizedDialogKey] + users?.remove(normalizedFrom) + if (users.isNullOrEmpty()) { + typingUsersByDialog.remove(normalizedDialogKey) + } + _typingUsers.value = typingUsersByDialog.keys.toSet() + } + typingTimeoutJobs.remove(timeoutKey) + } + } + + private fun clearTypingState() { + typingTimeoutJobs.values.forEach { it.cancel() } + typingTimeoutJobs.clear() + synchronized(typingStateLock) { + typingUsersByDialog.clear() + _typingUsers.value = emptySet() + } + } + private fun onAuthenticated() { setSyncInProgress(false) TransportManager.requestTransportServer() @@ -1021,6 +1104,7 @@ object ProtocolManager { protocol?.disconnect() protocol?.clearCredentials() messageRepository?.clearInitialization() + clearTypingState() _devices.value = emptyList() _pendingDeviceVerification.value = null syncRequestInFlight = false @@ -1036,6 +1120,7 @@ object ProtocolManager { protocol?.destroy() protocol = null messageRepository?.clearInitialization() + clearTypingState() _devices.value = emptyList() _pendingDeviceVerification.value = null syncRequestInFlight = false 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 7e4af98..96ff3a5 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 @@ -782,8 +782,8 @@ fun ChatDetailScreen( listState.firstVisibleItemScrollOffset <= 12 } } - val showScrollToBottomButton by remember(messagesWithDates, isAtBottom) { - derivedStateOf { messagesWithDates.isNotEmpty() && !isAtBottom } + val showScrollToBottomButton by remember(messagesWithDates, isAtBottom, isSendingMessage) { + derivedStateOf { messagesWithDates.isNotEmpty() && !isAtBottom && !isSendingMessage } } // Telegram-style: Прокрутка ТОЛЬКО при новых сообщениях (не при пагинации) @@ -2551,7 +2551,7 @@ fun ChatDetailScreen( Modifier.align(Alignment.BottomEnd) .padding( end = 14.dp, - bottom = if (isSystemAccount) 24.dp else 86.dp + bottom = if (isSystemAccount) 24.dp else 16.dp ) ) { Box( 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 4c539d8..69f1b0a 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 @@ -242,9 +242,32 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { // 🔥 Сохраняем ссылки на обработчики для очистки в onCleared() // ВАЖНО: Должны быть определены ДО init блока! - private val typingPacketHandler: (Packet) -> Unit = { packet -> + private val typingPacketHandler: (Packet) -> Unit = typingPacketHandler@{ packet -> val typingPacket = packet as PacketTyping - if (typingPacket.fromPublicKey == opponentKey) { + val currentDialog = opponentKey?.trim().orEmpty() + val currentAccount = myPublicKey?.trim().orEmpty() + if (currentDialog.isBlank() || currentAccount.isBlank()) { + return@typingPacketHandler + } + + val fromPublicKey = typingPacket.fromPublicKey.trim() + val toPublicKey = typingPacket.toPublicKey.trim() + if (fromPublicKey.isBlank() || toPublicKey.isBlank()) { + return@typingPacketHandler + } + if (fromPublicKey.equals(currentAccount, ignoreCase = true)) { + return@typingPacketHandler + } + + val shouldShowTyping = + if (isGroupDialogKey(currentDialog)) { + normalizeGroupId(toPublicKey).equals(normalizeGroupId(currentDialog), ignoreCase = true) + } else { + fromPublicKey.equals(currentDialog, ignoreCase = true) && + toPublicKey.equals(currentAccount, ignoreCase = true) + } + + if (shouldShowTyping) { showTypingIndicator() } } @@ -4163,7 +4186,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { } // 📁 Для Saved Messages - не отправляем typing indicator - if (opponent == sender || isGroupDialogKey(opponent)) { + if (opponent.equals(sender, ignoreCase = true)) { return } 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 c0ad443..3394fcf 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 @@ -192,6 +192,28 @@ private fun isGroupDialogKey(value: String): Boolean { return normalized.startsWith("#group:") || normalized.startsWith("group:") } +private fun normalizeGroupDialogKey(value: String): String { + val trimmed = value.trim() + val normalized = trimmed.lowercase(Locale.ROOT) + return when { + normalized.startsWith("#group:") -> "#group:${trimmed.substringAfter(':').trim()}" + normalized.startsWith("group:") -> "#group:${trimmed.substringAfter(':').trim()}" + else -> trimmed + } +} + +private fun isTypingForDialog(dialogKey: String, typingDialogs: Set): Boolean { + if (typingDialogs.isEmpty()) return false + if (isGroupDialogKey(dialogKey)) { + val normalizedDialogKey = normalizeGroupDialogKey(dialogKey) + return typingDialogs.any { + isGroupDialogKey(it) && + normalizeGroupDialogKey(it).equals(normalizedDialogKey, ignoreCase = true) + } + } + return typingDialogs.any { it.equals(dialogKey.trim(), ignoreCase = true) } +} + private val TELEGRAM_DIALOG_AVATAR_START = 10.dp private val TELEGRAM_DIALOG_TEXT_START = 72.dp private val TELEGRAM_DIALOG_AVATAR_SIZE = 54.dp @@ -442,9 +464,29 @@ fun ChatsListScreen( val syncInProgress by ProtocolManager.syncInProgress.collectAsState() val pendingDeviceVerification by ProtocolManager.pendingDeviceVerification.collectAsState() - // � Active downloads tracking (for header indicator) - val activeDownloads by com.rosetta.messenger.network.TransportManager.downloading.collectAsState() - val hasActiveDownloads = activeDownloads.isNotEmpty() + // 📥 Active FILE downloads tracking (account-scoped, excludes photo downloads) + val currentAccountKey = remember(accountPublicKey) { accountPublicKey.trim() } + val allFileDownloads by + com.rosetta.messenger.network.FileDownloadManager.downloads.collectAsState() + val accountFileDownloads = remember(allFileDownloads, currentAccountKey) { + allFileDownloads.values + .filter { + it.accountPublicKey.equals(currentAccountKey, ignoreCase = true) + } + .sortedByDescending { it.progress } + } + val activeFileDownloads = remember(accountFileDownloads) { + accountFileDownloads.filter { + it.status == com.rosetta.messenger.network.FileDownloadStatus.QUEUED || + it.status == + com.rosetta.messenger.network.FileDownloadStatus + .DOWNLOADING || + it.status == + com.rosetta.messenger.network.FileDownloadStatus + .DECRYPTING + } + } + val hasActiveDownloads = activeFileDownloads.isNotEmpty() // �🔥 Пользователи, которые сейчас печатают val typingUsers by ProtocolManager.typingUsers.collectAsState() @@ -473,6 +515,7 @@ fun ChatsListScreen( // 📬 Requests screen state var showRequestsScreen by remember { mutableStateOf(false) } + var showDownloadsScreen by remember { mutableStateOf(false) } var isInlineRequestsTransitionLocked by remember { mutableStateOf(false) } var isRequestsRouteTapLocked by remember { mutableStateOf(false) } val inlineRequestsTransitionLockMs = 340L @@ -498,6 +541,10 @@ fun ChatsListScreen( } } + LaunchedEffect(currentAccountKey) { + showDownloadsScreen = false + } + // 📂 Accounts section expanded state (arrow toggle) var accountsSectionExpanded by remember { mutableStateOf(false) } @@ -535,8 +582,10 @@ fun ChatsListScreen( // Back: drawer → закрыть, selection → сбросить // Когда ничего не открыто — НЕ перехватываем, система сама закроет приложение корректно - BackHandler(enabled = isSelectionMode || drawerState.isOpen) { - if (isSelectionMode) { + BackHandler(enabled = showDownloadsScreen || isSelectionMode || drawerState.isOpen) { + if (showDownloadsScreen) { + showDownloadsScreen = false + } else if (isSelectionMode) { selectedChatKeys = emptySet() } else if (drawerState.isOpen) { scope.launch { drawerState.close() } @@ -709,7 +758,7 @@ fun ChatsListScreen( ) { ModalNavigationDrawer( drawerState = drawerState, - gesturesEnabled = !showRequestsScreen, + gesturesEnabled = !showRequestsScreen && !showDownloadsScreen, drawerContent = { ModalDrawerSheet( drawerContainerColor = Color.Transparent, @@ -1335,7 +1384,12 @@ fun ChatsListScreen( ) { Scaffold( topBar = { - key(isDarkTheme, showRequestsScreen, isSelectionMode) { + key( + isDarkTheme, + showRequestsScreen, + showDownloadsScreen, + isSelectionMode + ) { Crossfade( targetState = isSelectionMode, animationSpec = tween(200), @@ -1473,12 +1527,16 @@ fun ChatsListScreen( // ═══ NORMAL HEADER ═══ TopAppBar( navigationIcon = { - if (showRequestsScreen) { + if (showRequestsScreen || showDownloadsScreen) { IconButton( onClick = { - setInlineRequestsVisible( - false - ) + if (showDownloadsScreen) { + showDownloadsScreen = false + } else { + setInlineRequestsVisible( + false + ) + } } ) { Icon( @@ -1557,7 +1615,14 @@ fun ChatsListScreen( } }, title = { - if (showRequestsScreen) { + if (showDownloadsScreen) { + Text( + "Downloads", + fontWeight = FontWeight.Bold, + fontSize = 20.sp, + color = Color.White + ) + } else if (showRequestsScreen) { Text( "Requests", fontWeight = @@ -1596,10 +1661,18 @@ fun ChatsListScreen( } }, actions = { - if (!showRequestsScreen) { + if (!showRequestsScreen && !showDownloadsScreen) { // 📥 Animated download indicator (Telegram-style) Box( - modifier = androidx.compose.ui.Modifier.size(48.dp), + modifier = + androidx.compose.ui.Modifier + .size(48.dp) + .clickable( + enabled = hasActiveDownloads, + onClick = { + showDownloadsScreen = true + } + ), contentAlignment = Alignment.Center ) { com.rosetta.messenger.ui.components.AnimatedDownloadIndicator( @@ -1709,39 +1782,105 @@ fun ChatsListScreen( val showSkeleton = isLoading - // 🎬 Animated content transition between main list and - // requests AnimatedContent( - targetState = showRequestsScreen, + targetState = showDownloadsScreen, transitionSpec = { if (targetState) { - // Opening requests: slide in from right + // Opening downloads: slide from right with fade slideInHorizontally( - animationSpec = tween(280, easing = FastOutSlowInEasing) - ) { fullWidth -> fullWidth } + fadeIn( + animationSpec = + tween( + 280, + easing = + FastOutSlowInEasing + ) + ) { fullWidth -> + fullWidth + } + fadeIn( animationSpec = tween(200) ) togetherWith slideOutHorizontally( - animationSpec = tween(280, easing = FastOutSlowInEasing) - ) { fullWidth -> -fullWidth / 4 } + fadeOut( + animationSpec = + tween( + 280, + easing = + FastOutSlowInEasing + ) + ) { fullWidth -> + -fullWidth / 4 + } + fadeOut( animationSpec = tween(150) ) } else { - // Closing requests: slide out to right + // Closing downloads: slide back to right slideInHorizontally( - animationSpec = tween(280, easing = FastOutSlowInEasing) - ) { fullWidth -> -fullWidth / 4 } + fadeIn( + animationSpec = + tween( + 280, + easing = + FastOutSlowInEasing + ) + ) { fullWidth -> + -fullWidth / 4 + } + fadeIn( animationSpec = tween(200) ) togetherWith slideOutHorizontally( - animationSpec = tween(280, easing = FastOutSlowInEasing) - ) { fullWidth -> fullWidth } + fadeOut( + animationSpec = + tween( + 280, + easing = + FastOutSlowInEasing + ) + ) { fullWidth -> + fullWidth + } + fadeOut( animationSpec = tween(150) ) } }, - label = "RequestsTransition" - ) { isRequestsScreen -> + label = "DownloadsTransition" + ) { isDownloadsScreen -> + if (isDownloadsScreen) { + FileDownloadsScreen( + downloads = activeFileDownloads, + isDarkTheme = isDarkTheme, + modifier = Modifier.fillMaxSize() + ) + } else { + // 🎬 Animated content transition between main list and + // requests + AnimatedContent( + targetState = showRequestsScreen, + transitionSpec = { + if (targetState) { + // Opening requests: slide in from right + slideInHorizontally( + animationSpec = tween(280, easing = FastOutSlowInEasing) + ) { fullWidth -> fullWidth } + fadeIn( + animationSpec = tween(200) + ) togetherWith + slideOutHorizontally( + animationSpec = tween(280, easing = FastOutSlowInEasing) + ) { fullWidth -> -fullWidth / 4 } + fadeOut( + animationSpec = tween(150) + ) + } else { + // Closing requests: slide out to right + slideInHorizontally( + animationSpec = tween(280, easing = FastOutSlowInEasing) + ) { fullWidth -> -fullWidth / 4 } + fadeIn( + animationSpec = tween(200) + ) togetherWith + slideOutHorizontally( + animationSpec = tween(280, easing = FastOutSlowInEasing) + ) { fullWidth -> fullWidth } + fadeOut( + animationSpec = tween(150) + ) + } + }, + label = "RequestsTransition" + ) { isRequestsScreen -> if (isRequestsScreen) { // 📬 Show Requests Screen with swipe-back Box( @@ -2054,13 +2193,14 @@ fun ChatsListScreen( ) val isTyping by remember( - dialog.opponentKey + dialog.opponentKey, + typingUsers ) { derivedStateOf { - typingUsers - .contains( - dialog.opponentKey - ) + isTypingForDialog( + dialog.opponentKey, + typingUsers + ) } } val isSelectedDialog = @@ -2241,7 +2381,9 @@ fun ChatsListScreen( } } } - } // Close AnimatedContent + } // Close AnimatedContent + } // Close downloads/main content switch + } // Close Downloads AnimatedContent // Console button removed } @@ -3746,7 +3888,11 @@ fun DialogItemContent( MessageRepository.isSystemAccount(dialog.opponentKey) if (dialog.verified > 0 || isRosettaOfficial) { Spacer(modifier = Modifier.width(4.dp)) - VerifiedBadge(verified = if (dialog.verified > 0) dialog.verified else 1, size = 16) + VerifiedBadge( + verified = if (dialog.verified > 0) dialog.verified else 1, + size = 16, + modifier = Modifier.offset(y = (-1).dp) + ) } // 🔒 Красная иконка замочка для заблокированных пользователей if (isBlocked) { @@ -4561,6 +4707,136 @@ fun RequestsScreen( } } +@Composable +private fun FileDownloadsScreen( + downloads: List, + isDarkTheme: Boolean, + modifier: Modifier = Modifier +) { + val background = if (isDarkTheme) Color(0xFF1A1A1A) else Color(0xFFF2F2F7) + val card = if (isDarkTheme) Color(0xFF212123) else Color.White + val primaryText = if (isDarkTheme) Color.White else Color(0xFF111111) + val secondaryText = if (isDarkTheme) Color(0xFF9B9B9F) else Color(0xFF6E6E73) + val divider = if (isDarkTheme) Color(0xFF2F2F31) else Color(0xFFE7E7EC) + + if (downloads.isEmpty()) { + Box( + modifier = modifier.background(background), + contentAlignment = Alignment.Center + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Icon( + imageVector = TablerIcons.Download, + contentDescription = null, + tint = secondaryText, + modifier = Modifier.size(34.dp) + ) + Spacer(modifier = Modifier.height(10.dp)) + Text( + text = "No active file downloads", + color = primaryText, + fontSize = 16.sp, + fontWeight = FontWeight.Medium + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = "New file downloads will appear here.", + color = secondaryText, + fontSize = 13.sp + ) + } + } + return + } + + LazyColumn( + modifier = modifier.background(background), + contentPadding = PaddingValues(horizontal = 12.dp, vertical = 10.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + items(items = downloads, key = { it.attachmentId }) { item -> + Surface( + color = card, + shape = RoundedCornerShape(14.dp), + tonalElevation = 0.dp + ) { + Column(modifier = Modifier.fillMaxWidth().padding(12.dp)) { + Row( + verticalAlignment = Alignment.CenterVertically + ) { + Box( + modifier = Modifier + .size(34.dp) + .clip(CircleShape) + .background( + if (isDarkTheme) Color(0xFF2B4E6E) + else Color(0xFFDCEEFF) + ), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = TablerIcons.Download, + contentDescription = null, + tint = if (isDarkTheme) Color(0xFF8FC6FF) else Color(0xFF228BE6), + modifier = Modifier.size(18.dp) + ) + } + Spacer(modifier = Modifier.width(10.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + text = item.fileName.ifBlank { "Unknown file" }, + color = primaryText, + fontSize = 15.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Spacer(modifier = Modifier.height(2.dp)) + Text( + text = formatDownloadStatusText(item), + color = secondaryText, + fontSize = 12.sp + ) + } + } + Spacer(modifier = Modifier.height(10.dp)) + LinearProgressIndicator( + progress = item.progress.coerceIn(0f, 1f), + modifier = Modifier + .fillMaxWidth() + .height(4.dp) + .clip(RoundedCornerShape(50)), + color = if (isDarkTheme) Color(0xFF4DA6FF) else Color(0xFF228BE6), + trackColor = if (isDarkTheme) Color(0xFF3A3A3D) else Color(0xFFD8D8DE) + ) + Spacer(modifier = Modifier.height(8.dp)) + Divider(color = divider, thickness = 0.5.dp) + Spacer(modifier = Modifier.height(6.dp)) + Text( + text = item.savedPath.ifBlank { "Storage path unavailable" }, + color = secondaryText, + fontSize = 11.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } + } + } +} + +private fun formatDownloadStatusText( + item: com.rosetta.messenger.network.FileDownloadState +): String { + val percent = (item.progress.coerceIn(0f, 1f) * 100).toInt().coerceIn(0, 100) + return when (item.status) { + com.rosetta.messenger.network.FileDownloadStatus.QUEUED -> "Queued" + com.rosetta.messenger.network.FileDownloadStatus.DOWNLOADING -> "Downloading $percent%" + com.rosetta.messenger.network.FileDownloadStatus.DECRYPTING -> "Decrypting" + com.rosetta.messenger.network.FileDownloadStatus.DONE -> "Completed" + com.rosetta.messenger.network.FileDownloadStatus.ERROR -> "Download failed" + } +} + /** 🎨 Enhanced Drawer Menu Item - пункт меню в стиле Telegram */ @Composable fun DrawerMenuItemEnhanced( diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/GroupInfoScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/GroupInfoScreen.kt index a7de082..620da55 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/GroupInfoScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/GroupInfoScreen.kt @@ -1428,11 +1428,7 @@ fun GroupInfoScreen( isDarkTheme = isDarkTheme, topSurfaceColor = topSurfaceColor, backgroundColor = backgroundColor, - onBack = { showEncryptionPage = false }, - onCopy = { - clipboardManager.setText(AnnotatedString(encryptionKey)) - Toast.makeText(context, "Encryption key copied", Toast.LENGTH_SHORT).show() - } + onBack = { showEncryptionPage = false } ) } @@ -1570,8 +1566,7 @@ private fun GroupEncryptionKeyPage( isDarkTheme: Boolean, topSurfaceColor: Color, backgroundColor: Color, - onBack: () -> Unit, - onCopy: () -> Unit + onBack: () -> Unit ) { val uriHandler = LocalUriHandler.current val safePeerTitle = peerTitle.ifBlank { "this group" } @@ -1614,14 +1609,6 @@ private fun GroupEncryptionKeyPage( fontSize = 20.sp, fontWeight = FontWeight.SemiBold ) - Spacer(modifier = Modifier.weight(1f)) - TextButton(onClick = onCopy) { - Text( - text = "Copy", - color = Color.White.copy(alpha = 0.9f), - fontWeight = FontWeight.Medium - ) - } } // Two-half layout like Telegram diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/components/AttachmentComponents.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/components/AttachmentComponents.kt index 9420f29..8303514 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/components/AttachmentComponents.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/components/AttachmentComponents.kt @@ -395,6 +395,7 @@ fun MessageAttachments( attachment = attachment, chachaKey = chachaKey, privateKey = privateKey, + currentUserPublicKey = currentUserPublicKey, isOutgoing = isOutgoing, isDarkTheme = isDarkTheme, timestamp = timestamp, @@ -1444,6 +1445,7 @@ fun FileAttachment( attachment: MessageAttachment, chachaKey: String, privateKey: String, + currentUserPublicKey: String = "", isOutgoing: Boolean, isDarkTheme: Boolean, timestamp: java.util.Date, @@ -1557,6 +1559,7 @@ fun FileAttachment( downloadTag = downloadTag, chachaKey = chachaKey, privateKey = privateKey, + accountPublicKey = currentUserPublicKey, fileName = fileName, savedFile = savedFile ) diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/components/ChatDetailComponents.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/components/ChatDetailComponents.kt index e68ca0c..04b19f0 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/components/ChatDetailComponents.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/components/ChatDetailComponents.kt @@ -71,6 +71,7 @@ import com.rosetta.messenger.repository.AvatarRepository import com.rosetta.messenger.ui.chats.models.* import com.rosetta.messenger.ui.chats.utils.* import com.rosetta.messenger.ui.components.AppleEmojiText +import com.rosetta.messenger.ui.components.AvatarImage import com.rosetta.messenger.ui.onboarding.PrimaryBlue import com.rosetta.messenger.utils.AttachmentFileManager import com.vanniktech.blurhash.BlurHash @@ -532,6 +533,15 @@ fun MessageBubble( val combinedBackgroundColor = if (isSelected) selectionBackgroundColor else highlightBackgroundColor + val telegramIncomingAvatarSize = 42.dp + val telegramIncomingAvatarLane = 48.dp + val telegramIncomingAvatarInset = 6.dp + val showIncomingGroupAvatar = + isGroupChat && + !message.isOutgoing && + showTail && + senderPublicKey.isNotBlank() + Row( modifier = Modifier.fillMaxWidth() @@ -579,6 +589,39 @@ fun MessageBubble( Spacer(modifier = Modifier.weight(1f)) } + if (!message.isOutgoing && isGroupChat) { + Box( + modifier = + Modifier.width(telegramIncomingAvatarLane) + .height(telegramIncomingAvatarSize) + .align(Alignment.Bottom), + contentAlignment = Alignment.BottomStart + ) { + if (showIncomingGroupAvatar) { + Box( + modifier = + Modifier.fillMaxSize() + .padding( + start = + telegramIncomingAvatarInset + ), + contentAlignment = Alignment.BottomStart + ) { + AvatarImage( + publicKey = senderPublicKey, + avatarRepository = avatarRepository, + size = telegramIncomingAvatarSize, + isDarkTheme = isDarkTheme, + displayName = + senderName.ifBlank { + senderPublicKey + } + ) + } + } + } + } + // Проверяем - есть ли только фотки без текста val hasOnlyMedia = message.attachments.isNotEmpty() && @@ -708,7 +751,8 @@ fun MessageBubble( Box( modifier = - Modifier.padding(end = 12.dp) + Modifier.align(Alignment.Bottom) + .padding(end = 12.dp) .then(bubbleWidthModifier) .graphicsLayer { this.alpha = selectionAlpha