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

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

View File

@@ -9,7 +9,8 @@ data class SendMessageRequest(
val type: String = "text",
val attachments: List<AttachmentRequest>? = null,
val replyToId: String? = null,
val quote: String? = null
val quote: String? = null,
val forwardedFromId: String? = null
)
data class AttachmentRequest(
@@ -62,6 +63,12 @@ interface ChatApi {
@POST("chats/{chatId}/read")
suspend fun markMessagesAsRead(@Path("chatId") chatId: String, @Body lastMessageId: String)
@DELETE("messages/{messageId}")
suspend fun deleteMessage(@Path("messageId") messageId: String, @Query("forEveryone") forEveryone: Boolean): retrofit2.Response<Unit>
@PUT("messages/{messageId}")
suspend fun editMessage(@Path("messageId") messageId: String, @Body request: SendMessageRequest): MessageDto
}
data class CreatePersonalChatRequest(

View File

@@ -20,7 +20,10 @@ data class MessageDto(
@SerializedName("sender", alternate = ["Sender"]) val sender: UserBasicDto? = null,
@SerializedName("media", alternate = ["Media"]) val media: List<MediaItemDto> = emptyList(),
@SerializedName("reactions", alternate = ["Reactions"]) val reactions: List<ReactionDto>? = emptyList(),
@SerializedName("replyTo", alternate = ["ReplyTo"]) val replyTo: MessageDto? = null
@SerializedName("replyTo", alternate = ["ReplyTo"]) val replyTo: MessageDto? = null,
@SerializedName("isPinned", alternate = ["IsPinned"]) val isPinned: Boolean? = false,
@SerializedName("forwardedFromId", alternate = ["ForwardedFromId"]) val forwardedFromId: String? = null,
@SerializedName("forwardedFrom", alternate = ["ForwardedFrom"]) val forwardedFrom: UserBasicDto? = null
)
data class ReactionDto(

View File

@@ -13,7 +13,9 @@ sealed class ChatEvent {
data class UserOnline(val userId: String) : ChatEvent()
data class UserOffline(val userId: String, val lastSeen: String?) : ChatEvent()
data class NewChat(val chat: ChatDto) : ChatEvent()
data class ReactionUpdated(val messageId: String, val chatId: String, val userId: String, val emoji: String) : ChatEvent()
data class ReactionUpdated(val messageId: String, val chatId: String, val userId: String, val emoji: String, val isRemoved: Boolean = false) : ChatEvent()
data class MessagePinned(val chatId: String, val message: MessageDto) : ChatEvent()
data class MessageUnpinned(val chatId: String, val messageId: String) : ChatEvent()
// Call Events (WebRTC Signaling)
data class CallIncoming(val chatId: String, val from: String, val offer: String, val callType: String) : ChatEvent()

View File

@@ -95,6 +95,14 @@ class ChatHubClient @Inject constructor() {
_events.tryEmit(ChatEvent.NewMessage(message))
}, MessageDto::class.java)
conn.on("message_edited", { messageId: String, chatId: String, content: String ->
_events.tryEmit(ChatEvent.MessageEdited(messageId, chatId, content))
}, String::class.java, String::class.java, String::class.java)
conn.on("message_deleted", { messageId: String, chatId: String ->
_events.tryEmit(ChatEvent.MessageDeleted(messageId, chatId))
}, String::class.java, String::class.java)
conn.on("messages_read", { data: MessagesReadEvent ->
_events.tryEmit(ChatEvent.MessagesRead(
data.effectiveChatId,
@@ -124,7 +132,8 @@ class ChatHubClient @Inject constructor() {
data.messageId ?: "",
data.chatId ?: "",
data.userId ?: "",
data.emoji ?: ""
data.emoji ?: "",
isRemoved = false
))
}, ReactionEvent::class.java)
@@ -133,7 +142,8 @@ class ChatHubClient @Inject constructor() {
data.messageId ?: "",
data.chatId ?: "",
data.userId ?: "",
"" // empty emoji signals removal
data.emoji ?: "",
isRemoved = true
))
}, ReactionEvent::class.java)
@@ -153,6 +163,20 @@ class ChatHubClient @Inject constructor() {
conn.on("call_ended", { chatId: String ->
_events.tryEmit(ChatEvent.CallEnded(chatId))
}, String::class.java)
conn.on("message_pinned", { data: Map<String, Any> ->
// The web version expects a message object, but here we might get a partial DTO or just IDs.
// Let's assume we get { chatId, message: MessageDto } based on web
// We'll trust the DTO mapping if possible, but SignalR java client is picky with nested objects in Maps.
// For simplicity, we might needs a dedicated DTO if it fails.
}, Map::class.java)
conn.on("message_pinned", { chatId: String, message: MessageDto ->
_events.tryEmit(ChatEvent.MessagePinned(chatId, message))
}, String::class.java, MessageDto::class.java)
conn.on("message_unpinned", { chatId: String, messageId: String ->
_events.tryEmit(ChatEvent.MessageUnpinned(chatId, messageId))
}, String::class.java, String::class.java)
}
}
@@ -161,9 +185,57 @@ class ChatHubClient @Inject constructor() {
_status.value = ConnectionStatus.DISCONNECTED
}
fun addReaction(messageId: String, chatId: String, emoji: String) {
if (hubConnection?.connectionState == HubConnectionState.CONNECTED) {
hubConnection?.invoke("add_reaction", mapOf(
"messageId" to messageId,
"chatId" to chatId,
"emoji" to emoji
))?.doOnError { Log.e("ChatHubClient", "add_reaction error", it) }
?.subscribe()
Log.d("ChatHubClient", "Invoked add_reaction: $emoji on $messageId")
} else {
Log.w("ChatHubClient", "Cannot add_reaction: Not connected")
}
}
fun removeReaction(messageId: String, chatId: String, emoji: String) {
if (hubConnection?.connectionState == HubConnectionState.CONNECTED) {
hubConnection?.invoke("remove_reaction", mapOf(
"messageId" to messageId,
"chatId" to chatId,
"emoji" to emoji
))?.doOnError { Log.e("ChatHubClient", "remove_reaction error", it) }
?.subscribe()
Log.d("ChatHubClient", "Invoked remove_reaction: $emoji on $messageId")
}
}
fun pinMessage(messageId: String, chatId: String) {
if (hubConnection?.connectionState == HubConnectionState.CONNECTED) {
hubConnection?.invoke("pin_message", mapOf(
"messageId" to messageId,
"chatId" to chatId
))?.doOnError { Log.e("ChatHubClient", "pin_message error", it) }
?.subscribe()
}
}
fun unpinMessage(messageId: String, chatId: String) {
if (hubConnection?.connectionState == HubConnectionState.CONNECTED) {
hubConnection?.invoke("unpin_message", mapOf(
"messageId" to messageId,
"chatId" to chatId
))?.doOnError { Log.e("ChatHubClient", "unpin_message error", it) }
?.subscribe()
}
}
fun readMessages(request: ReadMessagesRequest) {
if (hubConnection?.connectionState == HubConnectionState.CONNECTED) {
hubConnection?.send("read_messages", request)
hubConnection?.invoke("read_messages", request)
?.doOnError { Log.e("ChatHubClient", "read_messages error", it) }
?.subscribe()
Log.d("ChatHubClient", "Sent read_messages for chat: ${request.chatId}")
}
}
@@ -178,7 +250,9 @@ class ChatHubClient @Inject constructor() {
}
if (hubConnection?.connectionState == HubConnectionState.CONNECTED) {
hubConnection?.send("join_chat", chatId)
hubConnection?.invoke("join_chat", chatId)
?.doOnError { Log.e("ChatHubClient", "join_chat error", it) }
?.subscribe()
Log.d("ChatHubClient", "Joined chat room: $chatId")
} else {
Log.e("ChatHubClient", "Failed to join chat room $chatId: Not connected")
@@ -188,14 +262,18 @@ class ChatHubClient @Inject constructor() {
fun sendTypingIndicator(chatId: String) {
if (hubConnection?.connectionState == HubConnectionState.CONNECTED) {
hubConnection?.send("typing_start", chatId)
hubConnection?.invoke("typing_start", chatId)
?.doOnError { Log.e("ChatHubClient", "typing_start error", it) }
?.subscribe()
Log.d("ChatHubClient", "Sent typing indicator for chat: $chatId")
}
}
fun sendUserStoppedTyping(chatId: String) {
if (hubConnection?.connectionState == HubConnectionState.CONNECTED) {
hubConnection?.send("typing_stop", chatId)
hubConnection?.invoke("typing_stop", chatId)
?.doOnError { Log.e("ChatHubClient", "typing_stop error", it) }
?.subscribe()
Log.d("ChatHubClient", "Sent user stopped typing for chat: $chatId")
}
}

View File

@@ -68,13 +68,15 @@ class ChatRepositoryImpl @Inject constructor(
content: String?,
type: String,
attachments: List<chats.data.remote.api.AttachmentRequest>?,
replyToId: String?
replyToId: String?,
forwardedFromId: String?
): Message {
val request = SendMessageRequest(
content = content,
type = type,
attachments = attachments,
replyToId = replyToId
replyToId = replyToId,
forwardedFromId = forwardedFromId
)
val baseUrl = serverConfig.getBaseUrl().removeSuffix("/api/")
val userId = tokenManager.getUserId() ?: ""
@@ -140,4 +142,16 @@ class ChatRepositoryImpl @Inject constructor(
val request = chats.data.remote.api.CreatePersonalChatRequest(userId)
return api.createPersonalChat(request).toDomain(currentUserId, baseUrl)
}
override suspend fun deleteMessage(messageId: String, forEveryone: Boolean) {
api.deleteMessage(messageId, forEveryone)
messageDao.deleteMessage(messageId)
}
override suspend fun editMessage(messageId: String, content: String): Message {
val request = SendMessageRequest(content = content)
val currentUserId = tokenManager.getUserId() ?: ""
val baseUrl = serverConfig.getBaseUrl().removeSuffix("/api/")
return api.editMessage(messageId, request).toDomain(currentUserId, baseUrl)
}
}

View File

@@ -79,6 +79,9 @@ fun MessageDto.toDomain(currentUserId: String, baseUrl: String): Message {
mediaType = domainMediaType,
reactions = reactions?.associate { it.emoji to it.count } ?: emptyMap(),
isRead = senderId == currentUserId,
isPinned = isPinned ?: false,
isForwarded = forwardedFromId != null,
forwardedFromName = forwardedFrom?.displayName ?: forwardedFrom?.username,
replyTo = replyTo?.toDomain(currentUserId, baseUrl)
)
}

View File

@@ -13,6 +13,9 @@ data class Message(
val mediaType: MediaType = MediaType.TEXT,
val reactions: Map<String, Int> = emptyMap(),
val isRead: Boolean = false,
val isPinned: Boolean = false,
val isForwarded: Boolean = false,
val forwardedFromName: String? = null,
val replyTo: Message? = null
)

View File

@@ -12,7 +12,8 @@ interface ChatRepository {
content: String?,
type: String = "text",
attachments: List<chats.data.remote.api.AttachmentRequest>? = null,
replyToId: String? = null
replyToId: String? = null,
forwardedFromId: String? = null
): Message
suspend fun addReaction(messageId: String, emoji: String)
suspend fun sendTypingStatus(chatId: String)
@@ -24,5 +25,7 @@ interface ChatRepository {
suspend fun searchGifs(query: String, page: Int = 0): List<chats.data.remote.api.KlipyGifDto>
suspend fun getGifCategories(): List<chats.data.remote.api.GifCategoryDto>
suspend fun createPersonalChat(userId: String): Chat
suspend fun deleteMessage(messageId: String, forEveryone: Boolean)
suspend fun editMessage(messageId: String, content: String): Message
}

View File

@@ -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 {

View File

@@ -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) {

View File

@@ -15,6 +15,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.layout.onGloballyPositioned
@@ -57,8 +58,13 @@ fun MessageBubble(
autoPlay: Boolean = false,
initialPlaybackSpeed: Float = 1.0f,
onVoiceFinished: (Float) -> Unit = {},
onReply: (Message) -> Unit = {},
onReplyClick: (Message) -> Unit = {},
onSelect: (String) -> Unit = {},
onForward: (Message) -> Unit = {},
onPin: (Message) -> Unit = {},
onEdit: (Message) -> Unit = {},
onDelete: (Message, Boolean) -> Unit = { _, _ -> },
isSelected: Boolean = false,
isSelectionMode: Boolean = false,
onMediaClick: (chats.domain.model.Media) -> Unit = {}
@@ -69,6 +75,7 @@ fun MessageBubble(
val contentColor = Color.White
var showContextMenu by remember { mutableStateOf(false) }
var showDeleteDialog by remember { mutableStateOf(false) }
val backgroundColor = when {
isSelected -> if (isCurrentUser) Color(0xFF3096E5).copy(alpha = 0.5f) else Color(0xFF212121).copy(alpha = 0.5f)
@@ -156,6 +163,8 @@ fun MessageBubble(
)
) {
MessageContextMenu(
isMyMessage = isCurrentUser,
isPinned = message.isPinned,
onReactionSelected = {
onReactionClick(it)
showContextMenu = false
@@ -163,19 +172,36 @@ fun MessageBubble(
onAction = { action ->
showContextMenu = false
when(action) {
"reply" -> onReplyClick(message)
"reply" -> onReply(message)
"select" -> onSelect(message.id)
"forward" -> onForward(message)
"pin" -> onPin(message)
"edit" -> onEdit(message)
"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)
}
"delete" -> {
showDeleteDialog = true
}
}
}
)
}
}
// Forwarded Info
if (message.isForwarded) {
Text(
text = "Forwarded from ${message.forwardedFromName ?: "Unknown"}",
style = MaterialTheme.typography.labelSmall,
color = contentColor.copy(alpha = 0.7f),
fontStyle = androidx.compose.ui.text.font.FontStyle.Italic,
modifier = Modifier.padding(bottom = 4.dp)
)
}
// Reply Info
message.replyTo?.let { reply ->
Row(
@@ -447,6 +473,14 @@ fun MessageBubble(
color = contentColor.copy(alpha = 0.6f),
fontSize = 11.sp
)
if (message.isPinned) {
Icon(
imageVector = Icons.Default.PushPin,
contentDescription = "Pinned",
modifier = Modifier.size(12.dp).padding(start = 2.dp).graphicsLayer { rotationZ = 45f },
tint = contentColor.copy(alpha = 0.6f)
)
}
if (isCurrentUser) {
Spacer(modifier = Modifier.width(2.dp))
Icon(
@@ -481,6 +515,48 @@ fun MessageBubble(
onClose = { lightboxMediaIndex = null }
)
}
if (showDeleteDialog) {
var deleteForEveryone by remember { mutableStateOf(false) }
AlertDialog(
onDismissRequest = { showDeleteDialog = false },
title = { Text("Удалить сообщение?") },
text = {
Column {
Text("Вы уверены, что хотите удалить это сообщение?")
if (isCurrentUser) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(top = 8.dp).clickable { deleteForEveryone = !deleteForEveryone }
) {
Checkbox(
checked = deleteForEveryone,
onCheckedChange = { deleteForEveryone = it }
)
Text("Удалить у всех")
}
}
}
},
confirmButton = {
TextButton(
onClick = {
onDelete(message, deleteForEveryone)
showDeleteDialog = false
},
colors = ButtonDefaults.textButtonColors(contentColor = Color(0xFFE53935))
) {
Text("Удалить")
}
},
dismissButton = {
TextButton(onClick = { showDeleteDialog = false }) {
Text("Отмена")
}
}
)
}
}
@Composable
@@ -645,6 +721,8 @@ fun GridItem(media: Media, modifier: Modifier = Modifier) {
@Composable
fun MessageContextMenu(
isMyMessage: Boolean,
isPinned: Boolean,
onReactionSelected: (String) -> Unit,
onAction: (String) -> Unit
) {
@@ -712,9 +790,15 @@ fun MessageContextMenu(
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(
icon = Icons.Default.PushPin,
text = if (isPinned) "Открепить" else "Закрепить",
onClick = { onAction("pin") }
)
ContextMenuItem(Icons.Default.ContentCopy, "Копировать", onClick = { onAction("copy") })
ContextMenuItem(Icons.Default.Edit, "Редактировать", onClick = { onAction("edit") })
if (isMyMessage) {
ContextMenuItem(Icons.Default.Edit, "Редактировать", onClick = { onAction("edit") })
}
Divider(color = Color.White.copy(alpha = 0.1f))