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 9735a9e8963..8127f34335e 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 { @@ -2925,6 +2934,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 @@ -3413,6 +3438,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 @@ -3604,6 +3630,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/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/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/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 83c87f60c5a..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 @@ -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,26 @@ 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.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 +54,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 +124,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 +136,7 @@ public fun ChannelsScreen( modifier = Modifier .fillMaxSize() .semantics { paneTitle = title }, + snackbarHost = { StreamSnackbarHost(snackbarHostState) }, topBar = { if (isShowingHeader) { ChatTheme.componentFactory.ChannelListHeader( @@ -229,6 +245,40 @@ 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 types of search modes in the channel screen. */ 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..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 @@ -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( @@ -423,7 +437,7 @@ private fun ChannelListForContentStatePreview() { draftMessage = null, ), ItemState.ChannelItemState( - channel = PreviewChannelData.channelWithOnlineUser, + channel = PreviewChannelData.channelWithOneUser, typingUsers = emptyList(), draftMessage = PreviewMessageData.draftMessage, ), 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..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. @@ -908,14 +942,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) } /** @@ -957,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 d7e4530a4f6..1bacd5d0233 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 9a10ebe8c97..19ef7174405 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 94f1dbbdf26..51908527a4f 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 be571420618..0936d9339a8 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 c6f90f00a10..0e24e04a079 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 c7628d76f80..7741e0b23a7 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 ca3b52ca9d9..c14e650b64f 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 25422da5cc2..3a16271a885 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,20 @@ 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/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 new file mode 100644 index 00000000000..91df9d6ac0f --- /dev/null +++ b/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/ui/channels/list/ChannelListBannerTest.kt @@ -0,0 +1,40 @@ +/* + * 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 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 + +internal class ChannelListBannerTest : PaparazziComposeTest { + + @get:Rule + override val paparazzi = Paparazzi(deviceConfig = DeviceConfig.PIXEL_2) + + @Test + fun `error state`() { + snapshotWithDarkMode { + 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 2f344ad850f..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 @@ -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 @@ -50,6 +53,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 +340,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 { @@ -862,6 +971,174 @@ 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) + } + } + + @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(), 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 00000000000..a6e1cbb9236 Binary files /dev/null and b/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.channels.list_ChannelListBannerTest_error_state.png differ