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

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