feat: enhance chat and requests screens with avatar handling, pinning, and user blocking functionalities

This commit is contained in:
2026-02-11 05:50:08 +05:00
parent a0ef378909
commit 8c8a651500
13 changed files with 1244 additions and 451 deletions

View File

@@ -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
) )
} }

View File

@@ -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 для конвертации

View File

@@ -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
} }

View File

@@ -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)
} }
}
) )
} }

View File

@@ -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 Фото появляется в чате СРАЗУ,
* конвертация и отправка происходят в фоне * конвертация и отправка происходят в фоне

View File

@@ -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 эффектом */

View File

@@ -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)
)
}
} }
} }
} }

View File

@@ -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) }
} }
) )
} }

View File

@@ -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(",")) {

View File

@@ -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)
) )
} }
} }

View File

@@ -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 }
} }

View File

@@ -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

File diff suppressed because one or more lines are too long