Исправлены 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 fileName: String,
|
||||||
val status: FileDownloadStatus,
|
val status: FileDownloadStatus,
|
||||||
/** 0f..1f */
|
/** 0f..1f */
|
||||||
val progress: Float = 0f
|
val progress: Float = 0f,
|
||||||
|
val accountPublicKey: String = "",
|
||||||
|
val savedPath: String = ""
|
||||||
)
|
)
|
||||||
|
|
||||||
enum class FileDownloadStatus {
|
enum class FileDownloadStatus {
|
||||||
@@ -84,17 +86,27 @@ object FileDownloadManager {
|
|||||||
downloadTag: String,
|
downloadTag: String,
|
||||||
chachaKey: String,
|
chachaKey: String,
|
||||||
privateKey: String,
|
privateKey: String,
|
||||||
|
accountPublicKey: String,
|
||||||
fileName: String,
|
fileName: String,
|
||||||
savedFile: File
|
savedFile: File
|
||||||
) {
|
) {
|
||||||
// Уже в процессе?
|
// Уже в процессе?
|
||||||
if (jobs[attachmentId]?.isActive == true) return
|
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 {
|
jobs[attachmentId] = scope.launch {
|
||||||
try {
|
try {
|
||||||
update(attachmentId, fileName, FileDownloadStatus.DOWNLOADING, 0f)
|
update(
|
||||||
|
attachmentId,
|
||||||
|
fileName,
|
||||||
|
FileDownloadStatus.DOWNLOADING,
|
||||||
|
0f,
|
||||||
|
normalizedAccount,
|
||||||
|
savedPath
|
||||||
|
)
|
||||||
|
|
||||||
// Запускаем polling прогресса из TransportManager
|
// Запускаем polling прогресса из TransportManager
|
||||||
val progressJob = launch {
|
val progressJob = launch {
|
||||||
@@ -103,34 +115,87 @@ object FileDownloadManager {
|
|||||||
if (entry != null) {
|
if (entry != null) {
|
||||||
// CDN progress: 0-100 → 0f..0.8f (80% круга — скачивание)
|
// CDN progress: 0-100 → 0f..0.8f (80% круга — скачивание)
|
||||||
val p = (entry.progress / 100f) * 0.8f
|
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) {
|
val success = withContext(Dispatchers.IO) {
|
||||||
if (isGroupStoredKey(chachaKey)) {
|
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 {
|
} 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()
|
progressJob.cancel()
|
||||||
|
|
||||||
if (success) {
|
if (success) {
|
||||||
update(attachmentId, fileName, FileDownloadStatus.DONE, 1f)
|
update(
|
||||||
|
attachmentId,
|
||||||
|
fileName,
|
||||||
|
FileDownloadStatus.DONE,
|
||||||
|
1f,
|
||||||
|
normalizedAccount,
|
||||||
|
savedPath
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
update(attachmentId, fileName, FileDownloadStatus.ERROR, 0f)
|
update(
|
||||||
|
attachmentId,
|
||||||
|
fileName,
|
||||||
|
FileDownloadStatus.ERROR,
|
||||||
|
0f,
|
||||||
|
normalizedAccount,
|
||||||
|
savedPath
|
||||||
|
)
|
||||||
}
|
}
|
||||||
} catch (e: CancellationException) {
|
} catch (e: CancellationException) {
|
||||||
throw e
|
throw e
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
e.printStackTrace()
|
e.printStackTrace()
|
||||||
update(attachmentId, fileName, FileDownloadStatus.ERROR, 0f)
|
update(
|
||||||
|
attachmentId,
|
||||||
|
fileName,
|
||||||
|
FileDownloadStatus.ERROR,
|
||||||
|
0f,
|
||||||
|
normalizedAccount,
|
||||||
|
savedPath
|
||||||
|
)
|
||||||
} catch (_: OutOfMemoryError) {
|
} catch (_: OutOfMemoryError) {
|
||||||
System.gc()
|
System.gc()
|
||||||
update(attachmentId, fileName, FileDownloadStatus.ERROR, 0f)
|
update(
|
||||||
|
attachmentId,
|
||||||
|
fileName,
|
||||||
|
FileDownloadStatus.ERROR,
|
||||||
|
0f,
|
||||||
|
normalizedAccount,
|
||||||
|
savedPath
|
||||||
|
)
|
||||||
} finally {
|
} finally {
|
||||||
jobs.remove(attachmentId)
|
jobs.remove(attachmentId)
|
||||||
// Автоочистка через 5 секунд после завершения
|
// Автоочистка через 5 секунд после завершения
|
||||||
@@ -159,25 +224,55 @@ object FileDownloadManager {
|
|||||||
chachaKey: String,
|
chachaKey: String,
|
||||||
privateKey: String,
|
privateKey: String,
|
||||||
fileName: String,
|
fileName: String,
|
||||||
savedFile: File
|
savedFile: File,
|
||||||
|
accountPublicKey: String,
|
||||||
|
savedPath: String
|
||||||
): Boolean {
|
): Boolean {
|
||||||
val encryptedContent = TransportManager.downloadFile(attachmentId, downloadTag)
|
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)
|
val groupPassword = decodeGroupPassword(chachaKey, privateKey)
|
||||||
if (groupPassword.isNullOrBlank()) return false
|
if (groupPassword.isNullOrBlank()) return false
|
||||||
|
|
||||||
val decrypted = CryptoManager.decryptWithPassword(encryptedContent, groupPassword)
|
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
|
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) {
|
withContext(Dispatchers.IO) {
|
||||||
savedFile.parentFile?.mkdirs()
|
savedFile.parentFile?.mkdirs()
|
||||||
savedFile.writeBytes(bytes)
|
savedFile.writeBytes(bytes)
|
||||||
}
|
}
|
||||||
update(attachmentId, fileName, FileDownloadStatus.DECRYPTING, 0.98f)
|
update(
|
||||||
|
attachmentId,
|
||||||
|
fileName,
|
||||||
|
FileDownloadStatus.DECRYPTING,
|
||||||
|
0.98f,
|
||||||
|
accountPublicKey,
|
||||||
|
savedPath
|
||||||
|
)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -187,14 +282,30 @@ object FileDownloadManager {
|
|||||||
chachaKey: String,
|
chachaKey: String,
|
||||||
privateKey: String,
|
privateKey: String,
|
||||||
fileName: String,
|
fileName: String,
|
||||||
savedFile: File
|
savedFile: File,
|
||||||
|
accountPublicKey: String,
|
||||||
|
savedPath: String
|
||||||
): Boolean {
|
): Boolean {
|
||||||
// Streaming: скачиваем во temp file
|
// Streaming: скачиваем во temp file
|
||||||
val tempFile = TransportManager.downloadFileRaw(attachmentId, downloadTag)
|
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)
|
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
|
// Streaming decrypt: tempFile → AES → inflate → base64 → savedFile
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
@@ -208,13 +319,36 @@ object FileDownloadManager {
|
|||||||
tempFile.delete()
|
tempFile.delete()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
update(attachmentId, fileName, FileDownloadStatus.DECRYPTING, 0.98f)
|
update(
|
||||||
|
attachmentId,
|
||||||
|
fileName,
|
||||||
|
FileDownloadStatus.DECRYPTING,
|
||||||
|
0.98f,
|
||||||
|
accountPublicKey,
|
||||||
|
savedPath
|
||||||
|
)
|
||||||
return true
|
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 ->
|
_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 MANUAL_SYNC_BACKTRACK_MS = 120_000L
|
||||||
private const val MAX_DEBUG_LOGS = 600
|
private const val MAX_DEBUG_LOGS = 600
|
||||||
private const val DEBUG_LOG_FLUSH_DELAY_MS = 60L
|
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
|
// Server address - same as React Native version
|
||||||
private const val SERVER_ADDRESS = "ws://46.28.71.12:3000"
|
private const val SERVER_ADDRESS = "ws://46.28.71.12:3000"
|
||||||
@@ -59,6 +60,9 @@ object ProtocolManager {
|
|||||||
// Typing status
|
// Typing status
|
||||||
private val _typingUsers = MutableStateFlow<Set<String>>(emptySet())
|
private val _typingUsers = MutableStateFlow<Set<String>>(emptySet())
|
||||||
val typingUsers: StateFlow<Set<String>> = _typingUsers.asStateFlow()
|
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
|
// Connected devices and pending verification requests
|
||||||
private val _devices = MutableStateFlow<List<DeviceEntry>>(emptyList())
|
private val _devices = MutableStateFlow<List<DeviceEntry>>(emptyList())
|
||||||
@@ -200,6 +204,7 @@ object ProtocolManager {
|
|||||||
*/
|
*/
|
||||||
fun initializeAccount(publicKey: String, privateKey: String) {
|
fun initializeAccount(publicKey: String, privateKey: String) {
|
||||||
setSyncInProgress(false)
|
setSyncInProgress(false)
|
||||||
|
clearTypingState()
|
||||||
messageRepository?.initialize(publicKey, privateKey)
|
messageRepository?.initialize(publicKey, privateKey)
|
||||||
if (resyncRequiredAfterAccountInit || protocol?.isAuthenticated() == true) {
|
if (resyncRequiredAfterAccountInit || protocol?.isAuthenticated() == true) {
|
||||||
resyncRequiredAfterAccountInit = false
|
resyncRequiredAfterAccountInit = false
|
||||||
@@ -369,13 +374,26 @@ object ProtocolManager {
|
|||||||
// Обработчик typing (0x0B)
|
// Обработчик typing (0x0B)
|
||||||
waitPacket(0x0B) { packet ->
|
waitPacket(0x0B) { packet ->
|
||||||
val typingPacket = packet as PacketTyping
|
val typingPacket = packet as PacketTyping
|
||||||
|
val fromPublicKey = typingPacket.fromPublicKey.trim()
|
||||||
|
val toPublicKey = typingPacket.toPublicKey.trim()
|
||||||
|
if (fromPublicKey.isBlank() || toPublicKey.isBlank()) return@waitPacket
|
||||||
|
|
||||||
// Добавляем в set и удаляем через 3 секунды
|
val ownPublicKey =
|
||||||
_typingUsers.value = _typingUsers.value + typingPacket.fromPublicKey
|
getProtocol().getPublicKey()?.trim().orEmpty().ifBlank {
|
||||||
scope.launch {
|
messageRepository?.getCurrentAccountKey()?.trim().orEmpty()
|
||||||
delay(3000)
|
|
||||||
_typingUsers.value = _typingUsers.value - typingPacket.fromPublicKey
|
|
||||||
}
|
}
|
||||||
|
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)
|
// 📱 Обработчик списка устройств (0x17)
|
||||||
@@ -508,6 +526,71 @@ object ProtocolManager {
|
|||||||
return normalized.startsWith("#group:") || normalized.startsWith("group:")
|
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() {
|
private fun onAuthenticated() {
|
||||||
setSyncInProgress(false)
|
setSyncInProgress(false)
|
||||||
TransportManager.requestTransportServer()
|
TransportManager.requestTransportServer()
|
||||||
@@ -1021,6 +1104,7 @@ object ProtocolManager {
|
|||||||
protocol?.disconnect()
|
protocol?.disconnect()
|
||||||
protocol?.clearCredentials()
|
protocol?.clearCredentials()
|
||||||
messageRepository?.clearInitialization()
|
messageRepository?.clearInitialization()
|
||||||
|
clearTypingState()
|
||||||
_devices.value = emptyList()
|
_devices.value = emptyList()
|
||||||
_pendingDeviceVerification.value = null
|
_pendingDeviceVerification.value = null
|
||||||
syncRequestInFlight = false
|
syncRequestInFlight = false
|
||||||
@@ -1036,6 +1120,7 @@ object ProtocolManager {
|
|||||||
protocol?.destroy()
|
protocol?.destroy()
|
||||||
protocol = null
|
protocol = null
|
||||||
messageRepository?.clearInitialization()
|
messageRepository?.clearInitialization()
|
||||||
|
clearTypingState()
|
||||||
_devices.value = emptyList()
|
_devices.value = emptyList()
|
||||||
_pendingDeviceVerification.value = null
|
_pendingDeviceVerification.value = null
|
||||||
syncRequestInFlight = false
|
syncRequestInFlight = false
|
||||||
|
|||||||
@@ -782,8 +782,8 @@ fun ChatDetailScreen(
|
|||||||
listState.firstVisibleItemScrollOffset <= 12
|
listState.firstVisibleItemScrollOffset <= 12
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val showScrollToBottomButton by remember(messagesWithDates, isAtBottom) {
|
val showScrollToBottomButton by remember(messagesWithDates, isAtBottom, isSendingMessage) {
|
||||||
derivedStateOf { messagesWithDates.isNotEmpty() && !isAtBottom }
|
derivedStateOf { messagesWithDates.isNotEmpty() && !isAtBottom && !isSendingMessage }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Telegram-style: Прокрутка ТОЛЬКО при новых сообщениях (не при пагинации)
|
// Telegram-style: Прокрутка ТОЛЬКО при новых сообщениях (не при пагинации)
|
||||||
@@ -2551,7 +2551,7 @@ fun ChatDetailScreen(
|
|||||||
Modifier.align(Alignment.BottomEnd)
|
Modifier.align(Alignment.BottomEnd)
|
||||||
.padding(
|
.padding(
|
||||||
end = 14.dp,
|
end = 14.dp,
|
||||||
bottom = if (isSystemAccount) 24.dp else 86.dp
|
bottom = if (isSystemAccount) 24.dp else 16.dp
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
Box(
|
Box(
|
||||||
|
|||||||
@@ -242,9 +242,32 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
|
|
||||||
// 🔥 Сохраняем ссылки на обработчики для очистки в onCleared()
|
// 🔥 Сохраняем ссылки на обработчики для очистки в onCleared()
|
||||||
// ВАЖНО: Должны быть определены ДО init блока!
|
// ВАЖНО: Должны быть определены ДО init блока!
|
||||||
private val typingPacketHandler: (Packet) -> Unit = { packet ->
|
private val typingPacketHandler: (Packet) -> Unit = typingPacketHandler@{ packet ->
|
||||||
val typingPacket = packet as PacketTyping
|
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()
|
showTypingIndicator()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -4163,7 +4186,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 📁 Для Saved Messages - не отправляем typing indicator
|
// 📁 Для Saved Messages - не отправляем typing indicator
|
||||||
if (opponent == sender || isGroupDialogKey(opponent)) {
|
if (opponent.equals(sender, ignoreCase = true)) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -192,6 +192,28 @@ private fun isGroupDialogKey(value: String): Boolean {
|
|||||||
return normalized.startsWith("#group:") || normalized.startsWith("group:")
|
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_AVATAR_START = 10.dp
|
||||||
private val TELEGRAM_DIALOG_TEXT_START = 72.dp
|
private val TELEGRAM_DIALOG_TEXT_START = 72.dp
|
||||||
private val TELEGRAM_DIALOG_AVATAR_SIZE = 54.dp
|
private val TELEGRAM_DIALOG_AVATAR_SIZE = 54.dp
|
||||||
@@ -442,9 +464,29 @@ fun ChatsListScreen(
|
|||||||
val syncInProgress by ProtocolManager.syncInProgress.collectAsState()
|
val syncInProgress by ProtocolManager.syncInProgress.collectAsState()
|
||||||
val pendingDeviceVerification by ProtocolManager.pendingDeviceVerification.collectAsState()
|
val pendingDeviceVerification by ProtocolManager.pendingDeviceVerification.collectAsState()
|
||||||
|
|
||||||
// <EFBFBD> Active downloads tracking (for header indicator)
|
// 📥 Active FILE downloads tracking (account-scoped, excludes photo downloads)
|
||||||
val activeDownloads by com.rosetta.messenger.network.TransportManager.downloading.collectAsState()
|
val currentAccountKey = remember(accountPublicKey) { accountPublicKey.trim() }
|
||||||
val hasActiveDownloads = activeDownloads.isNotEmpty()
|
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>🔥 Пользователи, которые сейчас печатают
|
// <20>🔥 Пользователи, которые сейчас печатают
|
||||||
val typingUsers by ProtocolManager.typingUsers.collectAsState()
|
val typingUsers by ProtocolManager.typingUsers.collectAsState()
|
||||||
@@ -473,6 +515,7 @@ fun ChatsListScreen(
|
|||||||
|
|
||||||
// 📬 Requests screen state
|
// 📬 Requests screen state
|
||||||
var showRequestsScreen by remember { mutableStateOf(false) }
|
var showRequestsScreen by remember { mutableStateOf(false) }
|
||||||
|
var showDownloadsScreen by remember { mutableStateOf(false) }
|
||||||
var isInlineRequestsTransitionLocked by remember { mutableStateOf(false) }
|
var isInlineRequestsTransitionLocked by remember { mutableStateOf(false) }
|
||||||
var isRequestsRouteTapLocked by remember { mutableStateOf(false) }
|
var isRequestsRouteTapLocked by remember { mutableStateOf(false) }
|
||||||
val inlineRequestsTransitionLockMs = 340L
|
val inlineRequestsTransitionLockMs = 340L
|
||||||
@@ -498,6 +541,10 @@ fun ChatsListScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(currentAccountKey) {
|
||||||
|
showDownloadsScreen = false
|
||||||
|
}
|
||||||
|
|
||||||
// 📂 Accounts section expanded state (arrow toggle)
|
// 📂 Accounts section expanded state (arrow toggle)
|
||||||
var accountsSectionExpanded by remember { mutableStateOf(false) }
|
var accountsSectionExpanded by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
@@ -535,8 +582,10 @@ fun ChatsListScreen(
|
|||||||
|
|
||||||
// Back: drawer → закрыть, selection → сбросить
|
// Back: drawer → закрыть, selection → сбросить
|
||||||
// Когда ничего не открыто — НЕ перехватываем, система сама закроет приложение корректно
|
// Когда ничего не открыто — НЕ перехватываем, система сама закроет приложение корректно
|
||||||
BackHandler(enabled = isSelectionMode || drawerState.isOpen) {
|
BackHandler(enabled = showDownloadsScreen || isSelectionMode || drawerState.isOpen) {
|
||||||
if (isSelectionMode) {
|
if (showDownloadsScreen) {
|
||||||
|
showDownloadsScreen = false
|
||||||
|
} else if (isSelectionMode) {
|
||||||
selectedChatKeys = emptySet()
|
selectedChatKeys = emptySet()
|
||||||
} else if (drawerState.isOpen) {
|
} else if (drawerState.isOpen) {
|
||||||
scope.launch { drawerState.close() }
|
scope.launch { drawerState.close() }
|
||||||
@@ -709,7 +758,7 @@ fun ChatsListScreen(
|
|||||||
) {
|
) {
|
||||||
ModalNavigationDrawer(
|
ModalNavigationDrawer(
|
||||||
drawerState = drawerState,
|
drawerState = drawerState,
|
||||||
gesturesEnabled = !showRequestsScreen,
|
gesturesEnabled = !showRequestsScreen && !showDownloadsScreen,
|
||||||
drawerContent = {
|
drawerContent = {
|
||||||
ModalDrawerSheet(
|
ModalDrawerSheet(
|
||||||
drawerContainerColor = Color.Transparent,
|
drawerContainerColor = Color.Transparent,
|
||||||
@@ -1335,7 +1384,12 @@ fun ChatsListScreen(
|
|||||||
) {
|
) {
|
||||||
Scaffold(
|
Scaffold(
|
||||||
topBar = {
|
topBar = {
|
||||||
key(isDarkTheme, showRequestsScreen, isSelectionMode) {
|
key(
|
||||||
|
isDarkTheme,
|
||||||
|
showRequestsScreen,
|
||||||
|
showDownloadsScreen,
|
||||||
|
isSelectionMode
|
||||||
|
) {
|
||||||
Crossfade(
|
Crossfade(
|
||||||
targetState = isSelectionMode,
|
targetState = isSelectionMode,
|
||||||
animationSpec = tween(200),
|
animationSpec = tween(200),
|
||||||
@@ -1473,13 +1527,17 @@ fun ChatsListScreen(
|
|||||||
// ═══ NORMAL HEADER ═══
|
// ═══ NORMAL HEADER ═══
|
||||||
TopAppBar(
|
TopAppBar(
|
||||||
navigationIcon = {
|
navigationIcon = {
|
||||||
if (showRequestsScreen) {
|
if (showRequestsScreen || showDownloadsScreen) {
|
||||||
IconButton(
|
IconButton(
|
||||||
onClick = {
|
onClick = {
|
||||||
|
if (showDownloadsScreen) {
|
||||||
|
showDownloadsScreen = false
|
||||||
|
} else {
|
||||||
setInlineRequestsVisible(
|
setInlineRequestsVisible(
|
||||||
false
|
false
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
TablerIcons
|
TablerIcons
|
||||||
@@ -1557,7 +1615,14 @@ fun ChatsListScreen(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
title = {
|
title = {
|
||||||
if (showRequestsScreen) {
|
if (showDownloadsScreen) {
|
||||||
|
Text(
|
||||||
|
"Downloads",
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
fontSize = 20.sp,
|
||||||
|
color = Color.White
|
||||||
|
)
|
||||||
|
} else if (showRequestsScreen) {
|
||||||
Text(
|
Text(
|
||||||
"Requests",
|
"Requests",
|
||||||
fontWeight =
|
fontWeight =
|
||||||
@@ -1596,10 +1661,18 @@ fun ChatsListScreen(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
actions = {
|
actions = {
|
||||||
if (!showRequestsScreen) {
|
if (!showRequestsScreen && !showDownloadsScreen) {
|
||||||
// 📥 Animated download indicator (Telegram-style)
|
// 📥 Animated download indicator (Telegram-style)
|
||||||
Box(
|
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
|
contentAlignment = Alignment.Center
|
||||||
) {
|
) {
|
||||||
com.rosetta.messenger.ui.components.AnimatedDownloadIndicator(
|
com.rosetta.messenger.ui.components.AnimatedDownloadIndicator(
|
||||||
@@ -1709,6 +1782,72 @@ fun ChatsListScreen(
|
|||||||
|
|
||||||
val showSkeleton = isLoading
|
val showSkeleton = isLoading
|
||||||
|
|
||||||
|
AnimatedContent(
|
||||||
|
targetState = showDownloadsScreen,
|
||||||
|
transitionSpec = {
|
||||||
|
if (targetState) {
|
||||||
|
// Opening downloads: slide from right with fade
|
||||||
|
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 downloads: slide back 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 = "DownloadsTransition"
|
||||||
|
) { isDownloadsScreen ->
|
||||||
|
if (isDownloadsScreen) {
|
||||||
|
FileDownloadsScreen(
|
||||||
|
downloads = activeFileDownloads,
|
||||||
|
isDarkTheme = isDarkTheme,
|
||||||
|
modifier = Modifier.fillMaxSize()
|
||||||
|
)
|
||||||
|
} else {
|
||||||
// 🎬 Animated content transition between main list and
|
// 🎬 Animated content transition between main list and
|
||||||
// requests
|
// requests
|
||||||
AnimatedContent(
|
AnimatedContent(
|
||||||
@@ -2054,12 +2193,13 @@ fun ChatsListScreen(
|
|||||||
)
|
)
|
||||||
val isTyping by
|
val isTyping by
|
||||||
remember(
|
remember(
|
||||||
dialog.opponentKey
|
dialog.opponentKey,
|
||||||
|
typingUsers
|
||||||
) {
|
) {
|
||||||
derivedStateOf {
|
derivedStateOf {
|
||||||
|
isTypingForDialog(
|
||||||
|
dialog.opponentKey,
|
||||||
typingUsers
|
typingUsers
|
||||||
.contains(
|
|
||||||
dialog.opponentKey
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2242,6 +2382,8 @@ fun ChatsListScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} // Close AnimatedContent
|
} // Close AnimatedContent
|
||||||
|
} // Close downloads/main content switch
|
||||||
|
} // Close Downloads AnimatedContent
|
||||||
|
|
||||||
// Console button removed
|
// Console button removed
|
||||||
}
|
}
|
||||||
@@ -3746,7 +3888,11 @@ fun DialogItemContent(
|
|||||||
MessageRepository.isSystemAccount(dialog.opponentKey)
|
MessageRepository.isSystemAccount(dialog.opponentKey)
|
||||||
if (dialog.verified > 0 || isRosettaOfficial) {
|
if (dialog.verified > 0 || isRosettaOfficial) {
|
||||||
Spacer(modifier = Modifier.width(4.dp))
|
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) {
|
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 */
|
/** 🎨 Enhanced Drawer Menu Item - пункт меню в стиле Telegram */
|
||||||
@Composable
|
@Composable
|
||||||
fun DrawerMenuItemEnhanced(
|
fun DrawerMenuItemEnhanced(
|
||||||
|
|||||||
@@ -1428,11 +1428,7 @@ fun GroupInfoScreen(
|
|||||||
isDarkTheme = isDarkTheme,
|
isDarkTheme = isDarkTheme,
|
||||||
topSurfaceColor = topSurfaceColor,
|
topSurfaceColor = topSurfaceColor,
|
||||||
backgroundColor = backgroundColor,
|
backgroundColor = backgroundColor,
|
||||||
onBack = { showEncryptionPage = false },
|
onBack = { showEncryptionPage = false }
|
||||||
onCopy = {
|
|
||||||
clipboardManager.setText(AnnotatedString(encryptionKey))
|
|
||||||
Toast.makeText(context, "Encryption key copied", Toast.LENGTH_SHORT).show()
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1570,8 +1566,7 @@ private fun GroupEncryptionKeyPage(
|
|||||||
isDarkTheme: Boolean,
|
isDarkTheme: Boolean,
|
||||||
topSurfaceColor: Color,
|
topSurfaceColor: Color,
|
||||||
backgroundColor: Color,
|
backgroundColor: Color,
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit
|
||||||
onCopy: () -> Unit
|
|
||||||
) {
|
) {
|
||||||
val uriHandler = LocalUriHandler.current
|
val uriHandler = LocalUriHandler.current
|
||||||
val safePeerTitle = peerTitle.ifBlank { "this group" }
|
val safePeerTitle = peerTitle.ifBlank { "this group" }
|
||||||
@@ -1614,14 +1609,6 @@ private fun GroupEncryptionKeyPage(
|
|||||||
fontSize = 20.sp,
|
fontSize = 20.sp,
|
||||||
fontWeight = FontWeight.SemiBold
|
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
|
// Two-half layout like Telegram
|
||||||
|
|||||||
@@ -395,6 +395,7 @@ fun MessageAttachments(
|
|||||||
attachment = attachment,
|
attachment = attachment,
|
||||||
chachaKey = chachaKey,
|
chachaKey = chachaKey,
|
||||||
privateKey = privateKey,
|
privateKey = privateKey,
|
||||||
|
currentUserPublicKey = currentUserPublicKey,
|
||||||
isOutgoing = isOutgoing,
|
isOutgoing = isOutgoing,
|
||||||
isDarkTheme = isDarkTheme,
|
isDarkTheme = isDarkTheme,
|
||||||
timestamp = timestamp,
|
timestamp = timestamp,
|
||||||
@@ -1444,6 +1445,7 @@ fun FileAttachment(
|
|||||||
attachment: MessageAttachment,
|
attachment: MessageAttachment,
|
||||||
chachaKey: String,
|
chachaKey: String,
|
||||||
privateKey: String,
|
privateKey: String,
|
||||||
|
currentUserPublicKey: String = "",
|
||||||
isOutgoing: Boolean,
|
isOutgoing: Boolean,
|
||||||
isDarkTheme: Boolean,
|
isDarkTheme: Boolean,
|
||||||
timestamp: java.util.Date,
|
timestamp: java.util.Date,
|
||||||
@@ -1557,6 +1559,7 @@ fun FileAttachment(
|
|||||||
downloadTag = downloadTag,
|
downloadTag = downloadTag,
|
||||||
chachaKey = chachaKey,
|
chachaKey = chachaKey,
|
||||||
privateKey = privateKey,
|
privateKey = privateKey,
|
||||||
|
accountPublicKey = currentUserPublicKey,
|
||||||
fileName = fileName,
|
fileName = fileName,
|
||||||
savedFile = savedFile
|
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.models.*
|
||||||
import com.rosetta.messenger.ui.chats.utils.*
|
import com.rosetta.messenger.ui.chats.utils.*
|
||||||
import com.rosetta.messenger.ui.components.AppleEmojiText
|
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.ui.onboarding.PrimaryBlue
|
||||||
import com.rosetta.messenger.utils.AttachmentFileManager
|
import com.rosetta.messenger.utils.AttachmentFileManager
|
||||||
import com.vanniktech.blurhash.BlurHash
|
import com.vanniktech.blurhash.BlurHash
|
||||||
@@ -532,6 +533,15 @@ fun MessageBubble(
|
|||||||
val combinedBackgroundColor =
|
val combinedBackgroundColor =
|
||||||
if (isSelected) selectionBackgroundColor else highlightBackgroundColor
|
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(
|
Row(
|
||||||
modifier =
|
modifier =
|
||||||
Modifier.fillMaxWidth()
|
Modifier.fillMaxWidth()
|
||||||
@@ -579,6 +589,39 @@ fun MessageBubble(
|
|||||||
Spacer(modifier = Modifier.weight(1f))
|
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 =
|
val hasOnlyMedia =
|
||||||
message.attachments.isNotEmpty() &&
|
message.attachments.isNotEmpty() &&
|
||||||
@@ -708,7 +751,8 @@ fun MessageBubble(
|
|||||||
|
|
||||||
Box(
|
Box(
|
||||||
modifier =
|
modifier =
|
||||||
Modifier.padding(end = 12.dp)
|
Modifier.align(Alignment.Bottom)
|
||||||
|
.padding(end = 12.dp)
|
||||||
.then(bubbleWidthModifier)
|
.then(bubbleWidthModifier)
|
||||||
.graphicsLayer {
|
.graphicsLayer {
|
||||||
this.alpha = selectionAlpha
|
this.alpha = selectionAlpha
|
||||||
|
|||||||
Reference in New Issue
Block a user