Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,6 @@ import io.getstream.chat.android.models.Member
import io.getstream.chat.android.models.Message
import io.getstream.chat.android.models.PushPreference
import io.getstream.log.taggedLogger
import io.getstream.result.Error
import io.getstream.result.Result
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
Expand Down Expand Up @@ -104,15 +103,6 @@ internal class ChannelLogicLegacyImpl(

override suspend fun watch(limit: Int, userPresence: Boolean): Result<Channel> {
logger.i { "[watch] messagesLimit: $limit, userPresence: $userPresence" }
// Otherwise it's too easy for devs to create UI bugs which DDOS our API
if (mutableState.loading.value) {
logger.i { "Another request to watch this channel is in progress. Ignoring this request." }
return Result.Failure(
Error.GenericError(
"Another request to watch this channel is in progress. Ignoring this request.",
),
)
}
stateLogic.loadingNewestMessages()
return runChannelQuery(
"watch",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -710,6 +710,7 @@ internal class ChannelStateLogic(
}
}

mutableState.setLoading(false)
mutableState.setLoadingOlderMessages(false)
mutableState.setLoadingNewerMessages(false)
}
Expand Down Expand Up @@ -842,6 +843,7 @@ internal class ChannelStateLogic(
}
mutableState.recoveryNeeded = true
}
mutableState.setLoading(false)
mutableState.setLoadingOlderMessages(false)
mutableState.setLoadingNewerMessages(false)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -162,7 +162,9 @@ internal class ChannelStateLegacyImpl(
messagesTransformation(pinnedMessagesList) { it.isPinned(now) }

override val messagesState: StateFlow<MessagesState> =
combineStates(loading, sortedVisibleMessages) { loading: Boolean, messages: List<Message> ->
combineStates(loading, sortedVisibleMessages) { loading: Boolean, collected: List<Message> ->
// A collected copy of the messages can lag behind loading, so re-read before reporting no results.
val messages = if (!loading && collected.isEmpty()) sortedVisibleMessages.value else collected
when {
loading -> MessagesState.Loading
messages.isEmpty() -> MessagesState.OfflineNoResults
Expand Down Expand Up @@ -352,6 +354,14 @@ internal class ChannelStateLegacyImpl(
_loading?.value = isLoading
}

/**
* Marks the first load of the channel as in progress. Does nothing once the channel has data, so refreshing a
* loaded channel never replaces its content with a loading state.
*/
fun setLoadingIfEmpty() {
if (_channelData?.value == null) setLoading(true)
}

/**
* Sets hidden.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import io.getstream.chat.android.client.internal.state.extensions.internal.logic
import io.getstream.chat.android.client.internal.state.model.querychannels.pagination.internal.QueryChannelPaginationRequest
import io.getstream.chat.android.client.internal.state.plugin.QueryChannelsIdentifier
import io.getstream.chat.android.client.internal.state.plugin.identifier
import io.getstream.chat.android.client.internal.state.plugin.state.channel.internal.ChannelStateLegacyImpl
import io.getstream.chat.android.models.Message
import io.getstream.log.taggedLogger
import io.getstream.result.call.Call
Expand Down Expand Up @@ -100,10 +101,12 @@ internal class ChatClientStateCalls(
request: QueryChannelRequest,
): ChannelState {
logger.v { "[queryChannel] cid: $channelType:$channelId, request: $request" }
chatClient.queryChannel(channelType, channelId, request).launch(scope)
return deferredState
val state = deferredState
.await()
.channel(channelType, channelId)
(state as? ChannelStateLegacyImpl)?.setLoadingIfEmpty()
Comment thread
andremion marked this conversation as resolved.
chatClient.queryChannel(channelType, channelId, request).launch(scope)
return state
}

/** Reference request of the watch channel query. */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -256,6 +256,26 @@ internal class ChannelStateLogicTest {
verify(mutableState, times(0)).upsertMessages(any())
}

@Test
fun `given channel data arrives, loading should end once the data is in the state`() {
val channel = randomChannel()

channelStateLogic.updateDataForChannel(channel, messageLimit = 30)

inOrder(mutableState) {
verify(mutableState).setChannelData(any())
verify(mutableState).upsertMembers(channel.members)
verify(mutableState).setLoading(false)
}
}

@Test
fun `given the query fails, loading should end`() {
channelStateLogic.propagateQueryError(Error.GenericError(randomString()))

verify(mutableState).setLoading(false)
}

@Test
fun `given inside search should not upsert messages when messages are not coming from scroll update`() {
_insideSearch.value = true
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
/*
* 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.ChannelData
import io.getstream.chat.android.models.Message
import io.getstream.chat.android.models.MessagesState
import io.getstream.chat.android.models.User
import io.getstream.chat.android.randomMessage
import io.getstream.chat.android.test.TestCoroutineExtension
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import org.amshove.kluent.`should be equal to`
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.extension.RegisterExtension
import java.util.Date

@ExperimentalCoroutinesApi
internal class ChannelStateLegacyImplLoadingTest {

private lateinit var channelState: ChannelStateLegacyImpl

@BeforeEach
fun setUp() {
channelState = ChannelStateLegacyImpl(
channelType = CHANNEL_TYPE,
channelId = CHANNEL_ID,
userFlow = MutableStateFlow<User?>(currentUser),
latestUsers = MutableStateFlow(mapOf(currentUser.id to currentUser)),
activeLiveLocations = MutableStateFlow(emptyList()),
baseMessageLimit = null,
now = ::currentTime,
)
}

@Test
fun `setLoadingIfEmpty on a channel without data should show the loading state`() = runTest {
channelState.setLoadingIfEmpty()

channelState.loading.value `should be equal to` true
channelState.messagesState.value `should be equal to` MessagesState.Loading
}

@Test
fun `setLoadingIfEmpty on a channel with data should not show the loading state`() = runTest {
channelState.setChannelData(ChannelData(type = CHANNEL_TYPE, id = CHANNEL_ID))

channelState.setLoadingIfEmpty()

channelState.loading.value `should be equal to` false
channelState.messagesState.value `should be equal to` MessagesState.OfflineNoResults
}

@Test
fun `messagesState should not report no results while the loaded messages are still propagating`() =
runTest(StandardTestDispatcher(testCoroutines.dispatcher.scheduler)) {
val emissions = mutableListOf<MessagesState>()
backgroundScope.launch { channelState.messagesState.collect { emissions += it } }
channelState.setLoadingIfEmpty()
runCurrent()

channelState.setMessages(createMessages(3))
channelState.setLoading(false)
runCurrent()

val afterLoading = emissions.dropWhile { it != MessagesState.Loading }
afterLoading.contains(MessagesState.OfflineNoResults) `should be equal to` false
(afterLoading.last() is MessagesState.Result) `should be equal to` true
}

private fun createMessages(count: Int): List<Message> {
val now = currentTime()
return (1..count).map { i ->
randomMessage(
id = "message_$i",
cid = CID,
createdAt = Date(now + i * 1000L),
createdLocallyAt = null,
parentId = null,
shadowed = false,
deletedAt = null,
)
}
}

companion object {
@JvmField
@RegisterExtension
val testCoroutines = TestCoroutineExtension()

private const val CHANNEL_TYPE = "messaging"
private const val CHANNEL_ID = "123"
private const val CID = "messaging:123"

private val currentUser = User(id = "tom", name = "Tom")

private fun currentTime() = testCoroutines.dispatcher.scheduler.currentTime
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,13 @@ import io.getstream.chat.android.client.internal.state.plugin.QueryChannelsIdent
import io.getstream.chat.android.client.internal.state.plugin.internal.StatePlugin
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.channel.internal.ChannelStateLegacyImpl
import io.getstream.chat.android.client.setup.state.ClientState
import io.getstream.chat.android.models.InitializationState
import io.getstream.chat.android.models.User
import io.getstream.chat.android.randomChannel
import io.getstream.chat.android.test.TestCoroutineRule
import io.getstream.chat.android.test.asCall
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.launch
Expand All @@ -41,9 +44,11 @@ import org.junit.jupiter.api.Test
import org.mockito.kotlin.any
import org.mockito.kotlin.doReturn
import org.mockito.kotlin.eq
import org.mockito.kotlin.inOrder
import org.mockito.kotlin.mock
import org.mockito.kotlin.never
import org.mockito.kotlin.verify
import org.mockito.kotlin.whenever

@OptIn(ExperimentalCoroutinesApi::class)
internal class ChatClientStateCallsTest {
Expand Down Expand Up @@ -142,4 +147,23 @@ internal class ChatClientStateCallsTest {
// Then - stateRegistry.queryChannels should be called with the identifier
verify(stateRegistry).queryChannels(identifier)
}

@Test
fun `watchChannel marks the legacy channel state as loading before querying it`() = runTest {
// Given
userFlow.value = User(id = "test-user")
val channelState: ChannelStateLegacyImpl = mock()
whenever(stateRegistry.channel("messaging", "123")) doReturn channelState
whenever(chatClient.queryChannel(any(), any(), any(), any())) doReturn randomChannel().asCall()

// When
val result = chatClientStateCalls.watchChannel("messaging:123", messageLimit = 30, userPresence = true)

// Then
assertEquals(channelState, result)
inOrder(channelState, chatClient) {
verify(channelState).setLoadingIfEmpty()
verify(chatClient).queryChannel(eq("messaging"), eq("123"), any(), any())
}
}
}
Loading