From 9e9a57fa6cff7a653c0b94ff980b79bf84e891c4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Mion?= Date: Mon, 8 Jun 2026 10:52:14 +0100 Subject: [PATCH 1/4] compose: Add channel list retry banner on load failure Surface a "Tap to retry" banner pinned below the Channel List header when loading channels fails, aligning with the Figma "Loading Error" state. Previously a pagination failure produced no visible feedback. - Add ChannelsState.loadingError, set on load-more failure and cleared on a successful load - Add ChannelListBanner composable with a ChatComponentFactory slot and params holder for customisation - Render the banner above the list and wire onLoadingErrorClick to retry the failed load - Add the stream_compose_channel_list_banner_error string --- .../api/stream-chat-android-compose.api | 45 ++++++-- .../state/channels/list/ChannelsState.kt | 2 + .../compose/ui/channels/list/ChannelList.kt | 72 +++++++----- .../ui/channels/list/ChannelListBanner.kt | 91 +++++++++++++++ .../compose/ui/theme/ChatComponentFactory.kt | 14 +++ .../ui/theme/ChatComponentFactoryParams.kt | 11 ++ .../channels/ChannelListViewModel.kt | 6 +- .../src/main/res/values/strings.xml | 1 + .../ui/channels/list/ChannelListBannerTest.kt | 36 ++++++ .../channels/ChannelListViewModelTest.kt | 106 ++++++++++++++++++ ...list_ChannelListBannerTest_error_state.png | Bin 0 -> 12644 bytes 11 files changed, 345 insertions(+), 39 deletions(-) create mode 100644 stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/channels/list/ChannelListBanner.kt create mode 100644 stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/ui/channels/list/ChannelListBannerTest.kt create mode 100644 stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.channels.list_ChannelListBannerTest_error_state.png diff --git a/stream-chat-android-compose/api/stream-chat-android-compose.api b/stream-chat-android-compose/api/stream-chat-android-compose.api index f849ce0de22..a8dc1645c0b 100644 --- a/stream-chat-android-compose/api/stream-chat-android-compose.api +++ b/stream-chat-android-compose/api/stream-chat-android-compose.api @@ -41,18 +41,20 @@ public final class io/getstream/chat/android/compose/state/QueryConfig { public final class io/getstream/chat/android/compose/state/channels/list/ChannelsState { public static final field $stable I public fun ()V - public fun (ZZZLjava/util/List;Lio/getstream/chat/android/compose/state/channels/list/SearchQuery;)V - public synthetic fun (ZZZLjava/util/List;Lio/getstream/chat/android/compose/state/channels/list/SearchQuery;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun (ZZZLjava/util/List;Lio/getstream/chat/android/compose/state/channels/list/SearchQuery;Z)V + public synthetic fun (ZZZLjava/util/List;Lio/getstream/chat/android/compose/state/channels/list/SearchQuery;ZILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun component1 ()Z public final fun component2 ()Z public final fun component3 ()Z public final fun component4 ()Ljava/util/List; public final fun component5 ()Lio/getstream/chat/android/compose/state/channels/list/SearchQuery; - public final fun copy (ZZZLjava/util/List;Lio/getstream/chat/android/compose/state/channels/list/SearchQuery;)Lio/getstream/chat/android/compose/state/channels/list/ChannelsState; - public static synthetic fun copy$default (Lio/getstream/chat/android/compose/state/channels/list/ChannelsState;ZZZLjava/util/List;Lio/getstream/chat/android/compose/state/channels/list/SearchQuery;ILjava/lang/Object;)Lio/getstream/chat/android/compose/state/channels/list/ChannelsState; + public final fun component6 ()Z + public final fun copy (ZZZLjava/util/List;Lio/getstream/chat/android/compose/state/channels/list/SearchQuery;Z)Lio/getstream/chat/android/compose/state/channels/list/ChannelsState; + public static synthetic fun copy$default (Lio/getstream/chat/android/compose/state/channels/list/ChannelsState;ZZZLjava/util/List;Lio/getstream/chat/android/compose/state/channels/list/SearchQuery;ZILjava/lang/Object;)Lio/getstream/chat/android/compose/state/channels/list/ChannelsState; public fun equals (Ljava/lang/Object;)Z public final fun getChannelItems ()Ljava/util/List; public final fun getEndOfChannels ()Z + public final fun getLoadingError ()Z public final fun getSearchQuery ()Lio/getstream/chat/android/compose/state/channels/list/SearchQuery; public fun hashCode ()I public final fun isLoading ()Z @@ -906,8 +908,8 @@ public final class io/getstream/chat/android/compose/ui/channels/list/ChannelIte } public final class io/getstream/chat/android/compose/ui/channels/list/ChannelListKt { - public static final fun ChannelList (Landroidx/compose/ui/Modifier;Landroidx/compose/foundation/layout/PaddingValues;Lio/getstream/chat/android/compose/viewmodel/channels/ChannelListViewModel;Landroidx/compose/foundation/lazy/LazyListState;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function0;Landroidx/compose/runtime/Composer;II)V - public static final fun ChannelList (Lio/getstream/chat/android/compose/state/channels/list/ChannelsState;Lio/getstream/chat/android/models/User;Landroidx/compose/ui/Modifier;Landroidx/compose/foundation/layout/PaddingValues;Landroidx/compose/foundation/lazy/LazyListState;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function0;Landroidx/compose/runtime/Composer;II)V + public static final fun ChannelList (Landroidx/compose/ui/Modifier;Landroidx/compose/foundation/layout/PaddingValues;Lio/getstream/chat/android/compose/viewmodel/channels/ChannelListViewModel;Landroidx/compose/foundation/lazy/LazyListState;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;Landroidx/compose/runtime/Composer;II)V + public static final fun ChannelList (Lio/getstream/chat/android/compose/state/channels/list/ChannelsState;Lio/getstream/chat/android/models/User;Landroidx/compose/ui/Modifier;Landroidx/compose/foundation/layout/PaddingValues;Landroidx/compose/foundation/lazy/LazyListState;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;Landroidx/compose/runtime/Composer;III)V } public final class io/getstream/chat/android/compose/ui/channels/list/ComposableSingletons$ChannelItemKt { @@ -934,13 +936,20 @@ public final class io/getstream/chat/android/compose/ui/channels/list/Composable public final fun getLambda$633588870$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; } +public final class io/getstream/chat/android/compose/ui/channels/list/ComposableSingletons$ChannelListBannerKt { + public static final field INSTANCE Lio/getstream/chat/android/compose/ui/channels/list/ComposableSingletons$ChannelListBannerKt; + public fun ()V + public final fun getLambda$-638741787$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; + public final fun getLambda$1086266154$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; +} + public final class io/getstream/chat/android/compose/ui/channels/list/ComposableSingletons$ChannelListKt { public static final field INSTANCE Lio/getstream/chat/android/compose/ui/channels/list/ComposableSingletons$ChannelListKt; public fun ()V - public final fun getLambda$-1574868688$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function3; - public final fun getLambda$-9786949$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function3; + public final fun getLambda$-1069945477$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function3; + public final fun getLambda$-269013763$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function3; public final fun getLambda$1011130122$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function4; - public final fun getLambda$1179005625$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function3; + public final fun getLambda$2087787954$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function3; } public final class io/getstream/chat/android/compose/ui/channels/list/ComposableSingletons$ChannelsKt { @@ -2923,6 +2932,22 @@ public final class io/getstream/chat/android/compose/ui/theme/ChannelItemUnreadC public fun toString ()Ljava/lang/String; } +public final class io/getstream/chat/android/compose/ui/theme/ChannelListBannerParams { + public static final field $stable I + public fun ()V + public fun (Landroidx/compose/ui/Modifier;Lkotlin/jvm/functions/Function0;)V + public synthetic fun (Landroidx/compose/ui/Modifier;Lkotlin/jvm/functions/Function0;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun component1 ()Landroidx/compose/ui/Modifier; + public final fun component2 ()Lkotlin/jvm/functions/Function0; + public final fun copy (Landroidx/compose/ui/Modifier;Lkotlin/jvm/functions/Function0;)Lio/getstream/chat/android/compose/ui/theme/ChannelListBannerParams; + public static synthetic fun copy$default (Lio/getstream/chat/android/compose/ui/theme/ChannelListBannerParams;Landroidx/compose/ui/Modifier;Lkotlin/jvm/functions/Function0;ILjava/lang/Object;)Lio/getstream/chat/android/compose/ui/theme/ChannelListBannerParams; + public fun equals (Ljava/lang/Object;)Z + public final fun getModifier ()Landroidx/compose/ui/Modifier; + public final fun getOnClick ()Lkotlin/jvm/functions/Function0; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + public final class io/getstream/chat/android/compose/ui/theme/ChannelListConfig { public static final field $stable I public fun ()V @@ -3411,6 +3436,7 @@ public abstract interface class io/getstream/chat/android/compose/ui/theme/ChatC public fun ChannelItemReadStatusIndicator (Lio/getstream/chat/android/compose/ui/theme/ChannelItemReadStatusIndicatorParams;Landroidx/compose/runtime/Composer;I)V public fun ChannelItemTrailingContent (Landroidx/compose/foundation/layout/RowScope;Lio/getstream/chat/android/compose/ui/theme/ChannelItemTrailingContentParams;Landroidx/compose/runtime/Composer;I)V public fun ChannelItemUnreadCountIndicator (Lio/getstream/chat/android/compose/ui/theme/ChannelItemUnreadCountIndicatorParams;Landroidx/compose/runtime/Composer;I)V + public fun ChannelListBanner (Lio/getstream/chat/android/compose/ui/theme/ChannelListBannerParams;Landroidx/compose/runtime/Composer;I)V public fun ChannelListDividerItem (Landroidx/compose/foundation/lazy/LazyItemScope;Lio/getstream/chat/android/compose/ui/theme/ChannelListDividerItemParams;Landroidx/compose/runtime/Composer;I)V public fun ChannelListEmptyContent (Lio/getstream/chat/android/compose/ui/theme/ChannelListEmptyContentParams;Landroidx/compose/runtime/Composer;I)V public fun ChannelListEmptySearchContent (Lio/getstream/chat/android/compose/ui/theme/ChannelListEmptySearchContentParams;Landroidx/compose/runtime/Composer;I)V @@ -3602,6 +3628,7 @@ public final class io/getstream/chat/android/compose/ui/theme/ChatComponentFacto public static fun ChannelItemReadStatusIndicator (Lio/getstream/chat/android/compose/ui/theme/ChatComponentFactory;Lio/getstream/chat/android/compose/ui/theme/ChannelItemReadStatusIndicatorParams;Landroidx/compose/runtime/Composer;I)V public static fun ChannelItemTrailingContent (Lio/getstream/chat/android/compose/ui/theme/ChatComponentFactory;Landroidx/compose/foundation/layout/RowScope;Lio/getstream/chat/android/compose/ui/theme/ChannelItemTrailingContentParams;Landroidx/compose/runtime/Composer;I)V public static fun ChannelItemUnreadCountIndicator (Lio/getstream/chat/android/compose/ui/theme/ChatComponentFactory;Lio/getstream/chat/android/compose/ui/theme/ChannelItemUnreadCountIndicatorParams;Landroidx/compose/runtime/Composer;I)V + public static fun ChannelListBanner (Lio/getstream/chat/android/compose/ui/theme/ChatComponentFactory;Lio/getstream/chat/android/compose/ui/theme/ChannelListBannerParams;Landroidx/compose/runtime/Composer;I)V public static fun ChannelListDividerItem (Lio/getstream/chat/android/compose/ui/theme/ChatComponentFactory;Landroidx/compose/foundation/lazy/LazyItemScope;Lio/getstream/chat/android/compose/ui/theme/ChannelListDividerItemParams;Landroidx/compose/runtime/Composer;I)V public static fun ChannelListEmptyContent (Lio/getstream/chat/android/compose/ui/theme/ChatComponentFactory;Lio/getstream/chat/android/compose/ui/theme/ChannelListEmptyContentParams;Landroidx/compose/runtime/Composer;I)V public static fun ChannelListEmptySearchContent (Lio/getstream/chat/android/compose/ui/theme/ChatComponentFactory;Lio/getstream/chat/android/compose/ui/theme/ChannelListEmptySearchContentParams;Landroidx/compose/runtime/Composer;I)V diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/state/channels/list/ChannelsState.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/state/channels/list/ChannelsState.kt index 6630e9135e2..84f3468dead 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/state/channels/list/ChannelsState.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/state/channels/list/ChannelsState.kt @@ -24,6 +24,7 @@ package io.getstream.chat.android.compose.state.channels.list * @param endOfChannels If we've reached the end of channels, to stop triggering pagination. * @param channelItems The channel items to represent in the list. * @param searchQuery The current search query. + * @param loadingError If the last channel load failed. Cleared once a subsequent load succeeds. */ public data class ChannelsState( val isLoading: Boolean = true, @@ -31,4 +32,5 @@ public data class ChannelsState( val endOfChannels: Boolean = false, val channelItems: List = emptyList(), val searchQuery: SearchQuery = SearchQuery.Empty, + val loadingError: Boolean = false, ) 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 48cc61f5b5a..d839411c0bf 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 @@ -51,6 +51,7 @@ import io.getstream.chat.android.compose.state.channels.list.ItemState import io.getstream.chat.android.compose.ui.components.EmptyContent import io.getstream.chat.android.compose.ui.components.button.StreamButtonStyleDefaults import io.getstream.chat.android.compose.ui.components.button.StreamTextButton +import io.getstream.chat.android.compose.ui.theme.ChannelListBannerParams import io.getstream.chat.android.compose.ui.theme.ChannelListDividerItemParams import io.getstream.chat.android.compose.ui.theme.ChannelListEmptyContentParams import io.getstream.chat.android.compose.ui.theme.ChannelListEmptySearchContentParams @@ -95,6 +96,8 @@ import kotlinx.coroutines.launch * @param onChannelLongClick Handler for a long item tap. * @param onSearchResultClick Handler for a single search result tap. * @param onStartChatClick Handler for the "Start a chat" button in the empty state. If null, the button is hidden. + * @param onLoadingErrorClick Handler for the "Tap to retry" banner shown when loading the next page fails. + * Defaults to retrying the failed load. */ @Composable public fun ChannelList( @@ -114,6 +117,7 @@ public fun ChannelList( onChannelLongClick: (Channel) -> Unit = remember(viewModel) { { viewModel.selectChannel(it) } }, onSearchResultClick: (Message) -> Unit = {}, onStartChatClick: (() -> Unit)? = null, + onLoadingErrorClick: () -> Unit = remember(viewModel) { { viewModel.loadMore() } }, ) { val user by viewModel.user.collectAsState() val selectedCid = viewModel.selectedChannel.value?.cid @@ -161,6 +165,7 @@ public fun ChannelList( onChannelLongClick = onChannelLongClick, onSearchResultClick = onSearchResultClick, onStartChatClick = onStartChatClick, + onLoadingErrorClick = onLoadingErrorClick, ) } } @@ -188,6 +193,7 @@ public fun ChannelList( * @param onChannelLongClick Handler for a long item tap. * @param onSearchResultClick Handler for a single search result tap. * @param onStartChatClick Handler for the "Start a chat" button in the empty state. If null, the button is hidden. + * @param onLoadingErrorClick Handler for the "Tap to retry" banner shown when loading the next page fails. */ @Composable public fun ChannelList( @@ -201,42 +207,50 @@ public fun ChannelList( onChannelLongClick: (Channel) -> Unit = {}, onSearchResultClick: (Message) -> Unit = {}, onStartChatClick: (() -> Unit)? = null, + onLoadingErrorClick: () -> Unit = {}, ) { val (isLoading, _, _, channels, searchQuery) = channelsState when { channels.isNotEmpty() -> { - Channels( - modifier = modifier, - contentPadding = contentPadding, - channelsState = channelsState, - lazyListState = lazyListState, - onLastItemReached = onLastItemReached, - helperContent = { + Column(modifier = modifier) { + if (channelsState.loadingError) { with(ChatTheme.componentFactory) { - ChannelListHelperContent(params = ChannelListHelperContentParams()) + ChannelListBanner(params = ChannelListBannerParams(onClick = onLoadingErrorClick)) } - }, - loadingMoreContent = { - with(ChatTheme.componentFactory) { - ChannelListLoadingMoreItemContent(params = ChannelListLoadingMoreItemContentParams()) - } - }, - itemContent = { itemState -> - WrapperItemContent( - itemState = itemState, - currentUser = currentUser, - onChannelClick = onChannelClick, - onChannelLongClick = onChannelLongClick, - onSearchResultClick = onSearchResultClick, - ) - }, - divider = { - with(ChatTheme.componentFactory) { - ChannelListDividerItem(params = ChannelListDividerItemParams()) - } - }, - ) + } + Channels( + modifier = Modifier.fillMaxSize(), + contentPadding = contentPadding, + channelsState = channelsState, + lazyListState = lazyListState, + onLastItemReached = onLastItemReached, + helperContent = { + with(ChatTheme.componentFactory) { + ChannelListHelperContent(params = ChannelListHelperContentParams()) + } + }, + loadingMoreContent = { + with(ChatTheme.componentFactory) { + ChannelListLoadingMoreItemContent(params = ChannelListLoadingMoreItemContentParams()) + } + }, + itemContent = { itemState -> + WrapperItemContent( + itemState = itemState, + currentUser = currentUser, + onChannelClick = onChannelClick, + onChannelLongClick = onChannelLongClick, + onSearchResultClick = onSearchResultClick, + ) + }, + divider = { + with(ChatTheme.componentFactory) { + ChannelListDividerItem(params = ChannelListDividerItemParams()) + } + }, + ) + } } isLoading -> ChatTheme.componentFactory.ChannelListLoadingIndicator( diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/channels/list/ChannelListBanner.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/channels/list/ChannelListBanner.kt new file mode 100644 index 00000000000..7ce12d33733 --- /dev/null +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/channels/list/ChannelListBanner.kt @@ -0,0 +1,91 @@ +/* + * 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.compose.ui.channels.list + +import android.content.res.Configuration +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Icon +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import io.getstream.chat.android.compose.R +import io.getstream.chat.android.compose.ui.theme.ChatPreviewTheme +import io.getstream.chat.android.compose.ui.theme.ChatTheme +import io.getstream.chat.android.compose.ui.theme.StreamTokens +import io.getstream.chat.android.compose.ui.util.clickable + +/** + * Banner showing an error icon and a "Tap to retry" prompt, invoking [onClick] when tapped. Meant to be pinned below + * the channel list header while a channel load is in error. + * + * @param modifier [Modifier] instance for general styling. + * @param onClick Action invoked when the banner is tapped. + */ +@Composable +internal fun ChannelListBanner( + modifier: Modifier = Modifier, + onClick: () -> Unit = {}, +) { + val color = ChatTheme.colors.chatTextSystem + + Row( + modifier = modifier + .fillMaxWidth() + .background(ChatTheme.colors.backgroundCoreSurfaceDefault) + .clickable { onClick() } + .padding(StreamTokens.spacingSm), + horizontalArrangement = Arrangement.spacedBy( + StreamTokens.spacingXs, + Alignment.CenterHorizontally, + ), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + modifier = Modifier.size(16.dp), + painter = painterResource(R.drawable.stream_design_ic_exclamation_circle), + contentDescription = null, + tint = color, + ) + Text( + text = stringResource(R.string.stream_compose_channel_list_banner_error), + style = ChatTheme.typography.metadataEmphasis, + color = color, + ) + } +} + +@Composable +@Preview +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) +private fun ChannelListBannerErrorPreview() { + ChatPreviewTheme { + Surface { + ChannelListBanner(onClick = {}) + } + } +} diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/theme/ChatComponentFactory.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/theme/ChatComponentFactory.kt index 837d14ce6ba..95ee7f71a3c 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/theme/ChatComponentFactory.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/theme/ChatComponentFactory.kt @@ -311,6 +311,20 @@ public interface ChatComponentFactory { ) } + /** + * The default channel list banner, pinned below the header when loading the next page of channels fails. + * Shows a "Tap to retry" prompt and retries the failed load when tapped. + * + * @param params Parameters for this component. + */ + @Composable + public fun ChannelListBanner(params: ChannelListBannerParams) { + io.getstream.chat.android.compose.ui.channels.list.ChannelListBanner( + modifier = params.modifier, + onClick = params.onClick, + ) + } + /** * The default empty content of the channel list. * diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/theme/ChatComponentFactoryParams.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/theme/ChatComponentFactoryParams.kt index 0da471b28a3..801631148d6 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/theme/ChatComponentFactoryParams.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/theme/ChatComponentFactoryParams.kt @@ -159,6 +159,17 @@ public data class ChannelListLoadingIndicatorParams( val modifier: Modifier = Modifier, ) +/** + * Parameters for [ChatComponentFactory.ChannelListBanner]. + * + * @param modifier Modifier for styling. + * @param onClick Action invoked when the banner is tapped to retry the failed load. + */ +public data class ChannelListBannerParams( + val modifier: Modifier = Modifier, + val onClick: () -> Unit = {}, +) + /** * Parameters for [ChatComponentFactory.ChannelListEmptyContent]. * 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 548ee787768..28ec92bdc08 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 @@ -908,14 +908,18 @@ public class ChannelListViewModel internal constructor( } lastNextQuery = nextQuery logger.v { "[loadMoreQueryChannels] offset: ${nextQuery.offset}, limit: ${nextQuery.limit}" } + // Preserve loadingError until the outcome is known, so a failing retry doesn't clear and re-set it. channelsState = channelsState.copy(isLoadingMore = true) val result = chatClient.queryChannels(nextQuery).await() if (result.isSuccess) { logger.v { "[loadMoreQueryChannels] completed; channels.size: ${result.getOrNull()?.size}" } + channelsState = channelsState.copy(isLoadingMore = false, loadingError = false) } else { logger.e { "[loadMoreQueryChannels] failed: ${result.errorOrNull()}" } + // Clear the cached query so a retry re-issues this page instead of being rejected as a duplicate. + lastNextQuery = null + channelsState = channelsState.copy(isLoadingMore = false, loadingError = true) } - channelsState = channelsState.copy(isLoadingMore = false) } /** diff --git a/stream-chat-android-compose/src/main/res/values/strings.xml b/stream-chat-android-compose/src/main/res/values/strings.xml index 18d06556836..aa08d415354 100644 --- a/stream-chat-android-compose/src/main/res/values/strings.xml +++ b/stream-chat-android-compose/src/main/res/values/strings.xml @@ -34,6 +34,7 @@ No conversations yet Start a chat + Couldn\'t load new chats. Tap to retry Mute diff --git a/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/ui/channels/list/ChannelListBannerTest.kt b/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/ui/channels/list/ChannelListBannerTest.kt new file mode 100644 index 00000000000..f53ea0fcd46 --- /dev/null +++ b/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/ui/channels/list/ChannelListBannerTest.kt @@ -0,0 +1,36 @@ +/* + * 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.compose.ui.channels.list + +import app.cash.paparazzi.DeviceConfig +import app.cash.paparazzi.Paparazzi +import io.getstream.chat.android.compose.ui.PaparazziComposeTest +import org.junit.Rule +import org.junit.Test + +internal class ChannelListBannerTest : PaparazziComposeTest { + + @get:Rule + override val paparazzi = Paparazzi(deviceConfig = DeviceConfig.PIXEL_2) + + @Test + fun `error state`() { + snapshotWithDarkMode { + ChannelListBanner(onClick = {}) + } + } +} diff --git a/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/viewmodel/channels/ChannelListViewModelTest.kt b/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/viewmodel/channels/ChannelListViewModelTest.kt index 2f344ad850f..a101d537a84 100644 --- a/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/viewmodel/channels/ChannelListViewModelTest.kt +++ b/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/viewmodel/channels/ChannelListViewModelTest.kt @@ -50,6 +50,7 @@ import io.getstream.chat.android.randomMessage import io.getstream.chat.android.test.TestCoroutineExtension import io.getstream.chat.android.test.asCall import io.getstream.chat.android.ui.common.state.channels.actions.DeleteConversation +import io.getstream.result.Error import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.TestScope @@ -336,6 +337,111 @@ internal class ChannelListViewModelTest { assertEquals(30, captor.secondValue.offset) } + @Test + fun `Given loading more channels fails When loading more Should expose a loading error`() = runTest { + val nextPageRequest = QueryChannelsRequest( + filter = queryFilter, + querySort = querySort, + offset = 30, + limit = 30, + ) + val chatClient: ChatClient = mock() + whenever(chatClient.queryChannels(any())).doReturn( + listOf(channel1, channel2).asCall(), + Error.GenericError("network error").asCall(), + ) + val viewModel = Fixture(chatClient) + .givenCurrentUser() + .givenChannelsState( + channelsStateData = ChannelsStateData.Result(listOf(channel1, channel2)), + nextPageRequest = nextPageRequest, + loading = false, + ) + .givenChannelMutes() + .givenIsOffline(false) + .get(this) + + viewModel.loadMore() + advanceUntilIdle() + + assertTrue(viewModel.channelsState.loadingError) + assertFalse(viewModel.channelsState.isLoadingMore) + } + + @Test + fun `Given a loading error When retrying successfully Should clear the loading error`() = runTest { + val nextPageRequest = QueryChannelsRequest( + filter = queryFilter, + querySort = querySort, + offset = 30, + limit = 30, + ) + val chatClient: ChatClient = mock() + whenever(chatClient.queryChannels(any())).doReturn( + listOf(channel1, channel2).asCall(), + Error.GenericError("network error").asCall(), + listOf(channel1, channel2).asCall(), + ) + val viewModel = Fixture(chatClient) + .givenCurrentUser() + .givenChannelsState( + channelsStateData = ChannelsStateData.Result(listOf(channel1, channel2)), + nextPageRequest = nextPageRequest, + loading = false, + ) + .givenChannelMutes() + .givenIsOffline(false) + .get(this) + + viewModel.loadMore() + advanceUntilIdle() + assertTrue(viewModel.channelsState.loadingError) + + // Tapping the banner retries the same page; the failed query is no longer rejected as a duplicate. + viewModel.loadMore() + advanceUntilIdle() + + assertFalse(viewModel.channelsState.loadingError) + verify(chatClient, times(3)).queryChannels(any()) + } + + @Test + fun `Given a loading error When retrying unsuccessfully Should keep the loading error`() = runTest { + val nextPageRequest = QueryChannelsRequest( + filter = queryFilter, + querySort = querySort, + offset = 30, + limit = 30, + ) + val chatClient: ChatClient = mock() + whenever(chatClient.queryChannels(any())).doReturn( + listOf(channel1, channel2).asCall(), + Error.GenericError("network error").asCall(), + Error.GenericError("network error").asCall(), + ) + val viewModel = Fixture(chatClient) + .givenCurrentUser() + .givenChannelsState( + channelsStateData = ChannelsStateData.Result(listOf(channel1, channel2)), + nextPageRequest = nextPageRequest, + loading = false, + ) + .givenChannelMutes() + .givenIsOffline(false) + .get(this) + + viewModel.loadMore() + advanceUntilIdle() + assertTrue(viewModel.channelsState.loadingError) + + // A failing retry keeps the banner up (no flicker); the error is only cleared on success. + viewModel.loadMore() + advanceUntilIdle() + + assertTrue(viewModel.channelsState.loadingError) + verify(chatClient, times(3)).queryChannels(any()) + } + @Test fun `Given channel list in content state and the current user is offline When loading more channels Should do nothing`() = runTest { diff --git a/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.channels.list_ChannelListBannerTest_error_state.png b/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.channels.list_ChannelListBannerTest_error_state.png new file mode 100644 index 0000000000000000000000000000000000000000..a6e1cbb9236f353beec8cfb9e4363d7e27c3c351 GIT binary patch literal 12644 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 Date: Mon, 8 Jun 2026 11:07:26 +0100 Subject: [PATCH 2/4] compose: Fix duplicate key in ChannelList content preview The content-state preview listed two items backed by the same channelWithOnlineUser, producing identical LazyColumn keys (ItemState.ChannelItemState.key = channel.cid) and crashing the preview. Use channelWithOneUser for the draft-message item so each item has a unique key. --- .../chat/android/compose/ui/channels/list/ChannelList.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 d839411c0bf..beb02701fb4 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 @@ -437,7 +437,7 @@ private fun ChannelListForContentStatePreview() { draftMessage = null, ), ItemState.ChannelItemState( - channel = PreviewChannelData.channelWithOnlineUser, + channel = PreviewChannelData.channelWithOneUser, typingUsers = emptyList(), draftMessage = PreviewMessageData.draftMessage, ), From 8bc5bb2ab1e3a170b194f09768b404e5b1c7d8dc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Mion?= Date: Mon, 8 Jun 2026 13:50:14 +0100 Subject: [PATCH 3/4] compose: Add channel list action feedback snackbars Surface transient feedback on the Channel List when a user-triggered action completes, aligning with the Figma "User-triggered Action Fail" and "Channel Deleted" states. Previously channel actions failed silently. - Add an internal ChannelListViewModel.events flow emitting ChannelListEvent (ActionError per failed action, ChannelDeleted on a successful delete) - Route the 12 channel/user action handlers through the events flow - Host a StreamSnackbarHost on ChannelsScreen and map events to snackbars (error pill for failures, confirmation pill for "Chat deleted") - Add per-action error strings and "Chat deleted", with translations for all supported locales --- .../state/channels/list/ChannelListEvent.kt | 57 ++++++++++++ .../compose/ui/channels/ChannelsScreen.kt | 70 ++++++++++++++ .../channels/ChannelListViewModel.kt | 93 +++++++++++++------ .../src/main/res/values-es/strings.xml | 14 +++ .../src/main/res/values-fr/strings.xml | 14 +++ .../src/main/res/values-hi/strings.xml | 14 +++ .../src/main/res/values-in/strings.xml | 14 +++ .../src/main/res/values-it/strings.xml | 14 +++ .../src/main/res/values-ja/strings.xml | 14 +++ .../src/main/res/values-ko/strings.xml | 14 +++ .../src/main/res/values/strings.xml | 13 +++ .../channels/ChannelListViewModelTest.kt | 70 ++++++++++++++ 12 files changed, 371 insertions(+), 30 deletions(-) create mode 100644 stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/state/channels/list/ChannelListEvent.kt diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/state/channels/list/ChannelListEvent.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/state/channels/list/ChannelListEvent.kt new file mode 100644 index 00000000000..5ebde3df5d7 --- /dev/null +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/state/channels/list/ChannelListEvent.kt @@ -0,0 +1,57 @@ +/* + * 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.compose.state.channels.list + +import io.getstream.result.Error + +/** + * One-off events emitted by the channel list in response to user-triggered actions, used to drive transient feedback + * such as a snackbar. + */ +internal sealed interface ChannelListEvent { + + /** + * A user-triggered channel action failed. + * + * @param action The action that failed, used to pick the feedback message. + * @param error The error that caused the failure. + */ + data class ActionError(val action: ChannelListAction, val error: Error) : ChannelListEvent + + /** + * A channel was successfully deleted. + */ + data object ChannelDeleted : ChannelListEvent +} + +/** + * The user-triggered channel actions that can produce a [ChannelListEvent.ActionError]. + */ +internal enum class ChannelListAction { + MuteChannel, + UnmuteChannel, + PinChannel, + UnpinChannel, + ArchiveChannel, + UnarchiveChannel, + DeleteChannel, + LeaveGroup, + MuteUser, + UnmuteUser, + BlockUser, + UnblockUser, +} diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/channels/ChannelsScreen.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/channels/ChannelsScreen.kt index 83c87f60c5a..890556ce1ac 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/channels/ChannelsScreen.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/channels/ChannelsScreen.kt @@ -17,6 +17,7 @@ package io.getstream.chat.android.compose.ui.channels import androidx.activity.compose.BackHandler +import androidx.annotation.StringRes import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -24,19 +25,27 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarDuration +import androidx.compose.material3.SnackbarHostState import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalResources import androidx.compose.ui.platform.testTag import androidx.compose.ui.semantics.paneTitle import androidx.compose.ui.semantics.semantics import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel +import io.getstream.chat.android.compose.R +import io.getstream.chat.android.compose.state.channels.list.ChannelListAction +import io.getstream.chat.android.compose.state.channels.list.ChannelListEvent import io.getstream.chat.android.compose.state.channels.list.SearchQuery import io.getstream.chat.android.compose.ui.channels.list.ChannelList import io.getstream.chat.android.compose.ui.components.SimpleDialog @@ -46,12 +55,16 @@ import io.getstream.chat.android.compose.ui.theme.ChannelListSearchInputParams import io.getstream.chat.android.compose.ui.theme.ChannelMenuParams import io.getstream.chat.android.compose.ui.theme.ChatTheme import io.getstream.chat.android.compose.ui.theme.StreamTokens +import io.getstream.chat.android.compose.ui.util.StreamSnackbarHost +import io.getstream.chat.android.compose.ui.util.StreamSnackbarVariant +import io.getstream.chat.android.compose.ui.util.StreamSnackbarVisuals import io.getstream.chat.android.compose.viewmodel.channels.ChannelListViewModel import io.getstream.chat.android.compose.viewmodel.channels.ChannelListViewModelFactory import io.getstream.chat.android.models.Channel import io.getstream.chat.android.models.Message import io.getstream.chat.android.ui.common.state.channels.actions.ChannelAction import io.getstream.chat.android.ui.common.state.channels.actions.ViewInfo +import kotlinx.coroutines.launch /** * Default root Channel screen component, that provides the necessary ViewModel. @@ -112,6 +125,9 @@ public fun ChannelsScreen( var searchQuery by rememberSaveable { mutableStateOf(listViewModel.searchQuery.query) } + val snackbarHostState = remember(::SnackbarHostState) + EventHandler(listViewModel, snackbarHostState) + Box( modifier = Modifier .fillMaxSize() @@ -121,6 +137,7 @@ public fun ChannelsScreen( modifier = Modifier .fillMaxSize() .semantics { paneTitle = title }, + snackbarHost = { StreamSnackbarHost(snackbarHostState) }, topBar = { if (isShowingHeader) { ChatTheme.componentFactory.ChannelListHeader( @@ -229,6 +246,59 @@ public fun ChannelsScreen( } } +/** + * Collects [ChannelListViewModel.events] and surfaces them as a snackbar: an error pill for a failed channel action, + * and a confirmation pill when a channel is deleted. + */ +@Composable +private fun EventHandler(viewModel: ChannelListViewModel, snackbarHostState: SnackbarHostState) { + val snackbarScope = rememberCoroutineScope() + val resources = LocalResources.current + + fun showSnackbar(@StringRes resId: Int, variant: StreamSnackbarVariant) { + snackbarScope.launch { + snackbarHostState.showSnackbar( + StreamSnackbarVisuals( + message = resources.getString(resId), + variant = variant, + duration = SnackbarDuration.Short, + ), + ) + } + } + + LaunchedEffect(viewModel) { + viewModel.events.collect { event -> + when (event) { + is ChannelListEvent.ActionError -> + showSnackbar(event.action.errorMessageResId(), StreamSnackbarVariant.Error) + + is ChannelListEvent.ChannelDeleted -> + showSnackbar(R.string.stream_compose_channel_list_channel_deleted, StreamSnackbarVariant.Default) + } + } + } +} + +/** + * The snackbar message shown when [this] channel action fails. + */ +@StringRes +private fun ChannelListAction.errorMessageResId(): Int = when (this) { + ChannelListAction.MuteChannel -> R.string.stream_compose_channel_list_action_error_mute_channel + ChannelListAction.UnmuteChannel -> R.string.stream_compose_channel_list_action_error_unmute_channel + ChannelListAction.PinChannel -> R.string.stream_compose_channel_list_action_error_pin_channel + ChannelListAction.UnpinChannel -> R.string.stream_compose_channel_list_action_error_unpin_channel + ChannelListAction.ArchiveChannel -> R.string.stream_compose_channel_list_action_error_archive_channel + ChannelListAction.UnarchiveChannel -> R.string.stream_compose_channel_list_action_error_unarchive_channel + ChannelListAction.DeleteChannel -> R.string.stream_compose_channel_list_action_error_delete_channel + ChannelListAction.LeaveGroup -> R.string.stream_compose_channel_list_action_error_leave_group + ChannelListAction.MuteUser -> R.string.stream_compose_channel_list_action_error_mute_user + ChannelListAction.UnmuteUser -> R.string.stream_compose_channel_list_action_error_unmute_user + ChannelListAction.BlockUser -> R.string.stream_compose_channel_list_action_error_block_user + ChannelListAction.UnblockUser -> R.string.stream_compose_channel_list_action_error_unblock_user +} + /** * The types of search modes in the channel screen. */ 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 28ec92bdc08..1f66d8f5fd6 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 @@ -32,6 +32,8 @@ import io.getstream.chat.android.client.api.state.QueryChannelsState import io.getstream.chat.android.client.api.state.globalStateFlow import io.getstream.chat.android.client.api.state.queryChannelsAsState import io.getstream.chat.android.compose.state.QueryConfig +import io.getstream.chat.android.compose.state.channels.list.ChannelListAction +import io.getstream.chat.android.compose.state.channels.list.ChannelListEvent import io.getstream.chat.android.compose.state.channels.list.ChannelsState import io.getstream.chat.android.compose.state.channels.list.ItemState import io.getstream.chat.android.compose.state.channels.list.SearchQuery @@ -54,14 +56,19 @@ import io.getstream.chat.android.ui.common.state.channels.actions.ChannelAction import io.getstream.chat.android.ui.common.utils.extensions.defaultChannelListFilter import io.getstream.chat.android.ui.common.utils.extensions.isOneToOne import io.getstream.log.taggedLogger +import io.getstream.result.Result +import io.getstream.result.call.Call import io.getstream.result.call.toUnitCall import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancelChildren import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.filterNotNull @@ -267,6 +274,15 @@ public class ChannelListViewModel internal constructor( public var activeChannelAction: ChannelAction? by mutableStateOf(null) private set + // Buffer one emission so action callbacks aren't dropped if no collector is momentarily active. + private val _events = MutableSharedFlow(extraBufferCapacity = 1) + + /** + * Emits [ChannelListEvent]s as channel actions complete. Hot flow with no replay; collect it while the screen is + * active to surface transient feedback such as a snackbar. + */ + internal val events: SharedFlow = _events.asSharedFlow() + /** * The state of our network connection - if we're online, connecting or offline. */ @@ -441,7 +457,8 @@ public class ChannelListViewModel internal constructor( public fun muteChannel(channel: Channel) { dismissChannelAction() - chatClient.muteChannel(channel.type, channel.id).enqueue() + chatClient.muteChannel(channel.type, channel.id) + .enqueueTrackingError(ChannelListAction.MuteChannel) } /** @@ -451,7 +468,8 @@ public class ChannelListViewModel internal constructor( */ public fun pinChannel(channel: Channel) { dismissChannelAction() - chatClient.pinChannel(channel.type, channel.id).enqueue() + chatClient.pinChannel(channel.type, channel.id) + .enqueueTrackingError(ChannelListAction.PinChannel) } /** @@ -461,23 +479,20 @@ public class ChannelListViewModel internal constructor( */ public fun unpinChannel(channel: Channel) { dismissChannelAction() - chatClient.unpinChannel(channel.type, channel.id).enqueue() + chatClient.unpinChannel(channel.type, channel.id) + .enqueueTrackingError(ChannelListAction.UnpinChannel) } public fun archiveChannel(channel: Channel) { dismissChannelAction() - chatClient.archiveChannel( - channel.type, - channel.id, - ).enqueue() + chatClient.archiveChannel(channel.type, channel.id) + .enqueueTrackingError(ChannelListAction.ArchiveChannel) } public fun unarchiveChannel(channel: Channel) { dismissChannelAction() - chatClient.unarchiveChannel( - channel.type, - channel.id, - ).enqueue() + chatClient.unarchiveChannel(channel.type, channel.id) + .enqueueTrackingError(ChannelListAction.UnarchiveChannel) } /** @@ -488,7 +503,8 @@ public class ChannelListViewModel internal constructor( public fun unmuteChannel(channel: Channel) { dismissChannelAction() - chatClient.unmuteChannel(channel.type, channel.id).enqueue() + chatClient.unmuteChannel(channel.type, channel.id) + .enqueueTrackingError(ChannelListAction.UnmuteChannel) } /** @@ -500,7 +516,14 @@ public class ChannelListViewModel internal constructor( public fun deleteConversation(channel: Channel) { dismissChannelAction() - chatClient.channel(channel.cid).delete().toUnitCall().enqueue() + chatClient.channel(channel.cid).delete().toUnitCall().enqueue { result -> + when (result) { + is Result.Success -> _events.tryEmit(ChannelListEvent.ChannelDeleted) + is Result.Failure -> _events.tryEmit( + ChannelListEvent.ActionError(ChannelListAction.DeleteChannel, result.value), + ) + } + } } /** @@ -513,7 +536,9 @@ public class ChannelListViewModel internal constructor( dismissChannelAction() chatClient.clientState.user.value?.let { user -> - chatClient.channel(channel.type, channel.id).removeMembers(listOf(user.id)).enqueue() + chatClient.channel(channel.type, channel.id) + .removeMembers(listOf(user.id)) + .enqueueTrackingError(ChannelListAction.LeaveGroup) } } @@ -524,7 +549,8 @@ public class ChannelListViewModel internal constructor( */ public fun muteUser(userId: String) { dismissChannelAction() - chatClient.muteUser(userId).enqueue() + chatClient.muteUser(userId) + .enqueueTrackingError(ChannelListAction.MuteUser) } /** @@ -534,7 +560,8 @@ public class ChannelListViewModel internal constructor( */ public fun unmuteUser(userId: String) { dismissChannelAction() - chatClient.unmuteUser(userId).enqueue() + chatClient.unmuteUser(userId) + .enqueueTrackingError(ChannelListAction.UnmuteUser) } /** @@ -544,7 +571,8 @@ public class ChannelListViewModel internal constructor( */ public fun blockUser(userId: String) { dismissChannelAction() - chatClient.blockUser(userId).enqueue() + chatClient.blockUser(userId) + .enqueueTrackingError(ChannelListAction.BlockUser) } /** @@ -554,7 +582,19 @@ public class ChannelListViewModel internal constructor( */ public fun unblockUser(userId: String) { dismissChannelAction() - chatClient.unblockUser(userId).enqueue() + chatClient.unblockUser(userId) + .enqueueTrackingError(ChannelListAction.UnblockUser) + } + + /** + * Enqueues this call, emitting a [ChannelListEvent.ActionError] for [action] if it fails. + */ + private fun Call.enqueueTrackingError(action: ChannelListAction) { + enqueue { result -> + if (result is Result.Failure) { + _events.tryEmit(ChannelListEvent.ActionError(action, result.value)) + } + } } /** @@ -563,9 +603,7 @@ public class ChannelListViewModel internal constructor( * @param cid The CID of the channel that needs to be checked. * @return True if the channel is muted for the current user. */ - public fun isChannelMuted(cid: String): Boolean { - return channelMutes.value.any { cid == it.channel?.cid } - } + public fun isChannelMuted(cid: String): Boolean = channelMutes.value.any { cid == it.channel?.cid } /** * Checks if a user is muted by the current user. @@ -573,9 +611,7 @@ public class ChannelListViewModel internal constructor( * @param userId The ID of the user to check. * @return True if the user is muted. */ - public fun isUserMuted(userId: String): Boolean { - return globalMuted.value.any { it.target?.id == userId } - } + public fun isUserMuted(userId: String): Boolean = globalMuted.value.any { it.target?.id == userId } /** * Checks if a user is blocked by the current user. @@ -583,9 +619,7 @@ public class ChannelListViewModel internal constructor( * @param userId The ID of the user to check. * @return True if the user is blocked. */ - public fun isUserBlocked(userId: String): Boolean { - return globalBlockedUserIds.value.contains(userId) - } + public fun isUserBlocked(userId: String): Boolean = globalBlockedUserIds.value.contains(userId) /** * Dismisses the [activeChannelAction] and removes it from the UI. @@ -961,9 +995,8 @@ public class ChannelListViewModel internal constructor( /** * Builds the default channel filter, which represents "messaging" channels that the current user is a part of. */ - private fun defaultChannelsFilter(): Flow { - return chatClient.clientState.user.map(Filters::defaultChannelListFilter).filterNotNull() - } + private fun defaultChannelsFilter(): Flow = + chatClient.clientState.user.map(Filters::defaultChannelListFilter).filterNotNull() @Deprecated( message = "Avoid using this search query as `member.user.name` is an expensive operation. " + diff --git a/stream-chat-android-compose/src/main/res/values-es/strings.xml b/stream-chat-android-compose/src/main/res/values-es/strings.xml index 0966f3a4956..3c807dc699f 100644 --- a/stream-chat-android-compose/src/main/res/values-es/strings.xml +++ b/stream-chat-android-compose/src/main/res/values-es/strings.xml @@ -72,6 +72,20 @@ "No se encontraron resultados para \"%1$s\"" "Nueva conversación" "Iniciar una conversación" + "No se pudieron cargar nuevos chats. Toca para reintentar" + "Error al silenciar el chat." + "Error al activar el sonido del chat." + "Error al fijar el chat." + "Error al dejar de fijar el chat." + "Error al archivar el chat." + "Error al desarchivar el chat." + "Error al eliminar el chat." + "Error al salir del grupo." + "Error al silenciar al usuario." + "Error al activar el sonido del usuario." + "Error al bloquear al usuario." + "Error al desbloquear al usuario." + "Chat eliminado." "Escribiendo" "%s está escribiendo" "%1$s y %2$s están escribiendo" diff --git a/stream-chat-android-compose/src/main/res/values-fr/strings.xml b/stream-chat-android-compose/src/main/res/values-fr/strings.xml index aab62aed9cc..d87d6110bab 100644 --- a/stream-chat-android-compose/src/main/res/values-fr/strings.xml +++ b/stream-chat-android-compose/src/main/res/values-fr/strings.xml @@ -72,6 +72,20 @@ "Aucun résultat pour \"%1$s\"" "Nouvelle discussion" "Démarrer une discussion" + "Impossible de charger les nouveaux chats. Appuyez pour réessayer" + "Erreur lors de la mise en sourdine du chat." + "Erreur lors de la réactivation du chat." + "Erreur lors de l\'épinglage du chat." + "Erreur lors du désépinglage du chat." + "Erreur lors de l\'archivage du chat." + "Erreur lors du désarchivage du chat." + "Erreur lors de la suppression du chat." + "Erreur lors de la sortie du groupe." + "Erreur lors de la mise en sourdine de l\'utilisateur." + "Erreur lors de la réactivation de l\'utilisateur." + "Erreur lors du blocage de l\'utilisateur." + "Erreur lors du déblocage de l\'utilisateur." + "Chat supprimé." "Écrit" "%s écrit" "%1$s et %2$s écrivent" diff --git a/stream-chat-android-compose/src/main/res/values-hi/strings.xml b/stream-chat-android-compose/src/main/res/values-hi/strings.xml index 152f472badf..8066134a541 100644 --- a/stream-chat-android-compose/src/main/res/values-hi/strings.xml +++ b/stream-chat-android-compose/src/main/res/values-hi/strings.xml @@ -132,6 +132,20 @@ "\"%1$s\" के लिए कोई परिणाम नहीं" "नई चैट" "चैट शुरू करें" + "नई चैट लोड नहीं हो सकीं। पुनः प्रयास के लिए टैप करें" + "चैट म्यूट करने में त्रुटि।" + "चैट अनम्यूट करने में त्रुटि।" + "चैट पिन करने में त्रुटि।" + "चैट अनपिन करने में त्रुटि।" + "चैट आर्काइव करने में त्रुटि।" + "चैट अनआर्काइव करने में त्रुटि।" + "चैट हटाने में त्रुटि।" + "ग्रुप छोड़ने में त्रुटि।" + "उपयोगकर्ता को म्यूट करने में त्रुटि।" + "उपयोगकर्ता को अनम्यूट करने में त्रुटि।" + "उपयोगकर्ता को ब्लॉक करने में त्रुटि।" + "उपयोगकर्ता को अनब्लॉक करने में त्रुटि।" + "चैट हटा दी गई।" "टाइप कर रहा है" "%s टाइप कर रहा है" "%1$s और %2$s टाइप कर रहे हैं" diff --git a/stream-chat-android-compose/src/main/res/values-in/strings.xml b/stream-chat-android-compose/src/main/res/values-in/strings.xml index b8641a0dd00..015c2628916 100644 --- a/stream-chat-android-compose/src/main/res/values-in/strings.xml +++ b/stream-chat-android-compose/src/main/res/values-in/strings.xml @@ -72,6 +72,20 @@ "Tidak ada hasil untuk \"%1$s\"" "Obrolan baru" "Mulai obrolan" + "Tidak dapat memuat obrolan baru. Ketuk untuk mencoba lagi" + "Gagal membisukan obrolan." + "Gagal membunyikan obrolan." + "Gagal menyematkan obrolan." + "Gagal melepas sematan obrolan." + "Gagal mengarsipkan obrolan." + "Gagal membatalkan arsip obrolan." + "Gagal menghapus obrolan." + "Gagal keluar dari grup." + "Gagal membisukan pengguna." + "Gagal membunyikan pengguna." + "Gagal memblokir pengguna." + "Gagal membuka blokir pengguna." + "Obrolan dihapus." "Mengetik" "%s sedang mengetik" "%1$s dan %2$s sedang mengetik" diff --git a/stream-chat-android-compose/src/main/res/values-it/strings.xml b/stream-chat-android-compose/src/main/res/values-it/strings.xml index aa4c860bc22..65be6a552f9 100644 --- a/stream-chat-android-compose/src/main/res/values-it/strings.xml +++ b/stream-chat-android-compose/src/main/res/values-it/strings.xml @@ -132,6 +132,20 @@ "Nessun risultato per \"%1$s\"" "Nuova chat" "Inizia una chat" + "Impossibile caricare nuove chat. Tocca per riprovare" + "Errore durante il silenziamento della chat." + "Errore durante la riattivazione della chat." + "Errore durante il fissaggio della chat." + "Errore durante la rimozione della chat dai fissati." + "Errore durante l\'archiviazione della chat." + "Errore durante il ripristino della chat dall\'archivio." + "Errore durante l\'eliminazione della chat." + "Errore durante l\'uscita dal gruppo." + "Errore durante il silenziamento dell\'utente." + "Errore durante la riattivazione dell\'utente." + "Errore durante il blocco dell\'utente." + "Errore durante lo sblocco dell\'utente." + "Chat eliminata." "Sta scrivendo" "%s sta scrivendo" "%1$s e %2$s stanno scrivendo" diff --git a/stream-chat-android-compose/src/main/res/values-ja/strings.xml b/stream-chat-android-compose/src/main/res/values-ja/strings.xml index f37bcd36aef..7e726bf63cd 100644 --- a/stream-chat-android-compose/src/main/res/values-ja/strings.xml +++ b/stream-chat-android-compose/src/main/res/values-ja/strings.xml @@ -72,6 +72,20 @@ "\"%1$s\"の検索結果はありません" "新しいチャット" "チャットを始める" + "新しいチャットを読み込めませんでした。タップして再試行" + "チャットをミュートできませんでした。" + "チャットのミュートを解除できませんでした。" + "チャットをピン留めできませんでした。" + "チャットのピン留めを解除できませんでした。" + "チャットをアーカイブできませんでした。" + "チャットのアーカイブを解除できませんでした。" + "チャットを削除できませんでした。" + "グループを離れられませんでした。" + "ユーザーをミュートできませんでした。" + "ユーザーのミュートを解除できませんでした。" + "ユーザーをブロックできませんでした。" + "ユーザーのブロックを解除できませんでした。" + "チャットを削除しました。" "%d件の未読メッセージ" "%d件の未読メッセージ" diff --git a/stream-chat-android-compose/src/main/res/values-ko/strings.xml b/stream-chat-android-compose/src/main/res/values-ko/strings.xml index b8fede41890..8f37f3bcf59 100644 --- a/stream-chat-android-compose/src/main/res/values-ko/strings.xml +++ b/stream-chat-android-compose/src/main/res/values-ko/strings.xml @@ -72,6 +72,20 @@ "\"%1$s\"에 대한 검색결과가 없습니다" "새 채팅" "채팅 시작하기" + "새 채팅을 불러올 수 없습니다. 탭하여 재시도" + "채팅을 음소거하지 못했습니다." + "채팅 음소거를 해제하지 못했습니다." + "채팅을 고정하지 못했습니다." + "채팅 고정을 해제하지 못했습니다." + "채팅을 보관하지 못했습니다." + "채팅 보관을 해제하지 못했습니다." + "채팅을 삭제하지 못했습니다." + "그룹에서 나가지 못했습니다." + "사용자를 음소거하지 못했습니다." + "사용자 음소거를 해제하지 못했습니다." + "사용자를 차단하지 못했습니다." + "사용자 차단을 해제하지 못했습니다." + "채팅이 삭제되었습니다." "%d개의 안 읽은 메시지" "%d개의 안 읽은 메시지" diff --git a/stream-chat-android-compose/src/main/res/values/strings.xml b/stream-chat-android-compose/src/main/res/values/strings.xml index aa08d415354..343332bf89a 100644 --- a/stream-chat-android-compose/src/main/res/values/strings.xml +++ b/stream-chat-android-compose/src/main/res/values/strings.xml @@ -35,6 +35,19 @@ No conversations yet Start a chat Couldn\'t load new chats. Tap to retry + Error muting channel. + Error unmuting channel. + Error pinning channel. + Error unpinning channel. + Error archiving channel. + Error unarchiving channel. + Error deleting channel. + Error leaving group. + Error muting user. + Error unmuting user. + Error blocking user. + Error unblocking user. + Chat deleted. Mute diff --git a/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/viewmodel/channels/ChannelListViewModelTest.kt b/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/viewmodel/channels/ChannelListViewModelTest.kt index a101d537a84..014545a6265 100644 --- a/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/viewmodel/channels/ChannelListViewModelTest.kt +++ b/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/viewmodel/channels/ChannelListViewModelTest.kt @@ -16,6 +16,7 @@ package io.getstream.chat.android.compose.viewmodel.channels +import app.cash.turbine.test import io.getstream.chat.android.client.ChatClient import io.getstream.chat.android.client.api.event.ChatEventHandlerFactory import io.getstream.chat.android.client.api.models.QueryChannelsRequest @@ -27,6 +28,8 @@ import io.getstream.chat.android.client.channel.ChannelClient import io.getstream.chat.android.client.internal.state.plugin.internal.StatePlugin import io.getstream.chat.android.client.persistance.repository.RepositoryFacade import io.getstream.chat.android.client.setup.state.ClientState +import io.getstream.chat.android.compose.state.channels.list.ChannelListAction +import io.getstream.chat.android.compose.state.channels.list.ChannelListEvent import io.getstream.chat.android.compose.state.channels.list.ItemState import io.getstream.chat.android.compose.state.channels.list.SearchQuery import io.getstream.chat.android.models.AndFilterObject @@ -968,6 +971,73 @@ internal class ChannelListViewModelTest { assertEquals(queryFilter, channelFilterCaptor.firstValue) } + @Test + fun `Given a channel action fails When muting Should emit an action error event`() = runTest { + val chatClient: ChatClient = mock() + whenever(chatClient.muteChannel(any(), any(), eq(null))) + .doReturn(Error.GenericError("network error").asCall()) + val viewModel = Fixture(chatClient) + .givenCurrentUser() + .givenChannelsQuery() + .givenChannelsState( + channelsStateData = ChannelsStateData.Result(listOf(channel1, channel2)), + loading = false, + ) + .givenChannelMutes() + .get(this) + + viewModel.events.test { + viewModel.muteChannel(channel1) + val event = awaitItem() + assertInstanceOf(ChannelListEvent.ActionError::class.java, event) + assertEquals(ChannelListAction.MuteChannel, (event as ChannelListEvent.ActionError).action) + } + } + + @Test + fun `Given delete succeeds When deleting a channel Should emit a channel deleted event`() = runTest { + val chatClient: ChatClient = mock() + val channelClient: ChannelClient = mock() + val viewModel = Fixture(chatClient, channelClient) + .givenCurrentUser() + .givenChannelsQuery() + .givenChannelsState( + channelsStateData = ChannelsStateData.Result(listOf(channel1, channel2)), + loading = false, + ) + .givenChannelMutes() + .givenDeleteChannel() + .get(this) + + viewModel.events.test { + viewModel.deleteConversation(channel1) + assertEquals(ChannelListEvent.ChannelDeleted, awaitItem()) + } + } + + @Test + fun `Given delete fails When deleting a channel Should emit an action error event`() = runTest { + val chatClient: ChatClient = mock() + val channelClient: ChannelClient = mock() + whenever(channelClient.delete()).doReturn(Error.GenericError("network error").asCall()) + val viewModel = Fixture(chatClient, channelClient) + .givenCurrentUser() + .givenChannelsQuery() + .givenChannelsState( + channelsStateData = ChannelsStateData.Result(listOf(channel1, channel2)), + loading = false, + ) + .givenChannelMutes() + .get(this) + + viewModel.events.test { + viewModel.deleteConversation(channel1) + val event = awaitItem() + assertInstanceOf(ChannelListEvent.ActionError::class.java, event) + assertEquals(ChannelListAction.DeleteChannel, (event as ChannelListEvent.ActionError).action) + } + } + private class Fixture( private val chatClient: ChatClient = mock(), private val channelClient: ChannelClient = mock(), From d52675b0ce379749b796d79a09f87effd73fef5b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Mion?= Date: Tue, 9 Jun 2026 10:35:01 +0100 Subject: [PATCH 4/4] compose: Add tests for channel list feedback states - Add ChannelListViewModel tests covering each channel/user action emitting an error event on failure - Extract the action-to-error-message mapping into its own file with a plain unit test (no Android runtime needed) - Route the channel list banner snapshot through the component factory --- .../ui/channels/ChannelListActionMessages.kt | 42 ++++++++ .../compose/ui/channels/ChannelsScreen.kt | 20 ---- .../channels/ChannelListActionMessagesTest.kt | 47 ++++++++ .../ui/channels/list/ChannelListBannerTest.kt | 6 +- .../channels/ChannelListViewModelTest.kt | 101 ++++++++++++++++++ 5 files changed, 195 insertions(+), 21 deletions(-) create mode 100644 stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/channels/ChannelListActionMessages.kt create mode 100644 stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/ui/channels/ChannelListActionMessagesTest.kt diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/channels/ChannelListActionMessages.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/channels/ChannelListActionMessages.kt new file mode 100644 index 00000000000..cdb6f3fab4f --- /dev/null +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/channels/ChannelListActionMessages.kt @@ -0,0 +1,42 @@ +/* + * 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.compose.ui.channels + +import androidx.annotation.StringRes +import androidx.annotation.VisibleForTesting +import io.getstream.chat.android.compose.R +import io.getstream.chat.android.compose.state.channels.list.ChannelListAction + +/** + * The snackbar message shown when [this] channel action fails. + */ +@StringRes +@VisibleForTesting +internal fun ChannelListAction.errorMessageResId(): Int = when (this) { + ChannelListAction.MuteChannel -> R.string.stream_compose_channel_list_action_error_mute_channel + ChannelListAction.UnmuteChannel -> R.string.stream_compose_channel_list_action_error_unmute_channel + ChannelListAction.PinChannel -> R.string.stream_compose_channel_list_action_error_pin_channel + ChannelListAction.UnpinChannel -> R.string.stream_compose_channel_list_action_error_unpin_channel + ChannelListAction.ArchiveChannel -> R.string.stream_compose_channel_list_action_error_archive_channel + ChannelListAction.UnarchiveChannel -> R.string.stream_compose_channel_list_action_error_unarchive_channel + ChannelListAction.DeleteChannel -> R.string.stream_compose_channel_list_action_error_delete_channel + ChannelListAction.LeaveGroup -> R.string.stream_compose_channel_list_action_error_leave_group + ChannelListAction.MuteUser -> R.string.stream_compose_channel_list_action_error_mute_user + ChannelListAction.UnmuteUser -> R.string.stream_compose_channel_list_action_error_unmute_user + ChannelListAction.BlockUser -> R.string.stream_compose_channel_list_action_error_block_user + ChannelListAction.UnblockUser -> R.string.stream_compose_channel_list_action_error_unblock_user +} diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/channels/ChannelsScreen.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/channels/ChannelsScreen.kt index 890556ce1ac..3f58dc8bbb3 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/channels/ChannelsScreen.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/channels/ChannelsScreen.kt @@ -44,7 +44,6 @@ import androidx.compose.ui.semantics.semantics import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel import io.getstream.chat.android.compose.R -import io.getstream.chat.android.compose.state.channels.list.ChannelListAction import io.getstream.chat.android.compose.state.channels.list.ChannelListEvent import io.getstream.chat.android.compose.state.channels.list.SearchQuery import io.getstream.chat.android.compose.ui.channels.list.ChannelList @@ -280,25 +279,6 @@ private fun EventHandler(viewModel: ChannelListViewModel, snackbarHostState: Sna } } -/** - * The snackbar message shown when [this] channel action fails. - */ -@StringRes -private fun ChannelListAction.errorMessageResId(): Int = when (this) { - ChannelListAction.MuteChannel -> R.string.stream_compose_channel_list_action_error_mute_channel - ChannelListAction.UnmuteChannel -> R.string.stream_compose_channel_list_action_error_unmute_channel - ChannelListAction.PinChannel -> R.string.stream_compose_channel_list_action_error_pin_channel - ChannelListAction.UnpinChannel -> R.string.stream_compose_channel_list_action_error_unpin_channel - ChannelListAction.ArchiveChannel -> R.string.stream_compose_channel_list_action_error_archive_channel - ChannelListAction.UnarchiveChannel -> R.string.stream_compose_channel_list_action_error_unarchive_channel - ChannelListAction.DeleteChannel -> R.string.stream_compose_channel_list_action_error_delete_channel - ChannelListAction.LeaveGroup -> R.string.stream_compose_channel_list_action_error_leave_group - ChannelListAction.MuteUser -> R.string.stream_compose_channel_list_action_error_mute_user - ChannelListAction.UnmuteUser -> R.string.stream_compose_channel_list_action_error_unmute_user - ChannelListAction.BlockUser -> R.string.stream_compose_channel_list_action_error_block_user - ChannelListAction.UnblockUser -> R.string.stream_compose_channel_list_action_error_unblock_user -} - /** * The types of search modes in the channel screen. */ diff --git a/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/ui/channels/ChannelListActionMessagesTest.kt b/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/ui/channels/ChannelListActionMessagesTest.kt new file mode 100644 index 00000000000..ed347696de8 --- /dev/null +++ b/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/ui/channels/ChannelListActionMessagesTest.kt @@ -0,0 +1,47 @@ +/* + * 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.compose.ui.channels + +import io.getstream.chat.android.compose.R +import io.getstream.chat.android.compose.state.channels.list.ChannelListAction +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +internal class ChannelListActionMessagesTest { + + @Test + fun `Every channel action maps to its own error message`() { + val expected = mapOf( + ChannelListAction.MuteChannel to R.string.stream_compose_channel_list_action_error_mute_channel, + ChannelListAction.UnmuteChannel to R.string.stream_compose_channel_list_action_error_unmute_channel, + ChannelListAction.PinChannel to R.string.stream_compose_channel_list_action_error_pin_channel, + ChannelListAction.UnpinChannel to R.string.stream_compose_channel_list_action_error_unpin_channel, + ChannelListAction.ArchiveChannel to R.string.stream_compose_channel_list_action_error_archive_channel, + ChannelListAction.UnarchiveChannel to R.string.stream_compose_channel_list_action_error_unarchive_channel, + ChannelListAction.DeleteChannel to R.string.stream_compose_channel_list_action_error_delete_channel, + ChannelListAction.LeaveGroup to R.string.stream_compose_channel_list_action_error_leave_group, + ChannelListAction.MuteUser to R.string.stream_compose_channel_list_action_error_mute_user, + ChannelListAction.UnmuteUser to R.string.stream_compose_channel_list_action_error_unmute_user, + ChannelListAction.BlockUser to R.string.stream_compose_channel_list_action_error_block_user, + ChannelListAction.UnblockUser to R.string.stream_compose_channel_list_action_error_unblock_user, + ) + + ChannelListAction.entries.forEach { action -> + assertEquals(expected[action], action.errorMessageResId(), "Unexpected message for $action") + } + } +} diff --git a/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/ui/channels/list/ChannelListBannerTest.kt b/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/ui/channels/list/ChannelListBannerTest.kt index f53ea0fcd46..91df9d6ac0f 100644 --- a/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/ui/channels/list/ChannelListBannerTest.kt +++ b/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/ui/channels/list/ChannelListBannerTest.kt @@ -19,6 +19,8 @@ package io.getstream.chat.android.compose.ui.channels.list import app.cash.paparazzi.DeviceConfig import app.cash.paparazzi.Paparazzi import io.getstream.chat.android.compose.ui.PaparazziComposeTest +import io.getstream.chat.android.compose.ui.theme.ChannelListBannerParams +import io.getstream.chat.android.compose.ui.theme.ChatTheme import org.junit.Rule import org.junit.Test @@ -30,7 +32,9 @@ internal class ChannelListBannerTest : PaparazziComposeTest { @Test fun `error state`() { snapshotWithDarkMode { - ChannelListBanner(onClick = {}) + with(ChatTheme.componentFactory) { + ChannelListBanner(params = ChannelListBannerParams(onClick = {})) + } } } } diff --git a/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/viewmodel/channels/ChannelListViewModelTest.kt b/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/viewmodel/channels/ChannelListViewModelTest.kt index 014545a6265..f037c12b15e 100644 --- a/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/viewmodel/channels/ChannelListViewModelTest.kt +++ b/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/viewmodel/channels/ChannelListViewModelTest.kt @@ -1038,6 +1038,107 @@ internal class ChannelListViewModelTest { } } + @Test + fun `Given pinning a channel fails Should emit a pin action error`() = runActionErrorTest( + expected = ChannelListAction.PinChannel, + stub = { chatClient, _ -> whenever(chatClient.pinChannel(any(), any())).doReturn(failedCall()) }, + action = { it.pinChannel(channel1) }, + ) + + @Test + fun `Given unpinning a channel fails Should emit an unpin action error`() = runActionErrorTest( + expected = ChannelListAction.UnpinChannel, + stub = { chatClient, _ -> whenever(chatClient.unpinChannel(any(), any())).doReturn(failedCall()) }, + action = { it.unpinChannel(channel1) }, + ) + + @Test + fun `Given archiving a channel fails Should emit an archive action error`() = runActionErrorTest( + expected = ChannelListAction.ArchiveChannel, + stub = { chatClient, _ -> whenever(chatClient.archiveChannel(any(), any())).doReturn(failedCall()) }, + action = { it.archiveChannel(channel1) }, + ) + + @Test + fun `Given unarchiving a channel fails Should emit an unarchive action error`() = runActionErrorTest( + expected = ChannelListAction.UnarchiveChannel, + stub = { chatClient, _ -> whenever(chatClient.unarchiveChannel(any(), any())).doReturn(failedCall()) }, + action = { it.unarchiveChannel(channel1) }, + ) + + @Test + fun `Given unmuting a channel fails Should emit an unmute action error`() = runActionErrorTest( + expected = ChannelListAction.UnmuteChannel, + stub = { chatClient, _ -> whenever(chatClient.unmuteChannel(any(), any())).doReturn(failedCall()) }, + action = { it.unmuteChannel(channel1) }, + ) + + @Test + fun `Given leaving a group fails Should emit a leave action error`() = runActionErrorTest( + expected = ChannelListAction.LeaveGroup, + stub = { _, channelClient -> + whenever(channelClient.removeMembers(any(), anyOrNull(), anyOrNull())).doReturn(failedCall()) + }, + action = { it.leaveGroup(channel1) }, + ) + + @Test + fun `Given muting a user fails Should emit a mute user action error`() = runActionErrorTest( + expected = ChannelListAction.MuteUser, + stub = { chatClient, _ -> whenever(chatClient.muteUser(any(), anyOrNull())).doReturn(failedCall()) }, + action = { it.muteUser("userId") }, + ) + + @Test + fun `Given unmuting a user fails Should emit an unmute user action error`() = runActionErrorTest( + expected = ChannelListAction.UnmuteUser, + stub = { chatClient, _ -> whenever(chatClient.unmuteUser(any())).doReturn(failedCall()) }, + action = { it.unmuteUser("userId") }, + ) + + @Test + fun `Given blocking a user fails Should emit a block action error`() = runActionErrorTest( + expected = ChannelListAction.BlockUser, + stub = { chatClient, _ -> whenever(chatClient.blockUser(any())).doReturn(failedCall()) }, + action = { it.blockUser("userId") }, + ) + + @Test + fun `Given unblocking a user fails Should emit an unblock action error`() = runActionErrorTest( + expected = ChannelListAction.UnblockUser, + stub = { chatClient, _ -> whenever(chatClient.unblockUser(any())).doReturn(failedCall()) }, + action = { it.unblockUser("userId") }, + ) + + private fun failedCall(): io.getstream.result.call.Call = Error.GenericError("network error").asCall() + + private fun runActionErrorTest( + expected: ChannelListAction, + stub: (ChatClient, ChannelClient) -> Unit, + action: (ChannelListViewModel) -> Unit, + ) = runTest { + val chatClient: ChatClient = mock() + val channelClient: ChannelClient = mock() + stub(chatClient, channelClient) + val viewModel = Fixture(chatClient, channelClient) + .givenCurrentUser() + .givenChannelsQuery() + .givenChannelsState( + channelsStateData = ChannelsStateData.Result(listOf(channel1, channel2)), + loading = false, + ) + .givenChannelMutes() + .givenIsOffline(false) + .get(this) + + viewModel.events.test { + action(viewModel) + val event = awaitItem() + assertInstanceOf(ChannelListEvent.ActionError::class.java, event) + assertEquals(expected, (event as ChannelListEvent.ActionError).action) + } + } + private class Fixture( private val chatClient: ChatClient = mock(), private val channelClient: ChannelClient = mock(),