feat: implement support for multiple forwarded messages in chat components
This commit is contained in:
@@ -979,8 +979,8 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
}
|
||||
|
||||
// Парсим attachments для поиска MESSAGES (цитата)
|
||||
// <EFBFBD> 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<ReplyData> = 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<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 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 {}
|
||||
}
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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<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 */
|
||||
@Composable
|
||||
fun MessageSkeletonList(isDarkTheme: Boolean, modifier: Modifier = Modifier) {
|
||||
|
||||
@@ -38,6 +38,7 @@ data class ChatMessage(
|
||||
val status: MessageStatus = MessageStatus.SENT,
|
||||
val showDateHeader: Boolean = false,
|
||||
val replyData: ReplyData? = null,
|
||||
val forwardedMessages: List<ReplyData> = emptyList(), // Multiple forwarded messages (desktop parity)
|
||||
val attachments: List<MessageAttachment> = emptyList(),
|
||||
val chachaKey: String = "" // Для расшифровки attachments
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user