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()
|
||||
|
||||
/** Данные цитируемого сообщения */
|
||||
data class ReplyData(
|
||||
val messageId: String,
|
||||
val senderName: String, // Имя отправителя цитируемого сообщения
|
||||
val text: String,
|
||||
val isFromMe: Boolean // Цитируемое сообщение от меня?
|
||||
)
|
||||
|
||||
/** Модель сообщения (Legacy - для совместимости) */
|
||||
data class ChatMessage(
|
||||
val id: String,
|
||||
@@ -126,7 +134,8 @@ data class ChatMessage(
|
||||
val isOutgoing: Boolean,
|
||||
val timestamp: Date,
|
||||
val status: MessageStatus = MessageStatus.SENT,
|
||||
val showDateHeader: Boolean = false // Показывать ли разделитель даты
|
||||
val showDateHeader: Boolean = false, // Показывать ли разделитель даты
|
||||
val replyData: ReplyData? = null // Данные цитируемого сообщения
|
||||
)
|
||||
|
||||
enum class MessageStatus {
|
||||
@@ -1434,6 +1443,16 @@ private fun MessageBubble(
|
||||
.padding(horizontal = 12.dp, vertical = 7.dp)
|
||||
) {
|
||||
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)
|
||||
Spacer(modifier = Modifier.height(2.dp))
|
||||
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 анимацией */
|
||||
@Composable
|
||||
private fun DateHeader(dateText: String, secondaryTextColor: Color) {
|
||||
|
||||
@@ -12,6 +12,8 @@ import com.rosetta.messenger.database.RosettaDatabase
|
||||
import com.rosetta.messenger.network.*
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.flow.*
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import java.util.UUID
|
||||
import java.util.Date
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
@@ -419,6 +421,9 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
* 🔥 Быстрая конвертация Entity -> ChatMessage
|
||||
*/
|
||||
private fun entityToChatMessage(entity: MessageEntity): ChatMessage {
|
||||
// Парсим attachments для поиска MESSAGES (цитата)
|
||||
val replyData = parseReplyFromAttachments(entity.attachments, entity.fromMe == 1)
|
||||
|
||||
return ChatMessage(
|
||||
id = entity.messageId,
|
||||
text = entity.plainMessage, // Уже расшифровано при сохранении
|
||||
@@ -430,10 +435,59 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
2 -> MessageStatus.SENT // Changed from ERROR to SENT
|
||||
3 -> MessageStatus.READ
|
||||
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