From 876d64d345ea8a517a461995b5125e869cdb6fe4 Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 9 Mar 2026 13:49:42 +0300 Subject: [PATCH] android: enable multi-select forward execution in chat --- android/CHANGELOG.md | 6 ++ .../messenger/ui/chat/ChatScreen.kt | 68 +++++++++++++----- .../messenger/ui/chat/ChatViewModel.kt | 71 +++++++++++++------ .../messenger/ui/chat/MessageUiState.kt | 2 +- 4 files changed, 109 insertions(+), 38 deletions(-) diff --git a/android/CHANGELOG.md b/android/CHANGELOG.md index fb57738..85b6c1f 100644 --- a/android/CHANGELOG.md +++ b/android/CHANGELOG.md @@ -176,3 +176,9 @@ - Integrated `ChatViewModel` with reducer-backed selection logic while preserving current UI behavior. - Added base ViewModel handlers for entering/toggling multi-select mode (`onEnterMultiSelect`, `onToggleMessageMultiSelection`, `onClearSelection`). - Added unit tests for reducer transitions and available intents (`MessageActionStateTest`). + +### Step 28 - Core base / Android multi-forward execution +- Switched chat forward state from single-message payload to `forwardingMessageIds` set. +- Extended `ChatViewModel` forward flow: multi-select now forwards multiple source messages in one action. +- Wired `ForwardMessageBulkUseCase` for multi-message forwarding (sequential safe execution with error short-circuit). +- Updated chat action bar and forward sheet labels for multi-selection count. diff --git a/android/app/src/main/java/ru/daemonlord/messenger/ui/chat/ChatScreen.kt b/android/app/src/main/java/ru/daemonlord/messenger/ui/chat/ChatScreen.kt index a3ee84d..0a0bb2b 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/ui/chat/ChatScreen.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/ui/chat/ChatScreen.kt @@ -74,6 +74,9 @@ fun ChatRoute( onInputChanged = viewModel::onInputChanged, onSendClick = viewModel::onSendClick, onSelectMessage = viewModel::onSelectMessage, + onEnterMultiSelect = viewModel::onEnterMultiSelect, + onToggleMessageMultiSelection = viewModel::onToggleMessageMultiSelection, + onClearSelection = viewModel::onClearSelection, onReplySelected = viewModel::onReplySelected, onEditSelected = viewModel::onEditSelected, onDeleteSelected = viewModel::onDeleteSelected, @@ -94,6 +97,9 @@ fun ChatScreen( onInputChanged: (String) -> Unit, onSendClick: () -> Unit, onSelectMessage: (MessageItem?) -> Unit, + onEnterMultiSelect: (MessageItem) -> Unit, + onToggleMessageMultiSelection: (MessageItem) -> Unit, + onClearSelection: () -> Unit, onReplySelected: (MessageItem) -> Unit, onEditSelected: (MessageItem) -> Unit, onDeleteSelected: (Boolean) -> Unit, @@ -145,43 +151,66 @@ fun ChatScreen( verticalArrangement = Arrangement.spacedBy(6.dp), ) { items(state.messages, key = { it.id }) { message -> + val isSelected = state.actionState.selectedMessageIds.contains(message.id) MessageBubble( message = message, - isSelected = state.selectedMessage?.id == message.id, + isSelected = isSelected, reactions = state.reactionByMessageId[message.id].orEmpty(), onAttachmentImageClick = { imageUrl -> viewerImageUrl = imageUrl }, - onLongPress = { onSelectMessage(message) }, + onClick = { + if (state.actionState.mode == MessageSelectionMode.MULTI) { + onToggleMessageMultiSelection(message) + } + }, + onLongPress = { + if (state.actionState.mode == MessageSelectionMode.MULTI) { + onToggleMessageMultiSelection(message) + } else { + onEnterMultiSelect(message) + } + }, ) } } } } - if (state.selectedMessage != null) { + if (state.actionState.hasSelection) { Row( modifier = Modifier .fillMaxWidth() .padding(horizontal = 12.dp, vertical = 6.dp), horizontalArrangement = Arrangement.spacedBy(8.dp), ) { - Button(onClick = { onReplySelected(state.selectedMessage) }) { Text("Reply") } - Button( - onClick = { onEditSelected(state.selectedMessage) }, - enabled = state.selectedCanEdit, - ) { Text("Edit") } + Text( + text = "${state.actionState.selectedCount} selected", + style = MaterialTheme.typography.labelLarge, + modifier = Modifier.padding(top = 12.dp), + ) + if (state.actionState.mode == MessageSelectionMode.SINGLE && state.selectedMessage != null) { + Button(onClick = { onReplySelected(state.selectedMessage) }) { Text("Reply") } + Button( + onClick = { onEditSelected(state.selectedMessage) }, + enabled = state.selectedCanEdit, + ) { Text("Edit") } + } Button(onClick = { onDeleteSelected(false) }) { Text("Delete") } Button( onClick = { onDeleteSelected(true) }, - enabled = state.selectedCanDeleteForAll, + enabled = state.actionState.mode == MessageSelectionMode.SINGLE && state.selectedCanDeleteForAll, ) { Text("Del for all") } - Button(onClick = onForwardSelected) { Text("Forward") } - Button(onClick = { onToggleReaction("\uD83D\uDC4D") }) { Text("\uD83D\uDC4D") } - Button(onClick = { onToggleReaction("\uD83D\uDE02") }) { Text("\uD83D\uDE02") } - Button(onClick = { onSelectMessage(null) }) { Text("Close") } + Button(onClick = onForwardSelected) { + Text(if (state.actionState.mode == MessageSelectionMode.MULTI) "Forward selected" else "Forward") + } + if (state.actionState.mode == MessageSelectionMode.SINGLE) { + Button(onClick = { onToggleReaction("\uD83D\uDC4D") }) { Text("\uD83D\uDC4D") } + Button(onClick = { onToggleReaction("\uD83D\uDE02") }) { Text("\uD83D\uDE02") } + } + Button(onClick = onClearSelection) { Text("Close") } } } - if (state.forwardingMessage != null) { + if (state.forwardingMessageIds.isNotEmpty()) { Column( modifier = Modifier .fillMaxWidth() @@ -190,7 +219,11 @@ fun ChatScreen( verticalArrangement = Arrangement.spacedBy(6.dp), ) { Text( - text = "Forward message #${state.forwardingMessage.id}", + text = if (state.forwardingMessageIds.size == 1) { + "Forward message #${state.forwardingMessageIds.first()}" + } else { + "Forward ${state.forwardingMessageIds.size} messages" + }, style = MaterialTheme.typography.labelLarge, ) if (state.availableForwardTargets.isEmpty()) { @@ -215,7 +248,7 @@ fun ChatScreen( val header = if (state.editingMessage != null) { "Editing message #${state.editingMessage.id}" } else { - "Reply to #${state.replyToMessage?.id}" + "Reply to ${state.replyToMessage?.senderDisplayName ?: "#${state.replyToMessage?.id}"}" } Row( modifier = Modifier @@ -336,6 +369,7 @@ private fun MessageBubble( isSelected: Boolean, reactions: List, onAttachmentImageClick: (String) -> Unit, + onClick: () -> Unit, onLongPress: () -> Unit, ) { val isOutgoing = message.isOutgoing @@ -353,7 +387,7 @@ private fun MessageBubble( if (isSelected) MaterialTheme.colorScheme.tertiaryContainer else bubbleColor ) .combinedClickable( - onClick = {}, + onClick = onClick, onLongClick = onLongPress, ) .padding(horizontal = 10.dp, vertical = 8.dp), diff --git a/android/app/src/main/java/ru/daemonlord/messenger/ui/chat/ChatViewModel.kt b/android/app/src/main/java/ru/daemonlord/messenger/ui/chat/ChatViewModel.kt index a7c47aa..13060cb 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/ui/chat/ChatViewModel.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/ui/chat/ChatViewModel.kt @@ -18,6 +18,7 @@ import ru.daemonlord.messenger.domain.common.AppResult import ru.daemonlord.messenger.domain.message.model.MessageItem import ru.daemonlord.messenger.domain.message.usecase.DeleteMessageUseCase import ru.daemonlord.messenger.domain.message.usecase.EditMessageUseCase +import ru.daemonlord.messenger.domain.message.usecase.ForwardMessageBulkUseCase import ru.daemonlord.messenger.domain.message.usecase.ForwardMessageUseCase import ru.daemonlord.messenger.domain.message.usecase.ListMessageReactionsUseCase import ru.daemonlord.messenger.domain.message.usecase.LoadMoreMessagesUseCase @@ -46,6 +47,7 @@ class ChatViewModel @Inject constructor( private val markMessageDeliveredUseCase: MarkMessageDeliveredUseCase, private val markMessageReadUseCase: MarkMessageReadUseCase, private val forwardMessageUseCase: ForwardMessageUseCase, + private val forwardMessageBulkUseCase: ForwardMessageBulkUseCase, private val listMessageReactionsUseCase: ListMessageReactionsUseCase, private val toggleMessageReactionUseCase: ToggleMessageReactionUseCase, private val observeChatUseCase: ObserveChatUseCase, @@ -203,7 +205,8 @@ class ChatViewModel @Inject constructor( } fun onForwardSelected() { - val message = getFocusedSelectedMessage() ?: return + val selectedIds = selectedForwardMessageIds() + if (selectedIds.isEmpty()) return viewModelScope.launch { val targets = observeChatsUseCase(archived = false) .first() @@ -214,7 +217,7 @@ class ChatViewModel @Inject constructor( .toList() _uiState.update { it.copy( - forwardingMessage = message, + forwardingMessageIds = selectedIds, availableForwardTargets = targets, selectedMessage = null, selectedCanEdit = false, @@ -229,7 +232,7 @@ class ChatViewModel @Inject constructor( fun onForwardDismiss() { _uiState.update { it.copy( - forwardingMessage = null, + forwardingMessageIds = emptySet(), availableForwardTargets = emptyList(), isForwarding = false, ) @@ -237,26 +240,28 @@ class ChatViewModel @Inject constructor( } fun onForwardTargetSelected(targetChatId: Long) { - val message = uiState.value.forwardingMessage ?: return + val messageIds = uiState.value.forwardingMessageIds.toList().sorted() + if (messageIds.isEmpty()) return viewModelScope.launch { _uiState.update { it.copy(isForwarding = true, errorMessage = null) } - when (val result = forwardMessageUseCase(messageId = message.id, targetChatId = targetChatId)) { - is AppResult.Success -> { - _uiState.update { - it.copy( - isForwarding = false, - forwardingMessage = null, - availableForwardTargets = emptyList(), - ) - } + val result = if (messageIds.size == 1) { + forwardMessageUseCase(messageId = messageIds.first(), targetChatId = targetChatId) + } else { + forwardMessagesBulk(messageIds = messageIds, targetChatId = targetChatId) + } + when (result) { + is AppResult.Success -> _uiState.update { + it.copy( + isForwarding = false, + forwardingMessageIds = emptySet(), + availableForwardTargets = emptyList(), + ) } - is AppResult.Error -> { - _uiState.update { - it.copy( - isForwarding = false, - errorMessage = result.reason.toUiMessage(), - ) - } + is AppResult.Error -> _uiState.update { + it.copy( + isForwarding = false, + errorMessage = result.reason.toUiMessage(), + ) } } } @@ -496,6 +501,32 @@ class ChatViewModel @Inject constructor( } } + private fun selectedForwardMessageIds(): Set { + val state = uiState.value.actionState + if (state.mode == MessageSelectionMode.MULTI && state.selectedMessageIds.isNotEmpty()) { + return state.selectedMessageIds + } + return getFocusedSelectedMessage()?.let { setOf(it.id) }.orEmpty() + } + + private suspend fun forwardMessagesBulk( + messageIds: List, + targetChatId: Long, + ): AppResult { + messageIds.forEach { messageId -> + when ( + val result = forwardMessageBulkUseCase( + messageId = messageId, + targetChatIds = listOf(targetChatId), + ) + ) { + is AppResult.Success -> Unit + is AppResult.Error -> return result + } + } + return AppResult.Success(Unit) + } + private fun findMessageById(messageId: Long): MessageItem? { return uiState.value.messages.firstOrNull { it.id == messageId } } diff --git a/android/app/src/main/java/ru/daemonlord/messenger/ui/chat/MessageUiState.kt b/android/app/src/main/java/ru/daemonlord/messenger/ui/chat/MessageUiState.kt index 4de1ed0..eeacacc 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/ui/chat/MessageUiState.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/ui/chat/MessageUiState.kt @@ -18,7 +18,7 @@ data class MessageUiState( val selectedCanEdit: Boolean = false, val selectedCanDeleteForAll: Boolean = false, val reactionByMessageId: Map> = emptyMap(), - val forwardingMessage: MessageItem? = null, + val forwardingMessageIds: Set = emptySet(), val availableForwardTargets: List = emptyList(), val isForwarding: Boolean = false, val canSendMessages: Boolean = true,