feat: Add reply functionality with message quoting in ChatViewModel and ChatDetailScreen
This commit is contained in:
@@ -119,6 +119,14 @@ private val TelegramSendIcon: ImageVector
|
|||||||
}
|
}
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
|
/** Данные цитируемого сообщения */
|
||||||
|
data class ReplyData(
|
||||||
|
val messageId: String,
|
||||||
|
val senderName: String, // Имя отправителя цитируемого сообщения
|
||||||
|
val text: String,
|
||||||
|
val isFromMe: Boolean // Цитируемое сообщение от меня?
|
||||||
|
)
|
||||||
|
|
||||||
/** Модель сообщения (Legacy - для совместимости) */
|
/** Модель сообщения (Legacy - для совместимости) */
|
||||||
data class ChatMessage(
|
data class ChatMessage(
|
||||||
val id: String,
|
val id: String,
|
||||||
@@ -126,7 +134,8 @@ data class ChatMessage(
|
|||||||
val isOutgoing: Boolean,
|
val isOutgoing: Boolean,
|
||||||
val timestamp: Date,
|
val timestamp: Date,
|
||||||
val status: MessageStatus = MessageStatus.SENT,
|
val status: MessageStatus = MessageStatus.SENT,
|
||||||
val showDateHeader: Boolean = false // Показывать ли разделитель даты
|
val showDateHeader: Boolean = false, // Показывать ли разделитель даты
|
||||||
|
val replyData: ReplyData? = null // Данные цитируемого сообщения
|
||||||
)
|
)
|
||||||
|
|
||||||
enum class MessageStatus {
|
enum class MessageStatus {
|
||||||
@@ -1434,6 +1443,16 @@ private fun MessageBubble(
|
|||||||
.padding(horizontal = 12.dp, vertical = 7.dp)
|
.padding(horizontal = 12.dp, vertical = 7.dp)
|
||||||
) {
|
) {
|
||||||
Column {
|
Column {
|
||||||
|
// 🔥 Reply bubble (цитата) - как в React Native
|
||||||
|
message.replyData?.let { reply ->
|
||||||
|
ReplyBubble(
|
||||||
|
replyData = reply,
|
||||||
|
isOutgoing = message.isOutgoing,
|
||||||
|
isDarkTheme = isDarkTheme
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(6.dp))
|
||||||
|
}
|
||||||
|
|
||||||
AppleEmojiText(text = message.text, color = textColor, fontSize = 16.sp)
|
AppleEmojiText(text = message.text, color = textColor, fontSize = 16.sp)
|
||||||
Spacer(modifier = Modifier.height(2.dp))
|
Spacer(modifier = Modifier.height(2.dp))
|
||||||
Row(
|
Row(
|
||||||
@@ -1517,6 +1536,83 @@ private fun AnimatedMessageStatus(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 🔥 Reply bubble (цитата) внутри сообщения - как в React Native
|
||||||
|
* Стиль: вертикальная линия слева + имя + текст
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun ReplyBubble(
|
||||||
|
replyData: ReplyData,
|
||||||
|
isOutgoing: Boolean,
|
||||||
|
isDarkTheme: Boolean
|
||||||
|
) {
|
||||||
|
// Цвета как в React Native
|
||||||
|
val backgroundColor = if (isOutgoing) {
|
||||||
|
Color.Black.copy(alpha = 0.15f)
|
||||||
|
} else {
|
||||||
|
Color.Black.copy(alpha = 0.08f)
|
||||||
|
}
|
||||||
|
|
||||||
|
val borderColor = if (isOutgoing) {
|
||||||
|
Color.White
|
||||||
|
} else {
|
||||||
|
PrimaryBlue
|
||||||
|
}
|
||||||
|
|
||||||
|
val nameColor = if (isOutgoing) {
|
||||||
|
Color.White
|
||||||
|
} else {
|
||||||
|
PrimaryBlue
|
||||||
|
}
|
||||||
|
|
||||||
|
val textColor = if (isOutgoing) {
|
||||||
|
Color.White.copy(alpha = 0.85f)
|
||||||
|
} else {
|
||||||
|
if (isDarkTheme) Color.White else Color.Black
|
||||||
|
}
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(IntrinsicSize.Min)
|
||||||
|
.clip(RoundedCornerShape(4.dp))
|
||||||
|
.background(backgroundColor)
|
||||||
|
) {
|
||||||
|
// Вертикальная линия слева (как borderLeft в React Native)
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.width(3.dp)
|
||||||
|
.fillMaxHeight()
|
||||||
|
.background(borderColor)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Контент
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(start = 10.dp, end = 10.dp, top = 6.dp, bottom = 6.dp)
|
||||||
|
) {
|
||||||
|
// Имя отправителя цитируемого сообщения
|
||||||
|
Text(
|
||||||
|
text = replyData.senderName,
|
||||||
|
color = nameColor,
|
||||||
|
fontSize = 14.sp,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
|
)
|
||||||
|
|
||||||
|
// Текст цитируемого сообщения
|
||||||
|
Text(
|
||||||
|
text = replyData.text.ifEmpty { "..." },
|
||||||
|
color = textColor,
|
||||||
|
fontSize = 14.sp,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** 🚀 Разделитель даты с fade-in анимацией */
|
/** 🚀 Разделитель даты с fade-in анимацией */
|
||||||
@Composable
|
@Composable
|
||||||
private fun DateHeader(dateText: String, secondaryTextColor: Color) {
|
private fun DateHeader(dateText: String, secondaryTextColor: Color) {
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ import com.rosetta.messenger.database.RosettaDatabase
|
|||||||
import com.rosetta.messenger.network.*
|
import com.rosetta.messenger.network.*
|
||||||
import kotlinx.coroutines.*
|
import kotlinx.coroutines.*
|
||||||
import kotlinx.coroutines.flow.*
|
import kotlinx.coroutines.flow.*
|
||||||
|
import org.json.JSONArray
|
||||||
|
import org.json.JSONObject
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
import java.util.Date
|
import java.util.Date
|
||||||
import java.util.concurrent.ConcurrentHashMap
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
@@ -419,6 +421,9 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
* 🔥 Быстрая конвертация Entity -> ChatMessage
|
* 🔥 Быстрая конвертация Entity -> ChatMessage
|
||||||
*/
|
*/
|
||||||
private fun entityToChatMessage(entity: MessageEntity): ChatMessage {
|
private fun entityToChatMessage(entity: MessageEntity): ChatMessage {
|
||||||
|
// Парсим attachments для поиска MESSAGES (цитата)
|
||||||
|
val replyData = parseReplyFromAttachments(entity.attachments, entity.fromMe == 1)
|
||||||
|
|
||||||
return ChatMessage(
|
return ChatMessage(
|
||||||
id = entity.messageId,
|
id = entity.messageId,
|
||||||
text = entity.plainMessage, // Уже расшифровано при сохранении
|
text = entity.plainMessage, // Уже расшифровано при сохранении
|
||||||
@@ -430,10 +435,59 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
2 -> MessageStatus.SENT // Changed from ERROR to SENT
|
2 -> MessageStatus.SENT // Changed from ERROR to SENT
|
||||||
3 -> MessageStatus.READ
|
3 -> MessageStatus.READ
|
||||||
else -> MessageStatus.SENT
|
else -> MessageStatus.SENT
|
||||||
}
|
},
|
||||||
|
replyData = replyData
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Парсинг MESSAGES attachment для извлечения данных цитаты
|
||||||
|
* Формат: [{"message_id": "...", "publicKey": "...", "message": "..."}]
|
||||||
|
*/
|
||||||
|
private fun parseReplyFromAttachments(attachmentsJson: String, isFromMe: Boolean): ReplyData? {
|
||||||
|
if (attachmentsJson.isEmpty() || attachmentsJson == "[]") return null
|
||||||
|
|
||||||
|
return try {
|
||||||
|
val attachments = JSONArray(attachmentsJson)
|
||||||
|
for (i in 0 until attachments.length()) {
|
||||||
|
val attachment = attachments.getJSONObject(i)
|
||||||
|
val type = attachment.optInt("type", 0)
|
||||||
|
|
||||||
|
// MESSAGES = 1 (цитата)
|
||||||
|
if (type == 1) {
|
||||||
|
// Данные могут быть в blob или preview
|
||||||
|
val dataJson = attachment.optString("blob", "").ifEmpty {
|
||||||
|
attachment.optString("preview", "")
|
||||||
|
}
|
||||||
|
if (dataJson.isEmpty()) continue
|
||||||
|
|
||||||
|
val messagesArray = JSONArray(dataJson)
|
||||||
|
if (messagesArray.length() > 0) {
|
||||||
|
val replyMessage = messagesArray.getJSONObject(0)
|
||||||
|
val replyPublicKey = replyMessage.optString("publicKey", "")
|
||||||
|
val replyText = replyMessage.optString("message", "")
|
||||||
|
val replyMessageId = replyMessage.optString("message_id", "")
|
||||||
|
|
||||||
|
// Определяем, кто автор цитируемого сообщения
|
||||||
|
// Если publicKey == myPublicKey - цитата от меня
|
||||||
|
val isReplyFromMe = replyPublicKey == myPublicKey
|
||||||
|
|
||||||
|
return ReplyData(
|
||||||
|
messageId = replyMessageId,
|
||||||
|
senderName = if (isReplyFromMe) "You" else opponentTitle.ifEmpty { opponentUsername.ifEmpty { "User" } },
|
||||||
|
text = replyText,
|
||||||
|
isFromMe = isReplyFromMe
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
null
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Error parsing reply from attachments", e)
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Получить ключ диалога для группировки сообщений
|
* Получить ключ диалога для группировки сообщений
|
||||||
*/
|
*/
|
||||||
|
|||||||
Reference in New Issue
Block a user