Меню для сообщений
This commit is contained in:
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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) }
|
||||
}
|
||||
|
||||
@@ -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