feat: implement support for multiple forwarded messages in chat components

This commit is contained in:
k1ngsterr1
2026-02-11 22:26:11 +05:00
parent 0749dea03d
commit 6f195f4d09
4 changed files with 334 additions and 11 deletions

View File

@@ -979,8 +979,8 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
} }
// Парсим attachments для поиска MESSAGES (цитата) // Парсим attachments для поиска MESSAGES (цитата)
// <EFBFBD> P2.3: Передаём plainKeyAndNonce чтобы избежать повторного ECDH // 🚀 P2.3: Передаём plainKeyAndNonce чтобы избежать повторного ECDH
var replyData = val parsedReplyResult =
parseReplyFromAttachments( parseReplyFromAttachments(
attachmentsJson = entity.attachments, attachmentsJson = entity.attachments,
isFromMe = entity.fromMe == 1, isFromMe = entity.fromMe == 1,
@@ -988,6 +988,8 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
chachaKey = entity.chachaKey, chachaKey = entity.chachaKey,
plainKeyAndNonce = plainKeyAndNonce plainKeyAndNonce = plainKeyAndNonce
) )
var replyData = parsedReplyResult?.replyData
val forwardedMessages = parsedReplyResult?.forwardedMessages ?: emptyList()
// Если не нашли reply в attachments, пробуем распарсить из текста // Если не нашли reply в attachments, пробуем распарсить из текста
if (replyData == null) { if (replyData == null) {
@@ -1014,7 +1016,8 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
3 -> MessageStatus.READ 3 -> MessageStatus.READ
else -> MessageStatus.SENT else -> MessageStatus.SENT
}, },
replyData = replyData, replyData = if (forwardedMessages.isNotEmpty()) null else replyData,
forwardedMessages = forwardedMessages,
attachments = parsedAttachments, attachments = parsedAttachments,
chachaKey = entity.chachaKey chachaKey = entity.chachaKey
) )
@@ -1118,6 +1121,12 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
* "publicKey": "...", "message": "..."}] 🔥 ВАЖНО: Если blob зашифрован (формат * "publicKey": "...", "message": "..."}] 🔥 ВАЖНО: Если blob зашифрован (формат
* "iv:ciphertext"), расшифровываем его * "iv:ciphertext"), расшифровываем его
*/ */
/** Result of parsing reply/forward attachments */
data class ParsedReplyResult(
val replyData: ReplyData? = null,
val forwardedMessages: List<ReplyData> = emptyList()
)
private suspend fun parseReplyFromAttachments( private suspend fun parseReplyFromAttachments(
attachmentsJson: String, attachmentsJson: String,
isFromMe: Boolean, isFromMe: Boolean,
@@ -1125,7 +1134,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
chachaKey: String, chachaKey: String,
plainKeyAndNonce: ByteArray? = plainKeyAndNonce: ByteArray? =
null // 🚀 P2.3: Переиспользуем ключ из основной расшифровки null // 🚀 P2.3: Переиспользуем ключ из основной расшифровки
): ReplyData? { ): ParsedReplyResult? {
if (attachmentsJson.isEmpty() || attachmentsJson == "[]") { if (attachmentsJson.isEmpty() || attachmentsJson == "[]") {
return null return null
@@ -1245,6 +1254,73 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
} }
if (messagesArray.length() > 0) { 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<ReplyData>()
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<MessageAttachment>()
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 replyMessage = messagesArray.getJSONObject(0)
val replyPublicKey = replyMessage.optString("publicKey", "") val replyPublicKey = replyMessage.optString("publicKey", "")
val replyText = replyMessage.optString("message", "") val replyText = replyMessage.optString("message", "")
@@ -1286,8 +1362,6 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
// 🔥 ВАЖНО: message_id из JSON может не совпадать с messageId в Android БД! // 🔥 ВАЖНО: message_id из JSON может не совпадать с messageId в Android БД!
// Пытаемся найти реальный messageId в текущих сообщениях по тексту и // Пытаемся найти реальный messageId в текущих сообщениях по тексту и
// timestamp // timestamp
val account = myPublicKey ?: return null
val dialogKey = getDialogKey(account, opponentKey ?: "")
val realMessageId = val realMessageId =
try { try {
// Ищем сообщение в БД по публичному ключу, тексту и timestamp // Ищем сообщение в БД по публичному ключу, тексту и timestamp
@@ -1358,7 +1432,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
else opponentKey ?: "", else opponentKey ?: "",
recipientPrivateKey = myPrivateKey ?: "" recipientPrivateKey = myPrivateKey ?: ""
) )
return result return ParsedReplyResult(replyData = result)
} else {} } else {}
} else {} } else {}
} }

