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, text = chatMsg.text,
timestamp = chatMsg.timestamp.time, timestamp = chatMsg.timestamp.time,
isOutgoing = chatMsg.isOutgoing, 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 text: String,
val timestamp: Long, val timestamp: Long,
val isOutgoing: Boolean, val isOutgoing: Boolean,
val publicKey: String = "" // publicKey отправителя цитируемого сообщения val publicKey: String = "", // publicKey отправителя цитируемого сообщения
val attachments: List<MessageAttachment> = emptyList() // Для показа превью
) )
private val _replyMessages = MutableStateFlow<List<ReplyMessage>>(emptyList()) private val _replyMessages = MutableStateFlow<List<ReplyMessage>>(emptyList())
val replyMessages: StateFlow<List<ReplyMessage>> = _replyMessages.asStateFlow() val replyMessages: StateFlow<List<ReplyMessage>> = _replyMessages.asStateFlow()
@@ -976,11 +977,30 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
// Определяем, кто автор цитируемого сообщения // Определяем, кто автор цитируемого сообщения
val isReplyFromMe = replyPublicKey == myPublicKey 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( val result = ReplyData(
messageId = realMessageId, messageId = realMessageId,
senderName = if (isReplyFromMe) "You" else opponentTitle.ifEmpty { opponentUsername.ifEmpty { "User" } }, senderName = if (isReplyFromMe) "You" else opponentTitle.ifEmpty { opponentUsername.ifEmpty { "User" } },
text = replyText, text = replyText,
isFromMe = isReplyFromMe isFromMe = isReplyFromMe,
attachments = originalAttachments
) )
return result return result
} else { } else {
@@ -1138,12 +1158,15 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
// Работает и для reply, и для forward // Работает и для reply, и для forward
val replyData: ReplyData? = if (replyMsgs.isNotEmpty()) { val replyData: ReplyData? = if (replyMsgs.isNotEmpty()) {
val firstReply = replyMsgs.first() val firstReply = replyMsgs.first()
// 🖼️ Получаем attachments из текущих сообщений для превью
val replyAttachments = _messages.value.find { it.id == firstReply.messageId }?.attachments ?: emptyList()
ReplyData( ReplyData(
messageId = firstReply.messageId, messageId = firstReply.messageId,
senderName = if (firstReply.isOutgoing) "You" else opponentTitle.ifEmpty { opponentUsername.ifEmpty { "User" } }, senderName = if (firstReply.isOutgoing) "You" else opponentTitle.ifEmpty { opponentUsername.ifEmpty { "User" } },
text = firstReply.text, text = firstReply.text,
isFromMe = firstReply.isOutgoing, isFromMe = firstReply.isOutgoing,
isForwarded = isForward isForwarded = isForward,
attachments = replyAttachments
) )
} else null } else null
@@ -2070,6 +2093,42 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
fun canSend(): Boolean = _inputText.value.isNotBlank() && !isSending 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() { override fun onCleared() {
super.onCleared() super.onCleared()
lastReadMessageTimestamp = 0L lastReadMessageTimestamp = 0L

View File

@@ -1,8 +1,10 @@
package com.rosetta.messenger.ui.chats.components package com.rosetta.messenger.ui.chats.components
import android.graphics.Bitmap
import androidx.compose.animation.* import androidx.compose.animation.*
import androidx.compose.animation.core.* import androidx.compose.animation.core.*
import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.Image
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.border import androidx.compose.foundation.border
import androidx.compose.foundation.clickable 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.draw.shadow
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.TransformOrigin import androidx.compose.ui.graphics.TransformOrigin
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow 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.unit.sp
import androidx.compose.ui.window.Popup import androidx.compose.ui.window.Popup
import androidx.compose.ui.window.PopupProperties 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.components.AppleEmojiText
import com.rosetta.messenger.ui.onboarding.PrimaryBlue import com.rosetta.messenger.ui.onboarding.PrimaryBlue
import com.rosetta.messenger.ui.chats.models.* import com.rosetta.messenger.ui.chats.models.*
import com.rosetta.messenger.ui.chats.utils.* import com.rosetta.messenger.ui.chats.utils.*
import com.vanniktech.blurhash.BlurHash
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.withContext
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.* import java.util.*
@@ -574,7 +583,7 @@ fun AnimatedMessageStatus(
} }
} }
/** Reply bubble inside message */ /** Reply bubble inside message - Telegram style with image preview */
@Composable @Composable
fun ReplyBubble( fun ReplyBubble(
replyData: ReplyData, replyData: ReplyData,
@@ -596,6 +605,33 @@ fun ReplyBubble(
if (isDarkTheme) Color.White else Color.Black 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( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
@@ -604,12 +640,19 @@ fun ReplyBubble(
.clickable(onClick = onClick) .clickable(onClick = onClick)
.background(backgroundColor) .background(backgroundColor)
) { ) {
// Вертикальная полоска слева
Box(modifier = Modifier.width(3.dp).fillMaxHeight().background(borderColor)) Box(modifier = Modifier.width(3.dp).fillMaxHeight().background(borderColor))
Column( // Контент reply
Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .weight(1f)
.padding(start = 8.dp, end = 10.dp, top = 4.dp, bottom = 4.dp) .padding(start = 8.dp, end = if (hasImage) 4.dp else 10.dp, top = 4.dp, bottom = 4.dp),
verticalAlignment = Alignment.CenterVertically
) {
// Текстовая часть
Column(
modifier = Modifier.weight(1f)
) { ) {
Text( Text(
text = if (replyData.isForwarded) "Forwarded message" else replyData.senderName, text = if (replyData.isForwarded) "Forwarded message" else replyData.senderName,
@@ -620,14 +663,56 @@ fun ReplyBubble(
overflow = TextOverflow.Ellipsis 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(
text = replyData.text.ifEmpty { "..." }, text = displayText,
color = replyTextColor, color = replyTextColor,
fontSize = 14.sp, fontSize = 14.sp,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis 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 @Composable
fun ProfilePhotoMenu( fun ProfilePhotoMenu(
expanded: Boolean, expanded: Boolean,

View File

@@ -30,6 +30,7 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import app.rosette.android.ui.keyboard.AnimatedKeyboardTransition import app.rosette.android.ui.keyboard.AnimatedKeyboardTransition
import app.rosette.android.ui.keyboard.KeyboardTransitionCoordinator 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.AppleEmojiTextField
import com.rosetta.messenger.ui.components.OptimizedEmojiPicker import com.rosetta.messenger.ui.components.OptimizedEmojiPicker
import com.rosetta.messenger.ui.onboarding.PrimaryBlue import com.rosetta.messenger.ui.onboarding.PrimaryBlue
@@ -317,11 +318,18 @@ fun MessageInputBar(
) )
Spacer(modifier = Modifier.height(2.dp)) Spacer(modifier = Modifier.height(2.dp))
if (displayReplyMessages.isNotEmpty()) { if (displayReplyMessages.isNotEmpty()) {
val msg = displayReplyMessages.first()
val hasImageAttachment = msg.attachments.any {
it.type == AttachmentType.IMAGE
}
Text( Text(
text = if (displayReplyMessages.size == 1) { text = if (displayReplyMessages.size == 1) {
val msg = displayReplyMessages.first() if (msg.text.isEmpty() && hasImageAttachment) {
"Photo"
} else {
val shortText = msg.text.take(40) val shortText = msg.text.take(40)
if (shortText.length < msg.text.length) "$shortText..." else shortText if (shortText.length < msg.text.length) "$shortText..." else shortText
}
} else "${displayReplyMessages.size} messages", } else "${displayReplyMessages.size} messages",
fontSize = 13.sp, fontSize = 13.sp,
color = if (isDarkTheme) Color.White.copy(alpha = 0.6f) 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( Box(
modifier = Modifier modifier = Modifier
.size(32.dp) .size(32.dp)

View File

@@ -22,7 +22,8 @@ data class ReplyData(
val senderName: String, val senderName: String,
val text: String, val text: String,
val isFromMe: Boolean, val isFromMe: Boolean,
val isForwarded: Boolean = false val isForwarded: Boolean = false,
val attachments: List<MessageAttachment> = emptyList() // 🖼️ Для превью фото в reply
) )
/** Legacy message model (for compatibility) */ /** 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.material.icons.outlined.Block
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.lifecycle.viewmodel.compose.viewModel
import com.rosetta.messenger.data.MessageRepository 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.onboarding.PrimaryBlue
import com.rosetta.messenger.ui.components.VerifiedBadge import com.rosetta.messenger.ui.components.VerifiedBadge
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
@@ -62,7 +64,10 @@ fun OtherProfileScreen(
val avatarColors = getAvatarColor(user.publicKey, isDarkTheme) val avatarColors = getAvatarColor(user.publicKey, isDarkTheme)
val context = LocalContext.current val context = LocalContext.current
// 🟢 Наблюдаем за онлайн статусом пользователя в реальном времени // <EFBFBD> Получаем тот же ChatViewModel что и в ChatDetailScreen для очистки истории
val viewModel: ChatViewModel = viewModel(key = "chat_${user.publicKey}")
// <20>🟢 Наблюдаем за онлайн статусом пользователя в реальном времени
val messageRepository = remember { MessageRepository.getInstance(context) } val messageRepository = remember { MessageRepository.getInstance(context) }
val onlineStatus by messageRepository.observeUserOnlineStatus(user.publicKey) val onlineStatus by messageRepository.observeUserOnlineStatus(user.publicKey)
.collectAsState(initial = false to 0L) .collectAsState(initial = false to 0L)
@@ -289,7 +294,7 @@ private fun CollapsingOtherProfileHeader(
}, },
onClearChatClick = { onClearChatClick = {
onAvatarMenuChange(false) onAvatarMenuChange(false)
// TODO: Реализовать очистку истории чата viewModel.clearChatHistory()
} }
) )
} }