Исправлены typing и загрузки файлов, улучшен UI чатов
- Добавлена привязка загрузок файлов к аккаунту и путь сохранения в состоянии загрузки - Реализован экран Downloads с прогрессом и путём файла, плюс анимация открытия/закрытия - Исправлена логика typing-индикаторов в группах и ЛС (без ложных срабатываний) - Доработаны пузырьки групповых сообщений: Telegram-style аватар 42dp и отступы - Исправлено поведение кнопки прокрутки вниз в чате (без мигания при отправке, ближе к инпуту) - Убран Copy на экране Encryption Key группы
This commit is contained in:
@@ -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
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user