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.
|
||||
- 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`).
|
||||
|
||||
@@ -71,15 +71,73 @@ 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)
|
||||
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(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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 canSendMessages: Boolean = true,
|
||||
val sendRestrictionText: String? = null,
|
||||
val actionState: MessageActionState = MessageActionState(),
|
||||
)
|
||||
|
||||
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