Ответы
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.
@@ -67,12 +67,14 @@ class ChatRepositoryImpl @Inject constructor(
|
||||
chatId: String,
|
||||
content: String?,
|
||||
type: String,
|
||||
attachments: List<chats.data.remote.api.AttachmentRequest>?
|
||||
attachments: List<chats.data.remote.api.AttachmentRequest>?,
|
||||
replyToId: String?
|
||||
): Message {
|
||||
val request = SendMessageRequest(
|
||||
content = content,
|
||||
type = type,
|
||||
attachments = attachments
|
||||
attachments = attachments,
|
||||
replyToId = replyToId
|
||||
)
|
||||
val baseUrl = serverConfig.getBaseUrl().removeSuffix("/api/")
|
||||
val userId = tokenManager.getUserId() ?: ""
|
||||
|
||||
@@ -11,7 +11,8 @@ interface ChatRepository {
|
||||
chatId: String,
|
||||
content: String?,
|
||||
type: String = "text",
|
||||
attachments: List<chats.data.remote.api.AttachmentRequest>? = null
|
||||
attachments: List<chats.data.remote.api.AttachmentRequest>? = null,
|
||||
replyToId: String? = null
|
||||
): Message
|
||||
suspend fun addReaction(messageId: String, emoji: String)
|
||||
suspend fun sendTypingStatus(chatId: String)
|
||||
|
||||
@@ -56,6 +56,11 @@ import androidx.compose.foundation.text.BasicTextField
|
||||
import androidx.compose.ui.unit.sp
|
||||
import coil.compose.rememberAsyncImagePainter
|
||||
|
||||
import chats.presentation.components.SwipeableMessageItem
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class, androidx.compose.ui.ExperimentalComposeUiApi::class, androidx.compose.foundation.ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
fun ChatDetailScreen(
|
||||
@@ -98,6 +103,14 @@ fun ChatDetailScreen(
|
||||
|
||||
var selectedMediaList by remember { mutableStateOf<List<chats.domain.model.Media>?>(null) }
|
||||
var initialMediaIndex by remember { mutableIntStateOf(0) }
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
|
||||
// Автоматический фокус при ответе
|
||||
LaunchedEffect(state.replyingMessage) {
|
||||
if (state.replyingMessage != null) {
|
||||
focusRequester.requestFocus()
|
||||
}
|
||||
}
|
||||
|
||||
val playNextVoiceMessage = { currentId: String, speed: Float ->
|
||||
val currentIndex = state.messages.indexOfFirst { it.id == currentId }
|
||||
@@ -319,30 +332,45 @@ fun ChatDetailScreen(
|
||||
}) { item ->
|
||||
when(item) {
|
||||
is MessageListItem.MessageItem -> {
|
||||
MessageBubble(
|
||||
message = item.message,
|
||||
isCurrentUser = item.message.senderId == viewModel.getCurrentUserId(),
|
||||
autoPlay = item.message.id == autoPlayingMessageId,
|
||||
initialPlaybackSpeed = if (item.message.id == autoPlayingMessageId) currentPlaybackSpeed else 1.0f,
|
||||
onVoiceFinished = { speed -> playNextVoiceMessage(item.message.id, speed) },
|
||||
onReactionClick = { emoji -> viewModel.addReaction(item.message.id, emoji) },
|
||||
onMediaClick = { clickedMedia ->
|
||||
val isMedia = clickedMedia.type.startsWith("image") ||
|
||||
clickedMedia.type.startsWith("video") ||
|
||||
clickedMedia.type.contains("gif")
|
||||
|
||||
if (isMedia) {
|
||||
val initialIndex = allChatMedia.indexOfFirst { it.first.url == clickedMedia.url }
|
||||
selectedMediaList = allChatMedia.map { it.first }
|
||||
initialMediaIndex = if (initialIndex != -1) initialIndex else 0
|
||||
} else {
|
||||
try {
|
||||
val intent = android.content.Intent(android.content.Intent.ACTION_VIEW, android.net.Uri.parse(clickedMedia.url))
|
||||
context.startActivity(intent)
|
||||
} catch (e: Exception) { }
|
||||
}
|
||||
SwipeableMessageItem(
|
||||
onReply = {
|
||||
viewModel.onReply(item.message)
|
||||
}
|
||||
)
|
||||
) {
|
||||
MessageBubble(
|
||||
message = item.message,
|
||||
isCurrentUser = item.message.senderId == viewModel.getCurrentUserId(),
|
||||
autoPlay = item.message.id == autoPlayingMessageId,
|
||||
initialPlaybackSpeed = if (item.message.id == autoPlayingMessageId) currentPlaybackSpeed else 1.0f,
|
||||
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
|
||||
}
|
||||
if (index != -1) {
|
||||
scope.launch { listState.animateScrollToItem(index) }
|
||||
}
|
||||
},
|
||||
onMediaClick = { clickedMedia ->
|
||||
val isMedia = clickedMedia.type.startsWith("image") ||
|
||||
clickedMedia.type.startsWith("video") ||
|
||||
clickedMedia.type.contains("gif")
|
||||
|
||||
if (isMedia) {
|
||||
val initialIndex = allChatMedia.indexOfFirst { it.first.url == clickedMedia.url }
|
||||
selectedMediaList = allChatMedia.map { it.first }
|
||||
initialMediaIndex = if (initialIndex != -1) initialIndex else 0
|
||||
} else {
|
||||
try {
|
||||
val intent = android.content.Intent(android.content.Intent.ACTION_VIEW, android.net.Uri.parse(clickedMedia.url))
|
||||
context.startActivity(intent)
|
||||
} catch (e: Exception) { }
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
is MessageListItem.DateHeader -> {
|
||||
Box(
|
||||
@@ -586,6 +614,51 @@ fun ChatDetailScreen(
|
||||
}
|
||||
}
|
||||
}
|
||||
// Reply Preview Bar
|
||||
state.replyingMessage?.let { replyMsg ->
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 8.dp, vertical = 4.dp)
|
||||
.height(IntrinsicSize.Min),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxHeight()
|
||||
.width(2.dp)
|
||||
.background(MaterialTheme.colorScheme.primary)
|
||||
)
|
||||
Icon(
|
||||
imageVector = Icons.Default.Reply,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.padding(start = 8.dp).size(20.dp)
|
||||
)
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.padding(horizontal = 8.dp, vertical = 2.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "Ответ " + (if (replyMsg.senderId == viewModel.getCurrentUserId()) "самом себе" else replyMsg.senderName),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
Text(
|
||||
text = replyMsg.content ?: if (replyMsg.media.isNotEmpty()) "Медиа" else "...",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
IconButton(onClick = { viewModel.cancelReply() }) {
|
||||
Icon(Icons.Default.Close, contentDescription = "Cancel", modifier = Modifier.size(20.dp), tint = Color.Gray)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
@@ -628,11 +701,12 @@ fun ChatDetailScreen(
|
||||
)
|
||||
}
|
||||
|
||||
BasicTextField(
|
||||
BasicTextField(
|
||||
value = state.inputText,
|
||||
onValueChange = { viewModel.onInputTextChanged(it) },
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.focusRequester(focusRequester)
|
||||
.onFocusChanged {
|
||||
if (it.isFocused) {
|
||||
isEmojiPickerVisible = false
|
||||
|
||||
@@ -41,7 +41,8 @@ data class ChatDetailState(
|
||||
val pendingAttachments: List<File> = emptyList(),
|
||||
val isUploading: Boolean = false,
|
||||
val isCompressionEnabled: Boolean = true,
|
||||
val inputText: String = ""
|
||||
val inputText: String = "",
|
||||
val replyingMessage: Message? = null
|
||||
)
|
||||
|
||||
@HiltViewModel
|
||||
@@ -306,14 +307,24 @@ class ChatDetailViewModel @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
fun onReply(message: Message) {
|
||||
_state.update { it.copy(replyingMessage = message) }
|
||||
}
|
||||
|
||||
fun cancelReply() {
|
||||
_state.update { it.copy(replyingMessage = null) }
|
||||
}
|
||||
|
||||
fun sendMessage(onFail: (String) -> Unit = {}) {
|
||||
val chatId = currentChatId ?: return
|
||||
val text = _state.value.inputText
|
||||
val pending = _state.value.pendingAttachments
|
||||
val replyToId = _state.value.replyingMessage?.id
|
||||
if (text.isBlank() && pending.isEmpty()) return
|
||||
|
||||
// Clear input immediately to avoid double clicks and ensure UI experience
|
||||
_state.update { it.copy(inputText = "") }
|
||||
_state.update { it.copy(inputText = "", replyingMessage = null) }
|
||||
|
||||
val tempId = "temp_${System.currentTimeMillis()}"
|
||||
val userId = getCurrentUserId()
|
||||
@@ -374,7 +385,8 @@ class ChatDetailViewModel @Inject constructor(
|
||||
chatId = chatId,
|
||||
content = if (text.isBlank()) null else text,
|
||||
type = if (attachmentRequests != null) "media" else "text",
|
||||
attachments = attachmentRequests
|
||||
attachments = attachmentRequests,
|
||||
replyToId = replyToId
|
||||
)
|
||||
repository.deleteLocalMessage(tempId)
|
||||
repository.saveMessage(sentMessage)
|
||||
@@ -400,6 +412,9 @@ class ChatDetailViewModel @Inject constructor(
|
||||
|
||||
fun sendVoiceMessage(file: File) {
|
||||
val chatId = currentChatId ?: return
|
||||
val replyToId = _state.value.replyingMessage?.id
|
||||
_state.update { it.copy(replyingMessage = null) }
|
||||
|
||||
val tempId = "temp_voice_${System.currentTimeMillis()}"
|
||||
val userId = getCurrentUserId()
|
||||
|
||||
@@ -438,7 +453,8 @@ class ChatDetailViewModel @Inject constructor(
|
||||
chatId = chatId,
|
||||
content = null,
|
||||
type = "audio",
|
||||
attachments = listOf(attachment)
|
||||
attachments = listOf(attachment),
|
||||
replyToId = replyToId
|
||||
)
|
||||
|
||||
repository.deleteLocalMessage(tempId)
|
||||
@@ -560,6 +576,9 @@ class ChatDetailViewModel @Inject constructor(
|
||||
|
||||
fun sendGif(url: String) {
|
||||
val chatId = currentChatId ?: return
|
||||
val replyToId = _state.value.replyingMessage?.id
|
||||
_state.update { it.copy(replyingMessage = null) }
|
||||
|
||||
val tempId = "temp_gif_${System.currentTimeMillis()}"
|
||||
val userId = getCurrentUserId()
|
||||
|
||||
@@ -590,7 +609,7 @@ class ChatDetailViewModel @Inject constructor(
|
||||
fileName = "gif.gif",
|
||||
fileSize = 0
|
||||
)
|
||||
val sentMessage = repository.sendMessage(chatId, null, "image", listOf(attachment))
|
||||
val sentMessage = repository.sendMessage(chatId, null, "image", listOf(attachment), replyToId)
|
||||
|
||||
repository.deleteLocalMessage(tempId)
|
||||
repository.saveMessage(sentMessage)
|
||||
|
||||
@@ -50,6 +50,7 @@ fun MessageBubble(
|
||||
autoPlay: Boolean = false,
|
||||
initialPlaybackSpeed: Float = 1.0f,
|
||||
onVoiceFinished: (Float) -> Unit = {},
|
||||
onReplyClick: (Message) -> Unit = {},
|
||||
onMediaClick: (chats.domain.model.Media) -> Unit = {}
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
@@ -114,7 +115,7 @@ fun MessageBubble(
|
||||
.clip(RoundedCornerShape(4.dp))
|
||||
.background(contentColor.copy(alpha = 0.1f))
|
||||
.height(IntrinsicSize.Min)
|
||||
.clickable { /* Scroll to reply */ }
|
||||
.clickable { onReplyClick(reply) }
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
package chats.presentation.components
|
||||
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.gestures.Orientation
|
||||
import androidx.compose.foundation.gestures.draggable
|
||||
import androidx.compose.foundation.gestures.rememberDraggableState
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Reply
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
@Composable
|
||||
fun SwipeableMessageItem(
|
||||
onReply: () -> Unit,
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
val density = LocalDensity.current
|
||||
val scope = rememberCoroutineScope()
|
||||
val offsetX = remember { Animatable(0f) }
|
||||
val replyThreshold = with(density) { 60.dp.toPx() }
|
||||
val maxDrag = with(density) { 90.dp.toPx() }
|
||||
|
||||
var isTriggered by remember { mutableStateOf(false) }
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.draggable(
|
||||
orientation = Orientation.Horizontal,
|
||||
state = rememberDraggableState { delta ->
|
||||
scope.launch {
|
||||
// We only allow swiping to the left (negative delta)
|
||||
val newOffset = (offsetX.value + delta).coerceIn(-maxDrag, 0f)
|
||||
offsetX.snapTo(newOffset)
|
||||
|
||||
if (newOffset <= -replyThreshold && !isTriggered) {
|
||||
isTriggered = true
|
||||
// Trigger haptic feedback if available?
|
||||
} else if (newOffset > -replyThreshold) {
|
||||
isTriggered = false
|
||||
}
|
||||
}
|
||||
},
|
||||
onDragStopped = {
|
||||
if (offsetX.value <= -replyThreshold) {
|
||||
onReply()
|
||||
}
|
||||
scope.launch {
|
||||
offsetX.animateTo(0f)
|
||||
isTriggered = false
|
||||
}
|
||||
}
|
||||
)
|
||||
) {
|
||||
// Reply Icon (Underneath)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.align(Alignment.CenterEnd)
|
||||
.padding(end = 16.dp)
|
||||
.size(36.dp)
|
||||
.alpha(((-offsetX.value) / replyThreshold).coerceIn(0f, 1f))
|
||||
.clip(CircleShape)
|
||||
.background(if (isTriggered) MaterialTheme.colorScheme.primary else Color.Gray.copy(alpha = 0.2f)),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Reply,
|
||||
contentDescription = "Reply",
|
||||
tint = Color.White,
|
||||
modifier = Modifier.size(20.dp)
|
||||
)
|
||||
}
|
||||
|
||||
// Message Content
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.offset { IntOffset(offsetX.value.roundToInt(), 0) }
|
||||
.fillMaxWidth()
|
||||
) {
|
||||
content()
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user