feat: Add support for image attachments in replies; implement preview functionality similar to Telegram

This commit is contained in:
2026-01-26 19:10:40 +05:00
parent 9445f11010
commit f98e529042
6 changed files with 261 additions and 30 deletions

View File

@@ -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
)
}
}

View File

@@ -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

View File

@@ -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,

View File

@@ -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)

View File

@@ -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) */

View File

@@ -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()
}
)
}