diff --git a/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/ui/channel/AddMembersDialog.kt b/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/ui/channel/AddMembersDialog.kt deleted file mode 100644 index 6a99b610dd6..00000000000 --- a/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/ui/channel/AddMembersDialog.kt +++ /dev/null @@ -1,283 +0,0 @@ -/* - * 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.sample.ui.channel - -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.expandIn -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.animation.shrinkOut -import androidx.compose.foundation.Image -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.aspectRatio -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.lazy.grid.GridCells -import androidx.compose.foundation.lazy.grid.LazyVerticalGrid -import androidx.compose.foundation.lazy.grid.items -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.material3.AlertDialog -import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.Icon -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.lifecycle.viewmodel.compose.viewModel -import io.getstream.chat.android.compose.sample.R -import io.getstream.chat.android.compose.ui.components.LoadingIndicator -import io.getstream.chat.android.compose.ui.components.SearchInput -import io.getstream.chat.android.compose.ui.components.avatar.UserAvatar -import io.getstream.chat.android.compose.ui.theme.ChatTheme -import io.getstream.chat.android.compose.ui.util.adaptivelayout.AdaptiveLayoutInfo -import io.getstream.chat.android.models.User -import kotlinx.coroutines.flow.collectLatest -import java.util.UUID - -@Composable -fun AddMembersDialog( - cid: String, - onDismiss: () -> Unit, -) { - val viewModelKey by remember { mutableStateOf(UUID.randomUUID().toString()) } - - @Suppress("MagicNumber") - val resultLimit = if (AdaptiveLayoutInfo.singlePaneWindow()) 6 else 10 - val viewModel = viewModel( - AddMembersViewModel::class, - key = viewModelKey, - factory = AddMembersViewModelFactory(cid, resultLimit), - ) - val state by viewModel.state.collectAsStateWithLifecycle() - AlertDialog( - onDismissRequest = onDismiss, - text = { - AddMembersContent( - state = state, - onViewAction = viewModel::onViewAction, - ) - }, - confirmButton = { - TextButton( - colors = ButtonDefaults.textButtonColors(contentColor = ChatTheme.colors.accentPrimary), - onClick = { viewModel.onViewAction(AddMembersViewAction.ConfirmClick) }, - ) { - Text(text = stringResource(id = io.getstream.chat.android.compose.R.string.stream_compose_ok)) - } - }, - dismissButton = { - TextButton( - colors = ButtonDefaults.textButtonColors(contentColor = ChatTheme.colors.textSecondary), - onClick = onDismiss, - ) { - Text(text = stringResource(id = io.getstream.chat.android.compose.R.string.stream_compose_cancel)) - } - }, - containerColor = ChatTheme.colors.backgroundElevationElevation1, - ) - LaunchedEffect(viewModel) { - viewModel.events.collectLatest { event -> - when (event) { - AddMembersViewEvent.MembersAdded -> onDismiss() - } - } - } -} - -@Composable -private fun AddMembersContent( - state: AddMembersViewState, - onViewAction: (AddMembersViewAction) -> Unit = {}, -) { - val focusRequester = remember { FocusRequester() } - LaunchedEffect(Unit) { - focusRequester.requestFocus() - } - - Column( - horizontalAlignment = Alignment.CenterHorizontally, - ) { - SearchInput( - modifier = Modifier - .focusRequester(focusRequester) - .fillMaxWidth(), - query = state.query, - onValueChange = { onViewAction(AddMembersViewAction.QueryChanged(it)) }, - ) - Box( - contentAlignment = Alignment.Center, - ) { - LazyVerticalGrid( - columns = GridCells.Adaptive(64.dp), - contentPadding = PaddingValues(16.dp), - verticalArrangement = Arrangement.spacedBy(8.dp), - horizontalArrangement = Arrangement.spacedBy(24.dp), - ) { - items( - items = state.searchResult, - key = User::id, - ) { user -> - SearchResultItem( - user = user, - isSelected = state.selectedUsers.contains(user), - onViewAction = onViewAction, - ) - } - } - if (state.isLoading) { - LoadingIndicator( - modifier = Modifier - .size(172.dp), - ) - } - } - if (!state.isLoading && state.searchResult.isEmpty()) { - SearchResultEmpty( - modifier = Modifier, - ) - } - } -} - -@Composable -private fun SearchResultItem( - user: User, - isSelected: Boolean, - onViewAction: (AddMembersViewAction) -> Unit, -) { - Box { - Column( - verticalArrangement = Arrangement.spacedBy(4.dp), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - UserAvatar( - modifier = Modifier - .aspectRatio(1f) - .clickable { onViewAction(AddMembersViewAction.UserClick(user)) }, - user = user, - ) - Text( - text = user.name, - style = ChatTheme.typography.bodyEmphasis, - maxLines = 2, - textAlign = TextAlign.Center, - ) - } - AnimatedVisibility( - visible = isSelected, - enter = fadeIn() + expandIn(initialSize = { fullSize -> fullSize }), - exit = shrinkOut(targetSize = { fullSize -> fullSize }) + fadeOut(), - ) { - Icon( - modifier = Modifier.background(ChatTheme.colors.backgroundElevationElevation1, CircleShape), - tint = ChatTheme.colors.accentPrimary, - painter = painterResource(id = R.drawable.ic_check_filled), - contentDescription = null, - ) - } - } -} - -@Composable -private fun SearchResultEmpty( - modifier: Modifier, -) { - Column( - modifier = modifier, - verticalArrangement = Arrangement.Center, - horizontalAlignment = Alignment.CenterHorizontally, - ) { - Image( - painter = painterResource(id = R.drawable.empty_user_search), - contentDescription = null, - ) - Text( - text = stringResource(id = R.string.add_channel_no_matches), - color = ChatTheme.colors.textSecondary, - textAlign = TextAlign.Center, - ) - } -} - -@Preview(showBackground = true) -@Composable -private fun AddMembersLoadingPreview() { - ChatTheme { - AddMembersContent( - state = AddMembersViewState( - isLoading = true, - ), - ) - } -} - -@Preview(showBackground = true) -@Composable -private fun AddMembersContentPreview() { - ChatTheme { - AddMembersContent( - state = AddMembersViewState( - isLoading = false, - query = "query", - searchResult = listOf(user1, user2), - selectedUsers = listOf(user2), - ), - ) - } -} - -@Preview(showBackground = true) -@Composable -private fun AddMembersEmptyPreview() { - ChatTheme { - AddMembersContent( - state = AddMembersViewState( - isLoading = false, - ), - ) - } -} - -private val user1: User = User( - id = "jc", - name = "Jc MiƱarro", - image = "https://ca.slack-edge.com/T02RM6X6B-U011KEXDPB2-891dbb8df64f-128", - online = true, -) -private val user2: User = User( - id = "leia_organa", - name = "Leia Organa", - image = "https://vignette.wikia.nocookie.net/starwars/images/f/fc/Leia_Organa_TLJ.png", -) diff --git a/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/ui/channel/AddMembersViewModel.kt b/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/ui/channel/AddMembersViewModel.kt deleted file mode 100644 index 2c3a15ab325..00000000000 --- a/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/ui/channel/AddMembersViewModel.kt +++ /dev/null @@ -1,185 +0,0 @@ -/* - * 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.sample.ui.channel - -import androidx.lifecycle.ViewModel -import androidx.lifecycle.ViewModelProvider -import androidx.lifecycle.viewModelScope -import io.getstream.chat.android.client.ChatClient -import io.getstream.chat.android.client.api.models.QueryUsersRequest -import io.getstream.chat.android.client.api.state.watchChannelAsState -import io.getstream.chat.android.client.query.AddMembersParams -import io.getstream.chat.android.models.Filters -import io.getstream.chat.android.models.Member -import io.getstream.chat.android.models.MemberData -import io.getstream.chat.android.models.User -import io.getstream.chat.android.models.querysort.QuerySortByField -import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.SharedFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asSharedFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.debounce -import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.filterNotNull -import kotlinx.coroutines.flow.firstOrNull -import kotlinx.coroutines.flow.flatMapLatest -import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.onEach -import kotlinx.coroutines.flow.update -import kotlinx.coroutines.launch - -class AddMembersViewModel( - private val cid: String, - private val resultLimit: Int, - private val chatClient: ChatClient = ChatClient.instance(), -) : ViewModel() { - - private val channelMembers = chatClient - .watchChannelAsState(cid, messageLimit = 0, viewModelScope) - .filterNotNull() - .flatMapLatest { it.members } - - private val _state = MutableStateFlow(AddMembersViewState(currentUser = chatClient.getCurrentUser())) - val state: StateFlow = _state.asStateFlow() - - private val _events = MutableSharedFlow(extraBufferCapacity = 1) - val events: SharedFlow = _events.asSharedFlow() - - init { - _state - .map { it.query } - .debounce(TypingDebounceTimeoutInMillis) - .distinctUntilChanged() - .onEach { query -> - _state.update { currentState -> - currentState.copy( - isLoading = true, - ) - } - val channelMemberIds = (channelMembers.firstOrNull() ?: emptyList()).map(Member::getUserId) - chatClient.queryUsers(query.toRequest()) - .await() - .onSuccess { users -> - val searchResult = users.filterNot { it.id in channelMemberIds } - _state.update { currentState -> - currentState.copy( - isLoading = false, - searchResult = searchResult, - ) - } - } - } - .launchIn(viewModelScope) - } - - fun onViewAction(viewAction: AddMembersViewAction) { - when (viewAction) { - is AddMembersViewAction.QueryChanged -> { - _state.update { currentState -> - currentState.copy( - query = viewAction.query.trim(), - ) - } - } - - is AddMembersViewAction.UserClick -> { - _state.update { currentState -> - val user = viewAction.user - val isSelected = currentState.selectedUsers.contains(user) - val newSelectedUsers = if (isSelected) { - currentState.selectedUsers - user - } else { - currentState.selectedUsers + user - } - currentState.copy( - selectedUsers = newSelectedUsers, - ) - } - } - - AddMembersViewAction.ConfirmClick -> { - _state.update { currentState -> - currentState.copy(isLoading = true) - } - viewModelScope.launch { - val params = AddMembersParams( - members = _state.value.selectedUsers.map { user -> MemberData(user.id) }, - systemMessage = null, - ) - chatClient - .channel(cid) - .addMembers(params) - .await() - .onSuccess { _events.tryEmit(AddMembersViewEvent.MembersAdded) } - } - } - } - } - - private fun String.toRequest(): QueryUsersRequest { - val filter = if (isEmpty()) { - Filters.neutral() - } else { - Filters.autocomplete("name", this) - } - return QueryUsersRequest( - filter = filter, - offset = 0, - limit = resultLimit, - querySort = QuerySortByField.ascByName("name"), - presence = true, - ) - } -} - -private const val TypingDebounceTimeoutInMillis = 300L - -data class AddMembersViewState( - val currentUser: User? = null, - val isLoading: Boolean = true, - val query: String = "", - val searchResult: List = emptyList(), - val selectedUsers: List = emptyList(), -) - -sealed class AddMembersViewAction { - data class QueryChanged(val query: String) : AddMembersViewAction() - data class UserClick(val user: User) : AddMembersViewAction() - data object ConfirmClick : AddMembersViewAction() -} - -sealed class AddMembersViewEvent { - data object MembersAdded : AddMembersViewEvent() -} - -class AddMembersViewModelFactory( - private val cid: String, - private val resultLimit: Int = 6, - private val chatClient: ChatClient = ChatClient.instance(), -) : ViewModelProvider.Factory { - - override fun create(modelClass: Class): T { - require(modelClass == AddMembersViewModel::class.java) { - "AddMembersViewModelFactory can only create instances of AddMembersViewModel" - } - @Suppress("UNCHECKED_CAST") - return AddMembersViewModel(cid, resultLimit, chatClient) as T - } -} diff --git a/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/ui/channel/GroupChannelInfoActivity.kt b/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/ui/channel/GroupChannelInfoActivity.kt index a9b47a87283..6fe0121c1af 100644 --- a/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/ui/channel/GroupChannelInfoActivity.kt +++ b/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/ui/channel/GroupChannelInfoActivity.kt @@ -25,10 +25,6 @@ import androidx.activity.compose.setContent import androidx.activity.viewModels import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import io.getstream.chat.android.compose.sample.R import io.getstream.chat.android.compose.sample.feature.channel.draft.DraftChannelActivity @@ -75,20 +71,12 @@ class GroupChannelInfoActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContent { - var showAddMembers by remember { mutableStateOf(false) } ChatTheme { GroupChannelInfoScreen( modifier = Modifier.statusBarsPadding(), viewModelFactory = viewModelFactory, onNavigationIconClick = ::finish, - onAddMembersClick = { showAddMembers = true }, ) - if (showAddMembers) { - AddMembersDialog( - cid = channelId, - onDismiss = { showAddMembers = false }, - ) - } } LaunchedEffect(viewModel) { viewModel.events.collectLatest { event -> diff --git a/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/ui/chats/ChatsActivity.kt b/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/ui/chats/ChatsActivity.kt index b2fcd37e2f1..f00cd3e0002 100644 --- a/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/ui/chats/ChatsActivity.kt +++ b/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/ui/chats/ChatsActivity.kt @@ -55,7 +55,6 @@ import io.getstream.chat.android.compose.sample.feature.channel.ChannelConstants import io.getstream.chat.android.compose.sample.feature.channel.add.AddChannelActivity import io.getstream.chat.android.compose.sample.feature.channel.isGroupChannel import io.getstream.chat.android.compose.sample.feature.channel.list.CustomChatEventHandlerFactory -import io.getstream.chat.android.compose.sample.ui.channel.AddMembersDialog import io.getstream.chat.android.compose.sample.ui.component.AppBottomBar import io.getstream.chat.android.compose.sample.ui.component.AppBottomBarOption import io.getstream.chat.android.compose.sample.ui.component.CustomChatComponentFactory @@ -397,13 +396,10 @@ class ChatsActivity : ComponentActivity() { onNavigateToChannel = onNavigateToChannel, ) - var showAddMembers by remember { mutableStateOf(false) } - if (AdaptiveLayoutInfo.singlePaneWindow()) { GroupChannelInfoScreen( viewModelFactory = viewModelFactory, onNavigationIconClick = onNavigationIconClick, - onAddMembersClick = { showAddMembers = true }, ) } else { CompoundComponentFactory( @@ -425,17 +421,9 @@ class ChatsActivity : ComponentActivity() { GroupChannelInfoScreen( viewModelFactory = viewModelFactory, onNavigationIconClick = onNavigationIconClick, - onAddMembersClick = { showAddMembers = true }, ) } } - - if (showAddMembers) { - AddMembersDialog( - cid = channelId, - onDismiss = { showAddMembers = false }, - ) - } } @Composable 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 51b279c9a77..3ffaabbdf5b 100644 --- a/stream-chat-android-compose/api/stream-chat-android-compose.api +++ b/stream-chat-android-compose/api/stream-chat-android-compose.api @@ -833,6 +833,31 @@ public final class io/getstream/chat/android/compose/ui/channel/info/ChannelInfo public static final fun buildDefaultMemberActions (Lio/getstream/chat/android/models/Member;Ljava/util/Set;ZZLkotlin/jvm/functions/Function1;Landroidx/compose/runtime/Composer;I)Ljava/util/List; } +public final class io/getstream/chat/android/compose/ui/channel/info/ComposableSingletons$AddMembersBottomSheetKt { + public static final field INSTANCE Lio/getstream/chat/android/compose/ui/channel/info/ComposableSingletons$AddMembersBottomSheetKt; + public static field lambda-1 Lkotlin/jvm/functions/Function2; + public static field lambda-10 Lkotlin/jvm/functions/Function2; + public static field lambda-2 Lkotlin/jvm/functions/Function2; + public static field lambda-3 Lkotlin/jvm/functions/Function3; + public static field lambda-4 Lkotlin/jvm/functions/Function2; + public static field lambda-5 Lkotlin/jvm/functions/Function2; + public static field lambda-6 Lkotlin/jvm/functions/Function2; + public static field lambda-7 Lkotlin/jvm/functions/Function2; + public static field lambda-8 Lkotlin/jvm/functions/Function2; + public static field lambda-9 Lkotlin/jvm/functions/Function2; + public fun ()V + public final fun getLambda-1$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; + public final fun getLambda-10$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; + public final fun getLambda-2$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; + public final fun getLambda-3$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function3; + public final fun getLambda-4$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; + public final fun getLambda-5$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; + public final fun getLambda-6$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; + public final fun getLambda-7$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; + public final fun getLambda-8$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; + public final fun getLambda-9$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; +} + public final class io/getstream/chat/android/compose/ui/channel/info/ComposableSingletons$ChannelInfoMemberInfoModalSheetKt { public static final field INSTANCE Lio/getstream/chat/android/compose/ui/channel/info/ComposableSingletons$ChannelInfoMemberInfoModalSheetKt; public static field lambda-1 Lkotlin/jvm/functions/Function2; @@ -2493,6 +2518,22 @@ public final class io/getstream/chat/android/compose/ui/pinned/PinnedMessageList public static final fun PinnedMessageList (Lio/getstream/chat/android/compose/viewmodel/pinned/PinnedMessageListViewModel;Landroidx/compose/ui/Modifier;Lio/getstream/chat/android/models/User;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Landroidx/compose/runtime/Composer;II)V } +public final class io/getstream/chat/android/compose/ui/theme/AddMembersBottomSheetParams { + public static final field $stable I + public fun (Lio/getstream/chat/android/compose/viewmodel/channel/AddMembersViewModel;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function1;)V + public final fun component1 ()Lio/getstream/chat/android/compose/viewmodel/channel/AddMembersViewModel; + public final fun component2 ()Lkotlin/jvm/functions/Function0; + public final fun component3 ()Lkotlin/jvm/functions/Function1; + public final fun copy (Lio/getstream/chat/android/compose/viewmodel/channel/AddMembersViewModel;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function1;)Lio/getstream/chat/android/compose/ui/theme/AddMembersBottomSheetParams; + public static synthetic fun copy$default (Lio/getstream/chat/android/compose/ui/theme/AddMembersBottomSheetParams;Lio/getstream/chat/android/compose/viewmodel/channel/AddMembersViewModel;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)Lio/getstream/chat/android/compose/ui/theme/AddMembersBottomSheetParams; + public fun equals (Ljava/lang/Object;)Z + public final fun getOnConfirm ()Lkotlin/jvm/functions/Function1; + public final fun getOnDismiss ()Lkotlin/jvm/functions/Function0; + public final fun getViewModel ()Lio/getstream/chat/android/compose/viewmodel/channel/AddMembersViewModel; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + public final class io/getstream/chat/android/compose/ui/theme/AttachmentCameraPickerParams { public static final field $stable I public fun (Lio/getstream/chat/android/compose/state/messages/attachments/CameraPickerMode;Lkotlin/jvm/functions/Function1;)V @@ -3493,6 +3534,7 @@ public final class io/getstream/chat/android/compose/ui/theme/ChannelSwipeAction } public abstract interface class io/getstream/chat/android/compose/ui/theme/ChatComponentFactory { + public abstract fun AddMembersBottomSheet (Lio/getstream/chat/android/compose/ui/theme/AddMembersBottomSheetParams;Landroidx/compose/runtime/Composer;I)V public abstract fun AttachmentCameraPicker (Lio/getstream/chat/android/compose/ui/theme/AttachmentCameraPickerParams;Landroidx/compose/runtime/Composer;I)V public abstract fun AttachmentCommandPicker (Lio/getstream/chat/android/compose/ui/theme/AttachmentCommandPickerParams;Landroidx/compose/runtime/Composer;I)V public abstract fun AttachmentFilePicker (Lio/getstream/chat/android/compose/ui/theme/AttachmentFilePickerParams;Landroidx/compose/runtime/Composer;I)V @@ -3680,6 +3722,7 @@ public abstract interface class io/getstream/chat/android/compose/ui/theme/ChatC } public final class io/getstream/chat/android/compose/ui/theme/ChatComponentFactory$DefaultImpls { + public static fun AddMembersBottomSheet (Lio/getstream/chat/android/compose/ui/theme/ChatComponentFactory;Lio/getstream/chat/android/compose/ui/theme/AddMembersBottomSheetParams;Landroidx/compose/runtime/Composer;I)V public static fun AttachmentCameraPicker (Lio/getstream/chat/android/compose/ui/theme/ChatComponentFactory;Lio/getstream/chat/android/compose/ui/theme/AttachmentCameraPickerParams;Landroidx/compose/runtime/Composer;I)V public static fun AttachmentCommandPicker (Lio/getstream/chat/android/compose/ui/theme/ChatComponentFactory;Lio/getstream/chat/android/compose/ui/theme/AttachmentCommandPickerParams;Landroidx/compose/runtime/Composer;I)V public static fun AttachmentFilePicker (Lio/getstream/chat/android/compose/ui/theme/ChatComponentFactory;Lio/getstream/chat/android/compose/ui/theme/AttachmentFilePickerParams;Landroidx/compose/runtime/Composer;I)V @@ -6881,6 +6924,14 @@ public final class io/getstream/chat/android/compose/util/KeyValuePair { public fun toString ()Ljava/lang/String; } +public final class io/getstream/chat/android/compose/viewmodel/channel/AddMembersViewModel : androidx/lifecycle/ViewModel { + public static final field $stable I + public fun (Ljava/lang/String;Lkotlin/jvm/functions/Function1;)V + public synthetic fun (Ljava/lang/String;Lkotlin/jvm/functions/Function1;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun getState ()Lkotlinx/coroutines/flow/StateFlow; + public final fun onViewAction (Lio/getstream/chat/android/ui/common/feature/channel/info/AddMembersViewAction;)V +} + public final class io/getstream/chat/android/compose/viewmodel/channel/ChannelAttachmentsViewModel : androidx/lifecycle/ViewModel { public static final field $stable I public fun (Ljava/lang/String;Ljava/util/List;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;)V @@ -6929,6 +6980,7 @@ public final class io/getstream/chat/android/compose/viewmodel/channel/ChannelIn public static final field $stable I public fun (Ljava/lang/String;Lio/getstream/chat/android/ui/common/helper/CopyToClipboardHandler;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;)V public synthetic fun (Ljava/lang/String;Lio/getstream/chat/android/ui/common/helper/CopyToClipboardHandler;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun addMembers (Ljava/util/Set;)V public final fun getEvents ()Lkotlinx/coroutines/flow/SharedFlow; public final fun getState ()Lkotlinx/coroutines/flow/StateFlow; public final fun onMemberViewEvent (Lio/getstream/chat/android/ui/common/feature/channel/info/ChannelInfoMemberViewEvent;)V diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/channel/info/AddMembersBottomSheet.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/channel/info/AddMembersBottomSheet.kt new file mode 100644 index 00000000000..62af5ced790 --- /dev/null +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/channel/info/AddMembersBottomSheet.kt @@ -0,0 +1,500 @@ +/* + * 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. + */ + +@file:Suppress("TooManyFunctions") + +package io.getstream.chat.android.compose.ui.channel.info + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +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.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import io.getstream.chat.android.compose.R +import io.getstream.chat.android.compose.handlers.LoadMoreHandler +import io.getstream.chat.android.compose.ui.components.LoadingIndicator +import io.getstream.chat.android.compose.ui.components.SearchInput +import io.getstream.chat.android.compose.ui.components.avatar.AvatarSize +import io.getstream.chat.android.compose.ui.components.avatar.UserAvatar +import io.getstream.chat.android.compose.ui.components.button.StreamButton +import io.getstream.chat.android.compose.ui.components.button.StreamButtonStyleDefaults +import io.getstream.chat.android.compose.ui.components.common.RadioCheck +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 +import io.getstream.chat.android.compose.viewmodel.channel.AddMembersViewModel +import io.getstream.chat.android.models.User +import io.getstream.chat.android.previewdata.PreviewUserData +import io.getstream.chat.android.ui.common.feature.channel.info.AddMembersViewAction +import io.getstream.chat.android.ui.common.state.channel.info.AddMembersViewState + +/** + * A bottom sheet that allows users to search for and add members to a channel. + * + * @param viewModel The [AddMembersViewModel] managing the state and actions. + * @param onDismiss Callback invoked when the bottom sheet is dismissed without adding members. + * @param onConfirm Callback invoked with the selected [User] list when the user confirms. + * The caller is responsible for performing the actual API call and dismissing the sheet. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun AddMembersBottomSheet( + viewModel: AddMembersViewModel, + onDismiss: () -> Unit, + onConfirm: (List) -> Unit, +) { + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + val state by viewModel.state.collectAsStateWithLifecycle() + + ModalBottomSheet( + modifier = Modifier.statusBarsPadding(), + sheetState = sheetState, + containerColor = ChatTheme.colors.backgroundElevationElevation1, + onDismissRequest = onDismiss, + ) { + AddMembersBottomSheet( + state = state, + onQueryChange = { viewModel.onViewAction(AddMembersViewAction.QueryChanged(it)) }, + onUserClick = { viewModel.onViewAction(AddMembersViewAction.UserClick(it)) }, + onLoadMore = { viewModel.onViewAction(AddMembersViewAction.LoadMore) }, + onDismiss = onDismiss, + onConfirm = { onConfirm(state.selectedUsers) }, + ) + } +} + +@Suppress("LongParameterList") +@Composable +internal fun AddMembersBottomSheet( + state: AddMembersViewState, + onQueryChange: (String) -> Unit, + onUserClick: (User) -> Unit, + onLoadMore: () -> Unit, + onDismiss: () -> Unit, + onConfirm: () -> Unit, +) { + Column(modifier = Modifier.fillMaxSize()) { + AddMembersHeader( + hasSelection = state.selectedUserIds.isNotEmpty(), + onDismiss = onDismiss, + onConfirm = onConfirm, + ) + SearchInput( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = StreamTokens.spacingMd), + query = state.query, + onValueChange = onQueryChange, + ) + when { + state.isLoading -> { + Box( + modifier = Modifier + .fillMaxWidth() + .weight(1f), + contentAlignment = Alignment.Center, + ) { + LoadingIndicator(modifier = Modifier.size(172.dp)) + } + } + + state.searchResult.isEmpty() -> { + Box( + modifier = Modifier + .fillMaxWidth() + .weight(1f), + contentAlignment = Alignment.Center, + ) { + AddMembersEmptyState() + } + } + + else -> { + AddMembersList( + users = state.searchResult, + isSelected = state::isSelected, + isAlreadyMember = state::isAlreadyMember, + isLoadingMore = state.isLoadingMore, + onUserClick = onUserClick, + onLoadMore = onLoadMore, + ) + } + } + } +} + +@Composable +private fun AddMembersHeader( + hasSelection: Boolean, + onDismiss: () -> Unit, + onConfirm: () -> Unit, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(StreamTokens.spacingSm), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(StreamTokens.spacingSm), + ) { + StreamButton( + onClick = onDismiss, + style = StreamButtonStyleDefaults.secondaryOutline, + ) { + Icon( + modifier = Modifier.size(20.dp), + painter = painterResource(id = R.drawable.stream_compose_ic_close), + contentDescription = stringResource(id = R.string.stream_compose_cancel), + ) + } + Text( + modifier = Modifier.weight(1f), + text = stringResource(id = R.string.stream_compose_add_members_title), + style = ChatTheme.typography.headingSmall, + color = ChatTheme.colors.textPrimary, + textAlign = TextAlign.Center, + maxLines = 1, + ) + StreamButton( + onClick = onConfirm, + style = StreamButtonStyleDefaults.primarySolid, + enabled = hasSelection, + ) { + Icon( + modifier = Modifier.size(20.dp), + painter = painterResource(id = R.drawable.stream_compose_ic_checkmark), + contentDescription = stringResource(id = R.string.stream_compose_add_members_title), + ) + } + } +} + +@Suppress("LongParameterList") +@Composable +private fun AddMembersList( + users: List, + isSelected: (User) -> Boolean, + isAlreadyMember: (User) -> Boolean, + isLoadingMore: Boolean, + onUserClick: (User) -> Unit, + onLoadMore: () -> Unit, +) { + val listState = rememberLazyListState() + + LoadMoreHandler( + lazyListState = listState, + loadMore = onLoadMore, + ) + + LazyColumn( + state = listState, + modifier = Modifier.fillMaxWidth(), + contentPadding = PaddingValues( + top = StreamTokens.spacingSm, + bottom = StreamTokens.spacing3xl, + ), + ) { + itemsIndexed( + items = users, + key = { _, user -> user.id }, + ) { _, user -> + AddMembersUserItem( + user = user, + isSelected = isSelected(user), + isAlreadyMember = isAlreadyMember(user), + onClick = { if (!isAlreadyMember(user)) onUserClick(user) }, + ) + } + if (isLoadingMore) { + item { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = StreamTokens.spacingMd), + contentAlignment = Alignment.Center, + ) { + LoadingIndicator(modifier = Modifier.size(36.dp)) + } + } + } + } +} + +@Composable +private fun AddMembersUserItem( + user: User, + isSelected: Boolean, + isAlreadyMember: Boolean, + onClick: () -> Unit, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick, enabled = !isAlreadyMember) + .padding(horizontal = StreamTokens.spacing2xs) + .padding(horizontal = StreamTokens.spacingSm, vertical = StreamTokens.spacingXs), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(StreamTokens.spacingSm), + ) { + UserAvatar( + modifier = Modifier.size(AvatarSize.Medium), + user = user, + showIndicator = false, + showBorder = false, + ) + Column(modifier = Modifier.weight(1f)) { + Text( + text = user.name.takeIf(String::isNotBlank) ?: user.id, + style = ChatTheme.typography.bodyDefault, + color = if (isAlreadyMember) ChatTheme.colors.textSecondary else ChatTheme.colors.textPrimary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + if (isAlreadyMember) { + Text( + text = stringResource(id = R.string.stream_compose_add_members_already_member), + style = ChatTheme.typography.metadataDefault, + color = ChatTheme.colors.textTertiary, + maxLines = 1, + ) + } + } + if (!isAlreadyMember) { + RadioCheck( + checked = isSelected, + onCheckedChange = null, + ) + } + } +} + +@Composable +private fun AddMembersEmptyState() { + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(StreamTokens.spacingXs), + ) { + Icon( + modifier = Modifier.size(48.dp), + painter = painterResource(id = R.drawable.stream_compose_ic_search), + tint = ChatTheme.colors.textTertiary, + contentDescription = null, + ) + Text( + text = stringResource(id = R.string.stream_compose_add_members_no_results), + style = ChatTheme.typography.bodyDefault, + color = ChatTheme.colors.textSecondary, + textAlign = TextAlign.Center, + ) + } +} + +// ---- Previews ---- + +@Preview(showBackground = true) +@Composable +private fun AddMembersBottomSheetLoadingPreview() { + ChatTheme { + AddMembersBottomSheetLoading() + } +} + +@Composable +internal fun AddMembersBottomSheetLoading() { + AddMembersBottomSheet( + state = AddMembersViewState(isLoading = true), + onQueryChange = {}, + onUserClick = {}, + onLoadMore = {}, + onDismiss = {}, + onConfirm = {}, + ) +} + +@Preview(showBackground = true) +@Composable +private fun AddMembersBottomSheetEmptyPreview() { + ChatTheme { + AddMembersBottomSheetEmpty() + } +} + +@Composable +internal fun AddMembersBottomSheetEmpty() { + AddMembersBottomSheet( + state = AddMembersViewState( + isLoading = false, + query = "Darth Vader", + searchResult = emptyList(), + ), + onQueryChange = {}, + onUserClick = {}, + onLoadMore = {}, + onDismiss = {}, + onConfirm = {}, + ) +} + +@Preview(showBackground = true) +@Composable +private fun AddMembersBottomSheetResultsWithQueryPreview() { + ChatTheme { + AddMembersBottomSheetResultsWithQuery() + } +} + +@Composable +internal fun AddMembersBottomSheetResultsWithQuery() { + AddMembersBottomSheet( + state = AddMembersViewState( + isLoading = false, + query = "Han", + searchResult = previewUsers, + ), + onQueryChange = {}, + onUserClick = {}, + onLoadMore = {}, + onDismiss = {}, + onConfirm = {}, + ) +} + +@Preview(showBackground = true) +@Composable +private fun AddMembersBottomSheetResultsPreview() { + ChatTheme { + AddMembersBottomSheetResults() + } +} + +@Composable +internal fun AddMembersBottomSheetResults() { + AddMembersBottomSheet( + state = AddMembersViewState( + isLoading = false, + searchResult = previewUsers, + ), + onQueryChange = {}, + onUserClick = {}, + onLoadMore = {}, + onDismiss = {}, + onConfirm = {}, + ) +} + +@Preview(showBackground = true) +@Composable +private fun AddMembersBottomSheetResultsWithSelectionPreview() { + ChatTheme { + AddMembersBottomSheetResultsWithSelection() + } +} + +@Composable +internal fun AddMembersBottomSheetResultsWithSelection() { + AddMembersBottomSheet( + state = AddMembersViewState( + isLoading = false, + searchResult = previewUsers, + selectedUserIds = setOf(previewUsers[0].id, previewUsers[2].id), + ), + onQueryChange = {}, + onUserClick = {}, + onLoadMore = {}, + onDismiss = {}, + onConfirm = {}, + ) +} + +@Preview(showBackground = true) +@Composable +private fun AddMembersBottomSheetResultsWithMemberPreview() { + ChatTheme { + AddMembersBottomSheetResultsWithMember() + } +} + +@Composable +internal fun AddMembersBottomSheetResultsWithMember() { + AddMembersBottomSheet( + state = AddMembersViewState( + isLoading = false, + searchResult = previewUsers, + loadedMemberIds = setOf(previewUsers[0].id, previewUsers[1].id), + ), + onQueryChange = {}, + onUserClick = {}, + onLoadMore = {}, + onDismiss = {}, + onConfirm = {}, + ) +} + +@Preview(showBackground = true) +@Composable +private fun AddMembersBottomSheetLoadingMorePreview() { + ChatTheme { + AddMembersBottomSheetLoadingMore() + } +} + +@Composable +internal fun AddMembersBottomSheetLoadingMore() { + AddMembersBottomSheet( + state = AddMembersViewState( + isLoading = false, + searchResult = previewUsers, + isLoadingMore = true, + ), + onQueryChange = {}, + onUserClick = {}, + onLoadMore = {}, + onDismiss = {}, + onConfirm = {}, + ) +} + +private val previewUsers = listOf( + PreviewUserData.user2, + PreviewUserData.user3, + PreviewUserData.user4, + PreviewUserData.user5, + PreviewUserData.user6, +) diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/channel/info/GroupChannelInfoScreen.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/channel/info/GroupChannelInfoScreen.kt index 71a1ac8d18b..245c0b0c632 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/channel/info/GroupChannelInfoScreen.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/channel/info/GroupChannelInfoScreen.kt @@ -53,6 +53,7 @@ import io.getstream.chat.android.client.ChatClient import io.getstream.chat.android.compose.R import io.getstream.chat.android.compose.ui.components.ContentBox import io.getstream.chat.android.compose.ui.components.avatar.AvatarSize +import io.getstream.chat.android.compose.ui.theme.AddMembersBottomSheetParams import io.getstream.chat.android.compose.ui.theme.ChannelAvatarParams import io.getstream.chat.android.compose.ui.theme.ChannelInfoScreenModalParams import io.getstream.chat.android.compose.ui.theme.ChatTheme @@ -64,7 +65,9 @@ import io.getstream.chat.android.compose.ui.theme.GroupChannelInfoMemberSectionP import io.getstream.chat.android.compose.ui.theme.GroupChannelInfoTopBarParams import io.getstream.chat.android.compose.ui.theme.StreamTokens import io.getstream.chat.android.compose.ui.theme.UserAvatarParams +import io.getstream.chat.android.compose.ui.util.ViewModelStore import io.getstream.chat.android.compose.ui.util.getLastSeenText +import io.getstream.chat.android.compose.viewmodel.channel.AddMembersViewModel import io.getstream.chat.android.compose.viewmodel.channel.ChannelHeaderViewModel import io.getstream.chat.android.compose.viewmodel.channel.ChannelInfoViewModel import io.getstream.chat.android.compose.viewmodel.channel.ChannelInfoViewModelFactory @@ -90,7 +93,7 @@ import java.util.Date * @param modifier The [Modifier] to be applied to this screen. * @param currentUser The current logged-in user. Defaults to the current user from the [ChatClient]. * @param onNavigationIconClick Callback invoked when the navigation icon is clicked. - * @param onAddMembersClick Callback invoked when the "Add Members" button is clicked. + * @param onAddMembersClick Deprecated. The screen now manages the "Add Members" bottom sheet internally. */ @Composable public fun GroupChannelInfoScreen( @@ -98,6 +101,7 @@ public fun GroupChannelInfoScreen( modifier: Modifier = Modifier, currentUser: User? = ChatClient.instance().getCurrentUser(), onNavigationIconClick: () -> Unit = {}, + @Suppress("UNUSED_PARAMETER") onAddMembersClick: () -> Unit = {}, ) { val headerViewModel = viewModel(factory = viewModelFactory) @@ -105,17 +109,34 @@ public fun GroupChannelInfoScreen( val headerState by headerViewModel.state.collectAsStateWithLifecycle() val infoState by infoViewModel.state.collectAsStateWithLifecycle() + var showAddMembers by remember { mutableStateOf(false) } + GroupChannelInfoScaffold( modifier = modifier, currentUser = currentUser, headerState = headerState, infoState = infoState, onNavigationIconClick = onNavigationIconClick, - onAddMembersClick = onAddMembersClick, + onAddMembersClick = { showAddMembers = true }, onViewAction = infoViewModel::onViewAction, ) GroupChannelInfoScreenModal(infoViewModel) + if (showAddMembers) { + ViewModelStore { + val addMembersViewModel = viewModel(factory = viewModelFactory) + ChatTheme.componentFactory.AddMembersBottomSheet( + params = AddMembersBottomSheetParams( + viewModel = addMembersViewModel, + onDismiss = { showAddMembers = false }, + onConfirm = { users -> + infoViewModel.addMembers(users.map { it.id }.toSet()) + showAddMembers = false + }, + ), + ) + } + } } @Composable 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 b4c41422dfc..8f7c85688c1 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 @@ -2468,6 +2468,22 @@ public interface ChatComponentFactory { * * @param params Parameters for this component. */ + /** + * Factory method for creating the "Add Members" bottom sheet. + * + * Override this method to provide a custom implementation of the "Add Members" bottom sheet. + * + * @param params Parameters for this component. + */ + @Composable + public fun AddMembersBottomSheet(params: AddMembersBottomSheetParams) { + io.getstream.chat.android.compose.ui.channel.info.AddMembersBottomSheet( + viewModel = params.viewModel, + onDismiss = params.onDismiss, + onConfirm = params.onConfirm, + ) + } + @Composable public fun GroupChannelInfoAddMembersButton(params: GroupChannelInfoAddMembersButtonParams) { IconButton(onClick = params.onClick) { 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 d168008ebe3..130c0829979 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 @@ -53,6 +53,7 @@ import io.getstream.chat.android.compose.ui.messages.attachments.AttachmentPicke import io.getstream.chat.android.compose.ui.messages.composer.actions.AudioRecordingActions import io.getstream.chat.android.compose.ui.messages.list.MessagesLazyListState import io.getstream.chat.android.compose.ui.threads.ThreadListBannerState +import io.getstream.chat.android.compose.viewmodel.channel.AddMembersViewModel import io.getstream.chat.android.compose.viewmodel.messages.AttachmentsPickerViewModel import io.getstream.chat.android.compose.viewmodel.messages.MessageComposerViewModel import io.getstream.chat.android.models.Attachment @@ -1849,6 +1850,20 @@ public data class GroupChannelInfoAddMembersButtonParams( val onClick: () -> Unit, ) +/** + * Parameters for [ChatComponentFactory.AddMembersBottomSheet]. + * + * @param viewModel The [AddMembersViewModel] managing the state and actions. + * @param onDismiss Action invoked when the bottom sheet is dismissed without adding members. + * @param onConfirm Action invoked with the selected users when the user confirms. + * The caller is responsible for performing the API call and dismissing the sheet. + */ +public data class AddMembersBottomSheetParams( + val viewModel: AddMembersViewModel, + val onDismiss: () -> Unit, + val onConfirm: (List) -> Unit, +) + /** * Parameters for [ChatComponentFactory.ChannelInfoSeparatorItem]. */ diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/channel/AddMembersViewModel.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/channel/AddMembersViewModel.kt new file mode 100644 index 00000000000..5d8dd599a6e --- /dev/null +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/channel/AddMembersViewModel.kt @@ -0,0 +1,63 @@ +/* + * 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. + */ + +@file:OptIn(InternalStreamChatApi::class) + +package io.getstream.chat.android.compose.viewmodel.channel + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import io.getstream.chat.android.client.ChatClient +import io.getstream.chat.android.client.api.state.watchChannelAsState +import io.getstream.chat.android.core.internal.InternalStreamChatApi +import io.getstream.chat.android.ui.common.feature.channel.info.AddMembersViewAction +import io.getstream.chat.android.ui.common.feature.channel.info.AddMembersViewController +import io.getstream.chat.android.ui.common.state.channel.info.AddMembersViewState +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.filterNotNull + +/** + * ViewModel for managing the "Add Members" view and its actions. + * + * @param cid The full channel identifier (e.g., "messaging:123"). + * @param controllerProvider The provider for [AddMembersViewController]. + */ +public class AddMembersViewModel( + private val cid: String, + private val controllerProvider: ViewModel.() -> AddMembersViewController = { + AddMembersViewController( + scope = viewModelScope, + channelState = ChatClient.instance() + .watchChannelAsState(cid = cid, messageLimit = 0, coroutineScope = viewModelScope) + .filterNotNull(), + ) + }, +) : ViewModel() { + + private val controller: AddMembersViewController by lazy { controllerProvider() } + + /** + * @see [AddMembersViewController.state] + */ + public val state: StateFlow get() = controller.state + + /** + * @see [AddMembersViewController.onViewAction] + */ + public fun onViewAction(action: AddMembersViewAction) { + controller.onViewAction(action) + } +} diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/channel/ChannelInfoViewModel.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/channel/ChannelInfoViewModel.kt index 07ecc103da3..1bdf11e48e4 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/channel/ChannelInfoViewModel.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/channel/ChannelInfoViewModel.kt @@ -78,4 +78,11 @@ public class ChannelInfoViewModel( public fun onMemberViewEvent(event: ChannelInfoMemberViewEvent) { controller.onMemberViewEvent(event) } + + /** + * @see [ChannelInfoViewController.addMembers] + */ + public fun addMembers(userIds: Set) { + controller.addMembers(userIds) + } } diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/channel/ChannelInfoViewModelFactory.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/channel/ChannelInfoViewModelFactory.kt index 9684bb9e213..04e768347a7 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/channel/ChannelInfoViewModelFactory.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/channel/ChannelInfoViewModelFactory.kt @@ -45,6 +45,7 @@ public class ChannelInfoViewModelFactory( optionFilter = optionFilter, ) }, + AddMembersViewModel::class.java to { AddMembersViewModel(cid = cid) }, ) override fun create(modelClass: Class): T { 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 e9ec460ecb2..02e34cef5e1 100644 --- a/stream-chat-android-compose/src/main/res/values/strings.xml +++ b/stream-chat-android-compose/src/main/res/values/strings.xml @@ -372,6 +372,11 @@ Confirm Dismiss + + Add Members + No user found + Already a member + Reply to a message to start a thread " in " diff --git a/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/ui/channel/info/AddMembersBottomSheetTest.kt b/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/ui/channel/info/AddMembersBottomSheetTest.kt new file mode 100644 index 00000000000..5e305f571e5 --- /dev/null +++ b/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/ui/channel/info/AddMembersBottomSheetTest.kt @@ -0,0 +1,99 @@ +/* + * 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.channel.info + +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 AddMembersBottomSheetTest : PaparazziComposeTest { + + @get:Rule + override val paparazzi = Paparazzi(deviceConfig = DeviceConfig.PIXEL_2) + + @Test + fun loading() { + snapshot { AddMembersBottomSheetLoading() } + } + + @Test + fun `loading in dark mode`() { + snapshot(isInDarkMode = true) { AddMembersBottomSheetLoading() } + } + + @Test + fun empty() { + snapshot { AddMembersBottomSheetEmpty() } + } + + @Test + fun `empty in dark mode`() { + snapshot(isInDarkMode = true) { AddMembersBottomSheetEmpty() } + } + + @Test + fun `results with query`() { + snapshot { AddMembersBottomSheetResultsWithQuery() } + } + + @Test + fun `results with query in dark mode`() { + snapshot(isInDarkMode = true) { AddMembersBottomSheetResultsWithQuery() } + } + + @Test + fun results() { + snapshot { AddMembersBottomSheetResults() } + } + + @Test + fun `results in dark mode`() { + snapshot(isInDarkMode = true) { AddMembersBottomSheetResults() } + } + + @Test + fun `results with selection`() { + snapshot { AddMembersBottomSheetResultsWithSelection() } + } + + @Test + fun `results with selection in dark mode`() { + snapshot(isInDarkMode = true) { AddMembersBottomSheetResultsWithSelection() } + } + + @Test + fun `results with existing member`() { + snapshot { AddMembersBottomSheetResultsWithMember() } + } + + @Test + fun `results with existing member in dark mode`() { + snapshot(isInDarkMode = true) { AddMembersBottomSheetResultsWithMember() } + } + + @Test + fun `loading more`() { + snapshot { AddMembersBottomSheetLoadingMore() } + } + + @Test + fun `loading more in dark mode`() { + snapshot(isInDarkMode = true) { AddMembersBottomSheetLoadingMore() } + } +} diff --git a/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/viewmodel/channel/AddMembersViewModelTest.kt b/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/viewmodel/channel/AddMembersViewModelTest.kt new file mode 100644 index 00000000000..dd5a5cc1475 --- /dev/null +++ b/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/viewmodel/channel/AddMembersViewModelTest.kt @@ -0,0 +1,113 @@ +/* + * 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.viewmodel.channel + +import app.cash.turbine.test +import io.getstream.chat.android.randomCID +import io.getstream.chat.android.randomUser +import io.getstream.chat.android.ui.common.feature.channel.info.AddMembersViewAction +import io.getstream.chat.android.ui.common.feature.channel.info.AddMembersViewController +import io.getstream.chat.android.ui.common.state.channel.info.AddMembersViewState +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Test +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever + +internal class AddMembersViewModelTest { + + @Test + fun `state should reflect controller state`() = runTest { + val stateFlow = MutableStateFlow(AddMembersViewState()) + val sut = Fixture() + .givenControllerStateFlow(stateFlow) + .get() + + sut.state.test { + assertEquals(AddMembersViewState(), awaitItem()) + } + } + + @Test + fun `state updates are propagated from controller`() = runTest { + val stateFlow = MutableStateFlow(AddMembersViewState()) + val sut = Fixture() + .givenControllerStateFlow(stateFlow) + .get() + + sut.state.test { + skipItems(1) // Skip initial state + + val updatedState = AddMembersViewState(isLoading = false, query = "Alice") + stateFlow.value = updatedState + + assertEquals(updatedState, awaitItem()) + } + } + + @Test + fun `onViewAction should delegate to controller`() = runTest { + val fixture = Fixture() + val sut = fixture.get() + + val action = AddMembersViewAction.UserClick(user = randomUser()) + sut.onViewAction(action) + + fixture.verifyControllerOnViewAction(action) + } + + @Test + fun `onViewAction QueryChanged should delegate to controller`() = runTest { + val fixture = Fixture() + val sut = fixture.get() + + val action = AddMembersViewAction.QueryChanged(query = "search query") + sut.onViewAction(action) + + fixture.verifyControllerOnViewAction(action) + } + + @Test + fun `onViewAction LoadMore should delegate to controller`() = runTest { + val fixture = Fixture() + val sut = fixture.get() + + sut.onViewAction(AddMembersViewAction.LoadMore) + + fixture.verifyControllerOnViewAction(AddMembersViewAction.LoadMore) + } + + private class Fixture { + private val mockController: AddMembersViewController = mock() + + fun givenControllerStateFlow(stateFlow: MutableStateFlow) = apply { + whenever(mockController.state) doReturn stateFlow + } + + fun verifyControllerOnViewAction(action: AddMembersViewAction) = apply { + verify(mockController).onViewAction(action) + } + + fun get() = AddMembersViewModel( + cid = randomCID(), + controllerProvider = { mockController }, + ) + } +} diff --git a/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/viewmodel/channel/ChannelInfoViewModelFactoryTest.kt b/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/viewmodel/channel/ChannelInfoViewModelFactoryTest.kt index 795b39f0c42..b42758386a7 100644 --- a/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/viewmodel/channel/ChannelInfoViewModelFactoryTest.kt +++ b/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/viewmodel/channel/ChannelInfoViewModelFactoryTest.kt @@ -46,6 +46,15 @@ internal class ChannelInfoViewModelFactoryTest { assertInstanceOf(viewModel) } + @Test + fun `create should return correct AddMembersViewModel instance`() { + val sut = Fixture().get() + + val viewModel = sut.create(AddMembersViewModel::class.java) + + assertInstanceOf(viewModel) + } + @Test fun `create should throw IllegalArgumentException for unsupported ViewModel class`() { val sut = Fixture().get() @@ -56,7 +65,7 @@ internal class ChannelInfoViewModelFactoryTest { assertEquals( "ChannelInfoViewModelFactory can only create instances of " + - "[ChannelHeaderViewModel, ChannelInfoViewModel]", + "[ChannelHeaderViewModel, ChannelInfoViewModel, AddMembersViewModel]", exception.message, ) } diff --git a/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.channel.info_AddMembersBottomSheetTest_empty.png b/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.channel.info_AddMembersBottomSheetTest_empty.png new file mode 100644 index 00000000000..8ab4fe3366a Binary files /dev/null and b/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.channel.info_AddMembersBottomSheetTest_empty.png differ diff --git a/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.channel.info_AddMembersBottomSheetTest_empty_in_dark_mode.png b/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.channel.info_AddMembersBottomSheetTest_empty_in_dark_mode.png new file mode 100644 index 00000000000..ff722b8fefb Binary files /dev/null and b/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.channel.info_AddMembersBottomSheetTest_empty_in_dark_mode.png differ diff --git a/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.channel.info_AddMembersBottomSheetTest_loading.png b/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.channel.info_AddMembersBottomSheetTest_loading.png new file mode 100644 index 00000000000..65444eda026 Binary files /dev/null and b/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.channel.info_AddMembersBottomSheetTest_loading.png differ diff --git a/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.channel.info_AddMembersBottomSheetTest_loading_in_dark_mode.png b/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.channel.info_AddMembersBottomSheetTest_loading_in_dark_mode.png new file mode 100644 index 00000000000..b073543f846 Binary files /dev/null and b/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.channel.info_AddMembersBottomSheetTest_loading_in_dark_mode.png differ diff --git a/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.channel.info_AddMembersBottomSheetTest_loading_more.png b/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.channel.info_AddMembersBottomSheetTest_loading_more.png new file mode 100644 index 00000000000..f3d4838000c Binary files /dev/null and b/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.channel.info_AddMembersBottomSheetTest_loading_more.png differ diff --git a/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.channel.info_AddMembersBottomSheetTest_loading_more_in_dark_mode.png b/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.channel.info_AddMembersBottomSheetTest_loading_more_in_dark_mode.png new file mode 100644 index 00000000000..0f63329306c Binary files /dev/null and b/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.channel.info_AddMembersBottomSheetTest_loading_more_in_dark_mode.png differ diff --git a/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.channel.info_AddMembersBottomSheetTest_results.png b/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.channel.info_AddMembersBottomSheetTest_results.png new file mode 100644 index 00000000000..75158b1876d Binary files /dev/null and b/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.channel.info_AddMembersBottomSheetTest_results.png differ diff --git a/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.channel.info_AddMembersBottomSheetTest_results_in_dark_mode.png b/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.channel.info_AddMembersBottomSheetTest_results_in_dark_mode.png new file mode 100644 index 00000000000..4a4fa8df3e3 Binary files /dev/null and b/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.channel.info_AddMembersBottomSheetTest_results_in_dark_mode.png differ diff --git a/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.channel.info_AddMembersBottomSheetTest_results_with_existing_member.png b/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.channel.info_AddMembersBottomSheetTest_results_with_existing_member.png new file mode 100644 index 00000000000..a10978c1d0c Binary files /dev/null and b/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.channel.info_AddMembersBottomSheetTest_results_with_existing_member.png differ diff --git a/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.channel.info_AddMembersBottomSheetTest_results_with_existing_member_in_dark_mode.png b/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.channel.info_AddMembersBottomSheetTest_results_with_existing_member_in_dark_mode.png new file mode 100644 index 00000000000..cb172628c05 Binary files /dev/null and b/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.channel.info_AddMembersBottomSheetTest_results_with_existing_member_in_dark_mode.png differ diff --git a/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.channel.info_AddMembersBottomSheetTest_results_with_query.png b/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.channel.info_AddMembersBottomSheetTest_results_with_query.png new file mode 100644 index 00000000000..e2f79db9d61 Binary files /dev/null and b/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.channel.info_AddMembersBottomSheetTest_results_with_query.png differ diff --git a/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.channel.info_AddMembersBottomSheetTest_results_with_query_in_dark_mode.png b/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.channel.info_AddMembersBottomSheetTest_results_with_query_in_dark_mode.png new file mode 100644 index 00000000000..20c19b69869 Binary files /dev/null and b/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.channel.info_AddMembersBottomSheetTest_results_with_query_in_dark_mode.png differ diff --git a/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.channel.info_AddMembersBottomSheetTest_results_with_selection.png b/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.channel.info_AddMembersBottomSheetTest_results_with_selection.png new file mode 100644 index 00000000000..62d0a43770a Binary files /dev/null and b/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.channel.info_AddMembersBottomSheetTest_results_with_selection.png differ diff --git a/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.channel.info_AddMembersBottomSheetTest_results_with_selection_in_dark_mode.png b/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.channel.info_AddMembersBottomSheetTest_results_with_selection_in_dark_mode.png new file mode 100644 index 00000000000..2499341a914 Binary files /dev/null and b/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.channel.info_AddMembersBottomSheetTest_results_with_selection_in_dark_mode.png differ diff --git a/stream-chat-android-previewdata/src/main/kotlin/io/getstream/chat/android/previewdata/PreviewUserData.kt b/stream-chat-android-previewdata/src/main/kotlin/io/getstream/chat/android/previewdata/PreviewUserData.kt index 01c1ea71557..495e215963c 100644 --- a/stream-chat-android-previewdata/src/main/kotlin/io/getstream/chat/android/previewdata/PreviewUserData.kt +++ b/stream-chat-android-previewdata/src/main/kotlin/io/getstream/chat/android/previewdata/PreviewUserData.kt @@ -52,7 +52,7 @@ public object PreviewUserData { image = "https://vignette.wikia.nocookie.net/starwars/images/4/48/Chewbacca_TLJ.png", ) - private val user6: User = User( + public val user6: User = User( id = "c-3po", name = "C-3PO", image = "https://vignette.wikia.nocookie.net/starwars/images/3/3f/C-3PO_TLJ_Card_Trader_Award_Card.png", diff --git a/stream-chat-android-ui-common/api/stream-chat-android-ui-common.api b/stream-chat-android-ui-common/api/stream-chat-android-ui-common.api index 97ee478ea47..092522ba7ef 100644 --- a/stream-chat-android-ui-common/api/stream-chat-android-ui-common.api +++ b/stream-chat-android-ui-common/api/stream-chat-android-ui-common.api @@ -41,6 +41,41 @@ public final class io/getstream/chat/android/ui/common/feature/channel/attachmen public fun toString ()Ljava/lang/String; } +public abstract interface class io/getstream/chat/android/ui/common/feature/channel/info/AddMembersViewAction { +} + +public final class io/getstream/chat/android/ui/common/feature/channel/info/AddMembersViewAction$LoadMore : io/getstream/chat/android/ui/common/feature/channel/info/AddMembersViewAction { + public static final field $stable I + public static final field INSTANCE Lio/getstream/chat/android/ui/common/feature/channel/info/AddMembersViewAction$LoadMore; + public fun equals (Ljava/lang/Object;)Z + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class io/getstream/chat/android/ui/common/feature/channel/info/AddMembersViewAction$QueryChanged : io/getstream/chat/android/ui/common/feature/channel/info/AddMembersViewAction { + public static final field $stable I + public fun (Ljava/lang/String;)V + public final fun component1 ()Ljava/lang/String; + public final fun copy (Ljava/lang/String;)Lio/getstream/chat/android/ui/common/feature/channel/info/AddMembersViewAction$QueryChanged; + public static synthetic fun copy$default (Lio/getstream/chat/android/ui/common/feature/channel/info/AddMembersViewAction$QueryChanged;Ljava/lang/String;ILjava/lang/Object;)Lio/getstream/chat/android/ui/common/feature/channel/info/AddMembersViewAction$QueryChanged; + public fun equals (Ljava/lang/Object;)Z + public final fun getQuery ()Ljava/lang/String; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class io/getstream/chat/android/ui/common/feature/channel/info/AddMembersViewAction$UserClick : io/getstream/chat/android/ui/common/feature/channel/info/AddMembersViewAction { + public static final field $stable I + public fun (Lio/getstream/chat/android/models/User;)V + public final fun component1 ()Lio/getstream/chat/android/models/User; + public final fun copy (Lio/getstream/chat/android/models/User;)Lio/getstream/chat/android/ui/common/feature/channel/info/AddMembersViewAction$UserClick; + public static synthetic fun copy$default (Lio/getstream/chat/android/ui/common/feature/channel/info/AddMembersViewAction$UserClick;Lio/getstream/chat/android/models/User;ILjava/lang/Object;)Lio/getstream/chat/android/ui/common/feature/channel/info/AddMembersViewAction$UserClick; + public fun equals (Ljava/lang/Object;)Z + public final fun getUser ()Lio/getstream/chat/android/models/User; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + public abstract interface class io/getstream/chat/android/ui/common/feature/channel/info/ChannelInfoMemberViewAction { } @@ -1596,6 +1631,33 @@ public final class io/getstream/chat/android/ui/common/state/channel/attachments public fun toString ()Ljava/lang/String; } +public final class io/getstream/chat/android/ui/common/state/channel/info/AddMembersViewState { + public static final field $stable I + public fun ()V + public fun (ZZLjava/lang/String;Ljava/util/List;Ljava/util/Set;Ljava/util/Set;)V + public synthetic fun (ZZLjava/lang/String;Ljava/util/List;Ljava/util/Set;Ljava/util/Set;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun component1 ()Z + public final fun component2 ()Z + public final fun component3 ()Ljava/lang/String; + public final fun component4 ()Ljava/util/List; + public final fun component5 ()Ljava/util/Set; + public final fun component6 ()Ljava/util/Set; + public final fun copy (ZZLjava/lang/String;Ljava/util/List;Ljava/util/Set;Ljava/util/Set;)Lio/getstream/chat/android/ui/common/state/channel/info/AddMembersViewState; + public static synthetic fun copy$default (Lio/getstream/chat/android/ui/common/state/channel/info/AddMembersViewState;ZZLjava/lang/String;Ljava/util/List;Ljava/util/Set;Ljava/util/Set;ILjava/lang/Object;)Lio/getstream/chat/android/ui/common/state/channel/info/AddMembersViewState; + public fun equals (Ljava/lang/Object;)Z + public final fun getLoadedMemberIds ()Ljava/util/Set; + public final fun getQuery ()Ljava/lang/String; + public final fun getSearchResult ()Ljava/util/List; + public final fun getSelectedUserIds ()Ljava/util/Set; + public final fun getSelectedUsers ()Ljava/util/List; + public fun hashCode ()I + public final fun isAlreadyMember (Lio/getstream/chat/android/models/User;)Z + public final fun isLoading ()Z + public final fun isLoadingMore ()Z + public final fun isSelected (Lio/getstream/chat/android/models/User;)Z + public fun toString ()Ljava/lang/String; +} + public final class io/getstream/chat/android/ui/common/state/channel/info/BanMember : io/getstream/chat/android/ui/common/state/channel/info/MemberAction { public static final field $stable I public fun (Lio/getstream/chat/android/models/Member;Ljava/lang/String;Lkotlin/jvm/functions/Function0;)V diff --git a/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/feature/channel/info/AddMembersViewAction.kt b/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/feature/channel/info/AddMembersViewAction.kt new file mode 100644 index 00000000000..1e6ad6f9a5e --- /dev/null +++ b/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/feature/channel/info/AddMembersViewAction.kt @@ -0,0 +1,44 @@ +/* + * 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.ui.common.feature.channel.info + +import io.getstream.chat.android.models.User + +/** + * Represents actions that can be performed from the "Add Members" view. + */ +public sealed interface AddMembersViewAction { + + /** + * Represents a change in the search query. + * + * @param query The new search query. + */ + public data class QueryChanged(val query: String) : AddMembersViewAction + + /** + * Represents a click on a user in the search results, toggling their selection. + * + * @param user The user that was clicked. + */ + public data class UserClick(val user: User) : AddMembersViewAction + + /** + * Requests the next page of search results to be loaded and appended to the current list. + */ + public data object LoadMore : AddMembersViewAction +} diff --git a/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/feature/channel/info/AddMembersViewController.kt b/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/feature/channel/info/AddMembersViewController.kt new file mode 100644 index 00000000000..229585559ef --- /dev/null +++ b/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/feature/channel/info/AddMembersViewController.kt @@ -0,0 +1,169 @@ +/* + * 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.ui.common.feature.channel.info + +import io.getstream.chat.android.client.ChatClient +import io.getstream.chat.android.client.api.models.QueryUsersRequest +import io.getstream.chat.android.client.channel.state.ChannelState +import io.getstream.chat.android.core.internal.InternalStreamChatApi +import io.getstream.chat.android.models.Filters +import io.getstream.chat.android.models.Member +import io.getstream.chat.android.models.querysort.QuerySortByField +import io.getstream.chat.android.ui.common.state.channel.info.AddMembersViewState +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +/** + * Controller responsible for managing the state and actions related to adding members to a channel. + * + * It provides functionality to search for users, select/deselect them, and paginate results. + * Adding the selected members to the channel is delegated to the parent (e.g. [ChannelInfoViewController]). + * + * @param scope The [CoroutineScope] used for launching coroutines. + * @param resultLimit The maximum number of search results per page. + * @param chatClient The [ChatClient] instance used for interacting with the chat API. + * @param channelState A [Flow] representing the live state of the channel, used to keep track of + * current members reactively so that the list remains accurate when the sheet is re-opened. + */ +@OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class) +@InternalStreamChatApi +public class AddMembersViewController( + private val scope: CoroutineScope, + private val resultLimit: Int = DEFAULT_RESULT_LIMIT, + private val chatClient: ChatClient = ChatClient.instance(), + channelState: Flow, +) { + + private val channelMembers = channelState.flatMapLatest { it.members } + + private val _state = MutableStateFlow(AddMembersViewState()) + + /** + * A [StateFlow] representing the current state of the "Add Members" view. + */ + public val state: StateFlow = _state.asStateFlow() + + init { + // Keep loadedMemberIds in sync with the live channel member list. + channelMembers + .map { members -> members.map(Member::getUserId).toSet() } + .distinctUntilChanged() + .onEach { memberIds -> _state.update { it.copy(loadedMemberIds = memberIds) } } + .launchIn(scope) + + // Re-run search whenever the query changes, with debounce. + _state + .map { it.query } + .debounce(TYPING_DEBOUNCE_TIMEOUT_MS) + .distinctUntilChanged() + .onEach { query -> searchUsers(query) } + .launchIn(scope) + } + + /** + * Handles actions dispatched from the "Add Members" view. + * + * @param action The [AddMembersViewAction] to handle. + */ + public fun onViewAction(action: AddMembersViewAction) { + when (action) { + is AddMembersViewAction.QueryChanged -> { + _state.update { it.copy(query = action.query.trim()) } + } + + is AddMembersViewAction.UserClick -> { + _state.update { currentState -> + val userId = action.user.id + val newSelectedIds = if (userId in currentState.selectedUserIds) { + currentState.selectedUserIds - userId + } else { + currentState.selectedUserIds + userId + } + currentState.copy(selectedUserIds = newSelectedIds) + } + } + + is AddMembersViewAction.LoadMore -> loadMore() + } + } + + private fun searchUsers(query: String) { + scope.launch { + _state.update { it.copy(isLoading = true) } + chatClient.queryUsers(query.toSearchRequest(offset = 0)) + .await() + .onSuccess { users -> + _state.update { it.copy(isLoading = false, searchResult = users) } + } + .onError { + _state.update { it.copy(isLoading = false) } + } + } + } + + private fun loadMore() { + if (_state.value.isLoadingMore) return + scope.launch { + val currentResult = _state.value.searchResult + _state.update { it.copy(isLoadingMore = true) } + chatClient.queryUsers(_state.value.query.toSearchRequest(offset = currentResult.size)) + .await() + .onSuccess { newUsers -> + _state.update { it.copy(isLoadingMore = false, searchResult = it.searchResult + newUsers) } + } + .onError { + _state.update { it.copy(isLoadingMore = false) } + } + } + } + + private fun String.toSearchRequest(offset: Int): QueryUsersRequest { + val filter = if (isEmpty()) { + Filters.neutral() + } else { + Filters.or( + Filters.autocomplete("name", this), + Filters.autocomplete("id", this), + ) + } + return QueryUsersRequest( + filter = filter, + offset = offset, + limit = resultLimit, + querySort = QuerySortByField.ascByName("name"), + presence = true, + ) + } + + private companion object { + private const val DEFAULT_RESULT_LIMIT = 30 + private const val TYPING_DEBOUNCE_TIMEOUT_MS = 300L + } +} diff --git a/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/feature/channel/info/ChannelInfoViewController.kt b/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/feature/channel/info/ChannelInfoViewController.kt index fcb0d4bf231..43cb7dfebf7 100644 --- a/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/feature/channel/info/ChannelInfoViewController.kt +++ b/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/feature/channel/info/ChannelInfoViewController.kt @@ -22,11 +22,13 @@ import io.getstream.chat.android.client.api.state.globalStateFlow import io.getstream.chat.android.client.api.state.watchChannelAsState import io.getstream.chat.android.client.channel.ChannelClient import io.getstream.chat.android.client.channel.state.ChannelState +import io.getstream.chat.android.client.query.AddMembersParams import io.getstream.chat.android.core.internal.InternalStreamChatApi import io.getstream.chat.android.models.Channel import io.getstream.chat.android.models.ChannelCapabilities import io.getstream.chat.android.models.ChannelData import io.getstream.chat.android.models.Member +import io.getstream.chat.android.models.MemberData import io.getstream.chat.android.models.Message import io.getstream.chat.android.models.Mute import io.getstream.chat.android.models.User @@ -218,6 +220,27 @@ public class ChannelInfoViewController( } } + /** + * Adds the given members to the channel. + * + * @param userIds The set of user IDs to add as members. + */ + public fun addMembers(userIds: Set) { + if (userIds.isEmpty()) return + logger.d { "[addMembers] userIds: $userIds" } + scope.launch { + channelClient.addMembers( + AddMembersParams( + members = userIds.map { MemberData(it) }, + systemMessage = null, + ), + ).await() + .onError { error -> + logger.e { "[addMembers] error: ${error.message}" } + } + } + } + /** * Propagates events from the [ChannelInfoMemberViewEvent] to the [ChannelInfoViewEvent]. */ diff --git a/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/state/channel/info/AddMembersViewState.kt b/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/state/channel/info/AddMembersViewState.kt new file mode 100644 index 00000000000..47ae75cfb40 --- /dev/null +++ b/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/state/channel/info/AddMembersViewState.kt @@ -0,0 +1,53 @@ +/* + * 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.ui.common.state.channel.info + +import io.getstream.chat.android.models.User + +/** + * Represents the state of the "Add Members" view. + * + * @param isLoading Whether the view is currently loading the initial search results. + * @param isLoadingMore Whether the view is currently loading additional (paginated) results. + * @param query The current search query. + * @param searchResult The list of users matching the search query. + * @param selectedUserIds The set of IDs of users selected to be added as members. + * @param loadedMemberIds The set of IDs of users who are already members of the channel. + */ +public data class AddMembersViewState( + val isLoading: Boolean = true, + val isLoadingMore: Boolean = false, + val query: String = "", + val searchResult: List = emptyList(), + val selectedUserIds: Set = emptySet(), + val loadedMemberIds: Set = emptySet(), +) { + /** + * The list of users selected to be added as members, derived from [searchResult] and [selectedUserIds]. + */ + public val selectedUsers: List get() = searchResult.filter { it.id in selectedUserIds } + + /** + * Returns true if the given [user] is selected to be added as a member. + */ + public fun isSelected(user: User): Boolean = user.id in selectedUserIds + + /** + * Returns true if the given [user] is already a member of the channel. + */ + public fun isAlreadyMember(user: User): Boolean = user.id in loadedMemberIds +} diff --git a/stream-chat-android-ui-common/src/test/kotlin/io/getstream/chat/android/ui/common/feature/channel/info/AddMembersViewControllerTest.kt b/stream-chat-android-ui-common/src/test/kotlin/io/getstream/chat/android/ui/common/feature/channel/info/AddMembersViewControllerTest.kt new file mode 100644 index 00000000000..9cce832c1cc --- /dev/null +++ b/stream-chat-android-ui-common/src/test/kotlin/io/getstream/chat/android/ui/common/feature/channel/info/AddMembersViewControllerTest.kt @@ -0,0 +1,328 @@ +/* + * 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.ui.common.feature.channel.info + +import app.cash.turbine.test +import io.getstream.chat.android.client.ChatClient +import io.getstream.chat.android.client.channel.state.ChannelState +import io.getstream.chat.android.models.Member +import io.getstream.chat.android.models.User +import io.getstream.chat.android.randomGenericError +import io.getstream.chat.android.randomMembers +import io.getstream.chat.android.randomUser +import io.getstream.chat.android.test.asCall +import io.getstream.chat.android.ui.common.state.channel.info.AddMembersViewState +import io.getstream.result.Error +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.doAnswer +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever + +internal class AddMembersViewControllerTest { + + @Test + fun `initial state`() = runTest { + val sut = Fixture().get(backgroundScope) + + assertEquals(AddMembersViewState(), sut.state.value) + } + + @Test + fun `channel members are synced into loadedMemberIds`() = runTest { + val members = randomMembers(3) + val sut = Fixture() + .givenMembers(members) + .get(backgroundScope) + + sut.state.test { + skipItems(1) // Skip initial state + + val state = awaitItem() + assertEquals(members.map(Member::getUserId).toSet(), state.loadedMemberIds) + } + } + + @Test + fun `channel members update is reflected reactively`() = runTest { + val fixture = Fixture() + val sut = fixture.get(backgroundScope) + + sut.state.test { + skipItems(2) // Skip initial state and first search result + + val newMembers = randomMembers(2) + fixture.givenMembers(newMembers) + + val state = awaitItem() + assertEquals(newMembers.map(Member::getUserId).toSet(), state.loadedMemberIds) + } + } + + @Test + fun `initial search loads users after debounce`() = runTest { + val users = listOf(randomUser(), randomUser()) + val sut = Fixture() + .givenQueryUsers(users = users) + .get(backgroundScope) + + sut.state.test { + skipItems(1) // Skip initial loading state + + val state = awaitItem() + assertFalse(state.isLoading) + assertEquals(users, state.searchResult) + } + } + + @Test + fun `initial search error clears loading state`() = runTest { + val sut = Fixture() + .givenQueryUsers(error = randomGenericError()) + .get(backgroundScope) + + sut.state.test { + skipItems(1) // Skip initial loading state + + val state = awaitItem() + assertFalse(state.isLoading) + assertTrue(state.searchResult.isEmpty()) + } + } + + @Test + fun `QueryChanged updates the query`() = runTest { + val sut = Fixture() + .givenQueryUsers(users = emptyList()) + .get(backgroundScope) + + sut.state.test { + skipItems(2) // Skip initial state and initial search result + + sut.onViewAction(AddMembersViewAction.QueryChanged("John")) + + assertEquals("John", awaitItem().query) + } + } + + @Test + fun `QueryChanged trims whitespace from the query`() = runTest { + val sut = Fixture() + .givenQueryUsers(users = emptyList()) + .get(backgroundScope) + + sut.state.test { + skipItems(2) // Skip initial state and initial search result + + sut.onViewAction(AddMembersViewAction.QueryChanged(" John ")) + + assertEquals("John", awaitItem().query) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `QueryChanged triggers a new search after debounce`() = runTest { + val query = "Alice" + val users = listOf(randomUser(), randomUser()) + val sut = Fixture() + .givenQueryUsers(users = emptyList()) // initial empty-query search + .givenQueryUsers(users = users) // search for "Alice" + .get(backgroundScope) + + sut.state.test { + skipItems(2) // Skip initial state and empty-query search result + + sut.onViewAction(AddMembersViewAction.QueryChanged(query)) + skipItems(1) // Skip the query-only state update + + assertTrue(awaitItem().isLoading) // New search triggered + + val state = awaitItem() + assertFalse(state.isLoading) + assertEquals(query, state.query) + assertEquals(users, state.searchResult) + } + } + + @Test + fun `QueryChanged search error clears loading state`() = runTest { + val sut = Fixture() + .givenQueryUsers(users = emptyList()) // initial search + .givenQueryUsers(error = randomGenericError()) // error on next search + .get(backgroundScope) + + sut.state.test { + skipItems(2) // Skip initial state and empty-query search result + + sut.onViewAction(AddMembersViewAction.QueryChanged("Bob")) + skipItems(1) // Skip query state update + + assertTrue(awaitItem().isLoading) + + val state = awaitItem() + assertFalse(state.isLoading) + } + } + + @Test + fun `UserClick selects a user`() = runTest { + val user = randomUser() + val sut = Fixture() + .givenQueryUsers(users = listOf(user)) + .get(backgroundScope) + + sut.state.test { + skipItems(2) // Skip initial state and search result + + sut.onViewAction(AddMembersViewAction.UserClick(user)) + + val state = awaitItem() + assertTrue(user.id in state.selectedUserIds) + assertTrue(state.isSelected(user)) + } + } + + @Test + fun `UserClick deselects an already selected user`() = runTest { + val user = randomUser() + val sut = Fixture() + .givenQueryUsers(users = listOf(user)) + .get(backgroundScope) + + sut.state.test { + skipItems(2) // Skip initial state and search result + + sut.onViewAction(AddMembersViewAction.UserClick(user)) + skipItems(1) // Skip selected state + + sut.onViewAction(AddMembersViewAction.UserClick(user)) + + val state = awaitItem() + assertFalse(user.id in state.selectedUserIds) + assertFalse(state.isSelected(user)) + } + } + + @Test + fun `UserClick toggles selection independently for multiple users`() = runTest { + val user1 = randomUser() + val user2 = randomUser() + val sut = Fixture() + .givenQueryUsers(users = listOf(user1, user2)) + .get(backgroundScope) + + sut.state.test { + skipItems(2) // Skip initial state and search result + + sut.onViewAction(AddMembersViewAction.UserClick(user1)) + skipItems(1) // Skip user1 selected state + + sut.onViewAction(AddMembersViewAction.UserClick(user2)) + + val state = awaitItem() + assertTrue(user1.id in state.selectedUserIds) + assertTrue(user2.id in state.selectedUserIds) + assertEquals(listOf(user1, user2), state.selectedUsers) + } + } + + @Test + fun `LoadMore appends results to searchResult`() = runTest { + val initialUsers = listOf(randomUser(), randomUser()) + val moreUsers = listOf(randomUser(), randomUser()) + val sut = Fixture() + .givenQueryUsers(users = initialUsers) + .givenQueryUsers(users = moreUsers) + .get(backgroundScope) + + sut.state.test { + skipItems(2) // Skip initial state and initial search result + + sut.onViewAction(AddMembersViewAction.LoadMore) + + assertTrue(awaitItem().isLoadingMore) // Loading more + + val state = awaitItem() + assertFalse(state.isLoadingMore) + assertEquals(initialUsers + moreUsers, state.searchResult) + } + } + + @Test + fun `LoadMore error clears isLoadingMore and retains existing results`() = runTest { + val initialUsers = listOf(randomUser(), randomUser()) + val sut = Fixture() + .givenQueryUsers(users = initialUsers) + .givenQueryUsers(error = randomGenericError()) + .get(backgroundScope) + + sut.state.test { + skipItems(2) // Skip initial state and initial search result + + sut.onViewAction(AddMembersViewAction.LoadMore) + + assertTrue(awaitItem().isLoadingMore) // Loading more + + val state = awaitItem() + assertFalse(state.isLoadingMore) + assertEquals(initialUsers, state.searchResult) + } + } + + private class Fixture { + private val channelMembers = MutableStateFlow(emptyList()) + private val channelState: ChannelState = mock { + on { members } doReturn channelMembers + } + private val chatClient: ChatClient = mock() + private val queryUsersResults = mutableListOf?, Error?>>() + private var callCount = 0 + + init { + whenever(chatClient.queryUsers(any())) doAnswer { + val index = callCount++ + val (users, err) = queryUsersResults.getOrElse(index) { Pair(emptyList(), null) } + err?.asCall() ?: (users ?: emptyList()).asCall() + } + } + + fun givenMembers(members: List) = apply { + channelMembers.value = members + } + + fun givenQueryUsers( + users: List? = null, + error: Error? = null, + ) = apply { + queryUsersResults.add(Pair(users, error)) + } + + fun get(scope: CoroutineScope) = AddMembersViewController( + scope = scope, + chatClient = chatClient, + channelState = MutableStateFlow(channelState), + ) + } +}