feat: Add support for image attachments in replies; implement preview functionality similar to Telegram
This commit is contained in:
@@ -267,7 +267,8 @@ fun ChatDetailScreen(
|
||||
text = chatMsg.text,
|
||||
timestamp = chatMsg.timestamp.time,
|
||||
isOutgoing = chatMsg.isOutgoing,
|
||||
publicKey = if (chatMsg.isOutgoing) currentUserPublicKey else user.publicKey
|
||||
publicKey = if (chatMsg.isOutgoing) currentUserPublicKey else user.publicKey,
|
||||
attachments = chatMsg.attachments
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -111,7 +111,8 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
val text: String,
|
||||
val timestamp: Long,
|
||||
val isOutgoing: Boolean,
|
||||
val publicKey: String = "" // publicKey отправителя цитируемого сообщения
|
||||
val publicKey: String = "", // publicKey отправителя цитируемого сообщения
|
||||
val attachments: List<MessageAttachment> = emptyList() // Для показа превью
|
||||
)
|
||||
private val _replyMessages = MutableStateFlow<List<ReplyMessage>>(emptyList())
|
||||
val replyMessages: StateFlow<List<ReplyMessage>> = _replyMessages.asStateFlow()
|
||||
@@ -976,11 +977,30 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
// Определяем, кто автор цитируемого сообщения
|
||||
val isReplyFromMe = replyPublicKey == myPublicKey
|
||||
|
||||
// 🖼️ Загружаем attachments оригинального сообщения для превью в reply
|
||||
val originalAttachments = try {
|
||||
val originalMessage = messageDao.findMessageByContent(
|
||||
account = account,
|
||||
dialogKey = dialogKey,
|
||||
fromPublicKey = replyPublicKey,
|
||||
timestampFrom = replyTimestamp - 5000,
|
||||
timestampTo = replyTimestamp + 5000
|
||||
)
|
||||
if (originalMessage != null && originalMessage.attachments.isNotEmpty()) {
|
||||
parseAllAttachments(originalMessage.attachments)
|
||||
} else {
|
||||
emptyList()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
emptyList()
|
||||
}
|
||||
|
||||
val result = ReplyData(
|
||||
messageId = realMessageId,
|
||||
senderName = if (isReplyFromMe) "You" else opponentTitle.ifEmpty { opponentUsername.ifEmpty { "User" } },
|
||||
text = replyText,
|
||||
isFromMe = isReplyFromMe
|
||||
isFromMe = isReplyFromMe,
|
||||
attachments = originalAttachments
|
||||
)
|
||||
return result
|
||||
} else {
|
||||
@@ -1138,12 +1158,15 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
// Работает и для reply, и для forward
|
||||
val replyData: ReplyData? = if (replyMsgs.isNotEmpty()) {
|
||||
val firstReply = replyMsgs.first()
|
||||
// 🖼️ Получаем attachments из текущих сообщений для превью
|
||||
val replyAttachments = _messages.value.find { it.id == firstReply.messageId }?.attachments ?: emptyList()
|
||||
ReplyData(
|
||||
messageId = firstReply.messageId,
|
||||
senderName = if (firstReply.isOutgoing) "You" else opponentTitle.ifEmpty { opponentUsername.ifEmpty { "User" } },
|
||||
text = firstReply.text,
|
||||
isFromMe = firstReply.isOutgoing,
|
||||
isForwarded = isForward
|
||||
isForwarded = isForward,
|
||||
attachments = replyAttachments
|
||||
)
|
||||
} else null
|
||||
|
||||
@@ -2070,6 +2093,42 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
|
||||
fun canSend(): Boolean = _inputText.value.isNotBlank() && !isSending
|
||||
|
||||
/**
|
||||
* 🗑️ Очистка истории чата
|
||||
* Удаляет все сообщения между текущим пользователем и собеседником
|
||||
*/
|
||||
fun clearChatHistory() {
|
||||
val account = myPublicKey ?: return
|
||||
val opponent = opponentKey ?: return
|
||||
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
// Удаляем все сообщения из БД
|
||||
messageDao.deleteMessagesBetweenUsers(account, account, opponent)
|
||||
|
||||
// Очищаем кэш
|
||||
val dialogKey = getDialogKey(account, opponent)
|
||||
dialogMessagesCache.remove(dialogKey)
|
||||
|
||||
// Очищаем UI
|
||||
withContext(Dispatchers.Main) {
|
||||
_messages.value = emptyList()
|
||||
}
|
||||
|
||||
// Обновляем диалог (последнее сообщение и счётчики)
|
||||
val isSavedMessages = (opponent == account)
|
||||
if (isSavedMessages) {
|
||||
dialogDao.updateSavedMessagesDialogFromMessages(account)
|
||||
} else {
|
||||
dialogDao.updateDialogFromMessages(account, opponent)
|
||||
}
|
||||
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.e(TAG, "Failed to clear chat history", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
super.onCleared()
|
||||
lastReadMessageTimestamp = 0L
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
package com.rosetta.messenger.ui.chats.components
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import androidx.compose.animation.*
|
||||
import androidx.compose.animation.core.*
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
@@ -24,9 +26,11 @@ import androidx.compose.ui.draw.scale
|
||||
import androidx.compose.ui.draw.shadow
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.TransformOrigin
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
@@ -35,11 +39,16 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.window.Popup
|
||||
import androidx.compose.ui.window.PopupProperties
|
||||
import com.rosetta.messenger.network.AttachmentType
|
||||
import com.rosetta.messenger.network.MessageAttachment
|
||||
import com.rosetta.messenger.ui.components.AppleEmojiText
|
||||
import com.rosetta.messenger.ui.onboarding.PrimaryBlue
|
||||
import com.rosetta.messenger.ui.chats.models.*
|
||||
import com.rosetta.messenger.ui.chats.utils.*
|
||||
import com.vanniktech.blurhash.BlurHash
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.*
|
||||
|
||||
@@ -574,7 +583,7 @@ fun AnimatedMessageStatus(
|
||||
}
|
||||
}
|
||||
|
||||
/** Reply bubble inside message */
|
||||
/** Reply bubble inside message - Telegram style with image preview */
|
||||
@Composable
|
||||
fun ReplyBubble(
|
||||
replyData: ReplyData,
|
||||
@@ -595,6 +604,33 @@ fun ReplyBubble(
|
||||
} else {
|
||||
if (isDarkTheme) Color.White else Color.Black
|
||||
}
|
||||
|
||||
// 🖼️ Проверяем есть ли изображение в attachments
|
||||
val imageAttachment = replyData.attachments.firstOrNull { it.type == AttachmentType.IMAGE }
|
||||
val hasImage = imageAttachment != null
|
||||
|
||||
// Декодируем blurhash для превью
|
||||
var previewBitmap by remember { mutableStateOf<Bitmap?>(null) }
|
||||
|
||||
LaunchedEffect(imageAttachment?.preview) {
|
||||
if (imageAttachment != null && imageAttachment.preview.isNotEmpty()) {
|
||||
withContext(Dispatchers.IO) {
|
||||
try {
|
||||
// Получаем blurhash из preview (может быть в формате "tag::blurhash")
|
||||
val blurhash = if (imageAttachment.preview.contains("::")) {
|
||||
imageAttachment.preview.split("::").lastOrNull() ?: ""
|
||||
} else {
|
||||
imageAttachment.preview
|
||||
}
|
||||
if (blurhash.isNotEmpty() && !blurhash.startsWith("http")) {
|
||||
previewBitmap = BlurHash.decode(blurhash, 40, 40)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
// Ignore blurhash decode errors
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
@@ -604,29 +640,78 @@ fun ReplyBubble(
|
||||
.clickable(onClick = onClick)
|
||||
.background(backgroundColor)
|
||||
) {
|
||||
// Вертикальная полоска слева
|
||||
Box(modifier = Modifier.width(3.dp).fillMaxHeight().background(borderColor))
|
||||
|
||||
Column(
|
||||
// Контент reply
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = 8.dp, end = 10.dp, top = 4.dp, bottom = 4.dp)
|
||||
.weight(1f)
|
||||
.padding(start = 8.dp, end = if (hasImage) 4.dp else 10.dp, top = 4.dp, bottom = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = if (replyData.isForwarded) "Forwarded message" else replyData.senderName,
|
||||
color = nameColor,
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
// Текстовая часть
|
||||
Column(
|
||||
modifier = Modifier.weight(1f)
|
||||
) {
|
||||
Text(
|
||||
text = if (replyData.isForwarded) "Forwarded message" else replyData.senderName,
|
||||
color = nameColor,
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
|
||||
Text(
|
||||
text = replyData.text.ifEmpty { "..." },
|
||||
color = replyTextColor,
|
||||
fontSize = 14.sp,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
// Текст или "Photo"
|
||||
val displayText = when {
|
||||
replyData.text.isNotEmpty() -> replyData.text
|
||||
hasImage -> "Photo"
|
||||
replyData.attachments.any { it.type == AttachmentType.FILE } -> "File"
|
||||
else -> "..."
|
||||
}
|
||||
|
||||
Text(
|
||||
text = displayText,
|
||||
color = replyTextColor,
|
||||
fontSize = 14.sp,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
|
||||
// 🖼️ Превью изображения справа (как в Telegram)
|
||||
if (hasImage) {
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(36.dp)
|
||||
.clip(RoundedCornerShape(4.dp))
|
||||
.background(Color.Gray.copy(alpha = 0.3f))
|
||||
) {
|
||||
if (previewBitmap != null) {
|
||||
Image(
|
||||
bitmap = previewBitmap!!.asImageBitmap(),
|
||||
contentDescription = "Photo preview",
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentScale = ContentScale.Crop
|
||||
)
|
||||
} else {
|
||||
// Placeholder с иконкой
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(
|
||||
TablerIcons.Photo,
|
||||
contentDescription = null,
|
||||
tint = Color.White.copy(alpha = 0.7f),
|
||||
modifier = Modifier.size(20.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -785,7 +870,62 @@ private fun KebabMenuItem(
|
||||
)
|
||||
}
|
||||
|
||||
/** Profile photo menu for avatar */
|
||||
/** 🖼️ Превью изображения для reply в input bar (как в Telegram) */
|
||||
@Composable
|
||||
fun ReplyImagePreview(
|
||||
attachment: MessageAttachment,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
var previewBitmap by remember { mutableStateOf<android.graphics.Bitmap?>(null) }
|
||||
|
||||
LaunchedEffect(attachment.preview) {
|
||||
if (attachment.preview.isNotEmpty()) {
|
||||
withContext(Dispatchers.IO) {
|
||||
try {
|
||||
// Получаем blurhash из preview (может быть в формате "tag::blurhash")
|
||||
val blurhash = if (attachment.preview.contains("::")) {
|
||||
attachment.preview.split("::").lastOrNull() ?: ""
|
||||
} else {
|
||||
attachment.preview
|
||||
}
|
||||
if (blurhash.isNotEmpty() && !blurhash.startsWith("http")) {
|
||||
previewBitmap = BlurHash.decode(blurhash, 40, 40)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
// Ignore blurhash decode errors
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
.clip(RoundedCornerShape(4.dp))
|
||||
.background(Color.Gray.copy(alpha = 0.3f))
|
||||
) {
|
||||
if (previewBitmap != null) {
|
||||
Image(
|
||||
bitmap = previewBitmap!!.asImageBitmap(),
|
||||
contentDescription = "Photo preview",
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentScale = ContentScale.Crop
|
||||
)
|
||||
} else {
|
||||
// Placeholder с иконкой
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(
|
||||
TablerIcons.Photo,
|
||||
contentDescription = null,
|
||||
tint = Color.White.copy(alpha = 0.7f),
|
||||
modifier = Modifier.size(20.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}/** Profile photo menu for avatar */
|
||||
@Composable
|
||||
fun ProfilePhotoMenu(
|
||||
expanded: Boolean,
|
||||
|
||||
@@ -30,6 +30,7 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import app.rosette.android.ui.keyboard.AnimatedKeyboardTransition
|
||||
import app.rosette.android.ui.keyboard.KeyboardTransitionCoordinator
|
||||
import com.rosetta.messenger.network.AttachmentType
|
||||
import com.rosetta.messenger.ui.components.AppleEmojiTextField
|
||||
import com.rosetta.messenger.ui.components.OptimizedEmojiPicker
|
||||
import com.rosetta.messenger.ui.onboarding.PrimaryBlue
|
||||
@@ -317,11 +318,18 @@ fun MessageInputBar(
|
||||
)
|
||||
Spacer(modifier = Modifier.height(2.dp))
|
||||
if (displayReplyMessages.isNotEmpty()) {
|
||||
val msg = displayReplyMessages.first()
|
||||
val hasImageAttachment = msg.attachments.any {
|
||||
it.type == AttachmentType.IMAGE
|
||||
}
|
||||
Text(
|
||||
text = if (displayReplyMessages.size == 1) {
|
||||
val msg = displayReplyMessages.first()
|
||||
val shortText = msg.text.take(40)
|
||||
if (shortText.length < msg.text.length) "$shortText..." else shortText
|
||||
if (msg.text.isEmpty() && hasImageAttachment) {
|
||||
"Photo"
|
||||
} else {
|
||||
val shortText = msg.text.take(40)
|
||||
if (shortText.length < msg.text.length) "$shortText..." else shortText
|
||||
}
|
||||
} else "${displayReplyMessages.size} messages",
|
||||
fontSize = 13.sp,
|
||||
color = if (isDarkTheme) Color.White.copy(alpha = 0.6f)
|
||||
@@ -331,6 +339,23 @@ fun MessageInputBar(
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 🔥 Превью изображения (как в Telegram)
|
||||
if (displayReplyMessages.size == 1) {
|
||||
val msg = displayReplyMessages.first()
|
||||
val imageAttachment = msg.attachments.find {
|
||||
it.type == AttachmentType.IMAGE
|
||||
}
|
||||
if (imageAttachment != null) {
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
ReplyImagePreview(
|
||||
attachment = imageAttachment,
|
||||
modifier = Modifier.size(36.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
}
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(32.dp)
|
||||
|
||||
@@ -22,7 +22,8 @@ data class ReplyData(
|
||||
val senderName: String,
|
||||
val text: String,
|
||||
val isFromMe: Boolean,
|
||||
val isForwarded: Boolean = false
|
||||
val isForwarded: Boolean = false,
|
||||
val attachments: List<MessageAttachment> = emptyList() // 🖼️ Для превью фото в reply
|
||||
)
|
||||
|
||||
/** Legacy message model (for compatibility) */
|
||||
|
||||
@@ -15,7 +15,9 @@ import androidx.compose.material.icons.filled.MoreVert
|
||||
import androidx.compose.material.icons.outlined.Block
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.rosetta.messenger.data.MessageRepository
|
||||
import com.rosetta.messenger.ui.chats.ChatViewModel
|
||||
import com.rosetta.messenger.ui.onboarding.PrimaryBlue
|
||||
import com.rosetta.messenger.ui.components.VerifiedBadge
|
||||
import androidx.compose.ui.Alignment
|
||||
@@ -62,7 +64,10 @@ fun OtherProfileScreen(
|
||||
val avatarColors = getAvatarColor(user.publicKey, isDarkTheme)
|
||||
val context = LocalContext.current
|
||||
|
||||
// 🟢 Наблюдаем за онлайн статусом пользователя в реальном времени
|
||||
// <EFBFBD> Получаем тот же ChatViewModel что и в ChatDetailScreen для очистки истории
|
||||
val viewModel: ChatViewModel = viewModel(key = "chat_${user.publicKey}")
|
||||
|
||||
// <20>🟢 Наблюдаем за онлайн статусом пользователя в реальном времени
|
||||
val messageRepository = remember { MessageRepository.getInstance(context) }
|
||||
val onlineStatus by messageRepository.observeUserOnlineStatus(user.publicKey)
|
||||
.collectAsState(initial = false to 0L)
|
||||
@@ -289,7 +294,7 @@ private fun CollapsingOtherProfileHeader(
|
||||
},
|
||||
onClearChatClick = {
|
||||
onAvatarMenuChange(false)
|
||||
// TODO: Реализовать очистку истории чата
|
||||
viewModel.clearChatHistory()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user