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.
|
- 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 base ViewModel handlers for entering/toggling multi-select mode (`onEnterMultiSelect`, `onToggleMessageMultiSelection`, `onClearSelection`).
|
||||||
- Added unit tests for reducer transitions and available intents (`MessageActionStateTest`).
|
- 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,
|
onInputChanged = viewModel::onInputChanged,
|
||||||
onSendClick = viewModel::onSendClick,
|
onSendClick = viewModel::onSendClick,
|
||||||
onSelectMessage = viewModel::onSelectMessage,
|
onSelectMessage = viewModel::onSelectMessage,
|
||||||
|
onEnterMultiSelect = viewModel::onEnterMultiSelect,
|
||||||
|
onToggleMessageMultiSelection = viewModel::onToggleMessageMultiSelection,
|
||||||
|
onClearSelection = viewModel::onClearSelection,
|
||||||
onReplySelected = viewModel::onReplySelected,
|
onReplySelected = viewModel::onReplySelected,
|
||||||
onEditSelected = viewModel::onEditSelected,
|
onEditSelected = viewModel::onEditSelected,
|
||||||
onDeleteSelected = viewModel::onDeleteSelected,
|
onDeleteSelected = viewModel::onDeleteSelected,
|
||||||
@@ -94,6 +97,9 @@ fun ChatScreen(
|
|||||||
onInputChanged: (String) -> Unit,
|
onInputChanged: (String) -> Unit,
|
||||||
onSendClick: () -> Unit,
|
onSendClick: () -> Unit,
|
||||||
onSelectMessage: (MessageItem?) -> Unit,
|
onSelectMessage: (MessageItem?) -> Unit,
|
||||||
|
onEnterMultiSelect: (MessageItem) -> Unit,
|
||||||
|
onToggleMessageMultiSelection: (MessageItem) -> Unit,
|
||||||
|
onClearSelection: () -> Unit,
|
||||||
onReplySelected: (MessageItem) -> Unit,
|
onReplySelected: (MessageItem) -> Unit,
|
||||||
onEditSelected: (MessageItem) -> Unit,
|
onEditSelected: (MessageItem) -> Unit,
|
||||||
onDeleteSelected: (Boolean) -> Unit,
|
onDeleteSelected: (Boolean) -> Unit,
|
||||||
@@ -145,43 +151,66 @@ fun ChatScreen(
|
|||||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||||
) {
|
) {
|
||||||
items(state.messages, key = { it.id }) { message ->
|
items(state.messages, key = { it.id }) { message ->
|
||||||
|
val isSelected = state.actionState.selectedMessageIds.contains(message.id)
|
||||||
MessageBubble(
|
MessageBubble(
|
||||||
message = message,
|
message = message,
|
||||||
isSelected = state.selectedMessage?.id == message.id,
|
isSelected = isSelected,
|
||||||
reactions = state.reactionByMessageId[message.id].orEmpty(),
|
reactions = state.reactionByMessageId[message.id].orEmpty(),
|
||||||
onAttachmentImageClick = { imageUrl -> viewerImageUrl = imageUrl },
|
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(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(horizontal = 12.dp, vertical = 6.dp),
|
.padding(horizontal = 12.dp, vertical = 6.dp),
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
) {
|
) {
|
||||||
Button(onClick = { onReplySelected(state.selectedMessage) }) { Text("Reply") }
|
Text(
|
||||||
Button(
|
text = "${state.actionState.selectedCount} selected",
|
||||||
onClick = { onEditSelected(state.selectedMessage) },
|
style = MaterialTheme.typography.labelLarge,
|
||||||
enabled = state.selectedCanEdit,
|
modifier = Modifier.padding(top = 12.dp),
|
||||||
) { Text("Edit") }
|
)
|
||||||
|
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(false) }) { Text("Delete") }
|
||||||
Button(
|
Button(
|
||||||
onClick = { onDeleteSelected(true) },
|
onClick = { onDeleteSelected(true) },
|
||||||
enabled = state.selectedCanDeleteForAll,
|
enabled = state.actionState.mode == MessageSelectionMode.SINGLE && state.selectedCanDeleteForAll,
|
||||||
) { Text("Del for all") }
|
) { Text("Del for all") }
|
||||||
Button(onClick = onForwardSelected) { Text("Forward") }
|
Button(onClick = onForwardSelected) {
|
||||||
Button(onClick = { onToggleReaction("\uD83D\uDC4D") }) { Text("\uD83D\uDC4D") }
|
Text(if (state.actionState.mode == MessageSelectionMode.MULTI) "Forward selected" else "Forward")
|
||||||
Button(onClick = { onToggleReaction("\uD83D\uDE02") }) { Text("\uD83D\uDE02") }
|
}
|
||||||
Button(onClick = { onSelectMessage(null) }) { Text("Close") }
|
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(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
@@ -190,7 +219,11 @@ fun ChatScreen(
|
|||||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||||
) {
|
) {
|
||||||
Text(
|
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,
|
style = MaterialTheme.typography.labelLarge,
|
||||||
)
|
)
|
||||||
if (state.availableForwardTargets.isEmpty()) {
|
if (state.availableForwardTargets.isEmpty()) {
|
||||||
@@ -215,7 +248,7 @@ fun ChatScreen(
|
|||||||
val header = if (state.editingMessage != null) {
|
val header = if (state.editingMessage != null) {
|
||||||
"Editing message #${state.editingMessage.id}"
|
"Editing message #${state.editingMessage.id}"
|
||||||
} else {
|
} else {
|
||||||
"Reply to #${state.replyToMessage?.id}"
|
"Reply to ${state.replyToMessage?.senderDisplayName ?: "#${state.replyToMessage?.id}"}"
|
||||||
}
|
}
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -336,6 +369,7 @@ private fun MessageBubble(
|
|||||||
isSelected: Boolean,
|
isSelected: Boolean,
|
||||||
reactions: List<ru.daemonlord.messenger.domain.message.model.MessageReaction>,
|
reactions: List<ru.daemonlord.messenger.domain.message.model.MessageReaction>,
|
||||||
onAttachmentImageClick: (String) -> Unit,
|
onAttachmentImageClick: (String) -> Unit,
|
||||||
|
onClick: () -> Unit,
|
||||||
onLongPress: () -> Unit,
|
onLongPress: () -> Unit,
|
||||||
) {
|
) {
|
||||||
val isOutgoing = message.isOutgoing
|
val isOutgoing = message.isOutgoing
|
||||||
@@ -353,7 +387,7 @@ private fun MessageBubble(
|
|||||||
if (isSelected) MaterialTheme.colorScheme.tertiaryContainer else bubbleColor
|
if (isSelected) MaterialTheme.colorScheme.tertiaryContainer else bubbleColor
|
||||||
)
|
)
|
||||||
.combinedClickable(
|
.combinedClickable(
|
||||||
onClick = {},
|
onClick = onClick,
|
||||||
onLongClick = onLongPress,
|
onLongClick = onLongPress,
|
||||||
)
|
)
|
||||||
.padding(horizontal = 10.dp, vertical = 8.dp),
|
.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.model.MessageItem
|
||||||
import ru.daemonlord.messenger.domain.message.usecase.DeleteMessageUseCase
|
import ru.daemonlord.messenger.domain.message.usecase.DeleteMessageUseCase
|
||||||
import ru.daemonlord.messenger.domain.message.usecase.EditMessageUseCase
|
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.ForwardMessageUseCase
|
||||||
import ru.daemonlord.messenger.domain.message.usecase.ListMessageReactionsUseCase
|
import ru.daemonlord.messenger.domain.message.usecase.ListMessageReactionsUseCase
|
||||||
import ru.daemonlord.messenger.domain.message.usecase.LoadMoreMessagesUseCase
|
import ru.daemonlord.messenger.domain.message.usecase.LoadMoreMessagesUseCase
|
||||||
@@ -46,6 +47,7 @@ class ChatViewModel @Inject constructor(
|
|||||||
private val markMessageDeliveredUseCase: MarkMessageDeliveredUseCase,
|
private val markMessageDeliveredUseCase: MarkMessageDeliveredUseCase,
|
||||||
private val markMessageReadUseCase: MarkMessageReadUseCase,
|
private val markMessageReadUseCase: MarkMessageReadUseCase,
|
||||||
private val forwardMessageUseCase: ForwardMessageUseCase,
|
private val forwardMessageUseCase: ForwardMessageUseCase,
|
||||||
|
private val forwardMessageBulkUseCase: ForwardMessageBulkUseCase,
|
||||||
private val listMessageReactionsUseCase: ListMessageReactionsUseCase,
|
private val listMessageReactionsUseCase: ListMessageReactionsUseCase,
|
||||||
private val toggleMessageReactionUseCase: ToggleMessageReactionUseCase,
|
private val toggleMessageReactionUseCase: ToggleMessageReactionUseCase,
|
||||||
private val observeChatUseCase: ObserveChatUseCase,
|
private val observeChatUseCase: ObserveChatUseCase,
|
||||||
@@ -203,7 +205,8 @@ class ChatViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun onForwardSelected() {
|
fun onForwardSelected() {
|
||||||
val message = getFocusedSelectedMessage() ?: return
|
val selectedIds = selectedForwardMessageIds()
|
||||||
|
if (selectedIds.isEmpty()) return
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
val targets = observeChatsUseCase(archived = false)
|
val targets = observeChatsUseCase(archived = false)
|
||||||
.first()
|
.first()
|
||||||
@@ -214,7 +217,7 @@ class ChatViewModel @Inject constructor(
|
|||||||
.toList()
|
.toList()
|
||||||
_uiState.update {
|
_uiState.update {
|
||||||
it.copy(
|
it.copy(
|
||||||
forwardingMessage = message,
|
forwardingMessageIds = selectedIds,
|
||||||
availableForwardTargets = targets,
|
availableForwardTargets = targets,
|
||||||
selectedMessage = null,
|
selectedMessage = null,
|
||||||
selectedCanEdit = false,
|
selectedCanEdit = false,
|
||||||
@@ -229,7 +232,7 @@ class ChatViewModel @Inject constructor(
|
|||||||
fun onForwardDismiss() {
|
fun onForwardDismiss() {
|
||||||
_uiState.update {
|
_uiState.update {
|
||||||
it.copy(
|
it.copy(
|
||||||
forwardingMessage = null,
|
forwardingMessageIds = emptySet(),
|
||||||
availableForwardTargets = emptyList(),
|
availableForwardTargets = emptyList(),
|
||||||
isForwarding = false,
|
isForwarding = false,
|
||||||
)
|
)
|
||||||
@@ -237,26 +240,28 @@ class ChatViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun onForwardTargetSelected(targetChatId: Long) {
|
fun onForwardTargetSelected(targetChatId: Long) {
|
||||||
val message = uiState.value.forwardingMessage ?: return
|
val messageIds = uiState.value.forwardingMessageIds.toList().sorted()
|
||||||
|
if (messageIds.isEmpty()) return
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
_uiState.update { it.copy(isForwarding = true, errorMessage = null) }
|
_uiState.update { it.copy(isForwarding = true, errorMessage = null) }
|
||||||
when (val result = forwardMessageUseCase(messageId = message.id, targetChatId = targetChatId)) {
|
val result = if (messageIds.size == 1) {
|
||||||
is AppResult.Success -> {
|
forwardMessageUseCase(messageId = messageIds.first(), targetChatId = targetChatId)
|
||||||
_uiState.update {
|
} else {
|
||||||
it.copy(
|
forwardMessagesBulk(messageIds = messageIds, targetChatId = targetChatId)
|
||||||
isForwarding = false,
|
}
|
||||||
forwardingMessage = null,
|
when (result) {
|
||||||
availableForwardTargets = emptyList(),
|
is AppResult.Success -> _uiState.update {
|
||||||
)
|
it.copy(
|
||||||
}
|
isForwarding = false,
|
||||||
|
forwardingMessageIds = emptySet(),
|
||||||
|
availableForwardTargets = emptyList(),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
is AppResult.Error -> {
|
is AppResult.Error -> _uiState.update {
|
||||||
_uiState.update {
|
it.copy(
|
||||||
it.copy(
|
isForwarding = false,
|
||||||
isForwarding = false,
|
errorMessage = result.reason.toUiMessage(),
|
||||||
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? {
|
private fun findMessageById(messageId: Long): MessageItem? {
|
||||||
return uiState.value.messages.firstOrNull { it.id == messageId }
|
return uiState.value.messages.firstOrNull { it.id == messageId }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ data class MessageUiState(
|
|||||||
val selectedCanEdit: Boolean = false,
|
val selectedCanEdit: Boolean = false,
|
||||||
val selectedCanDeleteForAll: Boolean = false,
|
val selectedCanDeleteForAll: Boolean = false,
|
||||||
val reactionByMessageId: Map<Long, List<MessageReaction>> = emptyMap(),
|
val reactionByMessageId: Map<Long, List<MessageReaction>> = emptyMap(),
|
||||||
val forwardingMessage: MessageItem? = null,
|
val forwardingMessageIds: Set<Long> = emptySet(),
|
||||||
val availableForwardTargets: List<ForwardTargetUiModel> = emptyList(),
|
val availableForwardTargets: List<ForwardTargetUiModel> = emptyList(),
|
||||||
val isForwarding: Boolean = false,
|
val isForwarding: Boolean = false,
|
||||||
val canSendMessages: Boolean = true,
|
val canSendMessages: Boolean = true,
|
||||||
|
|||||||
Reference in New Issue
Block a user