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

@@ -710,15 +710,22 @@ fun MainScreen(
isDarkTheme = isDarkTheme
) {
RequestsListScreen(
isDarkTheme = isDarkTheme,
chatsViewModel = chatsListViewModel,
onBack = { navStack = navStack.filterNot { it is Screen.Requests } },
onUserSelect = { selectedRequestUser ->
navStack =
navStack.filterNot {
it is Screen.Requests || it is Screen.ChatDetail
} + Screen.ChatDetail(selectedRequestUser)
isDarkTheme = isDarkTheme,
chatsViewModel = chatsListViewModel,
pinnedChats = pinnedChats,
onTogglePin = { opponentKey ->
mainScreenScope.launch {
prefsManager.togglePinChat(opponentKey)
}
},
onBack = { navStack = navStack.filterNot { it is Screen.Requests } },
onUserSelect = { selectedRequestUser ->
navStack =
navStack.filterNot {
it is Screen.ChatDetail || it is Screen.OtherProfile
} + Screen.ChatDetail(selectedRequestUser)
},
avatarRepository = avatarRepository
)
}

View File

@@ -811,16 +811,8 @@ object MessageCrypto {
val compressed = compressedBuffer.copyOf(compressedSize)
// 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!)
val factory = javax.crypto.SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256")
val spec = javax.crypto.spec.PBEKeySpec(
password.toCharArray(),
"rosetta".toByteArray(Charsets.UTF_8),
1000,
256
)
val secretKey = factory.generateSecret(spec)
val keyBytes = secretKey.encoded
// Используем generatePBKDF2Key() для совместимости с crypto-js (UTF-8 encoding)
val keyBytes = generatePBKDF2Key(password)
// Generate random IV (16 bytes)
val iv = ByteArray(16)
@@ -1030,28 +1022,17 @@ object MessageCrypto {
// Password from plainKeyAndNonce - use same JS-like UTF-8 conversion
val password = bytesToJsUtf8String(plainKeyAndNonce)
// PBKDF2 key derivation
// CRITICAL: Must use SHA1 to match Desktop crypto-js (not SHA256!)
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
// PBKDF2 key derivation — SHA256 (совместимо с crypto-js и encryptReplyBlob)
val keyBytes = generatePBKDF2Key(password)
// AES-CBC decryption
val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding")
val keySpec = SecretKeySpec(keyBytes, "AES")
val ivSpec = IvParameterSpec(iv)
cipher.init(Cipher.DECRYPT_MODE, keySpec, ivSpec)
val decompressed = cipher.doFinal(ciphertext)
// Decompress with inflate
val inflater = java.util.zip.Inflater()
inflater.setInput(decompressed)
@@ -1059,12 +1040,41 @@ object MessageCrypto {
val outputSize = inflater.inflate(outputBuffer)
inflater.end()
val plaintext = String(outputBuffer, 0, outputSize, Charsets.UTF_8)
plaintext
} catch (e: Exception) {
// Return as-is, might be plain JSON
encryptedBlob
// 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
encryptedBlob
}
}
}
}

View File

