android: enable multi-select forward execution in chat
Some checks failed
CI / test (push) Has been cancelled
Some checks failed
CI / test (push) Has been cancelled
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user