diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 7455b0f..d8c905a 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,5 +1,14 @@ # Release Notes +## 1.3.3 + +### E2EE, чаты и производительность +- В release-сборке отключена frame-диагностика E2EE (детальный frame dump теперь только в debug). +- В `ChatsListScreen` убран двойной `collectAsState(chatsState)` и вынесены route-блоки в подкомпоненты (`CallsRouteContent`, `RequestsRouteContent`, общий `SwipeBackContainer`). +- Добавлена денормализация `primary_attachment_type` в таблице `messages` + индекс `(account, primary_attachment_type, timestamp)`. +- Обновлена миграция БД `14 -> 15`: добавление колонки, индекс и backfill значения типа вложения для уже сохраненных сообщений. +- Поисковые и call-history запросы переведены на `primary_attachment_type` с fallback на legacy `attachments LIKE` для старых записей. + ## 1.2.3 ### Групповые чаты и медиа diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 24938fe..5aa981e 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.3.2" -val rosettaVersionCode = 34 // Increment on each release +val rosettaVersionName = "1.3.3" +val rosettaVersionCode = 35 // Increment on each release val customWebRtcAar = file("libs/libwebrtc-custom.aar") android { 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 45b88a3..a0581d7 100644 --- a/app/src/main/java/com/rosetta/messenger/data/GroupRepository.kt +++ b/app/src/main/java/com/rosetta/messenger/data/GroupRepository.kt @@ -456,6 +456,7 @@ class GroupRepository private constructor(context: Context) { messageId = UUID.randomUUID().toString().replace("-", "").take(32), plainMessage = encryptedPlainMessage, attachments = "[]", + primaryAttachmentType = -1, dialogKey = dialogPublicKey ) ) 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 74ab40e..1b9cc55 100644 --- a/app/src/main/java/com/rosetta/messenger/data/MessageRepository.kt +++ b/app/src/main/java/com/rosetta/messenger/data/MessageRepository.kt @@ -207,6 +207,7 @@ class MessageRepository private constructor(private val context: Context) { messageId = messageId, plainMessage = encryptedPlainMessage, attachments = "[]", + primaryAttachmentType = -1, dialogKey = dialogKey ) ) @@ -266,6 +267,7 @@ class MessageRepository private constructor(private val context: Context) { messageId = messageId, plainMessage = encryptedPlainMessage, attachments = "[]", + primaryAttachmentType = -1, dialogKey = dialogKey ) ) @@ -528,6 +530,8 @@ class MessageRepository private constructor(private val context: Context) { messageId = messageId, plainMessage = encryptedPlainMessage, // 🔒 Зашифрованный текст attachments = attachmentsJson, + primaryAttachmentType = + resolvePrimaryAttachmentType(attachments), replyToMessageId = replyToMessageId, dialogKey = dialogKey ) @@ -860,6 +864,8 @@ class MessageRepository private constructor(private val context: Context) { messageId = messageId, // 🔥 Используем сгенерированный messageId! plainMessage = encryptedPlainMessage, // 🔒 Зашифрованный текст attachments = attachmentsJson, + primaryAttachmentType = + resolvePrimaryAttachmentType(packet.attachments), dialogKey = dialogKey ) @@ -1638,6 +1644,11 @@ class MessageRepository private constructor(private val context: Context) { return jsonArray.toString() } + private fun resolvePrimaryAttachmentType(attachments: List): Int { + if (attachments.isEmpty()) return -1 + return attachments.first().type.value + } + /** * 📸 Обработка AVATAR attachments - сохранение аватара отправителя в кэш Как в desktop: при * получении attachment с типом AVATAR - сохраняем в avatar_cache 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 d78acbc..ac72991 100644 --- a/app/src/main/java/com/rosetta/messenger/data/ReleaseNotes.kt +++ b/app/src/main/java/com/rosetta/messenger/data/ReleaseNotes.kt @@ -17,10 +17,11 @@ object ReleaseNotes { val RELEASE_NOTICE = """ Update v$VERSION_PLACEHOLDER - Защищенные звонки и диагностика E2EE - - Обновлен custom WebRTC для Android и исправлена совместимость аудио E2EE с Desktop - - Улучшены diagnostics для шифрования звонков (детализация ENC/DEC в crash reports) - - В Crash Reports добавлена кнопка копирования полного лога одним действием + Оптимизация E2EE и списка чатов + - В release отключена frame-диагностика E2EE (детальные frame-логи только в debug) + - Упрощен ChatsListScreen: убрано дублирование collectAsState и вынесены route-компоненты + - Ускорены выборки по вложениям: добавлен denormalized attachment type + индекс в БД + - Добавлена миграция БД с backfill типа вложения для старых сообщений """.trimIndent() fun getNotice(version: String): String = diff --git a/app/src/main/java/com/rosetta/messenger/database/MessageEntities.kt b/app/src/main/java/com/rosetta/messenger/database/MessageEntities.kt index f23fa40..f5f490a 100644 --- a/app/src/main/java/com/rosetta/messenger/database/MessageEntities.kt +++ b/app/src/main/java/com/rosetta/messenger/database/MessageEntities.kt @@ -81,7 +81,8 @@ data class LastMessageStatus( [ Index(value = ["account", "from_public_key", "to_public_key", "timestamp"]), Index(value = ["account", "message_id"], unique = true), - Index(value = ["account", "dialog_key", "timestamp"])] + Index(value = ["account", "dialog_key", "timestamp"]), + Index(value = ["account", "primary_attachment_type", "timestamp"])] ) data class MessageEntity( @PrimaryKey(autoGenerate = true) val id: Long = 0, @@ -99,6 +100,8 @@ data class MessageEntity( @ColumnInfo(name = "plain_message") val plainMessage: String, // 🔒 Зашифрованный текст (encryptWithPassword) для хранения в БД @ColumnInfo(name = "attachments") val attachments: String = "[]", // JSON массив вложений + @ColumnInfo(name = "primary_attachment_type", defaultValue = "-1") + val primaryAttachmentType: Int = -1, // Денормализованный тип 1-го вложения (-1 если нет) @ColumnInfo(name = "reply_to_message_id") val replyToMessageId: String? = null, // ID цитируемого сообщения @ColumnInfo(name = "dialog_key") val dialogKey: String // Ключ диалога для быстрой выборки @@ -545,8 +548,10 @@ interface MessageDao { """ SELECT * FROM messages WHERE account = :account - AND attachments != '[]' - AND attachments LIKE '%"type":0%' + AND ( + primary_attachment_type = 0 + OR (primary_attachment_type = -1 AND attachments != '[]' AND attachments LIKE '%"type":0%') + ) ORDER BY timestamp DESC LIMIT :limit OFFSET :offset """ @@ -561,8 +566,10 @@ interface MessageDao { """ SELECT * FROM messages WHERE account = :account - AND attachments != '[]' - AND attachments LIKE '%"type":2%' + AND ( + primary_attachment_type = 2 + OR (primary_attachment_type = -1 AND attachments != '[]' AND attachments LIKE '%"type":2%') + ) ORDER BY timestamp DESC LIMIT :limit OFFSET :offset """ @@ -593,8 +600,14 @@ interface MessageDao { ELSE m.from_public_key END WHERE m.account = :account - AND m.attachments != '[]' - AND (m.attachments LIKE '%"type":4%' OR m.attachments LIKE '%"type": 4%') + AND ( + m.primary_attachment_type = 4 + OR ( + m.primary_attachment_type = -1 + AND m.attachments != '[]' + AND (m.attachments LIKE '%"type":4%' OR m.attachments LIKE '%"type": 4%') + ) + ) ORDER BY m.timestamp DESC, m.message_id DESC LIMIT :limit """ @@ -611,8 +624,14 @@ interface MessageDao { END AS peer_key FROM messages WHERE account = :account - AND attachments != '[]' - AND (attachments LIKE '%"type":4%' OR attachments LIKE '%"type": 4%') + AND ( + primary_attachment_type = 4 + OR ( + primary_attachment_type = -1 + AND attachments != '[]' + AND (attachments LIKE '%"type":4%' OR attachments LIKE '%"type": 4%') + ) + ) """ ) suspend fun getCallHistoryPeers(account: String): List @@ -622,8 +641,14 @@ interface MessageDao { """ DELETE FROM messages WHERE account = :account - AND attachments != '[]' - AND (attachments LIKE '%"type":4%' OR attachments LIKE '%"type": 4%') + AND ( + primary_attachment_type = 4 + OR ( + primary_attachment_type = -1 + AND attachments != '[]' + AND (attachments LIKE '%"type":4%' OR attachments LIKE '%"type": 4%') + ) + ) """ ) suspend fun deleteAllCallMessages(account: String): Int diff --git a/app/src/main/java/com/rosetta/messenger/database/RosettaDatabase.kt b/app/src/main/java/com/rosetta/messenger/database/RosettaDatabase.kt index 710d5e1..c4d71ed 100644 --- a/app/src/main/java/com/rosetta/messenger/database/RosettaDatabase.kt +++ b/app/src/main/java/com/rosetta/messenger/database/RosettaDatabase.kt @@ -18,7 +18,7 @@ import androidx.sqlite.db.SupportSQLiteDatabase AccountSyncTimeEntity::class, GroupEntity::class, PinnedMessageEntity::class], - version = 14, + version = 15, exportSchema = false ) abstract class RosettaDatabase : RoomDatabase() { @@ -202,6 +202,36 @@ abstract class RosettaDatabase : RoomDatabase() { } } + /** + * 🧱 МИГРАЦИЯ 14->15: Денормализованный тип вложения для ускорения фильтров (media/files/calls) + */ + private val MIGRATION_14_15 = + object : Migration(14, 15) { + override fun migrate(database: SupportSQLiteDatabase) { + database.execSQL( + "ALTER TABLE messages ADD COLUMN primary_attachment_type INTEGER NOT NULL DEFAULT -1" + ) + database.execSQL( + "CREATE INDEX IF NOT EXISTS index_messages_account_primary_attachment_type_timestamp ON messages (account, primary_attachment_type, timestamp)" + ) + // Best-effort backfill для уже сохраненных сообщений. + database.execSQL( + """ + UPDATE messages + SET primary_attachment_type = CASE + WHEN attachments IS NULL OR TRIM(attachments) = '' OR TRIM(attachments) = '[]' THEN -1 + WHEN attachments LIKE '%"type":0%' OR attachments LIKE '%"type": 0%' THEN 0 + WHEN attachments LIKE '%"type":1%' OR attachments LIKE '%"type": 1%' THEN 1 + WHEN attachments LIKE '%"type":2%' OR attachments LIKE '%"type": 2%' THEN 2 + WHEN attachments LIKE '%"type":3%' OR attachments LIKE '%"type": 3%' THEN 3 + WHEN attachments LIKE '%"type":4%' OR attachments LIKE '%"type": 4%' THEN 4 + ELSE -1 + END + """ + ) + } + } + fun getDatabase(context: Context): RosettaDatabase { return INSTANCE ?: synchronized(this) { @@ -224,7 +254,8 @@ abstract class RosettaDatabase : RoomDatabase() { MIGRATION_10_11, MIGRATION_11_12, MIGRATION_12_13, - MIGRATION_13_14 + MIGRATION_13_14, + MIGRATION_14_15 ) .fallbackToDestructiveMigration() // Для разработки - только // если миграция не diff --git a/app/src/main/java/com/rosetta/messenger/network/CallManager.kt b/app/src/main/java/com/rosetta/messenger/network/CallManager.kt index cc576b8..bdca1f4 100644 --- a/app/src/main/java/com/rosetta/messenger/network/CallManager.kt +++ b/app/src/main/java/com/rosetta/messenger/network/CallManager.kt @@ -3,6 +3,7 @@ package com.rosetta.messenger.network import android.content.Context import android.media.AudioManager import android.util.Log +import com.rosetta.messenger.BuildConfig import com.rosetta.messenger.data.MessageRepository import java.security.MessageDigest import java.security.SecureRandom @@ -872,13 +873,15 @@ object CallManager { } sharedKeyBytes = keyBytes.copyOf(32) breadcrumb("KE: shared key fp=${sharedKeyBytes?.fingerprintHex(8)}") - // Open native diagnostics file for frame-level logging - try { - val dir = java.io.File(appContext!!.filesDir, "crash_reports") - if (!dir.exists()) dir.mkdirs() - val diagPath = java.io.File(dir, "e2ee_diag.txt").absolutePath - XChaCha20E2EE.nativeOpenDiagFile(diagPath) - } catch (_: Throwable) {} + // Frame-level diagnostics are enabled only for debug builds. + if (BuildConfig.DEBUG) { + try { + val dir = java.io.File(appContext!!.filesDir, "crash_reports") + if (!dir.exists()) dir.mkdirs() + val diagPath = java.io.File(dir, "e2ee_diag.txt").absolutePath + XChaCha20E2EE.nativeOpenDiagFile(diagPath) + } catch (_: Throwable) {} + } // If sender track already exists, bind encryptor now. val existingSender = pendingAudioSenderForE2ee 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 5b59134..07f1e53 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 @@ -4641,6 +4641,8 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { encryptedPlainMessage, // 🔒 Зашифрованный текст для хранения в // БД attachments = attachmentsJson, + primaryAttachmentType = + resolvePrimaryAttachmentTypeFromJson(attachmentsJson), replyToMessageId = null, dialogKey = dialogKey ) @@ -4649,6 +4651,18 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { } catch (e: Exception) {} } + private fun resolvePrimaryAttachmentTypeFromJson(attachmentsJson: String): Int { + if (attachmentsJson.isBlank() || attachmentsJson == "[]") return -1 + return try { + val array = JSONArray(attachmentsJson) + if (array.length() == 0) return -1 + val first = array.optJSONObject(0) ?: return -1 + first.optInt("type", -1) + } catch (_: Throwable) { + -1 + } + } + private fun showTypingIndicator() { _opponentTyping.value = true // Отменяем предыдущий таймер, чтобы избежать race condition 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 1dcbaa0..3ed62a1 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 @@ -666,6 +666,7 @@ fun ChatsListScreen( // Requests count for badge on hamburger & sidebar val topLevelChatsState by chatsViewModel.chatsState.collectAsState() + val topLevelIsLoading by chatsViewModel.isLoading.collectAsState() val topLevelRequestsCount = topLevelChatsState.requestsCount // Dev console dialog - commented out for now @@ -1934,8 +1935,8 @@ fun ChatsListScreen( // Это предотвращает "дергание" UI когда dialogs и requests // обновляются // независимо - val chatsState by chatsViewModel.chatsState.collectAsState() - val isLoading by chatsViewModel.isLoading.collectAsState() + val chatsState = topLevelChatsState + val isLoading = topLevelIsLoading val requests = chatsState.requests val requestsCount = chatsState.requestsCount @@ -2061,64 +2062,15 @@ fun ChatsListScreen( label = "CallsTransition" ) { isCallsScreen -> if (isCallsScreen) { - Box( - modifier = Modifier - .fillMaxSize() - .pointerInput(Unit) { - val velocityTracker = androidx.compose.ui.input.pointer.util.VelocityTracker() - awaitEachGesture { - val down = awaitFirstDown(requireUnconsumed = false) - velocityTracker.resetTracking() - velocityTracker.addPosition(down.uptimeMillis, down.position) - var totalDragX = 0f - var totalDragY = 0f - var claimed = false - val touchSlop = viewConfiguration.touchSlop * 0.6f - - while (true) { - val event = awaitPointerEvent() - val change = event.changes.firstOrNull { it.id == down.id } ?: break - if (change.changedToUpIgnoreConsumed()) break - - val delta = change.positionChange() - totalDragX += delta.x - totalDragY += delta.y - velocityTracker.addPosition(change.uptimeMillis, change.position) - - if (!claimed) { - val distance = kotlin.math.sqrt(totalDragX * totalDragX + totalDragY * totalDragY) - if (distance < touchSlop) continue - if (totalDragX > 0 && kotlin.math.abs(totalDragX) > kotlin.math.abs(totalDragY) * 1.2f) { - claimed = true - change.consume() - } else { - break - } - } else { - change.consume() - } - } - - if (claimed) { - val velocityX = velocityTracker.calculateVelocity().x - val screenWidth = size.width.toFloat() - if (totalDragX > screenWidth * 0.08f || velocityX > 200f) { - setInlineCallsVisible(false) - } - } - } - } - ) { - CallsHistoryScreen( - isDarkTheme = isDarkTheme, - accountPublicKey = accountPublicKey, - avatarRepository = avatarRepository, - onOpenChat = onUserSelect, - onStartCall = onStartCall, - onStartNewCall = onSearchClick, - modifier = Modifier.fillMaxSize() - ) - } + CallsRouteContent( + isDarkTheme = isDarkTheme, + accountPublicKey = accountPublicKey, + avatarRepository = avatarRepository, + onUserSelect = onUserSelect, + onStartCall = onStartCall, + onStartNewCall = onSearchClick, + onBack = { setInlineCallsVisible(false) } + ) } else { // 🎬 Animated content transition between main list and // requests @@ -2154,110 +2106,42 @@ fun ChatsListScreen( label = "RequestsTransition" ) { isRequestsScreen -> if (isRequestsScreen) { - // 📬 Show Requests Screen with swipe-back - Box( - modifier = Modifier - .fillMaxSize() - .pointerInput(Unit) { - val velocityTracker = androidx.compose.ui.input.pointer.util.VelocityTracker() - awaitEachGesture { - val down = awaitFirstDown(requireUnconsumed = false) - velocityTracker.resetTracking() - velocityTracker.addPosition(down.uptimeMillis, down.position) - var totalDragX = 0f - var totalDragY = 0f - var claimed = false - val touchSlop = viewConfiguration.touchSlop * 0.6f - - while (true) { - val event = awaitPointerEvent() - val change = event.changes.firstOrNull { it.id == down.id } ?: break - if (change.changedToUpIgnoreConsumed()) break - - val delta = change.positionChange() - totalDragX += delta.x - totalDragY += delta.y - velocityTracker.addPosition(change.uptimeMillis, change.position) - - if (!claimed) { - val distance = kotlin.math.sqrt(totalDragX * totalDragX + totalDragY * totalDragY) - if (distance < touchSlop) continue - if (totalDragX > 0 && kotlin.math.abs(totalDragX) > kotlin.math.abs(totalDragY) * 1.2f) { - claimed = true - change.consume() - } else { - break - } - } else { - change.consume() - } - } - - if (claimed) { - val velocityX = velocityTracker.calculateVelocity().x - val screenWidth = size.width.toFloat() - if (totalDragX > screenWidth * 0.08f || velocityX > 200f) { - setInlineRequestsVisible( - false - ) - } - } - } - } - ) { - RequestsScreen( + RequestsRouteContent( requests = requests, isDarkTheme = isDarkTheme, - onBack = { - setInlineRequestsVisible( - false - ) - }, - onRequestClick = { request -> - val user = - chatsViewModel - .dialogToSearchUser( - request - ) - onUserSelect(user) - }, - avatarRepository = - avatarRepository, + avatarRepository = avatarRepository, blockedUsers = blockedUsers, pinnedChats = pinnedChats, isDrawerOpen = drawerState.isOpen || - drawerState - .isAnimationRunning, - onTogglePin = { opponentKey -> - onTogglePin(opponentKey) - }, + drawerState.isAnimationRunning, + onTogglePin = onTogglePin, onDeleteDialog = { opponentKey -> scope.launch { - chatsViewModel - .deleteDialog( - opponentKey - ) + chatsViewModel.deleteDialog(opponentKey) } }, onBlockUser = { opponentKey -> scope.launch { - chatsViewModel - .blockUser( - opponentKey - ) + chatsViewModel.blockUser(opponentKey) } }, onUnblockUser = { opponentKey -> scope.launch { - chatsViewModel - .unblockUser( - opponentKey - ) + chatsViewModel.unblockUser(opponentKey) } + }, + onRequestClick = { request -> + val user = + chatsViewModel.dialogToSearchUser( + request + ) + onUserSelect(user) + }, + onBack = { + setInlineRequestsVisible(false) } ) - } // Close Box wrapper } else if (showSkeleton) { ChatsListSkeleton(isDarkTheme = isDarkTheme) } else if (isLoading && chatsState.isEmpty) { @@ -4919,6 +4803,135 @@ fun TypingIndicatorSmall() { } } +@Composable +private fun SwipeBackContainer( + onBack: () -> Unit, + modifier: Modifier = Modifier, + content: @Composable BoxScope.() -> Unit +) { + Box( + modifier = + modifier.fillMaxSize().pointerInput(onBack) { + val velocityTracker = VelocityTracker() + awaitEachGesture { + val down = awaitFirstDown(requireUnconsumed = false) + velocityTracker.resetTracking() + velocityTracker.addPosition(down.uptimeMillis, down.position) + var totalDragX = 0f + var totalDragY = 0f + var claimed = false + val touchSlop = viewConfiguration.touchSlop * 0.6f + + while (true) { + val event = awaitPointerEvent() + val change = + event.changes.firstOrNull { it.id == down.id } + ?: break + if (change.changedToUpIgnoreConsumed()) break + + val delta = change.positionChange() + totalDragX += delta.x + totalDragY += delta.y + velocityTracker.addPosition( + change.uptimeMillis, + change.position + ) + + if (!claimed) { + val distance = + kotlin.math.sqrt( + totalDragX * totalDragX + + totalDragY * totalDragY + ) + if (distance < touchSlop) continue + if ( + totalDragX > 0 && + kotlin.math.abs(totalDragX) > + kotlin.math.abs(totalDragY) * 1.2f + ) { + claimed = true + change.consume() + } else { + break + } + } else { + change.consume() + } + } + + if (claimed) { + val velocityX = velocityTracker.calculateVelocity().x + val screenWidth = size.width.toFloat() + if ( + totalDragX > screenWidth * 0.08f || + velocityX > 200f + ) { + onBack() + } + } + } + } + ) { + content() + } +} + +@Composable +private fun CallsRouteContent( + isDarkTheme: Boolean, + accountPublicKey: String, + avatarRepository: AvatarRepository?, + onUserSelect: (com.rosetta.messenger.network.SearchUser) -> Unit, + onStartCall: (com.rosetta.messenger.network.SearchUser) -> Unit, + onStartNewCall: () -> Unit, + onBack: () -> Unit +) { + SwipeBackContainer(onBack = onBack) { + CallsHistoryScreen( + isDarkTheme = isDarkTheme, + accountPublicKey = accountPublicKey, + avatarRepository = avatarRepository, + onOpenChat = onUserSelect, + onStartCall = onStartCall, + onStartNewCall = onStartNewCall, + modifier = Modifier.fillMaxSize() + ) + } +} + +@Composable +private fun RequestsRouteContent( + requests: List, + isDarkTheme: Boolean, + avatarRepository: AvatarRepository?, + blockedUsers: Set, + pinnedChats: Set, + isDrawerOpen: Boolean, + onTogglePin: (String) -> Unit, + onDeleteDialog: (String) -> Unit, + onBlockUser: (String) -> Unit, + onUnblockUser: (String) -> Unit, + onRequestClick: (DialogUiModel) -> Unit, + onBack: () -> Unit +) { + SwipeBackContainer(onBack = onBack) { + RequestsScreen( + requests = requests, + isDarkTheme = isDarkTheme, + onBack = onBack, + onRequestClick = onRequestClick, + avatarRepository = avatarRepository, + blockedUsers = blockedUsers, + pinnedChats = pinnedChats, + isDrawerOpen = isDrawerOpen, + onTogglePin = onTogglePin, + onDeleteDialog = onDeleteDialog, + onBlockUser = onBlockUser, + onUnblockUser = onUnblockUser + ) + } +} + /** 📬 Секция Requests — Telegram Archived Chats style */ @Composable fun RequestsSection(