From e992f1e26d9172c3d465d142fc0e7e7fa983c185 Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 9 Mar 2026 13:40:46 +0300 Subject: [PATCH] android: add message action state machine core --- android/CHANGELOG.md | 7 ++ .../messenger/ui/chat/ChatViewModel.kt | 90 +++++++++++++++++-- .../messenger/ui/chat/MessageActionState.kt | 81 +++++++++++++++++ .../messenger/ui/chat/MessageUiState.kt | 1 + .../ui/chat/MessageActionStateTest.kt | 74 +++++++++++++++ 5 files changed, 246 insertions(+), 7 deletions(-) create mode 100644 android/app/src/main/java/ru/daemonlord/messenger/ui/chat/MessageActionState.kt create mode 100644 android/app/src/test/java/ru/daemonlord/messenger/ui/chat/MessageActionStateTest.kt diff --git a/android/CHANGELOG.md b/android/CHANGELOG.md index b491f65..fb57738 100644 --- a/android/CHANGELOG.md +++ b/android/CHANGELOG.md @@ -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`). diff --git a/android/app/src/main/java/ru/daemonlord/messenger/ui/chat/ChatViewModel.kt b/android/app/src/main/java/ru/daemonlord/messenger/ui/chat/ChatViewModel.kt index c5e3519..a7c47aa 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/ui/chat/ChatViewModel.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/ui/chat/ChatViewModel.kt @@ -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." diff --git a/android/app/src/main/java/ru/daemonlord/messenger/ui/chat/MessageActionState.kt b/android/app/src/main/java/ru/daemonlord/messenger/ui/chat/MessageActionState.kt new file mode 100644 index 0000000..6b67990 --- /dev/null +++ b/android/app/src/main/java/ru/daemonlord/messenger/ui/chat/MessageActionState.kt @@ -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 = emptySet(), + val focusedMessageId: Long? = null, + val availableIntents: Set = 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, + 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(), + ) +} diff --git a/android/app/src/main/java/ru/daemonlord/messenger/ui/chat/MessageUiState.kt b/android/app/src/main/java/ru/daemonlord/messenger/ui/chat/MessageUiState.kt index 9b9055a..4de1ed0 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/ui/chat/MessageUiState.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/ui/chat/MessageUiState.kt @@ -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( diff --git a/android/app/src/test/java/ru/daemonlord/messenger/ui/chat/MessageActionStateTest.kt b/android/app/src/test/java/ru/daemonlord/messenger/ui/chat/MessageActionStateTest.kt new file mode 100644 index 0000000..36257b0 --- /dev/null +++ b/android/app/src/test/java/ru/daemonlord/messenger/ui/chat/MessageActionStateTest.kt @@ -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(), + ) + } +}