android: add message action state machine core
Some checks failed
CI / test (push) Has been cancelled
Some checks failed
CI / test (push) Has been cancelled
This commit is contained in:
@@ -169,3 +169,10 @@
|
|||||||
- Implemented bulk-forward flow in `NetworkMessageRepository` with Room/chat last-message updates.
|
- Implemented bulk-forward flow in `NetworkMessageRepository` with Room/chat last-message updates.
|
||||||
- Added `ForwardMessageBulkUseCase` for future multi-select message actions.
|
- Added `ForwardMessageBulkUseCase` for future multi-select message actions.
|
||||||
- Updated message repository unit test fakes to cover new API surface.
|
- Updated message repository unit test fakes to cover new API surface.
|
||||||
|
|
||||||
|
### Step 27 - Core base / message action state machine
|
||||||
|
- Added reusable `MessageActionState` reducer with explicit selection modes (`NONE`, `SINGLE`, `MULTI`).
|
||||||
|
- Added action-intent contract for message operations (reply/edit/forward/delete/reaction/clear).
|
||||||
|
- 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`).
|
||||||
|
|||||||
@@ -71,16 +71,74 @@ class ChatViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun onSelectMessage(message: MessageItem?) {
|
fun onSelectMessage(message: MessageItem?) {
|
||||||
|
if (message == null) {
|
||||||
|
onClearSelection()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val canEdit = canEdit(message)
|
||||||
|
val canDeleteForAll = canDeleteForAll(message)
|
||||||
_uiState.update {
|
_uiState.update {
|
||||||
it.copy(
|
it.copy(
|
||||||
selectedMessage = message,
|
selectedMessage = message,
|
||||||
selectedCanEdit = message?.let(::canEdit) == true,
|
selectedCanEdit = canEdit,
|
||||||
selectedCanDeleteForAll = message?.let(::canDeleteForAll) == true,
|
selectedCanDeleteForAll = canDeleteForAll,
|
||||||
|
actionState = it.actionState.selectSingle(
|
||||||
|
message = message,
|
||||||
|
canEdit = canEdit,
|
||||||
|
canDeleteForAll = canDeleteForAll,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (message != null) {
|
|
||||||
loadReactions(messageId = message.id)
|
loadReactions(messageId = message.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun onEnterMultiSelect(message: MessageItem) {
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(
|
||||||
|
selectedMessage = message,
|
||||||
|
selectedCanEdit = false,
|
||||||
|
selectedCanDeleteForAll = false,
|
||||||
|
actionState = it.actionState.selectMulti(
|
||||||
|
selectedIds = setOf(message.id),
|
||||||
|
focusedMessageId = message.id,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onToggleMessageMultiSelection(message: MessageItem) {
|
||||||
|
val current = uiState.value.actionState
|
||||||
|
val nextState = if (current.mode != MessageSelectionMode.MULTI) {
|
||||||
|
current.selectMulti(selectedIds = setOf(message.id), focusedMessageId = message.id)
|
||||||
|
} else {
|
||||||
|
val toggledIds = current.selectedMessageIds.toMutableSet().apply {
|
||||||
|
if (contains(message.id)) remove(message.id) else add(message.id)
|
||||||
|
}.toSet()
|
||||||
|
current.selectMulti(
|
||||||
|
selectedIds = toggledIds,
|
||||||
|
focusedMessageId = message.id,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val focused = nextState.focusedMessageId?.let(::findMessageById)
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(
|
||||||
|
selectedMessage = focused,
|
||||||
|
selectedCanEdit = focused?.let(::canEdit) == true,
|
||||||
|
selectedCanDeleteForAll = focused?.let(::canDeleteForAll) == true,
|
||||||
|
actionState = nextState,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onClearSelection() {
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(
|
||||||
|
selectedMessage = null,
|
||||||
|
selectedCanEdit = false,
|
||||||
|
selectedCanDeleteForAll = false,
|
||||||
|
actionState = it.actionState.clearSelection(),
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun onReplySelected(message: MessageItem) {
|
fun onReplySelected(message: MessageItem) {
|
||||||
@@ -91,6 +149,7 @@ class ChatViewModel @Inject constructor(
|
|||||||
selectedMessage = null,
|
selectedMessage = null,
|
||||||
selectedCanEdit = false,
|
selectedCanEdit = false,
|
||||||
selectedCanDeleteForAll = false,
|
selectedCanDeleteForAll = false,
|
||||||
|
actionState = it.actionState.clearSelection(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -103,6 +162,7 @@ class ChatViewModel @Inject constructor(
|
|||||||
selectedMessage = null,
|
selectedMessage = null,
|
||||||
selectedCanEdit = false,
|
selectedCanEdit = false,
|
||||||
selectedCanDeleteForAll = false,
|
selectedCanDeleteForAll = false,
|
||||||
|
actionState = it.actionState.clearSelection(),
|
||||||
inputText = message.text.orEmpty(),
|
inputText = message.text.orEmpty(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -116,12 +176,13 @@ class ChatViewModel @Inject constructor(
|
|||||||
selectedMessage = null,
|
selectedMessage = null,
|
||||||
selectedCanEdit = false,
|
selectedCanEdit = false,
|
||||||
selectedCanDeleteForAll = false,
|
selectedCanDeleteForAll = false,
|
||||||
|
actionState = it.actionState.clearSelection(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun onDeleteSelected(forAll: Boolean = false) {
|
fun onDeleteSelected(forAll: Boolean = false) {
|
||||||
val selected = uiState.value.selectedMessage ?: return
|
val selected = getFocusedSelectedMessage() ?: return
|
||||||
if (forAll && !canDeleteForAll(selected)) {
|
if (forAll && !canDeleteForAll(selected)) {
|
||||||
_uiState.update { it.copy(errorMessage = "Delete for all is available only for your own messages.") }
|
_uiState.update { it.copy(errorMessage = "Delete for all is available only for your own messages.") }
|
||||||
return
|
return
|
||||||
@@ -133,6 +194,7 @@ class ChatViewModel @Inject constructor(
|
|||||||
selectedMessage = null,
|
selectedMessage = null,
|
||||||
selectedCanEdit = false,
|
selectedCanEdit = false,
|
||||||
selectedCanDeleteForAll = false,
|
selectedCanDeleteForAll = false,
|
||||||
|
actionState = it.actionState.clearSelection(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
is AppResult.Error -> _uiState.update { it.copy(errorMessage = result.reason.toUiMessage()) }
|
is AppResult.Error -> _uiState.update { it.copy(errorMessage = result.reason.toUiMessage()) }
|
||||||
@@ -141,7 +203,7 @@ class ChatViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun onForwardSelected() {
|
fun onForwardSelected() {
|
||||||
val message = uiState.value.selectedMessage ?: return
|
val message = getFocusedSelectedMessage() ?: return
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
val targets = observeChatsUseCase(archived = false)
|
val targets = observeChatsUseCase(archived = false)
|
||||||
.first()
|
.first()
|
||||||
@@ -157,6 +219,7 @@ class ChatViewModel @Inject constructor(
|
|||||||
selectedMessage = null,
|
selectedMessage = null,
|
||||||
selectedCanEdit = false,
|
selectedCanEdit = false,
|
||||||
selectedCanDeleteForAll = false,
|
selectedCanDeleteForAll = false,
|
||||||
|
actionState = it.actionState.clearSelection(),
|
||||||
errorMessage = null,
|
errorMessage = null,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -200,7 +263,7 @@ class ChatViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun onToggleReaction(emoji: String) {
|
fun onToggleReaction(emoji: String) {
|
||||||
val selected = uiState.value.selectedMessage ?: return
|
val selected = getFocusedSelectedMessage() ?: return
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
when (val result = toggleMessageReactionUseCase(messageId = selected.id, emoji = emoji)) {
|
when (val result = toggleMessageReactionUseCase(messageId = selected.id, emoji = emoji)) {
|
||||||
is AppResult.Success -> {
|
is AppResult.Success -> {
|
||||||
@@ -424,6 +487,19 @@ class ChatViewModel @Inject constructor(
|
|||||||
return message.isOutgoing
|
return message.isOutgoing
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun getFocusedSelectedMessage(): MessageItem? {
|
||||||
|
val focusId = uiState.value.actionState.focusedMessageId
|
||||||
|
return if (focusId != null) {
|
||||||
|
findMessageById(focusId)
|
||||||
|
} else {
|
||||||
|
uiState.value.selectedMessage
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun findMessageById(messageId: Long): MessageItem? {
|
||||||
|
return uiState.value.messages.firstOrNull { it.id == messageId }
|
||||||
|
}
|
||||||
|
|
||||||
private fun AppError.toUiMessage(): String {
|
private fun AppError.toUiMessage(): String {
|
||||||
return when (this) {
|
return when (this) {
|
||||||
AppError.Network -> "Network error."
|
AppError.Network -> "Network error."
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
package ru.daemonlord.messenger.ui.chat
|
||||||
|
|
||||||
|
import ru.daemonlord.messenger.domain.message.model.MessageItem
|
||||||
|
|
||||||
|
enum class MessageSelectionMode {
|
||||||
|
NONE,
|
||||||
|
SINGLE,
|
||||||
|
MULTI,
|
||||||
|
}
|
||||||
|
|
||||||
|
enum class MessageActionIntent {
|
||||||
|
REPLY,
|
||||||
|
EDIT,
|
||||||
|
FORWARD,
|
||||||
|
FORWARD_BULK,
|
||||||
|
DELETE_FOR_ME,
|
||||||
|
DELETE_FOR_ALL,
|
||||||
|
TOGGLE_REACTION,
|
||||||
|
CLEAR_SELECTION,
|
||||||
|
}
|
||||||
|
|
||||||
|
data class MessageActionState(
|
||||||
|
val mode: MessageSelectionMode = MessageSelectionMode.NONE,
|
||||||
|
val selectedMessageIds: Set<Long> = emptySet(),
|
||||||
|
val focusedMessageId: Long? = null,
|
||||||
|
val availableIntents: Set<MessageActionIntent> = emptySet(),
|
||||||
|
) {
|
||||||
|
val hasSelection: Boolean
|
||||||
|
get() = selectedMessageIds.isNotEmpty()
|
||||||
|
|
||||||
|
val selectedCount: Int
|
||||||
|
get() = selectedMessageIds.size
|
||||||
|
}
|
||||||
|
|
||||||
|
fun MessageActionState.selectSingle(
|
||||||
|
message: MessageItem,
|
||||||
|
canEdit: Boolean,
|
||||||
|
canDeleteForAll: Boolean,
|
||||||
|
): MessageActionState {
|
||||||
|
val intents = buildSet {
|
||||||
|
add(MessageActionIntent.REPLY)
|
||||||
|
add(MessageActionIntent.FORWARD)
|
||||||
|
add(MessageActionIntent.DELETE_FOR_ME)
|
||||||
|
add(MessageActionIntent.TOGGLE_REACTION)
|
||||||
|
add(MessageActionIntent.CLEAR_SELECTION)
|
||||||
|
if (canEdit) add(MessageActionIntent.EDIT)
|
||||||
|
if (canDeleteForAll) add(MessageActionIntent.DELETE_FOR_ALL)
|
||||||
|
}
|
||||||
|
return copy(
|
||||||
|
mode = MessageSelectionMode.SINGLE,
|
||||||
|
selectedMessageIds = setOf(message.id),
|
||||||
|
focusedMessageId = message.id,
|
||||||
|
availableIntents = intents,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun MessageActionState.selectMulti(
|
||||||
|
selectedIds: Set<Long>,
|
||||||
|
focusedMessageId: Long? = null,
|
||||||
|
): MessageActionState {
|
||||||
|
if (selectedIds.isEmpty()) return clearSelection()
|
||||||
|
return copy(
|
||||||
|
mode = MessageSelectionMode.MULTI,
|
||||||
|
selectedMessageIds = selectedIds,
|
||||||
|
focusedMessageId = focusedMessageId ?: this.focusedMessageId ?: selectedIds.first(),
|
||||||
|
availableIntents = setOf(
|
||||||
|
MessageActionIntent.FORWARD_BULK,
|
||||||
|
MessageActionIntent.DELETE_FOR_ME,
|
||||||
|
MessageActionIntent.CLEAR_SELECTION,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun MessageActionState.clearSelection(): MessageActionState {
|
||||||
|
return copy(
|
||||||
|
mode = MessageSelectionMode.NONE,
|
||||||
|
selectedMessageIds = emptySet(),
|
||||||
|
focusedMessageId = null,
|
||||||
|
availableIntents = emptySet(),
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -23,6 +23,7 @@ data class MessageUiState(
|
|||||||
val isForwarding: Boolean = false,
|
val isForwarding: Boolean = false,
|
||||||
val canSendMessages: Boolean = true,
|
val canSendMessages: Boolean = true,
|
||||||
val sendRestrictionText: String? = null,
|
val sendRestrictionText: String? = null,
|
||||||
|
val actionState: MessageActionState = MessageActionState(),
|
||||||
)
|
)
|
||||||
|
|
||||||
data class ForwardTargetUiModel(
|
data class ForwardTargetUiModel(
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
package ru.daemonlord.messenger.ui.chat
|
||||||
|
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
import ru.daemonlord.messenger.domain.message.model.MessageItem
|
||||||
|
|
||||||
|
class MessageActionStateTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun selectSingle_setsSingleModeAndIntents() {
|
||||||
|
val message = message(id = 1L)
|
||||||
|
val state = MessageActionState().selectSingle(
|
||||||
|
message = message,
|
||||||
|
canEdit = true,
|
||||||
|
canDeleteForAll = true,
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(MessageSelectionMode.SINGLE, state.mode)
|
||||||
|
assertEquals(setOf(1L), state.selectedMessageIds)
|
||||||
|
assertEquals(1L, state.focusedMessageId)
|
||||||
|
assertTrue(state.availableIntents.contains(MessageActionIntent.REPLY))
|
||||||
|
assertTrue(state.availableIntents.contains(MessageActionIntent.EDIT))
|
||||||
|
assertTrue(state.availableIntents.contains(MessageActionIntent.DELETE_FOR_ALL))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun selectMulti_setsMultiModeAndBulkIntent() {
|
||||||
|
val state = MessageActionState().selectMulti(
|
||||||
|
selectedIds = setOf(10L, 11L),
|
||||||
|
focusedMessageId = 11L,
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(MessageSelectionMode.MULTI, state.mode)
|
||||||
|
assertEquals(2, state.selectedCount)
|
||||||
|
assertEquals(11L, state.focusedMessageId)
|
||||||
|
assertTrue(state.availableIntents.contains(MessageActionIntent.FORWARD_BULK))
|
||||||
|
assertTrue(state.availableIntents.contains(MessageActionIntent.DELETE_FOR_ME))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun clearSelection_resetsState() {
|
||||||
|
val state = MessageActionState(
|
||||||
|
mode = MessageSelectionMode.MULTI,
|
||||||
|
selectedMessageIds = setOf(1L, 2L),
|
||||||
|
focusedMessageId = 2L,
|
||||||
|
availableIntents = setOf(MessageActionIntent.FORWARD_BULK),
|
||||||
|
).clearSelection()
|
||||||
|
|
||||||
|
assertEquals(MessageSelectionMode.NONE, state.mode)
|
||||||
|
assertTrue(state.selectedMessageIds.isEmpty())
|
||||||
|
assertEquals(null, state.focusedMessageId)
|
||||||
|
assertTrue(state.availableIntents.isEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun message(id: Long): MessageItem {
|
||||||
|
return MessageItem(
|
||||||
|
id = id,
|
||||||
|
chatId = 1L,
|
||||||
|
senderId = 1L,
|
||||||
|
senderDisplayName = null,
|
||||||
|
type = "text",
|
||||||
|
text = "hi",
|
||||||
|
createdAt = "2026-03-09T10:00:00Z",
|
||||||
|
updatedAt = null,
|
||||||
|
isOutgoing = true,
|
||||||
|
status = "sent",
|
||||||
|
replyToMessageId = null,
|
||||||
|
forwardedFromMessageId = null,
|
||||||
|
attachmentWaveform = null,
|
||||||
|
attachments = emptyList(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user