From 5d3e97a13f7679617b02e66adccce153438e07e5 Mon Sep 17 00:00:00 2001 From: Gian <47775302+gpunto@users.noreply.github.com> Date: Thu, 1 Oct 2026 18:15:42 +0200 Subject: [PATCH 1/3] Restore channel drafts after a restart and keep drafts the composer never showed --- .../state/plugin/internal/StatePlugin.kt | 4 +- .../internal/QueryChannelListenerState.kt | 8 +- .../internal/QueryChannelsListenerState.kt | 3 + .../QueryGroupedChannelsListenerState.kt | 2 + .../global/internal/MutableGlobalState.kt | 6 + .../state/sync/internal/SyncManager.kt | 2 + .../state/internal/SyncManagerTest.kt | 53 ++++++ .../internal/QueryChannelListenerStateTest.kt | 67 ++++++++ .../QueryChannelsListenerStateTest.kt | 29 +++- .../QueryGroupedChannelsListenerStateTest.kt | 30 ++++ .../composer/MessageComposerController.kt | 38 +++-- .../composer/MessageComposerControllerTest.kt | 158 ++++++++++++++++++ 12 files changed, 383 insertions(+), 17 deletions(-) create mode 100644 stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/listener/internal/QueryChannelListenerStateTest.kt diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/internal/StatePlugin.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/internal/StatePlugin.kt index 6efe58b23015..482c5a52f5a7 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/internal/StatePlugin.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/internal/StatePlugin.kt @@ -117,13 +117,13 @@ public class StatePlugin internal constructor( private val chatClientConfig: ChatClientConfig, ) : Plugin, QueryMembersListener by QueryMembersListenerState(logic), - QueryChannelsListener by QueryChannelsListenerState(logic, queryingChannelsFree), + QueryChannelsListener by QueryChannelsListenerState(logic, mutableGlobalState, queryingChannelsFree), QueryGroupedChannelsListener by QueryGroupedChannelsListenerState( logic = logic, globalState = mutableGlobalState, groupedUnreadChannelsUpdater = groupedUnreadChannelsUpdater, ), - QueryChannelListener by QueryChannelListenerState(logic), + QueryChannelListener by QueryChannelListenerState(logic, mutableGlobalState), ThreadQueryListener by ThreadQueryListenerState(logic), ChannelMarkReadListener by ChannelMarkReadListenerState(logic), EditMessageListener by EditMessageListenerState(logic, clientState), diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/listener/internal/QueryChannelListenerState.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/listener/internal/QueryChannelListenerState.kt index 7e07d1c10bbf..5f23935fc50a 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/listener/internal/QueryChannelListenerState.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/listener/internal/QueryChannelListenerState.kt @@ -18,6 +18,7 @@ package io.getstream.chat.android.client.internal.state.plugin.listener.internal import io.getstream.chat.android.client.api.models.QueryChannelRequest import io.getstream.chat.android.client.internal.state.plugin.logic.internal.LogicRegistry +import io.getstream.chat.android.client.internal.state.plugin.state.global.internal.MutableGlobalState import io.getstream.chat.android.client.plugin.listeners.QueryChannelListener import io.getstream.chat.android.client.utils.stringify import io.getstream.chat.android.models.Channel @@ -28,8 +29,12 @@ import io.getstream.result.Result * Implementation of [QueryChannelListener] that handles state updates in the SDK. * * @param logic [LogicRegistry] + * @param mutableGlobalState [MutableGlobalState] */ -internal class QueryChannelListenerState(private val logic: LogicRegistry) : QueryChannelListener { +internal class QueryChannelListenerState( + private val logic: LogicRegistry, + private val mutableGlobalState: MutableGlobalState, +) : QueryChannelListener { private val logger by taggedLogger("QueryChannelListenerS") @@ -77,5 +82,6 @@ internal class QueryChannelListenerState(private val logic: LogicRegistry) : Que "request: $request, result: ${result.stringify { it.cid }}" } logic.channel(channelType, channelId).onQueryChannelResult(request, result) + result.onSuccess { channel -> mutableGlobalState.updateChannelDrafts(listOf(channel)) } } } diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/listener/internal/QueryChannelsListenerState.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/listener/internal/QueryChannelsListenerState.kt index ee836207908e..ffb1b491721d 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/listener/internal/QueryChannelsListenerState.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/listener/internal/QueryChannelsListenerState.kt @@ -20,6 +20,7 @@ import io.getstream.chat.android.client.api.models.QueryChannelsRequest import io.getstream.chat.android.client.api.models.QueryChannelsResult import io.getstream.chat.android.client.internal.state.model.querychannels.pagination.internal.toOfflinePaginationRequest import io.getstream.chat.android.client.internal.state.plugin.logic.internal.LogicRegistry +import io.getstream.chat.android.client.internal.state.plugin.state.global.internal.MutableGlobalState import io.getstream.chat.android.client.plugin.listeners.QueryChannelsListener import io.getstream.result.Result import kotlinx.coroutines.flow.MutableStateFlow @@ -39,6 +40,7 @@ import kotlinx.coroutines.flow.MutableStateFlow */ internal class QueryChannelsListenerState( private val logic: LogicRegistry, + private val mutableGlobalState: MutableGlobalState, private val queryingChannelsFree: MutableStateFlow, ) : QueryChannelsListener { @@ -65,6 +67,7 @@ internal class QueryChannelsListenerState( } val channels = result.map(QueryChannelsResult::channels) queryChannelsLogic.onQueryChannelsResult(channels, request) + channels.onSuccess(mutableGlobalState::updateChannelDrafts) queryingChannelsFree.value = true } } diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/listener/internal/QueryGroupedChannelsListenerState.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/listener/internal/QueryGroupedChannelsListenerState.kt index 4f5fa9f9947c..4e7d3d71e533 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/listener/internal/QueryGroupedChannelsListenerState.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/listener/internal/QueryGroupedChannelsListenerState.kt @@ -105,6 +105,8 @@ internal class QueryGroupedChannelsListenerState( // would never come down. finishFirstPageLoads(groups.orEmpty().keys - result.value.groups.keys, groups, completed = true) + result.value.groups.values.forEach { group -> globalState.updateChannelDrafts(group.channels) } + // Route each returned group's channels into the per-group state. The captured config lets // both ChannelListViewModel.loadMoreGroupedChannels and SyncManager.updateGroupedQueryChannels // reuse the caller's original parameters on paginated and recovery calls respectively. diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/state/global/internal/MutableGlobalState.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/state/global/internal/MutableGlobalState.kt index 06a8df003116..cf2457ccee8d 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/state/global/internal/MutableGlobalState.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/state/global/internal/MutableGlobalState.kt @@ -18,6 +18,7 @@ package io.getstream.chat.android.client.internal.state.plugin.state.global.inte import io.getstream.chat.android.client.api.state.GlobalState import io.getstream.chat.android.client.internal.state.utils.internal.mapState +import io.getstream.chat.android.models.Channel import io.getstream.chat.android.models.ChannelMute import io.getstream.chat.android.models.DraftMessage import io.getstream.chat.android.models.Location @@ -130,6 +131,11 @@ internal class MutableGlobalState( ?.let { it.value += (draftMessage.cid to draftMessage) } } + /** Adds the drafts the server returned with [channels]. */ + fun updateChannelDrafts(channels: List) { + channels.forEach { channel -> channel.draftMessage?.let(::updateDraftMessage) } + } + fun removeDraftMessage(draftMessage: DraftMessage) { draftMessage.parentId?.let { parentId -> _threadDraftMessages?.let { it.value -= parentId } diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/sync/internal/SyncManager.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/sync/internal/SyncManager.kt index 8d1ae86f9572..0b5d07eb48f9 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/sync/internal/SyncManager.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/sync/internal/SyncManager.kt @@ -709,6 +709,7 @@ internal class SyncManager( ?.let(logicRegistry::channel) ?.updateDataForChannel(channel, channel.messages.size) } + mutableGlobalState.updateChannelDrafts(foundChannels) repos.storeStateForChannels(foundChannels) val foundCids = foundChannels.mapTo(mutableSetOf(), Channel::cid) val stillMissingChannelIds = missingChannelIds.filterNot { it.cid in foundCids } @@ -785,6 +786,7 @@ internal class SyncManager( ?.let(logicRegistry::channel) ?.updateDataForChannel(channel, channel.messages.size) } + mutableGlobalState.updateChannelDrafts(foundChannels) repos.storeStateForChannels(foundChannels) foundChannels.mapTo(refreshedCids, Channel::cid) } diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/internal/SyncManagerTest.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/internal/SyncManagerTest.kt index db6fe8c3ea63..322e764098d3 100644 --- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/internal/SyncManagerTest.kt +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/internal/SyncManagerTest.kt @@ -295,6 +295,29 @@ internal class SyncManagerTest { assertEquals(createdAt, _syncState.value?.lastSyncedAt) } + @Test + fun `performSync adds the drafts of the refreshed watched channels to the global state`() = + runTest(testDispatcher) { + /* Given */ + val createdAt = localDate() + val rawCreatedAt = streamDateFormatter.format(createdAt) + val watchedChannel = randomChannel( + type = "messaging", + id = "watched", + draftMessage = randomDraftMessage(parentId = null), + ) + givenOversizedSyncPayload(eventCount = 3, createdAt = createdAt, rawCreatedAt = rawCreatedAt) + givenWatchedChannels(cids = setOf(watchedChannel.cid), foundChannels = listOf(watchedChannel)) + + val syncManager = buildSyncManager(eventReplayMaxCount = 2) + + /* When */ + syncManager.performSync(cids = listOf("1", "2")) + + /* Then */ + verify(mutableGlobalState).updateChannelDrafts(listOf(watchedChannel)) + } + @Test fun `performSync replays events when the payload size equals the replay limit`() = runTest(testDispatcher) { /* Given */ @@ -1428,6 +1451,36 @@ internal class SyncManagerTest { assertEquals(setOf(cidA, cidB), filter.values) } + @Test + fun `reconnect should add the drafts of the refreshed channels to the global state`() = + runTest(testDispatcher) { + val createdAt = localDate() + val rawCreatedAt = streamDateFormatter.format(createdAt) + val channel = randomChannel(type = "messaging", id = "a", draftMessage = randomDraftMessage(parentId = null)) + val activeState: ChannelState = mock { + on(it.cid) doReturn channel.cid + on(it.recoveryNeeded) doReturn false + } + + whenever(logicRegistry.getActiveQueryChannelsLogic()) doReturn emptyList() + whenever(logicRegistry.getActiveChannelsLogic()) doReturn emptyList() + whenever(logicRegistry.channel(any(), any())) doReturn mock() + whenever(stateRegistry.getActiveChannelStates()) doReturn mapOf(ChannelId.fromCid(channel.cid)!! to activeState) + whenever(chatClient.queryChannelsInternal(any())) doReturn TestCall( + Result.Success(QueryChannelsResult(channels = listOf(channel), predefinedFilter = null)), + ) + whenever(clientState.isOnline) doReturn true + whenever(repositoryFacade.selectSyncState(user.id)) doReturn null + + val syncManager = buildSyncManager() + syncManager.onEvent(connectedEvent(createdAt, rawCreatedAt)) + delay(100) + syncManager.onEvent(connectedEvent(createdAt, rawCreatedAt)) + delay(100) + + verify(mutableGlobalState).updateChannelDrafts(listOf(channel)) + } + @Test fun `on reconnect with multiple grouped queries should pass per-group limits and shared flags`() = runTest(testDispatcher) { diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/listener/internal/QueryChannelListenerStateTest.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/listener/internal/QueryChannelListenerStateTest.kt new file mode 100644 index 000000000000..b2618c0f2e13 --- /dev/null +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/listener/internal/QueryChannelListenerStateTest.kt @@ -0,0 +1,67 @@ +/* + * Copyright (c) 2014-2026 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-chat-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.getstream.chat.android.client.internal.state.plugin.listener.internal + +import io.getstream.chat.android.client.api.models.QueryChannelRequest +import io.getstream.chat.android.client.internal.state.plugin.logic.channel.internal.ChannelLogic +import io.getstream.chat.android.client.internal.state.plugin.logic.internal.LogicRegistry +import io.getstream.chat.android.client.internal.state.plugin.state.global.internal.MutableGlobalState +import io.getstream.chat.android.randomChannel +import io.getstream.chat.android.randomDraftMessage +import io.getstream.chat.android.randomString +import io.getstream.result.Error +import io.getstream.result.Result +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock + +internal class QueryChannelListenerStateTest { + + private val logicRegistry: LogicRegistry = mock { + on { channel(any(), any()) } doReturn mock() + } + private val mutableGlobalState = MutableGlobalState(randomString()) + private val listener = QueryChannelListenerState(logicRegistry, mutableGlobalState) + + @Test + fun `given the server returns the channel draft, it should reach the global state`() = runTest { + val draftMessage = randomDraftMessage(parentId = null) + val channel = randomChannel(draftMessage = draftMessage) + + listener.onQueryChannelResult(Result.Success(channel), channel.type, channel.id, QueryChannelRequest()) + + assertEquals(mapOf(draftMessage.cid to draftMessage), mutableGlobalState.channelDraftMessages.value) + } + + @Test + fun `given the query fails, it should leave the global state drafts untouched`() = runTest { + val draftMessage = randomDraftMessage(parentId = null) + mutableGlobalState.updateDraftMessage(draftMessage) + + listener.onQueryChannelResult( + Result.Failure(Error.GenericError(randomString())), + randomString(), + randomString(), + QueryChannelRequest(), + ) + + assertEquals(mapOf(draftMessage.cid to draftMessage), mutableGlobalState.channelDraftMessages.value) + } +} diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/listener/internal/QueryChannelsListenerStateTest.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/listener/internal/QueryChannelsListenerStateTest.kt index b061d768a883..2ae701c6dbd2 100644 --- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/listener/internal/QueryChannelsListenerStateTest.kt +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/listener/internal/QueryChannelsListenerStateTest.kt @@ -21,14 +21,18 @@ import io.getstream.chat.android.client.api.models.QueryChannelsRequest import io.getstream.chat.android.client.api.models.QueryChannelsResult import io.getstream.chat.android.client.internal.state.plugin.logic.internal.LogicRegistry import io.getstream.chat.android.client.internal.state.plugin.logic.querychannels.internal.QueryChannelsLogic +import io.getstream.chat.android.client.internal.state.plugin.state.global.internal.MutableGlobalState import io.getstream.chat.android.models.Channel import io.getstream.chat.android.models.Filters import io.getstream.chat.android.models.querysort.QuerySortByField import io.getstream.chat.android.randomChannel +import io.getstream.chat.android.randomDraftMessage +import io.getstream.chat.android.randomString import io.getstream.result.Error import io.getstream.result.Result import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test @@ -44,6 +48,7 @@ internal class QueryChannelsListenerStateTest { private lateinit var queryChannelsLogic: QueryChannelsLogic private lateinit var logicRegistry: LogicRegistry private lateinit var queryingChannelsFree: MutableStateFlow + private lateinit var mutableGlobalState: MutableGlobalState private lateinit var listener: QueryChannelsListenerState @BeforeEach @@ -53,7 +58,8 @@ internal class QueryChannelsListenerStateTest { on { queryChannels(any()) } doReturn queryChannelsLogic } queryingChannelsFree = MutableStateFlow(true) - listener = QueryChannelsListenerState(logicRegistry, queryingChannelsFree) + mutableGlobalState = MutableGlobalState(randomString()) + listener = QueryChannelsListenerState(logicRegistry, mutableGlobalState, queryingChannelsFree) } @Test @@ -99,6 +105,27 @@ internal class QueryChannelsListenerStateTest { verify(queryChannelsLogic, never()).applyResolvedSpec(any(), any()) } + @Test + fun `onQueryChannelsResult adds the returned channel drafts to the global state`() = runTest { + val draftMessage = randomDraftMessage(parentId = null) + val result = Result.Success( + QueryChannelsResult( + channels = listOf(randomChannel(draftMessage = draftMessage), randomChannel(draftMessage = null)), + predefinedFilter = null, + ), + ) + + val request = QueryChannelsRequest( + filter = Filters.eq("type", "messaging"), + querySort = QuerySortByField.descByName("last_message_at"), + limit = 30, + ) + + listener.onQueryChannelsResult(result, request) + + assertEquals(mapOf(draftMessage.cid to draftMessage), mutableGlobalState.channelDraftMessages.value) + } + @Test fun `onQueryChannelsResult does not apply resolved spec on failure`() = runTest { // Given diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/listener/internal/QueryGroupedChannelsListenerStateTest.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/listener/internal/QueryGroupedChannelsListenerStateTest.kt index 8423da25eda5..22f1847c525e 100644 --- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/listener/internal/QueryGroupedChannelsListenerStateTest.kt +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/listener/internal/QueryGroupedChannelsListenerStateTest.kt @@ -26,6 +26,8 @@ import io.getstream.chat.android.client.internal.state.plugin.state.global.inter import io.getstream.chat.android.models.GroupedChannels import io.getstream.chat.android.models.GroupedChannelsGroup import io.getstream.chat.android.models.GroupedChannelsGroupQuery +import io.getstream.chat.android.randomChannel +import io.getstream.chat.android.randomDraftMessage import io.getstream.result.Error import io.getstream.result.Result import kotlinx.coroutines.flow.MutableStateFlow @@ -220,6 +222,34 @@ internal class QueryGroupedChannelsListenerStateTest { verify(globalState, never()).setGroupedUnreadChannels(any()) } + @Test + fun `successful result adds the returned channel drafts to the global state`() = runTest { + val channels = listOf(randomChannel(draftMessage = randomDraftMessage(parentId = null))) + val result = Result.Success( + value = GroupedChannels( + groups = mapOf( + "support" to GroupedChannelsGroup( + groupKey = "support", + channels = channels, + unreadChannels = 0, + next = null, + prev = null, + ), + ), + ), + ) + + listener.onQueryGroupedChannelsResult( + result = result, + limit = null, + groups = mapOf("support" to GroupedChannelsGroupQuery(next = "cursor")), + watch = false, + presence = false, + ) + + verify(globalState).updateChannelDrafts(channels) + } + @Test fun `successful pagination result with prev cursor does not update grouped unread counts`() = runTest { // given 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 96ca63b469ad..3272719dd4df 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 @@ -452,20 +452,22 @@ public class MessageComposerController( if (config.draftMessageEnabled) { channelDraftMessages.onEach { - if (it[channelCid] == null && - !currentDraftId.isNullOrEmpty() && - _state.value.messageMode is MessageMode.Normal - ) { - clearData() + val messageMode = _state.value.messageMode + if (messageMode !is MessageMode.Normal) return@onEach + val draftMessage = it[channelCid] + when { + draftMessage == null && !currentDraftId.isNullOrEmpty() -> clearData() + draftMessage != null && canShowArrivedDraft() -> fetchDraftMessage(messageMode) } }.launchIn(scope) threadDraftMessages.onEach { - if (it[parentMessageId] == null && - !currentDraftId.isNullOrEmpty() && - _state.value.messageMode is MessageMode.MessageThread - ) { - clearData() + val messageMode = _state.value.messageMode + if (messageMode !is MessageMode.MessageThread) return@onEach + val draftMessage = it[parentMessageId] + when { + draftMessage == null && !currentDraftId.isNullOrEmpty() -> clearData() + draftMessage != null && canShowArrivedDraft() -> fetchDraftMessage(messageMode) } }.launchIn(scope) } @@ -540,13 +542,15 @@ public class MessageComposerController( private suspend fun saveDraftMessage(messageMode: MessageMode) { if (!config.draftMessageEnabled) return + val isDraftShown = currentDraftId != null currentDraftId = null val inputText = _messageInput.value.text // In legacy mode the slash + name + args is encoded inside `text`, so command/args fields // would duplicate what's already there and confuse cross-SDK readers. val activeCommand = _state.value.activeCommand.takeIf { config.activeCommandEnabled } if (inputText.isEmpty() && activeCommand == null) { - clearDraftMessage(messageMode) + // Only an emptied draft is deleted: one that never reached the input is still the user's + if (isDraftShown) clearDraftMessage(messageMode) return } getDraftMessageOrEmpty(messageMode).let { @@ -570,8 +574,9 @@ public class MessageComposerController( // doesn't block the new mode's reply-action restore or leak into a later cancel-restore. _state.update { it.copy(activeCommand = null) } discardCommandStash() - getDraftMessageOrEmpty(messageMode).let { draftMessage -> - currentDraftId = draftMessage.id + val storedDraftMessage = getDraftMessage(messageMode) + currentDraftId = storedDraftMessage?.id + (storedDraftMessage ?: messageMode.emptyDraftMessage()).let { draftMessage -> // Restore the reply action first so command-availability checks see the right action. draftMessage.replyMessage ?.let { performMessageAction(Reply(it)) } @@ -1518,6 +1523,13 @@ public class MessageComposerController( val activeCommandEnabled: Boolean = false, ) + /** A draft that arrives after the composer opened is shown only while the user has not started composing. */ + private fun canShowArrivedDraft(): Boolean = + currentDraftId == null && + _messageInput.value.text.isEmpty() && + _selectedAttachments.value.isEmpty() && + _state.value.activeCommand == null + private fun getDraftMessageOrEmpty(messageMode: MessageMode): DraftMessage = getDraftMessage(messageMode) ?: messageMode.emptyDraftMessage() diff --git a/stream-chat-android-ui-common/src/test/kotlin/io/getstream/chat/android/ui/common/feature/messages/composer/MessageComposerControllerTest.kt b/stream-chat-android-ui-common/src/test/kotlin/io/getstream/chat/android/ui/common/feature/messages/composer/MessageComposerControllerTest.kt index a28868cd004b..3636cdbfc82c 100644 --- a/stream-chat-android-ui-common/src/test/kotlin/io/getstream/chat/android/ui/common/feature/messages/composer/MessageComposerControllerTest.kt +++ b/stream-chat-android-ui-common/src/test/kotlin/io/getstream/chat/android/ui/common/feature/messages/composer/MessageComposerControllerTest.kt @@ -22,6 +22,7 @@ import io.getstream.chat.android.client.ChatClient import io.getstream.chat.android.client.api.state.GlobalState import io.getstream.chat.android.client.audio.AudioPlayer import io.getstream.chat.android.client.channel.state.ChannelState +import io.getstream.chat.android.client.events.ChatEvent import io.getstream.chat.android.client.setup.state.ClientState import io.getstream.chat.android.models.App import io.getstream.chat.android.models.AppSettings @@ -2064,6 +2065,151 @@ internal class MessageComposerControllerTest { assertEquals("thread draft", controller.state.value.inputValue) } + @Test + fun `Given the input is empty When a draft arrives Then the draft is shown`() = runTest { + val channelDrafts = MutableStateFlow(mapOf()) + val controller = Fixture() + .givenAppSettings() + .givenAudioPlayer(mock()) + .givenClientState(randomUser()) + .givenDraftFlows(channelDrafts = channelDrafts) + .givenChannelState() + .get() + advanceUntilIdle() + + channelDrafts.value = mapOf(CID to DraftMessage(id = "draft", cid = CID, text = "draft text")) + advanceUntilIdle() + + assertEquals("draft text", controller.state.value.inputValue) + } + + @Test + fun `Given the user is typing When a draft arrives Then the input is kept`() = runTest { + val channelDrafts = MutableStateFlow(mapOf()) + val controller = Fixture() + .givenAppSettings() + .givenAudioPlayer(mock()) + .givenClientState(randomUser()) + .givenDraftFlows(channelDrafts = channelDrafts) + .givenChannelState() + .get() + advanceUntilIdle() + controller.setMessageInput("typed text") + + channelDrafts.value = mapOf(CID to DraftMessage(id = "draft", cid = CID, text = "draft text")) + advanceUntilIdle() + + assertEquals("typed text", controller.state.value.inputValue) + } + + @Test + fun `Given a draft that never reached the input When the composer closes empty Then the draft is not deleted`() = + runTest { + val channelDrafts = MutableStateFlow(mapOf()) + val fixture = Fixture() + .givenAppSettings() + .givenAudioPlayer(mock()) + .givenClientState(randomUser()) + .givenDraftFlows(channelDrafts = channelDrafts) + .givenChannelState() + .givenDraftMessageStubs() + .givenStopTyping() + val controller = fixture.get() + advanceUntilIdle() + controller.setMessageInput("typed text") + channelDrafts.value = mapOf(CID to DraftMessage(id = "draft", cid = CID, text = "draft text")) + advanceUntilIdle() + controller.setMessageInput("") + + controller.onCleared() + advanceUntilIdle() + + verify(fixture.chatClient, never()).deleteDraftMessages(any(), any(), any()) + } + + @Test + fun `Given a shown draft When the user empties the input and the composer closes Then the draft is deleted`() = + runTest { + val draftMessage = DraftMessage(id = "draft", cid = CID, text = "draft text") + val fixture = Fixture() + .givenAppSettings() + .givenAudioPlayer(mock()) + .givenClientState(randomUser()) + .givenDraftFlows(channelDrafts = MutableStateFlow(mapOf(CID to draftMessage))) + .givenChannelState() + .givenDraftMessageStubs() + .givenStopTyping() + val controller = fixture.get() + advanceUntilIdle() + controller.setMessageInput("") + + controller.onCleared() + advanceUntilIdle() + + verify(fixture.chatClient).deleteDraftMessages(CHANNEL_TYPE, CHANNEL_ID, draftMessage) + } + + @Test + fun `Given a shown draft When it is deleted elsewhere Then the input is cleared`() = runTest { + val channelDrafts = MutableStateFlow(mapOf(CID to DraftMessage(id = "draft", cid = CID, text = "draft text"))) + val controller = Fixture() + .givenAppSettings() + .givenAudioPlayer(mock()) + .givenClientState(randomUser()) + .givenDraftFlows(channelDrafts = channelDrafts) + .givenChannelState() + .givenDraftMessageStubs() + .get() + advanceUntilIdle() + + channelDrafts.value = mapOf() + advanceUntilIdle() + + assertEquals("", controller.state.value.inputValue) + } + + @Test + fun `Given a thread with an empty input When a thread draft arrives Then the draft is shown`() = runTest { + val parentMessage = randomMessage(cid = CID) + val threadDrafts = MutableStateFlow(mapOf()) + val controller = Fixture() + .givenAppSettings() + .givenAudioPlayer(mock()) + .givenClientState(randomUser()) + .givenDraftFlows(threadDrafts = threadDrafts) + .givenChannelState() + .get(parentMessageId = parentMessage.id) + advanceUntilIdle() + + threadDrafts.value = mapOf( + parentMessage.id to DraftMessage(id = "draft", cid = CID, text = "thread draft", parentId = parentMessage.id), + ) + advanceUntilIdle() + + assertEquals("thread draft", controller.state.value.inputValue) + } + + @Test + fun `Given a shown thread draft When it is deleted elsewhere Then the input is cleared`() = runTest { + val parentMessage = randomMessage(cid = CID) + val threadDraft = DraftMessage(id = "draft", cid = CID, text = "thread draft", parentId = parentMessage.id) + val threadDrafts = MutableStateFlow(mapOf(parentMessage.id to threadDraft)) + val controller = Fixture() + .givenAppSettings() + .givenAudioPlayer(mock()) + .givenClientState(randomUser()) + .givenDraftFlows(threadDrafts = threadDrafts) + .givenChannelState() + .givenDraftMessageStubs() + .get(parentMessageId = parentMessage.id) + advanceUntilIdle() + + threadDrafts.value = mapOf() + advanceUntilIdle() + + assertEquals("", controller.state.value.inputValue) + } + @Test fun `Given a stubbed thread When the loaded parent arrives Then the full message replaces the stub`() = runTest { // The composer starts with an id-only parent stub; when the list provides the fully loaded @@ -3060,6 +3206,18 @@ internal class MessageComposerControllerTest { whenever(chatClient.deleteDraftMessages(any(), any(), any())) doReturn Unit.asCall() } + fun givenDraftFlows( + channelDrafts: StateFlow> = MutableStateFlow(mapOf()), + threadDrafts: StateFlow> = MutableStateFlow(mapOf()), + ) = apply { + whenever(globalState.channelDraftMessages) doReturn channelDrafts + whenever(globalState.threadDraftMessages) doReturn threadDrafts + } + + fun givenStopTyping() = apply { + whenever(chatClient.stopTyping(any(), any(), anyOrNull())) doReturn mock().asCall() + } + fun givenGlobalState( channelDrafts: Map = mapOf(), threadDrafts: Map = mapOf(), From 712e7ea6378116da7115a63e3412602fcd2d055b Mon Sep 17 00:00:00 2001 From: Gian <47775302+gpunto@users.noreply.github.com> Date: Fri, 2 Oct 2026 13:22:30 +0200 Subject: [PATCH 2/3] Restore drafts from recovered channel lists and keep a picked reply when a draft arrives --- .../state/sync/internal/SyncManager.kt | 1 + .../state/internal/SyncManagerTest.kt | 28 +++++++++++++++++++ .../composer/MessageComposerController.kt | 1 + .../composer/MessageComposerControllerTest.kt | 21 ++++++++++++++ 4 files changed, 51 insertions(+) diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/sync/internal/SyncManager.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/sync/internal/SyncManager.kt index 0b5d07eb48f9..39b5d48b6e4b 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/sync/internal/SyncManager.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/sync/internal/SyncManager.kt @@ -664,6 +664,7 @@ internal class SyncManager( logger.v { "[updateActiveQueryChannels] request completed; foundChannels.size: ${foundChannels.size}" } + mutableGlobalState.updateChannelDrafts(foundChannels) updatedCids.addAll(foundChannels.map { it.cid }) logger.v { "[updateActiveQueryChannels] updatedCids.size: ${updatedCids.size}" } } diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/internal/SyncManagerTest.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/internal/SyncManagerTest.kt index 322e764098d3..7e07c6244eac 100644 --- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/internal/SyncManagerTest.kt +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/internal/SyncManagerTest.kt @@ -85,6 +85,7 @@ import org.junit.jupiter.api.Test import org.mockito.kotlin.any import org.mockito.kotlin.anyOrNull import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.atLeastOnce import org.mockito.kotlin.doReturn import org.mockito.kotlin.eq import org.mockito.kotlin.mock @@ -1181,6 +1182,33 @@ internal class SyncManagerTest { verify(chatClient, never()).queryChannelsInternal(any()) } + @Test + fun `reconnect should add the drafts of the recovered channel list to the global state`() = + runTest(testDispatcher) { + val createdAt = localDate() + val rawCreatedAt = streamDateFormatter.format(createdAt) + val channel = randomChannel(draftMessage = randomDraftMessage(parentId = null)) + val standardQuery: QueryChannelsLogic = mock { + on(it.groupKey()) doReturn null + on(it.recoveryNeeded()) doReturn MutableStateFlow(true) + onBlocking { it.queryFirstPage() } doReturn Result.Success(listOf(channel)) + } + + whenever(logicRegistry.getActiveQueryChannelsLogic()) doReturn listOf(standardQuery) + whenever(logicRegistry.getActiveChannelsLogic()) doReturn emptyList() + whenever(stateRegistry.getActiveChannelStates()) doReturn emptyMap() + whenever(clientState.isOnline) doReturn true + whenever(repositoryFacade.selectSyncState(user.id)) doReturn null + + val syncManager = buildSyncManager() + syncManager.onEvent(connectedEvent(createdAt, rawCreatedAt)) + delay(100) + syncManager.onEvent(connectedEvent(createdAt, rawCreatedAt)) + delay(100) + + verify(mutableGlobalState, atLeastOnce()).updateChannelDrafts(listOf(channel)) + } + @Test fun `updateActiveQueryChannels should skip grouped queries`() = runTest(testDispatcher) { 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 3272719dd4df..de15cbbee141 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 @@ -1526,6 +1526,7 @@ public class MessageComposerController( /** A draft that arrives after the composer opened is shown only while the user has not started composing. */ private fun canShowArrivedDraft(): Boolean = currentDraftId == null && + _messageActions.value.isEmpty() && _messageInput.value.text.isEmpty() && _selectedAttachments.value.isEmpty() && _state.value.activeCommand == null diff --git a/stream-chat-android-ui-common/src/test/kotlin/io/getstream/chat/android/ui/common/feature/messages/composer/MessageComposerControllerTest.kt b/stream-chat-android-ui-common/src/test/kotlin/io/getstream/chat/android/ui/common/feature/messages/composer/MessageComposerControllerTest.kt index 3636cdbfc82c..b73a928563ed 100644 --- a/stream-chat-android-ui-common/src/test/kotlin/io/getstream/chat/android/ui/common/feature/messages/composer/MessageComposerControllerTest.kt +++ b/stream-chat-android-ui-common/src/test/kotlin/io/getstream/chat/android/ui/common/feature/messages/composer/MessageComposerControllerTest.kt @@ -2102,6 +2102,27 @@ internal class MessageComposerControllerTest { assertEquals("typed text", controller.state.value.inputValue) } + @Test + fun `Given the user picked a reply When a draft arrives Then the reply and empty input are kept`() = runTest { + val channelDrafts = MutableStateFlow(mapOf()) + val controller = Fixture() + .givenAppSettings() + .givenAudioPlayer(mock()) + .givenClientState(randomUser()) + .givenDraftFlows(channelDrafts = channelDrafts) + .givenChannelState() + .get() + advanceUntilIdle() + val reply = Reply(randomMessage(cid = CID)) + controller.performMessageAction(reply) + + channelDrafts.value = mapOf(CID to DraftMessage(id = "draft", cid = CID, text = "draft text")) + advanceUntilIdle() + + assertEquals("", controller.state.value.inputValue) + assertEquals(setOf(reply), controller.messageActions.value) + } + @Test fun `Given a draft that never reached the input When the composer closes empty Then the draft is not deleted`() = runTest { From 424804fe7439c2c9870124c0239ff929adf7d5f7 Mon Sep 17 00:00:00 2001 From: Gian <47775302+gpunto@users.noreply.github.com> Date: Fri, 2 Oct 2026 13:41:08 +0200 Subject: [PATCH 3/3] Forget the shown draft when the composer is cleared --- .../composer/MessageComposerController.kt | 1 + .../composer/MessageComposerControllerTest.kt | 45 +++++++++++++++++++ 2 files changed, 46 insertions(+) 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 de15cbbee141..7b265e3b041f 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 @@ -787,6 +787,7 @@ public class MessageComposerController( logger.i { "[clearData]" } dismissMessageActions() scope.launch { clearDraftMessage(_state.value.messageMode) } + currentDraftId = null _messageInput.value = MessageInput() clearAttachments() discardCommandStash() diff --git a/stream-chat-android-ui-common/src/test/kotlin/io/getstream/chat/android/ui/common/feature/messages/composer/MessageComposerControllerTest.kt b/stream-chat-android-ui-common/src/test/kotlin/io/getstream/chat/android/ui/common/feature/messages/composer/MessageComposerControllerTest.kt index b73a928563ed..2cbd40f90c1b 100644 --- a/stream-chat-android-ui-common/src/test/kotlin/io/getstream/chat/android/ui/common/feature/messages/composer/MessageComposerControllerTest.kt +++ b/stream-chat-android-ui-common/src/test/kotlin/io/getstream/chat/android/ui/common/feature/messages/composer/MessageComposerControllerTest.kt @@ -2102,6 +2102,51 @@ internal class MessageComposerControllerTest { assertEquals("typed text", controller.state.value.inputValue) } + @Test + fun `Given a shown draft was cleared When a new draft arrives Then the new draft is shown`() = runTest { + val channelDrafts = MutableStateFlow(mapOf(CID to DraftMessage(id = "first", cid = CID, text = "first draft"))) + val controller = Fixture() + .givenAppSettings() + .givenAudioPlayer(mock()) + .givenClientState(randomUser()) + .givenDraftFlows(channelDrafts = channelDrafts) + .givenChannelState() + .givenDraftMessageStubs() + .get() + advanceUntilIdle() + controller.clearData() + channelDrafts.value = mapOf() + advanceUntilIdle() + + channelDrafts.value = mapOf(CID to DraftMessage(id = "second", cid = CID, text = "second draft")) + advanceUntilIdle() + + assertEquals("second draft", controller.state.value.inputValue) + } + + @Test + fun `Given a shown draft was cleared When another channel draft changes Then the typed input is kept`() = runTest { + val channelDrafts = MutableStateFlow(mapOf(CID to DraftMessage(id = "first", cid = CID, text = "first draft"))) + val controller = Fixture() + .givenAppSettings() + .givenAudioPlayer(mock()) + .givenClientState(randomUser()) + .givenDraftFlows(channelDrafts = channelDrafts) + .givenChannelState() + .givenDraftMessageStubs() + .get() + advanceUntilIdle() + controller.clearData() + channelDrafts.value = mapOf() + advanceUntilIdle() + controller.setMessageInput("typed text") + + channelDrafts.value = mapOf("messaging:other" to DraftMessage(id = "other", cid = "messaging:other", text = "x")) + advanceUntilIdle() + + assertEquals("typed text", controller.state.value.inputValue) + } + @Test fun `Given the user picked a reply When a draft arrives Then the reply and empty input are kept`() = runTest { val channelDrafts = MutableStateFlow(mapOf())