Реакции, но с багом

This commit is contained in:
Халимов Рустам
2026-04-18 00:41:05 +03:00
parent b68f68a1f2
commit 629fddfca0
18 changed files with 471 additions and 26 deletions
@@ -270,10 +270,14 @@ fun ChatDetailScreen(
}
},
actions = {
IconButton(onClick = { /* TODO: Forward */ }) {
IconButton(onClick = {
viewModel.onForwardSelectedMessages()
}) {
Icon(Icons.Default.Forward, contentDescription = "Forward")
}
IconButton(onClick = { /* TODO: Delete */ }) {
IconButton(onClick = {
// TODO: Bulk Delete
}) {
Icon(Icons.Default.Delete, contentDescription = "Delete")
}
}
@@ -363,6 +367,7 @@ fun ChatDetailScreen(
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) },
onReply = { msg -> viewModel.onReply(msg) },
onReplyClick = { reply ->
val index = listItems.indexOfFirst {
it is MessageListItem.MessageItem && it.message.id == reply.id
@@ -372,6 +377,10 @@ fun ChatDetailScreen(
}
},
onSelect = { viewModel.toggleSelection(it) },
onForward = { viewModel.onForward(it) },
onPin = { viewModel.onPin(it) },
onEdit = { viewModel.onEdit(it) },
onDelete = { msg, forEveryone -> viewModel.deleteMessage(msg, forEveryone) },
isSelected = state.selectedMessageIds.contains(item.message.id),
isSelectionMode = state.selectedMessageIds.isNotEmpty(),
onMediaClick = { clickedMedia ->
@@ -725,6 +734,52 @@ fun ChatDetailScreen(
}
}
// Edit Preview Bar
state.editingMessage?.let { editMsg ->
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.Edit,
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 = "Редактирование",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.primary,
fontWeight = FontWeight.Bold
)
Text(
text = editMsg.content ?: "",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
IconButton(onClick = { viewModel.cancelEdit() }) {
Icon(Icons.Default.Close, contentDescription = "Cancel", modifier = Modifier.size(20.dp), tint = Color.Gray)
}
}
}
Row(
modifier = Modifier
.fillMaxWidth()
@@ -929,6 +984,52 @@ fun ChatDetailScreen(
onClose = { selectedMediaList = null }
)
}
if (state.forwardingMessages.isNotEmpty()) {
ForwardChatSelectionDialog(
chats = state.availableChatsToForward,
onChatSelected = { targetChatId ->
viewModel.forwardMessages(targetChatId, state.forwardingMessages)
viewModel.cancelForwarding()
viewModel.clearSelection()
},
onDismiss = { viewModel.cancelForwarding() }
)
}
}
@Composable
fun ForwardChatSelectionDialog(
chats: List<chats.domain.model.Chat>,
onChatSelected: (String) -> Unit,
onDismiss: () -> Unit
) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Выберите чат для пересылки") },
text = {
LazyColumn(modifier = Modifier.fillMaxWidth().heightIn(max = 400.dp)) {
items(chats) { chat ->
Row(
modifier = Modifier
.fillMaxWidth()
.clickable { onChatSelected(chat.id) }
.padding(vertical = 12.dp, horizontal = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
AppAvatar(url = chat.avatar, name = chat.name, size = 40.dp)
Spacer(Modifier.width(12.dp))
Text(chat.name, style = MaterialTheme.typography.bodyLarge)
}
Divider(color = Color.Gray.copy(alpha = 0.2f))
}
}
},
confirmButton = {},
dismissButton = {
TextButton(onClick = onDismiss) { Text("Отмена") }
}
)
}
sealed class MessageListItem {
@@ -43,7 +43,11 @@ data class ChatDetailState(
val isCompressionEnabled: Boolean = true,
val inputText: String = "",
val replyingMessage: Message? = null,
val selectedMessageIds: Set<String> = emptySet()
val editingMessage: Message? = null,
val forwardingMessages: List<Message> = emptyList(),
val availableChatsToForward: List<chats.domain.model.Chat> = emptyList(),
val selectedMessageIds: Set<String> = emptySet(),
val pinnedMessages: List<Message> = emptyList()
)
@HiltViewModel
@@ -187,6 +191,25 @@ class ChatDetailViewModel @Inject constructor(
s.copy(messages = updatedMessages)
}
}
// For handling reaction removed event
private fun removeMessageReaction(messageId: String, userId: String, emoji: String) {
_state.update { s ->
val updatedMessages = s.messages.map { msg ->
if (msg.id == messageId) {
val currentReactions = msg.reactions.toMutableMap()
val count = currentReactions[emoji] ?: 0
if (count > 1) {
currentReactions[emoji] = count - 1
} else {
currentReactions.remove(emoji)
}
msg.copy(reactions = currentReactions)
} else msg
}
s.copy(messages = updatedMessages)
}
}
private fun observeSignalREvents(chatId: String) {
signalrEventsJob?.cancel()
@@ -222,7 +245,24 @@ class ChatDetailViewModel @Inject constructor(
}
}
is ChatEvent.ReactionUpdated -> {
updateMessageReaction(event.messageId, event.userId, event.emoji)
if (event.isRemoved) {
removeMessageReaction(event.messageId, event.userId, event.emoji)
} else {
updateMessageReaction(event.messageId, event.userId, event.emoji)
}
}
is ChatEvent.MessageDeleted -> {
_state.update { currentState ->
currentState.copy(messages = currentState.messages.filter { it.id != event.messageId })
}
}
is ChatEvent.MessageEdited -> {
_state.update { currentState ->
val updated = currentState.messages.map { msg ->
if (msg.id == event.messageId) msg.copy(content = event.content) else msg
}
currentState.copy(messages = updated)
}
}
is ChatEvent.UserTyping -> {
_state.update { it.copy(isTyping = true) }
@@ -245,6 +285,18 @@ class ChatDetailViewModel @Inject constructor(
currentState.copy(messages = updatedMessages)
}
}
is ChatEvent.MessagePinned -> {
val baseUrl = serverConfig.getBaseUrl().removeSuffix("/api/")
_state.update { s ->
val domainMsg = event.message.toDomain(getCurrentUserId(), baseUrl)
s.copy(pinnedMessages = (s.pinnedMessages + domainMsg).distinctBy { it.id })
}
}
is ChatEvent.MessageUnpinned -> {
_state.update { s ->
s.copy(pinnedMessages = s.pinnedMessages.filter { it.id != event.messageId })
}
}
else -> Unit
}
}
@@ -313,6 +365,46 @@ class ChatDetailViewModel @Inject constructor(
_state.update { it.copy(replyingMessage = message) }
}
fun forwardMessages(targetChatId: String, messages: List<Message>) {
viewModelScope.launch {
messages.forEach { msg ->
repository.sendMessage(
chatId = targetChatId,
content = msg.content,
type = msg.mediaType.name.lowercase(),
forwardedFromId = msg.senderId
)
}
}
}
fun onForward(message: Message) {
_state.update { it.copy(forwardingMessages = listOf(message)) }
loadChatsForForwarding()
}
fun loadChatsForForwarding() {
viewModelScope.launch {
try {
val chats = repository.getChats()
_state.update { it.copy(availableChatsToForward = chats) }
} catch (e: Exception) {
// Ignore
}
}
}
fun cancelForwarding() {
_state.update { it.copy(forwardingMessages = emptyList(), availableChatsToForward = emptyList()) }
}
fun onForwardSelectedMessages() {
val selectedIds = _state.value.selectedMessageIds
val messages = _state.value.messages.filter { selectedIds.contains(it.id) }
_state.update { it.copy(forwardingMessages = messages) }
loadChatsForForwarding()
}
fun toggleSelection(messageId: String) {
_state.update { s ->
val newSelection = if (s.selectedMessageIds.contains(messageId)) {
@@ -332,13 +424,70 @@ class ChatDetailViewModel @Inject constructor(
_state.update { it.copy(replyingMessage = null) }
}
fun cancelEdit() {
_state.update { it.copy(editingMessage = null, inputText = "") }
}
fun deleteMessage(message: Message, forEveryone: Boolean) {
viewModelScope.launch {
try {
repository.deleteMessage(message.id, forEveryone)
// Local update if needed (will also come via SignalR for everyone, but forMe might need local only update)
_state.update { currentState ->
currentState.copy(messages = currentState.messages.filter { it.id != message.id })
}
} catch (e: Exception) {
_state.update { it.copy(error = "Delete failed: ${e.localizedMessage}") }
}
}
}
fun onEdit(message: Message) {
_state.update { it.copy(editingMessage = message, inputText = message.content ?: "", replyingMessage = null) }
}
fun pinMessage(messageId: String) {
val chatId = currentChatId ?: return
signalrClient.pinMessage(messageId, chatId)
}
fun unpinMessage(messageId: String) {
val chatId = currentChatId ?: return
signalrClient.unpinMessage(messageId, chatId)
}
fun onPin(message: Message) {
pinMessage(message.id)
}
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
val editingMsg = _state.value.editingMessage
if (text.isBlank() && pending.isEmpty()) return
// Handle Edit
if (editingMsg != null) {
_state.update { it.copy(inputText = "", editingMessage = null) }
viewModelScope.launch {
try {
val updated = repository.editMessage(editingMsg.id, text)
_state.update { currentState ->
val updatedList = currentState.messages.map {
if (it.id == updated.id) updated else it
}
currentState.copy(messages = updatedList)
}
} catch (e: Exception) {
_state.update { it.copy(error = "Edit failed: ${e.localizedMessage}") }
onFail(text)
}
}
return
}
// Clear input immediately to avoid double clicks and ensure UI experience
_state.update { it.copy(inputText = "", replyingMessage = null) }
@@ -417,13 +566,11 @@ class ChatDetailViewModel @Inject constructor(
}
fun addReaction(messageId: String, emoji: String) {
viewModelScope.launch {
try {
repository.addReaction(messageId, emoji)
} catch (e: Exception) {
// Ignore
}
}
val chatId = currentChatId ?: return
// We rely on SignalR event to update the count to avoid double counting
// especially since domain Message doesn't track user IDs for reactions yet.
signalrClient.addReaction(messageId, chatId, emoji)
}
fun sendVoiceMessage(file: File) {