View File

@@ -944,9 +944,9 @@ fun ChatsListScreen(
colors = colors =
TopAppBarDefaults.topAppBarColors( TopAppBarDefaults.topAppBarColors(
containerColor = containerColor =
Color(0xFF0D8CF4), if (isDarkTheme) Color(0xFF043359) else Color(0xFF0D8CF4),
scrolledContainerColor = scrolledContainerColor =
Color(0xFF0D8CF4), if (isDarkTheme) Color(0xFF043359) else Color(0xFF0D8CF4),
navigationIconContentColor = navigationIconContentColor =
Color.White, Color.White,
titleContentColor = titleContentColor =

View File

@@ -477,6 +477,7 @@ fun MessageBubble(
message.attachments.isNotEmpty() && message.attachments.isNotEmpty() &&
message.text.isEmpty() && message.text.isEmpty() &&
message.replyData == null && message.replyData == null &&
message.forwardedMessages.isEmpty() &&
message.attachments.all { message.attachments.all {
it.type == it.type ==
com.rosetta.messenger.network.AttachmentType com.rosetta.messenger.network.AttachmentType
@@ -488,6 +489,7 @@ fun MessageBubble(
message.attachments.isNotEmpty() && message.attachments.isNotEmpty() &&
message.text.isNotEmpty() && message.text.isNotEmpty() &&
message.replyData == null && message.replyData == null &&
message.forwardedMessages.isEmpty() &&
message.attachments.all { message.attachments.all {
it.type == it.type ==
com.rosetta.messenger.network.AttachmentType com.rosetta.messenger.network.AttachmentType
@@ -639,6 +641,19 @@ fun MessageBubble(
.padding(bubblePadding) .padding(bubblePadding)
) { ) {
Column { 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 -> message.replyData?.let { reply ->
ReplyBubble( ReplyBubble(
replyData = reply, replyData = reply,
@@ -793,8 +808,8 @@ fun MessageBubble(
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
} }
// Если есть reply - Telegram-style layout // Если есть reply/forward - Telegram-style layout
if (message.replyData != null && message.text.isNotEmpty() if ((message.replyData != null || message.forwardedMessages.isNotEmpty()) && message.text.isNotEmpty()
) { ) {
TelegramStyleMessageContent( TelegramStyleMessageContent(
textContent = { textContent = {
@@ -1408,6 +1423,239 @@ fun ReplyBubble(
} }
} }
/** Forwarded messages bubble — renders ALL forwarded messages like Desktop (Telegram-style) */
@Composable
fun ForwardedMessagesBubble(
forwardedMessages: List<ReplyData>,
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<Bitmap?>(null) }
var blurPreviewBitmap by remember { mutableStateOf<Bitmap?>(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<ImageSourceBounds?>(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 */ /** Message skeleton loader with shimmer animation */
@Composable @Composable
fun MessageSkeletonList(isDarkTheme: Boolean, modifier: Modifier = Modifier) { fun MessageSkeletonList(isDarkTheme: Boolean, modifier: Modifier = Modifier) {

View File

@@ -38,6 +38,7 @@ data class ChatMessage(
val status: MessageStatus = MessageStatus.SENT, val status: MessageStatus = MessageStatus.SENT,
val showDateHeader: Boolean = false, val showDateHeader: Boolean = false,
val replyData: ReplyData? = null, val replyData: ReplyData? = null,
val forwardedMessages: List<ReplyData> = emptyList(), // Multiple forwarded messages (desktop parity)
val attachments: List<MessageAttachment> = emptyList(), val attachments: List<MessageAttachment> = emptyList(),
val chachaKey: String = "" // Для расшифровки attachments val chachaKey: String = "" // Для расшифровки attachments
) )