android: add message action state machine core
Some checks failed
CI / test (push) Has been cancelled

This commit is contained in:
Codex
2026-03-09 13:40:46 +03:00
parent d8916d6738
commit e992f1e26d
5 changed files with 246 additions and 7 deletions

View File

@@ -169,3 +169,10 @@
- Implemented bulk-forward flow in `NetworkMessageRepository` with Room/chat last-message updates.
- Added `ForwardMessageBulkUseCase` for future multi-select message actions.
- 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`).

View File

@@ -71,16 +71,74 @@ class ChatViewModel @Inject constructor(
}
fun onSelectMessage(message: MessageItem?) {
if (message == null) {
onClearSelection()
return
}
val canEdit = canEdit(message)
val canDeleteForAll = canDeleteForAll(message)
_uiState.update {
it.copy(
selectedMessage = message,
selectedCanEdit = message?.let(::canEdit) == true,
selectedCanDeleteForAll = message?.let(::canDeleteForAll) == true,
selectedCanEdit = canEdit,
selectedCanDeleteForAll = canDeleteForAll,
actionState = it.actionState.selectSingle(
message = message,
canEdit = canEdit,
canDeleteForAll = canDeleteForAll,
),
)
}
if (message != null) {
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) {
@@ -91,6 +149,7 @@ class ChatViewModel @Inject constructor(
selectedMessage = null,
selectedCanEdit = false,
selectedCanDeleteForAll = false,
actionState = it.actionState.clearSelection(),
)
}
}
@@ -103,6 +162,7 @@ class ChatViewModel @Inject constructor(
selectedMessage = null,
selectedCanEdit = false,
selectedCanDeleteForAll = false,
actionState = it.actionState.clearSelection(),
inputText = message.text.orEmpty(),
)
}
@@ -116,12 +176,13 @@ class ChatViewModel @Inject constructor(
selectedMessage = null,
selectedCanEdit = false,
selectedCanDeleteForAll = false,
actionState = it.actionState.clearSelection(),
)
}
}
fun onDeleteSelected(forAll: Boolean = false) {
val selected = uiState.value.selectedMessage ?: return
val selected = getFocusedSelectedMessage() ?: return
if (forAll && !canDeleteForAll(selected)) {
_uiState.update { it.copy(errorMessage = "Delete for all is available only for your own messages.") }
return
@@ -133,6 +194,7 @@ class ChatViewModel @Inject constructor(
selectedMessage = null,
selectedCanEdit = false,
selectedCanDeleteForAll = false,
actionState = it.actionState.clearSelection(),
)
}
is AppResult.Error -> _uiState.update { it.copy(errorMessage = result.reason.toUiMessage()) }
@@ -141,7 +203,7 @@ class ChatViewModel @Inject constructor(
}
fun onForwardSelected() {
val message = uiState.value.selectedMessage ?: return
val message = getFocusedSelectedMessage() ?: return
viewModelScope.launch {
val targets = observeChatsUseCase(archived = false)
.first()
@@ -157,6 +219,7 @@ class ChatViewModel @Inject constructor(
selectedMessage = null,
selectedCanEdit = false,
selectedCanDeleteForAll = false,
actionState = it.actionState.clearSelection(),
errorMessage = null,
)
}
@@ -200,7 +263,7 @@ class ChatViewModel @Inject constructor(
}
fun onToggleReaction(emoji: String) {
val selected = uiState.value.selectedMessage ?: return
val selected = getFocusedSelectedMessage() ?: return
viewModelScope.launch {
when (val result = toggleMessageReactionUseCase(messageId = selected.id, emoji = emoji)) {
is AppResult.Success -> {
@@ -424,6 +487,19 @@ class ChatViewModel @Inject constructor(
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 {
return when (this) {
AppError.Network -> "Network error."

View File

@@ -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(),
)
}

View File

@@ -23,6 +23,7 @@ data class MessageUiState(
val isForwarding: Boolean = false,
val canSendMessages: Boolean = true,
val sendRestrictionText: String? = null,
val actionState: MessageActionState = MessageActionState(),
)
data class ForwardTargetUiModel(

View File

@@ -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(),
)
}
}