feat: enhance chat and requests screens with avatar handling, pinning, and user blocking functionalities
This commit is contained in:
@@ -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
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 Фото появляется в чате СРАЗУ,
|
||||
* конвертация и отправка происходят в фоне
|
||||
|
||||
@@ -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 эффектом */
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) }
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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(",")) {
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
1
app/src/main/res/raw/no_requests.json
Normal file
1
app/src/main/res/raw/no_requests.json
Normal file
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user