feat: enhance chat and requests screens with avatar handling, pinning, and user blocking functionalities
This commit is contained in:
@@ -712,13 +712,20 @@ fun MainScreen(
|
|||||||
RequestsListScreen(
|
RequestsListScreen(
|
||||||
isDarkTheme = isDarkTheme,
|
isDarkTheme = isDarkTheme,
|
||||||
chatsViewModel = chatsListViewModel,
|
chatsViewModel = chatsListViewModel,
|
||||||
|
pinnedChats = pinnedChats,
|
||||||
|
onTogglePin = { opponentKey ->
|
||||||
|
mainScreenScope.launch {
|
||||||
|
prefsManager.togglePinChat(opponentKey)
|
||||||
|
}
|
||||||
|
},
|
||||||
onBack = { navStack = navStack.filterNot { it is Screen.Requests } },
|
onBack = { navStack = navStack.filterNot { it is Screen.Requests } },
|
||||||
onUserSelect = { selectedRequestUser ->
|
onUserSelect = { selectedRequestUser ->
|
||||||
navStack =
|
navStack =
|
||||||
navStack.filterNot {
|
navStack.filterNot {
|
||||||
it is Screen.Requests || it is Screen.ChatDetail
|
it is Screen.ChatDetail || it is Screen.OtherProfile
|
||||||
} + Screen.ChatDetail(selectedRequestUser)
|
} + Screen.ChatDetail(selectedRequestUser)
|
||||||
}
|
},
|
||||||
|
avatarRepository = avatarRepository
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -811,16 +811,8 @@ object MessageCrypto {
|
|||||||
val compressed = compressedBuffer.copyOf(compressedSize)
|
val compressed = compressedBuffer.copyOf(compressedSize)
|
||||||
|
|
||||||
// PBKDF2 key derivation (matching crypto-js: crypto.PBKDF2(password, 'rosetta', {keySize: 256/32, iterations: 1000}))
|
// PBKDF2 key derivation (matching crypto-js: crypto.PBKDF2(password, 'rosetta', {keySize: 256/32, iterations: 1000}))
|
||||||
// CRITICAL: crypto-js PBKDF2 uses SHA256 by default (NOT SHA1!)
|
// Используем generatePBKDF2Key() для совместимости с crypto-js (UTF-8 encoding)
|
||||||
val factory = javax.crypto.SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256")
|
val keyBytes = generatePBKDF2Key(password)
|
||||||
val spec = javax.crypto.spec.PBEKeySpec(
|
|
||||||
password.toCharArray(),
|
|
||||||
"rosetta".toByteArray(Charsets.UTF_8),
|
|
||||||
1000,
|
|
||||||
256
|
|
||||||
)
|
|
||||||
val secretKey = factory.generateSecret(spec)
|
|
||||||
val keyBytes = secretKey.encoded
|
|
||||||
|
|
||||||
// Generate random IV (16 bytes)
|
// Generate random IV (16 bytes)
|
||||||
val iv = ByteArray(16)
|
val iv = ByteArray(16)
|
||||||
@@ -1031,18 +1023,8 @@ object MessageCrypto {
|
|||||||
// Password from plainKeyAndNonce - use same JS-like UTF-8 conversion
|
// Password from plainKeyAndNonce - use same JS-like UTF-8 conversion
|
||||||
val password = bytesToJsUtf8String(plainKeyAndNonce)
|
val password = bytesToJsUtf8String(plainKeyAndNonce)
|
||||||
|
|
||||||
// PBKDF2 key derivation
|
// PBKDF2 key derivation — SHA256 (совместимо с crypto-js и encryptReplyBlob)
|
||||||
// CRITICAL: Must use SHA1 to match Desktop crypto-js (not SHA256!)
|
val keyBytes = generatePBKDF2Key(password)
|
||||||
val factory = javax.crypto.SecretKeyFactory.getInstance("PBKDF2WithHmacSHA1")
|
|
||||||
val spec = javax.crypto.spec.PBEKeySpec(
|
|
||||||
password.toCharArray(),
|
|
||||||
"rosetta".toByteArray(Charsets.UTF_8),
|
|
||||||
1000,
|
|
||||||
256
|
|
||||||
)
|
|
||||||
val secretKey = factory.generateSecret(spec)
|
|
||||||
val keyBytes = secretKey.encoded
|
|
||||||
|
|
||||||
|
|
||||||
// AES-CBC decryption
|
// AES-CBC decryption
|
||||||
val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding")
|
val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding")
|
||||||
@@ -1051,7 +1033,6 @@ object MessageCrypto {
|
|||||||
cipher.init(Cipher.DECRYPT_MODE, keySpec, ivSpec)
|
cipher.init(Cipher.DECRYPT_MODE, keySpec, ivSpec)
|
||||||
val decompressed = cipher.doFinal(ciphertext)
|
val decompressed = cipher.doFinal(ciphertext)
|
||||||
|
|
||||||
|
|
||||||
// Decompress with inflate
|
// Decompress with inflate
|
||||||
val inflater = java.util.zip.Inflater()
|
val inflater = java.util.zip.Inflater()
|
||||||
inflater.setInput(decompressed)
|
inflater.setInput(decompressed)
|
||||||
@@ -1060,13 +1041,42 @@ object MessageCrypto {
|
|||||||
inflater.end()
|
inflater.end()
|
||||||
val plaintext = String(outputBuffer, 0, outputSize, Charsets.UTF_8)
|
val plaintext = String(outputBuffer, 0, outputSize, Charsets.UTF_8)
|
||||||
|
|
||||||
|
|
||||||
plaintext
|
plaintext
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
|
// Fallback: пробуем SHA1 для обратной совместимости со старыми сообщениями
|
||||||
|
try {
|
||||||
|
val parts = encryptedBlob.split(':')
|
||||||
|
if (parts.size != 2) return encryptedBlob
|
||||||
|
|
||||||
|
val iv = Base64.decode(parts[0], Base64.DEFAULT)
|
||||||
|
val ciphertext = Base64.decode(parts[1], Base64.DEFAULT)
|
||||||
|
val password = bytesToJsUtf8String(plainKeyAndNonce)
|
||||||
|
|
||||||
|
val factory = javax.crypto.SecretKeyFactory.getInstance("PBKDF2WithHmacSHA1")
|
||||||
|
val spec = javax.crypto.spec.PBEKeySpec(
|
||||||
|
password.toCharArray(),
|
||||||
|
"rosetta".toByteArray(Charsets.UTF_8),
|
||||||
|
1000,
|
||||||
|
256
|
||||||
|
)
|
||||||
|
val keyBytesSha1 = factory.generateSecret(spec).encoded
|
||||||
|
|
||||||
|
val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding")
|
||||||
|
cipher.init(Cipher.DECRYPT_MODE, SecretKeySpec(keyBytesSha1, "AES"), IvParameterSpec(iv))
|
||||||
|
val decompressed = cipher.doFinal(ciphertext)
|
||||||
|
|
||||||
|
val inflater = java.util.zip.Inflater()
|
||||||
|
inflater.setInput(decompressed)
|
||||||
|
val outputBuffer = ByteArray(decompressed.size * 10)
|
||||||
|
val outputSize = inflater.inflate(outputBuffer)
|
||||||
|
inflater.end()
|
||||||
|
String(outputBuffer, 0, outputSize, Charsets.UTF_8)
|
||||||
|
} catch (e2: Exception) {
|
||||||
// Return as-is, might be plain JSON
|
// Return as-is, might be plain JSON
|
||||||
encryptedBlob
|
encryptedBlob
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extension functions для конвертации
|
// Extension functions для конвертации
|
||||||
|
|||||||
@@ -11,9 +11,9 @@ import kotlinx.coroutines.flow.asStateFlow
|
|||||||
* Логика как в десктопе:
|
* Логика как в десктопе:
|
||||||
* 1. Пользователь выбирает сообщения в чате
|
* 1. Пользователь выбирает сообщения в чате
|
||||||
* 2. Нажимает Forward
|
* 2. Нажимает Forward
|
||||||
* 3. Открывается список чатов
|
* 3. Открывается список чатов (мультивыбор)
|
||||||
* 4. Выбирает чат куда переслать
|
* 4. Выбирает один или несколько чатов
|
||||||
* 5. Переходит в выбранный чат с сообщениями в Reply панели (как Forward)
|
* 5. Первый чат — навигация с reply панелью, остальные — прямая отправка
|
||||||
*
|
*
|
||||||
* Singleton для передачи данных между экранами
|
* Singleton для передачи данных между экранами
|
||||||
*/
|
*/
|
||||||
@@ -29,6 +29,7 @@ object ForwardManager {
|
|||||||
val isOutgoing: Boolean,
|
val isOutgoing: Boolean,
|
||||||
val senderPublicKey: String, // publicKey отправителя сообщения
|
val senderPublicKey: String, // publicKey отправителя сообщения
|
||||||
val originalChatPublicKey: String, // publicKey чата откуда пересылается
|
val originalChatPublicKey: String, // publicKey чата откуда пересылается
|
||||||
|
val senderName: String = "", // Имя отправителя для атрибуции
|
||||||
val attachments: List<MessageAttachment> = emptyList()
|
val attachments: List<MessageAttachment> = emptyList()
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -40,9 +41,14 @@ object ForwardManager {
|
|||||||
private val _showChatPicker = MutableStateFlow(false)
|
private val _showChatPicker = MutableStateFlow(false)
|
||||||
val showChatPicker: StateFlow<Boolean> = _showChatPicker.asStateFlow()
|
val showChatPicker: StateFlow<Boolean> = _showChatPicker.asStateFlow()
|
||||||
|
|
||||||
// Выбранный чат (publicKey собеседника)
|
// Выбранные чаты (publicKeys собеседников) — поддержка мультивыбора
|
||||||
private val _selectedChatPublicKey = MutableStateFlow<String?>(null)
|
private val _selectedChatPublicKeys = MutableStateFlow<List<String>>(emptyList())
|
||||||
val selectedChatPublicKey: StateFlow<String?> = _selectedChatPublicKey.asStateFlow()
|
val selectedChatPublicKeys: StateFlow<List<String>> = _selectedChatPublicKeys.asStateFlow()
|
||||||
|
|
||||||
|
// Обратная совместимость: первый выбранный чат
|
||||||
|
val selectedChatPublicKey: StateFlow<String?> get() = MutableStateFlow(
|
||||||
|
_selectedChatPublicKeys.value.firstOrNull()
|
||||||
|
)
|
||||||
|
|
||||||
// 🔥 Счётчик для триггера перезагрузки диалога при forward
|
// 🔥 Счётчик для триггера перезагрузки диалога при forward
|
||||||
private val _forwardTrigger = MutableStateFlow(0)
|
private val _forwardTrigger = MutableStateFlow(0)
|
||||||
@@ -62,10 +68,17 @@ object ForwardManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Выбрать чат для пересылки
|
* Выбрать один чат для пересылки (обратная совместимость)
|
||||||
*/
|
*/
|
||||||
fun selectChat(publicKey: String) {
|
fun selectChat(publicKey: String) {
|
||||||
_selectedChatPublicKey.value = publicKey
|
selectChats(listOf(publicKey))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Выбрать несколько чатов для пересылки
|
||||||
|
*/
|
||||||
|
fun selectChats(publicKeys: List<String>) {
|
||||||
|
_selectedChatPublicKeys.value = publicKeys
|
||||||
_showChatPicker.value = false
|
_showChatPicker.value = false
|
||||||
// 🔥 Увеличиваем триггер чтобы ChatDetailScreen перезагрузил диалог
|
// 🔥 Увеличиваем триггер чтобы ChatDetailScreen перезагрузил диалог
|
||||||
_forwardTrigger.value++
|
_forwardTrigger.value++
|
||||||
@@ -93,7 +106,7 @@ object ForwardManager {
|
|||||||
fun clear() {
|
fun clear() {
|
||||||
_forwardMessages.value = emptyList()
|
_forwardMessages.value = emptyList()
|
||||||
_showChatPicker.value = false
|
_showChatPicker.value = false
|
||||||
_selectedChatPublicKey.value = null
|
_selectedChatPublicKeys.value = emptyList()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -105,9 +118,9 @@ object ForwardManager {
|
|||||||
* Проверить есть ли сообщения для конкретного чата
|
* Проверить есть ли сообщения для конкретного чата
|
||||||
*/
|
*/
|
||||||
fun hasForwardMessagesForChat(publicKey: String): Boolean {
|
fun hasForwardMessagesForChat(publicKey: String): Boolean {
|
||||||
val selectedKey = _selectedChatPublicKey.value
|
val selectedKeys = _selectedChatPublicKeys.value
|
||||||
val hasMessages = _forwardMessages.value.isNotEmpty()
|
val hasMessages = _forwardMessages.value.isNotEmpty()
|
||||||
return selectedKey == publicKey && hasMessages
|
return publicKey in selectedKeys && hasMessages
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -115,28 +128,35 @@ object ForwardManager {
|
|||||||
* Комбинированный метод для атомарного получения данных
|
* Комбинированный метод для атомарного получения данных
|
||||||
*/
|
*/
|
||||||
fun getForwardMessagesForChat(publicKey: String): List<ForwardMessage> {
|
fun getForwardMessagesForChat(publicKey: String): List<ForwardMessage> {
|
||||||
val selectedKey = _selectedChatPublicKey.value
|
val selectedKeys = _selectedChatPublicKeys.value
|
||||||
return if (selectedKey == publicKey && _forwardMessages.value.isNotEmpty()) {
|
return if (publicKey in selectedKeys && _forwardMessages.value.isNotEmpty()) {
|
||||||
_forwardMessages.value
|
_forwardMessages.value
|
||||||
} else {
|
} else {
|
||||||
emptyList()
|
emptyList()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Получить список дополнительных чатов (кроме основного, куда навигируемся)
|
||||||
|
*/
|
||||||
|
fun getAdditionalChatKeys(primaryKey: String): List<String> {
|
||||||
|
return _selectedChatPublicKeys.value.filter { it != primaryKey }
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Атомарно получить forward-сообщения для конкретного чата и очистить pending state.
|
* Атомарно получить forward-сообщения для конкретного чата и очистить pending state.
|
||||||
* Это повторяет desktop-подход "consume once" после перехода в целевой диалог.
|
* Это повторяет desktop-подход "consume once" после перехода в целевой диалог.
|
||||||
*/
|
*/
|
||||||
@Synchronized
|
@Synchronized
|
||||||
fun consumeForwardMessagesForChat(publicKey: String): List<ForwardMessage> {
|
fun consumeForwardMessagesForChat(publicKey: String): List<ForwardMessage> {
|
||||||
val selectedKey = _selectedChatPublicKey.value
|
val selectedKeys = _selectedChatPublicKeys.value
|
||||||
val pending = _forwardMessages.value
|
val pending = _forwardMessages.value
|
||||||
if (selectedKey != publicKey || pending.isEmpty()) {
|
if (publicKey !in selectedKeys || pending.isEmpty()) {
|
||||||
return emptyList()
|
return emptyList()
|
||||||
}
|
}
|
||||||
|
|
||||||
_forwardMessages.value = emptyList()
|
_forwardMessages.value = emptyList()
|
||||||
_selectedChatPublicKey.value = null
|
_selectedChatPublicKeys.value = emptyList()
|
||||||
_showChatPicker.value = false
|
_showChatPicker.value = false
|
||||||
return pending
|
return pending
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -189,6 +189,7 @@ fun ChatDetailScreen(
|
|||||||
var showImageViewer by remember { mutableStateOf(false) }
|
var showImageViewer by remember { mutableStateOf(false) }
|
||||||
var imageViewerInitialIndex by remember { mutableStateOf(0) }
|
var imageViewerInitialIndex by remember { mutableStateOf(0) }
|
||||||
var imageViewerSourceBounds by remember { mutableStateOf<ImageSourceBounds?>(null) }
|
var imageViewerSourceBounds by remember { mutableStateOf<ImageSourceBounds?>(null) }
|
||||||
|
var imageViewerImages by remember { mutableStateOf<List<ViewableImage>>(emptyList()) }
|
||||||
|
|
||||||
// 🎨 Управление статус баром
|
// 🎨 Управление статус баром
|
||||||
DisposableEffect(isDarkTheme, showImageViewer, window, view) {
|
DisposableEffect(isDarkTheme, showImageViewer, window, view) {
|
||||||
@@ -1390,6 +1391,9 @@ fun ChatDetailScreen(
|
|||||||
user.publicKey,
|
user.publicKey,
|
||||||
originalChatPublicKey =
|
originalChatPublicKey =
|
||||||
user.publicKey,
|
user.publicKey,
|
||||||
|
senderName =
|
||||||
|
if (msg.isOutgoing) "You"
|
||||||
|
else user.title.ifEmpty { user.username.ifEmpty { "User" } },
|
||||||
attachments =
|
attachments =
|
||||||
msg.attachments
|
msg.attachments
|
||||||
.filter {
|
.filter {
|
||||||
@@ -1936,6 +1940,7 @@ fun ChatDetailScreen(
|
|||||||
bounds
|
bounds
|
||||||
->
|
->
|
||||||
// 📸 Открыть просмотрщик фото с shared element animation
|
// 📸 Открыть просмотрщик фото с shared element animation
|
||||||
|
// Фиксируем список на момент клика (защита от краша при новых сообщениях)
|
||||||
val allImages =
|
val allImages =
|
||||||
extractImagesFromMessages(
|
extractImagesFromMessages(
|
||||||
messages,
|
messages,
|
||||||
@@ -1946,6 +1951,8 @@ fun ChatDetailScreen(
|
|||||||
"User"
|
"User"
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
imageViewerImages =
|
||||||
|
allImages
|
||||||
imageViewerInitialIndex =
|
imageViewerInitialIndex =
|
||||||
findImageIndex(
|
findImageIndex(
|
||||||
allImages,
|
allImages,
|
||||||
@@ -2010,21 +2017,15 @@ fun ChatDetailScreen(
|
|||||||
} // Закрытие Box
|
} // Закрытие Box
|
||||||
|
|
||||||
// 📸 Image Viewer Overlay with Telegram-style shared element animation
|
// 📸 Image Viewer Overlay with Telegram-style shared element animation
|
||||||
if (showImageViewer) {
|
if (showImageViewer && imageViewerImages.isNotEmpty()) {
|
||||||
val allImages =
|
|
||||||
extractImagesFromMessages(
|
|
||||||
messages,
|
|
||||||
currentUserPublicKey,
|
|
||||||
user.publicKey,
|
|
||||||
user.title.ifEmpty { "User" }
|
|
||||||
)
|
|
||||||
ImageViewerScreen(
|
ImageViewerScreen(
|
||||||
images = allImages,
|
images = imageViewerImages,
|
||||||
initialIndex = imageViewerInitialIndex,
|
initialIndex = imageViewerInitialIndex,
|
||||||
privateKey = currentUserPrivateKey,
|
privateKey = currentUserPrivateKey,
|
||||||
onDismiss = {
|
onDismiss = {
|
||||||
showImageViewer = false
|
showImageViewer = false
|
||||||
imageViewerSourceBounds = null
|
imageViewerSourceBounds = null
|
||||||
|
imageViewerImages = emptyList()
|
||||||
onImageViewerChanged(false)
|
onImageViewerChanged(false)
|
||||||
},
|
},
|
||||||
onClosingStart = {
|
onClosingStart = {
|
||||||
@@ -2237,19 +2238,37 @@ fun ChatDetailScreen(
|
|||||||
showForwardPicker = false
|
showForwardPicker = false
|
||||||
ForwardManager.clear()
|
ForwardManager.clear()
|
||||||
},
|
},
|
||||||
onChatSelected = { selectedDialog ->
|
onChatsSelected = { selectedDialogs ->
|
||||||
showForwardPicker = false
|
showForwardPicker = false
|
||||||
ForwardManager.selectChat(selectedDialog.opponentKey)
|
if (selectedDialogs.isNotEmpty()) {
|
||||||
|
val primaryDialog = selectedDialogs.first()
|
||||||
|
val additionalDialogs = selectedDialogs.drop(1)
|
||||||
|
|
||||||
|
// Отправляем forward напрямую в дополнительные чаты
|
||||||
|
val fwdMessages = ForwardManager.consumeForwardMessages()
|
||||||
|
if (additionalDialogs.isNotEmpty() && fwdMessages.isNotEmpty()) {
|
||||||
|
additionalDialogs.forEach { dialog ->
|
||||||
|
viewModel.sendForwardDirectly(
|
||||||
|
dialog.opponentKey,
|
||||||
|
fwdMessages
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Навигируемся в первый выбранный чат с forward
|
||||||
|
ForwardManager.setForwardMessages(fwdMessages, showPicker = false)
|
||||||
|
ForwardManager.selectChat(primaryDialog.opponentKey)
|
||||||
val searchUser =
|
val searchUser =
|
||||||
SearchUser(
|
SearchUser(
|
||||||
title = selectedDialog.opponentTitle,
|
title = primaryDialog.opponentTitle,
|
||||||
username = selectedDialog.opponentUsername,
|
username = primaryDialog.opponentUsername,
|
||||||
publicKey = selectedDialog.opponentKey,
|
publicKey = primaryDialog.opponentKey,
|
||||||
verified = selectedDialog.verified,
|
verified = primaryDialog.verified,
|
||||||
online = selectedDialog.isOnline
|
online = primaryDialog.isOnline
|
||||||
)
|
)
|
||||||
onNavigateToChat(searchUser)
|
onNavigateToChat(searchUser)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -165,6 +165,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
val timestamp: Long,
|
val timestamp: Long,
|
||||||
val isOutgoing: Boolean,
|
val isOutgoing: Boolean,
|
||||||
val publicKey: String = "", // publicKey отправителя цитируемого сообщения
|
val publicKey: String = "", // publicKey отправителя цитируемого сообщения
|
||||||
|
val senderName: String = "", // Имя отправителя для атрибуции forward
|
||||||
val attachments: List<MessageAttachment> = emptyList() // Для показа превью
|
val attachments: List<MessageAttachment> = emptyList() // Для показа превью
|
||||||
)
|
)
|
||||||
private val _replyMessages = MutableStateFlow<List<ReplyMessage>>(emptyList())
|
private val _replyMessages = MutableStateFlow<List<ReplyMessage>>(emptyList())
|
||||||
@@ -544,6 +545,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
timestamp = fm.timestamp,
|
timestamp = fm.timestamp,
|
||||||
isOutgoing = fm.isOutgoing,
|
isOutgoing = fm.isOutgoing,
|
||||||
publicKey = fm.senderPublicKey,
|
publicKey = fm.senderPublicKey,
|
||||||
|
senderName = fm.senderName,
|
||||||
attachments = fm.attachments
|
attachments = fm.attachments
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -1248,6 +1250,8 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
val replyText = replyMessage.optString("message", "")
|
val replyText = replyMessage.optString("message", "")
|
||||||
val replyMessageIdFromJson = replyMessage.optString("message_id", "")
|
val replyMessageIdFromJson = replyMessage.optString("message_id", "")
|
||||||
val replyTimestamp = replyMessage.optLong("timestamp", 0L)
|
val replyTimestamp = replyMessage.optLong("timestamp", 0L)
|
||||||
|
val isForwarded = replyMessage.optBoolean("forwarded", false)
|
||||||
|
val senderNameFromJson = replyMessage.optString("senderName", "")
|
||||||
|
|
||||||
// 📸 Парсим attachments из JSON reply (как в Desktop)
|
// 📸 Парсим attachments из JSON reply (как в Desktop)
|
||||||
val replyAttachmentsFromJson = mutableListOf<MessageAttachment>()
|
val replyAttachmentsFromJson = mutableListOf<MessageAttachment>()
|
||||||
@@ -1343,6 +1347,11 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
},
|
},
|
||||||
text = replyText,
|
text = replyText,
|
||||||
isFromMe = isReplyFromMe,
|
isFromMe = isReplyFromMe,
|
||||||
|
isForwarded = isForwarded,
|
||||||
|
forwardedFromName = if (isForwarded) senderNameFromJson.ifEmpty {
|
||||||
|
if (isReplyFromMe) "You"
|
||||||
|
else opponentTitle.ifEmpty { opponentUsername.ifEmpty { "User" } }
|
||||||
|
} else "",
|
||||||
attachments = originalAttachments,
|
attachments = originalAttachments,
|
||||||
senderPublicKey =
|
senderPublicKey =
|
||||||
if (isReplyFromMe) myPublicKey ?: ""
|
if (isReplyFromMe) myPublicKey ?: ""
|
||||||
@@ -1397,7 +1406,10 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
timestamp = msg.timestamp.time,
|
timestamp = msg.timestamp.time,
|
||||||
isOutgoing = msg.isOutgoing,
|
isOutgoing = msg.isOutgoing,
|
||||||
// Если сообщение от меня - мой publicKey, иначе - собеседника
|
// Если сообщение от меня - мой publicKey, иначе - собеседника
|
||||||
publicKey = if (msg.isOutgoing) sender else opponent
|
publicKey = if (msg.isOutgoing) sender else opponent,
|
||||||
|
attachments =
|
||||||
|
msg.attachments
|
||||||
|
.filter { it.type != AttachmentType.MESSAGES }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
_isForwardMode.value = false
|
_isForwardMode.value = false
|
||||||
@@ -1419,7 +1431,6 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
attachments =
|
attachments =
|
||||||
msg.attachments
|
msg.attachments
|
||||||
.filter { it.type != AttachmentType.MESSAGES }
|
.filter { it.type != AttachmentType.MESSAGES }
|
||||||
.map { it.copy(localUri = "") }
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
_isForwardMode.value = true
|
_isForwardMode.value = true
|
||||||
@@ -1504,9 +1515,10 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
if (replyMsgs.isNotEmpty()) {
|
if (replyMsgs.isNotEmpty()) {
|
||||||
val firstReply = replyMsgs.first()
|
val firstReply = replyMsgs.first()
|
||||||
// 🖼️ Получаем attachments из текущих сообщений для превью
|
// 🖼️ Получаем attachments из текущих сообщений для превью
|
||||||
|
// Fallback на firstReply.attachments для forward из другого чата
|
||||||
val replyAttachments =
|
val replyAttachments =
|
||||||
_messages.value.find { it.id == firstReply.messageId }?.attachments
|
_messages.value.find { it.id == firstReply.messageId }?.attachments
|
||||||
?: emptyList()
|
?: firstReply.attachments.filter { it.type != AttachmentType.MESSAGES }
|
||||||
ReplyData(
|
ReplyData(
|
||||||
messageId = firstReply.messageId,
|
messageId = firstReply.messageId,
|
||||||
senderName =
|
senderName =
|
||||||
@@ -1518,6 +1530,11 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
text = firstReply.text,
|
text = firstReply.text,
|
||||||
isFromMe = firstReply.isOutgoing,
|
isFromMe = firstReply.isOutgoing,
|
||||||
isForwarded = isForward,
|
isForwarded = isForward,
|
||||||
|
forwardedFromName =
|
||||||
|
if (isForward) firstReply.senderName.ifEmpty {
|
||||||
|
if (firstReply.isOutgoing) "You"
|
||||||
|
else opponentTitle.ifEmpty { opponentUsername.ifEmpty { "User" } }
|
||||||
|
} else "",
|
||||||
attachments = replyAttachments,
|
attachments = replyAttachments,
|
||||||
senderPublicKey =
|
senderPublicKey =
|
||||||
if (firstReply.isOutgoing) myPublicKey ?: ""
|
if (firstReply.isOutgoing) myPublicKey ?: ""
|
||||||
@@ -1566,23 +1583,73 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
val messageAttachments = mutableListOf<MessageAttachment>()
|
val messageAttachments = mutableListOf<MessageAttachment>()
|
||||||
var replyBlobForDatabase = "" // Зашифрованный blob для БД (приватным ключом)
|
var replyBlobForDatabase = "" // Зашифрованный blob для БД (приватным ключом)
|
||||||
|
|
||||||
|
// 📸 Forward: сначала загружаем IMAGE на CDN, чтобы обновить ссылки в MESSAGES blob
|
||||||
|
// Map: originalAttId → (newAttId, newPreview) — для подстановки в reply JSON
|
||||||
|
val forwardedAttMap = mutableMapOf<String, Pair<String, String>>()
|
||||||
|
if (isForwardToSend && replyMsgsToSend.isNotEmpty()) {
|
||||||
|
val context = getApplication<Application>()
|
||||||
|
val isSaved = (sender == recipient)
|
||||||
|
var fwdIdx = 0
|
||||||
|
|
||||||
|
for (msg in replyMsgsToSend) {
|
||||||
|
for (att in msg.attachments) {
|
||||||
|
if (att.type == AttachmentType.IMAGE) {
|
||||||
|
try {
|
||||||
|
val imageBlob = AttachmentFileManager.readAttachment(
|
||||||
|
context = context,
|
||||||
|
attachmentId = att.id,
|
||||||
|
publicKey = msg.publicKey,
|
||||||
|
privateKey = privateKey
|
||||||
|
)
|
||||||
|
if (imageBlob != null) {
|
||||||
|
val encryptedBlob = MessageCrypto.encryptReplyBlob(imageBlob, plainKeyAndNonce)
|
||||||
|
val newAttId = "fwd_${timestamp}_${fwdIdx++}"
|
||||||
|
|
||||||
|
var uploadTag = ""
|
||||||
|
if (!isSaved) {
|
||||||
|
uploadTag = TransportManager.uploadFile(newAttId, encryptedBlob)
|
||||||
|
}
|
||||||
|
|
||||||
|
val blurhash = att.preview.substringAfter("::", "")
|
||||||
|
val newPreview = if (uploadTag.isNotEmpty()) "$uploadTag::$blurhash" else blurhash
|
||||||
|
|
||||||
|
forwardedAttMap[att.id] = Pair(newAttId, newPreview)
|
||||||
|
|
||||||
|
// Сохраняем локально с новым ID
|
||||||
|
// publicKey = msg.publicKey чтобы совпадал с JSON для parseReplyFromAttachments
|
||||||
|
AttachmentFileManager.saveAttachment(
|
||||||
|
context = context,
|
||||||
|
blob = imageBlob,
|
||||||
|
attachmentId = newAttId,
|
||||||
|
publicKey = msg.publicKey,
|
||||||
|
privateKey = privateKey
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) { }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (replyMsgsToSend.isNotEmpty()) {
|
if (replyMsgsToSend.isNotEmpty()) {
|
||||||
|
|
||||||
// Формируем JSON массив с цитируемыми сообщениями (как в Desktop)
|
// Формируем JSON массив с цитируемыми сообщениями (как в Desktop)
|
||||||
val replyJsonArray = JSONArray()
|
val replyJsonArray = JSONArray()
|
||||||
replyMsgsToSend.forEach { msg ->
|
replyMsgsToSend.forEach { msg ->
|
||||||
// Формируем attachments JSON (как в Desktop)
|
|
||||||
val attachmentsArray = JSONArray()
|
val attachmentsArray = JSONArray()
|
||||||
msg.attachments.forEach { att ->
|
msg.attachments.forEach { att ->
|
||||||
|
// Для forward IMAGE: подставляем НОВЫЙ id и preview (CDN tag)
|
||||||
|
val fwdInfo = forwardedAttMap[att.id]
|
||||||
|
val attId = fwdInfo?.first ?: att.id
|
||||||
|
val attPreview = fwdInfo?.second ?: att.preview
|
||||||
|
|
||||||
attachmentsArray.put(
|
attachmentsArray.put(
|
||||||
JSONObject().apply {
|
JSONObject().apply {
|
||||||
put("id", att.id)
|
put("id", attId)
|
||||||
put("type", att.type.value)
|
put("type", att.type.value)
|
||||||
put("preview", att.preview)
|
put("preview", attPreview)
|
||||||
put("width", att.width)
|
put("width", att.width)
|
||||||
put("height", att.height)
|
put("height", att.height)
|
||||||
// Для IMAGE/FILE - blob не включаем (слишком большой)
|
|
||||||
// Для MESSAGES - включаем blob
|
|
||||||
put(
|
put(
|
||||||
"blob",
|
"blob",
|
||||||
if (att.type == AttachmentType.MESSAGES) att.blob
|
if (att.type == AttachmentType.MESSAGES) att.blob
|
||||||
@@ -1599,17 +1666,19 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
put("message", msg.text)
|
put("message", msg.text)
|
||||||
put("timestamp", msg.timestamp)
|
put("timestamp", msg.timestamp)
|
||||||
put("attachments", attachmentsArray)
|
put("attachments", attachmentsArray)
|
||||||
|
if (isForwardToSend) {
|
||||||
|
put("forwarded", true)
|
||||||
|
put("senderName", msg.senderName)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
replyJsonArray.put(replyJson)
|
replyJsonArray.put(replyJson)
|
||||||
}
|
}
|
||||||
|
|
||||||
val replyBlobPlaintext = replyJsonArray.toString()
|
val replyBlobPlaintext = replyJsonArray.toString()
|
||||||
|
|
||||||
// 🔥 Шифруем reply blob (для network transmission) с ChaCha ключом
|
|
||||||
val encryptedReplyBlob =
|
val encryptedReplyBlob =
|
||||||
MessageCrypto.encryptReplyBlob(replyBlobPlaintext, plainKeyAndNonce)
|
MessageCrypto.encryptReplyBlob(replyBlobPlaintext, plainKeyAndNonce)
|
||||||
|
|
||||||
// 🔥 Re-encrypt с приватным ключом для хранения в БД (как в Desktop Архиве)
|
|
||||||
replyBlobForDatabase =
|
replyBlobForDatabase =
|
||||||
CryptoManager.encryptWithPassword(replyBlobPlaintext, privateKey)
|
CryptoManager.encryptWithPassword(replyBlobPlaintext, privateKey)
|
||||||
|
|
||||||
@@ -1667,6 +1736,8 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
put("id", att.id)
|
put("id", att.id)
|
||||||
put("type", att.type.value)
|
put("type", att.type.value)
|
||||||
put("preview", att.preview)
|
put("preview", att.preview)
|
||||||
|
put("width", att.width)
|
||||||
|
put("height", att.height)
|
||||||
// Только для MESSAGES сохраняем blob (reply
|
// Только для MESSAGES сохраняем blob (reply
|
||||||
// data небольшие)
|
// data небольшие)
|
||||||
// Для IMAGE/FILE - пустой blob
|
// Для IMAGE/FILE - пустой blob
|
||||||
@@ -1710,6 +1781,182 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 📨 Прямая отправка forward-сообщений в указанный чат (без навигации)
|
||||||
|
* Используется при мультивыборе чатов для отправки в дополнительные чаты
|
||||||
|
*/
|
||||||
|
fun sendForwardDirectly(
|
||||||
|
recipientPublicKey: String,
|
||||||
|
forwardMessages: List<ForwardManager.ForwardMessage>
|
||||||
|
) {
|
||||||
|
val sender = myPublicKey ?: return
|
||||||
|
val privateKey = myPrivateKey ?: return
|
||||||
|
if (forwardMessages.isEmpty()) return
|
||||||
|
|
||||||
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
|
try {
|
||||||
|
val context = getApplication<Application>()
|
||||||
|
val messageId = UUID.randomUUID().toString().replace("-", "").take(32)
|
||||||
|
val timestamp = System.currentTimeMillis()
|
||||||
|
val isSavedMessages = (sender == recipientPublicKey)
|
||||||
|
|
||||||
|
// Шифрование (пустой текст для forward)
|
||||||
|
val encryptResult = MessageCrypto.encryptForSending("", recipientPublicKey)
|
||||||
|
val encryptedContent = encryptResult.ciphertext
|
||||||
|
val encryptedKey = encryptResult.encryptedKey
|
||||||
|
val plainKeyAndNonce = encryptResult.plainKeyAndNonce
|
||||||
|
val privateKeyHash = CryptoManager.generatePrivateKeyHash(privateKey)
|
||||||
|
|
||||||
|
val messageAttachments = mutableListOf<MessageAttachment>()
|
||||||
|
var replyBlobForDatabase = ""
|
||||||
|
|
||||||
|
// 📸 Forward: сначала загружаем IMAGE на CDN, чтобы обновить ссылки в MESSAGES blob
|
||||||
|
// Map: originalAttId → (newAttId, newPreview)
|
||||||
|
val forwardedAttMap = mutableMapOf<String, Pair<String, String>>()
|
||||||
|
var fwdIdx = 0
|
||||||
|
for (fm in forwardMessages) {
|
||||||
|
for (att in fm.attachments) {
|
||||||
|
if (att.type == AttachmentType.IMAGE) {
|
||||||
|
try {
|
||||||
|
val imageBlob = AttachmentFileManager.readAttachment(
|
||||||
|
context = context,
|
||||||
|
attachmentId = att.id,
|
||||||
|
publicKey = fm.senderPublicKey,
|
||||||
|
privateKey = privateKey
|
||||||
|
)
|
||||||
|
if (imageBlob != null) {
|
||||||
|
val encryptedBlob = MessageCrypto.encryptReplyBlob(imageBlob, plainKeyAndNonce)
|
||||||
|
val newAttId = "fwd_${timestamp}_${fwdIdx++}"
|
||||||
|
|
||||||
|
var uploadTag = ""
|
||||||
|
if (!isSavedMessages) {
|
||||||
|
uploadTag = TransportManager.uploadFile(newAttId, encryptedBlob)
|
||||||
|
}
|
||||||
|
|
||||||
|
val blurhash = att.preview.substringAfter("::", "")
|
||||||
|
val newPreview = if (uploadTag.isNotEmpty()) "$uploadTag::$blurhash" else blurhash
|
||||||
|
|
||||||
|
forwardedAttMap[att.id] = Pair(newAttId, newPreview)
|
||||||
|
|
||||||
|
// Сохраняем локально с новым ID
|
||||||
|
// publicKey = fm.senderPublicKey чтобы совпадал с JSON для parseReplyFromAttachments
|
||||||
|
AttachmentFileManager.saveAttachment(
|
||||||
|
context = context,
|
||||||
|
blob = imageBlob,
|
||||||
|
attachmentId = newAttId,
|
||||||
|
publicKey = fm.senderPublicKey,
|
||||||
|
privateKey = privateKey
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) { }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Формируем MESSAGES attachment (reply/forward JSON) с обновлёнными ссылками
|
||||||
|
val replyJsonArray = JSONArray()
|
||||||
|
forwardMessages.forEach { fm ->
|
||||||
|
val attachmentsArray = JSONArray()
|
||||||
|
fm.attachments.forEach { att ->
|
||||||
|
// Для forward IMAGE: подставляем НОВЫЙ id и preview (CDN tag)
|
||||||
|
val fwdInfo = forwardedAttMap[att.id]
|
||||||
|
val attId = fwdInfo?.first ?: att.id
|
||||||
|
val attPreview = fwdInfo?.second ?: att.preview
|
||||||
|
|
||||||
|
attachmentsArray.put(JSONObject().apply {
|
||||||
|
put("id", attId)
|
||||||
|
put("type", att.type.value)
|
||||||
|
put("preview", attPreview)
|
||||||
|
put("width", att.width)
|
||||||
|
put("height", att.height)
|
||||||
|
put("blob", if (att.type == AttachmentType.MESSAGES) att.blob else "")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
replyJsonArray.put(JSONObject().apply {
|
||||||
|
put("message_id", fm.messageId)
|
||||||
|
put("publicKey", fm.senderPublicKey)
|
||||||
|
put("message", fm.text)
|
||||||
|
put("timestamp", fm.timestamp)
|
||||||
|
put("attachments", attachmentsArray)
|
||||||
|
put("forwarded", true)
|
||||||
|
put("senderName", fm.senderName)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
val replyBlobPlaintext = replyJsonArray.toString()
|
||||||
|
val encryptedReplyBlob = MessageCrypto.encryptReplyBlob(replyBlobPlaintext, plainKeyAndNonce)
|
||||||
|
replyBlobForDatabase = CryptoManager.encryptWithPassword(replyBlobPlaintext, privateKey)
|
||||||
|
|
||||||
|
val replyAttachmentId = "reply_${timestamp}"
|
||||||
|
messageAttachments.add(MessageAttachment(
|
||||||
|
id = replyAttachmentId,
|
||||||
|
blob = encryptedReplyBlob,
|
||||||
|
type = AttachmentType.MESSAGES,
|
||||||
|
preview = ""
|
||||||
|
))
|
||||||
|
|
||||||
|
// Отправляем пакет
|
||||||
|
val packet = PacketMessage().apply {
|
||||||
|
fromPublicKey = sender
|
||||||
|
toPublicKey = recipientPublicKey
|
||||||
|
content = encryptedContent
|
||||||
|
chachaKey = encryptedKey
|
||||||
|
this.timestamp = timestamp
|
||||||
|
this.privateKey = privateKeyHash
|
||||||
|
this.messageId = messageId
|
||||||
|
attachments = messageAttachments
|
||||||
|
}
|
||||||
|
if (!isSavedMessages) {
|
||||||
|
ProtocolManager.send(packet)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Сохраняем в БД
|
||||||
|
val attachmentsJson = JSONArray().apply {
|
||||||
|
messageAttachments.forEach { att ->
|
||||||
|
put(JSONObject().apply {
|
||||||
|
put("id", att.id)
|
||||||
|
put("type", att.type.value)
|
||||||
|
put("preview", att.preview)
|
||||||
|
put("width", att.width)
|
||||||
|
put("height", att.height)
|
||||||
|
put("blob", when (att.type) {
|
||||||
|
AttachmentType.MESSAGES -> replyBlobForDatabase
|
||||||
|
else -> ""
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}.toString()
|
||||||
|
|
||||||
|
saveMessageToDatabase(
|
||||||
|
messageId = messageId,
|
||||||
|
text = "",
|
||||||
|
encryptedContent = encryptedContent,
|
||||||
|
encryptedKey = encryptedKey,
|
||||||
|
timestamp = timestamp,
|
||||||
|
isFromMe = true,
|
||||||
|
delivered = if (isSavedMessages) 2 else 0,
|
||||||
|
attachmentsJson = attachmentsJson
|
||||||
|
)
|
||||||
|
|
||||||
|
// Сохраняем диалог (для списка чатов)
|
||||||
|
val db = RosettaDatabase.getDatabase(context)
|
||||||
|
val dialogDao = db.dialogDao()
|
||||||
|
val dialogKey = if (sender < recipientPublicKey) "$sender:$recipientPublicKey"
|
||||||
|
else "$recipientPublicKey:$sender"
|
||||||
|
val existingDialog = dialogDao.getDialog(sender, recipientPublicKey)
|
||||||
|
val encryptedLastMsg = CryptoManager.encryptWithPassword("Forwarded message", privateKey)
|
||||||
|
if (existingDialog != null) {
|
||||||
|
dialogDao.updateLastMessage(
|
||||||
|
sender,
|
||||||
|
recipientPublicKey,
|
||||||
|
encryptedLastMsg,
|
||||||
|
timestamp
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) { }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 📸🚀 Отправка изображения по URI с МГНОВЕННЫМ optimistic UI Фото появляется в чате СРАЗУ,
|
* 📸🚀 Отправка изображения по URI с МГНОВЕННЫМ optimistic UI Фото появляется в чате СРАЗУ,
|
||||||
* конвертация и отправка происходят в фоне
|
* конвертация и отправка происходят в фоне
|
||||||
|
|||||||
@@ -290,6 +290,10 @@ fun ChatsListScreen(
|
|||||||
// Реактивный set заблокированных пользователей из ViewModel (Room Flow)
|
// Реактивный set заблокированных пользователей из ViewModel (Room Flow)
|
||||||
val blockedUsers by chatsViewModel.blockedUsers.collectAsState()
|
val blockedUsers by chatsViewModel.blockedUsers.collectAsState()
|
||||||
|
|
||||||
|
// Requests count for badge on hamburger & sidebar
|
||||||
|
val topLevelChatsState by chatsViewModel.chatsState.collectAsState()
|
||||||
|
val topLevelRequestsCount = topLevelChatsState.requestsCount
|
||||||
|
|
||||||
// Dev console dialog - commented out for now
|
// Dev console dialog - commented out for now
|
||||||
/*
|
/*
|
||||||
if (showDevConsole) {
|
if (showDevConsole) {
|
||||||
@@ -515,15 +519,12 @@ fun ChatsListScreen(
|
|||||||
) {
|
) {
|
||||||
ModalNavigationDrawer(
|
ModalNavigationDrawer(
|
||||||
drawerState = drawerState,
|
drawerState = drawerState,
|
||||||
gesturesEnabled = true, // 🔥 Явно включаем свайп для открытия drawer
|
gesturesEnabled = !showRequestsScreen, // Disable drawer swipe when requests are open
|
||||||
drawerContent = {
|
drawerContent = {
|
||||||
ModalDrawerSheet(
|
ModalDrawerSheet(
|
||||||
drawerContainerColor = Color.Transparent,
|
drawerContainerColor = Color.Transparent,
|
||||||
windowInsets =
|
windowInsets =
|
||||||
WindowInsets(
|
WindowInsets(0),
|
||||||
0
|
|
||||||
), // 🎨 Убираем системные отступы - drawer идет до
|
|
||||||
// верха
|
|
||||||
modifier = Modifier.width(300.dp)
|
modifier = Modifier.width(300.dp)
|
||||||
) {
|
) {
|
||||||
Column(
|
Column(
|
||||||
@@ -532,7 +533,7 @@ fun ChatsListScreen(
|
|||||||
.background(drawerBackgroundColor)
|
.background(drawerBackgroundColor)
|
||||||
) {
|
) {
|
||||||
// ═══════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════
|
||||||
// 🎨 DRAWER HEADER - Avatar and status
|
// 🎨 DRAWER HEADER
|
||||||
// ═══════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════
|
||||||
val avatarColors =
|
val avatarColors =
|
||||||
getAvatarColor(
|
getAvatarColor(
|
||||||
@@ -543,10 +544,6 @@ fun ChatsListScreen(
|
|||||||
|
|
||||||
// Header с размытым фоном аватарки
|
// Header с размытым фоном аватарки
|
||||||
Box(modifier = Modifier.fillMaxWidth()) {
|
Box(modifier = Modifier.fillMaxWidth()) {
|
||||||
// ═══════════════════════════════════════════════════════════
|
|
||||||
// 🎨 BLURRED AVATAR BACKGROUND (на всю
|
|
||||||
// область header)
|
|
||||||
// ═══════════════════════════════════════════════════════════
|
|
||||||
BlurredAvatarBackground(
|
BlurredAvatarBackground(
|
||||||
publicKey = accountPublicKey,
|
publicKey = accountPublicKey,
|
||||||
avatarRepository = avatarRepository,
|
avatarRepository = avatarRepository,
|
||||||
@@ -567,30 +564,27 @@ fun ChatsListScreen(
|
|||||||
.statusBarsPadding()
|
.statusBarsPadding()
|
||||||
.padding(
|
.padding(
|
||||||
top = 16.dp,
|
top = 16.dp,
|
||||||
start =
|
start = 20.dp,
|
||||||
20.dp,
|
|
||||||
end = 20.dp,
|
end = 20.dp,
|
||||||
bottom =
|
bottom = 20.dp
|
||||||
20.dp
|
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
// Avatar - используем AvatarImage
|
// Avatar row with theme toggle
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.Top
|
||||||
|
) {
|
||||||
|
// Avatar
|
||||||
Box(
|
Box(
|
||||||
modifier =
|
modifier =
|
||||||
Modifier.size(72.dp)
|
Modifier.size(72.dp)
|
||||||
.clip(
|
.clip(CircleShape)
|
||||||
CircleShape
|
|
||||||
)
|
|
||||||
.background(
|
.background(
|
||||||
Color.White
|
Color.White
|
||||||
.copy(
|
.copy(alpha = 0.2f)
|
||||||
alpha =
|
|
||||||
0.2f
|
|
||||||
)
|
)
|
||||||
)
|
.padding(3.dp),
|
||||||
.padding(
|
|
||||||
3.dp
|
|
||||||
),
|
|
||||||
contentAlignment =
|
contentAlignment =
|
||||||
Alignment.Center
|
Alignment.Center
|
||||||
) {
|
) {
|
||||||
@@ -606,60 +600,70 @@ fun ChatsListScreen(
|
|||||||
accountName
|
accountName
|
||||||
.ifEmpty {
|
.ifEmpty {
|
||||||
accountUsername
|
accountUsername
|
||||||
} // 🔥 Для инициалов
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Theme toggle icon
|
||||||
|
IconButton(
|
||||||
|
onClick = { onToggleTheme() },
|
||||||
|
modifier = Modifier.size(40.dp)
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector =
|
||||||
|
if (isDarkTheme)
|
||||||
|
TablerIcons.Sun
|
||||||
|
else TablerIcons.Moon,
|
||||||
|
contentDescription =
|
||||||
|
if (isDarkTheme) "Light Mode"
|
||||||
|
else "Dark Mode",
|
||||||
|
tint =
|
||||||
|
if (isDarkTheme)
|
||||||
|
Color.White.copy(alpha = 0.8f)
|
||||||
|
else
|
||||||
|
Color.Black.copy(alpha = 0.7f),
|
||||||
|
modifier = Modifier.size(22.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Spacer(
|
Spacer(
|
||||||
modifier =
|
modifier =
|
||||||
Modifier.height(
|
Modifier.height(14.dp)
|
||||||
14.dp
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Display name (above username)
|
// Display name
|
||||||
if (accountName.isNotEmpty()) {
|
if (accountName.isNotEmpty()) {
|
||||||
Text(
|
Text(
|
||||||
text = accountName,
|
text = accountName,
|
||||||
fontSize = 16.sp,
|
fontSize = 16.sp,
|
||||||
fontWeight =
|
fontWeight =
|
||||||
FontWeight
|
FontWeight.SemiBold,
|
||||||
.SemiBold,
|
|
||||||
color =
|
color =
|
||||||
if (isDarkTheme
|
if (isDarkTheme)
|
||||||
)
|
|
||||||
Color.White
|
Color.White
|
||||||
else
|
else
|
||||||
Color.Black
|
Color.Black
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Username display (below name)
|
// Username
|
||||||
if (accountUsername.isNotEmpty()) {
|
if (accountUsername.isNotEmpty()) {
|
||||||
Spacer(
|
Spacer(
|
||||||
modifier =
|
modifier =
|
||||||
Modifier.height(
|
Modifier.height(4.dp)
|
||||||
4.dp
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text =
|
text =
|
||||||
"@$accountUsername",
|
"@$accountUsername",
|
||||||
fontSize = 13.sp,
|
fontSize = 13.sp,
|
||||||
color =
|
color =
|
||||||
if (isDarkTheme
|
if (isDarkTheme)
|
||||||
)
|
|
||||||
Color.White
|
Color.White
|
||||||
.copy(
|
.copy(alpha = 0.7f)
|
||||||
alpha =
|
|
||||||
0.7f
|
|
||||||
)
|
|
||||||
else
|
else
|
||||||
Color.Black
|
Color.Black
|
||||||
.copy(
|
.copy(alpha = 0.7f)
|
||||||
alpha =
|
|
||||||
0.7f
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -680,7 +684,7 @@ fun ChatsListScreen(
|
|||||||
val menuIconColor =
|
val menuIconColor =
|
||||||
textColor.copy(alpha = 0.6f)
|
textColor.copy(alpha = 0.6f)
|
||||||
|
|
||||||
// 👤 Profile Section
|
// 👤 Profile
|
||||||
DrawerMenuItemEnhanced(
|
DrawerMenuItemEnhanced(
|
||||||
icon = TablerIcons.User,
|
icon = TablerIcons.User,
|
||||||
text = "My Profile",
|
text = "My Profile",
|
||||||
@@ -696,7 +700,24 @@ fun ChatsListScreen(
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
// 📖 Saved Messages
|
// <EFBFBD> Requests
|
||||||
|
DrawerMenuItemEnhanced(
|
||||||
|
icon = TablerIcons.MessageCircle2,
|
||||||
|
text = "Requests",
|
||||||
|
iconColor = menuIconColor,
|
||||||
|
textColor = textColor,
|
||||||
|
badge = if (topLevelRequestsCount > 0) topLevelRequestsCount.toString() else null,
|
||||||
|
onClick = {
|
||||||
|
scope.launch {
|
||||||
|
drawerState.close()
|
||||||
|
kotlinx.coroutines
|
||||||
|
.delay(100)
|
||||||
|
showRequestsScreen = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// <20>📖 Saved Messages
|
||||||
DrawerMenuItemEnhanced(
|
DrawerMenuItemEnhanced(
|
||||||
icon = TablerIcons.Bookmark,
|
icon = TablerIcons.Bookmark,
|
||||||
text = "Saved Messages",
|
text = "Saved Messages",
|
||||||
@@ -705,9 +726,6 @@ fun ChatsListScreen(
|
|||||||
onClick = {
|
onClick = {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
drawerState.close()
|
drawerState.close()
|
||||||
// Ждём завершения
|
|
||||||
// анимации закрытия
|
|
||||||
// drawer
|
|
||||||
kotlinx.coroutines
|
kotlinx.coroutines
|
||||||
.delay(250)
|
.delay(250)
|
||||||
onSavedMessagesClick()
|
onSavedMessagesClick()
|
||||||
@@ -733,20 +751,6 @@ fun ChatsListScreen(
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
// 🌓 Theme Toggle
|
|
||||||
DrawerMenuItemEnhanced(
|
|
||||||
icon =
|
|
||||||
if (isDarkTheme)
|
|
||||||
TablerIcons.Sun
|
|
||||||
else TablerIcons.Moon,
|
|
||||||
text =
|
|
||||||
if (isDarkTheme)
|
|
||||||
"Light Mode"
|
|
||||||
else "Dark Mode",
|
|
||||||
iconColor = menuIconColor,
|
|
||||||
textColor = textColor,
|
|
||||||
onClick = { onToggleTheme() }
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════
|
||||||
@@ -782,10 +786,8 @@ fun ChatsListScreen(
|
|||||||
modifier =
|
modifier =
|
||||||
Modifier.fillMaxWidth()
|
Modifier.fillMaxWidth()
|
||||||
.padding(
|
.padding(
|
||||||
horizontal =
|
horizontal = 20.dp,
|
||||||
20.dp,
|
vertical = 12.dp
|
||||||
vertical =
|
|
||||||
12.dp
|
|
||||||
),
|
),
|
||||||
contentAlignment =
|
contentAlignment =
|
||||||
Alignment.CenterStart
|
Alignment.CenterStart
|
||||||
@@ -795,13 +797,9 @@ fun ChatsListScreen(
|
|||||||
fontSize = 12.sp,
|
fontSize = 12.sp,
|
||||||
color =
|
color =
|
||||||
if (isDarkTheme)
|
if (isDarkTheme)
|
||||||
Color(
|
Color(0xFF666666)
|
||||||
0xFF666666
|
|
||||||
)
|
|
||||||
else
|
else
|
||||||
Color(
|
Color(0xFF999999)
|
||||||
0xFF999999
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -827,11 +825,11 @@ fun ChatsListScreen(
|
|||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
TablerIcons
|
TablerIcons
|
||||||
.ArrowLeft,
|
.ChevronLeft,
|
||||||
contentDescription =
|
contentDescription =
|
||||||
"Back",
|
"Back",
|
||||||
tint =
|
tint =
|
||||||
PrimaryBlue
|
Color.White
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -846,6 +844,7 @@ fun ChatsListScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
) {
|
) {
|
||||||
|
Box {
|
||||||
Icon(
|
Icon(
|
||||||
TablerIcons
|
TablerIcons
|
||||||
.Menu2,
|
.Menu2,
|
||||||
@@ -858,6 +857,18 @@ fun ChatsListScreen(
|
|||||||
0.6f
|
0.6f
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
if (topLevelRequestsCount > 0) {
|
||||||
|
Box(
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.align(Alignment.TopEnd)
|
||||||
|
.offset(x = 2.dp, y = (-2).dp)
|
||||||
|
.size(8.dp)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(PrimaryBlue)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -873,13 +884,8 @@ fun ChatsListScreen(
|
|||||||
color = textColor
|
color = textColor
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
// Rosetta title
|
// Rosetta title or Connecting animation
|
||||||
// with status
|
if (protocolState == ProtocolState.AUTHENTICATED) {
|
||||||
Row(
|
|
||||||
verticalAlignment =
|
|
||||||
Alignment
|
|
||||||
.CenterVertically
|
|
||||||
) {
|
|
||||||
Text(
|
Text(
|
||||||
"Rosetta",
|
"Rosetta",
|
||||||
fontWeight =
|
fontWeight =
|
||||||
@@ -890,48 +896,12 @@ fun ChatsListScreen(
|
|||||||
color =
|
color =
|
||||||
textColor
|
textColor
|
||||||
)
|
)
|
||||||
Spacer(
|
} else {
|
||||||
modifier =
|
AnimatedDotsText(
|
||||||
Modifier.width(
|
baseText = "Connecting",
|
||||||
8.dp
|
color = textColor,
|
||||||
)
|
fontSize = 20.sp,
|
||||||
)
|
fontWeight = FontWeight.Bold
|
||||||
Box(
|
|
||||||
modifier =
|
|
||||||
Modifier.size(
|
|
||||||
10.dp
|
|
||||||
)
|
|
||||||
.clip(
|
|
||||||
CircleShape
|
|
||||||
)
|
|
||||||
.background(
|
|
||||||
when (protocolState
|
|
||||||
) {
|
|
||||||
ProtocolState
|
|
||||||
.AUTHENTICATED ->
|
|
||||||
Color(
|
|
||||||
0xFF4CAF50
|
|
||||||
)
|
|
||||||
ProtocolState
|
|
||||||
.CONNECTING,
|
|
||||||
ProtocolState
|
|
||||||
.CONNECTED,
|
|
||||||
ProtocolState
|
|
||||||
.HANDSHAKING ->
|
|
||||||
Color(
|
|
||||||
0xFFFFC107
|
|
||||||
)
|
|
||||||
ProtocolState
|
|
||||||
.DISCONNECTED ->
|
|
||||||
Color(
|
|
||||||
0xFFF44336
|
|
||||||
)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
.clickable {
|
|
||||||
showStatusDialog =
|
|
||||||
true
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1092,7 +1062,55 @@ fun ChatsListScreen(
|
|||||||
label = "RequestsTransition"
|
label = "RequestsTransition"
|
||||||
) { isRequestsScreen ->
|
) { isRequestsScreen ->
|
||||||
if (isRequestsScreen) {
|
if (isRequestsScreen) {
|
||||||
// 📬 Show Requests Screen
|
// 📬 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) {
|
||||||
|
showRequestsScreen = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
) {
|
||||||
RequestsScreen(
|
RequestsScreen(
|
||||||
requests = requests,
|
requests = requests,
|
||||||
isDarkTheme = isDarkTheme,
|
isDarkTheme = isDarkTheme,
|
||||||
@@ -1100,15 +1118,50 @@ fun ChatsListScreen(
|
|||||||
showRequestsScreen = false
|
showRequestsScreen = false
|
||||||
},
|
},
|
||||||
onRequestClick = { request ->
|
onRequestClick = { request ->
|
||||||
showRequestsScreen = false
|
|
||||||
val user =
|
val user =
|
||||||
chatsViewModel
|
chatsViewModel
|
||||||
.dialogToSearchUser(
|
.dialogToSearchUser(
|
||||||
request
|
request
|
||||||
)
|
)
|
||||||
onUserSelect(user)
|
onUserSelect(user)
|
||||||
|
},
|
||||||
|
avatarRepository =
|
||||||
|
avatarRepository,
|
||||||
|
blockedUsers = blockedUsers,
|
||||||
|
pinnedChats = pinnedChats,
|
||||||
|
isDrawerOpen =
|
||||||
|
drawerState.isOpen ||
|
||||||
|
drawerState
|
||||||
|
.isAnimationRunning,
|
||||||
|
onTogglePin = { opponentKey ->
|
||||||
|
onTogglePin(opponentKey)
|
||||||
|
},
|
||||||
|
onDeleteDialog = { opponentKey ->
|
||||||
|
scope.launch {
|
||||||
|
chatsViewModel
|
||||||
|
.deleteDialog(
|
||||||
|
opponentKey
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onBlockUser = { opponentKey ->
|
||||||
|
scope.launch {
|
||||||
|
chatsViewModel
|
||||||
|
.blockUser(
|
||||||
|
opponentKey
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onUnblockUser = { opponentKey ->
|
||||||
|
scope.launch {
|
||||||
|
chatsViewModel
|
||||||
|
.unblockUser(
|
||||||
|
opponentKey
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
} // Close Box wrapper
|
||||||
} else if (isLoading) {
|
} else if (isLoading) {
|
||||||
// 🚀 Shimmer skeleton пока данные грузятся
|
// 🚀 Shimmer skeleton пока данные грузятся
|
||||||
ChatsListSkeleton(isDarkTheme = isDarkTheme)
|
ChatsListSkeleton(isDarkTheme = isDarkTheme)
|
||||||
@@ -1586,6 +1639,49 @@ private fun EmptyChatsState(isDarkTheme: Boolean, modifier: Modifier = Modifier)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun EmptyRequestsState(isDarkTheme: Boolean, modifier: Modifier = Modifier) {
|
||||||
|
val secondaryTextColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666)
|
||||||
|
val backgroundColor = if (isDarkTheme) Color(0xFF1A1A1A) else Color(0xFFF2F2F7)
|
||||||
|
|
||||||
|
val composition by rememberLottieComposition(LottieCompositionSpec.RawRes(R.raw.no_requests))
|
||||||
|
val progress by animateLottieCompositionAsState(
|
||||||
|
composition = composition,
|
||||||
|
iterations = LottieConstants.IterateForever
|
||||||
|
)
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = modifier.fillMaxSize().background(backgroundColor),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.Center
|
||||||
|
) {
|
||||||
|
LottieAnimation(
|
||||||
|
composition = composition,
|
||||||
|
progress = { progress },
|
||||||
|
modifier = Modifier.size(160.dp)
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(20.dp))
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = "No Requests",
|
||||||
|
fontSize = 18.sp,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
color = secondaryTextColor,
|
||||||
|
textAlign = TextAlign.Center
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = "New message requests will appear here",
|
||||||
|
fontSize = 15.sp,
|
||||||
|
color = secondaryTextColor.copy(alpha = 0.7f),
|
||||||
|
textAlign = TextAlign.Center
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Chat item for list
|
// Chat item for list
|
||||||
@Composable
|
@Composable
|
||||||
fun ChatItem(
|
fun ChatItem(
|
||||||
@@ -2784,7 +2880,7 @@ fun RequestsSection(
|
|||||||
Modifier
|
Modifier
|
||||||
.defaultMinSize(minWidth = 22.dp, minHeight = 22.dp)
|
.defaultMinSize(minWidth = 22.dp, minHeight = 22.dp)
|
||||||
.clip(RoundedCornerShape(11.dp))
|
.clip(RoundedCornerShape(11.dp))
|
||||||
.background(iconBgColor),
|
.background(PrimaryBlue),
|
||||||
contentAlignment = Alignment.Center
|
contentAlignment = Alignment.Center
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
@@ -2809,7 +2905,7 @@ fun RequestsSection(
|
|||||||
Modifier
|
Modifier
|
||||||
.defaultMinSize(minWidth = 22.dp, minHeight = 22.dp)
|
.defaultMinSize(minWidth = 22.dp, minHeight = 22.dp)
|
||||||
.clip(RoundedCornerShape(11.dp))
|
.clip(RoundedCornerShape(11.dp))
|
||||||
.background(iconBgColor),
|
.background(PrimaryBlue),
|
||||||
contentAlignment = Alignment.Center
|
contentAlignment = Alignment.Center
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
@@ -2832,27 +2928,37 @@ fun RequestsScreen(
|
|||||||
requests: List<DialogUiModel>,
|
requests: List<DialogUiModel>,
|
||||||
isDarkTheme: Boolean,
|
isDarkTheme: Boolean,
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
onRequestClick: (DialogUiModel) -> Unit
|
onRequestClick: (DialogUiModel) -> Unit,
|
||||||
|
avatarRepository: com.rosetta.messenger.repository.AvatarRepository? = null,
|
||||||
|
blockedUsers: Set<String> = emptySet(),
|
||||||
|
pinnedChats: Set<String> = emptySet(),
|
||||||
|
isDrawerOpen: Boolean = false,
|
||||||
|
onTogglePin: (String) -> Unit = {},
|
||||||
|
onDeleteDialog: (String) -> Unit = {},
|
||||||
|
onBlockUser: (String) -> Unit = {},
|
||||||
|
onUnblockUser: (String) -> Unit = {}
|
||||||
) {
|
) {
|
||||||
val backgroundColor = if (isDarkTheme) Color(0xFF1A1A1A) else Color(0xFFF2F2F7)
|
val backgroundColor = if (isDarkTheme) Color(0xFF1A1A1A) else Color(0xFFF2F2F7)
|
||||||
val dividerColor = if (isDarkTheme) Color(0xFF3A3A3A) else Color(0xFFE8E8E8)
|
val dividerColor = if (isDarkTheme) Color(0xFF3A3A3A) else Color(0xFFE8E8E8)
|
||||||
|
val textColor = if (isDarkTheme) Color.White else Color.Black
|
||||||
|
val secondaryTextColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666)
|
||||||
|
|
||||||
|
var swipedItemKey by remember { mutableStateOf<String?>(null) }
|
||||||
|
|
||||||
|
LaunchedEffect(isDrawerOpen) {
|
||||||
|
if (isDrawerOpen) {
|
||||||
|
swipedItemKey = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var dialogToDelete by remember { mutableStateOf<DialogUiModel?>(null) }
|
||||||
|
var dialogToBlock by remember { mutableStateOf<DialogUiModel?>(null) }
|
||||||
|
var dialogToUnblock by remember { mutableStateOf<DialogUiModel?>(null) }
|
||||||
|
|
||||||
Column(modifier = Modifier.fillMaxSize().background(backgroundColor)) {
|
Column(modifier = Modifier.fillMaxSize().background(backgroundColor)) {
|
||||||
if (requests.isEmpty()) {
|
if (requests.isEmpty()) {
|
||||||
// Empty state
|
// Empty state with Lottie animation
|
||||||
Box(
|
EmptyRequestsState(isDarkTheme = isDarkTheme)
|
||||||
modifier = Modifier.fillMaxSize().padding(32.dp),
|
|
||||||
contentAlignment = Alignment.Center
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = "No requests",
|
|
||||||
fontSize = 16.sp,
|
|
||||||
color =
|
|
||||||
if (isDarkTheme) Color(0xFF8E8E8E)
|
|
||||||
else Color(0xFF8E8E93),
|
|
||||||
textAlign = TextAlign.Center
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
// Requests list
|
// Requests list
|
||||||
LazyColumn(modifier = Modifier.fillMaxSize()) {
|
LazyColumn(modifier = Modifier.fillMaxSize()) {
|
||||||
@@ -2861,11 +2967,35 @@ fun RequestsScreen(
|
|||||||
key = { it.opponentKey },
|
key = { it.opponentKey },
|
||||||
contentType = { "request" }
|
contentType = { "request" }
|
||||||
) { request ->
|
) { request ->
|
||||||
DialogItemContent(
|
val isBlocked = blockedUsers.contains(request.opponentKey)
|
||||||
|
val isPinned = pinnedChats.contains(request.opponentKey)
|
||||||
|
|
||||||
|
SwipeableDialogItem(
|
||||||
dialog = request,
|
dialog = request,
|
||||||
isDarkTheme = isDarkTheme,
|
isDarkTheme = isDarkTheme,
|
||||||
isTyping = false,
|
isTyping = false,
|
||||||
onClick = { onRequestClick(request) }
|
isBlocked = isBlocked,
|
||||||
|
isSavedMessages = false,
|
||||||
|
avatarRepository = avatarRepository,
|
||||||
|
isDrawerOpen = isDrawerOpen,
|
||||||
|
isSwipedOpen =
|
||||||
|
swipedItemKey == request.opponentKey,
|
||||||
|
onSwipeStarted = {
|
||||||
|
swipedItemKey = request.opponentKey
|
||||||
|
},
|
||||||
|
onSwipeClosed = {
|
||||||
|
if (swipedItemKey == request.opponentKey)
|
||||||
|
swipedItemKey = null
|
||||||
|
},
|
||||||
|
onClick = {
|
||||||
|
swipedItemKey = null
|
||||||
|
onRequestClick(request)
|
||||||
|
},
|
||||||
|
onDelete = { dialogToDelete = request },
|
||||||
|
onBlock = { dialogToBlock = request },
|
||||||
|
onUnblock = { dialogToUnblock = request },
|
||||||
|
isPinned = isPinned,
|
||||||
|
onPin = { onTogglePin(request.opponentKey) }
|
||||||
)
|
)
|
||||||
|
|
||||||
Divider(
|
Divider(
|
||||||
@@ -2877,6 +3007,111 @@ fun RequestsScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
dialogToDelete?.let { dialog ->
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = { dialogToDelete = null },
|
||||||
|
containerColor =
|
||||||
|
if (isDarkTheme) Color(0xFF2C2C2E) else Color.White,
|
||||||
|
title = {
|
||||||
|
Text(
|
||||||
|
"Delete Chat",
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = textColor
|
||||||
|
)
|
||||||
|
},
|
||||||
|
text = {
|
||||||
|
Text(
|
||||||
|
"Are you sure you want to delete this chat? This action cannot be undone.",
|
||||||
|
color = secondaryTextColor
|
||||||
|
)
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(
|
||||||
|
onClick = {
|
||||||
|
val opponentKey = dialog.opponentKey
|
||||||
|
dialogToDelete = null
|
||||||
|
onDeleteDialog(opponentKey)
|
||||||
|
}
|
||||||
|
) { Text("Delete", color = Color(0xFFFF3B30)) }
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = { dialogToDelete = null }) {
|
||||||
|
Text("Cancel", color = PrimaryBlue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
dialogToBlock?.let { dialog ->
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = { dialogToBlock = null },
|
||||||
|
containerColor =
|
||||||
|
if (isDarkTheme) Color(0xFF2C2C2E) else Color.White,
|
||||||
|
title = {
|
||||||
|
Text(
|
||||||
|
"Block ${dialog.opponentTitle.ifEmpty { "User" }}",
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = textColor
|
||||||
|
)
|
||||||
|
},
|
||||||
|
text = {
|
||||||
|
Text(
|
||||||
|
"Are you sure you want to block this user? They won't be able to send you messages.",
|
||||||
|
color = secondaryTextColor
|
||||||
|
)
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(
|
||||||
|
onClick = {
|
||||||
|
val opponentKey = dialog.opponentKey
|
||||||
|
dialogToBlock = null
|
||||||
|
onBlockUser(opponentKey)
|
||||||
|
}
|
||||||
|
) { Text("Block", color = Color(0xFFFF3B30)) }
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = { dialogToBlock = null }) {
|
||||||
|
Text("Cancel", color = PrimaryBlue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
dialogToUnblock?.let { dialog ->
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = { dialogToUnblock = null },
|
||||||
|
containerColor =
|
||||||
|
if (isDarkTheme) Color(0xFF2C2C2E) else Color.White,
|
||||||
|
title = {
|
||||||
|
Text(
|
||||||
|
"Unblock ${dialog.opponentTitle.ifEmpty { "User" }}",
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = textColor
|
||||||
|
)
|
||||||
|
},
|
||||||
|
text = {
|
||||||
|
Text(
|
||||||
|
"Are you sure you want to unblock this user? They will be able to send you messages again.",
|
||||||
|
color = secondaryTextColor
|
||||||
|
)
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(
|
||||||
|
onClick = {
|
||||||
|
val opponentKey = dialog.opponentKey
|
||||||
|
dialogToUnblock = null
|
||||||
|
onUnblockUser(opponentKey)
|
||||||
|
}
|
||||||
|
) { Text("Unblock", color = Color(0xFF34C759)) }
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = { dialogToUnblock = null }) {
|
||||||
|
Text("Cancel", color = PrimaryBlue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 🎨 Enhanced Drawer Menu Item - красивый пункт меню с hover эффектом */
|
/** 🎨 Enhanced Drawer Menu Item - красивый пункт меню с hover эффектом */
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package com.rosetta.messenger.ui.chats
|
package com.rosetta.messenger.ui.chats
|
||||||
|
|
||||||
import android.view.HapticFeedbackConstants
|
import android.view.HapticFeedbackConstants
|
||||||
|
import androidx.compose.animation.animateColorAsState
|
||||||
import androidx.compose.animation.core.*
|
import androidx.compose.animation.core.*
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
@@ -12,6 +13,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape
|
|||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.ArrowForward
|
import androidx.compose.material.icons.filled.ArrowForward
|
||||||
import androidx.compose.material.icons.filled.Close
|
import androidx.compose.material.icons.filled.Close
|
||||||
|
import androidx.compose.material.icons.filled.Check
|
||||||
import androidx.compose.material3.*
|
import androidx.compose.material3.*
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
@@ -33,11 +35,12 @@ import java.util.*
|
|||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 📨 BottomSheet для выбора чата при Forward сообщений
|
* 📨 BottomSheet для выбора чатов при Forward сообщений
|
||||||
*
|
*
|
||||||
* Логика как в десктопной версии:
|
* Поддержка мультивыбора:
|
||||||
* 1. Показывает список диалогов
|
* 1. Показывает список диалогов с чекбоксами
|
||||||
* 2. При выборе диалога - переходит в чат с сообщениями в Reply панели
|
* 2. Можно выбрать один или несколько чатов
|
||||||
|
* 3. Кнопка "Forward" внизу подтверждает выбор
|
||||||
*/
|
*/
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
@@ -47,9 +50,10 @@ fun ForwardChatPickerBottomSheet(
|
|||||||
currentUserPublicKey: String,
|
currentUserPublicKey: String,
|
||||||
avatarRepository: AvatarRepository? = null,
|
avatarRepository: AvatarRepository? = null,
|
||||||
onDismiss: () -> Unit,
|
onDismiss: () -> Unit,
|
||||||
onChatSelected: (DialogUiModel) -> Unit
|
onChatSelected: (DialogUiModel) -> Unit = {},
|
||||||
|
onChatsSelected: (List<DialogUiModel>) -> Unit = { list -> if (list.size == 1) onChatSelected(list.first()) }
|
||||||
) {
|
) {
|
||||||
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = false)
|
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
val view = LocalView.current
|
val view = LocalView.current
|
||||||
|
|
||||||
@@ -61,6 +65,9 @@ fun ForwardChatPickerBottomSheet(
|
|||||||
val forwardMessages by ForwardManager.forwardMessages.collectAsState()
|
val forwardMessages by ForwardManager.forwardMessages.collectAsState()
|
||||||
val messagesCount = forwardMessages.size
|
val messagesCount = forwardMessages.size
|
||||||
|
|
||||||
|
// Мультивыбор чатов
|
||||||
|
var selectedChats by remember { mutableStateOf<Set<String>>(emptySet()) }
|
||||||
|
|
||||||
// 🔥 Haptic feedback при открытии
|
// 🔥 Haptic feedback при открытии
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
view.performHapticFeedback(HapticFeedbackConstants.LONG_PRESS)
|
view.performHapticFeedback(HapticFeedbackConstants.LONG_PRESS)
|
||||||
@@ -109,7 +116,7 @@ fun ForwardChatPickerBottomSheet(
|
|||||||
onDismissRequest = { dismissWithAnimation() },
|
onDismissRequest = { dismissWithAnimation() },
|
||||||
sheetState = sheetState,
|
sheetState = sheetState,
|
||||||
containerColor = backgroundColor,
|
containerColor = backgroundColor,
|
||||||
scrimColor = Color.Black.copy(alpha = 0.6f), // 🔥 Более тёмный overlay - перекрывает status bar
|
scrimColor = Color.Black.copy(alpha = 0.6f),
|
||||||
dragHandle = {
|
dragHandle = {
|
||||||
// Кастомный handle
|
// Кастомный handle
|
||||||
Column(
|
Column(
|
||||||
@@ -131,7 +138,7 @@ fun ForwardChatPickerBottomSheet(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
shape = RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp),
|
shape = RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp),
|
||||||
modifier = Modifier.statusBarsPadding() // 🔥 Учитываем status bar
|
modifier = Modifier.statusBarsPadding()
|
||||||
) {
|
) {
|
||||||
Column(modifier = Modifier.fillMaxWidth().navigationBarsPadding()) {
|
Column(modifier = Modifier.fillMaxWidth().navigationBarsPadding()) {
|
||||||
// Header
|
// Header
|
||||||
@@ -142,7 +149,6 @@ fun ForwardChatPickerBottomSheet(
|
|||||||
) {
|
) {
|
||||||
// Иконка и заголовок
|
// Иконка и заголовок
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
// 🔥 Красивая иконка Forward
|
|
||||||
Icon(
|
Icon(
|
||||||
Icons.Filled.ArrowForward,
|
Icons.Filled.ArrowForward,
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
@@ -206,18 +212,24 @@ fun ForwardChatPickerBottomSheet(
|
|||||||
LazyColumn(
|
LazyColumn(
|
||||||
modifier =
|
modifier =
|
||||||
Modifier.fillMaxWidth()
|
Modifier.fillMaxWidth()
|
||||||
.heightIn(
|
.weight(1f)
|
||||||
min = 300.dp,
|
|
||||||
max = 400.dp
|
|
||||||
) // 🔥 Минимальная высота для лучшего UX
|
|
||||||
) {
|
) {
|
||||||
items(dialogs, key = { it.opponentKey }) { dialog ->
|
items(dialogs, key = { it.opponentKey }) { dialog ->
|
||||||
|
val isSelected = dialog.opponentKey in selectedChats
|
||||||
ForwardDialogItem(
|
ForwardDialogItem(
|
||||||
dialog = dialog,
|
dialog = dialog,
|
||||||
isDarkTheme = isDarkTheme,
|
isDarkTheme = isDarkTheme,
|
||||||
isSavedMessages = dialog.opponentKey == currentUserPublicKey,
|
isSavedMessages = dialog.opponentKey == currentUserPublicKey,
|
||||||
|
isSelected = isSelected,
|
||||||
avatarRepository = avatarRepository,
|
avatarRepository = avatarRepository,
|
||||||
onClick = { onChatSelected(dialog) }
|
onClick = {
|
||||||
|
view.performHapticFeedback(HapticFeedbackConstants.KEYBOARD_TAP)
|
||||||
|
selectedChats = if (isSelected) {
|
||||||
|
selectedChats - dialog.opponentKey
|
||||||
|
} else {
|
||||||
|
selectedChats + dialog.opponentKey
|
||||||
|
}
|
||||||
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
// Сепаратор между диалогами
|
// Сепаратор между диалогами
|
||||||
@@ -232,6 +244,45 @@ fun ForwardChatPickerBottomSheet(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Кнопка Forward (всегда видна, disabled когда ничего не выбрано)
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
Divider(color = dividerColor, thickness = 0.5.dp)
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
|
val hasSelection = selectedChats.isNotEmpty()
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
val selectedDialogs = dialogs.filter { it.opponentKey in selectedChats }
|
||||||
|
onChatsSelected(selectedDialogs)
|
||||||
|
},
|
||||||
|
enabled = hasSelection,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp)
|
||||||
|
.height(48.dp),
|
||||||
|
shape = RoundedCornerShape(12.dp),
|
||||||
|
colors = ButtonDefaults.buttonColors(
|
||||||
|
containerColor = PrimaryBlue,
|
||||||
|
disabledContainerColor = if (isDarkTheme) Color(0xFF3A3A3A) else Color(0xFFD1D1D6),
|
||||||
|
disabledContentColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF999999)
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Icons.Filled.ArrowForward,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(20.dp)
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
Text(
|
||||||
|
text = if (hasSelection)
|
||||||
|
"Forward to ${selectedChats.size} chat${if (selectedChats.size > 1) "s" else ""}"
|
||||||
|
else
|
||||||
|
"Select a chat",
|
||||||
|
fontSize = 16.sp,
|
||||||
|
fontWeight = FontWeight.SemiBold
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// Нижний padding
|
// Нижний padding
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
}
|
}
|
||||||
@@ -244,6 +295,7 @@ private fun ForwardDialogItem(
|
|||||||
dialog: DialogUiModel,
|
dialog: DialogUiModel,
|
||||||
isDarkTheme: Boolean,
|
isDarkTheme: Boolean,
|
||||||
isSavedMessages: Boolean = false,
|
isSavedMessages: Boolean = false,
|
||||||
|
isSelected: Boolean = false,
|
||||||
avatarRepository: AvatarRepository? = null,
|
avatarRepository: AvatarRepository? = null,
|
||||||
onClick: () -> Unit
|
onClick: () -> Unit
|
||||||
) {
|
) {
|
||||||
@@ -269,19 +321,22 @@ private fun ForwardDialogItem(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val selectedBg by animateColorAsState(
|
||||||
|
targetValue = if (isSelected) PrimaryBlue.copy(alpha = 0.08f) else Color.Transparent,
|
||||||
|
animationSpec = tween(150),
|
||||||
|
label = "selectedBg"
|
||||||
|
)
|
||||||
|
|
||||||
Row(
|
Row(
|
||||||
modifier =
|
modifier =
|
||||||
Modifier.fillMaxWidth()
|
Modifier.fillMaxWidth()
|
||||||
|
.background(selectedBg)
|
||||||
.clickable(onClick = onClick)
|
.clickable(onClick = onClick)
|
||||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically
|
||||||
) {
|
) {
|
||||||
// Avatar with real image support
|
// Avatar with real image support
|
||||||
if (isSavedMessages) {
|
if (isSavedMessages) {
|
||||||
// Saved Messages - special icon
|
|
||||||
val avatarColors = remember(dialog.opponentKey, isDarkTheme) {
|
|
||||||
getAvatarColor(dialog.opponentKey, isDarkTheme)
|
|
||||||
}
|
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.size(48.dp)
|
.size(48.dp)
|
||||||
@@ -357,9 +412,27 @@ private fun ForwardDialogItem(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Online indicator
|
// Чекбокс выбора (справа)
|
||||||
if (!isSavedMessages && dialog.isOnline == 1) {
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
Box(modifier = Modifier.size(10.dp).clip(CircleShape).background(Color(0xFF34C759)))
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(24.dp)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(
|
||||||
|
if (isSelected) Color(0xFF4CD964)
|
||||||
|
else if (isDarkTheme) Color(0xFF3A3A3A)
|
||||||
|
else Color(0xFFE0E0E0)
|
||||||
|
),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
if (isSelected) {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.Check,
|
||||||
|
contentDescription = "Selected",
|
||||||
|
tint = Color.White,
|
||||||
|
modifier = Modifier.size(16.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,25 +14,33 @@ import androidx.compose.material3.TopAppBarDefaults
|
|||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import com.rosetta.messenger.network.SearchUser
|
import com.rosetta.messenger.network.SearchUser
|
||||||
|
import com.rosetta.messenger.repository.AvatarRepository
|
||||||
import com.rosetta.messenger.ui.onboarding.PrimaryBlue
|
import com.rosetta.messenger.ui.onboarding.PrimaryBlue
|
||||||
import compose.icons.TablerIcons
|
import compose.icons.TablerIcons
|
||||||
import compose.icons.tablericons.ArrowLeft
|
import compose.icons.tablericons.ChevronLeft
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun RequestsListScreen(
|
fun RequestsListScreen(
|
||||||
isDarkTheme: Boolean,
|
isDarkTheme: Boolean,
|
||||||
chatsViewModel: ChatsListViewModel,
|
chatsViewModel: ChatsListViewModel,
|
||||||
|
pinnedChats: Set<String>,
|
||||||
|
onTogglePin: (String) -> Unit,
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
onUserSelect: (SearchUser) -> Unit
|
onUserSelect: (SearchUser) -> Unit,
|
||||||
|
avatarRepository: AvatarRepository? = null
|
||||||
) {
|
) {
|
||||||
val chatsState by chatsViewModel.chatsState.collectAsState()
|
val chatsState by chatsViewModel.chatsState.collectAsState()
|
||||||
val requests = chatsState.requests
|
val requests = chatsState.requests
|
||||||
|
val blockedUsers by chatsViewModel.blockedUsers.collectAsState()
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
val backgroundColor = if (isDarkTheme) Color(0xFF1A1A1A) else Color(0xFFF2F2F7)
|
val backgroundColor = if (isDarkTheme) Color(0xFF1A1A1A) else Color(0xFFF2F2F7)
|
||||||
val textColor = if (isDarkTheme) Color.White else Color.Black
|
val textColor = if (isDarkTheme) Color.White else Color.Black
|
||||||
|
|
||||||
@@ -42,9 +50,9 @@ fun RequestsListScreen(
|
|||||||
navigationIcon = {
|
navigationIcon = {
|
||||||
IconButton(onClick = onBack) {
|
IconButton(onClick = onBack) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = TablerIcons.ArrowLeft,
|
imageVector = TablerIcons.ChevronLeft,
|
||||||
contentDescription = "Back",
|
contentDescription = "Back",
|
||||||
tint = PrimaryBlue
|
tint = Color.White
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -79,6 +87,21 @@ fun RequestsListScreen(
|
|||||||
onBack = onBack,
|
onBack = onBack,
|
||||||
onRequestClick = { request ->
|
onRequestClick = { request ->
|
||||||
onUserSelect(chatsViewModel.dialogToSearchUser(request))
|
onUserSelect(chatsViewModel.dialogToSearchUser(request))
|
||||||
|
},
|
||||||
|
avatarRepository = avatarRepository,
|
||||||
|
blockedUsers = blockedUsers,
|
||||||
|
pinnedChats = pinnedChats,
|
||||||
|
onTogglePin = onTogglePin,
|
||||||
|
onDeleteDialog = { opponentKey ->
|
||||||
|
scope.launch {
|
||||||
|
chatsViewModel.deleteDialog(opponentKey)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onBlockUser = { opponentKey ->
|
||||||
|
scope.launch { chatsViewModel.blockUser(opponentKey) }
|
||||||
|
},
|
||||||
|
onUnblockUser = { opponentKey ->
|
||||||
|
scope.launch { chatsViewModel.unblockUser(opponentKey) }
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,10 @@ import androidx.compose.animation.core.spring
|
|||||||
import androidx.compose.animation.core.tween
|
import androidx.compose.animation.core.tween
|
||||||
import androidx.compose.foundation.Image
|
import androidx.compose.foundation.Image
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.combinedClickable
|
||||||
|
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
@@ -204,6 +207,8 @@ fun MessageAttachments(
|
|||||||
currentUserPublicKey: String = "",
|
currentUserPublicKey: String = "",
|
||||||
hasCaption: Boolean = false, // Если есть caption - время показывается под фото, не на фото
|
hasCaption: Boolean = false, // Если есть caption - время показывается под фото, не на фото
|
||||||
showTail: Boolean = true, // Показывать хвостик пузырька
|
showTail: Boolean = true, // Показывать хвостик пузырька
|
||||||
|
isSelectionMode: Boolean = false, // Блокировать клик на фото в selection mode
|
||||||
|
onLongClick: () -> Unit = {}, // Long press на фото — запускает selection mode
|
||||||
onImageClick: (attachmentId: String, bounds: ImageSourceBounds?) -> Unit = { _, _ -> },
|
onImageClick: (attachmentId: String, bounds: ImageSourceBounds?) -> Unit = { _, _ -> },
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier
|
||||||
) {
|
) {
|
||||||
@@ -230,6 +235,8 @@ fun MessageAttachments(
|
|||||||
messageStatus = messageStatus,
|
messageStatus = messageStatus,
|
||||||
hasCaption = hasCaption,
|
hasCaption = hasCaption,
|
||||||
showTail = showTail,
|
showTail = showTail,
|
||||||
|
isSelectionMode = isSelectionMode,
|
||||||
|
onLongClick = onLongClick,
|
||||||
onImageClick = onImageClick
|
onImageClick = onImageClick
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -290,6 +297,8 @@ fun ImageCollage(
|
|||||||
messageStatus: MessageStatus = MessageStatus.READ,
|
messageStatus: MessageStatus = MessageStatus.READ,
|
||||||
hasCaption: Boolean = false, // Если есть caption - время показывается под фото
|
hasCaption: Boolean = false, // Если есть caption - время показывается под фото
|
||||||
showTail: Boolean = true, // Показывать хвостик пузырька
|
showTail: Boolean = true, // Показывать хвостик пузырька
|
||||||
|
isSelectionMode: Boolean = false, // Блокировать клик на фото в selection mode
|
||||||
|
onLongClick: () -> Unit = {}, // Long press на фото — запускает selection mode
|
||||||
onImageClick: (attachmentId: String, bounds: ImageSourceBounds?) -> Unit = { _, _ -> },
|
onImageClick: (attachmentId: String, bounds: ImageSourceBounds?) -> Unit = { _, _ -> },
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier
|
||||||
) {
|
) {
|
||||||
@@ -335,6 +344,8 @@ fun ImageCollage(
|
|||||||
messageStatus = messageStatus,
|
messageStatus = messageStatus,
|
||||||
showTimeOverlay = showOverlayOnLast,
|
showTimeOverlay = showOverlayOnLast,
|
||||||
hasCaption = hasCaption,
|
hasCaption = hasCaption,
|
||||||
|
isSelectionMode = isSelectionMode,
|
||||||
|
onLongClick = onLongClick,
|
||||||
onImageClick = onImageClick
|
onImageClick = onImageClick
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -357,6 +368,8 @@ fun ImageCollage(
|
|||||||
messageStatus = messageStatus,
|
messageStatus = messageStatus,
|
||||||
showTimeOverlay = showOverlayOnLast && index == count - 1,
|
showTimeOverlay = showOverlayOnLast && index == count - 1,
|
||||||
aspectRatio = 1f,
|
aspectRatio = 1f,
|
||||||
|
isSelectionMode = isSelectionMode,
|
||||||
|
onLongClick = onLongClick,
|
||||||
onImageClick = onImageClick
|
onImageClick = onImageClick
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -382,6 +395,8 @@ fun ImageCollage(
|
|||||||
messageStatus = messageStatus,
|
messageStatus = messageStatus,
|
||||||
showTimeOverlay = false,
|
showTimeOverlay = false,
|
||||||
fillMaxSize = true,
|
fillMaxSize = true,
|
||||||
|
isSelectionMode = isSelectionMode,
|
||||||
|
onLongClick = onLongClick,
|
||||||
onImageClick = onImageClick
|
onImageClick = onImageClick
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -402,6 +417,8 @@ fun ImageCollage(
|
|||||||
messageStatus = messageStatus,
|
messageStatus = messageStatus,
|
||||||
showTimeOverlay = false,
|
showTimeOverlay = false,
|
||||||
fillMaxSize = true,
|
fillMaxSize = true,
|
||||||
|
isSelectionMode = isSelectionMode,
|
||||||
|
onLongClick = onLongClick,
|
||||||
onImageClick = onImageClick
|
onImageClick = onImageClick
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -417,6 +434,8 @@ fun ImageCollage(
|
|||||||
messageStatus = messageStatus,
|
messageStatus = messageStatus,
|
||||||
showTimeOverlay = showOverlayOnLast,
|
showTimeOverlay = showOverlayOnLast,
|
||||||
fillMaxSize = true,
|
fillMaxSize = true,
|
||||||
|
isSelectionMode = isSelectionMode,
|
||||||
|
onLongClick = onLongClick,
|
||||||
onImageClick = onImageClick
|
onImageClick = onImageClick
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -445,6 +464,8 @@ fun ImageCollage(
|
|||||||
messageStatus = messageStatus,
|
messageStatus = messageStatus,
|
||||||
showTimeOverlay = false,
|
showTimeOverlay = false,
|
||||||
fillMaxSize = true,
|
fillMaxSize = true,
|
||||||
|
isSelectionMode = isSelectionMode,
|
||||||
|
onLongClick = onLongClick,
|
||||||
onImageClick = onImageClick
|
onImageClick = onImageClick
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -460,6 +481,8 @@ fun ImageCollage(
|
|||||||
messageStatus = messageStatus,
|
messageStatus = messageStatus,
|
||||||
showTimeOverlay = false,
|
showTimeOverlay = false,
|
||||||
fillMaxSize = true,
|
fillMaxSize = true,
|
||||||
|
isSelectionMode = isSelectionMode,
|
||||||
|
onLongClick = onLongClick,
|
||||||
onImageClick = onImageClick
|
onImageClick = onImageClick
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -480,6 +503,8 @@ fun ImageCollage(
|
|||||||
messageStatus = messageStatus,
|
messageStatus = messageStatus,
|
||||||
showTimeOverlay = false,
|
showTimeOverlay = false,
|
||||||
fillMaxSize = true,
|
fillMaxSize = true,
|
||||||
|
isSelectionMode = isSelectionMode,
|
||||||
|
onLongClick = onLongClick,
|
||||||
onImageClick = onImageClick
|
onImageClick = onImageClick
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -495,6 +520,8 @@ fun ImageCollage(
|
|||||||
messageStatus = messageStatus,
|
messageStatus = messageStatus,
|
||||||
showTimeOverlay = showOverlayOnLast,
|
showTimeOverlay = showOverlayOnLast,
|
||||||
fillMaxSize = true,
|
fillMaxSize = true,
|
||||||
|
isSelectionMode = isSelectionMode,
|
||||||
|
onLongClick = onLongClick,
|
||||||
onImageClick = onImageClick
|
onImageClick = onImageClick
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -524,6 +551,8 @@ fun ImageCollage(
|
|||||||
messageStatus = messageStatus,
|
messageStatus = messageStatus,
|
||||||
showTimeOverlay = false,
|
showTimeOverlay = false,
|
||||||
fillMaxSize = true,
|
fillMaxSize = true,
|
||||||
|
isSelectionMode = isSelectionMode,
|
||||||
|
onLongClick = onLongClick,
|
||||||
onImageClick = onImageClick
|
onImageClick = onImageClick
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -539,6 +568,8 @@ fun ImageCollage(
|
|||||||
messageStatus = messageStatus,
|
messageStatus = messageStatus,
|
||||||
showTimeOverlay = false,
|
showTimeOverlay = false,
|
||||||
fillMaxSize = true,
|
fillMaxSize = true,
|
||||||
|
isSelectionMode = isSelectionMode,
|
||||||
|
onLongClick = onLongClick,
|
||||||
onImageClick = onImageClick
|
onImageClick = onImageClick
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -565,6 +596,8 @@ fun ImageCollage(
|
|||||||
messageStatus = messageStatus,
|
messageStatus = messageStatus,
|
||||||
showTimeOverlay = showOverlayOnLast && isLastItem,
|
showTimeOverlay = showOverlayOnLast && isLastItem,
|
||||||
fillMaxSize = true,
|
fillMaxSize = true,
|
||||||
|
isSelectionMode = isSelectionMode,
|
||||||
|
onLongClick = onLongClick,
|
||||||
onImageClick = onImageClick
|
onImageClick = onImageClick
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -588,6 +621,7 @@ fun ImageCollage(
|
|||||||
* 3. Если файл есть локально → DOWNLOADED (загружаем из файла)
|
* 3. Если файл есть локально → DOWNLOADED (загружаем из файла)
|
||||||
* 4. Иначе → NOT_DOWNLOADED (нужно скачать с CDN)
|
* 4. Иначе → NOT_DOWNLOADED (нужно скачать с CDN)
|
||||||
*/
|
*/
|
||||||
|
@OptIn(ExperimentalFoundationApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun ImageAttachment(
|
fun ImageAttachment(
|
||||||
attachment: MessageAttachment,
|
attachment: MessageAttachment,
|
||||||
@@ -602,6 +636,8 @@ fun ImageAttachment(
|
|||||||
aspectRatio: Float? = null,
|
aspectRatio: Float? = null,
|
||||||
fillMaxSize: Boolean = false,
|
fillMaxSize: Boolean = false,
|
||||||
hasCaption: Boolean = false,
|
hasCaption: Boolean = false,
|
||||||
|
isSelectionMode: Boolean = false,
|
||||||
|
onLongClick: () -> Unit = {},
|
||||||
onImageClick: (attachmentId: String, bounds: ImageSourceBounds?) -> Unit = { _, _ -> }
|
onImageClick: (attachmentId: String, bounds: ImageSourceBounds?) -> Unit = { _, _ -> }
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
@@ -964,7 +1000,12 @@ fun ImageAttachment(
|
|||||||
// Capture bounds for shared element transition
|
// Capture bounds for shared element transition
|
||||||
imageBounds = coordinates.boundsInWindow()
|
imageBounds = coordinates.boundsInWindow()
|
||||||
}
|
}
|
||||||
.clickable {
|
.combinedClickable(
|
||||||
|
indication = null,
|
||||||
|
interactionSource = remember { MutableInteractionSource() },
|
||||||
|
onLongClick = onLongClick,
|
||||||
|
onClick = {
|
||||||
|
if (!isSelectionMode) {
|
||||||
when (downloadStatus) {
|
when (downloadStatus) {
|
||||||
DownloadStatus.NOT_DOWNLOADED -> download()
|
DownloadStatus.NOT_DOWNLOADED -> download()
|
||||||
DownloadStatus.DOWNLOADED -> {
|
DownloadStatus.DOWNLOADED -> {
|
||||||
@@ -984,7 +1025,11 @@ fun ImageAttachment(
|
|||||||
DownloadStatus.ERROR -> download()
|
DownloadStatus.ERROR -> download()
|
||||||
else -> {}
|
else -> {}
|
||||||
}
|
}
|
||||||
},
|
} else {
|
||||||
|
onLongClick()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
),
|
||||||
contentAlignment = Alignment.Center
|
contentAlignment = Alignment.Center
|
||||||
) {
|
) {
|
||||||
// Фоновый слой - blurhash или placeholder
|
// Фоновый слой - blurhash или placeholder
|
||||||
@@ -1884,13 +1929,13 @@ private val uuidRegex =
|
|||||||
* Проверка является ли preview UUID тегом для скачивания Как в desktop:
|
* Проверка является ли preview UUID тегом для скачивания Как в desktop:
|
||||||
* attachment.preview.split("::")[0].match(uuidRegex)
|
* attachment.preview.split("::")[0].match(uuidRegex)
|
||||||
*/
|
*/
|
||||||
private fun isDownloadTag(preview: String): Boolean {
|
internal fun isDownloadTag(preview: String): Boolean {
|
||||||
val firstPart = preview.split("::").firstOrNull() ?: return false
|
val firstPart = preview.split("::").firstOrNull() ?: return false
|
||||||
return uuidRegex.matches(firstPart)
|
return uuidRegex.matches(firstPart)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Получить download tag из preview */
|
/** Получить download tag из preview */
|
||||||
private fun getDownloadTag(preview: String): String {
|
internal fun getDownloadTag(preview: String): String {
|
||||||
val parts = preview.split("::")
|
val parts = preview.split("::")
|
||||||
if (parts.isNotEmpty() && uuidRegex.matches(parts[0])) {
|
if (parts.isNotEmpty() && uuidRegex.matches(parts[0])) {
|
||||||
return parts[0]
|
return parts[0]
|
||||||
@@ -1899,7 +1944,7 @@ private fun getDownloadTag(preview: String): String {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Получить preview без download tag Как в desktop: preview.split("::").splice(1).join("::") */
|
/** Получить preview без download tag Как в desktop: preview.split("::").splice(1).join("::") */
|
||||||
private fun getPreview(preview: String): String {
|
internal fun getPreview(preview: String): String {
|
||||||
val parts = preview.split("::")
|
val parts = preview.split("::")
|
||||||
if (parts.isNotEmpty() && uuidRegex.matches(parts[0])) {
|
if (parts.isNotEmpty() && uuidRegex.matches(parts[0])) {
|
||||||
return parts.drop(1).joinToString("::")
|
return parts.drop(1).joinToString("::")
|
||||||
@@ -1931,7 +1976,7 @@ private fun parseFilePreview(preview: String): Pair<Long, String> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Декодирование base64 в Bitmap */
|
/** Декодирование base64 в Bitmap */
|
||||||
private fun base64ToBitmap(base64: String): Bitmap? {
|
internal fun base64ToBitmap(base64: String): Bitmap? {
|
||||||
return try {
|
return try {
|
||||||
val cleanBase64 =
|
val cleanBase64 =
|
||||||
if (base64.contains(",")) {
|
if (base64.contains(",")) {
|
||||||
|
|||||||
@@ -29,6 +29,8 @@ import androidx.compose.ui.graphics.vector.ImageVector
|
|||||||
import androidx.compose.ui.input.pointer.pointerInput
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
import androidx.compose.ui.layout.ContentScale
|
import androidx.compose.ui.layout.ContentScale
|
||||||
import androidx.compose.ui.layout.Layout
|
import androidx.compose.ui.layout.Layout
|
||||||
|
import androidx.compose.ui.layout.boundsInWindow
|
||||||
|
import androidx.compose.ui.layout.onGloballyPositioned
|
||||||
import androidx.compose.ui.platform.LocalConfiguration
|
import androidx.compose.ui.platform.LocalConfiguration
|
||||||
import androidx.compose.ui.platform.LocalDensity
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
@@ -44,6 +46,8 @@ import com.rosetta.messenger.ui.chats.models.*
|
|||||||
import com.rosetta.messenger.ui.chats.utils.*
|
import com.rosetta.messenger.ui.chats.utils.*
|
||||||
import com.rosetta.messenger.ui.components.AppleEmojiText
|
import com.rosetta.messenger.ui.components.AppleEmojiText
|
||||||
import com.rosetta.messenger.ui.onboarding.PrimaryBlue
|
import com.rosetta.messenger.ui.onboarding.PrimaryBlue
|
||||||
|
import com.rosetta.messenger.crypto.MessageCrypto
|
||||||
|
import com.rosetta.messenger.network.TransportManager
|
||||||
import com.rosetta.messenger.utils.AttachmentFileManager
|
import com.rosetta.messenger.utils.AttachmentFileManager
|
||||||
import com.vanniktech.blurhash.BlurHash
|
import com.vanniktech.blurhash.BlurHash
|
||||||
import compose.icons.TablerIcons
|
import compose.icons.TablerIcons
|
||||||
@@ -640,7 +644,10 @@ fun MessageBubble(
|
|||||||
replyData = reply,
|
replyData = reply,
|
||||||
isOutgoing = message.isOutgoing,
|
isOutgoing = message.isOutgoing,
|
||||||
isDarkTheme = isDarkTheme,
|
isDarkTheme = isDarkTheme,
|
||||||
onClick = { onReplyClick(reply.messageId) }
|
chachaKey = message.chachaKey,
|
||||||
|
privateKey = privateKey,
|
||||||
|
onClick = { onReplyClick(reply.messageId) },
|
||||||
|
onImageClick = onImageClick
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.height(4.dp))
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
}
|
}
|
||||||
@@ -664,7 +671,10 @@ fun MessageBubble(
|
|||||||
hasCaption = hasImageWithCaption,
|
hasCaption = hasImageWithCaption,
|
||||||
showTail = showTail, // Передаём для формы
|
showTail = showTail, // Передаём для формы
|
||||||
// пузырька
|
// пузырька
|
||||||
onImageClick = onImageClick
|
isSelectionMode = isSelectionMode,
|
||||||
|
onLongClick = onLongClick,
|
||||||
|
// В selection mode блокируем открытие фото
|
||||||
|
onImageClick = if (isSelectionMode) { _, _ -> } else onImageClick
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1067,7 +1077,10 @@ fun ReplyBubble(
|
|||||||
replyData: ReplyData,
|
replyData: ReplyData,
|
||||||
isOutgoing: Boolean,
|
isOutgoing: Boolean,
|
||||||
isDarkTheme: Boolean,
|
isDarkTheme: Boolean,
|
||||||
onClick: () -> Unit = {}
|
chachaKey: String = "",
|
||||||
|
privateKey: String = "",
|
||||||
|
onClick: () -> Unit = {},
|
||||||
|
onImageClick: (attachmentId: String, bounds: ImageSourceBounds?) -> Unit = { _, _ -> }
|
||||||
) {
|
) {
|
||||||
val context = androidx.compose.ui.platform.LocalContext.current
|
val context = androidx.compose.ui.platform.LocalContext.current
|
||||||
val backgroundColor =
|
val backgroundColor =
|
||||||
@@ -1095,13 +1108,11 @@ fun ReplyBubble(
|
|||||||
// Blurhash preview для fallback
|
// Blurhash preview для fallback
|
||||||
var blurPreviewBitmap by remember { mutableStateOf<Bitmap?>(null) }
|
var blurPreviewBitmap by remember { mutableStateOf<Bitmap?>(null) }
|
||||||
|
|
||||||
// Сначала загружаем blurhash preview
|
// Сначала загружаем blurhash preview (более высокое разрешение для чёткости)
|
||||||
LaunchedEffect(imageAttachment?.preview) {
|
LaunchedEffect(imageAttachment?.preview) {
|
||||||
if (imageAttachment != null && imageAttachment.preview.isNotEmpty()) {
|
if (imageAttachment != null && imageAttachment.preview.isNotEmpty()) {
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
try {
|
try {
|
||||||
// Получаем blurhash из preview (может быть в формате
|
|
||||||
// "tag::blurhash")
|
|
||||||
val blurhash =
|
val blurhash =
|
||||||
if (imageAttachment.preview.contains("::")) {
|
if (imageAttachment.preview.contains("::")) {
|
||||||
imageAttachment.preview.substringAfter("::")
|
imageAttachment.preview.substringAfter("::")
|
||||||
@@ -1115,57 +1126,49 @@ fun ReplyBubble(
|
|||||||
}
|
}
|
||||||
if (blurhash.isNotEmpty()) {
|
if (blurhash.isNotEmpty()) {
|
||||||
blurPreviewBitmap =
|
blurPreviewBitmap =
|
||||||
BlurHash.decode(blurhash, 36, 36)
|
BlurHash.decode(blurhash, 64, 64)
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
// Ignore blurhash decode errors
|
|
||||||
}
|
}
|
||||||
|
} catch (e: Exception) {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Потом пробуем загрузить полноценную картинку
|
// Загружаем полноценную картинку
|
||||||
LaunchedEffect(imageAttachment?.id) {
|
LaunchedEffect(imageAttachment?.id) {
|
||||||
if (imageAttachment != null) {
|
if (imageAttachment != null) {
|
||||||
|
// 1. Проверяем in-memory кэш (фото уже загружено в чате)
|
||||||
|
val cached = ImageBitmapCache.get("img_${imageAttachment.id}")
|
||||||
|
if (cached != null) {
|
||||||
|
imageBitmap = cached
|
||||||
|
return@LaunchedEffect
|
||||||
|
}
|
||||||
|
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
try {
|
try {
|
||||||
// Пробуем сначала из blob
|
// 2. Загружаем из localUri (для недавно отправленных/полученных фото)
|
||||||
if (imageAttachment.blob.isNotEmpty()) {
|
if (imageAttachment.localUri.isNotEmpty()) {
|
||||||
val decoded =
|
|
||||||
try {
|
try {
|
||||||
val cleanBase64 =
|
val uri = android.net.Uri.parse(imageAttachment.localUri)
|
||||||
if (imageAttachment.blob
|
context.contentResolver.openInputStream(uri)?.use { stream ->
|
||||||
.contains(
|
val bitmap = BitmapFactory.decodeStream(stream)
|
||||||
","
|
if (bitmap != null) {
|
||||||
)
|
imageBitmap = bitmap
|
||||||
) {
|
return@withContext
|
||||||
imageAttachment.blob
|
|
||||||
.substringAfter(
|
|
||||||
","
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
imageAttachment.blob
|
|
||||||
}
|
}
|
||||||
val decodedBytes =
|
|
||||||
Base64.decode(
|
|
||||||
cleanBase64,
|
|
||||||
Base64.DEFAULT
|
|
||||||
)
|
|
||||||
BitmapFactory.decodeByteArray(
|
|
||||||
decodedBytes,
|
|
||||||
0,
|
|
||||||
decodedBytes.size
|
|
||||||
)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
null
|
|
||||||
}
|
}
|
||||||
|
} catch (_: Exception) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Пробуем из blob
|
||||||
|
if (imageAttachment.blob.isNotEmpty()) {
|
||||||
|
val decoded = base64ToBitmap(imageAttachment.blob)
|
||||||
if (decoded != null) {
|
if (decoded != null) {
|
||||||
imageBitmap = decoded
|
imageBitmap = decoded
|
||||||
return@withContext
|
return@withContext
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Если blob нет - загружаем из локального файла
|
// 4. Загружаем из AttachmentFileManager
|
||||||
val localBlob =
|
val localBlob =
|
||||||
AttachmentFileManager.readAttachment(
|
AttachmentFileManager.readAttachment(
|
||||||
context,
|
context,
|
||||||
@@ -1173,37 +1176,84 @@ fun ReplyBubble(
|
|||||||
replyData.senderPublicKey,
|
replyData.senderPublicKey,
|
||||||
replyData.recipientPrivateKey
|
replyData.recipientPrivateKey
|
||||||
)
|
)
|
||||||
|
|
||||||
if (localBlob != null) {
|
if (localBlob != null) {
|
||||||
val decoded =
|
imageBitmap = base64ToBitmap(localBlob)
|
||||||
try {
|
if (imageBitmap != null) return@withContext
|
||||||
val cleanBase64 =
|
|
||||||
if (localBlob.contains(",")
|
|
||||||
) {
|
|
||||||
localBlob
|
|
||||||
.substringAfter(
|
|
||||||
","
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
localBlob
|
|
||||||
}
|
|
||||||
val decodedBytes =
|
|
||||||
Base64.decode(
|
|
||||||
cleanBase64,
|
|
||||||
Base64.DEFAULT
|
|
||||||
)
|
|
||||||
BitmapFactory.decodeByteArray(
|
|
||||||
decodedBytes,
|
|
||||||
0,
|
|
||||||
decodedBytes.size
|
|
||||||
)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
null
|
|
||||||
}
|
|
||||||
imageBitmap = decoded
|
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {}
|
} catch (e: Exception) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 5. Retry: фото может загрузиться в кэш параллельно
|
||||||
|
if (imageBitmap == null) {
|
||||||
|
repeat(6) {
|
||||||
|
kotlinx.coroutines.delay(500)
|
||||||
|
val retry = ImageBitmapCache.get("img_${imageAttachment.id}")
|
||||||
|
if (retry != null) {
|
||||||
|
imageBitmap = retry
|
||||||
|
return@LaunchedEffect
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. CDN download — для форвардов, где фото загружено на CDN
|
||||||
|
if (imageBitmap == null && imageAttachment.preview.isNotEmpty()) {
|
||||||
|
val downloadTag = getDownloadTag(imageAttachment.preview)
|
||||||
|
if (downloadTag.isNotEmpty()) {
|
||||||
|
try {
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
val encryptedContent = TransportManager.downloadFile(
|
||||||
|
imageAttachment.id, downloadTag
|
||||||
|
)
|
||||||
|
if (encryptedContent.isNotEmpty()) {
|
||||||
|
// Расшифровываем: нужен chachaKey сообщения-контейнера
|
||||||
|
val keyToUse = chachaKey.ifEmpty { replyData.recipientPrivateKey }
|
||||||
|
val privKey = privateKey.ifEmpty { replyData.recipientPrivateKey }
|
||||||
|
var decrypted: String? = null
|
||||||
|
|
||||||
|
// Способ 1: chachaKey + privateKey → ECDH → decrypt
|
||||||
|
if (chachaKey.isNotEmpty() && privKey.isNotEmpty()) {
|
||||||
|
try {
|
||||||
|
val plainKeyAndNonce = MessageCrypto.decryptKeyFromSender(
|
||||||
|
chachaKey, privKey
|
||||||
|
)
|
||||||
|
decrypted = MessageCrypto.decryptAttachmentBlobWithPlainKey(
|
||||||
|
encryptedContent, plainKeyAndNonce
|
||||||
|
)
|
||||||
|
} catch (_: Exception) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Способ 2: senderPublicKey + recipientPrivateKey
|
||||||
|
if (decrypted == null && replyData.senderPublicKey.isNotEmpty() && replyData.recipientPrivateKey.isNotEmpty()) {
|
||||||
|
try {
|
||||||
|
val plainKeyAndNonce = MessageCrypto.decryptKeyFromSender(
|
||||||
|
replyData.senderPublicKey, replyData.recipientPrivateKey
|
||||||
|
)
|
||||||
|
decrypted = MessageCrypto.decryptAttachmentBlobWithPlainKey(
|
||||||
|
encryptedContent, plainKeyAndNonce
|
||||||
|
)
|
||||||
|
} catch (_: Exception) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (decrypted != null) {
|
||||||
|
val bitmap = base64ToBitmap(decrypted)
|
||||||
|
if (bitmap != null) {
|
||||||
|
imageBitmap = bitmap
|
||||||
|
ImageBitmapCache.put("img_${imageAttachment.id}", bitmap)
|
||||||
|
// Сохраняем на диск
|
||||||
|
AttachmentFileManager.saveAttachment(
|
||||||
|
context = context,
|
||||||
|
blob = decrypted,
|
||||||
|
attachmentId = imageAttachment.id,
|
||||||
|
publicKey = replyData.senderPublicKey,
|
||||||
|
privateKey = replyData.recipientPrivateKey
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (_: Exception) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1219,19 +1269,36 @@ fun ReplyBubble(
|
|||||||
Box(modifier = Modifier.width(3.dp).fillMaxHeight().background(borderColor))
|
Box(modifier = Modifier.width(3.dp).fillMaxHeight().background(borderColor))
|
||||||
|
|
||||||
// Контент reply
|
// Контент reply
|
||||||
Row(
|
Column(
|
||||||
modifier =
|
modifier =
|
||||||
Modifier.weight(1f)
|
Modifier.weight(1f)
|
||||||
.padding(
|
.padding(
|
||||||
start = 8.dp,
|
start = 8.dp,
|
||||||
end = if (hasImage) 4.dp else 10.dp,
|
end = 10.dp,
|
||||||
top = 4.dp,
|
top = 4.dp,
|
||||||
bottom = 4.dp
|
bottom = 4.dp
|
||||||
),
|
)
|
||||||
verticalAlignment = Alignment.CenterVertically
|
|
||||||
) {
|
) {
|
||||||
// Текстовая часть
|
// Заголовок (имя отправителя / Forwarded from)
|
||||||
Column(modifier = Modifier.weight(1f)) {
|
if (replyData.isForwarded && replyData.forwardedFromName.isNotEmpty()) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Text(
|
||||||
|
text = "Forwarded from ",
|
||||||
|
color = nameColor,
|
||||||
|
fontSize = 14.sp,
|
||||||
|
fontWeight = FontWeight.Normal,
|
||||||
|
maxLines = 1
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = replyData.forwardedFromName,
|
||||||
|
color = nameColor,
|
||||||
|
fontSize = 14.sp,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
Text(
|
Text(
|
||||||
text =
|
text =
|
||||||
if (replyData.isForwarded) "Forwarded message"
|
if (replyData.isForwarded) "Forwarded message"
|
||||||
@@ -1242,18 +1309,23 @@ fun ReplyBubble(
|
|||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
overflow = TextOverflow.Ellipsis
|
overflow = TextOverflow.Ellipsis
|
||||||
)
|
)
|
||||||
|
|
||||||
// Текст или "Photo"
|
|
||||||
val displayText =
|
|
||||||
when {
|
|
||||||
replyData.text.isNotEmpty() -> replyData.text
|
|
||||||
hasImage -> "Photo"
|
|
||||||
replyData.attachments.any {
|
|
||||||
it.type == AttachmentType.FILE
|
|
||||||
} -> "File"
|
|
||||||
else -> "..."
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Текст сообщения
|
||||||
|
if (replyData.text.isNotEmpty()) {
|
||||||
|
AppleEmojiText(
|
||||||
|
text = replyData.text,
|
||||||
|
color = replyTextColor,
|
||||||
|
fontSize = 14.sp,
|
||||||
|
maxLines = 2,
|
||||||
|
overflow = android.text.TextUtils.TruncateAt.END,
|
||||||
|
enableLinks = false
|
||||||
|
)
|
||||||
|
} else if (!hasImage) {
|
||||||
|
val displayText = when {
|
||||||
|
replyData.attachments.any { it.type == AttachmentType.FILE } -> "File"
|
||||||
|
else -> "..."
|
||||||
|
}
|
||||||
AppleEmojiText(
|
AppleEmojiText(
|
||||||
text = displayText,
|
text = displayText,
|
||||||
color = replyTextColor,
|
color = replyTextColor,
|
||||||
@@ -1264,33 +1336,60 @@ fun ReplyBubble(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 🖼️ Превью изображения справа (как в Telegram)
|
// 🖼️ Большое фото внизу пузырька
|
||||||
if (hasImage) {
|
if (hasImage) {
|
||||||
Spacer(modifier = Modifier.width(8.dp))
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
|
|
||||||
|
// Aspect ratio из attachment
|
||||||
|
val imgWidth = imageAttachment?.width ?: 0
|
||||||
|
val imgHeight = imageAttachment?.height ?: 0
|
||||||
|
val aspectRatio = if (imgWidth > 0 && imgHeight > 0) {
|
||||||
|
imgWidth.toFloat() / imgHeight.toFloat()
|
||||||
|
} else {
|
||||||
|
1.5f
|
||||||
|
}
|
||||||
|
|
||||||
|
var photoBoxBounds by remember { mutableStateOf<ImageSourceBounds?>(null) }
|
||||||
|
|
||||||
Box(
|
Box(
|
||||||
modifier =
|
modifier =
|
||||||
Modifier.size(36.dp)
|
Modifier.fillMaxWidth()
|
||||||
.clip(RoundedCornerShape(4.dp))
|
.aspectRatio(aspectRatio.coerceIn(0.5f, 2.5f))
|
||||||
.background(Color.Gray.copy(alpha = 0.3f))
|
.heightIn(max = 180.dp)
|
||||||
|
.clip(RoundedCornerShape(6.dp))
|
||||||
|
.background(Color.Gray.copy(alpha = 0.2f))
|
||||||
|
.onGloballyPositioned { coords ->
|
||||||
|
val rect = coords.boundsInWindow()
|
||||||
|
photoBoxBounds = ImageSourceBounds(
|
||||||
|
left = rect.left,
|
||||||
|
top = rect.top,
|
||||||
|
width = rect.width,
|
||||||
|
height = rect.height,
|
||||||
|
cornerRadius = 6f,
|
||||||
|
thumbnailBitmap = imageBitmap ?: blurPreviewBitmap
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.clickable {
|
||||||
|
if (imageAttachment != null) {
|
||||||
|
onImageClick(imageAttachment.id, photoBoxBounds)
|
||||||
|
}
|
||||||
|
}
|
||||||
) {
|
) {
|
||||||
if (imageBitmap != null) {
|
if (imageBitmap != null) {
|
||||||
Image(
|
Image(
|
||||||
bitmap = imageBitmap!!.asImageBitmap(),
|
bitmap = imageBitmap!!.asImageBitmap(),
|
||||||
contentDescription = "Photo preview",
|
contentDescription = "Photo",
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
contentScale = ContentScale.Crop
|
contentScale = ContentScale.Crop
|
||||||
)
|
)
|
||||||
} else if (blurPreviewBitmap != null) {
|
} else if (blurPreviewBitmap != null) {
|
||||||
// Blurhash preview если картинка не загружена
|
|
||||||
Image(
|
Image(
|
||||||
bitmap =
|
bitmap = blurPreviewBitmap!!.asImageBitmap(),
|
||||||
blurPreviewBitmap!!.asImageBitmap(),
|
contentDescription = "Photo",
|
||||||
contentDescription = "Photo preview",
|
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
contentScale = ContentScale.Crop
|
contentScale = ContentScale.Crop
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
// Placeholder с иконкой только если нет blurhash
|
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
contentAlignment = Alignment.Center
|
contentAlignment = Alignment.Center
|
||||||
@@ -1298,11 +1397,8 @@ fun ReplyBubble(
|
|||||||
Icon(
|
Icon(
|
||||||
TablerIcons.Photo,
|
TablerIcons.Photo,
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
tint =
|
tint = Color.White.copy(alpha = 0.7f),
|
||||||
Color.White.copy(
|
modifier = Modifier.size(28.dp)
|
||||||
alpha = 0.7f
|
|
||||||
),
|
|
||||||
modifier = Modifier.size(20.dp)
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -395,7 +395,7 @@ fun ImageViewerScreen(
|
|||||||
}
|
}
|
||||||
),
|
),
|
||||||
pageSpacing = 30.dp, // Telegram: dp(30) между фото
|
pageSpacing = 30.dp, // Telegram: dp(30) между фото
|
||||||
key = { images[it].attachmentId },
|
key = { images.getOrNull(it)?.attachmentId ?: "page_$it" },
|
||||||
userScrollEnabled = !isAnimating, // Отключаем скролл во время анимации
|
userScrollEnabled = !isAnimating, // Отключаем скролл во время анимации
|
||||||
beyondBoundsPageCount = 1,
|
beyondBoundsPageCount = 1,
|
||||||
flingBehavior = PagerDefaults.flingBehavior(
|
flingBehavior = PagerDefaults.flingBehavior(
|
||||||
@@ -403,7 +403,7 @@ fun ImageViewerScreen(
|
|||||||
snapPositionalThreshold = 0.5f
|
snapPositionalThreshold = 0.5f
|
||||||
)
|
)
|
||||||
) { page ->
|
) { page ->
|
||||||
val image = images[page]
|
val image = images.getOrNull(page) ?: return@HorizontalPager
|
||||||
|
|
||||||
// 🎬 Telegram-style наслаивание: правая страница уменьшается и затемняется
|
// 🎬 Telegram-style наслаивание: правая страница уменьшается и затемняется
|
||||||
val pageOffset = (pagerState.currentPage - page) + pagerState.currentPageOffsetFraction
|
val pageOffset = (pagerState.currentPage - page) + pagerState.currentPageOffsetFraction
|
||||||
@@ -901,6 +901,10 @@ private suspend fun loadBitmapForViewerImage(
|
|||||||
privateKey: String
|
privateKey: String
|
||||||
): Bitmap? {
|
): Bitmap? {
|
||||||
return try {
|
return try {
|
||||||
|
// 0. Проверяем in-memory кэш (ReplyBubble / основной чат уже загрузили)
|
||||||
|
val cached = ImageBitmapCache.get("img_${image.attachmentId}")
|
||||||
|
if (cached != null) return cached
|
||||||
|
|
||||||
// 1. Если blob уже есть в сообщении
|
// 1. Если blob уже есть в сообщении
|
||||||
if (image.blob.isNotEmpty()) {
|
if (image.blob.isNotEmpty()) {
|
||||||
base64ToBitmapSafe(image.blob)?.let { return it }
|
base64ToBitmapSafe(image.blob)?.let { return it }
|
||||||
@@ -959,19 +963,6 @@ private fun base64ToBitmapSafe(base64String: String): Bitmap? {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Извлечение download tag из preview
|
|
||||||
*/
|
|
||||||
private fun getDownloadTag(preview: String): String {
|
|
||||||
return if (preview.contains("::")) {
|
|
||||||
preview.split("::").firstOrNull() ?: ""
|
|
||||||
} else if (isUUID(preview)) {
|
|
||||||
preview
|
|
||||||
} else {
|
|
||||||
""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Проверка является ли строка UUID
|
* Проверка является ли строка UUID
|
||||||
*/
|
*/
|
||||||
@@ -988,9 +979,10 @@ fun extractImagesFromMessages(
|
|||||||
opponentPublicKey: String,
|
opponentPublicKey: String,
|
||||||
opponentName: String
|
opponentName: String
|
||||||
): List<ViewableImage> {
|
): List<ViewableImage> {
|
||||||
|
val seenIds = mutableSetOf<String>()
|
||||||
return messages
|
return messages
|
||||||
.flatMap { message ->
|
.flatMap { message ->
|
||||||
message.attachments
|
val mainImages = message.attachments
|
||||||
.filter { it.type == AttachmentType.IMAGE }
|
.filter { it.type == AttachmentType.IMAGE }
|
||||||
.map { attachment ->
|
.map { attachment ->
|
||||||
ViewableImage(
|
ViewableImage(
|
||||||
@@ -1006,7 +998,31 @@ fun extractImagesFromMessages(
|
|||||||
caption = message.text
|
caption = message.text
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Also include images from reply/forward bubbles
|
||||||
|
val replyImages = message.replyData?.attachments
|
||||||
|
?.filter { it.type == AttachmentType.IMAGE }
|
||||||
|
?.map { attachment ->
|
||||||
|
val replySenderKey = message.replyData.senderPublicKey.ifEmpty {
|
||||||
|
if (message.replyData.isFromMe) currentPublicKey else opponentPublicKey
|
||||||
}
|
}
|
||||||
|
ViewableImage(
|
||||||
|
attachmentId = attachment.id,
|
||||||
|
preview = attachment.preview,
|
||||||
|
blob = attachment.blob,
|
||||||
|
chachaKey = message.chachaKey,
|
||||||
|
senderPublicKey = replySenderKey,
|
||||||
|
senderName = message.replyData.senderName,
|
||||||
|
timestamp = message.timestamp,
|
||||||
|
width = attachment.width,
|
||||||
|
height = attachment.height,
|
||||||
|
caption = message.replyData.text
|
||||||
|
)
|
||||||
|
} ?: emptyList()
|
||||||
|
|
||||||
|
mainImages + replyImages
|
||||||
|
}
|
||||||
|
.filter { seenIds.add(it.attachmentId) }
|
||||||
.sortedBy { it.timestamp }
|
.sortedBy { it.timestamp }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ data class ReplyData(
|
|||||||
val text: String,
|
val text: String,
|
||||||
val isFromMe: Boolean,
|
val isFromMe: Boolean,
|
||||||
val isForwarded: Boolean = false,
|
val isForwarded: Boolean = false,
|
||||||
|
val forwardedFromName: String = "", // Имя оригинального отправителя для атрибуции
|
||||||
val attachments: List<MessageAttachment> = emptyList(), // 🖼️ Для превью фото в reply
|
val attachments: List<MessageAttachment> = emptyList(), // 🖼️ Для превью фото в reply
|
||||||
val senderPublicKey: String = "", // Для расшифровки attachments в reply
|
val senderPublicKey: String = "", // Для расшифровки attachments в reply
|
||||||
val recipientPrivateKey: String = "" // Для расшифровки attachments в reply
|
val recipientPrivateKey: String = "" // Для расшифровки attachments в reply
|
||||||
|
|||||||
1
app/src/main/res/raw/no_requests.json
Normal file
1
app/src/main/res/raw/no_requests.json
Normal file
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user