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

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

View File

@@ -261,43 +261,62 @@ fun ChatDetailScreen(
Scaffold(
topBar = {
TopAppBar(
title = {
Row(verticalAlignment = Alignment.CenterVertically) {
AppAvatar(
url = state.chatAvatar,
name = state.chatName ?: chatName,
size = 36.dp,
modifier = Modifier.padding(end = 8.dp)
)
Column {
Text(state.chatName ?: chatName, style = MaterialTheme.typography.titleMedium)
if (state.isTyping) {
Text(
stringResource(R.string.typing),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.primary
)
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(
title = {
Row(verticalAlignment = Alignment.CenterVertically) {
AppAvatar(
url = state.chatAvatar,
name = state.chatName ?: chatName,
size = 36.dp,
modifier = Modifier.padding(end = 8.dp)
)
Column {
Text(state.chatName ?: chatName, style = MaterialTheme.typography.titleMedium)
if (state.isTyping) {
Text(
stringResource(R.string.typing),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.primary
)
}
}
}
},
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.Default.ArrowBack, contentDescription = stringResource(R.string.back))
}
},
actions = {
if (state.canCall) {
IconButton(onClick = { /* Вызов (WebRTC) */ }) {
Icon(Icons.Default.Call, contentDescription = stringResource(R.string.call))
}
IconButton(onClick = { /* Видеозвонок (WebRTC) */ }) {
Icon(Icons.Default.VideoCall, contentDescription = stringResource(R.string.video_call))
}
}
}
},
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.Default.ArrowBack, contentDescription = stringResource(R.string.back))
}
},
actions = {
if (state.canCall) {
IconButton(onClick = { /* Вызов (WebRTC) */ }) {
Icon(Icons.Default.Call, contentDescription = stringResource(R.string.call))
}
IconButton(onClick = { /* Видеозвонок (WebRTC) */ }) {
Icon(Icons.Default.VideoCall, contentDescription = stringResource(R.string.video_call))
}
}
}
)
)
}
}
) { paddingValues ->
Column(
@@ -345,7 +364,6 @@ fun ChatDetailScreen(
onVoiceFinished = { speed -> playNextVoiceMessage(item.message.id, speed) },
onReactionClick = { emoji -> viewModel.addReaction(item.message.id, emoji) },
onReplyClick = { reply ->
// TODO: Scroll to reply
val index = listItems.indexOfFirst {
it is MessageListItem.MessageItem && it.message.id == reply.id
}
@@ -353,6 +371,9 @@ fun ChatDetailScreen(
scope.launch { listState.animateScrollToItem(index) }
}
},
onSelect = { viewModel.toggleSelection(it) },
isSelected = state.selectedMessageIds.contains(item.message.id),
isSelectionMode = state.selectedMessageIds.isNotEmpty(),
onMediaClick = { clickedMedia ->
val isMedia = clickedMedia.type.startsWith("image") ||
clickedMedia.type.startsWith("video") ||
@@ -491,11 +512,12 @@ fun ChatDetailScreen(
}
// Панель ввода
Column(
modifier = Modifier
.fillMaxWidth()
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f))
) {
if (state.selectedMessageIds.isEmpty()) {
Column(
modifier = Modifier
.fillMaxWidth()
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f))
) {
// Pending Attachments Bar
if (state.pendingAttachments.isNotEmpty()) {
Row(
@@ -895,6 +917,7 @@ fun ChatDetailScreen(
viewModel.loadGifCategories()
}
}
}
}
}

View File

@@ -42,7 +42,8 @@ data class ChatDetailState(
val isUploading: Boolean = false,
val isCompressionEnabled: Boolean = true,
val inputText: String = "",
val replyingMessage: Message? = null
val replyingMessage: Message? = null,
val selectedMessageIds: Set<String> = emptySet()
)
@HiltViewModel
@@ -312,6 +313,21 @@ class ChatDetailViewModel @Inject constructor(
_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() {
_state.update { it.copy(replyingMessage = null) }
}

View File

@@ -5,6 +5,7 @@ import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.CircularProgressIndicator
@@ -14,6 +15,9 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
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 ru.knot.messager.R
import androidx.compose.ui.unit.IntOffset
@@ -33,13 +37,15 @@ import chats.presentation.components.LinkPreview
import android.content.Intent
import android.net.Uri
import androidx.compose.ui.platform.LocalContext
import androidx.compose.material3.Icon
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.foundation.shape.CircleShape
import core.presentation.components.AppMediaLightbox
import androidx.compose.material3.*
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.ui.input.pointer.pointerInput
import java.time.Instant
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import core.presentation.components.AppMediaLightbox
@OptIn(androidx.compose.foundation.ExperimentalFoundationApi::class)
@Composable
@@ -51,19 +57,24 @@ fun MessageBubble(
initialPlaybackSpeed: Float = 1.0f,
onVoiceFinished: (Float) -> Unit = {},
onReplyClick: (Message) -> Unit = {},
onSelect: (String) -> Unit = {},
isSelected: Boolean = false,
isSelectionMode: Boolean = false,
onMediaClick: (chats.domain.model.Media) -> Unit = {}
) {
val context = LocalContext.current
var showReactionPicker by remember { mutableStateOf(false) }
var lightboxMediaIndex by remember { mutableStateOf<Int?>(null) }
val backgroundColor = if (isCurrentUser) {
Color(0xFF3096E5)
} else {
Color(0xFF212121)
}
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) {
RoundedCornerShape(16.dp, 16.dp, 4.dp, 16.dp)
@@ -76,35 +87,93 @@ fun MessageBubble(
Row(
modifier = Modifier
.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),
horizontalArrangement = if (isCurrentUser) Arrangement.End else Arrangement.Start,
verticalAlignment = Alignment.Bottom
) {
// No avatars here as per user request (web parity)
Column(
var bubbleHeight by remember { mutableIntStateOf(0) }
var isNearBottom by remember { mutableStateOf(false) }
Box(
modifier = Modifier
.widthIn(max = 300.dp)
.clip(shape)
.background(backgroundColor)
.combinedClickable(
onClick = { /* Handle normal click */ },
onLongClick = { showReactionPicker = true }
)
.padding(8.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
) {
if (showReactionPicker) {
Popup(
alignment = Alignment.TopCenter,
offset = IntOffset(0, -100),
onDismissRequest = { showReactionPicker = false }
) {
ReactionPicker(onReactionSelected = {
onReactionClick(it)
showReactionPicker = false
})
// Actual bubble
Column(
modifier = Modifier
.clip(shape)
.background(backgroundColor)
.padding(8.dp)
) {
if (showContextMenu) {
val density = androidx.compose.ui.platform.LocalDensity.current
val menuWidth = 220.dp
val menuWidthPx = with(density) { menuWidth.toPx() }
Popup(
alignment = if (isNearBottom) {
if (isCurrentUser) Alignment.BottomStart else Alignment.BottomEnd
} 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
)
) {
MessageContextMenu(
onReactionSelected = {
onReactionClick(it)
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)
}
}
}
)
}
}
}
// Reply Info
message.replyTo?.let { reply ->
@@ -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 ->
AppMediaLightbox(
@@ -554,7 +637,89 @@ fun GridItem(media: Media, modifier: Modifier = Modifier) {
color = Color.White.copy(alpha = 0.5f),
fontSize = 8.sp
)
}
}
}
}
@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
)
}
}