diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 479fca6..42f765e 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -23,8 +23,8 @@ val gitShortSha = safeGitOutput("rev-parse", "--short", "HEAD") ?: "unknown" // ═══════════════════════════════════════════════════════════ // Rosetta versioning — bump here on each release // ═══════════════════════════════════════════════════════════ -val rosettaVersionName = "1.1.4" -val rosettaVersionCode = 16 // Increment on each release +val rosettaVersionName = "1.1.5" +val rosettaVersionCode = 17 // Increment on each release android { namespace = "com.rosetta.messenger" diff --git a/app/src/main/java/com/rosetta/messenger/MainActivity.kt b/app/src/main/java/com/rosetta/messenger/MainActivity.kt index 1ce0afc..6716fe4 100644 --- a/app/src/main/java/com/rosetta/messenger/MainActivity.kt +++ b/app/src/main/java/com/rosetta/messenger/MainActivity.kt @@ -496,6 +496,7 @@ private fun EncryptedAccount.toAccountInfo(): AccountInfo { */ sealed class Screen { data object Profile : Screen() + data object ProfileFromChat : Screen() data object Requests : Screen() data object Search : Screen() data object GroupSetup : Screen() @@ -600,6 +601,9 @@ fun MainScreen( // Derived visibility — only triggers recomposition when THIS screen changes val isProfileVisible by remember { derivedStateOf { navStack.any { it is Screen.Profile } } } + val isProfileFromChatVisible by remember { + derivedStateOf { navStack.any { it is Screen.ProfileFromChat } } + } val isRequestsVisible by remember { derivedStateOf { navStack.any { it is Screen.Requests } } } val isSearchVisible by remember { derivedStateOf { navStack.any { it is Screen.Search } } } val isGroupSetupVisible by remember { derivedStateOf { navStack.any { it is Screen.GroupSetup } } } @@ -662,11 +666,18 @@ fun MainScreen( navStack = navStack.dropLast(1) } fun openOwnProfile() { - navStack = + val filteredStack = navStack.filterNot { it is Screen.ChatDetail || it is Screen.OtherProfile || it is Screen.GroupInfo } - pushScreen(Screen.Profile) + // Single state update avoids intermediate frame (chat list flash/jitter) when opening + // profile from a mention inside chat. + navStack = + if (filteredStack.lastOrNull() == Screen.Profile) { + filteredStack + } else { + filteredStack + Screen.Profile + } } fun popProfileAndChildren() { navStack = @@ -1003,8 +1014,9 @@ fun MainScreen( onBack = { popChatAndChildren() }, onUserProfileClick = { user -> if (isCurrentAccountUser(user)) { - // Свой профиль — открываем My Profile - openOwnProfile() + // Свой профиль из чата открываем поверх текущего чата, + // чтобы возврат оставался в этот чат, а не в chat list. + pushScreen(Screen.ProfileFromChat) } else { // Открываем профиль другого пользователя pushScreen(Screen.OtherProfile(user)) @@ -1028,6 +1040,39 @@ fun MainScreen( } } + SwipeBackContainer( + isVisible = isProfileFromChatVisible, + onBack = { navStack = navStack.filterNot { it is Screen.ProfileFromChat } }, + isDarkTheme = isDarkTheme, + layer = 1, + propagateBackgroundProgress = false + ) { + ProfileScreen( + isDarkTheme = isDarkTheme, + accountName = accountName, + accountUsername = accountUsername, + accountVerified = accountVerified, + accountPublicKey = accountPublicKey, + accountPrivateKeyHash = privateKeyHash, + onBack = { navStack = navStack.filterNot { it is Screen.ProfileFromChat } }, + onSaveProfile = { name, username -> + accountName = name + accountUsername = username + mainScreenScope.launch { onAccountInfoUpdated() } + }, + onLogout = onLogout, + onNavigateToTheme = { pushScreen(Screen.Theme) }, + onNavigateToAppearance = { pushScreen(Screen.Appearance) }, + onNavigateToSafety = { pushScreen(Screen.Safety) }, + onNavigateToLogs = { pushScreen(Screen.Logs) }, + onNavigateToBiometric = { pushScreen(Screen.Biometric) }, + viewModel = profileViewModel, + avatarRepository = avatarRepository, + dialogDao = RosettaDatabase.getDatabase(context).dialogDao(), + backgroundBlurColorId = backgroundBlurColorId + ) + } + var isGroupInfoSwipeEnabled by remember { mutableStateOf(true) } LaunchedEffect(selectedGroup?.publicKey) { isGroupInfoSwipeEnabled = true diff --git a/app/src/main/java/com/rosetta/messenger/data/GroupRepository.kt b/app/src/main/java/com/rosetta/messenger/data/GroupRepository.kt index ff1d259..45b88a3 100644 --- a/app/src/main/java/com/rosetta/messenger/data/GroupRepository.kt +++ b/app/src/main/java/com/rosetta/messenger/data/GroupRepository.kt @@ -1,6 +1,7 @@ package com.rosetta.messenger.data import android.content.Context +import android.util.Log import com.rosetta.messenger.crypto.CryptoManager import com.rosetta.messenger.database.GroupEntity import com.rosetta.messenger.database.MessageEntity @@ -23,6 +24,7 @@ import kotlin.coroutines.resume class GroupRepository private constructor(context: Context) { + private val appContext = context.applicationContext private val db = RosettaDatabase.getDatabase(context.applicationContext) private val groupDao = db.groupDao() private val messageDao = db.messageDao() @@ -31,9 +33,11 @@ class GroupRepository private constructor(context: Context) { private val inviteInfoCache = ConcurrentHashMap() companion object { + private const val TAG = "GroupRepository" private const val GROUP_PREFIX = "#group:" private const val GROUP_INVITE_PASSWORD = "rosetta_group" private const val GROUP_WAIT_TIMEOUT_MS = 15_000L + private const val GROUP_CREATED_MARKER = "\$a=Group created" @Volatile private var INSTANCE: GroupRepository? = null @@ -232,7 +236,20 @@ class GroupRepository private constructor(context: Context) { return GroupJoinResult(success = false, error = "Failed to construct invite") } - return joinGroup(accountPublicKey, accountPrivateKey, invite) + val joinResult = joinGroup(accountPublicKey, accountPrivateKey, invite) + + if (joinResult.success) { + val dialogPublicKey = joinResult.dialogPublicKey + if (!dialogPublicKey.isNullOrBlank()) { + emitGroupCreatedMarker( + accountPublicKey = accountPublicKey, + accountPrivateKey = accountPrivateKey, + dialogPublicKey = dialogPublicKey + ) + } + } + + return joinResult } suspend fun joinGroup( @@ -455,6 +472,23 @@ class GroupRepository private constructor(context: Context) { ) } + private suspend fun emitGroupCreatedMarker( + accountPublicKey: String, + accountPrivateKey: String, + dialogPublicKey: String + ) { + try { + val messages = MessageRepository.getInstance(appContext) + messages.initialize(accountPublicKey, accountPrivateKey) + messages.sendMessage( + toPublicKey = dialogPublicKey, + text = GROUP_CREATED_MARKER + ) + } catch (e: Exception) { + Log.w(TAG, "Failed to emit group-created marker for sync visibility", e) + } + } + private fun buildStoredGroupKey(groupKey: String, privateKey: String): String { val encrypted = CryptoManager.encryptWithPassword(groupKey, privateKey) return "group:$encrypted" diff --git a/app/src/main/java/com/rosetta/messenger/data/MessageRepository.kt b/app/src/main/java/com/rosetta/messenger/data/MessageRepository.kt index c085b76..859e8bd 100644 --- a/app/src/main/java/com/rosetta/messenger/data/MessageRepository.kt +++ b/app/src/main/java/com/rosetta/messenger/data/MessageRepository.kt @@ -764,10 +764,13 @@ class MessageRepository private constructor(private val context: Context) { groupKey ) - // 📸 Обрабатываем AVATAR attachments - сохраняем аватар отправителя + // 📸 Обрабатываем AVATAR attachments: + // в личке — сохраняем аватар отправителя, в группе — аватар группы (desktop parity) + val avatarOwnerKey = + if (isGroupMessage) toGroupDialogPublicKey(packet.toPublicKey) else packet.fromPublicKey processAvatarAttachments( packet.attachments, - packet.fromPublicKey, + avatarOwnerKey, packet.chachaKey, privateKey, plainKeyAndNonce, @@ -1176,6 +1179,11 @@ class MessageRepository private constructor(private val context: Context) { } } + private fun toGroupDialogPublicKey(value: String): String { + val groupId = normalizeGroupId(value) + return if (groupId.isBlank()) value.trim() else "#group:$groupId" + } + private fun buildStoredGroupKey(groupKey: String, privateKey: String): String { return "group:${CryptoManager.encryptWithPassword(groupKey, privateKey)}" } @@ -1510,7 +1518,7 @@ class MessageRepository private constructor(private val context: Context) { */ private suspend fun processAvatarAttachments( attachments: List, - fromPublicKey: String, + avatarOwnerKey: String, encryptedKey: String, privateKey: String, plainKeyAndNonce: ByteArray? = null, @@ -1540,18 +1548,18 @@ class MessageRepository private constructor(private val context: Context) { if (decryptedBlob != null) { // 2. Сохраняем аватар в кэш val filePath = - AvatarFileManager.saveAvatar(context, decryptedBlob, fromPublicKey) + AvatarFileManager.saveAvatar(context, decryptedBlob, avatarOwnerKey) val entity = AvatarCacheEntity( - publicKey = fromPublicKey, + publicKey = avatarOwnerKey, avatar = filePath, timestamp = System.currentTimeMillis() ) avatarDao.insertAvatar(entity) // 3. Очищаем старые аватары (оставляем последние 5) - avatarDao.deleteOldAvatars(fromPublicKey, 5) + avatarDao.deleteOldAvatars(avatarOwnerKey, 5) } else {} } catch (e: Exception) {} } diff --git a/app/src/main/java/com/rosetta/messenger/data/ReleaseNotes.kt b/app/src/main/java/com/rosetta/messenger/data/ReleaseNotes.kt index 1b11aeb..567a7d7 100644 --- a/app/src/main/java/com/rosetta/messenger/data/ReleaseNotes.kt +++ b/app/src/main/java/com/rosetta/messenger/data/ReleaseNotes.kt @@ -17,20 +17,17 @@ object ReleaseNotes { val RELEASE_NOTICE = """ Update v$VERSION_PLACEHOLDER - Профиль и группы - - Фиксированные табы в профиле и группах - - Fast-scroll с отображением даты в медиа-галерее - - Поддержка Apple Emoji в аватарах и интерфейсе - - Восстановление ключей шифрования группы по инвайт-ссылке + Подключение + - Ускорен старт соединения и handshake при входе в аккаунт + - Логика reconnect синхронизирована с desktop-поведением + - Обновлён серверный endpoint на основной production (wss) - Аватары - - Улучшено отображение аватаров: поддержка текста с эмодзи - - Улучшена логика отображения в компоненте AvatarImage + Группы + - Добавлено предзагруженное кэширование участников группы + - Убран скачок "0 members" при повторном открытии группы - Исправления - - Исправлен переход по своему тэгу в группах - - Убрана лишняя подсветка в чатах - - Корректное отображение fast-scroll при изменении размера экрана + Интерфейс + - Исправлено вертикальное выравнивание verified-галочки в списке чатов """.trimIndent() fun getNotice(version: String): String = diff --git a/app/src/main/java/com/rosetta/messenger/database/AvatarEntities.kt b/app/src/main/java/com/rosetta/messenger/database/AvatarEntities.kt index 745e9f9..efda353 100644 --- a/app/src/main/java/com/rosetta/messenger/database/AvatarEntities.kt +++ b/app/src/main/java/com/rosetta/messenger/database/AvatarEntities.kt @@ -42,12 +42,18 @@ interface AvatarDao { */ @Query("SELECT * FROM avatar_cache WHERE public_key = :publicKey ORDER BY timestamp DESC") fun getAvatars(publicKey: String): Flow> + + @Query("SELECT * FROM avatar_cache WHERE public_key IN (:publicKeys) ORDER BY timestamp DESC") + fun getAvatarsByKeys(publicKeys: List): Flow> /** * Получить последний аватар пользователя */ @Query("SELECT * FROM avatar_cache WHERE public_key = :publicKey ORDER BY timestamp DESC LIMIT 1") suspend fun getLatestAvatar(publicKey: String): AvatarCacheEntity? + + @Query("SELECT * FROM avatar_cache WHERE public_key IN (:publicKeys) ORDER BY timestamp DESC LIMIT 1") + suspend fun getLatestAvatarByKeys(publicKeys: List): AvatarCacheEntity? /** * Получить последний аватар пользователя как Flow diff --git a/app/src/main/java/com/rosetta/messenger/network/FileDownloadManager.kt b/app/src/main/java/com/rosetta/messenger/network/FileDownloadManager.kt index 13bb6f7..ca873fd 100644 --- a/app/src/main/java/com/rosetta/messenger/network/FileDownloadManager.kt +++ b/app/src/main/java/com/rosetta/messenger/network/FileDownloadManager.kt @@ -12,7 +12,9 @@ data class FileDownloadState( val fileName: String, val status: FileDownloadStatus, /** 0f..1f */ - val progress: Float = 0f + val progress: Float = 0f, + val accountPublicKey: String = "", + val savedPath: String = "" ) enum class FileDownloadStatus { @@ -84,17 +86,27 @@ object FileDownloadManager { downloadTag: String, chachaKey: String, privateKey: String, + accountPublicKey: String, fileName: String, savedFile: File ) { // Уже в процессе? if (jobs[attachmentId]?.isActive == true) return + val normalizedAccount = accountPublicKey.trim() + val savedPath = savedFile.absolutePath - update(attachmentId, fileName, FileDownloadStatus.QUEUED, 0f) + update(attachmentId, fileName, FileDownloadStatus.QUEUED, 0f, normalizedAccount, savedPath) jobs[attachmentId] = scope.launch { try { - update(attachmentId, fileName, FileDownloadStatus.DOWNLOADING, 0f) + update( + attachmentId, + fileName, + FileDownloadStatus.DOWNLOADING, + 0f, + normalizedAccount, + savedPath + ) // Запускаем polling прогресса из TransportManager val progressJob = launch { @@ -103,34 +115,87 @@ object FileDownloadManager { if (entry != null) { // CDN progress: 0-100 → 0f..0.8f (80% круга — скачивание) val p = (entry.progress / 100f) * 0.8f - update(attachmentId, fileName, FileDownloadStatus.DOWNLOADING, p) + update( + attachmentId, + fileName, + FileDownloadStatus.DOWNLOADING, + p, + normalizedAccount, + savedPath + ) } } } val success = withContext(Dispatchers.IO) { if (isGroupStoredKey(chachaKey)) { - downloadGroupFile(attachmentId, downloadTag, chachaKey, privateKey, fileName, savedFile) + downloadGroupFile( + attachmentId = attachmentId, + downloadTag = downloadTag, + chachaKey = chachaKey, + privateKey = privateKey, + fileName = fileName, + savedFile = savedFile, + accountPublicKey = normalizedAccount, + savedPath = savedPath + ) } else { - downloadDirectFile(attachmentId, downloadTag, chachaKey, privateKey, fileName, savedFile) + downloadDirectFile( + attachmentId = attachmentId, + downloadTag = downloadTag, + chachaKey = chachaKey, + privateKey = privateKey, + fileName = fileName, + savedFile = savedFile, + accountPublicKey = normalizedAccount, + savedPath = savedPath + ) } } progressJob.cancel() if (success) { - update(attachmentId, fileName, FileDownloadStatus.DONE, 1f) + update( + attachmentId, + fileName, + FileDownloadStatus.DONE, + 1f, + normalizedAccount, + savedPath + ) } else { - update(attachmentId, fileName, FileDownloadStatus.ERROR, 0f) + update( + attachmentId, + fileName, + FileDownloadStatus.ERROR, + 0f, + normalizedAccount, + savedPath + ) } } catch (e: CancellationException) { throw e } catch (e: Exception) { e.printStackTrace() - update(attachmentId, fileName, FileDownloadStatus.ERROR, 0f) + update( + attachmentId, + fileName, + FileDownloadStatus.ERROR, + 0f, + normalizedAccount, + savedPath + ) } catch (_: OutOfMemoryError) { System.gc() - update(attachmentId, fileName, FileDownloadStatus.ERROR, 0f) + update( + attachmentId, + fileName, + FileDownloadStatus.ERROR, + 0f, + normalizedAccount, + savedPath + ) } finally { jobs.remove(attachmentId) // Автоочистка через 5 секунд после завершения @@ -159,25 +224,55 @@ object FileDownloadManager { chachaKey: String, privateKey: String, fileName: String, - savedFile: File + savedFile: File, + accountPublicKey: String, + savedPath: String ): Boolean { val encryptedContent = TransportManager.downloadFile(attachmentId, downloadTag) - update(attachmentId, fileName, FileDownloadStatus.DECRYPTING, 0.82f) + update( + attachmentId, + fileName, + FileDownloadStatus.DECRYPTING, + 0.82f, + accountPublicKey, + savedPath + ) val groupPassword = decodeGroupPassword(chachaKey, privateKey) if (groupPassword.isNullOrBlank()) return false val decrypted = CryptoManager.decryptWithPassword(encryptedContent, groupPassword) - update(attachmentId, fileName, FileDownloadStatus.DECRYPTING, 0.88f) + update( + attachmentId, + fileName, + FileDownloadStatus.DECRYPTING, + 0.88f, + accountPublicKey, + savedPath + ) val bytes = decrypted?.let { decodeBase64Payload(it) } ?: return false - update(attachmentId, fileName, FileDownloadStatus.DECRYPTING, 0.93f) + update( + attachmentId, + fileName, + FileDownloadStatus.DECRYPTING, + 0.93f, + accountPublicKey, + savedPath + ) withContext(Dispatchers.IO) { savedFile.parentFile?.mkdirs() savedFile.writeBytes(bytes) } - update(attachmentId, fileName, FileDownloadStatus.DECRYPTING, 0.98f) + update( + attachmentId, + fileName, + FileDownloadStatus.DECRYPTING, + 0.98f, + accountPublicKey, + savedPath + ) return true } @@ -187,14 +282,30 @@ object FileDownloadManager { chachaKey: String, privateKey: String, fileName: String, - savedFile: File + savedFile: File, + accountPublicKey: String, + savedPath: String ): Boolean { // Streaming: скачиваем во temp file val tempFile = TransportManager.downloadFileRaw(attachmentId, downloadTag) - update(attachmentId, fileName, FileDownloadStatus.DECRYPTING, 0.83f) + update( + attachmentId, + fileName, + FileDownloadStatus.DECRYPTING, + 0.83f, + accountPublicKey, + savedPath + ) val decryptedKeyAndNonce = MessageCrypto.decryptKeyFromSender(chachaKey, privateKey) - update(attachmentId, fileName, FileDownloadStatus.DECRYPTING, 0.88f) + update( + attachmentId, + fileName, + FileDownloadStatus.DECRYPTING, + 0.88f, + accountPublicKey, + savedPath + ) // Streaming decrypt: tempFile → AES → inflate → base64 → savedFile withContext(Dispatchers.IO) { @@ -208,13 +319,36 @@ object FileDownloadManager { tempFile.delete() } } - update(attachmentId, fileName, FileDownloadStatus.DECRYPTING, 0.98f) + update( + attachmentId, + fileName, + FileDownloadStatus.DECRYPTING, + 0.98f, + accountPublicKey, + savedPath + ) return true } - private fun update(id: String, fileName: String, status: FileDownloadStatus, progress: Float) { + private fun update( + id: String, + fileName: String, + status: FileDownloadStatus, + progress: Float, + accountPublicKey: String, + savedPath: String + ) { _downloads.update { map -> - map + (id to FileDownloadState(id, fileName, status, progress)) + map + ( + id to FileDownloadState( + attachmentId = id, + fileName = fileName, + status = status, + progress = progress, + accountPublicKey = accountPublicKey, + savedPath = savedPath + ) + ) } } } diff --git a/app/src/main/java/com/rosetta/messenger/network/ProtocolManager.kt b/app/src/main/java/com/rosetta/messenger/network/ProtocolManager.kt index 0f5d0db..554469b 100644 --- a/app/src/main/java/com/rosetta/messenger/network/ProtocolManager.kt +++ b/app/src/main/java/com/rosetta/messenger/network/ProtocolManager.kt @@ -29,9 +29,10 @@ 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" + // Desktop parity: use the same primary WebSocket endpoint as desktop client. + private const val SERVER_ADDRESS = "wss://wss.rosetta.im" private const val DEVICE_PREFS = "rosetta_protocol" private const val DEVICE_ID_KEY = "device_id" private const val DEVICE_ID_LENGTH = 128 @@ -59,6 +60,9 @@ object ProtocolManager { // Typing status private val _typingUsers = MutableStateFlow>(emptySet()) val typingUsers: StateFlow> = _typingUsers.asStateFlow() + private val typingStateLock = Any() + private val typingUsersByDialog = mutableMapOf>() + private val typingTimeoutJobs = ConcurrentHashMap() // Connected devices and pending verification requests private val _devices = MutableStateFlow>(emptyList()) @@ -200,6 +204,7 @@ object ProtocolManager { */ fun initializeAccount(publicKey: String, privateKey: String) { setSyncInProgress(false) + clearTypingState() messageRepository?.initialize(publicKey, privateKey) if (resyncRequiredAfterAccountInit || protocol?.isAuthenticated() == true) { resyncRequiredAfterAccountInit = false @@ -369,13 +374,26 @@ object ProtocolManager { // Обработчик typing (0x0B) waitPacket(0x0B) { packet -> val typingPacket = packet as PacketTyping - - // Добавляем в set и удаляем через 3 секунды - _typingUsers.value = _typingUsers.value + typingPacket.fromPublicKey - scope.launch { - delay(3000) - _typingUsers.value = _typingUsers.value - typingPacket.fromPublicKey + val fromPublicKey = typingPacket.fromPublicKey.trim() + val toPublicKey = typingPacket.toPublicKey.trim() + if (fromPublicKey.isBlank() || toPublicKey.isBlank()) return@waitPacket + + val ownPublicKey = + getProtocol().getPublicKey()?.trim().orEmpty().ifBlank { + messageRepository?.getCurrentAccountKey()?.trim().orEmpty() + } + if (ownPublicKey.isNotBlank() && fromPublicKey.equals(ownPublicKey, ignoreCase = true)) { + return@waitPacket } + + val dialogKey = + resolveTypingDialogKey( + fromPublicKey = fromPublicKey, + toPublicKey = toPublicKey, + ownPublicKey = ownPublicKey + ) ?: return@waitPacket + + rememberTypingEvent(dialogKey, fromPublicKey) } // 📱 Обработчик списка устройств (0x17) @@ -508,6 +526,71 @@ object ProtocolManager { return normalized.startsWith("#group:") || normalized.startsWith("group:") } + private fun normalizeGroupDialogKey(value: String): String { + val trimmed = value.trim() + val normalized = trimmed.lowercase(Locale.ROOT) + return when { + normalized.startsWith("#group:") -> "#group:${trimmed.substringAfter(':').trim()}" + normalized.startsWith("group:") -> "#group:${trimmed.substringAfter(':').trim()}" + else -> trimmed + } + } + + private fun resolveTypingDialogKey( + fromPublicKey: String, + toPublicKey: String, + ownPublicKey: String + ): String? { + return when { + isGroupDialogKey(toPublicKey) -> normalizeGroupDialogKey(toPublicKey) + ownPublicKey.isNotBlank() && toPublicKey.equals(ownPublicKey, ignoreCase = true) -> + fromPublicKey.trim() + else -> null + } + } + + private fun makeTypingTimeoutKey(dialogKey: String, fromPublicKey: String): String { + return "${dialogKey.lowercase(Locale.ROOT)}|${fromPublicKey.lowercase(Locale.ROOT)}" + } + + private fun rememberTypingEvent(dialogKey: String, fromPublicKey: String) { + val normalizedDialogKey = + if (isGroupDialogKey(dialogKey)) normalizeGroupDialogKey(dialogKey) else dialogKey.trim() + val normalizedFrom = fromPublicKey.trim() + if (normalizedDialogKey.isBlank() || normalizedFrom.isBlank()) return + + synchronized(typingStateLock) { + val users = typingUsersByDialog.getOrPut(normalizedDialogKey) { mutableSetOf() } + users.add(normalizedFrom) + _typingUsers.value = typingUsersByDialog.keys.toSet() + } + + val timeoutKey = makeTypingTimeoutKey(normalizedDialogKey, normalizedFrom) + typingTimeoutJobs.remove(timeoutKey)?.cancel() + typingTimeoutJobs[timeoutKey] = + scope.launch { + delay(TYPING_INDICATOR_TIMEOUT_MS) + synchronized(typingStateLock) { + val users = typingUsersByDialog[normalizedDialogKey] + users?.remove(normalizedFrom) + if (users.isNullOrEmpty()) { + typingUsersByDialog.remove(normalizedDialogKey) + } + _typingUsers.value = typingUsersByDialog.keys.toSet() + } + typingTimeoutJobs.remove(timeoutKey) + } + } + + private fun clearTypingState() { + typingTimeoutJobs.values.forEach { it.cancel() } + typingTimeoutJobs.clear() + synchronized(typingStateLock) { + typingUsersByDialog.clear() + _typingUsers.value = emptySet() + } + } + private fun onAuthenticated() { setSyncInProgress(false) TransportManager.requestTransportServer() @@ -1021,6 +1104,7 @@ object ProtocolManager { protocol?.disconnect() protocol?.clearCredentials() messageRepository?.clearInitialization() + clearTypingState() _devices.value = emptyList() _pendingDeviceVerification.value = null syncRequestInFlight = false @@ -1036,6 +1120,7 @@ object ProtocolManager { protocol?.destroy() protocol = null messageRepository?.clearInitialization() + clearTypingState() _devices.value = emptyList() _pendingDeviceVerification.value = null syncRequestInFlight = false diff --git a/app/src/main/java/com/rosetta/messenger/providers/AuthState.kt b/app/src/main/java/com/rosetta/messenger/providers/AuthState.kt index 5632e3a..61449cd 100644 --- a/app/src/main/java/com/rosetta/messenger/providers/AuthState.kt +++ b/app/src/main/java/com/rosetta/messenger/providers/AuthState.kt @@ -163,11 +163,8 @@ class AuthStateManager( // Step 8: Connect and authenticate with protocol ProtocolManager.connect() - - // Give WebSocket time to connect before authenticating - kotlinx.coroutines.delay(500) - ProtocolManager.authenticate(keyPair.publicKey, privateKeyHash) + ProtocolManager.reconnectNowIfNeeded("auth_state_create") Result.success(decryptedAccount) } catch (e: Exception) { @@ -210,11 +207,8 @@ class AuthStateManager( // Connect and authenticate with protocol ProtocolManager.connect() - - // Give WebSocket time to connect before authenticating - kotlinx.coroutines.delay(500) - ProtocolManager.authenticate(decryptedAccount.publicKey, decryptedAccount.privateKeyHash) + ProtocolManager.reconnectNowIfNeeded("auth_state_unlock") Result.success(decryptedAccount) } catch (e: Exception) { diff --git a/app/src/main/java/com/rosetta/messenger/repository/AvatarRepository.kt b/app/src/main/java/com/rosetta/messenger/repository/AvatarRepository.kt index 57d4111..5012006 100644 --- a/app/src/main/java/com/rosetta/messenger/repository/AvatarRepository.kt +++ b/app/src/main/java/com/rosetta/messenger/repository/AvatarRepository.kt @@ -53,6 +53,36 @@ class AvatarRepository( return false } } + + private fun normalizeOwnerKey(publicKey: String): String { + val trimmed = publicKey.trim() + if (trimmed.isBlank()) return trimmed + return when { + trimmed.startsWith("#group:") -> { + val groupId = trimmed.removePrefix("#group:").trim() + if (groupId.isBlank()) trimmed else "#group:$groupId" + } + trimmed.startsWith("group:", ignoreCase = true) -> { + val groupId = trimmed.substringAfter(':').trim() + if (groupId.isBlank()) trimmed else "#group:$groupId" + } + else -> trimmed + } + } + + private fun lookupKeys(publicKey: String): List { + val normalized = normalizeOwnerKey(publicKey) + if (normalized.isBlank()) return emptyList() + val keys = linkedSetOf(normalized) + if (normalized.startsWith("#group:")) { + keys.add(normalized.removePrefix("#group:")) + } + val trimmed = publicKey.trim() + if (trimmed.isNotBlank()) { + keys.add(trimmed) + } + return keys.toList() + } /** * Получить аватары пользователя @@ -60,14 +90,20 @@ class AvatarRepository( * @param allDecode true = вся история, false = только последний (для списков) */ fun getAvatars(publicKey: String, allDecode: Boolean = false): StateFlow> { + val normalizedKey = normalizeOwnerKey(publicKey) + val keys = lookupKeys(publicKey) + if (normalizedKey.isBlank() || keys.isEmpty()) { + return MutableStateFlow(emptyList()).asStateFlow() + } + // Проверяем LRU cache (accessOrder=true обновляет позицию при get) - memoryCache[publicKey]?.let { return it.flow.asStateFlow() } + memoryCache[normalizedKey]?.let { return it.flow.asStateFlow() } // Создаем новый flow для этого пользователя val flow = MutableStateFlow>(emptyList()) // Подписываемся на изменения в БД - val job = avatarDao.getAvatars(publicKey) + val job = avatarDao.getAvatarsByKeys(keys) .onEach { entities -> val avatars = if (allDecode) { // Параллельная загрузка всей истории @@ -86,7 +122,7 @@ class AvatarRepository( } .launchIn(repositoryScope) - memoryCache[publicKey] = CacheEntry(flow, job) + memoryCache[normalizedKey] = CacheEntry(flow, job) return flow.asStateFlow() } @@ -94,7 +130,9 @@ class AvatarRepository( * Получить последний аватар пользователя (suspend версия) */ suspend fun getLatestAvatar(publicKey: String): AvatarInfo? { - val entity = avatarDao.getLatestAvatar(publicKey) ?: return null + val keys = lookupKeys(publicKey) + if (keys.isEmpty()) return null + val entity = avatarDao.getLatestAvatarByKeys(keys) ?: return null return loadAndDecryptAvatar(entity) } @@ -108,22 +146,24 @@ class AvatarRepository( suspend fun saveAvatar(fromPublicKey: String, base64Image: String) { withContext(Dispatchers.IO) { try { + val ownerKey = normalizeOwnerKey(fromPublicKey) + if (ownerKey.isBlank()) return@withContext // Сохраняем файл - val filePath = AvatarFileManager.saveAvatar(context, base64Image, fromPublicKey) + val filePath = AvatarFileManager.saveAvatar(context, base64Image, ownerKey) // Сохраняем в БД val entity = AvatarCacheEntity( - publicKey = fromPublicKey, + publicKey = ownerKey, avatar = filePath, timestamp = System.currentTimeMillis() ) avatarDao.insertAvatar(entity) // Очищаем старые аватары (оставляем только последние N) - avatarDao.deleteOldAvatars(fromPublicKey, MAX_AVATAR_HISTORY) + avatarDao.deleteOldAvatars(ownerKey, MAX_AVATAR_HISTORY) // 🔄 Обновляем memory cache если он существует - val cached = memoryCache[fromPublicKey] + val cached = memoryCache[ownerKey] if (cached != null) { val avatarInfo = loadAndDecryptAvatar(entity) if (avatarInfo != null) { diff --git a/app/src/main/java/com/rosetta/messenger/ui/auth/AuthProtocolSync.kt b/app/src/main/java/com/rosetta/messenger/ui/auth/AuthProtocolSync.kt index da4d999..805a591 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/auth/AuthProtocolSync.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/auth/AuthProtocolSync.kt @@ -2,20 +2,24 @@ package com.rosetta.messenger.ui.auth import com.rosetta.messenger.network.ProtocolManager import com.rosetta.messenger.network.ProtocolState -import kotlinx.coroutines.delay import kotlinx.coroutines.flow.first import kotlinx.coroutines.withTimeoutOrNull +internal fun startAuthHandshakeFast(publicKey: String, privateKeyHash: String) { + // Desktop parity: start connection+handshake immediately, without artificial waits. + ProtocolManager.connect() + ProtocolManager.authenticate(publicKey, privateKeyHash) + ProtocolManager.reconnectNowIfNeeded("auth_fast_start") +} + internal suspend fun awaitAuthHandshakeState( publicKey: String, privateKeyHash: String, attempts: Int = 2, timeoutMs: Long = 25_000L ): ProtocolState? { - repeat(attempts) { - ProtocolManager.disconnect() - delay(200) - ProtocolManager.authenticate(publicKey, privateKeyHash) + repeat(attempts) { attempt -> + startAuthHandshakeFast(publicKey, privateKeyHash) val state = withTimeoutOrNull(timeoutMs) { ProtocolManager.state.first { @@ -26,6 +30,7 @@ internal suspend fun awaitAuthHandshakeState( if (state != null) { return state } + ProtocolManager.reconnectNowIfNeeded("auth_handshake_retry_${attempt + 1}") } return null } diff --git a/app/src/main/java/com/rosetta/messenger/ui/auth/SetPasswordScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/auth/SetPasswordScreen.kt index f27dfca..c314f8b 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/auth/SetPasswordScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/auth/SetPasswordScreen.kt @@ -527,17 +527,10 @@ fun SetPasswordScreen( keyPair.privateKey ) - val handshakeState = - awaitAuthHandshakeState( - keyPair.publicKey, - privateKeyHash - ) - if (handshakeState == null) { - error = - "Failed to connect to server. Please try again." - isCreating = false - return@launch - } + startAuthHandshakeFast( + keyPair.publicKey, + privateKeyHash + ) accountManager.setCurrentAccount(keyPair.publicKey) diff --git a/app/src/main/java/com/rosetta/messenger/ui/auth/UnlockScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/auth/UnlockScreen.kt index 234a297..fb5ef8d 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/auth/UnlockScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/auth/UnlockScreen.kt @@ -116,12 +116,7 @@ val decryptedPrivateKey = CryptoManager.decryptWithPassword( name = selectedAccount.name ) - val handshakeState = awaitAuthHandshakeState(account.publicKey, privateKeyHash) - if (handshakeState == null) { - onError("Failed to connect to server") - onUnlocking(false) - return - } + startAuthHandshakeFast(account.publicKey, privateKeyHash) accountManager.setCurrentAccount(account.publicKey) onSuccess(decryptedAccount) diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt index 82c4223..5aeee90 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt @@ -2,12 +2,19 @@ package com.rosetta.messenger.ui.chats import android.app.Activity import android.content.Context +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.graphics.Shader +import android.graphics.drawable.BitmapDrawable import android.net.Uri +import android.view.Gravity +import android.view.View import android.view.inputmethod.InputMethodManager import androidx.activity.compose.BackHandler import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.Crossfade import androidx.compose.animation.core.Spring import androidx.compose.animation.core.animateDpAsState @@ -55,6 +62,7 @@ import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.platform.LocalLifecycleOwner @@ -63,8 +71,10 @@ import androidx.compose.ui.platform.LocalView import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import androidx.compose.ui.viewinterop.AndroidView import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.viewmodel.compose.viewModel @@ -107,6 +117,26 @@ import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +private data class IncomingRunAvatarAccumulator( + val senderPublicKey: String, + val senderDisplayName: String, + var minTopPx: Float, + var maxBottomPx: Float +) + +private data class IncomingRunAvatarOverlay( + val runHeadIndex: Int, + val senderPublicKey: String, + val senderDisplayName: String, + val topPx: Float +) + +private data class IncomingRunAvatarUiState( + val showOnRunHeads: Set, + val showOnRunTails: Set, + val overlays: List +) + @OptIn( ExperimentalMaterial3Api::class, androidx.compose.foundation.ExperimentalFoundationApi::class, @@ -153,6 +183,7 @@ fun ChatDetailScreen( val chatWallpaperResId = remember(chatWallpaperId) { ThemeWallpapers.drawableResOrNull(chatWallpaperId) } val textColor = if (isDarkTheme) Color.White else Color.Black val secondaryTextColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF8E8E93) + val dateHeaderTextColor = if (isDarkTheme) Color.White else secondaryTextColor val headerIconColor = Color.White // 🔥 Keyboard & Emoji Coordinator @@ -186,6 +217,10 @@ fun ChatDetailScreen( // 🔥 MESSAGE SELECTION STATE - для Reply/Forward var selectedMessages by remember { mutableStateOf>(emptySet()) } val isSelectionMode = selectedMessages.isNotEmpty() + // После long press AndroidView текста может прислать tap на отпускание. + // В этом окне игнорируем tap по этому же сообщению, чтобы selection не снимался. + var longPressSuppressedMessageId by remember { mutableStateOf(null) } + var longPressSuppressUntilMs by remember { mutableLongStateOf(0L) } // 💬 MESSAGE CONTEXT MENU STATE var contextMenuMessage by remember { mutableStateOf(null) } @@ -210,13 +245,22 @@ fun ChatDetailScreen( } } - // 🔥 Закрытие экрана - мгновенно прячем клавиатуру через InputMethodManager - val hideKeyboardAndBack: () -> Unit = { - // Используем нативный InputMethodManager для МГНОВЕННОГО закрытия + val hideInputOverlays: () -> Unit = { val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager imm.hideSoftInputFromWindow(view.windowToken, 0) - focusManager.clearFocus() + window?.let { win -> + androidx.core.view.WindowCompat.getInsetsController(win, view) + .hide(androidx.core.view.WindowInsetsCompat.Type.ime()) + } + keyboardController?.hide() + focusManager.clearFocus(force = true) + showEmojiPicker = false + } + + // 🔥 Закрытие экрана - мгновенно прячем клавиатуру через InputMethodManager + val hideKeyboardAndBack: () -> Unit = { + hideInputOverlays() onBack() } @@ -228,10 +272,7 @@ fun ChatDetailScreen( else user.title.ifEmpty { user.publicKey.take(10) } val openDialogInfo: () -> Unit = { - val imm = - context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager - imm.hideSoftInputFromWindow(view.windowToken, 0) - focusManager.clearFocus() + hideInputOverlays() showContextMenu = false contextMenuMessage = null if (isGroupChat) { @@ -424,6 +465,9 @@ fun ChatDetailScreen( var groupAdminKeys by remember(user.publicKey, currentUserPublicKey) { mutableStateOf>(emptySet()) } + var groupMembersCount by remember(user.publicKey, currentUserPublicKey) { + mutableStateOf(null) + } var mentionCandidates by remember(user.publicKey, currentUserPublicKey) { mutableStateOf>(emptyList()) } @@ -438,6 +482,7 @@ fun ChatDetailScreen( LaunchedEffect(isGroupChat, user.publicKey, currentUserPublicKey) { if (!isGroupChat || user.publicKey.isBlank() || currentUserPublicKey.isBlank()) { groupAdminKeys = emptySet() + groupMembersCount = null mentionCandidates = emptyList() return@LaunchedEffect } @@ -445,15 +490,20 @@ fun ChatDetailScreen( val members = withContext(Dispatchers.IO) { groupRepository.requestGroupMembers(user.publicKey).orEmpty() } - val adminKey = members.firstOrNull().orEmpty() + val normalizedMembers = + members.map { it.trim() } + .filter { it.isNotBlank() } + .distinct() + groupMembersCount = normalizedMembers.size + + val adminKey = normalizedMembers.firstOrNull().orEmpty() groupAdminKeys = if (adminKey.isBlank()) emptySet() else setOf(adminKey) mentionCandidates = withContext(Dispatchers.IO) { - members.map { it.trim() } - .filter { it.isNotBlank() && !it.equals(currentUserPublicKey.trim(), ignoreCase = true) } - .distinct() + normalizedMembers + .filter { !it.equals(currentUserPublicKey.trim(), ignoreCase = true) } .mapNotNull { memberKey -> val resolvedUser = viewModel.resolveUserForProfile(memberKey) ?: return@mapNotNull null val normalizedUsername = resolvedUser.username.trim().trimStart('@') @@ -522,6 +572,31 @@ fun ChatDetailScreen( } } + // Long press должен только включать selection для сообщения (идемпотентно), + // иначе при двойном колбэке (text + bubble) сообщение мгновенно "откатывается". + val selectMessageOnLongPress: (messageId: String, canSelect: Boolean) -> Unit = + { messageId, canSelect -> + if (canSelect && !selectedMessages.contains(messageId)) { + selectedMessages = selectedMessages + messageId + } + } + val suppressTapAfterLongPress: (messageId: String) -> Unit = + { messageId -> + longPressSuppressedMessageId = messageId + longPressSuppressUntilMs = System.currentTimeMillis() + 350L + } + val shouldIgnoreTapAfterLongPress: (messageId: String) -> Boolean = + { messageId -> + val now = System.currentTimeMillis() + val isSuppressed = + longPressSuppressedMessageId == messageId && + now <= longPressSuppressUntilMs + if (isSuppressed || now > longPressSuppressUntilMs) { + longPressSuppressedMessageId = null + } + isSuppressed + } + // 🔥 ПАГИНАЦИЯ: Загружаем старые сообщения при прокрутке вверх // NOTE: Не нужен ручной scrollToItem - LazyColumn с reverseLayout=true // автоматически сохраняет позицию благодаря стабильным ключам (key = message.id) @@ -595,7 +670,250 @@ fun ChatDetailScreen( // �🔥 messagesWithDates — pre-computed in ViewModel on Dispatchers.Default // (dedup + sort + date headers off the main thread) val messagesWithDates by viewModel.messagesWithDates.collectAsState() + val resolveSenderPublicKey: (ChatMessage?) -> String = + remember(isGroupChat, currentUserPublicKey, user.publicKey) { + { msg -> + when { + msg == null -> "" + msg.senderPublicKey.isNotBlank() -> msg.senderPublicKey.trim() + msg.isOutgoing -> currentUserPublicKey.trim() + isGroupChat -> "" + else -> user.publicKey.trim() + } + } + } + val resolveSenderIdentity: (ChatMessage?) -> String = + remember(isGroupChat, currentUserPublicKey, user.publicKey) { + { msg -> + when { + msg == null -> "" + msg.isOutgoing -> + "out:${currentUserPublicKey.trim().lowercase(Locale.ROOT)}" + msg.senderPublicKey.isNotBlank() -> + "in:${msg.senderPublicKey.trim().lowercase(Locale.ROOT)}" + isGroupChat && msg.senderName.isNotBlank() -> + "name:${msg.senderName.trim().lowercase(Locale.ROOT)}" + !isGroupChat -> + "in:${user.publicKey.trim().lowercase(Locale.ROOT)}" + else -> "" + } + } + } + val isMessageBoundary: (ChatMessage, ChatMessage?) -> Boolean = + remember(isGroupChat, currentUserPublicKey, user.publicKey) { + { currentMessage, adjacentMessage -> + if (adjacentMessage == null) { + true + } else if (adjacentMessage.isOutgoing != currentMessage.isOutgoing) { + true + } else if ( + kotlin.math.abs( + currentMessage.timestamp.time - + adjacentMessage.timestamp.time + ) > 60_000L + ) { + true + } else if ( + isGroupChat && + !currentMessage.isOutgoing && + !adjacentMessage.isOutgoing + ) { + val currentSenderIdentity = + resolveSenderIdentity(currentMessage) + val adjacentSenderIdentity = + resolveSenderIdentity(adjacentMessage) + if ( + currentSenderIdentity.isBlank() || + adjacentSenderIdentity.isBlank() + ) { + true + } else { + !currentSenderIdentity.equals( + adjacentSenderIdentity, + ignoreCase = true + ) + } + } else { + false + } + } + } + val messageRunNewestIndex = + remember(messagesWithDates, isGroupChat, currentUserPublicKey, user.publicKey) { + IntArray(messagesWithDates.size).also { runHeadByIndex -> + messagesWithDates.indices.forEach { messageIndex -> + if (messageIndex == 0) { + runHeadByIndex[messageIndex] = messageIndex + } else { + val currentMessage = messagesWithDates[messageIndex].first + val newerMessage = messagesWithDates[messageIndex - 1].first + runHeadByIndex[messageIndex] = + if (isMessageBoundary(currentMessage, newerMessage)) { + messageIndex + } else { + runHeadByIndex[messageIndex - 1] + } + } + } + } + } + val messageRunOldestIndexByHead = + remember(messageRunNewestIndex) { + IntArray(messageRunNewestIndex.size) { it }.also { runTailByHead -> + messageRunNewestIndex.indices.forEach { messageIndex -> + val runHeadIndex = messageRunNewestIndex[messageIndex] + runTailByHead[runHeadIndex] = messageIndex + } + } + } + val density = LocalDensity.current + val incomingRunAvatarSize = 42.dp + val incomingRunAvatarInsetStart = 6.dp + val incomingRunAvatarTopGuard = 4.dp + val incomingRunAvatarUiState by remember( + isGroupChat, + messageRunNewestIndex, + messageRunOldestIndexByHead, + messagesWithDates, + listState, + density + ) { + derivedStateOf { + if (!isGroupChat || messagesWithDates.isEmpty()) { + return@derivedStateOf IncomingRunAvatarUiState( + showOnRunHeads = emptySet(), + showOnRunTails = emptySet(), + overlays = emptyList() + ) + } + val layoutInfo = listState.layoutInfo + val visibleItems = layoutInfo.visibleItemsInfo + if (visibleItems.isEmpty()) { + return@derivedStateOf IncomingRunAvatarUiState( + showOnRunHeads = emptySet(), + showOnRunTails = emptySet(), + overlays = emptyList() + ) + } + val avatarSizePx = with(density) { incomingRunAvatarSize.toPx() } + val topGuardPx = with(density) { incomingRunAvatarTopGuard.toPx() } + val viewportStart = layoutInfo.viewportStartOffset.toFloat() + val viewportEnd = layoutInfo.viewportEndOffset.toFloat() + val maxAvatarTop = viewportEnd - avatarSizePx + val visibleIndexSet = visibleItems.map { it.index }.toHashSet() + val visibleRuns = linkedMapOf() + + visibleItems.forEach { itemInfo -> + val visibleIndex = itemInfo.index + if (visibleIndex !in messagesWithDates.indices) { + return@forEach + } + val message = messagesWithDates[visibleIndex].first + if (message.isOutgoing) { + return@forEach + } + val senderPublicKey = resolveSenderPublicKey(message).trim() + if (senderPublicKey.isBlank()) { + return@forEach + } + val runHeadIndex = + messageRunNewestIndex.getOrNull(visibleIndex) + ?: visibleIndex + val itemTopPx = itemInfo.offset.toFloat() + val itemBottomPx = (itemInfo.offset + itemInfo.size).toFloat() + val currentRun = visibleRuns[runHeadIndex] + if (currentRun == null) { + visibleRuns[runHeadIndex] = + IncomingRunAvatarAccumulator( + senderPublicKey = senderPublicKey, + senderDisplayName = + message.senderName.ifBlank { + senderPublicKey + }, + minTopPx = itemTopPx, + maxBottomPx = itemBottomPx + ) + } else { + if (itemTopPx < currentRun.minTopPx) { + currentRun.minTopPx = itemTopPx + } + if (itemBottomPx > currentRun.maxBottomPx) { + currentRun.maxBottomPx = itemBottomPx + } + } + } + + val showOnRunHeads = hashSetOf() + val showOnRunTails = hashSetOf() + val overlays = arrayListOf() + + visibleRuns.forEach { (runHeadIndex, runData) -> + val runTailIndex = + messageRunOldestIndexByHead.getOrNull(runHeadIndex) + ?: runHeadIndex + val isRunHeadVisible = visibleIndexSet.contains(runHeadIndex) + val isRunTailVisible = visibleIndexSet.contains(runTailIndex) + + when { + isRunHeadVisible -> { + // Start/default phase: keep avatar on the lower (newest) bubble. + showOnRunHeads.add(runHeadIndex) + } + isRunTailVisible -> { + // End phase: keep avatar attached to the last bubble in run. + showOnRunTails.add(runHeadIndex) + } + else -> { + // Middle phase: floating avatar while scrolling through run. + var avatarTopPx = + kotlin.math.min( + runData.maxBottomPx - avatarSizePx, + maxAvatarTop + ) + val topClampPx = runData.minTopPx + topGuardPx + if (avatarTopPx < topClampPx) { + avatarTopPx = topClampPx + } + if ( + avatarTopPx + avatarSizePx > viewportStart && + avatarTopPx < viewportEnd + ) { + overlays += + IncomingRunAvatarOverlay( + runHeadIndex = runHeadIndex, + senderPublicKey = + runData.senderPublicKey, + senderDisplayName = + runData.senderDisplayName, + topPx = avatarTopPx + ) + } + } + } + } + + IncomingRunAvatarUiState( + showOnRunHeads = showOnRunHeads, + showOnRunTails = showOnRunTails, + overlays = overlays.sortedBy { it.topPx } + ) + } + } + val openProfileByPublicKey: (String) -> Unit = { rawPublicKey -> + val normalizedPublicKey = rawPublicKey.trim() + if (normalizedPublicKey.isNotBlank()) { + scope.launch { + val resolvedUser = + viewModel.resolveUserForProfile(normalizedPublicKey) + if (resolvedUser != null) { + showContextMenu = false + contextMenuMessage = null + onUserProfileClick(resolvedUser) + } + } + } + } // 🔥 Функция для скролла к сообщению с подсветкой val scrollToMessage: (String) -> Unit = { messageId -> scope.launch { @@ -635,10 +953,13 @@ fun ChatDetailScreen( val isRosettaOfficial = user.title.equals("Rosetta", ignoreCase = true) || user.username.equals("rosetta", ignoreCase = true) || isSystemAccount + val groupMembersSubtitleCount = groupMembersCount ?: 0 + val groupMembersSubtitle = + "$groupMembersSubtitleCount member${if (groupMembersSubtitleCount == 1) "" else "s"}" val chatSubtitle = when { isSavedMessages -> "Notes" - isGroupChat -> "group" + isGroupChat -> groupMembersSubtitle isTyping -> "" // Пустая строка, используем компонент TypingIndicator isOnline -> "online" isSystemAccount -> "official account" @@ -721,6 +1042,15 @@ fun ChatDetailScreen( .maxWithOrNull(compareBy({ it.timestamp.time }, { it.id })) ?.id var lastNewestMessageId by remember { mutableStateOf(null) } + val isAtBottom by remember(listState) { + derivedStateOf { + listState.firstVisibleItemIndex == 0 && + listState.firstVisibleItemScrollOffset <= 12 + } + } + val showScrollToBottomButton by remember(messagesWithDates, isAtBottom, isSendingMessage) { + derivedStateOf { messagesWithDates.isNotEmpty() && !isAtBottom && !isSendingMessage } + } // Telegram-style: Прокрутка ТОЛЬКО при новых сообщениях (не при пагинации) // 🔥 Скроллим только если изменился ID самого нового сообщения @@ -730,10 +1060,15 @@ fun ChatDetailScreen( lastNewestMessageId != null && newestMessageId != lastNewestMessageId ) { - // Новое сообщение пришло - скроллим вниз - delay(50) // Debounce - ждём стабилизации - listState.animateScrollToItem(0) - wasManualScroll = false + val newestMessage = messages.firstOrNull { it.id == newestMessageId } + val isOwnOutgoingMessage = newestMessage?.isOutgoing == true + val shouldAutoScroll = isAtBottom || isOwnOutgoingMessage + + if (shouldAutoScroll) { + delay(50) // Debounce - ждём стабилизации + listState.animateScrollToItem(0) + wasManualScroll = false + } } lastNewestMessageId = newestMessageId } @@ -1141,7 +1476,8 @@ fun ChatDetailScreen( color = Color.White, maxLines = 1, overflow = android.text.TextUtils.TruncateAt.END, - enableLinks = false + enableLinks = false, + minHeightMultiplier = 1.1f ) if (!isSavedMessages && !isGroupChat && @@ -1226,12 +1562,8 @@ fun ChatDetailScreen( Box { IconButton( onClick = { - // Закрываем - // клавиатуру перед открытием меню - keyboardController - ?.hide() - focusManager - .clearFocus() + // Закрываем клавиатуру/emoji перед открытием меню + hideInputOverlays() showMenu = true }, @@ -1277,6 +1609,7 @@ fun ChatDetailScreen( onGroupInfoClick = { showMenu = false + hideInputOverlays() onGroupInfoClick( user ) @@ -1284,6 +1617,7 @@ fun ChatDetailScreen( onSearchMembersClick = { showMenu = false + hideInputOverlays() onGroupInfoClick( user ) @@ -1847,11 +2181,10 @@ fun ChatDetailScreen( // Keep wallpaper on a fixed full-screen layer so it doesn't rescale // when content paddings (bottom bar/IME) change. if (chatWallpaperResId != null) { - Image( - painter = painterResource(id = chatWallpaperResId), - contentDescription = "Chat wallpaper", + TiledChatWallpaper( + wallpaperResId = chatWallpaperResId, modifier = Modifier.matchParentSize(), - contentScale = ContentScale.Crop + tileScale = 0.9f ) } else { Box( @@ -1998,7 +2331,7 @@ fun ChatDetailScreen( } } // Есть сообщения - else -> + else -> { LazyColumn( state = listState, modifier = @@ -2050,36 +2383,6 @@ fun ChatDetailScreen( index, (message, showDate) -> - // Определяем, - // показывать ли - // хвостик - // (последнее - // сообщение в - // группе) - val nextMessage = - messagesWithDates - .getOrNull( - index + - 1 - ) - ?.first - val showTail = - nextMessage == - null || - nextMessage - .isOutgoing != - message.isOutgoing || - (message.timestamp - .time - - nextMessage - .timestamp - .time) > - 60_000 - - // Определяем начало - // новой - // группы (для - // отступов) val prevMessage = messagesWithDates .getOrNull( @@ -2087,18 +2390,59 @@ fun ChatDetailScreen( 1 ) ?.first + val nextMessage = + messagesWithDates + .getOrNull( + index + + 1 + ) + ?.first + val senderPublicKeyForMessage = + resolveSenderPublicKey( + message + ) + // Для reverseLayout + DESC списка: + // prev = более новое сообщение, + // next = более старое. + val showTail = + isMessageBoundary(message, prevMessage) val isGroupStart = - prevMessage != - null && - (prevMessage - .isOutgoing != - message.isOutgoing || - (prevMessage - .timestamp - .time - - message.timestamp - .time) > - 60_000) + isMessageBoundary(message, nextMessage) + val runHeadIndex = + messageRunNewestIndex.getOrNull( + index + ) ?: index + val runTailIndex = + messageRunOldestIndexByHead + .getOrNull( + runHeadIndex + ) + ?: runHeadIndex + val isHeadPhase = + incomingRunAvatarUiState + .showOnRunHeads + .contains( + runHeadIndex + ) + val isTailPhase = + incomingRunAvatarUiState + .showOnRunTails + .contains( + runHeadIndex + ) + val showIncomingGroupAvatar = + isGroupChat && + !message.isOutgoing && + senderPublicKeyForMessage + .isNotBlank() && + ((index == + runHeadIndex && + isHeadPhase && + showTail) || + (index == + runTailIndex && + isTailPhase && + isGroupStart)) Column { if (showDate @@ -2110,19 +2454,11 @@ fun ChatDetailScreen( .time ), secondaryTextColor = - secondaryTextColor + dateHeaderTextColor ) } val selectionKey = message.id - val senderPublicKeyForMessage = - if (message.senderPublicKey.isNotBlank()) { - message.senderPublicKey - } else if (message.isOutgoing) { - currentUserPublicKey - } else { - user.publicKey - } MessageBubble( message = message, @@ -2134,6 +2470,8 @@ fun ChatDetailScreen( isSelectionMode, showTail = showTail, + showIncomingGroupAvatar = + showIncomingGroupAvatar, isGroupStart = isGroupStart, isSelected = @@ -2154,9 +2492,12 @@ fun ChatDetailScreen( message.senderName, isGroupChat = isGroupChat, + dialogPublicKey = + user.publicKey, showGroupSenderLabel = isGroupChat && - !message.isOutgoing, + !message.isOutgoing && + isGroupStart, isGroupSenderAdmin = isGroupChat && senderPublicKeyForMessage @@ -2203,12 +2544,21 @@ fun ChatDetailScreen( .clearFocus() showEmojiPicker = false - toggleMessageSelection( + selectMessageOnLongPress( selectionKey, true ) + suppressTapAfterLongPress( + selectionKey + ) }, onClick = { + if (shouldIgnoreTapAfterLongPress( + selectionKey + ) + ) { + return@MessageBubble + } val hasAvatar = message.attachments .any { @@ -2306,6 +2656,12 @@ fun ChatDetailScreen( true ) }, + onAvatarClick = { + avatarOwnerPublicKey -> + openProfileByPublicKey( + avatarOwnerPublicKey + ) + }, onForwardedSenderClick = { senderPublicKey -> // Open profile of the forwarded message sender scope.launch { @@ -2461,10 +2817,109 @@ fun ChatDetailScreen( } } } + if (incomingRunAvatarUiState.overlays.isNotEmpty()) { + val avatarInsetPx = + with(density) { + incomingRunAvatarInsetStart + .roundToPx() + } + Box( + modifier = + Modifier.matchParentSize() + .graphicsLayer { + clip = true + } + ) { + incomingRunAvatarUiState.overlays.forEach { overlay -> + key( + overlay.runHeadIndex, + overlay.senderPublicKey + ) { + Box( + modifier = + Modifier.offset { + IntOffset( + avatarInsetPx, + overlay.topPx + .toInt() + ) + } + ) { + AvatarImage( + publicKey = + overlay.senderPublicKey, + avatarRepository = + avatarRepository, + size = + incomingRunAvatarSize, + isDarkTheme = + isDarkTheme, + onClick = + if (isSelectionMode) null + else { + { + openProfileByPublicKey( + overlay.senderPublicKey + ) + } + }, + displayName = + overlay.senderDisplayName + ) + } + } + } + } + } + } } } } // Конец Column внутри Scaffold content + AnimatedVisibility( + visible = showScrollToBottomButton && !isLoading && !isSelectionMode, + enter = fadeIn(animationSpec = tween(140)) + expandVertically(expandFrom = Alignment.Bottom), + exit = fadeOut(animationSpec = tween(120)) + shrinkVertically(shrinkTowards = Alignment.Bottom), + modifier = + Modifier.align(Alignment.BottomEnd) + .padding( + end = 14.dp, + bottom = if (isSystemAccount) 24.dp else 16.dp + ) + ) { + Box( + modifier = + Modifier.size(38.dp) + .clip(CircleShape) + .background( + if (isDarkTheme) Color(0xFF2D2E31) + else Color.White + ) + .clickable( + indication = null, + interactionSource = + remember { + MutableInteractionSource() + } + ) { + scope.launch { + listState.animateScrollToItem(0) + wasManualScroll = false + } + }, + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = TablerIcons.ChevronDown, + contentDescription = "Scroll to bottom", + tint = + if (isDarkTheme) Color(0xFFF2F2F3) + else Color(0xFF2D3138), + modifier = Modifier.size(21.dp) + ) + } + } + // 📎 Media Picker — new tab-based ChatAttachAlert (Telegram-style) // Feature flag: set USE_NEW_ATTACH_ALERT to false to use old MediaPickerBottomSheet val USE_NEW_ATTACH_ALERT = true @@ -2944,3 +3399,62 @@ fun ChatDetailScreen( } // Закрытие outer Box } + +@Composable +private fun TiledChatWallpaper( + wallpaperResId: Int, + modifier: Modifier = Modifier, + tileScale: Float = 0.9f +) { + val context = LocalContext.current + val wallpaperDrawable = + remember(wallpaperResId, tileScale, context) { + val decoded = BitmapFactory.decodeResource(context.resources, wallpaperResId) + val normalizedScale = tileScale.coerceIn(0.2f, 2f) + + val scaledBitmap = + decoded?.let { original -> + if (normalizedScale == 1f) { + original + } else { + val width = + (original.width * normalizedScale) + .toInt() + .coerceAtLeast(1) + val height = + (original.height * normalizedScale) + .toInt() + .coerceAtLeast(1) + val scaled = + Bitmap.createScaledBitmap( + original, + width, + height, + true + ) + if (scaled != original) { + original.recycle() + } + scaled + } + } + + val safeBitmap = + scaledBitmap + ?: Bitmap.createBitmap(1, 1, Bitmap.Config.ARGB_8888) + .apply { + eraseColor(android.graphics.Color.TRANSPARENT) + } + + BitmapDrawable(context.resources, safeBitmap).apply { + setTileModeXY(Shader.TileMode.REPEAT, Shader.TileMode.REPEAT) + gravity = Gravity.TOP or Gravity.START + } + } + + AndroidView( + modifier = modifier, + factory = { ctx -> View(ctx).apply { background = wallpaperDrawable } }, + update = { view -> view.background = wallpaperDrawable } + ) +} diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatViewModel.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatViewModel.kt index 4c539d8..69f1b0a 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatViewModel.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatViewModel.kt @@ -242,9 +242,32 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { // 🔥 Сохраняем ссылки на обработчики для очистки в onCleared() // ВАЖНО: Должны быть определены ДО init блока! - private val typingPacketHandler: (Packet) -> Unit = { packet -> + private val typingPacketHandler: (Packet) -> Unit = typingPacketHandler@{ packet -> val typingPacket = packet as PacketTyping - if (typingPacket.fromPublicKey == opponentKey) { + val currentDialog = opponentKey?.trim().orEmpty() + val currentAccount = myPublicKey?.trim().orEmpty() + if (currentDialog.isBlank() || currentAccount.isBlank()) { + return@typingPacketHandler + } + + val fromPublicKey = typingPacket.fromPublicKey.trim() + val toPublicKey = typingPacket.toPublicKey.trim() + if (fromPublicKey.isBlank() || toPublicKey.isBlank()) { + return@typingPacketHandler + } + if (fromPublicKey.equals(currentAccount, ignoreCase = true)) { + return@typingPacketHandler + } + + val shouldShowTyping = + if (isGroupDialogKey(currentDialog)) { + normalizeGroupId(toPublicKey).equals(normalizeGroupId(currentDialog), ignoreCase = true) + } else { + fromPublicKey.equals(currentDialog, ignoreCase = true) && + toPublicKey.equals(currentAccount, ignoreCase = true) + } + + if (shouldShowTyping) { showTypingIndicator() } } @@ -4163,7 +4186,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { } // 📁 Для Saved Messages - не отправляем typing indicator - if (opponent == sender || isGroupDialogKey(opponent)) { + if (opponent.equals(sender, ignoreCase = true)) { return } diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListScreen.kt index c0ad443..90141d7 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListScreen.kt @@ -192,6 +192,28 @@ private fun isGroupDialogKey(value: String): Boolean { return normalized.startsWith("#group:") || normalized.startsWith("group:") } +private fun normalizeGroupDialogKey(value: String): String { + val trimmed = value.trim() + val normalized = trimmed.lowercase(Locale.ROOT) + return when { + normalized.startsWith("#group:") -> "#group:${trimmed.substringAfter(':').trim()}" + normalized.startsWith("group:") -> "#group:${trimmed.substringAfter(':').trim()}" + else -> trimmed + } +} + +private fun isTypingForDialog(dialogKey: String, typingDialogs: Set): Boolean { + if (typingDialogs.isEmpty()) return false + if (isGroupDialogKey(dialogKey)) { + val normalizedDialogKey = normalizeGroupDialogKey(dialogKey) + return typingDialogs.any { + isGroupDialogKey(it) && + normalizeGroupDialogKey(it).equals(normalizedDialogKey, ignoreCase = true) + } + } + return typingDialogs.any { it.equals(dialogKey.trim(), ignoreCase = true) } +} + private val TELEGRAM_DIALOG_AVATAR_START = 10.dp private val TELEGRAM_DIALOG_TEXT_START = 72.dp private val TELEGRAM_DIALOG_AVATAR_SIZE = 54.dp @@ -442,9 +464,29 @@ fun ChatsListScreen( val syncInProgress by ProtocolManager.syncInProgress.collectAsState() val pendingDeviceVerification by ProtocolManager.pendingDeviceVerification.collectAsState() - // � Active downloads tracking (for header indicator) - val activeDownloads by com.rosetta.messenger.network.TransportManager.downloading.collectAsState() - val hasActiveDownloads = activeDownloads.isNotEmpty() + // 📥 Active FILE downloads tracking (account-scoped, excludes photo downloads) + val currentAccountKey = remember(accountPublicKey) { accountPublicKey.trim() } + val allFileDownloads by + com.rosetta.messenger.network.FileDownloadManager.downloads.collectAsState() + val accountFileDownloads = remember(allFileDownloads, currentAccountKey) { + allFileDownloads.values + .filter { + it.accountPublicKey.equals(currentAccountKey, ignoreCase = true) + } + .sortedByDescending { it.progress } + } + val activeFileDownloads = remember(accountFileDownloads) { + accountFileDownloads.filter { + it.status == com.rosetta.messenger.network.FileDownloadStatus.QUEUED || + it.status == + com.rosetta.messenger.network.FileDownloadStatus + .DOWNLOADING || + it.status == + com.rosetta.messenger.network.FileDownloadStatus + .DECRYPTING + } + } + val hasActiveDownloads = activeFileDownloads.isNotEmpty() // �🔥 Пользователи, которые сейчас печатают val typingUsers by ProtocolManager.typingUsers.collectAsState() @@ -473,6 +515,7 @@ fun ChatsListScreen( // 📬 Requests screen state var showRequestsScreen by remember { mutableStateOf(false) } + var showDownloadsScreen by remember { mutableStateOf(false) } var isInlineRequestsTransitionLocked by remember { mutableStateOf(false) } var isRequestsRouteTapLocked by remember { mutableStateOf(false) } val inlineRequestsTransitionLockMs = 340L @@ -498,6 +541,10 @@ fun ChatsListScreen( } } + LaunchedEffect(currentAccountKey) { + showDownloadsScreen = false + } + // 📂 Accounts section expanded state (arrow toggle) var accountsSectionExpanded by remember { mutableStateOf(false) } @@ -535,8 +582,10 @@ fun ChatsListScreen( // Back: drawer → закрыть, selection → сбросить // Когда ничего не открыто — НЕ перехватываем, система сама закроет приложение корректно - BackHandler(enabled = isSelectionMode || drawerState.isOpen) { - if (isSelectionMode) { + BackHandler(enabled = showDownloadsScreen || isSelectionMode || drawerState.isOpen) { + if (showDownloadsScreen) { + showDownloadsScreen = false + } else if (isSelectionMode) { selectedChatKeys = emptySet() } else if (drawerState.isOpen) { scope.launch { drawerState.close() } @@ -709,7 +758,7 @@ fun ChatsListScreen( ) { ModalNavigationDrawer( drawerState = drawerState, - gesturesEnabled = !showRequestsScreen, + gesturesEnabled = !showRequestsScreen && !showDownloadsScreen, drawerContent = { ModalDrawerSheet( drawerContainerColor = Color.Transparent, @@ -1335,7 +1384,12 @@ fun ChatsListScreen( ) { Scaffold( topBar = { - key(isDarkTheme, showRequestsScreen, isSelectionMode) { + key( + isDarkTheme, + showRequestsScreen, + showDownloadsScreen, + isSelectionMode + ) { Crossfade( targetState = isSelectionMode, animationSpec = tween(200), @@ -1473,12 +1527,16 @@ fun ChatsListScreen( // ═══ NORMAL HEADER ═══ TopAppBar( navigationIcon = { - if (showRequestsScreen) { + if (showRequestsScreen || showDownloadsScreen) { IconButton( onClick = { - setInlineRequestsVisible( - false - ) + if (showDownloadsScreen) { + showDownloadsScreen = false + } else { + setInlineRequestsVisible( + false + ) + } } ) { Icon( @@ -1557,7 +1615,14 @@ fun ChatsListScreen( } }, title = { - if (showRequestsScreen) { + if (showDownloadsScreen) { + Text( + "Downloads", + fontWeight = FontWeight.Bold, + fontSize = 20.sp, + color = Color.White + ) + } else if (showRequestsScreen) { Text( "Requests", fontWeight = @@ -1596,10 +1661,18 @@ fun ChatsListScreen( } }, actions = { - if (!showRequestsScreen) { + if (!showRequestsScreen && !showDownloadsScreen) { // 📥 Animated download indicator (Telegram-style) Box( - modifier = androidx.compose.ui.Modifier.size(48.dp), + modifier = + androidx.compose.ui.Modifier + .size(48.dp) + .clickable( + enabled = hasActiveDownloads, + onClick = { + showDownloadsScreen = true + } + ), contentAlignment = Alignment.Center ) { com.rosetta.messenger.ui.components.AnimatedDownloadIndicator( @@ -1709,39 +1782,105 @@ fun ChatsListScreen( val showSkeleton = isLoading - // 🎬 Animated content transition between main list and - // requests AnimatedContent( - targetState = showRequestsScreen, + targetState = showDownloadsScreen, transitionSpec = { if (targetState) { - // Opening requests: slide in from right + // Opening downloads: slide from right with fade slideInHorizontally( - animationSpec = tween(280, easing = FastOutSlowInEasing) - ) { fullWidth -> fullWidth } + fadeIn( + animationSpec = + tween( + 280, + easing = + FastOutSlowInEasing + ) + ) { fullWidth -> + fullWidth + } + fadeIn( animationSpec = tween(200) ) togetherWith slideOutHorizontally( - animationSpec = tween(280, easing = FastOutSlowInEasing) - ) { fullWidth -> -fullWidth / 4 } + fadeOut( + animationSpec = + tween( + 280, + easing = + FastOutSlowInEasing + ) + ) { fullWidth -> + -fullWidth / 4 + } + fadeOut( animationSpec = tween(150) ) } else { - // Closing requests: slide out to right + // Closing downloads: slide back to right slideInHorizontally( - animationSpec = tween(280, easing = FastOutSlowInEasing) - ) { fullWidth -> -fullWidth / 4 } + fadeIn( + animationSpec = + tween( + 280, + easing = + FastOutSlowInEasing + ) + ) { fullWidth -> + -fullWidth / 4 + } + fadeIn( animationSpec = tween(200) ) togetherWith slideOutHorizontally( - animationSpec = tween(280, easing = FastOutSlowInEasing) - ) { fullWidth -> fullWidth } + fadeOut( + animationSpec = + tween( + 280, + easing = + FastOutSlowInEasing + ) + ) { fullWidth -> + fullWidth + } + fadeOut( animationSpec = tween(150) ) } }, - label = "RequestsTransition" - ) { isRequestsScreen -> + label = "DownloadsTransition" + ) { isDownloadsScreen -> + if (isDownloadsScreen) { + FileDownloadsScreen( + downloads = activeFileDownloads, + isDarkTheme = isDarkTheme, + modifier = Modifier.fillMaxSize() + ) + } else { + // 🎬 Animated content transition between main list and + // requests + AnimatedContent( + targetState = showRequestsScreen, + transitionSpec = { + if (targetState) { + // Opening requests: slide in from right + slideInHorizontally( + animationSpec = tween(280, easing = FastOutSlowInEasing) + ) { fullWidth -> fullWidth } + fadeIn( + animationSpec = tween(200) + ) togetherWith + slideOutHorizontally( + animationSpec = tween(280, easing = FastOutSlowInEasing) + ) { fullWidth -> -fullWidth / 4 } + fadeOut( + animationSpec = tween(150) + ) + } else { + // Closing requests: slide out to right + slideInHorizontally( + animationSpec = tween(280, easing = FastOutSlowInEasing) + ) { fullWidth -> -fullWidth / 4 } + fadeIn( + animationSpec = tween(200) + ) togetherWith + slideOutHorizontally( + animationSpec = tween(280, easing = FastOutSlowInEasing) + ) { fullWidth -> fullWidth } + fadeOut( + animationSpec = tween(150) + ) + } + }, + label = "RequestsTransition" + ) { isRequestsScreen -> if (isRequestsScreen) { // 📬 Show Requests Screen with swipe-back Box( @@ -2054,13 +2193,14 @@ fun ChatsListScreen( ) val isTyping by remember( - dialog.opponentKey + dialog.opponentKey, + typingUsers ) { derivedStateOf { - typingUsers - .contains( - dialog.opponentKey - ) + isTypingForDialog( + dialog.opponentKey, + typingUsers + ) } } val isSelectedDialog = @@ -2241,7 +2381,9 @@ fun ChatsListScreen( } } } - } // Close AnimatedContent + } // Close AnimatedContent + } // Close downloads/main content switch + } // Close Downloads AnimatedContent // Console button removed } @@ -3746,7 +3888,11 @@ fun DialogItemContent( MessageRepository.isSystemAccount(dialog.opponentKey) if (dialog.verified > 0 || isRosettaOfficial) { Spacer(modifier = Modifier.width(4.dp)) - VerifiedBadge(verified = if (dialog.verified > 0) dialog.verified else 1, size = 16) + VerifiedBadge( + verified = if (dialog.verified > 0) dialog.verified else 1, + size = 16, + modifier = Modifier.offset(y = (-2).dp) + ) } // 🔒 Красная иконка замочка для заблокированных пользователей if (isBlocked) { @@ -4561,6 +4707,136 @@ fun RequestsScreen( } } +@Composable +private fun FileDownloadsScreen( + downloads: List, + isDarkTheme: Boolean, + modifier: Modifier = Modifier +) { + val background = if (isDarkTheme) Color(0xFF1A1A1A) else Color(0xFFF2F2F7) + val card = if (isDarkTheme) Color(0xFF212123) else Color.White + val primaryText = if (isDarkTheme) Color.White else Color(0xFF111111) + val secondaryText = if (isDarkTheme) Color(0xFF9B9B9F) else Color(0xFF6E6E73) + val divider = if (isDarkTheme) Color(0xFF2F2F31) else Color(0xFFE7E7EC) + + if (downloads.isEmpty()) { + Box( + modifier = modifier.background(background), + contentAlignment = Alignment.Center + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Icon( + imageVector = TablerIcons.Download, + contentDescription = null, + tint = secondaryText, + modifier = Modifier.size(34.dp) + ) + Spacer(modifier = Modifier.height(10.dp)) + Text( + text = "No active file downloads", + color = primaryText, + fontSize = 16.sp, + fontWeight = FontWeight.Medium + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = "New file downloads will appear here.", + color = secondaryText, + fontSize = 13.sp + ) + } + } + return + } + + LazyColumn( + modifier = modifier.background(background), + contentPadding = PaddingValues(horizontal = 12.dp, vertical = 10.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + items(items = downloads, key = { it.attachmentId }) { item -> + Surface( + color = card, + shape = RoundedCornerShape(14.dp), + tonalElevation = 0.dp + ) { + Column(modifier = Modifier.fillMaxWidth().padding(12.dp)) { + Row( + verticalAlignment = Alignment.CenterVertically + ) { + Box( + modifier = Modifier + .size(34.dp) + .clip(CircleShape) + .background( + if (isDarkTheme) Color(0xFF2B4E6E) + else Color(0xFFDCEEFF) + ), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = TablerIcons.Download, + contentDescription = null, + tint = if (isDarkTheme) Color(0xFF8FC6FF) else Color(0xFF228BE6), + modifier = Modifier.size(18.dp) + ) + } + Spacer(modifier = Modifier.width(10.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + text = item.fileName.ifBlank { "Unknown file" }, + color = primaryText, + fontSize = 15.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Spacer(modifier = Modifier.height(2.dp)) + Text( + text = formatDownloadStatusText(item), + color = secondaryText, + fontSize = 12.sp + ) + } + } + Spacer(modifier = Modifier.height(10.dp)) + LinearProgressIndicator( + progress = item.progress.coerceIn(0f, 1f), + modifier = Modifier + .fillMaxWidth() + .height(4.dp) + .clip(RoundedCornerShape(50)), + color = if (isDarkTheme) Color(0xFF4DA6FF) else Color(0xFF228BE6), + trackColor = if (isDarkTheme) Color(0xFF3A3A3D) else Color(0xFFD8D8DE) + ) + Spacer(modifier = Modifier.height(8.dp)) + Divider(color = divider, thickness = 0.5.dp) + Spacer(modifier = Modifier.height(6.dp)) + Text( + text = item.savedPath.ifBlank { "Storage path unavailable" }, + color = secondaryText, + fontSize = 11.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } + } + } +} + +private fun formatDownloadStatusText( + item: com.rosetta.messenger.network.FileDownloadState +): String { + val percent = (item.progress.coerceIn(0f, 1f) * 100).toInt().coerceIn(0, 100) + return when (item.status) { + com.rosetta.messenger.network.FileDownloadStatus.QUEUED -> "Queued" + com.rosetta.messenger.network.FileDownloadStatus.DOWNLOADING -> "Downloading $percent%" + com.rosetta.messenger.network.FileDownloadStatus.DECRYPTING -> "Decrypting" + com.rosetta.messenger.network.FileDownloadStatus.DONE -> "Completed" + com.rosetta.messenger.network.FileDownloadStatus.ERROR -> "Download failed" + } +} + /** 🎨 Enhanced Drawer Menu Item - пункт меню в стиле Telegram */ @Composable fun DrawerMenuItemEnhanced( diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/GroupInfoScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/GroupInfoScreen.kt index 9b31a02..00652f2 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/GroupInfoScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/GroupInfoScreen.kt @@ -1,8 +1,12 @@ package com.rosetta.messenger.ui.chats import android.app.Activity +import android.content.Context +import android.view.inputmethod.InputMethodManager import android.widget.Toast import androidx.activity.compose.BackHandler +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut @@ -88,6 +92,7 @@ import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.platform.LocalView @@ -131,13 +136,20 @@ import com.rosetta.messenger.ui.components.AvatarImage import com.rosetta.messenger.ui.components.SharedMediaFastScrollOverlay import com.rosetta.messenger.ui.components.VerifiedBadge import com.rosetta.messenger.ui.icons.TelegramIcons +import com.rosetta.messenger.ui.settings.FullScreenAvatarViewer +import com.rosetta.messenger.ui.settings.ProfilePhotoPicker +import com.rosetta.messenger.utils.AvatarFileManager +import com.rosetta.messenger.utils.ImageCropHelper import androidx.lifecycle.viewmodel.compose.viewModel import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import androidx.core.view.WindowCompat import org.json.JSONArray +import org.json.JSONObject import java.text.SimpleDateFormat import java.util.Date import java.util.Locale @@ -203,6 +215,75 @@ private object GroupMembersMemoryCache { } } +private data class GroupMembersDiskCacheEntry( + val members: List, + val updatedAtMs: Long +) + +private object GroupMembersDiskCache { + private const val PREFS_NAME = "group_members_cache" + private const val KEY_PREFIX = "entry_" + private const val TTL_MS = 12 * 60 * 60 * 1000L + + fun getAny(context: Context, key: String): GroupMembersDiskCacheEntry? = read(context, key) + + fun getFresh(context: Context, key: String): GroupMembersDiskCacheEntry? { + val entry = read(context, key) ?: return null + return if (System.currentTimeMillis() - entry.updatedAtMs <= TTL_MS) entry else null + } + + fun put(context: Context, key: String, members: List) { + if (key.isBlank()) return + val normalizedMembers = members.map { it.trim() }.filter { it.isNotBlank() }.distinct() + if (normalizedMembers.isEmpty()) return + val payload = + JSONObject().apply { + put("updatedAtMs", System.currentTimeMillis()) + put("members", JSONArray().apply { normalizedMembers.forEach { put(it) } }) + }.toString() + context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + .edit() + .putString(KEY_PREFIX + key, payload) + .apply() + } + + fun remove(context: Context, key: String) { + if (key.isBlank()) return + context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + .edit() + .remove(KEY_PREFIX + key) + .apply() + } + + private fun read(context: Context, key: String): GroupMembersDiskCacheEntry? { + if (key.isBlank()) return null + val raw = + context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + .getString(KEY_PREFIX + key, null) ?: return null + return runCatching { + val json = JSONObject(raw) + val membersArray = json.optJSONArray("members") ?: JSONArray() + val membersList = + buildList { + repeat(membersArray.length()) { index -> + val value = membersArray.optString(index).trim() + if (value.isNotBlank()) add(value) + } + } + .distinct() + if (membersList.isEmpty()) { + null + } else { + GroupMembersDiskCacheEntry( + members = membersList, + updatedAtMs = json.optLong("updatedAtMs", 0L) + ) + } + } + .getOrNull() + } +} + private data class GroupMediaItem( val key: String, val attachment: MessageAttachment, @@ -238,6 +319,7 @@ fun GroupInfoScreen( ) { val context = androidx.compose.ui.platform.LocalContext.current val view = LocalView.current + val focusManager = LocalFocusManager.current val clipboardManager = androidx.compose.ui.platform.LocalClipboardManager.current val hapticFeedback = LocalHapticFeedback.current val scope = rememberCoroutineScope() @@ -253,6 +335,20 @@ fun GroupInfoScreen( val secondaryText = Color(0xFF8E8E93) val accentColor = if (isDarkTheme) Color(0xFF5AA5FF) else Color(0xFF228BE6) val actionContentColor = if (isDarkTheme) Color.White else Color(0xFF1C1C1E) + val groupActionButtonBlue = if (isDarkTheme) Color(0xFF285683) else Color(0xFF2478C2) + + LaunchedEffect(Unit) { + val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager + repeat(3) { + imm.hideSoftInputFromWindow(view.windowToken, 0) + (context as? Activity)?.window?.let { window -> + WindowCompat.getInsetsController(window, view) + .hide(androidx.core.view.WindowInsetsCompat.Type.ime()) + } + focusManager.clearFocus(force = true) + delay(16) + } + } // Keep status bar unified with group header color. DisposableEffect(topSurfaceColor, view) { @@ -299,19 +395,40 @@ fun GroupInfoScreen( var showEncryptionPage by rememberSaveable(dialogPublicKey) { mutableStateOf(false) } var encryptionKey by rememberSaveable(dialogPublicKey) { mutableStateOf("") } var encryptionKeyLoading by remember { mutableStateOf(false) } - var membersLoading by remember { mutableStateOf(false) } + var membersLoading by remember(dialogPublicKey) { mutableStateOf(false) } var isMuted by remember { mutableStateOf(false) } + var showGroupAvatarPicker by rememberSaveable(dialogPublicKey) { mutableStateOf(false) } + var showGroupAvatarViewer by rememberSaveable(dialogPublicKey) { mutableStateOf(false) } + var groupAvatarViewerTimestamp by rememberSaveable(dialogPublicKey) { mutableStateOf(0L) } + var groupAvatarViewerBitmap by remember(dialogPublicKey) { mutableStateOf(null) } var showAddMembersPicker by rememberSaveable(dialogPublicKey) { mutableStateOf(false) } var pendingInviteText by rememberSaveable(dialogPublicKey) { mutableStateOf("") } var isRefreshingMembers by remember(dialogPublicKey) { mutableStateOf(false) } - var members by remember(dialogPublicKey) { mutableStateOf>(emptyList()) } - val memberInfoByKey = remember(dialogPublicKey) { mutableStateMapOf() } - // Real online status from PacketOnlineState (0x05), NOT from SearchUser.online - val memberOnlineStatus = remember(dialogPublicKey) { mutableStateMapOf() } val membersCacheKey = remember(currentUserPublicKey, normalizedGroupId) { "${currentUserPublicKey.trim().lowercase()}|${normalizedGroupId.trim().lowercase()}" } + val initialMemoryMembersCache = remember(membersCacheKey) { + GroupMembersMemoryCache.getAny(membersCacheKey) + } + val initialDiskMembersCache = remember(membersCacheKey) { + GroupMembersDiskCache.getAny(context, membersCacheKey) + } + val hasInitialMembersCache = remember(initialMemoryMembersCache, initialDiskMembersCache) { + initialMemoryMembersCache != null || initialDiskMembersCache != null + } + val initialMembers = remember(initialMemoryMembersCache, initialDiskMembersCache) { + initialMemoryMembersCache?.members ?: initialDiskMembersCache?.members.orEmpty() + } + var members by remember(dialogPublicKey, membersCacheKey) { mutableStateOf(initialMembers) } + val memberInfoByKey = + remember(dialogPublicKey, membersCacheKey) { + mutableStateMapOf().apply { + initialMemoryMembersCache?.memberInfoByKey?.let { putAll(it) } + } + } + // Real online status from PacketOnlineState (0x05), NOT from SearchUser.online + val memberOnlineStatus = remember(dialogPublicKey, membersCacheKey) { mutableStateMapOf() } val groupEntity by produceState( initialValue = null, @@ -390,6 +507,49 @@ fun GroupInfoScreen( groupEntity?.description?.trim().orEmpty() } + val cropLauncher = + rememberLauncherForActivityResult( + contract = ActivityResultContracts.StartActivityForResult() + ) { result -> + val croppedUri = ImageCropHelper.getCroppedImageUri(result) + val cropError = ImageCropHelper.getCropError(result) + if (croppedUri != null) { + scope.launch { + val preparedBase64 = + withContext(Dispatchers.IO) { + val imageBytes = + runCatching { + context.contentResolver.openInputStream(croppedUri)?.use { stream -> + stream.readBytes() + } + }.getOrNull() + imageBytes?.let { bytes -> + val prepared = AvatarFileManager.imagePrepareForNetworkTransfer(context, bytes) + if (prepared.isBlank()) null else "data:image/png;base64,$prepared" + } + } + + val repository = avatarRepository + val saved = + preparedBase64 != null && + repository != null && + runCatching { + withContext(Dispatchers.IO) { + repository.saveAvatar(dialogPublicKey, preparedBase64) + } + }.isSuccess + + Toast.makeText( + context, + if (saved) "Group avatar updated" else "Failed to update group avatar", + Toast.LENGTH_SHORT + ).show() + } + } else if (cropError != null) { + Toast.makeText(context, "Failed to crop photo", Toast.LENGTH_SHORT).show() + } + } + LaunchedEffect(currentUserPublicKey, dialogPublicKey) { if (currentUserPublicKey.isNotBlank() && dialogPublicKey.isNotBlank()) { isMuted = preferencesManager.isChatMuted(currentUserPublicKey, dialogPublicKey) @@ -417,7 +577,9 @@ fun GroupInfoScreen( } } - val hasAnyCache = GroupMembersMemoryCache.getAny(membersCacheKey) != null + val hasAnyCache = + GroupMembersMemoryCache.getAny(membersCacheKey) != null || + GroupMembersDiskCache.getAny(context, membersCacheKey) != null val shouldShowLoader = showLoader && members.isEmpty() && !hasAnyCache if (shouldShowLoader) membersLoading = true isRefreshingMembers = true @@ -455,6 +617,7 @@ fun GroupInfoScreen( members = members, memberInfoByKey = memberInfoByKey.toMap() ) + GroupMembersDiskCache.put(context, membersCacheKey, members) } finally { if (shouldShowLoader) membersLoading = false isRefreshingMembers = false @@ -463,15 +626,44 @@ fun GroupInfoScreen( } LaunchedEffect(membersCacheKey) { - val cachedEntry = GroupMembersMemoryCache.getAny(membersCacheKey) - cachedEntry?.let { cached -> + val memoryCachedEntry = GroupMembersMemoryCache.getAny(membersCacheKey) + val diskCachedEntry = GroupMembersDiskCache.getAny(context, membersCacheKey) + + memoryCachedEntry?.let { cached -> members = cached.members memberInfoByKey.clear() memberInfoByKey.putAll(cached.memberInfoByKey) + } ?: diskCachedEntry?.let { cached -> + members = cached.members + if (memberInfoByKey.isEmpty()) { + val resolvedUsers = withContext(Dispatchers.IO) { + val resolvedMap = LinkedHashMap() + cached.members.forEach { memberKey -> + ProtocolManager.getCachedUserInfo(memberKey)?.let { resolved -> + resolvedMap[memberKey] = resolved + } + } + resolvedMap + } + if (resolvedUsers.isNotEmpty()) { + memberInfoByKey.putAll(resolvedUsers) + } + } + GroupMembersMemoryCache.put( + key = membersCacheKey, + members = members, + memberInfoByKey = memberInfoByKey.toMap() + ) } - if (GroupMembersMemoryCache.getFresh(membersCacheKey) == null) { - refreshMembers(force = true, showLoader = cachedEntry == null) + val hasFreshCache = + GroupMembersMemoryCache.getFresh(membersCacheKey) != null || + GroupMembersDiskCache.getFresh(context, membersCacheKey) != null + if (!hasFreshCache) { + refreshMembers( + force = true, + showLoader = memoryCachedEntry == null && diskCachedEntry == null + ) } } @@ -581,6 +773,20 @@ fun GroupInfoScreen( members.firstOrNull()?.trim()?.equals(normalizedCurrentUserKey, ignoreCase = true) == true } } + + fun openGroupAvatarViewer() { + val repository = avatarRepository ?: return + scope.launch { + val latestAvatar = repository.getAvatars(dialogPublicKey, allDecode = false).first().firstOrNull() + ?: return@launch + groupAvatarViewerTimestamp = latestAvatar.timestamp / 1000L + groupAvatarViewerBitmap = + withContext(Dispatchers.IO) { + AvatarFileManager.base64ToBitmap(latestAvatar.base64Data) + } + showGroupAvatarViewer = true + } + } var swipedMemberKey by remember(dialogPublicKey) { mutableStateOf(null) } var memberToKick by remember(dialogPublicKey) { mutableStateOf(null) } var isKickingMember by remember(dialogPublicKey) { mutableStateOf(false) } @@ -700,6 +906,7 @@ fun GroupInfoScreen( isLeaving = false if (left) { GroupMembersMemoryCache.remove(membersCacheKey) + GroupMembersDiskCache.remove(context, membersCacheKey) onGroupLeft() } else { Toast.makeText(context, "Failed to leave group", Toast.LENGTH_SHORT).show() @@ -744,6 +951,7 @@ fun GroupInfoScreen( members = members, memberInfoByKey = memberInfoByKey.toMap() ) + GroupMembersDiskCache.put(context, membersCacheKey, members) refreshMembers(force = true, showLoader = false) Toast.makeText(context, "Member removed", Toast.LENGTH_SHORT).show() } else { @@ -777,12 +985,12 @@ fun GroupInfoScreen( modifier = Modifier .fillMaxSize() .background(backgroundColor) - .statusBarsPadding() ) { Box( modifier = Modifier .fillMaxWidth() .background(topSurfaceColor) + .statusBarsPadding() .padding(horizontal = 14.dp, vertical = 10.dp) ) { IconButton(onClick = onBack, modifier = Modifier.align(Alignment.TopStart)) { @@ -861,6 +1069,13 @@ fun GroupInfoScreen( avatarRepository = avatarRepository, size = 86.dp, isDarkTheme = isDarkTheme, + onClick = { + if (currentUserIsAdmin) { + showGroupAvatarPicker = true + } else { + openGroupAvatarViewer() + } + }, displayName = groupTitle ) @@ -877,7 +1092,7 @@ fun GroupInfoScreen( ) Text( - text = if (membersLoading) { + text = if (membersLoading || (members.isEmpty() && !hasInitialMembersCache)) { "Loading members..." } else { "${members.size} members, $onlineCount online" @@ -896,16 +1111,18 @@ fun GroupInfoScreen( modifier = Modifier.weight(1f), icon = Icons.Default.Message, label = "Message", - backgroundColor = cardColor, - contentColor = actionContentColor, + backgroundColor = groupActionButtonBlue, + contentColor = Color.White, + iconColor = Color.White, onClick = onBack ) GroupActionButton( modifier = Modifier.weight(1f), icon = if (isMuted) Icons.Default.Notifications else Icons.Default.NotificationsOff, label = if (isMuted) "Unmute" else "Mute", - backgroundColor = cardColor, - contentColor = actionContentColor, + backgroundColor = groupActionButtonBlue, + contentColor = Color.White, + iconColor = Color.White, onClick = { scope.launch { val newMutedState = !isMuted @@ -922,8 +1139,9 @@ fun GroupInfoScreen( modifier = Modifier.weight(1f), icon = Icons.Default.ExitToApp, label = "Leave", - backgroundColor = cardColor, - contentColor = Color(0xFFFF7A7A), + backgroundColor = groupActionButtonBlue, + contentColor = Color.White, + iconColor = Color.White, onClick = { showLeaveConfirm = true } ) } @@ -1335,11 +1553,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 } ) } @@ -1408,6 +1622,27 @@ fun GroupInfoScreen( } ) } + + ProfilePhotoPicker( + isVisible = showGroupAvatarPicker, + onDismiss = { showGroupAvatarPicker = false }, + onPhotoSelected = { uri -> + showGroupAvatarPicker = false + val cropIntent = ImageCropHelper.createCropIntent(context, uri, isDarkTheme) + cropLauncher.launch(cropIntent) + }, + isDarkTheme = isDarkTheme + ) + + FullScreenAvatarViewer( + isVisible = showGroupAvatarViewer, + onDismiss = { showGroupAvatarViewer = false }, + displayName = groupTitle.ifBlank { shortPublicKey(dialogPublicKey) }, + avatarTimestamp = groupAvatarViewerTimestamp, + avatarBitmap = groupAvatarViewerBitmap, + publicKey = dialogPublicKey, + isDarkTheme = isDarkTheme + ) } @Composable @@ -1456,8 +1691,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" } @@ -1477,13 +1711,13 @@ private fun GroupEncryptionKeyPage( Column( modifier = Modifier .fillMaxSize() - .statusBarsPadding() ) { // Top bar Row( modifier = Modifier .fillMaxWidth() .background(topSurfaceColor) + .statusBarsPadding() .padding(horizontal = 4.dp, vertical = 6.dp), verticalAlignment = Alignment.CenterVertically ) { @@ -1500,14 +1734,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 @@ -1595,6 +1821,7 @@ private fun GroupActionButton( label: String, backgroundColor: Color, contentColor: Color, + iconColor: Color = contentColor, onClick: () -> Unit ) { Surface( @@ -1613,7 +1840,7 @@ private fun GroupActionButton( Icon( imageVector = icon, contentDescription = null, - tint = contentColor, + tint = iconColor, modifier = Modifier.size(20.dp) ) Spacer(modifier = Modifier.height(4.dp)) diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/GroupSetupScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/GroupSetupScreen.kt index 0a02a5e..0e7973e 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/GroupSetupScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/GroupSetupScreen.kt @@ -68,6 +68,7 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.core.view.WindowCompat +import androidx.core.view.WindowInsetsCompat import coil.compose.AsyncImage import coil.request.ImageRequest import com.rosetta.messenger.R @@ -149,6 +150,10 @@ fun GroupSetupScreen( val primaryTextColor = if (isDarkTheme) Color.White else Color.Black val secondaryTextColor = Color(0xFF8E8E93) val accentColor = if (isDarkTheme) Color(0xFF5AA5FF) else Color(0xFF228BE6) + val groupAvatarCameraButtonColor = + if (isDarkTheme) sectionColor else Color(0xFF8CC9F6) + val groupAvatarCameraIconColor = + if (isDarkTheme) accentColor else Color.White androidx.compose.runtime.DisposableEffect(topSurfaceColor, view) { val window = (view.context as? Activity)?.window @@ -208,12 +213,28 @@ fun GroupSetupScreen( } } + fun dismissInputUi() { + if (showEmojiKeyboard || coordinator.isEmojiVisible || coordinator.isEmojiBoxVisible) { + coordinator.closeEmoji(hideEmoji = { showEmojiKeyboard = false }) + } else { + showEmojiKeyboard = false + } + focusManager.clearFocus(force = true) + keyboardController?.hide() + val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager + imm.hideSoftInputFromWindow(view.windowToken, 0) + (context as? Activity)?.window?.let { window -> + WindowCompat.getInsetsController(window, view).hide(WindowInsetsCompat.Type.ime()) + } + } + fun handleBack() { if (isLoading) return errorText = null if (step == GroupSetupStep.DESCRIPTION) { step = GroupSetupStep.DETAILS } else { + dismissInputUi() onBack() } } @@ -402,7 +423,7 @@ fun GroupSetupScreen( Modifier .size(64.dp) .clip(CircleShape) - .background(sectionColor) + .background(groupAvatarCameraButtonColor) .clickable(enabled = !isLoading) { showPhotoPicker = true }, @@ -423,7 +444,7 @@ fun GroupSetupScreen( Icon( painter = TelegramIcons.Camera, contentDescription = "Set group avatar", - tint = accentColor, + tint = groupAvatarCameraIconColor, modifier = Modifier.size(24.dp) ) } @@ -713,6 +734,7 @@ fun GroupSetupScreen( avatarUriString = selectedAvatarUri ) } + dismissInputUi() openGroup( dialogPublicKey = result.dialogPublicKey, groupTitle = result.title.ifBlank { title.trim() } diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/SearchScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/SearchScreen.kt index 2fc23b4..2be8c01 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/SearchScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/SearchScreen.kt @@ -32,9 +32,11 @@ import androidx.compose.ui.draw.drawWithContent import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.graphics.Color +import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.platform.LocalView import androidx.compose.ui.res.painterResource import com.rosetta.messenger.repository.AvatarRepository @@ -446,9 +448,22 @@ private fun ChatsTabContent( val dividerColor = remember(isDarkTheme) { if (isDarkTheme) Color(0xFF2A2A2A) else Color(0xFFE8E8E8) } + val suggestionsPrefs = remember(currentUserPublicKey) { + context.getSharedPreferences("search_suggestions", Context.MODE_PRIVATE) + } + val hiddenSuggestionsKey = remember(currentUserPublicKey) { + "hidden_frequent_${currentUserPublicKey}" + } // ─── Загрузка частых контактов из БД (top dialogs) ─── var frequentContacts by remember { mutableStateOf>(emptyList()) } + var hiddenSuggestionKeys by remember(currentUserPublicKey) { mutableStateOf>(emptySet()) } + var frequentSuggestionToRemove by remember { mutableStateOf(null) } + + LaunchedEffect(currentUserPublicKey) { + hiddenSuggestionKeys = + suggestionsPrefs.getStringSet(hiddenSuggestionsKey, emptySet())?.toSet() ?: emptySet() + } LaunchedEffect(currentUserPublicKey) { if (currentUserPublicKey.isBlank()) return@LaunchedEffect @@ -478,6 +493,14 @@ private fun ChatsTabContent( } catch (_: Exception) { } } } + val visibleFrequentContacts = remember(frequentContacts, hiddenSuggestionKeys) { + frequentContacts.filterNot { hiddenSuggestionKeys.contains(it.publicKey) } + } + fun removeFrequentSuggestion(publicKey: String) { + val updated = (hiddenSuggestionKeys + publicKey).toSet() + hiddenSuggestionKeys = updated + suggestionsPrefs.edit().putStringSet(hiddenSuggestionsKey, updated).apply() + } Box(modifier = Modifier.fillMaxSize()) { if (searchQuery.isEmpty()) { @@ -486,10 +509,10 @@ private fun ChatsTabContent( modifier = Modifier.fillMaxSize() ) { // ─── Горизонтальный ряд частых контактов (как в Telegram) ─── - if (frequentContacts.isNotEmpty()) { + if (visibleFrequentContacts.isNotEmpty()) { item { FrequentContactsRow( - contacts = frequentContacts, + contacts = visibleFrequentContacts, avatarRepository = avatarRepository, isDarkTheme = isDarkTheme, textColor = textColor, @@ -504,6 +527,9 @@ private fun ChatsTabContent( ) RecentSearchesManager.addUser(user) onUserSelect(user) + }, + onLongPress = { contact -> + frequentSuggestionToRemove = contact } ) } @@ -653,6 +679,69 @@ private fun ChatsTabContent( } ) } + frequentSuggestionToRemove?.let { contact -> + val scrimColor = if (isDarkTheme) Color.Black.copy(alpha = 0.42f) else Color.Black.copy(alpha = 0.24f) + val popupColor = if (isDarkTheme) Color(0xFF2C2C2E) else Color.White + val popupSecondaryText = if (isDarkTheme) Color(0xFFAEAEB2) else Color(0xFF6D6D72) + + Box( + modifier = + Modifier + .matchParentSize() + .background(scrimColor) + .clickable( + indication = null, + interactionSource = remember { MutableInteractionSource() } + ) { frequentSuggestionToRemove = null } + ) + + Surface( + modifier = + Modifier + .align(Alignment.BottomCenter) + .imePadding() + .padding(start = 20.dp, end = 20.dp, bottom = 14.dp), + color = popupColor, + shape = RoundedCornerShape(18.dp), + tonalElevation = 0.dp, + shadowElevation = 0.dp + ) { + Column( + modifier = Modifier.padding(horizontal = 18.dp, vertical = 16.dp) + ) { + Text( + text = "Remove suggestion", + color = textColor, + fontWeight = FontWeight.Bold, + fontSize = 17.sp + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = "Are you sure you want to remove ${contact.name} from suggestions?", + color = popupSecondaryText, + fontSize = 16.sp, + lineHeight = 22.sp + ) + Spacer(modifier = Modifier.height(12.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End + ) { + TextButton(onClick = { frequentSuggestionToRemove = null }) { + Text("Cancel", color = AppPrimaryBlue, fontSize = 17.sp) + } + TextButton( + onClick = { + removeFrequentSuggestion(contact.publicKey) + frequentSuggestionToRemove = null + } + ) { + Text("Remove", color = Color(0xFFFF5A5F), fontSize = 17.sp) + } + } + } + } + } } } @@ -669,13 +758,16 @@ private data class FrequentContact( ) @Composable +@OptIn(ExperimentalFoundationApi::class) private fun FrequentContactsRow( contacts: List, avatarRepository: AvatarRepository?, isDarkTheme: Boolean, textColor: Color, - onClick: (FrequentContact) -> Unit + onClick: (FrequentContact) -> Unit, + onLongPress: (FrequentContact) -> Unit ) { + val haptic = LocalHapticFeedback.current LazyRow( modifier = Modifier .fillMaxWidth() @@ -687,10 +779,15 @@ private fun FrequentContactsRow( Column( modifier = Modifier .width(72.dp) - .clickable( - indication = null, - interactionSource = remember { MutableInteractionSource() } - ) { onClick(contact) } + .combinedClickable( + indication = null, + interactionSource = remember { MutableInteractionSource() }, + onClick = { onClick(contact) }, + onLongClick = { + haptic.performHapticFeedback(HapticFeedbackType.LongPress) + onLongPress(contact) + } + ) .padding(vertical = 4.dp), horizontalAlignment = Alignment.CenterHorizontally ) { diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/components/AttachmentComponents.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/components/AttachmentComponents.kt index 5cb7337..8303514 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/components/AttachmentComponents.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/components/AttachmentComponents.kt @@ -80,6 +80,19 @@ private val whitespaceRegex = "\\s+".toRegex() private fun isGroupStoredKey(value: String): Boolean = value.startsWith("group:") +private fun canonicalGroupDialogKey(value: String): String { + val trimmed = value.trim() + if (trimmed.isBlank()) return "" + val groupId = + when { + trimmed.startsWith("#group:") -> trimmed.removePrefix("#group:").trim() + trimmed.startsWith("group:", ignoreCase = true) -> + trimmed.substringAfter(':').trim() + else -> return trimmed + } + return if (groupId.isBlank()) "" else "#group:$groupId" +} + private fun decodeGroupPassword(storedKey: String, privateKey: String): String? { if (!isGroupStoredKey(storedKey)) return null val encoded = storedKey.removePrefix("group:") @@ -332,6 +345,8 @@ fun MessageAttachments( isOutgoing: Boolean, isDarkTheme: Boolean, senderPublicKey: String, + dialogPublicKey: String = "", + isGroupChat: Boolean = false, timestamp: java.util.Date, messageStatus: MessageStatus = MessageStatus.READ, avatarRepository: AvatarRepository? = null, @@ -380,6 +395,7 @@ fun MessageAttachments( attachment = attachment, chachaKey = chachaKey, privateKey = privateKey, + currentUserPublicKey = currentUserPublicKey, isOutgoing = isOutgoing, isDarkTheme = isDarkTheme, timestamp = timestamp, @@ -392,6 +408,8 @@ fun MessageAttachments( chachaKey = chachaKey, privateKey = privateKey, senderPublicKey = senderPublicKey, + dialogPublicKey = dialogPublicKey, + isGroupChat = isGroupChat, avatarRepository = avatarRepository, currentUserPublicKey = currentUserPublicKey, isOutgoing = isOutgoing, @@ -1427,6 +1445,7 @@ fun FileAttachment( attachment: MessageAttachment, chachaKey: String, privateKey: String, + currentUserPublicKey: String = "", isOutgoing: Boolean, isDarkTheme: Boolean, timestamp: java.util.Date, @@ -1540,6 +1559,7 @@ fun FileAttachment( downloadTag = downloadTag, chachaKey = chachaKey, privateKey = privateKey, + accountPublicKey = currentUserPublicKey, fileName = fileName, savedFile = savedFile ) @@ -1775,6 +1795,8 @@ fun AvatarAttachment( chachaKey: String, privateKey: String, senderPublicKey: String, + dialogPublicKey: String = "", + isGroupChat: Boolean = false, avatarRepository: AvatarRepository?, currentUserPublicKey: String = "", isOutgoing: Boolean, @@ -1924,11 +1946,16 @@ fun AvatarAttachment( // Сохраняем аватар в репозиторий (для UI обновления) // Если это исходящее сообщение с аватаром, сохраняем для текущего // пользователя + val normalizedDialogKey = dialogPublicKey.trim() + val canonicalDialogKey = canonicalGroupDialogKey(normalizedDialogKey) + val isGroupAvatarAttachment = isGroupChat || isGroupStoredKey(chachaKey) val targetPublicKey = - if (isOutgoing && currentUserPublicKey.isNotEmpty()) { - currentUserPublicKey - } else { - senderPublicKey + when { + isGroupAvatarAttachment && canonicalDialogKey.isNotEmpty() -> + canonicalDialogKey + isOutgoing && currentUserPublicKey.isNotEmpty() -> + currentUserPublicKey + else -> senderPublicKey } // ВАЖНО: ждем завершения сохранения в репозиторий diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/components/ChatDetailComponents.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/components/ChatDetailComponents.kt index 7dca103..afb8d36 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/components/ChatDetailComponents.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/components/ChatDetailComponents.kt @@ -71,6 +71,7 @@ import com.rosetta.messenger.repository.AvatarRepository import com.rosetta.messenger.ui.chats.models.* import com.rosetta.messenger.ui.chats.utils.* import com.rosetta.messenger.ui.components.AppleEmojiText +import com.rosetta.messenger.ui.components.AvatarImage import com.rosetta.messenger.ui.onboarding.PrimaryBlue import com.rosetta.messenger.utils.AttachmentFileManager import com.vanniktech.blurhash.BlurHash @@ -289,6 +290,7 @@ fun MessageBubble( isSystemSafeChat: Boolean = false, isSelectionMode: Boolean = false, showTail: Boolean = true, + showIncomingGroupAvatar: Boolean? = null, isGroupStart: Boolean = false, isSelected: Boolean = false, isHighlighted: Boolean = false, @@ -297,6 +299,7 @@ fun MessageBubble( senderPublicKey: String = "", senderName: String = "", isGroupChat: Boolean = false, + dialogPublicKey: String = "", showGroupSenderLabel: Boolean = false, isGroupSenderAdmin: Boolean = false, currentUserPublicKey: String = "", @@ -309,6 +312,7 @@ fun MessageBubble( onRetry: () -> Unit = {}, onDelete: () -> Unit = {}, onImageClick: (attachmentId: String, bounds: ImageSourceBounds?) -> Unit = { _, _ -> }, + onAvatarClick: (senderPublicKey: String) -> Unit = {}, onForwardedSenderClick: (senderPublicKey: String) -> Unit = {}, onMentionClick: (username: String) -> Unit = {}, onGroupInviteOpen: (SearchUser) -> Unit = {}, @@ -531,6 +535,16 @@ fun MessageBubble( val combinedBackgroundColor = if (isSelected) selectionBackgroundColor else highlightBackgroundColor + val telegramIncomingAvatarSize = 42.dp + val telegramIncomingAvatarLane = 48.dp + val telegramIncomingAvatarInset = 6.dp + val shouldShowIncomingGroupAvatar = + showIncomingGroupAvatar + ?: (isGroupChat && + !message.isOutgoing && + showTail && + senderPublicKey.isNotBlank()) + Row( modifier = Modifier.fillMaxWidth() @@ -542,7 +556,7 @@ fun MessageBubble( ) { // Selection checkmark AnimatedVisibility( - visible = isSelected, + visible = isSelected && message.isOutgoing, enter = fadeIn(tween(150)) + scaleIn( @@ -568,16 +582,63 @@ fun MessageBubble( } } - AnimatedVisibility( - visible = !isSelected, + AnimatedVisibility( + visible = !isSelected || !message.isOutgoing, enter = fadeIn(tween(100)), exit = fadeOut(tween(100)) - ) { Spacer(modifier = Modifier.width(12.dp)) } + ) { + val leadingSpacerWidth = + if (!message.isOutgoing && isGroupChat) 0.dp else 12.dp + Spacer(modifier = Modifier.width(leadingSpacerWidth)) + } if (message.isOutgoing) { Spacer(modifier = Modifier.weight(1f)) } + if (!message.isOutgoing && isGroupChat) { + Box( + modifier = + Modifier.width(telegramIncomingAvatarLane) + .height(telegramIncomingAvatarSize) + .align(Alignment.Bottom), + contentAlignment = Alignment.BottomStart + ) { + if (shouldShowIncomingGroupAvatar) { + Box( + modifier = + Modifier.fillMaxSize() + .padding( + start = + telegramIncomingAvatarInset + ), + contentAlignment = Alignment.BottomStart + ) { + val avatarClickHandler: (() -> Unit)? = + if (!isSelectionMode && + senderPublicKey + .isNotBlank() + ) { + { onAvatarClick(senderPublicKey) } + } else { + null + } + AvatarImage( + publicKey = senderPublicKey, + avatarRepository = avatarRepository, + size = telegramIncomingAvatarSize, + isDarkTheme = isDarkTheme, + onClick = avatarClickHandler, + displayName = + senderName.ifBlank { + senderPublicKey + } + ) + } + } + } + } + // Проверяем - есть ли только фотки без текста val hasOnlyMedia = message.attachments.isNotEmpty() && @@ -707,7 +768,8 @@ fun MessageBubble( Box( modifier = - Modifier.padding(end = 12.dp) + Modifier.align(Alignment.Bottom) + .padding(end = 12.dp) .then(bubbleWidthModifier) .graphicsLayer { this.alpha = selectionAlpha @@ -846,6 +908,8 @@ fun MessageBubble( isOutgoing = message.isOutgoing, isDarkTheme = isDarkTheme, senderPublicKey = senderPublicKey, + dialogPublicKey = dialogPublicKey, + isGroupChat = isGroupChat, timestamp = message.timestamp, messageStatus = attachmentDisplayStatus, avatarRepository = avatarRepository, @@ -1170,6 +1234,37 @@ fun MessageBubble( // 💬 Context menu anchor (DropdownMenu positions relative to this Box) contextMenuContent() } + + if (!message.isOutgoing) { + Spacer(modifier = Modifier.weight(1f)) + AnimatedVisibility( + visible = isSelected, + enter = + fadeIn(tween(150)) + + scaleIn( + initialScale = 0.3f, + animationSpec = + spring(dampingRatio = 0.6f) + ), + exit = fadeOut(tween(100)) + scaleOut(targetScale = 0.3f) + ) { + Box( + modifier = + Modifier.padding(start = 4.dp, end = 12.dp) + .size(24.dp) + .clip(CircleShape) + .background(PrimaryBlue), + contentAlignment = Alignment.Center + ) { + Icon( + painter = TelegramIcons.Done, + contentDescription = "Selected", + tint = Color.White, + modifier = Modifier.size(16.dp) + ) + } + } + } } } } @@ -1212,27 +1307,8 @@ private fun isGroupInviteCode(text: String): Boolean { } private fun groupSenderLabelColor(publicKey: String, isDarkTheme: Boolean): Color { - val paletteDark = - listOf( - Color(0xFF7ED957), - Color(0xFF6EC1FF), - Color(0xFFFF9F68), - Color(0xFFC38AFF), - Color(0xFFFF7AA2), - Color(0xFF4DD7C8) - ) - val paletteLight = - listOf( - Color(0xFF2E7D32), - Color(0xFF1565C0), - Color(0xFFEF6C00), - Color(0xFF6A1B9A), - Color(0xFFC2185B), - Color(0xFF00695C) - ) - val palette = if (isDarkTheme) paletteDark else paletteLight - val index = kotlin.math.abs(publicKey.hashCode()) % palette.size - return palette[index] + // Match nickname color with avatar initials color. + return com.rosetta.messenger.ui.chats.getAvatarColor(publicKey, isDarkTheme).textColor } @Composable diff --git a/app/src/main/java/com/rosetta/messenger/ui/components/AppleEmojiEditText.kt b/app/src/main/java/com/rosetta/messenger/ui/components/AppleEmojiEditText.kt index f9428b2..954da89 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/components/AppleEmojiEditText.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/components/AppleEmojiEditText.kt @@ -358,13 +358,14 @@ fun AppleEmojiText( enableMentions: Boolean = false, onMentionClick: ((String) -> Unit)? = null, onClick: (() -> Unit)? = null, // 🔥 Обычный tap (selection mode в MessageBubble) - onLongClick: (() -> Unit)? = null // 🔥 Callback для long press (selection в MessageBubble) + onLongClick: (() -> Unit)? = null, // 🔥 Callback для long press (selection в MessageBubble) + minHeightMultiplier: Float = 1.5f ) { val fontSizeValue = if (fontSize == androidx.compose.ui.unit.TextUnit.Unspecified) 15f else fontSize.value // Минимальная высота для корректного отображения emoji - val minHeight = (fontSizeValue * 1.5).toInt() + val minHeight = (fontSizeValue * minHeightMultiplier).toInt() // Преобразуем FontWeight в Android typeface style val typefaceStyle = when (fontWeight) { diff --git a/app/src/main/java/com/rosetta/messenger/ui/settings/ThemeScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/settings/ThemeScreen.kt index f771de8..b9608d1 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/settings/ThemeScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/settings/ThemeScreen.kt @@ -575,7 +575,7 @@ private fun ChatPreview(isDarkTheme: Boolean, wallpaperId: String) { painter = painterResource(id = wallpaperResId), contentDescription = "Chat wallpaper preview", modifier = Modifier.fillMaxSize(), - contentScale = ContentScale.Crop + contentScale = ContentScale.FillBounds ) }