Меню для сообщений

This commit is contained in:
Халимов Рустам
2026-04-17 23:44:11 +03:00
parent ed7521a563
commit 754e9e8ad0
10 changed files with 277 additions and 73 deletions
Binary file not shown.
Binary file not shown.
@@ -261,6 +261,24 @@ fun ChatDetailScreen(
Scaffold( Scaffold(
topBar = { topBar = {
if (state.selectedMessageIds.isNotEmpty()) {
TopAppBar(
title = { Text("${state.selectedMessageIds.size}") },
navigationIcon = {
IconButton(onClick = { viewModel.clearSelection() }) {
Icon(Icons.Default.Close, contentDescription = "Cancel")
}
},
actions = {
IconButton(onClick = { /* TODO: Forward */ }) {
Icon(Icons.Default.Forward, contentDescription = "Forward")
}
IconButton(onClick = { /* TODO: Delete */ }) {
Icon(Icons.Default.Delete, contentDescription = "Delete")
}
}
)
} else {
TopAppBar( TopAppBar(
title = { title = {
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
@@ -299,6 +317,7 @@ fun ChatDetailScreen(
} }
) )
} }
}
) { paddingValues -> ) { paddingValues ->
Column( Column(
modifier = Modifier modifier = Modifier
@@ -345,7 +364,6 @@ fun ChatDetailScreen(
onVoiceFinished = { speed -> playNextVoiceMessage(item.message.id, speed) }, onVoiceFinished = { speed -> playNextVoiceMessage(item.message.id, speed) },
onReactionClick = { emoji -> viewModel.addReaction(item.message.id, emoji) }, onReactionClick = { emoji -> viewModel.addReaction(item.message.id, emoji) },
onReplyClick = { reply -> onReplyClick = { reply ->
// TODO: Scroll to reply
val index = listItems.indexOfFirst { val index = listItems.indexOfFirst {
it is MessageListItem.MessageItem && it.message.id == reply.id it is MessageListItem.MessageItem && it.message.id == reply.id
} }
@@ -353,6 +371,9 @@ fun ChatDetailScreen(
scope.launch { listState.animateScrollToItem(index) } scope.launch { listState.animateScrollToItem(index) }
} }
}, },
onSelect = { viewModel.toggleSelection(it) },
isSelected = state.selectedMessageIds.contains(item.message.id),
isSelectionMode = state.selectedMessageIds.isNotEmpty(),
onMediaClick = { clickedMedia -> onMediaClick = { clickedMedia ->
val isMedia = clickedMedia.type.startsWith("image") || val isMedia = clickedMedia.type.startsWith("image") ||
clickedMedia.type.startsWith("video") || clickedMedia.type.startsWith("video") ||
@@ -491,6 +512,7 @@ fun ChatDetailScreen(
} }
// Панель ввода // Панель ввода
if (state.selectedMessageIds.isEmpty()) {
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
@@ -897,6 +919,7 @@ fun ChatDetailScreen(
} }
} }
} }
}
// Просмотрщик медиа // Просмотрщик медиа
selectedMediaList?.let { list -> selectedMediaList?.let { list ->
@@ -42,7 +42,8 @@ data class ChatDetailState(
val isUploading: Boolean = false, val isUploading: Boolean = false,
val isCompressionEnabled: Boolean = true, val isCompressionEnabled: Boolean = true,
val inputText: String = "", val inputText: String = "",
val replyingMessage: Message? = null val replyingMessage: Message? = null,
val selectedMessageIds: Set<String> = emptySet()
) )
@HiltViewModel @HiltViewModel
@@ -312,6 +313,21 @@ class ChatDetailViewModel @Inject constructor(
_state.update { it.copy(replyingMessage = message) } _state.update { it.copy(replyingMessage = message) }
} }
fun toggleSelection(messageId: String) {
_state.update { s ->
val newSelection = if (s.selectedMessageIds.contains(messageId)) {
s.selectedMessageIds - messageId
} else {
s.selectedMessageIds + messageId
}
s.copy(selectedMessageIds = newSelection)
}
}
fun clearSelection() {
_state.update { it.copy(selectedMessageIds = emptySet()) }
}
fun cancelReply() { fun cancelReply() {
_state.update { it.copy(replyingMessage = null) } _state.update { it.copy(replyingMessage = null) }
} }
@@ -5,6 +5,7 @@ import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
@@ -14,6 +15,9 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import ru.knot.messager.R import ru.knot.messager.R
import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.IntOffset
@@ -33,13 +37,15 @@ import chats.presentation.components.LinkPreview
import android.content.Intent import android.content.Intent
import android.net.Uri import android.net.Uri
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.material3.Icon import androidx.compose.material3.*
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.foundation.shape.CircleShape import androidx.compose.ui.text.font.FontWeight
import core.presentation.components.AppMediaLightbox import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.ui.input.pointer.pointerInput
import java.time.Instant import java.time.Instant
import java.time.ZoneId import java.time.ZoneId
import java.time.format.DateTimeFormatter import java.time.format.DateTimeFormatter
import core.presentation.components.AppMediaLightbox
@OptIn(androidx.compose.foundation.ExperimentalFoundationApi::class) @OptIn(androidx.compose.foundation.ExperimentalFoundationApi::class)
@Composable @Composable
@@ -51,19 +57,24 @@ fun MessageBubble(
initialPlaybackSpeed: Float = 1.0f, initialPlaybackSpeed: Float = 1.0f,
onVoiceFinished: (Float) -> Unit = {}, onVoiceFinished: (Float) -> Unit = {},
onReplyClick: (Message) -> Unit = {}, onReplyClick: (Message) -> Unit = {},
onSelect: (String) -> Unit = {},
isSelected: Boolean = false,
isSelectionMode: Boolean = false,
onMediaClick: (chats.domain.model.Media) -> Unit = {} onMediaClick: (chats.domain.model.Media) -> Unit = {}
) { ) {
val context = LocalContext.current val context = LocalContext.current
var showReactionPicker by remember { mutableStateOf(false) } var showReactionPicker by remember { mutableStateOf(false) }
var lightboxMediaIndex by remember { mutableStateOf<Int?>(null) } var lightboxMediaIndex by remember { mutableStateOf<Int?>(null) }
val backgroundColor = if (isCurrentUser) {
Color(0xFF3096E5)
} else {
Color(0xFF212121)
}
val contentColor = Color.White val contentColor = Color.White
var showContextMenu by remember { mutableStateOf(false) }
val backgroundColor = when {
isSelected -> if (isCurrentUser) Color(0xFF3096E5).copy(alpha = 0.5f) else Color(0xFF212121).copy(alpha = 0.5f)
showContextMenu -> Color(0xFF007AFF) // Even brighter highlight
isCurrentUser -> Color(0xFF3096E5)
else -> Color(0xFF212121)
}
val shape = if (isCurrentUser) { val shape = if (isCurrentUser) {
RoundedCornerShape(16.dp, 16.dp, 4.dp, 16.dp) RoundedCornerShape(16.dp, 16.dp, 4.dp, 16.dp)
@@ -76,33 +87,91 @@ fun MessageBubble(
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.pointerInput(Unit) { // Clicks anywhere in the Row row now work
detectTapGestures(
onTap = {
if (isSelectionMode) {
onSelect(message.id)
} else {
showContextMenu = !showContextMenu
}
},
onLongPress = {
if (!isSelectionMode) {
onSelect(message.id)
}
}
)
}
.padding(horizontal = 8.dp, vertical = 2.dp), .padding(horizontal = 8.dp, vertical = 2.dp),
horizontalArrangement = if (isCurrentUser) Arrangement.End else Arrangement.Start, horizontalArrangement = if (isCurrentUser) Arrangement.End else Arrangement.Start,
verticalAlignment = Alignment.Bottom verticalAlignment = Alignment.Bottom
) { ) {
// No avatars here as per user request (web parity) var bubbleHeight by remember { mutableIntStateOf(0) }
var isNearBottom by remember { mutableStateOf(false) }
Column( Box(
modifier = Modifier modifier = Modifier
.widthIn(max = 300.dp) .widthIn(max = 300.dp)
.onGloballyPositioned { coords ->
bubbleHeight = coords.size.height
val screenHeight = coords.parentLayoutCoordinates?.size?.height ?: 2000
val yInWindow = coords.localToWindow(Offset.Zero).y
// If bottom of message is in the last 40% of screen, open menu UP
isNearBottom = yInWindow > screenHeight * 0.6f
},
contentAlignment = if (isCurrentUser) Alignment.CenterEnd else Alignment.CenterStart
) {
// Actual bubble
Column(
modifier = Modifier
.clip(shape) .clip(shape)
.background(backgroundColor) .background(backgroundColor)
.combinedClickable(
onClick = { /* Handle normal click */ },
onLongClick = { showReactionPicker = true }
)
.padding(8.dp) .padding(8.dp)
) { ) {
if (showReactionPicker) { if (showContextMenu) {
val density = androidx.compose.ui.platform.LocalDensity.current
val menuWidth = 220.dp
val menuWidthPx = with(density) { menuWidth.toPx() }
Popup( Popup(
alignment = Alignment.TopCenter, alignment = if (isNearBottom) {
offset = IntOffset(0, -100), if (isCurrentUser) Alignment.BottomStart else Alignment.BottomEnd
onDismissRequest = { showReactionPicker = false } } else {
if (isCurrentUser) Alignment.TopStart else Alignment.TopEnd
},
offset = if (isCurrentUser) {
// Message is on RIGHT, show menu to its LEFT
IntOffset(-menuWidthPx.toInt() - 20, 0)
} else {
// Message is on LEFT, show menu to its RIGHT
IntOffset(menuWidthPx.toInt() + 20, 0)
},
onDismissRequest = { showContextMenu = false },
properties = androidx.compose.ui.window.PopupProperties(
focusable = true,
dismissOnBackPress = true,
dismissOnClickOutside = true
)
) { ) {
ReactionPicker(onReactionSelected = { MessageContextMenu(
onReactionSelected = {
onReactionClick(it) onReactionClick(it)
showReactionPicker = false showContextMenu = false
}) },
onAction = { action ->
showContextMenu = false
when(action) {
"reply" -> onReplyClick(message)
"select" -> onSelect(message.id)
"copy" -> {
val clipboard = context.getSystemService(android.content.Context.CLIPBOARD_SERVICE) as android.content.ClipboardManager
val clip = android.content.ClipData.newPlainText("message", message.content)
clipboard.setPrimaryClip(clip)
}
}
}
)
} }
} }
@@ -387,8 +456,22 @@ fun MessageBubble(
) )
} }
} }
} // End of bubble Column
if (isSelected) {
Icon(
imageVector = Icons.Default.CheckCircle,
contentDescription = null,
tint = Color.White,
modifier = Modifier
.align(Alignment.Center)
.size(32.dp)
.background(Color(0xFF3096E5), CircleShape)
.padding(2.dp)
)
} }
} } // End of Box
} // End of Row
lightboxMediaIndex?.let { index -> lightboxMediaIndex?.let { index ->
AppMediaLightbox( AppMediaLightbox(
@@ -558,3 +641,85 @@ fun GridItem(media: Media, modifier: Modifier = Modifier) {
} }
} }
} }
@Composable
fun MessageContextMenu(
onReactionSelected: (String) -> Unit,
onAction: (String) -> Unit
) {
androidx.compose.material3.Surface(
modifier = Modifier
.width(220.dp)
.clip(RoundedCornerShape(12.dp)),
color = Color(0xFF1E1E1E),
shadowElevation = 8.dp
) {
Column {
// Reactions
Row(
modifier = Modifier
.fillMaxWidth()
.padding(8.dp),
horizontalArrangement = Arrangement.SpaceBetween
) {
listOf("👍", "❤️", "😂", "😮", "😢", "🔥").forEach { emoji ->
Text(
text = emoji,
fontSize = 24.sp,
modifier = Modifier
.clip(CircleShape)
.clickable { onReactionSelected(emoji) }
.padding(4.dp)
)
}
}
Divider(color = Color.White.copy(alpha = 0.1f))
ContextMenuItem(Icons.Default.Reply, "Ответить", onClick = { onAction("reply") })
ContextMenuItem(Icons.Default.CheckCircle, "Выбрать", onClick = { onAction("select") })
ContextMenuItem(Icons.Default.Forward, "Переслать", onClick = { onAction("forward") })
ContextMenuItem(Icons.Default.PushPin, "Закрепить", onClick = { onAction("pin") })
ContextMenuItem(Icons.Default.ContentCopy, "Копировать", onClick = { onAction("copy") })
ContextMenuItem(Icons.Default.Edit, "Редактировать", onClick = { onAction("edit") })
Divider(color = Color.White.copy(alpha = 0.1f))
ContextMenuItem(
Icons.Default.Delete,
"Удалить",
textColor = Color(0xFFE53935),
onClick = { onAction("delete") }
)
}
}
}
@Composable
fun ContextMenuItem(
icon: ImageVector,
text: String,
textColor: Color = Color.White,
onClick: () -> Unit
) {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.padding(horizontal = 16.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = icon,
contentDescription = null,
tint = textColor.copy(alpha = 0.7f),
modifier = Modifier.size(20.dp)
)
Spacer(modifier = Modifier.width(16.dp))
Text(
text = text,
color = textColor,
style = MaterialTheme.typography.bodyMedium
)
}
}