diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatViewModel.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatViewModel.kt index 549792e..5785c87 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatViewModel.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatViewModel.kt @@ -979,8 +979,8 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { } // Парсим attachments для поиска MESSAGES (цитата) - // � P2.3: Передаём plainKeyAndNonce чтобы избежать повторного ECDH - var replyData = + // 🚀 P2.3: Передаём plainKeyAndNonce чтобы избежать повторного ECDH + val parsedReplyResult = parseReplyFromAttachments( attachmentsJson = entity.attachments, isFromMe = entity.fromMe == 1, @@ -988,6 +988,8 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { chachaKey = entity.chachaKey, plainKeyAndNonce = plainKeyAndNonce ) + var replyData = parsedReplyResult?.replyData + val forwardedMessages = parsedReplyResult?.forwardedMessages ?: emptyList() // Если не нашли reply в attachments, пробуем распарсить из текста if (replyData == null) { @@ -1014,7 +1016,8 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { 3 -> MessageStatus.READ else -> MessageStatus.SENT }, - replyData = replyData, + replyData = if (forwardedMessages.isNotEmpty()) null else replyData, + forwardedMessages = forwardedMessages, attachments = parsedAttachments, chachaKey = entity.chachaKey ) @@ -1118,6 +1121,12 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { * "publicKey": "...", "message": "..."}] 🔥 ВАЖНО: Если blob зашифрован (формат * "iv:ciphertext"), расшифровываем его */ + /** Result of parsing reply/forward attachments */ + data class ParsedReplyResult( + val replyData: ReplyData? = null, + val forwardedMessages: List = emptyList() + ) + private suspend fun parseReplyFromAttachments( attachmentsJson: String, isFromMe: Boolean, @@ -1125,7 +1134,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { chachaKey: String, plainKeyAndNonce: ByteArray? = null // 🚀 P2.3: Переиспользуем ключ из основной расшифровки - ): ReplyData? { + ): ParsedReplyResult? { if (attachmentsJson.isEmpty() || attachmentsJson == "[]") { return null @@ -1245,6 +1254,73 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { } if (messagesArray.length() > 0) { + val account = myPublicKey ?: return null + val dialogKey = getDialogKey(account, opponentKey ?: "") + + // Check if this is a forwarded set or a regular reply + // Desktop doesn't set "forwarded" flag, but sends multiple messages in the array + val firstMsg = messagesArray.getJSONObject(0) + val isForwardedSet = firstMsg.optBoolean("forwarded", false) || messagesArray.length() > 1 + + if (isForwardedSet) { + // 🔥 Parse ALL forwarded messages (desktop parity) + val forwardedList = mutableListOf() + for (idx in 0 until messagesArray.length()) { + val fwdMsg = messagesArray.getJSONObject(idx) + val fwdPublicKey = fwdMsg.optString("publicKey", "") + val fwdText = fwdMsg.optString("message", "") + val fwdMessageId = fwdMsg.optString("message_id", "") + val fwdTimestamp = fwdMsg.optLong("timestamp", 0L) + val fwdSenderName = fwdMsg.optString("senderName", "") + val fwdIsFromMe = fwdPublicKey == myPublicKey + + // Parse attachments from this forwarded message + val fwdAttachments = mutableListOf() + try { + val attArray = fwdMsg.optJSONArray("attachments") + if (attArray != null) { + for (j in 0 until attArray.length()) { + val attJson = attArray.getJSONObject(j) + val attId = attJson.optString("id", "") + val attType = AttachmentType.fromInt(attJson.optInt("type", 0)) + val attPreview = attJson.optString("preview", "") + val attBlob = attJson.optString("blob", "") + val attWidth = attJson.optInt("width", 0) + val attHeight = attJson.optInt("height", 0) + if (attId.isNotEmpty()) { + fwdAttachments.add(MessageAttachment( + id = attId, type = attType, preview = attPreview, + blob = attBlob, width = attWidth, height = attHeight + )) + } + } + } + } catch (_: Exception) {} + + val senderDisplayName = fwdSenderName.ifEmpty { + if (fwdIsFromMe) "You" + else opponentTitle.ifEmpty { opponentUsername.ifEmpty { "User" } } + } + + forwardedList.add(ReplyData( + messageId = fwdMessageId, + senderName = senderDisplayName, + text = fwdText, + isFromMe = fwdIsFromMe, + isForwarded = true, + forwardedFromName = senderDisplayName, + attachments = fwdAttachments, + senderPublicKey = if (fwdIsFromMe) myPublicKey ?: "" else opponentKey ?: "", + recipientPrivateKey = myPrivateKey ?: "" + )) + } + return ParsedReplyResult( + replyData = forwardedList.firstOrNull(), + forwardedMessages = forwardedList + ) + } + + // Regular reply (not forwarded) — use first message only val replyMessage = messagesArray.getJSONObject(0) val replyPublicKey = replyMessage.optString("publicKey", "") val replyText = replyMessage.optString("message", "") @@ -1286,8 +1362,6 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { // 🔥 ВАЖНО: message_id из JSON может не совпадать с messageId в Android БД! // Пытаемся найти реальный messageId в текущих сообщениях по тексту и // timestamp - val account = myPublicKey ?: return null - val dialogKey = getDialogKey(account, opponentKey ?: "") val realMessageId = try { // Ищем сообщение в БД по публичному ключу, тексту и timestamp @@ -1358,7 +1432,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { else opponentKey ?: "", recipientPrivateKey = myPrivateKey ?: "" ) - return result + return ParsedReplyResult(replyData = result) } else {} } else {} } diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListScreen.kt index 6cb9b5e..a989641 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListScreen.kt @@ -944,9 +944,9 @@ fun ChatsListScreen( colors = TopAppBarDefaults.topAppBarColors( containerColor = - Color(0xFF0D8CF4), + if (isDarkTheme) Color(0xFF043359) else Color(0xFF0D8CF4), scrolledContainerColor = - Color(0xFF0D8CF4), + if (isDarkTheme) Color(0xFF043359) else Color(0xFF0D8CF4), navigationIconContentColor = Color.White, titleContentColor = diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/components/ChatDetailComponents.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/components/ChatDetailComponents.kt index 49b8cfd..efbb909 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/components/ChatDetailComponents.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/components/ChatDetailComponents.kt @@ -477,6 +477,7 @@ fun MessageBubble( message.attachments.isNotEmpty() && message.text.isEmpty() && message.replyData == null && + message.forwardedMessages.isEmpty() && message.attachments.all { it.type == com.rosetta.messenger.network.AttachmentType @@ -488,6 +489,7 @@ fun MessageBubble( message.attachments.isNotEmpty() && message.text.isNotEmpty() && message.replyData == null && + message.forwardedMessages.isEmpty() && message.attachments.all { it.type == com.rosetta.messenger.network.AttachmentType @@ -639,6 +641,19 @@ fun MessageBubble( .padding(bubblePadding) ) { Column { + // 🔥 Forwarded messages (multiple, desktop parity) + if (message.forwardedMessages.isNotEmpty()) { + ForwardedMessagesBubble( + forwardedMessages = message.forwardedMessages, + isOutgoing = message.isOutgoing, + isDarkTheme = isDarkTheme, + chachaKey = message.chachaKey, + privateKey = privateKey, + onImageClick = onImageClick + ) + Spacer(modifier = Modifier.height(4.dp)) + } + message.replyData?.let { reply -> ReplyBubble( replyData = reply, @@ -793,8 +808,8 @@ fun MessageBubble( Spacer(modifier = Modifier.height(8.dp)) } - // Если есть reply - Telegram-style layout - if (message.replyData != null && message.text.isNotEmpty() + // Если есть reply/forward - Telegram-style layout + if ((message.replyData != null || message.forwardedMessages.isNotEmpty()) && message.text.isNotEmpty() ) { TelegramStyleMessageContent( textContent = { @@ -1408,6 +1423,239 @@ fun ReplyBubble( } } +/** Forwarded messages bubble — renders ALL forwarded messages like Desktop (Telegram-style) */ +@Composable +fun ForwardedMessagesBubble( + forwardedMessages: List, + isOutgoing: Boolean, + isDarkTheme: Boolean, + chachaKey: String = "", + privateKey: String = "", + onImageClick: (attachmentId: String, bounds: ImageSourceBounds?) -> Unit = { _, _ -> } +) { + val backgroundColor = + if (isOutgoing) Color.Black.copy(alpha = 0.1f) + else Color.Black.copy(alpha = 0.05f) + val borderColor = if (isOutgoing) Color.White.copy(alpha = 0.6f) else PrimaryBlue + + // Color palette for different senders + val senderColors = listOf( + Color(0xFF3390EC), // blue + Color(0xFF4CAF50), // green + Color(0xFFE65100), // orange + Color(0xFF9C27B0), // purple + Color(0xFFE53935), // red + Color(0xFF00ACC1), // cyan + ) + + // Map sender names to consistent colors + val senderColorMap = remember(forwardedMessages) { + val uniqueSenders = forwardedMessages.map { it.forwardedFromName }.distinct() + uniqueSenders.mapIndexed { index, name -> + name to senderColors[index % senderColors.size] + }.toMap() + } + + Column( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(4.dp)) + .background(backgroundColor) + ) { + Row(modifier = Modifier.fillMaxWidth().height(IntrinsicSize.Min)) { + // Single vertical border line for entire block + Box(modifier = Modifier.width(3.dp).fillMaxHeight().background(borderColor)) + + Column( + modifier = Modifier + .weight(1f) + .padding(start = 8.dp, end = 8.dp, top = 4.dp, bottom = 4.dp) + ) { + forwardedMessages.forEachIndexed { index, fwd -> + if (index > 0) { + Spacer(modifier = Modifier.height(6.dp)) + Divider( + color = if (isDarkTheme) Color.White.copy(alpha = 0.1f) + else Color.Black.copy(alpha = 0.08f), + thickness = 0.5.dp + ) + Spacer(modifier = Modifier.height(6.dp)) + } + + val nameColor = if (isOutgoing) { + Color.White + } else { + senderColorMap[fwd.forwardedFromName] ?: PrimaryBlue + } + + // "Forwarded from [Name]" + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = "Forwarded from ", + color = nameColor.copy(alpha = 0.7f), + fontSize = 13.sp, + fontWeight = FontWeight.Normal, + maxLines = 1 + ) + Text( + text = fwd.forwardedFromName.ifEmpty { "User" }, + color = nameColor, + fontSize = 13.sp, + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f, fill = false) + ) + } + + // Message text + if (fwd.text.isNotEmpty()) { + Spacer(modifier = Modifier.height(2.dp)) + val textColor = if (isOutgoing) Color.White.copy(alpha = 0.9f) + else if (isDarkTheme) Color.White else Color.Black + AppleEmojiText( + text = fwd.text, + color = textColor, + fontSize = 14.sp, + maxLines = 50, + overflow = android.text.TextUtils.TruncateAt.END, + enableLinks = false + ) + } + + // Attachments (images) + val imageAttachments = fwd.attachments.filter { it.type == AttachmentType.IMAGE } + if (imageAttachments.isNotEmpty()) { + Spacer(modifier = Modifier.height(4.dp)) + imageAttachments.forEach { imgAtt -> + ForwardedImagePreview( + attachment = imgAtt, + senderPublicKey = fwd.senderPublicKey, + recipientPrivateKey = fwd.recipientPrivateKey, + chachaKey = chachaKey, + privateKey = privateKey, + onImageClick = onImageClick + ) + } + } + } + } + } + } +} + +/** Image preview inside a forwarded message */ +@Composable +private fun ForwardedImagePreview( + attachment: MessageAttachment, + senderPublicKey: String, + recipientPrivateKey: String, + chachaKey: String, + privateKey: String, + onImageClick: (attachmentId: String, bounds: ImageSourceBounds?) -> Unit +) { + val context = androidx.compose.ui.platform.LocalContext.current + var imageBitmap by remember { mutableStateOf(null) } + var blurPreviewBitmap by remember { mutableStateOf(null) } + + // Load blur preview first + LaunchedEffect(attachment.id) { + if (attachment.preview.isNotEmpty()) { + try { + val bitmap = BlurHash.decode(attachment.preview, 32, 32) + if (bitmap != null) blurPreviewBitmap = bitmap + } catch (_: Exception) {} + } + } + + // Decrypt and load full image + LaunchedEffect(attachment.id) { + withContext(Dispatchers.IO) { + try { + // Try loading from file cache first + val cached = AttachmentFileManager.readAttachment( + context, attachment.id, senderPublicKey, recipientPrivateKey + ) + if (cached != null) { + val bytes = Base64.decode(cached, Base64.DEFAULT) + BitmapFactory.decodeByteArray(bytes, 0, bytes.size)?.let { + imageBitmap = it + return@withContext + } + } + + // Try decrypting from blob + if (attachment.blob.isNotEmpty()) { + val keyToUse = chachaKey.ifEmpty { recipientPrivateKey } + val privKey = privateKey.ifEmpty { recipientPrivateKey } + val decrypted = MessageCrypto.decryptAttachmentBlob( + attachment.blob, keyToUse, privKey + ) + if (decrypted != null) { + val bytes = Base64.decode(decrypted, Base64.DEFAULT) + BitmapFactory.decodeByteArray(bytes, 0, bytes.size)?.let { + imageBitmap = it + ImageBitmapCache.put("img_${attachment.id}", it) + AttachmentFileManager.saveAttachment( + context, decrypted, attachment.id, + senderPublicKey, recipientPrivateKey + ) + } + } + } + } catch (_: Exception) {} + } + } + + val imgWidth = attachment.width.takeIf { it > 0 } ?: 300 + val imgHeight = attachment.height.takeIf { it > 0 } ?: 200 + val aspectRatio = (imgWidth.toFloat() / imgHeight.toFloat()).coerceIn(0.5f, 2.5f) + + var photoBoxBounds by remember { mutableStateOf(null) } + + Box( + modifier = Modifier + .fillMaxWidth() + .aspectRatio(aspectRatio) + .heightIn(max = 200.dp) + .clip(RoundedCornerShape(6.dp)) + .background(Color.Gray.copy(alpha = 0.2f)) + .onGloballyPositioned { coords -> + val bounds = coords.boundsInWindow() + photoBoxBounds = ImageSourceBounds( + left = bounds.left, top = bounds.top, + width = bounds.width, height = bounds.height + ) + } + .clickable { onImageClick(attachment.id, photoBoxBounds) } + ) { + if (imageBitmap != null) { + Image( + bitmap = imageBitmap!!.asImageBitmap(), + contentDescription = "Photo", + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Crop + ) + } else if (blurPreviewBitmap != null) { + Image( + bitmap = blurPreviewBitmap!!.asImageBitmap(), + contentDescription = "Photo", + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Crop + ) + } else { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Icon( + TablerIcons.Photo, + contentDescription = null, + tint = Color.White.copy(alpha = 0.7f), + modifier = Modifier.size(28.dp) + ) + } + } + } +} + /** Message skeleton loader with shimmer animation */ @Composable fun MessageSkeletonList(isDarkTheme: Boolean, modifier: Modifier = Modifier) { diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/models/ChatDetailModels.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/models/ChatDetailModels.kt index 6f68079..8264404 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/models/ChatDetailModels.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/models/ChatDetailModels.kt @@ -38,6 +38,7 @@ data class ChatMessage( val status: MessageStatus = MessageStatus.SENT, val showDateHeader: Boolean = false, val replyData: ReplyData? = null, + val forwardedMessages: List = emptyList(), // Multiple forwarded messages (desktop parity) val attachments: List = emptyList(), val chachaKey: String = "" // Для расшифровки attachments )