Меню для сообщений
This commit is contained in:
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user