diff --git a/client-mobile/.gradle/8.5/executionHistory/executionHistory.bin b/client-mobile/.gradle/8.5/executionHistory/executionHistory.bin index 8099f3c..e2e2ae6 100644 Binary files a/client-mobile/.gradle/8.5/executionHistory/executionHistory.bin and b/client-mobile/.gradle/8.5/executionHistory/executionHistory.bin differ diff --git a/client-mobile/.gradle/8.5/executionHistory/executionHistory.lock b/client-mobile/.gradle/8.5/executionHistory/executionHistory.lock index 983dcba..601ead8 100644 Binary files a/client-mobile/.gradle/8.5/executionHistory/executionHistory.lock and b/client-mobile/.gradle/8.5/executionHistory/executionHistory.lock differ diff --git a/client-mobile/.gradle/8.5/fileHashes/fileHashes.bin b/client-mobile/.gradle/8.5/fileHashes/fileHashes.bin index 7d9f990..609a386 100644 Binary files a/client-mobile/.gradle/8.5/fileHashes/fileHashes.bin and b/client-mobile/.gradle/8.5/fileHashes/fileHashes.bin differ diff --git a/client-mobile/.gradle/8.5/fileHashes/fileHashes.lock b/client-mobile/.gradle/8.5/fileHashes/fileHashes.lock index 9f3cca0..6c2382f 100644 Binary files a/client-mobile/.gradle/8.5/fileHashes/fileHashes.lock and b/client-mobile/.gradle/8.5/fileHashes/fileHashes.lock differ diff --git a/client-mobile/.gradle/8.5/fileHashes/resourceHashesCache.bin b/client-mobile/.gradle/8.5/fileHashes/resourceHashesCache.bin index 338b816..dcde928 100644 Binary files a/client-mobile/.gradle/8.5/fileHashes/resourceHashesCache.bin and b/client-mobile/.gradle/8.5/fileHashes/resourceHashesCache.bin differ diff --git a/client-mobile/.gradle/buildOutputCleanup/buildOutputCleanup.lock b/client-mobile/.gradle/buildOutputCleanup/buildOutputCleanup.lock index 1262973..5254bad 100644 Binary files a/client-mobile/.gradle/buildOutputCleanup/buildOutputCleanup.lock and b/client-mobile/.gradle/buildOutputCleanup/buildOutputCleanup.lock differ diff --git a/client-mobile/.gradle/buildOutputCleanup/outputFiles.bin b/client-mobile/.gradle/buildOutputCleanup/outputFiles.bin index beb3dcf..e5ae1e2 100644 Binary files a/client-mobile/.gradle/buildOutputCleanup/outputFiles.bin and b/client-mobile/.gradle/buildOutputCleanup/outputFiles.bin differ diff --git a/client-mobile/chats/presentation/chat_detail/ChatDetailScreen.kt b/client-mobile/chats/presentation/chat_detail/ChatDetailScreen.kt index 225aea0..a17fb59 100644 --- a/client-mobile/chats/presentation/chat_detail/ChatDetailScreen.kt +++ b/client-mobile/chats/presentation/chat_detail/ChatDetailScreen.kt @@ -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() } } + } } } diff --git a/client-mobile/chats/presentation/chat_detail/ChatDetailViewModel.kt b/client-mobile/chats/presentation/chat_detail/ChatDetailViewModel.kt index 1e086ca..2a0596f 100644 --- a/client-mobile/chats/presentation/chat_detail/ChatDetailViewModel.kt +++ b/client-mobile/chats/presentation/chat_detail/ChatDetailViewModel.kt @@ -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 = 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) } } diff --git a/client-mobile/chats/presentation/components/MessageBubble.kt b/client-mobile/chats/presentation/components/MessageBubble.kt index 8baeea6..6947db2 100644 --- a/client-mobile/chats/presentation/components/MessageBubble.kt +++ b/client-mobile/chats/presentation/components/MessageBubble.kt @@ -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(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 + ) + } +}