diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt index acb967b..1b77bc2 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt @@ -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 ) } } diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatViewModel.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatViewModel.kt index 3c57a95..a8b607f 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatViewModel.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatViewModel.kt @@ -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 = emptyList() // Для показа превью ) private val _replyMessages = MutableStateFlow>(emptyList()) val replyMessages: StateFlow> = _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 diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/components/ChatDetailComponents.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/components/ChatDetailComponents.kt index f5194b1..5474c12 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/components/ChatDetailComponents.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/components/ChatDetailComponents.kt @@ -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(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(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, diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/input/ChatDetailInput.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/input/ChatDetailInput.kt index 734da66..229e286 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/input/ChatDetailInput.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/input/ChatDetailInput.kt @@ -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) diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/models/ChatDetailModels.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/models/ChatDetailModels.kt index 8ccf4b1..4618a18 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/models/ChatDetailModels.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/models/ChatDetailModels.kt @@ -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 = emptyList() // 🖼️ Для превью фото в reply ) /** Legacy message model (for compatibility) */ diff --git a/app/src/main/java/com/rosetta/messenger/ui/settings/OtherProfileScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/settings/OtherProfileScreen.kt index 1de7296..531548c 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/settings/OtherProfileScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/settings/OtherProfileScreen.kt @@ -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 - // 🟢 Наблюдаем за онлайн статусом пользователя в реальном времени + // � Получаем тот же ChatViewModel что и в ChatDetailScreen для очистки истории + val viewModel: ChatViewModel = viewModel(key = "chat_${user.publicKey}") + + // �🟢 Наблюдаем за онлайн статусом пользователя в реальном времени 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() } ) }