Исправлены 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

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