Исправлены typing и загрузки файлов, улучшен UI чатов

- Добавлена привязка загрузок файлов к аккаунту и путь сохранения в состоянии загрузки
- Реализован экран Downloads с прогрессом и путём файла, плюс анимация открытия/закрытия
- Исправлена логика typing-индикаторов в группах и ЛС (без ложных срабатываний)
- Доработаны пузырьки групповых сообщений: Telegram-style аватар 42dp и отступы
- Исправлено поведение кнопки прокрутки вниз в чате (без мигания при отправке, ближе к инпуту)
- Убран Copy на экране Encryption Key группы
This commit is contained in:
2026-03-08 19:07:56 +05:00
parent c7d5c47dd0
commit b0a41b2831
8 changed files with 637 additions and 85 deletions

View File

@@ -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
)
)
}
}
}

View File

@@ -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<Set<String>>(emptySet())
val typingUsers: StateFlow<Set<String>> = _typingUsers.asStateFlow()
private val typingStateLock = Any()
private val typingUsersByDialog = mutableMapOf<String, MutableSet<String>>()
private val typingTimeoutJobs = ConcurrentHashMap<String, Job>()
// Connected devices and pending verification requests
private val _devices = MutableStateFlow<List<DeviceEntry>>(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

View File

@@ -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(

View File

@@ -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
}

View File

@@ -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<String>): 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()
// <EFBFBD> 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()
// <20>🔥 Пользователи, которые сейчас печатают
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<com.rosetta.messenger.network.FileDownloadState>,
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(

View File

@@ -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

View File

@@ -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
)

View File

@@ -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