android: enable multi-select forward execution in chat
Some checks failed
CI / test (push) Has been cancelled

This commit is contained in:
Codex
2026-03-09 13:49:42 +03:00
parent 9e764574bc
commit 876d64d345
4 changed files with 109 additions and 38 deletions

View File

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

View File

@@ -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<ru.daemonlord.messenger.domain.message.model.MessageReaction>,
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),

View File

@@ -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<Long> {
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<Long>,
targetChatId: Long,
): AppResult<Unit> {
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 }
}

View File

@@ -18,7 +18,7 @@ data class MessageUiState(
val selectedCanEdit: Boolean = false,
val selectedCanDeleteForAll: Boolean = false,
val reactionByMessageId: Map<Long, List<MessageReaction>> = emptyMap(),
val forwardingMessage: MessageItem? = null,
val forwardingMessageIds: Set<Long> = emptySet(),
val availableForwardTargets: List<ForwardTargetUiModel> = emptyList(),
val isForwarding: Boolean = false,
val canSendMessages: Boolean = true,