diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/ChatClient.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/ChatClient.kt index 2caef41a0b3..4dbb89f7689 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/ChatClient.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/ChatClient.kt @@ -247,6 +247,7 @@ import kotlinx.coroutines.flow.first import kotlinx.coroutines.job import kotlinx.coroutines.launch import kotlinx.coroutines.plus +import kotlinx.coroutines.runBlocking import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withTimeout @@ -422,18 +423,13 @@ internal constructor( "This method is used to avoid race-condition between plugin initialization and dependency resolution.", ) public fun awaitInitializationState(timeoutMilliseconds: Long): InitializationState? { - var initState: InitializationState? = clientState.initializationState.value - var spendTime = 0L - inheritScope { Job(it) }.launch { - initState = withTimeoutOrNull(timeoutMilliseconds) { - clientState.initializationState.first { it == InitializationState.COMPLETE } + val currentState = clientState.initializationState.value + if (currentState != InitializationState.INITIALIZING) return currentState + return runBlocking { + withTimeoutOrNull(timeoutMilliseconds) { + clientState.initializationState.first { it != InitializationState.INITIALIZING } } } - while (initState == InitializationState.INITIALIZING && spendTime < timeoutMilliseconds) { - java.lang.Thread.sleep(INITIALIZATION_DELAY) - spendTime += INITIALIZATION_DELAY - } - return initState } /** diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/extensions/internal/Channel.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/extensions/internal/Channel.kt index 5f05786ded0..0285bc99651 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/extensions/internal/Channel.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/extensions/internal/Channel.kt @@ -265,7 +265,7 @@ public fun Collection.updateUsers(users: Map): List): Channel { - return if (users().map(User::id).any(users::containsKey)) { + return if (users().any { user -> users[user.id]?.let { it != user } == true }) { copy( messages = messages.updateUsers(users), members = members.updateUsers(users).toList(), diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/offline/repository/domain/user/internal/DatabaseUserRepository.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/offline/repository/domain/user/internal/DatabaseUserRepository.kt index c64b936404f..3553d3b0594 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/offline/repository/domain/user/internal/DatabaseUserRepository.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/offline/repository/domain/user/internal/DatabaseUserRepository.kt @@ -25,6 +25,8 @@ import io.getstream.chat.android.models.Mute import io.getstream.chat.android.models.User import io.getstream.log.taggedLogger import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch @@ -42,6 +44,9 @@ internal class DatabaseUserRepository( private val userCache = LruCache(cacheSize) private val latestUsersFlow: MutableStateFlow> = MutableStateFlow(emptyMap()) private val dbMutex = Mutex() + private val pendingEntities = mutableListOf() + private val pendingMutex = Mutex() + private var flushJob: Job? = null override fun observeLatestUsers(): StateFlow> = latestUsersFlow @@ -62,11 +67,29 @@ internal class DatabaseUserRepository( .filter { it != userCache[it.id] } .map { it.toEntity() } cacheUsers(users) - scope.launchWithMutex(dbMutex) { - logger.v { "[insertUsers] inserting ${usersToInsert.size} entities on DB, updated ${users.size} on cache" } - usersToInsert - .takeUnless { it.isEmpty() } - ?.let { userDao.insertMany(it) } + if (usersToInsert.isEmpty()) return + pendingMutex.withLock { + pendingEntities.addAll(usersToInsert) + if (flushJob?.isActive != true) { + flushJob = scope.launch { + delay(BATCH_FLUSH_DELAY_MS) + flushPendingUsers() + } + } + } + } + + private suspend fun flushPendingUsers() { + val snapshot = pendingMutex.withLock { + val copy = pendingEntities.toList() + pendingEntities.clear() + copy + } + if (snapshot.isEmpty()) return + val deduped = snapshot.associateBy { it.id }.values.toList() + logger.v { "[insertUsers] batch flushing ${deduped.size} entities to DB (from ${snapshot.size} enqueued)" } + dbMutex.withLock { + userDao.insertMany(deduped) } } @@ -112,10 +135,16 @@ internal class DatabaseUserRepository( } private fun cacheUsers(users: Collection) { - for (userEntity in users) { - userCache.put(userEntity.id, userEntity) + var changed = false + for (user in users) { + if (userCache[user.id] != user) { + userCache.put(user.id, user) + changed = true + } + } + if (changed) { + scope.launch { latestUsersFlow.value = userCache.snapshot() } } - scope.launch { latestUsersFlow.value = userCache.snapshot() } } private fun User.toEntity(): UserEntity = @@ -162,5 +191,6 @@ internal class DatabaseUserRepository( companion object { private const val ME_ID = "me" + private const val BATCH_FLUSH_DELAY_MS = 3_000L } } diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/event/handler/internal/EventHandlerSequential.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/event/handler/internal/EventHandlerSequential.kt index fdcb3de116e..934c1fdf89b 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/event/handler/internal/EventHandlerSequential.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/event/handler/internal/EventHandlerSequential.kt @@ -526,7 +526,9 @@ internal class EventHandlerSequential( ) pollEvents.forEach { batchBuilder.addPollToFetch(it.poll.id) } - val users: List = events.filterIsInstance().map { it.user } + + val users: List = events.filterIsInstance() + .filterNot { it is UserStartWatchingEvent || it is UserStopWatchingEvent } + .map { it.user } + events.filterIsInstance().map { it.me } batchBuilder.addUsers(users) diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/channels/list/ChannelItem.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/channels/list/ChannelItem.kt index 6dd08ebb50c..52d1faa8461 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/channels/list/ChannelItem.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/channels/list/ChannelItem.kt @@ -240,6 +240,7 @@ internal fun RowScope.DefaultChannelItemCenterContent( } } +@Suppress("LongMethod") @Composable private fun TitleRow( channelItemState: ItemState.ChannelItemState, @@ -260,11 +261,15 @@ private fun TitleRow( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(StreamTokens.spacing2xs), ) { + val channelNameFormatter = ChatTheme.channelNameFormatter + val formattedChannelName = remember(channel, currentUser, channelNameFormatter) { + channelNameFormatter.formatChannelName(channel, currentUser) + } Text( modifier = Modifier .testTag("Stream_ChannelName") .weight(1f, fill = false), - text = ChatTheme.channelNameFormatter.formatChannelName(channel, currentUser), + text = formattedChannelName, style = ChatTheme.typography.headingSmall, maxLines = 1, overflow = TextOverflow.Ellipsis, @@ -306,6 +311,7 @@ private fun TitleRow( } } +@Suppress("LongMethod") @Composable private fun MessageRow( channelItemState: ItemState.ChannelItemState, @@ -334,14 +340,22 @@ private fun MessageRow( ) } - val lastMessageText = + val messagePreviewFormatter = ChatTheme.messagePreviewFormatter + val lastMessageText = remember( + channelItemState.draftMessage, + lastMessage, + currentUser, + isDirectMessaging, + messagePreviewFormatter, + ) { channelItemState.draftMessage - ?.let { ChatTheme.messagePreviewFormatter.formatDraftMessagePreview(it) } + ?.let { messagePreviewFormatter.formatDraftMessagePreview(it) } ?: lastMessage?.let { - ChatTheme.messagePreviewFormatter.formatMessagePreview( + messagePreviewFormatter.formatMessagePreview( it, currentUser, isDirectMessaging, ) } + } Text( modifier = Modifier diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/channels/list/ChannelList.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/channels/list/ChannelList.kt index e346c37dd9b..b3a2162dc03 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/channels/list/ChannelList.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/channels/list/ChannelList.kt @@ -140,7 +140,6 @@ public fun ChannelList( } }, channelContent: @Composable LazyItemScope.(ItemState.ChannelItemState) -> Unit = { itemState -> - val user by viewModel.user.collectAsState() val selectedCid = viewModel.selectedChannel.value?.cid val enrichedItemState = if (selectedCid != null && itemState.channel.cid == selectedCid) { itemState.copy(isSelected = true) @@ -150,18 +149,17 @@ public fun ChannelList( with(ChatTheme.componentFactory) { ChannelListItemContent( channelItem = enrichedItemState, - currentUser = user, + currentUser = viewModel.user.value, onChannelClick = onChannelClick, onChannelLongClick = onChannelLongClick, ) } }, searchResultContent: @Composable LazyItemScope.(ItemState.SearchResultItemState) -> Unit = { itemState -> - val user by viewModel.user.collectAsState() with(ChatTheme.componentFactory) { SearchResultItemContent( searchResultItem = itemState, - currentUser = user, + currentUser = viewModel.user.value, onSearchResultClick = onSearchResultClick, ) } diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/channels/list/Channels.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/channels/list/Channels.kt index 9236fd81bae..a80de917852 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/channels/list/Channels.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/channels/list/Channels.kt @@ -90,6 +90,7 @@ public fun Channels( itemsIndexed( items = channelItems, key = { _, item -> item.key }, + contentType = { _, item -> item::class }, ) { index, item -> itemContent(item) diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/components/Timestamp.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/components/Timestamp.kt index 2efd46ad7da..7c60c791f99 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/components/Timestamp.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/components/Timestamp.kt @@ -18,6 +18,7 @@ package io.getstream.chat.android.compose.ui.components import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalInspectionMode import androidx.compose.ui.platform.testTag @@ -54,10 +55,12 @@ public fun Timestamp( val timestamp = if (LocalInspectionMode.current) { "13:49" } else { - when (formatType) { - TIME -> formatter.formatTime(date) - DATE -> formatter.formatDate(date) - RELATIVE -> formatter.formatRelativeTime(date) + remember(date, formatType) { + when (formatType) { + TIME -> formatter.formatTime(date) + DATE -> formatter.formatDate(date) + RELATIVE -> formatter.formatRelativeTime(date) + } } } diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/components/avatar/Avatar.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/components/avatar/Avatar.kt index 64ce794eda5..339c0a00fdd 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/components/avatar/Avatar.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/components/avatar/Avatar.kt @@ -16,7 +16,6 @@ package io.getstream.chat.android.compose.ui.components.avatar -import androidx.compose.animation.Crossfade import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.border @@ -55,17 +54,15 @@ internal fun Avatar( content = { state -> val painter = (state as? AsyncImagePainter.State.Success)?.painter - Crossfade(targetState = painter) { painter -> - if (painter == null) { - fallback() - } else { - Image( - modifier = Modifier.fillMaxSize(), - contentScale = ContentScale.Crop, - painter = painter, - contentDescription = null, - ) - } + if (painter == null) { + fallback() + } else { + Image( + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Crop, + painter = painter, + contentDescription = null, + ) } }, ) diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/channels/ChannelListViewModel.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/channels/ChannelListViewModel.kt index 222af8848d1..4aae569d930 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/channels/ChannelListViewModel.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/channels/ChannelListViewModel.kt @@ -53,6 +53,7 @@ import io.getstream.chat.android.ui.common.state.channels.actions.ChannelAction import io.getstream.chat.android.ui.common.utils.extensions.defaultChannelListFilter import io.getstream.log.taggedLogger import io.getstream.result.call.toUnitCall +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancelChildren @@ -62,9 +63,11 @@ import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.job @@ -234,6 +237,8 @@ public class ChannelListViewModel( */ private val searchMessageState: MutableStateFlow = MutableStateFlow(null) + private var previousChannelItems: Map = emptyMap() + private var lastNextQuery: QueryChannelsRequest? = null /** @@ -452,7 +457,9 @@ public class ChannelListViewModel( ) } } - }.collectLatest { newState -> channelsState = newState } + }.flowOn(Dispatchers.Default) + .debounce(CHANNEL_LIST_DEBOUNCE_MS) + .collectLatest { newState -> channelsState = newState } } } }.onFailure { @@ -747,12 +754,16 @@ public class ChannelListViewModel( ): List { val mutedChannelIds = channelMutes.map { channelMute -> channelMute.channel?.cid }.toSet() return channels.map { - ItemState.ChannelItemState( + val newItem = ItemState.ChannelItemState( channel = it, isMuted = it.cid in mutedChannelIds, typingUsers = typingEvents[it.cid]?.users ?: emptyList(), draftMessage = draftMessages[it.cid], ) + val previous = previousChannelItems[newItem.key] + if (previous != null && previous == newItem) previous else newItem + }.also { items -> + previousChannelItems = items.associateBy { it.key } } } @@ -771,6 +782,12 @@ public class ChannelListViewModel( * Minimum length of the search query to start searching for channels. */ private const val MIN_CHANNEL_SEARCH_QUERY_LENGTH = 3 + + /** + * Debounce for channel list state updates. Collapses rapid WebSocket events + * (e.g. 30 user.watching.start events) into fewer UI updates. + */ + private const val CHANNEL_LIST_DEBOUNCE_MS = 100L } private data class SearchMessageState(