@@ -7,18 +7,18 @@ import kotlinx.coroutines.flow.asStateFlow
/**
* 📨 Менеджер для пересылки сообщений (Forward)
*
*
* Логика как в десктопе:
* 1. Пользователь выбирает сообщения в чате
* 2. Нажимает Forward
* 3. Открывается список чатов
* 4. Выбирает чат куда переслать
* 5. Переходит в выбранный чат с сообщениями в Reply панели (как Forward)
*
* 3. Открывается список чатов (мультивыбор)
* 4. Выбирает один или несколько чатов
* 5. Первый чат — навигация с reply панелью, остальные — прямая отправка
*
* Singleton для передачи данных между экранами
*/
object ForwardManager {
/**
* Сообщение для пересылки
*/
@@ -29,25 +29,31 @@ object ForwardManager {
val isOutgoing: Boolean,
val senderPublicKey: String, // publicKey отправителя сообщения
val originalChatPublicKey: String, // publicKey чата откуда пересылается
val senderName: String = "", // Имя отправителя для атрибуции
val attachments: List<MessageAttachment> = emptyList()
)
// Сообщения для пересылки
private val _forwardMessages = MutableStateFlow<List<ForwardMessage>>(emptyList())
val forwardMessages: StateFlow<List<ForwardMessage>> = _forwardMessages.asStateFlow()
// Флаг показа выбора чата
private val _showChatPicker = MutableStateFlow(false)
val showChatPicker: StateFlow<Boolean> = _showChatPicker.asStateFlow()
// Выбранный чат (publicKey собеседника)
private val _selectedChatPublicKey = MutableStateFlow<String?>(null)
val selectedChatPublicKey: StateFlow<String?> = _selectedChatPublicKey.asStateFlow()
// Выбранные чаты (publicKeys собеседников) — поддержка мультивыбора
private val _selectedChatPublicKeys = MutableStateFlow<List<String>>(emptyList())
val selectedChatPublicKeys: StateFlow<List<String>> = _selectedChatPublicKeys.asStateFlow()
// Обратная совместимость: первый выбранный чат
val selectedChatPublicKey: StateFlow<String?> get() = MutableStateFlow(
_selectedChatPublicKeys.value.firstOrNull()
)
// 🔥 Счётчик для триггера перезагрузки диалога при forward
private val _forwardTrigger = MutableStateFlow(0)
val forwardTrigger: StateFlow<Int> = _forwardTrigger.asStateFlow()
/**
* Установить сообщения для пересылки и показать выбор чата
*/
@@ -60,24 +66,31 @@ object ForwardManager {
_showChatPicker.value = true
}
}
/**
* Выбрать чат для пересылки
* Выбрать один чат для пересылки (обратная совместимость)
*/
fun selectChat(publicKey: String) {
_selectedChatPublicKey.value = publicKey
selectChats(listOf(publicKey))
}
/**
* Выбрать несколько чатов для пересылки
*/
fun selectChats(publicKeys: List<String>) {
_selectedChatPublicKeys.value = publicKeys
_showChatPicker.value = false
// 🔥 Увеличиваем триггер чтобы ChatDetailScreen перезагрузил диалог
_forwardTrigger.value++
}
/**
* Скрыть выбор чата (отмена)
*/
fun hideChatPicker() {
_showChatPicker.value = false
}
/**
* Получить сообщения и очистить состояние
* Вызывается при открытии выбранного чата
@@ -86,57 +99,64 @@ object ForwardManager {
val messages = _forwardMessages.value
return messages
}
/**
* Очистить все данные (после применения или отмены)
*/
fun clear() {
_forwardMessages.value = emptyList()
_showChatPicker.value = false
_selectedChatPublicKey.value = null
_selectedChatPublicKeys.value = emptyList()
}
/**
* Проверить есть ли сообщения для пересылки
*/
fun hasForwardMessages(): Boolean = _forwardMessages.value.isNotEmpty()
/**
* Проверить есть ли сообщения для конкретного чата
*/
fun hasForwardMessagesForChat(publicKey: String): Boolean {
val selectedKey = _selectedChatPublicKey.value
val selectedKeys = _selectedChatPublicKeys.value
val hasMessages = _forwardMessages.value.isNotEmpty()
return selectedKey == publicKey && hasMessages
return publicKey in selectedKeys && hasMessages
}
/**
* Установить выбранный чат и вернуть сообщения для него
* Комбинированный метод для атомарного получения данных
*/
fun getForwardMessagesForChat(publicKey: String): List<ForwardMessage> {
val selectedKey = _selectedChatPublicKey.value
return if (selectedKey == publicKey && _forwardMessages.value.isNotEmpty()) {
val selectedKeys = _selectedChatPublicKeys.value
return if (publicKey in selectedKeys && _forwardMessages.value.isNotEmpty()) {
_forwardMessages.value
} else {
emptyList()
}
}
/**
* Получить список дополнительных чатов (кроме основного, куда навигируемся)
*/
fun getAdditionalChatKeys(primaryKey: String): List<String> {
return _selectedChatPublicKeys.value.filter { it != primaryKey }
}
/**
* Атомарно получить forward-сообщения для конкретного чата и очистить pending state.
* Это повторяет desktop-подход "consume once" после перехода в целевой диалог.
*/
@Synchronized
fun consumeForwardMessagesForChat(publicKey: String): List<ForwardMessage> {
val selectedKey = _selectedChatPublicKey.value
val selectedKeys = _selectedChatPublicKeys.value
val pending = _forwardMessages.value
if (selectedKey != publicKey || pending.isEmpty()) {
if (publicKey !in selectedKeys || pending.isEmpty()) {
return emptyList()
}
_forwardMessages.value = emptyList()
_selectedChatPublicKey.value = null
_selectedChatPublicKeys.value = emptyList()
_showChatPicker.value = false
return pending
}

View File

@@ -189,6 +189,7 @@ fun ChatDetailScreen(
var showImageViewer by remember { mutableStateOf(false) }
var imageViewerInitialIndex by remember { mutableStateOf(0) }
var imageViewerSourceBounds by remember { mutableStateOf<ImageSourceBounds?>(null) }
var imageViewerImages by remember { mutableStateOf<List<ViewableImage>>(emptyList()) }
// 🎨 Управление статус баром
DisposableEffect(isDarkTheme, showImageViewer, window, view) {
@@ -1390,6 +1391,9 @@ fun ChatDetailScreen(
user.publicKey,
originalChatPublicKey =
user.publicKey,
senderName =
if (msg.isOutgoing) "You"
else user.title.ifEmpty { user.username.ifEmpty { "User" } },
attachments =
msg.attachments
.filter {
@@ -1936,6 +1940,7 @@ fun ChatDetailScreen(
bounds
->
// 📸 Открыть просмотрщик фото с shared element animation
// Фиксируем список на момент клика (защита от краша при новых сообщениях)
val allImages =
extractImagesFromMessages(
messages,
@@ -1946,6 +1951,8 @@ fun ChatDetailScreen(
"User"
}
)
imageViewerImages =
allImages
imageViewerInitialIndex =
findImageIndex(
allImages,
@@ -2010,21 +2017,15 @@ fun ChatDetailScreen(
} // Закрытие Box
// 📸 Image Viewer Overlay with Telegram-style shared element animation
if (showImageViewer) {
val allImages =
extractImagesFromMessages(
messages,
currentUserPublicKey,
user.publicKey,
user.title.ifEmpty { "User" }
)
if (showImageViewer && imageViewerImages.isNotEmpty()) {
ImageViewerScreen(
images = allImages,
images = imageViewerImages,
initialIndex = imageViewerInitialIndex,
privateKey = currentUserPrivateKey,
onDismiss = {
showImageViewer = false
imageViewerSourceBounds = null
imageViewerImages = emptyList()
onImageViewerChanged(false)
},
onClosingStart = {
@@ -2237,18 +2238,36 @@ fun ChatDetailScreen(
showForwardPicker = false
ForwardManager.clear()
},
onChatSelected = { selectedDialog ->
onChatsSelected = { selectedDialogs ->
showForwardPicker = false
ForwardManager.selectChat(selectedDialog.opponentKey)
val searchUser =
SearchUser(
title = selectedDialog.opponentTitle,
username = selectedDialog.opponentUsername,
publicKey = selectedDialog.opponentKey,
verified = selectedDialog.verified,
online = selectedDialog.isOnline
)
onNavigateToChat(searchUser)
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 =
SearchUser(
title = primaryDialog.opponentTitle,
username = primaryDialog.opponentUsername,
publicKey = primaryDialog.opponentKey,
verified = primaryDialog.verified,
online = primaryDialog.isOnline
)
onNavigateToChat(searchUser)
}
}
)
}

View File

@@ -165,6 +165,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
val timestamp: Long,
val isOutgoing: Boolean,
val publicKey: String = "", // publicKey отправителя цитируемого сообщения
val senderName: String = "", // Имя отправителя для атрибуции forward
val attachments: List<MessageAttachment> = emptyList() // Для показа превью
)
private val _replyMessages = MutableStateFlow<List<ReplyMessage>>(emptyList())
@@ -544,6 +545,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
timestamp = fm.timestamp,
isOutgoing = fm.isOutgoing,
publicKey = fm.senderPublicKey,
senderName = fm.senderName,
attachments = fm.attachments
)
}
@@ -1248,6 +1250,8 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
val replyText = replyMessage.optString("message", "")
val replyMessageIdFromJson = replyMessage.optString("message_id", "")
val replyTimestamp = replyMessage.optLong("timestamp", 0L)
val isForwarded = replyMessage.optBoolean("forwarded", false)
val senderNameFromJson = replyMessage.optString("senderName", "")
// 📸 Парсим attachments из JSON reply (как в Desktop)
val replyAttachmentsFromJson = mutableListOf<MessageAttachment>()
@@ -1343,6 +1347,11 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
},
text = replyText,
isFromMe = isReplyFromMe,
isForwarded = isForwarded,
forwardedFromName = if (isForwarded) senderNameFromJson.ifEmpty {
if (isReplyFromMe) "You"
else opponentTitle.ifEmpty { opponentUsername.ifEmpty { "User" } }
} else "",
attachments = originalAttachments,
senderPublicKey =
if (isReplyFromMe) myPublicKey ?: ""
@@ -1397,7 +1406,10 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
timestamp = msg.timestamp.time,
isOutgoing = msg.isOutgoing,
// Если сообщение от меня - мой 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
@@ -1419,7 +1431,6 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
attachments =
msg.attachments
.filter { it.type != AttachmentType.MESSAGES }
.map { it.copy(localUri = "") }
)
}
_isForwardMode.value = true
@@ -1504,9 +1515,10 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
if (replyMsgs.isNotEmpty()) {
val firstReply = replyMsgs.first()
// 🖼️ Получаем attachments из текущих сообщений для превью
// Fallback на firstReply.attachments для forward из другого чата
val replyAttachments =
_messages.value.find { it.id == firstReply.messageId }?.attachments
?: emptyList()
?: firstReply.attachments.filter { it.type != AttachmentType.MESSAGES }
ReplyData(
messageId = firstReply.messageId,
senderName =
@@ -1518,6 +1530,11 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
text = firstReply.text,
isFromMe = firstReply.isOutgoing,
isForwarded = isForward,
forwardedFromName =
if (isForward) firstReply.senderName.ifEmpty {
if (firstReply.isOutgoing) "You"
else opponentTitle.ifEmpty { opponentUsername.ifEmpty { "User" } }
} else "",
attachments = replyAttachments,
senderPublicKey =
if (firstReply.isOutgoing) myPublicKey ?: ""
@@ -1566,23 +1583,73 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
val messageAttachments = mutableListOf<MessageAttachment>()
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()) {
// Формируем JSON массив с цитируемыми сообщениями (как в Desktop)
val replyJsonArray = JSONArray()
replyMsgsToSend.forEach { msg ->
// Формируем attachments JSON (как в Desktop)
val attachmentsArray = JSONArray()
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(
JSONObject().apply {
put("id", att.id)
put("id", attId)
put("type", att.type.value)
put("preview", att.preview)
put("preview", attPreview)
put("width", att.width)
put("height", att.height)
// Для IMAGE/FILE - blob не включаем (слишком большой)
// Для MESSAGES - включаем blob
put(
"blob",
if (att.type == AttachmentType.MESSAGES) att.blob
@@ -1599,17 +1666,19 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
put("message", msg.text)
put("timestamp", msg.timestamp)
put("attachments", attachmentsArray)
if (isForwardToSend) {
put("forwarded", true)
put("senderName", msg.senderName)
}
}
replyJsonArray.put(replyJson)
}
val replyBlobPlaintext = replyJsonArray.toString()
// 🔥 Шифруем reply blob (для network transmission) с ChaCha ключом
val encryptedReplyBlob =
MessageCrypto.encryptReplyBlob(replyBlobPlaintext, plainKeyAndNonce)
// 🔥 Re-encrypt с приватным ключом для хранения в БД (как в Desktop Архиве)
replyBlobForDatabase =
CryptoManager.encryptWithPassword(replyBlobPlaintext, privateKey)
@@ -1667,6 +1736,8 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
put("id", att.id)
put("type", att.type.value)
put("preview", att.preview)
put("width", att.width)
put("height", att.height)
// Только для MESSAGES сохраняем blob (reply
// data небольшие)
// Для 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 Фото появляется в чате СРАЗУ,
* конвертация и отправка происходят в фоне

View File

@@ -290,6 +290,10 @@ fun ChatsListScreen(
// Реактивный set заблокированных пользователей из ViewModel (Room Flow)
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
/*
if (showDevConsole) {
@@ -515,15 +519,12 @@ fun ChatsListScreen(
) {
ModalNavigationDrawer(
drawerState = drawerState,
gesturesEnabled = true, // 🔥 Явно включаем свайп для открытия drawer
gesturesEnabled = !showRequestsScreen, // Disable drawer swipe when requests are open
drawerContent = {
ModalDrawerSheet(
drawerContainerColor = Color.Transparent,
windowInsets =
WindowInsets(
0
), // 🎨 Убираем системные отступы - drawer идет до
// верха
WindowInsets(0),
modifier = Modifier.width(300.dp)
) {
Column(
@@ -532,7 +533,7 @@ fun ChatsListScreen(
.background(drawerBackgroundColor)
) {
// ═══════════════════════════════════════════════════════════
// 🎨 DRAWER HEADER - Avatar and status
// 🎨 DRAWER HEADER
// ═══════════════════════════════════════════════════════════
val avatarColors =
getAvatarColor(
@@ -543,10 +544,6 @@ fun ChatsListScreen(
// Header с размытым фоном аватарки
Box(modifier = Modifier.fillMaxWidth()) {
// ═══════════════════════════════════════════════════════════
// 🎨 BLURRED AVATAR BACKGROUND (на всю
// область header)
// ═══════════════════════════════════════════════════════════
BlurredAvatarBackground(
publicKey = accountPublicKey,
avatarRepository = avatarRepository,
@@ -567,99 +564,106 @@ fun ChatsListScreen(
.statusBarsPadding()
.padding(
top = 16.dp,
start =
20.dp,
start = 20.dp,
end = 20.dp,
bottom =
20.dp
bottom = 20.dp
)
) {
// Avatar - используем AvatarImage
Box(
modifier =
Modifier.size(72.dp)
.clip(
CircleShape
)
.background(
Color.White
.copy(
alpha =
0.2f
)
)
.padding(
3.dp
),
contentAlignment =
Alignment.Center
// Avatar row with theme toggle
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.Top
) {
AvatarImage(
publicKey =
accountPublicKey,
avatarRepository =
avatarRepository,
size = 66.dp,
isDarkTheme =
isDarkTheme,
displayName =
accountName
.ifEmpty {
accountUsername
} // 🔥 Для инициалов
)
// Avatar
Box(
modifier =
Modifier.size(72.dp)
.clip(CircleShape)
.background(
Color.White
.copy(alpha = 0.2f)
)
.padding(3.dp),
contentAlignment =
Alignment.Center
) {
AvatarImage(
publicKey =
accountPublicKey,
avatarRepository =
avatarRepository,
size = 66.dp,
isDarkTheme =
isDarkTheme,
displayName =
accountName
.ifEmpty {
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(
modifier =
Modifier.height(
14.dp
)
Modifier.height(14.dp)
)
// Display name (above username)
// Display name
if (accountName.isNotEmpty()) {
Text(
text = accountName,
fontSize = 16.sp,
fontWeight =
FontWeight
.SemiBold,
FontWeight.SemiBold,
color =
if (isDarkTheme
)
if (isDarkTheme)
Color.White
else
Color.Black
)
}
// Username display (below name)
// Username
if (accountUsername.isNotEmpty()) {
Spacer(
modifier =
Modifier.height(
4.dp
)
Modifier.height(4.dp)
)
Text(
text =
"@$accountUsername",
fontSize = 13.sp,
color =
if (isDarkTheme
)
if (isDarkTheme)
Color.White
.copy(
alpha =
0.7f
)
.copy(alpha = 0.7f)
else
Color.Black
.copy(
alpha =
0.7f
)
.copy(alpha = 0.7f)
)
}
}
@@ -680,7 +684,7 @@ fun ChatsListScreen(
val menuIconColor =
textColor.copy(alpha = 0.6f)
// 👤 Profile Section
// 👤 Profile
DrawerMenuItemEnhanced(
icon = TablerIcons.User,
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(
icon = TablerIcons.Bookmark,
text = "Saved Messages",
@@ -705,9 +726,6 @@ fun ChatsListScreen(
onClick = {
scope.launch {
drawerState.close()
// Ждём завершения
// анимации закрытия
// drawer
kotlinx.coroutines
.delay(250)
onSavedMessagesClick()
@@ -717,7 +735,7 @@ fun ChatsListScreen(
DrawerDivider(isDarkTheme)
// ⚙️ Settings
// ⚙️ Settings
DrawerMenuItemEnhanced(
icon = TablerIcons.Settings,
text = "Settings",
@@ -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.fillMaxWidth()
.padding(
horizontal =
20.dp,
vertical =
12.dp
horizontal = 20.dp,
vertical = 12.dp
),
contentAlignment =
Alignment.CenterStart
@@ -795,13 +797,9 @@ fun ChatsListScreen(
fontSize = 12.sp,
color =
if (isDarkTheme)
Color(
0xFF666666
)
Color(0xFF666666)
else
Color(
0xFF999999
)
Color(0xFF999999)
)
}
@@ -827,11 +825,11 @@ fun ChatsListScreen(
) {
Icon(
TablerIcons
.ArrowLeft,
.ChevronLeft,
contentDescription =
"Back",
tint =
PrimaryBlue
Color.White
)
}
} else {
@@ -846,18 +844,31 @@ fun ChatsListScreen(
}
}
) {
Icon(
TablerIcons
.Menu2,
contentDescription =
"Menu",
tint =
textColor
.copy(
alpha =
0.6f
)
)
Box {
Icon(
TablerIcons
.Menu2,
contentDescription =
"Menu",
tint =
textColor
.copy(
alpha =
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
)
} else {
// Rosetta title
// with status
Row(
verticalAlignment =
Alignment
.CenterVertically
) {
// Rosetta title or Connecting animation
if (protocolState == ProtocolState.AUTHENTICATED) {
Text(
"Rosetta",
fontWeight =
@@ -890,48 +896,12 @@ fun ChatsListScreen(
color =
textColor
)
Spacer(
modifier =
Modifier.width(
8.dp
)
)
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
}
} else {
AnimatedDotsText(
baseText = "Connecting",
color = textColor,
fontSize = 20.sp,
fontWeight = FontWeight.Bold
)
}
}
@@ -1092,7 +1062,55 @@ fun ChatsListScreen(
label = "RequestsTransition"
) { 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(
requests = requests,
isDarkTheme = isDarkTheme,
@@ -1100,15 +1118,50 @@ fun ChatsListScreen(
showRequestsScreen = false
},
onRequestClick = { request ->
showRequestsScreen = false
val user =
chatsViewModel
.dialogToSearchUser(
request
)
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) {
// 🚀 Shimmer skeleton пока данные грузятся
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
@Composable
fun ChatItem(
@@ -2784,7 +2880,7 @@ fun RequestsSection(
Modifier
.defaultMinSize(minWidth = 22.dp, minHeight = 22.dp)
.clip(RoundedCornerShape(11.dp))
.background(iconBgColor),
.background(PrimaryBlue),
contentAlignment = Alignment.Center
) {
Text(
@@ -2809,7 +2905,7 @@ fun RequestsSection(
Modifier
.defaultMinSize(minWidth = 22.dp, minHeight = 22.dp)
.clip(RoundedCornerShape(11.dp))
.background(iconBgColor),
.background(PrimaryBlue),
contentAlignment = Alignment.Center
) {
Text(
@@ -2832,27 +2928,37 @@ fun RequestsScreen(
requests: List<DialogUiModel>,
isDarkTheme: Boolean,
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 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)) {
if (requests.isEmpty()) {
// Empty state
Box(
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
)
}
// Empty state with Lottie animation
EmptyRequestsState(isDarkTheme = isDarkTheme)
} else {
// Requests list
LazyColumn(modifier = Modifier.fillMaxSize()) {
@@ -2861,11 +2967,35 @@ fun RequestsScreen(
key = { it.opponentKey },
contentType = { "request" }
) { request ->
DialogItemContent(
val isBlocked = blockedUsers.contains(request.opponentKey)
val isPinned = pinnedChats.contains(request.opponentKey)
SwipeableDialogItem(
dialog = request,
isDarkTheme = isDarkTheme,
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(
@@ -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 эффектом */

View File

@@ -1,6 +1,7 @@
package com.rosetta.messenger.ui.chats
import android.view.HapticFeedbackConstants
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.*
import androidx.compose.foundation.background
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.filled.ArrowForward
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Check
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
@@ -33,11 +35,12 @@ import java.util.*
import kotlinx.coroutines.launch
/**
* 📨 BottomSheet для выбора чата при Forward сообщений
* 📨 BottomSheet для выбора чатов при Forward сообщений
*
* Логика как в десктопной версии:
* 1. Показывает список диалогов
* 2. При выборе диалога - переходит в чат с сообщениями в Reply панели
* Поддержка мультивыбора:
* 1. Показывает список диалогов с чекбоксами
* 2. Можно выбрать один или несколько чатов
* 3. Кнопка "Forward" внизу подтверждает выбор
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -47,9 +50,10 @@ fun ForwardChatPickerBottomSheet(
currentUserPublicKey: String,
avatarRepository: AvatarRepository? = null,
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 view = LocalView.current
@@ -61,6 +65,9 @@ fun ForwardChatPickerBottomSheet(
val forwardMessages by ForwardManager.forwardMessages.collectAsState()
val messagesCount = forwardMessages.size
// Мультивыбор чатов
var selectedChats by remember { mutableStateOf<Set<String>>(emptySet()) }
// 🔥 Haptic feedback при открытии
LaunchedEffect(Unit) {
view.performHapticFeedback(HapticFeedbackConstants.LONG_PRESS)
@@ -72,7 +79,7 @@ fun ForwardChatPickerBottomSheet(
val window = (view.context as? android.app.Activity)?.window
val originalStatusBarColor = window?.statusBarColor ?: 0
val scrimColor = android.graphics.Color.argb(153, 0, 0, 0) // 60% черный
// Плавное затемнение
val fadeInAnimator = android.animation.ValueAnimator.ofArgb(originalStatusBarColor, scrimColor).apply {
duration = 200
@@ -81,7 +88,7 @@ fun ForwardChatPickerBottomSheet(
}
}
fadeInAnimator.start()
onDispose {
// Плавное восстановление
val fadeOutAnimator = android.animation.ValueAnimator.ofArgb(scrimColor, originalStatusBarColor).apply {
@@ -109,7 +116,7 @@ fun ForwardChatPickerBottomSheet(
onDismissRequest = { dismissWithAnimation() },
sheetState = sheetState,
containerColor = backgroundColor,
scrimColor = Color.Black.copy(alpha = 0.6f), // 🔥 Более тёмный overlay - перекрывает status bar
scrimColor = Color.Black.copy(alpha = 0.6f),
dragHandle = {
// Кастомный handle
Column(
@@ -131,7 +138,7 @@ fun ForwardChatPickerBottomSheet(
}
},
shape = RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp),
modifier = Modifier.statusBarsPadding() // 🔥 Учитываем status bar
modifier = Modifier.statusBarsPadding()
) {
Column(modifier = Modifier.fillMaxWidth().navigationBarsPadding()) {
// Header
@@ -142,7 +149,6 @@ fun ForwardChatPickerBottomSheet(
) {
// Иконка и заголовок
Row(verticalAlignment = Alignment.CenterVertically) {
// 🔥 Красивая иконка Forward
Icon(
Icons.Filled.ArrowForward,
contentDescription = null,
@@ -206,18 +212,24 @@ fun ForwardChatPickerBottomSheet(
LazyColumn(
modifier =
Modifier.fillMaxWidth()
.heightIn(
min = 300.dp,
max = 400.dp
) // 🔥 Минимальная высота для лучшего UX
.weight(1f)
) {
items(dialogs, key = { it.opponentKey }) { dialog ->
val isSelected = dialog.opponentKey in selectedChats
ForwardDialogItem(
dialog = dialog,
isDarkTheme = isDarkTheme,
isSavedMessages = dialog.opponentKey == currentUserPublicKey,
isSelected = isSelected,
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
Spacer(modifier = Modifier.height(16.dp))
}
@@ -244,6 +295,7 @@ private fun ForwardDialogItem(
dialog: DialogUiModel,
isDarkTheme: Boolean,
isSavedMessages: Boolean = false,
isSelected: Boolean = false,
avatarRepository: AvatarRepository? = null,
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(
modifier =
Modifier.fillMaxWidth()
.background(selectedBg)
.clickable(onClick = onClick)
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically
) {
// Avatar with real image support
if (isSavedMessages) {
// Saved Messages - special icon
val avatarColors = remember(dialog.opponentKey, isDarkTheme) {
getAvatarColor(dialog.opponentKey, isDarkTheme)
}
Box(
modifier = Modifier
.size(48.dp)
@@ -324,7 +379,7 @@ private fun ForwardDialogItem(
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f, fill = false)
)
// ✅ Verified badge
if (!isSavedMessages && dialog.verified > 0) {
VerifiedBadge(
@@ -347,7 +402,7 @@ private fun ForwardDialogItem(
dialog.lastMessage.isNotEmpty() -> dialog.lastMessage
else -> "No messages"
}
AppleEmojiText(
text = previewText,
fontSize = 14.sp,
@@ -357,9 +412,27 @@ private fun ForwardDialogItem(
)
}
// Online indicator
if (!isSavedMessages && dialog.isOnline == 1) {
Box(modifier = Modifier.size(10.dp).clip(CircleShape).background(Color(0xFF34C759)))
// Чекбокс выбора (справа)
Spacer(modifier = Modifier.width(8.dp))
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.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
import com.rosetta.messenger.network.SearchUser
import com.rosetta.messenger.repository.AvatarRepository
import com.rosetta.messenger.ui.onboarding.PrimaryBlue
import compose.icons.TablerIcons
import compose.icons.tablericons.ArrowLeft
import compose.icons.tablericons.ChevronLeft
import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun RequestsListScreen(
isDarkTheme: Boolean,
chatsViewModel: ChatsListViewModel,
pinnedChats: Set<String>,
onTogglePin: (String) -> Unit,
onBack: () -> Unit,
onUserSelect: (SearchUser) -> Unit
onUserSelect: (SearchUser) -> Unit,
avatarRepository: AvatarRepository? = null
) {
val chatsState by chatsViewModel.chatsState.collectAsState()
val requests = chatsState.requests
val blockedUsers by chatsViewModel.blockedUsers.collectAsState()
val scope = rememberCoroutineScope()
val backgroundColor = if (isDarkTheme) Color(0xFF1A1A1A) else Color(0xFFF2F2F7)
val textColor = if (isDarkTheme) Color.White else Color.Black
@@ -42,9 +50,9 @@ fun RequestsListScreen(
navigationIcon = {
IconButton(onClick = onBack) {
Icon(
imageVector = TablerIcons.ArrowLeft,
imageVector = TablerIcons.ChevronLeft,
contentDescription = "Back",
tint = PrimaryBlue
tint = Color.White
)
}
},
@@ -79,6 +87,21 @@ fun RequestsListScreen(
onBack = onBack,
onRequestClick = { 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.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.ExperimentalFoundationApi
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.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
@@ -204,6 +207,8 @@ fun MessageAttachments(
currentUserPublicKey: String = "",
hasCaption: Boolean = false, // Если есть caption - время показывается под фото, не на фото
showTail: Boolean = true, // Показывать хвостик пузырька
isSelectionMode: Boolean = false, // Блокировать клик на фото в selection mode
onLongClick: () -> Unit = {}, // Long press на фото — запускает selection mode
onImageClick: (attachmentId: String, bounds: ImageSourceBounds?) -> Unit = { _, _ -> },
modifier: Modifier = Modifier
) {
@@ -230,6 +235,8 @@ fun MessageAttachments(
messageStatus = messageStatus,
hasCaption = hasCaption,
showTail = showTail,
isSelectionMode = isSelectionMode,
onLongClick = onLongClick,
onImageClick = onImageClick
)
}
@@ -290,6 +297,8 @@ fun ImageCollage(
messageStatus: MessageStatus = MessageStatus.READ,
hasCaption: Boolean = false, // Если есть caption - время показывается под фото
showTail: Boolean = true, // Показывать хвостик пузырька
isSelectionMode: Boolean = false, // Блокировать клик на фото в selection mode
onLongClick: () -> Unit = {}, // Long press на фото — запускает selection mode
onImageClick: (attachmentId: String, bounds: ImageSourceBounds?) -> Unit = { _, _ -> },
modifier: Modifier = Modifier
) {
@@ -335,6 +344,8 @@ fun ImageCollage(
messageStatus = messageStatus,
showTimeOverlay = showOverlayOnLast,
hasCaption = hasCaption,
isSelectionMode = isSelectionMode,
onLongClick = onLongClick,
onImageClick = onImageClick
)
}
@@ -357,6 +368,8 @@ fun ImageCollage(
messageStatus = messageStatus,
showTimeOverlay = showOverlayOnLast && index == count - 1,
aspectRatio = 1f,
isSelectionMode = isSelectionMode,
onLongClick = onLongClick,
onImageClick = onImageClick
)
}
@@ -382,6 +395,8 @@ fun ImageCollage(
messageStatus = messageStatus,
showTimeOverlay = false,
fillMaxSize = true,
isSelectionMode = isSelectionMode,
onLongClick = onLongClick,
onImageClick = onImageClick
)
}
@@ -402,6 +417,8 @@ fun ImageCollage(
messageStatus = messageStatus,
showTimeOverlay = false,
fillMaxSize = true,
isSelectionMode = isSelectionMode,
onLongClick = onLongClick,
onImageClick = onImageClick
)
}
@@ -417,6 +434,8 @@ fun ImageCollage(
messageStatus = messageStatus,
showTimeOverlay = showOverlayOnLast,
fillMaxSize = true,
isSelectionMode = isSelectionMode,
onLongClick = onLongClick,
onImageClick = onImageClick
)
}
@@ -445,6 +464,8 @@ fun ImageCollage(
messageStatus = messageStatus,
showTimeOverlay = false,
fillMaxSize = true,
isSelectionMode = isSelectionMode,
onLongClick = onLongClick,
onImageClick = onImageClick
)
}
@@ -460,6 +481,8 @@ fun ImageCollage(
messageStatus = messageStatus,
showTimeOverlay = false,
fillMaxSize = true,
isSelectionMode = isSelectionMode,
onLongClick = onLongClick,
onImageClick = onImageClick
)
}
@@ -480,6 +503,8 @@ fun ImageCollage(
messageStatus = messageStatus,
showTimeOverlay = false,
fillMaxSize = true,
isSelectionMode = isSelectionMode,
onLongClick = onLongClick,
onImageClick = onImageClick
)
}
@@ -495,6 +520,8 @@ fun ImageCollage(
messageStatus = messageStatus,
showTimeOverlay = showOverlayOnLast,
fillMaxSize = true,
isSelectionMode = isSelectionMode,
onLongClick = onLongClick,
onImageClick = onImageClick
)
}
@@ -524,6 +551,8 @@ fun ImageCollage(
messageStatus = messageStatus,
showTimeOverlay = false,
fillMaxSize = true,
isSelectionMode = isSelectionMode,
onLongClick = onLongClick,
onImageClick = onImageClick
)
}
@@ -539,6 +568,8 @@ fun ImageCollage(
messageStatus = messageStatus,
showTimeOverlay = false,
fillMaxSize = true,
isSelectionMode = isSelectionMode,
onLongClick = onLongClick,
onImageClick = onImageClick
)
}
@@ -565,6 +596,8 @@ fun ImageCollage(
messageStatus = messageStatus,
showTimeOverlay = showOverlayOnLast && isLastItem,
fillMaxSize = true,
isSelectionMode = isSelectionMode,
onLongClick = onLongClick,
onImageClick = onImageClick
)
}
@@ -588,6 +621,7 @@ fun ImageCollage(
* 3. Если файл есть локально → DOWNLOADED (загружаем из файла)
* 4. Иначе → NOT_DOWNLOADED (нужно скачать с CDN)
*/
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun ImageAttachment(
attachment: MessageAttachment,
@@ -602,6 +636,8 @@ fun ImageAttachment(
aspectRatio: Float? = null,
fillMaxSize: Boolean = false,
hasCaption: Boolean = false,
isSelectionMode: Boolean = false,
onLongClick: () -> Unit = {},
onImageClick: (attachmentId: String, bounds: ImageSourceBounds?) -> Unit = { _, _ -> }
) {
val context = LocalContext.current
@@ -964,27 +1000,36 @@ fun ImageAttachment(
// Capture bounds for shared element transition
imageBounds = coordinates.boundsInWindow()
}
.clickable {
when (downloadStatus) {
DownloadStatus.NOT_DOWNLOADED -> download()
DownloadStatus.DOWNLOADED -> {
// 📸 Open image viewer with bounds for animation
val bounds = imageBounds?.let {
ImageSourceBounds(
left = it.left,
top = it.top,
width = it.width,
height = it.height,
cornerRadius = cornerRadius,
thumbnailBitmap = imageBitmap
)
.combinedClickable(
indication = null,
interactionSource = remember { MutableInteractionSource() },
onLongClick = onLongClick,
onClick = {
if (!isSelectionMode) {
when (downloadStatus) {
DownloadStatus.NOT_DOWNLOADED -> download()
DownloadStatus.DOWNLOADED -> {
// 📸 Open image viewer with bounds for animation
val bounds = imageBounds?.let {
ImageSourceBounds(
left = it.left,
top = it.top,
width = it.width,
height = it.height,
cornerRadius = cornerRadius,
thumbnailBitmap = imageBitmap
)
}
onImageClick(attachment.id, bounds)
}
DownloadStatus.ERROR -> download()
else -> {}
}
} else {
onLongClick()
}
onImageClick(attachment.id, bounds)
}
DownloadStatus.ERROR -> download()
else -> {}
}
},
),
contentAlignment = Alignment.Center
) {
// Фоновый слой - blurhash или placeholder
@@ -1884,13 +1929,13 @@ private val uuidRegex =
* Проверка является ли preview UUID тегом для скачивания Как в desktop:
* 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
return uuidRegex.matches(firstPart)
}
/** Получить download tag из preview */
private fun getDownloadTag(preview: String): String {
internal fun getDownloadTag(preview: String): String {
val parts = preview.split("::")
if (parts.isNotEmpty() && uuidRegex.matches(parts[0])) {
return parts[0]
@@ -1899,7 +1944,7 @@ private fun getDownloadTag(preview: String): String {
}
/** Получить 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("::")
if (parts.isNotEmpty() && uuidRegex.matches(parts[0])) {
return parts.drop(1).joinToString("::")
@@ -1931,7 +1976,7 @@ private fun parseFilePreview(preview: String): Pair<Long, String> {
}
/** Декодирование base64 в Bitmap */
private fun base64ToBitmap(base64: String): Bitmap? {
internal fun base64ToBitmap(base64: String): Bitmap? {
return try {
val cleanBase64 =
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.layout.ContentScale
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.LocalDensity
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.components.AppleEmojiText
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.vanniktech.blurhash.BlurHash
import compose.icons.TablerIcons
@@ -640,7 +644,10 @@ fun MessageBubble(
replyData = reply,
isOutgoing = message.isOutgoing,
isDarkTheme = isDarkTheme,
onClick = { onReplyClick(reply.messageId) }
chachaKey = message.chachaKey,
privateKey = privateKey,
onClick = { onReplyClick(reply.messageId) },
onImageClick = onImageClick
)
Spacer(modifier = Modifier.height(4.dp))
}
@@ -664,7 +671,10 @@ fun MessageBubble(
hasCaption = hasImageWithCaption,
showTail = showTail, // Передаём для формы
// пузырька
onImageClick = onImageClick
isSelectionMode = isSelectionMode,
onLongClick = onLongClick,
// В selection mode блокируем открытие фото
onImageClick = if (isSelectionMode) { _, _ -> } else onImageClick
)
}
@@ -1067,7 +1077,10 @@ fun ReplyBubble(
replyData: ReplyData,
isOutgoing: 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 backgroundColor =
@@ -1095,13 +1108,11 @@ fun ReplyBubble(
// Blurhash preview для fallback
var blurPreviewBitmap by remember { mutableStateOf<Bitmap?>(null) }
// Сначала загружаем blurhash preview
// Сначала загружаем blurhash preview (более высокое разрешение для чёткости)
LaunchedEffect(imageAttachment?.preview) {
if (imageAttachment != null && imageAttachment.preview.isNotEmpty()) {
withContext(Dispatchers.IO) {
try {
// Получаем blurhash из preview (может быть в формате
// "tag::blurhash")
val blurhash =
if (imageAttachment.preview.contains("::")) {
imageAttachment.preview.substringAfter("::")
@@ -1115,57 +1126,49 @@ fun ReplyBubble(
}
if (blurhash.isNotEmpty()) {
blurPreviewBitmap =
BlurHash.decode(blurhash, 36, 36)
BlurHash.decode(blurhash, 64, 64)
}
} catch (e: Exception) {
// Ignore blurhash decode errors
}
} catch (e: Exception) {}
}
}
}
// Потом пробуем загрузить полноценную картинку
// Загружаем полноценную картинку
LaunchedEffect(imageAttachment?.id) {
if (imageAttachment != null) {
// 1. Проверяем in-memory кэш (фото уже загружено в чате)
val cached = ImageBitmapCache.get("img_${imageAttachment.id}")
if (cached != null) {
imageBitmap = cached
return@LaunchedEffect
}
withContext(Dispatchers.IO) {
try {
// Пробуем сначала из blob
if (imageAttachment.blob.isNotEmpty()) {
val decoded =
try {
val cleanBase64 =
if (imageAttachment.blob
.contains(
","
)
) {
imageAttachment.blob
.substringAfter(
","
)
} else {
imageAttachment.blob
}
val decodedBytes =
Base64.decode(
cleanBase64,
Base64.DEFAULT
)
BitmapFactory.decodeByteArray(
decodedBytes,
0,
decodedBytes.size
)
} catch (e: Exception) {
null
// 2. Загружаем из localUri (для недавно отправленных/полученных фото)
if (imageAttachment.localUri.isNotEmpty()) {
try {
val uri = android.net.Uri.parse(imageAttachment.localUri)
context.contentResolver.openInputStream(uri)?.use { stream ->
val bitmap = BitmapFactory.decodeStream(stream)
if (bitmap != null) {
imageBitmap = bitmap
return@withContext
}
}
} catch (_: Exception) {}
}
// 3. Пробуем из blob
if (imageAttachment.blob.isNotEmpty()) {
val decoded = base64ToBitmap(imageAttachment.blob)
if (decoded != null) {
imageBitmap = decoded
return@withContext
}
}
// Если blob нет - загружаем из локального файла
// 4. Загружаем из AttachmentFileManager
val localBlob =
AttachmentFileManager.readAttachment(
context,
@@ -1173,37 +1176,84 @@ fun ReplyBubble(
replyData.senderPublicKey,
replyData.recipientPrivateKey
)
if (localBlob != null) {
val decoded =
try {
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
imageBitmap = base64ToBitmap(localBlob)
if (imageBitmap != null) return@withContext
}
} 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))
// Контент reply
Row(
Column(
modifier =
Modifier.weight(1f)
.padding(
start = 8.dp,
end = if (hasImage) 4.dp else 10.dp,
end = 10.dp,
top = 4.dp,
bottom = 4.dp
),
verticalAlignment = Alignment.CenterVertically
)
) {
// Текстовая часть
Column(modifier = Modifier.weight(1f)) {
// Заголовок (имя отправителя / Forwarded from)
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 =
if (replyData.isForwarded) "Forwarded message"
@@ -1242,18 +1309,23 @@ fun ReplyBubble(
maxLines = 1,
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(
text = displayText,
color = replyTextColor,
@@ -1264,33 +1336,60 @@ fun ReplyBubble(
)
}
// 🖼️ Превью изображения справа (как в Telegram)
// 🖼️ Большое фото внизу пузырька
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(
modifier =
Modifier.size(36.dp)
.clip(RoundedCornerShape(4.dp))
.background(Color.Gray.copy(alpha = 0.3f))
Modifier.fillMaxWidth()
.aspectRatio(aspectRatio.coerceIn(0.5f, 2.5f))
.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) {
Image(
bitmap = imageBitmap!!.asImageBitmap(),
contentDescription = "Photo preview",
contentDescription = "Photo",
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop
)
} else if (blurPreviewBitmap != null) {
// Blurhash preview если картинка не загружена
Image(
bitmap =
blurPreviewBitmap!!.asImageBitmap(),
contentDescription = "Photo preview",
bitmap = blurPreviewBitmap!!.asImageBitmap(),
contentDescription = "Photo",
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop
)
} else {
// Placeholder с иконкой только если нет blurhash
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
@@ -1298,11 +1397,8 @@ fun ReplyBubble(
Icon(
TablerIcons.Photo,
contentDescription = null,
tint =
Color.White.copy(
alpha = 0.7f
),
modifier = Modifier.size(20.dp)
tint = Color.White.copy(alpha = 0.7f),
modifier = Modifier.size(28.dp)
)
}
}

View File

@@ -395,7 +395,7 @@ fun ImageViewerScreen(
}
),
pageSpacing = 30.dp, // Telegram: dp(30) между фото
key = { images[it].attachmentId },
key = { images.getOrNull(it)?.attachmentId ?: "page_$it" },
userScrollEnabled = !isAnimating, // Отключаем скролл во время анимации
beyondBoundsPageCount = 1,
flingBehavior = PagerDefaults.flingBehavior(
@@ -403,7 +403,7 @@ fun ImageViewerScreen(
snapPositionalThreshold = 0.5f
)
) { page ->
val image = images[page]
val image = images.getOrNull(page) ?: return@HorizontalPager
// 🎬 Telegram-style наслаивание: правая страница уменьшается и затемняется
val pageOffset = (pagerState.currentPage - page) + pagerState.currentPageOffsetFraction
@@ -901,6 +901,10 @@ private suspend fun loadBitmapForViewerImage(
privateKey: String
): Bitmap? {
return try {
// 0. Проверяем in-memory кэш (ReplyBubble / основной чат уже загрузили)
val cached = ImageBitmapCache.get("img_${image.attachmentId}")
if (cached != null) return cached
// 1. Если blob уже есть в сообщении
if (image.blob.isNotEmpty()) {
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
*/
@@ -988,9 +979,10 @@ fun extractImagesFromMessages(
opponentPublicKey: String,
opponentName: String
): List<ViewableImage> {
val seenIds = mutableSetOf<String>()
return messages
.flatMap { message ->
message.attachments
val mainImages = message.attachments
.filter { it.type == AttachmentType.IMAGE }
.map { attachment ->
ViewableImage(
@@ -1006,7 +998,31 @@ fun extractImagesFromMessages(
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 }
}

View File

@@ -23,6 +23,7 @@ data class ReplyData(
val text: String,
val isFromMe: Boolean,
val isForwarded: Boolean = false,
val forwardedFromName: String = "", // Имя оригинального отправителя для атрибуции
val attachments: List<MessageAttachment> = emptyList(), // 🖼️ Для превью фото в reply
val senderPublicKey: String = "", // Для расшифровки attachments в reply
val recipientPrivateKey: String = "" // Для расшифровки attachments в reply

File diff suppressed because one or more lines are too long