From 7a65e356ecf615270c72878a7c57e1e1478cafd9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Mion?= Date: Fri, 20 Mar 2026 13:34:24 +0000 Subject: [PATCH 1/4] Refactor `MessageComposerController` to use `MessageComposerState` as the single source of truth for UI properties including `cooldownTimer`, `validationErrors`, `mentionSuggestions`, `commandSuggestions`, and `messageMode`. --- .../api/stream-chat-android-compose.api | 8 - .../attachments/AttachmentPickerMenu.kt | 4 +- .../messages/MessageComposerViewModel.kt | 49 ------ .../messages/MessageComposerViewModelTest.kt | 14 +- .../composer/MessageComposerController.kt | 145 ++++++------------ .../ui/sample/feature/chat/ChatFragment.kt | 11 +- .../api/stream-chat-android-ui-components.api | 7 - .../messages/MessageComposerViewModel.kt | 43 ------ .../messages/MessageComposerViewModelTest.kt | 13 +- 9 files changed, 60 insertions(+), 234 deletions(-) diff --git a/stream-chat-android-compose/api/stream-chat-android-compose.api b/stream-chat-android-compose/api/stream-chat-android-compose.api index 2718ec94c40..078910bdef8 100644 --- a/stream-chat-android-compose/api/stream-chat-android-compose.api +++ b/stream-chat-android-compose/api/stream-chat-android-compose.api @@ -7031,17 +7031,9 @@ public final class io/getstream/chat/android/compose/viewmodel/messages/MessageC public final fun completeRecording ()V public final fun createPoll (Lio/getstream/chat/android/models/CreatePollParams;)V public final fun dismissMessageActions ()V - public final fun getCommandSuggestions ()Lkotlinx/coroutines/flow/MutableStateFlow; - public final fun getCooldownTimer ()Lkotlinx/coroutines/flow/MutableStateFlow; public final fun getInputFocusEvents ()Lkotlinx/coroutines/flow/SharedFlow; - public final fun getLastActiveAction ()Lkotlinx/coroutines/flow/Flow; - public final fun getLinkPreviews ()Lkotlinx/coroutines/flow/MutableStateFlow; - public final fun getMentionSuggestions ()Lkotlinx/coroutines/flow/MutableStateFlow; public final fun getMessageComposerState ()Lkotlinx/coroutines/flow/StateFlow; public final fun getMessageInput ()Lkotlinx/coroutines/flow/MutableStateFlow; - public final fun getMessageMode ()Lkotlinx/coroutines/flow/MutableStateFlow; - public final fun getOwnCapabilities ()Lkotlinx/coroutines/flow/StateFlow; - public final fun getValidationErrors ()Lkotlinx/coroutines/flow/MutableStateFlow; public final fun holdRecording (Lkotlin/Pair;)V public final fun leaveThread ()V public final fun lockRecording ()V diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/messages/attachments/AttachmentPickerMenu.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/messages/attachments/AttachmentPickerMenu.kt index 68532297519..f6cce0b4361 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/messages/attachments/AttachmentPickerMenu.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/messages/attachments/AttachmentPickerMenu.kt @@ -71,7 +71,7 @@ public fun AttachmentPickerMenu( composerViewModel: MessageComposerViewModel, ) { val isPickerVisible = attachmentsPickerViewModel.isPickerVisible - val messageMode by composerViewModel.messageMode.collectAsStateWithLifecycle() + val composerState by composerViewModel.messageComposerState.collectAsStateWithLifecycle() var isShowingDialog by rememberSaveable { mutableStateOf(false) } @@ -131,7 +131,7 @@ public fun AttachmentPickerMenu( params = AttachmentPickerParams( modifier = Modifier.height(menuHeight), attachmentsPickerViewModel = attachmentsPickerViewModel, - messageMode = messageMode, + messageMode = composerState.messageMode, actions = actions, ), ) diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/messages/MessageComposerViewModel.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/messages/MessageComposerViewModel.kt index 87367c2f6e1..90c55d77971 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/messages/MessageComposerViewModel.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/messages/MessageComposerViewModel.kt @@ -18,10 +18,8 @@ package io.getstream.chat.android.compose.viewmodel.messages import androidx.lifecycle.ViewModel import io.getstream.chat.android.models.Attachment -import io.getstream.chat.android.models.ChannelCapabilities import io.getstream.chat.android.models.Command import io.getstream.chat.android.models.CreatePollParams -import io.getstream.chat.android.models.LinkPreview import io.getstream.chat.android.models.Message import io.getstream.chat.android.models.User import io.getstream.chat.android.ui.common.feature.messages.composer.MessageComposerController @@ -33,10 +31,8 @@ import io.getstream.chat.android.ui.common.state.messages.MessageInput import io.getstream.chat.android.ui.common.state.messages.MessageMode import io.getstream.chat.android.ui.common.state.messages.Reply import io.getstream.chat.android.ui.common.state.messages.composer.MessageComposerState -import io.getstream.chat.android.ui.common.state.messages.composer.ValidationError import io.getstream.chat.android.ui.common.utils.typing.TypingUpdatesBuffer import io.getstream.result.call.Call -import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.StateFlow @@ -70,51 +66,6 @@ public class MessageComposerViewModel( */ public val messageInput: MutableStateFlow = messageComposerController.messageInput - /** - * Represents the remaining time until the user is allowed to send the next message. - */ - public val cooldownTimer: MutableStateFlow = messageComposerController.cooldownTimer - - /** - * Represents the list of validation errors for the current text input and the currently selected attachments. - */ - public val validationErrors: MutableStateFlow> = messageComposerController.validationErrors - - /** - * Represents the list of users that can be used to autocomplete the current mention input. - */ - public val mentionSuggestions: MutableStateFlow> = messageComposerController.mentionSuggestions - - /** - * Represents the list of commands to be displayed in the command suggestion list popup. - */ - public val commandSuggestions: MutableStateFlow> = messageComposerController.commandSuggestions - - /** - * Represents the list of links that can be previewed. - */ - public val linkPreviews: MutableStateFlow> = messageComposerController.linkPreviews - - /** - * Current message mode, either [MessageMode.Normal] or [MessageMode.MessageThread]. Used to determine if we're - * sending a thread reply or a regular message. - */ - public val messageMode: MutableStateFlow = messageComposerController.messageMode - - /** - * Gets the active [Edit] or [Reply] action, whichever is last, to show on the UI. - */ - public val lastActiveAction: Flow = messageComposerController.lastActiveAction - - /** - * Holds information about the abilities the current user - * is able to exercise in the given channel. - * - * e.g. send messages, delete messages, etc... - * For a full list @see [ChannelCapabilities]. - */ - public val ownCapabilities: StateFlow> = messageComposerController.ownCapabilities - /** * Called when the input changes and the internal state needs to be updated. * diff --git a/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/viewmodel/messages/MessageComposerViewModelTest.kt b/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/viewmodel/messages/MessageComposerViewModelTest.kt index 24b661d83fc..c5d7ffafa7d 100644 --- a/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/viewmodel/messages/MessageComposerViewModelTest.kt +++ b/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/viewmodel/messages/MessageComposerViewModelTest.kt @@ -247,7 +247,6 @@ internal class MessageComposerViewModelTest { val messageComposerState = viewModel.messageComposerState.value messageComposerState.messageMode `should be instance of` MessageMode.MessageThread::class - viewModel.messageMode.value `should be instance of` MessageMode.MessageThread::class } @Test @@ -263,7 +262,6 @@ internal class MessageComposerViewModelTest { val messageComposerState = viewModel.messageComposerState.value messageComposerState.messageMode `should be instance of` MessageMode.Normal::class - viewModel.messageMode.value `should be instance of` MessageMode.Normal::class } @Test @@ -311,7 +309,7 @@ internal class MessageComposerViewModelTest { .givenChannelState(channelData) .get() - val ownCapabilities = viewModel.ownCapabilities.value + val ownCapabilities = viewModel.messageComposerState.value.ownCapabilities ownCapabilities.size `should be equal to` 3 } @@ -326,7 +324,6 @@ internal class MessageComposerViewModelTest { viewModel.setMessageInput("/") viewModel.messageComposerState.value.commandSuggestions.size `should be equal to` 1 - viewModel.commandSuggestions.value.size `should be equal to` 1 } @Test @@ -341,7 +338,6 @@ internal class MessageComposerViewModelTest { viewModel.setMessageInput("") viewModel.messageComposerState.value.commandSuggestions.size `should be equal to` 0 - viewModel.commandSuggestions.value.size `should be equal to` 0 } @Test @@ -355,10 +351,9 @@ internal class MessageComposerViewModelTest { viewModel.inputFocusEvents.test { viewModel.toggleCommandsVisibility() - viewModel.selectCommand(viewModel.commandSuggestions.value.first()) + viewModel.selectCommand(viewModel.messageComposerState.value.commandSuggestions.first()) viewModel.messageComposerState.value.commandSuggestions.size `should be equal to` 0 - viewModel.commandSuggestions.value.size `should be equal to` 0 viewModel.messageComposerState.value.inputValue `should be equal to` "/giphy " viewModel.messageInput.value.text `should be equal to` "/giphy " awaitItem() @@ -381,7 +376,6 @@ internal class MessageComposerViewModelTest { advanceUntilIdle() viewModel.messageComposerState.value.mentionSuggestions.size `should be equal to` 2 - viewModel.mentionSuggestions.value.size `should be equal to` 2 } @Test @@ -398,11 +392,10 @@ internal class MessageComposerViewModelTest { viewModel.setMessageInput("@") advanceUntilIdle() - viewModel.selectMention(viewModel.mentionSuggestions.value.first()) + viewModel.selectMention(viewModel.messageComposerState.value.mentionSuggestions.first()) advanceUntilIdle() viewModel.messageComposerState.value.mentionSuggestions.size `should be equal to` 0 - viewModel.mentionSuggestions.value.size `should be equal to` 0 viewModel.messageInput.value.text `should be equal to` "@Jc Miñarro " } @@ -429,7 +422,6 @@ internal class MessageComposerViewModelTest { advanceUntilIdle() viewModel.messageComposerState.value.mentionSuggestions.size `should be equal to` 0 - viewModel.mentionSuggestions.value.size `should be equal to` 0 viewModel.messageInput.value.text `should be equal to` "@Custom Mention " } diff --git a/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/feature/messages/composer/MessageComposerController.kt b/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/feature/messages/composer/MessageComposerController.kt index dca1df80c16..1b380d1d2df 100644 --- a/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/feature/messages/composer/MessageComposerController.kt +++ b/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/feature/messages/composer/MessageComposerController.kt @@ -48,7 +48,6 @@ import io.getstream.chat.android.ui.common.state.messages.ThreadReply import io.getstream.chat.android.ui.common.state.messages.composer.MessageComposerState import io.getstream.chat.android.ui.common.state.messages.composer.MessageValidator import io.getstream.chat.android.ui.common.state.messages.composer.RecordingState -import io.getstream.chat.android.ui.common.state.messages.composer.ValidationError import io.getstream.chat.android.ui.common.utils.AttachmentConstants import io.getstream.chat.android.ui.common.utils.extensions.addSchemeToUrlIfNeeded import io.getstream.chat.android.ui.common.utils.typing.TypingUpdatesBuffer @@ -63,6 +62,7 @@ import io.getstream.result.call.map import io.getstream.result.onSuccessSuspend import io.getstream.sdk.chat.audio.recording.StreamMediaRecorder import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.Job @@ -77,7 +77,6 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.debounce -import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChangedBy import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.flatMapLatest @@ -173,7 +172,7 @@ public class MessageComposerController( * e.g. send messages, delete messages, etc... * For a full list @see [ChannelCapabilities]. */ - public val ownCapabilities: StateFlow> = channelState + private val ownCapabilities: StateFlow> = channelState .filterNotNull() .flatMapLatest { it.channelData } .map { @@ -275,29 +274,6 @@ public class MessageComposerController( /** UI state of the current composer input. */ public val messageInput: MutableStateFlow = MutableStateFlow(MessageInput()) - /** Represents the remaining time until the user is allowed to send the next message. */ - public val cooldownTimer: MutableStateFlow = MutableStateFlow(0) - - /** - * Represents the list of validation errors for the current text input and the currently selected attachments. - */ - public val validationErrors: MutableStateFlow> = MutableStateFlow(emptyList()) - - /** - * Represents the list of users that can be used to autocomplete the current mention input. - */ - public val mentionSuggestions: MutableStateFlow> = MutableStateFlow(emptyList()) - - /** - * Represents the list of commands that can be executed for the channel. - */ - public val commandSuggestions: MutableStateFlow> = MutableStateFlow(emptyList()) - - /** - * Represents the list of links that can be previewed. - */ - public val linkPreviews: MutableStateFlow> = MutableStateFlow(emptyList()) - /** * Represents the list of users in the channel. */ @@ -313,26 +289,12 @@ public class MessageComposerController( */ private var cooldownTimerJob: Job? = null - /** - * Current message mode, either [MessageMode.Normal] or [MessageMode.MessageThread]. Used to determine if we're - * sending a thread reply or a regular message. - */ - public val messageMode: MutableStateFlow = MutableStateFlow(MessageMode.Normal) - /** * Set of currently active message actions. These are used to display different UI in the composer, * as well as help us decorate the message with information, such as the quoted message id. */ public val messageActions: MutableStateFlow> = MutableStateFlow(mutableSetOf()) - /** - * Represents a Flow that holds the last active [MessageAction] that is either the [Edit], [Reply]. - */ - public val lastActiveAction: Flow - get() = messageActions.map { actions -> - actions.lastOrNull { it is Edit || it is Reply } - } - /** * Gets the active [Edit] or [Reply] action, whichever is last, to show on the UI. */ @@ -349,7 +311,7 @@ public class MessageComposerController( * Gets the parent message id if we are in thread mode, or null otherwise. */ private val parentMessageId: String? - get() = (messageMode.value as? MessageMode.MessageThread)?.parentMessage?.id + get() = (state.value.messageMode as? MessageMode.MessageThread)?.parentMessage?.id /** * Gets the current text input in the message composer. @@ -361,7 +323,7 @@ public class MessageComposerController( * Gives us information if the composer is in the "thread" mode. */ private val isInThread: Boolean - get() = messageMode.value is MessageMode.MessageThread + get() = state.value.messageMode is MessageMode.MessageThread /** * Represents the selected mentions based on the message suggestion list. @@ -413,7 +375,7 @@ public class MessageComposerController( @OptIn(FlowPreview::class) @Suppress("LongMethod") private fun setupComposerState() { - fetchDraftMessage(messageMode.value) + fetchDraftMessage(state.value.messageMode) messageInput.onEach { value -> state.value = state.value.copy(inputValue = value.text) @@ -427,44 +389,11 @@ public class MessageComposerController( scope.launch { handleLinkPreviews() } }.launchIn(scope) - lastActiveAction.onEach { activeAction -> - state.value = state.value.copy(action = activeAction) - }.launchIn(scope) - - validationErrors.onEach { validationErrors -> - state.value = state.value.copy(validationErrors = validationErrors) + messageActions.onEach { actions -> + val activeAction = actions.lastOrNull { it is Edit || it is Reply } + state.update { it.copy(action = activeAction) } }.launchIn(scope) - mentionSuggestions.onEach { mentionSuggestions -> - state.value = state.value.copy(mentionSuggestions = mentionSuggestions) - }.launchIn(scope) - - commandSuggestions.onEach { commandSuggestions -> - state.value = state.value.copy(commandSuggestions = commandSuggestions) - }.launchIn(scope) - - linkPreviews.onEach { linkPreviews -> - state.value = state.value.copy(linkPreviews = linkPreviews) - }.launchIn(scope) - - cooldownTimer.onEach { cooldownTimer -> - state.value = state.value.copy(coolDownTime = cooldownTimer) - }.launchIn(scope) - - messageMode - .distinctUntilChanged { old, new -> - when (old) { - is MessageMode.Normal -> new is MessageMode.Normal - is MessageMode.MessageThread -> - old.parentMessage.id == (new as? MessageMode.MessageThread)?.parentMessage?.id - } - } - .onEach { messageMode -> - saveDraftMessage(state.value.messageMode) - state.value = state.value.copy(messageMode = messageMode) - fetchDraftMessage(messageMode) - }.launchIn(scope) - ownCapabilities.onEach { ownCapabilities -> state.value = state.value.copy(ownCapabilities = ownCapabilities) }.launchIn(scope) @@ -486,7 +415,7 @@ public class MessageComposerController( channelDraftMessages.onEach { if (it[channelCid] == null && !currentDraftId.isNullOrEmpty() && - messageMode.value is MessageMode.Normal + state.value.messageMode is MessageMode.Normal ) { clearData() } @@ -495,7 +424,7 @@ public class MessageComposerController( threadDraftMessages.onEach { if (it[parentMessageId] == null && !currentDraftId.isNullOrEmpty() && - messageMode.value is MessageMode.MessageThread + state.value.messageMode is MessageMode.MessageThread ) { clearData() } @@ -595,7 +524,20 @@ public class MessageComposerController( * @param messageMode The current message mode. */ public fun setMessageMode(messageMode: MessageMode) { - this.messageMode.value = messageMode + val previousMode = state.value.messageMode + if (isSameMessageMode(previousMode, messageMode)) return + scope.launch(start = CoroutineStart.UNDISPATCHED) { + saveDraftMessage(previousMode) + state.update { it.copy(messageMode = messageMode) } + fetchDraftMessage(messageMode) + } + } + + private fun isSameMessageMode(old: MessageMode, new: MessageMode): Boolean = when { + old is MessageMode.Normal && new is MessageMode.Normal -> true + old is MessageMode.MessageThread && new is MessageMode.MessageThread -> + old.parentMessage.id == new.parentMessage.id + else -> false } /** @@ -736,11 +678,11 @@ public class MessageComposerController( public fun clearData() { logger.i { "[clearData]" } dismissMessageActions() - scope.launch { clearDraftMessage(messageMode.value) } + scope.launch { clearDraftMessage(state.value.messageMode) } messageInput.value = MessageInput() clearAttachments() clearActiveCommand() - validationErrors.value = emptyList() + state.update { it.copy(validationErrors = emptyList()) } if (!isInThread) { state.update { it.copy(alsoSendToChannel = false) } } @@ -791,7 +733,7 @@ public class MessageComposerController( } val preparedMessage = message.copy( showInChannel = isInThread && state.value.alsoSendToChannel, - skipEnrichUrl = linkPreviews.value.isEmpty(), + skipEnrichUrl = state.value.linkPreviews.isEmpty(), ) clearData() @@ -928,7 +870,7 @@ public class MessageComposerController( typingUpdatesBuffer.clear() audioRecordingController.onCleared() scope.launch { - saveDraftMessage(messageMode.value) + saveDraftMessage(state.value.messageMode) scope.cancel() } } @@ -948,7 +890,9 @@ public class MessageComposerController( * Checks the current input for validation errors. */ private fun handleValidationErrors() { - validationErrors.value = messageValidator.validateMessage(messageInput.value.text, state.value.attachments) + state.update { + it.copy(validationErrors = messageValidator.validateMessage(messageInput.value.text, it.attachments)) + } } /** @@ -1006,15 +950,22 @@ public class MessageComposerController( * Toggles the visibility of the command suggestion list popup. */ public fun toggleCommandsVisibility() { - commandSuggestions.value = if (commandSuggestions.value.isEmpty()) commands else emptyList() + state.update { s -> + val showCommands = s.commandSuggestions.isEmpty() + s.copy(commandSuggestions = if (showCommands) commands else emptyList()) + } } /** * Dismisses the suggestions popup above the message composer. */ public fun dismissSuggestionsPopup() { - mentionSuggestions.value = emptyList() - commandSuggestions.value = emptyList() + state.update { + it.copy( + mentionSuggestions = emptyList(), + commandSuggestions = emptyList(), + ) + } } /** @@ -1100,7 +1051,7 @@ public class MessageComposerController( val messageInput = messageInput.value if (messageInput.source == MessageInput.Source.MentionSelected) { logger.v { "[handleMentionSuggestions] rejected (messageInput came from mention selection)" } - mentionSuggestions.value = emptyList() + state.update { it.copy(mentionSuggestions = emptyList()) } return } val inputText = messageInput.text @@ -1113,7 +1064,7 @@ public class MessageComposerController( emptyList() } withContext(DispatcherProvider.Main) { - mentionSuggestions.value = result + state.update { it.copy(mentionSuggestions = result) } } } } @@ -1123,12 +1074,13 @@ public class MessageComposerController( */ private fun handleCommandSuggestions() { val containsCommand = CommandPattern.matcher(messageText).find() - commandSuggestions.value = if (containsCommand && state.value.attachments.isEmpty()) { + val suggestions = if (containsCommand && state.value.attachments.isEmpty()) { val commandPattern = messageText.removePrefix("/") commands.filter { it.name.startsWith(commandPattern) } } else { emptyList() } + state.update { it.copy(commandSuggestions = suggestions) } } /** @@ -1147,8 +1099,7 @@ public class MessageComposerController( .coerceAtLeast(0) fun updateCooldownTime(timeRemaining: Int) { - cooldownTimer.value = timeRemaining - state.value = state.value.copy(coolDownTime = timeRemaining) + state.update { it.copy(coolDownTime = timeRemaining) } } // If the user is still unable to send messages show the timer @@ -1179,7 +1130,7 @@ public class MessageComposerController( .map { it.value } logger.v { "[handleLinkPreviews] previews: ${previews.map { it.originUrl }}" } - linkPreviews.value = previews + state.update { it.copy(linkPreviews = previews) } } private fun loadLatestMessagesIfNeeded() { @@ -1264,7 +1215,7 @@ public class MessageComposerController( * Cancels any link preview. */ public fun cancelLinkPreview() { - linkPreviews.value = emptyList() + state.update { it.copy(linkPreviews = emptyList()) } } } diff --git a/stream-chat-android-ui-components-sample/src/main/kotlin/io/getstream/chat/ui/sample/feature/chat/ChatFragment.kt b/stream-chat-android-ui-components-sample/src/main/kotlin/io/getstream/chat/ui/sample/feature/chat/ChatFragment.kt index b7e8db82778..10af4cad6b4 100644 --- a/stream-chat-android-ui-components-sample/src/main/kotlin/io/getstream/chat/ui/sample/feature/chat/ChatFragment.kt +++ b/stream-chat-android-ui-components-sample/src/main/kotlin/io/getstream/chat/ui/sample/feature/chat/ChatFragment.kt @@ -228,19 +228,16 @@ class ChatFragment : Fragment() { lifecycleScope.launch { lifecycle.repeatOnLifecycle(Lifecycle.State.RESUMED) { - messageComposerViewModel.messageMode.collect { messageMode -> + messageComposerViewModel.messageComposerState.collect { composerState -> + val messageMode = composerState.messageMode when (messageMode) { is MessageMode.Normal -> { /* no-op */ } is MessageMode.MessageThread -> { /* no-op */ } } val modeText = messageMode.javaClass.simpleName logger.d { "[onMessageModeChange] messageMode: $modeText" } - } - } - } - lifecycleScope.launch { - lifecycle.repeatOnLifecycle(Lifecycle.State.RESUMED) { - messageComposerViewModel.lastActiveAction.collect { messageAction -> + + val messageAction = composerState.action when (messageAction) { is Edit -> { /* no-op */ } is Reply -> { /* no-op */ } diff --git a/stream-chat-android-ui-components/api/stream-chat-android-ui-components.api b/stream-chat-android-ui-components/api/stream-chat-android-ui-components.api index 842e25ebdb1..53c0574a12b 100644 --- a/stream-chat-android-ui-components/api/stream-chat-android-ui-components.api +++ b/stream-chat-android-ui-components/api/stream-chat-android-ui-components.api @@ -4410,15 +4410,8 @@ public final class io/getstream/chat/android/ui/viewmodel/messages/MessageCompos public final fun createPoll (Lio/getstream/chat/android/models/CreatePollParams;)V public final fun dismissMessageActions ()V public final fun dismissSuggestionsPopup ()V - public final fun getCommandSuggestions ()Lkotlinx/coroutines/flow/MutableStateFlow; - public final fun getCooldownTimer ()Lkotlinx/coroutines/flow/MutableStateFlow; - public final fun getLastActiveAction ()Lkotlinx/coroutines/flow/Flow; - public final fun getMentionSuggestions ()Lkotlinx/coroutines/flow/MutableStateFlow; public final fun getMessageComposerState ()Lkotlinx/coroutines/flow/StateFlow; public final fun getMessageInput ()Lkotlinx/coroutines/flow/MutableStateFlow; - public final fun getMessageMode ()Lkotlinx/coroutines/flow/MutableStateFlow; - public final fun getOwnCapabilities ()Lkotlinx/coroutines/flow/StateFlow; - public final fun getValidationErrors ()Lkotlinx/coroutines/flow/MutableStateFlow; public final fun leaveThread ()V public final fun lockRecording ()V public final fun pauseRecording ()V diff --git a/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/viewmodel/messages/MessageComposerViewModel.kt b/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/viewmodel/messages/MessageComposerViewModel.kt index b98aaa6a809..178b60742bb 100644 --- a/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/viewmodel/messages/MessageComposerViewModel.kt +++ b/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/viewmodel/messages/MessageComposerViewModel.kt @@ -19,7 +19,6 @@ package io.getstream.chat.android.ui.viewmodel.messages import androidx.lifecycle.ViewModel import io.getstream.chat.android.core.internal.InternalStreamChatApi import io.getstream.chat.android.models.Attachment -import io.getstream.chat.android.models.ChannelCapabilities import io.getstream.chat.android.models.Command import io.getstream.chat.android.models.CreatePollParams import io.getstream.chat.android.models.Message @@ -31,9 +30,7 @@ import io.getstream.chat.android.ui.common.state.messages.MessageInput import io.getstream.chat.android.ui.common.state.messages.MessageMode import io.getstream.chat.android.ui.common.state.messages.Reply import io.getstream.chat.android.ui.common.state.messages.composer.MessageComposerState -import io.getstream.chat.android.ui.common.state.messages.composer.ValidationError import io.getstream.result.call.Call -import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -61,46 +58,6 @@ public class MessageComposerViewModel( */ public val messageInput: MutableStateFlow = messageComposerController.messageInput - /** - * Represents the remaining time until the user is allowed to send the next message. - */ - public val cooldownTimer: MutableStateFlow = messageComposerController.cooldownTimer - - /** - * Represents the list of validation errors for the current text input and the currently selected attachments. - */ - public val validationErrors: MutableStateFlow> = messageComposerController.validationErrors - - /** - * Represents the list of users that can be used to autocomplete the current mention input. - */ - public val mentionSuggestions: MutableStateFlow> = messageComposerController.mentionSuggestions - - /** - * Represents the list of commands to be displayed in the command suggestion list popup. - */ - public val commandSuggestions: MutableStateFlow> = messageComposerController.commandSuggestions - - /** - * Current message mode, either [MessageMode.Normal] or [MessageMode.MessageThread]. Used to determine if we're sending a thread - * reply or a regular message. - */ - public val messageMode: MutableStateFlow = messageComposerController.messageMode - - /** - * Gets the active [Edit] or [Reply] action, whichever is last, to show on the UI. - */ - public val lastActiveAction: Flow = messageComposerController.lastActiveAction - - /** - * Holds information about the abilities the current user - * is able to exercise in the given channel. - * - * e.g. send messages, delete messages, etc... - * For a full list @see [ChannelCapabilities]. - */ - public val ownCapabilities: StateFlow> = messageComposerController.ownCapabilities - /** * Called when the input changes and the internal state needs to be updated. * diff --git a/stream-chat-android-ui-components/src/test/kotlin/io/getstream/chat/android/ui/viewmodels/messages/MessageComposerViewModelTest.kt b/stream-chat-android-ui-components/src/test/kotlin/io/getstream/chat/android/ui/viewmodels/messages/MessageComposerViewModelTest.kt index 4875750d20b..c72d7a56f2c 100644 --- a/stream-chat-android-ui-components/src/test/kotlin/io/getstream/chat/android/ui/viewmodels/messages/MessageComposerViewModelTest.kt +++ b/stream-chat-android-ui-components/src/test/kotlin/io/getstream/chat/android/ui/viewmodels/messages/MessageComposerViewModelTest.kt @@ -201,7 +201,6 @@ internal class MessageComposerViewModelTest { val messageComposerState = viewModel.messageComposerState.value messageComposerState.messageMode `should be instance of` MessageMode.MessageThread::class - viewModel.messageMode.value `should be instance of` MessageMode.MessageThread::class } @Test @@ -217,7 +216,6 @@ internal class MessageComposerViewModelTest { val messageComposerState = viewModel.messageComposerState.value messageComposerState.messageMode `should be instance of` MessageMode.Normal::class - viewModel.messageMode.value `should be instance of` MessageMode.Normal::class } @Test @@ -265,7 +263,7 @@ internal class MessageComposerViewModelTest { .givenChannelState(channelData) .get() - val ownCapabilities = viewModel.ownCapabilities.value + val ownCapabilities = viewModel.messageComposerState.value.ownCapabilities ownCapabilities.size `should be equal to` 3 } @@ -280,7 +278,6 @@ internal class MessageComposerViewModelTest { viewModel.setMessageInput("/") viewModel.messageComposerState.value.commandSuggestions.size `should be equal to` 1 - viewModel.commandSuggestions.value.size `should be equal to` 1 } @Test @@ -295,7 +292,6 @@ internal class MessageComposerViewModelTest { viewModel.setMessageInput("") viewModel.messageComposerState.value.commandSuggestions.size `should be equal to` 0 - viewModel.commandSuggestions.value.size `should be equal to` 0 } @Test @@ -308,10 +304,9 @@ internal class MessageComposerViewModelTest { .get() viewModel.toggleCommandsVisibility() - viewModel.selectCommand(viewModel.commandSuggestions.value.first()) + viewModel.selectCommand(viewModel.messageComposerState.value.commandSuggestions.first()) viewModel.messageComposerState.value.commandSuggestions.size `should be equal to` 0 - viewModel.commandSuggestions.value.size `should be equal to` 0 viewModel.messageComposerState.value.inputValue `should be equal to` "/giphy " viewModel.messageInput.value.text `should be equal to` "/giphy " } @@ -331,7 +326,6 @@ internal class MessageComposerViewModelTest { advanceUntilIdle() viewModel.messageComposerState.value.mentionSuggestions.size `should be equal to` 2 - viewModel.mentionSuggestions.value.size `should be equal to` 2 } @Test @@ -348,11 +342,10 @@ internal class MessageComposerViewModelTest { viewModel.setMessageInput("@") advanceUntilIdle() - viewModel.selectMention(viewModel.mentionSuggestions.value.first()) + viewModel.selectMention(viewModel.messageComposerState.value.mentionSuggestions.first()) advanceUntilIdle() viewModel.messageComposerState.value.mentionSuggestions.size `should be equal to` 0 - viewModel.mentionSuggestions.value.size `should be equal to` 0 viewModel.messageInput.value.text `should be equal to` "@Jc Miñarro " } From 6a83f5050cbc2f7728183d8bacfb74442670223b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Mion?= Date: Fri, 20 Mar 2026 13:58:38 +0000 Subject: [PATCH 2/4] Restrict direct modification of state in `MessageComposerController` by exposing immutable `StateFlow` instead of `MutableStateFlow`. --- .../api/stream-chat-android-compose.api | 2 +- .../messages/MessageComposerViewModel.kt | 3 +- .../composer/MessageComposerController.kt | 133 +++++++++--------- .../api/stream-chat-android-ui-components.api | 2 +- .../messages/MessageComposerViewModel.kt | 3 +- 5 files changed, 74 insertions(+), 69 deletions(-) diff --git a/stream-chat-android-compose/api/stream-chat-android-compose.api b/stream-chat-android-compose/api/stream-chat-android-compose.api index 078910bdef8..1dc76d924c0 100644 --- a/stream-chat-android-compose/api/stream-chat-android-compose.api +++ b/stream-chat-android-compose/api/stream-chat-android-compose.api @@ -7033,7 +7033,7 @@ public final class io/getstream/chat/android/compose/viewmodel/messages/MessageC public final fun dismissMessageActions ()V public final fun getInputFocusEvents ()Lkotlinx/coroutines/flow/SharedFlow; public final fun getMessageComposerState ()Lkotlinx/coroutines/flow/StateFlow; - public final fun getMessageInput ()Lkotlinx/coroutines/flow/MutableStateFlow; + public final fun getMessageInput ()Lkotlinx/coroutines/flow/StateFlow; public final fun holdRecording (Lkotlin/Pair;)V public final fun leaveThread ()V public final fun lockRecording ()V diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/messages/MessageComposerViewModel.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/messages/MessageComposerViewModel.kt index 90c55d77971..30ea3cefe25 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/messages/MessageComposerViewModel.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/messages/MessageComposerViewModel.kt @@ -33,7 +33,6 @@ import io.getstream.chat.android.ui.common.state.messages.Reply import io.getstream.chat.android.ui.common.state.messages.composer.MessageComposerState import io.getstream.chat.android.ui.common.utils.typing.TypingUpdatesBuffer import io.getstream.result.call.Call -import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.StateFlow @@ -64,7 +63,7 @@ public class MessageComposerViewModel( /** * UI state of the current composer input. */ - public val messageInput: MutableStateFlow = messageComposerController.messageInput + public val messageInput: StateFlow = messageComposerController.messageInput /** * Called when the input changes and the internal state needs to be updated. diff --git a/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/feature/messages/composer/MessageComposerController.kt b/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/feature/messages/composer/MessageComposerController.kt index 1b380d1d2df..2c81fbe1fa8 100644 --- a/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/feature/messages/composer/MessageComposerController.kt +++ b/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/feature/messages/composer/MessageComposerController.kt @@ -75,6 +75,7 @@ import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.distinctUntilChangedBy @@ -268,11 +269,15 @@ public class MessageComposerController( private val sessionRepository = ComposerSessionRepository(savedStateHandle) + private val _state = MutableStateFlow(MessageComposerState()) + /** Full message composer state holding all the required information. */ - public val state: MutableStateFlow = MutableStateFlow(MessageComposerState()) + public val state: StateFlow = _state.asStateFlow() + + private val _messageInput = MutableStateFlow(MessageInput()) /** UI state of the current composer input. */ - public val messageInput: MutableStateFlow = MutableStateFlow(MessageInput()) + public val messageInput: StateFlow = _messageInput.asStateFlow() /** * Represents the list of users in the channel. @@ -289,17 +294,19 @@ public class MessageComposerController( */ private var cooldownTimerJob: Job? = null + private val _messageActions = MutableStateFlow>(mutableSetOf()) + /** * Set of currently active message actions. These are used to display different UI in the composer, * as well as help us decorate the message with information, such as the quoted message id. */ - public val messageActions: MutableStateFlow> = MutableStateFlow(mutableSetOf()) + public val messageActions: StateFlow> = _messageActions.asStateFlow() /** * Gets the active [Edit] or [Reply] action, whichever is last, to show on the UI. */ private val activeAction: MessageAction? - get() = messageActions.value.lastOrNull { it is Edit || it is Reply } + get() = _messageActions.value.lastOrNull { it is Edit || it is Reply } /** * Gives us information if the active action is Edit, for business logic purposes. @@ -311,19 +318,19 @@ public class MessageComposerController( * Gets the parent message id if we are in thread mode, or null otherwise. */ private val parentMessageId: String? - get() = (state.value.messageMode as? MessageMode.MessageThread)?.parentMessage?.id + get() = (_state.value.messageMode as? MessageMode.MessageThread)?.parentMessage?.id /** * Gets the current text input in the message composer. */ private val messageText: String - get() = messageInput.value.text + get() = _messageInput.value.text /** * Gives us information if the composer is in the "thread" mode. */ private val isInThread: Boolean - get() = state.value.messageMode is MessageMode.MessageThread + get() = _state.value.messageMode is MessageMode.MessageThread /** * Represents the selected mentions based on the message suggestion list. @@ -344,7 +351,7 @@ public class MessageComposerController( .onEach { messageValidator.maxMessageLength = it.maxMessageLength commands = it.commands - state.value = state.value.copy( + _state.value = _state.value.copy( hasCommands = commands.isNotEmpty(), pollsEnabled = it.pollsEnabled, ) @@ -375,9 +382,9 @@ public class MessageComposerController( @OptIn(FlowPreview::class) @Suppress("LongMethod") private fun setupComposerState() { - fetchDraftMessage(state.value.messageMode) - messageInput.onEach { value -> - state.value = state.value.copy(inputValue = value.text) + fetchDraftMessage(_state.value.messageMode) + _messageInput.onEach { value -> + _state.value = _state.value.copy(inputValue = value.text) if (canSendTypingUpdates.value) { typingUpdatesBuffer.onKeystroke(value.text) @@ -389,17 +396,17 @@ public class MessageComposerController( scope.launch { handleLinkPreviews() } }.launchIn(scope) - messageActions.onEach { actions -> + _messageActions.onEach { actions -> val activeAction = actions.lastOrNull { it is Edit || it is Reply } - state.update { it.copy(action = activeAction) } + _state.update { it.copy(action = activeAction) } }.launchIn(scope) ownCapabilities.onEach { ownCapabilities -> - state.value = state.value.copy(ownCapabilities = ownCapabilities) + _state.value = _state.value.copy(ownCapabilities = ownCapabilities) }.launchIn(scope) chatClient.clientState.user.onEach { currentUser -> - state.value = state.value.copy(currentUser = currentUser) + _state.value = _state.value.copy(currentUser = currentUser) }.launchIn(scope) audioRecordingController.recordingState.onEach { recording -> @@ -407,7 +414,7 @@ public class MessageComposerController( if (recording is RecordingState.Complete) { _recordingAttachment.value = recording.attachment } - state.update { it.copy(recording = recording) } + _state.update { it.copy(recording = recording) } syncAttachments() }.launchIn(scope) @@ -415,7 +422,7 @@ public class MessageComposerController( channelDraftMessages.onEach { if (it[channelCid] == null && !currentDraftId.isNullOrEmpty() && - state.value.messageMode is MessageMode.Normal + _state.value.messageMode is MessageMode.Normal ) { clearData() } @@ -424,7 +431,7 @@ public class MessageComposerController( threadDraftMessages.onEach { if (it[parentMessageId] == null && !currentDraftId.isNullOrEmpty() && - state.value.messageMode is MessageMode.MessageThread + _state.value.messageMode is MessageMode.MessageThread ) { clearData() } @@ -462,7 +469,7 @@ public class MessageComposerController( setMessageInputInternal(message.text, MessageInput.Source.Edit) _editModeMessage.value = fullMessage _editModeAttachments.value = attachments - messageActions.value += Edit(fullMessage) + _messageActions.update { it + Edit(fullMessage) } syncAttachments() } @@ -472,19 +479,19 @@ public class MessageComposerController( * @param value Current state value. */ public fun setMessageInput(value: String) { - if (this.messageInput.value.text == value) return - this.messageInput.value = MessageInput(value, MessageInput.Source.External) + if (_messageInput.value.text == value) return + _messageInput.value = MessageInput(value, MessageInput.Source.External) } private fun setMessageInputInternal(value: String, source: MessageInput.Source) { - if (this.messageInput.value.text == value) return - this.messageInput.value = MessageInput(value, source) + if (_messageInput.value.text == value) return + _messageInput.value = MessageInput(value, source) } private suspend fun saveDraftMessage(messageMode: MessageMode) { if (!config.isDraftMessageEnabled) return currentDraftId = null - when (val messageText = messageInput.value.text) { + when (val messageText = _messageInput.value.text) { "" -> clearDraftMessage(messageMode) else -> { getDraftMessageOrEmpty(messageMode).let { @@ -493,8 +500,8 @@ public class MessageComposerController( channelId = channelId, message = it.copy( text = messageText, - showInChannel = state.value.alsoSendToChannel, - replyMessage = (messageActions.value.firstOrNull { it is Reply } as? Reply)?.message, + showInChannel = _state.value.alsoSendToChannel, + replyMessage = (_messageActions.value.firstOrNull { it is Reply } as? Reply)?.message, ), ).await() } @@ -511,7 +518,7 @@ public class MessageComposerController( draftMessage.replyMessage ?.let { performMessageAction(Reply(it)) } ?: run { - messageActions.value = messageActions.value.filterNot { it is Reply }.toSet() + _messageActions.value = _messageActions.value.filterNot { it is Reply }.toSet() } } } @@ -524,11 +531,11 @@ public class MessageComposerController( * @param messageMode The current message mode. */ public fun setMessageMode(messageMode: MessageMode) { - val previousMode = state.value.messageMode + val previousMode = _state.value.messageMode if (isSameMessageMode(previousMode, messageMode)) return scope.launch(start = CoroutineStart.UNDISPATCHED) { saveDraftMessage(previousMode) - state.update { it.copy(messageMode = messageMode) } + _state.update { it.copy(messageMode = messageMode) } fetchDraftMessage(messageMode) } } @@ -546,14 +553,14 @@ public class MessageComposerController( * @param alsoSendToChannel If the message will be shown in the channel after it is sent. */ public fun setAlsoSendToChannel(alsoSendToChannel: Boolean) { - state.update { it.copy(alsoSendToChannel = alsoSendToChannel) } + _state.update { it.copy(alsoSendToChannel = alsoSendToChannel) } } /** * Handles selected [messageAction]. We only have three actions we can react to in the composer: - * - [ThreadReply] - We change the [messageMode] so we can send the message to a thread. + * - [ThreadReply] - We change the message mode so we can send the message to a thread. * - [Reply] - We need to reply to a message and set up the reply UI. - * - [Edit] - We need to change the [input] to the message we want to edit and change the UI to + * - [Edit] - We need to change the [messageInput] to the message we want to edit and change the UI to * match the editing action. * * @param messageAction The newly selected action. @@ -562,14 +569,14 @@ public class MessageComposerController( when (messageAction) { is ThreadReply -> setMessageMode(MessageMode.MessageThread(messageAction.message)) is Reply -> - messageActions.value = - (messageActions.value.filterNot { it is Reply } + messageAction).toSet() + _messageActions.value = + (_messageActions.value.filterNot { it is Reply } + messageAction).toSet() is Edit -> { setMessageInputInternal(messageAction.message.text, MessageInput.Source.Edit) _editModeMessage.value = messageAction.message _editModeAttachments.value = messageAction.message.attachments - messageActions.value += messageAction + _messageActions.update { it + messageAction } syncAttachments() } @@ -588,7 +595,7 @@ public class MessageComposerController( syncAttachments() } - this.messageActions.value = emptySet() + _messageActions.value = emptySet() } /** @@ -678,13 +685,13 @@ public class MessageComposerController( public fun clearData() { logger.i { "[clearData]" } dismissMessageActions() - scope.launch { clearDraftMessage(state.value.messageMode) } - messageInput.value = MessageInput() + scope.launch { clearDraftMessage(_state.value.messageMode) } + _messageInput.value = MessageInput() clearAttachments() clearActiveCommand() - state.update { it.copy(validationErrors = emptyList()) } + _state.update { it.copy(validationErrors = emptyList()) } if (!isInThread) { - state.update { it.copy(alsoSendToChannel = false) } + _state.update { it.copy(alsoSendToChannel = false) } } } @@ -732,8 +739,8 @@ public class MessageComposerController( chatClient.deleteMessage(activeMessage.id, true).enqueue() } val preparedMessage = message.copy( - showInChannel = isInThread && state.value.alsoSendToChannel, - skipEnrichUrl = state.value.linkPreviews.isEmpty(), + showInChannel = isInThread && _state.value.alsoSendToChannel, + skipEnrichUrl = _state.value.linkPreviews.isEmpty(), ) clearData() @@ -803,7 +810,7 @@ public class MessageComposerController( val currentUserId = chatClient.getCurrentUser()?.id val fullText = if (config.isActiveCommandEnabled) { - state.value.activeCommand?.let { "/${it.name} $message" } ?: message + _state.value.activeCommand?.let { "/${it.name} $message" } ?: message } else { message } @@ -870,13 +877,13 @@ public class MessageComposerController( typingUpdatesBuffer.clear() audioRecordingController.onCleared() scope.launch { - saveDraftMessage(state.value.messageMode) + saveDraftMessage(_state.value.messageMode) scope.cancel() } } private fun syncAttachments() { - state.update { + _state.update { it.copy( attachments = _editModeAttachments.value + _selectedAttachments.value.values.toList() + @@ -890,8 +897,8 @@ public class MessageComposerController( * Checks the current input for validation errors. */ private fun handleValidationErrors() { - state.update { - it.copy(validationErrors = messageValidator.validateMessage(messageInput.value.text, it.attachments)) + _state.update { + it.copy(validationErrors = messageValidator.validateMessage(_messageInput.value.text, it.attachments)) } } @@ -918,7 +925,7 @@ public class MessageComposerController( setMessageInputInternal(augmentedMessageText, MessageInput.Source.MentionSelected) selectedMentions += mention - state.update { it.copy(selectedMentions = selectedMentions) } + _state.update { it.copy(selectedMentions = selectedMentions) } } /** @@ -930,7 +937,7 @@ public class MessageComposerController( * @param command The command that was selected. */ public fun selectCommand(command: Command) { - state.update { it.copy(activeCommand = command) } + _state.update { it.copy(activeCommand = command) } setMessageInputInternal( value = if (config.isActiveCommandEnabled) "" else "/${command.name} ", source = MessageInput.Source.CommandSelected, @@ -942,7 +949,7 @@ public class MessageComposerController( * Dismisses the active command, clearing [MessageComposerState.activeCommand] and resetting the text input. */ public fun clearActiveCommand() { - state.update { it.copy(activeCommand = null) } + _state.update { it.copy(activeCommand = null) } setMessageInputInternal("", MessageInput.Source.Default) } @@ -950,7 +957,7 @@ public class MessageComposerController( * Toggles the visibility of the command suggestion list popup. */ public fun toggleCommandsVisibility() { - state.update { s -> + _state.update { s -> val showCommands = s.commandSuggestions.isEmpty() s.copy(commandSuggestions = if (showCommands) commands else emptyList()) } @@ -960,7 +967,7 @@ public class MessageComposerController( * Dismisses the suggestions popup above the message composer. */ public fun dismissSuggestionsPopup() { - state.update { + _state.update { it.copy( mentionSuggestions = emptyList(), commandSuggestions = emptyList(), @@ -1039,7 +1046,7 @@ public class MessageComposerController( audioRecordingController.completeRecordingSync().onSuccess { recording -> _recordingAttachment.value = recording syncAttachments() - sendMessage(buildNewMessage(messageInput.value.text, state.value.attachments), callback = {}) + sendMessage(buildNewMessage(_messageInput.value.text, _state.value.attachments), callback = {}) } } } @@ -1048,13 +1055,13 @@ public class MessageComposerController( * Shows the mention suggestion list popup if necessary. */ private fun handleMentionSuggestions() { - val messageInput = messageInput.value - if (messageInput.source == MessageInput.Source.MentionSelected) { + val currentInput = _messageInput.value + if (currentInput.source == MessageInput.Source.MentionSelected) { logger.v { "[handleMentionSuggestions] rejected (messageInput came from mention selection)" } - state.update { it.copy(mentionSuggestions = emptyList()) } + _state.update { it.copy(mentionSuggestions = emptyList()) } return } - val inputText = messageInput.text + val inputText = currentInput.text scope.launch(DispatcherProvider.IO) { val suggestion = mentionSuggester.typingSuggestion(inputText) logger.v { "[handleMentionSuggestions] suggestion: $suggestion" } @@ -1064,7 +1071,7 @@ public class MessageComposerController( emptyList() } withContext(DispatcherProvider.Main) { - state.update { it.copy(mentionSuggestions = result) } + _state.update { it.copy(mentionSuggestions = result) } } } } @@ -1074,13 +1081,13 @@ public class MessageComposerController( */ private fun handleCommandSuggestions() { val containsCommand = CommandPattern.matcher(messageText).find() - val suggestions = if (containsCommand && state.value.attachments.isEmpty()) { + val suggestions = if (containsCommand && _state.value.attachments.isEmpty()) { val commandPattern = messageText.removePrefix("/") commands.filter { it.name.startsWith(commandPattern) } } else { emptyList() } - state.update { it.copy(commandSuggestions = suggestions) } + _state.update { it.copy(commandSuggestions = suggestions) } } /** @@ -1099,7 +1106,7 @@ public class MessageComposerController( .coerceAtLeast(0) fun updateCooldownTime(timeRemaining: Int) { - state.update { it.copy(coolDownTime = timeRemaining) } + _state.update { it.copy(coolDownTime = timeRemaining) } } // If the user is still unable to send messages show the timer @@ -1130,7 +1137,7 @@ public class MessageComposerController( .map { it.value } logger.v { "[handleLinkPreviews] previews: ${previews.map { it.originUrl }}" } - state.update { it.copy(linkPreviews = previews) } + _state.update { it.copy(linkPreviews = previews) } } private fun loadLatestMessagesIfNeeded() { @@ -1215,7 +1222,7 @@ public class MessageComposerController( * Cancels any link preview. */ public fun cancelLinkPreview() { - state.update { it.copy(linkPreviews = emptyList()) } + _state.update { it.copy(linkPreviews = emptyList()) } } } diff --git a/stream-chat-android-ui-components/api/stream-chat-android-ui-components.api b/stream-chat-android-ui-components/api/stream-chat-android-ui-components.api index 53c0574a12b..d747ec1cd84 100644 --- a/stream-chat-android-ui-components/api/stream-chat-android-ui-components.api +++ b/stream-chat-android-ui-components/api/stream-chat-android-ui-components.api @@ -4411,7 +4411,7 @@ public final class io/getstream/chat/android/ui/viewmodel/messages/MessageCompos public final fun dismissMessageActions ()V public final fun dismissSuggestionsPopup ()V public final fun getMessageComposerState ()Lkotlinx/coroutines/flow/StateFlow; - public final fun getMessageInput ()Lkotlinx/coroutines/flow/MutableStateFlow; + public final fun getMessageInput ()Lkotlinx/coroutines/flow/StateFlow; public final fun leaveThread ()V public final fun lockRecording ()V public final fun pauseRecording ()V diff --git a/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/viewmodel/messages/MessageComposerViewModel.kt b/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/viewmodel/messages/MessageComposerViewModel.kt index 178b60742bb..02d07e6f56c 100644 --- a/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/viewmodel/messages/MessageComposerViewModel.kt +++ b/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/viewmodel/messages/MessageComposerViewModel.kt @@ -31,7 +31,6 @@ import io.getstream.chat.android.ui.common.state.messages.MessageMode import io.getstream.chat.android.ui.common.state.messages.Reply import io.getstream.chat.android.ui.common.state.messages.composer.MessageComposerState import io.getstream.result.call.Call -import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow /** @@ -56,7 +55,7 @@ public class MessageComposerViewModel( /** * UI state of the current composer input. */ - public val messageInput: MutableStateFlow = messageComposerController.messageInput + public val messageInput: StateFlow = messageComposerController.messageInput /** * Called when the input changes and the internal state needs to be updated. From 3c91592d6971089c1770a4775a6ea28bd0bba34f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Mion?= Date: Fri, 20 Mar 2026 14:49:54 +0000 Subject: [PATCH 3/4] saveDraftMessage(previousMode) is suspend (network .await()). While it ran, _state.value.messageMode was still the old mode, so overlapping setMessageMode calls could read stale mode. --- .../feature/messages/composer/MessageComposerController.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/feature/messages/composer/MessageComposerController.kt b/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/feature/messages/composer/MessageComposerController.kt index 2c81fbe1fa8..0dbadb3f446 100644 --- a/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/feature/messages/composer/MessageComposerController.kt +++ b/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/feature/messages/composer/MessageComposerController.kt @@ -534,8 +534,8 @@ public class MessageComposerController( val previousMode = _state.value.messageMode if (isSameMessageMode(previousMode, messageMode)) return scope.launch(start = CoroutineStart.UNDISPATCHED) { - saveDraftMessage(previousMode) _state.update { it.copy(messageMode = messageMode) } + saveDraftMessage(previousMode) fetchDraftMessage(messageMode) } } From aeb30b0d53ed4b691dc3a0e1d6bfdda4511bc3c7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Mion?= Date: Fri, 20 Mar 2026 14:51:28 +0000 Subject: [PATCH 4/4] =?UTF-8?q?=5Fstate=20was=20storing=20the=20same=20Mut?= =?UTF-8?q?ableSet=20instance=20as=20MessageComposerState.=20Further=20+?= =?UTF-8?q?=3D=20/=20clear()=20changed=20that=20object=20in=20place,=20so?= =?UTF-8?q?=20you=20lost=20clear=20=E2=80=9Csnapshot=E2=80=9D=20semantics?= =?UTF-8?q?=20and=20StateFlow=20might=20not=20emit=20when=20only=20the=20s?= =?UTF-8?q?et=20contents=20changed.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../feature/messages/composer/MessageComposerController.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/feature/messages/composer/MessageComposerController.kt b/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/feature/messages/composer/MessageComposerController.kt index 0dbadb3f446..b7ba1a216b5 100644 --- a/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/feature/messages/composer/MessageComposerController.kt +++ b/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/feature/messages/composer/MessageComposerController.kt @@ -855,6 +855,7 @@ public class MessageComposerController( text.contains("@${it.user.name.lowercase()}") }.map { it.user.id } this.selectedMentions.clear() + _state.update { it.copy(selectedMentions = emptySet()) } return remainingMentions.toMutableList() } @@ -925,7 +926,7 @@ public class MessageComposerController( setMessageInputInternal(augmentedMessageText, MessageInput.Source.MentionSelected) selectedMentions += mention - _state.update { it.copy(selectedMentions = selectedMentions) } + _state.update { it.copy(selectedMentions = selectedMentions.toSet()) } } /**