diff --git a/CHANGELOG.md b/CHANGELOG.md index e90ac86043f..9c66df2b586 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -74,6 +74,8 @@ ### ✅ Added - Add `FileAttachmentPreviewContent`, `FileAttachmentContent`,`FileAttachmentItem`, `FileUploadContent` and `FileUploadItem` to `ChatComponentFactory`. [#5791](https://github.com/GetStream/stream-chat-android/pull/5791) +- Introduce internal `DraftChannelViewController` and experimental classes `DraftChannelViewState`, `DraftChannelViewAction`, and `DraftChannelViewEvent`. [#5797](https://github.com/GetStream/stream-chat-android/pull/5797) +- Message member from the member bottom sheet of the channel info screen. [#5797](https://github.com/GetStream/stream-chat-android/pull/5797) ### ⚠️ Changed diff --git a/stream-chat-android-compose-sample/src/main/AndroidManifest.xml b/stream-chat-android-compose-sample/src/main/AndroidManifest.xml index de3534cff69..da0926e82a9 100644 --- a/stream-chat-android-compose-sample/src/main/AndroidManifest.xml +++ b/stream-chat-android-compose-sample/src/main/AndroidManifest.xml @@ -89,6 +89,10 @@ android:exported="false" android:windowSoftInputMode="adjustResize" /> + - - \ No newline at end of file + diff --git a/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/feature/channel/draft/DraftChannelActivity.kt b/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/feature/channel/draft/DraftChannelActivity.kt new file mode 100644 index 00000000000..86ea23b8737 --- /dev/null +++ b/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/feature/channel/draft/DraftChannelActivity.kt @@ -0,0 +1,92 @@ +/* + * Copyright (c) 2014-2025 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.feature.channel.draft + +import android.content.Context +import android.content.Intent +import android.os.Bundle +import android.widget.Toast +import androidx.activity.compose.setContent +import androidx.activity.viewModels +import androidx.compose.foundation.layout.systemBarsPadding +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Modifier +import io.getstream.chat.android.compose.sample.R +import io.getstream.chat.android.compose.sample.ui.BaseConnectedActivity +import io.getstream.chat.android.compose.sample.ui.MessagesActivity +import io.getstream.chat.android.compose.ui.theme.ChatTheme +import io.getstream.chat.android.ui.common.feature.channel.draft.DraftChannelViewEvent +import kotlinx.coroutines.flow.collectLatest + +class DraftChannelActivity : BaseConnectedActivity() { + + companion object { + private const val KEY_MEMBER_IDS = "memberIds" + + /** + * Creates an [Intent] for starting the [DraftChannelActivity]. + * + * @param context The calling [Context], used for building the [Intent]. + * @param memberIds The list of member IDs to be used for creating the channel. + */ + fun createIntent(context: Context, memberIds: List) = + Intent(context, DraftChannelActivity::class.java) + .putExtra(KEY_MEMBER_IDS, memberIds.toTypedArray()) + } + + private val viewModelFactory by lazy { + DraftChannelViewModelFactory( + memberIds = requireNotNull(intent.getStringArrayExtra(KEY_MEMBER_IDS)).toList(), + ) + } + private val viewModel by viewModels { viewModelFactory } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContent { + ChatTheme { + DraftChannelScreen( + modifier = Modifier.systemBarsPadding(), + viewModel = viewModel, + onNavigationIconClick = ::finish, + ) + LaunchedEffect(viewModel) { + viewModel.events.collectLatest { event -> + when (event) { + is DraftChannelViewEvent.NavigateToChannel -> { + startActivity( + MessagesActivity.createIntent( + context = applicationContext, + channelId = event.cid, + ), + ) + finish() + } + + is DraftChannelViewEvent.DraftChannelError -> + Toast.makeText( + applicationContext, + R.string.draft_channel_error, + Toast.LENGTH_SHORT, + ).show() + } + } + } + } + } + } +} diff --git a/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/feature/channel/draft/DraftChannelScreen.kt b/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/feature/channel/draft/DraftChannelScreen.kt new file mode 100644 index 00000000000..f7ed1ddd4e1 --- /dev/null +++ b/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/feature/channel/draft/DraftChannelScreen.kt @@ -0,0 +1,130 @@ +/* + * Copyright (c) 2014-2025 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.feature.channel.draft + +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Scaffold +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel +import io.getstream.chat.android.compose.ui.components.LoadingIndicator +import io.getstream.chat.android.compose.ui.messages.composer.MessageComposer +import io.getstream.chat.android.compose.ui.messages.header.MessageListHeader +import io.getstream.chat.android.compose.ui.theme.ChatTheme +import io.getstream.chat.android.compose.viewmodel.channel.ChannelHeaderViewModel +import io.getstream.chat.android.compose.viewmodel.channel.ChannelHeaderViewModelFactory +import io.getstream.chat.android.compose.viewmodel.messages.MessageComposerViewModel +import io.getstream.chat.android.compose.viewmodel.messages.MessagesViewModelFactory +import io.getstream.chat.android.ui.common.feature.channel.draft.DraftChannelViewAction +import io.getstream.chat.android.ui.common.state.channel.draft.DraftChannelViewState +import io.getstream.chat.android.ui.common.state.messages.list.ChannelHeaderViewState + +@Composable +fun DraftChannelScreen( + viewModel: DraftChannelViewModel, + onNavigationIconClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val state by viewModel.state.collectAsStateWithLifecycle() + + DraftChannelContent( + modifier = modifier, + state = state, + onNavigationIconClick = onNavigationIconClick, + onViewAction = viewModel::onViewAction, + ) +} + +@Composable +private fun DraftChannelContent( + state: DraftChannelViewState, + onNavigationIconClick: () -> Unit, + onViewAction: (action: DraftChannelViewAction) -> Unit, + modifier: Modifier = Modifier, +) { + when (state) { + is DraftChannelViewState.Loading -> LoadingIndicator( + modifier = modifier.fillMaxSize(), + ) + + is DraftChannelViewState.Content -> Scaffold( + modifier = modifier, + topBar = { + DraftChannelTopBar( + cid = state.channel.cid, + onNavigationIconClick = onNavigationIconClick, + ) + }, + bottomBar = { + DraftChannelBottomBar( + cid = state.channel.cid, + onMessageSent = { onViewAction(DraftChannelViewAction.MessageSent) }, + ) + }, + containerColor = ChatTheme.colors.appBackground, + ) { padding -> + ChatTheme.componentFactory.MessageListEmptyContent( + modifier = Modifier + .padding(padding) + .fillMaxSize(), + ) + } + } +} + +@Composable +private fun DraftChannelTopBar( + cid: String, + onNavigationIconClick: () -> Unit, +) { + val viewModel = viewModel(factory = ChannelHeaderViewModelFactory(cid)) + val state by viewModel.state.collectAsStateWithLifecycle() + + when (val content = state) { + is ChannelHeaderViewState.Loading -> LoadingIndicator( + modifier = Modifier.fillMaxWidth(), + ) + + is ChannelHeaderViewState.Content -> MessageListHeader( + channel = content.channel, + currentUser = content.currentUser, + connectionState = content.connectionState, + onBackPressed = onNavigationIconClick, + ) + } +} + +@Composable +private fun DraftChannelBottomBar( + cid: String, + onMessageSent: () -> Unit, +) { + val context = LocalContext.current + val viewModel = viewModel(key = cid, factory = MessagesViewModelFactory(context, cid)) + MessageComposer( + viewModel = viewModel, + onSendMessage = { message -> + viewModel.sendMessage(message) + onMessageSent() + }, + ) +} diff --git a/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/feature/channel/draft/DraftChannelViewModel.kt b/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/feature/channel/draft/DraftChannelViewModel.kt new file mode 100644 index 00000000000..9cbdc8615be --- /dev/null +++ b/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/feature/channel/draft/DraftChannelViewModel.kt @@ -0,0 +1,59 @@ +/* + * Copyright (c) 2014-2025 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.sample.feature.channel.draft + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import io.getstream.chat.android.core.internal.InternalStreamChatApi +import io.getstream.chat.android.ui.common.feature.channel.draft.DraftChannelViewAction +import io.getstream.chat.android.ui.common.feature.channel.draft.DraftChannelViewController +import io.getstream.chat.android.ui.common.feature.channel.draft.DraftChannelViewEvent +import io.getstream.chat.android.ui.common.state.channel.draft.DraftChannelViewState +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow + +class DraftChannelViewModel( + private val memberIds: List, + controllerProvider: ViewModel.() -> DraftChannelViewController = { + DraftChannelViewController( + memberIds = memberIds, + scope = viewModelScope, + ) + }, +) : ViewModel() { + + private val controller: DraftChannelViewController by lazy { controllerProvider() } + + /** + * @see [DraftChannelViewController.state] + */ + val state: StateFlow = controller.state + + /** + * @see [DraftChannelViewController.events] + */ + val events: SharedFlow = controller.events + + /** + * @see [DraftChannelViewController.onViewAction] + */ + fun onViewAction(action: DraftChannelViewAction) { + controller.onViewAction(action) + } +} diff --git a/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/feature/channel/draft/DraftChannelViewModelFactory.kt b/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/feature/channel/draft/DraftChannelViewModelFactory.kt new file mode 100644 index 00000000000..938abe7c928 --- /dev/null +++ b/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/feature/channel/draft/DraftChannelViewModelFactory.kt @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2014-2025 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.feature.channel.draft + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import io.getstream.chat.android.core.internal.InternalStreamChatApi + +class DraftChannelViewModelFactory(private val memberIds: List) : ViewModelProvider.Factory { + @OptIn(InternalStreamChatApi::class) + override fun create(modelClass: Class): T { + require(modelClass == DraftChannelViewModel::class.java) { + "DraftChannelViewModelFactory can only create instances of DraftChannelViewModel" + } + @Suppress("UNCHECKED_CAST") + return DraftChannelViewModel(memberIds) as T + } +} diff --git a/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/ui/channel/DirectChannelInfoActivity.kt b/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/ui/channel/DirectChannelInfoActivity.kt index ff77aa622b5..6ec5cb67059 100644 --- a/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/ui/channel/DirectChannelInfoActivity.kt +++ b/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/ui/channel/DirectChannelInfoActivity.kt @@ -76,24 +76,33 @@ class DirectChannelInfoActivity : BaseConnectedActivity() { LaunchedEffect(viewModel) { viewModel.events.collectLatest { event -> when (event) { - is ChannelInfoViewEvent.Error -> - showError(event) - - is ChannelInfoViewEvent.NavigateUp -> { - setResult(RESULT_OK) - finish() - } - - is ChannelInfoViewEvent.NavigateToPinnedMessages -> - openPinnedMessages() - - else -> Unit + is ChannelInfoViewEvent.Error -> showError(event) + is ChannelInfoViewEvent.Navigation -> onNavigationEvent(event) + is ChannelInfoViewEvent.Modal -> Unit } } } } } + private fun onNavigationEvent(event: ChannelInfoViewEvent.Navigation) { + when (event) { + is ChannelInfoViewEvent.NavigateUp -> { + setResult(RESULT_OK) + finish() + } + + is ChannelInfoViewEvent.NavigateToPinnedMessages -> + openPinnedMessages() + + // No need to handle these in DirectChannelInfoActivity, + // as it is only applicable for group channels. + is ChannelInfoViewEvent.NavigateToChannel, + is ChannelInfoViewEvent.NavigateToDraftChannel, + -> Unit + } + } + private fun openPinnedMessages() { val intent = PinnedMessagesActivity.createIntent( context = this, 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 330e07d0133..977dbbc83fc 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 @@ -26,7 +26,9 @@ import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Modifier import io.getstream.chat.android.compose.sample.R +import io.getstream.chat.android.compose.sample.feature.channel.draft.DraftChannelActivity import io.getstream.chat.android.compose.sample.ui.BaseConnectedActivity +import io.getstream.chat.android.compose.sample.ui.MessagesActivity import io.getstream.chat.android.compose.sample.ui.pinned.PinnedMessagesActivity import io.getstream.chat.android.compose.ui.channel.info.GroupChannelInfoScreen import io.getstream.chat.android.compose.ui.theme.ChatTheme @@ -75,24 +77,33 @@ class GroupChannelInfoActivity : BaseConnectedActivity() { LaunchedEffect(viewModel) { viewModel.events.collectLatest { event -> when (event) { - is ChannelInfoViewEvent.Error -> - showError(event) - - is ChannelInfoViewEvent.NavigateUp -> { - setResult(RESULT_OK) - finish() - } - - is ChannelInfoViewEvent.NavigateToPinnedMessages -> - openPinnedMessages() - - else -> Unit + is ChannelInfoViewEvent.Error -> showError(event) + is ChannelInfoViewEvent.Navigation -> onNavigationEvent(event) + is ChannelInfoViewEvent.Modal -> Unit } } } } } + private fun onNavigationEvent(event: ChannelInfoViewEvent.Navigation) { + when (event) { + is ChannelInfoViewEvent.NavigateUp -> { + setResult(RESULT_OK) + finish() + } + + is ChannelInfoViewEvent.NavigateToPinnedMessages -> + openPinnedMessages() + + is ChannelInfoViewEvent.NavigateToChannel -> + startActivity(MessagesActivity.createIntent(context = this, channelId = event.cid)) + + is ChannelInfoViewEvent.NavigateToDraftChannel -> + startActivity(DraftChannelActivity.createIntent(context = this, memberIds = listOf(event.memberId))) + } + } + private fun openPinnedMessages() { val intent = PinnedMessagesActivity.createIntent( context = this, 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 a127acb3d27..ace9546bd87 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 @@ -14,6 +14,8 @@ * limitations under the License. */ +@file:OptIn(ExperimentalStreamChatApi::class) + package io.getstream.chat.android.compose.sample.ui.chats import android.content.Context @@ -78,6 +80,7 @@ import io.getstream.chat.android.compose.viewmodel.channels.ChannelViewModelFact import io.getstream.chat.android.compose.viewmodel.messages.MessagesViewModelFactory import io.getstream.chat.android.compose.viewmodel.pinned.PinnedMessageListViewModel import io.getstream.chat.android.compose.viewmodel.pinned.PinnedMessageListViewModelFactory +import io.getstream.chat.android.core.ExperimentalStreamChatApi import io.getstream.chat.android.models.Channel import io.getstream.chat.android.models.Filters import io.getstream.chat.android.models.Message @@ -259,6 +262,12 @@ class ChatsActivity : BaseConnectedActivity() { onNavigationIconClick = { navigator.navigateBack() }, onNavigateUp = { navigator.popUpTo(pane = ThreePaneRole.List) }, onNavigateToPinnedMessages = { navigator.navigateToPinnedMessages(mode.channelId) }, + onNavigateToChannel = { channelId -> + navigator.navigateToChannel( + channelId = channelId, + singlePane = singlePane, + ) + }, ) is InfoContentMode.PinnedMessages -> PinnedMessagesContent( @@ -268,8 +277,7 @@ class ChatsActivity : BaseConnectedActivity() { navigator.navigateToMessage( channelId = message.cid, messageId = message.id, - replace = !singlePane, - popUp = singlePane, + singlePane = singlePane, ) }, ) @@ -319,11 +327,16 @@ class ChatsActivity : BaseConnectedActivity() { onNavigationIconClick: () -> Unit, onNavigateUp: () -> Unit, onNavigateToPinnedMessages: () -> Unit, + onNavigateToChannel: (cid: String) -> Unit, ) { val viewModelFactory = ChannelInfoViewModelFactory(context = applicationContext, cid = channelId) val viewModel = viewModel(key = channelId, factory = viewModelFactory) - viewModel.handleChannelInfoEvents(onNavigateUp, onNavigateToPinnedMessages) + viewModel.handleChannelInfoEvents( + onNavigateUp = onNavigateUp, + onNavigateToPinnedMessages = onNavigateToPinnedMessages, + onNavigateToChannel = onNavigateToChannel, + ) if (AdaptiveLayoutInfo.singlePaneWindow()) { GroupChannelInfoScreen( @@ -350,14 +363,20 @@ class ChatsActivity : BaseConnectedActivity() { private fun ChannelInfoViewModel.handleChannelInfoEvents( onNavigateUp: () -> Unit, onNavigateToPinnedMessages: () -> Unit, + onNavigateToChannel: (cid: String) -> Unit = {}, ) { LaunchedEffect(this) { events.collectLatest { event -> when (event) { - is ChannelInfoViewEvent.NavigateUp -> onNavigateUp() - is ChannelInfoViewEvent.NavigateToPinnedMessages -> onNavigateToPinnedMessages() + is ChannelInfoViewEvent.Navigation -> when (event) { + is ChannelInfoViewEvent.NavigateUp -> onNavigateUp() + is ChannelInfoViewEvent.NavigateToPinnedMessages -> onNavigateToPinnedMessages() + is ChannelInfoViewEvent.NavigateToChannel -> onNavigateToChannel(event.cid) + // https://linear.app/stream/issue/AND-582/compose-support-draft-messages-in-chatsactivity + is ChannelInfoViewEvent.NavigateToDraftChannel -> Unit + } is ChannelInfoViewEvent.Error -> showError(event) - else -> Unit + is ChannelInfoViewEvent.Modal -> Unit } } } @@ -462,16 +481,15 @@ private fun ThreePaneNavigator.navigateToPinnedMessages(channelId: String) { private fun ThreePaneNavigator.navigateToMessage( channelId: String, messageId: String, - replace: Boolean, - popUp: Boolean, + singlePane: Boolean, ) { navigateTo( destination = ThreePaneDestination( pane = ThreePaneRole.Detail, arguments = ChatMessageSelection(channelId, messageId), ), - replace = replace, - popUpTo = if (popUp) { + replace = !singlePane, + popUpTo = if (singlePane) { ThreePaneRole.List } else { null @@ -479,6 +497,24 @@ private fun ThreePaneNavigator.navigateToMessage( ) } +private fun ThreePaneNavigator.navigateToChannel( + channelId: String, + singlePane: Boolean, +) { + navigateTo( + destination = ThreePaneDestination( + pane = ThreePaneRole.Detail, + arguments = ChatMessageSelection(channelId), + ), + replace = !singlePane, + popUpTo = if (singlePane) { + null + } else { + ThreePaneRole.Detail + }, + ) +} + private fun Context.showError(error: ChannelInfoViewEvent.Error) { val message = when (error) { ChannelInfoViewEvent.RenameChannelError, diff --git a/stream-chat-android-compose-sample/src/main/res/values/strings.xml b/stream-chat-android-compose-sample/src/main/res/values/strings.xml index 6d50b5204ef..6f85498a983 100644 --- a/stream-chat-android-compose-sample/src/main/res/values/strings.xml +++ b/stream-chat-android-compose-sample/src/main/res/values/strings.xml @@ -33,7 +33,7 @@ User ID User Token Username (optional) - Enable adaptive layout + Enable adaptive layout (Experimental) Adjust layout based on screen sizes @@ -67,4 +67,7 @@ Chats Mentions Threads - \ No newline at end of file + + + Failed to draft a channel + 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 007371101fa..2e617c39a57 100644 --- a/stream-chat-android-compose/api/stream-chat-android-compose.api +++ b/stream-chat-android-compose/api/stream-chat-android-compose.api @@ -4388,6 +4388,12 @@ public final class io/getstream/chat/android/compose/viewmodel/channel/ChannelHe public final fun getState ()Lkotlinx/coroutines/flow/StateFlow; } +public final class io/getstream/chat/android/compose/viewmodel/channel/ChannelHeaderViewModelFactory : androidx/lifecycle/ViewModelProvider$Factory { + public static final field $stable I + public fun (Ljava/lang/String;)V + public fun create (Ljava/lang/Class;)Landroidx/lifecycle/ViewModel; +} + public final class io/getstream/chat/android/compose/viewmodel/channel/ChannelInfoMemberViewModel : androidx/lifecycle/ViewModel { public static final field $stable I public fun (Ljava/lang/String;Ljava/lang/String;Lkotlin/jvm/functions/Function1;)V diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/channel/info/ChannelInfoMemberInfoModalSheet.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/channel/info/ChannelInfoMemberInfoModalSheet.kt index db30c43d28e..0282acbf7f9 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/channel/info/ChannelInfoMemberInfoModalSheet.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/channel/info/ChannelInfoMemberInfoModalSheet.kt @@ -108,12 +108,11 @@ private fun ChannelInfoMemberInfoModalSheetContent( ) { val isLoading = state is ChannelInfoMemberViewState.Loading ContentBox( - contentAlignment = if (isLoading) Alignment.Center else Alignment.TopCenter, + modifier = Modifier.fillMaxWidth(), isLoading = isLoading, ) { val content = state as ChannelInfoMemberViewState.Content Column( - modifier = Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(12.dp), ) { diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/channel/info/DirectChannelInfoScreen.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/channel/info/DirectChannelInfoScreen.kt index eb918cea329..65031da91fb 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/channel/info/DirectChannelInfoScreen.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/channel/info/DirectChannelInfoScreen.kt @@ -154,11 +154,11 @@ private fun DirectChannelInfoContent( modifier = Modifier .padding(padding) .fillMaxSize(), - contentAlignment = if (isLoading) Alignment.Center else Alignment.TopCenter, isLoading = isLoading, ) { val content = state as ChannelInfoViewState.Content Column( + modifier = Modifier.matchParentSize(), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(8.dp), ) { 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 28fe085d4bc..6a49e68cf3a 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 @@ -39,7 +39,6 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource @@ -195,11 +194,11 @@ private fun GroupChannelInfoContent( val isLoading = state is ChannelInfoViewState.Loading ContentBox( modifier = modifier.fillMaxSize(), - contentAlignment = if (isLoading) Alignment.Center else Alignment.TopCenter, isLoading = isLoading, ) { val content = state as ChannelInfoViewState.Content LazyColumn( + modifier = Modifier.matchParentSize(), state = listState, ) { items( diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/channel/ChannelHeaderViewModelFactory.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/channel/ChannelHeaderViewModelFactory.kt new file mode 100644 index 00000000000..ce1d16efabc --- /dev/null +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/channel/ChannelHeaderViewModelFactory.kt @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2014-2025 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 androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import io.getstream.chat.android.core.ExperimentalStreamChatApi + +/** + * Factory for creating instances of [ChannelHeaderViewModel]. + * + * @param cid The full channel identifier (e.g., "messaging:123"). + */ +@ExperimentalStreamChatApi +public class ChannelHeaderViewModelFactory( + private val cid: String, +) : ViewModelProvider.Factory { + + override fun create(modelClass: Class): T { + require(modelClass == ChannelHeaderViewModel::class.java) { + "ChannelHeaderViewModelFactory can only create instances of ChannelHeaderViewModel" + } + @Suppress("UNCHECKED_CAST") + return ChannelHeaderViewModel(cid) as T + } +} 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 8953da37c96..44c63fa62c7 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 @@ -15,6 +15,40 @@ public abstract interface class io/getstream/chat/android/ui/common/disposable/D public abstract fun isDisposed ()Z } +public abstract interface class io/getstream/chat/android/ui/common/feature/channel/draft/DraftChannelViewAction { +} + +public final class io/getstream/chat/android/ui/common/feature/channel/draft/DraftChannelViewAction$MessageSent : io/getstream/chat/android/ui/common/feature/channel/draft/DraftChannelViewAction { + public static final field $stable I + public static final field INSTANCE Lio/getstream/chat/android/ui/common/feature/channel/draft/DraftChannelViewAction$MessageSent; + public fun equals (Ljava/lang/Object;)Z + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public abstract interface class io/getstream/chat/android/ui/common/feature/channel/draft/DraftChannelViewEvent { +} + +public final class io/getstream/chat/android/ui/common/feature/channel/draft/DraftChannelViewEvent$DraftChannelError : io/getstream/chat/android/ui/common/feature/channel/draft/DraftChannelViewEvent { + public static final field $stable I + public static final field INSTANCE Lio/getstream/chat/android/ui/common/feature/channel/draft/DraftChannelViewEvent$DraftChannelError; + 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/draft/DraftChannelViewEvent$NavigateToChannel : io/getstream/chat/android/ui/common/feature/channel/draft/DraftChannelViewEvent { + 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/draft/DraftChannelViewEvent$NavigateToChannel; + public static synthetic fun copy$default (Lio/getstream/chat/android/ui/common/feature/channel/draft/DraftChannelViewEvent$NavigateToChannel;Ljava/lang/String;ILjava/lang/Object;)Lio/getstream/chat/android/ui/common/feature/channel/draft/DraftChannelViewEvent$NavigateToChannel; + public fun equals (Ljava/lang/Object;)Z + public final fun getCid ()Ljava/lang/String; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + public abstract interface class io/getstream/chat/android/ui/common/feature/channel/info/ChannelInfoMemberViewAction { } @@ -67,12 +101,14 @@ public final class io/getstream/chat/android/ui/common/feature/channel/info/Chan public final class io/getstream/chat/android/ui/common/feature/channel/info/ChannelInfoMemberViewEvent$MessageMember : io/getstream/chat/android/ui/common/feature/channel/info/ChannelInfoMemberViewEvent { public static final field $stable I - public fun (Ljava/lang/String;)V + public fun (Ljava/lang/String;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/ChannelInfoMemberViewEvent$MessageMember; - public static synthetic fun copy$default (Lio/getstream/chat/android/ui/common/feature/channel/info/ChannelInfoMemberViewEvent$MessageMember;Ljava/lang/String;ILjava/lang/Object;)Lio/getstream/chat/android/ui/common/feature/channel/info/ChannelInfoMemberViewEvent$MessageMember; + public final fun component2 ()Ljava/lang/String; + public final fun copy (Ljava/lang/String;Ljava/lang/String;)Lio/getstream/chat/android/ui/common/feature/channel/info/ChannelInfoMemberViewEvent$MessageMember; + public static synthetic fun copy$default (Lio/getstream/chat/android/ui/common/feature/channel/info/ChannelInfoMemberViewEvent$MessageMember;Ljava/lang/String;Ljava/lang/String;ILjava/lang/Object;)Lio/getstream/chat/android/ui/common/feature/channel/info/ChannelInfoMemberViewEvent$MessageMember; public fun equals (Ljava/lang/Object;)Z - public final fun getChannelId ()Ljava/lang/String; + public final fun getDistinctCid ()Ljava/lang/String; + public final fun getMemberId ()Ljava/lang/String; public fun hashCode ()I public fun toString ()Ljava/lang/String; } @@ -381,6 +417,30 @@ public final class io/getstream/chat/android/ui/common/feature/channel/info/Chan public fun toString ()Ljava/lang/String; } +public final class io/getstream/chat/android/ui/common/feature/channel/info/ChannelInfoViewEvent$NavigateToChannel : io/getstream/chat/android/ui/common/feature/channel/info/ChannelInfoViewEvent$Navigation { + 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/ChannelInfoViewEvent$NavigateToChannel; + public static synthetic fun copy$default (Lio/getstream/chat/android/ui/common/feature/channel/info/ChannelInfoViewEvent$NavigateToChannel;Ljava/lang/String;ILjava/lang/Object;)Lio/getstream/chat/android/ui/common/feature/channel/info/ChannelInfoViewEvent$NavigateToChannel; + public fun equals (Ljava/lang/Object;)Z + public final fun getCid ()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/ChannelInfoViewEvent$NavigateToDraftChannel : io/getstream/chat/android/ui/common/feature/channel/info/ChannelInfoViewEvent$Navigation { + 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/ChannelInfoViewEvent$NavigateToDraftChannel; + public static synthetic fun copy$default (Lio/getstream/chat/android/ui/common/feature/channel/info/ChannelInfoViewEvent$NavigateToDraftChannel;Ljava/lang/String;ILjava/lang/Object;)Lio/getstream/chat/android/ui/common/feature/channel/info/ChannelInfoViewEvent$NavigateToDraftChannel; + public fun equals (Ljava/lang/Object;)Z + public final fun getMemberId ()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/ChannelInfoViewEvent$NavigateToPinnedMessages : io/getstream/chat/android/ui/common/feature/channel/info/ChannelInfoViewEvent$Navigation { public static final field $stable I public static final field INSTANCE Lio/getstream/chat/android/ui/common/feature/channel/info/ChannelInfoViewEvent$NavigateToPinnedMessages; @@ -1146,6 +1206,29 @@ public final class io/getstream/chat/android/ui/common/permissions/VisualMediaTy public static fun values ()[Lio/getstream/chat/android/ui/common/permissions/VisualMediaType; } +public abstract interface class io/getstream/chat/android/ui/common/state/channel/draft/DraftChannelViewState { +} + +public final class io/getstream/chat/android/ui/common/state/channel/draft/DraftChannelViewState$Content : io/getstream/chat/android/ui/common/state/channel/draft/DraftChannelViewState { + public static final field $stable I + public fun (Lio/getstream/chat/android/models/Channel;)V + public final fun component1 ()Lio/getstream/chat/android/models/Channel; + public final fun copy (Lio/getstream/chat/android/models/Channel;)Lio/getstream/chat/android/ui/common/state/channel/draft/DraftChannelViewState$Content; + public static synthetic fun copy$default (Lio/getstream/chat/android/ui/common/state/channel/draft/DraftChannelViewState$Content;Lio/getstream/chat/android/models/Channel;ILjava/lang/Object;)Lio/getstream/chat/android/ui/common/state/channel/draft/DraftChannelViewState$Content; + public fun equals (Ljava/lang/Object;)Z + public final fun getChannel ()Lio/getstream/chat/android/models/Channel; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class io/getstream/chat/android/ui/common/state/channel/draft/DraftChannelViewState$Loading : io/getstream/chat/android/ui/common/state/channel/draft/DraftChannelViewState { + public static final field $stable I + public static final field INSTANCE Lio/getstream/chat/android/ui/common/state/channel/draft/DraftChannelViewState$Loading; + public fun equals (Ljava/lang/Object;)Z + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + public abstract interface class io/getstream/chat/android/ui/common/state/channel/info/ChannelInfoMemberViewState { } diff --git a/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/feature/channel/draft/DraftChannelViewAction.kt b/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/feature/channel/draft/DraftChannelViewAction.kt new file mode 100644 index 00000000000..d8bc1698f9e --- /dev/null +++ b/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/feature/channel/draft/DraftChannelViewAction.kt @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2014-2025 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.draft + +import io.getstream.chat.android.core.ExperimentalStreamChatApi + +/** + * Represents actions that can be performed in the draft channel view. + */ +@ExperimentalStreamChatApi +public sealed interface DraftChannelViewAction { + + /** + * Represents the action of sending a message in the draft channel. + */ + public data object MessageSent : DraftChannelViewAction +} diff --git a/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/feature/channel/draft/DraftChannelViewController.kt b/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/feature/channel/draft/DraftChannelViewController.kt new file mode 100644 index 00000000000..e852e11cfe1 --- /dev/null +++ b/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/feature/channel/draft/DraftChannelViewController.kt @@ -0,0 +1,128 @@ +/* + * Copyright (c) 2014-2025 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.draft + +import io.getstream.chat.android.client.ChatClient +import io.getstream.chat.android.client.query.CreateChannelParams +import io.getstream.chat.android.core.internal.InternalStreamChatApi +import io.getstream.chat.android.models.Channel +import io.getstream.chat.android.models.MemberData +import io.getstream.chat.android.ui.common.state.channel.draft.DraftChannelViewState +import io.getstream.log.taggedLogger +import io.getstream.result.Error +import io.getstream.result.onSuccessSuspend +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch + +/** + * Controller responsible for managing the state and events related to the draft channel. + */ +@InternalStreamChatApi +public class DraftChannelViewController( + private val memberIds: List, + private val scope: CoroutineScope, + private val chatClient: ChatClient = ChatClient.instance(), +) { + private val logger by taggedLogger("Chat:NewDirectChannelViewController") + + private lateinit var cid: String + + /** + * A [StateFlow] representing the current state of the draft channel. + */ + public val state: StateFlow = + flow { createDraftChannel(::emit) } + .map { channel -> + cid = channel.cid + DraftChannelViewState.Content(channel = channel) + }.stateIn( + scope = scope, + started = SharingStarted.WhileSubscribed(STOP_TIMEOUT_IN_MILLIS), + initialValue = DraftChannelViewState.Loading, + ) + + private val _events = MutableSharedFlow(extraBufferCapacity = 1) + + /** + * A [SharedFlow] that emits one-shot events related to the draft channel, such as navigation events. + */ + public val events: SharedFlow = _events.asSharedFlow() + + /** + * Handles actions related to the draft channel view. + * + * @param action The [DraftChannelViewAction] representing the action to be handled. + */ + public fun onViewAction(action: DraftChannelViewAction) { + logger.d { "[onViewAction] action: $action" } + when (action) { + DraftChannelViewAction.MessageSent -> updateChannel() + } + } + + private suspend fun createDraftChannel(onSuccess: suspend (channel: Channel) -> Unit = {}) { + logger.d { "[createDraftChannel] memberIds: $memberIds" } + + val onError: (Error) -> Unit = { error -> + logger.e { "[createDraftChannel] error: $error" } + _events.tryEmit(DraftChannelViewEvent.DraftChannelError) + } + + runCatching { + requireNotNull(chatClient.getCurrentUser()?.id) { "User not connected" } + }.onSuccess { currentUserId -> + chatClient.createChannel( + channelType = "messaging", + channelId = "", + params = CreateChannelParams( + members = (memberIds + currentUserId).map(::MemberData), + extraData = mapOf("draft" to true), + ), + ).await() + .onSuccessSuspend(onSuccess) + .onError(onError) + }.onFailure { cause -> + onError(Error.ThrowableError(message = cause.message.orEmpty(), cause = cause)) + } + } + + private fun updateChannel() { + logger.d { "[updateChannel] cid: $cid" } + scope.launch { + chatClient.channel(cid) + .update(message = null, extraData = mapOf("draft" to false)) + .await() + .onSuccess { + _events.tryEmit(DraftChannelViewEvent.NavigateToChannel(cid)) + } + .onError { + logger.e { "[updateChannel] Failed to update channel: $cid" } + _events.tryEmit(DraftChannelViewEvent.DraftChannelError) + } + } + } +} + +private const val STOP_TIMEOUT_IN_MILLIS = 5_000L diff --git a/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/feature/channel/draft/DraftChannelViewEvent.kt b/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/feature/channel/draft/DraftChannelViewEvent.kt new file mode 100644 index 00000000000..9464b1b5d7a --- /dev/null +++ b/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/feature/channel/draft/DraftChannelViewEvent.kt @@ -0,0 +1,38 @@ +/* + * Copyright (c) 2014-2025 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.draft + +import io.getstream.chat.android.core.ExperimentalStreamChatApi + +/** + * Represents side-effect events related to draft channel actions. + */ +@ExperimentalStreamChatApi +public sealed interface DraftChannelViewEvent { + + /** + * Indicates an event to navigate to a specific channel. + * + * @param cid The full channel identifier (e.g., "messaging:123"). + */ + public data class NavigateToChannel(val cid: String) : DraftChannelViewEvent + + /** + * Indicates an error when trying to create a draft channel. + */ + public data object DraftChannelError : DraftChannelViewEvent +} diff --git a/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/feature/channel/info/ChannelInfoMemberViewController.kt b/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/feature/channel/info/ChannelInfoMemberViewController.kt index e51ffcd7320..fb75d23c5ad 100644 --- a/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/feature/channel/info/ChannelInfoMemberViewController.kt +++ b/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/feature/channel/info/ChannelInfoMemberViewController.kt @@ -19,31 +19,37 @@ 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.QueryChannelsRequest import io.getstream.chat.android.client.channel.state.ChannelState import io.getstream.chat.android.core.ExperimentalStreamChatApi 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.Filters import io.getstream.chat.android.models.Member +import io.getstream.chat.android.models.querysort.QuerySortByField import io.getstream.chat.android.state.extensions.watchChannelAsState import io.getstream.chat.android.ui.common.state.channel.info.ChannelInfoMemberViewState import io.getstream.log.taggedLogger +import io.getstream.result.onErrorSuspend +import io.getstream.result.onSuccessSuspend import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.SharingStarted.Companion.WhileSubscribed import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asSharedFlow -import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.flatMapLatest -import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.mapNotNull import kotlinx.coroutines.flow.onEach -import kotlinx.coroutines.flow.update +import kotlinx.coroutines.flow.stateIn /** * Controller responsible for managing the state and events related to channel member information. @@ -66,67 +72,82 @@ public class ChannelInfoMemberViewController( ) { private val logger by taggedLogger("Chat:ChannelInfoMemberViewController") - private val _state = MutableStateFlow(ChannelInfoMemberViewState.Loading) - /** * A [StateFlow] representing the current state of the channel info. */ - public val state: StateFlow = _state.asStateFlow() + @OptIn(ExperimentalCoroutinesApi::class) + public val state: StateFlow = + channelState.flatMapLatest { channel -> + combine( + channel.channelData, + channel.members + .mapNotNull { members -> members.firstOrNull { it.getUserId() == memberId } } + .onEach { logger.d { "[onMember] name: ${it.user.name}" } }, + queryDistinctChannel(), + ::ChannelInfoMemberData, + ) + }.map { (channelData, member, distinctChannel) -> + this.member = member + this.distinctCid = distinctChannel?.cid + ChannelInfoMemberViewState.Content( + member = member, + options = buildOptionList( + member = member, + capabilities = channelData.ownCapabilities, + ), + ) + }.stateIn( + scope = scope, + started = WhileSubscribed(STOP_TIMEOUT_IN_MILLIS), + initialValue = ChannelInfoMemberViewState.Loading, + ) private val _events = MutableSharedFlow(extraBufferCapacity = 1) /** - * A [SharedFlow] that emits one-time events related to channel info, such as errors or success events. + * A [SharedFlow] that emits one-shot events related to channel info, such as errors or success events. */ public val events: SharedFlow = _events.asSharedFlow() private lateinit var member: Member - - init { - @Suppress("OPT_IN_USAGE") - channelState - .flatMapLatest { channel -> - logger.d { "[onChannelState]" } - combine( - channel.channelData.onEach { - logger.d { - "[onChannelData] cid: ${it.cid}, name: ${it.name}, capabilities: ${it.ownCapabilities}" + private var distinctCid: String? = null + + private fun queryDistinctChannel(): Flow = + flow { + chatClient.getCurrentUser()?.id?.let { currentUserId -> + logger.d { "[queryDistinctChannel] currentUserId: $currentUserId, memberId: $memberId" } + chatClient.queryChannels( + request = QueryChannelsRequest( + filter = Filters.and( + Filters.eq("type", "messaging"), + Filters.distinct(listOf(memberId, currentUserId)), + ), + querySort = QuerySortByField.descByName("last_updated"), + messageLimit = 0, + limit = 1, + ), + ).await() + .onSuccessSuspend { channels -> + if (channels.isEmpty()) { + logger.w { "[queryDistinctChannel] No distinct channel found of member: $memberId" } + emit(null) + } else { + val channel = channels.first() + logger.d { "[queryDistinctChannel] Found distinct channel: ${channel.cid}" } + emit(channel) } - }, - channel.members - .mapNotNull { members -> members.firstOrNull { it.getUserId() == memberId } } - .onEach { logger.d { "[onMember] name: ${it.user.name}" } }, - ::ChannelInfoMemberData, - ) - } - .distinctUntilChanged() - .onEach { (channelData, member) -> - onChannelInfoData(channelData, member) + } + .onErrorSuspend { + logger.e { "[queryDistinctChannel] Error querying distinct channel of member: $memberId" } + emit(null) + } } - .launchIn(scope) - } - - private fun onChannelInfoData( - channelData: ChannelData, - member: Member, - ) { - this.member = member - - _state.update { - ChannelInfoMemberViewState.Content( - member = member, - options = buildOptionList( - member = member, - capabilities = channelData.ownCapabilities, - ), - ) } - } /** * Handles actions related to channel member information view. * - * @param action The [ChannelInfoMemberViewAction] representing the action to be performed. + * @param action The [ChannelInfoMemberViewAction] representing the action to be handled. */ public fun onViewAction( action: ChannelInfoMemberViewAction, @@ -134,8 +155,7 @@ public class ChannelInfoMemberViewController( logger.d { "[onViewAction] action: $action" } when (action) { is ChannelInfoMemberViewAction.MessageMemberClick -> - // https://linear.app/stream/issue/AND-567/compose-navigate-to-messages-from-the-member-modal-sheet-of-channel - _events.tryEmit(ChannelInfoMemberViewEvent.MessageMember(channelId = "")) + _events.tryEmit(ChannelInfoMemberViewEvent.MessageMember(memberId, distinctCid)) is ChannelInfoMemberViewAction.BanMemberClick -> _events.tryEmit(ChannelInfoMemberViewEvent.BanMember(member)) @@ -149,14 +169,16 @@ public class ChannelInfoMemberViewController( } } +private const val STOP_TIMEOUT_IN_MILLIS = 5_000L + private data class ChannelInfoMemberData( val channelData: ChannelData, val member: Member, + val distinctChannel: Channel?, ) private fun buildOptionList(member: Member, capabilities: Set) = buildList { - // https://linear.app/stream/issue/AND-567/compose-navigate-to-messages-from-the-member-modal-sheet-of-channel - // add(ChannelInfoMemberViewState.Content.Option.MessageMember(member = member)) + add(ChannelInfoMemberViewState.Content.Option.MessageMember(member = member)) if (capabilities.contains(ChannelCapabilities.BAN_CHANNEL_MEMBERS)) { if (member.banned) { add(ChannelInfoMemberViewState.Content.Option.UnbanMember(member = member)) diff --git a/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/feature/channel/info/ChannelInfoMemberViewEvent.kt b/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/feature/channel/info/ChannelInfoMemberViewEvent.kt index f709982e695..99b4f8db8ce 100644 --- a/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/feature/channel/info/ChannelInfoMemberViewEvent.kt +++ b/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/feature/channel/info/ChannelInfoMemberViewEvent.kt @@ -28,9 +28,10 @@ public sealed interface ChannelInfoMemberViewEvent { /** * Indicates an event to proceed with messaging a member. * - * @param channelId The ID of the channel to navigate to. + * @param memberId The ID of the member to message. + * @param distinctCid The full distinct channel ID, if any. */ - public data class MessageMember(val channelId: String) : ChannelInfoMemberViewEvent + public data class MessageMember(val memberId: String, val distinctCid: String?) : ChannelInfoMemberViewEvent /** * Indicates an event to proceed with banning a member. 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 4688d05c903..94c40ba7f45 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 @@ -90,7 +90,7 @@ public class ChannelInfoViewController( private val _events = MutableSharedFlow(extraBufferCapacity = 1) /** - * A [SharedFlow] that emits one-time events related to channel info, such as errors or success events. + * A [SharedFlow] that emits one-shot events related to channel info, such as errors or success events. */ public val events: SharedFlow = _events.asSharedFlow() @@ -159,7 +159,7 @@ public class ChannelInfoViewController( /** * Handles actions related to channel information view. * - * @param action The [ChannelInfoViewAction] representing the action to be performed. + * @param action The [ChannelInfoViewAction] representing the action to be handled. */ public fun onViewAction( action: ChannelInfoViewAction, @@ -194,8 +194,11 @@ public class ChannelInfoViewController( public fun onMemberViewEvent(event: ChannelInfoMemberViewEvent) { logger.d { "[onMemberViewEvent] event: $event" } when (event) { - // https://linear.app/stream/issue/AND-567/compose-navigate-to-messages-from-the-member-modal-sheet-of-channel - is ChannelInfoMemberViewEvent.MessageMember -> Unit + is ChannelInfoMemberViewEvent.MessageMember -> if (event.distinctCid != null) { + _events.tryEmit(ChannelInfoViewEvent.NavigateToChannel(event.distinctCid)) + } else { + _events.tryEmit(ChannelInfoViewEvent.NavigateToDraftChannel(event.memberId)) + } is ChannelInfoMemberViewEvent.BanMember -> _events.tryEmit(ChannelInfoViewEvent.BanMemberModal(event.member)) diff --git a/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/feature/channel/info/ChannelInfoViewEvent.kt b/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/feature/channel/info/ChannelInfoViewEvent.kt index 9d2be092217..1ae2d2a93d7 100644 --- a/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/feature/channel/info/ChannelInfoViewEvent.kt +++ b/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/feature/channel/info/ChannelInfoViewEvent.kt @@ -143,6 +143,20 @@ public sealed interface ChannelInfoViewEvent { */ public data object NavigateToPinnedMessages : Navigation(reason = null) + /** + * Indicates an event to navigate to the channel with the specified [cid]. + * + * @param cid The full channel ID of the channel to navigate to. + */ + public data class NavigateToChannel(val cid: String) : Navigation(reason = null) + + /** + * Indicates an event to navigate to draft a channel with the specified [memberId]. + * + * @param memberId The ID of the member to whom the draft channel belongs. + */ + public data class NavigateToDraftChannel(val memberId: String) : Navigation(reason = null) + /** * Represents error events occurred while performing an action. */ diff --git a/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/state/channel/draft/DraftChannelViewState.kt b/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/state/channel/draft/DraftChannelViewState.kt new file mode 100644 index 00000000000..1d7ebf69c75 --- /dev/null +++ b/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/state/channel/draft/DraftChannelViewState.kt @@ -0,0 +1,42 @@ +/* + * Copyright (c) 2014-2025 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.draft + +import io.getstream.chat.android.core.ExperimentalStreamChatApi +import io.getstream.chat.android.models.Channel + +/** + * Represents the state of the draft channel in the UI. + * + * This interface defines the possible states of the draft channel view, + * including loading and content states. + */ +@ExperimentalStreamChatApi +public sealed interface DraftChannelViewState { + + /** + * Represents the loading state of the draft channel. + */ + public data object Loading : DraftChannelViewState + + /** + * Represents the content state of the draft channel. + * + * @param channel The [Channel] object representing the direct channel being previewed. + */ + public data class Content(val channel: Channel) : DraftChannelViewState +} diff --git a/stream-chat-android-ui-common/src/test/kotlin/io/getstream/chat/android/ui/common/feature/channel/draft/DraftChannelViewControllerTest.kt b/stream-chat-android-ui-common/src/test/kotlin/io/getstream/chat/android/ui/common/feature/channel/draft/DraftChannelViewControllerTest.kt new file mode 100644 index 00000000000..38e4542eeab --- /dev/null +++ b/stream-chat-android-ui-common/src/test/kotlin/io/getstream/chat/android/ui/common/feature/channel/draft/DraftChannelViewControllerTest.kt @@ -0,0 +1,224 @@ +/* + * Copyright (c) 2014-2025 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(ExperimentalStreamChatApi::class) + +package io.getstream.chat.android.ui.common.feature.channel.draft + +import app.cash.turbine.test +import io.getstream.chat.android.client.ChatClient +import io.getstream.chat.android.client.channel.ChannelClient +import io.getstream.chat.android.client.query.CreateChannelParams +import io.getstream.chat.android.core.ExperimentalStreamChatApi +import io.getstream.chat.android.models.Channel +import io.getstream.chat.android.models.MemberData +import io.getstream.chat.android.models.User +import io.getstream.chat.android.positiveRandomInt +import io.getstream.chat.android.randomChannel +import io.getstream.chat.android.randomGenericError +import io.getstream.chat.android.randomString +import io.getstream.chat.android.randomUser +import io.getstream.chat.android.test.asCall +import io.getstream.chat.android.ui.common.state.channel.draft.DraftChannelViewState +import io.getstream.result.Error +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test +import org.mockito.kotlin.doAnswer +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever + +internal class DraftChannelViewControllerTest { + + @Test + fun `initial state`() = runTest { + val sut = Fixture().get(backgroundScope) + + assertEquals(DraftChannelViewState.Loading, sut.state.value) + } + + @Test + fun `create draft channel with no connected user`() = runTest { + val memberIds = randomMemberIds() + val fixture = Fixture().givenCreateDraftChannel(memberIds) + val sut = fixture.get(backgroundScope) + + sut.state.test { + skipItems(1) // Skip initial state + + sut.events.test { + assertEquals( + DraftChannelViewEvent.DraftChannelError, + awaitItem(), + ) + } + } + } + + @Test + fun `create draft channel success`() = runTest { + val memberIds = randomMemberIds() + val currentUser = randomUser() + val channel = randomChannel() + val fixture = Fixture().givenCreateDraftChannel(memberIds, currentUser, channel) + val sut = fixture.get(backgroundScope) + + sut.state.test { + skipItems(1) // Skip initial state + + assertEquals( + DraftChannelViewState.Content(channel), + awaitItem(), + ) + } + launch { fixture.verifyDraftChannelCreated(memberIds, currentUser) } + } + + @Test + fun `create draft channel error`() = runTest { + val memberIds = randomMemberIds() + val currentUser = randomUser() + val channel = randomChannel() + val fixture = Fixture().givenCreateDraftChannel(memberIds, currentUser, channel, error = randomGenericError()) + val sut = fixture.get(backgroundScope) + + sut.state.test { + skipItems(1) // Skip initial state + + sut.events.test { + assertEquals( + DraftChannelViewEvent.DraftChannelError, + awaitItem(), + ) + } + } + + launch { fixture.verifyDraftChannelCreated(memberIds, currentUser) } + } + + @Test + fun `message sent success`() = runTest { + val channel = randomChannel() + val currentUser = randomUser() + val fixture = Fixture() + .givenCreateDraftChannel(memberIds = randomMemberIds(), currentUser, channel) + .givenUpdateChannel(channel) + val sut = fixture.get(backgroundScope) + + sut.state.test { + skipItems(2) // Skip initial states + + sut.events.test { + sut.onViewAction(DraftChannelViewAction.MessageSent) + + assertEquals( + DraftChannelViewEvent.NavigateToChannel(channel.cid), + awaitItem(), + ) + } + } + + launch { fixture.verifyChannelUpdated(channel) } + } + + @Test + fun `message sent error`() = runTest { + val channel = randomChannel() + val currentUser = randomUser() + val fixture = Fixture() + .givenCreateDraftChannel(memberIds = randomMemberIds(), currentUser, channel) + .givenUpdateChannel(channel, error = randomGenericError()) + val sut = fixture.get(backgroundScope) + + sut.state.test { + skipItems(2) // Skip initial states + + sut.events.test { + sut.onViewAction(DraftChannelViewAction.MessageSent) + + assertEquals( + DraftChannelViewEvent.DraftChannelError, + awaitItem(), + ) + } + } + } +} + +private class Fixture { + private val chatClient: ChatClient = mock() + private val channelClient: ChannelClient = mock() + private var memberIds: List = randomMemberIds() + + fun givenCreateDraftChannel( + memberIds: List, + currentUser: User? = null, + channel: Channel? = null, + error: Error? = null, + ) = apply { + this.memberIds = memberIds + if (currentUser != null) { + whenever(chatClient.getCurrentUser()) doReturn currentUser + } + whenever( + chatClient.createChannel( + channelType = "messaging", + channelId = "", + params = CreateChannelParams( + members = (memberIds + listOfNotNull(currentUser?.id)).map(::MemberData), + extraData = mapOf("draft" to true), + ), + ), + ) doAnswer { + error?.asCall() ?: channel?.asCall() + } + } + + fun givenUpdateChannel(channel: Channel, error: Error? = null) = apply { + whenever(chatClient.channel(channel.cid)) doReturn channelClient + whenever(channelClient.update(message = null, extraData = mapOf("draft" to false))) doAnswer { + error?.asCall() ?: channel.asCall() + } + } + + fun verifyDraftChannelCreated(memberIds: List, currentUser: User) = apply { + verify(chatClient).createChannel( + channelType = "messaging", + channelId = "", + params = CreateChannelParams( + members = (memberIds + currentUser.id).map(::MemberData), + extraData = mapOf("draft" to true), + ), + ) + } + + fun verifyChannelUpdated(channel: Channel) = apply { + verify(chatClient).channel(channel.cid) + verify(channelClient).update(message = null, extraData = mapOf("draft" to false)) + } + + fun get(scope: CoroutineScope) = DraftChannelViewController( + memberIds = memberIds, + scope = scope, + chatClient = chatClient, + ) +} + +private fun randomMemberIds() = List(size = positiveRandomInt(10), init = ::randomString) diff --git a/stream-chat-android-ui-common/src/test/kotlin/io/getstream/chat/android/ui/common/feature/channel/info/ChannelInfoMemberViewControllerTest.kt b/stream-chat-android-ui-common/src/test/kotlin/io/getstream/chat/android/ui/common/feature/channel/info/ChannelInfoMemberViewControllerTest.kt index 6c865b6041b..04f8f6bc11b 100644 --- a/stream-chat-android-ui-common/src/test/kotlin/io/getstream/chat/android/ui/common/feature/channel/info/ChannelInfoMemberViewControllerTest.kt +++ b/stream-chat-android-ui-common/src/test/kotlin/io/getstream/chat/android/ui/common/feature/channel/info/ChannelInfoMemberViewControllerTest.kt @@ -20,25 +20,33 @@ 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.api.models.QueryChannelsRequest import io.getstream.chat.android.client.channel.state.ChannelState import io.getstream.chat.android.core.ExperimentalStreamChatApi 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.Filters import io.getstream.chat.android.models.Member +import io.getstream.chat.android.models.User +import io.getstream.chat.android.models.querysort.QuerySortByField import io.getstream.chat.android.models.toChannelData import io.getstream.chat.android.randomCID import io.getstream.chat.android.randomChannel import io.getstream.chat.android.randomMember import io.getstream.chat.android.randomString +import io.getstream.chat.android.randomUser +import io.getstream.chat.android.test.asCall import io.getstream.chat.android.ui.common.state.channel.info.ChannelInfoMemberViewState 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.Test +import org.mockito.kotlin.any import org.mockito.kotlin.doReturn import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever internal class ChannelInfoMemberViewControllerTest { @@ -56,7 +64,7 @@ internal class ChannelInfoMemberViewControllerTest { ownCapabilities = emptySet(), members = listOf(member), ) - val fixture = Fixture().given(channel = channel, memberId = member.getUserId()) + val fixture = Fixture().given(channel, memberId = member.getUserId()) val sut = fixture.get(backgroundScope) sut.state.test { @@ -65,7 +73,9 @@ internal class ChannelInfoMemberViewControllerTest { assertEquals( ChannelInfoMemberViewState.Content( member = member, - options = emptyList(), + options = listOf( + ChannelInfoMemberViewState.Content.Option.MessageMember(member), + ), ), awaitItem(), ) @@ -82,6 +92,7 @@ internal class ChannelInfoMemberViewControllerTest { ChannelInfoMemberViewState.Content( member = member, options = listOf( + ChannelInfoMemberViewState.Content.Option.MessageMember(member), ChannelInfoMemberViewState.Content.Option.BanMember(member), ChannelInfoMemberViewState.Content.Option.RemoveMember(member), ), @@ -97,6 +108,7 @@ internal class ChannelInfoMemberViewControllerTest { ChannelInfoMemberViewState.Content( member = member, options = listOf( + ChannelInfoMemberViewState.Content.Option.MessageMember(member), ChannelInfoMemberViewState.Content.Option.UnbanMember(member), ChannelInfoMemberViewState.Content.Option.RemoveMember(member), ), @@ -107,8 +119,9 @@ internal class ChannelInfoMemberViewControllerTest { } @Test - fun `member message click`() = runTest { - val fixture = Fixture() + fun `message member click with no distinct channel`() = runTest { + val member = randomMember() + val fixture = Fixture().given(memberId = member.getUserId()) val sut = fixture.get(backgroundScope) sut.state.test { @@ -117,8 +130,44 @@ internal class ChannelInfoMemberViewControllerTest { sut.events.test { sut.onViewAction(ChannelInfoMemberViewAction.MessageMemberClick) - // https://linear.app/stream/issue/AND-567/compose-navigate-to-messages-from-the-member-modal-sheet-of-channel - assertEquals(ChannelInfoMemberViewEvent.MessageMember(channelId = ""), awaitItem()) + assertEquals( + ChannelInfoMemberViewEvent.MessageMember( + memberId = member.getUserId(), + distinctCid = null, + ), + awaitItem(), + ) + } + } + } + + @Test + fun `message member click with distinct channel`() = runTest { + val member = randomMember() + val distinctChannel = randomChannel() + val currentUser = randomUser() + val fixture = Fixture() + .given( + channel = randomChannel(members = listOf(member)), + memberId = member.getUserId(), + distinctChannel = distinctChannel, + currentUser = currentUser, + ) + val sut = fixture.get(backgroundScope) + + sut.state.test { + skipItems(2) // Skip initial states + + sut.events.test { + sut.onViewAction(ChannelInfoMemberViewAction.MessageMemberClick) + + assertEquals( + ChannelInfoMemberViewEvent.MessageMember( + memberId = member.getUserId(), + distinctCid = distinctChannel.cid, + ), + awaitItem(), + ) } } } @@ -184,15 +233,43 @@ internal class ChannelInfoMemberViewControllerTest { on { channelData } doReturn channelData on { members } doReturn channelMembers } - private val chatClient: ChatClient = mock() + private val chatClient: ChatClient = mock { + on { getCurrentUser() } doReturn randomUser() + on { queryChannels(any()) } doReturn emptyList().asCall() + } private var memberId: String = randomString() - fun given(channel: Channel, memberId: String? = null) = apply { + fun given( + channel: Channel? = null, + memberId: String? = null, + distinctChannel: Channel? = null, + currentUser: User? = null, + ) = apply { + if (channel != null) { + channelData.value = channel.toChannelData() + channelMembers.value = channel.members + } if (memberId != null) { this.memberId = memberId } - channelData.value = channel.toChannelData() - channelMembers.value = channel.members + if (distinctChannel != null) { + whenever( + chatClient.queryChannels( + request = QueryChannelsRequest( + filter = Filters.and( + Filters.eq("type", "messaging"), + Filters.distinct(listOfNotNull(memberId, currentUser?.id)), + ), + querySort = QuerySortByField.descByName("last_updated"), + messageLimit = 0, + limit = 1, + ), + ), + ) doReturn listOf(distinctChannel).asCall() + } + if (currentUser != null) { + whenever(chatClient.getCurrentUser()) doReturn currentUser + } } fun get(scope: CoroutineScope) = ChannelInfoMemberViewController( diff --git a/stream-chat-android-ui-common/src/test/kotlin/io/getstream/chat/android/ui/common/feature/channel/info/ChannelInfoViewControllerTest.kt b/stream-chat-android-ui-common/src/test/kotlin/io/getstream/chat/android/ui/common/feature/channel/info/ChannelInfoViewControllerTest.kt index 751b00123e9..bd479b6355e 100644 --- a/stream-chat-android-ui-common/src/test/kotlin/io/getstream/chat/android/ui/common/feature/channel/info/ChannelInfoViewControllerTest.kt +++ b/stream-chat-android-ui-common/src/test/kotlin/io/getstream/chat/android/ui/common/feature/channel/info/ChannelInfoViewControllerTest.kt @@ -36,6 +36,7 @@ import io.getstream.chat.android.randomGenericError import io.getstream.chat.android.randomMember import io.getstream.chat.android.randomMembers import io.getstream.chat.android.randomMessage +import io.getstream.chat.android.randomString import io.getstream.chat.android.randomUser import io.getstream.chat.android.test.asCall import io.getstream.chat.android.ui.common.feature.channel.info.ChannelInfoViewEvent.Navigation @@ -968,6 +969,51 @@ internal class ChannelInfoViewControllerTest { } } + @Test + fun `message member with distinct channel`() = runTest { + val memberId = randomString() + val cid = randomCID() + val fixture = Fixture() + val sut = fixture.get(backgroundScope) + + sut.state.test { + skipItems(1) // Skip initial state + + sut.events.test { + sut.onMemberViewEvent(ChannelInfoMemberViewEvent.MessageMember(memberId, cid)) + + assertEquals( + ChannelInfoViewEvent.NavigateToChannel(cid), + awaitItem(), + ) + } + } + + launch { fixture.verifyNoMoreInteractions() } + } + + @Test + fun `message member with no distinct channel`() = runTest { + val memberId = randomString() + val fixture = Fixture() + val sut = fixture.get(backgroundScope) + + sut.state.test { + skipItems(1) // Skip initial state + + sut.events.test { + sut.onMemberViewEvent(ChannelInfoMemberViewEvent.MessageMember(memberId, distinctCid = null)) + + assertEquals( + ChannelInfoViewEvent.NavigateToDraftChannel(memberId), + awaitItem(), + ) + } + } + + launch { fixture.verifyNoMoreInteractions() } + } + @Test fun `ban member modal`() = runTest { val member = randomMember() @@ -1242,15 +1288,13 @@ private class Fixture { systemMessage = systemMessage, ), ) doAnswer { - error?.asCall() - ?: mock().asCall() + error?.asCall() ?: mock().asCall() } } fun givenDeleteChannel(error: Error? = null) = apply { whenever(channelClient.delete()) doAnswer { - error?.asCall() - ?: mock().asCall() + error?.asCall() ?: mock().asCall() } } @@ -1262,8 +1306,7 @@ private class Fixture { timeout = timeout, ), ) doAnswer { - error?.asCall() - ?: Unit.asCall() + error?.asCall() ?: Unit.asCall() } }