From 751026b19c6bea13258bd27050762a53fb178bad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Mion?= Date: Thu, 18 Jun 2026 13:48:22 +0100 Subject: [PATCH 1/2] client: Count thread replies toward the slow mode cooldown The composer cooldown is driven by ChannelState.lastSentMessageDate, which was derived only from the channel message list. Thread-only replies are excluded from that list, so sending a thread reply during slow mode did not start the countdown and the next send was rejected by the server. Track the current user's most recent thread-only reply at the channel-state write entry points and expose lastSentMessageDate as the later of the channel message date and that thread-reply date. The cooldown is now channel-wide and consistent between channel mode and thread mode, matching iOS. Applied to both ChannelStateImpl and the legacy ChannelStateLegacyImpl. --- .../channel/internal/ChannelStateImpl.kt | 38 ++++++- .../internal/ChannelStateLegacyImpl.kt | 25 ++++- .../channel/internal/LastSentMessageDate.kt | 56 +++++++++++ ...ChannelStateImplLastSentMessageDateTest.kt | 98 +++++++++++++++++++ .../internal/ChannelStateLegacyImplTest.kt | 69 +++++++++++++ 5 files changed, 280 insertions(+), 6 deletions(-) create mode 100644 stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/state/channel/internal/LastSentMessageDate.kt create mode 100644 stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/state/channel/internal/ChannelStateImplLastSentMessageDateTest.kt diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/state/channel/internal/ChannelStateImpl.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/state/channel/internal/ChannelStateImpl.kt index c76e82fd80b..53f2ebc6217 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/state/channel/internal/ChannelStateImpl.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/state/channel/internal/ChannelStateImpl.kt @@ -99,6 +99,12 @@ internal class ChannelStateImpl( private val _quotedMessagesMap = MutableStateFlow>>(emptyMap()) private val _messages = MutableStateFlow>(emptyList()) private val localOnlyMessages = MutableStateFlow>(emptyList()) + + /** + * Tracks the creation date of the current user's most recent thread-only reply. Thread-only + * replies are excluded from [_messages], so the cooldown derivation tracks them here separately. + */ + private val _lastSentThreadReplyDate = MutableStateFlow(null) private val _pendingEnabled = MutableStateFlow(false) private val _pendingMessages = MutableStateFlow>(emptyList()) @@ -262,11 +268,15 @@ internal class ChannelStateImpl( override val insideSearch: StateFlow = _insideSearch.asStateFlow() - override val lastSentMessageDate: StateFlow = combineStates(channelConfig, messages) { config, messages -> - messages - .filter { it.user.id == currentUser.value?.id } - .lastMessageAt(config.skipLastMsgUpdateForSystemMsgs) - } + private val lastSentChannelMessageDate: StateFlow = + combineStates(channelConfig, messages) { config, messages -> + messages + .filter { it.user.id == currentUser.value?.id } + .lastMessageAt(config.skipLastMsgUpdateForSystemMsgs) + } + + override val lastSentMessageDate: StateFlow = + combineStates(lastSentChannelMessageDate, _lastSentThreadReplyDate, ::latestOf) override val activeLiveLocations: StateFlow> = liveLocations.mapState { locations -> // Filter locations to only include those for this channel @@ -305,12 +315,28 @@ internal class ChannelStateImpl( // region Messages + /** Advances [_lastSentThreadReplyDate] with [date], never moving it backwards. */ + private fun advanceLastSentThreadReplyDate(date: Date?) { + date ?: return + _lastSentThreadReplyDate.update { current -> latestOf(current, date) } + } + + private fun trackOwnThreadReply(message: Message) { + val currentUserId = currentUser.value?.id ?: return + advanceLastSentThreadReplyDate(message.ownThreadReplyDate(currentUserId)) + } + + private fun trackOwnThreadReply(messages: Collection) { + advanceLastSentThreadReplyDate(messages.latestOwnThreadReplyDate(currentUser.value?.id)) + } + /** * Sets the list of messages (overriding the current one). * * @param messages The list of messages to set. */ fun setMessages(messages: List) { + trackOwnThreadReply(messages) val messagesToSet = messages.filterNot { shouldIgnoreUpsertion(it) } for (message in messagesToSet) { message.replyTo?.let { addQuotedMessage(it.id, message.id) } @@ -329,6 +355,7 @@ internal class ChannelStateImpl( * @param message The message to upsert. */ fun upsertMessage(message: Message) { + trackOwnThreadReply(message) if (shouldIgnoreUpsertion(message)) return message.replyTo?.let { addQuotedMessage(it.id, message.id) } message.replyMessageId?.let { addQuotedMessage(it, message.id) } @@ -367,6 +394,7 @@ internal class ChannelStateImpl( * for pagination performance; set to `true` for reconnection/sync paths where messages may overlap. */ fun upsertMessages(messages: List, preserveAttachmentUrls: Boolean = false) { + trackOwnThreadReply(messages) val messagesToUpsert = messages.filterNot { shouldIgnoreUpsertion(it) } if (messagesToUpsert.isEmpty()) return for (message in messagesToUpsert) { diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/state/channel/internal/ChannelStateLegacyImpl.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/state/channel/internal/ChannelStateLegacyImpl.kt index 1cc472e253d..c4e94be58e5 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/state/channel/internal/ChannelStateLegacyImpl.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/state/channel/internal/ChannelStateLegacyImpl.kt @@ -40,6 +40,7 @@ import io.getstream.chat.android.models.User import io.getstream.log.taggedLogger import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.update import java.util.Date import java.util.concurrent.atomic.AtomicInteger @@ -79,6 +80,13 @@ internal class ChannelStateLegacyImpl( private var messageLimit: Int? = baseMessageLimit private var _messages: MutableStateFlow>? = MutableStateFlow(emptyMap()) + + /** + * Tracks the creation date of the current user's most recent thread-only reply. Thread-only + * replies are excluded from the visible [messages] list, so the cooldown derivation tracks them + * here separately. + */ + private val _lastSentThreadReplyDate = MutableStateFlow(null) private var _pinnedMessages: MutableStateFlow>? = MutableStateFlow(emptyMap()) private var _typing: MutableStateFlow? = MutableStateFlow(TypingEvent(channelId, emptyList())) private var _rawReads: MutableStateFlow>? = MutableStateFlow(emptyMap()) @@ -252,7 +260,7 @@ internal class ChannelStateLegacyImpl( override val insideSearch: StateFlow = _insideSearch!! - override val lastSentMessageDate: StateFlow = combineStates( + private val lastSentChannelMessageDate: StateFlow = combineStates( userFlow, channelConfig, messages, @@ -264,6 +272,9 @@ internal class ChannelStateLegacyImpl( } } + override val lastSentMessageDate: StateFlow = + combineStates(lastSentChannelMessageDate, _lastSentThreadReplyDate, ::latestOf) + override fun toChannel(): Channel { // recreate a channel object from the various observables. return channelData.value @@ -609,7 +620,18 @@ internal class ChannelStateLegacyImpl( setPinned { pinned -> pinned.filter { it.value.wasCreatedAfter(date) } } } + /** Advances [_lastSentThreadReplyDate] with [date], never moving it backwards. */ + private fun advanceLastSentThreadReplyDate(date: Date?) { + date ?: return + _lastSentThreadReplyDate.update { current -> latestOf(current, date) } + } + + private fun trackOwnThreadReply(messages: Collection) { + advanceLastSentThreadReplyDate(messages.latestOwnThreadReplyDate(userFlow.value?.id)) + } + fun upsertMessages(updatedMessages: Collection) { + trackOwnThreadReply(updatedMessages) _messages?.apply { val newMessageList = (value + (updatedMessages.associateBy(Message::id) - deletedMessagesIds)).values value = applyMessageLimitIfNeeded(newMessageList).associateBy(Message::id) @@ -624,6 +646,7 @@ internal class ChannelStateLegacyImpl( } fun setMessages(messages: List) { + trackOwnThreadReply(messages) _messages?.value = applyMessageLimitIfNeeded(messages).associateBy(Message::id) } diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/state/channel/internal/LastSentMessageDate.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/state/channel/internal/LastSentMessageDate.kt new file mode 100644 index 00000000000..23da004cd78 --- /dev/null +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/state/channel/internal/LastSentMessageDate.kt @@ -0,0 +1,56 @@ +/* + * 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.state.channel.internal + +import io.getstream.chat.android.models.Message +import java.util.Date + +/** + * Returns the more recent of two nullable dates, or `null` when both are `null`. + */ +internal fun latestOf(first: Date?, second: Date?): Date? = when { + first == null -> second + second == null -> first + else -> maxOf(first, second) +} + +/** + * Returns the creation date of this message when it is a thread-only reply sent by [currentUserId], + * or `null` otherwise. + * + * The channel cooldown counts thread replies the same as channel messages, but thread-only replies + * (`parentId != null && !showInChannel`) are excluded from the channel message list, so the cooldown + * derivation tracks them separately. Shadowed messages are excluded to match the channel message + * date derivation. + */ +internal fun Message.ownThreadReplyDate(currentUserId: String): Date? = when { + user.id != currentUserId -> null + parentId == null || showInChannel -> null + shadowed -> null + else -> createdLocallyAt ?: createdAt +} + +/** + * Returns the creation date of the most recent thread-only reply sent by [currentUserId] in this + * collection, or `null` when there is none. + */ +internal fun Collection.latestOwnThreadReplyDate(currentUserId: String?): Date? { + currentUserId ?: return null + return asSequence() + .mapNotNull { it.ownThreadReplyDate(currentUserId) } + .maxOrNull() +} diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/state/channel/internal/ChannelStateImplLastSentMessageDateTest.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/state/channel/internal/ChannelStateImplLastSentMessageDateTest.kt new file mode 100644 index 00000000000..4887634ec74 --- /dev/null +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/state/channel/internal/ChannelStateImplLastSentMessageDateTest.kt @@ -0,0 +1,98 @@ +/* + * 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.state.channel.internal + +import io.getstream.chat.android.randomUser +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +internal class ChannelStateImplLastSentMessageDateTest : ChannelStateImplTestBase() { + + @Test + fun `a thread-only reply by the current user updates lastSentMessageDate`() = runTest { + val threadReply = createMessage(1, parentId = "parent1", showInChannel = false) + + channelState.upsertMessage(threadReply) + + // the reply is excluded from the visible message list, but still drives the cooldown + assertTrue(channelState.messages.value.isEmpty()) + assertEquals(threadReply.createdAt, channelState.lastSentMessageDate.value) + } + + @Test + fun `a thread-only reply via upsertMessages updates lastSentMessageDate`() = runTest { + val threadReply = createMessage(1, parentId = "parent1", showInChannel = false) + + channelState.upsertMessages(listOf(threadReply)) + + assertEquals(threadReply.createdAt, channelState.lastSentMessageDate.value) + } + + @Test + fun `a thread-only reply from another user does not update lastSentMessageDate`() = runTest { + val otherUserReply = createMessage(1, user = randomUser(), parentId = "parent1", showInChannel = false) + + channelState.upsertMessage(otherUserReply) + + assertNull(channelState.lastSentMessageDate.value) + } + + @Test + fun `a channel message by the current user still updates lastSentMessageDate`() = runTest { + val channelMessage = createMessage(1) + + channelState.upsertMessage(channelMessage) + + assertEquals(channelMessage.createdAt, channelState.lastSentMessageDate.value) + } + + @Test + fun `lastSentMessageDate is the later of the channel message and the thread reply`() = runTest { + val olderThreadReply = createMessage(1, parentId = "parent1", showInChannel = false) + val newerChannelMessage = createMessage(5) + + channelState.upsertMessage(olderThreadReply) + channelState.upsertMessage(newerChannelMessage) + + assertEquals(newerChannelMessage.createdAt, channelState.lastSentMessageDate.value) + } + + @Test + fun `a newer thread reply wins over an older channel message`() = runTest { + val olderChannelMessage = createMessage(1) + val newerThreadReply = createMessage(5, parentId = "parent1", showInChannel = false) + + channelState.upsertMessage(olderChannelMessage) + channelState.upsertMessage(newerThreadReply) + + assertEquals(newerThreadReply.createdAt, channelState.lastSentMessageDate.value) + } + + @Test + fun `the thread reply date survives a later refresh that omits it`() = runTest { + val threadReply = createMessage(5, parentId = "parent1", showInChannel = false) + channelState.upsertMessage(threadReply) + + // a server refresh replaces the message list with channel messages only (no thread replies) + channelState.setMessages(createMessages(count = 2, startIndex = 1)) + + assertEquals(threadReply.createdAt, channelState.lastSentMessageDate.value) + } +} diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/state/channel/internal/ChannelStateLegacyImplTest.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/state/channel/internal/ChannelStateLegacyImplTest.kt index e3ec7f08f9d..4f032cc05ce 100644 --- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/state/channel/internal/ChannelStateLegacyImplTest.kt +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/state/channel/internal/ChannelStateLegacyImplTest.kt @@ -30,6 +30,7 @@ import kotlinx.coroutines.test.advanceTimeBy import kotlinx.coroutines.test.runTest import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertNotNull +import org.junit.jupiter.api.Assertions.assertNull import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test @@ -132,6 +133,57 @@ internal class ChannelStateLegacyImplTest { assertEquals(listOf(currentUser), channelState.watchers.value) } + @Test + fun `a thread-only reply by the current user updates lastSentMessageDate`() = runTest { + val threadReply = ownMessage(index = 1, parentId = "parent1", showInChannel = false) + + channelState.upsertMessage(threadReply) + + // the reply is excluded from the visible message list, but still drives the cooldown + assertTrue(channelState.messages.value.isEmpty()) + assertEquals(threadReply.createdAt, channelState.lastSentMessageDate.value) + } + + @Test + fun `a thread-only reply from another user does not update lastSentMessageDate`() = runTest { + val otherUserReply = ownMessage(index = 1, parentId = "parent1", showInChannel = false, user = randomUser()) + + channelState.upsertMessage(otherUserReply) + + assertNull(channelState.lastSentMessageDate.value) + } + + @Test + fun `a channel message by the current user still updates lastSentMessageDate`() = runTest { + val channelMessage = ownMessage(index = 1) + + channelState.upsertMessage(channelMessage) + + assertEquals(channelMessage.createdAt, channelState.lastSentMessageDate.value) + } + + @Test + fun `a newer thread reply wins over an older channel message`() = runTest { + val olderChannelMessage = ownMessage(index = 1) + val newerThreadReply = ownMessage(index = 5, parentId = "parent1", showInChannel = false) + + channelState.upsertMessage(olderChannelMessage) + channelState.upsertMessage(newerThreadReply) + + assertEquals(newerThreadReply.createdAt, channelState.lastSentMessageDate.value) + } + + @Test + fun `the thread reply date survives a later refresh that omits it`() = runTest { + val threadReply = ownMessage(index = 5, parentId = "parent1", showInChannel = false) + channelState.upsertMessage(threadReply) + + // a server refresh replaces the message list with channel messages only (no thread replies) + channelState.setMessages(listOf(ownMessage(index = 1), ownMessage(index = 2))) + + assertEquals(threadReply.createdAt, channelState.lastSentMessageDate.value) + } + @Test fun `setLoadingOlderMessages when messageLimit is null should not change the limit`() = runTest { // given @@ -755,6 +807,23 @@ internal class ChannelStateLegacyImplTest { /** * Helper function to create a list of messages with sequential timestamps. */ + private fun ownMessage( + index: Int, + parentId: String? = null, + showInChannel: Boolean = true, + user: User = currentUser, + ): Message = randomMessage( + id = "message_$index", + cid = CID, + user = user, + createdAt = Date(currentTime() + index * 1000L), + createdLocallyAt = null, + parentId = parentId, + showInChannel = showInChannel, + shadowed = false, + deletedAt = null, + ) + private fun createMessages(count: Int): List { val now = currentTime() return (1..count).map { i -> From 232aad04cd674142a4e1369fe6e559016e1ffdf6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Mion?= Date: Thu, 18 Jun 2026 15:16:41 +0100 Subject: [PATCH 2/2] client: Reset thread-reply cooldown date on channel state destroy ChannelStateImpl.destroy() cleared every message-derived flow except the new _lastSentThreadReplyDate, so lastSentMessageDate would still emit a stale thread-reply date after teardown. Reset it alongside the message list. --- .../plugin/state/channel/internal/ChannelStateImpl.kt | 1 + .../ChannelStateImplLastSentMessageDateTest.kt | 11 +++++++++++ 2 files changed, 12 insertions(+) diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/state/channel/internal/ChannelStateImpl.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/state/channel/internal/ChannelStateImpl.kt index 53f2ebc6217..db1ed483086 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/state/channel/internal/ChannelStateImpl.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/state/channel/internal/ChannelStateImpl.kt @@ -1528,6 +1528,7 @@ internal class ChannelStateImpl( _repliedMessage.value = null _quotedMessagesMap.value = emptyMap() _messages.value = emptyList() + _lastSentThreadReplyDate.value = null _pendingMessages.value = emptyList() _pendingEnabled.value = false _cachedLatestMessages.value = emptyList() diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/state/channel/internal/ChannelStateImplLastSentMessageDateTest.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/state/channel/internal/ChannelStateImplLastSentMessageDateTest.kt index 4887634ec74..1675b450b4e 100644 --- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/state/channel/internal/ChannelStateImplLastSentMessageDateTest.kt +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/state/channel/internal/ChannelStateImplLastSentMessageDateTest.kt @@ -19,6 +19,7 @@ package io.getstream.chat.android.client.internal.state.plugin.state.channel.int import io.getstream.chat.android.randomUser import kotlinx.coroutines.test.runTest import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNotNull import org.junit.jupiter.api.Assertions.assertNull import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Test @@ -95,4 +96,14 @@ internal class ChannelStateImplLastSentMessageDateTest : ChannelStateImplTestBas assertEquals(threadReply.createdAt, channelState.lastSentMessageDate.value) } + + @Test + fun `destroy clears the thread-reply contribution to lastSentMessageDate`() = runTest { + channelState.upsertMessage(createMessage(1, parentId = "parent1", showInChannel = false)) + assertNotNull(channelState.lastSentMessageDate.value) + + channelState.destroy() + + assertNull(channelState.lastSentMessageDate.value) + } }