diff --git a/client-mobile/.gradle/8.5/executionHistory/executionHistory.bin b/client-mobile/.gradle/8.5/executionHistory/executionHistory.bin index 77a9abf..c04d354 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 4531dc2..e5f550c 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 9aedcf8..e7f978b 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 f34885a..3d161e4 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 1d6b0f5..f71c130 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 45925c5..33b05b9 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 d4a9a28..f8b929d 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/data/remote/api/ChatApi.kt b/client-mobile/chats/data/remote/api/ChatApi.kt index b42c72d..31154bc 100644 --- a/client-mobile/chats/data/remote/api/ChatApi.kt +++ b/client-mobile/chats/data/remote/api/ChatApi.kt @@ -9,7 +9,8 @@ data class SendMessageRequest( val type: String = "text", val attachments: List? = 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 + + @PUT("messages/{messageId}") + suspend fun editMessage(@Path("messageId") messageId: String, @Body request: SendMessageRequest): MessageDto } data class CreatePersonalChatRequest( diff --git a/client-mobile/chats/data/remote/dto/ChatDtos.kt b/client-mobile/chats/data/remote/dto/ChatDtos.kt index 9eb296d..9dcb104 100644 --- a/client-mobile/chats/data/remote/dto/ChatDtos.kt +++ b/client-mobile/chats/data/remote/dto/ChatDtos.kt @@ -20,7 +20,10 @@ data class MessageDto( @SerializedName("sender", alternate = ["Sender"]) val sender: UserBasicDto? = null, @SerializedName("media", alternate = ["Media"]) val media: List = emptyList(), @SerializedName("reactions", alternate = ["Reactions"]) val reactions: List? = 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( diff --git a/client-mobile/chats/data/remote/signalr/ChatEvent.kt b/client-mobile/chats/data/remote/signalr/ChatEvent.kt index 664eb07..5c82b99 100644 --- a/client-mobile/chats/data/remote/signalr/ChatEvent.kt +++ b/client-mobile/chats/data/remote/signalr/ChatEvent.kt @@ -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() diff --git a/client-mobile/chats/data/remote/signalr/ChatHubClient.kt b/client-mobile/chats/data/remote/signalr/ChatHubClient.kt index c31d18a..3379e5f 100644 --- a/client-mobile/chats/data/remote/signalr/ChatHubClient.kt +++ b/client-mobile/chats/data/remote/signalr/ChatHubClient.kt @@ -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 -> + // 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") } } diff --git a/client-mobile/chats/data/repository/ChatRepositoryImpl.kt b/client-mobile/chats/data/repository/ChatRepositoryImpl.kt index a612e1e..371e825 100644 --- a/client-mobile/chats/data/repository/ChatRepositoryImpl.kt +++ b/client-mobile/chats/data/repository/ChatRepositoryImpl.kt @@ -68,13 +68,15 @@ class ChatRepositoryImpl @Inject constructor( content: String?, type: String, attachments: List?, - 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) + } } diff --git a/client-mobile/chats/data/repository/Mappers.kt b/client-mobile/chats/data/repository/Mappers.kt index 0927091..cd68a02 100644 --- a/client-mobile/chats/data/repository/Mappers.kt +++ b/client-mobile/chats/data/repository/Mappers.kt @@ -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) ) } diff --git a/client-mobile/chats/domain/model/Message.kt b/client-mobile/chats/domain/model/Message.kt index e77e1e0..514b293 100644 --- a/client-mobile/chats/domain/model/Message.kt +++ b/client-mobile/chats/domain/model/Message.kt @@ -13,6 +13,9 @@ data class Message( val mediaType: MediaType = MediaType.TEXT, val reactions: Map = emptyMap(), val isRead: Boolean = false, + val isPinned: Boolean = false, + val isForwarded: Boolean = false, + val forwardedFromName: String? = null, val replyTo: Message? = null ) diff --git a/client-mobile/chats/domain/repository/ChatRepository.kt b/client-mobile/chats/domain/repository/ChatRepository.kt index 8c93e32..c5b620a 100644 --- a/client-mobile/chats/domain/repository/ChatRepository.kt +++ b/client-mobile/chats/domain/repository/ChatRepository.kt @@ -12,7 +12,8 @@ interface ChatRepository { content: String?, type: String = "text", attachments: List? = 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 suspend fun getGifCategories(): List suspend fun createPersonalChat(userId: String): Chat + suspend fun deleteMessage(messageId: String, forEveryone: Boolean) + suspend fun editMessage(messageId: String, content: String): Message } diff --git a/client-mobile/chats/presentation/chat_detail/ChatDetailScreen.kt b/client-mobile/chats/presentation/chat_detail/ChatDetailScreen.kt index a17fb59..071dc32 100644 --- a/client-mobile/chats/presentation/chat_detail/ChatDetailScreen.kt +++ b/client-mobile/chats/presentation/chat_detail/ChatDetailScreen.kt @@ -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, + 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 { diff --git a/client-mobile/chats/presentation/chat_detail/ChatDetailViewModel.kt b/client-mobile/chats/presentation/chat_detail/ChatDetailViewModel.kt index 2a0596f..183869e 100644 --- a/client-mobile/chats/presentation/chat_detail/ChatDetailViewModel.kt +++ b/client-mobile/chats/presentation/chat_detail/ChatDetailViewModel.kt @@ -43,7 +43,11 @@ data class ChatDetailState( val isCompressionEnabled: Boolean = true, val inputText: String = "", val replyingMessage: Message? = null, - val selectedMessageIds: Set = emptySet() + val editingMessage: Message? = null, + val forwardingMessages: List = emptyList(), + val availableChatsToForward: List = emptyList(), + val selectedMessageIds: Set = emptySet(), + val pinnedMessages: List = 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) { + 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) { diff --git a/client-mobile/chats/presentation/components/MessageBubble.kt b/client-mobile/chats/presentation/components/MessageBubble.kt index daf9988..8aa7d46 100644 --- a/client-mobile/chats/presentation/components/MessageBubble.kt +++ b/client-mobile/chats/presentation/components/MessageBubble.kt @@ -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))